pan-wizard 3.31.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 (47) hide show
  1. package/README.md +28 -24
  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/commands/pan/design-phase.md +8 -0
  8. package/commands/pan/exec-phase.md +1 -1
  9. package/commands/pan/git.md +2 -2
  10. package/commands/pan/learn.md +2 -0
  11. package/commands/pan/optimize.md +14 -2
  12. package/commands/pan/review-deep.md +5 -5
  13. package/hooks/dist/pan-context-monitor.js +1 -1
  14. package/hooks/dist/pan-cost-logger.js +63 -11
  15. package/hooks/dist/pan-state-reinject.js +2 -2
  16. package/hooks/dist/pan-stop-guard.js +2 -2
  17. package/hooks/dist/pan-trace-logger.js +283 -18
  18. package/package.json +1 -1
  19. package/pan-wizard-core/bin/lib/commands.cjs +2 -1
  20. package/pan-wizard-core/bin/lib/constants.cjs +28 -0
  21. package/pan-wizard-core/bin/lib/cost.cjs +3 -3
  22. package/pan-wizard-core/bin/lib/findings.cjs +381 -0
  23. package/pan-wizard-core/bin/lib/focus.cjs +72 -19
  24. package/pan-wizard-core/bin/lib/foreign-planning.cjs +24 -6
  25. package/pan-wizard-core/bin/lib/hygiene.cjs +12 -2
  26. package/pan-wizard-core/bin/lib/init.cjs +4 -1
  27. package/pan-wizard-core/bin/lib/optimize.cjs +349 -21
  28. package/pan-wizard-core/bin/lib/review-deep.cjs +36 -0
  29. package/pan-wizard-core/bin/lib/state.cjs +7 -2
  30. package/pan-wizard-core/bin/lib/verdict.cjs +431 -0
  31. package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -2
  32. package/pan-wizard-core/bin/lib/verify-scope.cjs +156 -0
  33. package/pan-wizard-core/bin/lib/verify.cjs +25 -2
  34. package/pan-wizard-core/bin/pan-tools.cjs +59 -5
  35. package/pan-wizard-core/learnings/README.md +10 -7
  36. package/pan-wizard-core/mcp/tool-registry.cjs +2 -0
  37. package/pan-wizard-core/references/verdict-contract.md +101 -0
  38. package/pan-wizard-core/templates/verification-report.md +16 -0
  39. package/pan-wizard-core/workflows/exec-phase.md +65 -47
  40. package/pan-wizard-core/workflows/health.md +2 -0
  41. package/pan-wizard-core/workflows/learn.md +4 -2
  42. package/pan-wizard-core/workflows/milestone-audit.md +15 -3
  43. package/pan-wizard-core/workflows/optimize.md +14 -1
  44. package/pan-wizard-core/workflows/plan-phase.md +20 -15
  45. package/pan-wizard-core/workflows/verify-phase.md +3 -13
  46. package/pan-zcode/README.md +3 -3
  47. package/scripts/git-hooks/pre-commit +2 -1
@@ -541,6 +541,15 @@ const HEALTH_STATUS = { HEALTHY: 'healthy', DEGRADED: 'degraded', BROKEN: 'broke
541
541
  /** Max JSON payload size before writing to tmpfile (bytes) */
542
542
  const MAX_JSON_SIZE = 50000;
543
543
 
544
+ /**
545
+ * Shape version of the machine-readable state JSON: `state` (load), `state json`,
546
+ * `progress` (json) and the MCP resources that wrap them (market item M17).
547
+ * Additive rule: fields may be added within a major version and are never renamed
548
+ * or removed; readers accept any 1.x and ignore fields they do not know. A rename
549
+ * or removal is a major bump ("2.0").
550
+ */
551
+ const STATE_CONTRACT = '1.0';
552
+
544
553
  /** Width of progress bar in status display (character count) */
545
554
  const PROGRESS_BAR_WIDTH = 10;
546
555
 
@@ -724,6 +733,24 @@ const FOREIGN_PLANNING_MARKERS = Object.freeze({
724
733
  'graphify.enabled', 'intel.enabled', 'hooks.workflow_guard', 'dynamic_routing', 'resolve_model_ids',
725
734
  ]),
726
735
  }),
736
+ // planning-with-files (OthmanAdi/planning-with-files README, read 2026-09-28 at
737
+ // v3.21.0; market item M3 / MI-028). It writes INTO .planning/ beside whatever else
738
+ // lives there: `.active_plan`, `.attestation`, `sessions/`, `ledger-<agent>.jsonl`, and
739
+ // one `YYYY-MM-DD-slug/` directory per task holding task_plan.md / findings.md /
740
+ // progress.md. PAN writes none of those names. `coexists`: unlike gsd-core, a match
741
+ // means the tree is SHARED, not owned. PAN keeps working in it and says so, rather
742
+ // than refusing. planning-with-files gitignores .planning/ by default, which is what
743
+ // the I005 health check exists for.
744
+ planning_with_files: Object.freeze({
745
+ tool: 'planning-with-files',
746
+ coexists: true,
747
+ files: Object.freeze(['.active_plan', '.attestation']),
748
+ filePatterns: Object.freeze([/^ledger-[A-Za-z0-9._-]+\.jsonl$/]),
749
+ dirs: Object.freeze(['sessions']),
750
+ // A dated task directory is strong evidence only with the tool's plan file in it.
751
+ dirPatterns: Object.freeze([{ re: /^\d{4}-\d{2}-\d{2}-[A-Za-z0-9][A-Za-z0-9._-]*$/, requires: 'task_plan.md' }]),
752
+ configKeys: Object.freeze([]),
753
+ }),
727
754
  });
728
755
 
729
756
  module.exports = {
@@ -806,6 +833,7 @@ module.exports = {
806
833
  HEALTH_STATUS,
807
834
  FOREIGN_PLANNING_MARKERS,
808
835
  MAX_JSON_SIZE,
836
+ STATE_CONTRACT,
809
837
  PROGRESS_BAR_WIDTH,
810
838
  MAX_SLUG_LENGTH,
811
839
  FILLED_BLOCK,
@@ -59,7 +59,7 @@ const DEFAULT_RATES = {
59
59
  // Anthropic — platform.claude.com/docs/en/about-claude/pricing, every row read
60
60
  // 2026-09-23. Opus 4.6+/Opus 5 are $5/$25 (the old $15/$75 Opus pricing ended with
61
61
  // the 4.5 generation). cache_write is the 5-minute write rate (1.25× input; the
62
- // 1-hour write bills 2×, which the ledger does not split yet). Cache reads follow
62
+ // 1-hour write bills 2× and is priced from cache_write_1h, see above). Cache reads follow
63
63
  // the 0.1× convention EXCEPT on three models the page footnotes: Fable 5.1 and
64
64
  // Mythos 5.1 at 0.025× input, Opus 5.5 at 0.05×. Each needs its own row, because
65
65
  // the family-prefix fallback would otherwise price it at its predecessor's reads.
@@ -469,8 +469,8 @@ function readRecords(cwd) {
469
469
  */
470
470
  // No single subagent runs for six hours — the longest native-workflow phase runs
471
471
  // measured in the harness finish inside an hour — while a parent-transcript slice
472
- // that spans a working day, or the idle night between two stops, does. Mirrored by
473
- // the hooks' SLICE_MAX_DURATION_MS, which nulls the span on write. Calibrated on
472
+ // that spans a working day, or the idle night between two stops, does. The hooks
473
+ // write the span as measured; only this reader applies the rule. Calibrated on
474
474
  // eleven field ledgers (2026-09): of the timed rows the old ratio rule flagged,
475
475
  // 55 spanned under three hours and 12 spanned six to twenty-four — the latter all
476
476
  // parent slices booked to a `general-purpose` or workflow subagent.
@@ -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
  }
@@ -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