immune-brain 3.6.8 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +11 -4
  2. package/README.zh-CN.md +10 -3
  3. package/package.json +3 -2
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
  9. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
  10. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
  11. package/plugins/immune-brain/dist/claude/mcp-server.mjs +7581 -5042
  12. package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
  13. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
  14. package/plugins/immune-brain/dist/imm-loop.md +27 -25
  15. package/plugins/immune-brain/dist/imm-planner.md +53 -32
  16. package/plugins/immune-brain/dist/imm-review-retro.md +123 -0
  17. package/plugins/immune-brain/dist/registry.yaml +9 -0
  18. package/plugins/immune-brain/dist/role-prompts/code-review.md +3 -1
  19. package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
  20. package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
  21. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
  22. package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
  23. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
  24. package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
  25. package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
  26. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
  27. package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
  28. package/plugins/immune-brain/runtime/github_issue_tracker.ts +254 -29
  29. package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
  30. package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
  31. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
  32. package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
  33. package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
  34. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
  35. package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
  36. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
  37. package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
  38. package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
  39. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
  40. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  41. package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
  42. package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
  43. package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
  44. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
  45. package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
  46. package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
  47. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
  48. package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
  49. package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
  50. package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
  51. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
  52. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
  53. package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
  54. package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
  55. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
  56. package/plugins/immune-brain/runtime/plan_core.ts +27 -65
  57. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  58. package/plugins/immune-brain/runtime/prompts/code-review.md +3 -1
  59. package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
  60. package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
  61. package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
  62. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
  63. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
  64. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
  65. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
  66. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
  67. package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
  68. package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
  69. package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
  70. package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
  71. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
  72. package/plugins/immune-brain/skills/imm-review-retro/SKILL.md +23 -0
  73. package/plugins/immune-brain/skills/imm-review-retro/scripts/review_retro.ts +355 -0
  74. package/plugins/immune-brain/skills/registry.yaml +9 -0
  75. package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
  76. package/plugins/immune-brain/bin/imm-retired +0 -4
  77. package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
  78. package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
  79. package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
  80. package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
  81. package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
  82. package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Frozen historical TaskRecord reader. TaskRecord v2 and v3 are no longer
3
+ * live contracts: nothing in the reducer/validation/coordinator dispatch
4
+ * accepts them. This module exists solely so `readAuditTaskPair` can keep
5
+ * reading `.imm/audit/` evidence written before the v3 drain window closed.
6
+ * Nothing here changes; it is relocated, not reimplemented.
7
+ */
8
+ import {
9
+ TASK_PHASES,
10
+ TASK_RECORD_CONTRACT_V2,
11
+ type ApprovalAuthorityRole,
12
+ type ApprovalKind,
13
+ type AuthorityAuditDescriptor,
14
+ type EvidenceStatus,
15
+ type TaskFinding,
16
+ type TaskIntentRefV1,
17
+ type TaskIntentV1,
18
+ type TaskApprovalV2,
19
+ type TaskPhase,
20
+ type TaskRecordV3,
21
+ } from "./types";
22
+ import { canonicalIntentHash, parseTaskIntentV1 } from "./intent";
23
+ import {
24
+ EVIDENCE_STATUSES,
25
+ KernelInvariantError,
26
+ KernelValidationError,
27
+ SHA256_HEX,
28
+ arrayAt,
29
+ enumAt,
30
+ objectAt,
31
+ parseApprovalV2,
32
+ parseFinding,
33
+ parseTaskRecordAtVersion,
34
+ positiveInteger,
35
+ rejectUnknown,
36
+ stringAt,
37
+ uniqueIds,
38
+ } from "./validation";
39
+
40
+ export interface TaskEvidenceV2 {
41
+ id: string;
42
+ acceptance_id: string;
43
+ task_revision: number;
44
+ intent_content_hash: string;
45
+ diff_hash: string;
46
+ status: EvidenceStatus;
47
+ actor_id: string;
48
+ summary: string;
49
+ }
50
+
51
+ export interface TaskHistoryEntryV2 {
52
+ id: string;
53
+ at: string;
54
+ type: string;
55
+ from_phase: TaskPhase;
56
+ to_phase: TaskPhase;
57
+ reason: string;
58
+ authority?: AuthorityAuditDescriptor;
59
+ }
60
+
61
+ export interface TaskRecordV2 {
62
+ contract: typeof TASK_RECORD_CONTRACT_V2;
63
+ task_id: string;
64
+ intent_revision: number;
65
+ intent_snapshot: TaskIntentV1;
66
+ intent_ref: TaskIntentRefV1;
67
+ artifact_ref?: { state: "active" | "frozen"; spec_path?: string };
68
+ phase: TaskPhase;
69
+ baseline: string;
70
+ evidence: TaskEvidenceV2[];
71
+ findings: TaskFinding[];
72
+ approvals: TaskApprovalV2[];
73
+ history: TaskHistoryEntryV2[];
74
+ }
75
+
76
+ function parseHistoryV2(
77
+ value: unknown,
78
+ index: number,
79
+ violations: string[],
80
+ ): TaskHistoryEntryV2 {
81
+ const item = objectAt(value, `record.history[${index}]`, violations);
82
+ rejectUnknown(
83
+ item,
84
+ ["id", "at", "type", "from_phase", "to_phase", "reason", "authority"],
85
+ `record.history[${index}]`,
86
+ violations,
87
+ );
88
+ let authority: AuthorityAuditDescriptor | undefined;
89
+ if (item.authority !== undefined) {
90
+ const auth = objectAt(item.authority, `record.history[${index}].authority`, violations);
91
+ rejectUnknown(
92
+ auth,
93
+ ["authority_kind", "actor_id", "confirmation_ref", "issued_at", "expires_at"],
94
+ `record.history[${index}].authority`,
95
+ violations,
96
+ );
97
+ const kind = enumAt(
98
+ auth.authority_kind,
99
+ ["review", "qa", "user"],
100
+ `record.history[${index}].authority.authority_kind`,
101
+ violations,
102
+ );
103
+ authority = {
104
+ authority_kind: kind as AuthorityAuditDescriptor["authority_kind"],
105
+ actor_id: stringAt(auth.actor_id, `record.history[${index}].authority.actor_id`, violations),
106
+ confirmation_ref: stringAt(auth.confirmation_ref, `record.history[${index}].authority.confirmation_ref`, violations),
107
+ issued_at: stringAt(auth.issued_at, `record.history[${index}].authority.issued_at`, violations),
108
+ expires_at: stringAt(auth.expires_at, `record.history[${index}].authority.expires_at`, violations),
109
+ };
110
+ }
111
+ return {
112
+ id: stringAt(item.id, `record.history[${index}].id`, violations),
113
+ at: stringAt(item.at, `record.history[${index}].at`, violations),
114
+ type: stringAt(item.type, `record.history[${index}].type`, violations),
115
+ from_phase: enumAt(item.from_phase, TASK_PHASES, `record.history[${index}].from_phase`, violations),
116
+ to_phase: enumAt(item.to_phase, TASK_PHASES, `record.history[${index}].to_phase`, violations),
117
+ reason: stringAt(item.reason, `record.history[${index}].reason`, violations),
118
+ ...(authority ? { authority } : {}),
119
+ };
120
+ }
121
+
122
+ function parseEvidenceV2(
123
+ value: unknown,
124
+ index: number,
125
+ acceptanceIds: Set<string> | null,
126
+ violations: string[],
127
+ ): TaskEvidenceV2 {
128
+ const item = objectAt(value, `record.evidence[${index}]`, violations);
129
+ rejectUnknown(
130
+ item,
131
+ ["id", "acceptance_id", "task_revision", "intent_content_hash", "diff_hash", "status", "actor_id", "summary"],
132
+ `record.evidence[${index}]`,
133
+ violations,
134
+ );
135
+ const acceptanceId = stringAt(
136
+ item.acceptance_id,
137
+ `record.evidence[${index}].acceptance_id`,
138
+ violations,
139
+ );
140
+ if (acceptanceIds && !acceptanceIds.has(acceptanceId))
141
+ violations.push(
142
+ `evidence ${String(item.id)} references unknown acceptance ${acceptanceId}`,
143
+ );
144
+ const intentContentHash = stringAt(
145
+ item.intent_content_hash,
146
+ `record.evidence[${index}].intent_content_hash`,
147
+ violations,
148
+ );
149
+ if (!SHA256_HEX.test(intentContentHash))
150
+ violations.push(`record.evidence[${index}].intent_content_hash must be sha256:<64 hex>`);
151
+ const diffHash = stringAt(item.diff_hash, `record.evidence[${index}].diff_hash`, violations);
152
+ if (!SHA256_HEX.test(diffHash))
153
+ violations.push(`record.evidence[${index}].diff_hash must be sha256:<64 hex>`);
154
+ return {
155
+ id: stringAt(item.id, `record.evidence[${index}].id`, violations),
156
+ acceptance_id: acceptanceId,
157
+ task_revision: positiveInteger(
158
+ item.task_revision,
159
+ `record.evidence[${index}].task_revision`,
160
+ violations,
161
+ ),
162
+ intent_content_hash: intentContentHash,
163
+ diff_hash: diffHash,
164
+ status: enumAt(item.status, EVIDENCE_STATUSES, `record.evidence[${index}].status`, violations),
165
+ actor_id: stringAt(item.actor_id, `record.evidence[${index}].actor_id`, violations),
166
+ summary: stringAt(item.summary, `record.evidence[${index}].summary`, violations),
167
+ };
168
+ }
169
+
170
+ export function parseTaskRecordV2(raw: unknown): TaskRecordV2 {
171
+ const violations: string[] = [];
172
+ const value = objectAt(raw, "record", violations);
173
+ rejectUnknown(
174
+ value,
175
+ ["contract", "task_id", "intent_revision", "intent_snapshot", "intent_ref", "artifact_ref", "phase", "baseline", "evidence", "findings", "approvals", "history"],
176
+ "record",
177
+ violations,
178
+ );
179
+ if (value.contract !== TASK_RECORD_CONTRACT_V2)
180
+ violations.push(`contract must equal ${TASK_RECORD_CONTRACT_V2}`);
181
+
182
+ let snapshot: TaskIntentV1 | null = null;
183
+ try {
184
+ snapshot = parseTaskIntentV1(value.intent_snapshot);
185
+ } catch {
186
+ violations.push("record.intent_snapshot must be a valid TaskIntent v1");
187
+ }
188
+
189
+ const taskId = stringAt(value.task_id, "record.task_id", violations);
190
+ const intentRevision = positiveInteger(
191
+ value.intent_revision,
192
+ "record.intent_revision",
193
+ violations,
194
+ );
195
+
196
+ const refRaw = objectAt(value.intent_ref, "record.intent_ref", violations);
197
+ rejectUnknown(refRaw, ["path", "revision", "content_hash"], "record.intent_ref", violations);
198
+ const refPath = stringAt(refRaw.path, "record.intent_ref.path", violations);
199
+ const refRevision = positiveInteger(
200
+ refRaw.revision,
201
+ "record.intent_ref.revision",
202
+ violations,
203
+ );
204
+ const refContentHash = stringAt(
205
+ refRaw.content_hash,
206
+ "record.intent_ref.content_hash",
207
+ violations,
208
+ );
209
+ if (!SHA256_HEX.test(refContentHash))
210
+ violations.push("record.intent_ref.content_hash must be sha256:<64 hex>");
211
+
212
+ let artifactRef: TaskRecordV2["artifact_ref"];
213
+ if (value.artifact_ref !== undefined) {
214
+ const artifactRaw = objectAt(value.artifact_ref, "record.artifact_ref", violations);
215
+ rejectUnknown(artifactRaw, ["state", "spec_path"], "record.artifact_ref", violations);
216
+ const state = enumAt(artifactRaw.state, ["active", "frozen"], "record.artifact_ref.state", violations) as "active" | "frozen";
217
+ const specPath = artifactRaw.spec_path === undefined
218
+ ? undefined
219
+ : stringAt(artifactRaw.spec_path, "record.artifact_ref.spec_path", violations);
220
+ if (specPath !== undefined && (!/^docs\/specs\/(?!archive\/)[A-Za-z0-9._/-]+\.spec\.md$/.test(specPath) || specPath.includes("..")))
221
+ violations.push("record.artifact_ref.spec_path must be one canonical active Spec path");
222
+ artifactRef = { state, ...(specPath === undefined ? {} : { spec_path: specPath }) };
223
+ }
224
+
225
+ const activeIntentPath = `docs/plans/${taskId}.intent.json`;
226
+ const frozenIntentPath = `docs/plans/archive/${taskId}.intent.json`;
227
+ if (
228
+ snapshot &&
229
+ (snapshot.task_id !== taskId ||
230
+ snapshot.revision !== intentRevision ||
231
+ snapshot.revision !== refRevision ||
232
+ (refPath !== activeIntentPath && refPath !== frozenIntentPath))
233
+ )
234
+ violations.push("intent_snapshot and intent_ref must match record identity");
235
+ if (artifactRef?.state === "active" && refPath !== activeIntentPath)
236
+ violations.push("active artifact_ref requires the active intent path");
237
+ if (artifactRef?.state === "frozen" && refPath !== activeIntentPath && refPath !== frozenIntentPath)
238
+ violations.push("frozen artifact_ref requires the active or archived intent path");
239
+ if (
240
+ snapshot &&
241
+ refContentHash !== "" &&
242
+ canonicalIntentHash(snapshot) !== refContentHash
243
+ )
244
+ violations.push("intent_ref.content_hash must equal the snapshot canonical hash");
245
+
246
+ const baseline = stringAt(value.baseline, "record.baseline", violations);
247
+ if (!SHA256_HEX.test(baseline))
248
+ violations.push("record.baseline must be sha256:<64 hex>");
249
+
250
+ const acceptanceIds = new Set(
251
+ snapshot ? snapshot.acceptance.map((item) => item.id) : [],
252
+ );
253
+ const evidence = arrayAt(value.evidence, "record.evidence", violations).map(
254
+ (item, index) => parseEvidenceV2(item, index, acceptanceIds, violations),
255
+ );
256
+ const findings = arrayAt(value.findings, "record.findings", violations).map(
257
+ (item, index) => parseFinding(item, index, violations),
258
+ );
259
+ const approvals = arrayAt(value.approvals, "record.approvals", violations).map(
260
+ (item, index) => parseApprovalV2(item, index, violations),
261
+ );
262
+ const history = arrayAt(value.history, "record.history", violations).map(
263
+ (item, index) => parseHistoryV2(item, index, violations),
264
+ );
265
+ uniqueIds(evidence, "record.evidence", violations);
266
+ uniqueIds(findings, "record.findings", violations);
267
+ uniqueIds(approvals, "record.approvals", violations);
268
+ uniqueIds(history, "record.history", violations);
269
+
270
+ const phase = enumAt(value.phase, TASK_PHASES, "phase", violations);
271
+
272
+ if (violations.length > 0) throw new KernelValidationError(violations);
273
+ return {
274
+ contract: TASK_RECORD_CONTRACT_V2,
275
+ task_id: taskId,
276
+ intent_revision: intentRevision,
277
+ intent_snapshot: snapshot as TaskIntentV1,
278
+ intent_ref: {
279
+ path: refPath,
280
+ revision: refRevision,
281
+ content_hash: refContentHash,
282
+ },
283
+ ...(artifactRef ? { artifact_ref: artifactRef } : {}),
284
+ phase,
285
+ baseline,
286
+ evidence,
287
+ findings,
288
+ approvals,
289
+ history,
290
+ };
291
+ }
292
+
293
+ /** Strict v3 drain parser: unknown fields and revision identity stay illegal. */
294
+ export function parseTaskRecordV3(raw: unknown): TaskRecordV3 {
295
+ return parseTaskRecordAtVersion(raw, 3) as TaskRecordV3;
296
+ }
297
+
298
+ export function assertKernelInvariantsV2(
299
+ intentRaw: TaskIntentV1,
300
+ recordRaw: TaskRecordV2,
301
+ ): void {
302
+ const intent = parseTaskIntentV1(intentRaw);
303
+ const record = parseTaskRecordV2(recordRaw);
304
+ const violations: string[] = [];
305
+ if (intent.task_id !== record.task_id)
306
+ violations.push("intent and record task_id must match");
307
+ if (intent.revision !== record.intent_revision)
308
+ violations.push("intent revision and record intent_revision must match");
309
+ if (canonicalIntentHash(record.intent_snapshot) !== record.intent_ref.content_hash)
310
+ violations.push("record intent_ref.content_hash must match its snapshot");
311
+ const requiredRole: Record<ApprovalKind, ApprovalAuthorityRole> = {
312
+ review: "reviewer",
313
+ qa: "qa",
314
+ user: "user",
315
+ };
316
+ for (const approval of record.approvals) {
317
+ if (approval.authority_role !== requiredRole[approval.kind])
318
+ violations.push(
319
+ `approval ${approval.id} kind ${approval.kind} requires authority_role ${requiredRole[approval.kind]}`,
320
+ );
321
+ }
322
+ if (violations.length > 0) throw new KernelInvariantError(violations);
323
+ }
@@ -12,8 +12,9 @@ import {
12
12
  readTaskRecordRaw,
13
13
  readWorkspaceStateRaw,
14
14
  } from "./storage";
15
+ import { stateDatabasePath } from "./storage_paths";
15
16
 
16
- const SOURCE_PATH = ".imm/state/workspace.json";
17
+ const SOURCE_PATH = stateDatabasePath();
17
18
  const GIT_OBJECT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
18
19
 
19
20
  /**
@@ -64,6 +65,13 @@ export interface PiCanaryPreparation {
64
65
  git_error: string | null;
65
66
  workspace: {
66
67
  current_working: string | null;
68
+ /**
69
+ * The workspace CAS token at preparation time. A workspace can return to
70
+ * the same owner value at a different revision (another task enrolled and
71
+ * settled in between), so the owner alone cannot prove the confirmed
72
+ * state is still current.
73
+ */
74
+ revision: string;
67
75
  };
68
76
  digest: string;
69
77
  }
@@ -152,6 +160,7 @@ export function preparePiCanary(root: string, input: PiCanaryPrepareInput): PiCa
152
160
  const state = readWorkspaceStateRaw(canonicalRoot);
153
161
  const workspace: PiCanaryPreparation["workspace"] = {
154
162
  current_working: state.state.current_working,
163
+ revision: state.revision,
155
164
  };
156
165
  if (claim && state.state.current_working !== claim.task_id)
157
166
  throw new Error(
@@ -1,4 +1,4 @@
1
- // Pure TaskRecord v3 reducer with a closed factual action vocabulary.
1
+ // Pure TaskRecord v4 reducer with a closed factual action vocabulary.
2
2
  // Never reads files, Git, workspace, or host context. Returns a branded
3
3
  // ReducedTaskMutation; the caller cannot construct or serialize it.
4
4
 
@@ -7,7 +7,6 @@ import { completionDecision } from "./completion";
7
7
  import { anchorForEvidence, isFreshPassingQaAttestation, refutationIdentity, refutationIsLive } from "./refutation";
8
8
  import {
9
9
  REDUCED_MUTATION_BRAND,
10
- TASK_RECORD_CONTRACT_V4,
11
10
  type AuthorityAuditDescriptor,
12
11
  type ReducedTaskMutation,
13
12
  type TaskAction,
@@ -163,10 +162,6 @@ function appendHistory(
163
162
  record.history.push(entry as (typeof record.history)[number]);
164
163
  }
165
164
 
166
- function sha256Hex(value: string): string {
167
- return createHash("sha256").update(value).digest("hex");
168
- }
169
-
170
165
  function intentRefMatches(intent: TaskIntentV1, ref: TaskIntentRefV3): boolean {
171
166
  const activePath = `docs/plans/${intent.task_id}.intent.json`;
172
167
  const archivedPath = `docs/plans/archive/${intent.task_id}.intent.json`;
@@ -209,19 +204,11 @@ export function findingsDigestV2(findings: TaskFinding[]): string {
209
204
  }
210
205
 
211
206
  /**
212
- * Two findings share an acceptance boundary when they name the same
213
- * acceptance, or when neither does and their review evidence names the same
214
- * violated reference. Null boundaries without comparable evidence are not
215
- * shared, so a second-round finding can no longer park the task by accident.
207
+ * Rework rounds one Review run may demand before the Kernel pauses the task for
208
+ * a human decision. Ordinary defects return to execution without a user gate,
209
+ * but an unbounded repair/review cycle is not progress either.
216
210
  */
217
- function sharesAcceptanceBoundary(left: TaskFinding, right: TaskFinding): boolean {
218
- if (left.acceptance_id !== null && right.acceptance_id !== null)
219
- return left.acceptance_id === right.acceptance_id;
220
- if (left.acceptance_id !== null || right.acceptance_id !== null) return false;
221
- const leftRef = left.evidence?.violated.ref ?? null;
222
- const rightRef = right.evidence?.violated.ref ?? null;
223
- return leftRef !== null && leftRef === rightRef;
224
- }
211
+ export const REVIEW_REWORK_ROUND_BUDGET = 5;
225
212
 
226
213
  export function reduceTask(
227
214
  recordRaw: TaskRecord,
@@ -444,10 +431,7 @@ export function reduceTask(
444
431
  const reviewRevision = approval.review_revision;
445
432
  if (reviewRevision && approval.kind !== "review")
446
433
  throw new KernelInvariantError(["review_revision is only valid on review approvals"]);
447
- if (record.contract !== TASK_RECORD_CONTRACT_V4) {
448
- if (reviewRevision)
449
- throw new KernelInvariantError(["review_revision requires a TaskRecord v4"]);
450
- } else if (approval.kind === "review") {
434
+ if (approval.kind === "review") {
451
435
  if (!reviewRevision)
452
436
  throw new KernelInvariantError(["v4 review approval requires review_revision"]);
453
437
  if (reviewRevision.base_head !== record.git_base_head)
@@ -567,9 +551,11 @@ export function reduceTask(
567
551
  ]);
568
552
  }
569
553
  const identity = refutationIdentity(record, action.diff_hash);
570
- // A prior finding that is still refuted by live evidence is not an
571
- // outstanding dispute, so a new claim on its boundary is not a repeat
572
- // offence. Once its evidence goes stale it blocks again and counts.
554
+ // A prior blocking Review claim is only a repeat offence when it names the
555
+ // same security boundary. Ordinary defects on an already-covered
556
+ // acceptance return to execution: changing the plan is a human decision,
557
+ // fixing a bug is not. A claim still refuted by live evidence is not an
558
+ // outstanding dispute at all.
573
559
  const priorBlockingReviewFindings = record.findings.filter(
574
560
  (finding) =>
575
561
  finding.source === "review" &&
@@ -604,12 +590,26 @@ export function reduceTask(
604
590
  ({ finding, inherited }) =>
605
591
  finding.kind === "blocking" &&
606
592
  inherited === undefined &&
607
- priorBlockingReviewFindings.some((prior) =>
608
- sharesAcceptanceBoundary(finding, prior),
593
+ finding.evidence?.violated.kind === "security_boundary" &&
594
+ priorBlockingReviewFindings.some(
595
+ (prior) =>
596
+ prior.evidence?.violated.kind === "security_boundary" &&
597
+ prior.evidence?.violated.ref === finding.evidence?.violated.ref,
609
598
  ),
610
599
  )?.finding;
600
+ // Only rounds that actually raised an unrefuted blocking claim count
601
+ // against the budget: a claim live QA evidence already answers is not
602
+ // progress lost, so repeating it can never park the task.
603
+ const effectiveBlockingRounds = new Set(
604
+ priorBlockingReviewFindings.map((finding) => finding.review_round),
605
+ ).size;
606
+ const hasEffectiveBlockingNow = admissions.some(
607
+ ({ finding, inherited }) => finding.kind === "blocking" && inherited === undefined,
608
+ );
611
609
  const parkForReplan =
612
- authorityAudit.authority_kind === "review" && disputed !== undefined;
610
+ authorityAudit.authority_kind === "review" &&
611
+ (disputed !== undefined ||
612
+ (hasEffectiveBlockingNow && effectiveBlockingRounds >= REVIEW_REWORK_ROUND_BUDGET));
613
613
  if (!parkForReplan) {
614
614
  record.artifact_state = "active";
615
615
  record.intent_ref.path = `docs/plans/${record.task_id}.intent.json`;
@@ -644,11 +644,13 @@ export function reduceTask(
644
644
  id: `${action.event_id}:replan-required`,
645
645
  kind: "replan_required" as const,
646
646
  status: "open" as const,
647
- acceptance_id: disputed.acceptance_id,
647
+ acceptance_id: disputed?.acceptance_id ?? null,
648
648
  source: "kernel" as const,
649
649
  review_round: round,
650
650
  summary:
651
- "Review returned this acceptance boundary twice; a durable replan is required.",
651
+ disputed !== undefined
652
+ ? "Review returned the same security boundary twice; a durable replan is required."
653
+ : `Review exhausted its ${REVIEW_REWORK_ROUND_BUDGET}-round rework budget; a durable replan is required.`,
652
654
  };
653
655
  if (findingIds.has(boundary.id))
654
656
  throw new KernelInvariantError([
@@ -708,7 +710,6 @@ export function reduceTask(
708
710
  throw new KernelInvariantError(["stop requires a reason"]);
709
711
  transitionLifecycle(record, "stopped");
710
712
  record.artifact_state = "frozen";
711
- record.intent_ref.path = `docs/plans/archive/${record.task_id}.intent.json`;
712
713
  appendHistory(record, action, from, action.reason, authorityAudit);
713
714
  break;
714
715
  }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Exact execution identity.
3
+ *
4
+ * A worktree owns one storage identity (`workspace_id`) and every enrolled
5
+ * execution owns one `run_id`. Authority mutations bind the exact run instead
6
+ * of resolving "the latest task occurrence", and every replayable operation
7
+ * derives its identity from committed facts so a replayed call returns the
8
+ * recorded result instead of writing again.
9
+ *
10
+ * `task_id` names the logical task: different worktrees may each run the same
11
+ * logical task with distinct run identities, and a terminal task cannot be
12
+ * re-enrolled in the same worktree.
13
+ */
14
+ import { createHash, randomUUID } from "node:crypto";
15
+ import type { DatabaseSync } from "node:sqlite";
16
+
17
+ import { KernelStoreSecurityError, workspaceIdentity } from "./sqlite_store";
18
+
19
+ export interface RunIdentity {
20
+ workspace_id: string;
21
+ task_id: string;
22
+ run_id: string;
23
+ }
24
+
25
+ export interface RunRowIdentity {
26
+ task_id: string;
27
+ run_id: string;
28
+ }
29
+
30
+ export function mintRunId(): string {
31
+ return `run-${randomUUID()}`;
32
+ }
33
+
34
+ export function runIdentity(db: DatabaseSync, row: RunRowIdentity): RunIdentity {
35
+ return { workspace_id: workspaceIdentity(db), task_id: row.task_id, run_id: row.run_id };
36
+ }
37
+
38
+ /** Bind a mutation to one exact run; a mismatch fails before any write. */
39
+ export function assertRunBinding(
40
+ identity: RunIdentity,
41
+ expected: RunRowIdentity,
42
+ operation: string,
43
+ ): void {
44
+ if (identity.run_id !== expected.run_id || identity.task_id !== expected.task_id)
45
+ throw new KernelStoreSecurityError(
46
+ `${operation} is bound to run ${expected.run_id} (task ${expected.task_id}) but the store holds run ${identity.run_id} (task ${identity.task_id})`,
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Deterministic replay identity for the authority operations that can lose a
52
+ * response. The event identifier is part of the committed authority bytes, so
53
+ * an identical request maps to an identical operation id and a replayed call
54
+ * returns the recorded result instead of writing again.
55
+ */
56
+ export function enrollmentOperationId(taskId: string, eventId: string): string {
57
+ return `enroll:${taskId}:${eventId}`;
58
+ }
59
+
60
+ /**
61
+ * Digest of an enrollment request's own content. The capability object is
62
+ * opaque and excluded, so a lost-response retry of the *same* confirmation
63
+ * matches, while a different path, digest, actor, nonce or event time is a
64
+ * different request and must not be answered from the committed operation.
65
+ */
66
+ export function enrollmentRequestDigest(request: {
67
+ task_id: string;
68
+ intent_path: string;
69
+ intent_revision: number;
70
+ intent_content_hash: string;
71
+ preparation_digest: string;
72
+ enrollment_event_id: string;
73
+ actor_id: string;
74
+ confirmation_ref: string;
75
+ nonce: string;
76
+ }): string {
77
+ const canonical = JSON.stringify({
78
+ task_id: request.task_id,
79
+ intent_path: request.intent_path,
80
+ intent_revision: request.intent_revision,
81
+ intent_content_hash: request.intent_content_hash,
82
+ preparation_digest: request.preparation_digest,
83
+ enrollment_event_id: request.enrollment_event_id,
84
+ actor_id: request.actor_id,
85
+ confirmation_ref: request.confirmation_ref,
86
+ nonce: request.nonce,
87
+ });
88
+ return `sha256:${createHash("sha256").update(canonical).digest("hex")}`;
89
+ }
90
+
91
+ export function drainOperationId(taskId: string, updatedAt: string): string {
92
+ return `drain:${taskId}:${updatedAt}`;
93
+ }
94
+
95
+ /**
96
+ * Digest of a terminal request's own content. The capability is deliberately
97
+ * opaque and excluded, so a retry that re-mints authority for the *same*
98
+ * request produces the same digest, while a different reason, actor or event
99
+ * time produces a different one and must be authorized again instead of being
100
+ * answered from the committed operation.
101
+ */
102
+ export function terminalRequestDigest(action: {
103
+ type: string;
104
+ event_id: string;
105
+ at: string;
106
+ actor_id: string;
107
+ reason?: unknown;
108
+ }): string {
109
+ const canonical = JSON.stringify({
110
+ type: action.type,
111
+ event_id: action.event_id,
112
+ at: action.at,
113
+ actor_id: action.actor_id,
114
+ reason: typeof action.reason === "string" ? action.reason : null,
115
+ });
116
+ return `sha256:${createHash("sha256").update(canonical).digest("hex")}`;
117
+ }
118
+
119
+ export function terminalOperationId(taskId: string, eventId: string): string {
120
+ return `terminal:${taskId}:${eventId}`;
121
+ }