session-orchestrator 3.23.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.
Files changed (102) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  5. package/CHANGELOG.md +278 -0
  6. package/README.md +16 -14
  7. package/agents/db-specialist.md +0 -1
  8. package/docs/ci-setup.md +180 -25
  9. package/docs/codex-setup.md +1 -1
  10. package/docs/components.md +3 -3
  11. package/docs/events-schema.md +46 -8
  12. package/docs/scope-collision-guard.md +4 -4
  13. package/docs/session-config-reference.md +64 -6
  14. package/docs/session-config-template.md +34 -4
  15. package/docs/telemetry/telemetry-claims.md +11 -10
  16. package/docs/telemetry.md +30 -1
  17. package/hooks/_lib/atomic-json.mjs +111 -0
  18. package/hooks/_lib/subagent-paths.mjs +143 -0
  19. package/hooks/cwd-change-restore.mjs +9 -29
  20. package/hooks/enforce-scope.mjs +35 -6
  21. package/hooks/hooks-codex.json +1 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/on-session-end.mjs +278 -12
  24. package/hooks/on-session-start.mjs +50 -2
  25. package/hooks/on-stop.mjs +349 -20
  26. package/hooks/post-bash-write-verify.mjs +104 -4
  27. package/hooks/post-subagent-discovery-validator.mjs +148 -18
  28. package/hooks/post-tool-batch-wave-signal.mjs +154 -40
  29. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  30. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  31. package/hooks/subagent-telemetry.mjs +11 -26
  32. package/package.json +1 -1
  33. package/scripts/autopilot.mjs +26 -12
  34. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  35. package/scripts/emit-event.mjs +10 -2
  36. package/scripts/lib/auq/parse.mjs +5 -29
  37. package/scripts/lib/auto-dialectic.mjs +68 -0
  38. package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
  39. package/scripts/lib/build-live-signals.mjs +25 -22
  40. package/scripts/lib/cold-start-detector.mjs +23 -14
  41. package/scripts/lib/config/block-header.mjs +55 -0
  42. package/scripts/lib/config/discovery-validator.mjs +7 -2
  43. package/scripts/lib/config/health-endpoints.mjs +383 -0
  44. package/scripts/lib/config/remote-hosts.mjs +233 -0
  45. package/scripts/lib/config.mjs +31 -3
  46. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  47. package/scripts/lib/events-schema.mjs +48 -0
  48. package/scripts/lib/events.mjs +238 -5
  49. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  50. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  51. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  52. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  53. package/scripts/lib/memory-banner.mjs +20 -8
  54. package/scripts/lib/peer-discovery.mjs +20 -2
  55. package/scripts/lib/reconcile/engine.mjs +236 -5
  56. package/scripts/lib/scope-gate.mjs +36 -0
  57. package/scripts/lib/session-close-backfill.mjs +59 -10
  58. package/scripts/lib/session-discovery.mjs +57 -3
  59. package/scripts/lib/session-end/phase-skip.mjs +2 -2
  60. package/scripts/lib/session-identity/own-session.mjs +62 -1
  61. package/scripts/lib/session-transition.mjs +1 -1
  62. package/scripts/lib/sessions-canonical.mjs +446 -0
  63. package/scripts/lib/telemetry/schema.mjs +74 -8
  64. package/scripts/lib/telemetry/sync.mjs +49 -12
  65. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  66. package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
  67. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  68. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  69. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  70. package/scripts/lib/validate/check-unwired-features.mjs +0 -7
  71. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  72. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  73. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  74. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  75. package/scripts/lib/vault-status/board-writer.mjs +174 -135
  76. package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
  77. package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
  78. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  79. package/scripts/lib/wave-resource-gate.mjs +127 -7
  80. package/scripts/lib/wave-transcript-tail.mjs +24 -4
  81. package/scripts/materialize-wave-scope.mjs +20 -4
  82. package/scripts/memory-propose.mjs +132 -8
  83. package/scripts/promote-vault-strict.mjs +4 -15
  84. package/scripts/site-numbers.mjs +36 -4
  85. package/scripts/validate-plugin.mjs +26 -0
  86. package/scripts/vault-consolidate.mjs +3 -11
  87. package/scripts/vault-integration-watcher.mjs +2 -4
  88. package/scripts/vault-mirror.mjs +111 -26
  89. package/skills/_shared/parallel-aware-auq.md +31 -2
  90. package/skills/_shared/parallel-aware-preamble.md +17 -4
  91. package/skills/_shared/state-ownership.md +1 -1
  92. package/skills/contract-version-bump/SKILL.md +1 -1
  93. package/skills/ecosystem-health/SKILL.md +4 -1
  94. package/skills/ecosystem-health/wizard.md +5 -0
  95. package/skills/evolve/SKILL.md +38 -1
  96. package/skills/journey-audit/SKILL.md +6 -5
  97. package/skills/reconcile/SKILL.md +5 -2
  98. package/skills/remote-offload/SKILL.md +89 -0
  99. package/skills/session-end/phase-3-6-tail.md +9 -6
  100. package/skills/session-start/SKILL.md +26 -3
  101. package/skills/wave-executor/SKILL.md +1 -1
  102. package/skills/wave-executor/wave-loop.md +43 -5
@@ -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. Read `input.transcript_path` (whole-session JSONL of assistant/user records),
16
- * scan the TAIL (last ~8 `type:"assistant"` records), concat text blocks.
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,19 @@
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 the transcript: the SubagentStop stdin payload has NO output_text
31
- * field. The agent's text lives in `input.transcript_path`.
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.
32
47
  *
33
48
  * Output channels — THREE writes, TWO different recipients:
34
49
  * - `discovery_validator_violation` in .orchestrator/metrics/events.jsonl,
@@ -55,6 +70,7 @@ import { promises as fs } from 'node:fs';
55
70
  import { tmpdir } from 'node:os';
56
71
  import path from 'node:path';
57
72
 
73
+ import { resolveSubagentSidecar } from './_lib/subagent-paths.mjs';
58
74
  import { appendJsonl } from '../scripts/lib/common.mjs';
59
75
  import { eventsFilePath } from '../scripts/lib/events.mjs';
60
76
  import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
@@ -226,6 +242,27 @@ const CARDINAL_RATIO_PATTERN = new RegExp(
226
242
  /** Inline-code spans are masked before the cardinal pattern runs. */
227
243
  const INLINE_CODE_RE = /`[^`\n]*`/g;
228
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
+
229
266
  /**
230
267
  * Commands that count as a MEASUREMENT inside a fenced block. `grep|rg|find`
231
268
  * (the #567 set) only covers text search; the #908 facts are measured with
@@ -463,9 +500,18 @@ function findViolations(text) {
463
500
 
464
501
  for (let i = 0; i < lines.length; i++) {
465
502
  const line = lines[i];
466
- let matched = CLAIM_PATTERNS.some((re) => re.test(line));
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));
467
514
  if (!matched && !fencedLines.has(i)) {
468
- const masked = line.replace(INLINE_CODE_RE, ' ');
469
515
  matched = CARDINAL_PATTERN.test(masked) || CARDINAL_RATIO_PATTERN.test(masked);
470
516
  }
471
517
  if (!matched) continue;
@@ -503,6 +549,58 @@ function firstNonEmptyString(input, keys, fallback) {
503
549
  return fallback;
504
550
  }
505
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
+
506
604
  /**
507
605
  * Sanitize a user/runtime-provided string for use in a tmp sentinel filename.
508
606
  *
@@ -524,23 +622,38 @@ function projectRootHash(projectRoot) {
524
622
  }
525
623
 
526
624
  /**
527
- * Build the dedup sentinel path for real project/session/agent contexts.
625
+ * Build the dedup sentinel path for real project/session/agent/claim contexts.
528
626
  * Missing fallback IDs intentionally return null so unrelated hooks/tests do
529
627
  * not collide on a global "unknown" key.
530
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
+ *
531
641
  * @param {object} opts
532
642
  * @param {string} opts.projectRoot
533
643
  * @param {string|null} opts.sessionId
534
- * @param {string|null} opts.agent
644
+ * @param {string|null} opts.agentId
645
+ * @param {string|null} opts.claimText
535
646
  * @returns {string|null}
536
647
  */
537
- function dedupSentinelPath({ projectRoot, sessionId, agent }) {
648
+ function dedupSentinelPath({ projectRoot, sessionId, agentId, claimText }) {
538
649
  if (typeof sessionId !== 'string' || !sessionId.trim()) return null;
539
- if (typeof agent !== 'string' || !agent.trim()) return null;
650
+ if (typeof agentId !== 'string' || !agentId.trim()) return null;
651
+ if (typeof claimText !== 'string' || !claimText.trim()) return null;
540
652
 
653
+ const claimHash = createHash('sha256').update(claimText).digest('hex').slice(0, 16);
541
654
  return path.join(
542
655
  tmpdir(),
543
- `psa006-${projectRootHash(projectRoot)}-${safeSentinelComponent(sessionId)}-${safeSentinelComponent(agent)}.lock`
656
+ `psa006-${projectRootHash(projectRoot)}-${safeSentinelComponent(sessionId)}-${safeSentinelComponent(agentId)}-${claimHash}.lock`
544
657
  );
545
658
  }
546
659
 
@@ -555,11 +668,19 @@ async function main() {
555
668
 
556
669
  if (!(await isEnabled())) return;
557
670
 
558
- const text = await readTranscriptTail(input.transcript_path);
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);
559
678
  const { violations, undatedVerified } = findViolations(text);
560
679
  if (violations.length === 0) return;
561
680
 
562
- const agentForDedup = firstNonEmptyString(input, ['agent_type'], null);
681
+ const agentForDedup =
682
+ firstNonEmptyString(input, ['agent_type', 'subagent_type'], null) ??
683
+ (await readAgentTypeFromMeta(sidecar.meta));
563
684
  const agent = agentForDedup ?? 'unknown';
564
685
  // session_id precedence: parent_session_id first, mirroring the sibling hook
565
686
  // hooks/subagent-telemetry.mjs (firstNonEmptyString(['parent_session_id',
@@ -567,10 +688,18 @@ async function main() {
567
688
  // parent_session_id` order disagreed with telemetry and could log the wrong id.
568
689
  const sessionId = firstNonEmptyString(input, ['parent_session_id', 'session_id'], null);
569
690
 
570
- // Project/session/agent deduplication: only emit additionalContext once for
571
- // repeated real contexts. Missing session IDs never create/read a sentinel,
572
- // so fallback traffic still surfaces warnings and cannot collide globally.
573
- const sentinel = dedupSentinelPath({ projectRoot: SO_PROJECT_DIR, sessionId, agent: agentForDedup });
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
+ });
574
703
 
575
704
  const filePath = eventsFilePath();
576
705
  for (const claim of violations) {
@@ -578,6 +707,7 @@ async function main() {
578
707
  event: 'discovery_validator_violation',
579
708
  timestamp: new Date().toISOString(),
580
709
  agent,
710
+ ...(agentId !== null ? { agent_id: agentId } : {}),
581
711
  ...(sessionId !== null ? { session_id: sessionId } : {}),
582
712
  claim_text: claim,
583
713
  });
@@ -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, writeFile, rename, mkdir } from 'node:fs/promises';
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';
@@ -42,6 +61,7 @@ if (!shouldRunHook('post-tool-batch-wave-signal')) process.exit(0);
42
61
  import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
43
62
  import { emitEvent } from '../scripts/lib/events.mjs';
44
63
  import { findScopeFile } from '../scripts/lib/scope-gate.mjs';
64
+ import { atomicMutateJson } from './_lib/atomic-json.mjs';
45
65
 
46
66
  // ---------------------------------------------------------------------------
47
67
  // Helpers
@@ -74,32 +94,6 @@ function readStdinJson() {
74
94
  });
75
95
  }
76
96
 
77
- /**
78
- * Atomic read-modify-write of a JSON file via temp + rename.
79
- * Reads the existing file (or starts with `defaultValue` when absent),
80
- * applies `mutate`, writes to a tmp file, then renames over the original.
81
- * Atomic on POSIX (same-filesystem rename). Best-effort on Windows.
82
- *
83
- * @param {string} filePath
84
- * @param {object} defaultValue — used when the file does not exist or is unparseable
85
- * @param {function(object): object} mutate — synchronous pure transformer
86
- */
87
- async function atomicMutateJson(filePath, defaultValue, mutate) {
88
- let current = defaultValue;
89
- try {
90
- const raw = await readFile(filePath, 'utf8');
91
- current = JSON.parse(raw);
92
- } catch {
93
- // File absent or unparseable — start from defaultValue.
94
- }
95
-
96
- const updated = mutate(current);
97
- const tmp = `${filePath}.tmp-ptb-${process.pid}-${Date.now()}`;
98
- await mkdir(path.dirname(filePath), { recursive: true });
99
- await writeFile(tmp, JSON.stringify(updated, null, 2) + '\n', 'utf8');
100
- await rename(tmp, filePath);
101
- }
102
-
103
97
  /**
104
98
  * Resolve the session-id for the heartbeat refresh.
105
99
  * Precedence:
@@ -135,6 +129,54 @@ async function resolveSessionIdForHeartbeat(input, sessionFile) {
135
129
  return null;
136
130
  }
137
131
 
132
+ /**
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
+
138
180
  /**
139
181
  * Resolve the current wave number from the wave-scope manifest's `.wave`.
140
182
  * Returns 0 when the file is absent or unparseable, mirroring the
@@ -208,10 +250,16 @@ async function main() {
208
250
 
209
251
  const sessionFile = path.join(SO_PROJECT_DIR, '.orchestrator', 'current-session.json');
210
252
 
211
- await atomicMutateJson(sessionFile, {}, (current) => ({
253
+ const lastBatchResult = await atomicMutateJson(sessionFile, {}, (current) => ({
212
254
  ...current,
213
255
  last_batch: lastBatch,
214
- }));
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
+ }
215
263
 
216
264
  // ----------------------------------------------------------------------
217
265
  // Heartbeat refresh (Epic #583 W3-P3, wires W2-I3 OQ2).
@@ -256,6 +304,32 @@ async function main() {
256
304
  ...(batchSize !== null ? { batch_size: batchSize } : {}),
257
305
  },
258
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
+ }
259
333
  } catch { /* best-effort — hook must remain non-blocking */ }
260
334
  } else if (waveSignal === null) {
261
335
  // ------------------------------------------------------------------
@@ -274,24 +348,41 @@ async function main() {
274
348
  // those windows — a drop to 0 (or any non-increase) is NOT a wave change
275
349
  // and is ignored, preventing spurious emissions on every batch.
276
350
  //
277
- // Final-wave limitation: the LAST wave never receives a `completed` event
278
- // here because there is no N+1 transition to trigger it. The coordinator
279
- // emits the final orchestrator.wave.completed at session close.
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.
280
359
  try {
281
360
  const wave = await resolveWaveNumber(SO_PROJECT_DIR);
282
361
  if (wave > 0) {
283
362
  // Read last_wave from the just-written session file (after the
284
363
  // last_batch RMW above, so we observe the latest persisted value).
285
364
  let lastWave = 0;
365
+ let lastWaveCompleted = 0;
286
366
  try {
287
367
  const raw = await readFile(sessionFile, 'utf8');
288
368
  const parsed = JSON.parse(raw);
289
369
  if (typeof parsed.last_wave === 'number') lastWave = parsed.last_wave;
290
- } catch { /* absent/unparseable lastWave stays 0 */ }
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 */ }
291
378
 
292
379
  if (wave > lastWave) {
293
- // Close the prior wave first (only when there was one).
294
- if (lastWave > 0) {
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) {
295
386
  await emitEvent('orchestrator.wave.completed', {
296
387
  wave_number: lastWave,
297
388
  ...(batchId !== null ? { batch_id: batchId } : {}),
@@ -305,11 +396,34 @@ async function main() {
305
396
  ...(batchId !== null ? { batch_id: batchId } : {}),
306
397
  ...(batchSize !== null ? { batch_size: batchSize } : {}),
307
398
  });
308
- // Persist the high-water mark so the next batch does not re-emit.
309
- await atomicMutateJson(sessionFile, {}, (current) => ({
310
- ...current,
311
- last_wave: wave,
312
- }));
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
+ }
313
427
  }
314
428
  }
315
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:
@@ -185,13 +185,19 @@ async function main() {
185
185
 
186
186
  // G7 — emit canonical event via emitEvent (single emission path: schema + webhook,
187
187
  // replacing the local hand-rolled appendFileSync bypass).
188
- await emitEvent('orchestrator.memory.propose_invoked', {
189
- session_id: sessionId,
190
- wave,
191
- argv_truncated: argvRedacted.slice(0, 512),
192
- cwd: process.cwd(),
193
- exit_code: null,
194
- });
188
+ // #1183 — a malformed record throws EventValidationError BEFORE any side
189
+ // effect (scripts/lib/events.mjs); this PreToolUse hook must never abort on
190
+ // that (it is deny-capable via emitAllow() below), so the emit is wrapped
191
+ // rather than left to propagate.
192
+ try {
193
+ await emitEvent('orchestrator.memory.propose_invoked', {
194
+ session_id: sessionId,
195
+ wave,
196
+ argv_truncated: argvRedacted.slice(0, 512),
197
+ cwd: process.cwd(),
198
+ exit_code: null,
199
+ });
200
+ } catch { /* telemetry never blocks the hook (#1183) */ }
195
201
 
196
202
  // Always allow — this is an observe-only audit hook
197
203
  return emitAllow();