@opengsd/gsd-core 1.18.0 → 1.20.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 +2 -1
- package/.opencode/plugins/gsd-core.js +2 -3
- package/agents/gsd-code-fixer.compact.md +7 -7
- package/agents/gsd-code-fixer.md +13 -7
- package/agents/gsd-code-reviewer.compact.md +49 -8
- package/agents/gsd-code-reviewer.md +53 -14
- package/agents/gsd-executor.md +8 -4
- package/agents/gsd-verifier.md +1 -1
- package/bin/install.js +422 -103
- package/commands/gsd/update.md +5 -2
- package/gsd-core/bin/gsd-tools.cjs +179 -30
- package/gsd-core/bin/lib/agent-install-check.cjs +29 -3
- package/gsd-core/bin/lib/args-terminator.cjs +114 -0
- package/gsd-core/bin/lib/broken-windows.cjs +59 -1
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
- package/gsd-core/bin/lib/capability-lock.cjs +13 -2
- package/gsd-core/bin/lib/capability-registry.cjs +376 -103
- package/gsd-core/bin/lib/capability-state.cjs +14 -8
- package/gsd-core/bin/lib/capability-validator.cjs +32 -0
- package/gsd-core/bin/lib/commands.cjs +27 -17
- package/gsd-core/bin/lib/config-loader.cjs +13 -3
- package/gsd-core/bin/lib/config.cjs +256 -58
- package/gsd-core/bin/lib/core-utils.cjs +45 -0
- package/gsd-core/bin/lib/decision-coverage-support.cjs +74 -0
- package/gsd-core/bin/lib/frontmatter.cjs +21 -3
- package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
- package/gsd-core/bin/lib/gate-context-drift.cjs +9 -4
- package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +6 -1
- package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +6 -1
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +134 -13
- package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
- package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
- package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +63 -22
- package/gsd-core/bin/lib/gate-ui-safety.cjs +23 -5
- package/gsd-core/bin/lib/git-info-exclude.cjs +52 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +31 -12
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +4 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +52 -1
- package/gsd-core/bin/lib/host-integration.cjs +13 -6
- package/gsd-core/bin/lib/init.cjs +245 -68
- package/gsd-core/bin/lib/install-engine.cjs +246 -32
- package/gsd-core/bin/lib/install-profiles.cjs +9 -4
- package/gsd-core/bin/lib/installer-migration-report.cjs +2 -0
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-line-diff.cjs +147 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +147 -1
- package/gsd-core/bin/lib/markdown-table.cjs +23 -0
- package/gsd-core/bin/lib/mcp-server.cjs +8 -5
- package/gsd-core/bin/lib/milestone-lock.cjs +8 -1
- package/gsd-core/bin/lib/milestone-release-map.cjs +244 -0
- package/gsd-core/bin/lib/milestone-release-tags.cjs +440 -0
- package/gsd-core/bin/lib/milestone.cjs +61 -51
- package/gsd-core/bin/lib/model-catalog.cjs +8 -0
- package/gsd-core/bin/lib/normalize-test-command.cjs +67 -0
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
- package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
- package/gsd-core/bin/lib/phase-id.cjs +629 -38
- package/gsd-core/bin/lib/phase-lifecycle.cjs +12 -44
- package/gsd-core/bin/lib/phase-locator.cjs +328 -19
- package/gsd-core/bin/lib/phase.cjs +3066 -653
- package/gsd-core/bin/lib/planning-document.cjs +26 -5
- package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
- package/gsd-core/bin/lib/planning-snapshot.cjs +15 -8
- package/gsd-core/bin/lib/planning-workspace.cjs +21 -3
- package/gsd-core/bin/lib/pristine-baseline.cjs +11 -3
- package/gsd-core/bin/lib/read-only-git-env.cjs +79 -0
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +57 -2
- package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
- package/gsd-core/bin/lib/review-lane-runner.cjs +33 -4
- package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +735 -137
- package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -7
- package/gsd-core/bin/lib/roadmap.cjs +383 -123
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +381 -14
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +8 -4
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +60 -16
- package/gsd-core/bin/lib/shell-command-projection.cjs +151 -19
- package/gsd-core/bin/lib/smart-entry.cjs +35 -11
- package/gsd-core/bin/lib/state-contract.cjs +39 -15
- package/gsd-core/bin/lib/state-document.cjs +344 -27
- package/gsd-core/bin/lib/state-transition.cjs +156 -32
- package/gsd-core/bin/lib/state.cjs +527 -186
- package/gsd-core/bin/lib/surface.cjs +9 -0
- package/gsd-core/bin/lib/task-command-router.cjs +5 -25
- package/gsd-core/bin/lib/task-content-resolution.cjs +46 -1
- package/gsd-core/bin/lib/template.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +3 -2
- package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
- package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
- package/gsd-core/bin/lib/verification.cjs +96 -9
- package/gsd-core/bin/lib/verify.cjs +4 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +26 -20
- package/gsd-core/bin/lib/worktree-safety.cjs +22 -1
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
- package/gsd-core/references/cicd-review-patterns.md +45 -0
- package/gsd-core/references/git-integration.md +11 -0
- package/gsd-core/references/loop-hook-dispatch.md +13 -6
- package/gsd-core/references/necessity-review.md +48 -0
- package/gsd-core/references/planning-config.md +2 -0
- package/gsd-core/references/tdd.md +2 -2
- package/gsd-core/templates/DEBUG.md +16 -0
- package/gsd-core/templates/config.json +2 -1
- package/gsd-core/templates/verification-report.md +1 -1
- package/gsd-core/workflows/audit-fix.md +2 -1
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +10 -1
- package/gsd-core/workflows/code-review-fix.md +46 -5
- package/gsd-core/workflows/code-review.md +64 -7
- package/gsd-core/workflows/complete-milestone/steps/project-md-evolution-review.md +145 -0
- package/gsd-core/workflows/complete-milestone/steps/release-map.md +80 -0
- package/gsd-core/workflows/complete-milestone.md +37 -150
- package/gsd-core/workflows/diagnose-issues.md +29 -5
- package/gsd-core/workflows/discuss-phase/modes/power.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +33 -2
- package/gsd-core/workflows/discuss-phase-power.md +19 -2
- package/gsd-core/workflows/discuss-phase.md +6 -10
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +13 -7
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +7 -2
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -1
- package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +11 -2
- package/gsd-core/workflows/execute-phase.md +10 -6
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/health.md +2 -1
- package/gsd-core/workflows/import.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +2 -2
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
- package/gsd-core/workflows/plan-phase.md +14 -14
- package/gsd-core/workflows/pr-branch.md +2 -0
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -1
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +8 -1
- package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +2 -1
- package/gsd-core/workflows/quick.md +1 -1
- package/gsd-core/workflows/reapply-patches.md +104 -0
- package/gsd-core/workflows/review.md +70 -154
- package/gsd-core/workflows/section-manifest.json +5 -0
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +57 -9
- package/gsd-core/workflows/settings.md +1 -0
- package/gsd-core/workflows/ship.md +15 -4
- package/gsd-core/workflows/spec-phase.md +3 -6
- package/gsd-core/workflows/verify-work.md +4 -3
- package/hooks/dist/gsd-agent-isolation-guard.js +11 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
- package/hooks/dist/gsd-graphify-update.sh +6 -0
- package/hooks/dist/gsd-path-export.js +95 -0
- package/hooks/dist/gsd-secret-read-guard.js +172 -39
- package/hooks/dist/gsd-statusline.js +37 -3
- package/hooks/dist/gsd-windsurf-pre-write.js +29 -2
- package/hooks/dist/gsd-workflow-guard.js +3 -2
- package/hooks/dist/gsd-worktree-path-guard.js +29 -2
- package/hooks/dist/lib/git-probe.js +16 -0
- package/hooks/dist/lib/isolation-sentinel.js +44 -0
- package/hooks/dist/managed-hooks-registry.cjs +1 -0
- package/hooks/gsd-agent-isolation-guard.js +11 -1
- package/hooks/gsd-cursor-subagent-start.js +6 -2
- package/hooks/gsd-graphify-update.sh +6 -0
- package/hooks/gsd-path-export.js +95 -0
- package/hooks/gsd-secret-read-guard.js +172 -39
- package/hooks/gsd-statusline.js +37 -3
- package/hooks/gsd-windsurf-pre-write.js +29 -2
- package/hooks/gsd-workflow-guard.js +3 -2
- package/hooks/gsd-worktree-path-guard.js +29 -2
- package/hooks/hooks.json +1 -0
- package/hooks/lib/git-probe.js +16 -0
- package/hooks/lib/isolation-sentinel.js +44 -0
- package/hooks/managed-hooks-registry.cjs +1 -0
- package/package.json +15 -8
- package/pi/gsd.cjs +1047 -10
- package/pi/package-agents/gsd-advisor-researcher.md +113 -0
- package/pi/package-agents/gsd-ai-researcher.md +111 -0
- package/pi/package-agents/gsd-assumptions-analyzer.md +110 -0
- package/pi/package-agents/gsd-code-fixer.md +774 -0
- package/pi/package-agents/gsd-code-reviewer.md +441 -0
- package/pi/package-agents/gsd-codebase-mapper.md +850 -0
- package/pi/package-agents/gsd-debug-session-manager.md +416 -0
- package/pi/package-agents/gsd-debugger.md +1274 -0
- package/pi/package-agents/gsd-doc-classifier.md +269 -0
- package/pi/package-agents/gsd-doc-synthesizer.md +259 -0
- package/pi/package-agents/gsd-doc-verifier.md +212 -0
- package/pi/package-agents/gsd-doc-writer.md +626 -0
- package/pi/package-agents/gsd-dom-verifier.md +164 -0
- package/pi/package-agents/gsd-domain-researcher.md +144 -0
- package/pi/package-agents/gsd-eval-auditor.md +185 -0
- package/pi/package-agents/gsd-eval-planner.md +149 -0
- package/pi/package-agents/gsd-executor.md +898 -0
- package/pi/package-agents/gsd-framework-selector.md +159 -0
- package/pi/package-agents/gsd-integration-checker.md +476 -0
- package/pi/package-agents/gsd-intel-updater.md +338 -0
- package/pi/package-agents/gsd-mempalace-curator.md +49 -0
- package/pi/package-agents/gsd-nyquist-auditor.md +200 -0
- package/pi/package-agents/gsd-pattern-mapper.md +340 -0
- package/pi/package-agents/gsd-phase-researcher.md +897 -0
- package/pi/package-agents/gsd-plan-checker.md +1077 -0
- package/pi/package-agents/gsd-planner.md +1003 -0
- package/pi/package-agents/gsd-project-researcher.md +611 -0
- package/pi/package-agents/gsd-research-synthesizer.md +259 -0
- package/pi/package-agents/gsd-roadmapper.md +779 -0
- package/pi/package-agents/gsd-security-auditor.md +171 -0
- package/pi/package-agents/gsd-ui-auditor.md +581 -0
- package/pi/package-agents/gsd-ui-checker.md +417 -0
- package/pi/package-agents/gsd-ui-researcher.md +469 -0
- package/pi/package-agents/gsd-user-profiler.md +174 -0
- package/pi/package-agents/gsd-verifier.md +963 -0
- package/pi/package-agents/manifest.json +39 -0
- package/pi/package-skills/gsd-add-tests/SKILL.md +39 -0
- package/pi/package-skills/gsd-ai-integration-phase/SKILL.md +36 -0
- package/pi/package-skills/gsd-audit-fix/SKILL.md +34 -0
- package/pi/package-skills/gsd-audit-milestone/SKILL.md +40 -0
- package/pi/package-skills/gsd-audit-uat/SKILL.md +26 -0
- package/pi/package-skills/gsd-autonomous/SKILL.md +52 -0
- package/pi/package-skills/gsd-capture/SKILL.md +68 -0
- package/pi/package-skills/gsd-cleanup/SKILL.md +25 -0
- package/pi/package-skills/gsd-code-review/SKILL.md +71 -0
- package/pi/package-skills/gsd-complete-milestone/SKILL.md +147 -0
- package/pi/package-skills/gsd-config/SKILL.md +60 -0
- package/pi/package-skills/gsd-debug/SKILL.md +64 -0
- package/pi/package-skills/gsd-discuss-phase/SKILL.md +74 -0
- package/pi/package-skills/gsd-docs-update/SKILL.md +50 -0
- package/pi/package-skills/gsd-eval-review/SKILL.md +35 -0
- package/pi/package-skills/gsd-execute-phase/SKILL.md +62 -0
- package/pi/package-skills/gsd-explore/SKILL.md +26 -0
- package/pi/package-skills/gsd-extract-learnings/SKILL.md +25 -0
- package/pi/package-skills/gsd-fast/SKILL.md +33 -0
- package/pi/package-skills/gsd-forensics/SKILL.md +59 -0
- package/pi/package-skills/gsd-graphify/SKILL.md +210 -0
- package/pi/package-skills/gsd-health/SKILL.md +36 -0
- package/pi/package-skills/gsd-help/SKILL.md +37 -0
- package/pi/package-skills/gsd-import/SKILL.md +46 -0
- package/pi/package-skills/gsd-inbox/SKILL.md +42 -0
- package/pi/package-skills/gsd-ingest-docs/SKILL.md +43 -0
- package/pi/package-skills/gsd-manager/SKILL.md +46 -0
- package/pi/package-skills/gsd-map-codebase/SKILL.md +85 -0
- package/pi/package-skills/gsd-mempalace-capture/SKILL.md +111 -0
- package/pi/package-skills/gsd-mempalace-recall/SKILL.md +111 -0
- package/pi/package-skills/gsd-milestone-summary/SKILL.md +54 -0
- package/pi/package-skills/gsd-mvp-phase/SKILL.md +45 -0
- package/pi/package-skills/gsd-new-milestone/SKILL.md +49 -0
- package/pi/package-skills/gsd-new-project/SKILL.md +49 -0
- package/pi/package-skills/gsd-next/SKILL.md +34 -0
- package/pi/package-skills/gsd-ns-context/SKILL.md +31 -0
- package/pi/package-skills/gsd-ns-ideate/SKILL.md +30 -0
- package/pi/package-skills/gsd-ns-manage/SKILL.md +42 -0
- package/pi/package-skills/gsd-ns-project/SKILL.md +34 -0
- package/pi/package-skills/gsd-ns-review/SKILL.md +35 -0
- package/pi/package-skills/gsd-ns-workflow/SKILL.md +44 -0
- package/pi/package-skills/gsd-onboard/SKILL.md +46 -0
- package/pi/package-skills/gsd-pause-work/SKILL.md +48 -0
- package/pi/package-skills/gsd-phase/SKILL.md +61 -0
- package/pi/package-skills/gsd-plan-phase/SKILL.md +62 -0
- package/pi/package-skills/gsd-plan-review-convergence/SKILL.md +63 -0
- package/pi/package-skills/gsd-pr-branch/SKILL.md +31 -0
- package/pi/package-skills/gsd-profile-user/SKILL.md +48 -0
- package/pi/package-skills/gsd-progress/SKILL.md +51 -0
- package/pi/package-skills/gsd-quick/SKILL.md +179 -0
- package/pi/package-skills/gsd-quick-batch/SKILL.md +114 -0
- package/pi/package-skills/gsd-resume-work/SKILL.md +31 -0
- package/pi/package-skills/gsd-review/SKILL.md +50 -0
- package/pi/package-skills/gsd-review-backlog/SKILL.md +65 -0
- package/pi/package-skills/gsd-secure-phase/SKILL.md +37 -0
- package/pi/package-skills/gsd-settings/SKILL.md +30 -0
- package/pi/package-skills/gsd-ship/SKILL.md +27 -0
- package/pi/package-skills/gsd-sketch/SKILL.md +56 -0
- package/pi/package-skills/gsd-spec-phase/SKILL.md +63 -0
- package/pi/package-skills/gsd-spike/SKILL.md +53 -0
- package/pi/package-skills/gsd-stats/SKILL.md +22 -0
- package/pi/package-skills/gsd-surface/SKILL.md +177 -0
- package/pi/package-skills/gsd-thread/SKILL.md +29 -0
- package/pi/package-skills/gsd-ui-phase/SKILL.md +35 -0
- package/pi/package-skills/gsd-ui-review/SKILL.md +35 -0
- package/pi/package-skills/gsd-ultraplan-phase/SKILL.md +38 -0
- package/pi/package-skills/gsd-undo/SKILL.md +38 -0
- package/pi/package-skills/gsd-update/SKILL.md +54 -0
- package/pi/package-skills/gsd-validate-phase/SKILL.md +37 -0
- package/pi/package-skills/gsd-verify-work/SKILL.md +41 -0
- package/pi/package-skills/gsd-workspace/SKILL.md +57 -0
- package/pi/package-skills/gsd-workstreams/SKILL.md +73 -0
- package/scripts/build-hooks.js +4 -0
- package/scripts/ci-next-health.cjs +246 -23
- package/scripts/command-contract-helpers.cjs +1 -1
- package/scripts/docs-guard-registry.cjs +22 -7
- package/scripts/gen-features.cjs +14 -2
- package/scripts/gen-health-docs.cjs +13 -1
- package/scripts/gen-pi-package-skills.cjs +217 -0
- package/scripts/lib/drift-scan.cjs +27 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +17 -1
- package/scripts/lib/platform-conformance-tier.generated.cjs +37 -1
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +3 -3
- package/scripts/lint-phase-enumeration-drift.cjs +8 -4
- package/scripts/lint-retired-runtime-name.cjs +5 -0
- package/scripts/lint-roadmap-progress-drift.cjs +421 -0
- package/scripts/mutation-matrix.cjs +23 -1
- package/scripts/npm-audit-baseline.cjs +41 -7
- package/scripts/reviewed-or-maintainer-policy.cjs +181 -0
- package/scripts/select-docs-guards.cjs +1 -1
- package/scripts/self-test-report-policy.cjs +350 -0
- package/scripts/sync-next-version.cjs +38 -6
- package/skills/gsd-update/SKILL.md +5 -2
- package/vscode/package.json +1 -1
- package/scripts/diagnose-validate-commit-in-tier.cjs +0 -270
- package/scripts/diagnose-validate-commit-phases.cjs +0 -348
- package/scripts/xtrace-phase-timing.cjs +0 -205
|
@@ -29,10 +29,15 @@ const configLoaderMod = require("./config-loader.cjs");
|
|
|
29
29
|
const { loadConfig } = configLoaderMod;
|
|
30
30
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
31
31
|
const phaseIdMod = require("./phase-id.cjs");
|
|
32
|
-
const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE,
|
|
32
|
+
const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase,
|
|
33
33
|
// #2761 M3: owns the bracket milestone intro and canonical pad2 spelling.
|
|
34
34
|
bracketMilestoneIntroSrcFor, } = phaseIdMod;
|
|
35
35
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
36
|
+
const phaseIdDisplayMod = require("./phase-id-display.cjs");
|
|
37
|
+
// #4304: phaseDisplayFor below renders through this adapter
|
|
38
|
+
// instead of re-deriving the bracket numeric grammar locally.
|
|
39
|
+
const { renderBracketPhaseDisplay } = phaseIdDisplayMod;
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
36
41
|
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
37
42
|
// #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.
|
|
38
43
|
const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasAnyMilestoneSection, classifyMilestoneScalar } = roadmapParserMod;
|
|
@@ -55,6 +60,29 @@ const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, propagateC
|
|
|
55
60
|
function isUnparseableFrontmatter(existingFm) {
|
|
56
61
|
return existingFm[FRONTMATTER_UNPARSEABLE] === true;
|
|
57
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Human-facing phase label for STATE.md descriptions. Identity-bearing fields
|
|
65
|
+
* such as Current Phase and the Current Position Phase row stay bare so their
|
|
66
|
+
* established readers remain byte-compatible; only descriptive text uses this
|
|
67
|
+
* renderer.
|
|
68
|
+
*/
|
|
69
|
+
function phaseDisplayFor(cwd, phaseNumber) {
|
|
70
|
+
if (resolvePhaseIdConvention(cwd) !== 'bracket')
|
|
71
|
+
return `Phase ${phaseNumber}`;
|
|
72
|
+
const config = loadConfig(cwd);
|
|
73
|
+
const project = typeof config['project_code'] === 'string' ? config['project_code'].trim() : '';
|
|
74
|
+
if (!project) {
|
|
75
|
+
error('phase_id_convention is "bracket" but project_code is missing in .planning/config.json');
|
|
76
|
+
}
|
|
77
|
+
const info = getMilestoneInfo(cwd);
|
|
78
|
+
const version = info.value?.version ?? '';
|
|
79
|
+
const display = renderBracketPhaseDisplay(version, phaseNumber, project);
|
|
80
|
+
if (display === null) {
|
|
81
|
+
error(`phase_id_convention is "bracket" but phase ${phaseNumber} cannot be rendered ` +
|
|
82
|
+
`(the active milestone ${JSON.stringify(version)} cannot be resolved, or the phase number is invalid)`);
|
|
83
|
+
}
|
|
84
|
+
return display;
|
|
85
|
+
}
|
|
58
86
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
59
87
|
const scanPhasePlans = require("./plan-scan.cjs");
|
|
60
88
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -76,7 +104,7 @@ const planningScopeMod = require("./planning-scope.cjs");
|
|
|
76
104
|
const { SCOPE } = planningScopeMod;
|
|
77
105
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
78
106
|
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
79
|
-
const { listMilestonePhaseDirs } = phaseLocatorMod;
|
|
107
|
+
const { phaseDirectoryOptions, listMilestonePhaseDirs, resolvePhaseDirectoryLookup, matchPhaseDirsForLookup } = phaseLocatorMod;
|
|
80
108
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
81
109
|
const stateTransitionMod = require("./state-transition.cjs");
|
|
82
110
|
// #3873 (ADR-3473 §8.8): FRONTMATTER_KEY_TO_BODY_LABEL below is now a
|
|
@@ -92,6 +120,46 @@ const project_root_cjs_1 = require("./project-root.cjs");
|
|
|
92
120
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
93
121
|
const milestoneLockMod = require("./milestone-lock.cjs");
|
|
94
122
|
const { transitionCore, applyStatePreservation, sliceCurrentPositionSection, stateReplaceProgressPercent, formatProgressMachineSegment } = stateTransitionMod;
|
|
123
|
+
function replaceStateBodyField(content, statePath, field, value) {
|
|
124
|
+
const { body, reassemble } = stateTransitionMod.beginFrontmatterReassembly(content, statePath);
|
|
125
|
+
const replaced = (0, state_document_cjs_1.stateReplaceField)(body, field, value);
|
|
126
|
+
return replaced === null ? content : reassemble(replaced);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Rewrite a handler-authored field inside Current Position only when it still
|
|
130
|
+
* carries the exact value the transition just emitted. The equality guard
|
|
131
|
+
* preserves executor-authored prose under the transition module's Knuth rule.
|
|
132
|
+
*/
|
|
133
|
+
function replaceCurrentPositionFieldValue(content, statePath, field, expected, value) {
|
|
134
|
+
const { body, reassemble } = stateTransitionMod.beginFrontmatterReassembly(content, statePath);
|
|
135
|
+
const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body);
|
|
136
|
+
const index = headings.findIndex((heading) => heading.level === 2 && /^current\s+position$/i.test(heading.text));
|
|
137
|
+
if (index === -1)
|
|
138
|
+
return content;
|
|
139
|
+
const heading = headings[index];
|
|
140
|
+
const lines = body.split('\n');
|
|
141
|
+
const headingLine = lines[heading.line - 1] ?? '';
|
|
142
|
+
const start = heading.offset + headingLine.length + 1;
|
|
143
|
+
let end = body.length;
|
|
144
|
+
for (let next = index + 1; next < headings.length; next++) {
|
|
145
|
+
if (headings[next].level < 2)
|
|
146
|
+
continue;
|
|
147
|
+
end = headings[next].offset;
|
|
148
|
+
if (end > 0 && body[end - 1] === '\n')
|
|
149
|
+
end -= 1;
|
|
150
|
+
if (end > 0 && body[end - 1] === '\r')
|
|
151
|
+
end -= 1;
|
|
152
|
+
end = Math.max(start, end);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
const section = body.slice(start, end);
|
|
156
|
+
if ((0, state_document_cjs_1.stateExtractField)(section, field) !== expected)
|
|
157
|
+
return content;
|
|
158
|
+
const replaced = (0, state_document_cjs_1.stateReplaceField)(section, field, value);
|
|
159
|
+
if (replaced === null)
|
|
160
|
+
return content;
|
|
161
|
+
return reassemble(body.slice(0, start) + replaced + body.slice(end));
|
|
162
|
+
}
|
|
95
163
|
// #3699: the frontmatter-key <-> body-field routing behind `state update`'s
|
|
96
164
|
// failure explanation, and the classification table it falls back to.
|
|
97
165
|
const { getFieldClassification, getFrontmatterBodySource, frontmatterKeyForBodyField } = stateTransitionMod;
|
|
@@ -374,6 +442,11 @@ function cmdStatePatch(cwd, patches, raw) {
|
|
|
374
442
|
if (!fieldCheck.valid) {
|
|
375
443
|
error(`state patch: ${fieldCheck.error}`);
|
|
376
444
|
}
|
|
445
|
+
// #4998: as for `state update` — refused up front rather than listed
|
|
446
|
+
// under `failed` with no reason.
|
|
447
|
+
if (state_document_cjs_1.FIELD_LINE_BREAK_RE.test(`${patches[field]}`)) {
|
|
448
|
+
error(`state patch: the value for "${field}" must be a single line (no CR, LF, U+2028 or U+2029)`);
|
|
449
|
+
}
|
|
377
450
|
}
|
|
378
451
|
const statePath = planningPaths(cwd).state;
|
|
379
452
|
try {
|
|
@@ -408,7 +481,20 @@ function cmdStatePatch(cwd, patches, raw) {
|
|
|
408
481
|
// `applyStatePreservation` restored that this patch never named at all
|
|
409
482
|
// (#3345's direction), a case the pre-#3471 version of this command never
|
|
410
483
|
// covered.
|
|
411
|
-
const
|
|
484
|
+
const reconciled = reconcileReportedFields(statePath, preWriteState, precomputed.updated, divergedFields, NO_SECTION_TARGETS, true);
|
|
485
|
+
// #4488, as in cmdStateUpdate: a field patchCore matched that already held
|
|
486
|
+
// the requested value changes no byte, so the reconcile above (which asks
|
|
487
|
+
// "what changed") drops it — alone (no snapshot at all) or beside a field
|
|
488
|
+
// that did change. It is updated when the persisted value is the requested
|
|
489
|
+
// one; a value the write pipeline discarded still differs and stays failed
|
|
490
|
+
// (trek-e review 5593733001, Minor 3).
|
|
491
|
+
const persisted = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
492
|
+
const persistedFm = extractFrontmatter(persisted, statePath);
|
|
493
|
+
const persistedBody = stripFrontmatter(persisted);
|
|
494
|
+
const alreadyHeld = precomputed.updated.filter((field) => !reconciled.includes(field)
|
|
495
|
+
&& !STATE_UPDATED_PROVENANCE_EXCLUSION.includes(field)
|
|
496
|
+
&& readStateTarget(persistedFm, persistedBody, field, NO_SECTION_TARGETS, true)?.trim() === String(patches[field]).trim());
|
|
497
|
+
const updated = [...reconciled, ...alreadyHeld];
|
|
412
498
|
const updatedSet = new Set(updated);
|
|
413
499
|
const failed = Object.keys(patches).filter((field) => !updatedSet.has(field));
|
|
414
500
|
const results = { updated, failed };
|
|
@@ -476,6 +562,11 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
476
562
|
if (!fieldCheck.valid) {
|
|
477
563
|
error(`state update: ${fieldCheck.error}`);
|
|
478
564
|
}
|
|
565
|
+
// #4998: the field writers are single-line and refuse a line break, which
|
|
566
|
+
// the transition would otherwise report as "field not found".
|
|
567
|
+
if (state_document_cjs_1.FIELD_LINE_BREAK_RE.test(value)) {
|
|
568
|
+
error(`state update: the value for "${field}" must be a single line (no CR, LF, U+2028 or U+2029)`);
|
|
569
|
+
}
|
|
479
570
|
const statePath = planningPaths(cwd).state;
|
|
480
571
|
try {
|
|
481
572
|
let updated = false;
|
|
@@ -527,7 +618,7 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
527
618
|
// restored during this write that this command never touched at all
|
|
528
619
|
// (#3345's direction) — reported separately from `updated` because this
|
|
529
620
|
// command's contract is a single-field boolean, not a per-field array.
|
|
530
|
-
const reconciled = reconcileReportedFields(statePath, preWriteState, updated ? [field] : [], divergedFields);
|
|
621
|
+
const reconciled = reconcileReportedFields(statePath, preWriteState, updated ? [field] : [], divergedFields, NO_SECTION_TARGETS, true);
|
|
531
622
|
// #4488: `updateCore` itself already told us whether it matched the field
|
|
532
623
|
// (`updated`, captured above `readModifyWriteStateMd` runs it) — that is a
|
|
533
624
|
// real signal, not a guess. `reconcileReportedFields` answers a DIFFERENT
|
|
@@ -711,17 +802,19 @@ function unsummarizedPlansForPositionPhase(cwd, positionPhase) {
|
|
|
711
802
|
// as a real answer. This is a lookup of ONE phase token STATE.md names, not
|
|
712
803
|
// a milestone enumeration, so the unscoped retry is in-contract.
|
|
713
804
|
const convention = resolvePhaseIdConvention(cwd);
|
|
805
|
+
const lookup = resolvePhaseDirectoryLookup(cwd, positionPhase);
|
|
714
806
|
const windowed = listMilestonePhaseDirs(phasesDir, { cwd, phaseIdConvention: convention });
|
|
715
807
|
const candidateDirs = windowed.scope === SCOPE.COMPLETE ? windowed.value : [];
|
|
716
808
|
// Canonical phase-token → directory matching (phase-id owner, #2562): both
|
|
717
809
|
// sides of the comparison derived by the same function, never a local regex.
|
|
718
|
-
const
|
|
810
|
+
const directoryContext = phaseDirectoryOptions(cwd);
|
|
811
|
+
const { matches } = matchPhaseDirsForLookup(candidateDirs, lookup, undefined, directoryContext);
|
|
719
812
|
if (matches.length > 0)
|
|
720
813
|
return scanOutstanding(phasesDir, matches[0]);
|
|
721
814
|
const unscoped = listMilestonePhaseDirs(phasesDir);
|
|
722
815
|
if (unscoped.scope !== SCOPE.COMPLETE)
|
|
723
816
|
return null;
|
|
724
|
-
const retry =
|
|
817
|
+
const retry = matchPhaseDirsForLookup(unscoped.value, lookup, undefined, directoryContext);
|
|
725
818
|
if (retry.matches.length === 0)
|
|
726
819
|
return null;
|
|
727
820
|
return scanOutstanding(phasesDir, retry.matches[0]);
|
|
@@ -770,7 +863,7 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
770
863
|
// silently absorbed.
|
|
771
864
|
const body = stripFrontmatter(content);
|
|
772
865
|
const positionScope = matchCurrentPositionSection(body) ?? body;
|
|
773
|
-
const positionPhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
|
|
866
|
+
const positionPhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase'), resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd)).phase;
|
|
774
867
|
// #4093: a Current Position section with ZERO labeled fields has no
|
|
775
868
|
// `Phase:` line either; frontmatter `current_phase` is the documented
|
|
776
869
|
// survivor of body drift (the reporter's document still carried it, and
|
|
@@ -1283,7 +1376,7 @@ function cmdStateAddDecision(cwd, options, raw) {
|
|
|
1283
1376
|
if (!phaseId) {
|
|
1284
1377
|
const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
1285
1378
|
const fm = extractFrontmatter(rawState, statePath);
|
|
1286
|
-
phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState)) ?? undefined;
|
|
1379
|
+
phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState), resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd)) ?? undefined;
|
|
1287
1380
|
}
|
|
1288
1381
|
const entry = `- [Phase ${phaseId || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
|
|
1289
1382
|
let _added = false;
|
|
@@ -1412,8 +1505,14 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
|
|
|
1412
1505
|
// Flatten line breaks so the entry is always a single Markdown bullet. The
|
|
1413
1506
|
// dedupe + rendering contract is line-oriented; a multiline --note-file would
|
|
1414
1507
|
// otherwise spill continuation lines outside the bullet and defeat dedupe.
|
|
1415
|
-
// Internal spacing (e.g. dollar columns) is preserved.
|
|
1416
|
-
|
|
1508
|
+
// Internal spacing (e.g. dollar columns) is preserved. #4998: every
|
|
1509
|
+
// FIELD_LINE_BREAK_RE character, so a U+2028/U+2029 cannot survive into the
|
|
1510
|
+
// bullet and start a forged `**Field:**` line for the `m`-flag readers.
|
|
1511
|
+
// Split and trimmed rather than one `\s*<break>+\s*` replace, which
|
|
1512
|
+
// backtracks quadratically over a long whitespace run with no break in it
|
|
1513
|
+
// (trek-e review 5608265301, Nit 5); the result is the same.
|
|
1514
|
+
const flatNote = noteText.split(new RegExp(`${state_document_cjs_1.FIELD_LINE_BREAK_RE.source}+`))
|
|
1515
|
+
.map((piece) => piece.trim()).filter(Boolean).join(' ');
|
|
1417
1516
|
const actionText = (action && action.trim()) || 'changed';
|
|
1418
1517
|
const afterText = after && after.trim() ? ` after Phase ${after.trim()}` : '';
|
|
1419
1518
|
const urgentText = urgent ? ' (URGENT)' : '';
|
|
@@ -1428,7 +1527,7 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
|
|
|
1428
1527
|
if (!phaseId) {
|
|
1429
1528
|
const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
1430
1529
|
const fm = extractFrontmatter(rawState, statePath);
|
|
1431
|
-
phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState)) ?? undefined;
|
|
1530
|
+
phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState), resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd)) ?? undefined;
|
|
1432
1531
|
}
|
|
1433
1532
|
const entry = `- Phase ${phaseId || '?'} ${actionText}${afterText}: ${flatNote}${urgentText}`;
|
|
1434
1533
|
let duplicate = false;
|
|
@@ -1598,6 +1697,19 @@ function cmdStateResolveBlocker(cwd, text, raw) {
|
|
|
1598
1697
|
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.`);
|
|
1599
1698
|
}
|
|
1600
1699
|
}
|
|
1700
|
+
/**
|
|
1701
|
+
* #4998 review (trek-e 5608265301, Minor 2): at most this many characters of a
|
|
1702
|
+
* wrapped value's continuation are echoed back — it is STATE.md text, and its
|
|
1703
|
+
* length is whatever the file holds.
|
|
1704
|
+
*/
|
|
1705
|
+
const SKIPPED_CONTINUATION_MAX_CHARS = 200;
|
|
1706
|
+
function boundedContinuation(text) {
|
|
1707
|
+
if (text.length <= SKIPPED_CONTINUATION_MAX_CHARS)
|
|
1708
|
+
return text;
|
|
1709
|
+
// Never end on half a surrogate pair.
|
|
1710
|
+
const end = /[\uD800-\uDBFF]/.test(text[SKIPPED_CONTINUATION_MAX_CHARS - 1]) ? SKIPPED_CONTINUATION_MAX_CHARS - 1 : SKIPPED_CONTINUATION_MAX_CHARS;
|
|
1711
|
+
return `${text.slice(0, end)}…`;
|
|
1712
|
+
}
|
|
1601
1713
|
function cmdStateRecordSession(cwd, options, raw) {
|
|
1602
1714
|
// #4186: a bare invocation is a usage error, not a heartbeat write. The
|
|
1603
1715
|
// pre-#4186 handler accepted zero arguments and still refreshed
|
|
@@ -1611,6 +1723,13 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1611
1723
|
if (!options.stopped_at && (options.resume_file === undefined || options.resume_file === null)) {
|
|
1612
1724
|
error('stopped-at or resume-file required for state record-session');
|
|
1613
1725
|
}
|
|
1726
|
+
// #4998: every field this writes is single-line; a line break in a value
|
|
1727
|
+
// would forge sibling lines (U+2028/U+2029 included — the readers' `m`-flag
|
|
1728
|
+
// anchors match there too), so it is refused before anything is touched.
|
|
1729
|
+
for (const [flag, value] of [['--stopped-at', options.stopped_at], ['--resume-file', options.resume_file]]) {
|
|
1730
|
+
if (typeof value === 'string' && state_document_cjs_1.FIELD_LINE_BREAK_RE.test(value))
|
|
1731
|
+
error(`state record-session: ${flag} must be a single line`);
|
|
1732
|
+
}
|
|
1614
1733
|
const statePath = planningPaths(cwd).state;
|
|
1615
1734
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
1616
1735
|
output({ error: 'STATE.md not found' }, raw, undefined);
|
|
@@ -1628,31 +1747,95 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1628
1747
|
// ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
|
|
1629
1748
|
// transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
|
|
1630
1749
|
const preWriteState = {};
|
|
1750
|
+
// #4998: fields left untouched because their existing value wraps onto the
|
|
1751
|
+
// next line. The writers are single-line, so replacing such a field rewrote
|
|
1752
|
+
// only its first line and orphaned the rest. Which lines continue a value and
|
|
1753
|
+
// which are a separate note directly below it cannot be told apart, so the
|
|
1754
|
+
// write leaves the field whole and says so rather than deleting either.
|
|
1755
|
+
// Also (`line_separator`): a carried value the appended section leaves out.
|
|
1756
|
+
const skippedFields = [];
|
|
1757
|
+
const skipWrapped = (field, before, after) => {
|
|
1758
|
+
const continuation = (0, state_document_cjs_1.editedLineContinuation)(before, after);
|
|
1759
|
+
if (continuation === null)
|
|
1760
|
+
return false;
|
|
1761
|
+
skippedFields.push({ field, reason: 'wrapped_value', continuation: boundedContinuation(continuation) });
|
|
1762
|
+
return true;
|
|
1763
|
+
};
|
|
1631
1764
|
readModifyWriteStateMd(statePath, (content) => {
|
|
1765
|
+
// #4998 review (trek-e 5608265301, Major 1): every field written here is read
|
|
1766
|
+
// from the session section when there is one (`## Session`, else `## Session
|
|
1767
|
+
// Continuity` — matchSessionSection, state-snapshot, the frontmatter sync), so
|
|
1768
|
+
// it is written there too. A line outside that section is not the field: a
|
|
1769
|
+
// write that landed on it reported success while the synced value stayed
|
|
1770
|
+
// stale. With no session section only `Stopped At` is read from the whole
|
|
1771
|
+
// body (sessionFieldScope); `state-snapshot` reads Last session and Resume
|
|
1772
|
+
// file from a session section alone, so with none they are not written in
|
|
1773
|
+
// place but in the section appended below, as `state update`/`patch` refuse
|
|
1774
|
+
// them (#4998 review, Minor 3). Located on the masked text (same offsets).
|
|
1775
|
+
const readBodyWide = (fieldName) => fieldName.toLowerCase() === 'stopped at';
|
|
1776
|
+
const sessionSection = () => {
|
|
1777
|
+
const masked = (0, state_document_cjs_1.maskFencedLines)(content, { document: true });
|
|
1778
|
+
return (0, markdown_sectionizer_cjs_1.collectSection)(masked, state_document_cjs_1.isSessionHeading, { levelBounded: true })
|
|
1779
|
+
?? (0, markdown_sectionizer_cjs_1.collectSection)(masked, state_document_cjs_1.isSessionContinuityHeading, { levelBounded: true });
|
|
1780
|
+
};
|
|
1781
|
+
const inSessionScope = (fieldName, write) => {
|
|
1782
|
+
const section = sessionSection();
|
|
1783
|
+
if (!section)
|
|
1784
|
+
return readBodyWide(fieldName) ? (0, state_document_cjs_1.writeOutsideFences)(content, write, { document: true }) : null;
|
|
1785
|
+
const body = (0, state_document_cjs_1.writeOutsideFences)(content.slice(section.bodyStart, section.bodyEnd), write);
|
|
1786
|
+
return body === null ? null : (0, markdown_sectionizer_cjs_1.replaceSection)(content, section, body);
|
|
1787
|
+
};
|
|
1788
|
+
const sessionScopeText = (fieldName) => {
|
|
1789
|
+
const masked = (0, state_document_cjs_1.maskFencedLines)(content, { document: true });
|
|
1790
|
+
const section = sessionSection();
|
|
1791
|
+
if (!section && !readBodyWide(fieldName))
|
|
1792
|
+
return '';
|
|
1793
|
+
return (0, state_document_cjs_1.readableBoldLabels)(section ? masked.slice(section.bodyStart, section.bodyEnd) : masked);
|
|
1794
|
+
};
|
|
1632
1795
|
// #4763 (1): read the pre-write session record. The capture mirrors the
|
|
1633
|
-
// WRITER
|
|
1634
|
-
//
|
|
1635
|
-
// document-wide too — scoping it to ## Session would miss an archive-section
|
|
1636
|
-
// line the writer actually displaced. Continuation lines join via
|
|
1796
|
+
// WRITER: the first case-insensitive label match in the scope above, so a
|
|
1797
|
+
// line the writer did not displace is not reported as displaced. Continuation lines join via
|
|
1637
1798
|
// stateFieldContinuation (the sanctioned joiner — stateExtractField alone is
|
|
1638
1799
|
// first-line-only), so a wrapped multi-line handoff is surfaced whole.
|
|
1800
|
+
// #4998: a line inside a fenced block is an example, not a field — the
|
|
1801
|
+
// writers below skip fences (`writeOutsideFences`), so the capture reads
|
|
1802
|
+
// the same unfenced text they write.
|
|
1803
|
+
// A `**Label**:` line the writer can replace is read too (readableBoldLabels).
|
|
1639
1804
|
const capturePrior = (fieldName) => {
|
|
1640
|
-
const
|
|
1805
|
+
const visible = sessionScopeText(fieldName);
|
|
1806
|
+
const first = (0, state_document_cjs_1.stateExtractField)(visible, fieldName);
|
|
1641
1807
|
if (first === null)
|
|
1642
1808
|
return undefined;
|
|
1643
|
-
const cont = (0, state_document_cjs_1.stateFieldContinuation)(
|
|
1809
|
+
const cont = (0, state_document_cjs_1.stateFieldContinuation)(visible, fieldName);
|
|
1644
1810
|
return cont ? `${first}\n${cont}` : first;
|
|
1645
1811
|
};
|
|
1646
|
-
|
|
1812
|
+
// #4998: with no body line, the frontmatter `stopped_at` is the only copy of
|
|
1813
|
+
// the record, and the sync re-derives it from the line inserted below — a
|
|
1814
|
+
// displacement like any other, so it is the prior record too.
|
|
1815
|
+
const fmStoppedAt = extractFrontmatter(content)['stopped_at'];
|
|
1816
|
+
priorRecord.stoppedAt = capturePrior('Stopped At')
|
|
1817
|
+
?? (typeof fmStoppedAt === 'string' && fmStoppedAt.trim() ? fmStoppedAt.trim() : undefined);
|
|
1647
1818
|
priorRecord.resumeFile = capturePrior('Resume File');
|
|
1819
|
+
// #4998: every in-place write skips fenced blocks, as the insertion
|
|
1820
|
+
// below does — a fenced example line is never the occurrence replaced.
|
|
1821
|
+
const replaceField = (fieldName, value) => inSessionScope(fieldName, (visible) => (0, state_document_cjs_1.stateReplaceField)(visible, fieldName, value));
|
|
1822
|
+
// The template check reads the field the way the writer finds it: an
|
|
1823
|
+
// authored `**Resume file**: plan.md` read as absent and was reset to None.
|
|
1824
|
+
const replaceFieldIfTemplate = (fieldName, defaults, value) => {
|
|
1825
|
+
const current = (0, state_document_cjs_1.stateExtractField)(sessionScopeText(fieldName), fieldName);
|
|
1826
|
+
if (current !== null && current.trim() !== ''
|
|
1827
|
+
&& !defaults.some((d) => d.toLowerCase() === current.trim().toLowerCase()))
|
|
1828
|
+
return content;
|
|
1829
|
+
return inSessionScope(fieldName, (visible) => (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(visible, fieldName, defaults, value)) ?? content;
|
|
1830
|
+
};
|
|
1648
1831
|
// Update Last session / Last Date
|
|
1649
|
-
let result = (
|
|
1650
|
-
if (result) {
|
|
1832
|
+
let result = replaceField('Last session', now);
|
|
1833
|
+
if (result && !skipWrapped('Last session', content, result)) {
|
|
1651
1834
|
content = result;
|
|
1652
1835
|
updated.push('Last session');
|
|
1653
1836
|
}
|
|
1654
|
-
result = (
|
|
1655
|
-
if (result) {
|
|
1837
|
+
result = replaceField('Last Date', now);
|
|
1838
|
+
if (result && !skipWrapped('Last Date', content, result)) {
|
|
1656
1839
|
content = result;
|
|
1657
1840
|
updated.push('Last Date');
|
|
1658
1841
|
}
|
|
@@ -1667,13 +1850,13 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1667
1850
|
// section rewrite would reset an executor-authored resume file to None).
|
|
1668
1851
|
let stoppedAtMatched = false;
|
|
1669
1852
|
if (options.stopped_at) {
|
|
1670
|
-
result = (
|
|
1853
|
+
result = replaceField('Stopped At', options.stopped_at);
|
|
1671
1854
|
if (!result)
|
|
1672
|
-
result = (
|
|
1855
|
+
result = replaceField('Stopped at', options.stopped_at);
|
|
1673
1856
|
if (result) {
|
|
1674
1857
|
stoppedAtMatched = true;
|
|
1675
|
-
if (result !== content) {
|
|
1676
|
-
content = result;
|
|
1858
|
+
if (result !== content && !skipWrapped('Stopped At', content, result)) {
|
|
1859
|
+
content = (0, state_document_cjs_1.readableEditedBoldLabel)(content, result);
|
|
1677
1860
|
updated.push('Stopped At');
|
|
1678
1861
|
}
|
|
1679
1862
|
}
|
|
@@ -1685,30 +1868,25 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1685
1868
|
const resumeFileDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Resume File'];
|
|
1686
1869
|
if (options.resume_file !== undefined && options.resume_file !== null) {
|
|
1687
1870
|
// Caller explicitly passed a value — always honour it.
|
|
1688
|
-
result = (
|
|
1871
|
+
result = replaceField('Resume File', options.resume_file);
|
|
1689
1872
|
if (!result)
|
|
1690
|
-
result = (
|
|
1691
|
-
if (result) {
|
|
1692
|
-
content = result;
|
|
1873
|
+
result = replaceField('Resume file', options.resume_file);
|
|
1874
|
+
if (result && !skipWrapped('Resume File', content, result)) {
|
|
1875
|
+
content = (0, state_document_cjs_1.readableEditedBoldLabel)(content, result);
|
|
1693
1876
|
updated.push('Resume File');
|
|
1694
1877
|
}
|
|
1695
1878
|
}
|
|
1696
1879
|
else {
|
|
1697
1880
|
// No explicit value — only set 'None' when existing value is also a known default
|
|
1698
1881
|
// (i.e. not executor-authored).
|
|
1699
|
-
|
|
1700
|
-
|
|
1882
|
+
let newRf = replaceFieldIfTemplate('Resume File', resumeFileDefaults, 'None');
|
|
1883
|
+
// Try alternate capitalisation
|
|
1884
|
+
if (newRf === content)
|
|
1885
|
+
newRf = replaceFieldIfTemplate('Resume file', resumeFileDefaults, 'None');
|
|
1886
|
+
if (newRf !== content && !skipWrapped('Resume File', content, newRf)) {
|
|
1701
1887
|
content = newRf;
|
|
1702
1888
|
updated.push('Resume File');
|
|
1703
1889
|
}
|
|
1704
|
-
else {
|
|
1705
|
-
// Try alternate capitalisation
|
|
1706
|
-
const newRfAlt = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Resume file', resumeFileDefaults, 'None');
|
|
1707
|
-
if (newRfAlt !== content) {
|
|
1708
|
-
content = newRfAlt;
|
|
1709
|
-
updated.push('Resume File');
|
|
1710
|
-
}
|
|
1711
|
-
}
|
|
1712
1890
|
}
|
|
1713
1891
|
// Bug #944: DWIM normalize/auto-create — when the caller supplied --stopped-at or
|
|
1714
1892
|
// --resume-file but the body lacks the canonical labels (in-place replace
|
|
@@ -1735,8 +1913,12 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1735
1913
|
// identical value is already persisted on disk and must not trigger the
|
|
1736
1914
|
// insertion rewrite below.
|
|
1737
1915
|
const needsStoppedAt = options.stopped_at && !stoppedAtMatched;
|
|
1738
|
-
|
|
1739
|
-
const
|
|
1916
|
+
// #4998: a field skipped as wrapped is present, not missing.
|
|
1917
|
+
const skipped = (field) => skippedFields.some((s) => s.field === field);
|
|
1918
|
+
const needsResumeFile = options.resume_file !== undefined && options.resume_file !== null
|
|
1919
|
+
&& !updated.includes('Resume File') && !skipped('Resume File');
|
|
1920
|
+
const needsLastSession = !updated.includes('Last session') && !updated.includes('Last Date')
|
|
1921
|
+
&& !skipped('Last session') && !skipped('Last Date');
|
|
1740
1922
|
if (callerSuppliedValues && (needsStoppedAt || needsResumeFile || needsLastSession)) {
|
|
1741
1923
|
const resumeValue = (options.resume_file !== undefined && options.resume_file !== null)
|
|
1742
1924
|
? options.resume_file
|
|
@@ -1747,92 +1929,143 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1747
1929
|
// (workstream.cts, gsd2-import.cts, templates/state.md) instead emit
|
|
1748
1930
|
// `## Session Continuity`. Treat each separately so we never append a
|
|
1749
1931
|
// duplicate section alongside an existing one.
|
|
1750
|
-
|
|
1751
|
-
|
|
1932
|
+
// #4998: a heading inside a fenced example is not a section. Detected
|
|
1933
|
+
// with the locator the writers below and the readers use (#4998 review).
|
|
1934
|
+
const headingScan = (0, state_document_cjs_1.maskFencedLines)(content, { document: true });
|
|
1935
|
+
const existingCanonicalSession = (0, markdown_sectionizer_cjs_1.collectSection)(headingScan, state_document_cjs_1.isSessionHeading, { levelBounded: true }) !== null;
|
|
1936
|
+
const existingSessionContinuity = (0, markdown_sectionizer_cjs_1.collectSection)(headingScan, state_document_cjs_1.isSessionContinuityHeading, { levelBounded: true }) !== null;
|
|
1752
1937
|
// Track whether the chosen branch's rewrite actually matched. The detector
|
|
1753
|
-
//
|
|
1754
|
-
//
|
|
1755
|
-
// below must be too. If a writer regex silently fails to match (line-ending
|
|
1938
|
+
// and the writer share one locator (CRLF-tolerant: the tokenizer strips
|
|
1939
|
+
// a trailing \r). If the writer still fails to match (line-ending
|
|
1756
1940
|
// mismatch, unexpected heading shape, ...), do NOT report success — the
|
|
1757
1941
|
// caller would believe fields were persisted that were silently dropped
|
|
1758
1942
|
// (#2450). The append branch always sets rewriteMatched=true (it always
|
|
1759
1943
|
// mutates content).
|
|
1760
1944
|
let rewriteMatched = false;
|
|
1945
|
+
// `created` means a session section was added, as in record-metric and
|
|
1946
|
+
// add-decision — not a field inserted into an existing one (#4998 review).
|
|
1947
|
+
let sectionAppended = false;
|
|
1948
|
+
// #4998: insert each missing field into `region`,
|
|
1949
|
+
// in the section's own spelling and in template order (Last session →
|
|
1950
|
+
// Stopped at → Resume file), beside the sibling it follows — a bold
|
|
1951
|
+
// `**Stopped at:**` above plain `Last session:` lines is a line the
|
|
1952
|
+
// template does not define. With no sibling present it goes right after
|
|
1953
|
+
// the heading in bold, the pre-#4998 shape. Every existing line is kept.
|
|
1954
|
+
// A heading at EOF with no line terminator gets one first, so its body
|
|
1955
|
+
// exists to insert into rather than a field being glued onto it.
|
|
1956
|
+
// The section is located on the masked text (same offsets): a fenced
|
|
1957
|
+
// `## Session` example is not the section, and a ``` in a frontmatter
|
|
1958
|
+
// block scalar does not hide the real one (Codex review round 2).
|
|
1959
|
+
const insertIntoSection = (isTarget) => {
|
|
1960
|
+
let section = (0, markdown_sectionizer_cjs_1.collectSection)((0, state_document_cjs_1.maskFencedLines)(content, { document: true }), isTarget, { levelBounded: true });
|
|
1961
|
+
if (section && content[section.bodyStart - 1] !== '\n' && section.bodyStart >= content.length) {
|
|
1962
|
+
content += content.includes('\r\n') ? '\r\n' : '\n';
|
|
1963
|
+
section = (0, markdown_sectionizer_cjs_1.collectSection)((0, state_document_cjs_1.maskFencedLines)(content, { document: true }), isTarget, { levelBounded: true });
|
|
1964
|
+
}
|
|
1965
|
+
if (!section || content[section.bodyStart - 1] !== '\n')
|
|
1966
|
+
return;
|
|
1967
|
+
const body = (0, state_document_cjs_1.insertMissingSessionFields)(content.slice(section.bodyStart, section.bodyEnd), [
|
|
1968
|
+
{ label: 'Last session', needed: needsLastSession, value: now },
|
|
1969
|
+
{ label: 'Stopped at', needed: !!needsStoppedAt, value: stoppedAtValue },
|
|
1970
|
+
{ label: 'Resume file', needed: needsResumeFile, value: resumeValue },
|
|
1971
|
+
]);
|
|
1972
|
+
content = (0, markdown_sectionizer_cjs_1.replaceSection)(content, section, body);
|
|
1973
|
+
rewriteMatched = true;
|
|
1974
|
+
};
|
|
1761
1975
|
if (existingCanonicalSession) {
|
|
1762
|
-
//
|
|
1763
|
-
// section
|
|
1764
|
-
//
|
|
1765
|
-
|
|
1766
|
-
// which correctly stops at the next section boundary without consuming it.
|
|
1767
|
-
// A trailing blank line is added so the next ## heading keeps its spacing.
|
|
1768
|
-
//
|
|
1769
|
-
// CRLF-tolerant (`\r?\n` after `[ \t]*`): the prior literal `\n` could not
|
|
1770
|
-
// match a CRLF STATE.md (`---\r\n`), silently no-op'ing the replace while
|
|
1771
|
-
// updated.push(...) reported success — #2450. The detector regex on the
|
|
1772
|
-
// line above (`/^## Session[ \t]*$/im`) was already CRLF-tolerant, so the
|
|
1773
|
-
// asymmetry armed the bug.
|
|
1774
|
-
const canonicalReplacement = [
|
|
1775
|
-
'## Session',
|
|
1776
|
-
'',
|
|
1777
|
-
`**Last session:** ${now}`,
|
|
1778
|
-
`**Stopped at:** ${stoppedAtValue}`,
|
|
1779
|
-
`**Resume file:** ${resumeValue}`,
|
|
1780
|
-
'',
|
|
1781
|
-
'',
|
|
1782
|
-
].join('\n');
|
|
1783
|
-
content = content.replace(/^(## Session[ \t]*\r?\n(?:(?!^## )[\s\S])*)/m, () => {
|
|
1784
|
-
rewriteMatched = true;
|
|
1785
|
-
return canonicalReplacement;
|
|
1786
|
-
});
|
|
1976
|
+
// #4998: insert the missing fields in place. This used to replace the
|
|
1977
|
+
// whole section with three canonical lines, which deleted any wrapped
|
|
1978
|
+
// value or note in it — and reset an authored Resume file to None.
|
|
1979
|
+
insertIntoSection(state_document_cjs_1.isSessionHeading);
|
|
1787
1980
|
}
|
|
1788
1981
|
else if (existingSessionContinuity) {
|
|
1789
1982
|
// #1101: a `## Session Continuity` section already exists (bootstrap
|
|
1790
1983
|
// shape). Previously this fell through to the append branch and created
|
|
1791
1984
|
// a SECOND `## Session` block — a duplicate. Instead, insert only the
|
|
1792
|
-
// canonical fields that are still missing
|
|
1985
|
+
// canonical fields that are still missing (#4998: beside their siblings),
|
|
1793
1986
|
// preserving the `## Session Continuity` heading and ALL existing lines
|
|
1794
1987
|
// (e.g. prose like "Next recommended action"). Fields already updated in
|
|
1795
1988
|
// place above (needs* false) are not re-inserted. A function replacement
|
|
1796
1989
|
// is used so `$`-bearing caller values are inserted literally (#3454).
|
|
1797
1990
|
//
|
|
1798
|
-
//
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
linesToInsert.push(`**Stopped at:** ${stoppedAtValue}`);
|
|
1804
|
-
if (needsResumeFile)
|
|
1805
|
-
linesToInsert.push(`**Resume file:** ${resumeValue}`);
|
|
1806
|
-
if (linesToInsert.length > 0) {
|
|
1807
|
-
// Case-insensitive to match the `existingSessionContinuity` detection
|
|
1808
|
-
// above (#1101 review F3) — otherwise a lowercase heading would detect
|
|
1809
|
-
// but no-op the insert while still reporting the fields as updated.
|
|
1810
|
-
content = content.replace(/^(## Session Continuity[ \t]*\r?\n)/im, (_m, heading) => {
|
|
1811
|
-
rewriteMatched = true;
|
|
1812
|
-
return heading + linesToInsert.join('\n') + '\n';
|
|
1813
|
-
});
|
|
1814
|
-
}
|
|
1815
|
-
// No `else` branch: if linesToInsert.length === 0 the outer guard at
|
|
1816
|
-
// :1144 (callerSuppliedValues && (needsStoppedAt || needsResumeFile
|
|
1817
|
-
// || needsLastSession)) could not have fired, so this whole block is
|
|
1818
|
-
// unreachable. Leaving `rewriteMatched = false` here is the fail-loud
|
|
1819
|
-
// posture — a future change to the outer guard or needs* computation
|
|
1820
|
-
// that makes this branch reachable will surface as a missing
|
|
1821
|
-
// updated[] entry (silent recorded:false) rather than re-arming #2450.
|
|
1991
|
+
// Heading match is case-insensitive and CRLF-tolerant like the
|
|
1992
|
+
// `existingSessionContinuity` detection above (#1101 review F3, #2450):
|
|
1993
|
+
// a detected heading the writer then misses would report fields as
|
|
1994
|
+
// updated that were never written (`rewriteMatched` stays false).
|
|
1995
|
+
insertIntoSection(state_document_cjs_1.isSessionContinuityHeading);
|
|
1822
1996
|
}
|
|
1823
1997
|
else {
|
|
1824
1998
|
// No session heading exists at all — append a new canonical section.
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1999
|
+
// #4998: the appended section becomes the one the sync reads, so a
|
|
2000
|
+
// field this call is not setting carries the value the sync reads
|
|
2001
|
+
// today (a body line, a wrapped field's first line, a table row)
|
|
2002
|
+
// rather than None or nothing — which dropped the frontmatter
|
|
2003
|
+
// `stopped_at` and reset an authored Resume file. Later writes then
|
|
2004
|
+
// land on these bold lines first. A field skipped above as wrapped
|
|
2005
|
+
// carries its existing value too — Last session included — so a
|
|
2006
|
+
// skipped field never comes back carrying the new value.
|
|
2007
|
+
// Read from the body, as the sync does — never from frontmatter, and
|
|
2008
|
+
// never from a fenced example.
|
|
2009
|
+
const syncBody = (0, state_document_cjs_1.readableBoldLabels)((0, state_document_cjs_1.maskFencedLines)(stripFrontmatter(content)));
|
|
2010
|
+
const carried = (fields, fallback) => {
|
|
2011
|
+
for (const field of fields) {
|
|
2012
|
+
const value = (0, state_document_cjs_1.stateExtractField)(syncBody, field);
|
|
2013
|
+
if (value !== null)
|
|
2014
|
+
return value;
|
|
2015
|
+
}
|
|
2016
|
+
return fallback;
|
|
2017
|
+
};
|
|
2018
|
+
const lastSessionValue = skipped('Last session') || skipped('Last Date')
|
|
2019
|
+
? carried(['Last session', 'Last Date'], now)
|
|
2020
|
+
: now;
|
|
2021
|
+
const scaffoldFields = [
|
|
2022
|
+
['Last session', lastSessionValue],
|
|
2023
|
+
['Stopped at', needsStoppedAt ? stoppedAtValue : carried(['Stopped At'], stoppedAtValue)],
|
|
2024
|
+
['Resume file', needsResumeFile ? resumeValue : carried(['Resume File'], resumeValue)],
|
|
2025
|
+
];
|
|
2026
|
+
// A document that ends inside an unclosed fence would swallow the
|
|
2027
|
+
// appended section — nothing reads it there. Write nothing, and say so.
|
|
2028
|
+
// The whole document is scanned on purpose: whether its tail is fenced
|
|
2029
|
+
// depends on every delimiter before it (frontmatter excluded: YAML, not
|
|
2030
|
+
// Markdown), and `scanFencedBlocks` reports
|
|
2031
|
+
// an unclosed block only as its LAST record (the fence runs to EOF), so
|
|
2032
|
+
// `closeLineIdx === -1` here means exactly "the tail is inside a fence".
|
|
2033
|
+
if ((0, state_document_cjs_1.markdownFencedBlocks)(content.split('\n'), { document: true }).some((b) => b.closeLineIdx === -1)) {
|
|
2034
|
+
// Named only for a field this call writes (Last session always, the
|
|
2035
|
+
// others when passed) and not already written in place or skipped
|
|
2036
|
+
// for another reason (Codex review; trek-e review 5608265301, Minor 3).
|
|
2037
|
+
const fieldKey = (f) => (f === 'Last Date' ? 'last session' : f.toLowerCase());
|
|
2038
|
+
const requested = new Set(['last session']);
|
|
2039
|
+
if (options.stopped_at)
|
|
2040
|
+
requested.add('stopped at');
|
|
2041
|
+
if (options.resume_file !== undefined && options.resume_file !== null)
|
|
2042
|
+
requested.add('resume file');
|
|
2043
|
+
const settled = new Set([...updated, ...skippedFields.map((s) => s.field)].map(fieldKey));
|
|
2044
|
+
// A Stopped at already holding the value needs no write (Codex review).
|
|
2045
|
+
if (stoppedAtMatched)
|
|
2046
|
+
settled.add('stopped at');
|
|
2047
|
+
for (const [label] of scaffoldFields) {
|
|
2048
|
+
const key = fieldKey(label);
|
|
2049
|
+
if (requested.has(key) && !settled.has(key))
|
|
2050
|
+
skippedFields.push({ field: label, reason: 'unclosed_fence' });
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
else {
|
|
2054
|
+
// A carried value holding a line separator (a hand-written table cell
|
|
2055
|
+
// can) would forge a line once written as a bold field: leave that
|
|
2056
|
+
// field out, and say so.
|
|
2057
|
+
const scaffoldLines = [];
|
|
2058
|
+
for (const [label, value] of scaffoldFields) {
|
|
2059
|
+
if (state_document_cjs_1.FIELD_LINE_BREAK_RE.test(value))
|
|
2060
|
+
skippedFields.push({ field: label, reason: 'line_separator' });
|
|
2061
|
+
else
|
|
2062
|
+
scaffoldLines.push(`**${label}:** ${value}`);
|
|
2063
|
+
}
|
|
2064
|
+
const scaffold = ['', '## Session', '', ...scaffoldLines, ''].join('\n');
|
|
2065
|
+
content = content.trimEnd() + '\n' + scaffold;
|
|
2066
|
+
rewriteMatched = true;
|
|
2067
|
+
sectionAppended = true;
|
|
2068
|
+
}
|
|
1836
2069
|
}
|
|
1837
2070
|
// #2450 defensive invariant: only report sessionCreated/updated when the
|
|
1838
2071
|
// chosen branch's rewrite actually mutated content. Unreachable when the
|
|
@@ -1848,7 +2081,7 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1848
2081
|
// same-line replace (CRLF-agnostic seam), so unconditional push is
|
|
1849
2082
|
// correct. The class-defect防御 here is for the INSERT path only.
|
|
1850
2083
|
if (rewriteMatched) {
|
|
1851
|
-
sessionCreated =
|
|
2084
|
+
sessionCreated = sectionAppended;
|
|
1852
2085
|
if (needsLastSession)
|
|
1853
2086
|
updated.push('Last session');
|
|
1854
2087
|
if (needsStoppedAt)
|
|
@@ -1863,22 +2096,48 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1863
2096
|
// bytes actually persisted (fix(#3351) generalized) and fold in any field
|
|
1864
2097
|
// preservation restored that this transform never touched (#3345's
|
|
1865
2098
|
// direction).
|
|
1866
|
-
|
|
2099
|
+
// #4998 review: read back where the fields were written — the session section.
|
|
2100
|
+
const reconciledUpdated = reconcileReportedFields(statePath, preWriteState, updated, divergedFields, NO_SECTION_TARGETS, true);
|
|
2101
|
+
// #4998: name every field left whole because its value wraps, and how to fix it.
|
|
2102
|
+
const skippedRows = skippedFields;
|
|
2103
|
+
const skippedDisclosure = skippedRows.map(({ field, reason, continuation }) => (reason === 'wrapped_value'
|
|
2104
|
+
? `state record-session left ${field} unchanged — its value continues onto the next line ` +
|
|
2105
|
+
`(${formatDiagnosticToken(continuation ?? '')}), and a single-line write would leave that line behind. ` +
|
|
2106
|
+
'Fold the value onto one line, or put a blank line before that line if it is a separate note, then re-run.'
|
|
2107
|
+
: reason === 'line_separator'
|
|
2108
|
+
? `state record-session left ${field} out of the new ## Session section — its existing value holds a ` +
|
|
2109
|
+
'line separator (U+2028/U+2029), which would split the line it is written on. Remove the separator, then re-run.'
|
|
2110
|
+
: `state record-session did not write ${field} — STATE.md has no session section and ends inside an unclosed ` +
|
|
2111
|
+
'code fence, which would swallow a new ## Session section. Close the fence, then re-run.'));
|
|
1867
2112
|
if (reconciledUpdated.length > 0) {
|
|
1868
2113
|
const result = { recorded: true, updated: reconciledUpdated };
|
|
1869
2114
|
if (sessionCreated)
|
|
1870
2115
|
result['created'] = true;
|
|
2116
|
+
if (skippedRows.length > 0) {
|
|
2117
|
+
result['skipped'] = skippedRows;
|
|
2118
|
+
for (const line of skippedDisclosure)
|
|
2119
|
+
process.stderr.write(`[gsd-tools] WARNING: ${line}\n`);
|
|
2120
|
+
}
|
|
1871
2121
|
// #4763 (1): surface any non-empty prior record the write displaced. Gated
|
|
1872
2122
|
// on reconciledUpdated (the fields that actually persisted, post-#3957
|
|
1873
2123
|
// reconcile) rather than the pre-reconciliation updated[]. Only fields with
|
|
1874
2124
|
// real prior content differing from the caller's value count — same-value
|
|
1875
|
-
// rewrites, the insert path
|
|
1876
|
-
//
|
|
1877
|
-
//
|
|
1878
|
-
|
|
1879
|
-
|
|
2125
|
+
// rewrites, the insert path with no prior record (no body label and, #4998,
|
|
2126
|
+
// no frontmatter `stopped_at`), and the #944 template-default DWIM
|
|
2127
|
+
// (defaults match case-insensitively, so a case-variant 'none' → 'None'
|
|
2128
|
+
// rewrite is normalization, not displacement) are excluded.
|
|
2129
|
+
// #4998: `None` is the same placeholder for Stopped At (the bootstrap
|
|
2130
|
+
// writes it there too, body or frontmatter) — no record was displaced. Nor
|
|
2131
|
+
// is the shipped template's whole-value `[...]` placeholder
|
|
2132
|
+
// (`[Description of last completed action]`; #4998 review, Nit 7).
|
|
2133
|
+
const isPlaceholder = (value) => value !== undefined
|
|
2134
|
+
&& (state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Resume File'].some((d) => d.toLowerCase() === value.toLowerCase())
|
|
2135
|
+
|| /^\[[^\]\n]*\]$/u.test(value.trim()));
|
|
2136
|
+
const isResumeTemplateDefault = isPlaceholder(priorRecord.resumeFile);
|
|
2137
|
+
const isStoppedAtPlaceholder = isPlaceholder(priorRecord.stoppedAt);
|
|
1880
2138
|
const replacedRecord = {};
|
|
1881
2139
|
if (reconciledUpdated.includes('Stopped At') && priorRecord.stoppedAt
|
|
2140
|
+
&& !isStoppedAtPlaceholder
|
|
1882
2141
|
&& priorRecord.stoppedAt !== options.stopped_at) {
|
|
1883
2142
|
replacedRecord['Stopped At'] = priorRecord.stoppedAt;
|
|
1884
2143
|
}
|
|
@@ -1891,6 +2150,10 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1891
2150
|
result['replacedRecord'] = replacedRecord;
|
|
1892
2151
|
output(result, raw, 'true');
|
|
1893
2152
|
}
|
|
2153
|
+
else if (skippedRows.length > 0) {
|
|
2154
|
+
// #4998: every field the call would have changed wraps — nothing written.
|
|
2155
|
+
declineNoOp(raw, 'recorded', 'every session field to update was left unchanged — see skipped', skippedDisclosure.join('\n[gsd-tools] WARNING: '), { skipped: skippedRows });
|
|
2156
|
+
}
|
|
1894
2157
|
else if (updated.length === 0) {
|
|
1895
2158
|
// Nothing was ever attempted — no --stopped-at/--resume-file supplied
|
|
1896
2159
|
// and no existing Last session/Last Date/Stopped At/Resume File labels
|
|
@@ -1924,12 +2187,20 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1924
2187
|
* Returns the section body, or null.
|
|
1925
2188
|
*/
|
|
1926
2189
|
function matchSessionSection(body) {
|
|
1927
|
-
const
|
|
1928
|
-
|
|
1929
|
-
const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, isSession, { levelBounded: true })
|
|
1930
|
-
?? (0, markdown_sectionizer_cjs_1.collectSection)(body, isSessionContinuity, { levelBounded: true });
|
|
2190
|
+
const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, state_document_cjs_1.isSessionHeading, { levelBounded: true })
|
|
2191
|
+
?? (0, markdown_sectionizer_cjs_1.collectSection)(body, state_document_cjs_1.isSessionContinuityHeading, { levelBounded: true });
|
|
1931
2192
|
return section ? section.body : null;
|
|
1932
2193
|
}
|
|
2194
|
+
/**
|
|
2195
|
+
* #4998: where the session fields (Stopped At, Paused At) are read from — the
|
|
2196
|
+
* session section, else the whole body — with fenced blocks blanked, because
|
|
2197
|
+
* `state record-session` writes them outside fences (`writeOutsideFences`).
|
|
2198
|
+
* A reader that still took a fenced example would sync it into frontmatter
|
|
2199
|
+
* over the field the writer just wrote.
|
|
2200
|
+
*/
|
|
2201
|
+
function sessionFieldScope(body) {
|
|
2202
|
+
return (0, state_document_cjs_1.maskFencedLines)(matchSessionSection(body) ?? body);
|
|
2203
|
+
}
|
|
1933
2204
|
/**
|
|
1934
2205
|
* Match the "Current Position" section body from a STATE.md body. #2956: this
|
|
1935
2206
|
* is the Phase analogue of matchSessionSection. `Phase` canonically lives under
|
|
@@ -1995,26 +2266,27 @@ function preferNewerLastActivity(existingFm, derivedFm) {
|
|
|
1995
2266
|
derivedFm['last_activity'] = exRaw;
|
|
1996
2267
|
}
|
|
1997
2268
|
}
|
|
1998
|
-
function parseProsePhaseField(value) {
|
|
2269
|
+
function parseProsePhaseField(value, convention, directoryContext) {
|
|
1999
2270
|
// #2121 Phase 2 (#2125): delegate to the canonical anchored parser so this
|
|
2000
2271
|
// module holds no independent prose phase-id regex. Drives #2111 — the
|
|
2001
2272
|
// anchored parser returns { phase: null } for a "Milestone vX.Y complete"
|
|
2002
2273
|
// body line (the old unanchored regex mined the minor-version digit, e.g.
|
|
2003
2274
|
// v0.5 -> "5"), so syncStateFrontmatter's #905 guard preserves the real
|
|
2004
2275
|
// current_phase instead of clobbering it.
|
|
2005
|
-
|
|
2276
|
+
// #5626: under `milestone-prefixed` the id is the whole `M-NN`; the convention comes from its owner.
|
|
2277
|
+
return parsePhaseFromProse(value, convention, directoryContext);
|
|
2006
2278
|
}
|
|
2007
|
-
function resolveStatePhase(fm, body) {
|
|
2279
|
+
function resolveStatePhase(fm, body, convention, directoryContext) {
|
|
2008
2280
|
const currentPositionScope = matchCurrentPositionSection(body) ?? body;
|
|
2009
2281
|
const frontmatterRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', null).value;
|
|
2010
2282
|
const legacyRaw = (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Current Phase').value;
|
|
2011
2283
|
const currentPositionRaw = (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Phase').value;
|
|
2012
2284
|
const sources = {
|
|
2013
|
-
frontmatter: parseProsePhaseField(frontmatterRaw).phase,
|
|
2014
|
-
legacy_current_phase: parseProsePhaseField(legacyRaw).phase,
|
|
2015
|
-
current_position_phase: parseProsePhaseField(currentPositionRaw).phase,
|
|
2285
|
+
frontmatter: parseProsePhaseField(frontmatterRaw, convention, directoryContext).phase,
|
|
2286
|
+
legacy_current_phase: parseProsePhaseField(legacyRaw, convention, directoryContext).phase,
|
|
2287
|
+
current_position_phase: parseProsePhaseField(currentPositionRaw, convention, directoryContext).phase,
|
|
2016
2288
|
};
|
|
2017
|
-
const prosePhase = parseProsePhaseField(currentPositionRaw);
|
|
2289
|
+
const prosePhase = parseProsePhaseField(currentPositionRaw, convention, directoryContext);
|
|
2018
2290
|
return {
|
|
2019
2291
|
phase: sources.frontmatter ?? sources.legacy_current_phase ?? sources.current_position_phase,
|
|
2020
2292
|
name: (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase_name', null).value
|
|
@@ -2069,9 +2341,9 @@ function resolveStatePhase(fm, body) {
|
|
|
2069
2341
|
* Reconcile the two only by giving `resolveStatePhase` an explicit scope
|
|
2070
2342
|
* parameter — never by pointing this at it and dropping the difference.
|
|
2071
2343
|
*/
|
|
2072
|
-
function resolveCurrentPhaseId(fm, body) {
|
|
2344
|
+
function resolveCurrentPhaseId(fm, body, convention, directoryContext) {
|
|
2073
2345
|
const positionSection = sliceCurrentPositionSection(body);
|
|
2074
|
-
const prosePhase = positionSection !== null ? parseProsePhaseField((0, state_document_cjs_1.stateFieldValue)(fm, positionSection, null, 'Phase').value).phase : null;
|
|
2346
|
+
const prosePhase = positionSection !== null ? parseProsePhaseField((0, state_document_cjs_1.stateFieldValue)(fm, positionSection, null, 'Phase').value, convention, directoryContext).phase : null;
|
|
2075
2347
|
return (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value ?? prosePhase;
|
|
2076
2348
|
}
|
|
2077
2349
|
function parseProseLastActivityField(value) {
|
|
@@ -2109,7 +2381,8 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
2109
2381
|
// so it is scopeable exactly like Stopped At under ## Session. Fall back to
|
|
2110
2382
|
// full-body search only when no ## Current Position section exists, so files
|
|
2111
2383
|
// with no section heading keep their current behaviour.
|
|
2112
|
-
const
|
|
2384
|
+
const directoryContext = phaseDirectoryOptions(cwd);
|
|
2385
|
+
const resolvedPhase = resolveStatePhase(fm, body, resolvePhaseIdConvention(cwd), directoryContext);
|
|
2113
2386
|
const currentPhase = resolvedPhase.phase;
|
|
2114
2387
|
const currentPhaseName = resolvedPhase.name;
|
|
2115
2388
|
const totalPhasesRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_phases', 'Total Phases').value;
|
|
@@ -2125,7 +2398,7 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
2125
2398
|
// preferNewerLastActivity and the write seam in buildStateFrontmatter). The
|
|
2126
2399
|
// write seam already scopes it to ## Session; this read seam must agree, so a
|
|
2127
2400
|
// stale "Paused At:" in a Session Continuity Archive cannot win here either.
|
|
2128
|
-
const sessionScope =
|
|
2401
|
+
const sessionScope = sessionFieldScope(body);
|
|
2129
2402
|
const pausedAt = (0, state_document_cjs_1.stateFieldValue)(fm, sessionScope, 'paused_at', 'Paused At').value;
|
|
2130
2403
|
// Parse numeric fields
|
|
2131
2404
|
const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
|
|
@@ -2168,7 +2441,8 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
2168
2441
|
// `## Session Continuity` heading. See matchSessionSection for the anchoring.
|
|
2169
2442
|
const sessionMatch = matchSessionSection(body);
|
|
2170
2443
|
if (sessionMatch !== null) {
|
|
2171
|
-
|
|
2444
|
+
// #4998: fenced example lines are not fields (as for sessionFieldScope).
|
|
2445
|
+
const sessionSection = (0, state_document_cjs_1.maskFencedLines)(sessionMatch);
|
|
2172
2446
|
// Accept both `**Last Date:**` (canonical template form) and `**Last session:**`
|
|
2173
2447
|
// (the form written by the DWIM auto-create / normalize path added for #944).
|
|
2174
2448
|
const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i)
|
|
@@ -2279,7 +2553,7 @@ function isMilestoneBounded(roadmapRaw, milestone, convention) {
|
|
|
2279
2553
|
* decimal, and project-code IDs are detected alike. Returns canonical keys
|
|
2280
2554
|
* (see phaseKeyFromToken).
|
|
2281
2555
|
*/
|
|
2282
|
-
function extractRetiredPhaseNumbers(scope, convention) {
|
|
2556
|
+
function extractRetiredPhaseNumbers(scope, convention, directoryContext) {
|
|
2283
2557
|
const retired = new Set();
|
|
2284
2558
|
const isChecklistOrHeading = /^\s*(?:[-*+]\s*\[[ xX]\]|#{1,6}\s)/;
|
|
2285
2559
|
// #612: the retirement filter has to widen with the counter it protects. The
|
|
@@ -2305,8 +2579,8 @@ function extractRetiredPhaseNumbers(scope, convention) {
|
|
|
2305
2579
|
while ((s = strikeSpan.exec(line)) !== null) {
|
|
2306
2580
|
const phaseRef = phaseRefRe.exec(s[1]);
|
|
2307
2581
|
// Require a digit so struck prose like ~~Phase Overview~~ is ignored.
|
|
2308
|
-
if (phaseRef && /\d/.test(phaseRef[1]))
|
|
2309
|
-
retired.add(phaseKeyFromToken(phaseRef[1]));
|
|
2582
|
+
if (phaseRef && (directoryContext?.phaseNaming === 'custom' || /\d/.test(phaseRef[1])))
|
|
2583
|
+
retired.add(phaseKeyFromToken(phaseRef[1], directoryContext));
|
|
2310
2584
|
}
|
|
2311
2585
|
}
|
|
2312
2586
|
return retired;
|
|
@@ -2350,7 +2624,7 @@ function extractRetiredPhaseNumbers(scope, convention) {
|
|
|
2350
2624
|
* retain the bracket-id and bare-0 rules. Sharing the implementation therefore
|
|
2351
2625
|
* cannot silently move either convention's total.
|
|
2352
2626
|
*/
|
|
2353
|
-
function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyConventionTokenSentinelRules) {
|
|
2627
|
+
function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyConventionTokenSentinelRules, directoryContext) {
|
|
2354
2628
|
let count = 0;
|
|
2355
2629
|
if (convention === 'bracket') {
|
|
2356
2630
|
const introSrc = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true);
|
|
@@ -2381,7 +2655,7 @@ function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyCon
|
|
|
2381
2655
|
continue;
|
|
2382
2656
|
// #1514: retired/folded phases are struck through in the ROADMAP;
|
|
2383
2657
|
// exclude them from the denominator (they can never be completed).
|
|
2384
|
-
if (retiredPhaseNums.has(phaseKeyFromToken(token)))
|
|
2658
|
+
if (retiredPhaseNums.has(phaseKeyFromToken(token, directoryContext)))
|
|
2385
2659
|
continue;
|
|
2386
2660
|
count++;
|
|
2387
2661
|
}
|
|
@@ -2397,7 +2671,7 @@ function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyCon
|
|
|
2397
2671
|
continue;
|
|
2398
2672
|
if (applyConventionTokenSentinelRules && isSentinelPhaseId(token))
|
|
2399
2673
|
continue;
|
|
2400
|
-
if (retiredPhaseNums.has(phaseKeyFromToken(token)))
|
|
2674
|
+
if (retiredPhaseNums.has(phaseKeyFromToken(token, directoryContext)))
|
|
2401
2675
|
continue;
|
|
2402
2676
|
count++;
|
|
2403
2677
|
}
|
|
@@ -2425,12 +2699,14 @@ options = {}) {
|
|
|
2425
2699
|
let roadmapScope = null;
|
|
2426
2700
|
let roadmapRaw = null;
|
|
2427
2701
|
let retiredPhaseNums = new Set();
|
|
2702
|
+
let directoryContext = phaseDirectoryOptions(cwd, undefined, '');
|
|
2428
2703
|
try {
|
|
2429
2704
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
2430
2705
|
roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
|
|
2431
2706
|
if (roadmapRaw !== null) {
|
|
2432
2707
|
roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
|
2433
|
-
|
|
2708
|
+
directoryContext = { ...directoryContext, roadmap: roadmapRaw };
|
|
2709
|
+
retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope, phaseConvention, directoryContext);
|
|
2434
2710
|
}
|
|
2435
2711
|
}
|
|
2436
2712
|
catch { /* fall through: no roadmap scope → no retired exclusion */ }
|
|
@@ -2457,7 +2733,7 @@ options = {}) {
|
|
|
2457
2733
|
// artifact; drop it from the disk phase set so it counts toward
|
|
2458
2734
|
// neither the denominator nor the numerator (mirrors the heading
|
|
2459
2735
|
// exclusion below). Project-code-aware via phaseKeyFromDir.
|
|
2460
|
-
if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir, phaseConvention)))
|
|
2736
|
+
if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir, phaseConvention, directoryContext)))
|
|
2461
2737
|
continue;
|
|
2462
2738
|
// #3185: dedup grouping routed through the canonical phaseKeyFromDir
|
|
2463
2739
|
// (src/phase-id.cts) instead of a local leading-digits regex that
|
|
@@ -2466,7 +2742,7 @@ options = {}) {
|
|
|
2466
2742
|
// so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on
|
|
2467
2743
|
// multi-segment milestone dirs. Same key surface used two lines
|
|
2468
2744
|
// above for the retiredPhaseNums exclusion, so both filters agree.
|
|
2469
|
-
const key = phaseKeyFromDir(dir, phaseConvention);
|
|
2745
|
+
const key = phaseKeyFromDir(dir, phaseConvention, directoryContext);
|
|
2470
2746
|
if (!seenPhaseNums.has(key)) {
|
|
2471
2747
|
seenPhaseNums.set(key, dir);
|
|
2472
2748
|
}
|
|
@@ -2497,6 +2773,7 @@ options = {}) {
|
|
|
2497
2773
|
roadmapScope,
|
|
2498
2774
|
roadmapRaw,
|
|
2499
2775
|
retiredPhaseNums,
|
|
2776
|
+
directoryContext,
|
|
2500
2777
|
};
|
|
2501
2778
|
}
|
|
2502
2779
|
/**
|
|
@@ -2531,7 +2808,7 @@ milestoneExplicitlyNull) {
|
|
|
2531
2808
|
// section overwrites current_phase here, and the next read surfaces it. Fall
|
|
2532
2809
|
// back to full-body search when no ## Current Position section exists.
|
|
2533
2810
|
const currentPositionScope = matchCurrentPositionSection(bodyContent) ?? bodyContent;
|
|
2534
|
-
const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(currentPositionScope, 'Phase'));
|
|
2811
|
+
const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(currentPositionScope, 'Phase'), cwd ? resolvePhaseIdConvention(cwd) : null, cwd ? phaseDirectoryOptions(cwd) : undefined);
|
|
2535
2812
|
const currentPhase = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase') ?? prosePhase.phase;
|
|
2536
2813
|
const currentPhaseName = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase Name') ?? prosePhase.name;
|
|
2537
2814
|
const currentPlan = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Plan');
|
|
@@ -2549,8 +2826,7 @@ milestoneExplicitlyNull) {
|
|
|
2549
2826
|
// Fall back to full-body search only when no ## Session section exists.
|
|
2550
2827
|
// #1101: prefer the canonical `## Session` block, falling back to the bootstrap
|
|
2551
2828
|
// `## Session Continuity` heading. See matchSessionSection for the anchoring.
|
|
2552
|
-
const
|
|
2553
|
-
const sessionBodyScope = sessionSectionMatch ?? bodyContent;
|
|
2829
|
+
const sessionBodyScope = sessionFieldScope(bodyContent);
|
|
2554
2830
|
const stoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped at');
|
|
2555
2831
|
// #2567: Paused At is a session field — scope it to ## Session too so a
|
|
2556
2832
|
// stale "Paused At:" line in an archive section cannot overwrite the value.
|
|
@@ -2630,7 +2906,7 @@ milestoneExplicitlyNull) {
|
|
|
2630
2906
|
// #5118 pre-write validation in `phase remove` reads exactly this set.
|
|
2631
2907
|
// #5038: an explicit-null `storedMilestone` is text, not a version;
|
|
2632
2908
|
// pass a real null so it never reaches the phase-dir version filter.
|
|
2633
|
-
const { phaseDirs, phaseDirScope, roadmapScope, roadmapRaw, retiredPhaseNums, } = scanStatePhaseDirs(cwd, phasesDir, phaseConvention, milestoneExplicitlyNull ? null : storedMilestone);
|
|
2909
|
+
const { phaseDirs, phaseDirScope, roadmapScope, roadmapRaw, retiredPhaseNums, directoryContext, } = scanStatePhaseDirs(cwd, phasesDir, phaseConvention, milestoneExplicitlyNull ? null : storedMilestone);
|
|
2634
2910
|
let diskTotalPlans = 0;
|
|
2635
2911
|
let diskTotalSummaries = 0;
|
|
2636
2912
|
let diskCompletedPhases = 0;
|
|
@@ -2668,7 +2944,7 @@ milestoneExplicitlyNull) {
|
|
|
2668
2944
|
// fence-aware bracket strategy with #3185's canonical legacy
|
|
2669
2945
|
// sentinel predicate for this read-path call.
|
|
2670
2946
|
const roadmapPhaseCount = roadmapScope !== null
|
|
2671
|
-
? countRoadmapPhaseHeadings(roadmapScope, phaseConvention, retiredPhaseNums, true)
|
|
2947
|
+
? countRoadmapPhaseHeadings(roadmapScope, phaseConvention, retiredPhaseNums, true, directoryContext)
|
|
2672
2948
|
: 0;
|
|
2673
2949
|
cached = (() => {
|
|
2674
2950
|
// #1761 read-path: mirror the cmdStateSync guard (#1794). When the
|
|
@@ -2799,8 +3075,14 @@ milestoneExplicitlyNull) {
|
|
|
2799
3075
|
// — no second ROADMAP parser here. A ROADMAP without a canonical
|
|
2800
3076
|
// `## Progress` table resolves no table → floor inert (disk count
|
|
2801
3077
|
// stands), the owner's own answer to "what is countable".
|
|
3078
|
+
// #5165: the milestone-grouped `## Progress` table keeps every
|
|
3079
|
+
// milestone's rows and sits OUTSIDE any narrower roadmapScope, so the
|
|
3080
|
+
// floor names the milestone STATE asserts (assertedMilestoneVersion —
|
|
3081
|
+
// present on every resync path, unlike the optional storedMilestone
|
|
3082
|
+
// parameter) and the owner counts only that milestone's rows. No row
|
|
3083
|
+
// for it => nulls => the floor is inert and the disk count stands.
|
|
2802
3084
|
const roadmapCompletedPhases = roadmapScope !== null && safeToUseRoadmapCount && !diskCountsWithheld
|
|
2803
|
-
? deriveProgressFromRoadmap(roadmapScope).completedPhases
|
|
3085
|
+
? deriveProgressFromRoadmap(roadmapScope, assertedMilestoneVersion ?? storedMilestone).completedPhases
|
|
2804
3086
|
: null;
|
|
2805
3087
|
const flooredCompletedPhases = roadmapCompletedPhases !== null
|
|
2806
3088
|
? Math.max(diskCompletedPhases, roadmapCompletedPhases)
|
|
@@ -3227,6 +3509,8 @@ function applyAuthoritativeProgressSubkeys(fm, intent, opts) {
|
|
|
3227
3509
|
for (const [subkey, value] of Object.entries(intent)) {
|
|
3228
3510
|
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
3229
3511
|
continue;
|
|
3512
|
+
if (subkey === 'percent')
|
|
3513
|
+
continue; // composed below, never applied raw (#5165)
|
|
3230
3514
|
if (!getFieldClassification(`progress.${subkey}`))
|
|
3231
3515
|
continue;
|
|
3232
3516
|
const isCompletedCounter = subkey === 'completed_phases' || subkey === 'completed_plans';
|
|
@@ -3244,10 +3528,18 @@ function applyAuthoritativeProgressSubkeys(fm, intent, opts) {
|
|
|
3244
3528
|
}
|
|
3245
3529
|
// percent: applied only when a completed counter moved (coherent with the
|
|
3246
3530
|
// counters that just landed) or when no percent exists to contradict.
|
|
3531
|
+
// #5165 / ADR-4629 §8.5: `computeProgressPercent` is the one composition
|
|
3532
|
+
// owner (min(plan fraction, phase fraction)) that every resyncing write uses.
|
|
3533
|
+
// When the block carries the counters it needs, the percent is composed by
|
|
3534
|
+
// that owner over the counters just landed, so a later resyncing write
|
|
3535
|
+
// cannot move what this transition wrote. The intent's own percent (a
|
|
3536
|
+
// phase-only fraction) is only the fallback for a block without plan counts.
|
|
3247
3537
|
const intentPercent = intent['percent'];
|
|
3248
3538
|
if (typeof intentPercent === 'number' && Number.isFinite(intentPercent) && (completedMoved || (0, state_document_cjs_1.toFiniteNumber)(base['percent']) === null)) {
|
|
3249
|
-
|
|
3250
|
-
|
|
3539
|
+
const composed = (0, state_document_cjs_1.computeProgressPercent)((0, state_document_cjs_1.toFiniteNumber)(base['completed_plans']), (0, state_document_cjs_1.toFiniteNumber)(base['total_plans']), (0, state_document_cjs_1.toFiniteNumber)(base['completed_phases']), (0, state_document_cjs_1.toFiniteNumber)(base['total_phases']), SCOPE.COMPLETE);
|
|
3540
|
+
const nextPercent = composed !== null ? composed : intentPercent;
|
|
3541
|
+
if (!Object.is(base['percent'], nextPercent)) {
|
|
3542
|
+
base['percent'] = nextPercent;
|
|
3251
3543
|
changed = true;
|
|
3252
3544
|
}
|
|
3253
3545
|
}
|
|
@@ -3831,7 +4123,10 @@ function assertStatePreservationOptions(options, caller) {
|
|
|
3831
4123
|
throw err;
|
|
3832
4124
|
}
|
|
3833
4125
|
}
|
|
3834
|
-
function applyPostSyncPreservation(originalContent, transformedContent, syncedContent, statePath, options
|
|
4126
|
+
function applyPostSyncPreservation(originalContent, transformedContent, syncedContent, statePath, options,
|
|
4127
|
+
// #5626: the project's phase-id convention, resolved by the caller from its cwd (null: unset). Trailing and
|
|
4128
|
+
// optional so `options` stays the fifth parameter the options guard and its callers rely on.
|
|
4129
|
+
phaseIdConvention = null, directoryContext) {
|
|
3835
4130
|
assertStatePreservationOptions(options, 'applyPostSyncPreservation');
|
|
3836
4131
|
const { resync, authoritativeFm, deriveProgressKeys, divergedFields, explicitProgressField, preWriteState } = options;
|
|
3837
4132
|
// Bug #1230: delta heuristic — snapshot pre-transform body source fields so
|
|
@@ -3867,8 +4162,7 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
3867
4162
|
// mirroring buildStateFrontmatter's sessionBodyScope logic.
|
|
3868
4163
|
// A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
|
|
3869
4164
|
// Archive prose) must not interfere with the delta comparison.
|
|
3870
|
-
const
|
|
3871
|
-
const preSessionScope = preSessionMatch ?? preBody;
|
|
4165
|
+
const preSessionScope = sessionFieldScope(preBody);
|
|
3872
4166
|
const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
|
|
3873
4167
|
// ADR-1769 Phase 6 / #1743 / #1695: snapshot the body source for the curated
|
|
3874
4168
|
// current_phase_name (the `Phase:` line parseProsePhaseField harvests). When
|
|
@@ -3888,7 +4182,7 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
3888
4182
|
const preCurrentPositionScope = matchCurrentPositionSection(preBody) ?? preBody;
|
|
3889
4183
|
const preBodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(preBody, 'Current Plan');
|
|
3890
4184
|
const preBodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(preBody, 'Current Phase')
|
|
3891
|
-
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(preCurrentPositionScope, 'Phase')).phase;
|
|
4185
|
+
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(preCurrentPositionScope, 'Phase'), phaseIdConvention, directoryContext).phase;
|
|
3892
4186
|
const preBodyPausedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Paused At');
|
|
3893
4187
|
const preBodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(preBody, 'Last Activity')
|
|
3894
4188
|
?? (0, state_document_cjs_1.stateExtractField)(preBody, 'Last activity');
|
|
@@ -3903,8 +4197,7 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
3903
4197
|
const postBodyStatus = (0, state_document_cjs_1.stateExtractField)(postBody, 'Status');
|
|
3904
4198
|
// Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
|
|
3905
4199
|
// consistent with the pre-transform snapshot above and buildStateFrontmatter.
|
|
3906
|
-
const
|
|
3907
|
-
const postSessionScope = postSessionMatch ?? postBody;
|
|
4200
|
+
const postSessionScope = sessionFieldScope(postBody);
|
|
3908
4201
|
const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
|
|
3909
4202
|
// ADR-1769 Phase 6 / #1695: post-transform body Phase source for the
|
|
3910
4203
|
// current_phase_name delta comparison.
|
|
@@ -3914,7 +4207,7 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
3914
4207
|
const postCurrentPositionScope = matchCurrentPositionSection(postBody) ?? postBody;
|
|
3915
4208
|
const postBodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(postBody, 'Current Plan');
|
|
3916
4209
|
const postBodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(postBody, 'Current Phase')
|
|
3917
|
-
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(postCurrentPositionScope, 'Phase')).phase;
|
|
4210
|
+
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(postCurrentPositionScope, 'Phase'), phaseIdConvention, directoryContext).phase;
|
|
3918
4211
|
const postBodyPausedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Paused At');
|
|
3919
4212
|
const postBodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(postBody, 'Last Activity')
|
|
3920
4213
|
?? (0, state_document_cjs_1.stateExtractField)(postBody, 'Last activity');
|
|
@@ -4131,7 +4424,7 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
4131
4424
|
function syncAndPreserveStateMd(originalContent, transformedContent, statePath, cwd, options) {
|
|
4132
4425
|
assertStatePreservationOptions(options, 'syncAndPreserveStateMd');
|
|
4133
4426
|
const synced = syncStateFrontmatter(transformedContent, cwd, options.authoritativeFm);
|
|
4134
|
-
return applyPostSyncPreservation(originalContent, transformedContent, synced, statePath, options);
|
|
4427
|
+
return applyPostSyncPreservation(originalContent, transformedContent, synced, statePath, options, cwd ? resolvePhaseIdConvention(cwd) : null, cwd ? phaseDirectoryOptions(cwd) : undefined);
|
|
4135
4428
|
}
|
|
4136
4429
|
/**
|
|
4137
4430
|
* Atomic read-modify-write for STATE.md.
|
|
@@ -4554,7 +4847,7 @@ function computeChangedFrontmatterFields(snapshotFm, persistedFm, bodyDeltas) {
|
|
|
4554
4847
|
* the markdown-sectionizer seam (ADR-1372), never a hand-rolled regex.
|
|
4555
4848
|
* Existing callers pass no `sectionTargets` and resolve exactly as before.
|
|
4556
4849
|
*/
|
|
4557
|
-
function readStateTarget(fm, body, field, sectionTargets) {
|
|
4850
|
+
function readStateTarget(fm, body, field, sectionTargets, sessionScope = false) {
|
|
4558
4851
|
if (field === 'Current Position') {
|
|
4559
4852
|
const section = stateTransitionMod.sliceCurrentPositionSection(body);
|
|
4560
4853
|
return section !== null ? section.trim() : null;
|
|
@@ -4563,7 +4856,14 @@ function readStateTarget(fm, body, field, sectionTargets) {
|
|
|
4563
4856
|
const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, (h) => h.text === field);
|
|
4564
4857
|
return section !== null ? section.body.trim() : null;
|
|
4565
4858
|
}
|
|
4566
|
-
|
|
4859
|
+
// #4998 review: no session writer changes a fenced example line, so a session
|
|
4860
|
+
// field is read with fences blanked — an unchanged example above the live
|
|
4861
|
+
// line hid the write and reported it missing. With `sessionScope` (`state
|
|
4862
|
+
// update`, `state patch` and `state record-session`, which write inside the
|
|
4863
|
+
// session section) it is read there; otherwise from the whole document.
|
|
4864
|
+
const bodyValue = stateTransitionMod.isSessionScopedBodyField(field)
|
|
4865
|
+
? (0, state_document_cjs_1.stateExtractField)((0, state_document_cjs_1.readableBoldLabels)(sessionScope ? sessionFieldScope(body) : (0, state_document_cjs_1.maskFencedLines)(body)), field)
|
|
4866
|
+
: (0, state_document_cjs_1.stateExtractField)(body, field);
|
|
4567
4867
|
if (bodyValue !== null)
|
|
4568
4868
|
return bodyValue;
|
|
4569
4869
|
if (Object.prototype.hasOwnProperty.call(fm, field))
|
|
@@ -4591,7 +4891,7 @@ const NO_SECTION_TARGETS = new Set();
|
|
|
4591
4891
|
* `STATE_FIELD_ABSENT` sentinel + `stateFieldValuesDiffer`) instead of a
|
|
4592
4892
|
* second, parallel absence convention — one rule, reused, not duplicated.
|
|
4593
4893
|
*/
|
|
4594
|
-
function stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets) {
|
|
4894
|
+
function stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets, sessionScope = false) {
|
|
4595
4895
|
const isDeclaredDerivedLeaf = field.includes('.') && Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, field);
|
|
4596
4896
|
if (isDeclaredDerivedLeaf && !sectionTargets.has(field)) {
|
|
4597
4897
|
const before = resolveFrontmatterPath(snapshotFm, field);
|
|
@@ -4603,8 +4903,8 @@ function stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody
|
|
|
4603
4903
|
return false;
|
|
4604
4904
|
return stateFieldValuesDiffer(before, after);
|
|
4605
4905
|
}
|
|
4606
|
-
const before = readStateTarget(snapshotFm, snapshotBody, field, sectionTargets);
|
|
4607
|
-
const after = readStateTarget(persistedFm, persistedBody, field, sectionTargets);
|
|
4906
|
+
const before = readStateTarget(snapshotFm, snapshotBody, field, sectionTargets, sessionScope);
|
|
4907
|
+
const after = readStateTarget(persistedFm, persistedBody, field, sectionTargets, sessionScope);
|
|
4608
4908
|
if (before === null && after === null)
|
|
4609
4909
|
return false;
|
|
4610
4910
|
if (before === null || after === null)
|
|
@@ -4615,7 +4915,9 @@ function reconcileReportedFields(statePath, preWriteState, reported, divergedFie
|
|
|
4615
4915
|
// ADR-4629 §8.2 / Decision 4 (C2): names in `reported` that are declared
|
|
4616
4916
|
// `target: 'section'` assertions, measured as whole-section deltas. Omitted
|
|
4617
4917
|
// by every pre-C2 caller, whose output is unchanged.
|
|
4618
|
-
sectionTargets = NO_SECTION_TARGETS
|
|
4918
|
+
sectionTargets = NO_SECTION_TARGETS,
|
|
4919
|
+
// #4998 review: the caller writes session fields inside the session section.
|
|
4920
|
+
sessionScope = false) {
|
|
4619
4921
|
void divergedFields; // ADR-3473 §8.7 D18: out-param only, not a candidate source here.
|
|
4620
4922
|
if (preWriteState.fm === undefined || preWriteState.body === undefined)
|
|
4621
4923
|
return [];
|
|
@@ -4624,7 +4926,7 @@ sectionTargets = NO_SECTION_TARGETS) {
|
|
|
4624
4926
|
const persistedBody = stripFrontmatter(persisted);
|
|
4625
4927
|
const snapshotFm = preWriteState.fm;
|
|
4626
4928
|
const snapshotBody = preWriteState.body;
|
|
4627
|
-
const changed = (field) => stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets);
|
|
4929
|
+
const changed = (field) => stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets, sessionScope);
|
|
4628
4930
|
// Candidate set = `reported` ∪ every frontmatter key (dotted-leaf
|
|
4629
4931
|
// granularity) whose persisted value differs from the snapshot, minus the
|
|
4630
4932
|
// provenance exclusion. A frontmatter-diff-discovered field is mapped
|
|
@@ -4789,8 +5091,9 @@ function stateSectionScopeUnits(units, sectionTargets) {
|
|
|
4789
5091
|
* matching `readStateTarget`'s long-standing special case.
|
|
4790
5092
|
*/
|
|
4791
5093
|
function verifyStateWriteIntent(intent, preContent, postContent, statePath, bodyDeltas) {
|
|
4792
|
-
|
|
4793
|
-
const
|
|
5094
|
+
// #5165: the write lands scoped to what it changed (platformWriteSync reads the file it replaces).
|
|
5095
|
+
const pre = (0, shell_command_projection_cjs_1.normalizeContent)(statePath, preContent, { prior: preContent }).content;
|
|
5096
|
+
const post = (0, shell_command_projection_cjs_1.normalizeContent)(statePath, postContent, { prior: preContent }).content;
|
|
4794
5097
|
const preFm = extractFrontmatter(pre, statePath);
|
|
4795
5098
|
const postFm = extractFrontmatter(post, statePath);
|
|
4796
5099
|
const preBody = stripFrontmatter(pre);
|
|
@@ -4989,7 +5292,7 @@ function applyStateWriteIntent(statePath, intent, transformFn, cwd, options = {}
|
|
|
4989
5292
|
return unwritten(verification);
|
|
4990
5293
|
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, candidate);
|
|
4991
5294
|
const reread = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
4992
|
-
if (reread !== (0, shell_command_projection_cjs_1.normalizeContent)(statePath, candidate).content) {
|
|
5295
|
+
if (reread !== (0, shell_command_projection_cjs_1.normalizeContent)(statePath, candidate, { prior: pre }).content) {
|
|
4993
5296
|
return Object.freeze({
|
|
4994
5297
|
ok: false,
|
|
4995
5298
|
reasons: Object.freeze([STATE_WRITE_INTENT_FAILURE.REREAD_MISMATCH]),
|
|
@@ -5049,13 +5352,13 @@ function cmdStateJson(cwd, raw) {
|
|
|
5049
5352
|
// change untouched — and `milestone`/`milestone_name`
|
|
5050
5353
|
// (preserve-if-placeholder) are out of D3's scope entirely.
|
|
5051
5354
|
if (existingFm) {
|
|
5052
|
-
const sessionScope =
|
|
5355
|
+
const sessionScope = sessionFieldScope(body);
|
|
5053
5356
|
const positionScope = matchCurrentPositionSection(body) ?? body;
|
|
5054
5357
|
const bodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Stopped at');
|
|
5055
5358
|
const bodyPausedAt = (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Paused At');
|
|
5056
5359
|
const bodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(body, 'Phase');
|
|
5057
5360
|
const bodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase')
|
|
5058
|
-
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
|
|
5361
|
+
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase'), resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd)).phase;
|
|
5059
5362
|
const bodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(body, 'Current Plan');
|
|
5060
5363
|
const bodyStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status');
|
|
5061
5364
|
// #3836: mirrors applyPostSyncPreservation's own derivation (state.cts
|
|
@@ -5116,7 +5419,7 @@ function cmdStateJson(cwd, raw) {
|
|
|
5116
5419
|
* phase locator's own rule, so this agrees with `init` and `phase-plan-index`.
|
|
5117
5420
|
*/
|
|
5118
5421
|
function firstPlanToRun(cwd, phaseNumber) {
|
|
5119
|
-
const located = phaseLocatorMod.findPhaseInternal(cwd, String(phaseNumber));
|
|
5422
|
+
const located = phaseLocatorMod.findPhaseInternal(cwd, String(phaseNumber), undefined, phaseDirectoryOptions(cwd));
|
|
5120
5423
|
if (!located || !located.found)
|
|
5121
5424
|
return null;
|
|
5122
5425
|
const { plans, incomplete_plans: incomplete } = located;
|
|
@@ -5152,6 +5455,10 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
5152
5455
|
if (phaseNumber == null || String(phaseNumber).trim() === '') {
|
|
5153
5456
|
error('phase required (--phase <N>)');
|
|
5154
5457
|
}
|
|
5458
|
+
if (resolvePhaseIdConvention(cwd) === 'bracket'
|
|
5459
|
+
&& (String(phaseNumber).startsWith('[') || String(phaseNumber).includes('-'))) {
|
|
5460
|
+
error('state begin-phase --phase requires a bare phase number such as 02; qualified ids are not accepted.', ERROR_REASON.USAGE);
|
|
5461
|
+
}
|
|
5155
5462
|
const statePath = planningPaths(cwd).state;
|
|
5156
5463
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
5157
5464
|
output({ error: 'STATE.md not found' }, raw, undefined);
|
|
@@ -5215,7 +5522,22 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
5215
5522
|
if (result.data?.['resumed']) {
|
|
5216
5523
|
delete rmwOptions.authoritativeFm;
|
|
5217
5524
|
}
|
|
5218
|
-
|
|
5525
|
+
let transitioned = result.content;
|
|
5526
|
+
if (resolvePhaseIdConvention(cwd) === 'bracket') {
|
|
5527
|
+
const display = phaseDisplayFor(cwd, phaseNumber);
|
|
5528
|
+
if (result.updated.includes('Last Activity Description')) {
|
|
5529
|
+
transitioned = replaceStateBodyField(transitioned, statePath, 'Last Activity Description', `${display} execution started`);
|
|
5530
|
+
}
|
|
5531
|
+
if (result.updated.includes('Current focus')) {
|
|
5532
|
+
transitioned = replaceStateBodyField(transitioned, statePath, 'Current focus', intent.phaseName ? `${display} — ${intent.phaseName}` : display);
|
|
5533
|
+
}
|
|
5534
|
+
const today = clock_cjs_1.realClock.localToday();
|
|
5535
|
+
const activitySuffix = result.data?.['resumed']
|
|
5536
|
+
? 'execution resumed (wave continue)'
|
|
5537
|
+
: 'execution started';
|
|
5538
|
+
transitioned = replaceCurrentPositionFieldValue(transitioned, statePath, 'Last Activity', `${today} — Phase ${phaseNumber} ${activitySuffix}`, `${today} — ${display} ${activitySuffix}`);
|
|
5539
|
+
}
|
|
5540
|
+
return transitioned;
|
|
5219
5541
|
}, cwd, rmwOptions);
|
|
5220
5542
|
// ADR-3408 §8.4 (D4): reconcile `beginPhaseCore`'s own success list against
|
|
5221
5543
|
// the bytes actually persisted (fix(#3351) generalized) and fold in any
|
|
@@ -5527,12 +5849,23 @@ function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
5527
5849
|
if (!intent.phaseName) {
|
|
5528
5850
|
const preFm = extractFrontmatter(content, statePath);
|
|
5529
5851
|
const curatedName = preFm['current_phase_name'];
|
|
5530
|
-
|
|
5852
|
+
// #5369: a status fragment a pre-fix write left as the name is not curated; do not reassert it.
|
|
5853
|
+
if (typeof curatedName === 'string' && curatedName.trim().length > 0 && !phaseIdMod.isPlannedPhaseStatusPhrase(curatedName)) {
|
|
5531
5854
|
rmwOptions.authoritativeFm = { current_phase_name: curatedName };
|
|
5532
5855
|
}
|
|
5533
5856
|
}
|
|
5534
5857
|
const result = transitionCore(content, intent, deps);
|
|
5535
5858
|
precomputedUpdated = result.updated;
|
|
5859
|
+
if (resolvePhaseIdConvention(cwd) === 'bracket') {
|
|
5860
|
+
const display = phaseDisplayFor(cwd, phaseNumber);
|
|
5861
|
+
let transitioned = result.content;
|
|
5862
|
+
if (result.updated.includes('Last Activity Description')) {
|
|
5863
|
+
transitioned = replaceStateBodyField(transitioned, statePath, 'Last Activity Description', `${display} planning complete — ${planCount || '?'} plans ready`);
|
|
5864
|
+
}
|
|
5865
|
+
const today = clock_cjs_1.realClock.localToday();
|
|
5866
|
+
transitioned = replaceCurrentPositionFieldValue(transitioned, statePath, 'Last Activity', `${today} — Phase ${phaseNumber} planning complete`, `${today} — ${display} planning complete`);
|
|
5867
|
+
return transitioned;
|
|
5868
|
+
}
|
|
5536
5869
|
return result.content;
|
|
5537
5870
|
}, cwd, rmwOptions);
|
|
5538
5871
|
// ADR-3408 §8.4 (D4): reconcile `plannedPhaseCore`'s own success list
|
|
@@ -5756,7 +6089,8 @@ function cmdStateValidate(cwd, raw, opts = {}) {
|
|
|
5756
6089
|
const { fm, body, scope: initialScope } = readStateFrontmatterScoped(content, statePath);
|
|
5757
6090
|
const scope = initialScope;
|
|
5758
6091
|
const status = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'status', 'Status').value || '';
|
|
5759
|
-
const
|
|
6092
|
+
const directoryContext = phaseDirectoryOptions(cwd);
|
|
6093
|
+
const resolvedPhase = resolveStatePhase(fm, body, resolvePhaseIdConvention(cwd), directoryContext);
|
|
5760
6094
|
const currentPhase = resolvedPhase.phase;
|
|
5761
6095
|
const totalPlansRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_plans_in_phase', 'Total Plans in Phase').value;
|
|
5762
6096
|
const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
|
|
@@ -5766,8 +6100,8 @@ function cmdStateValidate(cwd, raw, opts = {}) {
|
|
|
5766
6100
|
emit({ valid: false, warnings, scope });
|
|
5767
6101
|
return;
|
|
5768
6102
|
}
|
|
5769
|
-
const selectedPhaseKey = phaseKeyFromToken(currentPhase);
|
|
5770
|
-
if (Object.values(resolvedPhase.sources).some(source => source !== null && phaseKeyFromToken(source) !== selectedPhaseKey)) {
|
|
6103
|
+
const selectedPhaseKey = phaseKeyFromToken(currentPhase, directoryContext);
|
|
6104
|
+
if (Object.values(resolvedPhase.sources).some(source => source !== null && phaseKeyFromToken(source, directoryContext) !== selectedPhaseKey)) {
|
|
5771
6105
|
warnings.push(stateDiagnostic('S003', SEVERITY.WARNING, `Phase reference conflict: validating authoritative phase ${currentPhase}; align STATE.md phase sources`, 'Align STATE.md phase sources (frontmatter, Current Phase, Current Position Phase) on one phase'));
|
|
5772
6106
|
}
|
|
5773
6107
|
if (!node_fs_1.default.existsSync(phasesDir)) {
|
|
@@ -5796,7 +6130,7 @@ function cmdStateValidate(cwd, raw, opts = {}) {
|
|
|
5796
6130
|
let phaseDirPath;
|
|
5797
6131
|
try {
|
|
5798
6132
|
const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
5799
|
-
const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name, validateConvention) === selectedPhaseKey);
|
|
6133
|
+
const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name, validateConvention, directoryContext) === selectedPhaseKey);
|
|
5800
6134
|
if (!phaseDir) {
|
|
5801
6135
|
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'));
|
|
5802
6136
|
emit({ valid: false, warnings, scope });
|
|
@@ -5954,6 +6288,7 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
5954
6288
|
// write branch can surface it identically.
|
|
5955
6289
|
const existingSyncFm = extractFrontmatter(content, statePath);
|
|
5956
6290
|
const syncFrontmatterWasUnparseable = isUnparseableFrontmatter(existingSyncFm);
|
|
6291
|
+
let directoryContext = phaseDirectoryOptions(cwd, undefined, '');
|
|
5957
6292
|
const changes = [];
|
|
5958
6293
|
let modified = content;
|
|
5959
6294
|
const phasesDir = planningPaths(cwd).phases;
|
|
@@ -5973,8 +6308,9 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
5973
6308
|
const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'));
|
|
5974
6309
|
if (roadmapRaw !== null) {
|
|
5975
6310
|
syncRoadmapRaw = roadmapRaw;
|
|
6311
|
+
directoryContext = { ...directoryContext, roadmap: roadmapRaw };
|
|
5976
6312
|
syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
|
5977
|
-
syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope, syncConvention);
|
|
6313
|
+
syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope, syncConvention, directoryContext);
|
|
5978
6314
|
}
|
|
5979
6315
|
}
|
|
5980
6316
|
catch { /* fall through: no roadmap scope → no retired exclusion */ }
|
|
@@ -6011,7 +6347,7 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
6011
6347
|
entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
|
|
6012
6348
|
.filter(e => e.isDirectory())
|
|
6013
6349
|
.map(e => e.name)
|
|
6014
|
-
.filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name, syncConvention))))
|
|
6350
|
+
.filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name, syncConvention, directoryContext))))
|
|
6015
6351
|
.filter(name => syncMilestonePhaseDirs === null || syncMilestonePhaseDirs.has(name))
|
|
6016
6352
|
.sort();
|
|
6017
6353
|
}
|
|
@@ -6079,7 +6415,7 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
6079
6415
|
// pre-existing legacy sync behavior while #3185 remains the read-path owner.
|
|
6080
6416
|
let syncTotalPhases = null;
|
|
6081
6417
|
const roadmapPhaseCount = syncRoadmapScope !== null
|
|
6082
|
-
? countRoadmapPhaseHeadings(syncRoadmapScope, syncConvention, syncRetiredPhaseNums, syncConvention === 'bracket')
|
|
6418
|
+
? countRoadmapPhaseHeadings(syncRoadmapScope, syncConvention, syncRetiredPhaseNums, syncConvention === 'bracket', directoryContext)
|
|
6083
6419
|
: 0;
|
|
6084
6420
|
if (roadmapPhaseCount > 0) {
|
|
6085
6421
|
syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
|
|
@@ -6210,7 +6546,7 @@ function cmdStatePrune(cwd, options, raw) {
|
|
|
6210
6546
|
const body = stripFrontmatter(rawState);
|
|
6211
6547
|
// Prune needs an integer cutoff, so it parses the resolved id itself; a
|
|
6212
6548
|
// non-numeric or absent id lands on 0 and prune bails, as before.
|
|
6213
|
-
const currentPhase = parseInt(String(resolveCurrentPhaseId(fm, body)), 10) || 0;
|
|
6549
|
+
const currentPhase = parseInt(String(resolveCurrentPhaseId(fm, body, resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd))), 10) || 0;
|
|
6214
6550
|
const cutoff = currentPhase - keepRecent;
|
|
6215
6551
|
if (cutoff <= 0) {
|
|
6216
6552
|
emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false');
|
|
@@ -6435,7 +6771,7 @@ function cmdStateRebuild(cwd, options, raw) {
|
|
|
6435
6771
|
* that the phase execution is finished and the project is ready for the next phase.
|
|
6436
6772
|
* Implements the `gsd state complete-phase` subcommand (issue #2735).
|
|
6437
6773
|
*/
|
|
6438
|
-
function resolvePhaseIdForCompletePhase(fm, body, overridePhase) {
|
|
6774
|
+
function resolvePhaseIdForCompletePhase(fm, body, overridePhase, directoryContext) {
|
|
6439
6775
|
// #3187: route through the single #1760 fallback-chain owner (fm scalar
|
|
6440
6776
|
// then body field) instead of two raw stateExtractField calls on
|
|
6441
6777
|
// frontmatter-blind content — a STATE.md whose phase lives only in
|
|
@@ -6453,7 +6789,7 @@ function resolvePhaseIdForCompletePhase(fm, body, overridePhase) {
|
|
|
6453
6789
|
// "Phase 0.5 complete". A canonical token at the start of the value
|
|
6454
6790
|
// (3, 03, 3A, 3.3, 10.2, "3 of 5", "1 — Setup") is preserved; a milestone
|
|
6455
6791
|
// closure line yields null, so the caller's "unable to resolve" guard fires.
|
|
6456
|
-
return parsePhaseFromProse(candidate).phase;
|
|
6792
|
+
return parsePhaseFromProse(candidate, undefined, directoryContext).phase;
|
|
6457
6793
|
}
|
|
6458
6794
|
function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
6459
6795
|
const statePath = planningPaths(cwd).state;
|
|
@@ -6485,7 +6821,8 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
6485
6821
|
output({ error: 'Unable to read STATE.md frontmatter; refusing to run complete-phase to avoid a destructive rollback (#3489). Fix or remove the malformed frontmatter and retry.' }, raw, undefined);
|
|
6486
6822
|
return;
|
|
6487
6823
|
}
|
|
6488
|
-
const
|
|
6824
|
+
const directoryContext = phaseDirectoryOptions(cwd);
|
|
6825
|
+
const resolvedPhase = resolvePhaseIdForCompletePhase(fm, body, overridePhase, directoryContext);
|
|
6489
6826
|
if (!resolvedPhase || /^phase$/i.test(resolvedPhase)) {
|
|
6490
6827
|
output({ error: 'Unable to resolve current phase. Pass an explicit phase: state complete-phase --phase <N>' }, raw, undefined);
|
|
6491
6828
|
return;
|
|
@@ -6502,7 +6839,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
6502
6839
|
const existingCurrentPhaseRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value || '';
|
|
6503
6840
|
// #2125: same canonical parser as resolvePhaseIdForCompletePhase so the two
|
|
6504
6841
|
// sites cannot diverge on the token they extract.
|
|
6505
|
-
const existingCurrentPhase = parsePhaseFromProse(existingCurrentPhaseRaw).phase;
|
|
6842
|
+
const existingCurrentPhase = parsePhaseFromProse(existingCurrentPhaseRaw, undefined, directoryContext).phase;
|
|
6506
6843
|
if (existingCurrentPhase && existingCurrentPhase !== resolvedPhase) {
|
|
6507
6844
|
output({ updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op' }, raw, 'false');
|
|
6508
6845
|
return;
|
|
@@ -6535,6 +6872,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
6535
6872
|
const rmwOptions = { divergedFields, preWriteState };
|
|
6536
6873
|
const wrote = readModifyWriteStateMd(statePath, (content) => {
|
|
6537
6874
|
const currentPhase = resolvedPhase;
|
|
6875
|
+
const currentPhaseDisplay = phaseDisplayFor(cwd, currentPhase);
|
|
6538
6876
|
// Bug #1255: operate on body only so the YAML frontmatter `status:` key
|
|
6539
6877
|
// cannot shadow the body Status field (pipe-table or inline).
|
|
6540
6878
|
//
|
|
@@ -6564,7 +6902,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
6564
6902
|
updated.push({ kind: 'field', name: 'Last Activity' });
|
|
6565
6903
|
}
|
|
6566
6904
|
// Update Last Activity Description
|
|
6567
|
-
const activityDesc =
|
|
6905
|
+
const activityDesc = `${currentPhaseDisplay} marked complete`;
|
|
6568
6906
|
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
|
|
6569
6907
|
if (result) {
|
|
6570
6908
|
body = result;
|
|
@@ -6613,14 +6951,14 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
6613
6951
|
posBody = replaced;
|
|
6614
6952
|
}
|
|
6615
6953
|
// Update Last activity line if present
|
|
6616
|
-
const newActivity = `Last activity: ${today} —
|
|
6954
|
+
const newActivity = `Last activity: ${today} — ${currentPhaseDisplay} marked complete`;
|
|
6617
6955
|
if (/^Last activity:/im.test(posBody)) {
|
|
6618
6956
|
posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
|
|
6619
6957
|
}
|
|
6620
6958
|
else {
|
|
6621
6959
|
// Pipe-table format in Current Position (#1255)
|
|
6622
6960
|
// Value must match the inline branch (date + narrative), not bare date.
|
|
6623
|
-
const activityValue = `${today} —
|
|
6961
|
+
const activityValue = `${today} — ${currentPhaseDisplay} marked complete`;
|
|
6624
6962
|
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
|
|
6625
6963
|
?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
|
|
6626
6964
|
if (replaced !== null)
|
|
@@ -6729,6 +7067,9 @@ module.exports = {
|
|
|
6729
7067
|
cmdStateBeginPhase,
|
|
6730
7068
|
cmdStatePlannedPhase,
|
|
6731
7069
|
cmdStateCompletePhase,
|
|
7070
|
+
// #4304: the canonical write-path current-phase ladder, read by the bracket
|
|
7071
|
+
// `phase remove` refusal before it deletes or renumbers that phase.
|
|
7072
|
+
resolveCurrentPhaseId,
|
|
6732
7073
|
cmdStateValidate,
|
|
6733
7074
|
cmdStateSync,
|
|
6734
7075
|
cmdStatePrune,
|