immune-brain 4.5.0 → 5.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 (79) hide show
  1. package/README.md +157 -39
  2. package/README.zh-CN.md +160 -40
  3. package/package.json +1 -1
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +42 -45
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +189 -476
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +77 -102
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +7 -2
  9. package/plugins/immune-brain/.pi-extension/tsconfig.json +2 -2
  10. package/plugins/immune-brain/agents/immune-brain-advisory-reviewer.md +23 -0
  11. package/plugins/immune-brain/agents/immune-brain-qa.md +42 -0
  12. package/plugins/immune-brain/agents/immune-brain-reviewer.md +90 -1
  13. package/plugins/immune-brain/agents/immune-brain-ui-review.md +21 -0
  14. package/plugins/immune-brain/dist/BASELINE.md +4 -4
  15. package/plugins/immune-brain/dist/claude/mcp-server.mjs +9924 -9654
  16. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +3 -3
  17. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -1
  18. package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
  19. package/plugins/immune-brain/dist/imm-doc-prune.md +2 -2
  20. package/plugins/immune-brain/dist/{imm-agent-doc-maintain.md → imm-doc-slim.md} +9 -9
  21. package/plugins/immune-brain/dist/imm-planner.md +60 -20
  22. package/plugins/immune-brain/dist/imm-pr-fix.md +1 -1
  23. package/plugins/immune-brain/dist/{imm-review-retro.md → imm-retro.md} +26 -13
  24. package/plugins/immune-brain/dist/{imm-loop.md → imm-run.md} +44 -11
  25. package/plugins/immune-brain/dist/registry.yaml +9 -9
  26. package/plugins/immune-brain/dist/role-prompts/code-review.md +7 -0
  27. package/plugins/immune-brain/dist/role-prompts/executor.md +35 -2
  28. package/plugins/immune-brain/runtime/assurance/coordinator.ts +138 -69
  29. package/plugins/immune-brain/runtime/assurance/host_port.ts +20 -0
  30. package/plugins/immune-brain/runtime/assurance/qa.ts +2 -2
  31. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
  32. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +347 -0
  33. package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
  34. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +135 -396
  35. package/plugins/immune-brain/runtime/claude/mcp_server.ts +5 -4
  36. package/plugins/immune-brain/runtime/claude/review_host.ts +63 -16
  37. package/plugins/immune-brain/runtime/claude/role_agents.ts +83 -0
  38. package/plugins/immune-brain/runtime/commands/kernel.ts +3 -6
  39. package/plugins/immune-brain/runtime/github_issue_tracker.ts +738 -677
  40. package/plugins/immune-brain/runtime/kernel/application.ts +7 -7
  41. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +15 -8
  42. package/plugins/immune-brain/runtime/kernel/authority_port.ts +5 -14
  43. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +30 -49
  44. package/plugins/immune-brain/runtime/kernel/canary_application.ts +6 -5
  45. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +0 -1
  46. package/plugins/immune-brain/runtime/kernel/capability_registry.ts +7 -9
  47. package/plugins/immune-brain/runtime/kernel/enrollment.ts +170 -17
  48. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
  49. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  50. package/plugins/immune-brain/runtime/kernel/storage.ts +170 -11
  51. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +13 -0
  52. package/plugins/immune-brain/runtime/kernel/types.ts +2 -1
  53. package/plugins/immune-brain/runtime/kernel/validation.ts +3 -1
  54. package/plugins/immune-brain/runtime/loop_contract.ts +22 -22
  55. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  56. package/plugins/immune-brain/runtime/prompts/code-review.md +7 -0
  57. package/plugins/immune-brain/runtime/prompts/executor.md +35 -2
  58. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +27 -0
  59. package/plugins/immune-brain/runtime/unattended/batch_git.ts +66 -28
  60. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +106 -0
  61. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
  62. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +132 -45
  63. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
  64. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +40 -36
  65. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +98 -209
  66. package/plugins/immune-brain/runtime/unattended/batch_state.ts +36 -14
  67. package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
  68. package/plugins/immune-brain/runtime/workspace_scope.ts +78 -8
  69. package/plugins/immune-brain/skills/BASELINE.md +4 -4
  70. package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +1 -1
  71. package/plugins/immune-brain/skills/{imm-agent-doc-maintain → imm-doc-slim}/SKILL.md +9 -9
  72. package/plugins/immune-brain/skills/imm-planner/SKILL.md +4 -2
  73. package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +1 -1
  74. package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/SKILL.md +7 -7
  75. package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/scripts/review_retro.ts +46 -21
  76. package/plugins/immune-brain/skills/{imm-loop → imm-run}/SKILL.md +7 -7
  77. package/plugins/immune-brain/skills/registry.yaml +9 -9
  78. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -584
  79. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +0 -33
@@ -12,8 +12,9 @@ scoped implementation, staging and focused diagnostics. Enrollment itself does
12
12
  not start QA; the Parent owns the subsequent Kernel Assurance and foreground
13
13
  Review handoff under the same still-valid batch authorization.
14
14
 
15
- Before handoff, run the permitted diagnostic checks and return commands and
16
- outcomes to the Parent as structured diagnostic evidence. The read-only Loop
15
+ Before handoff, run the permitted diagnostic checks, complete the Delivery
16
+ Evidence check below and return its table to the Parent as structured diagnostic
17
+ evidence. The read-only Loop
17
18
  runtime action only constructs the dispatch envelope; it does not store
18
19
  evidence or change task state. Preserve failed and blocked attempts. Do not
19
20
  perform QA,
@@ -21,6 +22,38 @@ review, plan mutation, successor approval, Compounder work, or authority
21
22
  writes. If the requested change needs scope expansion, stop and return an
22
23
  `imm-planner` route with the concrete missing scope and verification reason.
23
24
 
25
+ ## Delivery Evidence
26
+
27
+ Reuse the TaskIntent and any bound Spec's agreed seams and controls. Before the
28
+ first Assurance attempt, return one compact row per acceptance; update affected
29
+ rows after repair. This is handoff evidence, not a new artifact or approval gate.
30
+
31
+ | Acceptance / invariant | Entry and consumers | Positive / negative / boundary controls | Command, outcome and input | Finding coverage |
32
+ |---|---|---|---|---|
33
+
34
+ For changed shared behavior, trace the real entry through its state owner,
35
+ write, serialization, reload and downstream consumer as applicable. Include
36
+ both affected hosts and recovery/replay branches. An async, return-type or
37
+ exception-contract change requires checking every affected caller's await,
38
+ cancellation and error propagation.
39
+
40
+ Use the real producer for the success control where available. Derive rejection
41
+ controls from a successful state, change only the target condition, and assert
42
+ the intended rejection reason and required side effects. A control must fail
43
+ when its target protection is removed. For byte-preservation claims compare the
44
+ relevant raw files, index and refs; for observed behavior link each required
45
+ criterion to its concrete observation.
46
+
47
+ Name whether each check consumed worktree or delivery bytes. Before Assurance,
48
+ verify task-owned changes are staged within scope, generated mirrors match,
49
+ and applicable type/build checks pass. On QA failure, reproduce the reported
50
+ failure under its delivery conditions before another attempt. For rework, name
51
+ the violated invariant, all known trigger classes and their controls in Finding
52
+ coverage; account for affected callers sharing that invariant within scope.
53
+ Record missing evidence as blocked and repair it before handoff. The Loop owns
54
+ verdict submission, finding disposition and the second-rework generalize-or-refute
55
+ rule.
56
+
24
57
  ## Code Quality Guard
25
58
 
26
59
  Before handoff, check the implementation for real implementation rather than
@@ -15,8 +15,10 @@ import {
15
15
  VerificationAbortedError,
16
16
  } from "./verification";
17
17
  import { createInvocationRegistry, type InvocationState, type InvocationToken } from "./invocations";
18
- import type { ReviewBundle, ReviewManifestV5, ReviewRevision } from "./review_evidence";
19
- import { buildRoleDelegationPacket } from "../role_prompt_bridge";
18
+ import type { ReviewBundle, ReviewManifestV5 } from "./review_evidence";
19
+ import { createVerdictAuthority, type VerdictAuthority } from "./verdict_authority";
20
+ import type { ConfirmationReferenceSource } from "./host_port";
21
+ import { buildRoleDelegationPacket, STATIC_REVIEW_RULES } from "../role_prompt_bridge";
20
22
  import type { AssuranceProjectionResult } from "../kernel/assurance_projection";
21
23
  import type { TaskIntentIdentityToken } from "../kernel/intent_token_registry";
22
24
  import type { AssuranceHostPort, HostReviewReservation } from "./host_port";
@@ -316,7 +318,10 @@ export interface ForegroundToolUpdate {
316
318
  details: Record<string, unknown>;
317
319
  }
318
320
 
321
+ /** Wall-clock milliseconds spent per advance stage; the stage names are the progress stages. */
322
+ export interface AssuranceTimings { total_ms: number; stage_ms: Record<string, number> }
319
323
  type AssuranceRecoveryFields = {
324
+ timings?: AssuranceTimings;
320
325
  diagnostics?: QaCheckDiagnostic[];
321
326
  environment_failure?: boolean;
322
327
  recovery?: AssuranceRecovery;
@@ -343,7 +348,7 @@ export type AssuranceSubmitReviewResult = AssuranceRecoveryFields & (
343
348
  | { state: "review_preparation_failed"; operation: "review"; operation_id: string; reason: string }
344
349
  | { state: "completed" }
345
350
  | { state: "settlement_unknown"; operation: "qa" | "review"; operation_id: string; reason: string }
346
- | { state: "blocked"; reason: string; code?: "verdict_invalid" });
351
+ | { state: "blocked"; reason: string; code?: "verdict_invalid"; recovery_action?: string });
347
352
 
348
353
  export type ActiveAssuranceState =
349
354
  | { state: "running"; operation: "qa"; operation_id: string; deadline_seconds: number }
@@ -352,51 +357,17 @@ export type ActiveAssuranceState =
352
357
 
353
358
  export interface AssuranceCoordinatorPorts {
354
359
  host: AssuranceHostPort;
360
+ confirmationReference: ConfirmationReferenceSource;
361
+ onReworkApplied?: (ctx: HostContext, taskId: string, findingsCount: number) => void;
355
362
  projectTask(root: string, taskId: string): Promise<AssuranceProjectionResult>;
356
363
  readTaskRecord(root: string, taskId: string): Promise<TaskRecordRead>;
357
364
  readTaskIntent(root: string, taskId: string): Promise<TaskIntentRead>;
358
- buildAssurance(
359
- root: string,
360
- taskId: string,
361
- role: AssuranceRole,
362
- projection: AssuranceProjectionResult,
363
- ): Promise<{
364
- snapshot: SnapshotDescriptor;
365
- descriptors: Map<string, VerificationDescriptor>;
366
- reviewBundle: ReviewBundle | null;
367
- reviewManifest?: ReviewManifestV5 | null;
368
- }>;
369
- /**
370
- * Publish and prove the deterministic task-scoped revision before QA runs, so
371
- * a preparation failure can never consume or fake a settled QA attestation.
372
- * Returns null for records still on the legacy v3 bundle path.
373
- */
374
- ensureReviewRevision?(
375
- root: string,
376
- taskId: string,
377
- projection: AssuranceProjectionResult,
378
- ): Promise<ReviewRevision | null>;
379
365
  runQa(
380
366
  snapshot: SnapshotDescriptor,
381
367
  descriptors: Map<string, VerificationDescriptor>,
382
368
  options: { signal?: AbortSignal; onProgress?: (progress: QaVerificationProgress) => void },
383
369
  ): Promise<AssuranceVerdict>;
384
370
  writeReviewEvidence(input: { snapshot: SnapshotDescriptor; evidence: ReviewBundle | ReviewManifestV5 }): { path: string; remove(): void };
385
- applyVerdict(
386
- ctx: HostContext,
387
- input: {
388
- taskId: string;
389
- snapshot: SnapshotDescriptor;
390
- verdict: AssuranceVerdict;
391
- invocation: InvocationToken;
392
- actorId: string;
393
- hooks?: {
394
- beforeCommit?: () => Promise<void>;
395
- onCommit?: () => void;
396
- afterCommit?: () => Promise<void>;
397
- };
398
- },
399
- ): Promise<void>;
400
371
  applyOrdinaryOperation(ctx: HostContext, input: { taskId: string; operation: { op: string; actor_id: string } }): Promise<unknown>;
401
372
  advanceBeforeProjection?: () => Promise<void>;
402
373
  qaBeforeProjection?: () => Promise<void>;
@@ -404,12 +375,23 @@ export interface AssuranceCoordinatorPorts {
404
375
  qaOnAuthorityCommit?: () => void;
405
376
  qaAfterAuthorityCommit?: () => Promise<void>;
406
377
  qaJobTimeoutMs?: number;
378
+ /** Attach per-stage `timings` to advance results. Defaults to IMM_ASSURANCE_TIMINGS=1. */
379
+ reportTimings?: boolean;
380
+ /** Overrides QA_MAX_IDENTICAL_FAILURES; tests only. */
381
+ qaMaxIdenticalFailures?: number;
407
382
  }
408
383
 
409
384
  export const QA_MIN_JOB_TIMEOUT_SECONDS = 15 * 60;
410
385
  export const QA_MAX_JOB_TIMEOUT_SECONDS = 60 * 60;
411
386
  export const QA_JOB_OVERHEAD_SECONDS = 2 * 60;
412
387
  export const QA_JOB_TIMEOUT_SECONDS = QA_MIN_JOB_TIMEOUT_SECONDS;
388
+ /**
389
+ * A QA run that fails before attestation writes no authority, so the Kernel
390
+ * would happily rerun the same descriptors forever. After this many identical
391
+ * failures on the same snapshot (record revision, intent hash, diff hash) the
392
+ * coordinator refuses to rerun until the snapshot changes.
393
+ */
394
+ export const QA_MAX_IDENTICAL_FAILURES = 2;
413
395
  export const REVIEW_PREPARATION_TIMEOUT_MS = 30_000;
414
396
  export const REVIEW_DISPATCH_TIMEOUT_MS = 120_000;
415
397
  export const REVIEW_VERDICT_VALIDATION_TIMEOUT_MS = 30_000;
@@ -464,19 +446,19 @@ export function snapshotDigest(snapshot: SnapshotDescriptor): string {
464
446
  return `sha256:${createHash("sha256").update(JSON.stringify(snapshot)).digest("hex")}`;
465
447
  }
466
448
 
467
- export function buildReviewPrompt(snapshot: SnapshotDescriptor, evidencePath?: string): string {
449
+ /**
450
+ * The per-dispatch Review facts: which evidence to read, which revision it pins,
451
+ * what the acceptances assert, and the exact verdict shapes.
452
+ *
453
+ * This is the whole prompt for a Host whose reviewer definition already carries
454
+ * the role contract and `STATIC_REVIEW_RULES` — the Claude Host. A Host whose
455
+ * dispatched agent carries no instructions (Pi) gets `buildReviewPrompt`, which
456
+ * prepends both.
457
+ */
458
+ export function buildReviewSnapshotPrompt(snapshot: SnapshotDescriptor, evidencePath?: string): string {
468
459
  if (snapshot.role !== "review") throw new Error("native review prompt requires review role");
469
460
  const acceptance = snapshot.acceptance.map((item) => `- ${item.id}: ${item.assertion}`).join("\n");
470
461
  const digest = snapshotDigest(snapshot);
471
- const rolePacket = buildRoleDelegationPacket({
472
- role: "code-review",
473
- context: {
474
- task_id: snapshot.task_id,
475
- review_gate: "imm-code-review",
476
- changed_files_signature: snapshot.diff_hash,
477
- snapshot_digest: digest,
478
- },
479
- });
480
462
  const revision = snapshot.review_revision;
481
463
  const evidenceContract = revision
482
464
  ? [
@@ -492,25 +474,44 @@ export function buildReviewPrompt(snapshot: SnapshotDescriptor, evidencePath?: s
492
474
  `The user-selected worktree may contain staged task changes that are absent from the isolated reviewer worktree. Review authority is bound only to the bundle dirty_files current_content bytes and committed HEAD provenance. Analyze code exclusively from those bundle bytes; repository file reads are permitted only for the provenance git commands above. A symbol present in current_content but absent from HEAD is the task change, not an absence.`,
493
475
  ];
494
476
  return [
495
- rolePacket.prompt,
496
477
  ...evidenceContract,
497
- `Do not edit files, create files, run mutating commands, or change Git state. Focus on correctness, regressions, security, and missing tests.`,
498
- `Execution outcomes for every acceptance were verified deterministically by the Kernel QA layer before this review and are embedded in this bundle under outcomes (the immutable evidence file, acceptance_id -> {status, summary}); do not re-execute descriptors and do not treat the absence of local test runs as a finding. Your review covers evidence provenance, code correctness, regressions, security, and missing tests against the embedded assertions and code.`,
499
478
  `Snapshot digest: ${digest}`,
500
479
  `TaskRecord revision: ${snapshot.record_revision}`,
501
480
  revision
502
481
  ? `Intent revision ${snapshot.intent_revision} (hash ${snapshot.intent_content_hash}), diff ${snapshot.diff_hash}, review revision ${revision.review_commit} (base ${revision.base_head}, tree ${revision.review_tree}, manifest ${revision.manifest_digest}), state ${snapshot.lifecycle}:${snapshot.artifact_state}.`
503
482
  : `Intent revision ${snapshot.intent_revision} (hash ${snapshot.intent_content_hash}), diff ${snapshot.diff_hash}, review bundle ${snapshot.review_bundle_digest}, state ${snapshot.lifecycle}:${snapshot.artifact_state}.`,
504
483
  "Acceptance assertions:", acceptance,
505
- "Reserve the final turn for exactly one strict JSON verdict. Reply with ONLY that object, without markdown fences or commentary.",
506
- `Every rework finding must carry machine-checkable provenance: evidence.trigger (the concrete inputs or state that reach the defect), a non-empty evidence.caller_chain (ordered repository paths or symbols), and evidence.violated {kind: "acceptance"|"security_boundary", ref}. The anchor is derived from that evidence; a finding without it is rejected and the correction must be resubmitted.`,
507
- `PASS shape: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"pass","approval":{"kind":"review","authority_role":"reviewer","summary":"<one line>"}}`,
508
- `A pass verdict may carry non-blocking notes as findings, but every one of them must set kind "advisory"; a blocking finding is a rework verdict and must omit approval: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"pass","approval":{"kind":"review","authority_role":"reviewer","summary":"<one line>"},"findings":[{"id":"review-1","kind":"advisory","acceptance_id":"<id|null>","summary":"<one line>","evidence":{"trigger":"<concrete inputs or state>","caller_chain":["<path-or-symbol>"],"violated":{"kind":"acceptance|security_boundary","ref":"<acceptance id or boundary>"}}}]}`,
484
+ `PASS shape: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"pass","approval":{"kind":"review","authority_role":"reviewer","summary":"<one line>","inspected_paths":["<every changed path of the reviewed change set>"]}}`,
485
+ `A pass verdict may carry non-blocking notes as findings, but every one of them must set kind "advisory"; a blocking finding is a rework verdict and must omit approval: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"pass","approval":{"kind":"review","authority_role":"reviewer","summary":"<one line>","inspected_paths":["<every changed path of the reviewed change set>"]},"findings":[{"id":"review-1","kind":"advisory","acceptance_id":"<id|null>","summary":"<one line>","evidence":{"trigger":"<concrete inputs or state>","caller_chain":["<path-or-symbol>"],"violated":{"kind":"acceptance|security_boundary","ref":"<acceptance id or boundary>"}}}]}`,
509
486
  `REWORK shape: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"rework","findings":[{"id":"review-1","kind":"blocking|advisory","acceptance_id":"<id|null>","summary":"<one line>","evidence":{"trigger":"<concrete inputs or state>","caller_chain":["<path-or-symbol>"],"violated":{"kind":"acceptance|security_boundary","ref":"<acceptance id or boundary>"}}}]}`,
510
487
  `REWORK verdicts must omit the approval field entirely; do not emit "approval": null.`,
511
488
  ].join("\n");
512
489
  }
513
490
 
491
+ /**
492
+ * The complete Review prompt: the internal role contract, the static review
493
+ * rules, and the per-dispatch facts. Dispatched by a Host whose agent carries no
494
+ * instructions of its own.
495
+ */
496
+ export function buildReviewPrompt(snapshot: SnapshotDescriptor, evidencePath?: string): string {
497
+ if (snapshot.role !== "review") throw new Error("native review prompt requires review role");
498
+ const digest = snapshotDigest(snapshot);
499
+ const rolePacket = buildRoleDelegationPacket({
500
+ role: "code-review",
501
+ context: {
502
+ task_id: snapshot.task_id,
503
+ review_gate: "imm-code-review",
504
+ changed_files_signature: snapshot.diff_hash,
505
+ snapshot_digest: digest,
506
+ },
507
+ });
508
+ return [
509
+ rolePacket.prompt,
510
+ ...STATIC_REVIEW_RULES,
511
+ buildReviewSnapshotPrompt(snapshot, evidencePath),
512
+ ].join("\n");
513
+ }
514
+
514
515
  function parseVerdictEvidence(value: unknown, index: number): FindingEvidence {
515
516
  if (!value || typeof value !== "object" || Array.isArray(value))
516
517
  throw new Error(`finding ${index} evidence is required`);
@@ -579,7 +580,28 @@ export function parseAssuranceVerdict(input: unknown, snapshot: SnapshotDescript
579
580
  const expectedKind = snapshot.role === "qa" ? "qa" : "review";
580
581
  const expectedRole = snapshot.role === "qa" ? "qa" : "reviewer";
581
582
  if (!approval || approval.kind !== expectedKind || approval.authority_role !== expectedRole || typeof approval.summary !== "string" || !approval.summary.trim()) throw new Error("pass verdict approval is invalid");
582
- const unknownApproval = Object.keys(approval).find((key) => !["kind", "authority_role", "summary"].includes(key));
583
+ // A Review pass must claim the paths it inspected: BR-DEC-3 converts silent
584
+ // partial coverage into an explicit false statement. The list is checked
585
+ // here against the reviewed change set and removed before settlement, so
586
+ // the Kernel approval and TaskRecord never see it. QA verdicts treat the
587
+ // field as unknown, exactly as before.
588
+ if (snapshot.role === "review") {
589
+ const required = snapshot.dirty_files;
590
+ const listed = approval.inspected_paths;
591
+ if (listed === undefined) throw new Error(`review pass verdict approval.inspected_paths is required; it must list every reviewed changed path${required.length ? `: ${required.join(", ")}` : " (none for this change set)"}`);
592
+ if (!Array.isArray(listed) || listed.some((entry) => typeof entry !== "string" || !entry.trim())) throw new Error("review pass verdict approval.inspected_paths must be an array of repository-relative path strings");
593
+ const seen = new Set<string>();
594
+ for (const entry of listed as string[]) {
595
+ if (seen.has(entry)) throw new Error(`review pass verdict approval.inspected_paths lists a duplicate path: ${entry}`);
596
+ seen.add(entry);
597
+ }
598
+ const foreign = (listed as string[]).filter((entry) => !required.includes(entry));
599
+ if (foreign.length) throw new Error(`review pass verdict approval.inspected_paths lists a path outside the reviewed change set: ${foreign.join(", ")}`);
600
+ const missing = required.filter((entry) => !seen.has(entry));
601
+ if (missing.length) throw new Error(`review pass verdict approval.inspected_paths omits reviewed changed paths: ${missing.join(", ")}`);
602
+ }
603
+ const allowedApproval = snapshot.role === "review" ? ["kind", "authority_role", "summary", "inspected_paths"] : ["kind", "authority_role", "summary"];
604
+ const unknownApproval = Object.keys(approval).find((key) => !allowedApproval.includes(key));
583
605
  if (unknownApproval) throw new Error(`pass verdict approval has unknown field: ${unknownApproval}`);
584
606
  const passFindings = raw.findings === undefined ? [] : parseVerdictFindings(raw.findings as unknown[], snapshot);
585
607
  if (passFindings.some((finding) => finding.kind !== "advisory")) throw new Error("pass verdict findings must all be advisory");
@@ -705,12 +727,21 @@ export class AssuranceCoordinator {
705
727
  private readonly operationControllers = new Map<string, { operationId: string; controller: AbortController }>();
706
728
  private readonly reviewReservations = new Map<string, ReviewReservation>();
707
729
  private readonly rejectedReviewOperations = new Map<string, { operationId: string; reason: string }>();
730
+ private readonly qaFailureStreaks = new Map<string, { key: string; count: number }>();
731
+ private readonly qaAttemptKeys = new Map<string, string>();
708
732
  private readonly unknownOperations = new Map<string, { operation: "qa" | "review"; operationId: string; reason: string }>();
709
733
  private readonly sessionInvocations = new Set<InvocationToken>();
710
734
  private sessionActive = true;
711
735
  private sessionGeneration = 0;
712
736
 
713
- constructor(private readonly ports: AssuranceCoordinatorPorts) {}
737
+ readonly authority: VerdictAuthority;
738
+ constructor(private readonly ports: AssuranceCoordinatorPorts, authorityOverrides: Partial<VerdictAuthority> = {}) {
739
+ this.authority = { ...createVerdictAuthority({
740
+ confirmationReference: ports.confirmationReference,
741
+ commitInvocation: token => this.commitInvocation(token),
742
+ onReworkApplied: ports.onReworkApplied,
743
+ }), ...authorityOverrides };
744
+ }
714
745
 
715
746
  onSessionStart(): void {
716
747
  this.sessionActive = true;
@@ -731,6 +762,8 @@ export class AssuranceCoordinator {
731
762
  this.removeEvidence(reservation);
732
763
  }
733
764
  this.reviewReservations.clear();
765
+ this.qaFailureStreaks.clear();
766
+ this.qaAttemptKeys.clear();
734
767
  for (const invocation of [...this.sessionInvocations]) this.closeSessionInvocation(invocation);
735
768
  }
736
769
 
@@ -762,7 +795,19 @@ export class AssuranceCoordinator {
762
795
 
763
796
  async advance(taskId: string, ctx: HostContext, signal?: AbortSignal, onUpdate?: (update: ForegroundToolUpdate) => void): Promise<AssuranceAdvanceResult> {
764
797
  const checks = new Map<string, QaCheckDiagnostic>();
798
+ const started = performance.now();
799
+ const stageMs: Record<string, number> = {};
800
+ let stage = "preparing";
801
+ let stageStarted = started;
802
+ const closeStage = (now: number) => { stageMs[stage] = (stageMs[stage] ?? 0) + Math.round(now - stageStarted); };
765
803
  const result = await this.advanceOnce(taskId, ctx, signal, update => {
804
+ const next = update.details.stage;
805
+ if (typeof next === "string" && next !== stage) {
806
+ const now = performance.now();
807
+ closeStage(now);
808
+ stage = next;
809
+ stageStarted = now;
810
+ }
766
811
  const diagnostic = update.details.diagnostic as QaCheckDiagnostic | undefined;
767
812
  if (diagnostic) {
768
813
  checks.set(diagnostic.descriptor_ref, diagnostic);
@@ -772,8 +817,25 @@ export class AssuranceCoordinator {
772
817
  }
773
818
  onUpdate?.(update);
774
819
  });
820
+ this.recordQaAttempt(taskId, result);
775
821
  const diagnostics = result.diagnostics ?? [...checks.values()];
776
- return this.withRecovery(taskId, ctx, diagnostics.length ? { ...result, diagnostics } : result);
822
+ const enriched = diagnostics.length ? { ...result, diagnostics } : result;
823
+ if (!(this.ports.reportTimings ?? process.env.IMM_ASSURANCE_TIMINGS === "1")) return this.withRecovery(taskId, ctx, enriched);
824
+ const finished = performance.now();
825
+ closeStage(finished);
826
+ return this.withRecovery(taskId, ctx, { ...enriched, timings: { total_ms: Math.round(finished - started), stage_ms: stageMs } });
827
+ }
828
+
829
+ private recordQaAttempt(taskId: string, result: AssuranceAdvanceResult): void {
830
+ const key = this.qaAttemptKeys.get(taskId);
831
+ this.qaAttemptKeys.delete(taskId);
832
+ if (!key || result.state === "cancelled") return;
833
+ if (result.state !== "failed" || result.operation !== "qa") {
834
+ this.qaFailureStreaks.delete(taskId);
835
+ return;
836
+ }
837
+ const streak = this.qaFailureStreaks.get(taskId);
838
+ this.qaFailureStreaks.set(taskId, { key, count: streak?.key === key ? streak.count + 1 : 1 });
777
839
  }
778
840
 
779
841
  private async withRecovery<T extends AssuranceAdvanceResult | AssuranceSubmitReviewResult>(taskId: string, ctx: HostContext, enriched: T): Promise<T> {
@@ -926,9 +988,9 @@ export class AssuranceCoordinator {
926
988
  progress("preparing_review_revision", "Proving the immutable Review revision before QA");
927
989
  try {
928
990
  if (parked.record?.contract === "assurance_kernel/task_record/v4") {
929
- if (!this.ports.ensureReviewRevision)
991
+ if (!this.authority.ensureReviewRevision)
930
992
  throw new Error("v4 Review revision preparation is unavailable");
931
- await this.ports.ensureReviewRevision(ctx.cwd, taskId, projection);
993
+ await this.authority.ensureReviewRevision(ctx.cwd, taskId, projection);
932
994
  }
933
995
  } catch (error) {
934
996
  return { state: "blocked", reason: `review revision preparation failed: ${boundedAssuranceError(error)}` };
@@ -942,12 +1004,18 @@ export class AssuranceCoordinator {
942
1004
  progress("capturing_snapshot", "Capturing the immutable QA snapshot");
943
1005
  await this.ports.qaBeforeProjection?.();
944
1006
  ensureOperationLive();
945
- const assurance = await this.ports.buildAssurance(ctx.cwd, taskId, "qa", projection);
1007
+ const assurance = await this.authority.buildAssurance(ctx.cwd, taskId, "qa", projection);
946
1008
  ensureOperationLive();
947
1009
  // Preparation counts against the same ceiling as the checks, so the
948
1010
  // aggregate clock starts before the first prepared group runs. A host
949
1011
  // may only tighten the derived budget, never raise it past the ceiling.
950
- const declaredQaJobMs = deriveQaJobTimeoutMs(assurance.descriptors.values());
1012
+ const qaKey = `${assurance.snapshot.record_revision}|${assurance.snapshot.intent_content_hash}|${assurance.snapshot.diff_hash}`;
1013
+ const streak = this.qaFailureStreaks.get(taskId);
1014
+ const maxFailures = this.ports.qaMaxIdenticalFailures ?? QA_MAX_IDENTICAL_FAILURES;
1015
+ if (streak?.key === qaKey && streak.count >= maxFailures)
1016
+ return { state: "blocked", reason: `deterministic QA already failed ${streak.count} times on this exact snapshot; change the workspace diff or intent, or restart the session after fixing the environment, before retrying` };
1017
+ this.qaAttemptKeys.set(taskId, qaKey);
1018
+ const declaredQaJobMs = deriveQaJobTimeoutMs(assurance.descriptors.values());
951
1019
  const qaJobTimeoutMs = Math.min(declaredQaJobMs, this.ports.qaJobTimeoutMs ?? declaredQaJobMs);
952
1020
  const qaJobDeadline = setTimeout(() => {
953
1021
  qaJobBudgetExceeded = true;
@@ -974,7 +1042,7 @@ export class AssuranceCoordinator {
974
1042
  try {
975
1043
  progress("settling_qa", "Settling deterministic QA through the Kernel revision boundary");
976
1044
  authorityBoundaryStarted = true;
977
- await this.ports.applyVerdict(ctx, {
1045
+ await this.authority.applyVerdict(ctx, {
978
1046
  taskId,
979
1047
  snapshot: assurance.snapshot,
980
1048
  verdict: qaVerdict,
@@ -1037,10 +1105,10 @@ export class AssuranceCoordinator {
1037
1105
  authorityBoundaryStarted = false;
1038
1106
  if (aborted()) return this.reviewPreparationFailed(taskId, operationId, "host cancellation after QA authority settlement");
1039
1107
  progress("preparing_review", "Preparing the reserved foreground Review evidence");
1040
- let review: Awaited<ReturnType<AssuranceCoordinatorPorts["buildAssurance"]>>;
1108
+ let review: Awaited<ReturnType<VerdictAuthority["buildAssurance"]>>;
1041
1109
  let evidence: { path: string; remove(): void };
1042
1110
  try {
1043
- review = await this.ports.buildAssurance(ctx.cwd, taskId, "review", fresh);
1111
+ review = await this.authority.buildAssurance(ctx.cwd, taskId, "review", fresh);
1044
1112
  const payload = review.reviewManifest ?? review.reviewBundle;
1045
1113
  if (!payload) throw new Error("review evidence is missing after QA settlement");
1046
1114
  evidence = this.ports.writeReviewEvidence({ snapshot: review.snapshot, evidence: payload });
@@ -1063,6 +1131,7 @@ export class AssuranceCoordinator {
1063
1131
  taskId,
1064
1132
  operationId,
1065
1133
  prompt: buildReviewPrompt(review.snapshot, evidence.path),
1134
+ snapshotPrompt: buildReviewSnapshotPrompt(review.snapshot, evidence.path),
1066
1135
  evidencePath: evidence.path,
1067
1136
  maxTurns: reviewTurnBudget(workload),
1068
1137
  });
@@ -1149,9 +1218,9 @@ export class AssuranceCoordinator {
1149
1218
  }
1150
1219
  if (reservation.snapshot.review_revision) {
1151
1220
  try {
1152
- if (!this.ports.ensureReviewRevision)
1221
+ if (!this.authority.ensureReviewRevision)
1153
1222
  throw new Error("v4 Review revision verification is unavailable");
1154
- const revision = await this.ports.ensureReviewRevision(ctx.cwd, taskId, fresh);
1223
+ const revision = await this.authority.ensureReviewRevision(ctx.cwd, taskId, fresh);
1155
1224
  if (!revision || revision.base_head !== reservation.snapshot.review_revision.base_head || revision.review_commit !== reservation.snapshot.review_revision.review_commit || revision.review_tree !== reservation.snapshot.review_revision.review_tree || revision.manifest_digest !== reservation.snapshot.review_revision.manifest_digest)
1156
1225
  throw new Error("Review revision changed before submission");
1157
1226
  } catch (error) {
@@ -1167,7 +1236,7 @@ export class AssuranceCoordinator {
1167
1236
  const invocation = this.openInvocation(taskId);
1168
1237
  this.releaseReviewReservation(taskId, reservation);
1169
1238
  try {
1170
- await this.ports.applyVerdict(ctx, {
1239
+ await this.authority.applyVerdict(ctx, {
1171
1240
  taskId,
1172
1241
  snapshot: reservation.snapshot,
1173
1242
  verdict,
@@ -1,7 +1,27 @@
1
+ import type { SnapshotDescriptor } from "./coordinator";
2
+
3
+ export type ConfirmationReferenceSource = (input: {
4
+ snapshot: SnapshotDescriptor;
5
+ actorId: string;
6
+ now: string;
7
+ }) => string;
8
+
1
9
  export interface ReviewRequest {
2
10
  taskId: string;
3
11
  operationId: string;
12
+ /**
13
+ * The complete Review prompt: internal role contract, static review rules and
14
+ * the per-dispatch facts. Dispatched by a Host whose reviewer agent carries no
15
+ * instructions of its own.
16
+ */
4
17
  prompt: string;
18
+ /**
19
+ * The per-dispatch facts alone, a strict suffix of `prompt`. A Host whose
20
+ * reviewer definition already carries the role contract and the static rules
21
+ * dispatches only this, so the static instructions stay generated from one
22
+ * source instead of being sent twice.
23
+ */
24
+ snapshotPrompt: string;
5
25
  evidencePath: string;
6
26
  maxTurns: number;
7
27
  }
@@ -8,14 +8,14 @@ import { tmpdir } from "node:os";
8
8
  import { join, sep } from "node:path";
9
9
  import { qaFindingId } from "./qa_findings";
10
10
  import { captureGitTaskRevisionSnapshot } from "../workspace_scope";
11
- import { readTaskRecord } from "../kernel/storage";
11
+ import { readTaskRecordRaw } from "../kernel/storage";
12
12
 
13
13
  function deliveryTreeForSnapshot(
14
14
  snapshot: SnapshotDescriptor,
15
15
  writeTree: typeof writeDeliveryTree,
16
16
  ): string {
17
17
  if (snapshot.review_revision?.review_tree) return snapshot.review_revision.review_tree;
18
- const record = readTaskRecord(snapshot.root, snapshot.task_id).record;
18
+ const record = readTaskRecordRaw(snapshot.root, snapshot.task_id).record;
19
19
  if (!record || record.contract !== "assurance_kernel/task_record/v4" || !record.git_base_head)
20
20
  throw new Error("QA delivery requires a TaskRecord v4 git_base_head");
21
21
  const captured = captureGitTaskRevisionSnapshot(
@@ -463,6 +463,10 @@ function publishInput(
463
463
  const recomputed = `sha256:${createHash("sha256").update(JSON.stringify(snapshot)).digest("hex")}`;
464
464
  if (recomputed !== input.expectedDiffHash)
465
465
  throw new Error("review task revision does not match assurance snapshot");
466
+ // A revision whose tree equals the base carries nothing to review; reserving
467
+ // Review for it would settle QA outcomes against bytes it does not contain.
468
+ if (Object.keys(snapshot.changed_paths).length === 0)
469
+ throw new Error("review revision carries no task change; stage the task's in-scope work before Review");
466
470
  return { snapshot, revision: publishReviewRevision(snapshot.repository_root, snapshot, recomputed, input.taskId) };
467
471
  }
468
472