session-orchestrator 5.2.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/architecture/SKILL.md +3 -1
- package/.agents/skills/autopilot/SKILL.md +5 -1
- package/.agents/skills/autopilot/agents/openai.yaml +5 -0
- package/.agents/skills/bootstrap/SKILL.md +5 -1
- package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
- package/.agents/skills/brainstorm/SKILL.md +5 -1
- package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +4 -2
- package/.agents/skills/close/SKILL.md +5 -1
- package/.agents/skills/close/agents/openai.yaml +5 -0
- package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
- package/.agents/skills/debug/SKILL.md +5 -1
- package/.agents/skills/debug/agents/openai.yaml +5 -0
- package/.agents/skills/discovery/SKILL.md +5 -1
- package/.agents/skills/discovery/agents/openai.yaml +5 -0
- package/.agents/skills/dispatcher/SKILL.md +5 -1
- package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
- package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ecosystem-health/SKILL.md +3 -1
- package/.agents/skills/eli5/SKILL.md +5 -1
- package/.agents/skills/eli5/agents/openai.yaml +5 -0
- package/.agents/skills/eval/SKILL.md +6 -2
- package/.agents/skills/eval/agents/openai.yaml +5 -0
- package/.agents/skills/evolve/SKILL.md +6 -2
- package/.agents/skills/evolve/agents/openai.yaml +5 -0
- package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
- package/.agents/skills/gitlab-ops/SKILL.md +3 -1
- package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
- package/.agents/skills/go/SKILL.md +5 -1
- package/.agents/skills/go/agents/openai.yaml +5 -0
- package/.agents/skills/grill/SKILL.md +5 -1
- package/.agents/skills/grill/agents/openai.yaml +5 -0
- package/.agents/skills/harness-audit/SKILL.md +5 -1
- package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
- package/.agents/skills/hook-development/SKILL.md +3 -1
- package/.agents/skills/mcp-builder/SKILL.md +3 -1
- package/.agents/skills/memory-cleanup/SKILL.md +5 -1
- package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
- package/.agents/skills/mode-selector/SKILL.md +3 -1
- package/.agents/skills/navigator/SKILL.md +22 -0
- package/.agents/skills/navigator/agents/openai.yaml +5 -0
- package/.agents/skills/npm-publish/SKILL.md +4 -2
- package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
- package/.agents/skills/persona-panel/SKILL.md +5 -1
- package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
- package/.agents/skills/plan/SKILL.md +5 -1
- package/.agents/skills/plan/agents/openai.yaml +5 -0
- package/.agents/skills/playwright-driver/SKILL.md +3 -1
- package/.agents/skills/portfolio/SKILL.md +5 -1
- package/.agents/skills/portfolio/agents/openai.yaml +5 -0
- package/.agents/skills/quality-gates/SKILL.md +3 -1
- package/.agents/skills/reconcile/SKILL.md +5 -1
- package/.agents/skills/reconcile/agents/openai.yaml +5 -0
- package/.agents/skills/release/SKILL.md +5 -1
- package/.agents/skills/release/agents/openai.yaml +5 -0
- package/.agents/skills/remote-offload/SKILL.md +3 -1
- package/.agents/skills/repo-audit/SKILL.md +5 -1
- package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
- package/.agents/skills/session/SKILL.md +21 -0
- package/.agents/skills/session/agents/openai.yaml +5 -0
- package/.agents/skills/session-end/SKILL.md +3 -1
- package/.agents/skills/session-plan/SKILL.md +3 -1
- package/.agents/skills/session-start/SKILL.md +3 -1
- package/.agents/skills/spinout/SKILL.md +5 -1
- package/.agents/skills/spinout/agents/openai.yaml +5 -0
- package/.agents/skills/sunset-review/SKILL.md +5 -1
- package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
- package/.agents/skills/templates-ack/SKILL.md +21 -0
- package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
- package/.agents/skills/test/SKILL.md +5 -1
- package/.agents/skills/test/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/.agents/skills/test-runner/SKILL.md +3 -1
- package/.agents/skills/tmux-layout/SKILL.md +3 -1
- package/.agents/skills/using-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ux-grill/SKILL.md +5 -1
- package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
- package/.agents/skills/vault-mirror/SKILL.md +3 -1
- package/.agents/skills/vault-sync/SKILL.md +3 -1
- package/.agents/skills/wave-executor/SKILL.md +3 -1
- package/.agents/skills/write-executable-plan/SKILL.md +3 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +4 -4
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
- package/.codex-plugin/skills/eval/SKILL.md +1 -1
- package/.codex-plugin/skills/evolve/SKILL.md +1 -1
- package/.codex-plugin/skills/navigator/SKILL.md +22 -0
- package/.codex-plugin/skills/{convergence-monitoring → navigator}/agents/openai.yaml +2 -2
- package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
- package/.codex-plugin/skills/session/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/{npm-publish → test-audit}/agents/openai.yaml +1 -1
- package/.cursor/commands/eval.md +1 -1
- package/.cursor/commands/navigator.md +15 -0
- package/.cursor/commands/session.md +1 -1
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/000-session-orchestrator.mdc +0 -2
- package/.cursor/rules/050-plan.mdc +1 -1
- package/.cursor/rules/060-evolve.mdc +27 -12
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/navigator/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +1 -0
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +12 -3
- package/AGENTS.md +9 -30
- package/CHANGELOG.md +206 -0
- package/README.md +13 -9
- package/SECURITY.md +12 -0
- package/agents/dialectic-deriver.md +13 -10
- package/agents/eval-judge.md +67 -45
- package/agents/skill-applied-judge.md +34 -19
- package/commands/session.md +7 -3
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +22 -7
- package/docs/ci-setup.md +57 -4
- package/docs/codex-setup.md +14 -2
- package/docs/components.md +12 -10
- package/docs/events-schema.md +65 -15
- package/docs/persona-panel.md +5 -4
- package/docs/rule-authoring.md +84 -17
- package/docs/scope-collision-guard.md +26 -4
- package/docs/session-config-reference.md +154 -12
- package/docs/session-config-template.md +41 -3
- package/docs/telemetry.md +2 -0
- package/hooks/_lib/hook-import-set.json +87 -6
- package/hooks/_lib/subagent-transcript.mjs +582 -31
- package/hooks/config-protection.mjs +11 -3
- package/hooks/cwd-change-restore.mjs +11 -3
- package/hooks/enforce-commands.mjs +70 -23
- package/hooks/enforce-scope.mjs +143 -33
- 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/loop-guard.mjs +11 -3
- package/hooks/on-session-end.mjs +58 -23
- package/hooks/on-session-start.mjs +101 -11
- package/hooks/on-stop.mjs +170 -22
- package/hooks/operator-steer.mjs +11 -3
- package/hooks/post-bash-issue-budget-refund.mjs +18 -8
- package/hooks/post-bash-write-verify.mjs +3 -2
- package/hooks/post-edit-import-probe.mjs +17 -9
- package/hooks/post-edit-validate.mjs +13 -5
- package/hooks/post-subagent-discovery-validator.mjs +98 -13
- package/hooks/post-tool-batch-wave-signal.mjs +202 -38
- package/hooks/post-tool-failure-corrective-context.mjs +11 -5
- package/hooks/post-tooluse-frontend-slop.mjs +10 -4
- package/hooks/pre-auq-clarity.mjs +15 -2
- package/hooks/pre-bash-destructive-guard.mjs +80 -9
- package/hooks/pre-bash-issue-budget.mjs +16 -11
- package/hooks/pre-bash-memory-propose-audit.mjs +137 -56
- package/hooks/pre-bash-sessions-ledger-guard.mjs +419 -22
- package/hooks/pre-bash-staging-fence.mjs +335 -31
- package/hooks/pre-bash-templates-first.mjs +19 -14
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +792 -74
- package/hooks/subagent-telemetry.mjs +111 -38
- package/hooks/wave-scope-commit-guard.mjs +197 -100
- package/monitors/monitors.json +1 -1
- package/output-styles/wave-summary.md +1 -1
- package/package.json +3 -3
- package/pi/prompts/eval.md +1 -1
- package/pi/prompts/navigator.md +12 -0
- package/pi/prompts/session.md +1 -1
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +7 -2
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +6 -2
- package/rules/opt-in-stack/backend-data.md +10 -2
- package/rules/opt-in-stack/backend.md +10 -4
- package/rules/opt-in-stack/frontend.md +7 -2
- package/rules/opt-in-stack/security-web.md +10 -4
- package/rules/opt-in-stack/swift.md +6 -2
- package/scripts/apply-session-learnings.mjs +492 -0
- package/scripts/archive-closed-prds.mjs +2 -1
- package/scripts/autopilot.mjs +70 -53
- package/scripts/backfill-abandoned-sessions.mjs +118 -16
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/check-sessions-integrity.mjs +300 -0
- package/scripts/cursor-install.mjs +1 -4
- package/scripts/dialectic-deriver.mjs +50 -13
- package/scripts/emit-session.mjs +371 -31
- package/scripts/eval-session.mjs +65 -3
- package/scripts/export-hw-learnings.mjs +22 -3
- package/scripts/fleet-checkin.mjs +147 -0
- package/scripts/generate-agents-skills.mjs +102 -29
- package/scripts/generate-cursor-adapter.mjs +61 -16
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/agent-status.mjs +2 -31
- package/scripts/lib/auq/clarity.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +12 -31
- package/scripts/lib/auq/schema.mjs +56 -41
- package/scripts/lib/auto-dialectic.mjs +304 -15
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +18 -2
- package/scripts/lib/autopilot/kill-switches.mjs +6 -3
- package/scripts/lib/autopilot/loop.mjs +19 -2
- package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/ci-status-banner.mjs +376 -16
- package/scripts/lib/command-blocker.mjs +275 -28
- package/scripts/lib/config/dialectic.mjs +12 -3
- package/scripts/lib/config/gate.mjs +74 -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 +163 -0
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +85 -10
- package/scripts/lib/convergence-monitor.mjs +74 -11
- package/scripts/lib/ecosystem-health.mjs +11 -0
- package/scripts/lib/eval/engine.mjs +421 -53
- package/scripts/lib/eval/judge.mjs +463 -40
- package/scripts/lib/eval/schema.mjs +10 -1
- package/scripts/lib/events-rotation.mjs +221 -25
- package/scripts/lib/events-schema.mjs +114 -0
- package/scripts/lib/events.mjs +545 -7
- package/scripts/lib/fleet-protocol.mjs +270 -0
- package/scripts/lib/frontmatter-guard.mjs +21 -10
- package/scripts/lib/gates/gate-baseline.mjs +40 -5
- package/scripts/lib/gates/gate-full.mjs +35 -5
- package/scripts/lib/gates/gate-helpers.mjs +336 -23
- package/scripts/lib/gates/gate-incremental.mjs +49 -23
- package/scripts/lib/gates/gate-per-file.mjs +42 -8
- package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
- package/scripts/lib/instruction-budget-guard.mjs +208 -8
- package/scripts/lib/io.mjs +42 -8
- package/scripts/lib/issue-close-strip-labels.mjs +207 -49
- package/scripts/lib/js-mask.mjs +197 -0
- package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
- 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/maintenance-due-banner.mjs +53 -88
- 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 +316 -0
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/peer-cards/merger.mjs +48 -10
- package/scripts/lib/peer-cards/reader.mjs +78 -2
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/process-group.mjs +950 -0
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +152 -33
- package/scripts/lib/reconcile/backlog.mjs +368 -0
- package/scripts/lib/reconcile/engine.mjs +55 -188
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
- package/scripts/lib/reconcile/sanitize.mjs +69 -3
- package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
- package/scripts/lib/resource-probe/parsers.mjs +31 -0
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +141 -74
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +3 -4
- package/scripts/lib/scope-echo.mjs +39 -2
- package/scripts/lib/scope-gate.mjs +605 -1
- package/scripts/lib/session-close-backfill.mjs +33 -6
- package/scripts/lib/session-id.mjs +9 -20
- package/scripts/lib/session-invocation.mjs +20 -0
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +27 -2
- package/scripts/lib/session-schema/constants.mjs +56 -2
- package/scripts/lib/session-schema/normalizer.mjs +56 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-schema.mjs +8 -3
- package/scripts/lib/session-start-probes.mjs +95 -10
- package/scripts/lib/session-token-rollup.mjs +245 -52
- package/scripts/lib/sessions-canonical.mjs +118 -4
- package/scripts/lib/sessions-integrity-banner.mjs +7 -1
- package/scripts/lib/sessions-staleness-banner.mjs +193 -51
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/skill-evidence-window.mjs +891 -0
- package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
- package/scripts/lib/skill-evolution/engine.mjs +18 -9
- package/scripts/lib/skill-judge.mjs +45 -3
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +56 -0
- package/scripts/lib/telemetry/pricing.mjs +118 -71
- package/scripts/lib/telemetry/schema.mjs +30 -0
- package/scripts/lib/telemetry/sync.mjs +61 -6
- package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
- package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
- package/scripts/lib/tests-src-ratio.mjs +140 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/user-invocable-skills.mjs +23 -3
- package/scripts/lib/ux-grill/reconcile.mjs +48 -22
- package/scripts/lib/validate/check-agents-skills.mjs +26 -15
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
- package/scripts/lib/validate/check-entry-guard.mjs +13 -50
- package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
- 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-pi-prompts.mjs +1 -0
- package/scripts/lib/validate/check-rules.mjs +7 -5
- package/scripts/lib/validate/check-skill-links.mjs +9 -1
- package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
- package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
- 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-untracked-test-deps.mjs +7 -102
- package/scripts/lib/validate/check-unwired-features.mjs +130 -27
- package/scripts/lib/validate/check-validator-registration.mjs +34 -10
- package/scripts/lib/validate/confidential-names.mjs +10 -0
- package/scripts/lib/validate-vendored-rules.mjs +210 -84
- package/scripts/lib/vault-mirror/namespace.mjs +46 -8
- package/scripts/lib/vault-mirror/process.mjs +10 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
- 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 +71 -16
- package/scripts/lib/vault-yaml.mjs +118 -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/lib/worktree/lifecycle.mjs +153 -1
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +306 -0
- package/scripts/release.mjs +40 -8
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/resolve-session-invocation.mjs +59 -0
- package/scripts/run-migrate-v2-cross-repo.mjs +158 -30
- package/scripts/run-quality-gate.mjs +357 -197
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +56 -5
- package/scripts/sweep-expired-rules.mjs +16 -4
- package/scripts/validate-plugin.mjs +19 -0
- package/scripts/validate-wave-scope.mjs +32 -105
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +81 -47
- 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/platform-tools.md +23 -11
- package/skills/_shared/state-ownership.md +5 -0
- package/skills/autopilot/SKILL.md +47 -18
- 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/convergence-monitoring/README.md +8 -1
- package/skills/convergence-monitoring/SIGNALS.md +50 -6
- package/skills/convergence-monitoring/SKILL.md +15 -6
- package/skills/eval/SKILL.md +39 -24
- package/skills/eval/rubric-v1.md +1 -0
- package/skills/eval/rubric-v2.md +457 -0
- package/skills/evolve/SKILL.md +3 -3
- package/skills/evolve/references/evolve-analyze-mode.md +10 -6
- package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
- package/skills/gitlab-ops/SKILL.md +3 -2
- 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/npm-publish/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/reconcile/SKILL.md +11 -0
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/SKILL.md +13 -16
- package/skills/session-end/discovery-scan.md +1 -1
- package/skills/session-end/learning-patterns.md +22 -12
- package/skills/session-end/metrics-collection.md +5 -6
- package/skills/session-end/phase-3-6-tail.md +55 -9
- 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/phase-5-issue-cleanup.md +9 -14
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +32 -60
- package/skills/session-plan/SKILL.md +17 -8
- package/skills/session-plan/references/session-plan-task-classification.md +2 -2
- 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 +4 -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/ux-grill/SKILL.md +1 -1
- package/skills/wave-executor/SKILL.md +9 -5
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +8 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +5 -2
- 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
- package/.cursor/commands/convergence-monitoring.md +0 -13
- package/.cursor/commands/npm-publish.md +0 -13
- package/pi/prompts/convergence-monitoring.md +0 -11
- package/pi/prompts/npm-publish.md +0 -11
|
@@ -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
|
|
@@ -288,6 +306,29 @@ const SHAPE_NONE = 'none';
|
|
|
288
306
|
/** The per-dispatch observability record (#1092) — see § Observability. */
|
|
289
307
|
const SCOPE_EVENT = 'orchestrator.wave_dispatch.scope_checked';
|
|
290
308
|
|
|
309
|
+
/**
|
|
310
|
+
* The worktree-base observability record (#1413).
|
|
311
|
+
*
|
|
312
|
+
* Same `wave_dispatch` domain and same `_checked` verb as `SCOPE_EVENT`, and
|
|
313
|
+
* written for EVERY `isolation: "worktree"` dispatch rather than only the
|
|
314
|
+
* alarming one. That is HR-105 applied at the source: a numerator-only stream
|
|
315
|
+
* cannot tell "genuinely rare" from "silently broken", and the firing rate this
|
|
316
|
+
* warning must stay under is then unfalsifiable. With every outcome in one
|
|
317
|
+
* stream the rate is `stale:true / all records of this name`.
|
|
318
|
+
*
|
|
319
|
+
* #1424 widened "every outcome" from two to three: the `stale: true|false`
|
|
320
|
+
* split was already covered, but each NON-measurement (peer STATE.md, no
|
|
321
|
+
* STATE.md, no `session-start-ref`, a git failure) returned early and emitted
|
|
322
|
+
* nothing — so the gate in front of the split reproduced exactly the
|
|
323
|
+
* zero-records ambiguity the split had removed. Those now carry
|
|
324
|
+
* `stale: null` plus a `skipped` reason, and only `isolation !== "worktree"`
|
|
325
|
+
* stays silent (HR-101: it is ~90% of all dispatches).
|
|
326
|
+
*/
|
|
327
|
+
const WORKTREE_BASE_EVENT = 'orchestrator.wave_dispatch.worktree_base_checked';
|
|
328
|
+
|
|
329
|
+
/** State-dir candidates, in the same order `waveKeyOf()` probes them. */
|
|
330
|
+
const STATE_DIR_CANDIDATES = ['.pi', '.cursor', '.codex', '.claude'];
|
|
331
|
+
|
|
291
332
|
/**
|
|
292
333
|
* The receive-side instruction line `renderScopeEchoInstruction()` renders and
|
|
293
334
|
* the coordinator appends immediately after the fenced block
|
|
@@ -335,9 +376,77 @@ const MAX_TRANSCRIPT_BYTES = 256 * 1024 * 1024;
|
|
|
335
376
|
* a completion — see § Liveness (b). Reading it as one would let every real
|
|
336
377
|
* background-batch collision through, which is the one direction this repair
|
|
337
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.
|
|
338
385
|
*/
|
|
339
386
|
const ASYNC_LAUNCH_ACK = 'Async agent launched successfully';
|
|
340
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
|
+
|
|
341
450
|
/**
|
|
342
451
|
* The consequence block spliced VERBATIM into the GUARD INACTIVE banner (#993),
|
|
343
452
|
* naming the enforcement this hook's outage stops applying.
|
|
@@ -727,8 +836,8 @@ export function extractScopeFromPrompt(prompt) {
|
|
|
727
836
|
|
|
728
837
|
/**
|
|
729
838
|
* The dispatch's human description — the field the liveness probe matches
|
|
730
|
-
* against the transcript's `tool_use` blocks
|
|
731
|
-
* payloads).
|
|
839
|
+
* against the transcript's `tool_use` blocks when an entry carries no exact
|
|
840
|
+
* `useId` (present in 147/147 measured payloads).
|
|
732
841
|
*
|
|
733
842
|
* @param {{description?: unknown}} toolInput
|
|
734
843
|
* @returns {string}
|
|
@@ -754,69 +863,401 @@ export function agentIdOf(toolInput) {
|
|
|
754
863
|
return 'unnamed-agent';
|
|
755
864
|
}
|
|
756
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
|
+
|
|
757
882
|
// ---------------------------------------------------------------------------
|
|
758
883
|
// Liveness — has an already-recorded agent FINISHED? (§ Liveness)
|
|
759
884
|
// ---------------------------------------------------------------------------
|
|
760
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
|
+
|
|
761
1009
|
/**
|
|
762
|
-
*
|
|
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
|
+
|
|
1030
|
+
/**
|
|
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.
|
|
763
1035
|
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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`.
|
|
770
1051
|
*
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
*
|
|
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.
|
|
774
1110
|
*
|
|
775
1111
|
* Pure and total — a malformed line is skipped, never thrown on.
|
|
776
1112
|
*
|
|
777
1113
|
* @param {string} raw — the transcript's JSONL text
|
|
778
|
-
* @returns {Map<string,
|
|
1114
|
+
* @returns {{states: Map<string, 'finished'|'running'|'unknown'>,
|
|
1115
|
+
* idsByDesc: Map<string, string[]>}}
|
|
779
1116
|
*/
|
|
780
|
-
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
const
|
|
786
|
-
|
|
787
|
-
|
|
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];
|
|
788
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; }
|
|
789
1141
|
|
|
790
|
-
//
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
+
}
|
|
795
1149
|
}
|
|
796
1150
|
|
|
797
|
-
if (!
|
|
798
|
-
let rec;
|
|
799
|
-
try { rec = JSON.parse(line); } catch { continue; }
|
|
1151
|
+
if (!hasToolRow) continue;
|
|
800
1152
|
const content = rec?.message?.content;
|
|
801
1153
|
if (!Array.isArray(content)) continue;
|
|
802
1154
|
|
|
803
1155
|
for (const block of content) {
|
|
804
|
-
if (block?.type === 'tool_use' &&
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
+
}
|
|
810
1169
|
continue;
|
|
811
1170
|
}
|
|
812
1171
|
if (block?.type === 'tool_result' && typeof block?.tool_use_id === 'string') {
|
|
813
|
-
|
|
814
|
-
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
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
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; }
|
|
815
1201
|
}
|
|
816
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);
|
|
817
1213
|
}
|
|
1214
|
+
return { states, idsByDesc };
|
|
1215
|
+
}
|
|
818
1216
|
|
|
819
|
-
|
|
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
|
+
}
|
|
820
1261
|
return out;
|
|
821
1262
|
}
|
|
822
1263
|
|
|
@@ -843,10 +1284,16 @@ function resultTextOf(block) {
|
|
|
843
1284
|
* been found. The no-collision path — the overwhelming majority — never touches
|
|
844
1285
|
* the file (§ Liveness, cost containment).
|
|
845
1286
|
*
|
|
846
|
-
* Resolution order per ledger entry:
|
|
847
|
-
* 1.
|
|
848
|
-
*
|
|
849
|
-
*
|
|
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
|
|
850
1297
|
* deny biting rather than inventing a completion).
|
|
851
1298
|
*
|
|
852
1299
|
* @param {object} params
|
|
@@ -854,9 +1301,13 @@ function resultTextOf(block) {
|
|
|
854
1301
|
* @param {number} [params.now]
|
|
855
1302
|
* @param {number} [params.ttlMs]
|
|
856
1303
|
* @param {(p: string, enc: string) => string} [params.readFn]
|
|
857
|
-
* @
|
|
1304
|
+
* @param {string} [params.selfUseId] the CURRENT dispatch's `tool_use_id`
|
|
1305
|
+
* @returns {(entry: {id: string, desc?: string, at?: string, useId?: string}) => boolean}
|
|
858
1306
|
*/
|
|
859
|
-
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);
|
|
860
1311
|
let index; // undefined = not loaded yet, null = unavailable
|
|
861
1312
|
const load = () => {
|
|
862
1313
|
if (index !== undefined) return index;
|
|
@@ -864,7 +1315,7 @@ export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN
|
|
|
864
1315
|
try {
|
|
865
1316
|
if (typeof transcriptPath === 'string' && transcriptPath.length > 0) {
|
|
866
1317
|
if (statSync(transcriptPath).size <= MAX_TRANSCRIPT_BYTES) {
|
|
867
|
-
index =
|
|
1318
|
+
index = buildLivenessIndex(readFn(transcriptPath, 'utf8'));
|
|
868
1319
|
}
|
|
869
1320
|
}
|
|
870
1321
|
} catch {
|
|
@@ -876,10 +1327,17 @@ export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN
|
|
|
876
1327
|
return (entry) => {
|
|
877
1328
|
try {
|
|
878
1329
|
const idx = load();
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
+
}
|
|
883
1341
|
}
|
|
884
1342
|
const at = Date.parse(entry?.at ?? '');
|
|
885
1343
|
if (Number.isFinite(at)) return now - at > ttlMs;
|
|
@@ -981,6 +1439,163 @@ export function listTrackedFiles(cwd) {
|
|
|
981
1439
|
}
|
|
982
1440
|
}
|
|
983
1441
|
|
|
1442
|
+
// ---------------------------------------------------------------------------
|
|
1443
|
+
// Stale worktree base (#1413) — a WARNING, never a decision
|
|
1444
|
+
//
|
|
1445
|
+
// `isolation: "worktree"` makes the HARNESS create `<repo>/.claude/worktrees/
|
|
1446
|
+
// agent-<hex>`; no code in this repo creates it and the `Agent` payload carries
|
|
1447
|
+
// no base-ref field (the 147-dispatch key census in the module docblock). The
|
|
1448
|
+
// base is the SESSION-START commit, and it does not follow a mid-session commit:
|
|
1449
|
+
// measured 2026-09-19 (session s18), worktrees created 25 minutes AFTER commit
|
|
1450
|
+
// `240efda6` still stood on its parent `8f15f77b`. A fix agent then silently
|
|
1451
|
+
// edits the OLD code, and its test run is structurally red on top — `validate-
|
|
1452
|
+
// plugin` is vitest's globalSetup and `check-guard-requires-parity.mjs` compares
|
|
1453
|
+
// against `git show HEAD:` inside the stale worktree.
|
|
1454
|
+
//
|
|
1455
|
+
// We have no lever on the base, so the only available fix is to be LOUD. This
|
|
1456
|
+
// never touches the verdict: the two functions below are TOTAL (every failure
|
|
1457
|
+
// resolves to silence), and the caller ignores their result for the decision.
|
|
1458
|
+
// ---------------------------------------------------------------------------
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* Facts for the worktree-base check, as a DISCRIMINATED record with three
|
|
1462
|
+
* outcomes rather than two (#1424):
|
|
1463
|
+
*
|
|
1464
|
+
* - `{stale: true|false, head, session_start_ref, subagent_type?}` — measured.
|
|
1465
|
+
* - `{stale: null, skipped: <reason>}` — the dispatch WAS on the `worktree`
|
|
1466
|
+
* branch, but the question could not be answered honestly. Still emitted,
|
|
1467
|
+
* because HR-105 needs the denominator: while every such case returned bare
|
|
1468
|
+
* `null` and the caller emitted nothing, "the check never had anything to
|
|
1469
|
+
* say" and "the check was silently disarmed" both read as zero records —
|
|
1470
|
+
* precisely the shape this event exists to close.
|
|
1471
|
+
* - `null` — `isolation !== 'worktree'`. SILENT by construction, and the one
|
|
1472
|
+
* outcome that must stay so: `isolation` appeared in 14 of 147 measured
|
|
1473
|
+
* dispatches, so recording the other ~90% would put the denominator on the
|
|
1474
|
+
* whole hot dispatch path (HR-101).
|
|
1475
|
+
*
|
|
1476
|
+
* IDENTITY GATE (`.claude/rules/identity-and-locks.md`): `session-start-ref`
|
|
1477
|
+
* lives in STATE.md, a SHARED working-copy artefact that routinely belongs to a
|
|
1478
|
+
* peer session. It is read only when STATE.md's own `session-id` equals this
|
|
1479
|
+
* process's raw id (hook payload `session_id`, else `CLAUDE_CODE_SESSION_ID`) —
|
|
1480
|
+
* a process-local witness REPLACES the shared one, never unions with it. On
|
|
1481
|
+
* mismatch, absence or any read failure this reports `stale: null`: a confident
|
|
1482
|
+
* warning about somebody else's session is worse than no warning at all. The
|
|
1483
|
+
* test runs PER CANDIDATE (`continue`, not `break`) — see the loop below.
|
|
1484
|
+
*
|
|
1485
|
+
* `parseStateMd` is imported DYNAMICALLY and only on the rare `worktree` branch
|
|
1486
|
+
* (14 of 147 measured dispatches carried `isolation` at all): it is a leaf module
|
|
1487
|
+
* with zero imports and already inside the committed hook-import closure, so
|
|
1488
|
+
* reuse costs no new edge, and binding it late keeps a load failure catchable
|
|
1489
|
+
* instead of disarming the guard at ESM link time (§ #993 late-bound deps).
|
|
1490
|
+
*
|
|
1491
|
+
* @param {{tool_input?: unknown}} input — the raw hook payload
|
|
1492
|
+
* @param {string} projectDir
|
|
1493
|
+
* @param {string} sessionId — `input.session_id`, or `'no-session'`
|
|
1494
|
+
* @returns {Promise<{head: string, session_start_ref: string, stale: boolean,
|
|
1495
|
+
* subagent_type?: string}|{stale: null, skipped: string}|null>}
|
|
1496
|
+
*/
|
|
1497
|
+
async function worktreeBaseFacts(input, projectDir, sessionId) {
|
|
1498
|
+
// The applicability gate sits OUTSIDE the try on purpose: everything below it
|
|
1499
|
+
// resolves to a `skipped` RECORD, so a throw here must not be able to mint one
|
|
1500
|
+
// for a dispatch that was never on the `worktree` branch at all.
|
|
1501
|
+
const toolInput = input?.tool_input;
|
|
1502
|
+
if (toolInput === null || typeof toolInput !== 'object') return null;
|
|
1503
|
+
if (toolInput.isolation !== 'worktree') return null;
|
|
1504
|
+
|
|
1505
|
+
try {
|
|
1506
|
+
const ownId = sessionId && sessionId !== 'no-session'
|
|
1507
|
+
? sessionId
|
|
1508
|
+
: (process.env.CLAUDE_CODE_SESSION_ID || '');
|
|
1509
|
+
if (!ownId) return { stale: null, skipped: 'no-own-id' };
|
|
1510
|
+
|
|
1511
|
+
const { parseStateMd } = await import(
|
|
1512
|
+
pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'state-md', 'yaml-parser.mjs')).href
|
|
1513
|
+
);
|
|
1514
|
+
|
|
1515
|
+
// #1424: the identity test lives INSIDE the loop. Breaking at the first
|
|
1516
|
+
// PARSEABLE STATE.md and testing `session-id` afterwards meant a single
|
|
1517
|
+
// left-over `.pi/STATE.md` — `.pi` sorts before `.claude` in
|
|
1518
|
+
// STATE_DIR_CANDIDATES — disarmed the check for a session whose own
|
|
1519
|
+
// `.claude/STATE.md` sat right there, readable and matching. `sawStateMd`
|
|
1520
|
+
// keeps the two silences apart afterwards: "nothing to read" is a different
|
|
1521
|
+
// fact from "read, and none of it was mine".
|
|
1522
|
+
let frontmatter = null;
|
|
1523
|
+
let sawStateMd = false;
|
|
1524
|
+
for (const dir of STATE_DIR_CANDIDATES) {
|
|
1525
|
+
try {
|
|
1526
|
+
const parsed = parseStateMd(readFileSync(path.join(projectDir, dir, 'STATE.md'), 'utf8'));
|
|
1527
|
+
if (!parsed?.frontmatter) continue;
|
|
1528
|
+
sawStateMd = true;
|
|
1529
|
+
if (parsed.frontmatter['session-id'] !== ownId) continue;
|
|
1530
|
+
frontmatter = parsed.frontmatter;
|
|
1531
|
+
break;
|
|
1532
|
+
} catch { /* try the next state dir */ }
|
|
1533
|
+
}
|
|
1534
|
+
if (frontmatter === null) {
|
|
1535
|
+
return { stale: null, skipped: sawStateMd ? 'identity-mismatch' : 'no-state-md' };
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
const startRef = typeof frontmatter['session-start-ref'] === 'string'
|
|
1539
|
+
? frontmatter['session-start-ref'].trim()
|
|
1540
|
+
: '';
|
|
1541
|
+
if (startRef === '') return { stale: null, skipped: 'no-start-ref' };
|
|
1542
|
+
|
|
1543
|
+
let head = '';
|
|
1544
|
+
try {
|
|
1545
|
+
head = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
1546
|
+
cwd: projectDir,
|
|
1547
|
+
encoding: 'utf8',
|
|
1548
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1549
|
+
}).trim();
|
|
1550
|
+
} catch {
|
|
1551
|
+
return { stale: null, skipped: 'git-error' };
|
|
1552
|
+
}
|
|
1553
|
+
if (head === '') return { stale: null, skipped: 'git-error' };
|
|
1554
|
+
|
|
1555
|
+
const facts = { head, session_start_ref: startRef, stale: head !== startRef };
|
|
1556
|
+
if (typeof toolInput.subagent_type === 'string' && toolInput.subagent_type !== '') {
|
|
1557
|
+
facts.subagent_type = toolInput.subagent_type;
|
|
1558
|
+
}
|
|
1559
|
+
return facts;
|
|
1560
|
+
} catch {
|
|
1561
|
+
// Total by construction: no parser, an unreadable state dir, anything else.
|
|
1562
|
+
// Still a RECORD rather than silence — the dispatch WAS on the `worktree`
|
|
1563
|
+
// branch, and a broken probe that emits nothing is indistinguishable from a
|
|
1564
|
+
// probe that had nothing to report (#1424 / HR-105).
|
|
1565
|
+
return { stale: null, skipped: 'probe-error' };
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* The operator-facing warning. It names the ACTION, not just the condition —
|
|
1571
|
+
* a warning the coordinator cannot act on is noise (HR-106).
|
|
1572
|
+
*
|
|
1573
|
+
* NO `⚠ ` PREFIX HERE: this text is handed to `emitWarn`, which prefixes it on
|
|
1574
|
+
* both channels it writes (stderr and the `systemMessage` payload). Prefixing
|
|
1575
|
+
* here too produced `⚠ ⚠ …`.
|
|
1576
|
+
*
|
|
1577
|
+
* @param {{head: string, session_start_ref: string}} facts
|
|
1578
|
+
* @returns {string}
|
|
1579
|
+
*/
|
|
1580
|
+
function staleWorktreeWarning(facts) {
|
|
1581
|
+
const head = facts.head.slice(0, 12);
|
|
1582
|
+
const base = facts.session_start_ref.slice(0, 12);
|
|
1583
|
+
return [
|
|
1584
|
+
`${HOOK_NAME}: STALE WORKTREE BASE (#1413) — this dispatch uses `
|
|
1585
|
+
+ `isolation: "worktree", but HEAD (${head}) has moved past this session's `
|
|
1586
|
+
+ `session-start-ref (${base}).`,
|
|
1587
|
+
' The harness bases a new agent worktree on the SESSION-START commit and offers',
|
|
1588
|
+
' no base-ref field, so the agent will silently get the OLDER code — and its test',
|
|
1589
|
+
' run is structurally red on top (check-guard-requires-parity compares against',
|
|
1590
|
+
' `git show HEAD:`, and validate-plugin is vitest\'s globalSetup).',
|
|
1591
|
+
' DO ONE OF: dispatch this wave IN-PLACE (omit `isolation`) — or verify the base',
|
|
1592
|
+
' before trusting the agent\'s result:',
|
|
1593
|
+
' git worktree list --porcelain | awk -v h="$(git rev-parse HEAD)" '
|
|
1594
|
+
+ '\'/^worktree /{w=$2} /^HEAD /{if (w ~ /\\.claude\\/worktrees\\/agent-/ && $2 != h) '
|
|
1595
|
+
+ 'print "STALE " substr($2,1,12) " " w}\'',
|
|
1596
|
+
].join('\n');
|
|
1597
|
+
}
|
|
1598
|
+
|
|
984
1599
|
/**
|
|
985
1600
|
* Clip a path for the deny reason without losing the discriminating tail.
|
|
986
1601
|
*
|
|
@@ -1198,7 +1813,10 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
1198
1813
|
}
|
|
1199
1814
|
|
|
1200
1815
|
const desc = agentDescOf(toolInput);
|
|
1201
|
-
|
|
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 };
|
|
1202
1820
|
|
|
1203
1821
|
// Row 7: ledger existed but was unparseable. Terminal warn — decided here and
|
|
1204
1822
|
// returned, never emitted mid-flow. SELF-HEALING since the review: the verdict
|
|
@@ -1219,13 +1837,51 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
1219
1837
|
? ledger.agents.filter((a) => a !== null && typeof a === 'object' && typeof a.id === 'string')
|
|
1220
1838
|
: [];
|
|
1221
1839
|
|
|
1222
|
-
// Row 10
|
|
1223
|
-
//
|
|
1224
|
-
|
|
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
|
+
});
|
|
1225
1881
|
|
|
1226
1882
|
const known = new Set(Array.isArray(knownFiles) ? knownFiles : []);
|
|
1227
1883
|
const agentScopes = [
|
|
1228
|
-
...
|
|
1884
|
+
...partners.map((p) => ({ id: p.key, files: promoteDirEntries(p.entry.files, known) })),
|
|
1229
1885
|
{ id, files: promoteDirEntries(files, known) },
|
|
1230
1886
|
];
|
|
1231
1887
|
|
|
@@ -1273,28 +1929,35 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
1273
1929
|
// § Liveness — the review's HIGH finding. A collision with an agent that has
|
|
1274
1930
|
// ALREADY FINISHED is a sequential repair pass, not a race. The probe is called
|
|
1275
1931
|
// ONLY here, so the transcript is read only on the path that would deny.
|
|
1276
|
-
const
|
|
1932
|
+
const byKey = new Map(partners.map((p) => [p.key, p]));
|
|
1277
1933
|
const probe = typeof isFinished === 'function' ? isFinished : () => false;
|
|
1278
|
-
const
|
|
1934
|
+
const finishedKeys = new Set();
|
|
1279
1935
|
const live = [];
|
|
1936
|
+
let selfClaims = 0;
|
|
1280
1937
|
for (const c of mine) {
|
|
1281
|
-
const
|
|
1282
|
-
const
|
|
1283
|
-
if (
|
|
1284
|
-
|
|
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++;
|
|
1285
1947
|
continue;
|
|
1286
1948
|
}
|
|
1287
1949
|
live.push(c);
|
|
1288
1950
|
}
|
|
1951
|
+
const counted = mine.length - selfClaims;
|
|
1289
1952
|
|
|
1290
1953
|
// Row 10a: every colliding prior agent has finished. Allow AND prune their
|
|
1291
1954
|
// records — leaving them would make the NEXT repair pass pay the transcript
|
|
1292
1955
|
// scan again for a question already answered.
|
|
1293
1956
|
if (live.length === 0) {
|
|
1294
|
-
const kept =
|
|
1957
|
+
const kept = partners.filter((p) => !finishedKeys.has(p.key)).map((p) => p.entry);
|
|
1295
1958
|
return {
|
|
1296
1959
|
action: 'allow',
|
|
1297
|
-
telemetry: telemetryFor('allow-finished',
|
|
1960
|
+
telemetry: finishedKeys.size > 0 ? telemetryFor('allow-finished', counted) : telemetryFor('allow'),
|
|
1298
1961
|
ledger: {
|
|
1299
1962
|
waveKey,
|
|
1300
1963
|
updated: at,
|
|
@@ -1307,13 +1970,15 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
1307
1970
|
// Row 11: the one case this hook exists for.
|
|
1308
1971
|
const shown = live.slice(0, MAX_REPORTED_COLLISIONS);
|
|
1309
1972
|
const lines = shown.map((c) => {
|
|
1310
|
-
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)' : '';
|
|
1311
1976
|
const ev = Array.isArray(c.evidence) ? c.evidence : [];
|
|
1312
1977
|
const evShown = ev.slice(0, MAX_EVIDENCE_PER_COLLISION).map(clipPath).join(', ');
|
|
1313
1978
|
const more = ev.length > MAX_EVIDENCE_PER_COLLISION
|
|
1314
1979
|
? ` (+${ev.length - MAX_EVIDENCE_PER_COLLISION} more)`
|
|
1315
1980
|
: '';
|
|
1316
|
-
return ` • "${id}" ↔ "${other}" [${c.kind}]: ${evShown}${more}`;
|
|
1981
|
+
return ` • "${id}" ↔ "${other}"${earlier} [${c.kind}]: ${evShown}${more}`;
|
|
1317
1982
|
});
|
|
1318
1983
|
const omitted = live.length > shown.length ? `\n (+${live.length - shown.length} further collisions)` : '';
|
|
1319
1984
|
|
|
@@ -1332,7 +1997,7 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
1332
1997
|
|
|
1333
1998
|
// Deliberately NOT persisting the ledger on deny: the dispatch did not happen,
|
|
1334
1999
|
// so recording it would make the retry-after-fix look like a duplicate.
|
|
1335
|
-
return { action: 'deny', telemetry: telemetryFor('deny',
|
|
2000
|
+
return { action: 'deny', telemetry: telemetryFor('deny', counted), reason, suggestion };
|
|
1336
2001
|
}
|
|
1337
2002
|
|
|
1338
2003
|
// ---------------------------------------------------------------------------
|
|
@@ -1354,7 +2019,9 @@ async function main() {
|
|
|
1354
2019
|
|
|
1355
2020
|
const waveKey = waveKeyOf(projectDir, sessionId, readFileSync);
|
|
1356
2021
|
const knownFiles = listTrackedFiles(projectDir);
|
|
1357
|
-
|
|
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 });
|
|
1358
2025
|
const ledgerPath = path.join(projectDir, LEDGER_REL);
|
|
1359
2026
|
|
|
1360
2027
|
// The read-modify-write CYCLE, run under the ledger lock below. Everything
|
|
@@ -1440,8 +2107,59 @@ async function main() {
|
|
|
1440
2107
|
} catch { /* observability is best-effort — it never blocks the decision */ }
|
|
1441
2108
|
}
|
|
1442
2109
|
|
|
1443
|
-
|
|
1444
|
-
|
|
2110
|
+
// #1413 — the stale-worktree-base warning, also awaited BEFORE the terminal
|
|
2111
|
+
// emit for the same process.exit() reason as the block above. It reads
|
|
2112
|
+
// `verdict` not at all: it can never turn an allow into a deny, and a git or
|
|
2113
|
+
// STATE.md failure resolves to silence rather than to a wrong accusation.
|
|
2114
|
+
//
|
|
2115
|
+
// ROUTED TO THE OPERATOR, not to stderr alone (w3-5): under the exit-0
|
|
2116
|
+
// PreToolUse protocol stderr goes nowhere — `io.mjs:545` calls it the "Debug
|
|
2117
|
+
// channel … Invisible under exit 0" — so the first cut of this check announced
|
|
2118
|
+
// the s18 incident class to a log and to nobody. The visible channel is the
|
|
2119
|
+
// top-level `systemMessage`, i.e. `emitWarn`, which carries NO
|
|
2120
|
+
// `permissionDecision` and therefore still means ALLOW (io.mjs § "which is
|
|
2121
|
+
// precisely what keeps warn non-blocking").
|
|
2122
|
+
//
|
|
2123
|
+
// The note is CARRIED to the single terminal emit below rather than emitted
|
|
2124
|
+
// here, because `emitWarn` calls `process.exit(0)` and never returns — warning
|
|
2125
|
+
// inline would terminate the process before a collision DENY could be emitted,
|
|
2126
|
+
// flipping a block into an allow (§ stdout discipline, the same reason
|
|
2127
|
+
// `decide()` is pure).
|
|
2128
|
+
//
|
|
2129
|
+
// `null` here means "not a worktree dispatch" and stays silent; ANY other
|
|
2130
|
+
// shape is emitted, including the `{stale: null, skipped: …}` non-measurements
|
|
2131
|
+
// (#1424). The warning still fires on `stale === true` alone — a skipped probe
|
|
2132
|
+
// accuses nobody.
|
|
2133
|
+
const worktreeBase = await worktreeBaseFacts(input, projectDir, sessionId);
|
|
2134
|
+
let staleNote = null;
|
|
2135
|
+
if (worktreeBase !== null) {
|
|
2136
|
+
if (worktreeBase.stale === true) staleNote = staleWorktreeWarning(worktreeBase);
|
|
2137
|
+
try {
|
|
2138
|
+
const { emitEvent, sessionAttribution } = await import(
|
|
2139
|
+
pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'events.mjs')).href
|
|
2140
|
+
);
|
|
2141
|
+
await emitEvent(
|
|
2142
|
+
WORKTREE_BASE_EVENT,
|
|
2143
|
+
{ hook: HOOK_NAME, ...worktreeBase, ...sessionAttribution(projectDir) },
|
|
2144
|
+
{ repoRoot: projectDir }
|
|
2145
|
+
);
|
|
2146
|
+
} catch { /* observability is best-effort — it never blocks the decision */ }
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// ONE terminal emit, with the #1413 note folded in where a visible channel is
|
|
2150
|
+
// free. On DENY the dispatch does not happen and `systemMessage` already
|
|
2151
|
+
// carries the ⛔ headline, so the note stays on the debug channel — a stale
|
|
2152
|
+
// base is moot for a dispatch that was just blocked.
|
|
2153
|
+
if (verdict.action === 'deny') {
|
|
2154
|
+
if (staleNote !== null) {
|
|
2155
|
+
try { console.error(`⚠ ${staleNote}`); } catch { /* stderr may be closed */ }
|
|
2156
|
+
}
|
|
2157
|
+
return emitDeny(verdict.reason, verdict.suggestion);
|
|
2158
|
+
}
|
|
2159
|
+
if (verdict.action === 'warn') {
|
|
2160
|
+
return emitWarn(staleNote === null ? verdict.note : `${verdict.note}\n\n${staleNote}`);
|
|
2161
|
+
}
|
|
2162
|
+
if (staleNote !== null) return emitWarn(staleNote);
|
|
1445
2163
|
return emitAllow();
|
|
1446
2164
|
}
|
|
1447
2165
|
|