pan-wizard 3.32.0 → 3.33.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 (62) hide show
  1. package/agents/pan-conductor.md +2 -2
  2. package/agents/pan-document_code.md +2 -2
  3. package/agents/pan-executor.md +3 -3
  4. package/agents/pan-experiment-runner.md +7 -10
  5. package/agents/pan-hardener.md +15 -15
  6. package/agents/pan-knowledge.md +1 -1
  7. package/agents/pan-meta-reviewer.md +2 -2
  8. package/agents/pan-optimizer.md +2 -2
  9. package/agents/pan-phase-researcher.md +2 -2
  10. package/agents/pan-plan-checker.md +6 -2
  11. package/agents/pan-planner.md +23 -10
  12. package/agents/pan-project-researcher.md +10 -67
  13. package/agents/pan-roadmapper.md +5 -48
  14. package/agents/pan-verifier.md +26 -11
  15. package/bin/install-lib.cjs +170 -15
  16. package/bin/install.js +39 -14
  17. package/commands/pan/army.md +2 -2
  18. package/commands/pan/experiment.md +2 -2
  19. package/commands/pan/focus-auto.md +12 -8
  20. package/commands/pan/focus-design.md +2 -0
  21. package/commands/pan/focus-exec.md +1 -1
  22. package/commands/pan/focus-scan.md +2 -7
  23. package/commands/pan/map-codebase.md +4 -4
  24. package/commands/pan/mcp-bridge.md +1 -1
  25. package/commands/pan/milestone-done.md +1 -2
  26. package/commands/pan/phase-tests.md +2 -2
  27. package/commands/pan/profile.md +2 -2
  28. package/commands/pan/research-phase.md +2 -4
  29. package/commands/pan/review-deep.md +3 -3
  30. package/commands/pan/todo-add.md +2 -2
  31. package/commands/pan/todo-check.md +2 -2
  32. package/hooks/dist/pan-state-reinject.js +140 -15
  33. package/package.json +1 -1
  34. package/pan-wizard-core/bin/lib/agents-md.cjs +4 -1
  35. package/pan-wizard-core/bin/lib/commands.cjs +93 -1
  36. package/pan-wizard-core/bin/lib/config.cjs +110 -0
  37. package/pan-wizard-core/bin/lib/constants.cjs +11 -9
  38. package/pan-wizard-core/bin/lib/core.cjs +27 -7
  39. package/pan-wizard-core/bin/lib/cost.cjs +13 -3
  40. package/pan-wizard-core/bin/lib/findings.cjs +6 -2
  41. package/pan-wizard-core/bin/lib/init.cjs +5 -1
  42. package/pan-wizard-core/bin/lib/phase.cjs +52 -2
  43. package/pan-wizard-core/bin/lib/review-deep.cjs +2 -2
  44. package/pan-wizard-core/bin/lib/state.cjs +3 -2
  45. package/pan-wizard-core/bin/lib/verdict.cjs +54 -4
  46. package/pan-wizard-core/bin/lib/verify-drift.cjs +14 -5
  47. package/pan-wizard-core/bin/lib/verify-scope.cjs +69 -2
  48. package/pan-wizard-core/bin/lib/verify.cjs +87 -7
  49. package/pan-wizard-core/bin/pan-tools.cjs +10 -2
  50. package/pan-wizard-core/mcp/server.cjs +18 -9
  51. package/pan-wizard-core/references/checkpoints.md +4 -2
  52. package/pan-wizard-core/references/verdict-contract.md +2 -1
  53. package/pan-wizard-core/templates/discovery.md +1 -1
  54. package/pan-wizard-core/templates/phase-prompt.md +2 -1
  55. package/pan-wizard-core/templates/verification-report.md +5 -0
  56. package/pan-wizard-core/workflows/exec-phase.md +8 -3
  57. package/pan-wizard-core/workflows/health.md +1 -0
  58. package/pan-wizard-core/workflows/new-project.md +1 -1
  59. package/pan-wizard-core/workflows/quick.md +6 -6
  60. package/pan-wizard-core/workflows/verify-phase.md +7 -2
  61. package/scripts/build-agent-plugin.js +19 -5
  62. package/scripts/test-surface.cjs +4 -0
@@ -103,6 +103,102 @@ function buildConfigDefaults(hasBraveSearch, userDefaults) {
103
103
  };
104
104
  }
105
105
 
106
+ // What PAN reads beyond the keys buildConfigDefaults writes (market-ideas M26). The
107
+ // rest of the known set is derived from the defaults, so a new default is known the
108
+ // day it is added. Each entry has a reader: loadConfig's flat aliases and alias
109
+ // sections (`planning.*`, `git.*`), the stop guard (`mode`, `workflow.stop_guard`),
110
+ // the roadmapper and new-project (`depth`), memory-optimize (`memory`), and the
111
+ // workflows that config-get `workflow.auto_advance`. tests/config-keys in
112
+ // tests/config.test.cjs pins this list against the keys shipped code and prose use.
113
+ const CONFIG_KEYS_BEYOND_DEFAULTS = Object.freeze({
114
+ top: Object.freeze(['research', 'plan_checker', 'verifier', 'nyquist_validation', 'mode', 'depth', 'planning', 'git',
115
+ 'focus', 'model_overrides', 'effort_overrides', 'cost', 'cache', 'build', 'verification', 'concurrency', 'memory']),
116
+ sections: Object.freeze({
117
+ workflow: Object.freeze(['auto_advance', 'stop_guard']),
118
+ planning: Object.freeze(['commit_docs', 'search_gitignored']),
119
+ git: Object.freeze(['branching_strategy', 'phase_branch_template', 'milestone_branch_template']),
120
+ }),
121
+ });
122
+ // Sections whose keys are the user's own (model ids, agent names, file lists,
123
+ // commands), so nothing below them is checked.
124
+ const OPEN_CONFIG_SECTIONS = Object.freeze(['focus', 'model_overrides', 'effort_overrides', 'cost', 'cache', 'build', 'verification', 'concurrency', 'memory']);
125
+
126
+ /** The known top-level keys, and the known keys one level inside each closed section. */
127
+ function knownConfigKeys() {
128
+ const defaults = buildConfigDefaults(false, {});
129
+ const top = new Set([...Object.keys(defaults), ...CONFIG_KEYS_BEYOND_DEFAULTS.top]);
130
+ const sections = new Map();
131
+ for (const [k, v] of Object.entries(defaults)) {
132
+ if (v && typeof v === 'object' && !Array.isArray(v)) sections.set(k, new Set(Object.keys(v)));
133
+ }
134
+ for (const [k, list] of Object.entries(CONFIG_KEYS_BEYOND_DEFAULTS.sections)) {
135
+ sections.set(k, new Set([...(sections.get(k) || []), ...list]));
136
+ }
137
+ for (const k of OPEN_CONFIG_SECTIONS) sections.delete(k);
138
+ return { top, sections };
139
+ }
140
+
141
+ /**
142
+ * Whether PAN reads a dot-notation key path: the top-level key is known, and inside a
143
+ * closed section so is the second segment. Deeper segments are not checked.
144
+ * @param {string} keyPath
145
+ * @returns {boolean}
146
+ */
147
+ function isKnownConfigPath(keyPath) {
148
+ const [head, sub] = String(keyPath).split('.');
149
+ const { top, sections } = knownConfigKeys();
150
+ if (!top.has(head)) return false;
151
+ return sub === undefined || !sections.has(head) || sections.get(head).has(sub);
152
+ }
153
+
154
+ /**
155
+ * The key paths in a parsed config.json that PAN does not read (market-ideas M26):
156
+ * unknown top-level keys, and unknown keys one level inside a closed section.
157
+ * @param {Object} config
158
+ * @returns {string[]}
159
+ */
160
+ function unknownConfigKeys(config) {
161
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return [];
162
+ const { top, sections } = knownConfigKeys();
163
+ const unknown = [];
164
+ for (const [k, v] of Object.entries(config)) {
165
+ if (!top.has(k)) { unknown.push(k); continue; }
166
+ const known = sections.get(k);
167
+ if (!known || !v || typeof v !== 'object' || Array.isArray(v)) continue;
168
+ for (const sub of Object.keys(v)) if (!known.has(sub)) unknown.push(`${k}.${sub}`);
169
+ }
170
+ return unknown;
171
+ }
172
+
173
+ /** The known key path nearest an unknown one (edit distance 2 or less), or null. */
174
+ function nearestConfigKey(keyPath) {
175
+ const { top, sections } = knownConfigKeys();
176
+ const candidates = [...top];
177
+ for (const [k, subs] of sections) for (const s of subs) candidates.push(`${k}.${s}`);
178
+ const want = String(keyPath).split('.').slice(0, 2).join('.');
179
+ let best = null;
180
+ let bestD = 3;
181
+ for (const c of candidates) {
182
+ const d = editDistance(want, c);
183
+ if (d < bestD) { best = c; bestD = d; }
184
+ }
185
+ return best;
186
+ }
187
+
188
+ function editDistance(a, b) {
189
+ const row = Array.from({ length: b.length + 1 }, (_, j) => j);
190
+ for (let i = 1; i <= a.length; i++) {
191
+ let prev = row[0];
192
+ row[0] = i;
193
+ for (let j = 1; j <= b.length; j++) {
194
+ const tmp = row[j];
195
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
196
+ prev = tmp;
197
+ }
198
+ }
199
+ return row[b.length];
200
+ }
201
+
106
202
  function cmdConfigEnsureSection(cwd, raw) {
107
203
  const configPath = path.join(planningPath(cwd), CONFIG_FILE);
108
204
 
@@ -216,6 +312,14 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
216
312
  try {
217
313
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
218
314
  const result = { updated: true, key: keyPath, value: parsedValue };
315
+ // A key PAN does not read is still written (a newer PAN, or the user's own
316
+ // note, may want it), but never silently: a typo used to look like a setting
317
+ // that took effect (market-ideas M26).
318
+ if (!isKnownConfigPath(keyPath)) {
319
+ const near = nearestConfigKey(keyPath);
320
+ result.warning = `unknown config key "${keyPath}": PAN does not read it` + (near ? `; did you mean "${near}"?` : '');
321
+ process.stderr.write(`pan-tools: ${result.warning}\n`);
322
+ }
219
323
  output(result, raw, `${keyPath}=${parsedValue}`);
220
324
  } catch (err) {
221
325
  // Config file could not be written — disk full, permissions, etc.
@@ -664,6 +768,12 @@ function cmdStandardsTools(cwd, standardId, raw) {
664
768
 
665
769
  module.exports = {
666
770
  buildConfigDefaults,
771
+ CONFIG_KEYS_BEYOND_DEFAULTS,
772
+ OPEN_CONFIG_SECTIONS,
773
+ knownConfigKeys,
774
+ isKnownConfigPath,
775
+ unknownConfigKeys,
776
+ nearestConfigKey,
667
777
  cmdConfigEnsureSection,
668
778
  cmdConfigSet,
669
779
  cmdConfigGet,
@@ -207,16 +207,18 @@ const STANDARDS_CATALOG = {
207
207
  level: 'foundational',
208
208
  url: 'https://owasp.org/www-project-top-ten/',
209
209
  checklist: [
210
- 'A01: Broken Access Control — verify authorization checks on all endpoints',
211
- 'A02: Cryptographic Failures — verify sensitive data encryption at rest and in transit',
212
- 'A03: Injection — verify input validation and parameterized queries',
213
- 'A04: Insecure Design — verify threat modeling and secure design patterns',
214
- 'A05: Security Misconfiguration — verify default credentials removed, headers set',
215
- 'A06: Vulnerable Components — verify dependency scanning, no known CVEs',
210
+ // The 2025 list (top10.owasp.org): until 2026-10-03 this held the 2021 list
211
+ // under the 2025 name (prompt audit, market-ideas M34). SSRF is now part of A01.
212
+ 'A01: Broken Access Control — verify authorization checks on all endpoints, and allowlists for server-side requests (SSRF)',
213
+ 'A02: Security Misconfiguration — verify default credentials removed, headers set',
214
+ 'A03: Software Supply Chain Failures — verify dependency scanning, no known CVEs, pinned and verified packages and build tooling',
215
+ 'A04: Cryptographic Failures — verify sensitive data encryption at rest and in transit',
216
+ 'A05: Injection — verify input validation and parameterized queries',
217
+ 'A06: Insecure Design — verify threat modeling and secure design patterns',
216
218
  'A07: Authentication Failures — verify MFA support, session management, credential storage',
217
- 'A08: Software and Data Integrity — verify CI/CD pipeline integrity, signed updates',
218
- 'A09: Logging and Monitoring — verify security events logged, alerts configured',
219
- 'A10: SSRF — verify server-side request validation, allowlists',
219
+ 'A08: Software or Data Integrity Failures — verify CI/CD pipeline integrity, signed updates',
220
+ 'A09: Security Logging and Alerting Failures — verify security events logged, alerts configured',
221
+ 'A10: Mishandling of Exceptional Conditions — verify errors fail closed, are handled, and release resources',
220
222
  ],
221
223
  },
222
224
  'owasp-asvs-l1': {
@@ -39,8 +39,13 @@ const { planningPath, planningRel } = require('./utils.cjs');
39
39
  // budget-profile project on Codex or OpenCode was told to spawn a model called
40
40
  // "mid" (reality check R28).
41
41
  // - openai: Codex's subagent docs (learn.chatgpt.com/docs/agent-configuration/
42
- // subagents, read 2026-09-23): "start with gpt-6-sol. Use gpt-6-luna when you
43
- // want a faster, lower-cost option". gpt-6-astra is the flagship above both.
42
+ // subagents, re-read 2026-10-03) now start demanding agents on gpt-6.1-sol "when
43
+ // your signed-in account or workspace has access", and keep gpt-6-luna as the
44
+ // "faster, lower-cost option". The mid tier stays gpt-6-sol: GPT-6.1 Sol's launch
45
+ // keeps it off on Enterprise and Edu until an administrator enables it
46
+ // (learn.chatgpt.com/docs/models, read 2026-10-03), so pinning it would hand
47
+ // those users a model they cannot run. The inherit tier runs Codex's own
48
+ // default, which is gpt-6.1-sol where available. gpt-6-astra is the flagship.
44
49
  // - google: the newest stable Flash and Flash-Lite on ai.google.dev's models page
45
50
  // (read 2026-09-23). The 2.5 family these rows used is "not deprecated" but
46
51
  // limited to users who have used it before, so a new project may not reach it.
@@ -58,12 +63,14 @@ const PROVIDER_MODELS = {
58
63
  // packages/web/src/content/docs/models.mdx and models.dev/api.json, both read
59
64
  // 2026-09-26; every id below exists there under that provider). Same tiers as
60
65
  // PROVIDER_MODELS, provider-qualified; `default` is Anthropic as it is there.
61
- // Reality check R41.
66
+ // Reality check R41. The Anthropic mid tier follows the `sonnet` alias, which
67
+ // resolves to Sonnet 5.5 on the Anthropic API from Claude Code 2.1.284;
68
+ // models.dev lists `anthropic/claude-sonnet-5-5` (api.json, read 2026-10-03).
62
69
  const OPENCODE_MODELS = {
63
- anthropic: { reasoning: 'inherit', mid: 'anthropic/claude-sonnet-5', fast: 'anthropic/claude-haiku-4-5' },
64
- openai: { reasoning: 'inherit', mid: 'openai/gpt-6-sol', fast: 'openai/gpt-6-luna' },
65
- google: { reasoning: 'inherit', mid: 'google/gemini-3.8-flash', fast: 'google/gemini-3.5-flash-lite' },
66
- default: { reasoning: 'inherit', mid: 'anthropic/claude-sonnet-5', fast: 'anthropic/claude-haiku-4-5' },
70
+ anthropic: { reasoning: 'inherit', mid: 'anthropic/claude-sonnet-5-5', fast: 'anthropic/claude-haiku-4-5' },
71
+ openai: { reasoning: 'inherit', mid: 'openai/gpt-6-sol', fast: 'openai/gpt-6-luna' },
72
+ google: { reasoning: 'inherit', mid: 'google/gemini-3.8-flash', fast: 'google/gemini-3.5-flash-lite' },
73
+ default: { reasoning: 'inherit', mid: 'anthropic/claude-sonnet-5-5', fast: 'anthropic/claude-haiku-4-5' },
67
74
  };
68
75
 
69
76
  /**
@@ -552,6 +559,18 @@ function escapeRegex(value) {
552
559
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
553
560
  }
554
561
 
562
+ /**
563
+ * Make free text safe for one markdown table cell: escape backslashes and pipes,
564
+ * and fold every run of line breaks into one space. A raw `|` splits the row into
565
+ * an extra column, and state.md's tables are appended to for the life of a
566
+ * project, so one bad row breaks every later read of the table (market-ideas M21).
567
+ * @param {*} value - Cell text; null and undefined become ''
568
+ * @returns {string}
569
+ */
570
+ function escapeTableCell(value) {
571
+ return String(value ?? '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' ');
572
+ }
573
+
555
574
  /**
556
575
  * Normalize a phase identifier to zero-padded format.
557
576
  * Examples: "1" → "01", "3A" → "03A", "12.1" → "12.1"
@@ -1454,6 +1473,7 @@ module.exports = {
1454
1473
  isGitRepo,
1455
1474
  execGit,
1456
1475
  escapeRegex,
1476
+ escapeTableCell,
1457
1477
  normalizePhaseName,
1458
1478
  comparePhaseNum,
1459
1479
  searchPhaseInDir,
@@ -91,6 +91,12 @@ const DEFAULT_RATES = {
91
91
  // 2026-08-31 was made permanent and the scheduled rise to $3/$15 cancelled.
92
92
  // Lesson: never write down a pre-announced price — this row carried the future
93
93
  // rate for a month and over-billed by half.
94
+ // Sonnet 5.5 (claude-sonnet-5-5): the same prices as Sonnet 5 — $2/$10, 5-minute
95
+ // writes $2.50, 1-hour $4, hits $0.20 (pricing page, read 2026-10-03). Claude Code
96
+ // 2.1.284 points the `sonnet` alias at it on the Anthropic API (model-config, read
97
+ // 2026-10-03). The family prefix already priced it at Sonnet 5's identical row; its
98
+ // own row means a later price change to either model cannot misprice the other.
99
+ 'claude-sonnet-5-5': { input: 2.0, output: 10.0, cache_read: 0.20, cache_write: 2.50, cache_write_1h: 4.0 },
94
100
  'claude-sonnet-5': { input: 2.0, output: 10.0, cache_read: 0.20, cache_write: 2.50, cache_write_1h: 4.0 },
95
101
  'claude-sonnet-4-6': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75, cache_write_1h: 6.0 },
96
102
  // Sonnet 4.5 (dated id claude-sonnet-4-5-20250929): $3/$15/$0.30/$3.75 (R7).
@@ -105,10 +111,14 @@ const DEFAULT_RATES = {
105
111
  // as plain input. Until 2026-09-22 this block said "no separate write charge" for
106
112
  // every row, which had stopped being true — Terra and Luna writes were a fifth
107
113
  // low (R27). Longest-prefix match lets each tier key win over a shorter one.
108
- // GPT-6: Astra is the flagship; Sol and Luna were released 2026-09-22. Codex's
109
- // config docs use gpt-6-sol as the default model and gpt-6-luna for lower-cost
110
- // subagents (learn.chatgpt.com/docs/config-file/config-basic, /agent-configuration/subagents).
114
+ // GPT-6: Astra is the flagship; Sol and Luna were released 2026-09-22. GPT-6.1 Sol
115
+ // is Codex's default model from 0.159.1 (2026-09-29): `model = "gpt-6.1-sol"` in
116
+ // config-basic, and the subagent docs' starting model for demanding agents, with
117
+ // gpt-6-luna for lower-cost ones (learn.chatgpt.com/docs/config-file/config-basic,
118
+ // /agent-configuration/subagents, both read 2026-10-03). Its row was read from the
119
+ // pricing page on 2026-10-03: GPT-6 Sol's prices, except cached input at 0.05×.
111
120
  'gpt-6-astra': { input: 10.0, output: 50.0, cache_read: 1.0, cache_write: 12.5 },
121
+ 'gpt-6.1-sol': { input: 2.0, output: 10.0, cache_read: 0.1, cache_write: 2.5 },
112
122
  'gpt-6-sol': { input: 2.0, output: 10.0, cache_read: 0.2, cache_write: 2.5 },
113
123
  'gpt-6-luna': { input: 0.10, output: 0.50, cache_read: 0.01, cache_write: 0.125 },
114
124
  // GPT-5.6. The bare `gpt-5.6` id is an alias that "routes requests to GPT-5.6 Sol"
@@ -160,7 +160,10 @@ function recordVerdict(cwd, input) {
160
160
 
161
161
  const { rows } = readLedger(cwd);
162
162
  const fold = foldFindings(rows);
163
- const base = { contract: V.VERDICT_CONTRACT, agent, verdict: verdict.verdict, outcome: verdict.outcome, source_kind: parsed.source_kind, warnings: parsed.warnings };
163
+ // `not_checked` (M23): what the judge could not run. A pass with entries here is a
164
+ // pass over less than the whole phase, and the record says so.
165
+ const notChecked = verdict.not_checked || [];
166
+ const base = { contract: V.VERDICT_CONTRACT, agent, verdict: verdict.verdict, outcome: verdict.outcome, not_checked: notChecked, source_kind: parsed.source_kind, warnings: parsed.warnings };
164
167
 
165
168
  const dup = fold.verdicts.find((r) => r.record_sig === recordSig && r.agent === agent && samePhase(r.phase, phase));
166
169
  if (dup) {
@@ -204,6 +207,7 @@ function recordVerdict(cwd, input) {
204
207
  v: LEDGER_V, kind: 'verdict', ts, verdict_id: verdictId, record_sig: recordSig, phase, milestone, agent,
205
208
  verdict: verdict.verdict, outcome: verdict.outcome, attempt, source, finding_ids: ids,
206
209
  by_severity: bySeverity, by_class: byClass, warnings: parsed.warnings,
210
+ ...(notChecked.length ? { not_checked: notChecked } : {}),
207
211
  });
208
212
  appendRows(cwd, [...findingRows, ...newRows, ...autoFixRows]);
209
213
 
@@ -216,7 +220,7 @@ function recordVerdict(cwd, input) {
216
220
  phase,
217
221
  description: `${agent} ${verdict.verdict || verdict.outcome} on phase ${phase} (attempt ${attempt}, ${ids.length} finding${ids.length === 1 ? '' : 's'})`,
218
222
  impact: verdict.outcome === 'fail' ? (serious ? 'major' : 'minor') : 'minor',
219
- context: { agent, phase, verdict: verdict.verdict, attempt, findings: ids.length, by_severity: bySeverity, by_class: byClass, verdict_id: verdictId },
223
+ context: { agent, phase, verdict: verdict.verdict, attempt, findings: ids.length, by_severity: bySeverity, by_class: byClass, verdict_id: verdictId, not_checked: notChecked.map((n) => n.check) },
220
224
  });
221
225
  if (previous && previous.outcome === 'fail') {
222
226
  traceEvent(cwd, {
@@ -4,7 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, toPosix, output, error, scanPendingTodos, isGitRepo, execGit } = require('./core.cjs');
7
+ const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, toPosix, output, error, scanPendingTodos, isGitRepo, execGit, escapeTableCell } = require('./core.cjs');
8
8
  const { PHASES_DIR, CODEBASE_DIR, QUICK_DIR, MILESTONES_DIR, STATE_FILE, ROADMAP_FILE, CONFIG_FILE, PROJECT_FILE, REQUIREMENTS_FILE, isPlanFile, isSummaryFile, isResearchFile, isContextFile, isVerificationFile, PLAN_SUFFIX, SUMMARY_SUFFIX, CONTEXT_SUFFIX, RESEARCH_SUFFIX, VERIFICATION_SUFFIX, UAT_SUFFIX, MAX_SLUG_LENGTH } = require('./constants.cjs');
9
9
  const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, classifyPhaseStatus, hasBraveSearchKey, parsePhaseDir, planningRel } = require('./utils.cjs');
10
10
  const { classifyPlanTier } = require('./phase.cjs');
@@ -445,6 +445,10 @@ function cmdInitQuick(cwd, description, raw) {
445
445
  next_num: nextNum,
446
446
  slug: slug,
447
447
  description: description || null,
448
+ // The description as one markdown table cell (pipes escaped, line breaks
449
+ // folded). The workflow writes this, never the raw text, into state.md's
450
+ // "Quick Tasks Completed" table, where a raw `|` would break the table.
451
+ description_cell: description ? escapeTableCell(description) : null,
448
452
 
449
453
  // Timestamps
450
454
  date: now.toISOString().split('T')[0],
@@ -239,7 +239,7 @@ function cmdFindPhase(cwd, phase, raw) {
239
239
  * @param {boolean} raw - If true, output raw value instead of JSON
240
240
  * @returns {void}
241
241
  */
242
- function cmdPhasePlanIndex(cwd, phase, raw) {
242
+ function cmdPhasePlanIndex(cwd, phase, raw, opts = {}) {
243
243
  if (!phase) {
244
244
  error('phase required for phase-plan-index');
245
245
  }
@@ -280,7 +280,52 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
280
280
 
281
281
  const { plans, waves, incomplete, hasCheckpoints } = buildPlanIndex(phaseDir, planFiles, summaryFiles);
282
282
 
283
- output({ phase: normalized, plans, waves, incomplete, has_checkpoints: hasCheckpoints }, raw);
283
+ const result = { phase: normalized, plans, waves, incomplete, has_checkpoints: hasCheckpoints };
284
+ // `--failed <ids>` (market-ideas M28): the plans that must not run after those
285
+ // failed, because a dependency, direct or transitive, is among them.
286
+ if (opts.failed) {
287
+ const failed = String(opts.failed).split(',').map((s) => s.trim()).filter(Boolean);
288
+ result.failed = failed;
289
+ result.blocked = blockedPlans(plans, failed);
290
+ }
291
+ output(result, raw);
292
+ }
293
+
294
+ /** Whether a `depends_on` entry names this plan: its full id ("03-02") or its plan number ("02", "2"). */
295
+ function dependencyNames(dep, planId) {
296
+ const d = String(dep).trim();
297
+ if (d === planId) return true;
298
+ const own = planId.split('-').pop();
299
+ const strip = (s) => s.replace(/^0+(?=\d)/, '');
300
+ return /^\d+$/.test(d) && /^\d+$/.test(own) && strip(d) === strip(own);
301
+ }
302
+
303
+ /**
304
+ * The plans that must not run because a plan they depend on failed (market-ideas
305
+ * M28). Transitive: a plan waiting on a blocked plan is blocked too. Plans that
306
+ * already have a summary are done and never blocked.
307
+ * @param {Array<{id: string, depends_on: string[], has_summary: boolean}>} plans
308
+ * @param {string[]} failedIds
309
+ * @returns {Array<{id: string, blocked_by: string[]}>} in plan order; blocked_by names the failed plans
310
+ */
311
+ function blockedPlans(plans, failedIds) {
312
+ const failed = new Set(failedIds);
313
+ const blockedBy = new Map(); // plan id → Set of failed ids behind it
314
+ let changed = true;
315
+ while (changed) {
316
+ changed = false;
317
+ for (const p of plans) {
318
+ if (p.has_summary || failed.has(p.id)) continue;
319
+ const roots = blockedBy.get(p.id) || new Set();
320
+ const before = roots.size;
321
+ for (const dep of p.depends_on || []) {
322
+ for (const f of failed) if (dependencyNames(dep, f)) roots.add(f);
323
+ for (const [b, bRoots] of blockedBy) if (dependencyNames(dep, b)) for (const r of bRoots) roots.add(r);
324
+ }
325
+ if (roots.size > before) { blockedBy.set(p.id, roots); changed = true; }
326
+ }
327
+ }
328
+ return plans.filter((p) => blockedBy.has(p.id)).map((p) => ({ id: p.id, blocked_by: [...blockedBy.get(p.id)].sort() }));
284
329
  }
285
330
 
286
331
  /**
@@ -339,10 +384,14 @@ function buildPlanIndex(phaseDir, planFiles, summaryFiles) {
339
384
  const hasSummary = completedPlanIds.has(planId);
340
385
  if (!hasSummary) incomplete.push(planId);
341
386
 
387
+ const rawDeps = frontmatter.depends_on || frontmatter['depends-on'];
388
+ const dependsOn = (Array.isArray(rawDeps) ? rawDeps : (rawDeps ? [rawDeps] : [])).map(String).filter((d) => d.trim());
389
+
342
390
  plans.push({
343
391
  id: planId, wave, autonomous,
344
392
  objective,
345
393
  files_modified: filesModified,
394
+ depends_on: dependsOn,
346
395
  task_count: taskCount,
347
396
  has_summary: hasSummary,
348
397
  });
@@ -930,6 +979,7 @@ module.exports = {
930
979
  cmdPhaseNextDecimal,
931
980
  cmdFindPhase,
932
981
  cmdPhasePlanIndex,
982
+ blockedPlans,
933
983
  cmdPhaseAdd,
934
984
  cmdPhaseInsert,
935
985
  cmdPhaseRemove,
@@ -19,7 +19,7 @@
19
19
 
20
20
  const fs = require('fs');
21
21
  const path = require('path');
22
- const { output, error, safeReadFile, toPosix } = require('./core.cjs');
22
+ const { output, error, safeReadFile, toPosix, escapeTableCell } = require('./core.cjs');
23
23
  const { planningPath } = require('./utils.cjs');
24
24
  const { publish } = require('./bus.cjs');
25
25
  const { findVerdictBlocks, validateVerdict } = require('./verdict.cjs');
@@ -235,7 +235,7 @@ function writeDeepReview(cwd, phaseNum, payload, opts) {
235
235
  const loc = f.file ? `\`${f.file}${f.line ? `:${f.line}` : ''}\`` : '—';
236
236
  // Neutralize markdown-table-breaking chars: escape pipes and flatten any
237
237
  // newlines so a finding description can't corrupt the table structure.
238
- const desc = String(f.description).replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' ');
238
+ const desc = escapeTableCell(f.description);
239
239
  lines.push(`| ${f.severity} | ${f.source} | ${f.category} | ${desc} | ${loc} |`);
240
240
  }
241
241
  lines.push('');
@@ -4,7 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { loadConfig, getMilestoneInfo, escapeRegex, safeReadFile, output, error } = require('./core.cjs');
7
+ const { loadConfig, getMilestoneInfo, escapeRegex, escapeTableCell, safeReadFile, output, error } = require('./core.cjs');
8
8
  const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
9
9
  const { withFileLock, writeFileAtomic } = require('./lock.cjs');
10
10
  const {
@@ -300,7 +300,8 @@ function cmdStateRecordMetric(cwd, options, raw) {
300
300
 
301
301
  if (metricsMatch) {
302
302
  let tableBody = metricsMatch[2].trimEnd();
303
- const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`;
303
+ const cell = escapeTableCell;
304
+ const newRow = `| Phase ${cell(phase)} P${cell(plan)} | ${cell(duration)} | ${cell(tasks || '-')} tasks | ${cell(files || '-')} files |`;
304
305
 
305
306
  if (tableBody.trim() === '' || tableBody.includes('None yet')) {
306
307
  tableBody = newRow;
@@ -40,7 +40,9 @@ const KNOWN_VERDICTS = Object.freeze({
40
40
  'pan-design-checker': Object.freeze({ PASS: 'pass', GAPS: 'fail' }),
41
41
  });
42
42
 
43
- const LIMITS = Object.freeze({ findings: 100, where: 200, summary: 300, verdict: 40, score: 40 });
43
+ const LIMITS = Object.freeze({ findings: 100, where: 200, summary: 300, verdict: 40, score: 40, not_checked: 20, check: 80 });
44
+ // What the verifier's test gate can report (verify-phase.md, run_test_suite).
45
+ const TEST_GATE_VALUES = Object.freeze(['passed', 'failed', 'skipped']);
44
46
  const AGENT_RE = /^[a-z][a-z0-9-]{0,63}$/;
45
47
  const OPEN_FENCE_RE = /^[ \t]*(`{3,}|~{3,})[ \t]*pan-verdict[ \t]*$/;
46
48
  const CLOSE_FENCE_RE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/;
@@ -195,7 +197,32 @@ function validateVerdict(obj, opts = {}) {
195
197
  findings.push({ class: cls, severity, where, summary });
196
198
  });
197
199
 
198
- return { ok: true, verdict: { contract, agent, outcome, verdict, phase, score, findings }, warnings };
200
+ const notChecked = normaliseNotChecked(obj.not_checked, warnings);
201
+ return { ok: true, verdict: { contract, agent, outcome, verdict, phase, score, findings, not_checked: notChecked }, warnings };
202
+ }
203
+
204
+ /**
205
+ * The checks a judge could not run, each `{check, reason}` (market-ideas M23).
206
+ * A verdict that carries them says what it did not see: a `pass` with tests that
207
+ * never ran is recorded as exactly that, never as a plain pass. Accepts bare
208
+ * strings as checks with no reason; drops entries with no check.
209
+ */
210
+ function normaliseNotChecked(value, warnings) {
211
+ if (value == null) return [];
212
+ if (!Array.isArray(value)) {
213
+ warnings.push('not_checked_not_an_array');
214
+ return [];
215
+ }
216
+ const out = [];
217
+ for (const entry of value.slice(0, LIMITS.not_checked)) {
218
+ const obj = typeof entry === 'string' ? { check: entry } : (entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : null);
219
+ const check = obj ? truncate(cleanText(obj.check ?? obj.gate ?? ''), LIMITS.check) : '';
220
+ if (!check) continue;
221
+ const reason = obj.reason == null || cleanText(obj.reason) === '' ? null : truncate(cleanText(obj.reason), LIMITS.summary);
222
+ out.push({ check, reason });
223
+ }
224
+ if (value.length > LIMITS.not_checked) warnings.push(`not_checked_truncated: ${value.length} reported, ${LIMITS.not_checked} kept`);
225
+ return out;
199
226
  }
200
227
 
201
228
  // ─── verification.md frontmatter adapter ─────────────────────────────────────
@@ -354,7 +381,23 @@ function verdictFromVerificationFrontmatter(text, agent = 'pan-verifier') {
354
381
  const summary = what || (path ? `unrequested change to ${path}` : '');
355
382
  if (summary) findings.push({ class: 'unrequested', severity: 'low', where: path, summary });
356
383
  }
357
- return { contract: VERDICT_CONTRACT, agent, verdict: status, outcome, findings };
384
+ // The test gate (M23). `not_checked` lists what the verifier could not run; a
385
+ // `test_gate: skipped` with no tests entry there still counts as one, so a model
386
+ // that records the gate but forgets the list cannot turn it into a plain pass.
387
+ let notChecked = listOfMappings(yaml, 'not_checked').map((n) => ({ check: n.check || n.gate || '', reason: n.reason || null }));
388
+ const testGateRaw = frontmatterScalar(yaml, 'test_gate');
389
+ const testGate = testGateRaw ? String(testGateRaw).toLowerCase() : null;
390
+ const isTests = (n) => /test/i.test(String(n.check));
391
+ let contradicted = false;
392
+ if (testGate === 'skipped' && !notChecked.some(isTests)) {
393
+ notChecked.push({ check: 'tests', reason: frontmatterScalar(yaml, 'test_gate_reason') || 'the test gate was skipped' });
394
+ } else if ((testGate === 'passed' || testGate === 'failed') && notChecked.some(isTests)) {
395
+ // The gate ran, so a `tests` entry is the template's example copied over: the
396
+ // recorded gate is the measurement, and the entry goes.
397
+ notChecked = notChecked.filter((n) => !isTests(n));
398
+ contradicted = true;
399
+ }
400
+ return { contract: VERDICT_CONTRACT, agent, verdict: status, outcome, findings, not_checked: notChecked, test_gate: testGate, not_checked_contradicted: contradicted };
358
401
  }
359
402
 
360
403
  /**
@@ -383,7 +426,14 @@ function parseVerdictText(text, opts = {}) {
383
426
  return { ok: false, error: 'unknown_status', reason: `verification status "${adapted.verdict}" is not passed, gaps_found or human_needed` };
384
427
  }
385
428
  const r = validateVerdict(adapted, opts);
386
- return r.ok ? { ...r, source_kind: 'frontmatter' } : r;
429
+ if (!r.ok) return r;
430
+ // Say when the verification does not record its test gate, or records a failed
431
+ // gate under a pass: both make "passed" claim more than the verifier checked.
432
+ if (!adapted.test_gate) r.warnings.push('test_gate_unrecorded: the verification does not say whether the test suite ran');
433
+ else if (!TEST_GATE_VALUES.includes(adapted.test_gate)) r.warnings.push(`test_gate_unknown: "${adapted.test_gate}" is not passed, failed or skipped`);
434
+ else if (adapted.test_gate === 'failed' && adapted.outcome === 'pass') r.warnings.push('test_gate_failed_under_pass: the test gate failed but the status is passed');
435
+ if (adapted.not_checked_contradicted) r.warnings.push(`not_checked_contradicted: test_gate is ${adapted.test_gate}, so the not_checked tests entry was dropped`);
436
+ return { ...r, source_kind: 'frontmatter' };
387
437
  }
388
438
  return { ok: false, error: 'no_verdict', reason: 'no pan-verdict block and no verification frontmatter status' };
389
439
  }
@@ -13,14 +13,24 @@ const {
13
13
  const { planningPath } = require('./utils.cjs');
14
14
 
15
15
  /**
16
- * Parse convention rules from CONVENTIONS.md markdown content.
16
+ * The codebase map's conventions document. map-codebase writes `conventions.md`;
17
+ * a map from an older PAN, or one written by hand, may be `CONVENTIONS.md`. Only a
18
+ * case-sensitive filesystem tells the two apart, which is why reading the uppercase
19
+ * name alone found nothing on Linux (prompt audit, market-ideas M34).
20
+ */
21
+ function readConventions(cwd) {
22
+ const dir = path.join(planningPath(cwd), 'codebase');
23
+ return safeReadFile(path.join(dir, 'conventions.md')) || safeReadFile(path.join(dir, 'CONVENTIONS.md'));
24
+ }
25
+
26
+ /**
27
+ * Parse convention rules from the conventions document's markdown content.
17
28
  * Extracts anti-pattern rules from prose containing "instead of", "not", "never".
18
29
  * Run drift check internally and return result object (no output).
19
30
  * Used by cmdValidateHealth --drift.
20
31
  */
21
32
  function runDriftCheck(cwd) {
22
- const conventionsPath = path.join(planningPath(cwd), 'codebase', 'CONVENTIONS.md');
23
- const conventionsContent = safeReadFile(conventionsPath);
33
+ const conventionsContent = readConventions(cwd);
24
34
  const claudeMdContent = safeReadFile(path.join(cwd, 'CLAUDE.md'));
25
35
  const combined = [conventionsContent, claudeMdContent].filter(Boolean).join('\n');
26
36
  const rules = parseConventionRules(combined || null);
@@ -198,8 +208,7 @@ function cmdDriftCheck(cwd, raw, args) {
198
208
  }
199
209
 
200
210
  // Load convention rules
201
- const conventionsPath = path.join(planningPath(cwd), 'codebase', 'CONVENTIONS.md');
202
- const conventionsContent = safeReadFile(conventionsPath);
211
+ const conventionsContent = readConventions(cwd);
203
212
  const claudeMdContent = safeReadFile(path.join(cwd, 'CLAUDE.md'));
204
213
  const combined = [conventionsContent, claudeMdContent].filter(Boolean).join('\n');
205
214
  const rules = parseConventionRules(combined || null);
@@ -18,7 +18,7 @@ const fs = require('fs');
18
18
  const path = require('path');
19
19
  const { safeReadFile, execGit, findPhaseInternal, toPosix, output, escapeRegex } = require('./core.cjs');
20
20
  const { extractFrontmatter } = require('./frontmatter.cjs');
21
- const { isPlanFile, isSummaryFile } = require('./constants.cjs');
21
+ const { isPlanFile, isSummaryFile, isVerificationFile } = require('./constants.cjs');
22
22
  const { planningRel } = require('./utils.cjs');
23
23
 
24
24
  const LOCKFILES = new Set([
@@ -153,4 +153,71 @@ function cmdVerifyScope(cwd, phase, raw) {
153
153
  output(r, raw, r.error ? undefined : String(r.candidates.length));
154
154
  }
155
155
 
156
- module.exports = { scopePhase, cmdVerifyScope, LOCKFILES };
156
+ // ─── Stale verification (market-ideas M27) ───────────────────────────────────
157
+ // A verification describes the code at one commit. Until 2026-10-03 nothing tied it
158
+ // to that commit, so a phase edited after it passed still read as verified. The
159
+ // verifier now records `verified_commit`; any file the phase covers that changed
160
+ // since then, committed or not, makes the verification stale.
161
+
162
+ const SHA_RE = /^[0-9a-f]{7,40}$/i;
163
+
164
+ /**
165
+ * The files a phase's verification covered: the plans' declared `files_modified` and
166
+ * the summaries' `key-files`, minus the planning tree, lockfiles and PAN's runtime
167
+ * directories. No git log scan, so `progress` can afford it for every phase.
168
+ */
169
+ function coveredFiles(cwd, info) {
170
+ const dir = path.join(cwd, info.directory);
171
+ const files = new Set();
172
+ for (const f of info.plans.filter(isPlanFile)) {
173
+ const fm = extractFrontmatter(safeReadFile(path.join(dir, f)) || '');
174
+ for (const p of asList(fm.files_modified || fm['files-modified'])) files.add(normalisePath(p));
175
+ }
176
+ for (const f of info.summaries.filter(isSummaryFile)) {
177
+ const fm = extractFrontmatter(safeReadFile(path.join(dir, f)) || '');
178
+ const kf = fm['key-files'] || fm.key_files || {};
179
+ for (const p of [...asList(kf.created), ...asList(kf.modified)]) files.add(normalisePath(p));
180
+ }
181
+ const planningPrefix = normalisePath(planningRel());
182
+ return [...files].filter((f) => f && !exclusionReason(f, planningPrefix)).sort();
183
+ }
184
+
185
+ /**
186
+ * Whether a phase's verification still describes the code.
187
+ * @param {string} cwd
188
+ * @param {string} phase
189
+ * @param {Object} [info] - findPhaseInternal's result, when the caller already has it
190
+ * @returns {Object} `{phase, state, verified_commit, verification, changed_since, reason?}` or `{error, reason}`.
191
+ * state: `fresh` · `stale` (covered files changed since the verified commit) ·
192
+ * `unverified` (no verification file) · `unknown` (no usable verified_commit, or no git)
193
+ */
194
+ function verificationFreshness(cwd, phase, info) {
195
+ info = info || findPhaseInternal(cwd, phase);
196
+ if (!info) return { error: 'phase_not_found', reason: `no phase directory for phase ${phase}` };
197
+ const dir = path.join(cwd, info.directory);
198
+ let names = [];
199
+ try { names = fs.readdirSync(dir); } catch { /* unreadable: no verification */ }
200
+ const verification = names.filter(isVerificationFile).sort().pop() || null;
201
+ const base = { phase: info.phase_number, state: 'unverified', verified_commit: null, verification: verification ? toPosix(path.join(info.directory, verification)) : null, changed_since: [] };
202
+ if (!verification) return base;
203
+ const fm = extractFrontmatter(safeReadFile(path.join(dir, verification)) || '');
204
+ const sha = String(fm.verified_commit || '').trim();
205
+ if (!SHA_RE.test(sha)) return { ...base, state: 'unknown', reason: 'the verification records no verified_commit' };
206
+ const known = execGit(cwd, ['cat-file', '-e', `${sha}^{commit}`]);
207
+ if (known.exitCode !== 0) return { ...base, state: 'unknown', verified_commit: sha, reason: 'the verified commit is not in this repository (rebased, squashed, or no git)' };
208
+ const covered = coveredFiles(cwd, info);
209
+ if (!covered.length) return { ...base, state: 'unknown', verified_commit: sha, reason: 'the phase declares no files to compare' };
210
+ // Against the working tree, not HEAD: an uncommitted edit makes the pass stale too.
211
+ const diff = execGit(cwd, ['-c', 'core.quotepath=off', 'diff', '--name-only', '--relative', sha, '--', ...covered]);
212
+ if (diff.exitCode !== 0) return { ...base, state: 'unknown', verified_commit: sha, reason: 'git diff failed' };
213
+ const changed = diff.stdout.split('\n').map(normalisePath).filter(Boolean).sort();
214
+ return { ...base, state: changed.length ? 'stale' : 'fresh', verified_commit: sha, changed_since: changed };
215
+ }
216
+
217
+ function cmdVerifyStale(cwd, phase, raw) {
218
+ if (!phase) { output({ error: 'phase_required', reason: 'Usage: verify stale <phase>' }, raw); return; }
219
+ const r = verificationFreshness(cwd, phase);
220
+ output(r, raw, r.error ? undefined : r.state);
221
+ }
222
+
223
+ module.exports = { scopePhase, cmdVerifyScope, verificationFreshness, cmdVerifyStale, coveredFiles, LOCKFILES };