pan-wizard 3.21.1 → 3.24.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 +38 -24
- package/agents/pan-conductor.md +3 -3
- package/agents/pan-design-checker.md +83 -0
- package/agents/pan-designer.md +67 -0
- package/agents/pan-document_code.md +3 -2
- package/agents/pan-executor.md +2 -2
- package/agents/pan-plan-checker.md +19 -6
- package/agents/pan-planner.md +4 -1
- package/agents/pan-previewer.md +1 -1
- package/agents/pan-roadmapper.md +3 -1
- package/agents/pan-verifier.md +10 -10
- package/bin/install-lib.cjs +128 -17
- package/bin/install.js +480 -74
- package/commands/pan/army.md +20 -14
- package/commands/pan/audit-deployment.md +17 -14
- package/commands/pan/cost.md +2 -2
- package/commands/pan/debug.md +1 -1
- package/commands/pan/design-phase.md +77 -0
- package/commands/pan/exec-phase.md +2 -2
- package/commands/pan/experiment.md +18 -14
- package/commands/pan/focus-auto.md +5 -5
- package/commands/pan/focus-design.md +13 -1
- package/commands/pan/focus-exec.md +1 -1
- package/commands/pan/learn.md +1 -1
- package/commands/pan/links.md +3 -1
- package/commands/pan/map-codebase.md +4 -4
- package/commands/pan/mcp-bridge.md +1 -1
- package/commands/pan/milestone-new.md +3 -1
- package/commands/pan/optimize.md +2 -2
- package/commands/pan/patches.md +1 -1
- package/commands/pan/plan-phase.md +3 -1
- package/commands/pan/preview.md +3 -3
- package/commands/pan/profile.md +3 -3
- package/commands/pan/research-phase.md +1 -1
- package/commands/pan/retro.md +4 -1
- package/commands/pan/review-deep.md +1 -1
- package/commands/pan/settings.md +2 -2
- package/commands/pan/what-if.md +1 -1
- package/hooks/dist/pan-check-update.js +149 -38
- package/hooks/dist/pan-context-monitor.js +155 -81
- package/hooks/dist/pan-cost-logger.js +288 -13
- package/hooks/dist/pan-statusline.js +24 -8
- package/hooks/dist/pan-stop-guard.js +160 -0
- package/hooks/dist/pan-trace-logger.js +261 -19
- package/package.json +2 -2
- package/pan-wizard-core/bin/lib/bridge.cjs +1 -1
- package/pan-wizard-core/bin/lib/bus.cjs +19 -4
- package/pan-wizard-core/bin/lib/campaign.cjs +8 -3
- package/pan-wizard-core/bin/lib/codebase.cjs +15 -5
- package/pan-wizard-core/bin/lib/commands.cjs +88 -6
- package/pan-wizard-core/bin/lib/config.cjs +27 -2
- package/pan-wizard-core/bin/lib/constants.cjs +1 -0
- package/pan-wizard-core/bin/lib/core.cjs +113 -8
- package/pan-wizard-core/bin/lib/cost.cjs +61 -24
- package/pan-wizard-core/bin/lib/distill.cjs +22 -4
- package/pan-wizard-core/bin/lib/doc-lint.cjs +25 -7
- package/pan-wizard-core/bin/lib/experiment.cjs +25 -1
- package/pan-wizard-core/bin/lib/focus.cjs +42 -7
- package/pan-wizard-core/bin/lib/frontmatter.cjs +62 -31
- package/pan-wizard-core/bin/lib/git.cjs +51 -16
- package/pan-wizard-core/bin/lib/hud.cjs +17 -2
- package/pan-wizard-core/bin/lib/init.cjs +2 -5
- package/pan-wizard-core/bin/lib/knowledge.cjs +20 -1
- package/pan-wizard-core/bin/lib/learn-lint.cjs +50 -0
- package/pan-wizard-core/bin/lib/links.cjs +9 -6
- package/pan-wizard-core/bin/lib/lock.cjs +23 -4
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +101 -17
- package/pan-wizard-core/bin/lib/memory-rebuild.cjs +26 -2
- package/pan-wizard-core/bin/lib/milestone.cjs +11 -5
- package/pan-wizard-core/bin/lib/optimize.cjs +18 -7
- package/pan-wizard-core/bin/lib/phase-remove.cjs +41 -2
- package/pan-wizard-core/bin/lib/phase.cjs +82 -11
- package/pan-wizard-core/bin/lib/preview.cjs +23 -4
- package/pan-wizard-core/bin/lib/review-deep.cjs +14 -6
- package/pan-wizard-core/bin/lib/roadmap.cjs +6 -2
- package/pan-wizard-core/bin/lib/runner.cjs +17 -7
- package/pan-wizard-core/bin/lib/squads.cjs +43 -11
- package/pan-wizard-core/bin/lib/state.cjs +32 -11
- package/pan-wizard-core/bin/lib/template.cjs +8 -3
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +14 -2
- package/pan-wizard-core/bin/lib/verify-drift.cjs +15 -1
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +9 -3
- package/pan-wizard-core/bin/lib/verify.cjs +84 -39
- package/pan-wizard-core/bin/lib/whatif.cjs +8 -1
- package/pan-wizard-core/bin/pan-tools.cjs +42 -19
- package/pan-wizard-core/learnings/index.json +7 -7
- package/pan-wizard-core/learnings/universal/autonomous-loop.md +5 -3
- package/pan-wizard-core/learnings/universal/concurrency.md +1 -1
- package/pan-wizard-core/references/design-methodology.md +94 -0
- package/pan-wizard-core/references/git-integration.md +9 -9
- package/pan-wizard-core/references/guardrails.md +4 -1
- package/pan-wizard-core/references/model-profile-resolution.md +1 -1
- package/pan-wizard-core/references/model-profiles.md +44 -34
- package/pan-wizard-core/references/planning-config.md +5 -5
- package/pan-wizard-core/references/verification-patterns.md +67 -0
- package/pan-wizard-core/templates/codebase/architecture.md +1 -1
- package/pan-wizard-core/templates/codebase/concerns.md +1 -1
- package/pan-wizard-core/templates/codebase/conventions.md +1 -1
- package/pan-wizard-core/templates/codebase/integrations.md +1 -1
- package/pan-wizard-core/templates/codebase/stack.md +1 -1
- package/pan-wizard-core/templates/codebase/structure.md +1 -1
- package/pan-wizard-core/templates/codebase/testing.md +1 -1
- package/pan-wizard-core/templates/design.md +146 -0
- package/pan-wizard-core/templates/discovery.md +3 -3
- package/pan-wizard-core/templates/milestone-archive.md +2 -2
- package/pan-wizard-core/templates/playbook.md +1 -1
- package/pan-wizard-core/templates/uat.md +1 -1
- package/pan-wizard-core/workflows/diagnose-issues.md +9 -7
- package/pan-wizard-core/workflows/exec-phase.md +28 -8
- package/pan-wizard-core/workflows/execute-plan.md +2 -3
- package/pan-wizard-core/workflows/health.md +24 -1
- package/pan-wizard-core/workflows/help.md +5 -4
- package/pan-wizard-core/workflows/learn.md +5 -5
- package/pan-wizard-core/workflows/map-codebase.md +1 -1
- package/pan-wizard-core/workflows/milestone-audit.md +1 -1
- package/pan-wizard-core/workflows/milestone-new.md +4 -4
- package/pan-wizard-core/workflows/new-project.md +47 -32
- package/pan-wizard-core/workflows/optimize.md +8 -8
- package/pan-wizard-core/workflows/pause.md +1 -1
- package/pan-wizard-core/workflows/phase-tests.md +2 -2
- package/pan-wizard-core/workflows/plan-phase.md +4 -0
- package/pan-wizard-core/workflows/profile.md +13 -10
- package/pan-wizard-core/workflows/research-phase.md +1 -1
- package/pan-wizard-core/workflows/resume-project.md +1 -1
- package/pan-wizard-core/workflows/settings.md +9 -9
- package/pan-wizard-core/workflows/transition.md +24 -8
- package/pan-wizard-core/workflows/update.md +23 -20
- package/pan-wizard-core/workflows/verify-phase.md +7 -2
- package/pan-zcode/KNOWN-BETA-RISKS.md +8 -0
- package/pan-zcode/README.md +14 -2
- package/pan-zcode/bin/install-zcode.js +1 -1
- package/pan-zcode/mcp/native-tools.cjs +1 -1
- package/pan-zcode/mcp/server.cjs +76 -14
- package/scripts/build-hooks.js +2 -1
- package/scripts/generate-skills-docs.py +0 -4
- package/scripts/run-tests.cjs +11 -4
package/bin/install.js
CHANGED
|
@@ -38,11 +38,54 @@ const reset = '\x1b[0m';
|
|
|
38
38
|
// Get version from package.json
|
|
39
39
|
const pkg = require('../package.json');
|
|
40
40
|
|
|
41
|
+
// Concrete model ids the E-9 capability advisory (see finishInstall) names to
|
|
42
|
+
// the user. Kept here so the next lineup move is an edit to this object rather
|
|
43
|
+
// than a hunt through printed strings — the printed recommendation has already
|
|
44
|
+
// had to be re-pointed as the lineup moved (docs/ECOSYSTEM-REVIEW-2026-06.md,
|
|
45
|
+
// "Stale capability detection"; B4.1, audit 2026-08). The advisory itself is
|
|
46
|
+
// phrased by capability, not by name; these are the "switch to this" examples
|
|
47
|
+
// that keep the advice actionable. Nothing in PAN gates on these values.
|
|
48
|
+
const RECOMMENDED_MODELS = {
|
|
49
|
+
flagship: 'claude-fable-5',
|
|
50
|
+
reasoningTier: 'claude-opus-5 / claude-opus-4-8',
|
|
51
|
+
};
|
|
52
|
+
|
|
41
53
|
// Source repo root — prevent installing PAN into its own source directory
|
|
42
54
|
const PAN_SOURCE_ROOT = path.resolve(__dirname, '..');
|
|
43
55
|
// Windows paths are case-insensitive; normalize for comparison
|
|
44
56
|
const normPath = p => process.platform === 'win32' ? p.toLowerCase() : p;
|
|
45
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Render a path for console output: relative to cwd when it is inside the project,
|
|
60
|
+
* absolute otherwise, always with forward slashes.
|
|
61
|
+
*
|
|
62
|
+
* Messages used to hardcode literals like '.codex/hooks.json'. That was only true
|
|
63
|
+
* for a local install — a --global or --config-dir install writes elsewhere, so the
|
|
64
|
+
* installer told users to inspect a file that did not exist while the real one sat
|
|
65
|
+
* somewhere they were never shown.
|
|
66
|
+
*/
|
|
67
|
+
const displayPath = (p) => {
|
|
68
|
+
const rel = path.relative(process.cwd(), p);
|
|
69
|
+
const use = (rel && !rel.startsWith('..')) ? rel : p;
|
|
70
|
+
return use.split(path.sep).join('/');
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True when `cwd` is the PAN source repo root OR any subdirectory of it — a
|
|
75
|
+
* containment check, not an exact match, so `cd docs && node ../bin/install.js`
|
|
76
|
+
* is also refused (a subdir install plants un-ignored .claude/AGENTS.md/etc.
|
|
77
|
+
* because .gitignore's self-install patterns are root-anchored). Uses
|
|
78
|
+
* fs.realpathSync on both sides so a symlink/junction into the repo can't bypass
|
|
79
|
+
* the guard (path.resolve alone does not canonicalize). Mirrors the
|
|
80
|
+
* memory-rebuild module's isInsideSourceRepo() helper.
|
|
81
|
+
*/
|
|
82
|
+
function isInsideSourceRepo(cwd) {
|
|
83
|
+
const realOr = p => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
|
84
|
+
const abs = normPath(realOr(cwd));
|
|
85
|
+
const src = normPath(realOr(PAN_SOURCE_ROOT));
|
|
86
|
+
return abs === src || abs.startsWith(src + path.sep) || abs.startsWith(src + '/');
|
|
87
|
+
}
|
|
88
|
+
|
|
46
89
|
// IMPROVEMENT-TODO P0 (v3.7.10): warning collector for non-fatal install
|
|
47
90
|
// failures. Replaces silent `catch {}` blocks in copy paths. Surfaced at end
|
|
48
91
|
// of install if non-empty. Required failures still throw / exit non-zero.
|
|
@@ -219,19 +262,65 @@ console.log(banner);
|
|
|
219
262
|
|
|
220
263
|
// Show help if requested
|
|
221
264
|
if (hasHelp) {
|
|
222
|
-
console.log(` ${yellow}Usage:${reset} npx pan-wizard [options]\n\n ${yellow}Options:${reset}\n ${cyan}-l, --local${reset} Install locally to current directory (default)\n ${cyan}-g, --global${reset} Install globally to config directory\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall PAN (remove all PAN files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--unified-skills${reset} Install commands as one shared .agents/skills/ tree (ADR-0028 alpha)\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime; installs project-level)${reset}\n npx pan-wizard\n\n ${dim}# Install for Claude Code in current project (default, --local implied)${reset}\n npx pan-wizard --claude\n\n ${dim}# Install for all runtimes in current project${reset}\n npx pan-wizard --all --local\n\n ${dim}# Install globally (available in all projects)${reset}\n npx pan-wizard --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx pan-wizard --gemini --global\n\n ${dim}# Install to custom config directory${reset}\n npx pan-wizard --codex --global --config-dir ~/.codex-work\n\n ${dim}# Uninstall PAN from Codex globally${reset}\n npx pan-wizard --codex --global --uninstall\n\n ${yellow}Notes:${reset}\n By default, PAN installs into the current project directory only.\n Use --global to install system-wide (writes to ~/.claude, ~/.gemini, etc.).\n The --config-dir option takes priority over CLAUDE_CONFIG_DIR / GEMINI_CONFIG_DIR / CODEX_HOME.\n`);
|
|
265
|
+
console.log(` ${yellow}Usage:${reset} npx pan-wizard [options]\n\n ${yellow}Options:${reset}\n ${cyan}-l, --local${reset} Install locally to current directory (default)\n ${cyan}-g, --global${reset} Install globally to config directory\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for GitHub Copilot CLI only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall PAN (remove all PAN files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--unified-skills${reset} Install commands as one shared .agents/skills/ tree (ADR-0028 alpha)\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime; installs project-level)${reset}\n npx pan-wizard\n\n ${dim}# Install for Claude Code in current project (default, --local implied)${reset}\n npx pan-wizard --claude\n\n ${dim}# Install for all runtimes in current project${reset}\n npx pan-wizard --all --local\n\n ${dim}# Install globally (available in all projects)${reset}\n npx pan-wizard --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx pan-wizard --gemini --global\n\n ${dim}# Install to custom config directory${reset}\n npx pan-wizard --codex --global --config-dir ~/.codex-work\n\n ${dim}# Uninstall PAN from Codex globally${reset}\n npx pan-wizard --codex --global --uninstall\n\n ${yellow}Notes:${reset}\n By default, PAN installs into the current project directory only.\n Use --global to install system-wide (writes to ~/.claude, ~/.gemini, etc.).\n The --config-dir option takes priority over CLAUDE_CONFIG_DIR / GEMINI_CONFIG_DIR / CODEX_HOME.\n`);
|
|
223
266
|
process.exit(0);
|
|
224
267
|
}
|
|
225
268
|
|
|
226
269
|
/**
|
|
227
|
-
* Read and parse settings.json
|
|
270
|
+
* Read and parse settings.json.
|
|
271
|
+
*
|
|
272
|
+
* Returns `{}` when there is nothing to preserve — the file is absent or empty.
|
|
273
|
+
* Returns `null` when the file EXISTS but cannot be used: unreadable, not valid
|
|
274
|
+
* JSON, or valid JSON of the wrong shape (an array, a string, `null`). Callers
|
|
275
|
+
* MUST treat `null` as "leave this file alone" — see settingsUnusable().
|
|
276
|
+
*
|
|
277
|
+
* Why the distinction is load-bearing: every caller merges PAN's keys into the
|
|
278
|
+
* object this returns and writes the result back. While parse failure also
|
|
279
|
+
* returned `{}`, an unparseable settings.json — a `//` comment is the common
|
|
280
|
+
* case, since people write them even though the format is strict JSON — came
|
|
281
|
+
* back as empty and was overwritten with PAN's keys alone. The user's model
|
|
282
|
+
* choice, permissions and auth settings were destroyed with no warning, no
|
|
283
|
+
* backup, and exit 0. Returning `null` makes that outcome unreachable: a caller
|
|
284
|
+
* that forgets to check throws instead of silently discarding user data.
|
|
285
|
+
*
|
|
286
|
+
* Comments are deliberately NOT tolerated via parseJsonc here. Parsing JSONC
|
|
287
|
+
* and writing strict JSON back would drop the comments — a quieter version of
|
|
288
|
+
* the same data loss. Warn and skip instead, exactly as configureOpencodePermissions
|
|
289
|
+
* already does for opencode.json.
|
|
228
290
|
*/
|
|
229
291
|
function readSettings(settingsPath) {
|
|
292
|
+
if (!fs.existsSync(settingsPath)) return {};
|
|
293
|
+
let content;
|
|
230
294
|
try {
|
|
231
|
-
|
|
295
|
+
content = fs.readFileSync(settingsPath, 'utf8');
|
|
232
296
|
} catch {
|
|
233
|
-
return
|
|
297
|
+
return null; // exists but unreadable (locked, permissions) — do not touch
|
|
234
298
|
}
|
|
299
|
+
if (content.trim() === '') return {}; // empty file: nothing to preserve
|
|
300
|
+
let parsed;
|
|
301
|
+
try {
|
|
302
|
+
parsed = JSON.parse(content);
|
|
303
|
+
} catch {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
// Valid JSON of the wrong shape is still unusable: merging PAN's keys into an
|
|
307
|
+
// array or a primitive and writing it back would corrupt the file.
|
|
308
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
309
|
+
return parsed;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Report an unusable settings file and tell the user we left it alone.
|
|
314
|
+
* Returns true when the caller must skip (i.e. readSettings returned null),
|
|
315
|
+
* so call sites read: `if (settingsUnusable(settings, p, 'hook config')) return;`
|
|
316
|
+
*/
|
|
317
|
+
function settingsUnusable(settings, settingsPath, whatWasSkipped) {
|
|
318
|
+
if (settings !== null) return false;
|
|
319
|
+
const name = path.basename(settingsPath);
|
|
320
|
+
console.log(` ${yellow}⚠${reset} Could not parse ${name} - skipping ${whatWasSkipped}`);
|
|
321
|
+
console.log(` ${dim}${settingsPath}${reset}`);
|
|
322
|
+
console.log(` ${dim}Your file was NOT modified. Fix the syntax (strict JSON — no comments or trailing commas) and re-run.${reset}`);
|
|
323
|
+
return true;
|
|
235
324
|
}
|
|
236
325
|
|
|
237
326
|
/**
|
|
@@ -262,11 +351,11 @@ function getCommitAttribution(runtime) {
|
|
|
262
351
|
let result;
|
|
263
352
|
|
|
264
353
|
if (runtime === 'opencode') {
|
|
265
|
-
const config = readSettings(path.join(getGlobalDir('opencode', null), 'opencode.json'));
|
|
354
|
+
const config = readSettings(path.join(getGlobalDir('opencode', null), 'opencode.json')) || {}; // unusable file = no info (read-only probe)
|
|
266
355
|
result = config.disable_ai_attribution === true ? null : undefined;
|
|
267
356
|
} else if (runtime === 'gemini') {
|
|
268
357
|
// Gemini: check gemini settings.json for attribution config
|
|
269
|
-
const settings = readSettings(path.join(getGlobalDir('gemini', explicitConfigDir), 'settings.json'));
|
|
358
|
+
const settings = readSettings(path.join(getGlobalDir('gemini', explicitConfigDir), 'settings.json')) || {}; // unusable file = no info (read-only probe)
|
|
270
359
|
if (!settings.attribution || settings.attribution.commit === undefined) {
|
|
271
360
|
result = undefined;
|
|
272
361
|
} else if (settings.attribution.commit === '') {
|
|
@@ -276,7 +365,7 @@ function getCommitAttribution(runtime) {
|
|
|
276
365
|
}
|
|
277
366
|
} else if (runtime === 'claude') {
|
|
278
367
|
// Claude Code
|
|
279
|
-
const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json'));
|
|
368
|
+
const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json')) || {}; // unusable file = no info (read-only probe)
|
|
280
369
|
if (!settings.attribution || settings.attribution.commit === undefined) {
|
|
281
370
|
result = undefined;
|
|
282
371
|
} else if (settings.attribution.commit === '') {
|
|
@@ -288,9 +377,9 @@ function getCommitAttribution(runtime) {
|
|
|
288
377
|
// Copilot CLI: user-editable settings live in settings.json; config.json is
|
|
289
378
|
// legacy (auto-migrated by the CLI, now internal state) — fall back for old installs
|
|
290
379
|
const copilotDir = getGlobalDir('copilot', explicitConfigDir);
|
|
291
|
-
let config = readSettings(path.join(copilotDir, 'settings.json'));
|
|
380
|
+
let config = readSettings(path.join(copilotDir, 'settings.json')) || {}; // unusable file = no info (read-only probe)
|
|
292
381
|
if (!config.attribution) {
|
|
293
|
-
config = readSettings(path.join(copilotDir, 'config.json'));
|
|
382
|
+
config = readSettings(path.join(copilotDir, 'config.json')) || {}; // unusable file = no info (read-only probe)
|
|
294
383
|
}
|
|
295
384
|
if (!config.attribution || config.attribution.commit === undefined) {
|
|
296
385
|
result = undefined;
|
|
@@ -612,6 +701,51 @@ function copySharedCore(srcDir, destDir, corePrefix, runtimePathPrefix, runtime)
|
|
|
612
701
|
} catch (err) {
|
|
613
702
|
if (err.code !== 'ENOENT') pushInstallWarning('stripInternalLearnings', 'learnings/internal', err);
|
|
614
703
|
}
|
|
704
|
+
stripInternalFromLearningsIndex(path.join(destDir, 'learnings', 'index.json'));
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Drop internal-scoped topics from an installed learnings/index.json and recompute
|
|
709
|
+
* its totals.
|
|
710
|
+
*
|
|
711
|
+
* Deleting learnings/internal/ from disk is only half the strip: the index still
|
|
712
|
+
* listed those topics, so every install shipped file paths that do not exist —
|
|
713
|
+
* dangling references for any consumer that resolves them — along with the internal
|
|
714
|
+
* topic names, their pattern ids, and totals counting content the package
|
|
715
|
+
* deliberately withholds. Each topic entry carries its own size_bytes and
|
|
716
|
+
* size_tokens_est, so the totals are recomputed exactly rather than estimated.
|
|
717
|
+
*
|
|
718
|
+
* Best-effort by design: a malformed or absent index is not worth failing an install
|
|
719
|
+
* over, and the strip of the files themselves has already happened.
|
|
720
|
+
*
|
|
721
|
+
* @param {string} indexPath - Path to the installed learnings/index.json
|
|
722
|
+
*/
|
|
723
|
+
function stripInternalFromLearningsIndex(indexPath) {
|
|
724
|
+
let parsed;
|
|
725
|
+
try {
|
|
726
|
+
parsed = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
|
727
|
+
} catch (err) {
|
|
728
|
+
if (err.code !== 'ENOENT') pushInstallWarning('stripInternalLearnings', 'learnings/index.json', err);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (!parsed || !Array.isArray(parsed.topics)) return;
|
|
732
|
+
|
|
733
|
+
const kept = parsed.topics.filter(t => t && t.scope !== 'internal');
|
|
734
|
+
if (kept.length === parsed.topics.length) return; // nothing internal to drop
|
|
735
|
+
|
|
736
|
+
parsed.topics = kept;
|
|
737
|
+
if (parsed.totals && typeof parsed.totals === 'object') {
|
|
738
|
+
parsed.totals.topics = kept.length;
|
|
739
|
+
parsed.totals.patterns = kept.reduce((n, t) => n + (Array.isArray(t.patterns) ? t.patterns.length : 0), 0);
|
|
740
|
+
parsed.totals.size_bytes = kept.reduce((n, t) => n + (t.size_bytes || 0), 0);
|
|
741
|
+
parsed.totals.size_tokens_est = kept.reduce((n, t) => n + (t.size_tokens_est || 0), 0);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
try {
|
|
745
|
+
fs.writeFileSync(indexPath, JSON.stringify(parsed, null, 2) + '\n');
|
|
746
|
+
} catch (err) {
|
|
747
|
+
pushInstallWarning('stripInternalLearnings', 'learnings/index.json', err);
|
|
748
|
+
}
|
|
615
749
|
}
|
|
616
750
|
|
|
617
751
|
/**
|
|
@@ -767,8 +901,14 @@ function cleanupOrphanedFiles(configDir) {
|
|
|
767
901
|
for (const relPath of orphanedFiles) {
|
|
768
902
|
const fullPath = path.join(configDir, relPath);
|
|
769
903
|
if (fs.existsSync(fullPath)) {
|
|
770
|
-
|
|
771
|
-
|
|
904
|
+
// A locked/permission-denied legacy file must not abort the whole install
|
|
905
|
+
// with an unhandled exception — surface it as a warning (M3, ADR audit 2026-08).
|
|
906
|
+
try {
|
|
907
|
+
fs.unlinkSync(fullPath);
|
|
908
|
+
console.log(` ${green}✓${reset} Removed orphaned ${relPath}`);
|
|
909
|
+
} catch (err) {
|
|
910
|
+
pushInstallWarning('staleCleanup', relPath, err);
|
|
911
|
+
}
|
|
772
912
|
}
|
|
773
913
|
}
|
|
774
914
|
}
|
|
@@ -815,14 +955,22 @@ function cleanupOrphanedHooks(settings) {
|
|
|
815
955
|
console.log(` ${green}✓${reset} Removed orphaned hook registrations`);
|
|
816
956
|
}
|
|
817
957
|
|
|
818
|
-
// Fix #330:
|
|
958
|
+
// Fix #330: migrate PAN's OWN legacy hooks/statusline.js path to
|
|
959
|
+
// pan-statusline.js. Anchor on the `hooks/` PREFIX plus the file basename —
|
|
960
|
+
// PAN's legacy hook was only ever registered under a hooks/ directory — so a
|
|
961
|
+
// user's custom command like `my-custom-statusline.js` or a script literally
|
|
962
|
+
// named `statusline.js` (e.g. `node ./statusline.js`) is NOT rewritten. The
|
|
963
|
+
// old looser matches corrupted such names to nonexistent pan-statusline.js
|
|
964
|
+
// paths and then made the M6/N4 statusline guard misclassify them as
|
|
965
|
+
// PAN-owned and stand down (M6 residual). Both slash directions are kept:
|
|
966
|
+
// Windows settings.json may carry escaped backslashes.
|
|
967
|
+
const legacyStatusline = /(^|[\\/])hooks([\\/])statusline\.js\b/;
|
|
819
968
|
if (settings.statusLine && settings.statusLine.command &&
|
|
820
|
-
settings.statusLine.command
|
|
969
|
+
legacyStatusline.test(settings.statusLine.command) &&
|
|
821
970
|
!settings.statusLine.command.includes('pan-statusline.js')) {
|
|
822
|
-
// Replace old path with new path
|
|
823
971
|
settings.statusLine.command = settings.statusLine.command.replace(
|
|
824
|
-
|
|
825
|
-
'
|
|
972
|
+
legacyStatusline,
|
|
973
|
+
'$1hooks$2pan-statusline.js'
|
|
826
974
|
);
|
|
827
975
|
console.log(` ${green}✓${reset} Updated statusline path (statusline.js → pan-statusline.js)`);
|
|
828
976
|
}
|
|
@@ -857,8 +1005,8 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
857
1005
|
if (runtime === 'codex') runtimeLabel = 'Codex';
|
|
858
1006
|
if (runtime === 'copilot') runtimeLabel = 'GitHub Copilot CLI';
|
|
859
1007
|
|
|
860
|
-
// Guard: never uninstall from the PAN source repository itself
|
|
861
|
-
if (
|
|
1008
|
+
// Guard: never uninstall from the PAN source repository itself (or any subdir)
|
|
1009
|
+
if (isInsideSourceRepo(process.cwd())) {
|
|
862
1010
|
console.error(`\n ${red}✗${reset} Refusing to uninstall from PAN's own source repository.`);
|
|
863
1011
|
console.error(` Run from your target project directory instead.\n`);
|
|
864
1012
|
process.exit(1);
|
|
@@ -1046,7 +1194,7 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
1046
1194
|
// 4. Remove PAN hooks (scripts + Copilot CLI hooks config file)
|
|
1047
1195
|
const hooksDir = path.join(targetDir, 'hooks');
|
|
1048
1196
|
if (fs.existsSync(hooksDir)) {
|
|
1049
|
-
const panHooks = ['pan-statusline.js', 'pan-check-update.js', 'pan-check-update.sh', 'pan-context-monitor.js', 'pan-cost-logger.js', 'pan-trace-logger.js', 'pan.json'];
|
|
1197
|
+
const panHooks = ['pan-statusline.js', 'pan-check-update.js', 'pan-check-update.sh', 'pan-context-monitor.js', 'pan-cost-logger.js', 'pan-trace-logger.js', 'pan-stop-guard.js', 'pan.json'];
|
|
1050
1198
|
let hookCount = 0;
|
|
1051
1199
|
for (const hook of panHooks) {
|
|
1052
1200
|
const hookPath = path.join(hooksDir, hook);
|
|
@@ -1212,8 +1360,16 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
1212
1360
|
|
|
1213
1361
|
// 6b. Clean up settings.json (remove PAN hooks and statusline)
|
|
1214
1362
|
const settingsPath = path.join(targetDir, 'settings.json');
|
|
1215
|
-
|
|
1216
|
-
|
|
1363
|
+
// Unparseable on the way OUT too: stripping PAN's keys means writing the file
|
|
1364
|
+
// back, which would replace the user's content. Skip just this step — NOT the
|
|
1365
|
+
// whole uninstall, which still has the opencode permission cleanup and the
|
|
1366
|
+
// manifest removal to do. The raw-bytes guard further down still deletes a
|
|
1367
|
+
// settings.json that is literally PAN's own empty `{}`.
|
|
1368
|
+
const existingSettings = fs.existsSync(settingsPath) ? readSettings(settingsPath) : null;
|
|
1369
|
+
const skipSettingsCleanup = fs.existsSync(settingsPath)
|
|
1370
|
+
&& settingsUnusable(existingSettings, settingsPath, 'settings.json cleanup');
|
|
1371
|
+
if (fs.existsSync(settingsPath) && !skipSettingsCleanup) {
|
|
1372
|
+
let settings = existingSettings;
|
|
1217
1373
|
let settingsModified = false;
|
|
1218
1374
|
|
|
1219
1375
|
// Remove PAN statusline if it references our hook
|
|
@@ -1289,6 +1445,27 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
1289
1445
|
}
|
|
1290
1446
|
}
|
|
1291
1447
|
|
|
1448
|
+
// Remove PAN stop guard from Stop (P-1809, v3.23+)
|
|
1449
|
+
if (settings.hooks && settings.hooks.Stop) {
|
|
1450
|
+
const before = settings.hooks.Stop.length;
|
|
1451
|
+
settings.hooks.Stop = settings.hooks.Stop.filter(entry => {
|
|
1452
|
+
if (entry.hooks && Array.isArray(entry.hooks)) {
|
|
1453
|
+
const hasPanHook = entry.hooks.some(h =>
|
|
1454
|
+
h.command && h.command.includes('pan-stop-guard')
|
|
1455
|
+
);
|
|
1456
|
+
return !hasPanHook;
|
|
1457
|
+
}
|
|
1458
|
+
return true;
|
|
1459
|
+
});
|
|
1460
|
+
if (settings.hooks.Stop.length < before) {
|
|
1461
|
+
settingsModified = true;
|
|
1462
|
+
console.log(` ${green}✓${reset} Removed auto-advance stop guard hook from settings`);
|
|
1463
|
+
}
|
|
1464
|
+
if (settings.hooks.Stop.length === 0) {
|
|
1465
|
+
delete settings.hooks.Stop;
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1292
1469
|
// Clean up empty hooks object
|
|
1293
1470
|
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
|
|
1294
1471
|
delete settings.hooks;
|
|
@@ -1313,18 +1490,41 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
1313
1490
|
writeSettings(settingsPath, settings);
|
|
1314
1491
|
}
|
|
1315
1492
|
removedCount++;
|
|
1493
|
+
} else if (Object.keys(settings).length === 0) {
|
|
1494
|
+
// No PAN entries and the parsed object is empty — but readSettings()
|
|
1495
|
+
// returns {} on ANY parse failure (BOM, comments, trailing comma), so an
|
|
1496
|
+
// empty object does NOT prove the file holds no user data. Only remove it
|
|
1497
|
+
// when the RAW bytes are literally an empty object; otherwise leave the
|
|
1498
|
+
// user's (unparseable) file untouched (N1 regression fix, ADR audit 2026-08).
|
|
1499
|
+
let rawIsEmptyObject = false;
|
|
1500
|
+
try {
|
|
1501
|
+
rawIsEmptyObject = fs.readFileSync(settingsPath, 'utf8').replace(/^/, '').trim() === '{}';
|
|
1502
|
+
} catch { /* unreadable — never delete */ }
|
|
1503
|
+
if (rawIsEmptyObject) {
|
|
1504
|
+
try {
|
|
1505
|
+
fs.unlinkSync(settingsPath);
|
|
1506
|
+
console.log(` ${green}✓${reset} Removed empty settings.json`);
|
|
1507
|
+
removedCount++;
|
|
1508
|
+
} catch (err) { pushInstallWarning('staleCleanup', settingsPath, err); }
|
|
1509
|
+
}
|
|
1316
1510
|
}
|
|
1317
1511
|
}
|
|
1318
1512
|
|
|
1319
1513
|
// 6. For OpenCode, clean up permissions from opencode.json
|
|
1320
1514
|
if (isOpencode) {
|
|
1321
1515
|
// For local uninstalls, clean up ./.opencode/opencode.json
|
|
1322
|
-
// For global uninstalls, clean up
|
|
1516
|
+
// For global uninstalls, clean up the resolved global dir — via getGlobalDir so
|
|
1517
|
+
// --config-dir is honoured here as well. Otherwise `--opencode --global
|
|
1518
|
+
// --config-dir <path> --uninstall` looked in ~/.config/opencode and left the
|
|
1519
|
+
// real config untouched, which is half of how this became residue.
|
|
1323
1520
|
const opencodeConfigDir = isGlobal
|
|
1324
|
-
?
|
|
1521
|
+
? getGlobalDir('opencode', explicitConfigDir)
|
|
1325
1522
|
: path.join(process.cwd(), '.opencode');
|
|
1326
1523
|
const configPath = path.join(opencodeConfigDir, 'opencode.json');
|
|
1327
|
-
|
|
1524
|
+
{
|
|
1525
|
+
// Read straight through instead of existsSync-then-read: absence and a corrupt
|
|
1526
|
+
// file both mean "nothing of ours to strip", and the check-then-use gap was a
|
|
1527
|
+
// CodeQL js/file-system-race. Same idiom as the hooks' metrics read.
|
|
1328
1528
|
try {
|
|
1329
1529
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
1330
1530
|
let modified = false;
|
|
@@ -1335,7 +1535,13 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
1335
1535
|
if (config.permission[permType]) {
|
|
1336
1536
|
const keys = Object.keys(config.permission[permType]);
|
|
1337
1537
|
for (const key of keys) {
|
|
1338
|
-
|
|
1538
|
+
// Match `pan-wizard` rather than `pan-wizard-core` so the malformed
|
|
1539
|
+
// key earlier versions wrote (`~/.config/opencode/pan-wizard/*`) is
|
|
1540
|
+
// cleaned too. Without this, machines that ran any global OpenCode
|
|
1541
|
+
// install before the glob was fixed keep that entry forever — the
|
|
1542
|
+
// uninstaller could never match it, which is what made it residue
|
|
1543
|
+
// rather than a cosmetic typo.
|
|
1544
|
+
if (key.includes('pan-wizard')) {
|
|
1339
1545
|
delete config.permission[permType][key];
|
|
1340
1546
|
modified = true;
|
|
1341
1547
|
}
|
|
@@ -1352,12 +1558,20 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
1352
1558
|
}
|
|
1353
1559
|
|
|
1354
1560
|
if (modified) {
|
|
1355
|
-
|
|
1561
|
+
// If stripping PAN permissions emptied the config, remove the file
|
|
1562
|
+
// rather than leaving a spurious {} behind — PAN created it via
|
|
1563
|
+
// configureOpencodePermissions, so an empty result is not a user file.
|
|
1564
|
+
if (Object.keys(config).length === 0) {
|
|
1565
|
+
fs.unlinkSync(configPath);
|
|
1566
|
+
console.log(` ${green}✓${reset} Removed empty opencode.json`);
|
|
1567
|
+
} else {
|
|
1568
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
1569
|
+
console.log(` ${green}✓${reset} Removed PAN permissions from opencode.json`);
|
|
1570
|
+
}
|
|
1356
1571
|
removedCount++;
|
|
1357
|
-
console.log(` ${green}✓${reset} Removed PAN permissions from opencode.json`);
|
|
1358
1572
|
}
|
|
1359
1573
|
} catch (e) {
|
|
1360
|
-
//
|
|
1574
|
+
// Missing file (ENOENT) or unparseable JSON — nothing for us to remove.
|
|
1361
1575
|
}
|
|
1362
1576
|
}
|
|
1363
1577
|
}
|
|
@@ -1436,9 +1650,13 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
1436
1650
|
*/
|
|
1437
1651
|
function configureOpencodePermissions(isGlobal = true) {
|
|
1438
1652
|
// For local installs, use ./.opencode/opencode.json
|
|
1439
|
-
// For global installs, use
|
|
1653
|
+
// For global installs, use the resolved global dir — via getGlobalDir so that
|
|
1654
|
+
// --config-dir is honoured. Calling getOpencodeGlobalDir() directly ignored the
|
|
1655
|
+
// flag, so `--opencode --global --config-dir <path>` installed the core into
|
|
1656
|
+
// <path> but wrote its permissions into ~/.config/opencode: the real install had
|
|
1657
|
+
// no permission config at all, and an unrelated directory got a stray one.
|
|
1440
1658
|
const opencodeConfigDir = isGlobal
|
|
1441
|
-
?
|
|
1659
|
+
? getGlobalDir('opencode', explicitConfigDir)
|
|
1442
1660
|
: path.join(process.cwd(), '.opencode');
|
|
1443
1661
|
const configPath = path.join(opencodeConfigDir, 'opencode.json');
|
|
1444
1662
|
|
|
@@ -1467,9 +1685,17 @@ function configureOpencodePermissions(isGlobal = true) {
|
|
|
1467
1685
|
|
|
1468
1686
|
// Build the PAN path using the actual config directory
|
|
1469
1687
|
// Use ~ shorthand if it's in the default location, otherwise use full path
|
|
1688
|
+
// The directory PAN's core actually lands in is `pan-wizard-core`. The
|
|
1689
|
+
// default-location branch used to emit `~/.config/opencode/pan-wizard/*`, which
|
|
1690
|
+
// matches nothing — so every default global OpenCode install granted read access
|
|
1691
|
+
// to a path that does not exist while PAN's real core stayed un-allow-listed. It
|
|
1692
|
+
// also became permanent residue: the uninstaller only removed keys containing
|
|
1693
|
+
// `pan-wizard-core`, so the wrong key could never be matched or cleaned.
|
|
1694
|
+
// Keep the ~ form for the default location (portable, and what a user expects to
|
|
1695
|
+
// see in their own config) and an absolute path otherwise.
|
|
1470
1696
|
const defaultConfigDir = path.join(os.homedir(), '.config', 'opencode');
|
|
1471
1697
|
const panPath = opencodeConfigDir === defaultConfigDir
|
|
1472
|
-
? '~/.config/opencode/pan-wizard/*'
|
|
1698
|
+
? '~/.config/opencode/pan-wizard-core/*'
|
|
1473
1699
|
: `${opencodeConfigDir.replace(/\\/g, '/')}/pan-wizard-core/*`;
|
|
1474
1700
|
|
|
1475
1701
|
let modified = false;
|
|
@@ -1548,6 +1774,10 @@ function verifyFileInstalled(filePath, description) {
|
|
|
1548
1774
|
// ──────────────────────────────────────────────────────
|
|
1549
1775
|
|
|
1550
1776
|
const PATCHES_DIR_NAME = 'pan-local-patches';
|
|
1777
|
+
// Backups of manifest entries that live OUTSIDE the runtime's config dir (Codex's
|
|
1778
|
+
// skills at ../.agents/skills/) land here, with the traversal segments stripped, so
|
|
1779
|
+
// the patches tree stays self-contained.
|
|
1780
|
+
const EXTERNAL_PATCHES_SUBDIR = '_external';
|
|
1551
1781
|
const MANIFEST_NAME = 'pan-file-manifest.json';
|
|
1552
1782
|
|
|
1553
1783
|
/**
|
|
@@ -1631,6 +1861,22 @@ function writeManifest(configDir, runtime = 'claude', isGlobal = false) {
|
|
|
1631
1861
|
}
|
|
1632
1862
|
}
|
|
1633
1863
|
}
|
|
1864
|
+
// Claude native skill shims: flat skills/pan-*.md files (E-5). Only the Claude
|
|
1865
|
+
// runtime's nested-commands install writes these; under --unified-skills the
|
|
1866
|
+
// shared .agents/skills tree above already covers skills. Tracking them lets
|
|
1867
|
+
// verifyInstall catch silent shim-write failures and saveLocalPatches back up
|
|
1868
|
+
// user edits — the codex/copilot SKILL.md branch above matches directories, not
|
|
1869
|
+
// these flat files.
|
|
1870
|
+
if (runtime === 'claude' && !unifiedSkills) {
|
|
1871
|
+
const claudeSkillsDir = path.join(configDir, 'skills');
|
|
1872
|
+
if (fs.existsSync(claudeSkillsDir)) {
|
|
1873
|
+
for (const file of fs.readdirSync(claudeSkillsDir)) {
|
|
1874
|
+
if (file.startsWith('pan-') && file.endsWith('.md')) {
|
|
1875
|
+
manifest.files['skills/' + file] = fileHash(path.join(claudeSkillsDir, file));
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1634
1880
|
if (fs.existsSync(agentsDir)) {
|
|
1635
1881
|
for (const file of fs.readdirSync(agentsDir)) {
|
|
1636
1882
|
if (file.startsWith('pan-') && (file.endsWith('.md') || file.endsWith('.toml'))) {
|
|
@@ -1678,21 +1924,44 @@ function saveLocalPatches(configDir) {
|
|
|
1678
1924
|
|
|
1679
1925
|
const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
|
|
1680
1926
|
const modified = [];
|
|
1927
|
+
const externalBackups = {};
|
|
1681
1928
|
|
|
1682
1929
|
for (const [relPath, originalHash] of Object.entries(manifest.files || {})) {
|
|
1683
|
-
// Keys reaching outside configDir
|
|
1684
|
-
//
|
|
1685
|
-
// `..` and write outside the patches tree.
|
|
1686
|
-
//
|
|
1687
|
-
|
|
1930
|
+
// Keys reaching outside configDir — Codex's skills live in ../.agents/skills/ —
|
|
1931
|
+
// used to be SKIPPED here, because joining a `..` path under patchesDir would
|
|
1932
|
+
// collapse the `..` and write outside the patches tree.
|
|
1933
|
+
//
|
|
1934
|
+
// Skipping was the wrong half of that trade: those keys are Codex's ONLY command
|
|
1935
|
+
// surface, so a Codex user who tuned a skill silently lost the edit on the next
|
|
1936
|
+
// upgrade, with no mention in the output and no backup to recover from — while
|
|
1937
|
+
// docs/USER-GUIDE.md and docs/AGENTS.md both promise, unqualified, that the
|
|
1938
|
+
// installer backs up locally modified files.
|
|
1939
|
+
//
|
|
1940
|
+
// Back them up under `_external/` with the traversal segments stripped instead.
|
|
1941
|
+
// That keeps the containment the guard existed for (the final path cannot escape
|
|
1942
|
+
// patchesDir — asserted below) while honouring the promise.
|
|
1943
|
+
const segments = relPath.split(/[\\/]/).filter(Boolean);
|
|
1944
|
+
const escapes = segments.some(s => s === '..');
|
|
1945
|
+
const safeRel = escapes
|
|
1946
|
+
? path.join(EXTERNAL_PATCHES_SUBDIR, ...segments.filter(s => s !== '..' && s !== '.'))
|
|
1947
|
+
: relPath;
|
|
1948
|
+
|
|
1688
1949
|
const fullPath = path.join(configDir, relPath);
|
|
1689
1950
|
if (!fs.existsSync(fullPath)) continue;
|
|
1690
1951
|
const currentHash = fileHash(fullPath);
|
|
1691
1952
|
if (currentHash !== originalHash) {
|
|
1692
|
-
const backupPath = path.join(patchesDir,
|
|
1953
|
+
const backupPath = path.join(patchesDir, safeRel);
|
|
1954
|
+
// Defence in depth: never write outside patchesDir, whatever the manifest says.
|
|
1955
|
+
const resolvedPatches = path.resolve(patchesDir);
|
|
1956
|
+
const resolvedBackup = path.resolve(backupPath);
|
|
1957
|
+
if (resolvedBackup !== resolvedPatches && !resolvedBackup.startsWith(resolvedPatches + path.sep)) {
|
|
1958
|
+
pushInstallWarning('saveLocalPatches', relPath, new Error('backup path escaped the patches directory — skipped'));
|
|
1959
|
+
continue;
|
|
1960
|
+
}
|
|
1693
1961
|
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
|
|
1694
1962
|
fs.copyFileSync(fullPath, backupPath);
|
|
1695
1963
|
modified.push(relPath);
|
|
1964
|
+
if (escapes) externalBackups[relPath] = safeRel.split(path.sep).join("/");
|
|
1696
1965
|
}
|
|
1697
1966
|
}
|
|
1698
1967
|
|
|
@@ -1700,7 +1969,11 @@ function saveLocalPatches(configDir) {
|
|
|
1700
1969
|
const meta = {
|
|
1701
1970
|
backed_up_at: new Date().toISOString(),
|
|
1702
1971
|
from_version: manifest.version,
|
|
1703
|
-
files: modified
|
|
1972
|
+
files: modified,
|
|
1973
|
+
// Files whose manifest key points outside the config dir are stored under
|
|
1974
|
+
// _external/ with the traversal stripped, so `files` alone would not locate
|
|
1975
|
+
// them. Map logical key -> path within pan-local-patches/ for the restore flow.
|
|
1976
|
+
...(Object.keys(externalBackups).length > 0 ? { external_files: externalBackups } : {}),
|
|
1704
1977
|
};
|
|
1705
1978
|
fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify(meta, null, 2));
|
|
1706
1979
|
console.log(' ' + yellow + 'i' + reset + ' Found ' + modified.length + ' locally modified PAN file(s) — backed up to ' + PATCHES_DIR_NAME + '/');
|
|
@@ -1723,7 +1996,9 @@ function reportLocalPatches(configDir, runtime = 'claude') {
|
|
|
1723
1996
|
try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; }
|
|
1724
1997
|
|
|
1725
1998
|
if (meta.files && meta.files.length > 0) {
|
|
1726
|
-
|
|
1999
|
+
// Copilot uses the same flat /pan-<name> form as OpenCode; it previously fell
|
|
2000
|
+
// through to Claude's '/pan:patches', a command form that does not exist there.
|
|
2001
|
+
const reapplyCommand = (runtime === 'opencode' || runtime === 'copilot')
|
|
1727
2002
|
? '/pan-patches'
|
|
1728
2003
|
: runtime === 'codex'
|
|
1729
2004
|
? '$pan-patches'
|
|
@@ -1772,8 +2047,8 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
1772
2047
|
if (isCodex) runtimeLabel = 'Codex';
|
|
1773
2048
|
if (isCopilot) runtimeLabel = 'GitHub Copilot CLI';
|
|
1774
2049
|
|
|
1775
|
-
// Guard: never install into the PAN source repository itself
|
|
1776
|
-
if (
|
|
2050
|
+
// Guard: never install into the PAN source repository itself (or any subdir)
|
|
2051
|
+
if (isInsideSourceRepo(process.cwd())) {
|
|
1777
2052
|
console.error(`\n ${red}✗${reset} Refusing to install PAN into its own source repository.`);
|
|
1778
2053
|
console.error(` Run the installer from your target project directory instead.\n`);
|
|
1779
2054
|
console.error(` Example: cd /path/to/my-project && node ${path.resolve(__dirname, 'install.js')} --claude --local\n`);
|
|
@@ -1938,6 +2213,22 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
1938
2213
|
try {
|
|
1939
2214
|
const skillsDir = path.join(targetDir, 'skills');
|
|
1940
2215
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
2216
|
+
// Upgrade path: sweep stale pan-* shims before regenerating, mirroring
|
|
2217
|
+
// the stale-cleanup copyFlattenedCommands does for command trees.
|
|
2218
|
+
// A rename/removal in commands/pan would otherwise leave orphan shims.
|
|
2219
|
+
for (const file of fs.readdirSync(skillsDir)) {
|
|
2220
|
+
if (file.startsWith('pan-') && file.endsWith('.md')) {
|
|
2221
|
+
try { fs.unlinkSync(path.join(skillsDir, file)); } catch (err) { pushInstallWarning('staleCleanup', file, err); }
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
const workflowsDir = path.join(targetDir, 'workflows');
|
|
2225
|
+
if (fs.existsSync(workflowsDir)) {
|
|
2226
|
+
for (const file of fs.readdirSync(workflowsDir)) {
|
|
2227
|
+
if (file.startsWith('pan-') && file.endsWith('.js')) {
|
|
2228
|
+
try { fs.unlinkSync(path.join(workflowsDir, file)); } catch (err) { pushInstallWarning('staleCleanup', file, err); }
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
1941
2232
|
let shimCount = 0;
|
|
1942
2233
|
for (const file of fs.readdirSync(panDest)) {
|
|
1943
2234
|
if (!file.endsWith('.md')) continue;
|
|
@@ -1981,6 +2272,9 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
1981
2272
|
if (err.code !== 'ENOENT') pushInstallWarning('stripInternalLearnings', 'learnings/internal', err);
|
|
1982
2273
|
}
|
|
1983
2274
|
}
|
|
2275
|
+
// The index lists those topics too — strip it here as well, or the metadata
|
|
2276
|
+
// ships even though the files did not.
|
|
2277
|
+
stripInternalFromLearningsIndex(path.join(skillDest, 'learnings', 'index.json'));
|
|
1984
2278
|
|
|
1985
2279
|
if (verifyInstalled(skillDest, 'pan-wizard-core')) {
|
|
1986
2280
|
console.log(` ${green}✓${reset} Installed pan-wizard-core`);
|
|
@@ -2022,7 +2316,13 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
2022
2316
|
// stripThinkingFrontmatter so `effort:` survives to be mapped to
|
|
2023
2317
|
// Codex's native model_reasoning_effort field.
|
|
2024
2318
|
if (isCodex) {
|
|
2025
|
-
|
|
2319
|
+
// Rewrite /pan:command mentions to Codex's $pan-command syntax before
|
|
2320
|
+
// TOML conversion — the command/skill path already does this, but the
|
|
2321
|
+
// agent path previously shipped invalid /pan: invocations in
|
|
2322
|
+
// developer_instructions (audit L3).
|
|
2323
|
+
const toml = convertClaudeAgentToCodexToml(
|
|
2324
|
+
convertSlashCommandsToCodexSkillMentions(stripSubTags(content))
|
|
2325
|
+
);
|
|
2026
2326
|
if (toml) {
|
|
2027
2327
|
const tomlName = entry.name.replace(/\.md$/, '.toml');
|
|
2028
2328
|
fs.writeFileSync(path.join(agentsDest, tomlName), toml);
|
|
@@ -2098,10 +2398,13 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
2098
2398
|
failures.push('VERSION');
|
|
2099
2399
|
}
|
|
2100
2400
|
|
|
2101
|
-
|
|
2102
|
-
// Write package.json to force CommonJS mode for PAN scripts
|
|
2103
|
-
//
|
|
2104
|
-
// Node
|
|
2401
|
+
{
|
|
2402
|
+
// Write package.json to force CommonJS mode for PAN scripts — for ALL
|
|
2403
|
+
// runtimes, INCLUDING Codex. The shipped hooks use require(); without this
|
|
2404
|
+
// marker Node walks up to the project's package.json and, in a
|
|
2405
|
+
// "type":"module" project, crashes every hook with "require is not defined"
|
|
2406
|
+
// (H1, ADR audit 2026-08). The uninstall step (see "Remove PAN package.json")
|
|
2407
|
+
// removes this marker for any runtime, so writing it for Codex is symmetric.
|
|
2105
2408
|
try {
|
|
2106
2409
|
const pkgJsonDest = path.join(targetDir, 'package.json');
|
|
2107
2410
|
fs.writeFileSync(pkgJsonDest, '{"type":"commonjs"}\n');
|
|
@@ -2126,16 +2429,36 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
2126
2429
|
const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
|
|
2127
2430
|
for (const entry of hookEntries) {
|
|
2128
2431
|
const srcFile = path.join(hooksSrc, entry);
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2432
|
+
const destFile = path.join(hooksDest, entry);
|
|
2433
|
+
// Read straight through rather than statSync-then-read. hooks/dist is flat,
|
|
2434
|
+
// so "not a regular file" is a skip, and letting the read itself report that
|
|
2435
|
+
// closes the check-then-use gap CodeQL flagged (js/file-system-race).
|
|
2436
|
+
try {
|
|
2437
|
+
// Template .js files to replace '.claude' with runtime-specific config dir.
|
|
2438
|
+
// '.claude' plays two roles in the hooks: home-anchored (cache dir,
|
|
2439
|
+
// global VERSION → machine-global config dir) and project-anchored
|
|
2440
|
+
// (project VERSION → per-project config dir). Templating both with a
|
|
2441
|
+
// single token planted a stray ~/.github (Copilot --local) and a dead
|
|
2442
|
+
// project VERSION check (Copilot --global), since Copilot's global dir
|
|
2443
|
+
// is .copilot but its project dir is .github (audit L37). Resolve each
|
|
2444
|
+
// role independently and context-anchored so it's correct in both modes.
|
|
2132
2445
|
if (entry.endsWith('.js')) {
|
|
2133
2446
|
let content = fs.readFileSync(srcFile, 'utf8');
|
|
2447
|
+
const homeDirToken = getConfigDirFromHome(runtime, true); // machine-global config dir
|
|
2448
|
+
const projectDirToken = `'${getDirName(runtime)}'`; // per-project config dir
|
|
2449
|
+
content = content.replace(/(join\(\s*homeDir\s*,\s*)'\.claude'/g, `$1${homeDirToken}`);
|
|
2450
|
+
content = content.replace(/(join\(\s*cwd\s*,\s*)'\.claude'/g, `$1${projectDirToken}`);
|
|
2451
|
+
// Fallback for any unanchored '.claude' occurrences.
|
|
2134
2452
|
content = content.replace(/'\.claude'/g, configDirReplacement);
|
|
2135
2453
|
fs.writeFileSync(destFile, content);
|
|
2136
2454
|
} else {
|
|
2137
2455
|
fs.copyFileSync(srcFile, destFile);
|
|
2138
2456
|
}
|
|
2457
|
+
} catch (e) {
|
|
2458
|
+
// A directory (EISDIR, or EPERM for a copy on Windows) or an entry that
|
|
2459
|
+
// vanished between readdir and read (ENOENT) is simply not a hook — skip
|
|
2460
|
+
// it. Anything else is a real failure and belongs to the outer handler.
|
|
2461
|
+
if (e.code !== 'EISDIR' && e.code !== 'ENOENT' && e.code !== 'EPERM') throw e;
|
|
2139
2462
|
}
|
|
2140
2463
|
}
|
|
2141
2464
|
if (verifyInstalled(hooksDest, 'hooks')) {
|
|
@@ -2261,6 +2584,9 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
2261
2584
|
const traceLoggerCommand = isGlobal
|
|
2262
2585
|
? buildHookCommand(targetDir, 'pan-trace-logger.js')
|
|
2263
2586
|
: 'node ' + dirName + '/hooks/pan-trace-logger.js';
|
|
2587
|
+
const stopGuardCommand = isGlobal
|
|
2588
|
+
? buildHookCommand(targetDir, 'pan-stop-guard.js')
|
|
2589
|
+
: 'node ' + dirName + '/hooks/pan-stop-guard.js';
|
|
2264
2590
|
|
|
2265
2591
|
if (isCodex) {
|
|
2266
2592
|
// Codex hooks (2026-06): Claude-compatible PascalCase events in the shared
|
|
@@ -2275,7 +2601,10 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
2275
2601
|
updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand,
|
|
2276
2602
|
});
|
|
2277
2603
|
fs.writeFileSync(hooksJsonPath, JSON.stringify(merged, null, 2) + '\n');
|
|
2278
|
-
|
|
2604
|
+
// Print the path we actually wrote. The hardcoded '.codex/hooks.json' was
|
|
2605
|
+
// wrong for --global and for --config-dir, telling users to inspect a file
|
|
2606
|
+
// that does not exist while the real one sat elsewhere.
|
|
2607
|
+
console.log(` ${green}✓${reset} Configured hooks (${displayPath(hooksJsonPath)}: update check, context monitor, cost + trace loggers)`);
|
|
2279
2608
|
} catch (e) {
|
|
2280
2609
|
pushInstallWarning('codexHooks', 'hooks.json', e);
|
|
2281
2610
|
}
|
|
@@ -2291,7 +2620,8 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
2291
2620
|
try {
|
|
2292
2621
|
fs.mkdirSync(path.dirname(hooksConfigPath), { recursive: true });
|
|
2293
2622
|
fs.writeFileSync(hooksConfigPath, JSON.stringify(hooksConfig, null, 2) + '\n');
|
|
2294
|
-
|
|
2623
|
+
// Same as the Codex case: a global Copilot install writes to ~/.copilot/, not .github/.
|
|
2624
|
+
console.log(` ${green}✓${reset} Configured hooks (${displayPath(hooksConfigPath)}: update check, context monitor, cost + trace loggers)`);
|
|
2295
2625
|
} catch (e) {
|
|
2296
2626
|
console.error(` ${yellow}✗${reset} Failed to write Copilot hooks config: ${e.message}`);
|
|
2297
2627
|
}
|
|
@@ -2302,6 +2632,10 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
2302
2632
|
// .github/copilot/ repo-level); config.json is internal CLI state.
|
|
2303
2633
|
const configPath = path.join(targetDir, 'config.json');
|
|
2304
2634
|
const config = readSettings(configPath);
|
|
2635
|
+
// Unusable legacy config: skip the migration rather than rewrite the file.
|
|
2636
|
+
// Silent here (no warn) — config.json is internal CLI state the user did not
|
|
2637
|
+
// author, and settings.json is the surface that gets the warning.
|
|
2638
|
+
if (config === null) return;
|
|
2305
2639
|
let legacyModified = false;
|
|
2306
2640
|
if (config.hooks) {
|
|
2307
2641
|
for (const evt of ['sessionStart', 'postToolUse']) {
|
|
@@ -2330,6 +2664,9 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
2330
2664
|
? path.join(targetDir, 'settings.json')
|
|
2331
2665
|
: path.join(targetDir, 'copilot', 'settings.json');
|
|
2332
2666
|
const copilotSettings = readSettings(copilotSettingsPath);
|
|
2667
|
+
if (settingsUnusable(copilotSettings, copilotSettingsPath, 'statusline configuration')) {
|
|
2668
|
+
return { settingsPath: copilotSettingsPath, settings: null, statuslineCommand, runtime };
|
|
2669
|
+
}
|
|
2333
2670
|
|
|
2334
2671
|
return { settingsPath: copilotSettingsPath, settings: copilotSettings, statuslineCommand, runtime };
|
|
2335
2672
|
}
|
|
@@ -2337,7 +2674,13 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
2337
2674
|
// Configure statusline and hooks in settings.json
|
|
2338
2675
|
// Claude Code, Gemini, OpenCode use settings.json
|
|
2339
2676
|
const settingsPath = path.join(targetDir, 'settings.json');
|
|
2340
|
-
const
|
|
2677
|
+
const rawSettings = readSettings(settingsPath);
|
|
2678
|
+
if (settingsUnusable(rawSettings, settingsPath, 'statusline and hook configuration')) {
|
|
2679
|
+
// Returning the path with settings:null tells finishInstall to skip every
|
|
2680
|
+
// write. Overwriting would destroy whatever the user has in there.
|
|
2681
|
+
return { settingsPath, settings: null, statuslineCommand, runtime };
|
|
2682
|
+
}
|
|
2683
|
+
const settings = cleanupOrphanedHooks(rawSettings);
|
|
2341
2684
|
|
|
2342
2685
|
// Enable experimental agents for Gemini CLI (required for custom sub-agents)
|
|
2343
2686
|
if (isGemini) {
|
|
@@ -2433,6 +2776,30 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
2433
2776
|
});
|
|
2434
2777
|
console.log(` ${green}✓${reset} Configured trace logger hook`);
|
|
2435
2778
|
}
|
|
2779
|
+
|
|
2780
|
+
// v3.23+ (P-1809): Stop hook guarding the auto-advance phase boundary.
|
|
2781
|
+
// Blocks a session stop ONCE when workflow.auto_advance is armed, state.md
|
|
2782
|
+
// says "ready to plan", and the roadmap has unbuilt phases — the exact
|
|
2783
|
+
// boundary-drop fingerprint from the 2026-08 field runs. Fail-open and
|
|
2784
|
+
// inert outside PAN projects; hosts that never fire Stop never trigger it
|
|
2785
|
+
// (same convention as the SubagentStop registrations above).
|
|
2786
|
+
if (!settings.hooks.Stop) {
|
|
2787
|
+
settings.hooks.Stop = [];
|
|
2788
|
+
}
|
|
2789
|
+
const hasStopGuardHook = settings.hooks.Stop.some(entry =>
|
|
2790
|
+
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('pan-stop-guard'))
|
|
2791
|
+
);
|
|
2792
|
+
if (!hasStopGuardHook) {
|
|
2793
|
+
settings.hooks.Stop.push({
|
|
2794
|
+
hooks: [
|
|
2795
|
+
{
|
|
2796
|
+
type: 'command',
|
|
2797
|
+
command: stopGuardCommand
|
|
2798
|
+
}
|
|
2799
|
+
]
|
|
2800
|
+
});
|
|
2801
|
+
console.log(` ${green}✓${reset} Configured auto-advance stop guard hook`);
|
|
2802
|
+
}
|
|
2436
2803
|
}
|
|
2437
2804
|
|
|
2438
2805
|
return { settingsPath, settings, statuslineCommand, runtime };
|
|
@@ -2441,28 +2808,48 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
2441
2808
|
/**
|
|
2442
2809
|
* Apply statusline config, then print completion message
|
|
2443
2810
|
*/
|
|
2444
|
-
function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true) {
|
|
2811
|
+
function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true, isPrimaryStatusline = true) {
|
|
2812
|
+
// settings === null means the existing file could not be parsed and the caller
|
|
2813
|
+
// already warned. Every branch below merges into `settings` and writes it back,
|
|
2814
|
+
// so proceeding would replace the user's file with PAN's keys alone. Skip, and
|
|
2815
|
+
// do NOT print the "Configured …" ticks — an install that silently reports
|
|
2816
|
+
// success while configuring nothing was its own finding in the 2026-08 test.
|
|
2817
|
+
if (settings === null) return;
|
|
2445
2818
|
const isOpencode = runtime === 'opencode';
|
|
2446
2819
|
const isCodex = runtime === 'codex';
|
|
2447
2820
|
const isCopilot = runtime === 'copilot';
|
|
2448
2821
|
|
|
2449
2822
|
if (shouldInstallStatusline && !isOpencode && !isCodex) {
|
|
2450
|
-
//
|
|
2451
|
-
//
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2823
|
+
// Preserve a user's EXISTING custom statusline in THIS runtime's settings.
|
|
2824
|
+
// finishInstall runs per runtime, so this is the per-runtime check the old
|
|
2825
|
+
// primary-only guard skipped — it clobbered custom Gemini/Copilot
|
|
2826
|
+
// statuslines (M6, ADR audit 2026-08). PAN's own statusline is replaceable.
|
|
2827
|
+
const existingCmd = settings.statusLine && settings.statusLine.command;
|
|
2828
|
+
const isPanStatusline = typeof existingCmd === 'string' && /pan-statusline/.test(existingCmd);
|
|
2829
|
+
// Preserve an existing custom statusline only for SECONDARY runtimes (not
|
|
2830
|
+
// prompted). The primary runtime's shouldInstallStatusline already carries
|
|
2831
|
+
// the user's interactive answer, so don't second-guess it (N4 fix).
|
|
2832
|
+
if (settings.statusLine && !isPanStatusline && !isPrimaryStatusline && !args.includes('--force-statusline')) {
|
|
2833
|
+
console.log(` ${dim}ℹ Kept your existing statusline (pass --force-statusline to replace it)${reset}`);
|
|
2834
|
+
} else {
|
|
2835
|
+
// Same schema everywhere — Copilot CLI also uses {type: "command", command}
|
|
2836
|
+
// in settings.json (statusline is experimental there as of 2026-05).
|
|
2837
|
+
settings.statusLine = {
|
|
2838
|
+
type: 'command',
|
|
2839
|
+
command: statuslineCommand
|
|
2840
|
+
};
|
|
2841
|
+
console.log(` ${green}✓${reset} Configured statusline`);
|
|
2842
|
+
if (isCopilot) {
|
|
2843
|
+
console.log(` ${dim}ℹ Copilot CLI statusline is experimental — if it doesn't render, start with 'copilot --experimental'${reset}`);
|
|
2844
|
+
}
|
|
2459
2845
|
}
|
|
2460
2846
|
}
|
|
2461
2847
|
|
|
2462
|
-
// Write settings/config when runtime supports it.
|
|
2463
|
-
//
|
|
2464
|
-
// .github/copilot/settings.json
|
|
2465
|
-
|
|
2848
|
+
// Write settings/config when runtime supports it. Skip the write entirely when
|
|
2849
|
+
// there is nothing to persist — avoids creating a spurious empty settings.json
|
|
2850
|
+
// (Copilot's .github/copilot/settings.json and, notably, OpenCode's .opencode/
|
|
2851
|
+
// settings.json, which OpenCode doesn't even use — its config is opencode.json).
|
|
2852
|
+
if (!isCodex && Object.keys(settings).length > 0) {
|
|
2466
2853
|
if (isCopilot) {
|
|
2467
2854
|
try { fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); } catch { /* surfaced by writeSettings */ }
|
|
2468
2855
|
}
|
|
@@ -2474,13 +2861,17 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
2474
2861
|
configureOpencodePermissions(isGlobal);
|
|
2475
2862
|
}
|
|
2476
2863
|
|
|
2477
|
-
// E-9
|
|
2478
|
-
//
|
|
2864
|
+
// E-9 Model-capability integration (the name docs/ARCHITECTURE.md uses for
|
|
2865
|
+
// this mechanism) — warn if user's default model lacks features Spec A relies
|
|
2866
|
+
// on (1M ctx, extended thinking, prompt caching). Advisory only — there is no
|
|
2867
|
+
// runtime capability gate, and as of this writing this notice is the whole
|
|
2868
|
+
// reason detectModelCapabilities exists in the shipped path.
|
|
2479
2869
|
if (!args.includes('--skip-warnings')) {
|
|
2480
2870
|
try {
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2871
|
+
// Use the `settings` param already resolved for this install. The prior
|
|
2872
|
+
// re-derivation read a nonexistent `targetDir`, throwing a ReferenceError
|
|
2873
|
+
// that the bare catch swallowed — so this warning could never fire (M5,
|
|
2874
|
+
// ADR audit 2026-08).
|
|
2484
2875
|
const modelField = settings && settings.model;
|
|
2485
2876
|
if (typeof modelField === 'string' && modelField.trim()) {
|
|
2486
2877
|
const caps = detectModelCapabilities(modelField);
|
|
@@ -2491,8 +2882,9 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
2491
2882
|
].filter(Boolean).join(', ');
|
|
2492
2883
|
console.log(`
|
|
2493
2884
|
${yellow}ℹ${reset} PAN's multi-agent workflows are tuned for frontier reasoning models. Default model "${modelField}" lacks: ${missing}.
|
|
2494
|
-
Features degrade gracefully, but for best results
|
|
2495
|
-
|
|
2885
|
+
Features degrade gracefully, but for best results set your default model to one with a 1M-token context window
|
|
2886
|
+
and extended thinking — ${RECOMMENDED_MODELS.flagship} is PAN's recommended flagship (deepest long-horizon
|
|
2887
|
+
reasoning for the bot army), or an Opus-tier model (${RECOMMENDED_MODELS.reasoningTier}) at lower cost.`);
|
|
2496
2888
|
}
|
|
2497
2889
|
}
|
|
2498
2890
|
} catch {
|
|
@@ -2533,6 +2925,13 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
2533
2925
|
* Handle statusline configuration with optional prompt
|
|
2534
2926
|
*/
|
|
2535
2927
|
function handleStatusline(settings, isInteractive, callback) {
|
|
2928
|
+
// settings === null means readSettings could not use the existing file and the
|
|
2929
|
+
// caller already warned. There is nothing to prompt about and nothing we may
|
|
2930
|
+
// write, so decline the statusline without asking.
|
|
2931
|
+
if (settings === null) {
|
|
2932
|
+
callback(false);
|
|
2933
|
+
return;
|
|
2934
|
+
}
|
|
2536
2935
|
const hasExisting = settings.statusLine != null;
|
|
2537
2936
|
|
|
2538
2937
|
if (!hasExisting) {
|
|
@@ -2644,13 +3043,20 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
|
|
|
2644
3043
|
const finalize = (shouldInstallStatusline) => {
|
|
2645
3044
|
for (const result of results) {
|
|
2646
3045
|
const useStatusline = statuslineRuntimes.includes(result.runtime) && shouldInstallStatusline;
|
|
3046
|
+
// The primary runtime is the one handleStatusline actually prompted about,
|
|
3047
|
+
// so its shouldInstallStatusline reflects explicit user consent — honor it
|
|
3048
|
+
// even over an existing custom statusline. Secondary runtimes were NOT
|
|
3049
|
+
// prompted, so their existing custom statuslines are preserved (N4 fix keeps
|
|
3050
|
+
// the interactive "replace" working; M6 still protects the others).
|
|
3051
|
+
const isPrimaryStatusline = !!primaryStatuslineResult && result.runtime === primaryStatuslineResult.runtime;
|
|
2647
3052
|
finishInstall(
|
|
2648
3053
|
result.settingsPath,
|
|
2649
3054
|
result.settings,
|
|
2650
3055
|
result.statuslineCommand,
|
|
2651
3056
|
useStatusline,
|
|
2652
3057
|
result.runtime,
|
|
2653
|
-
isGlobal
|
|
3058
|
+
isGlobal,
|
|
3059
|
+
isPrimaryStatusline
|
|
2654
3060
|
);
|
|
2655
3061
|
}
|
|
2656
3062
|
};
|