pan-wizard 3.21.1 → 3.24.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 (136) hide show
  1. package/README.md +38 -24
  2. package/agents/pan-conductor.md +3 -3
  3. package/agents/pan-design-checker.md +83 -0
  4. package/agents/pan-designer.md +67 -0
  5. package/agents/pan-document_code.md +3 -2
  6. package/agents/pan-executor.md +2 -2
  7. package/agents/pan-plan-checker.md +19 -6
  8. package/agents/pan-planner.md +4 -1
  9. package/agents/pan-previewer.md +1 -1
  10. package/agents/pan-roadmapper.md +3 -1
  11. package/agents/pan-verifier.md +10 -10
  12. package/bin/install-lib.cjs +128 -17
  13. package/bin/install.js +480 -74
  14. package/commands/pan/army.md +20 -14
  15. package/commands/pan/audit-deployment.md +17 -14
  16. package/commands/pan/cost.md +2 -2
  17. package/commands/pan/debug.md +1 -1
  18. package/commands/pan/design-phase.md +77 -0
  19. package/commands/pan/exec-phase.md +2 -2
  20. package/commands/pan/experiment.md +18 -14
  21. package/commands/pan/focus-auto.md +5 -5
  22. package/commands/pan/focus-design.md +13 -1
  23. package/commands/pan/focus-exec.md +1 -1
  24. package/commands/pan/learn.md +1 -1
  25. package/commands/pan/links.md +3 -1
  26. package/commands/pan/map-codebase.md +4 -4
  27. package/commands/pan/mcp-bridge.md +1 -1
  28. package/commands/pan/milestone-new.md +3 -1
  29. package/commands/pan/optimize.md +2 -2
  30. package/commands/pan/patches.md +1 -1
  31. package/commands/pan/plan-phase.md +3 -1
  32. package/commands/pan/preview.md +3 -3
  33. package/commands/pan/profile.md +3 -3
  34. package/commands/pan/research-phase.md +1 -1
  35. package/commands/pan/retro.md +4 -1
  36. package/commands/pan/review-deep.md +1 -1
  37. package/commands/pan/settings.md +2 -2
  38. package/commands/pan/what-if.md +1 -1
  39. package/hooks/dist/pan-check-update.js +149 -38
  40. package/hooks/dist/pan-context-monitor.js +155 -81
  41. package/hooks/dist/pan-cost-logger.js +288 -13
  42. package/hooks/dist/pan-statusline.js +24 -8
  43. package/hooks/dist/pan-stop-guard.js +160 -0
  44. package/hooks/dist/pan-trace-logger.js +261 -19
  45. package/package.json +2 -2
  46. package/pan-wizard-core/bin/lib/bridge.cjs +1 -1
  47. package/pan-wizard-core/bin/lib/bus.cjs +19 -4
  48. package/pan-wizard-core/bin/lib/campaign.cjs +8 -3
  49. package/pan-wizard-core/bin/lib/codebase.cjs +15 -5
  50. package/pan-wizard-core/bin/lib/commands.cjs +88 -6
  51. package/pan-wizard-core/bin/lib/config.cjs +27 -2
  52. package/pan-wizard-core/bin/lib/constants.cjs +1 -0
  53. package/pan-wizard-core/bin/lib/core.cjs +113 -8
  54. package/pan-wizard-core/bin/lib/cost.cjs +61 -24
  55. package/pan-wizard-core/bin/lib/distill.cjs +22 -4
  56. package/pan-wizard-core/bin/lib/doc-lint.cjs +25 -7
  57. package/pan-wizard-core/bin/lib/experiment.cjs +25 -1
  58. package/pan-wizard-core/bin/lib/focus.cjs +42 -7
  59. package/pan-wizard-core/bin/lib/frontmatter.cjs +62 -31
  60. package/pan-wizard-core/bin/lib/git.cjs +51 -16
  61. package/pan-wizard-core/bin/lib/hud.cjs +17 -2
  62. package/pan-wizard-core/bin/lib/init.cjs +2 -5
  63. package/pan-wizard-core/bin/lib/knowledge.cjs +20 -1
  64. package/pan-wizard-core/bin/lib/learn-lint.cjs +50 -0
  65. package/pan-wizard-core/bin/lib/links.cjs +9 -6
  66. package/pan-wizard-core/bin/lib/lock.cjs +23 -4
  67. package/pan-wizard-core/bin/lib/memory-optimize.cjs +101 -17
  68. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +26 -2
  69. package/pan-wizard-core/bin/lib/milestone.cjs +11 -5
  70. package/pan-wizard-core/bin/lib/optimize.cjs +18 -7
  71. package/pan-wizard-core/bin/lib/phase-remove.cjs +41 -2
  72. package/pan-wizard-core/bin/lib/phase.cjs +82 -11
  73. package/pan-wizard-core/bin/lib/preview.cjs +23 -4
  74. package/pan-wizard-core/bin/lib/review-deep.cjs +14 -6
  75. package/pan-wizard-core/bin/lib/roadmap.cjs +6 -2
  76. package/pan-wizard-core/bin/lib/runner.cjs +17 -7
  77. package/pan-wizard-core/bin/lib/squads.cjs +43 -11
  78. package/pan-wizard-core/bin/lib/state.cjs +32 -11
  79. package/pan-wizard-core/bin/lib/template.cjs +8 -3
  80. package/pan-wizard-core/bin/lib/verify-deploy.cjs +14 -2
  81. package/pan-wizard-core/bin/lib/verify-drift.cjs +15 -1
  82. package/pan-wizard-core/bin/lib/verify-preflight.cjs +9 -3
  83. package/pan-wizard-core/bin/lib/verify.cjs +84 -39
  84. package/pan-wizard-core/bin/lib/whatif.cjs +8 -1
  85. package/pan-wizard-core/bin/pan-tools.cjs +42 -19
  86. package/pan-wizard-core/learnings/index.json +7 -7
  87. package/pan-wizard-core/learnings/universal/autonomous-loop.md +5 -3
  88. package/pan-wizard-core/learnings/universal/concurrency.md +1 -1
  89. package/pan-wizard-core/references/design-methodology.md +94 -0
  90. package/pan-wizard-core/references/git-integration.md +9 -9
  91. package/pan-wizard-core/references/guardrails.md +4 -1
  92. package/pan-wizard-core/references/model-profile-resolution.md +1 -1
  93. package/pan-wizard-core/references/model-profiles.md +44 -34
  94. package/pan-wizard-core/references/planning-config.md +5 -5
  95. package/pan-wizard-core/references/verification-patterns.md +67 -0
  96. package/pan-wizard-core/templates/codebase/architecture.md +1 -1
  97. package/pan-wizard-core/templates/codebase/concerns.md +1 -1
  98. package/pan-wizard-core/templates/codebase/conventions.md +1 -1
  99. package/pan-wizard-core/templates/codebase/integrations.md +1 -1
  100. package/pan-wizard-core/templates/codebase/stack.md +1 -1
  101. package/pan-wizard-core/templates/codebase/structure.md +1 -1
  102. package/pan-wizard-core/templates/codebase/testing.md +1 -1
  103. package/pan-wizard-core/templates/design.md +146 -0
  104. package/pan-wizard-core/templates/discovery.md +3 -3
  105. package/pan-wizard-core/templates/milestone-archive.md +2 -2
  106. package/pan-wizard-core/templates/playbook.md +1 -1
  107. package/pan-wizard-core/templates/uat.md +1 -1
  108. package/pan-wizard-core/workflows/diagnose-issues.md +9 -7
  109. package/pan-wizard-core/workflows/exec-phase.md +28 -8
  110. package/pan-wizard-core/workflows/execute-plan.md +2 -3
  111. package/pan-wizard-core/workflows/health.md +24 -1
  112. package/pan-wizard-core/workflows/help.md +5 -4
  113. package/pan-wizard-core/workflows/learn.md +5 -5
  114. package/pan-wizard-core/workflows/map-codebase.md +1 -1
  115. package/pan-wizard-core/workflows/milestone-audit.md +1 -1
  116. package/pan-wizard-core/workflows/milestone-new.md +4 -4
  117. package/pan-wizard-core/workflows/new-project.md +47 -32
  118. package/pan-wizard-core/workflows/optimize.md +8 -8
  119. package/pan-wizard-core/workflows/pause.md +1 -1
  120. package/pan-wizard-core/workflows/phase-tests.md +2 -2
  121. package/pan-wizard-core/workflows/plan-phase.md +4 -0
  122. package/pan-wizard-core/workflows/profile.md +13 -10
  123. package/pan-wizard-core/workflows/research-phase.md +1 -1
  124. package/pan-wizard-core/workflows/resume-project.md +1 -1
  125. package/pan-wizard-core/workflows/settings.md +9 -9
  126. package/pan-wizard-core/workflows/transition.md +24 -8
  127. package/pan-wizard-core/workflows/update.md +23 -20
  128. package/pan-wizard-core/workflows/verify-phase.md +7 -2
  129. package/pan-zcode/KNOWN-BETA-RISKS.md +8 -0
  130. package/pan-zcode/README.md +14 -2
  131. package/pan-zcode/bin/install-zcode.js +1 -1
  132. package/pan-zcode/mcp/native-tools.cjs +1 -1
  133. package/pan-zcode/mcp/server.cjs +76 -14
  134. package/scripts/build-hooks.js +2 -1
  135. package/scripts/generate-skills-docs.py +0 -4
  136. package/scripts/run-tests.cjs +11 -4
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // pan-trace-logger — SubagentStop hook (v3.5+).
3
3
  //
4
- // Fires alongside pan-cost-logger on every SubagentStop event. If a trace
5
- // session is active (.planning/optimization/current-session exists), this
6
- // hook appends a completion event to the trace. This is the automatic
7
- // instrumentation layer of the circular optimization loop — no extra user
8
- // action required.
4
+ // Fires alongside pan-cost-logger on every SubagentStop event. In a PAN project
5
+ // (see isPanProject — M62) it ensures a day-scoped trace session exists and
6
+ // appends a completion event to it. This is the automatic instrumentation layer
7
+ // of the circular optimization loop — no extra user action required. Outside a
8
+ // PAN project the hook no-ops, so a global install does not create .planning/
9
+ // trace artifacts in every repo the user opens.
9
10
  //
10
11
  // Events logged per subagent:
11
12
  // - completion: agent finished, tokens used, exit status
@@ -16,6 +17,31 @@
16
17
 
17
18
  const fs = require('fs');
18
19
  const path = require('path');
20
+ const crypto = require('crypto');
21
+
22
+ // Runtime config dirs a local PAN install lands in (mirrors installer getDirName).
23
+ const PAN_RUNTIME_DIRS = ['.claude', '.codex', '.gemini', '.opencode', '.github'];
24
+
25
+ // M62: only instrument actual PAN projects. A global-install hook fires in EVERY
26
+ // repo the user opens; without this gate it silently creates .planning/
27
+ // optimization + trace artifacts in non-PAN repos. A project counts as PAN if it
28
+ // already has a .planning/ tree (a /pan command created it) OR carries a local
29
+ // PAN install (a manifest / core payload under a runtime config dir — covers a
30
+ // fresh local install before any .planning/ exists). Global installs in a plain
31
+ // repo match neither, so the hook no-ops. Best-effort — never throws.
32
+ function isPanProject(cwd) {
33
+ try {
34
+ if (!cwd) return false;
35
+ if (fs.existsSync(path.join(cwd, '.planning'))) return true;
36
+ for (const d of PAN_RUNTIME_DIRS) {
37
+ if (fs.existsSync(path.join(cwd, d, 'pan-file-manifest.json'))) return true;
38
+ if (fs.existsSync(path.join(cwd, d, 'pan-wizard-core'))) return true;
39
+ }
40
+ return false;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
19
45
 
20
46
  const PLANNING_DIR = '.planning';
21
47
  const OPTIMIZE_DIR = 'optimization';
@@ -23,9 +49,10 @@ const TRACES_DIR = 'traces';
23
49
  const CURRENT_SESSION_FILE = 'current-session';
24
50
  const TRACE_EVENT_FILE = 'trace.jsonl';
25
51
 
26
- // Trace event schema version — kept in sync by hand with pan-cost-logger.js +
27
- // cost.cjs (standalone zero-dep hooks can't share a module). See that file.
28
- const SCHEMA_V = 2;
52
+ // Trace event schema version — kept in sync by hand with pan-cost-logger.js
53
+ // (standalone zero-dep hooks can't share a module). v3 added the per-invocation
54
+ // `event_sig` discriminator to the completion event's context. See that file.
55
+ const SCHEMA_V = 3;
29
56
 
30
57
  // YYYYMMDD stamp for a Date (the day-scope of an auto-session id).
31
58
  function dayStamp(d) {
@@ -114,9 +141,145 @@ function readTraceCursor(cwd) {
114
141
  try { const c = JSON.parse(fs.readFileSync(traceCursorPath(cwd), 'utf-8')); return c && typeof c === 'object' ? c : {}; }
115
142
  catch { return {}; }
116
143
  }
144
+
145
+ // N17/N25-N27: reserved key in the cursor map recording, per transcript, the
146
+ // SIGNATURES of recently-seen SubagentStop events. Mirrors pan-cost-logger —
147
+ // an empty-slice event is a true re-fire / dual registration ONLY when its
148
+ // full-payload signature was already seen for that transcript; a parallel
149
+ // sibling (any payload difference) or a first-fire (missing/unreadable
150
+ // transcript) carries a new signature and must be recorded, not dropped.
151
+ // Bounded FIFO SET rather than a single slot (N25) so an interleaved sibling
152
+ // can't evict the signature identifying an earlier event's re-fire; bounded by
153
+ // COUNT, not transcript existence, so the L40 prune can't erase the live marker
154
+ // of a missing-transcript first fire (N27). See pan-cost-logger.js for the full
155
+ // design rationale (the two hooks are standalone and must stay in sync by hand).
156
+ const SEEN_EVENTS = '__seenEvents';
157
+ // Pre-N25 single-slot marker — no longer read; dropped on the next write.
158
+ const LEGACY_CONSUME_KEYS = '__consumeKeys';
159
+ // This window is the ONLY layer that recognizes a re-fire which is no longer the
160
+ // LATEST completion in the trace file: the dedup below compares against that one
161
+ // completion and nothing further. A sibling wave wider than this bound on one
162
+ // transcript therefore evicts the earliest sibling's signature, and a late
163
+ // re-fire of THAT sibling is emitted as a phantom completion. That residual is
164
+ // accepted deliberately, not overlooked — see eventSignature below for what
165
+ // widening either window was measured to cost.
166
+ const MAX_SEEN_SIGS = 8;
167
+ const MAX_SEEN_TRANSCRIPTS = 16;
168
+
169
+ // Full-payload hash: byte-identical for a dual-registration re-fire, different
170
+ // for a sibling whose payload differs in ANY field, with no hardcoding of which
171
+ // field a given runtime provides (N26).
172
+ //
173
+ // WHICH fields actually differ on real payloads is only partly established, so
174
+ // the sibling-admission benefit is CONDITIONAL. What this repo has observed:
175
+ // • `agent_type` / `subagent_type` is supplied and does vary between siblings
176
+ // of DIFFERENT type — the trace rows recorded under
177
+ // experiments/*/.planning/optimization/traces/ were written by THIS hook
178
+ // from real payloads and carry real agent names.
179
+ // • `session_id` is SHARED with the parent, and so is the session transcript
180
+ // (docs/FIELD-REPORT-army-2026-06.md, root cause 1).
181
+ // • `model` and `phase` came out null in those recorded rows — the payload
182
+ // carried neither; `usage` is absent entirely in headless mode
183
+ // (docs/HOOKS.md, P-1805).
184
+ // • No recorded payload in this repo carries an `agent_id` or any other
185
+ // per-invocation id; the hook tests inject one as a stand-in.
186
+ // So for two CONCURRENT SAME-TYPE siblings no varying payload field is confirmed
187
+ // on any host: where the host supplies one, both spawns are admitted; where it
188
+ // supplies none the payloads are the same bytes, hence indistinguishable from a
189
+ // re-fire, and the second stays suppressed.
190
+ //
191
+ // What the guards promise, stated as narrowly as they hold: a re-fire is
192
+ // suppressed while it is still RECOGNIZABLE — its signature is in this
193
+ // transcript's seen-event window (MAX_SEEN_SIGS), or it is byte-identical modulo
194
+ // `ts` to the LATEST completion already in the trace file. Nothing further. A
195
+ // re-fire arriving after its signature has been evicted from the marker window,
196
+ // and no longer adjacent to the completion it duplicates, IS emitted as a phantom
197
+ // completion. That is the residual; it is documented rather than engineered away.
198
+ //
199
+ // It was engineered away once, and the cure destroyed data. The dedup was widened
200
+ // to scan a tail of recent completions, with a second prong matching on a
201
+ // repeated signature alone for completions carrying no tokens. Both prongs delete
202
+ // real spawns. Five genuine spawns X,Y,X,Y,X on one shared transcript — the
203
+ // ordinary shared-session topology of docs/FIELD-REPORT-army-2026-06.md, where
204
+ // sequential same-type subagents deliver byte-identical payloads and therefore
205
+ // one signature — collapsed to TWO completions; when their slices carried real
206
+ // usage the collapsed completions' token counts vanished with them. Deleting real
207
+ // telemetry is strictly worse than the occasional phantom completion it prevents.
208
+ // The invariant that justified the contentless prong ("an event that consumed a
209
+ // real transcript slice never looks contentless") is also false: a slice of
210
+ // records that carry no `usage` and no `timestamp` yields zero on every axis and
211
+ // a null duration. So the dedup stays adjacent-only, and this comment states the
212
+ // residual instead of an invariant the code does not hold. Pinned in
213
+ // tests/trace-logger.test.cjs — `grep -n 'no genuine spawn' tests/trace-logger.test.cjs`.
214
+ //
215
+ // The NO-transcript path carries a wider residual: it never consults the marker
216
+ // layer at all (there is no transcript to key it by), so an INTERLEAVED dual
217
+ // registration there — A, B, A′, B′ — puts A′ and B′ out of adjacency reach and
218
+ // both are emitted as phantom completions. Closing that needs a lookback, and a
219
+ // lookback is the window whose failure mode is the data loss above, so it stays
220
+ // open.
221
+ //
222
+ // Where the guards CAN distinguish two events (payloads differ, so signatures
223
+ // differ) both are emitted — that is what the completion's `event_sig` is for.
224
+ // Where they CANNOT (identical bytes), the second is suppressed. The bias falls
225
+ // on the indistinguishable case only, deliberately: an undercount beats a
226
+ // phantom completion (N29).
227
+ //
228
+ // Its cost, stated because no other comment admits it: two SEQUENTIAL genuine
229
+ // spawns whose completions coincide in every field but `ts` collapse to one,
230
+ // losing the second's real counts. Inherent to an adjacent whole-record dedup,
231
+ // identical in the pre-`event_sig` code, and the alternative (no dedup)
232
+ // reinstates the duplicate-record bug. It is the one UNDER-count residual —
233
+ // distinct from the evicted-marker phantom (over-count) and from N29.
234
+ //
235
+ // Null when unserializable → fail open.
236
+ function eventSignature(data) {
237
+ try {
238
+ return crypto.createHash('sha1').update(JSON.stringify(data)).digest('hex');
239
+ } catch { return null; }
240
+ }
241
+
242
+ function getSeenSigs(cursor, transcriptPath) {
243
+ const se = cursor && cursor[SEEN_EVENTS];
244
+ const arr = se && typeof se === 'object' ? se[transcriptPath] : null;
245
+ return Array.isArray(arr) ? arr : [];
246
+ }
247
+ function addSeenSig(cursor, transcriptPath, sig) {
248
+ if (!sig) return; // unhashable payload → never mark (fail open to recording)
249
+ if (!cursor[SEEN_EVENTS] || typeof cursor[SEEN_EVENTS] !== 'object') cursor[SEEN_EVENTS] = {};
250
+ const se = cursor[SEEN_EVENTS];
251
+ const arr = Array.isArray(se[transcriptPath]) ? se[transcriptPath].filter((s) => s !== sig) : [];
252
+ arr.push(sig);
253
+ while (arr.length > MAX_SEEN_SIGS) arr.shift(); // FIFO — evict the oldest signature
254
+ delete se[transcriptPath]; // re-insert so key order tracks recency for the transcript cap
255
+ se[transcriptPath] = arr;
256
+ const keys = Object.keys(se);
257
+ for (let i = 0; i < keys.length - MAX_SEEN_TRANSCRIPTS; i++) delete se[keys[i]];
258
+ }
259
+
117
260
  function writeTraceCursor(cwd, cursor) {
118
- try { fs.mkdirSync(path.dirname(traceCursorPath(cwd)), { recursive: true }); fs.writeFileSync(traceCursorPath(cwd), JSON.stringify(cursor), 'utf-8'); }
119
- catch { /* best-effort — never block the agent loop */ }
261
+ try {
262
+ // Prune dead-transcript keys so the cursor map stays bounded (L40, ADR audit 2026-08).
263
+ const pruned = {};
264
+ for (const [tp, v] of Object.entries(cursor)) {
265
+ if (tp === SEEN_EVENTS || tp === LEGACY_CONSUME_KEYS) continue; // reserved markers — not paths
266
+ if (tp && fs.existsSync(tp)) pruned[tp] = v;
267
+ }
268
+ // Preserve the seen-event marker (N17/N25-N27). Deliberately NOT pruned by
269
+ // transcript existence — a missing-transcript first fire's marker must
270
+ // survive this very write (N27); bounded by count instead (L40).
271
+ const se = cursor[SEEN_EVENTS];
272
+ if (se && typeof se === 'object') {
273
+ const bounded = {};
274
+ for (const tp of Object.keys(se).slice(-MAX_SEEN_TRANSCRIPTS)) {
275
+ const arr = se[tp];
276
+ if (Array.isArray(arr) && arr.length) bounded[tp] = arr.slice(-MAX_SEEN_SIGS);
277
+ }
278
+ if (Object.keys(bounded).length) pruned[SEEN_EVENTS] = bounded;
279
+ }
280
+ fs.mkdirSync(path.dirname(traceCursorPath(cwd)), { recursive: true });
281
+ fs.writeFileSync(traceCursorPath(cwd), JSON.stringify(pruned), 'utf-8');
282
+ } catch { /* best-effort — never block the agent loop */ }
120
283
  }
121
284
 
122
285
  /**
@@ -275,6 +438,11 @@ function buildTraceEvents(data, sessionId, cwd) {
275
438
 
276
439
  const ts = new Date().toISOString();
277
440
  const agent = data.agent_type || data.subagent_type || 'unknown';
441
+ // This event's per-invocation identity, hashed once and used by BOTH layers:
442
+ // the seen-event marker below and the completion's `event_sig` context field.
443
+ // buildTraceEvents never mutates `data`, so hoisting the hash here yields the
444
+ // same value the marker calls used when they each computed it themselves.
445
+ const eventSig = eventSignature(data);
278
446
 
279
447
  // Per-call tokens come from the transcript SLICE. The SubagentStop `data.usage`,
280
448
  // when present, is a CUMULATIVE session counter — not this subagent's delta — so
@@ -298,7 +466,35 @@ function buildTraceEvents(data, sessionId, cwd) {
298
466
  durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts);
299
467
  if (!model) model = fromTranscript.model;
300
468
  if (cwd && fromTranscript.lineCount > since) {
469
+ // A real slice. Advance the cursor and remember this event's signature
470
+ // (N17/N25) so a later empty-slice event can tell its re-fire from a
471
+ // parallel sibling — even when other siblings are recorded in between (N25).
301
472
  cursor[data.transcript_path] = fromTranscript.lineCount;
473
+ addSeenSig(cursor, data.transcript_path, eventSig);
474
+ writeTraceCursor(cwd, cursor);
475
+ } else if (cwd && fromTranscript.lineCount <= since) {
476
+ // No transcript records past the cursor: this event consumed NO slice of its
477
+ // own. Two situations land here (N17):
478
+ // • A re-fire / dual global+local hook registration — the SAME event
479
+ // delivered again (byte-identical payload). Its all-zero completion row
480
+ // is a phantom the dedup cannot catch (zeros differ from the real row
481
+ // it follows), so emit nothing (M61). This marker is the ONLY layer
482
+ // that catches that case; there is no signature-matching backstop below
483
+ // it, by design (see eventSignature).
484
+ // • A PARALLEL SIBLING (another subagent — same or different type — whose
485
+ // sibling already consumed the shared transcript to EOF and advanced
486
+ // this shared cursor) or a FIRST FIRE with a missing/unreadable
487
+ // transcript. These are legitimate spawns that must be RECORDED (zero
488
+ // tokens), not dropped.
489
+ // The full-payload signature distinguishes them (N25/N26): emit nothing
490
+ // ONLY when this exact payload was already seen for this transcript;
491
+ // otherwise fall through and emit the completion.
492
+ if (eventSig && getSeenSigs(cursor, data.transcript_path).includes(eventSig)) {
493
+ return []; // already-seen event → re-fire; emit nothing (M61)
494
+ }
495
+ // Sibling / first-fire: remember this event's signature so its own re-fire
496
+ // is subsequently dropped, then fall through to emit the completion.
497
+ addSeenSig(cursor, data.transcript_path, eventSig);
302
498
  writeTraceCursor(cwd, cursor);
303
499
  }
304
500
  } else {
@@ -341,6 +537,22 @@ function buildTraceEvents(data, sessionId, cwd) {
341
537
  exit_code: data.exit_code || 0,
342
538
  token_source: tokenSource,
343
539
  clamped,
540
+ // This spawn's per-invocation discriminator: the event signature,
541
+ // persisted. The signature was already hashed for the seen-event marker
542
+ // but never written into the event, so two parallel same-type siblings
543
+ // produced completions byte-identical modulo `ts` and the dedup dropped the
544
+ // second one even though the marker layer had correctly admitted it (N26).
545
+ // Persisting it gives both layers ONE notion of event identity: siblings
546
+ // differ here even when every other field matches, while a re-fire carries
547
+ // the same signature and still matches the completion it duplicates.
548
+ // It is a field the dedup COMPARES as part of whole-event identity — never
549
+ // a key the dedup searches the trace file by. Two sequential subagents on a
550
+ // shared growing transcript share one payload and therefore one signature
551
+ // while holding different real token counts, so matching on the signature
552
+ // alone deletes genuine completions (see eventSignature). Mirrors
553
+ // pan-cost-logger's ledger-row field of the same name. null when the
554
+ // payload could not be hashed.
555
+ event_sig: eventSig,
344
556
  },
345
557
  impact: 'trivial',
346
558
  correction: null,
@@ -383,10 +595,12 @@ function appendTraceEvents(cwd, events, sessionId) {
383
595
  const sessionDir = path.join(getTracesDir(cwd), sessionId);
384
596
  fs.mkdirSync(sessionDir, { recursive: true });
385
597
  const file = path.join(sessionDir, TRACE_EVENT_FILE);
386
- // Idempotency guard: a re-fired SubagentStop must not double-log. If this
387
- // batch's completion event duplicates the last agent_completion already in
388
- // the file (every field but ts), skip the whole batch — the source of the
389
- // ~57% duplicate completion rows in the field (2026-07).
598
+ // Idempotency guard: a re-fired SubagentStop must not double-log — the
599
+ // source of the ~57% duplicate completion rows in the field (2026-07). If
600
+ // this batch's completion event duplicates the LAST agent_completion already
601
+ // in the file (every field but `ts`), skip the whole batch — its redundancy
602
+ // event, if any, is derived from the same numbers. Adjacent-only on purpose;
603
+ // see isDuplicateCompletion.
390
604
  const completion = events.find(e => e && e.category === 'agent_completion');
391
605
  if (completion && isDuplicateCompletion(file, completion)) return false;
392
606
  const lines = events.map(e => JSON.stringify(e)).join('\n') + '\n';
@@ -397,14 +611,37 @@ function appendTraceEvents(cwd, events, sessionId) {
397
611
  }
398
612
  }
399
613
 
400
- /** True when `completion` matches the file's last agent_completion row, ignoring ts. */
614
+ /**
615
+ * True when `completion` matches the file's LAST agent_completion, ignoring `ts`.
616
+ *
617
+ * Adjacent-only, and that is the design rather than an oversight. The comparison
618
+ * covers every field including the `event_sig` discriminator in `context`, so a
619
+ * true re-fire (same payload → same signature → same completion) still matches
620
+ * the one it follows, while two siblings the payload can distinguish both survive
621
+ * (N26).
622
+ *
623
+ * It deliberately does NOT scan back over a tail of recent completions, and does
624
+ * not match on a repeated signature. Both of those were tried and both delete
625
+ * real spawns: an identity scan over a window collapses genuine repeat spawns
626
+ * whose completions coincide, and a signature prong collapses genuine spawns that
627
+ * share a payload — the ordinary shared-transcript topology. eventSignature
628
+ * carries the measured reproduction and the residual this leaves standing;
629
+ * tests/trace-logger.test.cjs pins both
630
+ * (`grep -n 'no genuine spawn' tests/trace-logger.test.cjs`).
631
+ *
632
+ * Completions written before the discriminator existed hold no `event_sig` and an
633
+ * older `v`, so one written now never equals them. The bounded consequence: the
634
+ * first append after a schema bump can land beside a pre-bump completion without
635
+ * matching it, and the guard resumes on the following same-shape pair. Those
636
+ * older events still parse and still analyse — optimize.cjs reads a trace event
637
+ * field by field and requires no particular version or field to be present.
638
+ */
401
639
  function isDuplicateCompletion(file, completion) {
402
640
  let last;
403
641
  try {
404
- const raw = fs.readFileSync(file, 'utf-8');
405
- for (const line of raw.split('\n')) {
642
+ for (const line of fs.readFileSync(file, 'utf-8').split('\n')) {
406
643
  if (!line) continue;
407
- let e; try { e = JSON.parse(line); } catch { continue; }
644
+ let e; try { e = JSON.parse(line); } catch { continue; } // skip malformed rows
408
645
  if (e && e.category === 'agent_completion') last = e;
409
646
  }
410
647
  } catch {
@@ -425,7 +662,11 @@ if (require.main === module) {
425
662
  try {
426
663
  const data = JSON.parse(input);
427
664
  const cwd = data.cwd || data.workspace?.current_dir || process.cwd();
428
- // Always ensure a session exists — creates a day-scoped auto-session if needed
665
+ // M62: a global-install hook fires in every repo; skip non-PAN projects so
666
+ // we don't create .planning/ optimization + trace artifacts in them.
667
+ if (!isPanProject(cwd)) return;
668
+ // In a PAN project, ensure a session exists — creates a day-scoped
669
+ // auto-session if needed.
429
670
  const sessionId = ensureSessionId(cwd);
430
671
  const events = buildTraceEvents(data, sessionId, cwd);
431
672
  appendTraceEvents(cwd, events, sessionId);
@@ -440,6 +681,7 @@ module.exports = {
440
681
  appendTraceEvents,
441
682
  getCurrentSessionId,
442
683
  ensureSessionId,
684
+ isPanProject,
443
685
  PLANNING_DIR,
444
686
  OPTIMIZE_DIR,
445
687
  TRACES_DIR,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pan-wizard",
3
- "version": "3.21.1",
3
+ "version": "3.24.0",
4
4
  "description": "Command a bot army for your codebase: an Opus 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"
@@ -68,7 +68,7 @@
68
68
  "test:all": "node scripts/run-tests.cjs tests tests/scenarios",
69
69
  "test:e2e": "node scripts/run-tests.cjs tests/scenarios",
70
70
  "test:vscode": "npx playwright test --config tests/e2e/playwright.config.mjs",
71
- "test:watch": "node --test --watch tests/*.test.cjs",
71
+ "test:watch": "node scripts/run-tests.cjs --watch tests tests/scenarios",
72
72
  "build:plugin": "node scripts/build-plugin.js"
73
73
  }
74
74
  }
@@ -216,7 +216,7 @@ function recommendForPhase(cwd, phaseNum, opts) {
216
216
 
217
217
  return {
218
218
  phase: String(phaseNum),
219
- phase_name: phaseInfo.name || null,
219
+ phase_name: phaseInfo.phase_name || null,
220
220
  runtime: cache.runtime,
221
221
  total_candidates: tools.length,
222
222
  recommendations: scored.map(t => ({
@@ -11,7 +11,8 @@
11
11
  * Each line: {ts, source, payload}
12
12
  *
13
13
  * Channels are created on first publish. Readers use cursor-based drain
14
- * (read N lines from an offset) or consume-all drain (read + truncate).
14
+ * (read N lines from an offset) or partial-consume drain (read the drained
15
+ * window, splice just those lines out, and write the remainder back).
15
16
  *
16
17
  * Concurrent-write safety: each publish opens the file with append flag
17
18
  * (`a`) which the OS treats atomically for writes <PIPE_BUF on POSIX and
@@ -135,11 +136,15 @@ function readChannel(cwd, channel, opts) {
135
136
  }
136
137
 
137
138
  /**
138
- * Drain (read + optionally truncate) messages from a channel.
139
+ * Drain (read + optionally mutate) messages from a channel.
139
140
  *
140
141
  * Three drain modes:
141
142
  * - `peek` (default): read entries, leave file untouched
142
- * - `consume`: read entries, truncate file to zero bytes
143
+ * - `consume`: read entries, then splice out ONLY the drained window
144
+ * (offset .. offset+consumed) and write the remainder back — messages
145
+ * outside that window (before the offset, or beyond the read limit / the
146
+ * 1000-message default on large channels) survive. The file is emptied only
147
+ * when the drained window covers every line.
143
148
  * - `archive`: read entries, rename file to `<channel>-<ts>.archive.jsonl` so
144
149
  * historical data is preserved while the channel restarts empty
145
150
  *
@@ -158,7 +163,17 @@ function drain(cwd, channel, opts) {
158
163
  const file = channelFile(cwd, channel);
159
164
  if (mode === 'consume') {
160
165
  try {
161
- fs.writeFileSync(file, '', 'utf-8');
166
+ // Consume ONLY the drained window (offset .. offset+limit). Truncating the
167
+ // whole file to empty silently destroyed every message outside that window
168
+ // — anything before the offset, or beyond the read limit / the 1000-message
169
+ // default on large channels (H2, ADR audit 2026-08). Splice out the consumed
170
+ // lines and write the remainder back.
171
+ let allLines = [];
172
+ try { allLines = fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean); } catch { allLines = []; }
173
+ const offset = Math.max(0, Number(opts?.offset) || 0);
174
+ const consumed = read.entries.length; // raw lines actually returned by readChannel
175
+ const remaining = allLines.slice(0, offset).concat(allLines.slice(offset + consumed));
176
+ fs.writeFileSync(file, remaining.length ? remaining.join('\n') + '\n' : '', 'utf-8');
162
177
  } catch (e) {
163
178
  return { ...read, mode, drain_error: e.message };
164
179
  }
@@ -152,7 +152,11 @@ function isDreamDue(schedule, now) {
152
152
  if (!schedule.last_run) return false;
153
153
  const last = new Date(schedule.last_run);
154
154
  if (isNaN(last)) return false;
155
- return !sameUtcDay(last, at) || (schedule.history || []).length > 0;
155
+ // Due only once the last run rolled over into a NEW calendar day AND there is
156
+ // recorded activity to reflect on. The old `||` made this permanently true
157
+ // after the first run (history is never empty once a run is recorded), which
158
+ // contradicted the "once per calendar day that had activity" contract.
159
+ return !sameUtcDay(last, at) && (schedule.history || []).length > 0;
156
160
  }
157
161
 
158
162
  // ─── CLI ─────────────────────────────────────────────────────────────────────
@@ -190,8 +194,9 @@ function cmdCampaignStatus(cwd, raw) {
190
194
  function cmdCampaignDue(cwd, raw) {
191
195
  const schedule = readSchedule(cwd);
192
196
  const d = isRunDue(schedule, new Date());
193
- // exit-coded so a host scheduler can gate: 0 = due, 1 = not due
194
- output({ due: d.due, reason: d.reason, next_due: d.next_due }, raw, d.due ? 'due' : `not due (${d.reason})`);
197
+ // exit-coded so a host scheduler can gate: 0 = due, 1 = not due (M9 — output()
198
+ // used to hard-exit 0, so the documented gate always fired the run).
199
+ output({ due: d.due, reason: d.reason, next_due: d.next_due }, raw, d.due ? 'due' : `not due (${d.reason})`, d.due ? 0 : 1);
195
200
  }
196
201
 
197
202
  module.exports = {
@@ -98,8 +98,20 @@ function detectLanguages(cwd) {
98
98
  } catch { /* manifest not found */ }
99
99
  }
100
100
 
101
- // TypeScript subsumes javascript if tsconfig.json exists
102
- if (files_by_language.typescript && files_by_language.javascript) {
101
+ // TypeScript subsumes javascript only in a genuine TS project: there must be
102
+ // actual .ts source files AND a tsconfig.json. A bare tsconfig.json (common for
103
+ // editor type-checking of plain JS) or a manifest-promoted empty typescript array
104
+ // must NOT relabel a JS codebase as TypeScript.
105
+ let hasTsconfig = false;
106
+ try {
107
+ fs.accessSync(path.join(cwd, 'tsconfig.json'));
108
+ hasTsconfig = true;
109
+ } catch { /* no tsconfig */ }
110
+ if (
111
+ hasTsconfig &&
112
+ files_by_language.typescript && files_by_language.typescript.length > 0 &&
113
+ files_by_language.javascript && files_by_language.javascript.length > 0
114
+ ) {
103
115
  // Merge JS files under TypeScript project
104
116
  files_by_language.typescript = files_by_language.typescript.concat(files_by_language.javascript);
105
117
  delete files_by_language.javascript;
@@ -546,7 +558,6 @@ function detectErrorHandling(samples) {
546
558
  }
547
559
 
548
560
  function detectTestingPractices(cwd, sourceFileCount) {
549
- const patterns = ['*.test.*', '*.spec.*', '__tests__/**'];
550
561
  let testFileCount = 0;
551
562
 
552
563
  try {
@@ -690,9 +701,8 @@ function cmdDetectLanguages(cwd, raw) {
690
701
  * CLI: Analyze imports and build dependency graph.
691
702
  * @param {string} cwd - Project root
692
703
  * @param {boolean} raw - Raw output flag
693
- * @param {string[]} args - Additional arguments (--files f1,f2)
694
704
  */
695
- function cmdAnalyzeImports(cwd, raw, args) {
705
+ function cmdAnalyzeImports(cwd, raw) {
696
706
  const graph = buildDependencyGraph(cwd);
697
707
  const circularDeps = findCircularDeps(graph);
698
708
  const entryPoints = findEntryPoints(graph);