session-orchestrator 5.3.0 → 5.5.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/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- 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-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- 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 +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -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-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- 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 +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- 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 +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- 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 +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
|
@@ -76,7 +76,24 @@
|
|
|
76
76
|
* real background-batch collision through. Completion arrives later as a
|
|
77
77
|
* `<task-notification>` record carrying `<tool-use-id>toolu_…</tool-use-id>`
|
|
78
78
|
* and `<status>completed</status>` (measured: launch 14:14:26.768 →
|
|
79
|
-
* notification 14:24:39.360, ten minutes later).
|
|
79
|
+
* notification 14:24:39.360, ten minutes later). Any TERMINAL status
|
|
80
|
+
* finishes the id, not only `completed` — see {@link TERMINAL_STATUS_RE}.
|
|
81
|
+
* #1455: a 429'd agent notified `failed`, was resumed via SendMessage
|
|
82
|
+
* (new tool-use-id), and its original id never finished → permanent deny.
|
|
83
|
+
* Only a HARNESS-WRITTEN carrier record counts (#1459 Pkt 2 Rest,
|
|
84
|
+
* {@link notificationCarrierText}): a complete block inside a `tool_result`
|
|
85
|
+
* (a `Read` of a fixture), assistant text or a typed prompt is a forgery
|
|
86
|
+
* that would false-ALLOW an overlap with a still-running agent.
|
|
87
|
+
* c) EXACT ID, POSITIVE PROOF, RESUME (#1480, #1459 P1). A ledger entry keeps
|
|
88
|
+
* its dispatch's PreToolUse `tool_use_id` as `useId`, and liveness is
|
|
89
|
+
* looked up by THAT id; the description is only the fallback for entries
|
|
90
|
+
* without one, the ledger `id` never (B3). A dispatch finishes only on
|
|
91
|
+
* positive evidence — a terminal carrier, `is_error`, or a result in
|
|
92
|
+
* {@link RESULT_COMPLETION_FORMS}; any other result form is "unknown",
|
|
93
|
+
* i.e. no evidence (B2, row 13). A `SendMessage` to the dispatch's task id
|
|
94
|
+
* re-opens it until a terminal carrier for the dispatch, that SendMessage or
|
|
95
|
+
* the task id follows — a resumed agent is running, not done (#1459 P1;
|
|
96
|
+
* {@link buildLivenessIndex}).
|
|
80
97
|
*
|
|
81
98
|
* COST CONTAINMENT: the transcript is read ONLY when a collision has already
|
|
82
99
|
* been found — i.e. on the path that is about to deny. The 99 % no-collision
|
|
@@ -84,8 +101,9 @@
|
|
|
84
101
|
* costs 78 ms to read + 129 ms to scan; a typical one is 1–5 MB.
|
|
85
102
|
*
|
|
86
103
|
* BLIND FALLBACK + ITS CEILING (BV-004): when the transcript is unavailable or
|
|
87
|
-
* carries no
|
|
88
|
-
*
|
|
104
|
+
* carries no positive evidence for that agent (no record, or only an unknown
|
|
105
|
+
* result form), liveness falls back to the ledger entry's own age, with
|
|
106
|
+
* `IN_FLIGHT_TTL_MS` = 30 min. Named ceiling: the largest
|
|
89
107
|
* MEASURED same-batch dispatch spread is 95.7 s, so 30 min is ~19× headroom
|
|
90
108
|
* against the false-ALLOW direction, while both measured sequential repair gaps
|
|
91
109
|
* (36 min, 49 min) sit above it. Revisit trigger: a same-batch spread above
|
|
@@ -96,10 +114,14 @@
|
|
|
96
114
|
*
|
|
97
115
|
* A deny-capable hook on the DISPATCH path has an asymmetric blast radius: a
|
|
98
116
|
* false positive blocks every agent of the session (the guard becomes a session
|
|
99
|
-
* outage), while a false negative is a double-assignment
|
|
100
|
-
* still catch (`validate-wave-scope.mjs`,
|
|
101
|
-
* the W5 verification pass).
|
|
102
|
-
*
|
|
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:
|
|
103
125
|
*
|
|
104
126
|
* | # | Condition | Decision | Why |
|
|
105
127
|
* |---|----------------------------------------|---------------------|-----|
|
|
@@ -110,18 +132,21 @@
|
|
|
110
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" |
|
|
111
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 |
|
|
112
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` |
|
|
113
|
-
* | 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 |
|
|
114
136
|
* | 9 | `findScopeCollisions` → not evaluable | WARN + ALLOW | the library says "not evaluable". Denying on a verdict with no witness is an assertion without evidence |
|
|
115
|
-
* |10 | same agent id re-dispatched
|
|
116
|
-
* |10a| collision, but EVERY colliding prior agent has FINISHED | ALLOW (+ prune) | the sequential repair pass. Its ledger records are pruned, so the state cannot re-block the next one either |
|
|
117
|
-
* |11 | collision with a prior agent still IN FLIGHT | **DENY** | the
|
|
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 |
|
|
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 |
|
|
139
|
+
* |11 | collision with a prior agent still IN FLIGHT | **DENY** | the case this hook was built for (#1020) |
|
|
118
140
|
* |12 | unexpected throw | ALLOW + stderr | as row 2 |
|
|
119
|
-
* |13 | liveness probe throws / no evidence
|
|
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 |
|
|
120
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) |
|
|
121
144
|
*
|
|
122
145
|
* Every row that reaches a verdict from `decide()` — 5–11 and 13–14 — also
|
|
123
146
|
* leaves an event record (§ Observability), so "which row fired" is answerable
|
|
124
|
-
* 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
|
|
125
150
|
* was made), and neither do the two crash rows 2 and 12: a hook that fell over
|
|
126
151
|
* cannot describe itself, which is precisely why the GUARD INACTIVE banner on
|
|
127
152
|
* stderr is the signal there.
|
|
@@ -147,13 +172,21 @@
|
|
|
147
172
|
* ## Observability — the ledger half of #1092
|
|
148
173
|
*
|
|
149
174
|
* Every dispatch DECISION also appends one `orchestrator.wave_dispatch.scope_checked`
|
|
150
|
-
* 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
|
|
151
181
|
* exists is matrix rows 5/6: the no-signal ALLOW used to be byte-identical to
|
|
152
182
|
* "the guard never ran", and the in-ledger counter added first is a WAVE tally —
|
|
153
183
|
* it cannot say WHICH dispatch carried a scope. Payload: `wave` (omitted, never
|
|
154
184
|
* `0`, when unknown), `agent_id`, `declared_path_count`, `injected`, `shape`,
|
|
155
|
-
* `signal`, `ledger_result`, `collision_count`, `hook`,
|
|
156
|
-
*
|
|
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.
|
|
157
190
|
*
|
|
158
191
|
* WHAT IT PROVES: this hook SAW (or did not see) a `FILE-SCOPE` declaration in
|
|
159
192
|
* the prompt the coordinator handed to the dispatch tool, and what the guard
|
|
@@ -211,6 +244,7 @@ import path from 'node:path';
|
|
|
211
244
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
212
245
|
import { execFileSync } from 'node:child_process';
|
|
213
246
|
import { mkdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
247
|
+
import { homedir } from 'node:os';
|
|
214
248
|
|
|
215
249
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
216
250
|
|
|
@@ -231,7 +265,11 @@ import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
|
231
265
|
/** @type {typeof import('../scripts/lib/io.mjs').writeJsonAtomicSync} */ let writeJsonAtomicSync;
|
|
232
266
|
/** @type {typeof import('../scripts/lib/file-lock.mjs').withFileLock} */ let withFileLock;
|
|
233
267
|
/** @type {typeof import('../scripts/lib/scope-echo.mjs').scopeDigest} */ let scopeDigest;
|
|
268
|
+
/** @type {typeof import('../scripts/lib/platform.mjs').resolveSessionRoot} */ let resolveSessionRoot;
|
|
234
269
|
let findScopeCollisions;
|
|
270
|
+
let findOwnScopeFile;
|
|
271
|
+
let readProcessLocalSessionIds;
|
|
272
|
+
let classifyManifestSession;
|
|
235
273
|
|
|
236
274
|
const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
|
|
237
275
|
|
|
@@ -245,11 +283,43 @@ const HOOK_NAME = 'pre-task-scope-disjoint';
|
|
|
245
283
|
*/
|
|
246
284
|
const DISPATCH_TOOL = 'Agent';
|
|
247
285
|
|
|
248
|
-
/**
|
|
249
|
-
|
|
286
|
+
/**
|
|
287
|
+
* Ledger location (#1493.3): ONE FILE PER SESSION,
|
|
288
|
+
* `.orchestrator/wave-dispatch-scopes.<session>.json`, each with its own lock
|
|
289
|
+
* `.orchestrator/wave-dispatch-scopes.<session>.lock` (§ Ledger concurrency).
|
|
290
|
+
*
|
|
291
|
+
* Until #1493 every session of the working copy shared ONE file holding ONE
|
|
292
|
+
* `waveKey`, and `decide()` keeps prior agents only under the same key — whose
|
|
293
|
+
* first segment IS the session id. So cross-session claims were never compared
|
|
294
|
+
* (nothing is lost by splitting), but a peer session's dispatch reset the file
|
|
295
|
+
* and erased this session's live claims, and the deny's "delete the ledger"
|
|
296
|
+
* remedy erased the peer's. One file per session removes both.
|
|
297
|
+
*
|
|
298
|
+
* A flat sibling, not a directory, on purpose: bootstrap's `store-lock-ignore`
|
|
299
|
+
* step (#1495) ignores `.orchestrator/wave-dispatch-scopes.*` in consumer repos,
|
|
300
|
+
* which covers this name; a directory would surface untracked in each of them.
|
|
301
|
+
*/
|
|
302
|
+
const LEDGER_DIR_REL = '.orchestrator';
|
|
303
|
+
const LEDGER_STEM = 'wave-dispatch-scopes';
|
|
250
304
|
|
|
251
|
-
/**
|
|
252
|
-
|
|
305
|
+
/**
|
|
306
|
+
* The pre-#1493 single shared ledger. READ only, for migration: adopted when
|
|
307
|
+
* this session has no file yet AND its `waveKey` names this session; never
|
|
308
|
+
* written and never deleted — it may still be a peer's live state.
|
|
309
|
+
* Revisit-Trigger: remove this read one minor release after the split ships,
|
|
310
|
+
* when no session that recorded claims in the old file can still be running.
|
|
311
|
+
*/
|
|
312
|
+
const LEGACY_LEDGER_REL = path.join(LEDGER_DIR_REL, `${LEDGER_STEM}.json`);
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Longest session token a ledger file name carries. Harness session ids are
|
|
316
|
+
* UUIDs (36 chars) and pass through unchanged; the cap only keeps a pathological
|
|
317
|
+
* id below the 255-byte file-name limit. Two ids that sanitise to ONE token
|
|
318
|
+
* share a file again — degraded to the pre-#1493 behaviour, never worse, since
|
|
319
|
+
* `waveKey` still carries the raw id. Revisit if a harness ships ids that are
|
|
320
|
+
* not path-safe.
|
|
321
|
+
*/
|
|
322
|
+
const MAX_SESSION_TOKEN_CHARS = 128;
|
|
253
323
|
|
|
254
324
|
/**
|
|
255
325
|
* Ledger-lock budget. Short on purpose: the whole locked region is a read, a
|
|
@@ -260,6 +330,28 @@ const LEDGER_LOCK_REL = path.join('.orchestrator', 'wave-dispatch-scopes.lock');
|
|
|
260
330
|
const LEDGER_LOCK_TIMEOUT_MS = 2000;
|
|
261
331
|
const LEDGER_LOCK_POLL_MS = 25;
|
|
262
332
|
|
|
333
|
+
/**
|
|
334
|
+
* ONE deadline for ALL git work of one hook fire (#1489 Pkt 7). Every spawn
|
|
335
|
+
* gets at most what is LEFT of it, so the git work as a whole — never a single
|
|
336
|
+
* call — is what is bounded.
|
|
337
|
+
*
|
|
338
|
+
* The number is arithmetic, not taste: `hooks/hooks.json` gives this hook
|
|
339
|
+
* `"timeout": 5` (seconds) for the WHOLE process. The git work runs first; after
|
|
340
|
+
* it the ledger lock may wait up to `LEDGER_LOCK_TIMEOUT_MS` (2000). 5000 − 2000
|
|
341
|
+
* − 1500 leaves 1500 ms for node start, the late-bound imports, the transcript
|
|
342
|
+
* scan (≤ ~210 ms at the largest measured 70 MB) and the two awaited event
|
|
343
|
+
* appends — ~20× the 69 ms full path measured idle (§ Measured cost). The git
|
|
344
|
+
* work itself measured 27.8 ms together with lock, ledger and compare, so 1500
|
|
345
|
+
* binds only a git that hangs (a stuck lock file, a network-backed `HEAD`, a
|
|
346
|
+
* frozen filesystem). Before, each worktree call had its own 5 s and
|
|
347
|
+
* `listTrackedFiles` none: one hanging git outlived the harness, which killed
|
|
348
|
+
* the hook — a silent fail-open with no record of the lost verdict.
|
|
349
|
+
*
|
|
350
|
+
* Revisit-Trigger: a change of this hook's `timeout` in `hooks/hooks.json` or of
|
|
351
|
+
* `LEDGER_LOCK_TIMEOUT_MS` — the three numbers must keep summing below 5000.
|
|
352
|
+
*/
|
|
353
|
+
const GIT_BUDGET_MS = 1500;
|
|
354
|
+
|
|
263
355
|
/** Payload bounds — see § stdout discipline. */
|
|
264
356
|
const MAX_REPORTED_COLLISIONS = 5;
|
|
265
357
|
const MAX_EVIDENCE_PER_COLLISION = 4;
|
|
@@ -289,14 +381,14 @@ const SHAPE_NONE = 'none';
|
|
|
289
381
|
const SCOPE_EVENT = 'orchestrator.wave_dispatch.scope_checked';
|
|
290
382
|
|
|
291
383
|
/**
|
|
292
|
-
* The worktree-base observability record (#1413).
|
|
384
|
+
* The worktree-base observability record (#1413; carries the #1485 `decision`).
|
|
293
385
|
*
|
|
294
386
|
* Same `wave_dispatch` domain and same `_checked` verb as `SCOPE_EVENT`, and
|
|
295
387
|
* written for EVERY `isolation: "worktree"` dispatch rather than only the
|
|
296
388
|
* alarming one. That is HR-105 applied at the source: a numerator-only stream
|
|
297
|
-
* cannot tell "genuinely rare" from "silently broken", and the firing rate
|
|
298
|
-
*
|
|
299
|
-
*
|
|
389
|
+
* cannot tell "genuinely rare" from "silently broken", and the firing rate of
|
|
390
|
+
* the #1485 deny is then unfalsifiable. With every outcome in one stream the
|
|
391
|
+
* rate is `decision:"deny" / all records of this name`.
|
|
300
392
|
*
|
|
301
393
|
* #1424 widened "every outcome" from two to three: the `stale: true|false`
|
|
302
394
|
* split was already covered, but each NON-measurement (peer STATE.md, no
|
|
@@ -308,9 +400,6 @@ const SCOPE_EVENT = 'orchestrator.wave_dispatch.scope_checked';
|
|
|
308
400
|
*/
|
|
309
401
|
const WORKTREE_BASE_EVENT = 'orchestrator.wave_dispatch.worktree_base_checked';
|
|
310
402
|
|
|
311
|
-
/** State-dir candidates, in the same order `waveKeyOf()` probes them. */
|
|
312
|
-
const STATE_DIR_CANDIDATES = ['.pi', '.cursor', '.codex', '.claude'];
|
|
313
|
-
|
|
314
403
|
/**
|
|
315
404
|
* The receive-side instruction line `renderScopeEchoInstruction()` renders and
|
|
316
405
|
* the coordinator appends immediately after the fenced block
|
|
@@ -358,9 +447,77 @@ const MAX_TRANSCRIPT_BYTES = 256 * 1024 * 1024;
|
|
|
358
447
|
* a completion — see § Liveness (b). Reading it as one would let every real
|
|
359
448
|
* background-batch collision through, which is the one direction this repair
|
|
360
449
|
* must not take.
|
|
450
|
+
*
|
|
451
|
+
* Recognised only as the PREFIX of the result text (after trimStart): a finished
|
|
452
|
+
* sync report that merely QUOTES this sentence is not a launch receipt, and read
|
|
453
|
+
* as one it stayed "running" without TTL (R1 LOW). Census 2026-09-30, 5,881
|
|
454
|
+
* local transcripts: 4,684 of 4,684 ACK results begin with it, also in their
|
|
455
|
+
* first content part.
|
|
361
456
|
*/
|
|
362
457
|
const ASYNC_LAUNCH_ACK = 'Async agent launched successfully';
|
|
363
458
|
|
|
459
|
+
/**
|
|
460
|
+
* The `tool_result` forms that PROVE a dispatch finished (#1480 B2), one line
|
|
461
|
+
* per form. A result matching none of them is NOT a completion but "no
|
|
462
|
+
* evidence" — the entry falls back to the TTL rule (matrix row 13). `is_error`
|
|
463
|
+
* is handled beside this list (the dispatch never ran), and the ACK is tested
|
|
464
|
+
* FIRST, so a launch receipt can never read as a completion.
|
|
465
|
+
*
|
|
466
|
+
* Census 2026-09-30 (local transcripts, 14 days, counts only), Agent
|
|
467
|
+
* tool_results: ACK 4,730; sync completion carrying the `<usage>`/`total_tokens`
|
|
468
|
+
* trailer and `agentId:` 690 (690/690); `is_error` 37; neither 38. Of those 38,
|
|
469
|
+
* "This agent's report was delivered…" is a completion (35/35 without a
|
|
470
|
+
* terminal carrier before or after, 2026-09-30), "Fork started — processing in
|
|
471
|
+
* b…" is not (a start receipt without the ACK wording — before this list it was
|
|
472
|
+
* read as a completion: the B2 fail-open).
|
|
473
|
+
*
|
|
474
|
+
* Named ceiling (BV-004): any other wording reads as "unknown", so a fresh entry
|
|
475
|
+
* binds for up to IN_FLIGHT_TTL_MS although its agent may be done — a bounded
|
|
476
|
+
* false-DENY, never a false-ALLOW. Revisit-Trigger: a census re-run finds an
|
|
477
|
+
* Agent tool_result wording in neither this list nor ASYNC_LAUNCH_ACK — classify
|
|
478
|
+
* it, then add ONE line here.
|
|
479
|
+
*/
|
|
480
|
+
const RESULT_COMPLETION_FORMS = Object.freeze([
|
|
481
|
+
Object.freeze({ at: 'anywhere', text: '<usage>' }), // sync trailer, 690/690
|
|
482
|
+
Object.freeze({ at: 'anywhere', text: 'total_tokens' }), // sync trailer, 690/690
|
|
483
|
+
Object.freeze({ at: 'start', text: "This agent's report was delivered" }), // 35/35 without carrier
|
|
484
|
+
]);
|
|
485
|
+
|
|
486
|
+
/** The tool that addresses — and can RESUME — an already-dispatched agent (#1459 P1). */
|
|
487
|
+
const SEND_MESSAGE_TOOL = 'SendMessage';
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* A `SendMessage` result that reached nobody (`{"success":false,…"No agent
|
|
491
|
+
* named…"}`, census 2026-09-30): such a send activates nothing.
|
|
492
|
+
*/
|
|
493
|
+
const SEND_NO_EFFECT = '"success":false';
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* The harness's `agentId:` line in an Agent tool_result (ACK or sync trailer) —
|
|
497
|
+
* the task id a `SendMessage` addresses. Only a line that BEGINS with it, id on
|
|
498
|
+
* the same line, read as the LAST match over the content parts joined with
|
|
499
|
+
* `\n`: a sync result embeds the agent's own report ahead of the harness
|
|
500
|
+
* trailer, and a report ending in `agentId:` must not swallow the trailer's
|
|
501
|
+
* line (R1 MEDIUM — a wrong task id let a resumed agent read as finished).
|
|
502
|
+
* Census 2026-09-30, 5,881 local transcripts: in 5,375 of 5,375 Agent results
|
|
503
|
+
* carrying `agentId:`, the last one starts a line and sits in the last part.
|
|
504
|
+
*/
|
|
505
|
+
const AGENT_ID_LINE_RE = /^agentId:[ \t]*([A-Za-z0-9_-]+)/gm;
|
|
506
|
+
|
|
507
|
+
/** Per-dispatch liveness states — see {@link buildLivenessIndex}. */
|
|
508
|
+
const LIVE_FINISHED = 'finished';
|
|
509
|
+
const LIVE_RUNNING = 'running';
|
|
510
|
+
const LIVE_UNKNOWN = 'unknown';
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* `<task-notification>` statuses that mean the task is NO LONGER RUNNING (#1455).
|
|
514
|
+
* Enumerated from evidence, not guessed — census 2026-09-25 over the operator's
|
|
515
|
+
* local Claude Code transcripts (`rg "<tool-use-id>…<status>X</status>"` on
|
|
516
|
+
* `*.jsonl`): completed, failed 1430 lines, killed 609, stopped 28.
|
|
517
|
+
* `running` is deliberately absent — it is the one non-terminal spelling.
|
|
518
|
+
*/
|
|
519
|
+
const TERMINAL_STATUS_RE = /<status>(?:completed|failed|killed|stopped)<\/status>/;
|
|
520
|
+
|
|
364
521
|
/**
|
|
365
522
|
* The consequence block spliced VERBATIM into the GUARD INACTIVE banner (#993),
|
|
366
523
|
* naming the enforcement this hook's outage stops applying.
|
|
@@ -400,6 +557,7 @@ async function bootstrap() {
|
|
|
400
557
|
{
|
|
401
558
|
io: { specifier: lib('io.mjs') },
|
|
402
559
|
scopeGate: { specifier: lib('scope-gate.mjs') },
|
|
560
|
+
sessionIdentity: { specifier: lib('session-identity', 'own-session.mjs') },
|
|
403
561
|
fileLock: { specifier: lib('file-lock.mjs') },
|
|
404
562
|
// ONE normalization for the digest, shared with the receive side (#1092):
|
|
405
563
|
// `scope-echo.mjs` is pure (stdlib + `crypto-digest-utils.mjs`) and its
|
|
@@ -409,6 +567,10 @@ async function bootstrap() {
|
|
|
409
567
|
// names. Late-bound like every other repo module so a load failure
|
|
410
568
|
// banners instead of disarming the guard silently (#993).
|
|
411
569
|
scopeEcho: { specifier: lib('scope-echo.mjs') },
|
|
570
|
+
// #1492: ONE session-root resolver for every hook that reads the
|
|
571
|
+
// session's control files — this ledger, and `wave-scope.json` in
|
|
572
|
+
// enforce-scope / enforce-commands / post-bash-write-verify.
|
|
573
|
+
platform: { specifier: lib('platform.mjs') },
|
|
412
574
|
},
|
|
413
575
|
{
|
|
414
576
|
hookName: HOOK_NAME,
|
|
@@ -419,9 +581,11 @@ async function bootstrap() {
|
|
|
419
581
|
);
|
|
420
582
|
|
|
421
583
|
({ readStdin, emitAllow, emitDeny, emitWarn, writeJsonAtomicSync } = modules.io);
|
|
422
|
-
({ findScopeCollisions } = modules.scopeGate);
|
|
584
|
+
({ findScopeCollisions, findOwnScopeFile } = modules.scopeGate);
|
|
585
|
+
({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
|
|
423
586
|
({ withFileLock } = modules.fileLock);
|
|
424
587
|
({ scopeDigest } = modules.scopeEcho);
|
|
588
|
+
({ resolveSessionRoot } = modules.platform);
|
|
425
589
|
}
|
|
426
590
|
|
|
427
591
|
// ---------------------------------------------------------------------------
|
|
@@ -750,8 +914,8 @@ export function extractScopeFromPrompt(prompt) {
|
|
|
750
914
|
|
|
751
915
|
/**
|
|
752
916
|
* The dispatch's human description — the field the liveness probe matches
|
|
753
|
-
* against the transcript's `tool_use` blocks
|
|
754
|
-
* payloads).
|
|
917
|
+
* against the transcript's `tool_use` blocks when an entry carries no exact
|
|
918
|
+
* `useId` (present in 147/147 measured payloads).
|
|
755
919
|
*
|
|
756
920
|
* @param {{description?: unknown}} toolInput
|
|
757
921
|
* @returns {string}
|
|
@@ -777,69 +941,401 @@ export function agentIdOf(toolInput) {
|
|
|
777
941
|
return 'unnamed-agent';
|
|
778
942
|
}
|
|
779
943
|
|
|
944
|
+
/**
|
|
945
|
+
* A dispatch's EXACT identity (#1480 A): the PreToolUse payload's `tool_use_id`,
|
|
946
|
+
* or a ledger entry's stored `useId`. On Claude Code it equals the transcript's
|
|
947
|
+
* `tool_use` id, so liveness becomes a lookup instead of a description match;
|
|
948
|
+
* other harnesses may omit it, and `''` then means "no exact id". Taken
|
|
949
|
+
* verbatim, never re-spelled — and dropped rather than clipped past
|
|
950
|
+
* MAX_AGENT_ID_CHARS: a clipped id could never match, two clipped ids could
|
|
951
|
+
* match wrongly.
|
|
952
|
+
*
|
|
953
|
+
* @param {unknown} value
|
|
954
|
+
* @returns {string}
|
|
955
|
+
*/
|
|
956
|
+
function exactUseId(value) {
|
|
957
|
+
return typeof value === 'string' && value.trim() !== '' && value.length <= MAX_AGENT_ID_CHARS ? value : '';
|
|
958
|
+
}
|
|
959
|
+
|
|
780
960
|
// ---------------------------------------------------------------------------
|
|
781
961
|
// Liveness — has an already-recorded agent FINISHED? (§ Liveness)
|
|
782
962
|
// ---------------------------------------------------------------------------
|
|
783
963
|
|
|
964
|
+
const NOTIFICATION_OPEN = '<task-notification>';
|
|
965
|
+
const NOTIFICATION_CLOSE = '</task-notification>';
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* A notification head in the harness's own tag order: `<task-id>`,
|
|
969
|
+
* `<tool-use-id>`, optional `<output-file>`, `<status>` — anchored at the opener.
|
|
970
|
+
* Group 1 is the task id (the resume key, #1459 P1), group 2 the tool-use id,
|
|
971
|
+
* group 3 the whole `<status>` element.
|
|
972
|
+
*/
|
|
973
|
+
const NOTIFICATION_HEAD_RE = /^<task-notification>\s*<task-id>([^<]*)<\/task-id>\s*<tool-use-id>([^<]+)<\/tool-use-id>\s*(?:<output-file>[^<]*<\/output-file>\s*)?(<status>[^<]*<\/status>)/;
|
|
974
|
+
|
|
975
|
+
/** `origin.kind` / `commandMode` value the harness stamps on a notification carrier. */
|
|
976
|
+
const CARRIER_KIND = 'task-notification';
|
|
977
|
+
|
|
978
|
+
/** `queue-operation` operations measured to carry a notification (dequeue carries no content). */
|
|
979
|
+
const CARRIER_QUEUE_OPS = new Set(['enqueue', 'remove']);
|
|
980
|
+
|
|
981
|
+
/** First line of the harness preamble some `user` carriers put before the block. */
|
|
982
|
+
const SYSTEM_NOTIFICATION_MARK = '[SYSTEM NOTIFICATION - NOT USER INPUT]\n';
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* The notification text of a HARNESS-WRITTEN carrier record, or `null` for any
|
|
986
|
+
* other record (#1459 Pkt 2 Rest). Census 2026-09-28 over the operator's local
|
|
987
|
+
* transcripts (11,005 files, 2026-08-06 → 2026-09-28): every terminal id the
|
|
988
|
+
* pre-fix raw-line match accepted from a real carrier came from exactly one of
|
|
989
|
+
* three record forms, each of which carries ids the other two do not:
|
|
990
|
+
* - `user` with STRING `message.content` and `origin.kind: 'task-notification'`
|
|
991
|
+
* (a typed prompt carries `origin.kind: 'human'`). The text begins with the
|
|
992
|
+
* opener, or with the harness preamble ({@link SYSTEM_NOTIFICATION_MARK},
|
|
993
|
+
* tag-free, ending in a blank line) directly followed by the opener;
|
|
994
|
+
* - `queue-operation` with `operation` `enqueue`/`remove` and string `content`;
|
|
995
|
+
* - `attachment` of type `queued_command` with `commandMode: 'task-notification'`.
|
|
996
|
+
* Everything else — `tool_result` content, assistant text, tool inputs, typed
|
|
997
|
+
* prompts — is a quote or a forgery and yields `null`, i.e. no finished id.
|
|
998
|
+
*
|
|
999
|
+
* The guard on the `user` form is `origin.kind`, which only the harness sets; the
|
|
1000
|
+
* preamble rule merely strips a tag-free prefix and is no control on its own.
|
|
1001
|
+
*
|
|
1002
|
+
* Named residual (BV-004): a `queue-operation` record has no field that tells a
|
|
1003
|
+
* harness notification from an operator prompt typed while the agent was busy,
|
|
1004
|
+
* so an OPERATOR could forge through that one channel. An agent cannot through
|
|
1005
|
+
* the one producer censused: text a subagent or peer delivers via `SendMessage`
|
|
1006
|
+
* arrives wrapped by the harness, so it never begins with the opener. Measured
|
|
1007
|
+
* 2026-09-28 in a second run (count only, 11,008 transcript files, three more
|
|
1008
|
+
* than the census above): 7,131 records carry such a message, 1,801 of them as
|
|
1009
|
+
* `queue-operation`, 2,486 as `queued_command` with `commandMode: 'prompt'`;
|
|
1010
|
+
* 0 of the 7,131 begin with `<task-notification>`. Other queue producers
|
|
1011
|
+
* (scheduled wake-ups, a `/loop` body) were not censused. Revisit if the harness
|
|
1012
|
+
* adds a discriminator here (like `commandMode` on the attachment) or stops
|
|
1013
|
+
* wrapping.
|
|
1014
|
+
*
|
|
1015
|
+
* @param {any} rec — one parsed transcript record
|
|
1016
|
+
* @returns {string|null}
|
|
1017
|
+
*/
|
|
1018
|
+
function notificationCarrierText(rec) {
|
|
1019
|
+
if (rec?.type === 'user' && rec?.origin?.kind === CARRIER_KIND && typeof rec?.message?.content === 'string') {
|
|
1020
|
+
const text = rec.message.content;
|
|
1021
|
+
if (!text.startsWith(SYSTEM_NOTIFICATION_MARK)) return text;
|
|
1022
|
+
const open = text.indexOf('<');
|
|
1023
|
+
return open !== -1 && text.startsWith(`\n\n${NOTIFICATION_OPEN}`, open - 2) ? text.slice(open) : null;
|
|
1024
|
+
}
|
|
1025
|
+
if (rec?.type === 'queue-operation' && CARRIER_QUEUE_OPS.has(rec?.operation) && typeof rec?.content === 'string') {
|
|
1026
|
+
return rec.content;
|
|
1027
|
+
}
|
|
1028
|
+
const att = rec?.type === 'attachment' ? rec?.attachment : undefined;
|
|
1029
|
+
if (att?.type === 'queued_command' && att?.commandMode === CARRIER_KIND && typeof att?.prompt === 'string') {
|
|
1030
|
+
return att.prompt;
|
|
1031
|
+
}
|
|
1032
|
+
return null;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
784
1035
|
/**
|
|
785
|
-
*
|
|
1036
|
+
* The HEAD of a carrier text's FIRST `<task-notification>` block (#1459 Pkt 2) —
|
|
1037
|
+
* its task id, its tool-use id and whether its status is TERMINAL; at most one
|
|
1038
|
+
* head per carrier. Only a terminal head finishes an id; a non-terminal one only
|
|
1039
|
+
* contributes the task id (#1459 P1), which can open a dispatch, never close it.
|
|
1040
|
+
* (Named `finishedNotificationIds` until #1480; the ceiling below is unchanged.)
|
|
1041
|
+
*
|
|
1042
|
+
* The text must BEGIN with the opener (a block further in is a quote) and its
|
|
1043
|
+
* head must follow the harness tag order ({@link NOTIFICATION_HEAD_RE}).
|
|
1044
|
+
* `<summary>` and `<result>` carry free text that can QUOTE another
|
|
1045
|
+
* notification's `<tool-use-id>` and `<status>`, or even forge a whole block
|
|
1046
|
+
* (R2 F1: a summary that closes itself and opens a complete fake block defeated
|
|
1047
|
+
* the earlier cut-then-split approach). So nothing past the block's HEAD is read:
|
|
1048
|
+
* the head runs from the opener to the earliest `<summary>`, `<result>` or
|
|
1049
|
+
* `</task-notification>`. Fail-closed — the deny stays — when the text is not a
|
|
1050
|
+
* carrier's, does not begin with the opener, has no closing tag (truncated
|
|
1051
|
+
* block), or its head is out of order or carries no terminal status.
|
|
1052
|
+
*
|
|
1053
|
+
* Named ceiling (BV-004, #1467): a batch of several DIFFERENT notifications in
|
|
1054
|
+
* one carrier reports only the first as finished (the rest stay running, the
|
|
1055
|
+
* safe direction but a false-DENY source). Census 2026-09-30 over the operator's
|
|
1056
|
+
* local transcripts of all repos (11,684 files, 2026-08-06 → 2026-09-30, at
|
|
1057
|
+
* f58480e5): 23 preamble-form `user` carriers hold 2+ distinct terminal ids; 108
|
|
1058
|
+
* non-first ids are finished by no other carrier, and 0 of them is an `Agent`
|
|
1059
|
+
* dispatch id (105 Bash, 3 Monitor) — the only ids this index resolves, so no
|
|
1060
|
+
* Agent liveness is lost today. The 2026-09-28 figures (31 / 155) were not
|
|
1061
|
+
* reproduced. No split is built: it would gain nothing, and its safety is
|
|
1062
|
+
* unproven. `origin` has no per-block discriminator, so a split is safe only if
|
|
1063
|
+
* the harness escapes `<` in `<summary>`/`<result>` — unescaped, a block forged
|
|
1064
|
+
* there is byte-identical to a genuine `\n\n`-joined batch. The census suggests
|
|
1065
|
+
* escaping (12,039 of 25,031 results carry `<`, 3 a raw `<` + letter) but
|
|
1066
|
+
* does not establish it for every harness version. Revisit-Trigger: a re-run
|
|
1067
|
+
* census finds a non-first terminal id that is an `Agent` dispatch id finished
|
|
1068
|
+
* by no other carrier or `tool_result`; a split then needs escaping proven first.
|
|
1069
|
+
*
|
|
1070
|
+
* @param {string|null} text — a carrier's text from {@link notificationCarrierText}
|
|
1071
|
+
* @returns {{taskId: string, toolUseId: string, terminal: boolean}|null}
|
|
1072
|
+
*/
|
|
1073
|
+
function notificationHead(text) {
|
|
1074
|
+
if (typeof text !== 'string' || !text.startsWith(NOTIFICATION_OPEN)) return null;
|
|
1075
|
+
const close = text.indexOf(NOTIFICATION_CLOSE);
|
|
1076
|
+
if (close === -1) return null;
|
|
1077
|
+
let headEnd = close;
|
|
1078
|
+
for (const tag of ['<summary>', '<result>']) {
|
|
1079
|
+
const at = text.indexOf(tag);
|
|
1080
|
+
if (at !== -1 && at < headEnd) headEnd = at;
|
|
1081
|
+
}
|
|
1082
|
+
const head = text.slice(0, headEnd).match(NOTIFICATION_HEAD_RE);
|
|
1083
|
+
if (head === null) return null;
|
|
1084
|
+
return { taskId: head[1].trim(), toolUseId: head[2], terminal: TERMINAL_STATUS_RE.test(head[3]) };
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Classify an Agent dispatch's OWN `tool_result` (#1480 B2): `done` only on
|
|
1089
|
+
* positive evidence (`is_error`, or one of {@link RESULT_COMPLETION_FORMS}),
|
|
1090
|
+
* `ack` for the launch acknowledgement (a text PREFIX, see
|
|
1091
|
+
* {@link ASYNC_LAUNCH_ACK}), `unknown` for everything else. The ACK is tested
|
|
1092
|
+
* before the forms, so no ACK wording can ever read as a completion.
|
|
1093
|
+
*
|
|
1094
|
+
* @param {{is_error?: unknown}} block
|
|
1095
|
+
* @param {string} text — {@link resultTextOf}(block)
|
|
1096
|
+
* @returns {'done'|'ack'|'unknown'}
|
|
1097
|
+
*/
|
|
1098
|
+
function dispatchResultForm(block, text) {
|
|
1099
|
+
if (block?.is_error === true) return 'done';
|
|
1100
|
+
const lead = text.trimStart();
|
|
1101
|
+
if (lead.startsWith(ASYNC_LAUNCH_ACK)) return 'ack';
|
|
1102
|
+
for (const form of RESULT_COMPLETION_FORMS) {
|
|
1103
|
+
if (form.at === 'start' ? lead.startsWith(form.text) : text.includes(form.text)) return 'done';
|
|
1104
|
+
}
|
|
1105
|
+
return 'unknown';
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* The LAST line-leading `agentId:` a harness-written dispatch result names, or
|
|
1110
|
+
* `''` — see {@link AGENT_ID_LINE_RE}. Parts are joined with `\n` HERE (unlike
|
|
1111
|
+
* {@link resultTextOf}), so a part boundary is a line boundary and report text
|
|
1112
|
+
* cannot run into the trailer's line.
|
|
786
1113
|
*
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
1114
|
+
* @param {{content?: unknown}} block
|
|
1115
|
+
* @returns {string}
|
|
1116
|
+
*/
|
|
1117
|
+
function lastAgentIdIn(block) {
|
|
1118
|
+
const c = block?.content;
|
|
1119
|
+
const text = typeof c === 'string'
|
|
1120
|
+
? c
|
|
1121
|
+
: Array.isArray(c) ? c.map((part) => (typeof part?.text === 'string' ? part.text : '')).join('\n') : '';
|
|
1122
|
+
let last = '';
|
|
1123
|
+
for (const m of text.matchAll(AGENT_ID_LINE_RE)) last = m[1];
|
|
1124
|
+
return last;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Append `value` to the array stored under `key`.
|
|
793
1129
|
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
1130
|
+
* @template T
|
|
1131
|
+
* @param {Map<string, T[]>} map
|
|
1132
|
+
* @param {string} key
|
|
1133
|
+
* @param {T} value
|
|
1134
|
+
*/
|
|
1135
|
+
function pushTo(map, key, value) {
|
|
1136
|
+
const list = map.get(key);
|
|
1137
|
+
if (list === undefined) map.set(key, [value]);
|
|
1138
|
+
else list.push(value);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Index a session transcript by DISPATCH tool-use id → liveness state, plus
|
|
1143
|
+
* description → dispatch ids for ledger entries that carry no exact id.
|
|
1144
|
+
*
|
|
1145
|
+
* Record shapes read, all measured (census 2026-09-30 for the SendMessage half):
|
|
1146
|
+
* - `tool_use` `{name:'Agent', id, input.description}` — the dispatch U.
|
|
1147
|
+
* - `tool_use` `{name:'SendMessage', id, input.to}` — addresses a dispatch by
|
|
1148
|
+
* its task id T(U) (= agentId); 1,055 via a notification task id, 33 via the
|
|
1149
|
+
* ACK's agentId, 0 via the description.
|
|
1150
|
+
* - `tool_result` for U — classified by {@link dispatchResultForm}; for a
|
|
1151
|
+
* SendMessage only whether it reached nobody ({@link SEND_NO_EFFECT} or
|
|
1152
|
+
* `is_error`).
|
|
1153
|
+
* - a `<task-notification>` CARRIER record — only a harness-written carrier
|
|
1154
|
+
* ({@link notificationCarrierText}), only its first block's head
|
|
1155
|
+
* ({@link notificationHead}); a terminal `<status>` finishes (#1455: `failed`
|
|
1156
|
+
* too).
|
|
1157
|
+
*
|
|
1158
|
+
* T(U) is the last line-leading `agentId:` ({@link lastAgentIdIn}) of U's own
|
|
1159
|
+
* ACK or positive result, else the task
|
|
1160
|
+
* id of the first carrier head carrying U's tool-use id.
|
|
1161
|
+
*
|
|
1162
|
+
* RESUME (#1459 P1). U is ACTIVATED by its dispatch and by every SendMessage
|
|
1163
|
+
* with `to === T(U)` whose result is not a no-effect (a send without a result
|
|
1164
|
+
* yet counts — the safe direction). U is FINISHED when a completion lies AFTER
|
|
1165
|
+
* its last activation, by transcript line position: a terminal carrier with
|
|
1166
|
+
* tool-use id U, with the id of an activating SendMessage, or with task id T(U);
|
|
1167
|
+
* when the dispatch itself is the last activation, also U's own positive
|
|
1168
|
+
* result. Measured 2026-09-30: a resumed agent completes under the
|
|
1169
|
+
* SendMessage's id and the same task id, never again under U (455 cases); a
|
|
1170
|
+
* message queued to a running agent completes under U with the same task id
|
|
1171
|
+
* (566). Activations only OPEN; closing still needs a carrier or U's own
|
|
1172
|
+
* harness-written result.
|
|
1173
|
+
*
|
|
1174
|
+
* States: `finished`; `running` — no result, an ACK, or a SendMessage as last
|
|
1175
|
+
* activation with no completion after it (no TTL); `unknown` — only a result
|
|
1176
|
+
* form without positive evidence, which the probe treats as no evidence (TTL).
|
|
1177
|
+
* A repeated `tool_use` record (same Agent or SendMessage id) is the SAME event,
|
|
1178
|
+
* not a new activation: its position is the FIRST occurrence, so a copy after
|
|
1179
|
+
* the completion cannot re-open a finished agent (R1 D4, a false-DENY). A
|
|
1180
|
+
* repeated carrier still closes. Hardening without a measured case: census
|
|
1181
|
+
* 2026-09-30 (5,881 local transcripts) found 0 repeated Agent and 0 repeated
|
|
1182
|
+
* SendMessage `tool_use` records.
|
|
1183
|
+
*
|
|
1184
|
+
* Named ceiling (BV-004): `to` is matched against T(U) only — a SendMessage that
|
|
1185
|
+
* addresses the agent by name or description re-opens nothing (0 of ~6,200
|
|
1186
|
+
* measured 2026-09-30). Revisit-Trigger: a census re-run finds a SendMessage to
|
|
1187
|
+
* a dispatch whose `to` is not its task id.
|
|
797
1188
|
*
|
|
798
1189
|
* Pure and total — a malformed line is skipped, never thrown on.
|
|
799
1190
|
*
|
|
800
1191
|
* @param {string} raw — the transcript's JSONL text
|
|
801
|
-
* @returns {Map<string,
|
|
1192
|
+
* @returns {{states: Map<string, 'finished'|'running'|'unknown'>,
|
|
1193
|
+
* idsByDesc: Map<string, string[]>}}
|
|
802
1194
|
*/
|
|
803
|
-
|
|
804
|
-
const
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
|
|
1195
|
+
function buildLivenessIndex(raw) {
|
|
1196
|
+
const states = new Map();
|
|
1197
|
+
const idsByDesc = new Map();
|
|
1198
|
+
if (typeof raw !== 'string' || raw.length === 0) return { states, idsByDesc };
|
|
1199
|
+
|
|
1200
|
+
const dispatchPos = new Map(); // U → FIRST line position of its tool_use
|
|
1201
|
+
const results = new Map(); // U → [{pos, form, agentId}]
|
|
1202
|
+
const sendsByTo = new Map(); // SendMessage `to` → [{id, pos}]
|
|
1203
|
+
const sendIds = new Set();
|
|
1204
|
+
const sendNoEffect = new Set(); // SendMessage ids whose result reached nobody
|
|
1205
|
+
const headsByUse = new Map(); // carrier tool-use id → [{pos, taskId, terminal}]
|
|
1206
|
+
const terminalByTask = new Map(); // carrier task id → [pos] of terminal heads
|
|
1207
|
+
|
|
1208
|
+
const lines = raw.split('\n');
|
|
1209
|
+
for (let pos = 0; pos < lines.length; pos++) {
|
|
1210
|
+
const line = lines[pos];
|
|
811
1211
|
if (line.length < 24) continue;
|
|
1212
|
+
// Substring prefilter only — the verdict needs the PARSED record: a raw-line
|
|
1213
|
+
// match let a forged block in any record finish an id (#1459 Pkt 2 Rest).
|
|
1214
|
+
const hasNotification = line.includes('task-notification');
|
|
1215
|
+
const hasToolRow = line.includes('"tool_use"') || line.includes('tool_use_id');
|
|
1216
|
+
if (!hasNotification && !hasToolRow) continue;
|
|
1217
|
+
let rec;
|
|
1218
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
812
1219
|
|
|
813
|
-
//
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1220
|
+
// Completion (and the task id) — only from a harness-written carrier record.
|
|
1221
|
+
if (hasNotification) {
|
|
1222
|
+
const head = notificationHead(notificationCarrierText(rec));
|
|
1223
|
+
if (head !== null) {
|
|
1224
|
+
pushTo(headsByUse, head.toolUseId, { pos, taskId: head.taskId, terminal: head.terminal });
|
|
1225
|
+
if (head.terminal && head.taskId !== '') pushTo(terminalByTask, head.taskId, pos);
|
|
1226
|
+
}
|
|
818
1227
|
}
|
|
819
1228
|
|
|
820
|
-
if (!
|
|
821
|
-
let rec;
|
|
822
|
-
try { rec = JSON.parse(line); } catch { continue; }
|
|
1229
|
+
if (!hasToolRow) continue;
|
|
823
1230
|
const content = rec?.message?.content;
|
|
824
1231
|
if (!Array.isArray(content)) continue;
|
|
825
1232
|
|
|
826
1233
|
for (const block of content) {
|
|
827
|
-
if (block?.type === 'tool_use' &&
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1234
|
+
if (block?.type === 'tool_use' && typeof block?.id === 'string') {
|
|
1235
|
+
if (block.name === DISPATCH_TOOL) {
|
|
1236
|
+
if (!results.has(block.id)) { // first occurrence only — see D4 above
|
|
1237
|
+
results.set(block.id, []);
|
|
1238
|
+
dispatchPos.set(block.id, pos);
|
|
1239
|
+
const desc = typeof block?.input?.description === 'string' ? block.input.description.trim() : '';
|
|
1240
|
+
if (desc !== '') pushTo(idsByDesc, desc, block.id);
|
|
1241
|
+
}
|
|
1242
|
+
} else if (block.name === SEND_MESSAGE_TOOL && typeof block?.input?.to === 'string' && block.input.to !== ''
|
|
1243
|
+
&& !sendIds.has(block.id)) {
|
|
1244
|
+
sendIds.add(block.id);
|
|
1245
|
+
pushTo(sendsByTo, block.input.to, { id: block.id, pos });
|
|
1246
|
+
}
|
|
833
1247
|
continue;
|
|
834
1248
|
}
|
|
835
1249
|
if (block?.type === 'tool_result' && typeof block?.tool_use_id === 'string') {
|
|
836
|
-
|
|
837
|
-
if (
|
|
1250
|
+
const own = results.get(block.tool_use_id);
|
|
1251
|
+
if (own !== undefined) {
|
|
1252
|
+
const text = resultTextOf(block);
|
|
1253
|
+
const form = dispatchResultForm(block, text);
|
|
1254
|
+
// An error text is not a harness trailer — learn T(U) from ACK/completion only.
|
|
1255
|
+
const agentId = form === 'ack' || (form === 'done' && block.is_error !== true) ? lastAgentIdIn(block) : '';
|
|
1256
|
+
own.push({ pos, form, agentId });
|
|
1257
|
+
} else if (sendIds.has(block.tool_use_id)
|
|
1258
|
+
&& (block.is_error === true || resultTextOf(block).includes(SEND_NO_EFFECT))) {
|
|
1259
|
+
sendNoEffect.add(block.tool_use_id);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
for (const [useId, pos] of dispatchPos) {
|
|
1266
|
+
const own = results.get(useId) ?? [];
|
|
1267
|
+
const taskId = own.find((r) => r.agentId !== '')?.agentId
|
|
1268
|
+
?? (headsByUse.get(useId) ?? []).find((h) => h.taskId !== '')?.taskId
|
|
1269
|
+
?? '';
|
|
1270
|
+
|
|
1271
|
+
let lastPos = pos;
|
|
1272
|
+
let lastIsSend = false;
|
|
1273
|
+
const closers = [useId];
|
|
1274
|
+
if (taskId !== '') {
|
|
1275
|
+
for (const send of sendsByTo.get(taskId) ?? []) {
|
|
1276
|
+
if (sendNoEffect.has(send.id)) continue;
|
|
1277
|
+
closers.push(send.id);
|
|
1278
|
+
if (send.pos > lastPos) { lastPos = send.pos; lastIsSend = true; }
|
|
838
1279
|
}
|
|
839
1280
|
}
|
|
1281
|
+
|
|
1282
|
+
const closedAfter = closers.some((id) => (headsByUse.get(id) ?? []).some((h) => h.terminal && h.pos > lastPos))
|
|
1283
|
+
|| (taskId !== '' && (terminalByTask.get(taskId) ?? []).some((p) => p > lastPos))
|
|
1284
|
+
|| (!lastIsSend && own.some((r) => r.form === 'done' && r.pos > lastPos));
|
|
1285
|
+
|
|
1286
|
+
let state;
|
|
1287
|
+
if (closedAfter) state = LIVE_FINISHED;
|
|
1288
|
+
else if (lastIsSend || own.length === 0 || own.some((r) => r.form === 'ack')) state = LIVE_RUNNING;
|
|
1289
|
+
else state = LIVE_UNKNOWN;
|
|
1290
|
+
states.set(useId, state);
|
|
840
1291
|
}
|
|
1292
|
+
return { states, idsByDesc };
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Description-level verdict for a ledger entry WITHOUT an exact id: `false` if
|
|
1297
|
+
* any of the description's dispatches is running, `true` only if every one is
|
|
1298
|
+
* finished, `undefined` (no evidence → TTL) otherwise. `excludeUseId` drops the
|
|
1299
|
+
* CURRENT dispatch, whose `tool_use` may already sit in the transcript at
|
|
1300
|
+
* PreToolUse time and would make a finished same-named predecessor look alive.
|
|
1301
|
+
*
|
|
1302
|
+
* @param {{states: Map<string, string>, idsByDesc: Map<string, string[]>}} index
|
|
1303
|
+
* @param {string} desc
|
|
1304
|
+
* @param {string} excludeUseId
|
|
1305
|
+
* @returns {boolean|undefined}
|
|
1306
|
+
*/
|
|
1307
|
+
function descFinished(index, desc, excludeUseId) {
|
|
1308
|
+
let seen = false;
|
|
1309
|
+
let unknown = false;
|
|
1310
|
+
for (const useId of index.idsByDesc.get(desc) ?? []) {
|
|
1311
|
+
if (useId === excludeUseId) continue;
|
|
1312
|
+
seen = true;
|
|
1313
|
+
const state = index.states.get(useId);
|
|
1314
|
+
if (state === LIVE_RUNNING) return false;
|
|
1315
|
+
if (state !== LIVE_FINISHED) unknown = true;
|
|
1316
|
+
}
|
|
1317
|
+
return seen && !unknown ? true : undefined;
|
|
1318
|
+
}
|
|
841
1319
|
|
|
842
|
-
|
|
1320
|
+
/**
|
|
1321
|
+
* Index a session transcript by agent DESCRIPTION → completion state — the
|
|
1322
|
+
* description view of {@link buildLivenessIndex}.
|
|
1323
|
+
*
|
|
1324
|
+
* A description dispatched N times counts as finished only when EVERY one of its
|
|
1325
|
+
* tool_use ids is finished, and as running as soon as one is. Conservative on
|
|
1326
|
+
* purpose: one outstanding run of the same agent keeps the deny alive. A
|
|
1327
|
+
* description with no running run but an `unknown` one is absent (no evidence).
|
|
1328
|
+
*
|
|
1329
|
+
* @param {string} raw — the transcript's JSONL text
|
|
1330
|
+
* @returns {Map<string, boolean>} description → finished? (absent = no evidence)
|
|
1331
|
+
*/
|
|
1332
|
+
export function buildTranscriptIndex(raw) {
|
|
1333
|
+
const index = buildLivenessIndex(raw);
|
|
1334
|
+
const out = new Map();
|
|
1335
|
+
for (const desc of index.idsByDesc.keys()) {
|
|
1336
|
+
const finished = descFinished(index, desc, '');
|
|
1337
|
+
if (finished !== undefined) out.set(desc, finished);
|
|
1338
|
+
}
|
|
843
1339
|
return out;
|
|
844
1340
|
}
|
|
845
1341
|
|
|
@@ -866,10 +1362,16 @@ function resultTextOf(block) {
|
|
|
866
1362
|
* been found. The no-collision path — the overwhelming majority — never touches
|
|
867
1363
|
* the file (§ Liveness, cost containment).
|
|
868
1364
|
*
|
|
869
|
-
* Resolution order per ledger entry:
|
|
870
|
-
* 1.
|
|
871
|
-
*
|
|
872
|
-
*
|
|
1365
|
+
* Resolution order per ledger entry (#1480 A):
|
|
1366
|
+
* 1. an entry WITH `useId` → that exact dispatch's state: finished / running
|
|
1367
|
+
* are definitive, `unknown` or no record is no evidence;
|
|
1368
|
+
* 2. an entry WITHOUT `useId` (legacy, or a harness sending no `tool_use_id`)
|
|
1369
|
+
* → its non-empty description via {@link descFinished}, the current
|
|
1370
|
+
* dispatch (`selfUseId`) excluded. Never the ledger `id` — a
|
|
1371
|
+
* `desc (type)` string or a bare subagent type, which matched unrelated
|
|
1372
|
+
* dispatches (B3);
|
|
1373
|
+
* 3. no evidence → the entry's own age against `IN_FLIGHT_TTL_MS`;
|
|
1374
|
+
* 4. no usable timestamp either → NOT finished (matrix row 13 — keeps the
|
|
873
1375
|
* deny biting rather than inventing a completion).
|
|
874
1376
|
*
|
|
875
1377
|
* @param {object} params
|
|
@@ -877,9 +1379,13 @@ function resultTextOf(block) {
|
|
|
877
1379
|
* @param {number} [params.now]
|
|
878
1380
|
* @param {number} [params.ttlMs]
|
|
879
1381
|
* @param {(p: string, enc: string) => string} [params.readFn]
|
|
880
|
-
* @
|
|
1382
|
+
* @param {string} [params.selfUseId] the CURRENT dispatch's `tool_use_id`
|
|
1383
|
+
* @returns {(entry: {id: string, desc?: string, at?: string, useId?: string}) => boolean}
|
|
881
1384
|
*/
|
|
882
|
-
export function makeFinishedProbe({
|
|
1385
|
+
export function makeFinishedProbe({
|
|
1386
|
+
transcriptPath, now = Date.now(), ttlMs = IN_FLIGHT_TTL_MS, readFn = readFileSync, selfUseId,
|
|
1387
|
+
} = {}) {
|
|
1388
|
+
const excludeUseId = exactUseId(selfUseId);
|
|
883
1389
|
let index; // undefined = not loaded yet, null = unavailable
|
|
884
1390
|
const load = () => {
|
|
885
1391
|
if (index !== undefined) return index;
|
|
@@ -887,7 +1393,7 @@ export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN
|
|
|
887
1393
|
try {
|
|
888
1394
|
if (typeof transcriptPath === 'string' && transcriptPath.length > 0) {
|
|
889
1395
|
if (statSync(transcriptPath).size <= MAX_TRANSCRIPT_BYTES) {
|
|
890
|
-
index =
|
|
1396
|
+
index = buildLivenessIndex(readFn(transcriptPath, 'utf8'));
|
|
891
1397
|
}
|
|
892
1398
|
}
|
|
893
1399
|
} catch {
|
|
@@ -899,10 +1405,17 @@ export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN
|
|
|
899
1405
|
return (entry) => {
|
|
900
1406
|
try {
|
|
901
1407
|
const idx = load();
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1408
|
+
if (idx !== null) {
|
|
1409
|
+
const useId = exactUseId(entry?.useId);
|
|
1410
|
+
if (useId !== '') {
|
|
1411
|
+
const state = idx.states.get(useId);
|
|
1412
|
+
if (state === LIVE_FINISHED) return true;
|
|
1413
|
+
if (state === LIVE_RUNNING) return false;
|
|
1414
|
+
} else {
|
|
1415
|
+
const desc = typeof entry?.desc === 'string' ? entry.desc.trim() : '';
|
|
1416
|
+
const finished = desc === '' ? undefined : descFinished(idx, desc, excludeUseId);
|
|
1417
|
+
if (finished !== undefined) return finished;
|
|
1418
|
+
}
|
|
906
1419
|
}
|
|
907
1420
|
const at = Date.parse(entry?.at ?? '');
|
|
908
1421
|
if (Number.isFinite(at)) return now - at > ttlMs;
|
|
@@ -920,7 +1433,8 @@ export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN
|
|
|
920
1433
|
/**
|
|
921
1434
|
* Identify the wave this dispatch belongs to. Derived from the coordinator's
|
|
922
1435
|
* own scope file so a wave transition resets the ledger without anyone having to
|
|
923
|
-
* remember to clear it.
|
|
1436
|
+
* remember to clear it. `findOwnScopeFile` skips provably foreign manifests;
|
|
1437
|
+
* unbound or corrupt manifests still decide under the ENFORCE contract.
|
|
924
1438
|
*
|
|
925
1439
|
* FALLBACK, stated honestly (review MED): with no readable `wave-scope.json` the
|
|
926
1440
|
* key degrades to `<session>|w?|?`, so the ledger spans the whole SESSION and a
|
|
@@ -935,22 +1449,75 @@ export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN
|
|
|
935
1449
|
* @param {string} projectDir
|
|
936
1450
|
* @param {string} sessionId
|
|
937
1451
|
* @param {(p: string, enc: string) => string} readFn — injected `readFileSync`
|
|
938
|
-
*
|
|
1452
|
+
* @param {Set<string>} [ownIds] — process-local ids; repo helpers are late-bound
|
|
1453
|
+
* by bootstrap, as for the other guard dependencies.
|
|
939
1454
|
* @returns {string}
|
|
940
1455
|
*/
|
|
941
|
-
export function waveKeyOf(projectDir, sessionId, readFn
|
|
942
|
-
|
|
1456
|
+
export function waveKeyOf(projectDir, sessionId, readFn,
|
|
1457
|
+
ownIds = new Set(readProcessLocalSessionIds({ hookInput: { session_id: sessionId } }))) {
|
|
1458
|
+
const { path: scopeFile } = findOwnScopeFile(projectDir, ownIds, classifyManifestSession);
|
|
1459
|
+
if (scopeFile) {
|
|
943
1460
|
try {
|
|
944
|
-
const raw = readFn(
|
|
1461
|
+
const raw = readFn(scopeFile, 'utf8');
|
|
945
1462
|
const data = JSON.parse(raw);
|
|
946
1463
|
const wave = data?.wave ?? '?';
|
|
947
1464
|
const role = data?.role ?? '?';
|
|
948
1465
|
return `${sessionId}|w${wave}|${role}`;
|
|
949
|
-
} catch { /*
|
|
1466
|
+
} catch { /* corrupt / unreadable own-or-unknown manifest → no wave */ }
|
|
950
1467
|
}
|
|
951
1468
|
return `${sessionId}|w?|?`;
|
|
952
1469
|
}
|
|
953
1470
|
|
|
1471
|
+
/**
|
|
1472
|
+
* The session id this dispatch is keyed under — the payload's own, never a
|
|
1473
|
+
* lock or env read (§ 6 limit 10 of `docs/scope-collision-guard.md`).
|
|
1474
|
+
*
|
|
1475
|
+
* @param {object} input parsed PreToolUse payload
|
|
1476
|
+
* @returns {string}
|
|
1477
|
+
*/
|
|
1478
|
+
function sessionIdOf(input) {
|
|
1479
|
+
return typeof input?.session_id === 'string' ? input.session_id : 'no-session';
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* The repo-relative paths of THIS session's ledger and its lock (#1493.3).
|
|
1484
|
+
* The session id is reduced to a path-safe token (`[A-Za-z0-9._-]`, capped at
|
|
1485
|
+
* `MAX_SESSION_TOKEN_CHARS`), so no id can name a file outside `.orchestrator/`;
|
|
1486
|
+
* an id with no usable character falls back to `no-session`.
|
|
1487
|
+
*
|
|
1488
|
+
* @param {string} sessionId
|
|
1489
|
+
* @returns {{ ledgerRel: string, lockRel: string }}
|
|
1490
|
+
*/
|
|
1491
|
+
export function ledgerPathsFor(sessionId) {
|
|
1492
|
+
const token = String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, MAX_SESSION_TOKEN_CHARS);
|
|
1493
|
+
const safe = /[A-Za-z0-9]/.test(token) ? token : 'no-session';
|
|
1494
|
+
const base = path.join(LEDGER_DIR_REL, `${LEDGER_STEM}.${safe}`);
|
|
1495
|
+
return { ledgerRel: `${base}.json`, lockRel: `${base}.lock` };
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* Migration read of the pre-#1493 shared ledger: the parsed legacy ledger when
|
|
1500
|
+
* its `waveKey` belongs to THIS session (first `|` segment equals the id), else
|
|
1501
|
+
* `null`. An unparseable or foreign legacy file is not this session's state —
|
|
1502
|
+
* it is neither adopted nor reported as corruption (row 7 is about OUR file).
|
|
1503
|
+
*
|
|
1504
|
+
* @param {string|null} raw legacy file contents (`null` = absent)
|
|
1505
|
+
* @param {string} sessionId
|
|
1506
|
+
* @returns {object|null}
|
|
1507
|
+
*/
|
|
1508
|
+
function adoptLegacyLedger(raw, sessionId) {
|
|
1509
|
+
if (typeof raw !== 'string') return null;
|
|
1510
|
+
try {
|
|
1511
|
+
const legacy = JSON.parse(raw);
|
|
1512
|
+
return legacy !== null && typeof legacy === 'object' && typeof legacy.waveKey === 'string'
|
|
1513
|
+
&& legacy.waveKey.startsWith(`${sessionId}|`)
|
|
1514
|
+
? legacy
|
|
1515
|
+
: null;
|
|
1516
|
+
} catch {
|
|
1517
|
+
return null;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
954
1521
|
/**
|
|
955
1522
|
* The wave NUMBER out of a `waveKeyOf()` key, for the telemetry record (#1092).
|
|
956
1523
|
*
|
|
@@ -971,6 +1538,88 @@ function waveNumberOf(waveKey) {
|
|
|
971
1538
|
return Number.isSafeInteger(n) && n > 0 ? n : null;
|
|
972
1539
|
}
|
|
973
1540
|
|
|
1541
|
+
/** Thrown instead of spawning once the shared git deadline has passed. */
|
|
1542
|
+
class GitBudgetExhausted extends Error {
|
|
1543
|
+
/** @param {string} subcommand */
|
|
1544
|
+
constructor(subcommand) {
|
|
1545
|
+
super(`git ${subcommand}: shared git budget (${GIT_BUDGET_MS} ms) exhausted`);
|
|
1546
|
+
this.name = 'GitBudgetExhausted';
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* A git runner bound to ONE deadline shared by every call of this hook fire
|
|
1552
|
+
* (see `GIT_BUDGET_MS`). Each call gets `timeout` = what is left; once nothing
|
|
1553
|
+
* is left it throws without spawning. `exhausted()` turns sticky the moment a
|
|
1554
|
+
* call is refused or timed out, so a caller can tell "git said no" from "git
|
|
1555
|
+
* never got to answer" — the second is a skip, never evidence (§ matrix rows 8,
|
|
1556
|
+
* 15). `SIGKILL`, not the default SIGTERM: a child that ignores SIGTERM keeps
|
|
1557
|
+
* `execFileSync` blocked past its timeout. Safe here — every call is read-only
|
|
1558
|
+
* plumbing that takes no index lock (§ PSA).
|
|
1559
|
+
*
|
|
1560
|
+
* @param {number} deadline — epoch ms
|
|
1561
|
+
* @returns {{run: (args: string[], cwd: string, opts?: {maxBuffer?: number}) => string,
|
|
1562
|
+
* exhausted: () => boolean}}
|
|
1563
|
+
*/
|
|
1564
|
+
function makeGitRunner(deadline) {
|
|
1565
|
+
let exhausted = false;
|
|
1566
|
+
const run = (args, cwd, { maxBuffer } = {}) => {
|
|
1567
|
+
const remaining = Math.floor(deadline - Date.now());
|
|
1568
|
+
if (remaining <= 0) {
|
|
1569
|
+
exhausted = true;
|
|
1570
|
+
throw new GitBudgetExhausted(args[0]);
|
|
1571
|
+
}
|
|
1572
|
+
try {
|
|
1573
|
+
return execFileSync('git', args, {
|
|
1574
|
+
cwd,
|
|
1575
|
+
encoding: 'utf8',
|
|
1576
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1577
|
+
timeout: remaining,
|
|
1578
|
+
killSignal: 'SIGKILL',
|
|
1579
|
+
...(maxBuffer === undefined ? {} : { maxBuffer }),
|
|
1580
|
+
});
|
|
1581
|
+
} catch (e) {
|
|
1582
|
+
if (e?.code === 'ETIMEDOUT') exhausted = true;
|
|
1583
|
+
throw e;
|
|
1584
|
+
}
|
|
1585
|
+
};
|
|
1586
|
+
return { run, exhausted: () => exhausted };
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* `git rev-parse --show-toplevel` of `cwd`, or `''` when git cannot say.
|
|
1591
|
+
*
|
|
1592
|
+
* @param {string} cwd
|
|
1593
|
+
* @param {ReturnType<typeof makeGitRunner>} git
|
|
1594
|
+
* @returns {string}
|
|
1595
|
+
*/
|
|
1596
|
+
function gitToplevel(cwd, git) {
|
|
1597
|
+
try {
|
|
1598
|
+
return git.run(['rev-parse', '--show-toplevel'], cwd).trim();
|
|
1599
|
+
} catch {
|
|
1600
|
+
return '';
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/**
|
|
1605
|
+
* Tracked files of the repo rooted at `root`, plus WHY the list is empty when
|
|
1606
|
+
* it had to be (matrix row 8 — degrade, never deny, but leave a reason).
|
|
1607
|
+
*
|
|
1608
|
+
* @param {string} root — a git toplevel, or `''` when none could be resolved
|
|
1609
|
+
* @param {ReturnType<typeof makeGitRunner>} git
|
|
1610
|
+
* @returns {{files: string[], skipped?: 'budget-exhausted'|'git-error'}}
|
|
1611
|
+
*/
|
|
1612
|
+
function trackedFilesIn(root, git) {
|
|
1613
|
+
const failed = () => ({ files: [], skipped: git.exhausted() ? 'budget-exhausted' : 'git-error' });
|
|
1614
|
+
if (root === '') return failed();
|
|
1615
|
+
try {
|
|
1616
|
+
const stdout = git.run(['ls-files', '-z'], root, { maxBuffer: 32 * 1024 * 1024 });
|
|
1617
|
+
return { files: stdout.split('\0').filter((f) => f.length > 0) };
|
|
1618
|
+
} catch {
|
|
1619
|
+
return failed();
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
974
1623
|
/**
|
|
975
1624
|
* Tracked files, for glob expansion inside `findScopeCollisions`. The library is
|
|
976
1625
|
* pure and must not spawn — supplying this is precisely the hook's job.
|
|
@@ -984,82 +1633,181 @@ function waveNumberOf(waveKey) {
|
|
|
984
1633
|
* That is the dangerous direction, because the hook is the last gate before the
|
|
985
1634
|
* write.
|
|
986
1635
|
*
|
|
1636
|
+
* Standalone entry with its own `GIT_BUDGET_MS`; `main()` composes the same two
|
|
1637
|
+
* steps under the deadline it shares with the worktree-base check.
|
|
1638
|
+
*
|
|
987
1639
|
* @param {string} cwd
|
|
988
1640
|
* @returns {string[]}
|
|
989
1641
|
*/
|
|
990
1642
|
export function listTrackedFiles(cwd) {
|
|
991
|
-
const
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1643
|
+
const git = makeGitRunner(Date.now() + GIT_BUDGET_MS);
|
|
1644
|
+
return trackedFilesIn(gitToplevel(cwd, git), git).files;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
/**
|
|
1648
|
+
* The SETTINGS ROOT (#1485) — where `harnessBaseRef()` reads the project's
|
|
1649
|
+
* `.claude/settings{,.local}.json`: `$CLAUDE_PROJECT_DIR`, else the git toplevel
|
|
1650
|
+
* of `cwd`, else `cwd`. The launch dir FIRST, unlike `resolveSessionRoot()`: it is the
|
|
1651
|
+
* root the harness applies project settings from, and after entering a worktree
|
|
1652
|
+
* the gitignored `settings.local.json` exists only there.
|
|
1653
|
+
*
|
|
1654
|
+
* @param {string} cwd — the payload `cwd`
|
|
1655
|
+
* @param {string} cwdToplevel — `gitToplevel(cwd)`, `''` when git could not say
|
|
1656
|
+
* @returns {string}
|
|
1657
|
+
*/
|
|
1658
|
+
function settingsRootOf(cwd, cwdToplevel) {
|
|
1659
|
+
return (process.env.CLAUDE_PROJECT_DIR || '').trim() || cwdToplevel || cwd;
|
|
1005
1660
|
}
|
|
1006
1661
|
|
|
1007
1662
|
// ---------------------------------------------------------------------------
|
|
1008
|
-
// Stale worktree base (#1413) — a
|
|
1663
|
+
// Stale worktree base (#1413 → #1485) — a MEASURED mismatch DENIES the dispatch
|
|
1009
1664
|
//
|
|
1010
1665
|
// `isolation: "worktree"` makes the HARNESS create `<repo>/.claude/worktrees/
|
|
1011
1666
|
// agent-<hex>`; no code in this repo creates it and the `Agent` payload carries
|
|
1012
|
-
// no base-ref field (the 147-dispatch key census in the module docblock).
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1667
|
+
// no base-ref field (the 147-dispatch key census in the module docblock). Which
|
|
1668
|
+
// commit it branches from is documented harness behaviour (code.claude.com/docs
|
|
1669
|
+
// /en/worktrees § "Choose the base branch", read 2026-10-02): "Subagent worktrees
|
|
1670
|
+
// use the same base branch as `--worktree`, so they branch from your repository's
|
|
1671
|
+
// default branch unless `worktree.baseRef` is set to `"head"`" — `"fresh"` (the
|
|
1672
|
+
// default) is `origin/HEAD`, `"head"` is the current local HEAD.
|
|
1673
|
+
//
|
|
1674
|
+
// That REPLACES the #1413 model ("the base is the session-start commit"): it fit
|
|
1675
|
+
// s18 only because that session ran on `main`, where origin/main WAS the start
|
|
1676
|
+
// commit. #1485 measured the general case on a feature branch — agent worktrees
|
|
1677
|
+
// stood on `main` (`0efb3e97`), six commits behind the branch HEAD (`47c49652`),
|
|
1678
|
+
// so the agents silently edited old code. Owner decision 2026-10-02, option (a):
|
|
1679
|
+
// check with `git merge-base --is-ancestor` before dispatch and stop on mismatch.
|
|
1019
1680
|
//
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1681
|
+
// ONLY A MEASURED MISMATCH DENIES (`is-ancestor` exit 1 in a full-history repo,
|
|
1682
|
+
// against a cached origin ref the harness will branch from as-is). Every
|
|
1683
|
+
// non-measurement — no origin/HEAD, a shallow clone, a cached ref the harness
|
|
1684
|
+
// refetches first (`stale-remote-ref`), a git failure, an exit code other than
|
|
1685
|
+
// 0/1, a throw — is `stale: null` + `skipped`, recorded and ALLOWED: unknown is
|
|
1686
|
+
// not mismatch, and this hook guards the dispatch path, where a wrong deny is a
|
|
1687
|
+
// session outage (§ the fail-open note at the bottom of this file).
|
|
1688
|
+
//
|
|
1689
|
+
// Where the hook's base and the harness's can still DIVERGE (Claude Code
|
|
1690
|
+
// 2.1.287 bundle, read 2026-10-02):
|
|
1691
|
+
// - a settings layer this hook cannot read (`harnessBaseRef()`) — either way,
|
|
1692
|
+
// wrong deny or missed mismatch, from any `base_ref_source`;
|
|
1693
|
+
// - no `refs/remotes/origin/HEAD` symref: the harness falls back to
|
|
1694
|
+
// `origin/main|master`, the hook skips `no-origin-head` — a missed mismatch
|
|
1695
|
+
// only, never a wrong deny (measured 2026-10-02: 0 of 28 local repos lack
|
|
1696
|
+
// the symref);
|
|
1697
|
+
// - a stale FETCH_HEAD whose refetch then FAILS (offline): the harness keeps
|
|
1698
|
+
// the cached ref, so the skipped deny would have been right — the price of
|
|
1699
|
+
// not knowing in advance whether a fetch will succeed.
|
|
1023
1700
|
// ---------------------------------------------------------------------------
|
|
1024
1701
|
|
|
1702
|
+
/** `worktree.baseRef` values the docs define; anything else sets nothing here. */
|
|
1703
|
+
const BASE_REF_VALUES = new Set(['head', 'fresh']);
|
|
1704
|
+
|
|
1705
|
+
/**
|
|
1706
|
+
* The harness refetches origin before branching a `"fresh"` worktree when
|
|
1707
|
+
* FETCH_HEAD is older than this (Claude Code 2.1.287 bundle, read 2026-10-02:
|
|
1708
|
+
* `Date.now() - mtimeMs > 86400000`, then `git fetch origin <default>` with a
|
|
1709
|
+
* 5 s timeout). Revisit when the harness changes that constant — a mismatch
|
|
1710
|
+
* either skips a real deny or lets a refetched base be denied again.
|
|
1711
|
+
*/
|
|
1712
|
+
const FETCH_REFRESH_MS = 24 * 60 * 60 * 1000;
|
|
1713
|
+
|
|
1714
|
+
/**
|
|
1715
|
+
* The harness's effective `worktree.baseRef`, as far as this hook can see it,
|
|
1716
|
+
* and WHICH layer decided it — read in the documented precedence order (code.
|
|
1717
|
+
* claude.com/docs/en/settings § "Settings precedence", read 2026-10-02): local
|
|
1718
|
+
* → project → user, the FIRST file that sets the key to a defined value wins.
|
|
1719
|
+
* No file sets it → `'fresh'` from `'default'`.
|
|
1720
|
+
*
|
|
1721
|
+
* `settingsRoot` is the SESSION ROOT, not the hook payload's `cwd`: that `cwd`
|
|
1722
|
+
* follows the session's `cd` (docs/en/hooks § "cwd follows Claude"), while the
|
|
1723
|
+
* project settings the harness applies belong to the root it started in.
|
|
1724
|
+
*
|
|
1725
|
+
* Read only on the rare `worktree` branch (14 of 147 measured dispatches carried
|
|
1726
|
+
* `isolation` at all), so the hot dispatch path never pays these reads.
|
|
1727
|
+
*
|
|
1728
|
+
* NAMED CEILING (BV-004): blind to the two layers ABOVE local — managed settings
|
|
1729
|
+
* and the `--settings` CLI flag. The error points BOTH ways: a `"head"` there
|
|
1730
|
+
* with no visible file setting the key reads as `default` → `fresh` → a WRONG
|
|
1731
|
+
* DENY when HEAD is ahead of origin/HEAD; a `"fresh"` there shadowing a visible
|
|
1732
|
+
* `"head"` → a MISSED mismatch. Not read on purpose: `--settings` reaches no
|
|
1733
|
+
* hook, and managed settings arrive through several delivery mechanisms (a
|
|
1734
|
+
* `managed-settings.json` in a system dir, MDM/OS policy, server-managed settings
|
|
1735
|
+
* from the claude.ai console, an embedding host's SDK option — docs settings
|
|
1736
|
+
* § "Managed settings"), combined by their own intra-tier rules; reading the one
|
|
1737
|
+
* file among them would turn an honest "invisible" into a partial view that
|
|
1738
|
+
* still decides. The wrong-deny population is countable instead (HR-105): a
|
|
1739
|
+
* hidden `"head"` with no visible file setting the key yields `decision:"deny"`
|
|
1740
|
+
* records with `base_ref_source:"default"`; a hidden `"head"` shadowed by a
|
|
1741
|
+
* visible `"fresh"` yields them under that file's source instead. Revisit if
|
|
1742
|
+
* such a deny is reported in a session whose `/status` names a managed or
|
|
1743
|
+
* `--settings` source carrying `worktree.baseRef`.
|
|
1744
|
+
*
|
|
1745
|
+
* @param {string} settingsRoot
|
|
1746
|
+
* @returns {{baseRef: 'head'|'fresh', source: 'local'|'project'|'user'|'default'}}
|
|
1747
|
+
*/
|
|
1748
|
+
function harnessBaseRef(settingsRoot) {
|
|
1749
|
+
const userDir = (process.env.CLAUDE_CONFIG_DIR || '').trim() || path.join(homedir(), '.claude');
|
|
1750
|
+
for (const [source, file] of [
|
|
1751
|
+
['local', path.join(settingsRoot, '.claude', 'settings.local.json')],
|
|
1752
|
+
['project', path.join(settingsRoot, '.claude', 'settings.json')],
|
|
1753
|
+
['user', path.join(userDir, 'settings.json')],
|
|
1754
|
+
]) {
|
|
1755
|
+
try {
|
|
1756
|
+
const value = JSON.parse(readFileSync(file, 'utf8'))?.worktree?.baseRef;
|
|
1757
|
+
if (BASE_REF_VALUES.has(value)) return { baseRef: value, source };
|
|
1758
|
+
} catch { /* absent or unparseable — the next layer decides */ }
|
|
1759
|
+
}
|
|
1760
|
+
return { baseRef: 'fresh', source: 'default' };
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/** Where each `base_ref_source` lives, for the operator-facing deny text. */
|
|
1764
|
+
const BASE_REF_SOURCE_LABEL = {
|
|
1765
|
+
local: '.claude/settings.local.json',
|
|
1766
|
+
project: '.claude/settings.json',
|
|
1767
|
+
user: 'user settings.json',
|
|
1768
|
+
default: 'the default — no settings file this hook can read sets it',
|
|
1769
|
+
};
|
|
1770
|
+
|
|
1025
1771
|
/**
|
|
1026
1772
|
* Facts for the worktree-base check, as a DISCRIMINATED record with three
|
|
1027
1773
|
* outcomes rather than two (#1424):
|
|
1028
1774
|
*
|
|
1029
|
-
* - `{stale: true|false, head,
|
|
1775
|
+
* - `{stale: true|false, head, base, base_ref, base_ref_source, missing_commits?,
|
|
1776
|
+
* subagent_type?}` — measured. `stale` means "the worktree would MISS commits
|
|
1777
|
+
* of HEAD": `git merge-base --is-ancestor HEAD <base>` exited 1 in a repo
|
|
1778
|
+
* with full history and a FETCH_HEAD younger than `FETCH_REFRESH_MS` (so
|
|
1779
|
+
* the harness branches from that cached ref without refetching it first).
|
|
1780
|
+
* A base AHEAD of HEAD (origin moved on, HEAD contained)
|
|
1781
|
+
* is not stale — nothing of HEAD is lost. `base_ref_source` names the
|
|
1782
|
+
* settings layer that decided `base_ref` (`harnessBaseRef()`), so a wrong
|
|
1783
|
+
* deny from a layer this hook cannot read stays countable (HR-105).
|
|
1030
1784
|
* - `{stale: null, skipped: <reason>}` — the dispatch WAS on the `worktree`
|
|
1031
1785
|
* branch, but the question could not be answered honestly. Still emitted,
|
|
1032
|
-
* because HR-105 needs the denominator
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
1046
|
-
*
|
|
1047
|
-
* warning about somebody else's session is worse than no warning at all. The
|
|
1048
|
-
* test runs PER CANDIDATE (`continue`, not `break`) — see the loop below.
|
|
1049
|
-
*
|
|
1050
|
-
* `parseStateMd` is imported DYNAMICALLY and only on the rare `worktree` branch
|
|
1051
|
-
* (14 of 147 measured dispatches carried `isolation` at all): it is a leaf module
|
|
1052
|
-
* with zero imports and already inside the committed hook-import closure, so
|
|
1053
|
-
* reuse costs no new edge, and binding it late keeps a load failure catchable
|
|
1054
|
-
* instead of disarming the guard at ESM link time (§ #993 late-bound deps).
|
|
1786
|
+
* because HR-105 needs the denominator, and never denied (unknown ≠ mismatch).
|
|
1787
|
+
* - `null` — `isolation !== 'worktree'`. SILENT by construction: recording the
|
|
1788
|
+
* other ~90% of dispatches would put the denominator on the hot path (HR-101).
|
|
1789
|
+
*
|
|
1790
|
+
* TWO DIRECTORIES, deliberately different: git measures in `cwd` (the payload's,
|
|
1791
|
+
* which follows the session's `cd` — git finds the enclosing repo from any
|
|
1792
|
+
* subdirectory, and inside an entered worktree `"head"` means THAT worktree's
|
|
1793
|
+
* HEAD); settings are read at `settingsRoot` (`settingsRootOf()` — the root the
|
|
1794
|
+
* harness applies project settings from, which is NOT where the record lands:
|
|
1795
|
+
* that is `resolveSessionRoot()`).
|
|
1796
|
+
*
|
|
1797
|
+
* Every git call draws on the hook fire's ONE shared deadline (`GIT_BUDGET_MS`).
|
|
1798
|
+
* A call the budget cut short is no measurement: each skip then reads
|
|
1799
|
+
* `git-budget`, whichever reason the cut call would otherwise have produced — a
|
|
1800
|
+
* timed-out `origin/HEAD` lookup is not evidence that there is none.
|
|
1055
1801
|
*
|
|
1056
1802
|
* @param {{tool_input?: unknown}} input — the raw hook payload
|
|
1057
|
-
* @param {string}
|
|
1058
|
-
* @param {string}
|
|
1059
|
-
* @
|
|
1060
|
-
*
|
|
1803
|
+
* @param {string} cwd — the hook payload's `cwd`
|
|
1804
|
+
* @param {string} settingsRoot — `settingsRootOf()`
|
|
1805
|
+
* @param {ReturnType<typeof makeGitRunner>} gitRunner — the fire's shared runner
|
|
1806
|
+
* @returns {{head: string, base: string, base_ref: 'head'|'fresh',
|
|
1807
|
+
* base_ref_source: 'local'|'project'|'user'|'default', stale: boolean,
|
|
1808
|
+
* missing_commits?: number, subagent_type?: string}|{stale: null, skipped: string}|null}
|
|
1061
1809
|
*/
|
|
1062
|
-
|
|
1810
|
+
function worktreeBaseFacts(input, cwd, settingsRoot, gitRunner) {
|
|
1063
1811
|
// The applicability gate sits OUTSIDE the try on purpose: everything below it
|
|
1064
1812
|
// resolves to a `skipped` RECORD, so a throw here must not be able to mint one
|
|
1065
1813
|
// for a dispatch that was never on the `worktree` branch at all.
|
|
@@ -1067,98 +1815,106 @@ async function worktreeBaseFacts(input, projectDir, sessionId) {
|
|
|
1067
1815
|
if (toolInput === null || typeof toolInput !== 'object') return null;
|
|
1068
1816
|
if (toolInput.isolation !== 'worktree') return null;
|
|
1069
1817
|
|
|
1818
|
+
/** @param {string} reason */
|
|
1819
|
+
const skip = (reason) => ({ stale: null, skipped: gitRunner.exhausted() ? 'git-budget' : reason });
|
|
1070
1820
|
try {
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
: (process.env.CLAUDE_CODE_SESSION_ID || '');
|
|
1074
|
-
if (!ownId) return { stale: null, skipped: 'no-own-id' };
|
|
1075
|
-
|
|
1076
|
-
const { parseStateMd } = await import(
|
|
1077
|
-
pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'state-md', 'yaml-parser.mjs')).href
|
|
1078
|
-
);
|
|
1079
|
-
|
|
1080
|
-
// #1424: the identity test lives INSIDE the loop. Breaking at the first
|
|
1081
|
-
// PARSEABLE STATE.md and testing `session-id` afterwards meant a single
|
|
1082
|
-
// left-over `.pi/STATE.md` — `.pi` sorts before `.claude` in
|
|
1083
|
-
// STATE_DIR_CANDIDATES — disarmed the check for a session whose own
|
|
1084
|
-
// `.claude/STATE.md` sat right there, readable and matching. `sawStateMd`
|
|
1085
|
-
// keeps the two silences apart afterwards: "nothing to read" is a different
|
|
1086
|
-
// fact from "read, and none of it was mine".
|
|
1087
|
-
let frontmatter = null;
|
|
1088
|
-
let sawStateMd = false;
|
|
1089
|
-
for (const dir of STATE_DIR_CANDIDATES) {
|
|
1090
|
-
try {
|
|
1091
|
-
const parsed = parseStateMd(readFileSync(path.join(projectDir, dir, 'STATE.md'), 'utf8'));
|
|
1092
|
-
if (!parsed?.frontmatter) continue;
|
|
1093
|
-
sawStateMd = true;
|
|
1094
|
-
if (parsed.frontmatter['session-id'] !== ownId) continue;
|
|
1095
|
-
frontmatter = parsed.frontmatter;
|
|
1096
|
-
break;
|
|
1097
|
-
} catch { /* try the next state dir */ }
|
|
1098
|
-
}
|
|
1099
|
-
if (frontmatter === null) {
|
|
1100
|
-
return { stale: null, skipped: sawStateMd ? 'identity-mismatch' : 'no-state-md' };
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
const startRef = typeof frontmatter['session-start-ref'] === 'string'
|
|
1104
|
-
? frontmatter['session-start-ref'].trim()
|
|
1105
|
-
: '';
|
|
1106
|
-
if (startRef === '') return { stale: null, skipped: 'no-start-ref' };
|
|
1821
|
+
/** @param {string[]} args */
|
|
1822
|
+
const git = (args) => gitRunner.run(args, cwd).trim();
|
|
1107
1823
|
|
|
1108
1824
|
let head = '';
|
|
1109
|
-
try {
|
|
1110
|
-
|
|
1111
|
-
cwd: projectDir,
|
|
1112
|
-
encoding: 'utf8',
|
|
1113
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1114
|
-
}).trim();
|
|
1115
|
-
} catch {
|
|
1116
|
-
return { stale: null, skipped: 'git-error' };
|
|
1117
|
-
}
|
|
1118
|
-
if (head === '') return { stale: null, skipped: 'git-error' };
|
|
1825
|
+
try { head = git(['rev-parse', '--verify', 'HEAD^{commit}']); } catch { /* below */ }
|
|
1826
|
+
if (head === '') return skip('git-error');
|
|
1119
1827
|
|
|
1120
|
-
const
|
|
1828
|
+
const { baseRef, source } = harnessBaseRef(settingsRoot);
|
|
1829
|
+
const facts = { head, base_ref: baseRef, base_ref_source: source };
|
|
1121
1830
|
if (typeof toolInput.subagent_type === 'string' && toolInput.subagent_type !== '') {
|
|
1122
1831
|
facts.subagent_type = toolInput.subagent_type;
|
|
1123
1832
|
}
|
|
1124
|
-
|
|
1833
|
+
// `"head"` inside a worktree resolves to THAT worktree's HEAD (docs, same
|
|
1834
|
+
// section) — the very `git rev-parse HEAD` above, run in `cwd`.
|
|
1835
|
+
if (baseRef === 'head') return { ...facts, base: head, stale: false };
|
|
1836
|
+
|
|
1837
|
+
// `"fresh"`: origin/HEAD. Not cached locally → the harness fetches, or
|
|
1838
|
+
// falls back to HEAD; neither outcome is knowable here → skip, not deny.
|
|
1839
|
+
let base = '';
|
|
1840
|
+
try { base = git(['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/HEAD^{commit}']); } catch { /* below */ }
|
|
1841
|
+
if (base === '') return skip('no-origin-head');
|
|
1842
|
+
if (base === head) return { ...facts, base, stale: false };
|
|
1843
|
+
|
|
1844
|
+
try {
|
|
1845
|
+
git(['merge-base', '--is-ancestor', head, base]);
|
|
1846
|
+
return { ...facts, base, stale: false };
|
|
1847
|
+
} catch (e) {
|
|
1848
|
+
// Exit 1 is the ONE candidate "no"; 128 (bad object), a timeout or a
|
|
1849
|
+
// signal is a non-measurement and must not accuse anybody.
|
|
1850
|
+
if (e?.status !== 1) return skip('git-error');
|
|
1851
|
+
}
|
|
1852
|
+
// ...and exit 1 is a MEASUREMENT only over full history. A shallow clone's
|
|
1853
|
+
// cut-off graph answers "no" for a HEAD that IS an ancestor (measured
|
|
1854
|
+
// 2026-10-02: `clone --depth 1` + `fetch --depth 1` → exit 1, the full source
|
|
1855
|
+
// → exit 0). Probed only here, on the rare "no": an exit 0, which no missing
|
|
1856
|
+
// history can fake, never pays for it. A failed probe cannot rule shallow
|
|
1857
|
+
// out, so it skips as well. ONE spawn answers both questions below (two
|
|
1858
|
+
// lines, in argument order) — the deny path is the one that must finish
|
|
1859
|
+
// inside the shared budget.
|
|
1860
|
+
let shallow = '';
|
|
1861
|
+
let fetchHeadPath = '';
|
|
1862
|
+
try {
|
|
1863
|
+
[shallow = '', fetchHeadPath = ''] = git(['rev-parse', '--is-shallow-repository', '--git-path', 'FETCH_HEAD'])
|
|
1864
|
+
.split('\n')
|
|
1865
|
+
.map((line) => line.trim());
|
|
1866
|
+
} catch { /* below */ }
|
|
1867
|
+
if (shallow !== 'false') return skip(shallow === 'true' ? 'shallow' : 'git-error');
|
|
1868
|
+
|
|
1869
|
+
// ...and only over a ref the harness will actually branch from. With
|
|
1870
|
+
// FETCH_HEAD older than `FETCH_REFRESH_MS` (or absent — the harness reads
|
|
1871
|
+
// that as mtime 0) it first fetches origin and branches from the FETCHED
|
|
1872
|
+
// tip, which may well contain HEAD by now; this hook never fetches (network
|
|
1873
|
+
// inside a 5 s PreToolUse budget), so the cached ref decides nothing here.
|
|
1874
|
+
let fetchedMs = 0;
|
|
1875
|
+
try { if (fetchHeadPath !== '') fetchedMs = statSync(path.resolve(cwd, fetchHeadPath)).mtimeMs; } catch { /* absent → stale */ }
|
|
1876
|
+
if (Date.now() - fetchedMs > FETCH_REFRESH_MS) return { stale: null, skipped: 'stale-remote-ref' };
|
|
1877
|
+
|
|
1878
|
+
let missing = Number.NaN;
|
|
1879
|
+
try { missing = Number.parseInt(git(['rev-list', '--count', `${base}..${head}`]), 10); } catch { /* optional */ }
|
|
1880
|
+
return { ...facts, base, stale: true, ...(Number.isInteger(missing) ? { missing_commits: missing } : {}) };
|
|
1125
1881
|
} catch {
|
|
1126
|
-
// Total by construction
|
|
1127
|
-
// Still a RECORD rather than silence — the dispatch WAS on the `worktree`
|
|
1128
|
-
// branch, and a broken probe that emits nothing is indistinguishable from a
|
|
1129
|
-
// probe that had nothing to report (#1424 / HR-105).
|
|
1882
|
+
// Total by construction. Still a RECORD rather than silence (#1424 / HR-105).
|
|
1130
1883
|
return { stale: null, skipped: 'probe-error' };
|
|
1131
1884
|
}
|
|
1132
1885
|
}
|
|
1133
1886
|
|
|
1134
1887
|
/**
|
|
1135
|
-
* The
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
1142
|
-
* @param {{head: string,
|
|
1143
|
-
* @returns {string}
|
|
1888
|
+
* The deny reason + suggestion for a measured stale base. Names the ACTION, not
|
|
1889
|
+
* just the condition (HR-106), and the settings layer that chose `"fresh"`: an
|
|
1890
|
+
* operator whose `"head"` lives where this hook cannot read (managed settings,
|
|
1891
|
+
* `--settings`) then reads "the default" and knows the deny rests on that blind
|
|
1892
|
+
* spot. The reason stays ONE line: `emitDeny` lifts the first line into the
|
|
1893
|
+
* operator-visible `systemMessage` headline.
|
|
1894
|
+
*
|
|
1895
|
+
* @param {{head: string, base: string, base_ref_source?: string, missing_commits?: number}} facts
|
|
1896
|
+
* @returns {{reason: string, suggestion: string}}
|
|
1144
1897
|
*/
|
|
1145
|
-
function
|
|
1146
|
-
const
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1898
|
+
function staleWorktreeDeny(facts) {
|
|
1899
|
+
const missing = Number.isInteger(facts.missing_commits)
|
|
1900
|
+
? `${facts.missing_commits} commit(s)`
|
|
1901
|
+
: 'commits';
|
|
1902
|
+
const from = BASE_REF_SOURCE_LABEL[facts.base_ref_source] ?? BASE_REF_SOURCE_LABEL.default;
|
|
1903
|
+
return {
|
|
1904
|
+
reason:
|
|
1905
|
+
`${HOOK_NAME}: STALE WORKTREE BASE (#1485) — isolation: "worktree" would branch this `
|
|
1906
|
+
+ `agent from origin/HEAD ${facts.base.slice(0, 12)} (worktree.baseRef "fresh" from ${from}), `
|
|
1907
|
+
+ `which is missing ${missing} of HEAD ${facts.head.slice(0, 12)}; the agent would silently edit OLD code.`,
|
|
1908
|
+
suggestion: [
|
|
1909
|
+
'DO ONE OF:',
|
|
1910
|
+
' 1. re-dispatch IN-PLACE — omit `isolation` (isolation "none");',
|
|
1911
|
+
' 2. set `"worktree": {"baseRef": "head"}` in .claude/settings.json so agent',
|
|
1912
|
+
' worktrees branch from your local HEAD (code.claude.com/docs/en/worktrees) —',
|
|
1913
|
+
' a higher layer setting "fresh" (local settings, --settings, managed) overrides it;',
|
|
1914
|
+
' 3. get HEAD into the default branch on origin and fetch, then re-dispatch.',
|
|
1915
|
+
'Uncommitted changes reach no worktree under any base — commit first or go in-place.',
|
|
1916
|
+
].join('\n'),
|
|
1917
|
+
};
|
|
1162
1918
|
}
|
|
1163
1919
|
|
|
1164
1920
|
/**
|
|
@@ -1378,7 +2134,10 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
1378
2134
|
}
|
|
1379
2135
|
|
|
1380
2136
|
const desc = agentDescOf(toolInput);
|
|
1381
|
-
|
|
2137
|
+
// #1480 A: the exact dispatch id travels with the entry, so liveness is never
|
|
2138
|
+
// guessed from a description when the harness names the dispatch itself.
|
|
2139
|
+
const useId = exactUseId(input?.tool_use_id);
|
|
2140
|
+
const self = useId === '' ? { id, desc, files, at } : { id, desc, files, at, useId };
|
|
1382
2141
|
|
|
1383
2142
|
// Row 7: ledger existed but was unparseable. Terminal warn — decided here and
|
|
1384
2143
|
// returned, never emitted mid-flow. SELF-HEALING since the review: the verdict
|
|
@@ -1399,13 +2158,51 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
1399
2158
|
? ledger.agents.filter((a) => a !== null && typeof a === 'object' && typeof a.id === 'string')
|
|
1400
2159
|
: [];
|
|
1401
2160
|
|
|
1402
|
-
// Row 10
|
|
1403
|
-
//
|
|
1404
|
-
|
|
2161
|
+
// Row 10 (#1480 B4): a predecessor with the SAME agent id is no longer dropped
|
|
2162
|
+
// before the check. Dropping it unconditionally erased the claim of a still
|
|
2163
|
+
// RUNNING agent: [a,b] running, re-dispatched as [b,c], and a third agent on
|
|
2164
|
+
// `a` was allowed while the first run still wrote it. Now:
|
|
2165
|
+
// - the same `tool_use_id` → the same dispatch evaluated again → replaced;
|
|
2166
|
+
// - FINISHED (probe true) → pruned below, so a retry after a failed agent
|
|
2167
|
+
// stays allowed — a failed agent finishes via its `failed` carrier,
|
|
2168
|
+
// `is_error` or its report;
|
|
2169
|
+
// - both dispatches carry distinct exact ids and the predecessor is NOT
|
|
2170
|
+
// finished → an ordinary collision partner (row 11);
|
|
2171
|
+
// - otherwise no exact statement is possible → it stays as a CLAIM (a third
|
|
2172
|
+
// agent hitting only its scope still collides) but never collides with
|
|
2173
|
+
// this retry.
|
|
2174
|
+
// Named ceiling (BV-004): without exact ids a same-id retry cannot tell "my
|
|
2175
|
+
// earlier run is still going" from "it failed", so the retry is allowed while
|
|
2176
|
+
// the earlier claim is kept — the pre-#1480 behaviour for the retry itself,
|
|
2177
|
+
// and a claim a third agent can hit until the earlier run finishes or its TTL
|
|
2178
|
+
// lapses. Revisit-Trigger: a harness without `tool_use_id` in the PreToolUse
|
|
2179
|
+
// payload dispatches a measurable share of this repo's waves.
|
|
2180
|
+
// A predecessor wholly covered by this retry's scope, neither carrying an
|
|
2181
|
+
// exact id, adds nothing but ledger growth — both are resolved through the
|
|
2182
|
+
// same description and this entry is the younger one — so it is superseded.
|
|
2183
|
+
const superseded = (a) => a.id === id && (
|
|
2184
|
+
(useId !== '' && exactUseId(a.useId) === useId)
|
|
2185
|
+
|| (useId === '' && exactUseId(a.useId) === '' && desc !== '' && a.desc === desc
|
|
2186
|
+
&& Array.isArray(a.files) && a.files.every((f) => files.includes(f)))
|
|
2187
|
+
);
|
|
2188
|
+
const others = prior.filter((a) => !superseded(a));
|
|
2189
|
+
|
|
2190
|
+
// One comparison key per record, unique by construction: several records may
|
|
2191
|
+
// share an agent id now, and `findScopeCollisions` reports equal ids as
|
|
2192
|
+
// duplicates rather than comparing them. The readable id stays the key where
|
|
2193
|
+
// it is unique, so the deny text and every single-record wave are unchanged.
|
|
2194
|
+
const used = new Set([id]);
|
|
2195
|
+
const partners = others.map((entry) => {
|
|
2196
|
+
let key = entry.id;
|
|
2197
|
+
for (let n = 2; used.has(key); n++) key = `${entry.id} #${n}`;
|
|
2198
|
+
used.add(key);
|
|
2199
|
+
const selfClaim = entry.id === id && !(useId !== '' && exactUseId(entry.useId) !== '');
|
|
2200
|
+
return { key, entry, selfClaim };
|
|
2201
|
+
});
|
|
1405
2202
|
|
|
1406
2203
|
const known = new Set(Array.isArray(knownFiles) ? knownFiles : []);
|
|
1407
2204
|
const agentScopes = [
|
|
1408
|
-
...
|
|
2205
|
+
...partners.map((p) => ({ id: p.key, files: promoteDirEntries(p.entry.files, known) })),
|
|
1409
2206
|
{ id, files: promoteDirEntries(files, known) },
|
|
1410
2207
|
];
|
|
1411
2208
|
|
|
@@ -1453,28 +2250,35 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
1453
2250
|
// § Liveness — the review's HIGH finding. A collision with an agent that has
|
|
1454
2251
|
// ALREADY FINISHED is a sequential repair pass, not a race. The probe is called
|
|
1455
2252
|
// ONLY here, so the transcript is read only on the path that would deny.
|
|
1456
|
-
const
|
|
2253
|
+
const byKey = new Map(partners.map((p) => [p.key, p]));
|
|
1457
2254
|
const probe = typeof isFinished === 'function' ? isFinished : () => false;
|
|
1458
|
-
const
|
|
2255
|
+
const finishedKeys = new Set();
|
|
1459
2256
|
const live = [];
|
|
2257
|
+
let selfClaims = 0;
|
|
1460
2258
|
for (const c of mine) {
|
|
1461
|
-
const
|
|
1462
|
-
const
|
|
1463
|
-
if (
|
|
1464
|
-
|
|
2259
|
+
const otherKey = c.a === id ? c.b : c.a;
|
|
2260
|
+
const partner = byKey.get(otherKey);
|
|
2261
|
+
if (partner !== undefined && probe(partner.entry)) {
|
|
2262
|
+
finishedKeys.add(otherKey);
|
|
2263
|
+
continue;
|
|
2264
|
+
}
|
|
2265
|
+
// Row 10: an unfinished same-id claim without exact ids is kept, not collided with.
|
|
2266
|
+
if (partner?.selfClaim === true) {
|
|
2267
|
+
selfClaims++;
|
|
1465
2268
|
continue;
|
|
1466
2269
|
}
|
|
1467
2270
|
live.push(c);
|
|
1468
2271
|
}
|
|
2272
|
+
const counted = mine.length - selfClaims;
|
|
1469
2273
|
|
|
1470
2274
|
// Row 10a: every colliding prior agent has finished. Allow AND prune their
|
|
1471
2275
|
// records — leaving them would make the NEXT repair pass pay the transcript
|
|
1472
2276
|
// scan again for a question already answered.
|
|
1473
2277
|
if (live.length === 0) {
|
|
1474
|
-
const kept =
|
|
2278
|
+
const kept = partners.filter((p) => !finishedKeys.has(p.key)).map((p) => p.entry);
|
|
1475
2279
|
return {
|
|
1476
2280
|
action: 'allow',
|
|
1477
|
-
telemetry: telemetryFor('allow-finished',
|
|
2281
|
+
telemetry: finishedKeys.size > 0 ? telemetryFor('allow-finished', counted) : telemetryFor('allow'),
|
|
1478
2282
|
ledger: {
|
|
1479
2283
|
waveKey,
|
|
1480
2284
|
updated: at,
|
|
@@ -1487,13 +2291,15 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
1487
2291
|
// Row 11: the one case this hook exists for.
|
|
1488
2292
|
const shown = live.slice(0, MAX_REPORTED_COLLISIONS);
|
|
1489
2293
|
const lines = shown.map((c) => {
|
|
1490
|
-
const
|
|
2294
|
+
const otherKey = c.a === id ? c.b : c.a;
|
|
2295
|
+
const other = byKey.get(otherKey)?.entry.id ?? otherKey;
|
|
2296
|
+
const earlier = other === id ? ' (an earlier dispatch of the same agent)' : '';
|
|
1491
2297
|
const ev = Array.isArray(c.evidence) ? c.evidence : [];
|
|
1492
2298
|
const evShown = ev.slice(0, MAX_EVIDENCE_PER_COLLISION).map(clipPath).join(', ');
|
|
1493
2299
|
const more = ev.length > MAX_EVIDENCE_PER_COLLISION
|
|
1494
2300
|
? ` (+${ev.length - MAX_EVIDENCE_PER_COLLISION} more)`
|
|
1495
2301
|
: '';
|
|
1496
|
-
return ` • "${id}" ↔ "${other}" [${c.kind}]: ${evShown}${more}`;
|
|
2302
|
+
return ` • "${id}" ↔ "${other}"${earlier} [${c.kind}]: ${evShown}${more}`;
|
|
1497
2303
|
});
|
|
1498
2304
|
const omitted = live.length > shown.length ? `\n (+${live.length - shown.length} further collisions)` : '';
|
|
1499
2305
|
|
|
@@ -1508,11 +2314,12 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
1508
2314
|
'Two agents editing one file at the same time race each other (PSA-002). ' +
|
|
1509
2315
|
'Give the file exactly ONE owner in the wave plan, or wait for the named ' +
|
|
1510
2316
|
`agent(s) to finish and re-dispatch — a finished agent no longer blocks. ` +
|
|
1511
|
-
`If the ledger is stale, delete ${
|
|
2317
|
+
`If the ledger is stale, delete this session's ${ledgerPathsFor(sessionIdOf(input)).ledgerRel} ` +
|
|
2318
|
+
'— never another session\'s file beside it, which holds that session\'s live claims.';
|
|
1512
2319
|
|
|
1513
2320
|
// Deliberately NOT persisting the ledger on deny: the dispatch did not happen,
|
|
1514
2321
|
// so recording it would make the retry-after-fix look like a duplicate.
|
|
1515
|
-
return { action: 'deny', telemetry: telemetryFor('deny',
|
|
2322
|
+
return { action: 'deny', telemetry: telemetryFor('deny', counted), reason, suggestion };
|
|
1516
2323
|
}
|
|
1517
2324
|
|
|
1518
2325
|
// ---------------------------------------------------------------------------
|
|
@@ -1524,18 +2331,61 @@ async function main() {
|
|
|
1524
2331
|
const input = await readStdin();
|
|
1525
2332
|
if (!input) return emitAllow();
|
|
1526
2333
|
|
|
1527
|
-
|
|
2334
|
+
// The payload `cwd` follows the session's `cd`; it names the repo git measures
|
|
2335
|
+
// in, and only through its git toplevel where this session's state lives
|
|
2336
|
+
// (`resolveSessionRoot()`) — a `cd sub` must not move the ledger.
|
|
2337
|
+
const cwd = typeof input.cwd === 'string' && input.cwd !== ''
|
|
1528
2338
|
? input.cwd
|
|
1529
2339
|
: bannerProjectDir();
|
|
1530
|
-
const sessionId =
|
|
2340
|
+
const sessionId = sessionIdOf(input);
|
|
1531
2341
|
|
|
1532
2342
|
// Cheap pre-check: skip all I/O for the overwhelmingly common non-dispatch call.
|
|
1533
2343
|
if (input.tool_name !== DISPATCH_TOOL) return emitAllow();
|
|
1534
2344
|
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
const
|
|
2345
|
+
// ONE git deadline for this whole fire (`GIT_BUDGET_MS`). Worst case 7 spawns
|
|
2346
|
+
// against it: the toplevel below, up to 5 in `worktreeBaseFacts` (HEAD,
|
|
2347
|
+
// origin/HEAD, is-ancestor, shallow+FETCH_HEAD, rev-list) and `ls-files`.
|
|
2348
|
+
const git = makeGitRunner(Date.now() + GIT_BUDGET_MS);
|
|
2349
|
+
const cwdToplevel = gitToplevel(cwd, git);
|
|
2350
|
+
const sessionRoot = resolveSessionRoot(cwd, cwdToplevel);
|
|
2351
|
+
|
|
2352
|
+
const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
|
|
2353
|
+
const waveKey = waveKeyOf(sessionRoot, sessionId, readFileSync, ownIds);
|
|
2354
|
+
// `selfUseId`: this dispatch's own tool_use may already stand in the transcript
|
|
2355
|
+
// and must not make a finished same-named predecessor look alive (#1480 A).
|
|
2356
|
+
const isFinished = makeFinishedProbe({ transcriptPath: input.transcript_path, selfUseId: input.tool_use_id });
|
|
2357
|
+
const { ledgerRel, lockRel } = ledgerPathsFor(sessionId);
|
|
2358
|
+
const ledgerPath = path.join(sessionRoot, ledgerRel);
|
|
2359
|
+
|
|
2360
|
+
// #1485 — the stale-worktree-base verdict, computed BEFORE the ledger cycle
|
|
2361
|
+
// (and outside the lock: its git spawns hold nobody up there). It never
|
|
2362
|
+
// reads the collision verdict and a collision DENY always wins; on its own it
|
|
2363
|
+
// denies ONLY a measured mismatch (`stale === true`). Every skip is allowed.
|
|
2364
|
+
// It must be known before the cycle because a dispatch this check will deny
|
|
2365
|
+
// never happens, so the cycle must not persist it: a phantom claim would make
|
|
2366
|
+
// the deny's own remedy — the same agent re-dispatched in place under a new
|
|
2367
|
+
// tool_use_id — collide with it for up to `IN_FLIGHT_TTL_MS`.
|
|
2368
|
+
//
|
|
2369
|
+
// It draws on the shared budget BEFORE the tracked-file listing: a missed
|
|
2370
|
+
// stale base has no later gate (matrix row 15), a missed listing only
|
|
2371
|
+
// degrades glob expansion, and three later gates still see a collision.
|
|
2372
|
+
const worktreeBase = worktreeBaseFacts(input, cwd, settingsRootOf(cwd, cwdToplevel), git);
|
|
2373
|
+
const staleDeny = worktreeBase?.stale === true ? staleWorktreeDeny(worktreeBase) : null;
|
|
2374
|
+
|
|
2375
|
+
// Listed at `cwd`'s toplevel ONLY: the declared paths are repo-relative to the
|
|
2376
|
+
// repo the agents edit, which is the one the session works in (after entering
|
|
2377
|
+
// a worktree: that worktree). Whenever git resolved it, it IS `sessionRoot` —
|
|
2378
|
+
// except inside a harness agent worktree, which `sessionRoot` lifts to its
|
|
2379
|
+
// parent (#1492) and which lists the same repo's files from its own HEAD, and
|
|
2380
|
+
// in a session launched in a repo SUBDIRECTORY, where `sessionRoot` is that
|
|
2381
|
+
// launch dir and the listing stays at the toplevel above it;
|
|
2382
|
+
// when git could not, `sessionRoot` falls back to a `.git` ancestor (git just
|
|
2383
|
+
// failed there), `$CLAUDE_PROJECT_DIR` or a bare `cwd` — another repo, or a
|
|
2384
|
+
// subdirectory from which `ls-files` answers subdir-relative (the review-MED
|
|
2385
|
+
// class above) — so the listing degrades (matrix row 8) instead of following
|
|
2386
|
+
// it there.
|
|
2387
|
+
const known = trackedFilesIn(cwdToplevel, git);
|
|
2388
|
+
const knownFiles = known.files;
|
|
1539
2389
|
|
|
1540
2390
|
// The read-modify-write CYCLE, run under the ledger lock below. Everything
|
|
1541
2391
|
// inside is synchronous and emits NOTHING — an emit here would `process.exit()`
|
|
@@ -1549,7 +2399,15 @@ async function main() {
|
|
|
1549
2399
|
} catch (err) {
|
|
1550
2400
|
// Absent ledger is the normal first-dispatch case, NOT corruption (row 7
|
|
1551
2401
|
// must not fire on every wave's first agent).
|
|
1552
|
-
if (err?.code !== 'ENOENT')
|
|
2402
|
+
if (err?.code !== 'ENOENT') {
|
|
2403
|
+
ledgerCorrupt = true;
|
|
2404
|
+
} else {
|
|
2405
|
+
// #1493.3 migration: claims this session recorded in the shared
|
|
2406
|
+
// pre-#1493 file before the split still bind. Read, never touched.
|
|
2407
|
+
let legacyRaw = null;
|
|
2408
|
+
try { legacyRaw = readFileSync(path.join(sessionRoot, LEGACY_LEDGER_REL), 'utf8'); } catch { /* absent */ }
|
|
2409
|
+
ledger = adoptLegacyLedger(legacyRaw, sessionId);
|
|
2410
|
+
}
|
|
1553
2411
|
}
|
|
1554
2412
|
|
|
1555
2413
|
const verdict = decide({
|
|
@@ -1562,7 +2420,9 @@ async function main() {
|
|
|
1562
2420
|
isFinished,
|
|
1563
2421
|
});
|
|
1564
2422
|
|
|
1565
|
-
|
|
2423
|
+
// Not on a stale-base deny — the same rule as the collision deny path
|
|
2424
|
+
// (`decide()`, "Deliberately NOT persisting the ledger on deny").
|
|
2425
|
+
if (verdict.ledger && staleDeny === null) {
|
|
1566
2426
|
try {
|
|
1567
2427
|
writeJsonAtomicSync(ledgerPath, verdict.ledger);
|
|
1568
2428
|
} catch {
|
|
@@ -1574,7 +2434,7 @@ async function main() {
|
|
|
1574
2434
|
};
|
|
1575
2435
|
|
|
1576
2436
|
let verdict;
|
|
1577
|
-
const lockPath = path.join(
|
|
2437
|
+
const lockPath = path.join(sessionRoot, lockRel);
|
|
1578
2438
|
try {
|
|
1579
2439
|
mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
1580
2440
|
} catch { /* the unlocked fallback below still works */ }
|
|
@@ -1607,72 +2467,75 @@ async function main() {
|
|
|
1607
2467
|
// import is dynamic so a checkout without `scripts/lib/events.mjs` degrades to
|
|
1608
2468
|
// silence instead of crashing (§ Import safety), and the catch guarantees a
|
|
1609
2469
|
// failed write cannot change the verdict or the exit code.
|
|
2470
|
+
//
|
|
2471
|
+
// `decide()` never sees the stale-base check, so its `ledger_result` describes
|
|
2472
|
+
// the COLLISION verdict alone. When the stale base is what actually blocks the
|
|
2473
|
+
// dispatch (no collision deny beside it — that one keeps `deny`), the record
|
|
2474
|
+
// says so (#1489 Pkt 8): recorded as `allow`, a refused dispatch counted as a
|
|
2475
|
+
// dispatched one in every later query. The replaced value survives as
|
|
2476
|
+
// `collision_result`, so a `warn-ledger-corrupt` / `warn-not-evaluable` behind
|
|
2477
|
+
// a stale-base deny is not reported on stderr alone.
|
|
1610
2478
|
if (verdict.telemetry) {
|
|
2479
|
+
const telemetry = {
|
|
2480
|
+
...verdict.telemetry,
|
|
2481
|
+
...(staleDeny !== null && verdict.action !== 'deny'
|
|
2482
|
+
? { ledger_result: 'deny-stale-base', collision_result: verdict.telemetry.ledger_result }
|
|
2483
|
+
: {}),
|
|
2484
|
+
...(known.skipped === undefined ? {} : { known_files_skipped: known.skipped }),
|
|
2485
|
+
};
|
|
1611
2486
|
try {
|
|
1612
2487
|
const { emitEvent, sessionAttribution } = await import(
|
|
1613
2488
|
pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'events.mjs')).href
|
|
1614
2489
|
);
|
|
1615
2490
|
await emitEvent(
|
|
1616
2491
|
SCOPE_EVENT,
|
|
1617
|
-
{ hook: HOOK_NAME, ...
|
|
1618
|
-
{ repoRoot:
|
|
2492
|
+
{ hook: HOOK_NAME, ...telemetry, ...sessionAttribution(sessionRoot) },
|
|
2493
|
+
{ repoRoot: sessionRoot }
|
|
1619
2494
|
);
|
|
1620
2495
|
} catch { /* observability is best-effort — it never blocks the decision */ }
|
|
1621
2496
|
}
|
|
1622
2497
|
|
|
1623
|
-
// #
|
|
1624
|
-
// emit for the same process.exit() reason
|
|
1625
|
-
//
|
|
1626
|
-
// STATE.md failure resolves to silence rather than to a wrong accusation.
|
|
1627
|
-
//
|
|
1628
|
-
// ROUTED TO THE OPERATOR, not to stderr alone (w3-5): under the exit-0
|
|
1629
|
-
// PreToolUse protocol stderr goes nowhere — `io.mjs:545` calls it the "Debug
|
|
1630
|
-
// channel … Invisible under exit 0" — so the first cut of this check announced
|
|
1631
|
-
// the s18 incident class to a log and to nobody. The visible channel is the
|
|
1632
|
-
// top-level `systemMessage`, i.e. `emitWarn`, which carries NO
|
|
1633
|
-
// `permissionDecision` and therefore still means ALLOW (io.mjs § "which is
|
|
1634
|
-
// precisely what keeps warn non-blocking").
|
|
1635
|
-
//
|
|
1636
|
-
// The note is CARRIED to the single terminal emit below rather than emitted
|
|
1637
|
-
// here, because `emitWarn` calls `process.exit(0)` and never returns — warning
|
|
1638
|
-
// inline would terminate the process before a collision DENY could be emitted,
|
|
1639
|
-
// flipping a block into an allow (§ stdout discipline, the same reason
|
|
1640
|
-
// `decide()` is pure).
|
|
2498
|
+
// #1485 — the stale-worktree-base record (verdict computed above, before the
|
|
2499
|
+
// cycle), awaited BEFORE the terminal emit for the same process.exit() reason
|
|
2500
|
+
// as the block above.
|
|
1641
2501
|
//
|
|
1642
2502
|
// `null` here means "not a worktree dispatch" and stays silent; ANY other
|
|
1643
2503
|
// shape is emitted, including the `{stale: null, skipped: …}` non-measurements
|
|
1644
|
-
// (#1424)
|
|
1645
|
-
//
|
|
1646
|
-
const worktreeBase = await worktreeBaseFacts(input, projectDir, sessionId);
|
|
1647
|
-
let staleNote = null;
|
|
2504
|
+
// (#1424), with this check's own `decision` folded into the SAME record
|
|
2505
|
+
// rather than a second event.
|
|
1648
2506
|
if (worktreeBase !== null) {
|
|
1649
|
-
if (worktreeBase.stale === true) staleNote = staleWorktreeWarning(worktreeBase);
|
|
1650
2507
|
try {
|
|
1651
2508
|
const { emitEvent, sessionAttribution } = await import(
|
|
1652
2509
|
pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'events.mjs')).href
|
|
1653
2510
|
);
|
|
1654
2511
|
await emitEvent(
|
|
1655
2512
|
WORKTREE_BASE_EVENT,
|
|
1656
|
-
{
|
|
1657
|
-
|
|
2513
|
+
{
|
|
2514
|
+
hook: HOOK_NAME,
|
|
2515
|
+
...worktreeBase,
|
|
2516
|
+
decision: staleDeny === null ? 'allow' : 'deny',
|
|
2517
|
+
...sessionAttribution(sessionRoot),
|
|
2518
|
+
},
|
|
2519
|
+
{ repoRoot: sessionRoot }
|
|
1658
2520
|
);
|
|
1659
2521
|
} catch { /* observability is best-effort — it never blocks the decision */ }
|
|
1660
2522
|
}
|
|
1661
2523
|
|
|
1662
|
-
// ONE terminal emit
|
|
1663
|
-
//
|
|
1664
|
-
// carries the ⛔ headline, so the note stays on the debug channel — a stale
|
|
1665
|
-
// base is moot for a dispatch that was just blocked.
|
|
2524
|
+
// ONE terminal emit. A collision DENY keeps its own reason — the dispatch is
|
|
2525
|
+
// blocked either way — and the stale base goes to the debug channel beside it.
|
|
1666
2526
|
if (verdict.action === 'deny') {
|
|
1667
|
-
if (
|
|
1668
|
-
try { console.error(`⚠ ${
|
|
2527
|
+
if (staleDeny !== null) {
|
|
2528
|
+
try { console.error(`⚠ ${staleDeny.reason}`); } catch { /* stderr may be closed */ }
|
|
1669
2529
|
}
|
|
1670
2530
|
return emitDeny(verdict.reason, verdict.suggestion);
|
|
1671
2531
|
}
|
|
1672
|
-
if (
|
|
1673
|
-
|
|
2532
|
+
if (staleDeny !== null) {
|
|
2533
|
+
if (verdict.action === 'warn') {
|
|
2534
|
+
try { console.error(`⚠ ${verdict.note}`); } catch { /* stderr may be closed */ }
|
|
2535
|
+
}
|
|
2536
|
+
return emitDeny(staleDeny.reason, staleDeny.suggestion);
|
|
1674
2537
|
}
|
|
1675
|
-
if (
|
|
2538
|
+
if (verdict.action === 'warn') return emitWarn(verdict.note);
|
|
1676
2539
|
return emitAllow();
|
|
1677
2540
|
}
|
|
1678
2541
|
|
|
@@ -1720,9 +2583,10 @@ if (invokedAsScript()) {
|
|
|
1720
2583
|
// `enforce-scope`'s fail-closed handler — and the reason is the asymmetry
|
|
1721
2584
|
// named in the matrix header: enforce-scope guards a WRITE (denying one
|
|
1722
2585
|
// write is cheap), this guards the DISPATCH path (denying every dispatch
|
|
1723
|
-
// is a session outage).
|
|
2586
|
+
// is a session outage). A hidden COLLISION is caught downstream by
|
|
1724
2587
|
// `validate-wave-scope.mjs` and by `enforce-scope` at write time; a
|
|
1725
|
-
//
|
|
2588
|
+
// hidden #1485 stale base is not (matrix row 15) — the accepted cost of
|
|
2589
|
+
// the same asymmetry; a wrongly-denied dispatch is caught by nothing.
|
|
1726
2590
|
// -------------------------------------------------------------------------
|
|
1727
2591
|
try {
|
|
1728
2592
|
await bootstrap();
|