session-orchestrator 3.22.0 → 3.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (316) 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/remote-offload/SKILL.md +13 -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 +446 -0
  91. package/README.md +22 -17
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -2
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/session-reviewer.md +7 -1
  98. package/agents/test-writer.md +2 -1
  99. package/agents/ui-developer.md +2 -1
  100. package/commands/bootstrap.md +2 -2
  101. package/commands/close.md +3 -1
  102. package/commands/go.md +1 -1
  103. package/commands/journey-audit.md +43 -0
  104. package/docs/USER-GUIDE.md +2 -2
  105. package/docs/ci-setup.md +194 -25
  106. package/docs/codex-setup.md +64 -0
  107. package/docs/components.md +7 -7
  108. package/docs/cursor-setup.md +26 -47
  109. package/docs/events-schema.md +120 -10
  110. package/docs/github-mirror-protection.md +197 -0
  111. package/docs/pi-setup.md +2 -0
  112. package/docs/rule-authoring.md +3 -1
  113. package/docs/scope-collision-guard.md +49 -2
  114. package/docs/session-config-reference.md +89 -9
  115. package/docs/session-config-template.md +38 -7
  116. package/docs/telemetry/telemetry-claims.md +11 -10
  117. package/docs/telemetry.md +52 -1
  118. package/hooks/_lib/atomic-json.mjs +111 -0
  119. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  120. package/hooks/_lib/subagent-paths.mjs +143 -0
  121. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  122. package/hooks/cwd-change-restore.mjs +9 -29
  123. package/hooks/enforce-scope.mjs +93 -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 +2 -2
  128. package/hooks/on-session-end.mjs +486 -19
  129. package/hooks/on-session-start.mjs +263 -12
  130. package/hooks/on-stop.mjs +392 -24
  131. package/hooks/post-bash-write-verify.mjs +104 -4
  132. package/hooks/post-subagent-discovery-validator.mjs +182 -21
  133. package/hooks/post-tool-batch-wave-signal.mjs +165 -42
  134. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  135. package/hooks/pre-bash-issue-budget.mjs +117 -4
  136. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  138. package/hooks/pre-bash-staging-fence.mjs +4 -0
  139. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  140. package/hooks/skill-invocation-telemetry.mjs +21 -10
  141. package/hooks/subagent-telemetry.mjs +11 -26
  142. package/monitors/monitors.json +6 -0
  143. package/package.json +1 -1
  144. package/pi/prompts/journey-audit.md +12 -0
  145. package/rules/_index.md +9 -1
  146. package/rules/always-on/ask-via-tool.md +62 -0
  147. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  148. package/rules/always-on/build-value.md +47 -0
  149. package/rules/always-on/cross-session-messaging.md +59 -0
  150. package/rules/always-on/loop-and-monitor.md +221 -0
  151. package/rules/always-on/parallel-sessions.md +142 -12
  152. package/rules/always-on/receiving-review.md +108 -0
  153. package/rules/always-on/test-value.md +40 -0
  154. package/rules/always-on/verification-before-completion.md +77 -0
  155. package/scripts/archive-closed-prds.mjs +258 -18
  156. package/scripts/autopilot.mjs +31 -12
  157. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  158. package/scripts/backfill-evidence-digest.mjs +376 -0
  159. package/scripts/cursor-install.mjs +89 -48
  160. package/scripts/emit-event.mjs +10 -2
  161. package/scripts/export-hw-learnings.mjs +143 -2
  162. package/scripts/express-path.mjs +299 -0
  163. package/scripts/generate-cursor-adapter.mjs +253 -0
  164. package/scripts/github-protection-audit.mjs +358 -0
  165. package/scripts/lib/auq/parse.mjs +5 -29
  166. package/scripts/lib/auto-dialectic.mjs +68 -0
  167. package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
  168. package/scripts/lib/build-live-signals.mjs +49 -27
  169. package/scripts/lib/ci-status-banner.mjs +158 -11
  170. package/scripts/lib/cold-start-detector.mjs +23 -14
  171. package/scripts/lib/command-blocker.mjs +70 -0
  172. package/scripts/lib/config/block-header.mjs +55 -0
  173. package/scripts/lib/config/discovery-validator.mjs +7 -2
  174. package/scripts/lib/config/health-endpoints.mjs +383 -0
  175. package/scripts/lib/config/reconcile.mjs +79 -4
  176. package/scripts/lib/config/remote-hosts.mjs +233 -0
  177. package/scripts/lib/config/section-extractor.mjs +235 -36
  178. package/scripts/lib/config-schema.mjs +9 -1
  179. package/scripts/lib/config.mjs +87 -8
  180. package/scripts/lib/convergence-monitor.mjs +13 -2
  181. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  182. package/scripts/lib/dispatcher/cli.mjs +2 -2
  183. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  184. package/scripts/lib/events-schema.mjs +48 -0
  185. package/scripts/lib/events.mjs +238 -5
  186. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  187. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  188. package/scripts/lib/express-path.mjs +327 -0
  189. package/scripts/lib/file-lock.mjs +22 -4
  190. package/scripts/lib/gates/gate-full.mjs +81 -8
  191. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  192. package/scripts/lib/git-config-drift.mjs +134 -5
  193. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  194. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  195. package/scripts/lib/host-identity.mjs +247 -2
  196. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  197. package/scripts/lib/issue-budget.mjs +229 -30
  198. package/scripts/lib/learnings/io.mjs +55 -10
  199. package/scripts/lib/learnings/schema.mjs +95 -28
  200. package/scripts/lib/lock-reaper.mjs +7 -1
  201. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  202. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  203. package/scripts/lib/memory-banner.mjs +25 -10
  204. package/scripts/lib/memory-paths.mjs +15 -6
  205. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  206. package/scripts/lib/peer-discovery.mjs +20 -2
  207. package/scripts/lib/platform.mjs +72 -9
  208. package/scripts/lib/plugin-root.mjs +143 -19
  209. package/scripts/lib/project-hygiene.mjs +43 -3
  210. package/scripts/lib/quality-gate.mjs +271 -13
  211. package/scripts/lib/reconcile/emitter.mjs +87 -19
  212. package/scripts/lib/reconcile/engine.mjs +517 -18
  213. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  214. package/scripts/lib/reconcile/renderer.mjs +148 -3
  215. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  216. package/scripts/lib/reconcile/writer.mjs +415 -84
  217. package/scripts/lib/rule-loader.mjs +37 -2
  218. package/scripts/lib/rules-sync.mjs +51 -8
  219. package/scripts/lib/scope-gate.mjs +126 -0
  220. package/scripts/lib/session-close-backfill.mjs +427 -37
  221. package/scripts/lib/session-discovery.mjs +69 -5
  222. package/scripts/lib/session-end/phase-skip.mjs +38 -5
  223. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  224. package/scripts/lib/session-id.mjs +30 -14
  225. package/scripts/lib/session-identity/own-session.mjs +220 -0
  226. package/scripts/lib/session-lock.mjs +85 -30
  227. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  228. package/scripts/lib/session-schema/validator.mjs +40 -0
  229. package/scripts/lib/session-start-probes.mjs +608 -0
  230. package/scripts/lib/session-transition.mjs +277 -0
  231. package/scripts/lib/sessions-canonical.mjs +446 -0
  232. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  233. package/scripts/lib/spiral-carryover.mjs +90 -9
  234. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  235. package/scripts/lib/state-md/mission-status.mjs +350 -52
  236. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  237. package/scripts/lib/state-md.mjs +12 -2
  238. package/scripts/lib/telemetry/schema.mjs +74 -8
  239. package/scripts/lib/telemetry/sync.mjs +91 -16
  240. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  241. package/scripts/lib/validate/check-agents.mjs +66 -0
  242. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  243. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  244. package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
  245. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  246. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  247. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  248. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  249. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  250. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  251. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  252. package/scripts/lib/validate/check-unwired-features.mjs +333 -32
  253. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  254. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  255. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  256. package/scripts/lib/validate/repo-files.mjs +275 -0
  257. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  258. package/scripts/lib/vault-mirror/process.mjs +99 -43
  259. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  260. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  261. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  262. package/scripts/lib/vault-status/board-writer.mjs +381 -141
  263. package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
  264. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  265. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  266. package/scripts/lib/wave-resource-gate.mjs +127 -7
  267. package/scripts/lib/wave-transcript-tail.mjs +889 -0
  268. package/scripts/materialize-wave-scope.mjs +228 -15
  269. package/scripts/mcp-server.sh +11 -2
  270. package/scripts/memory-propose.mjs +132 -8
  271. package/scripts/parse-config.mjs +65 -0
  272. package/scripts/promote-vault-strict.mjs +4 -15
  273. package/scripts/site-numbers.mjs +36 -4
  274. package/scripts/token-audit.sh +9 -2
  275. package/scripts/validate-plugin.mjs +29 -0
  276. package/scripts/validate-wave-scope.mjs +67 -0
  277. package/scripts/vault-consolidate.mjs +3 -11
  278. package/scripts/vault-integration-watcher.mjs +2 -4
  279. package/scripts/vault-mirror.mjs +305 -51
  280. package/skills/_shared/monitor-patterns.md +31 -5
  281. package/skills/_shared/parallel-aware-auq.md +31 -2
  282. package/skills/_shared/parallel-aware-preamble.md +19 -4
  283. package/skills/_shared/platform-tools.md +11 -5
  284. package/skills/_shared/state-ownership.md +29 -2
  285. package/skills/autopilot/SKILL.md +5 -1
  286. package/skills/bootstrap/SKILL.md +3 -3
  287. package/skills/bootstrap/_shared-template.md +18 -10
  288. package/skills/bootstrap/deep-template.md +10 -6
  289. package/skills/bootstrap/fast-template.md +15 -8
  290. package/skills/bootstrap/standard-template.md +10 -6
  291. package/skills/claude-md-drift-check/checker.mjs +39 -11
  292. package/skills/contract-version-bump/SKILL.md +1 -1
  293. package/skills/dispatcher/SKILL.md +1 -1
  294. package/skills/ecosystem-health/SKILL.md +4 -1
  295. package/skills/ecosystem-health/wizard.md +5 -0
  296. package/skills/evolve/SKILL.md +38 -1
  297. package/skills/journey-audit/SKILL.md +270 -0
  298. package/skills/peekaboo-driver/SKILL.md +15 -3
  299. package/skills/persona-panel/SKILL.md +1 -1
  300. package/skills/reconcile/SKILL.md +46 -3
  301. package/skills/remote-offload/SKILL.md +89 -0
  302. package/skills/session-end/SKILL.md +17 -4
  303. package/skills/session-end/metrics-collection.md +7 -4
  304. package/skills/session-end/phase-3-6-tail.md +20 -9
  305. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  306. package/skills/session-plan/SKILL.md +6 -1
  307. package/skills/session-plan/wave-template.md +1 -0
  308. package/skills/session-start/SKILL.md +54 -17
  309. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  310. package/skills/session-start/phase-8-5-express-path.md +77 -12
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +5 -3
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +143 -22
  315. package/templates/_shared/journey-manifest.md +110 -0
  316. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -10,7 +10,13 @@
10
10
  * `session.started` had no terminal partner.
11
11
  *
12
12
  * JSONL format (`.orchestrator/metrics/events.jsonl`):
13
- * {"timestamp":<ISO>,"event":"orchestrator.session.ended","session_id":"...","reason":"<reason>","duration_ms":<int>}
13
+ * {"timestamp":<ISO>,"event":"orchestrator.session.ended","session_id":"...","semantic_session_id":"...","reason":"<reason>","duration_ms":<int>}
14
+ * (`session_id` / `semantic_session_id` are omitted when unresolvable — #1068 AC1.
15
+ * `duration_ms` likewise: it is written ONLY when the ending session IS the
16
+ * one `current-session.json` records AND a start timestamp parsed. A
17
+ * fabricated `0` reads as a measured zero-length session and is
18
+ * indistinguishable from one — 1082 of 1498 fleet records (72,2 %) carried
19
+ * exactly that zero, measured 2026-09-02. Omit, never fabricate.)
14
20
  *
15
21
  * Exit codes: 0 always (informational hook — must never block session teardown).
16
22
  * stdin: optional JSON { hook_event_name:"SessionEnd", session_id?, reason?, cwd? }.
@@ -30,15 +36,22 @@ if (!shouldRunHook('on-session-end')) process.exit(0);
30
36
 
31
37
  import { emitEvent } from '../scripts/lib/events.mjs';
32
38
  import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
33
- import { backfillAbandonedSession } from '../scripts/lib/session-close-backfill.mjs';
39
+ import {
40
+ backfillAbandonedSession,
41
+ backfillCompletedFromStateMd,
42
+ } from '../scripts/lib/session-close-backfill.mjs';
34
43
  import {
35
44
  readLockDetailed,
36
45
  release,
37
46
  loadOwnerProof,
38
47
  OWNER_PROOF_RELPATH,
39
48
  } from '../scripts/lib/session-lock.mjs';
49
+ import { parseSessionId } from '../scripts/lib/session-id.mjs';
40
50
  import { deregisterSelf, logSweepEvent } from '../scripts/lib/session-registry.mjs';
51
+ import { readConfigFile, parseSessionConfig } from '../scripts/lib/config.mjs';
52
+ import { flush } from '../scripts/lib/telemetry/sync.mjs';
41
53
  import { attemptLockReconciliation } from './_lib/lock-reconcile.mjs';
54
+ import { atomicMutateJson } from './_lib/atomic-json.mjs';
42
55
 
43
56
  // ---------------------------------------------------------------------------
44
57
  // stdin reading (inline — SessionEnd hooks exit 0 always, never deny)
@@ -72,11 +85,29 @@ async function readStdinJson() {
72
85
  }
73
86
 
74
87
  /**
75
- * Resolve this session's id + duration + semantic id. Stdin session_id wins;
76
- * otherwise fall back to `.orchestrator/current-session.json` (written by
77
- * on-session-start.mjs). duration_ms is only computed when the ENDING session
78
- * is the one recorded in current-session.json never fabricated for a
79
- * mismatched / unknown session.
88
+ * Resolve this session's id + duration + semantic id. A stdin session_id wins
89
+ * ONLY when it parses as a UUID; otherwise fall back to
90
+ * `.orchestrator/current-session.json` (written by on-session-start.mjs).
91
+ * duration_ms is only computed when the ENDING session is the one recorded in
92
+ * current-session.json — never fabricated for a mismatched / unknown session.
93
+ * Not measurable ⇒ `durationMs: null`, and the caller then OMITS the key
94
+ * entirely (#1193 W5 F1, the last site of the omit-never-fabricate class this
95
+ * session removed from `session.stopped` and `agent.stopped`).
96
+ *
97
+ * #1091 / Kanevry#66 — WRITER/READER SYMMETRY. `on-session-start.mjs`
98
+ * (`resolveSessionId`, :316-317) accepts a stdin raw id only when
99
+ * `parseSessionId(fromStdin)?.format === 'uuid'` and otherwise mints a
100
+ * `randomUUID()`; `current-session.json`, `session.lock` and the host registry
101
+ * are therefore ALWAYS keyed by a UUID. This reader used to accept ANY
102
+ * non-empty stdin string, so a harness that passed a non-UUID id
103
+ * (`{"session_id":"not-a-uuid"}`) resolved a key that matches nothing written
104
+ * at start: the raw-ID ownership compare below (:330) fails, the lock is
105
+ * neither released nor reconciled, and it LEAKS until its TTL expires. Same
106
+ * for `deregisterSelf()`, whose registry file is named after the id
107
+ * `registerSelf()` used. Mirroring the writer's rule here makes the fallback
108
+ * (which reads exactly those artifacts) the single source of the identity.
109
+ * The ownership compare itself stays an exact string `===` — this changes
110
+ * WHICH id is compared, never HOW.
80
111
  *
81
112
  * `semanticSessionId` is read from current-session.json (present since #587):
82
113
  * it is the SEMANTIC id (`<branch>-<date>-<mode>-<n>`) that sessions.jsonl is
@@ -92,13 +123,42 @@ async function readStdinJson() {
92
123
  * session therefore resolves `semanticSessionId: null` rather than inheriting
93
124
  * another live session's backfill identity.
94
125
  *
126
+ * #1193 W4a review F-A — `isRecordedSession` is computed from the RAW stdin
127
+ * UUID, NEVER from the resolved `sessionId`. The old order was
128
+ * `if (sessionId === null) sessionId = recordedId;` followed by
129
+ * `sessionId === recordedId`, which is SELF-FULFILLING: whenever stdin carried
130
+ * no session_id (or a non-UUID one), the fallback assigned the file's own id
131
+ * and the compare then trivially succeeded. Reproduced twice 2026-09-02 —
132
+ * peer-owned `current-session.json` (`last_wave: 3`) plus stdin
133
+ * `{"reason":"other"}` emitted `wave.completed` for the PEER's wave and wrote
134
+ * `last_wave_completed: 3` into the PEER's file, after which the peer's own
135
+ * SessionEnd stayed silent. The same vacuous predicate had always gated
136
+ * `durationMs` and `semanticSessionId`, so the #863 guard was hollow on that
137
+ * path too. One root fix (BV-003) for all three consumers: a `null` raw id
138
+ * means "not attestable" ⇒ `false`.
139
+ *
140
+ * WHY `sessionId` STILL FALLS BACK while the predicate does not. The two are
141
+ * deliberately asymmetric. `sessionId` is the hook's ACTOR identity — the id
142
+ * `deregisterSelf()` and the lock-release ownership compare use, and its
143
+ * current-session.json fallback is a named contract pinned by
144
+ * `tests/hooks/on-session-end.test.mjs` ("falls back to current-session.json
145
+ * session_id when stdin omits it"). `isRecordedSession` is an OWNERSHIP
146
+ * ASSERTION about a repo-global file; an assertion may never be derived from
147
+ * the very value it is asserting about. So the fallback stays for the emitted
148
+ * `session_id` field, and every claim that speaks FOR the recorded session
149
+ * (`duration_ms`, `semantic_session_id`, the final `wave.completed`) is gated
150
+ * on the strict raw compare instead. Precedent: `hooks/on-stop.mjs`
151
+ * `resolveStopDuration()`, which refuses the resolved id for the same reason.
152
+ *
95
153
  * @param {object|null} input
96
154
  * @param {string} projectRoot
97
- * @returns {Promise<{sessionId: string|null, semanticSessionId: string|null, durationMs: number}>}
155
+ * @returns {Promise<{sessionId: string|null, semanticSessionId: string|null, durationMs: number|null, isRecordedSession: boolean, rawStdinId: string|null}>}
98
156
  */
99
157
  async function resolveSession(input, projectRoot) {
100
158
  const fromStdin = input?.session_id ?? input?.sessionId ?? null;
101
- let sessionId = (typeof fromStdin === 'string' && fromStdin.length > 0) ? fromStdin : null;
159
+ // UUID-only, exactly as the writer decides it (see the docblock above).
160
+ const rawStdinId = parseSessionId(fromStdin)?.format === 'uuid' ? fromStdin : null;
161
+ let sessionId = rawStdinId;
102
162
 
103
163
  let recordedId = null;
104
164
  let semanticSessionId = null;
@@ -121,13 +181,17 @@ async function resolveSession(input, projectRoot) {
121
181
  }
122
182
  } catch { /* missing or unparseable is fine */ }
123
183
 
184
+ // Actor-identity fallback ONLY — see the docblock's asymmetry note. This
185
+ // value must NEVER feed the ownership predicate below.
124
186
  if (sessionId === null) sessionId = recordedId;
125
187
 
126
- // Only trust the recorded start time when the ending session IS the recorded one.
127
- const isRecordedSession = sessionId !== null && sessionId === recordedId;
188
+ // Only trust the recorded start time when the ending session IS the recorded
189
+ // one decided on the RAW stdin id, so an absent/non-UUID id is `false`
190
+ // rather than self-fulfilling (F-A).
191
+ const isRecordedSession = rawStdinId !== null && rawStdinId === recordedId;
128
192
  const durationMs = startedAtMs !== null && isRecordedSession
129
193
  ? Math.max(0, Date.now() - startedAtMs)
130
- : 0;
194
+ : null;
131
195
 
132
196
  // #863 defect (c) — same guard as durationMs above: only surface the
133
197
  // recorded semantic id when THIS ending session is genuinely the one
@@ -135,7 +199,350 @@ async function resolveSession(input, projectRoot) {
135
199
  // contamination scenario this closes.
136
200
  const resolvedSemanticSessionId = isRecordedSession ? semanticSessionId : null;
137
201
 
138
- return { sessionId, semanticSessionId: resolvedSemanticSessionId, durationMs };
202
+ // `rawStdinId` is returned so a LATER read of current-session.json can
203
+ // re-verify ownership against the same identity (W4c Q1-LOW-TOCTOU) instead
204
+ // of trusting an attestation made against an earlier read of the file.
205
+ return {
206
+ sessionId,
207
+ semanticSessionId: resolvedSemanticSessionId,
208
+ durationMs,
209
+ isRecordedSession,
210
+ rawStdinId,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * POST budget for the close-time telemetry flush (ms).
216
+ *
217
+ * NAMED CEILING, and the reason it is BELOW the module default POST_TIMEOUT_MS
218
+ * (3000): the two timeouts fail differently.
219
+ *
220
+ * - This one expiring is LOSSLESS. `flush()` catches the abort and routes the
221
+ * record into the bounded offline queue; the daily fallback
222
+ * (`shouldDailyFlush`) drains it on a later session.
223
+ * - The HARNESS timeout expiring is LOSSY. Claude Code kills the hook process
224
+ * mid-flight, so the enqueue never runs and the record is simply gone —
225
+ * which is the exact failure #1138 exists to remove.
226
+ *
227
+ * So the internal bound must stay comfortably under the harness bound, not
228
+ * merely below it. Budget (hooks/hooks.json `SessionEnd.timeout: 10` s, raised
229
+ * from 5 with this change): up to 500 ms stdin + a backfill measured at a
230
+ * ~845 ms median (its TAIL, not its median, is what would collide) + lock
231
+ * release + deregistration, then this 2 s. That leaves several seconds of slack
232
+ * for the backfill tail while still covering a normal round-trip many times
233
+ * over.
234
+ *
235
+ * Revisit if the hooks.json SessionEnd timeout changes — the pin lives in
236
+ * tests/hooks/on-session-end.test.mjs.
237
+ */
238
+ const TELEMETRY_FLUSH_TIMEOUT_MS = 2000;
239
+
240
+ /**
241
+ * Read `persistence` from the repo's Session Config. Defaults to `true` — the
242
+ * same default `scripts/lib/config.mjs` applies — so an unreadable or absent
243
+ * CLAUDE.md (or its Codex alias AGENTS.md) never silently disables the flush.
244
+ *
245
+ * @param {string} projectRoot
246
+ * @returns {Promise<boolean>}
247
+ */
248
+ async function readPersistence(projectRoot) {
249
+ try {
250
+ const md = await readConfigFile(projectRoot);
251
+ return parseSessionConfig(md).persistence !== false;
252
+ } catch {
253
+ return true;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Reduce a `flush()` result to the two-field breadcrumb the event carries.
259
+ *
260
+ * `reason` is normalised to its head token because `flush()` may return
261
+ * `build-error: <message>`, and a raw error message is unbounded free text in a
262
+ * stream whose whole purpose is aggregation by class.
263
+ *
264
+ * @param {{sent?: boolean, queued?: boolean, reason?: string}|null|undefined} res
265
+ * @returns {{outcome: 'sent'|'queued'|'gated'|'skipped', reason: string}}
266
+ */
267
+ function classifyFlush(res) {
268
+ const reason = String(res?.reason ?? 'unknown').split(':')[0];
269
+ if (res?.sent === true) return { outcome: 'sent', reason };
270
+ if (res?.queued === true) return { outcome: 'queued', reason };
271
+ if (reason === 'gated') return { outcome: 'gated', reason };
272
+ return { outcome: 'skipped', reason };
273
+ }
274
+
275
+ /**
276
+ * #1138 — the MECHANICAL telemetry flush.
277
+ *
278
+ * Until now the close-time flush existed only as prose in
279
+ * `skills/session-end/SKILL.md` § Phase 3.45, i.e. it ran only when the
280
+ * coordinator LLM happened to execute that phase. Measured 2026-08-23: 588
281
+ * session closes across 13 repos produced 82 ingest records (~14%). A hook is
282
+ * the only caller that fires on EVERY close, including the ones that never
283
+ * reach `/close` at all.
284
+ *
285
+ * Strictly best-effort: never throws, and bounded by TELEMETRY_FLUSH_TIMEOUT_MS.
286
+ * The consent gate lives INSIDE `flush()` (`resolveConsent()` is its first
287
+ * statement) — this function deliberately does not re-implement it, so there is
288
+ * exactly one place where "may we send?" is decided.
289
+ *
290
+ * Always emits `orchestrator.telemetry.flush` with `{ outcome, reason }` and
291
+ * NOTHING else — no payload, no anon_id. Per `.claude/rules/host-resources.md`
292
+ * HR-105, a mechanism whose firing rate nothing records cannot be falsified;
293
+ * this event is what makes the flush rate measurable next time.
294
+ *
295
+ * @param {string} projectRoot
296
+ * @returns {Promise<void>}
297
+ */
298
+ async function flushTelemetry(projectRoot) {
299
+ let result;
300
+ try {
301
+ result = (await readPersistence(projectRoot))
302
+ ? classifyFlush(await flush({
303
+ metricsDir: path.join(projectRoot, '.orchestrator', 'metrics'),
304
+ timeoutMs: TELEMETRY_FLUSH_TIMEOUT_MS,
305
+ }))
306
+ // `persistence: false` means this session leaves no durable local trace;
307
+ // a telemetry ping is a durable record too, so it honours the same switch.
308
+ : { outcome: 'skipped', reason: 'persistence-disabled' };
309
+ } catch {
310
+ result = { outcome: 'skipped', reason: 'error' };
311
+ }
312
+
313
+ // `result` is passed through verbatim: it holds EXACTLY {outcome, reason}, so
314
+ // no payload field can leak into the event by accident.
315
+ try {
316
+ await emitEvent('orchestrator.telemetry.flush', result);
317
+ } catch { /* observability is best-effort */ }
318
+ }
319
+
320
+ /**
321
+ * #1068 AC2 — emit ONE canonically queryable outcome per backfill attempt.
322
+ *
323
+ * Until now a backfill result reached exactly one place:
324
+ * `.orchestrator/metrics/session-close-backfill.log`, a side-log no lifecycle
325
+ * consumer reads and no staleness/integrity check joins against. The event
326
+ * stream is where every other lifecycle fact already lives, so the outcome goes
327
+ * there too — carrying IDENTITY (`session_id` raw UUID + the record id the
328
+ * backfill acted on + the attested semantic id), ACTION, and, when the action
329
+ * was an error, its REASON. The side-log is unchanged and remains the verbose
330
+ * copy; this is the queryable one.
331
+ *
332
+ * `kind` distinguishes the two backfill classes, which share the log file and
333
+ * would otherwise be indistinguishable in aggregate: `'abandoned'`
334
+ * (`backfillAbandonedSession`) vs `'state-md-completed'`
335
+ * (`backfillCompletedFromStateMd`).
336
+ *
337
+ * Every id key is OMITTED when unknown — never `""`, never a guess (#1068 AC1).
338
+ * Best-effort throughout: observability must never block teardown.
339
+ *
340
+ * @param {'abandoned'|'state-md-completed'} kind
341
+ * @param {{action?: string, sessionId?: string|null, supersedes?: string, error?: string}|null|undefined} result
342
+ * @param {{sessionId: string|null, semanticSessionId: string|null}} ids
343
+ * @returns {Promise<void>}
344
+ */
345
+ async function emitBackfillOutcome(kind, result, { sessionId, semanticSessionId }) {
346
+ try {
347
+ const recordId = typeof result?.sessionId === 'string' ? result.sessionId : null;
348
+ await emitEvent('orchestrator.session.backfill_completed', {
349
+ kind,
350
+ action: typeof result?.action === 'string' ? result.action : 'unknown',
351
+ ...(sessionId !== null ? { session_id: sessionId } : {}),
352
+ ...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
353
+ ...(recordId !== null ? { record_id: recordId } : {}),
354
+ ...(typeof result?.supersedes === 'string' ? { supersedes: result.supersedes } : {}),
355
+ ...(typeof result?.error === 'string' ? { reason: result.error } : {}),
356
+ });
357
+ } catch { /* observability is best-effort */ }
358
+ }
359
+
360
+ /**
361
+ * Emit `orchestrator.wave.final_refused` — the SIBLING event to
362
+ * `orchestrator.wave.completed` for every refusal path inside
363
+ * {@link emitFinalWaveCompleted} (#1201 Part B / Discovery D8). Deliberately
364
+ * a separate event name rather than `orchestrator.wave.completed` carrying
365
+ * `emitted:false`: existing consumers of `.completed` treat every row as a
366
+ * finished wave, and overloading it would silently corrupt that count.
367
+ *
368
+ * Wrapped in its OWN try/catch — independent of the caller's outer
369
+ * try/catch — so a telemetry failure on ONE refusal path can never surface
370
+ * as a failure of teardown, matching the best-effort contract every other
371
+ * emit in this SessionEnd hook already carries.
372
+ *
373
+ * @param {{sessionId: string|null, semanticSessionId: string|null}} ids
374
+ * @param {'not-recorded'|'clear'|'resume'|'unreadable'|'session-id-mismatch'|'no-wave'|'already-completed'|'exception'} reason
375
+ * @param {number} [waveNumber] - only when `last_wave` was resolved to a
376
+ * positive number before the refusal (currently only `already-completed`).
377
+ * @returns {Promise<void>}
378
+ */
379
+ async function emitFinalRefused({ sessionId, semanticSessionId }, reason, waveNumber) {
380
+ try {
381
+ await emitEvent('orchestrator.wave.final_refused', {
382
+ ...(sessionId !== null ? { session_id: sessionId } : {}),
383
+ ...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
384
+ reason,
385
+ ...(typeof waveNumber === 'number' ? { wave_number: waveNumber } : {}),
386
+ emitted_by: 'on-session-end',
387
+ });
388
+ } catch { /* best-effort — a refusal record must never itself block teardown */ }
389
+ }
390
+
391
+ /**
392
+ * Emit the FINAL `orchestrator.wave.completed` of the session (#1193).
393
+ *
394
+ * `hooks/post-tool-batch-wave-signal.mjs` closes wave N-1 only at an N-1→N
395
+ * transition, so the LAST wave of every session never received a completion —
396
+ * measured fleet-wide 2026-09-02 as 296 gaps over 296 wave runs (1018 started
397
+ * vs 722 completed), i.e. EXACTLY one missing final completion per run. The
398
+ * comment that claimed the coordinator emitted it at session close described a
399
+ * step that never existed. SessionEnd is that emitter.
400
+ *
401
+ * Deliberately SessionEnd-only: `on-stop.mjs` is not mirrored, so the ledger
402
+ * keeps the closed-vs-abandoned split measurable.
403
+ *
404
+ * Idempotent via the `last_wave_completed` high-water mark, written by both
405
+ * emitters and preserved across clear/compact by `on-session-start.mjs`.
406
+ * Emits nothing when `last_wave` is absent or 0 — an Express-Path or
407
+ * coordinator-direct session never batched, and zero waves is the correct
408
+ * reading there, not a gap.
409
+ *
410
+ * OWNERSHIP-GATED (#1193 review F1). `.orchestrator/current-session.json` is a
411
+ * single repo-global file describing whichever session most recently ran
412
+ * SessionStart — routinely a DIFFERENT, still-live session when two windows
413
+ * share this working copy. Emitting unguarded would (a) close a PEER's live
414
+ * wave with a completion the peer never reached, and (b) write
415
+ * `last_wave_completed` into the peer's file, so the peer's own SessionEnd then
416
+ * stays silent — preserving the very #1193 gap this closes, on the wrong
417
+ * session. So this reuses the SAME `isRecordedSession` predicate
418
+ * `resolveSession()` applies to `durationMs` and `semanticSessionId` (#863
419
+ * defect (c)); when it is false, nothing is emitted and nothing is written.
420
+ *
421
+ * REASON-GATED for `/clear` AND `resume` (#1193 review F2 + W4c Q3-MED-2). The
422
+ * SessionEnd matcher is empty, so `/clear` fires this hook mid-wave while the
423
+ * LOGICAL session continues (`hooks/on-session-start.mjs:383` preserves
424
+ * `last_wave` / `last_wave_completed` across exactly that). A `resume` is the
425
+ * SAME class — start preserves the marker across a resume of the same logical
426
+ * session just as it does across a clear — and resume is the MORE common of the
427
+ * two (fleet n = 1498 `session.ended`, 2026-09-02: 12 resume vs 9 clear).
428
+ * Closing the live wave on either is premature, and the preserved marker would
429
+ * then suppress the real completion later. Fleet `session.ended` reasons, re-measured
430
+ * 2026-09-02 over every repo's `.orchestrator/metrics/events.jsonl` under
431
+ * `~/Projects` (glob written as a path segment on purpose — a literal star
432
+ * followed by a slash would close this comment), except
433
+ * `EventDrop.at-deps-2026-09` — n = 1335: 1286 other, 27 completed, 12 resume,
434
+ * 8 clear, 1 error, 1 close. (Same denominator and date as the
435
+ * `session.ended` row in `docs/audits/2026-09-02-fleet-instruments.md`; the two
436
+ * disagreed by ~150 before W4a F-F because each counted a different repo set.)
437
+ *
438
+ * Strictly best-effort: never throws, never blocks teardown.
439
+ *
440
+ * SIX silent refusal paths (#1201 Part B / Discovery D8 — one more than the
441
+ * issue's original five) previously returned with no trace anywhere: per
442
+ * `.claude/rules/host-resources.md` § HR-105, a refusal that writes nothing
443
+ * is unfalsifiable. Every refusal now emits a SIBLING event,
444
+ * `orchestrator.wave.final_refused`, via {@link emitFinalRefused} — never
445
+ * `orchestrator.wave.completed` itself with an `emitted:false` flag, because
446
+ * that event's consumers treat every row as a finished wave (D8's explicit
447
+ * recommendation). Each emit is wrapped in its OWN try/catch inside
448
+ * `emitFinalRefused` so telemetry can never block teardown, on top of this
449
+ * function's own outer catch (which now also emits `reason: 'exception'`,
450
+ * best-effort).
451
+ *
452
+ * @param {string} projectRoot
453
+ * @param {{sessionId: string|null, semanticSessionId: string|null,
454
+ * isRecordedSession: boolean, reason: string,
455
+ * rawStdinId: string|null}} ctx
456
+ * @returns {Promise<{emitted: true, wave_number: number}|{emitted: false, reason: string}>}
457
+ * The return value is informational only — every branch has already
458
+ * persisted its own outcome via `orchestrator.wave.completed` or
459
+ * `orchestrator.wave.final_refused` by the time this resolves.
460
+ */
461
+ async function emitFinalWaveCompleted(
462
+ projectRoot,
463
+ { sessionId, semanticSessionId, isRecordedSession, reason, rawStdinId },
464
+ ) {
465
+ const ids = { sessionId, semanticSessionId };
466
+ try {
467
+ // F1 — never speak for a session current-session.json does not describe.
468
+ if (!isRecordedSession) {
469
+ await emitFinalRefused(ids, 'not-recorded');
470
+ return { emitted: false, reason: 'not-recorded' };
471
+ }
472
+ // F2 — `/clear` ends the HARNESS session, not the logical one. `resume` is
473
+ // the SAME class (W4c Q3-MED-2): `on-session-start.mjs` preserves
474
+ // `last_wave` / `last_wave_completed` across a resume of the same logical
475
+ // session exactly as it does across a clear, and resume is the MORE common
476
+ // of the two (fleet n=1498, 2026-09-02: 12 resume vs 9 clear).
477
+ if (reason === 'clear' || reason === 'resume') {
478
+ await emitFinalRefused(ids, reason);
479
+ return { emitted: false, reason };
480
+ }
481
+ const sessionFile = path.join(projectRoot, '.orchestrator', 'current-session.json');
482
+ let parsed = null;
483
+ try {
484
+ parsed = JSON.parse(await fs.readFile(sessionFile, 'utf8'));
485
+ } catch {
486
+ // absent or malformed → nothing attestable to close
487
+ await emitFinalRefused(ids, 'unreadable');
488
+ return { emitted: false, reason: 'unreadable' };
489
+ }
490
+
491
+ // W4c Q1-LOW-TOCTOU — `isRecordedSession` was attested against the FIRST
492
+ // read of this file (in `resolveSession()`); the values acted on below come
493
+ // from this SECOND read. Re-verify ownership here rather than inheriting a
494
+ // stale attestation. A genuine swap BETWEEN the two reads is not testable
495
+ // without a seam, and none is added for it — the peer-id case pins the
496
+ // re-check, and this predicate is what makes the window harmless.
497
+ if (rawStdinId === null || parsed?.session_id !== rawStdinId) {
498
+ await emitFinalRefused(ids, 'session-id-mismatch');
499
+ return { emitted: false, reason: 'session-id-mismatch' };
500
+ }
501
+
502
+ const lastWave = parsed?.last_wave;
503
+ if (typeof lastWave !== 'number' || !(lastWave > 0)) {
504
+ await emitFinalRefused(ids, 'no-wave');
505
+ return { emitted: false, reason: 'no-wave' };
506
+ }
507
+ // W4c Q3-MED-3(iii) / Q3-LOW-4 — strictly ABOVE the high-water mark, not
508
+ // merely different from it: a marker AHEAD of `last_wave` (written by the
509
+ // batch hook's explicit `wave-complete{N}` branch) means this wave is
510
+ // already closed, and a non-integer marker (`'3'`, `null`) counts as
511
+ // ABSENT rather than as "different".
512
+ const marker = Number.isInteger(parsed?.last_wave_completed) ? parsed.last_wave_completed : 0;
513
+ if (!(lastWave > marker)) {
514
+ await emitFinalRefused(ids, 'already-completed', lastWave);
515
+ return { emitted: false, reason: 'already-completed' };
516
+ }
517
+
518
+ await emitEvent('orchestrator.wave.completed', {
519
+ ...(sessionId !== null ? { session_id: sessionId } : {}),
520
+ ...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
521
+ wave_number: lastWave,
522
+ reason: 'session-end',
523
+ emitted_by: 'on-session-end',
524
+ });
525
+
526
+ // Monotone, exactly as the batch hook's `maxWave()` — the mark may only
527
+ // ever rise, whichever of the three writers gets here last (W4c Q3-MED-3).
528
+ const markResult = await atomicMutateJson(sessionFile, {}, (current) => ({
529
+ ...current,
530
+ last_wave_completed: Number.isInteger(current?.last_wave_completed)
531
+ && current.last_wave_completed > lastWave
532
+ ? current.last_wave_completed
533
+ : lastWave,
534
+ }), 'ose');
535
+ // The wave.completed EVENT above already fired regardless — this only
536
+ // withholds the shared-file high-water mark on a non-ENOENT failure.
537
+ if (!markResult.ok) {
538
+ console.error(`on-session-end: last_wave_completed mark skipped (${markResult.reason})`);
539
+ }
540
+ return { emitted: true, wave_number: lastWave };
541
+ } catch {
542
+ // best-effort — a SessionEnd hook must never block teardown
543
+ await emitFinalRefused(ids, 'exception');
544
+ return { emitted: false, reason: 'exception' };
545
+ }
139
546
  }
140
547
 
141
548
  // ---------------------------------------------------------------------------
@@ -148,16 +555,56 @@ async function main() {
148
555
 
149
556
  const reason =
150
557
  typeof input?.reason === 'string' && input.reason.length > 0 ? input.reason : 'other';
151
- const { sessionId, semanticSessionId, durationMs } = await resolveSession(input, projectRoot);
558
+ const { sessionId, semanticSessionId, durationMs, isRecordedSession, rawStdinId } =
559
+ await resolveSession(input, projectRoot);
152
560
 
153
561
  // Single emission path: emitEvent writes the canonical {timestamp, event, ...payload}
154
562
  // JSONL record AND fires the optional Clank webhook with the SAME event name.
155
- await emitEvent('orchestrator.session.ended', {
156
- ...(sessionId !== null ? { session_id: sessionId } : {}),
563
+ //
564
+ // #1068 AC1 the terminal event carries the raw UUID *and* the semantic id
565
+ // whenever the latter is ATTESTED, so a lifecycle outcome is joinable by
566
+ // identity from events.jsonl alone (previously only `session.lock.acquired`
567
+ // carried both, and only ~1/3 of sessions emit one). The key is OMITTED, never
568
+ // written as `""` or `null`, when `resolveSession()` could not attest it —
569
+ // "identity unresolved" must stay visibly unresolved rather than become a
570
+ // guessed id (#1068 AC1's explicit "niemals eine geratene ID"). Note the
571
+ // attestation bar is the #863 defect (c) guard inside `resolveSession()`: an
572
+ // ending session that is NOT the one current-session.json describes resolves
573
+ // `semanticSessionId: null` and therefore emits no key here.
574
+ // #1193 — close the last wave BEFORE the terminal session event, so the
575
+ // ledger's wave lifecycle is balanced within the session's own window. Gated
576
+ // on the SAME `isRecordedSession` attestation as the identity keys above, and
577
+ // skipped for `reason === 'clear'` and `reason === 'resume'` alike — both end
578
+ // the HARNESS session while the LOGICAL one continues; see the emitter's docblock.
579
+ //
580
+ // #1201 Part B — the return is `{emitted:true, wave_number}` or
581
+ // `{emitted:false, reason}`, but it is intentionally NOT branched on here:
582
+ // both outcomes already persisted themselves (`orchestrator.wave.completed`
583
+ // or the sibling `orchestrator.wave.final_refused`) before this call
584
+ // resolves, so nothing downstream in this hook needs to react to it.
585
+ await emitFinalWaveCompleted(projectRoot, {
586
+ sessionId,
587
+ semanticSessionId,
588
+ isRecordedSession,
157
589
  reason,
158
- duration_ms: durationMs,
590
+ rawStdinId,
159
591
  });
160
592
 
593
+ // #1183 — a malformed record throws EventValidationError BEFORE any side
594
+ // effect (scripts/lib/events.mjs); this hook must never abort on that, so
595
+ // the emit is wrapped rather than left to propagate.
596
+ try {
597
+ await emitEvent('orchestrator.session.ended', {
598
+ ...(sessionId !== null ? { session_id: sessionId } : {}),
599
+ ...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
600
+ reason,
601
+ // Omit-never-fabricate: only a MEASURED span is written. `null` here means
602
+ // the ending session is not the recorded one (or no start time parsed) —
603
+ // absence must stay absence, not become a zero-length session.
604
+ ...(Number.isFinite(durationMs) && durationMs >= 0 ? { duration_ms: durationMs } : {}),
605
+ });
606
+ } catch { /* telemetry never blocks the hook (#1183) */ }
607
+
161
608
  // -------------------------------------------------------------------------
162
609
  // C1 (#724) — Close-through backfill + deterministic lock release.
163
610
  // Both are STRICTLY best-effort: a SessionEnd hook must never block teardown
@@ -172,7 +619,21 @@ async function main() {
172
619
  // historical migration CLI) is NEVER passed here: a foreign lock that is live
173
620
  // at hook-time is, by definition, a real active session, not stale history.
174
621
  try {
175
- await backfillAbandonedSession({ repoRoot: projectRoot, sessionId, semanticSessionId });
622
+ const res = await backfillAbandonedSession({ repoRoot: projectRoot, sessionId, semanticSessionId });
623
+ await emitBackfillOutcome('abandoned', res, { sessionId, semanticSessionId });
624
+ } catch { /* best-effort — never block teardown */ }
625
+
626
+ // (a2) #429 — STATE.md `status: completed` self-heal. Orthogonal to (a)
627
+ // above and to THIS session's own id: it reads STATE.md directly and
628
+ // repairs a PAST session whose `status: completed` was set (by hand or
629
+ // otherwise) without session-end's Phase 3.7 ever writing the matching
630
+ // sessions.jsonl record — the exact state `commands/close.md`'s
631
+ // Pre-Check then reads as "already finalized" forever after. Cheap
632
+ // no-op on the overwhelmingly common path (STATE.md status is
633
+ // 'active'/'paused'/'idle', or the record already exists).
634
+ try {
635
+ const res = await backfillCompletedFromStateMd({ repoRoot: projectRoot });
636
+ await emitBackfillOutcome('state-md-completed', res, { sessionId, semanticSessionId });
176
637
  } catch { /* best-effort — never block teardown */ }
177
638
 
178
639
  // (b) Deterministic lock release — ONLY a lock whose raw/native session_id
@@ -276,7 +737,7 @@ async function main() {
276
737
  await emitEvent('orchestrator.session.lock.released', {
277
738
  session_id: sessionId,
278
739
  lock_session_id: lock.session_id,
279
- semantic_session_id: semanticSessionId,
740
+ ...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
280
741
  end_reason: reason,
281
742
  caller: 'on-session-end',
282
743
  outcome: benignAlreadyGone ? 'already-gone' : 'deleted',
@@ -348,6 +809,12 @@ async function main() {
348
809
  logSweepEvent({ event: 'deregister-failed', session_id: sessionId, error: err?.message ?? String(err) });
349
810
  }
350
811
  }
812
+
813
+ // (d) #1138 — mechanical telemetry flush. Deliberately LAST: it is the only
814
+ // step here that may touch the network, so every local-fs guarantee above
815
+ // (backfill, lock release, deregistration) is already durable before the
816
+ // hook spends any of its remaining budget on a POST.
817
+ await flushTelemetry(projectRoot);
351
818
  }
352
819
 
353
820
  // Exit 0 always — informational hook must never block session teardown.