@xaccefy/pi-casefile 0.6.2 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xaccefy/pi-casefile",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Offensive security case tracker for Pi Agent — bug bounties, CTFs, security audits",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -35,6 +35,7 @@
35
35
  "src/ledger.ts",
36
36
  "src/workflow.ts",
37
37
  "src/poc-runner.ts",
38
+ "src/scratchpad.ts",
38
39
  "src/sqlite-compat/index.ts",
39
40
  "skills",
40
41
  "README.md",
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Casefile — offensive security case tracker for Pi.
3
3
  *
4
- * Tools: CaseAdd, CaseUpdate, PromoteFinding, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseReport
4
+ * Tools: CaseAdd, CaseUpdate, PromoteFinding, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseReport, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
5
5
  * Command: /casefile — interactive dashboard
6
6
  * Event: before_agent_start — injects cyber workflow (+ active case list) once per user prompt
7
7
  */
@@ -46,6 +46,17 @@ import {
46
46
  writeCaseReport,
47
47
  } from "./ledger.ts";
48
48
  import { type PocRun, runPoc } from "./poc-runner.ts";
49
+ import {
50
+ type ScratchpadPhase,
51
+ type ScratchpadResume,
52
+ scratchpad_checkpoint,
53
+ scratchpad_clear,
54
+ scratchpad_init,
55
+ scratchpad_phase_done,
56
+ scratchpad_read,
57
+ scratchpad_resume,
58
+ scratchpad_write,
59
+ } from "./scratchpad.ts";
49
60
  import { STATIC_CYBER_WORKFLOW } from "./workflow.ts";
50
61
 
51
62
  // ── Schemas ───────────────────────────────────────────────────────────
@@ -216,6 +227,88 @@ const ReportSchema = Type.Object(
216
227
  { additionalProperties: false },
217
228
  );
218
229
 
230
+ // ── Tool: Scratchpad ─────────────────────────────────────────────────
231
+ //
232
+ // The scratchpad is the pipeline's crash-recoverable artifact store.
233
+ // The casefile owns state transitions; the scratchpad owns artifacts
234
+ // (recon maps, trace outputs, verification logs). Resume re-reads
235
+ // artifacts; it does not re-run completed phases (idempotent).
236
+
237
+ const SCRATCHPAD_PHASES = [
238
+ "recon",
239
+ "hunt",
240
+ "gapfil",
241
+ "trace",
242
+ "skeptic",
243
+ "validate",
244
+ "chain",
245
+ "patch",
246
+ "report",
247
+ ] as const;
248
+
249
+ const ScratchpadPhaseSchema = Type.String({
250
+ enum: [...SCRATCHPAD_PHASES],
251
+ description: "Pipeline phase: recon | hunt | gapfil | trace | skeptic | validate | chain | patch | report",
252
+ });
253
+
254
+ const ScratchpadInitSchema = Type.Object(
255
+ {
256
+ run_id: Type.String({ description: "Unique run identifier for this pipeline run" }),
257
+ },
258
+ { additionalProperties: false },
259
+ );
260
+
261
+ const ScratchpadResumeSchema = Type.Object(
262
+ {
263
+ run_id: Type.String({ description: "Run identifier to resume" }),
264
+ },
265
+ { additionalProperties: false },
266
+ );
267
+
268
+ const ScratchpadCheckpointSchema = Type.Object(
269
+ {
270
+ run_id: Type.String({ description: "Run identifier" }),
271
+ phase: ScratchpadPhaseSchema,
272
+ ids: Type.Optional(Type.Array(Type.String(), { description: "Key IDs produced by this phase (case IDs, finding IDs)" })),
273
+ summary: Type.Optional(Type.String({ description: "One-line summary of phase completion" })),
274
+ },
275
+ { additionalProperties: false },
276
+ );
277
+
278
+ const ScratchpadWriteSchema = Type.Object(
279
+ {
280
+ run_id: Type.String({ description: "Run identifier" }),
281
+ phase: ScratchpadPhaseSchema,
282
+ artifact_name: Type.String({ description: "Artifact filename (sanitized; path traversal is blocked)" }),
283
+ content: Type.String({ description: "Artifact content to write" }),
284
+ },
285
+ { additionalProperties: false },
286
+ );
287
+
288
+ const ScratchpadReadSchema = Type.Object(
289
+ {
290
+ run_id: Type.String({ description: "Run identifier" }),
291
+ phase: ScratchpadPhaseSchema,
292
+ artifact_name: Type.String({ description: "Artifact filename to read" }),
293
+ },
294
+ { additionalProperties: false },
295
+ );
296
+
297
+ const ScratchpadPhaseDoneSchema = Type.Object(
298
+ {
299
+ run_id: Type.String({ description: "Run identifier" }),
300
+ phase: ScratchpadPhaseSchema,
301
+ },
302
+ { additionalProperties: false },
303
+ );
304
+
305
+ const ScratchpadClearSchema = Type.Object(
306
+ {
307
+ run_id: Type.String({ description: "Run identifier to clear (deletes that run only)" }),
308
+ },
309
+ { additionalProperties: false },
310
+ );
311
+
219
312
  interface Theme {
220
313
  fg(color: string, text: string): string;
221
314
  bold(text: string): string;
@@ -1066,6 +1159,366 @@ export default function casefileExtension(pi: ExtensionAPI) {
1066
1159
  },
1067
1160
  });
1068
1161
 
1162
+ // ── Tool: ScratchpadInit ──
1163
+
1164
+ pi.registerTool({
1165
+ name: "ScratchpadInit",
1166
+ label: "Init Scratchpad",
1167
+ description:
1168
+ "Initialize a crash-recoverable artifact store for a pipeline run. Creates the directory structure and an initial state.json checkpoint. Idempotent — safe to call on resume without --fresh; returns the existing checkpoint if the run already exists.",
1169
+ promptSnippet: "Initialize the pipeline artifact store for a run",
1170
+ promptGuidelines: [
1171
+ "Call ScratchpadInit once at the start of a pipeline run (or on resume before ScratchpadResume).",
1172
+ "The run_id is arbitrary but should be unique per pipeline run — typically <target>-<timestamp>.",
1173
+ "On resume, ScratchpadInit returns the existing checkpoint without wiping it; pair with ScratchpadResume to skip completed phases.",
1174
+ ],
1175
+ parameters: ScratchpadInitSchema,
1176
+
1177
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1178
+ const cp = scratchpad_init(params.run_id as string);
1179
+ return {
1180
+ content: [
1181
+ {
1182
+ type: "text",
1183
+ text: `Scratchpad initialized for run ${cp.run_id}.\nCompleted phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}\nDone: ${cp.done}`,
1184
+ },
1185
+ ],
1186
+ details: { checkpoint: cp },
1187
+ };
1188
+ },
1189
+
1190
+ renderCall(args, theme) {
1191
+ return new Text(
1192
+ theme.fg("toolTitle", theme.bold("ScratchpadInit ")) +
1193
+ theme.fg("dim", (args.run_id as string) ?? ""),
1194
+ 0,
1195
+ 0,
1196
+ );
1197
+ },
1198
+
1199
+ renderResult(result, _opts, theme) {
1200
+ const cp = (result.details as { checkpoint: { run_id: string; done: boolean } } | undefined)?.checkpoint;
1201
+ return new Text(
1202
+ theme.fg("success", "✓ ") +
1203
+ `ScratchpadInit ${cp?.run_id ?? ""}${cp?.done ? " (done)" : ""}`,
1204
+ 0,
1205
+ 0,
1206
+ );
1207
+ },
1208
+ });
1209
+
1210
+ // ── Tool: ScratchpadResume ──
1211
+
1212
+ pi.registerTool({
1213
+ name: "ScratchpadResume",
1214
+ label: "Resume Scratchpad",
1215
+ description:
1216
+ "Read the checkpoint and artifact listing for a pipeline run to decide where to resume. Returns the next phase to run (or null if done) and which phases already completed. Returns null if the run does not exist.",
1217
+ promptSnippet: "Check pipeline resume state — which phases are done",
1218
+ promptGuidelines: [
1219
+ "Call ScratchpadResume at pipeline start to determine where to resume. If it returns a checkpoint, skip completed phases (check ScratchpadPhaseDone before each dispatch) and continue from next_phase.",
1220
+ "If ScratchpadResume returns null, the run has no checkpoint — call ScratchpadInit to start fresh.",
1221
+ "Use ScratchpadPhaseDone before dispatching each stage to avoid re-running completed phases (idempotent resume).",
1222
+ ],
1223
+ parameters: ScratchpadResumeSchema,
1224
+
1225
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1226
+ const resume = scratchpad_resume(params.run_id as string);
1227
+ if (!resume) {
1228
+ return {
1229
+ content: [
1230
+ {
1231
+ type: "text",
1232
+ text: `No scratchpad found for run ${params.run_id}. Call ScratchpadInit to start a new run.`,
1233
+ },
1234
+ ],
1235
+ details: { resume: null },
1236
+ };
1237
+ }
1238
+ const cp = resume.checkpoint;
1239
+ return {
1240
+ content: [
1241
+ {
1242
+ type: "text",
1243
+ text:
1244
+ `Resume run ${cp.run_id}:\n` +
1245
+ `Completed phases: ${cp.completed_phases.length ? cp.completed_phases.join(", ") : "none"}\n` +
1246
+ `Next phase: ${resume.next_phase ?? "none (run is done)"}\n` +
1247
+ `Done: ${cp.done}`,
1248
+ },
1249
+ ],
1250
+ details: { resume },
1251
+ };
1252
+ },
1253
+
1254
+ renderCall(args, theme) {
1255
+ return new Text(
1256
+ theme.fg("toolTitle", theme.bold("ScratchpadResume ")) +
1257
+ theme.fg("dim", (args.run_id as string) ?? ""),
1258
+ 0,
1259
+ 0,
1260
+ );
1261
+ },
1262
+
1263
+ renderResult(result, _opts, theme) {
1264
+ const resume = (result.details as { resume: ScratchpadResume | null } | undefined)?.resume;
1265
+ if (!resume) return new Text(theme.fg("warning", "↷ ScratchpadResume — no run found"), 0, 0);
1266
+ return new Text(
1267
+ theme.fg("success", "✓ ") +
1268
+ `ScratchpadResume ${resume.checkpoint.run_id} → next: ${resume.next_phase ?? "done"}`,
1269
+ 0,
1270
+ 0,
1271
+ );
1272
+ },
1273
+ });
1274
+
1275
+ // ── Tool: ScratchpadCheckpoint ──
1276
+
1277
+ pi.registerTool({
1278
+ name: "ScratchpadCheckpoint",
1279
+ label: "Checkpoint Phase",
1280
+ description:
1281
+ "Mark a pipeline phase as complete in the scratchpad state.json. Records the completion timestamp, key IDs, and an optional summary. Idempotent — re-checkpointing a phase overwrites its summary/IDs without duplicating the completed_phases entry.",
1282
+ promptSnippet: "Record a pipeline phase as complete",
1283
+ promptGuidelines: [
1284
+ "Call ScratchpadCheckpoint after every phase completes: ScratchpadCheckpoint(run_id, phase, { ids, summary }).",
1285
+ "ids are the key case/finding IDs the phase produced — used by resume to reconstruct state.",
1286
+ "Keep completed_phases in pipeline order; the checkpoint sorts automatically.",
1287
+ ],
1288
+ parameters: ScratchpadCheckpointSchema,
1289
+
1290
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1291
+ const cp = scratchpad_checkpoint(
1292
+ params.run_id as string,
1293
+ params.phase as ScratchpadPhase,
1294
+ { ids: params.ids as string[] | undefined, summary: params.summary as string | undefined },
1295
+ );
1296
+ return {
1297
+ content: [
1298
+ {
1299
+ type: "text",
1300
+ text:
1301
+ `Phase ${params.phase} checkpointed for run ${cp.run_id}.\n` +
1302
+ `Completed phases: ${cp.completed_phases.join(", ")}`,
1303
+ },
1304
+ ],
1305
+ details: { checkpoint: cp },
1306
+ };
1307
+ },
1308
+
1309
+ renderCall(args, theme) {
1310
+ return new Text(
1311
+ theme.fg("toolTitle", theme.bold("ScratchpadCheckpoint ")) +
1312
+ theme.fg("dim", `${args.run_id ?? ""} ${args.phase ?? ""}`),
1313
+ 0,
1314
+ 0,
1315
+ );
1316
+ },
1317
+
1318
+ renderResult(result, _opts, theme) {
1319
+ const cp = (result.details as { checkpoint: { run_id: string; completed_phases: string[] } } | undefined)?.checkpoint;
1320
+ return new Text(
1321
+ theme.fg("success", "✓ ") +
1322
+ `ScratchpadCheckpoint ${cp?.run_id ?? ""} — ${cp?.completed_phases.length ?? 0} phases done`,
1323
+ 0,
1324
+ 0,
1325
+ );
1326
+ },
1327
+ });
1328
+
1329
+ // ── Tool: ScratchpadWrite ──
1330
+
1331
+ pi.registerTool({
1332
+ name: "ScratchpadWrite",
1333
+ label: "Write Artifact",
1334
+ description:
1335
+ "Write an intermediate artifact (recon map, trace output, verification log) to a phase's subdirectory in the scratchpad. Overwrites if the name exists. Artifact names are sanitized — path traversal is blocked.",
1336
+ promptSnippet: "Save a pipeline artifact to the scratchpad",
1337
+ promptGuidelines: [
1338
+ "Agents write artifacts to the scratchpad, not to each other's output files (prevents an echo chamber).",
1339
+ "The casefile owns state transitions; the scratchpad owns artifacts. Use ScratchpadWrite for bulky intermediate outputs, not CaseUpdate.",
1340
+ ],
1341
+ parameters: ScratchpadWriteSchema,
1342
+
1343
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1344
+ const path = scratchpad_write(
1345
+ params.run_id as string,
1346
+ params.phase as ScratchpadPhase,
1347
+ params.artifact_name as string,
1348
+ params.content as string,
1349
+ );
1350
+ return {
1351
+ content: [
1352
+ {
1353
+ type: "text",
1354
+ text: `Artifact written: ${params.artifact_name} → ${path}`,
1355
+ },
1356
+ ],
1357
+ details: { path, artifact_name: params.artifact_name },
1358
+ };
1359
+ },
1360
+
1361
+ renderCall(args, theme) {
1362
+ return new Text(
1363
+ theme.fg("toolTitle", theme.bold("ScratchpadWrite ")) +
1364
+ theme.fg("dim", `${args.run_id ?? ""}/${args.phase ?? ""}/${args.artifact_name ?? ""}`),
1365
+ 0,
1366
+ 0,
1367
+ );
1368
+ },
1369
+
1370
+ renderResult(result, _opts, theme) {
1371
+ const name = (result.details as { artifact_name?: string } | undefined)?.artifact_name;
1372
+ return new Text(theme.fg("success", `✓ ScratchpadWrite ${name ?? ""}`), 0, 0);
1373
+ },
1374
+ });
1375
+
1376
+ // ── Tool: ScratchpadRead ──
1377
+
1378
+ pi.registerTool({
1379
+ name: "ScratchpadRead",
1380
+ label: "Read Artifact",
1381
+ description:
1382
+ "Read an artifact from a phase's subdirectory in the scratchpad. Returns null if the artifact is missing. Use to resume a phase from a prior run's intermediate output.",
1383
+ promptSnippet: "Read a pipeline artifact from the scratchpad",
1384
+ promptGuidelines: [
1385
+ "On resume, ScratchpadRead retrieves a prior phase's intermediate output so the next phase can proceed without re-running it.",
1386
+ "Returns null for missing artifacts — treat as 'not yet produced' rather than an error.",
1387
+ ],
1388
+ parameters: ScratchpadReadSchema,
1389
+
1390
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1391
+ const content = scratchpad_read(
1392
+ params.run_id as string,
1393
+ params.phase as ScratchpadPhase,
1394
+ params.artifact_name as string,
1395
+ );
1396
+ if (content === null) {
1397
+ return {
1398
+ content: [
1399
+ {
1400
+ type: "text",
1401
+ text: `Artifact not found: ${params.artifact_name} in ${params.phase}/`,
1402
+ },
1403
+ ],
1404
+ details: { artifact_name: params.artifact_name, found: false },
1405
+ };
1406
+ }
1407
+ return {
1408
+ content: [{ type: "text", text: content }],
1409
+ details: { artifact_name: params.artifact_name, found: true, length: content.length },
1410
+ };
1411
+ },
1412
+
1413
+ renderCall(args, theme) {
1414
+ return new Text(
1415
+ theme.fg("toolTitle", theme.bold("ScratchpadRead ")) +
1416
+ theme.fg("dim", `${args.run_id ?? ""}/${args.phase ?? ""}/${args.artifact_name ?? ""}`),
1417
+ 0,
1418
+ 0,
1419
+ );
1420
+ },
1421
+
1422
+ renderResult(result, _opts, theme) {
1423
+ const found = (result.details as { found?: boolean } | undefined)?.found;
1424
+ return new Text(
1425
+ found ? theme.fg("success", "✓ ScratchpadRead") : theme.fg("warning", "↷ ScratchpadRead — not found"),
1426
+ 0,
1427
+ 0,
1428
+ );
1429
+ },
1430
+ });
1431
+
1432
+ // ── Tool: ScratchpadPhaseDone ──
1433
+
1434
+ pi.registerTool({
1435
+ name: "ScratchpadPhaseDone",
1436
+ label: "Phase Done?",
1437
+ description:
1438
+ "Check whether a phase has already been checkpointed in the scratchpad — for idempotent re-run. Returns true if the phase is complete; skip re-dispatching it on resume.",
1439
+ promptSnippet: "Check if a pipeline phase is already complete",
1440
+ promptGuidelines: [
1441
+ "Call ScratchpadPhaseDone before dispatching each stage to avoid re-running completed phases on resume.",
1442
+ "A completed phase with a checkpoint is a no-op on re-run — skip it and continue to the next incomplete phase.",
1443
+ ],
1444
+ parameters: ScratchpadPhaseDoneSchema,
1445
+
1446
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1447
+ const done = scratchpad_phase_done(
1448
+ params.run_id as string,
1449
+ params.phase as ScratchpadPhase,
1450
+ );
1451
+ return {
1452
+ content: [
1453
+ {
1454
+ type: "text",
1455
+ text: `Phase ${params.phase} for run ${params.run_id}: ${done ? "DONE (skip on resume)" : "not done"}`,
1456
+ },
1457
+ ],
1458
+ details: { phase: params.phase, done },
1459
+ };
1460
+ },
1461
+
1462
+ renderCall(args, theme) {
1463
+ return new Text(
1464
+ theme.fg("toolTitle", theme.bold("ScratchpadPhaseDone ")) +
1465
+ theme.fg("dim", `${args.run_id ?? ""} ${args.phase ?? ""}`),
1466
+ 0,
1467
+ 0,
1468
+ );
1469
+ },
1470
+
1471
+ renderResult(result, _opts, theme) {
1472
+ const done = (result.details as { done?: boolean } | undefined)?.done;
1473
+ return new Text(
1474
+ done ? theme.fg("success", "✓ ScratchpadPhaseDone — done") : theme.fg("warning", "↷ ScratchpadPhaseDone — not done"),
1475
+ 0,
1476
+ 0,
1477
+ );
1478
+ },
1479
+ });
1480
+
1481
+ // ── Tool: ScratchpadClear ──
1482
+
1483
+ pi.registerTool({
1484
+ name: "ScratchpadClear",
1485
+ label: "Clear Run",
1486
+ description:
1487
+ "Clear a single pipeline run's scratchpad directory. Used by --fresh for one run. Does not touch other runs. The run must be re-initialized with ScratchpadInit afterward.",
1488
+ promptSnippet: "Clear one pipeline run's artifacts",
1489
+ promptGuidelines: [
1490
+ "Use ScratchpadClear to force a fresh start for a single run (--fresh). It deletes that run's directory only.",
1491
+ "After clearing, call ScratchpadInit to recreate the directory structure before writing artifacts.",
1492
+ ],
1493
+ parameters: ScratchpadClearSchema,
1494
+
1495
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1496
+ scratchpad_clear(params.run_id as string);
1497
+ return {
1498
+ content: [
1499
+ {
1500
+ type: "text",
1501
+ text: `Scratchpad cleared for run ${params.run_id}. Call ScratchpadInit to start a new run.`,
1502
+ },
1503
+ ],
1504
+ details: { run_id: params.run_id, cleared: true },
1505
+ };
1506
+ },
1507
+
1508
+ renderCall(args, theme) {
1509
+ return new Text(
1510
+ theme.fg("toolTitle", theme.bold("ScratchpadClear ")) +
1511
+ theme.fg("dim", (args.run_id as string) ?? ""),
1512
+ 0,
1513
+ 0,
1514
+ );
1515
+ },
1516
+
1517
+ renderResult(_result, _opts, theme) {
1518
+ return new Text(theme.fg("success", "✓ ScratchpadClear"), 0, 0);
1519
+ },
1520
+ });
1521
+
1069
1522
  // ── Command: /casefile ──
1070
1523
 
1071
1524
  pi.registerCommand("casefile", {
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Scratchpad — intermediate artifact store for pipeline runs.
3
+ *
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).
8
+ *
9
+ * Directory layout per pipeline run:
10
+ * {project_root}/.scratchpad/{run_id}/
11
+ * recon/ — fingerprints, tech detection, surface maps
12
+ * trace/ — per-finding reachability traces
13
+ * verify/ — PoC logs, run outputs
14
+ * state.json — checkpoint file with phase completion + key IDs
15
+ *
16
+ * Resume re-reads scratchpad artifacts; it does not re-run completed phases
17
+ * (idempotent). The `.scratchpad/` directory is preserved between runs;
18
+ * `--fresh` clears it via scratchpad_clear().
19
+ */
20
+
21
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
+ import { dirname, join, resolve } from "node:path";
23
+
24
+ // ── Types ────────────────────────────────────────────────────────────
25
+
26
+ export type ScratchpadPhase =
27
+ | "recon"
28
+ | "hunt"
29
+ | "gapfil"
30
+ | "trace"
31
+ | "skeptic"
32
+ | "validate"
33
+ | "chain"
34
+ | "patch"
35
+ | "report";
36
+
37
+ export interface ScratchpadCheckpoint {
38
+ run_id: string;
39
+ project_root: string;
40
+ created_at: string;
41
+ last_updated: string;
42
+ /** Ordered list of phases that have completed (in pipeline order). */
43
+ completed_phases: ScratchpadPhase[];
44
+ /** ISO timestamp of the last phase completion. */
45
+ last_phase_at: string | null;
46
+ /** Key IDs produced by each phase — case IDs, finding IDs, etc. */
47
+ phase_ids: Record<ScratchpadPhase, string[]>;
48
+ /** Free-form summary per phase, set by checkpoint(). */
49
+ phase_summaries: Record<ScratchpadPhase, string>;
50
+ /** Whether the run is fully complete. */
51
+ done: boolean;
52
+ }
53
+
54
+ export interface ScratchpadResume {
55
+ checkpoint: ScratchpadCheckpoint;
56
+ /** The next phase to run (or null if the run is done). */
57
+ next_phase: ScratchpadPhase | null;
58
+ /** Artifact references per phase: { trace: ["finding-abc.json", ...], ... } */
59
+ artifacts: Record<string, string[]>;
60
+ }
61
+
62
+ // ── Constants ────────────────────────────────────────────────────────
63
+
64
+ const PHASE_ORDER: ScratchpadPhase[] = [
65
+ "recon",
66
+ "hunt",
67
+ "gapfil",
68
+ "trace",
69
+ "skeptic",
70
+ "validate",
71
+ "chain",
72
+ "patch",
73
+ "report",
74
+ ];
75
+
76
+ const PHASE_DIRS: Record<ScratchpadPhase, string> = {
77
+ recon: "recon",
78
+ hunt: "hunt",
79
+ gapfil: "gapfil",
80
+ trace: "trace",
81
+ skeptic: "skeptic",
82
+ validate: "verify",
83
+ chain: "chain",
84
+ patch: "patch",
85
+ report: "report",
86
+ };
87
+
88
+ const SCRATCHPAD_DIR = ".scratchpad";
89
+
90
+ // ── Helpers ──────────────────────────────────────────────────────────
91
+
92
+ let scratchpadRootOverride: string | undefined;
93
+
94
+ /**
95
+ * Detect the workspace root by walking up for a .git dir or package.json,
96
+ * matching the ledger's detectWorkspaceRoot() heuristic.
97
+ */
98
+ function detectWorkspaceRoot(): string {
99
+ if (scratchpadRootOverride) return scratchpadRootOverride;
100
+
101
+ const envs = ["XPI_SCRATCHPAD_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE", "PWD"];
102
+ for (const e of envs) {
103
+ const v = process.env[e];
104
+ if (v) return resolve(v);
105
+ }
106
+
107
+ let curr = resolve(process.cwd());
108
+ for (let i = 0; i < 20; i++) {
109
+ if (existsSync(join(curr, ".git")) || existsSync(join(curr, "package.json"))) return curr;
110
+ const parent = dirname(curr);
111
+ if (parent === curr) break;
112
+ curr = parent;
113
+ }
114
+ return resolve(process.cwd());
115
+ }
116
+
117
+ /** Override the scratchpad root (for testing). Pass undefined to reset. */
118
+ export function setScratchpadRoot(path: string | undefined): void {
119
+ scratchpadRootOverride = path ? resolve(path) : undefined;
120
+ }
121
+
122
+ /** The top-level scratchpad directory for a given project root. */
123
+ export function getScratchpadRoot(projectRoot?: string): string {
124
+ const root = projectRoot ?? detectWorkspaceRoot();
125
+ return join(root, SCRATCHPAD_DIR);
126
+ }
127
+
128
+ /** The directory for a specific run. */
129
+ export function getRunDir(runId: string, projectRoot?: string): string {
130
+ return join(getScratchpadRoot(projectRoot), runId);
131
+ }
132
+
133
+ /** The state.json path for a run. */
134
+ export function getStatePath(runId: string, projectRoot?: string): string {
135
+ return join(getRunDir(runId, projectRoot), "state.json");
136
+ }
137
+
138
+ function emptyCheckpoint(runId: string, projectRoot: string): ScratchpadCheckpoint {
139
+ const now = new Date().toISOString();
140
+ return {
141
+ run_id: runId,
142
+ project_root: projectRoot,
143
+ created_at: now,
144
+ last_updated: now,
145
+ last_phase_at: null,
146
+ completed_phases: [],
147
+ phase_ids: {} as Record<ScratchpadPhase, string[]>,
148
+ phase_summaries: {} as Record<ScratchpadPhase, string>,
149
+ done: false,
150
+ };
151
+ }
152
+
153
+ function ensureRunDirs(runDir: string): void {
154
+ if (!existsSync(runDir)) mkdirSync(runDir, { recursive: true });
155
+ for (const phase of PHASE_ORDER) {
156
+ const dir = join(runDir, PHASE_DIRS[phase]);
157
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
158
+ }
159
+ }
160
+
161
+ function readCheckpointRaw(runId: string, projectRoot?: string): ScratchpadCheckpoint | null {
162
+ const statePath = getStatePath(runId, projectRoot);
163
+ if (!existsSync(statePath)) return null;
164
+ try {
165
+ const raw = readFileSync(statePath, "utf8");
166
+ const cp = JSON.parse(raw) as ScratchpadCheckpoint;
167
+ // Backfill maps for phases not yet checkpointed (defensive).
168
+ if (!cp.phase_ids) cp.phase_ids = {} as Record<ScratchpadPhase, string[]>;
169
+ if (!cp.phase_summaries) cp.phase_summaries = {} as Record<ScratchpadPhase, string>;
170
+ return cp;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ function writeCheckpointRaw(cp: ScratchpadCheckpoint, projectRoot?: string): void {
177
+ cp.last_updated = new Date().toISOString();
178
+ const statePath = getStatePath(cp.run_id, projectRoot);
179
+ ensureRunDirs(getRunDir(cp.run_id, projectRoot));
180
+ writeFileSync(statePath, JSON.stringify(cp, null, 2), "utf8");
181
+ }
182
+
183
+ // ── Public API ───────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Initialize a new scratchpad run. Creates the directory structure and writes
187
+ * an initial state.json. If the run already exists, returns the existing
188
+ * checkpoint (idempotent — safe to call on resume without --fresh).
189
+ */
190
+ export function scratchpad_init(runId: string, projectRoot?: string): ScratchpadCheckpoint {
191
+ const root = projectRoot ?? detectWorkspaceRoot();
192
+ const runDir = getRunDir(runId, root);
193
+ ensureRunDirs(runDir);
194
+
195
+ const existing = readCheckpointRaw(runId, root);
196
+ if (existing) return existing;
197
+
198
+ const cp = emptyCheckpoint(runId, root);
199
+ writeCheckpointRaw(cp, root);
200
+ return cp;
201
+ }
202
+
203
+ /**
204
+ * Write an artifact to a phase's subdirectory. Overwrites if the name exists.
205
+ * Returns the full path to the written artifact.
206
+ */
207
+ export function scratchpad_write(
208
+ runId: string,
209
+ phase: ScratchpadPhase,
210
+ artifactName: string,
211
+ content: string,
212
+ projectRoot?: string,
213
+ ): string {
214
+ const root = projectRoot ?? detectWorkspaceRoot();
215
+ const runDir = getRunDir(runId, root);
216
+ ensureRunDirs(runDir);
217
+
218
+ // Sanitize artifact name: no path traversal.
219
+ const safeName = artifactName.replace(/[^a-zA-Z0-9._-]/g, "_");
220
+ const dir = join(runDir, PHASE_DIRS[phase]);
221
+ const filePath = join(dir, safeName);
222
+ writeFileSync(filePath, content, "utf8");
223
+ return filePath;
224
+ }
225
+
226
+ /**
227
+ * Read an artifact. Returns null if missing.
228
+ */
229
+ export function scratchpad_read(
230
+ runId: string,
231
+ phase: ScratchpadPhase,
232
+ artifactName: string,
233
+ projectRoot?: string,
234
+ ): string | null {
235
+ const root = projectRoot ?? detectWorkspaceRoot();
236
+ const safeName = artifactName.replace(/[^a-zA-Z0-9._-]/g, "_");
237
+ const filePath = join(getRunDir(runId, root), PHASE_DIRS[phase], safeName);
238
+ if (!existsSync(filePath)) return null;
239
+ return readFileSync(filePath, "utf8");
240
+ }
241
+
242
+ /**
243
+ * List all artifacts written for a phase.
244
+ */
245
+ export function scratchpad_list(
246
+ runId: string,
247
+ phase: ScratchpadPhase,
248
+ projectRoot?: string,
249
+ ): string[] {
250
+ const root = projectRoot ?? detectWorkspaceRoot();
251
+ const dir = join(getRunDir(runId, root), PHASE_DIRS[phase]);
252
+ if (!existsSync(dir)) return [];
253
+ return readdirSync(dir).filter((f) => f !== "state.json");
254
+ }
255
+
256
+ /**
257
+ * Mark a phase as complete. Records the completion timestamp, key IDs, and an
258
+ * optional summary in state.json. Idempotent: re-checkpointing a phase
259
+ * overwrites its previous summary/IDs but does not duplicate the entry in
260
+ * completed_phases.
261
+ */
262
+ export function scratchpad_checkpoint(
263
+ runId: string,
264
+ phase: ScratchpadPhase,
265
+ data: { ids?: string[]; summary?: string },
266
+ projectRoot?: string,
267
+ ): ScratchpadCheckpoint {
268
+ const root = projectRoot ?? detectWorkspaceRoot();
269
+ const cp = readCheckpointRaw(runId, root) ?? scratchpad_init(runId, root);
270
+
271
+ if (!cp.completed_phases.includes(phase)) {
272
+ cp.completed_phases.push(phase);
273
+ // Keep completed_phases in pipeline order for predictable resume.
274
+ cp.completed_phases.sort((a, b) => PHASE_ORDER.indexOf(a) - PHASE_ORDER.indexOf(b));
275
+ }
276
+ cp.last_phase_at = new Date().toISOString();
277
+ if (data.ids) cp.phase_ids[phase] = data.ids;
278
+ if (data.summary) cp.phase_summaries[phase] = data.summary;
279
+
280
+ writeCheckpointRaw(cp, root);
281
+ return cp;
282
+ }
283
+
284
+ /**
285
+ * Read the checkpoint + all artifact references for resume.
286
+ * Returns null if the run doesn't exist.
287
+ */
288
+ export function scratchpad_resume(runId: string, projectRoot?: string): ScratchpadResume | null {
289
+ const root = projectRoot ?? detectWorkspaceRoot();
290
+ const cp = readCheckpointRaw(runId, root);
291
+ if (!cp) return null;
292
+
293
+ // Find the next phase: the first phase in order not in completed_phases.
294
+ const next = PHASE_ORDER.find((p) => !cp.completed_phases.includes(p)) ?? null;
295
+
296
+ // Gather artifact listing per completed phase.
297
+ const artifacts: Record<string, string[]> = {};
298
+ for (const phase of cp.completed_phases) {
299
+ artifacts[phase] = scratchpad_list(runId, phase, root);
300
+ }
301
+
302
+ return { checkpoint: cp, next_phase: next, artifacts };
303
+ }
304
+
305
+ /**
306
+ * Check whether a phase has already been checkpointed (for idempotent re-run).
307
+ */
308
+ export function scratchpad_phase_done(
309
+ runId: string,
310
+ phase: ScratchpadPhase,
311
+ projectRoot?: string,
312
+ ): boolean {
313
+ const cp = readCheckpointRaw(runId, projectRoot);
314
+ return cp?.completed_phases.includes(phase) ?? false;
315
+ }
316
+
317
+ /**
318
+ * Clear a specific run's scratchpad directory. Used by `--fresh` for a single
319
+ * run. Does not touch other runs.
320
+ */
321
+ export function scratchpad_clear(runId: string, projectRoot?: string): void {
322
+ const root = projectRoot ?? detectWorkspaceRoot();
323
+ const runDir = getRunDir(runId, root);
324
+ if (existsSync(runDir)) rmSync(runDir, { recursive: true, force: true });
325
+ }
326
+
327
+ /**
328
+ * Clear the entire scratchpad directory (all runs). Used by `--fresh` with no
329
+ * run ID. Use with care.
330
+ */
331
+ export function scratchpad_clear_all(projectRoot?: string): void {
332
+ const root = projectRoot ?? detectWorkspaceRoot();
333
+ const dir = getScratchpadRoot(root);
334
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
335
+ }
336
+
337
+ /**
338
+ * List all run IDs in the scratchpad (for resume selection).
339
+ */
340
+ export function scratchpad_list_runs(projectRoot?: string): string[] {
341
+ const root = projectRoot ?? detectWorkspaceRoot();
342
+ const dir = getScratchpadRoot(root);
343
+ if (!existsSync(dir)) return [];
344
+ return readdirSync(dir, { withFileTypes: true })
345
+ .filter((e) => e.isDirectory())
346
+ .map((e) => e.name)
347
+ .sort();
348
+ }
349
+
350
+ /**
351
+ * Mark the run as fully done. Prevents resume from re-entering.
352
+ */
353
+ export function scratchpad_finish(runId: string, projectRoot?: string): ScratchpadCheckpoint {
354
+ const root = projectRoot ?? detectWorkspaceRoot();
355
+ const cp = readCheckpointRaw(runId, root) ?? scratchpad_init(runId, root);
356
+ cp.done = true;
357
+ writeCheckpointRaw(cp, root);
358
+ return cp;
359
+ }
package/src/workflow.ts CHANGED
@@ -147,6 +147,8 @@ is considered disproven and promotion is blocked. If you cannot write a
147
147
  meaningful disconfirmation script, you may not understand the finding well
148
148
  enough to promote it.
149
149
 
150
+ **Adversarial disconfirmation (skeptic subagent):** For findings at severity >= high, a dedicated skeptic subagent independently re-reads the source and tries to disprove the finding BEFORE the exploit agent runs. The skeptic's \`disconfirmation_attempt\` is written into this \`disconfirmation\` field by the harness — it satisfies this gate and is stronger than self-disconfirmation because a separate agent produced it. If the skeptic says DISPROVEN, the finding is killed directly. Self-disconfirmation still applies for findings below high severity.
151
+
150
152
  ### 2. Production Path Verification (must be in impact field)
151
153
 
152
154
  The **impact** field for CONFIRMED must explicitly answer: