session-orchestrator 3.21.0 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (345) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +11 -2
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/040-discovery.mdc +6 -6
  38. package/.cursor/rules/050-plan.mdc +10 -10
  39. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  40. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  41. package/.cursor/skills/architecture/SKILL.md +13 -0
  42. package/.cursor/skills/autopilot/SKILL.md +12 -0
  43. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  44. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  45. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  46. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  47. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  48. package/.cursor/skills/daily/SKILL.md +12 -0
  49. package/.cursor/skills/debug/SKILL.md +13 -0
  50. package/.cursor/skills/discovery/SKILL.md +13 -0
  51. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  52. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  53. package/.cursor/skills/domain-model/SKILL.md +13 -0
  54. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  55. package/.cursor/skills/eli5/SKILL.md +13 -0
  56. package/.cursor/skills/eval/SKILL.md +12 -0
  57. package/.cursor/skills/evolve/SKILL.md +13 -0
  58. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  60. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  61. package/.cursor/skills/grill/SKILL.md +13 -0
  62. package/.cursor/skills/hook-development/SKILL.md +13 -0
  63. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  64. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  65. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  66. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  67. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  68. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  69. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  70. package/.cursor/skills/plan/SKILL.md +13 -0
  71. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  72. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  73. package/.cursor/skills/reconcile/SKILL.md +12 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +269 -0
  91. package/README.md +18 -15
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -1
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/memory-proposal-collector.md +6 -4
  98. package/agents/session-reviewer.md +7 -1
  99. package/agents/test-writer.md +2 -1
  100. package/agents/ui-developer.md +2 -1
  101. package/commands/bootstrap.md +2 -2
  102. package/commands/close.md +3 -1
  103. package/commands/eli5.md +33 -0
  104. package/commands/go.md +1 -1
  105. package/commands/journey-audit.md +43 -0
  106. package/commands/release.md +5 -3
  107. package/commands/test.md +2 -2
  108. package/docs/USER-GUIDE.md +2 -2
  109. package/docs/ci-setup.md +14 -0
  110. package/docs/codex-setup.md +64 -0
  111. package/docs/components.md +7 -6
  112. package/docs/cursor-setup.md +26 -47
  113. package/docs/events-schema.md +76 -4
  114. package/docs/github-mirror-protection.md +197 -0
  115. package/docs/pi-setup.md +2 -0
  116. package/docs/rule-authoring.md +3 -1
  117. package/docs/scope-collision-guard.md +52 -5
  118. package/docs/session-config-reference.md +57 -12
  119. package/docs/session-config-template.md +4 -3
  120. package/docs/telemetry.md +22 -0
  121. package/hooks/_lib/lock-bootstrap.mjs +26 -16
  122. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  123. package/hooks/enforce-scope.mjs +64 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +12 -2
  128. package/hooks/on-session-end.mjs +237 -104
  129. package/hooks/on-session-start.mjs +404 -110
  130. package/hooks/on-stop.mjs +48 -9
  131. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  132. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  133. package/hooks/pre-auq-clarity.mjs +787 -0
  134. package/hooks/pre-bash-issue-budget.mjs +133 -21
  135. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  136. package/hooks/pre-bash-staging-fence.mjs +4 -0
  137. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  138. package/hooks/skill-invocation-telemetry.mjs +21 -10
  139. package/monitors/monitors.json +6 -0
  140. package/package.json +3 -1
  141. package/pi/prompts/eli5.md +12 -0
  142. package/pi/prompts/journey-audit.md +12 -0
  143. package/rules/_index.md +9 -1
  144. package/rules/always-on/ask-via-tool.md +62 -0
  145. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  146. package/rules/always-on/build-value.md +47 -0
  147. package/rules/always-on/cross-session-messaging.md +59 -0
  148. package/rules/always-on/loop-and-monitor.md +221 -0
  149. package/rules/always-on/parallel-sessions.md +142 -12
  150. package/rules/always-on/receiving-review.md +108 -0
  151. package/rules/always-on/test-value.md +40 -0
  152. package/rules/always-on/verification-before-completion.md +77 -0
  153. package/scripts/archive-closed-prds.mjs +258 -18
  154. package/scripts/auq-audit.mjs +825 -0
  155. package/scripts/autopilot.mjs +12 -8
  156. package/scripts/backfill-evidence-digest.mjs +376 -0
  157. package/scripts/cursor-install.mjs +89 -48
  158. package/scripts/export-hw-learnings.mjs +143 -2
  159. package/scripts/express-path.mjs +299 -0
  160. package/scripts/generate-cursor-adapter.mjs +253 -0
  161. package/scripts/github-protection-audit.mjs +358 -0
  162. package/scripts/lib/auq/clarity.mjs +1314 -0
  163. package/scripts/lib/auq/parse.mjs +1006 -0
  164. package/scripts/lib/auq/schema.mjs +1457 -0
  165. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  166. package/scripts/lib/build-live-signals.mjs +24 -5
  167. package/scripts/lib/ci-status-banner.mjs +221 -68
  168. package/scripts/lib/command-blocker.mjs +70 -0
  169. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  170. package/scripts/lib/config/reconcile.mjs +79 -4
  171. package/scripts/lib/config/section-extractor.mjs +235 -36
  172. package/scripts/lib/config/vault-integration.mjs +12 -1
  173. package/scripts/lib/config-schema.mjs +9 -1
  174. package/scripts/lib/config.mjs +57 -6
  175. package/scripts/lib/convergence-monitor.mjs +13 -2
  176. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  177. package/scripts/lib/dispatcher/cli.mjs +2 -2
  178. package/scripts/lib/dispatcher/rank.mjs +4 -7
  179. package/scripts/lib/express-path.mjs +327 -0
  180. package/scripts/lib/file-lock.mjs +22 -4
  181. package/scripts/lib/gates/gate-full.mjs +84 -11
  182. package/scripts/lib/gates/gate-helpers.mjs +93 -21
  183. package/scripts/lib/git-config-drift.mjs +134 -5
  184. package/scripts/lib/host-identity.mjs +247 -2
  185. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  186. package/scripts/lib/io.mjs +239 -0
  187. package/scripts/lib/issue-budget.mjs +284 -31
  188. package/scripts/lib/learnings/io.mjs +55 -10
  189. package/scripts/lib/learnings/schema.mjs +95 -28
  190. package/scripts/lib/lock-reaper.mjs +7 -1
  191. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  192. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  193. package/scripts/lib/memory-banner.mjs +5 -2
  194. package/scripts/lib/memory-paths.mjs +15 -6
  195. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  196. package/scripts/lib/owner-interview.mjs +78 -32
  197. package/scripts/lib/peer-discovery.mjs +73 -22
  198. package/scripts/lib/platform.mjs +72 -9
  199. package/scripts/lib/plugin-root.mjs +143 -19
  200. package/scripts/lib/project-hygiene.mjs +104 -4
  201. package/scripts/lib/quality-gate.mjs +271 -13
  202. package/scripts/lib/reconcile/emitter.mjs +87 -19
  203. package/scripts/lib/reconcile/engine.mjs +281 -13
  204. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  205. package/scripts/lib/reconcile/renderer.mjs +165 -7
  206. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  207. package/scripts/lib/reconcile/writer.mjs +415 -84
  208. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  209. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  210. package/scripts/lib/resource-probe.mjs +18 -2
  211. package/scripts/lib/rule-loader.mjs +37 -2
  212. package/scripts/lib/rules-sync.mjs +51 -8
  213. package/scripts/lib/scope-gate.mjs +90 -0
  214. package/scripts/lib/session-close-backfill.mjs +369 -28
  215. package/scripts/lib/session-discovery.mjs +13 -3
  216. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  217. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  218. package/scripts/lib/session-id.mjs +30 -14
  219. package/scripts/lib/session-identity/own-session.mjs +159 -0
  220. package/scripts/lib/session-lock.mjs +85 -30
  221. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  222. package/scripts/lib/session-schema/validator.mjs +40 -0
  223. package/scripts/lib/session-start-probes.mjs +608 -0
  224. package/scripts/lib/session-transition.mjs +277 -0
  225. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  226. package/scripts/lib/spiral-carryover.mjs +111 -9
  227. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  228. package/scripts/lib/state-md/mission-status.mjs +467 -72
  229. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  230. package/scripts/lib/state-md.mjs +12 -2
  231. package/scripts/lib/telemetry/sync.mjs +46 -8
  232. package/scripts/lib/validate/check-agents.mjs +66 -0
  233. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  234. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  235. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  236. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  237. package/scripts/lib/validate/check-hooks-symmetry.mjs +59 -63
  238. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  239. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  240. package/scripts/lib/validate/check-rules.mjs +153 -9
  241. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  242. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  243. package/scripts/lib/validate/repo-files.mjs +275 -0
  244. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  245. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  246. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  247. package/scripts/lib/vault-mirror/process.mjs +99 -43
  248. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  249. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  250. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  251. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  252. package/scripts/lib/vcs-repo-spec.mjs +182 -13
  253. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  254. package/scripts/lib/wave-resource-gate.mjs +67 -73
  255. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  256. package/scripts/materialize-wave-scope.mjs +478 -0
  257. package/scripts/mcp-server.sh +11 -2
  258. package/scripts/parse-config.mjs +65 -0
  259. package/scripts/release.mjs +443 -122
  260. package/scripts/run-quality-gate.mjs +14 -0
  261. package/scripts/token-audit.sh +9 -2
  262. package/scripts/validate-plugin.mjs +6 -0
  263. package/scripts/validate-wave-scope.mjs +73 -1
  264. package/scripts/vault-backfill.mjs +32 -5
  265. package/scripts/vault-mirror.mjs +203 -34
  266. package/skills/_shared/monitor-patterns.md +31 -5
  267. package/skills/_shared/parallel-aware-auq.md +31 -25
  268. package/skills/_shared/parallel-aware-preamble.md +35 -4
  269. package/skills/_shared/platform-tools.md +11 -5
  270. package/skills/_shared/state-ownership.md +61 -8
  271. package/skills/autopilot/SKILL.md +5 -1
  272. package/skills/bootstrap/SKILL.md +5 -4
  273. package/skills/bootstrap/_shared-template.md +18 -10
  274. package/skills/bootstrap/deep-template.md +10 -6
  275. package/skills/bootstrap/fast-template.md +15 -8
  276. package/skills/bootstrap/standard-template.md +10 -6
  277. package/skills/brainstorm/SKILL.md +18 -18
  278. package/skills/brainstorm/soul.md +12 -0
  279. package/skills/claude-md-drift-check/checker.mjs +39 -11
  280. package/skills/discovery/SKILL.md +28 -24
  281. package/skills/dispatcher/SKILL.md +1 -1
  282. package/skills/eli5/SKILL.md +43 -0
  283. package/skills/evolve/SKILL.md +8 -9
  284. package/skills/gitlab-ops/SKILL.md +30 -26
  285. package/skills/grill/SKILL.md +6 -6
  286. package/skills/grill/soul.md +16 -0
  287. package/skills/journey-audit/SKILL.md +269 -0
  288. package/skills/memory-cleanup/SKILL.md +2 -2
  289. package/skills/npm-publish/SKILL.md +4 -4
  290. package/skills/peekaboo-driver/SKILL.md +18 -6
  291. package/skills/persona-panel/SKILL.md +1 -1
  292. package/skills/plan/SKILL.md +18 -16
  293. package/skills/plan/mode-feature.md +1 -1
  294. package/skills/plan/mode-new.md +35 -23
  295. package/skills/plan/soul.md +12 -0
  296. package/skills/reconcile/SKILL.md +44 -4
  297. package/skills/session-end/SKILL.md +70 -24
  298. package/skills/session-end/metrics-collection.md +7 -4
  299. package/skills/session-end/phase-3-6-tail.md +48 -5
  300. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  301. package/skills/session-plan/SKILL.md +6 -1
  302. package/skills/session-plan/wave-template.md +1 -0
  303. package/skills/session-start/SKILL.md +96 -48
  304. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  305. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  306. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  307. package/skills/session-start/phase-8-5-express-path.md +77 -12
  308. package/skills/session-start/soul.md +110 -0
  309. package/skills/test-runner/SKILL.md +2 -2
  310. package/skills/using-orchestrator/SKILL.md +1 -1
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +4 -2
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +129 -24
  315. package/skills/write-executable-plan/SKILL.md +6 -6
  316. package/templates/_shared/journey-manifest.md +110 -0
  317. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  318. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  319. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  320. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  321. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  322. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  323. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  324. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  325. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  326. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  327. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  328. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  329. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  330. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  331. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  332. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  333. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  334. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  335. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  336. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  337. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  338. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  339. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  340. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  341. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  342. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  343. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  344. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
  345. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -10,7 +10,8 @@
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.)
14
15
  *
15
16
  * Exit codes: 0 always (informational hook — must never block session teardown).
16
17
  * stdin: optional JSON { hook_event_name:"SessionEnd", session_id?, reason?, cwd? }.
@@ -30,16 +31,20 @@ if (!shouldRunHook('on-session-end')) process.exit(0);
30
31
 
31
32
  import { emitEvent } from '../scripts/lib/events.mjs';
32
33
  import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
33
- import { backfillAbandonedSession } from '../scripts/lib/session-close-backfill.mjs';
34
+ import {
35
+ backfillAbandonedSession,
36
+ backfillCompletedFromStateMd,
37
+ } from '../scripts/lib/session-close-backfill.mjs';
34
38
  import {
35
39
  readLockDetailed,
36
40
  release,
37
- isLockLive,
38
41
  loadOwnerProof,
39
- isLockOwnedByProof,
40
42
  OWNER_PROOF_RELPATH,
41
43
  } from '../scripts/lib/session-lock.mjs';
44
+ import { parseSessionId } from '../scripts/lib/session-id.mjs';
42
45
  import { deregisterSelf, logSweepEvent } from '../scripts/lib/session-registry.mjs';
46
+ import { readConfigFile, parseSessionConfig } from '../scripts/lib/config.mjs';
47
+ import { flush } from '../scripts/lib/telemetry/sync.mjs';
43
48
  import { attemptLockReconciliation } from './_lib/lock-reconcile.mjs';
44
49
 
45
50
  // ---------------------------------------------------------------------------
@@ -74,29 +79,40 @@ async function readStdinJson() {
74
79
  }
75
80
 
76
81
  /**
77
- * Resolve this session's id + duration + semantic id. Stdin session_id wins;
78
- * otherwise fall back to `.orchestrator/current-session.json` (written by
79
- * on-session-start.mjs). duration_ms is only computed when the ENDING session
80
- * is the one recorded in current-session.json never fabricated for a
81
- * mismatched / unknown session.
82
+ * Resolve this session's id + duration + semantic id. A stdin session_id wins
83
+ * ONLY when it parses as a UUID; otherwise fall back to
84
+ * `.orchestrator/current-session.json` (written by on-session-start.mjs).
85
+ * duration_ms is only computed when the ENDING session is the one recorded in
86
+ * current-session.json — never fabricated for a mismatched / unknown session.
87
+ *
88
+ * #1091 / Kanevry#66 — WRITER/READER SYMMETRY. `on-session-start.mjs`
89
+ * (`resolveSessionId`, :316-317) accepts a stdin raw id only when
90
+ * `parseSessionId(fromStdin)?.format === 'uuid'` and otherwise mints a
91
+ * `randomUUID()`; `current-session.json`, `session.lock` and the host registry
92
+ * are therefore ALWAYS keyed by a UUID. This reader used to accept ANY
93
+ * non-empty stdin string, so a harness that passed a non-UUID id
94
+ * (`{"session_id":"not-a-uuid"}`) resolved a key that matches nothing written
95
+ * at start: the raw-ID ownership compare below (:330) fails, the lock is
96
+ * neither released nor reconciled, and it LEAKS until its TTL expires. Same
97
+ * for `deregisterSelf()`, whose registry file is named after the id
98
+ * `registerSelf()` used. Mirroring the writer's rule here makes the fallback
99
+ * (which reads exactly those artifacts) the single source of the identity.
100
+ * The ownership compare itself stays an exact string `===` — this changes
101
+ * WHICH id is compared, never HOW.
82
102
  *
83
103
  * `semanticSessionId` is read from current-session.json (present since #587):
84
104
  * it is the SEMANTIC id (`<branch>-<date>-<mode>-<n>`) that sessions.jsonl is
85
105
  * keyed by, and the id the backfill (C1 #724) uses when the stdin session_id is
86
- * a harness UUID with no lock.acquired bridge.
106
+ * a harness UUID with no lock.acquired bridge. It is never a lock-release
107
+ * ownership identity; only the raw/native `sessionId` may release a live lock.
87
108
  *
88
109
  * #863 defect (c) — `semanticSessionId` is gated by the SAME "ending session
89
110
  * IS the recorded one" check as `durationMs` above. `current-session.json` is
90
111
  * a single repo-global file: it always reflects whichever session most
91
112
  * recently ran SessionStart, which may be a DIFFERENT, still-live session
92
- * when multiple windows share this repo. Before this fix, a foreign
93
- * terminating window (its own stdin `session_id` explicitly present, but NOT
94
- * equal to the recorded session) still inherited the CURRENTLY-recorded
95
- * session's `semantic_session_id` unconditionally — main()'s `ownBySemantic`
96
- * check then matched that OTHER, still-running session's lock and released
97
- * it. Gating this field closes that window: an unrelated/mismatched ending
98
- * session now resolves `semanticSessionId: null`, so `ownBySemantic` can
99
- * never accidentally fire on someone else's identity.
113
+ * when multiple windows share this repo. An unrelated/mismatched ending
114
+ * session therefore resolves `semanticSessionId: null` rather than inheriting
115
+ * another live session's backfill identity.
100
116
  *
101
117
  * @param {object|null} input
102
118
  * @param {string} projectRoot
@@ -104,7 +120,8 @@ async function readStdinJson() {
104
120
  */
105
121
  async function resolveSession(input, projectRoot) {
106
122
  const fromStdin = input?.session_id ?? input?.sessionId ?? null;
107
- let sessionId = (typeof fromStdin === 'string' && fromStdin.length > 0) ? fromStdin : null;
123
+ // UUID-only, exactly as the writer decides it (see the docblock above).
124
+ let sessionId = parseSessionId(fromStdin)?.format === 'uuid' ? fromStdin : null;
108
125
 
109
126
  let recordedId = null;
110
127
  let semanticSessionId = null;
@@ -144,6 +161,152 @@ async function resolveSession(input, projectRoot) {
144
161
  return { sessionId, semanticSessionId: resolvedSemanticSessionId, durationMs };
145
162
  }
146
163
 
164
+ /**
165
+ * POST budget for the close-time telemetry flush (ms).
166
+ *
167
+ * NAMED CEILING, and the reason it is BELOW the module default POST_TIMEOUT_MS
168
+ * (3000): the two timeouts fail differently.
169
+ *
170
+ * - This one expiring is LOSSLESS. `flush()` catches the abort and routes the
171
+ * record into the bounded offline queue; the daily fallback
172
+ * (`shouldDailyFlush`) drains it on a later session.
173
+ * - The HARNESS timeout expiring is LOSSY. Claude Code kills the hook process
174
+ * mid-flight, so the enqueue never runs and the record is simply gone —
175
+ * which is the exact failure #1138 exists to remove.
176
+ *
177
+ * So the internal bound must stay comfortably under the harness bound, not
178
+ * merely below it. Budget (hooks/hooks.json `SessionEnd.timeout: 10` s, raised
179
+ * from 5 with this change): up to 500 ms stdin + a backfill measured at a
180
+ * ~845 ms median (its TAIL, not its median, is what would collide) + lock
181
+ * release + deregistration, then this 2 s. That leaves several seconds of slack
182
+ * for the backfill tail while still covering a normal round-trip many times
183
+ * over.
184
+ *
185
+ * Revisit if the hooks.json SessionEnd timeout changes — the pin lives in
186
+ * tests/hooks/on-session-end.test.mjs.
187
+ */
188
+ const TELEMETRY_FLUSH_TIMEOUT_MS = 2000;
189
+
190
+ /**
191
+ * Read `persistence` from the repo's Session Config. Defaults to `true` — the
192
+ * same default `scripts/lib/config.mjs` applies — so an unreadable or absent
193
+ * CLAUDE.md (or its Codex alias AGENTS.md) never silently disables the flush.
194
+ *
195
+ * @param {string} projectRoot
196
+ * @returns {Promise<boolean>}
197
+ */
198
+ async function readPersistence(projectRoot) {
199
+ try {
200
+ const md = await readConfigFile(projectRoot);
201
+ return parseSessionConfig(md).persistence !== false;
202
+ } catch {
203
+ return true;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Reduce a `flush()` result to the two-field breadcrumb the event carries.
209
+ *
210
+ * `reason` is normalised to its head token because `flush()` may return
211
+ * `build-error: <message>`, and a raw error message is unbounded free text in a
212
+ * stream whose whole purpose is aggregation by class.
213
+ *
214
+ * @param {{sent?: boolean, queued?: boolean, reason?: string}|null|undefined} res
215
+ * @returns {{outcome: 'sent'|'queued'|'gated'|'skipped', reason: string}}
216
+ */
217
+ function classifyFlush(res) {
218
+ const reason = String(res?.reason ?? 'unknown').split(':')[0];
219
+ if (res?.sent === true) return { outcome: 'sent', reason };
220
+ if (res?.queued === true) return { outcome: 'queued', reason };
221
+ if (reason === 'gated') return { outcome: 'gated', reason };
222
+ return { outcome: 'skipped', reason };
223
+ }
224
+
225
+ /**
226
+ * #1138 — the MECHANICAL telemetry flush.
227
+ *
228
+ * Until now the close-time flush existed only as prose in
229
+ * `skills/session-end/SKILL.md` § Phase 3.45, i.e. it ran only when the
230
+ * coordinator LLM happened to execute that phase. Measured 2026-08-23: 588
231
+ * session closes across 13 repos produced 82 ingest records (~14%). A hook is
232
+ * the only caller that fires on EVERY close, including the ones that never
233
+ * reach `/close` at all.
234
+ *
235
+ * Strictly best-effort: never throws, and bounded by TELEMETRY_FLUSH_TIMEOUT_MS.
236
+ * The consent gate lives INSIDE `flush()` (`resolveConsent()` is its first
237
+ * statement) — this function deliberately does not re-implement it, so there is
238
+ * exactly one place where "may we send?" is decided.
239
+ *
240
+ * Always emits `orchestrator.telemetry.flush` with `{ outcome, reason }` and
241
+ * NOTHING else — no payload, no anon_id. Per `.claude/rules/host-resources.md`
242
+ * HR-105, a mechanism whose firing rate nothing records cannot be falsified;
243
+ * this event is what makes the flush rate measurable next time.
244
+ *
245
+ * @param {string} projectRoot
246
+ * @returns {Promise<void>}
247
+ */
248
+ async function flushTelemetry(projectRoot) {
249
+ let result;
250
+ try {
251
+ result = (await readPersistence(projectRoot))
252
+ ? classifyFlush(await flush({
253
+ metricsDir: path.join(projectRoot, '.orchestrator', 'metrics'),
254
+ timeoutMs: TELEMETRY_FLUSH_TIMEOUT_MS,
255
+ }))
256
+ // `persistence: false` means this session leaves no durable local trace;
257
+ // a telemetry ping is a durable record too, so it honours the same switch.
258
+ : { outcome: 'skipped', reason: 'persistence-disabled' };
259
+ } catch {
260
+ result = { outcome: 'skipped', reason: 'error' };
261
+ }
262
+
263
+ // `result` is passed through verbatim: it holds EXACTLY {outcome, reason}, so
264
+ // no payload field can leak into the event by accident.
265
+ try {
266
+ await emitEvent('orchestrator.telemetry.flush', result);
267
+ } catch { /* observability is best-effort */ }
268
+ }
269
+
270
+ /**
271
+ * #1068 AC2 — emit ONE canonically queryable outcome per backfill attempt.
272
+ *
273
+ * Until now a backfill result reached exactly one place:
274
+ * `.orchestrator/metrics/session-close-backfill.log`, a side-log no lifecycle
275
+ * consumer reads and no staleness/integrity check joins against. The event
276
+ * stream is where every other lifecycle fact already lives, so the outcome goes
277
+ * there too — carrying IDENTITY (`session_id` raw UUID + the record id the
278
+ * backfill acted on + the attested semantic id), ACTION, and, when the action
279
+ * was an error, its REASON. The side-log is unchanged and remains the verbose
280
+ * copy; this is the queryable one.
281
+ *
282
+ * `kind` distinguishes the two backfill classes, which share the log file and
283
+ * would otherwise be indistinguishable in aggregate: `'abandoned'`
284
+ * (`backfillAbandonedSession`) vs `'state-md-completed'`
285
+ * (`backfillCompletedFromStateMd`).
286
+ *
287
+ * Every id key is OMITTED when unknown — never `""`, never a guess (#1068 AC1).
288
+ * Best-effort throughout: observability must never block teardown.
289
+ *
290
+ * @param {'abandoned'|'state-md-completed'} kind
291
+ * @param {{action?: string, sessionId?: string|null, supersedes?: string, error?: string}|null|undefined} result
292
+ * @param {{sessionId: string|null, semanticSessionId: string|null}} ids
293
+ * @returns {Promise<void>}
294
+ */
295
+ async function emitBackfillOutcome(kind, result, { sessionId, semanticSessionId }) {
296
+ try {
297
+ const recordId = typeof result?.sessionId === 'string' ? result.sessionId : null;
298
+ await emitEvent('orchestrator.session.backfill_completed', {
299
+ kind,
300
+ action: typeof result?.action === 'string' ? result.action : 'unknown',
301
+ ...(sessionId !== null ? { session_id: sessionId } : {}),
302
+ ...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
303
+ ...(recordId !== null ? { record_id: recordId } : {}),
304
+ ...(typeof result?.supersedes === 'string' ? { supersedes: result.supersedes } : {}),
305
+ ...(typeof result?.error === 'string' ? { reason: result.error } : {}),
306
+ });
307
+ } catch { /* observability is best-effort */ }
308
+ }
309
+
147
310
  // ---------------------------------------------------------------------------
148
311
  // main
149
312
  // ---------------------------------------------------------------------------
@@ -158,8 +321,20 @@ async function main() {
158
321
 
159
322
  // Single emission path: emitEvent writes the canonical {timestamp, event, ...payload}
160
323
  // JSONL record AND fires the optional Clank webhook with the SAME event name.
324
+ //
325
+ // #1068 AC1 — the terminal event carries the raw UUID *and* the semantic id
326
+ // whenever the latter is ATTESTED, so a lifecycle outcome is joinable by
327
+ // identity from events.jsonl alone (previously only `session.lock.acquired`
328
+ // carried both, and only ~1/3 of sessions emit one). The key is OMITTED, never
329
+ // written as `""` or `null`, when `resolveSession()` could not attest it —
330
+ // "identity unresolved" must stay visibly unresolved rather than become a
331
+ // guessed id (#1068 AC1's explicit "niemals eine geratene ID"). Note the
332
+ // attestation bar is the #863 defect (c) guard inside `resolveSession()`: an
333
+ // ending session that is NOT the one current-session.json describes resolves
334
+ // `semanticSessionId: null` and therefore emits no key here.
161
335
  await emitEvent('orchestrator.session.ended', {
162
336
  ...(sessionId !== null ? { session_id: sessionId } : {}),
337
+ ...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
163
338
  reason,
164
339
  duration_ms: durationMs,
165
340
  });
@@ -178,27 +353,32 @@ async function main() {
178
353
  // historical migration CLI) is NEVER passed here: a foreign lock that is live
179
354
  // at hook-time is, by definition, a real active session, not stale history.
180
355
  try {
181
- await backfillAbandonedSession({ repoRoot: projectRoot, sessionId, semanticSessionId });
356
+ const res = await backfillAbandonedSession({ repoRoot: projectRoot, sessionId, semanticSessionId });
357
+ await emitBackfillOutcome('abandoned', res, { sessionId, semanticSessionId });
182
358
  } catch { /* best-effort — never block teardown */ }
183
359
 
184
- // (b) Deterministic lock release ONLY our OWN lock. Match by UUID first,
185
- // then by semantic id (the UUID rotates across clear/compact/resume while
186
- // the semantic id stays stable, #612). A foreign lock is never released.
360
+ // (a2) #429STATE.md `status: completed` self-heal. Orthogonal to (a)
361
+ // above and to THIS session's own id: it reads STATE.md directly and
362
+ // repairs a PAST session whose `status: completed` was set (by hand or
363
+ // otherwise) without session-end's Phase 3.7 ever writing the matching
364
+ // sessions.jsonl record — the exact state `commands/close.md`'s
365
+ // Pre-Check then reads as "already finalized" forever after. Cheap
366
+ // no-op on the overwhelmingly common path (STATE.md status is
367
+ // 'active'/'paused'/'idle', or the record already exists).
368
+ try {
369
+ const res = await backfillCompletedFromStateMd({ repoRoot: projectRoot });
370
+ await emitBackfillOutcome('state-md-completed', res, { sessionId, semanticSessionId });
371
+ } catch { /* best-effort — never block teardown */ }
372
+
373
+ // (b) Deterministic lock release — ONLY a lock whose raw/native session_id
374
+ // exactly matches this ending session's raw/native sessionId. Semantic
375
+ // IDs remain lifecycle/backfill metadata and persisted proofs remain an
376
+ // additional delete defense, but neither can heal a raw-ID mismatch.
187
377
  //
188
- // Epic #724 hardening ("ended logged but lock survived"): the ownership
189
- // match alone left two silent failure modes
190
- // (1) release() can fail at the fs layer; the swallowed try/catch below
191
- // made this invisible (session.ended is logged, the lock survives).
192
- // (2) when the harness UUID rotates (clear/compact/resume) and
193
- // current-session.json's semantic_session_id is null/stale, BOTH
194
- // ownership checks are false and this branch never runs release()
195
- // at all — the lock survives until the NEXT session-start's own
196
- // reaper sweep discovers it.
197
- // Both are addressed below without loosening the strict
198
- // never-release-a-foreign-lock invariant: release() only ever runs when
199
- // ownership matched; the reconciliation fallback only ever runs through
200
- // reapRepoLock(), which independently NEVER reaps a live lease (and only
201
- // ever reaps on this same host with a dead recorded PID).
378
+ // A mismatch routes only through reconciliation. The reaper independently
379
+ // never touches a live lease, a cross-host lease, or a lease whose
380
+ // recorded PID is still alive on this host; this retains dead/stale-lock
381
+ // recovery without granting a SessionEnd hook foreign-lock ownership.
202
382
  try {
203
383
  // readLockDetailed (additive, see its JSDoc in session-lock.mjs) replaces
204
384
  // readLock() here so an unreadable/corrupt lock file is DISTINGUISHABLE
@@ -219,61 +399,15 @@ async function main() {
219
399
  } catch { /* observability is best-effort */ }
220
400
  } else if (lockDetail.status === 'ok') {
221
401
  const lock = lockDetail.lock;
222
- const ownByUuid = sessionId !== null && lock.session_id === sessionId;
223
- const ownBySemanticStrict =
224
- semanticSessionId !== null && lock.semantic_session_id === semanticSessionId;
225
- // #863 (d) lock-shape trap: some on-disk locks store the semantic id
226
- // directly in `session_id` with no separate `semantic_session_id` field
227
- // at all (the "generated-semantic" acquisition path in
228
- // on-session-start.mjs mints `session_id === the semantic id`, and
229
- // bootstrapLock's v2 enrichment step never ran for that lock). Without
230
- // this fallback, ownBySemanticStrict is dead code for that shape.
231
- const ownBySemanticFallback =
232
- semanticSessionId !== null && lock.session_id === semanticSessionId;
233
- const ownBySemantic = ownBySemanticStrict || ownBySemanticFallback;
234
-
235
- // #987 Part 2 — persisted ownership proof (pid + host + started_at,
236
- // written at lock genesis by bootstrapLock Step 2b, see
237
- // writeOwnerProof() in session-lock.mjs). loadOwnerProof() is
238
- // fail-closed: a missing/corrupt proof file yields null, and a null
239
- // proof presented to isLockOwnedByProof() yields false — the hook then
240
- // degrades to exactly the pre-#987 (proof-less) behaviour below.
241
- const proof = loadOwnerProof({ repoRoot: projectRoot });
242
- const ownByProof = isLockOwnedByProof(lock, proof);
243
-
244
- // #906-class fix: the semantic session id
245
- // (`<branch>-<date>-<mode>-<n>`) is NOT globally unique — the
246
- // id-counter can hand out the SAME id to two different session
247
- // processes (live-observed twice on 2026-07-29: `main-2026-07-29-deep-1`
248
- // and `main-2026-07-29-session-1`, each assigned to two distinct
249
- // sessions). A live lock matched ONLY via a collidable semantic id
250
- // (strict OR fallback, never corroborated by a non-collidable factor)
251
- // is therefore NEVER trusted for release.
252
- //
253
- // #987 Part 2 — the discrimination the #906-class fix escalated as
254
- // "structurally unobservable" is now OBSERVABLE via the persisted
255
- // owner proof: a genuine same-session UUID rotation (clear/compact)
256
- // and a foreign same-day semantic collision used to produce the
257
- // IDENTICAL shape here (UUID mismatch + semantic match on a live
258
- // lock), so the previous fix conservatively left BOTH un-released
259
- // (bounded by the ttl_hours reaper). The proof written at lock genesis
260
- // (pid + host + started_at — presence-at-genesis, never the collidable
261
- // session_id/semantic id, see the FACTOR CHOICE docblock on
262
- // isLockOwnedByProof()) is exactly the discriminator that RCR-007
263
- // escalation named: on a self-rotation the on-disk lock is FROZEN at
264
- // its pre-rotation values (bootstrapLock's shouldForce is false for a
265
- // rotated UUID, so it bails without touching the lock), which are the
266
- // SAME values the proof captured at genesis → ownByProof is true → the
267
- // lock is released correctly. A foreign same-day collision wrote its
268
- // lock in a DIFFERENT process at a DIFFERENT millisecond → ownByProof
269
- // is false → semanticOnlyLive stays true → no release, the foreign
270
- // lease survives (the never-release-a-foreign-lock invariant is
271
- // untouched). No proof on disk (pre-#987 sessions, failed proof
272
- // write) → ownByProof false → the conservative pre-#987 behaviour.
273
- const semanticOnlyLive = !ownByUuid && !ownByProof && ownBySemantic && isLockLive(lock);
274
- const releaseEligible = (ownByUuid || ownBySemantic) && !semanticOnlyLive;
275
-
276
- if (releaseEligible) {
402
+ const ownByRawId = sessionId !== null && lock.session_id === sessionId;
403
+
404
+ if (ownByRawId) {
405
+ // The proof is deliberately passed to release() only after this raw-ID
406
+ // check. release() validates a supplied proof against its fresh on-disk
407
+ // read, preserving it as a second defense for the raw-owned path without
408
+ // allowing proof equality to establish ownership by itself.
409
+ const proof = loadOwnerProof({ repoRoot: projectRoot });
410
+
277
411
  // Defense-in-depth (#987): when a persisted proof exists, hand it to
278
412
  // release() so the delete is double-gated (session_id match AND
279
413
  // proof match) at the fs layer too. `proof` is `null` whenever
@@ -284,7 +418,7 @@ async function main() {
284
418
  // A 'proof-mismatch' result flows into the existing release_failed
285
419
  // breadcrumb below (releaseResult.reason surfaces verbatim).
286
420
  const releaseResult = release({
287
- sessionId: lock.session_id,
421
+ sessionId,
288
422
  repoRoot: projectRoot,
289
423
  proof,
290
424
  });
@@ -337,7 +471,7 @@ async function main() {
337
471
  await emitEvent('orchestrator.session.lock.released', {
338
472
  session_id: sessionId,
339
473
  lock_session_id: lock.session_id,
340
- semantic_session_id: semanticSessionId,
474
+ ...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
341
475
  end_reason: reason,
342
476
  caller: 'on-session-end',
343
477
  outcome: benignAlreadyGone ? 'already-gone' : 'deleted',
@@ -355,16 +489,9 @@ async function main() {
355
489
  } catch { /* best-effort — a leftover proof is self-invalidating */ }
356
490
  }
357
491
  } else {
358
- // Root-cause reconciliation fallback: either NEITHER the UUID nor the
359
- // semantic id matched the recorded lock, OR the ONLY match was a
360
- // collidable semantic comparison (strict or fallback) on a lock that
361
- // is still live (semanticOnlyLive). attemptLockReconciliation() is
362
- // the extracted, DI-testable seam (Issue #748) — it internally
363
- // no-ops when the lease is still live (isLockLive), which is exactly
364
- // what makes it safe to route the semanticOnlyLive case here too: it
365
- // is otherwise best-effort, and reapRepoLock() never touches a live
366
- // lease, a cross-host lease, or a lease whose recorded PID is still
367
- // alive on this host.
492
+ // A raw-ID mismatch never grants release ownership. Reconciliation is
493
+ // the only remaining cleanup path; it no-ops for a live lock and safely
494
+ // reaps only eligible dead/stale locks.
368
495
  await attemptLockReconciliation({ repoRoot: projectRoot, sessionId, lock });
369
496
  }
370
497
  }
@@ -416,6 +543,12 @@ async function main() {
416
543
  logSweepEvent({ event: 'deregister-failed', session_id: sessionId, error: err?.message ?? String(err) });
417
544
  }
418
545
  }
546
+
547
+ // (d) #1138 — mechanical telemetry flush. Deliberately LAST: it is the only
548
+ // step here that may touch the network, so every local-fs guarantee above
549
+ // (backfill, lock release, deregistration) is already durable before the
550
+ // hook spends any of its remaining budget on a POST.
551
+ await flushTelemetry(projectRoot);
419
552
  }
420
553
 
421
554
  // Exit 0 always — informational hook must never block session teardown.