session-orchestrator 3.22.0 → 3.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +446 -0
- package/README.md +22 -17
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -2
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +194 -25
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +120 -10
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +89 -9
- package/docs/session-config-template.md +38 -7
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +52 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +93 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +486 -19
- package/hooks/on-session-start.mjs +263 -12
- package/hooks/on-stop.mjs +392 -24
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +182 -21
- package/hooks/post-tool-batch-wave-signal.mjs +165 -42
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/hooks/subagent-telemetry.mjs +11 -26
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +31 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/emit-event.mjs +10 -2
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
- package/scripts/lib/build-live-signals.mjs +49 -27
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +87 -8
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +25 -10
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +517 -18
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +126 -0
- package/scripts/lib/session-close-backfill.mjs +427 -37
- package/scripts/lib/session-discovery.mjs +69 -5
- package/scripts/lib/session-end/phase-skip.mjs +38 -5
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +220 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +91 -16
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +333 -32
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +381 -141
- package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +889 -0
- package/scripts/materialize-wave-scope.mjs +228 -15
- package/scripts/mcp-server.sh +11 -2
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/parse-config.mjs +65 -0
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +29 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +305 -51
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +19 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +270 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +46 -3
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +20 -9
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +54 -17
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +5 -3
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +143 -22
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
* 1. shouldRunHook('post-subagent-discovery-validator') gate — exit 0 when disabled.
|
|
12
12
|
* 2. Read JSON payload from stdin; require hook_event_name === 'SubagentStop'.
|
|
13
13
|
* 3. Read `discovery-validator.enabled` from CLAUDE.md/AGENTS.md Session Config.
|
|
14
|
-
* Default OFF — exit 0 immediately unless explicitly enabled.
|
|
15
|
-
* 4.
|
|
16
|
-
*
|
|
14
|
+
* Default OFF (opt-in) — exit 0 immediately unless explicitly enabled.
|
|
15
|
+
* 4. Resolve the STOPPING SUBAGENT's OWN transcript (never the parent):
|
|
16
|
+
* `input.agent_transcript_path` when the harness sends it, else
|
|
17
|
+
* `<dir(transcript_path)>/<base>/subagents/agent-<agent_id>.jsonl`. Scan its
|
|
18
|
+
* TAIL (last ~8 `type:"assistant"` records), concat text blocks. When no
|
|
19
|
+
* `agent_id` is derivable (or the file is absent) the hook exits 0 and
|
|
20
|
+
* records NOTHING — see the scope note below.
|
|
17
21
|
* 5. Regex-scan the concatenated text for 8 claim patterns — 6 quantifier-
|
|
18
22
|
* triggered distributional claims, the #908 bare-cardinal repo-state
|
|
19
23
|
* fact ("14 commits", "92 learnings", "5 dirty files", "412 lines"), and
|
|
@@ -27,8 +31,32 @@
|
|
|
27
31
|
* date, `HEAD`, "as of", "measured at"). Undated-but-verified claims are
|
|
28
32
|
* counted and reported in the warn text — they are NOT violations in v1.
|
|
29
33
|
*
|
|
30
|
-
* Why read
|
|
31
|
-
* field
|
|
34
|
+
* Why read a transcript at all: the SubagentStop stdin payload has NO
|
|
35
|
+
* output_text field — the agent's text only exists on disk.
|
|
36
|
+
*
|
|
37
|
+
* WHICH transcript (#1191, the root cause behind the fleet false-positive
|
|
38
|
+
* flood): `input.transcript_path` is the PARENT/MAIN session transcript, not
|
|
39
|
+
* the subagent's. Scanning it flagged the COORDINATOR's own prose — wave plans,
|
|
40
|
+
* TL;DRs, complexity scores. Measured 2026-09-02 on a seeded random sample of
|
|
41
|
+
* 60 violations: 100% coordinator text, scope-adjusted precision 0%, and
|
|
42
|
+
* `agent` was `"unknown"` in 90.8% of 1,541 vault events. The hook therefore
|
|
43
|
+
* reads `<transcriptDir>/<session>/subagents/agent-<agent_id>.jsonl` (the same
|
|
44
|
+
* layout `hooks/subagent-telemetry.mjs` and `scripts/lib/wave-transcript-tail.mjs`
|
|
45
|
+
* read) and NEVER falls back to the parent path: a scan of the wrong transcript
|
|
46
|
+
* is worse than no scan.
|
|
47
|
+
*
|
|
48
|
+
* Output channels — THREE writes, TWO different recipients:
|
|
49
|
+
* - `discovery_validator_violation` in .orchestrator/metrics/events.jsonl,
|
|
50
|
+
* and the stderr WARN → the COORDINATOR's only copy of the finding. PSA-006
|
|
51
|
+
* makes REJECTING the unverified claim his duty, so these two are the
|
|
52
|
+
* channels that carry the rule's enforcement path.
|
|
53
|
+
* - stdout `hookSpecificOutput.additionalContext` → the STOPPING SUBAGENT
|
|
54
|
+
* (which continues and may self-correct), NOT the coordinator — a hook is
|
|
55
|
+
* structurally unable to address him. See the measured delivery note at the
|
|
56
|
+
* hookSpecificOutput write below for the shipped-binary evidence.
|
|
57
|
+
* All three are pinned together by the "all three channels fire" test in
|
|
58
|
+
* tests/hooks/post-subagent-discovery-validator.test.mjs — do not collapse
|
|
59
|
+
* the coordinator-visible pair into additionalContext.
|
|
32
60
|
*
|
|
33
61
|
* Exit codes: 0 always (informational, never blocking).
|
|
34
62
|
*/
|
|
@@ -42,6 +70,7 @@ import { promises as fs } from 'node:fs';
|
|
|
42
70
|
import { tmpdir } from 'node:os';
|
|
43
71
|
import path from 'node:path';
|
|
44
72
|
|
|
73
|
+
import { resolveSubagentSidecar } from './_lib/subagent-paths.mjs';
|
|
45
74
|
import { appendJsonl } from '../scripts/lib/common.mjs';
|
|
46
75
|
import { eventsFilePath } from '../scripts/lib/events.mjs';
|
|
47
76
|
import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
|
|
@@ -213,6 +242,27 @@ const CARDINAL_RATIO_PATTERN = new RegExp(
|
|
|
213
242
|
/** Inline-code spans are masked before the cardinal pattern runs. */
|
|
214
243
|
const INLINE_CODE_RE = /`[^`\n]*`/g;
|
|
215
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Gate-summary / STATUS-report lines are the HARNESS's own completion output —
|
|
247
|
+
* tool evidence, not an unverified assertion about the codebase — yet carried
|
|
248
|
+
* no exemption of their own: they were caught only incidentally, by whichever
|
|
249
|
+
* individual claim pattern happened not to fire on that exact phrasing. GitLab
|
|
250
|
+
* #1198 (Discovery D8, 2026-09-02): a 400-event sample of
|
|
251
|
+
* `discovery_validator_violation` records showed 186/400 (46.5%) firing on
|
|
252
|
+
* this exact class — the single largest false-positive source measured to
|
|
253
|
+
* date. Skipped at the LINE level, before ANY pattern runs, rather than by
|
|
254
|
+
* narrowing `CARDINAL_NOUN`: narrowing the noun set would ALSO silence a true
|
|
255
|
+
* "N files" claim appearing outside a gate-summary line, which is not this
|
|
256
|
+
* bug's fix.
|
|
257
|
+
*
|
|
258
|
+
* Four alternatives cover the measured shapes: an N-passed/M-failed count
|
|
259
|
+
* ("14904 passed / 0 failed"), a `STATUS:` report line (`skills/wave-executor`
|
|
260
|
+
* agent-report convention), a "Full Gate" heading, and a `Gate: <verdict>`
|
|
261
|
+
* summary line (English "typecheck" or German "grün"/"rot").
|
|
262
|
+
*/
|
|
263
|
+
const GATE_SUMMARY_LINE_RE =
|
|
264
|
+
/\b\d+\s+passed\s*\/\s*\d+\s+failed\b|^\s*STATUS:\s*(?:done|partial|failed)\b|\bFull Gate\b|\bGate:\s*(?:typecheck|grün|rot)\b/i;
|
|
265
|
+
|
|
216
266
|
/**
|
|
217
267
|
* Commands that count as a MEASUREMENT inside a fenced block. `grep|rg|find`
|
|
218
268
|
* (the #567 set) only covers text search; the #908 facts are measured with
|
|
@@ -450,9 +500,18 @@ function findViolations(text) {
|
|
|
450
500
|
|
|
451
501
|
for (let i = 0; i < lines.length; i++) {
|
|
452
502
|
const line = lines[i];
|
|
453
|
-
|
|
503
|
+
// #1198 FIX 2: gate-summary/STATUS lines are tool OUTPUT, not a claim —
|
|
504
|
+
// skipped before any pattern runs (see GATE_SUMMARY_LINE_RE header).
|
|
505
|
+
if (GATE_SUMMARY_LINE_RE.test(line)) continue;
|
|
506
|
+
|
|
507
|
+
// #1198 FIX 3 (masking-order bug): mask inline-code spans ONCE, then test
|
|
508
|
+
// BOTH the six CLAIM_PATTERNS and the cardinal/ratio patterns against the
|
|
509
|
+
// masked text. Previously only the cardinal branch masked — a claim
|
|
510
|
+
// quoted entirely inside backticks (evidence/example text, not an
|
|
511
|
+
// assertion) still tripped CLAIM_PATTERNS via the raw, unmasked line.
|
|
512
|
+
const masked = line.replace(INLINE_CODE_RE, ' ');
|
|
513
|
+
let matched = CLAIM_PATTERNS.some((re) => re.test(masked));
|
|
454
514
|
if (!matched && !fencedLines.has(i)) {
|
|
455
|
-
const masked = line.replace(INLINE_CODE_RE, ' ');
|
|
456
515
|
matched = CARDINAL_PATTERN.test(masked) || CARDINAL_RATIO_PATTERN.test(masked);
|
|
457
516
|
}
|
|
458
517
|
if (!matched) continue;
|
|
@@ -490,6 +549,58 @@ function firstNonEmptyString(input, keys, fallback) {
|
|
|
490
549
|
return fallback;
|
|
491
550
|
}
|
|
492
551
|
|
|
552
|
+
/**
|
|
553
|
+
* Resolve the STOPPING SUBAGENT's own sidecar pair (#1191, #1196).
|
|
554
|
+
*
|
|
555
|
+
* Thin wrapper over the consolidated derivation —
|
|
556
|
+
* `hooks/_lib/subagent-paths.mjs` `resolveSubagentSidecar()` — which now ALSO
|
|
557
|
+
* containment-checks the `agent_transcript_path` override this file used to
|
|
558
|
+
* return unvalidated (see that module's header divergence table). Returns
|
|
559
|
+
* null when the derivation is impossible — the caller must then scan
|
|
560
|
+
* NOTHING. Falling back to `input.transcript_path` is the defect this
|
|
561
|
+
* function exists to remove: that path is the coordinator's transcript.
|
|
562
|
+
*
|
|
563
|
+
* @param {object} input — SubagentStop stdin payload
|
|
564
|
+
* @param {string|null} agentId
|
|
565
|
+
* @returns {{base: string, transcript: string, meta: string}|null}
|
|
566
|
+
*/
|
|
567
|
+
function resolveAgentTranscriptPath(input, agentId) {
|
|
568
|
+
const agentTranscriptPath = firstNonEmptyString(input, ['agent_transcript_path'], null);
|
|
569
|
+
return resolveSubagentSidecar({ transcriptPath: input.transcript_path, agentId, agentTranscriptPath });
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Agent types carry a plugin qualifier, so the COLON is part of the real shape:
|
|
574
|
+
* `session-orchestrator:code-implementer` (37 chars, measured on-disk in a real
|
|
575
|
+
* sidecar meta.json 2026-09-02). Same constant as `AGENT_TYPE_META_RE` in
|
|
576
|
+
* hooks/on-stop.mjs — kept local because the two hooks share no module.
|
|
577
|
+
*/
|
|
578
|
+
const AGENT_TYPE_META_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Read the agent TYPE from the sidecar `agent-<id>.meta.json` the harness writes
|
|
582
|
+
* next to the subagent transcript. Used only when the stdin payload omits
|
|
583
|
+
* `agent_type` — the reason `agent` read `"unknown"` on ~91% of events.
|
|
584
|
+
*
|
|
585
|
+
* @param {string} metaPath — `resolveSubagentSidecar(...).meta`
|
|
586
|
+
* @returns {Promise<string|null>}
|
|
587
|
+
*/
|
|
588
|
+
async function readAgentTypeFromMeta(metaPath) {
|
|
589
|
+
try {
|
|
590
|
+
const meta = JSON.parse(await fs.readFile(metaPath, 'utf8'));
|
|
591
|
+
const t = meta?.agentType;
|
|
592
|
+
if (typeof t !== 'string' || !t.trim()) return null;
|
|
593
|
+
// Clamped with the same shape hooks/on-stop.mjs applies to `agentType`
|
|
594
|
+
// (colon included — `session-orchestrator:code-implementer` is the real
|
|
595
|
+
// shape). This value reaches BOTH the ledger event and the model-visible
|
|
596
|
+
// `additionalContext` string, so a mismatch is OMITTED rather than
|
|
597
|
+
// truncated: the caller then falls back to the honest `'unknown'`.
|
|
598
|
+
return AGENT_TYPE_META_RE.test(t.trim()) ? t.trim() : null;
|
|
599
|
+
} catch {
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
493
604
|
/**
|
|
494
605
|
* Sanitize a user/runtime-provided string for use in a tmp sentinel filename.
|
|
495
606
|
*
|
|
@@ -511,23 +622,38 @@ function projectRootHash(projectRoot) {
|
|
|
511
622
|
}
|
|
512
623
|
|
|
513
624
|
/**
|
|
514
|
-
* Build the dedup sentinel path for real project/session/agent contexts.
|
|
625
|
+
* Build the dedup sentinel path for real project/session/agent/claim contexts.
|
|
515
626
|
* Missing fallback IDs intentionally return null so unrelated hooks/tests do
|
|
516
627
|
* not collide on a global "unknown" key.
|
|
517
628
|
*
|
|
629
|
+
* #1198 FIX 1: the key used to be `(projectRoot, sessionId, agent_type)` —
|
|
630
|
+
* `agent_type` is a CLASS ("discovery"), not an individual agent, so two
|
|
631
|
+
* DIFFERENT real subagents of the same type running in the same session
|
|
632
|
+
* collided on the same sentinel: the second agent's own `additionalContext`
|
|
633
|
+
* feedback was silently suppressed even though it never received a copy of
|
|
634
|
+
* the first agent's warning. Keying on `agentId` (the harness's per-process
|
|
635
|
+
* `agent_id`/`subagent_id`) instead removes that cross-agent collision. The
|
|
636
|
+
* claim-text hash is ADDITIVE: it lets a genuinely SECOND, DISTINCT claim
|
|
637
|
+
* from the same real agent still surface its own suppression check, rather
|
|
638
|
+
* than being silenced merely because that agent already triggered once for a
|
|
639
|
+
* different claim.
|
|
640
|
+
*
|
|
518
641
|
* @param {object} opts
|
|
519
642
|
* @param {string} opts.projectRoot
|
|
520
643
|
* @param {string|null} opts.sessionId
|
|
521
|
-
* @param {string|null} opts.
|
|
644
|
+
* @param {string|null} opts.agentId
|
|
645
|
+
* @param {string|null} opts.claimText
|
|
522
646
|
* @returns {string|null}
|
|
523
647
|
*/
|
|
524
|
-
function dedupSentinelPath({ projectRoot, sessionId,
|
|
648
|
+
function dedupSentinelPath({ projectRoot, sessionId, agentId, claimText }) {
|
|
525
649
|
if (typeof sessionId !== 'string' || !sessionId.trim()) return null;
|
|
526
|
-
if (typeof
|
|
650
|
+
if (typeof agentId !== 'string' || !agentId.trim()) return null;
|
|
651
|
+
if (typeof claimText !== 'string' || !claimText.trim()) return null;
|
|
527
652
|
|
|
653
|
+
const claimHash = createHash('sha256').update(claimText).digest('hex').slice(0, 16);
|
|
528
654
|
return path.join(
|
|
529
655
|
tmpdir(),
|
|
530
|
-
`psa006-${projectRootHash(projectRoot)}-${safeSentinelComponent(sessionId)}-${safeSentinelComponent(
|
|
656
|
+
`psa006-${projectRootHash(projectRoot)}-${safeSentinelComponent(sessionId)}-${safeSentinelComponent(agentId)}-${claimHash}.lock`
|
|
531
657
|
);
|
|
532
658
|
}
|
|
533
659
|
|
|
@@ -542,11 +668,19 @@ async function main() {
|
|
|
542
668
|
|
|
543
669
|
if (!(await isEnabled())) return;
|
|
544
670
|
|
|
545
|
-
|
|
671
|
+
// #1191: scan the SUBAGENT's own transcript, never the parent. No agent_id
|
|
672
|
+
// (or no derivable path) → scan nothing and record nothing.
|
|
673
|
+
const agentId = firstNonEmptyString(input, ['agent_id', 'subagent_id'], null);
|
|
674
|
+
const sidecar = resolveAgentTranscriptPath(input, agentId);
|
|
675
|
+
if (sidecar === null) return;
|
|
676
|
+
|
|
677
|
+
const text = await readTranscriptTail(sidecar.transcript);
|
|
546
678
|
const { violations, undatedVerified } = findViolations(text);
|
|
547
679
|
if (violations.length === 0) return;
|
|
548
680
|
|
|
549
|
-
const agentForDedup =
|
|
681
|
+
const agentForDedup =
|
|
682
|
+
firstNonEmptyString(input, ['agent_type', 'subagent_type'], null) ??
|
|
683
|
+
(await readAgentTypeFromMeta(sidecar.meta));
|
|
550
684
|
const agent = agentForDedup ?? 'unknown';
|
|
551
685
|
// session_id precedence: parent_session_id first, mirroring the sibling hook
|
|
552
686
|
// hooks/subagent-telemetry.mjs (firstNonEmptyString(['parent_session_id',
|
|
@@ -554,10 +688,18 @@ async function main() {
|
|
|
554
688
|
// parent_session_id` order disagreed with telemetry and could log the wrong id.
|
|
555
689
|
const sessionId = firstNonEmptyString(input, ['parent_session_id', 'session_id'], null);
|
|
556
690
|
|
|
557
|
-
// Project/session/agent deduplication: only emit additionalContext
|
|
558
|
-
// repeated real contexts. Missing session IDs never create/read a
|
|
559
|
-
// so fallback traffic still surfaces warnings and cannot collide
|
|
560
|
-
|
|
691
|
+
// Project/session/agent/claim deduplication: only emit additionalContext
|
|
692
|
+
// once for repeated real contexts. Missing session IDs never create/read a
|
|
693
|
+
// sentinel, so fallback traffic still surfaces warnings and cannot collide
|
|
694
|
+
// globally. Keyed on agentId (not agent TYPE, #1198 FIX 1) plus the first
|
|
695
|
+
// violation's claim text so distinct real agents and distinct claims never
|
|
696
|
+
// share a sentinel.
|
|
697
|
+
const sentinel = dedupSentinelPath({
|
|
698
|
+
projectRoot: SO_PROJECT_DIR,
|
|
699
|
+
sessionId,
|
|
700
|
+
agentId,
|
|
701
|
+
claimText: violations[0],
|
|
702
|
+
});
|
|
561
703
|
|
|
562
704
|
const filePath = eventsFilePath();
|
|
563
705
|
for (const claim of violations) {
|
|
@@ -565,6 +707,7 @@ async function main() {
|
|
|
565
707
|
event: 'discovery_validator_violation',
|
|
566
708
|
timestamp: new Date().toISOString(),
|
|
567
709
|
agent,
|
|
710
|
+
...(agentId !== null ? { agent_id: agentId } : {}),
|
|
568
711
|
...(sessionId !== null ? { session_id: sessionId } : {}),
|
|
569
712
|
claim_text: claim,
|
|
570
713
|
});
|
|
@@ -594,12 +737,30 @@ async function main() {
|
|
|
594
737
|
}
|
|
595
738
|
|
|
596
739
|
if (alreadyWarned) {
|
|
597
|
-
// Events logged above; suppress
|
|
740
|
+
// Events logged above; suppress the repeat additionalContext for this
|
|
741
|
+
// already-warned real context. (Recipient is the stopping subagent, not the
|
|
742
|
+
// coordinator — see the delivery note at the hookSpecificOutput write below.)
|
|
598
743
|
return;
|
|
599
744
|
}
|
|
600
745
|
|
|
601
|
-
// v2.1.163+ additionalContext:
|
|
602
|
-
//
|
|
746
|
+
// v2.1.163+ additionalContext: surface the finding inline, not just in
|
|
747
|
+
// stderr + events.jsonl.
|
|
748
|
+
//
|
|
749
|
+
// The recipient is the SUBAGENT that just stopped — NOT the coordinator.
|
|
750
|
+
// Measured in the shipped binary (Claude Code 2.1.241, 2026-08-23): the
|
|
751
|
+
// SubagentStop hookSpecificOutput schema reads "additionalContext is
|
|
752
|
+
// non-error feedback delivered to the subagent; the subagent continues so it
|
|
753
|
+
// can act on it", and the emitter picks its target with
|
|
754
|
+
// `i.agentId ? "SubagentStop" : "Stop"`, yielding a `hook_additional_context`
|
|
755
|
+
// message into that agent's own loop. So this text lands in the transcript of
|
|
756
|
+
// the agent whose claims it is about, one moment after that agent is done.
|
|
757
|
+
//
|
|
758
|
+
// Worth knowing before "fixing" the path: PSA-006 asks the COORDINATOR to
|
|
759
|
+
// reject unverified distributional claims, and this channel never reaches
|
|
760
|
+
// him. His copy of the finding is events.jsonl + stderr, not this write.
|
|
761
|
+
// Whether he SHOULD receive it is a product question (#1116) — changing the
|
|
762
|
+
// route is not a comment edit.
|
|
763
|
+
//
|
|
603
764
|
// Non-blocking — exit 0 always. Decision:"block" must never be set here.
|
|
604
765
|
process.stdout.write(JSON.stringify({
|
|
605
766
|
hookSpecificOutput: {
|
|
@@ -29,10 +29,29 @@
|
|
|
29
29
|
*
|
|
30
30
|
* Exit codes: 0 always (informational, never blocking).
|
|
31
31
|
*
|
|
32
|
+
* OWNERSHIP RULE for the wave keys (#1193 W4c Q1-MED). `.orchestrator/` is
|
|
33
|
+
* repo-global, so `current-session.json` describes whichever session most
|
|
34
|
+
* recently ran SessionStart — routinely a DIFFERENT, still-live session when
|
|
35
|
+
* two windows share this working copy. `last_wave` and `last_wave_completed`
|
|
36
|
+
* are therefore written ONLY when this batch's RAW stdin `session_id` equals
|
|
37
|
+
* the file's `session_id` (a file without one is legacy and allowed). Writing
|
|
38
|
+
* them unguarded was reproduced both ways with the real hook binaries: session
|
|
39
|
+
* A's batch stamped `last_wave_completed` into B's record, so B's own attested
|
|
40
|
+
* SessionEnd stayed silent (the #1193 gap preserved on B), and A's `last_wave`
|
|
41
|
+
* landed in B's record, so B's SessionEnd emitted `wave.completed` for A's wave
|
|
42
|
+
* under B's identity. `last_batch` stays UNGATED — it is a batch-resolution
|
|
43
|
+
* breadcrumb, not a claim about whose wave lifecycle this is.
|
|
44
|
+
*
|
|
45
|
+
* MONOTONICITY RULE (#1193 W4c Q3-MED-3). Three writers touch
|
|
46
|
+
* `last_wave_completed` (this hook's explicit branch, this hook's fallback,
|
|
47
|
+
* `hooks/on-session-end.mjs`). Every one of them writes through `maxWave()`,
|
|
48
|
+
* so the mark can only ever rise — an explicit `wave-complete{5}` arriving
|
|
49
|
+
* while `last_wave` is 4 must not be walked BACKWARDS to 4 by a later writer.
|
|
50
|
+
*
|
|
32
51
|
* hooks.json wiring is managed separately (W3-C4 scope).
|
|
33
52
|
*/
|
|
34
53
|
|
|
35
|
-
import { readFile
|
|
54
|
+
import { readFile } from 'node:fs/promises';
|
|
36
55
|
import path from 'node:path';
|
|
37
56
|
|
|
38
57
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
@@ -41,6 +60,8 @@ if (!shouldRunHook('post-tool-batch-wave-signal')) process.exit(0);
|
|
|
41
60
|
|
|
42
61
|
import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
|
|
43
62
|
import { emitEvent } from '../scripts/lib/events.mjs';
|
|
63
|
+
import { findScopeFile } from '../scripts/lib/scope-gate.mjs';
|
|
64
|
+
import { atomicMutateJson } from './_lib/atomic-json.mjs';
|
|
44
65
|
|
|
45
66
|
// ---------------------------------------------------------------------------
|
|
46
67
|
// Helpers
|
|
@@ -73,32 +94,6 @@ function readStdinJson() {
|
|
|
73
94
|
});
|
|
74
95
|
}
|
|
75
96
|
|
|
76
|
-
/**
|
|
77
|
-
* Atomic read-modify-write of a JSON file via temp + rename.
|
|
78
|
-
* Reads the existing file (or starts with `defaultValue` when absent),
|
|
79
|
-
* applies `mutate`, writes to a tmp file, then renames over the original.
|
|
80
|
-
* Atomic on POSIX (same-filesystem rename). Best-effort on Windows.
|
|
81
|
-
*
|
|
82
|
-
* @param {string} filePath
|
|
83
|
-
* @param {object} defaultValue — used when the file does not exist or is unparseable
|
|
84
|
-
* @param {function(object): object} mutate — synchronous pure transformer
|
|
85
|
-
*/
|
|
86
|
-
async function atomicMutateJson(filePath, defaultValue, mutate) {
|
|
87
|
-
let current = defaultValue;
|
|
88
|
-
try {
|
|
89
|
-
const raw = await readFile(filePath, 'utf8');
|
|
90
|
-
current = JSON.parse(raw);
|
|
91
|
-
} catch {
|
|
92
|
-
// File absent or unparseable — start from defaultValue.
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const updated = mutate(current);
|
|
96
|
-
const tmp = `${filePath}.tmp-ptb-${process.pid}-${Date.now()}`;
|
|
97
|
-
await mkdir(path.dirname(filePath), { recursive: true });
|
|
98
|
-
await writeFile(tmp, JSON.stringify(updated, null, 2) + '\n', 'utf8');
|
|
99
|
-
await rename(tmp, filePath);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
97
|
/**
|
|
103
98
|
* Resolve the session-id for the heartbeat refresh.
|
|
104
99
|
* Precedence:
|
|
@@ -135,17 +130,73 @@ async function resolveSessionIdForHeartbeat(input, sessionFile) {
|
|
|
135
130
|
}
|
|
136
131
|
|
|
137
132
|
/**
|
|
138
|
-
*
|
|
133
|
+
* Monotone high-water helper — see the MONOTONICITY RULE in the module
|
|
134
|
+
* docblock. A non-integer `existing` (absent, `null`, or the string `'3'` a
|
|
135
|
+
* hand-edited file can carry) counts as ABSENT, never as a competing value.
|
|
136
|
+
*
|
|
137
|
+
* @param {unknown} existing
|
|
138
|
+
* @param {number} n
|
|
139
|
+
* @returns {number}
|
|
140
|
+
*/
|
|
141
|
+
function maxWave(existing, n) {
|
|
142
|
+
return Number.isInteger(existing) && existing > n ? existing : n;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Does THIS batch own `.orchestrator/current-session.json`?
|
|
147
|
+
* See the OWNERSHIP RULE in the module docblock for why the wave keys need it.
|
|
148
|
+
*
|
|
149
|
+
* Decided on the RAW stdin id only — deliberately NOT via
|
|
150
|
+
* `resolveSessionIdForHeartbeat()`, whose current-session.json fallback would
|
|
151
|
+
* make the compare self-fulfilling (the same F-A defect fixed in
|
|
152
|
+
* `hooks/on-session-end.mjs`: an assertion may never be derived from the value
|
|
153
|
+
* it asserts about). A file carrying no `session_id` is a legacy/pre-#587
|
|
154
|
+
* record with no owner to contend with → allowed.
|
|
155
|
+
*
|
|
156
|
+
* @param {object|null} input Parsed stdin payload.
|
|
157
|
+
* @param {string} sessionFile Absolute path to current-session.json.
|
|
158
|
+
* @returns {Promise<boolean>}
|
|
159
|
+
*/
|
|
160
|
+
async function ownsSessionFile(input, sessionFile) {
|
|
161
|
+
let rawStdinId = null;
|
|
162
|
+
if (typeof input?.session_id === 'string' && input.session_id.length > 0) {
|
|
163
|
+
rawStdinId = input.session_id;
|
|
164
|
+
} else if (typeof input?.sessionId === 'string' && input.sessionId.length > 0) {
|
|
165
|
+
rawStdinId = input.sessionId;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let recordedId = null;
|
|
169
|
+
try {
|
|
170
|
+
const parsed = JSON.parse(await readFile(sessionFile, 'utf8'));
|
|
171
|
+
if (typeof parsed?.session_id === 'string' && parsed.session_id.length > 0) {
|
|
172
|
+
recordedId = parsed.session_id;
|
|
173
|
+
}
|
|
174
|
+
} catch { /* absent or unparseable → no recorded owner */ }
|
|
175
|
+
|
|
176
|
+
if (recordedId === null) return true;
|
|
177
|
+
return rawStdinId !== null && rawStdinId === recordedId;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolve the current wave number from the wave-scope manifest's `.wave`.
|
|
139
182
|
* Returns 0 when the file is absent or unparseable, mirroring the
|
|
140
183
|
* pre-bash-memory-propose-audit.mjs G5 precedent ("wave defaults to 0 when
|
|
141
184
|
* wave-scope.json absent"). The file is deleted mid-session at Quality phase
|
|
142
185
|
* transitions and final cleanup, so absence is an expected, non-error state.
|
|
143
186
|
*
|
|
187
|
+
* The manifest is located via `findScopeFile()` (#1082), the same precedence
|
|
188
|
+
* every other consumer uses — `.pi` > `.cursor` > `.codex` > `.claude`. The
|
|
189
|
+
* previous hard-coded `.claude/` path made this hook's whole wave-lifecycle
|
|
190
|
+
* fallback structurally dead on Codex CLI, Cursor and pi: the manifest exists,
|
|
191
|
+
* it just is not under `.claude/`, so every batch read 0 and no
|
|
192
|
+
* `orchestrator.wave.started`/`completed` event was ever emitted there.
|
|
193
|
+
*
|
|
144
194
|
* @param {string} projectDir
|
|
145
195
|
* @returns {Promise<number>}
|
|
146
196
|
*/
|
|
147
197
|
async function resolveWaveNumber(projectDir) {
|
|
148
|
-
const waveFile =
|
|
198
|
+
const waveFile = findScopeFile(projectDir);
|
|
199
|
+
if (waveFile === null) return 0;
|
|
149
200
|
try {
|
|
150
201
|
const raw = await readFile(waveFile, 'utf8');
|
|
151
202
|
const data = JSON.parse(raw);
|
|
@@ -199,10 +250,16 @@ async function main() {
|
|
|
199
250
|
|
|
200
251
|
const sessionFile = path.join(SO_PROJECT_DIR, '.orchestrator', 'current-session.json');
|
|
201
252
|
|
|
202
|
-
await atomicMutateJson(sessionFile, {}, (current) => ({
|
|
253
|
+
const lastBatchResult = await atomicMutateJson(sessionFile, {}, (current) => ({
|
|
203
254
|
...current,
|
|
204
255
|
last_batch: lastBatch,
|
|
205
|
-
}));
|
|
256
|
+
}), 'ptb');
|
|
257
|
+
// Non-ENOENT failure only drops this turn's last_batch signal — the
|
|
258
|
+
// heartbeat refresh and wave-lifecycle logic below are independent and
|
|
259
|
+
// must still run (a throw here would silently skip both).
|
|
260
|
+
if (!lastBatchResult.ok) {
|
|
261
|
+
console.error(`post-tool-batch-wave-signal: last_batch write skipped (${lastBatchResult.reason})`);
|
|
262
|
+
}
|
|
206
263
|
|
|
207
264
|
// ----------------------------------------------------------------------
|
|
208
265
|
// Heartbeat refresh (Epic #583 W3-P3, wires W2-I3 OQ2).
|
|
@@ -247,6 +304,32 @@ async function main() {
|
|
|
247
304
|
...(batchSize !== null ? { batch_size: batchSize } : {}),
|
|
248
305
|
},
|
|
249
306
|
);
|
|
307
|
+
// #1193 review F2 — this branch is a SECOND emitter of
|
|
308
|
+
// `wave.completed`, so it must feed the same high-water mark the other
|
|
309
|
+
// two read; otherwise SessionEnd (and the fallback below) would close the
|
|
310
|
+
// very wave this signal just closed. Only a numbered completion can be
|
|
311
|
+
// recorded — an unnumbered signal marks nothing.
|
|
312
|
+
//
|
|
313
|
+
// W4c Q2-F1 — this branch also advances `last_wave`. Without it an
|
|
314
|
+
// explicit `wave-complete{5}` left the marker AHEAD of `last_wave: 4`,
|
|
315
|
+
// and SessionEnd then read `4 !== 5`, emitted a duplicate completed(4)
|
|
316
|
+
// and walked the marker BACKWARDS to 4. Both keys go through maxWave().
|
|
317
|
+
// W4c Q1-MED — and neither is written into a PEER's record.
|
|
318
|
+
if (
|
|
319
|
+
waveSignal === 'wave-complete'
|
|
320
|
+
&& typeof waveNumber === 'number'
|
|
321
|
+
&& waveNumber > 0
|
|
322
|
+
&& await ownsSessionFile(input, sessionFile)
|
|
323
|
+
) {
|
|
324
|
+
const waveResult = await atomicMutateJson(sessionFile, {}, (current) => ({
|
|
325
|
+
...current,
|
|
326
|
+
last_wave: maxWave(current?.last_wave, waveNumber),
|
|
327
|
+
last_wave_completed: maxWave(current?.last_wave_completed, waveNumber),
|
|
328
|
+
}), 'ptb');
|
|
329
|
+
if (!waveResult.ok) {
|
|
330
|
+
console.error(`post-tool-batch-wave-signal: last_wave write skipped (${waveResult.reason})`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
250
333
|
} catch { /* best-effort — hook must remain non-blocking */ }
|
|
251
334
|
} else if (waveSignal === null) {
|
|
252
335
|
// ------------------------------------------------------------------
|
|
@@ -265,24 +348,41 @@ async function main() {
|
|
|
265
348
|
// those windows — a drop to 0 (or any non-increase) is NOT a wave change
|
|
266
349
|
// and is ignored, preventing spurious emissions on every batch.
|
|
267
350
|
//
|
|
268
|
-
// Final
|
|
269
|
-
//
|
|
270
|
-
//
|
|
351
|
+
// Final wave (#1193): no N+1 transition exists for the LAST wave, so its
|
|
352
|
+
// `completed` is emitted by `hooks/on-session-end.mjs` at SessionEnd, not
|
|
353
|
+
// here — and not by the coordinator, which never did (the prose claim this
|
|
354
|
+
// comment used to make was false from #612 until #1193, costing exactly one
|
|
355
|
+
// missing completion per wave run fleet-wide). The two emitters are made
|
|
356
|
+
// idempotent by the `last_wave_completed` high-water mark persisted below:
|
|
357
|
+
// when an N+1 transition already closed wave N, SessionEnd sees
|
|
358
|
+
// last_wave_completed === last_wave and stays silent.
|
|
271
359
|
try {
|
|
272
360
|
const wave = await resolveWaveNumber(SO_PROJECT_DIR);
|
|
273
361
|
if (wave > 0) {
|
|
274
362
|
// Read last_wave from the just-written session file (after the
|
|
275
363
|
// last_batch RMW above, so we observe the latest persisted value).
|
|
276
364
|
let lastWave = 0;
|
|
365
|
+
let lastWaveCompleted = 0;
|
|
277
366
|
try {
|
|
278
367
|
const raw = await readFile(sessionFile, 'utf8');
|
|
279
368
|
const parsed = JSON.parse(raw);
|
|
280
369
|
if (typeof parsed.last_wave === 'number') lastWave = parsed.last_wave;
|
|
281
|
-
|
|
370
|
+
// #1193 review F2 — read in the SAME read as `last_wave`: a `/clear`
|
|
371
|
+
// mid-wave already emitted `completed(lastWave)` via SessionEnd, and
|
|
372
|
+
// `on-session-start.mjs` preserves that marker across the restart, so
|
|
373
|
+
// guarding on `wave > lastWave` alone would emit it a second time.
|
|
374
|
+
if (Number.isInteger(parsed.last_wave_completed)) {
|
|
375
|
+
lastWaveCompleted = parsed.last_wave_completed;
|
|
376
|
+
}
|
|
377
|
+
} catch { /* absent/unparseable → both stay 0 */ }
|
|
282
378
|
|
|
283
379
|
if (wave > lastWave) {
|
|
284
|
-
// Close the prior wave first
|
|
285
|
-
|
|
380
|
+
// Close the prior wave first — unless it was already closed. The
|
|
381
|
+
// compare is `>` and not `!==` (W4c Q3-MED-3): a marker AHEAD of
|
|
382
|
+
// `last_wave` (an explicit wave-complete for a higher wave) means the
|
|
383
|
+
// prior wave is already closed too, and a non-integer marker counts
|
|
384
|
+
// as absent rather than as "different".
|
|
385
|
+
if (lastWave > lastWaveCompleted) {
|
|
286
386
|
await emitEvent('orchestrator.wave.completed', {
|
|
287
387
|
wave_number: lastWave,
|
|
288
388
|
...(batchId !== null ? { batch_id: batchId } : {}),
|
|
@@ -296,11 +396,34 @@ async function main() {
|
|
|
296
396
|
...(batchId !== null ? { batch_id: batchId } : {}),
|
|
297
397
|
...(batchSize !== null ? { batch_size: batchSize } : {}),
|
|
298
398
|
});
|
|
299
|
-
// Persist the high-water
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
399
|
+
// Persist the high-water marks so the next batch does not re-emit,
|
|
400
|
+
// and so SessionEnd can tell whether the prior wave was already
|
|
401
|
+
// closed here (#1193). `last_wave_completed` is only advanced when a
|
|
402
|
+
// `completed` was actually emitted above (lastWave > 0).
|
|
403
|
+
// W4c Q1-MED — never stamp these into a PEER session's record. The
|
|
404
|
+
// events above are still emitted (they carry THIS session's
|
|
405
|
+
// attribution via emitEvent); only the shared-file claim is withheld.
|
|
406
|
+
// NAMED CEILING: for a non-owning session the marks therefore never
|
|
407
|
+
// advance, so this branch re-emits started{wave} once per batch until
|
|
408
|
+
// that session's own SessionStart takes over the file. Revisit if the
|
|
409
|
+
// ledger shows repeated started{N} for one wave in a shared checkout.
|
|
410
|
+
if (await ownsSessionFile(input, sessionFile)) {
|
|
411
|
+
const markResult = await atomicMutateJson(sessionFile, {}, (current) => ({
|
|
412
|
+
...current,
|
|
413
|
+
last_wave: maxWave(current?.last_wave, wave),
|
|
414
|
+
...(Math.max(lastWave, lastWaveCompleted) > 0
|
|
415
|
+
? {
|
|
416
|
+
last_wave_completed: maxWave(
|
|
417
|
+
current?.last_wave_completed,
|
|
418
|
+
Math.max(lastWave, lastWaveCompleted),
|
|
419
|
+
),
|
|
420
|
+
}
|
|
421
|
+
: {}),
|
|
422
|
+
}), 'ptb');
|
|
423
|
+
if (!markResult.ok) {
|
|
424
|
+
console.error(`post-tool-batch-wave-signal: last_wave mark skipped (${markResult.reason})`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
304
427
|
}
|
|
305
428
|
}
|
|
306
429
|
} catch { /* best-effort — hook must remain non-blocking */ }
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
* hooks.json wiring is managed separately (W3-C4 scope).
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
|
28
27
|
import path from 'node:path';
|
|
29
28
|
|
|
30
29
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
@@ -32,6 +31,7 @@ import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
|
32
31
|
if (!shouldRunHook('post-tool-failure-corrective-context')) process.exit(0);
|
|
33
32
|
|
|
34
33
|
import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
|
|
34
|
+
import { atomicMutateJson } from './_lib/atomic-json.mjs';
|
|
35
35
|
|
|
36
36
|
// ---------------------------------------------------------------------------
|
|
37
37
|
// Constants
|
|
@@ -74,35 +74,6 @@ function readStdinJson() {
|
|
|
74
74
|
});
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/**
|
|
78
|
-
* Atomic read-modify-write of a JSON file.
|
|
79
|
-
* Reads the existing file (or starts with `defaultValue` when absent),
|
|
80
|
-
* applies `mutate`, writes to a tmp file, renames over the original.
|
|
81
|
-
*
|
|
82
|
-
* @param {string} filePath
|
|
83
|
-
* @param {object} defaultValue — used when the file does not exist
|
|
84
|
-
* @param {function(object): object} mutate — pure transformer
|
|
85
|
-
*/
|
|
86
|
-
async function atomicMutateJson(filePath, defaultValue, mutate) {
|
|
87
|
-
let current = defaultValue;
|
|
88
|
-
try {
|
|
89
|
-
const raw = await readFile(filePath, 'utf8');
|
|
90
|
-
current = JSON.parse(raw);
|
|
91
|
-
} catch {
|
|
92
|
-
// File absent or unparseable — start from defaultValue.
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const updated = mutate(current);
|
|
96
|
-
const tmp = `${filePath}.tmp-ptf-${process.pid}-${Date.now()}`;
|
|
97
|
-
await mkdir(path.dirname(filePath), { recursive: true });
|
|
98
|
-
await writeFile(tmp, JSON.stringify(updated, null, 2) + '\n', 'utf8');
|
|
99
|
-
|
|
100
|
-
// Rename is atomic on POSIX (same-filesystem). On Windows this is best-effort
|
|
101
|
-
// via the fs.rename syscall (may fail if target is locked — swallowed by
|
|
102
|
-
// the catch in main()).
|
|
103
|
-
await rename(tmp, filePath);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
77
|
// ---------------------------------------------------------------------------
|
|
107
78
|
// Main
|
|
108
79
|
// ---------------------------------------------------------------------------
|
|
@@ -210,14 +181,20 @@ async function main() {
|
|
|
210
181
|
|
|
211
182
|
const sessionFile = path.join(SO_PROJECT_DIR, '.orchestrator', 'current-session.json');
|
|
212
183
|
|
|
213
|
-
await atomicMutateJson(sessionFile, {}, (current) => {
|
|
184
|
+
const result = await atomicMutateJson(sessionFile, {}, (current) => {
|
|
214
185
|
const existing = Array.isArray(current.corrective_context)
|
|
215
186
|
? current.corrective_context
|
|
216
187
|
: [];
|
|
217
188
|
// Append the new note and cap at MAX_ENTRIES (keep most-recent).
|
|
218
189
|
const updated = [...existing, note].slice(-MAX_ENTRIES);
|
|
219
190
|
return { ...current, corrective_context: updated };
|
|
220
|
-
});
|
|
191
|
+
}, 'ptf');
|
|
192
|
+
// additionalContext below is derived from `note` in-memory, independent of
|
|
193
|
+
// whether the write landed — a non-ENOENT read/parse failure only means
|
|
194
|
+
// this turn's note is not persisted to current-session.json.
|
|
195
|
+
if (!result.ok) {
|
|
196
|
+
console.error(`post-tool-failure-corrective-context: atomicMutateJson skipped write (${result.reason})`);
|
|
197
|
+
}
|
|
221
198
|
|
|
222
199
|
// Surface corrective context to Claude via additionalContext on the next turn.
|
|
223
200
|
// PostToolUseFailure hookSpecificOutput shape per CC docs:
|