@opengsd/gsd-core 1.6.0 → 1.7.0-rc.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/plugin.json +1 -1
- package/agents/gsd-verifier.md +1 -0
- package/bin/gsd-mcp-server.js +31 -0
- package/bin/install.js +293 -1145
- package/commands/gsd/review.md +6 -0
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +116 -1
- package/gsd-core/bin/lib/adapter-declarative.cjs +35 -0
- package/gsd-core/bin/lib/adapter-imperative.cjs +52 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +231 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +7 -7
- package/gsd-core/bin/lib/capability-loader.cjs +18 -0
- package/gsd-core/bin/lib/capability-lock.cjs +2 -2
- package/gsd-core/bin/lib/capability-registry.cjs +889 -82
- package/gsd-core/bin/lib/capability-source.cjs +4 -4
- package/gsd-core/bin/lib/capability-validator.cjs +198 -0
- package/gsd-core/bin/lib/cli-skew-check.cjs +44 -0
- package/gsd-core/bin/lib/command-aliases.cjs +8 -0
- package/gsd-core/bin/lib/config.cjs +27 -0
- package/gsd-core/bin/lib/embedding-adapter.cjs +27 -0
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +70 -0
- package/gsd-core/bin/lib/hook-bus.cjs +81 -0
- package/gsd-core/bin/lib/host-integration.cjs +408 -0
- package/gsd-core/bin/lib/init.cjs +1 -1
- package/gsd-core/bin/lib/install-engine.cjs +755 -0
- package/gsd-core/bin/lib/install-profiles.cjs +35 -4
- package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
- package/gsd-core/bin/lib/mcp-server.cjs +194 -0
- package/gsd-core/bin/lib/milestone.cjs +27 -30
- package/gsd-core/bin/lib/model-adapter.cjs +50 -0
- package/gsd-core/bin/lib/phase.cjs +41 -72
- package/gsd-core/bin/lib/planning-workspace.cjs +1 -1
- package/gsd-core/bin/lib/probe-core.cjs +91 -1
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +129 -13
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +3 -2
- package/gsd-core/bin/lib/roadmap.cjs +17 -3
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +65 -9
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +54 -4
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +5 -2
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1 -1
- package/gsd-core/bin/lib/runtime-name-policy.cjs +160 -30
- package/gsd-core/bin/lib/shell-command-projection.cjs +37 -1
- package/gsd-core/bin/lib/stale-bake-guard.cjs +254 -0
- package/gsd-core/bin/lib/state-command-router.cjs +4 -0
- package/gsd-core/bin/lib/state-io.cjs +55 -0
- package/gsd-core/bin/lib/state-transition.cjs +1588 -0
- package/gsd-core/bin/lib/state.cjs +306 -681
- package/gsd-core/bin/lib/surface.cjs +4 -1
- package/gsd-core/bin/lib/workstream.cjs +4 -4
- package/gsd-core/bin/shared/config-schema.manifest.json +9 -0
- package/gsd-core/references/honest-verifier.md +105 -0
- package/gsd-core/references/reviewer-instances.md +99 -0
- package/gsd-core/workflows/autonomous.md +9 -9
- package/gsd-core/workflows/manager.md +15 -15
- package/gsd-core/workflows/plan-phase.md +1 -1
- package/gsd-core/workflows/review.md +26 -0
- package/gsd-core/workflows/thread.md +4 -4
- package/gsd-core/workflows/verify-phase.md +11 -4
- package/hooks/dist/gsd-graphify-update.sh +7 -1
- package/hooks/gsd-graphify-update.sh +7 -1
- package/package.json +4 -4
- package/scripts/ci-test-scope.cjs +38 -9
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
- package/scripts/lint-regression-test-names.allowlist.json +3 -0
- package/scripts/lint-test-file-count.allowlist.json +19 -5
- package/scripts/mutation-matrix.cjs +45 -3
- package/scripts/prompt-injection-scan.sh +8 -0
- package/scripts/lint-windows-test-portability.cjs +0 -178
|
@@ -33,6 +33,9 @@ const frontmatter = require("./frontmatter.cjs");
|
|
|
33
33
|
const { extractFrontmatter, reconstructFrontmatter } = frontmatter;
|
|
34
34
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
35
35
|
const scanPhasePlans = require("./plan-scan.cjs");
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
37
|
+
const stateTransitionMod = require("./state-transition.cjs");
|
|
38
|
+
const { transitionCore, applyStatePreservation } = stateTransitionMod;
|
|
36
39
|
const state_document_cjs_1 = require("./state-document.cjs");
|
|
37
40
|
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
38
41
|
const STATE_PROGRESS_RESYNC_FIELDS = new Set([
|
|
@@ -253,21 +256,18 @@ function cmdStatePatch(cwd, patches, raw) {
|
|
|
253
256
|
}
|
|
254
257
|
const statePath = planningPaths(cwd).state;
|
|
255
258
|
try {
|
|
256
|
-
const results = { updated: [], failed: [] };
|
|
257
259
|
const shouldResync = shouldResyncStateProgress(Object.keys(patches));
|
|
258
|
-
//
|
|
260
|
+
// ADR-1769 Phase 6: dispatches to the STATE.md Transition Module. The
|
|
261
|
+
// per-patch stateReplaceField loop is the pure `patchCore` in
|
|
262
|
+
// src/state-transition.cts. readModifyWriteStateMd still owns the lock, the
|
|
263
|
+
// #1230/#1264 post-sync preservation, AND the #1695 curated-current_phase_name
|
|
264
|
+
// delta (table-driven) that this phase adds. Field-name validation (security)
|
|
265
|
+
// and the resync-progress decision stay in this adapter.
|
|
266
|
+
let results = { updated: [], failed: [] };
|
|
259
267
|
readModifyWriteStateMd(statePath, (content) => {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
content = result;
|
|
264
|
-
results.updated.push(field);
|
|
265
|
-
}
|
|
266
|
-
else {
|
|
267
|
-
results.failed.push(field);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
return content;
|
|
268
|
+
const result = transitionCore(content, { kind: 'patch', patches }, { clock: clock_cjs_1.realClock, progressProvider: () => null });
|
|
269
|
+
results = result.data ?? results;
|
|
270
|
+
return result.content;
|
|
271
271
|
}, cwd, { resync: shouldResync });
|
|
272
272
|
output(results, raw, results.updated.length > 0 ? 'true' : 'false');
|
|
273
273
|
}
|
|
@@ -290,20 +290,16 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
290
290
|
try {
|
|
291
291
|
let updated = false;
|
|
292
292
|
const shouldResync = shouldResyncStateProgress([field]);
|
|
293
|
+
// ADR-1769 Phase 7: dispatches to the STATE.md Transition Module. The
|
|
294
|
+
// body-strip/reassemble single-field update is the pure `updateCore` in
|
|
295
|
+
// src/state-transition.cts. readModifyWriteStateMd still owns the lock, the
|
|
296
|
+
// #1230/#1264/#1695 post-sync preservation, and the no-op write guard.
|
|
293
297
|
// Preserve curated progress for body-only updates, but allow fields that
|
|
294
298
|
// directly project into progress.* frontmatter to rebuild after mutation.
|
|
295
299
|
readModifyWriteStateMd(statePath, (content) => {
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
updated = true;
|
|
300
|
-
const existingFm = extractFrontmatter(content);
|
|
301
|
-
if (Object.keys(existingFm).length > 0) {
|
|
302
|
-
return `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${result}`;
|
|
303
|
-
}
|
|
304
|
-
return result;
|
|
305
|
-
}
|
|
306
|
-
return content;
|
|
300
|
+
const result = transitionCore(content, { kind: 'update', field: field, value: value }, { clock: clock_cjs_1.realClock, progressProvider: () => null });
|
|
301
|
+
updated = result.data?.updated === true;
|
|
302
|
+
return result.content;
|
|
307
303
|
}, cwd, { resync: shouldResync });
|
|
308
304
|
if (updated) {
|
|
309
305
|
output({ updated: true }, false, undefined);
|
|
@@ -338,185 +334,37 @@ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
|
|
|
338
334
|
`This may indicate STATE.md was externally modified or uses an unexpected format.\n`);
|
|
339
335
|
return content;
|
|
340
336
|
}
|
|
341
|
-
/**
|
|
342
|
-
* Update fields within the ## Current Position section of STATE.md.
|
|
343
|
-
* This keeps the Current Position body in sync with the bold frontmatter fields.
|
|
344
|
-
* Only updates fields that already exist in the section; does not add new lines.
|
|
345
|
-
* Fixes #1365: advance-plan could not update Status/Last activity after begin-phase.
|
|
346
|
-
*/
|
|
347
|
-
function updateCurrentPositionFields(content, fields) {
|
|
348
|
-
// ADR-1372 T6: locate ## Current Position using tokenizeHeadings, extract the
|
|
349
|
-
// untrimmed body span, apply field edits, then splice the modified body back in.
|
|
350
|
-
// Stop predicate mirrors (?=\n##|$): any heading with level ≥ 2.
|
|
351
|
-
const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
|
|
352
|
-
const posIdx = headings.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
|
|
353
|
-
if (posIdx === -1)
|
|
354
|
-
return content;
|
|
355
|
-
const posHeading = headings[posIdx];
|
|
356
|
-
const lines = content.split('\n');
|
|
357
|
-
const posHeadingLine = lines[posHeading.line - 1];
|
|
358
|
-
const posBodyStart = posHeading.offset + posHeadingLine.length + 1;
|
|
359
|
-
let posBodyEnd = content.length;
|
|
360
|
-
for (let j = posIdx + 1; j < headings.length; j++) {
|
|
361
|
-
if (STOP_H2_PLUS(headings[j].level)) {
|
|
362
|
-
posBodyEnd = headings[j].offset - 1;
|
|
363
|
-
break;
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
let posBody = content.slice(posBodyStart, posBodyEnd);
|
|
367
|
-
const statusDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Status'];
|
|
368
|
-
const lastActivityDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
|
|
369
|
-
if (fields.status) {
|
|
370
|
-
if (/^Status:/m.test(posBody)) {
|
|
371
|
-
// Inline format: Status: value — only replace when the existing value is a
|
|
372
|
-
// known template default (Knuth invariant: preserve executor-authored values).
|
|
373
|
-
const existingStatusMatch = posBody.match(/^Status:\s*(.+)$/m);
|
|
374
|
-
const existingStatus = existingStatusMatch ? existingStatusMatch[1].trim() : null;
|
|
375
|
-
const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
|
|
376
|
-
const matchesPattern = existingStatus && state_document_cjs_1.KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
|
|
377
|
-
const isDefault = !existingStatus || isInList || matchesPattern;
|
|
378
|
-
if (isDefault) {
|
|
379
|
-
posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
else {
|
|
383
|
-
// Table format: | Status | value | — apply the same preserve-authored guard
|
|
384
|
-
// as the inline branch: only overwrite a known template default.
|
|
385
|
-
// (Finding 2 code-review: the table branch was unconditional before this fix.)
|
|
386
|
-
const existingStatus = (0, state_document_cjs_1.stateExtractField)(posBody, 'Status');
|
|
387
|
-
const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
|
|
388
|
-
const matchesPattern = existingStatus && state_document_cjs_1.KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
|
|
389
|
-
const isDefault = !existingStatus || isInList || matchesPattern;
|
|
390
|
-
if (isDefault) {
|
|
391
|
-
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', fields.status);
|
|
392
|
-
if (replaced !== null)
|
|
393
|
-
posBody = replaced;
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
if (fields.lastActivity) {
|
|
398
|
-
if (/^Last activity:/im.test(posBody)) {
|
|
399
|
-
// Inline format — only replace when the existing value is a known template
|
|
400
|
-
// default (a bare ISO date). Executor-authored narrative prose is preserved.
|
|
401
|
-
const existingActivityMatch = posBody.match(/^Last activity:\s*(.+)$/im);
|
|
402
|
-
const existingActivity = existingActivityMatch ? existingActivityMatch[1].trim() : null;
|
|
403
|
-
// A bare ISO date (YYYY-MM-DD with nothing after) is handler-generated.
|
|
404
|
-
// A date with a narrative suffix (e.g. "2026-02-15 -- blocked by infra...")
|
|
405
|
-
// was authored by the executor and must be preserved.
|
|
406
|
-
const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
|
|
407
|
-
const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
|
|
408
|
-
const isDefault = !existingActivity || isDateShape || inList;
|
|
409
|
-
if (isDefault) {
|
|
410
|
-
posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`);
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
else {
|
|
414
|
-
// Table format — apply the same preserve-authored guard as the inline branch:
|
|
415
|
-
// only overwrite a bare ISO date or a known default; preserve narrative prose.
|
|
416
|
-
// (Finding 2 code-review: the table branch was unconditional before this fix.)
|
|
417
|
-
const existingActivity = (0, state_document_cjs_1.stateExtractField)(posBody, 'Last Activity')
|
|
418
|
-
?? (0, state_document_cjs_1.stateExtractField)(posBody, 'Last activity');
|
|
419
|
-
const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
|
|
420
|
-
const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
|
|
421
|
-
const isDefault = !existingActivity || isDateShape || inList;
|
|
422
|
-
if (isDefault) {
|
|
423
|
-
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', fields.lastActivity)
|
|
424
|
-
?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', fields.lastActivity);
|
|
425
|
-
if (replaced !== null)
|
|
426
|
-
posBody = replaced;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
if (fields.plan) {
|
|
431
|
-
if (/^Plan:/m.test(posBody)) {
|
|
432
|
-
posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
|
|
433
|
-
}
|
|
434
|
-
else {
|
|
435
|
-
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Plan', fields.plan);
|
|
436
|
-
if (replaced !== null)
|
|
437
|
-
posBody = replaced;
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
// Splice the modified body back in place of the original untrimmed span.
|
|
441
|
-
return content.slice(0, posBodyStart) + posBody + content.slice(posBodyEnd);
|
|
442
|
-
}
|
|
443
337
|
function cmdStateAdvancePlan(cwd, raw) {
|
|
444
338
|
const statePath = planningPaths(cwd).state;
|
|
445
339
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
446
340
|
output({ error: 'STATE.md not found' }, raw, undefined);
|
|
447
341
|
return;
|
|
448
342
|
}
|
|
449
|
-
|
|
450
|
-
|
|
343
|
+
// ADR-1769 Phase 2: dispatches to the STATE.md Transition Module. The
|
|
344
|
+
// ~80-line RMW callback that used to live here (plan parsing, advance vs
|
|
345
|
+
// phase-complete branching, template-default-aware field replacement,
|
|
346
|
+
// Current Position section mutation) is now the pure `advancePlanCore`
|
|
347
|
+
// function in src/state-transition.cts.
|
|
348
|
+
const intent = { kind: 'advancePlan' };
|
|
349
|
+
const deps = {
|
|
350
|
+
clock: clock_cjs_1.realClock,
|
|
351
|
+
progressProvider: () => null,
|
|
352
|
+
};
|
|
353
|
+
let resultData;
|
|
451
354
|
readModifyWriteStateMd(statePath, (content) => {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const planField = (0, state_document_cjs_1.stateExtractField)(content, 'Plan');
|
|
456
|
-
let currentPlan, totalPlans;
|
|
457
|
-
let useCompoundFormat = false;
|
|
458
|
-
if (legacyPlan && legacyTotal) {
|
|
459
|
-
currentPlan = parseInt(legacyPlan, 10);
|
|
460
|
-
totalPlans = parseInt(legacyTotal, 10);
|
|
461
|
-
}
|
|
462
|
-
else if (planField) {
|
|
463
|
-
// Compound format: "2 of 6 in current phase" or "2 of 6"
|
|
464
|
-
currentPlan = parseInt(planField, 10);
|
|
465
|
-
const ofMatch = planField.match(/of\s+(\d+)/);
|
|
466
|
-
totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN;
|
|
467
|
-
useCompoundFormat = true;
|
|
468
|
-
}
|
|
469
|
-
else {
|
|
470
|
-
currentPlan = NaN;
|
|
471
|
-
totalPlans = NaN;
|
|
472
|
-
}
|
|
473
|
-
if (isNaN(currentPlan) || isNaN(totalPlans)) {
|
|
474
|
-
result = { error: true };
|
|
475
|
-
return content;
|
|
476
|
-
}
|
|
477
|
-
const statusDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Status'];
|
|
478
|
-
const lastActivityDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
|
|
479
|
-
if (currentPlan >= totalPlans) {
|
|
480
|
-
// Phase-complete branch — only replace Status/Last Activity when the existing
|
|
481
|
-
// value is a known template default (Knuth invariant: preserve executor-authored).
|
|
482
|
-
content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Status', statusDefaults, 'Phase complete — ready for verification');
|
|
483
|
-
content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Last Activity', lastActivityDefaults, today);
|
|
484
|
-
// stateReplaceFieldWithFallback tries 'Last activity' alias too
|
|
485
|
-
content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Last activity', lastActivityDefaults, today);
|
|
486
|
-
content = updateCurrentPositionFields(content, { status: 'Phase complete — ready for verification', lastActivity: today });
|
|
487
|
-
result = { advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' };
|
|
488
|
-
}
|
|
489
|
-
else {
|
|
490
|
-
const newPlan = currentPlan + 1;
|
|
491
|
-
let planDisplayValue;
|
|
492
|
-
if (useCompoundFormat) {
|
|
493
|
-
// Preserve compound format: "X of Y in current phase" → replace X only
|
|
494
|
-
planDisplayValue = planField.replace(/^\d+/, String(newPlan));
|
|
495
|
-
content = (0, state_document_cjs_1.stateReplaceField)(content, 'Plan', planDisplayValue) || content;
|
|
496
|
-
}
|
|
497
|
-
else {
|
|
498
|
-
planDisplayValue = `${newPlan} of ${totalPlans}`;
|
|
499
|
-
content = (0, state_document_cjs_1.stateReplaceField)(content, 'Current Plan', String(newPlan)) || content;
|
|
500
|
-
}
|
|
501
|
-
// Normal advance — only replace Status/Last Activity when the existing value is
|
|
502
|
-
// a known template default (Knuth invariant: preserve executor-authored).
|
|
503
|
-
content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Status', statusDefaults, 'Ready to execute');
|
|
504
|
-
content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Last Activity', lastActivityDefaults, today);
|
|
505
|
-
content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Last activity', lastActivityDefaults, today);
|
|
506
|
-
content = updateCurrentPositionFields(content, { status: 'Ready to execute', lastActivity: today, plan: planDisplayValue });
|
|
507
|
-
result = { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans };
|
|
508
|
-
}
|
|
509
|
-
return content;
|
|
355
|
+
const result = transitionCore(content, intent, deps);
|
|
356
|
+
resultData = result.data;
|
|
357
|
+
return result.content;
|
|
510
358
|
}, cwd);
|
|
511
|
-
if (!
|
|
359
|
+
if (!resultData || resultData['error']) {
|
|
512
360
|
output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
|
|
513
361
|
return;
|
|
514
362
|
}
|
|
515
|
-
if (
|
|
516
|
-
output(
|
|
363
|
+
if (resultData['advanced'] === false) {
|
|
364
|
+
output(resultData, raw, 'false');
|
|
517
365
|
}
|
|
518
366
|
else {
|
|
519
|
-
output(
|
|
367
|
+
output(resultData, raw, 'true');
|
|
520
368
|
}
|
|
521
369
|
}
|
|
522
370
|
function cmdStateRecordMetric(cwd, options, raw) {
|
|
@@ -1366,6 +1214,9 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1366
1214
|
let completedPhases = null;
|
|
1367
1215
|
let totalPlans = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
|
|
1368
1216
|
let completedPlans = null;
|
|
1217
|
+
// #1761 read-path: set from cached.milestoneBounded inside the disk-scan
|
|
1218
|
+
// block; consumed at the percent computation to mirror the cmdStateSync guard.
|
|
1219
|
+
let milestoneUnbounded = false;
|
|
1369
1220
|
if (cwd) {
|
|
1370
1221
|
try {
|
|
1371
1222
|
const phasesDir = planningPaths(cwd).phases;
|
|
@@ -1379,10 +1230,11 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1379
1230
|
// exclusion (#1514). Computed before the disk scan so retired phases
|
|
1380
1231
|
// can be dropped from the dir set too.
|
|
1381
1232
|
let roadmapScope = null;
|
|
1233
|
+
let roadmapRaw = null;
|
|
1382
1234
|
let retiredPhaseNums = new Set();
|
|
1383
1235
|
try {
|
|
1384
1236
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
1385
|
-
|
|
1237
|
+
roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
|
|
1386
1238
|
if (roadmapRaw !== null) {
|
|
1387
1239
|
roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
|
1388
1240
|
retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope);
|
|
@@ -1457,20 +1309,36 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1457
1309
|
roadmapPhaseCount++;
|
|
1458
1310
|
}
|
|
1459
1311
|
}
|
|
1460
|
-
cached = {
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1312
|
+
cached = (() => {
|
|
1313
|
+
// #1761 read-path: mirror the cmdStateSync guard (#1794). When the
|
|
1314
|
+
// asserted milestone version can't be bounded to a versioned ROADMAP
|
|
1315
|
+
// heading, extractCurrentMilestone falls back to the whole document
|
|
1316
|
+
// and roadmapPhaseCount conflates sibling milestones. In that case
|
|
1317
|
+
// don't substitute the whole-doc count — fall back to the on-disk
|
|
1318
|
+
// phase-dir count only, and mark unbounded so percent is skipped
|
|
1319
|
+
// downstream (mirrors the sync write-path guard).
|
|
1320
|
+
let milestoneBounded = true;
|
|
1321
|
+
if (milestone && roadmapRaw !== null) {
|
|
1322
|
+
const versionedHeading = new RegExp(`^#{1,3}\\s+(?!Phase\\s+\\S).*${escapeRegex(String(milestone).trim())}`, 'mi');
|
|
1323
|
+
milestoneBounded = versionedHeading.test(roadmapRaw);
|
|
1324
|
+
}
|
|
1325
|
+
return {
|
|
1326
|
+
totalPhases: (!milestoneBounded || roadmapPhaseCount === 0)
|
|
1327
|
+
? phaseDirs.length
|
|
1328
|
+
: Math.max(phaseDirs.length, roadmapPhaseCount),
|
|
1329
|
+
milestoneBounded,
|
|
1330
|
+
completedPhases: diskCompletedPhases,
|
|
1331
|
+
totalPlans: diskTotalPlans,
|
|
1332
|
+
completedPlans: diskTotalSummaries,
|
|
1333
|
+
};
|
|
1334
|
+
})();
|
|
1468
1335
|
_diskScanCache.set(cwd, cached);
|
|
1469
1336
|
}
|
|
1470
1337
|
totalPhases = cached.totalPhases;
|
|
1471
1338
|
completedPhases = cached.completedPhases;
|
|
1472
1339
|
totalPlans = cached.totalPlans;
|
|
1473
1340
|
completedPlans = cached.completedPlans;
|
|
1341
|
+
milestoneUnbounded = cached.milestoneBounded === false;
|
|
1474
1342
|
}
|
|
1475
1343
|
}
|
|
1476
1344
|
catch { /* intentionally empty */ }
|
|
@@ -1481,7 +1349,11 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1481
1349
|
// instead of a false 100% from plan-only coverage (#3242 Bug B).
|
|
1482
1350
|
// Falls back to the body Progress: field only when no plan files exist on disk.
|
|
1483
1351
|
let progressPercent = (0, state_document_cjs_1.computeProgressPercent)(completedPlans, totalPlans, completedPhases, totalPhases);
|
|
1484
|
-
|
|
1352
|
+
// #1761 read-path: when the milestone can't be bounded, percent would be
|
|
1353
|
+
// derived from a conflated/understated total — skip it (mirror cmdStateSync).
|
|
1354
|
+
if (milestoneUnbounded)
|
|
1355
|
+
progressPercent = null;
|
|
1356
|
+
if (progressPercent === null && progressRaw && !milestoneUnbounded) {
|
|
1485
1357
|
const pctMatch = progressRaw.match(/(\d+)%/);
|
|
1486
1358
|
if (pctMatch)
|
|
1487
1359
|
progressPercent = parseInt(pctMatch[1], 10);
|
|
@@ -1779,7 +1651,7 @@ function acquireStateLock(statePath, clock) {
|
|
|
1779
1651
|
const stolen = lockPath + '.stale-' + process.pid + '-' + clock.now() + '-' + (_stateStealSeq++);
|
|
1780
1652
|
let renamed = false;
|
|
1781
1653
|
try {
|
|
1782
|
-
|
|
1654
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(lockPath, stolen);
|
|
1783
1655
|
renamed = true;
|
|
1784
1656
|
}
|
|
1785
1657
|
catch { /* another racer won */ }
|
|
@@ -1920,6 +1792,13 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
1920
1792
|
const preSessionMatch = matchSessionSection(preBody);
|
|
1921
1793
|
const preSessionScope = preSessionMatch ? preSessionMatch[1] : preBody;
|
|
1922
1794
|
const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
|
|
1795
|
+
// ADR-1769 Phase 6 / #1743 / #1695: snapshot the body source for the curated
|
|
1796
|
+
// current_phase_name (the `Phase:` line parseProsePhaseField harvests). When
|
|
1797
|
+
// this write does NOT change that line, the curated frontmatter value must
|
|
1798
|
+
// win over syncStateFrontmatter's body re-derivation (which can harvest a
|
|
1799
|
+
// wrong parenthetical aside — #1695). Gated by the field-classification
|
|
1800
|
+
// table's preserve-always row so the rule lives in one place.
|
|
1801
|
+
const preBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(preBody, 'Phase');
|
|
1923
1802
|
const modified = transformFn(content);
|
|
1924
1803
|
// Bug #948: no-op guard — if the transform produced no change, do NOT write
|
|
1925
1804
|
// the file. An unconditional write would bump `last_updated`, reset
|
|
@@ -1932,13 +1811,9 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
1932
1811
|
return;
|
|
1933
1812
|
}
|
|
1934
1813
|
let synced = syncStateFrontmatter(modified, cwd);
|
|
1935
|
-
// Compute postFm once and apply BOTH the progress-restore (when !resync)
|
|
1936
|
-
// AND the status/stopped_at preservation (#1230) before reconstructing.
|
|
1937
|
-
// This avoids double-wrapping the frontmatter block.
|
|
1938
|
-
const needsProgressRestore = !resync && preFm && preFm['progress'];
|
|
1939
1814
|
// Post-transform body source fields used for the delta comparison (#1230).
|
|
1940
1815
|
// 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.
|
|
1941
|
-
// Strip frontmatter so the YAML status key cannot shadow the body field.
|
|
1816
|
+
// Strip frontmatter so the YAML status key cannot shadow the body field we are tracking.
|
|
1942
1817
|
const postBody = stripFrontmatter(modified);
|
|
1943
1818
|
const postBodyStatus = (0, state_document_cjs_1.stateExtractField)(postBody, 'Status');
|
|
1944
1819
|
// Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
|
|
@@ -1946,40 +1821,25 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
1946
1821
|
const postSessionMatch = matchSessionSection(postBody);
|
|
1947
1822
|
const postSessionScope = postSessionMatch ? postSessionMatch[1] : postBody;
|
|
1948
1823
|
const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
|
|
1949
|
-
|
|
1824
|
+
// ADR-1769 Phase 6 / #1695: post-transform body Phase source for the
|
|
1825
|
+
// current_phase_name delta comparison.
|
|
1826
|
+
const postBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(postBody, 'Phase');
|
|
1827
|
+
// ADR-1769 #1796 (Path A — finish the consolidation): the post-sync
|
|
1828
|
+
// preservation block is now the pure, table-driven `applyStatePreservation`
|
|
1829
|
+
// in the STATE.md Transition Module. progress / status / stopped_at /
|
|
1830
|
+
// current_phase_name are all governed by their FIELD_CLASSIFICATION row —
|
|
1831
|
+
// one policy source, not three drifting encodings. Behavior-identical to
|
|
1832
|
+
// the pre-#1796 inline block; this is the absorption ADR-1769 / CONTEXT.md
|
|
1833
|
+
// already claimed shipped.
|
|
1950
1834
|
const postFm = extractFrontmatter(synced);
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
}
|
|
1960
|
-
// Bug #1230: preserve existing frontmatter status when this write did NOT
|
|
1961
|
-
// change the body's Status field. A write that doesn't touch Status must
|
|
1962
|
-
// not silently revert a hand-set frontmatter status (e.g. 'completed') to
|
|
1963
|
-
// whatever the stale body Status happens to derive (e.g. 'verifying').
|
|
1964
|
-
// Only apply when the existing frontmatter held a real, non-unknown status.
|
|
1965
|
-
if (postBodyStatus === preBodyStatus &&
|
|
1966
|
-
typeof preFmSnapshot['status'] === 'string' &&
|
|
1967
|
-
preFmSnapshot['status'].length > 0 &&
|
|
1968
|
-
preFmSnapshot['status'] !== 'unknown' &&
|
|
1969
|
-
postFm['status'] !== preFmSnapshot['status']) {
|
|
1970
|
-
postFm['status'] = preFmSnapshot['status'];
|
|
1971
|
-
mutated = true;
|
|
1972
|
-
}
|
|
1973
|
-
// Bug #1230: same delta heuristic for stopped_at.
|
|
1974
|
-
if (postBodyStoppedAt === preBodyStoppedAt &&
|
|
1975
|
-
typeof preFmSnapshot['stopped_at'] === 'string' &&
|
|
1976
|
-
preFmSnapshot['stopped_at'].length > 0 &&
|
|
1977
|
-
postFm['stopped_at'] !== preFmSnapshot['stopped_at']) {
|
|
1978
|
-
postFm['stopped_at'] = preFmSnapshot['stopped_at'];
|
|
1979
|
-
mutated = true;
|
|
1980
|
-
}
|
|
1981
|
-
if (mutated) {
|
|
1982
|
-
const yamlStr = reconstructFrontmatter(postFm);
|
|
1835
|
+
const preservation = applyStatePreservation({
|
|
1836
|
+
preFm, postFm, preFmSnapshot, resync,
|
|
1837
|
+
preBodyStatus, postBodyStatus,
|
|
1838
|
+
preBodyStoppedAt, postBodyStoppedAt,
|
|
1839
|
+
preBodyPhaseSource, postBodyPhaseSource,
|
|
1840
|
+
});
|
|
1841
|
+
if (preservation.mutated) {
|
|
1842
|
+
const yamlStr = reconstructFrontmatter(preservation.postFm);
|
|
1983
1843
|
const body = stripFrontmatter(synced);
|
|
1984
1844
|
synced = `---\n${yamlStr}\n---\n\n${body}`;
|
|
1985
1845
|
}
|
|
@@ -2044,201 +1904,27 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
2044
1904
|
output({ error: 'STATE.md not found' }, raw, undefined);
|
|
2045
1905
|
return;
|
|
2046
1906
|
}
|
|
2047
|
-
|
|
2048
|
-
|
|
1907
|
+
// ADR-1769 Phase 1: dispatches to the STATE.md Transition Module. The 175-line
|
|
1908
|
+
// RMW callback that used to live here (format detection + preservation policy
|
|
1909
|
+
// + section mutation + idempotency guard + resume branching) is now the pure
|
|
1910
|
+
// `transitionCore` function in src/state-transition.cts, backed by the
|
|
1911
|
+
// field-classification table. readModifyWriteStateMd still owns the lock,
|
|
1912
|
+
// #1230 post-sync preservation, and the no-op write guard.
|
|
1913
|
+
const intent = {
|
|
1914
|
+
kind: 'beginPhase',
|
|
1915
|
+
phaseNumber,
|
|
1916
|
+
phaseName: phaseName ?? null,
|
|
1917
|
+
planCount: planCount ?? null,
|
|
1918
|
+
};
|
|
1919
|
+
const deps = {
|
|
1920
|
+
clock: clock_cjs_1.realClock,
|
|
1921
|
+
progressProvider: () => null, // beginPhase doesn't consult disk progress; syncStateFrontmatter's scan is authoritative
|
|
1922
|
+
};
|
|
1923
|
+
let updated = [];
|
|
2049
1924
|
readModifyWriteStateMd(statePath, (content) => {
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
// plain-text pattern (`^Status:\s*`) before the body pipe-table row, so the
|
|
2054
|
-
// pipe-table `| Status | Planning |` is never updated and syncStateFrontmatter
|
|
2055
|
-
// re-derives 'planning' from the unchanged body — the status never advances.
|
|
2056
|
-
const existingFm = extractFrontmatter(content);
|
|
2057
|
-
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
|
2058
|
-
let body = stripFrontmatter(content);
|
|
2059
|
-
// Helper to reassemble content for field-replacement checks; callers that
|
|
2060
|
-
// only need to test/replace body fields use `body` directly, and the final
|
|
2061
|
-
// return reassembles the frontmatter block with the updated body.
|
|
2062
|
-
const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
|
|
2063
|
-
// Idempotency guard (#3127): if the phase is already mid-flight, do NOT
|
|
2064
|
-
// overwrite execution-progress fields (Current Plan, plan body line,
|
|
2065
|
-
// Last Activity Description). Only update fields that are safe to
|
|
2066
|
-
// refresh on resume (Last Activity date, Status if inconsistent).
|
|
2067
|
-
// A phase is considered mid-flight when Status contains 'Executing Phase N'
|
|
2068
|
-
// for the current phase number.
|
|
2069
|
-
// #1255: extract from body (not full content) so the YAML `status:` key
|
|
2070
|
-
// cannot shadow the body Status field.
|
|
2071
|
-
const currentStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status') || '';
|
|
2072
|
-
const isAlreadyExecuting = new RegExp(`Executing Phase\\s+${escapeRegex(String(phaseNumber))}\\b`, 'i').test(currentStatus);
|
|
2073
|
-
// Update Status field (body only — #1255)
|
|
2074
|
-
const statusValue = `Executing Phase ${phaseNumber}`;
|
|
2075
|
-
let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
|
|
2076
|
-
if (result) {
|
|
2077
|
-
body = result;
|
|
2078
|
-
updated.push('Status');
|
|
2079
|
-
}
|
|
2080
|
-
// Update Last Activity (safe to update on resume — tracks when execute-phase ran)
|
|
2081
|
-
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity', today);
|
|
2082
|
-
if (result) {
|
|
2083
|
-
body = result;
|
|
2084
|
-
updated.push('Last Activity');
|
|
2085
|
-
}
|
|
2086
|
-
if (!isAlreadyExecuting) {
|
|
2087
|
-
// First-time execution: set all progress fields
|
|
2088
|
-
// Update Last Activity Description
|
|
2089
|
-
const activityDesc = `Phase ${phaseNumber} execution started`;
|
|
2090
|
-
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
|
|
2091
|
-
if (result) {
|
|
2092
|
-
body = result;
|
|
2093
|
-
updated.push('Last Activity Description');
|
|
2094
|
-
}
|
|
2095
|
-
// Update Current Phase
|
|
2096
|
-
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Phase', String(phaseNumber));
|
|
2097
|
-
if (result) {
|
|
2098
|
-
body = result;
|
|
2099
|
-
updated.push('Current Phase');
|
|
2100
|
-
}
|
|
2101
|
-
// Update Current Phase Name
|
|
2102
|
-
if (phaseName) {
|
|
2103
|
-
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Phase Name', phaseName);
|
|
2104
|
-
if (result) {
|
|
2105
|
-
body = result;
|
|
2106
|
-
updated.push('Current Phase Name');
|
|
2107
|
-
}
|
|
2108
|
-
}
|
|
2109
|
-
// Update Current Plan to 1 (starting from the first plan)
|
|
2110
|
-
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Plan', '1');
|
|
2111
|
-
if (result) {
|
|
2112
|
-
body = result;
|
|
2113
|
-
updated.push('Current Plan');
|
|
2114
|
-
}
|
|
2115
|
-
// Update Total Plans in Phase
|
|
2116
|
-
if (planCount) {
|
|
2117
|
-
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Total Plans in Phase', String(planCount));
|
|
2118
|
-
if (result) {
|
|
2119
|
-
body = result;
|
|
2120
|
-
updated.push('Total Plans in Phase');
|
|
2121
|
-
}
|
|
2122
|
-
}
|
|
2123
|
-
// Update **Current focus:** body text line (#1104)
|
|
2124
|
-
const focusLabel = phaseName ? `Phase ${phaseNumber} — ${phaseName}` : `Phase ${phaseNumber}`;
|
|
2125
|
-
const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
|
|
2126
|
-
if (focusPattern.test(body)) {
|
|
2127
|
-
body = body.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
|
|
2128
|
-
updated.push('Current focus');
|
|
2129
|
-
}
|
|
2130
|
-
// Update ## Current Position section (#1104, #1365)
|
|
2131
|
-
// ADR-1372 T6: positionPattern → tokenizeHeadings + spliceStateSection.
|
|
2132
|
-
// Mirrors /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; stop at level ≥ 2.
|
|
2133
|
-
const posHs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body);
|
|
2134
|
-
const posIdx = posHs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
|
|
2135
|
-
if (posIdx !== -1) {
|
|
2136
|
-
const posH = posHs[posIdx];
|
|
2137
|
-
const bodyLines = body.split('\n');
|
|
2138
|
-
const posHL = bodyLines[posH.line - 1];
|
|
2139
|
-
const posBodyStart = posH.offset + posHL.length + 1;
|
|
2140
|
-
let posBodyEnd = body.length;
|
|
2141
|
-
for (let j = posIdx + 1; j < posHs.length; j++) {
|
|
2142
|
-
if (STOP_H2_PLUS(posHs[j].level)) {
|
|
2143
|
-
posBodyEnd = posHs[j].offset - 1;
|
|
2144
|
-
break;
|
|
2145
|
-
}
|
|
2146
|
-
}
|
|
2147
|
-
let posBody = body.slice(posBodyStart, posBodyEnd);
|
|
2148
|
-
// Update or insert Phase line
|
|
2149
|
-
const newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
|
|
2150
|
-
if (/^Phase:/m.test(posBody)) {
|
|
2151
|
-
posBody = posBody.replace(/^Phase:.*$/m, newPhase);
|
|
2152
|
-
}
|
|
2153
|
-
else {
|
|
2154
|
-
// Pipe-table format in Current Position (#1257): update the | Phase | … |
|
|
2155
|
-
// cell rather than prepending a spurious inline `Phase:` line (which left
|
|
2156
|
-
// the table cell stale). Mirrors the Status/Last-activity table branches.
|
|
2157
|
-
const phaseValue = `${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
|
|
2158
|
-
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Phase', phaseValue);
|
|
2159
|
-
if (replaced !== null)
|
|
2160
|
-
posBody = replaced;
|
|
2161
|
-
}
|
|
2162
|
-
// Update or insert Plan line
|
|
2163
|
-
const newPlan = `Plan: 1 of ${planCount || '?'}`;
|
|
2164
|
-
if (/^Plan:/m.test(posBody)) {
|
|
2165
|
-
posBody = posBody.replace(/^Plan:.*$/m, newPlan);
|
|
2166
|
-
}
|
|
2167
|
-
else {
|
|
2168
|
-
// Pipe-table format in Current Position (#1257): update the | Plan | … |
|
|
2169
|
-
// cell rather than appending after a prepended inline line.
|
|
2170
|
-
const planValue = `1 of ${planCount || '?'}`;
|
|
2171
|
-
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Plan', planValue);
|
|
2172
|
-
if (replaced !== null)
|
|
2173
|
-
posBody = replaced;
|
|
2174
|
-
}
|
|
2175
|
-
// Update Status line if present
|
|
2176
|
-
const newStatus = `Status: Executing Phase ${phaseNumber}`;
|
|
2177
|
-
if (/^Status:/m.test(posBody)) {
|
|
2178
|
-
posBody = posBody.replace(/^Status:.*$/m, newStatus);
|
|
2179
|
-
}
|
|
2180
|
-
else {
|
|
2181
|
-
// Pipe-table format in Current Position (#1255)
|
|
2182
|
-
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', `Executing Phase ${phaseNumber}`);
|
|
2183
|
-
if (replaced !== null)
|
|
2184
|
-
posBody = replaced;
|
|
2185
|
-
}
|
|
2186
|
-
// Update Last activity line if present
|
|
2187
|
-
const newActivity = `Last activity: ${today} — Phase ${phaseNumber} execution started`;
|
|
2188
|
-
if (/^Last activity:/im.test(posBody)) {
|
|
2189
|
-
posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
|
|
2190
|
-
}
|
|
2191
|
-
else {
|
|
2192
|
-
// Pipe-table format in Current Position (#1255)
|
|
2193
|
-
// Value must match the inline branch (date + narrative), not bare date.
|
|
2194
|
-
const activityValue = `${today} — Phase ${phaseNumber} execution started`;
|
|
2195
|
-
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
|
|
2196
|
-
?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
|
|
2197
|
-
if (replaced !== null)
|
|
2198
|
-
posBody = replaced;
|
|
2199
|
-
}
|
|
2200
|
-
body = body.slice(0, posBodyStart) + posBody + body.slice(posBodyEnd);
|
|
2201
|
-
updated.push('Current Position');
|
|
2202
|
-
}
|
|
2203
|
-
}
|
|
2204
|
-
else {
|
|
2205
|
-
// Resume path: only update Last activity timestamp in Current Position
|
|
2206
|
-
// (do not touch Plan:, stopped_at, progress.percent, or plan counter)
|
|
2207
|
-
// ADR-1372 T6: positionPattern → tokenizeHeadings; stop at level ≥ 2.
|
|
2208
|
-
const posHsR = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body);
|
|
2209
|
-
const posIdxR = posHsR.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
|
|
2210
|
-
if (posIdxR !== -1) {
|
|
2211
|
-
const posHR = posHsR[posIdxR];
|
|
2212
|
-
const bodyLinesR = body.split('\n');
|
|
2213
|
-
const posHLR = bodyLinesR[posHR.line - 1];
|
|
2214
|
-
const posBodyStartR = posHR.offset + posHLR.length + 1;
|
|
2215
|
-
let posBodyEndR = body.length;
|
|
2216
|
-
for (let j = posIdxR + 1; j < posHsR.length; j++) {
|
|
2217
|
-
if (STOP_H2_PLUS(posHsR[j].level)) {
|
|
2218
|
-
posBodyEndR = posHsR[j].offset - 1;
|
|
2219
|
-
break;
|
|
2220
|
-
}
|
|
2221
|
-
}
|
|
2222
|
-
let posBody = body.slice(posBodyStartR, posBodyEndR);
|
|
2223
|
-
const resumeActivity = `Last activity: ${today} — Phase ${phaseNumber} execution resumed (wave continue)`;
|
|
2224
|
-
if (/^Last activity:/im.test(posBody)) {
|
|
2225
|
-
posBody = posBody.replace(/^Last activity:.*$/im, resumeActivity);
|
|
2226
|
-
body = body.slice(0, posBodyStartR) + posBody + body.slice(posBodyEndR);
|
|
2227
|
-
updated.push('Last activity (resume)');
|
|
2228
|
-
}
|
|
2229
|
-
else {
|
|
2230
|
-
// Pipe-table format in Current Position (#1255)
|
|
2231
|
-
const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', resumeActivity)
|
|
2232
|
-
?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', resumeActivity);
|
|
2233
|
-
if (replaced !== null) {
|
|
2234
|
-
posBody = replaced;
|
|
2235
|
-
body = body.slice(0, posBodyStartR) + posBody + body.slice(posBodyEndR);
|
|
2236
|
-
updated.push('Last activity (resume)');
|
|
2237
|
-
}
|
|
2238
|
-
}
|
|
2239
|
-
}
|
|
2240
|
-
}
|
|
2241
|
-
return reassemble(body);
|
|
1925
|
+
const result = transitionCore(content, intent, deps);
|
|
1926
|
+
updated = result.updated;
|
|
1927
|
+
return result.content;
|
|
2242
1928
|
}, cwd);
|
|
2243
1929
|
output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
|
|
2244
1930
|
}
|
|
@@ -2355,63 +2041,28 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
|
|
|
2355
2041
|
output({ error: 'STATE.md not found' }, raw, undefined);
|
|
2356
2042
|
return;
|
|
2357
2043
|
}
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
//
|
|
2363
|
-
//
|
|
2364
|
-
//
|
|
2365
|
-
//
|
|
2044
|
+
// ADR-1769 Phase 4: dispatches to the STATE.md Transition Module. The RMW
|
|
2045
|
+
// callback that lived here (body strip/reassemble, template-aware Status +
|
|
2046
|
+
// Last Activity, Total Plans in Phase, Last Activity Description, Current
|
|
2047
|
+
// Position section update) is the pure `plannedPhaseCore` in
|
|
2048
|
+
// src/state-transition.cts, backed by the field-classification table.
|
|
2049
|
+
// resync:false is preserved: plan-phase must NOT re-derive milestone-wide
|
|
2050
|
+
// progress.* from a half-planned disk snapshot (#500 RC1). readModifyWriteStateMd
|
|
2051
|
+
// still owns the lock, the #1230 preservation, and the no-op write guard.
|
|
2052
|
+
const intent = {
|
|
2053
|
+
kind: 'plannedPhase',
|
|
2054
|
+
phaseNumber,
|
|
2055
|
+
planCount: planCount ?? null,
|
|
2056
|
+
};
|
|
2057
|
+
const deps = {
|
|
2058
|
+
clock: clock_cjs_1.realClock,
|
|
2059
|
+
progressProvider: () => null,
|
|
2060
|
+
};
|
|
2061
|
+
let updated = [];
|
|
2366
2062
|
readModifyWriteStateMd(statePath, (content) => {
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
// the plain-text pattern (`^Status:\s*`) before the body pipe-table row, so the
|
|
2371
|
-
// pipe-table `| Status | Planning |` cell is never updated and syncStateFrontmatter
|
|
2372
|
-
// re-derives 'planning' from the unchanged body — the status never advances.
|
|
2373
|
-
// (Mirrors the begin/complete-phase fix from #1255/#1256.)
|
|
2374
|
-
const existingFm = extractFrontmatter(content);
|
|
2375
|
-
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
|
2376
|
-
let body = stripFrontmatter(content);
|
|
2377
|
-
const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
|
|
2378
|
-
// Update Status — only when the existing value is a known template default
|
|
2379
|
-
// (Knuth invariant: preserve executor-authored values).
|
|
2380
|
-
const newBody = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Status', statusDefaults, 'Ready to execute');
|
|
2381
|
-
if (newBody !== body) {
|
|
2382
|
-
body = newBody;
|
|
2383
|
-
updated.push('Status');
|
|
2384
|
-
}
|
|
2385
|
-
// Update Total Plans in Phase
|
|
2386
|
-
if (planCount !== null && planCount !== undefined) {
|
|
2387
|
-
const result = (0, state_document_cjs_1.stateReplaceField)(body, 'Total Plans in Phase', String(planCount));
|
|
2388
|
-
if (result) {
|
|
2389
|
-
body = result;
|
|
2390
|
-
updated.push('Total Plans in Phase');
|
|
2391
|
-
}
|
|
2392
|
-
}
|
|
2393
|
-
// Update Last Activity — only when the existing value is a known template default
|
|
2394
|
-
{
|
|
2395
|
-
const after = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last Activity', lastActivityDefaults, today);
|
|
2396
|
-
if (after !== body) {
|
|
2397
|
-
body = after;
|
|
2398
|
-
updated.push('Last Activity');
|
|
2399
|
-
}
|
|
2400
|
-
}
|
|
2401
|
-
// Update Last Activity Description
|
|
2402
|
-
{
|
|
2403
|
-
const result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`);
|
|
2404
|
-
if (result) {
|
|
2405
|
-
body = result;
|
|
2406
|
-
updated.push('Last Activity Description');
|
|
2407
|
-
}
|
|
2408
|
-
}
|
|
2409
|
-
// Update Current Position section
|
|
2410
|
-
body = updateCurrentPositionFields(body, {
|
|
2411
|
-
status: 'Ready to execute',
|
|
2412
|
-
lastActivity: `${today} — Phase ${phaseNumber} planning complete`,
|
|
2413
|
-
});
|
|
2414
|
-
return reassemble(body);
|
|
2063
|
+
const result = transitionCore(content, intent, deps);
|
|
2064
|
+
updated = result.updated;
|
|
2065
|
+
return result.content;
|
|
2415
2066
|
}, cwd, { resync: false });
|
|
2416
2067
|
output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
|
|
2417
2068
|
}
|
|
@@ -2428,57 +2079,19 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
|
|
|
2428
2079
|
}
|
|
2429
2080
|
const resolvedName = (name && String(name).trim()) || 'milestone';
|
|
2430
2081
|
const statePath = planningPaths(cwd).state;
|
|
2431
|
-
|
|
2082
|
+
// ADR-1769 Phase 4: dispatches to the STATE.md Transition Module. The reset
|
|
2083
|
+
// policy (frontmatter rebuild + Current Position body reset) is the pure
|
|
2084
|
+
// `milestoneSwitchCore` in src/state-transition.cts. acquireStateLock +
|
|
2085
|
+
// platformWriteSync are retained (NOT readModifyWriteStateMd) because
|
|
2086
|
+
// milestoneSwitch rebuilds frontmatter directly and must not run the
|
|
2087
|
+
// steady-state syncStateFrontmatter post-sync.
|
|
2088
|
+
const intent = { kind: 'milestoneSwitch', version, name: resolvedName };
|
|
2089
|
+
const deps = { clock: clock_cjs_1.realClock, progressProvider: () => null };
|
|
2432
2090
|
const lockPath = acquireStateLock(statePath);
|
|
2433
2091
|
try {
|
|
2434
2092
|
const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
2435
|
-
const
|
|
2436
|
-
|
|
2437
|
-
// ADR-1372 T6: positionPattern → tokenizeHeadings + spliceStateSection.
|
|
2438
|
-
// Mirrors /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; stop at level ≥ 2.
|
|
2439
|
-
const resetPositionBody = `\nPhase: Not started (defining requirements)\n` +
|
|
2440
|
-
`Plan: —\n` +
|
|
2441
|
-
`Status: Defining requirements\n` +
|
|
2442
|
-
`Last activity: ${today} — Milestone ${version} started\n\n`;
|
|
2443
|
-
let newBody;
|
|
2444
|
-
const msPosHs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body);
|
|
2445
|
-
const msPosIdx = msPosHs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
|
|
2446
|
-
if (msPosIdx !== -1) {
|
|
2447
|
-
const msPosH = msPosHs[msPosIdx];
|
|
2448
|
-
const msBodyLines = body.split('\n');
|
|
2449
|
-
const msPosHL = msBodyLines[msPosH.line - 1];
|
|
2450
|
-
const msPosBodyStart = msPosH.offset + msPosHL.length + 1;
|
|
2451
|
-
let msPosBodyEnd = body.length;
|
|
2452
|
-
for (let j = msPosIdx + 1; j < msPosHs.length; j++) {
|
|
2453
|
-
if (STOP_H2_PLUS(msPosHs[j].level)) {
|
|
2454
|
-
msPosBodyEnd = msPosHs[j].offset - 1;
|
|
2455
|
-
break;
|
|
2456
|
-
}
|
|
2457
|
-
}
|
|
2458
|
-
newBody = body.slice(0, msPosBodyStart) + resetPositionBody + body.slice(msPosBodyEnd);
|
|
2459
|
-
}
|
|
2460
|
-
else {
|
|
2461
|
-
const preface = body.trim().length > 0 ? body : '# Project State\n';
|
|
2462
|
-
newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
|
|
2463
|
-
}
|
|
2464
|
-
const fm = {
|
|
2465
|
-
gsd_state_version: existingFm['gsd_state_version'] || '1.0',
|
|
2466
|
-
milestone: version,
|
|
2467
|
-
milestone_name: resolvedName,
|
|
2468
|
-
status: 'planning',
|
|
2469
|
-
last_updated: clock_cjs_1.realClock.nowIso(),
|
|
2470
|
-
last_activity: today,
|
|
2471
|
-
progress: {
|
|
2472
|
-
total_phases: 0,
|
|
2473
|
-
completed_phases: 0,
|
|
2474
|
-
total_plans: 0,
|
|
2475
|
-
completed_plans: 0,
|
|
2476
|
-
percent: 0,
|
|
2477
|
-
},
|
|
2478
|
-
};
|
|
2479
|
-
const yamlStr = reconstructFrontmatter(fm);
|
|
2480
|
-
const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
|
|
2481
|
-
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, assembled);
|
|
2093
|
+
const result = transitionCore(content, intent, deps);
|
|
2094
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, result.content);
|
|
2482
2095
|
output({ switched: true, version, name: resolvedName, status: 'planning' }, raw, 'true');
|
|
2483
2096
|
}
|
|
2484
2097
|
finally {
|
|
@@ -2559,7 +2172,6 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
2559
2172
|
const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
2560
2173
|
const changes = [];
|
|
2561
2174
|
let modified = content;
|
|
2562
|
-
const today = clock_cjs_1.realClock.today();
|
|
2563
2175
|
const phasesDir = planningPaths(cwd).phases;
|
|
2564
2176
|
if (!node_fs_1.default.existsSync(phasesDir)) {
|
|
2565
2177
|
output({ synced: true, changes: [], dry_run: !!verify }, raw, undefined);
|
|
@@ -2570,10 +2182,12 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
2570
2182
|
// exactly as buildStateFrontmatter does — otherwise `state sync --verify`
|
|
2571
2183
|
// would keep re-deriving the inflated denominator and report "no drift".
|
|
2572
2184
|
let syncRoadmapScope = null;
|
|
2185
|
+
let syncRoadmapRaw = null;
|
|
2573
2186
|
let syncRetiredPhaseNums = new Set();
|
|
2574
2187
|
try {
|
|
2575
2188
|
const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'));
|
|
2576
2189
|
if (roadmapRaw !== null) {
|
|
2190
|
+
syncRoadmapRaw = roadmapRaw;
|
|
2577
2191
|
syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
|
2578
2192
|
syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope);
|
|
2579
2193
|
}
|
|
@@ -2653,46 +2267,32 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
2653
2267
|
}
|
|
2654
2268
|
}
|
|
2655
2269
|
catch { /* intentionally empty */ }
|
|
2656
|
-
//
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2270
|
+
// ADR-1769 Phase 7: the body writes (Total Plans in Phase, Progress bar, Last
|
|
2271
|
+
// Activity) are the pure `syncCore` in src/state-transition.cts.
|
|
2272
|
+
// #1761: when a milestone version is set in frontmatter but the ROADMAP has no
|
|
2273
|
+
// versioned heading for it, the milestone cannot be bounded to a versioned phase
|
|
2274
|
+
// set — leave Progress untouched (percent=null) rather than silently writing
|
|
2275
|
+
// fallback-derived wrong values. Projects without a milestone version (the common
|
|
2276
|
+
// sync-test shape) are unaffected: the gate only fires when a version is asserted.
|
|
2277
|
+
const fmVersion = extractFrontmatter(content).milestone;
|
|
2278
|
+
const versionStr = typeof fmVersion === 'string' && fmVersion.trim() ? fmVersion.trim() : null;
|
|
2279
|
+
let milestoneBounded = true;
|
|
2280
|
+
if (versionStr !== null && syncRoadmapRaw !== null) {
|
|
2281
|
+
const versionedHeading = new RegExp(`^#{1,3}\\s+(?!Phase\\s+\\S).*${escapeRegex(versionStr)}`, 'mi');
|
|
2282
|
+
milestoneBounded = versionedHeading.test(syncRoadmapRaw);
|
|
2283
|
+
}
|
|
2284
|
+
let percent = null;
|
|
2285
|
+
if (!milestoneBounded) {
|
|
2286
|
+
changes.push(`Progress: skipped — milestone ${versionStr} cannot be bounded to a versioned ROADMAP phase set (#1761)`);
|
|
2665
2287
|
}
|
|
2666
|
-
|
|
2667
|
-
// computeProgressPercent applies min(plan_fraction, phase_fraction) so unrealised
|
|
2668
|
-
// ROADMAP phases cap the reported percent rather than allowing a false 100%.
|
|
2669
|
-
const percent = (() => {
|
|
2288
|
+
else {
|
|
2670
2289
|
const p = (0, state_document_cjs_1.computeProgressPercent)(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases);
|
|
2671
|
-
|
|
2672
|
-
})();
|
|
2673
|
-
const currentProgress = (0, state_document_cjs_1.stateExtractField)(modified, 'Progress');
|
|
2674
|
-
if (currentProgress) {
|
|
2675
|
-
const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
|
|
2676
|
-
if (currentPercent !== percent) {
|
|
2677
|
-
const barWidth = 10;
|
|
2678
|
-
const filled = Math.round(percent / 100 * barWidth);
|
|
2679
|
-
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
2680
|
-
const progressStr = `[${bar}] ${percent}%`;
|
|
2681
|
-
changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
|
|
2682
|
-
const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Progress', progressStr);
|
|
2683
|
-
if (result)
|
|
2684
|
-
modified = result;
|
|
2685
|
-
}
|
|
2686
|
-
}
|
|
2687
|
-
// Sync Last Activity
|
|
2688
|
-
const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Last Activity', today);
|
|
2689
|
-
if (result) {
|
|
2690
|
-
const oldActivity = (0, state_document_cjs_1.stateExtractField)(modified, 'Last Activity');
|
|
2691
|
-
if (oldActivity !== today) {
|
|
2692
|
-
changes.push(`Last Activity: ${oldActivity} -> ${today}`);
|
|
2693
|
-
}
|
|
2694
|
-
modified = result;
|
|
2290
|
+
percent = p !== null ? p : 0;
|
|
2695
2291
|
}
|
|
2292
|
+
const syncResult = transitionCore(modified, { kind: 'sync', totalPlansInPhase: highestIncompletePhase ? highestIncompletePhaseplanCount : null, percent }, { clock: clock_cjs_1.realClock, progressProvider: () => null });
|
|
2293
|
+
modified = syncResult.content;
|
|
2294
|
+
const coreChanges = syncResult.data?.changes ?? [];
|
|
2295
|
+
changes.push(...coreChanges);
|
|
2696
2296
|
if (verify) {
|
|
2697
2297
|
output({ synced: false, changes, dry_run: true }, raw, undefined);
|
|
2698
2298
|
return;
|
|
@@ -2721,8 +2321,13 @@ function cmdStatePrune(cwd, options, raw) {
|
|
|
2721
2321
|
}
|
|
2722
2322
|
const keepRecent = parseInt(String(options.keepRecent), 10) || 3;
|
|
2723
2323
|
const dryRun = !!options.dryRun;
|
|
2724
|
-
|
|
2725
|
-
|
|
2324
|
+
// #1760: the canonical STATE.md template emits `Phase: [X] of [Y]`, not
|
|
2325
|
+
// `Current Phase:`. Read both (mirroring buildStateFrontmatter /
|
|
2326
|
+
// resolvePhaseIdForCompletePhase) so prune engages on template-conformant
|
|
2327
|
+
// STATE.md instead of bailing with "Only 0 phases — nothing to prune".
|
|
2328
|
+
const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
2329
|
+
const currentPhaseRaw = (0, state_document_cjs_1.stateExtractField)(rawState, 'Current Phase') || (0, state_document_cjs_1.stateExtractField)(rawState, 'Phase');
|
|
2330
|
+
const currentPhase = parseInt(String(currentPhaseRaw), 10) || 0;
|
|
2726
2331
|
const cutoff = currentPhase - keepRecent;
|
|
2727
2332
|
if (cutoff <= 0) {
|
|
2728
2333
|
emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false');
|
|
@@ -2730,112 +2335,21 @@ function cmdStatePrune(cwd, options, raw) {
|
|
|
2730
2335
|
}
|
|
2731
2336
|
const archivePath = node_path_1.default.join(node_path_1.default.dirname(statePath), 'STATE-ARCHIVE.md');
|
|
2732
2337
|
const archived = [];
|
|
2733
|
-
//
|
|
2734
|
-
//
|
|
2735
|
-
//
|
|
2736
|
-
//
|
|
2737
|
-
|
|
2738
|
-
const
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
const i = hs.findIndex(h => pred(h.level, h.text));
|
|
2745
|
-
if (i === -1)
|
|
2746
|
-
return c;
|
|
2747
|
-
const h = hs[i];
|
|
2748
|
-
const ls = c.split('\n');
|
|
2749
|
-
const hl = ls[h.line - 1];
|
|
2750
|
-
const bs = h.offset + hl.length + 1;
|
|
2751
|
-
let se = c.length;
|
|
2752
|
-
for (let j = i + 1; j < hs.length; j++) {
|
|
2753
|
-
if (STOP_H2_H3(hs[j].level)) {
|
|
2754
|
-
se = hs[j].offset - 1;
|
|
2755
|
-
break;
|
|
2756
|
-
}
|
|
2757
|
-
}
|
|
2758
|
-
const body = c.slice(bs, se);
|
|
2759
|
-
const { keep, archive } = transform(body);
|
|
2760
|
-
if (archive.length > 0) {
|
|
2761
|
-
sections.push({ section: sectionName, count: archive.length, lines: archive });
|
|
2762
|
-
return c.slice(0, bs) + keep.join('\n') + c.slice(se);
|
|
2763
|
-
}
|
|
2764
|
-
return c;
|
|
2765
|
-
}
|
|
2766
|
-
// Prune Decisions section: entries like "- [Phase N]: ..."
|
|
2767
|
-
content = pruneSectionSpan(content, (lv, text) => (lv === 2 || lv === 3) && /^(?:Decisions|Decisions Made|Accumulated.*Decisions)$/i.test(text), (body) => {
|
|
2768
|
-
const keep = [], archive = [];
|
|
2769
|
-
for (const line of body.split('\n')) {
|
|
2770
|
-
const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i);
|
|
2771
|
-
if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
|
|
2772
|
-
archive.push(line);
|
|
2773
|
-
}
|
|
2774
|
-
else {
|
|
2775
|
-
keep.push(line);
|
|
2776
|
-
}
|
|
2777
|
-
}
|
|
2778
|
-
return { keep, archive };
|
|
2779
|
-
}, 'Decisions');
|
|
2780
|
-
// Prune Recently Completed section: entries mentioning phase numbers
|
|
2781
|
-
content = pruneSectionSpan(content, (lv, text) => (lv === 2 || lv === 3) && /^recently\s+completed$/i.test(text), (body) => {
|
|
2782
|
-
const keep = [], archive = [];
|
|
2783
|
-
for (const line of body.split('\n')) {
|
|
2784
|
-
const phaseMatch = line.match(/Phase\s+(\d+)/i);
|
|
2785
|
-
if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
|
|
2786
|
-
archive.push(line);
|
|
2787
|
-
}
|
|
2788
|
-
else {
|
|
2789
|
-
keep.push(line);
|
|
2790
|
-
}
|
|
2791
|
-
}
|
|
2792
|
-
return { keep, archive };
|
|
2793
|
-
}, 'Recently Completed');
|
|
2794
|
-
// Prune resolved blockers: lines marked as resolved (strikethrough ~~text~~
|
|
2795
|
-
// or "[RESOLVED]" prefix) with a phase reference older than cutoff
|
|
2796
|
-
content = pruneSectionSpan(content, (lv, text) => (lv === 2 || lv === 3) && /^(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)$/i.test(text), (body) => {
|
|
2797
|
-
const keep = [], archive = [];
|
|
2798
|
-
for (const line of body.split('\n')) {
|
|
2799
|
-
const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line);
|
|
2800
|
-
const phaseMatch = line.match(/Phase\s+(\d+)/i);
|
|
2801
|
-
if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
|
|
2802
|
-
archive.push(line);
|
|
2803
|
-
}
|
|
2804
|
-
else {
|
|
2805
|
-
keep.push(line);
|
|
2806
|
-
}
|
|
2807
|
-
}
|
|
2808
|
-
return { keep, archive };
|
|
2809
|
-
}, 'Blockers (resolved)');
|
|
2810
|
-
// Prune Performance Metrics table rows: keep only rows for phases > cutoff.
|
|
2811
|
-
// Preserves header rows (| Phase | ... and |---|...) and any prose around the table.
|
|
2812
|
-
content = pruneSectionSpan(content, (lv, text) => (lv === 2 || lv === 3) && /^performance\s+metrics$/i.test(text), (body) => {
|
|
2813
|
-
const keep = [], archive = [];
|
|
2814
|
-
for (const line of body.split('\n')) {
|
|
2815
|
-
// Table data row: starts with | followed by a number (phase)
|
|
2816
|
-
const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/);
|
|
2817
|
-
if (tableRowMatch) {
|
|
2818
|
-
const rowPhase = parseInt(tableRowMatch[1], 10);
|
|
2819
|
-
if (rowPhase <= cutoff) {
|
|
2820
|
-
archive.push(line);
|
|
2821
|
-
}
|
|
2822
|
-
else {
|
|
2823
|
-
keep.push(line);
|
|
2824
|
-
}
|
|
2825
|
-
}
|
|
2826
|
-
else {
|
|
2827
|
-
// Header row, separator row, or prose — always keep
|
|
2828
|
-
keep.push(line);
|
|
2829
|
-
}
|
|
2830
|
-
}
|
|
2831
|
-
return { keep, archive };
|
|
2832
|
-
}, 'Performance Metrics');
|
|
2833
|
-
return { newContent: content, archivedSections: sections };
|
|
2834
|
-
}
|
|
2338
|
+
// ADR-1769 Phase 7: the section-pruning is the pure `pruneCore` in
|
|
2339
|
+
// src/state-transition.cts (byte-identical tokenizeHeadings section splicing).
|
|
2340
|
+
// This adapter owns currentPhase derivation (#1760 `Phase`/`Current Phase`
|
|
2341
|
+
// fallback above), dry-run, and STATE-ARCHIVE.md writes.
|
|
2342
|
+
const runPruneCore = (content) => {
|
|
2343
|
+
const result = transitionCore(content, { kind: 'prune', cutoff }, { clock: clock_cjs_1.realClock, progressProvider: () => null });
|
|
2344
|
+
return {
|
|
2345
|
+
newContent: result.content,
|
|
2346
|
+
archivedSections: (result.data?.archivedSections) ?? [],
|
|
2347
|
+
};
|
|
2348
|
+
};
|
|
2835
2349
|
if (dryRun) {
|
|
2836
2350
|
// Dry-run: compute what would be pruned without writing anything
|
|
2837
2351
|
const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
2838
|
-
const result =
|
|
2352
|
+
const result = runPruneCore(content);
|
|
2839
2353
|
const totalPruned = result.archivedSections.reduce((sum, s) => sum + s.count, 0);
|
|
2840
2354
|
emit({
|
|
2841
2355
|
pruned: false,
|
|
@@ -2849,7 +2363,7 @@ function cmdStatePrune(cwd, options, raw) {
|
|
|
2849
2363
|
return;
|
|
2850
2364
|
}
|
|
2851
2365
|
readModifyWriteStateMd(statePath, (content) => {
|
|
2852
|
-
const result =
|
|
2366
|
+
const result = runPruneCore(content);
|
|
2853
2367
|
archived.push(...result.archivedSections);
|
|
2854
2368
|
return result.newContent;
|
|
2855
2369
|
}, cwd);
|
|
@@ -2876,6 +2390,116 @@ function cmdStatePrune(cwd, options, raw) {
|
|
|
2876
2390
|
archive_file: totalPruned > 0 ? 'STATE-ARCHIVE.md' : null,
|
|
2877
2391
|
}, raw, totalPruned > 0 ? 'true' : 'false');
|
|
2878
2392
|
}
|
|
2393
|
+
/**
|
|
2394
|
+
* Rebuild STATE.md body structure from canonical sources (ADR-1817).
|
|
2395
|
+
*
|
|
2396
|
+
* Implements the `gsd state rebuild` subcommand (issue #1817 Phase 2, #1826).
|
|
2397
|
+
* Wires the pure `rebuildCore` transition (Phase 1, #1827) to the CLI:
|
|
2398
|
+
* - Locks via `readModifyWriteStateMd` (real path) or reads-only (dry-run).
|
|
2399
|
+
* - Wires `phaseInventoryProvider` to a real `.planning/phases/` disk scan.
|
|
2400
|
+
* - `--dry-run`: computes the rebuild, emits a structured diff, writes nothing.
|
|
2401
|
+
* - `--verbose`: emits the audit-log entries to stderr (in addition to the
|
|
2402
|
+
* `## Rebuild Log` section that `rebuildCore` already appends to STATE.md).
|
|
2403
|
+
*
|
|
2404
|
+
* Per ADR-1817 §5 this is the heavy/manual counterpart to the lightweight,
|
|
2405
|
+
* auto-triggered `state sync` (3 frontmatter fields). The two compose
|
|
2406
|
+
* non-overlappingly.
|
|
2407
|
+
*/
|
|
2408
|
+
function cmdStateRebuild(cwd, options, raw) {
|
|
2409
|
+
const silent = !!options.silent;
|
|
2410
|
+
const emit = silent ? () => { } : (result, r, v) => output(result, r, v);
|
|
2411
|
+
const statePath = planningPaths(cwd).state;
|
|
2412
|
+
if (!node_fs_1.default.existsSync(statePath)) {
|
|
2413
|
+
emit({ error: 'STATE.md not found' }, raw);
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
const dryRun = !!options.dryRun;
|
|
2417
|
+
const verbose = !!options.verbose;
|
|
2418
|
+
// Wire phaseInventoryProvider to a real `.planning/phases/` disk scan. This
|
|
2419
|
+
// is the same canonical source `buildStateFrontmatter` consults; the Leaky-
|
|
2420
|
+
// Abstractions guard in `rebuildCore` (ADR-1817 §1) keeps the pure core
|
|
2421
|
+
// testable without this dep — here we provide it.
|
|
2422
|
+
const phaseInventoryProvider = () => {
|
|
2423
|
+
try {
|
|
2424
|
+
const phasesDir = node_path_1.default.join(planningPaths(cwd).planning, 'phases');
|
|
2425
|
+
if (!node_fs_1.default.existsSync(phasesDir) || !node_fs_1.default.statSync(phasesDir).isDirectory())
|
|
2426
|
+
return null;
|
|
2427
|
+
const entries = node_fs_1.default.readdirSync(phasesDir);
|
|
2428
|
+
const records = [];
|
|
2429
|
+
for (const entry of entries) {
|
|
2430
|
+
const full = node_path_1.default.join(phasesDir, entry);
|
|
2431
|
+
let stat;
|
|
2432
|
+
try {
|
|
2433
|
+
stat = node_fs_1.default.statSync(full);
|
|
2434
|
+
}
|
|
2435
|
+
catch {
|
|
2436
|
+
continue;
|
|
2437
|
+
}
|
|
2438
|
+
if (!stat.isDirectory())
|
|
2439
|
+
continue;
|
|
2440
|
+
// Directory-name convention: `<NN>-<slug>` (e.g. `03-test-phase`).
|
|
2441
|
+
const m = entry.match(/^(\d+)-(.+)$/);
|
|
2442
|
+
if (!m)
|
|
2443
|
+
continue;
|
|
2444
|
+
const files = node_fs_1.default.readdirSync(full);
|
|
2445
|
+
const planCount = files.filter(f => /-PLAN\.md$/i.test(f)).length;
|
|
2446
|
+
const summaryCount = files.filter(f => /-SUMMARY\.md$/i.test(f)).length;
|
|
2447
|
+
records.push({ number: m[1], name: m[2], planCount, summaryCount });
|
|
2448
|
+
}
|
|
2449
|
+
return records;
|
|
2450
|
+
}
|
|
2451
|
+
catch {
|
|
2452
|
+
return null;
|
|
2453
|
+
}
|
|
2454
|
+
};
|
|
2455
|
+
const deps = {
|
|
2456
|
+
progressProvider: () => null,
|
|
2457
|
+
clock: clock_cjs_1.realClock,
|
|
2458
|
+
phaseInventoryProvider,
|
|
2459
|
+
};
|
|
2460
|
+
const runRebuild = (content) => transitionCore(content, { kind: 'rebuild' }, deps);
|
|
2461
|
+
const emitVerboseLog = (log) => {
|
|
2462
|
+
if (!verbose || !Array.isArray(log))
|
|
2463
|
+
return;
|
|
2464
|
+
for (const entry of log) {
|
|
2465
|
+
// Treat user-data as data-only (ADR-1577 untrusted-input-boundary).
|
|
2466
|
+
process.stderr.write(`[rebuild] ${JSON.stringify(entry)}\n`);
|
|
2467
|
+
}
|
|
2468
|
+
};
|
|
2469
|
+
if (dryRun) {
|
|
2470
|
+
const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
2471
|
+
const result = runRebuild(content);
|
|
2472
|
+
const data = (result.data ?? {});
|
|
2473
|
+
emitVerboseLog(data.log);
|
|
2474
|
+
const mutated = data.mutated === true;
|
|
2475
|
+
emit({
|
|
2476
|
+
rebuilt: false,
|
|
2477
|
+
dry_run: true,
|
|
2478
|
+
mutations: Array.isArray(data.log) ? data.log.length : 0,
|
|
2479
|
+
mutated,
|
|
2480
|
+
note: mutated ? 'Run without --dry-run to apply changes' : 'Nothing to rebuild',
|
|
2481
|
+
}, raw, mutated ? 'true' : 'false');
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
// Real path: lock + RMW via the existing seam. The rebuild log is captured
|
|
2485
|
+
// so we can emit it to stderr under --verbose (the section is also written
|
|
2486
|
+
// to STATE.md by rebuildCore itself, per ADR-1817 §3).
|
|
2487
|
+
let capturedLog = [];
|
|
2488
|
+
let capturedMutated = false;
|
|
2489
|
+
readModifyWriteStateMd(statePath, (content) => {
|
|
2490
|
+
const result = runRebuild(content);
|
|
2491
|
+
const data = (result.data ?? {});
|
|
2492
|
+
capturedLog = Array.isArray(data.log) ? data.log : [];
|
|
2493
|
+
capturedMutated = data.mutated === true;
|
|
2494
|
+
return result.content;
|
|
2495
|
+
}, cwd);
|
|
2496
|
+
emitVerboseLog(capturedLog);
|
|
2497
|
+
emit({
|
|
2498
|
+
rebuilt: capturedMutated,
|
|
2499
|
+
mutations: capturedLog.length,
|
|
2500
|
+
note: capturedMutated ? 'STATE.md rebuilt; see ## Rebuild Log section for the audit trail' : 'Nothing to rebuild',
|
|
2501
|
+
}, raw, capturedMutated ? 'true' : 'false');
|
|
2502
|
+
}
|
|
2879
2503
|
/**
|
|
2880
2504
|
* Mark the current phase as COMPLETE in STATE.md.
|
|
2881
2505
|
* Updates Status, Last Activity, and the Current Position section to reflect
|
|
@@ -3044,6 +2668,7 @@ module.exports = {
|
|
|
3044
2668
|
cmdStateValidate,
|
|
3045
2669
|
cmdStateSync,
|
|
3046
2670
|
cmdStatePrune,
|
|
2671
|
+
cmdStateRebuild,
|
|
3047
2672
|
cmdStateMilestoneSwitch,
|
|
3048
2673
|
cmdSignalWaiting,
|
|
3049
2674
|
cmdSignalResume,
|