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
@@ -55,6 +55,7 @@ export type CanaryOperation =
55
55
  | { op: "approve_breaking_intent_revision"; capability: object; next_intent: TaskIntentV1; actor_id: string }
56
56
  | { op: "complete"; actor_id: string }
57
57
  | { op: "stop"; capability: object; reason: string; actor_id: string }
58
+ | { op: "authorize_rework"; capability: object; actor_id: string }
58
59
  | { op: "resolve_user_decision"; capability: object; finding_id: string; resolution: string; actor_id: string };
59
60
 
60
61
  export interface CanaryExecuteInput {
@@ -142,6 +143,8 @@ export function capabilityActionFor(input: {
142
143
  return { ...base, approval: input.approval } as TaskAction;
143
144
  case "request_rework":
144
145
  return { ...base, findings: input.findings } as TaskAction;
146
+ case "authorize_rework":
147
+ return { ...base, type: "authorize_rework" } as TaskAction;
145
148
  case "stop":
146
149
  return { ...base, reason: input.reason } as TaskAction;
147
150
  case "approve_breaking_intent_revision":
@@ -326,15 +329,17 @@ export function createCanaryApplication(
326
329
  );
327
330
  if (operation.op === "complete" && hasBoundSpec && snapshot.record.artifact_state !== "frozen")
328
331
  throw new KernelInvariantError(["complete requires frozen planning artifacts"]);
329
- const artifactTransition = snapshot.record.artifact_state === "frozen"
330
- && (
331
- operation.op === "request_rework"
332
- || operation.op === "approve_breaking_intent_revision"
332
+ const artifactTransition =
333
+ snapshot.record.artifact_state === "frozen" &&
334
+ (
335
+ operation.op === "request_rework" ||
336
+ operation.op === "authorize_rework" ||
337
+ operation.op === "approve_breaking_intent_revision"
333
338
  )
334
- ? transitionFor(input.root, snapshot.record, "restore")
335
- : operation.op === "stop" && snapshot.record.artifact_state !== "frozen"
336
- ? transitionFor(input.root, snapshot.record, "freeze", true)
337
- : undefined;
339
+ ? transitionFor(input.root, snapshot.record, "restore")
340
+ : operation.op === "stop" && snapshot.record.artifact_state !== "frozen"
341
+ ? transitionFor(input.root, snapshot.record, "freeze", true)
342
+ : undefined;
338
343
  const event_id = `${operation.op}:${input.task_id}:${at}`;
339
344
  const base = {
340
345
  event_id,
@@ -400,6 +405,10 @@ export function createCanaryApplication(
400
405
  capability = operation.capability;
401
406
  action = { ...base, type: "stop", reason: operation.reason };
402
407
  break;
408
+ case "authorize_rework":
409
+ capability = operation.capability;
410
+ action = { ...base, type: "authorize_rework" };
411
+ break;
403
412
  case "resolve_user_decision":
404
413
  capability = operation.capability;
405
414
  action = {
@@ -8,6 +8,10 @@ import {
8
8
  type EnrollmentAuthorityRegistry,
9
9
  type EnrollmentCapabilityBinding,
10
10
  } from "./enrollment_authority";
11
+ import type {
12
+ BatchAuthorityRegistry,
13
+ BatchAuthorizationBinding,
14
+ } from "./batch_authority";
11
15
  import { readTaskTombstone, type BackendClaim } from "./backend_claim";
12
16
  import { preparePiCanary, readGitHead } from "./pi_canary_prepare";
13
17
  import {
@@ -18,6 +22,19 @@ import {
18
22
  } from "./storage";
19
23
  import type { TaskRecord, TaskRecordV4, WorkspaceStateLike } from "./types";
20
24
 
25
+ /**
26
+ * Present only for a batch-derived enrollment. The child slot is consumed
27
+ * inside the same store lock as the TaskRecord write and released again if that
28
+ * write does not commit, so a consumed slot and a TaskRecord always agree.
29
+ */
30
+ export interface EnrollBatchContext {
31
+ registry: BatchAuthorityRegistry;
32
+ capability: object;
33
+ binding: BatchAuthorizationBinding;
34
+ /** base_head, then each commit this batch created on its own branch. */
35
+ expected_head: string;
36
+ }
37
+
21
38
  export interface EnrollCanaryInput {
22
39
  task_id: string;
23
40
  intent_path: string;
@@ -25,6 +42,7 @@ export interface EnrollCanaryInput {
25
42
  preparation_digest: string;
26
43
  capability: object;
27
44
  capability_binding: EnrollmentCapabilityBinding;
45
+ batch?: EnrollBatchContext;
28
46
  now: string;
29
47
  }
30
48
 
@@ -241,9 +259,46 @@ export function enrollCanaryTask(
241
259
  if (checks.gitBaseHead !== gitBaseHead)
242
260
  throw new Error("Git HEAD moved after the enrollment confirmation");
243
261
 
262
+ // A batch-derived enrollment must still stand on the lineage the
263
+ // literal user confirmed: child N's base is the commit child N-1
264
+ // produced on the batch branch, and child 1's base is base_head.
265
+ // expected_head is caller-supplied, so anchor its origin here rather
266
+ // than trusting the caller's own assertion: before this batch has
267
+ // consumed any slot it has created no commit, so the only lineage
268
+ // value it can hold is the confirmed base_head.
269
+ if (input.batch) {
270
+ const batch = input.batch.registry.inspect(
271
+ input.batch.capability,
272
+ input.batch.binding,
273
+ Date.parse(input.now),
274
+ );
275
+ if (
276
+ input.batch.registry.consumedChildren(input.batch.capability).length === 0 &&
277
+ input.batch.expected_head !== batch.base_head
278
+ )
279
+ throw new Error(
280
+ `batch_head_lineage_broken: the first child must enroll on the confirmed base_head ${batch.base_head}, not ${input.batch.expected_head}`,
281
+ );
282
+ if (checks.gitBaseHead !== input.batch.expected_head)
283
+ throw new Error(
284
+ `batch_head_lineage_broken: expected ${input.batch.expected_head}, found ${checks.gitBaseHead}`,
285
+ );
286
+ }
287
+
244
288
  // consume immediately before the marker write
245
289
  registry.consume(input.capability, input.capability_binding);
290
+ if (input.batch)
291
+ input.batch.registry.consumeChild(
292
+ input.batch.capability,
293
+ input.batch.binding,
294
+ input.task_id,
295
+ Date.parse(input.now),
296
+ );
246
297
 
298
+ // Set by beforeLock above, which throws when the repository has no
299
+ // committed HEAD. Re-assert it here: the compiler cannot carry a
300
+ // closure's narrowing across to this one.
301
+ if (!gitBaseHead) throw new Error("enrollment requires a committed Git HEAD");
247
302
  const record = buildTaskRecordV4(input, checks.intent, gitBaseHead);
248
303
  const nextWorkspace: WorkspaceStateLike = {
249
304
  ...checks.workspace.state,
@@ -260,19 +315,27 @@ export function enrollCanaryTask(
260
315
  created_at: input.now,
261
316
  updated_at: input.now,
262
317
  };
263
- const mutation = commitEnrollmentLocked(
264
- root,
265
- input.task_id,
266
- {
267
- contract: "assurance_kernel/workspace_transaction/v2",
268
- task_id: input.task_id,
269
- expected_record_hash: checks.current.revision,
270
- next_record_content: `${JSON.stringify(record, null, 2)}\n`,
271
- expected_workspace_hash: checks.workspace.revision,
272
- next_workspace_content: `${JSON.stringify(nextWorkspace, null, 2)}\n`,
273
- },
274
- claim as unknown as Record<string, unknown>,
275
- );
318
+ let mutation: ReturnType<typeof commitEnrollmentLocked>;
319
+ try {
320
+ mutation = commitEnrollmentLocked(
321
+ root,
322
+ input.task_id,
323
+ {
324
+ contract: "assurance_kernel/workspace_transaction/v2",
325
+ task_id: input.task_id,
326
+ expected_record_hash: checks.current.revision,
327
+ next_record_content: `${JSON.stringify(record, null, 2)}\n`,
328
+ expected_workspace_hash: checks.workspace.revision,
329
+ next_workspace_content: `${JSON.stringify(nextWorkspace, null, 2)}\n`,
330
+ },
331
+ claim as unknown as Record<string, unknown>,
332
+ );
333
+ } catch (error) {
334
+ // No TaskRecord was written, so the child slot must not stay used.
335
+ if (input.batch)
336
+ input.batch.registry.releaseChild(input.batch.capability, input.task_id);
337
+ throw error;
338
+ }
276
339
  return {
277
340
  record: mutation.record,
278
341
  backend_claim: claim,
@@ -19,6 +19,8 @@ export {
19
19
  appendJournalEntry,
20
20
  KernelStoreSecurityError,
21
21
  setAfterTaskTransactionWriteForTest,
22
+ type JournalEntry,
23
+ type JournalReasonCode,
22
24
  } from "./storage";
23
25
  export {
24
26
  reduceTask,
@@ -27,6 +27,7 @@ import {
27
27
  import {
28
28
  mintToken,
29
29
  type TaskIntentIdentityToken,
30
+ type TokenIdentity,
30
31
  } from "./intent_token_registry";
31
32
 
32
33
  export const INTENT_MAX_BYTES = 64 * 1024;
@@ -406,6 +407,26 @@ export interface ReadTaskIntentResult {
406
407
  token: TaskIntentIdentityToken;
407
408
  }
408
409
 
410
+ export interface ObservedTaskIntent {
411
+ intent: TaskIntentV1;
412
+ content_hash: string;
413
+ intent_ref: TaskIntentRefV1;
414
+ }
415
+
416
+ export class TaskIntentObservationError extends Error {
417
+ readonly code: "missing" | "invalid";
418
+
419
+ constructor(code: "missing" | "invalid", message: string) {
420
+ super(message);
421
+ this.name = "TaskIntentObservationError";
422
+ this.code = code;
423
+ }
424
+ }
425
+
426
+ interface TaskIntentReadSource extends ObservedTaskIntent {
427
+ identity: TokenIdentity;
428
+ }
429
+
409
430
  export function setIntentReaderTestHook(
410
431
  hook: { onBeforeDescriptorRead?: () => void } | null,
411
432
  ): void {
@@ -523,11 +544,11 @@ function assertIdentitiesUnchanged(
523
544
  }
524
545
  }
525
546
 
526
- export function readTaskIntent(
547
+ function readTaskIntentSource(
527
548
  root: string,
528
549
  taskId: string,
529
550
  requestedPath?: string,
530
- ): ReadTaskIntentResult {
551
+ ): TaskIntentReadSource {
531
552
  validateTaskId(taskId);
532
553
 
533
554
  const canonicalRoot = resolveCanonicalRoot(root);
@@ -545,7 +566,7 @@ export function readTaskIntent(
545
566
  if (!target.startsWith(canonicalRoot + sep))
546
567
  throw new Error("intent sidecar escapes project root");
547
568
  if (!sidecarPresent(canonicalRoot, sidecarPath))
548
- throw new Error(`TaskIntent sidecar is missing at ${sidecarPath}`);
569
+ throw new TaskIntentObservationError("missing", `TaskIntent sidecar is missing at ${sidecarPath}`);
549
570
 
550
571
  const pathIdentities = collectPathIdentities(canonicalRoot, sidecarPath);
551
572
  const fileIdentity = pathIdentities[pathIdentities.length - 1];
@@ -557,13 +578,19 @@ export function readTaskIntent(
557
578
  ["ls-files", "--error-unmatch", "--", sidecarPath],
558
579
  { cwd: canonicalRoot, stdio: ["ignore", "pipe", "pipe"] },
559
580
  );
560
- } catch {
561
- throw new Error("TaskIntent sidecar is not Git-tracked");
581
+ } catch (error) {
582
+ if (
583
+ typeof error === "object"
584
+ && error !== null
585
+ && "status" in error
586
+ && (error as { status?: unknown }).status === 1
587
+ ) throw new TaskIntentObservationError("invalid", "TaskIntent sidecar is not Git-tracked");
588
+ throw error;
562
589
  }
563
590
 
564
591
  const before = lstatSync(target);
565
592
  if (!before.isFile() || before.size > INTENT_MAX_BYTES)
566
- throw new Error("TaskIntent sidecar must be a regular file no larger than 64 KiB");
593
+ throw new TaskIntentObservationError("invalid", "TaskIntent sidecar must be a regular file no larger than 64 KiB");
567
594
 
568
595
  const fd = openSync(target, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
569
596
  let bytes: Buffer;
@@ -576,7 +603,7 @@ export function readTaskIntent(
576
603
  closeSync(fd);
577
604
  }
578
605
  if (bytes.byteLength > INTENT_MAX_BYTES)
579
- throw new Error("TaskIntent sidecar exceeds 64 KiB");
606
+ throw new TaskIntentObservationError("invalid", "TaskIntent sidecar exceeds 64 KiB");
580
607
 
581
608
  // Post-read identity re-verification without a second path read as the
582
609
  // source of bytes.
@@ -594,27 +621,15 @@ export function readTaskIntent(
594
621
  try {
595
622
  intent = parseTaskIntentV1(JSON.parse(bytes.toString("utf8")));
596
623
  } catch (error) {
597
- throw new Error(
624
+ throw new TaskIntentObservationError(
625
+ "invalid",
598
626
  `TaskIntent sidecar is invalid: ${error instanceof Error ? error.message : String(error)}`,
599
627
  );
600
628
  }
601
629
  if (intent.task_id !== taskId)
602
- throw new Error("intent.task_id does not match the sidecar filename task id");
630
+ throw new TaskIntentObservationError("invalid", "intent.task_id does not match the sidecar filename task id");
603
631
 
604
632
  const contentHash = canonicalIntentHash(intent);
605
- const token = mintToken({
606
- canonical_root: canonicalRoot,
607
- sidecar_path: sidecarPath,
608
- path_dev: fileIdentity.dev,
609
- path_ino: fileIdentity.ino,
610
- fd_dev: before.dev,
611
- fd_ino: before.ino,
612
- fd_size: before.size,
613
- fd_mtime_ms: before.mtimeMs,
614
- source_bytes_sha256: sourceBytesSha256,
615
- intent_content_hash: contentHash,
616
- });
617
-
618
633
  return {
619
634
  intent,
620
635
  content_hash: contentHash,
@@ -623,6 +638,35 @@ export function readTaskIntent(
623
638
  revision: intent.revision,
624
639
  content_hash: contentHash,
625
640
  },
626
- token,
641
+ identity: {
642
+ canonical_root: canonicalRoot,
643
+ sidecar_path: sidecarPath,
644
+ path_dev: fileIdentity.dev,
645
+ path_ino: fileIdentity.ino,
646
+ fd_dev: before.dev,
647
+ fd_ino: before.ino,
648
+ fd_size: before.size,
649
+ fd_mtime_ms: before.mtimeMs,
650
+ source_bytes_sha256: sourceBytesSha256,
651
+ intent_content_hash: contentHash,
652
+ },
627
653
  };
628
654
  }
655
+
656
+ export function observeTaskIntent(
657
+ root: string,
658
+ taskId: string,
659
+ requestedPath?: string,
660
+ ): ObservedTaskIntent {
661
+ const { identity: _identity, ...observed } = readTaskIntentSource(root, taskId, requestedPath);
662
+ return observed;
663
+ }
664
+
665
+ export function readTaskIntent(
666
+ root: string,
667
+ taskId: string,
668
+ requestedPath?: string,
669
+ ): ReadTaskIntentResult {
670
+ const { identity, ...observed } = readTaskIntentSource(root, taskId, requestedPath);
671
+ return { ...observed, token: mintToken(identity) };
672
+ }
@@ -229,10 +229,12 @@ export function buildAuthorityObservationSeedV2(
229
229
  function committedReceiptV2(
230
230
  receipt: AuthorityCommitReceipt,
231
231
  ): receipt is AuthorityCommitReceipt & {
232
+ contract: "assurance_kernel/authority_commit_receipt/v2";
232
233
  status: "committed" | "recovered_committed";
233
234
  observation_seed: AuthorityObservationSeedV2;
234
235
  } {
235
236
  return (
237
+ receipt.contract === "assurance_kernel/authority_commit_receipt/v2" &&
236
238
  (receipt.status === "committed" ||
237
239
  receipt.status === "recovered_committed") &&
238
240
  receipt.observation_seed !== undefined
@@ -178,6 +178,7 @@ function hasPrivilegedKind(action: TaskAction): boolean {
178
178
  action.type === "record_approval" ||
179
179
  action.type === "approve_breaking_intent_revision" ||
180
180
  action.type === "request_rework" ||
181
+ action.type === "authorize_rework" ||
181
182
  action.type === "stop" ||
182
183
  action.type === "resolve_user_decision"
183
184
  );
@@ -449,13 +450,16 @@ export function reduceTask(
449
450
  "request_rework requires review, qa, or user authority",
450
451
  ]);
451
452
  const round = reviewRound(record);
452
- const reviewAuthorityReworks = record.history.filter(
453
- (entry) =>
454
- entry.type === "request_rework" &&
455
- entry.authority?.authority_kind === "review",
456
- ).length;
453
+ const hasPriorBlockingReviewRework = record.findings.some(
454
+ (finding) =>
455
+ finding.source === "review" &&
456
+ finding.kind === "blocking" &&
457
+ finding.review_round !== null,
458
+ );
457
459
  const parkForReplan =
458
- authorityAudit.authority_kind === "review" && reviewAuthorityReworks >= 1;
460
+ authorityAudit.authority_kind === "review" &&
461
+ hasPriorBlockingReviewRework &&
462
+ action.findings.some((finding) => finding.kind === "blocking");
459
463
  if (!parkForReplan) {
460
464
  record.artifact_state = "active";
461
465
  record.intent_ref.path = `docs/plans/${record.task_id}.intent.json`;
@@ -470,8 +474,8 @@ export function reduceTask(
470
474
  record.findings.push({
471
475
  ...finding,
472
476
  status: "open",
473
- source: "review",
474
- review_round: round,
477
+ source: authorityAudit.authority_kind === "review" ? "review" : "execution",
478
+ review_round: authorityAudit.authority_kind === "review" ? round : null,
475
479
  });
476
480
  }
477
481
  if (
@@ -502,6 +506,28 @@ export function reduceTask(
502
506
  appendHistory(record, action, from, `review_round_${round}`, authorityAudit);
503
507
  break;
504
508
  }
509
+ case "authorize_rework": {
510
+ if (record.lifecycle !== "active")
511
+ throw new KernelInvariantError([
512
+ `cannot authorize rework while lifecycle is ${record.lifecycle}`,
513
+ ]);
514
+ if (authorityAudit?.authority_kind !== "user")
515
+ throw new KernelInvariantError([
516
+ "authorize_rework requires literal-user authority",
517
+ ]);
518
+ const open = record.findings.filter(
519
+ (finding) => finding.kind === "replan_required" && finding.status === "open",
520
+ );
521
+ if (open.length === 0)
522
+ throw new KernelInvariantError([
523
+ "authorize_rework requires an open replan boundary",
524
+ ]);
525
+ for (const finding of open) finding.status = "resolved";
526
+ record.artifact_state = "active";
527
+ record.intent_ref.path = `docs/plans/${record.task_id}.intent.json`;
528
+ appendHistory(record, action, from, open.map((finding) => finding.id).join(","), authorityAudit);
529
+ break;
530
+ }
505
531
  case "complete": {
506
532
  if (record.lifecycle !== "active" || record.artifact_state !== "frozen")
507
533
  throw new KernelInvariantError([
@@ -78,7 +78,22 @@ export type JournalReasonCode =
78
78
  | "source_read_failed"
79
79
  | "shadow_divergence"
80
80
  | "migration_ambiguous"
81
- | "readiness_query_nonqualifying";
81
+ | "readiness_query_nonqualifying"
82
+ // Emitted by runtime/commands/kernel.ts. Absent from this union until the
83
+ // journal types were first exported and type checked.
84
+ | "routing_policy_invalid"
85
+ | "routing_unavailable"
86
+ | "kernel_owner_active"
87
+ | "v3_owner_nonterminal"
88
+ | "input_oversize"
89
+ | "input_invalid"
90
+ | "intent_invalid"
91
+ | "task_path_mismatch"
92
+ | "destination_invalid"
93
+ | "destination_parent_invalid"
94
+ | "destination_parent_missing"
95
+ | "destination_exists"
96
+ | "destination_write_failed";
82
97
 
83
98
  export interface JournalEntry {
84
99
  contract: "assurance_kernel/journal/v1";
@@ -1337,7 +1352,7 @@ export function commitEnrollmentLocked(
1337
1352
  taskId: string,
1338
1353
  transaction: WorkspaceTransactionV2,
1339
1354
  claim: Record<string, unknown>,
1340
- ): { record: TaskRecordV2; workspace: WorkspaceState } {
1355
+ ): { record: TaskRecord; workspace: WorkspaceState } {
1341
1356
  const marker: EnrollmentMarker = {
1342
1357
  contract: "assurance_kernel/enrollment_transaction/v1",
1343
1358
  task_id: taskId,
@@ -700,7 +700,7 @@ export function migrateLegacyLayout(root: string): MigrationOutcome {
700
700
  affected_paths: initial.dirty_affected_paths,
701
701
  reason: initial.reason,
702
702
  };
703
- if (["migration_blocked_active", "invalid"].includes(initial.layout))
703
+ if (initial.layout === "migration_blocked_active" || initial.layout === "invalid")
704
704
  return {
705
705
  contract: "immune_brain/storage_layout_migration_result/v1",
706
706
  outcome: initial.layout,
@@ -242,10 +242,6 @@ function inspectOldLayout(root: string): OldLayoutFacts {
242
242
  facts.blocked_active = true;
243
243
  continue;
244
244
  }
245
- if (kind === "marker") {
246
- facts.pending_marker ??= path;
247
- continue;
248
- }
249
245
  if (kind === "claim") {
250
246
  facts.blocked_active = true;
251
247
  continue;
@@ -235,6 +235,15 @@ export interface TaskRecordV4 extends Omit<TaskRecordV3, "contract"> {
235
235
  /** The record shape every Kernel owner passes around during the v3 drain window. */
236
236
  export type TaskRecord = TaskRecordV3 | TaskRecordV4;
237
237
 
238
+ /**
239
+ * Narrow the stored record union before reading a v4-only field such as
240
+ * `git_base_head`. Comparing `record.contract` into a plain boolean does not
241
+ * narrow, which let adapters read v4 fields off a v3-shaped value unchecked.
242
+ */
243
+ export function isTaskRecordV4(record: TaskRecord): record is TaskRecordV4 {
244
+ return record.contract === TASK_RECORD_CONTRACT_V4;
245
+ }
246
+
238
247
  export interface TaskProjectionV3 extends CompletionDecision {
239
248
  contract: "assurance_kernel/projection/v3";
240
249
  task_id: string;
@@ -288,6 +297,7 @@ export type TaskAction =
288
297
  | (TaskActionBase & { type: "request_rework"; findings: TaskFinding[] })
289
298
  | (TaskActionBase & { type: "complete" })
290
299
  | (TaskActionBase & { type: "stop"; reason: string })
300
+ | (TaskActionBase & { type: "authorize_rework" })
291
301
  | (TaskActionBase & { type: "resolve_user_decision"; finding_id: string; resolution: string });
292
302
 
293
303
 
@@ -735,6 +735,7 @@ const ACTION_V2_TYPES = [
735
735
  "request_rework",
736
736
  "complete",
737
737
  "stop",
738
+ "authorize_rework",
738
739
  "resolve_user_decision",
739
740
  ] as const;
740
741
 
@@ -897,7 +898,8 @@ export function parseTaskAction(raw: unknown): TaskAction {
897
898
  };
898
899
  break;
899
900
  }
900
- case "complete": {
901
+ case "complete":
902
+ case "authorize_rework": {
901
903
  rejectUnknown(value, [...ACTION_BASE_FIELDS], "action", violations);
902
904
  action = { ...base, type: base.type };
903
905
  break;
@@ -1002,6 +1004,7 @@ export function assertTaskRecordUpdateV3(
1002
1004
  if (
1003
1005
  next.intent_ref.path !== previous.intent_ref.path &&
1004
1006
  action.type !== "request_rework" &&
1007
+ action.type !== "authorize_rework" &&
1005
1008
  action.type !== "stop"
1006
1009
  )
1007
1010
  violations.push("only artifact transitions may change intent_ref path");
@@ -1023,11 +1026,12 @@ export function assertTaskRecordUpdateV3(
1023
1026
  ? [action.finding_id]
1024
1027
  : action.type === "resolve_user_decision"
1025
1028
  ? [action.finding_id]
1026
- : action.type === "approve_breaking_intent_revision"
1027
- ? previous.findings
1028
- .filter((item) => item.kind === "replan_required" && item.status === "open")
1029
- .map((item) => item.id)
1030
- : [];
1029
+ : action.type === "authorize_rework" ||
1030
+ action.type === "approve_breaking_intent_revision"
1031
+ ? previous.findings
1032
+ .filter((item) => item.kind === "replan_required" && item.status === "open")
1033
+ .map((item) => item.id)
1034
+ : [];
1031
1035
  const reworkFindingIds =
1032
1036
  action.type === "request_rework"
1033
1037
  ? new Set(action.findings.map((item) => item.id))
@@ -26,7 +26,7 @@
26
26
 
27
27
  import { execFileSync } from "node:child_process";
28
28
  import { createHash } from "node:crypto";
29
- import {
29
+ import { type Stats,
30
30
  closeSync,
31
31
  constants as fsConstants,
32
32
  fstatSync,
@@ -145,7 +145,7 @@ export function setRoutingPolicyReaderTestHook(
145
145
  routingPolicyReaderTestHook = hook;
146
146
  }
147
147
 
148
- function statIdentity(stat: ReturnType<typeof statSync>): {
148
+ function statIdentity(stat: Stats): {
149
149
  dev: number;
150
150
  ino: number;
151
151
  size: number;
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/plugin_versioning.ts from the root package.json.
2
- export const PLUGIN_VERSION = "3.6.3" as const;
2
+ export const PLUGIN_VERSION = "3.6.5" as const;