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.
Files changed (61) hide show
  1. package/README.md +1 -1
  2. package/bin/install-lib.cjs +283 -1
  3. package/bin/install.js +127 -0
  4. package/commands/pan/hygiene.md +14 -8
  5. package/commands/pan/milestone-audit.md +10 -4
  6. package/hooks/dist/pan-cost-logger.js +69 -5
  7. package/hooks/dist/pan-stop-guard.js +32 -1
  8. package/hooks/dist/pan-trace-logger.js +35 -2
  9. package/package.json +3 -2
  10. package/pan-wizard-core/bin/lib/bridge.cjs +0 -1
  11. package/pan-wizard-core/bin/lib/bus.cjs +0 -1
  12. package/pan-wizard-core/bin/lib/campaign.cjs +3 -2
  13. package/pan-wizard-core/bin/lib/commands-learnings.cjs +8 -8
  14. package/pan-wizard-core/bin/lib/commands.cjs +15 -14
  15. package/pan-wizard-core/bin/lib/config.cjs +5 -5
  16. package/pan-wizard-core/bin/lib/constants.cjs +27 -0
  17. package/pan-wizard-core/bin/lib/context-budget.cjs +28 -0
  18. package/pan-wizard-core/bin/lib/core.cjs +190 -26
  19. package/pan-wizard-core/bin/lib/cost.cjs +0 -1
  20. package/pan-wizard-core/bin/lib/distill.cjs +3 -3
  21. package/pan-wizard-core/bin/lib/focus.cjs +16 -16
  22. package/pan-wizard-core/bin/lib/hud.cjs +1 -1
  23. package/pan-wizard-core/bin/lib/hygiene.cjs +397 -37
  24. package/pan-wizard-core/bin/lib/init.cjs +90 -13
  25. package/pan-wizard-core/bin/lib/knowledge.cjs +0 -1
  26. package/pan-wizard-core/bin/lib/memory.cjs +1 -1
  27. package/pan-wizard-core/bin/lib/milestone.cjs +3 -3
  28. package/pan-wizard-core/bin/lib/optimize.cjs +3 -3
  29. package/pan-wizard-core/bin/lib/phase.cjs +4 -4
  30. package/pan-wizard-core/bin/lib/planning-root.cjs +327 -0
  31. package/pan-wizard-core/bin/lib/preview.cjs +0 -1
  32. package/pan-wizard-core/bin/lib/review-deep.cjs +0 -1
  33. package/pan-wizard-core/bin/lib/roadmap.cjs +1 -1
  34. package/pan-wizard-core/bin/lib/state-compact.cjs +339 -0
  35. package/pan-wizard-core/bin/lib/state.cjs +0 -1
  36. package/pan-wizard-core/bin/lib/suggest.cjs +141 -0
  37. package/pan-wizard-core/bin/lib/template.cjs +1 -1
  38. package/pan-wizard-core/bin/lib/utils.cjs +39 -11
  39. package/pan-wizard-core/bin/lib/verify-deploy.cjs +113 -2
  40. package/pan-wizard-core/bin/lib/verify.cjs +4 -3
  41. package/pan-wizard-core/bin/lib/whatif.cjs +0 -1
  42. package/pan-wizard-core/bin/pan-tools.cjs +97 -7
  43. package/pan-wizard-core/mcp/native-tools.cjs +159 -0
  44. package/pan-wizard-core/mcp/orchestrator.cjs +179 -0
  45. package/{pan-zcode → pan-wizard-core}/mcp/server.cjs +35 -6
  46. package/{pan-zcode → pan-wizard-core}/mcp/tool-registry.cjs +60 -3
  47. package/pan-wizard-core/workflows/milestone-audit.md +35 -6
  48. package/pan-wizard-core/workflows/verify-phase.md +25 -6
  49. package/pan-zcode/README.md +17 -9
  50. package/pan-zcode/bin/install-zcode.js +4 -1
  51. package/scripts/build-plugin.js +35 -3
  52. package/scripts/deprecate-old-versions.js +225 -0
  53. package/scripts/plugin-path.js +84 -0
  54. package/pan-wizard-core/learnings/internal/.gitkeep +0 -2
  55. package/pan-wizard-core/learnings/internal/experiment-runner.md +0 -81
  56. package/pan-wizard-core/learnings/internal/external-research.md +0 -105
  57. package/pan-wizard-core/learnings/internal/loop-design.md +0 -33
  58. package/pan-wizard-core/learnings/internal/pan-dev-bugs.md +0 -181
  59. package/pan-zcode/mcp/native-tools.cjs +0 -63
  60. package/pan-zcode/mcp/orchestrator.cjs +0 -66
  61. /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
- PLANNING_DIR, PHASES_DIR, PLAN_SUFFIX, SUMMARY_SUFFIX, VERIFICATION_SUFFIX,
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 the absolute path to the .planning directory.
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
- * @returns {string} Absolute path to .planning/
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 planningPath(cwd) {
52
- return path.join(cwd, PLANNING_DIR);
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 .planning/phases/
85
+ * @returns {string} Absolute path to <planning-root>/phases/
59
86
  */
60
87
  function phasesPath(cwd) {
61
- return path.join(cwd, PLANNING_DIR, PHASES_DIR);
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 .planning/milestones/
94
+ * @returns {string} Absolute path to <planning-root>/milestones/
68
95
  */
69
96
  function milestonesPath(cwd) {
70
- return path.join(cwd, PLANNING_DIR, MILESTONES_DIR);
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,