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
@@ -11,6 +11,15 @@
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';
@@ -19,10 +28,96 @@ import { updateFrontmatterFields, writeStateMd } from './frontmatter-mutators.mj
19
28
  const MISSION_STATUS_HEADING_RE = /^##\s+Mission Status\s*$/;
20
29
  const WRITER_TIMESTAMP_SOURCE = '\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z';
21
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}$`);
22
51
  const CANONICAL_MISSION_STATUS_ENTRY_RE = new RegExp(
23
- `^- ([a-z][a-z0-9]*(?:-[a-z0-9]+)*-\\d+): (.*) \\(updated (${WRITER_TIMESTAMP_SOURCE})\\)$`
52
+ `^- (${MISSION_STATUS_ID_SOURCE}): (.*) \\(updated (${WRITER_TIMESTAMP_SOURCE})\\)$`
24
53
  );
25
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
+
26
121
  /**
27
122
  * Finds the first exact `## Mission Status` section and its closing heading.
28
123
  *
@@ -52,33 +147,92 @@ function isWriterTimestamp(timestamp) {
52
147
 
53
148
  /**
54
149
  * Parses the optional `mission-status:` block from a STATE.md frontmatter object
55
- * (as returned by `parseStateMd(...).frontmatter`).
150
+ * (as returned by `parseStateMd(...).frontmatter`), keeping only entries that pass
151
+ * `validateMissionStatusEntry`.
56
152
  *
57
153
  * Returns `null` when the `mission-status` key is absent (backward-compat: pre-#340
58
- * STATE.md files). Returns `[]` when the key is present but the value is an empty
59
- * 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.
60
156
  *
61
- * Does NOT validate individual entry shapes, and no helper in this repo does:
62
- * entry shape and the `status` enum are coordinator convention, deliberately not a
63
- * 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.
64
160
  *
65
161
  * @param {object} frontmatter
66
162
  * @returns {object[]|null}
67
163
  */
68
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: [] };
69
200
  if (frontmatter === null || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
70
- return null;
201
+ return empty;
71
202
  }
72
203
  if (!Object.prototype.hasOwnProperty.call(frontmatter, 'mission-status')) {
73
- return null;
204
+ return empty;
74
205
  }
75
206
  const raw = frontmatter['mission-status'];
76
207
  if (!Array.isArray(raw)) {
77
208
  // Present but not an array (e.g. null scalar from empty key) — treat as absent
78
- return null;
209
+ return empty;
79
210
  }
80
- // Return a shallow copy to prevent mutation of the parsed frontmatter
81
- 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 };
82
236
  }
83
237
 
84
238
  /**
@@ -90,9 +244,11 @@ export function parseMissionStatus(frontmatter) {
90
244
  * - Works on string input (pure — no file I/O). Returns the updated STATE.md contents.
91
245
  * - No-ops if `contents` has no parseable frontmatter (returns input unchanged).
92
246
  *
93
- * Individual entry objects are expected to carry `{ id, task, wave, status }`, but
94
- * this function does NOT enforce that shape and no validator in this repo does
95
- * 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.
96
252
  *
97
253
  * @param {string} contents
98
254
  * @param {object[]|null|undefined} missionStatusArray
@@ -164,6 +320,8 @@ function syncFrontmatterMissionStatus(frontmatter, taskId, status) {
164
320
  }
165
321
 
166
322
  /**
323
+ * `recoverFrontmatterMissionStatus` with the lines it declined attached.
324
+ *
167
325
  * Mirrors body-only task IDs into the frontmatter `mission-status` registry as
168
326
  * partial `{ id, status }` entries. This is a SUPERSET merge: existing entries are
169
327
  * never rewritten or reordered, so their full metadata (`task`, `wave`) survives —
@@ -176,45 +334,75 @@ function syncFrontmatterMissionStatus(frontmatter, taskId, status) {
176
334
  * #1084 one write later — measured m-1/m-2/m-3 in the body against `[m-1]` in the
177
335
  * frontmatter.
178
336
  *
179
- * The section parse stays strict and all-or-nothing: any nonblank line that is not a
180
- * unique canonical writer bullet (unsafe ID, pipe, malformed timestamp, duplicate,
181
- * prose) aborts the whole merge rather than fabricating entries from an ambiguous
182
- * body. Known limitation: one foreign-but-writer-accepted ID (e.g. `Docs_2`) or a
183
- * hand-written legacy bullet without a timestamp therefore suppresses the merge for
184
- * the whole file, silently.
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.
185
365
  *
186
366
  * @param {object} frontmatter
187
367
  * @param {string} body
188
- * @returns {object}
368
+ * @returns {{ frontmatter: object, skipped: Array<{line: string, reason: string}> }}
189
369
  */
190
- function recoverFrontmatterMissionStatus(frontmatter, body) {
370
+ export function recoverFrontmatterMissionStatusDetailed(frontmatter, body) {
191
371
  if (frontmatter === null || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
192
- return frontmatter;
372
+ return { frontmatter, skipped: [] };
193
373
  }
194
374
  const raw = frontmatter['mission-status'];
195
- if (!Array.isArray(raw) || typeof body !== 'string') return frontmatter;
375
+ if (!Array.isArray(raw) || typeof body !== 'string') return { frontmatter, skipped: [] };
196
376
 
197
377
  const lines = body.split('\n');
198
378
  const section = findMissionStatusSection(lines);
199
- if (section === null) return frontmatter;
379
+ if (section === null) return { frontmatter, skipped: [] };
200
380
 
201
381
  const entries = [];
202
382
  const ids = new Set();
383
+ const skipped = [];
203
384
  for (let i = section.headingIdx + 1; i < section.sectionEnd; i++) {
204
385
  const line = lines[i];
205
386
  if (line.trim() === '') continue;
206
387
 
207
388
  const match = CANONICAL_MISSION_STATUS_ENTRY_RE.exec(line);
208
- if (match === null) return frontmatter;
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.
209
394
  const [, id, status, timestamp] = match;
210
- if (
211
- status.length === 0 ||
212
- status.includes('|') ||
213
- status.includes('\n') ||
214
- !isWriterTimestamp(timestamp) ||
215
- ids.has(id)
216
- ) {
217
- return frontmatter;
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;
218
406
  }
219
407
  ids.add(id);
220
408
  entries.push({ id, status });
@@ -226,15 +414,30 @@ function recoverFrontmatterMissionStatus(frontmatter, body) {
226
414
  .map((e) => e.id)
227
415
  );
228
416
  const added = entries.filter((e) => !known.has(e.id));
229
- if (added.length === 0) return frontmatter;
230
- return { ...frontmatter, 'mission-status': [...raw, ...added] };
417
+ if (added.length === 0) return { frontmatter, skipped };
418
+ return { frontmatter: { ...frontmatter, 'mission-status': [...raw, ...added] }, skipped };
231
419
  }
232
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
+ */
233
435
  function serializeMissionStatusUpdate(frontmatter, body) {
234
- return serializeStateMd({
235
- frontmatter: recoverFrontmatterMissionStatus(frontmatter, body),
236
- body,
237
- });
436
+ const recovered = recoverFrontmatterMissionStatusDetailed(frontmatter, body);
437
+ return {
438
+ contents: serializeStateMd({ frontmatter: recovered.frontmatter, body }),
439
+ skipped: recovered.skipped,
440
+ };
238
441
  }
239
442
 
240
443
  /**
@@ -251,22 +454,65 @@ function serializeMissionStatusUpdate(frontmatter, body) {
251
454
  * live writer and the reader sat on different surfaces and drifted apart in both
252
455
  * directions. A legacy empty registry is recovered from canonical body bullets as
253
456
  * partial `{ id, status }` entries, which lets frontmatter readers classify the work
254
- * without fabricated metadata. Ambiguous legacy bodies remain empty; other unmatched
255
- * populated entries remain update-only. See `syncFrontmatterMissionStatus`.
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.
256
470
  *
257
471
  * Pure function — no I/O. Returns original `contents` unchanged on bad input.
258
472
  *
259
473
  * @param {string} contents - Current STATE.md file contents (string)
260
- * @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')
261
475
  * @param {string} status - One of: brainstormed | validated | in-dev | testing | completed
262
476
  * @returns {string}
263
477
  */
264
478
  export function setMissionStatus(contents, taskId, status) {
265
- if (typeof contents !== 'string') return contents;
266
- if (!taskId || typeof taskId !== 'string') return contents;
267
- 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: [] };
268
514
  const parsed = parseStateMd(contents);
269
- if (parsed === null) return contents;
515
+ if (parsed === null) return { contents, refused: 'unparseable', skipped: [] };
270
516
 
271
517
  // Computed once so every return path below emits the same synced frontmatter.
272
518
  const frontmatter = syncFrontmatterMissionStatus(parsed.frontmatter, taskId, status);
@@ -354,7 +600,8 @@ export function readMissionStatus(contents, taskId) {
354
600
  if (match) return match[1];
355
601
  }
356
602
 
357
- // Reader-only legacy compatibility. Recovery remains canonical and all-or-nothing.
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.
358
605
  const legacyEntryRe = new RegExp(`^-\\s+${escapedId}:\\s+(\\S+)`);
359
606
  for (let i = section.headingIdx + 1; i < section.sectionEnd; i++) {
360
607
  const match = legacyEntryRe.exec(lines[i]);
@@ -384,20 +631,71 @@ export async function writeMissionStatusOnDisk(repoRoot, missionStatusArray, opt
384
631
  );
385
632
  }
386
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
+
387
651
  /**
388
652
  * Lock-guarded `setMissionStatus` — sets or replaces a single task entry in
389
653
  * the `## Mission Status` body section under the state-lock.
390
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
+ *
391
672
  * @param {string|undefined} repoRoot
392
673
  * @param {string} taskId
393
674
  * @param {string} status brainstormed | validated | in-dev | testing | completed
394
675
  * @param {object} [opts]
395
- * @returns {Promise<{ written: boolean, path: string, contents: string|null }>}
676
+ * @returns {Promise<{ written: boolean, path: string, contents: string|null, reason?: string }>}
396
677
  */
397
678
  export async function setMissionStatusOnDisk(repoRoot, taskId, status, opts = {}) {
398
- return writeStateMd(
679
+ let refused;
680
+ const result = await writeStateMd(
399
681
  repoRoot,
400
- (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
+ },
401
698
  opts
402
699
  );
700
+ return refused ? { ...result, written: false, reason: refused } : result;
403
701
  }