pan-wizard 3.25.0 → 3.27.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 +1 -1
- package/bin/install-lib.cjs +283 -1
- package/bin/install.js +127 -0
- package/commands/pan/hygiene.md +14 -8
- package/commands/pan/milestone-audit.md +10 -4
- 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 +3 -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 +27 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +28 -0
- package/pan-wizard-core/bin/lib/core.cjs +190 -26
- package/pan-wizard-core/bin/lib/cost.cjs +0 -1
- 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/hud.cjs +1 -1
- package/pan-wizard-core/bin/lib/hygiene.cjs +397 -37
- package/pan-wizard-core/bin/lib/init.cjs +90 -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/suggest.cjs +141 -0
- 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-deploy.cjs +113 -2
- package/pan-wizard-core/bin/lib/verify.cjs +4 -3
- package/pan-wizard-core/bin/lib/whatif.cjs +0 -1
- package/pan-wizard-core/bin/pan-tools.cjs +97 -7
- package/pan-wizard-core/mcp/native-tools.cjs +159 -0
- package/pan-wizard-core/mcp/orchestrator.cjs +179 -0
- package/{pan-zcode → pan-wizard-core}/mcp/server.cjs +35 -6
- package/{pan-zcode → pan-wizard-core}/mcp/tool-registry.cjs +60 -3
- package/pan-wizard-core/workflows/milestone-audit.md +35 -6
- package/pan-wizard-core/workflows/verify-phase.md +25 -6
- package/pan-zcode/README.md +17 -9
- package/pan-zcode/bin/install-zcode.js +4 -1
- package/scripts/build-plugin.js +35 -3
- package/scripts/deprecate-old-versions.js +225 -0
- package/scripts/plugin-path.js +84 -0
- package/pan-wizard-core/learnings/internal/.gitkeep +0 -2
- package/pan-wizard-core/learnings/internal/experiment-runner.md +0 -81
- package/pan-wizard-core/learnings/internal/external-research.md +0 -105
- package/pan-wizard-core/learnings/internal/loop-design.md +0 -33
- package/pan-wizard-core/learnings/internal/pan-dev-bugs.md +0 -181
- package/pan-zcode/mcp/native-tools.cjs +0 -63
- package/pan-zcode/mcp/orchestrator.cjs +0 -66
- /package/{pan-zcode → pan-wizard-core}/mcp/merge-gate.cjs +0 -0
|
@@ -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,
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Command suggestions for unknown invocations — "did you mean …".
|
|
5
|
+
*
|
|
6
|
+
* ─── WHY ────────────────────────────────────────────────────────────────────
|
|
7
|
+
*
|
|
8
|
+
* An external harness ledger (2026-08-15) recorded `pan-tools trace …` **18
|
|
9
|
+
* times** across three projects — the single most-repeated agent behaviour it
|
|
10
|
+
* had seen. PAN refused correctly every time:
|
|
11
|
+
*
|
|
12
|
+
* Error: Unknown command: trace. Run pan-tools --help to see available commands.
|
|
13
|
+
*
|
|
14
|
+
* The docs were investigated and cleared: `tests/doc-command-surface.test.cjs`
|
|
15
|
+
* passes, and no shipped surface teaches the bare form. So no prose change could
|
|
16
|
+
* explain those 18 sightings, and none would prevent the 19th.
|
|
17
|
+
*
|
|
18
|
+
* What PAN *can* fix is the RECOVERY. `trace` is not a nonsense token — it is a
|
|
19
|
+
* real subcommand sitting one namespace away, under `optimize`. Answering
|
|
20
|
+
* "unknown, go read the list of sixty commands" throws that away and costs a
|
|
21
|
+
* round trip. Naming the correct form converts a dead end into a self-correction,
|
|
22
|
+
* and it does so whatever made the caller type it — which matters precisely
|
|
23
|
+
* because the cause could not be established.
|
|
24
|
+
*
|
|
25
|
+
* ─── HOW IT STAYS TRUE ──────────────────────────────────────────────────────
|
|
26
|
+
*
|
|
27
|
+
* The group→subcommand index is NOT hand-maintained. It is parsed from the
|
|
28
|
+
* dispatcher's own `Unknown <group> subcommand. Available: …` error strings,
|
|
29
|
+
* which are load-bearing — they are what a user sees — so they cannot rot
|
|
30
|
+
* quietly. This is the same trick `tests/doc-command-surface.test.cjs` uses, kept
|
|
31
|
+
* here rather than duplicated as a second list that would drift from the first.
|
|
32
|
+
*
|
|
33
|
+
* The parse happens ONLY on the error path, so a healthy invocation pays nothing,
|
|
34
|
+
* and every step fails open: if the source cannot be read or nothing matches, the
|
|
35
|
+
* caller falls back to the plain message it would have printed anyway.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract `group → [subcommands]` from dispatcher source text.
|
|
40
|
+
*
|
|
41
|
+
* Pure, so it can be tested against both the real dispatcher and fixtures.
|
|
42
|
+
* Tolerates trailing usage hints in the list (e.g. `clean [--apply] …`) by
|
|
43
|
+
* keeping only the leading bare token of each entry.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} sourceText
|
|
46
|
+
* @returns {Object<string, string[]>}
|
|
47
|
+
*/
|
|
48
|
+
function buildSubcommandIndex(sourceText) {
|
|
49
|
+
const index = {};
|
|
50
|
+
if (typeof sourceText !== 'string') return index;
|
|
51
|
+
const re = /Unknown ([a-z][a-z-]*) subcommand\. Available: ([^'"`\n]+)/g;
|
|
52
|
+
let m;
|
|
53
|
+
while ((m = re.exec(sourceText)) !== null) {
|
|
54
|
+
const group = m[1];
|
|
55
|
+
const subs = m[2]
|
|
56
|
+
.split(',')
|
|
57
|
+
.map((s) => s.trim().split(/\s+/)[0]) // drop " [--apply]"-style hints
|
|
58
|
+
.filter((s) => /^[a-z][a-z0-9-]*$/.test(s));
|
|
59
|
+
if (subs.length) index[group] = subs;
|
|
60
|
+
}
|
|
61
|
+
return index;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Levenshtein distance, iterative and allocation-light. */
|
|
65
|
+
function editDistance(a, b) {
|
|
66
|
+
if (a === b) return 0;
|
|
67
|
+
if (!a.length) return b.length;
|
|
68
|
+
if (!b.length) return a.length;
|
|
69
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
70
|
+
for (let i = 1; i <= a.length; i++) {
|
|
71
|
+
const cur = [i];
|
|
72
|
+
for (let j = 1; j <= b.length; j++) {
|
|
73
|
+
cur[j] = Math.min(
|
|
74
|
+
prev[j] + 1,
|
|
75
|
+
cur[j - 1] + 1,
|
|
76
|
+
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
prev = cur;
|
|
80
|
+
}
|
|
81
|
+
return prev[b.length];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Suggest corrections for an unknown top-level command.
|
|
86
|
+
*
|
|
87
|
+
* Two kinds, most useful first:
|
|
88
|
+
* 1. **Namespace miss** — the token IS a real subcommand of one or more groups.
|
|
89
|
+
* This is the `trace` case, and the reason this module exists.
|
|
90
|
+
* 2. **Typo** — close to a real top-level command by edit distance.
|
|
91
|
+
*
|
|
92
|
+
* The threshold scales with length so short commands do not attract noise
|
|
93
|
+
* (`cost` vs `hud` should not match) while longer ones tolerate one slip.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} name the unknown token
|
|
96
|
+
* @param {Object} index from buildSubcommandIndex
|
|
97
|
+
* @param {string[]} topLevel known top-level command names
|
|
98
|
+
* @returns {string[]} suggestion lines, empty when nothing is close enough
|
|
99
|
+
*/
|
|
100
|
+
function suggestCommand(name, index, topLevel) {
|
|
101
|
+
if (!name || typeof name !== 'string') return [];
|
|
102
|
+
const token = name.trim().toLowerCase();
|
|
103
|
+
if (!token) return [];
|
|
104
|
+
const out = [];
|
|
105
|
+
|
|
106
|
+
// 1. Namespace misses. Deterministically ordered so the message is stable.
|
|
107
|
+
const owners = Object.keys(index || {})
|
|
108
|
+
.filter((group) => (index[group] || []).includes(token))
|
|
109
|
+
.sort();
|
|
110
|
+
for (const group of owners) out.push(`pan-tools ${group} ${token}`);
|
|
111
|
+
|
|
112
|
+
// 2. Typos against top-level commands — only when the token is not already a
|
|
113
|
+
// known subcommand, so a correct-but-misplaced token is never muddied by
|
|
114
|
+
// spelling guesses.
|
|
115
|
+
if (out.length === 0 && Array.isArray(topLevel)) {
|
|
116
|
+
const max = token.length <= 4 ? 1 : 2;
|
|
117
|
+
const near = topLevel
|
|
118
|
+
.filter((c) => typeof c === 'string' && c !== token)
|
|
119
|
+
.map((c) => ({ c, d: editDistance(token, c) }))
|
|
120
|
+
.filter((x) => x.d <= max)
|
|
121
|
+
.sort((a, b) => a.d - b.d || a.c.localeCompare(b.c))
|
|
122
|
+
.slice(0, 3)
|
|
123
|
+
.map((x) => `pan-tools ${x.c}`);
|
|
124
|
+
out.push(...near);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Render the suggestions as the tail of an error message.
|
|
131
|
+
* Returns '' when there is nothing to add, so callers can concatenate blindly.
|
|
132
|
+
*/
|
|
133
|
+
function formatSuggestions(suggestions) {
|
|
134
|
+
if (!Array.isArray(suggestions) || suggestions.length === 0) return '';
|
|
135
|
+
// Trailing period matters: the caller appends more prose, and without it the
|
|
136
|
+
// message ran together as "…optimize trace Run pan-tools --help".
|
|
137
|
+
if (suggestions.length === 1) return ` Did you mean: ${suggestions[0]}.`;
|
|
138
|
+
return ` Did you mean one of: ${suggestions.join(' | ')}.`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { buildSubcommandIndex, suggestCommand, formatSuggestions, editDistance };
|
|
@@ -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,
|