@opengsd/gsd-core 1.12.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +12 -0
- package/agents/gsd-executor.md +63 -35
- package/agents/gsd-plan-checker.md +76 -57
- package/agents/gsd-planner.md +14 -0
- package/agents/gsd-ui-checker.md +19 -3
- package/agents/gsd-ui-researcher.md +29 -0
- package/agents/gsd-verifier.md +23 -1
- package/bin/install.js +239 -67
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/ns-workflow.md +2 -1
- package/commands/gsd/phase.md +1 -1
- package/commands/gsd/quick-batch.md +105 -0
- package/commands/gsd/surface.md +18 -8
- package/gsd-core/bin/gsd-tools.cjs +195 -50
- package/gsd-core/bin/lib/capability-activation.cjs +27 -0
- package/gsd-core/bin/lib/capability-registry.cjs +514 -114
- package/gsd-core/bin/lib/capability-state.cjs +7 -1
- package/gsd-core/bin/lib/capability-validator.cjs +120 -4
- package/gsd-core/bin/lib/capability-writer.cjs +14 -4
- package/gsd-core/bin/lib/check-command-router.cjs +85 -2
- package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
- package/gsd-core/bin/lib/clusters.cjs +1 -0
- package/gsd-core/bin/lib/command-aliases.cjs +16 -0
- package/gsd-core/bin/lib/commands.cjs +337 -13
- package/gsd-core/bin/lib/config-loader.cjs +3 -0
- package/gsd-core/bin/lib/core-utils.cjs +34 -7
- package/gsd-core/bin/lib/decisions.cjs +213 -1
- package/gsd-core/bin/lib/edge-probe.cjs +14 -1
- package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
- package/gsd-core/bin/lib/frontmatter.cjs +137 -23
- package/gsd-core/bin/lib/gap-checker.cjs +22 -13
- package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
- package/gsd-core/bin/lib/host-integration.cjs +57 -5
- package/gsd-core/bin/lib/init-command-router.cjs +14 -0
- package/gsd-core/bin/lib/init.cjs +132 -15
- package/gsd-core/bin/lib/install-engine.cjs +184 -12
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
- package/gsd-core/bin/lib/install-profiles.cjs +22 -14
- package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
- package/gsd-core/bin/lib/io.cjs +35 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
- package/gsd-core/bin/lib/markdown-table.cjs +123 -0
- package/gsd-core/bin/lib/milestone.cjs +22 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
- package/gsd-core/bin/lib/phase-id.cjs +251 -9
- package/gsd-core/bin/lib/phase.cjs +774 -35
- package/gsd-core/bin/lib/plan-document.cjs +10 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +147 -20
- package/gsd-core/bin/lib/planning-workspace.cjs +103 -28
- package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
- package/gsd-core/bin/lib/quick-batch.cjs +840 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
- package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
- package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
- package/gsd-core/bin/lib/roadmap-parser.cjs +499 -26
- package/gsd-core/bin/lib/roadmap.cjs +187 -58
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +233 -33
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +286 -108
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -43
- package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
- package/gsd-core/bin/lib/smart-entry.cjs +7 -9
- package/gsd-core/bin/lib/state-document.cjs +30 -5
- package/gsd-core/bin/lib/state-md-schema.cjs +23 -13
- package/gsd-core/bin/lib/state-transition.cjs +333 -44
- package/gsd-core/bin/lib/state.cjs +684 -125
- package/gsd-core/bin/lib/surface.cjs +23 -8
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
- package/gsd-core/bin/lib/uat.cjs +1419 -515
- package/gsd-core/bin/lib/update-context.cjs +6 -2
- package/gsd-core/bin/lib/validate.cjs +230 -12
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +273 -12
- package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
- package/gsd-core/bin/lib/verify.cjs +346 -16
- package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +8 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +8 -0
- package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
- package/gsd-core/references/agent-contracts.md +3 -3
- package/gsd-core/references/edge-probe.md +17 -13
- package/gsd-core/references/execute-mvp-tdd.md +18 -16
- package/gsd-core/references/execute-phase-response-language.md +6 -0
- package/gsd-core/references/executor-examples.md +42 -0
- package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
- package/gsd-core/references/mvp-concepts.md +2 -2
- package/gsd-core/references/plan-checker-examples.md +41 -0
- package/gsd-core/references/planner-antipatterns.md +25 -0
- package/gsd-core/references/planner-chunked.md +5 -1
- package/gsd-core/references/planner-coupling.md +42 -0
- package/gsd-core/references/planner-quick-batch.md +71 -0
- package/gsd-core/references/planner-reviews.md +47 -0
- package/gsd-core/references/planner-revision.md +75 -2
- package/gsd-core/references/planning-config.md +2 -1
- package/gsd-core/references/response-language-directive.md +9 -0
- package/gsd-core/references/revision-loop.md +118 -11
- package/gsd-core/references/tdd.md +14 -9
- package/gsd-core/references/verifier-evidence-gate.md +160 -0
- package/gsd-core/templates/phase-prompt.md +4 -0
- package/gsd-core/templates/verification-report.md +5 -0
- package/gsd-core/workflows/add-backlog.md +2 -0
- package/gsd-core/workflows/add-phase.md +2 -0
- package/gsd-core/workflows/add-tests.md +1 -1
- package/gsd-core/workflows/add-todo.md +1 -1
- package/gsd-core/workflows/ai-integration-phase.md +1 -1
- package/gsd-core/workflows/analyze-dependencies.md +2 -0
- package/gsd-core/workflows/audit-fix.md +2 -0
- package/gsd-core/workflows/audit-milestone.md +2 -0
- package/gsd-core/workflows/audit-uat.md +2 -0
- package/gsd-core/workflows/autonomous.md +2 -0
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +1 -1
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +15 -13
- package/gsd-core/workflows/code-review-fix.md +2 -0
- package/gsd-core/workflows/code-review.md +73 -31
- package/gsd-core/workflows/complete-milestone.md +13 -4
- package/gsd-core/workflows/debug.md +1 -1
- package/gsd-core/workflows/diagnose-issues.md +5 -1
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
- package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
- package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
- package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
- package/gsd-core/workflows/discuss-phase-power.md +2 -0
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +43 -13
- package/gsd-core/workflows/docs-update.md +1 -1
- package/gsd-core/workflows/edit-phase.md +2 -0
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +2 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +17 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
- package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
- package/gsd-core/workflows/execute-phase.md +32 -14
- package/gsd-core/workflows/execute-plan.md +8 -8
- package/gsd-core/workflows/explore.md +2 -0
- package/gsd-core/workflows/extract-learnings.md +2 -0
- package/gsd-core/workflows/fast.md +6 -0
- package/gsd-core/workflows/forensics.md +2 -0
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +1 -1
- package/gsd-core/workflows/help/modes/brief.md +2 -0
- package/gsd-core/workflows/help/modes/default.md +2 -0
- package/gsd-core/workflows/help/modes/full.md +12 -0
- package/gsd-core/workflows/help/modes/topic.md +2 -0
- package/gsd-core/workflows/help.md +2 -0
- package/gsd-core/workflows/import.md +3 -3
- package/gsd-core/workflows/inbox.md +1 -1
- package/gsd-core/workflows/ingest-docs.md +1 -1
- package/gsd-core/workflows/insert-phase.md +2 -0
- package/gsd-core/workflows/list-phase-assumptions.md +2 -0
- package/gsd-core/workflows/list-seeds.md +2 -0
- package/gsd-core/workflows/list-workspaces.md +2 -0
- package/gsd-core/workflows/manager.md +3 -3
- package/gsd-core/workflows/map-codebase.md +2 -0
- package/gsd-core/workflows/milestone-summary.md +2 -0
- package/gsd-core/workflows/mvp-phase.md +1 -1
- package/gsd-core/workflows/new-milestone.md +1 -1
- package/gsd-core/workflows/new-project.md +5 -3
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +2 -0
- package/gsd-core/workflows/node-repair.md +2 -0
- package/gsd-core/workflows/note.md +2 -0
- package/gsd-core/workflows/onboard.md +1 -1
- package/gsd-core/workflows/pause-work.md +19 -4
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
- package/gsd-core/workflows/plan-phase.md +130 -12
- package/gsd-core/workflows/plan-review-convergence.md +102 -10
- package/gsd-core/workflows/plant-seed.md +1 -1
- package/gsd-core/workflows/pr-branch.md +11 -3
- package/gsd-core/workflows/profile-user.md +1 -1
- package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
- package/gsd-core/workflows/progress.md +25 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
- package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
- package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
- package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
- package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
- package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
- package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
- package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
- package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
- package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
- package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
- package/gsd-core/workflows/quick-batch.md +203 -0
- package/gsd-core/workflows/quick.md +13 -3
- package/gsd-core/workflows/reapply-patches.md +2 -0
- package/gsd-core/workflows/remove-phase.md +2 -0
- package/gsd-core/workflows/remove-workspace.md +1 -1
- package/gsd-core/workflows/resume-project.md +6 -2
- package/gsd-core/workflows/review.md +215 -10
- package/gsd-core/workflows/scan.md +2 -0
- package/gsd-core/workflows/section-manifest.json +12 -0
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/session-report.md +2 -0
- package/gsd-core/workflows/settings-advanced.md +2 -0
- package/gsd-core/workflows/settings-integrations.md +9 -8
- package/gsd-core/workflows/settings.md +1 -1
- package/gsd-core/workflows/ship.md +10 -10
- package/gsd-core/workflows/sketch-wrap-up.md +2 -0
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/smart-entry.md +1 -1
- package/gsd-core/workflows/spec-phase.md +24 -19
- package/gsd-core/workflows/spike-wrap-up.md +2 -0
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +2 -0
- package/gsd-core/workflows/sync-skills.md +12 -4
- package/gsd-core/workflows/thread.md +2 -0
- package/gsd-core/workflows/transition.md +2 -0
- package/gsd-core/workflows/ui-phase.md +26 -5
- package/gsd-core/workflows/ui-review.md +1 -1
- package/gsd-core/workflows/ultraplan-phase.md +2 -0
- package/gsd-core/workflows/undo.md +1 -1
- package/gsd-core/workflows/update.md +41 -38
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work.md +49 -3
- package/hooks/dist/gsd-check-update-worker.js +19 -2
- package/hooks/dist/gsd-context-monitor.js +283 -12
- package/hooks/dist/gsd-node-runner.sh +1 -0
- package/hooks/dist/gsd-prompt-guard.js +30 -5
- package/hooks/dist/gsd-read-guard.js +2 -0
- package/hooks/dist/gsd-read-injection-scanner.js +5 -5
- package/hooks/dist/gsd-secret-read-guard.js +1079 -0
- package/hooks/dist/gsd-statusline.js +7 -3
- package/hooks/dist/gsd-validate-commit.sh +444 -7
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/lib/git-cmd.js +210 -1
- package/hooks/dist/lib/injection-patterns.js +36 -6
- package/hooks/dist/managed-hooks-registry.cjs +1 -0
- package/hooks/gsd-check-update-worker.js +19 -2
- package/hooks/gsd-context-monitor.js +283 -12
- package/hooks/gsd-node-runner.sh +1 -0
- package/hooks/gsd-prompt-guard.js +30 -5
- package/hooks/gsd-read-guard.js +2 -0
- package/hooks/gsd-read-injection-scanner.js +5 -5
- package/hooks/gsd-secret-read-guard.js +1079 -0
- package/hooks/gsd-statusline.js +7 -3
- package/hooks/gsd-validate-commit.sh +444 -7
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/hooks.json +6 -0
- package/hooks/lib/git-cmd.js +210 -1
- package/hooks/lib/injection-patterns.js +36 -6
- package/hooks/managed-hooks-registry.cjs +1 -0
- package/package.json +5 -5
- package/scripts/build-hooks.js +11 -4
- package/scripts/ci-test-scope.cjs +7 -0
- package/scripts/docs-guard-registry.cjs +10 -0
- package/scripts/gen-loop-host-contract.cjs +67 -15
- package/scripts/lib/shellcheck-fetch.cjs +247 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +5 -0
- package/scripts/lint-phase-enumeration-drift.cjs +24 -6
- package/scripts/lint-phase-id-drift.cjs +133 -8
- package/scripts/lint-portable-grep.cjs +176 -0
- package/scripts/lint-response-language-coverage.cjs +524 -0
- package/scripts/lint-test-file-count.allowlist.json +3 -1
- package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
- package/scripts/lint-workflow-shellcheck.cjs +614 -0
- package/scripts/npm-audit-baseline.cjs +376 -0
- package/scripts/prompt-injection-scan.sh +8 -0
- package/scripts/require-issue-link-policy.cjs +16 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -1
- package/skills/gsd-ns-workflow/SKILL.md +1 -0
- package/skills/gsd-phase/SKILL.md +1 -1
- package/skills/gsd-quick-batch/SKILL.md +105 -0
- package/skills/gsd-surface/SKILL.md +18 -8
- package/vscode/package.json +1 -1
|
@@ -14,7 +14,7 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
14
14
|
const pattern_cjs_1 = require("./pattern.cjs");
|
|
15
15
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
16
16
|
const ioMod = require("./io.cjs");
|
|
17
|
-
const { output, error } = ioMod;
|
|
17
|
+
const { output, error, declineNoOp, formatDiagnosticToken } = ioMod;
|
|
18
18
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
19
19
|
const cliExitModule = require("./cli-exit.cjs");
|
|
20
20
|
const { ExitError } = cliExitModule;
|
|
@@ -26,7 +26,9 @@ const configLoaderMod = require("./config-loader.cjs");
|
|
|
26
26
|
const { loadConfig } = configLoaderMod;
|
|
27
27
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
28
28
|
const phaseIdMod = require("./phase-id.cjs");
|
|
29
|
-
const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir, isSentinelPhaseId, scopeToPhase,
|
|
29
|
+
const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, matchPhaseDirs, phaseKeyFromToken, phaseKeyFromDir, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase,
|
|
30
|
+
// #2761 M3: owns the bracket milestone intro and canonical pad2 spelling.
|
|
31
|
+
bracketMilestoneIntroSrcFor, } = phaseIdMod;
|
|
30
32
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
31
33
|
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
32
34
|
// #3642: hasMilestoneSectioning no longer consumed here — its >=2 semantics answered sibling conflation, but this branch asks asserted-vs-section (>=1). It stays exported from roadmap-parser.cjs for its unit pins.
|
|
@@ -34,7 +36,7 @@ const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap,
|
|
|
34
36
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
35
37
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
36
38
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
37
|
-
const { planningDir, planningPaths } = planningWorkspace;
|
|
39
|
+
const { planningDir, planningPaths, resolvePhaseIdConvention } = planningWorkspace;
|
|
38
40
|
const clock_cjs_1 = require("./clock.cjs");
|
|
39
41
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
40
42
|
const frontmatter = require("./frontmatter.cjs");
|
|
@@ -53,6 +55,10 @@ function isUnparseableFrontmatter(existingFm) {
|
|
|
53
55
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
54
56
|
const scanPhasePlans = require("./plan-scan.cjs");
|
|
55
57
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
58
|
+
const coreUtilsMod = require("./core-utils.cjs");
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
60
|
+
const planDependencyGraphMod = require("./plan-dependency-graph.cjs");
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
56
62
|
const verificationMod = require("./verification.cjs");
|
|
57
63
|
const { isPhaseComplete } = verificationMod;
|
|
58
64
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -75,7 +81,7 @@ const project_root_cjs_1 = require("./project-root.cjs");
|
|
|
75
81
|
// it introduces no cycle on this path.
|
|
76
82
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
77
83
|
const milestoneLockMod = require("./milestone-lock.cjs");
|
|
78
|
-
const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
|
|
84
|
+
const { transitionCore, applyStatePreservation, sliceCurrentPositionSection, stateReplaceProgressPercent, formatProgressMachineSegment } = stateTransitionMod;
|
|
79
85
|
// #3699: the frontmatter-key <-> body-field routing behind `state update`'s
|
|
80
86
|
// failure explanation, and the classification table it falls back to.
|
|
81
87
|
const { getFieldClassification, getFrontmatterBodySource, frontmatterKeyForBodyField } = stateTransitionMod;
|
|
@@ -520,6 +526,42 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
520
526
|
}
|
|
521
527
|
}
|
|
522
528
|
// ─── State Progression Engine ────────────────────────────────────────────────
|
|
529
|
+
/**
|
|
530
|
+
* The "I could not read the plan position" message, DERIVED from
|
|
531
|
+
* `STATE_FIELD_SCHEMA.current_plan.acceptedShapes` rather than transcribed
|
|
532
|
+
* beside it.
|
|
533
|
+
*
|
|
534
|
+
* The accepted-shape set had two owners: the parser branches in
|
|
535
|
+
* `advancePlanCore` and an English list hand-written here. Nothing coupled
|
|
536
|
+
* them, so adding a branch left this message stale and removing one left it
|
|
537
|
+
* advertising a shape that errors — and no test could see either. ADR-3473
|
|
538
|
+
* §8.3 is "one implementation per rule"; the schema row is that one owner, and
|
|
539
|
+
* rows 23/24/25 already hold the parser to it.
|
|
540
|
+
*
|
|
541
|
+
* `Plan: N of M` is spelled out separately because there is no schema row for
|
|
542
|
+
* the body-only `Plan` field: `buildStateFrontmatter` never reads it into
|
|
543
|
+
* frontmatter, so it has no `current_*` key to hang a row on. That asymmetry is
|
|
544
|
+
* the schema's, not this function's.
|
|
545
|
+
*/
|
|
546
|
+
function advancePlanShapeError() {
|
|
547
|
+
const shapes = stateMdSchemaMod.STATE_FIELD_SCHEMA['current_plan']?.acceptedShapes ?? [];
|
|
548
|
+
const spellings = shapes.map((shape) => (shape === 'N'
|
|
549
|
+
? '`Current Plan: N` with `Total Plans in Phase: M`'
|
|
550
|
+
: `\`Current Plan: ${shape}\``));
|
|
551
|
+
// The body-only `Plan` field has no schema row to derive from:
|
|
552
|
+
// `buildStateFrontmatter` never reads it into frontmatter, so there is no
|
|
553
|
+
// `current_*` key to hang a row on. Its ONE accepted spelling is named here.
|
|
554
|
+
//
|
|
555
|
+
// `Plan: N` with a `Total Plans in Phase: M` sibling is deliberately NOT
|
|
556
|
+
// listed (#3791 review round 6, M2): the parser does not accept it. A
|
|
557
|
+
// revision of this PR added both the branch and this spelling together, on
|
|
558
|
+
// the reasoning that the message must advertise exactly what the parser
|
|
559
|
+
// accepts. That reasoning still holds — which is why removing the branch
|
|
560
|
+
// removes the spelling in the same commit. The invariant is the lockstep,
|
|
561
|
+
// not the length of the list.
|
|
562
|
+
spellings.push('`Plan: N of M`');
|
|
563
|
+
return `Cannot read the plan position from STATE.md. Expected one of: ${spellings.join(', ')}.`;
|
|
564
|
+
}
|
|
523
565
|
/**
|
|
524
566
|
* Replace a STATE.md field with fallback field name support.
|
|
525
567
|
* Tries `primary` first, then `fallback` (if provided), returns content unchanged
|
|
@@ -541,6 +583,86 @@ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
|
|
|
541
583
|
`This may indicate STATE.md was externally modified or uses an unexpected format.\n`);
|
|
542
584
|
return content;
|
|
543
585
|
}
|
|
586
|
+
/**
|
|
587
|
+
* #4067: disk-derived plan-completion answer for advance-plan's phase-complete
|
|
588
|
+
* guard.
|
|
589
|
+
*
|
|
590
|
+
* `advancePlanCore` decides "phase complete" purely from STATE.md's scalar plan
|
|
591
|
+
* counter (`currentPlan >= totalPlans`). That counter cannot represent
|
|
592
|
+
* wave-parallel execution — a stale counter carried over from the prior phase
|
|
593
|
+
* (the reported trigger: `Plan: 7 of 7` surviving into a 10-plan phase) or a
|
|
594
|
+
* counter raced by N concurrent executors both let the phase-complete branch
|
|
595
|
+
* fire while sibling plans are mid-flight. This helper answers the completion
|
|
596
|
+
* question from disk instead, exactly the way `state update-progress`
|
|
597
|
+
* recalculates it: every plan in the Current Position phase's directory has a
|
|
598
|
+
* SUMMARY.md.
|
|
599
|
+
*
|
|
600
|
+
* Single-derivation discipline: plan/summary counting is owned by
|
|
601
|
+
* `scanPhasePlans` (src/plan-scan.cts, ADR-3180 §7.5) — this helper consumes
|
|
602
|
+
* it, never re-derives. It deliberately does NOT consult `isPhaseComplete`
|
|
603
|
+
* (§7.4): that owner answers the *verification* question (passing
|
|
604
|
+
* `*-VERIFICATION.md`), a different question from "are all plans executed?".
|
|
605
|
+
* Blocked summaries (#3345) are filtered from the pairing set with the same
|
|
606
|
+
* shared predicate `scanPhasePlans` uses, so the named outstanding list can
|
|
607
|
+
* never disagree with the count-based decision.
|
|
608
|
+
*
|
|
609
|
+
* FAIL-OPEN contract: returns `null` when the disk answer is UNAVAILABLE — no
|
|
610
|
+
* readable phases dir, no directory matching the position phase, or a scan
|
|
611
|
+
* whose scope is not COMPLETE (the scan may be blind to plans it knows exist).
|
|
612
|
+
* `null` means "the caller must fall back to the counter-derived decision",
|
|
613
|
+
* NOT "plans are outstanding"; worlds the seam cannot see (STATE.md with no
|
|
614
|
+
* Current Position `Phase:` line, milestone-archived layouts) keep today's
|
|
615
|
+
* behavior rather than being newly refused.
|
|
616
|
+
*
|
|
617
|
+
* Returns `{ dir, outstanding, planCount, summaryCount }` where `outstanding`
|
|
618
|
+
* is empty when every plan on disk is summarized (vacuously so for a zero-plan
|
|
619
|
+
* phase — #3168's zero-plan-phase posture). `planCount`/`summaryCount` are the
|
|
620
|
+
* countable disk facts behind `outstanding` (live plan files; summaries after
|
|
621
|
+
* the #3345 blocked filter) — #4093's recovery decline reports them to a
|
|
622
|
+
* caller whose STATE.md has lost its labeled plan position, so the suggested
|
|
623
|
+
* repair values are computed from the SAME set `outstanding` was, and can
|
|
624
|
+
* never disagree with a count-based decision either.
|
|
625
|
+
*/
|
|
626
|
+
function scanOutstanding(phasesDir, dir) {
|
|
627
|
+
const phaseDirPath = node_path_1.default.join(phasesDir, dir);
|
|
628
|
+
const scan = scanPhasePlans(phaseDirPath);
|
|
629
|
+
if (scan.scope !== SCOPE.COMPLETE)
|
|
630
|
+
return null;
|
|
631
|
+
// Blocked summaries (#3345) are filtered with the same shared predicate
|
|
632
|
+
// scanPhasePlans uses for its own count, so the named outstanding list can
|
|
633
|
+
// never disagree with a count-based decision.
|
|
634
|
+
const countableSummaries = scan.summaryFiles.filter((f) => !planDependencyGraphMod.isSummaryFileBlocked(node_path_1.default.join(phaseDirPath, f)));
|
|
635
|
+
const outstanding = coreUtilsMod.findUnsummarizedPlans(scan.planFiles, countableSummaries);
|
|
636
|
+
return { dir, outstanding, planCount: scan.planFiles.length, summaryCount: countableSummaries.length };
|
|
637
|
+
}
|
|
638
|
+
function unsummarizedPlansForPositionPhase(cwd, positionPhase) {
|
|
639
|
+
const phasesDir = planningPaths(cwd).phases;
|
|
640
|
+
// #3185 (ADR-3180 Decision 1): "which phase directories exist" is owned by
|
|
641
|
+
// listMilestonePhaseDirs — no hand-rolled readdirSync here. The owner
|
|
642
|
+
// handles an absent phasesDir as a real empty and refuses sentinels.
|
|
643
|
+
//
|
|
644
|
+
// Two passes, narrowest first: the CURRENT-MILESTONE window (so an archived
|
|
645
|
+
// milestone's stale `01-*` directory cannot shadow the live one), then —
|
|
646
|
+
// only when the window cannot answer (no bounded ROADMAP, or the position
|
|
647
|
+
// phase is simply not in it) — an unscoped read, which the owner documents
|
|
648
|
+
// as a real answer. This is a lookup of ONE phase token STATE.md names, not
|
|
649
|
+
// a milestone enumeration, so the unscoped retry is in-contract.
|
|
650
|
+
const convention = resolvePhaseIdConvention(cwd);
|
|
651
|
+
const windowed = listMilestonePhaseDirs(phasesDir, { cwd, phaseIdConvention: convention });
|
|
652
|
+
const candidateDirs = windowed.scope === SCOPE.COMPLETE ? windowed.value : [];
|
|
653
|
+
// Canonical phase-token → directory matching (phase-id owner, #2562): both
|
|
654
|
+
// sides of the comparison derived by the same function, never a local regex.
|
|
655
|
+
const { matches } = matchPhaseDirs(candidateDirs, positionPhase, convention);
|
|
656
|
+
if (matches.length > 0)
|
|
657
|
+
return scanOutstanding(phasesDir, matches[0]);
|
|
658
|
+
const unscoped = listMilestonePhaseDirs(phasesDir);
|
|
659
|
+
if (unscoped.scope !== SCOPE.COMPLETE)
|
|
660
|
+
return null;
|
|
661
|
+
const retry = matchPhaseDirs(unscoped.value, positionPhase, convention);
|
|
662
|
+
if (retry.matches.length === 0)
|
|
663
|
+
return null;
|
|
664
|
+
return scanOutstanding(phasesDir, retry.matches[0]);
|
|
665
|
+
}
|
|
544
666
|
function cmdStateAdvancePlan(cwd, raw) {
|
|
545
667
|
const statePath = planningPaths(cwd).state;
|
|
546
668
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
@@ -567,6 +689,16 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
567
689
|
// STATE.md lock, so the position read and the claim read cannot interleave
|
|
568
690
|
// with another session's Current Position write.
|
|
569
691
|
let milestoneConflict = null;
|
|
692
|
+
// #4067: set when the disk-derived guard declines the phase-complete branch —
|
|
693
|
+
// named here so the post-lock output path can report it without re-deriving.
|
|
694
|
+
// Holder (not a bare let) so TypeScript's closure-unaware narrowing cannot
|
|
695
|
+
// collapse the post-lock read to `never` — the callback assigns it.
|
|
696
|
+
const outstandingRef = { value: null };
|
|
697
|
+
// #4093: the position phase token the callback resolved (Current Position
|
|
698
|
+
// `Phase:` line first, frontmatter `current_phase` as fallback), carried out
|
|
699
|
+
// so the generic parse-failure decline can derive recovery facts from disk
|
|
700
|
+
// without re-reading STATE.md outside the lock. Same holder idiom as above.
|
|
701
|
+
const positionPhaseRef = { value: null };
|
|
570
702
|
const wrote = readModifyWriteStateMd(statePath, (content) => {
|
|
571
703
|
// advance-plan has no phase argument of its own — the phase it advances is
|
|
572
704
|
// whatever ## Current Position names. Compare that against the milestone
|
|
@@ -576,6 +708,17 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
576
708
|
const body = stripFrontmatter(content);
|
|
577
709
|
const positionScope = matchCurrentPositionSection(body) ?? body;
|
|
578
710
|
const positionPhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
|
|
711
|
+
// #4093: a Current Position section with ZERO labeled fields has no
|
|
712
|
+
// `Phase:` line either; frontmatter `current_phase` is the documented
|
|
713
|
+
// survivor of body drift (the reporter's document still carried it, and
|
|
714
|
+
// `buildStateFrontmatter` re-derives it from the body only when the body
|
|
715
|
+
// HAS the line). It feeds the recovery DECLINE only — never a write.
|
|
716
|
+
let fmPhase = null;
|
|
717
|
+
if (positionPhase === null) {
|
|
718
|
+
const fmToken = extractFrontmatter(content, statePath)['current_phase'];
|
|
719
|
+
fmPhase = typeof fmToken === 'string' && fmToken.trim() !== '' ? fmToken.trim() : null;
|
|
720
|
+
}
|
|
721
|
+
positionPhaseRef.value = positionPhase ?? fmPhase;
|
|
579
722
|
if (positionPhase !== null) {
|
|
580
723
|
milestoneConflict = milestoneLockMod.checkMilestonePosition(cwd, positionPhase);
|
|
581
724
|
if (milestoneConflict) {
|
|
@@ -583,10 +726,53 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
583
726
|
}
|
|
584
727
|
}
|
|
585
728
|
const result = transitionCore(content, intent, deps);
|
|
729
|
+
// #4067: the transform's phase-complete branch is decided by STATE.md's
|
|
730
|
+
// scalar plan counter, which can neither carry a stale value across phases
|
|
731
|
+
// nor represent wave-parallel execution. Before letting that branch write
|
|
732
|
+
// "Phase complete — ready for verification", re-decide from disk (the same
|
|
733
|
+
// source state.update-progress recalculates from): every plan in the
|
|
734
|
+
// position phase's directory must have a SUMMARY.md. A non-empty
|
|
735
|
+
// outstanding list declines the ENTIRE write — STATE.md is returned
|
|
736
|
+
// byte-identical, so the decline is idempotent and safe for any number of
|
|
737
|
+
// concurrent callers (the disk answer is re-read under the STATE.md lock
|
|
738
|
+
// each call; the counter stays display-only). `null` (disk answer
|
|
739
|
+
// unavailable) fails open to the counter-derived decision, so every
|
|
740
|
+
// world this seam cannot see keeps today's behavior.
|
|
741
|
+
if (result.data?.['advanced'] === false
|
|
742
|
+
&& result.data?.['reason'] === 'last_plan'
|
|
743
|
+
&& positionPhase !== null) {
|
|
744
|
+
const diskAnswer = unsummarizedPlansForPositionPhase(cwd, positionPhase);
|
|
745
|
+
if (diskAnswer !== null && diskAnswer.outstanding.length > 0) {
|
|
746
|
+
outstandingRef.value = diskAnswer;
|
|
747
|
+
resultData = result.data;
|
|
748
|
+
precomputedUpdated = [];
|
|
749
|
+
return content;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
586
752
|
resultData = result.data;
|
|
587
753
|
precomputedUpdated = result.updated;
|
|
588
754
|
return result.content;
|
|
589
755
|
}, cwd, { divergedFields, preWriteState });
|
|
756
|
+
// #4067 decline path: plans remain unexecuted on disk. Shaped like the
|
|
757
|
+
// existing `last_plan` decline (advanced:false + machine-readable reason,
|
|
758
|
+
// exit 0) rather than a hard error — the caller did nothing wrong and
|
|
759
|
+
// STATE.md needs no repair; the remaining plans' executors will re-run this
|
|
760
|
+
// command, and the final one finds a fully-summarized phase and completes it.
|
|
761
|
+
const plansOutstanding = outstandingRef.value;
|
|
762
|
+
if (plansOutstanding !== null) {
|
|
763
|
+
declineNoOp(raw, 'advanced', 'plans_outstanding', `state advance-plan skipped — phase-complete declined: ${plansOutstanding.outstanding.length} plan(s) in .planning/phases/${plansOutstanding.dir} have no SUMMARY.md (${plansOutstanding.outstanding.join(', ')}). STATE.md was left unchanged; re-run once every plan has executed and written its summary.`, {
|
|
764
|
+
advanced: false,
|
|
765
|
+
phase_dir: plansOutstanding.dir,
|
|
766
|
+
outstanding_plans: plansOutstanding.outstanding,
|
|
767
|
+
milestone_conflict: milestoneConflict,
|
|
768
|
+
});
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
// `!resultData` is a type guard, not a second failure mode: the callback
|
|
772
|
+
// above assigns it unconditionally and only runs once STATE.md is known to
|
|
773
|
+
// exist (the missing-file case returns "STATE.md not found" earlier), and
|
|
774
|
+
// every `advancePlanCore` return path sets `data`. So the message below is
|
|
775
|
+
// the one a caller can actually receive.
|
|
590
776
|
if (!resultData || resultData['error']) {
|
|
591
777
|
// #3807: a multi-`Phase:` Current Position section carries its own cause
|
|
592
778
|
// and its own remedy (name the candidates; the caller resolves them).
|
|
@@ -598,7 +784,70 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
598
784
|
}, raw, undefined);
|
|
599
785
|
return;
|
|
600
786
|
}
|
|
601
|
-
|
|
787
|
+
// #3791 review round 6 (B1): the document carries both plan-position
|
|
788
|
+
// spellings with DIFFERENT numbers. Same posture as the case above — name
|
|
789
|
+
// the candidates and let the caller resolve them. Advancing either one
|
|
790
|
+
// would write a number into the other that nothing derived for it.
|
|
791
|
+
if (resultData && resultData['reason'] === 'ambiguous_plan_position') {
|
|
792
|
+
output({
|
|
793
|
+
error: 'STATE.md carries two plan positions with different numbers — refusing to advance either. Resolve them to a single current plan and re-run.',
|
|
794
|
+
reason: resultData['reason'],
|
|
795
|
+
plan_candidates: resultData['plan_candidates'],
|
|
796
|
+
}, raw, undefined);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
// #4093: the generic terminus — no accepted labeled plan-position shape
|
|
800
|
+
// parsed anywhere in the document (the reporter's case: ## Current
|
|
801
|
+
// Position drifted to pure narrative prose with zero labeled fields).
|
|
802
|
+
// Every OTHER refusal above carries a machine-readable reason and the
|
|
803
|
+
// evidence to act on; this one stranded the caller at a bare sentence
|
|
804
|
+
// with no recovery path. Give it the same posture: a `reason` the caller
|
|
805
|
+
// can branch on, plus — when the position phase can be resolved and its
|
|
806
|
+
// directory scanned — the disk-derived facts and the exact labeled lines
|
|
807
|
+
// to re-insert. Nothing is WRITTEN: STATE.md is returned byte-identical
|
|
808
|
+
// (the callback already returned the original content for this path),
|
|
809
|
+
// so the decline is idempotent and no repair is guessed into the file —
|
|
810
|
+
// the caller (human or agent) applies the suggested lines and re-runs.
|
|
811
|
+
// Disk is the recovery source per #4067's posture; the values below are
|
|
812
|
+
// computed from the SAME `scanOutstanding` counts the plans_outstanding
|
|
813
|
+
// guard uses, so the two declines can never disagree about a phase.
|
|
814
|
+
const positionToken = positionPhaseRef.value;
|
|
815
|
+
const diskFacts = positionToken !== null
|
|
816
|
+
? unsummarizedPlansForPositionPhase(cwd, positionToken)
|
|
817
|
+
: null;
|
|
818
|
+
if (diskFacts === null) {
|
|
819
|
+
// No resolvable phase (no Phase: line, no current_phase frontmatter, or
|
|
820
|
+
// no matching phase directory / incomplete scan): keep today's shape
|
|
821
|
+
// error, plus the reason so callers can tell this refusal from the
|
|
822
|
+
// ambiguous_* ones without string-matching the sentence.
|
|
823
|
+
output({ error: advancePlanShapeError(), reason: 'plan_position_unreadable' }, raw, undefined);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const planCount = diskFacts.planCount;
|
|
827
|
+
const summarized = diskFacts.summaryCount;
|
|
828
|
+
// A summarized count below the plan count means the next plan to execute
|
|
829
|
+
// is summarized+1; an equal count means the phase is done on disk and the
|
|
830
|
+
// position line should say so (current = total; the next advance-plan run
|
|
831
|
+
// takes the #4067-guarded phase-complete branch from it). Zero plan files
|
|
832
|
+
// means disk has no opinion — suggest nothing rather than `1 of 0`.
|
|
833
|
+
const payload = {
|
|
834
|
+
error: advancePlanShapeError(),
|
|
835
|
+
reason: 'plan_position_unreadable',
|
|
836
|
+
phase_dir: diskFacts.dir,
|
|
837
|
+
disk: { plan_count: planCount, summarized_count: summarized },
|
|
838
|
+
};
|
|
839
|
+
if (planCount > 0) {
|
|
840
|
+
const current = summarized < planCount ? summarized + 1 : planCount;
|
|
841
|
+
payload['suggested'] = {
|
|
842
|
+
current_plan: current,
|
|
843
|
+
total_plans: planCount,
|
|
844
|
+
lines: [`Current Plan: ${current}`, `Total Plans in Phase: ${planCount}`],
|
|
845
|
+
};
|
|
846
|
+
payload['error'] =
|
|
847
|
+
`${advancePlanShapeError()} Disk for phase ${diskFacts.dir}: ${summarized} of ${planCount} plan(s) summarized. ` +
|
|
848
|
+
`Re-insert a labeled plan position at the top of ## Current Position (e.g. Current Plan: ${current} with Total Plans in Phase: ${planCount}), then re-run.`;
|
|
849
|
+
}
|
|
850
|
+
output(payload, raw, undefined);
|
|
602
851
|
return;
|
|
603
852
|
}
|
|
604
853
|
// ADR-3408 §8.4 (D4): reconcile `advancePlanCore`'s own success list against
|
|
@@ -803,7 +1052,7 @@ function computeUpdateProgressPreview(statePath, cwd) {
|
|
|
803
1052
|
const existingFm = extractFrontmatter(preContent, statePath);
|
|
804
1053
|
const preBody = stripFrontmatter(preContent);
|
|
805
1054
|
const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
|
|
806
|
-
const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm));
|
|
1055
|
+
const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
|
|
807
1056
|
const progress = builtFm['progress'];
|
|
808
1057
|
const percent = progress && typeof progress['percent'] === 'number' ? progress['percent'] : null;
|
|
809
1058
|
const completedPlans = progress && typeof progress['completed_plans'] === 'number' ? progress['completed_plans'] : null;
|
|
@@ -844,7 +1093,39 @@ function cmdStateUpdateProgress(cwd, raw) {
|
|
|
844
1093
|
// excluded sentinels, unlike the owner). The owner already handles an
|
|
845
1094
|
// absent phasesDir as a real empty, so the fs.existsSync guard folds
|
|
846
1095
|
// into it.
|
|
847
|
-
|
|
1096
|
+
//
|
|
1097
|
+
// #2761 (round-11 BLOCKER, single-derivation hygiene): `phaseIdConvention`
|
|
1098
|
+
// threaded explicitly (resolved ambiently off `cwd` — this call site has
|
|
1099
|
+
// no `ws` of its own, same contract `resolvePhaseIdConvention` uses
|
|
1100
|
+
// elsewhere in this file, e.g. the `phaseConvention` ONCE-and-THREAD
|
|
1101
|
+
// pattern at ~:2267/:2300) rather than left `undefined`.
|
|
1102
|
+
//
|
|
1103
|
+
// This does NOT change `phaseScope` — `scope` (roadmap-parser.cts
|
|
1104
|
+
// `getMilestonePhaseFilter`) is assigned at :1979/:2030, both BEFORE
|
|
1105
|
+
// `headingConvention` resolves at ~:2048, so the #3217 withhold gate a
|
|
1106
|
+
// few lines below is convention-independent either way (verified
|
|
1107
|
+
// empirically: forcing `phaseIdConvention: null` here left every
|
|
1108
|
+
// `state update-progress` assertion in
|
|
1109
|
+
// tests/adr-612-bracket-phase-counting.test.cjs's round-11 BLOCKER block
|
|
1110
|
+
// unchanged). What DOES depend on convention is `phaseDirs`/`totalPlans`
|
|
1111
|
+
// — the enumerated `.value` these two lines feed into the #3233
|
|
1112
|
+
// zero-plans no-op check just below. The actual `percent` this command
|
|
1113
|
+
// reports/writes comes from a separate, already-correctly-threaded scan
|
|
1114
|
+
// (`computeUpdateProgressPreview` -> `buildStateFrontmatter`, which
|
|
1115
|
+
// resolves its own `phaseConvention` at :2267). Threading here removes a
|
|
1116
|
+
// second, silent, lazily-resolved answer for the SAME question that scan
|
|
1117
|
+
// already answers explicitly — the single-derivation discipline this
|
|
1118
|
+
// file's own :2300 comment states as a rule — rather than fixing an
|
|
1119
|
+
// observed defect. #2761 round-12: the #3233 gate IS the one place this
|
|
1120
|
+
// is observable, so it — not the reported percent — is what
|
|
1121
|
+
// tests/adr-612-bracket-phase-counting.test.cjs's round-12 addition to
|
|
1122
|
+
// the round-11 BLOCKER block pins: a bracket milestone with no plans on
|
|
1123
|
+
// disk versus a decoy directory outside the milestone window that must
|
|
1124
|
+
// not be swept in by a pass-all degrade.
|
|
1125
|
+
const { value: phaseDirs, scope } = listMilestonePhaseDirs(phasesDir, {
|
|
1126
|
+
cwd,
|
|
1127
|
+
phaseIdConvention: cwd ? resolvePhaseIdConvention(cwd) : null,
|
|
1128
|
+
});
|
|
848
1129
|
phaseScope = scope;
|
|
849
1130
|
for (const dir of phaseDirs) {
|
|
850
1131
|
const { planCount } = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
|
|
@@ -861,10 +1142,10 @@ function cmdStateUpdateProgress(cwd, raw) {
|
|
|
861
1142
|
// never read, and STATE.md's Progress field goes stale with no
|
|
862
1143
|
// user-visible signal beyond it. Mirrors the established
|
|
863
1144
|
// `[gsd-tools] WARNING:` stderr convention this file already uses
|
|
864
|
-
// (stateReplaceFieldWithFallback above) for a comparable silent no-op
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1145
|
+
// (stateReplaceFieldWithFallback above) for a comparable silent no-op —
|
|
1146
|
+
// now routed through the shared `declineNoOp` helper (#3957) so the
|
|
1147
|
+
// pairing is structural rather than hand-written per arm.
|
|
1148
|
+
declineNoOp(raw, 'updated', `phase scope is ${phaseScope}, not complete`, `state update-progress skipped — phase scope is ${phaseScope}, not complete. STATE.md's Progress field was left unchanged.`);
|
|
868
1149
|
return;
|
|
869
1150
|
}
|
|
870
1151
|
// #3233: zero plans in the current-milestone phases means there is nothing to
|
|
@@ -876,9 +1157,7 @@ function cmdStateUpdateProgress(cwd, raw) {
|
|
|
876
1157
|
// ("nothing to measure" ≠ "0% done"). The legitimate 0% case (plans exist,
|
|
877
1158
|
// none summarized → clampPercent(0, N>0) = 0) is unaffected: totalPlans > 0.
|
|
878
1159
|
if (totalPlans === 0) {
|
|
879
|
-
|
|
880
|
-
`STATE.md's Progress field was left unchanged (milestone archived?).\n`);
|
|
881
|
-
output({ updated: false, reason: 'no plans found in current-milestone phases — STATE.md left unchanged (milestone archived?)' }, raw, 'false');
|
|
1160
|
+
declineNoOp(raw, 'updated', 'no plans found in current-milestone phases — STATE.md left unchanged (milestone archived?)', `state update-progress skipped — no plans found in current-milestone phases (0 plans). STATE.md's Progress field was left unchanged (milestone archived?).`);
|
|
882
1161
|
return;
|
|
883
1162
|
}
|
|
884
1163
|
// #3583: percent AND the completed/total counts reported alongside it both
|
|
@@ -889,48 +1168,30 @@ function cmdStateUpdateProgress(cwd, raw) {
|
|
|
889
1168
|
// disagrees with its own completed/total.
|
|
890
1169
|
const preview = computeUpdateProgressPreview(statePath, cwd);
|
|
891
1170
|
if (preview.withheld) {
|
|
892
|
-
|
|
893
|
-
output({ updated: false, reason: preview.reason }, raw, 'false');
|
|
1171
|
+
declineNoOp(raw, 'updated', preview.reason, `state update-progress skipped — ${preview.reason}`);
|
|
894
1172
|
return;
|
|
895
1173
|
}
|
|
896
1174
|
const { percent, completedPlans: fmCompletedPlans, totalPlans: fmTotalPlans } = preview;
|
|
897
|
-
const
|
|
898
|
-
const filled = Math.round(percent / 100 * barWidth);
|
|
899
|
-
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
900
|
-
const progressStr = `[${bar}] ${percent}%`;
|
|
1175
|
+
const progressStr = formatProgressMachineSegment(percent);
|
|
901
1176
|
let updated = false;
|
|
902
1177
|
readModifyWriteStateMd(statePath, (content) => {
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
// eat its newline, mangling the nested block), while the body Progress: line
|
|
906
|
-
// — which frontmatter `percent` is re-derived from on every write — stays
|
|
907
|
-
// stale and silently reverts the update.
|
|
908
|
-
const body = stripFrontmatter(content);
|
|
909
|
-
const fmPrefix = content.slice(0, content.length - body.length);
|
|
910
|
-
// Swap only the machine segment ("[bar] NN%" or bare "NN%"), preserving any
|
|
911
|
-
// descriptive suffix an agent authored, e.g. "(2/4 plans done; blocked on…)".
|
|
912
|
-
const machineSegment = /(?:\[[^\]\r\n]*\][ \t]*)?\d{1,3}%/;
|
|
913
|
-
const replaceValue = (value) => machineSegment.test(value)
|
|
914
|
-
? value.replace(machineSegment, progressStr)
|
|
915
|
-
: progressStr;
|
|
916
|
-
// Try **Progress:** bold format first, then plain Progress: format.
|
|
917
|
-
const boldProgressPattern = /(\*\*Progress:\*\*[ \t]*)([^\r\n]*)/i;
|
|
918
|
-
const plainProgressPattern = /^(Progress:[ \t]*)([^\r\n]*)/im;
|
|
919
|
-
const pattern = boldProgressPattern.test(body)
|
|
920
|
-
? boldProgressPattern
|
|
921
|
-
: plainProgressPattern.test(body)
|
|
922
|
-
? plainProgressPattern
|
|
923
|
-
: null;
|
|
924
|
-
if (!pattern)
|
|
1178
|
+
const result = stateReplaceProgressPercent(content, percent);
|
|
1179
|
+
if (result === null)
|
|
925
1180
|
return content;
|
|
926
1181
|
updated = true;
|
|
927
|
-
return
|
|
1182
|
+
return result;
|
|
928
1183
|
}, cwd);
|
|
929
1184
|
if (updated) {
|
|
930
1185
|
output({ updated: true, percent, completed: fmCompletedPlans, total: fmTotalPlans, bar: progressStr }, raw, progressStr);
|
|
931
1186
|
}
|
|
932
1187
|
else {
|
|
933
|
-
|
|
1188
|
+
// #3957: the frontmatter progress data was already confirmed present a
|
|
1189
|
+
// few lines above (computeUpdateProgressPreview didn't withhold) — what's
|
|
1190
|
+
// actually missing here is the BODY `Progress:`/`**Progress:**` line
|
|
1191
|
+
// itself. The prior 'Progress field not found in STATE.md' reason named
|
|
1192
|
+
// the wrong layer and silently discarded percent/completed/total, which
|
|
1193
|
+
// the sibling success arm above reports from the same preview.
|
|
1194
|
+
declineNoOp(raw, 'updated', 'no Progress: line found in STATE.md body to update (frontmatter progress data is unaffected)', 'state update-progress skipped — no Progress: line found in STATE.md body to update (frontmatter progress data is unaffected).', { percent, completed: fmCompletedPlans, total: fmTotalPlans });
|
|
934
1195
|
}
|
|
935
1196
|
}
|
|
936
1197
|
function cmdStateAddDecision(cwd, options, raw) {
|
|
@@ -1236,7 +1497,15 @@ function cmdStateResolveBlocker(cwd, text, raw) {
|
|
|
1236
1497
|
output({ error: 'text required' }, raw, undefined);
|
|
1237
1498
|
return;
|
|
1238
1499
|
}
|
|
1239
|
-
|
|
1500
|
+
// #3957: track section-found and bullet-matched SEPARATELY. Previously
|
|
1501
|
+
// `resolved` was set unconditionally as soon as the heading was located —
|
|
1502
|
+
// before checking whether any bullet line actually matched `text` — so a
|
|
1503
|
+
// call naming a non-existent blocker reported `resolved: true` (a false
|
|
1504
|
+
// success). Only a real bullet match makes `resolved` true and the
|
|
1505
|
+
// rewrite happen; otherwise the transform returns `content` unchanged
|
|
1506
|
+
// (this repo's established no-op-return idiom).
|
|
1507
|
+
let sectionFound = false;
|
|
1508
|
+
let matched = false;
|
|
1240
1509
|
readModifyWriteStateMd(statePath, (content) => {
|
|
1241
1510
|
// ADR-1372 T6: find Blockers/Concerns section via tokenizeHeadings; stop at level 2 or 3.
|
|
1242
1511
|
// Mirrors /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i
|
|
@@ -1244,6 +1513,7 @@ function cmdStateResolveBlocker(cwd, text, raw) {
|
|
|
1244
1513
|
const i = hs.findIndex(h => (h.level === 2 || h.level === 3) && /^(?:Blockers|Blockers\/Concerns|Concerns)$/i.test(h.text));
|
|
1245
1514
|
if (i === -1)
|
|
1246
1515
|
return content;
|
|
1516
|
+
sectionFound = true;
|
|
1247
1517
|
const h = hs[i];
|
|
1248
1518
|
const ls = content.split('\n');
|
|
1249
1519
|
const hl = ls[h.line - 1];
|
|
@@ -1260,21 +1530,33 @@ function cmdStateResolveBlocker(cwd, text, raw) {
|
|
|
1260
1530
|
const filtered = lines.filter(line => {
|
|
1261
1531
|
if (!line.startsWith('- '))
|
|
1262
1532
|
return true;
|
|
1263
|
-
|
|
1533
|
+
// Case-insensitive substring match — unchanged from before the fix;
|
|
1534
|
+
// only whether a match occurred is now tracked accurately.
|
|
1535
|
+
const isMatch = line.toLowerCase().includes(text.toLowerCase());
|
|
1536
|
+
if (isMatch)
|
|
1537
|
+
matched = true;
|
|
1538
|
+
return !isMatch;
|
|
1264
1539
|
});
|
|
1540
|
+
if (!matched)
|
|
1541
|
+
return content;
|
|
1265
1542
|
let newBody = filtered.join('\n');
|
|
1266
1543
|
// If section is now empty, add placeholder
|
|
1267
1544
|
if (!newBody.trim() || !newBody.includes('- ')) {
|
|
1268
1545
|
newBody = 'None\n';
|
|
1269
1546
|
}
|
|
1270
|
-
resolved = true;
|
|
1271
1547
|
return content.slice(0, bs) + newBody + content.slice(se);
|
|
1272
1548
|
}, cwd);
|
|
1273
|
-
if (
|
|
1549
|
+
if (matched) {
|
|
1274
1550
|
output({ resolved: true, blocker: text }, raw, 'true');
|
|
1275
1551
|
}
|
|
1552
|
+
else if (!sectionFound) {
|
|
1553
|
+
declineNoOp(raw, 'resolved', 'no Blockers/Concerns section found in STATE.md', 'state resolve-blocker skipped — no Blockers/Concerns section found in STATE.md.');
|
|
1554
|
+
}
|
|
1276
1555
|
else {
|
|
1277
|
-
|
|
1556
|
+
// `formatDiagnosticToken` only guards the STDERR disclosure — the JSON
|
|
1557
|
+
// `reason` field can embed `text` raw since output()'s own
|
|
1558
|
+
// JSON.stringify serialization already escapes it correctly.
|
|
1559
|
+
declineNoOp(raw, 'resolved', `no blocker matching ${text} found in the Blockers section`, `state resolve-blocker skipped — no blocker matching ${formatDiagnosticToken(text)} found in the Blockers section.`);
|
|
1278
1560
|
}
|
|
1279
1561
|
}
|
|
1280
1562
|
function cmdStateRecordSession(cwd, options, raw) {
|
|
@@ -1516,8 +1798,22 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1516
1798
|
result['created'] = true;
|
|
1517
1799
|
output(result, raw, 'true');
|
|
1518
1800
|
}
|
|
1801
|
+
else if (updated.length === 0) {
|
|
1802
|
+
// Nothing was ever attempted — no --stopped-at/--resume-file supplied
|
|
1803
|
+
// and no existing Last session/Last Date/Stopped At/Resume File labels
|
|
1804
|
+
// to touch.
|
|
1805
|
+
declineNoOp(raw, 'recorded', 'no session fields found in STATE.md to update', 'state record-session skipped — no session fields found in STATE.md to update.');
|
|
1806
|
+
}
|
|
1519
1807
|
else {
|
|
1520
|
-
|
|
1808
|
+
// #3957: `updated` (pre-reconciliation) was non-empty — a rewrite
|
|
1809
|
+
// matched a session field and reported it as changed — but
|
|
1810
|
+
// `reconcileReportedFields` found the persisted bytes byte-identical to
|
|
1811
|
+
// what was already on disk (the matched field's supplied value equals
|
|
1812
|
+
// its already-recorded value), so nothing actually changed. Distinct
|
|
1813
|
+
// from the "nothing was ever attempted" case above: the prior single
|
|
1814
|
+
// reason collapsed both into 'No session fields found in STATE.md',
|
|
1815
|
+
// which was simply wrong for this case.
|
|
1816
|
+
declineNoOp(raw, 'recorded', 'the matched session field(s) already held the reported value — no bytes changed', 'state record-session skipped — the matched session field(s) already held the reported value; no bytes changed.');
|
|
1521
1817
|
}
|
|
1522
1818
|
}
|
|
1523
1819
|
/**
|
|
@@ -1819,7 +2115,56 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
1819
2115
|
// ROADMAP phase token against an on-disk phase directory — moved to the
|
|
1820
2116
|
// phase-id owner module in #2562 so every consumer derives BOTH sides of a
|
|
1821
2117
|
// phase comparison from the same function (see phase-id.cts). Imported at the
|
|
1822
|
-
// top of this file; call sites below are unchanged.
|
|
2118
|
+
// top of this file; call sites below are unchanged. #612 threads the optional
|
|
2119
|
+
// `convention` through that owner's `phaseKeyFromDir` (see phase-id.cts) rather
|
|
2120
|
+
// than re-deriving a bracket-aware key here.
|
|
2121
|
+
/**
|
|
2122
|
+
* #612: is the asserted milestone bounded to a heading in this ROADMAP?
|
|
2123
|
+
*
|
|
2124
|
+
* The legacy rule matches STATE's milestone STRING (`v2.0`) inside a heading.
|
|
2125
|
+
* The ADR-canonical bracket milestone heading is `## [GSD.02] Foundation` — a
|
|
2126
|
+
* name, no version — so that rule finds nothing, the milestone reads as
|
|
2127
|
+
* unbounded, and total_phases falls back to the on-disk directory count. Under
|
|
2128
|
+
* the bracket convention the milestone integer in the bracket is matched against
|
|
2129
|
+
* the `vN` of the milestone string instead (READING-B parity). Gated, and only
|
|
2130
|
+
* consulted after the legacy rule has already failed, so no non-bracket repo
|
|
2131
|
+
* changes answer.
|
|
2132
|
+
*/
|
|
2133
|
+
function isMilestoneBounded(roadmapRaw, milestone, convention) {
|
|
2134
|
+
// #3184: preserve roadmap-parser's canonical legacy answer and compose the
|
|
2135
|
+
// gated bracket extension on top of it. Re-deriving the version-heading
|
|
2136
|
+
// grammar here would restore the boundary drift that #3184 removed.
|
|
2137
|
+
if (isMilestoneBoundedInRoadmap(roadmapRaw, String(milestone).trim()))
|
|
2138
|
+
return true;
|
|
2139
|
+
if (convention !== 'bracket')
|
|
2140
|
+
return false;
|
|
2141
|
+
const vMatch = String(milestone).trim().match(/^v(\d+)/i);
|
|
2142
|
+
const milestoneInt = vMatch ? parseInt(vMatch[1], 10) : NaN;
|
|
2143
|
+
if (!Number.isSafeInteger(milestoneInt))
|
|
2144
|
+
return false;
|
|
2145
|
+
// Canonical spelling only — see the note in roadmap-parser's scoping branch.
|
|
2146
|
+
// Accepting `0*N` here bounded a milestone whose phases were invisible, which
|
|
2147
|
+
// un-suppressed a progress percent computed off an unscoped disk count.
|
|
2148
|
+
// #2761 M3: that padding rule and the grammar both come from the owner's
|
|
2149
|
+
// `bracketMilestoneIntroSrcFor`. This line and roadmap-parser's selector were
|
|
2150
|
+
// character-identical re-typings of one pattern, so "canonical spelling only"
|
|
2151
|
+
// was a convention two files had to keep agreeing on by hand — and the drift
|
|
2152
|
+
// guard could not see either copy.
|
|
2153
|
+
// #612 round-4 (Major 1, F12): fence-aware via tokenizeHeadings, not a raw
|
|
2154
|
+
// `.test(roadmapRaw)` — a FENCED `[GSD.02]` example heading (the ONLY one
|
|
2155
|
+
// in the document, with no real section for the asserted milestone at
|
|
2156
|
+
// all) previously bounded a milestone that isn't actually in the roadmap,
|
|
2157
|
+
// un-suppressing a percent computed off the wrong (prior-milestone-plus-
|
|
2158
|
+
// whole-disk) phase set. tokenizeHeadings never produces a token for a
|
|
2159
|
+
// fenced line, so a fenced-only example can no longer satisfy this test.
|
|
2160
|
+
const bracketMilestoneHeadingRe = new RegExp(`^${bracketMilestoneIntroSrcFor(milestoneInt)}`, 'i');
|
|
2161
|
+
// #612 round-5 (Minor 1): skip ≤3-space-indented tokens — `h.offset` is
|
|
2162
|
+
// tokenizeHeadings' LINE-START offset, not the `#` character, so an
|
|
2163
|
+
// indented heading here would bound a milestone the line-start-anchored
|
|
2164
|
+
// raw predecessor never matched. Restores raw parity; see roadmap-parser's
|
|
2165
|
+
// matching selector-reconstruction comment for the full rationale.
|
|
2166
|
+
return (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmapRaw).some((h) => h.level <= 3 && roadmapRaw[h.offset] === '#' && bracketMilestoneHeadingRe.test(h.text));
|
|
2167
|
+
}
|
|
1823
2168
|
/**
|
|
1824
2169
|
* Extract the set of retired/folded phase keys from a ROADMAP milestone scope
|
|
1825
2170
|
* (#1514). A retired phase is struck through with GFM strikethrough,
|
|
@@ -1841,16 +2186,31 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
1841
2186
|
* decimal, and project-code IDs are detected alike. Returns canonical keys
|
|
1842
2187
|
* (see phaseKeyFromToken).
|
|
1843
2188
|
*/
|
|
1844
|
-
function extractRetiredPhaseNumbers(scope) {
|
|
2189
|
+
function extractRetiredPhaseNumbers(scope, convention) {
|
|
1845
2190
|
const retired = new Set();
|
|
1846
2191
|
const isChecklistOrHeading = /^\s*(?:[-*+]\s*\[[ xX]\]|#{1,6}\s)/;
|
|
1847
|
-
|
|
2192
|
+
// #612: the retirement filter has to widen with the counter it protects. The
|
|
2193
|
+
// canonical #1514 gesture strikes the checklist BULLET and leaves the detail
|
|
2194
|
+
// heading intact, so a bracket-form retirement went undetected and the phase
|
|
2195
|
+
// stayed in the denominator forever — a shipped bracket milestone could never
|
|
2196
|
+
// reach 100%. Same selection rule as the counter: a non-bracket repo compiles
|
|
2197
|
+
// the bare `Phase\s+` this line spelled before.
|
|
2198
|
+
const introSrc = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention);
|
|
2199
|
+
const phaseRefRe = new RegExp(`^[\\s*_]*${introSrc}([\\w][\\w.-]*)`, 'i');
|
|
2200
|
+
// #612 round-5 (Major 1): fence-aware on the BRACKET path only — a fenced
|
|
2201
|
+
// AUTHORING EXAMPLE of the #1514 retirement gesture, spelled in bracket
|
|
2202
|
+
// form, must not retire a real phase. Reuses markdown-sectionizer's
|
|
2203
|
+
// single-owner stripFencedCode rather than a second fence parser. Legacy
|
|
2204
|
+
// stays the raw `scope`, byte-identical — its own fenced-example hazard is
|
|
2205
|
+
// pre-existing and out of scope.
|
|
2206
|
+
const scanScope = convention === 'bracket' ? (0, markdown_sectionizer_cjs_1.stripFencedCode)(scope).text : scope;
|
|
2207
|
+
for (const line of scanScope.split(/\r?\n/)) {
|
|
1848
2208
|
if (!isChecklistOrHeading.test(line))
|
|
1849
2209
|
continue;
|
|
1850
2210
|
const strikeSpan = /~~([^~]*?)~~/g;
|
|
1851
2211
|
let s;
|
|
1852
2212
|
while ((s = strikeSpan.exec(line)) !== null) {
|
|
1853
|
-
const phaseRef =
|
|
2213
|
+
const phaseRef = phaseRefRe.exec(s[1]);
|
|
1854
2214
|
// Require a digit so struck prose like ~~Phase Overview~~ is ignored.
|
|
1855
2215
|
if (phaseRef && /\d/.test(phaseRef[1]))
|
|
1856
2216
|
retired.add(phaseKeyFromToken(phaseRef[1]));
|
|
@@ -1858,12 +2218,110 @@ function extractRetiredPhaseNumbers(scope) {
|
|
|
1858
2218
|
}
|
|
1859
2219
|
return retired;
|
|
1860
2220
|
}
|
|
2221
|
+
/**
|
|
2222
|
+
* #612 (round-4 fix): the single shared implementation for the phase-heading
|
|
2223
|
+
* counter `buildStateFrontmatter` (read path) and `cmdStateSync` (write
|
|
2224
|
+
* path) each built inline as an independent copy. The comment at each call
|
|
2225
|
+
* site already claimed "the two counters must see the same phases or
|
|
2226
|
+
* `state json` and `state sync` report different totals for one repo
|
|
2227
|
+
* (#3242 Bug B)" — this makes that invariant STRUCTURAL (one implementation,
|
|
2228
|
+
* two call sites) instead of two copies a future edit could silently
|
|
2229
|
+
* diverge.
|
|
2230
|
+
*
|
|
2231
|
+
* Two DELIBERATELY DIFFERENT counting strategies, selected by `convention`:
|
|
2232
|
+
*
|
|
2233
|
+
* - BRACKET: counts via `tokenizeHeadings(scope)` at levels 2-4 (mirroring
|
|
2234
|
+
* `getMilestonePhaseFilter`'s own level bound, `roadmap-parser.cts:1090`),
|
|
2235
|
+
* testing each heading's (hash-stripped, fence-STRIPPED-by-construction)
|
|
2236
|
+
* text against the phase-heading-intro grammar directly. Fence-aware by
|
|
2237
|
+
* construction — `tokenizeHeadings` never produces a token for a fenced
|
|
2238
|
+
* line — closing round-4's Major 1: a fenced EXAMPLE phase heading in the
|
|
2239
|
+
* preamble (`` ### [GSD.02] 05: Example phase `` inside a
|
|
2240
|
+
* ` ```markdown ` block) previously inflated this count via the raw regex
|
|
2241
|
+
* below, which ran over the whole scope STRING with no fence awareness at
|
|
2242
|
+
* all (F9, F10 — `total_phases` read 3 where the milestone has 2 real
|
|
2243
|
+
* phases). The producer (`extractCurrentMilestone`'s returned scope
|
|
2244
|
+
* string) is deliberately NOT changed — every other consumer of that
|
|
2245
|
+
* string needs its full content fidelity, and the legacy path's identity
|
|
2246
|
+
* forbids touching the string all consumers share; this fixes the
|
|
2247
|
+
* COUNTING, not the scope.
|
|
2248
|
+
*
|
|
2249
|
+
* - LEGACY (any non-bracket convention, including unresolved/null): retain
|
|
2250
|
+
* the existing raw `content.exec()` counting strategy. On the read path,
|
|
2251
|
+
* route sentinel exclusion through #3185's canonical predicate; the sync
|
|
2252
|
+
* path intentionally retains its pre-existing absence of that exclusion.
|
|
2253
|
+
*
|
|
2254
|
+
* `applyConventionTokenSentinelRules` makes the remaining convention-specific
|
|
2255
|
+
* asymmetry explicit. Both read and sync exclude bare bracket token 999; only
|
|
2256
|
+
* the read path excludes canonical legacy sentinels. Both bracket paths also
|
|
2257
|
+
* retain the bracket-id and bare-0 rules. Sharing the implementation therefore
|
|
2258
|
+
* cannot silently move either convention's total.
|
|
2259
|
+
*/
|
|
2260
|
+
function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyConventionTokenSentinelRules) {
|
|
2261
|
+
let count = 0;
|
|
2262
|
+
if (convention === 'bracket') {
|
|
2263
|
+
const introSrc = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true);
|
|
2264
|
+
const phaseHeadingPattern = new RegExp(`^${introSrc}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'i');
|
|
2265
|
+
for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(scope)) {
|
|
2266
|
+
if (h.level < 2 || h.level > 4)
|
|
2267
|
+
continue;
|
|
2268
|
+
const m = phaseHeadingPattern.exec(h.text);
|
|
2269
|
+
if (!m)
|
|
2270
|
+
continue;
|
|
2271
|
+
const bracketId = m[1];
|
|
2272
|
+
const token = m[2];
|
|
2273
|
+
// Only count tokens that contain at least one digit — excludes
|
|
2274
|
+
// pure-word section headings (Overview, Details) while keeping
|
|
2275
|
+
// numeric phases (01, 05.1) and project-code IDs (PROJ-42).
|
|
2276
|
+
if (!/\d/.test(token))
|
|
2277
|
+
continue;
|
|
2278
|
+
// #612 READING-B: a bracket heading carries its sentinel in the
|
|
2279
|
+
// bracket, so `### [GSD.999] 01:` is an icebox item even though its
|
|
2280
|
+
// token is `01`.
|
|
2281
|
+
if (bracketId && isSentinelPhaseId(`${bracketId}-${token}`, 'bracket'))
|
|
2282
|
+
continue;
|
|
2283
|
+
// #612: under bracket the token rule composes with the bracket-id
|
|
2284
|
+
// check as the engine's {0, 999} sentinel set.
|
|
2285
|
+
if (bracketId && /^0\b/.test(token))
|
|
2286
|
+
continue;
|
|
2287
|
+
if (applyConventionTokenSentinelRules && /^999\b/.test(token))
|
|
2288
|
+
continue;
|
|
2289
|
+
// #1514: retired/folded phases are struck through in the ROADMAP;
|
|
2290
|
+
// exclude them from the denominator (they can never be completed).
|
|
2291
|
+
if (retiredPhaseNums.has(phaseKeyFromToken(token)))
|
|
2292
|
+
continue;
|
|
2293
|
+
count++;
|
|
2294
|
+
}
|
|
2295
|
+
return count;
|
|
2296
|
+
}
|
|
2297
|
+
// LEGACY stays on the pre-round-4 raw exec loop. #3185 owns the read-path
|
|
2298
|
+
// sentinel predicate; sync deliberately preserves its prior behavior.
|
|
2299
|
+
const phaseHeadingPattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true)}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi');
|
|
2300
|
+
let m;
|
|
2301
|
+
while ((m = phaseHeadingPattern.exec(scope)) !== null) {
|
|
2302
|
+
const token = m[1];
|
|
2303
|
+
if (!/\d/.test(token))
|
|
2304
|
+
continue;
|
|
2305
|
+
if (applyConventionTokenSentinelRules && isSentinelPhaseId(token))
|
|
2306
|
+
continue;
|
|
2307
|
+
if (retiredPhaseNums.has(phaseKeyFromToken(token)))
|
|
2308
|
+
continue;
|
|
2309
|
+
count++;
|
|
2310
|
+
}
|
|
2311
|
+
return count;
|
|
2312
|
+
}
|
|
1861
2313
|
/**
|
|
1862
2314
|
* Extract machine-readable fields from STATE.md markdown body and build
|
|
1863
2315
|
* a YAML frontmatter object. Allows hooks and scripts to read state
|
|
1864
2316
|
* reliably via `state json` instead of fragile regex parsing.
|
|
1865
2317
|
*/
|
|
1866
|
-
function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPhases
|
|
2318
|
+
function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPhases,
|
|
2319
|
+
// #4094: the stored siblings of storedTotalPhases, threaded from each call
|
|
2320
|
+
// site exactly the same way — see readStoredProgressCounter below. Under the
|
|
2321
|
+
// #3354/#3573 withhold condition the disk scan returns null for all four
|
|
2322
|
+
// counters, and these stored values are what the progress block falls back
|
|
2323
|
+
// to (else the keys are omitted).
|
|
2324
|
+
storedCompletedPhases, storedTotalPlans, storedCompletedPlans) {
|
|
1867
2325
|
// #2956: scope `Phase` extraction to ## Current Position (mirrors the read
|
|
1868
2326
|
// path in cmdStateSnapshot and the Stopped At / Paused At ## Session scoping
|
|
1869
2327
|
// below). Phase canonically lives in ## Current Position (templates/state.md);
|
|
@@ -1953,6 +2411,10 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1953
2411
|
// from the pre-existing frontmatter fields parsed above, a path this phase
|
|
1954
2412
|
// does not touch and which predates listMilestonePhaseDirs entirely.
|
|
1955
2413
|
let diskScope = SCOPE.COMPLETE;
|
|
2414
|
+
// #612: resolved ONCE per call, federated workstream -> root, and shared by
|
|
2415
|
+
// the heading counter, the retirement filter and the retired-directory skip so
|
|
2416
|
+
// no two of them can split on different answers.
|
|
2417
|
+
const phaseConvention = cwd ? resolvePhaseIdConvention(cwd) : null;
|
|
1956
2418
|
if (cwd) {
|
|
1957
2419
|
try {
|
|
1958
2420
|
const phasesDir = planningPaths(cwd).phases;
|
|
@@ -1973,7 +2435,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1973
2435
|
roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
|
|
1974
2436
|
if (roadmapRaw !== null) {
|
|
1975
2437
|
roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
|
1976
|
-
retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope);
|
|
2438
|
+
retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope, phaseConvention);
|
|
1977
2439
|
}
|
|
1978
2440
|
}
|
|
1979
2441
|
catch { /* fall through: no roadmap scope → no retired exclusion */ }
|
|
@@ -1984,7 +2446,11 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1984
2446
|
// CURRENT (stored) milestone" — routed through the canonical owner
|
|
1985
2447
|
// instead of a hand-rolled readdirSync + isDirInMilestone filter
|
|
1986
2448
|
// (which also never excluded sentinels, unlike the owner).
|
|
1987
|
-
const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, {
|
|
2449
|
+
const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, {
|
|
2450
|
+
cwd,
|
|
2451
|
+
versionOverride: storedMilestone ?? null,
|
|
2452
|
+
phaseIdConvention: phaseConvention,
|
|
2453
|
+
});
|
|
1988
2454
|
// Bug #2445: when stale phase dirs from a prior milestone remain in
|
|
1989
2455
|
// .planning/phases/ alongside new dirs with the same phase number,
|
|
1990
2456
|
// de-duplicate by normalized phase number keeping exactly one dir
|
|
@@ -1996,7 +2462,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1996
2462
|
// artifact; drop it from the disk phase set so it counts toward
|
|
1997
2463
|
// neither the denominator nor the numerator (mirrors the heading
|
|
1998
2464
|
// exclusion below). Project-code-aware via phaseKeyFromDir.
|
|
1999
|
-
if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir)))
|
|
2465
|
+
if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir, phaseConvention)))
|
|
2000
2466
|
continue;
|
|
2001
2467
|
// #3185: dedup grouping routed through the canonical phaseKeyFromDir
|
|
2002
2468
|
// (src/phase-id.cts) instead of a local leading-digits regex that
|
|
@@ -2005,7 +2471,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
2005
2471
|
// so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on
|
|
2006
2472
|
// multi-segment milestone dirs. Same key surface used two lines
|
|
2007
2473
|
// above for the retiredPhaseNums exclusion, so both filters agree.
|
|
2008
|
-
const key = phaseKeyFromDir(dir);
|
|
2474
|
+
const key = phaseKeyFromDir(dir, phaseConvention);
|
|
2009
2475
|
if (!seenPhaseNums.has(key)) {
|
|
2010
2476
|
seenPhaseNums.set(key, dir);
|
|
2011
2477
|
}
|
|
@@ -2049,31 +2515,15 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
2049
2515
|
if (isPhaseComplete(phaseDir).value.complete)
|
|
2050
2516
|
diskCompletedPhases++;
|
|
2051
2517
|
}
|
|
2052
|
-
// Count phase headings from ROADMAP
|
|
2053
|
-
//
|
|
2054
|
-
//
|
|
2055
|
-
//
|
|
2056
|
-
//
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
let m;
|
|
2062
|
-
while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
|
|
2063
|
-
// Only count tokens that contain at least one digit — excludes
|
|
2064
|
-
// pure-word section headings (Overview, Details) while keeping
|
|
2065
|
-
// numeric phases (01, 05.1) and project-code IDs (PROJ-42).
|
|
2066
|
-
// Also exclude sentinel phases (0 and 999.x backlog).
|
|
2067
|
-
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
2068
|
-
if (!/\d/.test(m[1]) || isSentinelPhaseId(m[1]))
|
|
2069
|
-
continue;
|
|
2070
|
-
// #1514: retired/folded phases are struck through in the ROADMAP;
|
|
2071
|
-
// exclude them from the denominator (they can never be completed).
|
|
2072
|
-
if (retiredPhaseNums.has(phaseKeyFromToken(m[1])))
|
|
2073
|
-
continue;
|
|
2074
|
-
roadmapPhaseCount++;
|
|
2075
|
-
}
|
|
2076
|
-
}
|
|
2518
|
+
// Count phase headings from ROADMAP — single source of truth for
|
|
2519
|
+
// total_phases (#549). #612 round-4: shared with cmdStateSync's
|
|
2520
|
+
// identical-purpose counter via countRoadmapPhaseHeadings (above
|
|
2521
|
+
// extractRetiredPhaseNumbers). The shared helper composes its
|
|
2522
|
+
// fence-aware bracket strategy with #3185's canonical legacy
|
|
2523
|
+
// sentinel predicate for this read-path call.
|
|
2524
|
+
const roadmapPhaseCount = roadmapScope !== null
|
|
2525
|
+
? countRoadmapPhaseHeadings(roadmapScope, phaseConvention, retiredPhaseNums, true)
|
|
2526
|
+
: 0;
|
|
2077
2527
|
cached = (() => {
|
|
2078
2528
|
// #1761 read-path: mirror the cmdStateSync guard (#1794). When the
|
|
2079
2529
|
// asserted milestone version can't be bounded to a versioned ROADMAP
|
|
@@ -2098,7 +2548,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
2098
2548
|
// the prior inline regex had no boundary assertion after the
|
|
2099
2549
|
// version token, so `v2.0` matched inside `v2.0.1` (#2562-class
|
|
2100
2550
|
// defect, design row 17).
|
|
2101
|
-
milestoneBounded =
|
|
2551
|
+
milestoneBounded = isMilestoneBounded(roadmapRaw, String(assertedMilestoneVersion).trim(), phaseConvention);
|
|
2102
2552
|
}
|
|
2103
2553
|
// #2828: distinguish a FLAT unmilestoned roadmap (no milestone sectioning
|
|
2104
2554
|
// at all — only Phase headings) from a MILESTONED-but-unbounded one
|
|
@@ -2140,7 +2590,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
2140
2590
|
// the disk count is the only source and remains correct.
|
|
2141
2591
|
const milestonedButUnbounded = !milestoneBounded && roadmapHasAnyMilestoneSection;
|
|
2142
2592
|
if (milestonedButUnbounded) {
|
|
2143
|
-
process.stderr.write(`gsd: warning — milestone '${String(assertedMilestoneVersion ?? '').trim()}' is asserted in STATE.md but matches no ROADMAP heading, and the ROADMAP carries milestone section(s) — one (#3642) or several (#3354) — none matching it; the whole-document count would attribute a foreign section's phases to this milestone and the on-disk phase-directory count would understate the declared total, so progress
|
|
2593
|
+
process.stderr.write(`gsd: warning — milestone '${String(assertedMilestoneVersion ?? '').trim()}' is asserted in STATE.md but matches no ROADMAP heading, and the ROADMAP carries milestone section(s) — one (#3642) or several (#3354) — none matching it; the whole-document count would attribute a foreign section's phases to this milestone and the on-disk phase-directory count would understate the declared total, so the progress counters (total_phases, completed_phases, total_plans, completed_plans) are left at their stored values. (#3354/#3642/#4094)\n`);
|
|
2144
2594
|
}
|
|
2145
2595
|
// #3573: the roadmap-absent sibling of the #3354 shape. With ROADMAP.md
|
|
2146
2596
|
// absent/unreadable the #549 heading counter never ran (roadmapScope
|
|
@@ -2157,21 +2607,31 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
2157
2607
|
typeof storedMilestone === 'string' &&
|
|
2158
2608
|
storedMilestone.trim() !== '';
|
|
2159
2609
|
if (roadmapAbsentWithAssertedMilestone) {
|
|
2160
|
-
process.stderr.write(`gsd: warning — milestone '${storedMilestone.trim()}' is asserted in STATE.md but ROADMAP.md is absent or unreadable, so the phase-heading total cannot be derived; the on-disk phase-directory count would understate the declared total, so progress
|
|
2610
|
+
process.stderr.write(`gsd: warning — milestone '${storedMilestone.trim()}' is asserted in STATE.md but ROADMAP.md is absent or unreadable, so the phase-heading total cannot be derived; the on-disk phase-directory count would understate the declared total, so the progress counters (total_phases, completed_phases, total_plans, completed_plans) are left at their stored values. (#3573) (#4094)\n`);
|
|
2161
2611
|
}
|
|
2612
|
+
// #4094: the withhold condition covers ALL FOUR progress counters,
|
|
2613
|
+
// not just total_phases. completed_phases / total_plans /
|
|
2614
|
+
// completed_plans are accumulated from the exact same phaseDirs
|
|
2615
|
+
// walk as total_phases (same loop, same scope, same filters), so
|
|
2616
|
+
// whenever that walk's scope is known-untrustworthy — the exact
|
|
2617
|
+
// condition #3354 established — they are equally untrustworthy.
|
|
2618
|
+
// Pre-#4094 only totalPhases was nulled here, so every resyncing
|
|
2619
|
+
// write silently clobbered the three stored siblings with the
|
|
2620
|
+
// under-scoped disk numbers.
|
|
2621
|
+
const diskCountsWithheld = milestonedButUnbounded || roadmapAbsentWithAssertedMilestone;
|
|
2162
2622
|
return {
|
|
2163
2623
|
// The two WITHHOLD shapes (#3354 milestoned-but-unbounded, #3573
|
|
2164
2624
|
// roadmap-absent-with-asserted-milestone) must be evaluated BEFORE
|
|
2165
2625
|
// safeToUseRoadmapCount — in the #3573 shape milestoneBounded is
|
|
2166
2626
|
// vacuously true (its gate requires roadmapRaw), so the safe-count
|
|
2167
2627
|
// arm would otherwise swallow the withhold.
|
|
2168
|
-
totalPhases:
|
|
2628
|
+
totalPhases: diskCountsWithheld
|
|
2169
2629
|
? null
|
|
2170
2630
|
: (safeToUseRoadmapCount ? Math.max(phaseDirs.length, roadmapPhaseCount) : phaseDirs.length),
|
|
2171
2631
|
milestoneBounded,
|
|
2172
|
-
completedPhases: diskCompletedPhases,
|
|
2173
|
-
totalPlans: diskTotalPlans,
|
|
2174
|
-
completedPlans: diskTotalSummaries,
|
|
2632
|
+
completedPhases: diskCountsWithheld ? null : diskCompletedPhases,
|
|
2633
|
+
totalPlans: diskCountsWithheld ? null : diskTotalPlans,
|
|
2634
|
+
completedPlans: diskCountsWithheld ? null : diskTotalSummaries,
|
|
2175
2635
|
phaseDirScope,
|
|
2176
2636
|
};
|
|
2177
2637
|
})();
|
|
@@ -2189,9 +2649,31 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
2189
2649
|
else if (storedTotalPhases !== null && storedTotalPhases !== undefined) {
|
|
2190
2650
|
totalPhases = storedTotalPhases;
|
|
2191
2651
|
}
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2652
|
+
// #4094: the same withhold-then-fall-back-to-stored pattern for the
|
|
2653
|
+
// three sibling counters. They are derived from the identical
|
|
2654
|
+
// phaseDirs walk, so cached.* === null here means the SAME withheld
|
|
2655
|
+
// condition — keep the stored frontmatter value when the caller can
|
|
2656
|
+
// supply it; else leave null (key omitted). Note completedPhases /
|
|
2657
|
+
// completedPlans have NO body-annotation fallback (only the totals
|
|
2658
|
+
// have body annotations), so an unstored-withheld counter is omitted.
|
|
2659
|
+
if (cached.completedPhases !== null) {
|
|
2660
|
+
completedPhases = cached.completedPhases;
|
|
2661
|
+
}
|
|
2662
|
+
else if (storedCompletedPhases !== null && storedCompletedPhases !== undefined) {
|
|
2663
|
+
completedPhases = storedCompletedPhases;
|
|
2664
|
+
}
|
|
2665
|
+
if (cached.totalPlans !== null) {
|
|
2666
|
+
totalPlans = cached.totalPlans;
|
|
2667
|
+
}
|
|
2668
|
+
else if (storedTotalPlans !== null && storedTotalPlans !== undefined) {
|
|
2669
|
+
totalPlans = storedTotalPlans;
|
|
2670
|
+
}
|
|
2671
|
+
if (cached.completedPlans !== null) {
|
|
2672
|
+
completedPlans = cached.completedPlans;
|
|
2673
|
+
}
|
|
2674
|
+
else if (storedCompletedPlans !== null && storedCompletedPlans !== undefined) {
|
|
2675
|
+
completedPlans = storedCompletedPlans;
|
|
2676
|
+
}
|
|
2195
2677
|
milestoneUnbounded = cached.milestoneBounded === false;
|
|
2196
2678
|
diskScope = cached.phaseDirScope;
|
|
2197
2679
|
}
|
|
@@ -2483,12 +2965,21 @@ function readStateHeadFreshness(cwd, stateHead) {
|
|
|
2483
2965
|
* instead of being clobbered by the on-disk phase-directory count.
|
|
2484
2966
|
*/
|
|
2485
2967
|
function readStoredTotalPhases(existingFm) {
|
|
2968
|
+
return readStoredProgressCounter(existingFm, 'total_phases');
|
|
2969
|
+
}
|
|
2970
|
+
/**
|
|
2971
|
+
* #4094: the three sibling readers of readStoredTotalPhases, one per progress
|
|
2972
|
+
* counter the #3354/#3573 withhold now protects. All four counters come from
|
|
2973
|
+
* the same disk-scan walk and are withheld together; these readers feed the
|
|
2974
|
+
* stored-value fallback for the three that previously had none.
|
|
2975
|
+
*/
|
|
2976
|
+
function readStoredProgressCounter(existingFm, key) {
|
|
2486
2977
|
if (!existingFm || typeof existingFm !== 'object')
|
|
2487
2978
|
return null;
|
|
2488
2979
|
const progress = existingFm['progress'];
|
|
2489
2980
|
if (!progress || typeof progress !== 'object')
|
|
2490
2981
|
return null;
|
|
2491
|
-
const raw = progress[
|
|
2982
|
+
const raw = progress[key];
|
|
2492
2983
|
if (raw === null || raw === undefined)
|
|
2493
2984
|
return null;
|
|
2494
2985
|
if (typeof raw === 'string' && raw.trim() === '')
|
|
@@ -2496,6 +2987,15 @@ function readStoredTotalPhases(existingFm) {
|
|
|
2496
2987
|
const n = Number(raw);
|
|
2497
2988
|
return Number.isFinite(n) ? n : null;
|
|
2498
2989
|
}
|
|
2990
|
+
function readStoredCompletedPhases(existingFm) {
|
|
2991
|
+
return readStoredProgressCounter(existingFm, 'completed_phases');
|
|
2992
|
+
}
|
|
2993
|
+
function readStoredTotalPlans(existingFm) {
|
|
2994
|
+
return readStoredProgressCounter(existingFm, 'total_plans');
|
|
2995
|
+
}
|
|
2996
|
+
function readStoredCompletedPlans(existingFm) {
|
|
2997
|
+
return readStoredProgressCounter(existingFm, 'completed_plans');
|
|
2998
|
+
}
|
|
2499
2999
|
function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanentEmptyFallback) {
|
|
2500
3000
|
// Read existing frontmatter BEFORE stripping — it may contain values
|
|
2501
3001
|
// that the body no longer has (e.g., Status field removed by an agent).
|
|
@@ -2533,7 +3033,7 @@ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanent
|
|
|
2533
3033
|
// milestoned-but-unbounded withhold can preserve it across the write
|
|
2534
3034
|
// (the derived progress sub-block replaces the stored one wholesale below,
|
|
2535
3035
|
// so an omitted key would otherwise DELETE the stored value).
|
|
2536
|
-
const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm));
|
|
3036
|
+
const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
|
|
2537
3037
|
// Preserve existing frontmatter status when body-derived status is 'unknown'.
|
|
2538
3038
|
// This prevents a missing Status: field in the body from overwriting a
|
|
2539
3039
|
// previously valid status (e.g., 'executing' → 'unknown').
|
|
@@ -3291,6 +3791,7 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
3291
3791
|
}
|
|
3292
3792
|
}
|
|
3293
3793
|
}
|
|
3794
|
+
let finalContent = syncedContent;
|
|
3294
3795
|
if (preservation.mutated || authoritativeReasserted) {
|
|
3295
3796
|
// #3742: preservation RESTORES frontmatter keys the body-derived rebuild
|
|
3296
3797
|
// could not produce (e.g. `current_phase` on a layout with no body
|
|
@@ -3311,9 +3812,15 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
3311
3812
|
}
|
|
3312
3813
|
const yamlStr = reconstructFrontmatter(preservation.postFm);
|
|
3313
3814
|
const body = stripFrontmatter(syncedContent);
|
|
3314
|
-
|
|
3815
|
+
finalContent = `---\n${yamlStr}\n---\n\n${body}`;
|
|
3816
|
+
}
|
|
3817
|
+
const persistedPercent = (0, state_document_cjs_1.toFiniteNumber)(preservation.postFm['progress'] && preservation.postFm['progress']['percent']);
|
|
3818
|
+
if (persistedPercent !== null) {
|
|
3819
|
+
const reconciled = stateReplaceProgressPercent(finalContent, persistedPercent);
|
|
3820
|
+
if (reconciled !== null)
|
|
3821
|
+
finalContent = reconciled;
|
|
3315
3822
|
}
|
|
3316
|
-
return
|
|
3823
|
+
return finalContent;
|
|
3317
3824
|
}
|
|
3318
3825
|
/**
|
|
3319
3826
|
* ADR-3408 §8.3 — the ONE write-seam composition: `syncStateFrontmatter` then
|
|
@@ -3858,7 +4365,7 @@ function cmdStateJson(cwd, raw) {
|
|
|
3858
4365
|
// reports the phase-directory count while the persisted file preserves the
|
|
3859
4366
|
// stored total, exactly the write/read divergence #3354 closed for its shape.
|
|
3860
4367
|
const storedMilestoneJson = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
|
|
3861
|
-
const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm));
|
|
4368
|
+
const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
|
|
3862
4369
|
// ADR-3408 §8.5 / D3: route stopped_at / paused_at / status / current_phase /
|
|
3863
4370
|
// current_phase_name / current_plan through the SAME `preserve-when-unchanged`
|
|
3864
4371
|
// executor the write path uses (`applyPreserveWhenUnchanged`), instead of a
|
|
@@ -4558,10 +5065,28 @@ function cmdStateValidate(cwd, raw, opts = {}) {
|
|
|
4558
5065
|
emit({ valid: false, warnings, scope });
|
|
4559
5066
|
return;
|
|
4560
5067
|
}
|
|
5068
|
+
// #612: #3208 replaced this lookup's `startsWith` prefix test with the
|
|
5069
|
+
// canonical key comparison — which is the right surface, and is exactly why it
|
|
5070
|
+
// now needs the convention. `phaseKeyFromDir` refuses to read a bracket
|
|
5071
|
+
// directory without an explicit signal (a bracket dir is string-
|
|
5072
|
+
// indistinguishable from the legacy letter-prefixed-decimal family, ADR-2121),
|
|
5073
|
+
// so un-threaded it returns the WHOLE dir name as the key —
|
|
5074
|
+
// `GSD.02-05-delta` -> `GSD.02-5-DELTA` — while `selectedPhaseKey` is the bare
|
|
5075
|
+
// `05` that `parsePhaseFromProse` yields. The two sides of one comparison were
|
|
5076
|
+
// derived under different conventions, which is #2562's defect class and the
|
|
5077
|
+
// thing this file's other three `phaseKeyFromDir` call sites already thread
|
|
5078
|
+
// against. Un-threaded, a bracket repo whose phase directory plainly exists
|
|
5079
|
+
// reports `no phase directory matches phase 05` and `valid: false` — a
|
|
5080
|
+
// wrong-and-confident answer on precisely the repos this convention supports.
|
|
5081
|
+
// Resolved here rather than reusing a caller's value because cmdStateValidate
|
|
5082
|
+
// has no other convention-dependent read. Non-bracket conventions (null,
|
|
5083
|
+
// 'milestone-prefixed', unresolvable) are byte-identical to the un-threaded
|
|
5084
|
+
// call by construction: `extractPhaseToken` branches only on `=== 'bracket'`.
|
|
5085
|
+
const validateConvention = resolvePhaseIdConvention(cwd);
|
|
4561
5086
|
let phaseDirPath;
|
|
4562
5087
|
try {
|
|
4563
5088
|
const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
4564
|
-
const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name) === selectedPhaseKey);
|
|
5089
|
+
const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name, validateConvention) === selectedPhaseKey);
|
|
4565
5090
|
if (!phaseDir) {
|
|
4566
5091
|
warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: no phase directory matches phase ${currentPhase}`, 'Create a phase directory matching the current phase or correct current_phase'));
|
|
4567
5092
|
emit({ valid: false, warnings, scope });
|
|
@@ -4730,22 +5255,51 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
4730
5255
|
let syncRoadmapScope = null;
|
|
4731
5256
|
let syncRoadmapRaw = null;
|
|
4732
5257
|
let syncRetiredPhaseNums = new Set();
|
|
5258
|
+
const syncConvention = resolvePhaseIdConvention(cwd);
|
|
4733
5259
|
try {
|
|
4734
5260
|
const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'));
|
|
4735
5261
|
if (roadmapRaw !== null) {
|
|
4736
5262
|
syncRoadmapRaw = roadmapRaw;
|
|
4737
5263
|
syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
|
4738
|
-
syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope);
|
|
5264
|
+
syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope, syncConvention);
|
|
4739
5265
|
}
|
|
4740
5266
|
}
|
|
4741
5267
|
catch { /* fall through: no roadmap scope → no retired exclusion */ }
|
|
5268
|
+
// #2761 Major 1 (round-2 adversarial review): this disk scan fed
|
|
5269
|
+
// totalDiskPlans/totalDiskSummaries/diskCompletedPhases/syncTotalPhases
|
|
5270
|
+
// below UNFILTERED — no milestone-window filter, unlike
|
|
5271
|
+
// buildStateFrontmatter's identical-purpose scan a few hundred lines above
|
|
5272
|
+
// (`:1698`). One command (`state sync`) therefore wrote TWO contradictory
|
|
5273
|
+
// numbers into the same STATE.md: frontmatter total_phases/completed_phases
|
|
5274
|
+
// milestone-scoped correctly (via the READ derivation), body Progress
|
|
5275
|
+
// percent computed from the whole disk. On the ADR-canonical version-less
|
|
5276
|
+
// bracket fixture (4 dirs, 3 complete; asserted milestone = 2 phases, both
|
|
5277
|
+
// complete): body wrote 75% where 100% is true (repro3).
|
|
5278
|
+
//
|
|
5279
|
+
// GATED on `syncConvention === 'bracket'` — an unconditional filter would
|
|
5280
|
+
// ALSO move LEGACY sync percents, since the milestone-scoping-vs-whole-disk
|
|
5281
|
+
// divergence this fixes is engine-wide, not bracket-specific; the gate
|
|
5282
|
+
// keeps legacy byte-identical, which is the binding constraint here. This
|
|
5283
|
+
// is a DEVIATION from an earlier "mirror :1698 unconditionally" phrasing —
|
|
5284
|
+
// deliberate, not an oversight: legacy repos are DOWNSTREAM of a Progress
|
|
5285
|
+
// percent that has read this way for a long time, and moving it as a side
|
|
5286
|
+
// effect of a bracket-only PR is out of this fix's scope.
|
|
5287
|
+
// Upstream #3185 made `listMilestonePhaseDirs` the sole phase-directory
|
|
5288
|
+
// enumeration owner; it delegates window membership to
|
|
5289
|
+
// getMilestonePhaseFilter. Cache that owner's bracket result as a set and
|
|
5290
|
+
// compose it with this scan, rather than restoring the retired direct
|
|
5291
|
+
// parser dependency. Legacy retains this scan's prior pass-all behavior.
|
|
5292
|
+
const syncMilestonePhaseDirs = syncConvention === 'bracket'
|
|
5293
|
+
? new Set(listMilestonePhaseDirs(phasesDir, { cwd, phaseIdConvention: syncConvention }).value)
|
|
5294
|
+
: null;
|
|
4742
5295
|
// Scan all phases
|
|
4743
5296
|
let entries;
|
|
4744
5297
|
try {
|
|
4745
5298
|
entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
|
|
4746
5299
|
.filter(e => e.isDirectory())
|
|
4747
5300
|
.map(e => e.name)
|
|
4748
|
-
.filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name))))
|
|
5301
|
+
.filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name, syncConvention))))
|
|
5302
|
+
.filter(name => syncMilestonePhaseDirs === null || syncMilestonePhaseDirs.has(name))
|
|
4749
5303
|
.sort();
|
|
4750
5304
|
}
|
|
4751
5305
|
catch {
|
|
@@ -4793,28 +5347,18 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
4793
5347
|
}
|
|
4794
5348
|
}
|
|
4795
5349
|
// Determine total phases from ROADMAP (may be larger than realized disk dirs).
|
|
4796
|
-
//
|
|
4797
|
-
//
|
|
4798
|
-
//
|
|
4799
|
-
//
|
|
5350
|
+
// #612 round-4: shares countRoadmapPhaseHeadings with buildStateFrontmatter
|
|
5351
|
+
// (defined just above extractRetiredPhaseNumbers) so both report
|
|
5352
|
+
// consistent totals off the SAME implementation, not two independently
|
|
5353
|
+
// maintained copies (#3242 Bug B).
|
|
5354
|
+
// #612 round-5: bracket sync enables the same bare-token 999 exclusion as
|
|
5355
|
+
// the read path and getMilestonePhaseFilter, preventing frontmatter/body
|
|
5356
|
+
// disagreement. Non-bracket conventions still pass false, preserving the
|
|
5357
|
+
// pre-existing legacy sync behavior while #3185 remains the read-path owner.
|
|
4800
5358
|
let syncTotalPhases = null;
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
|
|
4805
|
-
let m;
|
|
4806
|
-
while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== null) {
|
|
4807
|
-
// Only count tokens that contain at least one digit — excludes
|
|
4808
|
-
// pure-word section headings (Overview, Details) while keeping
|
|
4809
|
-
// numeric phases (01, 05.1) and project-code IDs (PROJ-42).
|
|
4810
|
-
if (!/\d/.test(m[1]))
|
|
4811
|
-
continue;
|
|
4812
|
-
// #1514: retired/folded phases are struck through; exclude from total.
|
|
4813
|
-
if (syncRetiredPhaseNums.has(phaseKeyFromToken(m[1])))
|
|
4814
|
-
continue;
|
|
4815
|
-
roadmapPhaseCount++;
|
|
4816
|
-
}
|
|
4817
|
-
}
|
|
5359
|
+
const roadmapPhaseCount = syncRoadmapScope !== null
|
|
5360
|
+
? countRoadmapPhaseHeadings(syncRoadmapScope, syncConvention, syncRetiredPhaseNums, syncConvention === 'bracket')
|
|
5361
|
+
: 0;
|
|
4818
5362
|
if (roadmapPhaseCount > 0) {
|
|
4819
5363
|
syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
|
|
4820
5364
|
}
|
|
@@ -4834,8 +5378,9 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
4834
5378
|
if (versionStr !== null && syncRoadmapRaw !== null) {
|
|
4835
5379
|
// #3184: routed through the single owner (roadmap-parser.cjs) instead of
|
|
4836
5380
|
// a hand-rolled, unbounded-substring re-derivation — see the identical
|
|
4837
|
-
// fix in buildStateFrontmatter above.
|
|
4838
|
-
|
|
5381
|
+
// fix in buildStateFrontmatter above. #612 composes its gated bracket
|
|
5382
|
+
// extension on top inside isMilestoneBounded.
|
|
5383
|
+
milestoneBounded = isMilestoneBounded(syncRoadmapRaw, versionStr, syncConvention);
|
|
4839
5384
|
}
|
|
4840
5385
|
let percent = null;
|
|
4841
5386
|
if (!milestoneBounded) {
|
|
@@ -4853,6 +5398,20 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
4853
5398
|
// it here (discarding `.value`, which duplicates `entries`'s own
|
|
4854
5399
|
// retired-phase-filtered listing) gets the real scope without changing
|
|
4855
5400
|
// the disk-scan totals computed above.
|
|
5401
|
+
//
|
|
5402
|
+
// #2761 (round-11 M2 follow-up): deliberately NOT threading
|
|
5403
|
+
// `phaseIdConvention` here, unlike the other call sites this same PR
|
|
5404
|
+
// converts. Only `.scope` is consumed (the `.value` directory list is
|
|
5405
|
+
// thrown away), and inside `getMilestonePhaseFilter` `scope` is computed
|
|
5406
|
+
// from `extractCurrentMilestoneScoped`/`classifyMilestoneWindow` BEFORE
|
|
5407
|
+
// `headingConvention` is resolved — `phaseIdConvention` only reaches the
|
|
5408
|
+
// heading/dir MEMBERSHIP scan (`scanMilestonePhaseIds`, `isDirInMilestone`)
|
|
5409
|
+
// that produces `.value`, never the scope discriminator itself. So the
|
|
5410
|
+
// `undefined` default here (lazy resolve-from-config) and an explicitly
|
|
5411
|
+
// threaded `syncConvention` would compute the identical `scope` either
|
|
5412
|
+
// way — there is no silent-inherit exposure to close at this site, only
|
|
5413
|
+
// at sites (milestone.cts, cmdStateUpdateProgress above) that also
|
|
5414
|
+
// consume `.value`.
|
|
4856
5415
|
const syncScope = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: versionStr }).scope;
|
|
4857
5416
|
if (syncScope !== SCOPE.COMPLETE) {
|
|
4858
5417
|
changes.push(`Progress: skipped — milestone phase scope is "${syncScope}", not COMPLETE (#3217)`);
|