pan-wizard 3.29.0 → 3.31.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 +40 -40
- package/bin/install-lib.cjs +186 -83
- package/bin/install.js +213 -195
- package/commands/pan/army.md +4 -4
- package/commands/pan/audit-deployment.md +2 -2
- package/commands/pan/cost.md +5 -5
- package/commands/pan/exec-phase.md +2 -0
- package/commands/pan/focus-auto.md +5 -5
- package/commands/pan/verify-phase.md +8 -9
- package/hooks/dist/pan-check-update.js +53 -2
- package/hooks/dist/pan-context-monitor.js +41 -0
- package/hooks/dist/pan-cost-logger.js +71 -2
- package/hooks/dist/pan-state-reinject.js +158 -0
- package/hooks/dist/pan-stop-guard.js +127 -2
- package/hooks/dist/pan-trace-logger.js +42 -1
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/agents-md.cjs +3 -2
- package/pan-wizard-core/bin/lib/commands.cjs +20 -4
- package/pan-wizard-core/bin/lib/config.cjs +2 -0
- package/pan-wizard-core/bin/lib/constants.cjs +3 -1
- package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
- package/pan-wizard-core/bin/lib/core.cjs +249 -27
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
- package/pan-wizard-core/bin/lib/cost.cjs +234 -88
- package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
- package/pan-wizard-core/bin/lib/memory-rebuild.cjs +12 -5
- package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
- package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
- package/pan-wizard-core/bin/lib/state.cjs +86 -22
- package/pan-wizard-core/bin/lib/suggest.cjs +21 -9
- package/pan-wizard-core/bin/lib/template.cjs +6 -3
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +20 -3
- package/pan-wizard-core/bin/pan-tools.cjs +10 -1
- package/pan-wizard-core/mcp/tool-registry.cjs +14 -4
- package/pan-wizard-core/references/model-profiles.md +4 -4
- package/pan-wizard-core/references/planning-config.md +19 -23
- package/pan-wizard-core/workflows/exec-phase.md +8 -0
- package/pan-wizard-core/workflows/help.md +2 -2
- package/pan-wizard-core/workflows/phase-tests.md +1 -1
- package/pan-wizard-core/workflows/progress.md +1 -1
- package/pan-wizard-core/workflows/settings.md +2 -4
- package/pan-wizard-core/workflows/todo-add.md +1 -1
- package/pan-wizard-core/workflows/todo-check.md +7 -6
- package/scripts/build-agent-plugin.js +4 -0
- package/scripts/build-hooks.js +2 -1
- package/scripts/build-plugin.js +10 -0
- package/scripts/npm-dist-tag.js +47 -0
- package/scripts/plugin-path.js +6 -1
- package/scripts/test-surface.cjs +11 -9
|
@@ -39,6 +39,25 @@
|
|
|
39
39
|
// per stop chain, so a user who genuinely wants to stop is delayed by exactly
|
|
40
40
|
// one continuation, never trapped.
|
|
41
41
|
//
|
|
42
|
+
// Gemini CLI (R29, 2026-09-23): the guard is registered there on AfterAgent,
|
|
43
|
+
// Gemini's end-of-turn event — until this date PAN registered it under Claude's
|
|
44
|
+
// `Stop` key, which Gemini skips with an "Invalid hook event name" warning, so it
|
|
45
|
+
// had never run. AfterAgent honours the same {decision: 'block', reason} output
|
|
46
|
+
// (a block re-prompts the agent with the reason), but its stop_hook_active is only
|
|
47
|
+
// true for the AfterAgent that evaluates a continuation the block started
|
|
48
|
+
// directly: a continuation that used tools reports false again (gemini-cli
|
|
49
|
+
// client.ts / useGeminiStream.ts, read 2026-09-23). The flag alone would let the
|
|
50
|
+
// guard block every such turn. So on AfterAgent the one-shot promise is kept with
|
|
51
|
+
// a marker per session, project and target phase in the per-user 0700 hook
|
|
52
|
+
// directory: a second stop aimed at the same phase is allowed. No safe marker
|
|
53
|
+
// directory, or no session id, means no block (fail open, as everywhere else).
|
|
54
|
+
//
|
|
55
|
+
// Codex and Copilot CLI (M14, 2026-09-26): registered on Codex's `Stop` and
|
|
56
|
+
// Copilot's `agentStop`. Both payloads carry `cwd` and `stop_hook_active` (Copilot
|
|
57
|
+
// keeps that one field snake_case inside its camelCase payload) and both honour the
|
|
58
|
+
// same {decision: 'block', reason} output, so the logic above applies unchanged.
|
|
59
|
+
// Copilot also ends the turn itself after eight consecutive blocks.
|
|
60
|
+
//
|
|
42
61
|
// Escape hatch: set workflow.stop_guard to false in .planning/config.json to
|
|
43
62
|
// disable the guard entirely without turning off auto_advance.
|
|
44
63
|
//
|
|
@@ -51,7 +70,48 @@
|
|
|
51
70
|
// rather than only reachable via stdin (same pattern as the other PAN hooks).
|
|
52
71
|
|
|
53
72
|
const fs = require('fs');
|
|
73
|
+
const os = require('os');
|
|
54
74
|
const path = require('path');
|
|
75
|
+
|
|
76
|
+
// ─── R39: one run per hook when Claude and Copilot share a project ───────────
|
|
77
|
+
// Copilot CLI also runs the hooks in a repository's .claude/settings.json and
|
|
78
|
+
// .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
|
|
79
|
+
// hooks loaded): in a project with both the Claude and the Copilot install, every
|
|
80
|
+
// PAN hook ran twice under Copilot — once from .github/hooks/pan.json, once from the
|
|
81
|
+
// Claude settings. The Copilot project copy (this file under .github/hooks) steps
|
|
82
|
+
// aside whenever the project's Claude settings register the same script, so the hook
|
|
83
|
+
// runs once, and runs again from here the moment the Claude registration is gone.
|
|
84
|
+
// Both files are repository hooks to Copilot and load under the same trust rule, so
|
|
85
|
+
// deferring never leaves zero. Only this copy defers: Claude Code never reads
|
|
86
|
+
// .github/hooks, Gemini and Codex never read .claude/settings.json, and a global
|
|
87
|
+
// Copilot copy loads where repository hooks may not. Identical in every hook
|
|
88
|
+
// Copilot registers — tests/copilot-hook-dedupe.test.cjs pins the copies.
|
|
89
|
+
function deferToClaudeRegistration(projectDir, hookFile = __filename) {
|
|
90
|
+
// Assembled, not written as a literal: the installer rewrites every quoted .claude
|
|
91
|
+
// literal in a hook copy to that runtime's own directory, and this one must stay Claude's.
|
|
92
|
+
const claudeDir = ['.', 'claude'].join('');
|
|
93
|
+
try {
|
|
94
|
+
const hooksDir = path.dirname(hookFile);
|
|
95
|
+
if (path.basename(hooksDir) !== 'hooks' || path.basename(path.dirname(hooksDir)) !== '.github') return false;
|
|
96
|
+
if (typeof projectDir !== 'string' || !projectDir) return false;
|
|
97
|
+
const script = path.basename(hookFile);
|
|
98
|
+
for (const name of ['settings.json', 'settings.local.json']) {
|
|
99
|
+
let settings;
|
|
100
|
+
try { settings = JSON.parse(fs.readFileSync(path.join(projectDir, claudeDir, name), 'utf8')); } catch { continue; }
|
|
101
|
+
const events = settings && typeof settings.hooks === 'object' ? settings.hooks : null;
|
|
102
|
+
if (!events) continue;
|
|
103
|
+
for (const groups of Object.values(events)) {
|
|
104
|
+
if (!Array.isArray(groups)) continue;
|
|
105
|
+
for (const group of groups) {
|
|
106
|
+
const handlers = group && Array.isArray(group.hooks) ? group.hooks : [];
|
|
107
|
+
if (handlers.some((h) => h && typeof h.command === 'string' && h.command.includes(script))) return true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch { /* fail open: run this copy */ }
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const crypto = require('crypto');
|
|
55
115
|
/**
|
|
56
116
|
* Which planning tree this hook acts on.
|
|
57
117
|
*
|
|
@@ -155,6 +215,63 @@ function readIfExists(p) {
|
|
|
155
215
|
try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
|
|
156
216
|
}
|
|
157
217
|
|
|
218
|
+
// Per-user hook state directory inside tmpdir, created 0700 — the same directory
|
|
219
|
+
// and the same checks as bridgeDir() in pan-context-monitor.js (hooks are
|
|
220
|
+
// standalone files and cannot require one another). Fail CLOSED (null) when the
|
|
221
|
+
// directory is not provably ours: a shared host must not be able to pre-plant a
|
|
222
|
+
// marker that silences the guard, or a symlink it writes through (M60).
|
|
223
|
+
function hookStateDir() {
|
|
224
|
+
const uid = (typeof process.getuid === 'function' ? process.getuid() : process.env.USERNAME || 'win');
|
|
225
|
+
const dir = path.join(os.tmpdir(), `pan-hooks-${uid}`);
|
|
226
|
+
try {
|
|
227
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
228
|
+
const st = fs.lstatSync(dir);
|
|
229
|
+
if (st.isSymbolicLink()) return null;
|
|
230
|
+
// POSIX-only ownership and mode checks: Windows fakes mode bits (N15).
|
|
231
|
+
if (typeof process.getuid === 'function') {
|
|
232
|
+
if (st.uid !== process.getuid()) return null;
|
|
233
|
+
if ((st.mode & 0o077) !== 0) return null;
|
|
234
|
+
}
|
|
235
|
+
return dir;
|
|
236
|
+
} catch { return null; }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The file name of the one-shot marker for an AfterAgent block (Gemini CLI).
|
|
241
|
+
* Keyed by session, project and the block's reason — the reason names the target
|
|
242
|
+
* phase, so a later drop at a DIFFERENT phase in the same session is still caught.
|
|
243
|
+
* Pure; null when there is no session id to key on.
|
|
244
|
+
*/
|
|
245
|
+
function onceMarkerName(sessionId, projectDir, reason) {
|
|
246
|
+
if (typeof sessionId !== 'string' || !sessionId) return null;
|
|
247
|
+
const key = crypto.createHash('sha256').update(`${sessionId}\0${projectDir}\0${reason}`).digest('hex').slice(0, 32);
|
|
248
|
+
return `stop-guard-${key}.json`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const MARKER_MAX_AGE_MS = 7 * 24 * 3600 * 1000;
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Apply the AfterAgent one-shot rule: returns true when this block may be issued
|
|
255
|
+
* (and records it), false when the same session already got it or no safe place
|
|
256
|
+
* to remember it exists. Best-effort pruning keeps the directory from growing.
|
|
257
|
+
*/
|
|
258
|
+
function claimOnceMarker(dir, name, now = Date.now()) {
|
|
259
|
+
if (!dir || !name) return false;
|
|
260
|
+
const marker = path.join(dir, name);
|
|
261
|
+
try {
|
|
262
|
+
for (const f of fs.readdirSync(dir)) {
|
|
263
|
+
if (!/^stop-guard-[0-9a-f]{32}\.json$/.test(f)) continue;
|
|
264
|
+
try { if (now - fs.statSync(path.join(dir, f)).mtimeMs > MARKER_MAX_AGE_MS) fs.unlinkSync(path.join(dir, f)); } catch { /* keep */ }
|
|
265
|
+
}
|
|
266
|
+
} catch { /* unreadable dir — the exclusive create below still decides */ }
|
|
267
|
+
try {
|
|
268
|
+
// 'wx' fails when the marker exists: the create IS the check, so two hook
|
|
269
|
+
// processes racing on the same stop cannot both block.
|
|
270
|
+
fs.writeFileSync(marker, JSON.stringify({ at: new Date(now).toISOString() }), { flag: 'wx', mode: 0o600 });
|
|
271
|
+
return true;
|
|
272
|
+
} catch { return false; }
|
|
273
|
+
}
|
|
274
|
+
|
|
158
275
|
function main() {
|
|
159
276
|
let input = '';
|
|
160
277
|
process.stdin.setEncoding('utf8');
|
|
@@ -166,18 +283,26 @@ function main() {
|
|
|
166
283
|
if (!payload || typeof payload !== 'object') payload = {};
|
|
167
284
|
|
|
168
285
|
const projectDir = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
|
|
286
|
+
if (deferToClaudeRegistration(projectDir)) process.exit(0);
|
|
169
287
|
const planningDir = planningPath(projectDir);
|
|
170
288
|
|
|
171
289
|
let config = null;
|
|
172
290
|
try { config = JSON.parse(fs.readFileSync(path.join(planningDir, 'config.json'), 'utf8')); } catch { /* no project / bad config -> allow */ }
|
|
173
291
|
|
|
174
|
-
|
|
292
|
+
let decision = buildStopDecision({
|
|
175
293
|
stopHookActive: payload.stop_hook_active === true,
|
|
176
294
|
config,
|
|
177
295
|
stateContent: readIfExists(path.join(planningDir, 'state.md')),
|
|
178
296
|
roadmapContent: readIfExists(path.join(planningDir, 'roadmap.md')),
|
|
179
297
|
});
|
|
180
298
|
|
|
299
|
+
// Gemini CLI's end-of-turn event: its stop_hook_active cannot carry the
|
|
300
|
+
// one-shot promise on its own (see the header), so a marker does.
|
|
301
|
+
if (decision && payload.hook_event_name === 'AfterAgent') {
|
|
302
|
+
const name = onceMarkerName(payload.session_id || payload.sessionId, projectDir, decision.reason);
|
|
303
|
+
if (!claimOnceMarker(hookStateDir(), name)) decision = null;
|
|
304
|
+
}
|
|
305
|
+
|
|
181
306
|
if (decision) process.stdout.write(JSON.stringify(decision));
|
|
182
307
|
} catch { /* fail open — never break a stop */ }
|
|
183
308
|
process.exit(0);
|
|
@@ -188,4 +313,4 @@ if (require.main === module) {
|
|
|
188
313
|
main();
|
|
189
314
|
}
|
|
190
315
|
|
|
191
|
-
module.exports = { buildStopDecision, UNTICKED_PHASE_RE };
|
|
316
|
+
module.exports = { deferToClaudeRegistration, buildStopDecision, UNTICKED_PHASE_RE, onceMarkerName, claimOnceMarker };
|
|
@@ -17,6 +17,45 @@
|
|
|
17
17
|
|
|
18
18
|
const fs = require('fs');
|
|
19
19
|
const path = require('path');
|
|
20
|
+
|
|
21
|
+
// ─── R39: one run per hook when Claude and Copilot share a project ───────────
|
|
22
|
+
// Copilot CLI also runs the hooks in a repository's .claude/settings.json and
|
|
23
|
+
// .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
|
|
24
|
+
// hooks loaded): in a project with both the Claude and the Copilot install, every
|
|
25
|
+
// PAN hook ran twice under Copilot — once from .github/hooks/pan.json, once from the
|
|
26
|
+
// Claude settings. The Copilot project copy (this file under .github/hooks) steps
|
|
27
|
+
// aside whenever the project's Claude settings register the same script, so the hook
|
|
28
|
+
// runs once, and runs again from here the moment the Claude registration is gone.
|
|
29
|
+
// Both files are repository hooks to Copilot and load under the same trust rule, so
|
|
30
|
+
// deferring never leaves zero. Only this copy defers: Claude Code never reads
|
|
31
|
+
// .github/hooks, Gemini and Codex never read .claude/settings.json, and a global
|
|
32
|
+
// Copilot copy loads where repository hooks may not. Identical in every hook
|
|
33
|
+
// Copilot registers — tests/copilot-hook-dedupe.test.cjs pins the copies.
|
|
34
|
+
function deferToClaudeRegistration(projectDir, hookFile = __filename) {
|
|
35
|
+
// Assembled, not written as a literal: the installer rewrites every quoted .claude
|
|
36
|
+
// literal in a hook copy to that runtime's own directory, and this one must stay Claude's.
|
|
37
|
+
const claudeDir = ['.', 'claude'].join('');
|
|
38
|
+
try {
|
|
39
|
+
const hooksDir = path.dirname(hookFile);
|
|
40
|
+
if (path.basename(hooksDir) !== 'hooks' || path.basename(path.dirname(hooksDir)) !== '.github') return false;
|
|
41
|
+
if (typeof projectDir !== 'string' || !projectDir) return false;
|
|
42
|
+
const script = path.basename(hookFile);
|
|
43
|
+
for (const name of ['settings.json', 'settings.local.json']) {
|
|
44
|
+
let settings;
|
|
45
|
+
try { settings = JSON.parse(fs.readFileSync(path.join(projectDir, claudeDir, name), 'utf8')); } catch { continue; }
|
|
46
|
+
const events = settings && typeof settings.hooks === 'object' ? settings.hooks : null;
|
|
47
|
+
if (!events) continue;
|
|
48
|
+
for (const groups of Object.values(events)) {
|
|
49
|
+
if (!Array.isArray(groups)) continue;
|
|
50
|
+
for (const group of groups) {
|
|
51
|
+
const handlers = group && Array.isArray(group.hooks) ? group.hooks : [];
|
|
52
|
+
if (handlers.some((h) => h && typeof h.command === 'string' && h.command.includes(script))) return true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch { /* fail open: run this copy */ }
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
20
59
|
const crypto = require('crypto');
|
|
21
60
|
/**
|
|
22
61
|
* Which planning tree this hook acts on.
|
|
@@ -175,7 +214,7 @@ function finalizeSession(cwd, sid) {
|
|
|
175
214
|
const COMMAND_TAIL_BYTES = 262144;
|
|
176
215
|
|
|
177
216
|
// PAN's own command namespace, as a runtime writes it: `/pan:exec-phase` (Claude Code,
|
|
178
|
-
// Gemini) or `/pan-exec-phase` (Codex, OpenCode, Copilot). Only these spawn PAN agents,
|
|
217
|
+
// Gemini) or `/pan-exec-phase` (Codex, OpenCode, Copilot, and Claude Code after --unified-skills). Only these spawn PAN agents,
|
|
179
218
|
// so only these are attributed — a host UI command (`/model`, `/compact`) and a plain
|
|
180
219
|
// typed prompt leave the row honestly unattributed instead of borrowing a name.
|
|
181
220
|
const PAN_COMMAND_RE = /<command-name>\s*\/?pan[:-]([a-z0-9][a-z0-9-]*)\s*<\/command-name>/i;
|
|
@@ -932,6 +971,7 @@ if (require.main === module) {
|
|
|
932
971
|
try {
|
|
933
972
|
const data = JSON.parse(input);
|
|
934
973
|
const cwd = data.cwd || data.workspace?.current_dir || process.cwd();
|
|
974
|
+
if (deferToClaudeRegistration(cwd)) return;
|
|
935
975
|
// M62: a global-install hook fires in every repo; skip non-PAN projects so
|
|
936
976
|
// we don't create .planning/ optimization + trace artifacts in them. The tree
|
|
937
977
|
// must already exist — see hasPlanningTree (field sweep 2026-09-17).
|
|
@@ -948,6 +988,7 @@ if (require.main === module) {
|
|
|
948
988
|
}
|
|
949
989
|
|
|
950
990
|
module.exports = {
|
|
991
|
+
deferToClaudeRegistration,
|
|
951
992
|
buildTraceEvents,
|
|
952
993
|
appendTraceEvents,
|
|
953
994
|
resolveAgentTranscript,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pan-wizard",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.31.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"
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// ─── AGENTS.md universal rules layer (ADR-0028 Phase 3) ─────────────────────
|
|
4
4
|
//
|
|
5
|
-
// AGENTS.md is the cross-runtime project-instructions standard;
|
|
6
|
-
//
|
|
5
|
+
// AGENTS.md is the cross-runtime project-instructions standard; Codex,
|
|
6
|
+
// OpenCode and Copilot CLI read it natively, Claude Code only through the @AGENTS.md
|
|
7
|
+
// import in CLAUDE.md; Gemini CLI reads GEMINI.md by default, not this file. PAN contributes one
|
|
7
8
|
// marker-fenced section so agents in any runtime understand the PAN context
|
|
8
9
|
// when reading the repo. User content outside the markers is never touched.
|
|
9
10
|
//
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const { safeReadFile, loadConfig, isGitIgnored, isGitRepo, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, resolveEffortInternal, detectProvider, resolveTierToModel, estimateCostMultiplier, MODEL_PROFILES, output, error, findPhaseInternal, scanPendingTodos, toPosix } = require('./core.cjs');
|
|
6
|
+
const { safeReadFile, loadConfig, isGitIgnored, isGitRepo, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, resolveModelDetailed, resolveEffortInternal, detectProvider, resolveTierToModel, estimateCostMultiplier, MODEL_PROFILES, output, error, findPhaseInternal, scanPendingTodos, toPosix } = require('./core.cjs');
|
|
7
7
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
8
8
|
const { PHASES_DIR, MILESTONES_DIR, QUICK_DIR, STATE_FILE, ROADMAP_FILE, PROJECT_FILE, PATTERNS_FILE, SESSION_HISTORY_FILE, LEARNINGS_FILE, CONTEXT_SUFFIX, UAT_SUFFIX, VERIFICATION_SUFFIX, isPlanFile, isSummaryFile, ARCHIVE_DIR_RE, PHASE_DIR_RE, CONTEXT_WINDOW, WARNING_THRESHOLD, CRITICAL_THRESHOLD, VALID_COMMIT_TYPES, DEFAULT_SENSITIVE_PATTERNS } = require('./constants.cjs');
|
|
9
9
|
const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, planningRel } = require('./utils.cjs');
|
|
@@ -220,7 +220,7 @@ function cmdHistoryDigest(cwd, raw) {
|
|
|
220
220
|
* @param {string} [metadataJson] - Optional JSON string with task metadata for complexity routing
|
|
221
221
|
* @returns {void}
|
|
222
222
|
*/
|
|
223
|
-
function cmdResolveModel(cwd, agentType, raw, metadataJson) {
|
|
223
|
+
function cmdResolveModel(cwd, agentType, raw, metadataJson, attempt) {
|
|
224
224
|
if (!agentType) {
|
|
225
225
|
error('agent-type required');
|
|
226
226
|
}
|
|
@@ -230,6 +230,11 @@ function cmdResolveModel(cwd, agentType, raw, metadataJson) {
|
|
|
230
230
|
try { taskMetadata = JSON.parse(metadataJson); }
|
|
231
231
|
catch { /* ignore invalid metadata, use static routing */ }
|
|
232
232
|
}
|
|
233
|
+
// --attempt N (M8): the retry number, 1 for a first try. It rides in the
|
|
234
|
+
// metadata so every resolution rule sees it; the flag wins over a metadata key.
|
|
235
|
+
if (attempt !== undefined) {
|
|
236
|
+
taskMetadata = { ...(taskMetadata && typeof taskMetadata === 'object' ? taskMetadata : {}), attempt };
|
|
237
|
+
}
|
|
233
238
|
|
|
234
239
|
const config = loadConfig(cwd);
|
|
235
240
|
const profile = config.model_profile || 'balanced';
|
|
@@ -243,9 +248,14 @@ function cmdResolveModel(cwd, agentType, raw, metadataJson) {
|
|
|
243
248
|
return;
|
|
244
249
|
}
|
|
245
250
|
|
|
246
|
-
const
|
|
251
|
+
const resolved = resolveModelDetailed(cwd, agentType, taskMetadata);
|
|
252
|
+
const model = resolved.model;
|
|
247
253
|
const effort = resolveEffortInternal(cwd, agentType);
|
|
248
254
|
const result = { model, profile, strategy, effort };
|
|
255
|
+
if (taskMetadata && Number.isInteger(taskMetadata.attempt)) {
|
|
256
|
+
result.attempt = taskMetadata.attempt;
|
|
257
|
+
result.escalated_from = resolved.escalated_from;
|
|
258
|
+
}
|
|
249
259
|
output(result, raw, model);
|
|
250
260
|
}
|
|
251
261
|
|
|
@@ -804,7 +814,13 @@ function cmdTodoComplete(cwd, filename, raw) {
|
|
|
804
814
|
}
|
|
805
815
|
|
|
806
816
|
const today = new Date().toISOString().split('T')[0];
|
|
807
|
-
|
|
817
|
+
// /pan:todo-add writes YAML frontmatter; the date goes inside it. Prepending it
|
|
818
|
+
// put `completed:` above the opening `---` and broke the block. A file with no
|
|
819
|
+
// frontmatter keeps the old first-line form.
|
|
820
|
+
const fm = content.match(/^---\r?\n/);
|
|
821
|
+
content = fm
|
|
822
|
+
? fm[0] + `completed: ${today}` + fm[0].slice(3) + content.slice(fm[0].length)
|
|
823
|
+
: `completed: ${today}\n` + content;
|
|
808
824
|
|
|
809
825
|
try {
|
|
810
826
|
fs.writeFileSync(path.join(completedDir, filename), content, 'utf-8');
|
|
@@ -84,6 +84,8 @@ function buildConfigDefaults(hasBraveSearch, userDefaults) {
|
|
|
84
84
|
strategy: 'static',
|
|
85
85
|
provider: 'auto',
|
|
86
86
|
cascade_quality_gate: true,
|
|
87
|
+
// Failure-tier escalation (M8): how many tiers a retry may climb.
|
|
88
|
+
max_escalations: 1,
|
|
87
89
|
complexity_thresholds: {
|
|
88
90
|
downgrade_max: 2,
|
|
89
91
|
upgrade_min: 6,
|
|
@@ -607,7 +607,9 @@ const SKILL_ALIGN_MAX_TASKS = 50; // larger drafts are a planning
|
|
|
607
607
|
const SKILL_ALIGN_CONTENT_CAP = 700; // chars of file head scored (≈ objective paragraph)
|
|
608
608
|
|
|
609
609
|
/** Valid conventional commit types */
|
|
610
|
-
|
|
610
|
+
// `perf` is a task commit type in references/git-integration.md; it was missing
|
|
611
|
+
// here, so `pan-tools commit --type perf` refused a type the docs prescribe.
|
|
612
|
+
const VALID_COMMIT_TYPES = ['feat', 'fix', 'docs', 'test', 'refactor', 'perf', 'chore'];
|
|
611
613
|
|
|
612
614
|
/** Default sensitive file patterns for commit safety checks */
|
|
613
615
|
const DEFAULT_SENSITIVE_PATTERNS = ['\\.env$', '\\.pem$', '\\.key$', 'credentials', 'secret', 'password', 'token'];
|
|
@@ -113,9 +113,23 @@ function assessCacheTtl(records, opts = {}) {
|
|
|
113
113
|
// last record: its cache writes are not a subagent's re-write after an idle
|
|
114
114
|
// gap, and the subagent cache lifetime setting does not govern them.
|
|
115
115
|
.filter(r => !(r && r.token_source === 'session-transcript'))
|
|
116
|
-
|
|
116
|
+
// A row that recorded the cache-write lifetime split (M13) says which writes
|
|
117
|
+
// were five-minute ones; only those can be a re-write after a short idle gap —
|
|
118
|
+
// a one-hour write already outlives it. Rows without the split fall back to the
|
|
119
|
+
// gap heuristic on the whole write.
|
|
120
|
+
.map(r => {
|
|
121
|
+
const measured = r && typeof r.cache_write_5m_tokens === 'number';
|
|
122
|
+
return {
|
|
123
|
+
t: r && r.ts ? new Date(r.ts).getTime() : NaN,
|
|
124
|
+
w: measured ? r.cache_write_5m_tokens : (Number(r && r.cache_write_tokens) || 0),
|
|
125
|
+
w1h: measured ? (Number(r.cache_write_1h_tokens) || 0) : 0,
|
|
126
|
+
measured,
|
|
127
|
+
};
|
|
128
|
+
})
|
|
117
129
|
.filter(r => Number.isFinite(r.t))
|
|
118
130
|
.sort((a, b) => a.t - b.t);
|
|
131
|
+
const measuredRows = rows.filter(r => r.measured);
|
|
132
|
+
const basis = measuredRows.length === 0 ? 'inferred' : measuredRows.length === rows.length ? 'measured' : 'mixed';
|
|
119
133
|
let shortIdle = 0; let shortIdleTokens = 0; let longIdle = 0;
|
|
120
134
|
for (let i = 1; i < rows.length; i++) {
|
|
121
135
|
if (rows[i].w < minWrite) continue;
|
|
@@ -126,14 +140,25 @@ function assessCacheTtl(records, opts = {}) {
|
|
|
126
140
|
const recommend = shortIdle >= recommendAt;
|
|
127
141
|
const setting = 'subagentPromptCacheTtl';
|
|
128
142
|
const severity = recommend && shortIdleTokens >= (opts.warnTokens ?? TTL_WARN_TOKENS) ? 'warn' : 'info';
|
|
143
|
+
const basisNote = basis === 'measured'
|
|
144
|
+
? ' (measured: the ledger records each write\'s cache lifetime)'
|
|
145
|
+
: basis === 'mixed'
|
|
146
|
+
? ' (partly measured: rows written before PAN recorded the cache lifetime are counted from idle gaps alone)'
|
|
147
|
+
: ' (inferred from idle gaps between rows: no row records the cache lifetime)';
|
|
129
148
|
const advice = recommend
|
|
130
|
-
? `${shortIdle} cache writes followed an idle gap of ${TTL_SHORT_MIN}–${TTL_LONG_MIN} min (~${shortIdleTokens.toLocaleString()} tokens re-written): subagents get the five-minute cache lifetime by default — set \`${setting}: "1h"\` in a Claude Code settings file. One-hour writes bill at 2× base input against 1.25×, so this pays off once a block is read twice within the hour
|
|
149
|
+
? `${shortIdle} cache writes followed an idle gap of ${TTL_SHORT_MIN}–${TTL_LONG_MIN} min (~${shortIdleTokens.toLocaleString()} tokens re-written): subagents get the five-minute cache lifetime by default — set \`${setting}: "1h"\` in a Claude Code settings file. One-hour writes bill at 2× base input against 1.25×, so this pays off once a block is read twice within the hour.${basisNote}`
|
|
131
150
|
: null;
|
|
132
151
|
return {
|
|
133
152
|
records_considered: rows.length,
|
|
134
153
|
writes_after_short_idle: shortIdle,
|
|
135
154
|
tokens_after_short_idle: shortIdleTokens,
|
|
136
155
|
writes_after_long_idle: longIdle,
|
|
156
|
+
// Where the split came from: 'measured' (every row carries it), 'mixed', or
|
|
157
|
+
// 'inferred' (none does — the gap heuristic alone).
|
|
158
|
+
basis,
|
|
159
|
+
measured_rows: measuredRows.length,
|
|
160
|
+
cache_write_1h_tokens: measuredRows.reduce((n, r) => n + r.w1h, 0),
|
|
161
|
+
cache_write_5m_tokens: measuredRows.reduce((n, r) => n + r.w, 0),
|
|
137
162
|
recommend,
|
|
138
163
|
setting,
|
|
139
164
|
severity,
|