pan-wizard 3.30.0 → 3.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -26
- package/agents/pan-design-checker.md +18 -0
- package/agents/pan-optimizer.md +5 -1
- package/agents/pan-plan-checker.md +23 -0
- package/agents/pan-reviewer.md +19 -0
- package/agents/pan-verifier.md +23 -0
- package/bin/install-lib.cjs +129 -33
- package/bin/install.js +16 -9
- package/commands/pan/army.md +4 -4
- package/commands/pan/design-phase.md +8 -0
- package/commands/pan/exec-phase.md +1 -1
- package/commands/pan/git.md +2 -2
- package/commands/pan/learn.md +2 -0
- package/commands/pan/optimize.md +14 -2
- package/commands/pan/review-deep.md +5 -5
- package/commands/pan/verify-phase.md +8 -9
- package/hooks/dist/pan-check-update.js +53 -2
- package/hooks/dist/pan-context-monitor.js +42 -1
- package/hooks/dist/pan-cost-logger.js +132 -11
- package/hooks/dist/pan-state-reinject.js +158 -0
- package/hooks/dist/pan-stop-guard.js +50 -4
- package/hooks/dist/pan-trace-logger.js +324 -18
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/commands.cjs +22 -5
- package/pan-wizard-core/bin/lib/config.cjs +2 -0
- package/pan-wizard-core/bin/lib/constants.cjs +31 -1
- package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
- package/pan-wizard-core/bin/lib/core.cjs +232 -23
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
- package/pan-wizard-core/bin/lib/cost.cjs +135 -46
- package/pan-wizard-core/bin/lib/findings.cjs +381 -0
- package/pan-wizard-core/bin/lib/focus.cjs +72 -19
- package/pan-wizard-core/bin/lib/foreign-planning.cjs +24 -6
- package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
- package/pan-wizard-core/bin/lib/hygiene.cjs +12 -2
- package/pan-wizard-core/bin/lib/init.cjs +4 -1
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
- package/pan-wizard-core/bin/lib/memory-rebuild.cjs +9 -2
- package/pan-wizard-core/bin/lib/optimize.cjs +349 -21
- package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
- package/pan-wizard-core/bin/lib/review-deep.cjs +36 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
- package/pan-wizard-core/bin/lib/state.cjs +93 -24
- package/pan-wizard-core/bin/lib/suggest.cjs +21 -9
- package/pan-wizard-core/bin/lib/template.cjs +6 -3
- package/pan-wizard-core/bin/lib/verdict.cjs +431 -0
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +19 -2
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -2
- package/pan-wizard-core/bin/lib/verify-scope.cjs +156 -0
- package/pan-wizard-core/bin/lib/verify.cjs +25 -2
- package/pan-wizard-core/bin/pan-tools.cjs +69 -6
- package/pan-wizard-core/learnings/README.md +10 -7
- package/pan-wizard-core/mcp/tool-registry.cjs +16 -4
- package/pan-wizard-core/references/verdict-contract.md +101 -0
- package/pan-wizard-core/templates/verification-report.md +16 -0
- package/pan-wizard-core/workflows/exec-phase.md +73 -47
- package/pan-wizard-core/workflows/health.md +2 -0
- package/pan-wizard-core/workflows/help.md +2 -2
- package/pan-wizard-core/workflows/learn.md +4 -2
- package/pan-wizard-core/workflows/milestone-audit.md +15 -3
- package/pan-wizard-core/workflows/optimize.md +14 -1
- package/pan-wizard-core/workflows/phase-tests.md +1 -1
- package/pan-wizard-core/workflows/plan-phase.md +20 -15
- package/pan-wizard-core/workflows/progress.md +1 -1
- package/pan-wizard-core/workflows/todo-add.md +1 -1
- package/pan-wizard-core/workflows/todo-check.md +7 -6
- package/pan-wizard-core/workflows/verify-phase.md +3 -13
- package/pan-zcode/README.md +3 -3
- package/scripts/build-agent-plugin.js +4 -0
- package/scripts/build-hooks.js +2 -1
- package/scripts/build-plugin.js +10 -0
- package/scripts/git-hooks/pre-commit +2 -1
- package/scripts/npm-dist-tag.js +47 -0
- package/scripts/plugin-path.js +6 -1
- package/scripts/test-surface.cjs +1 -0
|
@@ -320,6 +320,78 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
320
320
|
* @param {boolean} raw - If true, output raw value instead of JSON
|
|
321
321
|
* @returns {void}
|
|
322
322
|
*/
|
|
323
|
+
// ─── Progress table and Plans line, located by name ─────────────────────────
|
|
324
|
+
//
|
|
325
|
+
// `roadmap update-plan-progress` used to rewrite the phase's table row by column
|
|
326
|
+
// POSITION — cells 2 to 4, whatever the header said. On PAN's own milestone
|
|
327
|
+
// variant (`| Phase | Milestone | Plans Complete | Status | Completed |`) that put
|
|
328
|
+
// the plan count in the Milestone column; on a roadmap with other columns it
|
|
329
|
+
// destroyed the phase's goal and requirement ids. And the `**Plans:**` update was a
|
|
330
|
+
// lazy match from the phase heading onward, so a phase without its own Plans line
|
|
331
|
+
// had the NEXT phase's rewritten. Both are now located by name, and a table without
|
|
332
|
+
// Plans and Status columns is left alone and reported.
|
|
333
|
+
|
|
334
|
+
const splitRow = (line) => {
|
|
335
|
+
const inner = line.trim().replace(/^\|/, '').replace(/\|$/, '');
|
|
336
|
+
return inner.split(/(?<!\\)\|/).map((c) => c.trim());
|
|
337
|
+
};
|
|
338
|
+
const isTableLine = (line) => /^\s*\|.*\|\s*$/.test(line);
|
|
339
|
+
const isSeparator = (line) => /^\s*\|(?:\s*:?-{3,}:?\s*\|)+\s*$/.test(line);
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Set the Plans / Status / Completed cells of the phase's row in any table whose
|
|
343
|
+
* header names Plans and Status columns. Pure.
|
|
344
|
+
* @returns {{content: string, updated: boolean, reason: string|null}}
|
|
345
|
+
*/
|
|
346
|
+
function updateProgressTableRow(content, phaseNum, values) {
|
|
347
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
348
|
+
const lines = content.split(/\r?\n/);
|
|
349
|
+
const phaseRe = new RegExp(`^(?:phase\\s+)?${escapeRegex(String(phaseNum))}\\.?(?=\\s|:|$)`, 'i');
|
|
350
|
+
let reason = null;
|
|
351
|
+
for (let i = 0; i + 1 < lines.length; i++) {
|
|
352
|
+
if (!isTableLine(lines[i]) || !isSeparator(lines[i + 1])) continue;
|
|
353
|
+
const header = splitRow(lines[i]);
|
|
354
|
+
const col = (re) => header.findIndex((h) => re.test(h));
|
|
355
|
+
const plansIdx = col(/\bplans?\b/i);
|
|
356
|
+
const statusIdx = col(/^status$/i);
|
|
357
|
+
const doneIdx = col(/^completed?(?:\s+on|\s+date)?$/i);
|
|
358
|
+
for (let r = i + 2; r < lines.length && isTableLine(lines[r]); r++) {
|
|
359
|
+
const cells = splitRow(lines[r]);
|
|
360
|
+
if (!phaseRe.test(cells[0] || '')) continue;
|
|
361
|
+
if (plansIdx < 0 || statusIdx < 0) {
|
|
362
|
+
reason = `the table holding phase ${phaseNum} has no Plans and Status columns (${header.join(' | ')}) — left unchanged`;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
while (cells.length < header.length) cells.push('');
|
|
366
|
+
cells[plansIdx] = values.plans;
|
|
367
|
+
cells[statusIdx] = values.status;
|
|
368
|
+
if (doneIdx >= 0) cells[doneIdx] = values.completed || '';
|
|
369
|
+
const indent = lines[r].match(/^\s*/)[0];
|
|
370
|
+
lines[r] = `${indent}| ${cells.join(' | ')} |`;
|
|
371
|
+
return { content: lines.join(eol), updated: true, reason: null };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return { content, updated: false, reason: reason || `no progress-table row for phase ${phaseNum}` };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Rewrite `**Plans:**` inside the phase's own section only. Pure. */
|
|
378
|
+
function updatePhasePlansLine(content, phaseNum, text) {
|
|
379
|
+
const lines = content.split(/(?<=\n)/);
|
|
380
|
+
const headRe = new RegExp(`^(#{2,4})\\s*Phase\\s+${escapeRegex(String(phaseNum))}(?=[\\s:.]|$)`, 'i');
|
|
381
|
+
const start = lines.findIndex((l) => headRe.test(l));
|
|
382
|
+
if (start < 0) return { content, updated: false };
|
|
383
|
+
const level = lines[start].match(headRe)[1].length;
|
|
384
|
+
const nextHead = new RegExp(`^#{1,${level}}\\s`);
|
|
385
|
+
for (let i = start + 1; i < lines.length && !nextHead.test(lines[i]); i++) {
|
|
386
|
+
const m = lines[i].match(/^(.*?(?:\*\*Plans:\*\*|\*\*Plans\*\*:)\s*)[^\r\n]*(\r?\n?)$/);
|
|
387
|
+
if (m) {
|
|
388
|
+
lines[i] = `${m[1]}${text}${m[2]}`;
|
|
389
|
+
return { content: lines.join(''), updated: true };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return { content, updated: false };
|
|
393
|
+
}
|
|
394
|
+
|
|
323
395
|
function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
324
396
|
if (!phaseNum) {
|
|
325
397
|
error('phase number required for roadmap update-plan-progress');
|
|
@@ -356,26 +428,19 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
356
428
|
}
|
|
357
429
|
const phaseEscaped = escapeRegex(phaseNum);
|
|
358
430
|
|
|
359
|
-
// Progress table row:
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
roadmapContent =
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
);
|
|
369
|
-
|
|
370
|
-
// Update plan count in phase detail section
|
|
371
|
-
const planCountPattern = new RegExp(
|
|
372
|
-
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?(?:\\*\\*Plans:\\*\\*|\\*\\*Plans\\*\\*:)\\s*)[^\\n]+`,
|
|
373
|
-
'i'
|
|
374
|
-
);
|
|
431
|
+
// Progress table row: the Plans, Status and Completed cells, found by header.
|
|
432
|
+
const table = updateProgressTableRow(roadmapContent, phaseNum, {
|
|
433
|
+
plans: `${summaryCount}/${planCount}`,
|
|
434
|
+
status,
|
|
435
|
+
completed: isComplete ? today : '',
|
|
436
|
+
});
|
|
437
|
+
roadmapContent = table.content;
|
|
438
|
+
|
|
439
|
+
// Update plan count in the phase's own detail section
|
|
375
440
|
const planCountText = isComplete
|
|
376
441
|
? `${summaryCount}/${planCount} plans complete`
|
|
377
442
|
: `${summaryCount}/${planCount} plans executed`;
|
|
378
|
-
roadmapContent = roadmapContent
|
|
443
|
+
roadmapContent = updatePhasePlansLine(roadmapContent, phaseNum, planCountText).content;
|
|
379
444
|
|
|
380
445
|
// If complete: check checkbox
|
|
381
446
|
if (isComplete) {
|
|
@@ -402,6 +467,8 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
402
467
|
|
|
403
468
|
try {
|
|
404
469
|
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
|
|
470
|
+
// Read back: report success only for the file this command produced.
|
|
471
|
+
if (fs.readFileSync(roadmapPath, 'utf-8') !== roadmapContent) throw new Error('roadmap.md did not read back as written');
|
|
405
472
|
} catch (err) {
|
|
406
473
|
// error key => exit 1: the write failed, so progress was computed and then lost.
|
|
407
474
|
output({ updated: false, reason: 'Failed to write roadmap.md: ' + err.message, error: err.message || 'roadmap_write_failed' }, raw, 'write error');
|
|
@@ -415,7 +482,9 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
415
482
|
summary_count: summaryCount,
|
|
416
483
|
status,
|
|
417
484
|
complete: isComplete,
|
|
418
|
-
|
|
485
|
+
table_updated: table.updated,
|
|
486
|
+
...(table.updated ? {} : { table_reason: table.reason }),
|
|
487
|
+
}, raw, `${summaryCount}/${planCount} ${status}${table.updated ? '' : ' (progress table not updated)'}`);
|
|
419
488
|
}
|
|
420
489
|
|
|
421
490
|
/**
|
|
@@ -20,6 +20,7 @@ const {
|
|
|
20
20
|
PROGRESS_BAR_WIDTH,
|
|
21
21
|
FILLED_BLOCK,
|
|
22
22
|
EMPTY_BLOCK,
|
|
23
|
+
STATE_CONTRACT,
|
|
23
24
|
} = require('./constants.cjs');
|
|
24
25
|
const {
|
|
25
26
|
planningPath,
|
|
@@ -51,6 +52,7 @@ function cmdStateLoad(cwd, raw) {
|
|
|
51
52
|
const stateExists = stateRaw.length > 0;
|
|
52
53
|
|
|
53
54
|
const result = {
|
|
55
|
+
contract: STATE_CONTRACT,
|
|
54
56
|
config,
|
|
55
57
|
state_raw: stateRaw,
|
|
56
58
|
state_exists: stateExists,
|
|
@@ -799,20 +801,32 @@ function calculateProgressBar(completed, total) {
|
|
|
799
801
|
* Orchestrates extractFieldsFromState, scanPhaseProgress,
|
|
800
802
|
* normalizePhaseStatus, and calculateProgressBar.
|
|
801
803
|
*/
|
|
802
|
-
function buildStateFrontmatter(bodyContent, cwd) {
|
|
804
|
+
function buildStateFrontmatter(bodyContent, cwd, existing = {}) {
|
|
803
805
|
const fields = extractFieldsFromState(bodyContent);
|
|
806
|
+
const prior = existing && typeof existing === 'object' ? existing : {};
|
|
804
807
|
|
|
808
|
+
// The milestone comes from the roadmap resolver — unless the resolver only
|
|
809
|
+
// guessed (no milestone heading at all) and state.md already records one. A
|
|
810
|
+
// guess overwriting a recorded value is how a letter-series milestone ("R-2")
|
|
811
|
+
// was rewritten to the resolver's `v1.0` / `milestone` on every state write.
|
|
805
812
|
let milestone = null;
|
|
806
813
|
let milestoneName = null;
|
|
807
814
|
if (cwd) {
|
|
808
815
|
try {
|
|
809
816
|
const info = getMilestoneInfo(cwd);
|
|
810
|
-
|
|
811
|
-
|
|
817
|
+
const guessed = info.basis === 'default' || info.basis === 'no-milestone-heading';
|
|
818
|
+
if (!(guessed && prior.milestone)) {
|
|
819
|
+
milestone = info.version;
|
|
820
|
+
milestoneName = info.name;
|
|
821
|
+
}
|
|
812
822
|
} catch {
|
|
813
823
|
// No milestone configured or milestone file unreadable -- skip milestone fields
|
|
814
824
|
}
|
|
815
825
|
}
|
|
826
|
+
if (milestone == null && prior.milestone) {
|
|
827
|
+
milestone = prior.milestone;
|
|
828
|
+
milestoneName = prior.milestone_name != null ? prior.milestone_name : null;
|
|
829
|
+
}
|
|
816
830
|
|
|
817
831
|
let totalPhases = fields.totalPhasesRaw ? parseInt(fields.totalPhasesRaw, 10) : null;
|
|
818
832
|
let completedPhases = null;
|
|
@@ -837,20 +851,27 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
837
851
|
if (pctMatch) progressPercent = parseInt(pctMatch[1], 10);
|
|
838
852
|
}
|
|
839
853
|
|
|
840
|
-
const normalizedStatus = normalizePhaseStatus(fields.status, fields.pausedAt);
|
|
841
|
-
|
|
842
854
|
const frontmatter = { pan_state_version: '1.0' };
|
|
843
855
|
|
|
844
856
|
if (milestone) frontmatter.milestone = milestone;
|
|
845
857
|
if (milestoneName) frontmatter.milestone_name = milestoneName;
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
858
|
+
// A field the body restates wins; one it does not keeps its recorded value.
|
|
859
|
+
// Rebuilding from the body alone dropped current_phase_name, current_plan and
|
|
860
|
+
// the rest whenever a project's state.md did not repeat them in prose.
|
|
861
|
+
const keep = (key, fromBody) => {
|
|
862
|
+
if (fromBody != null && fromBody !== '') frontmatter[key] = fromBody;
|
|
863
|
+
else if (prior[key] != null) frontmatter[key] = prior[key];
|
|
864
|
+
};
|
|
865
|
+
keep('current_phase', fields.currentPhase);
|
|
866
|
+
keep('current_phase_name', fields.currentPhaseName);
|
|
867
|
+
keep('current_plan', fields.currentPlan);
|
|
868
|
+
frontmatter.status = (fields.status || fields.pausedAt)
|
|
869
|
+
? normalizePhaseStatus(fields.status, fields.pausedAt)
|
|
870
|
+
: (prior.status != null ? prior.status : normalizePhaseStatus(null, null));
|
|
871
|
+
keep('stopped_at', fields.stoppedAt);
|
|
872
|
+
keep('paused_at', fields.pausedAt);
|
|
852
873
|
frontmatter.last_updated = new Date().toISOString();
|
|
853
|
-
|
|
874
|
+
keep('last_activity', fields.lastActivity);
|
|
854
875
|
|
|
855
876
|
const progress = {};
|
|
856
877
|
if (totalPhases !== null) progress.total_phases = totalPhases;
|
|
@@ -859,19 +880,48 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
859
880
|
if (completedPlans !== null) progress.completed_plans = completedPlans;
|
|
860
881
|
if (progressPercent !== null) progress.percent = progressPercent;
|
|
861
882
|
if (Object.keys(progress).length > 0) frontmatter.progress = progress;
|
|
862
|
-
|
|
863
|
-
|
|
883
|
+
else if (prior.progress != null) frontmatter.progress = prior.progress;
|
|
884
|
+
|
|
885
|
+
// Keys PAN does not manage (a project's own notes) survive every write, in the
|
|
886
|
+
// order the file already had them; PAN's keys fill in around them.
|
|
887
|
+
const merged = {};
|
|
888
|
+
for (const key of Object.keys(prior)) merged[key] = key in frontmatter ? frontmatter[key] : prior[key];
|
|
889
|
+
for (const key of Object.keys(frontmatter)) if (!(key in merged)) merged[key] = frontmatter[key];
|
|
890
|
+
return merged;
|
|
864
891
|
}
|
|
865
892
|
|
|
893
|
+
// One front-matter block at the top of state.md: BOM-tolerant, LF or CRLF. The
|
|
894
|
+
// previous `^---\n…\n---\n` matched LF only, so on a CRLF checkout (git
|
|
895
|
+
// core.autocrlf=true) the old block was never stripped and every state write
|
|
896
|
+
// stacked a new, re-derived block on top of it.
|
|
897
|
+
const STATE_FRONTMATTER_RE = /^\uFEFF?---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
898
|
+
|
|
866
899
|
function stripFrontmatter(content) {
|
|
867
|
-
return content.replace(
|
|
900
|
+
return String(content).replace(STATE_FRONTMATTER_RE, '').replace(/^\uFEFF/, '').replace(/^(?:\r?\n)+/, '');
|
|
868
901
|
}
|
|
869
902
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
903
|
+
/**
|
|
904
|
+
* Re-derive state.md's front matter from its body, merged with the front matter
|
|
905
|
+
* already there. Output uses `eol` (default: whatever the content already uses).
|
|
906
|
+
* Throws (code STATE_STACKED_FRONTMATTER) when the content starts with more than
|
|
907
|
+
* one front-matter block — earlier releases stacked them on CRLF files, and
|
|
908
|
+
* choosing between them silently is exactly the guess this function must not make.
|
|
909
|
+
*/
|
|
910
|
+
function syncStateFrontmatter(content, cwd, eol) {
|
|
911
|
+
const text = String(content);
|
|
912
|
+
const lineEnd = eol || (/\r\n/.test(text) ? '\r\n' : '\n');
|
|
913
|
+
const m = text.match(STATE_FRONTMATTER_RE);
|
|
914
|
+
const existing = m ? extractFrontmatter(m[0]) : {};
|
|
915
|
+
const body = stripFrontmatter(text).replace(/\r\n/g, '\n');
|
|
916
|
+
if (STATE_FRONTMATTER_RE.test(body)) {
|
|
917
|
+
const err = new Error('state.md starts with more than one front-matter block (an earlier PAN release stacked them on CRLF files). Keep the block with the right values, delete the other, and run the command again — PAN will not pick one for you.');
|
|
918
|
+
err.code = 'STATE_STACKED_FRONTMATTER';
|
|
919
|
+
throw err;
|
|
920
|
+
}
|
|
921
|
+
const frontmatter = buildStateFrontmatter(body, cwd, existing);
|
|
873
922
|
const yamlStr = reconstructFrontmatter(frontmatter);
|
|
874
|
-
|
|
923
|
+
const out = `---\n${yamlStr}\n---\n\n${body}`;
|
|
924
|
+
return lineEnd === '\r\n' ? out.replace(/\n/g, '\r\n') : out;
|
|
875
925
|
}
|
|
876
926
|
|
|
877
927
|
/**
|
|
@@ -882,17 +932,33 @@ function syncStateFrontmatter(content, cwd) {
|
|
|
882
932
|
* state.md.lock and lands atomically (temp + rename), so concurrent agents
|
|
883
933
|
* cannot tear the file or interleave read-modify-write cycles. Lock
|
|
884
934
|
* acquisition is best-effort — on timeout the write proceeds unlocked,
|
|
885
|
-
*
|
|
935
|
+
* matching the file-lock contract.
|
|
936
|
+
*
|
|
937
|
+
* The file keeps its own line ending, and the write is verified: exactly one
|
|
938
|
+
* front-matter block goes in, and what is read back under the lock must be what
|
|
939
|
+
* was written. A write that cannot meet either stops with an error and leaves
|
|
940
|
+
* the file as it was — it never reports success over a file it did not produce.
|
|
886
941
|
*/
|
|
887
942
|
function writeStateMd(statePath, content, cwd) {
|
|
888
|
-
|
|
943
|
+
let diskEol = null;
|
|
944
|
+
try { diskEol = /\r\n/.test(fs.readFileSync(statePath, 'utf-8')) ? '\r\n' : '\n'; } catch { /* new file */ }
|
|
945
|
+
let synced;
|
|
946
|
+
try {
|
|
947
|
+
synced = syncStateFrontmatter(content, cwd, diskEol);
|
|
948
|
+
} catch (err) {
|
|
949
|
+
if (err && err.code === 'STATE_STACKED_FRONTMATTER') error(err.message);
|
|
950
|
+
throw err;
|
|
951
|
+
}
|
|
952
|
+
let mismatch = false;
|
|
889
953
|
try {
|
|
890
954
|
withFileLock(statePath, () => {
|
|
891
955
|
writeFileAtomic(statePath, synced);
|
|
956
|
+
mismatch = fs.readFileSync(statePath, 'utf-8') !== synced;
|
|
892
957
|
});
|
|
893
958
|
} catch (err) {
|
|
894
959
|
throw new Error('Failed to write state.md: ' + err.message);
|
|
895
960
|
}
|
|
961
|
+
if (mismatch) error('state.md did not read back as written — another writer changed it during the write. Re-run the command.');
|
|
896
962
|
}
|
|
897
963
|
|
|
898
964
|
/**
|
|
@@ -911,13 +977,16 @@ function cmdStateJson(cwd, raw) {
|
|
|
911
977
|
const frontmatter = extractFrontmatter(content);
|
|
912
978
|
|
|
913
979
|
if (!frontmatter || Object.keys(frontmatter).length === 0) {
|
|
914
|
-
const body = stripFrontmatter(content);
|
|
915
|
-
const built = buildStateFrontmatter(body, cwd);
|
|
980
|
+
const body = stripFrontmatter(content).replace(/\r\n/g, '\n');
|
|
981
|
+
const built = { ...buildStateFrontmatter(body, cwd), contract: STATE_CONTRACT };
|
|
916
982
|
output(built, raw, JSON.stringify(built, null, 2));
|
|
917
983
|
return;
|
|
918
984
|
}
|
|
919
985
|
|
|
920
|
-
output
|
|
986
|
+
// `contract` is the output's shape version (STATE_CONTRACT), never a state.md
|
|
987
|
+
// field: PAN's state frontmatter has no such key, so nothing is shadowed.
|
|
988
|
+
const withContract = { ...frontmatter, contract: STATE_CONTRACT };
|
|
989
|
+
output(withContract, raw, JSON.stringify(withContract, null, 2));
|
|
921
990
|
}
|
|
922
991
|
|
|
923
992
|
/**
|
|
@@ -48,15 +48,27 @@
|
|
|
48
48
|
function buildSubcommandIndex(sourceText) {
|
|
49
49
|
const index = {};
|
|
50
50
|
if (typeof sourceText !== 'string') return index;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
51
|
+
// The dispatcher words these messages three ways, and until 2026-09-26 only the
|
|
52
|
+
// first was read, so git, distill, experiment, init, state and links got no
|
|
53
|
+
// suggestion on a typo:
|
|
54
|
+
// Unknown optimize subcommand. Available: …
|
|
55
|
+
// Unknown state subcommand: ${subcommand}. Available: … (also `unknown …`, and
|
|
56
|
+
// Unknown init workflow: ${workflow}\nAvailable: …)
|
|
57
|
+
// git subcommand required. Available: …
|
|
58
|
+
const patterns = [
|
|
59
|
+
/[Uu]nknown ([a-z][a-z-]*) (?:subcommand|workflow)[^'"`\n.]*(?:\.\s*|\\n)Available: ([^'"`\n\\]+)/g,
|
|
60
|
+
/([a-z][a-z-]*) subcommand required\. Available: ([^'"`\n\\]+)/g,
|
|
61
|
+
];
|
|
62
|
+
for (const re of patterns) {
|
|
63
|
+
let m;
|
|
64
|
+
while ((m = re.exec(sourceText)) !== null) {
|
|
65
|
+
const group = m[1];
|
|
66
|
+
const subs = m[2]
|
|
67
|
+
.split(',')
|
|
68
|
+
.map((s) => s.trim().split(/\s+/)[0]) // drop " [--apply]"-style hints
|
|
69
|
+
.filter((s) => /^[a-z][a-z0-9-]*$/.test(s));
|
|
70
|
+
if (subs.length) index[group] = [...new Set([...(index[group] || []), ...subs])];
|
|
71
|
+
}
|
|
60
72
|
}
|
|
61
73
|
return index;
|
|
62
74
|
}
|
|
@@ -28,9 +28,12 @@ function cmdTemplateSelect(cwd, planPath, raw) {
|
|
|
28
28
|
const fullPath = path.join(cwd, planPath);
|
|
29
29
|
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
30
30
|
|
|
31
|
-
// Count task
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
// Count tasks: PAN plans write `<task type="...">` blocks (templates/phase-prompt.md,
|
|
32
|
+
// pan-planner); older plans used `### Task N` headings. Counting only the headings
|
|
33
|
+
// gave every current plan a task count of 0.
|
|
34
|
+
const xmlTasks = content.match(/<task[\s>]/g) || [];
|
|
35
|
+
const headingTasks = content.match(/###\s*Task\s*\d+/g) || [];
|
|
36
|
+
const taskCount = xmlTasks.length || headingTasks.length;
|
|
34
37
|
|
|
35
38
|
// Check for decision-related keywords
|
|
36
39
|
const decisionMatch = content.match(/decision/gi) || [];
|