session-orchestrator 3.22.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 (268) 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 +8 -0
  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/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  74. package/.cursor/skills/session-end/SKILL.md +13 -0
  75. package/.cursor/skills/session-plan/SKILL.md +13 -0
  76. package/.cursor/skills/session-start/SKILL.md +13 -0
  77. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  78. package/.cursor/skills/spinout/SKILL.md +12 -0
  79. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  80. package/.cursor/skills/test-runner/SKILL.md +13 -0
  81. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  82. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  83. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  84. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  85. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  86. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  87. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  88. package/.mcp.json +4 -1
  89. package/CHANGELOG.md +168 -0
  90. package/README.md +18 -15
  91. package/agents/AGENTS.md +23 -4
  92. package/agents/code-implementer.md +2 -1
  93. package/agents/db-specialist.md +2 -1
  94. package/agents/docs-writer.md +3 -1
  95. package/agents/eval-judge.md +1 -1
  96. package/agents/session-reviewer.md +7 -1
  97. package/agents/test-writer.md +2 -1
  98. package/agents/ui-developer.md +2 -1
  99. package/commands/bootstrap.md +2 -2
  100. package/commands/close.md +3 -1
  101. package/commands/go.md +1 -1
  102. package/commands/journey-audit.md +43 -0
  103. package/docs/USER-GUIDE.md +2 -2
  104. package/docs/ci-setup.md +14 -0
  105. package/docs/codex-setup.md +64 -0
  106. package/docs/components.md +6 -6
  107. package/docs/cursor-setup.md +26 -47
  108. package/docs/events-schema.md +76 -4
  109. package/docs/github-mirror-protection.md +197 -0
  110. package/docs/pi-setup.md +2 -0
  111. package/docs/rule-authoring.md +3 -1
  112. package/docs/scope-collision-guard.md +49 -2
  113. package/docs/session-config-reference.md +26 -4
  114. package/docs/session-config-template.md +4 -3
  115. package/docs/telemetry.md +22 -0
  116. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  117. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  118. package/hooks/enforce-scope.mjs +64 -0
  119. package/hooks/hooks-codex.json +1 -1
  120. package/hooks/hooks-cursor.json +201 -20
  121. package/hooks/hooks-pi.json +1 -1
  122. package/hooks/hooks.json +2 -2
  123. package/hooks/on-session-end.mjs +211 -10
  124. package/hooks/on-session-start.mjs +214 -11
  125. package/hooks/on-stop.mjs +48 -9
  126. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  127. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  128. package/hooks/pre-bash-issue-budget.mjs +117 -4
  129. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  130. package/hooks/pre-bash-staging-fence.mjs +4 -0
  131. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  132. package/hooks/skill-invocation-telemetry.mjs +21 -10
  133. package/monitors/monitors.json +6 -0
  134. package/package.json +1 -1
  135. package/pi/prompts/journey-audit.md +12 -0
  136. package/rules/_index.md +9 -1
  137. package/rules/always-on/ask-via-tool.md +62 -0
  138. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  139. package/rules/always-on/build-value.md +47 -0
  140. package/rules/always-on/cross-session-messaging.md +59 -0
  141. package/rules/always-on/loop-and-monitor.md +221 -0
  142. package/rules/always-on/parallel-sessions.md +142 -12
  143. package/rules/always-on/receiving-review.md +108 -0
  144. package/rules/always-on/test-value.md +40 -0
  145. package/rules/always-on/verification-before-completion.md +77 -0
  146. package/scripts/archive-closed-prds.mjs +258 -18
  147. package/scripts/autopilot.mjs +5 -0
  148. package/scripts/backfill-evidence-digest.mjs +376 -0
  149. package/scripts/cursor-install.mjs +89 -48
  150. package/scripts/export-hw-learnings.mjs +143 -2
  151. package/scripts/express-path.mjs +299 -0
  152. package/scripts/generate-cursor-adapter.mjs +253 -0
  153. package/scripts/github-protection-audit.mjs +358 -0
  154. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  155. package/scripts/lib/build-live-signals.mjs +24 -5
  156. package/scripts/lib/ci-status-banner.mjs +158 -11
  157. package/scripts/lib/command-blocker.mjs +70 -0
  158. package/scripts/lib/config/reconcile.mjs +79 -4
  159. package/scripts/lib/config/section-extractor.mjs +235 -36
  160. package/scripts/lib/config-schema.mjs +9 -1
  161. package/scripts/lib/config.mjs +57 -6
  162. package/scripts/lib/convergence-monitor.mjs +13 -2
  163. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  164. package/scripts/lib/dispatcher/cli.mjs +2 -2
  165. package/scripts/lib/express-path.mjs +327 -0
  166. package/scripts/lib/file-lock.mjs +22 -4
  167. package/scripts/lib/gates/gate-full.mjs +81 -8
  168. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  169. package/scripts/lib/git-config-drift.mjs +134 -5
  170. package/scripts/lib/host-identity.mjs +247 -2
  171. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  172. package/scripts/lib/issue-budget.mjs +229 -30
  173. package/scripts/lib/learnings/io.mjs +55 -10
  174. package/scripts/lib/learnings/schema.mjs +95 -28
  175. package/scripts/lib/lock-reaper.mjs +7 -1
  176. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  177. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  178. package/scripts/lib/memory-banner.mjs +5 -2
  179. package/scripts/lib/memory-paths.mjs +15 -6
  180. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  181. package/scripts/lib/platform.mjs +72 -9
  182. package/scripts/lib/plugin-root.mjs +143 -19
  183. package/scripts/lib/project-hygiene.mjs +43 -3
  184. package/scripts/lib/quality-gate.mjs +271 -13
  185. package/scripts/lib/reconcile/emitter.mjs +87 -19
  186. package/scripts/lib/reconcile/engine.mjs +281 -13
  187. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  188. package/scripts/lib/reconcile/renderer.mjs +148 -3
  189. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  190. package/scripts/lib/reconcile/writer.mjs +415 -84
  191. package/scripts/lib/rule-loader.mjs +37 -2
  192. package/scripts/lib/rules-sync.mjs +51 -8
  193. package/scripts/lib/scope-gate.mjs +90 -0
  194. package/scripts/lib/session-close-backfill.mjs +369 -28
  195. package/scripts/lib/session-discovery.mjs +13 -3
  196. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  197. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  198. package/scripts/lib/session-id.mjs +30 -14
  199. package/scripts/lib/session-identity/own-session.mjs +159 -0
  200. package/scripts/lib/session-lock.mjs +85 -30
  201. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  202. package/scripts/lib/session-schema/validator.mjs +40 -0
  203. package/scripts/lib/session-start-probes.mjs +608 -0
  204. package/scripts/lib/session-transition.mjs +277 -0
  205. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  206. package/scripts/lib/spiral-carryover.mjs +90 -9
  207. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  208. package/scripts/lib/state-md/mission-status.mjs +350 -52
  209. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  210. package/scripts/lib/state-md.mjs +12 -2
  211. package/scripts/lib/telemetry/sync.mjs +46 -8
  212. package/scripts/lib/validate/check-agents.mjs +66 -0
  213. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  214. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  215. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  216. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  217. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  218. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  219. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  220. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  221. package/scripts/lib/validate/repo-files.mjs +275 -0
  222. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  223. package/scripts/lib/vault-mirror/process.mjs +99 -43
  224. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  225. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  226. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  227. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  228. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  229. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  230. package/scripts/materialize-wave-scope.mjs +209 -12
  231. package/scripts/mcp-server.sh +11 -2
  232. package/scripts/parse-config.mjs +65 -0
  233. package/scripts/token-audit.sh +9 -2
  234. package/scripts/validate-plugin.mjs +3 -0
  235. package/scripts/validate-wave-scope.mjs +67 -0
  236. package/scripts/vault-mirror.mjs +203 -34
  237. package/skills/_shared/monitor-patterns.md +31 -5
  238. package/skills/_shared/parallel-aware-auq.md +1 -1
  239. package/skills/_shared/parallel-aware-preamble.md +4 -2
  240. package/skills/_shared/platform-tools.md +11 -5
  241. package/skills/_shared/state-ownership.md +29 -2
  242. package/skills/autopilot/SKILL.md +5 -1
  243. package/skills/bootstrap/SKILL.md +3 -3
  244. package/skills/bootstrap/_shared-template.md +18 -10
  245. package/skills/bootstrap/deep-template.md +10 -6
  246. package/skills/bootstrap/fast-template.md +15 -8
  247. package/skills/bootstrap/standard-template.md +10 -6
  248. package/skills/claude-md-drift-check/checker.mjs +39 -11
  249. package/skills/dispatcher/SKILL.md +1 -1
  250. package/skills/journey-audit/SKILL.md +269 -0
  251. package/skills/peekaboo-driver/SKILL.md +15 -3
  252. package/skills/persona-panel/SKILL.md +1 -1
  253. package/skills/reconcile/SKILL.md +41 -1
  254. package/skills/session-end/SKILL.md +17 -4
  255. package/skills/session-end/metrics-collection.md +7 -4
  256. package/skills/session-end/phase-3-6-tail.md +11 -3
  257. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  258. package/skills/session-plan/SKILL.md +6 -1
  259. package/skills/session-plan/wave-template.md +1 -0
  260. package/skills/session-start/SKILL.md +30 -16
  261. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  262. package/skills/session-start/phase-8-5-express-path.md +77 -12
  263. package/skills/vault-sync/validator.mjs +31 -0
  264. package/skills/wave-executor/SKILL.md +4 -2
  265. package/skills/wave-executor/circuit-breaker.md +34 -9
  266. package/skills/wave-executor/wave-loop.md +102 -19
  267. package/templates/_shared/journey-manifest.md +110 -0
  268. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -52,6 +52,8 @@ import {
52
52
  } from '../scripts/lib/session-registry.mjs';
53
53
  import { detectColdStart, consumeMarker } from '../scripts/lib/cold-start-detector.mjs';
54
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';
55
57
 
56
58
  const execFileAsync = promisify(execFile);
57
59
 
@@ -79,6 +81,19 @@ const execFileAsync = promisify(execFile);
79
81
  // re-introduce the bug.
80
82
  const bannerLines = [];
81
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
+
82
97
  /**
83
98
  * Queue one or more banner lines for the single end-of-hook flush.
84
99
  * @param {string|null|undefined} line — multi-line strings are pushed verbatim
@@ -89,7 +104,46 @@ function pushBanner(line) {
89
104
  }
90
105
 
91
106
  /**
92
- * Emit every queued banner line as ONE systemMessage envelope.
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.
93
147
  *
94
148
  * Uses `fs.writeSync(1, …)` rather than `console.log`: the top-level guard
95
149
  * calls `process.exit(0)`, which discards anything still sitting in libuv's
@@ -101,11 +155,22 @@ function pushBanner(line) {
101
155
  * Idempotent: a second call after a flush is a no-op.
102
156
  */
103
157
  function flushBanner() {
104
- if (bannerLines.length === 0) return;
105
- const payload = JSON.stringify({ systemMessage: bannerLines.join('\n') });
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
+ }
106
170
  bannerLines.length = 0;
171
+ pendingAdditionalContext = null;
107
172
  try {
108
- writeSync(1, `${payload}\n`);
173
+ writeSync(1, `${JSON.stringify(envelope)}\n`);
109
174
  } catch { /* stdout closed — the hook is informational and never blocks */ }
110
175
  }
111
176
 
@@ -349,6 +414,10 @@ async function resolveSessionId(input, projectRoot) {
349
414
  *
350
415
  * Returns the candidate string on success, null on any failure. Never throws.
351
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
+ *
352
421
  * Used by both branches of resolveSessionId() so the semantic_session_id
353
422
  * field on the session.lock is consistently populated regardless of whether
354
423
  * the SessionStart stdin payload provided a UUID (Claude Code) or nothing
@@ -359,7 +428,7 @@ async function resolveSessionId(input, projectRoot) {
359
428
  */
360
429
  async function deriveSemanticCandidate({ projectRoot, mode }) {
361
430
  try {
362
- const { resolveSemanticSessionId } = await import('../scripts/lib/session-id.mjs');
431
+ const { resolveSemanticSessionId, parseSessionId } = await import('../scripts/lib/session-id.mjs');
363
432
  const { discoverActiveSessions } = await import('../scripts/lib/session-discovery.mjs');
364
433
  const { execSync } = await import('node:child_process');
365
434
 
@@ -387,10 +456,28 @@ async function deriveSemanticCandidate({ projectRoot, mode }) {
387
456
  discoverActiveSessions(projectRoot).catch(() => []),
388
457
  readRegistry().catch(() => []),
389
458
  ]);
390
- const registrySessions = registryEntries.map((r) => ({
391
- sessionId: r.session_id,
392
- mode: r.mode ?? 'session',
393
- }));
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
+ });
394
481
  const activeSessions = [...localSessions, ...registrySessions];
395
482
 
396
483
  return await resolveSemanticSessionId({
@@ -467,6 +554,60 @@ async function emitHostBanner(projectRoot) {
467
554
  }
468
555
  }
469
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
+
470
611
  // ---------------------------------------------------------------------------
471
612
  // Main
472
613
  // ---------------------------------------------------------------------------
@@ -699,16 +840,74 @@ async function main() {
699
840
  const allActive = await discoverActiveSessions(projectRoot);
700
841
  const mechanicalPeers = allActive.filter((s) => s.sessionId !== sessionId);
701
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.
702
852
  const summary = mechanicalPeers
703
853
  .slice(0, 3)
704
- .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
+ })
705
867
  .join(', ');
706
868
  const overflow = mechanicalPeers.length > 3 ? ` +${mechanicalPeers.length - 3} more` : '';
707
- pushBanner(`🔍 Mechanical peer-detection: ${mechanicalPeers.length} active in same repo (${summary}${overflow})`);
869
+ pushBanner(`🔍 Mechanical peer-detection: ${mechanicalPeers.length} active in this repo's worktree set (${summary}${overflow})`);
708
870
  }
709
871
  } catch { /* best effort — banner is informational, never blocks */ }
710
872
  }
711
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
+
712
911
  const payload = {
713
912
  platform,
714
913
  project: projectName,
@@ -738,6 +937,10 @@ async function main() {
738
937
  }
739
938
  await emitEvent('orchestrator.session.started', payload);
740
939
 
940
+ // #1138 — one-time telemetry-consent nudge. Queued (never written) here so it
941
+ // rides the single stdout envelope below.
942
+ maybeQueueConsentNudge();
943
+
741
944
  // Single flush — see the bannerLines docstring for why this must stay the
742
945
  // only stdout write in the hook.
743
946
  flushBanner();
package/hooks/on-stop.mjs CHANGED
@@ -16,7 +16,8 @@
16
16
  * JSONL format (`.orchestrator/metrics/events.jsonl`) — emitted via the canonical
17
17
  * `emitEvent()` so the JSONL record and the optional Clank webhook always carry the
18
18
  * SAME dotted event name (was: bare `stop`/`subagent_stop` in JSONL vs dotted in webhook):
19
- * Stop: {"timestamp":<ISO>,"event":"orchestrator.session.stopped","session_id":"...","wave":<int>,"branch":"...","commit":"...","duration_ms":<int>}
19
+ * Stop: {"timestamp":<ISO>,"event":"orchestrator.session.stopped","session_id":"...","semantic_session_id":"...","wave":<int>,"branch":"...","commit":"...","duration_ms":<int>}
20
+ * (`session_id` / `semantic_session_id` are omitted when unresolvable — #1068 AC1.)
20
21
  * SubagentStop: {"timestamp":<ISO>,"event":"orchestrator.agent.stopped","agent":"<name>"}
21
22
  */
22
23
 
@@ -29,6 +30,7 @@ if (!shouldRunHook('on-stop')) process.exit(0);
29
30
 
30
31
  import { emitEvent } from '../scripts/lib/events.mjs';
31
32
  import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
33
+ import { parseSessionId } from '../scripts/lib/session-id.mjs';
32
34
  import { heartbeat, logSweepEvent } from '../scripts/lib/session-registry.mjs';
33
35
  import { updateHeartbeat } from '../scripts/lib/session-lock.mjs';
34
36
 
@@ -216,17 +218,39 @@ async function readWaveNumber(projectRoot) {
216
218
  // ---------------------------------------------------------------------------
217
219
 
218
220
  /**
219
- * Resolve this session's id. Stdin payload wins. If Claude Code did not pass
220
- * one (Codex / Cursor paths, or older harnesses), fall back to
221
- * `.orchestrator/current-session.json` which on-session-start.mjs writes.
221
+ * Resolve this session's id + its attested semantic id. A stdin payload id wins
222
+ * ONLY when it parses as a UUID. If Claude Code did not pass one (Codex /
223
+ * Cursor paths, or older harnesses) — or passed something that is not a UUID —
224
+ * fall back to `.orchestrator/current-session.json` which on-session-start.mjs
225
+ * writes.
226
+ *
227
+ * #1091 / Kanevry#66 — WRITER/READER SYMMETRY, same defect class as
228
+ * `hooks/on-session-end.mjs`: `on-session-start.mjs` accepts a stdin raw id
229
+ * only when `parseSessionId(fromStdin)?.format === 'uuid'` and otherwise mints
230
+ * a `randomUUID()`, so every artifact this hook then addresses by id (the host
231
+ * registry entry, `session.lock`) is keyed by a UUID. Passing a non-UUID stdin
232
+ * string through meant `heartbeat()` and `updateHeartbeat()` addressed a key
233
+ * that was never written: the registry entry silently went un-refreshed (and
234
+ * aged into the zombie sweep) while the lock heartbeat no-oped on an ownership
235
+ * mismatch.
236
+ *
237
+ * `semanticSessionId` (#1068 AC1) is read from the SAME file and is deliberately
238
+ * gated on the resolved id BEING the recorded one — `current-session.json` is a
239
+ * single repo-global file describing whichever session most recently ran
240
+ * SessionStart, so an unrelated window's turn-end must not inherit a live
241
+ * peer's semantic identity (the #863 defect (c) contamination shape, mirrored
242
+ * here from `hooks/on-session-end.mjs`).
222
243
  *
223
244
  * @param {object|null} input
224
245
  * @param {string} projectRoot
225
- * @returns {Promise<string|null>}
246
+ * @returns {Promise<{sessionId: string|null, semanticSessionId: string|null}>}
226
247
  */
227
248
  async function resolveSessionId(input, projectRoot) {
228
249
  const fromStdin = input?.session_id ?? input?.sessionId ?? null;
229
- if (typeof fromStdin === 'string' && fromStdin.length > 0) return fromStdin;
250
+ let sessionId = parseSessionId(fromStdin)?.format === 'uuid' ? fromStdin : null;
251
+
252
+ let recordedId = null;
253
+ let semanticSessionId = null;
230
254
  try {
231
255
  const raw = await fs.readFile(
232
256
  path.join(projectRoot, '.orchestrator', 'current-session.json'),
@@ -234,10 +258,20 @@ async function resolveSessionId(input, projectRoot) {
234
258
  );
235
259
  const parsed = JSON.parse(raw);
236
260
  if (typeof parsed.session_id === 'string' && parsed.session_id.length > 0) {
237
- return parsed.session_id;
261
+ recordedId = parsed.session_id;
262
+ }
263
+ if (typeof parsed.semantic_session_id === 'string' && parsed.semantic_session_id.length > 0) {
264
+ semanticSessionId = parsed.semantic_session_id;
238
265
  }
239
266
  } catch { /* missing or unparseable is fine */ }
240
- return null;
267
+
268
+ if (sessionId === null) sessionId = recordedId;
269
+
270
+ const isRecordedSession = sessionId !== null && sessionId === recordedId;
271
+ return {
272
+ sessionId,
273
+ semanticSessionId: isRecordedSession ? semanticSessionId : null,
274
+ };
241
275
  }
242
276
 
243
277
  /**
@@ -250,7 +284,7 @@ async function handleStop(input) {
250
284
  const wave = await readWaveNumber(projectRoot);
251
285
  const { commit, branch } = await gitInfo(projectRoot);
252
286
 
253
- const sessionId = await resolveSessionId(input, projectRoot);
287
+ const { sessionId, semanticSessionId } = await resolveSessionId(input, projectRoot);
254
288
 
255
289
  // v3.1.0 multi-session registry (#169), corrected in #1047 — REFRESH the
256
290
  // registry entry here; never remove it.
@@ -323,8 +357,13 @@ async function handleStop(input) {
323
357
  // Single emission path: emitEvent writes the canonical {timestamp, event, ...payload}
324
358
  // JSONL record AND fires the optional Clank webhook with the SAME event name — no
325
359
  // more bare-`stop` (JSONL) vs dotted-`stopped` (webhook) divergence.
360
+ // #1068 AC1 — carry the attested semantic id alongside the raw UUID so a
361
+ // turn-end outcome is joinable by identity from events.jsonl alone. Omitted
362
+ // (never `""`/`null`) when unattested: an unresolved identity stays visibly
363
+ // unresolved rather than becoming a guessed id.
326
364
  await emitEvent('orchestrator.session.stopped', {
327
365
  ...(sessionId !== null ? { session_id: sessionId } : {}),
366
+ ...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
328
367
  wave,
329
368
  ...(branch !== null ? { branch } : {}),
330
369
  ...(commit !== null ? { commit } : {}),
@@ -30,6 +30,19 @@
30
30
  * Why read the transcript: the SubagentStop stdin payload has NO output_text
31
31
  * field. The agent's text lives in `input.transcript_path`.
32
32
  *
33
+ * Output channels — THREE writes, TWO different recipients:
34
+ * - `discovery_validator_violation` in .orchestrator/metrics/events.jsonl,
35
+ * and the stderr WARN → the COORDINATOR's only copy of the finding. PSA-006
36
+ * makes REJECTING the unverified claim his duty, so these two are the
37
+ * channels that carry the rule's enforcement path.
38
+ * - stdout `hookSpecificOutput.additionalContext` → the STOPPING SUBAGENT
39
+ * (which continues and may self-correct), NOT the coordinator — a hook is
40
+ * structurally unable to address him. See the measured delivery note at the
41
+ * hookSpecificOutput write below for the shipped-binary evidence.
42
+ * All three are pinned together by the "all three channels fire" test in
43
+ * tests/hooks/post-subagent-discovery-validator.test.mjs — do not collapse
44
+ * the coordinator-visible pair into additionalContext.
45
+ *
33
46
  * Exit codes: 0 always (informational, never blocking).
34
47
  */
35
48
 
@@ -594,12 +607,30 @@ async function main() {
594
607
  }
595
608
 
596
609
  if (alreadyWarned) {
597
- // Events logged above; suppress additionalContext to prevent coordinator loop.
610
+ // Events logged above; suppress the repeat additionalContext for this
611
+ // already-warned real context. (Recipient is the stopping subagent, not the
612
+ // coordinator — see the delivery note at the hookSpecificOutput write below.)
598
613
  return;
599
614
  }
600
615
 
601
- // v2.1.163+ additionalContext: feed the warning back to the coordinator turn
602
- // so the finding is visible inline, not just in stderr + events.jsonl.
616
+ // v2.1.163+ additionalContext: surface the finding inline, not just in
617
+ // stderr + events.jsonl.
618
+ //
619
+ // The recipient is the SUBAGENT that just stopped — NOT the coordinator.
620
+ // Measured in the shipped binary (Claude Code 2.1.241, 2026-08-23): the
621
+ // SubagentStop hookSpecificOutput schema reads "additionalContext is
622
+ // non-error feedback delivered to the subagent; the subagent continues so it
623
+ // can act on it", and the emitter picks its target with
624
+ // `i.agentId ? "SubagentStop" : "Stop"`, yielding a `hook_additional_context`
625
+ // message into that agent's own loop. So this text lands in the transcript of
626
+ // the agent whose claims it is about, one moment after that agent is done.
627
+ //
628
+ // Worth knowing before "fixing" the path: PSA-006 asks the COORDINATOR to
629
+ // reject unverified distributional claims, and this channel never reaches
630
+ // him. His copy of the finding is events.jsonl + stderr, not this write.
631
+ // Whether he SHOULD receive it is a product question (#1116) — changing the
632
+ // route is not a comment edit.
633
+ //
603
634
  // Non-blocking — exit 0 always. Decision:"block" must never be set here.
604
635
  process.stdout.write(JSON.stringify({
605
636
  hookSpecificOutput: {
@@ -41,6 +41,7 @@ if (!shouldRunHook('post-tool-batch-wave-signal')) process.exit(0);
41
41
 
42
42
  import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
43
43
  import { emitEvent } from '../scripts/lib/events.mjs';
44
+ import { findScopeFile } from '../scripts/lib/scope-gate.mjs';
44
45
 
45
46
  // ---------------------------------------------------------------------------
46
47
  // Helpers
@@ -135,17 +136,25 @@ async function resolveSessionIdForHeartbeat(input, sessionFile) {
135
136
  }
136
137
 
137
138
  /**
138
- * Resolve the current wave number from .claude/wave-scope.json `.wave`.
139
+ * Resolve the current wave number from the wave-scope manifest's `.wave`.
139
140
  * Returns 0 when the file is absent or unparseable, mirroring the
140
141
  * pre-bash-memory-propose-audit.mjs G5 precedent ("wave defaults to 0 when
141
142
  * wave-scope.json absent"). The file is deleted mid-session at Quality phase
142
143
  * transitions and final cleanup, so absence is an expected, non-error state.
143
144
  *
145
+ * The manifest is located via `findScopeFile()` (#1082), the same precedence
146
+ * every other consumer uses — `.pi` > `.cursor` > `.codex` > `.claude`. The
147
+ * previous hard-coded `.claude/` path made this hook's whole wave-lifecycle
148
+ * fallback structurally dead on Codex CLI, Cursor and pi: the manifest exists,
149
+ * it just is not under `.claude/`, so every batch read 0 and no
150
+ * `orchestrator.wave.started`/`completed` event was ever emitted there.
151
+ *
144
152
  * @param {string} projectDir
145
153
  * @returns {Promise<number>}
146
154
  */
147
155
  async function resolveWaveNumber(projectDir) {
148
- const waveFile = path.join(projectDir, '.claude', 'wave-scope.json');
156
+ const waveFile = findScopeFile(projectDir);
157
+ if (waveFile === null) return 0;
149
158
  try {
150
159
  const raw = await readFile(waveFile, 'utf8');
151
160
  const data = JSON.parse(raw);