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.
- package/README.md +28 -24
- package/agents/pan-design-checker.md +18 -0
- package/agents/pan-optimizer.md +5 -1
- package/agents/pan-plan-checker.md +23 -0
- package/agents/pan-reviewer.md +19 -0
- package/agents/pan-verifier.md +23 -0
- package/commands/pan/design-phase.md +8 -0
- package/commands/pan/exec-phase.md +1 -1
- package/commands/pan/git.md +2 -2
- package/commands/pan/learn.md +2 -0
- package/commands/pan/optimize.md +14 -2
- package/commands/pan/review-deep.md +5 -5
- package/hooks/dist/pan-context-monitor.js +1 -1
- package/hooks/dist/pan-cost-logger.js +63 -11
- package/hooks/dist/pan-state-reinject.js +2 -2
- package/hooks/dist/pan-stop-guard.js +2 -2
- package/hooks/dist/pan-trace-logger.js +283 -18
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/commands.cjs +2 -1
- package/pan-wizard-core/bin/lib/constants.cjs +28 -0
- package/pan-wizard-core/bin/lib/cost.cjs +3 -3
- package/pan-wizard-core/bin/lib/findings.cjs +381 -0
- package/pan-wizard-core/bin/lib/focus.cjs +72 -19
- package/pan-wizard-core/bin/lib/foreign-planning.cjs +24 -6
- package/pan-wizard-core/bin/lib/hygiene.cjs +12 -2
- package/pan-wizard-core/bin/lib/init.cjs +4 -1
- package/pan-wizard-core/bin/lib/optimize.cjs +349 -21
- package/pan-wizard-core/bin/lib/review-deep.cjs +36 -0
- package/pan-wizard-core/bin/lib/state.cjs +7 -2
- package/pan-wizard-core/bin/lib/verdict.cjs +431 -0
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -2
- package/pan-wizard-core/bin/lib/verify-scope.cjs +156 -0
- package/pan-wizard-core/bin/lib/verify.cjs +25 -2
- package/pan-wizard-core/bin/pan-tools.cjs +59 -5
- package/pan-wizard-core/learnings/README.md +10 -7
- package/pan-wizard-core/mcp/tool-registry.cjs +2 -0
- package/pan-wizard-core/references/verdict-contract.md +101 -0
- package/pan-wizard-core/templates/verification-report.md +16 -0
- package/pan-wizard-core/workflows/exec-phase.md +65 -47
- package/pan-wizard-core/workflows/health.md +2 -0
- package/pan-wizard-core/workflows/learn.md +4 -2
- package/pan-wizard-core/workflows/milestone-audit.md +15 -3
- package/pan-wizard-core/workflows/optimize.md +14 -1
- package/pan-wizard-core/workflows/plan-phase.md +20 -15
- package/pan-wizard-core/workflows/verify-phase.md +3 -13
- package/pan-zcode/README.md +3 -3
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
871
|
-
if (!
|
|
872
|
-
output({ error: '
|
|
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
|
-
|
|
877
|
-
if (
|
|
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
|
|
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
|
/**
|