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
@@ -15,16 +15,44 @@
15
15
 
16
16
  const fs = require('fs');
17
17
  const path = require('path');
18
+ const crypto = require('crypto');
19
+
20
+ // Runtime config dirs a local PAN install lands in (mirrors installer getDirName).
21
+ const PAN_RUNTIME_DIRS = ['.claude', '.codex', '.gemini', '.opencode', '.github'];
22
+
23
+ // M62: only instrument actual PAN projects. A global-install hook fires in EVERY
24
+ // repo the user opens; without this gate it silently creates .planning/ metrics
25
+ // artifacts in non-PAN repos. A project counts as PAN if it already has a
26
+ // .planning/ tree (a /pan command created it) OR carries a local PAN install
27
+ // (a manifest / core payload under a runtime config dir — covers a fresh local
28
+ // install before any .planning/ exists). Global installs in a plain repo match
29
+ // neither, so the hook no-ops. Best-effort — never throws.
30
+ function isPanProject(cwd) {
31
+ try {
32
+ if (!cwd) return false;
33
+ if (fs.existsSync(path.join(cwd, '.planning'))) return true;
34
+ for (const d of PAN_RUNTIME_DIRS) {
35
+ if (fs.existsSync(path.join(cwd, d, 'pan-file-manifest.json'))) return true;
36
+ if (fs.existsSync(path.join(cwd, d, 'pan-wizard-core'))) return true;
37
+ }
38
+ return false;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
18
43
 
19
44
  const METRICS_DIR = 'metrics';
20
45
  const TOKENS_FILE = 'tokens.jsonl';
21
46
  const CURSOR_FILE = '.cost-cursor.json';
22
47
 
23
48
  // Ledger row schema version. Bump when the record shape changes so readers can
24
- // tell which shape a row was written in (pre-versioned rows read as v1). Kept as
25
- // a literal in both hooks + cost.cjs — the hooks are standalone zero-dep scripts
26
- // that can't import from pan-wizard-core, so this MUST stay in sync by hand.
27
- const SCHEMA_V = 2;
49
+ // tell which shape a row was written in (pre-versioned rows read as v1); v3 added
50
+ // the per-invocation `event_sig` discriminator. Kept as a literal in each hook —
51
+ // they are standalone zero-dep scripts that can't import from pan-wizard-core, so
52
+ // the two hooks must stay in sync by hand. No constant in pan-wizard-core mirrors
53
+ // it: the readers there take a row field by field rather than switching on its
54
+ // version, so an added field is additive for them.
55
+ const SCHEMA_V = 3;
28
56
 
29
57
  // Reverse-map a resolved model id to its cost tier so the "By tier" dashboard
30
58
  // section isn't blind on the hook path. Anthropic families only (the tiers PAN
@@ -77,10 +105,161 @@ function readCursor(cwd) {
77
105
  return c && typeof c === 'object' ? c : {};
78
106
  } catch { return {}; }
79
107
  }
108
+
109
+ // N17/N25-N27: reserved key in the cursor map recording, per transcript, the
110
+ // SIGNATURES of recently-seen SubagentStop events. An empty-slice event is a
111
+ // true re-fire / dual-registration ONLY when its full-payload signature was
112
+ // already seen for that transcript; a PARALLEL SIBLING (any payload difference)
113
+ // or a FIRST FIRE (missing/unreadable transcript, since===0) carries a new
114
+ // signature and must be recorded, not dropped. A bounded FIFO SET rather than a
115
+ // single slot (N25): a sibling recorded in between can no longer evict the
116
+ // signature that identifies an earlier event's re-fire. Bounded by COUNT, not
117
+ // by transcript existence, so the L40 dead-transcript prune can no longer erase
118
+ // the live marker of a missing-transcript first fire in the very write meant to
119
+ // persist it (N27). The name can never collide with a transcript path (an
120
+ // absolute file path).
121
+ const SEEN_EVENTS = '__seenEvents';
122
+ // Pre-N25 single-slot marker — no longer read; dropped on the next write
123
+ // (self-migrating, no reader of the old shape exists outside this hook).
124
+ const LEGACY_CONSUME_KEYS = '__consumeKeys';
125
+ // Bounds (L40): at most MAX_SEEN_TRANSCRIPTS transcripts x MAX_SEEN_SIGS
126
+ // signatures (~40 chars each) — a few KB worst-case. FIFO eviction on both axes.
127
+ // This window is the ONLY layer that recognizes a re-fire which is no longer
128
+ // ADJACENT to the row it duplicates: the ledger dedup below compares against the
129
+ // immediately preceding row and nothing further. A sibling wave wider than
130
+ // MAX_SEEN_SIGS on one transcript therefore evicts the earliest sibling's
131
+ // signature, and a late re-fire of THAT sibling is admitted as a phantom row.
132
+ // That residual is accepted deliberately, not overlooked — see eventSignature
133
+ // below for what widening either window was measured to cost.
134
+ const MAX_SEEN_SIGS = 8;
135
+ const MAX_SEEN_TRANSCRIPTS = 16;
136
+
137
+ // Signature of a SubagentStop event: a hash of the FULL payload as delivered.
138
+ // A dual-registration re-fire is byte-identical on stdin (the host pipes the
139
+ // same JSON to every registration of one event), so its signature matches. A
140
+ // parallel sibling is distinguished by ANY differing payload field, without
141
+ // hardcoding which field a given runtime provides (N26).
142
+ //
143
+ // WHICH fields actually differ on real payloads is only partly established, so
144
+ // the sibling-admission benefit is CONDITIONAL. What this repo has observed:
145
+ // • `agent_type` / `subagent_type` is supplied and does vary between siblings
146
+ // of DIFFERENT type — the trace rows recorded under
147
+ // experiments/*/.planning/optimization/traces/ were written by the sibling
148
+ // hook from real payloads and carry real agent names. Different-type
149
+ // siblings are therefore always separable.
150
+ // • `session_id` is SHARED with the parent, and so is the session transcript
151
+ // (docs/FIELD-REPORT-army-2026-06.md, root cause 1) — neither is
152
+ // per-invocation.
153
+ // • `model` and `phase` came out null in those recorded rows: the payload
154
+ // carried neither.
155
+ // • `usage` is absent entirely in headless mode (docs/HOOKS.md, P-1805).
156
+ // • No recorded payload in this repo carries an `agent_id` or any other
157
+ // per-invocation id. `grep -rn agent_id hooks/ pan-wizard-core/ tests/`
158
+ // finds only PAN's own agent-tracking artifacts (written by workflows) and
159
+ // the hook tests, which inject one as a stand-in.
160
+ // So for two CONCURRENT SAME-TYPE siblings no varying payload field is
161
+ // confirmed on any host. Where the host supplies one, both spawns are admitted;
162
+ // where it supplies none the two payloads are the same bytes, hence
163
+ // informationally indistinguishable from a re-fire, and the second stays
164
+ // suppressed.
165
+ //
166
+ // What the guards promise, stated as narrowly as they hold: a re-fire is
167
+ // suppressed while it is still RECOGNIZABLE — its signature is in this
168
+ // transcript's seen-event window (MAX_SEEN_SIGS), or it is byte-identical modulo
169
+ // `ts` to the row IMMEDIATELY PRECEDING it in the ledger. Nothing further. A
170
+ // re-fire arriving after its signature has been evicted from the marker window,
171
+ // and not adjacent to the row it duplicates, IS admitted as a phantom row. That
172
+ // is the residual; it is documented rather than engineered away.
173
+ //
174
+ // It was engineered away once, and the cure destroyed data. The ledger dedup was
175
+ // widened to scan a tail of recent rows, with a second prong matching on a
176
+ // repeated signature alone for rows carrying no tokens. Both prongs delete real
177
+ // spawns. Five genuine spawns X,Y,X,Y,X on one shared transcript — the ordinary
178
+ // shared-session topology of docs/FIELD-REPORT-army-2026-06.md, where sequential
179
+ // same-type subagents deliver byte-identical payloads and therefore one
180
+ // signature — collapsed to TWO rows; when their slices carried real usage the
181
+ // collapsed rows' token counts vanished with them. Deleting real cost data is
182
+ // strictly worse than the occasional phantom row it prevents. The invariant that
183
+ // justified the contentless prong ("an event that consumed a real transcript
184
+ // slice never looks contentless") is also false: a slice of records that carry
185
+ // no `usage` and no `timestamp` yields zero on every axis and a null duration.
186
+ // So the dedup stays adjacent-only, and this comment states the residual instead
187
+ // of an invariant the code does not hold. Pinned in
188
+ // tests/cost-logger-hook.test.cjs — `grep -n 'no genuine spawn' tests/cost-logger-hook.test.cjs`.
189
+ //
190
+ // The NO-transcript path carries a wider residual: it never consults the marker
191
+ // layer at all (there is no transcript to key it by), so an INTERLEAVED dual
192
+ // registration there — A, B, A′, B′ — puts A′ and B′ out of adjacency reach and
193
+ // both are admitted as phantom rows. Closing that needs a lookback, and a
194
+ // lookback is the window whose failure mode is the data loss above, so it stays
195
+ // open.
196
+ //
197
+ // Where the guards CAN distinguish two events (their payloads differ, so their
198
+ // signatures differ) both are recorded — that is what the row's `event_sig`
199
+ // exists for. Where they CANNOT (identical bytes), the second is suppressed.
200
+ // So the bias falls on the indistinguishable case only, and it is deliberate:
201
+ // an undercounted spawn beats a phantom row for cost reporting (N29).
202
+ //
203
+ // The cost of that bias, stated plainly because no other comment admits it:
204
+ // two SEQUENTIAL genuine spawns whose rows coincide in every field but `ts` —
205
+ // same agent, same tokens, same span — collapse to one row, and the second
206
+ // spawn's real tokens are lost. This is inherent to an adjacent whole-row
207
+ // dedup, predates `event_sig`, and is identical in the pre-`event_sig` code;
208
+ // removing the dedup instead reinstates the duplicate-row field bug it was
209
+ // added for. It is the one UNDER-count residual, distinct from the evicted-
210
+ // marker phantom (an over-count) and from N29 (concurrent same-type siblings).
211
+ //
212
+ // Returns null when the payload cannot be serialized — callers then fail OPEN
213
+ // (record, never mark).
214
+ function eventSignature(data) {
215
+ try {
216
+ return crypto.createHash('sha1').update(JSON.stringify(data)).digest('hex');
217
+ } catch { return null; }
218
+ }
219
+
220
+ function getSeenSigs(cursor, transcriptPath) {
221
+ const se = cursor && cursor[SEEN_EVENTS];
222
+ const arr = se && typeof se === 'object' ? se[transcriptPath] : null;
223
+ return Array.isArray(arr) ? arr : [];
224
+ }
225
+ function addSeenSig(cursor, transcriptPath, sig) {
226
+ if (!sig) return; // unhashable payload → never mark (fail open to recording)
227
+ if (!cursor[SEEN_EVENTS] || typeof cursor[SEEN_EVENTS] !== 'object') cursor[SEEN_EVENTS] = {};
228
+ const se = cursor[SEEN_EVENTS];
229
+ const arr = Array.isArray(se[transcriptPath]) ? se[transcriptPath].filter((s) => s !== sig) : [];
230
+ arr.push(sig);
231
+ while (arr.length > MAX_SEEN_SIGS) arr.shift(); // FIFO — evict the oldest signature
232
+ delete se[transcriptPath]; // re-insert so key order tracks recency for the transcript cap
233
+ se[transcriptPath] = arr;
234
+ const keys = Object.keys(se);
235
+ for (let i = 0; i < keys.length - MAX_SEEN_TRANSCRIPTS; i++) delete se[keys[i]];
236
+ }
237
+
80
238
  function writeCursor(cwd, cursor) {
81
239
  try {
240
+ // Prune cursor keys for transcripts that no longer exist so the map can't
241
+ // grow without bound over a long-lived project (L40, ADR audit 2026-08).
242
+ const pruned = {};
243
+ for (const [tp, v] of Object.entries(cursor)) {
244
+ if (tp === SEEN_EVENTS || tp === LEGACY_CONSUME_KEYS) continue; // reserved markers — not paths
245
+ if (tp && fs.existsSync(tp)) pruned[tp] = v;
246
+ }
247
+ // Preserve the seen-event marker (N17/N25-N27). Deliberately NOT pruned by
248
+ // transcript existence — a missing-transcript first fire's marker must
249
+ // survive this very write, or its re-fire is re-admitted as a phantom row
250
+ // (N27). Bounded by count instead (FIFO on both axes), which keeps the file
251
+ // strictly bounded per L40.
252
+ const se = cursor[SEEN_EVENTS];
253
+ if (se && typeof se === 'object') {
254
+ const bounded = {};
255
+ for (const tp of Object.keys(se).slice(-MAX_SEEN_TRANSCRIPTS)) {
256
+ const arr = se[tp];
257
+ if (Array.isArray(arr) && arr.length) bounded[tp] = arr.slice(-MAX_SEEN_SIGS);
258
+ }
259
+ if (Object.keys(bounded).length) pruned[SEEN_EVENTS] = bounded;
260
+ }
82
261
  fs.mkdirSync(path.dirname(cursorFilePath(cwd)), { recursive: true });
83
- fs.writeFileSync(cursorFilePath(cwd), JSON.stringify(cursor), 'utf-8');
262
+ fs.writeFileSync(cursorFilePath(cwd), JSON.stringify(pruned), 'utf-8');
84
263
  } catch { /* best-effort — never block the agent loop */ }
85
264
  }
86
265
 
@@ -117,6 +296,17 @@ function buildCostRecord(data, cwd) {
117
296
  // from a genuine zero-token run.
118
297
  let tokenSource = data.transcript_path ? 'transcript' : 'usage-fallback';
119
298
  let clamped = false;
299
+ // Set when a transcript-sourced event consumed no new records AND is an
300
+ // identical re-fire / dual-registration. Carried on the returned record as a
301
+ // transient flag so appendRecord can drop the phantom row; never written to
302
+ // the ledger (M61).
303
+ let emptySlice = false;
304
+ const agent = data.agent_type || data.subagent_type || null;
305
+ // This event's per-invocation identity, hashed once and used by BOTH layers:
306
+ // the seen-event marker below and the `event_sig` row field further down.
307
+ // buildCostRecord never mutates `data`, so hoisting the hash here yields the
308
+ // same value the marker calls used when they each computed it themselves.
309
+ const eventSig = eventSignature(data);
120
310
  if (data.transcript_path) {
121
311
  const cursor = readCursor(cwd);
122
312
  const since = cursor[data.transcript_path] || 0;
@@ -127,11 +317,42 @@ function buildCostRecord(data, cwd) {
127
317
  cacheWrite = fromTranscript.cache_creation_input_tokens;
128
318
  durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts);
129
319
  if (!model) model = fromTranscript.model;
130
- // Advance the cursor so the next subagent's record starts fresh — the slices
131
- // partition the transcript, so it is never re-summed on every event.
132
320
  if (fromTranscript.lineCount > since) {
321
+ // A real slice. Advance the cursor so the next subagent's record starts
322
+ // fresh — the slices partition the transcript, so it is never re-summed on
323
+ // every event. Remember this event's signature (N17/N25) so a later
324
+ // empty-slice event can tell its re-fire from a parallel sibling — even
325
+ // when other siblings are recorded in between (N25).
133
326
  cursor[data.transcript_path] = fromTranscript.lineCount;
327
+ addSeenSig(cursor, data.transcript_path, eventSig);
134
328
  writeCursor(cwd, cursor);
329
+ } else {
330
+ // No transcript records past the cursor: this event consumed NO slice of
331
+ // its own. Two very different situations land here (N17):
332
+ // • A re-fire / dual global+local hook registration — the SAME event
333
+ // delivered again (byte-identical payload). Its all-zero row is a
334
+ // phantom the ledger dedup cannot catch — the zeros differ from the
335
+ // real row the re-fire follows — so flag it and let appendRecord drop
336
+ // it (M61). This marker is the ONLY layer that catches that case; there
337
+ // is no signature-matching backstop below it, by design (see
338
+ // eventSignature).
339
+ // • A PARALLEL SIBLING (another subagent — same or different type — whose
340
+ // sibling already consumed the shared transcript to EOF and advanced
341
+ // this shared cursor) or a FIRST FIRE whose transcript_path is
342
+ // missing/unreadable (lineCount=0, since=0). These are legitimate
343
+ // spawns that must be RECORDED with zero tokens, not dropped —
344
+ // dropping them undercounts /pan:cost.
345
+ // The full-payload signature distinguishes them (N25/N26): DROP only when
346
+ // this exact payload was already seen for this transcript (true re-fire);
347
+ // otherwise record the spawn.
348
+ if (eventSig && getSeenSigs(cursor, data.transcript_path).includes(eventSig)) {
349
+ emptySlice = true; // already-seen event → re-fire; appendRecord drops the phantom row (M61)
350
+ } else {
351
+ // Sibling / first-fire: record the spawn (zero tokens) and remember its
352
+ // signature so a subsequent re-fire of THIS event is dropped.
353
+ addSeenSig(cursor, data.transcript_path, eventSig);
354
+ writeCursor(cwd, cursor);
355
+ }
135
356
  }
136
357
  } else {
137
358
  // No transcript to slice — best-effort from data.usage, plausibility-guarded
@@ -157,7 +378,7 @@ function buildCostRecord(data, cwd) {
157
378
  const record = {
158
379
  v: SCHEMA_V,
159
380
  ts: new Date().toISOString(),
160
- agent: data.agent_type || data.subagent_type || null,
381
+ agent,
161
382
  command,
162
383
  model,
163
384
  tier: tierForModel(model),
@@ -172,8 +393,29 @@ function buildCostRecord(data, cwd) {
172
393
  source: 'hook',
173
394
  token_source: tokenSource,
174
395
  clamped,
396
+ // This spawn's per-invocation discriminator: the event signature, persisted.
397
+ // The signature was already hashed for the seen-event marker but never
398
+ // written into the row, so two parallel same-type siblings produced rows
399
+ // that were byte-identical modulo `ts` and the dedup ate the second one even
400
+ // though the marker layer had correctly admitted it (N26). Persisting it
401
+ // gives both layers ONE notion of event identity: siblings differ here even
402
+ // when every other field matches, while a re-fire carries the same signature
403
+ // and still matches the row it duplicates.
404
+ // It is a field the dedup COMPARES as part of whole-row identity — never a
405
+ // key the dedup searches the ledger by. Two sequential subagents on a shared
406
+ // growing transcript share one payload and therefore one signature while
407
+ // holding different real token counts, so matching on the signature alone
408
+ // deletes genuine rows (see eventSignature). null when the payload could not
409
+ // be hashed.
410
+ event_sig: eventSig,
175
411
  };
176
412
 
413
+ // Non-enumerable transient flag: it must NOT be serialized into the ledger,
414
+ // but appendRecord needs to read it to drop a phantom re-fire row (M61).
415
+ if (emptySlice) {
416
+ Object.defineProperty(record, '__emptySlice', { value: true, enumerable: false });
417
+ }
418
+
177
419
  return record;
178
420
  }
179
421
 
@@ -264,6 +506,12 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
264
506
  */
265
507
  function appendRecord(cwd, record) {
266
508
  if (!record) return false;
509
+ // M61 re-fire guard: a transcript-sourced event that consumed no new records
510
+ // (buildCostRecord flags it via __emptySlice) is a re-fired / dual-registered
511
+ // SubagentStop with nothing of its own to attribute. Dropping it here is the
512
+ // real guard the last-row dedup could not be — the phantom all-zero row
513
+ // differs from the real row it follows, so the dedup never fired (M61).
514
+ if (record.__emptySlice) return false;
267
515
  try {
268
516
  const dir = path.join(cwd, '.planning', METRICS_DIR);
269
517
  fs.mkdirSync(dir, { recursive: true });
@@ -271,7 +519,8 @@ function appendRecord(cwd, record) {
271
519
  // Idempotency guard: a re-fired SubagentStop must not double-log. Skip the
272
520
  // append when this record is identical (every field but the timestamp) to
273
521
  // the immediately-preceding row — the source of ~57% duplicate rows in the
274
- // field (2026-07). Best-effort: any read error just proceeds with the append.
522
+ // field (2026-07). Adjacent-only on purpose; see isDuplicateOfLastRecord.
523
+ // Best-effort: any read error just proceeds with the append.
275
524
  if (isDuplicateOfLastRecord(file, record)) return false;
276
525
  fs.appendFileSync(file, JSON.stringify(record) + '\n', 'utf-8');
277
526
  return true;
@@ -280,12 +529,35 @@ function appendRecord(cwd, record) {
280
529
  }
281
530
  }
282
531
 
283
- /** True when `record` equals the last JSONL row of `file`, ignoring `ts`. */
532
+ /**
533
+ * True when `record` equals the LAST JSONL row of `file`, ignoring `ts`.
534
+ *
535
+ * Adjacent-only, and that is the design rather than an oversight. The comparison
536
+ * covers every field including the `event_sig` discriminator, so a true re-fire
537
+ * (same payload → same signature → same row) still matches the row it follows,
538
+ * while two siblings the payload can distinguish both survive (N26).
539
+ *
540
+ * It deliberately does NOT scan back over a tail of recent rows, and does not
541
+ * match on a repeated signature. Both of those were tried and both delete real
542
+ * spawns: an identity scan over a window collapses genuine repeat spawns whose
543
+ * rows coincide, and a signature prong collapses genuine spawns that share a
544
+ * payload — the ordinary shared-transcript topology. eventSignature carries the
545
+ * measured reproduction and the residual this leaves standing;
546
+ * tests/cost-logger-hook.test.cjs pins both
547
+ * (`grep -n 'no genuine spawn' tests/cost-logger-hook.test.cjs`).
548
+ *
549
+ * Rows written before the discriminator existed carry no `event_sig` and an
550
+ * older `v`, so a row written now never equals one of them. The bounded
551
+ * consequence: the first append after a schema bump can land beside a pre-bump
552
+ * row without matching it, and the guard resumes on the following same-shape
553
+ * pair. Those older rows still parse and still report — the readers in
554
+ * pan-wizard-core take a row field by field and require no particular version
555
+ * or field to be present.
556
+ */
284
557
  function isDuplicateOfLastRecord(file, record) {
285
558
  let prev;
286
559
  try {
287
- const raw = fs.readFileSync(file, 'utf-8');
288
- const lines = raw.split('\n').filter(Boolean);
560
+ const lines = fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean);
289
561
  if (!lines.length) return false;
290
562
  prev = JSON.parse(lines[lines.length - 1]);
291
563
  } catch {
@@ -308,6 +580,9 @@ if (require.main === module) {
308
580
  // fall back to process.cwd() which is the project root when Claude Code
309
581
  // invokes the hook.
310
582
  const cwd = data.cwd || data.workspace?.current_dir || process.cwd();
583
+ // M62: a global-install hook fires in every repo; don't pollute non-PAN
584
+ // projects with .planning/ metrics artifacts.
585
+ if (!isPanProject(cwd)) return;
311
586
  const record = buildCostRecord(data, cwd);
312
587
  appendRecord(cwd, record);
313
588
  } catch {
@@ -316,4 +591,4 @@ if (require.main === module) {
316
591
  });
317
592
  }
318
593
 
319
- module.exports = { buildCostRecord, appendRecord, readUsageFromTranscript, readCursor, writeCursor, METRICS_DIR, TOKENS_FILE, CURSOR_FILE };
594
+ module.exports = { buildCostRecord, appendRecord, readUsageFromTranscript, readCursor, writeCursor, isPanProject, METRICS_DIR, TOKENS_FILE, CURSOR_FILE };
@@ -44,14 +44,30 @@ function buildStatuslineOutput(data, deps) {
44
44
  // Mirrors bridgeDir() in pan-context-monitor.js (the reader).
45
45
  const uid = (typeof process.getuid === 'function' ? process.getuid() : process.env.USERNAME || 'win');
46
46
  const bridgeSubdir = pathMod.join(tmpDir, `pan-hooks-${uid}`);
47
- try { fsMod.mkdirSync(bridgeSubdir, { recursive: true, mode: 0o700 }); } catch { /* best-effort */ }
48
- const bridgePath = pathMod.join(bridgeSubdir, `claude-ctx-${session}.json`);
49
- fsMod.writeFileSync(bridgePath, JSON.stringify({
50
- session_id: session,
51
- remaining_percentage: remaining,
52
- used_pct: used,
53
- timestamp: Math.floor(Date.now() / 1000),
54
- }));
47
+ // Fail CLOSED if the per-user dir is pre-planted/symlinked/owned by
48
+ // someone else — mkdirSync{recursive} silently no-ops on an existing dir,
49
+ // so verify ownership+mode before writing the session bridge (M60).
50
+ let secure = false;
51
+ try {
52
+ fsMod.mkdirSync(bridgeSubdir, { recursive: true, mode: 0o700 });
53
+ const st = fsMod.lstatSync(bridgeSubdir);
54
+ // Symlink check is cross-platform; POSIX ownership/mode checks apply
55
+ // only where getuid exists (Windows fakes mode bits — gating on them
56
+ // there disabled the bridge entirely, N15).
57
+ secure = !st.isSymbolicLink();
58
+ if (secure && typeof process.getuid === 'function') {
59
+ secure = st.uid === process.getuid() && (st.mode & 0o077) === 0;
60
+ }
61
+ } catch { secure = false; }
62
+ if (secure) {
63
+ const bridgePath = pathMod.join(bridgeSubdir, `claude-ctx-${session}.json`);
64
+ fsMod.writeFileSync(bridgePath, JSON.stringify({
65
+ session_id: session,
66
+ remaining_percentage: remaining,
67
+ used_pct: used,
68
+ timestamp: Math.floor(Date.now() / 1000),
69
+ }));
70
+ }
55
71
  } catch { /* bridge is best-effort */ }
56
72
  }
57
73
 
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ // Auto-Advance Stop Guard - Stop hook (P-1809)
3
+ //
4
+ // Catches the P-1801/P-1807 boundary drop mechanically. Field data (PanLoop,
5
+ // 2026-08): with the gate-parity fix and prose hardening in place, autonomous
6
+ // builds still stopped at a phase boundary at a low, nondeterministic rate —
7
+ // the orchestrator finishes transition.md's state bookkeeping and ends its turn
8
+ // without issuing the Route A Task spawn. Prose cannot bind that tail reliably;
9
+ // a Stop hook can, because it runs as code when the session tries to end.
10
+ //
11
+ // When the session stops, this hook blocks ONCE — with a reason telling the
12
+ // agent to spawn the next phase — if and only if the disk shows the exact
13
+ // boundary-drop fingerprint:
14
+ // 1. .planning/config.json shows an autonomy signal: workflow.auto_advance
15
+ // === true OR mode === 'yolo'. P-1810 (PanLoop finding 7): the guard
16
+ // originally armed on auto_advance alone — narrower than the composite
17
+ // trigger it guards (flag OR config OR yolo) — and stayed dark on the
18
+ // exact base64url drop it was built for (mode yolo, auto_advance false).
19
+ // A Stop hook cannot see the --auto flag, so the chain's entry hops
20
+ // (discuss/plan/exec-phase) persist the flag into config, and yolo is
21
+ // accepted directly. transition.md Route B clears auto_advance at the
22
+ // milestone boundary, which disarms the config half at the true end.
23
+ // 2. .planning/state.md does NOT show a legitimate-stop marker (gaps found,
24
+ // verification failed, blocked). P-1812 (PanLoop finding 9): this
25
+ // condition was originally "status begins 'Ready to plan'" — one batch
26
+ // produced four different status phrasings for the same situation, and a
27
+ // run with BOTH arming conditions satisfied was disarmed by wording
28
+ // alone. The condition is now inverted: an unrecognised phrasing ARMS
29
+ // the guard (fail-safe — at worst one extra continuation, bounded by the
30
+ // one-shot design) instead of disarming it (fail-open — the build
31
+ // silently stops). Never re-introduce a required phrasing here; the
32
+ // model does not reliably write canonical wording and nothing forces it.
33
+ // 3. .planning/roadmap.md still has unticked `- [ ] **Phase N:` lines
34
+ // (the shipped template/roadmapper checklist shape — same detector the
35
+ // PanLoop harness uses to call a build incomplete)
36
+ //
37
+ // Loop safety: the host sets stop_hook_active on stop attempts that follow a
38
+ // stop-hook block, and this guard always allows those — it fires at most once
39
+ // per stop chain, so a user who genuinely wants to stop is delayed by exactly
40
+ // one continuation, never trapped.
41
+ //
42
+ // Escape hatch: set workflow.stop_guard to false in .planning/config.json to
43
+ // disable the guard entirely without turning off auto_advance.
44
+ //
45
+ // Fail-open everywhere: no .planning, unparseable config/state/roadmap, bad
46
+ // stdin — exit 0 silently. This guard must never block a stop on uncertainty;
47
+ // a missed catch is a re-run, a wrong block is a trapped session.
48
+ //
49
+ // The decision logic lives in the pure, exported buildStopDecision() so the
50
+ // full condition matrix is unit-tested (tests/stop-guard-hook.test.cjs)
51
+ // rather than only reachable via stdin (same pattern as the other PAN hooks).
52
+
53
+ const fs = require('fs');
54
+ const path = require('path');
55
+
56
+ // Unticked phase line, exactly as templates/roadmap.md and pan-roadmapper.md
57
+ // emit it. Fixture doctrine: this shape is pinned against the shipped template
58
+ // by the tests — never widen it from imagination, re-derive it from the files.
59
+ const UNTICKED_PHASE_RE = /^- \[ \] \*\*Phase (\d+(?:\.\d+)?):/m;
60
+
61
+ // Legitimate-stop markers in state.md (P-1812). Condition 2 exists only to
62
+ // let a REAL stop through — a verification failure, gaps, or a recorded
63
+ // blocker. Matching those (a short, high-precision list) is robust; matching
64
+ // the "chain should continue" wording was not: PAN wrote "Ready to plan",
65
+ // "Ready to execute", "Phase 2 planning pending" and two milestone phrasings
66
+ // for the same boundary within one field batch. Anything NOT matching this
67
+ // list arms the guard.
68
+ const LEGIT_STOP_RE = /gaps?\s+found|verification\s+(?:failed|found\s+gaps)|failed\s+verification|\bblocked\b|\bblocker\b/i;
69
+
70
+ // The stopped_at "ready to plan Phase N" phrasing, when present, names the
71
+ // next phase more precisely than the first-unticked roadmap line (a
72
+ // completed-but-unticked earlier phase would misdirect it — finding 8).
73
+ const READY_STOPPED_AT_RE = /ready to plan phase\s*(\d+(?:\.\d+)?)/i;
74
+
75
+ /**
76
+ * Pure decision: should this stop be blocked?
77
+ * @param {object} args
78
+ * @param {boolean} args.stopHookActive stop_hook_active from the payload
79
+ * @param {object|null} args.config parsed .planning/config.json, or null
80
+ * @param {string|null} args.stateContent .planning/state.md, or null
81
+ * @param {string|null} args.roadmapContent .planning/roadmap.md, or null
82
+ * @returns {{decision: 'block', reason: string}|null} null = allow the stop
83
+ */
84
+ function buildStopDecision({ stopHookActive, config, stateContent, roadmapContent }) {
85
+ if (stopHookActive) return null; // one-shot: never block a post-block stop
86
+ if (!config || typeof config !== 'object') return null;
87
+ const wf = (config.workflow && typeof config.workflow === 'object') ? config.workflow : {};
88
+ if (wf.stop_guard === false) return null; // explicit escape hatch beats every arming signal
89
+ // P-1810: arm on EITHER disk-visible autonomy signal — auto_advance alone
90
+ // was narrower than the trigger it guards and missed a real drop (finding 7).
91
+ const armed = wf.auto_advance === true || config.mode === 'yolo';
92
+ if (!armed) return null;
93
+ if (typeof stateContent !== 'string' || typeof roadmapContent !== 'string') return null;
94
+
95
+ // P-1812: inverted condition — allow the stop only when state records a
96
+ // legitimate reason to stop. Unrecognised status phrasings arm the guard.
97
+ if (LEGIT_STOP_RE.test(stateContent)) return null;
98
+
99
+ const unticked = roadmapContent.match(UNTICKED_PHASE_RE);
100
+ if (!unticked) return null; // every phase built — nothing to continue
101
+
102
+ // Prefer the phase state.md says is next: a COMPLETED phase left unticked
103
+ // (finding 8) would otherwise make the first-unticked line name a phase that
104
+ // is already built. The roadmap match still gates arming; state names the
105
+ // target when it can.
106
+ const stateNext = stateContent.match(READY_STOPPED_AT_RE);
107
+ const nextPhase = (stateNext && stateNext[1]) || unticked[1];
108
+ return {
109
+ decision: 'block',
110
+ reason:
111
+ `PAN auto-advance chain incomplete: autonomy is armed in .planning/config.json, ` +
112
+ `state.md records no failure/gaps/blocker, and roadmap.md still has unbuilt phases ` +
113
+ `(next: Phase ${nextPhase}). Ending the turn here is the P-1801/P-1807 boundary drop. ` +
114
+ `Continue the chain now: if the current phase is finished, follow transition.md's ` +
115
+ `offer_next_phase Route A and spawn Phase ${nextPhase} as a Task subagent ` +
116
+ `(plan-phase orchestrator, ARGUMENTS='${nextPhase} --auto'); if the current phase is ` +
117
+ `mid-flight, resume it instead. If the user explicitly asked to stop, first run: ` +
118
+ `pan-tools config-set workflow.auto_advance false — then stop. ` +
119
+ `(Disable this guard permanently with workflow.stop_guard: false.)`
120
+ };
121
+ }
122
+
123
+ function readIfExists(p) {
124
+ try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
125
+ }
126
+
127
+ function main() {
128
+ let input = '';
129
+ process.stdin.setEncoding('utf8');
130
+ process.stdin.on('data', chunk => { input += chunk; });
131
+ process.stdin.on('end', () => {
132
+ try {
133
+ let payload = {};
134
+ try { payload = JSON.parse(input); } catch { /* fail open on bad stdin */ }
135
+ if (!payload || typeof payload !== 'object') payload = {};
136
+
137
+ const projectDir = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
138
+ const planningDir = path.join(projectDir, '.planning');
139
+
140
+ let config = null;
141
+ try { config = JSON.parse(fs.readFileSync(path.join(planningDir, 'config.json'), 'utf8')); } catch { /* no project / bad config -> allow */ }
142
+
143
+ const decision = buildStopDecision({
144
+ stopHookActive: payload.stop_hook_active === true,
145
+ config,
146
+ stateContent: readIfExists(path.join(planningDir, 'state.md')),
147
+ roadmapContent: readIfExists(path.join(planningDir, 'roadmap.md')),
148
+ });
149
+
150
+ if (decision) process.stdout.write(JSON.stringify(decision));
151
+ } catch { /* fail open — never break a stop */ }
152
+ process.exit(0);
153
+ });
154
+ }
155
+
156
+ if (require.main === module) {
157
+ main();
158
+ }
159
+
160
+ module.exports = { buildStopDecision, UNTICKED_PHASE_RE };