@opengsd/gsd-core 1.12.0 → 1.14.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-advisor-researcher.compact.md +85 -0
- package/agents/gsd-ai-researcher.compact.md +96 -0
- package/agents/gsd-assumptions-analyzer.compact.md +81 -0
- package/agents/gsd-code-fixer.compact.md +458 -0
- package/agents/gsd-code-fixer.md +5 -5
- package/agents/gsd-code-reviewer.compact.md +269 -0
- package/agents/gsd-code-reviewer.md +15 -3
- package/agents/gsd-codebase-mapper.compact.md +760 -0
- package/agents/gsd-debug-session-manager.compact.md +345 -0
- package/agents/gsd-doc-classifier.compact.md +192 -0
- package/agents/gsd-doc-synthesizer.compact.md +200 -0
- package/agents/gsd-doc-verifier.compact.md +143 -0
- package/agents/gsd-doc-writer.compact.md +440 -0
- package/agents/gsd-dom-verifier.compact.md +138 -0
- package/agents/gsd-domain-researcher.compact.md +141 -0
- package/agents/gsd-eval-auditor.compact.md +160 -0
- package/agents/gsd-eval-planner.compact.md +137 -0
- package/agents/gsd-executor.md +63 -35
- package/agents/gsd-framework-selector.compact.md +82 -0
- package/agents/gsd-integration-checker.compact.md +245 -0
- package/agents/gsd-intel-updater.compact.md +226 -0
- package/agents/gsd-mempalace-curator.compact.md +45 -0
- package/agents/gsd-nyquist-auditor.compact.md +179 -0
- package/agents/gsd-pattern-mapper.compact.md +275 -0
- package/agents/gsd-plan-checker.md +76 -57
- package/agents/gsd-planner.md +14 -0
- package/agents/gsd-project-researcher.compact.md +587 -0
- package/agents/gsd-research-synthesizer.compact.md +212 -0
- package/agents/gsd-roadmapper.compact.md +454 -0
- package/agents/gsd-roadmapper.md +13 -0
- package/agents/gsd-security-auditor.compact.md +162 -0
- package/agents/gsd-ui-auditor.compact.md +404 -0
- package/agents/gsd-ui-checker.compact.md +277 -0
- package/agents/gsd-ui-checker.md +19 -3
- package/agents/gsd-ui-researcher.compact.md +282 -0
- package/agents/gsd-ui-researcher.md +29 -0
- package/agents/gsd-user-profiler.compact.md +108 -0
- package/agents/gsd-verifier.md +23 -1
- package/bin/install.js +444 -134
- package/commands/gsd/cleanup.md +1 -0
- package/commands/gsd/code-review.md +2 -1
- package/commands/gsd/complete-milestone.md +1 -0
- package/commands/gsd/config.md +1 -0
- package/commands/gsd/debug.md +1 -0
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/graphify.md +1 -0
- package/commands/gsd/health.md +1 -0
- package/commands/gsd/mempalace-capture.md +1 -0
- package/commands/gsd/mempalace-recall.md +1 -0
- package/commands/gsd/new-milestone.md +1 -0
- package/commands/gsd/new-project.md +1 -0
- package/commands/gsd/next.md +1 -0
- package/commands/gsd/ns-workflow.md +2 -1
- package/commands/gsd/pause-work.md +1 -0
- package/commands/gsd/phase.md +2 -1
- package/commands/gsd/pr-branch.md +1 -0
- package/commands/gsd/quick-batch.md +105 -0
- package/commands/gsd/resume-work.md +1 -0
- package/commands/gsd/review-backlog.md +1 -0
- package/commands/gsd/settings.md +2 -1
- package/commands/gsd/stats.md +1 -0
- package/commands/gsd/surface.md +18 -8
- package/commands/gsd/thread.md +1 -0
- package/commands/gsd/workspace.md +1 -0
- package/commands/gsd/workstreams.md +1 -0
- package/gsd-core/bin/check-latest-version.cjs +8 -3
- package/gsd-core/bin/gsd-tools.cjs +532 -174
- package/gsd-core/bin/lib/adr-parser.cjs +1 -1
- package/gsd-core/bin/lib/artifacts.cjs +2 -1
- package/gsd-core/bin/lib/audit.cjs +39 -22
- package/gsd-core/bin/lib/broken-windows.cjs +168 -49
- package/gsd-core/bin/lib/capability-activation.cjs +27 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +10 -6
- package/gsd-core/bin/lib/capability-loader.cjs +135 -1
- package/gsd-core/bin/lib/capability-registry.cjs +528 -116
- package/gsd-core/bin/lib/capability-source.cjs +19 -2
- package/gsd-core/bin/lib/capability-state.cjs +7 -1
- package/gsd-core/bin/lib/capability-validator.cjs +134 -5
- package/gsd-core/bin/lib/capability-writer.cjs +14 -4
- package/gsd-core/bin/lib/check-command-router.cjs +198 -38
- package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
- package/gsd-core/bin/lib/clusters.cjs +1 -0
- package/gsd-core/bin/lib/code-review-depth.cjs +2 -2
- package/gsd-core/bin/lib/command-aliases.cjs +16 -0
- package/gsd-core/bin/lib/commands.cjs +981 -79
- package/gsd-core/bin/lib/config-loader.cjs +4 -0
- package/gsd-core/bin/lib/config.cjs +153 -38
- package/gsd-core/bin/lib/core-utils.cjs +34 -7
- package/gsd-core/bin/lib/coverage.cjs +1 -1
- package/gsd-core/bin/lib/decisions.cjs +343 -28
- package/gsd-core/bin/lib/edge-probe.cjs +14 -1
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +29 -14
- 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/gsd2-import.cjs +1 -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 +87 -23
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +1 -1
- 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 +539 -60
- package/gsd-core/bin/lib/install-engine.cjs +199 -14
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
- package/gsd-core/bin/lib/install-profiles.cjs +36 -14
- package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +33 -4
- package/gsd-core/bin/lib/io.cjs +35 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +64 -39
- package/gsd-core/bin/lib/markdown-table.cjs +123 -0
- package/gsd-core/bin/lib/mcp-catalog.cjs +2 -2
- package/gsd-core/bin/lib/milestone.cjs +41 -10
- package/gsd-core/bin/lib/model-resolver.cjs +101 -10
- package/gsd-core/bin/lib/phase-command-router.cjs +20 -7
- package/gsd-core/bin/lib/phase-id.cjs +412 -31
- package/gsd-core/bin/lib/phase-lifecycle.cjs +61 -0
- package/gsd-core/bin/lib/phase.cjs +941 -98
- package/gsd-core/bin/lib/plan-document.cjs +10 -0
- package/gsd-core/bin/lib/planning-inspect.cjs +34 -18
- package/gsd-core/bin/lib/planning-snapshot.cjs +206 -30
- package/gsd-core/bin/lib/planning-workspace.cjs +153 -29
- package/gsd-core/bin/lib/pristine-baseline.cjs +182 -0
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +91 -4
- 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/refactor-trigger-command-router.cjs +61 -2
- package/gsd-core/bin/lib/research-store.cjs +11 -12
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
- package/gsd-core/bin/lib/review-lane-invocation.cjs +96 -1
- package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
- package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +337 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +555 -41
- package/gsd-core/bin/lib/roadmap.cjs +292 -69
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +260 -43
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +28 -20
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +294 -108
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +408 -47
- package/gsd-core/bin/lib/security.cjs +126 -7
- 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 +159 -32
- package/gsd-core/bin/lib/state-md-schema.cjs +44 -27
- package/gsd-core/bin/lib/state-transition.cjs +465 -62
- package/gsd-core/bin/lib/state.cjs +906 -151
- package/gsd-core/bin/lib/surface.cjs +83 -10
- package/gsd-core/bin/lib/task-command-router.cjs +12 -6
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
- package/gsd-core/bin/lib/uat.cjs +1420 -516
- package/gsd-core/bin/lib/update-context.cjs +36 -26
- package/gsd-core/bin/lib/validate.cjs +230 -12
- package/gsd-core/bin/lib/vendor/js-yaml.cjs +11 -3
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +316 -23
- package/gsd-core/bin/lib/verify-command-grounding.cjs +1 -1
- package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
- package/gsd-core/bin/lib/verify.cjs +531 -36
- package/gsd-core/bin/lib/workstream-inventory.cjs +21 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +21 -7
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +13 -0
- package/gsd-core/bin/verify-reapply-patches.cjs +507 -81
- package/gsd-core/references/agent-contracts.md +3 -3
- package/gsd-core/references/compact-content-gate.md +66 -0
- 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/loop-hook-dispatch.md +18 -0
- package/gsd-core/references/model-profiles.md +12 -3
- 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 +5 -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 +17 -9
- package/gsd-core/references/thinking-models-planning.md +18 -2
- package/gsd-core/references/verification-patterns.md +17 -4
- package/gsd-core/references/verifier-evidence-gate.md +160 -0
- package/gsd-core/references/worktree-path-safety.md +112 -2
- package/gsd-core/templates/README.md +7 -1
- package/gsd-core/templates/phase-prompt.md +4 -0
- package/gsd-core/templates/state.md +6 -3
- package/gsd-core/templates/summary.compact.md +212 -0
- package/gsd-core/templates/user-setup.compact.md +199 -0
- package/gsd-core/templates/user-setup.md +0 -9
- 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 +4 -3
- 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 +15 -10
- package/gsd-core/workflows/check-todos.md +5 -3
- package/gsd-core/workflows/cleanup.md +4 -2
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +22 -13
- package/gsd-core/workflows/code-review-fix.md +5 -3
- package/gsd-core/workflows/code-review.md +211 -43
- package/gsd-core/workflows/complete-milestone/detail/elaboration.md +274 -0
- package/gsd-core/workflows/complete-milestone.md +40 -254
- 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/detail/elaboration.md +179 -0
- package/gsd-core/workflows/docs-update.md +15 -156
- package/gsd-core/workflows/edit-phase.md +2 -0
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/detail/elaboration.md +124 -0
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +20 -3
- package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +56 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +24 -3
- package/gsd-core/workflows/execute-phase/steps/executor-progress-policy.md +43 -0
- 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/sequential-root-pin.md +35 -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 +78 -159
- package/gsd-core/workflows/execute-plan.md +28 -15
- 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.compact.md +398 -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 +3 -1
- 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 +52 -3
- 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 +55 -13
- package/gsd-core/workflows/new-project/detail/elaboration.md +216 -0
- package/gsd-core/workflows/new-project.md +37 -205
- 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/detail/elaboration.md +209 -0
- 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 +144 -185
- 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 +30 -10
- 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 +21 -4
- package/gsd-core/workflows/reapply-patches.md +79 -3
- 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 +19 -6
- 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 +48 -43
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work/detail/elaboration.md +230 -0
- package/gsd-core/workflows/verify-work.md +68 -182
- package/hooks/dist/gsd-agent-isolation-guard.js +42 -16
- package/hooks/dist/gsd-check-update-worker.js +19 -2
- package/hooks/dist/gsd-context-monitor.js +371 -27
- package/hooks/dist/gsd-cursor-subagent-start.js +34 -14
- 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 +1105 -0
- package/hooks/dist/gsd-statusline.js +18 -10
- package/hooks/dist/gsd-validate-commit.sh +474 -7
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/gsd-worktree-path-guard.js +25 -14
- package/hooks/dist/gsd-write-guard.js +46 -1
- package/hooks/dist/lib/dispatch-identity.js +187 -0
- package/hooks/dist/lib/filename-classification.js +64 -0
- package/hooks/dist/lib/git-cmd.js +210 -1
- package/hooks/dist/lib/injection-patterns.js +36 -6
- package/hooks/dist/lib/isolation-deny-reason.js +53 -1
- package/hooks/dist/lib/isolation-sentinel.js +58 -19
- package/hooks/dist/managed-hooks-registry.cjs +1 -0
- package/hooks/gsd-agent-isolation-guard.js +42 -16
- package/hooks/gsd-check-update-worker.js +19 -2
- package/hooks/gsd-context-monitor.js +371 -27
- package/hooks/gsd-cursor-subagent-start.js +34 -14
- 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 +1105 -0
- package/hooks/gsd-statusline.js +18 -10
- package/hooks/gsd-validate-commit.sh +474 -7
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/gsd-worktree-path-guard.js +25 -14
- package/hooks/gsd-write-guard.js +46 -1
- package/hooks/hooks.json +6 -0
- package/hooks/lib/dispatch-identity.js +187 -0
- package/hooks/lib/filename-classification.js +64 -0
- package/hooks/lib/git-cmd.js +210 -1
- package/hooks/lib/injection-patterns.js +36 -6
- package/hooks/lib/isolation-deny-reason.js +53 -1
- package/hooks/lib/isolation-sentinel.js +58 -19
- package/hooks/managed-hooks-registry.cjs +1 -0
- package/package.json +13 -9
- package/scripts/benchmark-compact-content-variants.cjs +298 -0
- package/scripts/benchmark-compact-content.cjs +368 -0
- package/scripts/build-hooks.js +11 -4
- package/scripts/check-contract-drift.cjs +4 -1
- package/scripts/check-env.cjs +36 -8
- package/scripts/check-glossary-refs.cjs +25 -21
- package/scripts/ci-next-health.cjs +271 -0
- package/scripts/ci-prepare-test-scope.cjs +7 -7
- package/scripts/ci-test-scope.cjs +133 -20
- package/scripts/ci-timeout-report.cjs +1 -1
- package/scripts/diff-touches-shipped-paths.cjs +1 -1
- package/scripts/docs-guard-registry.cjs +17 -2
- package/scripts/gen-adr-index.cjs +8 -2
- package/scripts/gen-inventory-manifest.cjs +12 -0
- package/scripts/gen-loop-host-contract.cjs +67 -15
- package/scripts/gen-platform-conformance-tier.cjs +557 -0
- package/scripts/lib/drift-scan.cjs +1 -1
- package/scripts/lib/macos-conformance-tier.generated.cjs +210 -0
- package/scripts/lib/npm-version-check-diagnosis.cjs +59 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +276 -0
- package/scripts/lib/shellcheck-fetch.cjs +247 -0
- package/scripts/lib/suite-detection.cjs +32 -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-allowed-tools-parity.cjs +221 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +24 -2
- package/scripts/lint-phase-enumeration-drift.cjs +24 -6
- package/scripts/lint-phase-id-drift.cjs +465 -15
- package/scripts/lint-portable-grep.cjs +176 -0
- package/scripts/lint-response-language-coverage.cjs +530 -0
- package/scripts/lint-source-test-name-collision.cjs +1 -1
- package/scripts/lint-test-file-count.allowlist.json +4 -1
- package/scripts/lint-vendored-deps.cjs +128 -17
- package/scripts/lint-workflow-shellcheck-baseline.json +1112 -0
- package/scripts/lint-workflow-shellcheck.cjs +614 -0
- package/scripts/npm-audit-baseline.cjs +376 -0
- package/scripts/prompt-injection-scan.sh +22 -0
- package/scripts/require-issue-link-policy.cjs +16 -1
- package/scripts/workflow-size.cjs +139 -0
- package/skills/gsd-cleanup/SKILL.md +1 -0
- package/skills/gsd-code-review/SKILL.md +2 -1
- package/skills/gsd-complete-milestone/SKILL.md +1 -0
- package/skills/gsd-config/SKILL.md +1 -0
- package/skills/gsd-debug/SKILL.md +1 -0
- package/skills/gsd-execute-phase/SKILL.md +1 -1
- package/skills/gsd-graphify/SKILL.md +1 -0
- package/skills/gsd-health/SKILL.md +1 -0
- package/skills/gsd-mempalace-capture/SKILL.md +1 -0
- package/skills/gsd-mempalace-recall/SKILL.md +1 -0
- package/skills/gsd-new-milestone/SKILL.md +1 -0
- package/skills/gsd-new-project/SKILL.md +1 -0
- package/skills/gsd-next/SKILL.md +1 -0
- package/skills/gsd-ns-workflow/SKILL.md +1 -0
- package/skills/gsd-pause-work/SKILL.md +1 -0
- package/skills/gsd-phase/SKILL.md +2 -1
- package/skills/gsd-pr-branch/SKILL.md +1 -0
- package/skills/gsd-quick-batch/SKILL.md +105 -0
- package/skills/gsd-resume-work/SKILL.md +1 -0
- package/skills/gsd-review-backlog/SKILL.md +1 -0
- package/skills/gsd-settings/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +1 -0
- package/skills/gsd-surface/SKILL.md +18 -8
- package/skills/gsd-thread/SKILL.md +1 -0
- package/skills/gsd-workspace/SKILL.md +1 -0
- package/skills/gsd-workstreams/SKILL.md +1 -0
- package/vscode/package.json +1 -1
- package/gsd-core/templates/claude-md.md +0 -145
- package/gsd-core/templates/codebase/concerns.md +0 -310
- package/gsd-core/templates/codebase/conventions.md +0 -307
- package/gsd-core/templates/codebase/integrations.md +0 -280
- package/gsd-core/templates/codebase/structure.md +0 -285
- package/gsd-core/templates/codebase/testing.md +0 -480
- package/gsd-core/templates/debug-subagent-prompt.md +0 -91
- package/gsd-core/templates/discovery.md +0 -146
|
@@ -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,8 +55,19 @@ 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;
|
|
64
|
+
// #4129: the single owner of "count the ROADMAP's milestone Complete rows"
|
|
65
|
+
// (phase-lifecycle.cts) — reused for the completed-phases numerator floor so
|
|
66
|
+
// this scan cannot grow a second ROADMAP parser. Pure computation module (no
|
|
67
|
+
// I/O), so it introduces no cycle on this path.
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
69
|
+
const phaseLifecycleMod = require("./phase-lifecycle.cjs");
|
|
70
|
+
const { deriveProgressFromRoadmap } = phaseLifecycleMod;
|
|
58
71
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
59
72
|
const planningScopeMod = require("./planning-scope.cjs");
|
|
60
73
|
const { SCOPE } = planningScopeMod;
|
|
@@ -75,7 +88,7 @@ const project_root_cjs_1 = require("./project-root.cjs");
|
|
|
75
88
|
// it introduces no cycle on this path.
|
|
76
89
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
77
90
|
const milestoneLockMod = require("./milestone-lock.cjs");
|
|
78
|
-
const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
|
|
91
|
+
const { transitionCore, applyStatePreservation, sliceCurrentPositionSection, stateReplaceProgressPercent, formatProgressMachineSegment } = stateTransitionMod;
|
|
79
92
|
// #3699: the frontmatter-key <-> body-field routing behind `state update`'s
|
|
80
93
|
// failure explanation, and the classification table it falls back to.
|
|
81
94
|
const { getFieldClassification, getFrontmatterBodySource, frontmatterKeyForBodyField } = stateTransitionMod;
|
|
@@ -280,7 +293,7 @@ function cmdStateGet(cwd, section, raw) {
|
|
|
280
293
|
// Try to find markdown section or field
|
|
281
294
|
const fieldEscaped = (0, pattern_cjs_1.escapeRegex)(section);
|
|
282
295
|
// Check for **field:** value (bold format)
|
|
283
|
-
const boldPattern = new RegExp(
|
|
296
|
+
const boldPattern = new RegExp(`^[ \\t]*\\*\\*${fieldEscaped}:\\*\\*[ \\t]*(.*)`, 'im');
|
|
284
297
|
const boldMatch = content.match(boldPattern);
|
|
285
298
|
if (boldMatch) {
|
|
286
299
|
output({ [section]: boldMatch[1].trim() }, raw, boldMatch[1].trim());
|
|
@@ -308,13 +321,10 @@ function readTextArgOrFile(cwd, value, filePath, label) {
|
|
|
308
321
|
return value;
|
|
309
322
|
// Path traversal guard: ensure file resolves within project directory
|
|
310
323
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
|
|
311
|
-
const {
|
|
312
|
-
const
|
|
313
|
-
if (!pathCheck.safe) {
|
|
314
|
-
throw new Error(`${label} path rejected: ${pathCheck.error}`);
|
|
315
|
-
}
|
|
324
|
+
const { assertWithinRoot, PathAcceptance } = require('./security.cjs');
|
|
325
|
+
const contained = assertWithinRoot(filePath, cwd, `${label} path`, PathAcceptance.AbsoluteInsideRoot);
|
|
316
326
|
try {
|
|
317
|
-
return node_fs_1.default.readFileSync(
|
|
327
|
+
return node_fs_1.default.readFileSync(contained, 'utf-8').trimEnd();
|
|
318
328
|
}
|
|
319
329
|
catch {
|
|
320
330
|
throw new Error(`${label} file not found: ${filePath}`);
|
|
@@ -483,7 +493,31 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
483
493
|
// (#3345's direction) — reported separately from `updated` because this
|
|
484
494
|
// command's contract is a single-field boolean, not a per-field array.
|
|
485
495
|
const reconciled = reconcileReportedFields(statePath, preWriteState, updated ? [field] : [], divergedFields);
|
|
486
|
-
|
|
496
|
+
// #4488: `updateCore` itself already told us whether it matched the field
|
|
497
|
+
// (`updated`, captured above `readModifyWriteStateMd` runs it) — that is a
|
|
498
|
+
// real signal, not a guess. `reconcileReportedFields` answers a DIFFERENT
|
|
499
|
+
// question ("what changed on disk") and, per its own docstring, reports
|
|
500
|
+
// `[]` whenever `preWriteState.fm` is `undefined`. That happens in two
|
|
501
|
+
// known cases, both of which mean "no snapshot was ever captured", not
|
|
502
|
+
// "nothing happened": (a) `readModifyWriteStateMd`'s #948 no-op guard
|
|
503
|
+
// fires because the transform's output was byte-identical to the input —
|
|
504
|
+
// the requested value already equals what's on disk, so the field WAS
|
|
505
|
+
// found and there was simply nothing left to change; (b)
|
|
506
|
+
// `applyPostSyncPreservation`'s `isUnparseableFrontmatter` early return —
|
|
507
|
+
// the ORIGINAL frontmatter block was malformed, so preservation never
|
|
508
|
+
// runs, yet `readModifyWriteStateMd` still persists the transform's raw
|
|
509
|
+
// output via `platformWriteSync`. In neither case did preservation
|
|
510
|
+
// discard or rewrite what the transform wrote, so trusting the
|
|
511
|
+
// transform's own `updated` signal here is never a false positive.
|
|
512
|
+
// Collapsing either case into the same `false` as "field not found" is
|
|
513
|
+
// the #4488 bug — `explainUpdateFailure` then reports a message that is
|
|
514
|
+
// actively false (it tells the caller to add a line that is already
|
|
515
|
+
// there). Every other `false` origin (case-D fallback did not apply, or
|
|
516
|
+
// the transform genuinely found nothing) is unaffected: there
|
|
517
|
+
// `preWriteState.fm` is defined (a normal sync ran) or `updated` was
|
|
518
|
+
// already false before this line.
|
|
519
|
+
const noopBecauseAlreadyCorrect = updated && preWriteState.fm === undefined;
|
|
520
|
+
updated = reconciled.includes(field) || noopBecauseAlreadyCorrect;
|
|
487
521
|
const preserved = reconciled.filter((f) => f !== field);
|
|
488
522
|
if (updated) {
|
|
489
523
|
// #3699 case D: surfaced so a caller can tell "wrote the body source" from
|
|
@@ -520,6 +554,42 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
520
554
|
}
|
|
521
555
|
}
|
|
522
556
|
// ─── State Progression Engine ────────────────────────────────────────────────
|
|
557
|
+
/**
|
|
558
|
+
* The "I could not read the plan position" message, DERIVED from
|
|
559
|
+
* `STATE_FIELD_SCHEMA.current_plan.acceptedShapes` rather than transcribed
|
|
560
|
+
* beside it.
|
|
561
|
+
*
|
|
562
|
+
* The accepted-shape set had two owners: the parser branches in
|
|
563
|
+
* `advancePlanCore` and an English list hand-written here. Nothing coupled
|
|
564
|
+
* them, so adding a branch left this message stale and removing one left it
|
|
565
|
+
* advertising a shape that errors — and no test could see either. ADR-3473
|
|
566
|
+
* §8.3 is "one implementation per rule"; the schema row is that one owner, and
|
|
567
|
+
* rows 23/24/25 already hold the parser to it.
|
|
568
|
+
*
|
|
569
|
+
* `Plan: N of M` is spelled out separately because there is no schema row for
|
|
570
|
+
* the body-only `Plan` field: `buildStateFrontmatter` never reads it into
|
|
571
|
+
* frontmatter, so it has no `current_*` key to hang a row on. That asymmetry is
|
|
572
|
+
* the schema's, not this function's.
|
|
573
|
+
*/
|
|
574
|
+
function advancePlanShapeError() {
|
|
575
|
+
const shapes = stateMdSchemaMod.STATE_FIELD_SCHEMA['current_plan']?.acceptedShapes ?? [];
|
|
576
|
+
const spellings = shapes.map((shape) => (shape === 'N'
|
|
577
|
+
? '`Current Plan: N` with `Total Plans in Phase: M`'
|
|
578
|
+
: `\`Current Plan: ${shape}\``));
|
|
579
|
+
// The body-only `Plan` field has no schema row to derive from:
|
|
580
|
+
// `buildStateFrontmatter` never reads it into frontmatter, so there is no
|
|
581
|
+
// `current_*` key to hang a row on. Its ONE accepted spelling is named here.
|
|
582
|
+
//
|
|
583
|
+
// `Plan: N` with a `Total Plans in Phase: M` sibling is deliberately NOT
|
|
584
|
+
// listed (#3791 review round 6, M2): the parser does not accept it. A
|
|
585
|
+
// revision of this PR added both the branch and this spelling together, on
|
|
586
|
+
// the reasoning that the message must advertise exactly what the parser
|
|
587
|
+
// accepts. That reasoning still holds — which is why removing the branch
|
|
588
|
+
// removes the spelling in the same commit. The invariant is the lockstep,
|
|
589
|
+
// not the length of the list.
|
|
590
|
+
spellings.push('`Plan: N of M`');
|
|
591
|
+
return `Cannot read the plan position from STATE.md. Expected one of: ${spellings.join(', ')}.`;
|
|
592
|
+
}
|
|
523
593
|
/**
|
|
524
594
|
* Replace a STATE.md field with fallback field name support.
|
|
525
595
|
* Tries `primary` first, then `fallback` (if provided), returns content unchanged
|
|
@@ -541,6 +611,86 @@ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
|
|
|
541
611
|
`This may indicate STATE.md was externally modified or uses an unexpected format.\n`);
|
|
542
612
|
return content;
|
|
543
613
|
}
|
|
614
|
+
/**
|
|
615
|
+
* #4067: disk-derived plan-completion answer for advance-plan's phase-complete
|
|
616
|
+
* guard.
|
|
617
|
+
*
|
|
618
|
+
* `advancePlanCore` decides "phase complete" purely from STATE.md's scalar plan
|
|
619
|
+
* counter (`currentPlan >= totalPlans`). That counter cannot represent
|
|
620
|
+
* wave-parallel execution — a stale counter carried over from the prior phase
|
|
621
|
+
* (the reported trigger: `Plan: 7 of 7` surviving into a 10-plan phase) or a
|
|
622
|
+
* counter raced by N concurrent executors both let the phase-complete branch
|
|
623
|
+
* fire while sibling plans are mid-flight. This helper answers the completion
|
|
624
|
+
* question from disk instead, exactly the way `state update-progress`
|
|
625
|
+
* recalculates it: every plan in the Current Position phase's directory has a
|
|
626
|
+
* SUMMARY.md.
|
|
627
|
+
*
|
|
628
|
+
* Single-derivation discipline: plan/summary counting is owned by
|
|
629
|
+
* `scanPhasePlans` (src/plan-scan.cts, ADR-3180 §7.5) — this helper consumes
|
|
630
|
+
* it, never re-derives. It deliberately does NOT consult `isPhaseComplete`
|
|
631
|
+
* (§7.4): that owner answers the *verification* question (passing
|
|
632
|
+
* `*-VERIFICATION.md`), a different question from "are all plans executed?".
|
|
633
|
+
* Blocked summaries (#3345) are filtered from the pairing set with the same
|
|
634
|
+
* shared predicate `scanPhasePlans` uses, so the named outstanding list can
|
|
635
|
+
* never disagree with the count-based decision.
|
|
636
|
+
*
|
|
637
|
+
* FAIL-OPEN contract: returns `null` when the disk answer is UNAVAILABLE — no
|
|
638
|
+
* readable phases dir, no directory matching the position phase, or a scan
|
|
639
|
+
* whose scope is not COMPLETE (the scan may be blind to plans it knows exist).
|
|
640
|
+
* `null` means "the caller must fall back to the counter-derived decision",
|
|
641
|
+
* NOT "plans are outstanding"; worlds the seam cannot see (STATE.md with no
|
|
642
|
+
* Current Position `Phase:` line, milestone-archived layouts) keep today's
|
|
643
|
+
* behavior rather than being newly refused.
|
|
644
|
+
*
|
|
645
|
+
* Returns `{ dir, outstanding, planCount, summaryCount }` where `outstanding`
|
|
646
|
+
* is empty when every plan on disk is summarized (vacuously so for a zero-plan
|
|
647
|
+
* phase — #3168's zero-plan-phase posture). `planCount`/`summaryCount` are the
|
|
648
|
+
* countable disk facts behind `outstanding` (live plan files; summaries after
|
|
649
|
+
* the #3345 blocked filter) — #4093's recovery decline reports them to a
|
|
650
|
+
* caller whose STATE.md has lost its labeled plan position, so the suggested
|
|
651
|
+
* repair values are computed from the SAME set `outstanding` was, and can
|
|
652
|
+
* never disagree with a count-based decision either.
|
|
653
|
+
*/
|
|
654
|
+
function scanOutstanding(phasesDir, dir) {
|
|
655
|
+
const phaseDirPath = node_path_1.default.join(phasesDir, dir);
|
|
656
|
+
const scan = scanPhasePlans(phaseDirPath);
|
|
657
|
+
if (scan.scope !== SCOPE.COMPLETE)
|
|
658
|
+
return null;
|
|
659
|
+
// Blocked summaries (#3345) are filtered with the same shared predicate
|
|
660
|
+
// scanPhasePlans uses for its own count, so the named outstanding list can
|
|
661
|
+
// never disagree with a count-based decision.
|
|
662
|
+
const countableSummaries = scan.summaryFiles.filter((f) => !planDependencyGraphMod.isSummaryFileBlocked(node_path_1.default.join(phaseDirPath, f)));
|
|
663
|
+
const outstanding = coreUtilsMod.findUnsummarizedPlans(scan.planFiles, countableSummaries);
|
|
664
|
+
return { dir, outstanding, planCount: scan.planFiles.length, summaryCount: countableSummaries.length };
|
|
665
|
+
}
|
|
666
|
+
function unsummarizedPlansForPositionPhase(cwd, positionPhase) {
|
|
667
|
+
const phasesDir = planningPaths(cwd).phases;
|
|
668
|
+
// #3185 (ADR-3180 Decision 1): "which phase directories exist" is owned by
|
|
669
|
+
// listMilestonePhaseDirs — no hand-rolled readdirSync here. The owner
|
|
670
|
+
// handles an absent phasesDir as a real empty and refuses sentinels.
|
|
671
|
+
//
|
|
672
|
+
// Two passes, narrowest first: the CURRENT-MILESTONE window (so an archived
|
|
673
|
+
// milestone's stale `01-*` directory cannot shadow the live one), then —
|
|
674
|
+
// only when the window cannot answer (no bounded ROADMAP, or the position
|
|
675
|
+
// phase is simply not in it) — an unscoped read, which the owner documents
|
|
676
|
+
// as a real answer. This is a lookup of ONE phase token STATE.md names, not
|
|
677
|
+
// a milestone enumeration, so the unscoped retry is in-contract.
|
|
678
|
+
const convention = resolvePhaseIdConvention(cwd);
|
|
679
|
+
const windowed = listMilestonePhaseDirs(phasesDir, { cwd, phaseIdConvention: convention });
|
|
680
|
+
const candidateDirs = windowed.scope === SCOPE.COMPLETE ? windowed.value : [];
|
|
681
|
+
// Canonical phase-token → directory matching (phase-id owner, #2562): both
|
|
682
|
+
// sides of the comparison derived by the same function, never a local regex.
|
|
683
|
+
const { matches } = matchPhaseDirs(candidateDirs, positionPhase, convention);
|
|
684
|
+
if (matches.length > 0)
|
|
685
|
+
return scanOutstanding(phasesDir, matches[0]);
|
|
686
|
+
const unscoped = listMilestonePhaseDirs(phasesDir);
|
|
687
|
+
if (unscoped.scope !== SCOPE.COMPLETE)
|
|
688
|
+
return null;
|
|
689
|
+
const retry = matchPhaseDirs(unscoped.value, positionPhase, convention);
|
|
690
|
+
if (retry.matches.length === 0)
|
|
691
|
+
return null;
|
|
692
|
+
return scanOutstanding(phasesDir, retry.matches[0]);
|
|
693
|
+
}
|
|
544
694
|
function cmdStateAdvancePlan(cwd, raw) {
|
|
545
695
|
const statePath = planningPaths(cwd).state;
|
|
546
696
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
@@ -567,6 +717,16 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
567
717
|
// STATE.md lock, so the position read and the claim read cannot interleave
|
|
568
718
|
// with another session's Current Position write.
|
|
569
719
|
let milestoneConflict = null;
|
|
720
|
+
// #4067: set when the disk-derived guard declines the phase-complete branch —
|
|
721
|
+
// named here so the post-lock output path can report it without re-deriving.
|
|
722
|
+
// Holder (not a bare let) so TypeScript's closure-unaware narrowing cannot
|
|
723
|
+
// collapse the post-lock read to `never` — the callback assigns it.
|
|
724
|
+
const outstandingRef = { value: null };
|
|
725
|
+
// #4093: the position phase token the callback resolved (Current Position
|
|
726
|
+
// `Phase:` line first, frontmatter `current_phase` as fallback), carried out
|
|
727
|
+
// so the generic parse-failure decline can derive recovery facts from disk
|
|
728
|
+
// without re-reading STATE.md outside the lock. Same holder idiom as above.
|
|
729
|
+
const positionPhaseRef = { value: null };
|
|
570
730
|
const wrote = readModifyWriteStateMd(statePath, (content) => {
|
|
571
731
|
// advance-plan has no phase argument of its own — the phase it advances is
|
|
572
732
|
// whatever ## Current Position names. Compare that against the milestone
|
|
@@ -576,6 +736,17 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
576
736
|
const body = stripFrontmatter(content);
|
|
577
737
|
const positionScope = matchCurrentPositionSection(body) ?? body;
|
|
578
738
|
const positionPhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
|
|
739
|
+
// #4093: a Current Position section with ZERO labeled fields has no
|
|
740
|
+
// `Phase:` line either; frontmatter `current_phase` is the documented
|
|
741
|
+
// survivor of body drift (the reporter's document still carried it, and
|
|
742
|
+
// `buildStateFrontmatter` re-derives it from the body only when the body
|
|
743
|
+
// HAS the line). It feeds the recovery DECLINE only — never a write.
|
|
744
|
+
let fmPhase = null;
|
|
745
|
+
if (positionPhase === null) {
|
|
746
|
+
const fmToken = extractFrontmatter(content, statePath)['current_phase'];
|
|
747
|
+
fmPhase = typeof fmToken === 'string' && fmToken.trim() !== '' ? fmToken.trim() : null;
|
|
748
|
+
}
|
|
749
|
+
positionPhaseRef.value = positionPhase ?? fmPhase;
|
|
579
750
|
if (positionPhase !== null) {
|
|
580
751
|
milestoneConflict = milestoneLockMod.checkMilestonePosition(cwd, positionPhase);
|
|
581
752
|
if (milestoneConflict) {
|
|
@@ -583,10 +754,53 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
583
754
|
}
|
|
584
755
|
}
|
|
585
756
|
const result = transitionCore(content, intent, deps);
|
|
757
|
+
// #4067: the transform's phase-complete branch is decided by STATE.md's
|
|
758
|
+
// scalar plan counter, which can neither carry a stale value across phases
|
|
759
|
+
// nor represent wave-parallel execution. Before letting that branch write
|
|
760
|
+
// "Phase complete — ready for verification", re-decide from disk (the same
|
|
761
|
+
// source state.update-progress recalculates from): every plan in the
|
|
762
|
+
// position phase's directory must have a SUMMARY.md. A non-empty
|
|
763
|
+
// outstanding list declines the ENTIRE write — STATE.md is returned
|
|
764
|
+
// byte-identical, so the decline is idempotent and safe for any number of
|
|
765
|
+
// concurrent callers (the disk answer is re-read under the STATE.md lock
|
|
766
|
+
// each call; the counter stays display-only). `null` (disk answer
|
|
767
|
+
// unavailable) fails open to the counter-derived decision, so every
|
|
768
|
+
// world this seam cannot see keeps today's behavior.
|
|
769
|
+
if (result.data?.['advanced'] === false
|
|
770
|
+
&& result.data?.['reason'] === 'last_plan'
|
|
771
|
+
&& positionPhase !== null) {
|
|
772
|
+
const diskAnswer = unsummarizedPlansForPositionPhase(cwd, positionPhase);
|
|
773
|
+
if (diskAnswer !== null && diskAnswer.outstanding.length > 0) {
|
|
774
|
+
outstandingRef.value = diskAnswer;
|
|
775
|
+
resultData = result.data;
|
|
776
|
+
precomputedUpdated = [];
|
|
777
|
+
return content;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
586
780
|
resultData = result.data;
|
|
587
781
|
precomputedUpdated = result.updated;
|
|
588
782
|
return result.content;
|
|
589
783
|
}, cwd, { divergedFields, preWriteState });
|
|
784
|
+
// #4067 decline path: plans remain unexecuted on disk. Shaped like the
|
|
785
|
+
// existing `last_plan` decline (advanced:false + machine-readable reason,
|
|
786
|
+
// exit 0) rather than a hard error — the caller did nothing wrong and
|
|
787
|
+
// STATE.md needs no repair; the remaining plans' executors will re-run this
|
|
788
|
+
// command, and the final one finds a fully-summarized phase and completes it.
|
|
789
|
+
const plansOutstanding = outstandingRef.value;
|
|
790
|
+
if (plansOutstanding !== null) {
|
|
791
|
+
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.`, {
|
|
792
|
+
advanced: false,
|
|
793
|
+
phase_dir: plansOutstanding.dir,
|
|
794
|
+
outstanding_plans: plansOutstanding.outstanding,
|
|
795
|
+
milestone_conflict: milestoneConflict,
|
|
796
|
+
});
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
// `!resultData` is a type guard, not a second failure mode: the callback
|
|
800
|
+
// above assigns it unconditionally and only runs once STATE.md is known to
|
|
801
|
+
// exist (the missing-file case returns "STATE.md not found" earlier), and
|
|
802
|
+
// every `advancePlanCore` return path sets `data`. So the message below is
|
|
803
|
+
// the one a caller can actually receive.
|
|
590
804
|
if (!resultData || resultData['error']) {
|
|
591
805
|
// #3807: a multi-`Phase:` Current Position section carries its own cause
|
|
592
806
|
// and its own remedy (name the candidates; the caller resolves them).
|
|
@@ -598,7 +812,70 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
598
812
|
}, raw, undefined);
|
|
599
813
|
return;
|
|
600
814
|
}
|
|
601
|
-
|
|
815
|
+
// #3791 review round 6 (B1): the document carries both plan-position
|
|
816
|
+
// spellings with DIFFERENT numbers. Same posture as the case above — name
|
|
817
|
+
// the candidates and let the caller resolve them. Advancing either one
|
|
818
|
+
// would write a number into the other that nothing derived for it.
|
|
819
|
+
if (resultData && resultData['reason'] === 'ambiguous_plan_position') {
|
|
820
|
+
output({
|
|
821
|
+
error: 'STATE.md carries two plan positions with different numbers — refusing to advance either. Resolve them to a single current plan and re-run.',
|
|
822
|
+
reason: resultData['reason'],
|
|
823
|
+
plan_candidates: resultData['plan_candidates'],
|
|
824
|
+
}, raw, undefined);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
// #4093: the generic terminus — no accepted labeled plan-position shape
|
|
828
|
+
// parsed anywhere in the document (the reporter's case: ## Current
|
|
829
|
+
// Position drifted to pure narrative prose with zero labeled fields).
|
|
830
|
+
// Every OTHER refusal above carries a machine-readable reason and the
|
|
831
|
+
// evidence to act on; this one stranded the caller at a bare sentence
|
|
832
|
+
// with no recovery path. Give it the same posture: a `reason` the caller
|
|
833
|
+
// can branch on, plus — when the position phase can be resolved and its
|
|
834
|
+
// directory scanned — the disk-derived facts and the exact labeled lines
|
|
835
|
+
// to re-insert. Nothing is WRITTEN: STATE.md is returned byte-identical
|
|
836
|
+
// (the callback already returned the original content for this path),
|
|
837
|
+
// so the decline is idempotent and no repair is guessed into the file —
|
|
838
|
+
// the caller (human or agent) applies the suggested lines and re-runs.
|
|
839
|
+
// Disk is the recovery source per #4067's posture; the values below are
|
|
840
|
+
// computed from the SAME `scanOutstanding` counts the plans_outstanding
|
|
841
|
+
// guard uses, so the two declines can never disagree about a phase.
|
|
842
|
+
const positionToken = positionPhaseRef.value;
|
|
843
|
+
const diskFacts = positionToken !== null
|
|
844
|
+
? unsummarizedPlansForPositionPhase(cwd, positionToken)
|
|
845
|
+
: null;
|
|
846
|
+
if (diskFacts === null) {
|
|
847
|
+
// No resolvable phase (no Phase: line, no current_phase frontmatter, or
|
|
848
|
+
// no matching phase directory / incomplete scan): keep today's shape
|
|
849
|
+
// error, plus the reason so callers can tell this refusal from the
|
|
850
|
+
// ambiguous_* ones without string-matching the sentence.
|
|
851
|
+
output({ error: advancePlanShapeError(), reason: 'plan_position_unreadable' }, raw, undefined);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
const planCount = diskFacts.planCount;
|
|
855
|
+
const summarized = diskFacts.summaryCount;
|
|
856
|
+
// A summarized count below the plan count means the next plan to execute
|
|
857
|
+
// is summarized+1; an equal count means the phase is done on disk and the
|
|
858
|
+
// position line should say so (current = total; the next advance-plan run
|
|
859
|
+
// takes the #4067-guarded phase-complete branch from it). Zero plan files
|
|
860
|
+
// means disk has no opinion — suggest nothing rather than `1 of 0`.
|
|
861
|
+
const payload = {
|
|
862
|
+
error: advancePlanShapeError(),
|
|
863
|
+
reason: 'plan_position_unreadable',
|
|
864
|
+
phase_dir: diskFacts.dir,
|
|
865
|
+
disk: { plan_count: planCount, summarized_count: summarized },
|
|
866
|
+
};
|
|
867
|
+
if (planCount > 0) {
|
|
868
|
+
const current = summarized < planCount ? summarized + 1 : planCount;
|
|
869
|
+
payload['suggested'] = {
|
|
870
|
+
current_plan: current,
|
|
871
|
+
total_plans: planCount,
|
|
872
|
+
lines: [`Current Plan: ${current}`, `Total Plans in Phase: ${planCount}`],
|
|
873
|
+
};
|
|
874
|
+
payload['error'] =
|
|
875
|
+
`${advancePlanShapeError()} Disk for phase ${diskFacts.dir}: ${summarized} of ${planCount} plan(s) summarized. ` +
|
|
876
|
+
`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.`;
|
|
877
|
+
}
|
|
878
|
+
output(payload, raw, undefined);
|
|
602
879
|
return;
|
|
603
880
|
}
|
|
604
881
|
// ADR-3408 §8.4 (D4): reconcile `advancePlanCore`'s own success list against
|
|
@@ -803,7 +1080,7 @@ function computeUpdateProgressPreview(statePath, cwd) {
|
|
|
803
1080
|
const existingFm = extractFrontmatter(preContent, statePath);
|
|
804
1081
|
const preBody = stripFrontmatter(preContent);
|
|
805
1082
|
const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
|
|
806
|
-
const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm));
|
|
1083
|
+
const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
|
|
807
1084
|
const progress = builtFm['progress'];
|
|
808
1085
|
const percent = progress && typeof progress['percent'] === 'number' ? progress['percent'] : null;
|
|
809
1086
|
const completedPlans = progress && typeof progress['completed_plans'] === 'number' ? progress['completed_plans'] : null;
|
|
@@ -844,7 +1121,39 @@ function cmdStateUpdateProgress(cwd, raw) {
|
|
|
844
1121
|
// excluded sentinels, unlike the owner). The owner already handles an
|
|
845
1122
|
// absent phasesDir as a real empty, so the fs.existsSync guard folds
|
|
846
1123
|
// into it.
|
|
847
|
-
|
|
1124
|
+
//
|
|
1125
|
+
// #2761 (round-11 BLOCKER, single-derivation hygiene): `phaseIdConvention`
|
|
1126
|
+
// threaded explicitly (resolved ambiently off `cwd` — this call site has
|
|
1127
|
+
// no `ws` of its own, same contract `resolvePhaseIdConvention` uses
|
|
1128
|
+
// elsewhere in this file, e.g. the `phaseConvention` ONCE-and-THREAD
|
|
1129
|
+
// pattern at ~:2267/:2300) rather than left `undefined`.
|
|
1130
|
+
//
|
|
1131
|
+
// This does NOT change `phaseScope` — `scope` (roadmap-parser.cts
|
|
1132
|
+
// `getMilestonePhaseFilter`) is assigned at :1979/:2030, both BEFORE
|
|
1133
|
+
// `headingConvention` resolves at ~:2048, so the #3217 withhold gate a
|
|
1134
|
+
// few lines below is convention-independent either way (verified
|
|
1135
|
+
// empirically: forcing `phaseIdConvention: null` here left every
|
|
1136
|
+
// `state update-progress` assertion in
|
|
1137
|
+
// tests/adr-612-bracket-phase-counting.test.cjs's round-11 BLOCKER block
|
|
1138
|
+
// unchanged). What DOES depend on convention is `phaseDirs`/`totalPlans`
|
|
1139
|
+
// — the enumerated `.value` these two lines feed into the #3233
|
|
1140
|
+
// zero-plans no-op check just below. The actual `percent` this command
|
|
1141
|
+
// reports/writes comes from a separate, already-correctly-threaded scan
|
|
1142
|
+
// (`computeUpdateProgressPreview` -> `buildStateFrontmatter`, which
|
|
1143
|
+
// resolves its own `phaseConvention` at :2267). Threading here removes a
|
|
1144
|
+
// second, silent, lazily-resolved answer for the SAME question that scan
|
|
1145
|
+
// already answers explicitly — the single-derivation discipline this
|
|
1146
|
+
// file's own :2300 comment states as a rule — rather than fixing an
|
|
1147
|
+
// observed defect. #2761 round-12: the #3233 gate IS the one place this
|
|
1148
|
+
// is observable, so it — not the reported percent — is what
|
|
1149
|
+
// tests/adr-612-bracket-phase-counting.test.cjs's round-12 addition to
|
|
1150
|
+
// the round-11 BLOCKER block pins: a bracket milestone with no plans on
|
|
1151
|
+
// disk versus a decoy directory outside the milestone window that must
|
|
1152
|
+
// not be swept in by a pass-all degrade.
|
|
1153
|
+
const { value: phaseDirs, scope } = listMilestonePhaseDirs(phasesDir, {
|
|
1154
|
+
cwd,
|
|
1155
|
+
phaseIdConvention: cwd ? resolvePhaseIdConvention(cwd) : null,
|
|
1156
|
+
});
|
|
848
1157
|
phaseScope = scope;
|
|
849
1158
|
for (const dir of phaseDirs) {
|
|
850
1159
|
const { planCount } = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
|
|
@@ -861,10 +1170,10 @@ function cmdStateUpdateProgress(cwd, raw) {
|
|
|
861
1170
|
// never read, and STATE.md's Progress field goes stale with no
|
|
862
1171
|
// user-visible signal beyond it. Mirrors the established
|
|
863
1172
|
// `[gsd-tools] WARNING:` stderr convention this file already uses
|
|
864
|
-
// (stateReplaceFieldWithFallback above) for a comparable silent no-op
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1173
|
+
// (stateReplaceFieldWithFallback above) for a comparable silent no-op —
|
|
1174
|
+
// now routed through the shared `declineNoOp` helper (#3957) so the
|
|
1175
|
+
// pairing is structural rather than hand-written per arm.
|
|
1176
|
+
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
1177
|
return;
|
|
869
1178
|
}
|
|
870
1179
|
// #3233: zero plans in the current-milestone phases means there is nothing to
|
|
@@ -876,9 +1185,7 @@ function cmdStateUpdateProgress(cwd, raw) {
|
|
|
876
1185
|
// ("nothing to measure" ≠ "0% done"). The legitimate 0% case (plans exist,
|
|
877
1186
|
// none summarized → clampPercent(0, N>0) = 0) is unaffected: totalPlans > 0.
|
|
878
1187
|
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');
|
|
1188
|
+
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
1189
|
return;
|
|
883
1190
|
}
|
|
884
1191
|
// #3583: percent AND the completed/total counts reported alongside it both
|
|
@@ -889,48 +1196,30 @@ function cmdStateUpdateProgress(cwd, raw) {
|
|
|
889
1196
|
// disagrees with its own completed/total.
|
|
890
1197
|
const preview = computeUpdateProgressPreview(statePath, cwd);
|
|
891
1198
|
if (preview.withheld) {
|
|
892
|
-
|
|
893
|
-
output({ updated: false, reason: preview.reason }, raw, 'false');
|
|
1199
|
+
declineNoOp(raw, 'updated', preview.reason, `state update-progress skipped — ${preview.reason}`);
|
|
894
1200
|
return;
|
|
895
1201
|
}
|
|
896
1202
|
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}%`;
|
|
1203
|
+
const progressStr = formatProgressMachineSegment(percent);
|
|
901
1204
|
let updated = false;
|
|
902
1205
|
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)
|
|
1206
|
+
const result = stateReplaceProgressPercent(content, percent);
|
|
1207
|
+
if (result === null)
|
|
925
1208
|
return content;
|
|
926
1209
|
updated = true;
|
|
927
|
-
return
|
|
1210
|
+
return result;
|
|
928
1211
|
}, cwd);
|
|
929
1212
|
if (updated) {
|
|
930
1213
|
output({ updated: true, percent, completed: fmCompletedPlans, total: fmTotalPlans, bar: progressStr }, raw, progressStr);
|
|
931
1214
|
}
|
|
932
1215
|
else {
|
|
933
|
-
|
|
1216
|
+
// #3957: the frontmatter progress data was already confirmed present a
|
|
1217
|
+
// few lines above (computeUpdateProgressPreview didn't withhold) — what's
|
|
1218
|
+
// actually missing here is the BODY `Progress:`/`**Progress:**` line
|
|
1219
|
+
// itself. The prior 'Progress field not found in STATE.md' reason named
|
|
1220
|
+
// the wrong layer and silently discarded percent/completed/total, which
|
|
1221
|
+
// the sibling success arm above reports from the same preview.
|
|
1222
|
+
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
1223
|
}
|
|
935
1224
|
}
|
|
936
1225
|
function cmdStateAddDecision(cwd, options, raw) {
|
|
@@ -1236,7 +1525,15 @@ function cmdStateResolveBlocker(cwd, text, raw) {
|
|
|
1236
1525
|
output({ error: 'text required' }, raw, undefined);
|
|
1237
1526
|
return;
|
|
1238
1527
|
}
|
|
1239
|
-
|
|
1528
|
+
// #3957: track section-found and bullet-matched SEPARATELY. Previously
|
|
1529
|
+
// `resolved` was set unconditionally as soon as the heading was located —
|
|
1530
|
+
// before checking whether any bullet line actually matched `text` — so a
|
|
1531
|
+
// call naming a non-existent blocker reported `resolved: true` (a false
|
|
1532
|
+
// success). Only a real bullet match makes `resolved` true and the
|
|
1533
|
+
// rewrite happen; otherwise the transform returns `content` unchanged
|
|
1534
|
+
// (this repo's established no-op-return idiom).
|
|
1535
|
+
let sectionFound = false;
|
|
1536
|
+
let matched = false;
|
|
1240
1537
|
readModifyWriteStateMd(statePath, (content) => {
|
|
1241
1538
|
// ADR-1372 T6: find Blockers/Concerns section via tokenizeHeadings; stop at level 2 or 3.
|
|
1242
1539
|
// Mirrors /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i
|
|
@@ -1244,6 +1541,7 @@ function cmdStateResolveBlocker(cwd, text, raw) {
|
|
|
1244
1541
|
const i = hs.findIndex(h => (h.level === 2 || h.level === 3) && /^(?:Blockers|Blockers\/Concerns|Concerns)$/i.test(h.text));
|
|
1245
1542
|
if (i === -1)
|
|
1246
1543
|
return content;
|
|
1544
|
+
sectionFound = true;
|
|
1247
1545
|
const h = hs[i];
|
|
1248
1546
|
const ls = content.split('\n');
|
|
1249
1547
|
const hl = ls[h.line - 1];
|
|
@@ -1260,24 +1558,48 @@ function cmdStateResolveBlocker(cwd, text, raw) {
|
|
|
1260
1558
|
const filtered = lines.filter(line => {
|
|
1261
1559
|
if (!line.startsWith('- '))
|
|
1262
1560
|
return true;
|
|
1263
|
-
|
|
1561
|
+
// Case-insensitive substring match — unchanged from before the fix;
|
|
1562
|
+
// only whether a match occurred is now tracked accurately.
|
|
1563
|
+
const isMatch = line.toLowerCase().includes(text.toLowerCase());
|
|
1564
|
+
if (isMatch)
|
|
1565
|
+
matched = true;
|
|
1566
|
+
return !isMatch;
|
|
1264
1567
|
});
|
|
1568
|
+
if (!matched)
|
|
1569
|
+
return content;
|
|
1265
1570
|
let newBody = filtered.join('\n');
|
|
1266
1571
|
// If section is now empty, add placeholder
|
|
1267
1572
|
if (!newBody.trim() || !newBody.includes('- ')) {
|
|
1268
1573
|
newBody = 'None\n';
|
|
1269
1574
|
}
|
|
1270
|
-
resolved = true;
|
|
1271
1575
|
return content.slice(0, bs) + newBody + content.slice(se);
|
|
1272
1576
|
}, cwd);
|
|
1273
|
-
if (
|
|
1577
|
+
if (matched) {
|
|
1274
1578
|
output({ resolved: true, blocker: text }, raw, 'true');
|
|
1275
1579
|
}
|
|
1580
|
+
else if (!sectionFound) {
|
|
1581
|
+
declineNoOp(raw, 'resolved', 'no Blockers/Concerns section found in STATE.md', 'state resolve-blocker skipped — no Blockers/Concerns section found in STATE.md.');
|
|
1582
|
+
}
|
|
1276
1583
|
else {
|
|
1277
|
-
|
|
1584
|
+
// `formatDiagnosticToken` only guards the STDERR disclosure — the JSON
|
|
1585
|
+
// `reason` field can embed `text` raw since output()'s own
|
|
1586
|
+
// JSON.stringify serialization already escapes it correctly.
|
|
1587
|
+
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
1588
|
}
|
|
1279
1589
|
}
|
|
1280
1590
|
function cmdStateRecordSession(cwd, options, raw) {
|
|
1591
|
+
// #4186: a bare invocation is a usage error, not a heartbeat write. The
|
|
1592
|
+
// pre-#4186 handler accepted zero arguments and still refreshed
|
|
1593
|
+
// `Last session` / `Last Date` / `last_updated` — a caller probing the
|
|
1594
|
+
// command's signature (the way other subcommands encourage) silently
|
|
1595
|
+
// mutated STATE.md. Mirrors `state update`'s required-arg guard
|
|
1596
|
+
// (cmdStateUpdate: `error('field and value required for state update')`),
|
|
1597
|
+
// including its ordering: validation precedes the STATE.md existence
|
|
1598
|
+
// check. Either flag suffices — `--resume-file` alone carries an explicit
|
|
1599
|
+
// value the handler must persist.
|
|
1600
|
+
if (!options.stopped_at && (options.resume_file === undefined || options.resume_file === null)) {
|
|
1601
|
+
error('stopped-at or resume-file required for state record-session');
|
|
1602
|
+
}
|
|
1281
1603
|
const statePath = planningPaths(cwd).state;
|
|
1282
1604
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
1283
1605
|
output({ error: 'STATE.md not found' }, raw, undefined);
|
|
@@ -1516,8 +1838,22 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1516
1838
|
result['created'] = true;
|
|
1517
1839
|
output(result, raw, 'true');
|
|
1518
1840
|
}
|
|
1841
|
+
else if (updated.length === 0) {
|
|
1842
|
+
// Nothing was ever attempted — no --stopped-at/--resume-file supplied
|
|
1843
|
+
// and no existing Last session/Last Date/Stopped At/Resume File labels
|
|
1844
|
+
// to touch.
|
|
1845
|
+
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.');
|
|
1846
|
+
}
|
|
1519
1847
|
else {
|
|
1520
|
-
|
|
1848
|
+
// #3957: `updated` (pre-reconciliation) was non-empty — a rewrite
|
|
1849
|
+
// matched a session field and reported it as changed — but
|
|
1850
|
+
// `reconcileReportedFields` found the persisted bytes byte-identical to
|
|
1851
|
+
// what was already on disk (the matched field's supplied value equals
|
|
1852
|
+
// its already-recorded value), so nothing actually changed. Distinct
|
|
1853
|
+
// from the "nothing was ever attempted" case above: the prior single
|
|
1854
|
+
// reason collapsed both into 'No session fields found in STATE.md',
|
|
1855
|
+
// which was simply wrong for this case.
|
|
1856
|
+
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
1857
|
}
|
|
1522
1858
|
}
|
|
1523
1859
|
/**
|
|
@@ -1819,7 +2155,56 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
1819
2155
|
// ROADMAP phase token against an on-disk phase directory — moved to the
|
|
1820
2156
|
// phase-id owner module in #2562 so every consumer derives BOTH sides of a
|
|
1821
2157
|
// phase comparison from the same function (see phase-id.cts). Imported at the
|
|
1822
|
-
// top of this file; call sites below are unchanged.
|
|
2158
|
+
// top of this file; call sites below are unchanged. #612 threads the optional
|
|
2159
|
+
// `convention` through that owner's `phaseKeyFromDir` (see phase-id.cts) rather
|
|
2160
|
+
// than re-deriving a bracket-aware key here.
|
|
2161
|
+
/**
|
|
2162
|
+
* #612: is the asserted milestone bounded to a heading in this ROADMAP?
|
|
2163
|
+
*
|
|
2164
|
+
* The legacy rule matches STATE's milestone STRING (`v2.0`) inside a heading.
|
|
2165
|
+
* The ADR-canonical bracket milestone heading is `## [GSD.02] Foundation` — a
|
|
2166
|
+
* name, no version — so that rule finds nothing, the milestone reads as
|
|
2167
|
+
* unbounded, and total_phases falls back to the on-disk directory count. Under
|
|
2168
|
+
* the bracket convention the milestone integer in the bracket is matched against
|
|
2169
|
+
* the `vN` of the milestone string instead (READING-B parity). Gated, and only
|
|
2170
|
+
* consulted after the legacy rule has already failed, so no non-bracket repo
|
|
2171
|
+
* changes answer.
|
|
2172
|
+
*/
|
|
2173
|
+
function isMilestoneBounded(roadmapRaw, milestone, convention) {
|
|
2174
|
+
// #3184: preserve roadmap-parser's canonical legacy answer and compose the
|
|
2175
|
+
// gated bracket extension on top of it. Re-deriving the version-heading
|
|
2176
|
+
// grammar here would restore the boundary drift that #3184 removed.
|
|
2177
|
+
if (isMilestoneBoundedInRoadmap(roadmapRaw, String(milestone).trim()))
|
|
2178
|
+
return true;
|
|
2179
|
+
if (convention !== 'bracket')
|
|
2180
|
+
return false;
|
|
2181
|
+
const vMatch = String(milestone).trim().match(/^v(\d+)/i);
|
|
2182
|
+
const milestoneInt = vMatch ? parseInt(vMatch[1], 10) : NaN;
|
|
2183
|
+
if (!Number.isSafeInteger(milestoneInt))
|
|
2184
|
+
return false;
|
|
2185
|
+
// Canonical spelling only — see the note in roadmap-parser's scoping branch.
|
|
2186
|
+
// Accepting `0*N` here bounded a milestone whose phases were invisible, which
|
|
2187
|
+
// un-suppressed a progress percent computed off an unscoped disk count.
|
|
2188
|
+
// #2761 M3: that padding rule and the grammar both come from the owner's
|
|
2189
|
+
// `bracketMilestoneIntroSrcFor`. This line and roadmap-parser's selector were
|
|
2190
|
+
// character-identical re-typings of one pattern, so "canonical spelling only"
|
|
2191
|
+
// was a convention two files had to keep agreeing on by hand — and the drift
|
|
2192
|
+
// guard could not see either copy.
|
|
2193
|
+
// #612 round-4 (Major 1, F12): fence-aware via tokenizeHeadings, not a raw
|
|
2194
|
+
// `.test(roadmapRaw)` — a FENCED `[GSD.02]` example heading (the ONLY one
|
|
2195
|
+
// in the document, with no real section for the asserted milestone at
|
|
2196
|
+
// all) previously bounded a milestone that isn't actually in the roadmap,
|
|
2197
|
+
// un-suppressing a percent computed off the wrong (prior-milestone-plus-
|
|
2198
|
+
// whole-disk) phase set. tokenizeHeadings never produces a token for a
|
|
2199
|
+
// fenced line, so a fenced-only example can no longer satisfy this test.
|
|
2200
|
+
const bracketMilestoneHeadingRe = new RegExp(`^${bracketMilestoneIntroSrcFor(milestoneInt)}`, 'i');
|
|
2201
|
+
// #612 round-5 (Minor 1): skip ≤3-space-indented tokens — `h.offset` is
|
|
2202
|
+
// tokenizeHeadings' LINE-START offset, not the `#` character, so an
|
|
2203
|
+
// indented heading here would bound a milestone the line-start-anchored
|
|
2204
|
+
// raw predecessor never matched. Restores raw parity; see roadmap-parser's
|
|
2205
|
+
// matching selector-reconstruction comment for the full rationale.
|
|
2206
|
+
return (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmapRaw).some((h) => h.level <= 3 && roadmapRaw[h.offset] === '#' && bracketMilestoneHeadingRe.test(h.text));
|
|
2207
|
+
}
|
|
1823
2208
|
/**
|
|
1824
2209
|
* Extract the set of retired/folded phase keys from a ROADMAP milestone scope
|
|
1825
2210
|
* (#1514). A retired phase is struck through with GFM strikethrough,
|
|
@@ -1841,16 +2226,31 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
1841
2226
|
* decimal, and project-code IDs are detected alike. Returns canonical keys
|
|
1842
2227
|
* (see phaseKeyFromToken).
|
|
1843
2228
|
*/
|
|
1844
|
-
function extractRetiredPhaseNumbers(scope) {
|
|
2229
|
+
function extractRetiredPhaseNumbers(scope, convention) {
|
|
1845
2230
|
const retired = new Set();
|
|
1846
2231
|
const isChecklistOrHeading = /^\s*(?:[-*+]\s*\[[ xX]\]|#{1,6}\s)/;
|
|
1847
|
-
|
|
2232
|
+
// #612: the retirement filter has to widen with the counter it protects. The
|
|
2233
|
+
// canonical #1514 gesture strikes the checklist BULLET and leaves the detail
|
|
2234
|
+
// heading intact, so a bracket-form retirement went undetected and the phase
|
|
2235
|
+
// stayed in the denominator forever — a shipped bracket milestone could never
|
|
2236
|
+
// reach 100%. Same selection rule as the counter: a non-bracket repo compiles
|
|
2237
|
+
// the bare `Phase\s+` this line spelled before.
|
|
2238
|
+
const introSrc = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention);
|
|
2239
|
+
const phaseRefRe = new RegExp(`^[\\s*_]*${introSrc}([\\w][\\w.-]*)`, 'i');
|
|
2240
|
+
// #612 round-5 (Major 1): fence-aware on the BRACKET path only — a fenced
|
|
2241
|
+
// AUTHORING EXAMPLE of the #1514 retirement gesture, spelled in bracket
|
|
2242
|
+
// form, must not retire a real phase. Reuses markdown-sectionizer's
|
|
2243
|
+
// single-owner stripFencedCode rather than a second fence parser. Legacy
|
|
2244
|
+
// stays the raw `scope`, byte-identical — its own fenced-example hazard is
|
|
2245
|
+
// pre-existing and out of scope.
|
|
2246
|
+
const scanScope = convention === 'bracket' ? (0, markdown_sectionizer_cjs_1.stripFencedCode)(scope).text : scope;
|
|
2247
|
+
for (const line of scanScope.split(/\r?\n/)) {
|
|
1848
2248
|
if (!isChecklistOrHeading.test(line))
|
|
1849
2249
|
continue;
|
|
1850
2250
|
const strikeSpan = /~~([^~]*?)~~/g;
|
|
1851
2251
|
let s;
|
|
1852
2252
|
while ((s = strikeSpan.exec(line)) !== null) {
|
|
1853
|
-
const phaseRef =
|
|
2253
|
+
const phaseRef = phaseRefRe.exec(s[1]);
|
|
1854
2254
|
// Require a digit so struck prose like ~~Phase Overview~~ is ignored.
|
|
1855
2255
|
if (phaseRef && /\d/.test(phaseRef[1]))
|
|
1856
2256
|
retired.add(phaseKeyFromToken(phaseRef[1]));
|
|
@@ -1858,12 +2258,110 @@ function extractRetiredPhaseNumbers(scope) {
|
|
|
1858
2258
|
}
|
|
1859
2259
|
return retired;
|
|
1860
2260
|
}
|
|
2261
|
+
/**
|
|
2262
|
+
* #612 (round-4 fix): the single shared implementation for the phase-heading
|
|
2263
|
+
* counter `buildStateFrontmatter` (read path) and `cmdStateSync` (write
|
|
2264
|
+
* path) each built inline as an independent copy. The comment at each call
|
|
2265
|
+
* site already claimed "the two counters must see the same phases or
|
|
2266
|
+
* `state json` and `state sync` report different totals for one repo
|
|
2267
|
+
* (#3242 Bug B)" — this makes that invariant STRUCTURAL (one implementation,
|
|
2268
|
+
* two call sites) instead of two copies a future edit could silently
|
|
2269
|
+
* diverge.
|
|
2270
|
+
*
|
|
2271
|
+
* Two DELIBERATELY DIFFERENT counting strategies, selected by `convention`:
|
|
2272
|
+
*
|
|
2273
|
+
* - BRACKET: counts via `tokenizeHeadings(scope)` at levels 2-4 (mirroring
|
|
2274
|
+
* `getMilestonePhaseFilter`'s own level bound, `roadmap-parser.cts:1090`),
|
|
2275
|
+
* testing each heading's (hash-stripped, fence-STRIPPED-by-construction)
|
|
2276
|
+
* text against the phase-heading-intro grammar directly. Fence-aware by
|
|
2277
|
+
* construction — `tokenizeHeadings` never produces a token for a fenced
|
|
2278
|
+
* line — closing round-4's Major 1: a fenced EXAMPLE phase heading in the
|
|
2279
|
+
* preamble (`` ### [GSD.02] 05: Example phase `` inside a
|
|
2280
|
+
* ` ```markdown ` block) previously inflated this count via the raw regex
|
|
2281
|
+
* below, which ran over the whole scope STRING with no fence awareness at
|
|
2282
|
+
* all (F9, F10 — `total_phases` read 3 where the milestone has 2 real
|
|
2283
|
+
* phases). The producer (`extractCurrentMilestone`'s returned scope
|
|
2284
|
+
* string) is deliberately NOT changed — every other consumer of that
|
|
2285
|
+
* string needs its full content fidelity, and the legacy path's identity
|
|
2286
|
+
* forbids touching the string all consumers share; this fixes the
|
|
2287
|
+
* COUNTING, not the scope.
|
|
2288
|
+
*
|
|
2289
|
+
* - LEGACY (any non-bracket convention, including unresolved/null): retain
|
|
2290
|
+
* the existing raw `content.exec()` counting strategy. On the read path,
|
|
2291
|
+
* route sentinel exclusion through #3185's canonical predicate; the sync
|
|
2292
|
+
* path intentionally retains its pre-existing absence of that exclusion.
|
|
2293
|
+
*
|
|
2294
|
+
* `applyConventionTokenSentinelRules` makes the remaining convention-specific
|
|
2295
|
+
* asymmetry explicit. Both read and sync exclude bare bracket token 999; only
|
|
2296
|
+
* the read path excludes canonical legacy sentinels. Both bracket paths also
|
|
2297
|
+
* retain the bracket-id and bare-0 rules. Sharing the implementation therefore
|
|
2298
|
+
* cannot silently move either convention's total.
|
|
2299
|
+
*/
|
|
2300
|
+
function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyConventionTokenSentinelRules) {
|
|
2301
|
+
let count = 0;
|
|
2302
|
+
if (convention === 'bracket') {
|
|
2303
|
+
const introSrc = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true);
|
|
2304
|
+
const phaseHeadingPattern = new RegExp(`^${introSrc}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'i');
|
|
2305
|
+
for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(scope)) {
|
|
2306
|
+
if (h.level < 2 || h.level > 4)
|
|
2307
|
+
continue;
|
|
2308
|
+
const m = phaseHeadingPattern.exec(h.text);
|
|
2309
|
+
if (!m)
|
|
2310
|
+
continue;
|
|
2311
|
+
const bracketId = m[1];
|
|
2312
|
+
const token = m[2];
|
|
2313
|
+
// Only count tokens that contain at least one digit — excludes
|
|
2314
|
+
// pure-word section headings (Overview, Details) while keeping
|
|
2315
|
+
// numeric phases (01, 05.1) and project-code IDs (PROJ-42).
|
|
2316
|
+
if (!/\d/.test(token))
|
|
2317
|
+
continue;
|
|
2318
|
+
// #612 READING-B: a bracket heading carries its sentinel in the
|
|
2319
|
+
// bracket, so `### [GSD.999] 01:` is an icebox item even though its
|
|
2320
|
+
// token is `01`.
|
|
2321
|
+
if (bracketId && isSentinelPhaseId(`${bracketId}-${token}`, 'bracket'))
|
|
2322
|
+
continue;
|
|
2323
|
+
// #612: under bracket the token rule composes with the bracket-id
|
|
2324
|
+
// check as the engine's {0, 999} sentinel set.
|
|
2325
|
+
if (bracketId && /^0\b/.test(token))
|
|
2326
|
+
continue;
|
|
2327
|
+
if (applyConventionTokenSentinelRules && /^999\b/.test(token))
|
|
2328
|
+
continue;
|
|
2329
|
+
// #1514: retired/folded phases are struck through in the ROADMAP;
|
|
2330
|
+
// exclude them from the denominator (they can never be completed).
|
|
2331
|
+
if (retiredPhaseNums.has(phaseKeyFromToken(token)))
|
|
2332
|
+
continue;
|
|
2333
|
+
count++;
|
|
2334
|
+
}
|
|
2335
|
+
return count;
|
|
2336
|
+
}
|
|
2337
|
+
// LEGACY stays on the pre-round-4 raw exec loop. #3185 owns the read-path
|
|
2338
|
+
// sentinel predicate; sync deliberately preserves its prior behavior.
|
|
2339
|
+
const phaseHeadingPattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true)}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi');
|
|
2340
|
+
let m;
|
|
2341
|
+
while ((m = phaseHeadingPattern.exec(scope)) !== null) {
|
|
2342
|
+
const token = m[1];
|
|
2343
|
+
if (!/\d/.test(token))
|
|
2344
|
+
continue;
|
|
2345
|
+
if (applyConventionTokenSentinelRules && isSentinelPhaseId(token))
|
|
2346
|
+
continue;
|
|
2347
|
+
if (retiredPhaseNums.has(phaseKeyFromToken(token)))
|
|
2348
|
+
continue;
|
|
2349
|
+
count++;
|
|
2350
|
+
}
|
|
2351
|
+
return count;
|
|
2352
|
+
}
|
|
1861
2353
|
/**
|
|
1862
2354
|
* Extract machine-readable fields from STATE.md markdown body and build
|
|
1863
2355
|
* a YAML frontmatter object. Allows hooks and scripts to read state
|
|
1864
2356
|
* reliably via `state json` instead of fragile regex parsing.
|
|
1865
2357
|
*/
|
|
1866
|
-
function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPhases
|
|
2358
|
+
function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPhases,
|
|
2359
|
+
// #4094: the stored siblings of storedTotalPhases, threaded from each call
|
|
2360
|
+
// site exactly the same way — see readStoredProgressCounter below. Under the
|
|
2361
|
+
// #3354/#3573 withhold condition the disk scan returns null for all four
|
|
2362
|
+
// counters, and these stored values are what the progress block falls back
|
|
2363
|
+
// to (else the keys are omitted).
|
|
2364
|
+
storedCompletedPhases, storedTotalPlans, storedCompletedPlans) {
|
|
1867
2365
|
// #2956: scope `Phase` extraction to ## Current Position (mirrors the read
|
|
1868
2366
|
// path in cmdStateSnapshot and the Stopped At / Paused At ## Session scoping
|
|
1869
2367
|
// below). Phase canonically lives in ## Current Position (templates/state.md);
|
|
@@ -1953,6 +2451,10 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1953
2451
|
// from the pre-existing frontmatter fields parsed above, a path this phase
|
|
1954
2452
|
// does not touch and which predates listMilestonePhaseDirs entirely.
|
|
1955
2453
|
let diskScope = SCOPE.COMPLETE;
|
|
2454
|
+
// #612: resolved ONCE per call, federated workstream -> root, and shared by
|
|
2455
|
+
// the heading counter, the retirement filter and the retired-directory skip so
|
|
2456
|
+
// no two of them can split on different answers.
|
|
2457
|
+
const phaseConvention = cwd ? resolvePhaseIdConvention(cwd) : null;
|
|
1956
2458
|
if (cwd) {
|
|
1957
2459
|
try {
|
|
1958
2460
|
const phasesDir = planningPaths(cwd).phases;
|
|
@@ -1973,7 +2475,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1973
2475
|
roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
|
|
1974
2476
|
if (roadmapRaw !== null) {
|
|
1975
2477
|
roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
|
1976
|
-
retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope);
|
|
2478
|
+
retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope, phaseConvention);
|
|
1977
2479
|
}
|
|
1978
2480
|
}
|
|
1979
2481
|
catch { /* fall through: no roadmap scope → no retired exclusion */ }
|
|
@@ -1984,7 +2486,11 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1984
2486
|
// CURRENT (stored) milestone" — routed through the canonical owner
|
|
1985
2487
|
// instead of a hand-rolled readdirSync + isDirInMilestone filter
|
|
1986
2488
|
// (which also never excluded sentinels, unlike the owner).
|
|
1987
|
-
const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, {
|
|
2489
|
+
const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, {
|
|
2490
|
+
cwd,
|
|
2491
|
+
versionOverride: storedMilestone ?? null,
|
|
2492
|
+
phaseIdConvention: phaseConvention,
|
|
2493
|
+
});
|
|
1988
2494
|
// Bug #2445: when stale phase dirs from a prior milestone remain in
|
|
1989
2495
|
// .planning/phases/ alongside new dirs with the same phase number,
|
|
1990
2496
|
// de-duplicate by normalized phase number keeping exactly one dir
|
|
@@ -1996,7 +2502,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1996
2502
|
// artifact; drop it from the disk phase set so it counts toward
|
|
1997
2503
|
// neither the denominator nor the numerator (mirrors the heading
|
|
1998
2504
|
// exclusion below). Project-code-aware via phaseKeyFromDir.
|
|
1999
|
-
if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir)))
|
|
2505
|
+
if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir, phaseConvention)))
|
|
2000
2506
|
continue;
|
|
2001
2507
|
// #3185: dedup grouping routed through the canonical phaseKeyFromDir
|
|
2002
2508
|
// (src/phase-id.cts) instead of a local leading-digits regex that
|
|
@@ -2005,7 +2511,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
2005
2511
|
// so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on
|
|
2006
2512
|
// multi-segment milestone dirs. Same key surface used two lines
|
|
2007
2513
|
// above for the retiredPhaseNums exclusion, so both filters agree.
|
|
2008
|
-
const key = phaseKeyFromDir(dir);
|
|
2514
|
+
const key = phaseKeyFromDir(dir, phaseConvention);
|
|
2009
2515
|
if (!seenPhaseNums.has(key)) {
|
|
2010
2516
|
seenPhaseNums.set(key, dir);
|
|
2011
2517
|
}
|
|
@@ -2046,34 +2552,21 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
2046
2552
|
// own comment on that field). Folding this consumer onto the raw
|
|
2047
2553
|
// summaries-met flag was the exact "consolidate two of three and
|
|
2048
2554
|
// leave the third" gap §7.4's forcing function rules out.
|
|
2049
|
-
|
|
2555
|
+
// #612: `phaseConvention` threaded so a bracket phase dir resolves
|
|
2556
|
+
// and scopes its verification report like its legacy twin — the
|
|
2557
|
+
// read-side half of the same thread cmdStateSync gets below.
|
|
2558
|
+
if (isPhaseComplete(phaseDir, { convention: phaseConvention }).value.complete)
|
|
2050
2559
|
diskCompletedPhases++;
|
|
2051
2560
|
}
|
|
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
|
-
}
|
|
2561
|
+
// Count phase headings from ROADMAP — single source of truth for
|
|
2562
|
+
// total_phases (#549). #612 round-4: shared with cmdStateSync's
|
|
2563
|
+
// identical-purpose counter via countRoadmapPhaseHeadings (above
|
|
2564
|
+
// extractRetiredPhaseNumbers). The shared helper composes its
|
|
2565
|
+
// fence-aware bracket strategy with #3185's canonical legacy
|
|
2566
|
+
// sentinel predicate for this read-path call.
|
|
2567
|
+
const roadmapPhaseCount = roadmapScope !== null
|
|
2568
|
+
? countRoadmapPhaseHeadings(roadmapScope, phaseConvention, retiredPhaseNums, true)
|
|
2569
|
+
: 0;
|
|
2077
2570
|
cached = (() => {
|
|
2078
2571
|
// #1761 read-path: mirror the cmdStateSync guard (#1794). When the
|
|
2079
2572
|
// asserted milestone version can't be bounded to a versioned ROADMAP
|
|
@@ -2098,7 +2591,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
2098
2591
|
// the prior inline regex had no boundary assertion after the
|
|
2099
2592
|
// version token, so `v2.0` matched inside `v2.0.1` (#2562-class
|
|
2100
2593
|
// defect, design row 17).
|
|
2101
|
-
milestoneBounded =
|
|
2594
|
+
milestoneBounded = isMilestoneBounded(roadmapRaw, String(assertedMilestoneVersion).trim(), phaseConvention);
|
|
2102
2595
|
}
|
|
2103
2596
|
// #2828: distinguish a FLAT unmilestoned roadmap (no milestone sectioning
|
|
2104
2597
|
// at all — only Phase headings) from a MILESTONED-but-unbounded one
|
|
@@ -2140,7 +2633,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
2140
2633
|
// the disk count is the only source and remains correct.
|
|
2141
2634
|
const milestonedButUnbounded = !milestoneBounded && roadmapHasAnyMilestoneSection;
|
|
2142
2635
|
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
|
|
2636
|
+
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
2637
|
}
|
|
2145
2638
|
// #3573: the roadmap-absent sibling of the #3354 shape. With ROADMAP.md
|
|
2146
2639
|
// absent/unreadable the #549 heading counter never ran (roadmapScope
|
|
@@ -2157,21 +2650,59 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
2157
2650
|
typeof storedMilestone === 'string' &&
|
|
2158
2651
|
storedMilestone.trim() !== '';
|
|
2159
2652
|
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
|
|
2653
|
+
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
2654
|
}
|
|
2655
|
+
// #4094: the withhold condition covers ALL FOUR progress counters,
|
|
2656
|
+
// not just total_phases. completed_phases / total_plans /
|
|
2657
|
+
// completed_plans are accumulated from the exact same phaseDirs
|
|
2658
|
+
// walk as total_phases (same loop, same scope, same filters), so
|
|
2659
|
+
// whenever that walk's scope is known-untrustworthy — the exact
|
|
2660
|
+
// condition #3354 established — they are equally untrustworthy.
|
|
2661
|
+
// Pre-#4094 only totalPhases was nulled here, so every resyncing
|
|
2662
|
+
// write silently clobbered the three stored siblings with the
|
|
2663
|
+
// under-scoped disk numbers.
|
|
2664
|
+
const diskCountsWithheld = milestonedButUnbounded || roadmapAbsentWithAssertedMilestone;
|
|
2665
|
+
// #4129: floor the completed-phases numerator at the ROADMAP's own
|
|
2666
|
+
// milestone Complete-row count. The disk numerator counts ONLY
|
|
2667
|
+
// phase dirs whose *-VERIFICATION.md routes `passed` (isPhaseComplete,
|
|
2668
|
+
// #2957 disk-strict — the gate stays untouched), so a completed
|
|
2669
|
+
// phase whose verification reads `stale` (a SUMMARY committed or
|
|
2670
|
+
// edited after it, #2348 clean-commit-time clock) or `missing`
|
|
2671
|
+
// (pre-verification era, hand-flipped ROADMAP row) drops out of the
|
|
2672
|
+
// count forever — while every other surface (the ROADMAP row
|
|
2673
|
+
// `phase complete` just flipped, the body `Completed Phases` field
|
|
2674
|
+
// completePhaseCore derives from deriveProgressFromRoadmap) still
|
|
2675
|
+
// asserts the phase complete. max(disk, ROADMAP) keeps the disk
|
|
2676
|
+
// signal for gap detection (a verification-passed phase whose ROADMAP
|
|
2677
|
+
// row is not yet flipped still counts) while never UNDER-counting
|
|
2678
|
+
// what the ROADMAP asserts. Scoped exactly like the denominator:
|
|
2679
|
+
// the same milestone window (roadmapScope), the same
|
|
2680
|
+
// safeToUseRoadmapCount gate, and never under the #3354/#3573
|
|
2681
|
+
// withhold — a whole-document Complete-row count must not leak
|
|
2682
|
+
// through an untrustworthy scope. Reuses deriveProgressFromRoadmap
|
|
2683
|
+
// (phase-lifecycle.cts, the one owner of "read the Progress table")
|
|
2684
|
+
// — no second ROADMAP parser here. A ROADMAP without a canonical
|
|
2685
|
+
// `## Progress` table resolves no table → floor inert (disk count
|
|
2686
|
+
// stands), the owner's own answer to "what is countable".
|
|
2687
|
+
const roadmapCompletedPhases = roadmapScope !== null && safeToUseRoadmapCount && !diskCountsWithheld
|
|
2688
|
+
? deriveProgressFromRoadmap(roadmapScope).completedPhases
|
|
2689
|
+
: null;
|
|
2690
|
+
const flooredCompletedPhases = roadmapCompletedPhases !== null
|
|
2691
|
+
? Math.max(diskCompletedPhases, roadmapCompletedPhases)
|
|
2692
|
+
: diskCompletedPhases;
|
|
2162
2693
|
return {
|
|
2163
2694
|
// The two WITHHOLD shapes (#3354 milestoned-but-unbounded, #3573
|
|
2164
2695
|
// roadmap-absent-with-asserted-milestone) must be evaluated BEFORE
|
|
2165
2696
|
// safeToUseRoadmapCount — in the #3573 shape milestoneBounded is
|
|
2166
2697
|
// vacuously true (its gate requires roadmapRaw), so the safe-count
|
|
2167
2698
|
// arm would otherwise swallow the withhold.
|
|
2168
|
-
totalPhases:
|
|
2699
|
+
totalPhases: diskCountsWithheld
|
|
2169
2700
|
? null
|
|
2170
2701
|
: (safeToUseRoadmapCount ? Math.max(phaseDirs.length, roadmapPhaseCount) : phaseDirs.length),
|
|
2171
2702
|
milestoneBounded,
|
|
2172
|
-
completedPhases:
|
|
2173
|
-
totalPlans: diskTotalPlans,
|
|
2174
|
-
completedPlans: diskTotalSummaries,
|
|
2703
|
+
completedPhases: diskCountsWithheld ? null : flooredCompletedPhases,
|
|
2704
|
+
totalPlans: diskCountsWithheld ? null : diskTotalPlans,
|
|
2705
|
+
completedPlans: diskCountsWithheld ? null : diskTotalSummaries,
|
|
2175
2706
|
phaseDirScope,
|
|
2176
2707
|
};
|
|
2177
2708
|
})();
|
|
@@ -2189,9 +2720,31 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
2189
2720
|
else if (storedTotalPhases !== null && storedTotalPhases !== undefined) {
|
|
2190
2721
|
totalPhases = storedTotalPhases;
|
|
2191
2722
|
}
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2723
|
+
// #4094: the same withhold-then-fall-back-to-stored pattern for the
|
|
2724
|
+
// three sibling counters. They are derived from the identical
|
|
2725
|
+
// phaseDirs walk, so cached.* === null here means the SAME withheld
|
|
2726
|
+
// condition — keep the stored frontmatter value when the caller can
|
|
2727
|
+
// supply it; else leave null (key omitted). Note completedPhases /
|
|
2728
|
+
// completedPlans have NO body-annotation fallback (only the totals
|
|
2729
|
+
// have body annotations), so an unstored-withheld counter is omitted.
|
|
2730
|
+
if (cached.completedPhases !== null) {
|
|
2731
|
+
completedPhases = cached.completedPhases;
|
|
2732
|
+
}
|
|
2733
|
+
else if (storedCompletedPhases !== null && storedCompletedPhases !== undefined) {
|
|
2734
|
+
completedPhases = storedCompletedPhases;
|
|
2735
|
+
}
|
|
2736
|
+
if (cached.totalPlans !== null) {
|
|
2737
|
+
totalPlans = cached.totalPlans;
|
|
2738
|
+
}
|
|
2739
|
+
else if (storedTotalPlans !== null && storedTotalPlans !== undefined) {
|
|
2740
|
+
totalPlans = storedTotalPlans;
|
|
2741
|
+
}
|
|
2742
|
+
if (cached.completedPlans !== null) {
|
|
2743
|
+
completedPlans = cached.completedPlans;
|
|
2744
|
+
}
|
|
2745
|
+
else if (storedCompletedPlans !== null && storedCompletedPlans !== undefined) {
|
|
2746
|
+
completedPlans = storedCompletedPlans;
|
|
2747
|
+
}
|
|
2195
2748
|
milestoneUnbounded = cached.milestoneBounded === false;
|
|
2196
2749
|
diskScope = cached.phaseDirScope;
|
|
2197
2750
|
}
|
|
@@ -2240,19 +2793,19 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
2240
2793
|
progressPercent = parseInt(pctMatch[1], 10);
|
|
2241
2794
|
}
|
|
2242
2795
|
let normalizedStatus = (0, state_document_cjs_1.normalizeStateStatus)(status, pausedAt);
|
|
2243
|
-
// #3578:
|
|
2244
|
-
//
|
|
2245
|
-
//
|
|
2246
|
-
//
|
|
2247
|
-
// prose must never decide milestone-level
|
|
2248
|
-
// totalPhases / diskScope, already derived above
|
|
2249
|
-
// the authority on whether the MILESTONE is actually
|
|
2250
|
-
// when: (a) normalizeStateStatus actually landed on
|
|
2251
|
-
// raw prose is UNAMBIGUOUSLY phase-completion prose —
|
|
2252
|
-
// pattern below deliberately excludes "All phases complete"
|
|
2253
|
-
// phase token) and milestone-close prose like "v1.0 milestone
|
|
2254
|
-
// (no leading "phase"); and (c) the counters are trustworthy (a
|
|
2255
|
-
// disk scope, both counts are finite numbers, and a positive
|
|
2796
|
+
// #3578: the declared status vocabulary (#4186) recognizes
|
|
2797
|
+
// `Phase ${N} complete` (state.cts's own phase-completion write) and maps
|
|
2798
|
+
// it to `completed`, so the phase-completion prose still collapses to the
|
|
2799
|
+
// milestone-level status even when other phases remain open — this guard
|
|
2800
|
+
// demotes it back. Phase-level prose must never decide milestone-level
|
|
2801
|
+
// status — completedPhases / totalPhases / diskScope, already derived above
|
|
2802
|
+
// from a disk scan, are the authority on whether the MILESTONE is actually
|
|
2803
|
+
// done. Only override when: (a) normalizeStateStatus actually landed on
|
|
2804
|
+
// 'completed'; (b) the raw prose is UNAMBIGUOUSLY phase-completion prose —
|
|
2805
|
+
// the anchored pattern below deliberately excludes "All phases complete"
|
|
2806
|
+
// (no `\S+` phase token) and milestone-close prose like "v1.0 milestone
|
|
2807
|
+
// complete" (no leading "phase"); and (c) the counters are trustworthy (a
|
|
2808
|
+
// COMPLETE disk scope, both counts are finite numbers, and a positive
|
|
2256
2809
|
// denominator) and affirmatively disagree with 'completed'. In every
|
|
2257
2810
|
// other case normalizedStatus is left exactly as normalizeStateStatus
|
|
2258
2811
|
// returned it.
|
|
@@ -2483,12 +3036,21 @@ function readStateHeadFreshness(cwd, stateHead) {
|
|
|
2483
3036
|
* instead of being clobbered by the on-disk phase-directory count.
|
|
2484
3037
|
*/
|
|
2485
3038
|
function readStoredTotalPhases(existingFm) {
|
|
3039
|
+
return readStoredProgressCounter(existingFm, 'total_phases');
|
|
3040
|
+
}
|
|
3041
|
+
/**
|
|
3042
|
+
* #4094: the three sibling readers of readStoredTotalPhases, one per progress
|
|
3043
|
+
* counter the #3354/#3573 withhold now protects. All four counters come from
|
|
3044
|
+
* the same disk-scan walk and are withheld together; these readers feed the
|
|
3045
|
+
* stored-value fallback for the three that previously had none.
|
|
3046
|
+
*/
|
|
3047
|
+
function readStoredProgressCounter(existingFm, key) {
|
|
2486
3048
|
if (!existingFm || typeof existingFm !== 'object')
|
|
2487
3049
|
return null;
|
|
2488
3050
|
const progress = existingFm['progress'];
|
|
2489
3051
|
if (!progress || typeof progress !== 'object')
|
|
2490
3052
|
return null;
|
|
2491
|
-
const raw = progress[
|
|
3053
|
+
const raw = progress[key];
|
|
2492
3054
|
if (raw === null || raw === undefined)
|
|
2493
3055
|
return null;
|
|
2494
3056
|
if (typeof raw === 'string' && raw.trim() === '')
|
|
@@ -2496,6 +3058,84 @@ function readStoredTotalPhases(existingFm) {
|
|
|
2496
3058
|
const n = Number(raw);
|
|
2497
3059
|
return Number.isFinite(n) ? n : null;
|
|
2498
3060
|
}
|
|
3061
|
+
function readStoredCompletedPhases(existingFm) {
|
|
3062
|
+
return readStoredProgressCounter(existingFm, 'completed_phases');
|
|
3063
|
+
}
|
|
3064
|
+
function readStoredTotalPlans(existingFm) {
|
|
3065
|
+
return readStoredProgressCounter(existingFm, 'total_plans');
|
|
3066
|
+
}
|
|
3067
|
+
function readStoredCompletedPlans(existingFm) {
|
|
3068
|
+
return readStoredProgressCounter(existingFm, 'completed_plans');
|
|
3069
|
+
}
|
|
3070
|
+
/**
|
|
3071
|
+
* #4129: is this authoritativeFm value a PARTIAL progress intent? The #2736
|
|
3072
|
+
* seam was string-only (names); #4129 extends it with one object direction —
|
|
3073
|
+
* the `progress` key carrying the sub-keys a transition resolved
|
|
3074
|
+
* authoritatively (completePhase's ROADMAP-derived completed_phases/percent).
|
|
3075
|
+
* Anything else keeps the seam's existing contract untouched.
|
|
3076
|
+
*/
|
|
3077
|
+
function isPartialProgressIntent(value) {
|
|
3078
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3079
|
+
}
|
|
3080
|
+
/**
|
|
3081
|
+
* #4129: merge a PARTIAL progress intent (see isPartialProgressIntent) into a
|
|
3082
|
+
* frontmatter object's `progress` block. Sub-keys are accepted only when they
|
|
3083
|
+
* are a declared `progress.*` row in FIELD_CLASSIFICATION — the single policy
|
|
3084
|
+
* source (ADR-3408 §8.5) decides which leaves exist; an intent may not invent
|
|
3085
|
+
* one. Returns whether anything changed.
|
|
3086
|
+
*
|
|
3087
|
+
* `completedOnlyRaise` (both application sites use it): completed counters
|
|
3088
|
+
* apply only when strictly greater than what is already in the block, so no
|
|
3089
|
+
* intent can LOWER a count another trustworthy signal already established —
|
|
3090
|
+
* at the pre-preservation site the disk derivation's own count (a
|
|
3091
|
+
* verification-passed phase whose ROADMAP row drifted behind), at the
|
|
3092
|
+
* post-preservation re-assert the #2969 monotonic property preservation just
|
|
3093
|
+
* enforced. `percent` follows its sibling: it is applied when a completed
|
|
3094
|
+
* counter moved this call (the intent percent was computed from the intent
|
|
3095
|
+
* counters and is coherent with them) or when the block has no percent to
|
|
3096
|
+
* lose (a repair, never a regression of an upstream withhold — the withhold
|
|
3097
|
+
* nulled percent upstream precisely so no write would re-assert one over
|
|
3098
|
+
* untrustworthy counts; here the intent's own counts ARE the trustworthy
|
|
3099
|
+
* source, the post-completion ROADMAP).
|
|
3100
|
+
*/
|
|
3101
|
+
function applyAuthoritativeProgressSubkeys(fm, intent, opts) {
|
|
3102
|
+
const current = fm['progress'];
|
|
3103
|
+
const base = isPartialProgressIntent(current)
|
|
3104
|
+
? { ...current }
|
|
3105
|
+
: {};
|
|
3106
|
+
let changed = false;
|
|
3107
|
+
let completedMoved = false;
|
|
3108
|
+
for (const [subkey, value] of Object.entries(intent)) {
|
|
3109
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
3110
|
+
continue;
|
|
3111
|
+
if (!getFieldClassification(`progress.${subkey}`))
|
|
3112
|
+
continue;
|
|
3113
|
+
const isCompletedCounter = subkey === 'completed_phases' || subkey === 'completed_plans';
|
|
3114
|
+
if (isCompletedCounter && opts.completedOnlyRaise) {
|
|
3115
|
+
const currentNum = (0, state_document_cjs_1.toFiniteNumber)(base[subkey]);
|
|
3116
|
+
if (currentNum !== null && currentNum >= value)
|
|
3117
|
+
continue;
|
|
3118
|
+
}
|
|
3119
|
+
if (isCompletedCounter && !Object.is(base[subkey], value))
|
|
3120
|
+
completedMoved = true;
|
|
3121
|
+
if (!Object.is(base[subkey], value)) {
|
|
3122
|
+
base[subkey] = value;
|
|
3123
|
+
changed = true;
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
// percent: applied only when a completed counter moved (coherent with the
|
|
3127
|
+
// counters that just landed) or when no percent exists to contradict.
|
|
3128
|
+
const intentPercent = intent['percent'];
|
|
3129
|
+
if (typeof intentPercent === 'number' && Number.isFinite(intentPercent) && (completedMoved || (0, state_document_cjs_1.toFiniteNumber)(base['percent']) === null)) {
|
|
3130
|
+
if (!Object.is(base['percent'], intentPercent)) {
|
|
3131
|
+
base['percent'] = intentPercent;
|
|
3132
|
+
changed = true;
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
if (changed)
|
|
3136
|
+
fm['progress'] = base;
|
|
3137
|
+
return changed;
|
|
3138
|
+
}
|
|
2499
3139
|
function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanentEmptyFallback) {
|
|
2500
3140
|
// Read existing frontmatter BEFORE stripping — it may contain values
|
|
2501
3141
|
// that the body no longer has (e.g., Status field removed by an agent).
|
|
@@ -2533,7 +3173,7 @@ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanent
|
|
|
2533
3173
|
// milestoned-but-unbounded withhold can preserve it across the write
|
|
2534
3174
|
// (the derived progress sub-block replaces the stored one wholesale below,
|
|
2535
3175
|
// so an omitted key would otherwise DELETE the stored value).
|
|
2536
|
-
const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm));
|
|
3176
|
+
const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
|
|
2537
3177
|
// Preserve existing frontmatter status when body-derived status is 'unknown'.
|
|
2538
3178
|
// This prevents a missing Status: field in the body from overwriting a
|
|
2539
3179
|
// previously valid status (e.g., 'executing' → 'unknown').
|
|
@@ -2701,11 +3341,22 @@ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanent
|
|
|
2701
3341
|
// parenthetical (`Closer-ruling measurement (D1a)` → `D1a`) — never runs
|
|
2702
3342
|
// the final word on a field the transition just resolved. The prose parser
|
|
2703
3343
|
// remains the fallback for genuinely unknown prose only.
|
|
3344
|
+
// #4129: the `progress` key carries a PARTIAL block (the object direction of
|
|
3345
|
+
// this seam — see applyAuthoritativeProgressSubkeys) for the same reason:
|
|
3346
|
+
// completePhase holds the POST-completion ROADMAP, and the disk scan this
|
|
3347
|
+
// function drives reads the PRE-completion one. The intent is applied as a
|
|
3348
|
+
// FLOOR here too (completedOnlyRaise): a derivation that already counted
|
|
3349
|
+
// MORE completed phases than the ROADMAP table asserts (verification-passed
|
|
3350
|
+
// phases whose table rows drifted behind) must not be lowered by the intent
|
|
3351
|
+
// — the two signals agree on direction (up), never on subtraction.
|
|
2704
3352
|
if (authoritativeFm) {
|
|
2705
3353
|
for (const [key, value] of Object.entries(authoritativeFm)) {
|
|
2706
3354
|
if (typeof value === 'string' && value.trim().length > 0) {
|
|
2707
3355
|
derivedFm[key] = value;
|
|
2708
3356
|
}
|
|
3357
|
+
else if (key === 'progress' && isPartialProgressIntent(value)) {
|
|
3358
|
+
applyAuthoritativeProgressSubkeys(derivedFm, value, { completedOnlyRaise: true });
|
|
3359
|
+
}
|
|
2709
3360
|
}
|
|
2710
3361
|
}
|
|
2711
3362
|
// #3257: propagate full-line frontmatter comments from the extracted source onto the
|
|
@@ -3282,6 +3933,10 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
3282
3933
|
// (equal), so the #1695 restore fires and would put the stale pre-transition
|
|
3283
3934
|
// name back over the authoritative one. Intent beats both the prose
|
|
3284
3935
|
// re-derivation and the curated restore — the transition just resolved it.
|
|
3936
|
+
// #4129: for the `progress` key the re-assert is a FLOOR, not an override —
|
|
3937
|
+
// the #2969 monotonic property preservation just enforced (completed
|
|
3938
|
+
// counters never move down) must not be undone by the intent, so completed
|
|
3939
|
+
// sub-keys apply only-raise here (see applyAuthoritativeProgressSubkeys).
|
|
3285
3940
|
let authoritativeReasserted = false;
|
|
3286
3941
|
if (authoritativeFm) {
|
|
3287
3942
|
for (const [key, value] of Object.entries(authoritativeFm)) {
|
|
@@ -3289,8 +3944,14 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
3289
3944
|
preservation.postFm[key] = value;
|
|
3290
3945
|
authoritativeReasserted = true;
|
|
3291
3946
|
}
|
|
3947
|
+
else if (key === 'progress' && isPartialProgressIntent(value)) {
|
|
3948
|
+
if (applyAuthoritativeProgressSubkeys(preservation.postFm, value, { completedOnlyRaise: true })) {
|
|
3949
|
+
authoritativeReasserted = true;
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3292
3952
|
}
|
|
3293
3953
|
}
|
|
3954
|
+
let finalContent = syncedContent;
|
|
3294
3955
|
if (preservation.mutated || authoritativeReasserted) {
|
|
3295
3956
|
// #3742: preservation RESTORES frontmatter keys the body-derived rebuild
|
|
3296
3957
|
// could not produce (e.g. `current_phase` on a layout with no body
|
|
@@ -3311,9 +3972,15 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
3311
3972
|
}
|
|
3312
3973
|
const yamlStr = reconstructFrontmatter(preservation.postFm);
|
|
3313
3974
|
const body = stripFrontmatter(syncedContent);
|
|
3314
|
-
|
|
3975
|
+
finalContent = `---\n${yamlStr}\n---\n\n${body}`;
|
|
3976
|
+
}
|
|
3977
|
+
const persistedPercent = (0, state_document_cjs_1.toFiniteNumber)(preservation.postFm['progress'] && preservation.postFm['progress']['percent']);
|
|
3978
|
+
if (persistedPercent !== null) {
|
|
3979
|
+
const reconciled = stateReplaceProgressPercent(finalContent, persistedPercent);
|
|
3980
|
+
if (reconciled !== null)
|
|
3981
|
+
finalContent = reconciled;
|
|
3315
3982
|
}
|
|
3316
|
-
return
|
|
3983
|
+
return finalContent;
|
|
3317
3984
|
}
|
|
3318
3985
|
/**
|
|
3319
3986
|
* ADR-3408 §8.3 — the ONE write-seam composition: `syncStateFrontmatter` then
|
|
@@ -3858,7 +4525,7 @@ function cmdStateJson(cwd, raw) {
|
|
|
3858
4525
|
// reports the phase-directory count while the persisted file preserves the
|
|
3859
4526
|
// stored total, exactly the write/read divergence #3354 closed for its shape.
|
|
3860
4527
|
const storedMilestoneJson = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
|
|
3861
|
-
const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm));
|
|
4528
|
+
const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
|
|
3862
4529
|
// ADR-3408 §8.5 / D3: route stopped_at / paused_at / status / current_phase /
|
|
3863
4530
|
// current_phase_name / current_plan through the SAME `preserve-when-unchanged`
|
|
3864
4531
|
// executor the write path uses (`applyPreserveWhenUnchanged`), instead of a
|
|
@@ -3947,6 +4614,26 @@ function cmdStateJson(cwd, raw) {
|
|
|
3947
4614
|
* Fixes: #1102 (plan counts), #1103 (status/last_activity), #1104 (body text).
|
|
3948
4615
|
*/
|
|
3949
4616
|
function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
4617
|
+
// #4138: `--phase` is this verb's one required argument, and an invocation
|
|
4618
|
+
// that names no phase must fail closed BEFORE any read-modify-write runs —
|
|
4619
|
+
// previously the missing flag flowed through as null and the transition
|
|
4620
|
+
// serialised `String(null)` into the body (`Phase: null — EXECUTING`,
|
|
4621
|
+
// `Status: Executing Phase null`, `last_activity_desc: Phase null execution
|
|
4622
|
+
// started`) while the post-sync frontmatter rebuild dropped current_phase /
|
|
4623
|
+
// current_phase_name entirely, so a single argument-less call un-set the
|
|
4624
|
+
// phase identity. The guard mirrors the sibling usage errors that already
|
|
4625
|
+
// exit non-zero (`state update`'s "field and value required", the router's
|
|
4626
|
+
// "unexpected positional argument" / "Invalid --plans value"), NOT
|
|
4627
|
+
// `cmdStateMilestoneSwitch`'s `output({error})` form, which exits 0 — the
|
|
4628
|
+
// issue's Expected is explicit: "Exit non-zero with a usage message and
|
|
4629
|
+
// write nothing." Empty and whitespace-only values are the same missing
|
|
4630
|
+
// argument (CONTRIBUTING.md CLI matrix); a flag-shaped `--phase --name x`
|
|
4631
|
+
// resolves to null in parseNamedArgs and lands here too. Runs before the
|
|
4632
|
+
// STATE.md existence check so argument validation always precedes I/O, and
|
|
4633
|
+
// before claimMilestonePhase so no phase-"null" milestone claim is taken.
|
|
4634
|
+
if (phaseNumber == null || String(phaseNumber).trim() === '') {
|
|
4635
|
+
error('phase required (--phase <N>)');
|
|
4636
|
+
}
|
|
3950
4637
|
const statePath = planningPaths(cwd).state;
|
|
3951
4638
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
3952
4639
|
output({ error: 'STATE.md not found' }, raw, undefined);
|
|
@@ -3960,7 +4647,11 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
3960
4647
|
// #1230 post-sync preservation, and the no-op write guard.
|
|
3961
4648
|
const intent = {
|
|
3962
4649
|
kind: 'beginPhase',
|
|
3963
|
-
|
|
4650
|
+
// The guard above made this non-null/non-empty; `error` is never-returning
|
|
4651
|
+
// at runtime but this module's destructured io binding does not narrow CFA,
|
|
4652
|
+
// so the narrowed fact is restated once (cmdStateUpdate's `field as string`
|
|
4653
|
+
// idiom, state.cts:782).
|
|
4654
|
+
phaseNumber: phaseNumber,
|
|
3964
4655
|
phaseName: phaseName ?? null,
|
|
3965
4656
|
planCount: planCount ?? null,
|
|
3966
4657
|
};
|
|
@@ -4256,6 +4947,12 @@ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summ
|
|
|
4256
4947
|
* Updates Status to "Ready to execute", Total Plans, Last Activity.
|
|
4257
4948
|
*/
|
|
4258
4949
|
function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
4950
|
+
// #4383: mirror begin-phase's command-boundary guard. A missing phase must
|
|
4951
|
+
// fail before even looking up STATE.md so no invalid invocation can enter
|
|
4952
|
+
// the read-modify-write path and serialize a null/blank phase identity.
|
|
4953
|
+
if (phaseNumber == null || String(phaseNumber).trim() === '') {
|
|
4954
|
+
error('phase required (--phase <N>)');
|
|
4955
|
+
}
|
|
4259
4956
|
const statePath = planningPaths(cwd).state;
|
|
4260
4957
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
4261
4958
|
output({ error: 'STATE.md not found' }, raw, undefined);
|
|
@@ -4271,7 +4968,7 @@ function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
4271
4968
|
// still owns the lock, the #1230 preservation, and the no-op write guard.
|
|
4272
4969
|
const intent = {
|
|
4273
4970
|
kind: 'plannedPhase',
|
|
4274
|
-
phaseNumber,
|
|
4971
|
+
phaseNumber: phaseNumber,
|
|
4275
4972
|
phaseName: phaseName ?? null,
|
|
4276
4973
|
planCount: planCount ?? null,
|
|
4277
4974
|
};
|
|
@@ -4558,10 +5255,28 @@ function cmdStateValidate(cwd, raw, opts = {}) {
|
|
|
4558
5255
|
emit({ valid: false, warnings, scope });
|
|
4559
5256
|
return;
|
|
4560
5257
|
}
|
|
5258
|
+
// #612: #3208 replaced this lookup's `startsWith` prefix test with the
|
|
5259
|
+
// canonical key comparison — which is the right surface, and is exactly why it
|
|
5260
|
+
// now needs the convention. `phaseKeyFromDir` refuses to read a bracket
|
|
5261
|
+
// directory without an explicit signal (a bracket dir is string-
|
|
5262
|
+
// indistinguishable from the legacy letter-prefixed-decimal family, ADR-2121),
|
|
5263
|
+
// so un-threaded it returns the WHOLE dir name as the key —
|
|
5264
|
+
// `GSD.02-05-delta` -> `GSD.02-5-DELTA` — while `selectedPhaseKey` is the bare
|
|
5265
|
+
// `05` that `parsePhaseFromProse` yields. The two sides of one comparison were
|
|
5266
|
+
// derived under different conventions, which is #2562's defect class and the
|
|
5267
|
+
// thing this file's other three `phaseKeyFromDir` call sites already thread
|
|
5268
|
+
// against. Un-threaded, a bracket repo whose phase directory plainly exists
|
|
5269
|
+
// reports `no phase directory matches phase 05` and `valid: false` — a
|
|
5270
|
+
// wrong-and-confident answer on precisely the repos this convention supports.
|
|
5271
|
+
// Resolved here rather than reusing a caller's value because cmdStateValidate
|
|
5272
|
+
// has no other convention-dependent read. Non-bracket conventions (null,
|
|
5273
|
+
// 'milestone-prefixed', unresolvable) are byte-identical to the un-threaded
|
|
5274
|
+
// call by construction: `extractPhaseToken` branches only on `=== 'bracket'`.
|
|
5275
|
+
const validateConvention = resolvePhaseIdConvention(cwd);
|
|
4561
5276
|
let phaseDirPath;
|
|
4562
5277
|
try {
|
|
4563
5278
|
const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
4564
|
-
const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name) === selectedPhaseKey);
|
|
5279
|
+
const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name, validateConvention) === selectedPhaseKey);
|
|
4565
5280
|
if (!phaseDir) {
|
|
4566
5281
|
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
5282
|
emit({ valid: false, warnings, scope });
|
|
@@ -4609,7 +5324,10 @@ function cmdStateValidate(cwd, raw, opts = {}) {
|
|
|
4609
5324
|
// ("verification passed" drift), not a false S007.
|
|
4610
5325
|
const files = node_fs_1.default.readdirSync(phaseDirPath);
|
|
4611
5326
|
const phaseDirBaseName = node_path_1.default.basename(phaseDirPath);
|
|
4612
|
-
|
|
5327
|
+
// #612: `validateConvention` threaded (already resolved above for
|
|
5328
|
+
// `phaseKeyFromDir`) so the S006/S007 scan scopes bracket dirs by
|
|
5329
|
+
// their real token instead of the include-everything fail-safe.
|
|
5330
|
+
const verificationFiles = scopeToPhase(files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md')), phaseDirBaseName, validateConvention);
|
|
4613
5331
|
for (const vf of verificationFiles) {
|
|
4614
5332
|
try {
|
|
4615
5333
|
const vContent = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDirPath, vf), 'utf-8');
|
|
@@ -4730,22 +5448,51 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
4730
5448
|
let syncRoadmapScope = null;
|
|
4731
5449
|
let syncRoadmapRaw = null;
|
|
4732
5450
|
let syncRetiredPhaseNums = new Set();
|
|
5451
|
+
const syncConvention = resolvePhaseIdConvention(cwd);
|
|
4733
5452
|
try {
|
|
4734
5453
|
const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'));
|
|
4735
5454
|
if (roadmapRaw !== null) {
|
|
4736
5455
|
syncRoadmapRaw = roadmapRaw;
|
|
4737
5456
|
syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
|
4738
|
-
syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope);
|
|
5457
|
+
syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope, syncConvention);
|
|
4739
5458
|
}
|
|
4740
5459
|
}
|
|
4741
5460
|
catch { /* fall through: no roadmap scope → no retired exclusion */ }
|
|
5461
|
+
// #2761 Major 1 (round-2 adversarial review): this disk scan fed
|
|
5462
|
+
// totalDiskPlans/totalDiskSummaries/diskCompletedPhases/syncTotalPhases
|
|
5463
|
+
// below UNFILTERED — no milestone-window filter, unlike
|
|
5464
|
+
// buildStateFrontmatter's identical-purpose scan a few hundred lines above
|
|
5465
|
+
// (`:1698`). One command (`state sync`) therefore wrote TWO contradictory
|
|
5466
|
+
// numbers into the same STATE.md: frontmatter total_phases/completed_phases
|
|
5467
|
+
// milestone-scoped correctly (via the READ derivation), body Progress
|
|
5468
|
+
// percent computed from the whole disk. On the ADR-canonical version-less
|
|
5469
|
+
// bracket fixture (4 dirs, 3 complete; asserted milestone = 2 phases, both
|
|
5470
|
+
// complete): body wrote 75% where 100% is true (repro3).
|
|
5471
|
+
//
|
|
5472
|
+
// GATED on `syncConvention === 'bracket'` — an unconditional filter would
|
|
5473
|
+
// ALSO move LEGACY sync percents, since the milestone-scoping-vs-whole-disk
|
|
5474
|
+
// divergence this fixes is engine-wide, not bracket-specific; the gate
|
|
5475
|
+
// keeps legacy byte-identical, which is the binding constraint here. This
|
|
5476
|
+
// is a DEVIATION from an earlier "mirror :1698 unconditionally" phrasing —
|
|
5477
|
+
// deliberate, not an oversight: legacy repos are DOWNSTREAM of a Progress
|
|
5478
|
+
// percent that has read this way for a long time, and moving it as a side
|
|
5479
|
+
// effect of a bracket-only PR is out of this fix's scope.
|
|
5480
|
+
// Upstream #3185 made `listMilestonePhaseDirs` the sole phase-directory
|
|
5481
|
+
// enumeration owner; it delegates window membership to
|
|
5482
|
+
// getMilestonePhaseFilter. Cache that owner's bracket result as a set and
|
|
5483
|
+
// compose it with this scan, rather than restoring the retired direct
|
|
5484
|
+
// parser dependency. Legacy retains this scan's prior pass-all behavior.
|
|
5485
|
+
const syncMilestonePhaseDirs = syncConvention === 'bracket'
|
|
5486
|
+
? new Set(listMilestonePhaseDirs(phasesDir, { cwd, phaseIdConvention: syncConvention }).value)
|
|
5487
|
+
: null;
|
|
4742
5488
|
// Scan all phases
|
|
4743
5489
|
let entries;
|
|
4744
5490
|
try {
|
|
4745
5491
|
entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
|
|
4746
5492
|
.filter(e => e.isDirectory())
|
|
4747
5493
|
.map(e => e.name)
|
|
4748
|
-
.filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name))))
|
|
5494
|
+
.filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name, syncConvention))))
|
|
5495
|
+
.filter(name => syncMilestonePhaseDirs === null || syncMilestonePhaseDirs.has(name))
|
|
4749
5496
|
.sort();
|
|
4750
5497
|
}
|
|
4751
5498
|
catch {
|
|
@@ -4771,7 +5518,10 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
4771
5518
|
// was a second, independent consumer of the same raw field the initial
|
|
4772
5519
|
// migration missed — without it, `state sync` and `state json` disagreed
|
|
4773
5520
|
// on completed_phases for the identical disk state.
|
|
4774
|
-
|
|
5521
|
+
// #612: `syncConvention` threaded — the write-side half of
|
|
5522
|
+
// buildStateFrontmatter's thread above, so `state sync` and `state json`
|
|
5523
|
+
// keep agreeing on completed_phases under the bracket convention.
|
|
5524
|
+
if (isPhaseComplete(dirPath, { convention: syncConvention }).value.complete)
|
|
4775
5525
|
diskCompletedPhases++;
|
|
4776
5526
|
// Track the highest phase with incomplete plans (or any plans)
|
|
4777
5527
|
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
@@ -4793,28 +5543,18 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
4793
5543
|
}
|
|
4794
5544
|
}
|
|
4795
5545
|
// Determine total phases from ROADMAP (may be larger than realized disk dirs).
|
|
4796
|
-
//
|
|
4797
|
-
//
|
|
4798
|
-
//
|
|
4799
|
-
//
|
|
5546
|
+
// #612 round-4: shares countRoadmapPhaseHeadings with buildStateFrontmatter
|
|
5547
|
+
// (defined just above extractRetiredPhaseNumbers) so both report
|
|
5548
|
+
// consistent totals off the SAME implementation, not two independently
|
|
5549
|
+
// maintained copies (#3242 Bug B).
|
|
5550
|
+
// #612 round-5: bracket sync enables the same bare-token 999 exclusion as
|
|
5551
|
+
// the read path and getMilestonePhaseFilter, preventing frontmatter/body
|
|
5552
|
+
// disagreement. Non-bracket conventions still pass false, preserving the
|
|
5553
|
+
// pre-existing legacy sync behavior while #3185 remains the read-path owner.
|
|
4800
5554
|
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
|
-
}
|
|
5555
|
+
const roadmapPhaseCount = syncRoadmapScope !== null
|
|
5556
|
+
? countRoadmapPhaseHeadings(syncRoadmapScope, syncConvention, syncRetiredPhaseNums, syncConvention === 'bracket')
|
|
5557
|
+
: 0;
|
|
4818
5558
|
if (roadmapPhaseCount > 0) {
|
|
4819
5559
|
syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
|
|
4820
5560
|
}
|
|
@@ -4834,8 +5574,9 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
4834
5574
|
if (versionStr !== null && syncRoadmapRaw !== null) {
|
|
4835
5575
|
// #3184: routed through the single owner (roadmap-parser.cjs) instead of
|
|
4836
5576
|
// a hand-rolled, unbounded-substring re-derivation — see the identical
|
|
4837
|
-
// fix in buildStateFrontmatter above.
|
|
4838
|
-
|
|
5577
|
+
// fix in buildStateFrontmatter above. #612 composes its gated bracket
|
|
5578
|
+
// extension on top inside isMilestoneBounded.
|
|
5579
|
+
milestoneBounded = isMilestoneBounded(syncRoadmapRaw, versionStr, syncConvention);
|
|
4839
5580
|
}
|
|
4840
5581
|
let percent = null;
|
|
4841
5582
|
if (!milestoneBounded) {
|
|
@@ -4853,6 +5594,20 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
4853
5594
|
// it here (discarding `.value`, which duplicates `entries`'s own
|
|
4854
5595
|
// retired-phase-filtered listing) gets the real scope without changing
|
|
4855
5596
|
// the disk-scan totals computed above.
|
|
5597
|
+
//
|
|
5598
|
+
// #2761 (round-11 M2 follow-up): deliberately NOT threading
|
|
5599
|
+
// `phaseIdConvention` here, unlike the other call sites this same PR
|
|
5600
|
+
// converts. Only `.scope` is consumed (the `.value` directory list is
|
|
5601
|
+
// thrown away), and inside `getMilestonePhaseFilter` `scope` is computed
|
|
5602
|
+
// from `extractCurrentMilestoneScoped`/`classifyMilestoneWindow` BEFORE
|
|
5603
|
+
// `headingConvention` is resolved — `phaseIdConvention` only reaches the
|
|
5604
|
+
// heading/dir MEMBERSHIP scan (`scanMilestonePhaseIds`, `isDirInMilestone`)
|
|
5605
|
+
// that produces `.value`, never the scope discriminator itself. So the
|
|
5606
|
+
// `undefined` default here (lazy resolve-from-config) and an explicitly
|
|
5607
|
+
// threaded `syncConvention` would compute the identical `scope` either
|
|
5608
|
+
// way — there is no silent-inherit exposure to close at this site, only
|
|
5609
|
+
// at sites (milestone.cts, cmdStateUpdateProgress above) that also
|
|
5610
|
+
// consume `.value`.
|
|
4856
5611
|
const syncScope = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: versionStr }).scope;
|
|
4857
5612
|
if (syncScope !== SCOPE.COMPLETE) {
|
|
4858
5613
|
changes.push(`Progress: skipped — milestone phase scope is "${syncScope}", not COMPLETE (#3217)`);
|