@opengsd/gsd-core 1.5.0-rc.2 → 1.5.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-advisor-researcher.md +1 -1
- package/agents/gsd-assumptions-analyzer.md +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-code-reviewer.md +1 -1
- package/agents/gsd-codebase-mapper.md +1 -1
- package/agents/gsd-debugger.md +1 -1
- package/agents/gsd-doc-writer.md +1 -1
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +1 -1
- package/agents/gsd-integration-checker.md +1 -1
- package/agents/gsd-mempalace-curator.md +47 -0
- package/agents/gsd-nyquist-auditor.md +1 -0
- package/agents/gsd-phase-researcher.md +1 -1
- package/agents/gsd-plan-checker.md +1 -1
- package/agents/gsd-planner.md +1 -1
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.md +1 -1
- package/agents/gsd-roadmapper.md +55 -2
- package/agents/gsd-security-auditor.md +1 -0
- package/agents/gsd-ui-auditor.md +1 -1
- package/agents/gsd-ui-checker.md +1 -1
- package/agents/gsd-ui-researcher.md +1 -1
- package/agents/gsd-verifier.md +13 -2
- package/bin/install.js +61 -64
- package/commands/gsd/mempalace-capture.md +71 -0
- package/commands/gsd/mempalace-recall.md +102 -0
- package/commands/gsd/ns-context.md +4 -2
- package/commands/gsd/progress.md +2 -1
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +277 -95
- package/gsd-core/bin/lib/active-workstream-store.cjs +6 -0
- package/gsd-core/bin/lib/capability-activation.cjs +86 -0
- package/gsd-core/bin/lib/capability-registry.cjs +1468 -11
- package/gsd-core/bin/lib/capability-state.cjs +128 -21
- package/gsd-core/bin/lib/capability-writer.cjs +354 -0
- package/gsd-core/bin/lib/check-command-router.cjs +328 -1
- package/gsd-core/bin/lib/clusters.cjs +2 -0
- package/gsd-core/bin/lib/command-roster.cjs +19 -0
- package/gsd-core/bin/lib/commands.cjs +33 -10
- package/gsd-core/bin/lib/config-loader.cjs +7 -8
- package/gsd-core/bin/lib/config-schema.cjs +32 -3
- package/gsd-core/bin/lib/config.cjs +81 -26
- package/gsd-core/bin/lib/core.cjs +5 -2
- package/gsd-core/bin/lib/edge-probe.cjs +25 -2
- package/gsd-core/bin/lib/frontmatter.cjs +53 -1
- package/gsd-core/bin/lib/git-base-branch.cjs +194 -0
- package/gsd-core/bin/lib/init.cjs +36 -11
- package/gsd-core/bin/lib/install-profiles.cjs +57 -1
- package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +157 -16
- package/gsd-core/bin/lib/model-resolver.cjs +47 -5
- package/gsd-core/bin/lib/phase.cjs +99 -23
- package/gsd-core/bin/lib/plan-drift-guard.cjs +117 -0
- package/gsd-core/bin/lib/probe-core.cjs +117 -1
- package/gsd-core/bin/lib/profile-output.cjs +45 -4
- package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +138 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +13 -3
- package/gsd-core/bin/lib/roadmap.cjs +97 -7
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +1946 -0
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +54 -30
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +27 -19
- package/gsd-core/bin/lib/runtime-homes.cjs +26 -20
- package/gsd-core/bin/lib/state-command-router.cjs +15 -3
- package/gsd-core/bin/lib/state-document.cjs +46 -1
- package/gsd-core/bin/lib/state.cjs +461 -94
- package/gsd-core/bin/lib/verify.cjs +92 -8
- package/gsd-core/bin/lib/worktree-safety.cjs +2 -1
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -2
- package/gsd-core/bin/shared/config-schema.manifest.json +0 -18
- package/gsd-core/bin/shared/model-catalog.json +1 -0
- package/gsd-core/references/edge-probe.md +11 -0
- package/gsd-core/references/loop-hook-dispatch.md +61 -0
- package/gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json +14 -0
- package/gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json +4 -0
- package/gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json +32 -0
- package/gsd-core/references/prohibition-probe.md +248 -0
- package/gsd-core/templates/config.json +1 -1
- package/gsd-core/templates/spec.md +14 -0
- package/gsd-core/workflows/audit-milestone.md +5 -3
- package/gsd-core/workflows/autonomous.md +10 -5
- package/gsd-core/workflows/code-review-fix.md +9 -7
- package/gsd-core/workflows/code-review.md +8 -6
- package/gsd-core/workflows/complete-milestone.md +1 -5
- package/gsd-core/workflows/discuss-phase.md +14 -0
- package/gsd-core/workflows/execute-phase.md +86 -146
- package/gsd-core/workflows/execute-plan.md +21 -6
- package/gsd-core/workflows/help/modes/full.md +7 -1
- package/gsd-core/workflows/new-project.md +3 -3
- package/gsd-core/workflows/next.md +50 -2
- package/gsd-core/workflows/pause-work.md +7 -1
- package/gsd-core/workflows/plan-phase.md +91 -221
- package/gsd-core/workflows/plan-review-convergence.md +14 -4
- package/gsd-core/workflows/pr-branch.md +4 -2
- package/gsd-core/workflows/profile-user.md +3 -1
- package/gsd-core/workflows/progress.md +58 -1
- package/gsd-core/workflows/quick.md +12 -8
- package/gsd-core/workflows/resume-project.md +17 -1
- package/gsd-core/workflows/review.md +19 -2
- package/gsd-core/workflows/secure-phase.md +4 -2
- package/gsd-core/workflows/settings-advanced.md +2 -0
- package/gsd-core/workflows/settings.md +27 -1
- package/gsd-core/workflows/ship.md +58 -5
- package/gsd-core/workflows/spec-phase.md +75 -0
- package/gsd-core/workflows/validate-phase.md +4 -2
- package/gsd-core/workflows/verify-phase.md +14 -4
- package/gsd-core/workflows/verify-work.md +27 -11
- package/hooks/dist/gsd-ensure-canonical-path.js +305 -0
- package/hooks/dist/gsd-statusline.js +1 -1
- package/hooks/dist/managed-hooks-registry.cjs +1 -0
- package/hooks/gsd-ensure-canonical-path.js +305 -0
- package/hooks/gsd-statusline.js +1 -1
- package/hooks/hooks.json +1 -0
- package/hooks/managed-hooks-registry.cjs +1 -0
- package/package.json +5 -4
- package/scripts/affected-tests-lib.cjs +16 -4
- package/scripts/build-hooks.js +7 -0
- package/scripts/changeset/new.cjs +17 -3
- package/scripts/fix-slash-commands.cjs +15 -3
- package/scripts/gen-capability-registry.cjs +373 -49
- package/scripts/gen-inventory-manifest.cjs +1 -4
- package/scripts/gen-loop-host-contract.cjs +55 -0
- package/scripts/issue-version-gate.cjs +140 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +327 -0
- package/scripts/lint-allow-test-rule-refs.cjs +162 -0
- package/scripts/lint-test-file-count.allowlist.json +14 -0
- package/scripts/mutation-matrix.cjs +108 -7
- package/scripts/pr-target-policy.cjs +63 -0
- package/scripts/release-tarball-smoke.cjs +7 -1
- package/scripts/research-profiles.cjs +5 -5
- package/scripts/run-tests.cjs +178 -17
|
@@ -245,34 +245,76 @@ function updateCurrentPositionFields(content, fields) {
|
|
|
245
245
|
let posBody = posMatch[2];
|
|
246
246
|
const statusDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Status'];
|
|
247
247
|
const lastActivityDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
|
|
248
|
-
if (fields.status
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
248
|
+
if (fields.status) {
|
|
249
|
+
if (/^Status:/m.test(posBody)) {
|
|
250
|
+
// Inline format: Status: value — only replace when the existing value is a
|
|
251
|
+
// known template default (Knuth invariant: preserve executor-authored values).
|
|
252
|
+
const existingStatusMatch = posBody.match(/^Status:\s*(.+)$/m);
|
|
253
|
+
const existingStatus = existingStatusMatch ? existingStatusMatch[1].trim() : null;
|
|
254
|
+
const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
|
|
255
|
+
const matchesPattern = existingStatus && state_document_cjs_1.KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
|
|
256
|
+
const isDefault = !existingStatus || isInList || matchesPattern;
|
|
257
|
+
if (isDefault) {
|
|
258
|
+
posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
// Table format: | Status | value | — apply the same preserve-authored guard
|
|
263
|
+
// as the inline branch: only overwrite a known template default.
|
|
264
|
+
// (Finding 2 code-review: the table branch was unconditional before this fix.)
|
|
265
|
+
const existingStatus = (0, state_document_cjs_1.stateExtractField)(posBody, 'Status');
|
|
266
|
+
const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
|
|
267
|
+
const matchesPattern = existingStatus && state_document_cjs_1.KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
|
|
268
|
+
const isDefault = !existingStatus || isInList || matchesPattern;
|
|
269
|
+
if (isDefault) {
|
|
270
|
+
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', fields.status);
|
|
271
|
+
if (replaced !== null)
|
|
272
|
+
posBody = replaced;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (fields.lastActivity) {
|
|
277
|
+
if (/^Last activity:/im.test(posBody)) {
|
|
278
|
+
// Inline format — only replace when the existing value is a known template
|
|
279
|
+
// default (a bare ISO date). Executor-authored narrative prose is preserved.
|
|
280
|
+
const existingActivityMatch = posBody.match(/^Last activity:\s*(.+)$/im);
|
|
281
|
+
const existingActivity = existingActivityMatch ? existingActivityMatch[1].trim() : null;
|
|
282
|
+
// A bare ISO date (YYYY-MM-DD with nothing after) is handler-generated.
|
|
283
|
+
// A date with a narrative suffix (e.g. "2026-02-15 -- blocked by infra...")
|
|
284
|
+
// was authored by the executor and must be preserved.
|
|
285
|
+
const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
|
|
286
|
+
const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
|
|
287
|
+
const isDefault = !existingActivity || isDateShape || inList;
|
|
288
|
+
if (isDefault) {
|
|
289
|
+
posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
// Table format — apply the same preserve-authored guard as the inline branch:
|
|
294
|
+
// only overwrite a bare ISO date or a known default; preserve narrative prose.
|
|
295
|
+
// (Finding 2 code-review: the table branch was unconditional before this fix.)
|
|
296
|
+
const existingActivity = (0, state_document_cjs_1.stateExtractField)(posBody, 'Last Activity')
|
|
297
|
+
?? (0, state_document_cjs_1.stateExtractField)(posBody, 'Last activity');
|
|
298
|
+
const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
|
|
299
|
+
const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
|
|
300
|
+
const isDefault = !existingActivity || isDateShape || inList;
|
|
301
|
+
if (isDefault) {
|
|
302
|
+
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', fields.lastActivity)
|
|
303
|
+
?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', fields.lastActivity);
|
|
304
|
+
if (replaced !== null)
|
|
305
|
+
posBody = replaced;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (fields.plan) {
|
|
310
|
+
if (/^Plan:/m.test(posBody)) {
|
|
311
|
+
posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Plan', fields.plan);
|
|
315
|
+
if (replaced !== null)
|
|
316
|
+
posBody = replaced;
|
|
317
|
+
}
|
|
276
318
|
}
|
|
277
319
|
return content.replace(posPattern, () => `${posMatch[1]}${posBody}`);
|
|
278
320
|
}
|
|
@@ -560,6 +602,101 @@ function cmdStateAddBlocker(cwd, text, raw) {
|
|
|
560
602
|
result['created'] = true;
|
|
561
603
|
output(result, raw, 'true');
|
|
562
604
|
}
|
|
605
|
+
function cmdStateAddRoadmapEvolution(cwd, options, raw) {
|
|
606
|
+
const statePath = planningPaths(cwd).state;
|
|
607
|
+
if (!node_fs_1.default.existsSync(statePath)) {
|
|
608
|
+
output({ error: 'STATE.md not found' }, raw, undefined);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const { phase, action, after, note, note_file, urgent } = options;
|
|
612
|
+
let noteText = undefined;
|
|
613
|
+
try {
|
|
614
|
+
noteText = readTextArgOrFile(cwd, note, note_file, 'note');
|
|
615
|
+
}
|
|
616
|
+
catch (err) {
|
|
617
|
+
output({ added: false, reason: err.message }, raw, 'false');
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
// Reject missing / empty / whitespace-only notes — an evolution entry with no
|
|
621
|
+
// narrative is meaningless and would corrupt the section with a dangling bullet.
|
|
622
|
+
if (!noteText || !noteText.trim()) {
|
|
623
|
+
output({ error: 'note required' }, raw, undefined);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
// Flatten line breaks so the entry is always a single Markdown bullet. The
|
|
627
|
+
// dedupe + rendering contract is line-oriented; a multiline --note-file would
|
|
628
|
+
// otherwise spill continuation lines outside the bullet and defeat dedupe.
|
|
629
|
+
// Internal spacing (e.g. dollar columns) is preserved.
|
|
630
|
+
const flatNote = noteText.replace(/\s*[\r\n]+\s*/g, ' ').trim();
|
|
631
|
+
const actionText = (action && action.trim()) || 'changed';
|
|
632
|
+
const afterText = after && after.trim() ? ` after Phase ${after.trim()}` : '';
|
|
633
|
+
const urgentText = urgent ? ' (URGENT)' : '';
|
|
634
|
+
const entry = `- Phase ${phase || '?'} ${actionText}${afterText}: ${flatNote}${urgentText}`;
|
|
635
|
+
let duplicate = false;
|
|
636
|
+
let created = false;
|
|
637
|
+
let subsectionCreated = false;
|
|
638
|
+
// The Roadmap Evolution subsection lives under `## Accumulated Context`. Scope
|
|
639
|
+
// every lookup to that section's body so a `### Roadmap Evolution` heading in an
|
|
640
|
+
// unrelated h2 section (or a fenced example) can never be matched or mutated.
|
|
641
|
+
// The accBody lookahead stops only at the next h2 (`\n##[^#]`), so nested h3
|
|
642
|
+
// subsections stay inside the captured Accumulated Context body.
|
|
643
|
+
// Section boundaries mirror the sibling handlers (add-decision/add-blocker):
|
|
644
|
+
// a trailing CR on a CRLF STATE.md is absorbed by the lazy body and trimmed,
|
|
645
|
+
// so following sections are preserved without data loss (see the CRLF test).
|
|
646
|
+
readModifyWriteStateMd(statePath, (content) => {
|
|
647
|
+
const accPattern = /(##\s*Accumulated Context\s*\n)([\s\S]*?)(?=\n##[^#]|$)/i;
|
|
648
|
+
const accMatch = content.match(accPattern);
|
|
649
|
+
if (accMatch) {
|
|
650
|
+
const accHeader = accMatch[1];
|
|
651
|
+
const accBody = accMatch[2];
|
|
652
|
+
// Find `### Roadmap Evolution` WITHIN the Accumulated Context body only.
|
|
653
|
+
// Bounded by the next h3/h2 or the end of the section body.
|
|
654
|
+
const subPattern = /(###\s*Roadmap Evolution\s*\n)([\s\S]*?)(?=\n###?|$)/i;
|
|
655
|
+
const subMatch = accBody.match(subPattern);
|
|
656
|
+
if (subMatch) {
|
|
657
|
+
let subBody = subMatch[2];
|
|
658
|
+
// Dedupe: exact (trimmed) line already present is a no-op replay.
|
|
659
|
+
if (subBody.split('\n').some((line) => line.trim() === entry.trim())) {
|
|
660
|
+
duplicate = true;
|
|
661
|
+
return content;
|
|
662
|
+
}
|
|
663
|
+
subBody = subBody.replace(/None yet\.?\s*\n?/gi, '');
|
|
664
|
+
subBody = subBody.trimEnd() + '\n' + entry + '\n';
|
|
665
|
+
const newAccBody = accBody.replace(subPattern, (_m, header) => `${header}${subBody}`);
|
|
666
|
+
return content.replace(accPattern, () => `${accHeader}${newAccBody}`);
|
|
667
|
+
}
|
|
668
|
+
// Subsection missing — append it at the end of the Accumulated Context body.
|
|
669
|
+
subsectionCreated = true;
|
|
670
|
+
const trimmedAcc = accBody.trimEnd();
|
|
671
|
+
const block = `${trimmedAcc ? `${trimmedAcc}\n\n` : ''}### Roadmap Evolution\n\n${entry}\n`;
|
|
672
|
+
return content.replace(accPattern, () => `${accHeader}${block}`);
|
|
673
|
+
}
|
|
674
|
+
// No `## Accumulated Context` — DWIM: create both at end of file.
|
|
675
|
+
// Mirrors the add-decision / add-blocker auto-create behavior.
|
|
676
|
+
created = true;
|
|
677
|
+
subsectionCreated = true;
|
|
678
|
+
const scaffold = [
|
|
679
|
+
'',
|
|
680
|
+
'## Accumulated Context',
|
|
681
|
+
'',
|
|
682
|
+
'### Roadmap Evolution',
|
|
683
|
+
'',
|
|
684
|
+
entry,
|
|
685
|
+
'',
|
|
686
|
+
].join('\n');
|
|
687
|
+
return content.trimEnd() + '\n' + scaffold;
|
|
688
|
+
}, cwd);
|
|
689
|
+
if (duplicate) {
|
|
690
|
+
output({ added: false, reason: 'duplicate', entry }, raw, 'false');
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const result = { added: true, entry };
|
|
694
|
+
if (created)
|
|
695
|
+
result['created'] = true;
|
|
696
|
+
if (subsectionCreated)
|
|
697
|
+
result['subsection_created'] = true;
|
|
698
|
+
output(result, raw, 'true');
|
|
699
|
+
}
|
|
563
700
|
function cmdStateResolveBlocker(cwd, text, raw) {
|
|
564
701
|
const statePath = planningPaths(cwd).state;
|
|
565
702
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
@@ -679,7 +816,9 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
679
816
|
// newly-written Stopped at / Resume file end up in the second (invisible) block.
|
|
680
817
|
// Fix: when a `## Session` heading already exists, normalize THAT block in place
|
|
681
818
|
// (insert / replace canonical bold-label lines within the existing section).
|
|
682
|
-
//
|
|
819
|
+
// A `## Session Continuity` heading (bootstrap shape) is handled additively —
|
|
820
|
+
// missing canonical fields are inserted while the heading and any prose are
|
|
821
|
+
// preserved (#1101). Only append a brand-new section when NEITHER heading exists.
|
|
683
822
|
const callerSuppliedValues = !!(options.stopped_at || (options.resume_file !== undefined && options.resume_file !== null));
|
|
684
823
|
const needsStoppedAt = options.stopped_at && !updated.includes('Stopped At');
|
|
685
824
|
const needsResumeFile = options.resume_file !== undefined && options.resume_file !== null && !updated.includes('Resume File');
|
|
@@ -689,9 +828,14 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
689
828
|
? options.resume_file
|
|
690
829
|
: 'None';
|
|
691
830
|
const stoppedAtValue = options.stopped_at || 'None';
|
|
692
|
-
// Determine whether a
|
|
693
|
-
|
|
694
|
-
|
|
831
|
+
// Determine whether a session heading already exists in the body. The
|
|
832
|
+
// canonical normalized form is `## Session`; the bootstrap templates
|
|
833
|
+
// (workstream.cts, gsd2-import.cts, templates/state.md) instead emit
|
|
834
|
+
// `## Session Continuity`. Treat each separately so we never append a
|
|
835
|
+
// duplicate section alongside an existing one.
|
|
836
|
+
const existingCanonicalSession = /^## Session[ \t]*$/im.test(content);
|
|
837
|
+
const existingSessionContinuity = /^## Session Continuity[ \t]*$/im.test(content);
|
|
838
|
+
if (existingCanonicalSession) {
|
|
695
839
|
// Normalize in place: replace the ENTIRE BODY of the existing ## Session
|
|
696
840
|
// section (heading + all content up to the next ## heading or EOF) with
|
|
697
841
|
// canonical bold-label lines. The negative-lookahead per-line pattern
|
|
@@ -708,8 +852,31 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
708
852
|
'',
|
|
709
853
|
].join('\n'));
|
|
710
854
|
}
|
|
855
|
+
else if (existingSessionContinuity) {
|
|
856
|
+
// #1101: a `## Session Continuity` section already exists (bootstrap
|
|
857
|
+
// shape). Previously this fell through to the append branch and created
|
|
858
|
+
// a SECOND `## Session` block — a duplicate. Instead, insert only the
|
|
859
|
+
// canonical fields that are still missing, right after the heading,
|
|
860
|
+
// preserving the `## Session Continuity` heading and ALL existing lines
|
|
861
|
+
// (e.g. prose like "Next recommended action"). Fields already updated in
|
|
862
|
+
// place above (needs* false) are not re-inserted. A function replacement
|
|
863
|
+
// is used so `$`-bearing caller values are inserted literally (#3454).
|
|
864
|
+
const linesToInsert = [];
|
|
865
|
+
if (needsLastSession)
|
|
866
|
+
linesToInsert.push(`**Last session:** ${now}`);
|
|
867
|
+
if (needsStoppedAt)
|
|
868
|
+
linesToInsert.push(`**Stopped at:** ${stoppedAtValue}`);
|
|
869
|
+
if (needsResumeFile)
|
|
870
|
+
linesToInsert.push(`**Resume file:** ${resumeValue}`);
|
|
871
|
+
if (linesToInsert.length > 0) {
|
|
872
|
+
// Case-insensitive to match the `existingSessionContinuity` detection
|
|
873
|
+
// above (#1101 review F3) — otherwise a lowercase heading would detect
|
|
874
|
+
// but no-op the insert while still reporting the fields as updated.
|
|
875
|
+
content = content.replace(/^(## Session Continuity[ \t]*\n)/im, (_m, heading) => heading + linesToInsert.join('\n') + '\n');
|
|
876
|
+
}
|
|
877
|
+
}
|
|
711
878
|
else {
|
|
712
|
-
// No
|
|
879
|
+
// No session heading exists at all — append a new canonical section.
|
|
713
880
|
const scaffold = [
|
|
714
881
|
'',
|
|
715
882
|
'## Session',
|
|
@@ -741,6 +908,20 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
741
908
|
output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
|
|
742
909
|
}
|
|
743
910
|
}
|
|
911
|
+
/**
|
|
912
|
+
* Match the session section body from a STATE.md body. #1101: recognise the
|
|
913
|
+
* bootstrap `## Session Continuity` heading but PREFER the normalized `## Session`
|
|
914
|
+
* block when both exist (legacy duplicate files), so the reader agrees with the
|
|
915
|
+
* writer (which updates `## Session` first). `(?:^|\n)` line-anchors (kept out of
|
|
916
|
+
* `/m` so `$` stays end-of-string for the `(?=\n##|$)` section boundary), which
|
|
917
|
+
* excludes an h3 `### Session Continuity`; the trailing-` Archive` boundary still
|
|
918
|
+
* excludes `## Session Continuity Archive` (preserving the #2444 scoping).
|
|
919
|
+
* Returns the match whose group 1 is the section body, or null.
|
|
920
|
+
*/
|
|
921
|
+
function matchSessionSection(body) {
|
|
922
|
+
return body.match(/(?:^|\n)##[ \t]*Session[ \t]*\n([\s\S]*?)(?=\n##|$)/i)
|
|
923
|
+
|| body.match(/(?:^|\n)##[ \t]*Session Continuity[ \t]*\n([\s\S]*?)(?=\n##|$)/i);
|
|
924
|
+
}
|
|
744
925
|
function cmdStateSnapshot(cwd, raw) {
|
|
745
926
|
const statePath = planningPaths(cwd).state;
|
|
746
927
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
@@ -816,7 +997,9 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
816
997
|
stopped_at: null,
|
|
817
998
|
resume_file: null,
|
|
818
999
|
};
|
|
819
|
-
|
|
1000
|
+
// #1101: prefer the canonical `## Session` block, falling back to the bootstrap
|
|
1001
|
+
// `## Session Continuity` heading. See matchSessionSection for the anchoring.
|
|
1002
|
+
const sessionMatch = matchSessionSection(body);
|
|
820
1003
|
if (sessionMatch) {
|
|
821
1004
|
const sessionSection = sessionMatch[1];
|
|
822
1005
|
// Accept both `**Last Date:**` (canonical template form) and `**Last session:**`
|
|
@@ -872,7 +1055,9 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
872
1055
|
// historical "Stopped at:" prose elsewhere in the body (e.g. in a
|
|
873
1056
|
// Session Continuity Archive section) never overwrites the current value.
|
|
874
1057
|
// Fall back to full-body search only when no ## Session section exists.
|
|
875
|
-
|
|
1058
|
+
// #1101: prefer the canonical `## Session` block, falling back to the bootstrap
|
|
1059
|
+
// `## Session Continuity` heading. See matchSessionSection for the anchoring.
|
|
1060
|
+
const sessionSectionMatch = matchSessionSection(bodyContent);
|
|
876
1061
|
const sessionBodyScope = sessionSectionMatch ? sessionSectionMatch[1] : bodyContent;
|
|
877
1062
|
const stoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped at');
|
|
878
1063
|
const pausedAt = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Paused At');
|
|
@@ -1142,6 +1327,19 @@ function acquireStateLock(statePath, clock) {
|
|
|
1142
1327
|
const staleThresholdMs = 10000;
|
|
1143
1328
|
const maxWaitMs = 30000;
|
|
1144
1329
|
const startedAt = clock.now();
|
|
1330
|
+
// Shared helper: check the time budget then back off with jitter before the
|
|
1331
|
+
// next retry. Both the EEXIST contention path and the recoverable-errno path
|
|
1332
|
+
// must go through this so neither can busy-spin (#1217).
|
|
1333
|
+
const checkBudgetAndSleep = (context) => {
|
|
1334
|
+
if (clock.now() - startedAt >= maxWaitMs) {
|
|
1335
|
+
const e = new Error('acquireStateLock: ' + lockPath + ' ' + context + ' for ' +
|
|
1336
|
+
(clock.now() - startedAt) + 'ms (exceeded ' + maxWaitMs + 'ms budget)');
|
|
1337
|
+
e.lockBudgetExceeded = true;
|
|
1338
|
+
throw e;
|
|
1339
|
+
}
|
|
1340
|
+
const jitter = Math.floor(Math.random() * 50);
|
|
1341
|
+
clock.sleep(retryDelay + jitter);
|
|
1342
|
+
};
|
|
1145
1343
|
while (true) {
|
|
1146
1344
|
try {
|
|
1147
1345
|
const fd = node_fs_1.default.openSync(lockPath, node_fs_1.default.constants.O_CREAT | node_fs_1.default.constants.O_EXCL | node_fs_1.default.constants.O_WRONLY);
|
|
@@ -1153,9 +1351,11 @@ function acquireStateLock(statePath, clock) {
|
|
|
1153
1351
|
}
|
|
1154
1352
|
catch (err) {
|
|
1155
1353
|
// Transient filesystem errors (Docker overlay-fs, NFS, OS signals, AV scanners)
|
|
1156
|
-
// are recoverable — retry the
|
|
1354
|
+
// are recoverable — retry with the same budget + backoff as the EEXIST path so
|
|
1355
|
+
// a permanently-failing errno cannot busy-spin at 100% CPU (#1217).
|
|
1157
1356
|
// See ACQUIRE_LOCK_RETRY_ERRNOS for the full list and rationale.
|
|
1158
1357
|
if (ACQUIRE_LOCK_RETRY_ERRNOS.has(err.code)) {
|
|
1358
|
+
checkBudgetAndSleep(err.code + ' persisted');
|
|
1159
1359
|
continue;
|
|
1160
1360
|
}
|
|
1161
1361
|
if (err.code !== 'EEXIST')
|
|
@@ -1166,22 +1366,39 @@ function acquireStateLock(statePath, clock) {
|
|
|
1166
1366
|
try {
|
|
1167
1367
|
const stat = node_fs_1.default.statSync(lockPath);
|
|
1168
1368
|
if ((clock).now() - stat.mtimeMs > staleThresholdMs) {
|
|
1369
|
+
let removed = false;
|
|
1169
1370
|
try {
|
|
1170
1371
|
node_fs_1.default.unlinkSync(lockPath);
|
|
1372
|
+
removed = true;
|
|
1373
|
+
}
|
|
1374
|
+
catch { /* swallow: bounded below */ }
|
|
1375
|
+
if (removed) {
|
|
1376
|
+
// Successful steal — retry immediately to grab the just-freed lock.
|
|
1377
|
+
// Must NOT call checkBudgetAndSleep here: a throw-after-delete would
|
|
1378
|
+
// corrupt the filesystem state, and the budget is already bounded on
|
|
1379
|
+
// the next iteration's EEXIST or open attempt (#1217 regression fix).
|
|
1380
|
+
continue;
|
|
1171
1381
|
}
|
|
1172
|
-
|
|
1382
|
+
// Persistent unlinkSync failure — apply budget + backoff so it cannot
|
|
1383
|
+
// busy-spin (#1217).
|
|
1384
|
+
checkBudgetAndSleep('stale lock removal failed');
|
|
1173
1385
|
continue;
|
|
1174
1386
|
}
|
|
1175
1387
|
}
|
|
1176
|
-
catch {
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1388
|
+
catch (err) {
|
|
1389
|
+
// Re-throw a budget-exceeded error from the unlinkSync failure path above
|
|
1390
|
+
// unchanged — its message already names the real cause ("stale lock removal
|
|
1391
|
+
// failed") and double-wrapping it would replace that with the misleading
|
|
1392
|
+
// "statSync failed after EEXIST" context string (#1217 diagnostic fix).
|
|
1393
|
+
if (err?.lockBudgetExceeded)
|
|
1394
|
+
throw err;
|
|
1395
|
+
// statSync failed — lock was likely released between our EEXIST and this
|
|
1396
|
+
// stat call. Apply budget + backoff so a persistent statSync failure
|
|
1397
|
+
// cannot busy-spin (#1217).
|
|
1398
|
+
checkBudgetAndSleep('statSync failed after EEXIST');
|
|
1399
|
+
continue;
|
|
1182
1400
|
}
|
|
1183
|
-
|
|
1184
|
-
(clock).sleep(retryDelay + jitter);
|
|
1401
|
+
checkBudgetAndSleep('held by live process');
|
|
1185
1402
|
}
|
|
1186
1403
|
}
|
|
1187
1404
|
}
|
|
@@ -1256,6 +1473,26 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
1256
1473
|
// Snapshot the existing progress block BEFORE the transform so we can
|
|
1257
1474
|
// restore it when resync is false.
|
|
1258
1475
|
const preFm = resync ? null : extractFrontmatter(content);
|
|
1476
|
+
// Bug #1230: delta heuristic — snapshot pre-transform body source fields so
|
|
1477
|
+
// we can detect whether THIS write changed them. syncStateFrontmatter
|
|
1478
|
+
// re-derives frontmatter status/stopped_at from the body on every write;
|
|
1479
|
+
// when the body's source field was NOT changed by the transform, the
|
|
1480
|
+
// existing frontmatter value (e.g. a hand-set 'completed') must win over
|
|
1481
|
+
// the body-derived value (e.g. 'verifying' from a stale "Status: Verifying
|
|
1482
|
+
// Phase 3" line that an earlier tool wrote). We do NOT disturb `preFm`
|
|
1483
|
+
// above (null when resync:true) — these are independent snapshots.
|
|
1484
|
+
// Strip frontmatter before calling stateExtractField so the YAML `status:`
|
|
1485
|
+
// key in the frontmatter block cannot shadow the body field we are tracking.
|
|
1486
|
+
const preBody = stripFrontmatter(content);
|
|
1487
|
+
const preFmSnapshot = extractFrontmatter(content);
|
|
1488
|
+
const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
|
|
1489
|
+
// Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
|
|
1490
|
+
// mirroring buildStateFrontmatter's sessionBodyScope logic (line ~1172).
|
|
1491
|
+
// A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
|
|
1492
|
+
// Archive prose) must not interfere with the delta comparison.
|
|
1493
|
+
const preSessionMatch = matchSessionSection(preBody);
|
|
1494
|
+
const preSessionScope = preSessionMatch ? preSessionMatch[1] : preBody;
|
|
1495
|
+
const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
|
|
1259
1496
|
const modified = transformFn(content);
|
|
1260
1497
|
// Bug #948: no-op guard — if the transform produced no change, do NOT write
|
|
1261
1498
|
// the file. An unconditional write would bump `last_updated`, reset
|
|
@@ -1268,14 +1505,53 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
1268
1505
|
return;
|
|
1269
1506
|
}
|
|
1270
1507
|
let synced = syncStateFrontmatter(modified, cwd);
|
|
1271
|
-
|
|
1508
|
+
// Compute postFm once and apply BOTH the progress-restore (when !resync)
|
|
1509
|
+
// AND the status/stopped_at preservation (#1230) before reconstructing.
|
|
1510
|
+
// This avoids double-wrapping the frontmatter block.
|
|
1511
|
+
const needsProgressRestore = !resync && preFm && preFm['progress'];
|
|
1512
|
+
// Post-transform body source fields used for the delta comparison (#1230).
|
|
1513
|
+
// Use `modified` (not `synced`): syncStateFrontmatter only rewrites the frontmatter block, so the body is identical in both — and we need the body the transform produced.
|
|
1514
|
+
// Strip frontmatter so the YAML status key cannot shadow the body field.
|
|
1515
|
+
const postBody = stripFrontmatter(modified);
|
|
1516
|
+
const postBodyStatus = (0, state_document_cjs_1.stateExtractField)(postBody, 'Status');
|
|
1517
|
+
// Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
|
|
1518
|
+
// consistent with the pre-transform snapshot above and buildStateFrontmatter.
|
|
1519
|
+
const postSessionMatch = matchSessionSection(postBody);
|
|
1520
|
+
const postSessionScope = postSessionMatch ? postSessionMatch[1] : postBody;
|
|
1521
|
+
const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
|
|
1522
|
+
let mutated = false;
|
|
1523
|
+
const postFm = extractFrontmatter(synced);
|
|
1524
|
+
if (needsProgressRestore) {
|
|
1272
1525
|
// Re-apply the curated progress block that syncStateFrontmatter just
|
|
1273
1526
|
// overwrote with disk-derived values. Only restore keys that were present
|
|
1274
1527
|
// in the snapshot — this preserves any new non-progress frontmatter fields
|
|
1275
1528
|
// (e.g., status, current_phase) that syncStateFrontmatter legitimately
|
|
1276
1529
|
// derived from the updated body.
|
|
1277
|
-
const postFm = extractFrontmatter(synced);
|
|
1278
1530
|
postFm['progress'] = preFm['progress'];
|
|
1531
|
+
mutated = true;
|
|
1532
|
+
}
|
|
1533
|
+
// Bug #1230: preserve existing frontmatter status when this write did NOT
|
|
1534
|
+
// change the body's Status field. A write that doesn't touch Status must
|
|
1535
|
+
// not silently revert a hand-set frontmatter status (e.g. 'completed') to
|
|
1536
|
+
// whatever the stale body Status happens to derive (e.g. 'verifying').
|
|
1537
|
+
// Only apply when the existing frontmatter held a real, non-unknown status.
|
|
1538
|
+
if (postBodyStatus === preBodyStatus &&
|
|
1539
|
+
typeof preFmSnapshot['status'] === 'string' &&
|
|
1540
|
+
preFmSnapshot['status'].length > 0 &&
|
|
1541
|
+
preFmSnapshot['status'] !== 'unknown' &&
|
|
1542
|
+
postFm['status'] !== preFmSnapshot['status']) {
|
|
1543
|
+
postFm['status'] = preFmSnapshot['status'];
|
|
1544
|
+
mutated = true;
|
|
1545
|
+
}
|
|
1546
|
+
// Bug #1230: same delta heuristic for stopped_at.
|
|
1547
|
+
if (postBodyStoppedAt === preBodyStoppedAt &&
|
|
1548
|
+
typeof preFmSnapshot['stopped_at'] === 'string' &&
|
|
1549
|
+
preFmSnapshot['stopped_at'].length > 0 &&
|
|
1550
|
+
postFm['stopped_at'] !== preFmSnapshot['stopped_at']) {
|
|
1551
|
+
postFm['stopped_at'] = preFmSnapshot['stopped_at'];
|
|
1552
|
+
mutated = true;
|
|
1553
|
+
}
|
|
1554
|
+
if (mutated) {
|
|
1279
1555
|
const yamlStr = reconstructFrontmatter(postFm);
|
|
1280
1556
|
const body = stripFrontmatter(synced);
|
|
1281
1557
|
synced = `---\n${yamlStr}\n---\n\n${body}`;
|
|
@@ -1344,74 +1620,89 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
1344
1620
|
const today = clock_cjs_1.realClock.today();
|
|
1345
1621
|
const updated = [];
|
|
1346
1622
|
readModifyWriteStateMd(statePath, (content) => {
|
|
1623
|
+
// Bug #1255: all body-field replacements must operate on the body only
|
|
1624
|
+
// (frontmatter stripped), not on the full content. When the full content is
|
|
1625
|
+
// passed to stateReplaceField the YAML `status: planning` key matches the
|
|
1626
|
+
// plain-text pattern (`^Status:\s*`) before the body pipe-table row, so the
|
|
1627
|
+
// pipe-table `| Status | Planning |` is never updated and syncStateFrontmatter
|
|
1628
|
+
// re-derives 'planning' from the unchanged body — the status never advances.
|
|
1629
|
+
const existingFm = extractFrontmatter(content);
|
|
1630
|
+
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
|
1631
|
+
let body = stripFrontmatter(content);
|
|
1632
|
+
// Helper to reassemble content for field-replacement checks; callers that
|
|
1633
|
+
// only need to test/replace body fields use `body` directly, and the final
|
|
1634
|
+
// return reassembles the frontmatter block with the updated body.
|
|
1635
|
+
const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
|
|
1347
1636
|
// Idempotency guard (#3127): if the phase is already mid-flight, do NOT
|
|
1348
1637
|
// overwrite execution-progress fields (Current Plan, plan body line,
|
|
1349
1638
|
// Last Activity Description). Only update fields that are safe to
|
|
1350
1639
|
// refresh on resume (Last Activity date, Status if inconsistent).
|
|
1351
1640
|
// A phase is considered mid-flight when Status contains 'Executing Phase N'
|
|
1352
1641
|
// for the current phase number.
|
|
1353
|
-
|
|
1642
|
+
// #1255: extract from body (not full content) so the YAML `status:` key
|
|
1643
|
+
// cannot shadow the body Status field.
|
|
1644
|
+
const currentStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status') || '';
|
|
1354
1645
|
const isAlreadyExecuting = new RegExp(`Executing Phase\\s+${escapeRegex(String(phaseNumber))}\\b`, 'i').test(currentStatus);
|
|
1355
|
-
// Update Status field
|
|
1646
|
+
// Update Status field (body only — #1255)
|
|
1356
1647
|
const statusValue = `Executing Phase ${phaseNumber}`;
|
|
1357
|
-
let result = (0, state_document_cjs_1.stateReplaceField)(
|
|
1648
|
+
let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
|
|
1358
1649
|
if (result) {
|
|
1359
|
-
|
|
1650
|
+
body = result;
|
|
1360
1651
|
updated.push('Status');
|
|
1361
1652
|
}
|
|
1362
1653
|
// Update Last Activity (safe to update on resume — tracks when execute-phase ran)
|
|
1363
|
-
result = (0, state_document_cjs_1.stateReplaceField)(
|
|
1654
|
+
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity', today);
|
|
1364
1655
|
if (result) {
|
|
1365
|
-
|
|
1656
|
+
body = result;
|
|
1366
1657
|
updated.push('Last Activity');
|
|
1367
1658
|
}
|
|
1368
1659
|
if (!isAlreadyExecuting) {
|
|
1369
1660
|
// First-time execution: set all progress fields
|
|
1370
1661
|
// Update Last Activity Description
|
|
1371
1662
|
const activityDesc = `Phase ${phaseNumber} execution started`;
|
|
1372
|
-
result = (0, state_document_cjs_1.stateReplaceField)(
|
|
1663
|
+
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
|
|
1373
1664
|
if (result) {
|
|
1374
|
-
|
|
1665
|
+
body = result;
|
|
1375
1666
|
updated.push('Last Activity Description');
|
|
1376
1667
|
}
|
|
1377
1668
|
// Update Current Phase
|
|
1378
|
-
result = (0, state_document_cjs_1.stateReplaceField)(
|
|
1669
|
+
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Phase', String(phaseNumber));
|
|
1379
1670
|
if (result) {
|
|
1380
|
-
|
|
1671
|
+
body = result;
|
|
1381
1672
|
updated.push('Current Phase');
|
|
1382
1673
|
}
|
|
1383
1674
|
// Update Current Phase Name
|
|
1384
1675
|
if (phaseName) {
|
|
1385
|
-
result = (0, state_document_cjs_1.stateReplaceField)(
|
|
1676
|
+
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Phase Name', phaseName);
|
|
1386
1677
|
if (result) {
|
|
1387
|
-
|
|
1678
|
+
body = result;
|
|
1388
1679
|
updated.push('Current Phase Name');
|
|
1389
1680
|
}
|
|
1390
1681
|
}
|
|
1391
1682
|
// Update Current Plan to 1 (starting from the first plan)
|
|
1392
|
-
result = (0, state_document_cjs_1.stateReplaceField)(
|
|
1683
|
+
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Plan', '1');
|
|
1393
1684
|
if (result) {
|
|
1394
|
-
|
|
1685
|
+
body = result;
|
|
1395
1686
|
updated.push('Current Plan');
|
|
1396
1687
|
}
|
|
1397
1688
|
// Update Total Plans in Phase
|
|
1398
1689
|
if (planCount) {
|
|
1399
|
-
result = (0, state_document_cjs_1.stateReplaceField)(
|
|
1690
|
+
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Total Plans in Phase', String(planCount));
|
|
1400
1691
|
if (result) {
|
|
1401
|
-
|
|
1692
|
+
body = result;
|
|
1402
1693
|
updated.push('Total Plans in Phase');
|
|
1403
1694
|
}
|
|
1404
1695
|
}
|
|
1405
1696
|
// Update **Current focus:** body text line (#1104)
|
|
1406
1697
|
const focusLabel = phaseName ? `Phase ${phaseNumber} — ${phaseName}` : `Phase ${phaseNumber}`;
|
|
1407
1698
|
const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
|
|
1408
|
-
if (focusPattern.test(
|
|
1409
|
-
|
|
1699
|
+
if (focusPattern.test(body)) {
|
|
1700
|
+
body = body.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
|
|
1410
1701
|
updated.push('Current focus');
|
|
1411
1702
|
}
|
|
1412
1703
|
// Update ## Current Position section (#1104, #1365)
|
|
1413
1704
|
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
|
|
1414
|
-
const positionMatch =
|
|
1705
|
+
const positionMatch = body.match(positionPattern);
|
|
1415
1706
|
if (positionMatch) {
|
|
1416
1707
|
const header = positionMatch[1];
|
|
1417
1708
|
let posBody = positionMatch[2];
|
|
@@ -1421,7 +1712,13 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
1421
1712
|
posBody = posBody.replace(/^Phase:.*$/m, newPhase);
|
|
1422
1713
|
}
|
|
1423
1714
|
else {
|
|
1424
|
-
|
|
1715
|
+
// Pipe-table format in Current Position (#1257): update the | Phase | … |
|
|
1716
|
+
// cell rather than prepending a spurious inline `Phase:` line (which left
|
|
1717
|
+
// the table cell stale). Mirrors the Status/Last-activity table branches.
|
|
1718
|
+
const phaseValue = `${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
|
|
1719
|
+
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Phase', phaseValue);
|
|
1720
|
+
if (replaced !== null)
|
|
1721
|
+
posBody = replaced;
|
|
1425
1722
|
}
|
|
1426
1723
|
// Update or insert Plan line
|
|
1427
1724
|
const newPlan = `Plan: 1 of ${planCount || '?'}`;
|
|
@@ -1429,19 +1726,39 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
1429
1726
|
posBody = posBody.replace(/^Plan:.*$/m, newPlan);
|
|
1430
1727
|
}
|
|
1431
1728
|
else {
|
|
1432
|
-
|
|
1729
|
+
// Pipe-table format in Current Position (#1257): update the | Plan | … |
|
|
1730
|
+
// cell rather than appending after a prepended inline line.
|
|
1731
|
+
const planValue = `1 of ${planCount || '?'}`;
|
|
1732
|
+
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Plan', planValue);
|
|
1733
|
+
if (replaced !== null)
|
|
1734
|
+
posBody = replaced;
|
|
1433
1735
|
}
|
|
1434
1736
|
// Update Status line if present
|
|
1435
1737
|
const newStatus = `Status: Executing Phase ${phaseNumber}`;
|
|
1436
1738
|
if (/^Status:/m.test(posBody)) {
|
|
1437
1739
|
posBody = posBody.replace(/^Status:.*$/m, newStatus);
|
|
1438
1740
|
}
|
|
1741
|
+
else {
|
|
1742
|
+
// Pipe-table format in Current Position (#1255)
|
|
1743
|
+
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', `Executing Phase ${phaseNumber}`);
|
|
1744
|
+
if (replaced !== null)
|
|
1745
|
+
posBody = replaced;
|
|
1746
|
+
}
|
|
1439
1747
|
// Update Last activity line if present
|
|
1440
1748
|
const newActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution started`;
|
|
1441
1749
|
if (/^Last activity:/im.test(posBody)) {
|
|
1442
1750
|
posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
|
|
1443
1751
|
}
|
|
1444
|
-
|
|
1752
|
+
else {
|
|
1753
|
+
// Pipe-table format in Current Position (#1255)
|
|
1754
|
+
// Value must match the inline branch (date + narrative), not bare date.
|
|
1755
|
+
const activityValue = `${today} -- Phase ${phaseNumber} execution started`;
|
|
1756
|
+
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
|
|
1757
|
+
?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
|
|
1758
|
+
if (replaced !== null)
|
|
1759
|
+
posBody = replaced;
|
|
1760
|
+
}
|
|
1761
|
+
body = body.replace(positionPattern, () => `${header}${posBody}`);
|
|
1445
1762
|
updated.push('Current Position');
|
|
1446
1763
|
}
|
|
1447
1764
|
}
|
|
@@ -1449,19 +1766,29 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
1449
1766
|
// Resume path: only update Last activity timestamp in Current Position
|
|
1450
1767
|
// (do not touch Plan:, stopped_at, progress.percent, or plan counter)
|
|
1451
1768
|
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
|
|
1452
|
-
const positionMatch =
|
|
1769
|
+
const positionMatch = body.match(positionPattern);
|
|
1453
1770
|
if (positionMatch) {
|
|
1454
1771
|
const header = positionMatch[1];
|
|
1455
1772
|
let posBody = positionMatch[2];
|
|
1456
1773
|
const resumeActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution resumed (wave continue)`;
|
|
1457
1774
|
if (/^Last activity:/im.test(posBody)) {
|
|
1458
1775
|
posBody = posBody.replace(/^Last activity:.*$/im, resumeActivity);
|
|
1459
|
-
|
|
1776
|
+
body = body.replace(positionPattern, () => `${header}${posBody}`);
|
|
1460
1777
|
updated.push('Last activity (resume)');
|
|
1461
1778
|
}
|
|
1779
|
+
else {
|
|
1780
|
+
// Pipe-table format in Current Position (#1255)
|
|
1781
|
+
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', resumeActivity)
|
|
1782
|
+
?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', resumeActivity);
|
|
1783
|
+
if (replaced !== null) {
|
|
1784
|
+
posBody = replaced;
|
|
1785
|
+
body = body.replace(positionPattern, () => `${header}${posBody}`);
|
|
1786
|
+
updated.push('Last activity (resume)');
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1462
1789
|
}
|
|
1463
1790
|
}
|
|
1464
|
-
return
|
|
1791
|
+
return reassemble(body);
|
|
1465
1792
|
}, cwd);
|
|
1466
1793
|
output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
|
|
1467
1794
|
}
|
|
@@ -1561,43 +1888,54 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
|
|
|
1561
1888
|
// doing so tramples curated/known-good counters. Route through the body-only
|
|
1562
1889
|
// write contract (resync:false), the same guard state.update uses. (#500 RC1)
|
|
1563
1890
|
readModifyWriteStateMd(statePath, (content) => {
|
|
1891
|
+
// Bug #1257: all body-field replacements must operate on the body only
|
|
1892
|
+
// (frontmatter stripped), not on the full content. When the full content is
|
|
1893
|
+
// passed to stateReplaceFieldIfTemplate the YAML `status: planning` key matches
|
|
1894
|
+
// the plain-text pattern (`^Status:\s*`) before the body pipe-table row, so the
|
|
1895
|
+
// pipe-table `| Status | Planning |` cell is never updated and syncStateFrontmatter
|
|
1896
|
+
// re-derives 'planning' from the unchanged body — the status never advances.
|
|
1897
|
+
// (Mirrors the begin/complete-phase fix from #1255/#1256.)
|
|
1898
|
+
const existingFm = extractFrontmatter(content);
|
|
1899
|
+
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
|
1900
|
+
let body = stripFrontmatter(content);
|
|
1901
|
+
const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
|
|
1564
1902
|
// Update Status — only when the existing value is a known template default
|
|
1565
1903
|
// (Knuth invariant: preserve executor-authored values).
|
|
1566
|
-
const
|
|
1567
|
-
if (
|
|
1568
|
-
|
|
1904
|
+
const newBody = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Status', statusDefaults, 'Ready to execute');
|
|
1905
|
+
if (newBody !== body) {
|
|
1906
|
+
body = newBody;
|
|
1569
1907
|
updated.push('Status');
|
|
1570
1908
|
}
|
|
1571
1909
|
// Update Total Plans in Phase
|
|
1572
1910
|
if (planCount !== null && planCount !== undefined) {
|
|
1573
|
-
const result = (0, state_document_cjs_1.stateReplaceField)(
|
|
1911
|
+
const result = (0, state_document_cjs_1.stateReplaceField)(body, 'Total Plans in Phase', String(planCount));
|
|
1574
1912
|
if (result) {
|
|
1575
|
-
|
|
1913
|
+
body = result;
|
|
1576
1914
|
updated.push('Total Plans in Phase');
|
|
1577
1915
|
}
|
|
1578
1916
|
}
|
|
1579
1917
|
// Update Last Activity — only when the existing value is a known template default
|
|
1580
1918
|
{
|
|
1581
|
-
const after = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(
|
|
1582
|
-
if (after !==
|
|
1583
|
-
|
|
1919
|
+
const after = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last Activity', lastActivityDefaults, today);
|
|
1920
|
+
if (after !== body) {
|
|
1921
|
+
body = after;
|
|
1584
1922
|
updated.push('Last Activity');
|
|
1585
1923
|
}
|
|
1586
1924
|
}
|
|
1587
1925
|
// Update Last Activity Description
|
|
1588
1926
|
{
|
|
1589
|
-
const result = (0, state_document_cjs_1.stateReplaceField)(
|
|
1927
|
+
const result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`);
|
|
1590
1928
|
if (result) {
|
|
1591
|
-
|
|
1929
|
+
body = result;
|
|
1592
1930
|
updated.push('Last Activity Description');
|
|
1593
1931
|
}
|
|
1594
1932
|
}
|
|
1595
1933
|
// Update Current Position section
|
|
1596
|
-
|
|
1934
|
+
body = updateCurrentPositionFields(body, {
|
|
1597
1935
|
status: 'Ready to execute',
|
|
1598
1936
|
lastActivity: `${today} -- Phase ${phaseNumber} planning complete`,
|
|
1599
1937
|
});
|
|
1600
|
-
return
|
|
1938
|
+
return reassemble(body);
|
|
1601
1939
|
}, cwd, { resync: false });
|
|
1602
1940
|
output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
|
|
1603
1941
|
}
|
|
@@ -2078,29 +2416,35 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
2078
2416
|
const updated = [];
|
|
2079
2417
|
readModifyWriteStateMd(statePath, (content) => {
|
|
2080
2418
|
const currentPhase = resolvedPhase;
|
|
2081
|
-
//
|
|
2419
|
+
// Bug #1255: operate on body only so the YAML frontmatter `status:` key
|
|
2420
|
+
// cannot shadow the body Status field (pipe-table or inline).
|
|
2421
|
+
const existingFm = extractFrontmatter(content);
|
|
2422
|
+
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
|
2423
|
+
let body = stripFrontmatter(content);
|
|
2424
|
+
const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
|
|
2425
|
+
// Update Status field (body only — #1255)
|
|
2082
2426
|
const statusValue = `Phase ${currentPhase} complete`;
|
|
2083
|
-
let result = (0, state_document_cjs_1.stateReplaceField)(
|
|
2427
|
+
let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
|
|
2084
2428
|
if (result) {
|
|
2085
|
-
|
|
2429
|
+
body = result;
|
|
2086
2430
|
updated.push('Status');
|
|
2087
2431
|
}
|
|
2088
2432
|
// Update Last Activity date
|
|
2089
|
-
result = (0, state_document_cjs_1.stateReplaceField)(
|
|
2433
|
+
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity', today);
|
|
2090
2434
|
if (result) {
|
|
2091
|
-
|
|
2435
|
+
body = result;
|
|
2092
2436
|
updated.push('Last Activity');
|
|
2093
2437
|
}
|
|
2094
2438
|
// Update Last Activity Description
|
|
2095
2439
|
const activityDesc = `Phase ${currentPhase} marked complete`;
|
|
2096
|
-
result = (0, state_document_cjs_1.stateReplaceField)(
|
|
2440
|
+
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
|
|
2097
2441
|
if (result) {
|
|
2098
|
-
|
|
2442
|
+
body = result;
|
|
2099
2443
|
updated.push('Last Activity Description');
|
|
2100
2444
|
}
|
|
2101
2445
|
// Update ## Current Position section
|
|
2102
2446
|
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
|
|
2103
|
-
const positionMatch =
|
|
2447
|
+
const positionMatch = body.match(positionPattern);
|
|
2104
2448
|
if (positionMatch) {
|
|
2105
2449
|
const header = positionMatch[1];
|
|
2106
2450
|
let posBody = positionMatch[2];
|
|
@@ -2109,20 +2453,42 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
2109
2453
|
if (/^Phase:/m.test(posBody)) {
|
|
2110
2454
|
posBody = posBody.replace(/^Phase:.*$/m, newPhase);
|
|
2111
2455
|
}
|
|
2456
|
+
else {
|
|
2457
|
+
// Pipe-table format in Current Position (#1255)
|
|
2458
|
+
// Value cell must be bare (no "Phase:" label prefix) — the column header already provides the label.
|
|
2459
|
+
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Phase', `${currentPhase} — COMPLETE`);
|
|
2460
|
+
if (replaced !== null)
|
|
2461
|
+
posBody = replaced;
|
|
2462
|
+
}
|
|
2112
2463
|
// Update Status line if present
|
|
2113
2464
|
const newStatus = `Status: Phase ${currentPhase} complete`;
|
|
2114
2465
|
if (/^Status:/m.test(posBody)) {
|
|
2115
2466
|
posBody = posBody.replace(/^Status:.*$/m, newStatus);
|
|
2116
2467
|
}
|
|
2468
|
+
else {
|
|
2469
|
+
// Pipe-table format in Current Position (#1255)
|
|
2470
|
+
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', `Phase ${currentPhase} complete`);
|
|
2471
|
+
if (replaced !== null)
|
|
2472
|
+
posBody = replaced;
|
|
2473
|
+
}
|
|
2117
2474
|
// Update Last activity line if present
|
|
2118
2475
|
const newActivity = `Last activity: ${today} -- Phase ${currentPhase} marked complete`;
|
|
2119
2476
|
if (/^Last activity:/im.test(posBody)) {
|
|
2120
2477
|
posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
|
|
2121
2478
|
}
|
|
2122
|
-
|
|
2479
|
+
else {
|
|
2480
|
+
// Pipe-table format in Current Position (#1255)
|
|
2481
|
+
// Value must match the inline branch (date + narrative), not bare date.
|
|
2482
|
+
const activityValue = `${today} -- Phase ${currentPhase} marked complete`;
|
|
2483
|
+
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
|
|
2484
|
+
?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
|
|
2485
|
+
if (replaced !== null)
|
|
2486
|
+
posBody = replaced;
|
|
2487
|
+
}
|
|
2488
|
+
body = body.replace(positionPattern, () => `${header}${posBody}`);
|
|
2123
2489
|
updated.push('Current Position');
|
|
2124
2490
|
}
|
|
2125
|
-
return
|
|
2491
|
+
return reassemble(body);
|
|
2126
2492
|
}, cwd);
|
|
2127
2493
|
output({ updated, phase: resolvedPhase }, raw, updated.length > 0 ? 'true' : 'false');
|
|
2128
2494
|
}
|
|
@@ -2146,6 +2512,7 @@ module.exports = {
|
|
|
2146
2512
|
cmdStateUpdateProgress,
|
|
2147
2513
|
cmdStateAddDecision,
|
|
2148
2514
|
cmdStateAddBlocker,
|
|
2515
|
+
cmdStateAddRoadmapEvolution,
|
|
2149
2516
|
cmdStateResolveBlocker,
|
|
2150
2517
|
cmdStateRecordSession,
|
|
2151
2518
|
cmdStateSnapshot,
|