pan-wizard 3.34.0 → 3.36.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 +16 -10
- 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-designer.md +1 -1
- package/agents/pan-executor.md +43 -1
- package/agents/pan-experiment-runner.md +1 -1
- package/agents/pan-hardener.md +3 -1
- package/agents/pan-meta-reviewer.md +1 -1
- package/agents/pan-phase-researcher.md +16 -2
- package/agents/pan-plan-checker.md +37 -9
- package/agents/pan-planner.md +14 -2
- package/agents/pan-previewer.md +2 -0
- package/agents/pan-project-researcher.md +9 -1
- package/agents/pan-research-synthesizer.md +1 -1
- package/agents/pan-reviewer.md +8 -2
- package/agents/pan-verifier.md +3 -3
- package/bin/install-lib.cjs +119 -16
- package/bin/install.js +34 -62
- package/commands/pan/cost.md +21 -5
- 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 +2 -3
- 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 +13 -1
- package/pan-wizard-core/bin/lib/core.cjs +9 -4
- package/pan-wizard-core/bin/lib/cost.cjs +146 -0
- package/pan-wizard-core/bin/lib/doc-lint.cjs +5 -3
- package/pan-wizard-core/bin/lib/findings.cjs +239 -23
- package/pan-wizard-core/bin/lib/git.cjs +1 -1
- 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/mutate.cjs +257 -0
- package/pan-wizard-core/bin/lib/optimize.cjs +13 -1
- package/pan-wizard-core/bin/lib/phase-gate.cjs +180 -0
- package/pan-wizard-core/bin/lib/phase-remove.cjs +91 -11
- package/pan-wizard-core/bin/lib/phase.cjs +61 -9
- package/pan-wizard-core/bin/lib/plan-lint.cjs +161 -0
- package/pan-wizard-core/bin/lib/preview.cjs +35 -31
- package/pan-wizard-core/bin/lib/research-lint.cjs +121 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +1 -0
- package/pan-wizard-core/bin/lib/test-gate.cjs +433 -0
- package/pan-wizard-core/bin/lib/usage.cjs +104 -0
- 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-red.cjs +138 -0
- package/pan-wizard-core/bin/lib/verify-scope.cjs +148 -6
- package/pan-wizard-core/bin/lib/verify.cjs +27 -27
- package/pan-wizard-core/bin/lib/whatif.cjs +48 -6
- package/pan-wizard-core/bin/pan-tools.cjs +106 -9
- package/pan-wizard-core/mcp/merge-gate.cjs +14 -3
- package/pan-wizard-core/mcp/native-tools.cjs +3 -3
- package/pan-wizard-core/mcp/orchestrator.cjs +4 -1
- package/pan-wizard-core/opencode/pan-wizard.js +36 -8
- package/pan-wizard-core/references/design-methodology.md +1 -1
- package/pan-wizard-core/references/handoff-decisions.md +1 -1
- 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 +6 -6
- package/pan-wizard-core/templates/debug-subagent-prompt.md +2 -1
- package/pan-wizard-core/templates/design.md +5 -4
- package/pan-wizard-core/templates/phase-prompt.md +9 -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/research-project/architecture.md +6 -0
- package/pan-wizard-core/templates/research-project/features.md +6 -0
- package/pan-wizard-core/templates/research-project/pitfalls.md +6 -0
- package/pan-wizard-core/templates/research-project/stack.md +6 -0
- package/pan-wizard-core/templates/research.md +10 -0
- package/pan-wizard-core/templates/state.md +4 -4
- package/pan-wizard-core/templates/summary-complex.md +5 -1
- package/pan-wizard-core/templates/summary-minimal.md +4 -0
- package/pan-wizard-core/templates/summary-standard.md +5 -1
- package/pan-wizard-core/templates/summary.md +8 -1
- 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 +92 -45
- package/pan-wizard-core/workflows/execute-plan.md +3 -1
- package/pan-wizard-core/workflows/help.md +103 -44
- 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 +40 -8
- package/pan-wizard-core/workflows/progress.md +1 -1
- package/pan-wizard-core/workflows/quick.md +2 -2
- package/pan-wizard-core/workflows/remove-phase.md +11 -1
- package/pan-wizard-core/workflows/settings.md +17 -0
- 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 +40 -47
- package/scripts/build-help.cjs +106 -0
- package/scripts/build-hooks.js +1 -0
- package/scripts/generate-skills-docs.py +2 -2
- package/scripts/mutation-probe.cjs +7 -80
- package/scripts/prompt-lint.cjs +167 -0
- package/scripts/test-surface.cjs +169 -6
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 };
|
|
@@ -13,7 +13,7 @@ const os = require('os');
|
|
|
13
13
|
*
|
|
14
14
|
* @param {Object} data - Parsed stdin JSON from Claude Code.
|
|
15
15
|
* @param {Object} [deps] - Optional dep injection for testing.
|
|
16
|
-
* {fs, path, homeDir, tmpDir} — defaults to real modules + OS paths.
|
|
16
|
+
* {fs, path, homeDir, tmpDir, configDir} — defaults to real modules + OS paths (configDir: CLAUDE_CONFIG_DIR, ignored when homeDir is given).
|
|
17
17
|
* @returns {string} The statusline content.
|
|
18
18
|
*/
|
|
19
19
|
function buildStatuslineOutput(data, deps) {
|
|
@@ -155,7 +155,10 @@ function buildStatuslineOutput(data, deps) {
|
|
|
155
155
|
|
|
156
156
|
// Current task from todos
|
|
157
157
|
let task = '';
|
|
158
|
-
|
|
158
|
+
// Claude Code keeps its todos in CLAUDE_CONFIG_DIR when that is set (a given
|
|
159
|
+
// homeDir is a test seam and ignores it).
|
|
160
|
+
const configDir = d.configDir !== undefined ? d.configDir : (d.homeDir ? '' : (process.env.CLAUDE_CONFIG_DIR || ''));
|
|
161
|
+
const todosDir = configDir ? pathMod.join(configDir, 'todos') : pathMod.join(homeDir, '.claude', 'todos');
|
|
159
162
|
let todosExists = false;
|
|
160
163
|
try { todosExists = fsMod.statSync(todosDir).isDirectory(); } catch { /* missing */ }
|
|
161
164
|
if (session && todosExists) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pan-wizard",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.36.0",
|
|
4
4
|
"description": "Command a bot army for your codebase: a reasoning-tier Mission Control delegates whole-project goals to specialist squads and ships behind a human merge gate. Five AI CLIs, zero context rot.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"pan-wizard": "bin/install.js"
|
|
@@ -556,6 +556,21 @@ function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
|
|
|
556
556
|
output(fullResult, raw);
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
+
/**
|
|
560
|
+
* Map a --freshness value to Brave's `freshness` parameter, which accepts only
|
|
561
|
+
* pd/pw/pm/py or a YYYY-MM-DDtoYYYY-MM-DD range. Returns null for anything else.
|
|
562
|
+
* @param {string} value - day|week|month|year, or a value Brave accepts as is
|
|
563
|
+
* @returns {string|null}
|
|
564
|
+
*/
|
|
565
|
+
function braveFreshness(value) {
|
|
566
|
+
const v = String(value || '').trim().toLowerCase();
|
|
567
|
+
const named = { day: 'pd', week: 'pw', month: 'pm', year: 'py' };
|
|
568
|
+
if (named[v]) return named[v];
|
|
569
|
+
if (/^p[dwmy]$/.test(v)) return v;
|
|
570
|
+
if (/^\d{4}-\d{2}-\d{2}to\d{4}-\d{2}-\d{2}$/.test(v)) return v;
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
|
|
559
574
|
/**
|
|
560
575
|
* Search the web using Brave Search API and return structured results.
|
|
561
576
|
*
|
|
@@ -588,6 +603,12 @@ async function cmdWebsearch(query, options, raw) {
|
|
|
588
603
|
return;
|
|
589
604
|
}
|
|
590
605
|
|
|
606
|
+
const freshness = options.freshness ? braveFreshness(options.freshness) : null;
|
|
607
|
+
if (options.freshness && !freshness) {
|
|
608
|
+
output({ available: false, error: `Invalid --freshness "${options.freshness}": use day, week, month, year, pd, pw, pm, py or YYYY-MM-DDtoYYYY-MM-DD` }, raw, '');
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
591
612
|
const params = new URLSearchParams({
|
|
592
613
|
q: query,
|
|
593
614
|
count: String(options.limit || 10),
|
|
@@ -596,8 +617,8 @@ async function cmdWebsearch(query, options, raw) {
|
|
|
596
617
|
text_decorations: 'false'
|
|
597
618
|
});
|
|
598
619
|
|
|
599
|
-
if (
|
|
600
|
-
params.set('freshness',
|
|
620
|
+
if (freshness) {
|
|
621
|
+
params.set('freshness', freshness);
|
|
601
622
|
}
|
|
602
623
|
|
|
603
624
|
try {
|
|
@@ -1161,6 +1182,7 @@ module.exports = {
|
|
|
1161
1182
|
cmdSummaryExtract,
|
|
1162
1183
|
summaryOneLiner,
|
|
1163
1184
|
cmdWebsearch,
|
|
1185
|
+
braveFreshness,
|
|
1164
1186
|
cmdProgressRender,
|
|
1165
1187
|
cmdTodoComplete,
|
|
1166
1188
|
cmdScaffold,
|
|
@@ -53,10 +53,21 @@ function buildConfigDefaults(hasBraveSearch, userDefaults) {
|
|
|
53
53
|
verifier: true,
|
|
54
54
|
nyquist_validation: false,
|
|
55
55
|
phase_record_compact: false,
|
|
56
|
+
// V1: the phase gate. `enforce` (the default since V1E) refuses; `warn` reports
|
|
57
|
+
// refusals and completes.
|
|
58
|
+
phase_gate: 'enforce',
|
|
59
|
+
// V5: the mutation report on a phase's diff. `report` lists surviving mutants and
|
|
60
|
+
// never refuses; `off` skips it; `gate` is reserved for V5G.
|
|
61
|
+
mutation_gate: 'report',
|
|
56
62
|
// M2: opt-in per-phase HTML reports at the verify→complete gate.
|
|
57
63
|
phase_reports: { enabled: false, open: false, theme: 'auto', index: true },
|
|
58
64
|
},
|
|
59
65
|
parallelization: true,
|
|
66
|
+
// A7: the pre-tool guard. `report` logs to <planning>/guard.jsonl; `enforce` also
|
|
67
|
+
// denies a write that puts a credential in a file; `off` disables it.
|
|
68
|
+
hooks: { guard: 'report' },
|
|
69
|
+
// Spend per phase, in API-equivalent dollars. It only warns (80% and over); 0 = off.
|
|
70
|
+
cost: { phase_budget_usd: 20 },
|
|
60
71
|
brave_search: hasBraveSearch,
|
|
61
72
|
budget: {
|
|
62
73
|
default_points: 50,
|
|
@@ -97,6 +108,7 @@ function buildConfigDefaults(hasBraveSearch, userDefaults) {
|
|
|
97
108
|
...userDefaults,
|
|
98
109
|
workflow: { ...hardcoded.workflow, ...(userDefaults.workflow || {}) },
|
|
99
110
|
budget: { ...hardcoded.budget, ...(userDefaults.budget || {}) },
|
|
111
|
+
cost: { ...hardcoded.cost, ...(userDefaults.cost || {}) },
|
|
100
112
|
commit: { ...hardcoded.commit, ...(userDefaults.commit || {}) },
|
|
101
113
|
execution: { ...hardcoded.execution, ...(userDefaults.execution || {}) },
|
|
102
114
|
routing: { ...hardcoded.routing, ...(userDefaults.routing || {}) },
|
|
@@ -114,7 +126,7 @@ const CONFIG_KEYS_BEYOND_DEFAULTS = Object.freeze({
|
|
|
114
126
|
top: Object.freeze(['research', 'plan_checker', 'verifier', 'nyquist_validation', 'mode', 'depth', 'planning', 'git',
|
|
115
127
|
'focus', 'model_overrides', 'effort_overrides', 'cost', 'cache', 'build', 'verification', 'concurrency', 'memory']),
|
|
116
128
|
sections: Object.freeze({
|
|
117
|
-
workflow: Object.freeze(['auto_advance', 'stop_guard']),
|
|
129
|
+
workflow: Object.freeze(['auto_advance', 'stop_guard', 'test_command', 'test_file_command']),
|
|
118
130
|
planning: Object.freeze(['commit_docs', 'search_gitignored']),
|
|
119
131
|
git: Object.freeze(['branching_strategy', 'phase_branch_template', 'milestone_branch_template']),
|
|
120
132
|
}),
|
|
@@ -559,23 +559,28 @@ function isGitRepo(cwd) {
|
|
|
559
559
|
}
|
|
560
560
|
|
|
561
561
|
/**
|
|
562
|
-
* Execute a git command safely with proper argument escaping.
|
|
562
|
+
* Execute a git command safely with proper argument escaping. stdout is trimmed;
|
|
563
|
+
* `keepLeading` trims only whitespace at its end, for output whose first column means
|
|
564
|
+
* something (`git status --porcelain` starts an unstaged edit with a space, which a
|
|
565
|
+
* full trim cuts off the first line).
|
|
563
566
|
* @param {string} cwd - Working directory for git
|
|
564
567
|
* @param {string[]} args - Git arguments (e.g., ['add', 'file.md'])
|
|
568
|
+
* @param {{keepLeading?: boolean}} [opts]
|
|
565
569
|
* @returns {{exitCode: number, stdout: string, stderr: string}}
|
|
566
570
|
*/
|
|
567
|
-
function execGit(cwd, args) {
|
|
571
|
+
function execGit(cwd, args, opts = {}) {
|
|
572
|
+
const clean = (s) => (opts.keepLeading ? String(s).replace(/\s+$/, '') : String(s).trim());
|
|
568
573
|
try {
|
|
569
574
|
const stdout = execFileSync('git', args, {
|
|
570
575
|
cwd,
|
|
571
576
|
stdio: 'pipe',
|
|
572
577
|
encoding: 'utf-8',
|
|
573
578
|
});
|
|
574
|
-
return { exitCode: 0, stdout: stdout
|
|
579
|
+
return { exitCode: 0, stdout: clean(stdout), stderr: '' };
|
|
575
580
|
} catch (err) {
|
|
576
581
|
return {
|
|
577
582
|
exitCode: err.status ?? 1,
|
|
578
|
-
stdout: (err.stdout ?? '').toString()
|
|
583
|
+
stdout: clean((err.stdout ?? '').toString()),
|
|
579
584
|
stderr: (err.stderr ?? '').toString().trim(),
|
|
580
585
|
};
|
|
581
586
|
}
|
|
@@ -664,6 +664,146 @@ function aggregate(cwd, opts) {
|
|
|
664
664
|
};
|
|
665
665
|
}
|
|
666
666
|
|
|
667
|
+
// ─── Per-phase budget ───────────────────────────────────────────────────────
|
|
668
|
+
// `cost.phase_budget_usd` (default $20, `0`/`false` = off). It only WARNS: `near` from
|
|
669
|
+
// PHASE_BUDGET_WARN_PCT of the budget, `over` once spend reaches it. No run stops on it.
|
|
670
|
+
// On a subscription the dollars are API-equivalent: what the tokens would cost at the
|
|
671
|
+
// configured rates.
|
|
672
|
+
|
|
673
|
+
const DEFAULT_PHASE_BUDGET_USD = 20;
|
|
674
|
+
const PHASE_BUDGET_WARN_PCT = 80;
|
|
675
|
+
|
|
676
|
+
function phaseBudgetUsd(cwd) {
|
|
677
|
+
let v;
|
|
678
|
+
try { v = loadConfig(cwd)?.cost?.phase_budget_usd; } catch { /* defaults */ }
|
|
679
|
+
if (v === false || v === 0 || v === '0') return 0;
|
|
680
|
+
const n = Number(v);
|
|
681
|
+
return v != null && Number.isFinite(n) && n > 0 ? n : DEFAULT_PHASE_BUDGET_USD;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* A phase's spend against its budget.
|
|
686
|
+
* @returns {{phase, budget_usd, spent_usd, pct, status: 'off'|'ok'|'near'|'over', mode: 'warn', warn_at_pct, unpriced}}
|
|
687
|
+
*/
|
|
688
|
+
function phaseBudget(cwd, phase) {
|
|
689
|
+
const budget = phaseBudgetUsd(cwd);
|
|
690
|
+
const rates = effectiveRates(loadConfig(cwd));
|
|
691
|
+
const key = samePhaseKey(phase);
|
|
692
|
+
let spent = 0;
|
|
693
|
+
let unpriced = 0;
|
|
694
|
+
for (const r of readRecords(cwd)) {
|
|
695
|
+
if (samePhaseKey(r.phase) !== key || isSuspectRecord(r) || isEmptyRecord(r)) continue;
|
|
696
|
+
const usd = typeof r.cost_usd === 'number' ? r.cost_usd : computeCost(r, rates);
|
|
697
|
+
if (typeof usd === 'number') spent += usd;
|
|
698
|
+
else unpriced++;
|
|
699
|
+
}
|
|
700
|
+
spent = round4(spent);
|
|
701
|
+
const pct = budget > 0 ? Math.round((spent / budget) * 1000) / 10 : null;
|
|
702
|
+
let status = 'off';
|
|
703
|
+
if (budget > 0) status = pct >= 100 ? 'over' : pct >= PHASE_BUDGET_WARN_PCT ? 'near' : 'ok';
|
|
704
|
+
return { phase: String(phase), budget_usd: budget, spent_usd: spent, pct, status, mode: 'warn', warn_at_pct: PHASE_BUDGET_WARN_PCT, unpriced };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function cmdCostBudget(cwd, phase, raw) {
|
|
708
|
+
if (!phase) error('Usage: cost budget --phase <N>');
|
|
709
|
+
const r = phaseBudget(cwd, phase);
|
|
710
|
+
output(r, raw, r.status);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ─── Cost forecast (program P6) ─────────────────────────────────────────────
|
|
714
|
+
// What a plan and a phase will cost, as p10/p50/p90 dollars, from what this project's
|
|
715
|
+
// agents have cost so far. Fewer than FORECAST_MIN_SAMPLES observations is no
|
|
716
|
+
// forecast: the result says `insufficient` rather than extrapolating from two runs.
|
|
717
|
+
|
|
718
|
+
const FORECAST_MIN_SAMPLES = 5;
|
|
719
|
+
const FORECAST_ITERATIONS = 2000;
|
|
720
|
+
const PLAN_AGENT = 'pan-executor';
|
|
721
|
+
|
|
722
|
+
const round4 = (x) => Math.round(x * 10000) / 10000;
|
|
723
|
+
|
|
724
|
+
/** Linear interpolation between the closest ranks (R type 7). */
|
|
725
|
+
function quantile(sorted, q) {
|
|
726
|
+
const h = (sorted.length - 1) * q;
|
|
727
|
+
const lo = Math.floor(h);
|
|
728
|
+
return sorted[lo] + (sorted[Math.min(lo + 1, sorted.length - 1)] - sorted[lo]) * (h - lo);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function quantiles(values) {
|
|
732
|
+
const s = [...values].sort((a, b) => a - b);
|
|
733
|
+
return { p10: round4(quantile(s, 0.1)), p50: round4(quantile(s, 0.5)), p90: round4(quantile(s, 0.9)) };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** mulberry32: a seeded PRNG, so a forecast is reproducible. */
|
|
737
|
+
function prng(seed) {
|
|
738
|
+
let a = (Number(seed) >>> 0) || 1;
|
|
739
|
+
return () => {
|
|
740
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
741
|
+
let t = a;
|
|
742
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
743
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
744
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const samePhaseKey = (p) => String(p == null ? '' : p).replace(/^0+(?=\d)/, '');
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Forecast a plan and a phase.
|
|
752
|
+
*
|
|
753
|
+
* - Per plan: the cost of each pan-executor call at one tier (the tier the latest
|
|
754
|
+
* executor call ran at, unless `tier` is given). Tiers differ several-fold in price,
|
|
755
|
+
* so they are never pooled.
|
|
756
|
+
* - Per phase: for each past phase, the cost of its other agents (planner, checkers,
|
|
757
|
+
* verifier, reviewer …); a phase is resampled as one of those plus one executor
|
|
758
|
+
* draw per plan, `FORECAST_ITERATIONS` times, seeded.
|
|
759
|
+
*
|
|
760
|
+
* @param {string} cwd
|
|
761
|
+
* @param {{plans?: number, tier?: string, excludePhase?: string, seed?: number}} [opts]
|
|
762
|
+
*/
|
|
763
|
+
function costForecast(cwd, opts = {}) {
|
|
764
|
+
const rates = effectiveRates(loadConfig(cwd));
|
|
765
|
+
const exclude = opts.excludePhase != null ? samePhaseKey(opts.excludePhase) : null;
|
|
766
|
+
const rows = [];
|
|
767
|
+
for (const r of readRecords(cwd)) {
|
|
768
|
+
if (isSuspectRecord(r) || isEmptyRecord(r)) continue;
|
|
769
|
+
if (exclude != null && samePhaseKey(r.phase) === exclude) continue;
|
|
770
|
+
const usd = typeof r.cost_usd === 'number' ? r.cost_usd : computeCost(r, rates);
|
|
771
|
+
if (typeof usd === 'number') rows.push({ ...r, usd });
|
|
772
|
+
}
|
|
773
|
+
const executor = rows.filter((r) => r.agent === PLAN_AGENT);
|
|
774
|
+
const tier = opts.tier || (executor.length ? executor[executor.length - 1].tier || null : null);
|
|
775
|
+
const planCosts = executor.filter((r) => (r.tier || null) === tier).map((r) => r.usd);
|
|
776
|
+
const parsedPlans = Number.parseInt(opts.plans, 10);
|
|
777
|
+
const plans = Number.isFinite(parsedPlans) && parsedPlans >= 0 ? parsedPlans : 1;
|
|
778
|
+
const insufficient = (n) => ({ status: 'insufficient', n, needed: FORECAST_MIN_SAMPLES });
|
|
779
|
+
|
|
780
|
+
const perPlan = planCosts.length >= FORECAST_MIN_SAMPLES ? { status: 'ok', n: planCosts.length, ...quantiles(planCosts) } : insufficient(planCosts.length);
|
|
781
|
+
|
|
782
|
+
// A past phase is one the executor ran in; its overhead is everything else it spent.
|
|
783
|
+
const ranIn = new Set(executor.filter((r) => r.phase != null).map((r) => samePhaseKey(r.phase)));
|
|
784
|
+
const overhead = new Map([...ranIn].map((p) => [p, 0]));
|
|
785
|
+
for (const r of rows) {
|
|
786
|
+
const p = samePhaseKey(r.phase);
|
|
787
|
+
if (r.agent !== PLAN_AGENT && overhead.has(p)) overhead.set(p, overhead.get(p) + r.usd);
|
|
788
|
+
}
|
|
789
|
+
const overheads = [...overhead.values()];
|
|
790
|
+
let phase;
|
|
791
|
+
if (perPlan.status !== 'ok') phase = { ...insufficient(planCosts.length), n_phases: overheads.length, reason: 'too few executor calls at this tier' };
|
|
792
|
+
else if (overheads.length < FORECAST_MIN_SAMPLES) phase = { ...insufficient(overheads.length), n_phases: overheads.length, reason: 'too few past phases' };
|
|
793
|
+
else {
|
|
794
|
+
const rand = prng(opts.seed == null ? 1 : opts.seed);
|
|
795
|
+
const pick = (arr) => arr[Math.floor(rand() * arr.length)];
|
|
796
|
+
const sims = [];
|
|
797
|
+
for (let i = 0; i < FORECAST_ITERATIONS; i++) {
|
|
798
|
+
let usd = pick(overheads);
|
|
799
|
+
for (let k = 0; k < plans; k++) usd += pick(planCosts);
|
|
800
|
+
sims.push(usd);
|
|
801
|
+
}
|
|
802
|
+
phase = { status: 'ok', n_phases: overheads.length, ...quantiles(sims) };
|
|
803
|
+
}
|
|
804
|
+
return { agent: PLAN_AGENT, tier, plans, min_samples: FORECAST_MIN_SAMPLES, per_plan: perPlan, phase };
|
|
805
|
+
}
|
|
806
|
+
|
|
667
807
|
/**
|
|
668
808
|
* Render aggregation as a human-readable table.
|
|
669
809
|
* @param {Object} agg - from aggregate()
|
|
@@ -900,6 +1040,12 @@ function cmdModelsCheck(raw) {
|
|
|
900
1040
|
|
|
901
1041
|
module.exports = {
|
|
902
1042
|
computeCost,
|
|
1043
|
+
phaseBudget,
|
|
1044
|
+
cmdCostBudget,
|
|
1045
|
+
DEFAULT_PHASE_BUDGET_USD,
|
|
1046
|
+
costForecast,
|
|
1047
|
+
quantiles,
|
|
1048
|
+
FORECAST_MIN_SAMPLES,
|
|
903
1049
|
appendRecord,
|
|
904
1050
|
readRecords,
|
|
905
1051
|
aggregate,
|
|
@@ -343,11 +343,13 @@ function scanDocFlags(cwd, opts = {}) {
|
|
|
343
343
|
const docDirs = opts.docDirs || ['docs'];
|
|
344
344
|
const collected = [];
|
|
345
345
|
for (const d of docDirs) {
|
|
346
|
-
const abs = path.
|
|
346
|
+
const abs = path.resolve(cwd, d);
|
|
347
347
|
if (!fs.existsSync(abs)) continue;
|
|
348
348
|
for (const f of walkMarkdownFiles(abs)) {
|
|
349
349
|
if (f.readError) continue;
|
|
350
|
-
|
|
350
|
+
// Relative to the project root whether --doc-dir was given relative or
|
|
351
|
+
// absolute, so the frozen-doc allowlist sees project segments only.
|
|
352
|
+
collected.push({ rel: path.relative(cwd, path.join(abs, f.relativePath)).replace(/\\/g, '/'), content: f.content });
|
|
351
353
|
}
|
|
352
354
|
}
|
|
353
355
|
for (const rel of (opts.files || [])) {
|
|
@@ -379,7 +381,7 @@ function scanDocFlags(cwd, opts = {}) {
|
|
|
379
381
|
|
|
380
382
|
function cmdDocLintFlags(cwd, opts = {}, raw) {
|
|
381
383
|
for (const d of opts.docDirs || []) {
|
|
382
|
-
const abs = path.
|
|
384
|
+
const abs = path.resolve(cwd, d);
|
|
383
385
|
if (fs.existsSync(abs)) requireDirectory(abs);
|
|
384
386
|
}
|
|
385
387
|
const r = scanDocFlags(cwd, opts);
|