immune-brain 5.3.0 → 5.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +4 -4
  2. package/README.zh-CN.md +4 -4
  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-enroll.ts +54 -70
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +163 -28
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +71 -10
  8. package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +1 -1
  9. package/plugins/immune-brain/.pi-extension/pi-run-continuation.ts +161 -0
  10. package/plugins/immune-brain/agents/immune-brain-reviewer.md +26 -2
  11. package/plugins/immune-brain/dist/BASELINE.md +13 -1
  12. package/plugins/immune-brain/dist/claude/mcp-server.mjs +16130 -12734
  13. package/plugins/immune-brain/dist/docs/reference/code-quality-guard.md +3 -1
  14. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +41 -1
  15. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  16. package/plugins/immune-brain/dist/imm-planner.md +57 -16
  17. package/plugins/immune-brain/dist/imm-run-batch.md +398 -0
  18. package/plugins/immune-brain/dist/imm-run.md +89 -344
  19. package/plugins/immune-brain/dist/role-prompts/code-review.md +26 -2
  20. package/plugins/immune-brain/dist/role-prompts/executor.md +35 -10
  21. package/plugins/immune-brain/hooks/hooks.json +21 -1
  22. package/plugins/immune-brain/runtime/assurance/coordinator.ts +203 -13
  23. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +2 -1
  24. package/plugins/immune-brain/runtime/assurance/enrollment_git_base.ts +99 -14
  25. package/plugins/immune-brain/runtime/assurance/phase_entry.ts +1 -7
  26. package/plugins/immune-brain/runtime/assurance/qa.ts +126 -43
  27. package/plugins/immune-brain/runtime/assurance/qa_findings.ts +8 -0
  28. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +48 -0
  29. package/plugins/immune-brain/runtime/assurance/run_continuation.ts +284 -0
  30. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +46 -4
  31. package/plugins/immune-brain/runtime/assurance/verification.ts +83 -35
  32. package/plugins/immune-brain/runtime/assurance/workflow_autocommit.ts +222 -0
  33. package/plugins/immune-brain/runtime/claude/interaction.ts +11 -2
  34. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +199 -48
  35. package/plugins/immune-brain/runtime/claude/lane_guard.ts +8 -15
  36. package/plugins/immune-brain/runtime/claude/mcp_server.ts +105 -9
  37. package/plugins/immune-brain/runtime/claude/run_continuation.ts +274 -0
  38. package/plugins/immune-brain/runtime/commands/kernel.ts +41 -103
  39. package/plugins/immune-brain/runtime/github_issue_tracker.ts +60 -2
  40. package/plugins/immune-brain/runtime/kernel/application.ts +4 -1
  41. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +103 -20
  42. package/plugins/immune-brain/runtime/kernel/canary_application.ts +57 -7
  43. package/plugins/immune-brain/runtime/kernel/completion.ts +28 -4
  44. package/plugins/immune-brain/runtime/kernel/enrollment.ts +48 -8
  45. package/plugins/immune-brain/runtime/kernel/intent.ts +169 -104
  46. package/plugins/immune-brain/runtime/kernel/reducer.ts +136 -6
  47. package/plugins/immune-brain/runtime/kernel/risk_floor.ts +286 -0
  48. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +83 -1
  49. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +1 -1
  50. package/plugins/immune-brain/runtime/kernel/types.ts +49 -3
  51. package/plugins/immune-brain/runtime/kernel/validation.ts +147 -7
  52. package/plugins/immune-brain/runtime/loop_contract.ts +5 -0
  53. package/plugins/immune-brain/runtime/plan_core.ts +15 -8
  54. package/plugins/immune-brain/runtime/plugin_source_skew.ts +58 -0
  55. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  56. package/plugins/immune-brain/runtime/prompts/code-review.md +26 -2
  57. package/plugins/immune-brain/runtime/prompts/executor.md +35 -10
  58. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +5 -3
  59. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +40 -10
  60. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +57 -3
  61. package/plugins/immune-brain/runtime/unattended/batch_git.ts +424 -66
  62. package/plugins/immune-brain/runtime/unattended/batch_integration.ts +2 -1
  63. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +13 -0
  64. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +43 -24
  65. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +6 -2
  66. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +102 -41
  67. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +9 -3
  68. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +9 -9
  69. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +90 -32
  70. package/plugins/immune-brain/runtime/unattended/batch_state.ts +102 -4
  71. package/plugins/immune-brain/runtime/unattended/parent_delivery_consolidation.ts +248 -0
  72. package/plugins/immune-brain/runtime/workspace_scope.ts +126 -18
  73. package/plugins/immune-brain/skills/BASELINE.md +13 -1
  74. package/plugins/immune-brain/skills/imm-planner/SKILL.md +5 -0
  75. package/plugins/immune-brain/skills/imm-run/SKILL.md +12 -8
@@ -12,6 +12,8 @@ import {
12
12
  type FindingKind,
13
13
  type FindingSource,
14
14
  type FindingStatus,
15
+ type ReplanCause,
16
+ type ResidualRiskAcceptanceV1,
15
17
  type TaskAction,
16
18
  type ReviewAdvisoryFindingV1,
17
19
  type TaskApprovalV2,
@@ -156,7 +158,10 @@ const FINDING_KINDS: FindingKind[] = [
156
158
  "unresolved_user_decision",
157
159
  "replan_required",
158
160
  ];
159
- const FINDING_STATUSES: FindingStatus[] = ["open", "resolved", "refuted"];
161
+ const FINDING_STATUSES: FindingStatus[] = ["open", "resolved", "refuted", "accepted_risk"];
162
+ const REPLAN_CAUSES: ReplanCause[] = ["budget_exhausted", "repeated_security_boundary"];
163
+ /** Upper bound of one `authorize_rework` grant. */
164
+ export const MAX_REWORK_GRANT_ROUNDS = 5;
160
165
  const FINDING_SOURCES: FindingSource[] = [
161
166
  "execution",
162
167
  "review",
@@ -240,6 +245,7 @@ export function parseFinding(
240
245
  "anchor",
241
246
  "evidence",
242
247
  "counterevidence",
248
+ "replan_cause",
243
249
  ],
244
250
  path,
245
251
  violations,
@@ -267,6 +273,9 @@ export function parseFinding(
267
273
  ),
268
274
  }
269
275
  : {}),
276
+ ...(item.replan_cause !== undefined
277
+ ? { replan_cause: enumAt(item.replan_cause, REPLAN_CAUSES, `${path}.replan_cause`, violations) }
278
+ : {}),
270
279
  };
271
280
  }
272
281
 
@@ -277,7 +286,7 @@ function parseHistoryV3(
277
286
  ): TaskHistoryEntryV3 {
278
287
  const path = `record.history[${index}]`;
279
288
  const item = objectAt(value, path, violations);
280
- rejectUnknown(item, ["id", "at", "type", "from_state", "to_state", "reason", "authority"], path, violations);
289
+ rejectUnknown(item, ["id", "at", "type", "from_state", "to_state", "reason", "authority", "review_revision"], path, violations);
281
290
  let authority: AuthorityAuditDescriptor | undefined;
282
291
  if (item.authority !== undefined) {
283
292
  const auth = objectAt(item.authority, `${path}.authority`, violations);
@@ -292,6 +301,11 @@ function parseHistoryV3(
292
301
  : {}),
293
302
  };
294
303
  }
304
+ const reviewRevision = item.review_revision === undefined
305
+ ? undefined
306
+ : parseReviewRevisionIdentity(item.review_revision, `${path}.review_revision`, violations);
307
+ if (reviewRevision && authority?.authority_kind !== "review")
308
+ violations.push(`${path}.review_revision is only valid on review-authored history`);
295
309
  return {
296
310
  id: stringAt(item.id, `${path}.id`, violations),
297
311
  at: stringAt(item.at, `${path}.at`, violations),
@@ -300,6 +314,7 @@ function parseHistoryV3(
300
314
  to_state: stringAt(item.to_state, `${path}.to_state`, violations),
301
315
  reason: stringAt(item.reason, `${path}.reason`, violations),
302
316
  ...(authority ? { authority } : {}),
317
+ ...(reviewRevision ? { review_revision: reviewRevision } : {}),
303
318
  };
304
319
  }
305
320
 
@@ -488,13 +503,46 @@ function parseAttestationV3(
488
503
  };
489
504
  }
490
505
 
506
+ function parseResidualRiskAcceptance(
507
+ value: unknown,
508
+ path: string,
509
+ violations: string[],
510
+ ): ResidualRiskAcceptanceV1 {
511
+ const item = objectAt(value, path, violations);
512
+ rejectUnknown(
513
+ item,
514
+ ["intent_revision", "intent_content_hash", "diff_hash", "finding_ids", "reason", "actor_id", "accepted_at"],
515
+ path,
516
+ violations,
517
+ );
518
+ const intentContentHash = stringAt(item.intent_content_hash, `${path}.intent_content_hash`, violations);
519
+ if (!SHA256_HEX.test(intentContentHash)) violations.push(`${path}.intent_content_hash must be sha256:<64 hex>`);
520
+ const diffHash = stringAt(item.diff_hash, `${path}.diff_hash`, violations);
521
+ if (!SHA256_HEX.test(diffHash)) violations.push(`${path}.diff_hash must be sha256:<64 hex>`);
522
+ const reason = stringAt(item.reason, `${path}.reason`, violations);
523
+ if (reason.trim() === "") violations.push(`${path}.reason must be non-empty`);
524
+ const findingIds = arrayAt(item.finding_ids, `${path}.finding_ids`, violations).map((entry, index) =>
525
+ stringAt(entry, `${path}.finding_ids[${index}]`, violations),
526
+ );
527
+ if (findingIds.length === 0) violations.push(`${path}.finding_ids must name at least one finding`);
528
+ return {
529
+ intent_revision: positiveInteger(item.intent_revision, `${path}.intent_revision`, violations),
530
+ intent_content_hash: intentContentHash,
531
+ diff_hash: diffHash,
532
+ finding_ids: findingIds,
533
+ reason,
534
+ actor_id: stringAt(item.actor_id, `${path}.actor_id`, violations),
535
+ accepted_at: stringAt(item.accepted_at, `${path}.accepted_at`, violations),
536
+ };
537
+ }
538
+
491
539
  export function parseTaskRecordAtVersion(raw: unknown, version: 3 | 4): TaskRecordV3 | TaskRecordV4 {
492
540
  const violations: string[] = [];
493
541
  const value = objectAt(raw, "record", violations);
494
542
  rejectUnknown(
495
543
  value,
496
544
  ["contract", "task_id", "intent_snapshot", "intent_ref", "lifecycle", "artifact_state", "baseline", "attestations", "findings", "history",
497
- ...(version === 4 ? ["git_base_head"] : [])],
545
+ ...(version === 4 ? ["git_base_head", "review_round_budget_extension", "residual_risk_acceptance"] : [])],
498
546
  "record",
499
547
  violations,
500
548
  );
@@ -551,6 +599,28 @@ export function parseTaskRecordAtVersion(raw: unknown, version: 3 | 4): TaskReco
551
599
  }
552
600
  const findings = arrayAt(value.findings, "record.findings", violations).map((item, index) => parseFinding(item, index, violations));
553
601
  const history = arrayAt(value.history, "record.history", violations).map((item, index) => parseHistoryV3(item, index, violations));
602
+ for (const [index, finding] of findings.entries()) {
603
+ const path = `record.findings[${index}]`;
604
+ if (finding.replan_cause !== undefined && finding.kind !== "replan_required")
605
+ violations.push(`${path}.replan_cause is only valid on a replan_required finding`);
606
+ if (finding.status === "accepted_risk" && (finding.kind !== "blocking" || finding.source !== "review"))
607
+ violations.push(`${path} accepted_risk requires a blocking Review finding`);
608
+ }
609
+ let budgetExtension: number | undefined;
610
+ if (value.review_round_budget_extension !== undefined) {
611
+ budgetExtension = positiveInteger(value.review_round_budget_extension, "record.review_round_budget_extension", violations);
612
+ }
613
+ const residualRiskAcceptance = value.residual_risk_acceptance === undefined
614
+ ? undefined
615
+ : parseResidualRiskAcceptance(value.residual_risk_acceptance, "record.residual_risk_acceptance", violations);
616
+ if (findings.some((finding) => finding.status === "accepted_risk") && residualRiskAcceptance === undefined)
617
+ violations.push("record.residual_risk_acceptance is required while a finding is accepted_risk");
618
+ if (residualRiskAcceptance) {
619
+ for (const id of residualRiskAcceptance.finding_ids) {
620
+ if (!findings.some((finding) => finding.id === id && finding.status === "accepted_risk"))
621
+ violations.push(`record.residual_risk_acceptance.finding_ids names ${id}, which is not accepted_risk`);
622
+ }
623
+ }
554
624
  uniqueIds(attestations, "record.attestations", violations);
555
625
  uniqueIds(findings, "record.findings", violations);
556
626
  uniqueIds(history, "record.history", violations);
@@ -613,6 +683,8 @@ export function parseTaskRecordAtVersion(raw: unknown, version: 3 | 4): TaskReco
613
683
  artifact_state: artifactState as TaskRecordV3["artifact_state"],
614
684
  baseline,
615
685
  ...(version === 4 ? { git_base_head: gitBaseHead } : {}),
686
+ ...(version === 4 && budgetExtension !== undefined ? { review_round_budget_extension: budgetExtension } : {}),
687
+ ...(version === 4 && residualRiskAcceptance !== undefined ? { residual_risk_acceptance: residualRiskAcceptance } : {}),
616
688
  attestations,
617
689
  findings,
618
690
  history,
@@ -664,6 +736,7 @@ const ACTION_V2_TYPES = [
664
736
  "complete",
665
737
  "stop",
666
738
  "authorize_rework",
739
+ "accept_residual_risk",
667
740
  "resolve_user_decision",
668
741
  ] as const;
669
742
 
@@ -849,16 +922,33 @@ export function parseTaskAction(raw: unknown): TaskAction {
849
922
  };
850
923
  break;
851
924
  }
852
- case "complete":
853
- case "authorize_rework": {
925
+ case "complete": {
854
926
  rejectUnknown(value, [...ACTION_BASE_FIELDS], "action", violations);
855
927
  action = { ...base, type: base.type };
856
928
  break;
857
929
  }
930
+ case "authorize_rework": {
931
+ rejectUnknown(value, [...ACTION_BASE_FIELDS, "rounds"], "action", violations);
932
+ let rounds: number | undefined;
933
+ if (value.rounds !== undefined) {
934
+ rounds = positiveInteger(value.rounds, "action.rounds", violations);
935
+ if (rounds > MAX_REWORK_GRANT_ROUNDS)
936
+ violations.push(`action.rounds must be an integer from 1 to ${MAX_REWORK_GRANT_ROUNDS}`);
937
+ }
938
+ action = { ...base, type: "authorize_rework", ...(rounds !== undefined ? { rounds } : {}) };
939
+ break;
940
+ }
941
+ case "accept_residual_risk": {
942
+ rejectUnknown(value, [...ACTION_BASE_FIELDS, "reason"], "action", violations);
943
+ const reason = stringAt(value.reason, "action.reason", violations);
944
+ if (reason.trim() === "") violations.push("accept_residual_risk requires a non-empty reason");
945
+ action = { ...base, type: "accept_residual_risk", reason };
946
+ break;
947
+ }
858
948
  case "request_rework": {
859
949
  rejectUnknown(
860
950
  value,
861
- [...ACTION_BASE_FIELDS, "findings"],
951
+ [...ACTION_BASE_FIELDS, "findings", "review_revision"],
862
952
  "action",
863
953
  violations,
864
954
  );
@@ -869,7 +959,15 @@ export function parseTaskAction(raw: unknown): TaskAction {
869
959
  ).map((item, index) => parseFinding(item, index, violations));
870
960
  if (findings.length === 0)
871
961
  violations.push("action.findings must contain at least one finding");
872
- action = { ...base, type: "request_rework", findings };
962
+ const reviewRevision = value.review_revision === undefined
963
+ ? undefined
964
+ : parseReviewRevisionIdentity(value.review_revision, "action.review_revision", violations);
965
+ action = {
966
+ ...base,
967
+ type: "request_rework",
968
+ findings,
969
+ ...(reviewRevision ? { review_revision: reviewRevision } : {}),
970
+ };
873
971
  break;
874
972
  }
875
973
  case "stop": {
@@ -961,6 +1059,37 @@ export function assertTaskRecordUpdateV3(
961
1059
  violations.push("only artifact transitions may change intent_ref path");
962
1060
  }
963
1061
 
1062
+ // The budget extension grows only by an authorize_rework grant, and the
1063
+ // residual-risk acceptance changes only by accept_residual_risk.
1064
+ const previousExtension = previous.review_round_budget_extension ?? 0;
1065
+ const expectedExtension = previousExtension +
1066
+ (action.type === "authorize_rework" ? (action.rounds ?? 1) : 0);
1067
+ if ((next.review_round_budget_extension ?? 0) !== expectedExtension)
1068
+ violations.push("review_round_budget_extension changes only by an authorize_rework grant");
1069
+ if (action.type === "accept_residual_risk") {
1070
+ const acceptance = next.residual_risk_acceptance;
1071
+ const newlyAccepted = next.findings
1072
+ .filter((item) => item.status === "accepted_risk" &&
1073
+ previous.findings.find((prior) => prior.id === item.id)?.status !== "accepted_risk")
1074
+ .map((item) => item.id)
1075
+ .sort();
1076
+ if (!acceptance)
1077
+ violations.push("accept_residual_risk must record residual_risk_acceptance");
1078
+ else if (
1079
+ acceptance.diff_hash !== action.diff_hash ||
1080
+ acceptance.intent_revision !== next.intent_snapshot.revision ||
1081
+ acceptance.intent_content_hash !== next.intent_ref.content_hash ||
1082
+ acceptance.reason !== action.reason ||
1083
+ acceptance.actor_id !== action.actor_id ||
1084
+ JSON.stringify([...acceptance.finding_ids].sort()) !== JSON.stringify(newlyAccepted)
1085
+ )
1086
+ violations.push("residual_risk_acceptance must bind the accepting action and the findings it accepted");
1087
+ } else if (
1088
+ JSON.stringify(next.residual_risk_acceptance ?? null) !==
1089
+ JSON.stringify(previous.residual_risk_acceptance ?? null)
1090
+ )
1091
+ violations.push("residual_risk_acceptance changes only by accept_residual_risk");
1092
+
964
1093
  // Attestations and history are append-only.
965
1094
  if (next.attestations.length < previous.attestations.length)
966
1095
  violations.push("attestations are append-only");
@@ -978,6 +1107,7 @@ export function assertTaskRecordUpdateV3(
978
1107
  : action.type === "resolve_user_decision"
979
1108
  ? [action.finding_id]
980
1109
  : action.type === "authorize_rework" ||
1110
+ action.type === "accept_residual_risk" ||
981
1111
  action.type === "approve_breaking_intent_revision"
982
1112
  ? previous.findings
983
1113
  .filter((item) => item.kind === "replan_required" && item.status === "open")
@@ -1013,6 +1143,16 @@ export function assertTaskRecordUpdateV3(
1013
1143
  )) &&
1014
1144
  withoutFields(prior, ["status"]) === withoutFields(current, ["status"])
1015
1145
  ) &&
1146
+ !(
1147
+ // accept_residual_risk moves an open blocking Review finding to
1148
+ // accepted_risk and changes nothing else about it.
1149
+ action.type === "accept_residual_risk" &&
1150
+ prior.kind === "blocking" &&
1151
+ prior.source === "review" &&
1152
+ prior.status === "open" &&
1153
+ current.status === "accepted_risk" &&
1154
+ withoutFields(prior, ["status"]) === withoutFields(current, ["status"])
1155
+ ) &&
1016
1156
  !(
1017
1157
  action.type === "refute_finding" &&
1018
1158
  prior.id === action.finding_id &&
@@ -56,6 +56,10 @@ export interface LoopRoute {
56
56
  export function resolveLoopRoute(input: {
57
57
  ownership: LoopRouteOwnership;
58
58
  target: LoopRouteTarget;
59
+ /**
60
+ * A scope change that is not a same-directory file addition. The owner adds a
61
+ * sibling file itself with `revise_intent` (ADR 0021) and stays in the Loop.
62
+ */
59
63
  scope_expansion?: boolean;
60
64
  }): LoopRoute {
61
65
  if (input.scope_expansion) return { entry: "imm-run", next: "imm-planner" };
@@ -189,6 +193,7 @@ export function buildLoopAction(input: {
189
193
  ownership: LoopRouteOwnership;
190
194
  target: LoopRouteTarget;
191
195
  context?: RoleDelegationContext;
196
+ /** See `resolveLoopRoute`: a same-directory file addition is not one. */
192
197
  scope_expansion?: boolean;
193
198
  kernel_operation?: Extract<LoopAction, { next: "imm_kernel_canary" }>["tool"]["operation"];
194
199
  }): LoopAction {
@@ -665,7 +665,7 @@ function validateSpecDesign(spec: ParsedSpecDesign | null): {
665
665
  warnings: [
666
666
  {
667
667
  code: "spec_design_metadata_missing",
668
- message: `${spec.path} uses the legacy design contract; add Design risk, Diagram decision, and Diagram reason when revising it.`,
668
+ message: `${spec.path} uses the legacy design contract; add Design risk (and Diagram decision with Diagram reason only when a diagram is required) when revising it.`,
669
669
  field: "design_metadata",
670
670
  },
671
671
  ],
@@ -679,13 +679,20 @@ function validateSpecDesign(spec: ParsedSpecDesign | null): {
679
679
  if (!risk || !["low", "medium", "high"].includes(risk)) {
680
680
  errors.push("Referenced Spec Design risk must be Low, Medium, or High.");
681
681
  }
682
- if (!decision || !["required", "not_required"].includes(decision)) {
683
- errors.push(
684
- "Referenced Spec Diagram decision must be required or not_required.",
685
- );
686
- }
687
- if (!spec.diagram_reason) {
688
- errors.push("Referenced Spec is missing Diagram reason.");
682
+ // A Spec that needs no diagram records neither diagram field; once either
683
+ // field is present the pair must be complete.
684
+ const diagramFieldsRecorded = Boolean(
685
+ spec.diagram_decision || spec.diagram_reason,
686
+ );
687
+ if (diagramFieldsRecorded) {
688
+ if (!decision || !["required", "not_required"].includes(decision)) {
689
+ errors.push(
690
+ "Referenced Spec Diagram decision must be required or not_required.",
691
+ );
692
+ }
693
+ if (!spec.diagram_reason) {
694
+ errors.push("Referenced Spec is missing Diagram reason.");
695
+ }
689
696
  }
690
697
  if (["medium", "high"].includes(risk || "") && !spec.has_technical_design) {
691
698
  errors.push(
@@ -0,0 +1,58 @@
1
+ // Read-only observation for working on Immune-Brain with Immune-Brain: when
2
+ // the workspace is this plugin's own source repository but the Host loaded the
3
+ // plugin from somewhere else (an installed package or cache), tool behavior
4
+ // follows the loaded build, not the workspace changes. The plugin version does
5
+ // not move during development, so the generated Claude bundle stands in for
6
+ // the build: every runtime change regenerates it under the build parity check.
7
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { runningRuntimeSource } from "./kernel/runtime_contracts";
10
+
11
+ const WORKSPACE_PLUGIN_DIR = join("plugins", "immune-brain");
12
+ const PLUGIN_MANIFEST = join(".claude-plugin", "plugin.json");
13
+ const BUILD_PROXY = join("dist", "claude", "mcp-server.mjs");
14
+ const MAX_LEVELS = 8;
15
+
16
+ export interface PluginSourceSkewWarning {
17
+ code: "loaded_plugin_differs_from_workspace";
18
+ paths: string[];
19
+ message: string;
20
+ }
21
+
22
+ function pluginRootAbove(dir: string): string | null {
23
+ let current = dir;
24
+ for (let level = 0; level < MAX_LEVELS; level++) {
25
+ if (existsSync(join(current, PLUGIN_MANIFEST))) return current;
26
+ const parent = dirname(current);
27
+ if (parent === current) return null;
28
+ current = parent;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function sameBuild(a: string, b: string): boolean {
34
+ const left = join(a, BUILD_PROXY), right = join(b, BUILD_PROXY);
35
+ if (!existsSync(left) || !existsSync(right)) return false;
36
+ if (statSync(left).size !== statSync(right).size) return false;
37
+ return readFileSync(left).equals(readFileSync(right));
38
+ }
39
+
40
+ /** Null unless `root` is the plugin source repository and the loaded plugin is a different, differing build. */
41
+ export function pluginSourceSkewWarning(root: string, loadedFrom: string = runningRuntimeSource()): PluginSourceSkewWarning | null {
42
+ try {
43
+ const workspace = join(root, WORKSPACE_PLUGIN_DIR);
44
+ if (!existsSync(join(workspace, PLUGIN_MANIFEST))) return null;
45
+ const loaded = pluginRootAbove(loadedFrom);
46
+ if (!loaded) return null;
47
+ const workspaceReal = realpathSync(workspace), loadedReal = realpathSync(loaded);
48
+ if (workspaceReal === loadedReal || sameBuild(loadedReal, workspaceReal)) return null;
49
+ return {
50
+ code: "loaded_plugin_differs_from_workspace",
51
+ paths: [loadedReal, workspaceReal],
52
+ message: `this Host runs Immune-Brain from ${loadedReal}, whose build differs from the workspace plugin ${workspaceReal}; tools follow the loaded build, so reload the Host with the workspace plugin before relying on workspace runtime changes`,
53
+ };
54
+ } catch {
55
+ // An observation never blocks status.
56
+ return null;
57
+ }
58
+ }
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/plugin_versioning.ts from the root package.json.
2
- export const PLUGIN_VERSION = "5.3.0" as const;
2
+ export const PLUGIN_VERSION = "5.5.1" as const;
@@ -46,6 +46,27 @@ cite the acceptance whose principle it violates as `acceptance_id` and
46
46
  to be written into the acceptance text; a missing literal clause is not itself a
47
47
  finding and does not call for an Intent revision.
48
48
 
49
+ ## Trigger and Threat Model
50
+
51
+ `evidence.trigger` must read "concrete input or state → observable wrong
52
+ result". A finding that cannot name the wrong result is not blocking. Judge
53
+ against the threat model and supported environment declared in the Spec or
54
+ TaskIntent goal: a defect that needs an attacker, input source, repository
55
+ format, encoding, or platform outside that declaration is advisory. When
56
+ nothing is declared, assume inputs come from the repository's own tooling and
57
+ the user's trusted workspace. `security_boundary` means a security, data-loss,
58
+ or authority boundary the change actually crosses.
59
+
60
+ From Review round 3 the manifest carries `prior_review_commit` and
61
+ `delta_paths`: the paths the latest rework changed. Review that delta and its
62
+ direct interactions. A blocking finding is admitted only when at least one
63
+ `caller_chain` entry names a path in `delta_paths`, or `violated.kind` is
64
+ `security_boundary`; an edge case that already existed in round 1 is advisory
65
+ unless it crosses a security, data-loss, or authority boundary. A verdict that
66
+ blocks outside the delta is rejected for resubmission. When the manifest carries
67
+ `delta_skipped` instead, no delta exists for the round and this rule does not
68
+ apply.
69
+
49
70
  ## Invariant and Evidence Coverage
50
71
 
51
72
  Judge whether the change actually closes the invariant its acceptance names,
@@ -95,7 +116,7 @@ contract: `contract`, `role`, `task_id`, `snapshot_digest`, `decision` (`pass`
95
116
  or `rework`), and for `pass` include `approval` (`kind`, `authority_role`,
96
117
  `summary`), for `rework` include `findings` (`id`, `kind`, `acceptance_id`,
97
118
  `summary`, `evidence`). Every rework finding's `evidence` must carry
98
- `trigger` (the concrete inputs or state that reach the defect), a non-empty
119
+ `trigger` (the concrete input or state that reaches the defect and the observable wrong result it produces), a non-empty
99
120
  `caller_chain` (ordered repository paths or symbols), and `violated`
100
121
  (`kind`: `acceptance` or `security_boundary`, `ref`: the acceptance id or
101
122
  boundary name). Do not invent fields, and never send an anchor yourself: the
@@ -104,6 +125,9 @@ violated.ref, caller_chain}`, so an identical claim keeps one stable identity
104
125
  across review rounds while a different call chain is a different claim. A
105
126
  passing review carries no blocking findings; non-blocking notes may ride along as
106
127
  `kind: "advisory"` findings, and every finding, advisory included, needs the
107
- same machine-checkable evidence. If the
128
+ same machine-checkable evidence. Advisory notes are archived on a pass and never
129
+ become Executor work. A rework carries only `kind: "blocking"` findings, at
130
+ least one: the coordinator rejects a rework with an advisory finding or without
131
+ a blocking one, so submit `pass` with `inspected_paths` when nothing blocks. If the
108
132
  checkpoint is `awaiting_user_successor_decision`, stop without dispatch; only
109
133
  a literal user may invoke `--approve-successor`.
@@ -10,23 +10,41 @@ On a batch `handoff.role=executor`, implement only its named enrolled child;
10
10
  the batch stays `running` with that child `enrolled`. Return to the Parent after
11
11
  scoped implementation, staging and focused diagnostics. Enrollment itself does
12
12
  not start QA; the Parent owns the subsequent Kernel Assurance and foreground
13
- Review handoff under the same still-valid batch authorization.
13
+ Review handoff under the same still-valid batch authorization. A child settlement
14
+ returns execution to the bound Parent; continue dependency-ready independent
15
+ work until whole-plan integration and final verification pass. Critical and new
16
+ repair tasks use their own native Enrollment; stopped or excluded work never
17
+ counts as Parent success.
14
18
 
15
- Before handoff, run the permitted diagnostic checks, complete the Delivery
16
- Evidence check below and return its table to the Parent as structured diagnostic
17
- evidence. The read-only Loop
19
+ Before handoff, run the permitted diagnostic checks and stage the task-owned
20
+ changes. On rework, also complete the Delivery Evidence check below and return
21
+ its table to the Parent as structured diagnostic evidence. The read-only Loop
18
22
  runtime action only constructs the dispatch envelope; it does not store
19
23
  evidence or change task state. Preserve failed and blocked attempts. Do not
20
24
  perform QA,
21
25
  review, plan mutation, successor approval, Compounder work, or authority
22
- writes. If the requested change needs scope expansion, stop and return an
23
- `imm-planner` route with the concrete missing scope and verification reason.
26
+ writes. A sibling file in a directory the scope already names a file in is a
27
+ same-directory addition: submit `revise_intent` with the complete next intent
28
+ and continue, without a Planner route or a native gate. If the requested change
29
+ needs scope expansion beyond that (a glob, a new directory, a removed entry, a
30
+ path the project's risk floor names in `.imm/risk-floor.json`), stop and return an `imm-planner` route
31
+ with the concrete missing scope and verification reason.
24
32
 
25
33
  ## Delivery Evidence
26
34
 
27
- Reuse the TaskIntent and any bound Spec's agreed seams and controls. Before the
28
- first Assurance attempt, return one compact row per acceptance; update affected
29
- rows after repair. This is handoff evidence, not a new artifact or approval gate.
35
+ Prefer direct checks. For truncated display, use an explicit shell with
36
+ `pipefail`, preserve the check's real exit status and a task-owned full log;
37
+ display success is not check success. Keep global shell settings unchanged.
38
+ When the Spec or user requests test-first, observe a business-assertion red
39
+ before implementation, then the corresponding green at the same seam; missing
40
+ modules, syntax errors or harness failures are not that red. A known omitted
41
+ generated output blocks handoff: fix it within scope or use the existing Intent
42
+ revision path before delivery.
43
+
44
+ Reuse the TaskIntent and any bound Spec's agreed seams and controls. Return one
45
+ compact row per violated acceptance or invariant only when handling rework; a
46
+ first delivery needs no table. This is handoff evidence, not a new artifact or
47
+ approval gate.
30
48
 
31
49
  | Acceptance / invariant | Entry and consumers | Positive / negative / boundary controls | Command, outcome and input | Finding coverage |
32
50
  |---|---|---|---|---|
@@ -37,6 +55,13 @@ both affected hosts and recovery/replay branches. An async, return-type or
37
55
  exception-contract change requires checking every affected caller's await,
38
56
  cancellation and error propagation.
39
57
 
58
+ Before Assurance, run the existing tests that call a changed entry or assert
59
+ the changed contract, found by searching the tests for its exported names,
60
+ including tests outside `scope_hint`. Focused acceptance descriptors do not run
61
+ them, so a stale caller test otherwise surfaces only at final verification. A
62
+ failing caller test outside the scope is a scope gap: add it through the Intent
63
+ revision path before delivery, never leave it for final verification.
64
+
40
65
  Use the real producer for the success control where available. Derive rejection
41
66
  controls from a successful state, change only the target condition, and assert
42
67
  the intended rejection reason and required side effects. A control must fail
@@ -75,7 +100,7 @@ internal return values that a public assertion already covers. Before adding a
75
100
  test, search existing tests for the same behavior and extend or parameterize
76
101
  them instead of duplicating setup. When the change retires or replaces a
77
102
  behavior, update or delete the tests that protected it within scope and name
78
- the retired behavior or continuing owner in Delivery Evidence.
103
+ the retired behavior or continuing owner in the handoff.
79
104
  Treat naming, function length, parameter count, nesting, and abstraction taste
80
105
  as contextual signals, never as automatic failure thresholds.
81
106
 
@@ -13,6 +13,7 @@ import { projectAssurance } from "../kernel/assurance_projection";
13
13
  import { createMutationAuthorityRegistry, digestOfAction, type CapabilityBindingV2 } from "../kernel/authority_port";
14
14
  import { capabilityActionFor, createCanaryApplication } from "../kernel/canary_application";
15
15
  import { canonicalIntentHash, classifyIntentRevision, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
16
+ import type { RiskFloor } from "../kernel/risk_floor";
16
17
  import { boundSpecPath } from "../kernel/spec_binding";
17
18
  import { readTaskRecordRaw, recoverKernelStoreFollowUps } from "../kernel/storage";
18
19
  import type { TaskIntentV1 } from "../kernel/types";
@@ -69,8 +70,8 @@ export function parseLaneRevision(value: unknown): LaneRevisionRequest | undefin
69
70
  * TaskIntent as the batch authorized it; `current` is the Lane's enrolled one.
70
71
  * Null means covered.
71
72
  */
72
- export function delegatedRevisionRefusal(authorized: TaskIntentV1, current: TaskIntentV1, next: TaskIntentV1): string | null {
73
- if (classifyIntentRevision(current, next) !== "breaking") return "only a breaking revision needs this approval; apply a compatible one with revise_intent";
73
+ export function delegatedRevisionRefusal(authorized: TaskIntentV1, current: TaskIntentV1, next: TaskIntentV1, floor: RiskFloor): string | null {
74
+ if (classifyIntentRevision(current, next, floor) !== "breaking") return "only a breaking revision needs this approval; apply a compatible one with revise_intent";
74
75
  if (next.revision <= current.revision) return "the revision number must increase";
75
76
  if (next.goal !== authorized.goal || next.owner !== authorized.owner || next.task_id !== authorized.task_id)
76
77
  return "goal, owner and task identity are outside the delegation";
@@ -99,7 +100,8 @@ export function applyDelegatedIntentRevision(input: {
99
100
  return (async () => {
100
101
  const { lane_root: root, task_id: taskId, next_intent: nextIntent, now } = input;
101
102
  recoverKernelStoreFollowUps(root, taskId);
102
- const prior = readTaskIntent(root, taskId, readTaskRecordRaw(root, taskId).record?.intent_ref.path);
103
+ const record = readTaskRecordRaw(root, taskId).record;
104
+ const prior = readTaskIntent(root, taskId, record?.intent_ref.path, record?.intent_snapshot);
103
105
  const before = await projectAssurance(root, taskId, taskDeliveryIdentity);
104
106
  if (before.error || before.claim?.task_id !== taskId || before.projection.lifecycle !== "active")
105
107
  throw new Error(`the Lane does not hold an active run of ${taskId}`);