session-orchestrator 3.20.0 → 3.22.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 (202) 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/rules/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/030-wave-execution.mdc +10 -8
  6. package/.cursor/rules/040-discovery.mdc +6 -6
  7. package/.cursor/rules/050-plan.mdc +8 -8
  8. package/CHANGELOG.md +515 -0
  9. package/README.md +16 -11
  10. package/agents/analyst.md +1 -1
  11. package/agents/architect-reviewer.md +1 -1
  12. package/agents/code-implementer.md +4 -2
  13. package/agents/db-specialist.md +1 -1
  14. package/agents/dialectic-deriver.md +1 -1
  15. package/agents/docs-writer.md +1 -1
  16. package/agents/memory-proposal-collector.md +7 -5
  17. package/agents/qa-strategist.md +1 -1
  18. package/agents/security-reviewer.md +1 -1
  19. package/agents/session-reviewer.md +42 -1
  20. package/agents/skill-applied-judge.md +1 -1
  21. package/agents/test-writer.md +1 -1
  22. package/agents/ui-developer.md +1 -1
  23. package/agents/ux-evaluator.md +1 -1
  24. package/commands/eli5.md +33 -0
  25. package/commands/release.md +62 -0
  26. package/commands/test.md +2 -2
  27. package/docs/components.md +6 -5
  28. package/docs/migration-v3.md +9 -6
  29. package/docs/persona-panel.md +3 -1
  30. package/docs/scope-collision-guard.md +167 -0
  31. package/docs/session-config-reference.md +31 -8
  32. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  33. package/hooks/enforce-scope.mjs +103 -3
  34. package/hooks/hooks-codex.json +1 -1
  35. package/hooks/hooks.json +21 -1
  36. package/hooks/on-session-end.mjs +76 -97
  37. package/hooks/on-session-start.mjs +195 -104
  38. package/hooks/on-stop.mjs +127 -12
  39. package/hooks/post-bash-write-verify.mjs +8 -32
  40. package/hooks/pre-auq-clarity.mjs +787 -0
  41. package/hooks/pre-bash-issue-budget.mjs +17 -18
  42. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  43. package/package.json +3 -1
  44. package/pi/prompts/eli5.md +12 -0
  45. package/pi/prompts/release.md +12 -0
  46. package/scripts/auq-audit.mjs +825 -0
  47. package/scripts/autopilot.mjs +10 -9
  48. package/scripts/emit-session.mjs +42 -0
  49. package/scripts/export-hw-learnings.mjs +61 -2
  50. package/scripts/lib/auq/clarity.mjs +1314 -0
  51. package/scripts/lib/auq/parse.mjs +1006 -0
  52. package/scripts/lib/auq/schema.mjs +1457 -0
  53. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  54. package/scripts/lib/backlog-scan.mjs +106 -15
  55. package/scripts/lib/build-live-signals.mjs +7 -3
  56. package/scripts/lib/ci-status-banner.mjs +267 -77
  57. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  58. package/scripts/lib/config/vault-integration.mjs +12 -1
  59. package/scripts/lib/dispatcher/rank.mjs +4 -7
  60. package/scripts/lib/gates/gate-full.mjs +3 -3
  61. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  62. package/scripts/lib/git-config-drift.mjs +471 -0
  63. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  64. package/scripts/lib/io.mjs +432 -7
  65. package/scripts/lib/issue-budget.mjs +63 -9
  66. package/scripts/lib/learnings/select.mjs +157 -3
  67. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  68. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  69. package/scripts/lib/named-vault-resolver.mjs +105 -16
  70. package/scripts/lib/owner-interview.mjs +78 -32
  71. package/scripts/lib/peer-cards/schema.mjs +6 -2
  72. package/scripts/lib/peer-discovery.mjs +73 -22
  73. package/scripts/lib/project-hygiene.mjs +64 -4
  74. package/scripts/lib/reconcile/renderer.mjs +17 -4
  75. package/scripts/lib/reconcile/writer.mjs +69 -30
  76. package/scripts/lib/redact-spans.mjs +89 -0
  77. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  78. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  79. package/scripts/lib/resource-probe.mjs +18 -2
  80. package/scripts/lib/scope-baseline.mjs +77 -17
  81. package/scripts/lib/scope-gate.mjs +658 -0
  82. package/scripts/lib/secret-masker.mjs +262 -0
  83. package/scripts/lib/session-lock.mjs +34 -10
  84. package/scripts/lib/session-registry.mjs +9 -1
  85. package/scripts/lib/spiral-carryover.mjs +23 -2
  86. package/scripts/lib/state-md/mission-status.mjs +164 -58
  87. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  88. package/scripts/lib/validate/check-agents.mjs +77 -5
  89. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  90. package/scripts/lib/validate/check-commands.mjs +2 -20
  91. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
  93. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  94. package/scripts/lib/validate/check-rules.mjs +153 -9
  95. package/scripts/lib/validate/check-skills.mjs +191 -0
  96. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  97. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  98. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  99. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  100. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  101. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  102. package/scripts/lib/validate/tier-inference.mjs +46 -8
  103. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  104. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  105. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  106. package/scripts/lib/vault-mirror/process.mjs +264 -31
  107. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  108. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  109. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  110. package/scripts/lib/vcs-repo-spec.mjs +680 -30
  111. package/scripts/lib/wave-resource-gate.mjs +67 -73
  112. package/scripts/materialize-wave-scope.mjs +281 -0
  113. package/scripts/print-learnings-index.mjs +30 -3
  114. package/scripts/release.mjs +983 -107
  115. package/scripts/run-quality-gate.mjs +14 -0
  116. package/scripts/site-numbers.mjs +1049 -0
  117. package/scripts/validate-plugin.mjs +64 -0
  118. package/scripts/validate-wave-scope.mjs +286 -12
  119. package/scripts/vault-backfill.mjs +32 -5
  120. package/scripts/vault-mirror.mjs +26 -1
  121. package/skills/_shared/monitor-patterns.md +24 -4
  122. package/skills/_shared/parallel-aware-auq.md +30 -24
  123. package/skills/_shared/parallel-aware-preamble.md +31 -2
  124. package/skills/_shared/state-ownership.md +49 -6
  125. package/skills/bootstrap/SKILL.md +2 -1
  126. package/skills/brainstorm/SKILL.md +18 -18
  127. package/skills/brainstorm/soul.md +12 -0
  128. package/skills/claude-md-drift-check/SKILL.md +9 -1
  129. package/skills/debug/SKILL.md +4 -1
  130. package/skills/discovery/SKILL.md +28 -24
  131. package/skills/discovery/issue-templates.md +4 -4
  132. package/skills/discovery/probes-code.md +2 -2
  133. package/skills/discovery/probes-feature.md +6 -6
  134. package/skills/discovery/probes-infra.md +2 -2
  135. package/skills/discovery/probes-session.md +5 -5
  136. package/skills/dispatcher/SKILL.md +10 -1
  137. package/skills/eli5/SKILL.md +43 -0
  138. package/skills/evolve/SKILL.md +8 -9
  139. package/skills/frontmatter-guard/SKILL.md +9 -1
  140. package/skills/gitlab-ops/SKILL.md +73 -59
  141. package/skills/gitlab-portfolio/SKILL.md +10 -1
  142. package/skills/grill/SKILL.md +6 -6
  143. package/skills/grill/soul.md +16 -0
  144. package/skills/memory-cleanup/SKILL.md +20 -7
  145. package/skills/npm-publish/SKILL.md +23 -51
  146. package/skills/peekaboo-driver/SKILL.md +3 -3
  147. package/skills/persona-panel/SKILL.md +3 -1
  148. package/skills/plan/SKILL.md +18 -16
  149. package/skills/plan/mode-feature.md +1 -1
  150. package/skills/plan/mode-new.md +42 -12
  151. package/skills/plan/soul.md +12 -0
  152. package/skills/reconcile/SKILL.md +3 -3
  153. package/skills/repo-audit/SKILL.md +10 -1
  154. package/skills/session-end/SKILL.md +97 -22
  155. package/skills/session-end/metrics-collection.md +1 -1
  156. package/skills/session-end/phase-3-6-tail.md +37 -2
  157. package/skills/session-end/session-metrics-write.md +4 -10
  158. package/skills/session-plan/SKILL.md +2 -2
  159. package/skills/session-plan/wave-template.md +1 -1
  160. package/skills/session-start/SKILL.md +82 -36
  161. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  162. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  163. package/skills/session-start/soul.md +110 -0
  164. package/skills/spinout/SKILL.md +5 -1
  165. package/skills/sunset-review/SKILL.md +11 -1
  166. package/skills/test-runner/SKILL.md +2 -2
  167. package/skills/tmux-layout/SKILL.md +7 -2
  168. package/skills/using-orchestrator/SKILL.md +1 -1
  169. package/skills/vault-mirror/SKILL.md +10 -1
  170. package/skills/vault-sync/SKILL.md +10 -1
  171. package/skills/vault-sync/validator.mjs +55 -6
  172. package/skills/wave-executor/wave-loop.md +64 -12
  173. package/skills/write-executable-plan/SKILL.md +6 -6
  174. package/scripts/lib/mission-status-schema.mjs +0 -114
  175. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  176. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  177. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  178. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  179. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  180. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  181. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  182. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  183. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  184. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  185. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  186. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  187. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  188. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  189. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  190. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  191. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  192. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  193. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  194. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  195. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  196. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  197. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  198. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  199. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  200. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  201. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  202. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -34,11 +34,10 @@ import { backfillAbandonedSession } from '../scripts/lib/session-close-backfill.
34
34
  import {
35
35
  readLockDetailed,
36
36
  release,
37
- isLockLive,
38
37
  loadOwnerProof,
39
- isLockOwnedByProof,
40
38
  OWNER_PROOF_RELPATH,
41
39
  } from '../scripts/lib/session-lock.mjs';
40
+ import { deregisterSelf, logSweepEvent } from '../scripts/lib/session-registry.mjs';
42
41
  import { attemptLockReconciliation } from './_lib/lock-reconcile.mjs';
43
42
 
44
43
  // ---------------------------------------------------------------------------
@@ -82,20 +81,16 @@ async function readStdinJson() {
82
81
  * `semanticSessionId` is read from current-session.json (present since #587):
83
82
  * it is the SEMANTIC id (`<branch>-<date>-<mode>-<n>`) that sessions.jsonl is
84
83
  * keyed by, and the id the backfill (C1 #724) uses when the stdin session_id is
85
- * a harness UUID with no lock.acquired bridge.
84
+ * a harness UUID with no lock.acquired bridge. It is never a lock-release
85
+ * ownership identity; only the raw/native `sessionId` may release a live lock.
86
86
  *
87
87
  * #863 defect (c) — `semanticSessionId` is gated by the SAME "ending session
88
88
  * IS the recorded one" check as `durationMs` above. `current-session.json` is
89
89
  * a single repo-global file: it always reflects whichever session most
90
90
  * recently ran SessionStart, which may be a DIFFERENT, still-live session
91
- * when multiple windows share this repo. Before this fix, a foreign
92
- * terminating window (its own stdin `session_id` explicitly present, but NOT
93
- * equal to the recorded session) still inherited the CURRENTLY-recorded
94
- * session's `semantic_session_id` unconditionally — main()'s `ownBySemantic`
95
- * check then matched that OTHER, still-running session's lock and released
96
- * it. Gating this field closes that window: an unrelated/mismatched ending
97
- * session now resolves `semanticSessionId: null`, so `ownBySemantic` can
98
- * never accidentally fire on someone else's identity.
91
+ * when multiple windows share this repo. An unrelated/mismatched ending
92
+ * session therefore resolves `semanticSessionId: null` rather than inheriting
93
+ * another live session's backfill identity.
99
94
  *
100
95
  * @param {object|null} input
101
96
  * @param {string} projectRoot
@@ -180,24 +175,15 @@ async function main() {
180
175
  await backfillAbandonedSession({ repoRoot: projectRoot, sessionId, semanticSessionId });
181
176
  } catch { /* best-effort — never block teardown */ }
182
177
 
183
- // (b) Deterministic lock release — ONLY our OWN lock. Match by UUID first,
184
- // then by semantic id (the UUID rotates across clear/compact/resume while
185
- // the semantic id stays stable, #612). A foreign lock is never released.
178
+ // (b) Deterministic lock release — ONLY a lock whose raw/native session_id
179
+ // exactly matches this ending session's raw/native sessionId. Semantic
180
+ // IDs remain lifecycle/backfill metadata and persisted proofs remain an
181
+ // additional delete defense, but neither can heal a raw-ID mismatch.
186
182
  //
187
- // Epic #724 hardening ("ended logged but lock survived"): the ownership
188
- // match alone left two silent failure modes
189
- // (1) release() can fail at the fs layer; the swallowed try/catch below
190
- // made this invisible (session.ended is logged, the lock survives).
191
- // (2) when the harness UUID rotates (clear/compact/resume) and
192
- // current-session.json's semantic_session_id is null/stale, BOTH
193
- // ownership checks are false and this branch never runs release()
194
- // at all — the lock survives until the NEXT session-start's own
195
- // reaper sweep discovers it.
196
- // Both are addressed below without loosening the strict
197
- // never-release-a-foreign-lock invariant: release() only ever runs when
198
- // ownership matched; the reconciliation fallback only ever runs through
199
- // reapRepoLock(), which independently NEVER reaps a live lease (and only
200
- // ever reaps on this same host with a dead recorded PID).
183
+ // A mismatch routes only through reconciliation. The reaper independently
184
+ // never touches a live lease, a cross-host lease, or a lease whose
185
+ // recorded PID is still alive on this host; this retains dead/stale-lock
186
+ // recovery without granting a SessionEnd hook foreign-lock ownership.
201
187
  try {
202
188
  // readLockDetailed (additive, see its JSDoc in session-lock.mjs) replaces
203
189
  // readLock() here so an unreadable/corrupt lock file is DISTINGUISHABLE
@@ -218,73 +204,28 @@ async function main() {
218
204
  } catch { /* observability is best-effort */ }
219
205
  } else if (lockDetail.status === 'ok') {
220
206
  const lock = lockDetail.lock;
221
- const ownByUuid = sessionId !== null && lock.session_id === sessionId;
222
- const ownBySemanticStrict =
223
- semanticSessionId !== null && lock.semantic_session_id === semanticSessionId;
224
- // #863 (d) — lock-shape trap: some on-disk locks store the semantic id
225
- // directly in `session_id` with no separate `semantic_session_id` field
226
- // at all (the "generated-semantic" acquisition path in
227
- // on-session-start.mjs mints `session_id === the semantic id`, and
228
- // bootstrapLock's v2 enrichment step never ran for that lock). Without
229
- // this fallback, ownBySemanticStrict is dead code for that shape.
230
- const ownBySemanticFallback =
231
- semanticSessionId !== null && lock.session_id === semanticSessionId;
232
- const ownBySemantic = ownBySemanticStrict || ownBySemanticFallback;
207
+ const ownByRawId = sessionId !== null && lock.session_id === sessionId;
233
208
 
234
- // #987 Part 2 — persisted ownership proof (pid + host + started_at,
235
- // written at lock genesis by bootstrapLock Step 2b, see
236
- // writeOwnerProof() in session-lock.mjs). loadOwnerProof() is
237
- // fail-closed: a missing/corrupt proof file yields null, and a null
238
- // proof presented to isLockOwnedByProof() yields false — the hook then
239
- // degrades to exactly the pre-#987 (proof-less) behaviour below.
240
- const proof = loadOwnerProof({ repoRoot: projectRoot });
241
- const ownByProof = isLockOwnedByProof(lock, proof);
209
+ if (ownByRawId) {
210
+ // The proof is deliberately passed to release() only after this raw-ID
211
+ // check. release() validates a supplied proof against its fresh on-disk
212
+ // read, preserving it as a second defense for the raw-owned path without
213
+ // allowing proof equality to establish ownership by itself.
214
+ const proof = loadOwnerProof({ repoRoot: projectRoot });
242
215
 
243
- // #906-class fix: the semantic session id
244
- // (`<branch>-<date>-<mode>-<n>`) is NOT globally unique — the
245
- // id-counter can hand out the SAME id to two different session
246
- // processes (live-observed twice on 2026-07-29: `main-2026-07-29-deep-1`
247
- // and `main-2026-07-29-session-1`, each assigned to two distinct
248
- // sessions). A live lock matched ONLY via a collidable semantic id
249
- // (strict OR fallback, never corroborated by a non-collidable factor)
250
- // is therefore NEVER trusted for release.
251
- //
252
- // #987 Part 2 — the discrimination the #906-class fix escalated as
253
- // "structurally unobservable" is now OBSERVABLE via the persisted
254
- // owner proof: a genuine same-session UUID rotation (clear/compact)
255
- // and a foreign same-day semantic collision used to produce the
256
- // IDENTICAL shape here (UUID mismatch + semantic match on a live
257
- // lock), so the previous fix conservatively left BOTH un-released
258
- // (bounded by the ttl_hours reaper). The proof written at lock genesis
259
- // (pid + host + started_at — presence-at-genesis, never the collidable
260
- // session_id/semantic id, see the FACTOR CHOICE docblock on
261
- // isLockOwnedByProof()) is exactly the discriminator that RCR-007
262
- // escalation named: on a self-rotation the on-disk lock is FROZEN at
263
- // its pre-rotation values (bootstrapLock's shouldForce is false for a
264
- // rotated UUID, so it bails without touching the lock), which are the
265
- // SAME values the proof captured at genesis → ownByProof is true → the
266
- // lock is released correctly. A foreign same-day collision wrote its
267
- // lock in a DIFFERENT process at a DIFFERENT millisecond → ownByProof
268
- // is false → semanticOnlyLive stays true → no release, the foreign
269
- // lease survives (the never-release-a-foreign-lock invariant is
270
- // untouched). No proof on disk (pre-#987 sessions, failed proof
271
- // write) → ownByProof false → the conservative pre-#987 behaviour.
272
- const semanticOnlyLive = !ownByUuid && !ownByProof && ownBySemantic && isLockLive(lock);
273
- const releaseEligible = (ownByUuid || ownBySemantic) && !semanticOnlyLive;
274
-
275
- if (releaseEligible) {
276
216
  // Defense-in-depth (#987): when a persisted proof exists, hand it to
277
217
  // release() so the delete is double-gated (session_id match AND
278
- // proof match) at the fs layer too. TRAP release()'s proof gate
279
- // triggers on `proof !== undefined`, so a null proof MUST be
280
- // spread-guarded out: passing `proof: null` would fail
281
- // isLockOwnedByProof() unconditionally and refuse EVERY release.
218
+ // proof match) at the fs layer too. `proof` is `null` whenever
219
+ // loadOwnerProof() could not prove ownership (pre-#987 sessions,
220
+ // failed proof write) — release() gates on `proof != null` (#989) and
221
+ // degrades to the session_id-only path for that case, so passing it
222
+ // through unguarded is correct.
282
223
  // A 'proof-mismatch' result flows into the existing release_failed
283
224
  // breadcrumb below (releaseResult.reason surfaces verbatim).
284
225
  const releaseResult = release({
285
- sessionId: lock.session_id,
226
+ sessionId,
286
227
  repoRoot: projectRoot,
287
- ...(proof ? { proof } : {}),
228
+ proof,
288
229
  });
289
230
  // release() has a no-throw contract (always returns a structured
290
231
  // result). A matched ownership that still fails to delete — an
@@ -353,22 +294,60 @@ async function main() {
353
294
  } catch { /* best-effort — a leftover proof is self-invalidating */ }
354
295
  }
355
296
  } else {
356
- // Root-cause reconciliation fallback: either NEITHER the UUID nor the
357
- // semantic id matched the recorded lock, OR the ONLY match was a
358
- // collidable semantic comparison (strict or fallback) on a lock that
359
- // is still live (semanticOnlyLive). attemptLockReconciliation() is
360
- // the extracted, DI-testable seam (Issue #748) — it internally
361
- // no-ops when the lease is still live (isLockLive), which is exactly
362
- // what makes it safe to route the semanticOnlyLive case here too: it
363
- // is otherwise best-effort, and reapRepoLock() never touches a live
364
- // lease, a cross-host lease, or a lease whose recorded PID is still
365
- // alive on this host.
297
+ // A raw-ID mismatch never grants release ownership. Reconciliation is
298
+ // the only remaining cleanup path; it no-ops for a live lock and safely
299
+ // reaps only eligible dead/stale locks.
366
300
  await attemptLockReconciliation({ repoRoot: projectRoot, sessionId, lock });
367
301
  }
368
302
  }
369
303
  // 'absent' — no lock file at all; nothing to release or reconcile
370
304
  // (mirrors the pre-existing `if (lock)` guard's false branch).
371
305
  } catch { /* best-effort — never block teardown */ }
306
+
307
+ // (c) #1047 — host-registry deregistration, the symmetric partner of
308
+ // on-session-start.mjs's registerSelf(). This used to live in
309
+ // hooks/on-stop.mjs, which fires at TURN end, so every assistant turn
310
+ // deleted the entry of a still-live session; on-stop.mjs now refreshes
311
+ // the entry (heartbeat) and teardown happens here, at the real end.
312
+ //
313
+ // Keyed by `sessionId` ONLY, never by `semanticSessionId`: the registry
314
+ // file is named after the id registerSelf() was called with, and in the
315
+ // `generated-uuid-fallback-collision` path (on-session-start.mjs) the
316
+ // semantic candidate names ANOTHER session's entry — deregistering by it
317
+ // would delete a foreign live session. Ownership is therefore structural
318
+ // here, not a check.
319
+ //
320
+ // CONSEQUENCE, accepted deliberately — and the affected platform is
321
+ // CODEX ALONE. Measured 2026-08-17 across the three bridge manifests:
322
+ // hooks.json SessionStart + SessionEnd -> registers, deregisters
323
+ // hooks-pi.json session_start + session_shutdown -> registers, deregisters
324
+ // (session_shutdown maps to THIS file)
325
+ // hooks-codex.json SessionStart + Stop, no SessionEnd
326
+ // -> registers, never deregisters <- the gap
327
+ // hooks-cursor.json afterFileEdit + beforeShellExecution only
328
+ // -> never registers, so nothing to leak
329
+ // On Codex an entry therefore persists until sweepZombies() removes it at
330
+ // the next SessionStart — up to the sweep threshold (`thresholdMin`,
331
+ // default 60 min) after the session ended. That is the same path crash
332
+ // and Ctrl-C already rely on for EVERY platform; no platform-detecting
333
+ // second teardown branch exists by design.
334
+ //
335
+ // Note where this is written: THIS file does not run on Codex, so the
336
+ // consequence is also pointed at from hooks/on-stop.mjs, which does.
337
+ //
338
+ // The `sessionId` guard is not decoration: deregisterSelf() throws
339
+ // TypeError on a null/empty id, and "no id resolvable" is a normal
340
+ // degraded state (no stdin id, no current-session.json), not a failure
341
+ // worth a sweep.log breadcrumb.
342
+ if (sessionId) {
343
+ try {
344
+ await deregisterSelf(sessionId);
345
+ } catch (err) {
346
+ // Deregistration failed — observability breadcrumb to sweep.log, never a
347
+ // throw and never stderr: the hook must not block teardown.
348
+ logSweepEvent({ event: 'deregister-failed', session_id: sessionId, error: err?.message ?? String(err) });
349
+ }
350
+ }
372
351
  }
373
352
 
374
353
  // Exit 0 always — informational hook must never block session teardown.
@@ -29,6 +29,7 @@
29
29
  */
30
30
 
31
31
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
32
+ import { writeSync } from 'node:fs';
32
33
  import { execFile } from 'node:child_process';
33
34
  import { promisify } from 'node:util';
34
35
  import { randomUUID } from 'node:crypto';
@@ -47,11 +48,67 @@ import {
47
48
  detectPeers,
48
49
  sweepZombies,
49
50
  logSweepEvent,
51
+ repoPathHash,
50
52
  } from '../scripts/lib/session-registry.mjs';
51
53
  import { detectColdStart, consumeMarker } from '../scripts/lib/cold-start-detector.mjs';
54
+ import { parseSessionId } from '../scripts/lib/session-id.mjs';
52
55
 
53
56
  const execFileAsync = promisify(execFile);
54
57
 
58
+ // ---------------------------------------------------------------------------
59
+ // Banner buffer (#1089 / #1052 A3a)
60
+ // ---------------------------------------------------------------------------
61
+ //
62
+ // MEASURED 2026-08-21: this hook emitted FIVE independent
63
+ // `console.log(JSON.stringify({ systemMessage }))` calls, and Claude Code
64
+ // surfaces only the FIRST JSON object a SessionStart hook writes to stdout.
65
+ // A live run against a copied 5-peer registry produced four stdout lines of
66
+ // which the operator saw exactly one — the host/resource line. The three that
67
+ // were silently discarded included:
68
+ //
69
+ // ⚠️ 👥 Peers: 5 active (… session-orchestrator:main:wave-0 …)
70
+ // 🔍 Mechanical peer-detection: 1 active in same repo (…)
71
+ //
72
+ // i.e. the two lines whose entire job is to warn that ANOTHER SESSION HOLDS
73
+ // THIS WORKING COPY. The information was computed correctly, formatted
74
+ // correctly, and thrown away by the transport — the "built but not wired"
75
+ // class, with the wiring defect one layer below where anyone was looking.
76
+ //
77
+ // The fix is structural: every banner line goes into one buffer and leaves as
78
+ // ONE systemMessage. Adding a sixth banner in future therefore cannot
79
+ // re-introduce the bug.
80
+ const bannerLines = [];
81
+
82
+ /**
83
+ * Queue one or more banner lines for the single end-of-hook flush.
84
+ * @param {string|null|undefined} line — multi-line strings are pushed verbatim
85
+ */
86
+ function pushBanner(line) {
87
+ if (typeof line !== 'string' || line.length === 0) return;
88
+ bannerLines.push(line);
89
+ }
90
+
91
+ /**
92
+ * Emit every queued banner line as ONE systemMessage envelope.
93
+ *
94
+ * Uses `fs.writeSync(1, …)` rather than `console.log`: the top-level guard
95
+ * calls `process.exit(0)`, which discards anything still sitting in libuv's
96
+ * async write queue when stdout is a pipe (see
97
+ * `.claude/rules/anti-pattern-console-log-process-exit-drops-stdout…`). The
98
+ * banner is far below the 64 KiB pipe buffer today, but a synchronous write
99
+ * costs nothing and removes the failure mode rather than staying under it.
100
+ *
101
+ * Idempotent: a second call after a flush is a no-op.
102
+ */
103
+ function flushBanner() {
104
+ if (bannerLines.length === 0) return;
105
+ const payload = JSON.stringify({ systemMessage: bannerLines.join('\n') });
106
+ bannerLines.length = 0;
107
+ try {
108
+ writeSync(1, `${payload}\n`);
109
+ } catch { /* stdout closed — the hook is informational and never blocks */ }
110
+ }
111
+
55
112
  // ---------------------------------------------------------------------------
56
113
  // Helpers
57
114
  // ---------------------------------------------------------------------------
@@ -191,92 +248,43 @@ async function readStdinJson(timeoutMs = 500) {
191
248
  */
192
249
  async function resolveSessionId(input, projectRoot) {
193
250
  const fromStdin = (input && (input.session_id || input.sessionId)) ?? null;
251
+ const parsedStdinId = parseSessionId(fromStdin);
252
+ const rawStdinSessionId = parsedStdinId?.format === 'uuid' ? fromStdin : null;
194
253
 
195
- // Mode normalization is shared by both branches (stdin-UUID + semantic-gen)
196
- // so the semantic-id derivation works identically regardless of how
197
- // sessionId was sourced. Epic #583 D4 #587: a semantic id is ALWAYS surfaced.
254
+ // Mode normalization is shared by both branches so semantic attribution is
255
+ // derived independently of the raw session identity. A semantic id is always
256
+ // descriptive metadata, never the physical lock/registry session_id.
198
257
  const rawMode = (input && (input.mode || input.session_type)) || 'session';
199
258
  const normalizedMode =
200
259
  String(rawMode).toLowerCase().replace(/[^a-z-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'session';
201
260
 
202
261
  let sessionId;
203
262
  let source;
204
- // Epic #583 D4 #587: semanticSessionId is computed on BOTH branches so the
205
- // session.lock + downstream consumers always see the semantic form, even
206
- // when Claude Code's stdin payload provided a UUID-v4. Defaults to null
207
- // and is populated below; the lock-bootstrap helper falls back to mirroring
208
- // sessionId when this stays null.
263
+ // The lock and downstream consumers surface this independently-derived
264
+ // semantic attribution alongside the physical raw session id.
209
265
  let semanticSessionId = null;
210
266
 
211
- if (typeof fromStdin === 'string' && fromStdin.length > 0) {
212
- sessionId = fromStdin;
267
+ if (rawStdinSessionId !== null) {
268
+ sessionId = rawStdinSessionId;
213
269
  source = 'stdin';
214
-
215
- // D4 #587: even on the stdin-UUID path, ALSO compute a semantic
216
- // session-id so the lock and registry can surface it downstream.
217
- // Best-effort: any failure leaves semanticSessionId = null which is
218
- // handled gracefully by the lock-bootstrap helper.
219
- try {
220
- const semCandidate = await deriveSemanticCandidate({
221
- projectRoot,
222
- mode: normalizedMode,
223
- });
224
- if (semCandidate) semanticSessionId = semCandidate;
225
- } catch { /* best effort — leave semanticSessionId = null */ }
226
270
  } else {
227
- // No stdin id generate a semantic session-id via P2.2 #573 (PRD §3 P2).
228
- // Falls back to randomUUID() on any failure to keep the hook non-blocking.
229
- try {
230
- const candidate = await deriveSemanticCandidate({
231
- projectRoot,
232
- mode: normalizedMode,
233
- });
234
- if (!candidate) throw new Error('semantic-derivation-empty');
235
-
236
- // Race-free host-wide uniqueness: try to atomically claim the registry slot
237
- // for this sessionId via O_CREAT|O_EXCL. If another process won the race
238
- // (EEXIST), fall back to UUID-v4 to guarantee distinct sessionIds across
239
- // parallel hook invocations. The empty file is immediately overwritten by
240
- // registerSelf() with the real entry — _validEntry() in readers filters
241
- // any zero-content artifact during the millisecond gap.
242
- try {
243
- // entryPath() is NOT exported from session-registry.mjs (it is an
244
- // internal helper); replicate its body inline so the race-free slot
245
- // claim actually succeeds. Without this inline build, the
246
- // destructuring on `entryPath` returns undefined and calling it
247
- // throws TypeError → silent UUID-fallback, defeating the semantic-id
248
- // generation path entirely. Pre-existing bug pre-dating Epic #583 P3.
249
- const { activeDir } = await import('../scripts/lib/session-registry.mjs');
250
- const fsAsync = await import('node:fs/promises');
251
- await fsAsync.mkdir(activeDir(), { recursive: true });
252
- const claimPath = path.join(activeDir(), `${candidate}.json`);
253
- const handle = await fsAsync.open(claimPath, 'wx'); // O_CREAT|O_EXCL — fails with EEXIST on collision
254
- await handle.close();
255
- sessionId = candidate;
256
- semanticSessionId = candidate;
257
- source = 'generated-semantic';
258
- } catch (claimErr) {
259
- if (claimErr.code === 'EEXIST') {
260
- // Collision with another parallel hook — fall back to UUID-v4 silently.
261
- // Hook is informational-only; never write to stderr per existing convention.
262
- sessionId = randomUUID();
263
- source = 'generated-uuid-fallback-collision';
264
- // We still surface the semantic candidate as semanticSessionId so the
265
- // lock reflects what this session would have been called — useful for
266
- // forensics even when the UUID won the registry-slot race.
267
- semanticSessionId = candidate;
268
- } else {
269
- throw claimErr; // surfaces to outer catch → UUID fallback
270
- }
271
- }
272
- } catch {
273
- // Semantic id generation failed for any reason — silently fall back to UUID-v4.
274
- // Hook is informational-only; never write to stderr per existing convention.
275
- sessionId = randomUUID();
276
- source = 'generated-uuid-fallback';
277
- }
271
+ // A missing, malformed, or semantic stdin value is not a trustworthy raw
272
+ // identity. Generate the physical id locally; semantic derivation below is
273
+ // attribution only and must never become the lock/registry key.
274
+ sessionId = randomUUID();
275
+ source = 'generated-uuid';
278
276
  }
279
277
 
278
+ // Derive a descriptive semantic label for either raw-id source. Best-effort:
279
+ // a failure leaves it null without changing the physical raw session_id.
280
+ try {
281
+ const semCandidate = await deriveSemanticCandidate({
282
+ projectRoot,
283
+ mode: normalizedMode,
284
+ });
285
+ if (semCandidate) semanticSessionId = semCandidate;
286
+ } catch { /* best effort — leave semanticSessionId = null */ }
287
+
280
288
  try {
281
289
  const dir = path.join(projectRoot, '.orchestrator');
282
290
  await mkdir(dir, { recursive: true });
@@ -415,15 +423,43 @@ async function emitHostBanner(projectRoot) {
415
423
  if (!host || !resources) return null;
416
424
 
417
425
  const hostLine = `🖥️ Host: ${host.host_class} · ${host.ram_total_gb} GB RAM · ${host.platform ?? 'unknown'} · ${host.is_ssh ? 'ssh' : 'local'}`;
418
- const procSuffix = resources.claude_processes_count === null
426
+
427
+ // #1089: report the memory number a human can ACT on.
428
+ //
429
+ // The old line printed `ram_free_gb`, which on Darwin is `os.freemem()` =
430
+ // `Pages free` only. Median across 1477 measured session starts: 0.4 GB —
431
+ // on hosts with 24-128 GB installed. So this banner spent four months
432
+ // telling the operator the machine was seconds from death while the same
433
+ // machine reported 40%+ memory free and ran full waves clean. Six repos
434
+ // logged that false alarm as a learning; one capped agents for five
435
+ // consecutive sessions off this number.
436
+ //
437
+ // Precedence mirrors evaluate()'s memorySignal(): pressure > available >
438
+ // free. `free` survives only as the last resort — on Linux/Windows it is
439
+ // genuinely accurate, which is exactly where nothing better is published.
440
+ const memLine = (() => {
441
+ if (resources.memory_pressure_pct_free !== null && resources.memory_pressure_pct_free !== undefined) {
442
+ return `${resources.memory_pressure_pct_free}% memory free (OS pressure)`;
443
+ }
444
+ if (resources.ram_available_gb !== null && resources.ram_available_gb !== undefined) {
445
+ return `${resources.ram_available_gb.toFixed(1)} GB available`;
446
+ }
447
+ return `${resources.ram_free_gb.toFixed(1)} GB free`;
448
+ })();
449
+
450
+ // Peer SESSIONS, not Claude PROCESSES. Measured ratio 6.0:1 — the old
451
+ // suffix read "17 Claude processes running" on a host carrying 3 sessions,
452
+ // which is alarming and means nothing actionable.
453
+ const peerSuffix = resources.peer_sessions_count === null || resources.peer_sessions_count === undefined
419
454
  ? ''
420
- : ` · ${resources.claude_processes_count} Claude process${resources.claude_processes_count === 1 ? '' : 'es'} running`;
421
- const resourceLine = `📊 Resources: ${resources.ram_free_gb.toFixed(1)} GB free · CPU ${resources.cpu_load_pct}%${procSuffix}`;
422
- const banner = `${hostLine}\n${resourceLine}`;
455
+ : ` · ${resources.peer_sessions_count} peer session${resources.peer_sessions_count === 1 ? '' : 's'}`;
456
+ const resourceLine = `📊 Resources: ${memLine} · CPU ${resources.cpu_load_pct}%${peerSuffix}`;
423
457
 
424
- // systemMessage envelope is the Claude Code hook contract; ignored by
425
- // consumers that do not read stdout, harmless in all cases.
426
- console.log(JSON.stringify({ systemMessage: banner }));
458
+ // systemMessage envelope is the Claude Code hook contract; buffered so all
459
+ // banner lines leave as ONE object (see flushBanner Claude Code reads
460
+ // only the first).
461
+ pushBanner(hostLine);
462
+ pushBanner(resourceLine);
427
463
 
428
464
  return { host, resources };
429
465
  } catch {
@@ -464,11 +500,7 @@ async function main() {
464
500
  // Config / STATE.md / git, and only then the tool. The banner carries the
465
501
  // ORDER, not an absolute; full routing + exceptions in
466
502
  // .claude/rules/ask-via-tool.md.
467
- try {
468
- console.log(JSON.stringify({
469
- systemMessage: '🎯 Decide: operator verb (/go) > derive+report > AUQ if blocking (.claude/rules/ask-via-tool.md).',
470
- }));
471
- } catch { /* best effort */ }
503
+ pushBanner('🎯 Decide: operator verb (/go) > derive+report > AUQ if blocking (.claude/rules/ask-via-tool.md).');
472
504
  }
473
505
 
474
506
  // F1.3 cold-start abandonment fix (PRD 2026-05-21). Emit a one-shot
@@ -490,11 +522,7 @@ async function main() {
490
522
  enabled: coldStartCfg.enabled !== false,
491
523
  });
492
524
  if (decision.shouldEmit) {
493
- try {
494
- console.log(JSON.stringify({
495
- systemMessage: decision.bannerLines.join('\n'),
496
- }));
497
- } catch { /* best effort — stdout may be closed */ }
525
+ pushBanner(decision.bannerLines.join('\n'));
498
526
  if (decision.markerPath) {
499
527
  await consumeMarker(decision.markerPath).catch(() => false);
500
528
  }
@@ -592,17 +620,61 @@ async function main() {
592
620
  // Append a peer line to the host banner when a banner was already emitted
593
621
  // and at least one peer is live on this host.
594
622
  if (bannerData && peers.length > 0) {
623
+ // #1052 A3a — split peers by the axis that actually decides behaviour.
624
+ //
625
+ // `.claude/rules/parallel-sessions.md` defines the operator-session axis by
626
+ // the WORKING COPY, not by reachability: a peer in this checkout contends
627
+ // for one git index, one filesystem, one STATE.md, and can hold the
628
+ // wave-scope guard. A peer in another repo contends only for host capacity.
629
+ // Those are different problems and the old single-line summary blurred them
630
+ // into one comma-separated list sorted by nothing.
631
+ //
632
+ // Correlate on `repo_path_hash` rather than `repo_name`: a sibling worktree
633
+ // of this repo carries a DIFFERENT working copy (and a different hash) while
634
+ // often sharing a similar name, and it is the checkout — not the name — that
635
+ // can collide. Falls back to name comparison only if the hash is missing.
636
+ let selfRepoHash = null;
637
+ try { selfRepoHash = repoPathHash(projectRoot); } catch { /* best effort */ }
638
+ const sameCopy = peers.filter((p) =>
639
+ selfRepoHash && p.repo_path_hash ? p.repo_path_hash === selfRepoHash : false);
640
+ const elsewhere = peers.filter((p) => !sameCopy.includes(p));
641
+
595
642
  const threshold = await peerWarnThreshold(projectRoot);
596
- const icon = peers.length >= threshold ? '⚠️ ' : '';
597
- const summary = peers
598
- .map((p) => `${p.repo_name ?? 'unknown'}:${p.branch ?? 'unknown'}:wave-${p.current_wave ?? 0}`)
599
- .slice(0, 5)
600
- .join(', ');
601
- const overflow = peers.length > 5 ? ` +${peers.length - 5} more` : '';
602
- const peerLine = `${icon}👥 Peers: ${peers.length} active (${summary}${overflow})`;
603
- try {
604
- console.log(JSON.stringify({ systemMessage: peerLine }));
605
- } catch { /* best effort */ }
643
+ // WARN on any peer in THIS working copy one is already enough to collide
644
+ // or on host-wide count crossing the configured threshold.
645
+ const icon = sameCopy.length > 0 || peers.length >= threshold ? '⚠️ ' : '';
646
+
647
+ // Semantic ids are NOT unique — several live sessions routinely share e.g.
648
+ // `main-2026-08-21-session-2`, which makes a bare semantic label unusable
649
+ // for addressing a specific peer. Append a short uuid discriminator so two
650
+ // rows can be told apart (and so the id can be pasted into a lookup).
651
+ const fmt = (p) => {
652
+ const label = p.semantic_session_id ?? p.session_id ?? 'unknown';
653
+ const disc = p.session_id ? `#${String(p.session_id).slice(0, 8)}` : '';
654
+ return `${label}${disc}:${p.branch ?? 'unknown'}:wave-${p.current_wave ?? 0}`;
655
+ };
656
+ pushBanner(`${icon}👥 Peers: ${peers.length} live on this host`);
657
+ if (sameCopy.length > 0) {
658
+ const list = sameCopy.slice(0, 3).map(fmt).join(', ');
659
+ const more = sameCopy.length > 3 ? ` +${sameCopy.length - 3} more` : '';
660
+ // This is the line that matters. A peer here means PSA-002 territory:
661
+ // it may hold .claude/wave-scope.json, carry uncommitted work in files
662
+ // you are about to edit, or switch the branch under you.
663
+ pushBanner(` ⚠️ ${sameCopy.length} in THIS working copy — coordinate before editing (${list}${more})`);
664
+ }
665
+ if (elsewhere.length > 0) {
666
+ const list = elsewhere
667
+ .slice(0, 4)
668
+ .map((p) => `${p.repo_name ?? 'unknown'}:${p.branch ?? 'unknown'}`)
669
+ .join(', ');
670
+ const more = elsewhere.length > 4 ? ` +${elsewhere.length - 4} more` : '';
671
+ pushBanner(` other repos (host capacity only): ${list}${more}`);
672
+ }
673
+ // #1052 A3a acceptance criterion: state the limit, never imply a status we
674
+ // cannot read. `ListAgents` is a MODEL-side tool; this hook is a Node
675
+ // process and structurally cannot call it, so busy/waiting/idle is
676
+ // unavailable here. The coordinator half (A3b) overlays it later.
677
+ pushBanner(' (registry view: repo/branch/wave. Liveness (busy/idle) is model-side — not available in this hook.)');
606
678
  }
607
679
 
608
680
  // Epic #583 W3-P3 — Mechanical peer-detection banner (independent of the
@@ -632,10 +704,7 @@ async function main() {
632
704
  .map((p) => `${p.sessionId}:${p.mode ?? 'session'}`)
633
705
  .join(', ');
634
706
  const overflow = mechanicalPeers.length > 3 ? ` +${mechanicalPeers.length - 3} more` : '';
635
- const mechanicalLine = `🔍 Mechanical peer-detection: ${mechanicalPeers.length} active in same repo (${summary}${overflow})`;
636
- try {
637
- console.log(JSON.stringify({ systemMessage: mechanicalLine }));
638
- } catch { /* best effort */ }
707
+ pushBanner(`🔍 Mechanical peer-detection: ${mechanicalPeers.length} active in same repo (${summary}${overflow})`);
639
708
  }
640
709
  } catch { /* best effort — banner is informational, never blocks */ }
641
710
  }
@@ -652,9 +721,27 @@ async function main() {
652
721
  payload.ram_free_gb = bannerData.resources.ram_free_gb;
653
722
  payload.cpu_load_pct = bannerData.resources.cpu_load_pct;
654
723
  payload.claude_processes_count = bannerData.resources.claude_processes_count;
724
+ // #1089 — record the signals the verdict is actually computed from, so the
725
+ // firing rate of each rule class is measurable AFTER the fact.
726
+ //
727
+ // This is the defect that let the old rule set fire on 99.0% of starts for
728
+ // four months undetected: `resource_verdict` was written to sessions.jsonl
729
+ // for exactly 15 of 1734 sessions, all inside one week in April 2026, and
730
+ // the fields logged here were the two MISLEADING ones (`ram_free_gb`,
731
+ // `claude_processes_count`) — so even the surviving telemetry could not
732
+ // have falsified the thresholds. The three added below are the ones the
733
+ // rules now judge on. `.claude/rules/host-resources.md` HR-005 turns them
734
+ // into a standing 10%-firing-rate audit.
735
+ payload.ram_available_gb = bannerData.resources.ram_available_gb ?? null;
736
+ payload.memory_pressure_pct_free = bannerData.resources.memory_pressure_pct_free ?? null;
737
+ payload.peer_sessions_count = bannerData.resources.peer_sessions_count ?? null;
655
738
  }
656
739
  await emitEvent('orchestrator.session.started', payload);
657
740
 
741
+ // Single flush — see the bannerLines docstring for why this must stay the
742
+ // only stdout write in the hook.
743
+ flushBanner();
744
+
658
745
  // Size-based rotation of events.jsonl (#251). Session-start is the single
659
746
  // rotation trigger — per-append overhead is rejected design. Any failure
660
747
  // is swallowed: rotation must NEVER block the hook.
@@ -681,6 +768,10 @@ async function main() {
681
768
  }
682
769
 
683
770
  // Top-level guard — always exit 0 (non-blocking informational hook).
771
+ // flushBanner() runs here too so a throw partway through main() still surfaces
772
+ // whatever was already collected; it is idempotent, so the normal path (which
773
+ // flushes at the end of main) does not double-emit.
684
774
  main().catch(() => {}).finally(() => {
775
+ flushBanner();
685
776
  process.exit(0);
686
777
  });