pan-wizard 3.27.0 → 3.29.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 (40) hide show
  1. package/README.md +48 -48
  2. package/agents/pan-previewer.md +1 -1
  3. package/bin/install-lib.cjs +580 -18
  4. package/bin/install.js +25 -44
  5. package/commands/pan/army.md +1 -1
  6. package/commands/pan/cost.md +14 -2
  7. package/commands/pan/preview.md +2 -2
  8. package/hooks/dist/pan-check-update.js +4 -0
  9. package/hooks/dist/pan-cost-logger.js +322 -43
  10. package/hooks/dist/pan-trace-logger.js +275 -32
  11. package/package.json +8 -2
  12. package/pan-wizard-core/bin/lib/commands.cjs +3 -1
  13. package/pan-wizard-core/bin/lib/constants.cjs +39 -0
  14. package/pan-wizard-core/bin/lib/context-budget.cjs +80 -0
  15. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +511 -0
  16. package/pan-wizard-core/bin/lib/cost.cjs +174 -18
  17. package/pan-wizard-core/bin/lib/foreign-planning.cjs +56 -0
  18. package/pan-wizard-core/bin/lib/git.cjs +5 -1
  19. package/pan-wizard-core/bin/lib/hud.cjs +5 -3
  20. package/pan-wizard-core/bin/lib/hygiene.cjs +52 -24
  21. package/pan-wizard-core/bin/lib/init.cjs +8 -0
  22. package/pan-wizard-core/bin/lib/memory.cjs +14 -8
  23. package/pan-wizard-core/bin/lib/optimize.cjs +78 -2
  24. package/pan-wizard-core/bin/lib/utils.cjs +22 -0
  25. package/pan-wizard-core/bin/lib/verify.cjs +46 -12
  26. package/pan-wizard-core/bin/pan-tools.cjs +8 -1
  27. package/pan-wizard-core/mcp/server.cjs +92 -8
  28. package/pan-wizard-core/mcp/tool-registry.cjs +50 -3
  29. package/pan-wizard-core/references/model-profiles.md +2 -2
  30. package/pan-wizard-core/workflows/health.md +2 -0
  31. package/pan-zcode/README.md +1 -1
  32. package/scripts/build-agent-plugin.js +220 -0
  33. package/scripts/build-plugin.js +48 -3
  34. package/scripts/coverage-gate.cjs +257 -0
  35. package/scripts/generate-skills-docs.py +1 -1
  36. package/scripts/install-git-hooks.js +5 -0
  37. package/scripts/mutation-probe.cjs +272 -0
  38. package/scripts/release-check.js +80 -13
  39. package/scripts/test-quality-lint.cjs +240 -0
  40. package/scripts/test-surface.cjs +335 -0
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * Mutation probe — does the suite NOTICE when the code is wrong?
5
+ *
6
+ * Coverage says a line executed. It does not say an assertion depended on it. This probe
7
+ * answers the harder question by breaking the code on purpose: apply one small mutation,
8
+ * run the tests that claim to cover that file, and see whether anything turns red. A
9
+ * mutation the suite does not catch is a **survivor** — a line that runs during the tests
10
+ * and that no assertion actually constrains.
11
+ *
12
+ * Phase 6 of docs/specs/testing-system-redesign-2026-09.md. That phase proposed Stryker as
13
+ * a devDependency; this is the same idea without one, for a specific reason: nearly every
14
+ * test here asserts through a SPAWNED `pan-tools` process, so a mutation-testing framework
15
+ * built around in-process instrumentation would have to re-run whole subprocess suites per
16
+ * mutant anyway. What it would add over this file is a mutant catalogue and an HTML report,
17
+ * at the cost of a large dependency tree in a repo whose headline claim is zero
18
+ * dependencies. So: a sampled probe, dependency-free, and REPORT-ONLY.
19
+ *
20
+ * **This is not a gate and must never become one.** A survivor is a question ("should an
21
+ * assertion pin this?"), and some survivors are correct — equivalent mutants, defensive
22
+ * branches, log strings. Release-check does not run it and CI does not run it.
23
+ *
24
+ * Safety: mutations are applied inside a throwaway `git worktree`, never in your checkout.
25
+ * The worktree is removed at the end, including after a crash.
26
+ *
27
+ * node scripts/mutation-probe.cjs # default targets, 20 mutants
28
+ * node scripts/mutation-probe.cjs --max 50
29
+ * node scripts/mutation-probe.cjs --target hooks/pan-cost-logger.js
30
+ * node scripts/mutation-probe.cjs --seed 7 --json
31
+ */
32
+
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+ const os = require('os');
36
+ const { execFileSync, spawnSync } = require('child_process');
37
+
38
+ const ROOT = path.resolve(__dirname, '..');
39
+
40
+ /**
41
+ * What to probe, and which tests are supposed to catch a break in it. The spec names the
42
+ * hooks and the dispatcher first: the hooks because they are the code that writes the
43
+ * ledger every field number comes from, the dispatcher because its arms are the surface a
44
+ * user and an orchestrator hit.
45
+ */
46
+ const TARGETS = [
47
+ { file: 'hooks/pan-cost-logger.js', tests: ['tests/cost-logger-hook.test.cjs', 'tests/cost-logger.test.cjs'] },
48
+ { file: 'hooks/pan-trace-logger.js', tests: ['tests/trace-logger.test.cjs'] },
49
+ { file: 'pan-wizard-core/bin/lib/cost.cjs', tests: ['tests/cost.test.cjs'] },
50
+ { file: 'pan-wizard-core/bin/lib/cost-rebuild.cjs', tests: ['tests/cost-rebuild.test.cjs'] },
51
+ { file: 'pan-wizard-core/bin/pan-tools.cjs', tests: ['tests/dispatcher.test.cjs', 'tests/dispatcher-arms.test.cjs'] },
52
+ ];
53
+
54
+ /**
55
+ * The mutation operators. Each is a plain textual swap with a token that must appear
56
+ * OUTSIDE a string or comment to be worth mutating (see mutableLine). They are deliberately
57
+ * few and blunt: the point is to find unconstrained logic, not to enumerate every possible
58
+ * defect.
59
+ */
60
+ const OPERATORS = [
61
+ { id: 'gte→gt', find: ' >= ', replace: ' > ' },
62
+ { id: 'gt→gte', find: ' > ', replace: ' >= ' },
63
+ { id: 'lte→lt', find: ' <= ', replace: ' < ' },
64
+ { id: 'lt→lte', find: ' < ', replace: ' <= ' },
65
+ { id: 'eq→neq', find: ' === ', replace: ' !== ' },
66
+ { id: 'neq→eq', find: ' !== ', replace: ' === ' },
67
+ { id: 'and→or', find: ' && ', replace: ' || ' },
68
+ { id: 'or→and', find: ' || ', replace: ' && ' },
69
+ { id: 'true→false', find: 'return true', replace: 'return false' },
70
+ { id: 'false→true', find: 'return false', replace: 'return true' },
71
+ { id: 'plus→minus', find: ' + 1', replace: ' - 1' },
72
+ ];
73
+
74
+ /**
75
+ * Is this line worth mutating? Skips comments, and skips a line whose only occurrence of
76
+ * the token is inside a string literal — mutating a message changes nothing a test should
77
+ * be pinning, and a survivor there would be noise.
78
+ *
79
+ * Crude but conservative: a line containing a quote is only mutated when the token also
80
+ * occurs before the first quote.
81
+ */
82
+ function mutableLine(line, token) {
83
+ const trimmed = line.trim();
84
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return false;
85
+ const at = line.indexOf(token);
86
+ if (at === -1) return false;
87
+ const firstQuote = Math.min(
88
+ ...["'", '"', '`'].map((q) => { const i = line.indexOf(q); return i === -1 ? Infinity : i; }),
89
+ );
90
+ return at < firstQuote;
91
+ }
92
+
93
+ /**
94
+ * Every mutation available in a source file: one per (line, operator) pair, first
95
+ * occurrence on the line.
96
+ *
97
+ * @returns {Array<{line: number, op: string, before: string, after: string}>}
98
+ */
99
+ function mutationsFor(src) {
100
+ const lines = src.split(/\r?\n/);
101
+ const out = [];
102
+ lines.forEach((line, i) => {
103
+ for (const op of OPERATORS) {
104
+ if (!mutableLine(line, op.find)) continue;
105
+ out.push({ line: i + 1, op: op.id, before: line, after: line.replace(op.find, op.replace) });
106
+ }
107
+ });
108
+ return out;
109
+ }
110
+
111
+ /** Apply one mutation to a source string, by line number. Throws if the line moved. */
112
+ function applyMutation(src, mutation) {
113
+ const eol = src.includes('\r\n') ? '\r\n' : '\n';
114
+ const lines = src.split(/\r?\n/);
115
+ const idx = mutation.line - 1;
116
+ if (lines[idx] !== mutation.before) {
117
+ throw new Error(`line ${mutation.line} is not what the mutation was built from`);
118
+ }
119
+ lines[idx] = mutation.after;
120
+ return lines.join(eol);
121
+ }
122
+
123
+ /** Deterministic shuffle, so a run is reproducible from its seed. */
124
+ function sample(items, n, seed) {
125
+ let s = seed >>> 0;
126
+ const rand = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 0x100000000; };
127
+ const copy = items.slice();
128
+ for (let i = copy.length - 1; i > 0; i--) {
129
+ const j = Math.floor(rand() * (i + 1));
130
+ [copy[i], copy[j]] = [copy[j], copy[i]];
131
+ }
132
+ return copy.slice(0, n);
133
+ }
134
+
135
+ function parseArgs(argv) {
136
+ const a = { max: 20, seed: 1, json: false, targets: [], timeoutMs: 600000, help: false };
137
+ for (let i = 0; i < argv.length; i++) {
138
+ const k = argv[i]; const v = argv[i + 1];
139
+ if (k === '--max') { a.max = Math.max(1, Number(v) || 1); i++; }
140
+ else if (k === '--seed') { a.seed = Number(v) || 1; i++; }
141
+ else if (k === '--target') { a.targets.push(v); i++; }
142
+ else if (k === '--timeout') { a.timeoutMs = Math.max(30000, Number(v) || 30000); i++; }
143
+ else if (k === '--json') a.json = true;
144
+ else if (k === '--help' || k === '-h') a.help = true;
145
+ else throw new Error(`unknown argument: ${k}`);
146
+ }
147
+ return a;
148
+ }
149
+
150
+ /**
151
+ * Run one target's tests inside `cwd`. A mutation is CAUGHT when they fail.
152
+ * A test run that cannot start at all counts as caught too — the code is broken enough
153
+ * that nothing ran, which is not a survivor.
154
+ */
155
+ function testsFail(cwd, tests, timeoutMs) {
156
+ const r = spawnSync(process.execPath, ['--test', ...tests], {
157
+ cwd, encoding: 'utf-8', timeout: timeoutMs, stdio: ['ignore', 'pipe', 'pipe'],
158
+ });
159
+ if (r.error && r.error.code === 'ETIMEDOUT') return true; // an infinite loop is a detection
160
+ return r.status !== 0;
161
+ }
162
+
163
+ function main() {
164
+ const args = parseArgs(process.argv.slice(2));
165
+ if (args.help) {
166
+ process.stdout.write('node scripts/mutation-probe.cjs [--max n] [--seed n] [--target <file>]... [--timeout ms] [--json]\n');
167
+ return 0;
168
+ }
169
+
170
+ const targets = args.targets.length
171
+ ? TARGETS.filter((t) => args.targets.some((want) => t.file.endsWith(want.replace(/\\/g, '/'))))
172
+ : TARGETS;
173
+ if (!targets.length) {
174
+ process.stderr.write(`no known target matched ${args.targets.join(', ')}\nknown: ${TARGETS.map((t) => t.file).join(', ')}\n`);
175
+ return 1;
176
+ }
177
+
178
+ // Build the candidate list from the real sources, then sample across all targets so one
179
+ // large file cannot crowd the others out.
180
+ const candidates = [];
181
+ for (const t of targets) {
182
+ const src = fs.readFileSync(path.join(ROOT, t.file), 'utf-8');
183
+ for (const m of mutationsFor(src)) candidates.push({ ...m, ...t });
184
+ }
185
+ const chosen = sample(candidates, args.max, args.seed);
186
+
187
+ // Everything happens in a throwaway worktree: the probe never edits your checkout.
188
+ const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-mutation-'));
189
+ let created = false;
190
+ const survivors = [];
191
+ let caught = 0;
192
+ try {
193
+ execFileSync('git', ['worktree', 'add', '--detach', wt, 'HEAD'], { cwd: ROOT, stdio: 'ignore' });
194
+ created = true;
195
+ // The hooks are copied to hooks/dist by the build; tests that spawn the built copy
196
+ // need it present in the worktree.
197
+ spawnSync(process.execPath, ['scripts/build-hooks.js'], { cwd: wt, stdio: 'ignore' });
198
+
199
+ let i = 0;
200
+ for (const m of chosen) {
201
+ i += 1;
202
+ const file = path.join(wt, m.file);
203
+ const original = fs.readFileSync(file, 'utf-8');
204
+ let mutated;
205
+ try {
206
+ mutated = applyMutation(original, m);
207
+ } catch {
208
+ continue; // the worktree's copy differs from the working tree — skip, do not guess
209
+ }
210
+ if (mutated === original) continue;
211
+ try {
212
+ fs.writeFileSync(file, mutated);
213
+ // A hook mutation must reach the built copy the tests spawn.
214
+ if (m.file.startsWith('hooks/')) spawnSync(process.execPath, ['scripts/build-hooks.js'], { cwd: wt, stdio: 'ignore' });
215
+ const detected = testsFail(wt, m.tests, args.timeoutMs);
216
+ if (detected) caught += 1;
217
+ else survivors.push(m);
218
+ if (!args.json) {
219
+ process.stdout.write(` [${String(i).padStart(2)}/${chosen.length}] ${detected ? 'caught ' : 'SURVIVED'} ${m.file}:${m.line} ${m.op}\n`);
220
+ }
221
+ } finally {
222
+ fs.writeFileSync(file, original);
223
+ if (m.file.startsWith('hooks/')) spawnSync(process.execPath, ['scripts/build-hooks.js'], { cwd: wt, stdio: 'ignore' });
224
+ }
225
+ }
226
+ } finally {
227
+ if (created) {
228
+ try { execFileSync('git', ['worktree', 'remove', '--force', wt], { cwd: ROOT, stdio: 'ignore' }); } catch { /* fall through */ }
229
+ }
230
+ fs.rmSync(wt, { recursive: true, force: true });
231
+ try { execFileSync('git', ['worktree', 'prune'], { cwd: ROOT, stdio: 'ignore' }); } catch { /* best effort */ }
232
+ }
233
+
234
+ const ran = caught + survivors.length;
235
+ const report = {
236
+ candidates: candidates.length,
237
+ sampled: chosen.length,
238
+ ran,
239
+ caught,
240
+ survived: survivors.length,
241
+ score: ran ? Number(((caught / ran) * 100).toFixed(1)) : null,
242
+ seed: args.seed,
243
+ survivors: survivors.map((s) => ({ file: s.file, line: s.line, op: s.op, code: s.before.trim().slice(0, 120) })),
244
+ };
245
+
246
+ if (args.json) {
247
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
248
+ return 0;
249
+ }
250
+
251
+ process.stdout.write(`\nmutation probe — REPORT ONLY, never a gate\n`);
252
+ process.stdout.write(` ${report.caught} caught / ${report.ran} run (${report.score === null ? 'n/a' : report.score + '%'}), from ${report.candidates} candidates, seed ${report.seed}\n`);
253
+ if (survivors.length) {
254
+ process.stdout.write(`\n survivors — the suite ran this line and no assertion constrained it:\n`);
255
+ for (const s of report.survivors) process.stdout.write(` ${s.file}:${s.line} ${s.op}\n ${s.code}\n`);
256
+ process.stdout.write('\n Each is a question, not a verdict: some are equivalent mutants or defensive\n branches where no assertion is owed. Pin the ones that describe real behaviour.\n');
257
+ } else {
258
+ process.stdout.write('\n no survivors in this sample\n');
259
+ }
260
+ return 0;
261
+ }
262
+
263
+ module.exports = { mutationsFor, applyMutation, mutableLine, sample, OPERATORS, TARGETS };
264
+
265
+ if (require.main === module) {
266
+ try {
267
+ process.exit(main());
268
+ } catch (e) {
269
+ process.stderr.write(`mutation probe failed: ${e.message}\n`);
270
+ process.exit(1);
271
+ }
272
+ }
@@ -3,7 +3,7 @@
3
3
  * release-check.js — Pre-publish validation gate.
4
4
  *
5
5
  * Wired into `prepublishOnly` so `npm publish` fails BEFORE upload if any
6
- * gate is red. Runs seven checks in order; first failure aborts.
6
+ * gate is red. Runs nine checks in order; first failure aborts.
7
7
  *
8
8
  * 1. build:hooks — hook scripts copy/build cleanly
9
9
  * 2. test:all — full test suite (unit + scenario) passes
@@ -11,9 +11,13 @@
11
11
  * (we have zero runtime deps, but the dev-deps are checked)
12
12
  * 4. doc-lint counts — no drift-prone count violations in user-facing docs
13
13
  * 5. links validate — doc↔code link graph resolves (no broken references)
14
- * 6. npm pack dry-run — package builds; size is sane
14
+ * 6. npm pack dry-run — package builds; size is sane; zero runtime dependencies
15
15
  * 7. smoke install — npm pack + install into temp dir + run pan-tools list
16
16
  * catches "ships but doesn't actually work" failures
17
+ * 8. bundles — both plugin builders build; dist/pan-agent-plugin is fresh
18
+ * 9. coverage gate — the suite under Node's coverage: every dispatcher arm
19
+ * executed, line/function floors per module group
20
+ * (scripts/coverage-gate.cjs; skipped on Node < 22)
17
21
  *
18
22
  * Usage:
19
23
  * node scripts/release-check.js # all gates
@@ -57,7 +61,7 @@ function run(cmd, args, opts = {}) {
57
61
  }
58
62
 
59
63
  // Gate 1: build:hooks
60
- process.stderr.write('\n[release-check] Gate 1/7: build:hooks\n');
64
+ process.stderr.write('\n[release-check] Gate 1/9: build:hooks\n');
61
65
  {
62
66
  const r = run('npm', ['run', 'build:hooks']);
63
67
  logGate('build:hooks', r.status === 0, r.status !== 0 ? `exit ${r.status}` : '');
@@ -65,7 +69,7 @@ process.stderr.write('\n[release-check] Gate 1/7: build:hooks\n');
65
69
  }
66
70
 
67
71
  // Gate 2: test:all
68
- process.stderr.write('\n[release-check] Gate 2/7: test:all\n');
72
+ process.stderr.write('\n[release-check] Gate 2/9: test:all\n');
69
73
  {
70
74
  const r = run('npm', ['run', 'test:all']);
71
75
  logGate('test:all', r.status === 0, r.status !== 0 ? `exit ${r.status}` : '');
@@ -74,9 +78,9 @@ process.stderr.write('\n[release-check] Gate 2/7: test:all\n');
74
78
 
75
79
  // Gate 3: npm audit (production deps only)
76
80
  if (SKIP_AUDIT) {
77
- process.stderr.write('\n[release-check] Gate 3/7: npm audit (SKIPPED)\n');
81
+ process.stderr.write('\n[release-check] Gate 3/9: npm audit (SKIPPED)\n');
78
82
  } else {
79
- process.stderr.write('\n[release-check] Gate 3/7: npm audit --omit=dev\n');
83
+ process.stderr.write('\n[release-check] Gate 3/9: npm audit --omit=dev\n');
80
84
  const r = run('npm', ['audit', '--omit=dev', '--audit-level=high'], { capture: true });
81
85
  // npm audit exits non-zero on findings. We tolerate moderate; fail on high+.
82
86
  const ok = r.status === 0;
@@ -88,7 +92,7 @@ if (SKIP_AUDIT) {
88
92
  }
89
93
 
90
94
  // Gate 4: doc-lint counts on user-facing docs (count-SSoT enforcement)
91
- process.stderr.write('\n[release-check] Gate 4/7: doc-lint counts docs/\n');
95
+ process.stderr.write('\n[release-check] Gate 4/9: doc-lint counts docs/\n');
92
96
  {
93
97
  const tools = path.join(REPO_ROOT, 'pan-wizard-core', 'bin', 'pan-tools.cjs');
94
98
  const docsDir = path.join(REPO_ROOT, 'docs');
@@ -103,7 +107,7 @@ process.stderr.write('\n[release-check] Gate 4/7: doc-lint counts docs/\n');
103
107
 
104
108
  // Gate 5: doc↔code link graph resolves (anti-fake — a doc cannot reference a
105
109
  // code anchor that doesn't exist; deterministic, self-enforcing exit 1).
106
- process.stderr.write('\n[release-check] Gate 5/7: links validate\n');
110
+ process.stderr.write('\n[release-check] Gate 5/9: links validate\n');
107
111
  {
108
112
  const tools = path.join(REPO_ROOT, 'pan-wizard-core', 'bin', 'pan-tools.cjs');
109
113
  const r = run('node', [tools, 'links', 'validate', '--raw'], { capture: true });
@@ -125,16 +129,21 @@ process.stderr.write('\n[release-check] Gate 5/7: links validate\n');
125
129
 
126
130
  // Gate 6: npm pack — produces a non-empty, sanely-sized tarball (read the file,
127
131
  // never parse stdout)
128
- process.stderr.write('\n[release-check] Gate 6/7: npm pack (size sanity)\n');
132
+ process.stderr.write('\n[release-check] Gate 6/9: npm pack (size sanity)\n');
129
133
  {
130
134
  const tmp6 = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-release-pack-'));
131
135
  const r = run('npm', ['pack', '--pack-destination', tmp6], { capture: true });
132
136
  const tgz = r.status === 0 ? fs.readdirSync(tmp6).find(f => f.endsWith('.tgz')) : null;
133
137
  const size = tgz ? fs.statSync(path.join(tmp6, tgz)).size : 0;
134
138
  const sizeMB = (size / 1024 / 1024).toFixed(2);
139
+ // R8: "zero runtime dependencies" is a headline claim (README, COMPARISON.md); a
140
+ // dependency added by accident must turn the release red before it ships. The unit
141
+ // pin is tests/package-contract.test.cjs; this is the publish-time backstop.
142
+ const deps = Object.keys(require(path.join(REPO_ROOT, 'package.json')).dependencies || {});
143
+ const zeroDeps = deps.length === 0;
135
144
  // Sane = a non-empty tarball under 50MB (large for a zero-runtime-dep tool)
136
- const ok = r.status === 0 && !!tgz && size > 0 && size < 50 * 1024 * 1024;
137
- logGate('npm pack', ok, tgz ? `${sizeMB}MB tarball` : `no tarball (exit ${r.status})`);
145
+ const ok = r.status === 0 && !!tgz && size > 0 && size < 50 * 1024 * 1024 && zeroDeps;
146
+ logGate('npm pack', ok, (tgz ? `${sizeMB}MB tarball` : `no tarball (exit ${r.status})`) + (zeroDeps ? '' : `; runtime dependencies present: ${deps.join(', ')}`));
138
147
  fs.rmSync(tmp6, { recursive: true, force: true });
139
148
  if (!ok) {
140
149
  process.stderr.write((r.stderr || '') + '\n');
@@ -144,9 +153,9 @@ process.stderr.write('\n[release-check] Gate 6/7: npm pack (size sanity)\n');
144
153
 
145
154
  // Gate 7: smoke install — pack and install into temp dir, run pan-tools
146
155
  if (SKIP_SMOKE) {
147
- process.stderr.write('\n[release-check] Gate 7/7: smoke install (SKIPPED)\n');
156
+ process.stderr.write('\n[release-check] Gate 7/9: smoke install (SKIPPED)\n');
148
157
  } else {
149
- process.stderr.write('\n[release-check] Gate 7/7: smoke install (npm pack + install + sanity)\n');
158
+ process.stderr.write('\n[release-check] Gate 7/9: smoke install (npm pack + install + sanity)\n');
150
159
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-release-smoke-'));
151
160
  try {
152
161
  // Pack — read the .tgz npm writes to tmpDir; never parse its stdout (see note).
@@ -192,6 +201,64 @@ if (SKIP_SMOKE) {
192
201
  }
193
202
  }
194
203
 
204
+ // Gate 8: distribution bundles — both plugin builders produce a manifest. Built into
205
+ // temp dirs (never dist/) so the gate cannot race a concurrently running test and
206
+ // leaves the checkout untouched. A bundle that fails to build is a release that
207
+ // ships a broken marketplace entry.
208
+ process.stderr.write('\n[release-check] Gate 8/9: distribution bundles (build:plugin + build:agent-plugin)\n');
209
+ {
210
+ const tmp8 = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-release-bundles-'));
211
+ try {
212
+ const claudeOut = path.join(tmp8, 'claude');
213
+ const agentOut = path.join(tmp8, 'agent');
214
+ const a = run('node', [path.join(REPO_ROOT, 'scripts', 'build-plugin.js')], { capture: true, env: { ...process.env, PAN_PLUGIN_OUT: claudeOut } });
215
+ const b = run('node', [path.join(REPO_ROOT, 'scripts', 'build-agent-plugin.js')], { capture: true, env: { ...process.env, PAN_AGENT_PLUGIN_OUT: agentOut } });
216
+ const okA = a.status === 0 && fs.existsSync(path.join(claudeOut, '.claude-plugin', 'plugin.json'));
217
+ const okB = b.status === 0 && fs.existsSync(path.join(agentOut, 'plugin.json')) && fs.existsSync(path.join(agentOut, 'mcp.json'));
218
+ // R10: .agents/plugins/marketplace.json (Codex) and .github/plugin/marketplace.json
219
+ // (Copilot) resolve to ./dist/pan-agent-plugin with no rebuild-on-resolve — unlike
220
+ // the Claude `command` source. A stale dist/ shipped silently on 2026-09-10 (built
221
+ // before the vendor-directory commit). Compare it with the fresh build when it
222
+ // exists; the gate stays read-only and never writes dist/.
223
+ let staleDetail = '';
224
+ const distAgent = path.join(REPO_ROOT, 'dist', 'pan-agent-plugin');
225
+ if (okB && fs.existsSync(distAgent)) {
226
+ const { dirDigest } = require(path.join(REPO_ROOT, 'bin', 'install-lib.cjs'));
227
+ if (dirDigest(distAgent) !== dirDigest(agentOut)) {
228
+ staleDetail = 'dist/pan-agent-plugin is STALE — run `npm run build:agent-plugin` (the Codex and Copilot marketplaces install from it)';
229
+ }
230
+ }
231
+ const ok8 = okA && okB && !staleDetail;
232
+ const detail = ok8
233
+ ? 'Claude plugin + Agent Plugins bundle built' + (fs.existsSync(distAgent) ? '; dist/pan-agent-plugin matches the fresh build' : '')
234
+ : staleDetail || `claude:${okA ? 'ok' : 'FAIL exit ' + a.status} agent-plugins:${okB ? 'ok' : 'FAIL exit ' + b.status}`;
235
+ logGate('distribution bundles', ok8, detail);
236
+ if (!ok8) {
237
+ process.stderr.write((a.stderr || '') + (b.stderr || '') + '\n');
238
+ process.exit(1);
239
+ }
240
+ } finally {
241
+ try { fs.rmSync(tmp8, { recursive: true, force: true }); } catch { /* best-effort */ }
242
+ }
243
+ }
244
+
245
+ // Gate 9: coverage gate — the whole suite once more, under Node's own coverage
246
+ // instrumentation, then: every dispatcher case arm executed (or allowlisted with a
247
+ // reason in tests/fixtures/coverage-policy.json) and line/function floors per module
248
+ // group. Gate 2 says the tests pass; this gate says the shipped code ran. On Node
249
+ // below 22 the script reports "skipped" and exits 0 — the CI Node-22 job carries it.
250
+ process.stderr.write('\n[release-check] Gate 9/9: coverage gate (dispatcher arms + coverage floors)\n');
251
+ {
252
+ const r = run('node', [path.join(REPO_ROOT, 'scripts', 'coverage-gate.cjs')], { capture: true });
253
+ const text = ((r.stdout || '') + (r.stderr || '')).trim();
254
+ const first = text.split('\n')[0] || '';
255
+ logGate('coverage gate', r.status === 0, first.replace(/^coverage gate — /, ''));
256
+ if (r.status !== 0) {
257
+ process.stderr.write(text + '\n');
258
+ process.exit(1);
259
+ }
260
+ }
261
+
195
262
  // Summary
196
263
  process.stderr.write('\n[release-check] Summary:\n');
197
264
  for (const c of checks) {