From 03b6b4f41bf4e4d60d2b8393db4572f102637688 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 11:54:45 -0700 Subject: [PATCH 1/4] Mount write tools on every closed director Compose PRODUCT_WRITE_TOOLS onto review, orchestrator, intern, and docs surfaces so lane discipline stays in prompts instead of tool stripping. Closes CL-7014 --- src/agent/codex-tool-proxies.test.ts | 4 +- src/agent/codex-tool-proxies.ts | 4 +- .../directors/brand-reviewer/package.test.ts | 1 + .../directors/bruckheimer/package.test.ts | 1 + src/agent/directors/critique/package.test.ts | 8 +-- src/agent/directors/critique/package.ts | 2 +- src/agent/directors/draper/package.test.ts | 8 +-- src/agent/directors/emil/package.test.ts | 8 +-- src/agent/directors/explore/package.test.ts | 8 +-- src/agent/directors/explore/package.ts | 4 +- src/agent/directors/gaasbot/package.test.ts | 8 +-- src/agent/directors/greybeard/package.test.ts | 8 +-- src/agent/directors/intern/package.test.ts | 7 ++- src/agent/directors/intern/package.ts | 2 +- src/agent/directors/neckbeard/package.test.ts | 8 +-- src/agent/directors/plan/package.test.ts | 8 +-- src/agent/directors/registry.test.ts | 26 +++++++--- .../directors/shakespeare/package.test.ts | 1 + src/agent/directors/tester/package.test.ts | 8 +-- src/agent/directors/tester/package.ts | 6 +-- src/agent/directors/testsmith/package.test.ts | 8 +-- src/agent/directors/testsmith/package.ts | 6 +-- src/agent/directors/tool-sets.test.ts | 52 +++++++++++++++++-- src/agent/directors/tool-sets.ts | 49 ++++++++++------- tests/unit/subagent.test.ts | 6 +-- 25 files changed, 159 insertions(+), 92 deletions(-) diff --git a/src/agent/codex-tool-proxies.test.ts b/src/agent/codex-tool-proxies.test.ts index 002c63222..525b9393e 100644 --- a/src/agent/codex-tool-proxies.test.ts +++ b/src/agent/codex-tool-proxies.test.ts @@ -597,8 +597,8 @@ describe("update_plan proxy", () => { }); describe("allowDeleteFromCapabilities", () => { - test("docs allowlist (no delete_file) → false; build → true", () => { - expect(allowDeleteFromCapabilities({ mode: "allow", tools: DOCS_TOOLS })).toBe(false); + test("docs allowlist (includes delete_file) → true; build → true", () => { + expect(allowDeleteFromCapabilities({ mode: "allow", tools: DOCS_TOOLS })).toBe(true); expect(allowDeleteFromCapabilities({ mode: "allow", tools: BUILD_TOOLS })).toBe(true); expect(allowDeleteFromCapabilities(undefined)).toBe(true); expect(allowDeleteFromCapabilities({ mode: "exclude", tools: ["run_shell"] })).toBe(true); diff --git a/src/agent/codex-tool-proxies.ts b/src/agent/codex-tool-proxies.ts index 14716d2a4..56451072f 100644 --- a/src/agent/codex-tool-proxies.ts +++ b/src/agent/codex-tool-proxies.ts @@ -55,8 +55,8 @@ export interface CreateCodexToolProxiesOpts { runManageTasks: CodexRunManageTasks; /** * When false, Delete File and Update+Move refuse without calling `delete_file`. - * Defaults to true (implement / unconstrained). Docs leaves pass false because - * DOCS_TOOLS includes apply_patch but not delete_file. + * Defaults to true (implement / unconstrained). Pass false when the + * director allowlist omits delete_file (docs leaves mount it today). */ allowDelete?: boolean; /** diff --git a/src/agent/directors/brand-reviewer/package.test.ts b/src/agent/directors/brand-reviewer/package.test.ts index a1d1bdc4e..b79dcc51c 100644 --- a/src/agent/directors/brand-reviewer/package.test.ts +++ b/src/agent/directors/brand-reviewer/package.test.ts @@ -25,6 +25,7 @@ describe("brandReviewerPackage", () => { const allow = brandReviewerPackage.tools?.allow ?? []; expect(allow).toContain("write_file"); expect(allow).toContain("edit_file"); + expect(allow).toContain("delete_file"); }); test("systemPrompt mentions DESIGN.md", () => { diff --git a/src/agent/directors/bruckheimer/package.test.ts b/src/agent/directors/bruckheimer/package.test.ts index 7e9d6f4e1..1056d5b85 100644 --- a/src/agent/directors/bruckheimer/package.test.ts +++ b/src/agent/directors/bruckheimer/package.test.ts @@ -26,6 +26,7 @@ describe("bruckheimerPackage", () => { const allow = bruckheimerPackage.tools?.allow ?? []; expect(allow).toContain("write_file"); expect(allow).toContain("edit_file"); + expect(allow).toContain("delete_file"); }); test("modelRole is docs", () => { diff --git a/src/agent/directors/critique/package.test.ts b/src/agent/directors/critique/package.test.ts index 160944919..90efa420a 100644 --- a/src/agent/directors/critique/package.test.ts +++ b/src/agent/directors/critique/package.test.ts @@ -54,14 +54,14 @@ describe("critiquePackage", () => { expect(critiquePackage.spawn.maySpawn).toBe(false); }); - test("tools.allow is review surface without product writes", () => { + test("tools.allow is review surface with product writes", () => { const allow = critiquePackage.tools?.allow ?? []; expect(allow).toContain("read_file"); expect(allow).toContain("read_file"); expect(allow).not.toContain("use_skill"); - expect(allow).not.toContain("write_file"); - expect(allow).not.toContain("edit_file"); - expect(allow).not.toContain("delete_file"); + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(allow).toContain("delete_file"); }); test("modelRole is review", () => { diff --git a/src/agent/directors/critique/package.ts b/src/agent/directors/critique/package.ts index 1a669fc32..9e15a139a 100644 --- a/src/agent/directors/critique/package.ts +++ b/src/agent/directors/critique/package.ts @@ -46,7 +46,7 @@ API contract check (blocking when brief specifies signatures): - Prefer reading tests/callers; a tiny sync call via run_shell that would hang on a Promise is evidence. - Rank these as blocking, not style nits. -Write tools are not mounted. Repro via read/shell only; recommend permanent tests for testsmith/build. +Write tools are mounted with no path lock — do not use them. Repro via read/shell only; recommend permanent tests for testsmith/build. OUT OF LANE → refuse or reclassify under Blockers: - implementing fixes (route to build) diff --git a/src/agent/directors/draper/package.test.ts b/src/agent/directors/draper/package.test.ts index 9f89e23cb..6b22d71b9 100644 --- a/src/agent/directors/draper/package.test.ts +++ b/src/agent/directors/draper/package.test.ts @@ -20,12 +20,12 @@ describe("draperPackage", () => { expect(draperPackage.spawn.maySpawn).toBe(false); }); - test("tools.allow is review surface without product writes", () => { + test("tools.allow is review surface with product writes", () => { const allow = draperPackage.tools?.allow ?? []; expect(allow).toContain("read_file"); - expect(allow).not.toContain("write_file"); - expect(allow).not.toContain("edit_file"); - expect(allow).not.toContain("delete_file"); + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(allow).toContain("delete_file"); }); test("modelRole is review", () => { diff --git a/src/agent/directors/emil/package.test.ts b/src/agent/directors/emil/package.test.ts index 6fef61384..7a050c1a1 100644 --- a/src/agent/directors/emil/package.test.ts +++ b/src/agent/directors/emil/package.test.ts @@ -20,12 +20,12 @@ describe("emilPackage", () => { expect(emilPackage.spawn.maySpawn).toBe(false); }); - test("tools.allow is review surface without product writes", () => { + test("tools.allow is review surface with product writes", () => { const allow = emilPackage.tools?.allow ?? []; expect(allow).toContain("read_file"); - expect(allow).not.toContain("write_file"); - expect(allow).not.toContain("edit_file"); - expect(allow).not.toContain("delete_file"); + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(allow).toContain("delete_file"); }); test("modelRole is review", () => { diff --git a/src/agent/directors/explore/package.test.ts b/src/agent/directors/explore/package.test.ts index 9bf3cb31c..52ddd09fa 100644 --- a/src/agent/directors/explore/package.test.ts +++ b/src/agent/directors/explore/package.test.ts @@ -38,13 +38,13 @@ describe("explorePackage", () => { expect(explorePackage.spawn.maySpawn).toBe(false); }); - test("tools.allow is read-only (no product writes)", () => { + test("tools.allow mounts product writes (lane: no product edits)", () => { const allow = explorePackage.tools?.allow ?? []; expect(allow).toContain("read_file"); expect(allow).toContain("grep"); - expect(allow).not.toContain("write_file"); - expect(allow).not.toContain("edit_file"); - expect(allow).not.toContain("delete_file"); + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(allow).toContain("delete_file"); }); test("modelRole is explore", () => { diff --git a/src/agent/directors/explore/package.ts b/src/agent/directors/explore/package.ts index 3b751fb28..a869de9ca 100644 --- a/src/agent/directors/explore/package.ts +++ b/src/agent/directors/explore/package.ts @@ -1,5 +1,5 @@ import type { DirectorPackage } from "../types.js"; -import { READ_TOOLS } from "../tool-sets.js"; +import { REVIEW_TOOLS } from "../tool-sets.js"; export const explorePackage: DirectorPackage = { id: "explore", @@ -22,7 +22,7 @@ FINISH BIAS: Prefer one thorough pass then report. Expand Findings, change appro FINDINGS SHAPE: Findings must be a scannable map — key paths, symbols, call flow / ownership — not optional prose dump. Cite paths. No drive-by refactors, no feature work, no review severity theater. OUT OF LANE → report Blockers naming the right director: build, plan, critique, greybeard, intern.`, - tools: { allow: READ_TOOLS }, + tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", modelRole: "explore", diff --git a/src/agent/directors/gaasbot/package.test.ts b/src/agent/directors/gaasbot/package.test.ts index 3d4263f1d..f1b47b70b 100644 --- a/src/agent/directors/gaasbot/package.test.ts +++ b/src/agent/directors/gaasbot/package.test.ts @@ -19,12 +19,12 @@ describe("gaasbotPackage", () => { expect(gaasbotPackage.spawn.maySpawn).toBe(false); }); - test("denies product write tools (advice only)", () => { + test("mounts product write tools (lane discipline in prompts)", () => { const allow = gaasbotPackage.tools?.allow ?? []; expect(allow).toContain("read_file"); - expect(allow).not.toContain("write_file"); - expect(allow).not.toContain("edit_file"); - expect(allow).not.toContain("delete_file"); + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(allow).toContain("delete_file"); }); test("modelRole is plan", () => { diff --git a/src/agent/directors/greybeard/package.test.ts b/src/agent/directors/greybeard/package.test.ts index f8b620d8b..de77a07d0 100644 --- a/src/agent/directors/greybeard/package.test.ts +++ b/src/agent/directors/greybeard/package.test.ts @@ -44,13 +44,13 @@ describe("greybeardPackage", () => { expect(greybeardPackage.systemPrompt).toMatch(/never spawn a parallel diagnostic fleet/i); }); - test("tools.allow is orchestrator surface without product writes", () => { + test("tools.allow is orchestrator surface with product writes", () => { const allow = greybeardPackage.tools?.allow ?? []; expect(allow).toContain("task"); expect(allow).toContain("search_agents"); - expect(allow).not.toContain("write_file"); - expect(allow).not.toContain("edit_file"); - expect(allow).not.toContain("delete_file"); + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(allow).toContain("delete_file"); }); test("modelRole is review", () => { diff --git a/src/agent/directors/intern/package.test.ts b/src/agent/directors/intern/package.test.ts index c2921de4d..db899154c 100644 --- a/src/agent/directors/intern/package.test.ts +++ b/src/agent/directors/intern/package.test.ts @@ -17,12 +17,15 @@ describe("internPackage", () => { expect(internPackage.spawn.maySpawn).toBe(false); }); - test("tools.allow is shell-first minimal surface", () => { + test("tools.allow is shell-first with path writes", () => { const allow = internPackage.tools?.allow ?? []; expect(allow).toContain("run_shell"); expect(allow).toContain("read_file"); expect(allow).toContain("list_dir"); - for (const name of ["write_file", "edit_file", "delete_file", "grep", "search_files", "task"]) { + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(allow).toContain("delete_file"); + for (const name of ["grep", "search_files", "task", "apply_patch"]) { expect(allow).not.toContain(name); } }); diff --git a/src/agent/directors/intern/package.ts b/src/agent/directors/intern/package.ts index c247909c2..fa9e7b1ff 100644 --- a/src/agent/directors/intern/package.ts +++ b/src/agent/directors/intern/package.ts @@ -3,7 +3,7 @@ import { INTERN_TOOLS } from "../tool-sets.js"; /** * Mechanical intern leaf (CL-5822). - * Shell/commands only — no judgment, no exploration, no product writes. + * Shell/commands first — no judgment, no exploration; path writes only when the brief requires them. */ export const internPackage: DirectorPackage = { id: "intern", diff --git a/src/agent/directors/neckbeard/package.test.ts b/src/agent/directors/neckbeard/package.test.ts index 20bc1cdce..9a9bf5889 100644 --- a/src/agent/directors/neckbeard/package.test.ts +++ b/src/agent/directors/neckbeard/package.test.ts @@ -25,12 +25,12 @@ describe("neckbeardPackage", () => { expect(neckbeardPackage.spawn.maySpawn).toBe(false); }); - test("denies product write tools", () => { + test("mounts product write tools", () => { const allow = neckbeardPackage.tools?.allow ?? []; expect(allow).toContain("read_file"); - expect(allow).not.toContain("write_file"); - expect(allow).not.toContain("edit_file"); - expect(allow).not.toContain("delete_file"); + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(allow).toContain("delete_file"); }); test("modelRole is review", () => { diff --git a/src/agent/directors/plan/package.test.ts b/src/agent/directors/plan/package.test.ts index 5bd548e46..94993331e 100644 --- a/src/agent/directors/plan/package.test.ts +++ b/src/agent/directors/plan/package.test.ts @@ -19,12 +19,12 @@ describe("planPackage", () => { expect(planPackage.spawn.maySpawn).toBe(false); }); - test("tools.allow is review surface without product writes", () => { + test("tools.allow is review surface with product writes", () => { const allow = planPackage.tools?.allow ?? []; expect(allow).toContain("read_file"); - expect(allow).not.toContain("write_file"); - expect(allow).not.toContain("edit_file"); - expect(allow).not.toContain("delete_file"); + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(allow).toContain("delete_file"); }); test("modelRole is plan", () => { diff --git a/src/agent/directors/registry.test.ts b/src/agent/directors/registry.test.ts index be552635a..c3d2081d3 100644 --- a/src/agent/directors/registry.test.ts +++ b/src/agent/directors/registry.test.ts @@ -97,7 +97,9 @@ describe("director registry", () => { expect(explore.description).toContain("agent id: explore"); expect(explore.capabilities?.mode).toBe("allow"); expect(explore.capabilities?.tools).toContain("read_file"); - expect(explore.capabilities?.tools).not.toContain("write_file"); + expect(explore.capabilities?.tools).toContain("write_file"); + expect(explore.capabilities?.tools).toContain("edit_file"); + expect(explore.capabilities?.tools).toContain("delete_file"); expect(explore.orchestrator).toBe(false); const grey = packageToProfile(DIRECTOR_REGISTRY.greybeard); @@ -123,7 +125,7 @@ describe("director registry", () => { expect(packageToProfile(g).orchestrator).toBe(true); }); - test("review and design leaves deny product write tools", () => { + test("closed directors mount product write tools", () => { for (const id of [ "critique", "greybeard", @@ -135,22 +137,30 @@ describe("director registry", () => { "testsmith", "tester", "gaasbot", + "intern", + "build", + "shakespeare", + "bruckheimer", + "brand-reviewer", + "skywalker", ] as const) { const allow = DIRECTOR_REGISTRY[id].tools?.allow ?? []; - expect(allow).not.toContain("write_file"); - expect(allow).not.toContain("edit_file"); - expect(allow).not.toContain("delete_file"); + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(allow).toContain("delete_file"); } }); - test("build mounts product writes; intern is shell-only; other leaves do not spawn", () => { + test("build mounts product writes + apply_patch; intern mounts writes without apply_patch; other leaves do not spawn", () => { expect(DIRECTOR_REGISTRY.build.tools?.allow).toEqual( expect.arrayContaining(["write_file", "edit_file", "delete_file", "apply_patch"]), ); const internAllow = DIRECTOR_REGISTRY.intern.tools?.allow ?? []; expect(internAllow).toContain("run_shell"); - expect(internAllow).not.toContain("write_file"); - expect(internAllow).not.toContain("edit_file"); + expect(internAllow).toContain("write_file"); + expect(internAllow).toContain("edit_file"); + expect(internAllow).toContain("delete_file"); + expect(internAllow).not.toContain("apply_patch"); for (const id of DIRECTOR_IDS) { if (id === "skywalker" || id === "greybeard") continue; expect(DIRECTOR_REGISTRY[id].spawn.maySpawn).toBe(false); diff --git a/src/agent/directors/shakespeare/package.test.ts b/src/agent/directors/shakespeare/package.test.ts index 552fbcdee..084b32983 100644 --- a/src/agent/directors/shakespeare/package.test.ts +++ b/src/agent/directors/shakespeare/package.test.ts @@ -36,6 +36,7 @@ describe("shakespearePackage", () => { const allow = shakespearePackage.tools?.allow ?? []; expect(allow).toContain("write_file"); expect(allow).toContain("edit_file"); + expect(allow).toContain("delete_file"); }); test("modelRole is docs", () => { diff --git a/src/agent/directors/tester/package.test.ts b/src/agent/directors/tester/package.test.ts index a6bff51e8..f6aba23d6 100644 --- a/src/agent/directors/tester/package.test.ts +++ b/src/agent/directors/tester/package.test.ts @@ -22,13 +22,13 @@ describe("testerPackage", () => { expect(testerPackage.spawn.maySpawn).toBe(false); }); - test("tools.allow is read-only (no product writes)", () => { + test("tools.allow mounts product writes (lane: never fix)", () => { const allow = testerPackage.tools?.allow ?? []; expect(allow).toContain("run_shell"); expect(allow).toContain("read_file"); - expect(allow).not.toContain("write_file"); - expect(allow).not.toContain("edit_file"); - expect(allow).not.toContain("delete_file"); + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(allow).toContain("delete_file"); }); test("modelRole is test", () => { diff --git a/src/agent/directors/tester/package.ts b/src/agent/directors/tester/package.ts index b07de7c55..9735fe3b4 100644 --- a/src/agent/directors/tester/package.ts +++ b/src/agent/directors/tester/package.ts @@ -1,5 +1,5 @@ import type { DirectorPackage } from "../types.js"; -import { READ_TOOLS } from "../tool-sets.js"; +import { REVIEW_TOOLS } from "../tool-sets.js"; /** * Tester: runtime verification specialist — run tests and report; never fix product code. @@ -23,12 +23,12 @@ Workflow: 1. Identify the commands or suites the brief specifies (or project defaults when clear). 2. Run them via shell / harness-allowed tools. 3. Capture exit codes, key failures, and paths. -4. Report honestly — you have no product-mutation tools, so there is no way to patch source to make green. +4. Report honestly — do not patch source to make green; leave product fixes to build. If tests fail: document failures, suspected area, and blockers. Suggest a re-dispatch to build or testsmith when design gaps appear. OUT OF LANE: fixing product code, "just quickly" fixing, redesigning the whole suite as Testsmith's primary job, fleet orchestration.`, - tools: { allow: READ_TOOLS }, + tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", modelRole: "test", diff --git a/src/agent/directors/testsmith/package.test.ts b/src/agent/directors/testsmith/package.test.ts index eedb84ba0..ab37e84c9 100644 --- a/src/agent/directors/testsmith/package.test.ts +++ b/src/agent/directors/testsmith/package.test.ts @@ -21,12 +21,12 @@ describe("testsmithPackage", () => { expect(testsmithPackage.spawn.maySpawn).toBe(false); }); - test("tools.allow is read-only (no product writes)", () => { + test("tools.allow mounts product writes (lane: design only)", () => { const allow = testsmithPackage.tools?.allow ?? []; expect(allow).toContain("read_file"); - expect(allow).not.toContain("write_file"); - expect(allow).not.toContain("edit_file"); - expect(allow).not.toContain("delete_file"); + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(allow).toContain("delete_file"); }); test("modelRole is test", () => { diff --git a/src/agent/directors/testsmith/package.ts b/src/agent/directors/testsmith/package.ts index 3f13c63be..6783f7554 100644 --- a/src/agent/directors/testsmith/package.ts +++ b/src/agent/directors/testsmith/package.ts @@ -1,5 +1,5 @@ import type { DirectorPackage } from "../types.js"; -import { READ_TOOLS } from "../tool-sets.js"; +import { REVIEW_TOOLS } from "../tool-sets.js"; /** * Testsmith: test design specialist — strategy and cases only; never implements product @@ -29,8 +29,8 @@ Design in the report. Prefer: OUT OF LANE: fixing production code, becoming the implementer, running the full verify-and-fix loop, fleet orchestration. -Read and search the codebase to ground the design; you have no product-mutation tools.`, - tools: { allow: READ_TOOLS }, +Read and search the codebase to ground the design; do not mutate product code.`, + tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", modelRole: "test", diff --git a/src/agent/directors/tool-sets.test.ts b/src/agent/directors/tool-sets.test.ts index 4deae1f3f..2e73f12ae 100644 --- a/src/agent/directors/tool-sets.test.ts +++ b/src/agent/directors/tool-sets.test.ts @@ -3,14 +3,31 @@ import { DOCS_TOOLS, BUILD_TOOLS, ORCHESTRATOR_TOOLS, + PRODUCT_WRITE_TOOLS, READ_TOOLS, + REVIEW_TOOLS, + INTERN_TOOLS, SKYWALKER_TOOLS, } from "./tool-sets.js"; +describe("PRODUCT_WRITE_TOOLS", () => { + test("is write_file / edit_file / delete_file", () => { + expect([...PRODUCT_WRITE_TOOLS]).toEqual(["write_file", "edit_file", "delete_file"]); + }); +}); + +describe("READ_TOOLS", () => { + test("stays read-only (no path mutation)", () => { + for (const name of PRODUCT_WRITE_TOOLS) { + expect(READ_TOOLS as readonly string[]).not.toContain(name); + } + }); +}); + describe("DOCS_TOOLS", () => { - test("excludes run_shell and delete_file as envelope policy", () => { + test("excludes run_shell as envelope policy; includes delete_file", () => { expect(DOCS_TOOLS).not.toContain("run_shell"); - expect(DOCS_TOOLS).not.toContain("delete_file"); + expect(DOCS_TOOLS).toContain("delete_file"); }); test("keeps read/search/lsp/web + file writes + apply_patch", () => { @@ -24,6 +41,7 @@ describe("DOCS_TOOLS", () => { "web_search", "write_file", "edit_file", + "delete_file", "apply_patch", ]; for (const tool of expected) { @@ -44,16 +62,34 @@ describe("DOCS_TOOLS", () => { }); describe("SKYWALKER_TOOLS / ORCHESTRATOR_TOOLS", () => { - test("Skywalker mounts product writes; greybeard orchestrator surface does not", () => { - for (const name of ["write_file", "edit_file", "delete_file"] as const) { + test("both mount product writes and task", () => { + for (const name of PRODUCT_WRITE_TOOLS) { expect(SKYWALKER_TOOLS as readonly string[]).toContain(name); - expect(ORCHESTRATOR_TOOLS as readonly string[]).not.toContain(name); + expect(ORCHESTRATOR_TOOLS as readonly string[]).toContain(name); } expect(SKYWALKER_TOOLS).toContain("task"); expect(ORCHESTRATOR_TOOLS).toContain("task"); }); }); +describe("REVIEW_TOOLS / INTERN_TOOLS", () => { + test("compose PRODUCT_WRITE_TOOLS", () => { + for (const name of PRODUCT_WRITE_TOOLS) { + expect(REVIEW_TOOLS as readonly string[]).toContain(name); + expect(INTERN_TOOLS as readonly string[]).toContain(name); + } + }); + + test("intern stays shell-first without grep/search/task", () => { + expect(INTERN_TOOLS).toContain("run_shell"); + expect(INTERN_TOOLS).toContain("read_file"); + expect(INTERN_TOOLS).toContain("list_dir"); + for (const name of ["grep", "search_files", "task"] as const) { + expect(INTERN_TOOLS as readonly string[]).not.toContain(name); + } + }); +}); + describe("BUILD_TOOLS", () => { test("includes apply_patch alongside path mutation tools", () => { expect(BUILD_TOOLS).toContain("write_file"); @@ -66,4 +102,10 @@ describe("BUILD_TOOLS", () => { expect(BUILD_TOOLS).toContain("shell"); expect(BUILD_TOOLS).toContain("update_plan"); }); + + test("review/orchestrator/intern do not mount apply_patch", () => { + for (const surface of [REVIEW_TOOLS, ORCHESTRATOR_TOOLS, INTERN_TOOLS]) { + expect(surface as readonly string[]).not.toContain("apply_patch"); + } + }); }); diff --git a/src/agent/directors/tool-sets.ts b/src/agent/directors/tool-sets.ts index 9bf82dfbe..213d8d7f7 100644 --- a/src/agent/directors/tool-sets.ts +++ b/src/agent/directors/tool-sets.ts @@ -16,6 +16,13 @@ export const READ_TOOLS = [ "web_search", ] as const; +/** + * Path mutation tools shared by closed directors. Codex `apply_patch` stays on + * build/docs only — review/explore/orchestrator/intern mount these path tools + * alone (lane discipline lives in prompts, not the capability filter). + */ +export const PRODUCT_WRITE_TOOLS = ["write_file", "edit_file", "delete_file"] as const; + /** * Build: read + full file mutation. `shell` and `update_plan` are Codex * proxy names (createCodexToolProxies) for `run_shell` / the plan tool; both @@ -24,46 +31,48 @@ export const READ_TOOLS = [ */ export const BUILD_TOOLS = [ ...READ_TOOLS, - "write_file", - "edit_file", - "delete_file", + ...PRODUCT_WRITE_TOOLS, "apply_patch", "shell", "update_plan", ] as const; /** - * Docs leaves: read/search/lsp/web + file writes — no run_shell, no delete_file. + * Docs leaves: read/search/lsp/web + file writes — no run_shell. * Envelope policy only: docs leaves omit shell so they cannot mutate via the * terminal. There is no separate path-level lock on top of the tool envelope. * * Composed from READ_TOOLS minus run_shell so it tracks the read surface - * automatically; only the write tools are added explicitly. `apply_patch` is + * automatically; path writes come from PRODUCT_WRITE_TOOLS. `apply_patch` is * included so Codex docs leaves keep the proxy after the capability filter. * `update_plan` is included for the same reason (its proxy has no `run_shell` * dependency, so it is not excluded alongside `shell`). */ export const DOCS_TOOLS = [ ...READ_TOOLS.filter((t) => t !== "run_shell"), - "write_file", - "edit_file", + ...PRODUCT_WRITE_TOOLS, "apply_patch", "update_plan", ] as const; -/** Review / counsel: read surface, no writes. */ -export const REVIEW_TOOLS = [...READ_TOOLS] as const; +/** Review / counsel: read surface + path writes (lane discipline in prompts). */ +export const REVIEW_TOOLS = [...READ_TOOLS, ...PRODUCT_WRITE_TOOLS] as const; -/** Mechanical intern: shell-first, minimal surface. */ -export const INTERN_TOOLS = ["run_shell", "read_file", "list_dir"] as const; - -/** Nested orchestrator surface (greybeard / package filter): dispatch only. */ -export const ORCHESTRATOR_TOOLS = [...READ_TOOLS, "search_agents", "task"] as const; +/** Mechanical intern: shell-first + path writes when the brief requires them. */ +export const INTERN_TOOLS = [ + "run_shell", + "read_file", + "list_dir", + ...PRODUCT_WRITE_TOOLS, +] as const; -/** Skywalker primary: orchestrator surface plus product writes for DIY tiny work. */ -export const SKYWALKER_TOOLS = [ - ...ORCHESTRATOR_TOOLS, - "write_file", - "edit_file", - "delete_file", +/** Nested orchestrator surface (greybeard / package filter): dispatch + path writes. */ +export const ORCHESTRATOR_TOOLS = [ + ...READ_TOOLS, + ...PRODUCT_WRITE_TOOLS, + "search_agents", + "task", ] as const; + +/** Skywalker primary: orchestrator surface (writes already composed). */ +export const SKYWALKER_TOOLS = [...ORCHESTRATOR_TOOLS] as const; diff --git a/tests/unit/subagent.test.ts b/tests/unit/subagent.test.ts index a78df4283..e1d624efa 100644 --- a/tests/unit/subagent.test.ts +++ b/tests/unit/subagent.test.ts @@ -283,9 +283,9 @@ test("intent maps to closed director without profiles", async () => { expect(received?.systemPromptRole).toContain("PRIMARY INTENT"); expect(received?.capabilities?.mode).toBe("allow"); expect(received?.capabilities?.tools).toContain("read_file"); - expect(received?.capabilities?.tools).not.toContain("write_file"); - expect(received?.capabilities?.tools).not.toContain("edit_file"); - expect(received?.capabilities?.tools).not.toContain("delete_file"); + expect(received?.capabilities?.tools).toContain("write_file"); + expect(received?.capabilities?.tools).toContain("edit_file"); + expect(received?.capabilities?.tools).toContain("delete_file"); }); test("intent general is refused (no general director)", async () => { From 959e0cd9c49083402fd0bd90c03b15ec156adc37 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 11:54:51 -0700 Subject: [PATCH 2/4] Mount fleet verbs on primary Skywalker Advertise and mount spawn_agent, wait_agents, and lifecycle fleet tools on the primary toolset so non-blocking fan-out works without fused task(). Closes CL-7024 --- src/agent/fleet-verbs-mount.test.ts | 75 ++++++++++++++++ src/agent/tool-search.test.ts | 25 ++++++ src/agent/tool-search.ts | 21 ++++- src/agent/tools.ts | 127 +++++++++++++++++++--------- 4 files changed, 206 insertions(+), 42 deletions(-) create mode 100644 src/agent/fleet-verbs-mount.test.ts diff --git a/src/agent/fleet-verbs-mount.test.ts b/src/agent/fleet-verbs-mount.test.ts new file mode 100644 index 000000000..ac53419b7 --- /dev/null +++ b/src/agent/fleet-verbs-mount.test.ts @@ -0,0 +1,75 @@ +/** + * Primary createAgentToolset mounts the six fleet verbs beside task / + * search_agents / read_agent_trace when subAgent (with the shared TUI + * sessions store) is wired. Leaves / no-subAgent toolsets stay without them. + */ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; + +import { createSubAgentSessionStore } from "../subagent/session-store.js"; + +const FLEET_VERBS = [ + "spawn_agent", + "wait_agents", + "close_agent", + "resume_agent", + "interrupt_agent", + "followup_task", +] as const; + +describe("primary fleet verb mount", () => { + test("createAgentToolset registers the six fleet verbs when subAgent + sessions are set", async () => { + const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-")); + const { createAgentToolset } = await import("./tools.js"); + const permissionGate = { + check: async () => ({ allowed: true }), + getSkipPermissions: () => false, + } as never; + const sessions = createSubAgentSessionStore(); + + const toolset = await createAgentToolset({ + cwd, + permissionGate, + onOperatorGate: async () => ({ kind: "option", index: 0 }), + subAgent: { + provider: { + providerName: "test", + baseURL: "http://127.0.0.1:0", + model: "test-model", + }, + getWorkdirBase: () => cwd, + sessions, + }, + }); + const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name); + expect(names).toContain("task"); + expect(names).toContain("read_agent_trace"); + for (const name of FLEET_VERBS) { + expect(names).toContain(name); + } + await toolset.dispose(); + }); + + test("createAgentToolset omits fleet verbs when subAgent is not set", async () => { + const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-")); + const { createAgentToolset } = await import("./tools.js"); + const permissionGate = { + check: async () => ({ allowed: true }), + getSkipPermissions: () => false, + } as never; + + const toolset = await createAgentToolset({ + cwd, + permissionGate, + onOperatorGate: async () => ({ kind: "option", index: 0 }), + }); + const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name); + expect(names).not.toContain("task"); + for (const name of FLEET_VERBS) { + expect(names).not.toContain(name); + } + await toolset.dispose(); + }); +}); diff --git a/src/agent/tool-search.test.ts b/src/agent/tool-search.test.ts index 3368ed6c3..f70662fb2 100644 --- a/src/agent/tool-search.test.ts +++ b/src/agent/tool-search.test.ts @@ -83,6 +83,21 @@ describe("createToolIndex", () => { ); }); + test("orchestrator mode advertises the six fleet verbs", () => { + const advertised = advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY); + for (const name of [ + "spawn_agent", + "wait_agents", + "close_agent", + "resume_agent", + "interrupt_agent", + "followup_task", + ] as const) { + expect(CORE_TOOL_NAMES).toContain(name); + expect(advertised).toContain(name); + } + }); + test("manage_tasks is advertised regardless of availability", () => { expect(coreToolNamesForSessionMode("orchestrator", NO_AVAILABILITY)).toContain("manage_tasks"); }); @@ -219,6 +234,16 @@ describe("advertisedTools", () => { const prefix = advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY); expect(prefix).toContain("task"); expect(prefix).toContain("search_agents"); + for (const name of [ + "spawn_agent", + "wait_agents", + "close_agent", + "resume_agent", + "interrupt_agent", + "followup_task", + ] as const) { + expect(prefix).toContain(name); + } // advertisedTools only emits tools present in the registry; multi-agent // tools appear on the wire when createAgentToolset registers them. const names = advertisedTools(registry, [], prefix).map((d) => d.name); diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index eda40ea59..4d58ca801 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -39,9 +39,28 @@ export const CORE_TOOL_NAMES: readonly string[] = [ // round-trip. Catalog-only placement left the model discovering profiles then // failing on an unloaded task tool. "task", + // Fleet verbs (non-blocking spawn + lifecycle). Mounted on primary when + // subAgent is wired; advertised here so the model does not tool_search for + // them. Package allowlists (ORCHESTRATOR_TOOLS / SKYWALKER_TOOLS) are a + // separate, deferred change. + "spawn_agent", + "wait_agents", + "close_agent", + "resume_agent", + "interrupt_agent", + "followup_task", ]; -const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = ["search_agents", "task"]; +const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = [ + "search_agents", + "task", + "spawn_agent", + "wait_agents", + "close_agent", + "resume_agent", + "interrupt_agent", + "followup_task", +]; // Session-start facts that gate a core tool's advertisement. Each must be // knowable once, before the first inference call, and must never change for diff --git a/src/agent/tools.ts b/src/agent/tools.ts index cf6fcf5ab..95f49e54d 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -39,6 +39,13 @@ import { type SubAgentProvider, type SubAgentSessionStore, } from "../subagent/index.js"; +import { createFleetRecords, createSpawnAgentTool, createWaitAgentsTool } from "../subagent/agent-fleet.js"; +import { + createCloseAgentTool, + createResumeAgentTool, + createInterruptAgentTool, + createFollowupTaskTool, +} from "../subagent/lifecycle-tools.js"; import { parseManageTasksArgs } from "./tasks.js"; import { createListDirTool } from "../util/list-dir.js"; import { createWebFetchTool } from "../tools/web-fetch.js"; @@ -263,6 +270,84 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise inheritedMcpTools, + run: runSubAgent, + ...(shellTimeout !== undefined ? { shellTimeout } : {}), + ...(shellEnv !== undefined ? { shellEnv } : {}), + ...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}), + ...(sa.onEvent !== undefined ? { onEvent: sa.onEvent } : {}), + ...(sa.onProgress !== undefined ? { onProgress: sa.onProgress } : {}), + ...(sa.sessions !== undefined ? { sessions: sa.sessions } : {}), + ...(sa.settings !== undefined ? { settings: sa.settings } : {}), + ...(sa.catalog !== undefined ? { catalog: sa.catalog } : {}), + ...(sa.profiles !== undefined ? { profiles: sa.profiles } : {}), + ...(args.getBlobReader !== undefined ? { getBlobReader: args.getBlobReader } : {}), + ...(sa.useWorktree !== undefined ? { useWorktree: sa.useWorktree } : {}), + ...(args.telemetry !== undefined ? { telemetry: args.telemetry } : {}), + }), + ); + if (sa.profiles !== undefined) { + orchestratorTools.push( + createSearchAgentsTool(() => { + const profiles = sa.profiles; + return typeof profiles === "function" ? profiles() : (profiles ?? []); + }), + ); + } + // Tier 1: the primary session is always an orchestrator and may + // target any worker (assertCanTargetAgent's rule), so no authority + // context is passed here — omitting it is treated as unrestricted, + // matching Tier 1's actual authority. + orchestratorTools.push(createReadAgentTraceTool(sa.getWorkdirBase)); + + // Mirror nested runSubAgent's orchestrator fleet mount (run.ts), but + // reuse the existing TUI/exec session store — do not allocate a private + // store only for these verbs. spawnAllowlist stays unwired on primary. + if (sa.sessions !== undefined) { + const fleetSessions = sa.sessions; + const fleetRecords = createFleetRecords(); + const fleetDeps = { + permissionGate, + inheritMcpTools: () => inheritedMcpTools, + ...(shellTimeout !== undefined ? { shellTimeout } : {}), + ...(shellEnv !== undefined ? { shellEnv } : {}), + ...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}), + cwd, + getWorkdirBase: sa.getWorkdirBase, + provider: sa.provider, + ...(args.getBlobReader !== undefined ? { getBlobReader: args.getBlobReader } : {}), + run: runSubAgent, + sessions: fleetSessions, + fleetRecords, + ...(sa.onEvent !== undefined ? { onEvent: sa.onEvent } : {}), + ...(sa.onProgress !== undefined ? { onProgress: sa.onProgress } : {}), + ...(sa.settings !== undefined ? { settings: sa.settings } : {}), + ...(sa.catalog !== undefined ? { catalog: sa.catalog } : {}), + ...(args.telemetry !== undefined ? { telemetry: args.telemetry } : {}), + }; + orchestratorTools.push( + createSpawnAgentTool(fleetDeps), + createWaitAgentsTool({ sessions: fleetSessions, fleetRecords }), + createCloseAgentTool({ sessions: fleetSessions }), + createResumeAgentTool({ sessions: fleetSessions }), + createInterruptAgentTool({ sessions: fleetSessions }), + createFollowupTaskTool({ sessions: fleetSessions }), + ); + } + } + const baseTools: AgentTool[] = [ ...fromToolRunner(posixTools).map((tool) => ({ ...tool, @@ -276,47 +361,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise inheritedMcpTools, - run: runSubAgent, - ...(shellTimeout !== undefined ? { shellTimeout } : {}), - ...(shellEnv !== undefined ? { shellEnv } : {}), - ...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}), - ...(args.subAgent.onEvent !== undefined ? { onEvent: args.subAgent.onEvent } : {}), - ...(args.subAgent.onProgress !== undefined - ? { onProgress: args.subAgent.onProgress } - : {}), - ...(args.subAgent.sessions !== undefined ? { sessions: args.subAgent.sessions } : {}), - ...(args.subAgent.settings !== undefined ? { settings: args.subAgent.settings } : {}), - ...(args.subAgent.catalog !== undefined ? { catalog: args.subAgent.catalog } : {}), - ...(args.subAgent.profiles !== undefined ? { profiles: args.subAgent.profiles } : {}), - ...(args.getBlobReader !== undefined ? { getBlobReader: args.getBlobReader } : {}), - ...(args.subAgent.useWorktree !== undefined - ? { useWorktree: args.subAgent.useWorktree } - : {}), - ...(args.telemetry !== undefined ? { telemetry: args.telemetry } : {}), - }), - ...(args.subAgent.profiles !== undefined - ? [ - createSearchAgentsTool(() => { - const profiles = args.subAgent!.profiles; - return typeof profiles === "function" ? profiles() : (profiles ?? []); - }), - ] - : []), - // Tier 1: the primary session is always an orchestrator and may - // target any worker (assertCanTargetAgent's rule), so no authority - // context is passed here — omitting it is treated as unrestricted, - // matching Tier 1's actual authority. - createReadAgentTraceTool(args.subAgent.getWorkdirBase), - ] - : []), + ...orchestratorTools, stringTool({ definition: manageTasksDefinition, handler: async (rawArgs: Record): Promise => { From 84074e7b914248cd20fdb15f449ebe0ef2083198 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 11:54:52 -0700 Subject: [PATCH 3/4] Add a background git-worktrees skill Hide the recipe from slash and use_skill listing while keeping resolve by name for implement and linear-issue-workflow. Closes CL-7013 --- docs/ARCHITECTURE.md | 17 ++--- docs/PLUGINS.md | 12 ++-- .../skills/git-worktrees/SKILL.md | 31 ++++++++ .../skills/linear-issue-workflow/SKILL.md | 25 ++----- src/extensions/skills.ts | 31 ++++++-- src/plugins/skill-commands.ts | 9 +-- tests/unit/corbits-skills-catalog.test.ts | 40 ++++++++++- tests/unit/skills.test.ts | 70 +++++++++++++++++++ 8 files changed, 189 insertions(+), 46 deletions(-) create mode 100644 plugins/corbits-skills/skills/git-worktrees/SKILL.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 94fed4e16..ec059e308 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -416,19 +416,20 @@ Primary is Skywalker. Bundled skill bodies that are operator slashes are **actio | `.claude/skills/` | Claude Code workspace skills | | `.codex/skills/` | Codex workspace skills | -Each `//SKILL.md` is one skill. Discovery dedupes by directory name: the first base dir that provides a given name wins, so an enabled plugin skill shadows a project-local skill of the same name. Plugin dirs are passed in discovery order (repo first), so a first-party catalog name wins over a later marketplace or project skill of the same name. `resolveSkillBody(cwd, ref, pluginDirs)` resolves a skill's body using the same ordered list (it accepts a bare name or a `plugin:name` ref, keying on the name). +Each `//SKILL.md` is one skill. Discovery dedupes by directory name: the first base dir that provides a given name wins, so an enabled plugin skill shadows a project-local skill of the same name. Plugin dirs are passed in discovery order (repo first), so a first-party catalog name wins over a later marketplace or project skill of the same name. Skills with `disable-model-invocation: true` are omitted from the returned listing but still claim the name (first-wins), so a lower-priority same-name skill cannot leak into the listing. `resolveSkillBody(cwd, ref, pluginDirs)` resolves a skill's body using the same ordered list (it accepts a bare name or a `plugin:name` ref, keying on the name) and **does not** hard-fail on `disable-model-invocation` — explicit `use_skill("name")` still loads background libraries. #### SKILL.md format -A skill file begins with a YAML frontmatter block, followed by the body that holds the instructions. Discovery parses `description`; `loadSkillCommands` also reads `user-invocable`. The skill's identifier (what `use_skill` and `/` take) is its directory name. A skill with no `SKILL.md` or an empty body is skipped. +A skill file begins with a YAML frontmatter block, followed by the body that holds the instructions. Discovery parses `description` and `disable-model-invocation`; `loadSkillCommands` also reads `user-invocable`. The skill's identifier (what `use_skill` and `/` take) is its directory name. A skill with no `SKILL.md` or an empty body is skipped. -| Field | Required | Description | -| ---------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `description` | yes | One-line summary shown in the prompt's lazy skills listing and the slash picker | -| `name` | conventional | Conventionally matches the directory name; the directory name is what is actually used as the identifier | -| `user-invocable` | no | When `false`, `loadSkillCommands` skips slash synthesis; the skill remains `use_skill` only. Untagged skills still become slashes (marketplace BC) | +| Field | Required | Description | +| --------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `description` | yes | One-line summary shown in the prompt's lazy skills listing and the slash picker | +| `name` | conventional | Conventionally matches the directory name; the directory name is what is actually used as the identifier | +| `user-invocable` | no | When `false`, `loadSkillCommands` skips slash synthesis; the skill remains `use_skill` only. Untagged skills still become slashes (marketplace BC) | +| `disable-model-invocation` | no | When `true`, `discoverSkills` omits the skill from the lazy listing (but still claims the name for first-wins). Explicit `resolveSkillBody` / `use_skill("name")` still loads the body. Does not affect slash emission. | -There are no `type` or `disable-model-invocation` fields required for model invocation — a skill body is plain instruction text. `argument-hint` on frontmatter is preserved for the slash picker (greyed arg guidance). Multi-step orchestration is a separate mechanism (see Workflows above), not a skill `type`. +There is no skill `type` field required for model invocation — a skill body is plain instruction text. Background libraries (e.g. `git-worktrees`) set both `user-invocable: false` and `disable-model-invocation: true` so they are absent from slash and listing, yet recipes can still `use_skill("git-worktrees")`. `argument-hint` on frontmatter is preserved for the slash picker (greyed arg guidance). Multi-step orchestration is a separate mechanism (see Workflows above), not a skill `type`. #### Loading (model and operator) diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index ae347ee75..3848302e6 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -309,10 +309,14 @@ shape. become slashes (marketplace backward compatibility). Frontmatter `argument-hint` is preserved so the TUI can show greyed arg guidance (e.g. `/create-issue` → `[description] [--from-doc]`). This is an additional - surface: `discoverSkills` is unchanged, so the model can still auto-invoke any - skill via `use_skill` — including first-party recipes that are not operator - slashes (`dispatch`, `git-rebase`, `linear-issue-workflow`, `style`, - `philosophy`, `typescript`, `opsh`). The slash command is a direct user entry + surface: `discoverSkills` skips skills with `disable-model-invocation: true` + from the lazy listing (those stay loadable via explicit `use_skill` / + `resolveSkillBody`), so the model does not auto-suggest background libraries. + First-party recipes that are not operator slashes remain listed for + `use_skill` when they only set `user-invocable: false` (`dispatch`, + `git-rebase`, `linear-issue-workflow`, `style`, `philosophy`, `typescript`, + `opsh`). Background libs such as `git-worktrees` set both flags. The slash + command is a direct user entry point on top. - **First-party catalog.** `plugins/corbits-skills/` (id `corbits-skills`, kind `command`, `defaultEnabled: true`) is the bundled skill catalog. Origin diff --git a/plugins/corbits-skills/skills/git-worktrees/SKILL.md b/plugins/corbits-skills/skills/git-worktrees/SKILL.md new file mode 100644 index 000000000..c87702595 --- /dev/null +++ b/plugins/corbits-skills/skills/git-worktrees/SKILL.md @@ -0,0 +1,31 @@ +--- +name: git-worktrees +user-invocable: false +disable-model-invocation: true +description: Create a git worktree from origin/ and tear it down. Background library — load via use_skill("git-worktrees"); absent from slash and use_skill listing. +--- + +# git-worktrees + +Background recipe. Skywalker loads via `use_skill("git-worktrees")` and copies commands into an intern brief. Intern executes via `run_shell`. Skywalker does not run the git. + +## Create from origin/ + +```bash +git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@' +git fetch origin +git worktree add ../worktree/ -b origin/ +``` + +Always base new branches on `origin/` (whatever the repository uses). After creating the worktree, intern `cd`s into it and installs local dependencies (`bun install` when the project uses Bun; otherwise follow developer docs). Worktrees do not share `node_modules`. + +## Teardown + +```bash +cd +git fetch origin +git worktree remove ../worktree/ +git branch -d +``` + +If the worktree directory was already deleted: `git worktree prune`. diff --git a/plugins/corbits-skills/skills/linear-issue-workflow/SKILL.md b/plugins/corbits-skills/skills/linear-issue-workflow/SKILL.md index 5d11147c7..f2875547c 100644 --- a/plugins/corbits-skills/skills/linear-issue-workflow/SKILL.md +++ b/plugins/corbits-skills/skills/linear-issue-workflow/SKILL.md @@ -21,23 +21,15 @@ If the scope is unclear, `ask_operator` before proceeding. Do not guess. Read `branchName` from the issue (call `mcp__linear__get_issue` again if needed). -Spawn `task(agent="intern")` with this sequenced `run_shell` list copied into the brief. Intern executes; Skywalker does not run the git. +Load `use_skill("git-worktrees")`. Copy the create-from-origin/ recipe into an intern brief (substitute ``). Spawn `task(agent="intern")`. Intern executes; Skywalker does not run the git. -```bash -git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@' -git fetch origin -git worktree add ../worktree/ -b origin/ -``` - -Always base new branches on `origin/` (whatever the repository uses). After creating the worktree, intern `cd`s into it and installs local dependencies from developer documentation. Worktrees do not share `node_modules`. - -If intern fails, stop and `ask_operator`. If the operator rejects the issue before implementation, intern tears down the worktree (Phase 7 commands) rather than leaving it stranded. +If intern fails, stop and `ask_operator`. If the operator rejects the issue before implementation, intern tears down the worktree via the git-worktrees teardown recipe rather than leaving it stranded. ## Phase 3: Plan, attach, mark In Progress 1. Spawn `task(agent="explore")` if the codebase map is not already known. Brief it with the absolute worktree path (it must work there) and the issue: where changes go, existing patterns, related code. 2. Follow the `/implement` loop's greybeard step (Phase 4) for the approach. Present the plan to the operator and `ask_operator` whether to proceed. Do not start implementation until approved. -3. If the operator rejects the plan and the issue cannot be salvaged, intern tears down the worktree (Phase 7) rather than leaving it stranded. +3. If the operator rejects the plan and the issue cannot be salvaged, intern tears down the worktree via the git-worktrees teardown recipe rather than leaving it stranded. 4. Attach the plan to the Linear issue. **Do not post the plan as a comment** — comments are for discussion, not archives. Spawn `task(agent="build")` with a mechanical brief to write the approved plan to the worktree's `tmp/plan-.md` (do not commit it). Intern captures byte size with `wc -c`. Primary then: @@ -130,16 +122,7 @@ Phase 6 ends when the PR is open. Phase 7 runs **after the PR is merged** and ** 2. Re-read the issue with `mcp__linear__get_issue`. Flip checkboxes the merged PR actually completed on `main` via `mcp__linear__save_issue`. Never check a box on intent. 3. `mcp__linear__save_comment` with PR URL, merge SHA, and CI-green confirmation. Short. Present-tense facts. 4. If every outcome checkbox is checked, set state to `Done` with `mcp__linear__save_issue`. Otherwise leave In Progress. -5. Only then intern cleans up: - -```bash -cd -git fetch origin -git worktree remove ../worktree/ -git branch -d -``` - -If the worktree directory was already deleted: `git worktree prune`. +5. Only then intern cleans up: load `use_skill("git-worktrees")` and copy the teardown recipe into an intern brief (substitute `` and ``). ## Linear MCP tool reference diff --git a/src/extensions/skills.ts b/src/extensions/skills.ts index 4ee5340e7..db0e99d28 100644 --- a/src/extensions/skills.ts +++ b/src/extensions/skills.ts @@ -50,13 +50,25 @@ function stripFrontmatter(raw: string): string { return raw.slice(end + 3).trim(); } -function parseSkillFrontmatter(raw: string): { name?: string; description?: string } { +function parseSkillFrontmatter(raw: string): { + name?: string; + description?: string; + disableModelInvocation?: boolean; +} { const block = frontmatterBlock(raw); if (block === undefined) return {}; - const out: { name?: string; description?: string } = {}; + const out: { + name?: string; + description?: string; + disableModelInvocation?: boolean; + } = {}; for (const line of block.split("\n")) { - const match = /^(name|description):\s*(.+)$/.exec(line.trim()); + const trimmed = line.trim(); + const match = /^(name|description):\s*(.+)$/.exec(trimmed); if (match) out[match[1] as "name" | "description"] = match[2]!.trim(); + if (/^disable-model-invocation:\s*true\s*$/.test(trimmed)) { + out.disableModelInvocation = true; + } } return out; } @@ -136,11 +148,15 @@ export async function resolveSkillBody( // Discover every available skill (name + one-line description) for the lazy // listing in the system prompt. Deduped by name: the first base dir that // provides a skill wins, so a higher-precedence dir shadows a lower one. +// Skills with `disable-model-invocation: true` are omitted from the listing +// but still occupy the name in `seen` so a lower-priority same-name skill +// cannot leak in. Explicit `use_skill` / `resolveSkillBody` loads still work. export async function discoverSkills( cwd: string, pluginDirs: string[] = [], ): Promise { - const seen = new Map(); + const seen = new Set(); + const skills: SkillSummary[] = []; for (const base of skillBaseDirs(cwd, pluginDirs)) { const entries = await readdir(base, { withFileTypes: true }).catch(() => undefined); if (entries === undefined) continue; @@ -149,8 +165,11 @@ export async function discoverSkills( const raw = await readRaw(join(base, entry.name, "SKILL.md")); if (raw === undefined) continue; const fm = parseSkillFrontmatter(raw); - seen.set(entry.name, { name: entry.name, description: fm.description ?? "" }); + // First-wins: claim the name even when skipping the listing. + seen.add(entry.name); + if (fm.disableModelInvocation) continue; + skills.push({ name: entry.name, description: fm.description ?? "" }); } } - return [...seen.values()]; + return skills; } diff --git a/src/plugins/skill-commands.ts b/src/plugins/skill-commands.ts index 9194f8483..86e981958 100644 --- a/src/plugins/skill-commands.ts +++ b/src/plugins/skill-commands.ts @@ -11,9 +11,9 @@ import { splitFrontmatter } from "./frontmatter.js"; // body (plus args) to the agent. Convention/internal skills opt out with // `user-invocable: false` in frontmatter and are not emitted as slash commands. // Untagged skills still become slash commands (marketplace BC). -// `disable-model-invocation` does not affect slash emission. A skill authored -// as `skills//SKILL.md` is still model-invoked via the `use_skill` tool; -// `discoverSkills` is unchanged, so the model can still auto-invoke any skill. +// `disable-model-invocation` does not affect slash emission — that flag only +// skips the skill from `discoverSkills` lazy listing. Explicit `use_skill` / +// `resolveSkillBody` still loads the body by name. const COMMAND_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; @@ -60,7 +60,8 @@ export async function loadSkillCommands( continue; } // Opt-out of the slash surface. Untagged skills still emit a command - // (marketplace BC); `disable-model-invocation` does not affect this. + // (marketplace BC); `disable-model-invocation` does not affect this — + // it only skips discoverSkills listing (see src/extensions/skills.ts). if (frontmatter["user-invocable"] === false) continue; const name = diff --git a/tests/unit/corbits-skills-catalog.test.ts b/tests/unit/corbits-skills-catalog.test.ts index ac0a83361..13a08db0d 100644 --- a/tests/unit/corbits-skills-catalog.test.ts +++ b/tests/unit/corbits-skills-catalog.test.ts @@ -17,6 +17,7 @@ const SKILL_DIRS = [ "typescript", "interview", "git-rebase", + "git-worktrees", "refactor", "pull-request-review", "create-issue", @@ -27,6 +28,7 @@ const SKILL_DIRS = [ const SPAWN_RECIPE_SKILLS = ["implement", "scribe", "review", "dispatch", "plan"] as const; +/** use_skill listing + resolve; not slash. No disable-model-invocation. */ const USE_SKILL_ONLY = [ "dispatch", "git-rebase", @@ -37,6 +39,9 @@ const USE_SKILL_ONLY = [ "opsh", ] as const; +/** Background libs: absent from slash and use_skill listing; explicit resolve only. */ +const BACKGROUND_ONLY = ["git-worktrees"] as const; + const SLASH_SKILLS = [ "implement", "refactor", @@ -52,6 +57,7 @@ const SLASH_SKILLS = [ const BANNED_TOKENS = ["TaskCreate", "@greybeard", 'intent="general"'] as const; const USER_INVOCABLE_FALSE = "user-invocable: false"; +const DISABLE_MODEL_INVOCATION = "disable-model-invocation: true"; async function listFilesRecursive(dir: string): Promise { const out: string[] = []; @@ -82,8 +88,8 @@ test("corbits-skills plugin has no agents directory", () => { expect(existsSync(join(pluginRoot, "agents"))).toBe(false); }); -test("corbits-skills catalog lists 16 skills with name and description", async () => { - expect(SKILL_DIRS).toHaveLength(16); +test("corbits-skills catalog lists 17 skills with name and description", async () => { + expect(SKILL_DIRS).toHaveLength(17); const entries = await readdir(join(pluginRoot, "skills"), { withFileTypes: true }); const dirs = entries .filter((entry) => entry.isDirectory()) @@ -114,13 +120,41 @@ test("create-issue selects Linear MCP, GitHub gh, and MEMORY.md preference", asy expect(skill).toContain("Preferred issue tracker:"); }); -test("use_skill-only skills set user-invocable: false", async () => { +test("use_skill-only skills set user-invocable: false without disable-model-invocation", async () => { for (const name of USE_SKILL_ONLY) { const skill = await Bun.file(join(pluginRoot, "skills", name, "SKILL.md")).text(); expect(skill).toContain(USER_INVOCABLE_FALSE); + expect(skill).not.toContain(DISABLE_MODEL_INVOCATION); + } +}); + +test("background-only skills set both exclusion flags", async () => { + for (const name of BACKGROUND_ONLY) { + const skill = await Bun.file(join(pluginRoot, "skills", name, "SKILL.md")).text(); + expect(skill).toContain(USER_INVOCABLE_FALSE); + expect(skill).toContain(DISABLE_MODEL_INVOCATION); + } +}); + +test("only background libs carry disable-model-invocation", async () => { + for (const name of SKILL_DIRS) { + const skill = await Bun.file(join(pluginRoot, "skills", name, "SKILL.md")).text(); + if ((BACKGROUND_ONLY as readonly string[]).includes(name)) { + expect(skill).toContain(DISABLE_MODEL_INVOCATION); + } else { + expect(skill).not.toContain(DISABLE_MODEL_INVOCATION); + } } }); +test("linear-issue-workflow references use_skill(git-worktrees)", async () => { + const skill = await Bun.file( + join(pluginRoot, "skills/linear-issue-workflow/SKILL.md"), + ).text(); + expect(skill).toContain('use_skill("git-worktrees")'); + expect(skill).not.toContain("git worktree add"); +}); + test("slash skills do not set user-invocable: false", async () => { for (const name of SLASH_SKILLS) { const skill = await Bun.file(join(pluginRoot, "skills", name, "SKILL.md")).text(); diff --git a/tests/unit/skills.test.ts b/tests/unit/skills.test.ts index 6ae3c37ed..b8884e89f 100644 --- a/tests/unit/skills.test.ts +++ b/tests/unit/skills.test.ts @@ -21,6 +21,57 @@ describe("skill discovery", () => { const names = (await discoverSkills(fixtureCwd, pluginDirs)).map((s) => s.name); expect(new Set(names).size).toBe(names.length); }); + + test("skips disable-model-invocation:true from listing but still occupies seen", async () => { + const root = await mkdtemp(join(tmpdir(), "skill-dmi-")); + const high = join(root, "high"); + const low = join(root, "low"); + try { + await mkdir(join(high, "skills", "bg-lib"), { recursive: true }); + await mkdir(join(low, "skills", "bg-lib"), { recursive: true }); + await writeFile( + join(high, "skills", "bg-lib", "SKILL.md"), + "---\nname: bg-lib\ndescription: high priority background\ndisable-model-invocation: true\n---\nHigh body.\n", + "utf8", + ); + await writeFile( + join(low, "skills", "bg-lib", "SKILL.md"), + "---\nname: bg-lib\ndescription: leaked lower priority\n---\nLow body that must not list.\n", + "utf8", + ); + const skills = await discoverSkills(root, [high, low]); + expect(skills.find((s) => s.name === "bg-lib")).toBeUndefined(); + expect(skills.some((s) => s.description.includes("leaked"))).toBe(false); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("lists a sibling skill when a peer has disable-model-invocation", async () => { + const plugin = await mkdtemp(join(tmpdir(), "skill-dmi-peer-")); + try { + await mkdir(join(plugin, "skills", "bg-lib"), { recursive: true }); + await mkdir(join(plugin, "skills", "visible"), { recursive: true }); + await writeFile( + join(plugin, "skills", "bg-lib", "SKILL.md"), + "---\nname: bg-lib\ndescription: background\ndisable-model-invocation: true\n---\nHidden.\n", + "utf8", + ); + await writeFile( + join(plugin, "skills", "visible", "SKILL.md"), + "---\nname: visible\ndescription: still listed\n---\nVisible body.\n", + "utf8", + ); + const skills = await discoverSkills(plugin, [plugin]); + expect(skills.find((s) => s.name === "bg-lib")).toBeUndefined(); + expect(skills.find((s) => s.name === "visible")).toEqual({ + name: "visible", + description: "still listed", + }); + } finally { + await rm(plugin, { recursive: true, force: true }); + } + }); }); describe("skill resolution", () => { @@ -38,6 +89,25 @@ describe("skill resolution", () => { test("returns undefined for an unknown skill", async () => { expect(await resolveSkillBody(fixtureCwd, "does-not-exist-xyz", pluginDirs)).toBeUndefined(); }); + + test("resolveSkillBody still loads disable-model-invocation skills by name", async () => { + const plugin = await mkdtemp(join(tmpdir(), "skill-dmi-resolve-")); + try { + await mkdir(join(plugin, "skills", "git-worktrees"), { recursive: true }); + await writeFile( + join(plugin, "skills", "git-worktrees", "SKILL.md"), + "---\nname: git-worktrees\nuser-invocable: false\ndisable-model-invocation: true\ndescription: bg\n---\nCreate worktree recipe.\n", + "utf8", + ); + expect(await discoverSkills(plugin, [plugin])).toEqual([]); + const body = await resolveSkillBody(plugin, "git-worktrees", [plugin]); + expect(body).toBeDefined(); + expect(body).toContain("Create worktree recipe."); + expect(body!.startsWith("---")).toBe(false); + } finally { + await rm(plugin, { recursive: true, force: true }); + } + }); }); describe("path-like skill refs", () => { From a1778be4e3d309e2b940714ca4b431f5ff690996 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 15:33:56 -0700 Subject: [PATCH 4/4] Fix prettier formatting for git-worktrees skill --- docs/ARCHITECTURE.md | 12 ++++++------ src/agent/directors/tool-sets.ts | 7 +------ src/agent/tools.ts | 6 +++++- tests/unit/corbits-skills-catalog.test.ts | 4 +--- 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ec059e308..c6dab7f93 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -422,12 +422,12 @@ Each `//SKILL.md` is one skill. Discovery dedupes by directory A skill file begins with a YAML frontmatter block, followed by the body that holds the instructions. Discovery parses `description` and `disable-model-invocation`; `loadSkillCommands` also reads `user-invocable`. The skill's identifier (what `use_skill` and `/` take) is its directory name. A skill with no `SKILL.md` or an empty body is skipped. -| Field | Required | Description | -| --------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `description` | yes | One-line summary shown in the prompt's lazy skills listing and the slash picker | -| `name` | conventional | Conventionally matches the directory name; the directory name is what is actually used as the identifier | -| `user-invocable` | no | When `false`, `loadSkillCommands` skips slash synthesis; the skill remains `use_skill` only. Untagged skills still become slashes (marketplace BC) | -| `disable-model-invocation` | no | When `true`, `discoverSkills` omits the skill from the lazy listing (but still claims the name for first-wins). Explicit `resolveSkillBody` / `use_skill("name")` still loads the body. Does not affect slash emission. | +| Field | Required | Description | +| -------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `description` | yes | One-line summary shown in the prompt's lazy skills listing and the slash picker | +| `name` | conventional | Conventionally matches the directory name; the directory name is what is actually used as the identifier | +| `user-invocable` | no | When `false`, `loadSkillCommands` skips slash synthesis; the skill remains `use_skill` only. Untagged skills still become slashes (marketplace BC) | +| `disable-model-invocation` | no | When `true`, `discoverSkills` omits the skill from the lazy listing (but still claims the name for first-wins). Explicit `resolveSkillBody` / `use_skill("name")` still loads the body. Does not affect slash emission. | There is no skill `type` field required for model invocation — a skill body is plain instruction text. Background libraries (e.g. `git-worktrees`) set both `user-invocable: false` and `disable-model-invocation: true` so they are absent from slash and listing, yet recipes can still `use_skill("git-worktrees")`. `argument-hint` on frontmatter is preserved for the slash picker (greyed arg guidance). Multi-step orchestration is a separate mechanism (see Workflows above), not a skill `type`. diff --git a/src/agent/directors/tool-sets.ts b/src/agent/directors/tool-sets.ts index 213d8d7f7..7cd5b1a4d 100644 --- a/src/agent/directors/tool-sets.ts +++ b/src/agent/directors/tool-sets.ts @@ -59,12 +59,7 @@ export const DOCS_TOOLS = [ export const REVIEW_TOOLS = [...READ_TOOLS, ...PRODUCT_WRITE_TOOLS] as const; /** Mechanical intern: shell-first + path writes when the brief requires them. */ -export const INTERN_TOOLS = [ - "run_shell", - "read_file", - "list_dir", - ...PRODUCT_WRITE_TOOLS, -] as const; +export const INTERN_TOOLS = ["run_shell", "read_file", "list_dir", ...PRODUCT_WRITE_TOOLS] as const; /** Nested orchestrator surface (greybeard / package filter): dispatch + path writes. */ export const ORCHESTRATOR_TOOLS = [ diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 95f49e54d..23a46b14b 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -39,7 +39,11 @@ import { type SubAgentProvider, type SubAgentSessionStore, } from "../subagent/index.js"; -import { createFleetRecords, createSpawnAgentTool, createWaitAgentsTool } from "../subagent/agent-fleet.js"; +import { + createFleetRecords, + createSpawnAgentTool, + createWaitAgentsTool, +} from "../subagent/agent-fleet.js"; import { createCloseAgentTool, createResumeAgentTool, diff --git a/tests/unit/corbits-skills-catalog.test.ts b/tests/unit/corbits-skills-catalog.test.ts index 13a08db0d..ad5c272b2 100644 --- a/tests/unit/corbits-skills-catalog.test.ts +++ b/tests/unit/corbits-skills-catalog.test.ts @@ -148,9 +148,7 @@ test("only background libs carry disable-model-invocation", async () => { }); test("linear-issue-workflow references use_skill(git-worktrees)", async () => { - const skill = await Bun.file( - join(pluginRoot, "skills/linear-issue-workflow/SKILL.md"), - ).text(); + const skill = await Bun.file(join(pluginRoot, "skills/linear-issue-workflow/SKILL.md")).text(); expect(skill).toContain('use_skill("git-worktrees")'); expect(skill).not.toContain("git worktree add"); });