Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e6af30e
feat(skills): install catalog groups from Settings
BastiHu Sep 21, 2026
80c8e82
feat(skills): link standalone skills across providers
BastiHu Sep 24, 2026
f2630ec
fix(skills): clarify link, unlink, and delete actions
BastiHu Sep 24, 2026
fbeae48
fix(skills): explain how to resolve catalog link conflicts
BastiHu Sep 24, 2026
d128c3f
fix(skills): isolate catalog ownership and provider refresh
BastiHu Sep 24, 2026
c71cb38
feat(skills): inspect provider skills in Settings
BastiHu Sep 24, 2026
c38d2c5
feat(skills): replace conflicting catalog links with confirmation
BastiHu Sep 24, 2026
8e22085
chore(skills): merge latest j5/main into catalog branch
BastiHu Sep 24, 2026
1370594
chore(skills): merge updated catalog branch into inventory
BastiHu Sep 24, 2026
794ff07
chore(skills): merge updated inventory branch into linking
BastiHu Sep 24, 2026
7858a27
fix(skills): align settings with other pages
BastiHu Sep 24, 2026
5dda1e7
fix(skills): carry settings layout through linking stack
BastiHu Sep 24, 2026
f26fe6f
fix(skills): reject catalog URLs with embedded credentials
BastiHu Sep 24, 2026
7f968c8
Merge commit 'f26fe6f22' into codex/skills-review-fix-215-20260924
BastiHu Sep 24, 2026
4ac2dd7
Merge branch 'codex/skills-review-fix-215-20260924' into codex/skills…
BastiHu Sep 24, 2026
9934c89
fix(skills): skip broken roots during link inspection
BastiHu Sep 24, 2026
d924a8b
docs(skills): clarify project link location
BastiHu Sep 24, 2026
e7d8c54
fix(skills): remove unused Claude frontmatter fields
BastiHu Sep 24, 2026
2783520
chore(skills): carry Claude parser cleanup into linking
BastiHu Sep 24, 2026
572993b
fix(skills): retain frontmatter fields used by linking
BastiHu Sep 24, 2026
1fff73d
chore(skills): restack linking on merged inventory
BastiHu Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Treat these upstream areas as off-limits except for those explicit appended case

### Sanctioned appended integration cases

Against candidate upstream pin `62aef8587c0263bed5ae2b26c38d6d138a0676f6`, the inventory has 42 cases: 1–41 plus 15b. Existing numbers remain stable. Line coordinates and landing SHAs below describe historical integrations; use named symbols and tests after upstream movement. Branding has its own inventory in `BRANDING.md`; temporary defect patches are listed separately below.
Against candidate upstream pin `62aef8587c0263bed5ae2b26c38d6d138a0676f6`, the inventory has 43 cases: 1–42 plus 15b. Existing numbers remain stable. Line coordinates and landing SHAs below describe historical integrations; use named symbols and tests after upstream movement. Branding has its own inventory in `BRANDING.md`; temporary defect patches are listed separately below.

1. A1's independent Squadron communication-ledger migration lane: `apps/server/src/persistence/Layers/Sqlite.ts` runs `runJ5CompatibleUpstreamMigrations` before `runJ5A2AMigrations`. Case 31 defines the compatibility wrapper; J5's own migration IDs remain in separate tracking tables and run after upstream migration completion.
2. The authenticated J5 MCP registration seam: `apps/server/src/mcp/McpHttpServer.ts:31-34` imports `J5McpIntegrationLive` and `J5OrchestratorSurfaceRegistrationLive`; `:246-252` selects the J5-owned orchestration allowlist and appends the J5 communication toolkit. The complete upstream `OrchestratorToolkitRegistrationLive` remains compiled at `:229-232` but is intentionally absent from the production merge. Registration stays in `apps/server/src/j5/a2a/mcp/registration.ts`; the combined HTTP/MCP runtime is provided once at case 5's server graph. `t3_thread_read` is intentionally re-declared in J5-owned code with factual thread-state prose because the upstream Tool description contains excluded app-owned/delegation semantics; never replace it with the upstream Tool constant during a rebase. `t3_thread_wait` is deliberately absent from the J5 surface since 2026-09-14 (a turn that blocks on another thread cannot receive the platform notices that queue behind it; see `orchestratorSurface.ts` and the registration tests that assert its absence); never re-add it. `list_participants` exposes every result field in snake_case at every depth, including nested agent `thread_id`, provenance `spawned_by_participant_id` / `source_participant_id`, `placement_parent_id`, and `display_name`; never restore any upstream camelCase directory or provenance field during a rebase. Later J5 verbs extend the fork-owned toolkit without another protected-file registration or runtime provider.
Expand Down Expand Up @@ -256,6 +256,8 @@ migrations after upstream migrations.

41. Skills inventory extends case 40's single Settings page with Catalog and Installed sections sharing an environment picker. `server.ts` adds optional skill provenance fields. `ClaudeSkills.ts` appends plugin discovery from `j5/skills/claudePluginSkills.ts` and exports its existing settings-path/repository-root helpers; keep its upstream directory-name explanation intact. `ClaudeDriver.ts` passes the instance environment, `ClaudeProvider.ts` appends the bounded bundled-skill reload probe, and `CodexProvider.ts` carries optional provenance. The registry appends J5's cached path resolver at snapshot ingestion and invalidates it on explicit refresh; workspace resolution stays in the J5 refresher. On rebase, preserve plugin enablement/precedence, built-in composer visibility, status-only cache reuse, and environment/project selection. Origin and discovery-state helpers live in `packages/shared/src/j5/skillInventory.ts`; contracts remain wire-only.

42. Skills linking extends the J5 contract/RPC exports and the authorization/handler spreads from case 40 with `j5.skills.links.*`. The Settings inventory mounts J5's link preview, inspected unlink batch, confirmed original-folder deletion, and managed-record panel; search continues to use the single Skills route. The shared J5 inventory helpers classify linkable origins and discovery state; catalog and standalone linking reuse J5 root resolution, filesystem operations, and affected-provider refresh. On rebase, preserve fresh preview validation, canonical project containment, the Git exposure warning, ownership identity and forget-only recovery, stale unlink/deletion preview rejection, and release the mutation permit before refreshing providers. Catalog remains user-scoped; standalone links can target an explicitly selected project.

## Pin and upstream advance runbook

Current candidate pin: `62aef8587c0263bed5ae2b26c38d6d138a0676f6`, from `t3code/codex-turn-mapping`. The previous published pin was `b9fa1399cfbacf23f35ba9201af8aebe3f41e807`; its integration is recorded below.
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type * as RpcGroup from "effect/unstable/rpc/RpcGroup";

import { AGENT_PERSONA_RPC_SCOPES } from "../j5/agents/agentPersonaRpc.ts";
import { ARTIFACT_RPC_SCOPES } from "../j5/artifacts/artifactRpc.ts";
import { SKILL_LINK_RPC_SCOPES } from "../j5/skills/skillLinkRpc.ts";
import { SKILL_CATALOG_RPC_SCOPES } from "../j5/skills/skillCatalogRpc.ts";

type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
Expand All @@ -28,6 +29,7 @@ export const RPC_REQUIRED_SCOPES = {
...AGENT_PERSONA_RPC_SCOPES,
...ARTIFACT_RPC_SCOPES,
...SKILL_CATALOG_RPC_SCOPES,
...SKILL_LINK_RPC_SCOPES,
[ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
[ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
Expand Down
186 changes: 186 additions & 0 deletions apps/server/src/j5/skills/skillCatalogInstaller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import * as NodeFSP from "node:fs/promises";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import { ProviderInstanceId, type SkillCatalogReplacement } from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { stringify } from "yaml";
Expand All @@ -22,6 +23,7 @@ import {
type Catalog,
} from "./skillCatalogInstaller.ts";
import { canonicalSkillRoot } from "./skillFileSystem.ts";
import { createManagedSkillLink, listManagedSkillLinks, previewSkillLink } from "./skillLinks.ts";

vi.mock("node:fs/promises", async (original) => ({ ...(await original<typeof NodeFSP>()) }));
const windows = HostProcessPlatform.defaultValue() === "win32";
Expand Down Expand Up @@ -128,6 +130,172 @@ it("rolls back a newly created link if its ownership identity cannot be read", a
expect((await loadState(stateDir)).links).toEqual([]);
});

describe("confirmed catalog replacements", () => {
async function preview(broken = false) {
const old = NodePath.join(root, "old-catalog");
await writeCatalog(old);
for (const dir of dirs()) {
await NodeFSP.mkdir(dir, { recursive: true });
await NodeFSP.symlink(
NodePath.join(old, "skills", "explain"),
NodePath.join(dir, "explain"),
linkType,
);
}
if (broken) await NodeFSP.rm(old, { recursive: true });
const result = await apply(["core"]);
return result.conflicts.map((conflict) => {
expect(conflict.replacement).toBeDefined();
return conflict.replacement!;
});
}
async function replace(
replacements: ReadonlyArray<SkillCatalogReplacement>,
selected = ["core"],
) {
return runApply({
catalog: await loadCatalog(NodePath.join(catalogDir, "catalog.yaml")),
catalogDir,
stateDir,
targets,
state: await loadState(stateDir),
selected,
windows,
replacements,
});
}

it.each([false, true])(
"replaces only confirmed external links, including broken links (%s)",
async (broken) => {
const [chosen, other] = await preview(broken);
const result = await replace([chosen!]);
expect(result).toMatchObject({ installed: 1, removed: 0, failed: [] });
expect(result.conflicts.map((conflict) => conflict.linkPath)).toEqual([other!.linkPath]);
expect(await NodeFSP.realpath(chosen!.linkPath)).toBe(await NodeFSP.realpath(chosen!.target));
expect(await NodeFSP.readlink(other!.linkPath)).toBe(other!.currentTarget);
expect((await loadState(stateDir)).links).toEqual([
{ path: chosen!.linkPath, target: chosen!.target },
]);
if (!broken)
expect(
(await NodeFSP.stat(NodePath.join(chosen!.currentTarget, "SKILL.md"))).isFile(),
).toBe(true);
expect(await apply([])).toMatchObject({ removed: 1 });
expect(await NodeFSP.readlink(other!.linkPath)).toBe(other!.currentTarget);
},
);

it.each(["file", "directory", "target", "identity", "missing"])(
"rejects a link changed to %s before any mutation",
async (change) => {
const requests = await preview();
const chosen = requests[0]!;
const saved = await NodeFSP.readFile(stateFilePath(stateDir), "utf8");
await NodeFSP.rename(chosen.linkPath, chosen.linkPath + ".old");
if (change === "file") await NodeFSP.writeFile(chosen.linkPath, "keep");
if (change === "directory") await NodeFSP.mkdir(chosen.linkPath);
if (change === "target" || change === "identity") {
await NodeFSP.symlink(
change === "identity" ? chosen.currentTarget : catalogDir,
chosen.linkPath,
linkType,
);
}
await expect(replace(requests)).rejects.toThrow("changed since preview");
expect(await NodeFSP.readFile(stateFilePath(stateDir), "utf8")).toBe(saved);
expect(await NodeFSP.readlink(requests[1]!.linkPath)).toBe(requests[1]!.currentTarget);
if (change === "file") expect(await NodeFSP.readFile(chosen.linkPath, "utf8")).toBe("keep");
if (change === "directory")
expect((await NodeFSP.lstat(chosen.linkPath)).isDirectory()).toBe(true);
},
);

it("refuses non-link conflicts and forged or out-of-scope replacements", async () => {
const [chosen] = await preview();
const requests = [
[{ ...chosen!, linkPath: NodePath.join(root, "elsewhere") }],
[{ ...chosen!, target: NodePath.join(root, "other-source") }],
[{ ...chosen!, identity: "stale" }],
[chosen!, chosen!],
];
for (const request of requests)
await expect(replace(request)).rejects.toThrow("changed since preview");
await expect(replace([chosen!], [])).rejects.toThrow("changed since preview");
await NodeFSP.unlink(chosen!.linkPath);
await NodeFSP.mkdir(chosen!.linkPath);
const result = await apply(["core"]);
expect(
result.conflicts.find((conflict) => conflict.linkPath === chosen!.linkPath)?.replacement,
).toBeUndefined();
await expect(replace([chosen!])).rejects.toThrow("changed since preview");
});

it("does not apply unrelated additions or removals during link replacement", async () => {
const [chosen, other] = await preview();
const owned = NodePath.join(await canonicalSkillRoot(dirs()[0]!), "retired");
const oldTarget = NodePath.join(root, "retired-source");
await NodeFSP.symlink(oldTarget, owned, linkType);
const state = await loadState(stateDir);
await saveState(stateDir, {
...state,
links: [...state.links, { path: owned, target: oldTarget }],
});
await NodeFSP.unlink(other!.linkPath);
expect(await replace([chosen!])).toMatchObject({ installed: 1, removed: 0 });
expect(await NodeFSP.readlink(owned)).toBe(oldTarget);
await expect(NodeFSP.lstat(other!.linkPath)).rejects.toMatchObject({ code: "ENOENT" });
expect((await loadState(stateDir)).links).toContainEqual({ path: owned, target: oldTarget });
});

it("rejects a confirmation after another client changes the saved selection", async () => {
const requests = await preview();
await saveState(stateDir, { ...(await loadState(stateDir)), groups: [] });
await expect(replace(requests)).rejects.toThrow("selection changed since preview");
for (const request of requests)
expect(await NodeFSP.readlink(request.linkPath)).toBe(request.currentTarget);
expect((await loadState(stateDir)).groups).toEqual([]);
});

it("checks identity again immediately before replacing a same-target link", async () => {
const [chosen] = await preview();
await NodeFSP.rename(chosen!.linkPath, chosen!.linkPath + ".old");
await NodeFSP.symlink(chosen!.currentTarget, chosen!.linkPath, linkType);
const result = await applyPlan(
{
removals: [],
additions: [
{
skill: "explain",
linkPath: chosen!.linkPath,
target: chosen!.target,
replace: true,
previousTarget: chosen!.currentTarget,
previousIdentity: chosen!.identity,
},
],
},
{ windows },
);
expect(result.applied).toEqual([]);
expect(result.failed[0]?.error).toContain("changed since inspection");
expect(await NodeFSP.readlink(chosen!.linkPath)).toBe(chosen!.currentTarget);
});

it("restores external links and leaves ownership unchanged when state saving fails", async () => {
const requests = await preview(true);
const saved = await loadState(stateDir);
vi.spyOn(NodeFSP, "rename").mockRejectedValueOnce(new Error("save denied"));
await expect(replace(requests)).rejects.toMatchObject({
name: "IncompleteApplyError",
result: { installed: 0, removed: 0 },
});
expect(await loadState(stateDir)).toEqual(saved);
for (const request of requests)
expect(await NodeFSP.readlink(request.linkPath)).toBe(request.currentTarget);
});
});

describe("catalog validation and selection", () => {
it("overlaps source validation reads with a limit of eight", async () => {
const skills = Array.from({ length: 17 }, (_, index) => `skill-${index}`);
Expand Down Expand Up @@ -299,6 +467,24 @@ describe("link ownership", () => {
expect(await NodeFSP.realpath(foreign)).toBe(await NodeFSP.realpath(skill));
});

it("leaves managed links owned by the linker untouched", async () => {
const skill = NodePath.join(catalogDir, "skills", "explain");
const stateDir = NodePath.join(root, "link-state");
const preview = {
...(await previewSkillLink(skill, dirs()[0]!, "claudeAgent")),
sharedWith: [],
};
const request = {
source: { instanceId: ProviderInstanceId.make("claude"), path: skill, name: "explain" },
targetInstanceId: ProviderInstanceId.make("codex"),
scope: "user" as const,
};
await createManagedSkillLink(stateDir, preview, request, windows);
await apply(["core"]);
await apply([]);
expect((await listManagedSkillLinks(stateDir))[0]?.status).toBe("linked");
});

it("counts a shared physical skills root once and migrates equivalent saved paths", async () => {
const agentRoot = NodePath.join(homeDir, ".agents", "skills");
const claudeRoot = NodePath.join(homeDir, ".claude", "skills");
Expand Down
Loading
Loading