pan-wizard 3.30.0 → 3.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +30 -26
  2. package/agents/pan-design-checker.md +18 -0
  3. package/agents/pan-optimizer.md +5 -1
  4. package/agents/pan-plan-checker.md +23 -0
  5. package/agents/pan-reviewer.md +19 -0
  6. package/agents/pan-verifier.md +23 -0
  7. package/bin/install-lib.cjs +129 -33
  8. package/bin/install.js +16 -9
  9. package/commands/pan/army.md +4 -4
  10. package/commands/pan/design-phase.md +8 -0
  11. package/commands/pan/exec-phase.md +1 -1
  12. package/commands/pan/git.md +2 -2
  13. package/commands/pan/learn.md +2 -0
  14. package/commands/pan/optimize.md +14 -2
  15. package/commands/pan/review-deep.md +5 -5
  16. package/commands/pan/verify-phase.md +8 -9
  17. package/hooks/dist/pan-check-update.js +53 -2
  18. package/hooks/dist/pan-context-monitor.js +42 -1
  19. package/hooks/dist/pan-cost-logger.js +132 -11
  20. package/hooks/dist/pan-state-reinject.js +158 -0
  21. package/hooks/dist/pan-stop-guard.js +50 -4
  22. package/hooks/dist/pan-trace-logger.js +324 -18
  23. package/package.json +1 -1
  24. package/pan-wizard-core/bin/lib/commands.cjs +22 -5
  25. package/pan-wizard-core/bin/lib/config.cjs +2 -0
  26. package/pan-wizard-core/bin/lib/constants.cjs +31 -1
  27. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
  28. package/pan-wizard-core/bin/lib/core.cjs +232 -23
  29. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
  30. package/pan-wizard-core/bin/lib/cost.cjs +135 -46
  31. package/pan-wizard-core/bin/lib/findings.cjs +381 -0
  32. package/pan-wizard-core/bin/lib/focus.cjs +72 -19
  33. package/pan-wizard-core/bin/lib/foreign-planning.cjs +24 -6
  34. package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
  35. package/pan-wizard-core/bin/lib/hygiene.cjs +12 -2
  36. package/pan-wizard-core/bin/lib/init.cjs +4 -1
  37. package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
  38. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +9 -2
  39. package/pan-wizard-core/bin/lib/optimize.cjs +349 -21
  40. package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
  41. package/pan-wizard-core/bin/lib/review-deep.cjs +36 -0
  42. package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
  43. package/pan-wizard-core/bin/lib/state.cjs +93 -24
  44. package/pan-wizard-core/bin/lib/suggest.cjs +21 -9
  45. package/pan-wizard-core/bin/lib/template.cjs +6 -3
  46. package/pan-wizard-core/bin/lib/verdict.cjs +431 -0
  47. package/pan-wizard-core/bin/lib/verify-deploy.cjs +19 -2
  48. package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -2
  49. package/pan-wizard-core/bin/lib/verify-scope.cjs +156 -0
  50. package/pan-wizard-core/bin/lib/verify.cjs +25 -2
  51. package/pan-wizard-core/bin/pan-tools.cjs +69 -6
  52. package/pan-wizard-core/learnings/README.md +10 -7
  53. package/pan-wizard-core/mcp/tool-registry.cjs +16 -4
  54. package/pan-wizard-core/references/verdict-contract.md +101 -0
  55. package/pan-wizard-core/templates/verification-report.md +16 -0
  56. package/pan-wizard-core/workflows/exec-phase.md +73 -47
  57. package/pan-wizard-core/workflows/health.md +2 -0
  58. package/pan-wizard-core/workflows/help.md +2 -2
  59. package/pan-wizard-core/workflows/learn.md +4 -2
  60. package/pan-wizard-core/workflows/milestone-audit.md +15 -3
  61. package/pan-wizard-core/workflows/optimize.md +14 -1
  62. package/pan-wizard-core/workflows/phase-tests.md +1 -1
  63. package/pan-wizard-core/workflows/plan-phase.md +20 -15
  64. package/pan-wizard-core/workflows/progress.md +1 -1
  65. package/pan-wizard-core/workflows/todo-add.md +1 -1
  66. package/pan-wizard-core/workflows/todo-check.md +7 -6
  67. package/pan-wizard-core/workflows/verify-phase.md +3 -13
  68. package/pan-zcode/README.md +3 -3
  69. package/scripts/build-agent-plugin.js +4 -0
  70. package/scripts/build-hooks.js +2 -1
  71. package/scripts/build-plugin.js +10 -0
  72. package/scripts/git-hooks/pre-commit +2 -1
  73. package/scripts/npm-dist-tag.js +47 -0
  74. package/scripts/plugin-path.js +6 -1
  75. package/scripts/test-surface.cjs +1 -0
@@ -0,0 +1,381 @@
1
+ 'use strict';
2
+ /**
3
+ * Findings ledger — what PAN's judges found, and what was done about it
4
+ * (evidence loop, docs/specs/evidence_loop_featureai.md §3.4; ADR-0049).
5
+ *
6
+ * `.planning/findings.jsonl` is append-only, like the cost and trace ledgers, so
7
+ * parallel writers and interrupted runs never lose an update. Three row kinds:
8
+ *
9
+ * verdict one recorded judge report (agent, phase, outcome, attempt, the ids it listed)
10
+ * finding one finding, written the first time its id is seen
11
+ * disposition fixed | deferred | dismissed | decision, with a reason
12
+ *
13
+ * Status is FOLDED on read, never stored (D6):
14
+ * - the latest disposition wins;
15
+ * - except `fixed`, which gives way to `open` when a newer verdict reports the id
16
+ * again (a regression);
17
+ * - with no disposition, a finding is `open`.
18
+ *
19
+ * Two rules are applied when a verdict is recorded:
20
+ * - AUTO-FIX: the same agent's earlier open findings on the phase that the new
21
+ * verdict does not report become `fixed`. That is how a gap-closure round
22
+ * closes its gaps. `human` and `unrequested` are exempt, because a re-run does
23
+ * not re-check them.
24
+ * - IDEMPOTENCY: recording the same artifact twice is a no-op that returns the
25
+ * first verdict (record_sig). That is why exec-phase and verify-phase may both
26
+ * record one verification.md.
27
+ */
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const crypto = require('crypto');
31
+ const { output, toPosix, normalizePhaseName, findPhaseInternal, getMilestoneInfo } = require('./core.cjs');
32
+ const { planningPath, planningRel } = require('./utils.cjs');
33
+ const V = require('./verdict.cjs');
34
+
35
+ const FINDINGS_FILE = 'findings.jsonl';
36
+ const LEDGER_V = 1;
37
+ const REASON_MAX = 300;
38
+ const FINDING_ID_RE = /^f_[0-9a-f]{10}$/;
39
+ const STATUSES = Object.freeze(['open', ...V.DISPOSITIONS]);
40
+
41
+ function ledgerPath(cwd) {
42
+ return planningPath(cwd, FINDINGS_FILE);
43
+ }
44
+
45
+ function sha(s) {
46
+ return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
47
+ }
48
+
49
+ /** Ledger rows in file order; malformed lines are counted, never fatal. */
50
+ function readLedger(cwd) {
51
+ let raw = '';
52
+ try { raw = fs.readFileSync(ledgerPath(cwd), 'utf-8'); } catch { return { rows: [], malformed: 0 }; }
53
+ const rows = [];
54
+ let malformed = 0;
55
+ for (const line of raw.split('\n')) {
56
+ if (!line.trim()) continue;
57
+ try {
58
+ const r = JSON.parse(line);
59
+ if (r && typeof r === 'object' && typeof r.kind === 'string') rows.push(r);
60
+ else malformed++;
61
+ } catch { malformed++; }
62
+ }
63
+ return { rows, malformed };
64
+ }
65
+
66
+ /**
67
+ * Fold the ledger into verdicts and findings with their derived status.
68
+ * Row order (sequence), not timestamps, decides "newer": clocks can disagree,
69
+ * an append-only file cannot.
70
+ */
71
+ function foldFindings(rows) {
72
+ const verdicts = [];
73
+ const findings = new Map();
74
+ rows.forEach((r, seq) => {
75
+ if (r.kind === 'finding' && typeof r.id === 'string' && !findings.has(r.id)) {
76
+ findings.set(r.id, {
77
+ id: r.id, phase: r.phase, milestone: r.milestone || null, agent: r.agent, class: r.class,
78
+ severity: r.severity, where: r.where || null, summary: r.summary, first_seen: r.ts,
79
+ first_verdict_id: r.verdict_id || null, reports: 0, last_reported: null, _reportedSeq: -1,
80
+ disposition: null, reason: null, disposed_at: null, auto: false, _disposedSeq: -1,
81
+ });
82
+ } else if (r.kind === 'verdict') {
83
+ verdicts.push({ ...r, _seq: seq });
84
+ for (const id of Array.isArray(r.finding_ids) ? r.finding_ids : []) {
85
+ const f = findings.get(id);
86
+ if (!f) continue;
87
+ f.reports++;
88
+ f.last_reported = r.ts;
89
+ f._reportedSeq = seq;
90
+ }
91
+ } else if (r.kind === 'disposition' && typeof r.id === 'string') {
92
+ const f = findings.get(r.id);
93
+ if (!f || !V.DISPOSITIONS.includes(r.disposition)) return;
94
+ f.disposition = r.disposition;
95
+ f.reason = r.reason || null;
96
+ f.disposed_at = r.ts;
97
+ f.auto = r.auto === true;
98
+ f._disposedSeq = seq;
99
+ }
100
+ });
101
+ for (const f of findings.values()) {
102
+ if (!f.disposition) f.status = 'open';
103
+ else if (f.disposition === 'fixed' && f._reportedSeq > f._disposedSeq) f.status = 'open';
104
+ else f.status = f.disposition;
105
+ }
106
+ return { verdicts, findings };
107
+ }
108
+
109
+ /** A finding as the CLI shows it (internal sequence fields removed). */
110
+ function publicFinding(f) {
111
+ const { _reportedSeq, _disposedSeq, ...rest } = f;
112
+ return rest;
113
+ }
114
+
115
+ function countBy(list, key) {
116
+ const out = {};
117
+ for (const x of list) out[x[key]] = (out[x[key]] || 0) + 1;
118
+ return out;
119
+ }
120
+
121
+ function samePhase(a, b) {
122
+ return V.comparablePhase(a) === V.comparablePhase(b);
123
+ }
124
+
125
+ /** `.planning/` must exist: the ledger fills a planning tree, it never creates one. */
126
+ function requirePlanningTree(cwd) {
127
+ try { return fs.statSync(planningPath(cwd)).isDirectory(); } catch { return false; }
128
+ }
129
+
130
+ function appendRows(cwd, rows) {
131
+ if (!rows.length) return;
132
+ fs.appendFileSync(ledgerPath(cwd), rows.map((r) => JSON.stringify(r)).join('\n') + '\n', 'utf-8');
133
+ }
134
+
135
+ /** Log a trace event through optimize.cjs; best effort, never fails a record. */
136
+ function traceEvent(cwd, event) {
137
+ try { require('./optimize.cjs').logTraceEvent(cwd, event); } catch { /* telemetry is best effort */ }
138
+ }
139
+
140
+ /**
141
+ * Record a judge's verdict from an artifact's text.
142
+ * @param {string} cwd
143
+ * @param {{phase: string, text: string, source?: string, agent?: string}} input
144
+ * @returns {Object} the record result, or `{error, reason}`
145
+ */
146
+ function recordVerdict(cwd, input) {
147
+ if (!requirePlanningTree(cwd)) return { error: 'not_a_pan_project', reason: 'no .planning/ directory here' };
148
+ if (!input.phase) return { error: 'phase_required', reason: 'findings record needs --phase <N>' };
149
+ const phaseInfo = findPhaseInternal(cwd, input.phase);
150
+ if (!phaseInfo) return { error: 'phase_not_found', reason: `no phase directory for phase ${input.phase}` };
151
+ const phase = normalizePhaseName(phaseInfo.phase_number || input.phase);
152
+ const text = typeof input.text === 'string' ? input.text : '';
153
+ if (!text.trim()) return { error: 'empty_input', reason: 'nothing to record' };
154
+
155
+ const parsed = V.parseVerdictText(text, { agent: input.agent, phase });
156
+ if (!parsed.ok) return { error: parsed.error, reason: parsed.reason };
157
+ const verdict = parsed.verdict;
158
+ const agent = verdict.agent;
159
+ const recordSig = V.recordSig(text);
160
+
161
+ const { rows } = readLedger(cwd);
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 };
164
+
165
+ const dup = fold.verdicts.find((r) => r.record_sig === recordSig && r.agent === agent && samePhase(r.phase, phase));
166
+ if (dup) {
167
+ return { ...base, recorded: false, duplicate: true, verdict_id: dup.verdict_id, attempt: dup.attempt, phase, findings: (dup.finding_ids || []).length, new: 0, auto_fixed: 0, reopened: 0 };
168
+ }
169
+
170
+ const prior = fold.verdicts.filter((r) => r.agent === agent && samePhase(r.phase, phase));
171
+ const attempt = prior.length + 1;
172
+ const previous = prior.length ? prior[prior.length - 1] : null;
173
+ const ts = new Date().toISOString();
174
+ const milestone = getMilestoneInfo(cwd).version || null;
175
+ const verdictId = 'v_' + sha([recordSig, phase, agent, rows.length].join('\u0000')).slice(0, 10);
176
+
177
+ const ids = [];
178
+ const newRows = [];
179
+ const findingRows = [];
180
+ for (const f of verdict.findings) {
181
+ const id = V.findingId(phase, agent, f);
182
+ if (ids.includes(id)) continue; // the same finding twice in one report is one finding
183
+ ids.push(id);
184
+ if (!fold.findings.has(id)) {
185
+ findingRows.push({ v: LEDGER_V, kind: 'finding', ts, id, verdict_id: verdictId, phase, milestone, agent, class: f.class, severity: f.severity, where: f.where, summary: f.summary });
186
+ }
187
+ }
188
+ const reopened = ids.filter((id) => fold.findings.has(id) && fold.findings.get(id).status === 'fixed').length;
189
+ const autoFixRows = [];
190
+ for (const f of fold.findings.values()) {
191
+ if (f.agent !== agent || !samePhase(f.phase, phase) || f.status !== 'open') continue;
192
+ if (ids.includes(f.id) || V.AUTO_FIX_EXEMPT.includes(f.class)) continue;
193
+ autoFixRows.push({ v: LEDGER_V, kind: 'disposition', ts, id: f.id, disposition: 'fixed', reason: `not reported by ${agent} at attempt ${attempt}`, auto: true, verdict_id: verdictId });
194
+ }
195
+
196
+ const bySeverity = countBy(verdict.findings, 'severity');
197
+ const byClass = countBy(verdict.findings, 'class');
198
+ let source = '(text)';
199
+ if (input.source) {
200
+ const rel = path.relative(path.resolve(cwd), path.resolve(cwd, input.source));
201
+ source = rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? toPosix(rel) : '(external file)';
202
+ }
203
+ newRows.push({
204
+ v: LEDGER_V, kind: 'verdict', ts, verdict_id: verdictId, record_sig: recordSig, phase, milestone, agent,
205
+ verdict: verdict.verdict, outcome: verdict.outcome, attempt, source, finding_ids: ids,
206
+ by_severity: bySeverity, by_class: byClass, warnings: parsed.warnings,
207
+ });
208
+ appendRows(cwd, [...findingRows, ...newRows, ...autoFixRows]);
209
+
210
+ const category = { pass: 'verdict_passed', fail: 'verdict_failed', needs_human: 'verdict_needs_human' }[verdict.outcome];
211
+ const serious = (bySeverity.critical || 0) + (bySeverity.high || 0) > 0;
212
+ traceEvent(cwd, {
213
+ type: verdict.outcome === 'fail' ? 'error' : 'decision',
214
+ category,
215
+ agent,
216
+ phase,
217
+ description: `${agent} ${verdict.verdict || verdict.outcome} on phase ${phase} (attempt ${attempt}, ${ids.length} finding${ids.length === 1 ? '' : 's'})`,
218
+ 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 },
220
+ });
221
+ if (previous && previous.outcome === 'fail') {
222
+ traceEvent(cwd, {
223
+ type: 'correction',
224
+ category: 'verdict_retry',
225
+ agent,
226
+ phase,
227
+ description: `${agent} judged phase ${phase} again (attempt ${attempt}): ${previous.outcome} → ${verdict.outcome}, ${autoFixRows.length} earlier finding(s) no longer reported`,
228
+ impact: 'minor',
229
+ context: { agent, phase, attempt, previous_outcome: previous.outcome, outcome: verdict.outcome, auto_fixed: autoFixRows.length, reopened, verdict_id: verdictId },
230
+ });
231
+ }
232
+
233
+ return { ...base, recorded: true, duplicate: false, verdict_id: verdictId, attempt, phase, findings: ids.length, new: findingRows.length, auto_fixed: autoFixRows.length, reopened, ledger: planningRel(FINDINGS_FILE) };
234
+ }
235
+
236
+ /**
237
+ * Record a disposition for findings, by id or in bulk (`--phase N [--agent A] --open`).
238
+ * @param {{ids?: string[], phase?: string, agent?: string, open?: boolean, as: string, reason?: string}} input
239
+ */
240
+ function disposeFindings(cwd, input) {
241
+ if (!requirePlanningTree(cwd)) return { error: 'not_a_pan_project', reason: 'no .planning/ directory here' };
242
+ const as = String(input.as || '').trim().toLowerCase();
243
+ if (!V.DISPOSITIONS.includes(as)) return { error: 'invalid_disposition', reason: `--as must be one of ${V.DISPOSITIONS.join(', ')}` };
244
+ const reason = input.reason == null ? '' : String(input.reason).replace(/\s+/g, ' ').trim().slice(0, REASON_MAX);
245
+ if (V.REASON_REQUIRED.includes(as) && !reason) {
246
+ return { error: 'reason_required', reason: `a ${as} disposition needs --reason "<why>" (MI-031: never dispose silently)` };
247
+ }
248
+ const { rows } = readLedger(cwd);
249
+ const fold = foldFindings(rows);
250
+ let targets;
251
+ if (Array.isArray(input.ids) && input.ids.length) {
252
+ targets = input.ids;
253
+ } else if (input.open && input.phase) {
254
+ targets = [...fold.findings.values()]
255
+ .filter((f) => f.status === 'open' && samePhase(f.phase, input.phase) && (!input.agent || f.agent === input.agent))
256
+ .map((f) => f.id);
257
+ } else {
258
+ return { error: 'no_targets', reason: 'name finding ids, or --phase <N> --open [--agent <name>]' };
259
+ }
260
+ const ts = new Date().toISOString();
261
+ const disposed = [];
262
+ const skipped = [];
263
+ const out = [];
264
+ for (const id of targets) {
265
+ const f = fold.findings.get(id);
266
+ if (!f) { skipped.push({ id, why: 'unknown id' }); continue; }
267
+ if (f.status === as) { skipped.push({ id, why: `already ${as}` }); continue; }
268
+ out.push({ v: LEDGER_V, kind: 'disposition', ts, id, disposition: as, reason: reason || null, auto: false });
269
+ disposed.push(id);
270
+ }
271
+ appendRows(cwd, out);
272
+ return { contract: V.VERDICT_CONTRACT, disposition: as, disposed, skipped };
273
+ }
274
+
275
+ /** Findings with their folded status, filtered. */
276
+ function listFindings(cwd, filters = {}) {
277
+ const { rows, malformed } = readLedger(cwd);
278
+ const fold = foldFindings(rows);
279
+ let list = [...fold.findings.values()];
280
+ if (filters.phase) list = list.filter((f) => samePhase(f.phase, filters.phase));
281
+ if (filters.agent) list = list.filter((f) => f.agent === filters.agent);
282
+ if (filters.status) list = list.filter((f) => f.status === filters.status);
283
+ if (filters.class) list = list.filter((f) => f.class === filters.class);
284
+ if (filters.milestone) list = list.filter((f) => f.milestone === filters.milestone);
285
+ const findings = list.map(publicFinding);
286
+ return {
287
+ contract: V.VERDICT_CONTRACT,
288
+ findings,
289
+ counts: { total: findings.length, by_status: countBy(findings, 'status'), by_class: countBy(findings, 'class'), by_severity: countBy(findings, 'severity') },
290
+ verdicts: fold.verdicts.length,
291
+ malformed_rows: malformed,
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Tech debt for a milestone audit: deferred findings (with the reason recorded at
297
+ * the time) and findings nobody disposed, grouped by phase.
298
+ */
299
+ function findingsDebt(cwd, opts = {}) {
300
+ const milestone = opts.milestone || getMilestoneInfo(cwd).version || null;
301
+ const { rows } = readLedger(cwd);
302
+ const fold = foldFindings(rows);
303
+ const byPhase = new Map();
304
+ for (const f of fold.findings.values()) {
305
+ if (milestone && f.milestone !== milestone) continue;
306
+ if (f.status !== 'deferred' && f.status !== 'open') continue;
307
+ const key = V.comparablePhase(f.phase);
308
+ if (!byPhase.has(key)) byPhase.set(key, { phase: f.phase, deferred: [], open: [] });
309
+ byPhase.get(key)[f.status === 'deferred' ? 'deferred' : 'open'].push(publicFinding(f));
310
+ }
311
+ const phases = [...byPhase.values()].sort((a, b) => String(a.phase).localeCompare(String(b.phase), undefined, { numeric: true }));
312
+ return {
313
+ contract: V.VERDICT_CONTRACT,
314
+ milestone,
315
+ phases,
316
+ deferred_count: phases.reduce((n, p) => n + p.deferred.length, 0),
317
+ open_count: phases.reduce((n, p) => n + p.open.length, 0),
318
+ };
319
+ }
320
+
321
+ // ─── CLI ─────────────────────────────────────────────────────────────────────
322
+
323
+ function cmdFindingsRecord(cwd, opts, raw) {
324
+ let text = null;
325
+ let source = null;
326
+ const inputs = [opts.file != null, opts.text != null, !!opts.stdin].filter(Boolean).length;
327
+ if (inputs !== 1) { output({ error: 'one_input', reason: 'give exactly one of --file <path>, --text <string>, --stdin' }, raw); return; }
328
+ if (opts.file != null) {
329
+ try { text = fs.readFileSync(path.resolve(cwd, opts.file), 'utf-8'); } catch (e) {
330
+ output({ error: 'unreadable_file', reason: `cannot read ${opts.file}: ${e.code || e.message}` }, raw);
331
+ return;
332
+ }
333
+ source = opts.file;
334
+ } else if (opts.text != null) {
335
+ text = String(opts.text);
336
+ } else {
337
+ try { text = fs.readFileSync(0, 'utf-8'); } catch { text = ''; }
338
+ source = null;
339
+ }
340
+ const r = recordVerdict(cwd, { phase: opts.phase, text, source, agent: opts.agent });
341
+ // --raw prints the judge's own verdict word (the word the workflow branches on),
342
+ // or the outcome when the report gave none. A failed verdict is data: exit 0.
343
+ output(r, raw, r.error ? undefined : (r.verdict || r.outcome));
344
+ }
345
+
346
+ function cmdFindingsDispose(cwd, opts, raw) {
347
+ output(disposeFindings(cwd, opts), raw);
348
+ }
349
+
350
+ function cmdFindingsList(cwd, opts, raw) {
351
+ if (opts.status && !STATUSES.includes(opts.status)) { output({ error: 'invalid_status', reason: `--status must be one of ${STATUSES.join(', ')}` }, raw); return; }
352
+ if (opts.class && !V.FINDING_CLASSES.includes(opts.class)) { output({ error: 'invalid_class', reason: `--class must be one of ${V.FINDING_CLASSES.join(', ')}` }, raw); return; }
353
+ const r = listFindings(cwd, opts);
354
+ output(r, raw, String(r.counts.total));
355
+ }
356
+
357
+ function cmdFindingsDebt(cwd, opts, raw) {
358
+ const r = findingsDebt(cwd, opts);
359
+ output(r, raw, `${r.deferred_count} deferred, ${r.open_count} open`);
360
+ }
361
+
362
+ /** Positional finding ids in an argv slice. */
363
+ function findingIdsIn(args) {
364
+ return args.filter((a) => FINDING_ID_RE.test(a));
365
+ }
366
+
367
+ module.exports = {
368
+ FINDINGS_FILE,
369
+ STATUSES,
370
+ readLedger,
371
+ foldFindings,
372
+ recordVerdict,
373
+ disposeFindings,
374
+ listFindings,
375
+ findingsDebt,
376
+ findingIdsIn,
377
+ cmdFindingsRecord,
378
+ cmdFindingsDispose,
379
+ cmdFindingsList,
380
+ cmdFindingsDebt,
381
+ };
@@ -566,30 +566,76 @@ function cmdFocusSync(cwd, raw, ...args) {
566
566
 
567
567
  // ─── Exec helpers ───────────────────────────────────────────────────────────
568
568
 
569
+ const BATCH_DATE_RE = /^batch-(\d{4}-\d{2}-\d{2})/;
570
+
569
571
  /**
570
- * Read the oldest open batch file from .planning/focus/.
571
- * Batches are named batch-YYYY-MM-DD.json; lexical sort == chronological.
572
- * Oldest-first ensures older unfinished batches get executed before newer ones.
572
+ * The batch files in .planning/focus/, oldest first; the last one is the batch to run.
573
+ * Order: the date in the name, then the modification time for batches planned the same
574
+ * day, then the name. `focus plan` writes batch-YYYY-MM-DD.json; `/pan:focus-auto` and
575
+ * hand-planned batches add a slug after the date, which a plain name sort would rank
576
+ * below the same day's plain file whatever order they were written in. This is the one
577
+ * ordering every reader uses: `focus exec`, `focus classify-stages` and `preflight
578
+ * batch` each used to choose a batch its own way.
573
579
  * @param {string} cwd - Project root
574
- * @returns {Object|null} Parsed batch data or null
580
+ * @returns {string[]} File names, oldest first
575
581
  */
576
- function readLatestBatch(cwd) {
582
+ function listBatchFiles(cwd) {
577
583
  const focusDir = planningPath(cwd, FOCUS_DIR);
578
- let files;
584
+ let names;
579
585
  try {
580
- files = fs.readdirSync(focusDir).filter(f => f.startsWith('batch-') && f.endsWith('.json'));
586
+ names = fs.readdirSync(focusDir).filter(f => f.startsWith('batch-') && f.endsWith('.json'));
581
587
  } catch {
582
- return null;
588
+ return [];
583
589
  }
590
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
591
+ return names
592
+ .map((name) => {
593
+ const m = BATCH_DATE_RE.exec(name);
594
+ let mtime = 0;
595
+ try { mtime = fs.statSync(path.join(focusDir, name)).mtimeMs; } catch { /* removed meanwhile */ }
596
+ return { name, date: m ? m[1] : '', mtime };
597
+ })
598
+ .sort((a, b) => cmp(a.date, b.date) || a.mtime - b.mtime || cmp(a.name, b.name))
599
+ .map((e) => e.name);
600
+ }
601
+
602
+ /**
603
+ * Read the newest batch in .planning/focus/, with its items as an array.
604
+ *
605
+ * Newest, not oldest. v2.10.0 (E-12) sorted ascending so "older unfinished batches
606
+ * execute before newer ones", but nothing ever marks a batch finished, so it returned
607
+ * the first batch a project planned, every time. `/pan:focus-exec` executes the newest
608
+ * batch, so its `classify-stages` step was classifying a different batch from the one it
609
+ * ran. A batch is a snapshot of the backlog: a newer plan already carries whatever an
610
+ * older one left undone.
611
+ *
612
+ * Items: `focus plan` writes them under `batch`. `/pan:focus-auto` documents `items`, and
613
+ * batches agents write put the list under `items`, often with `batch` holding the batch's
614
+ * NAME (measured 2026-09-29, structure only: 1 of 102 batch files in four projects had a
615
+ * `batch` array). Either way the list comes back as `.batch`, with the key it came from
616
+ * in `.items_key`, a string `batch` kept as `.batch_name`, and the file name in `.file`.
617
+ *
618
+ * @param {string} cwd - Project root
619
+ * @returns {Object|null} null when there is no batch file. When the newest file cannot be
620
+ * used: `{ file, error, batch: [], items_key: null }`. An older batch never stands in.
621
+ */
622
+ function readLatestBatch(cwd) {
623
+ const files = listBatchFiles(cwd);
584
624
  if (files.length === 0) return null;
585
- files.sort();
586
- const content = safeReadFile(path.join(focusDir, files[0]));
587
- if (!content) return null;
625
+ const file = files[files.length - 1];
626
+ const unusable = (why) => ({ file, error: `${file} ${why}`, batch: [], items_key: null });
627
+ let data;
588
628
  try {
589
- return JSON.parse(content);
629
+ data = JSON.parse(safeReadFile(path.join(planningPath(cwd, FOCUS_DIR), file)));
590
630
  } catch {
591
- return null;
631
+ return unusable('is not valid JSON');
592
632
  }
633
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return unusable('is not a batch object');
634
+ const itemsKey = Array.isArray(data.batch) ? 'batch' : Array.isArray(data.items) ? 'items' : null;
635
+ if (!itemsKey) return unusable('has no item list (expected a "batch" or "items" array)');
636
+ const out = { ...data, batch: data[itemsKey], items_key: itemsKey, file };
637
+ if (typeof data.batch === 'string') out.batch_name = data.batch;
638
+ return out;
593
639
  }
594
640
 
595
641
  /**
@@ -619,16 +665,22 @@ function cmdFocusExec(cwd, raw, ...args) {
619
665
  output({ error: 'No batch file found. Run focus plan first.' }, raw);
620
666
  return;
621
667
  }
668
+ const batchFile = planningRel(FOCUS_DIR, batch.file);
669
+ if (batch.error) {
670
+ output({ error: batch.error, batch_file: batchFile }, raw);
671
+ return;
672
+ }
622
673
 
623
674
  if (batch.batch.length === 0) {
624
- output({ error: 'Batch is empty. Run focus plan with items.' }, raw);
675
+ output({ error: 'Batch is empty. Run focus plan with items.', batch_file: batchFile }, raw);
625
676
  return;
626
677
  }
627
678
 
628
- // Classify items by tier
629
- const micro = batch.batch.filter(i => i.tier === FOCUS_TIERS.MICRO);
630
- const standard = batch.batch.filter(i => i.tier === FOCUS_TIERS.STANDARD);
631
- const full = batch.batch.filter(i => i.tier === FOCUS_TIERS.FULL);
679
+ // Classify items by tier (agent-written batches can hold non-object entries)
680
+ const tierOf = (i) => (i && typeof i === 'object' ? i.tier : undefined);
681
+ const micro = batch.batch.filter(i => tierOf(i) === FOCUS_TIERS.MICRO);
682
+ const standard = batch.batch.filter(i => tierOf(i) === FOCUS_TIERS.STANDARD);
683
+ const full = batch.batch.filter(i => tierOf(i) === FOCUS_TIERS.FULL);
632
684
 
633
685
  const result = {
634
686
  dry_run: dryRun,
@@ -642,7 +694,7 @@ function cmdFocusExec(cwd, raw, ...args) {
642
694
  full: full.length,
643
695
  },
644
696
  items: batch.batch,
645
- batch_file: planningRel(FOCUS_DIR, `batch-${batch.date}.json`),
697
+ batch_file: batchFile,
646
698
  };
647
699
 
648
700
  output(result, raw);
@@ -1192,6 +1244,7 @@ module.exports = {
1192
1244
  checkVersionCrossRef,
1193
1245
  // Exec
1194
1246
  cmdFocusExec,
1247
+ listBatchFiles,
1195
1248
  readLatestBatch,
1196
1249
  // Auto
1197
1250
  categoryFilter,
@@ -11,10 +11,14 @@
11
11
  * tool? — from POSITIVE markers PAN never writes (FOREIGN_PLANNING_MARKERS in
12
12
  * constants.cjs, sourced from gsd-core's docs/USER-GUIDE.md, read 2026-09-10).
13
13
  *
14
- * Rule: foreign when any marker FILE exists, or at least two marker DIRECTORIES exist,
15
- * or config.json carries any of the tool's flat dotted keys (PAN nests `workflow: {}`;
16
- * gsd-core writes `"workflow.discuss_mode"`). An unreadable config.json is not evidence.
17
- * Returns { tool, key, evidence[] } or null. Never throws.
14
+ * Rule: foreign when any marker FILE exists (a name, or a name pattern), or at least
15
+ * two marker DIRECTORIES exist, or config.json carries any of the tool's flat dotted
16
+ * keys (PAN nests `workflow: {}`; gsd-core writes `"workflow.discuss_mode"`). A pattern
17
+ * directory holding the file it `requires` counts as file-strength evidence; without
18
+ * that file it is one directory. An unreadable config.json is not evidence.
19
+ * Returns { tool, key, evidence[], coexists } or null. Never throws. `coexists` marks
20
+ * a tool that SHARES the tree (planning-with-files): callers keep working and report
21
+ * it, instead of refusing the tree as another tool's.
18
22
  */
19
23
  const fs = require('fs');
20
24
  const path = require('path');
@@ -35,13 +39,27 @@ function detectForeignPlanningTree(planningDir) {
35
39
  }
36
40
  for (const [key, m] of Object.entries(FOREIGN_PLANNING_MARKERS)) {
37
41
  const fileHits = m.files.filter(f => names.has(f));
42
+ for (const re of m.filePatterns || []) {
43
+ for (const e of entries) if (!e.isDirectory() && re.test(e.name)) fileHits.push(e.name);
44
+ }
38
45
  const dirHits = m.dirs.filter(d => dirs.has(d));
46
+ const strongDirs = [];
47
+ for (const { re, requires } of m.dirPatterns || []) {
48
+ for (const d of [...dirs].sort()) {
49
+ if (!re.test(d)) continue;
50
+ let holds = false;
51
+ try { holds = fs.existsSync(path.join(planningDir, d, requires)); } catch { holds = false; }
52
+ if (holds) strongDirs.push(d);
53
+ else dirHits.push(d);
54
+ }
55
+ }
39
56
  const configHits = cfg ? m.configKeys.filter(k => Object.prototype.hasOwnProperty.call(cfg, k)) : [];
40
- if (fileHits.length > 0 || dirHits.length >= 2 || configHits.length > 0) {
57
+ if (fileHits.length > 0 || strongDirs.length > 0 || dirHits.length >= 2 || configHits.length > 0) {
41
58
  return {
42
59
  tool: m.tool,
43
60
  key,
44
- evidence: [...fileHits, ...dirHits.map(d => d + '/'), ...configHits.map(k => 'config.json:' + k)],
61
+ evidence: [...fileHits, ...strongDirs.map(d => d + '/'), ...dirHits.map(d => d + '/'), ...configHits.map(k => 'config.json:' + k)],
62
+ coexists: m.coexists === true,
45
63
  };
46
64
  }
47
65
  }
@@ -89,8 +89,11 @@ function extractFrontmatter(content) {
89
89
  const frontmatter = {};
90
90
 
91
91
  // --- Frontmatter delimiter detection ---
92
- // Match the first YAML block bounded by opening "---\n" and closing "\n---"
93
- const match = content.match(/^---\n([\s\S]+?)\n---/);
92
+ // Match the first YAML block bounded by opening "---\n" and closing "\n---".
93
+ // CRLF and a leading BOM are normalised first: the LF-only match found no front
94
+ // matter at all in a CRLF file, so every reader saw an empty object.
95
+ const text = String(content == null ? '' : content).replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
96
+ const match = text.match(/^---\n([\s\S]+?)\n---/);
94
97
  if (!match) return frontmatter;
95
98
 
96
99
  const yaml = match[1];
@@ -534,7 +534,15 @@ function scanOneRoot(cwd, root, opts) {
534
534
  // rename would rename its state files. One warn finding, nothing fixable, and
535
535
  // none of the per-tree checks run on it. Reality check R15.
536
536
  const foreign = detectForeignPlanningTree(planningPath(cwd));
537
- if (foreign) {
537
+ // A tool that SHARES the tree (planning-with-files) owns only its own names;
538
+ // PAN's checks still apply to PAN's files. One info finding says so.
539
+ const shared = foreign && foreign.coexists
540
+ ? mkFinding('shared-planning-tree', 'info', planningRel(),
541
+ `${foreign.tool} also writes into this planning tree (${foreign.evidence.join(', ')}) — PAN leaves its files alone and checks only its own`,
542
+ null)
543
+ : null;
544
+ if (shared) shared.track = root.name;
545
+ if (foreign && !foreign.coexists) {
538
546
  const f = mkFinding('foreign-planning-tree', 'warn', planningRel(),
539
547
  `planning tree belongs to ${foreign.tool} (${foreign.evidence.join(', ')}) — PAN will not rename or repair its files; run PAN with --planning-dir to give it a tree of its own (ADR-0043)`,
540
548
  null);
@@ -543,6 +551,7 @@ function scanOneRoot(cwd, root, opts) {
543
551
  }
544
552
  const fragment = checkPlanningFragment(cwd);
545
553
  const findings = [
554
+ ...(shared ? [shared] : []),
546
555
  ...fragment.findings,
547
556
  ...checkLegacyUppercase(cwd).findings,
548
557
  ...checkTmpOrphans(cwd).findings,
@@ -621,7 +630,8 @@ function applyFix(cwd, finding) {
621
630
  // Defence in depth for R15: the scan never emits this fix for a foreign tree,
622
631
  // but a stale findings list or a hand-built one must not rename another
623
632
  // tool's files either.
624
- if (detectForeignPlanningTree(path.dirname(abs))) {
633
+ const owner = detectForeignPlanningTree(path.dirname(abs));
634
+ if (owner && !owner.coexists) {
625
635
  return { applied: false, detail: 'refused: this planning tree belongs to another tool (see the foreign-planning-tree finding)' };
626
636
  }
627
637
  // Two-step rename: Windows treats case-only renames inconsistently
@@ -286,7 +286,7 @@ function cmdInitNewProject(cwd, raw) {
286
286
  // Never scaffold PAN's files into a .planning/ another tool owns (R15). The error
287
287
  // key carries the exit code; the fix is a separate tree via --planning-dir.
288
288
  const foreign = detectForeignPlanningTreeAt(cwd);
289
- if (foreign) {
289
+ if (foreign && !foreign.coexists) {
290
290
  output({ error: `planning tree belongs to ${foreign.tool}`, evidence: foreign.evidence, fix: 'Run PAN with --planning-dir <dir> to use a separate tree (ADR-0043)' }, raw);
291
291
  return;
292
292
  }
@@ -356,6 +356,9 @@ function cmdInitNewProject(cwd, raw) {
356
356
 
357
357
  // File paths
358
358
  project_path: planningRelPath(PROJECT_FILE),
359
+
360
+ // Another tool that shares the tree (planning-with-files), or null
361
+ shared_planning_tree: foreign && foreign.coexists ? { tool: foreign.tool, evidence: foreign.evidence } : null,
359
362
  };
360
363
 
361
364
  output(result, raw);
@@ -214,17 +214,23 @@ function quarantinePath(cwd) {
214
214
  * restore an entry by hand. The file leads with a warning so it is never loaded
215
215
  * as trusted instruction memory.
216
216
  */
217
+ const QUARANTINE_HEADER = '# Quarantined memory (DO NOT auto-load as instructions)\n\nEntries below were pulled out of standing memory during reconcile because they read like\ndirectives aimed at the agent (possible cross-generation prompt injection). They are\nNOT trusted instructions. Review each; restore to state.md by hand only if legitimate.\n';
218
+
217
219
  function appendQuarantine(cwd, entries, now) {
218
220
  if (!entries.length) return;
219
221
  const p = quarantinePath(cwd);
220
- const fresh = !fs.existsSync(p);
221
222
  fs.mkdirSync(path.dirname(p), { recursive: true });
223
+ // Create the file with its warning header atomically (`wx` fails if it exists)
224
+ // instead of checking existsSync first: between that check and the append a
225
+ // concurrent reconcile could write the header twice, or skip it (CodeQL #46,
226
+ // js/file-system-race).
227
+ try {
228
+ fs.writeFileSync(p, QUARANTINE_HEADER, { encoding: 'utf-8', flag: 'wx' });
229
+ } catch (e) {
230
+ if (e.code !== 'EEXIST') throw e;
231
+ }
222
232
  const stamp = now || '(undated)';
223
- const header = fresh
224
- ? '# Quarantined memory (DO NOT auto-load as instructions)\n\nEntries below were pulled out of standing memory during reconcile because they read like\ndirectives aimed at the agent (possible cross-generation prompt injection). They are\nNOT trusted instructions. Review each; restore to state.md by hand only if legitimate.\n'
225
- : '';
226
- const block = `${header}\n## Quarantined ${stamp}\n\n${entries.join('\n')}\n`;
227
- fs.appendFileSync(p, block, 'utf-8');
233
+ fs.appendFileSync(p, `\n## Quarantined ${stamp}\n\n${entries.join('\n')}\n`, 'utf-8');
228
234
  }
229
235
 
230
236
  /**