pan-wizard 3.30.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 (42) hide show
  1. package/README.md +2 -2
  2. package/bin/install-lib.cjs +129 -33
  3. package/bin/install.js +16 -9
  4. package/commands/pan/army.md +4 -4
  5. package/commands/pan/verify-phase.md +8 -9
  6. package/hooks/dist/pan-check-update.js +53 -2
  7. package/hooks/dist/pan-context-monitor.js +41 -0
  8. package/hooks/dist/pan-cost-logger.js +70 -1
  9. package/hooks/dist/pan-state-reinject.js +158 -0
  10. package/hooks/dist/pan-stop-guard.js +48 -2
  11. package/hooks/dist/pan-trace-logger.js +41 -0
  12. package/package.json +1 -1
  13. package/pan-wizard-core/bin/lib/commands.cjs +20 -4
  14. package/pan-wizard-core/bin/lib/config.cjs +2 -0
  15. package/pan-wizard-core/bin/lib/constants.cjs +3 -1
  16. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
  17. package/pan-wizard-core/bin/lib/core.cjs +232 -23
  18. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
  19. package/pan-wizard-core/bin/lib/cost.cjs +132 -43
  20. package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
  21. package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
  22. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +9 -2
  23. package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
  24. package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
  25. package/pan-wizard-core/bin/lib/state.cjs +86 -22
  26. package/pan-wizard-core/bin/lib/suggest.cjs +21 -9
  27. package/pan-wizard-core/bin/lib/template.cjs +6 -3
  28. package/pan-wizard-core/bin/lib/verify-deploy.cjs +19 -2
  29. package/pan-wizard-core/bin/pan-tools.cjs +10 -1
  30. package/pan-wizard-core/mcp/tool-registry.cjs +14 -4
  31. package/pan-wizard-core/workflows/exec-phase.md +8 -0
  32. package/pan-wizard-core/workflows/help.md +2 -2
  33. package/pan-wizard-core/workflows/phase-tests.md +1 -1
  34. package/pan-wizard-core/workflows/progress.md +1 -1
  35. package/pan-wizard-core/workflows/todo-add.md +1 -1
  36. package/pan-wizard-core/workflows/todo-check.md +7 -6
  37. package/scripts/build-agent-plugin.js +4 -0
  38. package/scripts/build-hooks.js +2 -1
  39. package/scripts/build-plugin.js +10 -0
  40. package/scripts/npm-dist-tag.js +47 -0
  41. package/scripts/plugin-path.js +6 -1
  42. package/scripts/test-surface.cjs +1 -0
@@ -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 (_) {
@@ -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;
@@ -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
  ```
@@ -19,7 +19,7 @@ Extract from init JSON: `commit_docs`, `date`, `timestamp`, `todo_count`, `todos
19
19
 
20
20
  Ensure directories exist:
21
21
  ```bash
22
- mkdir -p .planning/todos/pending .planning/todos/done
22
+ mkdir -p .planning/todos/pending .planning/todos/completed
23
23
  ```
24
24
 
25
25
  Note existing areas from the todos array for consistency in infer_area step.
@@ -105,7 +105,7 @@ Use AskUserQuestion:
105
105
  - header: "Action"
106
106
  - question: "This todo relates to Phase [N]: [name]. What would you like to do?"
107
107
  - options:
108
- - "Work on it now" — move to done, start working
108
+ - "Work on it now" — mark it completed, start working
109
109
  - "Add to phase plan" — include when planning Phase [N]
110
110
  - "Brainstorm approach" — think through before deciding
111
111
  - "Put it back" — return to list
@@ -116,7 +116,7 @@ Use AskUserQuestion:
116
116
  - header: "Action"
117
117
  - question: "What would you like to do with this todo?"
118
118
  - options:
119
- - "Work on it now" — move to done, start working
119
+ - "Work on it now" — mark it completed, start working
120
120
  - "Create a phase" — /pan:add-phase with this scope
121
121
  - "Brainstorm approach" — think through before deciding
122
122
  - "Put it back" — return to list
@@ -125,8 +125,9 @@ Use AskUserQuestion:
125
125
  <step name="execute_action">
126
126
  **Work on it now:**
127
127
  ```bash
128
- mv ".planning/todos/pending/[filename]" ".planning/todos/done/"
128
+ node ~/.claude/pan-wizard-core/bin/pan-tools.cjs todo complete "[filename]"
129
129
  ```
130
+ This moves it to `.planning/todos/completed/` with a `completed:` date — the directory `init todos` and `/pan:progress` count.
130
131
  Update state.md todo count. Present problem/solution context. Begin work or ask how to proceed.
131
132
 
132
133
  **Add to phase plan:**
@@ -150,11 +151,11 @@ Re-run `init todos` to get updated count, then update state.md "### Pending Todo
150
151
  </step>
151
152
 
152
153
  <step name="git_commit">
153
- If todo was moved to done/, commit the change:
154
+ If the todo was completed, commit the change:
154
155
 
155
156
  ```bash
156
157
  git rm --cached .planning/todos/pending/[filename] 2>/dev/null || true
157
- node ~/.claude/pan-wizard-core/bin/pan-tools.cjs commit "docs: start work on todo - [title]" --files .planning/todos/done/[filename] .planning/state.md
158
+ node ~/.claude/pan-wizard-core/bin/pan-tools.cjs commit "docs: start work on todo - [title]" --files .planning/todos/completed/[filename] .planning/state.md
158
159
  ```
159
160
 
160
161
  Tool respects `commit_docs` config and gitignore automatically.
@@ -172,5 +173,5 @@ Confirm: "Committed: docs: start work on todo - [title]"
172
173
  - [ ] Appropriate actions offered
173
174
  - [ ] Selected action executed
174
175
  - [ ] state.md updated if todo count changed
175
- - [ ] Changes committed to git (if todo moved to done/)
176
+ - [ ] Changes committed to git (if the todo was completed)
176
177
  </success_criteria>
@@ -150,6 +150,8 @@ function hookCommands(rootVar) {
150
150
  contextMonitorCommand: cmd('pan-context-monitor.js'),
151
151
  costLoggerCommand: cmd('pan-cost-logger.js'),
152
152
  traceLoggerCommand: cmd('pan-trace-logger.js'),
153
+ stopGuardCommand: cmd('pan-stop-guard.js'),
154
+ stateReinjectCommand: cmd('pan-state-reinject.js'),
153
155
  };
154
156
  }
155
157
 
@@ -166,6 +168,8 @@ function emitCopilotNamespace(agentsSrc, nsDir) {
166
168
  content = lib.rewriteAgentReferenceCopy(content, TOKEN_PREFIX);
167
169
  content = lib.stripThinkingFrontmatter(content, 'copilot');
168
170
  content = lib.convertClaudeToCopilotAgent(content);
171
+ // The agent carries the root token, so it carries what the token means.
172
+ if (content.includes(TOKEN_PREFIX)) content = content.replace(/\s*$/, '\n\n' + lib.agentPluginSkillAdapterNote('agent') + '\n');
169
173
  fs.writeFileSync(path.join(agentsDest, f.replace(/\.md$/, '.agent.md')), content);
170
174
  agents++;
171
175
  }
@@ -22,7 +22,8 @@ const HOOKS_TO_COPY = [
22
22
  'pan-statusline.js',
23
23
  'pan-cost-logger.js',
24
24
  'pan-trace-logger.js',
25
- 'pan-stop-guard.js'
25
+ 'pan-stop-guard.js',
26
+ 'pan-state-reinject.js'
26
27
  ];
27
28
 
28
29
  function build() {
@@ -169,6 +169,15 @@ function main() {
169
169
  );
170
170
  }
171
171
 
172
+ // 7. Eval suite (market-ideas M4). `claude plugin eval` reads `evals/` at the
173
+ // plugin root; the cases live in harness/plugin-evals/ beside the harness
174
+ // scenarios they come from. harness/ is not in the npm package, so a build from
175
+ // an installed copy has no suite to copy and the plugin ships without one.
176
+ const evalsSrc = path.join(ROOT, 'harness', 'plugin-evals');
177
+ if (fs.existsSync(evalsSrc)) {
178
+ fs.cpSync(evalsSrc, path.join(OUT, 'evals'), { recursive: true });
179
+ }
180
+
172
181
  // Sanity report
173
182
  const count = (p) => { try { return fs.readdirSync(p).length; } catch { return 0; } };
174
183
  console.log('PAN plugin built at', path.relative(ROOT, OUT));
@@ -176,6 +185,7 @@ function main() {
176
185
  console.log(' agents:', count(path.join(OUT, 'agents')));
177
186
  console.log(' hooks:', count(path.join(OUT, 'hooks')));
178
187
  console.log(' workflows:', workflowScripts.length);
188
+ console.log(' eval cases:', count(path.join(OUT, 'evals')) ? fs.readdirSync(path.join(OUT, 'evals'), { withFileTypes: true }).filter(e => e.isDirectory()).length : 0);
179
189
  console.log(' version:', pkg.version);
180
190
  }
181
191
 
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Print the npm dist-tag a version publishes under: `next` for a prerelease
4
+ * (`3.31.0-rc.1`), `latest` otherwise.
5
+ *
6
+ * WHY. The release workflow ran `npm publish` with no `--tag`, and npm's default
7
+ * tag is `latest` — so a release candidate would have become what
8
+ * `npm install pan-wizard` resolves to for every user (market-ideas queue M1,
9
+ * 2026-09-21). Publishing a candidate under `next` keeps it installable by name
10
+ * (`pan-wizard@next`, `pan-wizard@3.31.0-rc.1`) without moving anyone onto it.
11
+ *
12
+ * A version that is not `major.minor.patch[-prerelease]` exits 1 rather than
13
+ * guessing a tag: publishing junk under `latest` is the failure this prevents.
14
+ *
15
+ * Usage:
16
+ * node scripts/npm-dist-tag.js # the tag for package.json's version
17
+ * node scripts/npm-dist-tag.js 3.31.0-rc.1
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const path = require('path');
23
+ const { parse } = require('./deprecate-old-versions.js');
24
+
25
+ /**
26
+ * @param {string} version
27
+ * @returns {'latest'|'next'|null} null when the version does not parse
28
+ */
29
+ function distTagFor(version) {
30
+ const v = parse(version);
31
+ if (!v) return null;
32
+ return v.pre === null ? 'latest' : 'next';
33
+ }
34
+
35
+ function main() {
36
+ const version = process.argv[2] || require(path.join(__dirname, '..', 'package.json')).version;
37
+ const tag = distTagFor(version);
38
+ if (!tag) {
39
+ process.stderr.write(`npm-dist-tag: '${version}' is not a semver version — refusing to pick a tag\n`);
40
+ process.exit(1);
41
+ }
42
+ process.stdout.write(tag + '\n');
43
+ }
44
+
45
+ module.exports = { distTagFor };
46
+
47
+ if (require.main === module) main();
@@ -49,8 +49,13 @@ function fail(message) {
49
49
  function build() {
50
50
  try {
51
51
  // Relay the builder's stdout to stderr so stdout stays single-line.
52
+ // PAN_PLUGIN_OUT redirects the builder; this script prints PLUGIN_DIR, so a value
53
+ // inherited from the caller's environment would build one place and point the
54
+ // marketplace at another (3.28.0 review, LOW). Build where the path says.
55
+ const env = { ...process.env };
56
+ delete env.PAN_PLUGIN_OUT;
52
57
  const out = execFileSync(process.execPath, [path.join(ROOT, 'scripts', 'build-plugin.js')], {
53
- cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
58
+ cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env,
54
59
  });
55
60
  if (out) process.stderr.write(out);
56
61
  } catch (err) {
@@ -68,6 +68,7 @@ const EVENT_HOOKS = Object.freeze({
68
68
  postToolUse: ['pan-context-monitor.js'],
69
69
  subagentStop: ['pan-cost-logger.js', 'pan-trace-logger.js'],
70
70
  stop: ['pan-stop-guard.js'],
71
+ compact: ['pan-state-reinject.js'],
71
72
  });
72
73
 
73
74
  // ─── Parsers (pure) ─────────────────────────────────────────────────────────