@pareto4/pp-core 1.11.1 → 1.12.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/plugin.json +1 -1
- package/README.md +31 -0
- package/agents/pp-code-fixer.md +1 -1
- package/agents/pp-debug-session-manager.md +1 -1
- package/agents/pp-debugger.md +1 -1
- package/agents/pp-executor.md +20 -10
- package/agents/pp-intel-updater.md +1 -1
- package/agents/pp-phase-researcher.md +1 -1
- package/agents/pp-plan-checker.md +5 -5
- package/agents/pp-planner.md +10 -24
- package/agents/pp-project-researcher.md +1 -1
- package/agents/pp-research-synthesizer.md +1 -1
- package/agents/pp-ui-researcher.md +1 -1
- package/agents/pp-verifier.md +8 -4
- package/bin/install.js +429 -82
- package/commands/pp-autonomous.md +8 -6
- package/commands/pp-discovery-to-build.md +1 -1
- package/commands/pp-discovery.md +3 -2
- package/commands/pp-discuss-phase.md +2 -2
- package/commands/pp-execute-phase.md +3 -1
- package/commands/pp-graphify.md +5 -5
- package/commands/pp-import.md +1 -1
- package/commands/pp-map-codebase.md +1 -1
- package/commands/pp-ns-project.md +2 -3
- package/commands/pp-ns-workflow.md +1 -1
- package/commands/pp-plan-phase.md +2 -1
- package/commands/pp-verify-work.md +2 -1
- package/gemini-extension.json +1 -1
- package/hooks/dist/pp-cursor-session-start.js +2 -2
- package/hooks/dist/pp-session-state.sh +1 -1
- package/hooks/dist/pp-statusline.js +21 -2
- package/hooks/pp-cursor-session-start.js +2 -2
- package/hooks/pp-session-state.sh +1 -1
- package/hooks/pp-statusline.js +21 -2
- package/package.json +18 -5
- package/pareto-skills/pp-project-setup/SKILL.md +5 -63
- package/pp-core/bin/detect-build-commands.sh +182 -0
- package/pp-core/bin/lib/capability-registry.cjs +65 -7
- package/pp-core/bin/lib/check-command-router.cjs +79 -38
- package/pp-core/bin/lib/clusters.cjs +2 -3
- package/pp-core/bin/lib/command-aliases.cjs +36 -0
- package/pp-core/bin/lib/command-arg-projection.cjs +99 -1
- package/pp-core/bin/lib/commands.cjs +21 -6
- package/pp-core/bin/lib/compose-bindings.cjs +70 -6
- package/pp-core/bin/lib/config-schema.cjs +16 -0
- package/pp-core/bin/lib/config.cjs +142 -27
- package/pp-core/bin/lib/core-utils.cjs +43 -7
- package/pp-core/bin/lib/discovery-input-check.cjs +218 -0
- package/pp-core/bin/lib/discovery-rebaseline.cjs +1372 -0
- package/pp-core/bin/lib/discovery-research-track.cjs +979 -0
- package/pp-core/bin/lib/discovery-rollup.cjs +476 -0
- package/pp-core/bin/lib/discovery-screens.cjs +1995 -0
- package/pp-core/bin/lib/discovery-sweep.cjs +390 -0
- package/pp-core/bin/lib/discovery-table.cjs +98 -0
- package/pp-core/bin/lib/dispatch-plan.cjs +472 -0
- package/pp-core/bin/lib/frontmatter.cjs +17 -2
- package/pp-core/bin/lib/init-command-router.cjs +8 -5
- package/pp-core/bin/lib/init-contract.cjs +93 -4
- package/pp-core/bin/lib/init.cjs +124 -134
- package/pp-core/bin/lib/install-profiles.cjs +5 -0
- package/pp-core/bin/lib/install-run.cjs +536 -0
- package/pp-core/bin/lib/io.cjs +15 -0
- package/pp-core/bin/lib/loop-resolver.cjs +24 -0
- package/pp-core/bin/lib/milestone.cjs +345 -48
- package/pp-core/bin/lib/phase-command-router.cjs +23 -1
- package/pp-core/bin/lib/phase-locator.cjs +15 -6
- package/pp-core/bin/lib/phase-scope.cjs +91 -0
- package/pp-core/bin/lib/phase-tier.cjs +467 -0
- package/pp-core/bin/lib/phase.cjs +247 -21
- package/pp-core/bin/lib/phases-command-router.cjs +2 -0
- package/pp-core/bin/lib/plan-graph.cjs +368 -0
- package/pp-core/bin/lib/planning-workspace.cjs +53 -0
- package/pp-core/bin/lib/platform-bindings.cjs +32 -2
- package/pp-core/bin/lib/profile-output.cjs +11 -3
- package/pp-core/bin/lib/prohibition-enforcement.cjs +112 -0
- package/pp-core/bin/lib/roadmap-parser.cjs +366 -99
- package/pp-core/bin/lib/roadmap.cjs +141 -39
- package/pp-core/bin/lib/runtime-hooks-surface.cjs +1 -1
- package/pp-core/bin/lib/shell-command-projection.cjs +24 -0
- package/pp-core/bin/lib/state-command-router.cjs +82 -13
- package/pp-core/bin/lib/state-document.cjs +124 -7
- package/pp-core/bin/lib/state.cjs +784 -257
- package/pp-core/bin/lib/template.cjs +22 -6
- package/pp-core/bin/lib/vault-command-router.cjs +57 -6
- package/pp-core/bin/lib/vault-practices.cjs +6 -36
- package/pp-core/bin/lib/vault-write-back.cjs +20 -4
- package/pp-core/bin/lib/vault.cjs +555 -23
- package/pp-core/bin/lib/verify.cjs +53 -31
- package/pp-core/bin/lib/workstream.cjs +1 -1
- package/pp-core/bin/lib/worktree-safety.cjs +26 -15
- package/pp-core/bin/portable-timeout.sh +89 -0
- package/pp-core/bin/pp-tools.cjs +149 -9
- package/pp-core/bin/shared/config-defaults.manifest.json +7 -0
- package/pp-core/bin/shared/config-schema.manifest.json +3 -0
- package/pp-core/references/autonomous-best-effort.md +461 -0
- package/pp-core/references/autonomous-lifecycle.md +146 -0
- package/pp-core/references/autonomous-smart-discuss.md +16 -12
- package/pp-core/references/checkpoints.md +1 -1
- package/pp-core/references/discovery-id-conventions.md +202 -0
- package/pp-core/references/discovery-rebaseline.md +254 -0
- package/pp-core/references/discovery-research-track.md +283 -0
- package/pp-core/references/discovery-traceability.md +400 -0
- package/pp-core/references/loop-hook-dispatch.md +7 -0
- package/pp-core/references/mandatory-initial-read.md +1 -1
- package/pp-core/references/planner-gap-closure.md +1 -0
- package/pp-core/references/planner-human-verify-mode.md +1 -1
- package/pp-core/references/planner-load-graph-context.md +1 -1
- package/pp-core/references/planner-plan-graph.md +66 -0
- package/pp-core/references/planner-tdd.md +1 -0
- package/pp-core/references/planning-config.md +8 -0
- package/pp-core/references/sketch-tooling.md +4 -0
- package/pp-core/references/tdd.md +1 -0
- package/pp-core/templates/claude-md.md +1 -1
- package/pp-core/templates/phase-prompt.md +6 -2
- package/pp-core/templates/prototype-review/review.js +303 -0
- package/pp-core/templates/state.md +6 -0
- package/pp-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/pp-core/workflows/add-backlog.md +1 -1
- package/pp-core/workflows/add-phase.md +1 -1
- package/pp-core/workflows/add-tests.md +1 -1
- package/pp-core/workflows/add-todo.md +1 -1
- package/pp-core/workflows/ai-integration-phase.md +2 -2
- package/pp-core/workflows/audit-fix.md +12 -16
- package/pp-core/workflows/audit-milestone.md +1 -1
- package/pp-core/workflows/audit-uat.md +1 -1
- package/pp-core/workflows/autonomous.md +68 -306
- package/pp-core/workflows/check-todos.md +1 -1
- package/pp-core/workflows/cleanup.md +1 -1
- package/pp-core/workflows/code-review-fix.md +1 -1
- package/pp-core/workflows/code-review.md +19 -7
- package/pp-core/workflows/complete-milestone.md +41 -60
- package/pp-core/workflows/debug.md +1 -1
- package/pp-core/workflows/diagnose-issues.md +1 -1
- package/pp-core/workflows/discovery-sprint/gap-analysis.md +140 -0
- package/pp-core/workflows/discovery-sprint/input-package.md +140 -0
- package/pp-core/workflows/discovery-sprint/prefill.md +92 -0
- package/pp-core/workflows/discovery-sprint/prototype.md +257 -0
- package/pp-core/workflows/discovery-sprint/rebaseline.md +276 -0
- package/pp-core/workflows/discovery-sprint/session-13-gates.md +24 -0
- package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +92 -0
- package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +98 -0
- package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +215 -0
- package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +146 -0
- package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +98 -0
- package/pp-core/workflows/discovery-sprint/sessions/06-security.md +107 -0
- package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +98 -0
- package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +85 -0
- package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +109 -0
- package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +119 -0
- package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +92 -0
- package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +98 -0
- package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +205 -0
- package/pp-core/workflows/discovery-sprint/templates/context-template.md +266 -0
- package/pp-core/workflows/discovery-sprint.md +79 -1714
- package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +5 -5
- package/pp-core/workflows/discovery-to-build/steps/screen-inventory-gate.md +70 -0
- package/pp-core/workflows/discovery-to-build/templates/discovery-references.md +54 -0
- package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +184 -0
- package/pp-core/workflows/discovery-to-build.md +21 -238
- package/pp-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/pp-core/workflows/discuss-phase/modes/auto.md +1 -1
- package/pp-core/workflows/discuss-phase/modes/chain.md +1 -1
- package/pp-core/workflows/discuss-phase-assumptions.md +1 -1
- package/pp-core/workflows/discuss-phase.md +9 -2
- package/pp-core/workflows/do.md +4 -4
- package/pp-core/workflows/docs-update.md +1 -1
- package/pp-core/workflows/edit-phase.md +1 -1
- package/pp-core/workflows/eval-review.md +1 -1
- package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +45 -0
- package/pp-core/workflows/execute-phase/steps/client-context-gate.md +1 -1
- package/pp-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +90 -0
- package/pp-core/workflows/execute-phase/steps/install-run.md +29 -0
- package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +23 -0
- package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +76 -77
- package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +72 -0
- package/pp-core/workflows/execute-phase/steps/vault-fetch.md +5 -5
- package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +19 -112
- package/pp-core/workflows/execute-phase.md +102 -216
- package/pp-core/workflows/execute-plan.md +6 -2
- package/pp-core/workflows/explore.md +1 -1
- package/pp-core/workflows/extract-learnings.md +1 -1
- package/pp-core/workflows/forensics.md +4 -4
- package/pp-core/workflows/graduation.md +1 -1
- package/pp-core/workflows/health.md +1 -1
- package/pp-core/workflows/help/modes/brief.md +1 -0
- package/pp-core/workflows/help/modes/default.md +2 -1
- package/pp-core/workflows/help/modes/full.md +12 -5
- package/pp-core/workflows/import.md +1 -1
- package/pp-core/workflows/ingest-docs.md +1 -1
- package/pp-core/workflows/init.md +18 -2
- package/pp-core/workflows/insert-phase.md +1 -1
- package/pp-core/workflows/list-workspaces.md +1 -1
- package/pp-core/workflows/manager.md +3 -3
- package/pp-core/workflows/map-codebase.md +2 -2
- package/pp-core/workflows/milestone-summary.md +4 -4
- package/pp-core/workflows/mvp-phase.md +1 -1
- package/pp-core/workflows/new-milestone.md +7 -8
- package/pp-core/workflows/new-project.md +6 -1
- package/pp-core/workflows/new-workspace.md +1 -1
- package/pp-core/workflows/next.md +2 -2
- package/pp-core/workflows/note.md +2 -2
- package/pp-core/workflows/pause-work.md +1 -1
- package/pp-core/workflows/plan-milestone-gaps.md +1 -1
- package/pp-core/workflows/plan-phase/steps/chunked-planning.md +111 -0
- package/pp-core/workflows/plan-phase/steps/phase-tier.md +34 -0
- package/pp-core/workflows/plan-phase.md +43 -167
- package/pp-core/workflows/plan-review-convergence.md +2 -2
- package/pp-core/workflows/plant-seed.md +1 -1
- package/pp-core/workflows/pr-branch.md +1 -1
- package/pp-core/workflows/profile-user.md +1 -1
- package/pp-core/workflows/progress.md +3 -3
- package/pp-core/workflows/quick.md +3 -1
- package/pp-core/workflows/remove-phase.md +1 -1
- package/pp-core/workflows/remove-workspace.md +1 -1
- package/pp-core/workflows/resume-project.md +6 -12
- package/pp-core/workflows/review.md +1 -1
- package/pp-core/workflows/scan.md +1 -1
- package/pp-core/workflows/secure-phase.md +1 -1
- package/pp-core/workflows/settings-advanced.md +1 -1
- package/pp-core/workflows/settings-integrations.md +1 -1
- package/pp-core/workflows/settings.md +1 -1
- package/pp-core/workflows/ship.md +25 -8
- package/pp-core/workflows/sketch-wrap-up.md +1 -1
- package/pp-core/workflows/sketch.md +2 -1
- package/pp-core/workflows/spec-phase.md +1 -1
- package/pp-core/workflows/spike-wrap-up.md +1 -1
- package/pp-core/workflows/spike.md +1 -1
- package/pp-core/workflows/stats.md +2 -2
- package/pp-core/workflows/thread.md +1 -1
- package/pp-core/workflows/transition.md +5 -9
- package/pp-core/workflows/ui-phase.md +23 -2
- package/pp-core/workflows/ui-review.md +1 -1
- package/pp-core/workflows/ultraplan-phase.md +2 -2
- package/pp-core/workflows/validate-phase.md +1 -1
- package/pp-core/workflows/vault-write-back.md +1 -1
- package/pp-core/workflows/verify-phase.md +29 -26
- package/pp-core/workflows/verify-work.md +14 -10
- package/scripts/base64-scan.sh +7 -41
- package/scripts/changeset/cli.cjs +25 -5
- package/scripts/dev-env.cjs +462 -0
- package/scripts/lib/allowlist-ratchet.cjs +103 -1
- package/scripts/lib/size-budget.cjs +311 -0
- package/scripts/lint-neutrality-names.cjs +694 -0
- package/scripts/lint-org-coordinates.allowlist.json +35 -0
- package/scripts/lint-org-coordinates.cjs +426 -0
- package/scripts/lint-skill-deps.cjs +48 -9
- package/scripts/lint-test-file-count.cjs +17 -2
- package/scripts/prompt-injection-scan.sh +41 -20
- package/scripts/sync-manifest-versions.cjs +41 -7
- package/scripts/sync-runtime-launcher.cjs +106 -1
- package/scripts/update-size-baseline.cjs +274 -30
- package/scripts/workflow-size.cjs +61 -9
- package/commands/pp-build-handoff.md +0 -46
- package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +0 -94
|
@@ -19,10 +19,10 @@ const configLoaderMod = require("./config-loader.cjs");
|
|
|
19
19
|
const { loadConfig } = configLoaderMod;
|
|
20
20
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
21
21
|
const phaseIdMod = require("./phase-id.cjs");
|
|
22
|
-
const { escapeRegex } = phaseIdMod;
|
|
22
|
+
const { escapeRegex, normalizePhaseName } = phaseIdMod;
|
|
23
23
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
24
24
|
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
25
|
-
const {
|
|
25
|
+
const { getDeclaredMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone, getClosedMilestonePhaseDirFilter } = roadmapParserMod;
|
|
26
26
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
27
27
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
28
28
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
@@ -33,6 +33,9 @@ const frontmatter = require("./frontmatter.cjs");
|
|
|
33
33
|
const { extractFrontmatter, reconstructFrontmatter } = frontmatter;
|
|
34
34
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
35
35
|
const scanPhasePlans = require("./plan-scan.cjs");
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
37
|
+
const phaseLifecycleMod = require("./phase-lifecycle.cjs");
|
|
38
|
+
const { deriveProgressFromRoadmap } = phaseLifecycleMod;
|
|
36
39
|
const state_document_cjs_1 = require("./state-document.cjs");
|
|
37
40
|
const STATE_PROGRESS_RESYNC_FIELDS = new Set([
|
|
38
41
|
'Progress',
|
|
@@ -73,8 +76,13 @@ function cmdStateLoad(cwd, raw) {
|
|
|
73
76
|
const configExists = node_fs_1.default.existsSync(node_path_1.default.join(planDir, 'config.json'));
|
|
74
77
|
const roadmapExists = node_fs_1.default.existsSync(node_path_1.default.join(planDir, 'ROADMAP.md'));
|
|
75
78
|
const stateExists = stateRaw.length > 0;
|
|
79
|
+
// Pack 6 D-07: the emitted config is display-safe for its remote coordinate
|
|
80
|
+
// key (config.cjs's projection); `config` itself stays raw. Lazily required
|
|
81
|
+
// so this module's load graph is unchanged.
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
83
|
+
const { redactConfigCoordinates } = require('./config.cjs');
|
|
76
84
|
const result = {
|
|
77
|
-
config,
|
|
85
|
+
config: redactConfigCoordinates('', config),
|
|
78
86
|
state_raw: stateRaw,
|
|
79
87
|
state_exists: stateExists,
|
|
80
88
|
roadmap_exists: roadmapExists,
|
|
@@ -172,20 +180,26 @@ function cmdStatePatch(cwd, patches, raw) {
|
|
|
172
180
|
const results = { updated: [], failed: [] };
|
|
173
181
|
const shouldResync = shouldResyncStateProgress(Object.keys(patches));
|
|
174
182
|
// Use atomic read-modify-write to prevent lost updates from concurrent agents
|
|
175
|
-
readModifyWriteStateMd(statePath, (content) => {
|
|
183
|
+
const fmReport = readModifyWriteStateMd(statePath, (content) => {
|
|
184
|
+
// Patch the BODY only, as cmdStateUpdate does. The plain-line pattern is
|
|
185
|
+
// case-insensitive, so on the whole file `Status` matched the frontmatter
|
|
186
|
+
// `status:` line first; the re-derivation then restored it from the untouched
|
|
187
|
+
// body, and the verb reported a write it never made.
|
|
188
|
+
let body = stripFrontmatter(content);
|
|
189
|
+
const head = content.slice(0, content.length - body.length);
|
|
176
190
|
for (const [field, value] of Object.entries(patches)) {
|
|
177
|
-
const result = (0, state_document_cjs_1.stateReplaceField)(
|
|
191
|
+
const result = (0, state_document_cjs_1.stateReplaceField)(body, field, value);
|
|
178
192
|
if (result) {
|
|
179
|
-
|
|
193
|
+
body = result;
|
|
180
194
|
results.updated.push(field);
|
|
181
195
|
}
|
|
182
196
|
else {
|
|
183
197
|
results.failed.push(field);
|
|
184
198
|
}
|
|
185
199
|
}
|
|
186
|
-
return
|
|
200
|
+
return head + body;
|
|
187
201
|
}, cwd, { resync: shouldResync });
|
|
188
|
-
output(results, raw, results.updated.length > 0 ? 'true' : 'false');
|
|
202
|
+
output({ ...results, frontmatter_updated: fmReport.frontmatter_updated }, raw, results.updated.length > 0 ? 'true' : 'false');
|
|
189
203
|
}
|
|
190
204
|
catch {
|
|
191
205
|
error('STATE.md not found');
|
|
@@ -208,7 +222,7 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
208
222
|
const shouldResync = shouldResyncStateProgress([field]);
|
|
209
223
|
// Preserve curated progress for body-only updates, but allow fields that
|
|
210
224
|
// directly project into progress.* frontmatter to rebuild after mutation.
|
|
211
|
-
readModifyWriteStateMd(statePath, (content) => {
|
|
225
|
+
const fmReport = readModifyWriteStateMd(statePath, (content) => {
|
|
212
226
|
const body = stripFrontmatter(content);
|
|
213
227
|
const result = (0, state_document_cjs_1.stateReplaceField)(body, field, value);
|
|
214
228
|
if (result) {
|
|
@@ -222,10 +236,10 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
222
236
|
return content;
|
|
223
237
|
}, cwd, { resync: shouldResync });
|
|
224
238
|
if (updated) {
|
|
225
|
-
output({ updated: true }, false, undefined);
|
|
239
|
+
output({ updated: true, frontmatter_updated: fmReport.frontmatter_updated }, false, undefined);
|
|
226
240
|
}
|
|
227
241
|
else {
|
|
228
|
-
output({ updated: false, reason: `Field "${field}" not found in STATE.md
|
|
242
|
+
output({ updated: false, reason: `Field "${field}" not found in STATE.md`, frontmatter_updated: fmReport.frontmatter_updated }, false, undefined);
|
|
229
243
|
}
|
|
230
244
|
}
|
|
231
245
|
catch {
|
|
@@ -349,7 +363,7 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
349
363
|
}
|
|
350
364
|
const today = clock_cjs_1.realClock.today();
|
|
351
365
|
let result = null;
|
|
352
|
-
readModifyWriteStateMd(statePath, (content) => {
|
|
366
|
+
const fmReport = readModifyWriteStateMd(statePath, (content) => {
|
|
353
367
|
// Try legacy separate fields first, then compound "Plan: X of Y" format
|
|
354
368
|
const legacyPlan = (0, state_document_cjs_1.stateExtractField)(content, 'Current Plan');
|
|
355
369
|
const legacyTotal = (0, state_document_cjs_1.stateExtractField)(content, 'Total Plans in Phase');
|
|
@@ -410,15 +424,121 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
410
424
|
return content;
|
|
411
425
|
}, cwd);
|
|
412
426
|
if (!result || result['error']) {
|
|
413
|
-
output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
|
|
427
|
+
output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md', frontmatter_updated: fmReport.frontmatter_updated }, raw, undefined);
|
|
414
428
|
return;
|
|
415
429
|
}
|
|
430
|
+
const advanceResult = { ...result, frontmatter_updated: fmReport.frontmatter_updated };
|
|
416
431
|
if (result['advanced'] === false) {
|
|
417
|
-
output(
|
|
432
|
+
output(advanceResult, raw, 'false');
|
|
418
433
|
}
|
|
419
434
|
else {
|
|
420
|
-
output(
|
|
435
|
+
output(advanceResult, raw, 'true');
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// ─── Section placeholders (261008-mwk follow-up) ─────────────────────────────
|
|
439
|
+
// A markdown table's header row and separator row, each matched within its own
|
|
440
|
+
// line — the one owner for the writer (record-metric) and the reader
|
|
441
|
+
// (state-snapshot's Decisions Made table). The separator class is `[-|: \t]`,
|
|
442
|
+
// never `[-|\s]`: a class that admits `\n` ran past the blank line under an
|
|
443
|
+
// empty table, so the "table body" began at the NEXT section. record-metric then
|
|
444
|
+
// wrote its row into that section (and, with `None yet` there, deleted the
|
|
445
|
+
// section's heading), and state-snapshot read the next section's table rows as
|
|
446
|
+
// decisions.
|
|
447
|
+
const TABLE_SEPARATOR_ROW = String.raw `\|[-|: \t]+`;
|
|
448
|
+
const TABLE_HEAD_ROWS = String.raw `\n\|[^\n]+\n${TABLE_SEPARATOR_ROW}\n`;
|
|
449
|
+
const TABLE_SEPARATOR_LINE_RE = new RegExp(String.raw `^${TABLE_SEPARATOR_ROW}\s*$`);
|
|
450
|
+
// ─── record-metric's table (261008-mwk follow-up 2) ──────────────────────────
|
|
451
|
+
// The canonical per-plan metrics table: the STATE.md template carries it under
|
|
452
|
+
// `**By Plan:**`, and record-metric's section-absent scaffold writes it.
|
|
453
|
+
const METRICS_TABLE_HEADER = '| Plan | Duration | Tasks | Files |';
|
|
454
|
+
const METRICS_TABLE_SEPARATOR = '|------|----------|-------|-------|';
|
|
455
|
+
function tableCells(line) {
|
|
456
|
+
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
|
|
457
|
+
}
|
|
458
|
+
// One row in the table's OWN column shape, derived from its header: a column is
|
|
459
|
+
// filled only when its header names a value record-metric holds (Phase, Plan,
|
|
460
|
+
// Duration, Tasks, Files); any other column gets `-`. When only one of Phase/Plan
|
|
461
|
+
// exists it carries the `Phase N PM` identifier (the upstream row convention).
|
|
462
|
+
function metricRowFor(header, m) {
|
|
463
|
+
const names = header.map((h) => h.toLowerCase());
|
|
464
|
+
const id = `Phase ${m.phase} P${m.plan}`;
|
|
465
|
+
const hasPhase = names.includes('phase');
|
|
466
|
+
const hasPlan = names.includes('plan');
|
|
467
|
+
const cells = names.map((n) => {
|
|
468
|
+
if (n === 'phase')
|
|
469
|
+
return hasPlan ? m.phase : id;
|
|
470
|
+
if (n === 'plan')
|
|
471
|
+
return hasPhase ? m.plan : id;
|
|
472
|
+
if (n === 'duration')
|
|
473
|
+
return m.duration;
|
|
474
|
+
if (n === 'tasks')
|
|
475
|
+
return `${m.tasks || '-'} tasks`;
|
|
476
|
+
if (n === 'files')
|
|
477
|
+
return `${m.files || '-'} files`;
|
|
478
|
+
return '-';
|
|
479
|
+
});
|
|
480
|
+
return `| ${cells.join(' | ')} |`;
|
|
481
|
+
}
|
|
482
|
+
// Finds record-metric's table: the first table under `## Performance Metrics`
|
|
483
|
+
// whose header has a Duration column. Its body is the run of `|` rows (and any
|
|
484
|
+
// placeholder lines) directly under the separator — never prose that follows.
|
|
485
|
+
// When the section has no such table, `insertAt` is where the By Plan table is
|
|
486
|
+
// created: right after the section's last table, or under the heading if the
|
|
487
|
+
// section has none (D-9 revised).
|
|
488
|
+
function locateMetricsTable(content) {
|
|
489
|
+
const lines = content.split('\n');
|
|
490
|
+
const start = lines.findIndex((l) => /^#{2,3}\s*Performance Metrics\s*$/i.test(l.trimEnd()));
|
|
491
|
+
if (start === -1)
|
|
492
|
+
return { kind: 'no-section' };
|
|
493
|
+
const level = lines[start].match(/^#+/)[0].length;
|
|
494
|
+
let end = lines.length;
|
|
495
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
496
|
+
const h = lines[i].match(/^(#{1,6})\s/);
|
|
497
|
+
if (h && h[1].length <= level) {
|
|
498
|
+
end = i;
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
421
501
|
}
|
|
502
|
+
let insertAt = start + 1;
|
|
503
|
+
for (let i = start + 1; i + 1 < end; i++) {
|
|
504
|
+
if (!lines[i].trimStart().startsWith('|') || !TABLE_SEPARATOR_LINE_RE.test(lines[i + 1].trim()))
|
|
505
|
+
continue;
|
|
506
|
+
const header = tableCells(lines[i]);
|
|
507
|
+
let j = i + 2;
|
|
508
|
+
while (j < end && (lines[j].trimStart().startsWith('|') || (lines[j].trim() !== '' && isPlaceholderLine(lines[j]))))
|
|
509
|
+
j++;
|
|
510
|
+
if (header.some((h) => h.toLowerCase() === 'duration'))
|
|
511
|
+
return { kind: 'table', lines, header, bodyStart: i + 2, bodyEnd: j };
|
|
512
|
+
insertAt = j;
|
|
513
|
+
i = j - 1;
|
|
514
|
+
}
|
|
515
|
+
return { kind: 'no-table', lines, insertAt };
|
|
516
|
+
}
|
|
517
|
+
// The one owner of "this line is a template placeholder, not an entry". A line
|
|
518
|
+
// counts only when the WHOLE line (an optional `- ` / `* ` list marker aside) is
|
|
519
|
+
// `None`, `None yet` or `No decisions yet`, with an optional trailing period.
|
|
520
|
+
// Before this owner, add-blocker, add-decision, add-roadmap-evolution and
|
|
521
|
+
// record-metric each matched those words anywhere in the section: `None yet.`
|
|
522
|
+
// became a leftover `yet.` line, `- None yet` left a lone `-`, an existing entry
|
|
523
|
+
// such as `- Nonexistent endpoint returns 500` was rewritten to `- xistent …`,
|
|
524
|
+
// and record-metric dropped every row of a table in which any row mentioned
|
|
525
|
+
// `None yet`.
|
|
526
|
+
const SECTION_PLACEHOLDER_RE = /^\s*(?:[-*]\s+)?(?:none(?:\s+yet)?|no decisions yet)\.?\s*$/i;
|
|
527
|
+
function isPlaceholderLine(line) {
|
|
528
|
+
return SECTION_PLACEHOLDER_RE.test(line);
|
|
529
|
+
}
|
|
530
|
+
// A table row is a placeholder when at least one cell is a placeholder and every
|
|
531
|
+
// other cell is empty or `-` (e.g. `| None yet | | | |`).
|
|
532
|
+
function isPlaceholderTableRow(line) {
|
|
533
|
+
const t = line.trim();
|
|
534
|
+
if (!t.startsWith('|'))
|
|
535
|
+
return false;
|
|
536
|
+
const cells = t.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
|
|
537
|
+
return cells.some(isPlaceholderLine) && cells.every((c) => c === '' || c === '-' || isPlaceholderLine(c));
|
|
538
|
+
}
|
|
539
|
+
// Remove placeholder lines whole; every other line is kept byte for byte.
|
|
540
|
+
function stripPlaceholderLines(body) {
|
|
541
|
+
return body.split('\n').filter((line) => !isPlaceholderLine(line) && !isPlaceholderTableRow(line)).join('\n');
|
|
422
542
|
}
|
|
423
543
|
function cmdStateRecordMetric(cwd, options, raw) {
|
|
424
544
|
const statePath = planningPaths(cwd).state;
|
|
@@ -431,44 +551,61 @@ function cmdStateRecordMetric(cwd, options, raw) {
|
|
|
431
551
|
output({ error: 'phase, plan, and duration required' }, raw, undefined);
|
|
432
552
|
return;
|
|
433
553
|
}
|
|
434
|
-
let
|
|
554
|
+
let recorded = false;
|
|
435
555
|
let created = false;
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
const
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
556
|
+
let tableCreated = false;
|
|
557
|
+
const fmReport = readModifyWriteStateMd(statePath, (content) => {
|
|
558
|
+
const place = locateMetricsTable(content);
|
|
559
|
+
const metric = { phase, plan, duration, tasks, files };
|
|
560
|
+
if (place.kind === 'table') {
|
|
561
|
+
// 261008-mwk follow-up 2: the row goes INSIDE the table, in the table's own
|
|
562
|
+
// column shape; placeholder rows in the body are dropped, every other row kept.
|
|
563
|
+
const body = place.lines.slice(place.bodyStart, place.bodyEnd).filter((l) => !isPlaceholderLine(l) && !isPlaceholderTableRow(l));
|
|
564
|
+
const next = [
|
|
565
|
+
...place.lines.slice(0, place.bodyStart),
|
|
566
|
+
...body,
|
|
567
|
+
metricRowFor(place.header, metric),
|
|
568
|
+
...place.lines.slice(place.bodyEnd),
|
|
569
|
+
];
|
|
570
|
+
recorded = true;
|
|
571
|
+
return next.join('\n');
|
|
572
|
+
}
|
|
573
|
+
if (place.kind === 'no-table') {
|
|
574
|
+
// D-9 revised: a Performance Metrics section with no Duration table (e.g. a
|
|
575
|
+
// STATE.md from the template before it carried `**By Plan:**`) gets the same
|
|
576
|
+
// By Plan table the template and the section-absent scaffold carry, placed after
|
|
577
|
+
// the section's last table (or under the heading) — never after prose, never
|
|
578
|
+
// in another table's shape.
|
|
579
|
+
const block = ['', '**By Plan:**', '', METRICS_TABLE_HEADER, METRICS_TABLE_SEPARATOR, metricRowFor(tableCells(METRICS_TABLE_HEADER), metric)];
|
|
580
|
+
const following = place.lines[place.insertAt];
|
|
581
|
+
if (following !== undefined && following.trim() !== '')
|
|
582
|
+
block.push('');
|
|
583
|
+
recorded = true;
|
|
584
|
+
tableCreated = true;
|
|
585
|
+
return [...place.lines.slice(0, place.insertAt), ...block, ...place.lines.slice(place.insertAt)].join('\n');
|
|
586
|
+
}
|
|
587
|
+
// Section absent — DWIM: auto-create the canonical ## Performance Metrics
|
|
588
|
+
// scaffold, then append the row. Matches state begin-phase / advance-plan DWIM behavior.
|
|
454
589
|
const scaffold = [
|
|
455
590
|
'',
|
|
456
591
|
'## Performance Metrics',
|
|
457
592
|
'',
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
593
|
+
METRICS_TABLE_HEADER,
|
|
594
|
+
METRICS_TABLE_SEPARATOR,
|
|
595
|
+
metricRowFor(tableCells(METRICS_TABLE_HEADER), metric),
|
|
461
596
|
'',
|
|
462
597
|
].join('\n');
|
|
463
|
-
|
|
598
|
+
recorded = true;
|
|
464
599
|
created = true;
|
|
465
600
|
return content.trimEnd() + '\n' + scaffold;
|
|
466
601
|
}, cwd);
|
|
467
|
-
|
|
468
|
-
|
|
602
|
+
const result = { recorded, phase, plan, duration };
|
|
603
|
+
if (tableCreated)
|
|
604
|
+
result['table_created'] = true;
|
|
469
605
|
if (created)
|
|
470
606
|
result['created'] = true;
|
|
471
|
-
|
|
607
|
+
result['frontmatter_updated'] = fmReport.frontmatter_updated;
|
|
608
|
+
output(result, raw, recorded ? 'true' : 'false');
|
|
472
609
|
}
|
|
473
610
|
function cmdStateUpdateProgress(cwd, raw) {
|
|
474
611
|
const statePath = planningPaths(cwd).state;
|
|
@@ -476,20 +613,23 @@ function cmdStateUpdateProgress(cwd, raw) {
|
|
|
476
613
|
output({ error: 'STATE.md not found' }, raw, undefined);
|
|
477
614
|
return;
|
|
478
615
|
}
|
|
479
|
-
// Count summaries across current milestone phases only (outside lock — read-only)
|
|
616
|
+
// Count summaries across current milestone phases only (outside lock — read-only).
|
|
617
|
+
// 261007-ixt D-9: from the same milestone scan as the frontmatter block, so a closed
|
|
618
|
+
// milestone's leftover directories are not counted here either. When every directory
|
|
619
|
+
// belongs to a closed milestone, the disk says nothing about the current one and the
|
|
620
|
+
// bar is left as it is.
|
|
480
621
|
const phasesDir = planningPaths(cwd).phases;
|
|
481
622
|
let totalPlans = 0;
|
|
482
623
|
let totalSummaries = 0;
|
|
483
624
|
if (node_fs_1.default.existsSync(phasesDir)) {
|
|
484
|
-
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
const { planCount, summaryCount } = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
|
|
490
|
-
totalPlans += planCount;
|
|
491
|
-
totalSummaries += summaryCount;
|
|
625
|
+
_diskScanCache.delete(cwd);
|
|
626
|
+
const scan = scanMilestoneDisk(cwd);
|
|
627
|
+
if (scan === CLOSED_MILESTONE_ONLY) {
|
|
628
|
+
output({ updated: false, reason: 'No phase directory belongs to the current milestone', frontmatter_updated: [] }, raw, 'false');
|
|
629
|
+
return;
|
|
492
630
|
}
|
|
631
|
+
totalPlans = scan ? scan.totalPlans : 0;
|
|
632
|
+
totalSummaries = scan ? scan.completedPlans : 0;
|
|
493
633
|
}
|
|
494
634
|
const percent = totalPlans > 0 ? Math.min(100, Math.round(totalSummaries / totalPlans * 100)) : 0;
|
|
495
635
|
const barWidth = 10;
|
|
@@ -499,25 +639,31 @@ function cmdStateUpdateProgress(cwd, raw) {
|
|
|
499
639
|
let updated = false;
|
|
500
640
|
const _totalPlans = totalPlans;
|
|
501
641
|
const _totalSummaries = totalSummaries;
|
|
502
|
-
readModifyWriteStateMd(statePath, (content) => {
|
|
642
|
+
const fmReport = readModifyWriteStateMd(statePath, (content) => {
|
|
643
|
+
// 261007-ixt: `Progress:` is a BODY field. On the whole file the case-insensitive
|
|
644
|
+
// multi-line plain pattern matched the YAML `progress:` key first and replaced it
|
|
645
|
+
// with the bar — deleting the block's first key (`total_phases`) — while the body
|
|
646
|
+
// was untouched and the result said `updated: true`. Edit the body only.
|
|
647
|
+
const body = stripFrontmatter(content);
|
|
648
|
+
const head = content.slice(0, content.length - body.length);
|
|
503
649
|
// Try **Progress:** bold format first, then plain Progress: format
|
|
504
650
|
const boldProgressPattern = /(\*\*Progress:\*\*\s*).*/i;
|
|
505
|
-
const plainProgressPattern = /^(Progress
|
|
506
|
-
if (boldProgressPattern.test(
|
|
651
|
+
const plainProgressPattern = /^(Progress:[ \t]*).*/im;
|
|
652
|
+
if (boldProgressPattern.test(body)) {
|
|
507
653
|
updated = true;
|
|
508
|
-
return
|
|
654
|
+
return head + body.replace(boldProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
|
|
509
655
|
}
|
|
510
|
-
else if (plainProgressPattern.test(
|
|
656
|
+
else if (plainProgressPattern.test(body)) {
|
|
511
657
|
updated = true;
|
|
512
|
-
return
|
|
658
|
+
return head + body.replace(plainProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
|
|
513
659
|
}
|
|
514
660
|
return content;
|
|
515
661
|
}, cwd);
|
|
516
662
|
if (updated) {
|
|
517
|
-
output({ updated: true, percent, completed: _totalSummaries, total: _totalPlans, bar: progressStr }, raw, progressStr);
|
|
663
|
+
output({ updated: true, percent, completed: _totalSummaries, total: _totalPlans, bar: progressStr, frontmatter_updated: fmReport.frontmatter_updated }, raw, progressStr);
|
|
518
664
|
}
|
|
519
665
|
else {
|
|
520
|
-
output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false');
|
|
666
|
+
output({ updated: false, reason: 'Progress field not found in STATE.md', frontmatter_updated: fmReport.frontmatter_updated }, raw, 'false');
|
|
521
667
|
}
|
|
522
668
|
}
|
|
523
669
|
function cmdStateAddDecision(cwd, options, raw) {
|
|
@@ -544,14 +690,14 @@ function cmdStateAddDecision(cwd, options, raw) {
|
|
|
544
690
|
const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
|
|
545
691
|
let _added = false;
|
|
546
692
|
let created = false;
|
|
547
|
-
readModifyWriteStateMd(statePath, (content) => {
|
|
693
|
+
const fmReport = readModifyWriteStateMd(statePath, (content) => {
|
|
548
694
|
// Find Decisions section (various heading patterns)
|
|
549
695
|
const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
|
|
550
696
|
const match = content.match(sectionPattern);
|
|
551
697
|
if (match) {
|
|
552
698
|
let sectionBody = match[2];
|
|
553
699
|
// Remove placeholders
|
|
554
|
-
sectionBody = sectionBody
|
|
700
|
+
sectionBody = stripPlaceholderLines(sectionBody);
|
|
555
701
|
sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
|
|
556
702
|
_added = true;
|
|
557
703
|
return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
|
|
@@ -573,6 +719,7 @@ function cmdStateAddDecision(cwd, options, raw) {
|
|
|
573
719
|
const result = { added: true, decision: entry };
|
|
574
720
|
if (created)
|
|
575
721
|
result['created'] = true;
|
|
722
|
+
result['frontmatter_updated'] = fmReport.frontmatter_updated;
|
|
576
723
|
output(result, raw, 'true');
|
|
577
724
|
}
|
|
578
725
|
function cmdStateAddBlocker(cwd, text, raw) {
|
|
@@ -597,12 +744,12 @@ function cmdStateAddBlocker(cwd, text, raw) {
|
|
|
597
744
|
const entry = `- ${blockerText}`;
|
|
598
745
|
let _added = false;
|
|
599
746
|
let created = false;
|
|
600
|
-
readModifyWriteStateMd(statePath, (content) => {
|
|
747
|
+
const fmReport = readModifyWriteStateMd(statePath, (content) => {
|
|
601
748
|
const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
|
|
602
749
|
const match = content.match(sectionPattern);
|
|
603
750
|
if (match) {
|
|
604
751
|
let sectionBody = match[2];
|
|
605
|
-
sectionBody = sectionBody
|
|
752
|
+
sectionBody = stripPlaceholderLines(sectionBody);
|
|
606
753
|
sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
|
|
607
754
|
_added = true;
|
|
608
755
|
return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
|
|
@@ -623,6 +770,7 @@ function cmdStateAddBlocker(cwd, text, raw) {
|
|
|
623
770
|
const result = { added: true, blocker: blockerText };
|
|
624
771
|
if (created)
|
|
625
772
|
result['created'] = true;
|
|
773
|
+
result['frontmatter_updated'] = fmReport.frontmatter_updated;
|
|
626
774
|
output(result, raw, 'true');
|
|
627
775
|
}
|
|
628
776
|
function cmdStateAddRoadmapEvolution(cwd, options, raw) {
|
|
@@ -666,7 +814,7 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
|
|
|
666
814
|
// Section boundaries mirror the sibling handlers (add-decision/add-blocker):
|
|
667
815
|
// a trailing CR on a CRLF STATE.md is absorbed by the lazy body and trimmed,
|
|
668
816
|
// so following sections are preserved without data loss (see the CRLF test).
|
|
669
|
-
readModifyWriteStateMd(statePath, (content) => {
|
|
817
|
+
const fmReport = readModifyWriteStateMd(statePath, (content) => {
|
|
670
818
|
const accPattern = /(##\s*Accumulated Context\s*\n)([\s\S]*?)(?=\n##[^#]|$)/i;
|
|
671
819
|
const accMatch = content.match(accPattern);
|
|
672
820
|
if (accMatch) {
|
|
@@ -683,7 +831,7 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
|
|
|
683
831
|
duplicate = true;
|
|
684
832
|
return content;
|
|
685
833
|
}
|
|
686
|
-
subBody = subBody
|
|
834
|
+
subBody = stripPlaceholderLines(subBody);
|
|
687
835
|
subBody = subBody.trimEnd() + '\n' + entry + '\n';
|
|
688
836
|
const newAccBody = accBody.replace(subPattern, (_m, header) => `${header}${subBody}`);
|
|
689
837
|
return content.replace(accPattern, () => `${accHeader}${newAccBody}`);
|
|
@@ -710,7 +858,7 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
|
|
|
710
858
|
return content.trimEnd() + '\n' + scaffold;
|
|
711
859
|
}, cwd);
|
|
712
860
|
if (duplicate) {
|
|
713
|
-
output({ added: false, reason: 'duplicate', entry }, raw, 'false');
|
|
861
|
+
output({ added: false, reason: 'duplicate', entry, frontmatter_updated: fmReport.frontmatter_updated }, raw, 'false');
|
|
714
862
|
return;
|
|
715
863
|
}
|
|
716
864
|
const result = { added: true, entry };
|
|
@@ -718,6 +866,7 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
|
|
|
718
866
|
result['created'] = true;
|
|
719
867
|
if (subsectionCreated)
|
|
720
868
|
result['subsection_created'] = true;
|
|
869
|
+
result['frontmatter_updated'] = fmReport.frontmatter_updated;
|
|
721
870
|
output(result, raw, 'true');
|
|
722
871
|
}
|
|
723
872
|
function cmdStateResolveBlocker(cwd, text, raw) {
|
|
@@ -731,7 +880,7 @@ function cmdStateResolveBlocker(cwd, text, raw) {
|
|
|
731
880
|
return;
|
|
732
881
|
}
|
|
733
882
|
let resolved = false;
|
|
734
|
-
readModifyWriteStateMd(statePath, (content) => {
|
|
883
|
+
const fmReport = readModifyWriteStateMd(statePath, (content) => {
|
|
735
884
|
const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
|
|
736
885
|
const match = content.match(sectionPattern);
|
|
737
886
|
if (match) {
|
|
@@ -753,10 +902,10 @@ function cmdStateResolveBlocker(cwd, text, raw) {
|
|
|
753
902
|
return content;
|
|
754
903
|
}, cwd);
|
|
755
904
|
if (resolved) {
|
|
756
|
-
output({ resolved: true, blocker: text }, raw, 'true');
|
|
905
|
+
output({ resolved: true, blocker: text, frontmatter_updated: fmReport.frontmatter_updated }, raw, 'true');
|
|
757
906
|
}
|
|
758
907
|
else {
|
|
759
|
-
output({ resolved: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
|
|
908
|
+
output({ resolved: false, reason: 'Blockers section not found in STATE.md', frontmatter_updated: fmReport.frontmatter_updated }, raw, 'false');
|
|
760
909
|
}
|
|
761
910
|
}
|
|
762
911
|
function cmdStateRecordSession(cwd, options, raw) {
|
|
@@ -768,7 +917,7 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
768
917
|
const now = clock_cjs_1.realClock.nowIso();
|
|
769
918
|
const updated = [];
|
|
770
919
|
let sessionCreated = false;
|
|
771
|
-
readModifyWriteStateMd(statePath, (content) => {
|
|
920
|
+
const fmReport = readModifyWriteStateMd(statePath, (content) => {
|
|
772
921
|
// Update Last session / Last Date
|
|
773
922
|
let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last session', now);
|
|
774
923
|
if (result) {
|
|
@@ -920,15 +1069,16 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
920
1069
|
updated.push('Resume File');
|
|
921
1070
|
}
|
|
922
1071
|
return content;
|
|
923
|
-
}, cwd);
|
|
1072
|
+
}, cwd, { stoppedAtSetByCaller: !!options.stopped_at });
|
|
924
1073
|
if (updated.length > 0) {
|
|
925
1074
|
const result = { recorded: true, updated };
|
|
926
1075
|
if (sessionCreated)
|
|
927
1076
|
result['created'] = true;
|
|
1077
|
+
result['frontmatter_updated'] = fmReport.frontmatter_updated;
|
|
928
1078
|
output(result, raw, 'true');
|
|
929
1079
|
}
|
|
930
1080
|
else {
|
|
931
|
-
output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
|
|
1081
|
+
output({ recorded: false, reason: 'No session fields found in STATE.md', frontmatter_updated: fmReport.frontmatter_updated }, raw, 'false');
|
|
932
1082
|
}
|
|
933
1083
|
}
|
|
934
1084
|
/**
|
|
@@ -941,6 +1091,28 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
941
1091
|
* excludes `## Session Continuity Archive` (preserving the #2444 scoping).
|
|
942
1092
|
* Returns the match whose group 1 is the section body, or null.
|
|
943
1093
|
*/
|
|
1094
|
+
/**
|
|
1095
|
+
* Replace ONE Session-block field's value on its own line (13-REVIEW WR-05).
|
|
1096
|
+
* `stateReplaceField`'s patterns use `\s*` after the colon, which also matches
|
|
1097
|
+
* a newline: with an empty `Stopped at:` it overwrote the NEXT line
|
|
1098
|
+
* (`Resume file: …`), and the following reset then found no `Resume file:` at
|
|
1099
|
+
* all. Here the gap is `[ \t]*` and the value `.*`, so a match never leaves
|
|
1100
|
+
* its line; an empty value gains one space. Bold (`**Field:**`) first, then the
|
|
1101
|
+
* plain line-start form — the same two forms, in the same order, the shared
|
|
1102
|
+
* helper reads. `fieldName` is a fixed literal at every call site.
|
|
1103
|
+
*/
|
|
1104
|
+
function replaceSessionLine(section, fieldName, value) {
|
|
1105
|
+
const escaped = escapeRegex(fieldName);
|
|
1106
|
+
for (const pattern of [
|
|
1107
|
+
new RegExp(`(\\*\\*${escaped}:\\*\\*)([ \\t]*)(.*)`, 'i'),
|
|
1108
|
+
new RegExp(`^(${escaped}:)([ \\t]*)(.*)$`, 'im'),
|
|
1109
|
+
]) {
|
|
1110
|
+
if (pattern.test(section)) {
|
|
1111
|
+
return section.replace(pattern, (_m, label, gap) => `${label}${gap || ' '}${value}`);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return null;
|
|
1115
|
+
}
|
|
944
1116
|
function matchSessionSection(body) {
|
|
945
1117
|
return body.match(/(?:^|\n)##[ \t]*Session[ \t]*\n([\s\S]*?)(?=\n##|$)/i)
|
|
946
1118
|
|| body.match(/(?:^|\n)##[ \t]*Session Continuity[ \t]*\n([\s\S]*?)(?=\n##|$)/i);
|
|
@@ -948,7 +1120,15 @@ function matchSessionSection(body) {
|
|
|
948
1120
|
function parseProsePhaseField(value) {
|
|
949
1121
|
if (!value)
|
|
950
1122
|
return { phase: null, name: null };
|
|
951
|
-
|
|
1123
|
+
// 261007-ixt D-2: a phase id is read only from the START of the value (optionally
|
|
1124
|
+
// after the word `Phase`) — the forms the engine writes (`11 (name) — EXECUTING`,
|
|
1125
|
+
// `08 — COMPLETE`). The unanchored search took the `1` out of `Milestone v1.1
|
|
1126
|
+
// complete`, the line `milestone complete` itself writes, and rewound the pointer
|
|
1127
|
+
// ten phases. A version string is not a phase id; anything that does not start
|
|
1128
|
+
// with one is prose, so the name is refused too and the stored values stand.
|
|
1129
|
+
const phaseMatch = value.match(/^\s*(?:phase\s+)?(\d+[A-Z]?(?:\.\d+)*)\b/i);
|
|
1130
|
+
if (!phaseMatch)
|
|
1131
|
+
return { phase: null, name: null };
|
|
952
1132
|
const parenName = value.match(/\(([^)]+)\)/);
|
|
953
1133
|
const dashName = value.match(/—\s*([^(\n]+?)(?:\s*\(|$)/);
|
|
954
1134
|
const rawName = parenName?.[1] ?? dashName?.[1] ?? null;
|
|
@@ -1018,7 +1198,7 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
1018
1198
|
const progressPercent = progressRaw ? parseInt(progressRaw.replace('%', ''), 10) : null;
|
|
1019
1199
|
// Extract decisions table
|
|
1020
1200
|
const decisions = [];
|
|
1021
|
-
const decisionsMatch = body.match(
|
|
1201
|
+
const decisionsMatch = body.match(new RegExp(String.raw `##\s*Decisions Made[\s\S]*?${TABLE_HEAD_ROWS}([\s\S]*?)(?=\n##|\n$|$)`, 'i'));
|
|
1022
1202
|
if (decisionsMatch) {
|
|
1023
1203
|
const tableBody = decisionsMatch[1];
|
|
1024
1204
|
const rows = tableBody.trim().split('\n').filter(r => r.includes('|'));
|
|
@@ -1071,6 +1251,12 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
1071
1251
|
if (resumeFileMatch)
|
|
1072
1252
|
session.resume_file = resumeFileMatch[1].trim();
|
|
1073
1253
|
}
|
|
1254
|
+
// 261007-rly D-10: the stored stopped_at is the record (the writers hold it,
|
|
1255
|
+
// state json reports it); the Session line beneath it may be stale. Same
|
|
1256
|
+
// frontmatter-first rule as the scalars above (#3265).
|
|
1257
|
+
const storedStoppedAt = fmScalar('stopped_at');
|
|
1258
|
+
if (storedStoppedAt)
|
|
1259
|
+
session.stopped_at = storedStoppedAt;
|
|
1074
1260
|
const result = {
|
|
1075
1261
|
current_phase: currentPhase,
|
|
1076
1262
|
current_phase_name: currentPhaseName,
|
|
@@ -1089,21 +1275,231 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
1089
1275
|
output(result, raw, undefined);
|
|
1090
1276
|
}
|
|
1091
1277
|
// ─── State Frontmatter Sync ──────────────────────────────────────────────────
|
|
1278
|
+
/**
|
|
1279
|
+
* The roadmap side of reconcileCompletedPhases (261007-ixt D-1): how many phases
|
|
1280
|
+
* the ROADMAP progress table marks `Complete`, or null when no table speaks.
|
|
1281
|
+
* Current-milestone scope first; the whole file minus `<details>` only when the
|
|
1282
|
+
* scope is silent — that fallback can only RAISE the count, which the min() then
|
|
1283
|
+
* discards. A detected progress table that marks no row Complete yields 0, not
|
|
1284
|
+
* null: a table that lists the phases and grants none must not be read as silence,
|
|
1285
|
+
* or the first Built phase of a milestone would be counted Complete.
|
|
1286
|
+
*/
|
|
1287
|
+
function roadmapCompletedPhases(roadmapRaw, roadmapScope) {
|
|
1288
|
+
// The phases the current milestone's scope names. A progress table may also carry a
|
|
1289
|
+
// shipped milestone's rows (the template's milestone-grouped layout keeps them, with a
|
|
1290
|
+
// Milestone column): those rows grant nothing to this milestone.
|
|
1291
|
+
const scopedIds = new Set();
|
|
1292
|
+
const headingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
|
|
1293
|
+
let hm;
|
|
1294
|
+
while ((hm = headingPattern.exec(roadmapScope)) !== null) {
|
|
1295
|
+
if (/\d/.test(hm[1]))
|
|
1296
|
+
scopedIds.add(normalizePhaseName(hm[1]));
|
|
1297
|
+
}
|
|
1298
|
+
const fromText = (text) => {
|
|
1299
|
+
const table = countCompleteProgressRows(text, scopedIds);
|
|
1300
|
+
if (table !== null)
|
|
1301
|
+
return table;
|
|
1302
|
+
const derived = deriveProgressFromRoadmap(text);
|
|
1303
|
+
if (derived.completedPhases !== null)
|
|
1304
|
+
return derived.completedPhases;
|
|
1305
|
+
return derived.totalPhases !== null ? 0 : null;
|
|
1306
|
+
};
|
|
1307
|
+
const scoped = fromText(roadmapScope);
|
|
1308
|
+
if (scoped !== null)
|
|
1309
|
+
return scoped;
|
|
1310
|
+
return fromText(extractCurrentMilestone(roadmapRaw));
|
|
1311
|
+
}
|
|
1312
|
+
/**
|
|
1313
|
+
* Column-aware read of a ROADMAP progress table: the header row names the `Phase` and
|
|
1314
|
+
* `Status` columns, so a table with a Milestone column (pp-core/templates/roadmap.md's
|
|
1315
|
+
* milestone-grouped layout) is read as well as the four-column one. Counts the data
|
|
1316
|
+
* rows whose Status cell is `Complete`, among the rows whose phase is in `phaseIds`
|
|
1317
|
+
* (all rows when `phaseIds` is empty). Returns null when no such table is present, or
|
|
1318
|
+
* when the table lists none of `phaseIds` — a table that says nothing about this
|
|
1319
|
+
* milestone's phases is silence, not a grant of zero.
|
|
1320
|
+
*/
|
|
1321
|
+
function countCompleteProgressRows(text, phaseIds) {
|
|
1322
|
+
const cellsOf = (line) => {
|
|
1323
|
+
const t = line.trim();
|
|
1324
|
+
if (!t.startsWith('|'))
|
|
1325
|
+
return null;
|
|
1326
|
+
return t.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
|
|
1327
|
+
};
|
|
1328
|
+
const lines = text.split(/\r?\n/);
|
|
1329
|
+
for (let i = 0; i + 1 < lines.length; i++) {
|
|
1330
|
+
const header = cellsOf(lines[i]);
|
|
1331
|
+
if (!header)
|
|
1332
|
+
continue;
|
|
1333
|
+
const phaseCol = header.findIndex((c) => /^phase$/i.test(c));
|
|
1334
|
+
const statusCol = header.findIndex((c) => /^status$/i.test(c));
|
|
1335
|
+
const separator = cellsOf(lines[i + 1]);
|
|
1336
|
+
if (phaseCol === -1 || statusCol === -1 || !separator || !separator.every((c) => /^:?-+:?$/.test(c)))
|
|
1337
|
+
continue;
|
|
1338
|
+
let listed = 0;
|
|
1339
|
+
let complete = 0;
|
|
1340
|
+
for (let j = i + 2; j < lines.length; j++) {
|
|
1341
|
+
const cells = cellsOf(lines[j]);
|
|
1342
|
+
if (!cells)
|
|
1343
|
+
break;
|
|
1344
|
+
const id = cells[phaseCol] ?? '';
|
|
1345
|
+
if (!/^\d/.test(id))
|
|
1346
|
+
continue;
|
|
1347
|
+
if (phaseIds.size > 0 && !phaseIds.has(normalizePhaseName(id)))
|
|
1348
|
+
continue;
|
|
1349
|
+
listed++;
|
|
1350
|
+
if (/^complete$/i.test(cells[statusCol] ?? ''))
|
|
1351
|
+
complete++;
|
|
1352
|
+
}
|
|
1353
|
+
return listed > 0 ? complete : null;
|
|
1354
|
+
}
|
|
1355
|
+
return null;
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Read the milestone-scoped ROADMAP once and return the two counts the progress
|
|
1359
|
+
* block takes from it: the digit-bearing phase headings (single source of truth
|
|
1360
|
+
* for total_phases, #549 — numeric 01/05.1 and project codes PROJ-42, never the
|
|
1361
|
+
* pure-word `## Phase Overview:`) and the progress table's Complete count (D-1).
|
|
1362
|
+
* Consumed by buildStateFrontmatter and cmdStateSync so both report one percent.
|
|
1363
|
+
*/
|
|
1364
|
+
function readRoadmapCounts(cwd, roadmapOverride) {
|
|
1365
|
+
let phaseHeadingCount = 0;
|
|
1366
|
+
let completedPhases = null;
|
|
1367
|
+
try {
|
|
1368
|
+
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
1369
|
+
const roadmapRaw = typeof roadmapOverride === 'string' ? roadmapOverride : (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
|
|
1370
|
+
if (roadmapRaw !== null) {
|
|
1371
|
+
const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
|
1372
|
+
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
|
|
1373
|
+
let m;
|
|
1374
|
+
while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
|
|
1375
|
+
if (/\d/.test(m[1]))
|
|
1376
|
+
phaseHeadingCount++;
|
|
1377
|
+
}
|
|
1378
|
+
completedPhases = roadmapCompletedPhases(roadmapRaw, roadmapScope);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
catch { /* fall through: callers use the disk counts */ }
|
|
1382
|
+
return { phaseHeadingCount, completedPhases };
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* The current milestone's progress as the disk and the ROADMAP report it — the
|
|
1386
|
+
* ONE scan both progress surfaces read (the frontmatter block in
|
|
1387
|
+
* buildStateFrontmatter and the body `Progress:` bar in cmdStateSync), so they
|
|
1388
|
+
* cannot count different directories (261007-ixt D-1; before this, state sync
|
|
1389
|
+
* scanned every directory under phases/, prior milestones included, while the
|
|
1390
|
+
* frontmatter scanned only the milestone's). Returns null when there is no phases
|
|
1391
|
+
* directory or the scan fails. Raw counts only: each consumer reconciles
|
|
1392
|
+
* completed phases through reconcileCompletedPhases.
|
|
1393
|
+
*
|
|
1394
|
+
* Cached per cwd per process (#1967) unless a ROADMAP override is given — that is
|
|
1395
|
+
* the text a transaction is ABOUT to write (phase complete flips the row to
|
|
1396
|
+
* Complete in the same file set as STATE.md), so its counts must neither come
|
|
1397
|
+
* from nor go into the cache.
|
|
1398
|
+
*/
|
|
1399
|
+
/**
|
|
1400
|
+
* scanMilestoneDisk's answer when every candidate directory belongs to a CLOSED
|
|
1401
|
+
* milestone (261007-ixt D-9): the disk holds nothing about the current milestone, so
|
|
1402
|
+
* no consumer derives anything from it — distinct from `null` (no phases directory,
|
|
1403
|
+
* or the scan failed), which keeps each consumer's long-standing fallback.
|
|
1404
|
+
*/
|
|
1405
|
+
const CLOSED_MILESTONE_ONLY = 'closed-milestone-only';
|
|
1406
|
+
function scanMilestoneDisk(cwd, roadmapOverride) {
|
|
1407
|
+
try {
|
|
1408
|
+
const phasesDir = planningPaths(cwd).phases;
|
|
1409
|
+
if (!node_fs_1.default.existsSync(phasesDir))
|
|
1410
|
+
return null;
|
|
1411
|
+
const useCache = typeof roadmapOverride !== 'string';
|
|
1412
|
+
const cached = useCache ? _diskScanCache.get(cwd) : undefined;
|
|
1413
|
+
if (cached)
|
|
1414
|
+
return cached;
|
|
1415
|
+
const isDirInMilestone = getMilestonePhaseFilter(cwd);
|
|
1416
|
+
// 261007-ixt D-9: a directory a closed milestone's archived ROADMAP names (and the
|
|
1417
|
+
// current scope does not) is that milestone's, never the current one's.
|
|
1418
|
+
const isDirInClosedMilestone = getClosedMilestonePhaseDirFilter(cwd);
|
|
1419
|
+
const inMilestoneDirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
|
|
1420
|
+
.filter(e => e.isDirectory()).map(e => e.name)
|
|
1421
|
+
.filter(isDirInMilestone);
|
|
1422
|
+
const allMatchingDirs = inMilestoneDirs.filter(dir => !isDirInClosedMilestone(dir));
|
|
1423
|
+
// Every candidate belonged to a closed milestone: the disk holds nothing about the
|
|
1424
|
+
// current one, so nothing is derived and the stored block stands (the #905 fallback
|
|
1425
|
+
// on write, the same fallback in cmdStateJson on read). Counting zero instead would
|
|
1426
|
+
// overwrite a true closed-milestone record with 0/0.
|
|
1427
|
+
if (inMilestoneDirs.length > 0 && allMatchingDirs.length === 0)
|
|
1428
|
+
return CLOSED_MILESTONE_ONLY;
|
|
1429
|
+
// Bug #2445: when stale phase dirs from a prior milestone remain in
|
|
1430
|
+
// .planning/phases/ alongside new dirs with the same phase number,
|
|
1431
|
+
// de-duplicate by normalized phase number keeping the most recently
|
|
1432
|
+
// modified dir. This prevents double-counting (e.g. two "Phase 1" dirs).
|
|
1433
|
+
const seenPhaseNums = new Map(); // normalizedNum -> dirName
|
|
1434
|
+
for (const dir of allMatchingDirs) {
|
|
1435
|
+
const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
|
|
1436
|
+
const key = m ? m[1].toLowerCase() : dir;
|
|
1437
|
+
if (!seenPhaseNums.has(key)) {
|
|
1438
|
+
seenPhaseNums.set(key, dir);
|
|
1439
|
+
}
|
|
1440
|
+
else {
|
|
1441
|
+
// Keep the dir that is newer on disk (more likely current milestone)
|
|
1442
|
+
try {
|
|
1443
|
+
const existing = node_path_1.default.join(phasesDir, seenPhaseNums.get(key));
|
|
1444
|
+
const candidate = node_path_1.default.join(phasesDir, dir);
|
|
1445
|
+
if (node_fs_1.default.statSync(candidate).mtimeMs > node_fs_1.default.statSync(existing).mtimeMs) {
|
|
1446
|
+
seenPhaseNums.set(key, dir);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
catch { /* keep existing on stat error */ }
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
const phaseDirs = [...seenPhaseNums.values()];
|
|
1453
|
+
let diskTotalPlans = 0;
|
|
1454
|
+
let diskTotalSummaries = 0;
|
|
1455
|
+
let diskCompletedPhases = 0;
|
|
1456
|
+
for (const dir of phaseDirs) {
|
|
1457
|
+
const { planCount, summaryCount, completed } = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
|
|
1458
|
+
diskTotalPlans += planCount;
|
|
1459
|
+
diskTotalSummaries += summaryCount;
|
|
1460
|
+
if (completed)
|
|
1461
|
+
diskCompletedPhases++;
|
|
1462
|
+
}
|
|
1463
|
+
// ONE read of the ROADMAP feeds both counts it contributes: the phase
|
|
1464
|
+
// headings (total_phases, #549) and the progress table's Complete rows (the
|
|
1465
|
+
// cap on completed_phases, D-1). When the ROADMAP is unreadable both fall
|
|
1466
|
+
// through: phaseDirs.length and the disk count.
|
|
1467
|
+
const roadmap = readRoadmapCounts(cwd, roadmapOverride);
|
|
1468
|
+
const scan = {
|
|
1469
|
+
totalPhases: roadmap.phaseHeadingCount > 0
|
|
1470
|
+
? Math.max(phaseDirs.length, roadmap.phaseHeadingCount)
|
|
1471
|
+
: phaseDirs.length,
|
|
1472
|
+
diskCompletedPhases,
|
|
1473
|
+
roadmapCompletedPhases: roadmap.completedPhases,
|
|
1474
|
+
totalPlans: diskTotalPlans,
|
|
1475
|
+
completedPlans: diskTotalSummaries,
|
|
1476
|
+
};
|
|
1477
|
+
if (useCache)
|
|
1478
|
+
_diskScanCache.set(cwd, scan);
|
|
1479
|
+
return scan;
|
|
1480
|
+
}
|
|
1481
|
+
catch {
|
|
1482
|
+
return null;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1092
1485
|
/**
|
|
1093
1486
|
* Extract machine-readable fields from STATE.md markdown body and build
|
|
1094
1487
|
* a YAML frontmatter object. Allows hooks and scripts to read state
|
|
1095
1488
|
* reliably via `state json` instead of fragile regex parsing.
|
|
1096
1489
|
*/
|
|
1097
|
-
function buildStateFrontmatter(bodyContent, cwd) {
|
|
1098
|
-
|
|
1490
|
+
function buildStateFrontmatter(bodyContent, cwd, roadmapOverride) {
|
|
1491
|
+
// 261007-ixt D-2: the three positional source fields are read from ## Current
|
|
1492
|
+
// Position first (extractPositionalField), so history elsewhere in the body
|
|
1493
|
+
// cannot stand in for the current position.
|
|
1494
|
+
const prosePhase = parseProsePhaseField((0, state_document_cjs_1.extractPositionalField)(bodyContent, 'Phase'));
|
|
1099
1495
|
const currentPhase = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase') ?? prosePhase.phase;
|
|
1100
1496
|
const currentPhaseName = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase Name') ?? prosePhase.name;
|
|
1101
1497
|
const currentPlan = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Plan');
|
|
1102
1498
|
const totalPhasesRaw = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Total Phases');
|
|
1103
1499
|
const totalPlansRaw = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Total Plans in Phase');
|
|
1104
|
-
const status = (0, state_document_cjs_1.
|
|
1500
|
+
const status = (0, state_document_cjs_1.extractPositionalField)(bodyContent, 'Status');
|
|
1105
1501
|
const progressRaw = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Progress');
|
|
1106
|
-
const rawLastActivity = (0, state_document_cjs_1.
|
|
1502
|
+
const rawLastActivity = (0, state_document_cjs_1.extractPositionalField)(bodyContent, 'Last Activity', 'Last activity');
|
|
1107
1503
|
const proseLastActivity = parseProseLastActivityField(rawLastActivity);
|
|
1108
1504
|
const lastActivity = proseLastActivity.date ?? rawLastActivity;
|
|
1109
1505
|
const lastActivityDesc = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Last Activity Description') ?? proseLastActivity.description;
|
|
@@ -1121,9 +1517,14 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1121
1517
|
let milestoneName = null;
|
|
1122
1518
|
if (cwd) {
|
|
1123
1519
|
try {
|
|
1124
|
-
|
|
1125
|
-
milestone
|
|
1126
|
-
|
|
1520
|
+
// 261007-ixt W4: a stored record carries only a DECLARED milestone. When nothing
|
|
1521
|
+
// declares one, no milestone key is derived (an absent key is no claim), rather
|
|
1522
|
+
// than the first version the ROADMAP happens to mention.
|
|
1523
|
+
const info = getDeclaredMilestoneInfo(cwd);
|
|
1524
|
+
if (info) {
|
|
1525
|
+
milestone = info.version;
|
|
1526
|
+
milestoneName = info.name;
|
|
1527
|
+
}
|
|
1127
1528
|
}
|
|
1128
1529
|
catch { /* intentionally empty */ }
|
|
1129
1530
|
}
|
|
@@ -1131,93 +1532,23 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1131
1532
|
let completedPhases = null;
|
|
1132
1533
|
let totalPlans = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
|
|
1133
1534
|
let completedPlans = null;
|
|
1535
|
+
// 261007-ixt D-9: every phase directory belongs to a closed milestone → derive no
|
|
1536
|
+
// progress block at all (not even from body `Total Phases:` / `Progress:` fields), so
|
|
1537
|
+
// syncStateFrontmatter keeps the stored one rather than a partial or zeroed block.
|
|
1538
|
+
let closedMilestoneOnly = false;
|
|
1134
1539
|
if (cwd) {
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
// Bug #2445: when stale phase dirs from a prior milestone remain in
|
|
1147
|
-
// .planning/phases/ alongside new dirs with the same phase number,
|
|
1148
|
-
// de-duplicate by normalized phase number keeping the most recently
|
|
1149
|
-
// modified dir. This prevents double-counting (e.g. two "Phase 1" dirs).
|
|
1150
|
-
const seenPhaseNums = new Map(); // normalizedNum -> dirName
|
|
1151
|
-
for (const dir of allMatchingDirs) {
|
|
1152
|
-
const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
|
|
1153
|
-
const key = m ? m[1].toLowerCase() : dir;
|
|
1154
|
-
if (!seenPhaseNums.has(key)) {
|
|
1155
|
-
seenPhaseNums.set(key, dir);
|
|
1156
|
-
}
|
|
1157
|
-
else {
|
|
1158
|
-
// Keep the dir that is newer on disk (more likely current milestone)
|
|
1159
|
-
try {
|
|
1160
|
-
const existing = node_path_1.default.join(phasesDir, seenPhaseNums.get(key));
|
|
1161
|
-
const candidate = node_path_1.default.join(phasesDir, dir);
|
|
1162
|
-
if (node_fs_1.default.statSync(candidate).mtimeMs > node_fs_1.default.statSync(existing).mtimeMs) {
|
|
1163
|
-
seenPhaseNums.set(key, dir);
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
catch { /* keep existing on stat error */ }
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
const phaseDirs = [...seenPhaseNums.values()];
|
|
1170
|
-
let diskTotalPlans = 0;
|
|
1171
|
-
let diskTotalSummaries = 0;
|
|
1172
|
-
let diskCompletedPhases = 0;
|
|
1173
|
-
for (const dir of phaseDirs) {
|
|
1174
|
-
const phaseDir = node_path_1.default.join(phasesDir, dir);
|
|
1175
|
-
const { planCount, summaryCount, completed } = scanPhasePlans(phaseDir);
|
|
1176
|
-
diskTotalPlans += planCount;
|
|
1177
|
-
diskTotalSummaries += summaryCount;
|
|
1178
|
-
if (completed)
|
|
1179
|
-
diskCompletedPhases++;
|
|
1180
|
-
}
|
|
1181
|
-
// Count phase headings from ROADMAP using a digit-containing pattern
|
|
1182
|
-
// that matches both numeric phases (01, 05.1) and project-code phases
|
|
1183
|
-
// (PROJ-42, CK-05) but excludes pure-word section headers like
|
|
1184
|
-
// `## Phase Overview:` or `## Phase Details:` — single source of
|
|
1185
|
-
// truth for total_phases (#549).
|
|
1186
|
-
let roadmapPhaseCount = 0;
|
|
1187
|
-
try {
|
|
1188
|
-
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
1189
|
-
const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
|
|
1190
|
-
if (roadmapRaw !== null) {
|
|
1191
|
-
const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
|
1192
|
-
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
|
|
1193
|
-
let m;
|
|
1194
|
-
while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
|
|
1195
|
-
// Only count tokens that contain at least one digit — excludes
|
|
1196
|
-
// pure-word section headings (Overview, Details) while keeping
|
|
1197
|
-
// numeric phases (01, 05.1) and project-code IDs (PROJ-42).
|
|
1198
|
-
if (/\d/.test(m[1]))
|
|
1199
|
-
roadmapPhaseCount++;
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
catch { /* fall through: phaseDirs.length used as sole count */ }
|
|
1204
|
-
cached = {
|
|
1205
|
-
totalPhases: roadmapPhaseCount > 0
|
|
1206
|
-
? Math.max(phaseDirs.length, roadmapPhaseCount)
|
|
1207
|
-
: phaseDirs.length,
|
|
1208
|
-
completedPhases: diskCompletedPhases,
|
|
1209
|
-
totalPlans: diskTotalPlans,
|
|
1210
|
-
completedPlans: diskTotalSummaries,
|
|
1211
|
-
};
|
|
1212
|
-
_diskScanCache.set(cwd, cached);
|
|
1213
|
-
}
|
|
1214
|
-
totalPhases = cached.totalPhases;
|
|
1215
|
-
completedPhases = cached.completedPhases;
|
|
1216
|
-
totalPlans = cached.totalPlans;
|
|
1217
|
-
completedPlans = cached.completedPlans;
|
|
1218
|
-
}
|
|
1540
|
+
const scan = scanMilestoneDisk(cwd, roadmapOverride);
|
|
1541
|
+
if (scan === CLOSED_MILESTONE_ONLY) {
|
|
1542
|
+
closedMilestoneOnly = true;
|
|
1543
|
+
}
|
|
1544
|
+
else if (scan) {
|
|
1545
|
+
totalPhases = scan.totalPhases;
|
|
1546
|
+
// 261007-ixt D-1: the disk scan counts BUILT phases; the ROADMAP table says
|
|
1547
|
+
// which are COMPLETE. The record claims no more than the table grants.
|
|
1548
|
+
completedPhases = (0, state_document_cjs_1.reconcileCompletedPhases)(scan.diskCompletedPhases, scan.roadmapCompletedPhases);
|
|
1549
|
+
totalPlans = scan.totalPlans;
|
|
1550
|
+
completedPlans = scan.completedPlans;
|
|
1219
1551
|
}
|
|
1220
|
-
catch { /* intentionally empty */ }
|
|
1221
1552
|
}
|
|
1222
1553
|
// Derive percent from disk counts when available (ground truth).
|
|
1223
1554
|
// Uses min(plan_fraction, phase_fraction) via computeProgressPercent so that
|
|
@@ -1253,6 +1584,13 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1253
1584
|
if (lastActivityDesc)
|
|
1254
1585
|
fm['last_activity_desc'] = lastActivityDesc;
|
|
1255
1586
|
const progress = {};
|
|
1587
|
+
if (closedMilestoneOnly) {
|
|
1588
|
+
totalPhases = null;
|
|
1589
|
+
completedPhases = null;
|
|
1590
|
+
totalPlans = null;
|
|
1591
|
+
completedPlans = null;
|
|
1592
|
+
progressPercent = null;
|
|
1593
|
+
}
|
|
1256
1594
|
if (totalPhases !== null)
|
|
1257
1595
|
progress['total_phases'] = totalPhases;
|
|
1258
1596
|
if (completedPhases !== null)
|
|
@@ -1280,12 +1618,23 @@ function stripFrontmatter(content) {
|
|
|
1280
1618
|
}
|
|
1281
1619
|
return result;
|
|
1282
1620
|
}
|
|
1283
|
-
|
|
1621
|
+
/**
|
|
1622
|
+
* @param roadmapOverride ROADMAP text to count from instead of the file on disk —
|
|
1623
|
+
* for a caller that writes ROADMAP.md and STATE.md as one file set (phase
|
|
1624
|
+
* complete), so the frontmatter reflects the roadmap the transaction commits.
|
|
1625
|
+
*/
|
|
1626
|
+
function syncStateFrontmatter(content, cwd, roadmapOverride, activityFieldSetByCaller, stoppedAtFieldSetByCaller) {
|
|
1284
1627
|
// Read existing frontmatter BEFORE stripping — it may contain values
|
|
1285
1628
|
// that the body no longer has (e.g., Status field removed by an agent).
|
|
1286
1629
|
const existingFm = extractFrontmatter(content);
|
|
1287
1630
|
const body = stripFrontmatter(content);
|
|
1288
|
-
|
|
1631
|
+
// 261007-ixt D-10: a body `Last activity:` line that lags the frontmatter must not move
|
|
1632
|
+
// the record's date backwards — on every writer path, guarded or not. The hold is for
|
|
1633
|
+
// a DERIVED date only: when the calling verb itself set the `Last activity` field
|
|
1634
|
+
// (`state update "Last activity" …`), that explicit value flows to the frontmatter so
|
|
1635
|
+
// the body and the frontmatter agree.
|
|
1636
|
+
const built = buildStateFrontmatter(body, cwd, roadmapOverride);
|
|
1637
|
+
const derivedFm = activityFieldSetByCaller ? built : (0, state_document_cjs_1.holdNewerActivityDate)(existingFm, built);
|
|
1289
1638
|
// Preserve existing frontmatter status when body-derived status is 'unknown'.
|
|
1290
1639
|
// This prevents a missing Status: field in the body from overwriting a
|
|
1291
1640
|
// previously valid status (e.g., 'executing' → 'unknown').
|
|
@@ -1328,6 +1677,31 @@ function syncStateFrontmatter(content, cwd) {
|
|
|
1328
1677
|
if (!derivedFm['stopped_at'] && existingFm['stopped_at']) {
|
|
1329
1678
|
derivedFm['stopped_at'] = existingFm['stopped_at'];
|
|
1330
1679
|
}
|
|
1680
|
+
// 261007-rly D-7: THE stopped_at hold, one owner for every writer
|
|
1681
|
+
// (writeStateMd, readModifyWriteStateMd, phase complete's transaction). A stored
|
|
1682
|
+
// stopped_at is an operator-authored record; a write that did not change the
|
|
1683
|
+
// Session-scoped `Stopped at:` line must not replace it with whatever that line
|
|
1684
|
+
// says — measured on the v1.1 close, where the stale "Phase 12 context gathered"
|
|
1685
|
+
// replaced the handover note (todos/done/milestone-complete-writes-a-poor-
|
|
1686
|
+
// milestone-record.md). This is the #1230 delta heuristic, which used to live
|
|
1687
|
+
// only in readModifyWriteStateMd: when THIS write changed the line
|
|
1688
|
+
// (`state record-session --stopped-at`, `milestone complete --stopped-at`) the
|
|
1689
|
+
// caller passes true and the new value flows. Undefined holds — fail-safe for a
|
|
1690
|
+
// future caller that forgets the flag. The absent-derived #905 fallback above
|
|
1691
|
+
// stays as it was; the two agree whenever both apply.
|
|
1692
|
+
if (!stoppedAtFieldSetByCaller &&
|
|
1693
|
+
typeof existingFm['stopped_at'] === 'string' &&
|
|
1694
|
+
existingFm['stopped_at'].length > 0 &&
|
|
1695
|
+
derivedFm['stopped_at'] !== existingFm['stopped_at']) {
|
|
1696
|
+
derivedFm['stopped_at'] = existingFm['stopped_at'];
|
|
1697
|
+
}
|
|
1698
|
+
// 261007-ixt W4: when nothing declares a current milestone, the writer derives
|
|
1699
|
+
// no milestone — the stored one (and its name) stands, as for the keys above.
|
|
1700
|
+
if (!derivedFm['milestone'] && existingFm['milestone']) {
|
|
1701
|
+
derivedFm['milestone'] = existingFm['milestone'];
|
|
1702
|
+
if (existingFm['milestone_name'])
|
|
1703
|
+
derivedFm['milestone_name'] = existingFm['milestone_name'];
|
|
1704
|
+
}
|
|
1331
1705
|
if (!derivedFm['paused_at'] && existingFm['paused_at']) {
|
|
1332
1706
|
derivedFm['paused_at'] = existingFm['paused_at'];
|
|
1333
1707
|
}
|
|
@@ -1488,16 +1862,47 @@ function withStateLock(statePath, fn) {
|
|
|
1488
1862
|
* @param clock
|
|
1489
1863
|
* Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
|
|
1490
1864
|
*/
|
|
1491
|
-
|
|
1865
|
+
/** The positional `Last activity` source field — the one D-10's hold compares. */
|
|
1866
|
+
function lastActivityField(body) {
|
|
1867
|
+
return (0, state_document_cjs_1.extractPositionalField)(body, 'Last Activity', 'Last activity');
|
|
1868
|
+
}
|
|
1869
|
+
/**
|
|
1870
|
+
* The Session-scoped `Stopped At` source field — the one the stopped_at hold
|
|
1871
|
+
* compares (261007-rly D-7). Exactly the scope buildStateFrontmatter derives
|
|
1872
|
+
* `stopped_at` from (#2444/#1101): the `## Session` / `## Session Continuity`
|
|
1873
|
+
* section, else the whole body.
|
|
1874
|
+
*/
|
|
1875
|
+
function sessionStoppedAtField(body) {
|
|
1876
|
+
const sessionMatch = matchSessionSection(body);
|
|
1877
|
+
const scope = sessionMatch ? sessionMatch[1] : body;
|
|
1878
|
+
return (0, state_document_cjs_1.stateExtractField)(scope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(scope, 'Stopped at');
|
|
1879
|
+
}
|
|
1880
|
+
function writeStateMd(statePath, content, cwd, clock, options) {
|
|
1492
1881
|
// Invalidate disk scan cache before computing new frontmatter — the write
|
|
1493
1882
|
// may create new PLAN/SUMMARY files that buildStateFrontmatter must see.
|
|
1494
1883
|
// Safe for any calling pattern, not just short-lived CLI processes (#1967).
|
|
1495
1884
|
if (cwd)
|
|
1496
1885
|
_diskScanCache.delete(cwd);
|
|
1497
|
-
const synced = syncStateFrontmatter(content, cwd);
|
|
1498
1886
|
const lockPath = acquireStateLock(statePath, clock);
|
|
1499
1887
|
try {
|
|
1888
|
+
// 261007-ixt D-3: diff against the file ON DISK, not the caller's `content` —
|
|
1889
|
+
// a caller may have edited the frontmatter text itself (a case-insensitive
|
|
1890
|
+
// `Status:` replace lands on the YAML `status:` line), and a file that did not
|
|
1891
|
+
// exist (health --repair) has no frontmatter to compare. The report describes
|
|
1892
|
+
// what this write changed on disk.
|
|
1893
|
+
const onDisk = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
|
|
1894
|
+
const before = onDisk === null ? {} : extractFrontmatter(onDisk);
|
|
1895
|
+
// D-10: did the caller set the Last activity field (vs. the file on disk)?
|
|
1896
|
+
const activityFieldSetByCaller = onDisk !== null &&
|
|
1897
|
+
lastActivityField(stripFrontmatter(onDisk)) !== lastActivityField(stripFrontmatter(content));
|
|
1898
|
+
// 261007-rly D-7: did the caller change the Session-scoped Stopped at line?
|
|
1899
|
+
// An explicit value (milestone complete --stopped-at) flows even when the
|
|
1900
|
+
// Session line already read the same text.
|
|
1901
|
+
const stoppedAtFieldSetByCaller = options?.stoppedAtSetByCaller === true || (onDisk !== null &&
|
|
1902
|
+
sessionStoppedAtField(stripFrontmatter(onDisk)) !== sessionStoppedAtField(stripFrontmatter(content)));
|
|
1903
|
+
const synced = syncStateFrontmatter(content, cwd, undefined, activityFieldSetByCaller, stoppedAtFieldSetByCaller);
|
|
1500
1904
|
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
|
|
1905
|
+
return { written: true, frontmatter_updated: (0, state_document_cjs_1.diffFrontmatterFields)(before, extractFrontmatter(synced)) };
|
|
1501
1906
|
}
|
|
1502
1907
|
finally {
|
|
1503
1908
|
releaseStateLock(lockPath);
|
|
@@ -1543,14 +1948,31 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
1543
1948
|
// key in the frontmatter block cannot shadow the body field we are tracking.
|
|
1544
1949
|
const preBody = stripFrontmatter(content);
|
|
1545
1950
|
const preFmSnapshot = extractFrontmatter(content);
|
|
1546
|
-
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1549
|
-
|
|
1550
|
-
//
|
|
1551
|
-
|
|
1552
|
-
const
|
|
1553
|
-
|
|
1951
|
+
// 261007-ixt D-2: the delta unit reads each source field exactly as the
|
|
1952
|
+
// derivation does (extractPositionalField), so "this write did not change the
|
|
1953
|
+
// field the key derives from" is judged on the same line the key comes from.
|
|
1954
|
+
const preBodyStatus = (0, state_document_cjs_1.extractPositionalField)(preBody, 'Status');
|
|
1955
|
+
// Bug #1230 / Change B: the stopped_at delta is scoped to the ## Session
|
|
1956
|
+
// section, read through the same owner the derivation uses (261007-rly D-7).
|
|
1957
|
+
const preBodyStoppedAt = sessionStoppedAtField(preBody);
|
|
1958
|
+
// 2026-09-20: the SAME #1230 delta contract, extended to the position-derived
|
|
1959
|
+
// fields. #1230 applied it to `status` and `stopped_at` only; `current_phase`,
|
|
1960
|
+
// `current_phase_name` and `last_activity_desc` are ALSO re-derived from the
|
|
1961
|
+
// body by syncStateFrontmatter and were left without it. Measured consequence:
|
|
1962
|
+
// on a STATE.md whose ## Current Position had drifted behind its frontmatter,
|
|
1963
|
+
// a write that never touched that section still propagated the stale body into
|
|
1964
|
+
// frontmatter — observed moving `current_phase` from 10 to 09 and restoring a
|
|
1965
|
+
// two-phase-old `last_activity_desc`, while the command exited 0. Snapshot the
|
|
1966
|
+
// whole section: these three fields all derive from it, so one scope is the
|
|
1967
|
+
// honest delta unit.
|
|
1968
|
+
// Delta unit is the SOURCE FIELD each frontmatter key derives from, exactly as
|
|
1969
|
+
// #1230 used stateExtractField(preBody, 'Status') — not the enclosing section.
|
|
1970
|
+
// A section-scoped delta is too coarse: these verbs legitimately rewrite the
|
|
1971
|
+
// `Status:` line inside ## Current Position, which would disable the guard for
|
|
1972
|
+
// every other field in it. (Measured: the first attempt at this fix did
|
|
1973
|
+
// precisely that and the clobber still reproduced.)
|
|
1974
|
+
const preBodyPhaseField = (0, state_document_cjs_1.extractPositionalField)(preBody, 'Phase');
|
|
1975
|
+
const preBodyActivityField = (0, state_document_cjs_1.extractPositionalField)(preBody, 'Last Activity', 'Last activity');
|
|
1554
1976
|
const modified = transformFn(content);
|
|
1555
1977
|
// Bug #948: no-op guard — if the transform produced no change, do NOT write
|
|
1556
1978
|
// the file. An unconditional write would bump `last_updated`, reset
|
|
@@ -1560,9 +1982,13 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
1560
1982
|
// content already returns the mutated string, and callers that detect a
|
|
1561
1983
|
// no-op explicitly return the original content unchanged.
|
|
1562
1984
|
if (modified === content) {
|
|
1563
|
-
return;
|
|
1985
|
+
return { written: false, frontmatter_updated: [] };
|
|
1564
1986
|
}
|
|
1565
|
-
|
|
1987
|
+
// D-10: the transform set the Last activity field iff the field differs.
|
|
1988
|
+
// 261007-rly D-7: likewise the Session-scoped Stopped at line — the hold for
|
|
1989
|
+
// stopped_at lives in syncStateFrontmatter now, keyed on this delta.
|
|
1990
|
+
let synced = syncStateFrontmatter(modified, cwd, undefined, lastActivityField(stripFrontmatter(modified)) !== preBodyActivityField, options?.stoppedAtSetByCaller === true ||
|
|
1991
|
+
sessionStoppedAtField(stripFrontmatter(modified)) !== preBodyStoppedAt);
|
|
1566
1992
|
// Compute postFm once and apply BOTH the progress-restore (when !resync)
|
|
1567
1993
|
// AND the status/stopped_at preservation (#1230) before reconstructing.
|
|
1568
1994
|
// This avoids double-wrapping the frontmatter block.
|
|
@@ -1571,12 +1997,9 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
1571
1997
|
// Use `modified` (not `synced`): syncStateFrontmatter only rewrites the frontmatter block, so the body is identical in both — and we need the body the transform produced.
|
|
1572
1998
|
// Strip frontmatter so the YAML status key cannot shadow the body field.
|
|
1573
1999
|
const postBody = stripFrontmatter(modified);
|
|
1574
|
-
const postBodyStatus = (0, state_document_cjs_1.
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
const postSessionMatch = matchSessionSection(postBody);
|
|
1578
|
-
const postSessionScope = postSessionMatch ? postSessionMatch[1] : postBody;
|
|
1579
|
-
const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
|
|
2000
|
+
const postBodyStatus = (0, state_document_cjs_1.extractPositionalField)(postBody, 'Status');
|
|
2001
|
+
const postBodyPhaseField = (0, state_document_cjs_1.extractPositionalField)(postBody, 'Phase');
|
|
2002
|
+
const postBodyActivityField = (0, state_document_cjs_1.extractPositionalField)(postBody, 'Last Activity', 'Last activity');
|
|
1580
2003
|
let mutated = false;
|
|
1581
2004
|
const postFm = extractFrontmatter(synced);
|
|
1582
2005
|
if (needsProgressRestore) {
|
|
@@ -1601,13 +2024,33 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
1601
2024
|
postFm['status'] = preFmSnapshot['status'];
|
|
1602
2025
|
mutated = true;
|
|
1603
2026
|
}
|
|
1604
|
-
// Bug #1230
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
2027
|
+
// Bug #1230's stopped_at copy of this heuristic moved into syncStateFrontmatter
|
|
2028
|
+
// (261007-rly D-7), where writeStateMd reaches it too; it is passed the same
|
|
2029
|
+
// Session-scoped delta above.
|
|
2030
|
+
// 2026-09-20: position-derived fields get the same treatment as status and
|
|
2031
|
+
// stopped_at above. When THIS write did not change the ## Current Position
|
|
2032
|
+
// section, a frontmatter value that was set deliberately must not be reverted
|
|
2033
|
+
// to whatever a stale section happens to derive. Conservative by construction:
|
|
2034
|
+
// it only ever RESTORES a pre-existing frontmatter value, and only when the
|
|
2035
|
+
// body source it derives from is byte-identical before and after.
|
|
2036
|
+
const preserveFm = (key) => {
|
|
2037
|
+
const prev = preFmSnapshot[key];
|
|
2038
|
+
if (typeof prev === 'string' && prev.length > 0 && postFm[key] !== prev) {
|
|
2039
|
+
postFm[key] = prev;
|
|
2040
|
+
mutated = true;
|
|
2041
|
+
}
|
|
2042
|
+
};
|
|
2043
|
+
// `current_phase` / `current_phase_name` derive from the body's `Phase:` field
|
|
2044
|
+
// (parseProsePhaseField). If this write did not change that field, a
|
|
2045
|
+
// deliberately-set frontmatter value must survive.
|
|
2046
|
+
if (postBodyPhaseField === preBodyPhaseField) {
|
|
2047
|
+
preserveFm('current_phase');
|
|
2048
|
+
preserveFm('current_phase_name');
|
|
2049
|
+
}
|
|
2050
|
+
// `last_activity_desc` derives from the `Last activity:` field
|
|
2051
|
+
// (parseProseLastActivityField().description).
|
|
2052
|
+
if (postBodyActivityField === preBodyActivityField) {
|
|
2053
|
+
preserveFm('last_activity_desc');
|
|
1611
2054
|
}
|
|
1612
2055
|
if (mutated) {
|
|
1613
2056
|
const yamlStr = reconstructFrontmatter(postFm);
|
|
@@ -1615,6 +2058,9 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
1615
2058
|
synced = `---\n${yamlStr}\n---\n\n${body}`;
|
|
1616
2059
|
}
|
|
1617
2060
|
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
|
|
2061
|
+
// 261007-ixt D-3: diffed AFTER the #1230/25c9760 guards and the resync:false
|
|
2062
|
+
// restore, so the report describes the frontmatter that hit the disk.
|
|
2063
|
+
return { written: true, frontmatter_updated: (0, state_document_cjs_1.diffFrontmatterFields)(preFmSnapshot, extractFrontmatter(synced)) };
|
|
1618
2064
|
}
|
|
1619
2065
|
finally {
|
|
1620
2066
|
releaseStateLock(lockPath);
|
|
@@ -1632,14 +2078,26 @@ function cmdStateJson(cwd, raw) {
|
|
|
1632
2078
|
// Always rebuild from body + disk so progress counters reflect current state.
|
|
1633
2079
|
// Returning cached frontmatter directly causes stale percent/completed_plans
|
|
1634
2080
|
// when SUMMARY files were added after the last STATE.md write (#1589).
|
|
1635
|
-
|
|
2081
|
+
// 261007-ixt W3a: the same date hold the writer applies, so read and write agree.
|
|
2082
|
+
const built = (0, state_document_cjs_1.holdNewerActivityDate)(existingFm, buildStateFrontmatter(body, cwd));
|
|
1636
2083
|
// Preserve frontmatter-only fields that cannot be recovered from the body.
|
|
1637
|
-
|
|
2084
|
+
// 261007-rly D-7: a stored stopped_at wins over the Session line whenever it
|
|
2085
|
+
// exists — the writer holds it there (syncStateFrontmatter), so the read agrees.
|
|
2086
|
+
if (existingFm && typeof existingFm['stopped_at'] === 'string' && existingFm['stopped_at'].length > 0) {
|
|
2087
|
+
built['stopped_at'] = existingFm['stopped_at'];
|
|
2088
|
+
}
|
|
2089
|
+
else if (existingFm && existingFm['stopped_at'] && !built['stopped_at']) {
|
|
1638
2090
|
built['stopped_at'] = existingFm['stopped_at'];
|
|
1639
2091
|
}
|
|
1640
2092
|
if (existingFm && existingFm['paused_at'] && !built['paused_at']) {
|
|
1641
2093
|
built['paused_at'] = existingFm['paused_at'];
|
|
1642
2094
|
}
|
|
2095
|
+
// 261007-ixt W4: the same milestone fallback syncStateFrontmatter applies.
|
|
2096
|
+
if (existingFm && !built['milestone'] && existingFm['milestone']) {
|
|
2097
|
+
built['milestone'] = existingFm['milestone'];
|
|
2098
|
+
if (existingFm['milestone_name'])
|
|
2099
|
+
built['milestone_name'] = existingFm['milestone_name'];
|
|
2100
|
+
}
|
|
1643
2101
|
// Preserve existing status when body-derived status is 'unknown' (same logic as syncStateFrontmatter).
|
|
1644
2102
|
if (built['status'] === 'unknown' && existingFm && existingFm['status'] && existingFm['status'] !== 'unknown') {
|
|
1645
2103
|
built['status'] = existingFm['status'];
|
|
@@ -1661,6 +2119,12 @@ function cmdStateJson(cwd, raw) {
|
|
|
1661
2119
|
if (existingFm && (0, state_document_cjs_1.shouldPreserveExistingProgress)(existingFm['progress'], built['progress'])) {
|
|
1662
2120
|
built['progress'] = (0, state_document_cjs_1.normalizeProgressNumbers)(existingFm['progress']);
|
|
1663
2121
|
}
|
|
2122
|
+
// 261007-ixt D-9: when the disk derives no progress block (e.g. every phase dir
|
|
2123
|
+
// belongs to a closed milestone), report the stored one — the same #905 fallback
|
|
2124
|
+
// syncStateFrontmatter applies on write, so the read and the write agree.
|
|
2125
|
+
if (existingFm && !built['progress'] && existingFm['progress']) {
|
|
2126
|
+
built['progress'] = (0, state_document_cjs_1.normalizeProgressNumbers)(existingFm['progress']);
|
|
2127
|
+
}
|
|
1664
2128
|
output(built, raw, JSON.stringify(built, null, 2));
|
|
1665
2129
|
}
|
|
1666
2130
|
/**
|
|
@@ -1677,7 +2141,7 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
1677
2141
|
}
|
|
1678
2142
|
const today = clock_cjs_1.realClock.today();
|
|
1679
2143
|
const updated = [];
|
|
1680
|
-
readModifyWriteStateMd(statePath, (content) => {
|
|
2144
|
+
const fmReport = readModifyWriteStateMd(statePath, (content) => {
|
|
1681
2145
|
// Bug #1255: all body-field replacements must operate on the body only
|
|
1682
2146
|
// (frontmatter stripped), not on the full content. When the full content is
|
|
1683
2147
|
// passed to stateReplaceField the YAML `status: planning` key matches the
|
|
@@ -1848,7 +2312,7 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
1848
2312
|
}
|
|
1849
2313
|
return reassemble(body);
|
|
1850
2314
|
}, cwd);
|
|
1851
|
-
output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
|
|
2315
|
+
output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null, frontmatter_updated: fmReport.frontmatter_updated }, raw, updated.length > 0 ? 'true' : 'false');
|
|
1852
2316
|
}
|
|
1853
2317
|
/**
|
|
1854
2318
|
* Write a WAITING.json signal file when GSD hits a decision point.
|
|
@@ -1899,26 +2363,66 @@ function cmdSignalResume(cwd, raw) {
|
|
|
1899
2363
|
// ─── Gate Functions (STATE.md consistency enforcement) ────────────────────────
|
|
1900
2364
|
/**
|
|
1901
2365
|
* Update the ## Performance Metrics section in STATE.md content.
|
|
1902
|
-
*
|
|
2366
|
+
* Recomputes the Velocity total and upserts a By Phase table row.
|
|
1903
2367
|
* Returns modified content string.
|
|
2368
|
+
*
|
|
2369
|
+
* `Total plans completed` is a COUNT, NOT AN ACCUMULATOR (D-08-40,
|
|
2370
|
+
* 08-VERIFICATION.md SC4): the number of executed plans — `*-SUMMARY.md` files —
|
|
2371
|
+
* across the CURRENT MILESTONE's phase directories, recomputed on every call.
|
|
2372
|
+
*
|
|
2373
|
+
* It used to be `prevTotal + summaryCount`, read back out of the STATE text.
|
|
2374
|
+
* That is wrong because this function is not called once per phase:
|
|
2375
|
+
* `execute-phase.md` § update_roadmap runs `phase complete` on EVERY execute
|
|
2376
|
+
* run, including the gap-closure re-runs a phase performs when verification
|
|
2377
|
+
* returns findings — this workstream's own phase 8 did two (plans 08-10 and
|
|
2378
|
+
* 08-11). MEASURED 2026-09-19 on a clean fixture, re-completing an
|
|
2379
|
+
* already-complete phase carried the total 2 → 4 → 6 while ROADMAP.md stayed
|
|
2380
|
+
* byte-identical: a silently corrupted stored record, the CLAUDE.md arm.
|
|
2381
|
+
*
|
|
2382
|
+
* The walk is the SAME milestone-scoped one `cmdStateUpdateProgress` already
|
|
2383
|
+
* reports as `completed` (`scanMilestoneDisk` — the milestone filter, the #2445
|
|
2384
|
+
* de-dup and the 261007-ixt D-9 closed-milestone exclusion), so STATE's counters cannot disagree — D-08-14's
|
|
2385
|
+
* spirit. A missing phases directory counts 0. `summaryCount` stays a parameter:
|
|
2386
|
+
* the By Phase row below is per-phase and still uses it, and that upsert was
|
|
2387
|
+
* already idempotent — only the Velocity total accumulated.
|
|
1904
2388
|
*/
|
|
1905
2389
|
function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summaryCount) {
|
|
1906
|
-
// Update Velocity: Total plans completed
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
2390
|
+
// Update Velocity: Total plans completed — the milestone's disk truth.
|
|
2391
|
+
// 261007-ixt D-9: the walk IS scanMilestoneDisk now (the one update-progress and the
|
|
2392
|
+
// frontmatter block read), so a closed milestone's leftover directories are excluded
|
|
2393
|
+
// here too. When every directory belongs to a closed milestone the disk says nothing
|
|
2394
|
+
// about the current one and the stored total is left as it is.
|
|
2395
|
+
let newTotal = 0;
|
|
2396
|
+
const phasesDir = planningPaths(cwd).phases;
|
|
2397
|
+
if (node_fs_1.default.existsSync(phasesDir)) {
|
|
2398
|
+
_diskScanCache.delete(cwd);
|
|
2399
|
+
const scan = scanMilestoneDisk(cwd);
|
|
2400
|
+
newTotal = scan === CLOSED_MILESTONE_ONLY ? null : (scan ? scan.completedPlans : 0);
|
|
2401
|
+
}
|
|
2402
|
+
if (newTotal !== null) {
|
|
2403
|
+
content = content.replace(/Total plans completed:\s*(\d+|\[N\])/, `Total plans completed: ${newTotal}`);
|
|
2404
|
+
}
|
|
1911
2405
|
// Update By Phase table — upsert row for this phase
|
|
1912
2406
|
const byPhaseMatch = content.match(byPhaseTablePattern);
|
|
1913
2407
|
if (byPhaseMatch) {
|
|
1914
2408
|
let tableBody = byPhaseMatch[2].trim();
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
2409
|
+
// The row for this phase is found by phase IDENTITY, not by the literal argument:
|
|
2410
|
+
// `phase complete 4` must update a row spelled `04` (and vice versa), never append
|
|
2411
|
+
// a second row for the same phase. The row keeps its own id spelling.
|
|
2412
|
+
const wanted = normalizePhaseName(String(phaseNum));
|
|
2413
|
+
const lines = tableBody.split('\n');
|
|
2414
|
+
const rowIdx = lines.findIndex((line) => {
|
|
2415
|
+
const cell = /^\|\s*([^|]*?)\s*\|/.exec(line);
|
|
2416
|
+
return cell !== null && /^[A-Za-z0-9.-]+$/.test(cell[1]) && normalizePhaseName(cell[1]) === wanted;
|
|
2417
|
+
});
|
|
2418
|
+
if (rowIdx !== -1) {
|
|
1918
2419
|
// Update existing row
|
|
1919
|
-
|
|
2420
|
+
const rowId = /^\|\s*([^|]*?)\s*\|/.exec(lines[rowIdx])[1];
|
|
2421
|
+
lines[rowIdx] = `| ${rowId} | ${summaryCount} | - | - |`;
|
|
2422
|
+
tableBody = lines.join('\n');
|
|
1920
2423
|
}
|
|
1921
2424
|
else {
|
|
2425
|
+
const newRow = `| ${phaseNum} | ${summaryCount} | - | - |`;
|
|
1922
2426
|
// Remove placeholder row and add new row
|
|
1923
2427
|
tableBody = tableBody.replace(/^\|\s*-\s*\|\s*-\s*\|\s*-\s*\|\s*-\s*\|$/m, '').trim();
|
|
1924
2428
|
tableBody = tableBody ? tableBody + '\n' + newRow : newRow;
|
|
@@ -1945,7 +2449,7 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
|
|
|
1945
2449
|
// milestone-wide progress.* frontmatter from a half-planned disk snapshot —
|
|
1946
2450
|
// doing so tramples curated/known-good counters. Route through the body-only
|
|
1947
2451
|
// write contract (resync:false), the same guard state.update uses. (#500 RC1)
|
|
1948
|
-
readModifyWriteStateMd(statePath, (content) => {
|
|
2452
|
+
const fmReport = readModifyWriteStateMd(statePath, (content) => {
|
|
1949
2453
|
// Bug #1257: all body-field replacements must operate on the body only
|
|
1950
2454
|
// (frontmatter stripped), not on the full content. When the full content is
|
|
1951
2455
|
// passed to stateReplaceFieldIfTemplate the YAML `status: planning` key matches
|
|
@@ -1989,13 +2493,31 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
|
|
|
1989
2493
|
}
|
|
1990
2494
|
}
|
|
1991
2495
|
// Update Current Position section
|
|
2496
|
+
// The Current Position write MUST be accounted for. Until 2026-09-20 it was
|
|
2497
|
+
// not: this was the only one of the four `updated`-reporting state verbs
|
|
2498
|
+
// that called updateCurrentPositionFields without pushing to `updated`
|
|
2499
|
+
// (cmdStateCompletePhase pushes 'Current Position'; cmdStateBeginPhase
|
|
2500
|
+
// pushes 'Current focus'; cmdStateRecordSession tracks its own fields).
|
|
2501
|
+
//
|
|
2502
|
+
// WHY THAT MATTERED, measured: the transform still changed the body, so the
|
|
2503
|
+
// no-op guard (#948) did not fire, the file WAS written, and
|
|
2504
|
+
// syncStateFrontmatter then re-derived frontmatter from the body. On a
|
|
2505
|
+
// STATE.md whose body had drifted behind its frontmatter, that propagated
|
|
2506
|
+
// the STALE body into frontmatter — observed moving `current_phase` from 10
|
|
2507
|
+
// to 09 and restoring a two-phase-old `last_activity_desc` — while this
|
|
2508
|
+
// command exited 0 and reported `updated: []`. Every cheap check said
|
|
2509
|
+
// "success, nothing changed"; only a cp→diff saw it. An unreported write is
|
|
2510
|
+
// the dangerous half, so the report is now complete.
|
|
2511
|
+
const beforePosition = body;
|
|
1992
2512
|
body = updateCurrentPositionFields(body, {
|
|
1993
2513
|
status: 'Ready to execute',
|
|
1994
2514
|
lastActivity: `${today} — Phase ${phaseNumber} planning complete`,
|
|
1995
2515
|
});
|
|
2516
|
+
if (body !== beforePosition)
|
|
2517
|
+
updated.push('Current Position');
|
|
1996
2518
|
return reassemble(body);
|
|
1997
2519
|
}, cwd, { resync: false });
|
|
1998
|
-
output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
|
|
2520
|
+
output({ updated, phase: phaseNumber, plan_count: planCount, frontmatter_updated: fmReport.frontmatter_updated }, raw, updated.length > 0 ? 'true' : 'false');
|
|
1999
2521
|
}
|
|
2000
2522
|
/**
|
|
2001
2523
|
* Bug #2630: reset STATE.md for a new milestone cycle.
|
|
@@ -2029,11 +2551,27 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
|
|
|
2029
2551
|
const preface = body.trim().length > 0 ? body : '# Project State\n';
|
|
2030
2552
|
newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
|
|
2031
2553
|
}
|
|
2554
|
+
// The Session block must move with the milestone. Left alone, its `Stopped at:`
|
|
2555
|
+
// still names the PREVIOUS milestone's stop point while this write drops the
|
|
2556
|
+
// frontmatter `stopped_at`; the next writer (state.planned-phase, measured
|
|
2557
|
+
// 2026-10-09 on vault-assurance) then derives `stopped_at` from that stale
|
|
2558
|
+
// line and records "Phase 12 context gathered" against a milestone whose
|
|
2559
|
+
// first phase is 13. Reset the stop point and the resume pointer here, and
|
|
2560
|
+
// record the same stop point in frontmatter, so body and frontmatter agree.
|
|
2561
|
+
const switchStoppedAt = `Milestone ${version} started`;
|
|
2562
|
+
const sessionMatch = matchSessionSection(newBody);
|
|
2563
|
+
if (sessionMatch && sessionMatch.index !== undefined) {
|
|
2564
|
+
const section = sessionMatch[0];
|
|
2565
|
+
let resetSection = replaceSessionLine(section, 'Stopped At', switchStoppedAt) ?? section;
|
|
2566
|
+
resetSection = replaceSessionLine(resetSection, 'Resume File', 'None') ?? resetSection;
|
|
2567
|
+
newBody = newBody.slice(0, sessionMatch.index) + resetSection + newBody.slice(sessionMatch.index + section.length);
|
|
2568
|
+
}
|
|
2032
2569
|
const fm = {
|
|
2033
2570
|
gsd_state_version: existingFm['gsd_state_version'] || '1.0',
|
|
2034
2571
|
milestone: version,
|
|
2035
2572
|
milestone_name: resolvedName,
|
|
2036
2573
|
status: 'planning',
|
|
2574
|
+
stopped_at: switchStoppedAt,
|
|
2037
2575
|
last_updated: clock_cjs_1.realClock.nowIso(),
|
|
2038
2576
|
last_activity: today,
|
|
2039
2577
|
progress: {
|
|
@@ -2126,11 +2664,19 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
2126
2664
|
const verify = options && options.verify;
|
|
2127
2665
|
const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
2128
2666
|
const changes = [];
|
|
2129
|
-
|
|
2667
|
+
// 261007-ixt: the fields below are BODY fields, so they are read and replaced in
|
|
2668
|
+
// the body only. stateReplaceField's plain pattern is case-insensitive and
|
|
2669
|
+
// multi-line, so on the whole file `Progress` matched the YAML `progress:` key
|
|
2670
|
+
// first: the bar was written into the frontmatter (then discarded by the
|
|
2671
|
+
// resync), the body bar never moved, and `changes` reported an update that did
|
|
2672
|
+
// not land. Measured on a STATE.md with a frontmatter progress block.
|
|
2673
|
+
const syncBody = stripFrontmatter(content);
|
|
2674
|
+
const syncHead = content.slice(0, content.length - syncBody.length);
|
|
2675
|
+
let modified = syncBody;
|
|
2130
2676
|
const today = clock_cjs_1.realClock.today();
|
|
2131
2677
|
const phasesDir = planningPaths(cwd).phases;
|
|
2132
2678
|
if (!node_fs_1.default.existsSync(phasesDir)) {
|
|
2133
|
-
output({ synced: true, changes: [], dry_run: !!verify }, raw, undefined);
|
|
2679
|
+
output({ synced: true, changes: [], dry_run: !!verify, frontmatter_updated: [] }, raw, undefined);
|
|
2134
2680
|
return;
|
|
2135
2681
|
}
|
|
2136
2682
|
// Scan all phases
|
|
@@ -2142,23 +2688,16 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
2142
2688
|
.sort();
|
|
2143
2689
|
}
|
|
2144
2690
|
catch {
|
|
2145
|
-
output({ synced: true, changes: [], dry_run: !!verify }, raw, undefined);
|
|
2691
|
+
output({ synced: true, changes: [], dry_run: !!verify, frontmatter_updated: [] }, raw, undefined);
|
|
2146
2692
|
return;
|
|
2147
2693
|
}
|
|
2148
|
-
let totalDiskPlans = 0;
|
|
2149
|
-
let totalDiskSummaries = 0;
|
|
2150
|
-
let diskCompletedPhases = 0;
|
|
2151
2694
|
let highestIncompletePhase = null;
|
|
2152
2695
|
let _highestIncompletePhaseNum = null;
|
|
2153
2696
|
let highestIncompletePhaseplanCount = 0;
|
|
2154
2697
|
let _highestIncompletePhaseSummaryCount = 0;
|
|
2155
2698
|
for (const dir of entries) {
|
|
2156
2699
|
const dirPath = node_path_1.default.join(phasesDir, dir);
|
|
2157
|
-
const { planCount: plans, summaryCount: summaries
|
|
2158
|
-
totalDiskPlans += plans;
|
|
2159
|
-
totalDiskSummaries += summaries;
|
|
2160
|
-
if (completed)
|
|
2161
|
-
diskCompletedPhases++;
|
|
2700
|
+
const { planCount: plans, summaryCount: summaries } = scanPhasePlans(dirPath);
|
|
2162
2701
|
// Track the highest phase with incomplete plans (or any plans)
|
|
2163
2702
|
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
|
2164
2703
|
if (phaseMatch && plans > 0) {
|
|
@@ -2178,33 +2717,6 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
2178
2717
|
}
|
|
2179
2718
|
}
|
|
2180
2719
|
}
|
|
2181
|
-
// Determine total phases from ROADMAP (may be larger than realized disk dirs).
|
|
2182
|
-
// Mirrors the logic in buildStateFrontmatter so both report consistent percents (#3242 Bug B).
|
|
2183
|
-
let syncTotalPhases = null;
|
|
2184
|
-
try {
|
|
2185
|
-
let roadmapPhaseCount = 0;
|
|
2186
|
-
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
2187
|
-
const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
|
|
2188
|
-
if (roadmapRaw !== null) {
|
|
2189
|
-
const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
|
2190
|
-
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
|
|
2191
|
-
let m;
|
|
2192
|
-
while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
|
|
2193
|
-
// Only count tokens that contain at least one digit — excludes
|
|
2194
|
-
// pure-word section headings (Overview, Details) while keeping
|
|
2195
|
-
// numeric phases (01, 05.1) and project-code IDs (PROJ-42).
|
|
2196
|
-
if (/\d/.test(m[1]))
|
|
2197
|
-
roadmapPhaseCount++;
|
|
2198
|
-
}
|
|
2199
|
-
}
|
|
2200
|
-
if (roadmapPhaseCount > 0) {
|
|
2201
|
-
syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
|
|
2202
|
-
}
|
|
2203
|
-
else {
|
|
2204
|
-
syncTotalPhases = entries.length;
|
|
2205
|
-
}
|
|
2206
|
-
}
|
|
2207
|
-
catch { /* intentionally empty */ }
|
|
2208
2720
|
// Sync Total Plans in Phase
|
|
2209
2721
|
if (highestIncompletePhase) {
|
|
2210
2722
|
const currentPlansField = (0, state_document_cjs_1.stateExtractField)(modified, 'Total Plans in Phase');
|
|
@@ -2218,11 +2730,21 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
2218
2730
|
// Sync Progress — use shared helper so formula stays in one place (#3242 Bug B).
|
|
2219
2731
|
// computeProgressPercent applies min(plan_fraction, phase_fraction) so unrealised
|
|
2220
2732
|
// ROADMAP phases cap the reported percent rather than allowing a false 100%.
|
|
2733
|
+
// 261007-ixt D-1: the body bar reads the SAME milestone scan as the frontmatter
|
|
2734
|
+
// block (scanMilestoneDisk) and takes completed phases from the same owner, so
|
|
2735
|
+
// the two surfaces cannot disagree. Fresh scan: sync re-derives from disk.
|
|
2736
|
+
_diskScanCache.delete(cwd);
|
|
2737
|
+
const syncScanRaw = scanMilestoneDisk(cwd);
|
|
2738
|
+
const syncScan = syncScanRaw === CLOSED_MILESTONE_ONLY ? null : syncScanRaw;
|
|
2221
2739
|
const percent = (() => {
|
|
2222
|
-
const p =
|
|
2740
|
+
const p = syncScan
|
|
2741
|
+
? (0, state_document_cjs_1.computeProgressPercent)(syncScan.completedPlans, syncScan.totalPlans, (0, state_document_cjs_1.reconcileCompletedPhases)(syncScan.diskCompletedPhases, syncScan.roadmapCompletedPhases), syncScan.totalPhases)
|
|
2742
|
+
: null;
|
|
2223
2743
|
return p !== null ? p : 0;
|
|
2224
2744
|
})();
|
|
2225
|
-
|
|
2745
|
+
// D-9: nothing on disk is the current milestone's → the bar is left as it is rather
|
|
2746
|
+
// than written as 0%.
|
|
2747
|
+
const currentProgress = syncScanRaw === CLOSED_MILESTONE_ONLY ? null : (0, state_document_cjs_1.stateExtractField)(modified, 'Progress');
|
|
2226
2748
|
if (currentProgress) {
|
|
2227
2749
|
const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
|
|
2228
2750
|
if (currentPercent !== percent) {
|
|
@@ -2246,13 +2768,15 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
2246
2768
|
modified = result;
|
|
2247
2769
|
}
|
|
2248
2770
|
if (verify) {
|
|
2249
|
-
output({ synced: false, changes, dry_run: true }, raw, undefined);
|
|
2771
|
+
output({ synced: false, changes, dry_run: true, frontmatter_updated: [] }, raw, undefined);
|
|
2250
2772
|
return;
|
|
2251
2773
|
}
|
|
2252
|
-
|
|
2253
|
-
|
|
2774
|
+
const nextContent = syncHead + modified;
|
|
2775
|
+
let frontmatterUpdated = [];
|
|
2776
|
+
if (changes.length > 0 || nextContent !== content) {
|
|
2777
|
+
frontmatterUpdated = writeStateMd(statePath, nextContent, cwd).frontmatter_updated;
|
|
2254
2778
|
}
|
|
2255
|
-
output({ synced: true, changes, dry_run: false }, raw, undefined);
|
|
2779
|
+
output({ synced: true, changes, dry_run: false, frontmatter_updated: frontmatterUpdated }, raw, undefined);
|
|
2256
2780
|
}
|
|
2257
2781
|
/**
|
|
2258
2782
|
* Prune old entries from STATE.md sections that grow unboundedly (#1970).
|
|
@@ -2277,7 +2801,7 @@ function cmdStatePrune(cwd, options, raw) {
|
|
|
2277
2801
|
const currentPhase = parseInt(currentPhaseRaw, 10) || 0;
|
|
2278
2802
|
const cutoff = currentPhase - keepRecent;
|
|
2279
2803
|
if (cutoff <= 0) {
|
|
2280
|
-
emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}
|
|
2804
|
+
emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}`, frontmatter_updated: [] }, raw, 'false');
|
|
2281
2805
|
return;
|
|
2282
2806
|
}
|
|
2283
2807
|
const archivePath = node_path_1.default.join(node_path_1.default.dirname(statePath), 'STATE-ARCHIVE.md');
|
|
@@ -2396,10 +2920,11 @@ function cmdStatePrune(cwd, options, raw) {
|
|
|
2396
2920
|
sections: result.archivedSections.map(s => ({ section: s.section, entries_would_archive: s.count })),
|
|
2397
2921
|
total_would_archive: totalPruned,
|
|
2398
2922
|
note: totalPruned > 0 ? 'Run without --dry-run to actually prune' : 'Nothing to prune',
|
|
2923
|
+
frontmatter_updated: [],
|
|
2399
2924
|
}, raw, totalPruned > 0 ? 'true' : 'false');
|
|
2400
2925
|
return;
|
|
2401
2926
|
}
|
|
2402
|
-
readModifyWriteStateMd(statePath, (content) => {
|
|
2927
|
+
const fmReport = readModifyWriteStateMd(statePath, (content) => {
|
|
2403
2928
|
const result = prunePass(content);
|
|
2404
2929
|
archived.push(...result.archivedSections);
|
|
2405
2930
|
return result.newContent;
|
|
@@ -2425,6 +2950,7 @@ function cmdStatePrune(cwd, options, raw) {
|
|
|
2425
2950
|
sections: archived.map(s => ({ section: s.section, entries_archived: s.count })),
|
|
2426
2951
|
total_archived: totalPruned,
|
|
2427
2952
|
archive_file: totalPruned > 0 ? 'STATE-ARCHIVE.md' : null,
|
|
2953
|
+
frontmatter_updated: fmReport.frontmatter_updated,
|
|
2428
2954
|
}, raw, totalPruned > 0 ? 'true' : 'false');
|
|
2429
2955
|
}
|
|
2430
2956
|
/**
|
|
@@ -2467,12 +2993,12 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
2467
2993
|
const existingCurrentPhaseMatch = String(existingCurrentPhaseRaw).match(/(\d+[A-Z]?(?:\.\d+)*)/i);
|
|
2468
2994
|
const existingCurrentPhase = existingCurrentPhaseMatch ? existingCurrentPhaseMatch[1] : null;
|
|
2469
2995
|
if (existingCurrentPhase && existingCurrentPhase !== resolvedPhase) {
|
|
2470
|
-
output({ updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op' }, raw, 'false');
|
|
2996
|
+
output({ updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op', frontmatter_updated: [] }, raw, 'false');
|
|
2471
2997
|
return;
|
|
2472
2998
|
}
|
|
2473
2999
|
const today = clock_cjs_1.realClock.today();
|
|
2474
3000
|
const updated = [];
|
|
2475
|
-
readModifyWriteStateMd(statePath, (content) => {
|
|
3001
|
+
const fmReport = readModifyWriteStateMd(statePath, (content) => {
|
|
2476
3002
|
const currentPhase = resolvedPhase;
|
|
2477
3003
|
// Bug #1255: operate on body only so the YAML frontmatter `status:` key
|
|
2478
3004
|
// cannot shadow the body Status field (pipe-table or inline).
|
|
@@ -2548,7 +3074,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
2548
3074
|
}
|
|
2549
3075
|
return reassemble(body);
|
|
2550
3076
|
}, cwd);
|
|
2551
|
-
output({ updated, phase: resolvedPhase }, raw, updated.length > 0 ? 'true' : 'false');
|
|
3077
|
+
output({ updated, phase: resolvedPhase, frontmatter_updated: fmReport.frontmatter_updated }, raw, updated.length > 0 ? 'true' : 'false');
|
|
2552
3078
|
}
|
|
2553
3079
|
module.exports = {
|
|
2554
3080
|
stateExtractField: state_document_cjs_1.stateExtractField,
|
|
@@ -2559,6 +3085,7 @@ module.exports = {
|
|
|
2559
3085
|
writeStateMd,
|
|
2560
3086
|
readModifyWriteStateMd,
|
|
2561
3087
|
syncStateFrontmatter,
|
|
3088
|
+
matchSessionSection,
|
|
2562
3089
|
withStateLock,
|
|
2563
3090
|
updatePerformanceMetricsSection,
|
|
2564
3091
|
cmdStateLoad,
|