session-orchestrator 5.3.0 → 5.4.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/navigator/SKILL.md +22 -0
- package/.agents/skills/navigator/agents/openai.yaml +5 -0
- 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/navigator/SKILL.md +22 -0
- package/.codex-plugin/skills/navigator/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/navigator.md +15 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +27 -12
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/navigator/SKILL.md +13 -0
- 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 +70 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +57 -4
- package/docs/components.md +12 -12
- package/docs/events-schema.md +11 -8
- package/docs/persona-panel.md +5 -4
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +26 -4
- package/docs/session-config-reference.md +55 -6
- package/docs/session-config-template.md +11 -2
- package/hooks/_lib/hook-import-set.json +27 -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 +53 -0
- package/hooks/on-stop.mjs +3 -1
- package/hooks/post-tool-batch-wave-signal.mjs +3 -1
- package/hooks/pre-bash-memory-propose-audit.mjs +52 -3
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +559 -72
- package/hooks/subagent-telemetry.mjs +96 -19
- package/package.json +3 -3
- package/pi/prompts/navigator.md +12 -0
- 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 +492 -0
- package/scripts/archive-closed-prds.mjs +2 -1
- package/scripts/autopilot.mjs +49 -53
- package/scripts/backfill-abandoned-sessions.mjs +1 -1
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/cursor-install.mjs +1 -4
- package/scripts/emit-session.mjs +296 -2
- package/scripts/export-hw-learnings.mjs +22 -3
- package/scripts/fleet-checkin.mjs +147 -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 +10 -6
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- 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-schema.mjs +13 -0
- package/scripts/lib/config.mjs +71 -10
- package/scripts/lib/events.mjs +21 -2
- package/scripts/lib/fleet-protocol.mjs +270 -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/instruction-budget-guard.mjs +69 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +34 -3
- package/scripts/lib/learnings/io.mjs +154 -26
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- 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 +104 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +291 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +92 -0
- package/scripts/lib/orphan-reaper/scan.mjs +506 -0
- package/scripts/lib/orphan-reaper.mjs +43 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/process-group.mjs +62 -11
- 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 +3 -4
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +27 -2
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-token-rollup.mjs +245 -52
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/telemetry/pricing.mjs +118 -71
- package/scripts/lib/tests-src-ratio.mjs +140 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- 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 +87 -14
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/telemetry.mjs +8 -1
- package/scripts/lib/vault-status/board-writer.mjs +48 -11
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- 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/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +10 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +158 -30
- package/scripts/run-quality-gate.mjs +323 -302
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +56 -5
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +72 -46
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +131 -0
- package/skills/_shared/state-ownership.md +5 -0
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +1 -1
- package/skills/bootstrap/_shared-template.md +1 -1
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/evolve/SKILL.md +2 -2
- package/skills/evolve/references/evolve-analyze-mode.md +10 -6
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/navigator/SKILL.md +193 -0
- package/skills/navigator/references/account-check.sh +87 -0
- package/skills/navigator/references/fleet-decisions.mjs +231 -0
- package/skills/navigator/references/slot-check.sh +114 -0
- 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 +22 -12
- package/skills/session-end/metrics-collection.md +5 -6
- package/skills/session-end/references/phase-3-documentation-updates.md +2 -2
- 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 +22 -60
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +6 -0
- package/skills/session-start/references/operations-contract.md +4 -0
- 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 +2 -2
- 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/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
- package/skills/wave-executor/references/wave-loop-review.md +3 -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
|
|
@@ -112,11 +130,11 @@
|
|
|
112
130
|
* | 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
131
|
* | 8 | `git ls-files` failed | ALLOW (degraded) | glob-vs-glob expansion degrades, concrete collisions are still found. A git outage is not a scope violation |
|
|
114
132
|
* | 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 |
|
|
133
|
+
* |10 | same agent id re-dispatched | ALLOW; predecessor pruned only when FINISHED | a retry after a failed agent is legitimate (a failed agent is finished), so the guard must not self-lock. #1480 B4: an unfinished predecessor is KEPT as a claim — a third agent hitting only its scope still collides. With distinct `tool_use_id`s on both it is an ordinary partner (row 11); without them it never collides with its own retry. Replaced WITHOUT a probe only where no claim is lost: the same `tool_use_id` (the same dispatch evaluated again), or — neither carrying an id — a predecessor wholly covered by the retry's scope |
|
|
134
|
+
* |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 |
|
|
117
135
|
* |11 | collision with a prior agent still IN FLIGHT | **DENY** | the one case this hook exists for |
|
|
118
136
|
* |12 | unexpected throw | ALLOW + stderr | as row 2 |
|
|
119
|
-
* |13 | liveness probe throws / no evidence
|
|
137
|
+
* |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
138
|
* |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 |
|
|
121
139
|
*
|
|
122
140
|
* Every row that reaches a verdict from `decide()` — 5–11 and 13–14 — also
|
|
@@ -358,9 +376,77 @@ const MAX_TRANSCRIPT_BYTES = 256 * 1024 * 1024;
|
|
|
358
376
|
* a completion — see § Liveness (b). Reading it as one would let every real
|
|
359
377
|
* background-batch collision through, which is the one direction this repair
|
|
360
378
|
* must not take.
|
|
379
|
+
*
|
|
380
|
+
* Recognised only as the PREFIX of the result text (after trimStart): a finished
|
|
381
|
+
* sync report that merely QUOTES this sentence is not a launch receipt, and read
|
|
382
|
+
* as one it stayed "running" without TTL (R1 LOW). Census 2026-09-30, 5,881
|
|
383
|
+
* local transcripts: 4,684 of 4,684 ACK results begin with it, also in their
|
|
384
|
+
* first content part.
|
|
361
385
|
*/
|
|
362
386
|
const ASYNC_LAUNCH_ACK = 'Async agent launched successfully';
|
|
363
387
|
|
|
388
|
+
/**
|
|
389
|
+
* The `tool_result` forms that PROVE a dispatch finished (#1480 B2), one line
|
|
390
|
+
* per form. A result matching none of them is NOT a completion but "no
|
|
391
|
+
* evidence" — the entry falls back to the TTL rule (matrix row 13). `is_error`
|
|
392
|
+
* is handled beside this list (the dispatch never ran), and the ACK is tested
|
|
393
|
+
* FIRST, so a launch receipt can never read as a completion.
|
|
394
|
+
*
|
|
395
|
+
* Census 2026-09-30 (local transcripts, 14 days, counts only), Agent
|
|
396
|
+
* tool_results: ACK 4,730; sync completion carrying the `<usage>`/`total_tokens`
|
|
397
|
+
* trailer and `agentId:` 690 (690/690); `is_error` 37; neither 38. Of those 38,
|
|
398
|
+
* "This agent's report was delivered…" is a completion (35/35 without a
|
|
399
|
+
* terminal carrier before or after, 2026-09-30), "Fork started — processing in
|
|
400
|
+
* b…" is not (a start receipt without the ACK wording — before this list it was
|
|
401
|
+
* read as a completion: the B2 fail-open).
|
|
402
|
+
*
|
|
403
|
+
* Named ceiling (BV-004): any other wording reads as "unknown", so a fresh entry
|
|
404
|
+
* binds for up to IN_FLIGHT_TTL_MS although its agent may be done — a bounded
|
|
405
|
+
* false-DENY, never a false-ALLOW. Revisit-Trigger: a census re-run finds an
|
|
406
|
+
* Agent tool_result wording in neither this list nor ASYNC_LAUNCH_ACK — classify
|
|
407
|
+
* it, then add ONE line here.
|
|
408
|
+
*/
|
|
409
|
+
const RESULT_COMPLETION_FORMS = Object.freeze([
|
|
410
|
+
Object.freeze({ at: 'anywhere', text: '<usage>' }), // sync trailer, 690/690
|
|
411
|
+
Object.freeze({ at: 'anywhere', text: 'total_tokens' }), // sync trailer, 690/690
|
|
412
|
+
Object.freeze({ at: 'start', text: "This agent's report was delivered" }), // 35/35 without carrier
|
|
413
|
+
]);
|
|
414
|
+
|
|
415
|
+
/** The tool that addresses — and can RESUME — an already-dispatched agent (#1459 P1). */
|
|
416
|
+
const SEND_MESSAGE_TOOL = 'SendMessage';
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* A `SendMessage` result that reached nobody (`{"success":false,…"No agent
|
|
420
|
+
* named…"}`, census 2026-09-30): such a send activates nothing.
|
|
421
|
+
*/
|
|
422
|
+
const SEND_NO_EFFECT = '"success":false';
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The harness's `agentId:` line in an Agent tool_result (ACK or sync trailer) —
|
|
426
|
+
* the task id a `SendMessage` addresses. Only a line that BEGINS with it, id on
|
|
427
|
+
* the same line, read as the LAST match over the content parts joined with
|
|
428
|
+
* `\n`: a sync result embeds the agent's own report ahead of the harness
|
|
429
|
+
* trailer, and a report ending in `agentId:` must not swallow the trailer's
|
|
430
|
+
* line (R1 MEDIUM — a wrong task id let a resumed agent read as finished).
|
|
431
|
+
* Census 2026-09-30, 5,881 local transcripts: in 5,375 of 5,375 Agent results
|
|
432
|
+
* carrying `agentId:`, the last one starts a line and sits in the last part.
|
|
433
|
+
*/
|
|
434
|
+
const AGENT_ID_LINE_RE = /^agentId:[ \t]*([A-Za-z0-9_-]+)/gm;
|
|
435
|
+
|
|
436
|
+
/** Per-dispatch liveness states — see {@link buildLivenessIndex}. */
|
|
437
|
+
const LIVE_FINISHED = 'finished';
|
|
438
|
+
const LIVE_RUNNING = 'running';
|
|
439
|
+
const LIVE_UNKNOWN = 'unknown';
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* `<task-notification>` statuses that mean the task is NO LONGER RUNNING (#1455).
|
|
443
|
+
* Enumerated from evidence, not guessed — census 2026-09-25 over the operator's
|
|
444
|
+
* local Claude Code transcripts (`rg "<tool-use-id>…<status>X</status>"` on
|
|
445
|
+
* `*.jsonl`): completed, failed 1430 lines, killed 609, stopped 28.
|
|
446
|
+
* `running` is deliberately absent — it is the one non-terminal spelling.
|
|
447
|
+
*/
|
|
448
|
+
const TERMINAL_STATUS_RE = /<status>(?:completed|failed|killed|stopped)<\/status>/;
|
|
449
|
+
|
|
364
450
|
/**
|
|
365
451
|
* The consequence block spliced VERBATIM into the GUARD INACTIVE banner (#993),
|
|
366
452
|
* naming the enforcement this hook's outage stops applying.
|
|
@@ -750,8 +836,8 @@ export function extractScopeFromPrompt(prompt) {
|
|
|
750
836
|
|
|
751
837
|
/**
|
|
752
838
|
* The dispatch's human description — the field the liveness probe matches
|
|
753
|
-
* against the transcript's `tool_use` blocks
|
|
754
|
-
* payloads).
|
|
839
|
+
* against the transcript's `tool_use` blocks when an entry carries no exact
|
|
840
|
+
* `useId` (present in 147/147 measured payloads).
|
|
755
841
|
*
|
|
756
842
|
* @param {{description?: unknown}} toolInput
|
|
757
843
|
* @returns {string}
|
|
@@ -777,69 +863,401 @@ export function agentIdOf(toolInput) {
|
|
|
777
863
|
return 'unnamed-agent';
|
|
778
864
|
}
|
|
779
865
|
|
|
866
|
+
/**
|
|
867
|
+
* A dispatch's EXACT identity (#1480 A): the PreToolUse payload's `tool_use_id`,
|
|
868
|
+
* or a ledger entry's stored `useId`. On Claude Code it equals the transcript's
|
|
869
|
+
* `tool_use` id, so liveness becomes a lookup instead of a description match;
|
|
870
|
+
* other harnesses may omit it, and `''` then means "no exact id". Taken
|
|
871
|
+
* verbatim, never re-spelled — and dropped rather than clipped past
|
|
872
|
+
* MAX_AGENT_ID_CHARS: a clipped id could never match, two clipped ids could
|
|
873
|
+
* match wrongly.
|
|
874
|
+
*
|
|
875
|
+
* @param {unknown} value
|
|
876
|
+
* @returns {string}
|
|
877
|
+
*/
|
|
878
|
+
function exactUseId(value) {
|
|
879
|
+
return typeof value === 'string' && value.trim() !== '' && value.length <= MAX_AGENT_ID_CHARS ? value : '';
|
|
880
|
+
}
|
|
881
|
+
|
|
780
882
|
// ---------------------------------------------------------------------------
|
|
781
883
|
// Liveness — has an already-recorded agent FINISHED? (§ Liveness)
|
|
782
884
|
// ---------------------------------------------------------------------------
|
|
783
885
|
|
|
886
|
+
const NOTIFICATION_OPEN = '<task-notification>';
|
|
887
|
+
const NOTIFICATION_CLOSE = '</task-notification>';
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* A notification head in the harness's own tag order: `<task-id>`,
|
|
891
|
+
* `<tool-use-id>`, optional `<output-file>`, `<status>` — anchored at the opener.
|
|
892
|
+
* Group 1 is the task id (the resume key, #1459 P1), group 2 the tool-use id,
|
|
893
|
+
* group 3 the whole `<status>` element.
|
|
894
|
+
*/
|
|
895
|
+
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>)/;
|
|
896
|
+
|
|
897
|
+
/** `origin.kind` / `commandMode` value the harness stamps on a notification carrier. */
|
|
898
|
+
const CARRIER_KIND = 'task-notification';
|
|
899
|
+
|
|
900
|
+
/** `queue-operation` operations measured to carry a notification (dequeue carries no content). */
|
|
901
|
+
const CARRIER_QUEUE_OPS = new Set(['enqueue', 'remove']);
|
|
902
|
+
|
|
903
|
+
/** First line of the harness preamble some `user` carriers put before the block. */
|
|
904
|
+
const SYSTEM_NOTIFICATION_MARK = '[SYSTEM NOTIFICATION - NOT USER INPUT]\n';
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* The notification text of a HARNESS-WRITTEN carrier record, or `null` for any
|
|
908
|
+
* other record (#1459 Pkt 2 Rest). Census 2026-09-28 over the operator's local
|
|
909
|
+
* transcripts (11,005 files, 2026-08-06 → 2026-09-28): every terminal id the
|
|
910
|
+
* pre-fix raw-line match accepted from a real carrier came from exactly one of
|
|
911
|
+
* three record forms, each of which carries ids the other two do not:
|
|
912
|
+
* - `user` with STRING `message.content` and `origin.kind: 'task-notification'`
|
|
913
|
+
* (a typed prompt carries `origin.kind: 'human'`). The text begins with the
|
|
914
|
+
* opener, or with the harness preamble ({@link SYSTEM_NOTIFICATION_MARK},
|
|
915
|
+
* tag-free, ending in a blank line) directly followed by the opener;
|
|
916
|
+
* - `queue-operation` with `operation` `enqueue`/`remove` and string `content`;
|
|
917
|
+
* - `attachment` of type `queued_command` with `commandMode: 'task-notification'`.
|
|
918
|
+
* Everything else — `tool_result` content, assistant text, tool inputs, typed
|
|
919
|
+
* prompts — is a quote or a forgery and yields `null`, i.e. no finished id.
|
|
920
|
+
*
|
|
921
|
+
* The guard on the `user` form is `origin.kind`, which only the harness sets; the
|
|
922
|
+
* preamble rule merely strips a tag-free prefix and is no control on its own.
|
|
923
|
+
*
|
|
924
|
+
* Named residual (BV-004): a `queue-operation` record has no field that tells a
|
|
925
|
+
* harness notification from an operator prompt typed while the agent was busy,
|
|
926
|
+
* so an OPERATOR could forge through that one channel. An agent cannot through
|
|
927
|
+
* the one producer censused: text a subagent or peer delivers via `SendMessage`
|
|
928
|
+
* arrives wrapped by the harness, so it never begins with the opener. Measured
|
|
929
|
+
* 2026-09-28 in a second run (count only, 11,008 transcript files, three more
|
|
930
|
+
* than the census above): 7,131 records carry such a message, 1,801 of them as
|
|
931
|
+
* `queue-operation`, 2,486 as `queued_command` with `commandMode: 'prompt'`;
|
|
932
|
+
* 0 of the 7,131 begin with `<task-notification>`. Other queue producers
|
|
933
|
+
* (scheduled wake-ups, a `/loop` body) were not censused. Revisit if the harness
|
|
934
|
+
* adds a discriminator here (like `commandMode` on the attachment) or stops
|
|
935
|
+
* wrapping.
|
|
936
|
+
*
|
|
937
|
+
* @param {any} rec — one parsed transcript record
|
|
938
|
+
* @returns {string|null}
|
|
939
|
+
*/
|
|
940
|
+
function notificationCarrierText(rec) {
|
|
941
|
+
if (rec?.type === 'user' && rec?.origin?.kind === CARRIER_KIND && typeof rec?.message?.content === 'string') {
|
|
942
|
+
const text = rec.message.content;
|
|
943
|
+
if (!text.startsWith(SYSTEM_NOTIFICATION_MARK)) return text;
|
|
944
|
+
const open = text.indexOf('<');
|
|
945
|
+
return open !== -1 && text.startsWith(`\n\n${NOTIFICATION_OPEN}`, open - 2) ? text.slice(open) : null;
|
|
946
|
+
}
|
|
947
|
+
if (rec?.type === 'queue-operation' && CARRIER_QUEUE_OPS.has(rec?.operation) && typeof rec?.content === 'string') {
|
|
948
|
+
return rec.content;
|
|
949
|
+
}
|
|
950
|
+
const att = rec?.type === 'attachment' ? rec?.attachment : undefined;
|
|
951
|
+
if (att?.type === 'queued_command' && att?.commandMode === CARRIER_KIND && typeof att?.prompt === 'string') {
|
|
952
|
+
return att.prompt;
|
|
953
|
+
}
|
|
954
|
+
return null;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* The HEAD of a carrier text's FIRST `<task-notification>` block (#1459 Pkt 2) —
|
|
959
|
+
* its task id, its tool-use id and whether its status is TERMINAL; at most one
|
|
960
|
+
* head per carrier. Only a terminal head finishes an id; a non-terminal one only
|
|
961
|
+
* contributes the task id (#1459 P1), which can open a dispatch, never close it.
|
|
962
|
+
* (Named `finishedNotificationIds` until #1480; the ceiling below is unchanged.)
|
|
963
|
+
*
|
|
964
|
+
* The text must BEGIN with the opener (a block further in is a quote) and its
|
|
965
|
+
* head must follow the harness tag order ({@link NOTIFICATION_HEAD_RE}).
|
|
966
|
+
* `<summary>` and `<result>` carry free text that can QUOTE another
|
|
967
|
+
* notification's `<tool-use-id>` and `<status>`, or even forge a whole block
|
|
968
|
+
* (R2 F1: a summary that closes itself and opens a complete fake block defeated
|
|
969
|
+
* the earlier cut-then-split approach). So nothing past the block's HEAD is read:
|
|
970
|
+
* the head runs from the opener to the earliest `<summary>`, `<result>` or
|
|
971
|
+
* `</task-notification>`. Fail-closed — the deny stays — when the text is not a
|
|
972
|
+
* carrier's, does not begin with the opener, has no closing tag (truncated
|
|
973
|
+
* block), or its head is out of order or carries no terminal status.
|
|
974
|
+
*
|
|
975
|
+
* Named ceiling (BV-004, #1467): a batch of several DIFFERENT notifications in
|
|
976
|
+
* one carrier reports only the first as finished (the rest stay running, the
|
|
977
|
+
* safe direction but a false-DENY source). Census 2026-09-30 over the operator's
|
|
978
|
+
* local transcripts of all repos (11,684 files, 2026-08-06 → 2026-09-30, at
|
|
979
|
+
* f58480e5): 23 preamble-form `user` carriers hold 2+ distinct terminal ids; 108
|
|
980
|
+
* non-first ids are finished by no other carrier, and 0 of them is an `Agent`
|
|
981
|
+
* dispatch id (105 Bash, 3 Monitor) — the only ids this index resolves, so no
|
|
982
|
+
* Agent liveness is lost today. The 2026-09-28 figures (31 / 155) were not
|
|
983
|
+
* reproduced. No split is built: it would gain nothing, and its safety is
|
|
984
|
+
* unproven. `origin` has no per-block discriminator, so a split is safe only if
|
|
985
|
+
* the harness escapes `<` in `<summary>`/`<result>` — unescaped, a block forged
|
|
986
|
+
* there is byte-identical to a genuine `\n\n`-joined batch. The census suggests
|
|
987
|
+
* escaping (12,039 of 25,031 results carry `<`, 3 a raw `<` + letter) but
|
|
988
|
+
* does not establish it for every harness version. Revisit-Trigger: a re-run
|
|
989
|
+
* census finds a non-first terminal id that is an `Agent` dispatch id finished
|
|
990
|
+
* by no other carrier or `tool_result`; a split then needs escaping proven first.
|
|
991
|
+
*
|
|
992
|
+
* @param {string|null} text — a carrier's text from {@link notificationCarrierText}
|
|
993
|
+
* @returns {{taskId: string, toolUseId: string, terminal: boolean}|null}
|
|
994
|
+
*/
|
|
995
|
+
function notificationHead(text) {
|
|
996
|
+
if (typeof text !== 'string' || !text.startsWith(NOTIFICATION_OPEN)) return null;
|
|
997
|
+
const close = text.indexOf(NOTIFICATION_CLOSE);
|
|
998
|
+
if (close === -1) return null;
|
|
999
|
+
let headEnd = close;
|
|
1000
|
+
for (const tag of ['<summary>', '<result>']) {
|
|
1001
|
+
const at = text.indexOf(tag);
|
|
1002
|
+
if (at !== -1 && at < headEnd) headEnd = at;
|
|
1003
|
+
}
|
|
1004
|
+
const head = text.slice(0, headEnd).match(NOTIFICATION_HEAD_RE);
|
|
1005
|
+
if (head === null) return null;
|
|
1006
|
+
return { taskId: head[1].trim(), toolUseId: head[2], terminal: TERMINAL_STATUS_RE.test(head[3]) };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Classify an Agent dispatch's OWN `tool_result` (#1480 B2): `done` only on
|
|
1011
|
+
* positive evidence (`is_error`, or one of {@link RESULT_COMPLETION_FORMS}),
|
|
1012
|
+
* `ack` for the launch acknowledgement (a text PREFIX, see
|
|
1013
|
+
* {@link ASYNC_LAUNCH_ACK}), `unknown` for everything else. The ACK is tested
|
|
1014
|
+
* before the forms, so no ACK wording can ever read as a completion.
|
|
1015
|
+
*
|
|
1016
|
+
* @param {{is_error?: unknown}} block
|
|
1017
|
+
* @param {string} text — {@link resultTextOf}(block)
|
|
1018
|
+
* @returns {'done'|'ack'|'unknown'}
|
|
1019
|
+
*/
|
|
1020
|
+
function dispatchResultForm(block, text) {
|
|
1021
|
+
if (block?.is_error === true) return 'done';
|
|
1022
|
+
const lead = text.trimStart();
|
|
1023
|
+
if (lead.startsWith(ASYNC_LAUNCH_ACK)) return 'ack';
|
|
1024
|
+
for (const form of RESULT_COMPLETION_FORMS) {
|
|
1025
|
+
if (form.at === 'start' ? lead.startsWith(form.text) : text.includes(form.text)) return 'done';
|
|
1026
|
+
}
|
|
1027
|
+
return 'unknown';
|
|
1028
|
+
}
|
|
1029
|
+
|
|
784
1030
|
/**
|
|
785
|
-
*
|
|
1031
|
+
* The LAST line-leading `agentId:` a harness-written dispatch result names, or
|
|
1032
|
+
* `''` — see {@link AGENT_ID_LINE_RE}. Parts are joined with `\n` HERE (unlike
|
|
1033
|
+
* {@link resultTextOf}), so a part boundary is a line boundary and report text
|
|
1034
|
+
* cannot run into the trailer's line.
|
|
786
1035
|
*
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
1036
|
+
* @param {{content?: unknown}} block
|
|
1037
|
+
* @returns {string}
|
|
1038
|
+
*/
|
|
1039
|
+
function lastAgentIdIn(block) {
|
|
1040
|
+
const c = block?.content;
|
|
1041
|
+
const text = typeof c === 'string'
|
|
1042
|
+
? c
|
|
1043
|
+
: Array.isArray(c) ? c.map((part) => (typeof part?.text === 'string' ? part.text : '')).join('\n') : '';
|
|
1044
|
+
let last = '';
|
|
1045
|
+
for (const m of text.matchAll(AGENT_ID_LINE_RE)) last = m[1];
|
|
1046
|
+
return last;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Append `value` to the array stored under `key`.
|
|
793
1051
|
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
1052
|
+
* @template T
|
|
1053
|
+
* @param {Map<string, T[]>} map
|
|
1054
|
+
* @param {string} key
|
|
1055
|
+
* @param {T} value
|
|
1056
|
+
*/
|
|
1057
|
+
function pushTo(map, key, value) {
|
|
1058
|
+
const list = map.get(key);
|
|
1059
|
+
if (list === undefined) map.set(key, [value]);
|
|
1060
|
+
else list.push(value);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Index a session transcript by DISPATCH tool-use id → liveness state, plus
|
|
1065
|
+
* description → dispatch ids for ledger entries that carry no exact id.
|
|
1066
|
+
*
|
|
1067
|
+
* Record shapes read, all measured (census 2026-09-30 for the SendMessage half):
|
|
1068
|
+
* - `tool_use` `{name:'Agent', id, input.description}` — the dispatch U.
|
|
1069
|
+
* - `tool_use` `{name:'SendMessage', id, input.to}` — addresses a dispatch by
|
|
1070
|
+
* its task id T(U) (= agentId); 1,055 via a notification task id, 33 via the
|
|
1071
|
+
* ACK's agentId, 0 via the description.
|
|
1072
|
+
* - `tool_result` for U — classified by {@link dispatchResultForm}; for a
|
|
1073
|
+
* SendMessage only whether it reached nobody ({@link SEND_NO_EFFECT} or
|
|
1074
|
+
* `is_error`).
|
|
1075
|
+
* - a `<task-notification>` CARRIER record — only a harness-written carrier
|
|
1076
|
+
* ({@link notificationCarrierText}), only its first block's head
|
|
1077
|
+
* ({@link notificationHead}); a terminal `<status>` finishes (#1455: `failed`
|
|
1078
|
+
* too).
|
|
1079
|
+
*
|
|
1080
|
+
* T(U) is the last line-leading `agentId:` ({@link lastAgentIdIn}) of U's own
|
|
1081
|
+
* ACK or positive result, else the task
|
|
1082
|
+
* id of the first carrier head carrying U's tool-use id.
|
|
1083
|
+
*
|
|
1084
|
+
* RESUME (#1459 P1). U is ACTIVATED by its dispatch and by every SendMessage
|
|
1085
|
+
* with `to === T(U)` whose result is not a no-effect (a send without a result
|
|
1086
|
+
* yet counts — the safe direction). U is FINISHED when a completion lies AFTER
|
|
1087
|
+
* its last activation, by transcript line position: a terminal carrier with
|
|
1088
|
+
* tool-use id U, with the id of an activating SendMessage, or with task id T(U);
|
|
1089
|
+
* when the dispatch itself is the last activation, also U's own positive
|
|
1090
|
+
* result. Measured 2026-09-30: a resumed agent completes under the
|
|
1091
|
+
* SendMessage's id and the same task id, never again under U (455 cases); a
|
|
1092
|
+
* message queued to a running agent completes under U with the same task id
|
|
1093
|
+
* (566). Activations only OPEN; closing still needs a carrier or U's own
|
|
1094
|
+
* harness-written result.
|
|
1095
|
+
*
|
|
1096
|
+
* States: `finished`; `running` — no result, an ACK, or a SendMessage as last
|
|
1097
|
+
* activation with no completion after it (no TTL); `unknown` — only a result
|
|
1098
|
+
* form without positive evidence, which the probe treats as no evidence (TTL).
|
|
1099
|
+
* A repeated `tool_use` record (same Agent or SendMessage id) is the SAME event,
|
|
1100
|
+
* not a new activation: its position is the FIRST occurrence, so a copy after
|
|
1101
|
+
* the completion cannot re-open a finished agent (R1 D4, a false-DENY). A
|
|
1102
|
+
* repeated carrier still closes. Hardening without a measured case: census
|
|
1103
|
+
* 2026-09-30 (5,881 local transcripts) found 0 repeated Agent and 0 repeated
|
|
1104
|
+
* SendMessage `tool_use` records.
|
|
1105
|
+
*
|
|
1106
|
+
* Named ceiling (BV-004): `to` is matched against T(U) only — a SendMessage that
|
|
1107
|
+
* addresses the agent by name or description re-opens nothing (0 of ~6,200
|
|
1108
|
+
* measured 2026-09-30). Revisit-Trigger: a census re-run finds a SendMessage to
|
|
1109
|
+
* a dispatch whose `to` is not its task id.
|
|
797
1110
|
*
|
|
798
1111
|
* Pure and total — a malformed line is skipped, never thrown on.
|
|
799
1112
|
*
|
|
800
1113
|
* @param {string} raw — the transcript's JSONL text
|
|
801
|
-
* @returns {Map<string,
|
|
1114
|
+
* @returns {{states: Map<string, 'finished'|'running'|'unknown'>,
|
|
1115
|
+
* idsByDesc: Map<string, string[]>}}
|
|
802
1116
|
*/
|
|
803
|
-
|
|
804
|
-
const
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
|
|
1117
|
+
function buildLivenessIndex(raw) {
|
|
1118
|
+
const states = new Map();
|
|
1119
|
+
const idsByDesc = new Map();
|
|
1120
|
+
if (typeof raw !== 'string' || raw.length === 0) return { states, idsByDesc };
|
|
1121
|
+
|
|
1122
|
+
const dispatchPos = new Map(); // U → FIRST line position of its tool_use
|
|
1123
|
+
const results = new Map(); // U → [{pos, form, agentId}]
|
|
1124
|
+
const sendsByTo = new Map(); // SendMessage `to` → [{id, pos}]
|
|
1125
|
+
const sendIds = new Set();
|
|
1126
|
+
const sendNoEffect = new Set(); // SendMessage ids whose result reached nobody
|
|
1127
|
+
const headsByUse = new Map(); // carrier tool-use id → [{pos, taskId, terminal}]
|
|
1128
|
+
const terminalByTask = new Map(); // carrier task id → [pos] of terminal heads
|
|
1129
|
+
|
|
1130
|
+
const lines = raw.split('\n');
|
|
1131
|
+
for (let pos = 0; pos < lines.length; pos++) {
|
|
1132
|
+
const line = lines[pos];
|
|
811
1133
|
if (line.length < 24) continue;
|
|
1134
|
+
// Substring prefilter only — the verdict needs the PARSED record: a raw-line
|
|
1135
|
+
// match let a forged block in any record finish an id (#1459 Pkt 2 Rest).
|
|
1136
|
+
const hasNotification = line.includes('task-notification');
|
|
1137
|
+
const hasToolRow = line.includes('"tool_use"') || line.includes('tool_use_id');
|
|
1138
|
+
if (!hasNotification && !hasToolRow) continue;
|
|
1139
|
+
let rec;
|
|
1140
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
812
1141
|
|
|
813
|
-
//
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1142
|
+
// Completion (and the task id) — only from a harness-written carrier record.
|
|
1143
|
+
if (hasNotification) {
|
|
1144
|
+
const head = notificationHead(notificationCarrierText(rec));
|
|
1145
|
+
if (head !== null) {
|
|
1146
|
+
pushTo(headsByUse, head.toolUseId, { pos, taskId: head.taskId, terminal: head.terminal });
|
|
1147
|
+
if (head.terminal && head.taskId !== '') pushTo(terminalByTask, head.taskId, pos);
|
|
1148
|
+
}
|
|
818
1149
|
}
|
|
819
1150
|
|
|
820
|
-
if (!
|
|
821
|
-
let rec;
|
|
822
|
-
try { rec = JSON.parse(line); } catch { continue; }
|
|
1151
|
+
if (!hasToolRow) continue;
|
|
823
1152
|
const content = rec?.message?.content;
|
|
824
1153
|
if (!Array.isArray(content)) continue;
|
|
825
1154
|
|
|
826
1155
|
for (const block of content) {
|
|
827
|
-
if (block?.type === 'tool_use' &&
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1156
|
+
if (block?.type === 'tool_use' && typeof block?.id === 'string') {
|
|
1157
|
+
if (block.name === DISPATCH_TOOL) {
|
|
1158
|
+
if (!results.has(block.id)) { // first occurrence only — see D4 above
|
|
1159
|
+
results.set(block.id, []);
|
|
1160
|
+
dispatchPos.set(block.id, pos);
|
|
1161
|
+
const desc = typeof block?.input?.description === 'string' ? block.input.description.trim() : '';
|
|
1162
|
+
if (desc !== '') pushTo(idsByDesc, desc, block.id);
|
|
1163
|
+
}
|
|
1164
|
+
} else if (block.name === SEND_MESSAGE_TOOL && typeof block?.input?.to === 'string' && block.input.to !== ''
|
|
1165
|
+
&& !sendIds.has(block.id)) {
|
|
1166
|
+
sendIds.add(block.id);
|
|
1167
|
+
pushTo(sendsByTo, block.input.to, { id: block.id, pos });
|
|
1168
|
+
}
|
|
833
1169
|
continue;
|
|
834
1170
|
}
|
|
835
1171
|
if (block?.type === 'tool_result' && typeof block?.tool_use_id === 'string') {
|
|
836
|
-
|
|
837
|
-
if (
|
|
1172
|
+
const own = results.get(block.tool_use_id);
|
|
1173
|
+
if (own !== undefined) {
|
|
1174
|
+
const text = resultTextOf(block);
|
|
1175
|
+
const form = dispatchResultForm(block, text);
|
|
1176
|
+
// An error text is not a harness trailer — learn T(U) from ACK/completion only.
|
|
1177
|
+
const agentId = form === 'ack' || (form === 'done' && block.is_error !== true) ? lastAgentIdIn(block) : '';
|
|
1178
|
+
own.push({ pos, form, agentId });
|
|
1179
|
+
} else if (sendIds.has(block.tool_use_id)
|
|
1180
|
+
&& (block.is_error === true || resultTextOf(block).includes(SEND_NO_EFFECT))) {
|
|
1181
|
+
sendNoEffect.add(block.tool_use_id);
|
|
1182
|
+
}
|
|
838
1183
|
}
|
|
839
1184
|
}
|
|
840
1185
|
}
|
|
841
1186
|
|
|
842
|
-
for (const [
|
|
1187
|
+
for (const [useId, pos] of dispatchPos) {
|
|
1188
|
+
const own = results.get(useId) ?? [];
|
|
1189
|
+
const taskId = own.find((r) => r.agentId !== '')?.agentId
|
|
1190
|
+
?? (headsByUse.get(useId) ?? []).find((h) => h.taskId !== '')?.taskId
|
|
1191
|
+
?? '';
|
|
1192
|
+
|
|
1193
|
+
let lastPos = pos;
|
|
1194
|
+
let lastIsSend = false;
|
|
1195
|
+
const closers = [useId];
|
|
1196
|
+
if (taskId !== '') {
|
|
1197
|
+
for (const send of sendsByTo.get(taskId) ?? []) {
|
|
1198
|
+
if (sendNoEffect.has(send.id)) continue;
|
|
1199
|
+
closers.push(send.id);
|
|
1200
|
+
if (send.pos > lastPos) { lastPos = send.pos; lastIsSend = true; }
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const closedAfter = closers.some((id) => (headsByUse.get(id) ?? []).some((h) => h.terminal && h.pos > lastPos))
|
|
1205
|
+
|| (taskId !== '' && (terminalByTask.get(taskId) ?? []).some((p) => p > lastPos))
|
|
1206
|
+
|| (!lastIsSend && own.some((r) => r.form === 'done' && r.pos > lastPos));
|
|
1207
|
+
|
|
1208
|
+
let state;
|
|
1209
|
+
if (closedAfter) state = LIVE_FINISHED;
|
|
1210
|
+
else if (lastIsSend || own.length === 0 || own.some((r) => r.form === 'ack')) state = LIVE_RUNNING;
|
|
1211
|
+
else state = LIVE_UNKNOWN;
|
|
1212
|
+
states.set(useId, state);
|
|
1213
|
+
}
|
|
1214
|
+
return { states, idsByDesc };
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Description-level verdict for a ledger entry WITHOUT an exact id: `false` if
|
|
1219
|
+
* any of the description's dispatches is running, `true` only if every one is
|
|
1220
|
+
* finished, `undefined` (no evidence → TTL) otherwise. `excludeUseId` drops the
|
|
1221
|
+
* CURRENT dispatch, whose `tool_use` may already sit in the transcript at
|
|
1222
|
+
* PreToolUse time and would make a finished same-named predecessor look alive.
|
|
1223
|
+
*
|
|
1224
|
+
* @param {{states: Map<string, string>, idsByDesc: Map<string, string[]>}} index
|
|
1225
|
+
* @param {string} desc
|
|
1226
|
+
* @param {string} excludeUseId
|
|
1227
|
+
* @returns {boolean|undefined}
|
|
1228
|
+
*/
|
|
1229
|
+
function descFinished(index, desc, excludeUseId) {
|
|
1230
|
+
let seen = false;
|
|
1231
|
+
let unknown = false;
|
|
1232
|
+
for (const useId of index.idsByDesc.get(desc) ?? []) {
|
|
1233
|
+
if (useId === excludeUseId) continue;
|
|
1234
|
+
seen = true;
|
|
1235
|
+
const state = index.states.get(useId);
|
|
1236
|
+
if (state === LIVE_RUNNING) return false;
|
|
1237
|
+
if (state !== LIVE_FINISHED) unknown = true;
|
|
1238
|
+
}
|
|
1239
|
+
return seen && !unknown ? true : undefined;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* Index a session transcript by agent DESCRIPTION → completion state — the
|
|
1244
|
+
* description view of {@link buildLivenessIndex}.
|
|
1245
|
+
*
|
|
1246
|
+
* A description dispatched N times counts as finished only when EVERY one of its
|
|
1247
|
+
* tool_use ids is finished, and as running as soon as one is. Conservative on
|
|
1248
|
+
* purpose: one outstanding run of the same agent keeps the deny alive. A
|
|
1249
|
+
* description with no running run but an `unknown` one is absent (no evidence).
|
|
1250
|
+
*
|
|
1251
|
+
* @param {string} raw — the transcript's JSONL text
|
|
1252
|
+
* @returns {Map<string, boolean>} description → finished? (absent = no evidence)
|
|
1253
|
+
*/
|
|
1254
|
+
export function buildTranscriptIndex(raw) {
|
|
1255
|
+
const index = buildLivenessIndex(raw);
|
|
1256
|
+
const out = new Map();
|
|
1257
|
+
for (const desc of index.idsByDesc.keys()) {
|
|
1258
|
+
const finished = descFinished(index, desc, '');
|
|
1259
|
+
if (finished !== undefined) out.set(desc, finished);
|
|
1260
|
+
}
|
|
843
1261
|
return out;
|
|
844
1262
|
}
|
|
845
1263
|
|
|
@@ -866,10 +1284,16 @@ function resultTextOf(block) {
|
|
|
866
1284
|
* been found. The no-collision path — the overwhelming majority — never touches
|
|
867
1285
|
* the file (§ Liveness, cost containment).
|
|
868
1286
|
*
|
|
869
|
-
* Resolution order per ledger entry:
|
|
870
|
-
* 1.
|
|
871
|
-
*
|
|
872
|
-
*
|
|
1287
|
+
* Resolution order per ledger entry (#1480 A):
|
|
1288
|
+
* 1. an entry WITH `useId` → that exact dispatch's state: finished / running
|
|
1289
|
+
* are definitive, `unknown` or no record is no evidence;
|
|
1290
|
+
* 2. an entry WITHOUT `useId` (legacy, or a harness sending no `tool_use_id`)
|
|
1291
|
+
* → its non-empty description via {@link descFinished}, the current
|
|
1292
|
+
* dispatch (`selfUseId`) excluded. Never the ledger `id` — a
|
|
1293
|
+
* `desc (type)` string or a bare subagent type, which matched unrelated
|
|
1294
|
+
* dispatches (B3);
|
|
1295
|
+
* 3. no evidence → the entry's own age against `IN_FLIGHT_TTL_MS`;
|
|
1296
|
+
* 4. no usable timestamp either → NOT finished (matrix row 13 — keeps the
|
|
873
1297
|
* deny biting rather than inventing a completion).
|
|
874
1298
|
*
|
|
875
1299
|
* @param {object} params
|
|
@@ -877,9 +1301,13 @@ function resultTextOf(block) {
|
|
|
877
1301
|
* @param {number} [params.now]
|
|
878
1302
|
* @param {number} [params.ttlMs]
|
|
879
1303
|
* @param {(p: string, enc: string) => string} [params.readFn]
|
|
880
|
-
* @
|
|
1304
|
+
* @param {string} [params.selfUseId] the CURRENT dispatch's `tool_use_id`
|
|
1305
|
+
* @returns {(entry: {id: string, desc?: string, at?: string, useId?: string}) => boolean}
|
|
881
1306
|
*/
|
|
882
|
-
export function makeFinishedProbe({
|
|
1307
|
+
export function makeFinishedProbe({
|
|
1308
|
+
transcriptPath, now = Date.now(), ttlMs = IN_FLIGHT_TTL_MS, readFn = readFileSync, selfUseId,
|
|
1309
|
+
} = {}) {
|
|
1310
|
+
const excludeUseId = exactUseId(selfUseId);
|
|
883
1311
|
let index; // undefined = not loaded yet, null = unavailable
|
|
884
1312
|
const load = () => {
|
|
885
1313
|
if (index !== undefined) return index;
|
|
@@ -887,7 +1315,7 @@ export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN
|
|
|
887
1315
|
try {
|
|
888
1316
|
if (typeof transcriptPath === 'string' && transcriptPath.length > 0) {
|
|
889
1317
|
if (statSync(transcriptPath).size <= MAX_TRANSCRIPT_BYTES) {
|
|
890
|
-
index =
|
|
1318
|
+
index = buildLivenessIndex(readFn(transcriptPath, 'utf8'));
|
|
891
1319
|
}
|
|
892
1320
|
}
|
|
893
1321
|
} catch {
|
|
@@ -899,10 +1327,17 @@ export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN
|
|
|
899
1327
|
return (entry) => {
|
|
900
1328
|
try {
|
|
901
1329
|
const idx = load();
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1330
|
+
if (idx !== null) {
|
|
1331
|
+
const useId = exactUseId(entry?.useId);
|
|
1332
|
+
if (useId !== '') {
|
|
1333
|
+
const state = idx.states.get(useId);
|
|
1334
|
+
if (state === LIVE_FINISHED) return true;
|
|
1335
|
+
if (state === LIVE_RUNNING) return false;
|
|
1336
|
+
} else {
|
|
1337
|
+
const desc = typeof entry?.desc === 'string' ? entry.desc.trim() : '';
|
|
1338
|
+
const finished = desc === '' ? undefined : descFinished(idx, desc, excludeUseId);
|
|
1339
|
+
if (finished !== undefined) return finished;
|
|
1340
|
+
}
|
|
906
1341
|
}
|
|
907
1342
|
const at = Date.parse(entry?.at ?? '');
|
|
908
1343
|
if (Number.isFinite(at)) return now - at > ttlMs;
|
|
@@ -1378,7 +1813,10 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
1378
1813
|
}
|
|
1379
1814
|
|
|
1380
1815
|
const desc = agentDescOf(toolInput);
|
|
1381
|
-
|
|
1816
|
+
// #1480 A: the exact dispatch id travels with the entry, so liveness is never
|
|
1817
|
+
// guessed from a description when the harness names the dispatch itself.
|
|
1818
|
+
const useId = exactUseId(input?.tool_use_id);
|
|
1819
|
+
const self = useId === '' ? { id, desc, files, at } : { id, desc, files, at, useId };
|
|
1382
1820
|
|
|
1383
1821
|
// Row 7: ledger existed but was unparseable. Terminal warn — decided here and
|
|
1384
1822
|
// returned, never emitted mid-flow. SELF-HEALING since the review: the verdict
|
|
@@ -1399,13 +1837,51 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
1399
1837
|
? ledger.agents.filter((a) => a !== null && typeof a === 'object' && typeof a.id === 'string')
|
|
1400
1838
|
: [];
|
|
1401
1839
|
|
|
1402
|
-
// Row 10
|
|
1403
|
-
//
|
|
1404
|
-
|
|
1840
|
+
// Row 10 (#1480 B4): a predecessor with the SAME agent id is no longer dropped
|
|
1841
|
+
// before the check. Dropping it unconditionally erased the claim of a still
|
|
1842
|
+
// RUNNING agent: [a,b] running, re-dispatched as [b,c], and a third agent on
|
|
1843
|
+
// `a` was allowed while the first run still wrote it. Now:
|
|
1844
|
+
// - the same `tool_use_id` → the same dispatch evaluated again → replaced;
|
|
1845
|
+
// - FINISHED (probe true) → pruned below, so a retry after a failed agent
|
|
1846
|
+
// stays allowed — a failed agent finishes via its `failed` carrier,
|
|
1847
|
+
// `is_error` or its report;
|
|
1848
|
+
// - both dispatches carry distinct exact ids and the predecessor is NOT
|
|
1849
|
+
// finished → an ordinary collision partner (row 11);
|
|
1850
|
+
// - otherwise no exact statement is possible → it stays as a CLAIM (a third
|
|
1851
|
+
// agent hitting only its scope still collides) but never collides with
|
|
1852
|
+
// this retry.
|
|
1853
|
+
// Named ceiling (BV-004): without exact ids a same-id retry cannot tell "my
|
|
1854
|
+
// earlier run is still going" from "it failed", so the retry is allowed while
|
|
1855
|
+
// the earlier claim is kept — the pre-#1480 behaviour for the retry itself,
|
|
1856
|
+
// and a claim a third agent can hit until the earlier run finishes or its TTL
|
|
1857
|
+
// lapses. Revisit-Trigger: a harness without `tool_use_id` in the PreToolUse
|
|
1858
|
+
// payload dispatches a measurable share of this repo's waves.
|
|
1859
|
+
// A predecessor wholly covered by this retry's scope, neither carrying an
|
|
1860
|
+
// exact id, adds nothing but ledger growth — both are resolved through the
|
|
1861
|
+
// same description and this entry is the younger one — so it is superseded.
|
|
1862
|
+
const superseded = (a) => a.id === id && (
|
|
1863
|
+
(useId !== '' && exactUseId(a.useId) === useId)
|
|
1864
|
+
|| (useId === '' && exactUseId(a.useId) === '' && desc !== '' && a.desc === desc
|
|
1865
|
+
&& Array.isArray(a.files) && a.files.every((f) => files.includes(f)))
|
|
1866
|
+
);
|
|
1867
|
+
const others = prior.filter((a) => !superseded(a));
|
|
1868
|
+
|
|
1869
|
+
// One comparison key per record, unique by construction: several records may
|
|
1870
|
+
// share an agent id now, and `findScopeCollisions` reports equal ids as
|
|
1871
|
+
// duplicates rather than comparing them. The readable id stays the key where
|
|
1872
|
+
// it is unique, so the deny text and every single-record wave are unchanged.
|
|
1873
|
+
const used = new Set([id]);
|
|
1874
|
+
const partners = others.map((entry) => {
|
|
1875
|
+
let key = entry.id;
|
|
1876
|
+
for (let n = 2; used.has(key); n++) key = `${entry.id} #${n}`;
|
|
1877
|
+
used.add(key);
|
|
1878
|
+
const selfClaim = entry.id === id && !(useId !== '' && exactUseId(entry.useId) !== '');
|
|
1879
|
+
return { key, entry, selfClaim };
|
|
1880
|
+
});
|
|
1405
1881
|
|
|
1406
1882
|
const known = new Set(Array.isArray(knownFiles) ? knownFiles : []);
|
|
1407
1883
|
const agentScopes = [
|
|
1408
|
-
...
|
|
1884
|
+
...partners.map((p) => ({ id: p.key, files: promoteDirEntries(p.entry.files, known) })),
|
|
1409
1885
|
{ id, files: promoteDirEntries(files, known) },
|
|
1410
1886
|
];
|
|
1411
1887
|
|
|
@@ -1453,28 +1929,35 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
1453
1929
|
// § Liveness — the review's HIGH finding. A collision with an agent that has
|
|
1454
1930
|
// ALREADY FINISHED is a sequential repair pass, not a race. The probe is called
|
|
1455
1931
|
// ONLY here, so the transcript is read only on the path that would deny.
|
|
1456
|
-
const
|
|
1932
|
+
const byKey = new Map(partners.map((p) => [p.key, p]));
|
|
1457
1933
|
const probe = typeof isFinished === 'function' ? isFinished : () => false;
|
|
1458
|
-
const
|
|
1934
|
+
const finishedKeys = new Set();
|
|
1459
1935
|
const live = [];
|
|
1936
|
+
let selfClaims = 0;
|
|
1460
1937
|
for (const c of mine) {
|
|
1461
|
-
const
|
|
1462
|
-
const
|
|
1463
|
-
if (
|
|
1464
|
-
|
|
1938
|
+
const otherKey = c.a === id ? c.b : c.a;
|
|
1939
|
+
const partner = byKey.get(otherKey);
|
|
1940
|
+
if (partner !== undefined && probe(partner.entry)) {
|
|
1941
|
+
finishedKeys.add(otherKey);
|
|
1942
|
+
continue;
|
|
1943
|
+
}
|
|
1944
|
+
// Row 10: an unfinished same-id claim without exact ids is kept, not collided with.
|
|
1945
|
+
if (partner?.selfClaim === true) {
|
|
1946
|
+
selfClaims++;
|
|
1465
1947
|
continue;
|
|
1466
1948
|
}
|
|
1467
1949
|
live.push(c);
|
|
1468
1950
|
}
|
|
1951
|
+
const counted = mine.length - selfClaims;
|
|
1469
1952
|
|
|
1470
1953
|
// Row 10a: every colliding prior agent has finished. Allow AND prune their
|
|
1471
1954
|
// records — leaving them would make the NEXT repair pass pay the transcript
|
|
1472
1955
|
// scan again for a question already answered.
|
|
1473
1956
|
if (live.length === 0) {
|
|
1474
|
-
const kept =
|
|
1957
|
+
const kept = partners.filter((p) => !finishedKeys.has(p.key)).map((p) => p.entry);
|
|
1475
1958
|
return {
|
|
1476
1959
|
action: 'allow',
|
|
1477
|
-
telemetry: telemetryFor('allow-finished',
|
|
1960
|
+
telemetry: finishedKeys.size > 0 ? telemetryFor('allow-finished', counted) : telemetryFor('allow'),
|
|
1478
1961
|
ledger: {
|
|
1479
1962
|
waveKey,
|
|
1480
1963
|
updated: at,
|
|
@@ -1487,13 +1970,15 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
1487
1970
|
// Row 11: the one case this hook exists for.
|
|
1488
1971
|
const shown = live.slice(0, MAX_REPORTED_COLLISIONS);
|
|
1489
1972
|
const lines = shown.map((c) => {
|
|
1490
|
-
const
|
|
1973
|
+
const otherKey = c.a === id ? c.b : c.a;
|
|
1974
|
+
const other = byKey.get(otherKey)?.entry.id ?? otherKey;
|
|
1975
|
+
const earlier = other === id ? ' (an earlier dispatch of the same agent)' : '';
|
|
1491
1976
|
const ev = Array.isArray(c.evidence) ? c.evidence : [];
|
|
1492
1977
|
const evShown = ev.slice(0, MAX_EVIDENCE_PER_COLLISION).map(clipPath).join(', ');
|
|
1493
1978
|
const more = ev.length > MAX_EVIDENCE_PER_COLLISION
|
|
1494
1979
|
? ` (+${ev.length - MAX_EVIDENCE_PER_COLLISION} more)`
|
|
1495
1980
|
: '';
|
|
1496
|
-
return ` • "${id}" ↔ "${other}" [${c.kind}]: ${evShown}${more}`;
|
|
1981
|
+
return ` • "${id}" ↔ "${other}"${earlier} [${c.kind}]: ${evShown}${more}`;
|
|
1497
1982
|
});
|
|
1498
1983
|
const omitted = live.length > shown.length ? `\n (+${live.length - shown.length} further collisions)` : '';
|
|
1499
1984
|
|
|
@@ -1512,7 +1997,7 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
1512
1997
|
|
|
1513
1998
|
// Deliberately NOT persisting the ledger on deny: the dispatch did not happen,
|
|
1514
1999
|
// so recording it would make the retry-after-fix look like a duplicate.
|
|
1515
|
-
return { action: 'deny', telemetry: telemetryFor('deny',
|
|
2000
|
+
return { action: 'deny', telemetry: telemetryFor('deny', counted), reason, suggestion };
|
|
1516
2001
|
}
|
|
1517
2002
|
|
|
1518
2003
|
// ---------------------------------------------------------------------------
|
|
@@ -1534,7 +2019,9 @@ async function main() {
|
|
|
1534
2019
|
|
|
1535
2020
|
const waveKey = waveKeyOf(projectDir, sessionId, readFileSync);
|
|
1536
2021
|
const knownFiles = listTrackedFiles(projectDir);
|
|
1537
|
-
|
|
2022
|
+
// `selfUseId`: this dispatch's own tool_use may already stand in the transcript
|
|
2023
|
+
// and must not make a finished same-named predecessor look alive (#1480 A).
|
|
2024
|
+
const isFinished = makeFinishedProbe({ transcriptPath: input.transcript_path, selfUseId: input.tool_use_id });
|
|
1538
2025
|
const ledgerPath = path.join(projectDir, LEDGER_REL);
|
|
1539
2026
|
|
|
1540
2027
|
// The read-modify-write CYCLE, run under the ledger lock below. Everything
|