@xaccefy/pi-casefile 0.9.1 → 0.9.2

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
@@ -9,9 +9,9 @@
9
9
  import { createHash } from "node:crypto";
10
10
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
11
  import { dirname, join } from "node:path";
12
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
13
13
  import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
14
- import { Type } from "typebox";
14
+ import { type TSchema, Type } from "typebox";
15
15
  import {
16
16
  CANARY_ASSESSMENT_VALUES,
17
17
  CONFIRM_DIFFERENTIAL_VALUES,
@@ -19,11 +19,7 @@ import {
19
19
  SEVERITY_MATCH_VALUES,
20
20
  validateMainAgentVerdict,
21
21
  } from "./evidence.ts";
22
- import {
23
- controlTargetAuthorizationError,
24
- type HarnessVerifyResult,
25
- replayDifferential,
26
- } from "./harness-verify.ts";
22
+ import { controlTargetAuthorizationError, replayDifferential } from "./harness-verify.ts";
27
23
  import {
28
24
  addCaseResult,
29
25
  addEvidenceItemResult,
@@ -73,7 +69,7 @@ import { pipeline_submit, SUBMIT_STAGES, type SubmitStage } from "./pipeline-sub
73
69
  import { type PocRun, type PocRunOptions, runPoc } from "./poc-runner.ts";
74
70
  import {
75
71
  detectWorkspaceRoot,
76
- PHASE_ORDER,
72
+ SCRATCHPAD_PHASES,
77
73
  type ScratchpadPhase,
78
74
  type ScratchpadResume,
79
75
  scratchpad_checkpoint,
@@ -182,12 +178,13 @@ const EvidenceAddSchema = Type.Object(
182
178
 
183
179
  // ── Tool: PromoteFinding (phase 1) / ConfirmFinding (phase 2) ──────────
184
180
  //
185
- // Confirmation is TWO-PHASE: a worker may run PromoteFinding (the harness runs
186
- // the PoC 2x + control, validates nonce-bound evidence.json, and records the
187
- // bundle), but only the MAIN coordinator agent may review/re-execute and commit
188
- // a verdict via ConfirmFinding. Zero exit is necessary run integrity and
189
- // markers are diagnostic only; the machine records predicate/canary
190
- // differentials and the main agent owns the semantic vulnerability judgment.
181
+ // Confirmation is TWO-PHASE and main-agent-owned: PromoteFinding runs the PoC
182
+ // 2x + control, validates nonce-bound evidence.json, and records the pending
183
+ // bundle; ConfirmFinding then performs the main coordinator's review/replay and
184
+ // commits or refuses the verdict. Subagents may gather or challenge evidence,
185
+ // but they cannot run validation or confirmation gates. Zero exit is necessary
186
+ // run integrity and markers are diagnostic only; the machine records
187
+ // predicate/canary differentials and the main agent owns the semantic judgment.
191
188
 
192
189
  const PromoteSchema = Type.Object(
193
190
  {
@@ -195,10 +192,12 @@ const PromoteSchema = Type.Object(
195
192
  poc_path: Type.String({
196
193
  description: "Absolute path to the PoC script on disk",
197
194
  }),
198
- control_path: Type.String({
199
- description:
200
- "REQUIRED: absolute path to the SAME script as poc_path (sha256-equality is ENFORCED). The harness runs it with PI_POC_MODE=control and PI_POC_TARGET=control_target.",
201
- }),
195
+ control_path: Type.Optional(
196
+ Type.String({
197
+ description:
198
+ "Optional absolute path to the SAME script as poc_path (sha256-equality is ENFORCED). Defaults to poc_path. The harness runs it with PI_POC_MODE=control and PI_POC_TARGET=control_target.",
199
+ }),
200
+ ),
202
201
  control_target: Type.String({
203
202
  minLength: 1,
204
203
  description:
@@ -358,9 +357,9 @@ const UnlinkSchema = Type.Object(
358
357
  // artifacts; it does not re-run completed phases (idempotent).
359
358
 
360
359
  const ScratchpadPhaseSchema = Type.String({
361
- enum: [...PHASE_ORDER],
360
+ enum: [...SCRATCHPAD_PHASES],
362
361
  description:
363
- "Pipeline phase: recon | hunt | gapfil | trace | skeptic | validate | chain | patch | report",
362
+ "Pipeline phase: recon | hunt | trace | skeptic | validate | chain | patch | report (legacy gapfil is accepted for older runs)",
364
363
  });
365
364
 
366
365
  /** run_id-only schema, shared by Scratchpad Init / Resume / Clear. */
@@ -699,13 +698,13 @@ export function detectHost(): "omp" | "pi" {
699
698
  * case list DOES change as cases are added, so it is refreshed every prompt.
700
699
  *
701
700
  * mode selects the workflow text: "lite" injects the single-agent workflow
702
- * (no subagent dispatch), anything else gets the full subagent pipeline,
701
+ * (no subagent dispatch), "swarm" gets the full subagent pipeline,
703
702
  * rendered for the host's dispatch convention (pi-subagents vs OMP task).
704
703
  */
705
704
  function buildAgentInjection(
706
705
  active: CaseRecord[],
707
706
  includeWorkflow: boolean,
708
- mode: XpMode = "on",
707
+ mode: XpMode = "swarm",
709
708
  ): string {
710
709
  const caseList = buildCaseListContext(active);
711
710
  if (!includeWorkflow) return caseList;
@@ -722,13 +721,15 @@ function buildAgentInjection(
722
721
  // ── XP (offensive / exploit) mode toggle ─────────────────────────────
723
722
  // Casefile historically injected the cyber workflow into every prompt.
724
723
  // For normal dev work that is just noise, so XP mode defaults OFF. Enable
725
- // it for offensive/audit sessions to get the full attacker discipline back,
726
- // or lite for the single-agent variant (no subagent dispatch). Toggle with
727
- // /xp (or /xp on|off|lite); override per-session with PI_XP_MODE.
724
+ // swarm for the bounded multi-agent variant, or lite for the single-agent
725
+ // attacker discipline. Toggle with /xp (off <-> swarm), or set explicitly with
726
+ // /xp on|lite|swarm|off. "on" means the default enabled SWARM mode; use "lite"
727
+ // for no subagent dispatch.
728
+ // Override per-session with PI_XP_MODE.
728
729
  // Pure helpers exported for unit tests.
729
730
 
730
731
  export const XP_MODE_ENV = "PI_XP_MODE";
731
- export type XpMode = "on" | "off" | "lite";
732
+ export type XpMode = "swarm" | "off" | "lite";
732
733
 
733
734
  export function getXpModeStatePath(): string {
734
735
  return join(dirname(getCasefilePath()), "xp-mode");
@@ -739,13 +740,14 @@ export function readXpMode(
739
740
  statePath: string = getXpModeStatePath(),
740
741
  ): XpMode {
741
742
  const env = (envValue ?? "").trim().toLowerCase();
742
- if (env === "on" || env === "1" || env === "true") return "on";
743
+ if (env === "swarm") return "swarm";
744
+ if (env === "on" || env === "1" || env === "true") return "swarm";
743
745
  if (env === "lite") return "lite";
744
746
  if (env === "off" || env === "0" || env === "false") return "off";
745
747
  try {
746
748
  if (existsSync(statePath)) {
747
749
  const v = readFileSync(statePath, "utf8").trim().toLowerCase();
748
- if (v === "on") return "on";
750
+ if (v === "swarm" || v === "on") return "swarm";
749
751
  if (v === "lite") return "lite";
750
752
  if (v === "off") return "off";
751
753
  }
@@ -765,11 +767,12 @@ export function writeXpMode(state: XpMode, statePath: string = getXpModeStatePat
765
767
 
766
768
  export function parseXpModeArg(args: string, current: XpMode): XpMode {
767
769
  const arg = (args ?? "").trim().toLowerCase();
768
- if (arg === "on") return "on";
770
+ if (arg === "swarm") return "swarm";
771
+ if (arg === "on") return "swarm";
769
772
  if (arg === "off") return "off";
770
773
  if (arg === "lite") return "lite";
771
- // Bare /xp toggles between on and off (lite is only set explicitly).
772
- return current === "on" ? "off" : "on";
774
+ // Bare /xp is the low-ceremony path: toggle the default XP workflow on/off.
775
+ return current === "off" ? "swarm" : "off";
773
776
  }
774
777
 
775
778
  // ── Main extension ────────────────────────────────────────────────────
@@ -789,34 +792,37 @@ export default function casefileExtension(pi: ExtensionAPI) {
789
792
  setScratchpadRoot(workspaceRoot);
790
793
  process.env.PI_POC_ROOT ??= workspaceRoot;
791
794
 
792
- // ── Diagnostic Error Handler Middleware ──
793
- const originalRegisterTool = pi.registerTool.bind(pi);
794
- pi.registerTool = (spec: any) => {
795
+ // ── Diagnostic Error Handler ──
796
+ const registerCaseTool = <TParams extends TSchema, TDetails = unknown, TState = unknown>(
797
+ spec: ToolDefinition<TParams, TDetails, TState>,
798
+ ) => {
795
799
  const origExecute = spec.execute;
796
- spec.execute = async (...args: any[]) => {
797
- try {
798
- return await origExecute(...args);
799
- } catch (err) {
800
- const message = err instanceof Error ? err.message : String(err);
801
- let hint = "";
802
- if (
803
- message.includes("SQLITE") ||
804
- message.includes("database") ||
805
- message.includes("permission") ||
806
- message.includes("readonly") ||
807
- message.includes("lock")
808
- ) {
809
- hint = `\n\nHint: A database access error occurred on the casefile SQLite ledger.\nTo troubleshoot:\n 1. Check filesystem read/write permissions for the database path: ${getCasefilePath()}.\n 2. If using a locked folder, you can override the ledger location by setting:\n export PI_CASEFILE_PATH=/your/writable/directory/casefile.db`;
800
+ pi.registerTool({
801
+ ...spec,
802
+ execute: async (...args: Parameters<typeof origExecute>) => {
803
+ try {
804
+ return await origExecute(...args);
805
+ } catch (err) {
806
+ const message = err instanceof Error ? err.message : String(err);
807
+ let hint = "";
808
+ if (
809
+ message.includes("SQLITE") ||
810
+ message.includes("database") ||
811
+ message.includes("permission") ||
812
+ message.includes("readonly") ||
813
+ message.includes("lock")
814
+ ) {
815
+ hint = `\n\nHint: A database access error occurred on the casefile SQLite ledger.\nTo troubleshoot:\n 1. Check filesystem read/write permissions for the database path: ${getCasefilePath()}.\n 2. If using a locked folder, you can override the ledger location by setting:\n export PI_CASEFILE_PATH=/your/writable/directory/casefile.db`;
816
+ }
817
+ throw new Error(`${spec.name} failed: ${message}${hint}`, { cause: err });
810
818
  }
811
- throw new Error(`${spec.name} failed: ${message}${hint}`, { cause: err });
812
- }
813
- };
814
- originalRegisterTool(spec);
819
+ },
820
+ });
815
821
  };
816
822
 
817
823
  // ── Tool: CaseAdd ──
818
824
 
819
- pi.registerTool({
825
+ registerCaseTool({
820
826
  name: "CaseAdd",
821
827
  label: "Add Case",
822
828
  description:
@@ -870,7 +876,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
870
876
 
871
877
  // ── Tool: CaseUpdate ──
872
878
 
873
- pi.registerTool({
879
+ registerCaseTool({
874
880
  name: "CaseUpdate",
875
881
  label: "Update Case",
876
882
  description:
@@ -925,7 +931,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
925
931
 
926
932
  // ── Tool: EvidenceAdd ──
927
933
 
928
- pi.registerTool({
934
+ registerCaseTool({
929
935
  name: "EvidenceAdd",
930
936
  label: "Add Evidence Item",
931
937
  description:
@@ -945,7 +951,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
945
951
  summary: params.summary as string,
946
952
  artifactPath: params.artifact_path as string | undefined,
947
953
  });
948
- const record = getCaseById(params.case_id as string)!;
954
+ const record = getCaseById(params.case_id as string);
955
+ if (!record) throw new Error(`Case not found after evidence insert: ${params.case_id}`);
949
956
  return {
950
957
  content: [
951
958
  {
@@ -1018,7 +1025,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1018
1025
  { additionalProperties: false },
1019
1026
  );
1020
1027
 
1021
- pi.registerTool({
1028
+ registerCaseTool({
1022
1029
  name: "CoverageAdd",
1023
1030
  label: "Record Coverage",
1024
1031
  description:
@@ -1040,7 +1047,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
1040
1047
  note: params.note as string,
1041
1048
  evidenceItemId: params.evidence_item_id as string | undefined,
1042
1049
  });
1043
- const record = getCaseById(params.case_id as string)!;
1050
+ const record = getCaseById(params.case_id as string);
1051
+ if (!record) throw new Error(`Case not found after coverage insert: ${params.case_id}`);
1044
1052
  return {
1045
1053
  content: [
1046
1054
  {
@@ -1084,11 +1092,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
1084
1092
  { additionalProperties: false },
1085
1093
  );
1086
1094
 
1087
- pi.registerTool({
1095
+ registerCaseTool({
1088
1096
  name: "CoverageReport",
1089
1097
  label: "Coverage Matrix",
1090
1098
  description:
1091
- "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.",
1099
+ "Render the machine-checkable coverage matrix for a case: which (asset × attack-class) cells are tested, with wide-verdict propagation. Run before deciding HUNT coverage is done — the plateau stop (zero new classes testable) must be visible in the matrix, not asserted in prose.",
1092
1100
  promptSnippet: "Show which attack classes were tested where",
1093
1101
  promptGuidelines: [
1094
1102
  "Run CoverageReport before claiming 'every class is COVERED/SKIPPED/NOT_FOUND' — the claim must match the matrix.",
@@ -1133,239 +1141,249 @@ export default function casefileExtension(pi: ExtensionAPI) {
1133
1141
 
1134
1142
  // ── Tool: PromoteFinding (phase 1) ──
1135
1143
 
1136
- pi.registerTool({
1137
- name: "PromoteFinding",
1138
- label: "Run PoC Evidence",
1139
- description:
1140
- "Phase 1 of confirmation: run the same PoC twice against the case target and once against an operator-approved control_target, validate nonce-bound evidence.json with a response-body assertion, then have the harness execute one immutable HTTP request template against both target and control. The machine records a predicate differential, or a stronger canary differential when a reflection placeholder is requested and observed only on target; neither is automatically a vulnerability verdict. Exit 0 is necessary run integrity, never proof. Networked execution, controls, and private replay are operator-gated. Blind/OOB confirmation fails closed until source separation exists. Records a pending bundle for main-agent semantic review via ConfirmFinding.",
1141
- promptSnippet: "Phase 1: run PoC evidence (target x2 + control) and record the pending bundle",
1142
- promptGuidelines: [
1143
- "Use PromoteFinding when an investigating case has a concrete PoC script on disk and you are ready to subject its claim to the machine gate.",
1144
- "Prerequisites: status='investigating' and non-empty poc, evidence, impact, severity, target, plus an artifact-backed EvidenceAdd 'observation' item on the case (the initial signal, with artifact_path). The final disconfirmation comes from the main agent at confirm time.",
1145
- "The PoC MUST write evidence.json to $PI_POC_EVIDENCE_DIR: { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status?, body_contains/body_regex } }, observations }. A non-empty body predicate is mandatory; status-only evidence is rejected. verify.url must belong to the case target.",
1146
- "For reflection-capable requests, place {{PI_POC_CANARY}} exactly once in verify.url/body/header values and declare verify.canary={mode:'reflection',placeholder:'{{PI_POC_CANARY}}'}. The harness substitutes an unpredictable value only after the PoC exits and requires target-only reflection; the raw token is not persisted.",
1147
- "control_path (REQUIRED): the SAME script as poc_path. control_target must be pre-approved by the operator in PI_POC_CONTROL_TARGETS. The harness derives the control request from the target request, changes only its origin, and applies the same predicates to two conclusive responses.",
1148
- "local:true requires PI_POC_ALLOW_NETWORK=1. Private/internal harness replay additionally requires PI_POC_ALLOW_PRIVATE_REPLAY=1. Neither silently falls back to a model verdict.",
1149
- "Blind/OOB classes are not promotable through the built-in loopback listener because the PoC can self-call it; obtain a direct-response or state oracle, otherwise keep the case investigating.",
1150
- "After the bundle is recorded, return control to the main agent. The main agent must inspect the script/evidence, attempt disconfirmation, and call ConfirmFinding itself; that call performs a fresh harness-owned target/control replay. Never delegate phase 2 and never CaseUpdate status='confirmed' directly.",
1151
- ],
1152
- parameters: PromoteSchema,
1153
-
1154
- async execute(_id, params, _signal, _onUpdate, _ctx) {
1155
- // Validate promotability BEFORE running the PoC — each sandboxed run can
1156
- // take 30s (plus first-time image pull), so fail cheap when the case
1157
- // can't advance anyway (missing, wrong status, missing required fields,
1158
- // missing artifact-backed observation evidence).
1159
- const caseId = params.id as string;
1160
- const current = assertPromotable(caseId);
1161
-
1162
- const fail = (text: string, _extra?: Record<string, unknown>): never => {
1163
- throw new Error(text);
1164
- };
1144
+ if (!startedAsSubagent)
1145
+ registerCaseTool({
1146
+ name: "PromoteFinding",
1147
+ label: "Run PoC Evidence",
1148
+ description:
1149
+ "Main-agent phase 1 of confirmation: run the same PoC twice against the case target and once against an operator-approved control_target, validate nonce-bound evidence.json with a response-body assertion, then have the harness execute one immutable HTTP request template against both target and control. control_path defaults to poc_path; if supplied, sha256 equality is enforced. The machine records a predicate differential, or a stronger canary differential when a reflection placeholder is requested and observed only on target; neither is automatically a vulnerability verdict. Exit 0 is necessary run integrity, never proof. Networked execution, controls, and private replay are operator-gated. Blind/OOB confirmation fails closed until source separation exists. Records a pending bundle for main-agent semantic review via ConfirmFinding. Worker/subagent processes are rejected.",
1150
+ promptSnippet:
1151
+ "Phase 1: run PoC evidence (target x2 + control) and record the pending bundle",
1152
+ promptGuidelines: [
1153
+ "Use PromoteFinding only from the main/coordinator agent when an investigating case has a concrete PoC script on disk and you are ready to subject its claim to the machine gate.",
1154
+ "Prerequisites: status='investigating' and non-empty poc, evidence, impact, severity, target, plus an artifact-backed EvidenceAdd 'observation' item on the case (the initial signal, with artifact_path). The final disconfirmation comes from the main agent at confirm time.",
1155
+ "The PoC MUST write evidence.json to $PI_POC_EVIDENCE_DIR: { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status?, body_contains/body_regex } }, observations }. A non-empty body predicate is mandatory; status-only evidence is rejected. verify.url must belong to the case target.",
1156
+ "For reflection-capable requests, place {{PI_POC_CANARY}} exactly once in verify.url/body/header values and declare verify.canary={mode:'reflection',placeholder:'{{PI_POC_CANARY}}'}. The harness substitutes an unpredictable value only after the PoC exits and requires target-only reflection; the raw token is not persisted.",
1157
+ "control_path is optional and defaults to poc_path; if supplied, it must be the SAME script as poc_path. control_target must be pre-approved by the operator in PI_POC_CONTROL_TARGETS. The harness derives the control request from the target request, changes only its origin, and applies the same predicates to two conclusive responses.",
1158
+ "local:true requires PI_POC_ALLOW_NETWORK=1. Private/internal harness replay additionally requires PI_POC_ALLOW_PRIVATE_REPLAY=1. Neither silently falls back to a model verdict.",
1159
+ "Blind/OOB classes are not promotable through the built-in loopback listener because the PoC can self-call it; obtain a direct-response or state oracle, otherwise keep the case investigating.",
1160
+ "After the bundle is recorded, stay in the main agent: inspect the script/evidence, attempt disconfirmation, and call ConfirmFinding itself; that call performs a fresh harness-owned target/control replay. Never delegate validation/confirmation and never CaseUpdate status='confirmed' directly.",
1161
+ ],
1162
+ parameters: PromoteSchema,
1165
1163
 
1166
- const controlPath = (params.control_path as string | undefined)?.trim() ?? "";
1167
- const controlTarget = (params.control_target as string | undefined)?.trim() ?? "";
1168
- if (!controlPath) {
1169
- return fail(
1170
- "control_path is REQUIRED: the SAME script as poc_path (sha256-equality is ENFORCED), run by the harness with PI_POC_MODE=control and PI_POC_TARGET=control_target.",
1171
- { missingControl: true },
1172
- );
1173
- }
1174
- if (!controlTarget) {
1175
- return fail(
1176
- "control_target is REQUIRED: a distinct baseline target that lacks the vulnerability.",
1177
- { missingControlTarget: true },
1178
- );
1179
- }
1180
- if (controlTarget === current.target) {
1181
- return fail(
1182
- "control_target must differ from the case target; a control run against the vulnerable target proves nothing.",
1183
- { controlTargetEqualsCaseTarget: true },
1184
- );
1185
- }
1186
- if (params.local === true && process.env.PI_POC_ALLOW_NETWORK !== "1") {
1187
- return fail(
1188
- "Networked PoC execution is operator-gated. Set PI_POC_ALLOW_NETWORK=1 to authorize the host-network sandbox for this session.",
1189
- { networkNotAuthorized: true },
1190
- );
1191
- }
1192
- const controlAuthorization = controlTargetAuthorizationError(controlTarget);
1193
- if (controlAuthorization) {
1194
- return fail(
1195
- `CONTROL AUTHORIZATION FAILED: ${controlAuthorization}. ` +
1196
- "The operator must set PI_POC_CONTROL_TARGETS to the exact approved control host/origin before this control can anchor confirmation.",
1197
- { controlNotAuthorized: true },
1198
- );
1199
- }
1164
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
1165
+ if (isSubagentProcess()) {
1166
+ throw new Error(
1167
+ "PromoteFinding is reserved for the main/coordinator agent. A worker or subagent may gather evidence but cannot run validation or create a promotion bundle.",
1168
+ );
1169
+ }
1170
+ // Validate promotability BEFORE running the PoC — each sandboxed run can
1171
+ // take 30s (plus first-time image pull), so fail cheap when the case
1172
+ // can't advance anyway (missing, wrong status, missing required fields,
1173
+ // missing artifact-backed observation evidence).
1174
+ const caseId = params.id as string;
1175
+ const current = assertPromotable(caseId);
1200
1176
 
1201
- // Same-file contract (anti-cheat): control must be the SAME bytes as the
1202
- // PoC, differing only via the harness-set env. Check BEFORE any run.
1203
- const pocPath = (params.poc_path as string | undefined)?.trim() ?? "";
1204
- let pocHash: string | undefined;
1205
- let controlHash: string | undefined;
1206
- try {
1207
- pocHash = createHash("sha256").update(readFileSync(pocPath)).digest("hex");
1208
- controlHash = createHash("sha256").update(readFileSync(controlPath)).digest("hex");
1209
- } catch (e) {
1210
- return fail(
1211
- `Cannot read PoC/control scripts for the same-file check: ${(e as Error).message}`,
1212
- { sameFileCheckFailed: true },
1213
- );
1214
- }
1215
- if (pocHash !== controlHash) {
1216
- return fail(
1217
- "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path (sha256 mismatch). Case remains investigating.",
1218
- { controlHashMismatch: true },
1219
- );
1220
- }
1177
+ const fail = (text: string, _extra?: Record<string, unknown>): never => {
1178
+ throw new Error(text);
1179
+ };
1221
1180
 
1222
- // ── OOB callback (Tier 1, opt-in for blind classes) ──
1223
- const oobRequested = params.oob === true;
1224
- if (oobRequested) {
1225
- return fail(
1226
- "OOB confirmation is fail-closed: the built-in loopback listener is reachable by the PoC and cannot prove the target caused a callback. A source-separated, operator-owned callback service is required before blind findings can be promoted.",
1227
- { oobSourceSeparationRequired: true },
1228
- );
1229
- }
1230
- const runOptions = (pocMode: string, target: string): PocRunOptions => ({
1231
- network: params.local === true ? "host" : "none",
1232
- local: params.local === true,
1233
- env: {
1234
- PI_POC_MODE: pocMode,
1235
- PI_POC_TARGET: target,
1236
- },
1237
- });
1181
+ const pocPath = (params.poc_path as string | undefined)?.trim() ?? "";
1182
+ const controlPath = (params.control_path as string | undefined)?.trim() || pocPath;
1183
+ const controlTarget = (params.control_target as string | undefined)?.trim() ?? "";
1184
+ if (!pocPath) {
1185
+ return fail("poc_path is REQUIRED: absolute path to the PoC script run by the harness.", {
1186
+ missingPocPath: true,
1187
+ });
1188
+ }
1189
+ if (!controlTarget) {
1190
+ return fail(
1191
+ "control_target is REQUIRED: a distinct baseline target that lacks the vulnerability.",
1192
+ { missingControlTarget: true },
1193
+ );
1194
+ }
1195
+ if (controlTarget === current.target) {
1196
+ return fail(
1197
+ "control_target must differ from the case target; a control run against the vulnerable target proves nothing.",
1198
+ { controlTargetEqualsCaseTarget: true },
1199
+ );
1200
+ }
1201
+ if (params.local === true && process.env.PI_POC_ALLOW_NETWORK !== "1") {
1202
+ return fail(
1203
+ "Networked PoC execution is operator-gated. Set PI_POC_ALLOW_NETWORK=1 to authorize the host-network sandbox for this session.",
1204
+ { networkNotAuthorized: true },
1205
+ );
1206
+ }
1207
+ const controlAuthorization = controlTargetAuthorizationError(controlTarget);
1208
+ if (controlAuthorization) {
1209
+ return fail(
1210
+ `CONTROL AUTHORIZATION FAILED: ${controlAuthorization}. ` +
1211
+ "The operator must set PI_POC_CONTROL_TARGETS to the exact approved control host/origin before this control can anchor confirmation.",
1212
+ { controlNotAuthorized: true },
1213
+ );
1214
+ }
1238
1215
 
1239
- const caseTarget = current.target ?? "";
1240
- let targetRuns!: [PocEvidenceRun, PocEvidenceRun];
1241
- let control!: PocEvidenceRun;
1242
- let harnessVerified!: HarnessVerifyResult;
1243
- // Determinism: TWO target runs + one control run. Exit 0 is run
1244
- // integrity only; nonce-bound body evidence and the harness-owned
1245
- // target/control replay form the machine gate.
1246
- const run1 = runPoc(pocPath, runOptions("poc", caseTarget));
1247
- const run2 = runPoc(pocPath, runOptions("poc", caseTarget));
1248
- const controlRun = runPoc(controlPath, runOptions("control", controlTarget));
1249
-
1250
- const evidenceRun = (r: PocRun, mode: "poc" | "control", target: string): PocEvidenceRun => {
1251
- if (!r.completed || !r.outputComplete) {
1216
+ // Same-file contract (anti-cheat): control must be the SAME bytes as the
1217
+ // PoC, differing only via the harness-set env. Check BEFORE any run.
1218
+ let pocHash: string | undefined;
1219
+ let controlHash: string | undefined;
1220
+ try {
1221
+ pocHash = createHash("sha256").update(readFileSync(pocPath)).digest("hex");
1222
+ controlHash = createHash("sha256").update(readFileSync(controlPath)).digest("hex");
1223
+ } catch (e) {
1252
1224
  return fail(
1253
- `${mode} run did not complete or output capture was incomplete` +
1254
- (r.infraError ? ` (infra: ${r.output.trim()})` : "") +
1255
- ". A crash is not evidence. Case remains investigating.",
1256
- { run: r, pocCrashed: true },
1225
+ `Cannot read PoC/control scripts for the same-file check: ${(e as Error).message}`,
1226
+ { sameFileCheckFailed: true },
1257
1227
  );
1258
1228
  }
1259
- if (r.evidenceError) {
1229
+ if (pocHash !== controlHash) {
1260
1230
  return fail(
1261
- `EVIDENCE CONTRACT FAILED (${mode} run): ${r.evidenceError}. ` +
1262
- "The PoC must write evidence.json to $PI_POC_EVIDENCE_DIR — { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status?, body_contains / body_regex } }, observations }; a response-body assertion is mandatory — " +
1263
- "the file is bound to this run and validated by the harness. Case remains investigating.",
1264
- { run: r, evidenceError: r.evidenceError },
1231
+ "CONTROL CHECK FAILED: control_path must be the SAME script as poc_path (sha256 mismatch). Case remains investigating.",
1232
+ { controlHashMismatch: true },
1265
1233
  );
1266
1234
  }
1267
- if (!r.evidence || !r.evidenceSha256 || !r.nonce) {
1268
- return fail(`${mode} run produced no evidence. Case remains investigating.`, {
1269
- run: r,
1270
- });
1235
+
1236
+ // ── OOB callback (Tier 1, opt-in for blind classes) ──
1237
+ const oobRequested = params.oob === true;
1238
+ if (oobRequested) {
1239
+ return fail(
1240
+ "OOB confirmation is fail-closed: the built-in loopback listener is reachable by the PoC and cannot prove the target caused a callback. A source-separated, operator-owned callback service is required before blind findings can be promoted.",
1241
+ { oobSourceSeparationRequired: true },
1242
+ );
1271
1243
  }
1272
- return {
1273
- mode,
1274
- target,
1275
- nonce: r.nonce,
1276
- ranAt: r.ranAt,
1277
- exitCode: r.exitCode,
1278
- sandbox: r.sandbox,
1279
- completed: r.completed,
1280
- outputComplete: r.outputComplete,
1281
- output: r.output ?? "",
1282
- evidence: r.evidence,
1283
- evidenceSha256: r.evidenceSha256,
1284
- evidencePath: r.evidencePath,
1244
+ const runOptions = (pocMode: string, target: string): PocRunOptions => ({
1245
+ network: params.local === true ? "host" : "none",
1246
+ local: params.local === true,
1247
+ env: {
1248
+ PI_POC_MODE: pocMode,
1249
+ PI_POC_TARGET: target,
1250
+ },
1251
+ });
1252
+
1253
+ const caseTarget = current.target ?? "";
1254
+ // Determinism: TWO target runs + one control run. Exit 0 is run
1255
+ // integrity only; nonce-bound body evidence and the harness-owned
1256
+ // target/control replay form the machine gate.
1257
+ const run1 = runPoc(pocPath, runOptions("poc", caseTarget));
1258
+ const run2 = runPoc(pocPath, runOptions("poc", caseTarget));
1259
+ const controlRun = runPoc(controlPath, runOptions("control", controlTarget));
1260
+
1261
+ const evidenceRun = (
1262
+ r: PocRun,
1263
+ mode: "poc" | "control",
1264
+ target: string,
1265
+ ): PocEvidenceRun => {
1266
+ if (!r.completed || !r.outputComplete) {
1267
+ return fail(
1268
+ `${mode} run did not complete or output capture was incomplete` +
1269
+ (r.infraError ? ` (infra: ${r.output.trim()})` : "") +
1270
+ ". A crash is not evidence. Case remains investigating.",
1271
+ { run: r, pocCrashed: true },
1272
+ );
1273
+ }
1274
+ if (r.evidenceError) {
1275
+ return fail(
1276
+ `EVIDENCE CONTRACT FAILED (${mode} run): ${r.evidenceError}. ` +
1277
+ "The PoC must write evidence.json to $PI_POC_EVIDENCE_DIR — { nonce (echo $PI_POC_NONCE), claim, verify: { method, url, expect: { status?, body_contains / body_regex } }, observations }; a response-body assertion is mandatory — " +
1278
+ "the file is bound to this run and validated by the harness. Case remains investigating.",
1279
+ { run: r, evidenceError: r.evidenceError },
1280
+ );
1281
+ }
1282
+ if (!r.evidence || !r.evidenceSha256 || !r.nonce) {
1283
+ return fail(`${mode} run produced no evidence. Case remains investigating.`, {
1284
+ run: r,
1285
+ });
1286
+ }
1287
+ return {
1288
+ mode,
1289
+ target,
1290
+ nonce: r.nonce,
1291
+ ranAt: r.ranAt,
1292
+ exitCode: r.exitCode,
1293
+ sandbox: r.sandbox,
1294
+ completed: r.completed,
1295
+ outputComplete: r.outputComplete,
1296
+ output: r.output ?? "",
1297
+ evidence: r.evidence,
1298
+ evidenceSha256: r.evidenceSha256,
1299
+ evidencePath: r.evidencePath,
1300
+ };
1285
1301
  };
1286
- };
1287
1302
 
1288
- targetRuns = [evidenceRun(run1, "poc", caseTarget), evidenceRun(run2, "poc", caseTarget)];
1289
- control = evidenceRun(controlRun, "control", controlTarget);
1290
-
1291
- // Tier 2 (docs/poc-trust-model.md): the harness executes the SAME
1292
- // request template against target and operator-approved control, applying
1293
- // the target's predicates to both. DNS is pinned at connect time.
1294
- harnessVerified = await replayDifferential(
1295
- targetRuns[0].evidence,
1296
- caseTarget,
1297
- controlTarget,
1298
- { allowPrivate: process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1" },
1299
- );
1303
+ const targetRuns: [PocEvidenceRun, PocEvidenceRun] = [
1304
+ evidenceRun(run1, "poc", caseTarget),
1305
+ evidenceRun(run2, "poc", caseTarget),
1306
+ ];
1307
+ const control = evidenceRun(controlRun, "control", controlTarget);
1308
+
1309
+ // Tier 2 (docs/poc-trust-model.md): the harness executes the SAME
1310
+ // request template against target and operator-approved control, applying
1311
+ // the target's predicates to both. DNS is pinned at connect time.
1312
+ const harnessVerified = await replayDifferential(
1313
+ targetRuns[0].evidence,
1314
+ caseTarget,
1315
+ controlTarget,
1316
+ { allowPrivate: process.env.PI_POC_ALLOW_PRIVATE_REPLAY === "1" },
1317
+ );
1300
1318
 
1301
- const bundle: PendingConfirmation = {
1302
- caseId,
1303
- ranAt: new Date().toISOString(),
1304
- pocPath,
1305
- pocSha256: pocHash,
1306
- controlPath,
1307
- controlTarget,
1308
- targetRuns,
1309
- controlRun: control,
1310
- harnessVerified,
1311
- };
1319
+ const bundle: PendingConfirmation = {
1320
+ caseId,
1321
+ ranAt: new Date().toISOString(),
1322
+ pocPath,
1323
+ pocSha256: pocHash,
1324
+ controlPath,
1325
+ controlTarget,
1326
+ targetRuns,
1327
+ controlRun: control,
1328
+ harnessVerified,
1329
+ };
1312
1330
 
1313
- let record: CaseRecord;
1314
- try {
1315
- record = storePendingConfirmation(caseId, bundle);
1316
- } catch (e) {
1317
- return fail(`Pending confirmation rejected: ${(e as Error).message}`, {
1318
- storeRejected: true,
1319
- });
1320
- }
1331
+ let record: CaseRecord;
1332
+ try {
1333
+ record = storePendingConfirmation(caseId, bundle);
1334
+ } catch (e) {
1335
+ return fail(`Pending confirmation rejected: ${(e as Error).message}`, {
1336
+ storeRejected: true,
1337
+ });
1338
+ }
1321
1339
 
1322
- return {
1323
- content: [
1324
- {
1325
- type: "text",
1326
- text:
1327
- `Phase 1 complete — evidence bundle recorded on ${caseId} (expires in 1h).\n` +
1328
- `Target runs: 2, Control run: 1 — all with validated nonce-bound evidence.json.\n` +
1329
- `Evidence sha256: ${targetRuns[0].evidenceSha256}\n` +
1330
- `PoC script sha256 (at run time): ${pocHash}\n` +
1331
- `Harness verify replay: ${harnessVerified.attempted ? (harnessVerified.pass ? `PASS (status ${harnessVerified.status})` : `FAILED — ${harnessVerified.note}`) : harnessVerified.note}\n` +
1332
- `\nMAIN-AGENT REVIEW REQUIRED (do not delegate): inspect case ${caseId}, PoC ${pocPath}, control ${controlTarget}, evidence ${targetRuns[0].evidenceSha256}, and PoC hash ${pocHash}. Hunt for a trivial predicate or fabricated differential and perform a concrete disconfirmation attempt, then call ConfirmFinding yourself. A CONFIRMED call performs and stores a fresh harness-owned target/control replay; NOT_CONFIRMED keeps the case investigating.`,
1333
- },
1334
- ],
1335
- details: {
1336
- record,
1337
- bundle: {
1338
- caseId,
1339
- ranAt: bundle.ranAt,
1340
- pocPath,
1341
- controlPath,
1342
- controlTarget,
1343
- pocSha256: pocHash,
1344
- evidenceSha256: targetRuns[0].evidenceSha256,
1345
- harnessVerified,
1340
+ return {
1341
+ content: [
1342
+ {
1343
+ type: "text",
1344
+ text:
1345
+ `Phase 1 complete — evidence bundle recorded on ${caseId} (expires in 1h).\n` +
1346
+ `Target runs: 2, Control run: 1 — all with validated nonce-bound evidence.json.\n` +
1347
+ `Evidence sha256: ${targetRuns[0].evidenceSha256}\n` +
1348
+ `PoC script sha256 (at run time): ${pocHash}\n` +
1349
+ `Harness verify replay: ${harnessVerified.attempted ? (harnessVerified.pass ? `PASS (status ${harnessVerified.status})` : `FAILED — ${harnessVerified.note}`) : harnessVerified.note}\n` +
1350
+ `\nMAIN-AGENT REVIEW REQUIRED (do not delegate): inspect case ${caseId}, PoC ${pocPath}, control ${controlTarget}, evidence ${targetRuns[0].evidenceSha256}, and PoC hash ${pocHash}. Hunt for a trivial predicate or fabricated differential and perform a concrete disconfirmation attempt, then call ConfirmFinding yourself. A CONFIRMED call performs and stores a fresh harness-owned target/control replay; NOT_CONFIRMED keeps the case investigating.`,
1351
+ },
1352
+ ],
1353
+ details: {
1354
+ record,
1355
+ bundle: {
1356
+ caseId,
1357
+ ranAt: bundle.ranAt,
1358
+ pocPath,
1359
+ controlPath,
1360
+ controlTarget,
1361
+ pocSha256: pocHash,
1362
+ evidenceSha256: targetRuns[0].evidenceSha256,
1363
+ harnessVerified,
1364
+ },
1346
1365
  },
1347
- },
1348
- };
1349
- },
1366
+ };
1367
+ },
1350
1368
 
1351
- renderCall(args, theme) {
1352
- return callLine(theme, "PromoteFinding", (args.id as string) ?? "");
1353
- },
1369
+ renderCall(args, theme) {
1370
+ return callLine(theme, "PromoteFinding", (args.id as string) ?? "");
1371
+ },
1354
1372
 
1355
- renderResult(result, _opts, theme) {
1356
- const details = result.details as { bundle?: { evidenceSha256?: string } } | undefined;
1357
- if (!details?.bundle) {
1358
- return new Text(theme.fg("error", "✗ PromoteFinding failed"), 0, 0);
1359
- }
1360
- return new Text(
1361
- theme.fg("success", "✓ ") +
1362
- theme.fg("dim", "evidence bundle ") +
1363
- theme.fg("muted", details.bundle.evidenceSha256?.slice(0, 12) ?? ""),
1364
- 0,
1365
- 0,
1366
- );
1367
- },
1368
- });
1373
+ renderResult(result, _opts, theme) {
1374
+ const details = result.details as { bundle?: { evidenceSha256?: string } } | undefined;
1375
+ if (!details?.bundle) {
1376
+ return new Text(theme.fg("error", "✗ PromoteFinding failed"), 0, 0);
1377
+ }
1378
+ return new Text(
1379
+ theme.fg("success", "✓ ") +
1380
+ theme.fg("dim", "evidence bundle ") +
1381
+ theme.fg("muted", details.bundle.evidenceSha256?.slice(0, 12) ?? ""),
1382
+ 0,
1383
+ 0,
1384
+ );
1385
+ },
1386
+ });
1369
1387
 
1370
1388
  // ── Tool: ConfirmFinding (phase 2) ──
1371
1389
 
@@ -1373,7 +1391,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1373
1391
  // execute-time check remains as defense in depth if process state changes
1374
1392
  // after registration or another integration forwards a stale tool handle.
1375
1393
  if (!startedAsSubagent)
1376
- pi.registerTool({
1394
+ registerCaseTool({
1377
1395
  name: "ConfirmFinding",
1378
1396
  label: "Main-Agent Confirmation",
1379
1397
  description:
@@ -1392,7 +1410,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
1392
1410
  async execute(_id, params, _signal, _onUpdate, _ctx) {
1393
1411
  if (isSubagentProcess()) {
1394
1412
  throw new Error(
1395
- "ConfirmFinding is reserved for the main/coordinator agent. A worker or subagent may produce evidence but cannot confirm a PoC.",
1413
+ "ConfirmFinding is reserved for the main/coordinator agent. A worker or subagent may gather or challenge evidence but cannot run validation or confirm a PoC.",
1396
1414
  );
1397
1415
  }
1398
1416
  const caseId = params.id as string;
@@ -1461,7 +1479,7 @@ ${formatCaseDetail(record)}`,
1461
1479
 
1462
1480
  // ── Tool: CaseGet ──
1463
1481
 
1464
- pi.registerTool({
1482
+ registerCaseTool({
1465
1483
  name: "CaseGet",
1466
1484
  label: "Get Case",
1467
1485
  description: "Get full details of a single case by ID.",
@@ -1490,7 +1508,7 @@ ${formatCaseDetail(record)}`,
1490
1508
 
1491
1509
  // ── Tool: CaseList ──
1492
1510
 
1493
- pi.registerTool({
1511
+ registerCaseTool({
1494
1512
  name: "CaseList",
1495
1513
  label: "List Cases",
1496
1514
  description:
@@ -1520,7 +1538,7 @@ ${formatCaseDetail(record)}`,
1520
1538
 
1521
1539
  // ── Tool: CaseSearch ──
1522
1540
 
1523
- pi.registerTool({
1541
+ registerCaseTool({
1524
1542
  name: "CaseSearch",
1525
1543
  label: "Search Cases",
1526
1544
  description:
@@ -1548,7 +1566,7 @@ ${formatCaseDetail(record)}`,
1548
1566
 
1549
1567
  // ── Tool: CaseLink ──
1550
1568
 
1551
- pi.registerTool({
1569
+ registerCaseTool({
1552
1570
  name: "CaseLink",
1553
1571
  label: "Link Cases",
1554
1572
  description:
@@ -1620,7 +1638,7 @@ ${formatCaseDetail(record)}`,
1620
1638
 
1621
1639
  // ── Tool: CaseUnlink ──
1622
1640
 
1623
- pi.registerTool({
1641
+ registerCaseTool({
1624
1642
  name: "CaseUnlink",
1625
1643
  label: "Unlink Cases",
1626
1644
  description: "Remove a bidirectional link between two cases.",
@@ -1687,7 +1705,7 @@ ${formatCaseDetail(record)}`,
1687
1705
  { additionalProperties: false },
1688
1706
  );
1689
1707
 
1690
- pi.registerTool({
1708
+ registerCaseTool({
1691
1709
  name: "ChainSuggest",
1692
1710
  label: "Suggest Exploit Chains",
1693
1711
  description:
@@ -1736,15 +1754,15 @@ ${formatCaseDetail(record)}`,
1736
1754
 
1737
1755
  // ── Tool: CaseContext ──
1738
1756
 
1739
- pi.registerTool({
1757
+ registerCaseTool({
1740
1758
  name: "CaseContext",
1741
1759
  label: "Generate Case Context",
1742
1760
  description:
1743
- "Generate the case context bundle for a confirmed or reported case under the casefile report directory (next to the casefile DB): full evidence, PoC verification log, disconfirmation attempt, links, and timeline, plus the target report path. The report writer (reporter subagent) turns this context into the final polished H1-style report. Hypothesis/investigating/blocked/killed cases are rejected — promote to confirmed first.",
1744
- promptSnippet: "Generate case context for the report writer",
1761
+ "Generate the case context bundle for a confirmed or reported case under the casefile report directory (next to the casefile DB): full evidence, PoC verification log, disconfirmation attempt, links, and timeline, plus the target report path. The main agent turns this context into the final polished H1-style report. Hypothesis/investigating/blocked/killed cases are rejected — promote to confirmed first.",
1762
+ promptSnippet: "Generate case context for the final report",
1745
1763
  promptGuidelines: [
1746
1764
  "Use CaseContext only for confirmed or already reported cases. Keep hypotheses and investigating cases in the ledger until proof is captured.",
1747
- "After CaseContext, dispatch the reporter subagent (agents/reporter) to write the final report to the returned report path, then CaseUpdate(status: 'reported').",
1765
+ "After CaseContext, write the final report to the returned report path yourself, then CaseUpdate(status: 'reported').",
1748
1766
  ],
1749
1767
  parameters: IdSchema,
1750
1768
 
@@ -1754,7 +1772,7 @@ ${formatCaseDetail(record)}`,
1754
1772
  content: [
1755
1773
  {
1756
1774
  type: "text",
1757
- text: `Case context written: ${contextPath}\nReport path (for the reporter agent): ${path}\n${formatCase(record)}`,
1775
+ text: `Case context written: ${contextPath}\nReport path: ${path}\n${formatCase(record)}`,
1758
1776
  },
1759
1777
  ],
1760
1778
  details: { path, contextPath, record },
@@ -1779,7 +1797,7 @@ ${formatCaseDetail(record)}`,
1779
1797
 
1780
1798
  pi.registerCommand("xp", {
1781
1799
  description:
1782
- "Toggle casefile XP (offensive) mode. ON injects the full cyber workflow (subagent pipeline); LITE injects the single-agent workflow (no subagent dispatch); OFF (default) keeps context quiet for normal dev work. Usage: /xp [on|off|lite]",
1800
+ "Toggle casefile XP (offensive) mode. Bare /xp and /xp on select SWARM, the bounded multi-agent workflow. LITE keeps XP single-agent. OFF (default) keeps context quiet for normal dev work. Usage: /xp [on|lite|swarm|off]",
1783
1801
  handler: async (args, ctx) => {
1784
1802
  const next = parseXpModeArg(args ?? "", readXpMode());
1785
1803
  writeXpMode(next);
@@ -1790,23 +1808,25 @@ ${formatCaseDetail(record)}`,
1790
1808
  if (next !== "off") workflowInjected = false;
1791
1809
  ctx.ui.notify(
1792
1810
  `Casefile XP mode: ${next.toUpperCase()} (takes effect on the next prompt)`,
1793
- next === "on" ? "info" : "warning",
1811
+ next === "off" ? "warning" : "info",
1794
1812
  );
1795
1813
  },
1796
1814
  });
1797
1815
 
1798
1816
  // ── Tool: PipelineSubmit ──
1799
1817
 
1800
- pi.registerTool({
1818
+ registerCaseTool({
1801
1819
  name: "PipelineSubmit",
1802
1820
  label: "Submit Stage Output",
1803
1821
  description:
1804
1822
  "Submit a pipeline stage's output (hunt, trace, skeptic, validate, chain, report) through the validation gate. Validates required fields against the stage spec (mirrors schemas/*.json), applies the deterministic pre-filter (test-path and file-existence filters on hunt findings, trivial dedup by file+class+line), and counts repair attempts (max 2, then rejected). A stage cannot advance on an invalid output — submit fixed output until accepted.",
1805
1823
  promptSnippet: "Validate and submit a pipeline stage's output",
1806
1824
  promptGuidelines: [
1807
- "Every stage output a subagent returns must go through PipelineSubmit before the next stage is dispatched — do not eyeball schemas.",
1825
+ "Every delegated stage output and every main-agent VALIDATE/REPORT output must go through PipelineSubmit before the next stage starts — do not eyeball schemas.",
1808
1826
  "verdict repair → fix the listed fields and re-submit the same output; budget is 2 attempts per finding, then rejected.",
1809
- "Skeptic: unparseable/schema-invalid = UNDETERMINED (never DISPROVEN). Tracer error = UNREACHABLE. Both return repair.",
1827
+ "Skeptic: unparseable/schema-invalid = no verdict (repair/re-dispatch); only schema-valid DISPROVEN kills, and schema-valid UNDETERMINED blocks validation.",
1828
+ "Tracer crash/invalid output = no trace verdict; repair or re-dispatch.",
1829
+ 'Only schema-valid trace_result: "UNREACHABLE" blocks advancement as a proven unreachable path; schema-valid UNDETERMINED blocks validation until resolved.',
1810
1830
  "Test-path findings and hallucinated files are rejected by the pre-filter, not repairable — the finding itself is noise.",
1811
1831
  ],
1812
1832
  parameters: Type.Object(
@@ -1866,7 +1886,7 @@ ${formatCaseDetail(record)}`,
1866
1886
 
1867
1887
  // ── Tool: ScratchpadInit ──
1868
1888
 
1869
- pi.registerTool({
1889
+ registerCaseTool({
1870
1890
  name: "ScratchpadInit",
1871
1891
  label: "Init Scratchpad",
1872
1892
  description:
@@ -1904,7 +1924,7 @@ ${formatCaseDetail(record)}`,
1904
1924
 
1905
1925
  // ── Tool: ScratchpadResume ──
1906
1926
 
1907
- pi.registerTool({
1927
+ registerCaseTool({
1908
1928
  name: "ScratchpadResume",
1909
1929
  label: "Resume Scratchpad",
1910
1930
  description:
@@ -1963,7 +1983,7 @@ ${formatCaseDetail(record)}`,
1963
1983
 
1964
1984
  // ── Tool: ScratchpadCheckpoint ──
1965
1985
 
1966
- pi.registerTool({
1986
+ registerCaseTool({
1967
1987
  name: "ScratchpadCheckpoint",
1968
1988
  label: "Checkpoint Phase",
1969
1989
  description:
@@ -2013,7 +2033,7 @@ ${formatCaseDetail(record)}`,
2013
2033
 
2014
2034
  // ── Tool: ScratchpadWrite ──
2015
2035
 
2016
- pi.registerTool({
2036
+ registerCaseTool({
2017
2037
  name: "ScratchpadWrite",
2018
2038
  label: "Write Artifact",
2019
2039
  description:
@@ -2059,7 +2079,7 @@ ${formatCaseDetail(record)}`,
2059
2079
 
2060
2080
  // ── Tool: ScratchpadRead ──
2061
2081
 
2062
- pi.registerTool({
2082
+ registerCaseTool({
2063
2083
  name: "ScratchpadRead",
2064
2084
  label: "Read Artifact",
2065
2085
  description:
@@ -2116,7 +2136,7 @@ ${formatCaseDetail(record)}`,
2116
2136
 
2117
2137
  // ── Tool: ScratchpadPhaseDone ──
2118
2138
 
2119
- pi.registerTool({
2139
+ registerCaseTool({
2120
2140
  name: "ScratchpadPhaseDone",
2121
2141
  label: "Phase Done?",
2122
2142
  description:
@@ -2159,7 +2179,7 @@ ${formatCaseDetail(record)}`,
2159
2179
 
2160
2180
  // ── Tool: ScratchpadClear ──
2161
2181
 
2162
- pi.registerTool({
2182
+ registerCaseTool({
2163
2183
  name: "ScratchpadClear",
2164
2184
  label: "Clear Run",
2165
2185
  description: