pan-wizard 3.30.0 → 3.32.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 +30 -26
- package/agents/pan-design-checker.md +18 -0
- package/agents/pan-optimizer.md +5 -1
- package/agents/pan-plan-checker.md +23 -0
- package/agents/pan-reviewer.md +19 -0
- package/agents/pan-verifier.md +23 -0
- package/bin/install-lib.cjs +129 -33
- package/bin/install.js +16 -9
- package/commands/pan/army.md +4 -4
- package/commands/pan/design-phase.md +8 -0
- package/commands/pan/exec-phase.md +1 -1
- package/commands/pan/git.md +2 -2
- package/commands/pan/learn.md +2 -0
- package/commands/pan/optimize.md +14 -2
- package/commands/pan/review-deep.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 +42 -1
- package/hooks/dist/pan-cost-logger.js +132 -11
- package/hooks/dist/pan-state-reinject.js +158 -0
- package/hooks/dist/pan-stop-guard.js +50 -4
- package/hooks/dist/pan-trace-logger.js +324 -18
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/commands.cjs +22 -5
- package/pan-wizard-core/bin/lib/config.cjs +2 -0
- package/pan-wizard-core/bin/lib/constants.cjs +31 -1
- package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
- package/pan-wizard-core/bin/lib/core.cjs +232 -23
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
- package/pan-wizard-core/bin/lib/cost.cjs +135 -46
- package/pan-wizard-core/bin/lib/findings.cjs +381 -0
- package/pan-wizard-core/bin/lib/focus.cjs +72 -19
- package/pan-wizard-core/bin/lib/foreign-planning.cjs +24 -6
- package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
- package/pan-wizard-core/bin/lib/hygiene.cjs +12 -2
- package/pan-wizard-core/bin/lib/init.cjs +4 -1
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
- package/pan-wizard-core/bin/lib/memory-rebuild.cjs +9 -2
- package/pan-wizard-core/bin/lib/optimize.cjs +349 -21
- package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
- package/pan-wizard-core/bin/lib/review-deep.cjs +36 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
- package/pan-wizard-core/bin/lib/state.cjs +93 -24
- 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/verdict.cjs +431 -0
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +19 -2
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -2
- package/pan-wizard-core/bin/lib/verify-scope.cjs +156 -0
- package/pan-wizard-core/bin/lib/verify.cjs +25 -2
- package/pan-wizard-core/bin/pan-tools.cjs +69 -6
- package/pan-wizard-core/learnings/README.md +10 -7
- package/pan-wizard-core/mcp/tool-registry.cjs +16 -4
- package/pan-wizard-core/references/verdict-contract.md +101 -0
- package/pan-wizard-core/templates/verification-report.md +16 -0
- package/pan-wizard-core/workflows/exec-phase.md +73 -47
- package/pan-wizard-core/workflows/health.md +2 -0
- package/pan-wizard-core/workflows/help.md +2 -2
- package/pan-wizard-core/workflows/learn.md +4 -2
- package/pan-wizard-core/workflows/milestone-audit.md +15 -3
- package/pan-wizard-core/workflows/optimize.md +14 -1
- package/pan-wizard-core/workflows/phase-tests.md +1 -1
- package/pan-wizard-core/workflows/plan-phase.md +20 -15
- package/pan-wizard-core/workflows/progress.md +1 -1
- package/pan-wizard-core/workflows/todo-add.md +1 -1
- package/pan-wizard-core/workflows/todo-check.md +7 -6
- package/pan-wizard-core/workflows/verify-phase.md +3 -13
- package/pan-zcode/README.md +3 -3
- package/scripts/build-agent-plugin.js +4 -0
- package/scripts/build-hooks.js +2 -1
- package/scripts/build-plugin.js +10 -0
- package/scripts/git-hooks/pre-commit +2 -1
- package/scripts/npm-dist-tag.js +47 -0
- package/scripts/plugin-path.js +6 -1
- package/scripts/test-surface.cjs +1 -0
|
@@ -52,6 +52,12 @@
|
|
|
52
52
|
// directory: a second stop aimed at the same phase is allowed. No safe marker
|
|
53
53
|
// directory, or no session id, means no block (fail open, as everywhere else).
|
|
54
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
|
+
//
|
|
55
61
|
// Escape hatch: set workflow.stop_guard to false in .planning/config.json to
|
|
56
62
|
// disable the guard entirely without turning off auto_advance.
|
|
57
63
|
//
|
|
@@ -66,13 +72,52 @@
|
|
|
66
72
|
const fs = require('fs');
|
|
67
73
|
const os = require('os');
|
|
68
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
|
+
}
|
|
69
114
|
const crypto = require('crypto');
|
|
70
115
|
/**
|
|
71
116
|
* Which planning tree this hook acts on.
|
|
72
117
|
*
|
|
73
118
|
* Mirrors pan-wizard-core/bin/lib/planning-root.cjs, which hooks cannot require
|
|
74
|
-
* (they are standalone and run inside the host runtime).
|
|
75
|
-
* identical copy — if the CLI is pointed at a track while a hook still writes to
|
|
119
|
+
* (they are standalone and run inside the host runtime). Every PAN hook that
|
|
120
|
+
* reads the planning tree carries an identical copy — if the CLI is pointed at a track while a hook still writes to
|
|
76
121
|
* `.planning/`, that tree's telemetry lands in the wrong place.
|
|
77
122
|
*
|
|
78
123
|
* Env only — a hook gets no argv. A value that escapes the project root is
|
|
@@ -238,6 +283,7 @@ function main() {
|
|
|
238
283
|
if (!payload || typeof payload !== 'object') payload = {};
|
|
239
284
|
|
|
240
285
|
const projectDir = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
|
|
286
|
+
if (deferToClaudeRegistration(projectDir)) process.exit(0);
|
|
241
287
|
const planningDir = planningPath(projectDir);
|
|
242
288
|
|
|
243
289
|
let config = null;
|
|
@@ -253,7 +299,7 @@ function main() {
|
|
|
253
299
|
// Gemini CLI's end-of-turn event: its stop_hook_active cannot carry the
|
|
254
300
|
// one-shot promise on its own (see the header), so a marker does.
|
|
255
301
|
if (decision && payload.hook_event_name === 'AfterAgent') {
|
|
256
|
-
const name = onceMarkerName(payload.session_id, projectDir, decision.reason);
|
|
302
|
+
const name = onceMarkerName(payload.session_id || payload.sessionId, projectDir, decision.reason);
|
|
257
303
|
if (!claimOnceMarker(hookStateDir(), name)) decision = null;
|
|
258
304
|
}
|
|
259
305
|
|
|
@@ -267,4 +313,4 @@ if (require.main === module) {
|
|
|
267
313
|
main();
|
|
268
314
|
}
|
|
269
315
|
|
|
270
|
-
module.exports = { buildStopDecision, UNTICKED_PHASE_RE, onceMarkerName, claimOnceMarker };
|
|
316
|
+
module.exports = { deferToClaudeRegistration, buildStopDecision, UNTICKED_PHASE_RE, onceMarkerName, claimOnceMarker };
|
|
@@ -9,21 +9,63 @@
|
|
|
9
9
|
// trace artifacts in every repo the user opens.
|
|
10
10
|
//
|
|
11
11
|
// Events logged per subagent:
|
|
12
|
-
// - completion: agent finished, tokens used, exit status
|
|
12
|
+
// - completion: agent finished, tokens used, exit status, tool calls/failures
|
|
13
13
|
// - redundancy: detected when the same agent type ran twice in this session
|
|
14
14
|
// with similar token counts (rough heuristic for repeated work)
|
|
15
|
+
// - tool_error: a failed tool call inside the subagent's own transcript
|
|
16
|
+
// (evidence loop, ADR-0049), grouped per spawn, redacted, capped
|
|
15
17
|
//
|
|
16
18
|
// Errors are swallowed — this hook must never block the main agent loop.
|
|
17
19
|
|
|
18
20
|
const fs = require('fs');
|
|
21
|
+
const os = require('os');
|
|
19
22
|
const path = require('path');
|
|
23
|
+
|
|
24
|
+
// ─── R39: one run per hook when Claude and Copilot share a project ───────────
|
|
25
|
+
// Copilot CLI also runs the hooks in a repository's .claude/settings.json and
|
|
26
|
+
// .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
|
|
27
|
+
// hooks loaded): in a project with both the Claude and the Copilot install, every
|
|
28
|
+
// PAN hook ran twice under Copilot — once from .github/hooks/pan.json, once from the
|
|
29
|
+
// Claude settings. The Copilot project copy (this file under .github/hooks) steps
|
|
30
|
+
// aside whenever the project's Claude settings register the same script, so the hook
|
|
31
|
+
// runs once, and runs again from here the moment the Claude registration is gone.
|
|
32
|
+
// Both files are repository hooks to Copilot and load under the same trust rule, so
|
|
33
|
+
// deferring never leaves zero. Only this copy defers: Claude Code never reads
|
|
34
|
+
// .github/hooks, Gemini and Codex never read .claude/settings.json, and a global
|
|
35
|
+
// Copilot copy loads where repository hooks may not. Identical in every hook
|
|
36
|
+
// Copilot registers — tests/copilot-hook-dedupe.test.cjs pins the copies.
|
|
37
|
+
function deferToClaudeRegistration(projectDir, hookFile = __filename) {
|
|
38
|
+
// Assembled, not written as a literal: the installer rewrites every quoted .claude
|
|
39
|
+
// literal in a hook copy to that runtime's own directory, and this one must stay Claude's.
|
|
40
|
+
const claudeDir = ['.', 'claude'].join('');
|
|
41
|
+
try {
|
|
42
|
+
const hooksDir = path.dirname(hookFile);
|
|
43
|
+
if (path.basename(hooksDir) !== 'hooks' || path.basename(path.dirname(hooksDir)) !== '.github') return false;
|
|
44
|
+
if (typeof projectDir !== 'string' || !projectDir) return false;
|
|
45
|
+
const script = path.basename(hookFile);
|
|
46
|
+
for (const name of ['settings.json', 'settings.local.json']) {
|
|
47
|
+
let settings;
|
|
48
|
+
try { settings = JSON.parse(fs.readFileSync(path.join(projectDir, claudeDir, name), 'utf8')); } catch { continue; }
|
|
49
|
+
const events = settings && typeof settings.hooks === 'object' ? settings.hooks : null;
|
|
50
|
+
if (!events) continue;
|
|
51
|
+
for (const groups of Object.values(events)) {
|
|
52
|
+
if (!Array.isArray(groups)) continue;
|
|
53
|
+
for (const group of groups) {
|
|
54
|
+
const handlers = group && Array.isArray(group.hooks) ? group.hooks : [];
|
|
55
|
+
if (handlers.some((h) => h && typeof h.command === 'string' && h.command.includes(script))) return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch { /* fail open: run this copy */ }
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
20
62
|
const crypto = require('crypto');
|
|
21
63
|
/**
|
|
22
64
|
* Which planning tree this hook acts on.
|
|
23
65
|
*
|
|
24
66
|
* Mirrors pan-wizard-core/bin/lib/planning-root.cjs, which hooks cannot require
|
|
25
|
-
* (they are standalone and run inside the host runtime).
|
|
26
|
-
* identical copy — if the CLI is pointed at a track while a hook still writes to
|
|
67
|
+
* (they are standalone and run inside the host runtime). Every PAN hook that
|
|
68
|
+
* reads the planning tree carries an identical copy — if the CLI is pointed at a track while a hook still writes to
|
|
27
69
|
* `.planning/`, that tree's telemetry lands in the wrong place.
|
|
28
70
|
*
|
|
29
71
|
* Env only — a hook gets no argv. A value that escapes the project root is
|
|
@@ -105,11 +147,13 @@ const TRACES_DIR = 'traces';
|
|
|
105
147
|
const CURRENT_SESSION_FILE = 'current-session';
|
|
106
148
|
const TRACE_EVENT_FILE = 'trace.jsonl';
|
|
107
149
|
|
|
108
|
-
// Trace event schema version
|
|
109
|
-
//
|
|
110
|
-
// `
|
|
111
|
-
//
|
|
112
|
-
|
|
150
|
+
// Trace event schema version. v3 added the per-invocation `event_sig`
|
|
151
|
+
// discriminator to the completion event's context; v4 added `agent_id` and the
|
|
152
|
+
// `agent-transcript` token source (both in lockstep with pan-cost-logger.js, whose
|
|
153
|
+
// ledger rows gained the same fields). v5 is this hook's alone: `tool_calls` and
|
|
154
|
+
// `tool_errors` on the completion and the `error/tool_error` event. The cost
|
|
155
|
+
// ledger's rows did not change, so pan-cost-logger stays at 4.
|
|
156
|
+
const SCHEMA_V = 5;
|
|
113
157
|
|
|
114
158
|
// YYYYMMDD stamp for a Date (the day-scope of an auto-session id).
|
|
115
159
|
function dayStamp(d) {
|
|
@@ -345,10 +389,11 @@ const MAX_SEEN_TRANSCRIPTS = 16;
|
|
|
345
389
|
// • `model` and `phase` came out null in those recorded rows — the payload
|
|
346
390
|
// carried neither; `usage` is absent entirely in headless mode
|
|
347
391
|
// (docs/HOOKS.md, P-1805).
|
|
348
|
-
// •
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
392
|
+
// • Claude Code 2.1.280 sends `agent_id`, a per-spawn id (captured 2026-09-29 in
|
|
393
|
+
// tests/fixtures/hooks/subagent-stop-claude.json); no other host's payload has
|
|
394
|
+
// been captured with one.
|
|
395
|
+
// So for two CONCURRENT SAME-TYPE siblings a varying payload field is confirmed only
|
|
396
|
+
// on Claude Code: where the host supplies one, both spawns are admitted; where it
|
|
352
397
|
// supplies none the payloads are the same bytes, hence indistinguishable from a
|
|
353
398
|
// re-fire, and the second stays suppressed.
|
|
354
399
|
//
|
|
@@ -396,10 +441,13 @@ const MAX_SEEN_TRANSCRIPTS = 16;
|
|
|
396
441
|
// reinstates the duplicate-record bug. It is the one UNDER-count residual —
|
|
397
442
|
// distinct from the evicted-marker phantom (over-count) and from N29.
|
|
398
443
|
//
|
|
399
|
-
// Null when unserializable → fail open.
|
|
444
|
+
// Null when unserializable → fail open. SHA-256 cut to the 40 hex characters the
|
|
445
|
+
// SHA-1 it replaced produced, so persisted `event_sig` values keep their shape: a
|
|
446
|
+
// dedup key, not a security boundary, but CodeQL rightly flags SHA-1 over a payload
|
|
447
|
+
// that carries a session id (2026-09-29).
|
|
400
448
|
function eventSignature(data) {
|
|
401
449
|
try {
|
|
402
|
-
return crypto.createHash('
|
|
450
|
+
return crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 40);
|
|
403
451
|
} catch { return null; }
|
|
404
452
|
}
|
|
405
453
|
|
|
@@ -552,6 +600,27 @@ function resolveAgentTranscript(data) {
|
|
|
552
600
|
}
|
|
553
601
|
}
|
|
554
602
|
|
|
603
|
+
// Host-internal agents (measured 2026-09-28). Claude Code runs helper agents of its own:
|
|
604
|
+
// the compaction summariser, and one at almost every turn end of an interactive session.
|
|
605
|
+
// It fires SubagentStop for them with an `agent_id`, an EMPTY `agent_type`, and an
|
|
606
|
+
// `agent_transcript_path` it never writes. That shape was captured from a real `/compact`
|
|
607
|
+
// on Claude Code 2.1.280 (tests/fixtures/hooks/subagent-stop-internal-claude.json). Both
|
|
608
|
+
// loggers booked each as an `unknown` zero-token spawn: 181 rows in three field projects
|
|
609
|
+
// in eleven days, skewing optimize learn's agent stats and the ledger's spawn counts.
|
|
610
|
+
// The host documents `agent_type` as present whenever a hook fires inside a subagent, so
|
|
611
|
+
// a named instance with no type and no transcript is not a spawn anyone asked for, and
|
|
612
|
+
// nothing is recorded. A NAMED spawn whose file is late or missing keeps its zero-token
|
|
613
|
+
// row (N17/N25); a payload with no agent instance at all keeps the parent-slice path.
|
|
614
|
+
// Identical in pan-cost-logger.js and pan-trace-logger.js (tests/trace-logger.test.cjs
|
|
615
|
+
// pins the copies).
|
|
616
|
+
function isHostInternalAgent(data, agentTranscript) {
|
|
617
|
+
if (!data || typeof data !== 'object' || agentTranscript) return false;
|
|
618
|
+
const typed = [data.agent_type, data.subagent_type].some((v) => typeof v === 'string' && v.trim() !== '');
|
|
619
|
+
if (typed) return false;
|
|
620
|
+
return (typeof data.agent_id === 'string' && AGENT_ID_SAFE.test(data.agent_id))
|
|
621
|
+
|| (typeof data.agent_transcript_path === 'string' && data.agent_transcript_path !== '');
|
|
622
|
+
}
|
|
623
|
+
|
|
555
624
|
// Slice ceilings — a sum over one subagent's conversation, so above a single
|
|
556
625
|
// call's PLAUSIBLE_MAX; cache_read and output mirror cost.cjs isSuspectRecord's
|
|
557
626
|
// absolute limits, input is a hook-only sanity ceiling (same values as
|
|
@@ -658,6 +727,191 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
|
|
|
658
727
|
return totals;
|
|
659
728
|
}
|
|
660
729
|
|
|
730
|
+
// ─── Tool-failure capture (evidence loop EL-9, ADR-0049) ─────────────────────
|
|
731
|
+
// The optimisation loop learns from failures, and until this capture the only
|
|
732
|
+
// failures it saw were the ones an agent chose to report through `optimize trace
|
|
733
|
+
// log`. Across the field projects that was none, while the subagents' own
|
|
734
|
+
// transcripts held a failed tool call in roughly one spawn in four (measured
|
|
735
|
+
// 2026-09-28). The hook already reads the subagent's own transcript for tokens, so
|
|
736
|
+
// it records the failed tool results in that same slice. It does so only on the
|
|
737
|
+
// agent-transcript path: a slice of the shared parent transcript would book the
|
|
738
|
+
// session's failures to whichever spawn stopped.
|
|
739
|
+
|
|
740
|
+
const MAX_FAILURE_EVENTS = 10;
|
|
741
|
+
const ERROR_MESSAGE_MAX = 160;
|
|
742
|
+
// First match wins; `exit_code` is decided before these, from the Bash result shape.
|
|
743
|
+
const ERROR_CLASSES = [
|
|
744
|
+
['permission_denied', /\bPermission (to use|for this command)\b|\bwas denied\b|\bdenied by\b/i],
|
|
745
|
+
['edit_precondition', /String to replace not found|has not been read|modified since (it was )?read|must read/i],
|
|
746
|
+
['not_found', /does not exist|no such file|\bENOENT\b|\bnot found\b/i],
|
|
747
|
+
['network_or_timeout', /getaddrinfo|ECONNREFUSED|ECONNRESET|ETIMEDOUT|fetch failed|timed? ?out|socket hang up/i],
|
|
748
|
+
['user_rejected', /\binterrupted\b|\brejected\b|doesn't want to proceed/i],
|
|
749
|
+
['tool_input', /InputValidationError|Invalid tool parameters|invalid input/i],
|
|
750
|
+
];
|
|
751
|
+
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,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,})/g;
|
|
752
|
+
const SECRET_KV_RE = /\b(api[_-]?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|client[_-]?secret|token|secret|password|passwd|pwd|authorization)(["']?\s*[:=]\s*)(?!Bearer\s|<redacted>)("[^"]*"|'[^']*'|[^\s,;&]+)/gi;
|
|
753
|
+
|
|
754
|
+
/** Records of a transcript past `sinceLine` (the same record count the token cursor uses). */
|
|
755
|
+
function readTranscriptSlice(transcriptPath, sinceLine = 0) {
|
|
756
|
+
let raw;
|
|
757
|
+
try { raw = fs.readFileSync(transcriptPath, 'utf-8'); } catch { return []; }
|
|
758
|
+
const out = [];
|
|
759
|
+
let seen = 0;
|
|
760
|
+
for (const line of raw.split('\n')) {
|
|
761
|
+
if (!line) continue;
|
|
762
|
+
seen++;
|
|
763
|
+
if (seen <= sinceLine) continue;
|
|
764
|
+
try { out.push(JSON.parse(line)); } catch { /* a torn line is skipped, as in the token read */ }
|
|
765
|
+
}
|
|
766
|
+
return out;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Error text a trace file may hold. Trace files are committed in some projects, so
|
|
771
|
+
* the message is redacted before it is stored:
|
|
772
|
+
* - bearer tokens, key/value secrets and known token prefixes;
|
|
773
|
+
* - long mixed letter-and-digit runs;
|
|
774
|
+
* - URL query strings;
|
|
775
|
+
* - the project directory, which becomes . (portable, and shorter);
|
|
776
|
+
* - the user's home directory, which becomes ~.
|
|
777
|
+
* The message is also capped at ERROR_MESSAGE_MAX characters.
|
|
778
|
+
*/
|
|
779
|
+
function redactErrorText(text, home = os.homedir(), projectDir = null) {
|
|
780
|
+
let t = String(text == null ? '' : text).replace(/\u001b\[[0-9;]*[A-Za-z]/g, '');
|
|
781
|
+
t = t.replace(/\bBearer\s+[A-Za-z0-9._~+\/=-]+/gi, 'Bearer <redacted>');
|
|
782
|
+
t = t.replace(SECRET_KV_RE, '$1$2<redacted>');
|
|
783
|
+
t = t.replace(TOKEN_RE, '<redacted>');
|
|
784
|
+
t = t.replace(/[A-Za-z0-9+=_-]{32,}/g, (run) => (/\d/.test(run) && /[A-Za-z]/.test(run) ? '<redacted>' : run));
|
|
785
|
+
t = t.replace(/(https?:\/\/[^\s?#]+)\?[^\s#]*/gi, '$1?<redacted>');
|
|
786
|
+
if (typeof projectDir === 'string' && projectDir.length > 3) {
|
|
787
|
+
for (const d of new Set([projectDir, projectDir.replace(/\\/g, '/'), projectDir.replace(/\//g, '\\')])) {
|
|
788
|
+
t = t.split(d).join('.');
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (home && home.length > 3) {
|
|
792
|
+
for (const h of new Set([home, home.replace(/\\/g, '/'), home.replace(/\//g, '\\')])) {
|
|
793
|
+
t = t.split(h).join('~');
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
t = t.replace(/[A-Za-z]:[\\/]+Users[\\/]+[^\\/\s]+/gi, '~').replace(/\/(?:home|Users)\/[^/\s]+/g, '~');
|
|
797
|
+
t = t.replace(/\s+/g, ' ').trim();
|
|
798
|
+
return t.length > ERROR_MESSAGE_MAX ? t.slice(0, ERROR_MESSAGE_MAX - 1) + '…' : t;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Class, exit code and the informative line of a failed tool result. A shell failure
|
|
803
|
+
* (Bash, or PowerShell on Windows) opens with `Exit code <n>`, and a line after it
|
|
804
|
+
* says what failed. npm/yarn/pnpm first echo the script they run (`> pkg@1.0.0 test`,
|
|
805
|
+
* `> node --test`), which says nothing about the failure, so those lines are skipped.
|
|
806
|
+
* The first real run on 2026-09-28 recorded exactly that echo as its message.
|
|
807
|
+
*/
|
|
808
|
+
function classifyToolError(text) {
|
|
809
|
+
const lines = String(text == null ? '' : text).replace(/<\/?tool_use_error>/g, '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
810
|
+
const first = lines[0] || '';
|
|
811
|
+
const exit = /^Exit code (\d+)$/.exec(first);
|
|
812
|
+
if (exit) {
|
|
813
|
+
const code = Number(exit[1]);
|
|
814
|
+
const said = lines.slice(1).find((l) => !/^> /.test(l));
|
|
815
|
+
return { errorClass: 'exit_code', exitCode: code, message: said || `exit code ${code}` };
|
|
816
|
+
}
|
|
817
|
+
const probe = lines.slice(0, 3).join(' ');
|
|
818
|
+
for (const [cls, re] of ERROR_CLASSES) if (re.test(probe)) return { errorClass: cls, exitCode: null, message: first };
|
|
819
|
+
return { errorClass: 'other', exitCode: null, message: first || '(no message)' };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* The failed tool calls of one transcript slice, grouped by (tool, class, message).
|
|
824
|
+
* A retry loop repeating one failure is one group with a count, not a flood.
|
|
825
|
+
* @returns {{toolCalls: number, toolErrors: number, groups: Object[], omitted: number}}
|
|
826
|
+
*/
|
|
827
|
+
function extractToolFailures(records, home = os.homedir(), projectDir = null) {
|
|
828
|
+
const names = new Map();
|
|
829
|
+
let toolCalls = 0;
|
|
830
|
+
for (const r of records) {
|
|
831
|
+
const content = r && r.message && r.message.content;
|
|
832
|
+
if (!Array.isArray(content)) continue;
|
|
833
|
+
for (const b of content) {
|
|
834
|
+
if (b && b.type === 'tool_use' && typeof b.id === 'string') {
|
|
835
|
+
names.set(b.id, typeof b.name === 'string' && b.name ? b.name.slice(0, 64) : 'unknown');
|
|
836
|
+
toolCalls++;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
const groups = new Map();
|
|
841
|
+
let toolErrors = 0;
|
|
842
|
+
for (const r of records) {
|
|
843
|
+
const content = r && r.message && r.message.content;
|
|
844
|
+
if (!Array.isArray(content)) continue;
|
|
845
|
+
for (const b of content) {
|
|
846
|
+
if (!b || b.type !== 'tool_result' || b.is_error !== true) continue;
|
|
847
|
+
toolErrors++;
|
|
848
|
+
const text = Array.isArray(b.content)
|
|
849
|
+
? b.content.filter((x) => x && x.type === 'text' && typeof x.text === 'string').map((x) => x.text).join('\n')
|
|
850
|
+
: (typeof b.content === 'string' ? b.content : '');
|
|
851
|
+
const tool = names.get(b.tool_use_id) || 'unknown';
|
|
852
|
+
const { errorClass, exitCode, message } = classifyToolError(text);
|
|
853
|
+
const redacted = redactErrorText(message, home, projectDir);
|
|
854
|
+
const shape = redacted.replace(/\d+/g, 'N');
|
|
855
|
+
const key = `${tool}\u0000${errorClass}\u0000${shape}`;
|
|
856
|
+
const g = groups.get(key);
|
|
857
|
+
if (g) g.count++;
|
|
858
|
+
else groups.set(key, { tool, errorClass, exitCode, message: redacted, messageSig: crypto.createHash('sha256').update(shape).digest('hex').slice(0, 12), count: 1 });
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
const all = [...groups.values()];
|
|
862
|
+
return { toolCalls, toolErrors, groups: all.slice(0, MAX_FAILURE_EVENTS), omitted: Math.max(0, all.length - MAX_FAILURE_EVENTS) };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* `execution.error_pattern_learning: false` in the project's config turns the
|
|
867
|
+
* capture off. The key shipped as "reserved" and was never read; this is its
|
|
868
|
+
* meaning. Default on; an unreadable config does not switch it off.
|
|
869
|
+
*/
|
|
870
|
+
function errorCaptureEnabled(cwd) {
|
|
871
|
+
if (!cwd) return true;
|
|
872
|
+
try {
|
|
873
|
+
const cfg = JSON.parse(fs.readFileSync(planningPath(cwd, 'config.json'), 'utf-8'));
|
|
874
|
+
const nested = cfg && cfg.execution && typeof cfg.execution === 'object' ? cfg.execution.error_pattern_learning : undefined;
|
|
875
|
+
return !(nested === false || (cfg && cfg['execution.error_pattern_learning'] === false));
|
|
876
|
+
} catch {
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** The agent type the host wrote beside a per-agent transcript (`agent-<id>.meta.json`). */
|
|
882
|
+
function agentTypeFromMeta(agentTranscriptPath) {
|
|
883
|
+
try {
|
|
884
|
+
const meta = JSON.parse(fs.readFileSync(agentTranscriptPath.replace(/\.jsonl$/, '.meta.json'), 'utf-8'));
|
|
885
|
+
const type = meta && typeof meta.agentType === 'string' ? meta.agentType.trim() : '';
|
|
886
|
+
return /^[A-Za-z0-9][A-Za-z0-9:._-]{0,63}$/.test(type) ? type : null;
|
|
887
|
+
} catch {
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Copilot CLI's camelCase hook payload (docs.github.com/en/copilot/reference/hooks-reference,
|
|
893
|
+
// read 2026-09-28; documented, not yet observed live) names every field differently from
|
|
894
|
+
// Claude's snake_case one, so this hook read no session, transcript or agent from it and
|
|
895
|
+
// booked every Copilot spawn as `unknown`. Its `agentType` is the KIND of agent (built-in
|
|
896
|
+
// or custom); `agentName` is the configured name PAN's agents carry, so the name wins.
|
|
897
|
+
// Claude payloads carry none of these keys and pass through unchanged. Identical in
|
|
898
|
+
// pan-cost-logger.js and pan-trace-logger.js (tests/copilot-payload.test.cjs pins both).
|
|
899
|
+
function normalizeHookPayload(data) {
|
|
900
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return data;
|
|
901
|
+
const pick = (...keys) => {
|
|
902
|
+
for (const k of keys) if (typeof data[k] === 'string' && data[k]) return data[k];
|
|
903
|
+
return undefined;
|
|
904
|
+
};
|
|
905
|
+
const out = { ...data };
|
|
906
|
+
if (out.session_id === undefined && pick('sessionId')) out.session_id = pick('sessionId');
|
|
907
|
+
if (out.transcript_path === undefined && pick('transcriptPath')) out.transcript_path = pick('transcriptPath');
|
|
908
|
+
if (out.agent_id === undefined && pick('agentId')) out.agent_id = pick('agentId');
|
|
909
|
+
const name = pick('agent_name', 'agentName');
|
|
910
|
+
if (name) out.agent_type = name;
|
|
911
|
+
else if (out.agent_type === undefined && pick('agentType')) out.agent_type = pick('agentType');
|
|
912
|
+
return out;
|
|
913
|
+
}
|
|
914
|
+
|
|
661
915
|
/**
|
|
662
916
|
* Build trace event(s) from a SubagentStop payload.
|
|
663
917
|
*
|
|
@@ -674,10 +928,11 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
|
|
|
674
928
|
*/
|
|
675
929
|
function buildTraceEvents(data, sessionId, cwd) {
|
|
676
930
|
if (!data || typeof data !== 'object') return [];
|
|
931
|
+
data = normalizeHookPayload(data);
|
|
677
932
|
if (data.hook_event_name && data.hook_event_name !== 'SubagentStop') return [];
|
|
678
933
|
|
|
679
934
|
const ts = new Date().toISOString();
|
|
680
|
-
|
|
935
|
+
let agent = data.agent_type || data.subagent_type || 'unknown';
|
|
681
936
|
// This event's per-invocation identity, hashed once and used by BOTH layers:
|
|
682
937
|
// the seen-event marker below and the completion's `event_sig` context field.
|
|
683
938
|
// buildTraceEvents never mutates `data`, so hoisting the hash here yields the
|
|
@@ -698,6 +953,9 @@ function buildTraceEvents(data, sessionId, cwd) {
|
|
|
698
953
|
// `transcript` for a slice of the shared parent transcript (the fallback when
|
|
699
954
|
// the host names no agent), `usage-fallback` for the payload's own counters.
|
|
700
955
|
const agentTranscript = resolveAgentTranscript(data);
|
|
956
|
+
if (isHostInternalAgent(data, agentTranscript)) return []; // the host's own helper, not a spawn
|
|
957
|
+
// A payload without an agent type still names it in the host's meta file.
|
|
958
|
+
if (agent === 'unknown' && agentTranscript) agent = agentTypeFromMeta(agentTranscript) || agent;
|
|
701
959
|
const parentPath = typeof data.transcript_path === 'string' && data.transcript_path ? data.transcript_path : null;
|
|
702
960
|
// A named agent whose file is not there consumes nothing (see pan-cost-logger:
|
|
703
961
|
// the parent slice would be the whole session booked to one spawn).
|
|
@@ -710,6 +968,7 @@ function buildTraceEvents(data, sessionId, cwd) {
|
|
|
710
968
|
: sliceSource ? 'transcript' : 'usage-fallback';
|
|
711
969
|
const agentId = typeof data.agent_id === 'string' && data.agent_id ? data.agent_id : null;
|
|
712
970
|
let clamped = false;
|
|
971
|
+
let failures = null;
|
|
713
972
|
if (sliceSource || agentFileMissing) {
|
|
714
973
|
const keyPath = sliceSource || parentPath;
|
|
715
974
|
const cursor = readTraceCursor(cwd);
|
|
@@ -726,6 +985,11 @@ function buildTraceEvents(data, sessionId, cwd) {
|
|
|
726
985
|
clamped = rawIn > SLICE_MAX.input || rawOut > SLICE_MAX.output || rawCr > SLICE_MAX.cache_read;
|
|
727
986
|
durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts); // as measured
|
|
728
987
|
if (!model) model = fromTranscript.model;
|
|
988
|
+
// Failures come from the SAME slice the tokens did (records past the cursor), so
|
|
989
|
+
// a resumed agent's earlier failures are not counted again.
|
|
990
|
+
if (tokenSource === 'agent-transcript' && fromTranscript.lineCount > since && errorCaptureEnabled(cwd)) {
|
|
991
|
+
failures = extractToolFailures(readTranscriptSlice(sliceSource, since), os.homedir(), cwd ? path.resolve(cwd) : null);
|
|
992
|
+
}
|
|
729
993
|
if (cwd && fromTranscript.lineCount > since) {
|
|
730
994
|
// A real slice. Advance the cursor and remember this event's signature
|
|
731
995
|
// (N17/N25) so a later empty-slice event can tell its re-fire from a
|
|
@@ -778,6 +1042,10 @@ function buildTraceEvents(data, sessionId, cwd) {
|
|
|
778
1042
|
// path otherwise bypasses).
|
|
779
1043
|
const sessionMeta = cwd ? readSessionMetaById(cwd, sessionId) : {};
|
|
780
1044
|
const phase = data.phase || sessionMeta.phase || null;
|
|
1045
|
+
// The day-scoped auto-session names no command (only a session opened by
|
|
1046
|
+
// `optimize trace init` or focus mode does); the parent transcript names it
|
|
1047
|
+
// instead (see readCommandFromTranscript).
|
|
1048
|
+
const command = data.command || sessionMeta.command || readCommandFromTranscript(data.transcript_path) || null;
|
|
781
1049
|
|
|
782
1050
|
const events = [];
|
|
783
1051
|
|
|
@@ -793,9 +1061,7 @@ function buildTraceEvents(data, sessionId, cwd) {
|
|
|
793
1061
|
description: `${agent} completed`,
|
|
794
1062
|
context: {
|
|
795
1063
|
model,
|
|
796
|
-
|
|
797
|
-
// names it instead (see readCommandFromTranscript).
|
|
798
|
-
command: data.command || sessionMeta.command || readCommandFromTranscript(data.transcript_path) || null,
|
|
1064
|
+
command,
|
|
799
1065
|
agent_id: agentId,
|
|
800
1066
|
input_tokens: inputTokens,
|
|
801
1067
|
output_tokens: outputTokens,
|
|
@@ -805,6 +1071,10 @@ function buildTraceEvents(data, sessionId, cwd) {
|
|
|
805
1071
|
exit_code: data.exit_code || 0,
|
|
806
1072
|
token_source: tokenSource,
|
|
807
1073
|
clamped,
|
|
1074
|
+
// Tool calls and failed tool results in this spawn's own transcript slice;
|
|
1075
|
+
// null whenever the slice is not the agent's own (nothing is attributable).
|
|
1076
|
+
tool_calls: failures ? failures.toolCalls : null,
|
|
1077
|
+
tool_errors: failures ? failures.toolErrors : null,
|
|
808
1078
|
// This spawn's per-invocation discriminator: the event signature,
|
|
809
1079
|
// persisted. The signature was already hashed for the seen-event marker
|
|
810
1080
|
// but never written into the event, so two parallel same-type siblings
|
|
@@ -848,6 +1118,27 @@ function buildTraceEvents(data, sessionId, cwd) {
|
|
|
848
1118
|
});
|
|
849
1119
|
}
|
|
850
1120
|
|
|
1121
|
+
// One event per distinct failure (tool, class, message), with its count. The
|
|
1122
|
+
// completion's tool_errors keeps the total when more groups exist than are written.
|
|
1123
|
+
if (failures) {
|
|
1124
|
+
for (const g of failures.groups) {
|
|
1125
|
+
events.push({
|
|
1126
|
+
v: SCHEMA_V,
|
|
1127
|
+
ts,
|
|
1128
|
+
session: sessionId,
|
|
1129
|
+
agent,
|
|
1130
|
+
phase,
|
|
1131
|
+
type: 'error',
|
|
1132
|
+
category: 'tool_error',
|
|
1133
|
+
description: `${agent}: ${g.tool} failed (${g.errorClass})${g.count > 1 ? ` x${g.count}` : ''}`,
|
|
1134
|
+
context: { tool: g.tool, error_class: g.errorClass, exit_code: g.exitCode, message: g.message, message_sig: g.messageSig, count: g.count, command, agent_id: agentId },
|
|
1135
|
+
impact: 'minor',
|
|
1136
|
+
correction: null,
|
|
1137
|
+
tokens_wasted: null,
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
851
1142
|
return events;
|
|
852
1143
|
}
|
|
853
1144
|
|
|
@@ -932,10 +1223,15 @@ if (require.main === module) {
|
|
|
932
1223
|
try {
|
|
933
1224
|
const data = JSON.parse(input);
|
|
934
1225
|
const cwd = data.cwd || data.workspace?.current_dir || process.cwd();
|
|
1226
|
+
if (deferToClaudeRegistration(cwd)) return;
|
|
935
1227
|
// M62: a global-install hook fires in every repo; skip non-PAN projects so
|
|
936
1228
|
// we don't create .planning/ optimization + trace artifacts in them. The tree
|
|
937
1229
|
// must already exist — see hasPlanningTree (field sweep 2026-09-17).
|
|
938
1230
|
if (!hasPlanningTree(cwd)) return;
|
|
1231
|
+
// A host-internal helper agent records nothing, so it must not mint the day's
|
|
1232
|
+
// session either (buildTraceEvents repeats the check for direct callers).
|
|
1233
|
+
const normalized = normalizeHookPayload(data);
|
|
1234
|
+
if (isHostInternalAgent(normalized, resolveAgentTranscript(normalized))) return;
|
|
939
1235
|
// In a PAN project, ensure a session exists — creates a day-scoped
|
|
940
1236
|
// auto-session if needed.
|
|
941
1237
|
const sessionId = ensureSessionId(cwd);
|
|
@@ -948,6 +1244,9 @@ if (require.main === module) {
|
|
|
948
1244
|
}
|
|
949
1245
|
|
|
950
1246
|
module.exports = {
|
|
1247
|
+
deferToClaudeRegistration,
|
|
1248
|
+
normalizeHookPayload,
|
|
1249
|
+
isHostInternalAgent,
|
|
951
1250
|
buildTraceEvents,
|
|
952
1251
|
appendTraceEvents,
|
|
953
1252
|
resolveAgentTranscript,
|
|
@@ -958,6 +1257,13 @@ module.exports = {
|
|
|
958
1257
|
PAN_RUNTIME_DIRS,
|
|
959
1258
|
readCommandFromTranscript,
|
|
960
1259
|
isSessionStale,
|
|
1260
|
+
readTranscriptSlice,
|
|
1261
|
+
extractToolFailures,
|
|
1262
|
+
classifyToolError,
|
|
1263
|
+
redactErrorText,
|
|
1264
|
+
errorCaptureEnabled,
|
|
1265
|
+
agentTypeFromMeta,
|
|
1266
|
+
MAX_FAILURE_EVENTS,
|
|
961
1267
|
PLANNING_DIR,
|
|
962
1268
|
OPTIMIZE_DIR,
|
|
963
1269
|
TRACES_DIR,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pan-wizard",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.32.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"
|
|
@@ -3,9 +3,9 @@
|
|
|
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
|
-
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');
|
|
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, STATE_CONTRACT } = require('./constants.cjs');
|
|
9
9
|
const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, planningRel } = require('./utils.cjs');
|
|
10
10
|
const { estimateTokens } = require('./context-budget.cjs');
|
|
11
11
|
const { collectPhaseSummaries, readErrorPatterns, appendErrorPattern, appendSessionSummary, parseLearnings, formatLearningEntry, cmdLearningsExtract, cmdLearningsList, cmdLearningsPrune } = require('./commands-learnings.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
|
|
|
@@ -682,6 +692,7 @@ function cmdProgressRender(cwd, format, raw) {
|
|
|
682
692
|
} else {
|
|
683
693
|
// JSON format
|
|
684
694
|
output({
|
|
695
|
+
contract: STATE_CONTRACT,
|
|
685
696
|
milestone_version: milestone.version,
|
|
686
697
|
milestone_name: milestone.name,
|
|
687
698
|
phases,
|
|
@@ -804,7 +815,13 @@ function cmdTodoComplete(cwd, filename, raw) {
|
|
|
804
815
|
}
|
|
805
816
|
|
|
806
817
|
const today = new Date().toISOString().split('T')[0];
|
|
807
|
-
|
|
818
|
+
// /pan:todo-add writes YAML frontmatter; the date goes inside it. Prepending it
|
|
819
|
+
// put `completed:` above the opening `---` and broke the block. A file with no
|
|
820
|
+
// frontmatter keeps the old first-line form.
|
|
821
|
+
const fm = content.match(/^---\r?\n/);
|
|
822
|
+
content = fm
|
|
823
|
+
? fm[0] + `completed: ${today}` + fm[0].slice(3) + content.slice(fm[0].length)
|
|
824
|
+
: `completed: ${today}\n` + content;
|
|
808
825
|
|
|
809
826
|
try {
|
|
810
827
|
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,
|