Repository navigation
Commit c663245
docs(pm-dispatch): pin the shared checkout to a detached origin/main before a subagent batch (#24685)
Fixes #24585
Clause-②: no
## What changed
One section of
`.claude/skills/pm-dispatch/references/dispatch-runbook.md`, the
pre-dispatch step for a subagent batch (lines 137-141). The PM seat no
longer fast-forwards its session branch on the shared checkout. It
fetches `main` and checks out `origin/main` detached:
```text
git -C REPO-ROOT fetch origin main && git -C REPO-ROOT checkout --detach origin/main
```
(`REPO-ROOT` stands for the file's existing placeholder spelling, which
is unchanged in the file.)
- L137, the heading: "先快进本地检出" becomes "先把本地检出钉到 main".
- L139: the old 139 and the reason half of the old 141 merge into one
line. Subagents read agent definitions and skill text from the local
checkout, so a stale checkout gives the whole batch stale definitions.
- L140: the command above replaces `pull --ff-only origin main`.
- L141: states the properties the new form actually has. On a detached
HEAD the stop hook skips its branch block, and the session branch is
never fast-forwarded. Only dirty changes that conflict with the switch
are refused, and divergence is not refused. This replaces the old
promise that `--ff-only` makes "a dirty or diverged checkout fail
loudly". The measurements below show that half of that promise was never
true for dirty changes, and that the new form drops the divergence half.
Ruling of record: the maintainer's 「技能侧可选改法,立skills卡片」, recorded
verbatim in the card body. The carded option is implemented exactly as
written. The two items in the card's "Not in this card" section are
untouched: the hook's own counting, and the stale local ref.
## Line budget
- File: 241 lines before, 241 after. The ceiling is 241
(`check:pm-skill-ratchet`: "is 241 lines (ceiling 241; headroom 0)").
- Lines changed: 4 replaced in place. Nothing was re-wrapped to make
room.
- Bytes per changed line, without the newline, against `MAX_LINE_BYTES =
120`: L137 49 to 57; L139 100 to 113; L140 87 to 111; L141 117 to 120.
- The dispatch's premise quoted L140 and L141 as 88 and 118 bytes. Those
figures include the newline. The gate counts without it, so its numbers
are 87 and 117.
## The three measurements the card asks for
All readings were taken on `origin/main` `eadea5720` (this branch's
base). The git-behaviour probe ran in a throwaway repository under the
session scratchpad: a bare origin, an author clone, and a "shared" clone
on a session branch whose `origin/SESSION-BRANCH` ref was written
locally and never pushed. The probe never touched the shared checkout,
and it was deleted before this PR. The stop hook script itself was read
only: it was not executed and not modified. The probe ran the same git
commands the hook runs.
### 1. Every reader of the shared checkout's branch name
`git grep -nE
"abbrev-ref|show-current|symbolic-ref|symbolic-full-name|@\{u\}|..."
HEAD -- .claude/ scripts/pm/ .githooks/` returns 11 hits. Three of them
read a branch name. The others are prose about detached processes or
clones.
| Reader | What it does with the name | On a detached HEAD |
|---|---|---|
| `.claude/hooks/guard-main-checkout.sh:120` | `rev-parse --abbrev-ref
HEAD`, printed in the refusal text at L132 only. Whether to block is
decided structurally (git-dir vs common-dir, L114-117). | Prints `HEAD`
(probe E). The block decision is unchanged. |
| `.claude/hooks/guard-main-checkout-bash.sh` | Reads no branch name:
zero hits. Same structural git-dir vs common-dir test (L347-350). |
Unaffected. |
| `scripts/pm/os-regen-merge.sh:891` (`mode_run`) | Refuses `main` and
`HEAD`. | Refuses to run on the shared checkout, with exit 1. That is
stricter than today: on the session branch it would proceed. `:729`
writes the name into its record only after `mode_run` passed, so only in
a dev worktree. |
| `scripts/pm/check-harness-current.mjs` | Reads `rev-parse HEAD` as a
sha (L124, L139, L141, L157) and no name. | Unaffected. |
| `.githooks/pre-commit`, `.githooks/pre-push` | Zero branch-name reads.
The seat never commits or pushes from the shared checkout. | Unaffected.
|
### 2. Other fast-forwards in the skill or `os-dev.md` that assume a
branch
`git grep -nE "git pull|pull
--|快进|fast-forward|--ff\b|ff-only|共享检出|shared checkout|本地检出|primary
checkout" HEAD -- .claude/skills/pm-dispatch/ .claude/agents/os-dev.md`
returns 11 hits. The only fast-forward is the one this PR replaces (old
L140-141). The rest are:
- `SKILL.md:31` and `os-dev.md:28` say never edit the shared checkout.
Compatible.
- `reading-discipline.md:8` says do not verify `main` from the shared
checkout's tree. Compatible.
- `seat-lifecycle.md:21` is the harness-current reading, which works by
sha. Compatible.
- `platform-readings.md:59` uses 快进 for squash committer dates.
Unrelated.
Outside the skill, `scripts/check-skill-frame-freshness.mjs:136` prints
the remedy `git fetch origin main && git merge --ff-only origin/main`.
It runs in whatever checkout invokes it; `os-dev.md:151` lists it as a
dev-worktree local family. Probe D: on a detached HEAD, that merge
fast-forwards HEAD with exit 0, and `branch --show-current` stays empty,
so it is compatible.
### 3. The stop hook's control flow, and whether it stays silent across
rounds
Source: `/root/.claude/stop-hook-git-check.sh`, md5
`d36da5d989abb71904a379c7de566b95`, read only.
- L6-24: recursion guard, not a git repo, or no remote, then exit 0.
- L27-37: uncommitted or untracked changes, then exit 2. This runs
before any branch read, so it still fires on a dirty detached checkout.
That is correct, and this change does not alter it.
- L39-40: `current_branch=$(git branch --show-current)`, then `if [[ -n
"$current_branch" ]]`. Everything else lives inside that block: the
signing check (L77-114) and the `$upstream..HEAD` count (L116-124).
- L127: exit 0.
- Reading: on a detached HEAD, `git branch --show-current` prints an
empty string. The whole branch block is skipped and the hook exits 0.
The card's claim is **confirmed**.
Probe readings, using the hook's own commands:
| Step | `branch --show-current` | Hook branch block | `rev-list
origin/BRANCH..HEAD` | `HEAD ^origin/main` |
|---|---|---|---|---|
| A1, old recipe, after 3 commits land on main | `claude/sess` | entered
| **3** (the reported symptom) | 0 |
| A2, new recipe, round 1 | empty | skipped | n/a | 0 |
| A3, new recipe, round 2 | empty | skipped | n/a | 0 |
| A4, new recipe, round 3 | empty | skipped | n/a | 0 |
After A2 the session branch ref is still present and unmoved. **NOT
MEASURED:** a live seat session's stop hook across several real dispatch
rounds. That needs this text landed and a seat running it, and the hook
belongs to the harness, which this run must not execute. The seat can
confirm it on its first rounds after landing.
Live reading of the shared checkout at this PR's open (read only): it is
on `claude/eloquent-curie-6slepw` at `3a931fca1`. Its remote branch
exists at the same sha, a zero-diff mirror of `main` pushed by the seat.
This is the cost the card names. `origin/main` is already 2 commits
ahead, so the next fast-forward under the old text would show 2
"unpushed" commits.
### The refusal surface, old vs new (probe B and C)
| Case | `pull --ff-only` (old) | `checkout --detach origin/main` (new)
|
|---|---|---|
| B1: dirty tracked file the incoming commits do not touch | exit 0,
change carried along | exit 0, change carried along (`M b.txt`) |
| B2: dirty tracked file the incoming commits touch | exit 1, "would be
overwritten by merge" | exit 1, "would be overwritten by checkout" |
| B3: untracked file colliding with an incoming file | not run | exit 1,
"untracked working tree files would be overwritten" |
| C1: local commit on the session branch | exit 128, "Not possible to
fast-forward" | exit 0; the commit stays on the session branch |
| C2: local commit on the detached HEAD | not applicable | exit 0 with
"you are leaving 1 commit behind"; the commit is reachable from no ref |
So the old line's "dirty fails loudly" held only for B2 under either
form. Its "diverged fails loudly" is lost: C1 and C2 both exit 0. The
new L141 says this ("不拒分叉") instead of carrying the old promise forward.
Divergence on the shared checkout already breaks the never-edit rule
(`SKILL.md:31`, plus the two PreToolUse guards). In C1 the commit
survives on its branch; in C2 it survives only in the reflog.
## Gates
All 17 derived families exit 0 at `ee7b8181e` (`node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, change set derived from git: 1 path,
+4/-4). Each exit code was captured before any pipe:
- `node scripts/check-closing-keyword-parity.mjs`: 0
- `node scripts/check-closing-keyword-parity.mjs --self-test`: 0
- `node scripts/check-comment-mask-corpus.mjs`: 0
- `node scripts/pm/check-harness-current.mjs --self-test`: 0
- `pnpm --filter @objectstack/lint run check:doc-formula-expressions`:
the first attempt exited 3 (PREREQUISITE NOT MET: formula and lint not
built), so nothing was measured. After `turbo run build
--filter=@objectstack/formula --filter=@objectstack/lint` under the
verify lock, it exits 0.
- `pnpm check:agent-test-spelling`: 0
- `pnpm check:cross-package-test-inputs`: 0
- `pnpm check:doc-authoring`: 0
- `pnpm check:driver-memory-census`: 0
- `pnpm check:gitlink-declared`: 0
- `pnpm check:nul-bytes`: 0
- `pnpm check:pm-governed-merges`: 0
- `pnpm check:pm-skill-id-lint`: 0 ("87 file(s) clean")
- `pnpm check:pm-skill-ratchet`: 0 ("dispatch-runbook.md is 241 lines
(ceiling 241; headroom 0)"; the 120-byte line check prints only on
failure)
- `pnpm check:refd-timer-probe`: 0
- `pnpm check:skill-frame-sync`: 0
- `pnpm check:watch-hint-literal`: 0
Reconciliation with `--ran` (each line annotated with its exit code):
"17 derived, 17 run, 0 NOT-MEASURED, 0 UNRUN", and "a DERIVED zero — all
17 recorded an exit code and none of them is 3".
No package is touched, so there is no dependency-closure build and no
package test or typecheck. `.claude/**` publishes nothing, so this PR
carries `skip-changeset`.
## Acceptance notes
- The header of `scripts/pm/check-harness-current.mjs` says "the primary
checkout is not advanced in place (worktree-first)". The runbook has
advanced it in place before every batch, both before this PR
(fast-forward) and after it (detached checkout). This is comment drift
and nothing reads it. Carrier: none.
- `os-regen-merge.sh` will now refuse on the shared checkout (`HEAD`),
where before it would have run on the session branch. This is a side
effect in the safe direction, noted only.
- One instance of the card's cost, taken from the dispatch as an example
and not as a ruling: the environment pre-created
`origin/claude/eloquent-curie-6slepw`, and the seat pushed the shared
checkout there twice as a zero-diff mirror of `main`.
## 维护者速读(草稿)
**改了什么** —— PM 席位每批派发子代理前,不再在会话分支上快进本地检出,改为取回 main 后以游离 HEAD 钉在
`origin/main`。只改技能手册的一节,4 行原位替换,行数不变。
**为什么改** —— 云端停止钩子按会话分支计未推送提交。快进让 main 的提交挂到会话分支上,钩子每轮提示推送。照做会推出与 main
零差异的分支,本席位已推过两次。游离 HEAD 下钩子跳过分支检查,提示消失。子代理读到的仍是最新定义。
**风险与代价(含回滚)** ——
新写法不再拒绝分叉,共享检出上若有本地提交会被丢下或留在原分支。但共享检出本就禁止编辑,有两道钩子把守。与切换冲突的脏改动仍被拒。回滚即还原这
4 行。
**席位意见** ——
**你要做的** ——
---
_Generated by [Claude
Code](https://claude.ai/code/session_01HEdP5tPhqQfzwRmKDxwn85)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent 3b8e2b1 commit c663245
1 file changed
Lines changed: 4 additions & 4 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
134 | 134 | | |
135 | 135 | | |
136 | 136 | | |
137 | | - | |
| 137 | + | |
138 | 138 | | |
139 | | - | |
140 | | - | |
141 | | - | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
142 | 142 | | |
143 | 143 | | |
144 | 144 | | |
| |||
0 commit comments