session-orchestrator 5.4.0 → 5.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/060-evolve.mdc +5 -5
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +115 -3
- package/README.md +30 -15
- package/docs/ci-setup.md +120 -23
- package/docs/components.md +7 -7
- package/docs/events-schema.md +17 -14
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/scope-collision-guard.md +19 -15
- package/docs/session-config-reference.md +7 -7
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +17 -8
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +4 -2
- package/hooks/on-stop.mjs +77 -171
- package/hooks/post-bash-write-verify.mjs +219 -24
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +41 -156
- package/hooks/pre-bash-memory-propose-audit.mjs +28 -11
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-task-scope-disjoint.mjs +603 -241
- package/hooks/wave-scope-commit-guard.mjs +235 -73
- package/package.json +9 -8
- package/scripts/apply-session-learnings.mjs +35 -14
- package/scripts/archive-closed-prds.mjs +38 -11
- package/scripts/autopilot.mjs +8 -1
- package/scripts/backfill-abandoned-sessions.mjs +80 -5
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +46 -2
- package/scripts/emit-session.mjs +5 -3
- package/scripts/export-hw-learnings.mjs +18 -7
- package/scripts/fleet-checkin.mjs +26 -3
- package/scripts/lib/autopilot/loop.mjs +25 -10
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config.mjs +5 -1
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +51 -3
- package/scripts/lib/fleet-protocol.mjs +20 -2
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +8 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
- package/scripts/lib/learnings/io.mjs +125 -16
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/named-vault-resolver.mjs +48 -5
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
- package/scripts/lib/orphan-reaper/scan.mjs +4 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +5 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +83 -22
- package/scripts/lib/scope-baseline.mjs +116 -25
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +201 -27
- package/scripts/lib/session-close-backfill.mjs +202 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-registry.mjs +41 -16
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +39 -34
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +5 -2
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +77 -2
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
- package/scripts/lib/vault-status/board-writer.mjs +5 -1
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/release.mjs +1 -0
- package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
- package/scripts/run-quality-gate.mjs +8 -5
- package/scripts/self-update.mjs +229 -0
- package/scripts/sweep-expired-learnings.mjs +560 -28
- package/scripts/validate-wave-scope.mjs +60 -6
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-mirror.mjs +170 -33
- package/skills/_shared/fleet-protocol.md +74 -71
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/_shared/state-ownership.md +38 -14
- package/skills/autopilot/SKILL.md +1 -1
- package/skills/bootstrap/SKILL.md +5 -3
- package/skills/bootstrap/_shared-template.md +96 -1
- package/skills/bootstrap/fast-template.md +3 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +11 -2
- package/skills/evolve/references/evolve-analyze-mode.md +37 -17
- package/skills/session-end/learning-patterns.md +3 -3
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/session-metrics-write.md +45 -10
- package/skills/session-start/SKILL.md +3 -3
- package/skills/session-start/references/operations-contract.md +5 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +2 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +14 -6
- package/.agents/skills/navigator/SKILL.md +0 -22
- package/.agents/skills/navigator/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/navigator/SKILL.md +0 -22
- package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
- package/.cursor/commands/navigator.md +0 -15
- package/.cursor/skills/navigator/SKILL.md +0 -13
- package/pi/prompts/navigator.md +0 -12
- package/skills/navigator/SKILL.md +0 -193
- package/skills/navigator/references/account-check.sh +0 -87
- package/skills/navigator/references/fleet-decisions.mjs +0 -231
- package/skills/navigator/references/slot-check.sh +0 -114
|
@@ -114,10 +114,14 @@
|
|
|
114
114
|
*
|
|
115
115
|
* A deny-capable hook on the DISPATCH path has an asymmetric blast radius: a
|
|
116
116
|
* false positive blocks every agent of the session (the guard becomes a session
|
|
117
|
-
* outage), while a false negative is a double-assignment
|
|
118
|
-
* still catch (`validate-wave-scope.mjs`,
|
|
119
|
-
* the W5 verification pass).
|
|
120
|
-
*
|
|
117
|
+
* outage), while a false negative of the COLLISION check is a double-assignment
|
|
118
|
+
* that three later gates still catch (`validate-wave-scope.mjs`,
|
|
119
|
+
* `enforce-scope.mjs` at write time, and the W5 verification pass). The #1485
|
|
120
|
+
* stale-base check (row 15) has no such later gate — a missed stale base is an
|
|
121
|
+
* agent editing old code — so it is kept fail-open by a different rule: it
|
|
122
|
+
* denies only on a MEASUREMENT, and every unknown is ALLOW plus a record.
|
|
123
|
+
* Fail-closed is right for a WRITE guard; it is wrong here. Each row below is a
|
|
124
|
+
* deliberate choice, not an oversight:
|
|
121
125
|
*
|
|
122
126
|
* | # | Condition | Decision | Why |
|
|
123
127
|
* |---|----------------------------------------|---------------------|-----|
|
|
@@ -128,18 +132,21 @@
|
|
|
128
132
|
* | 5 | prompt carries no scope marker | ALLOW + COUNT | 105 of 147 real prompts (71.4 %) have none. Non-extractable ≠ violation; denying these would deny 7 dispatches in 10. #1092: the allow now carries a counter-only record, so it is no longer byte-identical to "the guard never ran" |
|
|
129
133
|
* | 6 | scope block present but unparseable | ALLOW + COUNT | same reason as 5 — the parser is the fragile part, so its failures must resolve to the harmless side. Counted under a DISTINCT class from row 5 |
|
|
130
134
|
* | 7 | ledger unreadable / corrupt | WARN + ALLOW + SELF-HEAL | loss of state is not evidence of a violation; loud so it gets noticed. The verdict now CARRIES a fresh ledger, so the corruption is repaired on the spot — without it the guard stayed OFF for the whole remaining wave, visible only in one `systemMessage` |
|
|
131
|
-
* | 8 | `git ls-files` failed
|
|
135
|
+
* | 8 | `git ls-files` failed, or the shared git budget (`GIT_BUDGET_MS`) ran out | ALLOW (degraded) + `known_files_skipped` in the record | glob-vs-glob expansion degrades, concrete collisions are still found. A git outage is not a scope violation. #1489 Pkt 7: a HANGING git used to outlive the 5 s harness timeout, which killed the hook — the verdict lost with no record |
|
|
132
136
|
* | 9 | `findScopeCollisions` → not evaluable | WARN + ALLOW | the library says "not evaluable". Denying on a verdict with no witness is an assertion without evidence |
|
|
133
137
|
* |10 | same agent id re-dispatched | ALLOW; predecessor pruned only when FINISHED | a retry after a failed agent is legitimate (a failed agent is finished), so the guard must not self-lock. #1480 B4: an unfinished predecessor is KEPT as a claim — a third agent hitting only its scope still collides. With distinct `tool_use_id`s on both it is an ordinary partner (row 11); without them it never collides with its own retry. Replaced WITHOUT a probe only where no claim is lost: the same `tool_use_id` (the same dispatch evaluated again), or — neither carrying an id — a predecessor wholly covered by the retry's scope |
|
|
134
138
|
* |10a| collision, but EVERY colliding prior agent has FINISHED (positive evidence) | ALLOW (+ prune) | the sequential repair pass. Its ledger records are pruned, so the state cannot re-block the next one either |
|
|
135
|
-
* |11 | collision with a prior agent still IN FLIGHT | **DENY** | the
|
|
139
|
+
* |11 | collision with a prior agent still IN FLIGHT | **DENY** | the case this hook was built for (#1020) |
|
|
136
140
|
* |12 | unexpected throw | ALLOW + stderr | as row 2 |
|
|
137
141
|
* |13 | liveness probe throws / no positive evidence (no record, unknown result form) | treat as IN FLIGHT | keeps row 11 biting; the blind case is bounded by `IN_FLIGHT_TTL_MS`, never unbounded. A dispatch re-opened by a SendMessage with no completion after it is in flight outright, without TTL |
|
|
138
142
|
* |14 | ledger lock not acquirable in `LEDGER_LOCK_TIMEOUT_MS` | run UNLOCKED (degraded) | the lock removes the read-modify-write race (below); failing to take it must not deny, so the cycle degrades to the pre-lock behaviour |
|
|
143
|
+
* |15 | `isolation: "worktree"`, `worktree.baseRef` not `"head"`, `merge-base --is-ancestor HEAD origin/HEAD` exits 1 in a full-history repo whose FETCH_HEAD is < 24 h old | **DENY**, no ledger write | #1485: the agent would branch from a base missing commits of HEAD and edit OLD code. Every non-measurement — no origin/HEAD, shallow, `stale-remote-ref`, git error, git budget spent (`git-budget`) — is ALLOW + a `skipped` record. No ledger write, so the in-place re-dispatch the deny advises meets no phantom claim. A collision DENY (row 11) wins the terminal emit; this one then goes to stderr. Otherwise the `scope_checked` record carries `ledger_result: 'deny-stale-base'`, never the collision verdict's allow (#1489 Pkt 8) |
|
|
139
144
|
*
|
|
140
145
|
* Every row that reaches a verdict from `decide()` — 5–11 and 13–14 — also
|
|
141
146
|
* leaves an event record (§ Observability), so "which row fired" is answerable
|
|
142
|
-
* after the fact and not only in the moment.
|
|
147
|
+
* after the fact and not only in the moment. Row 15 is decided outside
|
|
148
|
+
* `decide()` and leaves its own `worktree_base_checked` record for EVERY
|
|
149
|
+
* worktree dispatch, allow and skip included. Rows 1–4 emit nothing (no decision
|
|
143
150
|
* was made), and neither do the two crash rows 2 and 12: a hook that fell over
|
|
144
151
|
* cannot describe itself, which is precisely why the GUARD INACTIVE banner on
|
|
145
152
|
* stderr is the signal there.
|
|
@@ -165,13 +172,21 @@
|
|
|
165
172
|
* ## Observability — the ledger half of #1092
|
|
166
173
|
*
|
|
167
174
|
* Every dispatch DECISION also appends one `orchestrator.wave_dispatch.scope_checked`
|
|
168
|
-
* record to `<
|
|
175
|
+
* record to `<session root>/.orchestrator/metrics/events.jsonl` — the root
|
|
176
|
+
* `resolveSessionRoot()` resolves (`scripts/lib/platform.mjs`: the git toplevel
|
|
177
|
+
* of the payload `cwd` — its nearest `.git` ancestor when git cannot answer —
|
|
178
|
+
* but never above a launch dir inside it; so neither the subdirectory a `cd`
|
|
179
|
+
* moved to, #1489 Pkt 6, nor the launch dir a worktree session left), which is
|
|
180
|
+
* also where the ledger and its lock live. The reason it
|
|
169
181
|
* exists is matrix rows 5/6: the no-signal ALLOW used to be byte-identical to
|
|
170
182
|
* "the guard never ran", and the in-ledger counter added first is a WAVE tally —
|
|
171
183
|
* it cannot say WHICH dispatch carried a scope. Payload: `wave` (omitted, never
|
|
172
184
|
* `0`, when unknown), `agent_id`, `declared_path_count`, `injected`, `shape`,
|
|
173
|
-
* `signal`, `ledger_result`, `collision_count`, `hook`,
|
|
174
|
-
*
|
|
185
|
+
* `signal`, `ledger_result`, `collision_count`, `hook`, `known_files_skipped`
|
|
186
|
+
* (only when the tracked-file listing degraded, matrix row 8), `collision_result`
|
|
187
|
+
* (only beside `ledger_result: 'deny-stale-base'` — the collision verdict it
|
|
188
|
+
* replaced), plus `session_id` / `semantic_session_id` when a session lock is
|
|
189
|
+
* readable.
|
|
175
190
|
*
|
|
176
191
|
* WHAT IT PROVES: this hook SAW (or did not see) a `FILE-SCOPE` declaration in
|
|
177
192
|
* the prompt the coordinator handed to the dispatch tool, and what the guard
|
|
@@ -229,6 +244,7 @@ import path from 'node:path';
|
|
|
229
244
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
230
245
|
import { execFileSync } from 'node:child_process';
|
|
231
246
|
import { mkdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
247
|
+
import { homedir } from 'node:os';
|
|
232
248
|
|
|
233
249
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
234
250
|
|
|
@@ -249,7 +265,12 @@ import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
|
249
265
|
/** @type {typeof import('../scripts/lib/io.mjs').writeJsonAtomicSync} */ let writeJsonAtomicSync;
|
|
250
266
|
/** @type {typeof import('../scripts/lib/file-lock.mjs').withFileLock} */ let withFileLock;
|
|
251
267
|
/** @type {typeof import('../scripts/lib/scope-echo.mjs').scopeDigest} */ let scopeDigest;
|
|
268
|
+
/** @type {typeof import('../scripts/lib/platform.mjs').resolveSessionRoot} */ let resolveSessionRoot;
|
|
252
269
|
let findScopeCollisions;
|
|
270
|
+
/** @type {typeof import('../scripts/lib/scope-gate.mjs').scopeEntrySpelling} */ let scopeEntrySpelling;
|
|
271
|
+
let findOwnScopeFile;
|
|
272
|
+
let readProcessLocalSessionIds;
|
|
273
|
+
let classifyManifestSession;
|
|
253
274
|
|
|
254
275
|
const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
|
|
255
276
|
|
|
@@ -263,11 +284,43 @@ const HOOK_NAME = 'pre-task-scope-disjoint';
|
|
|
263
284
|
*/
|
|
264
285
|
const DISPATCH_TOOL = 'Agent';
|
|
265
286
|
|
|
266
|
-
/**
|
|
267
|
-
|
|
287
|
+
/**
|
|
288
|
+
* Ledger location (#1493.3): ONE FILE PER SESSION,
|
|
289
|
+
* `.orchestrator/wave-dispatch-scopes.<session>.json`, each with its own lock
|
|
290
|
+
* `.orchestrator/wave-dispatch-scopes.<session>.lock` (§ Ledger concurrency).
|
|
291
|
+
*
|
|
292
|
+
* Until #1493 every session of the working copy shared ONE file holding ONE
|
|
293
|
+
* `waveKey`, and `decide()` keeps prior agents only under the same key — whose
|
|
294
|
+
* first segment IS the session id. So cross-session claims were never compared
|
|
295
|
+
* (nothing is lost by splitting), but a peer session's dispatch reset the file
|
|
296
|
+
* and erased this session's live claims, and the deny's "delete the ledger"
|
|
297
|
+
* remedy erased the peer's. One file per session removes both.
|
|
298
|
+
*
|
|
299
|
+
* A flat sibling, not a directory, on purpose: bootstrap's `store-lock-ignore`
|
|
300
|
+
* step (#1495) ignores `.orchestrator/wave-dispatch-scopes.*` in consumer repos,
|
|
301
|
+
* which covers this name; a directory would surface untracked in each of them.
|
|
302
|
+
*/
|
|
303
|
+
const LEDGER_DIR_REL = '.orchestrator';
|
|
304
|
+
const LEDGER_STEM = 'wave-dispatch-scopes';
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The pre-#1493 single shared ledger. READ only, for migration: adopted when
|
|
308
|
+
* this session has no file yet AND its `waveKey` names this session; never
|
|
309
|
+
* written and never deleted — it may still be a peer's live state.
|
|
310
|
+
* Revisit-Trigger: remove this read one minor release after the split ships,
|
|
311
|
+
* when no session that recorded claims in the old file can still be running.
|
|
312
|
+
*/
|
|
313
|
+
const LEGACY_LEDGER_REL = path.join(LEDGER_DIR_REL, `${LEDGER_STEM}.json`);
|
|
268
314
|
|
|
269
|
-
/**
|
|
270
|
-
|
|
315
|
+
/**
|
|
316
|
+
* Longest session token a ledger file name carries. Harness session ids are
|
|
317
|
+
* UUIDs (36 chars) and pass through unchanged; the cap only keeps a pathological
|
|
318
|
+
* id below the 255-byte file-name limit. Two ids that sanitise to ONE token
|
|
319
|
+
* share a file again — degraded to the pre-#1493 behaviour, never worse, since
|
|
320
|
+
* `waveKey` still carries the raw id. Revisit if a harness ships ids that are
|
|
321
|
+
* not path-safe.
|
|
322
|
+
*/
|
|
323
|
+
const MAX_SESSION_TOKEN_CHARS = 128;
|
|
271
324
|
|
|
272
325
|
/**
|
|
273
326
|
* Ledger-lock budget. Short on purpose: the whole locked region is a read, a
|
|
@@ -278,6 +331,28 @@ const LEDGER_LOCK_REL = path.join('.orchestrator', 'wave-dispatch-scopes.lock');
|
|
|
278
331
|
const LEDGER_LOCK_TIMEOUT_MS = 2000;
|
|
279
332
|
const LEDGER_LOCK_POLL_MS = 25;
|
|
280
333
|
|
|
334
|
+
/**
|
|
335
|
+
* ONE deadline for ALL git work of one hook fire (#1489 Pkt 7). Every spawn
|
|
336
|
+
* gets at most what is LEFT of it, so the git work as a whole — never a single
|
|
337
|
+
* call — is what is bounded.
|
|
338
|
+
*
|
|
339
|
+
* The number is arithmetic, not taste: `hooks/hooks.json` gives this hook
|
|
340
|
+
* `"timeout": 5` (seconds) for the WHOLE process. The git work runs first; after
|
|
341
|
+
* it the ledger lock may wait up to `LEDGER_LOCK_TIMEOUT_MS` (2000). 5000 − 2000
|
|
342
|
+
* − 1500 leaves 1500 ms for node start, the late-bound imports, the transcript
|
|
343
|
+
* scan (≤ ~210 ms at the largest measured 70 MB) and the two awaited event
|
|
344
|
+
* appends — ~20× the 69 ms full path measured idle (§ Measured cost). The git
|
|
345
|
+
* work itself measured 27.8 ms together with lock, ledger and compare, so 1500
|
|
346
|
+
* binds only a git that hangs (a stuck lock file, a network-backed `HEAD`, a
|
|
347
|
+
* frozen filesystem). Before, each worktree call had its own 5 s and
|
|
348
|
+
* `listTrackedFiles` none: one hanging git outlived the harness, which killed
|
|
349
|
+
* the hook — a silent fail-open with no record of the lost verdict.
|
|
350
|
+
*
|
|
351
|
+
* Revisit-Trigger: a change of this hook's `timeout` in `hooks/hooks.json` or of
|
|
352
|
+
* `LEDGER_LOCK_TIMEOUT_MS` — the three numbers must keep summing below 5000.
|
|
353
|
+
*/
|
|
354
|
+
const GIT_BUDGET_MS = 1500;
|
|
355
|
+
|
|
281
356
|
/** Payload bounds — see § stdout discipline. */
|
|
282
357
|
const MAX_REPORTED_COLLISIONS = 5;
|
|
283
358
|
const MAX_EVIDENCE_PER_COLLISION = 4;
|
|
@@ -307,14 +382,14 @@ const SHAPE_NONE = 'none';
|
|
|
307
382
|
const SCOPE_EVENT = 'orchestrator.wave_dispatch.scope_checked';
|
|
308
383
|
|
|
309
384
|
/**
|
|
310
|
-
* The worktree-base observability record (#1413).
|
|
385
|
+
* The worktree-base observability record (#1413; carries the #1485 `decision`).
|
|
311
386
|
*
|
|
312
387
|
* Same `wave_dispatch` domain and same `_checked` verb as `SCOPE_EVENT`, and
|
|
313
388
|
* written for EVERY `isolation: "worktree"` dispatch rather than only the
|
|
314
389
|
* alarming one. That is HR-105 applied at the source: a numerator-only stream
|
|
315
|
-
* cannot tell "genuinely rare" from "silently broken", and the firing rate
|
|
316
|
-
*
|
|
317
|
-
*
|
|
390
|
+
* cannot tell "genuinely rare" from "silently broken", and the firing rate of
|
|
391
|
+
* the #1485 deny is then unfalsifiable. With every outcome in one stream the
|
|
392
|
+
* rate is `decision:"deny" / all records of this name`.
|
|
318
393
|
*
|
|
319
394
|
* #1424 widened "every outcome" from two to three: the `stale: true|false`
|
|
320
395
|
* split was already covered, but each NON-measurement (peer STATE.md, no
|
|
@@ -326,9 +401,6 @@ const SCOPE_EVENT = 'orchestrator.wave_dispatch.scope_checked';
|
|
|
326
401
|
*/
|
|
327
402
|
const WORKTREE_BASE_EVENT = 'orchestrator.wave_dispatch.worktree_base_checked';
|
|
328
403
|
|
|
329
|
-
/** State-dir candidates, in the same order `waveKeyOf()` probes them. */
|
|
330
|
-
const STATE_DIR_CANDIDATES = ['.pi', '.cursor', '.codex', '.claude'];
|
|
331
|
-
|
|
332
404
|
/**
|
|
333
405
|
* The receive-side instruction line `renderScopeEchoInstruction()` renders and
|
|
334
406
|
* the coordinator appends immediately after the fenced block
|
|
@@ -486,6 +558,7 @@ async function bootstrap() {
|
|
|
486
558
|
{
|
|
487
559
|
io: { specifier: lib('io.mjs') },
|
|
488
560
|
scopeGate: { specifier: lib('scope-gate.mjs') },
|
|
561
|
+
sessionIdentity: { specifier: lib('session-identity', 'own-session.mjs') },
|
|
489
562
|
fileLock: { specifier: lib('file-lock.mjs') },
|
|
490
563
|
// ONE normalization for the digest, shared with the receive side (#1092):
|
|
491
564
|
// `scope-echo.mjs` is pure (stdlib + `crypto-digest-utils.mjs`) and its
|
|
@@ -495,6 +568,10 @@ async function bootstrap() {
|
|
|
495
568
|
// names. Late-bound like every other repo module so a load failure
|
|
496
569
|
// banners instead of disarming the guard silently (#993).
|
|
497
570
|
scopeEcho: { specifier: lib('scope-echo.mjs') },
|
|
571
|
+
// #1492: ONE session-root resolver for every hook that reads the
|
|
572
|
+
// session's control files — this ledger, and `wave-scope.json` in
|
|
573
|
+
// enforce-scope / enforce-commands / post-bash-write-verify.
|
|
574
|
+
platform: { specifier: lib('platform.mjs') },
|
|
498
575
|
},
|
|
499
576
|
{
|
|
500
577
|
hookName: HOOK_NAME,
|
|
@@ -505,9 +582,11 @@ async function bootstrap() {
|
|
|
505
582
|
);
|
|
506
583
|
|
|
507
584
|
({ readStdin, emitAllow, emitDeny, emitWarn, writeJsonAtomicSync } = modules.io);
|
|
508
|
-
({ findScopeCollisions } = modules.scopeGate);
|
|
585
|
+
({ findScopeCollisions, findOwnScopeFile, scopeEntrySpelling } = modules.scopeGate);
|
|
586
|
+
({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
|
|
509
587
|
({ withFileLock } = modules.fileLock);
|
|
510
588
|
({ scopeDigest } = modules.scopeEcho);
|
|
589
|
+
({ resolveSessionRoot } = modules.platform);
|
|
511
590
|
}
|
|
512
591
|
|
|
513
592
|
// ---------------------------------------------------------------------------
|
|
@@ -661,36 +740,6 @@ function cleanScopeLine(line) {
|
|
|
661
740
|
.trim();
|
|
662
741
|
}
|
|
663
742
|
|
|
664
|
-
/**
|
|
665
|
-
* Canonicalise a scope entry's SPELLING so two agents writing the same file two
|
|
666
|
-
* ways are not read as disjoint (review LOW). Measured before this existed:
|
|
667
|
-
* `['./scripts/lib/foo.mjs']` vs `['scripts/lib/foo.mjs']` compared `ok: true`
|
|
668
|
-
* — the hook extracts from PROSE, and `looksLikeRepoPath` admits a `./` prefix,
|
|
669
|
-
* so both spellings reach the comparison verbatim.
|
|
670
|
-
*
|
|
671
|
-
* Purely syntactic and meaning-preserving: `./` prefixes, `/./` segments and
|
|
672
|
-
* duplicated slashes are removed. A TRAILING slash is deliberately kept — it is
|
|
673
|
-
* the directory-prefix operator of `pathMatchesPattern`, so stripping it would
|
|
674
|
-
* silently narrow a scope. The `dir` ↔ `dir/` case is handled by
|
|
675
|
-
* {@link promoteDirEntries}, which decides it on evidence rather than guessing.
|
|
676
|
-
*
|
|
677
|
-
* `scope-gate.mjs` is a hook-safe pure library and out of this change's scope,
|
|
678
|
-
* so the normalisation lives on THIS side of the call, applied to both sides of
|
|
679
|
-
* every comparison.
|
|
680
|
-
*
|
|
681
|
-
* @param {string} entry
|
|
682
|
-
* @returns {string}
|
|
683
|
-
*/
|
|
684
|
-
export function normalizeScopeEntry(entry) {
|
|
685
|
-
if (typeof entry !== 'string') return '';
|
|
686
|
-
let s = entry.trim();
|
|
687
|
-
if (s === '') return '';
|
|
688
|
-
s = s.replace(/\/{2,}/g, '/'); // `a//b` → `a/b`
|
|
689
|
-
s = s.replace(/(?:^|\/)\.\//g, (m) => (m.startsWith('/') ? '/' : '')); // `./a`, `a/./b`
|
|
690
|
-
while (s.startsWith('./')) s = s.slice(2);
|
|
691
|
-
return s;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
743
|
/**
|
|
695
744
|
* Promote an entry that names a DIRECTORY to its `dir/` prefix form, on
|
|
696
745
|
* evidence. `scripts/lib` and `scripts/lib/` are the same claim, but
|
|
@@ -737,7 +786,11 @@ function collectScopePaths(segments) {
|
|
|
737
786
|
for (const raw of segments) {
|
|
738
787
|
// A trailing sentence period is punctuation, never part of a path
|
|
739
788
|
// (measured: "docs/events-schema.md." at the end of an inline declaration).
|
|
740
|
-
|
|
789
|
+
// Lexical cleaning only (prose → path); the SPELLING rule is
|
|
790
|
+
// `scopeEntrySpelling` in scope-gate.mjs, applied in `decide()` (#1510 g).
|
|
791
|
+
// The second trim: a period stripped after `cleanScopeLine`'s trim can
|
|
792
|
+
// expose a space (`foo.mjs .`), which `looksLikeRepoPath` would reject.
|
|
793
|
+
const cleaned = cleanScopeLine(raw).replace(/\.$/, '').trim();
|
|
741
794
|
if (!looksLikeRepoPath(cleaned)) continue;
|
|
742
795
|
if (seen.has(cleaned)) continue;
|
|
743
796
|
seen.add(cleaned);
|
|
@@ -828,7 +881,8 @@ export function extractScopeSignal(prompt) {
|
|
|
828
881
|
* (and the tests that pin them) keep the original signature.
|
|
829
882
|
*
|
|
830
883
|
* @param {string} prompt
|
|
831
|
-
* @returns {string[]} repo-relative paths/globs
|
|
884
|
+
* @returns {string[]} repo-relative paths/globs as declared (lexically cleaned,
|
|
885
|
+
* deduped, order preserved) — the SPELLING rule runs in `decide()` (#1510 g)
|
|
832
886
|
*/
|
|
833
887
|
export function extractScopeFromPrompt(prompt) {
|
|
834
888
|
return extractScopeSignal(prompt).files;
|
|
@@ -1355,7 +1409,8 @@ export function makeFinishedProbe({
|
|
|
1355
1409
|
/**
|
|
1356
1410
|
* Identify the wave this dispatch belongs to. Derived from the coordinator's
|
|
1357
1411
|
* own scope file so a wave transition resets the ledger without anyone having to
|
|
1358
|
-
* remember to clear it.
|
|
1412
|
+
* remember to clear it. `findOwnScopeFile` skips provably foreign manifests;
|
|
1413
|
+
* unbound or corrupt manifests still decide under the ENFORCE contract.
|
|
1359
1414
|
*
|
|
1360
1415
|
* FALLBACK, stated honestly (review MED): with no readable `wave-scope.json` the
|
|
1361
1416
|
* key degrades to `<session>|w?|?`, so the ledger spans the whole SESSION and a
|
|
@@ -1370,22 +1425,75 @@ export function makeFinishedProbe({
|
|
|
1370
1425
|
* @param {string} projectDir
|
|
1371
1426
|
* @param {string} sessionId
|
|
1372
1427
|
* @param {(p: string, enc: string) => string} readFn — injected `readFileSync`
|
|
1373
|
-
*
|
|
1428
|
+
* @param {Set<string>} [ownIds] — process-local ids; repo helpers are late-bound
|
|
1429
|
+
* by bootstrap, as for the other guard dependencies.
|
|
1374
1430
|
* @returns {string}
|
|
1375
1431
|
*/
|
|
1376
|
-
export function waveKeyOf(projectDir, sessionId, readFn
|
|
1377
|
-
|
|
1432
|
+
export function waveKeyOf(projectDir, sessionId, readFn,
|
|
1433
|
+
ownIds = new Set(readProcessLocalSessionIds({ hookInput: { session_id: sessionId } }))) {
|
|
1434
|
+
const { path: scopeFile } = findOwnScopeFile(projectDir, ownIds, classifyManifestSession);
|
|
1435
|
+
if (scopeFile) {
|
|
1378
1436
|
try {
|
|
1379
|
-
const raw = readFn(
|
|
1437
|
+
const raw = readFn(scopeFile, 'utf8');
|
|
1380
1438
|
const data = JSON.parse(raw);
|
|
1381
1439
|
const wave = data?.wave ?? '?';
|
|
1382
1440
|
const role = data?.role ?? '?';
|
|
1383
1441
|
return `${sessionId}|w${wave}|${role}`;
|
|
1384
|
-
} catch { /*
|
|
1442
|
+
} catch { /* corrupt / unreadable own-or-unknown manifest → no wave */ }
|
|
1385
1443
|
}
|
|
1386
1444
|
return `${sessionId}|w?|?`;
|
|
1387
1445
|
}
|
|
1388
1446
|
|
|
1447
|
+
/**
|
|
1448
|
+
* The session id this dispatch is keyed under — the payload's own, never a
|
|
1449
|
+
* lock or env read (§ 6 limit 10 of `docs/scope-collision-guard.md`).
|
|
1450
|
+
*
|
|
1451
|
+
* @param {object} input parsed PreToolUse payload
|
|
1452
|
+
* @returns {string}
|
|
1453
|
+
*/
|
|
1454
|
+
function sessionIdOf(input) {
|
|
1455
|
+
return typeof input?.session_id === 'string' ? input.session_id : 'no-session';
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* The repo-relative paths of THIS session's ledger and its lock (#1493.3).
|
|
1460
|
+
* The session id is reduced to a path-safe token (`[A-Za-z0-9._-]`, capped at
|
|
1461
|
+
* `MAX_SESSION_TOKEN_CHARS`), so no id can name a file outside `.orchestrator/`;
|
|
1462
|
+
* an id with no usable character falls back to `no-session`.
|
|
1463
|
+
*
|
|
1464
|
+
* @param {string} sessionId
|
|
1465
|
+
* @returns {{ ledgerRel: string, lockRel: string }}
|
|
1466
|
+
*/
|
|
1467
|
+
export function ledgerPathsFor(sessionId) {
|
|
1468
|
+
const token = String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, MAX_SESSION_TOKEN_CHARS);
|
|
1469
|
+
const safe = /[A-Za-z0-9]/.test(token) ? token : 'no-session';
|
|
1470
|
+
const base = path.join(LEDGER_DIR_REL, `${LEDGER_STEM}.${safe}`);
|
|
1471
|
+
return { ledgerRel: `${base}.json`, lockRel: `${base}.lock` };
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* Migration read of the pre-#1493 shared ledger: the parsed legacy ledger when
|
|
1476
|
+
* its `waveKey` belongs to THIS session (first `|` segment equals the id), else
|
|
1477
|
+
* `null`. An unparseable or foreign legacy file is not this session's state —
|
|
1478
|
+
* it is neither adopted nor reported as corruption (row 7 is about OUR file).
|
|
1479
|
+
*
|
|
1480
|
+
* @param {string|null} raw legacy file contents (`null` = absent)
|
|
1481
|
+
* @param {string} sessionId
|
|
1482
|
+
* @returns {object|null}
|
|
1483
|
+
*/
|
|
1484
|
+
function adoptLegacyLedger(raw, sessionId) {
|
|
1485
|
+
if (typeof raw !== 'string') return null;
|
|
1486
|
+
try {
|
|
1487
|
+
const legacy = JSON.parse(raw);
|
|
1488
|
+
return legacy !== null && typeof legacy === 'object' && typeof legacy.waveKey === 'string'
|
|
1489
|
+
&& legacy.waveKey.startsWith(`${sessionId}|`)
|
|
1490
|
+
? legacy
|
|
1491
|
+
: null;
|
|
1492
|
+
} catch {
|
|
1493
|
+
return null;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1389
1497
|
/**
|
|
1390
1498
|
* The wave NUMBER out of a `waveKeyOf()` key, for the telemetry record (#1092).
|
|
1391
1499
|
*
|
|
@@ -1406,6 +1514,88 @@ function waveNumberOf(waveKey) {
|
|
|
1406
1514
|
return Number.isSafeInteger(n) && n > 0 ? n : null;
|
|
1407
1515
|
}
|
|
1408
1516
|
|
|
1517
|
+
/** Thrown instead of spawning once the shared git deadline has passed. */
|
|
1518
|
+
class GitBudgetExhausted extends Error {
|
|
1519
|
+
/** @param {string} subcommand */
|
|
1520
|
+
constructor(subcommand) {
|
|
1521
|
+
super(`git ${subcommand}: shared git budget (${GIT_BUDGET_MS} ms) exhausted`);
|
|
1522
|
+
this.name = 'GitBudgetExhausted';
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* A git runner bound to ONE deadline shared by every call of this hook fire
|
|
1528
|
+
* (see `GIT_BUDGET_MS`). Each call gets `timeout` = what is left; once nothing
|
|
1529
|
+
* is left it throws without spawning. `exhausted()` turns sticky the moment a
|
|
1530
|
+
* call is refused or timed out, so a caller can tell "git said no" from "git
|
|
1531
|
+
* never got to answer" — the second is a skip, never evidence (§ matrix rows 8,
|
|
1532
|
+
* 15). `SIGKILL`, not the default SIGTERM: a child that ignores SIGTERM keeps
|
|
1533
|
+
* `execFileSync` blocked past its timeout. Safe here — every call is read-only
|
|
1534
|
+
* plumbing that takes no index lock (§ PSA).
|
|
1535
|
+
*
|
|
1536
|
+
* @param {number} deadline — epoch ms
|
|
1537
|
+
* @returns {{run: (args: string[], cwd: string, opts?: {maxBuffer?: number}) => string,
|
|
1538
|
+
* exhausted: () => boolean}}
|
|
1539
|
+
*/
|
|
1540
|
+
function makeGitRunner(deadline) {
|
|
1541
|
+
let exhausted = false;
|
|
1542
|
+
const run = (args, cwd, { maxBuffer } = {}) => {
|
|
1543
|
+
const remaining = Math.floor(deadline - Date.now());
|
|
1544
|
+
if (remaining <= 0) {
|
|
1545
|
+
exhausted = true;
|
|
1546
|
+
throw new GitBudgetExhausted(args[0]);
|
|
1547
|
+
}
|
|
1548
|
+
try {
|
|
1549
|
+
return execFileSync('git', args, {
|
|
1550
|
+
cwd,
|
|
1551
|
+
encoding: 'utf8',
|
|
1552
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1553
|
+
timeout: remaining,
|
|
1554
|
+
killSignal: 'SIGKILL',
|
|
1555
|
+
...(maxBuffer === undefined ? {} : { maxBuffer }),
|
|
1556
|
+
});
|
|
1557
|
+
} catch (e) {
|
|
1558
|
+
if (e?.code === 'ETIMEDOUT') exhausted = true;
|
|
1559
|
+
throw e;
|
|
1560
|
+
}
|
|
1561
|
+
};
|
|
1562
|
+
return { run, exhausted: () => exhausted };
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* `git rev-parse --show-toplevel` of `cwd`, or `''` when git cannot say.
|
|
1567
|
+
*
|
|
1568
|
+
* @param {string} cwd
|
|
1569
|
+
* @param {ReturnType<typeof makeGitRunner>} git
|
|
1570
|
+
* @returns {string}
|
|
1571
|
+
*/
|
|
1572
|
+
function gitToplevel(cwd, git) {
|
|
1573
|
+
try {
|
|
1574
|
+
return git.run(['rev-parse', '--show-toplevel'], cwd).trim();
|
|
1575
|
+
} catch {
|
|
1576
|
+
return '';
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* Tracked files of the repo rooted at `root`, plus WHY the list is empty when
|
|
1582
|
+
* it had to be (matrix row 8 — degrade, never deny, but leave a reason).
|
|
1583
|
+
*
|
|
1584
|
+
* @param {string} root — a git toplevel, or `''` when none could be resolved
|
|
1585
|
+
* @param {ReturnType<typeof makeGitRunner>} git
|
|
1586
|
+
* @returns {{files: string[], skipped?: 'budget-exhausted'|'git-error'}}
|
|
1587
|
+
*/
|
|
1588
|
+
function trackedFilesIn(root, git) {
|
|
1589
|
+
const failed = () => ({ files: [], skipped: git.exhausted() ? 'budget-exhausted' : 'git-error' });
|
|
1590
|
+
if (root === '') return failed();
|
|
1591
|
+
try {
|
|
1592
|
+
const stdout = git.run(['ls-files', '-z'], root, { maxBuffer: 32 * 1024 * 1024 });
|
|
1593
|
+
return { files: stdout.split('\0').filter((f) => f.length > 0) };
|
|
1594
|
+
} catch {
|
|
1595
|
+
return failed();
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1409
1599
|
/**
|
|
1410
1600
|
* Tracked files, for glob expansion inside `findScopeCollisions`. The library is
|
|
1411
1601
|
* pure and must not spawn — supplying this is precisely the hook's job.
|
|
@@ -1419,82 +1609,181 @@ function waveNumberOf(waveKey) {
|
|
|
1419
1609
|
* That is the dangerous direction, because the hook is the last gate before the
|
|
1420
1610
|
* write.
|
|
1421
1611
|
*
|
|
1612
|
+
* Standalone entry with its own `GIT_BUDGET_MS`; `main()` composes the same two
|
|
1613
|
+
* steps under the deadline it shares with the worktree-base check.
|
|
1614
|
+
*
|
|
1422
1615
|
* @param {string} cwd
|
|
1423
1616
|
* @returns {string[]}
|
|
1424
1617
|
*/
|
|
1425
1618
|
export function listTrackedFiles(cwd) {
|
|
1426
|
-
const
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1619
|
+
const git = makeGitRunner(Date.now() + GIT_BUDGET_MS);
|
|
1620
|
+
return trackedFilesIn(gitToplevel(cwd, git), git).files;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* The SETTINGS ROOT (#1485) — where `harnessBaseRef()` reads the project's
|
|
1625
|
+
* `.claude/settings{,.local}.json`: `$CLAUDE_PROJECT_DIR`, else the git toplevel
|
|
1626
|
+
* of `cwd`, else `cwd`. The launch dir FIRST, unlike `resolveSessionRoot()`: it is the
|
|
1627
|
+
* root the harness applies project settings from, and after entering a worktree
|
|
1628
|
+
* the gitignored `settings.local.json` exists only there.
|
|
1629
|
+
*
|
|
1630
|
+
* @param {string} cwd — the payload `cwd`
|
|
1631
|
+
* @param {string} cwdToplevel — `gitToplevel(cwd)`, `''` when git could not say
|
|
1632
|
+
* @returns {string}
|
|
1633
|
+
*/
|
|
1634
|
+
function settingsRootOf(cwd, cwdToplevel) {
|
|
1635
|
+
return (process.env.CLAUDE_PROJECT_DIR || '').trim() || cwdToplevel || cwd;
|
|
1440
1636
|
}
|
|
1441
1637
|
|
|
1442
1638
|
// ---------------------------------------------------------------------------
|
|
1443
|
-
// Stale worktree base (#1413) — a
|
|
1639
|
+
// Stale worktree base (#1413 → #1485) — a MEASURED mismatch DENIES the dispatch
|
|
1444
1640
|
//
|
|
1445
1641
|
// `isolation: "worktree"` makes the HARNESS create `<repo>/.claude/worktrees/
|
|
1446
1642
|
// agent-<hex>`; no code in this repo creates it and the `Agent` payload carries
|
|
1447
|
-
// no base-ref field (the 147-dispatch key census in the module docblock).
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
//
|
|
1451
|
-
//
|
|
1452
|
-
//
|
|
1453
|
-
//
|
|
1643
|
+
// no base-ref field (the 147-dispatch key census in the module docblock). Which
|
|
1644
|
+
// commit it branches from is documented harness behaviour (code.claude.com/docs
|
|
1645
|
+
// /en/worktrees § "Choose the base branch", read 2026-10-02): "Subagent worktrees
|
|
1646
|
+
// use the same base branch as `--worktree`, so they branch from your repository's
|
|
1647
|
+
// default branch unless `worktree.baseRef` is set to `"head"`" — `"fresh"` (the
|
|
1648
|
+
// default) is `origin/HEAD`, `"head"` is the current local HEAD.
|
|
1649
|
+
//
|
|
1650
|
+
// That REPLACES the #1413 model ("the base is the session-start commit"): it fit
|
|
1651
|
+
// s18 only because that session ran on `main`, where origin/main WAS the start
|
|
1652
|
+
// commit. #1485 measured the general case on a feature branch — agent worktrees
|
|
1653
|
+
// stood on `main` (`0efb3e97`), six commits behind the branch HEAD (`47c49652`),
|
|
1654
|
+
// so the agents silently edited old code. Owner decision 2026-10-02, option (a):
|
|
1655
|
+
// check with `git merge-base --is-ancestor` before dispatch and stop on mismatch.
|
|
1454
1656
|
//
|
|
1455
|
-
//
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1657
|
+
// ONLY A MEASURED MISMATCH DENIES (`is-ancestor` exit 1 in a full-history repo,
|
|
1658
|
+
// against a cached origin ref the harness will branch from as-is). Every
|
|
1659
|
+
// non-measurement — no origin/HEAD, a shallow clone, a cached ref the harness
|
|
1660
|
+
// refetches first (`stale-remote-ref`), a git failure, an exit code other than
|
|
1661
|
+
// 0/1, a throw — is `stale: null` + `skipped`, recorded and ALLOWED: unknown is
|
|
1662
|
+
// not mismatch, and this hook guards the dispatch path, where a wrong deny is a
|
|
1663
|
+
// session outage (§ the fail-open note at the bottom of this file).
|
|
1664
|
+
//
|
|
1665
|
+
// Where the hook's base and the harness's can still DIVERGE (Claude Code
|
|
1666
|
+
// 2.1.287 bundle, read 2026-10-02):
|
|
1667
|
+
// - a settings layer this hook cannot read (`harnessBaseRef()`) — either way,
|
|
1668
|
+
// wrong deny or missed mismatch, from any `base_ref_source`;
|
|
1669
|
+
// - no `refs/remotes/origin/HEAD` symref: the harness falls back to
|
|
1670
|
+
// `origin/main|master`, the hook skips `no-origin-head` — a missed mismatch
|
|
1671
|
+
// only, never a wrong deny (measured 2026-10-02: 0 of 28 local repos lack
|
|
1672
|
+
// the symref);
|
|
1673
|
+
// - a stale FETCH_HEAD whose refetch then FAILS (offline): the harness keeps
|
|
1674
|
+
// the cached ref, so the skipped deny would have been right — the price of
|
|
1675
|
+
// not knowing in advance whether a fetch will succeed.
|
|
1458
1676
|
// ---------------------------------------------------------------------------
|
|
1459
1677
|
|
|
1678
|
+
/** `worktree.baseRef` values the docs define; anything else sets nothing here. */
|
|
1679
|
+
const BASE_REF_VALUES = new Set(['head', 'fresh']);
|
|
1680
|
+
|
|
1681
|
+
/**
|
|
1682
|
+
* The harness refetches origin before branching a `"fresh"` worktree when
|
|
1683
|
+
* FETCH_HEAD is older than this (Claude Code 2.1.287 bundle, read 2026-10-02:
|
|
1684
|
+
* `Date.now() - mtimeMs > 86400000`, then `git fetch origin <default>` with a
|
|
1685
|
+
* 5 s timeout). Revisit when the harness changes that constant — a mismatch
|
|
1686
|
+
* either skips a real deny or lets a refetched base be denied again.
|
|
1687
|
+
*/
|
|
1688
|
+
const FETCH_REFRESH_MS = 24 * 60 * 60 * 1000;
|
|
1689
|
+
|
|
1690
|
+
/**
|
|
1691
|
+
* The harness's effective `worktree.baseRef`, as far as this hook can see it,
|
|
1692
|
+
* and WHICH layer decided it — read in the documented precedence order (code.
|
|
1693
|
+
* claude.com/docs/en/settings § "Settings precedence", read 2026-10-02): local
|
|
1694
|
+
* → project → user, the FIRST file that sets the key to a defined value wins.
|
|
1695
|
+
* No file sets it → `'fresh'` from `'default'`.
|
|
1696
|
+
*
|
|
1697
|
+
* `settingsRoot` is the SESSION ROOT, not the hook payload's `cwd`: that `cwd`
|
|
1698
|
+
* follows the session's `cd` (docs/en/hooks § "cwd follows Claude"), while the
|
|
1699
|
+
* project settings the harness applies belong to the root it started in.
|
|
1700
|
+
*
|
|
1701
|
+
* Read only on the rare `worktree` branch (14 of 147 measured dispatches carried
|
|
1702
|
+
* `isolation` at all), so the hot dispatch path never pays these reads.
|
|
1703
|
+
*
|
|
1704
|
+
* NAMED CEILING (BV-004): blind to the two layers ABOVE local — managed settings
|
|
1705
|
+
* and the `--settings` CLI flag. The error points BOTH ways: a `"head"` there
|
|
1706
|
+
* with no visible file setting the key reads as `default` → `fresh` → a WRONG
|
|
1707
|
+
* DENY when HEAD is ahead of origin/HEAD; a `"fresh"` there shadowing a visible
|
|
1708
|
+
* `"head"` → a MISSED mismatch. Not read on purpose: `--settings` reaches no
|
|
1709
|
+
* hook, and managed settings arrive through several delivery mechanisms (a
|
|
1710
|
+
* `managed-settings.json` in a system dir, MDM/OS policy, server-managed settings
|
|
1711
|
+
* from the claude.ai console, an embedding host's SDK option — docs settings
|
|
1712
|
+
* § "Managed settings"), combined by their own intra-tier rules; reading the one
|
|
1713
|
+
* file among them would turn an honest "invisible" into a partial view that
|
|
1714
|
+
* still decides. The wrong-deny population is countable instead (HR-105): a
|
|
1715
|
+
* hidden `"head"` with no visible file setting the key yields `decision:"deny"`
|
|
1716
|
+
* records with `base_ref_source:"default"`; a hidden `"head"` shadowed by a
|
|
1717
|
+
* visible `"fresh"` yields them under that file's source instead. Revisit if
|
|
1718
|
+
* such a deny is reported in a session whose `/status` names a managed or
|
|
1719
|
+
* `--settings` source carrying `worktree.baseRef`.
|
|
1720
|
+
*
|
|
1721
|
+
* @param {string} settingsRoot
|
|
1722
|
+
* @returns {{baseRef: 'head'|'fresh', source: 'local'|'project'|'user'|'default'}}
|
|
1723
|
+
*/
|
|
1724
|
+
function harnessBaseRef(settingsRoot) {
|
|
1725
|
+
const userDir = (process.env.CLAUDE_CONFIG_DIR || '').trim() || path.join(homedir(), '.claude');
|
|
1726
|
+
for (const [source, file] of [
|
|
1727
|
+
['local', path.join(settingsRoot, '.claude', 'settings.local.json')],
|
|
1728
|
+
['project', path.join(settingsRoot, '.claude', 'settings.json')],
|
|
1729
|
+
['user', path.join(userDir, 'settings.json')],
|
|
1730
|
+
]) {
|
|
1731
|
+
try {
|
|
1732
|
+
const value = JSON.parse(readFileSync(file, 'utf8'))?.worktree?.baseRef;
|
|
1733
|
+
if (BASE_REF_VALUES.has(value)) return { baseRef: value, source };
|
|
1734
|
+
} catch { /* absent or unparseable — the next layer decides */ }
|
|
1735
|
+
}
|
|
1736
|
+
return { baseRef: 'fresh', source: 'default' };
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/** Where each `base_ref_source` lives, for the operator-facing deny text. */
|
|
1740
|
+
const BASE_REF_SOURCE_LABEL = {
|
|
1741
|
+
local: '.claude/settings.local.json',
|
|
1742
|
+
project: '.claude/settings.json',
|
|
1743
|
+
user: 'user settings.json',
|
|
1744
|
+
default: 'the default — no settings file this hook can read sets it',
|
|
1745
|
+
};
|
|
1746
|
+
|
|
1460
1747
|
/**
|
|
1461
1748
|
* Facts for the worktree-base check, as a DISCRIMINATED record with three
|
|
1462
1749
|
* outcomes rather than two (#1424):
|
|
1463
1750
|
*
|
|
1464
|
-
* - `{stale: true|false, head,
|
|
1751
|
+
* - `{stale: true|false, head, base, base_ref, base_ref_source, missing_commits?,
|
|
1752
|
+
* subagent_type?}` — measured. `stale` means "the worktree would MISS commits
|
|
1753
|
+
* of HEAD": `git merge-base --is-ancestor HEAD <base>` exited 1 in a repo
|
|
1754
|
+
* with full history and a FETCH_HEAD younger than `FETCH_REFRESH_MS` (so
|
|
1755
|
+
* the harness branches from that cached ref without refetching it first).
|
|
1756
|
+
* A base AHEAD of HEAD (origin moved on, HEAD contained)
|
|
1757
|
+
* is not stale — nothing of HEAD is lost. `base_ref_source` names the
|
|
1758
|
+
* settings layer that decided `base_ref` (`harnessBaseRef()`), so a wrong
|
|
1759
|
+
* deny from a layer this hook cannot read stays countable (HR-105).
|
|
1465
1760
|
* - `{stale: null, skipped: <reason>}` — the dispatch WAS on the `worktree`
|
|
1466
1761
|
* branch, but the question could not be answered honestly. Still emitted,
|
|
1467
|
-
* because HR-105 needs the denominator
|
|
1468
|
-
*
|
|
1469
|
-
*
|
|
1470
|
-
*
|
|
1471
|
-
*
|
|
1472
|
-
*
|
|
1473
|
-
*
|
|
1474
|
-
*
|
|
1475
|
-
*
|
|
1476
|
-
*
|
|
1477
|
-
*
|
|
1478
|
-
*
|
|
1479
|
-
*
|
|
1480
|
-
*
|
|
1481
|
-
*
|
|
1482
|
-
* warning about somebody else's session is worse than no warning at all. The
|
|
1483
|
-
* test runs PER CANDIDATE (`continue`, not `break`) — see the loop below.
|
|
1484
|
-
*
|
|
1485
|
-
* `parseStateMd` is imported DYNAMICALLY and only on the rare `worktree` branch
|
|
1486
|
-
* (14 of 147 measured dispatches carried `isolation` at all): it is a leaf module
|
|
1487
|
-
* with zero imports and already inside the committed hook-import closure, so
|
|
1488
|
-
* reuse costs no new edge, and binding it late keeps a load failure catchable
|
|
1489
|
-
* instead of disarming the guard at ESM link time (§ #993 late-bound deps).
|
|
1762
|
+
* because HR-105 needs the denominator, and never denied (unknown ≠ mismatch).
|
|
1763
|
+
* - `null` — `isolation !== 'worktree'`. SILENT by construction: recording the
|
|
1764
|
+
* other ~90% of dispatches would put the denominator on the hot path (HR-101).
|
|
1765
|
+
*
|
|
1766
|
+
* TWO DIRECTORIES, deliberately different: git measures in `cwd` (the payload's,
|
|
1767
|
+
* which follows the session's `cd` — git finds the enclosing repo from any
|
|
1768
|
+
* subdirectory, and inside an entered worktree `"head"` means THAT worktree's
|
|
1769
|
+
* HEAD); settings are read at `settingsRoot` (`settingsRootOf()` — the root the
|
|
1770
|
+
* harness applies project settings from, which is NOT where the record lands:
|
|
1771
|
+
* that is `resolveSessionRoot()`).
|
|
1772
|
+
*
|
|
1773
|
+
* Every git call draws on the hook fire's ONE shared deadline (`GIT_BUDGET_MS`).
|
|
1774
|
+
* A call the budget cut short is no measurement: each skip then reads
|
|
1775
|
+
* `git-budget`, whichever reason the cut call would otherwise have produced — a
|
|
1776
|
+
* timed-out `origin/HEAD` lookup is not evidence that there is none.
|
|
1490
1777
|
*
|
|
1491
1778
|
* @param {{tool_input?: unknown}} input — the raw hook payload
|
|
1492
|
-
* @param {string}
|
|
1493
|
-
* @param {string}
|
|
1494
|
-
* @
|
|
1495
|
-
*
|
|
1779
|
+
* @param {string} cwd — the hook payload's `cwd`
|
|
1780
|
+
* @param {string} settingsRoot — `settingsRootOf()`
|
|
1781
|
+
* @param {ReturnType<typeof makeGitRunner>} gitRunner — the fire's shared runner
|
|
1782
|
+
* @returns {{head: string, base: string, base_ref: 'head'|'fresh',
|
|
1783
|
+
* base_ref_source: 'local'|'project'|'user'|'default', stale: boolean,
|
|
1784
|
+
* missing_commits?: number, subagent_type?: string}|{stale: null, skipped: string}|null}
|
|
1496
1785
|
*/
|
|
1497
|
-
|
|
1786
|
+
function worktreeBaseFacts(input, cwd, settingsRoot, gitRunner) {
|
|
1498
1787
|
// The applicability gate sits OUTSIDE the try on purpose: everything below it
|
|
1499
1788
|
// resolves to a `skipped` RECORD, so a throw here must not be able to mint one
|
|
1500
1789
|
// for a dispatch that was never on the `worktree` branch at all.
|
|
@@ -1502,98 +1791,106 @@ async function worktreeBaseFacts(input, projectDir, sessionId) {
|
|
|
1502
1791
|
if (toolInput === null || typeof toolInput !== 'object') return null;
|
|
1503
1792
|
if (toolInput.isolation !== 'worktree') return null;
|
|
1504
1793
|
|
|
1794
|
+
/** @param {string} reason */
|
|
1795
|
+
const skip = (reason) => ({ stale: null, skipped: gitRunner.exhausted() ? 'git-budget' : reason });
|
|
1505
1796
|
try {
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
: (process.env.CLAUDE_CODE_SESSION_ID || '');
|
|
1509
|
-
if (!ownId) return { stale: null, skipped: 'no-own-id' };
|
|
1510
|
-
|
|
1511
|
-
const { parseStateMd } = await import(
|
|
1512
|
-
pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'state-md', 'yaml-parser.mjs')).href
|
|
1513
|
-
);
|
|
1514
|
-
|
|
1515
|
-
// #1424: the identity test lives INSIDE the loop. Breaking at the first
|
|
1516
|
-
// PARSEABLE STATE.md and testing `session-id` afterwards meant a single
|
|
1517
|
-
// left-over `.pi/STATE.md` — `.pi` sorts before `.claude` in
|
|
1518
|
-
// STATE_DIR_CANDIDATES — disarmed the check for a session whose own
|
|
1519
|
-
// `.claude/STATE.md` sat right there, readable and matching. `sawStateMd`
|
|
1520
|
-
// keeps the two silences apart afterwards: "nothing to read" is a different
|
|
1521
|
-
// fact from "read, and none of it was mine".
|
|
1522
|
-
let frontmatter = null;
|
|
1523
|
-
let sawStateMd = false;
|
|
1524
|
-
for (const dir of STATE_DIR_CANDIDATES) {
|
|
1525
|
-
try {
|
|
1526
|
-
const parsed = parseStateMd(readFileSync(path.join(projectDir, dir, 'STATE.md'), 'utf8'));
|
|
1527
|
-
if (!parsed?.frontmatter) continue;
|
|
1528
|
-
sawStateMd = true;
|
|
1529
|
-
if (parsed.frontmatter['session-id'] !== ownId) continue;
|
|
1530
|
-
frontmatter = parsed.frontmatter;
|
|
1531
|
-
break;
|
|
1532
|
-
} catch { /* try the next state dir */ }
|
|
1533
|
-
}
|
|
1534
|
-
if (frontmatter === null) {
|
|
1535
|
-
return { stale: null, skipped: sawStateMd ? 'identity-mismatch' : 'no-state-md' };
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
const startRef = typeof frontmatter['session-start-ref'] === 'string'
|
|
1539
|
-
? frontmatter['session-start-ref'].trim()
|
|
1540
|
-
: '';
|
|
1541
|
-
if (startRef === '') return { stale: null, skipped: 'no-start-ref' };
|
|
1797
|
+
/** @param {string[]} args */
|
|
1798
|
+
const git = (args) => gitRunner.run(args, cwd).trim();
|
|
1542
1799
|
|
|
1543
1800
|
let head = '';
|
|
1544
|
-
try {
|
|
1545
|
-
|
|
1546
|
-
cwd: projectDir,
|
|
1547
|
-
encoding: 'utf8',
|
|
1548
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1549
|
-
}).trim();
|
|
1550
|
-
} catch {
|
|
1551
|
-
return { stale: null, skipped: 'git-error' };
|
|
1552
|
-
}
|
|
1553
|
-
if (head === '') return { stale: null, skipped: 'git-error' };
|
|
1801
|
+
try { head = git(['rev-parse', '--verify', 'HEAD^{commit}']); } catch { /* below */ }
|
|
1802
|
+
if (head === '') return skip('git-error');
|
|
1554
1803
|
|
|
1555
|
-
const
|
|
1804
|
+
const { baseRef, source } = harnessBaseRef(settingsRoot);
|
|
1805
|
+
const facts = { head, base_ref: baseRef, base_ref_source: source };
|
|
1556
1806
|
if (typeof toolInput.subagent_type === 'string' && toolInput.subagent_type !== '') {
|
|
1557
1807
|
facts.subagent_type = toolInput.subagent_type;
|
|
1558
1808
|
}
|
|
1559
|
-
|
|
1809
|
+
// `"head"` inside a worktree resolves to THAT worktree's HEAD (docs, same
|
|
1810
|
+
// section) — the very `git rev-parse HEAD` above, run in `cwd`.
|
|
1811
|
+
if (baseRef === 'head') return { ...facts, base: head, stale: false };
|
|
1812
|
+
|
|
1813
|
+
// `"fresh"`: origin/HEAD. Not cached locally → the harness fetches, or
|
|
1814
|
+
// falls back to HEAD; neither outcome is knowable here → skip, not deny.
|
|
1815
|
+
let base = '';
|
|
1816
|
+
try { base = git(['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/HEAD^{commit}']); } catch { /* below */ }
|
|
1817
|
+
if (base === '') return skip('no-origin-head');
|
|
1818
|
+
if (base === head) return { ...facts, base, stale: false };
|
|
1819
|
+
|
|
1820
|
+
try {
|
|
1821
|
+
git(['merge-base', '--is-ancestor', head, base]);
|
|
1822
|
+
return { ...facts, base, stale: false };
|
|
1823
|
+
} catch (e) {
|
|
1824
|
+
// Exit 1 is the ONE candidate "no"; 128 (bad object), a timeout or a
|
|
1825
|
+
// signal is a non-measurement and must not accuse anybody.
|
|
1826
|
+
if (e?.status !== 1) return skip('git-error');
|
|
1827
|
+
}
|
|
1828
|
+
// ...and exit 1 is a MEASUREMENT only over full history. A shallow clone's
|
|
1829
|
+
// cut-off graph answers "no" for a HEAD that IS an ancestor (measured
|
|
1830
|
+
// 2026-10-02: `clone --depth 1` + `fetch --depth 1` → exit 1, the full source
|
|
1831
|
+
// → exit 0). Probed only here, on the rare "no": an exit 0, which no missing
|
|
1832
|
+
// history can fake, never pays for it. A failed probe cannot rule shallow
|
|
1833
|
+
// out, so it skips as well. ONE spawn answers both questions below (two
|
|
1834
|
+
// lines, in argument order) — the deny path is the one that must finish
|
|
1835
|
+
// inside the shared budget.
|
|
1836
|
+
let shallow = '';
|
|
1837
|
+
let fetchHeadPath = '';
|
|
1838
|
+
try {
|
|
1839
|
+
[shallow = '', fetchHeadPath = ''] = git(['rev-parse', '--is-shallow-repository', '--git-path', 'FETCH_HEAD'])
|
|
1840
|
+
.split('\n')
|
|
1841
|
+
.map((line) => line.trim());
|
|
1842
|
+
} catch { /* below */ }
|
|
1843
|
+
if (shallow !== 'false') return skip(shallow === 'true' ? 'shallow' : 'git-error');
|
|
1844
|
+
|
|
1845
|
+
// ...and only over a ref the harness will actually branch from. With
|
|
1846
|
+
// FETCH_HEAD older than `FETCH_REFRESH_MS` (or absent — the harness reads
|
|
1847
|
+
// that as mtime 0) it first fetches origin and branches from the FETCHED
|
|
1848
|
+
// tip, which may well contain HEAD by now; this hook never fetches (network
|
|
1849
|
+
// inside a 5 s PreToolUse budget), so the cached ref decides nothing here.
|
|
1850
|
+
let fetchedMs = 0;
|
|
1851
|
+
try { if (fetchHeadPath !== '') fetchedMs = statSync(path.resolve(cwd, fetchHeadPath)).mtimeMs; } catch { /* absent → stale */ }
|
|
1852
|
+
if (Date.now() - fetchedMs > FETCH_REFRESH_MS) return { stale: null, skipped: 'stale-remote-ref' };
|
|
1853
|
+
|
|
1854
|
+
let missing = Number.NaN;
|
|
1855
|
+
try { missing = Number.parseInt(git(['rev-list', '--count', `${base}..${head}`]), 10); } catch { /* optional */ }
|
|
1856
|
+
return { ...facts, base, stale: true, ...(Number.isInteger(missing) ? { missing_commits: missing } : {}) };
|
|
1560
1857
|
} catch {
|
|
1561
|
-
// Total by construction
|
|
1562
|
-
// Still a RECORD rather than silence — the dispatch WAS on the `worktree`
|
|
1563
|
-
// branch, and a broken probe that emits nothing is indistinguishable from a
|
|
1564
|
-
// probe that had nothing to report (#1424 / HR-105).
|
|
1858
|
+
// Total by construction. Still a RECORD rather than silence (#1424 / HR-105).
|
|
1565
1859
|
return { stale: null, skipped: 'probe-error' };
|
|
1566
1860
|
}
|
|
1567
1861
|
}
|
|
1568
1862
|
|
|
1569
1863
|
/**
|
|
1570
|
-
* The
|
|
1571
|
-
*
|
|
1572
|
-
*
|
|
1573
|
-
*
|
|
1574
|
-
*
|
|
1575
|
-
*
|
|
1576
|
-
*
|
|
1577
|
-
* @param {{head: string,
|
|
1578
|
-
* @returns {string}
|
|
1864
|
+
* The deny reason + suggestion for a measured stale base. Names the ACTION, not
|
|
1865
|
+
* just the condition (HR-106), and the settings layer that chose `"fresh"`: an
|
|
1866
|
+
* operator whose `"head"` lives where this hook cannot read (managed settings,
|
|
1867
|
+
* `--settings`) then reads "the default" and knows the deny rests on that blind
|
|
1868
|
+
* spot. The reason stays ONE line: `emitDeny` lifts the first line into the
|
|
1869
|
+
* operator-visible `systemMessage` headline.
|
|
1870
|
+
*
|
|
1871
|
+
* @param {{head: string, base: string, base_ref_source?: string, missing_commits?: number}} facts
|
|
1872
|
+
* @returns {{reason: string, suggestion: string}}
|
|
1579
1873
|
*/
|
|
1580
|
-
function
|
|
1581
|
-
const
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1874
|
+
function staleWorktreeDeny(facts) {
|
|
1875
|
+
const missing = Number.isInteger(facts.missing_commits)
|
|
1876
|
+
? `${facts.missing_commits} commit(s)`
|
|
1877
|
+
: 'commits';
|
|
1878
|
+
const from = BASE_REF_SOURCE_LABEL[facts.base_ref_source] ?? BASE_REF_SOURCE_LABEL.default;
|
|
1879
|
+
return {
|
|
1880
|
+
reason:
|
|
1881
|
+
`${HOOK_NAME}: STALE WORKTREE BASE (#1485) — isolation: "worktree" would branch this `
|
|
1882
|
+
+ `agent from origin/HEAD ${facts.base.slice(0, 12)} (worktree.baseRef "fresh" from ${from}), `
|
|
1883
|
+
+ `which is missing ${missing} of HEAD ${facts.head.slice(0, 12)}; the agent would silently edit OLD code.`,
|
|
1884
|
+
suggestion: [
|
|
1885
|
+
'DO ONE OF:',
|
|
1886
|
+
' 1. re-dispatch IN-PLACE — omit `isolation` (isolation "none");',
|
|
1887
|
+
' 2. set `"worktree": {"baseRef": "head"}` in .claude/settings.json so agent',
|
|
1888
|
+
' worktrees branch from your local HEAD (code.claude.com/docs/en/worktrees) —',
|
|
1889
|
+
' a higher layer setting "fresh" (local settings, --settings, managed) overrides it;',
|
|
1890
|
+
' 3. get HEAD into the default branch on origin and fetch, then re-dispatch.',
|
|
1891
|
+
'Uncommitted changes reach no worktree under any base — commit first or go in-place.',
|
|
1892
|
+
].join('\n'),
|
|
1893
|
+
};
|
|
1597
1894
|
}
|
|
1598
1895
|
|
|
1599
1896
|
/**
|
|
@@ -1749,9 +2046,11 @@ export function scopeDigestFields(files, prompt, digestFn) {
|
|
|
1749
2046
|
* @param {string} [params.nowIso] dispatch timestamp recorded on the entry
|
|
1750
2047
|
* @param {(paths: string[]) => string} [params.digestFn] scope-digest function
|
|
1751
2048
|
* (injected for testability; defaults to the late-bound `scopeDigest`)
|
|
2049
|
+
* @param {(entry: string) => string} [params.spellFn] scope-entry spelling rule
|
|
2050
|
+
* (injected for testability; defaults to the late-bound `scopeEntrySpelling`)
|
|
1752
2051
|
* @returns {Verdict}
|
|
1753
2052
|
*/
|
|
1754
|
-
export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, collide, isFinished, nowIso, digestFn }) {
|
|
2053
|
+
export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, collide, isFinished, nowIso, digestFn, spellFn }) {
|
|
1755
2054
|
const toolName = input?.tool_name;
|
|
1756
2055
|
// Row 4: not our tool.
|
|
1757
2056
|
if (toolName !== DISPATCH_TOOL) return { action: 'allow' };
|
|
@@ -1762,7 +2061,14 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
1762
2061
|
const at = typeof nowIso === 'string' ? nowIso : new Date().toISOString();
|
|
1763
2062
|
|
|
1764
2063
|
const signal = extractScopeSignal(toolInput.prompt);
|
|
1765
|
-
|
|
2064
|
+
// #1510 g — ONE spelling rule, scope-gate's, applied before the ledger, the
|
|
2065
|
+
// digest and `promoteDirEntries` see an entry: `./scripts/lib` must reach the
|
|
2066
|
+
// dir-promotion as `scripts/lib`, or a bare-dir claim stays a literal and
|
|
2067
|
+
// compares disjoint with the files beneath it. Re-deduped on the spelling.
|
|
2068
|
+
// Unbound (a direct importer that never ran `bootstrap()`) ⇒ entries as
|
|
2069
|
+
// extracted; `findScopeCollisions` still spells both sides itself.
|
|
2070
|
+
const spell = typeof spellFn === 'function' ? spellFn : scopeEntrySpelling;
|
|
2071
|
+
const files = typeof spell === 'function' ? [...new Set(signal.files.map(spell))] : signal.files;
|
|
1766
2072
|
const id = agentIdOf(toolInput);
|
|
1767
2073
|
const priorAgents = (ledger !== null && ledger?.waveKey === waveKey && Array.isArray(ledger.agents))
|
|
1768
2074
|
? ledger.agents
|
|
@@ -1993,7 +2299,8 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
1993
2299
|
'Two agents editing one file at the same time race each other (PSA-002). ' +
|
|
1994
2300
|
'Give the file exactly ONE owner in the wave plan, or wait for the named ' +
|
|
1995
2301
|
`agent(s) to finish and re-dispatch — a finished agent no longer blocks. ` +
|
|
1996
|
-
`If the ledger is stale, delete ${
|
|
2302
|
+
`If the ledger is stale, delete this session's ${ledgerPathsFor(sessionIdOf(input)).ledgerRel} ` +
|
|
2303
|
+
'— never another session\'s file beside it, which holds that session\'s live claims.';
|
|
1997
2304
|
|
|
1998
2305
|
// Deliberately NOT persisting the ledger on deny: the dispatch did not happen,
|
|
1999
2306
|
// so recording it would make the retry-after-fix look like a duplicate.
|
|
@@ -2009,20 +2316,61 @@ async function main() {
|
|
|
2009
2316
|
const input = await readStdin();
|
|
2010
2317
|
if (!input) return emitAllow();
|
|
2011
2318
|
|
|
2012
|
-
|
|
2319
|
+
// The payload `cwd` follows the session's `cd`; it names the repo git measures
|
|
2320
|
+
// in, and only through its git toplevel where this session's state lives
|
|
2321
|
+
// (`resolveSessionRoot()`) — a `cd sub` must not move the ledger.
|
|
2322
|
+
const cwd = typeof input.cwd === 'string' && input.cwd !== ''
|
|
2013
2323
|
? input.cwd
|
|
2014
2324
|
: bannerProjectDir();
|
|
2015
|
-
const sessionId =
|
|
2325
|
+
const sessionId = sessionIdOf(input);
|
|
2016
2326
|
|
|
2017
2327
|
// Cheap pre-check: skip all I/O for the overwhelmingly common non-dispatch call.
|
|
2018
2328
|
if (input.tool_name !== DISPATCH_TOOL) return emitAllow();
|
|
2019
2329
|
|
|
2020
|
-
|
|
2021
|
-
|
|
2330
|
+
// ONE git deadline for this whole fire (`GIT_BUDGET_MS`). Worst case 7 spawns
|
|
2331
|
+
// against it: the toplevel below, up to 5 in `worktreeBaseFacts` (HEAD,
|
|
2332
|
+
// origin/HEAD, is-ancestor, shallow+FETCH_HEAD, rev-list) and `ls-files`.
|
|
2333
|
+
const git = makeGitRunner(Date.now() + GIT_BUDGET_MS);
|
|
2334
|
+
const cwdToplevel = gitToplevel(cwd, git);
|
|
2335
|
+
const sessionRoot = resolveSessionRoot(cwd, cwdToplevel);
|
|
2336
|
+
|
|
2337
|
+
const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
|
|
2338
|
+
const waveKey = waveKeyOf(sessionRoot, sessionId, readFileSync, ownIds);
|
|
2022
2339
|
// `selfUseId`: this dispatch's own tool_use may already stand in the transcript
|
|
2023
2340
|
// and must not make a finished same-named predecessor look alive (#1480 A).
|
|
2024
2341
|
const isFinished = makeFinishedProbe({ transcriptPath: input.transcript_path, selfUseId: input.tool_use_id });
|
|
2025
|
-
const
|
|
2342
|
+
const { ledgerRel, lockRel } = ledgerPathsFor(sessionId);
|
|
2343
|
+
const ledgerPath = path.join(sessionRoot, ledgerRel);
|
|
2344
|
+
|
|
2345
|
+
// #1485 — the stale-worktree-base verdict, computed BEFORE the ledger cycle
|
|
2346
|
+
// (and outside the lock: its git spawns hold nobody up there). It never
|
|
2347
|
+
// reads the collision verdict and a collision DENY always wins; on its own it
|
|
2348
|
+
// denies ONLY a measured mismatch (`stale === true`). Every skip is allowed.
|
|
2349
|
+
// It must be known before the cycle because a dispatch this check will deny
|
|
2350
|
+
// never happens, so the cycle must not persist it: a phantom claim would make
|
|
2351
|
+
// the deny's own remedy — the same agent re-dispatched in place under a new
|
|
2352
|
+
// tool_use_id — collide with it for up to `IN_FLIGHT_TTL_MS`.
|
|
2353
|
+
//
|
|
2354
|
+
// It draws on the shared budget BEFORE the tracked-file listing: a missed
|
|
2355
|
+
// stale base has no later gate (matrix row 15), a missed listing only
|
|
2356
|
+
// degrades glob expansion, and three later gates still see a collision.
|
|
2357
|
+
const worktreeBase = worktreeBaseFacts(input, cwd, settingsRootOf(cwd, cwdToplevel), git);
|
|
2358
|
+
const staleDeny = worktreeBase?.stale === true ? staleWorktreeDeny(worktreeBase) : null;
|
|
2359
|
+
|
|
2360
|
+
// Listed at `cwd`'s toplevel ONLY: the declared paths are repo-relative to the
|
|
2361
|
+
// repo the agents edit, which is the one the session works in (after entering
|
|
2362
|
+
// a worktree: that worktree). Whenever git resolved it, it IS `sessionRoot` —
|
|
2363
|
+
// except inside a harness agent worktree, which `sessionRoot` lifts to its
|
|
2364
|
+
// parent (#1492) and which lists the same repo's files from its own HEAD, and
|
|
2365
|
+
// in a session launched in a repo SUBDIRECTORY, where `sessionRoot` is that
|
|
2366
|
+
// launch dir and the listing stays at the toplevel above it;
|
|
2367
|
+
// when git could not, `sessionRoot` falls back to a `.git` ancestor (git just
|
|
2368
|
+
// failed there), `$CLAUDE_PROJECT_DIR` or a bare `cwd` — another repo, or a
|
|
2369
|
+
// subdirectory from which `ls-files` answers subdir-relative (the review-MED
|
|
2370
|
+
// class above) — so the listing degrades (matrix row 8) instead of following
|
|
2371
|
+
// it there.
|
|
2372
|
+
const known = trackedFilesIn(cwdToplevel, git);
|
|
2373
|
+
const knownFiles = known.files;
|
|
2026
2374
|
|
|
2027
2375
|
// The read-modify-write CYCLE, run under the ledger lock below. Everything
|
|
2028
2376
|
// inside is synchronous and emits NOTHING — an emit here would `process.exit()`
|
|
@@ -2036,7 +2384,15 @@ async function main() {
|
|
|
2036
2384
|
} catch (err) {
|
|
2037
2385
|
// Absent ledger is the normal first-dispatch case, NOT corruption (row 7
|
|
2038
2386
|
// must not fire on every wave's first agent).
|
|
2039
|
-
if (err?.code !== 'ENOENT')
|
|
2387
|
+
if (err?.code !== 'ENOENT') {
|
|
2388
|
+
ledgerCorrupt = true;
|
|
2389
|
+
} else {
|
|
2390
|
+
// #1493.3 migration: claims this session recorded in the shared
|
|
2391
|
+
// pre-#1493 file before the split still bind. Read, never touched.
|
|
2392
|
+
let legacyRaw = null;
|
|
2393
|
+
try { legacyRaw = readFileSync(path.join(sessionRoot, LEGACY_LEDGER_REL), 'utf8'); } catch { /* absent */ }
|
|
2394
|
+
ledger = adoptLegacyLedger(legacyRaw, sessionId);
|
|
2395
|
+
}
|
|
2040
2396
|
}
|
|
2041
2397
|
|
|
2042
2398
|
const verdict = decide({
|
|
@@ -2049,7 +2405,9 @@ async function main() {
|
|
|
2049
2405
|
isFinished,
|
|
2050
2406
|
});
|
|
2051
2407
|
|
|
2052
|
-
|
|
2408
|
+
// Not on a stale-base deny — the same rule as the collision deny path
|
|
2409
|
+
// (`decide()`, "Deliberately NOT persisting the ledger on deny").
|
|
2410
|
+
if (verdict.ledger && staleDeny === null) {
|
|
2053
2411
|
try {
|
|
2054
2412
|
writeJsonAtomicSync(ledgerPath, verdict.ledger);
|
|
2055
2413
|
} catch {
|
|
@@ -2061,7 +2419,7 @@ async function main() {
|
|
|
2061
2419
|
};
|
|
2062
2420
|
|
|
2063
2421
|
let verdict;
|
|
2064
|
-
const lockPath = path.join(
|
|
2422
|
+
const lockPath = path.join(sessionRoot, lockRel);
|
|
2065
2423
|
try {
|
|
2066
2424
|
mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
2067
2425
|
} catch { /* the unlocked fallback below still works */ }
|
|
@@ -2094,72 +2452,75 @@ async function main() {
|
|
|
2094
2452
|
// import is dynamic so a checkout without `scripts/lib/events.mjs` degrades to
|
|
2095
2453
|
// silence instead of crashing (§ Import safety), and the catch guarantees a
|
|
2096
2454
|
// failed write cannot change the verdict or the exit code.
|
|
2455
|
+
//
|
|
2456
|
+
// `decide()` never sees the stale-base check, so its `ledger_result` describes
|
|
2457
|
+
// the COLLISION verdict alone. When the stale base is what actually blocks the
|
|
2458
|
+
// dispatch (no collision deny beside it — that one keeps `deny`), the record
|
|
2459
|
+
// says so (#1489 Pkt 8): recorded as `allow`, a refused dispatch counted as a
|
|
2460
|
+
// dispatched one in every later query. The replaced value survives as
|
|
2461
|
+
// `collision_result`, so a `warn-ledger-corrupt` / `warn-not-evaluable` behind
|
|
2462
|
+
// a stale-base deny is not reported on stderr alone.
|
|
2097
2463
|
if (verdict.telemetry) {
|
|
2464
|
+
const telemetry = {
|
|
2465
|
+
...verdict.telemetry,
|
|
2466
|
+
...(staleDeny !== null && verdict.action !== 'deny'
|
|
2467
|
+
? { ledger_result: 'deny-stale-base', collision_result: verdict.telemetry.ledger_result }
|
|
2468
|
+
: {}),
|
|
2469
|
+
...(known.skipped === undefined ? {} : { known_files_skipped: known.skipped }),
|
|
2470
|
+
};
|
|
2098
2471
|
try {
|
|
2099
2472
|
const { emitEvent, sessionAttribution } = await import(
|
|
2100
2473
|
pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'events.mjs')).href
|
|
2101
2474
|
);
|
|
2102
2475
|
await emitEvent(
|
|
2103
2476
|
SCOPE_EVENT,
|
|
2104
|
-
{ hook: HOOK_NAME, ...
|
|
2105
|
-
{ repoRoot:
|
|
2477
|
+
{ hook: HOOK_NAME, ...telemetry, ...sessionAttribution(sessionRoot) },
|
|
2478
|
+
{ repoRoot: sessionRoot }
|
|
2106
2479
|
);
|
|
2107
2480
|
} catch { /* observability is best-effort — it never blocks the decision */ }
|
|
2108
2481
|
}
|
|
2109
2482
|
|
|
2110
|
-
// #
|
|
2111
|
-
// emit for the same process.exit() reason
|
|
2112
|
-
//
|
|
2113
|
-
// STATE.md failure resolves to silence rather than to a wrong accusation.
|
|
2114
|
-
//
|
|
2115
|
-
// ROUTED TO THE OPERATOR, not to stderr alone (w3-5): under the exit-0
|
|
2116
|
-
// PreToolUse protocol stderr goes nowhere — `io.mjs:545` calls it the "Debug
|
|
2117
|
-
// channel … Invisible under exit 0" — so the first cut of this check announced
|
|
2118
|
-
// the s18 incident class to a log and to nobody. The visible channel is the
|
|
2119
|
-
// top-level `systemMessage`, i.e. `emitWarn`, which carries NO
|
|
2120
|
-
// `permissionDecision` and therefore still means ALLOW (io.mjs § "which is
|
|
2121
|
-
// precisely what keeps warn non-blocking").
|
|
2122
|
-
//
|
|
2123
|
-
// The note is CARRIED to the single terminal emit below rather than emitted
|
|
2124
|
-
// here, because `emitWarn` calls `process.exit(0)` and never returns — warning
|
|
2125
|
-
// inline would terminate the process before a collision DENY could be emitted,
|
|
2126
|
-
// flipping a block into an allow (§ stdout discipline, the same reason
|
|
2127
|
-
// `decide()` is pure).
|
|
2483
|
+
// #1485 — the stale-worktree-base record (verdict computed above, before the
|
|
2484
|
+
// cycle), awaited BEFORE the terminal emit for the same process.exit() reason
|
|
2485
|
+
// as the block above.
|
|
2128
2486
|
//
|
|
2129
2487
|
// `null` here means "not a worktree dispatch" and stays silent; ANY other
|
|
2130
2488
|
// shape is emitted, including the `{stale: null, skipped: …}` non-measurements
|
|
2131
|
-
// (#1424)
|
|
2132
|
-
//
|
|
2133
|
-
const worktreeBase = await worktreeBaseFacts(input, projectDir, sessionId);
|
|
2134
|
-
let staleNote = null;
|
|
2489
|
+
// (#1424), with this check's own `decision` folded into the SAME record
|
|
2490
|
+
// rather than a second event.
|
|
2135
2491
|
if (worktreeBase !== null) {
|
|
2136
|
-
if (worktreeBase.stale === true) staleNote = staleWorktreeWarning(worktreeBase);
|
|
2137
2492
|
try {
|
|
2138
2493
|
const { emitEvent, sessionAttribution } = await import(
|
|
2139
2494
|
pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'events.mjs')).href
|
|
2140
2495
|
);
|
|
2141
2496
|
await emitEvent(
|
|
2142
2497
|
WORKTREE_BASE_EVENT,
|
|
2143
|
-
{
|
|
2144
|
-
|
|
2498
|
+
{
|
|
2499
|
+
hook: HOOK_NAME,
|
|
2500
|
+
...worktreeBase,
|
|
2501
|
+
decision: staleDeny === null ? 'allow' : 'deny',
|
|
2502
|
+
...sessionAttribution(sessionRoot),
|
|
2503
|
+
},
|
|
2504
|
+
{ repoRoot: sessionRoot }
|
|
2145
2505
|
);
|
|
2146
2506
|
} catch { /* observability is best-effort — it never blocks the decision */ }
|
|
2147
2507
|
}
|
|
2148
2508
|
|
|
2149
|
-
// ONE terminal emit
|
|
2150
|
-
//
|
|
2151
|
-
// carries the ⛔ headline, so the note stays on the debug channel — a stale
|
|
2152
|
-
// base is moot for a dispatch that was just blocked.
|
|
2509
|
+
// ONE terminal emit. A collision DENY keeps its own reason — the dispatch is
|
|
2510
|
+
// blocked either way — and the stale base goes to the debug channel beside it.
|
|
2153
2511
|
if (verdict.action === 'deny') {
|
|
2154
|
-
if (
|
|
2155
|
-
try { console.error(`⚠ ${
|
|
2512
|
+
if (staleDeny !== null) {
|
|
2513
|
+
try { console.error(`⚠ ${staleDeny.reason}`); } catch { /* stderr may be closed */ }
|
|
2156
2514
|
}
|
|
2157
2515
|
return emitDeny(verdict.reason, verdict.suggestion);
|
|
2158
2516
|
}
|
|
2159
|
-
if (
|
|
2160
|
-
|
|
2517
|
+
if (staleDeny !== null) {
|
|
2518
|
+
if (verdict.action === 'warn') {
|
|
2519
|
+
try { console.error(`⚠ ${verdict.note}`); } catch { /* stderr may be closed */ }
|
|
2520
|
+
}
|
|
2521
|
+
return emitDeny(staleDeny.reason, staleDeny.suggestion);
|
|
2161
2522
|
}
|
|
2162
|
-
if (
|
|
2523
|
+
if (verdict.action === 'warn') return emitWarn(verdict.note);
|
|
2163
2524
|
return emitAllow();
|
|
2164
2525
|
}
|
|
2165
2526
|
|
|
@@ -2207,9 +2568,10 @@ if (invokedAsScript()) {
|
|
|
2207
2568
|
// `enforce-scope`'s fail-closed handler — and the reason is the asymmetry
|
|
2208
2569
|
// named in the matrix header: enforce-scope guards a WRITE (denying one
|
|
2209
2570
|
// write is cheap), this guards the DISPATCH path (denying every dispatch
|
|
2210
|
-
// is a session outage).
|
|
2571
|
+
// is a session outage). A hidden COLLISION is caught downstream by
|
|
2211
2572
|
// `validate-wave-scope.mjs` and by `enforce-scope` at write time; a
|
|
2212
|
-
//
|
|
2573
|
+
// hidden #1485 stale base is not (matrix row 15) — the accepted cost of
|
|
2574
|
+
// the same asymmetry; a wrongly-denied dispatch is caught by nothing.
|
|
2213
2575
|
// -------------------------------------------------------------------------
|
|
2214
2576
|
try {
|
|
2215
2577
|
await bootstrap();
|