immune-brain 3.6.9 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/package.json +3 -2
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
  4. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
  5. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
  6. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
  7. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
  8. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
  9. package/plugins/immune-brain/dist/claude/mcp-server.mjs +7587 -5049
  10. package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
  11. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
  12. package/plugins/immune-brain/dist/imm-loop.md +27 -25
  13. package/plugins/immune-brain/dist/imm-planner.md +34 -27
  14. package/plugins/immune-brain/dist/imm-review-retro.md +2 -2
  15. package/plugins/immune-brain/dist/role-prompts/code-review.md +3 -1
  16. package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
  17. package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
  18. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
  19. package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
  20. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
  21. package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
  22. package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
  23. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
  24. package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
  25. package/plugins/immune-brain/runtime/github_issue_tracker.ts +1 -1
  26. package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
  27. package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
  28. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
  29. package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
  30. package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
  31. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
  32. package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
  33. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
  34. package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
  35. package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
  36. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
  37. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  38. package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
  39. package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
  40. package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
  41. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
  42. package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
  43. package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
  44. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
  45. package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
  46. package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
  47. package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
  48. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
  49. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
  50. package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
  51. package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
  52. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
  53. package/plugins/immune-brain/runtime/plan_core.ts +27 -65
  54. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  55. package/plugins/immune-brain/runtime/prompts/code-review.md +3 -1
  56. package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
  57. package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
  58. package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
  59. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
  60. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
  61. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
  62. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
  63. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
  64. package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
  65. package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
  66. package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
  67. package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
  68. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
  69. package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
  70. package/plugins/immune-brain/bin/imm-retired +0 -4
  71. package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
  72. package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
  73. package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
  74. package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
  75. package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
  76. package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
@@ -1,4 +1,3 @@
1
- import { createHash } from "node:crypto";
2
1
  import {
3
2
  closeSync,
4
3
  constants as fsConstants,
@@ -8,7 +7,7 @@ import {
8
7
  writeFileSync,
9
8
  } from "node:fs";
10
9
  import { execFileSync } from "node:child_process";
11
- import { join, resolve } from "node:path";
10
+ import { resolve } from "node:path";
12
11
  import process from "node:process";
13
12
  import { fileURLToPath } from "node:url";
14
13
  import {
@@ -27,6 +26,7 @@ import {
27
26
  parseTaskIntentV1,
28
27
  RISK_FLOOR_SCOPE_PREFIXES,
29
28
  } from "../kernel/intent";
29
+ import { inspectSpecBinding } from "../kernel/spec_binding";
30
30
  import { projectTask, resolveProjectedRisk } from "../kernel/completion";
31
31
  import { deriveAssuranceAuthorization } from "../kernel/assurance_projection";
32
32
  import { taskDiffIdentity, taskRevisionIdentity } from "../workspace_scope";
@@ -40,7 +40,7 @@ import { projectLegacyAudit } from "../kernel/legacy_audit";
40
40
  import { inspectStorageLayout } from "../kernel/storage_paths";
41
41
  import { migrateLegacyLayout } from "../kernel/storage_layout_migration";
42
42
  import { readBackendClaim } from "../kernel/backend_claim";
43
- import { withKernelStoreLock } from "../kernel";
43
+ import { probeKernelStore } from "../kernel";
44
44
 
45
45
  export interface KernelCommandResult {
46
46
  stdout: string;
@@ -65,12 +65,6 @@ function errorResult(code: string, message: string, returncode: number): KernelC
65
65
  return jsonResult({ error: { code, message } }, returncode);
66
66
  }
67
67
 
68
- function asRecord(value: unknown): Record<string, unknown> {
69
- return value && typeof value === "object" && !Array.isArray(value)
70
- ? (value as Record<string, unknown>)
71
- : {};
72
- }
73
-
74
68
  function journalFor(
75
69
  command: string,
76
70
  phase: TaskPhase | null,
@@ -121,16 +115,7 @@ function runStatus(root: string): KernelExecution {
121
115
  try {
122
116
  const layout = inspectStorageLayout(root);
123
117
  const claim = readBackendClaim(root);
124
- const workspace = (() => {
125
- try {
126
- const raw = JSON.parse(readSecureProjectFile(root, ".imm/state/workspace.json")) as {
127
- current_working?: unknown;
128
- };
129
- return typeof raw.current_working === "string" ? raw.current_working : null;
130
- } catch {
131
- return null;
132
- }
133
- })();
118
+ const workspace = readWorkspaceOwner(root);
134
119
  return {
135
120
  result: jsonResult({
136
121
  contract: "assurance_kernel/status/v1",
@@ -193,10 +178,7 @@ function matchingFloorEntries(intent: TaskIntentV1): string[] {
193
178
 
194
179
  function readWorkspaceOwner(root: string): string | null {
195
180
  try {
196
- const raw = JSON.parse(readSecureProjectFile(root, ".imm/state/workspace.json")) as {
197
- current_working?: unknown;
198
- };
199
- return typeof raw.current_working === "string" ? raw.current_working : null;
181
+ return readWorkspaceStateRaw(root).state.current_working;
200
182
  } catch {
201
183
  return null;
202
184
  }
@@ -292,8 +274,8 @@ function runInspect(root: string): KernelExecution {
292
274
  try {
293
275
  identity =
294
276
  record.contract === "assurance_kernel/task_record/v4"
295
- ? taskRevisionIdentity(root, intent.scope_hint, record.git_base_head)
296
- : taskDiffIdentity(root, intent.scope_hint);
277
+ ? taskRevisionIdentity(root, intent.scope_hint, record.git_base_head, intent.task_id)
278
+ : taskDiffIdentity(root, intent.scope_hint, intent.task_id);
297
279
  } catch (error) {
298
280
  return sourceFailure("inspect", error);
299
281
  }
@@ -499,7 +481,7 @@ function runIntentAuthor(args: string[], root: string): KernelExecution {
499
481
  // its frozen manifest under both locks. The original authoring is retried
500
482
  // only after the affected migration diff is committed.
501
483
  try {
502
- withKernelStoreLock(root, () => undefined);
484
+ probeKernelStore(root);
503
485
  } catch (error) {
504
486
  return {
505
487
  result: errorResult(
@@ -536,46 +518,13 @@ function runIntentAuthor(args: string[], root: string): KernelExecution {
536
518
  };
537
519
  }
538
520
  if (layout.layout === "recovery_required" || layout.layout === "migration_required") {
539
- const migration = migrateLegacyLayout(root);
540
- if (migration.outcome === "migrated") {
541
- return {
542
- result: jsonResult({
543
- contract: "assurance_kernel/migration_completed/v1",
544
- operation: "intent author",
545
- affected_paths: migration.affected_paths,
546
- next_action: "commit the affected migration paths, then retry intent author",
547
- }),
548
- journal: journalFor(
549
- "intent",
550
- null,
551
- "escalated",
552
- "migration_ambiguous",
553
- null,
554
- "Legacy evidence was relocated without Git index writes; commit and retry.",
555
- ),
556
- };
557
- }
558
- if (migration.outcome === "migration_uncommitted") {
559
- return {
560
- result: errorResult(
561
- "migration_uncommitted",
562
- `affected storage paths differ from HEAD: ${migration.affected_paths.join(", ") || "(none)"}`,
563
- 1,
564
- ),
565
- journal: journalFor(
566
- "intent",
567
- null,
568
- "rejected",
569
- "migration_ambiguous",
570
- null,
571
- "Commit or restore the affected paths before retrying.",
572
- ),
573
- };
574
- }
521
+ // Authoring never migrates implicitly: the retired layout is converted by
522
+ // the explicit claimless command only, so creating a draft cannot publish a
523
+ // database or retire historical files behind the operator's back.
575
524
  return {
576
525
  result: errorResult(
577
- layout.layout === "recovery_required" ? "layout_recovery_required" : "layout_migration_blocked",
578
- migration.reason ?? layout.reason ?? "storage layout is not ready",
526
+ layout.layout === "recovery_required" ? "layout_recovery_required" : "layout_migration_required",
527
+ `${layout.reason ?? "storage layout is not ready"}; run imm-kernel migrate --storage-layout first`,
579
528
  1,
580
529
  ),
581
530
  journal: journalFor(
@@ -584,7 +533,7 @@ function runIntentAuthor(args: string[], root: string): KernelExecution {
584
533
  "rejected",
585
534
  "source_invalid",
586
535
  null,
587
- "Resolve the reported storage layout condition before authoring.",
536
+ "Run imm-kernel migrate --storage-layout, commit the affected paths, then retry intent author.",
588
537
  ),
589
538
  };
590
539
  }
@@ -760,7 +709,7 @@ function runIntentAuthor(args: string[], root: string): KernelExecution {
760
709
  return {
761
710
  result: errorResult(
762
711
  "intent_invalid",
763
- `candidate acceptance verification failed strict verification_descriptor/v1 parsing: ${error instanceof Error ? error.message : error}`,
712
+ `candidate acceptance verification failed strict verification_descriptor/v2 parsing: ${error instanceof Error ? error.message : error}`,
764
713
  1,
765
714
  ),
766
715
  journal: journalFor(
@@ -769,7 +718,7 @@ function runIntentAuthor(args: string[], root: string): KernelExecution {
769
718
  "rejected",
770
719
  "intent_invalid",
771
720
  null,
772
- "Fix the reported verification_descriptor/v1 violations and retry.",
721
+ "Fix the reported verification_descriptor/v2 violations and retry.",
773
722
  ),
774
723
  };
775
724
  }
@@ -1132,6 +1081,29 @@ function runIntentValidate(args: string[], root: string): KernelExecution {
1132
1081
  ),
1133
1082
  };
1134
1083
  }
1084
+ const specBinding = inspectSpecBinding(intent);
1085
+ if (!specBinding.ok) {
1086
+ return {
1087
+ result: jsonResult(
1088
+ {
1089
+ contract: "assurance_kernel/intent_validation/v1",
1090
+ valid: false,
1091
+ path: relative,
1092
+ task_id: intent.task_id,
1093
+ reason: specBinding.message,
1094
+ },
1095
+ 0,
1096
+ ),
1097
+ journal: journalFor(
1098
+ "intent",
1099
+ null,
1100
+ "ok",
1101
+ "command_ok",
1102
+ null,
1103
+ null,
1104
+ ),
1105
+ };
1106
+ }
1135
1107
 
1136
1108
  const verification: Array<{ id: string; eligible: boolean; reason: string | null }> = [];
1137
1109
  let allEligible = true;
@@ -1244,6 +1216,25 @@ function executeKernelCommand(args: string[], root: string): KernelExecution {
1244
1216
  };
1245
1217
  return runInspect(root);
1246
1218
  }
1219
+ if (command === "migrate") {
1220
+ if (flags.length !== 1 || flags[0] !== "--storage-layout")
1221
+ return {
1222
+ result: errorResult(
1223
+ "invalid_command",
1224
+ "migrate accepts only --storage-layout",
1225
+ 2,
1226
+ ),
1227
+ journal: journalFor(
1228
+ command,
1229
+ null,
1230
+ "rejected",
1231
+ "invalid_command",
1232
+ null,
1233
+ "Run imm-kernel migrate --storage-layout.",
1234
+ ),
1235
+ };
1236
+ return runStorageLayoutMigration(root);
1237
+ }
1247
1238
  if (command === "audit") {
1248
1239
  if (args.length !== 2 || args[1] !== "--legacy")
1249
1240
  return {
@@ -1341,6 +1332,106 @@ function executeKernelCommand(args: string[], root: string): KernelExecution {
1341
1332
  };
1342
1333
  }
1343
1334
 
1335
+ /**
1336
+ * Explicit claimless storage-layout migration.
1337
+ *
1338
+ * The retired file store is converted here and nowhere else: this command never
1339
+ * authors an intent, never opens a task, and never needs an active claim, so a
1340
+ * worktree that is still on the retired layout has one reachable entry point.
1341
+ * Mutating commands keep their fail-closed gate; this is the command that
1342
+ * resolves it.
1343
+ */
1344
+ function runStorageLayoutMigration(root: string): KernelExecution {
1345
+ const inspection = inspectStorageLayout(root);
1346
+ // Only a live owner blocks the command. Every other diagnosis is handed to
1347
+ // the migrator, which owns the recovery decisions: an interrupted
1348
+ // publication shows both stores and must stay reachable through this CLI.
1349
+ if (inspection.layout === "migration_blocked_active")
1350
+ return {
1351
+ result: errorResult(
1352
+ "layout_migration_blocked",
1353
+ inspection.reason ?? "a live legacy task still owns work; settle or stop it on the prior runtime first",
1354
+ 1,
1355
+ ),
1356
+ journal: journalFor(
1357
+ "migrate",
1358
+ null,
1359
+ "rejected",
1360
+ "source_invalid",
1361
+ null,
1362
+ "Resolve the reported layout condition, then rerun imm-kernel migrate --storage-layout.",
1363
+ ),
1364
+ };
1365
+ const migration = migrateLegacyLayout(root);
1366
+ if (migration.outcome === "migrated")
1367
+ return {
1368
+ result: jsonResult({
1369
+ contract: "assurance_kernel/migration_completed/v1",
1370
+ operation: "storage layout migration",
1371
+ affected_paths: migration.affected_paths,
1372
+ next_action: "commit the affected migration paths, then retry the original command",
1373
+ }),
1374
+ journal: journalFor(
1375
+ "migrate",
1376
+ null,
1377
+ "escalated",
1378
+ "migration_ambiguous",
1379
+ null,
1380
+ "Commit the affected migration paths, then retry the original command.",
1381
+ ),
1382
+ };
1383
+ if (migration.outcome === "migration_uncommitted")
1384
+ return {
1385
+ result: jsonResult({
1386
+ contract: "assurance_kernel/migration_pending_commit/v1",
1387
+ operation: "storage layout migration",
1388
+ affected_paths: migration.affected_paths,
1389
+ reason: migration.reason,
1390
+ next_action: "commit the preserved audit evidence, then rerun imm-kernel migrate --storage-layout",
1391
+ }),
1392
+ journal: journalFor(
1393
+ "migrate",
1394
+ null,
1395
+ "escalated",
1396
+ "migration_ambiguous",
1397
+ null,
1398
+ "Commit the preserved audit evidence, then rerun imm-kernel migrate --storage-layout.",
1399
+ ),
1400
+ };
1401
+ if (migration.outcome === "already_migrated")
1402
+ return {
1403
+ result: jsonResult({
1404
+ contract: "assurance_kernel/migration_completed/v1",
1405
+ operation: "storage layout migration",
1406
+ affected_paths: [],
1407
+ next_action: "retry the original command",
1408
+ }),
1409
+ journal: journalFor(
1410
+ "migrate",
1411
+ null,
1412
+ "ok",
1413
+ "command_ok",
1414
+ null,
1415
+ "Retry the original command.",
1416
+ ),
1417
+ };
1418
+ return {
1419
+ result: errorResult(
1420
+ "layout_migration_blocked",
1421
+ migration.reason ?? "the storage layout migration did not complete",
1422
+ 1,
1423
+ ),
1424
+ journal: journalFor(
1425
+ "migrate",
1426
+ null,
1427
+ "rejected",
1428
+ "source_invalid",
1429
+ null,
1430
+ "Resolve the reported layout condition, then rerun imm-kernel migrate --storage-layout.",
1431
+ ),
1432
+ };
1433
+ }
1434
+
1344
1435
  export function runKernelCommand(
1345
1436
  args: string[],
1346
1437
  root = process.cwd(),
@@ -2360,7 +2360,7 @@ export async function runGithubInitiativePublication(
2360
2360
  );
2361
2361
  if (!("get" in topology)) return publicationResult(topology.status, topology.message, topology);
2362
2362
  const historicalRelations = new Map<string, { blocked_by: number[]; state_reason: string | null }>();
2363
- for (const [taskId, binding] of amendment.historical) {
2363
+ for (const [taskId] of amendment.historical) {
2364
2364
  const issue = topology.get(taskId)!;
2365
2365
  const observed = await readBlockedByIds(absoluteRoot, gh, "upsert-task", initial.repository, issue.number);
2366
2366
  if (!Array.isArray(observed)) return publicationResult(observed.status, observed.message, observed);
@@ -0,0 +1,33 @@
1
+ // The literal-user authority identity, spelled once.
2
+ //
3
+ // Audit survey before the convergence (122 settled records under .imm/audit/,
4
+ // counting every `actor_id` in each record): `literal-user` appears 220 times
5
+ // across 54 records — the spelling Pi has recorded since the first managed
6
+ // task. The shortened `user` appears 4 times, all of them in three
7
+ // Claude-Host-era records, and the Kernel's own batch validation already
8
+ // demanded "a literal-user actor_id" while accepting the string "user". The
9
+ // converged spelling is therefore `literal-user`.
10
+ //
11
+ // Settled records are never rewritten: the reader keeps accepting both
12
+ // spellings, so a historical `user` audit or a batch capability issued before
13
+ // the convergence still validates and stays byte-identical. Every newly written
14
+ // record carries the canonical spelling.
15
+
16
+ export const LITERAL_USER_ACTOR_ID = "literal-user";
17
+
18
+ /** Spellings that mean the literal user. Both are read; only one is written. */
19
+ const LITERAL_USER_SPELLINGS: ReadonlySet<string> = new Set([LITERAL_USER_ACTOR_ID, "user"]);
20
+
21
+ /** True for a historical or current literal-user actor. */
22
+ export function isLiteralUserActor(actorId: string): boolean {
23
+ return LITERAL_USER_SPELLINGS.has(actorId);
24
+ }
25
+
26
+ /**
27
+ * The spelling to record. A historical `user` is read as what it meant and
28
+ * recorded as the canonical identity; every other actor is passed through
29
+ * unchanged.
30
+ */
31
+ export function canonicalActorId(actorId: string): string {
32
+ return actorId === "user" ? LITERAL_USER_ACTOR_ID : actorId;
33
+ }
@@ -4,9 +4,9 @@
4
4
  // the dedicated recoverable workspace transaction. Consumed by the Pi canary
5
5
  // host adapter via canary_application.
6
6
 
7
+ import { terminalRequestDigest } from "./run_identity";
7
8
  import {
8
9
  type MutationAuthorityRegistry,
9
- type ValidatedAuthorityV2,
10
10
  } from "./authority_port";
11
11
  import { asTaskDiffSnapshot } from "./completion";
12
12
  import { canonicalIntentHash, readTaskIntent } from "./intent";
@@ -26,6 +26,7 @@ import {
26
26
  commitTerminalLocked,
27
27
  readTaskRecordRaw,
28
28
  readWorkspaceStateRaw,
29
+ currentRunId,
29
30
  revisionForContent,
30
31
  serializeWorkspace,
31
32
  withKernelStoreLock,
@@ -37,8 +38,6 @@ import type {
37
38
  AuthorityAuditDescriptor,
38
39
  MutationAuthorityCapabilityV2,
39
40
  StoredTaskMutationV3,
40
- TaskAction,
41
- TaskIntentV1,
42
41
  TaskRecord,
43
42
  } from "./types";
44
43
  import { TASK_TOMBSTONE_CONTRACT, type TaskTombstone } from "./backend_claim";
@@ -164,9 +163,15 @@ export function applyTaskAction(
164
163
  action.type === "stop" ||
165
164
  action.type === "resolve_user_decision";
166
165
 
166
+ // A capability is opaque: its binding lives in the issuing registry, so
167
+ // the run this worktree holds is read here and handed to the registry as
168
+ // the expected identity. Authority issued for another run is refused
169
+ // before any write, which is what keeps it inside the worktree that
170
+ // issued it even when task, record, intent and diff content are identical.
167
171
  const expectedAuthority = privileged
168
172
  ? {
169
173
  task_id,
174
+ run_id: currentRunId(root, task_id) ?? undefined,
170
175
  action,
171
176
  expected_record_hash: current.revision,
172
177
  intent_revision: isRevisionAction
@@ -189,6 +194,7 @@ export function applyTaskAction(
189
194
  const inspectedAudit = expectedAuthority
190
195
  ? registry.inspect(capability, expectedAuthority, now)
191
196
  : null;
197
+ const authorityRunId = inspectedAudit?.run_id;
192
198
  const mutation = reduceTask(
193
199
  current.record,
194
200
  action,
@@ -227,9 +233,8 @@ export function applyTaskAction(
227
233
  // All preflight passed: consume tokens and authority, then commit.
228
234
  consumeIntentToken(prior_intent_token);
229
235
  consumeIntentToken(freshRead.token);
230
- let consumedAudit: ValidatedAuthorityV2 | null = null;
231
236
  if (expectedAuthority) {
232
- consumedAudit = registry.consume(
237
+ registry.consume(
233
238
  capability as MutationAuthorityCapabilityV2,
234
239
  expectedAuthority,
235
240
  now,
@@ -284,7 +289,17 @@ export function applyTaskAction(
284
289
  next_workspace_content: serializeWorkspace(nextWorkspaceState),
285
290
  ...(input.artifact_transition ? { artifact_relocations: input.artifact_transition.relocations } : {}),
286
291
  };
287
- commitTerminalLocked(root, task_id, transaction, tombstone);
292
+ commitTerminalLocked(
293
+ root,
294
+ task_id,
295
+ transaction,
296
+ tombstone,
297
+ authorityRunId,
298
+ // The request this settlement answers: a retry with a different
299
+ // reason, actor or event time is a different decision and must be
300
+ // authorized again rather than replayed.
301
+ terminalRequestDigest(action),
302
+ );
288
303
  return {
289
304
  revision: canonicalRecordHash(nextRecord),
290
305
  record: nextRecord,
@@ -303,6 +318,7 @@ export function applyTaskAction(
303
318
  workspace.revision,
304
319
  nextWorkspaceState,
305
320
  input.artifact_transition?.relocations,
321
+ authorityRunId,
306
322
  );
307
323
  });
308
324
  }
@@ -12,9 +12,16 @@
12
12
  // "start QA" or "start Review"; it returns Kernel-owned facts only.
13
13
 
14
14
  import { readBackendClaim, readTaskTombstone } from "./backend_claim";
15
- import { readTaskRecord, readAuditTaskPair, readWorkspaceStateRaw, reconcileKernelAuthority } from "./storage";
15
+ import {
16
+ readTaskRecord,
17
+ readAuditTaskPair,
18
+ readCommittedRecord,
19
+ readWorkspaceStateRaw,
20
+ reconcileKernelAuthority,
21
+ } from "./storage";
16
22
  import { projectTask, resolveProjectedRisk } from "./completion";
17
- import type { AssuranceObligation, TaskIntentV1, TaskRecord, TaskRecordV2 } from "./types";
23
+ import type { AssuranceObligation, TaskIntentV1, TaskRecord, TaskRecordV3 } from "./types";
24
+ import type { TaskRecordV2 } from "./legacy_task_record";
18
25
 
19
26
  export interface TaskDiffSnapshot {
20
27
  diff_hash: string;
@@ -34,6 +41,12 @@ export interface AssuranceAuthorizationReadiness {
34
41
  }
35
42
 
36
43
  export interface AssuranceProjection {
44
+ /**
45
+ * The exact run this projection describes: one worktree owns one store and
46
+ * one active run, so the run id is the authority identity a capability must
47
+ * bind. Null when no run is committed.
48
+ */
49
+ run_id: string | null;
37
50
  record_revision: string;
38
51
  workspace_revision: string;
39
52
  intent_revision: number;
@@ -83,6 +96,7 @@ export function deriveAssuranceAuthorization(input: {
83
96
 
84
97
  function emptyProjection(): AssuranceProjection {
85
98
  return {
99
+ run_id: null,
86
100
  record_revision: "",
87
101
  workspace_revision: "",
88
102
  intent_revision: 0,
@@ -128,6 +142,36 @@ function projectHistoricalTerminal(
128
142
  };
129
143
  }
130
144
 
145
+ function projectHistoricalTerminalV3(
146
+ record: TaskRecordV3,
147
+ recordRevision: string,
148
+ workspaceRevision: string,
149
+ ): AssuranceProjection {
150
+ const freshAcceptanceIds = [
151
+ ...new Set(
152
+ record.attestations
153
+ .filter((item) => item.kind === "qa")
154
+ .flatMap((item) => item.acceptance_results)
155
+ .filter((item) => item.status === "passed")
156
+ .map((item) => item.acceptance_id),
157
+ ),
158
+ ];
159
+ const approvalKinds = [...new Set(record.attestations.map((item) => item.kind))];
160
+ return {
161
+ ...emptyProjection(),
162
+ record_revision: recordRevision,
163
+ workspace_revision: workspaceRevision,
164
+ intent_revision: record.intent_snapshot.revision,
165
+ intent_content_hash: record.intent_ref.content_hash,
166
+ lifecycle: record.lifecycle,
167
+ artifact_state: "frozen",
168
+ risk: record.intent_snapshot.risk,
169
+ fresh_acceptance_ids: freshAcceptanceIds,
170
+ fresh_approval_kinds: approvalKinds,
171
+ completion_ready: record.lifecycle === "done",
172
+ };
173
+ }
174
+
131
175
  function freshApprovalKinds(
132
176
  record: TaskRecord,
133
177
  currentIntentContentHash: string,
@@ -168,6 +212,8 @@ function projectFromRecord(
168
212
  (finding) => finding.kind === "unresolved_user_decision" && finding.status === "open",
169
213
  ).length;
170
214
  return {
215
+ // Filled by the caller from the committed run row.
216
+ run_id: null,
171
217
  record_revision: recordRevision,
172
218
  workspace_revision: workspaceRevision,
173
219
  intent_revision: record.intent_snapshot.revision,
@@ -232,9 +278,19 @@ export async function projectAssurance(
232
278
  const tombstone = readTaskTombstone(root, taskId);
233
279
  if (tombstone) {
234
280
  const authority = reconcileKernelAuthority(root, taskId);
235
- if (authority.state === "repairable_stale_claim")
281
+ // A terminal proof keyed by task id may have been exported by
282
+ // another worktree's run of this same task: it is that run's
283
+ // evidence, not a conflict with this worktree's own run. The local
284
+ // store decides, and an active local run keeps its authority.
285
+ if (authority.state === "active_owner") {
286
+ // fall through: the local run owns this task here
287
+ } else if (authority.state === "repairable_stale_claim")
236
288
  return fail(`task ${taskId} has a repairable stale backend claim`, claim);
237
- return fail(authority.diagnostic ?? `authority state conflicts for ${taskId}`, claim);
289
+ else
290
+ return fail(
291
+ authority.diagnostic ?? `authority state conflicts for ${taskId}`,
292
+ claim,
293
+ );
238
294
  }
239
295
  } else {
240
296
  const authority = reconcileKernelAuthority(root, taskId);
@@ -254,6 +310,28 @@ export async function projectAssurance(
254
310
  }
255
311
  if (!read.record) {
256
312
  if (!terminalOwner) return fail(`task ${taskId} has no TaskRecord v3`, claim);
313
+ const committedRunId = reconcileKernelAuthority(root, taskId).owner_run_id;
314
+ // The store keeps the terminal record itself, so a settled task projects
315
+ // from committed facts even while its audit export is still in flight.
316
+ const committed = await readCommittedRecord(root, taskId);
317
+ if (committed) {
318
+ const workspace = await readWorkspaceStateRaw(root);
319
+ return {
320
+ contract: "assurance_kernel/assurance_projection/v1",
321
+ task_id: taskId,
322
+ error: null,
323
+ claim: null,
324
+ projection: {
325
+ ...projectFromRecord(
326
+ committed.record,
327
+ committed.revision,
328
+ workspace.revision,
329
+ diffProvider(root, committed.record),
330
+ ),
331
+ run_id: committedRunId,
332
+ },
333
+ };
334
+ }
257
335
  const auditPair = await readAuditTaskPair(root, taskId);
258
336
  if (!auditPair) return fail(`task ${taskId} has no terminal audit pair`, claim);
259
337
  const workspace = await readWorkspaceStateRaw(root);
@@ -265,6 +343,14 @@ export async function projectAssurance(
265
343
  claim: null,
266
344
  projection: projectHistoricalTerminal(auditPair.record, auditPair.recordRevision, workspace.revision),
267
345
  };
346
+ if (auditPair.record.contract === "assurance_kernel/task_record/v3")
347
+ return {
348
+ contract: "assurance_kernel/assurance_projection/v1",
349
+ task_id: taskId,
350
+ error: null,
351
+ claim: null,
352
+ projection: projectHistoricalTerminalV3(auditPair.record, auditPair.recordRevision, workspace.revision),
353
+ };
268
354
  return {
269
355
  contract: "assurance_kernel/assurance_projection/v1",
270
356
  task_id: taskId,
@@ -284,7 +370,10 @@ export async function projectAssurance(
284
370
  task_id: taskId,
285
371
  error: null,
286
372
  claim,
287
- projection: projectFromRecord(read.record, read.revision, workspace.revision, snapshot),
373
+ projection: {
374
+ ...projectFromRecord(read.record, read.revision, workspace.revision, snapshot),
375
+ run_id: reconcileKernelAuthority(root, taskId).owner_run_id,
376
+ },
288
377
  };
289
378
  } catch (error) {
290
379
  return fail(error instanceof Error ? error.message : String(error));