immune-brain 3.6.3 → 3.6.5

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 (31) hide show
  1. package/package.json +6 -3
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +117 -47
  4. package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +6 -4
  5. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -44
  6. package/plugins/immune-brain/dist/claude/mcp-server.mjs +213 -81
  7. package/plugins/immune-brain/dist/imm-loop.md +8 -2
  8. package/plugins/immune-brain/dist/imm-planner.md +9 -4
  9. package/plugins/immune-brain/runtime/assurance/coordinator.ts +35 -2
  10. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +23 -13
  11. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +133 -45
  12. package/plugins/immune-brain/runtime/claude/mcp_server.ts +14 -1
  13. package/plugins/immune-brain/runtime/claude/review_host.ts +0 -10
  14. package/plugins/immune-brain/runtime/commands/kernel.ts +15 -13
  15. package/plugins/immune-brain/runtime/github_issue_tracker.ts +107 -11
  16. package/plugins/immune-brain/runtime/kernel/application.ts +6 -0
  17. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +4 -1
  18. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +407 -0
  19. package/plugins/immune-brain/runtime/kernel/canary_application.ts +17 -8
  20. package/plugins/immune-brain/runtime/kernel/enrollment.ts +76 -13
  21. package/plugins/immune-brain/runtime/kernel/index.ts +2 -0
  22. package/plugins/immune-brain/runtime/kernel/intent.ts +67 -23
  23. package/plugins/immune-brain/runtime/kernel/observation.ts +2 -0
  24. package/plugins/immune-brain/runtime/kernel/reducer.ts +34 -8
  25. package/plugins/immune-brain/runtime/kernel/storage.ts +17 -2
  26. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +1 -1
  27. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +0 -4
  28. package/plugins/immune-brain/runtime/kernel/types.ts +10 -0
  29. package/plugins/immune-brain/runtime/kernel/validation.ts +10 -6
  30. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +2 -2
  31. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
@@ -17,6 +17,7 @@ import { createInvocationRegistry, type InvocationState, type InvocationToken }
17
17
  import type { ReviewBundle, ReviewManifestV5, ReviewRevision } from "./review_evidence";
18
18
  import { buildRoleDelegationPacket } from "../role_prompt_bridge";
19
19
  import type { AssuranceProjectionResult } from "../kernel/assurance_projection";
20
+ import type { TaskIntentIdentityToken } from "../kernel/intent_token_registry";
20
21
  import type { AssuranceHostPort, HostReviewReservation } from "./host_port";
21
22
 
22
23
  export type AssuranceRole = "qa" | "review";
@@ -38,7 +39,11 @@ export interface TaskRecordRead {
38
39
  record?: { contract?: string; findings: Array<{ kind: string; status: string }> } | null;
39
40
  }
40
41
  export interface TaskIntentRead {
41
- token?: string;
42
+ /**
43
+ * The kernel's own intent identity token. Declared as `string` before this
44
+ * port was ever type checked, which is a shape no host reader has produced.
45
+ */
46
+ token?: TaskIntentIdentityToken;
42
47
  }
43
48
 
44
49
  export interface GithubTerminalProjectionInput {
@@ -431,6 +436,7 @@ export class AssuranceCoordinator {
431
436
  sessionGenerationValue(): number { return this.sessionGeneration; }
432
437
 
433
438
  async advance(taskId: string, ctx: HostContext, signal?: AbortSignal, onUpdate?: (update: ForegroundToolUpdate) => void): Promise<AssuranceAdvanceResult> {
439
+ if (this.isInvocationOpen(taskId)) return { state: "blocked", reason: "an authority invocation is already open" };
434
440
  const active = this.active(taskId);
435
441
  if (active?.state === "review_ready") {
436
442
  const reservation = this.reviewReservations.get(taskId);
@@ -814,6 +820,17 @@ export class AssuranceCoordinator {
814
820
  return { state: "blocked", reason: `Kernel requires ${settled.projection.next_obligation} after Review` };
815
821
  }
816
822
 
823
+ isReviewVerdictValid(taskId: string, verdictInput: unknown): boolean {
824
+ const reservation = this.reviewReservations.get(taskId);
825
+ if (!reservation) return false;
826
+ try {
827
+ parseAssuranceVerdict(verdictInput, reservation.snapshot);
828
+ return true;
829
+ } catch {
830
+ return false;
831
+ }
832
+ }
833
+
817
834
  abandonReview(taskId: string, reason: string): AssuranceSubmitReviewResult {
818
835
  const reservation = this.reviewReservations.get(taskId);
819
836
  if (!reservation) return { state: "blocked", reason };
@@ -828,6 +845,12 @@ export class AssuranceCoordinator {
828
845
  return { state: "review_ready", operation: "review", operation_id: reservation.operationId, snapshot_digest: snapshotDigest(reservation.snapshot), review_bundle_digest: reservation.snapshot.review_bundle_digest ?? "", agent_params: reservation.hostReservation.dispatch };
829
846
  }
830
847
 
848
+ releaseStoppedReview(taskId: string): void {
849
+ const reservation = this.reviewReservations.get(taskId);
850
+ if (reservation) this.releaseReviewReservation(taskId, reservation);
851
+ this.rejectedReviewOperations.delete(taskId);
852
+ }
853
+
831
854
  private releaseReviewReservation(taskId: string, reservation: ReviewReservation, rejectionReason?: string): void {
832
855
  if (this.reviewReservations.get(taskId) !== reservation) return;
833
856
  this.reviewReservations.delete(taskId);
@@ -873,7 +896,17 @@ export class AssuranceCoordinator {
873
896
  }
874
897
  }
875
898
 
876
- private unknownAfterCommit(taskId: string, operation: "qa" | "review", operationId: string, reason: string): AssuranceAdvanceResult {
899
+ /**
900
+ * Declared as the wider advance result, which made it unusable from
901
+ * `submitReview` without an implicit widen. The value has always been the
902
+ * settlement_unknown member both result unions share.
903
+ */
904
+ private unknownAfterCommit(
905
+ taskId: string,
906
+ operation: "qa" | "review",
907
+ operationId: string,
908
+ reason: string,
909
+ ): { state: "settlement_unknown"; operation: "qa" | "review"; operation_id: string; reason: string } {
877
910
  this.unknownOperations.set(taskId, { operation, operationId, reason });
878
911
  return { state: "settlement_unknown", operation, operation_id: operationId, reason };
879
912
  }
@@ -240,14 +240,32 @@ const SNAPSHOT_IDENTITY = {
240
240
  date: "1970-01-01T00:00:00 +0000",
241
241
  };
242
242
 
243
- export interface ReviewRevision {
243
+ /**
244
+ * The bare synthetic-commit identity. `publishReviewRevision` can prove these
245
+ * five fields from Git alone; it has no manifest inputs and therefore cannot
246
+ * produce a digest.
247
+ */
248
+ export interface ReviewRevisionCommit {
244
249
  contract: "assurance_kernel/review_revision/v1";
245
250
  base_head: string;
246
251
  review_tree: string;
247
252
  review_commit: string;
248
253
  review_ref: string;
249
254
  diff_hash: string;
250
- manifest_digest?: string;
255
+ }
256
+
257
+ /**
258
+ * The full Review revision identity a host port must return.
259
+ *
260
+ * `manifest_digest` was optional here, which is the type-level hole that let a
261
+ * host return the bare commit identity and still satisfy
262
+ * `AssuranceCoordinatorPorts.ensureReviewRevision`. `submitReview` compares all
263
+ * four identity fields against the reservation, so an absent digest compared
264
+ * against a real one and failed every v4 submission at runtime instead of at
265
+ * build time. Requiring it makes that omission a compile error.
266
+ */
267
+ export interface ReviewRevision extends ReviewRevisionCommit {
268
+ manifest_digest: string;
251
269
  }
252
270
 
253
271
  export interface ReviewManifestV5 {
@@ -354,7 +372,7 @@ export function publishReviewRevision(
354
372
  snapshot: GitTaskRevisionSnapshot,
355
373
  diffHash: string,
356
374
  taskId: string,
357
- ): ReviewRevision {
375
+ ): ReviewRevisionCommit {
358
376
  if (snapshot.base_head !== snapshot.base_head.toLowerCase() || !GIT_COMMIT_ID.test(snapshot.base_head))
359
377
  throw new Error("review revision base has invalid identity");
360
378
  if (!REVISION_DIFF_HASH.test(diffHash)) throw new Error("review revision diff hash has invalid identity");
@@ -429,7 +447,7 @@ function publishInput(
429
447
  scopeHint: unknown;
430
448
  expectedDiffHash: string;
431
449
  },
432
- ): { snapshot: GitTaskRevisionSnapshot; revision: ReviewRevision } {
450
+ ): { snapshot: GitTaskRevisionSnapshot; revision: ReviewRevisionCommit } {
433
451
  if (typeof input.baseHead !== "string" || !GIT_COMMIT_ID.test(input.baseHead))
434
452
  throw new Error("review requires a TaskRecord v4 git_base_head");
435
453
  if (!REVISION_DIFF_HASH.test(input.expectedDiffHash))
@@ -486,14 +504,6 @@ export function captureReviewManifest(
486
504
  return manifest;
487
505
  }
488
506
 
489
- /** Publish and return the exact revision a v4 task must use. */
490
- export function ensureReviewRevision(
491
- root: string,
492
- input: { taskId: string; baseHead: string; scopeHint: unknown; expectedDiffHash: string },
493
- ): ReviewRevision {
494
- return publishInput(root, input).revision;
495
- }
496
-
497
507
  export function listReviewRefs(root: string): Array<{ ref: string; commit: string; taskId: string }> {
498
508
  const output = gitEvidence(root, ["for-each-ref", "--format=%(refname) %(objectname)", `${REVIEW_REF_NAMESPACE}/`]);
499
509
  const refs: Array<{ ref: string; commit: string; taskId: string }> = [];
@@ -534,7 +544,7 @@ export function reconcileReviewRefs(
534
544
  return { removed, failed };
535
545
  }
536
546
 
537
- export function deleteReviewRef(root: string, revision: ReviewRevision): void {
547
+ export function deleteReviewRef(root: string, revision: ReviewRevisionCommit): void {
538
548
  const parts = revision.review_ref.split("/");
539
549
  let validRef = false;
540
550
  if (
@@ -20,12 +20,12 @@ import {
20
20
  import {
21
21
  captureReviewBundle,
22
22
  captureReviewManifest,
23
- ensureReviewRevision,
24
23
  writeNativeReviewEvidence,
24
+ type ReviewRevision,
25
25
  } from "../assurance/review_evidence";
26
26
  import { parseVerificationDescriptor } from "../verification_descriptor";
27
- import { projectAssurance, type AssuranceProjectionResult } from "../kernel/assurance_projection";
28
- import type { TaskRecord } from "../kernel/types";
27
+ import { projectAssurance, type AssuranceProjection, type AssuranceProjectionResult } from "../kernel/assurance_projection";
28
+ import { isTaskRecordV4, type TaskApprovalV2, type TaskRecord } from "../kernel/types";
29
29
  import { readTaskRecord, readTaskRecordRaw } from "../kernel/storage";
30
30
  import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
31
31
  import { capabilityActionFor, createCanaryApplication } from "../kernel/canary_application";
@@ -51,6 +51,7 @@ import {
51
51
  isPrivilegedOperation,
52
52
  NativeAuthorityError,
53
53
  type NativeConfirmationPort,
54
+ type PrivilegedOperation,
54
55
  } from "./interaction";
55
56
  import { ClaudeReviewHost, FileHookEventLog, type ClaudeHookEvent } from "./review_host";
56
57
  import { probeHost, type PermissionMode } from "./capability";
@@ -118,21 +119,29 @@ export async function submitClaudeReview(
118
119
  if (observed.release) return coordinator.abandonReview(taskId, observed.reason);
119
120
  return { state: "blocked", reason: observed.reason };
120
121
  }
121
- const parentJson = extractVerdictJson(verdictInput);
122
- const receiptJson = extractVerdictJson(observed.receipt.result);
123
- if (parentJson && receiptJson && verdictFingerprint(parentJson) !== verdictFingerprint(receiptJson)) {
124
- return { state: "blocked", reason: "parent verdict does not match reviewer receipt" };
122
+ const parentValid = coordinator.isReviewVerdictValid(taskId, verdictInput);
123
+ if (!parentValid) return coordinator.submitReview(taskId, ctx, verdictInput);
124
+ const receiptValid = coordinator.isReviewVerdictValid(taskId, observed.receipt.result);
125
+ if (!receiptValid) {
126
+ return coordinator.abandonReview(taskId, "reviewer receipt is not a valid verdict");
125
127
  }
126
- if (parentJson && !receiptJson) {
127
- return { state: "blocked", reason: "reviewer receipt is not a valid verdict" };
128
+ const parentJson = extractVerdictJson(verdictInput)!;
129
+ const receiptJson = extractVerdictJson(observed.receipt.result)!;
130
+ if (verdictFingerprint(parentJson) !== verdictFingerprint(receiptJson)) {
131
+ return { state: "blocked", reason: "parent verdict does not match reviewer receipt" };
128
132
  }
129
133
  return coordinator.submitReview(taskId, ctx, verdictInput);
130
134
  }
131
135
 
136
+ /** `extra` arrives as `Record<string, unknown>`; only a real string is a reason. */
137
+ function stopReason(value: unknown): string {
138
+ return typeof value === "string" && value.length > 0 ? value : "user stop";
139
+ }
140
+
132
141
  function assertProjectionBinding(before: AssuranceProjectionResult, after: AssuranceProjectionResult, allowDiffChange = false): void {
133
- const fields = (allowDiffChange
142
+ const fields: ReadonlyArray<keyof AssuranceProjection> = allowDiffChange
134
143
  ? ["record_revision", "workspace_revision", "intent_revision", "intent_content_hash"]
135
- : ["record_revision", "workspace_revision", "intent_revision", "intent_content_hash", "diff_hash"]) as const;
144
+ : ["record_revision", "workspace_revision", "intent_revision", "intent_content_hash", "diff_hash"];
136
145
  if (before.error || !before.claim || after.error || !after.claim || before.claim.task_id !== after.claim.task_id
137
146
  || fields.some((field) => before.projection[field] !== after.projection[field])) {
138
147
  throw new Error("Task changed after native confirmation; authority aborted before capability issuance");
@@ -146,6 +155,58 @@ function qaOutcomes(record: { attestations: Array<{ kind: string; acceptance_res
146
155
  );
147
156
  }
148
157
 
158
+ /**
159
+ * Publish the task-scoped synthetic revision for a v4 record and return the
160
+ * exact identity the Review snapshot binds.
161
+ *
162
+ * `submitReview` re-derives this identity and compares all four fields —
163
+ * `manifest_digest` included — against the reservation. Returning the bare
164
+ * commit identity therefore compared a real digest against `undefined` and
165
+ * failed every v4 submission with "Review revision changed before submission",
166
+ * so the manifest is recomputed here rather than only the commit. The outcomes
167
+ * come from the same `qaOutcomes` the Review snapshot is built from, which
168
+ * makes the two digests equal by construction instead of by coincidence.
169
+ *
170
+ * v3 records keep the legacy full-source bundle and return null.
171
+ */
172
+ export async function ensureClaudeReviewRevision(
173
+ root: string,
174
+ taskId: string,
175
+ projection: AssuranceProjectionResult,
176
+ ): Promise<ReviewRevision | null> {
177
+ const current = await readTaskRecord(root, taskId);
178
+ const record = current.record;
179
+ if (!record) throw new Error(`task ${taskId} has no TaskRecord`);
180
+ if (current.revision !== projection.projection.record_revision)
181
+ throw new Error("TaskRecord changed before Review revision preparation");
182
+ if (record.contract !== "assurance_kernel/task_record/v4") return null;
183
+ if (!record.git_base_head)
184
+ throw new Error("Review revision requires a TaskRecord v4 git_base_head");
185
+ const manifest = captureReviewManifest(root, {
186
+ taskId,
187
+ baseHead: record.git_base_head,
188
+ scopeHint: record.intent_snapshot.scope_hint,
189
+ expectedDiffHash: projection.projection.diff_hash,
190
+ intentRevision: projection.projection.intent_revision,
191
+ intentContentHash: projection.projection.intent_content_hash,
192
+ recordRevision: projection.projection.record_revision,
193
+ workspaceRevision: projection.projection.workspace_revision,
194
+ lifecycle: projection.projection.lifecycle,
195
+ artifactState: projection.projection.artifact_state,
196
+ risk: record.intent_snapshot.risk,
197
+ outcomes: qaOutcomes(record),
198
+ });
199
+ return {
200
+ contract: "assurance_kernel/review_revision/v1",
201
+ base_head: manifest.base_head,
202
+ review_tree: manifest.review_tree,
203
+ review_commit: manifest.review_commit,
204
+ review_ref: manifest.review_ref,
205
+ diff_hash: manifest.diff_hash,
206
+ manifest_digest: manifest.manifest_digest,
207
+ };
208
+ }
209
+
149
210
  async function buildAssuranceSnapshot(
150
211
  root: string,
151
212
  taskId: string,
@@ -153,23 +214,25 @@ async function buildAssuranceSnapshot(
153
214
  projection: AssuranceProjectionResult,
154
215
  runner: FrozenRunner,
155
216
  ) {
156
- const record = await readTaskRecord(root, taskId);
157
- if (!record.record || record.revision !== projection.projection.record_revision) throw new Error("TaskRecord changed before assurance snapshot capture");
158
- const intent = record.record.intent_snapshot;
217
+ const read = await readTaskRecord(root, taskId);
218
+ const record = read.record;
219
+ if (!record || read.revision !== projection.projection.record_revision) throw new Error("TaskRecord changed before assurance snapshot capture");
220
+ const intent = record.intent_snapshot;
159
221
  const descriptors = new Map<string, VerificationDescriptor>();
160
222
  for (const item of intent.acceptance) {
161
223
  const descriptor = parseVerificationDescriptor(item.verification);
162
224
  assertRunnerCompatible(descriptor, runner);
163
225
  descriptors.set(item.id, descriptor);
164
226
  }
165
- const v4 = record.record.contract === "assurance_kernel/task_record/v4";
166
- const reviewBundle = role === "review" && !v4
167
- ? captureReviewBundle(root, intent.scope_hint, projection.projection.diff_hash, qaOutcomes(record.record))
227
+ // `git_base_head` exists only on TaskRecord v4, so this must narrow the union
228
+ // rather than test the contract string into a plain boolean.
229
+ const reviewBundle = role === "review" && !isTaskRecordV4(record)
230
+ ? captureReviewBundle(root, intent.scope_hint, projection.projection.diff_hash, qaOutcomes(record))
168
231
  : null;
169
- const reviewManifest = role === "review" && v4
232
+ const reviewManifest = role === "review" && isTaskRecordV4(record)
170
233
  ? captureReviewManifest(root, {
171
234
  taskId,
172
- baseHead: record.record.git_base_head,
235
+ baseHead: record.git_base_head,
173
236
  scopeHint: intent.scope_hint,
174
237
  expectedDiffHash: projection.projection.diff_hash,
175
238
  intentRevision: projection.projection.intent_revision,
@@ -179,7 +242,7 @@ async function buildAssuranceSnapshot(
179
242
  lifecycle: projection.projection.lifecycle,
180
243
  artifactState: projection.projection.artifact_state,
181
244
  risk: intent.risk,
182
- outcomes: qaOutcomes(record.record),
245
+ outcomes: qaOutcomes(record),
183
246
  })
184
247
  : null;
185
248
  const dirtyFiles = reviewManifest ? Object.keys(reviewManifest.changed_paths) : reviewBundle ? Object.keys(reviewBundle.dirty_files) : [];
@@ -295,7 +358,13 @@ export interface ClaudeRuntimeOptions {
295
358
  cwd: string;
296
359
  env?: Record<string, string | undefined>;
297
360
  host?: ClaudeReviewHost;
298
- ports?: AssuranceCoordinatorPorts;
361
+ /**
362
+ * Overrides layered on top of the real production ports, never a
363
+ * replacement for them. A whole synthetic ports object could previously be
364
+ * substituted here, so a suite could pass while the object production
365
+ * actually wires was never constructed once.
366
+ */
367
+ ports?: Partial<AssuranceCoordinatorPorts>;
299
368
  interactive?: boolean;
300
369
  permissionMode?: PermissionMode;
301
370
  requestConfirmation?: NativeConfirmationPort;
@@ -319,11 +388,11 @@ export class ClaudeRuntime {
319
388
  this.interactive = options.interactive ?? true;
320
389
  this.requestConfirmation = options.requestConfirmation;
321
390
  this.host = options.host ?? new ClaudeReviewHost(new FileHookEventLog());
322
- if (options.ports) {
323
- this.coordinator = new AssuranceCoordinator({ ...options.ports, host: this.host });
324
- return;
325
- }
326
- this.coordinator = new AssuranceCoordinator(this.createKernelPorts());
391
+ this.coordinator = new AssuranceCoordinator({
392
+ ...this.createKernelPorts(),
393
+ ...options.ports,
394
+ host: this.host,
395
+ });
327
396
  }
328
397
 
329
398
  observe(event: ClaudeHookEvent): void {
@@ -343,25 +412,25 @@ export class ClaudeRuntime {
343
412
  await this.coordinator.onSessionShutdown();
344
413
  }
345
414
 
415
+ /**
416
+ * The exact ports object the coordinator runs on. Public so a conformance
417
+ * suite can drive what production wires instead of a hand-built double: the
418
+ * host adapter defects that reached published plugins all lived in this
419
+ * object and none of them were reachable from a test while it was private.
420
+ */
421
+ kernelPorts(): AssuranceCoordinatorPorts {
422
+ return this.createKernelPorts();
423
+ }
424
+
346
425
  private createKernelPorts(): AssuranceCoordinatorPorts {
347
426
  return {
348
427
  host: this.host,
349
428
  projectTask: (root, taskId) => projectAssurance(root, taskId, diffSnapshotOf),
350
- readTaskRecord: (root, taskId) => readTaskRecord(root, taskId),
351
- readTaskIntent: (root, taskId) => readTaskIntentForRecord(root, taskId),
429
+ readTaskRecord: async (root, taskId) => readTaskRecord(root, taskId),
430
+ readTaskIntent: async (root, taskId) => readTaskIntentForRecord(root, taskId),
352
431
  frozenRunner: async () => resolveBunRunner(),
353
432
  buildAssurance: (root, taskId, role, projection, runner) => buildAssuranceSnapshot(root, taskId, role, projection, runner),
354
- ensureReviewRevision: async (root, taskId, projection) => {
355
- const current = await readTaskRecord(root, taskId);
356
- if (!current.record) throw new Error(`task ${taskId} has no TaskRecord`);
357
- if (current.record.contract !== "assurance_kernel/task_record/v4") return null;
358
- return ensureReviewRevision(root, {
359
- taskId,
360
- baseHead: current.record.git_base_head,
361
- scopeHint: current.record.intent_snapshot.scope_hint,
362
- expectedDiffHash: projection.projection.diff_hash,
363
- });
364
- },
433
+ ensureReviewRevision: (root, taskId, projection) => ensureClaudeReviewRevision(root, taskId, projection),
365
434
  runQa: (snapshot, descriptors, runner, options) => runDeterministicQa(snapshot, descriptors, runner, options),
366
435
  writeReviewEvidence: (input) => writeNativeReviewEvidence(input.evidence),
367
436
  applyVerdict: (ctx, input) => this.applyVerdict(ctx, input),
@@ -459,6 +528,19 @@ export class ClaudeRuntime {
459
528
  return submitClaudeReview(this.host, this.coordinator, { cwd: this.cwd }, taskId, verdictInput);
460
529
  }
461
530
 
531
+ /**
532
+ * Ordinary Kernel operation, not a privileged one: canary_application builds
533
+ * the action without a capability and the Pi Host lists resolve_finding in
534
+ * its ordinary KERNEL_OPERATIONS. The reducer owns every precondition, so
535
+ * this port reads no findings and tests no kind.
536
+ */
537
+ async resolveFinding(taskId: string, findingId: string) {
538
+ return this.executeOrdinary({ cwd: this.cwd }, {
539
+ taskId,
540
+ operation: { op: "resolve_finding", finding_id: findingId, actor_id: "executor" },
541
+ });
542
+ }
543
+
462
544
  async authorize(taskId: string, operation: string, meta: ToolMeta, extra: Record<string, unknown> = {}) {
463
545
  if (operation === "repair_authority_state") {
464
546
  const authority = reconcileKernelAuthority(this.cwd, taskId);
@@ -468,7 +550,7 @@ export class ClaudeRuntime {
468
550
  return repairKernelAuthority(this.cwd, taskId, authority.revision);
469
551
  }
470
552
  if (!isPrivilegedOperation(operation) && operation !== "request_authorization") throw new Error(`unsupported privileged operation ${operation}`);
471
- let op = operation;
553
+ let op: PrivilegedOperation | "request_authorization" | "resolve_user_decision" | "authorize_rework" = operation;
472
554
  let decisionOp: { finding_id: string; resolution: string } | undefined;
473
555
  const projection = await this.status(taskId);
474
556
  if (projection.error || !projection.claim) throw new Error(projection.error ?? "no active backend claim");
@@ -485,6 +567,8 @@ export class ClaudeRuntime {
485
567
  if (open.length !== 1) throw new Error(`resolve-user-decision requires exactly one open user decision; found ${open.length}`);
486
568
  op = "resolve_user_decision";
487
569
  decisionOp = { finding_id: open[0].id, resolution: `resume after literal-user decision: ${open[0].summary}` };
570
+ } else if (readiness.state === "authorize_rework") {
571
+ op = "authorize_rework";
488
572
  } else {
489
573
  throw new Error(readiness.blocked ?? "no unique host-derived authorization operation");
490
574
  }
@@ -584,7 +668,7 @@ export class ClaudeRuntime {
584
668
  confirmation_ref: confirmation,
585
669
  ...(op === "approve_breaking_intent_revision" ? { next_intent: nextIntent, next_intent_ref: nextIntentRef } : {}),
586
670
  ...(op === "resolve_user_decision" && decisionOp ? decisionOp : {}),
587
- ...(op === "stop" ? { reason: extra.reason ?? "user stop" } : {}),
671
+ ...(op === "stop" ? { reason: stopReason(extra.reason) } : {}),
588
672
  });
589
673
  throwIfCancelled(meta.signal);
590
674
  const result = app.execute({
@@ -596,13 +680,17 @@ export class ClaudeRuntime {
596
680
  actor_id: actorId,
597
681
  ...(op === "approve_breaking_intent_revision" ? { next_intent: nextIntent, next_intent_ref: nextIntentRef } : {}),
598
682
  ...(op === "resolve_user_decision" && decisionOp ? decisionOp : {}),
599
- ...(op === "stop" ? { reason: extra.reason ?? "user stop" } : {}),
683
+ ...(op === "stop" ? { reason: stopReason(extra.reason) } : {}),
600
684
  } as never,
601
685
  prior_intent_token: priorIntent.token,
602
686
  diffProvider: diffSnapshotOf,
603
687
  now,
604
688
  });
605
- if (op === "stop" || op === "approve_breaking_intent_revision") stagePlanningArtifactTransition(this.cwd, result.record);
689
+ if (
690
+ op === "stop" ||
691
+ op === "authorize_rework" ||
692
+ op === "approve_breaking_intent_revision"
693
+ ) stagePlanningArtifactTransition(this.cwd, result.record);
606
694
  return result;
607
695
  } catch (error) {
608
696
  if (sidecar && priorBytes && priorIndexState) {
@@ -672,7 +760,7 @@ export class ClaudeRuntime {
672
760
  stagePlanningArtifactTransition(ctx.cwd, result.record);
673
761
  return;
674
762
  }
675
- const approval = {
763
+ const approval: TaskApprovalV2 = {
676
764
  id: `approval-${input.snapshot.role}-${randomUUID().slice(0, 8)}`,
677
765
  kind: input.snapshot.role === "qa" ? "qa" : "review",
678
766
  authority_role: input.snapshot.role === "qa" ? "qa" : "reviewer",
@@ -707,7 +795,7 @@ export class ClaudeRuntime {
707
795
  }));
708
796
  }
709
797
 
710
- private async executeOrdinary(ctx: HostContext, input: { taskId: string; operation: { op: string; actor_id: string; next_intent?: unknown } }) {
798
+ private async executeOrdinary(ctx: HostContext, input: { taskId: string; operation: { op: string; actor_id: string; next_intent?: unknown; finding_id?: string } }) {
711
799
  const { app } = await this.authority();
712
800
  const operation = input.operation.op === "revise_intent"
713
801
  ? { ...input.operation, next_intent: await parseTaskIntentV1(input.operation.next_intent) }
@@ -26,6 +26,7 @@ export const TOOLS = [
26
26
  { name: "approve_breaking_intent_revision", description: "Approve a breaking TaskIntent revision.", privileged: true },
27
27
  { name: "stop", description: "Stop the active task with literal-user authority.", privileged: true },
28
28
  { name: "repair_authority_state", description: "Repair a proven recoverable stale backend claim.", privileged: false },
29
+ { name: "resolve_finding", description: "Resolve one open blocking or advisory finding whose cause is fixed and verified.", privileged: false },
29
30
  ] as const;
30
31
 
31
32
  export function listMcpTools() {
@@ -39,8 +40,13 @@ export function listMcpTools() {
39
40
  ...(tool.name === "approve_breaking_intent_revision" ? { next_intent: { type: "object" } } : {}),
40
41
  ...(tool.name === "stop" ? { reason: { type: "string" } } : {}),
41
42
  ...(tool.name === "submit_review" ? { verdict: { type: "object" } } : {}),
43
+ ...(tool.name === "resolve_finding" ? { finding_id: { type: "string" } } : {}),
42
44
  },
43
- required: tool.name === "submit_review" ? ["task_id", "verdict"] : ["task_id"],
45
+ required: tool.name === "submit_review"
46
+ ? ["task_id", "verdict"]
47
+ : tool.name === "resolve_finding"
48
+ ? ["task_id", "finding_id"]
49
+ : ["task_id"],
44
50
  },
45
51
  annotations: tool.privileged ? privilegedAnnotations() : { readOnlyHint: tool.name === "status" },
46
52
  }));
@@ -119,6 +125,13 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
119
125
  if (!Object.hasOwn(args, "verdict")) throw new Error("verdict is required");
120
126
  return runtime.submitReview(taskId, args.verdict);
121
127
  }
128
+ if (name === "resolve_finding") {
129
+ // Structural only. Which findings may be resolved, and when, stays
130
+ // the reducer's decision; duplicating it here would create a second
131
+ // authority that could drift from the Kernel.
132
+ if (typeof args.finding_id !== "string" || !args.finding_id) throw new Error("finding_id is required");
133
+ return runtime.resolveFinding(taskId, args.finding_id);
134
+ }
122
135
  if (name === "request_authorization" || name === "approve_breaking_intent_revision" || name === "stop" || name === "repair_authority_state") {
123
136
  return runtime.authorize(taskId, name, toolMeta, args);
124
137
  }
@@ -271,16 +271,6 @@ interface PendingReview {
271
271
  error?: string;
272
272
  }
273
273
 
274
- function matchesReservation(event: ClaudeHookEvent, pending: PendingReview): boolean {
275
- if ("operationId" in event && event.operationId && event.operationId !== pending.request.operationId) return false;
276
- if ("taskId" in event && event.taskId && event.taskId !== pending.request.taskId) return false;
277
- if (pending.sessionId && event.sessionId !== pending.sessionId) return false;
278
- const eventAgentId = "agentId" in event ? event.agentId : "";
279
- if (event.type === "SubagentStop" && (!eventAgentId || !pending.agentId || eventAgentId !== pending.agentId)) return false;
280
- if (pending.agentId && eventAgentId && eventAgentId !== pending.agentId) return false;
281
- return true;
282
- }
283
-
284
274
  function bindsStart(event: Extract<ClaudeHookEvent, { type: "SubagentStart" }>, pending: PendingReview): boolean {
285
275
  if (event.taskId && event.taskId !== pending.request.taskId) return false;
286
276
  if (event.prompt !== undefined) {
@@ -248,19 +248,6 @@ function runInspect(root: string): KernelExecution {
248
248
  ),
249
249
  };
250
250
  }
251
- let declared: TaskRisk;
252
- let intent: TaskIntentV1;
253
- try {
254
- const raw = JSON.parse(
255
- readSecureProjectFile(root, `docs/plans/${claim.task_id}.intent.json`),
256
- ) as { risk?: unknown };
257
- if (raw.risk !== "routine" && raw.risk !== "material" && raw.risk !== "critical")
258
- throw new Error(`intent.risk is unreadable for ${claim.task_id}`);
259
- declared = raw.risk;
260
- intent = parseTaskIntentV1(raw);
261
- } catch (error) {
262
- return sourceFailure("inspect", error);
263
- }
264
251
  let recordRead: ReturnType<typeof readTaskRecordRaw>;
265
252
  try {
266
253
  recordRead = readTaskRecordRaw(root, claim.task_id);
@@ -285,6 +272,21 @@ function runInspect(root: string): KernelExecution {
285
272
  };
286
273
  }
287
274
  const record = recordRead.record;
275
+ let declared: TaskRisk;
276
+ let intent: TaskIntentV1;
277
+ try {
278
+ const raw = JSON.parse(
279
+ readSecureProjectFile(root, record.intent_ref.path),
280
+ ) as { risk?: unknown };
281
+ if (raw.risk !== "routine" && raw.risk !== "material" && raw.risk !== "critical")
282
+ throw new Error(`intent.risk is unreadable for ${claim.task_id}`);
283
+ declared = raw.risk;
284
+ intent = parseTaskIntentV1(raw);
285
+ if (canonicalIntentHash(intent) !== record.intent_ref.content_hash)
286
+ throw new Error("TaskIntent sidecar does not match TaskRecord content hash");
287
+ } catch (error) {
288
+ return sourceFailure("inspect", error);
289
+ }
288
290
  const workspaceState = readWorkspaceStateRaw(root);
289
291
  let identity;
290
292
  try {