immune-brain 5.1.0 → 5.1.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.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +37 -5
  4. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +230 -10
  5. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +66 -3
  6. package/plugins/immune-brain/agents/immune-brain-reviewer.md +26 -1
  7. package/plugins/immune-brain/dist/claude/mcp-server.mjs +8712 -7410
  8. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  9. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +8 -6
  10. package/plugins/immune-brain/dist/imm-brainstorm.md +4 -4
  11. package/plugins/immune-brain/dist/imm-planner.md +41 -5
  12. package/plugins/immune-brain/dist/imm-run.md +123 -9
  13. package/plugins/immune-brain/dist/role-prompts/code-review.md +25 -0
  14. package/plugins/immune-brain/dist/role-prompts/executor.md +7 -0
  15. package/plugins/immune-brain/hooks/hooks.json +22 -1
  16. package/plugins/immune-brain/runtime/assurance/active_task_notice.ts +42 -0
  17. package/plugins/immune-brain/runtime/assurance/coordinator.ts +31 -4
  18. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
  19. package/plugins/immune-brain/runtime/assurance/review_mediation.ts +107 -0
  20. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +3 -0
  21. package/plugins/immune-brain/runtime/claude/interaction.ts +15 -0
  22. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +120 -59
  23. package/plugins/immune-brain/runtime/claude/lane_guard.ts +33 -0
  24. package/plugins/immune-brain/runtime/claude/mcp_server.ts +142 -31
  25. package/plugins/immune-brain/runtime/commands/kernel.ts +54 -1
  26. package/plugins/immune-brain/runtime/github_issue_tracker.ts +235 -7
  27. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +4 -1
  28. package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -2
  29. package/plugins/immune-brain/runtime/kernel/completion.ts +24 -1
  30. package/plugins/immune-brain/runtime/kernel/reducer.ts +15 -2
  31. package/plugins/immune-brain/runtime/kernel/runtime_contracts.json +7 -0
  32. package/plugins/immune-brain/runtime/kernel/runtime_contracts.ts +75 -0
  33. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +6 -0
  34. package/plugins/immune-brain/runtime/kernel/storage.ts +20 -4
  35. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +33 -3
  36. package/plugins/immune-brain/runtime/kernel/types.ts +7 -0
  37. package/plugins/immune-brain/runtime/kernel/validation.ts +13 -2
  38. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  39. package/plugins/immune-brain/runtime/prompts/code-review.md +25 -0
  40. package/plugins/immune-brain/runtime/prompts/executor.md +7 -0
  41. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +1 -1
  42. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +160 -0
  43. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +222 -0
  44. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +72 -0
  45. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +447 -37
  46. package/plugins/immune-brain/runtime/unattended/batch_leak_restore.ts +131 -0
  47. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +33 -16
  48. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +16 -3
  49. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +44 -0
  50. package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +40 -0
  51. package/plugins/immune-brain/runtime/unattended/batch_state.ts +199 -6
  52. package/plugins/immune-brain/runtime/unattended/lane_workspace.ts +58 -0
  53. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +1 -1
  54. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -1
@@ -8,6 +8,8 @@ const PROTOCOL_MARKER = "<!-- immune-brain-tracker:v1 -->";
8
8
  const KIND_INITIATIVE_MARKER = "<!-- immune-brain:kind=initiative -->";
9
9
  const KIND_TASK_MARKER = "<!-- immune-brain:kind=task -->";
10
10
  const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
11
+ /** Public scenario identity: an upstream Brainstorm id or a Spec-local id. */
12
+ const SCENARIO_ID_PATTERN = /^(?:BR-SCN|SCN)-[1-9][0-9]{0,8}$/;
11
13
  const MAX_GH_OUTPUT = 8 * 1024 * 1024;
12
14
  const MAX_DIAGNOSTIC = 512;
13
15
  const GH_TIMEOUT_MS = 20_000;
@@ -62,6 +64,23 @@ export interface InitiativeProjection {
62
64
  out_of_scope?: string[];
63
65
  }
64
66
 
67
+ /**
68
+ * One user scenario projected onto the Task that first makes it observable.
69
+ * Public projection only: it never widens TaskIntent scope or authority.
70
+ */
71
+ export interface InitiativeTaskScenario {
72
+ id: string;
73
+ actor: string;
74
+ given: string;
75
+ when: string;
76
+ then: string;
77
+ mode: "automated" | "manual";
78
+ /** Canonical acceptance IDs this scenario maps to. Required for automated; optional for manual. */
79
+ acceptance?: string[];
80
+ /** Why a deterministic check cannot prove this scenario. Required for manual. */
81
+ manual_reason?: string;
82
+ }
83
+
65
84
  export interface InitiativePublicationInput {
66
85
  initiative_id: string;
67
86
  goal: string;
@@ -71,6 +90,8 @@ export interface InitiativePublicationInput {
71
90
  intent: string;
72
91
  acceptance: Array<{ id: string; summary: string }>;
73
92
  projection?: TaskProjection;
93
+ /** Optional. Omitted or empty renders no User scenarios section for this Task. */
94
+ scenarios?: InitiativeTaskScenario[];
74
95
  }>;
75
96
  /**
76
97
  * Explicit amendment input: binds the caller's desired pending frontier to
@@ -179,6 +200,15 @@ export type TrackerOperation =
179
200
  goal: string;
180
201
  slices: InitiativeSlice[];
181
202
  projection?: InitiativeProjection;
203
+ /** Derived Parent listing. Absent or empty renders no User scenarios section. */
204
+ scenarios?: Array<{
205
+ slice_id: string;
206
+ id: string;
207
+ mode: "automated" | "manual";
208
+ when: string;
209
+ then: string;
210
+ manual_reason?: string;
211
+ }>;
182
212
  }
183
213
  | {
184
214
  op: "upsert-task";
@@ -191,6 +221,8 @@ export type TrackerOperation =
191
221
  projection?: TaskProjection;
192
222
  /** The canonical TaskIntent content hash this Task was prepared from. */
193
223
  intent_hash?: string;
224
+ /** Public scenarios owned by this Task. Absent renders no User scenarios section. */
225
+ scenarios?: InitiativeTaskScenario[];
194
226
  }
195
227
  | {
196
228
  op: "mark-terminal";
@@ -270,7 +302,7 @@ function countLiteral(value: string, needle: string): number {
270
302
  return count;
271
303
  }
272
304
 
273
- function marker(name: "repo-id" | "initiative-id" | "task-id" | "slice-id" | "intent-hash", value: string | number): string {
305
+ function marker(name: "repo-id" | "initiative-id" | "task-id" | "slice-id" | "intent-hash" | "scenarios", value: string | number): string {
274
306
  return `<!-- immune-brain:${name}=${value} -->`;
275
307
  }
276
308
 
@@ -730,8 +762,9 @@ function stripPublishedIntentHash(body: string): string {
730
762
  return body.replace(/<!-- immune-brain:intent-hash=[A-Za-z0-9][A-Za-z0-9._:-]{0,127} -->\n?/g, "");
731
763
  }
732
764
 
733
- function ownershipMarkerValue(body: string, name: "initiative-id" | "slice-id" | "task-id"): string | null {
734
- const values = [...body.matchAll(new RegExp(`<!-- immune-brain:${name}=([A-Za-z0-9][A-Za-z0-9._-]{0,127}) -->`, "g"))];
765
+ function ownershipMarkerValue(body: string, name: "initiative-id" | "slice-id" | "task-id" | "scenarios"): string | null {
766
+ const pattern = name === "scenarios" ? "[A-Za-z0-9%_!*'()~.-]+" : "[A-Za-z0-9][A-Za-z0-9._-]{0,127}";
767
+ const values = [...body.matchAll(new RegExp(`<!-- immune-brain:${name}=(${pattern}) -->`, "g"))];
735
768
  return values.length === 1 ? values[0][1] : null;
736
769
  }
737
770
 
@@ -953,6 +986,160 @@ function listText(values: string[] | undefined, fallback: string): string {
953
986
  return values?.length ? values.map((value) => `- ${value}`).join("\n") : fallback;
954
987
  }
955
988
 
989
+ /**
990
+ * Child section after Acceptance criteria. Empty input returns "" so a Task
991
+ * without scenarios, and a publication with none at all, stays byte-identical.
992
+ */
993
+ function childScenarioSection(scenarios: InitiativeTaskScenario[] | undefined): string {
994
+ if (!scenarios?.length) return "";
995
+ const items = scenarios.map((scenario) => {
996
+ const mapped = scenario.acceptance?.length ? scenario.acceptance.map((id) => `\`${id}\``).join(", ") : "none";
997
+ const visible = `Actor: ${scenario.actor}. Given: ${scenario.given}. When: ${scenario.when}. Then: ${scenario.then}.`;
998
+ if (scenario.mode === "manual")
999
+ return `- [ ] \`${scenario.id}\` (manual, ${mapped}): ${visible} Reason: ${scenario.manual_reason}.`;
1000
+ return `- \`${scenario.id}\` (automated, ${mapped}): ${visible}`;
1001
+ });
1002
+ return `\n## User scenarios\n\n${items.join("\n")}\n`;
1003
+ }
1004
+
1005
+ /**
1006
+ * Parent listing after Testing strategy, derived from the Tasks. One plain
1007
+ * bullet per scenario; no checkboxes. Empty input returns "".
1008
+ */
1009
+ function parentScenarioSection(scenarios: Array<{ slice_id: string; id: string; mode: "automated" | "manual"; actor?: string; given?: string; when: string; then: string; manual_reason?: string }> | undefined): string {
1010
+ if (!scenarios?.length) return "";
1011
+ const items = scenarios.map((scenario) => {
1012
+ const prefix = scenario.actor && scenario.given ? `Actor: ${scenario.actor}. Given: ${scenario.given}. ` : "";
1013
+ const reason = scenario.manual_reason ? ` Reason: ${scenario.manual_reason}.` : "";
1014
+ return `- \`${scenario.id}\` (Slice \`${scenario.slice_id}\`, ${scenario.mode}): ${prefix}When: ${scenario.when}. Then: ${scenario.then}.${reason}`;
1015
+ });
1016
+ return `\n## User scenarios\n\n${items.join("\n")}\n`;
1017
+ }
1018
+
1019
+
1020
+ type HistoricalScenarioListing = InitiativeTaskScenario & { slice_id: string };
1021
+
1022
+ /**
1023
+ * Read the Parent listings already published by historical Children. Their
1024
+ * Child bytes stay untouched, but an amendment must keep those listings and
1025
+ * reserve their IDs before it rebuilds the Parent or accepts pending input.
1026
+ */
1027
+ function historicalScenarioListings(children: Iterable<{ body?: unknown }>): HistoricalScenarioListing[] | string {
1028
+ const listings: HistoricalScenarioListing[] = [];
1029
+ for (const child of children) {
1030
+ if (typeof child.body !== "string") continue;
1031
+ const sliceId = ownershipMarkerValue(child.body, "slice-id");
1032
+ if (!sliceId) continue;
1033
+ const markerStarts = [...child.body.matchAll(/<!-- immune-brain:scenarios=/g)].length;
1034
+ if (markerStarts > 1) return `historical Slice ${sliceId} has duplicate scenarios markers`;
1035
+ const encoded = ownershipMarkerValue(child.body, "scenarios");
1036
+ if (encoded === null) {
1037
+ if (markerStarts === 0) continue;
1038
+ return `historical Slice ${sliceId} has a malformed scenarios marker`;
1039
+ }
1040
+ const malformed = `historical Slice ${sliceId} has a malformed scenarios marker`;
1041
+ let decoded: unknown;
1042
+ try { decoded = JSON.parse(decodeURIComponent(encoded)); }
1043
+ catch { return malformed; }
1044
+ let scenarios: InitiativeTaskScenario[] | undefined;
1045
+ try { scenarios = normalizeTaskScenarios(decoded, `historical Slice ${sliceId}`, undefined, new Set()); }
1046
+ catch { return malformed; }
1047
+ // The marker is the only source of historical scenarios, so it must hold
1048
+ // exactly what the publish path writes for these values: the same
1049
+ // validator, re-encoded byte for byte. Anything else is an unamendable
1050
+ // Child, and the batch is rejected before any remote write.
1051
+ if (scenarios === undefined || encodeURIComponent(JSON.stringify(scenarios)) !== encoded) return malformed;
1052
+ for (const scenario of scenarios) listings.push({ ...scenario, slice_id: sliceId });
1053
+ }
1054
+ return listings;
1055
+ }
1056
+
1057
+ function retainHistoricalScenarioIds(children: Iterable<{ body?: unknown }>, seenScenarioIds = new Set<string>()): string | null {
1058
+ const listings = historicalScenarioListings(children);
1059
+ if (typeof listings === "string") return listings;
1060
+ for (const listing of listings) {
1061
+ if (seenScenarioIds.has(listing.id)) return `historical Slice ${listing.slice_id} repeats scenario id ${listing.id}`;
1062
+ seenScenarioIds.add(listing.id);
1063
+ }
1064
+ return null;
1065
+ }
1066
+
1067
+ /**
1068
+ * Reject the whole batch, before any remote write, when a scenario is malformed
1069
+ * or names an acceptance ID its TaskIntent lacks. Text uses the same public
1070
+ * projection rules as every other projected field. When `acceptanceIds` is
1071
+ * omitted the TaskIntent-bound existence check is skipped, which is what the
1072
+ * historical read-back needs: a published marker already passed it once.
1073
+ */
1074
+ function normalizeTaskScenarios(
1075
+ value: unknown,
1076
+ taskId: string,
1077
+ acceptanceIds: Set<string> | undefined,
1078
+ seenScenarioIds: Set<string>,
1079
+ ): InitiativeTaskScenario[] | undefined {
1080
+ if (value === undefined) return undefined;
1081
+ if (!Array.isArray(value)) throw new Error(`Task ${taskId} scenarios must be an array`);
1082
+ if (value.length === 0) return undefined;
1083
+ return value.map((item, index) => {
1084
+ const name = `Task ${taskId} scenarios[${index}]`;
1085
+ if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`${name} must be an object`);
1086
+ const raw = item as Record<string, unknown>;
1087
+ const required = ["id", "actor", "given", "when", "then", "mode"] as const;
1088
+ for (const field of required) {
1089
+ if (raw[field] === undefined) throw new Error(`${name} is missing ${field}`);
1090
+ }
1091
+ const id = typeof raw.id === "string" && SCENARIO_ID_PATTERN.test(raw.id) ? raw.id : null;
1092
+ if (!id) throw new Error(`${name} id must match BR-SCN-<n> or SCN-<n>`);
1093
+ if (seenScenarioIds.has(id)) throw new Error(`Task ${taskId} repeats scenario id ${id}`);
1094
+ seenScenarioIds.add(id);
1095
+ if (raw.mode !== "automated" && raw.mode !== "manual")
1096
+ throw new Error(`${name} mode must be automated or manual`);
1097
+ const acceptance = raw.acceptance === undefined
1098
+ ? undefined
1099
+ : normalizedList(raw.acceptance, `${name}.acceptance`, 128)?.map((acceptanceId) => identifier(acceptanceId, `${name}.acceptance`));
1100
+ if (acceptance && new Set(acceptance).size !== acceptance.length)
1101
+ throw new Error(`${name} repeats an acceptance id`);
1102
+ for (const acceptanceId of acceptance ?? []) {
1103
+ if (acceptanceIds && !acceptanceIds.has(acceptanceId))
1104
+ throw new Error(`Task ${taskId} scenario ${id} names unknown acceptance id ${acceptanceId}`);
1105
+ }
1106
+ if (raw.mode === "automated" && !acceptance?.length)
1107
+ throw new Error(`Task ${taskId} scenario ${id} is automated with no acceptance id`);
1108
+ if (raw.mode === "manual" && raw.manual_reason === undefined)
1109
+ throw new Error(`Task ${taskId} scenario ${id} is manual without manual_reason`);
1110
+ const scenario: InitiativeTaskScenario = {
1111
+ id,
1112
+ actor: projectionText(raw.actor, `${name}.actor`, 500),
1113
+ given: projectionText(raw.given, `${name}.given`, 500),
1114
+ when: projectionText(raw.when, `${name}.when`, 500),
1115
+ then: projectionText(raw.then, `${name}.then`, 500),
1116
+ mode: raw.mode,
1117
+ ...(acceptance ? { acceptance } : {}),
1118
+ ...(raw.manual_reason === undefined ? {} : { manual_reason: projectionText(raw.manual_reason, `${name}.manual_reason`, 500) }),
1119
+ };
1120
+ // The historical read-back re-applies these rules to the stored values, so
1121
+ // each one must survive its own projection a second time. Escaping can grow
1122
+ // a value past its bound and a restricted-context acceptance ID passes the
1123
+ // pending path; either would make a published Child unamendable.
1124
+ const roundTrip: Array<[string, string]> = [
1125
+ ["actor", scenario.actor],
1126
+ ["given", scenario.given],
1127
+ ["when", scenario.when],
1128
+ ["then", scenario.then],
1129
+ ...(scenario.manual_reason === undefined ? [] : [["manual_reason", scenario.manual_reason] as [string, string]]),
1130
+ ];
1131
+ for (const [label, value] of roundTrip) {
1132
+ if (projectionText(value, `${name}.${label}`, 500) !== value)
1133
+ throw new Error(`${name}.${label} fails the public-projection round-trip`);
1134
+ }
1135
+ for (const acceptanceId of acceptance ?? []) {
1136
+ if (projectionText(acceptanceId, `${name}.acceptance`, 128) !== acceptanceId || identifier(acceptanceId, `${name}.acceptance`) !== acceptanceId)
1137
+ throw new Error(`${name}.acceptance fails the public-projection round-trip`);
1138
+ }
1139
+ return scenario;
1140
+ });
1141
+ }
1142
+
956
1143
  function bodyLimitFailure(operation: TrackerOperation["op"], body: string, reserve = 0): GithubTrackerResult | null {
957
1144
  return Buffer.byteLength(body, "utf8") + reserve <= GITHUB_ISSUE_BODY_LIMIT
958
1145
  ? null
@@ -973,7 +1160,7 @@ function createInitiativeBody(
973
1160
  KIND_INITIATIVE_MARKER,
974
1161
  marker("repo-id", repository.id),
975
1162
  marker("initiative-id", operation.initiative_id),
976
- ].join("\n")}\n\n${provenance}## How to use this Issue\n\n- Edit planning prose and Slice ordering directly after creation.\n- Keep each Slice marker attached to exactly one stable Slice entry.\n- The tracker never rewrites this Parent after creation; the tracker closes it as completed once every Slice Child is completed, and never closes it otherwise.\n\n## Problem\n\n${publicText(projection.problem ?? "The Initiative addresses the bounded delivery described below.", "projection.problem")}\n\n## Result\n\n${publicText(projection.result ?? operation.goal, "projection.result")}\n\n## Initiative design\n\n${publicText(projection.design ?? "Each Child preserves the shared Initiative decisions and boundaries recorded here.", "projection.design")}\n\n## Decisions\n\n${listText(projection.decisions, "- No additional Initiative decisions recorded.")}\n\n## Testing strategy\n\n${publicText(projection.testing_strategy ?? "Each Child closes from its focused acceptance verification.", "projection.testing_strategy")}\n\n## Out of scope\n\n${listText(projection.out_of_scope, "- Unrelated work outside this Initiative.")}\n\n## Slices\n\n${operation.slices.length + historicalSlices.length === 0 ? "No Slices recorded yet." : [...historicalSlices, ...operation.slices.map((slice) => `- [ ] ${marker("slice-id", slice.id)} **${slice.id}**: ${slice.result ?? slice.goal}${slice.blocked_by?.length ? ` (blocked by: ${slice.blocked_by.join(", ")})` : ""}`)].join("\n")}\n\n${ISSUE_FOOTER}\n`;
1163
+ ].join("\n")}\n\n${provenance}## How to use this Issue\n\n- Edit planning prose and Slice ordering directly after creation.\n- Keep each Slice marker attached to exactly one stable Slice entry.\n- The tracker never rewrites this Parent after creation; the tracker closes it as completed once every Slice Child is completed, and never closes it otherwise.\n\n## Problem\n\n${publicText(projection.problem ?? "The Initiative addresses the bounded delivery described below.", "projection.problem")}\n\n## Result\n\n${publicText(projection.result ?? operation.goal, "projection.result")}\n\n## Initiative design\n\n${publicText(projection.design ?? "Each Child preserves the shared Initiative decisions and boundaries recorded here.", "projection.design")}\n\n## Decisions\n\n${listText(projection.decisions, "- No additional Initiative decisions recorded.")}\n\n## Testing strategy\n\n${publicText(projection.testing_strategy ?? "Each Child closes from its focused acceptance verification.", "projection.testing_strategy")}\n${parentScenarioSection(operation.scenarios)}\n## Out of scope\n\n${listText(projection.out_of_scope, "- Unrelated work outside this Initiative.")}\n\n## Slices\n\n${operation.slices.length + historicalSlices.length === 0 ? "No Slices recorded yet." : [...historicalSlices, ...operation.slices.map((slice) => `- [ ] ${marker("slice-id", slice.id)} **${slice.id}**: ${slice.result ?? slice.goal}${slice.blocked_by?.length ? ` (blocked by: ${slice.blocked_by.join(", ")})` : ""}`)].join("\n")}\n\n${ISSUE_FOOTER}\n`;
977
1164
  }
978
1165
 
979
1166
  async function createInitiative(
@@ -1056,7 +1243,8 @@ function childBody(
1056
1243
  // idempotent replay can compare against what was actually published
1057
1244
  // instead of against whatever bytes it happens to read this time.
1058
1245
  ...(operation.intent_hash ? [marker("intent-hash", operation.intent_hash)] : []),
1059
- ].join("\n")}\n\n## Parent\n\n| Initiative | \`${operation.initiative_id}\` |\n| Parent Issue | [#${parent.number}](${parent.url}) |\n| Slice | \`${operation.slice_id}\` |\n| Risk | \`${operation.risk}\` |\n\n## Current behavior\n\n${publicText(projection.current_behavior ?? "The current behavior is defined by the repository's existing contract.", "projection.current_behavior")}\n\n## Desired behavior\n\n${publicText(projection.desired_behavior ?? projection.result ?? operation.goal, "projection.desired_behavior")}\n\n## Key interfaces\n\n${listText(projection.key_interfaces, "- Canonical TaskIntent acceptance and Kernel lifecycle remain authoritative.")}\n\n## Acceptance criteria\n\n${acceptance}\n\n## Verification\n\n${publicText(projection.verification ?? "Run the focused acceptance verification declared by the TaskIntent.", "projection.verification")}\n\n## Blocked by\n\n${projection.blocked_by?.length ? projection.blocked_by.map((id) => `- \`${identifier(id, "blocked_by task_id")}\``).join("\n") : "None"}\n\n## Out of scope\n\n${listText(projection.out_of_scope, "- Scope not declared by the validated TaskIntent.")}\n\n## Agent handoff\n\n${publicText(projection.agent_handoff ?? "Implement only the bounded TaskIntent result and run the focused checks. Do not widen scope or treat GitHub as authorization.", "projection.agent_handoff")}\n\n${ISSUE_FOOTER}\n`;
1246
+ ...(operation.scenarios?.length ? [marker("scenarios", encodeURIComponent(JSON.stringify(operation.scenarios)))] : []),
1247
+ ].join("\n")}\n\n## Parent\n\n| Initiative | \`${operation.initiative_id}\` |\n| Parent Issue | [#${parent.number}](${parent.url}) |\n| Slice | \`${operation.slice_id}\` |\n| Risk | \`${operation.risk}\` |\n\n## Current behavior\n\n${publicText(projection.current_behavior ?? "The current behavior is defined by the repository's existing contract.", "projection.current_behavior")}\n\n## Desired behavior\n\n${publicText(projection.desired_behavior ?? projection.result ?? operation.goal, "projection.desired_behavior")}\n\n## Key interfaces\n\n${listText(projection.key_interfaces, "- Canonical TaskIntent acceptance and Kernel lifecycle remain authoritative.")}\n\n## Acceptance criteria\n\n${acceptance}\n${childScenarioSection(operation.scenarios)}\n## Verification\n\n${publicText(projection.verification ?? "Run the focused acceptance verification declared by the TaskIntent.", "projection.verification")}\n\n## Blocked by\n\n${projection.blocked_by?.length ? projection.blocked_by.map((id) => `- \`${identifier(id, "blocked_by task_id")}\``).join("\n") : "None"}\n\n## Out of scope\n\n${listText(projection.out_of_scope, "- Scope not declared by the validated TaskIntent.")}\n\n## Agent handoff\n\n${publicText(projection.agent_handoff ?? "Implement only the bounded TaskIntent result and run the focused checks. Do not widen scope or treat GitHub as authorization.", "projection.agent_handoff")}\n\n${ISSUE_FOOTER}\n`;
1060
1248
  }
1061
1249
 
1062
1250
  /** Derived approved-final content and baseline-derived historical evidence for an amendment. */
@@ -1094,6 +1282,26 @@ function approvedAmendmentContent(
1094
1282
  blocked_by: operation.projection?.blocked_by,
1095
1283
  })),
1096
1284
  }) as Extract<TrackerOperation, { op: "create-initiative" }>;
1285
+ const historicalScenarios = historicalScenarioListings([...amendment.historical.values()].map((binding) => ({ body: binding.body })));
1286
+ if (typeof historicalScenarios === "string") return historicalScenarios;
1287
+ parent.scenarios = [
1288
+ ...historicalScenarios.map((scenario) => ({
1289
+ slice_id: scenario.slice_id,
1290
+ id: scenario.id,
1291
+ mode: scenario.mode,
1292
+ when: scenario.when,
1293
+ then: scenario.then,
1294
+ ...(typeof scenario.manual_reason === "string" ? { manual_reason: scenario.manual_reason } : {}),
1295
+ })),
1296
+ ...prepared.order.flatMap((operation) =>
1297
+ (operation.scenarios ?? []).map((scenario) => ({
1298
+ slice_id: operation.slice_id,
1299
+ id: scenario.id,
1300
+ mode: scenario.mode,
1301
+ when: scenario.when,
1302
+ then: scenario.then,
1303
+ }))),
1304
+ ];
1097
1305
  const historicalSliceIds: Set<string> = new Set();
1098
1306
  for (const child of amendment.historical.values()) {
1099
1307
  const sliceId = ownershipMarkerValue(child.body, "slice-id");
@@ -1120,7 +1328,7 @@ function approvedAmendmentContent(
1120
1328
  void sourceStub;
1121
1329
  // The approved final Parent body fixes the Slices checklist, so every Child
1122
1330
  // title is numbered from the Initiative order rather than from this batch.
1123
- const parentBody = createInitiativeBody(sourceStub.repository, parent, historicalSlices);
1331
+ const parentBody = createInitiativeBody(sourceStub.repository, { ...parent, scenarios: parent.scenarios }, historicalSlices);
1124
1332
  const oversizedParent = bodyLimitFailure("create-initiative", parentBody);
1125
1333
  if (oversizedParent) return `${oversizedParent.status}: ${oversizedParent.message}`;
1126
1334
  const pendingContent = new Map<string, { title: string; body: string }>();
@@ -1587,6 +1795,7 @@ function validateOperation(operation: TrackerOperation): TrackerOperation {
1587
1795
  id: identifier(item.id, `acceptance[${index}].id`),
1588
1796
  summary: projectionText(item.summary, `acceptance[${index}].summary`, 500),
1589
1797
  })),
1798
+ scenarios: operation.scenarios,
1590
1799
  };
1591
1800
  taskIssueTitle(normalized);
1592
1801
  return normalized;
@@ -1727,10 +1936,16 @@ function preflightPublication(root: string, input: InitiativePublicationInput):
1727
1936
  // projection is stamped with them, so a missing or oversized display name
1728
1937
  // fails the batch closed with zero remote writes.
1729
1938
  const initiativeShortName = initiativeDisplayNames(input.projection).shortName;
1939
+ const seenScenarioIds = new Set<string>();
1940
+ if (input.amendment) {
1941
+ const historicalBindings = (input.amendment.historical ?? []).map((child) => (child as { binding?: { body?: unknown } }).binding ?? {});
1942
+ const historicalScenarioError = retainHistoricalScenarioIds(historicalBindings, seenScenarioIds);
1943
+ if (historicalScenarioError) throw new Error(historicalScenarioError);
1944
+ }
1730
1945
  const publications = input.tasks.map((task, index) => {
1731
1946
  if (!task || typeof task !== "object" || Array.isArray(task)) throw new Error(`tasks[${index}] must be an object`);
1732
1947
  if (typeof task.intent !== "string") throw new Error(`tasks[${index}].intent must be a string`);
1733
- return taskPublication(root, input.initiative_id, task.slice_id, task.intent, task.acceptance, task.projection, index + 1, initiativeShortName);
1948
+ return taskPublication(root, input.initiative_id, task.slice_id, task.intent, task.acceptance, task.projection, task.scenarios, index + 1, initiativeShortName, seenScenarioIds);
1734
1949
  });
1735
1950
  const historicalIds = new Set<string>();
1736
1951
  if (input.amendment) {
@@ -1772,6 +1987,15 @@ function preflightPublication(root: string, input: InitiativePublicationInput):
1772
1987
  blocked_by: operation.projection?.blocked_by,
1773
1988
  })),
1774
1989
  }) as Extract<TrackerOperation, { op: "create-initiative" }>;
1990
+ initiative.scenarios = operations.flatMap((operation) =>
1991
+ (operation.scenarios ?? []).map((scenario) => ({
1992
+ slice_id: operation.slice_id,
1993
+ id: scenario.id,
1994
+ mode: scenario.mode,
1995
+ when: scenario.when,
1996
+ then: scenario.then,
1997
+ })),
1998
+ );
1775
1999
  const intentBindings = new Map(publications.map((publication) => [publication.operation.task_id, {
1776
2000
  intent_path: publication.intent_path,
1777
2001
  intent_content_hash: publication.intent_content_hash,
@@ -2874,8 +3098,10 @@ function taskPublication(
2874
3098
  intentPath: string,
2875
3099
  acceptance: unknown,
2876
3100
  projection: TaskProjection | undefined,
3101
+ scenarios: unknown,
2877
3102
  ordinal: number,
2878
3103
  initiativeShortName: string,
3104
+ seenScenarioIds: Set<string>,
2879
3105
  ): PreparedPublicationTask {
2880
3106
  const absoluteRoot = resolve(root);
2881
3107
  const absolutePath = resolve(absoluteRoot, intentPath);
@@ -2901,6 +3127,7 @@ function taskPublication(
2901
3127
  });
2902
3128
  const missingIds = [...expectedIds].filter((id) => !publicById.has(id));
2903
3129
  if (missingIds.length) throw new Error(`Task ${taskId} is missing public acceptance ids: ${missingIds.join(", ")}`);
3130
+ const normalizedScenarios = normalizeTaskScenarios(scenarios, taskId, expectedIds, seenScenarioIds);
2904
3131
  // Display names are stamped from the Initiative so every Child title carries
2905
3132
  // the same short name and its declared Slice position; an explicitly
2906
3133
  // supplied value must agree, never silently override.
@@ -2924,6 +3151,7 @@ function taskPublication(
2924
3151
  acceptance: intent.acceptance.map((item) => publicById.get(item.id)!),
2925
3152
  projection: stampedProjection,
2926
3153
  intent_hash: read.content_hash,
3154
+ scenarios: normalizedScenarios,
2927
3155
  }) as Extract<TrackerOperation, { op: "upsert-task" }>,
2928
3156
  intent_path: read.intent_ref.path,
2929
3157
  intent_content_hash: read.content_hash,
@@ -21,6 +21,7 @@ import {
21
21
  reconcileKernelAuthority,
22
22
  } from "./storage";
23
23
  import { projectTask, resolveProjectedRisk } from "./completion";
24
+ import { inspectSpecBinding } from "./spec_binding";
24
25
  import { taskDeliveryIdentity } from "../workspace_scope";
25
26
  import type { AssuranceObligation, TaskIntentV1, TaskRecord, TaskRecordV3 } from "./types";
26
27
  import type { TaskRecordV2 } from "./legacy_task_record";
@@ -236,7 +237,9 @@ function projectFromRecord(
236
237
  independence_violations: decision.independence_violations,
237
238
  open_user_decision_count: openUserDecisionCount,
238
239
  completion_ready: decision.complete,
239
- authorization: deriveAssuranceAuthorization({
240
+ authorization: !inspectSpecBinding(intent).ok
241
+ ? { state: "none", blocked: "invalid Spec binding requires a valid breaking intent revision" }
242
+ : deriveAssuranceAuthorization({
240
243
  next_obligation: decision.next_obligation,
241
244
  open_user_decision_count: openUserDecisionCount,
242
245
  }),
@@ -26,7 +26,7 @@ import {
26
26
  type BackendClaim,
27
27
  } from "./backend_claim";
28
28
  import { canonicalIntentHash } from "./intent";
29
- import { boundSpecPath, readBoundActiveSpec } from "./spec_binding";
29
+ import { assertValidSpecBinding, boundSpecPath, readBoundActiveSpec } from "./spec_binding";
30
30
  import { parseTaskRecord } from "./validation";
31
31
  import type { TaskIntentIdentityToken } from "./intent_token_registry";
32
32
  import {
@@ -208,6 +208,7 @@ export function createCanaryApplication(
208
208
  const current = readTaskRecordRaw(input.root, input.task_id);
209
209
  if (!current.record)
210
210
  throw new KernelInvariantError([`task ${input.task_id} has no TaskRecord v3`]);
211
+ assertValidSpecBinding(current.record.intent_snapshot);
211
212
  const workspace = readWorkspaceStateRaw(input.root);
212
213
  if (current.record.artifact_state === "frozen")
213
214
  return { revision: current.revision, record: current.record, workspace };
@@ -318,7 +319,13 @@ export function createCanaryApplication(
318
319
  const diffHash = asTaskDiffSnapshot((input.diffProvider ?? taskDeliveryIdentity)(input.root, snapshot.record)).diff_hash;
319
320
  if (operation.op === "stop" && !("capability" in operation))
320
321
  throw new KernelInvariantError(["stop requires user authority capability"]);
321
- const hasBoundSpec = boundSpecPath(snapshot.intent_snapshot) !== undefined;
322
+ // Only a capability-bound breaking revision may recover an invalid prior
323
+ // binding. Validate the replacement before any authority write.
324
+ if (operation.op === "approve_breaking_intent_revision") assertValidSpecBinding(operation.next_intent);
325
+ else assertValidSpecBinding(snapshot.intent_snapshot);
326
+ const hasBoundSpec = operation.op === "approve_breaking_intent_revision"
327
+ ? boundSpecPath(operation.next_intent) !== undefined
328
+ : boundSpecPath(snapshot.intent_snapshot) !== undefined;
322
329
  if (operation.op === "complete" && hasBoundSpec && snapshot.record.artifact_state !== "frozen")
323
330
  throw new KernelInvariantError(["complete requires frozen planning artifacts"]);
324
331
  const artifactTransition =
@@ -1,5 +1,5 @@
1
1
  import { classifyTaskRisk } from "./intent";
2
- import { archivePath, boundSpecPath } from "./spec_binding";
2
+ import { archivePath, boundSpecPath, inspectSpecBinding } from "./spec_binding";
3
3
  import type {
4
4
  ApprovalKind,
5
5
  CompletionDecision,
@@ -49,6 +49,8 @@ export function resolveProjectedRisk(
49
49
  intent: TaskIntentV1,
50
50
  changedPaths: readonly string[] = [],
51
51
  ): TaskIntentV1["risk"] {
52
+ // Invalid bindings have no trusted planning-path exclusions.
53
+ if (!inspectSpecBinding(intent).ok) return classifyTaskRisk(changedPaths, intent.risk);
52
54
  const excluded = ownSidecarPaths(intent);
53
55
  return classifyTaskRisk(
54
56
  changedPaths.filter((path) => !excluded.has(path)),
@@ -182,6 +184,27 @@ export function projectTask(
182
184
  currentIntentContentHash: string,
183
185
  changedPaths: readonly string[] = [],
184
186
  ): TaskProjectionV3 {
187
+ if (record.lifecycle === "active" && !inspectSpecBinding(intent).ok) {
188
+ assertKernelInvariantsV3(intent, record);
189
+ return {
190
+ contract: "assurance_kernel/projection/v3",
191
+ task_id: record.task_id,
192
+ intent_revision: intent.revision,
193
+ lifecycle: record.lifecycle,
194
+ artifact_state: record.artifact_state,
195
+ blocked: true,
196
+ next_obligation: "revise_intent",
197
+ complete: false,
198
+ fresh_acceptance_ids: [],
199
+ missing_acceptance_ids: intent.acceptance.map((item) => item.id),
200
+ stale_attestation_ids: record.attestations.map((item) => item.id),
201
+ missing_approval_kinds: REQUIRED_ATTESTATIONS[resolveProjectedRisk(intent, changedPaths)],
202
+ blocking_finding_ids: record.findings.filter((item) => item.kind === "blocking" && item.status !== "resolved").map((item) => item.id),
203
+ unresolved_user_decision_ids: record.findings.filter((item) => item.kind === "unresolved_user_decision" && item.status === "open").map((item) => item.id),
204
+ replan_required_ids: record.findings.filter((item) => item.kind === "replan_required" && item.status === "open").map((item) => item.id),
205
+ independence_violations: [],
206
+ };
207
+ }
185
208
  const decision = completionDecision(
186
209
  intent,
187
210
  record,
@@ -23,6 +23,7 @@ import {
23
23
  parseTaskRecord,
24
24
  } from "./validation";
25
25
  import { classifyIntentRevision, canonicalIntentHash } from "./intent";
26
+ import { assertValidSpecBinding } from "./spec_binding";
26
27
 
27
28
  const RISK_RANK: Record<TaskIntentV1["risk"], number> = {
28
29
  routine: 0,
@@ -431,15 +432,24 @@ export function reduceTask(
431
432
  const reviewRevision = approval.review_revision;
432
433
  if (reviewRevision && approval.kind !== "review")
433
434
  throw new KernelInvariantError(["review_revision is only valid on review approvals"]);
435
+ if (approval.reviewer_verdict_sha256 && approval.kind !== "review")
436
+ throw new KernelInvariantError(["reviewer_verdict_sha256 is only valid on review approvals"]);
434
437
  if (approval.kind === "review") {
435
438
  if (!reviewRevision)
436
439
  throw new KernelInvariantError(["v4 review approval requires review_revision"]);
437
440
  if (reviewRevision.base_head !== record.git_base_head)
438
441
  throw new KernelInvariantError(["review_revision.base_head must equal the Enrollment git_base_head"]);
439
442
  }
440
- const { review_revision: storedReviewRevision, ...approvalWithoutRevision } = approval;
443
+ // Re-emit the optional review-only fields in the strict parser's emission
444
+ // order so a written attestation stays byte-identical to its parse roundtrip.
445
+ const {
446
+ review_revision: storedReviewRevision,
447
+ advisory_findings: storedAdvisoryFindings,
448
+ reviewer_verdict_sha256: storedReviewerDigest,
449
+ ...approvalWithoutExtras
450
+ } = approval;
441
451
  record.attestations.push({
442
- ...approvalWithoutRevision,
452
+ ...approvalWithoutExtras,
443
453
  acceptance_results: approval.kind === "qa"
444
454
  ? record.intent_snapshot.acceptance.map((item) => ({
445
455
  acceptance_id: item.id,
@@ -448,12 +458,15 @@ export function reduceTask(
448
458
  }))
449
459
  : [],
450
460
  ...(storedReviewRevision ? { review_revision: storedReviewRevision } : {}),
461
+ ...(storedAdvisoryFindings ? { advisory_findings: storedAdvisoryFindings } : {}),
462
+ ...(storedReviewerDigest ? { reviewer_verdict_sha256: storedReviewerDigest } : {}),
451
463
  });
452
464
  appendHistory(record, action, from, approval.id, authorityAudit);
453
465
  break;
454
466
  }
455
467
  case "revise_intent":
456
468
  case "approve_breaking_intent_revision": {
469
+ assertValidSpecBinding(action.next_intent);
457
470
  if (record.lifecycle !== "active")
458
471
  throw new KernelInvariantError([
459
472
  `cannot revise intent while lifecycle is ${record.lifecycle}`,
@@ -0,0 +1,7 @@
1
+ {
2
+ "contract": "immune_brain/runtime_contracts/v1",
3
+ "task_record": "assurance_kernel/task_record/v4",
4
+ "task_tombstone": "assurance_kernel/task_tombstone/v2",
5
+ "assurance_projection": "assurance_kernel/assurance_projection/v1",
6
+ "kernel_store_schema": 1
7
+ }
@@ -0,0 +1,75 @@
1
+ // The contract identifiers one runtime reads and writes in a worktree's
2
+ // Authority Store. A lane-mode batch reads every Lane's Kernel state with the
3
+ // coordinator's own runtime, so the coordinator and the runtime a Lane
4
+ // Executor loads must agree on these identifiers; a plugin version number
5
+ // proves nothing (two different builds can carry the same one).
6
+ //
7
+ // `runtime_contracts.json` is the same set as data, shipped next to this
8
+ // module, so a coordinator can read another runtime's identifiers without
9
+ // executing its code. A test keeps the two in step.
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { dirname, join, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { TASK_TOMBSTONE_CONTRACT } from "./backend_claim";
14
+ import { KERNEL_STORE_SCHEMA_VERSION } from "./storage_paths";
15
+ import { TASK_RECORD_CONTRACT_V4 } from "./types";
16
+
17
+ export const RUNTIME_CONTRACTS_MANIFEST = "runtime_contracts.json";
18
+
19
+ export interface RuntimeContracts {
20
+ contract: "immune_brain/runtime_contracts/v1";
21
+ task_record: string;
22
+ task_tombstone: string;
23
+ assurance_projection: string;
24
+ kernel_store_schema: number;
25
+ }
26
+
27
+ /** The identifiers this running runtime supports. */
28
+ export const RUNTIME_CONTRACTS: RuntimeContracts = {
29
+ contract: "immune_brain/runtime_contracts/v1",
30
+ task_record: TASK_RECORD_CONTRACT_V4,
31
+ task_tombstone: TASK_TOMBSTONE_CONTRACT,
32
+ assurance_projection: "assurance_kernel/assurance_projection/v1",
33
+ kernel_store_schema: KERNEL_STORE_SCHEMA_VERSION,
34
+ };
35
+
36
+ const KEYS = ["task_record", "task_tombstone", "assurance_projection", "kernel_store_schema"] as const;
37
+
38
+ /** Where this running runtime's code was loaded from, for a refusal that names both sides. */
39
+ export function runningRuntimeSource(): string {
40
+ try {
41
+ return dirname(fileURLToPath(import.meta.url));
42
+ } catch {
43
+ return "unknown";
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Read another runtime's identifiers from its manifest. `runtimeDir` is either
49
+ * the `runtime/kernel` directory or a plugin root that contains one. Null when
50
+ * no manifest exists there; a malformed manifest throws.
51
+ */
52
+ export function readRuntimeContracts(runtimeDir: string): { contracts: RuntimeContracts; path: string } | null {
53
+ const candidates = [
54
+ join(runtimeDir, RUNTIME_CONTRACTS_MANIFEST),
55
+ join(runtimeDir, "kernel", RUNTIME_CONTRACTS_MANIFEST),
56
+ join(runtimeDir, "runtime", "kernel", RUNTIME_CONTRACTS_MANIFEST),
57
+ join(runtimeDir, "plugins", "immune-brain", "runtime", "kernel", RUNTIME_CONTRACTS_MANIFEST),
58
+ ];
59
+ const path = candidates.find((candidate) => existsSync(candidate));
60
+ if (!path) return null;
61
+ const raw = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
62
+ if (raw.contract !== "immune_brain/runtime_contracts/v1")
63
+ throw new Error(`${path} is not an immune_brain/runtime_contracts/v1 manifest`);
64
+ for (const key of KEYS) {
65
+ const value = raw[key];
66
+ if (key === "kernel_store_schema" ? !Number.isSafeInteger(value) : typeof value !== "string" || !value)
67
+ throw new Error(`${path} has an invalid ${key}`);
68
+ }
69
+ return { contracts: raw as unknown as RuntimeContracts, path: resolve(path) };
70
+ }
71
+
72
+ /** The identifiers on which two runtimes disagree; empty when they can read each other's state. */
73
+ export function runtimeContractDifferences(a: RuntimeContracts, b: RuntimeContracts): string[] {
74
+ return KEYS.filter((key) => a[key] !== b[key]).map((key) => `${key}: ${String(a[key])} != ${String(b[key])}`);
75
+ }
@@ -57,6 +57,12 @@ export function readBoundActiveSpec(
57
57
  }
58
58
  }
59
59
 
60
+ /** Validate a candidate before it can replace enrolled authority. */
61
+ export function assertValidSpecBinding(intent: TaskIntentV1): void {
62
+ const binding = inspectSpecBinding(intent);
63
+ if (!binding.ok) throw new KernelInvariantError([binding.message]);
64
+ }
65
+
60
66
  export interface BoundSpec {
61
67
  active: string;
62
68
  archive: string;