@opengsd/gsd-core 1.19.0 → 1.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.compact.md +5 -5
- package/agents/gsd-code-fixer.md +9 -4
- 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/bin/install.js +36 -40
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/new-project.md +2 -2
- package/commands/gsd/update.md +5 -2
- package/gsd-core/bin/gsd-tools.cjs +177 -93
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
- package/gsd-core/bin/lib/capability-registry.cjs +284 -93
- package/gsd-core/bin/lib/capability-validator.cjs +15 -0
- package/gsd-core/bin/lib/commands.cjs +46 -17
- package/gsd-core/bin/lib/config-loader.cjs +3 -3
- package/gsd-core/bin/lib/core-utils.cjs +11 -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 +1 -1
- 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/git-info-exclude.cjs +1 -6
- package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
- 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/init.cjs +181 -68
- package/gsd-core/bin/lib/install-engine.cjs +9 -9
- package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
- package/gsd-core/bin/lib/install-profiles.cjs +4 -2
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -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.cjs +18 -50
- 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 +627 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
- package/gsd-core/bin/lib/phase-locator.cjs +328 -19
- package/gsd-core/bin/lib/phase.cjs +3039 -666
- 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 +12 -7
- package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
- package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
- package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +759 -144
- package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
- package/gsd-core/bin/lib/roadmap.cjs +358 -106
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
- package/gsd-core/bin/lib/smart-entry.cjs +22 -11
- package/gsd-core/bin/lib/state-contract.cjs +11 -7
- package/gsd-core/bin/lib/state-document.cjs +332 -23
- package/gsd-core/bin/lib/state-transition.cjs +51 -15
- package/gsd-core/bin/lib/state.cjs +499 -180
- 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/verify.cjs +2 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +27 -21
- package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
- package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
- package/gsd-core/references/cicd-review-patterns.md +45 -0
- package/gsd-core/references/necessity-review.md +48 -0
- package/gsd-core/workflows/code-review-fix.md +46 -5
- package/gsd-core/workflows/code-review.md +3 -0
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
- package/gsd-core/workflows/plan-phase.md +1 -1
- package/gsd-core/workflows/pr-branch.md +2 -0
- package/gsd-core/workflows/reapply-patches.md +104 -0
- package/gsd-core/workflows/review.md +54 -141
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +57 -9
- package/gsd-core/workflows/ship.md +12 -0
- package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
- package/hooks/dist/gsd-graphify-update.sh +6 -0
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/gsd-worktree-path-guard.js +4 -4
- package/hooks/dist/lib/git-probe.js +16 -0
- package/hooks/dist/lib/isolation-sentinel.js +77 -2
- package/hooks/gsd-agent-isolation-guard.js +1 -1
- package/hooks/gsd-cursor-subagent-start.js +1 -1
- package/hooks/gsd-graphify-update.sh +6 -0
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-windsurf-pre-write.js +4 -4
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/gsd-worktree-path-guard.js +4 -4
- package/hooks/lib/git-probe.js +16 -0
- package/hooks/lib/isolation-sentinel.js +77 -2
- package/package.json +3 -3
- package/pi/package-agents/gsd-code-fixer.md +9 -4
- package/pi/package-agents/gsd-code-reviewer.md +53 -14
- package/pi/package-agents/gsd-executor.md +8 -4
- package/pi/package-agents/manifest.json +3 -3
- package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
- package/pi/package-skills/gsd-update/SKILL.md +4 -1
- package/scripts/changeset/lint.cjs +28 -3
- package/scripts/ci-next-health.cjs +246 -23
- package/scripts/docs-guard-registry.cjs +2 -0
- package/scripts/gen-features.cjs +14 -2
- package/scripts/lib/drift-scan.cjs +27 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +10 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +15 -0
- package/scripts/lint-phase-enumeration-drift.cjs +8 -4
- package/scripts/lint-roadmap-progress-drift.cjs +421 -0
- package/scripts/merge-commit-pr.cjs +186 -0
- package/scripts/mutation-matrix.cjs +23 -1
- package/scripts/release-ancestry.cjs +86 -0
- package/scripts/release-pipeline-pr.cjs +92 -0
- package/scripts/require-issue-link-policy.cjs +18 -9
- package/scripts/sync-next-version.cjs +48 -7
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-new-project/SKILL.md +1 -1
- package/skills/gsd-update/SKILL.md +5 -2
- package/vscode/package.json +1 -1
|
@@ -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,
|
|
32
|
+
const { parsePhaseFromProse, phaseNumberTokenSource, 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'), resolvePhaseIdConvention(cwd)).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), resolvePhaseIdConvention(cwd)) ?? 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), resolvePhaseIdConvention(cwd)) ?? 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,7 +2266,7 @@ function preferNewerLastActivity(existingFm, derivedFm) {
|
|
|
1995
2266
|
derivedFm['last_activity'] = exRaw;
|
|
1996
2267
|
}
|
|
1997
2268
|
}
|
|
1998
|
-
function parseProsePhaseField(value, convention) {
|
|
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"
|
|
@@ -2003,19 +2274,19 @@ function parseProsePhaseField(value, convention) {
|
|
|
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.
|
|
2006
|
-
return parsePhaseFromProse(value, convention);
|
|
2277
|
+
return parsePhaseFromProse(value, convention, directoryContext);
|
|
2007
2278
|
}
|
|
2008
|
-
function resolveStatePhase(fm, body, convention) {
|
|
2279
|
+
function resolveStatePhase(fm, body, convention, directoryContext) {
|
|
2009
2280
|
const currentPositionScope = matchCurrentPositionSection(body) ?? body;
|
|
2010
2281
|
const frontmatterRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', null).value;
|
|
2011
2282
|
const legacyRaw = (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Current Phase').value;
|
|
2012
2283
|
const currentPositionRaw = (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Phase').value;
|
|
2013
2284
|
const sources = {
|
|
2014
|
-
frontmatter: parseProsePhaseField(frontmatterRaw, convention).phase,
|
|
2015
|
-
legacy_current_phase: parseProsePhaseField(legacyRaw, convention).phase,
|
|
2016
|
-
current_position_phase: parseProsePhaseField(currentPositionRaw, convention).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,
|
|
2017
2288
|
};
|
|
2018
|
-
const prosePhase = parseProsePhaseField(currentPositionRaw, convention);
|
|
2289
|
+
const prosePhase = parseProsePhaseField(currentPositionRaw, convention, directoryContext);
|
|
2019
2290
|
return {
|
|
2020
2291
|
phase: sources.frontmatter ?? sources.legacy_current_phase ?? sources.current_position_phase,
|
|
2021
2292
|
name: (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase_name', null).value
|
|
@@ -2070,9 +2341,9 @@ function resolveStatePhase(fm, body, convention) {
|
|
|
2070
2341
|
* Reconcile the two only by giving `resolveStatePhase` an explicit scope
|
|
2071
2342
|
* parameter — never by pointing this at it and dropping the difference.
|
|
2072
2343
|
*/
|
|
2073
|
-
function resolveCurrentPhaseId(fm, body, convention) {
|
|
2344
|
+
function resolveCurrentPhaseId(fm, body, convention, directoryContext) {
|
|
2074
2345
|
const positionSection = sliceCurrentPositionSection(body);
|
|
2075
|
-
const prosePhase = positionSection !== null ? parseProsePhaseField((0, state_document_cjs_1.stateFieldValue)(fm, positionSection, null, 'Phase').value, convention).phase : null;
|
|
2346
|
+
const prosePhase = positionSection !== null ? parseProsePhaseField((0, state_document_cjs_1.stateFieldValue)(fm, positionSection, null, 'Phase').value, convention, directoryContext).phase : null;
|
|
2076
2347
|
return (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value ?? prosePhase;
|
|
2077
2348
|
}
|
|
2078
2349
|
function parseProseLastActivityField(value) {
|
|
@@ -2110,7 +2381,8 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
2110
2381
|
// so it is scopeable exactly like Stopped At under ## Session. Fall back to
|
|
2111
2382
|
// full-body search only when no ## Current Position section exists, so files
|
|
2112
2383
|
// with no section heading keep their current behaviour.
|
|
2113
|
-
const
|
|
2384
|
+
const directoryContext = phaseDirectoryOptions(cwd);
|
|
2385
|
+
const resolvedPhase = resolveStatePhase(fm, body, resolvePhaseIdConvention(cwd), directoryContext);
|
|
2114
2386
|
const currentPhase = resolvedPhase.phase;
|
|
2115
2387
|
const currentPhaseName = resolvedPhase.name;
|
|
2116
2388
|
const totalPhasesRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_phases', 'Total Phases').value;
|
|
@@ -2126,7 +2398,7 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
2126
2398
|
// preferNewerLastActivity and the write seam in buildStateFrontmatter). The
|
|
2127
2399
|
// write seam already scopes it to ## Session; this read seam must agree, so a
|
|
2128
2400
|
// stale "Paused At:" in a Session Continuity Archive cannot win here either.
|
|
2129
|
-
const sessionScope =
|
|
2401
|
+
const sessionScope = sessionFieldScope(body);
|
|
2130
2402
|
const pausedAt = (0, state_document_cjs_1.stateFieldValue)(fm, sessionScope, 'paused_at', 'Paused At').value;
|
|
2131
2403
|
// Parse numeric fields
|
|
2132
2404
|
const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
|
|
@@ -2169,7 +2441,8 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
2169
2441
|
// `## Session Continuity` heading. See matchSessionSection for the anchoring.
|
|
2170
2442
|
const sessionMatch = matchSessionSection(body);
|
|
2171
2443
|
if (sessionMatch !== null) {
|
|
2172
|
-
|
|
2444
|
+
// #4998: fenced example lines are not fields (as for sessionFieldScope).
|
|
2445
|
+
const sessionSection = (0, state_document_cjs_1.maskFencedLines)(sessionMatch);
|
|
2173
2446
|
// Accept both `**Last Date:**` (canonical template form) and `**Last session:**`
|
|
2174
2447
|
// (the form written by the DWIM auto-create / normalize path added for #944).
|
|
2175
2448
|
const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i)
|
|
@@ -2280,7 +2553,7 @@ function isMilestoneBounded(roadmapRaw, milestone, convention) {
|
|
|
2280
2553
|
* decimal, and project-code IDs are detected alike. Returns canonical keys
|
|
2281
2554
|
* (see phaseKeyFromToken).
|
|
2282
2555
|
*/
|
|
2283
|
-
function extractRetiredPhaseNumbers(scope, convention) {
|
|
2556
|
+
function extractRetiredPhaseNumbers(scope, convention, directoryContext) {
|
|
2284
2557
|
const retired = new Set();
|
|
2285
2558
|
const isChecklistOrHeading = /^\s*(?:[-*+]\s*\[[ xX]\]|#{1,6}\s)/;
|
|
2286
2559
|
// #612: the retirement filter has to widen with the counter it protects. The
|
|
@@ -2306,8 +2579,8 @@ function extractRetiredPhaseNumbers(scope, convention) {
|
|
|
2306
2579
|
while ((s = strikeSpan.exec(line)) !== null) {
|
|
2307
2580
|
const phaseRef = phaseRefRe.exec(s[1]);
|
|
2308
2581
|
// Require a digit so struck prose like ~~Phase Overview~~ is ignored.
|
|
2309
|
-
if (phaseRef && /\d/.test(phaseRef[1]))
|
|
2310
|
-
retired.add(phaseKeyFromToken(phaseRef[1]));
|
|
2582
|
+
if (phaseRef && (directoryContext?.phaseNaming === 'custom' || /\d/.test(phaseRef[1])))
|
|
2583
|
+
retired.add(phaseKeyFromToken(phaseRef[1], directoryContext));
|
|
2311
2584
|
}
|
|
2312
2585
|
}
|
|
2313
2586
|
return retired;
|
|
@@ -2351,7 +2624,7 @@ function extractRetiredPhaseNumbers(scope, convention) {
|
|
|
2351
2624
|
* retain the bracket-id and bare-0 rules. Sharing the implementation therefore
|
|
2352
2625
|
* cannot silently move either convention's total.
|
|
2353
2626
|
*/
|
|
2354
|
-
function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyConventionTokenSentinelRules) {
|
|
2627
|
+
function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyConventionTokenSentinelRules, directoryContext) {
|
|
2355
2628
|
let count = 0;
|
|
2356
2629
|
if (convention === 'bracket') {
|
|
2357
2630
|
const introSrc = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true);
|
|
@@ -2382,7 +2655,7 @@ function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyCon
|
|
|
2382
2655
|
continue;
|
|
2383
2656
|
// #1514: retired/folded phases are struck through in the ROADMAP;
|
|
2384
2657
|
// exclude them from the denominator (they can never be completed).
|
|
2385
|
-
if (retiredPhaseNums.has(phaseKeyFromToken(token)))
|
|
2658
|
+
if (retiredPhaseNums.has(phaseKeyFromToken(token, directoryContext)))
|
|
2386
2659
|
continue;
|
|
2387
2660
|
count++;
|
|
2388
2661
|
}
|
|
@@ -2398,7 +2671,7 @@ function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyCon
|
|
|
2398
2671
|
continue;
|
|
2399
2672
|
if (applyConventionTokenSentinelRules && isSentinelPhaseId(token))
|
|
2400
2673
|
continue;
|
|
2401
|
-
if (retiredPhaseNums.has(phaseKeyFromToken(token)))
|
|
2674
|
+
if (retiredPhaseNums.has(phaseKeyFromToken(token, directoryContext)))
|
|
2402
2675
|
continue;
|
|
2403
2676
|
count++;
|
|
2404
2677
|
}
|
|
@@ -2426,12 +2699,14 @@ options = {}) {
|
|
|
2426
2699
|
let roadmapScope = null;
|
|
2427
2700
|
let roadmapRaw = null;
|
|
2428
2701
|
let retiredPhaseNums = new Set();
|
|
2702
|
+
let directoryContext = phaseDirectoryOptions(cwd, undefined, '');
|
|
2429
2703
|
try {
|
|
2430
2704
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
2431
2705
|
roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
|
|
2432
2706
|
if (roadmapRaw !== null) {
|
|
2433
2707
|
roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
|
2434
|
-
|
|
2708
|
+
directoryContext = { ...directoryContext, roadmap: roadmapRaw };
|
|
2709
|
+
retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope, phaseConvention, directoryContext);
|
|
2435
2710
|
}
|
|
2436
2711
|
}
|
|
2437
2712
|
catch { /* fall through: no roadmap scope → no retired exclusion */ }
|
|
@@ -2458,7 +2733,7 @@ options = {}) {
|
|
|
2458
2733
|
// artifact; drop it from the disk phase set so it counts toward
|
|
2459
2734
|
// neither the denominator nor the numerator (mirrors the heading
|
|
2460
2735
|
// exclusion below). Project-code-aware via phaseKeyFromDir.
|
|
2461
|
-
if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir, phaseConvention)))
|
|
2736
|
+
if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir, phaseConvention, directoryContext)))
|
|
2462
2737
|
continue;
|
|
2463
2738
|
// #3185: dedup grouping routed through the canonical phaseKeyFromDir
|
|
2464
2739
|
// (src/phase-id.cts) instead of a local leading-digits regex that
|
|
@@ -2467,7 +2742,7 @@ options = {}) {
|
|
|
2467
2742
|
// so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on
|
|
2468
2743
|
// multi-segment milestone dirs. Same key surface used two lines
|
|
2469
2744
|
// above for the retiredPhaseNums exclusion, so both filters agree.
|
|
2470
|
-
const key = phaseKeyFromDir(dir, phaseConvention);
|
|
2745
|
+
const key = phaseKeyFromDir(dir, phaseConvention, directoryContext);
|
|
2471
2746
|
if (!seenPhaseNums.has(key)) {
|
|
2472
2747
|
seenPhaseNums.set(key, dir);
|
|
2473
2748
|
}
|
|
@@ -2498,6 +2773,7 @@ options = {}) {
|
|
|
2498
2773
|
roadmapScope,
|
|
2499
2774
|
roadmapRaw,
|
|
2500
2775
|
retiredPhaseNums,
|
|
2776
|
+
directoryContext,
|
|
2501
2777
|
};
|
|
2502
2778
|
}
|
|
2503
2779
|
/**
|
|
@@ -2532,7 +2808,7 @@ milestoneExplicitlyNull) {
|
|
|
2532
2808
|
// section overwrites current_phase here, and the next read surfaces it. Fall
|
|
2533
2809
|
// back to full-body search when no ## Current Position section exists.
|
|
2534
2810
|
const currentPositionScope = matchCurrentPositionSection(bodyContent) ?? bodyContent;
|
|
2535
|
-
const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(currentPositionScope, 'Phase'), cwd ? resolvePhaseIdConvention(cwd) : null);
|
|
2811
|
+
const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(currentPositionScope, 'Phase'), cwd ? resolvePhaseIdConvention(cwd) : null, cwd ? phaseDirectoryOptions(cwd) : undefined);
|
|
2536
2812
|
const currentPhase = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase') ?? prosePhase.phase;
|
|
2537
2813
|
const currentPhaseName = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase Name') ?? prosePhase.name;
|
|
2538
2814
|
const currentPlan = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Plan');
|
|
@@ -2550,8 +2826,7 @@ milestoneExplicitlyNull) {
|
|
|
2550
2826
|
// Fall back to full-body search only when no ## Session section exists.
|
|
2551
2827
|
// #1101: prefer the canonical `## Session` block, falling back to the bootstrap
|
|
2552
2828
|
// `## Session Continuity` heading. See matchSessionSection for the anchoring.
|
|
2553
|
-
const
|
|
2554
|
-
const sessionBodyScope = sessionSectionMatch ?? bodyContent;
|
|
2829
|
+
const sessionBodyScope = sessionFieldScope(bodyContent);
|
|
2555
2830
|
const stoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped at');
|
|
2556
2831
|
// #2567: Paused At is a session field — scope it to ## Session too so a
|
|
2557
2832
|
// stale "Paused At:" line in an archive section cannot overwrite the value.
|
|
@@ -2631,7 +2906,7 @@ milestoneExplicitlyNull) {
|
|
|
2631
2906
|
// #5118 pre-write validation in `phase remove` reads exactly this set.
|
|
2632
2907
|
// #5038: an explicit-null `storedMilestone` is text, not a version;
|
|
2633
2908
|
// pass a real null so it never reaches the phase-dir version filter.
|
|
2634
|
-
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);
|
|
2635
2910
|
let diskTotalPlans = 0;
|
|
2636
2911
|
let diskTotalSummaries = 0;
|
|
2637
2912
|
let diskCompletedPhases = 0;
|
|
@@ -2669,7 +2944,7 @@ milestoneExplicitlyNull) {
|
|
|
2669
2944
|
// fence-aware bracket strategy with #3185's canonical legacy
|
|
2670
2945
|
// sentinel predicate for this read-path call.
|
|
2671
2946
|
const roadmapPhaseCount = roadmapScope !== null
|
|
2672
|
-
? countRoadmapPhaseHeadings(roadmapScope, phaseConvention, retiredPhaseNums, true)
|
|
2947
|
+
? countRoadmapPhaseHeadings(roadmapScope, phaseConvention, retiredPhaseNums, true, directoryContext)
|
|
2673
2948
|
: 0;
|
|
2674
2949
|
cached = (() => {
|
|
2675
2950
|
// #1761 read-path: mirror the cmdStateSync guard (#1794). When the
|
|
@@ -3851,7 +4126,7 @@ function assertStatePreservationOptions(options, caller) {
|
|
|
3851
4126
|
function applyPostSyncPreservation(originalContent, transformedContent, syncedContent, statePath, options,
|
|
3852
4127
|
// #5626: the project's phase-id convention, resolved by the caller from its cwd (null: unset). Trailing and
|
|
3853
4128
|
// optional so `options` stays the fifth parameter the options guard and its callers rely on.
|
|
3854
|
-
phaseIdConvention = null) {
|
|
4129
|
+
phaseIdConvention = null, directoryContext) {
|
|
3855
4130
|
assertStatePreservationOptions(options, 'applyPostSyncPreservation');
|
|
3856
4131
|
const { resync, authoritativeFm, deriveProgressKeys, divergedFields, explicitProgressField, preWriteState } = options;
|
|
3857
4132
|
// Bug #1230: delta heuristic — snapshot pre-transform body source fields so
|
|
@@ -3887,8 +4162,7 @@ phaseIdConvention = null) {
|
|
|
3887
4162
|
// mirroring buildStateFrontmatter's sessionBodyScope logic.
|
|
3888
4163
|
// A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
|
|
3889
4164
|
// Archive prose) must not interfere with the delta comparison.
|
|
3890
|
-
const
|
|
3891
|
-
const preSessionScope = preSessionMatch ?? preBody;
|
|
4165
|
+
const preSessionScope = sessionFieldScope(preBody);
|
|
3892
4166
|
const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
|
|
3893
4167
|
// ADR-1769 Phase 6 / #1743 / #1695: snapshot the body source for the curated
|
|
3894
4168
|
// current_phase_name (the `Phase:` line parseProsePhaseField harvests). When
|
|
@@ -3908,7 +4182,7 @@ phaseIdConvention = null) {
|
|
|
3908
4182
|
const preCurrentPositionScope = matchCurrentPositionSection(preBody) ?? preBody;
|
|
3909
4183
|
const preBodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(preBody, 'Current Plan');
|
|
3910
4184
|
const preBodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(preBody, 'Current Phase')
|
|
3911
|
-
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(preCurrentPositionScope, 'Phase'), phaseIdConvention).phase;
|
|
4185
|
+
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(preCurrentPositionScope, 'Phase'), phaseIdConvention, directoryContext).phase;
|
|
3912
4186
|
const preBodyPausedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Paused At');
|
|
3913
4187
|
const preBodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(preBody, 'Last Activity')
|
|
3914
4188
|
?? (0, state_document_cjs_1.stateExtractField)(preBody, 'Last activity');
|
|
@@ -3923,8 +4197,7 @@ phaseIdConvention = null) {
|
|
|
3923
4197
|
const postBodyStatus = (0, state_document_cjs_1.stateExtractField)(postBody, 'Status');
|
|
3924
4198
|
// Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
|
|
3925
4199
|
// consistent with the pre-transform snapshot above and buildStateFrontmatter.
|
|
3926
|
-
const
|
|
3927
|
-
const postSessionScope = postSessionMatch ?? postBody;
|
|
4200
|
+
const postSessionScope = sessionFieldScope(postBody);
|
|
3928
4201
|
const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
|
|
3929
4202
|
// ADR-1769 Phase 6 / #1695: post-transform body Phase source for the
|
|
3930
4203
|
// current_phase_name delta comparison.
|
|
@@ -3934,7 +4207,7 @@ phaseIdConvention = null) {
|
|
|
3934
4207
|
const postCurrentPositionScope = matchCurrentPositionSection(postBody) ?? postBody;
|
|
3935
4208
|
const postBodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(postBody, 'Current Plan');
|
|
3936
4209
|
const postBodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(postBody, 'Current Phase')
|
|
3937
|
-
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(postCurrentPositionScope, 'Phase'), phaseIdConvention).phase;
|
|
4210
|
+
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(postCurrentPositionScope, 'Phase'), phaseIdConvention, directoryContext).phase;
|
|
3938
4211
|
const postBodyPausedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Paused At');
|
|
3939
4212
|
const postBodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(postBody, 'Last Activity')
|
|
3940
4213
|
?? (0, state_document_cjs_1.stateExtractField)(postBody, 'Last activity');
|
|
@@ -4151,7 +4424,7 @@ phaseIdConvention = null) {
|
|
|
4151
4424
|
function syncAndPreserveStateMd(originalContent, transformedContent, statePath, cwd, options) {
|
|
4152
4425
|
assertStatePreservationOptions(options, 'syncAndPreserveStateMd');
|
|
4153
4426
|
const synced = syncStateFrontmatter(transformedContent, cwd, options.authoritativeFm);
|
|
4154
|
-
return applyPostSyncPreservation(originalContent, transformedContent, synced, statePath, options, cwd ? resolvePhaseIdConvention(cwd) : null);
|
|
4427
|
+
return applyPostSyncPreservation(originalContent, transformedContent, synced, statePath, options, cwd ? resolvePhaseIdConvention(cwd) : null, cwd ? phaseDirectoryOptions(cwd) : undefined);
|
|
4155
4428
|
}
|
|
4156
4429
|
/**
|
|
4157
4430
|
* Atomic read-modify-write for STATE.md.
|
|
@@ -4574,7 +4847,7 @@ function computeChangedFrontmatterFields(snapshotFm, persistedFm, bodyDeltas) {
|
|
|
4574
4847
|
* the markdown-sectionizer seam (ADR-1372), never a hand-rolled regex.
|
|
4575
4848
|
* Existing callers pass no `sectionTargets` and resolve exactly as before.
|
|
4576
4849
|
*/
|
|
4577
|
-
function readStateTarget(fm, body, field, sectionTargets) {
|
|
4850
|
+
function readStateTarget(fm, body, field, sectionTargets, sessionScope = false) {
|
|
4578
4851
|
if (field === 'Current Position') {
|
|
4579
4852
|
const section = stateTransitionMod.sliceCurrentPositionSection(body);
|
|
4580
4853
|
return section !== null ? section.trim() : null;
|
|
@@ -4583,7 +4856,14 @@ function readStateTarget(fm, body, field, sectionTargets) {
|
|
|
4583
4856
|
const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, (h) => h.text === field);
|
|
4584
4857
|
return section !== null ? section.body.trim() : null;
|
|
4585
4858
|
}
|
|
4586
|
-
|
|
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);
|
|
4587
4867
|
if (bodyValue !== null)
|
|
4588
4868
|
return bodyValue;
|
|
4589
4869
|
if (Object.prototype.hasOwnProperty.call(fm, field))
|
|
@@ -4611,7 +4891,7 @@ const NO_SECTION_TARGETS = new Set();
|
|
|
4611
4891
|
* `STATE_FIELD_ABSENT` sentinel + `stateFieldValuesDiffer`) instead of a
|
|
4612
4892
|
* second, parallel absence convention — one rule, reused, not duplicated.
|
|
4613
4893
|
*/
|
|
4614
|
-
function stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets) {
|
|
4894
|
+
function stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets, sessionScope = false) {
|
|
4615
4895
|
const isDeclaredDerivedLeaf = field.includes('.') && Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, field);
|
|
4616
4896
|
if (isDeclaredDerivedLeaf && !sectionTargets.has(field)) {
|
|
4617
4897
|
const before = resolveFrontmatterPath(snapshotFm, field);
|
|
@@ -4623,8 +4903,8 @@ function stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody
|
|
|
4623
4903
|
return false;
|
|
4624
4904
|
return stateFieldValuesDiffer(before, after);
|
|
4625
4905
|
}
|
|
4626
|
-
const before = readStateTarget(snapshotFm, snapshotBody, field, sectionTargets);
|
|
4627
|
-
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);
|
|
4628
4908
|
if (before === null && after === null)
|
|
4629
4909
|
return false;
|
|
4630
4910
|
if (before === null || after === null)
|
|
@@ -4635,7 +4915,9 @@ function reconcileReportedFields(statePath, preWriteState, reported, divergedFie
|
|
|
4635
4915
|
// ADR-4629 §8.2 / Decision 4 (C2): names in `reported` that are declared
|
|
4636
4916
|
// `target: 'section'` assertions, measured as whole-section deltas. Omitted
|
|
4637
4917
|
// by every pre-C2 caller, whose output is unchanged.
|
|
4638
|
-
sectionTargets = NO_SECTION_TARGETS
|
|
4918
|
+
sectionTargets = NO_SECTION_TARGETS,
|
|
4919
|
+
// #4998 review: the caller writes session fields inside the session section.
|
|
4920
|
+
sessionScope = false) {
|
|
4639
4921
|
void divergedFields; // ADR-3473 §8.7 D18: out-param only, not a candidate source here.
|
|
4640
4922
|
if (preWriteState.fm === undefined || preWriteState.body === undefined)
|
|
4641
4923
|
return [];
|
|
@@ -4644,7 +4926,7 @@ sectionTargets = NO_SECTION_TARGETS) {
|
|
|
4644
4926
|
const persistedBody = stripFrontmatter(persisted);
|
|
4645
4927
|
const snapshotFm = preWriteState.fm;
|
|
4646
4928
|
const snapshotBody = preWriteState.body;
|
|
4647
|
-
const changed = (field) => stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets);
|
|
4929
|
+
const changed = (field) => stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets, sessionScope);
|
|
4648
4930
|
// Candidate set = `reported` ∪ every frontmatter key (dotted-leaf
|
|
4649
4931
|
// granularity) whose persisted value differs from the snapshot, minus the
|
|
4650
4932
|
// provenance exclusion. A frontmatter-diff-discovered field is mapped
|
|
@@ -5070,13 +5352,13 @@ function cmdStateJson(cwd, raw) {
|
|
|
5070
5352
|
// change untouched — and `milestone`/`milestone_name`
|
|
5071
5353
|
// (preserve-if-placeholder) are out of D3's scope entirely.
|
|
5072
5354
|
if (existingFm) {
|
|
5073
|
-
const sessionScope =
|
|
5355
|
+
const sessionScope = sessionFieldScope(body);
|
|
5074
5356
|
const positionScope = matchCurrentPositionSection(body) ?? body;
|
|
5075
5357
|
const bodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Stopped at');
|
|
5076
5358
|
const bodyPausedAt = (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Paused At');
|
|
5077
5359
|
const bodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(body, 'Phase');
|
|
5078
5360
|
const bodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase')
|
|
5079
|
-
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase'), resolvePhaseIdConvention(cwd)).phase;
|
|
5361
|
+
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase'), resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd)).phase;
|
|
5080
5362
|
const bodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(body, 'Current Plan');
|
|
5081
5363
|
const bodyStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status');
|
|
5082
5364
|
// #3836: mirrors applyPostSyncPreservation's own derivation (state.cts
|
|
@@ -5137,7 +5419,7 @@ function cmdStateJson(cwd, raw) {
|
|
|
5137
5419
|
* phase locator's own rule, so this agrees with `init` and `phase-plan-index`.
|
|
5138
5420
|
*/
|
|
5139
5421
|
function firstPlanToRun(cwd, phaseNumber) {
|
|
5140
|
-
const located = phaseLocatorMod.findPhaseInternal(cwd, String(phaseNumber));
|
|
5422
|
+
const located = phaseLocatorMod.findPhaseInternal(cwd, String(phaseNumber), undefined, phaseDirectoryOptions(cwd));
|
|
5141
5423
|
if (!located || !located.found)
|
|
5142
5424
|
return null;
|
|
5143
5425
|
const { plans, incomplete_plans: incomplete } = located;
|
|
@@ -5173,6 +5455,10 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
5173
5455
|
if (phaseNumber == null || String(phaseNumber).trim() === '') {
|
|
5174
5456
|
error('phase required (--phase <N>)');
|
|
5175
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
|
+
}
|
|
5176
5462
|
const statePath = planningPaths(cwd).state;
|
|
5177
5463
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
5178
5464
|
output({ error: 'STATE.md not found' }, raw, undefined);
|
|
@@ -5236,7 +5522,22 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
5236
5522
|
if (result.data?.['resumed']) {
|
|
5237
5523
|
delete rmwOptions.authoritativeFm;
|
|
5238
5524
|
}
|
|
5239
|
-
|
|
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;
|
|
5240
5541
|
}, cwd, rmwOptions);
|
|
5241
5542
|
// ADR-3408 §8.4 (D4): reconcile `beginPhaseCore`'s own success list against
|
|
5242
5543
|
// the bytes actually persisted (fix(#3351) generalized) and fold in any
|
|
@@ -5555,6 +5856,16 @@ function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
5555
5856
|
}
|
|
5556
5857
|
const result = transitionCore(content, intent, deps);
|
|
5557
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
|
+
}
|
|
5558
5869
|
return result.content;
|
|
5559
5870
|
}, cwd, rmwOptions);
|
|
5560
5871
|
// ADR-3408 §8.4 (D4): reconcile `plannedPhaseCore`'s own success list
|
|
@@ -5778,7 +6089,8 @@ function cmdStateValidate(cwd, raw, opts = {}) {
|
|
|
5778
6089
|
const { fm, body, scope: initialScope } = readStateFrontmatterScoped(content, statePath);
|
|
5779
6090
|
const scope = initialScope;
|
|
5780
6091
|
const status = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'status', 'Status').value || '';
|
|
5781
|
-
const
|
|
6092
|
+
const directoryContext = phaseDirectoryOptions(cwd);
|
|
6093
|
+
const resolvedPhase = resolveStatePhase(fm, body, resolvePhaseIdConvention(cwd), directoryContext);
|
|
5782
6094
|
const currentPhase = resolvedPhase.phase;
|
|
5783
6095
|
const totalPlansRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_plans_in_phase', 'Total Plans in Phase').value;
|
|
5784
6096
|
const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
|
|
@@ -5788,8 +6100,8 @@ function cmdStateValidate(cwd, raw, opts = {}) {
|
|
|
5788
6100
|
emit({ valid: false, warnings, scope });
|
|
5789
6101
|
return;
|
|
5790
6102
|
}
|
|
5791
|
-
const selectedPhaseKey = phaseKeyFromToken(currentPhase);
|
|
5792
|
-
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)) {
|
|
5793
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'));
|
|
5794
6106
|
}
|
|
5795
6107
|
if (!node_fs_1.default.existsSync(phasesDir)) {
|
|
@@ -5818,7 +6130,7 @@ function cmdStateValidate(cwd, raw, opts = {}) {
|
|
|
5818
6130
|
let phaseDirPath;
|
|
5819
6131
|
try {
|
|
5820
6132
|
const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
5821
|
-
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);
|
|
5822
6134
|
if (!phaseDir) {
|
|
5823
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'));
|
|
5824
6136
|
emit({ valid: false, warnings, scope });
|
|
@@ -5976,6 +6288,7 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
5976
6288
|
// write branch can surface it identically.
|
|
5977
6289
|
const existingSyncFm = extractFrontmatter(content, statePath);
|
|
5978
6290
|
const syncFrontmatterWasUnparseable = isUnparseableFrontmatter(existingSyncFm);
|
|
6291
|
+
let directoryContext = phaseDirectoryOptions(cwd, undefined, '');
|
|
5979
6292
|
const changes = [];
|
|
5980
6293
|
let modified = content;
|
|
5981
6294
|
const phasesDir = planningPaths(cwd).phases;
|
|
@@ -5995,8 +6308,9 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
5995
6308
|
const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'));
|
|
5996
6309
|
if (roadmapRaw !== null) {
|
|
5997
6310
|
syncRoadmapRaw = roadmapRaw;
|
|
6311
|
+
directoryContext = { ...directoryContext, roadmap: roadmapRaw };
|
|
5998
6312
|
syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
|
5999
|
-
syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope, syncConvention);
|
|
6313
|
+
syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope, syncConvention, directoryContext);
|
|
6000
6314
|
}
|
|
6001
6315
|
}
|
|
6002
6316
|
catch { /* fall through: no roadmap scope → no retired exclusion */ }
|
|
@@ -6033,7 +6347,7 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
6033
6347
|
entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
|
|
6034
6348
|
.filter(e => e.isDirectory())
|
|
6035
6349
|
.map(e => e.name)
|
|
6036
|
-
.filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name, syncConvention))))
|
|
6350
|
+
.filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name, syncConvention, directoryContext))))
|
|
6037
6351
|
.filter(name => syncMilestonePhaseDirs === null || syncMilestonePhaseDirs.has(name))
|
|
6038
6352
|
.sort();
|
|
6039
6353
|
}
|
|
@@ -6072,7 +6386,7 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
6072
6386
|
if (completion.value.complete)
|
|
6073
6387
|
diskCompletedPhases++;
|
|
6074
6388
|
// Track the highest phase with incomplete plans (or any plans)
|
|
6075
|
-
const phaseMatch = dir.match(new RegExp(`^(${
|
|
6389
|
+
const phaseMatch = dir.match(new RegExp(`^(${phaseNumberTokenSource(syncConvention)})`, 'i'));
|
|
6076
6390
|
if (phaseMatch && plans > 0) {
|
|
6077
6391
|
if (summaries < plans) {
|
|
6078
6392
|
// Incomplete phase — this is likely the current one
|
|
@@ -6101,7 +6415,7 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
6101
6415
|
// pre-existing legacy sync behavior while #3185 remains the read-path owner.
|
|
6102
6416
|
let syncTotalPhases = null;
|
|
6103
6417
|
const roadmapPhaseCount = syncRoadmapScope !== null
|
|
6104
|
-
? countRoadmapPhaseHeadings(syncRoadmapScope, syncConvention, syncRetiredPhaseNums, syncConvention === 'bracket')
|
|
6418
|
+
? countRoadmapPhaseHeadings(syncRoadmapScope, syncConvention, syncRetiredPhaseNums, syncConvention === 'bracket', directoryContext)
|
|
6105
6419
|
: 0;
|
|
6106
6420
|
if (roadmapPhaseCount > 0) {
|
|
6107
6421
|
syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
|
|
@@ -6232,7 +6546,7 @@ function cmdStatePrune(cwd, options, raw) {
|
|
|
6232
6546
|
const body = stripFrontmatter(rawState);
|
|
6233
6547
|
// Prune needs an integer cutoff, so it parses the resolved id itself; a
|
|
6234
6548
|
// non-numeric or absent id lands on 0 and prune bails, as before.
|
|
6235
|
-
const currentPhase = parseInt(String(resolveCurrentPhaseId(fm, body, resolvePhaseIdConvention(cwd))), 10) || 0;
|
|
6549
|
+
const currentPhase = parseInt(String(resolveCurrentPhaseId(fm, body, resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd))), 10) || 0;
|
|
6236
6550
|
const cutoff = currentPhase - keepRecent;
|
|
6237
6551
|
if (cutoff <= 0) {
|
|
6238
6552
|
emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false');
|
|
@@ -6457,7 +6771,7 @@ function cmdStateRebuild(cwd, options, raw) {
|
|
|
6457
6771
|
* that the phase execution is finished and the project is ready for the next phase.
|
|
6458
6772
|
* Implements the `gsd state complete-phase` subcommand (issue #2735).
|
|
6459
6773
|
*/
|
|
6460
|
-
function resolvePhaseIdForCompletePhase(fm, body, overridePhase) {
|
|
6774
|
+
function resolvePhaseIdForCompletePhase(fm, body, overridePhase, directoryContext) {
|
|
6461
6775
|
// #3187: route through the single #1760 fallback-chain owner (fm scalar
|
|
6462
6776
|
// then body field) instead of two raw stateExtractField calls on
|
|
6463
6777
|
// frontmatter-blind content — a STATE.md whose phase lives only in
|
|
@@ -6475,7 +6789,7 @@ function resolvePhaseIdForCompletePhase(fm, body, overridePhase) {
|
|
|
6475
6789
|
// "Phase 0.5 complete". A canonical token at the start of the value
|
|
6476
6790
|
// (3, 03, 3A, 3.3, 10.2, "3 of 5", "1 — Setup") is preserved; a milestone
|
|
6477
6791
|
// closure line yields null, so the caller's "unable to resolve" guard fires.
|
|
6478
|
-
return parsePhaseFromProse(candidate).phase;
|
|
6792
|
+
return parsePhaseFromProse(candidate, undefined, directoryContext).phase;
|
|
6479
6793
|
}
|
|
6480
6794
|
function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
6481
6795
|
const statePath = planningPaths(cwd).state;
|
|
@@ -6507,7 +6821,8 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
6507
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);
|
|
6508
6822
|
return;
|
|
6509
6823
|
}
|
|
6510
|
-
const
|
|
6824
|
+
const directoryContext = phaseDirectoryOptions(cwd);
|
|
6825
|
+
const resolvedPhase = resolvePhaseIdForCompletePhase(fm, body, overridePhase, directoryContext);
|
|
6511
6826
|
if (!resolvedPhase || /^phase$/i.test(resolvedPhase)) {
|
|
6512
6827
|
output({ error: 'Unable to resolve current phase. Pass an explicit phase: state complete-phase --phase <N>' }, raw, undefined);
|
|
6513
6828
|
return;
|
|
@@ -6524,7 +6839,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
6524
6839
|
const existingCurrentPhaseRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value || '';
|
|
6525
6840
|
// #2125: same canonical parser as resolvePhaseIdForCompletePhase so the two
|
|
6526
6841
|
// sites cannot diverge on the token they extract.
|
|
6527
|
-
const existingCurrentPhase = parsePhaseFromProse(existingCurrentPhaseRaw).phase;
|
|
6842
|
+
const existingCurrentPhase = parsePhaseFromProse(existingCurrentPhaseRaw, undefined, directoryContext).phase;
|
|
6528
6843
|
if (existingCurrentPhase && existingCurrentPhase !== resolvedPhase) {
|
|
6529
6844
|
output({ updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op' }, raw, 'false');
|
|
6530
6845
|
return;
|
|
@@ -6557,6 +6872,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
6557
6872
|
const rmwOptions = { divergedFields, preWriteState };
|
|
6558
6873
|
const wrote = readModifyWriteStateMd(statePath, (content) => {
|
|
6559
6874
|
const currentPhase = resolvedPhase;
|
|
6875
|
+
const currentPhaseDisplay = phaseDisplayFor(cwd, currentPhase);
|
|
6560
6876
|
// Bug #1255: operate on body only so the YAML frontmatter `status:` key
|
|
6561
6877
|
// cannot shadow the body Status field (pipe-table or inline).
|
|
6562
6878
|
//
|
|
@@ -6586,7 +6902,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
6586
6902
|
updated.push({ kind: 'field', name: 'Last Activity' });
|
|
6587
6903
|
}
|
|
6588
6904
|
// Update Last Activity Description
|
|
6589
|
-
const activityDesc =
|
|
6905
|
+
const activityDesc = `${currentPhaseDisplay} marked complete`;
|
|
6590
6906
|
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
|
|
6591
6907
|
if (result) {
|
|
6592
6908
|
body = result;
|
|
@@ -6635,14 +6951,14 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
6635
6951
|
posBody = replaced;
|
|
6636
6952
|
}
|
|
6637
6953
|
// Update Last activity line if present
|
|
6638
|
-
const newActivity = `Last activity: ${today} —
|
|
6954
|
+
const newActivity = `Last activity: ${today} — ${currentPhaseDisplay} marked complete`;
|
|
6639
6955
|
if (/^Last activity:/im.test(posBody)) {
|
|
6640
6956
|
posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
|
|
6641
6957
|
}
|
|
6642
6958
|
else {
|
|
6643
6959
|
// Pipe-table format in Current Position (#1255)
|
|
6644
6960
|
// Value must match the inline branch (date + narrative), not bare date.
|
|
6645
|
-
const activityValue = `${today} —
|
|
6961
|
+
const activityValue = `${today} — ${currentPhaseDisplay} marked complete`;
|
|
6646
6962
|
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
|
|
6647
6963
|
?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
|
|
6648
6964
|
if (replaced !== null)
|
|
@@ -6751,6 +7067,9 @@ module.exports = {
|
|
|
6751
7067
|
cmdStateBeginPhase,
|
|
6752
7068
|
cmdStatePlannedPhase,
|
|
6753
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,
|
|
6754
7073
|
cmdStateValidate,
|
|
6755
7074
|
cmdStateSync,
|
|
6756
7075
|
cmdStatePrune,
|