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.
- package/README.md +157 -39
- package/README.zh-CN.md +160 -40
- package/package.json +1 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +42 -45
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +189 -476
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +77 -102
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +7 -2
- package/plugins/immune-brain/.pi-extension/tsconfig.json +2 -2
- package/plugins/immune-brain/agents/immune-brain-advisory-reviewer.md +23 -0
- package/plugins/immune-brain/agents/immune-brain-qa.md +42 -0
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +90 -1
- package/plugins/immune-brain/agents/immune-brain-ui-review.md +21 -0
- package/plugins/immune-brain/dist/BASELINE.md +4 -4
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +9924 -9654
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +3 -3
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -1
- package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
- package/plugins/immune-brain/dist/imm-doc-prune.md +2 -2
- package/plugins/immune-brain/dist/{imm-agent-doc-maintain.md → imm-doc-slim.md} +9 -9
- package/plugins/immune-brain/dist/imm-planner.md +60 -20
- package/plugins/immune-brain/dist/imm-pr-fix.md +1 -1
- package/plugins/immune-brain/dist/{imm-review-retro.md → imm-retro.md} +26 -13
- package/plugins/immune-brain/dist/{imm-loop.md → imm-run.md} +44 -11
- package/plugins/immune-brain/dist/registry.yaml +9 -9
- package/plugins/immune-brain/dist/role-prompts/code-review.md +7 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +35 -2
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +138 -69
- package/plugins/immune-brain/runtime/assurance/host_port.ts +20 -0
- package/plugins/immune-brain/runtime/assurance/qa.ts +2 -2
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
- package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +347 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +135 -396
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +5 -4
- package/plugins/immune-brain/runtime/claude/review_host.ts +63 -16
- package/plugins/immune-brain/runtime/claude/role_agents.ts +83 -0
- package/plugins/immune-brain/runtime/commands/kernel.ts +3 -6
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +738 -677
- package/plugins/immune-brain/runtime/kernel/application.ts +7 -7
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +15 -8
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +5 -14
- package/plugins/immune-brain/runtime/kernel/batch_authority.ts +30 -49
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +6 -5
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +0 -1
- package/plugins/immune-brain/runtime/kernel/capability_registry.ts +7 -9
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +170 -17
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
- package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
- package/plugins/immune-brain/runtime/kernel/storage.ts +170 -11
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +13 -0
- package/plugins/immune-brain/runtime/kernel/types.ts +2 -1
- package/plugins/immune-brain/runtime/kernel/validation.ts +3 -1
- package/plugins/immune-brain/runtime/loop_contract.ts +22 -22
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +7 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +35 -2
- package/plugins/immune-brain/runtime/role_prompt_bridge.ts +27 -0
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +66 -28
- package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +106 -0
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +132 -45
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +40 -36
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +98 -209
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +36 -14
- package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
- package/plugins/immune-brain/runtime/workspace_scope.ts +78 -8
- package/plugins/immune-brain/skills/BASELINE.md +4 -4
- package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +1 -1
- package/plugins/immune-brain/skills/{imm-agent-doc-maintain → imm-doc-slim}/SKILL.md +9 -9
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +4 -2
- package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +1 -1
- package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/SKILL.md +7 -7
- package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/scripts/review_retro.ts +46 -21
- package/plugins/immune-brain/skills/{imm-loop → imm-run}/SKILL.md +7 -7
- package/plugins/immune-brain/skills/registry.yaml +9 -9
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -584
- package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +0 -33
|
@@ -20,13 +20,13 @@ import { readBackendClaim } from "../kernel/backend_claim";
|
|
|
20
20
|
import { computeBatchPlanDigest, type BatchAuthorizationBinding } from "../kernel/batch_authority";
|
|
21
21
|
import { readGitHead } from "../kernel/pi_canary_prepare";
|
|
22
22
|
import { readTaskIntent } from "../kernel/intent";
|
|
23
|
-
import {
|
|
23
|
+
import { readTaskRecordRaw, readSettledTaskEvidence, readWorkspaceStateRaw, enrollmentEventIdFor, reconcileKernelAuthority } from "../kernel/storage";
|
|
24
|
+
import type { TaskRecord } from "../kernel/types";
|
|
24
25
|
import { pathMatchesScope } from "../workspace_scope";
|
|
25
26
|
import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHead, type ReconfirmationSnapshot } from "./batch_reconfirmation";
|
|
26
|
-
import {
|
|
27
|
+
import { projectBatchPlan } from "./batch_plan";
|
|
27
28
|
import { batchReason, type BatchReasonKey } from "./batch_reasons";
|
|
28
29
|
import { isTerminalBatchState, readBatchRunState, type BatchRunStateRecord } from "./batch_state";
|
|
29
|
-
import { readRunRowByTask, withKernelRead } from "../kernel/sqlite_store";
|
|
30
30
|
import type {
|
|
31
31
|
BatchPlanBudget,
|
|
32
32
|
BatchPlanChild,
|
|
@@ -98,6 +98,29 @@ export function readActiveClaimTaskId(root: string): string | null {
|
|
|
98
98
|
return workspace.state.current_working || (claim?.lifecycle_status === "active" ? claim.task_id : null);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The initiative whose resumable batch holds `taskId` as an in-flight child, or
|
|
103
|
+
* null. Read-only; a Host uses it to re-enter the batch after a foreground
|
|
104
|
+
* child settles, and anything unreadable simply means there is nothing to continue.
|
|
105
|
+
*/
|
|
106
|
+
export function findResumableBatchSlugForTask(root: string, taskId: string): string | null {
|
|
107
|
+
const batchesDir = join(root, ".imm", "state", "batches");
|
|
108
|
+
if (!existsSync(batchesDir)) return null;
|
|
109
|
+
for (const file of readdirSync(batchesDir)) {
|
|
110
|
+
if (!file.endsWith(".json")) continue;
|
|
111
|
+
try {
|
|
112
|
+
const record = JSON.parse(readFileSync(join(batchesDir, file), "utf8")) as BatchRunStateRecord;
|
|
113
|
+
if (record?.contract !== "assurance_kernel/batch_run_state/v1") continue;
|
|
114
|
+
if (record.batch_state !== "running" && record.batch_state !== "needs_human") continue;
|
|
115
|
+
if (record.children.some((child) => child.task_id === taskId && (child.state === "enrolled" || child.state === "settled")))
|
|
116
|
+
return record.initiative_slug;
|
|
117
|
+
} catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
101
124
|
/**
|
|
102
125
|
* The batch record for this initiative, or a corruption marker. A terminal
|
|
103
126
|
* record (completed, budget_stopped, failed, rejected) is not active: returning
|
|
@@ -182,10 +205,78 @@ export function findSettledBatchRecord(root: string, initiativeSlug: string): Ba
|
|
|
182
205
|
return newest;
|
|
183
206
|
}
|
|
184
207
|
|
|
208
|
+
export type BatchLineage =
|
|
209
|
+
| { kind: "equal"; head: string }
|
|
210
|
+
| { kind: "fast_forward"; head: string }
|
|
211
|
+
| { kind: "broken"; message: string };
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Classify live HEAD against the batch's recorded head. `fast_forward` is HEAD
|
|
215
|
+
* on the same batch branch with the recorded head as an ancestor and every
|
|
216
|
+
* recorded child commit still reachable: the user's own commit, adopted rather
|
|
217
|
+
* than refused. Anything else is the fatal `batch_head_lineage_broken`.
|
|
218
|
+
*/
|
|
219
|
+
export function classifyBatchLineage(input: {
|
|
220
|
+
root: string;
|
|
221
|
+
branch: string;
|
|
222
|
+
expectedHead: string;
|
|
223
|
+
childCommits: readonly string[];
|
|
224
|
+
/** When set, a fast-forward carrying this batch's own trailer is refused. */
|
|
225
|
+
batchId?: string;
|
|
226
|
+
}): BatchLineage {
|
|
227
|
+
const { root, branch, expectedHead, childCommits } = input;
|
|
228
|
+
const run = (args: string[]) =>
|
|
229
|
+
spawnSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
230
|
+
const broken = (reason: string): BatchLineage => ({
|
|
231
|
+
kind: "broken",
|
|
232
|
+
message: `batch_head_lineage_broken: ${reason}`,
|
|
233
|
+
});
|
|
234
|
+
const branchResult = run(["symbolic-ref", "--short", "HEAD"]);
|
|
235
|
+
const currentBranch = branchResult.stdout.trim();
|
|
236
|
+
if (branchResult.status !== 0 || currentBranch !== branch)
|
|
237
|
+
return broken(`current branch ${currentBranch} does not match expected branch ${branch}`);
|
|
238
|
+
const headResult = run(["rev-parse", "HEAD"]);
|
|
239
|
+
const head = headResult.stdout.trim();
|
|
240
|
+
if (headResult.status !== 0 || !head) return broken("current HEAD is unreadable");
|
|
241
|
+
if (head !== expectedHead && run(["merge-base", "--is-ancestor", expectedHead, head]).status !== 0)
|
|
242
|
+
return broken(`current HEAD ${head} does not descend from expected batch head ${expectedHead}`);
|
|
243
|
+
for (const commit of childCommits) {
|
|
244
|
+
if (run(["merge-base", "--is-ancestor", commit, head]).status !== 0)
|
|
245
|
+
return broken(`recorded commit ${commit} is no longer reachable from HEAD`);
|
|
246
|
+
}
|
|
247
|
+
if (head === expectedHead) return { kind: "equal", head };
|
|
248
|
+
// Only the runner writes this batch's trailer, and every commit it recorded
|
|
249
|
+
// is already in childCommits: a fast-forward claiming it is an impersonation
|
|
250
|
+
// (or an unverified own commit), never the user's own work.
|
|
251
|
+
if (input.batchId) {
|
|
252
|
+
const claimed = run([
|
|
253
|
+
"log",
|
|
254
|
+
"--fixed-strings",
|
|
255
|
+
`--grep=Immune-Brain-Batch: ${input.batchId}`,
|
|
256
|
+
"--format=%H",
|
|
257
|
+
`${expectedHead}..${head}`,
|
|
258
|
+
]);
|
|
259
|
+
const claimant = claimed.stdout.split(/\s+/).find(Boolean);
|
|
260
|
+
if (claimed.status !== 0 || claimant)
|
|
261
|
+
return broken(`commit ${claimant ?? head} claims batch ${input.batchId} without recorded evidence`);
|
|
262
|
+
}
|
|
263
|
+
return { kind: "fast_forward", head };
|
|
264
|
+
}
|
|
265
|
+
|
|
185
266
|
/** The HEAD a resumable batch must still sit on: its last child commit, or its base. */
|
|
186
|
-
export function expectedBatchHead(record: {
|
|
267
|
+
export function expectedBatchHead(record: {
|
|
268
|
+
base_head: string;
|
|
269
|
+
commits?: unknown;
|
|
270
|
+
adopted_heads?: ReadonlyArray<{ from: string; to: string }>;
|
|
271
|
+
}): string {
|
|
187
272
|
const commits = Array.isArray(record.commits) ? (record.commits as string[]) : [];
|
|
188
|
-
|
|
273
|
+
let head = commits.length > 0 ? commits[commits.length - 1]! : record.base_head;
|
|
274
|
+
// An adoption is keyed by the head it moved from, so one recorded before the
|
|
275
|
+
// last child commit can never resurface as the expected head.
|
|
276
|
+
for (const adoption of record.adopted_heads ?? []) {
|
|
277
|
+
if (adoption.from === head) head = adoption.to;
|
|
278
|
+
}
|
|
279
|
+
return head;
|
|
189
280
|
}
|
|
190
281
|
|
|
191
282
|
/**
|
|
@@ -226,19 +317,20 @@ export function isOwnBatchClaim(
|
|
|
226
317
|
if (!childInBatch || !(childInBatch.state === "enrolled" || childInBatch.state === "needs_human")) {
|
|
227
318
|
return false;
|
|
228
319
|
}
|
|
229
|
-
let rec:
|
|
320
|
+
let rec: TaskRecord | null = null;
|
|
230
321
|
try {
|
|
231
|
-
|
|
232
|
-
|
|
322
|
+
// Through the store read: the record is identity-validated against its
|
|
323
|
+
// run instead of being parsed from a raw row.
|
|
324
|
+
rec = readTaskRecordRaw(root, taskId).record;
|
|
233
325
|
} catch {
|
|
234
326
|
return false;
|
|
235
327
|
}
|
|
236
328
|
if (!rec) return false;
|
|
237
|
-
const lineageHeads = [existingBatch.base_head]
|
|
238
|
-
Array.isArray(existingBatch.commits) ? existingBatch.commits : []
|
|
239
|
-
|
|
329
|
+
const lineageHeads = [existingBatch.base_head]
|
|
330
|
+
.concat(Array.isArray(existingBatch.commits) ? existingBatch.commits : [])
|
|
331
|
+
.concat((existingBatch.adopted_heads ?? []).map((adoption) => adoption.to));
|
|
240
332
|
if (!lineageHeads.includes(rec.git_base_head)) return false;
|
|
241
|
-
if (claim.enrollment_event_id !==
|
|
333
|
+
if (claim.enrollment_event_id !== enrollmentEventIdFor(taskId, claim.created_at)) return false;
|
|
242
334
|
const createdAt = Date.parse(claim.created_at);
|
|
243
335
|
if (!Number.isFinite(createdAt) || createdAt > Date.parse(existingBatch.updated_at)) return false;
|
|
244
336
|
if (claim.task_id !== taskId || claim.lifecycle_status !== "active") return false;
|
|
@@ -264,16 +356,13 @@ function authorizedScopeOf(root: string, taskId: string, state: string): string[
|
|
|
264
356
|
// fallback below
|
|
265
357
|
}
|
|
266
358
|
if (scope.length === 0 && state === "settled") {
|
|
267
|
-
// A settled child has no live state record;
|
|
268
|
-
//
|
|
359
|
+
// A settled child has no live state record; the Authority Store's settled
|
|
360
|
+
// Run read is its authority. Read-only, so a refusal still writes nothing.
|
|
269
361
|
try {
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
const snapshot = (settled?.record as { intent_snapshot?: { scope_hint?: string[] } } | null | undefined)
|
|
273
|
-
?.intent_snapshot;
|
|
362
|
+
const settled = readSettledTaskEvidence(root, taskId);
|
|
363
|
+
const snapshot = settled?.record.intent_snapshot;
|
|
274
364
|
scope = snapshot?.scope_hint ?? [];
|
|
275
|
-
recordedIntentPath = recordedIntentPath ??
|
|
276
|
-
?.intent_ref?.path;
|
|
365
|
+
recordedIntentPath = recordedIntentPath ?? settled?.record.intent_ref?.path;
|
|
277
366
|
} catch {
|
|
278
367
|
// fallback below
|
|
279
368
|
}
|
|
@@ -347,11 +436,11 @@ async function projectPlanSurface(input: {
|
|
|
347
436
|
let budget: BatchPlanBudget;
|
|
348
437
|
|
|
349
438
|
if (isResuming && existingBatch) {
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
439
|
+
// A resume keeps the confirmed count limits of the persisted record.
|
|
440
|
+
budget = {
|
|
441
|
+
max_children: existingBatch.budget.max_children,
|
|
442
|
+
qa_failure_limit: existingBatch.budget.qa_failure_limit,
|
|
443
|
+
};
|
|
355
444
|
try {
|
|
356
445
|
recoveryChildren = existingBatch.children.map((c) => {
|
|
357
446
|
const intentPath = `docs/plans/${c.task_id}.intent.json`;
|
|
@@ -635,7 +724,6 @@ export interface BatchConfirmationFacts {
|
|
|
635
724
|
batch_branch: string;
|
|
636
725
|
plan_digest: string;
|
|
637
726
|
budget: BatchPlanBudget;
|
|
638
|
-
expires_at: string;
|
|
639
727
|
reuse_blockers: string[];
|
|
640
728
|
children: Array<{ task_id: string; slice_id: string; risk: string; status: BatchPlanChildStatus }>;
|
|
641
729
|
excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
|
|
@@ -656,7 +744,6 @@ export type BatchAuthorizationOutcome<HostRejection> =
|
|
|
656
744
|
batch_id: string;
|
|
657
745
|
reuse_authorization: boolean;
|
|
658
746
|
reuse_blockers: string[];
|
|
659
|
-
expires_at: string;
|
|
660
747
|
binding: BatchAuthorizationBinding;
|
|
661
748
|
}
|
|
662
749
|
| { outcome: "rejected"; rejection: BatchPreflightRejection }
|
|
@@ -678,7 +765,7 @@ export interface BatchAuthorizationOptions<HostRejection> {
|
|
|
678
765
|
|
|
679
766
|
/**
|
|
680
767
|
* The one authorization flow both Host adapters call after the shared
|
|
681
|
-
* preflight: ADR-0005's reuse
|
|
768
|
+
* preflight: ADR-0005's reuse decision, the literal-user gate, the
|
|
682
769
|
* post-gate claim/drift cascade, and the BatchAuthorizationBinding. A Host
|
|
683
770
|
* supplies its gate, its confirmation reference, and its nonce; every decision
|
|
684
771
|
* below stays Host-independent.
|
|
@@ -696,10 +783,6 @@ export async function authorizeBatch<HostRejection>(
|
|
|
696
783
|
} = projection;
|
|
697
784
|
const existingBatch = projection.existing_batch;
|
|
698
785
|
|
|
699
|
-
// ADR-0005 Decision 1: one Batch Authorization spans the work it authorizes,
|
|
700
|
-
// so its expiry is the deadline the literal user confirmed rather than a fixed
|
|
701
|
-
// window that lapses while a child is parked on a foreground Review.
|
|
702
|
-
const isExistingExpired = isResuming && Date.parse(existingBatch!.authorization_expires_at) <= Date.now();
|
|
703
786
|
const branchBefore = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
|
|
704
787
|
if (branchBefore.status !== 0)
|
|
705
788
|
return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
|
|
@@ -709,20 +792,29 @@ export async function authorizeBatch<HostRejection>(
|
|
|
709
792
|
// binds falls through to the gate below, which names the reason.
|
|
710
793
|
const ownsUnpersistedHead = isResuming && existingBatch !== null && existingBatch.plan_digest === planDigest &&
|
|
711
794
|
expectedBatchHead(existingBatch) !== baseHead && ownUnpersistedBatchHead(root, existingBatch, baseHead);
|
|
795
|
+
// A fast-forward on the batch branch is the user's own work: the runner adopts
|
|
796
|
+
// it, so it neither blocks reuse nor counts as moved provenance.
|
|
797
|
+
const fastForwardsRecordedHead = isResuming && existingBatch !== null && existingBatch.branch === batchBranch &&
|
|
798
|
+
expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead &&
|
|
799
|
+
classifyBatchLineage({
|
|
800
|
+
root,
|
|
801
|
+
branch: batchBranch,
|
|
802
|
+
expectedHead: expectedBatchHead(existingBatch),
|
|
803
|
+
childCommits: existingBatch.commits,
|
|
804
|
+
batchId: existingBatch.batch_id,
|
|
805
|
+
}).kind === "fast_forward";
|
|
712
806
|
if (isResuming && existingBatch && !existingBatch.commits.length && existingBatch.children[0]?.state === "settled" &&
|
|
713
|
-
expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead)
|
|
807
|
+
expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead && !fastForwardsRecordedHead)
|
|
714
808
|
return { outcome: "rejected", rejection: batchRejection("head_moved", "first unpersisted commit provenance is invalid") };
|
|
715
809
|
const reuseBlockers: string[] = [];
|
|
716
810
|
if (isResuming && existingBatch) {
|
|
717
|
-
if (isExistingExpired) reuseBlockers.push("batch_authorization_expired");
|
|
718
|
-
if (Date.parse(existingBatch.budget.deadline_at) <= Date.now()) reuseBlockers.push("batch_budget_expired");
|
|
719
811
|
if (existingBatch.batch_state !== "running") reuseBlockers.push("batch_not_running");
|
|
720
812
|
if (existingBatch.plan_digest !== planDigest) reuseBlockers.push("batch_plan_digest_changed");
|
|
721
813
|
if (existingBatch.branch !== batchBranch) reuseBlockers.push("batch_branch_changed");
|
|
722
|
-
if (expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead
|
|
814
|
+
if (expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead && !fastForwardsRecordedHead)
|
|
815
|
+
reuseBlockers.push("batch_head_lineage_moved");
|
|
723
816
|
}
|
|
724
817
|
const reuseAuthorization = isResuming && reuseBlockers.length === 0;
|
|
725
|
-
const expiresAt = reuseAuthorization ? existingBatch!.authorization_expires_at : budget.deadline_at;
|
|
726
818
|
|
|
727
819
|
const batchId = isResuming && existingBatch ? existingBatch.batch_id : `batch-${initiativeSlug}-${randomUUID()}`;
|
|
728
820
|
const facts: BatchConfirmationFacts = {
|
|
@@ -730,7 +822,6 @@ export async function authorizeBatch<HostRejection>(
|
|
|
730
822
|
batch_branch: batchBranch,
|
|
731
823
|
plan_digest: planDigest,
|
|
732
824
|
budget,
|
|
733
|
-
expires_at: expiresAt,
|
|
734
825
|
reuse_blockers: reuseBlockers,
|
|
735
826
|
children: projection.recovery_children.map((child) => ({
|
|
736
827
|
task_id: child.task_id,
|
|
@@ -777,8 +868,8 @@ export async function authorizeBatch<HostRejection>(
|
|
|
777
868
|
if (finalClaimTaskId && !finalOwnClaim)
|
|
778
869
|
return { outcome: "rejected", rejection: batchRejection("claim_appeared_during_confirmation", finalClaimTaskId) };
|
|
779
870
|
|
|
780
|
-
// Confirmation cannot adopt a changed record
|
|
781
|
-
//
|
|
871
|
+
// Confirmation cannot adopt a changed record after the user answers. Re-read
|
|
872
|
+
// the record and branch after the async checks.
|
|
782
873
|
if (isResuming) {
|
|
783
874
|
const current = findExistingActiveBatch(root, initiativeSlug);
|
|
784
875
|
if (!current || current.corrupt || JSON.stringify(current.record) !== JSON.stringify(existingBatch))
|
|
@@ -787,8 +878,6 @@ export async function authorizeBatch<HostRejection>(
|
|
|
787
878
|
const branchAfter = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
|
|
788
879
|
if (branchAfter.status !== 0 || branchAfter.stdout !== branchBefore.stdout)
|
|
789
880
|
return { outcome: "rejected", rejection: batchRejection("confirmation_failed", "Git branch moved after native confirmation") };
|
|
790
|
-
if (Date.parse(expiresAt) <= Date.now())
|
|
791
|
-
return { outcome: "rejected", rejection: batchRejection("confirmation_failed", "confirmed batch deadline expired before authorization issuance") };
|
|
792
881
|
|
|
793
882
|
if (projection.reconfirmation) {
|
|
794
883
|
try { projection.reconfirmation.assertUnchanged(); }
|
|
@@ -803,16 +892,14 @@ export async function authorizeBatch<HostRejection>(
|
|
|
803
892
|
budget,
|
|
804
893
|
actor_id: LITERAL_USER_ACTOR_ID,
|
|
805
894
|
confirmation_ref: options.confirmationRef({ batch_id: batchId, request_id: requestId }),
|
|
806
|
-
expires_at: expiresAt,
|
|
807
895
|
nonce: options.nonce,
|
|
808
896
|
};
|
|
809
|
-
if (projection.reconfirmation) retainReconfirmation(binding.nonce,
|
|
897
|
+
if (projection.reconfirmation) retainReconfirmation(binding.nonce, projection.reconfirmation);
|
|
810
898
|
return {
|
|
811
899
|
outcome: "authorized",
|
|
812
900
|
batch_id: batchId,
|
|
813
901
|
reuse_authorization: reuseAuthorization,
|
|
814
902
|
reuse_blockers: reuseBlockers,
|
|
815
|
-
expires_at: expiresAt,
|
|
816
903
|
binding,
|
|
817
904
|
};
|
|
818
905
|
}
|
|
@@ -24,7 +24,6 @@ export type BatchReasonKey =
|
|
|
24
24
|
| "empty_enrollable_set"
|
|
25
25
|
| "plan_projection_failed"
|
|
26
26
|
| "confirmation_port_unavailable"
|
|
27
|
-
| "confirmation_timed_out"
|
|
28
27
|
| "confirmation_cancelled"
|
|
29
28
|
| "confirmation_declined"
|
|
30
29
|
| "confirmation_no_decision"
|
|
@@ -115,11 +114,6 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
|
|
|
115
114
|
reason: "native confirmation port is unavailable",
|
|
116
115
|
recovery_action: "retry through a fresh native gate in the current Host",
|
|
117
116
|
},
|
|
118
|
-
confirmation_timed_out: {
|
|
119
|
-
state: "rejected",
|
|
120
|
-
reason: "native confirmation timed out waiting for user interaction",
|
|
121
|
-
recovery_action: "retry through a fresh native gate in the current Host",
|
|
122
|
-
},
|
|
123
117
|
confirmation_cancelled: {
|
|
124
118
|
state: "cancelled",
|
|
125
119
|
reason: "native interaction cancelled",
|
|
@@ -3,9 +3,9 @@ import { spawnSync } from "node:child_process";
|
|
|
3
3
|
import { computeBatchPlanDigest, type BatchPlanChild as DigestChild } from "../kernel/batch_authority";
|
|
4
4
|
import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
|
|
5
5
|
import { projectAssurance } from "../kernel/assurance_projection";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
6
|
+
import { readAuditTaskPair, readSecureProjectBytes, readSettledRunEvidence, enrollmentEventIdFor, readWorkspaceStateRaw, type SettledRunEvidence } from "../kernel/storage";
|
|
7
|
+
import { auditRunDirPath, auditRunRecordPath, auditRunTerminalProofPath, batchCommitEvidencePath, batchStatePath } from "../kernel/storage_paths";
|
|
8
|
+
import { taskDeliveryIdentity, taskCommitRevisionIdentity, pathMatchesScope } from "../workspace_scope";
|
|
9
9
|
import { projectTask } from "../kernel/completion";
|
|
10
10
|
import { isTerminalBatchState, type BatchRunStateRecord } from "./batch_state";
|
|
11
11
|
import type { BatchPlanChild } from "./types";
|
|
@@ -31,7 +31,7 @@ export function ownUnpersistedBatchHead(root: string, record: BatchRunStateRecor
|
|
|
31
31
|
record.children[0]?.state !== "settled" || record.children.some(c => c.commit !== null)) return false;
|
|
32
32
|
const task = record.children[0].task_id;
|
|
33
33
|
if (!id.test(task)) return false;
|
|
34
|
-
const evidence = JSON.parse(decode(readSecureProjectBytes(root,
|
|
34
|
+
const evidence = JSON.parse(decode(readSecureProjectBytes(root, batchCommitEvidencePath(record.batch_id, task))));
|
|
35
35
|
if (evidence.contract !== "assurance_kernel/batch_commit_evidence/v1" || evidence.batch_id !== record.batch_id ||
|
|
36
36
|
evidence.task_id !== task || evidence.commit !== head || evidence.parent_head !== record.base_head) return false;
|
|
37
37
|
if (git(root, ["symbolic-ref", "--short", "HEAD"]).trim() !== record.branch) return false;
|
|
@@ -39,18 +39,19 @@ export function ownUnpersistedBatchHead(root: string, record: BatchRunStateRecor
|
|
|
39
39
|
if (parents.length !== 1 || parents[0] !== record.base_head) return false;
|
|
40
40
|
const metadata = git(root, ["show", "-s", "--format=%an%x00%s%x00%(trailers:key=Immune-Brain-Batch,valueonly)", head]).split("\0");
|
|
41
41
|
if (metadata[0] !== batchAuthor || !metadata[1]?.startsWith(`imm(${task}):`) || metadata[2]?.trim() !== record.batch_id) return false;
|
|
42
|
-
const
|
|
43
|
-
if (!
|
|
42
|
+
const settled = readSettledRunEvidence(root, task);
|
|
43
|
+
if (!settled || settled.lifecycle !== "done") return false;
|
|
44
|
+
const run = settled.run_id;
|
|
45
|
+
if (!id.test(run)) return false;
|
|
44
46
|
const pair = readAuditTaskPair(root, task, run);
|
|
45
47
|
if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4" || pair.record.lifecycle !== "done" || pair.proof.terminal_lifecycle !== "done") return false;
|
|
46
|
-
|
|
47
|
-
if (!local || local.run_id !== run || local.state !== "done" || !matchesLocalProof(local.terminal_proof_json, pair.proof)) return false;
|
|
48
|
+
if (!matchesLocalProof(settled.proof_json, pair.proof)) return false;
|
|
48
49
|
for (const file of ["task-record.json", "terminal-proof.json"]) {
|
|
49
|
-
const path =
|
|
50
|
+
const path = `${auditRunDirPath(task, run)}/${file}`;
|
|
50
51
|
if (!readSecureProjectBytes(root, path).equals(Buffer.from(git(root, ["show", `${head}:${path}`])))) return false;
|
|
51
52
|
}
|
|
52
53
|
const paths = git(root, ["diff-tree", "--no-commit-id", "--name-only", "--no-renames", "-z", "-r", head]).split("\0").filter(Boolean);
|
|
53
|
-
if (!paths.length || paths.some(path => !path.startsWith(
|
|
54
|
+
if (!paths.length || paths.some(path => !path.startsWith(`${auditRunDirPath(task, run)}/`) &&
|
|
54
55
|
!pair.record.intent_snapshot.scope_hint.some(s => pathMatchesScope(path, s)))) return false;
|
|
55
56
|
const identity = taskCommitRevisionIdentity(root, pair.record.intent_snapshot.scope_hint, pair.record.git_base_head, head);
|
|
56
57
|
if (!projectTask(pair.record.intent_snapshot, pair.record, identity.diff_hash, pair.record.intent_ref.content_hash, identity.changed_paths).complete) return false;
|
|
@@ -67,17 +68,16 @@ export interface ReconfirmationSnapshot {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
// Ephemeral captured observations, not authority: the runner independently
|
|
70
|
-
// validates the native capability. Entries
|
|
71
|
-
const confirmations = new Map<string,
|
|
72
|
-
export function retainReconfirmation(nonce: string,
|
|
73
|
-
for (const [key, value] of confirmations) if (value.expires <= Date.now()) confirmations.delete(key);
|
|
71
|
+
// validates the native capability. Entries are discarded on application.
|
|
72
|
+
const confirmations = new Map<string, ReconfirmationSnapshot>();
|
|
73
|
+
export function retainReconfirmation(nonce: string, snapshot: ReconfirmationSnapshot): void {
|
|
74
74
|
if (confirmations.has(nonce)) refuse();
|
|
75
|
-
confirmations.set(nonce,
|
|
75
|
+
confirmations.set(nonce, snapshot);
|
|
76
76
|
}
|
|
77
77
|
export function takeReconfirmation(nonce: string): ReconfirmationSnapshot {
|
|
78
78
|
const found = confirmations.get(nonce); confirmations.delete(nonce);
|
|
79
|
-
if (!found
|
|
80
|
-
return found
|
|
79
|
+
if (!found) refuse();
|
|
80
|
+
return found;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
export async function captureBatchReconfirmation(root: string, record: BatchRunStateRecord, children: BatchPlanChild[]): Promise<ReconfirmationSnapshot> {
|
|
@@ -86,7 +86,7 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
|
|
|
86
86
|
const assertNoCommitEvidence = () => {
|
|
87
87
|
for (const child of record.children) {
|
|
88
88
|
if (!id.test(child.task_id)) refuse();
|
|
89
|
-
const path =
|
|
89
|
+
const path = batchCommitEvidencePath(record.batch_id, child.task_id);
|
|
90
90
|
let missing = false;
|
|
91
91
|
try { readSecureProjectBytes(root, path); }
|
|
92
92
|
catch (error) {
|
|
@@ -97,13 +97,13 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
|
|
|
97
97
|
}
|
|
98
98
|
};
|
|
99
99
|
assertNoCommitEvidence();
|
|
100
|
-
const statePath =
|
|
100
|
+
const statePath = batchStatePath(record.batch_id);
|
|
101
101
|
const stateBytes = readSecureProjectBytes(root, statePath);
|
|
102
102
|
if (JSON.stringify(JSON.parse(decode(stateBytes))) !== JSON.stringify(record)) refuse();
|
|
103
103
|
const files = new Map<string, Buffer>();
|
|
104
104
|
const capture = (path: string) => { const bytes = readSecureProjectBytes(root, path); files.set(path, bytes); return bytes; };
|
|
105
105
|
const oldChildren: DigestChild[] = [];
|
|
106
|
-
const changed: Array<{ task: string; run: string; identity: string;
|
|
106
|
+
const changed: Array<{ task: string; run: string; identity: string; settled: string }> = [];
|
|
107
107
|
let scope: string[] | null = null;
|
|
108
108
|
const workspace = readWorkspaceStateRaw(root);
|
|
109
109
|
if (workspace.state.current_working !== null) refuse();
|
|
@@ -122,11 +122,11 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
|
|
|
122
122
|
if (currentHash !== tracked.content_hash || currentHash !== child.intent_content_hash || current.revision !== child.intent_revision) refuse();
|
|
123
123
|
if (currentHash === oldHash) continue;
|
|
124
124
|
if (scope !== null || previous.state === "pending" || previous.state === "skipped_blocked" || current.revision <= old.revision || current.goal !== old.goal || current.owner !== old.owner) refuse();
|
|
125
|
-
const
|
|
126
|
-
if (!
|
|
127
|
-
const
|
|
128
|
-
if (!
|
|
129
|
-
Date.parse(
|
|
125
|
+
const settled = readSettledRunEvidence(root, child.task_id);
|
|
126
|
+
if (!settled || settled.lifecycle !== "done") refuse();
|
|
127
|
+
const run = settled.run_id;
|
|
128
|
+
if (!id.test(run) || settled.enrollment_event_id !== enrollmentEventIdFor(child.task_id, settled.created_at) ||
|
|
129
|
+
Date.parse(settled.created_at) > Date.parse(record.updated_at)) refuse();
|
|
130
130
|
const pair = readAuditTaskPair(root, child.task_id, run);
|
|
131
131
|
if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4") refuse();
|
|
132
132
|
const r = pair.record;
|
|
@@ -134,22 +134,22 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
|
|
|
134
134
|
r.git_base_head !== record.base_head || pair.proof.terminal_lifecycle !== "done") refuse();
|
|
135
135
|
const terminal = r.history.at(-1);
|
|
136
136
|
if (terminal?.type !== "complete" || terminal.from_state !== "active:frozen" || terminal.to_state !== "done:frozen" || terminal.id !== pair.proof.terminal_event_id) refuse();
|
|
137
|
-
const recordPath =
|
|
138
|
-
const proofPath =
|
|
139
|
-
if (!capture(recordPath).equals(Buffer.from(
|
|
137
|
+
const recordPath = auditRunRecordPath(child.task_id, run);
|
|
138
|
+
const proofPath = auditRunTerminalProofPath(child.task_id, run);
|
|
139
|
+
if (!capture(recordPath).equals(Buffer.from(settled.record_json))) refuse();
|
|
140
140
|
const capturedProof = JSON.parse(decode(capture(proofPath)));
|
|
141
|
-
if (!matchesLocalProof(
|
|
142
|
-
const identity =
|
|
141
|
+
if (!matchesLocalProof(settled.proof_json, capturedProof) || !matchesLocalProof(settled.proof_json, pair.proof)) refuse();
|
|
142
|
+
const identity = taskDeliveryIdentity(root, r);
|
|
143
143
|
const projection = await projectAssurance(root, child.task_id, (cwd, task) => {
|
|
144
144
|
if (task.contract !== "assurance_kernel/task_record/v4" || !task.git_base_head) refuse();
|
|
145
|
-
return
|
|
145
|
+
return taskDeliveryIdentity(cwd, task);
|
|
146
146
|
});
|
|
147
147
|
if (projection.error || projection.claim || projection.projection.run_id !== run || projection.projection.lifecycle !== "done" ||
|
|
148
148
|
!projection.projection.completion_ready || projection.projection.intent_content_hash !== currentHash || projection.projection.diff_hash !== identity.diff_hash ||
|
|
149
149
|
!projection.projection.fresh_approval_kinds.includes("qa") ||
|
|
150
150
|
(projection.projection.risk !== "routine" && !projection.projection.fresh_approval_kinds.includes("review"))) refuse();
|
|
151
151
|
scope = r.intent_snapshot.scope_hint;
|
|
152
|
-
changed.push({ task: child.task_id, run, identity: JSON.stringify(identity),
|
|
152
|
+
changed.push({ task: child.task_id, run, identity: JSON.stringify(identity), settled: JSON.stringify(settled) });
|
|
153
153
|
}
|
|
154
154
|
if (computeBatchPlanDigest(oldChildren) !== record.plan_digest || changed.length !== 1 || scope === null) refuse();
|
|
155
155
|
const confirmedScope = scope;
|
|
@@ -162,7 +162,7 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
|
|
|
162
162
|
for (const entry of entries) {
|
|
163
163
|
const code = entry.slice(0, 2), path = entry.slice(3);
|
|
164
164
|
if (code[1] !== " " || !["A", "M", "D"].includes(code[0]!) ||
|
|
165
|
-
(!path.startsWith(
|
|
165
|
+
(!path.startsWith(`${auditRunDirPath(owner.task, owner.run)}/`) &&
|
|
166
166
|
!confirmedScope.some(s => pathMatchesScope(path, s)))) refuse();
|
|
167
167
|
}
|
|
168
168
|
const flagsBytes = git(root, ["ls-files", "-v"]);
|
|
@@ -173,11 +173,15 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
|
|
|
173
173
|
if (head() !== record.base_head || branch() !== record.branch) refuse();
|
|
174
174
|
const assertDeliveryUnchanged = () => {
|
|
175
175
|
for (const c of changed) {
|
|
176
|
+
let settled: SettledRunEvidence | null;
|
|
177
|
+
try {
|
|
178
|
+
settled = readSettledRunEvidence(root, c.task);
|
|
179
|
+
} catch { refuse(); }
|
|
180
|
+
if (!settled || settled.run_id !== c.run || JSON.stringify(settled) !== c.settled) refuse();
|
|
176
181
|
const pair = readAuditTaskPair(root, c.task, c.run);
|
|
177
182
|
if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4" || !pair.record.git_base_head) refuse();
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
if (localRunId(root, c.task) !== c.run || JSON.stringify(local) !== c.local || JSON.stringify(diff) !== c.identity) refuse();
|
|
183
|
+
const diff = taskDeliveryIdentity(root, pair.record);
|
|
184
|
+
if (JSON.stringify(diff) !== c.identity) refuse();
|
|
181
185
|
}
|
|
182
186
|
};
|
|
183
187
|
const assertUnchanged = () => {
|