immune-brain 3.6.6 → 3.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +24 -3
  2. package/README.zh-CN.md +23 -3
  3. package/package.json +2 -1
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +29 -19
  6. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +932 -0
  7. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +200 -2
  8. package/plugins/immune-brain/dist/claude/mcp-server.mjs +3404 -227
  9. package/plugins/immune-brain/dist/imm-loop.md +22 -0
  10. package/plugins/immune-brain/dist/role-prompts/code-review.md +9 -1
  11. package/plugins/immune-brain/runtime/assurance/coordinator.ts +101 -3
  12. package/plugins/immune-brain/runtime/claude/interaction.ts +16 -3
  13. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +840 -17
  14. package/plugins/immune-brain/runtime/claude/mcp_server.ts +72 -10
  15. package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -0
  16. package/plugins/immune-brain/runtime/kernel/completion.ts +19 -1
  17. package/plugins/immune-brain/runtime/kernel/reducer.ts +162 -11
  18. package/plugins/immune-brain/runtime/kernel/refutation.ts +82 -0
  19. package/plugins/immune-brain/runtime/kernel/types.ts +34 -1
  20. package/plugins/immune-brain/runtime/kernel/validation.ts +202 -9
  21. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  22. package/plugins/immune-brain/runtime/prompts/code-review.md +9 -1
  23. package/plugins/immune-brain/runtime/unattended/batch_git.ts +775 -0
  24. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +203 -0
  25. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +1224 -0
  26. package/plugins/immune-brain/runtime/unattended/batch_state.ts +360 -0
  27. package/plugins/immune-brain/runtime/unattended/types.ts +60 -0
  28. package/plugins/immune-brain/skills/imm-loop/SKILL.md +1 -0
@@ -9,6 +9,8 @@ import {
9
9
  type ApprovalAuthorityRole,
10
10
  type ApprovalKind,
11
11
  type EvidenceStatus,
12
+ type FindingCounterevidence,
13
+ type FindingEvidence,
12
14
  type FindingKind,
13
15
  type FindingSource,
14
16
  type FindingStatus,
@@ -28,6 +30,7 @@ import {
28
30
  type TaskRecordV4,
29
31
  } from "./types";
30
32
  import { canonicalIntentHash, parseTaskIntentV1 } from "./intent";
33
+ import { refutationIdentity, refutationIsLive, anchorForEvidence, isFreshPassingQaAttestation } from "./refutation";
31
34
 
32
35
  const GIT_OBJECT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
33
36
 
@@ -157,7 +160,7 @@ const FINDING_KINDS: FindingKind[] = [
157
160
  "unresolved_user_decision",
158
161
  "replan_required",
159
162
  ];
160
- const FINDING_STATUSES: FindingStatus[] = ["open", "resolved"];
163
+ const FINDING_STATUSES: FindingStatus[] = ["open", "resolved", "refuted"];
161
164
  const FINDING_SOURCES: FindingSource[] = [
162
165
  "execution",
163
166
  "review",
@@ -171,6 +174,56 @@ const APPROVAL_AUTHORITY_ROLES: ApprovalAuthorityRole[] = [
171
174
  "user",
172
175
  ];
173
176
 
177
+ function nullableAnchor(
178
+ value: unknown,
179
+ path: string,
180
+ violations: string[],
181
+ ): string | null {
182
+ if (value === null || value === undefined) return null;
183
+ return stringAt(value, path, violations);
184
+ }
185
+
186
+ function parseFindingEvidence(
187
+ value: unknown,
188
+ path: string,
189
+ violations: string[],
190
+ ): FindingEvidence | null {
191
+ if (value === null || value === undefined) return null;
192
+ const item = objectAt(value, path, violations);
193
+ rejectUnknown(item, ["trigger", "caller_chain", "violated"], path, violations);
194
+ const trigger = stringAt(item.trigger, `${path}.trigger`, violations);
195
+ const chain = arrayAt(item.caller_chain, `${path}.caller_chain`, violations);
196
+ if (chain.length === 0)
197
+ violations.push(`${path}.caller_chain must contain at least one entry`);
198
+ const caller_chain = chain.map((entry, index) =>
199
+ stringAt(entry, `${path}.caller_chain[${index}]`, violations),
200
+ );
201
+ const violatedRaw = objectAt(item.violated, `${path}.violated`, violations);
202
+ rejectUnknown(violatedRaw, ["kind", "ref"], `${path}.violated`, violations);
203
+ const kind = enumAt(
204
+ violatedRaw.kind,
205
+ ["acceptance", "security_boundary"] as const,
206
+ `${path}.violated.kind`,
207
+ violations,
208
+ );
209
+ const ref = stringAt(violatedRaw.ref, `${path}.violated.ref`, violations);
210
+ return { trigger, caller_chain, violated: { kind, ref } };
211
+ }
212
+
213
+ function parseFindingCounterevidence(
214
+ value: unknown,
215
+ path: string,
216
+ violations: string[],
217
+ ): FindingCounterevidence | null {
218
+ if (value === null || value === undefined) return null;
219
+ const item = objectAt(value, path, violations);
220
+ rejectUnknown(item, ["attestation_id", "acceptance_id"], path, violations);
221
+ return {
222
+ attestation_id: stringAt(item.attestation_id, `${path}.attestation_id`, violations),
223
+ acceptance_id: stringAt(item.acceptance_id, `${path}.acceptance_id`, violations),
224
+ };
225
+ }
226
+
174
227
  function parseFinding(
175
228
  value: unknown,
176
229
  index: number,
@@ -180,7 +233,18 @@ function parseFinding(
180
233
  const item = objectAt(value, path, violations);
181
234
  rejectUnknown(
182
235
  item,
183
- ["id", "kind", "status", "acceptance_id", "source", "review_round", "summary"],
236
+ [
237
+ "id",
238
+ "kind",
239
+ "status",
240
+ "acceptance_id",
241
+ "source",
242
+ "review_round",
243
+ "summary",
244
+ "anchor",
245
+ "evidence",
246
+ "counterevidence",
247
+ ],
184
248
  path,
185
249
  violations,
186
250
  );
@@ -192,6 +256,21 @@ function parseFinding(
192
256
  source: enumAt(item.source, FINDING_SOURCES, `${path}.source`, violations),
193
257
  review_round: nullablePositiveInteger(item.review_round, `${path}.review_round`, violations),
194
258
  summary: stringAt(item.summary, `${path}.summary`, violations),
259
+ ...(item.anchor !== undefined
260
+ ? { anchor: nullableAnchor(item.anchor, `${path}.anchor`, violations) }
261
+ : {}),
262
+ ...(item.evidence !== undefined
263
+ ? { evidence: parseFindingEvidence(item.evidence, `${path}.evidence`, violations) }
264
+ : {}),
265
+ ...(item.counterevidence !== undefined
266
+ ? {
267
+ counterevidence: parseFindingCounterevidence(
268
+ item.counterevidence,
269
+ `${path}.counterevidence`,
270
+ violations,
271
+ ),
272
+ }
273
+ : {}),
195
274
  };
196
275
  }
197
276
 
@@ -645,6 +724,55 @@ function parseTaskRecordAtVersion(raw: unknown, version: 3 | 4): TaskRecordV3 |
645
724
  uniqueIds(attestations, "record.attestations", violations);
646
725
  uniqueIds(findings, "record.findings", violations);
647
726
  uniqueIds(history, "record.history", violations);
727
+ // A persisted refutation is authority: it suppresses a blocking finding
728
+ // until its bound evidence goes stale. The record must therefore prove the
729
+ // binding is one the Kernel could have written, or a malformed record could
730
+ // suppress a finding with evidence for a different acceptance.
731
+ for (const [index, finding] of findings.entries()) {
732
+ const path = `record.findings[${index}]`;
733
+ if (finding.anchor != null && !SHA256_HEX.test(finding.anchor))
734
+ violations.push(`${path}.anchor must be sha256:<64 hex>`);
735
+ const anchor = finding.anchor ?? null;
736
+ const evidence = finding.evidence ?? null;
737
+ if ((anchor === null) !== (evidence === null))
738
+ violations.push(`${path} must carry anchor and evidence together`);
739
+ if (anchor !== null && evidence !== null && anchor !== anchorForEvidence(evidence))
740
+ violations.push(`${path}.anchor must equal the digest of its evidence`);
741
+ // A refuted user decision or replan boundary would hide the very gate
742
+ // those findings exist to hold; only review claims may be refuted.
743
+ if (
744
+ finding.status === "refuted" &&
745
+ (finding.kind === "unresolved_user_decision" || finding.kind === "replan_required")
746
+ )
747
+ violations.push(`${path} refuted requires a blocking or advisory finding`);
748
+ const counterevidence = finding.counterevidence ?? null;
749
+ if (finding.status === "refuted" && !counterevidence)
750
+ violations.push(`${path} status refuted requires counterevidence`);
751
+ if (!counterevidence) continue;
752
+ if (finding.status === "open")
753
+ violations.push(`${path} open finding cannot carry counterevidence`);
754
+ // The binding need not name the finding's own acceptance: an inherited
755
+ // refutation carries the QA evidence it was originally proved with. It
756
+ // must name an acceptance that evidence actually reports as passed, or
757
+ // the refutation would assert nothing.
758
+ const attestation = attestations.find(
759
+ (item) => item.id === counterevidence.attestation_id,
760
+ );
761
+ if (!attestation || attestation.kind !== "qa")
762
+ violations.push(
763
+ `${path}.counterevidence.attestation_id must reference a qa attestation`,
764
+ );
765
+ else if (
766
+ !attestation.acceptance_results.some(
767
+ (result) =>
768
+ result.acceptance_id === counterevidence.acceptance_id &&
769
+ result.status === "passed",
770
+ )
771
+ )
772
+ violations.push(
773
+ `${path}.counterevidence.acceptance_id must be an acceptance its attestation passed`,
774
+ );
775
+ }
648
776
  if (violations.length > 0) throw new KernelValidationError(violations);
649
777
  const record = {
650
778
  contract: expectedContract,
@@ -729,6 +857,7 @@ export function assertKernelInvariantsV2(
729
857
  const ACTION_V2_TYPES = [
730
858
  "record_finding",
731
859
  "resolve_finding",
860
+ "refute_finding",
732
861
  "record_approval",
733
862
  "revise_intent",
734
863
  "approve_breaking_intent_revision",
@@ -831,6 +960,29 @@ export function parseTaskAction(raw: unknown): TaskAction {
831
960
  };
832
961
  break;
833
962
  }
963
+ case "refute_finding": {
964
+ rejectUnknown(
965
+ value,
966
+ [...ACTION_BASE_FIELDS, "finding_id", "attestation_id"],
967
+ "action",
968
+ violations,
969
+ );
970
+ action = {
971
+ ...base,
972
+ type: "refute_finding",
973
+ finding_id: stringAt(
974
+ value.finding_id,
975
+ "action.finding_id",
976
+ violations,
977
+ ),
978
+ attestation_id: stringAt(
979
+ value.attestation_id,
980
+ "action.attestation_id",
981
+ violations,
982
+ ),
983
+ };
984
+ break;
985
+ }
834
986
  case "record_approval": {
835
987
  rejectUnknown(
836
988
  value,
@@ -1032,22 +1184,63 @@ export function assertTaskRecordUpdateV3(
1032
1184
  .filter((item) => item.kind === "replan_required" && item.status === "open")
1033
1185
  .map((item) => item.id)
1034
1186
  : [];
1035
- const reworkFindingIds =
1036
- action.type === "request_rework"
1037
- ? new Set(action.findings.map((item) => item.id))
1038
- : new Set<string>();
1187
+ // A transition may change only the fields it writes; a legal-looking
1188
+ // transition must not carry a rewritten claim along with it.
1189
+ const withoutFields = (finding: TaskFinding, fields: readonly string[]): string => {
1190
+ const copy: Record<string, unknown> = { ...finding };
1191
+ for (const field of fields) delete copy[field];
1192
+ return JSON.stringify(copy);
1193
+ };
1039
1194
  for (const prior of previous.findings) {
1040
1195
  const current = next.findings.find((item) => item.id === prior.id);
1041
1196
  if (!current)
1042
1197
  violations.push(`finding item ${prior.id} was removed`);
1043
1198
  else if (
1044
1199
  JSON.stringify(current) !== JSON.stringify(prior) &&
1200
+ // A finding may move to resolved from open, or from a refutation
1201
+ // whose bound evidence went stale (the Spec's stale -> resolved
1202
+ // edge); a refutation that is still live already suppresses the
1203
+ // finding and may not be made permanent. Only refute_finding may set
1204
+ // or renew refuted, and only stale refutations are rebindable.
1045
1205
  !(
1046
1206
  resolvingFindingIds.includes(prior.id) &&
1047
- prior.status === "open" &&
1048
- current.status === "resolved"
1207
+ prior.status !== "resolved" &&
1208
+ current.status === "resolved" &&
1209
+ (prior.status === "open" ||
1210
+ !refutationIsLive(
1211
+ prior,
1212
+ previous.attestations,
1213
+ refutationIdentity(previous, action.diff_hash),
1214
+ )) &&
1215
+ withoutFields(prior, ["status"]) === withoutFields(current, ["status"])
1049
1216
  ) &&
1050
- !(reworkFindingIds.has(prior.id) && current.review_round !== null)
1217
+ !(
1218
+ action.type === "refute_finding" &&
1219
+ prior.id === action.finding_id &&
1220
+ prior.status !== "resolved" &&
1221
+ current.status === "refuted" &&
1222
+ // First refutation, or the Spec's stale -> refuted renewal: a
1223
+ // refutation that is still live may neither be renewed nor
1224
+ // rebound to other evidence.
1225
+ (prior.status === "open" ||
1226
+ !refutationIsLive(
1227
+ prior,
1228
+ previous.attestations,
1229
+ refutationIdentity(previous, action.diff_hash),
1230
+ )) &&
1231
+ current.counterevidence?.attestation_id === action.attestation_id &&
1232
+ // The renewal binds the acceptance the finding names, not just any
1233
+ // acceptance the attestation happens to pass (Spec: the evidence
1234
+ // must cover the finding's own acceptance_id).
1235
+ current.counterevidence?.acceptance_id === prior.acceptance_id &&
1236
+ isFreshPassingQaAttestation(
1237
+ previous.attestations.find((item) => item.id === action.attestation_id),
1238
+ current.counterevidence?.acceptance_id ?? "",
1239
+ refutationIdentity(previous, action.diff_hash),
1240
+ ) &&
1241
+ withoutFields(prior, ["status", "counterevidence"]) ===
1242
+ withoutFields(current, ["status", "counterevidence"])
1243
+ )
1051
1244
  )
1052
1245
  violations.push(`finding item ${prior.id} was rewritten`);
1053
1246
  }
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/plugin_versioning.ts from the root package.json.
2
- export const PLUGIN_VERSION = "3.6.6" as const;
2
+ export const PLUGIN_VERSION = "3.6.7" as const;
@@ -30,6 +30,14 @@ Return exactly one JSON object with the fields required by the Loop review
30
30
  contract: `contract`, `role`, `task_id`, `snapshot_digest`, `decision` (`pass`
31
31
  or `rework`), and for `pass` include `approval` (`kind`, `authority_role`,
32
32
  `summary`), for `rework` include `findings` (`id`, `kind`, `acceptance_id`,
33
- `summary`). Do not invent fields. A passing review has no findings. If the
33
+ `summary`, `evidence`). Every rework finding's `evidence` must carry
34
+ `trigger` (the concrete inputs or state that reach the defect), a non-empty
35
+ `caller_chain` (ordered repository paths or symbols), and `violated`
36
+ (`kind`: `acceptance` or `security_boundary`, `ref`: the acceptance id or
37
+ boundary name). Do not invent fields, and never send an anchor yourself: the
38
+ Kernel derives it as the sha256 of the canonical `{violated.kind,
39
+ violated.ref, caller_chain}`, so an identical claim keeps one stable identity
40
+ across review rounds while a different call chain is a different claim. A
41
+ passing review has no findings. If the
34
42
  checkpoint is `awaiting_user_successor_decision`, stop without dispatch; only
35
43
  a literal user may invoke `--approve-successor`.