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