session-orchestrator 3.21.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 (117) 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/040-discovery.mdc +6 -6
  6. package/.cursor/rules/050-plan.mdc +8 -8
  7. package/CHANGELOG.md +101 -0
  8. package/README.md +10 -10
  9. package/agents/memory-proposal-collector.md +6 -4
  10. package/commands/eli5.md +33 -0
  11. package/commands/release.md +5 -3
  12. package/commands/test.md +2 -2
  13. package/docs/components.md +6 -5
  14. package/docs/scope-collision-guard.md +3 -3
  15. package/docs/session-config-reference.md +31 -8
  16. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  17. package/hooks/hooks-codex.json +1 -1
  18. package/hooks/hooks.json +11 -1
  19. package/hooks/on-session-end.mjs +24 -92
  20. package/hooks/on-session-start.mjs +195 -104
  21. package/hooks/pre-auq-clarity.mjs +787 -0
  22. package/hooks/pre-bash-issue-budget.mjs +17 -18
  23. package/package.json +3 -1
  24. package/pi/prompts/eli5.md +12 -0
  25. package/scripts/auq-audit.mjs +825 -0
  26. package/scripts/autopilot.mjs +7 -8
  27. package/scripts/lib/auq/clarity.mjs +1314 -0
  28. package/scripts/lib/auq/parse.mjs +1006 -0
  29. package/scripts/lib/auq/schema.mjs +1457 -0
  30. package/scripts/lib/ci-status-banner.mjs +63 -57
  31. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  32. package/scripts/lib/config/vault-integration.mjs +12 -1
  33. package/scripts/lib/dispatcher/rank.mjs +4 -7
  34. package/scripts/lib/gates/gate-full.mjs +3 -3
  35. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  36. package/scripts/lib/io.mjs +239 -0
  37. package/scripts/lib/issue-budget.mjs +63 -9
  38. package/scripts/lib/owner-interview.mjs +78 -32
  39. package/scripts/lib/peer-discovery.mjs +73 -22
  40. package/scripts/lib/project-hygiene.mjs +64 -4
  41. package/scripts/lib/reconcile/renderer.mjs +17 -4
  42. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  43. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  44. package/scripts/lib/resource-probe.mjs +18 -2
  45. package/scripts/lib/spiral-carryover.mjs +23 -2
  46. package/scripts/lib/state-md/mission-status.mjs +147 -50
  47. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  48. package/scripts/lib/validate/check-hooks-symmetry.mjs +30 -0
  49. package/scripts/lib/validate/check-rules.mjs +153 -9
  50. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  51. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  52. package/scripts/lib/vcs-repo-spec.mjs +182 -13
  53. package/scripts/lib/wave-resource-gate.mjs +67 -73
  54. package/scripts/materialize-wave-scope.mjs +281 -0
  55. package/scripts/release.mjs +443 -122
  56. package/scripts/run-quality-gate.mjs +14 -0
  57. package/scripts/validate-plugin.mjs +3 -0
  58. package/scripts/validate-wave-scope.mjs +6 -1
  59. package/scripts/vault-backfill.mjs +32 -5
  60. package/skills/_shared/parallel-aware-auq.md +30 -24
  61. package/skills/_shared/parallel-aware-preamble.md +31 -2
  62. package/skills/_shared/state-ownership.md +32 -6
  63. package/skills/bootstrap/SKILL.md +2 -1
  64. package/skills/brainstorm/SKILL.md +18 -18
  65. package/skills/brainstorm/soul.md +12 -0
  66. package/skills/discovery/SKILL.md +28 -24
  67. package/skills/eli5/SKILL.md +43 -0
  68. package/skills/evolve/SKILL.md +8 -9
  69. package/skills/gitlab-ops/SKILL.md +30 -26
  70. package/skills/grill/SKILL.md +6 -6
  71. package/skills/grill/soul.md +16 -0
  72. package/skills/memory-cleanup/SKILL.md +2 -2
  73. package/skills/npm-publish/SKILL.md +4 -4
  74. package/skills/peekaboo-driver/SKILL.md +3 -3
  75. package/skills/plan/SKILL.md +18 -16
  76. package/skills/plan/mode-feature.md +1 -1
  77. package/skills/plan/mode-new.md +35 -23
  78. package/skills/plan/soul.md +12 -0
  79. package/skills/reconcile/SKILL.md +3 -3
  80. package/skills/session-end/SKILL.md +53 -20
  81. package/skills/session-end/phase-3-6-tail.md +37 -2
  82. package/skills/session-start/SKILL.md +69 -35
  83. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  84. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  85. package/skills/session-start/soul.md +110 -0
  86. package/skills/test-runner/SKILL.md +2 -2
  87. package/skills/using-orchestrator/SKILL.md +1 -1
  88. package/skills/wave-executor/wave-loop.md +27 -5
  89. package/skills/write-executable-plan/SKILL.md +6 -6
  90. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  91. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  92. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  93. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  94. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  95. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  96. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  97. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  98. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  99. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  100. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  101. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  102. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  103. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  104. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  105. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  106. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  107. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  108. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  109. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  110. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  111. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  112. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  113. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  114. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  115. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  116. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  117. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
package/hooks/hooks.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "echo '🎯 Session Orchestrator v3.21.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
9
+ "command": "echo '🎯 Session Orchestrator v3.22.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
10
  "async": false
11
11
  },
12
12
  {
@@ -105,6 +105,16 @@
105
105
  "timeout": 5
106
106
  }
107
107
  ]
108
+ },
109
+ {
110
+ "matcher": "AskUserQuestion",
111
+ "hooks": [
112
+ {
113
+ "type": "command",
114
+ "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/pre-auq-clarity.mjs\"",
115
+ "timeout": 5
116
+ }
117
+ ]
108
118
  }
109
119
  ],
110
120
  "PostToolUse": [
@@ -34,9 +34,7 @@ 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';
42
40
  import { deregisterSelf, logSweepEvent } from '../scripts/lib/session-registry.mjs';
@@ -83,20 +81,16 @@ async function readStdinJson() {
83
81
  * `semanticSessionId` is read from current-session.json (present since #587):
84
82
  * it is the SEMANTIC id (`<branch>-<date>-<mode>-<n>`) that sessions.jsonl is
85
83
  * keyed by, and the id the backfill (C1 #724) uses when the stdin session_id is
86
- * 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.
87
86
  *
88
87
  * #863 defect (c) — `semanticSessionId` is gated by the SAME "ending session
89
88
  * IS the recorded one" check as `durationMs` above. `current-session.json` is
90
89
  * a single repo-global file: it always reflects whichever session most
91
90
  * recently ran SessionStart, which may be a DIFFERENT, still-live session
92
- * when multiple windows share this repo. Before this fix, a foreign
93
- * terminating window (its own stdin `session_id` explicitly present, but NOT
94
- * equal to the recorded session) still inherited the CURRENTLY-recorded
95
- * session's `semantic_session_id` unconditionally — main()'s `ownBySemantic`
96
- * check then matched that OTHER, still-running session's lock and released
97
- * it. Gating this field closes that window: an unrelated/mismatched ending
98
- * session now resolves `semanticSessionId: null`, so `ownBySemantic` can
99
- * 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.
100
94
  *
101
95
  * @param {object|null} input
102
96
  * @param {string} projectRoot
@@ -181,24 +175,15 @@ async function main() {
181
175
  await backfillAbandonedSession({ repoRoot: projectRoot, sessionId, semanticSessionId });
182
176
  } catch { /* best-effort — never block teardown */ }
183
177
 
184
- // (b) Deterministic lock release — ONLY our OWN lock. Match by UUID first,
185
- // then by semantic id (the UUID rotates across clear/compact/resume while
186
- // 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.
187
182
  //
188
- // Epic #724 hardening ("ended logged but lock survived"): the ownership
189
- // match alone left two silent failure modes
190
- // (1) release() can fail at the fs layer; the swallowed try/catch below
191
- // made this invisible (session.ended is logged, the lock survives).
192
- // (2) when the harness UUID rotates (clear/compact/resume) and
193
- // current-session.json's semantic_session_id is null/stale, BOTH
194
- // ownership checks are false and this branch never runs release()
195
- // at all — the lock survives until the NEXT session-start's own
196
- // reaper sweep discovers it.
197
- // Both are addressed below without loosening the strict
198
- // never-release-a-foreign-lock invariant: release() only ever runs when
199
- // ownership matched; the reconciliation fallback only ever runs through
200
- // reapRepoLock(), which independently NEVER reaps a live lease (and only
201
- // 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.
202
187
  try {
203
188
  // readLockDetailed (additive, see its JSDoc in session-lock.mjs) replaces
204
189
  // readLock() here so an unreadable/corrupt lock file is DISTINGUISHABLE
@@ -219,61 +204,15 @@ async function main() {
219
204
  } catch { /* observability is best-effort */ }
220
205
  } else if (lockDetail.status === 'ok') {
221
206
  const lock = lockDetail.lock;
222
- const ownByUuid = sessionId !== null && lock.session_id === sessionId;
223
- const ownBySemanticStrict =
224
- semanticSessionId !== null && lock.semantic_session_id === semanticSessionId;
225
- // #863 (d) — lock-shape trap: some on-disk locks store the semantic id
226
- // directly in `session_id` with no separate `semantic_session_id` field
227
- // at all (the "generated-semantic" acquisition path in
228
- // on-session-start.mjs mints `session_id === the semantic id`, and
229
- // bootstrapLock's v2 enrichment step never ran for that lock). Without
230
- // this fallback, ownBySemanticStrict is dead code for that shape.
231
- const ownBySemanticFallback =
232
- semanticSessionId !== null && lock.session_id === semanticSessionId;
233
- const ownBySemantic = ownBySemanticStrict || ownBySemanticFallback;
207
+ const ownByRawId = sessionId !== null && lock.session_id === sessionId;
234
208
 
235
- // #987 Part 2 — persisted ownership proof (pid + host + started_at,
236
- // written at lock genesis by bootstrapLock Step 2b, see
237
- // writeOwnerProof() in session-lock.mjs). loadOwnerProof() is
238
- // fail-closed: a missing/corrupt proof file yields null, and a null
239
- // proof presented to isLockOwnedByProof() yields false — the hook then
240
- // degrades to exactly the pre-#987 (proof-less) behaviour below.
241
- const proof = loadOwnerProof({ repoRoot: projectRoot });
242
- 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 });
243
215
 
244
- // #906-class fix: the semantic session id
245
- // (`<branch>-<date>-<mode>-<n>`) is NOT globally unique — the
246
- // id-counter can hand out the SAME id to two different session
247
- // processes (live-observed twice on 2026-07-29: `main-2026-07-29-deep-1`
248
- // and `main-2026-07-29-session-1`, each assigned to two distinct
249
- // sessions). A live lock matched ONLY via a collidable semantic id
250
- // (strict OR fallback, never corroborated by a non-collidable factor)
251
- // is therefore NEVER trusted for release.
252
- //
253
- // #987 Part 2 — the discrimination the #906-class fix escalated as
254
- // "structurally unobservable" is now OBSERVABLE via the persisted
255
- // owner proof: a genuine same-session UUID rotation (clear/compact)
256
- // and a foreign same-day semantic collision used to produce the
257
- // IDENTICAL shape here (UUID mismatch + semantic match on a live
258
- // lock), so the previous fix conservatively left BOTH un-released
259
- // (bounded by the ttl_hours reaper). The proof written at lock genesis
260
- // (pid + host + started_at — presence-at-genesis, never the collidable
261
- // session_id/semantic id, see the FACTOR CHOICE docblock on
262
- // isLockOwnedByProof()) is exactly the discriminator that RCR-007
263
- // escalation named: on a self-rotation the on-disk lock is FROZEN at
264
- // its pre-rotation values (bootstrapLock's shouldForce is false for a
265
- // rotated UUID, so it bails without touching the lock), which are the
266
- // SAME values the proof captured at genesis → ownByProof is true → the
267
- // lock is released correctly. A foreign same-day collision wrote its
268
- // lock in a DIFFERENT process at a DIFFERENT millisecond → ownByProof
269
- // is false → semanticOnlyLive stays true → no release, the foreign
270
- // lease survives (the never-release-a-foreign-lock invariant is
271
- // untouched). No proof on disk (pre-#987 sessions, failed proof
272
- // write) → ownByProof false → the conservative pre-#987 behaviour.
273
- const semanticOnlyLive = !ownByUuid && !ownByProof && ownBySemantic && isLockLive(lock);
274
- const releaseEligible = (ownByUuid || ownBySemantic) && !semanticOnlyLive;
275
-
276
- if (releaseEligible) {
277
216
  // Defense-in-depth (#987): when a persisted proof exists, hand it to
278
217
  // release() so the delete is double-gated (session_id match AND
279
218
  // proof match) at the fs layer too. `proof` is `null` whenever
@@ -284,7 +223,7 @@ async function main() {
284
223
  // A 'proof-mismatch' result flows into the existing release_failed
285
224
  // breadcrumb below (releaseResult.reason surfaces verbatim).
286
225
  const releaseResult = release({
287
- sessionId: lock.session_id,
226
+ sessionId,
288
227
  repoRoot: projectRoot,
289
228
  proof,
290
229
  });
@@ -355,16 +294,9 @@ async function main() {
355
294
  } catch { /* best-effort — a leftover proof is self-invalidating */ }
356
295
  }
357
296
  } else {
358
- // Root-cause reconciliation fallback: either NEITHER the UUID nor the
359
- // semantic id matched the recorded lock, OR the ONLY match was a
360
- // collidable semantic comparison (strict or fallback) on a lock that
361
- // is still live (semanticOnlyLive). attemptLockReconciliation() is
362
- // the extracted, DI-testable seam (Issue #748) — it internally
363
- // no-ops when the lease is still live (isLockLive), which is exactly
364
- // what makes it safe to route the semanticOnlyLive case here too: it
365
- // is otherwise best-effort, and reapRepoLock() never touches a live
366
- // lease, a cross-host lease, or a lease whose recorded PID is still
367
- // 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.
368
300
  await attemptLockReconciliation({ repoRoot: projectRoot, sessionId, lock });
369
301
  }
370
302
  }
@@ -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
  });