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.
- package/README.md +38 -24
- package/agents/pan-conductor.md +3 -3
- package/agents/pan-design-checker.md +83 -0
- package/agents/pan-designer.md +67 -0
- package/agents/pan-document_code.md +3 -2
- package/agents/pan-executor.md +2 -2
- package/agents/pan-plan-checker.md +19 -6
- package/agents/pan-planner.md +4 -1
- package/agents/pan-previewer.md +1 -1
- package/agents/pan-roadmapper.md +3 -1
- package/agents/pan-verifier.md +10 -10
- package/bin/install-lib.cjs +128 -17
- package/bin/install.js +480 -74
- package/commands/pan/army.md +20 -14
- package/commands/pan/audit-deployment.md +17 -14
- package/commands/pan/cost.md +2 -2
- package/commands/pan/debug.md +1 -1
- package/commands/pan/design-phase.md +77 -0
- package/commands/pan/exec-phase.md +2 -2
- package/commands/pan/experiment.md +18 -14
- package/commands/pan/focus-auto.md +5 -5
- package/commands/pan/focus-design.md +13 -1
- package/commands/pan/focus-exec.md +1 -1
- package/commands/pan/learn.md +1 -1
- package/commands/pan/links.md +3 -1
- package/commands/pan/map-codebase.md +4 -4
- package/commands/pan/mcp-bridge.md +1 -1
- package/commands/pan/milestone-new.md +3 -1
- package/commands/pan/optimize.md +2 -2
- package/commands/pan/patches.md +1 -1
- package/commands/pan/plan-phase.md +3 -1
- package/commands/pan/preview.md +3 -3
- package/commands/pan/profile.md +3 -3
- package/commands/pan/research-phase.md +1 -1
- package/commands/pan/retro.md +4 -1
- package/commands/pan/review-deep.md +1 -1
- package/commands/pan/settings.md +2 -2
- package/commands/pan/what-if.md +1 -1
- package/hooks/dist/pan-check-update.js +149 -38
- package/hooks/dist/pan-context-monitor.js +155 -81
- package/hooks/dist/pan-cost-logger.js +288 -13
- package/hooks/dist/pan-statusline.js +24 -8
- package/hooks/dist/pan-stop-guard.js +160 -0
- package/hooks/dist/pan-trace-logger.js +261 -19
- package/package.json +2 -2
- package/pan-wizard-core/bin/lib/bridge.cjs +1 -1
- package/pan-wizard-core/bin/lib/bus.cjs +19 -4
- package/pan-wizard-core/bin/lib/campaign.cjs +8 -3
- package/pan-wizard-core/bin/lib/codebase.cjs +15 -5
- package/pan-wizard-core/bin/lib/commands.cjs +88 -6
- package/pan-wizard-core/bin/lib/config.cjs +27 -2
- package/pan-wizard-core/bin/lib/constants.cjs +1 -0
- package/pan-wizard-core/bin/lib/core.cjs +113 -8
- package/pan-wizard-core/bin/lib/cost.cjs +61 -24
- package/pan-wizard-core/bin/lib/distill.cjs +22 -4
- package/pan-wizard-core/bin/lib/doc-lint.cjs +25 -7
- package/pan-wizard-core/bin/lib/experiment.cjs +25 -1
- package/pan-wizard-core/bin/lib/focus.cjs +42 -7
- package/pan-wizard-core/bin/lib/frontmatter.cjs +62 -31
- package/pan-wizard-core/bin/lib/git.cjs +51 -16
- package/pan-wizard-core/bin/lib/hud.cjs +17 -2
- package/pan-wizard-core/bin/lib/init.cjs +2 -5
- package/pan-wizard-core/bin/lib/knowledge.cjs +20 -1
- package/pan-wizard-core/bin/lib/learn-lint.cjs +50 -0
- package/pan-wizard-core/bin/lib/links.cjs +9 -6
- package/pan-wizard-core/bin/lib/lock.cjs +23 -4
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +101 -17
- package/pan-wizard-core/bin/lib/memory-rebuild.cjs +26 -2
- package/pan-wizard-core/bin/lib/milestone.cjs +11 -5
- package/pan-wizard-core/bin/lib/optimize.cjs +18 -7
- package/pan-wizard-core/bin/lib/phase-remove.cjs +41 -2
- package/pan-wizard-core/bin/lib/phase.cjs +82 -11
- package/pan-wizard-core/bin/lib/preview.cjs +23 -4
- package/pan-wizard-core/bin/lib/review-deep.cjs +14 -6
- package/pan-wizard-core/bin/lib/roadmap.cjs +6 -2
- package/pan-wizard-core/bin/lib/runner.cjs +17 -7
- package/pan-wizard-core/bin/lib/squads.cjs +43 -11
- package/pan-wizard-core/bin/lib/state.cjs +32 -11
- package/pan-wizard-core/bin/lib/template.cjs +8 -3
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +14 -2
- package/pan-wizard-core/bin/lib/verify-drift.cjs +15 -1
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +9 -3
- package/pan-wizard-core/bin/lib/verify.cjs +84 -39
- package/pan-wizard-core/bin/lib/whatif.cjs +8 -1
- package/pan-wizard-core/bin/pan-tools.cjs +42 -19
- package/pan-wizard-core/learnings/index.json +7 -7
- package/pan-wizard-core/learnings/universal/autonomous-loop.md +5 -3
- package/pan-wizard-core/learnings/universal/concurrency.md +1 -1
- package/pan-wizard-core/references/design-methodology.md +94 -0
- package/pan-wizard-core/references/git-integration.md +9 -9
- package/pan-wizard-core/references/guardrails.md +4 -1
- package/pan-wizard-core/references/model-profile-resolution.md +1 -1
- package/pan-wizard-core/references/model-profiles.md +44 -34
- package/pan-wizard-core/references/planning-config.md +5 -5
- package/pan-wizard-core/references/verification-patterns.md +67 -0
- package/pan-wizard-core/templates/codebase/architecture.md +1 -1
- package/pan-wizard-core/templates/codebase/concerns.md +1 -1
- package/pan-wizard-core/templates/codebase/conventions.md +1 -1
- package/pan-wizard-core/templates/codebase/integrations.md +1 -1
- package/pan-wizard-core/templates/codebase/stack.md +1 -1
- package/pan-wizard-core/templates/codebase/structure.md +1 -1
- package/pan-wizard-core/templates/codebase/testing.md +1 -1
- package/pan-wizard-core/templates/design.md +146 -0
- package/pan-wizard-core/templates/discovery.md +3 -3
- package/pan-wizard-core/templates/milestone-archive.md +2 -2
- package/pan-wizard-core/templates/playbook.md +1 -1
- package/pan-wizard-core/templates/uat.md +1 -1
- package/pan-wizard-core/workflows/diagnose-issues.md +9 -7
- package/pan-wizard-core/workflows/exec-phase.md +28 -8
- package/pan-wizard-core/workflows/execute-plan.md +2 -3
- package/pan-wizard-core/workflows/health.md +24 -1
- package/pan-wizard-core/workflows/help.md +5 -4
- package/pan-wizard-core/workflows/learn.md +5 -5
- package/pan-wizard-core/workflows/map-codebase.md +1 -1
- package/pan-wizard-core/workflows/milestone-audit.md +1 -1
- package/pan-wizard-core/workflows/milestone-new.md +4 -4
- package/pan-wizard-core/workflows/new-project.md +47 -32
- package/pan-wizard-core/workflows/optimize.md +8 -8
- package/pan-wizard-core/workflows/pause.md +1 -1
- package/pan-wizard-core/workflows/phase-tests.md +2 -2
- package/pan-wizard-core/workflows/plan-phase.md +4 -0
- package/pan-wizard-core/workflows/profile.md +13 -10
- package/pan-wizard-core/workflows/research-phase.md +1 -1
- package/pan-wizard-core/workflows/resume-project.md +1 -1
- package/pan-wizard-core/workflows/settings.md +9 -9
- package/pan-wizard-core/workflows/transition.md +24 -8
- package/pan-wizard-core/workflows/update.md +23 -20
- package/pan-wizard-core/workflows/verify-phase.md +7 -2
- package/pan-zcode/KNOWN-BETA-RISKS.md +8 -0
- package/pan-zcode/README.md +14 -2
- package/pan-zcode/bin/install-zcode.js +1 -1
- package/pan-zcode/mcp/native-tools.cjs +1 -1
- package/pan-zcode/mcp/server.cjs +76 -14
- package/scripts/build-hooks.js +2 -1
- package/scripts/generate-skills-docs.py +0 -4
- 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)
|
|
25
|
-
//
|
|
26
|
-
// that can't import from pan-wizard-core, so
|
|
27
|
-
|
|
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(
|
|
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
|
|
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).
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 };
|