pan-wizard 3.22.0 → 3.24.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 (134) hide show
  1. package/README.md +38 -24
  2. package/agents/pan-conductor.md +3 -3
  3. package/agents/pan-design-checker.md +83 -0
  4. package/agents/pan-designer.md +67 -0
  5. package/agents/pan-document_code.md +3 -2
  6. package/agents/pan-executor.md +2 -2
  7. package/agents/pan-plan-checker.md +19 -6
  8. package/agents/pan-planner.md +4 -1
  9. package/agents/pan-previewer.md +1 -1
  10. package/agents/pan-roadmapper.md +3 -1
  11. package/agents/pan-verifier.md +10 -10
  12. package/bin/install-lib.cjs +128 -17
  13. package/bin/install.js +480 -74
  14. package/commands/pan/army.md +20 -14
  15. package/commands/pan/audit-deployment.md +17 -14
  16. package/commands/pan/cost.md +2 -2
  17. package/commands/pan/debug.md +1 -1
  18. package/commands/pan/design-phase.md +77 -0
  19. package/commands/pan/exec-phase.md +2 -2
  20. package/commands/pan/experiment.md +18 -14
  21. package/commands/pan/focus-auto.md +5 -5
  22. package/commands/pan/focus-design.md +13 -1
  23. package/commands/pan/focus-exec.md +1 -1
  24. package/commands/pan/learn.md +1 -1
  25. package/commands/pan/links.md +3 -1
  26. package/commands/pan/map-codebase.md +4 -4
  27. package/commands/pan/mcp-bridge.md +1 -1
  28. package/commands/pan/milestone-new.md +3 -1
  29. package/commands/pan/optimize.md +2 -2
  30. package/commands/pan/patches.md +1 -1
  31. package/commands/pan/plan-phase.md +3 -1
  32. package/commands/pan/preview.md +3 -3
  33. package/commands/pan/profile.md +3 -3
  34. package/commands/pan/research-phase.md +1 -1
  35. package/commands/pan/retro.md +4 -1
  36. package/commands/pan/review-deep.md +1 -1
  37. package/commands/pan/settings.md +2 -2
  38. package/commands/pan/what-if.md +1 -1
  39. package/hooks/dist/pan-check-update.js +149 -38
  40. package/hooks/dist/pan-context-monitor.js +155 -81
  41. package/hooks/dist/pan-cost-logger.js +288 -13
  42. package/hooks/dist/pan-statusline.js +24 -8
  43. package/hooks/dist/pan-stop-guard.js +160 -0
  44. package/hooks/dist/pan-trace-logger.js +261 -19
  45. package/package.json +2 -2
  46. package/pan-wizard-core/bin/lib/bridge.cjs +1 -1
  47. package/pan-wizard-core/bin/lib/bus.cjs +19 -4
  48. package/pan-wizard-core/bin/lib/campaign.cjs +8 -3
  49. package/pan-wizard-core/bin/lib/codebase.cjs +15 -5
  50. package/pan-wizard-core/bin/lib/commands.cjs +88 -6
  51. package/pan-wizard-core/bin/lib/config.cjs +27 -2
  52. package/pan-wizard-core/bin/lib/constants.cjs +1 -0
  53. package/pan-wizard-core/bin/lib/core.cjs +113 -8
  54. package/pan-wizard-core/bin/lib/cost.cjs +61 -24
  55. package/pan-wizard-core/bin/lib/distill.cjs +22 -4
  56. package/pan-wizard-core/bin/lib/doc-lint.cjs +25 -7
  57. package/pan-wizard-core/bin/lib/experiment.cjs +25 -1
  58. package/pan-wizard-core/bin/lib/focus.cjs +42 -7
  59. package/pan-wizard-core/bin/lib/frontmatter.cjs +62 -31
  60. package/pan-wizard-core/bin/lib/git.cjs +51 -16
  61. package/pan-wizard-core/bin/lib/hud.cjs +17 -2
  62. package/pan-wizard-core/bin/lib/init.cjs +2 -5
  63. package/pan-wizard-core/bin/lib/knowledge.cjs +20 -1
  64. package/pan-wizard-core/bin/lib/learn-lint.cjs +50 -0
  65. package/pan-wizard-core/bin/lib/links.cjs +9 -6
  66. package/pan-wizard-core/bin/lib/lock.cjs +23 -4
  67. package/pan-wizard-core/bin/lib/memory-optimize.cjs +11 -6
  68. package/pan-wizard-core/bin/lib/milestone.cjs +11 -5
  69. package/pan-wizard-core/bin/lib/optimize.cjs +18 -7
  70. package/pan-wizard-core/bin/lib/phase-remove.cjs +41 -2
  71. package/pan-wizard-core/bin/lib/phase.cjs +82 -11
  72. package/pan-wizard-core/bin/lib/preview.cjs +23 -4
  73. package/pan-wizard-core/bin/lib/review-deep.cjs +14 -6
  74. package/pan-wizard-core/bin/lib/roadmap.cjs +6 -2
  75. package/pan-wizard-core/bin/lib/runner.cjs +17 -7
  76. package/pan-wizard-core/bin/lib/squads.cjs +43 -11
  77. package/pan-wizard-core/bin/lib/state.cjs +32 -11
  78. package/pan-wizard-core/bin/lib/template.cjs +8 -3
  79. package/pan-wizard-core/bin/lib/verify-deploy.cjs +14 -2
  80. package/pan-wizard-core/bin/lib/verify-drift.cjs +15 -1
  81. package/pan-wizard-core/bin/lib/verify-preflight.cjs +9 -3
  82. package/pan-wizard-core/bin/lib/verify.cjs +84 -39
  83. package/pan-wizard-core/bin/lib/whatif.cjs +8 -1
  84. package/pan-wizard-core/bin/pan-tools.cjs +42 -19
  85. package/pan-wizard-core/learnings/index.json +7 -7
  86. package/pan-wizard-core/learnings/universal/autonomous-loop.md +5 -3
  87. package/pan-wizard-core/learnings/universal/concurrency.md +1 -1
  88. package/pan-wizard-core/references/design-methodology.md +94 -0
  89. package/pan-wizard-core/references/git-integration.md +9 -9
  90. package/pan-wizard-core/references/guardrails.md +4 -1
  91. package/pan-wizard-core/references/model-profile-resolution.md +1 -1
  92. package/pan-wizard-core/references/model-profiles.md +44 -34
  93. package/pan-wizard-core/references/planning-config.md +5 -5
  94. package/pan-wizard-core/references/verification-patterns.md +67 -0
  95. package/pan-wizard-core/templates/codebase/architecture.md +1 -1
  96. package/pan-wizard-core/templates/codebase/concerns.md +1 -1
  97. package/pan-wizard-core/templates/codebase/conventions.md +1 -1
  98. package/pan-wizard-core/templates/codebase/integrations.md +1 -1
  99. package/pan-wizard-core/templates/codebase/stack.md +1 -1
  100. package/pan-wizard-core/templates/codebase/structure.md +1 -1
  101. package/pan-wizard-core/templates/codebase/testing.md +1 -1
  102. package/pan-wizard-core/templates/design.md +146 -0
  103. package/pan-wizard-core/templates/discovery.md +3 -3
  104. package/pan-wizard-core/templates/milestone-archive.md +2 -2
  105. package/pan-wizard-core/templates/playbook.md +1 -1
  106. package/pan-wizard-core/templates/uat.md +1 -1
  107. package/pan-wizard-core/workflows/diagnose-issues.md +9 -7
  108. package/pan-wizard-core/workflows/exec-phase.md +28 -8
  109. package/pan-wizard-core/workflows/execute-plan.md +2 -3
  110. package/pan-wizard-core/workflows/health.md +24 -1
  111. package/pan-wizard-core/workflows/help.md +5 -4
  112. package/pan-wizard-core/workflows/learn.md +5 -5
  113. package/pan-wizard-core/workflows/map-codebase.md +1 -1
  114. package/pan-wizard-core/workflows/milestone-audit.md +1 -1
  115. package/pan-wizard-core/workflows/milestone-new.md +4 -4
  116. package/pan-wizard-core/workflows/new-project.md +47 -32
  117. package/pan-wizard-core/workflows/optimize.md +8 -8
  118. package/pan-wizard-core/workflows/pause.md +1 -1
  119. package/pan-wizard-core/workflows/phase-tests.md +2 -2
  120. package/pan-wizard-core/workflows/plan-phase.md +4 -0
  121. package/pan-wizard-core/workflows/profile.md +13 -10
  122. package/pan-wizard-core/workflows/research-phase.md +1 -1
  123. package/pan-wizard-core/workflows/resume-project.md +1 -1
  124. package/pan-wizard-core/workflows/settings.md +9 -9
  125. package/pan-wizard-core/workflows/transition.md +24 -8
  126. package/pan-wizard-core/workflows/update.md +23 -20
  127. package/pan-wizard-core/workflows/verify-phase.md +7 -2
  128. package/pan-zcode/README.md +12 -2
  129. package/pan-zcode/bin/install-zcode.js +1 -1
  130. package/pan-zcode/mcp/native-tools.cjs +1 -1
  131. package/pan-zcode/mcp/server.cjs +76 -14
  132. package/scripts/build-hooks.js +2 -1
  133. package/scripts/generate-skills-docs.py +0 -4
  134. package/scripts/run-tests.cjs +11 -4
@@ -21,9 +21,21 @@ const SCANNABLE_EXTS = ['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.py'];
21
21
 
22
22
  // ─── Pass 1: Deterministic static analysis ───────────────────────────────────
23
23
 
24
+ // Calls that genuinely CANNOT throw when given any single argument, so a
25
+ // try/catch wrapping only such a call is truly phantom (dead catch clause).
26
+ // Deliberately EXCLUDES throwing calls — JSON.parse (SyntaxError on bad input),
27
+ // JSON.stringify (TypeError on circular refs / BigInt), new RegExp, fs.*Sync,
28
+ // etc. — because their try/catch is load-bearing and must not be flagged for
29
+ // removal. (M14: earlier the pattern listed JSON.parse/JSON.stringify and
30
+ // wrongly flagged their guards as phantom.)
31
+ const NON_THROWING_CALLS = ['Number', 'String', 'Boolean', 'parseInt', 'parseFloat'];
32
+
24
33
  function findPhantomTryCatch(content, filePath) {
25
34
  const findings = [];
26
- const re = /try\s*\{\s*(?:return\s+)?(JSON\.parse|JSON\.stringify|Number|String|Boolean|parseInt|parseFloat)\([^)]*\)\s*;?\s*\}\s*catch/g;
35
+ const re = new RegExp(
36
+ 'try\\s*\\{\\s*(?:return\\s+)?(' + NON_THROWING_CALLS.join('|') + ')\\([^)]*\\)\\s*;?\\s*\\}\\s*catch',
37
+ 'g'
38
+ );
27
39
  let match;
28
40
  while ((match = re.exec(content)) !== null) {
29
41
  const line = content.slice(0, match.index).split('\n').length;
@@ -372,6 +384,12 @@ function readPatternsMemory(cwd) {
372
384
  return { patterns, file: filePath };
373
385
  }
374
386
 
387
+ // M15: the patterns-memory file (distill-patterns.md) records DETECTIONS, not
388
+ // resolutions — there is no resolution/fix marker in the entry schema
389
+ // ({ date, pattern, file, note }). So a prior entry only proves the pattern was
390
+ // SEEN before, not that it was ever fixed. Labeling a re-detection "REGRESSED —
391
+ // last resolved <date>" was a false claim. We now report the honest signal:
392
+ // the pattern is RECURRING (still present across sessions / previously detected).
375
393
  function detectRegressedPatterns(currentFindings, memory) {
376
394
  const regressed = [];
377
395
  for (const f of currentFindings) {
@@ -380,8 +398,8 @@ function detectRegressedPatterns(currentFindings, memory) {
380
398
  regressed.push({
381
399
  ...f,
382
400
  regressed: true,
383
- previously_resolved: prior.date,
384
- message: f.message + ` (REGRESSED — last resolved ${prior.date})`,
401
+ previously_detected: prior.date,
402
+ message: f.message + ` (RECURRING — previously detected ${prior.date})`,
385
403
  });
386
404
  }
387
405
  }
@@ -426,7 +444,7 @@ function writePatternsMemory(cwd, findings, opts) {
426
444
  fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
427
445
  return { written: true, file: filePath, count: all.length };
428
446
  } catch (e) {
429
- return { written: false, error: e.message };
447
+ return { written: false, error: e.message || 'write_failed' };
430
448
  }
431
449
  }
432
450
 
@@ -39,7 +39,10 @@ const DEFAULT_SCHEMA_PATH = path.join(
39
39
  * @param {string} cwd - working directory (used to resolve relative paths)
40
40
  * @param {string} dir - directory to scan (relative to cwd or absolute)
41
41
  * @param {object} opts - { schema: string, format: 'json'|'human', strict: bool, exclude: string[], raw: bool }
42
- * @returns {void} — writes to stdout via output(); exit code via process.exit
42
+ * @returns {void} — writes to stdout, then exits. Exit code: 0 clean, 1 violations
43
+ * found, 2 the schema itself is malformed. output() exits, so every branch that
44
+ * calls it must pass its verdict as output()'s 4th argument; a bare
45
+ * process.exit() after an output() call is unreachable.
43
46
  */
44
47
  function cmdDocLint(cwd, dir, opts = {}) {
45
48
  const targetDir = path.isAbsolute(dir) ? dir : path.join(cwd, dir);
@@ -63,8 +66,16 @@ function cmdDocLint(cwd, dir, opts = {}) {
63
66
  process.stderr.write(` ${schemaPath}:${e.line} — ${e.message}\n`);
64
67
  }
65
68
  } else {
66
- output({ schema_errors: schemaErrors, schema: schemaPath }, false);
69
+ // Exit 2 is passed to output() because output() EXITS. The `process.exit(2)`
70
+ // below it was dead code on this branch, so `doc-lint --format json` against a
71
+ // malformed schema printed the errors and reported SUCCESS -- nothing was
72
+ // linted, and the caller was told everything was fine. The exit code is not
73
+ // derived here: `schema_errors` is a plural collection, deliberately outside
74
+ // output()'s error family (an empty array is truthy), so gates state their
75
+ // verdict explicitly.
76
+ output({ schema_errors: schemaErrors, schema: schemaPath }, false, undefined, 2);
67
77
  }
78
+ // Reached only on the --raw branch above, which does not call output().
68
79
  process.exit(2);
69
80
  }
70
81
 
@@ -102,6 +113,9 @@ function cmdDocLint(cwd, dir, opts = {}) {
102
113
  process.stdout.write(summaryLine(violations, fileCount) + '\n');
103
114
  }
104
115
  } else {
116
+ // Same verdict the --raw branch reports via the process.exit() below; passed
117
+ // explicitly because output() exits and would otherwise discard it, making
118
+ // `doc-lint --format json` a linter that never fails.
105
119
  output({
106
120
  directory: dir,
107
121
  schema: schemaPath,
@@ -109,7 +123,7 @@ function cmdDocLint(cwd, dir, opts = {}) {
109
123
  error_count: errorCount,
110
124
  warning_count: warningCount,
111
125
  violations,
112
- }, false);
126
+ }, false, undefined, errorCount > 0 ? 1 : 0);
113
127
  }
114
128
 
115
129
  process.exit(errorCount > 0 ? 1 : 0);
@@ -131,8 +145,9 @@ function cmdDocLintSchemaCheck(cwd, schemaPath, opts = {}) {
131
145
  error_count: errors.length,
132
146
  errors,
133
147
  };
134
- output(result, opts.raw);
135
- process.exit(result.ok ? 0 : 1);
148
+ // output() is called on both branches here, so the process.exit() below it was
149
+ // unconditionally dead: schema-check reported a malformed schema and exited 0.
150
+ output(result, opts.raw, undefined, result.ok ? 0 : 1);
136
151
  }
137
152
 
138
153
  // ─── Count-drift lint (IMPROVEMENT-TODO P1, v3.7.10) ────────────────────────
@@ -178,6 +193,7 @@ const COUNT_ALLOWED_RE = /(^|[\\/])(CLAUDE\.md|CHANGELOG\.md|MEMORY\.md|SKILLS-F
178
193
  const COUNT_ALLOWED_DIR_SEGMENTS = [
179
194
  'decisions', // ADRs — frozen
180
195
  'specs', // feature specs — frozen
196
+ 'audits', // audit reports — point-in-time snapshots, exempt by their own preamble
181
197
  'experiments', // harvested experiment artifacts
182
198
  'learnings', // AI-derived patterns; evidence quotes reference numbers
183
199
  'archive', // archived old docs
@@ -278,8 +294,9 @@ function cmdDocLintCounts(cwd, dir, opts = {}) {
278
294
  process.stdout.write(`\n${violations.length} violation(s) across ${fileCount} files\n`);
279
295
  }
280
296
  } else {
281
- output(result, false);
297
+ output(result, false, undefined, violations.length > 0 ? 1 : 0);
282
298
  }
299
+ // Reached only on the --raw branch (release-check gate 4 relies on this code).
283
300
  process.exit(violations.length > 0 ? 1 : 0);
284
301
  }
285
302
 
@@ -355,8 +372,9 @@ function cmdDocLintFlags(cwd, opts = {}, raw) {
355
372
  process.stdout.write(`\n${r.violation_count} aspirational flag(s)\n`);
356
373
  }
357
374
  } else {
358
- output(r, false);
375
+ output(r, false, undefined, r.violation_count > 0 ? 1 : 0);
359
376
  }
377
+ // Reached only on the --raw branch.
360
378
  process.exit(r.violation_count > 0 ? 1 : 0);
361
379
  }
362
380
 
@@ -156,7 +156,31 @@ function newExperiment(slug, opts = {}) {
156
156
  // going because the workflow-level commit step exit code was 0.
157
157
  initExperimentGit(expPath);
158
158
 
159
- // Optional: invoke installer for the chosen runtime in the experiment folder
159
+ // Optional: invoke installer for the chosen runtime in the experiment folder.
160
+ // M16: the installer only ships in the PAN SOURCE repo (bin/install.js). In an
161
+ // INSTALLED host project PAN_SOURCE_ROOT resolves to the config dir (e.g.
162
+ // .claude/), which never contains bin/install.js — so the installer step would
163
+ // always fail. Detect the missing installer up front and default to skip with a
164
+ // clear note, rather than attempting a spawn that is doomed to error.
165
+ const installerPresent = fs.existsSync(path.join(PAN_SOURCE_ROOT, 'bin', 'install.js'));
166
+ if (!opts.skipInstaller && !installerPresent) {
167
+ manifest.status = 'scaffolded';
168
+ manifest.installer_skipped = 'installer not available (bin/install.js absent — PAN is installed, not running from source); run the PAN installer manually in the experiment folder if a runtime is needed';
169
+ try {
170
+ fs.writeFileSync(
171
+ path.join(expPath, '.planning', 'experiment.json'),
172
+ JSON.stringify(manifest, null, 2)
173
+ );
174
+ } catch { /* best effort — non-fatal */ }
175
+ return {
176
+ experiment_id: slug,
177
+ path: expPath,
178
+ runtime,
179
+ idea_path: manifest.idea_path,
180
+ created_at: createdAt,
181
+ installer_skipped: manifest.installer_skipped,
182
+ };
183
+ }
160
184
  if (!opts.skipInstaller) {
161
185
  const installerError = runInstaller(expPath, runtime);
162
186
  if (installerError) {
@@ -8,7 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
- const { output, error, safeReadFile, loadConfig, scanPendingTodos, scanSourceTodos, toPosix, isGitRepo, execGit, escapeRegex } = require('./core.cjs');
11
+ const { output, EXIT_OK, error, safeReadFile, loadConfig, scanPendingTodos, scanSourceTodos, toPosix, isGitRepo, execGit, escapeRegex, normalizePhaseName } = require('./core.cjs');
12
12
  const {
13
13
  PLANNING_DIR, PHASES_DIR, ROADMAP_FILE, PATTERNS_FILE, EFFORT_POINTS, PRIORITY_LEVELS, EFFORT_SIZES,
14
14
  FOCUS_MODES, FOCUS_TIERS, FOCUS_DIR,
@@ -46,7 +46,17 @@ function collectWorkItems(cwd) {
46
46
  try { dirs = fs.readdirSync(phasesDir); } catch { dirs = []; }
47
47
 
48
48
  for (const phase of phases) {
49
- const dirName = dirs.find(d => d.startsWith(phase.number + '-') || d === phase.number);
49
+ // Normalize before matching. Roadmap headings are UNPADDED — the shipped
50
+ // templates/roadmap.md writes "### Phase 1:" and `phase add` appends the same —
51
+ // while phase directories are zero-padded (`01-foundation`). Matching the raw
52
+ // heading number meant '1-' never matched '01-foundation', so focus scan found
53
+ // ZERO work items on a roadmap in PAN's own format, and the whole focus family
54
+ // (scan/plan/classify-stages/exec/auto) was a no-op on any default-shaped
55
+ // project. normalizePhaseName pads and is idempotent, so a roadmap that already
56
+ // says "Phase 01" still matches. Same normalization core.cjs searchPhaseInDir
57
+ // applies, which is why `find-phase 1` always worked where this did not.
58
+ const normalized = normalizePhaseName(phase.number);
59
+ const dirName = dirs.find(d => d.startsWith(normalized + '-') || d === normalized);
50
60
  if (!dirName) continue;
51
61
 
52
62
  const phaseDir = path.join(phasesDir, dirName);
@@ -344,7 +354,13 @@ function cmdFocusPlan(cwd, raw, ...args) {
344
354
  // Collect items
345
355
  const { items, sources } = collectWorkItems(cwd);
346
356
  if (items.length === 0) {
347
- output({ error: 'No work items found. Run focus scan first or add phases/todos.' }, raw);
357
+ // EXIT_OK: an empty backlog is a legitimate answer, not a failure — it is the
358
+ // desired end state of a burn-down loop. `focus scan` already reports zero items
359
+ // as a success payload ({ items: [], total: 0 }) and the shipped scenario test
360
+ // asserts `focus plan` succeeds on a project with no TODOs
361
+ // (tests/scenarios/workflow-focus.test.cjs), so exiting non-zero here would make
362
+ // "nothing left to do" indistinguishable from a crash for the same caller.
363
+ output({ error: 'No work items found. Run focus scan first or add phases/todos.' }, raw, undefined, EXIT_OK);
348
364
  return;
349
365
  }
350
366
 
@@ -404,23 +420,35 @@ function checkDocStaleness(cwd, opts) {
404
420
  const current = [];
405
421
  const options = opts || {};
406
422
 
407
- // Count actuals
423
+ // Count actuals. M17: a filesystem count only reconciles a documented count
424
+ // when the corresponding SOURCE directory actually exists. In an INSTALLED
425
+ // host project commands/pan, agents/, and pan-wizard-core/bin/lib are all
426
+ // absent — reconciling a documented "N commands" against an actual of 0 there
427
+ // is a false positive, so the per-entity check is SKIPPED when its dir is
428
+ // missing (tracked via *Present flags below). Explicit --tests/--suites and the
429
+ // version cross-reference are layout-independent and always run.
408
430
  let commandCount = 0;
431
+ let commandsPresent = false;
409
432
  try {
410
433
  const cmdDir = path.join(cwd, 'commands', 'pan');
411
434
  commandCount = fs.readdirSync(cmdDir).filter(f => f.endsWith('.md')).length;
435
+ commandsPresent = true;
412
436
  } catch { /* no commands dir */ }
413
437
 
414
438
  let agentCount = 0;
439
+ let agentsPresent = false;
415
440
  try {
416
441
  const agentDir = path.join(cwd, 'agents');
417
442
  agentCount = fs.readdirSync(agentDir).filter(f => f.endsWith('.md')).length;
443
+ agentsPresent = true;
418
444
  } catch { /* no agents dir */ }
419
445
 
420
446
  let moduleCount = 0;
447
+ let modulesPresent = false;
421
448
  try {
422
449
  const libDir = path.join(cwd, 'pan-wizard-core', 'bin', 'lib');
423
450
  moduleCount = fs.readdirSync(libDir).filter(f => f.endsWith('.cjs')).length;
451
+ modulesPresent = true;
424
452
  } catch { /* no lib dir */ }
425
453
 
426
454
  const actuals = { commands: commandCount, agents: agentCount, modules: moduleCount };
@@ -429,9 +457,9 @@ function checkDocStaleness(cwd, opts) {
429
457
  for (const relFile of DOC_SYNC_FILES) {
430
458
  const content = safeReadFile(path.join(cwd, relFile));
431
459
  if (!content) continue;
432
- checkCount(content, relFile, 'commands', commandCount, stale, current);
433
- checkCount(content, relFile, 'agents', agentCount, stale, current);
434
- checkCount(content, relFile, 'modules', moduleCount, stale, current);
460
+ if (commandsPresent) checkCount(content, relFile, 'commands', commandCount, stale, current);
461
+ if (agentsPresent) checkCount(content, relFile, 'agents', agentCount, stale, current);
462
+ if (modulesPresent) checkCount(content, relFile, 'modules', moduleCount, stale, current);
435
463
 
436
464
  // Check test/suite counts if provided
437
465
  if (options.tests != null) {
@@ -790,6 +818,13 @@ function focusAutoUpdate(cwd, raw, getVal) {
790
818
  timestamp: new Date().toISOString(),
791
819
  };
792
820
 
821
+ // M18: wire --prompts-remaining so the `prompts_complete` stop reason is
822
+ // reachable (focus-auto.md step 8 instructs recording it). Absent → null, so
823
+ // determineStopReason's `prompts_remaining === 0` check never fires spuriously
824
+ // for non-prompts categories or when the flag isn't supplied.
825
+ const promptsRemainingRaw = getVal('--prompts-remaining', null);
826
+ cycle.prompts_remaining = promptsRemainingRaw != null ? Number(promptsRemainingRaw) : null;
827
+
793
828
  // Anti-fake: re-run the suite when verification is enabled; else record that
794
829
  // this count was self-reported (tests_verified:false) so the trust is visible.
795
830
  const tv = verifyTestCount(cwd, loadConfig(cwd), cycle.tests_after);
@@ -4,7 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { safeReadFile, output, error } = require('./core.cjs');
7
+ const { safeReadFile, output, error, escapeRegex } = require('./core.cjs');
8
8
  const { FIELD_VALUE_RE, PRIORITY_LEVELS, EFFORT_SIZES } = require('./constants.cjs');
9
9
 
10
10
  // --- Inline array rendering thresholds -------------------------------------------
@@ -157,7 +157,10 @@ function renderYamlEntry(lines, key, value, depth) {
157
157
  if (Array.isArray(value)) {
158
158
  if (value.length === 0) {
159
159
  lines.push(`${indent}${key}: []`);
160
- } else if (value.every(item => typeof item === 'string') && value.length <= MAX_INLINE_ARRAY_ITEMS && value.join(', ').length < MAX_INLINE_ARRAY_WIDTH) {
160
+ } else if (value.every(item => typeof item === 'string') && value.length <= MAX_INLINE_ARRAY_ITEMS && value.join(', ').length < MAX_INLINE_ARRAY_WIDTH && value.every(item => !item.includes(','))) {
161
+ // Inline form `[a, b, c]` is delimited by ', ', so an item that itself
162
+ // contains a comma would be re-split into multiple items on parse (lossy).
163
+ // Force multi-line ("- item") for those arrays so each item survives whole.
161
164
  lines.push(`${indent}${key}: [${value.join(', ')}]`);
162
165
  } else {
163
166
  lines.push(`${indent}${key}:`);
@@ -226,56 +229,84 @@ function parseMustHavesBlock(content, blockName) {
226
229
  if (!fmMatch) return [];
227
230
 
228
231
  const yaml = fmMatch[1];
232
+ const lines = yaml.split('\n');
233
+ const indentOf = (line) => line.match(/^(\s*)/)[1].length;
234
+
235
+ // Find `blockName:` as a child of `must_haves:` — by RELATIVE indent, never a
236
+ // fixed column. This function used to require exactly 4 spaces
237
+ // (`^\s{4}artifacts:`) with list items at exactly 6 and continuations at 8+.
238
+ // Nothing PAN ships is written that way: `template fill plan`,
239
+ // templates/phase-prompt.md, agents/pan-planner.md and agents/pan-verifier.md
240
+ // all put `must_haves:` at column 0 with its children at 2. So every real plan
241
+ // parsed as ZERO must_haves, and `verify reconcile` — the anti-rubber-stamp gate
242
+ // — reported "no must_haves declared … verdict trusted" and exited 0 on a stub.
243
+ // The 2026-08 audit chain missed it for five rounds because its fixtures were
244
+ // hand-written at 4-space, i.e. it verified the parser against itself.
245
+ const mustHavesIdx = lines.findIndex(l => /^\s*must_haves:\s*$/.test(l));
246
+ if (mustHavesIdx === -1) return [];
247
+ const mustHavesIndent = indentOf(lines[mustHavesIdx]);
248
+
249
+ // The block header is the first `blockName:` deeper than must_haves and before
250
+ // the next line at must_haves' own level or shallower (which ends the section).
251
+ let blockIdx = -1;
252
+ for (let i = mustHavesIdx + 1; i < lines.length; i++) {
253
+ const line = lines[i];
254
+ if (line.trim() === '') continue;
255
+ const ind = indentOf(line);
256
+ if (ind <= mustHavesIndent) break; // left the must_haves section
257
+ if (new RegExp(`^\\s*${escapeRegex(blockName)}:\\s*$`).test(line)) { blockIdx = i; break; }
258
+ }
259
+ if (blockIdx === -1) return [];
260
+ const blockIndent = indentOf(lines[blockIdx]);
229
261
 
230
- // Locate the block header at exactly 4-space indent (must_haves child level)
231
- const blockPattern = new RegExp(`^\\s{4}${blockName}:\\s*$`, 'm');
232
- const blockStart = yaml.search(blockPattern);
233
- if (blockStart === -1) return [];
234
-
235
- const afterBlock = yaml.slice(blockStart);
236
- // Skip the header line itself, then process remaining lines
237
- const blockLines = afterBlock.split('\n').slice(1);
262
+ const blockLines = lines.slice(blockIdx + 1);
238
263
 
239
264
  const items = [];
240
265
  let currentItem = null;
266
+ // Indent of the list-item dashes, learned from the first one we see, so that
267
+ // continuation lines can be told apart from items without assuming a width.
268
+ let itemIndent = null;
241
269
 
242
270
  for (const line of blockLines) {
243
271
  // Skip blank lines within the block
244
272
  if (line.trim() === '') continue;
245
273
 
246
- // --- Indentation-based grouping ---
247
- // Measure indent to detect when we've left the block (indent <= 4
248
- // means we've returned to must_haves level or a sibling block)
249
- const indent = line.match(/^(\s*)/)[1].length;
250
- if (indent <= 4 && line.trim() !== '') break;
274
+ // Leaving the block: back to the block header's level or shallower (a sibling
275
+ // block such as key_links, or back out to must_haves).
276
+ const indent = indentOf(line);
277
+ if (indent <= blockIndent) break;
251
278
 
252
- if (line.match(/^\s{6}-\s+/)) {
253
- // New list item at 6-space indent (direct child of the block)
279
+ const dashMatch = line.match(/^(\s*)-\s+(.*)$/);
280
+ const isNewItem = dashMatch && (itemIndent === null || dashMatch[1].length <= itemIndent);
281
+
282
+ if (isNewItem) {
283
+ if (itemIndent === null) itemIndent = dashMatch[1].length;
284
+ // New list item (direct child of the block)
254
285
  if (currentItem) items.push(currentItem);
255
286
  currentItem = {};
256
287
 
257
- // Check if it is a simple string item (no colon means not key-value)
258
- const simpleMatch = line.match(/^\s{6}-\s+"?([^"]+)"?\s*$/);
259
- if (simpleMatch && !line.includes(':')) {
260
- currentItem = simpleMatch[1];
288
+ const body = dashMatch[2];
289
+ // A plain string item — no `key: value` shape.
290
+ const kvMatch = body.match(/^(\w+):\s*"?([^"]*)"?\s*$/);
291
+ if (kvMatch) {
292
+ currentItem = {};
293
+ currentItem[kvMatch[1]] = kvMatch[2];
261
294
  } else {
262
- // Key-value on same line as dash: "- path: value"
263
- const kvMatch = line.match(/^\s{6}-\s+(\w+):\s*"?([^"]*)"?\s*$/);
264
- if (kvMatch) {
265
- currentItem = {};
266
- currentItem[kvMatch[1]] = kvMatch[2];
267
- }
295
+ currentItem = body.replace(/^"|"$/g, '').trim();
268
296
  }
269
297
  } else if (currentItem && typeof currentItem === 'object') {
270
- // Continuation key-value at 8+ space indent (properties of current list item)
271
- const kvMatch = line.match(/^\s{8,}(\w+):\s*"?([^"]*)"?\s*$/);
298
+ // Continuation key-value: deeper than the item's dash (properties of it)
299
+ const kvMatch = line.match(/^\s+(\w+):\s*"?([^"]*)"?\s*$/);
272
300
  if (kvMatch) {
273
301
  const val = kvMatch[2];
274
302
  // Coerce pure-integer strings to numbers for convenience
275
303
  currentItem[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val;
276
304
  }
277
- // Array items nested under a property at 10+ space indent
278
- const arrMatch = line.match(/^\s{10,}-\s+"?([^"]+)"?\s*$/);
305
+ // Array items nested under a property. Any dash line reaching this branch is
306
+ // already deeper than the list-item dash (a shallower one would have been
307
+ // treated as a new item above), so relative depth alone identifies it —
308
+ // no fixed column.
309
+ const arrMatch = line.match(/^\s+-\s+"?([^"]+)"?\s*$/);
279
310
  if (arrMatch) {
280
311
  // Convert the most recently added key's scalar value into an array,
281
312
  // then append this item to that array
@@ -3,6 +3,25 @@
3
3
  const { output, error, isGitRepo, execGit, loadConfig } = require('./core.cjs');
4
4
  const { runCommitSafetyChecks, VALID_COMMIT_TYPES } = require('./commands.cjs');
5
5
 
6
+ // ─── Failure reporting ────────────────────────────────────────────────────────
7
+ //
8
+ // Every subcommand here asks git to DO something. When git refuses, the payload
9
+ // says `<verb>: false` — a shape output() cannot classify on its own, because the
10
+ // same shape also carries legitimate negative answers elsewhere in PAN (see the
11
+ // exit-code contract in core.cjs). So each failure site names itself with an
12
+ // error-family key, which is what output() derives a non-zero exit from.
13
+ //
14
+ // Whatever the value is, it must never be able to come out EMPTY: git does not
15
+ // always write to stderr, and an empty string is falsy — it would launder the
16
+ // failure back into exit 0. Two safe forms are used here:
17
+ // • a stable code (`error: 'push_failed'`) — the form the query subcommands in
18
+ // this file already used (`error: 'status_failed', detail: r.stderr`);
19
+ // • stderr with a fallback (`error: r.stderr || 'unknown git error'`) — used by
20
+ // `commit` only, so all three of PAN's commit paths (this one, cmdCommit and
21
+ // cmdBatchCommit in commands.cjs) report a refused commit identically.
22
+ // The stderr text stays in `detail` where it always was, so existing consumers
23
+ // keep reading the same field.
24
+
6
25
  // ─── Helpers ──────────────────────────────────────────────────────────────────
7
26
 
8
27
  function getCurrentBranch(cwd) {
@@ -52,7 +71,10 @@ function cmdGitCommit(cwd, opts, raw) {
52
71
  const config = loadConfig(cwd);
53
72
  const safety = runCommitSafetyChecks(cwd, config, force);
54
73
  if (safety.blocked) {
55
- output({ committed: false, reason: safety.reason, safety_checks: safety.safetyChecks, hint: safety.hint }, raw, 'blocked');
74
+ // A blocked commit is a refusal that protected something (staged deletions, a
75
+ // secret) — the commit did NOT happen, so the caller must be able to stop. Same
76
+ // classification CLI-REFERENCE already documents for `dirty_working_tree`.
77
+ output({ committed: false, reason: safety.reason, error: 'commit_blocked', safety_checks: safety.safetyChecks, hint: safety.hint }, raw, 'blocked');
56
78
  return;
57
79
  }
58
80
 
@@ -61,10 +83,16 @@ function cmdGitCommit(cwd, opts, raw) {
61
83
  const r = execGit(cwd, commitArgs);
62
84
  if (r.exitCode !== 0) {
63
85
  if (r.stdout.includes('nothing to commit') || r.stderr.includes('nothing to commit')) {
86
+ // No error key, exit 0: nothing to commit means no change was NEEDED, not that
87
+ // a change failed. Pinned as a success in CLI-REFERENCE ("Error Shape").
64
88
  output({ committed: false, reason: 'nothing_to_commit' }, raw, 'nothing to commit');
65
89
  return;
66
90
  }
67
- output({ committed: false, reason: 'commit_failed', detail: r.stderr }, raw, 'commit failed');
91
+ // `|| 'unknown git error'` is load-bearing: the exit code is derived from a
92
+ // TRUTHY error key and git does not always write to stderr, so an empty string
93
+ // would launder this real failure back into exit 0. Same shape as the two
94
+ // sibling commit paths in commands.cjs (cmdCommit, cmdBatchCommit).
95
+ output({ committed: false, reason: 'commit_failed', error: r.stderr || 'unknown git error', detail: r.stderr }, raw, 'commit failed');
68
96
  return;
69
97
  }
70
98
  const hash = execGit(cwd, ['rev-parse', '--short', 'HEAD']).stdout || null;
@@ -90,7 +118,7 @@ function cmdGitBranch(cwd, sub, opts, raw) {
90
118
  if (!branchName) { error('--name or --phase required for branch create'); }
91
119
  const r = execGit(cwd, ['checkout', '-b', branchName]);
92
120
  if (r.exitCode !== 0) {
93
- output({ created: false, branch: branchName, detail: r.stderr }, raw, 'failed');
121
+ output({ created: false, branch: branchName, error: 'branch_create_failed', detail: r.stderr }, raw, 'failed');
94
122
  return;
95
123
  }
96
124
  output({ created: true, branch: branchName }, raw, branchName);
@@ -100,7 +128,7 @@ function cmdGitBranch(cwd, sub, opts, raw) {
100
128
  if (!name) { error('--name required for branch switch'); }
101
129
  const r = execGit(cwd, ['checkout', name]);
102
130
  if (r.exitCode !== 0) {
103
- output({ switched: false, branch: name, detail: r.stderr }, raw, 'failed');
131
+ output({ switched: false, branch: name, error: 'branch_switch_failed', detail: r.stderr }, raw, 'failed');
104
132
  return;
105
133
  }
106
134
  output({ switched: true, branch: name }, raw, name);
@@ -111,7 +139,7 @@ function cmdGitBranch(cwd, sub, opts, raw) {
111
139
  const flag = force ? '-D' : '-d';
112
140
  const r = execGit(cwd, ['branch', flag, name]);
113
141
  if (r.exitCode !== 0) {
114
- output({ deleted: false, branch: name, detail: r.stderr, hint: force ? null : 'Use --force to delete unmerged branches' }, raw, 'failed');
142
+ output({ deleted: false, branch: name, error: 'branch_delete_failed', detail: r.stderr, hint: force ? null : 'Use --force to delete unmerged branches' }, raw, 'failed');
115
143
  return;
116
144
  }
117
145
  output({ deleted: true, branch: name }, raw, name);
@@ -134,7 +162,7 @@ function cmdGitPush(cwd, opts, raw) {
134
162
 
135
163
  const r = execGit(cwd, pushArgs);
136
164
  if (r.exitCode !== 0) {
137
- output({ pushed: false, remote, branch, detail: r.stderr }, raw, 'push failed');
165
+ output({ pushed: false, remote, branch, error: 'push_failed', detail: r.stderr }, raw, 'push failed');
138
166
  return;
139
167
  }
140
168
  output({ pushed: true, remote, branch, force: !!force }, raw, remote + '/' + branch);
@@ -182,14 +210,14 @@ function cmdGitStash(cwd, sub, opts, raw) {
182
210
  if (sub === 'save') {
183
211
  const args = name ? ['stash', 'push', '-m', name] : ['stash', 'push'];
184
212
  const r = execGit(cwd, args);
185
- if (r.exitCode !== 0) { output({ stashed: false, detail: r.stderr }, raw, 'stash failed'); return; }
213
+ if (r.exitCode !== 0) { output({ stashed: false, error: 'stash_save_failed', detail: r.stderr }, raw, 'stash failed'); return; }
186
214
  output({ stashed: true, name: name || null }, raw, 'stashed');
187
215
  return;
188
216
  }
189
217
  if (sub === 'pop') {
190
218
  const args = index != null ? ['stash', 'pop', 'stash@{' + index + '}'] : ['stash', 'pop'];
191
219
  const r = execGit(cwd, args);
192
- if (r.exitCode !== 0) { output({ popped: false, detail: r.stderr }, raw, 'pop failed'); return; }
220
+ if (r.exitCode !== 0) { output({ popped: false, error: 'stash_pop_failed', detail: r.stderr }, raw, 'pop failed'); return; }
193
221
  output({ popped: true }, raw, 'popped');
194
222
  return;
195
223
  }
@@ -202,7 +230,7 @@ function cmdGitStash(cwd, sub, opts, raw) {
202
230
  if (sub === 'drop') {
203
231
  const args = index != null ? ['stash', 'drop', 'stash@{' + index + '}'] : ['stash', 'drop'];
204
232
  const r = execGit(cwd, args);
205
- if (r.exitCode !== 0) { output({ dropped: false, detail: r.stderr }, raw, 'drop failed'); return; }
233
+ if (r.exitCode !== 0) { output({ dropped: false, error: 'stash_drop_failed', detail: r.stderr }, raw, 'drop failed'); return; }
206
234
  output({ dropped: true }, raw, 'dropped');
207
235
  return;
208
236
  }
@@ -250,7 +278,7 @@ function cmdGitRollback(cwd, opts, raw) {
250
278
  }
251
279
  const r = execGit(cwd, ['reset', '--hard', targetTag]);
252
280
  if (r.exitCode !== 0) {
253
- output({ rolled_back: false, tag: targetTag, detail: r.stderr }, raw, 'rollback failed');
281
+ output({ rolled_back: false, tag: targetTag, error: 'rollback_failed', detail: r.stderr }, raw, 'rollback failed');
254
282
  return;
255
283
  }
256
284
  }
@@ -280,7 +308,7 @@ function cmdGitTag(cwd, sub, opts, raw) {
280
308
  : ['-c', 'tag.gpgsign=false', 'tag', name];
281
309
  const r = execGit(cwd, args);
282
310
  if (r.exitCode !== 0) {
283
- output({ created: false, tag: name, detail: r.stderr }, raw, 'tag create failed');
311
+ output({ created: false, tag: name, error: 'tag_create_failed', detail: r.stderr }, raw, 'tag create failed');
284
312
  return;
285
313
  }
286
314
  output({ created: true, tag: name }, raw, name);
@@ -290,7 +318,7 @@ function cmdGitTag(cwd, sub, opts, raw) {
290
318
  if (!name) { error('--name required for tag delete'); }
291
319
  const r = execGit(cwd, ['tag', '-d', name]);
292
320
  if (r.exitCode !== 0) {
293
- output({ deleted: false, tag: name, detail: r.stderr }, raw, 'tag delete failed');
321
+ output({ deleted: false, tag: name, error: 'tag_delete_failed', detail: r.stderr }, raw, 'tag delete failed');
294
322
  return;
295
323
  }
296
324
  output({ deleted: true, tag: name }, raw, name);
@@ -310,14 +338,14 @@ function cmdGitSync(cwd, opts, raw) {
310
338
 
311
339
  const fetchR = execGit(cwd, ['fetch', remote]);
312
340
  if (fetchR.exitCode !== 0) {
313
- output({ synced: false, detail: fetchR.stderr }, raw, 'fetch failed');
341
+ output({ synced: false, error: 'fetch_failed', detail: fetchR.stderr }, raw, 'fetch failed');
314
342
  return;
315
343
  }
316
344
 
317
345
  const pullArgs = rebase ? ['pull', '--rebase', remote, branch] : ['pull', remote, branch];
318
346
  const pullR = execGit(cwd, pullArgs);
319
347
  if (pullR.exitCode !== 0) {
320
- output({ synced: false, detail: pullR.stderr }, raw, 'pull failed');
348
+ output({ synced: false, error: 'pull_failed', detail: pullR.stderr }, raw, 'pull failed');
321
349
  return;
322
350
  }
323
351
 
@@ -337,15 +365,22 @@ function cmdGit(cwd, subcommand, args, raw) {
337
365
  const hasFlag = flag => args.includes(flag);
338
366
 
339
367
  switch (subcommand) {
340
- case 'commit':
368
+ case 'commit': {
369
+ // M20: filter positional file paths from args AFTER dropping the leading
370
+ // subcommand token ('commit'). Operating on the full args left 'commit'
371
+ // itself in the files list, so a path literally named 'commit' got staged.
372
+ // Also drop the value that follows --type / --message so a flag's argument
373
+ // is never mistaken for a file path.
374
+ const commitArgs = args.slice(1);
341
375
  return cmdGitCommit(cwd, {
342
376
  type: getOpt('--type', null),
343
377
  message: getOpt('--message', null),
344
378
  all: hasFlag('--all'),
345
379
  amend: hasFlag('--amend'),
346
380
  force: hasFlag('--force'),
347
- files: args.filter((a, i) => a !== '--type' && a !== '--message' && !a.startsWith('--') && args[i - 1] !== '--type' && args[i - 1] !== '--message'),
381
+ files: commitArgs.filter((a, i) => !a.startsWith('--') && commitArgs[i - 1] !== '--type' && commitArgs[i - 1] !== '--message'),
348
382
  }, raw);
383
+ }
349
384
  case 'branch':
350
385
  return cmdGitBranch(cwd, sub2, {
351
386
  name: getOpt('--name', null),