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.
Files changed (75) hide show
  1. package/README.md +30 -26
  2. package/agents/pan-design-checker.md +18 -0
  3. package/agents/pan-optimizer.md +5 -1
  4. package/agents/pan-plan-checker.md +23 -0
  5. package/agents/pan-reviewer.md +19 -0
  6. package/agents/pan-verifier.md +23 -0
  7. package/bin/install-lib.cjs +129 -33
  8. package/bin/install.js +16 -9
  9. package/commands/pan/army.md +4 -4
  10. package/commands/pan/design-phase.md +8 -0
  11. package/commands/pan/exec-phase.md +1 -1
  12. package/commands/pan/git.md +2 -2
  13. package/commands/pan/learn.md +2 -0
  14. package/commands/pan/optimize.md +14 -2
  15. package/commands/pan/review-deep.md +5 -5
  16. package/commands/pan/verify-phase.md +8 -9
  17. package/hooks/dist/pan-check-update.js +53 -2
  18. package/hooks/dist/pan-context-monitor.js +42 -1
  19. package/hooks/dist/pan-cost-logger.js +132 -11
  20. package/hooks/dist/pan-state-reinject.js +158 -0
  21. package/hooks/dist/pan-stop-guard.js +50 -4
  22. package/hooks/dist/pan-trace-logger.js +324 -18
  23. package/package.json +1 -1
  24. package/pan-wizard-core/bin/lib/commands.cjs +22 -5
  25. package/pan-wizard-core/bin/lib/config.cjs +2 -0
  26. package/pan-wizard-core/bin/lib/constants.cjs +31 -1
  27. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
  28. package/pan-wizard-core/bin/lib/core.cjs +232 -23
  29. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
  30. package/pan-wizard-core/bin/lib/cost.cjs +135 -46
  31. package/pan-wizard-core/bin/lib/findings.cjs +381 -0
  32. package/pan-wizard-core/bin/lib/focus.cjs +72 -19
  33. package/pan-wizard-core/bin/lib/foreign-planning.cjs +24 -6
  34. package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
  35. package/pan-wizard-core/bin/lib/hygiene.cjs +12 -2
  36. package/pan-wizard-core/bin/lib/init.cjs +4 -1
  37. package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
  38. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +9 -2
  39. package/pan-wizard-core/bin/lib/optimize.cjs +349 -21
  40. package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
  41. package/pan-wizard-core/bin/lib/review-deep.cjs +36 -0
  42. package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
  43. package/pan-wizard-core/bin/lib/state.cjs +93 -24
  44. package/pan-wizard-core/bin/lib/suggest.cjs +21 -9
  45. package/pan-wizard-core/bin/lib/template.cjs +6 -3
  46. package/pan-wizard-core/bin/lib/verdict.cjs +431 -0
  47. package/pan-wizard-core/bin/lib/verify-deploy.cjs +19 -2
  48. package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -2
  49. package/pan-wizard-core/bin/lib/verify-scope.cjs +156 -0
  50. package/pan-wizard-core/bin/lib/verify.cjs +25 -2
  51. package/pan-wizard-core/bin/pan-tools.cjs +69 -6
  52. package/pan-wizard-core/learnings/README.md +10 -7
  53. package/pan-wizard-core/mcp/tool-registry.cjs +16 -4
  54. package/pan-wizard-core/references/verdict-contract.md +101 -0
  55. package/pan-wizard-core/templates/verification-report.md +16 -0
  56. package/pan-wizard-core/workflows/exec-phase.md +73 -47
  57. package/pan-wizard-core/workflows/health.md +2 -0
  58. package/pan-wizard-core/workflows/help.md +2 -2
  59. package/pan-wizard-core/workflows/learn.md +4 -2
  60. package/pan-wizard-core/workflows/milestone-audit.md +15 -3
  61. package/pan-wizard-core/workflows/optimize.md +14 -1
  62. package/pan-wizard-core/workflows/phase-tests.md +1 -1
  63. package/pan-wizard-core/workflows/plan-phase.md +20 -15
  64. package/pan-wizard-core/workflows/progress.md +1 -1
  65. package/pan-wizard-core/workflows/todo-add.md +1 -1
  66. package/pan-wizard-core/workflows/todo-check.md +7 -6
  67. package/pan-wizard-core/workflows/verify-phase.md +3 -13
  68. package/pan-zcode/README.md +3 -3
  69. package/scripts/build-agent-plugin.js +4 -0
  70. package/scripts/build-hooks.js +2 -1
  71. package/scripts/build-plugin.js +10 -0
  72. package/scripts/git-hooks/pre-commit +2 -1
  73. package/scripts/npm-dist-tag.js +47 -0
  74. package/scripts/plugin-path.js +6 -1
  75. 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: update Plans column (summaries/plans) and Status column
360
- const tablePattern = new RegExp(
361
- `(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|)[^|]*(\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
362
- 'i'
363
- );
364
- const dateField = isComplete ? ` ${today} ` : ' ';
365
- roadmapContent = roadmapContent.replace(
366
- tablePattern,
367
- `$1 ${summaryCount}/${planCount} $2 ${status.padEnd(11)}$3${dateField}$4`
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.replace(planCountPattern, `$1${planCountText}`);
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
- }, raw, `${summaryCount}/${planCount} ${status}`);
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
- milestone = info.version;
811
- milestoneName = info.name;
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
- if (fields.currentPhase) frontmatter.current_phase = fields.currentPhase;
847
- if (fields.currentPhaseName) frontmatter.current_phase_name = fields.currentPhaseName;
848
- if (fields.currentPlan) frontmatter.current_plan = fields.currentPlan;
849
- frontmatter.status = normalizedStatus;
850
- if (fields.stoppedAt) frontmatter.stopped_at = fields.stoppedAt;
851
- if (fields.pausedAt) frontmatter.paused_at = fields.pausedAt;
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
- if (fields.lastActivity) frontmatter.last_activity = fields.lastActivity;
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
- return frontmatter;
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(/^---\n[\s\S]*?\n---\n*/, '');
900
+ return String(content).replace(STATE_FRONTMATTER_RE, '').replace(/^\uFEFF/, '').replace(/^(?:\r?\n)+/, '');
868
901
  }
869
902
 
870
- function syncStateFrontmatter(content, cwd) {
871
- const body = stripFrontmatter(content);
872
- const frontmatter = buildStateFrontmatter(body, cwd);
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
- return `---\n${yamlStr}\n---\n\n${body}`;
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
- * preserving single-agent behavior exactly.
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
- const synced = syncStateFrontmatter(content, cwd);
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(frontmatter, raw, JSON.stringify(frontmatter, null, 2));
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
- const re = /Unknown ([a-z][a-z-]*) subcommand\. Available: ([^'"`\n]+)/g;
52
- let m;
53
- while ((m = re.exec(sourceText)) !== null) {
54
- const group = m[1];
55
- const subs = m[2]
56
- .split(',')
57
- .map((s) => s.trim().split(/\s+/)[0]) // drop " [--apply]"-style hints
58
- .filter((s) => /^[a-z][a-z0-9-]*$/.test(s));
59
- if (subs.length) index[group] = subs;
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 headings (### Task N)
32
- const taskMatch = content.match(/###\s*Task\s*\d+/g) || [];
33
- const taskCount = taskMatch.length;
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) || [];