docs(pm): the isolated review subagent writes under a round-unique scratch path and never trusts a file it did not write this turn (#18061, half A) - #18094
Conversation
The in-session review subagent's order in `references/contract-review.md` said nothing about where it writes its scratch files. One session's concurrently dispatched subagents share ONE flat scratchpad directory, so generically-named files (`pr.json`, `card.json`, …) written minutes apart by sibling rounds overwrite each other silently and well-formed — a reviewer re-reading such a file reports a confident verdict about the wrong diff. Add one rule line to that order: every scratch file goes under a round-unique directory named by what is being reviewed (`<scratchpad>/pr-<n>/`, the shape `.claude/agents/os-dev.md` already mandates for the dev side as `issue-<n>/`), and never read a scratch file you did not write yourself in the same turn. The file is at its `check:pm-skill-ratchet` ceiling (60/60, headroom 0), so the line is paid for by a density merge of the two adjacent per-item lines ② and ③ of the review record; every clause of both survives. Claude-Session: https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewHead: ① derived judgments (seat-measured on the fetched head, ⛔ not taken from the report):
② semver: unchanged — nothing published; ③ boundary flags: none binding. Two readings for the seat, not the tree: (a) the dev measured this session's scratchpad live — 499 flat entries, and Implemented-by: Verdict: PASS — ready + auto-merge by this seat. Generated by Claude Code |
Fixes #18061
Half A only. Half B (the
check-reference-carrier-shapeblind spot) is excluded per triage 5657095122 — see Acceptance notes. Nothing here measures, asserts or touches it.What the card measured
One session's concurrently dispatched subagents share ONE flat scratchpad directory. Generically-named files —
pr.json,card.json, thecard_comments.json/card-comments.jsonpair — written minutes apart by sibling rounds overwrite each other silently and well-formed: a reviewer that re-readpr.jsonwould have reported a confident verdict about the wrong diff. The card corrects its own reporting reviewer: cross-session isolation held (exactly one session directory exists under the scratchpad root), so the scope is session-specific but not agent-specific — a seat collides with its own siblings.The change — one rule line, in the review subagent's order
.claude/skills/pm-dispatch/references/contract-review.md, immediately after the two lines that already spell that order out (when to spin an isolated reviewer up; what to feed it):Two clauses, both from the triage's
⛔ Scope for half A:.claude/agents/os-dev.mdalready mandates dev-side asissue-N/, applied to the PR;SCRATCHPAD/pr-N/in this body only. The file itself carries the repo's ordinary angle-bracket placeholder spelling, and every quotation below respells it the same way (issue-N/,batch:N,--test PATHS); angle-bracket fragments do not survive this surface intact (AGENTS.md, GitHub mutates body BYTES).Paying for the line — the file was at its ceiling
check:pm-skill-ratchetbefore the change, 2026-09-14T01:52Z, atf27e86b08:Headroom 0, so the line is paid for by a density merge, not by deleting a rule. The two adjacent per-item lines ② and ③ of the review record became one:
Every clause survives. ② keeps
semver 定级, and变更级别is the referent of定级— the grading IS the change level, so the agreement clause against the changeset declaration is intact. ③ loses only the head noun处置, whose content is the body that follows it (逐旗答复或升级). ⛔ No rule is dropped, and no wrap is counted as a line.check:pm-skill-ratchetafter the change, 2026-09-14T02:03Z, at7ded5bad7— exit 0:Widest line in the file is unchanged at 120 bytes (the 120-byte per-line rule the ratchet enforces for non-table lines); the new rule line measures 118 bytes and the merged line 115.
The concurrency-budget half of the subject already landed
The triage notes that a path scheme alone does not address the lock starvation the same wave caused. ⛔ No budget rule is added here, because two already exist and are quoted below as they stand today at
f27e86b08(the angle-bracket placeholder in the first is spelledNfor this surface only):.claude/skills/pm-dispatch/SKILL.md:60—|batch:N| 同时在飞的 dev 上限 | 默认2;n的维护者天花板5|.claude/skills/pm-dispatch/SKILL.md:436—- 第 N 单派发前读scripts/pm/os-verify-lock.sh --status:到达深度 ≥LOCK_DEPTH_HOLD(= 2)即等。(
references/core-rules.md:11carries the same pair in one line.) Both landed in7ef05f997as write-identity lock 3.Premise readings
Taken in the worktree at
f27e86b08before the first edit.P1 — the dev half of the remedy is already landed. TRUE. 2026-09-14T01:50:36Z,
sed -n '30,35p' .claude/agents/os-dev.md:⇒ ⛔ os-dev.md is not touched by this PR.
P2 — the colliding rounds had no rule to follow. TRUE. Same timestamp,
grep -c -i scratch:The lit control rules out a broken matcher. And the runbook is not where the review subagent's order is templated: its only mention of this review points away, at
:161—无主阻塞项的契约面(含packages/spec)走contract-review.md独立性件的隔离达档复核— and its## 派发词构造细则section templates the dev dispatch word, not the reviewer's brief. ⇒ ⛔dispatch-runbook.mdis not touched either;contract-review.mdis the whole surface.P3 — the file is at its ceiling. TRUE. 2026-09-14T01:52Z; the two ratchet rows quoted above, headroom 0 on both. ⇒ the density merge is mandatory, not stylistic.
Verification
Gate families — derived in the worktree, run with exits captured by redirect-then-capture (⛔ never through a pipe), recorded and reconciled:
node scripts/pm/dispatch-gates.mjs --ran /tmp/ran-18061.txtat7ded5bad7:All 16 commands (the 15 derived plus
pnpm check:pm-governed-prose, recorded as outside the derivation) exited 0, includingcheck:pm-skill-ratchet,check:skill-frame-sync,check:pm-governed-merges,check:pm-skill-id-lint,check:nul-bytes,check:doc-authoring.pnpm --filter @objectstack/lint run check:doc-formula-expressionsfirst exited 3 —PREREQUISITE NOT MET, its own text saying "Nothing was measured … It is NOT a finding". That is NOT MEASURED, ⛔ not a failure. Its prescribed fix (turbo run build --filter=@objectstack/formula --filter=@objectstack/lint) was run under the shared verify lock (os-verify-lock: VERDICT command-exit 0 · held the lock 2s · waited 0s), after which the gate exited 0.Governed-surface tier.
node scripts/pm/check-governed-merges.mjs --test .claude/skills/pm-dispatch/references/contract-review.md— exit 3 (the deliberate GOVERNED code, ⛔ not a finding):Landing tier, read from
scripts/pm/check-governed-queue-guard.mjs's own exported predicate:--test PATHSpredicate lives oncheck-governed-merges.mjs, ⛔ not oncheck-governed-queue-guard.mjs— the latter readsGITHUB_EVENT_PATHand exits 1 with "could not look must never exit 0 here" when given a path. The tier above is computed from its exportedgovernedTierFor, which is the same decision.Lint — a proven narrowing, not a skipped run. The repo-wide
pnpm lintis CI's run. The narrowing here is total, and all three readings are present:ESLint#isPathIgnored('.claude/skills/pm-dispatch/references/contract-review.md')→true; lit controlisPathIgnored('scripts/pm/check-skill-line-ratchet.mjs')→false.--format json:eslint THAT-FILE --no-inline-config --format json→ exit 0,errorCount: 0, one warning reading "File ignored because no matching configuration was supplied." — zero lintable files in this diff.Control characters.
grep -naPover the changed file for the C0/DEL set: no hits (exit 1).pnpm check:nul-bytesexit 0.Changeset: none owed, and the label is the declaration.
changeset-check(.github/workflows/pr-automation.yml) declares exactly two exemptions — theskip-changesetlabel, and the changesets release PR pinned by branch and author. There is ⛔ no path exemption, so.claude/**earns no automatic pass: the label IS the declaration, and it is applied on this PR. The substantive test is satisfied independently — nothing published moves,.claude/**ships in no package'sfiles[].Acceptance notes
packages/lint/scripts/check-reference-carrier-shape.mjs, and this PR's closing keyword is written as instructed by the dispatching seat.batchdefault2and theLOCK_DEPTH_HOLD(= 2) arrival-depth wait, both from7ef05f997.check-governed-queue-guard.mjs --test PATHS; that flag is oncheck-governed-merges.mjs. A tooling-usage note for the dispatching seat, ⛔ not a repo defect — neither script is wrong. Carrier: this PR's reviewing seat.issue-18061is already taken by a file another round wrote, so the per-issue subdirectory.claude/agents/os-dev.mdmandates could not be created under that name. This round wrote underdev-18061/instead and re-fetched the card body itself rather than trusting the sibling's file. Live confirmation of the finding, ⛔ not a second finding. Carrier: this card.Clause-②: no—.claude/skills/pm-dispatch/references/**is internal dispatch doctrine, not publishedskills/**; the diff makes no falsifiable operator or contract-semantics claim, adds no exported symbol and no key on a published payload.维护者速读(草稿)
改了什么。 席内契约复核细则加一条:隔离复核子代理的临时文件必须写进按所审 PR 命名的独立目录,且不读本轮不是自己写的临时文件。文件正好卡在 60 行天花板上,这一行由把复核记录 ②③ 两条并成一条来支付——两条的每个子句都还在,⛔ 没有删规则。
为什么改。 一个席位同时派五个复核子代理时,它们共用同一个扁平暂存目录,
pr.json这种通名文件被兄弟轮次静默覆盖。失效形态是静默且格式完好:复核者拿到的是另一个 PR 的 payload,却会给出一个自信的裁决——受管面复核上的「自信的错答案」,⛔ 不是工具小毛病。跨会话隔离本身没坏,坏的是同一会话内部。风险与代价(含回滚)。 代价是复核子代理多建一层目录。风险面只有一处:②③ 合并后阅读密度变高,但两条的操作性内容一字未少。回滚 =
git revert单个 commit,受管面无运行时影响,不发布任何包,无 changeset。席位意见。 (留空,待席内定稿)
你要做的。 这是受管面(
.claude/**),按 Prime Directive #14 只能由你手动合并:⛔ 不入队、⛔ 不挂 auto-merge、⛔ 不翻出 draft。落地档位为references。请确认那条新规则的中文措辞与本文件的机读语域一致,以及 ②③ 合并是否可接受。Generated by Claude Code