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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +11 -2
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +10 -10
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +269 -0
- package/README.md +18 -15
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -1
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/memory-proposal-collector.md +6 -4
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/eli5.md +33 -0
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/commands/release.md +5 -3
- package/commands/test.md +2 -2
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +14 -0
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -6
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +76 -4
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +52 -5
- package/docs/session-config-reference.md +57 -12
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +26 -16
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/enforce-scope.mjs +64 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +12 -2
- package/hooks/on-session-end.mjs +237 -104
- package/hooks/on-session-start.mjs +404 -110
- package/hooks/on-stop.mjs +48 -9
- package/hooks/post-subagent-discovery-validator.mjs +34 -3
- package/hooks/post-tool-batch-wave-signal.mjs +11 -2
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +133 -21
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/monitors/monitors.json +6 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +12 -8
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
- package/scripts/lib/build-live-signals.mjs +24 -5
- package/scripts/lib/ci-status-banner.mjs +221 -68
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +57 -6
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +84 -11
- package/scripts/lib/gates/gate-helpers.mjs +93 -21
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/io.mjs +239 -0
- package/scripts/lib/issue-budget.mjs +284 -31
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +5 -2
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +104 -4
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +281 -13
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +165 -7
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +90 -0
- package/scripts/lib/session-close-backfill.mjs +369 -28
- package/scripts/lib/session-discovery.mjs +13 -3
- package/scripts/lib/session-end/phase-skip.mjs +37 -4
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +159 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +111 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +467 -72
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/sync.mjs +46 -8
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
- package/scripts/lib/validate/check-hooks-symmetry.mjs +59 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-unwired-features.mjs +340 -32
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-writer.mjs +211 -10
- package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
- package/scripts/lib/vcs-repo-spec.mjs +182 -13
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +478 -0
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/release.mjs +443 -122
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +6 -0
- package/scripts/validate-wave-scope.mjs +73 -1
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -25
- package/skills/_shared/parallel-aware-preamble.md +35 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +61 -8
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +5 -4
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/discovery/SKILL.md +28 -24
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/gitlab-ops/SKILL.md +30 -26
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/npm-publish/SKILL.md +4 -4
- package/skills/peekaboo-driver/SKILL.md +18 -6
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +35 -23
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +44 -4
- package/skills/session-end/SKILL.md +70 -24
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +48 -5
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +96 -48
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/session-start/soul.md +110 -0
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +4 -2
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +129 -24
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/templates/_shared/journey-manifest.md +110 -0
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
- 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
|
|
25
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
201
|
+
return empty;
|
|
37
202
|
}
|
|
38
203
|
if (!Object.prototype.hasOwnProperty.call(frontmatter, 'mission-status')) {
|
|
39
|
-
return
|
|
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
|
|
209
|
+
return empty;
|
|
45
210
|
}
|
|
46
|
-
|
|
47
|
-
|
|
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 }
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
87
|
-
*
|
|
88
|
-
* neither created nor an error, because `setMissionStatus(contents,
|
|
89
|
-
* knows only `id` and `status` — it lacks the `task` and `wave`
|
|
90
|
-
* carries, so a synthesised entry would be shape-invalid yet look
|
|
91
|
-
* frontmatter consumers such as `vault-status/narrative-mirror.mjs`.
|
|
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.
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
|
530
|
+
return serializeMissionStatusUpdate(frontmatter, bodyOut);
|
|
178
531
|
}
|
|
179
532
|
|
|
180
|
-
|
|
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
|
|
563
|
+
return serializeMissionStatusUpdate(frontmatter, rebuilt.join('\n'));
|
|
218
564
|
}
|
|
219
565
|
}
|
|
220
566
|
|
|
221
|
-
return
|
|
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'`
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
//
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
for (let i = headingIdx + 1; i < sectionEnd; i++) {
|
|
263
|
-
const
|
|
264
|
-
if (
|
|
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
|
-
|
|
679
|
+
let refused;
|
|
680
|
+
const result = await writeStateMd(
|
|
302
681
|
repoRoot,
|
|
303
|
-
(contents) =>
|
|
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
|
}
|