pan-wizard 3.29.0 → 3.31.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 (50) hide show
  1. package/README.md +40 -40
  2. package/bin/install-lib.cjs +186 -83
  3. package/bin/install.js +213 -195
  4. package/commands/pan/army.md +4 -4
  5. package/commands/pan/audit-deployment.md +2 -2
  6. package/commands/pan/cost.md +5 -5
  7. package/commands/pan/exec-phase.md +2 -0
  8. package/commands/pan/focus-auto.md +5 -5
  9. package/commands/pan/verify-phase.md +8 -9
  10. package/hooks/dist/pan-check-update.js +53 -2
  11. package/hooks/dist/pan-context-monitor.js +41 -0
  12. package/hooks/dist/pan-cost-logger.js +71 -2
  13. package/hooks/dist/pan-state-reinject.js +158 -0
  14. package/hooks/dist/pan-stop-guard.js +127 -2
  15. package/hooks/dist/pan-trace-logger.js +42 -1
  16. package/package.json +1 -1
  17. package/pan-wizard-core/bin/lib/agents-md.cjs +3 -2
  18. package/pan-wizard-core/bin/lib/commands.cjs +20 -4
  19. package/pan-wizard-core/bin/lib/config.cjs +2 -0
  20. package/pan-wizard-core/bin/lib/constants.cjs +3 -1
  21. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
  22. package/pan-wizard-core/bin/lib/core.cjs +249 -27
  23. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
  24. package/pan-wizard-core/bin/lib/cost.cjs +234 -88
  25. package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
  26. package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
  27. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +12 -5
  28. package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
  29. package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
  30. package/pan-wizard-core/bin/lib/state.cjs +86 -22
  31. package/pan-wizard-core/bin/lib/suggest.cjs +21 -9
  32. package/pan-wizard-core/bin/lib/template.cjs +6 -3
  33. package/pan-wizard-core/bin/lib/verify-deploy.cjs +20 -3
  34. package/pan-wizard-core/bin/pan-tools.cjs +10 -1
  35. package/pan-wizard-core/mcp/tool-registry.cjs +14 -4
  36. package/pan-wizard-core/references/model-profiles.md +4 -4
  37. package/pan-wizard-core/references/planning-config.md +19 -23
  38. package/pan-wizard-core/workflows/exec-phase.md +8 -0
  39. package/pan-wizard-core/workflows/help.md +2 -2
  40. package/pan-wizard-core/workflows/phase-tests.md +1 -1
  41. package/pan-wizard-core/workflows/progress.md +1 -1
  42. package/pan-wizard-core/workflows/settings.md +2 -4
  43. package/pan-wizard-core/workflows/todo-add.md +1 -1
  44. package/pan-wizard-core/workflows/todo-check.md +7 -6
  45. package/scripts/build-agent-plugin.js +4 -0
  46. package/scripts/build-hooks.js +2 -1
  47. package/scripts/build-plugin.js +10 -0
  48. package/scripts/npm-dist-tag.js +47 -0
  49. package/scripts/plugin-path.js +6 -1
  50. package/scripts/test-surface.cjs +11 -9
@@ -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
  /**
@@ -799,20 +799,32 @@ function calculateProgressBar(completed, total) {
799
799
  * Orchestrates extractFieldsFromState, scanPhaseProgress,
800
800
  * normalizePhaseStatus, and calculateProgressBar.
801
801
  */
802
- function buildStateFrontmatter(bodyContent, cwd) {
802
+ function buildStateFrontmatter(bodyContent, cwd, existing = {}) {
803
803
  const fields = extractFieldsFromState(bodyContent);
804
+ const prior = existing && typeof existing === 'object' ? existing : {};
804
805
 
806
+ // The milestone comes from the roadmap resolver — unless the resolver only
807
+ // guessed (no milestone heading at all) and state.md already records one. A
808
+ // guess overwriting a recorded value is how a letter-series milestone ("R-2")
809
+ // was rewritten to the resolver's `v1.0` / `milestone` on every state write.
805
810
  let milestone = null;
806
811
  let milestoneName = null;
807
812
  if (cwd) {
808
813
  try {
809
814
  const info = getMilestoneInfo(cwd);
810
- milestone = info.version;
811
- milestoneName = info.name;
815
+ const guessed = info.basis === 'default' || info.basis === 'no-milestone-heading';
816
+ if (!(guessed && prior.milestone)) {
817
+ milestone = info.version;
818
+ milestoneName = info.name;
819
+ }
812
820
  } catch {
813
821
  // No milestone configured or milestone file unreadable -- skip milestone fields
814
822
  }
815
823
  }
824
+ if (milestone == null && prior.milestone) {
825
+ milestone = prior.milestone;
826
+ milestoneName = prior.milestone_name != null ? prior.milestone_name : null;
827
+ }
816
828
 
817
829
  let totalPhases = fields.totalPhasesRaw ? parseInt(fields.totalPhasesRaw, 10) : null;
818
830
  let completedPhases = null;
@@ -837,20 +849,27 @@ function buildStateFrontmatter(bodyContent, cwd) {
837
849
  if (pctMatch) progressPercent = parseInt(pctMatch[1], 10);
838
850
  }
839
851
 
840
- const normalizedStatus = normalizePhaseStatus(fields.status, fields.pausedAt);
841
-
842
852
  const frontmatter = { pan_state_version: '1.0' };
843
853
 
844
854
  if (milestone) frontmatter.milestone = milestone;
845
855
  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;
856
+ // A field the body restates wins; one it does not keeps its recorded value.
857
+ // Rebuilding from the body alone dropped current_phase_name, current_plan and
858
+ // the rest whenever a project's state.md did not repeat them in prose.
859
+ const keep = (key, fromBody) => {
860
+ if (fromBody != null && fromBody !== '') frontmatter[key] = fromBody;
861
+ else if (prior[key] != null) frontmatter[key] = prior[key];
862
+ };
863
+ keep('current_phase', fields.currentPhase);
864
+ keep('current_phase_name', fields.currentPhaseName);
865
+ keep('current_plan', fields.currentPlan);
866
+ frontmatter.status = (fields.status || fields.pausedAt)
867
+ ? normalizePhaseStatus(fields.status, fields.pausedAt)
868
+ : (prior.status != null ? prior.status : normalizePhaseStatus(null, null));
869
+ keep('stopped_at', fields.stoppedAt);
870
+ keep('paused_at', fields.pausedAt);
852
871
  frontmatter.last_updated = new Date().toISOString();
853
- if (fields.lastActivity) frontmatter.last_activity = fields.lastActivity;
872
+ keep('last_activity', fields.lastActivity);
854
873
 
855
874
  const progress = {};
856
875
  if (totalPhases !== null) progress.total_phases = totalPhases;
@@ -859,19 +878,48 @@ function buildStateFrontmatter(bodyContent, cwd) {
859
878
  if (completedPlans !== null) progress.completed_plans = completedPlans;
860
879
  if (progressPercent !== null) progress.percent = progressPercent;
861
880
  if (Object.keys(progress).length > 0) frontmatter.progress = progress;
862
-
863
- return frontmatter;
881
+ else if (prior.progress != null) frontmatter.progress = prior.progress;
882
+
883
+ // Keys PAN does not manage (a project's own notes) survive every write, in the
884
+ // order the file already had them; PAN's keys fill in around them.
885
+ const merged = {};
886
+ for (const key of Object.keys(prior)) merged[key] = key in frontmatter ? frontmatter[key] : prior[key];
887
+ for (const key of Object.keys(frontmatter)) if (!(key in merged)) merged[key] = frontmatter[key];
888
+ return merged;
864
889
  }
865
890
 
891
+ // One front-matter block at the top of state.md: BOM-tolerant, LF or CRLF. The
892
+ // previous `^---\n…\n---\n` matched LF only, so on a CRLF checkout (git
893
+ // core.autocrlf=true) the old block was never stripped and every state write
894
+ // stacked a new, re-derived block on top of it.
895
+ const STATE_FRONTMATTER_RE = /^\uFEFF?---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/;
896
+
866
897
  function stripFrontmatter(content) {
867
- return content.replace(/^---\n[\s\S]*?\n---\n*/, '');
898
+ return String(content).replace(STATE_FRONTMATTER_RE, '').replace(/^\uFEFF/, '').replace(/^(?:\r?\n)+/, '');
868
899
  }
869
900
 
870
- function syncStateFrontmatter(content, cwd) {
871
- const body = stripFrontmatter(content);
872
- const frontmatter = buildStateFrontmatter(body, cwd);
901
+ /**
902
+ * Re-derive state.md's front matter from its body, merged with the front matter
903
+ * already there. Output uses `eol` (default: whatever the content already uses).
904
+ * Throws (code STATE_STACKED_FRONTMATTER) when the content starts with more than
905
+ * one front-matter block — earlier releases stacked them on CRLF files, and
906
+ * choosing between them silently is exactly the guess this function must not make.
907
+ */
908
+ function syncStateFrontmatter(content, cwd, eol) {
909
+ const text = String(content);
910
+ const lineEnd = eol || (/\r\n/.test(text) ? '\r\n' : '\n');
911
+ const m = text.match(STATE_FRONTMATTER_RE);
912
+ const existing = m ? extractFrontmatter(m[0]) : {};
913
+ const body = stripFrontmatter(text).replace(/\r\n/g, '\n');
914
+ if (STATE_FRONTMATTER_RE.test(body)) {
915
+ 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.');
916
+ err.code = 'STATE_STACKED_FRONTMATTER';
917
+ throw err;
918
+ }
919
+ const frontmatter = buildStateFrontmatter(body, cwd, existing);
873
920
  const yamlStr = reconstructFrontmatter(frontmatter);
874
- return `---\n${yamlStr}\n---\n\n${body}`;
921
+ const out = `---\n${yamlStr}\n---\n\n${body}`;
922
+ return lineEnd === '\r\n' ? out.replace(/\n/g, '\r\n') : out;
875
923
  }
876
924
 
877
925
  /**
@@ -882,17 +930,33 @@ function syncStateFrontmatter(content, cwd) {
882
930
  * state.md.lock and lands atomically (temp + rename), so concurrent agents
883
931
  * cannot tear the file or interleave read-modify-write cycles. Lock
884
932
  * acquisition is best-effort — on timeout the write proceeds unlocked,
885
- * preserving single-agent behavior exactly.
933
+ * matching the file-lock contract.
934
+ *
935
+ * The file keeps its own line ending, and the write is verified: exactly one
936
+ * front-matter block goes in, and what is read back under the lock must be what
937
+ * was written. A write that cannot meet either stops with an error and leaves
938
+ * the file as it was — it never reports success over a file it did not produce.
886
939
  */
887
940
  function writeStateMd(statePath, content, cwd) {
888
- const synced = syncStateFrontmatter(content, cwd);
941
+ let diskEol = null;
942
+ try { diskEol = /\r\n/.test(fs.readFileSync(statePath, 'utf-8')) ? '\r\n' : '\n'; } catch { /* new file */ }
943
+ let synced;
944
+ try {
945
+ synced = syncStateFrontmatter(content, cwd, diskEol);
946
+ } catch (err) {
947
+ if (err && err.code === 'STATE_STACKED_FRONTMATTER') error(err.message);
948
+ throw err;
949
+ }
950
+ let mismatch = false;
889
951
  try {
890
952
  withFileLock(statePath, () => {
891
953
  writeFileAtomic(statePath, synced);
954
+ mismatch = fs.readFileSync(statePath, 'utf-8') !== synced;
892
955
  });
893
956
  } catch (err) {
894
957
  throw new Error('Failed to write state.md: ' + err.message);
895
958
  }
959
+ if (mismatch) error('state.md did not read back as written — another writer changed it during the write. Re-run the command.');
896
960
  }
897
961
 
898
962
  /**
@@ -911,7 +975,7 @@ function cmdStateJson(cwd, raw) {
911
975
  const frontmatter = extractFrontmatter(content);
912
976
 
913
977
  if (!frontmatter || Object.keys(frontmatter).length === 0) {
914
- const body = stripFrontmatter(content);
978
+ const body = stripFrontmatter(content).replace(/\r\n/g, '\n');
915
979
  const built = buildStateFrontmatter(body, cwd);
916
980
  output(built, raw, JSON.stringify(built, null, 2));
917
981
  return;
@@ -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) || [];
@@ -32,6 +32,24 @@ function detectInstalledRuntimes(cwd) {
32
32
  return found;
33
33
  }
34
34
 
35
+ /**
36
+ * Whether `buf` is the file a manifest hash was taken from, allowing for line
37
+ * endings: raw bytes, the all-LF form or the all-CRLF form may match, so a file
38
+ * git re-checked out with `core.autocrlf=true` is not reported as modified.
39
+ * Binary (NUL-bearing) buffers compare raw only. Same rule as
40
+ * `bytesMatchHashIgnoringEol` in bin/install-lib.cjs, which the installed core
41
+ * cannot require; tests/install-eol-hash.test.cjs pins both.
42
+ */
43
+ function bytesMatchHashIgnoringEol(crypto, buf, expectedHash) {
44
+ if (!expectedHash) return false;
45
+ const sha = b => crypto.createHash('sha256').update(b).digest('hex');
46
+ if (sha(buf) === expectedHash) return true;
47
+ if (buf.includes(0)) return false;
48
+ const lf = buf.toString('latin1').replace(/\r\n/g, '\n');
49
+ if (sha(Buffer.from(lf, 'latin1')) === expectedHash) return true;
50
+ return sha(Buffer.from(lf.replace(/\n/g, '\r\n'), 'latin1')) === expectedHash;
51
+ }
52
+
35
53
  /**
36
54
  * Validate a single PAN runtime installation.
37
55
  * Checks: manifest files exist, hashes match, settings integrity.
@@ -61,8 +79,7 @@ function validateRuntimeInstall(cwd, configDir, runtime) {
61
79
  const absPath = path.join(baseDir, relPath);
62
80
  try {
63
81
  const content = fs.readFileSync(absPath);
64
- const actualHash = crypto.createHash('sha256').update(content).digest('hex');
65
- if (actualHash !== expectedHash) {
82
+ if (!bytesMatchHashIgnoringEol(crypto, content, expectedHash)) {
66
83
  modified.push(relPath);
67
84
  }
68
85
  } catch (_) {
@@ -107,7 +124,7 @@ function validateRuntimeInstall(cwd, configDir, runtime) {
107
124
  }
108
125
  }
109
126
  }
110
- // Copilot/Gemini statusLine
127
+ // statusLine (Claude, Copilot; PAN writes none for Gemini since 2026-09-23)
111
128
  if (settings.statusLine && settings.statusLine.command) {
112
129
  hookCommands.push(settings.statusLine.command);
113
130
  }
@@ -420,7 +420,13 @@ async function main() {
420
420
  case 'resolve-model': {
421
421
  const metadataIdx = args.indexOf('--metadata');
422
422
  const metadataJson = metadataIdx !== -1 ? args[metadataIdx + 1] : undefined;
423
- commands.cmdResolveModel(cwd, args[1], raw, metadataJson);
423
+ const attemptRaw = getArgValue(args, '--attempt');
424
+ let attempt;
425
+ if (attemptRaw !== null && attemptRaw !== undefined) {
426
+ if (!/^[1-9]\d*$/.test(String(attemptRaw))) error('--attempt must be a positive integer (1 for a first try)');
427
+ attempt = parseInt(attemptRaw, 10);
428
+ }
429
+ commands.cmdResolveModel(cwd, args[1], raw, metadataJson, attempt);
424
430
  break;
425
431
  }
426
432
 
@@ -1272,6 +1278,9 @@ async function main() {
1272
1278
  daily_budget: budget != null ? Number(budget) : undefined,
1273
1279
  enabled: args.includes('--disable') ? false : undefined,
1274
1280
  paused: args.includes('--pause') ? true : (args.includes('--resume') ? false : undefined),
1281
+ // campaign.cjs has always honoured enforce_budget; no flag reached it, so
1282
+ // making the daily budget a hard stop meant editing schedule.json by hand.
1283
+ enforce_budget: args.includes('--enforce-budget') ? true : (args.includes('--advisory-budget') ? false : undefined),
1275
1284
  }, raw);
1276
1285
  } else if (subcommand === 'status' || !subcommand) {
1277
1286
  campaign.cmdCampaignStatus(cwd, raw);
@@ -75,13 +75,23 @@ const RESOURCES = [
75
75
  const SPAWN_TOOLS = [
76
76
  {
77
77
  name: 'pan_resolve_model', title: 'Resolve model for an agent', verb: 'resolve-model',
78
- description: 'Resolve the model tier/id PAN would use for an agent under the active profile.',
78
+ description: 'Resolve the model tier/id PAN would use for an agent under the active profile. Pass attempt > 1 for a retry after a failure: the tier climbs toward the agent\'s quality tier (routing.max_escalations caps it).',
79
79
  readOnly: true, destructive: false,
80
80
  inputSchema: {
81
81
  type: 'object', additionalProperties: false, required: ['agent'],
82
- properties: { agent: { type: 'string', description: 'Agent type, e.g. pan-planner' } },
82
+ properties: {
83
+ agent: { type: 'string', description: 'Agent type, e.g. pan-planner' },
84
+ attempt: { type: 'integer', minimum: 1, maximum: 99, description: '1 for a first try; 2 for the first retry after a failure' },
85
+ },
86
+ },
87
+ args: (i) => {
88
+ const out = [str('agent', i && i.agent, AGENT_RE, 64)];
89
+ if (i && i.attempt !== undefined) {
90
+ if (!Number.isInteger(i.attempt) || i.attempt < 1 || i.attempt > 99) throw new Error('Invalid "attempt": must be an integer from 1 to 99');
91
+ out.push('--attempt', String(i.attempt));
92
+ }
93
+ return out;
83
94
  },
84
- args: (i) => [str('agent', i && i.agent, AGENT_RE, 64)],
85
95
  },
86
96
  {
87
97
  name: 'pan_find_phase', title: 'Find a phase', verb: 'find-phase',
@@ -155,7 +165,7 @@ const { NATIVE_TOOLS } = require('./native-tools.cjs');
155
165
  // safety argument of ADR-0041 is that no client input reaches it.
156
166
  const PROJECT_CWD_PROPERTY = Object.freeze({
157
167
  type: 'string',
158
- description: 'Absolute path of the PAN project to operate on. Optional: defaults to the directory the server was started in. Pass it when the server was launched from a plugin directory (Agent Plugins clients do this by default), or to address another project.',
168
+ description: 'Absolute path of the PAN project to operate on. Optional: defaults to the directory the server was started in. Pass it when the server was launched from a plugin directory (Agent Plugins clients do this by default), or to address another project. The server acts on any existing directory named here with the permissions of the user who runs it — it trusts its caller as it trusts the session that started it.',
159
169
  });
160
170
 
161
171
  const PROJECT_CWD_MAX = 1024;
@@ -11,8 +11,8 @@ PAN uses three abstract tiers instead of hardcoded model names:
11
11
  | Tier | Purpose | Anthropic | OpenAI | Google |
12
12
  |------|---------|-----------|--------|--------|
13
13
  | `reasoning` | Architecture, planning, complex decisions | inherit (your session's top-tier model) | inherit | inherit |
14
- | `mid` | Execution, research, verification | Sonnet | mid | gemini-2.5-flash |
15
- | `fast` | Read-only extraction, budget tasks | Haiku | fast | gemini-2.5-flash-lite |
14
+ | `mid` | Execution, research, verification | Sonnet | gpt-6-sol | gemini-3.8-flash |
15
+ | `fast` | Read-only extraction, budget tasks | Haiku | gpt-6-luna | gemini-3.5-flash-lite |
16
16
 
17
17
  **Why `inherit` for reasoning?** Host runtimes map "opus" to a specific model version. PAN returns `inherit` for reasoning-tier agents, so they use whatever top-tier model the user has configured. This avoids version conflicts and silent fallbacks.
18
18
 
@@ -39,7 +39,7 @@ At install time PAN also runs a **best-effort, advisory** capability check on th
39
39
  | Class | Example model IDs | Role in PAN | Context | Relative cost | Notes |
40
40
  |-------|-------------------|-------------|---------|---------------|-------|
41
41
  | Fable / Mythos | `claude-fable-5` | **Recommended flagship** — deepest long-horizon reasoning; best for the bot army's Mission Control + planning | 1M | ~2× Opus | Runs input safety classifiers (see caveat below); requires 30-day data retention |
42
- | Opus | `claude-opus-5`, `claude-opus-4-8` | **Cost-conscious pick** — same 1M context + thinking, about half the cost, no cyber classifier | 1M | 1× | The safe pick when you want Opus behavior without Fable's refusal surface |
42
+ | Opus | `claude-opus-5-5`, `claude-opus-5`, `claude-opus-4-8` | **Cost-conscious pick** — same 1M context + thinking, about half the cost, no cyber classifier | 1M | 1× | The safe pick when you want Opus behavior without Fable's refusal surface |
43
43
 
44
44
  **Why the Fable class is the recommended flagship.** It is Anthropic's deepest class for demanding, long-horizon agentic work — exactly what PAN's hierarchical bot army (Mission Control → squads → workers) asks of its reasoning tier. Select the current release in that class in your host runtime and `inherit` routes the reasoning-tier agents to it automatically.
45
45
 
@@ -117,7 +117,7 @@ PAN auto-detects the LLM provider to map tiers to the right model names:
117
117
 
118
118
  1. **Explicit config** — `routing.provider` in config.json (if not `"auto"`)
119
119
  2. **Environment variable** — `PAN_PROVIDER` env var
120
- 3. **Runtime directory** — `.claude/` → Anthropic, `.codex/` → OpenAI, `.gemini/` → Google
120
+ 3. **Runtime directory** — `.claude/` → Anthropic, `.codex/` → OpenAI, `.gemini/` → Google, `.opencode/` → OpenAI, `.github/` → default (first match wins)
121
121
  4. **Fallback** — Default provider map (Anthropic-style names)
122
122
 
123
123
  ---
@@ -4,24 +4,22 @@ Configuration options for `.planning/` directory behavior.
4
4
 
5
5
  <config_schema>
6
6
  ```json
7
- "planning": {
8
- "commit_docs": true,
9
- "search_gitignored": false
10
- },
11
- "git": {
12
- "branching_strategy": "none",
13
- "phase_branch_template": "pan/phase-{phase}-{slug}",
14
- "milestone_branch_template": "pan/{milestone}-{slug}"
15
- }
7
+ "commit_docs": true,
8
+ "search_gitignored": false,
9
+ "branching_strategy": "none",
10
+ "phase_branch_template": "pan/phase-{phase}-{slug}",
11
+ "milestone_branch_template": "pan/{milestone}-{slug}"
16
12
  ```
17
13
 
18
14
  | Option | Default | Description |
19
15
  |--------|---------|-------------|
20
16
  | `commit_docs` | `true` | Whether to commit planning artifacts to git |
21
17
  | `search_gitignored` | `false` | Add `--no-ignore` to broad rg searches |
22
- | `git.branching_strategy` | `"none"` | Git branching approach: `"none"`, `"phase"`, or `"milestone"` |
23
- | `git.phase_branch_template` | `"pan/phase-{phase}-{slug}"` | Branch template for phase strategy |
24
- | `git.milestone_branch_template` | `"pan/{milestone}-{slug}"` | Branch template for milestone strategy |
18
+ | `branching_strategy` | `"none"` | Git branching approach: `"none"`, `"phase"`, or `"milestone"` |
19
+ | `phase_branch_template` | `"pan/phase-{phase}-{slug}"` | Branch template for phase strategy |
20
+ | `milestone_branch_template` | `"pan/{milestone}-{slug}"` | Branch template for milestone strategy |
21
+
22
+ These keys are top-level. The nested `planning.*` / `git.*` forms older versions wrote are read only when the top-level key is absent. A config `config-ensure-section` creates carries every top-level key, and one `/pan:new-project` writes carries a top-level `commit_docs` — so write the top-level key; a nested one is ignored whenever the top-level key exists.
25
23
  </config_schema>
26
24
 
27
25
  <commit_docs_behavior>
@@ -84,10 +82,8 @@ To use uncommitted mode:
84
82
 
85
83
  1. **Set config:**
86
84
  ```json
87
- "planning": {
88
- "commit_docs": false,
89
- "search_gitignored": true
90
- }
85
+ "commit_docs": false,
86
+ "search_gitignored": true
91
87
  ```
92
88
 
93
89
  2. **Add to .gitignore:**
@@ -101,7 +97,7 @@ To use uncommitted mode:
101
97
  git commit -m "chore: stop tracking planning docs"
102
98
  ```
103
99
 
104
- 4. **Branch merges:** When using `branching_strategy: phase` or `milestone`, the `milestone-done` workflow automatically strips `.planning/` files from staging before merge commits when `commit_docs: false`.
100
+ 4. **Branch merges:** PAN never merges branches; when you merge a phase or milestone branch yourself with `commit_docs: false`, keep `.planning/` out of the merge commit.
105
101
 
106
102
  </setup_uncommitted_mode>
107
103
 
@@ -113,24 +109,24 @@ To use uncommitted mode:
113
109
  |----------|---------------------|--------------|-------------|
114
110
  | `none` | Never | N/A | N/A |
115
111
  | `phase` | At `execute-phase` start | Single phase | User merges after phase |
116
- | `milestone` | At first `execute-phase` of milestone | Entire milestone | At `milestone-done` |
112
+ | `milestone` | At first `execute-phase` of milestone | Entire milestone | User merges after milestone |
117
113
 
118
- **When `git.branching_strategy: "none"` (default):**
114
+ **When `branching_strategy: "none"` (default):**
119
115
  - All work commits to current branch
120
116
  - Standard PAN behavior
121
117
 
122
- **When `git.branching_strategy: "phase"`:**
118
+ **When `branching_strategy: "phase"`:**
123
119
  - `execute-phase` creates/switches to a branch before execution
124
120
  - Branch name from `phase_branch_template` (e.g., `pan/phase-03-authentication`)
125
121
  - All plan commits go to that branch
126
122
  - User merges branches manually after phase completion
127
- - `milestone-done` offers to merge all phase branches
123
+ - `milestone-done` does not merge — merge the phase branches yourself
128
124
 
129
- **When `git.branching_strategy: "milestone"`:**
125
+ **When `branching_strategy: "milestone"`:**
130
126
  - First `execute-phase` of milestone creates the milestone branch
131
127
  - Branch name from `milestone_branch_template` (e.g., `pan/v1.0-mvp`)
132
128
  - All phases in milestone commit to same branch
133
- - `milestone-done` offers to merge milestone branch to main
129
+ - `milestone-done` archives and tags but does not merge — merge the milestone branch yourself
134
130
 
135
131
  **Template variables:**
136
132
 
@@ -117,6 +117,14 @@ Parse JSON for: `phase`, `plans[]` (each with `id`, `wave`, `autonomous`, `objec
117
117
 
118
118
  **Filtering:** Skip plans where `has_summary: true`. If `--gaps-only`: also skip non-gap_closure plans. If all filtered: "No matching incomplete plans" → exit.
119
119
 
120
+ **If `--gaps-only`:** this run is the fix round after a verification found gaps — a second attempt at work that failed once. Resolve the executor for that attempt and use it in place of `executor_model` for every spawn in this run:
121
+
122
+ ```bash
123
+ EXECUTOR_MODEL=$(node ~/.claude/pan-wizard-core/bin/pan-tools.cjs resolve-model pan-executor --attempt 2 --raw)
124
+ ```
125
+
126
+ It equals `executor_model` unless the `budget` profile runs the executor below its quality tier; then it is one tier higher (capped by `routing.max_escalations`), so the gaps are not retried on the tier that left them.
127
+
120
128
  Report:
121
129
  ```
122
130
  ## Execution Plan
@@ -224,7 +224,7 @@ The Focus workflow is a **scan → plan → exec → sync** pipeline. Each step
224
224
  | `/pan:plan-phase <N>` | Research + plan a phase with verification |
225
225
  | `/pan:exec-phase <N>` | Execute plans with wave-based parallelization |
226
226
  | `/pan:research-phase <N>` | Deep ecosystem research for specialized domains |
227
- | `/pan:verify-phase <N>` | Validate built features through UAT |
227
+ | `/pan:verify-phase <N>` | Re-run goal-backward verification with a test-suite gate |
228
228
 
229
229
  ### Phase Management
230
230
  | Command | Description |
@@ -348,7 +348,7 @@ The Focus workflow is a **scan → plan → exec → sync** pipeline. Each step
348
348
 
349
349
  **`/pan:exec-phase <N>`** — Execute all plans in a phase. Groups by wave, runs waves sequentially, plans within each wave in parallel.
350
350
 
351
- **`/pan:verify-phase <N>`** — Conversational UAT. Presents tests one at a time, diagnoses failures, creates fix plans.
351
+ **`/pan:verify-phase <N>`** — Re-run goal-backward verification behind a test-suite gate. Writes the phase's verification report (passed, gaps_found or human_needed); fix gaps with `/pan:plan-phase <N> --gaps`.
352
352
 
353
353
  **`/pan:quick <desc>`** — Small tasks with PAN guarantees but shorter path. Skips researcher, checker, verifier. Lives in `.planning/quick/`.
354
354
 
@@ -364,7 +364,7 @@ Present next steps:
364
364
 
365
365
  **Also available:**
366
366
  - `/pan:phase-tests {next_phase}` — test another phase
367
- - `/pan:verify-phase {phase_number}` — run UAT verification
367
+ - `/pan:verify-phase {phase_number}` — re-run goal-backward verification
368
368
 
369
369
  ---
370
370
  ```
@@ -252,7 +252,7 @@ uat.md exists with gaps (diagnosed issues). User needs to plan fixes.
252
252
 
253
253
  **Also available:**
254
254
  - `/pan:exec-phase {phase}` — execute phase plans
255
- - `/pan:verify-phase {phase}` — run more UAT testing
255
+ - `/pan:verify-phase {phase}` — re-run goal-backward verification
256
256
 
257
257
  ---
258
258
  ```