@xaccefy/pi-casefile 0.9.4 → 0.10.1

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/src/scratchpad.ts CHANGED
@@ -1,27 +1,27 @@
1
1
  /**
2
- * Scratchpad — intermediate artifact store for pipeline runs.
2
+ * Scratchpad — intermediate working-notes store for a run.
3
3
  *
4
4
  * The casefile owns state transitions; the scratchpad owns artifacts.
5
- * Agents write their outputs here (recon maps, trace outputs, verification
6
- * logs) instead of stuffing everything into casefile text fields or relying
7
- * on each other's output streams (which creates an echo chamber).
5
+ * The agent writes its outputs here (recon maps, trace outputs, verification
6
+ * logs) instead of stuffing everything into casefile text fields.
8
7
  *
9
- * Directory layout per pipeline run (one subdir per phase — see PHASE_DIRS):
8
+ * Directory layout per run (one subdir per phase — see PHASE_DIRS):
10
9
  * {project_root}/.scratchpad/{run_id}/
11
10
  * recon/ — fingerprints, tech detection, surface maps
12
11
  * hunt/ — per-class findings
13
- * gapfil/ — gap-fill audit notes
12
+ * gapfil/ — gap-fill audit notes (legacy)
14
13
  * trace/ — per-finding reachability traces
15
14
  * skeptic/ — adversarial disproof attempts
16
15
  * verify/ — PoC logs, run outputs (validate phase)
17
16
  * chain/ — exploit-chain analysis
18
17
  * patch/ — remediation work
19
18
  * report/ — final report context
20
- * state.json — checkpoint file with phase completion + key IDs
19
+ * state.json — legacy checkpoint file (read for context gating; the
20
+ * write-side checkpoint API was removed with the pipeline)
21
21
  *
22
22
  * Resume re-reads scratchpad artifacts; it does not re-run completed phases
23
23
  * (idempotent). The `.scratchpad/` directory is preserved between runs;
24
- * `--fresh` clears it via scratchpad_clear().
24
+ * `scratchpad_clear()` clears it for a single run.
25
25
  */
26
26
 
27
27
  import { createHash } from "node:crypto";
@@ -65,16 +65,13 @@ export interface ScratchpadCheckpoint {
65
65
 
66
66
  export interface ScratchpadResume {
67
67
  checkpoint: ScratchpadCheckpoint;
68
- /** The next phase to run (or null if the run is done). */
69
- next_phase: ScratchpadPhase | null;
70
68
  /** Artifact references per phase: { trace: ["finding-abc.json", ...], ... } */
71
69
  artifacts: Record<string, string[]>;
72
70
  }
73
71
 
74
72
  // ── Constants ────────────────────────────────────────────────────────
75
73
 
76
- // All accepted artifact buckets. Some are legacy/manual-only and should not be
77
- // scheduled by ScratchpadResume for new swarm runs.
74
+ // All accepted artifact buckets. Some are legacy/manual-only.
78
75
  export const SCRATCHPAD_PHASES: ScratchpadPhase[] = [
79
76
  "recon",
80
77
  "hunt",
@@ -87,17 +84,6 @@ export const SCRATCHPAD_PHASES: ScratchpadPhase[] = [
87
84
  "report",
88
85
  ];
89
86
 
90
- // Active pipeline order for new/resumed runs.
91
- export const PHASE_ORDER: ScratchpadPhase[] = [
92
- "recon",
93
- "hunt",
94
- "trace",
95
- "skeptic",
96
- "validate",
97
- "chain",
98
- "report",
99
- ];
100
-
101
87
  const PHASE_DIRS: Record<ScratchpadPhase, string> = {
102
88
  recon: "recon",
103
89
  hunt: "hunt",
@@ -173,59 +159,41 @@ function sanitizeName(name: string, label: string): string {
173
159
  return safe;
174
160
  }
175
161
 
176
- function runDirName(runId: string): string {
177
- const safe = sanitizeName(runId, "run_id");
178
- if (safe === runId) return safe;
179
- const suffix = createHash("sha256").update(runId).digest("hex").slice(0, 12);
180
- return `${safe.slice(0, 80)}-${suffix}`;
181
- }
182
-
183
162
  /**
184
- * Artifact names get the same disambiguation as run dirs: sanitization is
185
- * lossy ("a/b" and "a_b" both become "a_b"), so a changed name gets a content
186
- * hash suffix — distinct inputs can no longer silently overwrite each other's
187
- * file. Reads use the same mapping, so round-trips stay consistent.
163
+ * Sanitized name + disambiguation hash: sanitization is lossy ("a/b" and
164
+ * "a_b" both become "a_b"), so a CHANGED name gets a content hash suffix —
165
+ * distinct inputs can no longer silently overwrite each other's file.
166
+ * Reads use the same mapping, so round-trips stay consistent.
188
167
  */
189
- function artifactFileName(name: string): string {
190
- const safe = sanitizeName(name, "artifact name");
168
+ function sanitizeWithHashSuffix(name: string, label: string): string {
169
+ const safe = sanitizeName(name, label);
191
170
  if (safe === name) return safe;
192
171
  const suffix = createHash("sha256").update(name).digest("hex").slice(0, 12);
193
172
  return `${safe.slice(0, 80)}-${suffix}`;
194
173
  }
195
174
 
175
+ function runDirName(runId: string): string {
176
+ return sanitizeWithHashSuffix(runId, "run_id");
177
+ }
178
+
179
+ function artifactFileName(name: string): string {
180
+ return sanitizeWithHashSuffix(name, "artifact name");
181
+ }
182
+
196
183
  /** Cap on a single scratchpad artifact (2 MiB) — a hallucinating or hostile
197
184
  * subagent must not be able to fill the disk with unbounded writes. */
198
185
  const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024;
199
186
 
200
- /** Pre-hash-suffix naming used by older scratchpad versions (sanitize only). */
201
- function legacyRunDirName(runId: string): string {
202
- return sanitizeName(runId, "run_id");
203
- }
204
-
205
187
  /** The directory for a specific run. */
206
- export function getRunDir(runId: string, projectRoot?: string): string {
188
+ function getRunDir(runId: string, projectRoot?: string): string {
207
189
  return join(getScratchpadRoot(projectRoot), runDirName(runId));
208
190
  }
209
191
 
210
192
  /** The state.json path for a run. */
211
- export function getStatePath(runId: string, projectRoot?: string): string {
193
+ function getStatePath(runId: string, projectRoot?: string): string {
212
194
  return join(getRunDir(runId, projectRoot), "state.json");
213
195
  }
214
196
 
215
- function emptyCheckpoint(runId: string, projectRoot: string): ScratchpadCheckpoint {
216
- const now = new Date().toISOString();
217
- return {
218
- run_id: runId,
219
- project_root: projectRoot,
220
- created_at: now,
221
- last_updated: now,
222
- last_phase_at: null,
223
- completed_phases: [],
224
- phase_ids: {} as Record<ScratchpadPhase, string[]>,
225
- phase_summaries: {} as Record<ScratchpadPhase, string>,
226
- };
227
- }
228
-
229
197
  function ensureRunDirs(runDir: string): void {
230
198
  const scratchpadRoot = dirname(runDir);
231
199
  const projectRoot = dirname(scratchpadRoot);
@@ -272,33 +240,8 @@ function readCheckpointRaw(runId: string, projectRoot?: string): ScratchpadCheck
272
240
  return cp;
273
241
  }
274
242
 
275
- function writeCheckpointRaw(cp: ScratchpadCheckpoint, projectRoot?: string): void {
276
- cp.last_updated = new Date().toISOString();
277
- const statePath = getStatePath(cp.run_id, projectRoot);
278
- ensureRunDirs(getRunDir(cp.run_id, projectRoot));
279
- writeSafeFileAtomic(statePath, JSON.stringify(cp, null, 2));
280
- }
281
-
282
243
  // ── Public API ───────────────────────────────────────────────────────
283
244
 
284
- /**
285
- * Initialize a new scratchpad run. Creates the directory structure and writes
286
- * an initial state.json. If the run already exists, returns the existing
287
- * checkpoint (idempotent — safe to call on resume without --fresh).
288
- */
289
- export function scratchpad_init(runId: string, projectRoot?: string): ScratchpadCheckpoint {
290
- const root = projectRoot ?? detectWorkspaceRoot();
291
- const runDir = getRunDir(runId, root);
292
- ensureRunDirs(runDir);
293
-
294
- const existing = readCheckpointRaw(runId, root);
295
- if (existing) return existing;
296
-
297
- const cp = emptyCheckpoint(runId, root);
298
- writeCheckpointRaw(cp, root);
299
- return cp;
300
- }
301
-
302
245
  /**
303
246
  * Write an artifact to a phase's subdirectory. Overwrites if the name exists.
304
247
  * Returns the full path to the written artifact.
@@ -349,37 +292,80 @@ export function scratchpad_read(
349
292
  }
350
293
 
351
294
  /**
352
- * List all artifacts written for a phase.
295
+ * A run directory discovered by direct scan — no state.json required. The slim
296
+ * extension surface (Write/Read/Clear only) never checkpoints, so discovery
297
+ * must not depend on state.json existing.
353
298
  */
354
- export function scratchpad_runs(projectRoot?: string): string[] {
299
+ export type DiscoveredScratchpadRun = {
300
+ /** Directory name under .scratchpad (the sanitized run id). */
301
+ dir: string;
302
+ /** Artifact file names per phase bucket, non-empty buckets only. */
303
+ phases: Record<string, string[]>;
304
+ };
305
+
306
+ /**
307
+ * Scan the scratchpad root for run directories carrying artifacts. Unlike
308
+ * scratchpad_runs(), this lists runs WITHOUT a state.json checkpoint — the
309
+ * only writer of state.json (ScratchpadInit/Checkpoint) is no longer exposed
310
+ * as a tool, so directory scan is the primary discovery path.
311
+ */
312
+ export function scratchpad_discover_artifacts(projectRoot?: string): DiscoveredScratchpadRun[] {
355
313
  const root = getScratchpadRoot(projectRoot);
356
314
  if (!existsSync(root)) return [];
357
315
  assertSafeStateDirectory(dirname(root), [SCRATCHPAD_DIR]);
358
- const out: string[] = [];
316
+ const out: DiscoveredScratchpadRun[] = [];
359
317
  for (const entry of readdirSync(root, { withFileTypes: true })) {
318
+ // Dirent.isDirectory() is lstat-based: a symlinked run dir reports as a
319
+ // symlink and is skipped here.
360
320
  if (!entry.isDirectory()) continue;
361
- const state = join(root, entry.name, "state.json");
362
- if (!assertSafeRegularFile(state, "Scratchpad checkpoint")) continue;
363
- try {
364
- const cp = JSON.parse(readSafeFile(state, "Scratchpad checkpoint").toString("utf8")) as {
365
- run_id?: unknown;
366
- };
367
- if (typeof cp.run_id !== "string") continue;
368
- if (getRunDir(cp.run_id, projectRoot) === join(root, entry.name)) {
369
- out.push(cp.run_id);
370
- } else if (join(root, legacyRunDirName(cp.run_id)) === join(root, entry.name)) {
371
- // Runs created before the hash-suffix naming used sanitizeName(runId)
372
- // as the directory; keep surfacing them in bundle discovery. Safe ids
373
- // were never suffixed, so only legacy unsafe ids can land here.
374
- out.push(cp.run_id);
321
+ const dir = entry.name;
322
+ const phases: Record<string, string[]> = {};
323
+ for (const phase of SCRATCHPAD_PHASES) {
324
+ const phaseDir = join(root, dir, PHASE_DIRS[phase]);
325
+ if (!existsSync(phaseDir)) continue;
326
+ try {
327
+ assertSafeStateDirectory(dirname(root), [SCRATCHPAD_DIR, dir, PHASE_DIRS[phase]]);
328
+ } catch {
329
+ continue;
375
330
  }
376
- } catch {
377
- // Corrupt runs are ignored during report bundle discovery; direct resume still fails closed.
331
+ const names = readdirSync(phaseDir).filter(
332
+ (f) =>
333
+ f !== "state.json" && assertSafeRegularFile(join(phaseDir, f), "Scratchpad artifact"),
334
+ );
335
+ if (names.length > 0) phases[phase] = names;
378
336
  }
337
+ if (Object.keys(phases).length > 0) out.push({ dir, phases });
379
338
  }
380
339
  return out;
381
340
  }
382
341
 
342
+ /**
343
+ * Read an artifact from a DISCOVERED run directory (see
344
+ * scratchpad_discover_artifacts) by phase bucket key. Same safety checks as
345
+ * scratchpad_read, but addressed by directory name because the original run id
346
+ * is unrecoverable without a checkpoint.
347
+ */
348
+ export function scratchpad_read_discovered(
349
+ dir: string,
350
+ phase: ScratchpadPhase,
351
+ artifactName: string,
352
+ projectRoot?: string,
353
+ ): string | null {
354
+ const phaseDir = PHASE_DIRS[phase];
355
+ if (!phaseDir) return null;
356
+ if (!dir || dir === "." || dir === ".." || dir.includes("/") || dir.includes("\\")) return null;
357
+ const root = getScratchpadRoot(projectRoot);
358
+ const filePath = join(root, dir, phaseDir, artifactName);
359
+ if (existsSync(filePath)) {
360
+ assertSafeStateDirectory(dirname(root), [SCRATCHPAD_DIR, dir, phaseDir]);
361
+ }
362
+ if (!assertSafeRegularFile(filePath, "Scratchpad artifact")) return null;
363
+ return readSafeFile(filePath, "Scratchpad artifact").toString("utf8");
364
+ }
365
+
366
+ /**
367
+ * List all artifacts written for a phase.
368
+ */
383
369
  export function scratchpad_list(
384
370
  runId: string,
385
371
  phase: ScratchpadPhase,
@@ -395,68 +381,26 @@ export function scratchpad_list(
395
381
  }
396
382
 
397
383
  /**
398
- * Mark a phase as complete. Records the completion timestamp, key IDs, and an
399
- * optional summary in state.json. Idempotent: re-checkpointing a phase
400
- * overwrites its previous summary/IDs but does not duplicate the entry in
401
- * completed_phases.
402
- */
403
- export function scratchpad_checkpoint(
404
- runId: string,
405
- phase: ScratchpadPhase,
406
- data: { ids?: string[]; summary?: string },
407
- projectRoot?: string,
408
- ): ScratchpadCheckpoint {
409
- const root = projectRoot ?? detectWorkspaceRoot();
410
- const cp = readCheckpointRaw(runId, root) ?? scratchpad_init(runId, root);
411
-
412
- if (!cp.completed_phases.includes(phase)) {
413
- cp.completed_phases.push(phase);
414
- // Keep completed_phases in pipeline order for predictable resume.
415
- cp.completed_phases.sort((a, b) => SCRATCHPAD_PHASES.indexOf(a) - SCRATCHPAD_PHASES.indexOf(b));
416
- }
417
- cp.last_phase_at = new Date().toISOString();
418
- if (data.ids) cp.phase_ids[phase] = data.ids;
419
- if (data.summary) cp.phase_summaries[phase] = data.summary;
420
-
421
- writeCheckpointRaw(cp, root);
422
- return cp;
423
- }
424
-
425
- /**
426
- * Read the checkpoint + all artifact references for resume.
427
- * Returns null if the run doesn't exist.
384
+ * Read a legacy checkpoint (state.json) + artifact references for resume.
385
+ * Returns null if the run has no checkpoint — the write-side checkpoint API
386
+ * was removed with the pipeline; this only reads what older runs left behind.
428
387
  */
429
388
  export function scratchpad_resume(runId: string, projectRoot?: string): ScratchpadResume | null {
430
389
  const root = projectRoot ?? detectWorkspaceRoot();
431
390
  const cp = readCheckpointRaw(runId, root);
432
391
  if (!cp) return null;
433
392
 
434
- // Find the next phase: the first phase in order not in completed_phases.
435
- const next = PHASE_ORDER.find((p) => !cp.completed_phases.includes(p)) ?? null;
436
-
437
393
  // Gather artifact listing per completed phase.
438
394
  const artifacts: Record<string, string[]> = {};
439
395
  for (const phase of cp.completed_phases) {
440
396
  artifacts[phase] = scratchpad_list(runId, phase, root);
441
397
  }
442
398
 
443
- return { checkpoint: cp, next_phase: next, artifacts };
444
- }
445
-
446
- /**
447
- * Check whether a phase has already been checkpointed (for idempotent re-run).
448
- */
449
- export function scratchpad_phase_done(
450
- runId: string,
451
- phase: ScratchpadPhase,
452
- projectRoot?: string,
453
- ): boolean {
454
- const cp = readCheckpointRaw(runId, projectRoot);
455
- return cp?.completed_phases.includes(phase) ?? false;
399
+ return { checkpoint: cp, artifacts };
456
400
  }
457
401
 
458
402
  /**
459
- * Clear a specific run's scratchpad directory. Used by `--fresh` for a single
403
+ * Clear a specific run's scratchpad directory a fresh start for that one
460
404
  * run. Does not touch other runs.
461
405
  */
462
406
  export function scratchpad_clear(runId: string, projectRoot?: string): void {