session-orchestrator 3.17.0 → 3.19.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 (187) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +105 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/docs/USER-GUIDE.md +8 -3
  21. package/docs/ci-setup.md +121 -7
  22. package/docs/codex-setup.md +1 -1
  23. package/docs/components.md +6 -6
  24. package/docs/cursor-setup.md +22 -9
  25. package/docs/events-schema.md +5 -1
  26. package/docs/instruction-delivery.md +444 -0
  27. package/docs/rule-authoring.md +58 -9
  28. package/docs/session-config-reference.md +244 -9
  29. package/docs/session-config-template.md +39 -3
  30. package/hooks/_lib/guard-source-loader.mjs +467 -0
  31. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  32. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  33. package/hooks/config-protection.mjs +0 -0
  34. package/hooks/enforce-commands.mjs +10 -2
  35. package/hooks/hooks-codex.json +1 -1
  36. package/hooks/hooks-cursor.json +11 -2
  37. package/hooks/hooks-pi.json +10 -0
  38. package/hooks/hooks.json +21 -1
  39. package/hooks/on-session-end.mjs +178 -18
  40. package/hooks/on-session-start.mjs +23 -0
  41. package/hooks/post-bash-write-verify.mjs +977 -0
  42. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  43. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  44. package/hooks/pre-bash-issue-budget.mjs +167 -0
  45. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  46. package/hooks/pre-bash-templates-first.mjs +96 -63
  47. package/hooks/subagent-telemetry.mjs +527 -37
  48. package/package.json +5 -2
  49. package/pi/prompts/contract-version-bump.md +12 -0
  50. package/rules/README.md +32 -0
  51. package/scripts/archive-closed-prds.mjs +12 -22
  52. package/scripts/autopilot-multi.mjs +103 -20
  53. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  54. package/scripts/check-doc-consistency.sh +17 -1
  55. package/scripts/eval-session.mjs +50 -9
  56. package/scripts/fleet-instruction-scan.mjs +141 -0
  57. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  58. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  59. package/scripts/lib/backlog-scan.mjs +39 -6
  60. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  61. package/scripts/lib/ci-status-banner.mjs +75 -12
  62. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  63. package/scripts/lib/command-blocker.mjs +1013 -58
  64. package/scripts/lib/config/config-protection.mjs +2 -1
  65. package/scripts/lib/config/drift-check.mjs +9 -1
  66. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  67. package/scripts/lib/config/issue-budget.mjs +123 -0
  68. package/scripts/lib/config/reconcile.mjs +21 -0
  69. package/scripts/lib/config/section-extractor.mjs +121 -1
  70. package/scripts/lib/config-schema.mjs +23 -3
  71. package/scripts/lib/config.mjs +17 -0
  72. package/scripts/lib/convergence-monitor.mjs +49 -3
  73. package/scripts/lib/description-surface.mjs +535 -0
  74. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  75. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  76. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  77. package/scripts/lib/eval/engine.mjs +47 -5
  78. package/scripts/lib/events.mjs +59 -7
  79. package/scripts/lib/gates/gate-full.mjs +15 -3
  80. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  81. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  82. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  83. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  84. package/scripts/lib/handover-gate.mjs +7 -3
  85. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  86. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  87. package/scripts/lib/io.mjs +345 -10
  88. package/scripts/lib/issue-budget.mjs +269 -0
  89. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  90. package/scripts/lib/label-scope.mjs +47 -0
  91. package/scripts/lib/learnings/schema.mjs +43 -3
  92. package/scripts/lib/lock-reaper.mjs +1 -2
  93. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  94. package/scripts/lib/peer-discovery.mjs +645 -0
  95. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  96. package/scripts/lib/product-repo-detect.mjs +9 -8
  97. package/scripts/lib/project-hygiene.mjs +432 -0
  98. package/scripts/lib/quality-gate.mjs +167 -0
  99. package/scripts/lib/recommendations-v0.mjs +1 -1
  100. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  101. package/scripts/lib/reconcile/emitter.mjs +23 -4
  102. package/scripts/lib/reconcile/engine.mjs +147 -39
  103. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  104. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  105. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  106. package/scripts/lib/resource-probe.mjs +19 -0
  107. package/scripts/lib/rule-loader.mjs +6 -0
  108. package/scripts/lib/scope-baseline.mjs +564 -0
  109. package/scripts/lib/scope-gate.mjs +399 -98
  110. package/scripts/lib/session-close-backfill.mjs +61 -6
  111. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  112. package/scripts/lib/session-id.mjs +221 -41
  113. package/scripts/lib/session-lock.mjs +304 -6
  114. package/scripts/lib/session-schema/constants.mjs +22 -3
  115. package/scripts/lib/session-schema/validator.mjs +16 -0
  116. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  117. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  118. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  119. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  120. package/scripts/lib/spiral-carryover.mjs +142 -30
  121. package/scripts/lib/state-md/mission-status.mjs +53 -3
  122. package/scripts/lib/subagents-schema.mjs +43 -9
  123. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  124. package/scripts/lib/tests-src-ratio.mjs +484 -0
  125. package/scripts/lib/validate/check-agents.mjs +56 -0
  126. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  127. package/scripts/lib/validate/check-rules.mjs +217 -35
  128. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  129. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  130. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  131. package/scripts/lib/vault-archive.mjs +17 -2
  132. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  133. package/scripts/lib/vault-mirror/process.mjs +30 -0
  134. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  135. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  136. package/scripts/lib/wave-resource-gate.mjs +115 -11
  137. package/scripts/lib/worktree/listing.mjs +44 -7
  138. package/scripts/mcp-server.sh +17 -3
  139. package/scripts/measure-context-overhead.sh +151 -0
  140. package/scripts/memory-propose.mjs +72 -9
  141. package/scripts/print-applicable-rules.mjs +51 -12
  142. package/scripts/release.mjs +534 -0
  143. package/scripts/run-quality-gate.mjs +123 -5
  144. package/scripts/validate-wave-scope.mjs +182 -17
  145. package/scripts/vault-integration-watcher.mjs +32 -10
  146. package/skills/_shared/config-reading.md +2 -2
  147. package/skills/bootstrap/fast-template.md +1 -1
  148. package/skills/claude-md-drift-check/checker.mjs +145 -28
  149. package/skills/contract-version-bump/SKILL.md +219 -0
  150. package/skills/discovery/SKILL.md +4 -4
  151. package/skills/discovery/issue-templates.md +11 -11
  152. package/skills/discovery/probes-audit.md +1 -1
  153. package/skills/discovery/probes-feature.md +1 -1
  154. package/skills/discovery/probes-session.md +26 -5
  155. package/skills/ecosystem-health/SKILL.md +1 -1
  156. package/skills/ecosystem-health/wizard.md +4 -4
  157. package/skills/evolve/SKILL.md +1 -0
  158. package/skills/gitlab-ops/SKILL.md +20 -12
  159. package/skills/gitlab-portfolio/SKILL.md +2 -2
  160. package/skills/hook-development/SKILL.md +1 -1
  161. package/skills/mode-selector/SKILL.md +1 -1
  162. package/skills/npm-publish/SKILL.md +17 -1
  163. package/skills/plan/SKILL.md +5 -5
  164. package/skills/plan/mode-feature.md +4 -4
  165. package/skills/plan/mode-new.md +10 -10
  166. package/skills/plan/mode-retro.md +1 -1
  167. package/skills/quality-gates/SKILL.md +1 -1
  168. package/skills/reconcile/SKILL.md +21 -4
  169. package/skills/session-end/SKILL.md +34 -13
  170. package/skills/session-end/discovery-scan.md +4 -2
  171. package/skills/session-end/drift-operations.md +4 -4
  172. package/skills/session-end/metrics-collection.md +13 -0
  173. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  174. package/skills/session-end/phase-3-6-tail.md +2 -1
  175. package/skills/session-end/plan-verification.md +5 -2
  176. package/skills/session-end/vault-operations.md +1 -1
  177. package/skills/session-end/verification-checklist.md +1 -1
  178. package/skills/session-plan/SKILL.md +6 -2
  179. package/skills/session-plan/wave-template.md +2 -0
  180. package/skills/session-start/SKILL.md +73 -7
  181. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  182. package/skills/test-runner/SKILL.md +2 -2
  183. package/skills/vault-sync/validator.mjs +108 -7
  184. package/skills/wave-executor/SKILL.md +5 -2
  185. package/skills/wave-executor/circuit-breaker.md +2 -0
  186. package/skills/wave-executor/wave-loop.md +163 -10
  187. package/templates/_shared/loop.md +4 -4
@@ -88,6 +88,16 @@
88
88
  }
89
89
  ]
90
90
  },
91
+ {
92
+ "matcher": "bash",
93
+ "hooks": [
94
+ {
95
+ "type": "command",
96
+ "command": "sh \"$PI_PLUGIN_ROOT/hooks/run-node.sh\" \"$PI_PLUGIN_ROOT/hooks/post-bash-write-verify.mjs\"",
97
+ "timeout": 5
98
+ }
99
+ ]
100
+ },
91
101
  {
92
102
  "matcher": "*",
93
103
  "hooks": [
package/hooks/hooks.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "echo '🎯 Session Orchestrator v3.17.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
9
+ "command": "echo '🎯 Session Orchestrator v3.19.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
10
  "async": false
11
11
  },
12
12
  {
@@ -74,11 +74,21 @@
74
74
  "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-memory-propose-audit.mjs\"",
75
75
  "timeout": 5
76
76
  },
77
+ {
78
+ "type": "command",
79
+ "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-sessions-ledger-guard.mjs\"",
80
+ "timeout": 5
81
+ },
77
82
  {
78
83
  "type": "command",
79
84
  "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-templates-first.mjs\"",
80
85
  "timeout": 5
81
86
  },
87
+ {
88
+ "type": "command",
89
+ "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-issue-budget.mjs\"",
90
+ "timeout": 5
91
+ },
82
92
  {
83
93
  "type": "command",
84
94
  "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/enforce-commands.mjs\"",
@@ -108,6 +118,16 @@
108
118
  }
109
119
  ]
110
120
  },
121
+ {
122
+ "matcher": "Bash",
123
+ "hooks": [
124
+ {
125
+ "type": "command",
126
+ "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/post-bash-write-verify.mjs\"",
127
+ "timeout": 5
128
+ }
129
+ ]
130
+ },
111
131
  {
112
132
  "matcher": "*",
113
133
  "hooks": [
@@ -31,7 +31,14 @@ if (!shouldRunHook('on-session-end')) process.exit(0);
31
31
  import { emitEvent } from '../scripts/lib/events.mjs';
32
32
  import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
33
33
  import { backfillAbandonedSession } from '../scripts/lib/session-close-backfill.mjs';
34
- import { readLock, release } from '../scripts/lib/session-lock.mjs';
34
+ import {
35
+ readLockDetailed,
36
+ release,
37
+ isLockLive,
38
+ loadOwnerProof,
39
+ isLockOwnedByProof,
40
+ OWNER_PROOF_RELPATH,
41
+ } from '../scripts/lib/session-lock.mjs';
35
42
  import { attemptLockReconciliation } from './_lib/lock-reconcile.mjs';
36
43
 
37
44
  // ---------------------------------------------------------------------------
@@ -77,6 +84,19 @@ async function readStdinJson() {
77
84
  * keyed by, and the id the backfill (C1 #724) uses when the stdin session_id is
78
85
  * a harness UUID with no lock.acquired bridge.
79
86
  *
87
+ * #863 defect (c) — `semanticSessionId` is gated by the SAME "ending session
88
+ * IS the recorded one" check as `durationMs` above. `current-session.json` is
89
+ * a single repo-global file: it always reflects whichever session most
90
+ * recently ran SessionStart, which may be a DIFFERENT, still-live session
91
+ * when multiple windows share this repo. Before this fix, a foreign
92
+ * terminating window (its own stdin `session_id` explicitly present, but NOT
93
+ * equal to the recorded session) still inherited the CURRENTLY-recorded
94
+ * session's `semantic_session_id` unconditionally — main()'s `ownBySemantic`
95
+ * check then matched that OTHER, still-running session's lock and released
96
+ * it. Gating this field closes that window: an unrelated/mismatched ending
97
+ * session now resolves `semanticSessionId: null`, so `ownBySemantic` can
98
+ * never accidentally fire on someone else's identity.
99
+ *
80
100
  * @param {object|null} input
81
101
  * @param {string} projectRoot
82
102
  * @returns {Promise<{sessionId: string|null, semanticSessionId: string|null, durationMs: number}>}
@@ -109,12 +129,18 @@ async function resolveSession(input, projectRoot) {
109
129
  if (sessionId === null) sessionId = recordedId;
110
130
 
111
131
  // Only trust the recorded start time when the ending session IS the recorded one.
112
- const durationMs =
113
- startedAtMs !== null && sessionId !== null && sessionId === recordedId
114
- ? Math.max(0, Date.now() - startedAtMs)
115
- : 0;
132
+ const isRecordedSession = sessionId !== null && sessionId === recordedId;
133
+ const durationMs = startedAtMs !== null && isRecordedSession
134
+ ? Math.max(0, Date.now() - startedAtMs)
135
+ : 0;
136
+
137
+ // #863 defect (c) — same guard as durationMs above: only surface the
138
+ // recorded semantic id when THIS ending session is genuinely the one
139
+ // current-session.json describes. See the docblock above for the exact
140
+ // contamination scenario this closes.
141
+ const resolvedSemanticSessionId = isRecordedSession ? semanticSessionId : null;
116
142
 
117
- return { sessionId, semanticSessionId, durationMs };
143
+ return { sessionId, semanticSessionId: resolvedSemanticSessionId, durationMs };
118
144
  }
119
145
 
120
146
  // ---------------------------------------------------------------------------
@@ -173,14 +199,93 @@ async function main() {
173
199
  // reapRepoLock(), which independently NEVER reaps a live lease (and only
174
200
  // ever reaps on this same host with a dead recorded PID).
175
201
  try {
176
- const lock = readLock({ repoRoot: projectRoot });
177
- if (lock) {
202
+ // readLockDetailed (additive, see its JSDoc in session-lock.mjs) replaces
203
+ // readLock() here so an unreadable/corrupt lock file is DISTINGUISHABLE
204
+ // from a genuinely absent one, instead of both collapsing to the same
205
+ // `null`. Only 'absent' is treated as "nothing to release/reconcile" —
206
+ // 'unreadable'/'corrupt' is an anomaly worth a breadcrumb, since a lock we
207
+ // cannot even parse is neither released nor reconciled below, and used to
208
+ // silently look identical to "no lock, all clear".
209
+ const lockDetail = readLockDetailed({ repoRoot: projectRoot });
210
+
211
+ if (lockDetail.status === 'unreadable' || lockDetail.status === 'corrupt') {
212
+ try {
213
+ await emitEvent('orchestrator.session.lock.read_anomaly', {
214
+ session_id: sessionId,
215
+ status: lockDetail.status,
216
+ ...(lockDetail.status === 'unreadable' ? { error: lockDetail.error } : {}),
217
+ });
218
+ } catch { /* observability is best-effort */ }
219
+ } else if (lockDetail.status === 'ok') {
220
+ const lock = lockDetail.lock;
178
221
  const ownByUuid = sessionId !== null && lock.session_id === sessionId;
179
- const ownBySemantic =
222
+ const ownBySemanticStrict =
180
223
  semanticSessionId !== null && lock.semantic_session_id === semanticSessionId;
224
+ // #863 (d) — lock-shape trap: some on-disk locks store the semantic id
225
+ // directly in `session_id` with no separate `semantic_session_id` field
226
+ // at all (the "generated-semantic" acquisition path in
227
+ // on-session-start.mjs mints `session_id === the semantic id`, and
228
+ // bootstrapLock's v2 enrichment step never ran for that lock). Without
229
+ // this fallback, ownBySemanticStrict is dead code for that shape.
230
+ const ownBySemanticFallback =
231
+ semanticSessionId !== null && lock.session_id === semanticSessionId;
232
+ const ownBySemantic = ownBySemanticStrict || ownBySemanticFallback;
181
233
 
182
- if (ownByUuid || ownBySemantic) {
183
- const releaseResult = release({ sessionId: lock.session_id, repoRoot: projectRoot });
234
+ // #987 Part 2 — persisted ownership proof (pid + host + started_at,
235
+ // written at lock genesis by bootstrapLock Step 2b, see
236
+ // writeOwnerProof() in session-lock.mjs). loadOwnerProof() is
237
+ // fail-closed: a missing/corrupt proof file yields null, and a null
238
+ // proof presented to isLockOwnedByProof() yields false — the hook then
239
+ // degrades to exactly the pre-#987 (proof-less) behaviour below.
240
+ const proof = loadOwnerProof({ repoRoot: projectRoot });
241
+ const ownByProof = isLockOwnedByProof(lock, proof);
242
+
243
+ // #906-class fix: the semantic session id
244
+ // (`<branch>-<date>-<mode>-<n>`) is NOT globally unique — the
245
+ // id-counter can hand out the SAME id to two different session
246
+ // processes (live-observed twice on 2026-07-29: `main-2026-07-29-deep-1`
247
+ // and `main-2026-07-29-session-1`, each assigned to two distinct
248
+ // sessions). A live lock matched ONLY via a collidable semantic id
249
+ // (strict OR fallback, never corroborated by a non-collidable factor)
250
+ // is therefore NEVER trusted for release.
251
+ //
252
+ // #987 Part 2 — the discrimination the #906-class fix escalated as
253
+ // "structurally unobservable" is now OBSERVABLE via the persisted
254
+ // owner proof: a genuine same-session UUID rotation (clear/compact)
255
+ // and a foreign same-day semantic collision used to produce the
256
+ // IDENTICAL shape here (UUID mismatch + semantic match on a live
257
+ // lock), so the previous fix conservatively left BOTH un-released
258
+ // (bounded by the ttl_hours reaper). The proof written at lock genesis
259
+ // (pid + host + started_at — presence-at-genesis, never the collidable
260
+ // session_id/semantic id, see the FACTOR CHOICE docblock on
261
+ // isLockOwnedByProof()) is exactly the discriminator that RCR-007
262
+ // escalation named: on a self-rotation the on-disk lock is FROZEN at
263
+ // its pre-rotation values (bootstrapLock's shouldForce is false for a
264
+ // rotated UUID, so it bails without touching the lock), which are the
265
+ // SAME values the proof captured at genesis → ownByProof is true → the
266
+ // lock is released correctly. A foreign same-day collision wrote its
267
+ // lock in a DIFFERENT process at a DIFFERENT millisecond → ownByProof
268
+ // is false → semanticOnlyLive stays true → no release, the foreign
269
+ // lease survives (the never-release-a-foreign-lock invariant is
270
+ // untouched). No proof on disk (pre-#987 sessions, failed proof
271
+ // write) → ownByProof false → the conservative pre-#987 behaviour.
272
+ const semanticOnlyLive = !ownByUuid && !ownByProof && ownBySemantic && isLockLive(lock);
273
+ const releaseEligible = (ownByUuid || ownBySemantic) && !semanticOnlyLive;
274
+
275
+ if (releaseEligible) {
276
+ // Defense-in-depth (#987): when a persisted proof exists, hand it to
277
+ // release() so the delete is double-gated (session_id match AND
278
+ // proof match) at the fs layer too. TRAP — release()'s proof gate
279
+ // triggers on `proof !== undefined`, so a null proof MUST be
280
+ // spread-guarded out: passing `proof: null` would fail
281
+ // isLockOwnedByProof() unconditionally and refuse EVERY release.
282
+ // A 'proof-mismatch' result flows into the existing release_failed
283
+ // breadcrumb below (releaseResult.reason surfaces verbatim).
284
+ const releaseResult = release({
285
+ sessionId: lock.session_id,
286
+ repoRoot: projectRoot,
287
+ ...(proof ? { proof } : {}),
288
+ });
184
289
  // release() has a no-throw contract (always returns a structured
185
290
  // result). A matched ownership that still fails to delete — an
186
291
  // fs-error, or an unexpected non-delete outcome other than the benign
@@ -194,20 +299,75 @@ async function main() {
194
299
  reason: releaseResult.ok
195
300
  ? (releaseResult.reason ?? 'not-deleted')
196
301
  : (releaseResult.reason ?? 'fs-error'),
302
+ caller: 'on-session-end',
197
303
  });
198
304
  } catch { /* observability is best-effort */ }
305
+ } else {
306
+ // #952 (A) — SUCCESS breadcrumb. Until now ONLY the failure path
307
+ // emitted: a release that actually worked left zero trace, so
308
+ // "the hook-lock vanished somewhere in the start chain" (#914
309
+ // residual 3) was forensically undecidable — an absent lock could
310
+ // equally mean "we released it cleanly at SessionEnd" or "something
311
+ // else deleted it". Both REACHED outcomes now emit, distinguished by
312
+ // `outcome`:
313
+ // 'deleted' — we unlinked it ourselves (the normal close).
314
+ // 'already-gone' — ownership matched, but the lock had ALREADY
315
+ // vanished between readLock() and release()
316
+ // (release() reason 'no-lock'). This is the
317
+ // forensically INTERESTING one: a third party
318
+ // removed our lock while we still held it, which
319
+ // IS the #914 disappearance class. Staying silent
320
+ // on it would leave exactly that signal unobservable.
321
+ //
322
+ // `end_reason` (not `reason`) deliberately: the sibling
323
+ // `…lock.release_failed` event above uses `reason` for the FAILURE
324
+ // reason, so reusing the key for the SessionEnd reason
325
+ // (clear|logout|prompt_input_exit|other) inside the same
326
+ // `orchestrator.session.lock.*` namespace would make any consumer
327
+ // roll-up over `.reason` mix "clear" with "fs-error".
328
+ //
329
+ // Emitted at the CALL-SITE, never inside release(): session-lock.mjs
330
+ // deliberately carries no dependency on events.mjs, and release() is
331
+ // synchronous while emitEvent() is async. `caller` keeps the two
332
+ // release call-sites (this hook and the autopilot worktree pipeline)
333
+ // distinguishable in the single stream.
334
+ try {
335
+ await emitEvent('orchestrator.session.lock.released', {
336
+ session_id: sessionId,
337
+ lock_session_id: lock.session_id,
338
+ semantic_session_id: semanticSessionId,
339
+ end_reason: reason,
340
+ caller: 'on-session-end',
341
+ outcome: benignAlreadyGone ? 'already-gone' : 'deleted',
342
+ verified: releaseResult.verified === true,
343
+ });
344
+ } catch { /* observability is best-effort */ }
345
+
346
+ // #987 hygiene — the successful own release consumed the genesis
347
+ // proof; remove it best-effort. A leftover would be harmless (its
348
+ // millisecond started_at cannot match any FUTURE lock, so it
349
+ // self-invalidates), this just avoids the stale artifact. ENOENT
350
+ // (no proof was ever written) lands in the same swallow.
351
+ try {
352
+ await fs.unlink(path.join(projectRoot, OWNER_PROOF_RELPATH));
353
+ } catch { /* best-effort — a leftover proof is self-invalidating */ }
199
354
  }
200
355
  } else {
201
- // Root-cause reconciliation fallback: neither the UUID nor the
202
- // semantic id matched the recorded lock. attemptLockReconciliation()
203
- // is the extracted, DI-testable seam (Issue #748) it internally
204
- // no-ops when the lease is still live (isLockLive), and is otherwise
205
- // best-effort: reapRepoLock() never touches a live lease, a
206
- // cross-host lease, or a lease whose recorded PID is still alive on
207
- // this host.
356
+ // Root-cause reconciliation fallback: either NEITHER the UUID nor the
357
+ // semantic id matched the recorded lock, OR the ONLY match was a
358
+ // collidable semantic comparison (strict or fallback) on a lock that
359
+ // is still live (semanticOnlyLive). attemptLockReconciliation() is
360
+ // the extracted, DI-testable seam (Issue #748) it internally
361
+ // no-ops when the lease is still live (isLockLive), which is exactly
362
+ // what makes it safe to route the semanticOnlyLive case here too: it
363
+ // is otherwise best-effort, and reapRepoLock() never touches a live
364
+ // lease, a cross-host lease, or a lease whose recorded PID is still
365
+ // alive on this host.
208
366
  await attemptLockReconciliation({ repoRoot: projectRoot, sessionId, lock });
209
367
  }
210
368
  }
369
+ // 'absent' — no lock file at all; nothing to release or reconcile
370
+ // (mirrors the pre-existing `if (lock)` guard's false branch).
211
371
  } catch { /* best-effort — never block teardown */ }
212
372
  }
213
373
 
@@ -542,6 +542,29 @@ async function main() {
542
542
  });
543
543
  } catch { /* hook must remain non-blocking */ }
544
544
 
545
+ // Epic #926 — close-through backfill at SessionStart (decoupled from /close).
546
+ // hooks/on-session-end.mjs already backfills, but SessionEnd only fires on a
547
+ // REGULAR close: a session killed by Ctrl-C, a timeout or a crash leaves no
548
+ // ledger entry, and the backfill then waits for the NEXT clean close. Running
549
+ // it here reconstructs the PREVIOUS abandoned session whatever killed it.
550
+ //
551
+ // ORDERING IS LOAD-BEARING — this MUST stay before the
552
+ // `orchestrator.session.started` emit at the end of main(). That ordering is
553
+ // this session's structural self-exclusion: our own started-event is not in
554
+ // events.jsonl yet, so we are not a backfill candidate at all. (On a
555
+ // clear/compact/resume re-fire an earlier started-event IS present; that case
556
+ // is caught by the core's own `skipped-own-live-lock` guard against the lock
557
+ // bootstrapped above.) A live FOREIGN session is likewise protected: the core
558
+ // evaluates lock ownership against the CANDIDATE, so a candidate holding a
559
+ // live lock is skipped before the dead-by-age relaxation is consulted.
560
+ //
561
+ // Best-effort like every other probe here: any failure is swallowed so a
562
+ // backfill problem can never block a session start.
563
+ try {
564
+ const { backfillOnSessionStart } = await import('../scripts/backfill-abandoned-sessions.mjs');
565
+ await backfillOnSessionStart({ repoRoot: projectRoot });
566
+ } catch { /* hook must remain non-blocking */ }
567
+
545
568
  let peers = [];
546
569
  try {
547
570
  await sweepZombies().catch(() => ({ removed: [], logged: 0 }));