pan-wizard 3.34.0 → 3.35.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 +6 -6
- package/agents/pan-conductor.md +2 -2
- package/agents/pan-debugger.md +3 -1
- package/agents/pan-design-checker.md +1 -0
- package/agents/pan-executor.md +18 -1
- package/agents/pan-experiment-runner.md +1 -1
- package/agents/pan-hardener.md +1 -1
- package/agents/pan-meta-reviewer.md +1 -1
- package/agents/pan-phase-researcher.md +2 -2
- package/agents/pan-plan-checker.md +9 -8
- package/agents/pan-planner.md +3 -1
- package/agents/pan-project-researcher.md +1 -1
- package/agents/pan-research-synthesizer.md +1 -1
- package/agents/pan-reviewer.md +3 -2
- package/agents/pan-verifier.md +1 -1
- package/bin/install-lib.cjs +119 -16
- package/bin/install.js +34 -62
- package/commands/pan/cost.md +0 -4
- package/commands/pan/dashboard.md +0 -3
- package/commands/pan/design-phase.md +2 -2
- package/commands/pan/hud.md +1 -5
- package/commands/pan/knowledge.md +0 -1
- package/commands/pan/mcp-bridge.md +1 -3
- package/commands/pan/optimize.md +2 -0
- package/commands/pan/phase-tests.md +1 -1
- package/commands/pan/preview.md +1 -2
- package/commands/pan/report.md +1 -5
- package/commands/pan/research-phase.md +4 -4
- package/commands/pan/review-deep.md +0 -2
- package/commands/pan/what-if.md +1 -2
- package/hooks/dist/pan-context-monitor.js +12 -3
- package/hooks/dist/pan-guard.js +244 -0
- package/hooks/dist/pan-statusline.js +5 -2
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/commands.cjs +24 -2
- package/pan-wizard-core/bin/lib/config.cjs +5 -0
- package/pan-wizard-core/bin/lib/findings.cjs +126 -19
- package/pan-wizard-core/bin/lib/hygiene.cjs +11 -5
- package/pan-wizard-core/bin/lib/init.cjs +6 -0
- package/pan-wizard-core/bin/lib/optimize.cjs +13 -1
- package/pan-wizard-core/bin/lib/phase-gate.cjs +174 -0
- package/pan-wizard-core/bin/lib/phase.cjs +24 -0
- package/pan-wizard-core/bin/lib/preview.cjs +32 -31
- package/pan-wizard-core/bin/lib/verdict.cjs +140 -10
- package/pan-wizard-core/bin/lib/verify-integrity.cjs +181 -0
- package/pan-wizard-core/bin/lib/verify-plan-set.cjs +230 -0
- package/pan-wizard-core/bin/lib/verify-scope.cjs +6 -5
- package/pan-wizard-core/bin/lib/whatif.cjs +48 -6
- package/pan-wizard-core/bin/pan-tools.cjs +13 -4
- package/pan-wizard-core/mcp/merge-gate.cjs +14 -3
- package/pan-wizard-core/mcp/native-tools.cjs +3 -3
- package/pan-wizard-core/opencode/pan-wizard.js +36 -8
- package/pan-wizard-core/references/model-profiles.md +1 -1
- package/pan-wizard-core/references/planning-config.md +3 -3
- package/pan-wizard-core/references/verdict-contract.md +6 -1
- package/pan-wizard-core/templates/config.json +2 -6
- package/pan-wizard-core/templates/debug-subagent-prompt.md +2 -1
- package/pan-wizard-core/templates/phase-prompt.md +2 -0
- package/pan-wizard-core/templates/planner-subagent-prompt.md +2 -0
- package/pan-wizard-core/templates/preview-report.md +1 -1
- package/pan-wizard-core/templates/state.md +4 -4
- package/pan-wizard-core/workflows/diagnose-issues.md +4 -4
- package/pan-wizard-core/workflows/discuss-phase.md +2 -1
- package/pan-wizard-core/workflows/exec-phase.md +51 -40
- package/pan-wizard-core/workflows/milestone-gaps.md +2 -2
- package/pan-wizard-core/workflows/new-project.md +8 -16
- package/pan-wizard-core/workflows/plan-phase.md +22 -8
- package/pan-wizard-core/workflows/progress.md +1 -1
- package/pan-wizard-core/workflows/quick.md +2 -2
- package/pan-wizard-core/workflows/todo-check.md +3 -2
- package/pan-wizard-core/workflows/transition.md +4 -0
- package/pan-wizard-core/workflows/verify-phase.md +6 -6
- package/scripts/build-hooks.js +1 -0
- package/scripts/generate-skills-docs.py +2 -2
- package/scripts/prompt-lint.cjs +167 -0
- package/scripts/test-surface.cjs +1 -0
package/bin/install.js
CHANGED
|
@@ -369,13 +369,11 @@ function getCommitAttribution(runtime) {
|
|
|
369
369
|
const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json')) || {}; // unusable file = no info (read-only probe)
|
|
370
370
|
result = lib.commitAttributionFromSettings(settings);
|
|
371
371
|
} else if (runtime === 'copilot') {
|
|
372
|
-
// Copilot CLI: user
|
|
373
|
-
//
|
|
372
|
+
// Copilot CLI: user settings live in settings.json only. config.json is the CLI's
|
|
373
|
+
// internal state, and Copilot 1.0.93 ignores user settings there (R48), so a
|
|
374
|
+
// stale `attribution` left in it must not decide PAN's attribution.
|
|
374
375
|
const copilotDir = getGlobalDir('copilot', explicitConfigDir);
|
|
375
|
-
|
|
376
|
-
if (!config.attribution) {
|
|
377
|
-
config = readSettings(path.join(copilotDir, 'config.json')) || {}; // unusable file = no info (read-only probe)
|
|
378
|
-
}
|
|
376
|
+
const config = readSettings(path.join(copilotDir, 'settings.json')) || {}; // unusable file = no info (read-only probe)
|
|
379
377
|
if (!config.attribution || config.attribution.commit === undefined) {
|
|
380
378
|
result = undefined;
|
|
381
379
|
} else if (config.attribution.commit === '') {
|
|
@@ -1222,7 +1220,7 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
1222
1220
|
// 4. Remove PAN hooks (scripts + Copilot CLI hooks config file)
|
|
1223
1221
|
const hooksDir = path.join(targetDir, 'hooks');
|
|
1224
1222
|
if (fs.existsSync(hooksDir)) {
|
|
1225
|
-
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-state-reinject.js', 'pan.json'];
|
|
1223
|
+
const panHooks = ['pan-statusline.js', 'pan-check-update.js', 'pan-check-update.sh', 'pan-guard.js', 'pan-context-monitor.js', 'pan-cost-logger.js', 'pan-trace-logger.js', 'pan-stop-guard.js', 'pan-state-reinject.js', 'pan.json'];
|
|
1226
1224
|
let hookCount = 0;
|
|
1227
1225
|
for (const hook of panHooks) {
|
|
1228
1226
|
const hookPath = path.join(hooksDir, hook);
|
|
@@ -1576,35 +1574,12 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
1576
1574
|
// CodeQL js/file-system-race. Same idiom as the hooks' metrics read.
|
|
1577
1575
|
try {
|
|
1578
1576
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
1579
|
-
let modified
|
|
1580
|
-
|
|
1581
|
-
// Remove PAN permission entries
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
const keys = Object.keys(config.permission[permType]);
|
|
1586
|
-
for (const key of keys) {
|
|
1587
|
-
// Match `pan-wizard` rather than `pan-wizard-core` so the malformed
|
|
1588
|
-
// key earlier versions wrote (`~/.config/opencode/pan-wizard/*`) is
|
|
1589
|
-
// cleaned too. Without this, machines that ran any global OpenCode
|
|
1590
|
-
// install before the glob was fixed keep that entry forever — the
|
|
1591
|
-
// uninstaller could never match it, which is what made it residue
|
|
1592
|
-
// rather than a cosmetic typo.
|
|
1593
|
-
if (key.includes('pan-wizard')) {
|
|
1594
|
-
delete config.permission[permType][key];
|
|
1595
|
-
modified = true;
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
// Clean up empty objects
|
|
1599
|
-
if (Object.keys(config.permission[permType]).length === 0) {
|
|
1600
|
-
delete config.permission[permType];
|
|
1601
|
-
}
|
|
1602
|
-
}
|
|
1603
|
-
}
|
|
1604
|
-
if (Object.keys(config.permission).length === 0) {
|
|
1605
|
-
delete config.permission;
|
|
1606
|
-
}
|
|
1607
|
-
}
|
|
1577
|
+
let modified;
|
|
1578
|
+
|
|
1579
|
+
// Remove PAN permission entries, v1 `permission` keys and OpenCode 2
|
|
1580
|
+
// `permissions[]` rules alike (R47). Keys matching `pan-wizard` rather than
|
|
1581
|
+
// `pan-wizard-core` also clean the malformed key earlier versions wrote.
|
|
1582
|
+
({ modified } = lib.stripOpencodePermissions(config));
|
|
1608
1583
|
|
|
1609
1584
|
if (modified) {
|
|
1610
1585
|
// If stripping PAN permissions emptied the config, remove the file
|
|
@@ -1718,6 +1693,18 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
1718
1693
|
console.log(` ${yellow}⚠${reset} No PAN files found to remove.`);
|
|
1719
1694
|
}
|
|
1720
1695
|
|
|
1696
|
+
// A local install's runtime directory that the removal left empty goes too (X1).
|
|
1697
|
+
// Never a global one (~/.claude and the like are the host's own homes), and never a
|
|
1698
|
+
// directory that still holds anything: `.github` usually holds the project's files.
|
|
1699
|
+
if (!isGlobal) {
|
|
1700
|
+
try {
|
|
1701
|
+
if (fs.readdirSync(targetDir).length === 0) {
|
|
1702
|
+
fs.rmdirSync(targetDir);
|
|
1703
|
+
console.log(` ${green}✓${reset} Removed empty ${locationLabel}`);
|
|
1704
|
+
}
|
|
1705
|
+
} catch { /* already gone or unreadable: leave it */ }
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1721
1708
|
console.log(`
|
|
1722
1709
|
${green}Done!${reset} PAN has been uninstalled from ${runtimeLabel}.
|
|
1723
1710
|
Your other files and settings have been preserved.
|
|
@@ -1760,10 +1747,6 @@ function configureOpencodePermissions(isGlobal = true) {
|
|
|
1760
1747
|
}
|
|
1761
1748
|
}
|
|
1762
1749
|
|
|
1763
|
-
// Ensure permission structure exists
|
|
1764
|
-
if (!config.permission) {
|
|
1765
|
-
config.permission = {};
|
|
1766
|
-
}
|
|
1767
1750
|
|
|
1768
1751
|
// Build the PAN path using the actual config directory
|
|
1769
1752
|
// Use ~ shorthand if it's in the default location, otherwise use full path
|
|
@@ -1780,25 +1763,9 @@ function configureOpencodePermissions(isGlobal = true) {
|
|
|
1780
1763
|
? '~/.config/opencode/pan-wizard-core/*'
|
|
1781
1764
|
: `${opencodeConfigDir.replace(/\\/g, '/')}/pan-wizard-core/*`;
|
|
1782
1765
|
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
if (!config.permission.read || typeof config.permission.read !== 'object') {
|
|
1787
|
-
config.permission.read = {};
|
|
1788
|
-
}
|
|
1789
|
-
if (config.permission.read[panPath] !== 'allow') {
|
|
1790
|
-
config.permission.read[panPath] = 'allow';
|
|
1791
|
-
modified = true;
|
|
1792
|
-
}
|
|
1793
|
-
|
|
1794
|
-
// Configure external_directory permission (the safety guard for paths outside project)
|
|
1795
|
-
if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') {
|
|
1796
|
-
config.permission.external_directory = {};
|
|
1797
|
-
}
|
|
1798
|
-
if (config.permission.external_directory[panPath] !== 'allow') {
|
|
1799
|
-
config.permission.external_directory[panPath] = 'allow';
|
|
1800
|
-
modified = true;
|
|
1801
|
-
}
|
|
1766
|
+
// v1 `permission` object in a new or v1 file; the native `permissions[]` array
|
|
1767
|
+
// in a file the user already converted to OpenCode 2, never both (R47).
|
|
1768
|
+
const { modified } = lib.addOpencodePermissions(config, panPath);
|
|
1802
1769
|
|
|
1803
1770
|
if (!modified) {
|
|
1804
1771
|
return; // Already configured
|
|
@@ -2785,6 +2752,10 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
2785
2752
|
const stateReinjectCommand = isGlobal
|
|
2786
2753
|
? buildHookCommand(targetDir, 'pan-state-reinject.js')
|
|
2787
2754
|
: 'node ' + dirName + '/hooks/pan-state-reinject.js';
|
|
2755
|
+
// The guard names its host: payloads and the way to deny differ per runtime (A7).
|
|
2756
|
+
const guardCommand = (isGlobal
|
|
2757
|
+
? buildHookCommand(targetDir, 'pan-guard.js')
|
|
2758
|
+
: 'node ' + dirName + '/hooks/pan-guard.js') + ` --host ${runtime}`;
|
|
2788
2759
|
// Gemini and Copilot (M33): a marker before the compaction, the block on the next
|
|
2789
2760
|
// tool result. The inject mode names the host, whose output shapes differ.
|
|
2790
2761
|
const stateReinjectMarkCommand = `${stateReinjectCommand} --mark`;
|
|
@@ -2798,7 +2769,7 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
2798
2769
|
if (!isOpencode) {
|
|
2799
2770
|
const entrypoints = lib.verifyHookEntrypoints([
|
|
2800
2771
|
updateCheckCommand, contextMonitorCommand, statuslineCommand, costLoggerCommand,
|
|
2801
|
-
traceLoggerCommand, stopGuardCommand, stateReinjectCommand, stateReinjectMarkCommand, stateReinjectInjectCommand,
|
|
2772
|
+
traceLoggerCommand, stopGuardCommand, stateReinjectCommand, stateReinjectMarkCommand, stateReinjectInjectCommand, guardCommand,
|
|
2802
2773
|
], process.cwd());
|
|
2803
2774
|
if (!entrypoints.ok) {
|
|
2804
2775
|
console.error(`\n ${red}✖ Hook verification FAILED — these hook commands would run a script that is not there:${reset}`);
|
|
@@ -2818,7 +2789,7 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
2818
2789
|
let existing = null;
|
|
2819
2790
|
try { existing = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); } catch { /* absent or invalid — start fresh */ }
|
|
2820
2791
|
const merged = lib.mergeCodexHooksConfig(existing, {
|
|
2821
|
-
updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand, stopGuardCommand, stateReinjectCommand,
|
|
2792
|
+
updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand, stopGuardCommand, stateReinjectCommand, guardCommand,
|
|
2822
2793
|
});
|
|
2823
2794
|
fs.writeFileSync(hooksJsonPath, JSON.stringify(merged, null, 2) + '\n');
|
|
2824
2795
|
// Print the path we actually wrote. The hardcoded '.codex/hooks.json' was
|
|
@@ -2836,7 +2807,7 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
2836
2807
|
// from any legacy config.json registration.
|
|
2837
2808
|
if (isCopilot) {
|
|
2838
2809
|
const hooksConfigPath = path.join(targetDir, 'hooks', 'pan.json');
|
|
2839
|
-
const hooksConfig = buildCopilotHooksConfig({ updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand, stopGuardCommand, stateReinjectMarkCommand, stateReinjectInjectCommand });
|
|
2810
|
+
const hooksConfig = buildCopilotHooksConfig({ updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand, stopGuardCommand, stateReinjectMarkCommand, stateReinjectInjectCommand, guardCommand });
|
|
2840
2811
|
try {
|
|
2841
2812
|
fs.mkdirSync(path.dirname(hooksConfigPath), { recursive: true });
|
|
2842
2813
|
fs.writeFileSync(hooksConfigPath, JSON.stringify(hooksConfig, null, 2) + '\n');
|
|
@@ -2930,6 +2901,7 @@ function install(isGlobal, runtime = 'claude') {
|
|
|
2930
2901
|
// unbuilt phases — fail-open and inert outside PAN projects.
|
|
2931
2902
|
const registrations = [
|
|
2932
2903
|
{ slot: 'sessionStart', hook: 'pan-check-update', command: updateCheckCommand, label: 'update check hook' },
|
|
2904
|
+
{ slot: 'preToolUse', hook: 'pan-guard', command: guardCommand, label: 'pre-tool guard hook' },
|
|
2933
2905
|
{ slot: 'postToolUse', hook: 'pan-context-monitor', command: contextMonitorCommand, label: 'context window monitor hook' },
|
|
2934
2906
|
{ slot: 'subagentStop', hook: 'pan-cost-logger', command: costLoggerCommand, label: 'cost logger hook' },
|
|
2935
2907
|
{ slot: 'subagentStop', hook: 'pan-trace-logger', command: traceLoggerCommand, label: 'trace logger hook' },
|
package/commands/pan/cost.md
CHANGED
|
@@ -16,10 +16,6 @@ Reads `.planning/metrics/tokens.jsonl` — an append-only log where each line is
|
|
|
16
16
|
Default output is JSON for piping. Use `--format table` for human-readable tables or `--format chart` for an ASCII bar chart of daily spend; both print text, with or without `--raw`.
|
|
17
17
|
</objective>
|
|
18
18
|
|
|
19
|
-
<execution_context>
|
|
20
|
-
@~/.claude/pan-wizard-core/bin/lib/cost.cjs
|
|
21
|
-
</execution_context>
|
|
22
|
-
|
|
23
19
|
<subcommands>
|
|
24
20
|
|
|
25
21
|
### `report` (default)
|
|
@@ -20,6 +20,3 @@ pan-tools hud [--out <file>] [--open] [--stdout]
|
|
|
20
20
|
See **`/pan:hud`** for the full panel list, flags, JSON result shape, and runtime compatibility. The two commands are interchangeable.
|
|
21
21
|
</objective>
|
|
22
22
|
|
|
23
|
-
<execution_context>
|
|
24
|
-
@~/.claude/pan-wizard-core/bin/lib/hud.cjs
|
|
25
|
-
</execution_context>
|
|
@@ -53,7 +53,7 @@ ELSE (default):
|
|
|
53
53
|
1. **Validate phase** — error if the phase number is missing or not in `roadmap.md`.
|
|
54
54
|
2. **Prime context** — resolve the phase directory; gather `{phase}-context.md`, `{phase}-research.md`, and the phase boundary from `roadmap.md`.
|
|
55
55
|
3. **Design** — spawn `pan-designer` with a `<files_to_read>` block (context, research, roadmap boundary, `CLAUDE.md`). It fills in `templates/design.md` at the `phase` tier (`spike` if `--spike`).
|
|
56
|
-
4. **Verify (reflexion loop)** — spawn `pan-design-checker` with the drafted design + the boundary source:
|
|
56
|
+
4. **Verify (reflexion loop)** — spawn `pan-design-checker` with the drafted design + the boundary source, plus an `<open_findings>` block holding `pan-tools findings list --phase {phase} --agent pan-design-checker --status open` (empty on the first check):
|
|
57
57
|
- If it PASSES → done.
|
|
58
58
|
- If it finds gaps (iteration 1) → `pan-designer` revises (address genuine gaps only), re-check.
|
|
59
59
|
- If it finds gaps (iteration 2) → final revision, record remaining gaps as caveats.
|
|
@@ -62,7 +62,7 @@ ELSE (default):
|
|
|
62
62
|
```bash
|
|
63
63
|
node ~/.claude/pan-wizard-core/bin/pan-tools.cjs findings record --phase "{phase}" --agent pan-design-checker --file "{phase_dir}/{padded_phase}-design-check.md" --raw
|
|
64
64
|
```
|
|
65
|
-
A re-check is the next attempt and closes the
|
|
65
|
+
A re-check is the next attempt. It answers each earlier gap in `resolutions`, and the record closes the ones answered `resolved` with evidence; a gap it neither reports nor answers stays open. When the final iteration still has gaps, record them as the caveats they are before writing the design:
|
|
66
66
|
```bash
|
|
67
67
|
node ~/.claude/pan-wizard-core/bin/pan-tools.cjs findings dispose --phase "{phase}" --agent pan-design-checker --open --as deferred --reason "design caveat after 2 revision iterations"
|
|
68
68
|
```
|
package/commands/pan/hud.md
CHANGED
|
@@ -16,10 +16,6 @@ It is a *view*, not a new source of truth: every panel aggregates state PAN alre
|
|
|
16
16
|
Default output is `.planning/hud.html`. Open it in any browser. Re-run any time for a fresh snapshot.
|
|
17
17
|
</objective>
|
|
18
18
|
|
|
19
|
-
<execution_context>
|
|
20
|
-
@~/.claude/pan-wizard-core/bin/lib/hud.cjs
|
|
21
|
-
</execution_context>
|
|
22
|
-
|
|
23
19
|
<usage>
|
|
24
20
|
|
|
25
21
|
```
|
|
@@ -62,7 +58,7 @@ Panels render only when they have data — a plain (non-army) project still gets
|
|
|
62
58
|
| **Requirements & quality** | Requirements done/open + last verification artifacts | `requirements.md`, phase `*-verification.md` / `*-uat.md` |
|
|
63
59
|
| **Recent activity** | Last commits (the army's committed output) | `git log` |
|
|
64
60
|
|
|
65
|
-
*(army)* panels appear only when a campaign is scheduled or army worktrees exist — graceful degradation per ADR-0035. The *(fallback)* **Planning activity** panel appears only when there is no phase/roadmap layout, so it fills the gap for focus-auto and imported projects instead of leaving a bare page. Telemetry shows an honest advisory (never a fabricated
|
|
61
|
+
*(army)* panels appear only when a campaign is scheduled or army worktrees exist — graceful degradation per ADR-0035. The *(fallback)* **Planning activity** panel appears only when there is no phase/roadmap layout, so it fills the gap for focus-auto and imported projects instead of leaving a bare page. Telemetry shows an honest advisory (never a fabricated zero-dollar total) when the cost ledger is poisoned or every record is unpriced.
|
|
66
62
|
|
|
67
63
|
</panels>
|
|
68
64
|
|
|
@@ -23,7 +23,6 @@ Consolidates Spec B v1's X-3 converse + X-6 teach + X-10 explain into one comman
|
|
|
23
23
|
</objective>
|
|
24
24
|
|
|
25
25
|
<execution_context>
|
|
26
|
-
@~/.claude/pan-wizard-core/bin/lib/knowledge.cjs
|
|
27
26
|
@~/.claude/agents/pan-knowledge.md
|
|
28
27
|
@~/.claude/pan-wizard-core/templates/playbook.md
|
|
29
28
|
</execution_context>
|
|
@@ -15,9 +15,7 @@ Surface Model Context Protocol (MCP) tools visible to the host runtime and recom
|
|
|
15
15
|
Reduced scope from Spec B v1's X-7: **discovery and recommendation only**. Auto-injection of MCP tools into planner context and auto-invocation from executor agents are deliberately deferred and still unscheduled (the v3.5 target passed without them). This kept v3.3 narrow and avoids coupling PAN to Claude Code's MCP schema stability.
|
|
16
16
|
</objective>
|
|
17
17
|
|
|
18
|
-
<
|
|
19
|
-
@~/.claude/pan-wizard-core/bin/lib/bridge.cjs
|
|
20
|
-
</execution_context>
|
|
18
|
+
Each subcommand runs `node ~/.claude/pan-wizard-core/bin/pan-tools.cjs bridge <list|recommend|cache> [flags]`. The CLI verb is `bridge`, not `mcp-bridge`: `pan-tools mcp-bridge` is an unknown command.
|
|
21
19
|
|
|
22
20
|
<subcommands>
|
|
23
21
|
|
package/commands/pan/optimize.md
CHANGED
|
@@ -111,3 +111,5 @@ List all trace sessions, most recent first.
|
|
|
111
111
|
Each iteration puts what the last run lacked where the next run's agents read it: fewer repeated errors and gaps.
|
|
112
112
|
|
|
113
113
|
**See also:** `/pan:learn`, `/pan:exec-phase`
|
|
114
|
+
|
|
115
|
+
Follow the workflow at `~/.claude/pan-wizard-core/workflows/optimize.md`. Each subcommand runs `node ~/.claude/pan-wizard-core/bin/pan-tools.cjs optimize <subcommand> [flags]` — e.g. `optimize apply --report <path>`, `optimize revert --last`, `optimize trace init --description "..."`.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pan:phase-tests
|
|
3
3
|
group: Phase Management
|
|
4
|
-
description: Generate tests for a completed phase
|
|
4
|
+
description: Generate tests for a completed phase from its summaries, context decisions, verification report and implementation
|
|
5
5
|
argument-hint: "<phase> [additional instructions]"
|
|
6
6
|
allowed-tools:
|
|
7
7
|
- Read
|
package/commands/pan/preview.md
CHANGED
|
@@ -19,7 +19,6 @@ Consolidates Spec B v1's architect + simulate + predict-milestone into one entry
|
|
|
19
19
|
</objective>
|
|
20
20
|
|
|
21
21
|
<execution_context>
|
|
22
|
-
@~/.claude/pan-wizard-core/bin/lib/preview.cjs
|
|
23
22
|
@~/.claude/pan-wizard-core/templates/preview-report.md
|
|
24
23
|
</execution_context>
|
|
25
24
|
|
|
@@ -74,7 +73,7 @@ Consolidates Spec B v1's architect + simulate + predict-milestone into one entry
|
|
|
74
73
|
|
|
75
74
|
**Before committing to a milestone date externally:** run `/pan:preview milestone`. Look at `confidence_pct` and `sample_size`. If sample is <3, don't promise a date.
|
|
76
75
|
|
|
77
|
-
**Before running phases in parallel:** run `/pan:preview phases`. Parallel batches from the data layer are based on declared `
|
|
76
|
+
**Before running phases in parallel:** run `/pan:preview phases`. Parallel batches from the data layer are based on declared dependencies only — the roadmap's `**Depends on:** Phase N` line and any `phase:N` entry in a plan's `depends_on`; `hidden_coupling_count > 0` means there are cross-phase references the author should promote to explicit deps (on the roadmap's Depends on line) before parallelizing.
|
|
78
77
|
|
|
79
78
|
</workflow>
|
|
80
79
|
|
package/commands/pan/report.md
CHANGED
|
@@ -13,13 +13,9 @@ Render **self-contained HTML reports** — no server, no network, no external CS
|
|
|
13
13
|
|
|
14
14
|
Like `pan:hud`, these are *views*, not a new source of truth: every value is read from what PAN already tracks on disk (the phase's `plan`/`summary`/`verification` artifacts and their frontmatter, `roadmap.md`, and the cost ledger). The command only writes the rendered file(s), so it can never corrupt planning data. Reports reuse the HUD's visual language, so they look like part of the same product.
|
|
15
15
|
|
|
16
|
-
Reports are honest by construction: the `verify reconcile` verdict is shown beside the (rubber-stampable) self-reported verification status, status is framed as a current-disk snapshot, and any spend is gated so a poisoned or unpriced ledger never renders a fake
|
|
16
|
+
Reports are honest by construction: the `verify reconcile` verdict is shown beside the (rubber-stampable) self-reported verification status, status is framed as a current-disk snapshot, and any spend is gated so a poisoned or unpriced ledger never renders a fake zero-dollar spend.
|
|
17
17
|
</objective>
|
|
18
18
|
|
|
19
|
-
<execution_context>
|
|
20
|
-
@~/.claude/pan-wizard-core/bin/lib/phase-report.cjs
|
|
21
|
-
</execution_context>
|
|
22
|
-
|
|
23
19
|
<usage>
|
|
24
20
|
|
|
25
21
|
```
|
|
@@ -137,8 +137,8 @@ Write to: {phase_dir}/{padded_phase}-research.md
|
|
|
137
137
|
|
|
138
138
|
```
|
|
139
139
|
Task(
|
|
140
|
-
prompt=
|
|
141
|
-
subagent_type="
|
|
140
|
+
prompt=filled_prompt,
|
|
141
|
+
subagent_type="pan-phase-researcher",
|
|
142
142
|
model="{researcher_model}",
|
|
143
143
|
description="Research Phase {phase}"
|
|
144
144
|
)
|
|
@@ -171,8 +171,8 @@ Continue research for Phase {phase_number}: {phase_name}
|
|
|
171
171
|
|
|
172
172
|
```
|
|
173
173
|
Task(
|
|
174
|
-
prompt=
|
|
175
|
-
subagent_type="
|
|
174
|
+
prompt=continuation_prompt,
|
|
175
|
+
subagent_type="pan-phase-researcher",
|
|
176
176
|
model="{researcher_model}",
|
|
177
177
|
description="Continue research Phase {phase}"
|
|
178
178
|
)
|
|
@@ -24,8 +24,6 @@ Consolidates Spec B v1's X-4 (self-review) + X-12 (harden) into a single command
|
|
|
24
24
|
</objective>
|
|
25
25
|
|
|
26
26
|
<execution_context>
|
|
27
|
-
@~/.claude/pan-wizard-core/bin/lib/review-deep.cjs
|
|
28
|
-
@~/.claude/pan-wizard-core/bin/lib/bus.cjs
|
|
29
27
|
@~/.claude/agents/pan-hardener.md
|
|
30
28
|
@~/.claude/agents/pan-meta-reviewer.md
|
|
31
29
|
</execution_context>
|
package/commands/pan/what-if.md
CHANGED
|
@@ -20,7 +20,6 @@ Unchanged from Spec B v1's X-9. Already narrow enough to stand alone.
|
|
|
20
20
|
</objective>
|
|
21
21
|
|
|
22
22
|
<execution_context>
|
|
23
|
-
@~/.claude/pan-wizard-core/bin/lib/whatif.cjs
|
|
24
23
|
@~/.claude/agents/pan-counterfactual.md
|
|
25
24
|
</execution_context>
|
|
26
25
|
|
|
@@ -80,7 +79,7 @@ This writes `.planning/counterfactuals/<phase>-<slug>.md`. The file belongs to t
|
|
|
80
79
|
pan-tools whatif cleanup --worktree <path> --branch <name> --force
|
|
81
80
|
```
|
|
82
81
|
|
|
83
|
-
Removes the worktree directory and deletes the counterfactual branch.
|
|
82
|
+
Removes the worktree directory and deletes the counterfactual branch. Pass the `worktree.worktree_path` and `worktree.branch` that `whatif prepare` returned, unchanged. Cleanup refuses (exit 1, nothing removed) a branch outside `pan-whatif/` and a worktree that is not checked out on that branch, so it can never delete your own work. Otherwise best-effort: warnings are surfaced but don't block.
|
|
84
83
|
|
|
85
84
|
### Stage 5 — Confirm
|
|
86
85
|
|
|
@@ -429,14 +429,23 @@ function buildContextWarning(metrics, warnState, nowSeconds, opts = {}) {
|
|
|
429
429
|
};
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
-
/**
|
|
433
|
-
|
|
432
|
+
/**
|
|
433
|
+
* Claude Code's settings for a project, highest precedence first: local, project, user.
|
|
434
|
+
* The user file lives in CLAUDE_CONFIG_DIR when that is set, as Claude Code reads it.
|
|
435
|
+
* A given homeDir is a test seam and ignores CLAUDE_CONFIG_DIR (as the statusline and
|
|
436
|
+
* hygiene do), unless configDir is passed too.
|
|
437
|
+
*/
|
|
438
|
+
function readClaudeSettings(projectDir, homeArg, configArg) {
|
|
439
|
+
const seam = homeArg !== undefined;
|
|
440
|
+
const homeDir = seam ? homeArg : os.homedir();
|
|
441
|
+
const configDir = configArg !== undefined ? configArg : (seam ? '' : (process.env.CLAUDE_CONFIG_DIR || ''));
|
|
434
442
|
// Assembled, not a literal: the installer rewrites quoted .claude literals in hook
|
|
435
443
|
// copies, and these are Claude Code's own settings (only it writes the bridge).
|
|
436
444
|
const dir = ['.', 'claude'].join('');
|
|
437
445
|
const files = [];
|
|
438
446
|
if (typeof projectDir === 'string' && projectDir) files.push(path.join(projectDir, dir, 'settings.local.json'), path.join(projectDir, dir, 'settings.json'));
|
|
439
|
-
if (
|
|
447
|
+
if (configDir) files.push(path.join(configDir, 'settings.json'));
|
|
448
|
+
else if (homeDir) files.push(path.join(homeDir, dir, 'settings.json'));
|
|
440
449
|
const out = [];
|
|
441
450
|
for (const f of files) {
|
|
442
451
|
try { out.push(JSON.parse(fs.readFileSync(f, 'utf8'))); } catch { /* absent or unreadable */ }
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// PAN guard — a pre-tool hook (program A7).
|
|
4
|
+
//
|
|
5
|
+
// Registered on the hosts with a pre-tool event that can deny a call: Claude Code
|
|
6
|
+
// and Codex (`PreToolUse`), Gemini CLI (`BeforeTool`) and Copilot CLI (`preToolUse`).
|
|
7
|
+
// OpenCode registers no hooks. The command carries `--host <runtime>`, because the
|
|
8
|
+
// payload and the way to deny differ per host.
|
|
9
|
+
//
|
|
10
|
+
// Rules, over the files a tool call is about to write:
|
|
11
|
+
// secret_write the written text holds a credential (known token shapes,
|
|
12
|
+
// or a long key=value secret)
|
|
13
|
+
// state_write a direct write to PAN's own state: state.md, findings.jsonl,
|
|
14
|
+
// the token ledger (pan-tools owns those)
|
|
15
|
+
// undeclared_test_edit a test file the phase in flight does not declare in any
|
|
16
|
+
// unfinished plan's files_modified
|
|
17
|
+
//
|
|
18
|
+
// Mode, from the planning tree's config.json `hooks.guard`:
|
|
19
|
+
// off nothing
|
|
20
|
+
// report (default) every match is appended to <planning>/guard.jsonl, the call proceeds
|
|
21
|
+
// enforce as report, and a secret_write is denied; the other rules still only report
|
|
22
|
+
// until the writers of PAN's state go through pan-tools verbs (A7b)
|
|
23
|
+
//
|
|
24
|
+
// Inert outside a PAN project. Fail-open on anything it cannot parse; never fail-open
|
|
25
|
+
// on a matched deny. Never writes outside the planning tree.
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
|
|
30
|
+
const TOKEN_RE = /\b(?:sk-ant-[A-Za-z0-9_-]{10,}|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,}|-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----)/;
|
|
31
|
+
const SECRET_KV_RE = /\b(api[_-]?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|client[_-]?secret|secret[_-]?key|password|passwd)\b["']?\s*[:=]\s*["']([^"'\s]{12,})["']/i;
|
|
32
|
+
const TEST_PATH_RE = /(^|\/)(tests?|__tests__|specs?)\//i;
|
|
33
|
+
const TEST_FILE_RE = /(\.(test|spec)\.[a-z0-9]+|(^|\/)test_[^/]+\.py|_test\.(py|go)|Tests?\.(java|kt|cs))$/i;
|
|
34
|
+
const MODES = ['off', 'report', 'enforce'];
|
|
35
|
+
|
|
36
|
+
// ─── R39: one run per hook when Claude and Copilot share a project ───────────
|
|
37
|
+
// Copilot CLI also runs the hooks in a repository's .claude/settings.json and
|
|
38
|
+
// .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
|
|
39
|
+
// hooks loaded): in a project with both the Claude and the Copilot install, every
|
|
40
|
+
// PAN hook ran twice under Copilot — once from .github/hooks/pan.json, once from the
|
|
41
|
+
// Claude settings. The Copilot project copy (this file under .github/hooks) steps
|
|
42
|
+
// aside whenever the project's Claude settings register the same script, so the hook
|
|
43
|
+
// runs once, and runs again from here the moment the Claude registration is gone.
|
|
44
|
+
// Both files are repository hooks to Copilot and load under the same trust rule, so
|
|
45
|
+
// deferring never leaves zero. Only this copy defers: Claude Code never reads
|
|
46
|
+
// .github/hooks, Gemini and Codex never read .claude/settings.json, and a global
|
|
47
|
+
// Copilot copy loads where repository hooks may not. Identical in every hook
|
|
48
|
+
// Copilot registers — tests/copilot-hook-dedupe.test.cjs pins the copies.
|
|
49
|
+
function deferToClaudeRegistration(projectDir, hookFile = __filename) {
|
|
50
|
+
// Assembled, not written as a literal: the installer rewrites every quoted .claude
|
|
51
|
+
// literal in a hook copy to that runtime's own directory, and this one must stay Claude's.
|
|
52
|
+
const claudeDir = ['.', 'claude'].join('');
|
|
53
|
+
try {
|
|
54
|
+
const hooksDir = path.dirname(hookFile);
|
|
55
|
+
if (path.basename(hooksDir) !== 'hooks' || path.basename(path.dirname(hooksDir)) !== '.github') return false;
|
|
56
|
+
if (typeof projectDir !== 'string' || !projectDir) return false;
|
|
57
|
+
const script = path.basename(hookFile);
|
|
58
|
+
for (const name of ['settings.json', 'settings.local.json']) {
|
|
59
|
+
let settings;
|
|
60
|
+
try { settings = JSON.parse(fs.readFileSync(path.join(projectDir, claudeDir, name), 'utf8')); } catch { continue; }
|
|
61
|
+
const events = settings && typeof settings.hooks === 'object' ? settings.hooks : null;
|
|
62
|
+
if (!events) continue;
|
|
63
|
+
for (const groups of Object.values(events)) {
|
|
64
|
+
if (!Array.isArray(groups)) continue;
|
|
65
|
+
for (const group of groups) {
|
|
66
|
+
const handlers = group && Array.isArray(group.hooks) ? group.hooks : [];
|
|
67
|
+
if (handlers.some((h) => h && typeof h.command === 'string' && h.command.includes(script))) return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch { /* fail open: run this copy */ }
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
function planningDirName() {
|
|
77
|
+
const raw = process.env.PAN_PLANNING_DIR || '';
|
|
78
|
+
if (raw.trim()) {
|
|
79
|
+
const rel = raw.trim().replace(/\\/g, '/');
|
|
80
|
+
const bad = rel.startsWith('/') || /^[A-Za-z]:/.test(rel) || rel.split('/').includes('..');
|
|
81
|
+
if (!bad) return rel;
|
|
82
|
+
}
|
|
83
|
+
const track = (process.env.PAN_TRACK || '').trim();
|
|
84
|
+
if (track && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(track)) return `.planning/tracks/${track}`;
|
|
85
|
+
return '.planning';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readJson(p) {
|
|
89
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A value that is a placeholder, not a credential. */
|
|
93
|
+
function isPlaceholder(v) {
|
|
94
|
+
return /^(?:\$\{|\$[A-Z_]|process\.env|os\.environ|<|xxx|your[_-]|changeme|example|dummy|placeholder|redacted|\*+$)/i.test(v);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hasSecret(text) {
|
|
98
|
+
if (typeof text !== 'string' || !text) return false;
|
|
99
|
+
if (TOKEN_RE.test(text)) return true;
|
|
100
|
+
const m = text.match(SECRET_KV_RE);
|
|
101
|
+
return !!(m && !isPlaceholder(m[2]) && /\d/.test(m[2]) && /[A-Za-z]/.test(m[2]));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The tool call as `{tool, writes: [{path, text}], command}`, whatever the host.
|
|
106
|
+
* Claude/Codex/Gemini send `tool_name` + `tool_input`; Copilot sends `toolName` +
|
|
107
|
+
* `toolArgs` (a JSON string).
|
|
108
|
+
*/
|
|
109
|
+
function normalise(payload) {
|
|
110
|
+
const p = payload && typeof payload === 'object' ? payload : {};
|
|
111
|
+
const tool = String(p.tool_name || p.toolName || p.tool || '');
|
|
112
|
+
let input = p.tool_input || p.toolArgs || p.input || {};
|
|
113
|
+
if (typeof input === 'string') { try { input = JSON.parse(input); } catch { input = { command: input }; } }
|
|
114
|
+
if (!input || typeof input !== 'object') input = {};
|
|
115
|
+
const writes = [];
|
|
116
|
+
const file = input.file_path || input.filePath || input.path || input.notebook_path || input.absolute_path;
|
|
117
|
+
const texts = [input.content, input.new_string, input.newString, input.new_source, input.file_text, input.text]
|
|
118
|
+
.concat(Array.isArray(input.edits) ? input.edits.map((e) => e && (e.new_string || e.newString)) : [])
|
|
119
|
+
.filter((t) => typeof t === 'string');
|
|
120
|
+
if (typeof file === 'string' && file && (texts.length || /write|edit|create|replace|notebook/i.test(tool))) {
|
|
121
|
+
writes.push({ path: file, text: texts.join('\n') });
|
|
122
|
+
}
|
|
123
|
+
const command = typeof input.command === 'string' ? input.command : (Array.isArray(input.command) ? input.command.join(' ') : '');
|
|
124
|
+
// Codex edits files through apply_patch: the patch names its files and carries the text.
|
|
125
|
+
if (/\*\*\* (?:Add|Update) File: /.test(command)) {
|
|
126
|
+
let cur = null;
|
|
127
|
+
for (const line of command.split(/\r?\n/)) {
|
|
128
|
+
const m = line.match(/^\*\*\* (?:Add|Update) File: (.+)$/);
|
|
129
|
+
if (m) { cur = { path: m[1].trim(), text: '' }; writes.push(cur); continue; }
|
|
130
|
+
if (cur && line.startsWith('+')) cur.text += line.slice(1) + '\n';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { tool, writes, command };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Project-relative POSIX path, or null when the path is outside the project. */
|
|
137
|
+
function rel(projectDir, p) {
|
|
138
|
+
const abs = path.resolve(projectDir, p);
|
|
139
|
+
const r = path.relative(projectDir, abs).replace(/\\/g, '/');
|
|
140
|
+
return r.startsWith('..') || path.isAbsolute(r) ? null : r;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isTestFile(r) {
|
|
144
|
+
return TEST_PATH_RE.test(r) || TEST_FILE_RE.test(r);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** files_modified of the unfinished plans of the phase state.md names, or null when none is in flight. */
|
|
148
|
+
function declaredFiles(projectDir, planningRel) {
|
|
149
|
+
let state;
|
|
150
|
+
try { state = fs.readFileSync(path.join(projectDir, planningRel, 'state.md'), 'utf8'); } catch { return null; }
|
|
151
|
+
const m = state.match(/\*\*Current Phase:\*\*[ \t]*([0-9]+(?:\.[0-9]+)?)/i) || state.match(/^Phase:[ \t]*([0-9]+(?:\.[0-9]+)?)/m);
|
|
152
|
+
if (!m) return null;
|
|
153
|
+
const want = String(parseFloat(m[1]));
|
|
154
|
+
const phasesDir = path.join(projectDir, planningRel, 'phases');
|
|
155
|
+
let dir;
|
|
156
|
+
try { dir = fs.readdirSync(phasesDir).find((d) => { const n = d.match(/^(\d+(?:\.\d+)?)-/); return n && String(parseFloat(n[1])) === want; }); } catch { return null; }
|
|
157
|
+
if (!dir) return null;
|
|
158
|
+
const full = path.join(phasesDir, dir);
|
|
159
|
+
const names = fs.readdirSync(full);
|
|
160
|
+
const done = new Set(names.filter((n) => n.endsWith('-summary.md')).map((n) => n.replace(/-summary\.md$/, '')));
|
|
161
|
+
const out = new Set();
|
|
162
|
+
let plans = 0;
|
|
163
|
+
for (const n of names.filter((x) => x.endsWith('-plan.md'))) {
|
|
164
|
+
if (done.has(n.replace(/-plan\.md$/, ''))) continue;
|
|
165
|
+
plans++;
|
|
166
|
+
const text = fs.readFileSync(path.join(full, n), 'utf8').replace(/\r\n/g, '\n');
|
|
167
|
+
const fm = text.match(/^---\n([\s\S]*?)\n---/);
|
|
168
|
+
if (!fm) continue;
|
|
169
|
+
const inline = fm[1].match(/^files_modified:[ \t]*\[([^\]]*)\]/m);
|
|
170
|
+
if (inline) inline[1].split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean).forEach((f) => out.add(f.replace(/^\.\//, '')));
|
|
171
|
+
const block = fm[1].match(/^files_modified:[ \t]*\n((?:[ \t]+-.*\n?)+)/m);
|
|
172
|
+
if (block) block[1].split('\n').map((l) => l.replace(/^[ \t]+-[ \t]*/, '').trim().replace(/^["']|["']$/g, '')).filter(Boolean).forEach((f) => out.add(f.replace(/^\.\//, '')));
|
|
173
|
+
}
|
|
174
|
+
return plans ? out : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The rule matches for one normalised call. */
|
|
178
|
+
function evaluate(call, projectDir, planningRel) {
|
|
179
|
+
const hits = [];
|
|
180
|
+
const declared = call.writes.length ? declaredFiles(projectDir, planningRel) : null;
|
|
181
|
+
const ownState = new Set([`${planningRel}/state.md`, `${planningRel}/findings.jsonl`, `${planningRel}/metrics/tokens.jsonl`]);
|
|
182
|
+
for (const w of call.writes) {
|
|
183
|
+
const r = rel(projectDir, w.path);
|
|
184
|
+
if (hasSecret(w.text)) hits.push({ rule: 'secret_write', path: r || w.path });
|
|
185
|
+
if (!r) continue;
|
|
186
|
+
if (ownState.has(r)) hits.push({ rule: 'state_write', path: r });
|
|
187
|
+
if (declared && isTestFile(r) && !declared.has(r)) hits.push({ rule: 'undeclared_test_edit', path: r });
|
|
188
|
+
}
|
|
189
|
+
return hits;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function decide(hits, mode) {
|
|
193
|
+
if (mode !== 'enforce') return null;
|
|
194
|
+
return hits.find((h) => h.rule === 'secret_write') || null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The host whose deny format this call needs: Copilot by payload shape, else the --host flag. */
|
|
198
|
+
function hostOf(payload, flagHost) {
|
|
199
|
+
const p = payload && typeof payload === 'object' ? payload : {};
|
|
200
|
+
if (typeof p.toolName === 'string' && p.tool_name === undefined) return 'copilot';
|
|
201
|
+
return flagHost;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function main() {
|
|
205
|
+
const flagHost = (process.argv[process.argv.indexOf('--host') + 1] || 'claude').toLowerCase();
|
|
206
|
+
let raw = '';
|
|
207
|
+
try { raw = fs.readFileSync(0, 'utf8'); } catch { return; }
|
|
208
|
+
let payload;
|
|
209
|
+
try { payload = JSON.parse(raw); } catch { return; } // fail open
|
|
210
|
+
const projectDir = (payload && (payload.cwd || payload.workspace || payload.project_dir)) || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
211
|
+
if (deferToClaudeRegistration(projectDir)) return; // the Claude registration covers this call under Copilot
|
|
212
|
+
// The payload names the host better than the registration does: Copilot sends its own
|
|
213
|
+
// camelCase payload (toolName, toolArgs) even to a hook registered in .claude/settings.json,
|
|
214
|
+
// and denies only on the JSON decision, so a Copilot payload gets Copilot's deny whatever
|
|
215
|
+
// --host says. The flag decides for the snake_case hosts (Claude Code, Codex, Gemini).
|
|
216
|
+
const host = hostOf(payload, flagHost);
|
|
217
|
+
const planningRel = planningDirName();
|
|
218
|
+
const planning = path.join(projectDir, planningRel);
|
|
219
|
+
if (!fs.existsSync(planning)) return; // not a PAN project
|
|
220
|
+
const cfg = readJson(path.join(planning, 'config.json')) || {};
|
|
221
|
+
const mode = MODES.includes(cfg.hooks && cfg.hooks.guard) ? cfg.hooks.guard : 'report';
|
|
222
|
+
if (mode === 'off') return;
|
|
223
|
+
let hits = [];
|
|
224
|
+
try { hits = evaluate(normalise(payload), projectDir, planningRel); } catch { return; } // fail open
|
|
225
|
+
if (!hits.length) return;
|
|
226
|
+
const deny = decide(hits, mode);
|
|
227
|
+
try {
|
|
228
|
+
const ts = new Date().toISOString();
|
|
229
|
+
const lines = hits.map((h) => JSON.stringify({ ts, host, mode, rule: h.rule, path: h.path, tool: normalise(payload).tool, action: deny && h === deny ? 'denied' : 'reported' }));
|
|
230
|
+
fs.appendFileSync(path.join(planning, 'guard.jsonl'), lines.join('\n') + '\n');
|
|
231
|
+
} catch { /* the log is best effort; a deny below still stands */ }
|
|
232
|
+
if (!deny) return;
|
|
233
|
+
const reason = `PAN guard: this write puts a credential into ${deny.path}. Keep secrets out of the repository (an environment variable or a local, ignored file), then retry. (hooks.guard: enforce)`;
|
|
234
|
+
if (host === 'copilot') {
|
|
235
|
+
process.stdout.write(JSON.stringify({ permissionDecision: 'deny', permissionDecisionReason: reason }) + '\n');
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
process.stderr.write(reason + '\n');
|
|
239
|
+
process.exitCode = 2;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (require.main === module) main();
|
|
243
|
+
|
|
244
|
+
module.exports = { normalise, evaluate, decide, hasSecret, declaredFiles, isTestFile, hostOf, deferToClaudeRegistration };
|