session-orchestrator 3.21.0 → 3.23.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 (345) 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/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +11 -2
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/040-discovery.mdc +6 -6
  38. package/.cursor/rules/050-plan.mdc +10 -10
  39. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  40. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  41. package/.cursor/skills/architecture/SKILL.md +13 -0
  42. package/.cursor/skills/autopilot/SKILL.md +12 -0
  43. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  44. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  45. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  46. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  47. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  48. package/.cursor/skills/daily/SKILL.md +12 -0
  49. package/.cursor/skills/debug/SKILL.md +13 -0
  50. package/.cursor/skills/discovery/SKILL.md +13 -0
  51. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  52. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  53. package/.cursor/skills/domain-model/SKILL.md +13 -0
  54. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  55. package/.cursor/skills/eli5/SKILL.md +13 -0
  56. package/.cursor/skills/eval/SKILL.md +12 -0
  57. package/.cursor/skills/evolve/SKILL.md +13 -0
  58. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  60. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  61. package/.cursor/skills/grill/SKILL.md +13 -0
  62. package/.cursor/skills/hook-development/SKILL.md +13 -0
  63. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  64. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  65. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  66. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  67. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  68. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  69. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  70. package/.cursor/skills/plan/SKILL.md +13 -0
  71. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  72. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  73. package/.cursor/skills/reconcile/SKILL.md +12 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +269 -0
  91. package/README.md +18 -15
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -1
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/memory-proposal-collector.md +6 -4
  98. package/agents/session-reviewer.md +7 -1
  99. package/agents/test-writer.md +2 -1
  100. package/agents/ui-developer.md +2 -1
  101. package/commands/bootstrap.md +2 -2
  102. package/commands/close.md +3 -1
  103. package/commands/eli5.md +33 -0
  104. package/commands/go.md +1 -1
  105. package/commands/journey-audit.md +43 -0
  106. package/commands/release.md +5 -3
  107. package/commands/test.md +2 -2
  108. package/docs/USER-GUIDE.md +2 -2
  109. package/docs/ci-setup.md +14 -0
  110. package/docs/codex-setup.md +64 -0
  111. package/docs/components.md +7 -6
  112. package/docs/cursor-setup.md +26 -47
  113. package/docs/events-schema.md +76 -4
  114. package/docs/github-mirror-protection.md +197 -0
  115. package/docs/pi-setup.md +2 -0
  116. package/docs/rule-authoring.md +3 -1
  117. package/docs/scope-collision-guard.md +52 -5
  118. package/docs/session-config-reference.md +57 -12
  119. package/docs/session-config-template.md +4 -3
  120. package/docs/telemetry.md +22 -0
  121. package/hooks/_lib/lock-bootstrap.mjs +26 -16
  122. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  123. package/hooks/enforce-scope.mjs +64 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +12 -2
  128. package/hooks/on-session-end.mjs +237 -104
  129. package/hooks/on-session-start.mjs +404 -110
  130. package/hooks/on-stop.mjs +48 -9
  131. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  132. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  133. package/hooks/pre-auq-clarity.mjs +787 -0
  134. package/hooks/pre-bash-issue-budget.mjs +133 -21
  135. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  136. package/hooks/pre-bash-staging-fence.mjs +4 -0
  137. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  138. package/hooks/skill-invocation-telemetry.mjs +21 -10
  139. package/monitors/monitors.json +6 -0
  140. package/package.json +3 -1
  141. package/pi/prompts/eli5.md +12 -0
  142. package/pi/prompts/journey-audit.md +12 -0
  143. package/rules/_index.md +9 -1
  144. package/rules/always-on/ask-via-tool.md +62 -0
  145. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  146. package/rules/always-on/build-value.md +47 -0
  147. package/rules/always-on/cross-session-messaging.md +59 -0
  148. package/rules/always-on/loop-and-monitor.md +221 -0
  149. package/rules/always-on/parallel-sessions.md +142 -12
  150. package/rules/always-on/receiving-review.md +108 -0
  151. package/rules/always-on/test-value.md +40 -0
  152. package/rules/always-on/verification-before-completion.md +77 -0
  153. package/scripts/archive-closed-prds.mjs +258 -18
  154. package/scripts/auq-audit.mjs +825 -0
  155. package/scripts/autopilot.mjs +12 -8
  156. package/scripts/backfill-evidence-digest.mjs +376 -0
  157. package/scripts/cursor-install.mjs +89 -48
  158. package/scripts/export-hw-learnings.mjs +143 -2
  159. package/scripts/express-path.mjs +299 -0
  160. package/scripts/generate-cursor-adapter.mjs +253 -0
  161. package/scripts/github-protection-audit.mjs +358 -0
  162. package/scripts/lib/auq/clarity.mjs +1314 -0
  163. package/scripts/lib/auq/parse.mjs +1006 -0
  164. package/scripts/lib/auq/schema.mjs +1457 -0
  165. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  166. package/scripts/lib/build-live-signals.mjs +24 -5
  167. package/scripts/lib/ci-status-banner.mjs +221 -68
  168. package/scripts/lib/command-blocker.mjs +70 -0
  169. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  170. package/scripts/lib/config/reconcile.mjs +79 -4
  171. package/scripts/lib/config/section-extractor.mjs +235 -36
  172. package/scripts/lib/config/vault-integration.mjs +12 -1
  173. package/scripts/lib/config-schema.mjs +9 -1
  174. package/scripts/lib/config.mjs +57 -6
  175. package/scripts/lib/convergence-monitor.mjs +13 -2
  176. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  177. package/scripts/lib/dispatcher/cli.mjs +2 -2
  178. package/scripts/lib/dispatcher/rank.mjs +4 -7
  179. package/scripts/lib/express-path.mjs +327 -0
  180. package/scripts/lib/file-lock.mjs +22 -4
  181. package/scripts/lib/gates/gate-full.mjs +84 -11
  182. package/scripts/lib/gates/gate-helpers.mjs +93 -21
  183. package/scripts/lib/git-config-drift.mjs +134 -5
  184. package/scripts/lib/host-identity.mjs +247 -2
  185. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  186. package/scripts/lib/io.mjs +239 -0
  187. package/scripts/lib/issue-budget.mjs +284 -31
  188. package/scripts/lib/learnings/io.mjs +55 -10
  189. package/scripts/lib/learnings/schema.mjs +95 -28
  190. package/scripts/lib/lock-reaper.mjs +7 -1
  191. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  192. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  193. package/scripts/lib/memory-banner.mjs +5 -2
  194. package/scripts/lib/memory-paths.mjs +15 -6
  195. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  196. package/scripts/lib/owner-interview.mjs +78 -32
  197. package/scripts/lib/peer-discovery.mjs +73 -22
  198. package/scripts/lib/platform.mjs +72 -9
  199. package/scripts/lib/plugin-root.mjs +143 -19
  200. package/scripts/lib/project-hygiene.mjs +104 -4
  201. package/scripts/lib/quality-gate.mjs +271 -13
  202. package/scripts/lib/reconcile/emitter.mjs +87 -19
  203. package/scripts/lib/reconcile/engine.mjs +281 -13
  204. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  205. package/scripts/lib/reconcile/renderer.mjs +165 -7
  206. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  207. package/scripts/lib/reconcile/writer.mjs +415 -84
  208. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  209. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  210. package/scripts/lib/resource-probe.mjs +18 -2
  211. package/scripts/lib/rule-loader.mjs +37 -2
  212. package/scripts/lib/rules-sync.mjs +51 -8
  213. package/scripts/lib/scope-gate.mjs +90 -0
  214. package/scripts/lib/session-close-backfill.mjs +369 -28
  215. package/scripts/lib/session-discovery.mjs +13 -3
  216. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  217. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  218. package/scripts/lib/session-id.mjs +30 -14
  219. package/scripts/lib/session-identity/own-session.mjs +159 -0
  220. package/scripts/lib/session-lock.mjs +85 -30
  221. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  222. package/scripts/lib/session-schema/validator.mjs +40 -0
  223. package/scripts/lib/session-start-probes.mjs +608 -0
  224. package/scripts/lib/session-transition.mjs +277 -0
  225. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  226. package/scripts/lib/spiral-carryover.mjs +111 -9
  227. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  228. package/scripts/lib/state-md/mission-status.mjs +467 -72
  229. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  230. package/scripts/lib/state-md.mjs +12 -2
  231. package/scripts/lib/telemetry/sync.mjs +46 -8
  232. package/scripts/lib/validate/check-agents.mjs +66 -0
  233. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  234. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  235. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  236. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  237. package/scripts/lib/validate/check-hooks-symmetry.mjs +59 -63
  238. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  239. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  240. package/scripts/lib/validate/check-rules.mjs +153 -9
  241. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  242. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  243. package/scripts/lib/validate/repo-files.mjs +275 -0
  244. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  245. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  246. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  247. package/scripts/lib/vault-mirror/process.mjs +99 -43
  248. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  249. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  250. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  251. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  252. package/scripts/lib/vcs-repo-spec.mjs +182 -13
  253. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  254. package/scripts/lib/wave-resource-gate.mjs +67 -73
  255. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  256. package/scripts/materialize-wave-scope.mjs +478 -0
  257. package/scripts/mcp-server.sh +11 -2
  258. package/scripts/parse-config.mjs +65 -0
  259. package/scripts/release.mjs +443 -122
  260. package/scripts/run-quality-gate.mjs +14 -0
  261. package/scripts/token-audit.sh +9 -2
  262. package/scripts/validate-plugin.mjs +6 -0
  263. package/scripts/validate-wave-scope.mjs +73 -1
  264. package/scripts/vault-backfill.mjs +32 -5
  265. package/scripts/vault-mirror.mjs +203 -34
  266. package/skills/_shared/monitor-patterns.md +31 -5
  267. package/skills/_shared/parallel-aware-auq.md +31 -25
  268. package/skills/_shared/parallel-aware-preamble.md +35 -4
  269. package/skills/_shared/platform-tools.md +11 -5
  270. package/skills/_shared/state-ownership.md +61 -8
  271. package/skills/autopilot/SKILL.md +5 -1
  272. package/skills/bootstrap/SKILL.md +5 -4
  273. package/skills/bootstrap/_shared-template.md +18 -10
  274. package/skills/bootstrap/deep-template.md +10 -6
  275. package/skills/bootstrap/fast-template.md +15 -8
  276. package/skills/bootstrap/standard-template.md +10 -6
  277. package/skills/brainstorm/SKILL.md +18 -18
  278. package/skills/brainstorm/soul.md +12 -0
  279. package/skills/claude-md-drift-check/checker.mjs +39 -11
  280. package/skills/discovery/SKILL.md +28 -24
  281. package/skills/dispatcher/SKILL.md +1 -1
  282. package/skills/eli5/SKILL.md +43 -0
  283. package/skills/evolve/SKILL.md +8 -9
  284. package/skills/gitlab-ops/SKILL.md +30 -26
  285. package/skills/grill/SKILL.md +6 -6
  286. package/skills/grill/soul.md +16 -0
  287. package/skills/journey-audit/SKILL.md +269 -0
  288. package/skills/memory-cleanup/SKILL.md +2 -2
  289. package/skills/npm-publish/SKILL.md +4 -4
  290. package/skills/peekaboo-driver/SKILL.md +18 -6
  291. package/skills/persona-panel/SKILL.md +1 -1
  292. package/skills/plan/SKILL.md +18 -16
  293. package/skills/plan/mode-feature.md +1 -1
  294. package/skills/plan/mode-new.md +35 -23
  295. package/skills/plan/soul.md +12 -0
  296. package/skills/reconcile/SKILL.md +44 -4
  297. package/skills/session-end/SKILL.md +70 -24
  298. package/skills/session-end/metrics-collection.md +7 -4
  299. package/skills/session-end/phase-3-6-tail.md +48 -5
  300. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  301. package/skills/session-plan/SKILL.md +6 -1
  302. package/skills/session-plan/wave-template.md +1 -0
  303. package/skills/session-start/SKILL.md +96 -48
  304. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  305. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  306. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  307. package/skills/session-start/phase-8-5-express-path.md +77 -12
  308. package/skills/session-start/soul.md +110 -0
  309. package/skills/test-runner/SKILL.md +2 -2
  310. package/skills/using-orchestrator/SKILL.md +1 -1
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +4 -2
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +129 -24
  315. package/skills/write-executable-plan/SKILL.md +6 -6
  316. package/templates/_shared/journey-manifest.md +110 -0
  317. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  318. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  319. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  320. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  321. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  322. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  323. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  324. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  325. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  326. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  327. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  328. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  329. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  330. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  331. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  332. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  333. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  334. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  335. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  336. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  337. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  338. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  339. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  340. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  341. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  342. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  343. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  344. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
  345. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -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,132 @@ 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';
55
+ import { readTelemetryState, resolveConsent, isCiEnv } from '../scripts/lib/telemetry/consent.mjs';
56
+ import { loadOwnerConfig } from '../scripts/lib/owner-yaml.mjs';
52
57
 
53
58
  const execFileAsync = promisify(execFile);
54
59
 
60
+ // ---------------------------------------------------------------------------
61
+ // Banner buffer (#1089 / #1052 A3a)
62
+ // ---------------------------------------------------------------------------
63
+ //
64
+ // MEASURED 2026-08-21: this hook emitted FIVE independent
65
+ // `console.log(JSON.stringify({ systemMessage }))` calls, and Claude Code
66
+ // surfaces only the FIRST JSON object a SessionStart hook writes to stdout.
67
+ // A live run against a copied 5-peer registry produced four stdout lines of
68
+ // which the operator saw exactly one — the host/resource line. The three that
69
+ // were silently discarded included:
70
+ //
71
+ // ⚠️ 👥 Peers: 5 active (… session-orchestrator:main:wave-0 …)
72
+ // 🔍 Mechanical peer-detection: 1 active in same repo (…)
73
+ //
74
+ // i.e. the two lines whose entire job is to warn that ANOTHER SESSION HOLDS
75
+ // THIS WORKING COPY. The information was computed correctly, formatted
76
+ // correctly, and thrown away by the transport — the "built but not wired"
77
+ // class, with the wiring defect one layer below where anyone was looking.
78
+ //
79
+ // The fix is structural: every banner line goes into one buffer and leaves as
80
+ // ONE systemMessage. Adding a sixth banner in future therefore cannot
81
+ // re-introduce the bug.
82
+ const bannerLines = [];
83
+
84
+ /**
85
+ * Coordinator-directed context for the single end-of-hook flush (#1138).
86
+ *
87
+ * The banner above is for the OPERATOR (`systemMessage`); this is for the
88
+ * MODEL (`hookSpecificOutput.additionalContext`). Both leave in the SAME JSON
89
+ * object for the same reason the banner was consolidated: Claude Code surfaces
90
+ * only the FIRST JSON object a SessionStart hook writes to stdout, so a second
91
+ * `writeSync` would be silently discarded.
92
+ *
93
+ * @type {string|null}
94
+ */
95
+ let pendingAdditionalContext = null;
96
+
97
+ /**
98
+ * Queue one or more banner lines for the single end-of-hook flush.
99
+ * @param {string|null|undefined} line — multi-line strings are pushed verbatim
100
+ */
101
+ function pushBanner(line) {
102
+ if (typeof line !== 'string' || line.length === 0) return;
103
+ bannerLines.push(line);
104
+ }
105
+
106
+ /**
107
+ * Render a session id compactly for a banner line.
108
+ *
109
+ * Semantic ids (`main-2026-08-23-deep-1`) are already short and are printed
110
+ * verbatim — they carry the branch and the mode, which is exactly what an
111
+ * operator needs to recognise a peer. A UUID (Claude Code's `session_id` on
112
+ * hosts where no semantic id was resolved) is truncated to its first 8 chars:
113
+ * long enough to disambiguate the handful of peers a repo can have, short
114
+ * enough that three of them fit on one banner line.
115
+ *
116
+ * Ceiling: 8 chars is ~4 billion values against at most a few dozen live
117
+ * sessions per host — revisit only if two peers ever collide in a banner.
118
+ *
119
+ * @param {string} id
120
+ * @returns {string}
121
+ */
122
+ function shortSessionId(id) {
123
+ if (typeof id !== 'string' || id.length === 0) return '?';
124
+ return id.length > 24 ? id.slice(0, 8) : id;
125
+ }
126
+
127
+ /**
128
+ * Queue the ONE `additionalContext` string for the single end-of-hook flush.
129
+ * Last writer wins; there is deliberately no accumulation, because every
130
+ * character here costs context on every session start.
131
+ * @param {string|null|undefined} text
132
+ */
133
+ function setAdditionalContext(text) {
134
+ if (typeof text !== 'string' || text.length === 0) return;
135
+ pendingAdditionalContext = text;
136
+ }
137
+
138
+ /**
139
+ * Emit the queued banner lines AND any queued additionalContext as ONE JSON
140
+ * envelope.
141
+ *
142
+ * Both keys are valid siblings of one hook-output object: the shipped Claude
143
+ * Code binary (v2.1.241) documents `systemMessage` ("Display a message to the
144
+ * user (all hooks)") and `hookSpecificOutput.additionalContext` ("Text injected
145
+ * into model context") as fields of the same "Hook JSON Output" object, and its
146
+ * output validator recognises both keys on that one object.
147
+ *
148
+ * Uses `fs.writeSync(1, …)` rather than `console.log`: the top-level guard
149
+ * calls `process.exit(0)`, which discards anything still sitting in libuv's
150
+ * async write queue when stdout is a pipe (see
151
+ * `.claude/rules/anti-pattern-console-log-process-exit-drops-stdout…`). The
152
+ * banner is far below the 64 KiB pipe buffer today, but a synchronous write
153
+ * costs nothing and removes the failure mode rather than staying under it.
154
+ *
155
+ * Idempotent: a second call after a flush is a no-op.
156
+ */
157
+ function flushBanner() {
158
+ const hasBanner = bannerLines.length > 0;
159
+ const hasContext = typeof pendingAdditionalContext === 'string' && pendingAdditionalContext.length > 0;
160
+ if (!hasBanner && !hasContext) return;
161
+
162
+ const envelope = {};
163
+ if (hasBanner) envelope.systemMessage = bannerLines.join('\n');
164
+ if (hasContext) {
165
+ envelope.hookSpecificOutput = {
166
+ hookEventName: 'SessionStart',
167
+ additionalContext: pendingAdditionalContext,
168
+ };
169
+ }
170
+ bannerLines.length = 0;
171
+ pendingAdditionalContext = null;
172
+ try {
173
+ writeSync(1, `${JSON.stringify(envelope)}\n`);
174
+ } catch { /* stdout closed — the hook is informational and never blocks */ }
175
+ }
176
+
55
177
  // ---------------------------------------------------------------------------
56
178
  // Helpers
57
179
  // ---------------------------------------------------------------------------
@@ -191,92 +313,43 @@ async function readStdinJson(timeoutMs = 500) {
191
313
  */
192
314
  async function resolveSessionId(input, projectRoot) {
193
315
  const fromStdin = (input && (input.session_id || input.sessionId)) ?? null;
316
+ const parsedStdinId = parseSessionId(fromStdin);
317
+ const rawStdinSessionId = parsedStdinId?.format === 'uuid' ? fromStdin : null;
194
318
 
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.
319
+ // Mode normalization is shared by both branches so semantic attribution is
320
+ // derived independently of the raw session identity. A semantic id is always
321
+ // descriptive metadata, never the physical lock/registry session_id.
198
322
  const rawMode = (input && (input.mode || input.session_type)) || 'session';
199
323
  const normalizedMode =
200
324
  String(rawMode).toLowerCase().replace(/[^a-z-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'session';
201
325
 
202
326
  let sessionId;
203
327
  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.
328
+ // The lock and downstream consumers surface this independently-derived
329
+ // semantic attribution alongside the physical raw session id.
209
330
  let semanticSessionId = null;
210
331
 
211
- if (typeof fromStdin === 'string' && fromStdin.length > 0) {
212
- sessionId = fromStdin;
332
+ if (rawStdinSessionId !== null) {
333
+ sessionId = rawStdinSessionId;
213
334
  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
335
  } 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
- }
336
+ // A missing, malformed, or semantic stdin value is not a trustworthy raw
337
+ // identity. Generate the physical id locally; semantic derivation below is
338
+ // attribution only and must never become the lock/registry key.
339
+ sessionId = randomUUID();
340
+ source = 'generated-uuid';
278
341
  }
279
342
 
343
+ // Derive a descriptive semantic label for either raw-id source. Best-effort:
344
+ // a failure leaves it null without changing the physical raw session_id.
345
+ try {
346
+ const semCandidate = await deriveSemanticCandidate({
347
+ projectRoot,
348
+ mode: normalizedMode,
349
+ });
350
+ if (semCandidate) semanticSessionId = semCandidate;
351
+ } catch { /* best effort — leave semanticSessionId = null */ }
352
+
280
353
  try {
281
354
  const dir = path.join(projectRoot, '.orchestrator');
282
355
  await mkdir(dir, { recursive: true });
@@ -341,6 +414,10 @@ async function resolveSessionId(input, projectRoot) {
341
414
  *
342
415
  * Returns the candidate string on success, null on any failure. Never throws.
343
416
  *
417
+ * Contract (#1066, "Minimal"): the semantic id is a best-effort, host-wide
418
+ * monotonic LABEL, never an ownership key — ownership stays `(raw session_id,
419
+ * owner proof)`, and the two identity forms are never interchangeable.
420
+ *
344
421
  * Used by both branches of resolveSessionId() so the semantic_session_id
345
422
  * field on the session.lock is consistently populated regardless of whether
346
423
  * the SessionStart stdin payload provided a UUID (Claude Code) or nothing
@@ -351,7 +428,7 @@ async function resolveSessionId(input, projectRoot) {
351
428
  */
352
429
  async function deriveSemanticCandidate({ projectRoot, mode }) {
353
430
  try {
354
- const { resolveSemanticSessionId } = await import('../scripts/lib/session-id.mjs');
431
+ const { resolveSemanticSessionId, parseSessionId } = await import('../scripts/lib/session-id.mjs');
355
432
  const { discoverActiveSessions } = await import('../scripts/lib/session-discovery.mjs');
356
433
  const { execSync } = await import('node:child_process');
357
434
 
@@ -379,10 +456,28 @@ async function deriveSemanticCandidate({ projectRoot, mode }) {
379
456
  discoverActiveSessions(projectRoot).catch(() => []),
380
457
  readRegistry().catch(() => []),
381
458
  ]);
382
- const registrySessions = registryEntries.map((r) => ({
383
- sessionId: r.session_id,
384
- mode: r.mode ?? 'session',
385
- }));
459
+
460
+ // Registry census (#1066 AC1): the registry keeps the two identity forms in
461
+ // SEPARATE fields — `session_id` is the RAW id (a UUID on Claude Code) and
462
+ // `semantic_session_id` is the label. resolveSemanticSessionId counts only
463
+ // semantic candidates, so projecting `session_id` alone made the host-wide
464
+ // registry contribute NOTHING to the n-increment, and two sessions on one
465
+ // host could mint the same label. Preference order per entry:
466
+ // 1. `semantic_session_id`, when it parses as semantic;
467
+ // 2. `session_id`, ONLY when it itself parses as semantic (Codex/Cursor
468
+ // may write a semantic raw id).
469
+ // A legacy v1 entry (no `semantic_session_id`, UUID `session_id`) matches
470
+ // neither and is dropped — a UUID is never reintroduced as a candidate.
471
+ const semanticLabelOf = (entry) => {
472
+ for (const candidate of [entry?.semantic_session_id, entry?.session_id]) {
473
+ if (parseSessionId(candidate)?.format === 'semantic') return candidate;
474
+ }
475
+ return null;
476
+ };
477
+ const registrySessions = registryEntries.flatMap((r) => {
478
+ const label = semanticLabelOf(r);
479
+ return label === null ? [] : [{ sessionId: label, mode: r.mode ?? 'session' }];
480
+ });
386
481
  const activeSessions = [...localSessions, ...registrySessions];
387
482
 
388
483
  return await resolveSemanticSessionId({
@@ -415,15 +510,43 @@ async function emitHostBanner(projectRoot) {
415
510
  if (!host || !resources) return null;
416
511
 
417
512
  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
513
+
514
+ // #1089: report the memory number a human can ACT on.
515
+ //
516
+ // The old line printed `ram_free_gb`, which on Darwin is `os.freemem()` =
517
+ // `Pages free` only. Median across 1477 measured session starts: 0.4 GB —
518
+ // on hosts with 24-128 GB installed. So this banner spent four months
519
+ // telling the operator the machine was seconds from death while the same
520
+ // machine reported 40%+ memory free and ran full waves clean. Six repos
521
+ // logged that false alarm as a learning; one capped agents for five
522
+ // consecutive sessions off this number.
523
+ //
524
+ // Precedence mirrors evaluate()'s memorySignal(): pressure > available >
525
+ // free. `free` survives only as the last resort — on Linux/Windows it is
526
+ // genuinely accurate, which is exactly where nothing better is published.
527
+ const memLine = (() => {
528
+ if (resources.memory_pressure_pct_free !== null && resources.memory_pressure_pct_free !== undefined) {
529
+ return `${resources.memory_pressure_pct_free}% memory free (OS pressure)`;
530
+ }
531
+ if (resources.ram_available_gb !== null && resources.ram_available_gb !== undefined) {
532
+ return `${resources.ram_available_gb.toFixed(1)} GB available`;
533
+ }
534
+ return `${resources.ram_free_gb.toFixed(1)} GB free`;
535
+ })();
536
+
537
+ // Peer SESSIONS, not Claude PROCESSES. Measured ratio 6.0:1 — the old
538
+ // suffix read "17 Claude processes running" on a host carrying 3 sessions,
539
+ // which is alarming and means nothing actionable.
540
+ const peerSuffix = resources.peer_sessions_count === null || resources.peer_sessions_count === undefined
419
541
  ? ''
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}`;
542
+ : ` · ${resources.peer_sessions_count} peer session${resources.peer_sessions_count === 1 ? '' : 's'}`;
543
+ const resourceLine = `📊 Resources: ${memLine} · CPU ${resources.cpu_load_pct}%${peerSuffix}`;
423
544
 
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 }));
545
+ // systemMessage envelope is the Claude Code hook contract; buffered so all
546
+ // banner lines leave as ONE object (see flushBanner Claude Code reads
547
+ // only the first).
548
+ pushBanner(hostLine);
549
+ pushBanner(resourceLine);
427
550
 
428
551
  return { host, resources };
429
552
  } catch {
@@ -431,6 +554,60 @@ async function emitHostBanner(projectRoot) {
431
554
  }
432
555
  }
433
556
 
557
+ /**
558
+ * The one-time telemetry-consent instruction handed to the coordinator (#1138).
559
+ *
560
+ * Deliberately terse: it is injected into the model context on EVERY session
561
+ * start until the operator decides, so every character is a recurring cost.
562
+ * It names the phase rather than restating it, so the AUQ wording stays
563
+ * single-sourced in `skills/session-start/SKILL.md` § Phase 6.8.
564
+ */
565
+ const CONSENT_NUDGE =
566
+ 'Telemetrie-Consent ist auf diesem Host unentschieden. Stelle JETZT, vor jeder anderen Arbeit, '
567
+ + 'genau einmal die consent-neutrale AskUserQuestion aus skills/session-start/SKILL.md § Phase 6.8 '
568
+ + '(zwei Optionen, keine "(Recommended)"-Markierung; Codex/Cursor: nummerierte Liste) und rufe danach '
569
+ + 'grantConsent() bzw. denyConsent() aus scripts/lib/telemetry/consent.mjs. Danach nie wieder fragen.';
570
+
571
+ /**
572
+ * #1138 — queue the consent nudge when, and only when, this host has no
573
+ * telemetry decision on record.
574
+ *
575
+ * WHY A HOOK AT ALL. Phase 6.8 was prose only: it fired when the coordinator
576
+ * happened to reach line ~1060 of a 1230-line skill, after 24 other phases.
577
+ * Measured 2026-08-23: 0 records from any host other than the author's.
578
+ *
579
+ * WHY `isCiEnv` AND NOT `isHeadless`. `isHeadless()` answers "can THIS process
580
+ * prompt on a TTY?" — and this process is a hook whose stdout is a pipe, so it
581
+ * would answer `true` unconditionally and the nudge could never fire. Measured
582
+ * in a plain non-TTY subprocess on this host, 2026-08-23:
583
+ * `isHeadless()=true isCiEnv()=false stdout.isTTY=undefined`
584
+ * The question that actually matters here is a different one: "is there an
585
+ * operator who can answer an AskUserQuestion?" The prompting happens in the
586
+ * live session, not in this process, so TTY-ness of the hook is irrelevant and
587
+ * `isCiEnv()` — the fail-closed "this is unattended automation" probe — is the
588
+ * correct gate. (The same trap sits in the Phase 6.8 prose, which passes
589
+ * `interactive: !isHeadless()` from a `node -e` subprocess whose stdout is also
590
+ * a pipe: it would have resolved `prompt:false` every time even if executed.)
591
+ *
592
+ * `resolveConsent` remains the single decision point: `prompt` is true only for
593
+ * a host with no stored decision, no env override, and no fleet flag.
594
+ *
595
+ * Never throws — a failure here must not affect session start.
596
+ */
597
+ function maybeQueueConsentNudge() {
598
+ try {
599
+ if (isCiEnv(process.env)) return;
600
+ const consent = resolveConsent({
601
+ env: process.env,
602
+ ownerConfig: loadOwnerConfig().config,
603
+ state: readTelemetryState().record,
604
+ interactive: true,
605
+ });
606
+ if (consent.prompt !== true) return;
607
+ setAdditionalContext(CONSENT_NUDGE);
608
+ } catch { /* best effort — the nudge is informational, never blocks */ }
609
+ }
610
+
434
611
  // ---------------------------------------------------------------------------
435
612
  // Main
436
613
  // ---------------------------------------------------------------------------
@@ -464,11 +641,7 @@ async function main() {
464
641
  // Config / STATE.md / git, and only then the tool. The banner carries the
465
642
  // ORDER, not an absolute; full routing + exceptions in
466
643
  // .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 */ }
644
+ pushBanner('🎯 Decide: operator verb (/go) > derive+report > AUQ if blocking (.claude/rules/ask-via-tool.md).');
472
645
  }
473
646
 
474
647
  // F1.3 cold-start abandonment fix (PRD 2026-05-21). Emit a one-shot
@@ -490,11 +663,7 @@ async function main() {
490
663
  enabled: coldStartCfg.enabled !== false,
491
664
  });
492
665
  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 */ }
666
+ pushBanner(decision.bannerLines.join('\n'));
498
667
  if (decision.markerPath) {
499
668
  await consumeMarker(decision.markerPath).catch(() => false);
500
669
  }
@@ -592,17 +761,61 @@ async function main() {
592
761
  // Append a peer line to the host banner when a banner was already emitted
593
762
  // and at least one peer is live on this host.
594
763
  if (bannerData && peers.length > 0) {
764
+ // #1052 A3a — split peers by the axis that actually decides behaviour.
765
+ //
766
+ // `.claude/rules/parallel-sessions.md` defines the operator-session axis by
767
+ // the WORKING COPY, not by reachability: a peer in this checkout contends
768
+ // for one git index, one filesystem, one STATE.md, and can hold the
769
+ // wave-scope guard. A peer in another repo contends only for host capacity.
770
+ // Those are different problems and the old single-line summary blurred them
771
+ // into one comma-separated list sorted by nothing.
772
+ //
773
+ // Correlate on `repo_path_hash` rather than `repo_name`: a sibling worktree
774
+ // of this repo carries a DIFFERENT working copy (and a different hash) while
775
+ // often sharing a similar name, and it is the checkout — not the name — that
776
+ // can collide. Falls back to name comparison only if the hash is missing.
777
+ let selfRepoHash = null;
778
+ try { selfRepoHash = repoPathHash(projectRoot); } catch { /* best effort */ }
779
+ const sameCopy = peers.filter((p) =>
780
+ selfRepoHash && p.repo_path_hash ? p.repo_path_hash === selfRepoHash : false);
781
+ const elsewhere = peers.filter((p) => !sameCopy.includes(p));
782
+
595
783
  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 */ }
784
+ // WARN on any peer in THIS working copy one is already enough to collide
785
+ // or on host-wide count crossing the configured threshold.
786
+ const icon = sameCopy.length > 0 || peers.length >= threshold ? '⚠️ ' : '';
787
+
788
+ // Semantic ids are NOT unique — several live sessions routinely share e.g.
789
+ // `main-2026-08-21-session-2`, which makes a bare semantic label unusable
790
+ // for addressing a specific peer. Append a short uuid discriminator so two
791
+ // rows can be told apart (and so the id can be pasted into a lookup).
792
+ const fmt = (p) => {
793
+ const label = p.semantic_session_id ?? p.session_id ?? 'unknown';
794
+ const disc = p.session_id ? `#${String(p.session_id).slice(0, 8)}` : '';
795
+ return `${label}${disc}:${p.branch ?? 'unknown'}:wave-${p.current_wave ?? 0}`;
796
+ };
797
+ pushBanner(`${icon}👥 Peers: ${peers.length} live on this host`);
798
+ if (sameCopy.length > 0) {
799
+ const list = sameCopy.slice(0, 3).map(fmt).join(', ');
800
+ const more = sameCopy.length > 3 ? ` +${sameCopy.length - 3} more` : '';
801
+ // This is the line that matters. A peer here means PSA-002 territory:
802
+ // it may hold .claude/wave-scope.json, carry uncommitted work in files
803
+ // you are about to edit, or switch the branch under you.
804
+ pushBanner(` ⚠️ ${sameCopy.length} in THIS working copy — coordinate before editing (${list}${more})`);
805
+ }
806
+ if (elsewhere.length > 0) {
807
+ const list = elsewhere
808
+ .slice(0, 4)
809
+ .map((p) => `${p.repo_name ?? 'unknown'}:${p.branch ?? 'unknown'}`)
810
+ .join(', ');
811
+ const more = elsewhere.length > 4 ? ` +${elsewhere.length - 4} more` : '';
812
+ pushBanner(` other repos (host capacity only): ${list}${more}`);
813
+ }
814
+ // #1052 A3a acceptance criterion: state the limit, never imply a status we
815
+ // cannot read. `ListAgents` is a MODEL-side tool; this hook is a Node
816
+ // process and structurally cannot call it, so busy/waiting/idle is
817
+ // unavailable here. The coordinator half (A3b) overlays it later.
818
+ pushBanner(' (registry view: repo/branch/wave. Liveness (busy/idle) is model-side — not available in this hook.)');
606
819
  }
607
820
 
608
821
  // Epic #583 W3-P3 — Mechanical peer-detection banner (independent of the
@@ -627,19 +840,74 @@ async function main() {
627
840
  const allActive = await discoverActiveSessions(projectRoot);
628
841
  const mechanicalPeers = allActive.filter((s) => s.sessionId !== sessionId);
629
842
  if (mechanicalPeers.length > 0) {
843
+ // #1137 part 1 — say WHERE, not just how many. discoverActiveSessions()
844
+ // walks EVERY path `git worktree list` reports, which includes worktrees
845
+ // another coordinator parked outside this working copy (e.g. under
846
+ // /private/tmp/…). Measured 2026-08-23: all 4 reported "peers" were the
847
+ // 4 registered worktrees, none of them in this checkout — and the old
848
+ // wording ("active in same repo") read as 4 sessions competing for THIS
849
+ // directory. The surface is deliberately unchanged (a foreign-worktree
850
+ // session IS a peer of this repo); only the label and the per-peer
851
+ // detail are, so the operator can tell the two apart at a glance.
630
852
  const summary = mechanicalPeers
631
853
  .slice(0, 3)
632
- .map((p) => `${p.sessionId}:${p.mode ?? 'session'}`)
854
+ .map((p) => {
855
+ const where = p.worktreePath ? path.basename(p.worktreePath) : '?';
856
+ // #1151 — carry the peer's MODE when the source recorded one.
857
+ // `worktree:id` says where a peer sits but not what it is doing,
858
+ // and the operator's next decision (wait / promote / proceed)
859
+ // turns on exactly that: a `deep` peer holding this worktree set
860
+ // is a different situation from a `discovery` one. Guarded rather
861
+ // than assumed — sessionFromLock() passes `lock.mode` verbatim, so
862
+ // a lock written without the field yields undefined here, while
863
+ // sessionFromRegistryEntry() always defaults it to 'session'.
864
+ const mode = typeof p.mode === 'string' && p.mode.length > 0 ? `:${p.mode}` : '';
865
+ return `${where}:${shortSessionId(p.sessionId)}${mode}`;
866
+ })
633
867
  .join(', ');
634
868
  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 */ }
869
+ pushBanner(`🔍 Mechanical peer-detection: ${mechanicalPeers.length} active in this repo's worktree set (${summary}${overflow})`);
639
870
  }
640
871
  } catch { /* best effort — banner is informational, never blocks */ }
641
872
  }
642
873
 
874
+ // Phase 4 measurement probes — the mechanical caller (#1128).
875
+ //
876
+ // `skills/session-start/SKILL.md` § Phase 4 names 18 probes with module paths
877
+ // and entry functions. Measured 2026-08-23 at `4f6404e`, not one of them had
878
+ // a caller anywhere in hooks/, npm scripts, .gitlab-ci.yml or .husky/ — the
879
+ // only caller was the prose itself, and across 336 recorded session starts
880
+ // there was no event proving any of them had ever run. Built, documented,
881
+ // never wired.
882
+ //
883
+ // Placement is deliberate: AFTER the backfill (so `sessions-staleness` and
884
+ // `sessions-integrity` measure the reconstructed ledger, not the stale one)
885
+ // and BEFORE flushBanner() (so probe findings ride the single systemMessage
886
+ // envelope instead of adding a second stdout object Claude Code would
887
+ // discard — see the bannerLines docstring and HR-106).
888
+ //
889
+ // Best-effort exactly like `backfillOnSessionStart` above: the runner has no
890
+ // rejecting path of its own, and this try/catch is defence-in-depth so a
891
+ // future regression inside it still cannot block a session start.
892
+ // Escape hatch: SO_DISABLE_STARTUP_PROBES=1.
893
+ //
894
+ // `enable-host-banner: false` silences the DISPLAY, never the MEASUREMENT.
895
+ // The documented opt-out ("a user who silenced session-start banners has
896
+ // opted out of ALL session-start banners") governs `pushBanner` and nothing
897
+ // else: the probes still run and `orchestrator.probes.completed` is still
898
+ // written, because the defect this wiring repairs is precisely that nobody
899
+ // could tell whether the probes had ever run (HR-105). Gating the run on a
900
+ // display preference would rebuild that blind spot behind a config key.
901
+ if (process.env.SO_DISABLE_STARTUP_PROBES !== '1') {
902
+ try {
903
+ const { runSessionStartProbes } = await import('../scripts/lib/session-start-probes.mjs');
904
+ const probeRun = await runSessionStartProbes({ repoRoot: projectRoot });
905
+ if (bannerData) {
906
+ for (const line of probeRun.bannerLines) pushBanner(line);
907
+ }
908
+ } catch { /* hook must remain non-blocking */ }
909
+ }
910
+
643
911
  const payload = {
644
912
  platform,
645
913
  project: projectName,
@@ -652,9 +920,31 @@ async function main() {
652
920
  payload.ram_free_gb = bannerData.resources.ram_free_gb;
653
921
  payload.cpu_load_pct = bannerData.resources.cpu_load_pct;
654
922
  payload.claude_processes_count = bannerData.resources.claude_processes_count;
923
+ // #1089 — record the signals the verdict is actually computed from, so the
924
+ // firing rate of each rule class is measurable AFTER the fact.
925
+ //
926
+ // This is the defect that let the old rule set fire on 99.0% of starts for
927
+ // four months undetected: `resource_verdict` was written to sessions.jsonl
928
+ // for exactly 15 of 1734 sessions, all inside one week in April 2026, and
929
+ // the fields logged here were the two MISLEADING ones (`ram_free_gb`,
930
+ // `claude_processes_count`) — so even the surviving telemetry could not
931
+ // have falsified the thresholds. The three added below are the ones the
932
+ // rules now judge on. `.claude/rules/host-resources.md` HR-005 turns them
933
+ // into a standing 10%-firing-rate audit.
934
+ payload.ram_available_gb = bannerData.resources.ram_available_gb ?? null;
935
+ payload.memory_pressure_pct_free = bannerData.resources.memory_pressure_pct_free ?? null;
936
+ payload.peer_sessions_count = bannerData.resources.peer_sessions_count ?? null;
655
937
  }
656
938
  await emitEvent('orchestrator.session.started', payload);
657
939
 
940
+ // #1138 — one-time telemetry-consent nudge. Queued (never written) here so it
941
+ // rides the single stdout envelope below.
942
+ maybeQueueConsentNudge();
943
+
944
+ // Single flush — see the bannerLines docstring for why this must stay the
945
+ // only stdout write in the hook.
946
+ flushBanner();
947
+
658
948
  // Size-based rotation of events.jsonl (#251). Session-start is the single
659
949
  // rotation trigger — per-append overhead is rejected design. Any failure
660
950
  // is swallowed: rotation must NEVER block the hook.
@@ -681,6 +971,10 @@ async function main() {
681
971
  }
682
972
 
683
973
  // Top-level guard — always exit 0 (non-blocking informational hook).
974
+ // flushBanner() runs here too so a throw partway through main() still surfaces
975
+ // whatever was already collected; it is idempotent, so the normal path (which
976
+ // flushes at the end of main) does not double-emit.
684
977
  main().catch(() => {}).finally(() => {
978
+ flushBanner();
685
979
  process.exit(0);
686
980
  });