session-orchestrator 3.21.0 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (345) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +11 -2
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/040-discovery.mdc +6 -6
  38. package/.cursor/rules/050-plan.mdc +10 -10
  39. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  40. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  41. package/.cursor/skills/architecture/SKILL.md +13 -0
  42. package/.cursor/skills/autopilot/SKILL.md +12 -0
  43. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  44. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  45. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  46. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  47. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  48. package/.cursor/skills/daily/SKILL.md +12 -0
  49. package/.cursor/skills/debug/SKILL.md +13 -0
  50. package/.cursor/skills/discovery/SKILL.md +13 -0
  51. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  52. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  53. package/.cursor/skills/domain-model/SKILL.md +13 -0
  54. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  55. package/.cursor/skills/eli5/SKILL.md +13 -0
  56. package/.cursor/skills/eval/SKILL.md +12 -0
  57. package/.cursor/skills/evolve/SKILL.md +13 -0
  58. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  60. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  61. package/.cursor/skills/grill/SKILL.md +13 -0
  62. package/.cursor/skills/hook-development/SKILL.md +13 -0
  63. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  64. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  65. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  66. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  67. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  68. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  69. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  70. package/.cursor/skills/plan/SKILL.md +13 -0
  71. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  72. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  73. package/.cursor/skills/reconcile/SKILL.md +12 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +269 -0
  91. package/README.md +18 -15
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -1
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/memory-proposal-collector.md +6 -4
  98. package/agents/session-reviewer.md +7 -1
  99. package/agents/test-writer.md +2 -1
  100. package/agents/ui-developer.md +2 -1
  101. package/commands/bootstrap.md +2 -2
  102. package/commands/close.md +3 -1
  103. package/commands/eli5.md +33 -0
  104. package/commands/go.md +1 -1
  105. package/commands/journey-audit.md +43 -0
  106. package/commands/release.md +5 -3
  107. package/commands/test.md +2 -2
  108. package/docs/USER-GUIDE.md +2 -2
  109. package/docs/ci-setup.md +14 -0
  110. package/docs/codex-setup.md +64 -0
  111. package/docs/components.md +7 -6
  112. package/docs/cursor-setup.md +26 -47
  113. package/docs/events-schema.md +76 -4
  114. package/docs/github-mirror-protection.md +197 -0
  115. package/docs/pi-setup.md +2 -0
  116. package/docs/rule-authoring.md +3 -1
  117. package/docs/scope-collision-guard.md +52 -5
  118. package/docs/session-config-reference.md +57 -12
  119. package/docs/session-config-template.md +4 -3
  120. package/docs/telemetry.md +22 -0
  121. package/hooks/_lib/lock-bootstrap.mjs +26 -16
  122. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  123. package/hooks/enforce-scope.mjs +64 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +12 -2
  128. package/hooks/on-session-end.mjs +237 -104
  129. package/hooks/on-session-start.mjs +404 -110
  130. package/hooks/on-stop.mjs +48 -9
  131. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  132. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  133. package/hooks/pre-auq-clarity.mjs +787 -0
  134. package/hooks/pre-bash-issue-budget.mjs +133 -21
  135. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  136. package/hooks/pre-bash-staging-fence.mjs +4 -0
  137. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  138. package/hooks/skill-invocation-telemetry.mjs +21 -10
  139. package/monitors/monitors.json +6 -0
  140. package/package.json +3 -1
  141. package/pi/prompts/eli5.md +12 -0
  142. package/pi/prompts/journey-audit.md +12 -0
  143. package/rules/_index.md +9 -1
  144. package/rules/always-on/ask-via-tool.md +62 -0
  145. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  146. package/rules/always-on/build-value.md +47 -0
  147. package/rules/always-on/cross-session-messaging.md +59 -0
  148. package/rules/always-on/loop-and-monitor.md +221 -0
  149. package/rules/always-on/parallel-sessions.md +142 -12
  150. package/rules/always-on/receiving-review.md +108 -0
  151. package/rules/always-on/test-value.md +40 -0
  152. package/rules/always-on/verification-before-completion.md +77 -0
  153. package/scripts/archive-closed-prds.mjs +258 -18
  154. package/scripts/auq-audit.mjs +825 -0
  155. package/scripts/autopilot.mjs +12 -8
  156. package/scripts/backfill-evidence-digest.mjs +376 -0
  157. package/scripts/cursor-install.mjs +89 -48
  158. package/scripts/export-hw-learnings.mjs +143 -2
  159. package/scripts/express-path.mjs +299 -0
  160. package/scripts/generate-cursor-adapter.mjs +253 -0
  161. package/scripts/github-protection-audit.mjs +358 -0
  162. package/scripts/lib/auq/clarity.mjs +1314 -0
  163. package/scripts/lib/auq/parse.mjs +1006 -0
  164. package/scripts/lib/auq/schema.mjs +1457 -0
  165. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  166. package/scripts/lib/build-live-signals.mjs +24 -5
  167. package/scripts/lib/ci-status-banner.mjs +221 -68
  168. package/scripts/lib/command-blocker.mjs +70 -0
  169. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  170. package/scripts/lib/config/reconcile.mjs +79 -4
  171. package/scripts/lib/config/section-extractor.mjs +235 -36
  172. package/scripts/lib/config/vault-integration.mjs +12 -1
  173. package/scripts/lib/config-schema.mjs +9 -1
  174. package/scripts/lib/config.mjs +57 -6
  175. package/scripts/lib/convergence-monitor.mjs +13 -2
  176. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  177. package/scripts/lib/dispatcher/cli.mjs +2 -2
  178. package/scripts/lib/dispatcher/rank.mjs +4 -7
  179. package/scripts/lib/express-path.mjs +327 -0
  180. package/scripts/lib/file-lock.mjs +22 -4
  181. package/scripts/lib/gates/gate-full.mjs +84 -11
  182. package/scripts/lib/gates/gate-helpers.mjs +93 -21
  183. package/scripts/lib/git-config-drift.mjs +134 -5
  184. package/scripts/lib/host-identity.mjs +247 -2
  185. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  186. package/scripts/lib/io.mjs +239 -0
  187. package/scripts/lib/issue-budget.mjs +284 -31
  188. package/scripts/lib/learnings/io.mjs +55 -10
  189. package/scripts/lib/learnings/schema.mjs +95 -28
  190. package/scripts/lib/lock-reaper.mjs +7 -1
  191. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  192. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  193. package/scripts/lib/memory-banner.mjs +5 -2
  194. package/scripts/lib/memory-paths.mjs +15 -6
  195. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  196. package/scripts/lib/owner-interview.mjs +78 -32
  197. package/scripts/lib/peer-discovery.mjs +73 -22
  198. package/scripts/lib/platform.mjs +72 -9
  199. package/scripts/lib/plugin-root.mjs +143 -19
  200. package/scripts/lib/project-hygiene.mjs +104 -4
  201. package/scripts/lib/quality-gate.mjs +271 -13
  202. package/scripts/lib/reconcile/emitter.mjs +87 -19
  203. package/scripts/lib/reconcile/engine.mjs +281 -13
  204. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  205. package/scripts/lib/reconcile/renderer.mjs +165 -7
  206. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  207. package/scripts/lib/reconcile/writer.mjs +415 -84
  208. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  209. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  210. package/scripts/lib/resource-probe.mjs +18 -2
  211. package/scripts/lib/rule-loader.mjs +37 -2
  212. package/scripts/lib/rules-sync.mjs +51 -8
  213. package/scripts/lib/scope-gate.mjs +90 -0
  214. package/scripts/lib/session-close-backfill.mjs +369 -28
  215. package/scripts/lib/session-discovery.mjs +13 -3
  216. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  217. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  218. package/scripts/lib/session-id.mjs +30 -14
  219. package/scripts/lib/session-identity/own-session.mjs +159 -0
  220. package/scripts/lib/session-lock.mjs +85 -30
  221. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  222. package/scripts/lib/session-schema/validator.mjs +40 -0
  223. package/scripts/lib/session-start-probes.mjs +608 -0
  224. package/scripts/lib/session-transition.mjs +277 -0
  225. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  226. package/scripts/lib/spiral-carryover.mjs +111 -9
  227. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  228. package/scripts/lib/state-md/mission-status.mjs +467 -72
  229. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  230. package/scripts/lib/state-md.mjs +12 -2
  231. package/scripts/lib/telemetry/sync.mjs +46 -8
  232. package/scripts/lib/validate/check-agents.mjs +66 -0
  233. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  234. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  235. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  236. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  237. package/scripts/lib/validate/check-hooks-symmetry.mjs +59 -63
  238. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  239. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  240. package/scripts/lib/validate/check-rules.mjs +153 -9
  241. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  242. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  243. package/scripts/lib/validate/repo-files.mjs +275 -0
  244. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  245. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  246. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  247. package/scripts/lib/vault-mirror/process.mjs +99 -43
  248. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  249. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  250. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  251. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  252. package/scripts/lib/vcs-repo-spec.mjs +182 -13
  253. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  254. package/scripts/lib/wave-resource-gate.mjs +67 -73
  255. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  256. package/scripts/materialize-wave-scope.mjs +478 -0
  257. package/scripts/mcp-server.sh +11 -2
  258. package/scripts/parse-config.mjs +65 -0
  259. package/scripts/release.mjs +443 -122
  260. package/scripts/run-quality-gate.mjs +14 -0
  261. package/scripts/token-audit.sh +9 -2
  262. package/scripts/validate-plugin.mjs +6 -0
  263. package/scripts/validate-wave-scope.mjs +73 -1
  264. package/scripts/vault-backfill.mjs +32 -5
  265. package/scripts/vault-mirror.mjs +203 -34
  266. package/skills/_shared/monitor-patterns.md +31 -5
  267. package/skills/_shared/parallel-aware-auq.md +31 -25
  268. package/skills/_shared/parallel-aware-preamble.md +35 -4
  269. package/skills/_shared/platform-tools.md +11 -5
  270. package/skills/_shared/state-ownership.md +61 -8
  271. package/skills/autopilot/SKILL.md +5 -1
  272. package/skills/bootstrap/SKILL.md +5 -4
  273. package/skills/bootstrap/_shared-template.md +18 -10
  274. package/skills/bootstrap/deep-template.md +10 -6
  275. package/skills/bootstrap/fast-template.md +15 -8
  276. package/skills/bootstrap/standard-template.md +10 -6
  277. package/skills/brainstorm/SKILL.md +18 -18
  278. package/skills/brainstorm/soul.md +12 -0
  279. package/skills/claude-md-drift-check/checker.mjs +39 -11
  280. package/skills/discovery/SKILL.md +28 -24
  281. package/skills/dispatcher/SKILL.md +1 -1
  282. package/skills/eli5/SKILL.md +43 -0
  283. package/skills/evolve/SKILL.md +8 -9
  284. package/skills/gitlab-ops/SKILL.md +30 -26
  285. package/skills/grill/SKILL.md +6 -6
  286. package/skills/grill/soul.md +16 -0
  287. package/skills/journey-audit/SKILL.md +269 -0
  288. package/skills/memory-cleanup/SKILL.md +2 -2
  289. package/skills/npm-publish/SKILL.md +4 -4
  290. package/skills/peekaboo-driver/SKILL.md +18 -6
  291. package/skills/persona-panel/SKILL.md +1 -1
  292. package/skills/plan/SKILL.md +18 -16
  293. package/skills/plan/mode-feature.md +1 -1
  294. package/skills/plan/mode-new.md +35 -23
  295. package/skills/plan/soul.md +12 -0
  296. package/skills/reconcile/SKILL.md +44 -4
  297. package/skills/session-end/SKILL.md +70 -24
  298. package/skills/session-end/metrics-collection.md +7 -4
  299. package/skills/session-end/phase-3-6-tail.md +48 -5
  300. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  301. package/skills/session-plan/SKILL.md +6 -1
  302. package/skills/session-plan/wave-template.md +1 -0
  303. package/skills/session-start/SKILL.md +96 -48
  304. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  305. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  306. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  307. package/skills/session-start/phase-8-5-express-path.md +77 -12
  308. package/skills/session-start/soul.md +110 -0
  309. package/skills/test-runner/SKILL.md +2 -2
  310. package/skills/using-orchestrator/SKILL.md +1 -1
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +4 -2
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +129 -24
  315. package/skills/write-executable-plan/SKILL.md +6 -6
  316. package/templates/_shared/journey-manifest.md +110 -0
  317. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  318. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  319. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  320. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  321. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  322. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  323. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  324. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  325. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  326. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  327. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  328. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  329. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  330. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  331. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  332. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  333. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  334. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  335. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  336. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  337. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  338. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  339. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  340. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  341. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  342. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  343. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  344. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
  345. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -11,40 +11,228 @@
11
11
  * delegate to the pure helpers above and route the read+write cycle through
12
12
  * `writeStateMd` from frontmatter-mutators.mjs, which acquires
13
13
  * `.orchestrator/state.lock` for mechanical serialization (PSA-004).
14
+ *
15
+ * #1104 resolved the two remaining recovery limits with **Option 1 (skip-and-warn)
16
+ * + Option 2 (id gate)**: body recovery now skips the individual lines it cannot
17
+ * parse instead of abandoning the section at the first one, reports them through
18
+ * `recoverFrontmatterMissionStatusDetailed`, and `setMissionStatusOnDisk` — the
19
+ * layer that already does I/O, so the pure contract above survives — turns a
20
+ * non-empty skip list into one stderr WARN. `setMissionStatus` refuses a task id
21
+ * the recovery grammar cannot read, so the writer stops manufacturing the very
22
+ * lines the recovery has to skip.
14
23
  */
15
24
 
16
25
  import { parseStateMd, serializeStateMd } from './yaml-parser.mjs';
17
26
  import { updateFrontmatterFields, writeStateMd } from './frontmatter-mutators.mjs';
18
27
 
28
+ const MISSION_STATUS_HEADING_RE = /^##\s+Mission Status\s*$/;
29
+ const WRITER_TIMESTAMP_SOURCE = '\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z';
30
+ const WRITER_TIMESTAMP_RE = new RegExp(`^${WRITER_TIMESTAMP_SOURCE}$`);
31
+
32
+ /**
33
+ * Task-ID grammar, shared by THREE surfaces on purpose: the body writer's own
34
+ * `taskId` gate in `setMissionStatus` (#1104 Option 2), the body recovery regex
35
+ * below, and the frontmatter validator in `parseMissionStatusStrict`. ONE source:
36
+ * a reader whose ID grammar is narrower than the writer's drops entries the
37
+ * writer just produced — the two-surface divergence class this module exists to
38
+ * close (#960/#1084/#1104). Accepts `m-1`, `docs-2`, `w2-1`, `w2-a-10`; rejects
39
+ * `M-1`, `m1`, `Docs_2`, `docs`.
40
+ *
41
+ * The example list is measured, not asserted: this docblock advertised `w2-a10`
42
+ * until #1104, and the grammar rejects it — every segment but the last must be
43
+ * separated by `-`, so `a10` is not a segment boundary. Harmless while the
44
+ * grammar only gated the READER; load-bearing since `setMissionStatus` gates its
45
+ * `taskId` on it, where a wrong example buys a silent no-op. Pinned by
46
+ * `tests/lib/state-md-mission-status.test.mjs` ("refuses an id its own recovery
47
+ * cannot read").
48
+ */
49
+ const MISSION_STATUS_ID_SOURCE = '[a-z][a-z0-9]*(?:-[a-z0-9]+)*-\\d+';
50
+ const MISSION_STATUS_ID_RE = new RegExp(`^${MISSION_STATUS_ID_SOURCE}$`);
51
+ const CANONICAL_MISSION_STATUS_ENTRY_RE = new RegExp(
52
+ `^- (${MISSION_STATUS_ID_SOURCE}): (.*) \\(updated (${WRITER_TIMESTAMP_SOURCE})\\)$`
53
+ );
54
+
55
+ /**
56
+ * The 5-value mission-status vocabulary (`skills/session-plan/SKILL.md` §
57
+ * Mission-Status Enum). A VOCABULARY, not a state machine and not a gate: an
58
+ * out-of-enum value is reported as a WARNING by `parseMissionStatusStrict` and
59
+ * still returned by `parseMissionStatus`, because dropping it on the read side
60
+ * would hide on the frontmatter surface exactly what `setMissionStatus`
61
+ * deliberately makes visible on both.
62
+ *
63
+ * @type {readonly string[]}
64
+ */
65
+ export const MISSION_STATUS_VALUES = Object.freeze([
66
+ 'brainstormed',
67
+ 'validated',
68
+ 'in-dev',
69
+ 'testing',
70
+ 'completed',
71
+ ]);
72
+
73
+ /** Scalar shapes this YAML subset can produce for an entry field. */
74
+ function isScalarField(value) {
75
+ return (
76
+ value === null ||
77
+ typeof value === 'string' ||
78
+ typeof value === 'number' ||
79
+ typeof value === 'boolean'
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Validates ONE `mission-status` entry.
85
+ *
86
+ * Hard requirements are exactly the fields a consumer cannot work without: the
87
+ * entry must be a mapping, `id` must match the writer's own ID grammar, and
88
+ * `status` must be a non-empty string. `task` and `wave` are OPTIONAL — the
89
+ * recovery path in `recoverFrontmatterMissionStatus` emits truthful partial
90
+ * `{ id, status }` entries on purpose, and requiring the metadata here would
91
+ * delete them on read (measured: `setMissionStatus` on a legacy body yields
92
+ * `[{"id":"docs-2","status":"completed"}]`). When present they must still be
93
+ * scalars — an object or array there is a parse gone sideways, and renders as
94
+ * `[object Object]` in the vault rollup table.
95
+ *
96
+ * @param {unknown} entry
97
+ * @returns {{ valid: true, warning: string|null }|{ valid: false, reason: string }}
98
+ */
99
+ function validateMissionStatusEntry(entry) {
100
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
101
+ return { valid: false, reason: 'not-a-mapping' };
102
+ }
103
+ if (typeof entry.id !== 'string' || !MISSION_STATUS_ID_RE.test(entry.id)) {
104
+ return { valid: false, reason: 'invalid-id' };
105
+ }
106
+ if (typeof entry.status !== 'string' || entry.status.trim() === '') {
107
+ return { valid: false, reason: 'invalid-status' };
108
+ }
109
+ if (Object.prototype.hasOwnProperty.call(entry, 'task') && !isScalarField(entry.task)) {
110
+ return { valid: false, reason: 'invalid-task' };
111
+ }
112
+ if (Object.prototype.hasOwnProperty.call(entry, 'wave') && !isScalarField(entry.wave)) {
113
+ return { valid: false, reason: 'invalid-wave' };
114
+ }
115
+ return {
116
+ valid: true,
117
+ warning: MISSION_STATUS_VALUES.includes(entry.status) ? null : 'status-not-in-enum',
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Finds the first exact `## Mission Status` section and its closing heading.
123
+ *
124
+ * @param {string[]} lines
125
+ * @returns {{ headingIdx: number, sectionEnd: number }|null}
126
+ */
127
+ function findMissionStatusSection(lines) {
128
+ if (!Array.isArray(lines)) return null;
129
+ const headingIdx = lines.findIndex((line) => MISSION_STATUS_HEADING_RE.test(line));
130
+ if (headingIdx === -1) return null;
131
+
132
+ let sectionEnd = lines.length;
133
+ for (let i = headingIdx + 1; i < lines.length; i++) {
134
+ if (/^##\s+/.test(lines[i])) {
135
+ sectionEnd = i;
136
+ break;
137
+ }
138
+ }
139
+ return { headingIdx, sectionEnd };
140
+ }
141
+
142
+ function isWriterTimestamp(timestamp) {
143
+ if (!WRITER_TIMESTAMP_RE.test(timestamp)) return false;
144
+ const parsed = new Date(timestamp);
145
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === timestamp;
146
+ }
147
+
19
148
  /**
20
149
  * Parses the optional `mission-status:` block from a STATE.md frontmatter object
21
- * (as returned by `parseStateMd(...).frontmatter`).
150
+ * (as returned by `parseStateMd(...).frontmatter`), keeping only entries that pass
151
+ * `validateMissionStatusEntry`.
22
152
  *
23
153
  * Returns `null` when the `mission-status` key is absent (backward-compat: pre-#340
24
- * STATE.md files). Returns `[]` when the key is present but the value is an empty
25
- * array. Returns the array of entries when present and non-empty.
154
+ * STATE.md files) or holds a non-array. Returns `[]` when the key is present but the
155
+ * value is an empty array or when every entry in it was rejected.
26
156
  *
27
- * Does NOT validate individual entry shapes, and no helper in this repo does:
28
- * entry shape and the `status` enum are coordinator convention, deliberately not a
29
- * mechanical gate (see `syncFrontmatterMissionStatus` below for why).
157
+ * The rejects are NOT surfaced here (the return type is unchanged for existing
158
+ * callers). Use `parseMissionStatusStrict` when you need to report them; a caller
159
+ * that only counts or renders entries wants the clean list.
30
160
  *
31
161
  * @param {object} frontmatter
32
162
  * @returns {object[]|null}
33
163
  */
34
164
  export function parseMissionStatus(frontmatter) {
165
+ return parseMissionStatusStrict(frontmatter).items;
166
+ }
167
+
168
+ /**
169
+ * `parseMissionStatus` with its rejects and vocabulary warnings attached.
170
+ *
171
+ * Before #1111 nothing in this repo validated entry shape, so a malformed entry —
172
+ * a flow-mapping list item mangled into a key literally named `{ id`, a bare
173
+ * scalar left in the array, an entry with no `status` — reached session-end Phase
174
+ * 1.9/1.10 and `vault-status/narrative-mirror.mjs` as a plausible-looking task and
175
+ * was counted. `items` is what a consumer may trust; `invalid` is what a reporter
176
+ * must show instead of silently dropping.
177
+ *
178
+ * The `{ id` mangling itself no longer originates HERE: yaml-parser.mjs now parses
179
+ * a single-line flow mapping into the same object a block item yields. This
180
+ * validator stays the reader-side net for every OTHER source of a malformed entry
181
+ * — a hand-built array passed to `writeMissionStatus`, a future parser change, a
182
+ * third-party writer — none of which the parser fix can speak for.
183
+ *
184
+ * - `items` — mirrors `parseMissionStatus` exactly (`null` when the key is absent
185
+ * or not an array; otherwise the entries that validated, in source order).
186
+ * - `invalid` — `{ index, reason }` per rejected entry, `index` addressing the RAW
187
+ * array. Reasons: `not-a-mapping`, `invalid-id`, `invalid-status`, `invalid-task`,
188
+ * `invalid-wave`.
189
+ * - `warnings` — `{ index, reason }` for entries that are structurally fine but
190
+ * suspect: `duplicate-id` (a second entry with an id already seen — the sync in
191
+ * `syncFrontmatterMissionStatus` only ever updates the FIRST match, so the copy
192
+ * keeps a stale status forever and Phase 1.10 counts the task twice) and
193
+ * `status-not-in-enum`. Both stay in `items` on purpose.
194
+ *
195
+ * @param {object} frontmatter
196
+ * @returns {{ items: object[]|null, invalid: Array<{index: number, reason: string}>, warnings: Array<{index: number, reason: string}> }}
197
+ */
198
+ export function parseMissionStatusStrict(frontmatter) {
199
+ const empty = { items: null, invalid: [], warnings: [] };
35
200
  if (frontmatter === null || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
36
- return null;
201
+ return empty;
37
202
  }
38
203
  if (!Object.prototype.hasOwnProperty.call(frontmatter, 'mission-status')) {
39
- return null;
204
+ return empty;
40
205
  }
41
206
  const raw = frontmatter['mission-status'];
42
207
  if (!Array.isArray(raw)) {
43
208
  // Present but not an array (e.g. null scalar from empty key) — treat as absent
44
- return null;
209
+ return empty;
45
210
  }
46
- // Return a shallow copy to prevent mutation of the parsed frontmatter
47
- return raw.slice();
211
+
212
+ // A fresh array (never `raw` itself) keeps the shallow-copy contract: callers may
213
+ // push/splice their result without touching the parsed frontmatter.
214
+ const items = [];
215
+ const invalid = [];
216
+ const warnings = [];
217
+ const seenIds = new Set();
218
+ for (let index = 0; index < raw.length; index++) {
219
+ const entry = raw[index];
220
+ const verdict = validateMissionStatusEntry(entry);
221
+ if (!verdict.valid) {
222
+ invalid.push({ index, reason: verdict.reason });
223
+ continue;
224
+ }
225
+ if (seenIds.has(entry.id)) {
226
+ warnings.push({ index, reason: 'duplicate-id' });
227
+ } else {
228
+ seenIds.add(entry.id);
229
+ }
230
+ if (verdict.warning !== null) {
231
+ warnings.push({ index, reason: verdict.warning });
232
+ }
233
+ items.push(entry);
234
+ }
235
+ return { items, invalid, warnings };
48
236
  }
49
237
 
50
238
  /**
@@ -56,9 +244,11 @@ export function parseMissionStatus(frontmatter) {
56
244
  * - Works on string input (pure — no file I/O). Returns the updated STATE.md contents.
57
245
  * - No-ops if `contents` has no parseable frontmatter (returns input unchanged).
58
246
  *
59
- * Individual entry objects are expected to carry `{ id, task, wave, status }`, but
60
- * this function does NOT enforce that shape and no validator in this repo does
61
- * callers own the invariant.
247
+ * Individual entry objects are expected to carry `{ id, task, wave, status }`. This
248
+ * WRITER does not enforce that shape whatever it is handed is serialized, so a
249
+ * malformed entry lands visibly in the file rather than being dropped on the way in.
250
+ * The READER is where the shape is checked since #1111: `parseMissionStatus` returns
251
+ * only entries that validate, and `parseMissionStatusStrict` reports the rest.
62
252
  *
63
253
  * @param {string} contents
64
254
  * @param {object[]|null|undefined} missionStatusArray
@@ -83,13 +273,17 @@ export function writeMissionStatus(contents, missionStatusArray) {
83
273
  * entry level — the input is never mutated, which keeps `parseMissionStatus`'s
84
274
  * shallow-copy contract intact for anything else holding the same nested entries).
85
275
  *
86
- * UPDATE-ONLY by design: when the key is absent, is not an array, or holds no entry
87
- * with a matching `id`, the frontmatter is returned unchanged. It is deliberately
88
- * neither created nor an error, because `setMissionStatus(contents, taskId, status)`
89
- * knows only `id` and `status` — it lacks the `task` and `wave` fields a full entry
90
- * carries, so a synthesised entry would be shape-invalid yet look authoritative to
91
- * frontmatter consumers such as `vault-status/narrative-mirror.mjs`. Throwing is
92
- * likewise excluded by the never-throw contract of `setMissionStatus`.
276
+ * UPDATE-ONLY by design: when the key is absent, is not an array, or holds no
277
+ * matching entry in a populated array, the frontmatter is returned unchanged. It is
278
+ * deliberately neither created nor an error, because `setMissionStatus(contents,
279
+ * taskId, status)` knows only `id` and `status` — it lacks the `task` and `wave`
280
+ * fields a full entry carries, so a synthesised entry would be shape-invalid yet look
281
+ * authoritative to frontmatter consumers such as `vault-status/narrative-mirror.mjs`.
282
+ * Throwing is likewise excluded by the never-throw contract of `setMissionStatus`.
283
+ *
284
+ * An empty array is recovered from the final body by
285
+ * `recoverFrontmatterMissionStatus`, which persists only the truthful `id` and
286
+ * `status` values available there. It does not fabricate absent metadata.
93
287
  *
94
288
  * `status` is mirrored verbatim without an enum check on purpose: gating it would
95
289
  * reintroduce the exact divergence (body says X, frontmatter says Y) this sync exists
@@ -125,6 +319,127 @@ function syncFrontmatterMissionStatus(frontmatter, taskId, status) {
125
319
  return { ...frontmatter, 'mission-status': entries };
126
320
  }
127
321
 
322
+ /**
323
+ * `recoverFrontmatterMissionStatus` with the lines it declined attached.
324
+ *
325
+ * Mirrors body-only task IDs into the frontmatter `mission-status` registry as
326
+ * partial `{ id, status }` entries. This is a SUPERSET merge: existing entries are
327
+ * never rewritten or reordered, so their full metadata (`task`, `wave`) survives —
328
+ * only IDs absent from the registry are appended.
329
+ *
330
+ * It must not be gated on an EMPTY registry. Recovering only from empty froze the
331
+ * registry after its first recovery: the sync path (`syncFrontmatterMissionStatus`)
332
+ * is update-only, so every subsequently added task ID was never mirrored again, and
333
+ * Phase 1.9/1.10 read a plausible undercount instead of the obvious zero. That is
334
+ * #1084 one write later — measured m-1/m-2/m-3 in the body against `[m-1]` in the
335
+ * frontmatter.
336
+ *
337
+ * **#1104 decision — Option 1 (skip-and-warn) + Option 2 (id gate).** The parse was
338
+ * all-or-nothing until #1104: ONE nonblank line that was not a unique canonical
339
+ * writer bullet aborted the whole merge. Measured over all 16 host-local
340
+ * `<repo>/.claude/STATE.md` (2026-08-21), exactly one repo carried the #1084 shape,
341
+ * and its bullets were hand-written (`- m-1 D1 ADR-Delta: completed`, no
342
+ * `(updated <ISO>)`), so the abort fired on line 1 and the class the recovery was
343
+ * built for was served 0 of 1.
344
+ * The abort is now per LINE: a line that does not parse is skipped and reported here,
345
+ * every other line still recovers. Nothing is ever fabricated — a skipped line
346
+ * contributes no entry, exactly as before. Option 2 is the other half, in
347
+ * `setMissionStatus`: the writer now refuses an id its own recovery cannot read, so
348
+ * the writer can no longer manufacture the lines this function has to skip.
349
+ *
350
+ * Option 3 (WARN) is honoured at the seam rather than here: this function stays pure,
351
+ * and `setMissionStatusOnDisk` — which already does I/O — turns a non-empty `skipped`
352
+ * into ONE stderr WARN. That is what makes "recovery declined N lines" distinguishable
353
+ * from "nothing to do", the third #1104 acceptance criterion.
354
+ *
355
+ * `skipped` reasons, in evaluation order per line:
356
+ * - `non-canonical` — not a writer bullet at all: prose, an id outside
357
+ * `MISSION_STATUS_ID_SOURCE` (`Docs_2`, `m_1`), or a timestamp of the wrong SHAPE
358
+ * (`(updated yesterday)`). This is the legacy hand-written class.
359
+ * - `unsafe-status` — canonical shape, but the status is empty or carries a `|`,
360
+ * which the block-seq serializer cannot round-trip.
361
+ * - `invalid-timestamp` — right shape, not a real instant (e.g. `2026-02-30T…`).
362
+ * - `duplicate-id` — a SECOND bullet for an id already recovered from this section.
363
+ * The FIRST wins, matching `setMissionStatus`/`readMissionStatus`, which both
364
+ * operate on the first matching bullet.
365
+ *
366
+ * @param {object} frontmatter
367
+ * @param {string} body
368
+ * @returns {{ frontmatter: object, skipped: Array<{line: string, reason: string}> }}
369
+ */
370
+ export function recoverFrontmatterMissionStatusDetailed(frontmatter, body) {
371
+ if (frontmatter === null || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
372
+ return { frontmatter, skipped: [] };
373
+ }
374
+ const raw = frontmatter['mission-status'];
375
+ if (!Array.isArray(raw) || typeof body !== 'string') return { frontmatter, skipped: [] };
376
+
377
+ const lines = body.split('\n');
378
+ const section = findMissionStatusSection(lines);
379
+ if (section === null) return { frontmatter, skipped: [] };
380
+
381
+ const entries = [];
382
+ const ids = new Set();
383
+ const skipped = [];
384
+ for (let i = section.headingIdx + 1; i < section.sectionEnd; i++) {
385
+ const line = lines[i];
386
+ if (line.trim() === '') continue;
387
+
388
+ const match = CANONICAL_MISSION_STATUS_ENTRY_RE.exec(line);
389
+ if (match === null) {
390
+ skipped.push({ line, reason: 'non-canonical' });
391
+ continue;
392
+ }
393
+ // A status can never contain `\n` here — `line` came out of a `\n` split.
394
+ const [, id, status, timestamp] = match;
395
+ if (status.length === 0 || status.includes('|')) {
396
+ skipped.push({ line, reason: 'unsafe-status' });
397
+ continue;
398
+ }
399
+ if (!isWriterTimestamp(timestamp)) {
400
+ skipped.push({ line, reason: 'invalid-timestamp' });
401
+ continue;
402
+ }
403
+ if (ids.has(id)) {
404
+ skipped.push({ line, reason: 'duplicate-id' });
405
+ continue;
406
+ }
407
+ ids.add(id);
408
+ entries.push({ id, status });
409
+ }
410
+
411
+ const known = new Set(
412
+ raw
413
+ .filter((e) => e !== null && typeof e === 'object' && !Array.isArray(e))
414
+ .map((e) => e.id)
415
+ );
416
+ const added = entries.filter((e) => !known.has(e.id));
417
+ if (added.length === 0) return { frontmatter, skipped };
418
+ return { frontmatter: { ...frontmatter, 'mission-status': [...raw, ...added] }, skipped };
419
+ }
420
+
421
+ /**
422
+ * Serialize a mission-status update, carrying the recovery's skipped lines out
423
+ * with it.
424
+ *
425
+ * Returns `skipped` rather than discarding it (and rather than leaving the
426
+ * caller to re-run the recovery to see it): before 2026-08-28
427
+ * `setMissionStatusOnDisk` called `recoverFrontmatterMissionStatusDetailed` a
428
+ * SECOND time purely to read `skipped`, so the same body was parsed twice per
429
+ * write and the two results could disagree the moment either input drifted.
430
+ *
431
+ * @param {object} frontmatter
432
+ * @param {string} body
433
+ * @returns {{ contents: string, skipped: Array<{line: string, reason: string}> }}
434
+ */
435
+ function serializeMissionStatusUpdate(frontmatter, body) {
436
+ const recovered = recoverFrontmatterMissionStatusDetailed(frontmatter, body);
437
+ return {
438
+ contents: serializeStateMd({ frontmatter: recovered.frontmatter, body }),
439
+ skipped: recovered.skipped,
440
+ };
441
+ }
442
+
128
443
  /**
129
444
  * Sets (or updates) the mission status for a single task in the `## Mission Status` body
130
445
  * section of STATE.md. Creates the section if it does not exist.
@@ -137,21 +452,67 @@ function syncFrontmatterMissionStatus(frontmatter, taskId, status) {
137
452
  * during a wave; the frontmatter array is what `parseMissionStatus` consumers read
138
453
  * (`vault-status/narrative-mirror.mjs`, session-end Phase 1.9/1.10). Before this sync the
139
454
  * live writer and the reader sat on different surfaces and drifted apart in both
140
- * directions. Frontmatter mirroring is UPDATE-ONLY see `syncFrontmatterMissionStatus`.
455
+ * directions. A legacy empty registry is recovered from canonical body bullets as
456
+ * partial `{ id, status }` entries, which lets frontmatter readers classify the work
457
+ * without fabricated metadata. Since #1104 that recovery skips only the lines it cannot
458
+ * parse instead of abandoning the whole section; other unmatched populated entries remain
459
+ * update-only. See `syncFrontmatterMissionStatus` and
460
+ * `recoverFrontmatterMissionStatusDetailed`.
461
+ *
462
+ * **#1104 Option 2 — `taskId` is gated on the reader's own grammar**
463
+ * (`MISSION_STATUS_ID_SOURCE`: lowercase alphanumeric segments joined by `-`, ending in
464
+ * `-<digits>` — `m-1`, `docs-2`, `w2-1`; NOT `M-1`, `m1`, `Docs_2`, `w2-a10`). Until #1104 this
465
+ * gate was `typeof taskId === 'string'`, so the writer produced body lines
466
+ * (`- Docs_2: in-dev (updated …)`) that its OWN recovery could never read back — the
467
+ * mechanism behind "setMissionStatus in a loop seeds only the body". A non-conforming id
468
+ * now returns `contents` unchanged rather than writing an unreadable line: refusing to
469
+ * write is recoverable, writing a line no reader accepts is not.
141
470
  *
142
471
  * Pure function — no I/O. Returns original `contents` unchanged on bad input.
143
472
  *
144
473
  * @param {string} contents - Current STATE.md file contents (string)
145
- * @param {string} taskId - Task identifier (e.g. 'm-1', 'docs-2')
474
+ * @param {string} taskId - Task identifier matching `MISSION_STATUS_ID_SOURCE` (e.g. 'm-1', 'docs-2')
146
475
  * @param {string} status - One of: brainstormed | validated | in-dev | testing | completed
147
476
  * @returns {string}
148
477
  */
149
478
  export function setMissionStatus(contents, taskId, status) {
150
- if (typeof contents !== 'string') return contents;
151
- if (!taskId || typeof taskId !== 'string') return contents;
152
- if (!status || typeof status !== 'string') return contents;
479
+ return setMissionStatusDetailed(contents, taskId, status).contents;
480
+ }
481
+
482
+ /**
483
+ * `setMissionStatus` with the outcome attached — the same relationship
484
+ * `recoverFrontmatterMissionStatusDetailed` has to its merge-only view, and for
485
+ * the same reason: a pure function cannot report, and a caller that only sees
486
+ * `contents` cannot tell a refusal from a no-op.
487
+ *
488
+ * There are FIVE ways this function declines to write, and until 2026-08-28
489
+ * `setMissionStatusOnDisk` could only name ONE of them — it re-tested
490
+ * `MISSION_STATUS_ID_RE` itself and warned on the grammar case alone. The other
491
+ * four (`contents` not a string, a missing/non-string `taskId`, a
492
+ * missing/non-string `status`, STATE.md that does not parse) returned `contents`
493
+ * unchanged, which `writeStateMd` reports as `written: false` — byte-identical
494
+ * to "the file already said that". Re-deriving the reason at the seam is what
495
+ * made four of five invisible; the reason now travels with the result.
496
+ *
497
+ * `refused` is OMITTED (not `null`) on the success path, so `'refused' in out`
498
+ * is the discriminator and no caller has to compare against a sentinel.
499
+ *
500
+ * @param {string} contents
501
+ * @param {string} taskId
502
+ * @param {string} status
503
+ * @returns {{
504
+ * contents: string,
505
+ * refused?: 'bad-contents'|'bad-id'|'id-grammar'|'bad-status'|'unparseable',
506
+ * skipped: Array<{line: string, reason: string}>,
507
+ * }}
508
+ */
509
+ export function setMissionStatusDetailed(contents, taskId, status) {
510
+ if (typeof contents !== 'string') return { contents, refused: 'bad-contents', skipped: [] };
511
+ if (!taskId || typeof taskId !== 'string') return { contents, refused: 'bad-id', skipped: [] };
512
+ if (!MISSION_STATUS_ID_RE.test(taskId)) return { contents, refused: 'id-grammar', skipped: [] };
513
+ if (!status || typeof status !== 'string') return { contents, refused: 'bad-status', skipped: [] };
153
514
  const parsed = parseStateMd(contents);
154
- if (parsed === null) return contents;
515
+ if (parsed === null) return { contents, refused: 'unparseable', skipped: [] };
155
516
 
156
517
  // Computed once so every return path below emits the same synced frontmatter.
157
518
  const frontmatter = syncFrontmatterMissionStatus(parsed.frontmatter, taskId, status);
@@ -160,31 +521,16 @@ export function setMissionStatus(contents, taskId, status) {
160
521
  const bullet = `- ${taskId}: ${status} (updated ${timestamp})`;
161
522
  const lines = parsed.body.split('\n');
162
523
 
163
- // Find existing ## Mission Status section
164
- let headingIdx = -1;
165
- for (let i = 0; i < lines.length; i++) {
166
- if (/^##\s+Mission Status\b/.test(lines[i])) {
167
- headingIdx = i;
168
- break;
169
- }
170
- }
171
-
172
- if (headingIdx === -1) {
524
+ const section = findMissionStatusSection(lines);
525
+ if (section === null) {
173
526
  // Section does not exist — append it at the end
174
527
  let bodyOut = parsed.body;
175
528
  if (!bodyOut.endsWith('\n')) bodyOut += '\n';
176
529
  bodyOut += `\n## Mission Status\n\n${bullet}\n`;
177
- return serializeStateMd({ frontmatter, body: bodyOut });
530
+ return serializeMissionStatusUpdate(frontmatter, bodyOut);
178
531
  }
179
532
 
180
- // Find end of section: next ## heading or end of lines
181
- let sectionEnd = lines.length;
182
- for (let i = headingIdx + 1; i < lines.length; i++) {
183
- if (/^##\s+/.test(lines[i])) {
184
- sectionEnd = i;
185
- break;
186
- }
187
- }
533
+ const { headingIdx, sectionEnd } = section;
188
534
 
189
535
  // Look for an existing entry with this taskId within the section
190
536
  const entryRe = new RegExp(`^-\\s+${taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:`);
@@ -214,18 +560,20 @@ export function setMissionStatus(contents, taskId, status) {
214
560
  const before = lines.slice(0, headingIdx + 1);
215
561
  const after = lines.slice(insertAt);
216
562
  const rebuilt = [...before, '', bullet, ...after];
217
- return serializeStateMd({ frontmatter, body: rebuilt.join('\n') });
563
+ return serializeMissionStatusUpdate(frontmatter, rebuilt.join('\n'));
218
564
  }
219
565
  }
220
566
 
221
- return serializeStateMd({ frontmatter, body: lines.join('\n') });
567
+ return serializeMissionStatusUpdate(frontmatter, lines.join('\n'));
222
568
  }
223
569
 
224
570
  /**
225
571
  * Reads the current mission status for a single task from the `## Mission Status` body
226
572
  * section of STATE.md.
227
573
  *
228
- * Returns the status string (e.g. `'in-dev'`) or `null` if the task is not found or the
574
+ * Returns the full status string before a current-writer timestamp (e.g. `'in-dev'`
575
+ * or `'needs manual testing'`). For legacy body lines that lack that exact form, falls
576
+ * back to the first status token. Returns `null` if the task is not found or the
229
577
  * section does not exist. Never throws — returns `null` on any bad input.
230
578
  *
231
579
  * @param {string} contents - Current STATE.md file contents (string)
@@ -239,29 +587,25 @@ export function readMissionStatus(contents, taskId) {
239
587
  if (parsed === null) return null;
240
588
 
241
589
  const lines = parsed.body.split('\n');
242
- let headingIdx = -1;
243
- for (let i = 0; i < lines.length; i++) {
244
- if (/^##\s+Mission Status\b/.test(lines[i])) {
245
- headingIdx = i;
246
- break;
247
- }
248
- }
249
- if (headingIdx === -1) return null;
590
+ const section = findMissionStatusSection(lines);
591
+ if (section === null) return null;
250
592
 
251
- let sectionEnd = lines.length;
252
- for (let i = headingIdx + 1; i < lines.length; i++) {
253
- if (/^##\s+/.test(lines[i])) {
254
- sectionEnd = i;
255
- break;
256
- }
593
+ // Prefer the full current-writer status, including internal spaces.
594
+ const escapedId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
595
+ const writerEntryRe = new RegExp(
596
+ `^- ${escapedId}: (.*) \\(updated ${WRITER_TIMESTAMP_SOURCE}\\)$`
597
+ );
598
+ for (let i = section.headingIdx + 1; i < section.sectionEnd; i++) {
599
+ const match = writerEntryRe.exec(lines[i]);
600
+ if (match) return match[1];
257
601
  }
258
602
 
259
- // Match: - <taskId>: <status> (updated ...)
260
- const escapedId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
261
- const entryRe = new RegExp(`^-\\s+${escapedId}:\\s+(\\S+)`);
262
- for (let i = headingIdx + 1; i < sectionEnd; i++) {
263
- const m = entryRe.exec(lines[i]);
264
- if (m) return m[1];
603
+ // Reader-only legacy compatibility. Recovery still accepts canonical bullets only —
604
+ // since #1104 it skips the others per line instead of abandoning the section.
605
+ const legacyEntryRe = new RegExp(`^-\\s+${escapedId}:\\s+(\\S+)`);
606
+ for (let i = section.headingIdx + 1; i < section.sectionEnd; i++) {
607
+ const match = legacyEntryRe.exec(lines[i]);
608
+ if (match) return match[1];
265
609
  }
266
610
  return null;
267
611
  }
@@ -287,20 +631,71 @@ export async function writeMissionStatusOnDisk(repoRoot, missionStatusArray, opt
287
631
  );
288
632
  }
289
633
 
634
+ /**
635
+ * Why each refusal declined to write, in the words an operator can act on.
636
+ *
637
+ * A closed map rather than an inline ternary chain: adding a refusal to
638
+ * `setMissionStatusDetailed` without a message here yields `undefined` in the
639
+ * WARN, which is loud in a test — the fail-toward-visible direction.
640
+ *
641
+ * @type {Readonly<Record<string, string>>}
642
+ */
643
+ const REFUSAL_REASONS = Object.freeze({
644
+ 'bad-contents': 'STATE.md contents were not a string',
645
+ 'bad-id': 'taskId was missing or not a string',
646
+ 'id-grammar': 'taskId does not match the mission-status id grammar',
647
+ 'bad-status': 'status was missing or not a string',
648
+ unparseable: 'STATE.md could not be parsed',
649
+ });
650
+
290
651
  /**
291
652
  * Lock-guarded `setMissionStatus` — sets or replaces a single task entry in
292
653
  * the `## Mission Status` body section under the state-lock.
293
654
  *
655
+ * This is the #1104 Option-3 seam: the recovery is pure and cannot report the
656
+ * body lines it skipped, so the wrapper that already does I/O emits ONE stderr
657
+ * WARN naming the count and the first reason. Without it a declined recovery is
658
+ * byte-identical to "nothing to recover" — the silence the issue's third
659
+ * acceptance criterion is about. It never blocks the write: the skipped lines
660
+ * are pre-existing body content, not a defect in THIS write.
661
+ *
662
+ * The refusal WARN is the same seam applied to the OTHER silence. It used to
663
+ * re-test `MISSION_STATUS_ID_RE` here and so covered 1 of the 5 ways
664
+ * `setMissionStatus` declines; it now reads `refused` off
665
+ * `setMissionStatusDetailed` and covers all five. Exactly ONE line is written
666
+ * per call: a refusal means nothing was written, so the skipped-lines report
667
+ * would describe a merge that did not happen.
668
+ *
669
+ * `reason` is added to the result on a refusal, matching how `writeStateMd`
670
+ * already reports its own declines (`reason: 'size-ceiling'`).
671
+ *
294
672
  * @param {string|undefined} repoRoot
295
673
  * @param {string} taskId
296
674
  * @param {string} status brainstormed | validated | in-dev | testing | completed
297
675
  * @param {object} [opts]
298
- * @returns {Promise<{ written: boolean, path: string, contents: string|null }>}
676
+ * @returns {Promise<{ written: boolean, path: string, contents: string|null, reason?: string }>}
299
677
  */
300
678
  export async function setMissionStatusOnDisk(repoRoot, taskId, status, opts = {}) {
301
- return writeStateMd(
679
+ let refused;
680
+ const result = await writeStateMd(
302
681
  repoRoot,
303
- (contents) => setMissionStatus(contents, taskId, status),
682
+ (contents) => {
683
+ const outcome = setMissionStatusDetailed(contents, taskId, status);
684
+ refused = outcome.refused;
685
+ if (outcome.refused) {
686
+ process.stderr.write(
687
+ `⚠ setMissionStatusOnDisk: ${REFUSAL_REASONS[outcome.refused]} ` +
688
+ `(reason: ${outcome.refused}, taskId: ${JSON.stringify(taskId)}) — nothing written\n`
689
+ );
690
+ } else if (outcome.skipped.length > 0) {
691
+ process.stderr.write(
692
+ `⚠ setMissionStatusOnDisk: mission-status recovery skipped ${outcome.skipped.length} ` +
693
+ `body line(s) — first: ${outcome.skipped[0].reason} (${outcome.skipped[0].line.trim()})\n`
694
+ );
695
+ }
696
+ return outcome.contents;
697
+ },
304
698
  opts
305
699
  );
700
+ return refused ? { ...result, written: false, reason: refused } : result;
306
701
  }