session-orchestrator 3.22.0 → 3.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (316) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +446 -0
  91. package/README.md +22 -17
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -2
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/session-reviewer.md +7 -1
  98. package/agents/test-writer.md +2 -1
  99. package/agents/ui-developer.md +2 -1
  100. package/commands/bootstrap.md +2 -2
  101. package/commands/close.md +3 -1
  102. package/commands/go.md +1 -1
  103. package/commands/journey-audit.md +43 -0
  104. package/docs/USER-GUIDE.md +2 -2
  105. package/docs/ci-setup.md +194 -25
  106. package/docs/codex-setup.md +64 -0
  107. package/docs/components.md +7 -7
  108. package/docs/cursor-setup.md +26 -47
  109. package/docs/events-schema.md +120 -10
  110. package/docs/github-mirror-protection.md +197 -0
  111. package/docs/pi-setup.md +2 -0
  112. package/docs/rule-authoring.md +3 -1
  113. package/docs/scope-collision-guard.md +49 -2
  114. package/docs/session-config-reference.md +89 -9
  115. package/docs/session-config-template.md +38 -7
  116. package/docs/telemetry/telemetry-claims.md +11 -10
  117. package/docs/telemetry.md +52 -1
  118. package/hooks/_lib/atomic-json.mjs +111 -0
  119. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  120. package/hooks/_lib/subagent-paths.mjs +143 -0
  121. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  122. package/hooks/cwd-change-restore.mjs +9 -29
  123. package/hooks/enforce-scope.mjs +93 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +2 -2
  128. package/hooks/on-session-end.mjs +486 -19
  129. package/hooks/on-session-start.mjs +263 -12
  130. package/hooks/on-stop.mjs +392 -24
  131. package/hooks/post-bash-write-verify.mjs +104 -4
  132. package/hooks/post-subagent-discovery-validator.mjs +182 -21
  133. package/hooks/post-tool-batch-wave-signal.mjs +165 -42
  134. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  135. package/hooks/pre-bash-issue-budget.mjs +117 -4
  136. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  138. package/hooks/pre-bash-staging-fence.mjs +4 -0
  139. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  140. package/hooks/skill-invocation-telemetry.mjs +21 -10
  141. package/hooks/subagent-telemetry.mjs +11 -26
  142. package/monitors/monitors.json +6 -0
  143. package/package.json +1 -1
  144. package/pi/prompts/journey-audit.md +12 -0
  145. package/rules/_index.md +9 -1
  146. package/rules/always-on/ask-via-tool.md +62 -0
  147. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  148. package/rules/always-on/build-value.md +47 -0
  149. package/rules/always-on/cross-session-messaging.md +59 -0
  150. package/rules/always-on/loop-and-monitor.md +221 -0
  151. package/rules/always-on/parallel-sessions.md +142 -12
  152. package/rules/always-on/receiving-review.md +108 -0
  153. package/rules/always-on/test-value.md +40 -0
  154. package/rules/always-on/verification-before-completion.md +77 -0
  155. package/scripts/archive-closed-prds.mjs +258 -18
  156. package/scripts/autopilot.mjs +31 -12
  157. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  158. package/scripts/backfill-evidence-digest.mjs +376 -0
  159. package/scripts/cursor-install.mjs +89 -48
  160. package/scripts/emit-event.mjs +10 -2
  161. package/scripts/export-hw-learnings.mjs +143 -2
  162. package/scripts/express-path.mjs +299 -0
  163. package/scripts/generate-cursor-adapter.mjs +253 -0
  164. package/scripts/github-protection-audit.mjs +358 -0
  165. package/scripts/lib/auq/parse.mjs +5 -29
  166. package/scripts/lib/auto-dialectic.mjs +68 -0
  167. package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
  168. package/scripts/lib/build-live-signals.mjs +49 -27
  169. package/scripts/lib/ci-status-banner.mjs +158 -11
  170. package/scripts/lib/cold-start-detector.mjs +23 -14
  171. package/scripts/lib/command-blocker.mjs +70 -0
  172. package/scripts/lib/config/block-header.mjs +55 -0
  173. package/scripts/lib/config/discovery-validator.mjs +7 -2
  174. package/scripts/lib/config/health-endpoints.mjs +383 -0
  175. package/scripts/lib/config/reconcile.mjs +79 -4
  176. package/scripts/lib/config/remote-hosts.mjs +233 -0
  177. package/scripts/lib/config/section-extractor.mjs +235 -36
  178. package/scripts/lib/config-schema.mjs +9 -1
  179. package/scripts/lib/config.mjs +87 -8
  180. package/scripts/lib/convergence-monitor.mjs +13 -2
  181. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  182. package/scripts/lib/dispatcher/cli.mjs +2 -2
  183. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  184. package/scripts/lib/events-schema.mjs +48 -0
  185. package/scripts/lib/events.mjs +238 -5
  186. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  187. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  188. package/scripts/lib/express-path.mjs +327 -0
  189. package/scripts/lib/file-lock.mjs +22 -4
  190. package/scripts/lib/gates/gate-full.mjs +81 -8
  191. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  192. package/scripts/lib/git-config-drift.mjs +134 -5
  193. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  194. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  195. package/scripts/lib/host-identity.mjs +247 -2
  196. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  197. package/scripts/lib/issue-budget.mjs +229 -30
  198. package/scripts/lib/learnings/io.mjs +55 -10
  199. package/scripts/lib/learnings/schema.mjs +95 -28
  200. package/scripts/lib/lock-reaper.mjs +7 -1
  201. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  202. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  203. package/scripts/lib/memory-banner.mjs +25 -10
  204. package/scripts/lib/memory-paths.mjs +15 -6
  205. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  206. package/scripts/lib/peer-discovery.mjs +20 -2
  207. package/scripts/lib/platform.mjs +72 -9
  208. package/scripts/lib/plugin-root.mjs +143 -19
  209. package/scripts/lib/project-hygiene.mjs +43 -3
  210. package/scripts/lib/quality-gate.mjs +271 -13
  211. package/scripts/lib/reconcile/emitter.mjs +87 -19
  212. package/scripts/lib/reconcile/engine.mjs +517 -18
  213. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  214. package/scripts/lib/reconcile/renderer.mjs +148 -3
  215. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  216. package/scripts/lib/reconcile/writer.mjs +415 -84
  217. package/scripts/lib/rule-loader.mjs +37 -2
  218. package/scripts/lib/rules-sync.mjs +51 -8
  219. package/scripts/lib/scope-gate.mjs +126 -0
  220. package/scripts/lib/session-close-backfill.mjs +427 -37
  221. package/scripts/lib/session-discovery.mjs +69 -5
  222. package/scripts/lib/session-end/phase-skip.mjs +38 -5
  223. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  224. package/scripts/lib/session-id.mjs +30 -14
  225. package/scripts/lib/session-identity/own-session.mjs +220 -0
  226. package/scripts/lib/session-lock.mjs +85 -30
  227. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  228. package/scripts/lib/session-schema/validator.mjs +40 -0
  229. package/scripts/lib/session-start-probes.mjs +608 -0
  230. package/scripts/lib/session-transition.mjs +277 -0
  231. package/scripts/lib/sessions-canonical.mjs +446 -0
  232. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  233. package/scripts/lib/spiral-carryover.mjs +90 -9
  234. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  235. package/scripts/lib/state-md/mission-status.mjs +350 -52
  236. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  237. package/scripts/lib/state-md.mjs +12 -2
  238. package/scripts/lib/telemetry/schema.mjs +74 -8
  239. package/scripts/lib/telemetry/sync.mjs +91 -16
  240. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  241. package/scripts/lib/validate/check-agents.mjs +66 -0
  242. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  243. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  244. package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
  245. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  246. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  247. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  248. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  249. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  250. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  251. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  252. package/scripts/lib/validate/check-unwired-features.mjs +333 -32
  253. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  254. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  255. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  256. package/scripts/lib/validate/repo-files.mjs +275 -0
  257. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  258. package/scripts/lib/vault-mirror/process.mjs +99 -43
  259. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  260. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  261. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  262. package/scripts/lib/vault-status/board-writer.mjs +381 -141
  263. package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
  264. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  265. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  266. package/scripts/lib/wave-resource-gate.mjs +127 -7
  267. package/scripts/lib/wave-transcript-tail.mjs +889 -0
  268. package/scripts/materialize-wave-scope.mjs +228 -15
  269. package/scripts/mcp-server.sh +11 -2
  270. package/scripts/memory-propose.mjs +132 -8
  271. package/scripts/parse-config.mjs +65 -0
  272. package/scripts/promote-vault-strict.mjs +4 -15
  273. package/scripts/site-numbers.mjs +36 -4
  274. package/scripts/token-audit.sh +9 -2
  275. package/scripts/validate-plugin.mjs +29 -0
  276. package/scripts/validate-wave-scope.mjs +67 -0
  277. package/scripts/vault-consolidate.mjs +3 -11
  278. package/scripts/vault-integration-watcher.mjs +2 -4
  279. package/scripts/vault-mirror.mjs +305 -51
  280. package/skills/_shared/monitor-patterns.md +31 -5
  281. package/skills/_shared/parallel-aware-auq.md +31 -2
  282. package/skills/_shared/parallel-aware-preamble.md +19 -4
  283. package/skills/_shared/platform-tools.md +11 -5
  284. package/skills/_shared/state-ownership.md +29 -2
  285. package/skills/autopilot/SKILL.md +5 -1
  286. package/skills/bootstrap/SKILL.md +3 -3
  287. package/skills/bootstrap/_shared-template.md +18 -10
  288. package/skills/bootstrap/deep-template.md +10 -6
  289. package/skills/bootstrap/fast-template.md +15 -8
  290. package/skills/bootstrap/standard-template.md +10 -6
  291. package/skills/claude-md-drift-check/checker.mjs +39 -11
  292. package/skills/contract-version-bump/SKILL.md +1 -1
  293. package/skills/dispatcher/SKILL.md +1 -1
  294. package/skills/ecosystem-health/SKILL.md +4 -1
  295. package/skills/ecosystem-health/wizard.md +5 -0
  296. package/skills/evolve/SKILL.md +38 -1
  297. package/skills/journey-audit/SKILL.md +270 -0
  298. package/skills/peekaboo-driver/SKILL.md +15 -3
  299. package/skills/persona-panel/SKILL.md +1 -1
  300. package/skills/reconcile/SKILL.md +46 -3
  301. package/skills/remote-offload/SKILL.md +89 -0
  302. package/skills/session-end/SKILL.md +17 -4
  303. package/skills/session-end/metrics-collection.md +7 -4
  304. package/skills/session-end/phase-3-6-tail.md +20 -9
  305. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  306. package/skills/session-plan/SKILL.md +6 -1
  307. package/skills/session-plan/wave-template.md +1 -0
  308. package/skills/session-start/SKILL.md +54 -17
  309. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  310. package/skills/session-start/phase-8-5-express-path.md +77 -12
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +5 -3
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +143 -22
  315. package/templates/_shared/journey-manifest.md +110 -0
  316. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -20,11 +20,15 @@
20
20
  * by the time this probe fires. Gating on "no live lock" would make the
21
21
  * banner structurally silent forever. Instead:
22
22
  *
23
- * - `lastLedgerAt` = `completed_at` of the last PARSEABLE, GENUINE
24
- * (non-backfill-stub) `sessions.jsonl` record
25
- * (scanned from EOF backward, skipping malformed
26
- * lines) see "Backfill-stub self-erasure fix"
27
- * below for the stub-skip logic and its fallback.
23
+ * - `lastLedgerAt` = the NEWEST instant any PARSEABLE `sessions.jsonl`
24
+ * record proves the ledger was alive at — a genuine
25
+ * record contributes its `completed_at` (or, absent
26
+ * one, its `started_at`), a backfill stub
27
+ * contributes ONLY its `started_at`. See
28
+ * "Backfill-stub self-erasure fix" below for why the
29
+ * stub's `completed_at` is excluded, and "#1125 —
30
+ * newest-across-all, not genuine-first" for why the
31
+ * two kinds are maxed rather than ranked.
28
32
  * - `cutoff` = the CURRENT session's `session.lock`
29
33
  * `started_at` (via `readLock()`); when no lock is
30
34
  * readable, `cutoff = now` (all events count).
@@ -58,24 +62,50 @@
58
62
  * inflate `deltaHours` for perfectly healthy, promptly-closed sessions
59
63
  * (a session's own mid-session events would newly count as "after" the
60
64
  * anchor), reintroducing false positives on the opposite side. (a) is
61
- * chosen: `lastLedgerEntry()` skips any record `isBackfillStub()` flags and
62
- * keeps searching backward for a GENUINE `completed_at`. Stub recognition
63
- * uses EITHER marker (OR, not AND) deliberately both are set by the same
64
- * producer today, but requiring both would silently stop matching the day a
65
- * future backfill variant drops one of them while keeping the other; OR
65
+ * chosen: a stub's `completed_at` is NEVER an anchor candidate. Stub
66
+ * recognition uses EITHER marker (OR, not AND) deliberately — both are set by
67
+ * the same producer today, but requiring both would silently stop matching the
68
+ * day a future backfill variant drops one of them while keeping the other; OR
66
69
  * degrades gracefully (still catches it), AND does not.
67
70
  *
68
- * All-stub fallback (deliberately NOT null): when NO genuine record exists
69
- * anywhere in the file every record is a backfill stub — this is a
70
- * STRONGER signal of the close-through gap than an ordinary stale ledger,
71
- * not a weaker one: no session has EVER genuinely closed. The module's
72
- * usual fail-quiet convention (null on missing/empty/ambiguous input) does
73
- * not extend to "we have data but all of it is synthetic" that state IS
74
- * the failure this banner exists to catch, so `lastLedgerEntry()` instead
75
- * anchors on the newest stub's `started_at` (grounded in the real
76
- * `orchestrator.session.started` event in the common casesee
77
- * `synthesizeRecord()`unlike that same stub's fabricated `completed_at`)
78
- * and flags the result `stubFallback: true` for callers that want to say so.
71
+ * #1125 — newest-across-ALL records, not genuine-first: excluding the stub's
72
+ * `completed_at` (above) is correct; excluding the whole STUB from the anchor
73
+ * search was not. The original implementation scanned from EOF backward and
74
+ * returned the first GENUINE record it met, reaching the stub `started_at`
75
+ * fallback only when no genuine record existed ANYWHERE. That is a PRIORITY
76
+ * ORDER, and it lets an OLD genuine record outrank a NEWER stub: whenever the
77
+ * most recent ledger activity is a stub, the anchor fell back to a stale
78
+ * genuine `completed_at` and the reported gap ballooned by the difference.
79
+ * Measured in the vault repo 2026-08-22 (issue #1125): a 173h `alert` the
80
+ * probe's TOP severity against a ledger whose newest records were from the
81
+ * same day, contradicted by three independent counter-measurements (231 of 233
82
+ * records carrying `completed_at`, newest `2026-08-22T16:23Z`;
83
+ * `backfill-abandoned-sessions.mjs --dry-run` finding 0 to backfill; all 15
84
+ * sessions since 2026-08-15 closed). A permanent top-severity false alarm
85
+ * trains exactly the looking-away this probe exists to prevent, so it is a
86
+ * defect of the same class as a missed gap, not a cosmetic one.
87
+ *
88
+ * The anchor is therefore the MAXIMUM over every record's own contribution:
89
+ * - GENUINE record → its `completed_at`; or, when that field is absent or
90
+ * unparseable, its `started_at` as a floor.
91
+ * - BACKFILL STUB → its `started_at` ONLY (never the fabricated
92
+ * `completed_at` — that is the axis-(a) rule above).
93
+ * A stub written for an abandoned session still PROVES the ledger was alive at
94
+ * that instant: that session IS recorded (as abandoned), so activity around it
95
+ * is not evidence of an unrecorded close-through. Taking a MAX can only move
96
+ * the anchor FORWARD relative to the genuine-only anchor, so it never
97
+ * re-introduces axis (b)'s inflation of `deltaHours`.
98
+ *
99
+ * Stub-anchored reporting (deliberately NOT null): when the winning anchor is a
100
+ * stub's `started_at` — either because every record is a stub (no session has
101
+ * EVER genuinely closed, a STRONGER close-through signal than an ordinary stale
102
+ * ledger) or merely because the newest ledger activity happens to be a stub —
103
+ * the result carries `stubFallback: true` so the message can say the anchor is
104
+ * a stub `started_at` (grounded in the real `orchestrator.session.started`
105
+ * event in the common case — see `synthesizeRecord()`) rather than imply a
106
+ * measured close. The module's usual fail-quiet convention (null on
107
+ * missing/empty/ambiguous input) does not extend to "we have data but all of it
108
+ * is synthetic" — that state IS the failure this banner exists to catch.
79
109
  *
80
110
  * Severity: warn above `2 × DEFAULT_TTL_HOURS` (8h, imported from
81
111
  * `session-lock.mjs` rather than duplicated), alert above 24h.
@@ -158,57 +188,92 @@ function isBackfillStub(record) {
158
188
  }
159
189
 
160
190
  /**
161
- * Scan `sessions.jsonl` lines from EOF backward and return the anchor
162
- * instant to measure ledger staleness against. Malformed or non-conforming
163
- * lines (bad JSON, non-object) are skipped, not treated as fatal.
191
+ * Parse one record field into an anchor candidate. Returns `null` when the
192
+ * field is absent, not a string, or not a parseable timestamp.
164
193
  *
165
- * Two passes, in priority order:
166
- * 1. GENUINE the last (by position) record that is NOT `isBackfillStub()`
167
- * and carries a valid `completed_at`. This is the trustworthy case:
168
- * `completed_at` was written by the real session-end path.
169
- * 2. STUB-FALLBACK only reached when the loop above finds no genuine
170
- * record at all (every record is a stub, or the file has none). Anchors
171
- * on the newest-by-position stub's `started_at` instead of its
172
- * `completed_at` — see the module-header design note for why. Flags
173
- * `stubFallback: true` on the returned object; omitted (`undefined`) on
174
- * the genuine path so existing callers checking `ledger.ms`/`ledger.iso`
175
- * see no behavioural change.
194
+ * @param {unknown} value
195
+ * @returns {{iso: string, ms: number}|null}
196
+ */
197
+ function tsCandidate(value) {
198
+ if (typeof value !== 'string') return null;
199
+ const ms = Date.parse(value);
200
+ return Number.isFinite(ms) ? { iso: value, ms } : null;
201
+ }
202
+
203
+ /**
204
+ * Keep whichever of the two candidates is newer. A `null` candidate never
205
+ * displaces a real one. On an exact tie the LATER-seen candidate wins, which
206
+ * (with the forward scan in `lastLedgerEntry()`) preserves the previous
207
+ * "nearest-EOF record wins" tie-break.
208
+ *
209
+ * @param {{iso: string, ms: number}|null} current
210
+ * @param {{iso: string, ms: number}|null} candidate
211
+ * @returns {{iso: string, ms: number}|null}
212
+ */
213
+ function keepNewer(current, candidate) {
214
+ if (candidate === null) return current;
215
+ if (current === null) return candidate;
216
+ return candidate.ms >= current.ms ? candidate : current;
217
+ }
218
+
219
+ /**
220
+ * Scan all `sessions.jsonl` lines and return the anchor instant to measure
221
+ * ledger staleness against: the NEWEST instant any record proves the ledger
222
+ * was alive at. Malformed or non-conforming lines (bad JSON, non-object) are
223
+ * skipped, not treated as fatal.
224
+ *
225
+ * Which timestamp each record kind contributes, and why (full reasoning in the
226
+ * module header, "#1125 — newest-across-ALL records"):
227
+ * - GENUINE (`!isBackfillStub()`) → `completed_at`, written by the real
228
+ * session-end path; falling back to `started_at` when `completed_at` is
229
+ * absent or unparseable, so a truncated/in-flight record still contributes
230
+ * the coverage it does prove. Taking the newer of the two can only move the
231
+ * anchor forward, never backward, so it does not inflate `deltaHours`.
232
+ * - BACKFILL STUB → `started_at` ONLY. Its `completed_at` may be the backfill
233
+ * RUN's own wall-clock rather than a measurement of when the session ended
234
+ * (see `synthesizeRecord()`), and anchoring on that would let a backfill run
235
+ * retroactively erase a real multi-day gap.
236
+ *
237
+ * The two kinds are MAXED, not ranked: an older genuine record must not outrank
238
+ * a newer stub (that priority order was the #1125 false-alert defect). When the
239
+ * winning candidate is a stub's `started_at`, `stubFallback: true` is set on the
240
+ * result so the caller can say the anchor is a stub start, not a measured close;
241
+ * the key is omitted (`undefined`) on the genuine path.
176
242
  *
177
243
  * @param {string[]} lines
178
244
  * @returns {{iso: string, ms: number, stubFallback?: true}|null}
179
245
  */
180
246
  function lastLedgerEntry(lines) {
181
- let newestStub = null; // newest-by-position stub with a parseable started_at
247
+ let newestGenuine = null; // newest genuine completed_at (or started_at floor)
248
+ let newestStub = null; // newest stub started_at
182
249
 
183
- for (let i = lines.length - 1; i >= 0; i--) {
250
+ for (const line of lines) {
184
251
  let record;
185
252
  try {
186
- record = JSON.parse(lines[i]);
253
+ record = JSON.parse(line);
187
254
  } catch {
188
255
  continue;
189
256
  }
190
257
  if (!record || typeof record !== 'object') continue;
191
258
 
192
259
  if (isBackfillStub(record)) {
193
- // Never anchor on a stub's completed_at (it may be the backfill run's
194
- // own wall-clock) — remember it only as a fallback candidate, and only
195
- // the first (nearest-EOF, i.e. newest-by-position) one seen.
196
- if (newestStub === null && typeof record.started_at === 'string') {
197
- const startedMs = Date.parse(record.started_at);
198
- if (Number.isFinite(startedMs)) newestStub = { iso: record.started_at, ms: startedMs };
199
- }
260
+ newestStub = keepNewer(newestStub, tsCandidate(record.started_at));
200
261
  continue;
201
262
  }
202
263
 
203
- if (typeof record.completed_at !== 'string') continue;
204
- const ms = Date.parse(record.completed_at);
205
- if (!Number.isFinite(ms)) continue;
206
- return { iso: record.completed_at, ms };
264
+ let own = tsCandidate(record.completed_at);
265
+ const started = tsCandidate(record.started_at);
266
+ // `completed_at` is the normal anchor and wins ties; `started_at` only
267
+ // takes over when it is genuinely newer or `completed_at` is unusable.
268
+ if (started !== null && (own === null || started.ms > own.ms)) own = started;
269
+ newestGenuine = keepNewer(newestGenuine, own);
207
270
  }
208
271
 
209
- // No genuine record anywhere see module-header "All-stub fallback" note:
210
- // this is a stronger alarm signal than null, not a null-worthy absence.
211
- return newestStub ? { iso: newestStub.iso, ms: newestStub.ms, stubFallback: true } : null;
272
+ if (newestGenuine === null && newestStub === null) return null;
273
+
274
+ const stubWins = newestGenuine === null || (newestStub !== null && newestStub.ms > newestGenuine.ms);
275
+ if (stubWins) return { iso: newestStub.iso, ms: newestStub.ms, stubFallback: true };
276
+ return { iso: newestGenuine.iso, ms: newestGenuine.ms };
212
277
  }
213
278
 
214
279
  /**
@@ -282,7 +347,7 @@ function resolveCutoffMs(repoRoot, nowMs) {
282
347
  * event exists, the foreign event is not after the last ledger entry, or the
283
348
  * resulting gap is under the warn threshold. Never throws.
284
349
  *
285
- * When the anchor comes from the all-stub fallback (`ledger.stubFallback`),
350
+ * When the newest anchor is a backfill stub (`ledger.stubFallback`),
286
351
  * `lastLedgerAt` is a STUB's `started_at`, not a genuine `completed_at` — the
287
352
  * message says so explicitly rather than implying a real close was measured.
288
353
  *
@@ -327,11 +392,13 @@ export function checkSessionsStaleness({ repoRoot, now = Date.now() } = {}) {
327
392
 
328
393
  const severity = deltaHours > ALERT_THRESHOLD_HOURS ? 'alert' : 'warn';
329
394
 
330
- // stubFallback (see lastLedgerEntry()): every sessions.jsonl record is a
331
- // backfill stub — ledger.iso is a STUB's started_at, not a measured
332
- // completed_at. Say so explicitly rather than implying a real close.
395
+ // stubFallback (see lastLedgerEntry()): the newest anchor in sessions.jsonl
396
+ // is a backfill stub — ledger.iso is that STUB's started_at, not a measured
397
+ // completed_at. Say so explicitly rather than implying a real close. (The
398
+ // wording deliberately does NOT claim "stub-only": since #1125 a stub can
399
+ // win the anchor while older genuine records exist.)
333
400
  const ledgerDescription = ledger.stubFallback
334
- ? `last sessions.jsonl entry is backfill-stub-onlynewest stub started_at ${ledger.iso}`
401
+ ? `newest sessions.jsonl entry is a backfill stub — its started_at ${ledger.iso}`
335
402
  : `last sessions.jsonl entry ${ledger.iso}`;
336
403
 
337
404
  const base =
@@ -43,6 +43,7 @@ import {
43
43
  formatBlockReason,
44
44
  resolveIssueBudgetSessionId,
45
45
  } from './issue-budget.mjs';
46
+ import { resolveProjectDir } from './platform.mjs';
46
47
  import { resolveRepoSpec } from './vcs-repo-spec.mjs';
47
48
 
48
49
  /**
@@ -83,6 +84,27 @@ function isIssueCreateArgv(cmd, args) {
83
84
  return args[0] === 'issue' && (args[1] === 'create' || args[1] === 'new');
84
85
  }
85
86
 
87
+ /**
88
+ * Under vitest, a `repoRoot` that resolves to the ambient working copy is almost
89
+ * always the default-parameter leak (#1105): the test meant to pass a synthetic
90
+ * `mkdtemp` root but did not, and the issue-budget ledger would be charged in
91
+ * the operator's real tree. Production keeps the cwd default unchanged; this
92
+ * gate only fires when `process.env.VITEST` is set.
93
+ *
94
+ * @param {string} budgetRoot — ledger root about to be passed to `chargeIssueBudget`
95
+ * @returns {string|null} refusal message, or null when charging may proceed
96
+ */
97
+ function refuseAmbientLedgerUnderVitest(budgetRoot) {
98
+ if (!process.env.VITEST) return null;
99
+ if (path.resolve(budgetRoot) === path.resolve(process.cwd())) {
100
+ return (
101
+ 'spiral-carryover: refusing to charge the issue-budget ledger at the ambient ' +
102
+ 'repo root under VITEST — pass an explicit synthetic repoRoot'
103
+ );
104
+ }
105
+ return null;
106
+ }
107
+
86
108
  /**
87
109
  * Run a CLI command and return { ok, stdout, stderr }. Never throws.
88
110
  *
@@ -97,14 +119,46 @@ function isIssueCreateArgv(cmd, args) {
97
119
  * exactly the session-end promise at SKILL.md:319 / :1113 being preserved. It
98
120
  * bites for any FUTURE non-exempt producer added to this module.
99
121
  *
122
+ * REPO BINDING (#1058 follow-on). The ledger root is the `repoRoot` the CALLER
123
+ * named — the same value that already decides the `-R` host-pinning spec a few
124
+ * lines down. Before this parameter existed, `runCli` re-derived it from
125
+ * `process.env.CLAUDE_PROJECT_DIR || process.cwd()`, so one call could file an
126
+ * issue into repo A (via `-R`) while charging repo B's budget ledger. Two
127
+ * answers to "which repo" inside one call path is the defect; the caller's
128
+ * answer is the authoritative one.
129
+ *
130
+ * That split brain was also a live test leak, measured 2026-08-23: a sandboxed
131
+ * `CLAUDE_PROJECT_DIR=$(mktemp -d) npx vitest run tests/lib/spiral-carryover.test.mjs`
132
+ * left `{"sessionId":"1c2e5507-…","count":0,"exempt":15}` in the sandbox — 15
133
+ * bookings per run, carrying the REAL session id, which under a plain
134
+ * `npm test` land in this repo's live issue-budget ledger (since #1141 the
135
+ * per-session file `.orchestrator/runtime/issue-budget/<hash>.json`; the path
136
+ * is owned by `budgetStateRel` in `scripts/lib/issue-budget.mjs` — no caller,
137
+ * including this one, spells it out).
138
+ *
139
+ * The `repoRoot`-less fallback is `resolveProjectDir()` from `platform.mjs` —
140
+ * the SAME resolver `hooks/pre-bash-issue-budget.mjs` uses, so both producers
141
+ * of this ledger agree on which file they are charging. It strictly supersedes
142
+ * the hand-rolled expression it replaces: env fast-path first (including the
143
+ * Codex/Cursor/pi variants), then a walk-up for the instruction file — `CLAUDE.md`
144
+ * on Claude Code, `AGENTS.md` on Codex CLI (transparent aliases) — or `.git`, then cwd.
145
+ *
100
146
  * @param {string} cmd
101
147
  * @param {string[]} args
148
+ * @param {string} [repoRoot] — repo whose issue-budget ledger this call is
149
+ * charged against. Falls back to `resolveProjectDir()` when absent.
102
150
  * @returns {{ ok: boolean, stdout: string, stderr: string, budgetBlocked?: boolean }}
103
151
  */
104
- function runCli(cmd, args) {
152
+ function runCli(cmd, args, repoRoot) {
105
153
  if (isIssueCreateArgv(cmd, args)) {
106
154
  try {
107
- const repoRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
155
+ const budgetRoot = (typeof repoRoot === 'string' && repoRoot.trim())
156
+ ? repoRoot
157
+ : (resolveProjectDir() || process.cwd());
158
+ const vitestLedgerRefusal = refuseAmbientLedgerUnderVitest(budgetRoot);
159
+ if (vitestLedgerRefusal) {
160
+ return { ok: false, stdout: '', stderr: vitestLedgerRefusal, budgetBlocked: true };
161
+ }
108
162
  // The harness exports CLAUDE_CODE_SESSION_ID (measured 2026-08-21: it is
109
163
  // present in the Bash tool environment this module runs in). There is no
110
164
  // CLAUDE_SESSION_ID — reading that name made this whole block dead code
@@ -113,16 +167,43 @@ function runCli(cmd, args) {
113
167
  let currentSession = null;
114
168
  if (typeof nativeRawId === 'string' && nativeRawId.length > 0) {
115
169
  try {
116
- currentSession = JSON.parse(
117
- readFileSync(path.join(repoRoot, '.orchestrator', 'current-session.json'), 'utf8'),
170
+ const parsed = JSON.parse(
171
+ readFileSync(path.join(budgetRoot, '.orchestrator', 'current-session.json'), 'utf8'),
118
172
  );
173
+ // SESSION BINDING (#1058). `current-session.json` is repo-global —
174
+ // every session sharing this working copy writes the same path — so
175
+ // the file in hand may describe a PEER session, not us.
176
+ //
177
+ // The binding itself is not new: `resolveIssueBudgetSessionId`
178
+ // already requires `currentSession.session_id === nativeRawId` before
179
+ // it will adopt the file's semantic label, and falls back to the raw
180
+ // env id otherwise. What was missing is that the mismatch was
181
+ // INVISIBLE — the comparison lives one module over, so a foreign file
182
+ // was discarded with no trace, which is the same error class as using
183
+ // it silently. Deciding it HERE makes the outcome identical and the
184
+ // reason audible; it deliberately does not restate the wider
185
+ // multi-id-space classifier `scripts/lib/quality-gate.mjs` needs
186
+ // (that reader has no raw env id guaranteed in hand — this one does,
187
+ // by the `typeof nativeRawId === 'string'` guard above).
188
+ const foreignId = typeof parsed?.session_id === 'string' && parsed.session_id.length > 0
189
+ ? parsed.session_id
190
+ : null;
191
+ if (foreignId !== null && foreignId !== nativeRawId) {
192
+ process.stderr.write(
193
+ '⚠️ spiral-carryover: .orchestrator/current-session.json belongs to another ' +
194
+ `session (${foreignId}) — using the native session id for issue-budget ` +
195
+ 'accounting. Another session is active in this working copy (PSA-001).\n',
196
+ );
197
+ } else {
198
+ currentSession = parsed;
199
+ }
119
200
  } catch {
120
201
  // Missing or malformed records conservatively retain the native env key.
121
202
  }
122
203
  }
123
204
  const titleIdx = args.indexOf('--title');
124
205
  const verdict = chargeIssueBudget({
125
- repoRoot,
206
+ repoRoot: budgetRoot,
126
207
  sessionId: resolveIssueBudgetSessionId(nativeRawId, currentSession),
127
208
  command: [cmd, ...args].join(' '),
128
209
  title: titleIdx >= 0 ? (args[titleIdx + 1] ?? null) : null,
@@ -211,7 +292,7 @@ async function findExistingLabeledIssue({
211
292
  'number,url,body',
212
293
  ];
213
294
  if (spec) args.push('-R', spec);
214
- const res = runCli('gh', args);
295
+ const res = runCli('gh', args, repoRoot);
215
296
  if (!res.ok) return { exists: false };
216
297
  let arr;
217
298
  try {
@@ -231,7 +312,7 @@ async function findExistingLabeledIssue({
231
312
  // Default: gitlab via glab.
232
313
  const args = ['issue', 'list', '--label', label, '--per-page', '100', '--output', 'json'];
233
314
  if (spec) args.push('-R', spec);
234
- const res = runCli('glab', args);
315
+ const res = runCli('glab', args, repoRoot);
235
316
  if (!res.ok) return { exists: false };
236
317
  let arr;
237
318
  try {
@@ -420,7 +501,7 @@ export async function createSpiralCarryoverIssue({
420
501
  }
421
502
  if (spec) args.push('-R', spec);
422
503
 
423
- const res = runCli(cmd, args);
504
+ const res = runCli(cmd, args, repoRoot);
424
505
  if (!res.ok) {
425
506
  return { created: false, skipped: 'error', error: res.stderr.trim() || 'CLI invocation failed' };
426
507
  }
@@ -609,7 +690,7 @@ export async function createBrokenWindowIssue({
609
690
  }
610
691
  if (spec) args.push('-R', spec);
611
692
 
612
- const res = runCli(cmd, args);
693
+ const res = runCli(cmd, args, repoRoot);
613
694
  if (!res.ok) {
614
695
  return { created: false, skipped: 'error', error: res.stderr.trim() || 'CLI invocation failed' };
615
696
  }
@@ -239,11 +239,20 @@ function extractFrontmatterBlock(text) {
239
239
  *
240
240
  * Scoped to the frontmatter block only (see the "Frontmatter-safe round-trip
241
241
  * guard" comment above `DEFAULT_STATE_MD_SIZE_CEILING_BYTES` for the scoping
242
- * rationale). Content with no parseable frontmatter (`parseStateMd(after) ===
243
- * null`) is treated as SAFE — there is nothing frontmatter-shaped to verify,
244
- * and this keeps non-STATE.md-shaped writes (e.g. arbitrary test fixtures)
245
- * ungated by this check, matching `evaluateSizeCeiling`'s content-agnostic
246
- * posture for that case.
242
+ * rationale).
243
+ *
244
+ * `parseStateMd(after) === null` has TWO causes and they get OPPOSITE verdicts,
245
+ * because collapsing them is what made this guard blind:
246
+ * - NO frontmatter fence in `after` → SAFE. There is nothing frontmatter-shaped
247
+ * to verify, and non-STATE.md-shaped writes (arbitrary test fixtures, plain
248
+ * body text) stay ungated, matching `evaluateSizeCeiling`'s content-agnostic
249
+ * posture for that case.
250
+ * - A fence IS present but its contents do not parse → UNSAFE. A write whose
251
+ * frontmatter this repo's own parser cannot read back is precisely the
252
+ * corruption class this guard exists to refuse; treating it as "nothing to
253
+ * verify" let the single worst input pass unchecked (the 2026-08-24 #1111
254
+ * regression, where one flow item nulled the whole document, sat in exactly
255
+ * this hole).
247
256
  *
248
257
  * Never throws — mirrors the never-throw contract of yaml-parser.mjs.
249
258
  *
@@ -251,12 +260,20 @@ function extractFrontmatterBlock(text) {
251
260
  * @returns {{ unsafe: boolean, reason: string|null }}
252
261
  */
253
262
  export function evaluateFrontmatterSafe(after) {
263
+ const beforeBlock = extractFrontmatterBlock(after);
254
264
  const parsed = parseStateMd(after);
255
265
  if (parsed === null) {
256
- return { unsafe: false, reason: null };
266
+ if (beforeBlock === null) {
267
+ return { unsafe: false, reason: null };
268
+ }
269
+ return {
270
+ unsafe: true,
271
+ reason:
272
+ `frontmatter block is present but parseStateMd could not read it back ` +
273
+ `(${Buffer.byteLength(beforeBlock, 'utf8')}B unparseable)`,
274
+ };
257
275
  }
258
276
  const reserialized = serializeStateMd(parsed);
259
- const beforeBlock = extractFrontmatterBlock(after);
260
277
  const afterBlock = extractFrontmatterBlock(reserialized);
261
278
  if (beforeBlock !== afterBlock) {
262
279
  return {
@@ -320,7 +337,10 @@ function writeFileAtomic(filePath, contents) {
320
337
  * deliberately left unshipped when issue #739 first landed, because the
321
338
  * yaml-parser asymmetry it exists to catch made it false-positive on ordinary
322
339
  * content at the time) — #747 closed that asymmetry in yaml-parser.mjs, so
323
- * the check is safe to enforce. It catches FUTURE serializer/parser drift
340
+ * the check is safe to enforce. A frontmatter fence that is present but does
341
+ * NOT parse back is refused by the same guard (see `evaluateFrontmatterSafe`);
342
+ * only content with no fence at all leaves it inert. It catches FUTURE
343
+ * serializer/parser drift
324
344
  * that reintroduces a non-idempotent frontmatter round-trip — the exact
325
345
  * incident class behind #739 — rather than the symptom (file size) the
326
346
  * size-ceiling guard was limited to. Same non-throw, WARN-then-refuse
@@ -395,6 +415,19 @@ export async function writeStateMd(repoRoot, transformer, opts = {}) {
395
415
  return { written: false, path: statePath, contents: before, reason: 'frontmatter-unsafe' };
396
416
  }
397
417
 
418
+ // A list item the parser cannot represent is dropped on READ (see
419
+ // yaml-parser.mjs `parseBlockValue`), so this write is what makes the drop
420
+ // permanent on disk. Dropping is the right call — the alternatives are a
421
+ // mangled key or a dead no-op — but it must not be SILENT, which is the
422
+ // whole complaint the per-item scoping answers. Name the items, then write
423
+ // anyway: blocking here would re-create the no-op this fix removed.
424
+ const droppedItems = parseStateMd(before)?.warnings;
425
+ if (droppedItems !== undefined) {
426
+ process.stderr.write(
427
+ `⚠ writeStateMd: ${statePath} — dropping ${droppedItems.length} unrepresentable list item(s): ` +
428
+ `${droppedItems.map((w) => `${w.key}[${w.index}] (${w.reason})`).join(', ')}\n`
429
+ );
430
+ }
398
431
  writeFileAtomic(statePath, after);
399
432
  return { written: true, path: statePath, contents: after };
400
433
  },