pan-wizard 3.5.1 → 3.7.10

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 (93) hide show
  1. package/README.md +10 -10
  2. package/agents/pan-executor.md +18 -0
  3. package/agents/pan-experiment-runner.md +126 -0
  4. package/agents/pan-phase-researcher.md +16 -0
  5. package/agents/pan-plan-checker.md +80 -0
  6. package/agents/pan-planner.md +19 -0
  7. package/agents/pan-reviewer.md +2 -0
  8. package/agents/pan-verifier.md +41 -0
  9. package/bin/install-lib.cjs +55 -0
  10. package/bin/install.js +71 -22
  11. package/commands/pan/debug.md +1 -1
  12. package/commands/pan/experiment.md +219 -0
  13. package/commands/pan/health.md +1 -1
  14. package/commands/pan/learn.md +15 -1
  15. package/commands/pan/optimize.md +13 -0
  16. package/commands/pan/patches.md +10 -1
  17. package/commands/pan/phase-tests.md +1 -4
  18. package/commands/pan/todo-add.md +1 -1
  19. package/commands/pan/todo-check.md +1 -1
  20. package/hooks/dist/pan-cost-logger.js +54 -4
  21. package/hooks/dist/pan-trace-logger.js +72 -3
  22. package/package.json +67 -66
  23. package/pan-wizard-core/bin/lib/commands.cjs +8 -0
  24. package/pan-wizard-core/bin/lib/config.cjs +13 -2
  25. package/pan-wizard-core/bin/lib/context-budget.cjs +73 -0
  26. package/pan-wizard-core/bin/lib/core.cjs +13 -0
  27. package/pan-wizard-core/bin/lib/doc-lint/frontmatter.js +270 -0
  28. package/pan-wizard-core/bin/lib/doc-lint/reporter.js +45 -0
  29. package/pan-wizard-core/bin/lib/doc-lint/schema.js +202 -0
  30. package/pan-wizard-core/bin/lib/doc-lint/validate.js +190 -0
  31. package/pan-wizard-core/bin/lib/doc-lint/walk.js +135 -0
  32. package/pan-wizard-core/bin/lib/doc-lint.cjs +287 -0
  33. package/pan-wizard-core/bin/lib/experiment.cjs +501 -0
  34. package/pan-wizard-core/bin/lib/learn-index.cjs +235 -0
  35. package/pan-wizard-core/bin/lib/learn-lint.cjs +292 -0
  36. package/pan-wizard-core/bin/lib/optimize.cjs +474 -1
  37. package/pan-wizard-core/bin/lib/runner.cjs +472 -0
  38. package/pan-wizard-core/bin/pan-tools.cjs +222 -2
  39. package/pan-wizard-core/learnings/README.md +70 -0
  40. package/pan-wizard-core/learnings/index.json +540 -0
  41. package/pan-wizard-core/learnings/internal/.gitkeep +2 -0
  42. package/pan-wizard-core/learnings/internal/experiment-runner.md +81 -0
  43. package/pan-wizard-core/learnings/internal/external-research.md +93 -0
  44. package/pan-wizard-core/learnings/internal/loop-design.md +33 -0
  45. package/pan-wizard-core/learnings/internal/pan-dev-bugs.md +181 -0
  46. package/pan-wizard-core/learnings/universal/.gitkeep +2 -0
  47. package/pan-wizard-core/learnings/universal/atomic-state.md +21 -0
  48. package/pan-wizard-core/learnings/universal/binary-io.md +21 -0
  49. package/pan-wizard-core/learnings/universal/comment-syntax.md +21 -0
  50. package/pan-wizard-core/learnings/universal/composition.md +33 -0
  51. package/pan-wizard-core/learnings/universal/concurrency.md +33 -0
  52. package/pan-wizard-core/learnings/universal/dag-scheduler.md +33 -0
  53. package/pan-wizard-core/learnings/universal/data-driven-design.md +21 -0
  54. package/pan-wizard-core/learnings/universal/design-process.md +21 -0
  55. package/pan-wizard-core/learnings/universal/empirical-spike.md +21 -0
  56. package/pan-wizard-core/learnings/universal/error-handling.md +23 -0
  57. package/pan-wizard-core/learnings/universal/error-paths.md +21 -0
  58. package/pan-wizard-core/learnings/universal/glob-semantics.md +21 -0
  59. package/pan-wizard-core/learnings/universal/idempotency.md +21 -0
  60. package/pan-wizard-core/learnings/universal/invariants.md +21 -0
  61. package/pan-wizard-core/learnings/universal/io-patterns.md +21 -0
  62. package/pan-wizard-core/learnings/universal/numeric-edge-cases.md +21 -0
  63. package/pan-wizard-core/learnings/universal/output-conventions.md +21 -0
  64. package/pan-wizard-core/learnings/universal/parser-design.md +21 -0
  65. package/pan-wizard-core/learnings/universal/phase-locking.md +21 -0
  66. package/pan-wizard-core/learnings/universal/pipe-friendly-cli.md +21 -0
  67. package/pan-wizard-core/learnings/universal/schema-design.md +21 -0
  68. package/pan-wizard-core/learnings/universal/secret-handling.md +21 -0
  69. package/pan-wizard-core/learnings/universal/streaming-io.md +21 -0
  70. package/pan-wizard-core/learnings/universal/test-patterns.md +57 -0
  71. package/pan-wizard-core/learnings/universal/test-strategy.md +33 -0
  72. package/pan-wizard-core/learnings/universal/unicode.md +21 -0
  73. package/pan-wizard-core/learnings/universal/vendor-pattern.md +21 -0
  74. package/pan-wizard-core/references/guardrails.md +58 -0
  75. package/pan-wizard-core/references/handoff-decisions.md +156 -0
  76. package/pan-wizard-core/references/schemas/pan-command.schema.yml +39 -0
  77. package/pan-wizard-core/references/verification-patterns.md +31 -0
  78. package/pan-wizard-core/templates/config.json +2 -1
  79. package/pan-wizard-core/templates/idea.md +52 -0
  80. package/pan-wizard-core/templates/summary-complex.md +14 -5
  81. package/pan-wizard-core/templates/summary-minimal.md +6 -0
  82. package/pan-wizard-core/templates/summary-standard.md +14 -3
  83. package/pan-wizard-core/workflows/discuss-phase.md +108 -1
  84. package/pan-wizard-core/workflows/exec-phase.md +37 -1
  85. package/pan-wizard-core/workflows/execute-plan.md +14 -0
  86. package/pan-wizard-core/workflows/health.md +23 -0
  87. package/pan-wizard-core/workflows/new-project.md +65 -81
  88. package/pan-wizard-core/workflows/plan-phase.md +58 -0
  89. package/pan-wizard-core/workflows/transition.md +102 -7
  90. package/pan-wizard-core/workflows/verify-phase.md +14 -0
  91. package/scripts/build-hooks.js +7 -1
  92. package/scripts/generate-skills-docs.py +10 -8
  93. package/scripts/release-check.js +184 -0
@@ -0,0 +1,501 @@
1
+ 'use strict';
2
+ /**
3
+ * experiment.cjs — Self-improvement loop W1: experiment scaffolding.
4
+ *
5
+ * Spec: docs/specs/self_improvement_loop_featureai.md
6
+ * ADR: ADR-0026 (pending)
7
+ *
8
+ * Manages the lifecycle of an "experiment" — an isolated project folder
9
+ * outside the PAN source repo where we drive an external AI coding session
10
+ * to build an idea, then harvest the resulting telemetry back into
11
+ * pan-wizard-core/learnings/.
12
+ *
13
+ * W1 (this file as shipped in v3.7.0):
14
+ * - newExperiment(slug, opts) — scaffold folder + copy idea + write manifest
15
+ * - listExperiments(opts) — enumerate experiments under root
16
+ * - getExperimentManifest(slug, opts) — read manifest by slug
17
+ *
18
+ * W3 (v3.7.2): adds harvestExperiment, pruneExperiment.
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const os = require('os');
24
+ const { execFileSync } = require('child_process');
25
+
26
+ // Source repo root — used for the "never write inside source repo" guard.
27
+ // Computed once at module load; mirrors install.js PAN_SOURCE_ROOT.
28
+ const PAN_SOURCE_ROOT = path.resolve(__dirname, '..', '..', '..');
29
+
30
+ // Default experiment root: ~/pan-experiments. Configurable via opts.root.
31
+ const PAN_EXPERIMENTS_ROOT_DEFAULT = path.join(os.homedir(), 'pan-experiments');
32
+
33
+ const VALID_RUNTIMES = ['claude', 'codex', 'gemini', 'opencode', 'copilot'];
34
+
35
+ // Slug rules: lowercase, digits, hyphens; max 40 chars; cannot start/end with hyphen.
36
+ const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
37
+
38
+ // ── Helpers ─────────────────────────────────────────────────────────────────
39
+
40
+ function normPath(p) {
41
+ return process.platform === 'win32' ? p.toLowerCase() : p;
42
+ }
43
+
44
+ function isInsideSourceRepo(p) {
45
+ const abs = normPath(path.resolve(p));
46
+ const src = normPath(PAN_SOURCE_ROOT);
47
+ return abs === src || abs.startsWith(src + path.sep) || abs.startsWith(src + '/');
48
+ }
49
+
50
+ function validateSlug(slug) {
51
+ if (typeof slug !== 'string' || slug.length === 0) {
52
+ return 'slug must be a non-empty string';
53
+ }
54
+ if (slug.length > 40) {
55
+ return `slug too long (max 40 chars, got ${slug.length})`;
56
+ }
57
+ if (!SLUG_RE.test(slug)) {
58
+ return 'slug invalid: must be lowercase letters, digits, hyphens (no leading/trailing hyphen)';
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function validateRuntime(runtime) {
64
+ if (!VALID_RUNTIMES.includes(runtime)) {
65
+ return `runtime must be one of: ${VALID_RUNTIMES.join(', ')}, got "${runtime}"`;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ // ── newExperiment ───────────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Scaffold a new experiment folder.
74
+ *
75
+ * @param {string} slug - lowercase-hyphen slug, max 40 chars
76
+ * @param {object} opts
77
+ * @param {string} opts.ideaPath - absolute path to the idea.md to copy in
78
+ * @param {string} opts.runtime - one of VALID_RUNTIMES
79
+ * @param {string} [opts.root] - experiment root dir (default: PAN_EXPERIMENTS_ROOT_DEFAULT)
80
+ * @param {boolean} [opts.skipInstaller] - if true, don't run the PAN installer (used by tests)
81
+ * @param {number} [opts.budget] - optional budget cap in points (saved to manifest)
82
+ * @returns {object} { experiment_id, path, runtime, idea_path, created_at } or { error }
83
+ */
84
+ function newExperiment(slug, opts = {}) {
85
+ const slugError = validateSlug(slug);
86
+ if (slugError) return { error: slugError };
87
+
88
+ const runtime = opts.runtime || 'claude';
89
+ const runtimeError = validateRuntime(runtime);
90
+ if (runtimeError) return { error: runtimeError };
91
+
92
+ if (!opts.ideaPath) {
93
+ return { error: 'ideaPath is required' };
94
+ }
95
+ if (!fs.existsSync(opts.ideaPath)) {
96
+ return { error: `idea file not found: ${opts.ideaPath}` };
97
+ }
98
+
99
+ const root = opts.root || PAN_EXPERIMENTS_ROOT_DEFAULT;
100
+ if (isInsideSourceRepo(root)) {
101
+ return {
102
+ error: `refusing to scaffold experiment inside PAN source repo (${PAN_SOURCE_ROOT}); ` +
103
+ `set opts.root to a directory outside the source tree`,
104
+ };
105
+ }
106
+
107
+ const expPath = path.join(root, slug);
108
+ if (fs.existsSync(expPath)) {
109
+ return { error: `experiment folder already exists: ${expPath}` };
110
+ }
111
+
112
+ const createdAt = new Date().toISOString();
113
+
114
+ try {
115
+ fs.mkdirSync(path.join(expPath, '.planning'), { recursive: true });
116
+ } catch (err) {
117
+ return { error: `failed to create experiment folder: ${err.message}` };
118
+ }
119
+
120
+ // Copy idea.md into <experiment>/.planning/idea.md
121
+ try {
122
+ const ideaContent = fs.readFileSync(opts.ideaPath, 'utf-8');
123
+ fs.writeFileSync(path.join(expPath, '.planning', 'idea.md'), ideaContent);
124
+ } catch (err) {
125
+ return { error: `failed to copy idea: ${err.message}` };
126
+ }
127
+
128
+ // Write the experiment manifest
129
+ const manifest = {
130
+ experiment_id: slug,
131
+ runtime,
132
+ idea_path: path.join(expPath, '.planning', 'idea.md'),
133
+ source_idea_path: path.resolve(opts.ideaPath),
134
+ created_at: createdAt,
135
+ status: 'scaffolded',
136
+ budget: opts.budget != null ? opts.budget : null,
137
+ pan_version: readPanVersion(),
138
+ path: expPath,
139
+ };
140
+
141
+ try {
142
+ fs.writeFileSync(
143
+ path.join(expPath, '.planning', 'experiment.json'),
144
+ JSON.stringify(manifest, null, 2)
145
+ );
146
+ } catch (err) {
147
+ return { error: `failed to write manifest: ${err.message}` };
148
+ }
149
+
150
+ // P-EXP-001 root-cause fix (2026-05-02): initialize git and inherit identity
151
+ // from the PAN source repo. Without this, autonomous runs that hit a fresh
152
+ // shell without global git config get `committed: false reason: commit_failed`
153
+ // returns from `pan-tools commit`, silently leaving artifacts uncommitted.
154
+ // whoocache hit this exactly — 24 min of work, no commits, and the run kept
155
+ // going because the workflow-level commit step exit code was 0.
156
+ initExperimentGit(expPath);
157
+
158
+ // Optional: invoke installer for the chosen runtime in the experiment folder
159
+ if (!opts.skipInstaller) {
160
+ const installerError = runInstaller(expPath, runtime);
161
+ if (installerError) {
162
+ // Non-fatal: the experiment exists, but installer failed. Return both info.
163
+ manifest.installer_error = installerError;
164
+ try {
165
+ fs.writeFileSync(
166
+ path.join(expPath, '.planning', 'experiment.json'),
167
+ JSON.stringify(manifest, null, 2)
168
+ );
169
+ } catch { /* best effort */ }
170
+ return {
171
+ ...manifest,
172
+ warning: `experiment scaffolded, but installer failed: ${installerError}`,
173
+ };
174
+ }
175
+ manifest.status = 'ready';
176
+ // P-101 fix (v3.7.1): persist the status update — earlier versions
177
+ // mutated the in-memory manifest but never wrote it back, so the on-disk
178
+ // file kept saying "scaffolded" forever.
179
+ try {
180
+ fs.writeFileSync(
181
+ path.join(expPath, '.planning', 'experiment.json'),
182
+ JSON.stringify(manifest, null, 2)
183
+ );
184
+ } catch { /* best effort — non-fatal */ }
185
+ }
186
+
187
+ return {
188
+ experiment_id: slug,
189
+ path: expPath,
190
+ runtime,
191
+ idea_path: manifest.idea_path,
192
+ created_at: createdAt,
193
+ };
194
+ }
195
+
196
+ function runInstaller(expPath, runtime) {
197
+ const installerPath = path.join(PAN_SOURCE_ROOT, 'bin', 'install.js');
198
+ try {
199
+ execFileSync('node', [installerPath, `--${runtime}`, '--local'], {
200
+ cwd: expPath,
201
+ stdio: ['pipe', 'pipe', 'pipe'],
202
+ timeout: 60000,
203
+ });
204
+ return null;
205
+ } catch (err) {
206
+ return err.stderr?.toString() || err.message;
207
+ }
208
+ }
209
+
210
+ // P-EXP-001 root-cause fix (2026-05-02): initialize git and configure local
211
+ // user identity so the autonomous loop's `pan-tools commit` calls succeed even
212
+ // in environments without global git config. Identity is inherited from the
213
+ // PAN source repo when available; falls back to placeholders that produce
214
+ // valid commits (the agent can't run, but at least the commits land).
215
+ function initExperimentGit(expPath) {
216
+ const tryGit = (args, cwd) => {
217
+ try {
218
+ execFileSync('git', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 });
219
+ return true;
220
+ } catch { return false; }
221
+ };
222
+ const readGit = (args, cwd) => {
223
+ try {
224
+ return execFileSync('git', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000, encoding: 'utf-8' }).trim();
225
+ } catch { return ''; }
226
+ };
227
+
228
+ // Init the experiment repo. Idempotent — git init on an existing repo is a no-op.
229
+ if (!tryGit(['init', '--initial-branch=main'], expPath)) {
230
+ // Fallback for older git that doesn't know --initial-branch
231
+ tryGit(['init'], expPath);
232
+ }
233
+
234
+ // Inherit identity from the PAN source repo, then global, then placeholders.
235
+ const sourceEmail = readGit(['config', '--get', 'user.email'], PAN_SOURCE_ROOT);
236
+ const sourceName = readGit(['config', '--get', 'user.name'], PAN_SOURCE_ROOT);
237
+ const email = sourceEmail || 'experiment@pan-wizard.local';
238
+ const name = sourceName || 'PAN Experiment Runner';
239
+
240
+ tryGit(['config', '--local', 'user.email', email], expPath);
241
+ tryGit(['config', '--local', 'user.name', name], expPath);
242
+ }
243
+
244
+ function readPanVersion() {
245
+ try {
246
+ const pkg = JSON.parse(fs.readFileSync(path.join(PAN_SOURCE_ROOT, 'package.json'), 'utf-8'));
247
+ return pkg.version;
248
+ } catch {
249
+ return 'unknown';
250
+ }
251
+ }
252
+
253
+ // ── listExperiments ─────────────────────────────────────────────────────────
254
+
255
+ /**
256
+ * Enumerate all experiments under the root directory.
257
+ * Returns { experiments: [...], count } where each entry has the manifest fields.
258
+ */
259
+ function listExperiments(opts = {}) {
260
+ const root = opts.root || PAN_EXPERIMENTS_ROOT_DEFAULT;
261
+
262
+ let entries;
263
+ try {
264
+ entries = fs.readdirSync(root, { withFileTypes: true });
265
+ } catch (err) {
266
+ if (err.code === 'ENOENT') {
267
+ return { experiments: [], count: 0 };
268
+ }
269
+ return { error: `failed to read root: ${err.message}` };
270
+ }
271
+
272
+ // Soft-pruned experiments are renamed to <slug>-archived-<ISO-ts>. Skip
273
+ // those by default — they're not active. Pass opts.includeArchived=true
274
+ // to surface them anyway (status/diagnostic listings).
275
+ const ARCHIVED_RE = /-archived-\d{4}-\d{2}-\d{2}T/;
276
+
277
+ const experiments = [];
278
+ for (const entry of entries) {
279
+ if (!entry.isDirectory()) continue;
280
+ const slug = entry.name;
281
+ if (!opts.includeArchived && ARCHIVED_RE.test(slug)) continue;
282
+ const manifestPath = path.join(root, slug, '.planning', 'experiment.json');
283
+ try {
284
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
285
+ experiments.push({
286
+ ...manifest,
287
+ path: path.join(root, slug),
288
+ });
289
+ } catch {
290
+ // Not a PAN experiment folder; skip silently
291
+ }
292
+ }
293
+
294
+ // Sort newest first
295
+ experiments.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
296
+
297
+ return { experiments, count: experiments.length };
298
+ }
299
+
300
+ // ── getExperimentManifest ───────────────────────────────────────────────────
301
+
302
+ /**
303
+ * Read the manifest for a single experiment by slug.
304
+ */
305
+ function getExperimentManifest(slug, opts = {}) {
306
+ const slugError = validateSlug(slug);
307
+ if (slugError) return { error: slugError };
308
+
309
+ const root = opts.root || PAN_EXPERIMENTS_ROOT_DEFAULT;
310
+ const manifestPath = path.join(root, slug, '.planning', 'experiment.json');
311
+
312
+ try {
313
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
314
+ return manifest;
315
+ } catch (err) {
316
+ if (err.code === 'ENOENT') {
317
+ return { error: `experiment not found: ${slug}` };
318
+ }
319
+ return { error: `failed to read manifest: ${err.message}` };
320
+ }
321
+ }
322
+
323
+ // ── harvestExperiment (W3) ──────────────────────────────────────────────────
324
+
325
+ // Paths to harvest from <experiment>/ — relative to the experiment folder.
326
+ // Optional paths are skipped if absent (e.g., a fresh experiment hasn't
327
+ // produced .planning/optimization/ yet).
328
+ const HARVEST_PATHS = [
329
+ '.planning/idea.md',
330
+ '.planning/experiment.json',
331
+ '.planning/state.md',
332
+ '.planning/run-state.json',
333
+ '.planning/agent-history.json',
334
+ '.planning/optimization',
335
+ '.planning/phases',
336
+ ];
337
+
338
+ /**
339
+ * Recursively copy a directory or file. Returns total bytes copied.
340
+ */
341
+ function copyRecursive(src, dest) {
342
+ let total = 0;
343
+ const stat = fs.statSync(src);
344
+ if (stat.isDirectory()) {
345
+ fs.mkdirSync(dest, { recursive: true });
346
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
347
+ const childSrc = path.join(src, entry.name);
348
+ const childDest = path.join(dest, entry.name);
349
+ total += copyRecursive(childSrc, childDest);
350
+ }
351
+ } else if (stat.isFile()) {
352
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
353
+ fs.copyFileSync(src, dest);
354
+ total += stat.size;
355
+ }
356
+ return total;
357
+ }
358
+
359
+ /**
360
+ * Harvest an experiment's telemetry into <sourceRoot>/experiments/<slug>/.
361
+ *
362
+ * Copies idea.md, experiment.json, state.md, run-state.json, agent-history.json,
363
+ * optimization/, and phases/ if present. Writes a harvest.json manifest.
364
+ *
365
+ * @param {string} slug
366
+ * @param {object} opts
367
+ * @param {string} [opts.root] - experiment root (default PAN_EXPERIMENTS_ROOT_DEFAULT)
368
+ * @param {string} [opts.sourceRoot] - destination repo root (default PAN_SOURCE_ROOT)
369
+ * @param {boolean} [opts.force] - overwrite an existing harvest
370
+ * @returns {object} { experiment_id, harvest_path, harvested_paths, total_bytes, harvested_at }
371
+ */
372
+ function harvestExperiment(slug, opts = {}) {
373
+ const slugError = validateSlug(slug);
374
+ if (slugError) return { error: slugError };
375
+
376
+ const root = opts.root || PAN_EXPERIMENTS_ROOT_DEFAULT;
377
+ const sourceRoot = opts.sourceRoot || PAN_SOURCE_ROOT;
378
+
379
+ const expPath = path.join(root, slug);
380
+ if (!fs.existsSync(expPath)) {
381
+ return { error: `experiment not found: ${slug}` };
382
+ }
383
+
384
+ const harvestDir = path.join(sourceRoot, 'experiments', slug);
385
+ if (fs.existsSync(harvestDir) && !opts.force) {
386
+ return {
387
+ error: `harvest already exists at ${harvestDir} (use --force to overwrite)`,
388
+ };
389
+ }
390
+
391
+ // Wipe existing harvest if force
392
+ if (fs.existsSync(harvestDir) && opts.force) {
393
+ try {
394
+ fs.rmSync(harvestDir, { recursive: true, force: true });
395
+ } catch (err) {
396
+ return { error: `failed to clear existing harvest: ${err.message}` };
397
+ }
398
+ }
399
+
400
+ fs.mkdirSync(harvestDir, { recursive: true });
401
+
402
+ const harvestedPaths = [];
403
+ let totalBytes = 0;
404
+
405
+ for (const rel of HARVEST_PATHS) {
406
+ const srcPath = path.join(expPath, rel);
407
+ if (!fs.existsSync(srcPath)) continue;
408
+ const destPath = path.join(harvestDir, rel);
409
+ try {
410
+ const bytes = copyRecursive(srcPath, destPath);
411
+ harvestedPaths.push(rel);
412
+ totalBytes += bytes;
413
+ } catch (err) {
414
+ return { error: `failed to copy ${rel}: ${err.message}` };
415
+ }
416
+ }
417
+
418
+ const harvestManifest = {
419
+ experiment_id: slug,
420
+ harvested_at: new Date().toISOString(),
421
+ source_path: expPath,
422
+ harvest_path: harvestDir,
423
+ harvested_paths: harvestedPaths,
424
+ total_bytes: totalBytes,
425
+ pan_version: readPanVersion(),
426
+ };
427
+
428
+ try {
429
+ fs.writeFileSync(
430
+ path.join(harvestDir, 'harvest.json'),
431
+ JSON.stringify(harvestManifest, null, 2)
432
+ );
433
+ } catch (err) {
434
+ return { error: `failed to write harvest manifest: ${err.message}` };
435
+ }
436
+
437
+ return harvestManifest;
438
+ }
439
+
440
+ // ── pruneExperiment (W3) ────────────────────────────────────────────────────
441
+
442
+ /**
443
+ * Prune (delete) an experiment after harvest.
444
+ *
445
+ * Soft mode (default): rename to <root>/<slug>-archived-<timestamp>.
446
+ * Hard mode (opts.hard=true): permanently delete the folder.
447
+ *
448
+ * @param {string} slug
449
+ * @param {object} opts
450
+ * @param {string} [opts.root]
451
+ * @param {boolean} [opts.hard]
452
+ * @returns {object} { pruned: slug, mode: 'soft'|'hard', archive_path? }
453
+ */
454
+ function pruneExperiment(slug, opts = {}) {
455
+ const slugError = validateSlug(slug);
456
+ if (slugError) return { error: slugError };
457
+
458
+ const root = opts.root || PAN_EXPERIMENTS_ROOT_DEFAULT;
459
+ const expPath = path.join(root, slug);
460
+ if (!fs.existsSync(expPath)) {
461
+ return { error: `experiment not found: ${slug}` };
462
+ }
463
+
464
+ if (opts.hard) {
465
+ try {
466
+ fs.rmSync(expPath, { recursive: true, force: true });
467
+ } catch (err) {
468
+ return { error: `failed to delete experiment: ${err.message}` };
469
+ }
470
+ return { pruned: slug, mode: 'hard' };
471
+ }
472
+
473
+ // Soft: rename to <root>/<slug>-archived-<ts>
474
+ // Filesystem-safe timestamp: replace : and . with -
475
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
476
+ const archivePath = path.join(root, `${slug}-archived-${ts}`);
477
+ try {
478
+ fs.renameSync(expPath, archivePath);
479
+ } catch (err) {
480
+ return { error: `failed to archive experiment: ${err.message}` };
481
+ }
482
+
483
+ return { pruned: slug, mode: 'soft', archive_path: archivePath };
484
+ }
485
+
486
+ // ── Exports ─────────────────────────────────────────────────────────────────
487
+
488
+ module.exports = {
489
+ newExperiment,
490
+ listExperiments,
491
+ getExperimentManifest,
492
+ harvestExperiment,
493
+ pruneExperiment,
494
+ PAN_EXPERIMENTS_ROOT_DEFAULT,
495
+ PAN_SOURCE_ROOT,
496
+ VALID_RUNTIMES,
497
+ HARVEST_PATHS,
498
+ // Test-only exports (validation helpers)
499
+ _validateSlug: validateSlug,
500
+ _isInsideSourceRepo: isInsideSourceRepo,
501
+ };