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
@@ -9,10 +9,12 @@
9
9
  * G1 tool filter — only Edit/Write/MultiEdit are gated
10
10
  * G2 file_path present + string
11
11
  * G3 wave-scope.json exists
12
- * G3b (#1123) the manifest belongs to THIS session — a manifest that
12
+ * G3b (#1123, #1194) the manifest belongs to THIS session — a manifest that
13
13
  * PROVABLY names another live session in this shared working copy is not
14
14
  * ours to enforce; allow + emit one event. Runs AFTER the parse so a
15
- * corrupt manifest still fails closed.
15
+ * corrupt manifest still fails closed. Identity is PROCESS-LOCAL only
16
+ * (hook payload + CLAUDE_CODE_SESSION_ID), never the repo-global
17
+ * `session.lock` — see the gate's own block for why.
16
18
  * G4 path-guard gate enabled
17
19
  * G5 enforcement != "off"
18
20
  * G5b (#792) allowlist-first: an EXPLICIT absolute allowedPaths entry that
@@ -102,8 +104,10 @@ let readJson;
102
104
  let classifyEmptyScope;
103
105
  let suggestForEmptyScope;
104
106
  let sessionStartedAtMs;
105
- // #1123 — "is this manifest even mine?" (G3b).
106
- let readOwnSessionIds;
107
+ // #1123 — "is this manifest even mine?" (G3b). Process-local identity only
108
+ // (#1194): the repo-global `session.lock` tier is shared by every session in the
109
+ // checkout and would classify a peer's manifest as ours.
110
+ let readProcessLocalSessionIds;
107
111
  let classifyManifestSession;
108
112
 
109
113
  const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
@@ -180,7 +184,7 @@ async function bootstrap() {
180
184
  ({ findScopeFile, pathMatchesPattern, suggestForScopeViolation } = modules.hardening);
181
185
  ({ readJson } = modules.common);
182
186
  ({ classifyEmptyScope, suggestForEmptyScope, sessionStartedAtMs } = modules.scopeGate);
183
- ({ readOwnSessionIds, classifyManifestSession } = modules.sessionIdentity);
187
+ ({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
184
188
  }
185
189
 
186
190
  async function main() {
@@ -251,13 +255,38 @@ async function main() {
251
255
  // without a `session` field is `'unknown'` and stays enforced: only what is
252
256
  // PROVABLY foreign is treated as foreign.
253
257
  //
258
+ // IDENTITY TIER (#1194): `readProcessLocalSessionIds` — the hook payload and
259
+ // `CLAUDE_CODE_SESSION_ID` — and deliberately NOT `readOwnSessionIds`, whose
260
+ // third tier is the repo-global `session.lock`. That lock is ONE file shared
261
+ // by every session in the checkout, so unioning it made a peer's manifest
262
+ // match a peer-written lock id and classify as `'own'`: Gate 7 then denied the
263
+ // second session's legitimate writes — the exact lockout G3b exists to end.
264
+ // A better signal REPLACES a worse one (`host-resources.md` § HR-102).
265
+ //
266
+ // The trade this buys, accepted rather than hidden: a session that wrote a
267
+ // PEER's id into its OWN manifest (it lost the `bootstrapLock()` race, so
268
+ // `sessionAttribution()` handed it the peer's lock id) now reads `'foreign'`
269
+ // and its own guard stands down. The defense is on the WRITER side — the
270
+ // manifest writer must omit the `session` keys when the lock does not name it
271
+ // (`skills/wave-executor/wave-loop.md` § Scope Manifest, "Verify the binding
272
+ // names YOU before you write it") — not on this reader.
273
+ //
274
+ // CEILING (BV-004): on a harness that exports no session env var and puts no
275
+ // `session_id` in the hook payload (Codex CLI, Cursor today), both tiers are
276
+ // empty, so G3b is permanently `'unknown'` = enforce = pre-#1123 behaviour
277
+ // there. Revisit when Codex/Cursor hook payloads carry a session id.
278
+ //
254
279
  // ACCEPTED RESIDUAL, named rather than hidden: `session` is a plain field in a
255
280
  // file any process in this working copy can write, so writing a foreign id
256
281
  // into it switches this guard off for that manifest. That is the SAME power
257
282
  // `enforcement: "off"` already grants in the same file — this gate adds no new
258
283
  // authority, and the manifest is the coordinator's own artefact either way.
259
284
  {
260
- const ownIds = readOwnSessionIds(projectRoot, { hookInput: input });
285
+ // `new Set(...)` is load-bearing: `readProcessLocalSessionIds` returns a
286
+ // string[], and `classifyManifestSession` does `ownIds instanceof Set ?
287
+ // ownIds : new Set()` — a bare array would silently become EMPTY, making
288
+ // every manifest read `unknown`.
289
+ const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
261
290
  const { verdict, manifestIds } = classifyManifestSession(scope, ownIds);
262
291
  if (verdict === 'foreign') {
263
292
  // Observability only, and deliberately NOT emitWarn: this branch is hit on
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "echo '🎯 Session Orchestrator v3.23.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
+ "command": "echo '🎯 Session Orchestrator v3.24.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
11
11
  "async": false
12
12
  },
13
13
  {
package/hooks/hooks.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "echo '🎯 Session Orchestrator v3.23.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
9
+ "command": "echo '🎯 Session Orchestrator v3.24.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
10
  "async": false
11
11
  },
12
12
  {
@@ -11,7 +11,12 @@
11
11
  *
12
12
  * JSONL format (`.orchestrator/metrics/events.jsonl`):
13
13
  * {"timestamp":<ISO>,"event":"orchestrator.session.ended","session_id":"...","semantic_session_id":"...","reason":"<reason>","duration_ms":<int>}
14
- * (`session_id` / `semantic_session_id` are omitted when unresolvable — #1068 AC1.)
14
+ * (`session_id` / `semantic_session_id` are omitted when unresolvable — #1068 AC1.
15
+ * `duration_ms` likewise: it is written ONLY when the ending session IS the
16
+ * one `current-session.json` records AND a start timestamp parsed. A
17
+ * fabricated `0` reads as a measured zero-length session and is
18
+ * indistinguishable from one — 1082 of 1498 fleet records (72,2 %) carried
19
+ * exactly that zero, measured 2026-09-02. Omit, never fabricate.)
15
20
  *
16
21
  * Exit codes: 0 always (informational hook — must never block session teardown).
17
22
  * stdin: optional JSON { hook_event_name:"SessionEnd", session_id?, reason?, cwd? }.
@@ -46,6 +51,7 @@ import { deregisterSelf, logSweepEvent } from '../scripts/lib/session-registry.m
46
51
  import { readConfigFile, parseSessionConfig } from '../scripts/lib/config.mjs';
47
52
  import { flush } from '../scripts/lib/telemetry/sync.mjs';
48
53
  import { attemptLockReconciliation } from './_lib/lock-reconcile.mjs';
54
+ import { atomicMutateJson } from './_lib/atomic-json.mjs';
49
55
 
50
56
  // ---------------------------------------------------------------------------
51
57
  // stdin reading (inline — SessionEnd hooks exit 0 always, never deny)
@@ -84,6 +90,9 @@ async function readStdinJson() {
84
90
  * `.orchestrator/current-session.json` (written by on-session-start.mjs).
85
91
  * duration_ms is only computed when the ENDING session is the one recorded in
86
92
  * current-session.json — never fabricated for a mismatched / unknown session.
93
+ * Not measurable ⇒ `durationMs: null`, and the caller then OMITS the key
94
+ * entirely (#1193 W5 F1, the last site of the omit-never-fabricate class this
95
+ * session removed from `session.stopped` and `agent.stopped`).
87
96
  *
88
97
  * #1091 / Kanevry#66 — WRITER/READER SYMMETRY. `on-session-start.mjs`
89
98
  * (`resolveSessionId`, :316-317) accepts a stdin raw id only when
@@ -114,14 +123,42 @@ async function readStdinJson() {
114
123
  * session therefore resolves `semanticSessionId: null` rather than inheriting
115
124
  * another live session's backfill identity.
116
125
  *
126
+ * #1193 W4a review F-A — `isRecordedSession` is computed from the RAW stdin
127
+ * UUID, NEVER from the resolved `sessionId`. The old order was
128
+ * `if (sessionId === null) sessionId = recordedId;` followed by
129
+ * `sessionId === recordedId`, which is SELF-FULFILLING: whenever stdin carried
130
+ * no session_id (or a non-UUID one), the fallback assigned the file's own id
131
+ * and the compare then trivially succeeded. Reproduced twice 2026-09-02 —
132
+ * peer-owned `current-session.json` (`last_wave: 3`) plus stdin
133
+ * `{"reason":"other"}` emitted `wave.completed` for the PEER's wave and wrote
134
+ * `last_wave_completed: 3` into the PEER's file, after which the peer's own
135
+ * SessionEnd stayed silent. The same vacuous predicate had always gated
136
+ * `durationMs` and `semanticSessionId`, so the #863 guard was hollow on that
137
+ * path too. One root fix (BV-003) for all three consumers: a `null` raw id
138
+ * means "not attestable" ⇒ `false`.
139
+ *
140
+ * WHY `sessionId` STILL FALLS BACK while the predicate does not. The two are
141
+ * deliberately asymmetric. `sessionId` is the hook's ACTOR identity — the id
142
+ * `deregisterSelf()` and the lock-release ownership compare use, and its
143
+ * current-session.json fallback is a named contract pinned by
144
+ * `tests/hooks/on-session-end.test.mjs` ("falls back to current-session.json
145
+ * session_id when stdin omits it"). `isRecordedSession` is an OWNERSHIP
146
+ * ASSERTION about a repo-global file; an assertion may never be derived from
147
+ * the very value it is asserting about. So the fallback stays for the emitted
148
+ * `session_id` field, and every claim that speaks FOR the recorded session
149
+ * (`duration_ms`, `semantic_session_id`, the final `wave.completed`) is gated
150
+ * on the strict raw compare instead. Precedent: `hooks/on-stop.mjs`
151
+ * `resolveStopDuration()`, which refuses the resolved id for the same reason.
152
+ *
117
153
  * @param {object|null} input
118
154
  * @param {string} projectRoot
119
- * @returns {Promise<{sessionId: string|null, semanticSessionId: string|null, durationMs: number}>}
155
+ * @returns {Promise<{sessionId: string|null, semanticSessionId: string|null, durationMs: number|null, isRecordedSession: boolean, rawStdinId: string|null}>}
120
156
  */
121
157
  async function resolveSession(input, projectRoot) {
122
158
  const fromStdin = input?.session_id ?? input?.sessionId ?? null;
123
159
  // UUID-only, exactly as the writer decides it (see the docblock above).
124
- let sessionId = parseSessionId(fromStdin)?.format === 'uuid' ? fromStdin : null;
160
+ const rawStdinId = parseSessionId(fromStdin)?.format === 'uuid' ? fromStdin : null;
161
+ let sessionId = rawStdinId;
125
162
 
126
163
  let recordedId = null;
127
164
  let semanticSessionId = null;
@@ -144,13 +181,17 @@ async function resolveSession(input, projectRoot) {
144
181
  }
145
182
  } catch { /* missing or unparseable is fine */ }
146
183
 
184
+ // Actor-identity fallback ONLY — see the docblock's asymmetry note. This
185
+ // value must NEVER feed the ownership predicate below.
147
186
  if (sessionId === null) sessionId = recordedId;
148
187
 
149
- // Only trust the recorded start time when the ending session IS the recorded one.
150
- const isRecordedSession = sessionId !== null && sessionId === recordedId;
188
+ // Only trust the recorded start time when the ending session IS the recorded
189
+ // one decided on the RAW stdin id, so an absent/non-UUID id is `false`
190
+ // rather than self-fulfilling (F-A).
191
+ const isRecordedSession = rawStdinId !== null && rawStdinId === recordedId;
151
192
  const durationMs = startedAtMs !== null && isRecordedSession
152
193
  ? Math.max(0, Date.now() - startedAtMs)
153
- : 0;
194
+ : null;
154
195
 
155
196
  // #863 defect (c) — same guard as durationMs above: only surface the
156
197
  // recorded semantic id when THIS ending session is genuinely the one
@@ -158,7 +199,16 @@ async function resolveSession(input, projectRoot) {
158
199
  // contamination scenario this closes.
159
200
  const resolvedSemanticSessionId = isRecordedSession ? semanticSessionId : null;
160
201
 
161
- return { sessionId, semanticSessionId: resolvedSemanticSessionId, durationMs };
202
+ // `rawStdinId` is returned so a LATER read of current-session.json can
203
+ // re-verify ownership against the same identity (W4c Q1-LOW-TOCTOU) instead
204
+ // of trusting an attestation made against an earlier read of the file.
205
+ return {
206
+ sessionId,
207
+ semanticSessionId: resolvedSemanticSessionId,
208
+ durationMs,
209
+ isRecordedSession,
210
+ rawStdinId,
211
+ };
162
212
  }
163
213
 
164
214
  /**
@@ -307,6 +357,194 @@ async function emitBackfillOutcome(kind, result, { sessionId, semanticSessionId
307
357
  } catch { /* observability is best-effort */ }
308
358
  }
309
359
 
360
+ /**
361
+ * Emit `orchestrator.wave.final_refused` — the SIBLING event to
362
+ * `orchestrator.wave.completed` for every refusal path inside
363
+ * {@link emitFinalWaveCompleted} (#1201 Part B / Discovery D8). Deliberately
364
+ * a separate event name rather than `orchestrator.wave.completed` carrying
365
+ * `emitted:false`: existing consumers of `.completed` treat every row as a
366
+ * finished wave, and overloading it would silently corrupt that count.
367
+ *
368
+ * Wrapped in its OWN try/catch — independent of the caller's outer
369
+ * try/catch — so a telemetry failure on ONE refusal path can never surface
370
+ * as a failure of teardown, matching the best-effort contract every other
371
+ * emit in this SessionEnd hook already carries.
372
+ *
373
+ * @param {{sessionId: string|null, semanticSessionId: string|null}} ids
374
+ * @param {'not-recorded'|'clear'|'resume'|'unreadable'|'session-id-mismatch'|'no-wave'|'already-completed'|'exception'} reason
375
+ * @param {number} [waveNumber] - only when `last_wave` was resolved to a
376
+ * positive number before the refusal (currently only `already-completed`).
377
+ * @returns {Promise<void>}
378
+ */
379
+ async function emitFinalRefused({ sessionId, semanticSessionId }, reason, waveNumber) {
380
+ try {
381
+ await emitEvent('orchestrator.wave.final_refused', {
382
+ ...(sessionId !== null ? { session_id: sessionId } : {}),
383
+ ...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
384
+ reason,
385
+ ...(typeof waveNumber === 'number' ? { wave_number: waveNumber } : {}),
386
+ emitted_by: 'on-session-end',
387
+ });
388
+ } catch { /* best-effort — a refusal record must never itself block teardown */ }
389
+ }
390
+
391
+ /**
392
+ * Emit the FINAL `orchestrator.wave.completed` of the session (#1193).
393
+ *
394
+ * `hooks/post-tool-batch-wave-signal.mjs` closes wave N-1 only at an N-1→N
395
+ * transition, so the LAST wave of every session never received a completion —
396
+ * measured fleet-wide 2026-09-02 as 296 gaps over 296 wave runs (1018 started
397
+ * vs 722 completed), i.e. EXACTLY one missing final completion per run. The
398
+ * comment that claimed the coordinator emitted it at session close described a
399
+ * step that never existed. SessionEnd is that emitter.
400
+ *
401
+ * Deliberately SessionEnd-only: `on-stop.mjs` is not mirrored, so the ledger
402
+ * keeps the closed-vs-abandoned split measurable.
403
+ *
404
+ * Idempotent via the `last_wave_completed` high-water mark, written by both
405
+ * emitters and preserved across clear/compact by `on-session-start.mjs`.
406
+ * Emits nothing when `last_wave` is absent or 0 — an Express-Path or
407
+ * coordinator-direct session never batched, and zero waves is the correct
408
+ * reading there, not a gap.
409
+ *
410
+ * OWNERSHIP-GATED (#1193 review F1). `.orchestrator/current-session.json` is a
411
+ * single repo-global file describing whichever session most recently ran
412
+ * SessionStart — routinely a DIFFERENT, still-live session when two windows
413
+ * share this working copy. Emitting unguarded would (a) close a PEER's live
414
+ * wave with a completion the peer never reached, and (b) write
415
+ * `last_wave_completed` into the peer's file, so the peer's own SessionEnd then
416
+ * stays silent — preserving the very #1193 gap this closes, on the wrong
417
+ * session. So this reuses the SAME `isRecordedSession` predicate
418
+ * `resolveSession()` applies to `durationMs` and `semanticSessionId` (#863
419
+ * defect (c)); when it is false, nothing is emitted and nothing is written.
420
+ *
421
+ * REASON-GATED for `/clear` AND `resume` (#1193 review F2 + W4c Q3-MED-2). The
422
+ * SessionEnd matcher is empty, so `/clear` fires this hook mid-wave while the
423
+ * LOGICAL session continues (`hooks/on-session-start.mjs:383` preserves
424
+ * `last_wave` / `last_wave_completed` across exactly that). A `resume` is the
425
+ * SAME class — start preserves the marker across a resume of the same logical
426
+ * session just as it does across a clear — and resume is the MORE common of the
427
+ * two (fleet n = 1498 `session.ended`, 2026-09-02: 12 resume vs 9 clear).
428
+ * Closing the live wave on either is premature, and the preserved marker would
429
+ * then suppress the real completion later. Fleet `session.ended` reasons, re-measured
430
+ * 2026-09-02 over every repo's `.orchestrator/metrics/events.jsonl` under
431
+ * `~/Projects` (glob written as a path segment on purpose — a literal star
432
+ * followed by a slash would close this comment), except
433
+ * `EventDrop.at-deps-2026-09` — n = 1335: 1286 other, 27 completed, 12 resume,
434
+ * 8 clear, 1 error, 1 close. (Same denominator and date as the
435
+ * `session.ended` row in `docs/audits/2026-09-02-fleet-instruments.md`; the two
436
+ * disagreed by ~150 before W4a F-F because each counted a different repo set.)
437
+ *
438
+ * Strictly best-effort: never throws, never blocks teardown.
439
+ *
440
+ * SIX silent refusal paths (#1201 Part B / Discovery D8 — one more than the
441
+ * issue's original five) previously returned with no trace anywhere: per
442
+ * `.claude/rules/host-resources.md` § HR-105, a refusal that writes nothing
443
+ * is unfalsifiable. Every refusal now emits a SIBLING event,
444
+ * `orchestrator.wave.final_refused`, via {@link emitFinalRefused} — never
445
+ * `orchestrator.wave.completed` itself with an `emitted:false` flag, because
446
+ * that event's consumers treat every row as a finished wave (D8's explicit
447
+ * recommendation). Each emit is wrapped in its OWN try/catch inside
448
+ * `emitFinalRefused` so telemetry can never block teardown, on top of this
449
+ * function's own outer catch (which now also emits `reason: 'exception'`,
450
+ * best-effort).
451
+ *
452
+ * @param {string} projectRoot
453
+ * @param {{sessionId: string|null, semanticSessionId: string|null,
454
+ * isRecordedSession: boolean, reason: string,
455
+ * rawStdinId: string|null}} ctx
456
+ * @returns {Promise<{emitted: true, wave_number: number}|{emitted: false, reason: string}>}
457
+ * The return value is informational only — every branch has already
458
+ * persisted its own outcome via `orchestrator.wave.completed` or
459
+ * `orchestrator.wave.final_refused` by the time this resolves.
460
+ */
461
+ async function emitFinalWaveCompleted(
462
+ projectRoot,
463
+ { sessionId, semanticSessionId, isRecordedSession, reason, rawStdinId },
464
+ ) {
465
+ const ids = { sessionId, semanticSessionId };
466
+ try {
467
+ // F1 — never speak for a session current-session.json does not describe.
468
+ if (!isRecordedSession) {
469
+ await emitFinalRefused(ids, 'not-recorded');
470
+ return { emitted: false, reason: 'not-recorded' };
471
+ }
472
+ // F2 — `/clear` ends the HARNESS session, not the logical one. `resume` is
473
+ // the SAME class (W4c Q3-MED-2): `on-session-start.mjs` preserves
474
+ // `last_wave` / `last_wave_completed` across a resume of the same logical
475
+ // session exactly as it does across a clear, and resume is the MORE common
476
+ // of the two (fleet n=1498, 2026-09-02: 12 resume vs 9 clear).
477
+ if (reason === 'clear' || reason === 'resume') {
478
+ await emitFinalRefused(ids, reason);
479
+ return { emitted: false, reason };
480
+ }
481
+ const sessionFile = path.join(projectRoot, '.orchestrator', 'current-session.json');
482
+ let parsed = null;
483
+ try {
484
+ parsed = JSON.parse(await fs.readFile(sessionFile, 'utf8'));
485
+ } catch {
486
+ // absent or malformed → nothing attestable to close
487
+ await emitFinalRefused(ids, 'unreadable');
488
+ return { emitted: false, reason: 'unreadable' };
489
+ }
490
+
491
+ // W4c Q1-LOW-TOCTOU — `isRecordedSession` was attested against the FIRST
492
+ // read of this file (in `resolveSession()`); the values acted on below come
493
+ // from this SECOND read. Re-verify ownership here rather than inheriting a
494
+ // stale attestation. A genuine swap BETWEEN the two reads is not testable
495
+ // without a seam, and none is added for it — the peer-id case pins the
496
+ // re-check, and this predicate is what makes the window harmless.
497
+ if (rawStdinId === null || parsed?.session_id !== rawStdinId) {
498
+ await emitFinalRefused(ids, 'session-id-mismatch');
499
+ return { emitted: false, reason: 'session-id-mismatch' };
500
+ }
501
+
502
+ const lastWave = parsed?.last_wave;
503
+ if (typeof lastWave !== 'number' || !(lastWave > 0)) {
504
+ await emitFinalRefused(ids, 'no-wave');
505
+ return { emitted: false, reason: 'no-wave' };
506
+ }
507
+ // W4c Q3-MED-3(iii) / Q3-LOW-4 — strictly ABOVE the high-water mark, not
508
+ // merely different from it: a marker AHEAD of `last_wave` (written by the
509
+ // batch hook's explicit `wave-complete{N}` branch) means this wave is
510
+ // already closed, and a non-integer marker (`'3'`, `null`) counts as
511
+ // ABSENT rather than as "different".
512
+ const marker = Number.isInteger(parsed?.last_wave_completed) ? parsed.last_wave_completed : 0;
513
+ if (!(lastWave > marker)) {
514
+ await emitFinalRefused(ids, 'already-completed', lastWave);
515
+ return { emitted: false, reason: 'already-completed' };
516
+ }
517
+
518
+ await emitEvent('orchestrator.wave.completed', {
519
+ ...(sessionId !== null ? { session_id: sessionId } : {}),
520
+ ...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
521
+ wave_number: lastWave,
522
+ reason: 'session-end',
523
+ emitted_by: 'on-session-end',
524
+ });
525
+
526
+ // Monotone, exactly as the batch hook's `maxWave()` — the mark may only
527
+ // ever rise, whichever of the three writers gets here last (W4c Q3-MED-3).
528
+ const markResult = await atomicMutateJson(sessionFile, {}, (current) => ({
529
+ ...current,
530
+ last_wave_completed: Number.isInteger(current?.last_wave_completed)
531
+ && current.last_wave_completed > lastWave
532
+ ? current.last_wave_completed
533
+ : lastWave,
534
+ }), 'ose');
535
+ // The wave.completed EVENT above already fired regardless — this only
536
+ // withholds the shared-file high-water mark on a non-ENOENT failure.
537
+ if (!markResult.ok) {
538
+ console.error(`on-session-end: last_wave_completed mark skipped (${markResult.reason})`);
539
+ }
540
+ return { emitted: true, wave_number: lastWave };
541
+ } catch {
542
+ // best-effort — a SessionEnd hook must never block teardown
543
+ await emitFinalRefused(ids, 'exception');
544
+ return { emitted: false, reason: 'exception' };
545
+ }
546
+ }
547
+
310
548
  // ---------------------------------------------------------------------------
311
549
  // main
312
550
  // ---------------------------------------------------------------------------
@@ -317,7 +555,8 @@ async function main() {
317
555
 
318
556
  const reason =
319
557
  typeof input?.reason === 'string' && input.reason.length > 0 ? input.reason : 'other';
320
- const { sessionId, semanticSessionId, durationMs } = await resolveSession(input, projectRoot);
558
+ const { sessionId, semanticSessionId, durationMs, isRecordedSession, rawStdinId } =
559
+ await resolveSession(input, projectRoot);
321
560
 
322
561
  // Single emission path: emitEvent writes the canonical {timestamp, event, ...payload}
323
562
  // JSONL record AND fires the optional Clank webhook with the SAME event name.
@@ -332,13 +571,40 @@ async function main() {
332
571
  // attestation bar is the #863 defect (c) guard inside `resolveSession()`: an
333
572
  // ending session that is NOT the one current-session.json describes resolves
334
573
  // `semanticSessionId: null` and therefore emits no key here.
335
- await emitEvent('orchestrator.session.ended', {
336
- ...(sessionId !== null ? { session_id: sessionId } : {}),
337
- ...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
574
+ // #1193 — close the last wave BEFORE the terminal session event, so the
575
+ // ledger's wave lifecycle is balanced within the session's own window. Gated
576
+ // on the SAME `isRecordedSession` attestation as the identity keys above, and
577
+ // skipped for `reason === 'clear'` and `reason === 'resume'` alike — both end
578
+ // the HARNESS session while the LOGICAL one continues; see the emitter's docblock.
579
+ //
580
+ // #1201 Part B — the return is `{emitted:true, wave_number}` or
581
+ // `{emitted:false, reason}`, but it is intentionally NOT branched on here:
582
+ // both outcomes already persisted themselves (`orchestrator.wave.completed`
583
+ // or the sibling `orchestrator.wave.final_refused`) before this call
584
+ // resolves, so nothing downstream in this hook needs to react to it.
585
+ await emitFinalWaveCompleted(projectRoot, {
586
+ sessionId,
587
+ semanticSessionId,
588
+ isRecordedSession,
338
589
  reason,
339
- duration_ms: durationMs,
590
+ rawStdinId,
340
591
  });
341
592
 
593
+ // #1183 — a malformed record throws EventValidationError BEFORE any side
594
+ // effect (scripts/lib/events.mjs); this hook must never abort on that, so
595
+ // the emit is wrapped rather than left to propagate.
596
+ try {
597
+ await emitEvent('orchestrator.session.ended', {
598
+ ...(sessionId !== null ? { session_id: sessionId } : {}),
599
+ ...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
600
+ reason,
601
+ // Omit-never-fabricate: only a MEASURED span is written. `null` here means
602
+ // the ending session is not the recorded one (or no start time parsed) —
603
+ // absence must stay absence, not become a zero-length session.
604
+ ...(Number.isFinite(durationMs) && durationMs >= 0 ? { duration_ms: durationMs } : {}),
605
+ });
606
+ } catch { /* telemetry never blocks the hook (#1183) */ }
607
+
342
608
  // -------------------------------------------------------------------------
343
609
  // C1 (#724) — Close-through backfill + deterministic lock release.
344
610
  // Both are STRICTLY best-effort: a SessionEnd hook must never block teardown
@@ -124,6 +124,30 @@ function shortSessionId(id) {
124
124
  return id.length > 24 ? id.slice(0, 8) : id;
125
125
  }
126
126
 
127
+ /**
128
+ * GH#67 — render the provenance marker for ONE mechanically-detected peer.
129
+ *
130
+ * `discoverActiveSessions()` annotates registry-sourced peers additively
131
+ * (`registryOnly` / `lockSuperseded` / `lockOwnerId`); lock-sourced peers carry
132
+ * none of the three, so they render byte-identically to the pre-GH#67 banner.
133
+ *
134
+ * `lockSuperseded: true` means a LIVE lock at THIS root is held by a different
135
+ * raw session_id than the registry entry — the common cause is a finished task
136
+ * whose SessionEnd never ran, leaving a fresh-but-orphaned registry record.
137
+ * It is a HINT, never a verdict: the session lock is advisory, so the entry may
138
+ * equally be a live peer that lost the acquire race (#1085). The marker is
139
+ * therefore purely additive — it never removes the peer, and never changes the
140
+ * count or the WARN decision (HR-106).
141
+ *
142
+ * @param {{registryOnly?:boolean, lockSuperseded?:boolean, lockOwnerId?:string|null}} peer
143
+ * @returns {string} '' for a lock-sourced peer, else a bracketed marker.
144
+ */
145
+ function supersessionMarker(peer) {
146
+ if (!peer || peer.registryOnly !== true) return '';
147
+ if (peer.lockSuperseded === true) return ' [registry-only, superseded]';
148
+ return ' [registry-only]';
149
+ }
150
+
127
151
  /**
128
152
  * Queue the ONE `additionalContext` string for the single end-of-hook flush.
129
153
  * Last writer wins; there is deliberately no accumulation, because every
@@ -380,6 +404,12 @@ async function resolveSessionId(input, projectRoot) {
380
404
  if (Object.prototype.hasOwnProperty.call(prev, 'last_batch')) {
381
405
  preserved.last_batch = prev.last_batch;
382
406
  }
407
+ // #1193 — the final-wave completion marker must survive a
408
+ // clear/compact too: dropping it re-arms a duplicate SessionEnd
409
+ // `orchestrator.wave.completed` for a wave already closed.
410
+ if (Object.prototype.hasOwnProperty.call(prev, 'last_wave_completed')) {
411
+ preserved.last_wave_completed = prev.last_wave_completed;
412
+ }
383
413
  }
384
414
  } catch { /* absent / unparseable → no preservation (reset) */ }
385
415
  }
@@ -738,6 +768,11 @@ async function main() {
738
768
  } catch { /* hook must remain non-blocking */ }
739
769
 
740
770
  let peers = [];
771
+ // GH#67 consumer half — how many mechanically-detected peers were registry-
772
+ // sourced entries that a LIVE lock at this root supersedes. Counted for the
773
+ // session.started payload below; NEVER subtracted from any peer count
774
+ // (HR-106: the banner reports the number the verdict judged).
775
+ let mechanicalPeersSuperseded = 0;
741
776
  try {
742
777
  await sweepZombies().catch(() => ({ removed: [], logged: 0 }));
743
778
  try {
@@ -839,6 +874,7 @@ async function main() {
839
874
  const { discoverActiveSessions } = await import('../scripts/lib/session-discovery.mjs');
840
875
  const allActive = await discoverActiveSessions(projectRoot);
841
876
  const mechanicalPeers = allActive.filter((s) => s.sessionId !== sessionId);
877
+ mechanicalPeersSuperseded = mechanicalPeers.filter((p) => p.lockSuperseded === true).length;
842
878
  if (mechanicalPeers.length > 0) {
843
879
  // #1137 part 1 — say WHERE, not just how many. discoverActiveSessions()
844
880
  // walks EVERY path `git worktree list` reports, which includes worktrees
@@ -862,11 +898,14 @@ async function main() {
862
898
  // a lock written without the field yields undefined here, while
863
899
  // sessionFromRegistryEntry() always defaults it to 'session'.
864
900
  const mode = typeof p.mode === 'string' && p.mode.length > 0 ? `:${p.mode}` : '';
865
- return `${where}:${shortSessionId(p.sessionId)}${mode}`;
901
+ return `${where}:${shortSessionId(p.sessionId)}${mode}${supersessionMarker(p)}`;
866
902
  })
867
903
  .join(', ');
868
904
  const overflow = mechanicalPeers.length > 3 ? ` +${mechanicalPeers.length - 3} more` : '';
869
905
  pushBanner(`🔍 Mechanical peer-detection: ${mechanicalPeers.length} active in this repo's worktree set (${summary}${overflow})`);
906
+ if (mechanicalPeersSuperseded > 0) {
907
+ pushBanner(` ${mechanicalPeersSuperseded} of them registry-only and superseded by this root's live lock — likely a finished task without SessionEnd (GH#67). Still counted above: the session lock is advisory.`);
908
+ }
870
909
  }
871
910
  } catch { /* best effort — banner is informational, never blocks */ }
872
911
  }
@@ -914,6 +953,10 @@ async function main() {
914
953
  branch,
915
954
  session_id: sessionId,
916
955
  peer_count: peers.length,
956
+ // GH#67 — additive: how many mechanically-detected peers a live lock at
957
+ // this root supersedes. `peer_count` is deliberately unchanged; this field
958
+ // makes the supersession rate measurable instead of inferred (HR-105).
959
+ peers_superseded: mechanicalPeersSuperseded,
917
960
  };
918
961
  if (bannerData) {
919
962
  payload.host_class = bannerData.host.host_class;
@@ -935,7 +978,12 @@ async function main() {
935
978
  payload.memory_pressure_pct_free = bannerData.resources.memory_pressure_pct_free ?? null;
936
979
  payload.peer_sessions_count = bannerData.resources.peer_sessions_count ?? null;
937
980
  }
938
- await emitEvent('orchestrator.session.started', payload);
981
+ // #1183 — a malformed record throws EventValidationError BEFORE any side
982
+ // effect (scripts/lib/events.mjs); this hook must never abort on that, so
983
+ // the emit is wrapped rather than left to propagate.
984
+ try {
985
+ await emitEvent('orchestrator.session.started', payload);
986
+ } catch { /* telemetry never blocks the hook (#1183) */ }
939
987
 
940
988
  // #1138 — one-time telemetry-consent nudge. Queued (never written) here so it
941
989
  // rides the single stdout envelope below.