session-orchestrator 3.22.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 +8 -0
- 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/050-plan.mdc +2 -2
- 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 +168 -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/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/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- 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 +6 -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 +49 -2
- package/docs/session-config-reference.md +26 -4
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- 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 +2 -2
- package/hooks/on-session-end.mjs +211 -10
- package/hooks/on-session-start.mjs +214 -11
- 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-bash-issue-budget.mjs +117 -4
- 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 +1 -1
- 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/autopilot.mjs +5 -0
- 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/autopilot/worktree-pipeline.mjs +240 -16
- package/scripts/lib/build-live-signals.mjs +24 -5
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- 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/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- 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/issue-budget.mjs +229 -30
- 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/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- 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 +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- 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 +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- 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-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 +29 -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-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-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/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +209 -12
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/_shared/parallel-aware-preamble.md +4 -2
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- 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/claude-md-drift-check/checker.mjs +39 -11
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +41 -1
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +11 -3
- 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 +30 -16
- 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/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 +102 -19
- package/templates/_shared/journey-manifest.md +110 -0
- 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
|
-
`^- (
|
|
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
|
|
59
|
-
*
|
|
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
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
|
201
|
+
return empty;
|
|
71
202
|
}
|
|
72
203
|
if (!Object.prototype.hasOwnProperty.call(frontmatter, 'mission-status')) {
|
|
73
|
-
return
|
|
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
|
|
209
|
+
return empty;
|
|
79
210
|
}
|
|
80
|
-
|
|
81
|
-
|
|
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 }
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
* the
|
|
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
|
|
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)
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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.
|
|
255
|
-
* populated entries remain
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
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
|
-
|
|
679
|
+
let refused;
|
|
680
|
+
const result = await writeStateMd(
|
|
399
681
|
repoRoot,
|
|
400
|
-
(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
|
+
},
|
|
401
698
|
opts
|
|
402
699
|
);
|
|
700
|
+
return refused ? { ...result, written: false, reason: refused } : result;
|
|
403
701
|
}
|