@xaccefy/pi-casefile 0.7.6 → 0.8.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/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, CaseContext, PipelineSubmit, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
4
+ * Tools: CaseAdd, CaseUpdate, PromoteFinding, EvidenceAdd, CoverageAdd, CoverageReport, ChainSuggest, CaseGet, CaseList, CaseSearch, CaseLink, CaseUnlink, CaseContext, PipelineSubmit, ScratchpadInit, ScratchpadResume, ScratchpadCheckpoint, ScratchpadWrite, ScratchpadRead, ScratchpadPhaseDone, ScratchpadClear
5
5
  * Command: /casefile — interactive dashboard
6
6
  * Event: before_agent_start — injects cyber workflow once per session, refreshes the active case list per prompt
7
7
  */
@@ -14,6 +14,7 @@ import { Type } from "@sinclair/typebox";
14
14
 
15
15
  import {
16
16
  addCaseResult,
17
+ addEvidenceItemResult,
17
18
  assertPromotable,
18
19
  type CaseConfidence,
19
20
  type CaseInput,
@@ -24,7 +25,14 @@ import {
24
25
  type CaseStatus,
25
26
  type CaseUpdate,
26
27
  CONFIDENCE_VALUES,
28
+ COVERAGE_SCOPE_VALUES,
29
+ type CoverageItem,
30
+ type CoverageScope,
27
31
  countCases,
32
+ coverageSummary,
33
+ EVIDENCE_ROLE_VALUES,
34
+ type EvidenceItem,
35
+ type EvidenceRole,
28
36
  formatCase,
29
37
  formatCaseDetail,
30
38
  formatCases,
@@ -36,10 +44,12 @@ import {
36
44
  promoteFindingResult,
37
45
  readActiveCases,
38
46
  readCasefile,
47
+ recordCoverageResult,
39
48
  SEARCH_FIELD_VALUES,
40
49
  SEVERITY_VALUES,
41
50
  STATUS_VALUES,
42
51
  searchCases,
52
+ suggestChains,
43
53
  unlinkCasesResult,
44
54
  updateCaseResult,
45
55
  writeCaseContext,
@@ -47,6 +57,7 @@ import {
47
57
  import { pipeline_submit, SUBMIT_STAGES, type SubmitStage } from "./pipeline-submit.ts";
48
58
  import { type PocRun, runPoc } from "./poc-runner.ts";
49
59
  import {
60
+ PHASE_ORDER,
50
61
  type ScratchpadPhase,
51
62
  type ScratchpadResume,
52
63
  scratchpad_checkpoint,
@@ -91,6 +102,12 @@ const CommonFields = {
91
102
  description: "Explicit assumptions, unknowns, or uncertainty notes",
92
103
  }),
93
104
  ),
105
+ disproveIf: Type.Optional(
106
+ Type.Array(Type.String(), {
107
+ description:
108
+ "Falsification conditions — what would disprove this hypothesis (REQUIRED on CaseAdd)",
109
+ }),
110
+ ),
94
111
  };
95
112
 
96
113
  // ── Tool: CaseAdd ─────────────────────────────────────────────────────
@@ -114,6 +131,30 @@ const UpdateSchema = Type.Object(
114
131
  { additionalProperties: false },
115
132
  );
116
133
 
134
+ // ── Tool: EvidenceAdd ────────────────────────────────────────────────
135
+
136
+ const EvidenceAddSchema = Type.Object(
137
+ {
138
+ case_id: Type.String({ description: "Case ID to attach the evidence item to" }),
139
+ role: Type.String({
140
+ enum: [...EVIDENCE_ROLE_VALUES],
141
+ description:
142
+ "Evidence role: observation | reproduction | impact | refutation | cleanup. " +
143
+ "refutation justifies a kill; cleanup tracks engagement cleanup items; " +
144
+ "reproduction is auto-recorded by the PoC gate at promote.",
145
+ }),
146
+ summary: Type.String({ description: "Short summary of this evidence item" }),
147
+ artifact_path: Type.Optional(
148
+ Type.String({
149
+ description:
150
+ "Path to the artifact file on disk. Stored as basename + SHA-256 hash " +
151
+ "(full path is never persisted).",
152
+ }),
153
+ ),
154
+ },
155
+ { additionalProperties: false },
156
+ );
157
+
117
158
  // ── Tool: PromoteFinding ─────────────────────────────────────────────
118
159
 
119
160
  const PromoteSchema = Type.Object(
@@ -133,6 +174,12 @@ const PromoteSchema = Type.Object(
133
174
  "Absolute path to a disconfirmation script that tries to disprove the finding; must exit non-zero (failure to disprove)",
134
175
  }),
135
176
  ),
177
+ control_path: Type.Optional(
178
+ Type.String({
179
+ description:
180
+ "Absolute path to a control-target script: runs the SAME PoC against a control that lacks the vulnerability. The harness checks the control run's output — if the verification_marker appears there, promotion is BLOCKED (the PoC prints the marker without the vulnerable condition). REQUIRED when local:true (live findings).",
181
+ }),
182
+ ),
136
183
  local: Type.Optional(Type.Boolean({ description: "Run locally instead of in Docker sandbox" })),
137
184
  },
138
185
  { additionalProperties: false },
@@ -140,36 +187,35 @@ const PromoteSchema = Type.Object(
140
187
 
141
188
  // ── Tool: CaseGet ─────────────────────────────────────────────────────
142
189
 
143
- const GetSchema = Type.Object(
190
+ /** id-only schema, shared by CaseGet / CaseContext. */
191
+ const IdSchema = Type.Object(
144
192
  {
145
193
  id: Type.String({ description: "Case ID" }),
146
194
  },
147
195
  { additionalProperties: false },
148
196
  );
149
197
 
150
- // ── Tool: CaseList ────────────────────────────────────────────────────
198
+ // ── Tools: CaseList / CaseSearch ──────────────────────────────────────
151
199
 
152
- const ListSchema = Type.Object(
153
- {
154
- status: Type.Optional(CaseStatusSchema),
155
- confidence: Type.Optional(CaseConfidenceSchema),
156
- severity: Type.Optional(CaseSeveritySchema),
157
- minSeverity: Type.Optional(CaseSeveritySchema),
158
- priority: Type.Optional(CasePrioritySchema),
159
- tag: Type.Optional(Type.String({ description: "Filter by tag" })),
160
- since: Type.Optional(
161
- Type.String({ description: "ISO timestamp; only cases created at/after this time" }),
162
- ),
163
- until: Type.Optional(
164
- Type.String({ description: "ISO timestamp; only cases created at/before this time" }),
165
- ),
166
- limit: Type.Optional(Type.Number({ description: "Max results (default 50)" })),
167
- offset: Type.Optional(Type.Number({ description: "Skip N results for pagination" })),
168
- },
169
- { additionalProperties: false },
170
- );
200
+ /** Structured filter fields, shared by the list and search schemas. */
201
+ const FILTER_FIELDS = {
202
+ status: Type.Optional(CaseStatusSchema),
203
+ confidence: Type.Optional(CaseConfidenceSchema),
204
+ severity: Type.Optional(CaseSeveritySchema),
205
+ minSeverity: Type.Optional(CaseSeveritySchema),
206
+ priority: Type.Optional(CasePrioritySchema),
207
+ tag: Type.Optional(Type.String({ description: "Filter by tag" })),
208
+ since: Type.Optional(
209
+ Type.String({ description: "ISO timestamp; only cases created at/after this time" }),
210
+ ),
211
+ until: Type.Optional(
212
+ Type.String({ description: "ISO timestamp; only cases created at/before this time" }),
213
+ ),
214
+ limit: Type.Optional(Type.Number({ description: "Max results (default 50)" })),
215
+ offset: Type.Optional(Type.Number({ description: "Skip N results for pagination" })),
216
+ };
171
217
 
172
- // ── Tool: CaseSearch ──────────────────────────────────────────────────
218
+ const ListSchema = Type.Object({ ...FILTER_FIELDS }, { additionalProperties: false });
173
219
 
174
220
  const SearchSchema = Type.Object(
175
221
  {
@@ -180,20 +226,7 @@ const SearchSchema = Type.Object(
180
226
  description: "Restrict search to a specific field",
181
227
  }),
182
228
  ),
183
- status: Type.Optional(CaseStatusSchema),
184
- confidence: Type.Optional(CaseConfidenceSchema),
185
- severity: Type.Optional(CaseSeveritySchema),
186
- minSeverity: Type.Optional(CaseSeveritySchema),
187
- priority: Type.Optional(CasePrioritySchema),
188
- tag: Type.Optional(Type.String({ description: "Filter by tag" })),
189
- since: Type.Optional(
190
- Type.String({ description: "ISO timestamp; only cases created at/after this time" }),
191
- ),
192
- until: Type.Optional(
193
- Type.String({ description: "ISO timestamp; only cases created at/before this time" }),
194
- ),
195
- limit: Type.Optional(Type.Number()),
196
- offset: Type.Optional(Type.Number()),
229
+ ...FILTER_FIELDS,
197
230
  },
198
231
  { additionalProperties: false },
199
232
  );
@@ -225,13 +258,6 @@ const UnlinkSchema = Type.Object(
225
258
  { additionalProperties: false },
226
259
  );
227
260
 
228
- const ContextSchema = Type.Object(
229
- {
230
- id: Type.String({ description: "Case ID to build the context bundle for" }),
231
- },
232
- { additionalProperties: false },
233
- );
234
-
235
261
  // ── Tool: Scratchpad ─────────────────────────────────────────────────
236
262
  //
237
263
  // The scratchpad is the pipeline's crash-recoverable artifact store.
@@ -239,34 +265,16 @@ const ContextSchema = Type.Object(
239
265
  // (recon maps, trace outputs, verification logs). Resume re-reads
240
266
  // artifacts; it does not re-run completed phases (idempotent).
241
267
 
242
- const SCRATCHPAD_PHASES = [
243
- "recon",
244
- "hunt",
245
- "gapfil",
246
- "trace",
247
- "skeptic",
248
- "validate",
249
- "chain",
250
- "patch",
251
- "report",
252
- ] as const;
253
-
254
268
  const ScratchpadPhaseSchema = Type.String({
255
- enum: [...SCRATCHPAD_PHASES],
269
+ enum: [...PHASE_ORDER],
256
270
  description:
257
271
  "Pipeline phase: recon | hunt | gapfil | trace | skeptic | validate | chain | patch | report",
258
272
  });
259
273
 
260
- const ScratchpadInitSchema = Type.Object(
261
- {
262
- run_id: Type.String({ description: "Unique run identifier for this pipeline run" }),
263
- },
264
- { additionalProperties: false },
265
- );
266
-
267
- const ScratchpadResumeSchema = Type.Object(
274
+ /** run_id-only schema, shared by Scratchpad Init / Resume / Clear. */
275
+ const RunIdSchema = Type.Object(
268
276
  {
269
- run_id: Type.String({ description: "Run identifier to resume" }),
277
+ run_id: Type.String({ description: "Pipeline run identifier" }),
270
278
  },
271
279
  { additionalProperties: false },
272
280
  );
@@ -314,13 +322,6 @@ const ScratchpadPhaseDoneSchema = Type.Object(
314
322
  { additionalProperties: false },
315
323
  );
316
324
 
317
- const ScratchpadClearSchema = Type.Object(
318
- {
319
- run_id: Type.String({ description: "Run identifier to clear (deletes that run only)" }),
320
- },
321
- { additionalProperties: false },
322
- );
323
-
324
325
  interface Theme {
325
326
  fg(color: string, text: string): string;
326
327
  bold(text: string): string;
@@ -392,6 +393,57 @@ function renderCaseResult(
392
393
  return theme.fg(color, prefix) + renderOneLine(details.record, theme);
393
394
  }
394
395
 
396
+ /** One-line tool call header, shared by every tool's renderCall. */
397
+ function callLine(theme: Theme, name: string, detail?: string): Text {
398
+ const title = theme.fg("toolTitle", theme.bold(detail !== undefined ? `${name} ` : name));
399
+ return new Text(title + (detail ? theme.fg("dim", detail) : ""), 0, 0);
400
+ }
401
+
402
+ /** CaseRecord[] page summary, shared by CaseList / CaseSearch renderResult. */
403
+ function renderCasePage(
404
+ result: { details: unknown },
405
+ theme: Theme,
406
+ noun: string,
407
+ expanded: boolean,
408
+ ): Text {
409
+ const details = result.details as { cases?: CaseRecord[]; total?: number } | undefined;
410
+ const total = details?.total ?? 0;
411
+ const cases = details?.cases ?? [];
412
+ let line = theme.fg("success", "✓ ") + theme.fg("muted", `${total} ${noun}`);
413
+ if (expanded && cases.length > 0) {
414
+ line += `\n${cases.map((c) => ` ${renderOneLine(c, theme)}`).join("\n")}`;
415
+ }
416
+ return new Text(line, 0, 0);
417
+ }
418
+
419
+ /** Filtered case query, shared by CaseList / CaseSearch execute. */
420
+ function runCaseQuery(
421
+ params: Record<string, unknown>,
422
+ header: (count: number, total: number, offset: number) => string,
423
+ emptyText: string,
424
+ ) {
425
+ const { cases, total } = searchCases({
426
+ query: params.query as string | undefined,
427
+ field: params.field as CaseSearchField | undefined,
428
+ status: params.status as CaseStatus | undefined,
429
+ confidence: params.confidence as CaseConfidence | undefined,
430
+ severity: params.severity as CaseSeverity | undefined,
431
+ minSeverity: params.minSeverity as CaseSeverity | undefined,
432
+ priority: params.priority as CasePriority | undefined,
433
+ tag: params.tag as string | undefined,
434
+ since: params.since as string | undefined,
435
+ until: params.until as string | undefined,
436
+ limit: params.limit as number | undefined,
437
+ offset: params.offset as number | undefined,
438
+ });
439
+ const offset = (params.offset as number | undefined) ?? 0;
440
+ const body = cases.length > 0 ? formatCases(cases) : emptyText;
441
+ return {
442
+ content: [{ type: "text" as const, text: `${header(cases.length, total, offset)}\n${body}` }],
443
+ details: { cases, total, offset },
444
+ };
445
+ }
446
+
395
447
  // ── Dashboard component ──────────────────────────────────────────────
396
448
 
397
449
  class CasefileDashboard {
@@ -656,6 +708,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
656
708
  promptSnippet: "Record a security finding or hypothesis as a case",
657
709
  promptGuidelines: [
658
710
  "Use CaseAdd for a new security lead. New cases start as status='hypothesis' or 'investigating' — promote later with CaseUpdate.",
711
+ "disproveIf is REQUIRED on CaseAdd: name the falsification conditions (what would disprove this hypothesis). A hypothesis that can't say what kills it isn't a hypothesis yet.",
659
712
  "Check the injected case list or CaseList/CaseSearch first. Do not add a duplicate for the same title/scope.",
660
713
  "CaseAdd rejects exact and NEAR-duplicates (same target + overlapping title, e.g. parallel-subagent re-phrasings). A near-duplicate result → continue the existing case ID via CaseUpdate, don't create a new one.",
661
714
  "confirmed/reported only via their gates: proof in poc + PromoteFinding for confirmed; CaseContext + report for reported.",
@@ -685,12 +738,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
685
738
  },
686
739
 
687
740
  renderCall(args, theme) {
688
- return new Text(
689
- theme.fg("toolTitle", theme.bold("CaseAdd ")) +
690
- theme.fg("muted", (args.title as string) ?? ""),
691
- 0,
692
- 0,
693
- );
741
+ return callLine(theme, "CaseAdd", (args.title as string) ?? "");
694
742
  },
695
743
 
696
744
  renderResult(result, { expanded }, theme) {
@@ -738,12 +786,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
738
786
  },
739
787
 
740
788
  renderCall(args, theme) {
741
- return new Text(
742
- theme.fg("toolTitle", theme.bold("CaseUpdate ")) +
743
- theme.fg("dim", (args.id as string) ?? ""),
744
- 0,
745
- 0,
746
- );
789
+ return callLine(theme, "CaseUpdate", (args.id as string) ?? "");
747
790
  },
748
791
 
749
792
  renderResult(result, { expanded }, theme) {
@@ -764,19 +807,216 @@ export default function casefileExtension(pi: ExtensionAPI) {
764
807
  },
765
808
  });
766
809
 
810
+ // ── Tool: EvidenceAdd ──
811
+
812
+ pi.registerTool({
813
+ name: "EvidenceAdd",
814
+ label: "Add Evidence Item",
815
+ description:
816
+ "Record a role-typed, artifact-backed evidence item on a case (observation, reproduction, impact, refutation, cleanup). Artifact files are stored as basename + SHA-256 — the full path is never persisted. refutation items justify a kill; cleanup items track engagement cleanup before REPORT.",
817
+ promptSnippet: "Record a role-typed evidence item",
818
+ promptGuidelines: [
819
+ "Use EvidenceAdd for artifact-backed evidence: raw responses, logs, screenshots, disproof attempts — anything a claim should trace back to.",
820
+ "role=refutation is the structural justification for a kill (killed without one requires a kill-reason token in assumptions/nextStep).",
821
+ "role=cleanup tracks engagement cleanup items — confirmed before REPORT for sanctioned engagements.",
822
+ "reproduction is auto-recorded by the PromoteFinding gate (the PoC run itself, hashed); you don't add it manually.",
823
+ ],
824
+ parameters: EvidenceAddSchema,
825
+
826
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
827
+ const item = addEvidenceItemResult(params.case_id as string, {
828
+ role: params.role as EvidenceRole,
829
+ summary: params.summary as string,
830
+ artifactPath: params.artifact_path as string | undefined,
831
+ });
832
+ const record = getCaseById(params.case_id as string)!;
833
+ return {
834
+ content: [
835
+ {
836
+ type: "text",
837
+ text: `Evidence item recorded:\n[${item.role}] ${item.summary}${item.artifactPath ? ` — ${item.artifactPath} sha256:${item.sha256?.slice(0, 12)}…` : ""}\n\n${formatCaseDetail(record)}`,
838
+ },
839
+ ],
840
+ details: { item, record },
841
+ };
842
+ },
843
+
844
+ renderCall(args, theme) {
845
+ return callLine(
846
+ theme,
847
+ "EvidenceAdd",
848
+ `${(args.case_id as string) ?? ""} [${(args.role as string) ?? ""}]`,
849
+ );
850
+ },
851
+
852
+ renderResult(result, _opts, theme) {
853
+ const details = result.details as { item?: EvidenceItem } | undefined;
854
+ if (!details?.item) {
855
+ return new Text(theme.fg("error", "✗ EvidenceAdd failed"), 0, 0);
856
+ }
857
+ return new Text(
858
+ theme.fg("success", "✓ ") +
859
+ theme.fg("dim", `[${details.item.role}] `) +
860
+ truncateToWidth(details.item.summary, 60),
861
+ 0,
862
+ 0,
863
+ );
864
+ },
865
+ });
866
+
867
+ // ── Tool: CoverageAdd ──
868
+
869
+ const CoverageAddSchema = Type.Object(
870
+ {
871
+ case_id: Type.String({
872
+ description: "Case ID (the pipeline-run or finding case) to record coverage under",
873
+ }),
874
+ asset: Type.String({
875
+ description:
876
+ "The asset tested — copy it verbatim from the case target where shown. For scope=wide use the deployment-wide identifier.",
877
+ }),
878
+ class: Type.String({
879
+ description:
880
+ "The attack class tested (e.g. sql-injection, xss, idor, ssti, ssrf, auth-bypass, ...).",
881
+ }),
882
+ scope: Type.Union(
883
+ COVERAGE_SCOPE_VALUES.map((s) => Type.Literal(s)),
884
+ {
885
+ description:
886
+ "'wide' if the verdict applies to the whole deployment/account/host (recorded ONCE, applies to every asset of the deployment — do NOT re-test it per asset); 'local' if specific to this one asset.",
887
+ },
888
+ ),
889
+ note: Type.String({
890
+ description:
891
+ "Short note of the tests ACTUALLY RUN and the verdict: techniques tried · result · key gap. A verdict guessed without testing can hide a real issue.",
892
+ }),
893
+ },
894
+ { additionalProperties: false },
895
+ );
896
+
897
+ pi.registerTool({
898
+ name: "CoverageAdd",
899
+ label: "Record Coverage",
900
+ description:
901
+ "Record what you tested so it is not re-tested. Call AFTER finishing a CLASS of issue, for BOTH outcomes (found or clean — a clean result is just as important to record). scope=wide: the verdict is a property of the whole deployment, recorded once and applied to every later asset (do NOT re-test per asset); scope=local: specific to this one asset. The cell's existence marks that class tested for that asset.",
902
+ promptSnippet: "Record a tested attack class (coverage)",
903
+ promptGuidelines: [
904
+ "Record a coverage cell after you finish testing a class on an asset — found OR clean. Clean results are what make 'every class is COVERED' machine-checkable.",
905
+ "scope=wide for deployment-wide verdicts (record once, applies to every asset of the deployment — do NOT re-test it per asset). scope=local for single-asset verdicts.",
906
+ "The note must describe tests you ACTUALLY RAN, not assumptions. A verdict guessed without testing can hide a real issue.",
907
+ "Coverage cells live on the pipeline-run case (or the target's main case); CoverageReport shows the matrix.",
908
+ ],
909
+ parameters: CoverageAddSchema,
910
+
911
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
912
+ const item = recordCoverageResult(params.case_id as string, {
913
+ asset: params.asset as string,
914
+ class: params.class as string,
915
+ scope: (params.scope ?? "local") as CoverageScope,
916
+ note: params.note as string,
917
+ });
918
+ const record = getCaseById(params.case_id as string)!;
919
+ return {
920
+ content: [
921
+ {
922
+ type: "text",
923
+ text: `Coverage recorded: [${item.scope}] ${item.asset} × ${item.class} — ${item.note}\n\n${formatCaseDetail(record)}`,
924
+ },
925
+ ],
926
+ details: { item, record },
927
+ };
928
+ },
929
+
930
+ renderCall(args, theme) {
931
+ return callLine(
932
+ theme,
933
+ "CoverageAdd",
934
+ `${(args.asset as string) ?? ""} [${(args.class as string) ?? ""}]`,
935
+ );
936
+ },
937
+
938
+ renderResult(result, _opts, theme) {
939
+ const details = result.details as { item?: CoverageItem } | undefined;
940
+ if (!details?.item) {
941
+ return new Text(theme.fg("error", "✗ CoverageAdd failed"), 0, 0);
942
+ }
943
+ return new Text(
944
+ theme.fg("success", "✓ ") +
945
+ theme.fg("dim", `[${details.item.scope}] `) +
946
+ truncateToWidth(`${details.item.asset} × ${details.item.class}`, 50),
947
+ 0,
948
+ 0,
949
+ );
950
+ },
951
+ });
952
+
953
+ // ── Tool: CoverageReport ──
954
+
955
+ const CoverageReportSchema = Type.Object(
956
+ {
957
+ case_id: Type.String({ description: "Case ID to render the coverage matrix for" }),
958
+ },
959
+ { additionalProperties: false },
960
+ );
961
+
962
+ pi.registerTool({
963
+ name: "CoverageReport",
964
+ label: "Coverage Matrix",
965
+ description:
966
+ "Render the machine-checkable coverage matrix for a case: which (asset × attack-class) cells are tested, with wide-verdict propagation. Run before deciding the hunt/gapfill is done — the plateau stop (zero new classes testable) must be visible in the matrix, not asserted in prose.",
967
+ promptSnippet: "Show which attack classes were tested where",
968
+ promptGuidelines: [
969
+ "Run CoverageReport before claiming 'every class is COVERED/SKIPPED/NOT_FOUND' — the claim must match the matrix.",
970
+ "A class with a wide clean verdict covers every asset — do NOT re-test it per asset.",
971
+ "Classes tested with no cell recorded are invisible: record coverage as you finish each class (CoverageAdd).",
972
+ ],
973
+ parameters: CoverageReportSchema,
974
+
975
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
976
+ const summary = coverageSummary(params.case_id as string);
977
+ const lines: string[] = [`Coverage matrix for ${params.case_id}:`];
978
+ for (const asset of summary.assets) {
979
+ lines.push(`\n## ${asset}`);
980
+ for (const cell of summary.byAsset[asset] ?? []) {
981
+ lines.push(
982
+ `- [${cell.scope}] ${cell.class} — ${cell.note}${cell.testedBy ? ` (by ${cell.testedBy})` : ""}`,
983
+ );
984
+ }
985
+ }
986
+ if (summary.items.length === 0) {
987
+ lines.push("\n(no coverage recorded yet — run CoverageAdd as each class is tested)");
988
+ }
989
+ return {
990
+ content: [{ type: "text", text: lines.join("\n") }],
991
+ details: { summary },
992
+ };
993
+ },
994
+
995
+ renderCall(args, theme) {
996
+ return callLine(theme, "CoverageReport", (args.case_id as string) ?? "");
997
+ },
998
+
999
+ renderResult(result, _opts, theme) {
1000
+ const details = result.details as { summary?: { items?: CoverageItem[] } } | undefined;
1001
+ const n = details?.summary?.items?.length ?? 0;
1002
+ return new Text(theme.fg("success", `✓ ${n} coverage cell(s)`), 0, 0);
1003
+ },
1004
+ });
1005
+
767
1006
  // ── Tool: PromoteFinding ──
768
1007
 
769
1008
  pi.registerTool({
770
1009
  name: "PromoteFinding",
771
1010
  label: "Promote Finding",
772
1011
  description:
773
- "Run an on-disk PoC script (Docker sandbox or local) and, on exit 0 + verification marker present in output, promote an investigating case to confirmed. The verification_marker proves the exploit actually worked — exit code 0 alone is NOT sufficient. Optionally run a disconfirmation script that must exit non-0 (finding survived the attempt to disprove).",
1012
+ "Run an on-disk PoC script (Docker sandbox or local) and, on exit 0 + verification marker present in output, promote an investigating case to confirmed. The verification_marker proves the exploit actually worked — exit code 0 alone is NOT sufficient. For live findings (local:true) a control_path script is REQUIRED: the same PoC run against a control lacking the vuln must NOT print the marker — this blocks unconditional-marker and mock-target cheats. Optionally run a disconfirmation script that must exit non-0 (finding survived the attempt to disprove).",
774
1013
  promptSnippet: "Run a PoC and promote an investigating case to confirmed",
775
1014
  promptGuidelines: [
776
1015
  "Use PromoteFinding when an investigating case has a concrete PoC script on disk and you are ready to prove it.",
777
- "Prerequisites: status='investigating' and non-empty poc, evidence, impact, severity, target, disconfirmation.",
1016
+ "Prerequisites: status='investigating' and non-empty poc, evidence, impact, severity, target, disconfirmation, plus an EvidenceAdd 'observation' item on the case (the initial signal) — the PoC gate auto-records the reproduction item.",
778
1017
  "Default sandbox: docker run --rm --network none. Use local:true for network-dependent bugs.",
779
1018
  "Gate: exit 0 AND verification_marker in the PoC output. The marker (e.g. 'VULN_CONFIRMED_<case-id>') must be printed only AFTER the exploit is verified (data extracted, callback received, payload reflected) — never unconditionally or before the exploit check. The marker check prevents fluke exit 0 (script crashed early) and mocked PoCs (target faked) from passing.",
1019
+ "control_path (REQUIRED for local:true): a script that runs the SAME PoC against a control lacking the vuln (patched replica, second account, baseline endpoint). The harness blocks promotion if the verification_marker appears in the control run's output — an unconditional-marker PoC cannot pass this.",
780
1020
  "disconfirmation_path: a script that tries to disprove the finding; if it exits 0, promotion is blocked.",
781
1021
  "Never CaseUpdate status='confirmed' directly — it is rejected. Always use PromoteFinding.",
782
1022
  ],
@@ -786,42 +1026,58 @@ export default function casefileExtension(pi: ExtensionAPI) {
786
1026
  // Validate promotability BEFORE running the PoC — a sandboxed run can take
787
1027
  // 30s (plus first-time image pull), so fail cheap when the case can't
788
1028
  // advance anyway (missing, wrong status, missing required fields).
789
- assertPromotable(params.id as string);
1029
+ const caseId = params.id as string;
1030
+ assertPromotable(caseId);
1031
+
1032
+ // Shared blocked-promotion shape: the case stays investigating and the
1033
+ // caller gets the record back for context.
1034
+ const fail = (text: string, extra?: Record<string, unknown>) => ({
1035
+ content: [{ type: "text" as const, text }],
1036
+ isError: true,
1037
+ details: { record: getCaseById(caseId), ...extra },
1038
+ });
790
1039
 
791
1040
  // Reject empty/whitespace markers BEFORE any PoC run — it's a param
792
1041
  // error, so fail cheap instead of burning a (up to 30s) sandboxed run.
793
1042
  const marker = (params.verification_marker as string | undefined)?.trim();
794
1043
  if (!marker) {
795
- return {
796
- content: [
797
- {
798
- type: "text",
799
- text:
800
- "verification_marker is empty or whitespace. " +
801
- "A non-empty marker printed only AFTER the PoC confirms exploitation is required — " +
802
- "exit code 0 alone is not sufficient. Case remains investigating.",
803
- },
804
- ],
805
- isError: true,
806
- details: { record: getCaseById(params.id as string) },
807
- };
1044
+ return fail(
1045
+ "verification_marker is empty or whitespace. " +
1046
+ "A non-empty marker printed only AFTER the PoC confirms exploitation is required — " +
1047
+ "exit code 0 alone is not sufficient. Case remains investigating.",
1048
+ );
1049
+ }
1050
+
1051
+ // Live findings require control_path check BEFORE paying for the PoC
1052
+ // run (an agent that forgot the control script shouldn't burn a 30s
1053
+ // sandboxed run to be told).
1054
+ if (params.local === true && !params.control_path) {
1055
+ return fail(
1056
+ "Live findings (local:true) require control_path: a script that runs the same PoC " +
1057
+ "against a control lacking the vuln. The harness verifies the verification_marker is " +
1058
+ "absent from the control run's output — that is what proves the marker is target-dependent. " +
1059
+ "Write the control script and retry.",
1060
+ { missingControl: true },
1061
+ );
808
1062
  }
809
1063
 
810
1064
  const run = runPoc(params.poc_path as string, params.local !== true);
811
1065
 
812
1066
  // Fail closed without throwing: non-zero PoC must leave the case investigating.
813
1067
  if (run.exitCode !== 0) {
814
- const record = getCaseById(params.id as string);
815
- return {
816
- content: [
817
- {
818
- type: "text",
819
- text: `PoC failed (exit ${run.exitCode}). Case remains investigating.\nOutput:\n${run.output}`,
820
- },
821
- ],
822
- isError: true,
823
- details: { record, run },
824
- };
1068
+ return fail(
1069
+ `PoC failed (exit ${run.exitCode}). Case remains investigating.\nOutput:\n${run.output}`,
1070
+ { run },
1071
+ );
1072
+ }
1073
+
1074
+ // Defense-in-depth: exit 0 implies the run completed (sandbox wrapper /
1075
+ // local spawn semantics), but never trust a run the runner says crashed.
1076
+ if (!run.completed) {
1077
+ return fail(
1078
+ `PoC did NOT complete (spawn error, killed, or timeout). Case remains investigating.\nOutput:\n${run.output}`,
1079
+ { run, pocCrashed: true },
1080
+ );
825
1081
  }
826
1082
 
827
1083
  // Verification marker check: exit code 0 alone is NOT sufficient.
@@ -829,63 +1085,63 @@ export default function casefileExtension(pi: ExtensionAPI) {
829
1085
  // exploit actually worked — not just that the script ran. This blocks
830
1086
  // fluke exit 0 (crash before real logic) and mocked PoCs that don't
831
1087
  // actually exploit the target.
832
- if (!(run.output ?? "").includes(marker)) {
833
- const record = getCaseById(params.id as string);
834
- return {
835
- content: [
836
- {
837
- type: "text",
838
- text:
839
- `PoC exited 0 but the verification marker "${marker}" was NOT found in the output.\n` +
840
- `This means the script ran but did not prove exploitation. The marker must be printed only AFTER the PoC verifies the exploit worked (data extracted, callback received, payload reflected, etc.).\n` +
841
- `Do not print the marker unconditionally — print it only when the exploit is confirmed.\n\nOutput:\n${run.output}`,
842
- },
843
- ],
844
- isError: true,
845
- details: { record, run, markerMissing: true },
846
- };
1088
+ if (!run.output.includes(marker)) {
1089
+ return fail(
1090
+ `PoC exited 0 but the verification marker "${marker}" was NOT found in the output.\n` +
1091
+ `This means the script ran but did not prove exploitation. The marker must be printed only AFTER the PoC verifies the exploit worked (data extracted, callback received, payload reflected, etc.).\n` +
1092
+ `Do not print the marker unconditionally — print it only when the exploit is confirmed.\n\nOutput:\n${run.output}`,
1093
+ { run, markerMissing: true },
1094
+ );
847
1095
  }
848
1096
 
849
1097
  // Run disconfirmation script if provided — must exit NON-0 (finding survived the attempt to disprove).
850
1098
  let disconfirmationRun: PocRun | undefined;
851
1099
  if (params.disconfirmation_path) {
852
1100
  disconfirmationRun = runPoc(params.disconfirmation_path as string, params.local !== true);
1101
+ if (!disconfirmationRun.completed) {
1102
+ return fail(
1103
+ `Disconfirmation script did NOT complete (spawn error, killed, or timeout — no completion marker). ` +
1104
+ `A crash is not a survived disproof: fix the disconfirmation script and retry.\n` +
1105
+ `Output:\n${disconfirmationRun.output}`,
1106
+ { run, disconfirmationRun, disconfirmationCrashed: true },
1107
+ );
1108
+ }
853
1109
  if (disconfirmationRun.exitCode === 0) {
854
- const record = getCaseById(params.id as string);
855
- return {
856
- content: [
857
- {
858
- type: "text",
859
- text:
860
- `Disconfirmation script exited 0 (finding was disproven). ` +
861
- `Case remains investigating.\nOutput:\n${disconfirmationRun.output}`,
862
- },
863
- ],
864
- isError: true,
865
- details: { record, run, disconfirmationRun },
866
- };
1110
+ return fail(
1111
+ `Disconfirmation script exited 0 (finding was disproven). ` +
1112
+ `Case remains investigating.\nOutput:\n${disconfirmationRun.output}`,
1113
+ { run, disconfirmationRun },
1114
+ );
867
1115
  }
868
1116
  }
869
1117
 
870
- const result = promoteFindingResult(
871
- params.id as string,
872
- {
873
- path: run.path,
874
- exitCode: run.exitCode,
875
- ranAt: run.ranAt,
876
- output: run.output,
877
- sandbox: run.sandbox,
878
- },
879
- disconfirmationRun
880
- ? {
881
- path: disconfirmationRun.path,
882
- exitCode: disconfirmationRun.exitCode,
883
- ranAt: disconfirmationRun.ranAt,
884
- output: disconfirmationRun.output,
885
- sandbox: disconfirmationRun.sandbox,
886
- }
887
- : undefined,
888
- );
1118
+ // Control-target anti-cheat check: the same PoC pointed at a control that
1119
+ // lacks the vuln must NOT print the marker. The control script is
1120
+ // agent-written, but the marker-absence check is harness-side and
1121
+ // deterministic — the model cannot pass it by asserting success.
1122
+ let controlRun: PocRun | undefined;
1123
+ if (params.control_path) {
1124
+ controlRun = runPoc(params.control_path as string, params.local !== true);
1125
+ if (!controlRun.completed) {
1126
+ return fail(
1127
+ `CONTROL CHECK FAILED: the control-target script did NOT complete (spawn error, killed, or timeout). ` +
1128
+ `A control run that never executed proves nothing about the marker — fix the control script and retry.\n` +
1129
+ `Control output:\n${controlRun.output}`,
1130
+ { run, controlRun, controlCrashed: true },
1131
+ );
1132
+ }
1133
+ if (controlRun.output.includes(marker)) {
1134
+ return fail(
1135
+ `CONTROL CHECK FAILED: the verification marker "${marker}" appeared in the control-target run. ` +
1136
+ `The PoC prints the marker without the vulnerable condition — a cheating PoC (unconditional marker) ` +
1137
+ `or a broken check. Case remains investigating.\nControl output:\n${controlRun.output}`,
1138
+ { run, controlRun, controlCheated: true },
1139
+ );
1140
+ }
1141
+ }
1142
+
1143
+ // PocRun is structurally a PocVerification — pass the runs straight through.
1144
+ const result = promoteFindingResult(caseId, run, disconfirmationRun, controlRun, marker);
889
1145
  const record = result.record;
890
1146
  return {
891
1147
  content: [
@@ -899,12 +1155,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
899
1155
  },
900
1156
 
901
1157
  renderCall(args, theme) {
902
- return new Text(
903
- theme.fg("toolTitle", theme.bold("PromoteFinding ")) +
904
- theme.fg("dim", (args.id as string) ?? ""),
905
- 0,
906
- 0,
907
- );
1158
+ return callLine(theme, "PromoteFinding", (args.id as string) ?? "");
908
1159
  },
909
1160
 
910
1161
  renderResult(result, _options, theme) {
@@ -921,7 +1172,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
921
1172
  label: "Get Case",
922
1173
  description: "Get full details of a single case by ID.",
923
1174
  promptSnippet: "Look up a specific case by ID",
924
- parameters: GetSchema,
1175
+ parameters: IdSchema,
925
1176
 
926
1177
  async execute(_id, params, _signal, _onUpdate, _ctx) {
927
1178
  const record = getCaseById(params.id as string);
@@ -935,11 +1186,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
935
1186
  },
936
1187
 
937
1188
  renderCall(args, theme) {
938
- return new Text(
939
- theme.fg("toolTitle", theme.bold("CaseGet ")) + theme.fg("dim", (args.id as string) ?? ""),
940
- 0,
941
- 0,
942
- );
1189
+ return callLine(theme, "CaseGet", (args.id as string) ?? "");
943
1190
  },
944
1191
 
945
1192
  renderResult(result, _options, theme) {
@@ -961,40 +1208,19 @@ export default function casefileExtension(pi: ExtensionAPI) {
961
1208
  parameters: ListSchema,
962
1209
 
963
1210
  async execute(_id, params, _signal, _onUpdate, _ctx) {
964
- const { cases, total } = searchCases({
965
- status: params.status as CaseStatus | undefined,
966
- confidence: params.confidence as CaseConfidence | undefined,
967
- severity: params.severity as CaseSeverity | undefined,
968
- minSeverity: params.minSeverity as CaseSeverity | undefined,
969
- priority: params.priority as CasePriority | undefined,
970
- tag: params.tag,
971
- since: params.since as string | undefined,
972
- until: params.until as string | undefined,
973
- limit: params.limit,
974
- offset: params.offset,
975
- });
976
- const offset = params.offset ?? 0;
977
- const header = `Showing ${cases.length} of ${total} cases (offset: ${offset})`;
978
- const body = cases.length > 0 ? formatCases(cases) : "No cases match filters.";
979
- return {
980
- content: [{ type: "text", text: `${header}\n${body}` }],
981
- details: { cases, total, offset },
982
- };
1211
+ return runCaseQuery(
1212
+ params as Record<string, unknown>,
1213
+ (count, total, offset) => `Showing ${count} of ${total} cases (offset: ${offset})`,
1214
+ "No cases match filters.",
1215
+ );
983
1216
  },
984
1217
 
985
1218
  renderCall(_args, theme) {
986
- return new Text(theme.fg("toolTitle", theme.bold("CaseList")), 0, 0);
1219
+ return callLine(theme, "CaseList");
987
1220
  },
988
1221
 
989
1222
  renderResult(result, { expanded }, theme) {
990
- const details = result.details as { cases?: CaseRecord[]; total?: number } | undefined;
991
- const total = details?.total ?? 0;
992
- const cases = details?.cases ?? [];
993
- let line = theme.fg("success", "✓ ") + theme.fg("muted", `${total} case(s)`);
994
- if (expanded && cases.length > 0) {
995
- line += `\n${cases.map((c) => ` ${renderOneLine(c, theme)}`).join("\n")}`;
996
- }
997
- return new Text(line, 0, 0);
1223
+ return renderCasePage(result, theme, "case(s)", expanded);
998
1224
  },
999
1225
  });
1000
1226
 
@@ -1009,46 +1235,20 @@ export default function casefileExtension(pi: ExtensionAPI) {
1009
1235
  parameters: SearchSchema,
1010
1236
 
1011
1237
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1012
- const { cases, total } = searchCases({
1013
- query: params.query,
1014
- field: params.field as CaseSearchField | undefined,
1015
- status: params.status as CaseStatus | undefined,
1016
- confidence: params.confidence as CaseConfidence | undefined,
1017
- severity: params.severity as CaseSeverity | undefined,
1018
- minSeverity: params.minSeverity as CaseSeverity | undefined,
1019
- priority: params.priority as CasePriority | undefined,
1020
- tag: params.tag,
1021
- since: params.since as string | undefined,
1022
- until: params.until as string | undefined,
1023
- limit: params.limit,
1024
- offset: params.offset,
1025
- });
1026
- const offset = params.offset ?? 0;
1027
- const header = `Search "${params.query}"${params.field ? ` in ${params.field}` : ""}: ${cases.length} of ${total} results (offset: ${offset})`;
1028
- const body = cases.length > 0 ? formatCases(cases) : "No matching cases.";
1029
- return {
1030
- content: [{ type: "text", text: `${header}\n${body}` }],
1031
- details: { cases, total, offset },
1032
- };
1238
+ return runCaseQuery(
1239
+ params as Record<string, unknown>,
1240
+ (count, total, offset) =>
1241
+ `Search "${params.query}"${params.field ? ` in ${params.field}` : ""}: ${count} of ${total} results (offset: ${offset})`,
1242
+ "No matching cases.",
1243
+ );
1033
1244
  },
1034
1245
 
1035
1246
  renderCall(args, theme) {
1036
- return new Text(
1037
- theme.fg("toolTitle", theme.bold("CaseSearch ")) + theme.fg("dim", `"${args.query}"`),
1038
- 0,
1039
- 0,
1040
- );
1247
+ return callLine(theme, "CaseSearch", `"${args.query}"`);
1041
1248
  },
1042
1249
 
1043
1250
  renderResult(result, { expanded }, theme) {
1044
- const details = result.details as { cases?: CaseRecord[]; total?: number } | undefined;
1045
- const total = details?.total ?? 0;
1046
- const cases = details?.cases ?? [];
1047
- let line = theme.fg("success", "✓ ") + theme.fg("muted", `${total} result(s)`);
1048
- if (expanded && cases.length > 0) {
1049
- line += `\n${cases.map((c) => ` ${renderOneLine(c, theme)}`).join("\n")}`;
1050
- }
1051
- return new Text(line, 0, 0);
1251
+ return renderCasePage(result, theme, "result(s)", expanded);
1052
1252
  },
1053
1253
  });
1054
1254
 
@@ -1094,14 +1294,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
1094
1294
 
1095
1295
  renderCall(args, theme) {
1096
1296
  const kind = args.kind ? ` [${args.kind}]` : "";
1097
- return new Text(
1098
- theme.fg("toolTitle", theme.bold("CaseLink ")) +
1099
- theme.fg(
1100
- "dim",
1101
- `${(args.source_id as string) ?? ""} ↔ ${(args.target_id as string) ?? ""}${kind}`,
1102
- ),
1103
- 0,
1104
- 0,
1297
+ return callLine(
1298
+ theme,
1299
+ "CaseLink",
1300
+ `${(args.source_id as string) ?? ""} ↔ ${(args.target_id as string) ?? ""}${kind}`,
1105
1301
  );
1106
1302
  },
1107
1303
 
@@ -1163,14 +1359,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
1163
1359
  },
1164
1360
 
1165
1361
  renderCall(args, theme) {
1166
- return new Text(
1167
- theme.fg("toolTitle", theme.bold("CaseUnlink ")) +
1168
- theme.fg(
1169
- "dim",
1170
- `${(args.source_id as string) ?? ""} ↻ ${(args.target_id as string) ?? ""}`,
1171
- ),
1172
- 0,
1173
- 0,
1362
+ return callLine(
1363
+ theme,
1364
+ "CaseUnlink",
1365
+ `${(args.source_id as string) ?? ""} ↻ ${(args.target_id as string) ?? ""}`,
1174
1366
  );
1175
1367
  },
1176
1368
 
@@ -1187,6 +1379,65 @@ export default function casefileExtension(pi: ExtensionAPI) {
1187
1379
  },
1188
1380
  });
1189
1381
 
1382
+ // ── Tool: ChainSuggest ──
1383
+
1384
+ const ChainSuggestSchema = Type.Object(
1385
+ {
1386
+ case_id: Type.Optional(
1387
+ Type.String({
1388
+ description:
1389
+ "Optional: scope suggestions to this case and its linked cases. Omit to scan all non-terminal cases.",
1390
+ }),
1391
+ ),
1392
+ },
1393
+ { additionalProperties: false },
1394
+ );
1395
+
1396
+ pi.registerTool({
1397
+ name: "ChainSuggest",
1398
+ label: "Suggest Exploit Chains",
1399
+ description:
1400
+ "Scan non-terminal cases for exploitable chains (credential+endpoint→ATO, open-redirect+OAuth→token theft, XSS+state-changing→CSRF bypass, IDOR+user-data→mass leak, SSTI→RCE, race+payment→financial, info-disclosure+SSRF). Returns ranked candidates with confidence and a suggested link kind — the agent decides whether to CaseLink them or open an escalation case. Catches chain combinations the model may have missed.",
1401
+ promptSnippet: "Find missed exploit-chain combinations",
1402
+ promptGuidelines: [
1403
+ "Run ChainSuggest before concluding an engagement — low-severity findings that chain into high-impact (ATO, token theft, mass leak) are the ones triage cares about.",
1404
+ "A suggestion is a HYPOTHESIS to verify, not a finding: test the chained behavior on the live target before linking or promoting anything.",
1405
+ "Chain a suggested pair with CaseLink (suggested kind) or open a new escalation case with status=hypothesis.",
1406
+ ],
1407
+ parameters: ChainSuggestSchema,
1408
+
1409
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1410
+ const suggestions = suggestChains((params.case_id as string | undefined) ?? undefined);
1411
+ const lines: string[] = [
1412
+ suggestions.length
1413
+ ? `${suggestions.length} chain candidate(s):`
1414
+ : "No chain candidates found across non-terminal cases.",
1415
+ ];
1416
+ for (const s of suggestions) {
1417
+ const pair = s.targetId
1418
+ ? `${s.sourceId} (${s.sourceTitle}) + ${s.targetId} (${s.targetTitle ?? ""})`
1419
+ : `${s.sourceId} (${s.sourceTitle})`;
1420
+ lines.push(
1421
+ `\n[${s.pattern}] conf ${s.confidence}% — ${pair}\n ${s.rationale}${s.suggestedKind ? `\n suggested link kind: ${s.suggestedKind}` : ""}`,
1422
+ );
1423
+ }
1424
+ return {
1425
+ content: [{ type: "text", text: lines.join("\n") }],
1426
+ details: { suggestions },
1427
+ };
1428
+ },
1429
+
1430
+ renderCall(args, theme) {
1431
+ return callLine(theme, "ChainSuggest", (args.case_id as string) ?? "all");
1432
+ },
1433
+
1434
+ renderResult(result, _opts, theme) {
1435
+ const details = result.details as { suggestions?: { length: number } } | undefined;
1436
+ const n = details?.suggestions?.length ?? 0;
1437
+ return new Text(theme.fg(n ? "accent" : "dim", `${n} chain candidate(s)`), 0, 0);
1438
+ },
1439
+ });
1440
+
1190
1441
  // ── Tool: CaseContext ──
1191
1442
 
1192
1443
  pi.registerTool({
@@ -1199,7 +1450,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1199
1450
  "Use CaseContext only for confirmed or already reported cases. Keep hypotheses and investigating cases in the ledger until proof is captured.",
1200
1451
  "After CaseContext, dispatch the reporter subagent (agents/reporter) to write the final report to the returned report path, then CaseUpdate(status: 'reported').",
1201
1452
  ],
1202
- parameters: ContextSchema,
1453
+ parameters: IdSchema,
1203
1454
 
1204
1455
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1205
1456
  const { path, contextPath, record } = writeCaseContext(params.id as string);
@@ -1215,12 +1466,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1215
1466
  },
1216
1467
 
1217
1468
  renderCall(args, theme) {
1218
- return new Text(
1219
- theme.fg("toolTitle", theme.bold("CaseContext ")) +
1220
- theme.fg("dim", (args.id as string) ?? ""),
1221
- 0,
1222
- 0,
1223
- );
1469
+ return callLine(theme, "CaseContext", (args.id as string) ?? "");
1224
1470
  },
1225
1471
 
1226
1472
  renderResult(result, _options, theme) {
@@ -1298,12 +1544,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1298
1544
  },
1299
1545
 
1300
1546
  renderCall(args, theme) {
1301
- return new Text(
1302
- theme.fg("toolTitle", theme.bold("PipelineSubmit ")) +
1303
- theme.fg("dim", `${args.stage ?? ""}`),
1304
- 0,
1305
- 0,
1306
- );
1547
+ return callLine(theme, "PipelineSubmit", `${args.stage ?? ""}`);
1307
1548
  },
1308
1549
 
1309
1550
  renderResult(result, _opts, theme) {
@@ -1335,7 +1576,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1335
1576
  "The run_id is arbitrary but should be unique per pipeline run — typically <target>-<timestamp>.",
1336
1577
  "On resume, ScratchpadInit returns the existing checkpoint without wiping it; pair with ScratchpadResume to skip completed phases.",
1337
1578
  ],
1338
- parameters: ScratchpadInitSchema,
1579
+ parameters: RunIdSchema,
1339
1580
 
1340
1581
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1341
1582
  const cp = scratchpad_init(params.run_id as string);
@@ -1351,12 +1592,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1351
1592
  },
1352
1593
 
1353
1594
  renderCall(args, theme) {
1354
- return new Text(
1355
- theme.fg("toolTitle", theme.bold("ScratchpadInit ")) +
1356
- theme.fg("dim", (args.run_id as string) ?? ""),
1357
- 0,
1358
- 0,
1359
- );
1595
+ return callLine(theme, "ScratchpadInit", (args.run_id as string) ?? "");
1360
1596
  },
1361
1597
 
1362
1598
  renderResult(result, _opts, theme) {
@@ -1378,7 +1614,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1378
1614
  "If ScratchpadResume returns null, the run has no checkpoint — call ScratchpadInit to start fresh.",
1379
1615
  "Use ScratchpadPhaseDone before dispatching each stage to avoid re-running completed phases (idempotent resume).",
1380
1616
  ],
1381
- parameters: ScratchpadResumeSchema,
1617
+ parameters: RunIdSchema,
1382
1618
 
1383
1619
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1384
1620
  const resume = scratchpad_resume(params.run_id as string);
@@ -1409,12 +1645,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1409
1645
  },
1410
1646
 
1411
1647
  renderCall(args, theme) {
1412
- return new Text(
1413
- theme.fg("toolTitle", theme.bold("ScratchpadResume ")) +
1414
- theme.fg("dim", (args.run_id as string) ?? ""),
1415
- 0,
1416
- 0,
1417
- );
1648
+ return callLine(theme, "ScratchpadResume", (args.run_id as string) ?? "");
1418
1649
  },
1419
1650
 
1420
1651
  renderResult(result, _opts, theme) {
@@ -1463,12 +1694,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1463
1694
  },
1464
1695
 
1465
1696
  renderCall(args, theme) {
1466
- return new Text(
1467
- theme.fg("toolTitle", theme.bold("ScratchpadCheckpoint ")) +
1468
- theme.fg("dim", `${args.run_id ?? ""} ${args.phase ?? ""}`),
1469
- 0,
1470
- 0,
1471
- );
1697
+ return callLine(theme, "ScratchpadCheckpoint", `${args.run_id ?? ""} ${args.phase ?? ""}`);
1472
1698
  },
1473
1699
 
1474
1700
  renderResult(result, _opts, theme) {
@@ -1517,11 +1743,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
1517
1743
  },
1518
1744
 
1519
1745
  renderCall(args, theme) {
1520
- return new Text(
1521
- theme.fg("toolTitle", theme.bold("ScratchpadWrite ")) +
1522
- theme.fg("dim", `${args.run_id ?? ""}/${args.phase ?? ""}/${args.artifact_name ?? ""}`),
1523
- 0,
1524
- 0,
1746
+ return callLine(
1747
+ theme,
1748
+ "ScratchpadWrite",
1749
+ `${args.run_id ?? ""}/${args.phase ?? ""}/${args.artifact_name ?? ""}`,
1525
1750
  );
1526
1751
  },
1527
1752
 
@@ -1569,11 +1794,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
1569
1794
  },
1570
1795
 
1571
1796
  renderCall(args, theme) {
1572
- return new Text(
1573
- theme.fg("toolTitle", theme.bold("ScratchpadRead ")) +
1574
- theme.fg("dim", `${args.run_id ?? ""}/${args.phase ?? ""}/${args.artifact_name ?? ""}`),
1575
- 0,
1576
- 0,
1797
+ return callLine(
1798
+ theme,
1799
+ "ScratchpadRead",
1800
+ `${args.run_id ?? ""}/${args.phase ?? ""}/${args.artifact_name ?? ""}`,
1577
1801
  );
1578
1802
  },
1579
1803
 
@@ -1617,12 +1841,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1617
1841
  },
1618
1842
 
1619
1843
  renderCall(args, theme) {
1620
- return new Text(
1621
- theme.fg("toolTitle", theme.bold("ScratchpadPhaseDone ")) +
1622
- theme.fg("dim", `${args.run_id ?? ""} ${args.phase ?? ""}`),
1623
- 0,
1624
- 0,
1625
- );
1844
+ return callLine(theme, "ScratchpadPhaseDone", `${args.run_id ?? ""} ${args.phase ?? ""}`);
1626
1845
  },
1627
1846
 
1628
1847
  renderResult(result, _opts, theme) {
@@ -1649,7 +1868,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1649
1868
  "Use ScratchpadClear to force a fresh start for a single run (--fresh). It deletes that run's directory only.",
1650
1869
  "After clearing, call ScratchpadInit to recreate the directory structure before writing artifacts.",
1651
1870
  ],
1652
- parameters: ScratchpadClearSchema,
1871
+ parameters: RunIdSchema,
1653
1872
 
1654
1873
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1655
1874
  scratchpad_clear(params.run_id as string);
@@ -1665,12 +1884,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1665
1884
  },
1666
1885
 
1667
1886
  renderCall(args, theme) {
1668
- return new Text(
1669
- theme.fg("toolTitle", theme.bold("ScratchpadClear ")) +
1670
- theme.fg("dim", (args.run_id as string) ?? ""),
1671
- 0,
1672
- 0,
1673
- );
1887
+ return callLine(theme, "ScratchpadClear", (args.run_id as string) ?? "");
1674
1888
  },
1675
1889
 
1676
1890
  renderResult(_result, _opts, theme) {