pan-wizard 3.31.0 → 3.33.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.
Files changed (87) hide show
  1. package/README.md +28 -24
  2. package/agents/pan-conductor.md +2 -2
  3. package/agents/pan-design-checker.md +18 -0
  4. package/agents/pan-document_code.md +2 -2
  5. package/agents/pan-executor.md +3 -3
  6. package/agents/pan-experiment-runner.md +7 -10
  7. package/agents/pan-hardener.md +15 -15
  8. package/agents/pan-knowledge.md +1 -1
  9. package/agents/pan-meta-reviewer.md +2 -2
  10. package/agents/pan-optimizer.md +7 -3
  11. package/agents/pan-phase-researcher.md +2 -2
  12. package/agents/pan-plan-checker.md +29 -2
  13. package/agents/pan-planner.md +23 -10
  14. package/agents/pan-project-researcher.md +10 -67
  15. package/agents/pan-reviewer.md +19 -0
  16. package/agents/pan-roadmapper.md +5 -48
  17. package/agents/pan-verifier.md +49 -11
  18. package/bin/install-lib.cjs +170 -15
  19. package/bin/install.js +39 -14
  20. package/commands/pan/army.md +2 -2
  21. package/commands/pan/design-phase.md +8 -0
  22. package/commands/pan/exec-phase.md +1 -1
  23. package/commands/pan/experiment.md +2 -2
  24. package/commands/pan/focus-auto.md +12 -8
  25. package/commands/pan/focus-design.md +2 -0
  26. package/commands/pan/focus-exec.md +1 -1
  27. package/commands/pan/focus-scan.md +2 -7
  28. package/commands/pan/git.md +2 -2
  29. package/commands/pan/learn.md +2 -0
  30. package/commands/pan/map-codebase.md +4 -4
  31. package/commands/pan/mcp-bridge.md +1 -1
  32. package/commands/pan/milestone-done.md +1 -2
  33. package/commands/pan/optimize.md +14 -2
  34. package/commands/pan/phase-tests.md +2 -2
  35. package/commands/pan/profile.md +2 -2
  36. package/commands/pan/research-phase.md +2 -4
  37. package/commands/pan/review-deep.md +8 -8
  38. package/commands/pan/todo-add.md +2 -2
  39. package/commands/pan/todo-check.md +2 -2
  40. package/hooks/dist/pan-context-monitor.js +1 -1
  41. package/hooks/dist/pan-cost-logger.js +63 -11
  42. package/hooks/dist/pan-state-reinject.js +142 -17
  43. package/hooks/dist/pan-stop-guard.js +2 -2
  44. package/hooks/dist/pan-trace-logger.js +283 -18
  45. package/package.json +1 -1
  46. package/pan-wizard-core/bin/lib/agents-md.cjs +4 -1
  47. package/pan-wizard-core/bin/lib/commands.cjs +95 -2
  48. package/pan-wizard-core/bin/lib/config.cjs +110 -0
  49. package/pan-wizard-core/bin/lib/constants.cjs +39 -9
  50. package/pan-wizard-core/bin/lib/core.cjs +27 -7
  51. package/pan-wizard-core/bin/lib/cost.cjs +16 -6
  52. package/pan-wizard-core/bin/lib/findings.cjs +385 -0
  53. package/pan-wizard-core/bin/lib/focus.cjs +72 -19
  54. package/pan-wizard-core/bin/lib/foreign-planning.cjs +24 -6
  55. package/pan-wizard-core/bin/lib/hygiene.cjs +12 -2
  56. package/pan-wizard-core/bin/lib/init.cjs +9 -2
  57. package/pan-wizard-core/bin/lib/optimize.cjs +349 -21
  58. package/pan-wizard-core/bin/lib/phase.cjs +52 -2
  59. package/pan-wizard-core/bin/lib/review-deep.cjs +38 -2
  60. package/pan-wizard-core/bin/lib/state.cjs +10 -4
  61. package/pan-wizard-core/bin/lib/verdict.cjs +481 -0
  62. package/pan-wizard-core/bin/lib/verify-drift.cjs +14 -5
  63. package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -2
  64. package/pan-wizard-core/bin/lib/verify-scope.cjs +223 -0
  65. package/pan-wizard-core/bin/lib/verify.cjs +111 -8
  66. package/pan-wizard-core/bin/pan-tools.cjs +68 -6
  67. package/pan-wizard-core/learnings/README.md +10 -7
  68. package/pan-wizard-core/mcp/server.cjs +18 -9
  69. package/pan-wizard-core/mcp/tool-registry.cjs +2 -0
  70. package/pan-wizard-core/references/checkpoints.md +4 -2
  71. package/pan-wizard-core/references/verdict-contract.md +102 -0
  72. package/pan-wizard-core/templates/discovery.md +1 -1
  73. package/pan-wizard-core/templates/phase-prompt.md +2 -1
  74. package/pan-wizard-core/templates/verification-report.md +21 -0
  75. package/pan-wizard-core/workflows/exec-phase.md +73 -50
  76. package/pan-wizard-core/workflows/health.md +3 -0
  77. package/pan-wizard-core/workflows/learn.md +4 -2
  78. package/pan-wizard-core/workflows/milestone-audit.md +15 -3
  79. package/pan-wizard-core/workflows/new-project.md +1 -1
  80. package/pan-wizard-core/workflows/optimize.md +14 -1
  81. package/pan-wizard-core/workflows/plan-phase.md +20 -15
  82. package/pan-wizard-core/workflows/quick.md +6 -6
  83. package/pan-wizard-core/workflows/verify-phase.md +10 -15
  84. package/pan-zcode/README.md +3 -3
  85. package/scripts/build-agent-plugin.js +19 -5
  86. package/scripts/git-hooks/pre-commit +2 -1
  87. package/scripts/test-surface.cjs +4 -0
@@ -9,13 +9,16 @@
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');
20
23
 
21
24
  // ─── R39: one run per hook when Claude and Copilot share a project ───────────
@@ -61,8 +64,8 @@ const crypto = require('crypto');
61
64
  * Which planning tree this hook acts on.
62
65
  *
63
66
  * Mirrors pan-wizard-core/bin/lib/planning-root.cjs, which hooks cannot require
64
- * (they are standalone and run inside the host runtime). All PAN hooks carry an
65
- * 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
66
69
  * `.planning/`, that tree's telemetry lands in the wrong place.
67
70
  *
68
71
  * Env only — a hook gets no argv. A value that escapes the project root is
@@ -144,11 +147,13 @@ const TRACES_DIR = 'traces';
144
147
  const CURRENT_SESSION_FILE = 'current-session';
145
148
  const TRACE_EVENT_FILE = 'trace.jsonl';
146
149
 
147
- // Trace event schema version — kept in sync by hand with pan-cost-logger.js
148
- // (standalone zero-dep hooks can't share a module). v3 added the per-invocation
149
- // `event_sig` discriminator to the completion event's context; v4 added
150
- // `agent_id` and the `agent-transcript` token source. See that file.
151
- const SCHEMA_V = 4;
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;
152
157
 
153
158
  // YYYYMMDD stamp for a Date (the day-scope of an auto-session id).
154
159
  function dayStamp(d) {
@@ -384,10 +389,11 @@ const MAX_SEEN_TRANSCRIPTS = 16;
384
389
  // • `model` and `phase` came out null in those recorded rows — the payload
385
390
  // carried neither; `usage` is absent entirely in headless mode
386
391
  // (docs/HOOKS.md, P-1805).
387
- // • No recorded payload in this repo carries an `agent_id` or any other
388
- // per-invocation id; the hook tests inject one as a stand-in.
389
- // So for two CONCURRENT SAME-TYPE siblings no varying payload field is confirmed
390
- // on any host: where the host supplies one, both spawns are admitted; where it
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
391
397
  // supplies none the payloads are the same bytes, hence indistinguishable from a
392
398
  // re-fire, and the second stays suppressed.
393
399
  //
@@ -435,10 +441,13 @@ const MAX_SEEN_TRANSCRIPTS = 16;
435
441
  // reinstates the duplicate-record bug. It is the one UNDER-count residual —
436
442
  // distinct from the evicted-marker phantom (over-count) and from N29.
437
443
  //
438
- // 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).
439
448
  function eventSignature(data) {
440
449
  try {
441
- return crypto.createHash('sha1').update(JSON.stringify(data)).digest('hex');
450
+ return crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 40);
442
451
  } catch { return null; }
443
452
  }
444
453
 
@@ -591,6 +600,27 @@ function resolveAgentTranscript(data) {
591
600
  }
592
601
  }
593
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
+
594
624
  // Slice ceilings — a sum over one subagent's conversation, so above a single
595
625
  // call's PLAUSIBLE_MAX; cache_read and output mirror cost.cjs isSuspectRecord's
596
626
  // absolute limits, input is a hook-only sanity ceiling (same values as
@@ -697,6 +727,191 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
697
727
  return totals;
698
728
  }
699
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
+
700
915
  /**
701
916
  * Build trace event(s) from a SubagentStop payload.
702
917
  *
@@ -713,10 +928,11 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
713
928
  */
714
929
  function buildTraceEvents(data, sessionId, cwd) {
715
930
  if (!data || typeof data !== 'object') return [];
931
+ data = normalizeHookPayload(data);
716
932
  if (data.hook_event_name && data.hook_event_name !== 'SubagentStop') return [];
717
933
 
718
934
  const ts = new Date().toISOString();
719
- const agent = data.agent_type || data.subagent_type || 'unknown';
935
+ let agent = data.agent_type || data.subagent_type || 'unknown';
720
936
  // This event's per-invocation identity, hashed once and used by BOTH layers:
721
937
  // the seen-event marker below and the completion's `event_sig` context field.
722
938
  // buildTraceEvents never mutates `data`, so hoisting the hash here yields the
@@ -737,6 +953,9 @@ function buildTraceEvents(data, sessionId, cwd) {
737
953
  // `transcript` for a slice of the shared parent transcript (the fallback when
738
954
  // the host names no agent), `usage-fallback` for the payload's own counters.
739
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;
740
959
  const parentPath = typeof data.transcript_path === 'string' && data.transcript_path ? data.transcript_path : null;
741
960
  // A named agent whose file is not there consumes nothing (see pan-cost-logger:
742
961
  // the parent slice would be the whole session booked to one spawn).
@@ -749,6 +968,7 @@ function buildTraceEvents(data, sessionId, cwd) {
749
968
  : sliceSource ? 'transcript' : 'usage-fallback';
750
969
  const agentId = typeof data.agent_id === 'string' && data.agent_id ? data.agent_id : null;
751
970
  let clamped = false;
971
+ let failures = null;
752
972
  if (sliceSource || agentFileMissing) {
753
973
  const keyPath = sliceSource || parentPath;
754
974
  const cursor = readTraceCursor(cwd);
@@ -765,6 +985,11 @@ function buildTraceEvents(data, sessionId, cwd) {
765
985
  clamped = rawIn > SLICE_MAX.input || rawOut > SLICE_MAX.output || rawCr > SLICE_MAX.cache_read;
766
986
  durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts); // as measured
767
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
+ }
768
993
  if (cwd && fromTranscript.lineCount > since) {
769
994
  // A real slice. Advance the cursor and remember this event's signature
770
995
  // (N17/N25) so a later empty-slice event can tell its re-fire from a
@@ -817,6 +1042,10 @@ function buildTraceEvents(data, sessionId, cwd) {
817
1042
  // path otherwise bypasses).
818
1043
  const sessionMeta = cwd ? readSessionMetaById(cwd, sessionId) : {};
819
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;
820
1049
 
821
1050
  const events = [];
822
1051
 
@@ -832,9 +1061,7 @@ function buildTraceEvents(data, sessionId, cwd) {
832
1061
  description: `${agent} completed`,
833
1062
  context: {
834
1063
  model,
835
- // Outside focus mode the trace session carries no command; the parent transcript
836
- // names it instead (see readCommandFromTranscript).
837
- command: data.command || sessionMeta.command || readCommandFromTranscript(data.transcript_path) || null,
1064
+ command,
838
1065
  agent_id: agentId,
839
1066
  input_tokens: inputTokens,
840
1067
  output_tokens: outputTokens,
@@ -844,6 +1071,10 @@ function buildTraceEvents(data, sessionId, cwd) {
844
1071
  exit_code: data.exit_code || 0,
845
1072
  token_source: tokenSource,
846
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,
847
1078
  // This spawn's per-invocation discriminator: the event signature,
848
1079
  // persisted. The signature was already hashed for the seen-event marker
849
1080
  // but never written into the event, so two parallel same-type siblings
@@ -887,6 +1118,27 @@ function buildTraceEvents(data, sessionId, cwd) {
887
1118
  });
888
1119
  }
889
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
+
890
1142
  return events;
891
1143
  }
892
1144
 
@@ -976,6 +1228,10 @@ if (require.main === module) {
976
1228
  // we don't create .planning/ optimization + trace artifacts in them. The tree
977
1229
  // must already exist — see hasPlanningTree (field sweep 2026-09-17).
978
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;
979
1235
  // In a PAN project, ensure a session exists — creates a day-scoped
980
1236
  // auto-session if needed.
981
1237
  const sessionId = ensureSessionId(cwd);
@@ -989,6 +1245,8 @@ if (require.main === module) {
989
1245
 
990
1246
  module.exports = {
991
1247
  deferToClaudeRegistration,
1248
+ normalizeHookPayload,
1249
+ isHostInternalAgent,
992
1250
  buildTraceEvents,
993
1251
  appendTraceEvents,
994
1252
  resolveAgentTranscript,
@@ -999,6 +1257,13 @@ module.exports = {
999
1257
  PAN_RUNTIME_DIRS,
1000
1258
  readCommandFromTranscript,
1001
1259
  isSessionStale,
1260
+ readTranscriptSlice,
1261
+ extractToolFailures,
1262
+ classifyToolError,
1263
+ redactErrorText,
1264
+ errorCaptureEnabled,
1265
+ agentTypeFromMeta,
1266
+ MAX_FAILURE_EVENTS,
1002
1267
  PLANNING_DIR,
1003
1268
  OPTIMIZE_DIR,
1004
1269
  TRACES_DIR,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pan-wizard",
3
- "version": "3.31.0",
3
+ "version": "3.33.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"
@@ -29,7 +29,10 @@ function buildAgentsMdSection() {
29
29
  'This project uses PAN Wizard for structured, phase-based planning and execution.',
30
30
  '',
31
31
  '- `.planning/` is PAN\'s state directory (state.md, roadmap.md, phase directories). Treat it as the source of truth for planning state and modify it through PAN commands, not by hand.',
32
- '- PAN commands install as `pan-*` skills/commands (for example `/pan-help`, `/pan-new-project`, `/pan-exec-phase`). Start with `/pan-help`.',
32
+ // One section serves every runtime in the project, so it names each one's form
33
+ // (the README's list); `/pan-help` alone sent Claude Code users to a command
34
+ // that does not exist there (prompt audit, market-ideas M34).
35
+ '- PAN\'s commands are named per runtime: `/pan:help`, `/pan:new-project`, `/pan:exec-phase` in Claude Code and Gemini CLI; `/pan-help` and so on in OpenCode, Copilot CLI and unified-skills Claude installs; `$pan-help` in Codex. Start with the help command.',
33
36
  '- The `pan-tools` dispatcher backs every command; it lives under `pan-wizard-core/` inside the runtime\'s config directory (or `.agents/` for unified installs).',
34
37
  PAN_AGENTS_END,
35
38
  ].join('\n');
@@ -5,11 +5,13 @@ const fs = require('fs');
5
5
  const path = require('path');
6
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');
12
12
  const { planningRootRel } = require('./planning-root.cjs');
13
+ const { verificationFreshness } = require('./verify-scope.cjs');
14
+ const { isVerificationFile } = require('./constants.cjs');
13
15
 
14
16
  /**
15
17
  * Generate a URL-safe slug from text by lowercasing and replacing non-alphanumeric chars.
@@ -631,6 +633,7 @@ function cmdProgressRender(cwd, format, raw) {
631
633
  const milestone = getMilestoneInfo(cwd);
632
634
 
633
635
  const phases = [];
636
+ const verifiedPhases = []; // indexes into phases[] of phases with a verification file
634
637
  let totalPlans = 0;
635
638
  let totalSummaries = 0;
636
639
 
@@ -661,12 +664,25 @@ function cmdProgressRender(cwd, format, raw) {
661
664
  else if (summaryCount > 0) status = 'In Progress';
662
665
  else status = 'Planned';
663
666
 
667
+ if (phaseFiles.some(isVerificationFile)) verifiedPhases.push(phases.length);
664
668
  phases.push({ number: phaseNum, name: phaseName, plans: planCount, summaries: summaryCount, status });
665
669
  }
666
670
  } catch {
667
671
  // Phases directory does not exist or is unreadable; return empty progress
668
672
  }
669
673
 
674
+ // A verified phase whose covered files changed since the verified commit is
675
+ // stale (market-ideas M27). Not for the bar or the health score: the statusline
676
+ // calls the bar, which never shows it, and each check costs a git call.
677
+ if (format !== 'bar' && format !== 'health') {
678
+ for (const i of verifiedPhases) {
679
+ const f = verificationFreshness(cwd, phases[i].number);
680
+ if (f.error) continue;
681
+ phases[i].verification = f.state;
682
+ if (f.state === 'stale') phases[i].changed_since_verification = f.changed_since;
683
+ }
684
+ }
685
+
670
686
  const percent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
671
687
 
672
688
  if (format === 'health') {
@@ -682,7 +698,7 @@ function cmdProgressRender(cwd, format, raw) {
682
698
  rendered += '| Phase | Name | Plans | Status |\n';
683
699
  rendered += '|-------|------|-------|--------|\n';
684
700
  for (const phase of phases) {
685
- rendered += `| ${phase.number} | ${phase.name} | ${phase.summaries}/${phase.plans} | ${phase.status} |\n`;
701
+ rendered += `| ${phase.number} | ${phase.name} | ${phase.summaries}/${phase.plans} | ${phase.status}${phase.verification === 'stale' ? ' (verification stale)' : ''} |\n`;
686
702
  }
687
703
  output({ rendered }, raw, rendered);
688
704
  } else if (format === 'bar') {
@@ -692,6 +708,7 @@ function cmdProgressRender(cwd, format, raw) {
692
708
  } else {
693
709
  // JSON format
694
710
  output({
711
+ contract: STATE_CONTRACT,
695
712
  milestone_version: milestone.version,
696
713
  milestone_name: milestone.name,
697
714
  phases,
@@ -1026,9 +1043,85 @@ function shouldSkipTests(files) {
1026
1043
  return files.every(f => /\.md$/i.test(f));
1027
1044
  }
1028
1045
 
1046
+ // ─── version (market-ideas M29) ──────────────────────────────────────────────
1047
+ // The version of the PAN core that is running, where it lives, and, with --check,
1048
+ // the update state the SessionStart update check last recorded. Reads only local
1049
+ // files: the check itself (`npm view`, up to 10 s) stays in the hook's background
1050
+ // child, so `version --check` never waits on the network and never throws offline.
1051
+
1052
+ // Where each install directory's update-check hook writes its cache: the hook's
1053
+ // `join(homeDir, '.claude', 'cache')` is rewritten to the runtime's GLOBAL config dir
1054
+ // (getConfigDirFromHome(runtime, true) in the installer).
1055
+ const UPDATE_CACHE_HOME_DIR = {
1056
+ '.claude': ['.claude'], '.codex': ['.codex'], '.gemini': ['.gemini'],
1057
+ '.opencode': ['.config', 'opencode'], opencode: ['.config', 'opencode'],
1058
+ '.github': ['.copilot'], '.copilot': ['.copilot'],
1059
+ };
1060
+
1061
+ /** The running core's version, its directory, and the install directory holding it. */
1062
+ function panVersionInfo(coreDir = path.resolve(__dirname, '..', '..')) {
1063
+ let version = null;
1064
+ // VERSION is written by the installer; a source checkout has the package.json instead.
1065
+ try { version = fs.readFileSync(path.join(coreDir, 'VERSION'), 'utf8').trim() || null; } catch { /* not an install */ }
1066
+ if (!version) {
1067
+ try { version = JSON.parse(fs.readFileSync(path.join(coreDir, '..', 'package.json'), 'utf8')).version || null; } catch { /* neither */ }
1068
+ }
1069
+ return { version, core: toPosix(coreDir), install_dir: path.basename(path.dirname(coreDir)) };
1070
+ }
1071
+
1072
+ /** The update-check cache for this install's runtime, else the newest one any runtime wrote. */
1073
+ function readUpdateCache(homeDir, installDir) {
1074
+ const read = (segments) => {
1075
+ const file = path.join(homeDir, ...segments, 'cache', 'pan-update-check.json');
1076
+ try { return { file: toPosix(file), data: JSON.parse(fs.readFileSync(file, 'utf8')) }; } catch { return null; }
1077
+ };
1078
+ const own = UPDATE_CACHE_HOME_DIR[installDir] ? read(UPDATE_CACHE_HOME_DIR[installDir]) : null;
1079
+ if (own) return own;
1080
+ const seen = new Set();
1081
+ const all = [];
1082
+ for (const segs of Object.values(UPDATE_CACHE_HOME_DIR)) {
1083
+ const key = segs.join('/');
1084
+ if (seen.has(key)) continue;
1085
+ seen.add(key);
1086
+ const c = read(segs);
1087
+ if (c) all.push(c);
1088
+ }
1089
+ all.sort((a, b) => (Number(b.data.checked) || 0) - (Number(a.data.checked) || 0));
1090
+ return all[0] || null;
1091
+ }
1092
+
1093
+ /**
1094
+ * The update state from a cache record: `unchecked` (no check has run), `offline`
1095
+ * (the last check could not reach npm), `available`, `current`, or `ahead` (this
1096
+ * core is newer than npm's latest, e.g. a prerelease or a source checkout).
1097
+ */
1098
+ function updateState(version, cache) {
1099
+ if (!cache) return { state: 'unchecked', latest: null, checked_at: null, cache: null };
1100
+ const checkedAt = Number(cache.data.checked) ? new Date(Number(cache.data.checked) * 1000).toISOString() : null;
1101
+ const latest = cache.data.latest && cache.data.latest !== 'unknown' ? String(cache.data.latest) : null;
1102
+ if (!latest) return { state: 'offline', latest: null, checked_at: checkedAt, cache: cache.file };
1103
+ const { compareVersions } = require('./hygiene.cjs');
1104
+ const cmp = version ? compareVersions(version, latest) : -1;
1105
+ return { state: cmp < 0 ? 'available' : cmp > 0 ? 'ahead' : 'current', latest, checked_at: checkedAt, cache: cache.file };
1106
+ }
1107
+
1108
+ function cmdVersion(_cwd, opts, raw) {
1109
+ const info = panVersionInfo();
1110
+ const result = { version: info.version, core: info.core, install_dir: info.install_dir, node: process.version };
1111
+ if (opts.check) result.update = updateState(info.version, readUpdateCache(require('os').homedir(), info.install_dir));
1112
+ const text = opts.check
1113
+ ? `${info.version || 'unknown'} (${result.update.state}${result.update.latest ? `, npm latest ${result.update.latest}` : ''})`
1114
+ : String(info.version || 'unknown');
1115
+ output(result, raw, text);
1116
+ }
1117
+
1029
1118
  // ---- Error patterns, session history, learnings — extracted to commands-learnings.cjs (re-exported below)
1030
1119
 
1031
1120
  module.exports = {
1121
+ cmdVersion,
1122
+ panVersionInfo,
1123
+ readUpdateCache,
1124
+ updateState,
1032
1125
  cmdGenerateSlug,
1033
1126
  cmdCurrentTimestamp,
1034
1127
  cmdListTodos,