pan-wizard 3.26.0 → 3.28.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 +48 -48
- package/agents/pan-previewer.md +1 -1
- package/bin/install-lib.cjs +580 -18
- package/bin/install.js +25 -44
- package/commands/pan/army.md +1 -1
- package/commands/pan/hygiene.md +14 -8
- package/commands/pan/milestone-audit.md +10 -4
- package/commands/pan/preview.md +2 -2
- package/hooks/dist/pan-cost-logger.js +69 -5
- package/hooks/dist/pan-stop-guard.js +32 -1
- package/hooks/dist/pan-trace-logger.js +35 -2
- package/package.json +5 -2
- package/pan-wizard-core/bin/lib/bridge.cjs +0 -1
- package/pan-wizard-core/bin/lib/bus.cjs +0 -1
- package/pan-wizard-core/bin/lib/campaign.cjs +3 -2
- package/pan-wizard-core/bin/lib/commands-learnings.cjs +8 -8
- package/pan-wizard-core/bin/lib/commands.cjs +15 -14
- package/pan-wizard-core/bin/lib/config.cjs +5 -5
- package/pan-wizard-core/bin/lib/constants.cjs +49 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +98 -0
- package/pan-wizard-core/bin/lib/core.cjs +190 -26
- package/pan-wizard-core/bin/lib/cost.cjs +113 -11
- package/pan-wizard-core/bin/lib/distill.cjs +3 -3
- package/pan-wizard-core/bin/lib/focus.cjs +16 -16
- package/pan-wizard-core/bin/lib/foreign-planning.cjs +56 -0
- package/pan-wizard-core/bin/lib/hud.cjs +1 -1
- package/pan-wizard-core/bin/lib/hygiene.cjs +428 -37
- package/pan-wizard-core/bin/lib/init.cjs +98 -13
- package/pan-wizard-core/bin/lib/knowledge.cjs +0 -1
- package/pan-wizard-core/bin/lib/memory.cjs +1 -1
- package/pan-wizard-core/bin/lib/milestone.cjs +3 -3
- package/pan-wizard-core/bin/lib/optimize.cjs +3 -3
- package/pan-wizard-core/bin/lib/phase.cjs +4 -4
- package/pan-wizard-core/bin/lib/planning-root.cjs +327 -0
- package/pan-wizard-core/bin/lib/preview.cjs +0 -1
- package/pan-wizard-core/bin/lib/review-deep.cjs +0 -1
- package/pan-wizard-core/bin/lib/roadmap.cjs +1 -1
- package/pan-wizard-core/bin/lib/state-compact.cjs +339 -0
- package/pan-wizard-core/bin/lib/state.cjs +0 -1
- package/pan-wizard-core/bin/lib/template.cjs +1 -1
- package/pan-wizard-core/bin/lib/utils.cjs +39 -11
- package/pan-wizard-core/bin/lib/verify.cjs +26 -5
- package/pan-wizard-core/bin/lib/whatif.cjs +0 -1
- package/pan-wizard-core/bin/pan-tools.cjs +58 -4
- package/pan-wizard-core/mcp/server.cjs +92 -8
- package/pan-wizard-core/mcp/tool-registry.cjs +50 -3
- package/pan-wizard-core/references/model-profiles.md +2 -2
- package/pan-wizard-core/workflows/health.md +1 -0
- package/pan-wizard-core/workflows/milestone-audit.md +35 -6
- package/pan-zcode/README.md +1 -1
- package/scripts/build-agent-plugin.js +220 -0
- package/scripts/build-plugin.js +48 -3
- package/scripts/generate-skills-docs.py +1 -1
- package/scripts/release-check.js +58 -12
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
-
const {
|
|
7
|
+
const { PHASES_DIR, ROADMAP_FILE, REQUIREMENTS_FILE, isPlanFile, isSummaryFile, isContextFile, isResearchFile, PHASE_HEADER_RE, getPlanId, getSummaryId } = require('./constants.cjs');
|
|
8
8
|
const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, classifyPhaseStatus } = require('./utils.cjs');
|
|
9
9
|
const { escapeRegex, normalizePhaseName, output, error, findPhaseInternal } = require('./core.cjs');
|
|
10
10
|
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* state compact — move closed history out of state.md.
|
|
3
|
+
*
|
|
4
|
+
* state.md sits in CACHEABLE_CONTEXT_FILES, so it is re-read into EVERY agent
|
|
5
|
+
* call. Its section writers only ever append (`sectionBody.trimEnd() + entry`),
|
|
6
|
+
* so the file can only grow: a field project reached 54 KB, of which 30 KB was
|
|
7
|
+
* a June session log, a resolved milestone audit, and three phase closures —
|
|
8
|
+
* roughly 7k tokens of settled history re-read on every call for months. Prompt
|
|
9
|
+
* cache reads outweigh generated tokens by about two orders of magnitude in a
|
|
10
|
+
* PAN project, which makes this file the single largest recurring cost.
|
|
11
|
+
*
|
|
12
|
+
* The rule this module encodes: PAN already knows how to bound its other
|
|
13
|
+
* append-only store (`memory compact`); state.md gets the same treatment.
|
|
14
|
+
*
|
|
15
|
+
* Safety, in order of importance:
|
|
16
|
+
* 1. Nothing is ever deleted. Sections are appended to state-history.md
|
|
17
|
+
* FIRST, and only then removed from state.md.
|
|
18
|
+
* 2. `state get <section>` can read ANY heading by name, so a section is
|
|
19
|
+
* archived only when it is unambiguously historical — a dated heading
|
|
20
|
+
* past the retention window, or one that says "closure". Everything else
|
|
21
|
+
* stays, including anything unrecognised.
|
|
22
|
+
* 3. A protected list guards the headings PAN itself reads and writes, so
|
|
23
|
+
* they survive even if a title were to match the historical patterns.
|
|
24
|
+
* 4. Dry-run by default. `--apply` is required to touch either file.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const { output, error } = require('./core.cjs');
|
|
30
|
+
const {
|
|
31
|
+
STATE_FILE,
|
|
32
|
+
STATE_HISTORY_FILE,
|
|
33
|
+
STATE_COMPACT_KEEP_DAYS,
|
|
34
|
+
CHARS_PER_TOKEN,
|
|
35
|
+
} = require('./constants.cjs');
|
|
36
|
+
const { planningPath, planningRel } = require('./utils.cjs');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Headings PAN's own tooling reads or writes. Never archived, whatever else
|
|
40
|
+
* matches — `state.cjs` parses Decisions/Blockers/Session by name, and the
|
|
41
|
+
* remainder are the live working set a resumed session depends on.
|
|
42
|
+
*/
|
|
43
|
+
const PROTECTED_HEADINGS = [
|
|
44
|
+
/^decisions/i,
|
|
45
|
+
/^accumulated/i,
|
|
46
|
+
/^blockers/i,
|
|
47
|
+
/^concerns/i,
|
|
48
|
+
/^session\b/i,
|
|
49
|
+
/^next action/i,
|
|
50
|
+
/^phase progress/i,
|
|
51
|
+
/^project reference/i,
|
|
52
|
+
/^source authority/i,
|
|
53
|
+
/^toolchain/i,
|
|
54
|
+
/^metrics/i,
|
|
55
|
+
/^current/i,
|
|
56
|
+
/^status/i,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** Marker left in state.md where an archived section used to be. */
|
|
60
|
+
const POINTER_PREFIX = '_Archived to ';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Body fields that `state.cjs::extractFieldsFromState` reads to REGENERATE the
|
|
64
|
+
* frontmatter on every write. It takes the FIRST `**Field:**` match anywhere in
|
|
65
|
+
* the body, so removing a section that carries one can silently change which
|
|
66
|
+
* value wins — a compaction that quietly rewrites `status:` or `Current Phase`
|
|
67
|
+
* would be exactly the kind of invisible damage this tool must not do.
|
|
68
|
+
*
|
|
69
|
+
* A section carrying any of these is therefore never archived, regardless of
|
|
70
|
+
* how historical its heading looks.
|
|
71
|
+
*/
|
|
72
|
+
const FRONTMATTER_SOURCE_FIELDS = [
|
|
73
|
+
'Current Phase', 'Current Phase Name', 'Current Plan', 'Total Phases',
|
|
74
|
+
'Total Plans in Phase', 'Status', 'Progress', 'Last Activity',
|
|
75
|
+
'Last Activity Description', 'Stopped At', 'Stopped at', 'Paused At',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const FRONTMATTER_SOURCE_RE = new RegExp(
|
|
79
|
+
'\\*\\*(?:' + FRONTMATTER_SOURCE_FIELDS.map(f => f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + '):\\*\\*',
|
|
80
|
+
'i'
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
/** A heading that opens with an ISO date: "## 2026-06-04 — Post-milestone session". */
|
|
84
|
+
const DATED_HEADING_RE = /^\s*(\d{4}-\d{2}-\d{2})\b/;
|
|
85
|
+
|
|
86
|
+
/** A heading recording a closed unit of work: "## Phase 3 closure" / "## Phase 3 closed". */
|
|
87
|
+
const CLOSURE_HEADING_RE = /\b(closure|closures|closed)\b/i;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Split state.md into its preamble and top-level (H2) sections.
|
|
91
|
+
*
|
|
92
|
+
* Only `## ` headings split the file; deeper headings belong to their parent
|
|
93
|
+
* section, because `state.cjs` writes Decisions/Blockers as `###` nested inside
|
|
94
|
+
* an H2 and splitting on those would tear a live section in half.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} content - full state.md text
|
|
97
|
+
* @returns {{preamble: string, sections: Array<{title: string, text: string, start: number}>}}
|
|
98
|
+
*/
|
|
99
|
+
function splitSections(content) {
|
|
100
|
+
const lines = String(content || '').split(/\r?\n/);
|
|
101
|
+
const heads = [];
|
|
102
|
+
lines.forEach((line, i) => {
|
|
103
|
+
if (/^##(?!#)\s+/.test(line)) heads.push({ i, title: line.replace(/^##\s+/, '').trim() });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const preamble = lines.slice(0, heads.length ? heads[0].i : lines.length).join('\n');
|
|
107
|
+
const sections = heads.map((h, k) => {
|
|
108
|
+
const end = k + 1 < heads.length ? heads[k + 1].i : lines.length;
|
|
109
|
+
return { title: h.title, text: lines.slice(h.i, end).join('\n'), start: h.i + 1 };
|
|
110
|
+
});
|
|
111
|
+
return { preamble, sections };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Decide whether one section is settled history.
|
|
116
|
+
*
|
|
117
|
+
* @param {{title: string}} section
|
|
118
|
+
* @param {number} keepDays - dated sections newer than this stay
|
|
119
|
+
* @param {number} now - epoch ms, injectable for tests
|
|
120
|
+
* @returns {{archive: boolean, reason: string}}
|
|
121
|
+
*/
|
|
122
|
+
function classifySection(section, keepDays, now) {
|
|
123
|
+
const title = section.title || '';
|
|
124
|
+
const text = section.text || '';
|
|
125
|
+
|
|
126
|
+
// Already compacted: the section is now just a pointer at its archived body.
|
|
127
|
+
// Without this a closure heading would be re-archived on every run, appending
|
|
128
|
+
// the same pointer to state-history.md forever.
|
|
129
|
+
if (text.includes(POINTER_PREFIX)) {
|
|
130
|
+
return { archive: false, reason: 'already archived — only a pointer remains' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (PROTECTED_HEADINGS.some(re => re.test(title))) {
|
|
134
|
+
return { archive: false, reason: 'protected — PAN reads or writes this section' };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (FRONTMATTER_SOURCE_RE.test(section.text || '')) {
|
|
138
|
+
return { archive: false, reason: 'carries a field the frontmatter is rebuilt from — moving it could change state.md metadata' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const dated = title.match(DATED_HEADING_RE);
|
|
142
|
+
if (dated) {
|
|
143
|
+
const t = Date.parse(dated[1]);
|
|
144
|
+
if (!Number.isFinite(t)) return { archive: false, reason: 'unparseable date — left in place' };
|
|
145
|
+
const ageDays = Math.floor((now - t) / (24 * 3600 * 1000));
|
|
146
|
+
if (ageDays > keepDays) {
|
|
147
|
+
return { archive: true, reason: `dated section ${ageDays}d old (keep ${keepDays}d)` };
|
|
148
|
+
}
|
|
149
|
+
return { archive: false, reason: `dated section ${ageDays}d old — within the ${keepDays}d window` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (CLOSURE_HEADING_RE.test(title)) {
|
|
153
|
+
return { archive: true, reason: 'closure record — the work it describes is finished' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { archive: false, reason: 'not recognised as history — left in place' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Rebuild state.md with the named sections replaced by a pointer.
|
|
161
|
+
*
|
|
162
|
+
* Shared by the planner and the applier so a dry-run reports the exact bytes
|
|
163
|
+
* `--apply` will produce, rather than an estimate that could disagree with it.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} content - current state.md text
|
|
166
|
+
* @param {Set<string>} toArchive - section titles to replace
|
|
167
|
+
* @param {string} stamp - YYYY-MM-DD recorded in each pointer
|
|
168
|
+
* @returns {string} the rebuilt file
|
|
169
|
+
*/
|
|
170
|
+
function rebuildState(content, toArchive, stamp) {
|
|
171
|
+
const { preamble, sections } = splitSections(content);
|
|
172
|
+
const parts = [preamble.trimEnd()];
|
|
173
|
+
for (const s of sections) {
|
|
174
|
+
parts.push(toArchive.has(s.title)
|
|
175
|
+
? `## ${s.title}\n\n${POINTER_PREFIX}[${STATE_HISTORY_FILE}](${STATE_HISTORY_FILE}) on ${stamp}._`
|
|
176
|
+
: s.text.trimEnd());
|
|
177
|
+
}
|
|
178
|
+
return parts.filter(Boolean).join('\n\n') + '\n';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Plan a compaction without touching anything.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} cwd - project root
|
|
185
|
+
* @param {Object} [opts] - {keepDays, now}
|
|
186
|
+
* @returns {Object} plan with per-section verdicts and before/after sizes
|
|
187
|
+
*/
|
|
188
|
+
function planStateCompaction(cwd, opts = {}) {
|
|
189
|
+
const keepDays = Number.isFinite(Number(opts.keepDays)) && opts.keepDays !== null && opts.keepDays !== undefined
|
|
190
|
+
? Number(opts.keepDays)
|
|
191
|
+
: STATE_COMPACT_KEEP_DAYS;
|
|
192
|
+
const now = opts.now || Date.now();
|
|
193
|
+
const statePath = planningPath(cwd, STATE_FILE);
|
|
194
|
+
|
|
195
|
+
let content;
|
|
196
|
+
try {
|
|
197
|
+
content = fs.readFileSync(statePath, 'utf-8');
|
|
198
|
+
} catch {
|
|
199
|
+
return { found: false, path: planningRel(STATE_FILE), sections: [], archivable: [], keep_days: keepDays };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const { preamble, sections } = splitSections(content);
|
|
203
|
+
const verdicts = sections.map(s => {
|
|
204
|
+
const c = classifySection(s, keepDays, now);
|
|
205
|
+
return { title: s.title, bytes: s.text.length, archive: c.archive, reason: c.reason };
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
let archivable = verdicts.filter(v => v.archive);
|
|
209
|
+
const bytesBefore = content.length;
|
|
210
|
+
// Build the post-compaction text for real rather than estimating it. An
|
|
211
|
+
// estimate that ignores pointer overhead can claim a saving on a file where
|
|
212
|
+
// the pointers cost more than the sections they replace — a dry-run that
|
|
213
|
+
// overstates its own benefit is worse than no dry-run.
|
|
214
|
+
const stamp = new Date(now).toISOString().slice(0, 10);
|
|
215
|
+
let bytesAfter = rebuildState(content, new Set(archivable.map(v => v.title)), stamp).length;
|
|
216
|
+
|
|
217
|
+
// The point of compaction is a smaller re-read on every agent call. On a file
|
|
218
|
+
// whose history sections are tiny, the pointers left behind cost more than the
|
|
219
|
+
// bodies they replace — churning state.md to make it BIGGER helps nobody, so
|
|
220
|
+
// stand down and say why.
|
|
221
|
+
if (archivable.length > 0 && bytesAfter >= bytesBefore) {
|
|
222
|
+
for (const v of verdicts) {
|
|
223
|
+
if (!v.archive) continue;
|
|
224
|
+
v.archive = false;
|
|
225
|
+
v.reason = 'archiving it would not shrink state.md — the pointer costs more than the section';
|
|
226
|
+
}
|
|
227
|
+
archivable = [];
|
|
228
|
+
bytesAfter = bytesBefore;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
found: true,
|
|
233
|
+
path: planningRel(STATE_FILE),
|
|
234
|
+
history_path: planningRel(STATE_HISTORY_FILE),
|
|
235
|
+
keep_days: keepDays,
|
|
236
|
+
sections: verdicts,
|
|
237
|
+
archivable,
|
|
238
|
+
bytes_before: bytesBefore,
|
|
239
|
+
bytes_after: bytesAfter,
|
|
240
|
+
tokens_before: Math.ceil(bytesBefore / CHARS_PER_TOKEN),
|
|
241
|
+
tokens_after: Math.ceil(bytesAfter / CHARS_PER_TOKEN),
|
|
242
|
+
tokens_saved_per_call: Math.max(0, Math.ceil((bytesBefore - bytesAfter) / CHARS_PER_TOKEN)),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Apply a compaction. Archive is written BEFORE state.md is rewritten, so an
|
|
248
|
+
* interruption can only ever leave a duplicate — never a loss.
|
|
249
|
+
*
|
|
250
|
+
* @param {string} cwd - project root
|
|
251
|
+
* @param {Object} [opts] - {keepDays, now, apply}
|
|
252
|
+
* @returns {Object} result
|
|
253
|
+
*/
|
|
254
|
+
function compactState(cwd, opts = {}) {
|
|
255
|
+
const plan = planStateCompaction(cwd, opts);
|
|
256
|
+
if (!plan.found) return { ...plan, applied: false, dry_run: !opts.apply };
|
|
257
|
+
if (!opts.apply || plan.archivable.length === 0) {
|
|
258
|
+
return { ...plan, applied: false, dry_run: !opts.apply, archived: [] };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const statePath = planningPath(cwd, STATE_FILE);
|
|
262
|
+
const historyPath = planningPath(cwd, STATE_HISTORY_FILE);
|
|
263
|
+
const content = fs.readFileSync(statePath, 'utf-8');
|
|
264
|
+
const { sections } = splitSections(content);
|
|
265
|
+
const now = opts.now || Date.now();
|
|
266
|
+
const stamp = new Date(now).toISOString().slice(0, 10);
|
|
267
|
+
|
|
268
|
+
const toArchive = new Set(plan.archivable.map(v => v.title));
|
|
269
|
+
const archivedText = sections.filter(s => toArchive.has(s.title)).map(s => s.text.trimEnd()).join('\n\n');
|
|
270
|
+
|
|
271
|
+
// 1. Append to history FIRST, so an interruption can only ever duplicate.
|
|
272
|
+
//
|
|
273
|
+
// The preamble is created with an exclusive open rather than an
|
|
274
|
+
// existsSync check. Check-then-write is a time-of-check/time-of-use race
|
|
275
|
+
// (CWE-367): two compactions running together would each see "absent" and
|
|
276
|
+
// each prepend a preamble. `wx` makes creation atomic — EEXIST simply
|
|
277
|
+
// means someone else won, which is the outcome we wanted anyway.
|
|
278
|
+
const preamble = `# State history\n\nSections compacted out of ${STATE_FILE} by \`pan-tools state compact\`.\n`
|
|
279
|
+
+ 'They are kept verbatim and are no longer re-read into agent context.\n';
|
|
280
|
+
try {
|
|
281
|
+
fs.writeFileSync(historyPath, preamble, { flag: 'wx', encoding: 'utf-8' });
|
|
282
|
+
} catch (e) {
|
|
283
|
+
if (e.code !== 'EEXIST') throw e;
|
|
284
|
+
}
|
|
285
|
+
fs.appendFileSync(historyPath,
|
|
286
|
+
`\n\n<!-- compacted from ${STATE_FILE} on ${stamp} -->\n${archivedText}\n`, 'utf-8');
|
|
287
|
+
|
|
288
|
+
// 2. Only now rewrite state.md, replacing each archived section with a pointer.
|
|
289
|
+
const next = rebuildState(content, toArchive, stamp);
|
|
290
|
+
|
|
291
|
+
const { writeStateMd } = require('./state.cjs');
|
|
292
|
+
writeStateMd(statePath, next, cwd);
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
...plan,
|
|
296
|
+
applied: true,
|
|
297
|
+
dry_run: false,
|
|
298
|
+
archived: plan.archivable.map(v => v.title),
|
|
299
|
+
bytes_after: next.length,
|
|
300
|
+
tokens_after: Math.ceil(next.length / CHARS_PER_TOKEN),
|
|
301
|
+
tokens_saved_per_call: Math.max(0, Math.ceil((plan.bytes_before - next.length) / CHARS_PER_TOKEN)),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** CLI wrapper for `state compact`. */
|
|
306
|
+
function cmdStateCompact(cwd, opts, raw) {
|
|
307
|
+
const result = compactState(cwd, opts);
|
|
308
|
+
if (!result.found) {
|
|
309
|
+
error(`${result.path} not found — nothing to compact`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (!raw) return output(result, false);
|
|
313
|
+
|
|
314
|
+
const lines = [];
|
|
315
|
+
lines.push(result.applied
|
|
316
|
+
? `state compact — APPLIED (${result.archived.length} section(s) archived)`
|
|
317
|
+
: `state compact — DRY-RUN (pass --apply to execute)`);
|
|
318
|
+
lines.push(` ${result.path}: ${(result.bytes_before / 1024).toFixed(1)} KB (~${result.tokens_before} tokens) re-read on every agent call`);
|
|
319
|
+
for (const s of result.sections) {
|
|
320
|
+
lines.push(` ${s.archive ? '→' : '·'} ${(s.bytes / 1024).toFixed(1).padStart(6)} KB ${s.title.slice(0, 54)}`);
|
|
321
|
+
lines.push(` ${s.reason}`);
|
|
322
|
+
}
|
|
323
|
+
if (result.archivable.length === 0) {
|
|
324
|
+
lines.push(' Nothing to compact — no settled history past the retention window.');
|
|
325
|
+
} else {
|
|
326
|
+
lines.push(` → ${result.history_path}`);
|
|
327
|
+
lines.push(` after: ~${result.tokens_after} tokens (saves ~${result.tokens_saved_per_call} tokens per agent call)`);
|
|
328
|
+
}
|
|
329
|
+
output(result, true, lines.join('\n'));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = {
|
|
333
|
+
splitSections,
|
|
334
|
+
classifySection,
|
|
335
|
+
planStateCompaction,
|
|
336
|
+
compactState,
|
|
337
|
+
cmdStateCompact,
|
|
338
|
+
PROTECTED_HEADINGS,
|
|
339
|
+
};
|
|
@@ -8,7 +8,6 @@ const { loadConfig, getMilestoneInfo, escapeRegex, safeReadFile, output, error }
|
|
|
8
8
|
const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
|
|
9
9
|
const { withFileLock, writeFileAtomic } = require('./lock.cjs');
|
|
10
10
|
const {
|
|
11
|
-
PLANNING_DIR,
|
|
12
11
|
STATE_FILE,
|
|
13
12
|
ROADMAP_FILE,
|
|
14
13
|
CONFIG_FILE,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const {
|
|
8
|
-
|
|
8
|
+
PHASES_DIR, PLAN_SUFFIX, SUMMARY_SUFFIX, VERIFICATION_SUFFIX,
|
|
9
9
|
SIMPLE_TASK_THRESHOLD, SIMPLE_FILE_THRESHOLD, COMPLEX_TASK_THRESHOLD, COMPLEX_FILE_THRESHOLD,
|
|
10
10
|
} = require('./constants.cjs');
|
|
11
11
|
const { planningPath, phasesPath } = require('./utils.cjs');
|
|
@@ -9,13 +9,13 @@ const fs = require('fs');
|
|
|
9
9
|
const os = require('os');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const {
|
|
12
|
-
PLANNING_DIR,
|
|
13
12
|
PHASES_DIR,
|
|
14
13
|
MILESTONES_DIR,
|
|
15
14
|
isPlanFile,
|
|
16
15
|
isSummaryFile,
|
|
17
16
|
PHASE_DIR_RE,
|
|
18
17
|
} = require('./constants.cjs');
|
|
18
|
+
const { planningRootRel, planningRootAbs } = require('./planning-root.cjs');
|
|
19
19
|
|
|
20
20
|
// ─── File utilities ──────────────────────────────────────────────────────────
|
|
21
21
|
|
|
@@ -44,30 +44,57 @@ function removeQuotes(str) {
|
|
|
44
44
|
// ─── Phase directory utilities ───────────────────────────────────────────────
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* Build
|
|
47
|
+
* Build an absolute path inside the ACTIVE planning root.
|
|
48
|
+
*
|
|
49
|
+
* The root is `.planning/` by default but may be a track (`--track verify`) or
|
|
50
|
+
* any project-relative tree (`--planning-dir`). Every absolute planning path in
|
|
51
|
+
* the codebase goes through here, which is what makes a non-default root
|
|
52
|
+
* reachable at all — see planning-root.cjs.
|
|
53
|
+
*
|
|
48
54
|
* @param {string} cwd - Project root directory
|
|
49
|
-
* @
|
|
55
|
+
* @param {...string} segments - Path segments below the planning root
|
|
56
|
+
* @returns {string} Absolute path
|
|
57
|
+
*/
|
|
58
|
+
function planningPath(cwd, ...segments) {
|
|
59
|
+
return path.join(planningRootAbs(cwd), ...segments.filter(s => s != null && s !== ''));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build a project-relative, POSIX-separated path inside the active planning
|
|
64
|
+
* root — the display form used in command output, findings, and `git add`
|
|
65
|
+
* arguments. Kept in lockstep with planningPath() so what a command reports is
|
|
66
|
+
* the tree it actually touched.
|
|
67
|
+
*
|
|
68
|
+
* @param {...string} segments - Path segments below the planning root
|
|
69
|
+
* @returns {string} e.g. `.planning/tracks/verify/state.md`
|
|
50
70
|
*/
|
|
51
|
-
function
|
|
52
|
-
|
|
71
|
+
function planningRel(...segments) {
|
|
72
|
+
const parts = [planningRootRel()];
|
|
73
|
+
for (const s of segments) {
|
|
74
|
+
if (s == null || s === '') continue;
|
|
75
|
+
for (const seg of String(s).split(/[\\/]+/)) {
|
|
76
|
+
if (seg && seg !== '.') parts.push(seg);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return parts.join('/');
|
|
53
80
|
}
|
|
54
81
|
|
|
55
82
|
/**
|
|
56
|
-
* Build the absolute path to the phases directory.
|
|
83
|
+
* Build the absolute path to the phases directory of the active planning root.
|
|
57
84
|
* @param {string} cwd - Project root directory
|
|
58
|
-
* @returns {string} Absolute path to
|
|
85
|
+
* @returns {string} Absolute path to <planning-root>/phases/
|
|
59
86
|
*/
|
|
60
87
|
function phasesPath(cwd) {
|
|
61
|
-
return
|
|
88
|
+
return planningPath(cwd, PHASES_DIR);
|
|
62
89
|
}
|
|
63
90
|
|
|
64
91
|
/**
|
|
65
|
-
* Build the absolute path to the milestones directory.
|
|
92
|
+
* Build the absolute path to the milestones directory of the active planning root.
|
|
66
93
|
* @param {string} cwd - Project root directory
|
|
67
|
-
* @returns {string} Absolute path to
|
|
94
|
+
* @returns {string} Absolute path to <planning-root>/milestones/
|
|
68
95
|
*/
|
|
69
96
|
function milestonesPath(cwd) {
|
|
70
|
-
return
|
|
97
|
+
return planningPath(cwd, MILESTONES_DIR);
|
|
71
98
|
}
|
|
72
99
|
|
|
73
100
|
/**
|
|
@@ -159,6 +186,7 @@ module.exports = {
|
|
|
159
186
|
readJsonFile,
|
|
160
187
|
removeQuotes,
|
|
161
188
|
planningPath,
|
|
189
|
+
planningRel,
|
|
162
190
|
phasesPath,
|
|
163
191
|
milestonesPath,
|
|
164
192
|
listPhaseDirs,
|
|
@@ -9,18 +9,20 @@ const { safeReadFile, normalizePhaseName, comparePhaseNum, execGit, findPhaseInt
|
|
|
9
9
|
const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
|
|
10
10
|
const { writeStateMd, readStateSafe } = require('./state.cjs');
|
|
11
11
|
const {
|
|
12
|
-
|
|
12
|
+
PHASES_DIR, STATE_FILE, ROADMAP_FILE, REQUIREMENTS_FILE, CONFIG_FILE, PROJECT_FILE, PATTERNS_FILE,
|
|
13
13
|
isPlanFile, isSummaryFile, isVerificationFile, PHASE_HEADER_RE, PHASE_DIR_RE, ARCHIVE_DIR_RE, FIELD_VALUE_RE,
|
|
14
14
|
PLAN_SUFFIX, SUMMARY_SUFFIX, STANDARDS_FILE, STANDARDS_CATALOG, HEALTH_STATUS,
|
|
15
15
|
BUILTIN_DRIFT_RULES, DRIFT_VERDICTS, BINARY_EXTENSIONS, DRIFT_MAX_FILES, DRIFT_MAX_FILE_SIZE, DRIFT_SEVERITY_WEIGHTS,
|
|
16
16
|
} = require('./constants.cjs');
|
|
17
17
|
const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, fileAccessible } = require('./utils.cjs');
|
|
18
|
+
const { detectForeignPlanningTree } = require('./foreign-planning.cjs');
|
|
18
19
|
// Drift detection lives in verify-drift.cjs; re-exported below so consumers of
|
|
19
20
|
// verify.cjs are unaffected by the decomposition.
|
|
20
21
|
const { runDriftCheck, parseConventionRules, checkFileConventions, calculateDriftScore, getChangedFiles, cmdDriftCheck } = require('./verify-drift.cjs');
|
|
21
22
|
const { collectVerificationStats, countRoadmapPhases, groupGapPatterns, cmdRetro } = require('./verify-retro.cjs');
|
|
22
23
|
const { detectInstalledRuntimes, validateRuntimeInstall, cmdValidateDeployment } = require('./verify-deploy.cjs');
|
|
23
24
|
const { cmdPreflight, cmdDepsValidate } = require('./verify-preflight.cjs');
|
|
25
|
+
const { planningRootRel } = require('./planning-root.cjs');
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* Spot-check files mentioned in summary content.
|
|
@@ -715,7 +717,7 @@ function cmdValidateConsistency(cwd, raw) {
|
|
|
715
717
|
*/
|
|
716
718
|
function checkPlanningDirExists(cwd, addIssue) {
|
|
717
719
|
if (!fileAccessible(planningPath(cwd))) {
|
|
718
|
-
addIssue('error', 'E001',
|
|
720
|
+
addIssue('error', 'E001', planningRootRel() + '/ directory not found', 'Run /pan:new-project to initialize');
|
|
719
721
|
return false;
|
|
720
722
|
}
|
|
721
723
|
return true;
|
|
@@ -998,7 +1000,7 @@ function repairIssues(cwd, repairs) {
|
|
|
998
1000
|
const milestone = getMilestoneInfo(cwd);
|
|
999
1001
|
let stateContent = '# Session State\n\n';
|
|
1000
1002
|
stateContent += '## Project Reference\n\n';
|
|
1001
|
-
stateContent += `See: ${
|
|
1003
|
+
stateContent += `See: ${planningRootRel()}/${PROJECT_FILE}\n\n`;
|
|
1002
1004
|
stateContent += '## Position\n\n';
|
|
1003
1005
|
stateContent += `**Milestone:** ${milestone.version} ${milestone.name}\n`;
|
|
1004
1006
|
stateContent += '**Current phase:** (determining...)\n';
|
|
@@ -1292,13 +1294,29 @@ function cmdValidateHealth(cwd, options, raw) {
|
|
|
1292
1294
|
|
|
1293
1295
|
// Check 1: .planning/ exists (fatal if missing -- skip remaining checks)
|
|
1294
1296
|
if (!checkPlanningDirExists(cwd, addIssue)) {
|
|
1297
|
+
// A verdict payload carries `errors[]`, which is OUTSIDE output()'s error family
|
|
1298
|
+
// (plural collections are detail, not a failure signal — see CLI-REFERENCE "Error
|
|
1299
|
+
// Shape"), so the exit code must be set explicitly here, as `reconcile` does.
|
|
1300
|
+
// `broken` → 1. Reality check RC2 (2026-09-10): this site exited 0 for a missing
|
|
1301
|
+
// .planning/, so an orchestrator gating on the exit code read it as healthy.
|
|
1295
1302
|
output({
|
|
1296
1303
|
status: HEALTH_STATUS.BROKEN,
|
|
1297
1304
|
errors,
|
|
1298
1305
|
warnings,
|
|
1299
1306
|
info,
|
|
1300
1307
|
repairable_count: 0,
|
|
1301
|
-
}, raw);
|
|
1308
|
+
}, raw, undefined, 1);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// Check 1b: the tree exists but belongs to another tool (gsd-core shares the
|
|
1313
|
+
// directory name and PAN's legacy uppercase file names). Report that as its own
|
|
1314
|
+
// error and stop: E002-E005 would describe a foreign layout as a broken PAN one,
|
|
1315
|
+
// and --repair must never write into it. Reality check R15.
|
|
1316
|
+
const foreign = detectForeignPlanningTree(planningPath(cwd));
|
|
1317
|
+
if (foreign) {
|
|
1318
|
+
addIssue('error', 'E006', `planning tree belongs to ${foreign.tool}: ${foreign.evidence.join(', ')}`, 'Run PAN with --planning-dir <dir> to use a separate tree (ADR-0043)');
|
|
1319
|
+
output({ status: HEALTH_STATUS.BROKEN, errors, warnings, info, repairable_count: 0 }, raw, undefined, 1);
|
|
1302
1320
|
return;
|
|
1303
1321
|
}
|
|
1304
1322
|
|
|
@@ -1417,7 +1435,10 @@ function cmdValidateHealth(cwd, options, raw) {
|
|
|
1417
1435
|
result.link_graph = linkGraphResult;
|
|
1418
1436
|
}
|
|
1419
1437
|
|
|
1420
|
-
|
|
1438
|
+
// Explicit verdict exit: `broken` → 1; `degraded` and `healthy` → 0 (warnings are
|
|
1439
|
+
// not failures). Computed AFTER --repair ran, so the code reflects the post-repair
|
|
1440
|
+
// state the JSON reports. See the note at the early-return site above.
|
|
1441
|
+
output(result, raw, undefined, status === HEALTH_STATUS.BROKEN ? 1 : 0);
|
|
1421
1442
|
}
|
|
1422
1443
|
|
|
1423
1444
|
/**
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
const fs = require('fs');
|
|
20
20
|
const path = require('path');
|
|
21
21
|
const { output, error, safeReadFile, isGitRepo, execGit, toPosix, findPhaseInternal } = require('./core.cjs');
|
|
22
|
-
const { PLANNING_DIR } = require('./constants.cjs');
|
|
23
22
|
const { planningPath } = require('./utils.cjs');
|
|
24
23
|
|
|
25
24
|
const COUNTERFACTUALS_DIR = 'counterfactuals';
|
|
@@ -128,7 +128,7 @@
|
|
|
128
128
|
* init verify-work <phase> All context for verify-work workflow
|
|
129
129
|
* init phase-op <phase> Generic phase operation context
|
|
130
130
|
* init todos [area] All context for todo workflows
|
|
131
|
-
* init milestone-op
|
|
131
|
+
* init milestone-op [--all-tracks] All context for milestone operations
|
|
132
132
|
* init map-codebase All context for map-codebase workflow
|
|
133
133
|
* init progress All context for progress workflow
|
|
134
134
|
*
|
|
@@ -219,6 +219,8 @@ const docLint = require('./lib/doc-lint.cjs');
|
|
|
219
219
|
const learnLint = require('./lib/learn-lint.cjs');
|
|
220
220
|
const learnIndex = require('./lib/learn-index.cjs');
|
|
221
221
|
const links = require('./lib/links.cjs');
|
|
222
|
+
const { setPlanningRoot, describePlanningRoot } = require('./lib/planning-root.cjs');
|
|
223
|
+
const stateCompact = require('./lib/state-compact.cjs');
|
|
222
224
|
|
|
223
225
|
/**
|
|
224
226
|
* Get the value following a flag in the args array.
|
|
@@ -233,6 +235,30 @@ function getArgValue(args, flag, defaultVal = null) {
|
|
|
233
235
|
return args[idx + 1];
|
|
234
236
|
}
|
|
235
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Read a global `--flag value` / `--flag=value` pair and REMOVE it from args,
|
|
240
|
+
* so per-command parsers never see it. Returns null when the flag is absent.
|
|
241
|
+
*
|
|
242
|
+
* @param {string[]} args - CLI arguments (mutated)
|
|
243
|
+
* @param {string} flag - Flag name (e.g. '--track')
|
|
244
|
+
* @returns {string|null} The flag's value, or null
|
|
245
|
+
*/
|
|
246
|
+
function takeFlagValue(args, flag) {
|
|
247
|
+
const eqArg = args.find(a => a.startsWith(`${flag}=`));
|
|
248
|
+
if (eqArg) {
|
|
249
|
+
const value = eqArg.slice(flag.length + 1).trim();
|
|
250
|
+
if (!value) error(`Missing value for ${flag}`);
|
|
251
|
+
args.splice(args.indexOf(eqArg), 1);
|
|
252
|
+
return value;
|
|
253
|
+
}
|
|
254
|
+
const idx = args.indexOf(flag);
|
|
255
|
+
if (idx === -1) return null;
|
|
256
|
+
const value = args[idx + 1];
|
|
257
|
+
if (!value || value.startsWith('--')) error(`Missing value for ${flag}`);
|
|
258
|
+
args.splice(idx, 2);
|
|
259
|
+
return value;
|
|
260
|
+
}
|
|
261
|
+
|
|
236
262
|
/**
|
|
237
263
|
* Parse JSON string or call error() with a descriptive message.
|
|
238
264
|
* @param {string} raw - Raw JSON string
|
|
@@ -270,6 +296,22 @@ async function main() {
|
|
|
270
296
|
error(`Invalid --cwd: ${cwd}`);
|
|
271
297
|
}
|
|
272
298
|
|
|
299
|
+
// Which planning tree do we act on? `--cwd` moves the PROJECT root; these
|
|
300
|
+
// move the PLANNING root inside it, so a repo holding several planning trees
|
|
301
|
+
// can address any of them. Parsed here, before dispatch, because the answer
|
|
302
|
+
// has to be settled once for every path the command will build.
|
|
303
|
+
const planningDirFlag = takeFlagValue(args, '--planning-dir');
|
|
304
|
+
const trackFlag = takeFlagValue(args, '--track');
|
|
305
|
+
const allTracksIndex = args.indexOf('--all-tracks');
|
|
306
|
+
const allTracks = allTracksIndex !== -1;
|
|
307
|
+
if (allTracks) args.splice(allTracksIndex, 1);
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
setPlanningRoot({ planningDir: planningDirFlag, track: trackFlag });
|
|
311
|
+
} catch (e) {
|
|
312
|
+
error(e.message);
|
|
313
|
+
}
|
|
314
|
+
|
|
273
315
|
const rawIndex = args.indexOf('--raw');
|
|
274
316
|
const raw = rawIndex !== -1;
|
|
275
317
|
if (rawIndex !== -1) args.splice(rawIndex, 1);
|
|
@@ -282,7 +324,13 @@ async function main() {
|
|
|
282
324
|
|
|
283
325
|
const command = args[0];
|
|
284
326
|
|
|
285
|
-
const USAGE = 'Usage: pan-tools <command> [args] [--raw] [--cwd <path>]
|
|
327
|
+
const USAGE = 'Usage: pan-tools <command> [args] [--raw] [--cwd <path>] [--track <name> | --planning-dir <path>] [--all-tracks]\n'
|
|
328
|
+
+ '\nPlanning root (which .planning tree to act on):\n'
|
|
329
|
+
+ ' --track <name> act on .planning/tracks/<name>/ instead of .planning/\n'
|
|
330
|
+
+ ' --planning-dir <path> act on an arbitrary project-relative planning tree\n'
|
|
331
|
+
+ ' --all-tracks (hygiene) act on the root tree AND every discovered track\n'
|
|
332
|
+
+ ' env: PAN_TRACK, PAN_PLANNING_DIR (flags win)\n'
|
|
333
|
+
+ '\nCommands: state, resolve-model, estimate-cost, find-phase, git, distill, experiment, commit, verify-summary, template, frontmatter, verify, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, config-set, config-get, history-digest, phases, roadmap, requirements, phase, milestone, validate, progress, context-budget, todo, scaffold, init, phase-plan-index, state-snapshot, summary-extract, rollback-snapshot, batch-commit, websearch, focus, preflight, dashboard, hud, report, learnings, deps, drift-check, memory, bridge, whatif, knowledge, skills, hygiene, review-deep, preview, cost, models, squad, worktree, campaign, bus, cache, retro, codebase, standards, optimize, doc-lint, learn, links';
|
|
286
334
|
|
|
287
335
|
if (!command) {
|
|
288
336
|
error(USAGE);
|
|
@@ -317,6 +365,11 @@ async function main() {
|
|
|
317
365
|
}
|
|
318
366
|
}
|
|
319
367
|
state.cmdStatePatch(cwd, patches, raw);
|
|
368
|
+
} else if (subcommand === 'compact') {
|
|
369
|
+
stateCompact.cmdStateCompact(cwd, {
|
|
370
|
+
apply: args.includes('--apply'),
|
|
371
|
+
keepDays: getArgValue(args, '--keep-days'),
|
|
372
|
+
}, raw);
|
|
320
373
|
} else if (subcommand === 'advance-plan') {
|
|
321
374
|
state.cmdStateAdvancePlan(cwd, raw);
|
|
322
375
|
} else if (subcommand === 'record-metric') {
|
|
@@ -765,7 +818,7 @@ async function main() {
|
|
|
765
818
|
init.cmdInitTodos(cwd, args[2], raw);
|
|
766
819
|
break;
|
|
767
820
|
case 'milestone-op':
|
|
768
|
-
init.cmdInitMilestoneOp(cwd, raw);
|
|
821
|
+
init.cmdInitMilestoneOp(cwd, raw, { allTracks });
|
|
769
822
|
break;
|
|
770
823
|
case 'map-codebase':
|
|
771
824
|
init.cmdInitMapCodebase(cwd, raw);
|
|
@@ -1087,13 +1140,14 @@ async function main() {
|
|
|
1087
1140
|
const hygieneOpts = {
|
|
1088
1141
|
traceAgeDays: getArgValue(args, '--trace-age-days'),
|
|
1089
1142
|
apply: args.includes('--apply'),
|
|
1143
|
+
allTracks,
|
|
1090
1144
|
};
|
|
1091
1145
|
if (subcommand === 'scan') {
|
|
1092
1146
|
hygiene.cmdHygieneScan(cwd, hygieneOpts, raw);
|
|
1093
1147
|
} else if (subcommand === 'clean') {
|
|
1094
1148
|
hygiene.cmdHygieneClean(cwd, hygieneOpts, raw);
|
|
1095
1149
|
} else {
|
|
1096
|
-
error('Unknown hygiene subcommand. Available: scan, clean [--apply] [--trace-age-days N]');
|
|
1150
|
+
error('Unknown hygiene subcommand. Available: scan, clean [--apply] [--trace-age-days N] [--all-tracks] [--track <name>]');
|
|
1097
1151
|
}
|
|
1098
1152
|
break;
|
|
1099
1153
|
}
|