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
@@ -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);
@@ -459,6 +459,65 @@ function listTraceSessions(cwd) {
459
459
 
460
460
  // ─── Local analysis (no agent) ────────────────────────────────────────────────
461
461
 
462
+ // Judge verdicts, as the findings ledger logs them (evidence loop) and as the
463
+ // workflow prose logged them before it (legacy categories, kept so older sessions
464
+ // still analyse). Legacy categories name no agent reliably, so each maps to its judge.
465
+ const VERDICT_CATEGORIES = {
466
+ verdict_passed: { outcome: 'pass' },
467
+ verdict_failed: { outcome: 'fail' },
468
+ verdict_needs_human: { outcome: 'needs_human' },
469
+ verification_passed: { outcome: 'pass', agent: 'pan-verifier' },
470
+ verification_gaps: { outcome: 'fail', agent: 'pan-verifier' },
471
+ verification_human_needed: { outcome: 'needs_human', agent: 'pan-verifier' },
472
+ plan_verified: { outcome: 'pass', agent: 'pan-plan-checker' },
473
+ plan_checker_issues: { outcome: 'fail', agent: 'pan-plan-checker' },
474
+ reviewer_correction: { outcome: 'fail', agent: 'pan-reviewer' },
475
+ reviewer_warnings: { outcome: 'pass', agent: 'pan-reviewer' },
476
+ };
477
+
478
+ /**
479
+ * Failed tool calls grouped the way a lesson would be: the same agent hitting the
480
+ * same failure (tool, class, message signature). `spawns` and `sessions` are what
481
+ * separate a recurring failure from one run's accident.
482
+ */
483
+ function toolErrorPatterns(toolErrors) {
484
+ const groups = new Map();
485
+ for (const e of toolErrors) {
486
+ const c = e.context || {};
487
+ const key = [e.agent || 'unknown', c.tool || 'unknown', c.error_class || 'other', c.message_sig || c.message || ''].join('\u0000');
488
+ let g = groups.get(key);
489
+ if (!g) {
490
+ g = { agent: e.agent || 'unknown', tool: c.tool || 'unknown', error_class: c.error_class || 'other', exit_code: c.exit_code == null ? null : c.exit_code, message: c.message || '', occurrences: 0, spawns: new Set(), sessions: new Set() };
491
+ groups.set(key, g);
492
+ }
493
+ g.occurrences += typeof c.count === 'number' && c.count > 0 ? c.count : 1;
494
+ g.spawns.add(c.agent_id || `${e.session}|${e.ts}`);
495
+ if (e.session) g.sessions.add(e.session);
496
+ }
497
+ return [...groups.values()]
498
+ .map((g) => ({ ...g, spawns: g.spawns.size, sessions: g.sessions.size }))
499
+ .sort((a, b) => b.spawns - a.spawns || b.occurrences - a.occurrences);
500
+ }
501
+
502
+ /** Per-judge verdict counts, retries and the retries that turned a failure into a pass. */
503
+ function verdictStats(events) {
504
+ const stats = {};
505
+ const bump = (agent) => (stats[agent] = stats[agent] || { pass: 0, fail: 0, needs_human: 0, retries: 0, resolved_by_retry: 0 });
506
+ for (const e of events) {
507
+ const v = VERDICT_CATEGORIES[e.category];
508
+ if (v) {
509
+ const agent = (e.context && e.context.agent) || v.agent || e.agent || 'unknown';
510
+ bump(agent)[v.outcome]++;
511
+ } else if (e.type === 'correction' && e.category === 'verdict_retry') {
512
+ const agent = (e.context && e.context.agent) || e.agent || 'unknown';
513
+ const s = bump(agent);
514
+ s.retries++;
515
+ if (e.context && e.context.outcome === 'pass') s.resolved_by_retry++;
516
+ }
517
+ }
518
+ return stats;
519
+ }
520
+
462
521
  function analyzeEvents(events, sessionMeta) {
463
522
  const errors = events.filter(e => e.type === 'error');
464
523
  const gaps = events.filter(e => e.type === 'gap');
@@ -466,8 +525,14 @@ function analyzeEvents(events, sessionMeta) {
466
525
  const decisions = events.filter(e => e.type === 'decision');
467
526
  const corrections = events.filter(e => e.type === 'correction');
468
527
  const memoryMisses = events.filter(e => e.type === 'memory_miss');
469
- const reviewerCorrections = events.filter(e => e.type === 'error' && e.category === 'reviewer_correction');
528
+ // The legacy category, or the reviewer's failed verdict as the findings ledger logs it.
529
+ const reviewerCorrections = events.filter(e => e.type === 'error'
530
+ && (e.category === 'reviewer_correction' || (e.category === 'verdict_failed' && ((e.context && e.context.agent) || e.agent) === 'pan-reviewer')));
470
531
  const memoryPrimed = events.filter(e => e.type === 'decision' && e.category === 'memory_primed');
532
+ const toolErrors = events.filter(e => e.type === 'error' && e.category === 'tool_error');
533
+ const toolPatterns = toolErrorPatterns(toolErrors);
534
+ const verdicts = verdictStats(events);
535
+ const measuredSpawns = events.filter(e => e.category === 'agent_completion' && e.context && typeof e.context.tool_errors === 'number');
471
536
 
472
537
  function frequencyMap(arr) {
473
538
  const map = {};
@@ -591,6 +656,12 @@ function analyzeEvents(events, sessionMeta) {
591
656
  wasted_tokens: wastedTokens,
592
657
  reviewer_corrections: reviewerCorrections.length,
593
658
  memory_primed_count: memoryPrimed.length,
659
+ tool_errors: toolPatterns.reduce((n, p) => n + p.occurrences, 0),
660
+ tool_error_patterns: toolPatterns.length,
661
+ spawns_measured: measuredSpawns.length,
662
+ spawns_with_tool_errors: measuredSpawns.filter(e => e.context.tool_errors > 0).length,
663
+ verdict_failures: Object.values(verdicts).reduce((n, s) => n + s.fail, 0),
664
+ verdict_retries: Object.values(verdicts).reduce((n, s) => n + s.retries, 0),
594
665
  total_input_tokens: tokenTotals.input,
595
666
  total_output_tokens: tokenTotals.output,
596
667
  total_cache_read_tokens: tokenTotals.cache_read,
@@ -599,6 +670,8 @@ function analyzeEvents(events, sessionMeta) {
599
670
  timing,
600
671
  overhead,
601
672
  error_patterns: frequencyMap(errors),
673
+ tool_error_patterns: toolPatterns,
674
+ verdict_stats: verdicts,
602
675
  gap_patterns: frequencyMap(gaps),
603
676
  memory_miss_patterns: frequencyMap(memoryMisses),
604
677
  agent_stats: agentStats,
@@ -672,6 +745,54 @@ function parseAutoApplyBlock(reportContent) {
672
745
 
673
746
  // ─── Apply recommendations ────────────────────────────────────────────────────
674
747
 
748
+ // Every apply is recorded so it can be undone exactly (evidence loop EL-11, spec D10).
749
+ // A memory entry is read by every agent in every later run, so a bad one degrades
750
+ // all of them; before these records an append could not even be located afterwards,
751
+ // and running the same report twice appended everything twice.
752
+
753
+ function sha256Hex(s) {
754
+ return require('crypto').createHash('sha256').update(s, 'utf8').digest('hex');
755
+ }
756
+
757
+ /** Hash of a file's content with its line endings normalised: a CRLF checkout of the same bytes matches. */
758
+ function normalisedHash(text) {
759
+ return sha256Hex(String(text).replace(/\r\n/g, '\n'));
760
+ }
761
+
762
+ /** One action's identity, from what the report asked for (not from the text written, which carries a timestamp). */
763
+ function actionSig(action) {
764
+ return sha256Hex([action.type, action.path || action.target || '', action.description || '', action.content || action.suggestion || ''].join('\u0000')).slice(0, 16);
765
+ }
766
+
767
+ function newApplyId(now = new Date()) {
768
+ const p = (n) => String(n).padStart(2, '0');
769
+ const stamp = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}_${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
770
+ return `apl_${stamp}_${require('crypto').randomBytes(2).toString('hex')}`;
771
+ }
772
+
773
+ /** Rows of optimization/applied.jsonl in order; legacy rows (before apply records) have no `kind`. */
774
+ function readApplyLog(cwd) {
775
+ let raw = '';
776
+ try { raw = fs.readFileSync(path.join(getOptimizeDir(cwd), APPLIED_LOG), 'utf-8'); } catch { return []; }
777
+ const rows = [];
778
+ for (const line of raw.split('\n')) {
779
+ if (!line.trim()) continue;
780
+ try { rows.push(JSON.parse(line)); } catch { /* a torn line is skipped */ }
781
+ }
782
+ return rows;
783
+ }
784
+
785
+ /** apply_id → Set of reverted action indices. */
786
+ function revertedIndices(rows) {
787
+ const out = new Map();
788
+ for (const r of rows) {
789
+ if (r.kind !== 'revert' || !r.apply_id) continue;
790
+ if (!out.has(r.apply_id)) out.set(r.apply_id, new Set());
791
+ for (const i of r.reverted || []) out.get(r.apply_id).add(i);
792
+ }
793
+ return out;
794
+ }
795
+
675
796
  function applyReportRecommendations(cwd, reportPath) {
676
797
  let reportContent;
677
798
  try {
@@ -696,11 +817,30 @@ function applyReportRecommendations(cwd, reportPath) {
696
817
  return { applied: [], skipped: [], note: 'Auto-apply block is not a valid array' };
697
818
  }
698
819
 
820
+ // Actions an earlier apply wrote and nobody reverted: applying them again would
821
+ // append the same text a second time.
822
+ const log = readApplyLog(cwd);
823
+ const reverted = revertedIndices(log);
824
+ const live = new Map();
825
+ for (const r of log) {
826
+ if (r.kind !== 'apply' || !Array.isArray(r.actions)) continue;
827
+ const undone = reverted.get(r.apply_id) || new Set();
828
+ for (const a of r.actions) if (a.action_sig && !undone.has(a.i)) live.set(a.action_sig, r.apply_id);
829
+ }
830
+
831
+ const applyId = newApplyId();
699
832
  const applied = [];
700
833
  const skipped = [];
834
+ const records = [];
835
+ const rel = (abs) => toPosixRel(cwd, abs);
836
+ const appendRecorded = (abs, text, action, i) => {
837
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
838
+ fs.appendFileSync(abs, text, 'utf-8');
839
+ records.push({ i, type: action.type, path: rel(abs), kind: 'appended', text, sha256_after: normalisedHash(fs.readFileSync(abs, 'utf-8')), action_sig: actionSig(action) });
840
+ };
701
841
 
702
842
  const resolvedCwd = path.resolve(cwd);
703
- for (const action of actions) {
843
+ actions.forEach((action, i) => {
704
844
  try {
705
845
  // Containment: action.path on memory writes is report/agent-authored and
706
846
  // must stay inside the project. A `../` (or absolute) path otherwise
@@ -709,9 +849,14 @@ function applyReportRecommendations(cwd, reportPath) {
709
849
  const abs = path.resolve(cwd, action.path || '');
710
850
  if (abs !== resolvedCwd && !abs.startsWith(resolvedCwd + path.sep)) {
711
851
  skipped.push({ action, reason: 'path escapes project root — skipped' });
712
- continue;
852
+ return;
713
853
  }
714
854
  }
855
+ const earlier = live.get(actionSig(action));
856
+ if (earlier) {
857
+ skipped.push({ action, reason: `already applied in ${earlier} — skipped (revert that apply to apply it again)` });
858
+ return;
859
+ }
715
860
  if (action.type === 'memory') {
716
861
  // Write new memory entry (skip if file exists to avoid overwriting manual edits)
717
862
  const memPath = path.join(cwd, action.path);
@@ -721,25 +866,21 @@ function applyReportRecommendations(cwd, reportPath) {
721
866
  } catch {
722
867
  fs.mkdirSync(path.dirname(memPath), { recursive: true });
723
868
  fs.writeFileSync(memPath, action.content, 'utf-8');
869
+ records.push({ i, type: action.type, path: rel(memPath), kind: 'created', sha256_after: normalisedHash(fs.readFileSync(memPath, 'utf-8')), action_sig: actionSig(action) });
724
870
  applied.push({ action, result: `Written to ${action.path}` });
725
871
  }
726
872
  } else if (action.type === 'memory_append') {
727
- // Append to existing memory file
728
- const memPath = path.join(cwd, action.path);
729
- fs.mkdirSync(path.dirname(memPath), { recursive: true });
730
- fs.appendFileSync(memPath, '\n' + action.content, 'utf-8');
873
+ appendRecorded(path.join(cwd, action.path), '\n' + action.content, action, i);
731
874
  applied.push({ action, result: `Appended to ${action.path}` });
732
875
  } else if (action.type === 'note') {
733
876
  // Write a human-readable suggestion note
734
- const notePath = path.join(getOptimizeDir(cwd), 'suggestions.md');
735
877
  const entry = `\n## ${new Date().toISOString()}: ${action.description || 'Suggestion'}\n\n${action.content || action.suggestion || ''}\n\n**Target:** ${action.target || 'unspecified'}\n`;
736
- fs.appendFileSync(notePath, entry);
878
+ appendRecorded(path.join(getOptimizeDir(cwd), 'suggestions.md'), entry, action, i);
737
879
  applied.push({ action, result: 'Suggestion written to optimization/suggestions.md' });
738
880
  } else if (action.type === 'planning_note') {
739
881
  // Write optimization note into .planning/optimization/config-suggestions.md
740
- const notePath = path.join(getOptimizeDir(cwd), 'config-suggestions.md');
741
882
  const entry = `\n## ${new Date().toISOString()}\n${action.content}\n`;
742
- fs.appendFileSync(notePath, entry);
883
+ appendRecorded(path.join(getOptimizeDir(cwd), 'config-suggestions.md'), entry, action, i);
743
884
  applied.push({ action, result: 'Config suggestion recorded' });
744
885
  } else {
745
886
  skipped.push({ action, reason: `Unknown action type: ${action.type}` });
@@ -747,21 +888,108 @@ function applyReportRecommendations(cwd, reportPath) {
747
888
  } catch (e) {
748
889
  skipped.push({ action, reason: e.message });
749
890
  }
750
- }
891
+ });
751
892
 
752
- // Log what was applied for cumulative stats
893
+ // Log what was applied: the counts for cumulative stats, the actions for revert.
753
894
  try {
754
895
  const logEntry = {
755
896
  ts: new Date().toISOString(),
897
+ kind: 'apply',
898
+ apply_id: applyId,
756
899
  report: path.basename(reportPath),
757
900
  applied_count: applied.length,
758
901
  skipped_count: skipped.length,
759
902
  applied_types: applied.map(a => a.action.type),
903
+ actions: records,
760
904
  };
905
+ fs.mkdirSync(getOptimizeDir(cwd), { recursive: true });
761
906
  fs.appendFileSync(path.join(getOptimizeDir(cwd), APPLIED_LOG), JSON.stringify(logEntry) + '\n');
762
907
  } catch {}
763
908
 
764
- return { applied, skipped };
909
+ return { apply_id: applyId, applied, skipped };
910
+ }
911
+
912
+ function toPosixRel(cwd, abs) {
913
+ return path.relative(path.resolve(cwd), abs).split(path.sep).join('/');
914
+ }
915
+
916
+ /**
917
+ * Undo one apply: delete the files it created, cut the text it appended.
918
+ *
919
+ * It refuses, and says why, whenever undoing could destroy someone's work:
920
+ * - a file whose content changed since the apply (the hash ignores line
921
+ * endings, so a CRLF checkout of the same bytes still matches);
922
+ * - a file a LATER unreverted apply also wrote. Reverts go last-in, first-out
923
+ * per file, so an earlier append is never cut from under a later one.
924
+ * A CRLF file is written back CRLF. Legacy log rows (before apply records) cannot
925
+ * be reverted and are reported as such.
926
+ *
927
+ * @param {string} cwd
928
+ * @param {string} which - an apply_id, or 'last'
929
+ */
930
+ function revertApply(cwd, which) {
931
+ const rows = readApplyLog(cwd);
932
+ const reverted = revertedIndices(rows);
933
+ const applies = rows.map((r, seq) => ({ r, seq })).filter(({ r }) => r.kind === 'apply' || !r.kind);
934
+ let target;
935
+ if (which === 'last') {
936
+ target = [...applies].reverse().find(({ r }) => r.kind !== 'apply' || (r.actions || []).some((a) => !(reverted.get(r.apply_id) || new Set()).has(a.i)));
937
+ if (!target) return { error: 'nothing_applied', reason: 'no apply with anything left to revert' };
938
+ } else {
939
+ target = applies.find(({ r }) => r.apply_id === which);
940
+ if (!target) return { error: 'unknown_apply', reason: `no apply ${which} in optimization/${APPLIED_LOG}` };
941
+ }
942
+ const { r: apply, seq } = target;
943
+ if (apply.kind !== 'apply' || !Array.isArray(apply.actions)) {
944
+ return { error: 'not_revertible', reason: `the apply of ${apply.report || 'that report'} at ${apply.ts || 'an unknown time'} predates apply records, so what it wrote cannot be located` };
945
+ }
946
+ const done = reverted.get(apply.apply_id) || new Set();
947
+ const pending = apply.actions.filter((a) => !done.has(a.i));
948
+ if (!pending.length) return { apply_id: apply.apply_id, reverted: [], refused: [], status: 'nothing_to_revert' };
949
+
950
+ const resolvedCwd = path.resolve(cwd);
951
+ const laterOwner = (p) => {
952
+ for (const { r, seq: s } of applies) {
953
+ if (s <= seq || r.kind !== 'apply' || !Array.isArray(r.actions)) continue;
954
+ const undone = reverted.get(r.apply_id) || new Set();
955
+ if (r.actions.some((a) => a.path === p && !undone.has(a.i))) return r.apply_id;
956
+ }
957
+ return null;
958
+ };
959
+
960
+ const out = [];
961
+ const refused = [];
962
+ for (const a of [...pending].sort((x, y) => y.i - x.i)) {
963
+ const abs = path.resolve(cwd, a.path || '');
964
+ if (!a.path || (abs !== resolvedCwd && !abs.startsWith(resolvedCwd + path.sep))) { refused.push({ i: a.i, path: a.path, reason: 'path outside the project' }); continue; }
965
+ const later = laterOwner(a.path);
966
+ if (later) { refused.push({ i: a.i, path: a.path, reason: `a later apply (${later}) also changed this file; revert it first` }); continue; }
967
+ let raw;
968
+ try { raw = fs.readFileSync(abs, 'utf-8'); } catch { refused.push({ i: a.i, path: a.path, reason: 'the file is gone' }); continue; }
969
+ if (normalisedHash(raw) !== a.sha256_after) { refused.push({ i: a.i, path: a.path, reason: 'the file changed since the apply (edited by hand?)' }); continue; }
970
+ try {
971
+ if (a.kind === 'created') {
972
+ fs.unlinkSync(abs);
973
+ } else {
974
+ const crlf = raw.includes('\r\n');
975
+ const norm = raw.replace(/\r\n/g, '\n');
976
+ const text = String(a.text || '').replace(/\r\n/g, '\n');
977
+ if (!text || !norm.endsWith(text)) { refused.push({ i: a.i, path: a.path, reason: 'the appended text is not at the end of the file' }); continue; }
978
+ const kept = norm.slice(0, norm.length - text.length);
979
+ fs.writeFileSync(abs, crlf ? kept.replace(/\n/g, '\r\n') : kept, 'utf-8');
980
+ }
981
+ out.push(a.i);
982
+ } catch (e) {
983
+ refused.push({ i: a.i, path: a.path, reason: e.message });
984
+ }
985
+ }
986
+ if (out.length || refused.length) {
987
+ try {
988
+ fs.appendFileSync(path.join(getOptimizeDir(cwd), APPLIED_LOG), JSON.stringify({ ts: new Date().toISOString(), kind: 'revert', apply_id: apply.apply_id, reverted: out, refused }) + '\n');
989
+ } catch {}
990
+ }
991
+ const remaining = pending.length - out.length;
992
+ return { apply_id: apply.apply_id, reverted: out.sort((x, y) => x - y), refused, status: remaining === 0 ? 'reverted' : (out.length ? 'partial' : 'refused') };
765
993
  }
766
994
 
767
995
  // Derive basic memory actions from a raw JSON analysis when no optimizer agent
@@ -781,6 +1009,26 @@ function deriveActionsFromAnalysis(analysis) {
781
1009
  });
782
1010
  }
783
1011
 
1012
+ // A failure becomes a suggestion only once it recurs across spawns: one run's
1013
+ // accident (a TDD red step, a grep with no match) is not a lesson.
1014
+ const recurring = (analysis.tool_error_patterns || []).filter(p => p.spawns >= 2).slice(0, 3);
1015
+ recurring.forEach(p => {
1016
+ actions.push({
1017
+ type: 'note',
1018
+ description: `Recurring tool failure: ${p.agent} ${p.tool} (${p.error_class})`,
1019
+ content: `${p.agent}'s ${p.tool} call failed with "${p.message}" in ${p.spawns} spawns across ${p.sessions} session(s) (${p.occurrences} times in all). Record the working form of the call, or the precondition it needs, as a memory entry for ${p.agent}.`,
1020
+ target: '.planning/memory/',
1021
+ });
1022
+ });
1023
+ Object.entries(analysis.verdict_stats || {}).filter(([, s]) => s.fail >= 2).forEach(([agent, s]) => {
1024
+ actions.push({
1025
+ type: 'note',
1026
+ description: `Repeated judge failures: ${agent}`,
1027
+ content: `${agent} failed ${s.fail} time(s) (${s.retries} retr${s.retries === 1 ? 'y' : 'ies'}, ${s.resolved_by_retry} resolved by the retry). Read the recorded findings (\`pan-tools findings list --agent ${agent}\`) for the class that keeps recurring before the next phase.`,
1028
+ target: '.planning/memory/',
1029
+ });
1030
+ });
1031
+
784
1032
  if (gap_patterns && gap_patterns.length > 0) {
785
1033
  gap_patterns.slice(0, 3).forEach(p => {
786
1034
  actions.push({
@@ -812,14 +1060,18 @@ function getOptimizeStats(cwd) {
812
1060
  let totalApplied = 0;
813
1061
  let totalSkipped = 0;
814
1062
  let applyRuns = 0;
1063
+ let lastApplyId = null;
1064
+ const revertedRuns = new Set();
815
1065
  try {
816
1066
  const raw = fs.readFileSync(path.join(getOptimizeDir(cwd), APPLIED_LOG), 'utf-8');
817
1067
  raw.trim().split('\n').filter(Boolean).forEach(line => {
818
1068
  try {
819
1069
  const e = JSON.parse(line);
1070
+ if (e.kind === 'revert') { if (e.apply_id && (e.reverted || []).length) revertedRuns.add(e.apply_id); return; }
820
1071
  totalApplied += e.applied_count || 0;
821
1072
  totalSkipped += e.skipped_count || 0;
822
1073
  applyRuns++;
1074
+ if (e.apply_id) lastApplyId = e.apply_id;
823
1075
  } catch {}
824
1076
  });
825
1077
  } catch {}
@@ -832,6 +1084,8 @@ function getOptimizeStats(cwd) {
832
1084
  total_optimizations_applied: totalApplied,
833
1085
  total_skipped: totalSkipped,
834
1086
  apply_runs: applyRuns,
1087
+ reverted_runs: revertedRuns.size,
1088
+ last_apply_id: lastApplyId,
835
1089
  current_session: getCurrentSessionId(cwd),
836
1090
  };
837
1091
  } catch (e) {
@@ -866,20 +1120,81 @@ function cmdOptimizeTrace(cwd, sub, opts, raw) {
866
1120
  }
867
1121
  }
868
1122
 
1123
+ /**
1124
+ * The last `n` trace sessions, newest last, by start time (the session id's own
1125
+ * date order when a session never recorded one).
1126
+ */
1127
+ function recentSessionIds(cwd, n) {
1128
+ let dirs = [];
1129
+ try { dirs = fs.readdirSync(getTracesDir(cwd), { withFileTypes: true }).filter(e => e.isDirectory() && e.name.startsWith('sess_')).map(e => e.name); } catch { return []; }
1130
+ const started = (sid) => {
1131
+ try { return JSON.parse(fs.readFileSync(path.join(getTracesDir(cwd), sid, OPT_SESSION_FILE), 'utf-8')).started_at || ''; } catch { return ''; }
1132
+ };
1133
+ return dirs.map(sid => ({ sid, at: started(sid) }))
1134
+ .sort((a, b) => (a.at || a.sid).localeCompare(b.at || b.sid))
1135
+ .slice(-n)
1136
+ .map(x => x.sid);
1137
+ }
1138
+
1139
+ /**
1140
+ * One analysis over several sessions (`optimize learn --sessions <n>`): a
1141
+ * recommendation should explain failures that recur across runs, not one run's
1142
+ * accident.
1143
+ */
1144
+ function generatePooledReport(cwd, sessionIds) {
1145
+ const events = [];
1146
+ const starts = [];
1147
+ const ends = [];
1148
+ for (const sid of sessionIds) {
1149
+ const s = readTraceSession(cwd, sid);
1150
+ if (s.error) continue;
1151
+ events.push(...s.events);
1152
+ if (s.metadata && s.metadata.started_at) starts.push(s.metadata.started_at);
1153
+ if (s.metadata && s.metadata.ended_at) ends.push(s.metadata.ended_at);
1154
+ }
1155
+ const metadata = {
1156
+ pooled_sessions: sessionIds,
1157
+ started_at: starts.length ? starts.sort()[0] : undefined,
1158
+ ended_at: ends.length ? ends.sort()[ends.length - 1] : undefined,
1159
+ };
1160
+ return {
1161
+ session_id: sessionIds[sessionIds.length - 1],
1162
+ pooled_sessions: sessionIds,
1163
+ generated_at: new Date().toISOString(),
1164
+ metadata,
1165
+ ...analyzeEvents(events, metadata),
1166
+ raw_events: events,
1167
+ };
1168
+ }
1169
+
869
1170
  function cmdOptimizeLearn(cwd, opts, raw) {
870
- const sessionId = opts.sessionId || getCurrentSessionId(cwd);
871
- if (!sessionId) {
872
- output({ error: 'No trace session active. Start one with: pan-tools optimize trace init' }, raw);
1171
+ const pool = opts.sessions == null ? null : Number(opts.sessions);
1172
+ if (pool !== null && (!Number.isInteger(pool) || pool < 1)) {
1173
+ output({ error: '--sessions must be a whole number of sessions (1 or more)' }, raw);
873
1174
  return;
874
1175
  }
875
-
876
- const report = generateLocalReport(cwd, sessionId);
877
- if (report.error) { output(report, raw); return; }
1176
+ let report;
1177
+ let reportName;
1178
+ if (pool !== null && pool > 1) {
1179
+ const ids = recentSessionIds(cwd, pool);
1180
+ if (!ids.length) { output({ error: 'No trace sessions to pool. Sessions appear once a PAN command has run.' }, raw); return; }
1181
+ report = generatePooledReport(cwd, ids);
1182
+ reportName = `pooled-${ids.length}-${ids[ids.length - 1]}-analysis.json`;
1183
+ } else {
1184
+ const sessionId = opts.sessionId || getCurrentSessionId(cwd);
1185
+ if (!sessionId) {
1186
+ output({ error: 'No trace session active. Start one with: pan-tools optimize trace init' }, raw);
1187
+ return;
1188
+ }
1189
+ report = generateLocalReport(cwd, sessionId);
1190
+ if (report.error) { output(report, raw); return; }
1191
+ reportName = `${sessionId}-analysis.json`;
1192
+ }
1193
+ const sessionId = report.session_id;
878
1194
 
879
1195
  // Persist as JSON analysis for the optimizer agent to read
880
1196
  const reportsDir = getReportsDir(cwd);
881
1197
  try { fs.mkdirSync(reportsDir, { recursive: true }); } catch {}
882
- const reportName = `${sessionId}-analysis.json`;
883
1198
  const reportPath = path.join(reportsDir, reportName);
884
1199
  try {
885
1200
  fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n');
@@ -891,8 +1206,11 @@ function cmdOptimizeLearn(cwd, opts, raw) {
891
1206
  output({
892
1207
  session_id: sessionId,
893
1208
  analysis_path: planningRel(OPTIMIZE_DIR, OPT_REPORTS_DIR, reportName),
1209
+ ...(report.pooled_sessions ? { pooled_sessions: report.pooled_sessions } : {}),
894
1210
  summary: report.summary,
895
1211
  top_error_patterns: report.error_patterns.slice(0, 5),
1212
+ top_tool_error_patterns: report.tool_error_patterns.slice(0, 5),
1213
+ verdict_stats: report.verdict_stats,
896
1214
  top_gap_patterns: report.gap_patterns.slice(0, 5),
897
1215
  top_memory_misses: report.memory_miss_patterns.slice(0, 5),
898
1216
  agent_stats: report.agent_stats,
@@ -912,6 +1230,13 @@ function cmdOptimizeApply(cwd, opts, raw) {
912
1230
  output({ report: path.basename(reportPath), ...result }, raw);
913
1231
  }
914
1232
 
1233
+ function cmdOptimizeRevert(cwd, opts, raw) {
1234
+ const which = opts.last ? 'last' : opts.applyId;
1235
+ if (!which) { output({ error: 'Usage: optimize revert <apply_id> | --last' }, raw); return; }
1236
+ const r = revertApply(cwd, which);
1237
+ output(r, raw, r.error ? undefined : r.status);
1238
+ }
1239
+
915
1240
  function cmdOptimizeStats(cwd, raw) {
916
1241
  output(getOptimizeStats(cwd), raw);
917
1242
  }
@@ -1344,6 +1669,9 @@ module.exports = {
1344
1669
  cmdOptimizeTrace,
1345
1670
  cmdOptimizeLearn,
1346
1671
  cmdOptimizeApply,
1672
+ cmdOptimizeRevert,
1673
+ revertApply,
1674
+ readApplyLog,
1347
1675
  cmdOptimizeStats,
1348
1676
  cmdOptimizeList,
1349
1677
  // Path helpers (used by hook)
@@ -22,6 +22,7 @@ const path = require('path');
22
22
  const { output, error, safeReadFile, toPosix } = require('./core.cjs');
23
23
  const { planningPath } = require('./utils.cjs');
24
24
  const { publish } = require('./bus.cjs');
25
+ const { findVerdictBlocks, validateVerdict } = require('./verdict.cjs');
25
26
 
26
27
  const REVIEWS_DIR = 'reviews';
27
28
  const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
@@ -41,12 +42,19 @@ function reviewsDir(cwd) {
41
42
  * Recognized severities (case-insensitive): critical, high, medium, low, info.
42
43
  * Missing severity defaults to `info`.
43
44
  *
45
+ * A report that ends with a valid `pan-verdict` block (verdict.cjs) is read from
46
+ * the block instead. pan-reviewer's report puts its findings in tables under
47
+ * `### Findings`, which the bullet grammar above never matched, so before the
48
+ * block existed the reviewer half of every deep review merged as zero findings.
49
+ *
44
50
  * @param {string} content - Full markdown content
45
51
  * @param {string} source - Label for finding.source (e.g. "reviewer", "hardener")
46
52
  * @returns {Array<Object>}
47
53
  */
48
54
  function parseReviewFindings(content, source) {
49
55
  if (typeof content !== 'string' || !content) return [];
56
+ const fromBlock = findingsFromVerdictBlock(content, source);
57
+ if (fromBlock) return fromBlock;
50
58
  const findings = [];
51
59
  const lines = content.split('\n');
52
60
  let inFindings = false;
@@ -81,6 +89,34 @@ function parseReviewFindings(content, source) {
81
89
  return findings;
82
90
  }
83
91
 
92
+ /**
93
+ * The findings of a report's last `pan-verdict` block, in this module's finding
94
+ * shape; null when the report has no usable block (then the bullet grammar runs).
95
+ * `where` is `path[:line]`; the class becomes the category.
96
+ */
97
+ function findingsFromVerdictBlock(content, source) {
98
+ const blocks = findVerdictBlocks(content);
99
+ if (!blocks.length) return null;
100
+ const last = blocks[blocks.length - 1];
101
+ if (!last.closed) return null;
102
+ let parsed;
103
+ try { parsed = JSON.parse(last.body); } catch { return null; }
104
+ const r = validateVerdict(parsed);
105
+ if (!r.ok) return null;
106
+ return r.verdict.findings.map((f) => {
107
+ const m = f.where ? /^(.*?)(?::(\d+))?$/.exec(f.where) : null;
108
+ return {
109
+ source,
110
+ severity: f.severity,
111
+ category: f.class,
112
+ description: f.summary,
113
+ file: m ? m[1] : null,
114
+ line: m && m[2] ? Number(m[2]) : null,
115
+ rationale: null,
116
+ };
117
+ });
118
+ }
119
+
84
120
  /**
85
121
  * Merge findings from the three reviewers into a consolidated list plus
86
122
  * a conflict table. A "conflict" is when the meta-reviewer explicitly
@@ -20,6 +20,7 @@ const {
20
20
  PROGRESS_BAR_WIDTH,
21
21
  FILLED_BLOCK,
22
22
  EMPTY_BLOCK,
23
+ STATE_CONTRACT,
23
24
  } = require('./constants.cjs');
24
25
  const {
25
26
  planningPath,
@@ -51,6 +52,7 @@ function cmdStateLoad(cwd, raw) {
51
52
  const stateExists = stateRaw.length > 0;
52
53
 
53
54
  const result = {
55
+ contract: STATE_CONTRACT,
54
56
  config,
55
57
  state_raw: stateRaw,
56
58
  state_exists: stateExists,
@@ -976,12 +978,15 @@ function cmdStateJson(cwd, raw) {
976
978
 
977
979
  if (!frontmatter || Object.keys(frontmatter).length === 0) {
978
980
  const body = stripFrontmatter(content).replace(/\r\n/g, '\n');
979
- const built = buildStateFrontmatter(body, cwd);
981
+ const built = { ...buildStateFrontmatter(body, cwd), contract: STATE_CONTRACT };
980
982
  output(built, raw, JSON.stringify(built, null, 2));
981
983
  return;
982
984
  }
983
985
 
984
- output(frontmatter, raw, JSON.stringify(frontmatter, null, 2));
986
+ // `contract` is the output's shape version (STATE_CONTRACT), never a state.md
987
+ // field: PAN's state frontmatter has no such key, so nothing is shadowed.
988
+ const withContract = { ...frontmatter, contract: STATE_CONTRACT };
989
+ output(withContract, raw, JSON.stringify(withContract, null, 2));
985
990
  }
986
991
 
987
992
  /**