session-orchestrator 5.3.0 → 5.5.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 (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -86,6 +86,45 @@ export {
86
86
  export const DEFAULT_TTL_HOURS = 4;
87
87
  export const LOCK_PATH = '.orchestrator/session.lock';
88
88
 
89
+ /**
90
+ * How far in the future a `last_heartbeat` may lie and still count as live
91
+ * (#1494). Every heartbeat is stamped by `Date.now()` on the writing host, so
92
+ * on one host it is never ahead of the clock unless that clock was stepped
93
+ * backwards (NTP at boot, a manual change) — the tolerance absorbs such a step
94
+ * and cross-host skew on a shared checkout. Ceiling (BV-004): a clock stepped
95
+ * back by more than 5 minutes makes every live lock written just before it read
96
+ * as stale, and the next SessionStart reclaims it. Revisit if a `reclaimed`
97
+ * event follows a clock step on a host whose previous session was still running.
98
+ *
99
+ * Exported as the ONE copy of this tolerance (#1505.1/.2): the session registry
100
+ * (`session-registry.mjs` `_ageMinutes`) and the lock reaper (`lock-reaper.mjs`
101
+ * `ageHoursOf`, `currentSessionAgeHours`) apply the same future-stamp rule.
102
+ */
103
+ export const FUTURE_HEARTBEAT_TOLERANCE_MS = 5 * 60 * 1000;
104
+
105
+ /**
106
+ * The lock manager's own transient files beside `session.lock` (#1505.3): the
107
+ * `writeLockAtomic()` tmp (6 random bytes), the `createSessionLockExclusive()`
108
+ * tmp and the `reclaimIfNotLive()` tombstone (8 random bytes each). A process
109
+ * SIGKILLed between creating one and its cleanup leaves it behind for good.
110
+ * Anchored and exact so `session.lock` itself — or any foreign file — can
111
+ * never match.
112
+ */
113
+ const LOCK_LEFTOVER_RE = /^\.session\.lock\.(?:tmp\.[0-9a-f]{12}|create\.tmp\.[0-9a-f]{16}|reclaim\.[0-9a-f]{16})$/;
114
+
115
+ /**
116
+ * Minimum age before {@link sweepLockLeftovers} removes a leftover. Every one
117
+ * of them lives inside ONE synchronous call (write → rename/link → unlink),
118
+ * milliseconds long. Ceiling (BV-004): the age is taken from the later of
119
+ * mtime and ctime, because a tombstone is a RENAMED lock whose mtime is the
120
+ * old lock's last write; rename updates ctime (measured 2026-10-03 on APFS;
121
+ * ext4/xfs do too). On a filesystem whose rename touches neither, a tombstone
122
+ * could look old mid-reclaim — the tombstone content check below still keeps
123
+ * every live one. Revisit if a `reclaimed` event is followed by a lost
124
+ * link-back on such a filesystem.
125
+ */
126
+ export const LOCK_LEFTOVER_MIN_AGE_MS = 60 * 60 * 1000;
127
+
89
128
  /**
90
129
  * Where the durable lock-ownership proof lives, relative to the repo root
91
130
  * (#987 Part 1). `.orchestrator/runtime/` is machine-local, gitignored state —
@@ -156,17 +195,18 @@ function lockAgeHours(lock) {
156
195
  * liveness rule thresholds against, surfaced as a number so callers (the
157
196
  * Phase-1.2 stale-lock AUQ, recovery diagnostics) can report WHY a lock was
158
197
  * classified stale instead of asserting a PID verdict the lock cannot support
159
- * (#1137). Mirrors `isLockLive()`'s `last_heartbeat` → `started_at` fallback.
198
+ * (#1137). Reads `last_heartbeat` only, like `isLockLive()`: a lock without one
199
+ * has no heartbeat age, so this returns `null` rather than an age invented from
200
+ * `started_at` (#595, 2026-10-02 — the v1 fallback is gone).
160
201
  *
161
- * @param {{ last_heartbeat?: string, started_at?: string }} lock
162
- * @returns {number|null} minutes since the last heartbeat, or null if unparseable.
202
+ * @param {{ last_heartbeat?: string }} lock
203
+ * @returns {number|null} minutes since the last heartbeat, or null when the lock
204
+ * carries no parseable `last_heartbeat`.
163
205
  */
164
206
  function heartbeatAgeMinutes(lock) {
165
207
  if (!lock || typeof lock !== 'object') return null;
166
- const hbStr = (typeof lock.last_heartbeat === 'string' && lock.last_heartbeat.length > 0)
167
- ? lock.last_heartbeat
168
- : lock.started_at;
169
- const ts = Date.parse(hbStr);
208
+ if (typeof lock.last_heartbeat !== 'string' || lock.last_heartbeat.length === 0) return null;
209
+ const ts = Date.parse(lock.last_heartbeat);
170
210
  if (Number.isNaN(ts)) return null;
171
211
  return (Date.now() - ts) / (60 * 1000);
172
212
  }
@@ -179,20 +219,19 @@ function heartbeatAgeMinutes(lock) {
179
219
  * `session_id` field carries a UUID and the caller wants to preserve the
180
220
  * always-semantic id alongside it).
181
221
  *
182
- * Back-compat: v1 locks (no `last_heartbeat`) are normalised on read with
183
- * `last_heartbeat = started_at`. The optional `semantic_session_id` field is
184
- * left undefined when absent. This lets pre-#583 lockfiles flow through the
185
- * new liveness rule transparently — the v1 lock's `started_at` becomes its
186
- * effective heartbeat, so TTL freshness still rescues recent locks even when
187
- * the writer process is dead (the D2/D5 production case).
188
- *
189
- * RETAINED, not forgotten (#595, re-verified 2026-08-15): zero v1 files exist
190
- * on this host, but this normalisation is MIRRORED in
191
- * `scripts/lib/harness-audit/categories/category4.mjs` `lockIsLive()` and
192
- * pinned by `tests/lib/lock-ttl-parity.test.mjs`. Removing it here alone
193
- * breaks that parity by construction — see
194
- * `skills/_shared/state-ownership.md` § Schema v1 Sunset for the full
195
- * co-change set the removal needs.
222
+ * A lock WITHOUT `last_heartbeat` (#595 sunset, 2026-10-02) is returned as-is:
223
+ * its heartbeat is no longer filled in from `started_at`, and it is deliberately not
224
+ * rejected either. It stays VISIBLE — `readLockDetailed()` reports `ok` and
225
+ * `acquire()` classifies it `stale-heartbeat` (heartbeat age `null`), which
226
+ * lets the SessionStart bootstrap reclaim it — while `isLockLive()` reads it
227
+ * as not live. Rejecting it here would make it `corrupt`: still reclaimable
228
+ * (`acquire()` reports `corrupt` since 2026-10-02 — until then the
229
+ * create-or-fail hit EEXIST and the vanished-race branch answered `active`
230
+ * with `existingLock: null` on every attempt, wedging the repo), but invisible
231
+ * to every `readLock()` caller, which would lose its session_id, host and
232
+ * started_at. No writer has produced such a lock since Epic #583:
233
+ * `buildLock()`, the hook bootstrap and `updateHeartbeat()` all set
234
+ * `last_heartbeat`.
196
235
  *
197
236
  * @param {string} raw
198
237
  * @returns {object|null}
@@ -203,18 +242,9 @@ function parseLock(raw) {
203
242
  // The six-field predicate lives in ONE place (#1153 P7) — see
204
243
  // `./session-lock-shape.mjs` for the other consumer and the fail-open
205
244
  // drift this sharing prevents.
206
- if (isLockShape(obj)) {
207
- // Schema v1 → v2 normalisation: when `last_heartbeat` is absent or
208
- // non-string, treat the lock as if it heartbeat-ed once at started_at.
209
- const normalised = { ...obj };
210
- if (typeof normalised.last_heartbeat !== 'string' || normalised.last_heartbeat.length === 0) {
211
- normalised.last_heartbeat = normalised.started_at;
212
- }
213
- // semantic_session_id stays undefined when absent — callers that need it
214
- // should fall back to session_id.
215
- return normalised;
216
- }
217
- return null;
245
+ // semantic_session_id stays undefined when absent — callers that need it
246
+ // should fall back to session_id.
247
+ return isLockShape(obj) ? obj : null;
218
248
  } catch {
219
249
  return null;
220
250
  }
@@ -241,14 +271,21 @@ function buildLock({ sessionId, mode, ttlHours, semanticSessionId }) {
241
271
  session_id: sessionId,
242
272
  started_at: startedAt,
243
273
  last_heartbeat: startedAt,
244
- mode,
274
+ // mode and ttl_hours are coerced here because acquire()/forceAcquire() take
275
+ // them from hand-written callers (dispatcher claimRepo, the phase-1.2 prose)
276
+ // unchecked: `mode: undefined` vanished from the JSON and `ttlHours: null`
277
+ // or "4" had the wrong type, so the lock failed isLockShape() — and a lock
278
+ // that fails the shape is reclaimed as 'corrupt' by the next session even
279
+ // while its heartbeat is fresh. 'unknown' classifies like any unknown mode
280
+ // (parallel-ok, see the acquire() wrapper).
281
+ mode: typeof mode === 'string' && mode.trim().length > 0 ? mode : 'unknown',
245
282
  pid: process.pid,
246
283
  // `host` stays the RAW hostname — it is an on-the-wire event field
247
284
  // (orchestrator.session.lock.acquired) and feeds the privacy-hash contract.
248
285
  // `host_id` is the additive normalised twin every comparison reads (#1072).
249
286
  host: os.hostname(),
250
287
  host_id: stableHostname(),
251
- ttl_hours: ttlHours,
288
+ ttl_hours: Number.isFinite(ttlHours) && ttlHours > 0 ? ttlHours : DEFAULT_TTL_HOURS,
252
289
  };
253
290
  if (typeof semanticSessionId === 'string' && semanticSessionId.length > 0) {
254
291
  lock.semantic_session_id = semanticSessionId;
@@ -266,24 +303,32 @@ function buildLock({ sessionId, mode, ttlHours, semanticSessionId }) {
266
303
  *
267
304
  * Liveness rule: a lock is live when (now - last_heartbeat) < ttl_hours.
268
305
  *
269
- * The `started_at` fallback below is RETAINED, not forgotten (#595,
270
- * re-verified 2026-08-15): it is mirrored verbatim in
271
- * `scripts/lib/harness-audit/categories/category4.mjs` `lockIsLive()` and
272
- * pinned by `tests/lib/lock-ttl-parity.test.mjs`. See
273
- * `skills/_shared/state-ownership.md` § Schema v1 Sunset.
306
+ * No `started_at` fallback (#595 sunset, 2026-10-02): a lock with no
307
+ * `last_heartbeat` is NOT live — nothing is heartbeating it, so it must not
308
+ * hold the repo for a TTL window counted from its start. The rule is mirrored
309
+ * in `scripts/lib/harness-audit/categories/category4.mjs` `lockIsLive()`, and
310
+ * `tests/lib/lock-ttl-parity.test.mjs` holds the two to identical verdicts.
311
+ * See `skills/_shared/state-ownership.md` § Schema v1 Sunset.
312
+ *
313
+ * A heartbeat stamped more than {@link FUTURE_HEARTBEAT_TOLERANCE_MS} in the
314
+ * future is NOT live either (#1494): `now - heartbeat` went negative, stayed
315
+ * below every TTL, and such a lock — a hand edit, a clock stepped backwards —
316
+ * held the repo until the wall clock caught up, a year for a year-ahead stamp.
317
+ * Same rule as the orphan-reaper's throttle marker (#1487,
318
+ * `orphan-reaper/scan-throttle.mjs` `shouldScanNow`), plus a tolerance: a
319
+ * marker in the future only costs one extra scan, a lock misread as dead is
320
+ * taken over while its owner still runs.
274
321
  *
275
- * @param {{ last_heartbeat: string, started_at: string, ttl_hours?: number }} lock
322
+ * @param {{ last_heartbeat: string, ttl_hours?: number }} lock
276
323
  * @param {number} [nowMs]
277
324
  * @returns {boolean}
278
325
  */
279
326
  export function isLockLive(lock, nowMs = Date.now()) {
280
327
  if (!lock || typeof lock !== 'object') return false;
281
- // Back-compat: prefer last_heartbeat; fall back to started_at when absent.
282
- const hbStr = (typeof lock.last_heartbeat === 'string' && lock.last_heartbeat.length > 0)
283
- ? lock.last_heartbeat
284
- : lock.started_at;
285
- const heartbeatMs = Date.parse(hbStr);
328
+ if (typeof lock.last_heartbeat !== 'string' || lock.last_heartbeat.length === 0) return false;
329
+ const heartbeatMs = Date.parse(lock.last_heartbeat);
286
330
  if (Number.isNaN(heartbeatMs)) return false;
331
+ if (heartbeatMs > nowMs + FUTURE_HEARTBEAT_TOLERANCE_MS) return false;
287
332
  const ttlHours = typeof lock.ttl_hours === 'number' ? lock.ttl_hours : DEFAULT_TTL_HOURS;
288
333
  const ttlMs = ttlHours * 3600 * 1000;
289
334
  return (nowMs - heartbeatMs) < ttlMs;
@@ -328,9 +373,12 @@ function writeLockAtomic(lockFile, lock) {
328
373
  * the file-lock primitive (tryAcquireFileLock) backing the state-lock and
329
374
  * staging-fence-lock modules.
330
375
  *
331
- * Used ONLY by the no-existing-lock branch of {@link acquire}. The
332
- * intentional-overwrite paths (`forceAcquire`, `updateHeartbeat`) keep using
333
- * `writeLockAtomic` because they MUST replace an existing lock, not fail on it.
376
+ * Used by the no-existing-lock branch of {@link acquire} and by the
377
+ * single-winner reclaim (`forceAcquire({ onlyIfNotLive: true })` →
378
+ * `reclaimIfNotLive()`), which moves the old lock aside first and then needs
379
+ * exactly this create-or-fail. The unconditional overwrite paths (a plain
380
+ * `forceAcquire`, `updateHeartbeat`) keep using `writeLockAtomic` because they
381
+ * MUST replace an existing lock, not fail on it.
334
382
  *
335
383
  * @param {string} lockFile Absolute path to .orchestrator/session.lock.
336
384
  * @param {object} lock Lock object to serialize.
@@ -481,8 +529,11 @@ export function readLockDetailed(opts = {}) {
481
529
  * Returns one of:
482
530
  * { ok: true, lock, exclusivityClass? }
483
531
  * — lock created
484
- * { ok: false, reason: 'active', existingLock, exclusivityClass? }
485
- * — local lock held (live TTL, live PID)
532
+ * { ok: false, reason: 'active', existingLock, ageHours, exclusivityClass? }
533
+ * — local lock held: its last_heartbeat is within ttl_hours. `ageHours`
534
+ * is the age from `started_at` (null when unparseable). `existingLock`
535
+ * is null, `ageHours` null, only when a lost create race found the
536
+ * winner's lock already gone.
486
537
  * { ok: false, reason: 'stale-heartbeat', existingLock, ageHours, heartbeatAgeMinutes, exclusivityClass? }
487
538
  * — local lock stale: its last_heartbeat is older than ttl_hours. This is
488
539
  * the ONLY stale reason (#1137). It replaced the `stale-pid-dead` /
@@ -492,6 +543,18 @@ export function readLockDetailed(opts = {}) {
492
543
  * recorded pids dead, INCLUDING the currently heartbeating session's
493
544
  * own lock), so `stale-pid-alive` was unreachable same-host and every
494
545
  * stale lock rendered as "confirmed dead" in the recovery AUQ.
546
+ * { ok: false, reason: 'corrupt', existingLock: null, exclusivityClass? }
547
+ * — a lock file is present but fails parseLock() (invalid JSON, or not
548
+ * the six-field lock shape). It can never be a live lock — every
549
+ * writer writes atomically — so the SessionStart bootstrap reclaims it
550
+ * with one stderr WARN. Before 2026-10-02 this case answered 'active'
551
+ * with `existingLock: null` on every attempt and wedged the repo.
552
+ * { ok: false, reason: 'unreadable', existingLock: null, error, exclusivityClass? }
553
+ * — the lock path exists but cannot be read (EACCES, or a directory at
554
+ * the path: EISDIR). Nothing was written and the path is left as it
555
+ * is; the SessionStart bootstrap reports it (stderr WARN + a
556
+ * `orchestrator.session.lock.read_anomaly` event) instead of reclaiming
557
+ * it. Until #1494 this answered 'active' / existingLock:null silently.
495
558
  * { ok: false, reason: 'missing-session-id' }
496
559
  * — no usable `sessionId` given; NOTHING was written. Same reason string
497
560
  * and same predicate as forceAcquire() (see its docblock for why an
@@ -536,6 +599,10 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
536
599
  return { ok: false, reason: 'missing-session-id' };
537
600
  }
538
601
 
602
+ // #1505.3: remove what a SIGKILLed writer or reclaimer left beside the lock.
603
+ // Result-neutral and best-effort: it never reads or touches `session.lock`.
604
+ try { sweepLockLeftovers({ repoRoot }); } catch { /* never blocks acquire */ }
605
+
539
606
  const lockFile = lockPathFor(repoRoot);
540
607
 
541
608
  // -------------------------------------------------------------------------
@@ -634,7 +701,16 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
634
701
  // produced the #744 incident: a live heartbeating session was
635
702
  // misclassified 'stale-pid-dead' mid-wave.
636
703
  if (isLockLive(existing)) {
637
- return { ok: false, reason: 'active', existingLock: existing, exclusivityClass: callerClass };
704
+ // ageHours is the figure the Phase-1.2 "active" prompt renders
705
+ // ("started <ageHours>h ago"); it was missing from this shape, so the
706
+ // prompt printed a value nothing had computed (#1494).
707
+ return {
708
+ ok: false,
709
+ reason: 'active',
710
+ existingLock: existing,
711
+ ageHours: lockAgeHours(existing),
712
+ exclusivityClass: callerClass,
713
+ };
638
714
  }
639
715
 
640
716
  // Heartbeat expired — ONE stale reason, derived from the same signal the
@@ -657,12 +733,42 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
657
733
  };
658
734
  };
659
735
 
660
- const existing = readLock({ repoRoot });
661
-
662
- if (existing !== null) {
736
+ // A file that is present but fails parseLock() — invalid JSON, or JSON
737
+ // without the six-field lock shape — gets its OWN reason instead of
738
+ // collapsing into the vanished-race branch below. It used to: readLock()
739
+ // returned null, the create-or-fail hit EEXIST on the file still there,
740
+ // the re-read was null again, and every attempt answered
741
+ // `active` / `existingLock: null` — which the bootstrap cannot reclaim, so
742
+ // one damaged file blocked every new session in the repo for good.
743
+ // Such a file can never be a live lock: all four production writers
744
+ // (createSessionLockExclusive, writeLockAtomic via forceAcquire and
745
+ // updateHeartbeat, the bootstrap's writeJsonAtomicSync) write a full
746
+ // buildLock() body via tmp+link / tmp+rename, so no reader ever sees a
747
+ // partial write. Reported, not cleared — the caller decides (see the
748
+ // decision-deferred note in the module header).
749
+ // Ceiling (BV-004): a future writer emitting a body that fails
750
+ // isLockShape() would be classified corrupt too. Revisit if the lock
751
+ // schema ever changes a required field's type or name.
752
+ const corrupt = () => ({ ok: false, reason: 'corrupt', existingLock: null, exclusivityClass: callerClass });
753
+
754
+ // A lock path that exists but cannot be read — EACCES, or a DIRECTORY at
755
+ // the path (EISDIR) — gets its own reason too (#1494, #599). It used to
756
+ // fall through to the create-or-fail, hit EEXIST and answer
757
+ // 'active' / existingLock:null, which nobody reports: the bootstrap
758
+ // neither forced nor warned, and every session ran without a lock, with
759
+ // no trace. Never reclaimed — what sits there was not written by a lock
760
+ // writer, and a directory may hold content nobody here created (PSA-003).
761
+ const unreadable = (error) => ({ ok: false, reason: 'unreadable', existingLock: null, error, exclusivityClass: callerClass });
762
+
763
+ const existing = readLockDetailed({ repoRoot });
764
+
765
+ if (existing.status === 'ok') {
663
766
  // A lock is present — classify it.
664
- return classifyExisting(existing);
767
+ return classifyExisting(existing.lock);
665
768
  }
769
+ if (existing.status === 'corrupt') return corrupt();
770
+ if (existing.status === 'unreadable') return unreadable(existing.error);
771
+ // 'absent' → create below.
666
772
 
667
773
  // No existing lock — create one with a TOCTOU-safe create-or-fail (#590).
668
774
  // Two concurrent SessionStart hooks can both reach this branch having each
@@ -679,15 +785,15 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
679
785
 
680
786
  // reason === 'exists' — we lost the create race. Re-read the now-present
681
787
  // lock and classify it exactly as if we had seen it on the up-front check.
682
- const raced = readLock({ repoRoot });
683
- if (raced === null) {
684
- // The EEXIST winner's lock vanished before we could re-read it (ENOENT /
685
- // unparseable). Defensive fallback: report 'active' so the caller defers
686
- // rather than racing again — mirrors tryAcquireStateLock's vanish-race
687
- // handling (a lost-then-vanished race resolves conservatively).
688
- return { ok: false, reason: 'active', existingLock: null, exclusivityClass: callerClass };
689
- }
690
- return classifyExisting(raced);
788
+ const raced = readLockDetailed({ repoRoot });
789
+ if (raced.status === 'ok') return classifyExisting(raced.lock);
790
+ if (raced.status === 'corrupt') return corrupt();
791
+ if (raced.status === 'unreadable') return unreadable(raced.error);
792
+ // The EEXIST winner's lock vanished before we could re-read it (ENOENT).
793
+ // Defensive fallback: report 'active' so the caller defers rather than
794
+ // racing again — mirrors tryAcquireStateLock's vanish-race handling (a
795
+ // lost-then-vanished race resolves conservatively).
796
+ return { ok: false, reason: 'active', existingLock: null, ageHours: null, exclusivityClass: callerClass };
691
797
  } catch (err) {
692
798
  return { ok: false, reason: 'fs-error', error: err.message, exclusivityClass: callerClass };
693
799
  }
@@ -718,7 +824,14 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
718
824
  * no information while breaking the direct `forceAcquire()` calls documented in
719
825
  * `skills/session-start/references/phase-1-2-session-lock.md`.
720
826
  *
721
- * @param {{ sessionId: string, mode: string, ttlHours?: number, repoRoot?: string, semanticSessionId?: string }} args
827
+ * `onlyIfNotLive: true` (#1494) turns the unconditional overwrite into a
828
+ * single-winner takeover of a stale or corrupt lock: a lock that is live when
829
+ * it is moved aside is put back, and every lost race returns a fresh
830
+ * `acquire()` result instead of `ok: true` — see `reclaimIfNotLive()`. The
831
+ * SessionStart bootstrap passes it for its `stale-heartbeat` / `corrupt`
832
+ * reclaims; the operator-approved force-take of a LIVE lock (Phase 1.2) omits it.
833
+ *
834
+ * @param {{ sessionId: string, mode: string, ttlHours?: number, repoRoot?: string, semanticSessionId?: string, onlyIfNotLive?: boolean }} args
722
835
  */
723
836
  /**
724
837
  * A `sessionId` is usable only when it is a non-blank string. ONE predicate,
@@ -733,10 +846,13 @@ function hasUsableSessionId(sessionId) {
733
846
  return typeof sessionId === 'string' && sessionId.trim().length > 0;
734
847
  }
735
848
 
736
- export function forceAcquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoot, semanticSessionId } = {}) {
849
+ export function forceAcquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoot, semanticSessionId, onlyIfNotLive = false } = {}) {
737
850
  if (!hasUsableSessionId(sessionId)) {
738
851
  return { ok: false, reason: 'missing-session-id' };
739
852
  }
853
+ if (onlyIfNotLive === true) {
854
+ return reclaimIfNotLive({ sessionId, mode, ttlHours, repoRoot, semanticSessionId });
855
+ }
740
856
  try {
741
857
  const replacedLock = readLock({ repoRoot });
742
858
  const lock = buildLock({ sessionId, mode, ttlHours, semanticSessionId });
@@ -753,6 +869,143 @@ export function forceAcquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, re
753
869
  }
754
870
  }
755
871
 
872
+ /**
873
+ * Single-winner takeover of a stale or corrupt lock — `forceAcquire({
874
+ * onlyIfNotLive: true })` (#1494).
875
+ *
876
+ * The plain force is last-writer-wins: two SessionStarts that both saw the same
877
+ * stale lock both forced it, both got `ok: true`, and the later rename won while
878
+ * the loser kept running as if it held the lock. Here the lock is first renamed
879
+ * to a unique tombstone. A rename moves exactly one directory entry, so only one
880
+ * caller can hold the moved file; it is judged only then, from the tombstone:
881
+ * - not live (stale, heartbeat-less, unparseable) → replaced via the same
882
+ * create-or-fail link as a fresh acquire();
883
+ * - live → a racer finished its takeover between our acquire() read and our
884
+ * rename, and what we moved is ITS lock: linked back, never replaced.
885
+ * Every losing path — the rename finds nothing (ENOENT: a racer moved it
886
+ * first), the create finds a lock (EEXIST), the moved lock was live — ends in
887
+ * ONE fresh `acquire()`, which reads whatever sits at the path now and answers
888
+ * as on a first look (normally `active` with the winner's lock).
889
+ *
890
+ * Ceilings (BV-004), both reachable with TWO SessionStarts:
891
+ * (1) A racer's fresh lock that we move and link back is absent for that
892
+ * window, and any reader in it sees no lock. The reader most likely to land
893
+ * there is the winner's own bootstrap re-reading the lock it just acquired, so
894
+ * `hooks/_lib/lock-bootstrap.mjs` re-reads on ENOENT (5 × 10 ms) instead of
895
+ * giving up the enrichment; a third reader just sees "absent" briefly.
896
+ * (2) A lock that turned LIVE after both starts read it stale — its own owner
897
+ * heartbeated in between — is moved by one start while the other's rename
898
+ * finds nothing and its fresh `acquire()` creates a lock in the empty path. The
899
+ * link-back then fails with EEXIST and the owner's lock is dropped; the owner
900
+ * learns it at its next heartbeat (updateHeartbeat() returns false on the
901
+ * session mismatch). Not a regression: the plain force overwrote that live lock
902
+ * unconditionally. Revisit if a `reclaimed` event pair within one second shows
903
+ * up in events.jsonl again, or a session's heartbeat starts failing right after
904
+ * another start reclaimed its lock.
905
+ *
906
+ * @param {{ sessionId: string, mode: string, ttlHours: number, repoRoot?: string, semanticSessionId?: string }} args
907
+ * @returns {object} forceAcquire()'s `{ ok: true, lock, replacedLock? }` or
908
+ * `fs-error` shape, or — on a lost race — the result of a fresh `acquire()`.
909
+ */
910
+ function reclaimIfNotLive({ sessionId, mode, ttlHours, repoRoot, semanticSessionId }) {
911
+ const lockFile = lockPathFor(repoRoot);
912
+ const reacquire = () => acquire({ sessionId, mode, ttlHours, repoRoot, semanticSessionId, quiet: true });
913
+ const tomb = path.join(path.dirname(lockFile), `.session.lock.reclaim.${crypto.randomBytes(8).toString('hex')}`);
914
+
915
+ try {
916
+ fs.renameSync(lockFile, tomb);
917
+ } catch (err) {
918
+ if (err && err.code === 'ENOENT') return reacquire();
919
+ return { ok: false, reason: 'fs-error', error: err.message };
920
+ }
921
+
922
+ let keepTomb = false;
923
+ try {
924
+ let raw;
925
+ try {
926
+ raw = fs.readFileSync(tomb, 'utf8');
927
+ } catch {
928
+ // Unjudgeable — something unreadable replaced the stale file after
929
+ // acquire() read it. Not ours to drop: put it back (a directory cannot
930
+ // be hard-linked, and renaming a directory never replaces a file); if
931
+ // the path is taken by then, it stays under the tombstone name.
932
+ keepTomb = true;
933
+ try {
934
+ if (fs.lstatSync(tomb).isDirectory()) fs.renameSync(tomb, lockFile);
935
+ else { fs.linkSync(tomb, lockFile); keepTomb = false; }
936
+ } catch { /* stays at the tombstone name */ }
937
+ return reacquire();
938
+ }
939
+
940
+ const moved = parseLock(raw);
941
+ if (moved !== null && isLockLive(moved)) {
942
+ try { fs.linkSync(tomb, lockFile); } catch { /* ceiling (2) above */ }
943
+ return reacquire();
944
+ }
945
+
946
+ const lock = buildLock({ sessionId, mode, ttlHours, semanticSessionId });
947
+ const created = createSessionLockExclusive(lockFile, lock);
948
+ if (created.ok) {
949
+ const result = { ok: true, lock };
950
+ if (moved !== null) result.replacedLock = moved;
951
+ return result;
952
+ }
953
+ if (created.reason === 'exists') return reacquire();
954
+ return created;
955
+ } finally {
956
+ if (!keepTomb) {
957
+ try { fs.unlinkSync(tomb); } catch { /* best-effort */ }
958
+ }
959
+ }
960
+ }
961
+
962
+ /**
963
+ * Remove the lock manager's own leftovers beside `session.lock` (#1505.3) —
964
+ * files named exactly like {@link LOCK_LEFTOVER_RE}, regular files only (lstat:
965
+ * a symlink, directory or anything else is never followed nor removed), last
966
+ * touched more than {@link LOCK_LEFTOVER_MIN_AGE_MS} ago. A tombstone is
967
+ * removed only on the decision its killed reclaimer would have taken itself:
968
+ * readable and not a live lock. An unreadable one is kept, as
969
+ * `reclaimIfNotLive()` keeps it. Unlinking a tombstone removes only that NAME —
970
+ * when it is still a hard link to the primary lock's inode (killed between
971
+ * link-back and unlink), `session.lock` keeps its content.
972
+ *
973
+ * Never throws; a file that vanished or cannot be removed is skipped.
974
+ *
975
+ * @param {{ repoRoot?: string, nowMs?: number }} [opts]
976
+ * @returns {{ removed: string[] }} basenames removed
977
+ */
978
+ export function sweepLockLeftovers({ repoRoot, nowMs = Date.now() } = {}) {
979
+ const removed = [];
980
+ let dir;
981
+ let names;
982
+ try {
983
+ dir = path.dirname(lockPathFor(repoRoot));
984
+ names = fs.readdirSync(dir);
985
+ } catch {
986
+ return { removed };
987
+ }
988
+ for (const name of names) {
989
+ if (!LOCK_LEFTOVER_RE.test(name)) continue;
990
+ const full = path.join(dir, name);
991
+ try {
992
+ const st = fs.lstatSync(full);
993
+ if (!st.isFile()) continue;
994
+ // A future mtime/ctime (clock stepped back) yields a negative age: kept.
995
+ if (!(nowMs - Math.max(st.mtimeMs, st.ctimeMs) > LOCK_LEFTOVER_MIN_AGE_MS)) continue;
996
+ if (name.startsWith('.session.lock.reclaim.')) {
997
+ let raw;
998
+ try { raw = fs.readFileSync(full, 'utf8'); } catch { continue; }
999
+ const moved = parseLock(raw);
1000
+ if (moved !== null && isLockLive(moved, nowMs)) continue;
1001
+ }
1002
+ fs.unlinkSync(full);
1003
+ removed.push(name);
1004
+ } catch { /* vanished or unremovable — best-effort */ }
1005
+ }
1006
+ return { removed };
1007
+ }
1008
+
756
1009
  /**
757
1010
  * Extract an ownership-proof object from a lock — the field-name translation
758
1011
  * layer `isLockOwnedByProof()` expects. Intended usage: a caller that just