Files

10 KiB

Row 64: a managed session gets its role's skills as prompt text on both harnesses

Author and gate owner: Sage (mos-0855). Reviewers Darkwing and Filbert. Sage, 2026-10-19. Brief docs/plans/2026-10-11_managed-session-config.md, anchor "Managed sessions get their role's skills on both harnesses", blob 21ae25a3. Issue #1557. Rulings mos-0857 (reviewer checks), mos-0862 (mutant coverage).

Base 087d202bc. Candidate c6d7d538f, one commit, built in /mnt/storage/scratch/sage-r64/wt and gated in a clean detached worktree at /mnt/storage/scratch/sage-r64/cand. The commit is in the canonical object store, so git show c6d7d538f works from the canonical checkout. candidate.diff is that output. candidate-manifest.sha256 lists the 28 changed paths.

The choice (probe.md)

The probe ran Claude Code 2.1.303 and read the pinned pi docs. On Claude Code a --plugin-dir skill works only with the Skill tool on, and with it on, Claude Code's own bundled skills come along under names that belong to the Claude Code version. On pi, --skill puts a description in the prompt and the model reads SKILL.md with a file tool, which the gate refuses because the bundle is outside the workspace.

So the skill text goes into prompt.md on both harnesses, rendered from the bundle copies. Pi keeps --no-skills. Claude Code keeps --disable-slash-commands and --restricted. Neither harness discovers a skill, no new tool passes the gate, and the manifest's digests cover what the model reads.

What changed

  • roles/*.json: an optional skills list. Names match /^[A-Za-z0-9._-]+$/, with "." and ".." refused, no repeats, none on version 1 role files (role.mjs). resolve.mjs carries the list on the record.
  • First lists: coder ms-unslop, ms-sdlc, ms-file-read, ms-file-write. Reviewer ms-unslop, ms-sdlc, ms-file-read. Cto ms-unslop, ms-sdlc, ms-archify, ms-grill-me. Pm ms-unslop, ms-goal, ms-communications. The chief of staff lists none.
  • bundle.mjs reads each skill from <dataRoot>/skills-enabled/<name>, copies it to <bundle>/skills/<name> and renders every file, in sorted path order, between <skill-file skill="..." path="..."> and </skill-file> lines under a "## Skills" heading. A role with no list gets no heading, no skills directory and an empty manifest list. The manifest records each skill's path and per-file sha256.
  • Refusals: a listed skill that isn't enabled is skill-not-enabled and names scripts/skill.sh activate. A symlink anywhere in the skill (the skill directory, SKILL.md, a sub-file) or a non-file entry, a file name outside SKILL_PATH, non-UTF-8 bytes, or a file holding the end marker is skill-refused. lstatSync throughout, so a symlink is refused, not followed.
  • SKILL_BYTES is 64 KiB across all of a role's skills together. The claude adapter passes prompt.md as one --system-prompt argument and Linux caps one argument at 128 KiB (MAX_ARG_STRLEN), so the cap leaves room for the contract and the rest of the prompt. Bytes per role by wc -c over the repo copies: coder 16853, reviewer 16252, cto 35888, pm 29080. Each fits (skills.test.mjs, the SKILL_BYTES test).
  • launcher.mjs:254 checks readSkills(resolved.skills, skillsDir) after the route and contract checks and before the harness-off and binary checks, so the refusal comes before any run exists and doesn't depend on the harness. skill-not-enabled stays skill-not-enabled, anything else becomes skill-refused.
  • adapters/README.md and packages/harness/README.md describe the section and the flags. The other test edits give the fixtures a skills-enabled directory.

Tests the brief names, each on both harnesses

Brief test Where
exactly the contracted skills skills.test.mjs:45, four roles x pi and claude-code, prompt vs bundle copy vs source, byte for byte
a contracted skill that isn't enabled refuses before any spawn skills.test.mjs:109 (build, both harnesses), launcher.test.mjs row 64 pi test and the row 64 Claude Code test (launch, no run made)
an enabled but unlisted skill is absent skills.test.mjs:92, both harnesses
no list: Pi runs with --no-skills, Claude Code gets none skills.test.mjs:79 (build, both harnesses) and :170 (chief through each real adapter)

skills.test.mjs:170 runs each real adapter.sh against a stand-in CLI that records argv. For the reviewer and the chief on each harness it checks:

  • one --system-prompt, equal to prompt.md less trailing newlines
  • every marked file equal to its bundle copy
  • no skill path or marker anywhere else in argv
  • --no-skills and no --skill on pi, --disable-slash-commands and --restricted on Claude Code

The four-way loop and the Claude Code launcher test were added after gate run 1 started, when I read the brief's "each on both harnesses" against the tests. I stopped run 1 (bus and business had passed). Run 2 is on the amended commit.

Mutants (mutants.py and mutants-run2.txt here, run 2 on c6d7d538f, logs in /mnt/storage/scratch/sage-r64/mutants/)

Mutant Change Caught by
M1 drop the launcher precheck launcher.test.mjs, both row 64 tests
M2 map skill-not-enabled to skill-refused launcher.test.mjs, both row 64 tests
M3 no SKILL_PATH check skills.test.mjs
M4 no SKILL_BYTES check skills.test.mjs
M5 no end-marker check skills.test.mjs
M6 the walk follows symlinks (statSync) skills.test.mjs
M7 the found check follows symlinks skills.test.mjs
M8 no newline before the end marker skills.test.mjs
M9 role names allow "." and ".." role.test.mjs
M10 resolve drops the skills list skills.test.mjs (resolve.test.mjs passes)
M11 Claude Code without --disable-slash-commands skills.test.mjs
M12 Claude Code without --restricted skills.test.mjs
M13 pi without --no-skills skills.test.mjs

13 of 13 killed. M10 is caught only by skills.test.mjs. Mos ruled that enough (mos-0862): a mutant counts as killed if any gate test catches it. M6 and M7 add statSync to the import so they fail on behavior, not on a ReferenceError. The files were restored byte for byte (restored True) and the orphan sweep found nothing.

Base drift

packages/harness/README.md and packages/harness/tests/bundle.test.mjs changed on refactor in row 134 (3a600cf5b), in different hunks from this row. The rebase onto 087d202bc was clean, and the gate runs on that base.

Gate

/mnt/storage/scratch/sage-r64/gate.sh from the template, on the clean worktree at c6d7d538f, dirty 0, with the manifest checked before any suite. Run 2: GATE_PASSED (gate-run2.txt here, logs in /mnt/storage/scratch/sage-r64/out/). 15 package suites, 2384 pass and 0 fail by an awk sum over the log (harness 133, cli 349, business 149, plugins 111). All 10 script suites passed. The compose network was removed, and no container from the gate is left.

Run 1, on the earlier commit ece9fb5a3, was stopped by hand after bus and business passed (gate-run1.txt, out-stopped-run1/). It didn't fail. I stopped it to add the both-harness tests above.

For the reviewers (mos-0857)

  1. The rendered prompt.md matches the bundle copies byte for byte: skills.test.mjs:45 for the build and :170 for what each adapter actually passes.
  2. --no-skills, --disable-slash-commands and --restricted are still on: adapters/pi/adapter.sh, adapters/claude/adapter.sh, and skills.test.mjs:170 with M11-M13.
  3. candidate-manifest.sha256 against git show c6d7d538f.
  4. The 64 KiB cap against the one-argument limit, and that every repo role fits.
  5. That the launcher precheck comes before any run. (Round 1 correction, Darkwing N1: the code also puts it before the harness-specific checks, but no test pins that order. The brief asks for "before any spawn", which the launcher tests check.)

Landing waits for Mos's go (mos-0856, mos-0861 A2): activate the skills in the live data root, then start one managed coder session per harness and attach the manifest.

Round 1 follow-ups

  • F1 (Filbert, #1557 comment 29218, doc-only): packages/harness/README.md:55-58 says a symlinked skill, a bad file name, non-UTF-8 bytes, the end marker and an over-cap list refuse skill-not-enabled. The code gives them bundle-refused, which the launcher maps to skill-refused. No test reads that README (grep of packages//tests and scripts/test-.sh), so under decision 137 it doesn't block. It gets its own doc commit at landing, after the reviewed candidate, so the approved manifest stays as reviewed.
  • Darkwing (#1557 comment 29219) N1: covered by the item 5 correction above. N2: a leading UTF-8 BOM is stripped by the decoder, so the copy and digest would be 3 bytes off (ignoreBOM: true fixes it). N3: EACCES or ENOTDIR inside a skill dir comes out as launch-failed or a raw throw, not skill-refused, though the launch still refuses. N2 and N3 are follow-up issue #1672. N4: the cli and webui fixture edits are mechanical. N5: past this row, coder, reviewer, cto and pm launches refuse until all eight listed skills are active in the data root. Live has only ms-communications. Any re-pin past row 64 activates them first (sage-1064 R4).
  • Landing prep (sage-1065): cherry-pick 9f0039711 of c6d7d538f on 1a01f63ea, range-diff =, patch-id 7e1948e2f2e9 equal. F1 doc commit 6b6567b8b. landing-manifest.sha256 is the same 28 paths at 6b6567b8b; 27 match the candidate manifest and the README differs by F1 only. Nothing goes onto refactor before Mos's go.

Landing (mos-0863)

Mos gave the go for steps 1, 2, 3 and 5 in mos-0863. Step 4, the live activation, is now a window step (D3, agents/sage/work/window-plan/carry-past-row64.md). N2 and N3 are #1672, with its own row after 173 (D4). D5: the item 5 correction above is the N1 text fix, in this packet commit.

The landing gate ran gate-land.sh on a clean worktree at 6b6567b8b, with landing-manifest.sha256 checked before any suite (gate-land-run1.txt here, logs in /mnt/storage/scratch/sage-r64/out-land/). 9f0039711 and 6b6567b8b go onto refactor by fast-forward from 1a01f63ea, so the landed shas are the gated ones.