immune-brain 4.4.1 → 4.6.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 +3 -2
- 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 +0 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +47 -2
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +23 -18
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -3
- package/plugins/immune-brain/dist/BASELINE.md +18 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +1401 -1101
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -0
- package/plugins/immune-brain/dist/imm-brainstorm.md +21 -0
- package/plugins/immune-brain/dist/imm-loop.md +52 -6
- package/plugins/immune-brain/dist/imm-planner.md +102 -13
- package/plugins/immune-brain/dist/role-prompts/code-review.md +19 -0
- package/plugins/immune-brain/dist/role-prompts/qa.md +13 -1
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +55 -2
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +73 -27
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +3 -4
- package/plugins/immune-brain/runtime/claude/review_host.ts +15 -0
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +956 -634
- package/plugins/immune-brain/runtime/kernel/application.ts +2 -5
- 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 +1 -2
- 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 +21 -12
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
- package/plugins/immune-brain/runtime/kernel/storage.ts +8 -3
- 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/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +19 -0
- package/plugins/immune-brain/runtime/prompts/qa.md +13 -1
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +9 -1
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +152 -46
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +193 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +112 -122
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +57 -16
- package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
- package/plugins/immune-brain/runtime/v4_runtime.ts +54 -0
- package/plugins/immune-brain/runtime/workspace_scope.ts +75 -12
- package/plugins/immune-brain/skills/BASELINE.md +18 -1
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +5 -0
- package/plugins/immune-brain/skills/imm-loop/SKILL.md +4 -2
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +10 -1
- package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +0 -33
|
@@ -19,7 +19,6 @@ import type {
|
|
|
19
19
|
} from "./types";
|
|
20
20
|
|
|
21
21
|
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
22
|
-
export const DEFAULT_DEADLINE_MS = 8 * 60 * 60 * 1_000;
|
|
23
22
|
const DEFAULT_QA_FAILURE_LIMIT = 2;
|
|
24
23
|
|
|
25
24
|
/**
|
|
@@ -123,7 +122,7 @@ function dependencyClosures(order: GithubInitiativeObservation["tasks"]): Map<st
|
|
|
123
122
|
return closures;
|
|
124
123
|
}
|
|
125
124
|
|
|
126
|
-
function budget(input: ProjectBatchPlanInput, enrollableCount: number
|
|
125
|
+
function budget(input: ProjectBatchPlanInput, enrollableCount: number): BatchPlanBudget {
|
|
127
126
|
const maxChildren = input.budget?.max_children === undefined
|
|
128
127
|
? positiveSafeInteger(enrollableCount, "budget.max_children")
|
|
129
128
|
: positiveSafeInteger(input.budget.max_children, "budget.max_children");
|
|
@@ -132,12 +131,7 @@ function budget(input: ProjectBatchPlanInput, enrollableCount: number, confirmat
|
|
|
132
131
|
const qaFailureLimit = input.budget?.qa_failure_limit === undefined
|
|
133
132
|
? DEFAULT_QA_FAILURE_LIMIT
|
|
134
133
|
: positiveSafeInteger(input.budget.qa_failure_limit, "budget.qa_failure_limit");
|
|
135
|
-
|
|
136
|
-
? new Date(confirmationTime.milliseconds + DEFAULT_DEADLINE_MS).toISOString()
|
|
137
|
-
: timestamp(input.budget.deadline_at, "budget.deadline_at").iso;
|
|
138
|
-
if (Date.parse(deadline) <= confirmationTime.milliseconds)
|
|
139
|
-
throw new Error("budget.deadline_at must be later than confirmation_time");
|
|
140
|
-
return { max_children: maxChildren, deadline_at: deadline, qa_failure_limit: qaFailureLimit };
|
|
134
|
+
return { max_children: maxChildren, qa_failure_limit: qaFailureLimit };
|
|
141
135
|
}
|
|
142
136
|
|
|
143
137
|
export async function projectBatchPlan(
|
|
@@ -238,6 +232,6 @@ export async function projectBatchPlan(
|
|
|
238
232
|
children,
|
|
239
233
|
enrollable,
|
|
240
234
|
plan_digest: planDigest,
|
|
241
|
-
budget: budget(input, enrollable.length
|
|
235
|
+
budget: budget(input, enrollable.length),
|
|
242
236
|
};
|
|
243
237
|
}
|
|
@@ -22,7 +22,8 @@ import { readGitHead } from "../kernel/pi_canary_prepare";
|
|
|
22
22
|
import { readTaskIntent } from "../kernel/intent";
|
|
23
23
|
import { localRunId, readAuditTaskPair, readTaskRecordRaw, readWorkspaceStateRaw, reconcileKernelAuthority } from "../kernel/storage";
|
|
24
24
|
import { pathMatchesScope } from "../workspace_scope";
|
|
25
|
-
import {
|
|
25
|
+
import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHead, type ReconfirmationSnapshot } from "./batch_reconfirmation";
|
|
26
|
+
import { projectBatchPlan } from "./batch_plan";
|
|
26
27
|
import { batchReason, type BatchReasonKey } from "./batch_reasons";
|
|
27
28
|
import { isTerminalBatchState, readBatchRunState, type BatchRunStateRecord } from "./batch_state";
|
|
28
29
|
import { readRunRowByTask, withKernelRead } from "../kernel/sqlite_store";
|
|
@@ -44,6 +45,8 @@ export interface BatchPreflightOptions {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export interface BatchPreflightProjection {
|
|
48
|
+
/** Captured observations only; never serialized or used as capability. */
|
|
49
|
+
reconfirmation?: ReconfirmationSnapshot;
|
|
47
50
|
initiative_slug: string;
|
|
48
51
|
batch_branch: string;
|
|
49
52
|
is_resuming: boolean;
|
|
@@ -95,6 +98,29 @@ export function readActiveClaimTaskId(root: string): string | null {
|
|
|
95
98
|
return workspace.state.current_working || (claim?.lifecycle_status === "active" ? claim.task_id : null);
|
|
96
99
|
}
|
|
97
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
|
+
|
|
98
124
|
/**
|
|
99
125
|
* The batch record for this initiative, or a corruption marker. A terminal
|
|
100
126
|
* record (completed, budget_stopped, failed, rejected) is not active: returning
|
|
@@ -179,10 +205,78 @@ export function findSettledBatchRecord(root: string, initiativeSlug: string): Ba
|
|
|
179
205
|
return newest;
|
|
180
206
|
}
|
|
181
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
|
+
|
|
182
266
|
/** The HEAD a resumable batch must still sit on: its last child commit, or its base. */
|
|
183
|
-
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 {
|
|
184
272
|
const commits = Array.isArray(record.commits) ? (record.commits as string[]) : [];
|
|
185
|
-
|
|
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;
|
|
186
280
|
}
|
|
187
281
|
|
|
188
282
|
/**
|
|
@@ -231,9 +325,9 @@ export function isOwnBatchClaim(
|
|
|
231
325
|
return false;
|
|
232
326
|
}
|
|
233
327
|
if (!rec) return false;
|
|
234
|
-
const lineageHeads = [existingBatch.base_head]
|
|
235
|
-
Array.isArray(existingBatch.commits) ? existingBatch.commits : []
|
|
236
|
-
|
|
328
|
+
const lineageHeads = [existingBatch.base_head]
|
|
329
|
+
.concat(Array.isArray(existingBatch.commits) ? existingBatch.commits : [])
|
|
330
|
+
.concat((existingBatch.adopted_heads ?? []).map((adoption) => adoption.to));
|
|
237
331
|
if (!lineageHeads.includes(rec.git_base_head)) return false;
|
|
238
332
|
if (claim.enrollment_event_id !== `enroll-${taskId}-${claim.created_at}`) return false;
|
|
239
333
|
const createdAt = Date.parse(claim.created_at);
|
|
@@ -291,8 +385,8 @@ function porcelainEntries(root: string): Array<{ code: string; path: string }> |
|
|
|
291
385
|
// an unstaged modification (" M path") and silently mis-scoped the path.
|
|
292
386
|
const statusProc = spawnSync(
|
|
293
387
|
"git",
|
|
294
|
-
["-C", root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"],
|
|
295
|
-
{ encoding: "utf8" },
|
|
388
|
+
["-C", root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all", "--ignore-submodules=none"],
|
|
389
|
+
{ encoding: "utf8", env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } },
|
|
296
390
|
);
|
|
297
391
|
if (statusProc.status !== 0) return null;
|
|
298
392
|
const entries: Array<{ code: string; path: string }> = [];
|
|
@@ -344,11 +438,11 @@ async function projectPlanSurface(input: {
|
|
|
344
438
|
let budget: BatchPlanBudget;
|
|
345
439
|
|
|
346
440
|
if (isResuming && existingBatch) {
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
441
|
+
// A resume keeps the confirmed count limits of the persisted record.
|
|
442
|
+
budget = {
|
|
443
|
+
max_children: existingBatch.budget.max_children,
|
|
444
|
+
qa_failure_limit: existingBatch.budget.qa_failure_limit,
|
|
445
|
+
};
|
|
352
446
|
try {
|
|
353
447
|
recoveryChildren = existingBatch.children.map((c) => {
|
|
354
448
|
const intentPath = `docs/plans/${c.task_id}.intent.json`;
|
|
@@ -552,22 +646,26 @@ export async function projectBatchPreflight(
|
|
|
552
646
|
});
|
|
553
647
|
if (!planSurface.ok)
|
|
554
648
|
return reject(planSurface.key, planSurface.detail);
|
|
649
|
+
let reconfirmation: ReconfirmationSnapshot | undefined;
|
|
650
|
+
if (activeRecord && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
|
|
651
|
+
try { reconfirmation = await captureBatchReconfirmation(root, activeRecord, planSurface.surface.recovery_children); }
|
|
652
|
+
catch { return reject("plan_projection_failed", "batch plan reconfirmation is not eligible"); }
|
|
653
|
+
}
|
|
555
654
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
risk_by_task: planSurface.surface.risk_by_task,
|
|
568
|
-
excluded: planSurface.surface.excluded,
|
|
569
|
-
},
|
|
655
|
+
const projection: BatchPreflightProjection = {
|
|
656
|
+
initiative_slug: initiativeSlug,
|
|
657
|
+
batch_branch: batchBranch,
|
|
658
|
+
is_resuming: isResuming,
|
|
659
|
+
existing_batch: existingBatch,
|
|
660
|
+
base_head: baseHead,
|
|
661
|
+
budget: planSurface.surface.budget,
|
|
662
|
+
plan_digest: planSurface.surface.plan_digest,
|
|
663
|
+
recovery_children: planSurface.surface.recovery_children,
|
|
664
|
+
risk_by_task: planSurface.surface.risk_by_task,
|
|
665
|
+
excluded: planSurface.surface.excluded,
|
|
570
666
|
};
|
|
667
|
+
if (reconfirmation) Object.defineProperty(projection, "reconfirmation", { value: reconfirmation });
|
|
668
|
+
return { ok: true, projection };
|
|
571
669
|
}
|
|
572
670
|
|
|
573
671
|
/**
|
|
@@ -628,7 +726,6 @@ export interface BatchConfirmationFacts {
|
|
|
628
726
|
batch_branch: string;
|
|
629
727
|
plan_digest: string;
|
|
630
728
|
budget: BatchPlanBudget;
|
|
631
|
-
expires_at: string;
|
|
632
729
|
reuse_blockers: string[];
|
|
633
730
|
children: Array<{ task_id: string; slice_id: string; risk: string; status: BatchPlanChildStatus }>;
|
|
634
731
|
excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
|
|
@@ -649,7 +746,6 @@ export type BatchAuthorizationOutcome<HostRejection> =
|
|
|
649
746
|
batch_id: string;
|
|
650
747
|
reuse_authorization: boolean;
|
|
651
748
|
reuse_blockers: string[];
|
|
652
|
-
expires_at: string;
|
|
653
749
|
binding: BatchAuthorizationBinding;
|
|
654
750
|
}
|
|
655
751
|
| { outcome: "rejected"; rejection: BatchPreflightRejection }
|
|
@@ -671,7 +767,7 @@ export interface BatchAuthorizationOptions<HostRejection> {
|
|
|
671
767
|
|
|
672
768
|
/**
|
|
673
769
|
* The one authorization flow both Host adapters call after the shared
|
|
674
|
-
* preflight: ADR-0005's reuse
|
|
770
|
+
* preflight: ADR-0005's reuse decision, the literal-user gate, the
|
|
675
771
|
* post-gate claim/drift cascade, and the BatchAuthorizationBinding. A Host
|
|
676
772
|
* supplies its gate, its confirmation reference, and its nonce; every decision
|
|
677
773
|
* below stays Host-independent.
|
|
@@ -689,10 +785,6 @@ export async function authorizeBatch<HostRejection>(
|
|
|
689
785
|
} = projection;
|
|
690
786
|
const existingBatch = projection.existing_batch;
|
|
691
787
|
|
|
692
|
-
// ADR-0005 Decision 1: one Batch Authorization spans the work it authorizes,
|
|
693
|
-
// so its expiry is the deadline the literal user confirmed rather than a fixed
|
|
694
|
-
// window that lapses while a child is parked on a foreground Review.
|
|
695
|
-
const isExistingExpired = isResuming && Date.parse(existingBatch!.authorization_expires_at) <= Date.now();
|
|
696
788
|
const branchBefore = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
|
|
697
789
|
if (branchBefore.status !== 0)
|
|
698
790
|
return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
|
|
@@ -700,17 +792,31 @@ export async function authorizeBatch<HostRejection>(
|
|
|
700
792
|
// ADR-0005 Decision 1: a resume of an intact, still-binding authorization
|
|
701
793
|
// reuses it instead of opening a second native gate. Anything that no longer
|
|
702
794
|
// binds falls through to the gate below, which names the reason.
|
|
795
|
+
const ownsUnpersistedHead = isResuming && existingBatch !== null && existingBatch.plan_digest === planDigest &&
|
|
796
|
+
expectedBatchHead(existingBatch) !== baseHead && ownUnpersistedBatchHead(root, existingBatch, baseHead);
|
|
797
|
+
// A fast-forward on the batch branch is the user's own work: the runner adopts
|
|
798
|
+
// it, so it neither blocks reuse nor counts as moved provenance.
|
|
799
|
+
const fastForwardsRecordedHead = isResuming && existingBatch !== null && existingBatch.branch === batchBranch &&
|
|
800
|
+
expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead &&
|
|
801
|
+
classifyBatchLineage({
|
|
802
|
+
root,
|
|
803
|
+
branch: batchBranch,
|
|
804
|
+
expectedHead: expectedBatchHead(existingBatch),
|
|
805
|
+
childCommits: existingBatch.commits,
|
|
806
|
+
batchId: existingBatch.batch_id,
|
|
807
|
+
}).kind === "fast_forward";
|
|
808
|
+
if (isResuming && existingBatch && !existingBatch.commits.length && existingBatch.children[0]?.state === "settled" &&
|
|
809
|
+
expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead && !fastForwardsRecordedHead)
|
|
810
|
+
return { outcome: "rejected", rejection: batchRejection("head_moved", "first unpersisted commit provenance is invalid") };
|
|
703
811
|
const reuseBlockers: string[] = [];
|
|
704
812
|
if (isResuming && existingBatch) {
|
|
705
|
-
if (isExistingExpired) reuseBlockers.push("batch_authorization_expired");
|
|
706
|
-
if (Date.parse(existingBatch.budget.deadline_at) <= Date.now()) reuseBlockers.push("batch_budget_expired");
|
|
707
813
|
if (existingBatch.batch_state !== "running") reuseBlockers.push("batch_not_running");
|
|
708
814
|
if (existingBatch.plan_digest !== planDigest) reuseBlockers.push("batch_plan_digest_changed");
|
|
709
815
|
if (existingBatch.branch !== batchBranch) reuseBlockers.push("batch_branch_changed");
|
|
710
|
-
if (expectedBatchHead(existingBatch) !== baseHead
|
|
816
|
+
if (expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead && !fastForwardsRecordedHead)
|
|
817
|
+
reuseBlockers.push("batch_head_lineage_moved");
|
|
711
818
|
}
|
|
712
819
|
const reuseAuthorization = isResuming && reuseBlockers.length === 0;
|
|
713
|
-
const expiresAt = reuseAuthorization ? existingBatch!.authorization_expires_at : budget.deadline_at;
|
|
714
820
|
|
|
715
821
|
const batchId = isResuming && existingBatch ? existingBatch.batch_id : `batch-${initiativeSlug}-${randomUUID()}`;
|
|
716
822
|
const facts: BatchConfirmationFacts = {
|
|
@@ -718,7 +824,6 @@ export async function authorizeBatch<HostRejection>(
|
|
|
718
824
|
batch_branch: batchBranch,
|
|
719
825
|
plan_digest: planDigest,
|
|
720
826
|
budget,
|
|
721
|
-
expires_at: expiresAt,
|
|
722
827
|
reuse_blockers: reuseBlockers,
|
|
723
828
|
children: projection.recovery_children.map((child) => ({
|
|
724
829
|
task_id: child.task_id,
|
|
@@ -753,7 +858,7 @@ export async function authorizeBatch<HostRejection>(
|
|
|
753
858
|
if (drift.plan_digest !== planDigest)
|
|
754
859
|
return { outcome: "rejected", rejection: batchRejection("plan_changed", drift.plan_digest) };
|
|
755
860
|
if (drift.base_head === null) return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
|
|
756
|
-
if (drift.base_head !== baseHead)
|
|
861
|
+
if (drift.base_head !== baseHead || (ownsUnpersistedHead && !ownUnpersistedBatchHead(root, existingBatch!, baseHead)))
|
|
757
862
|
return { outcome: "rejected", rejection: batchRejection("head_moved", drift.base_head) };
|
|
758
863
|
|
|
759
864
|
// The drift projection's own claim read happened before its plan read; this
|
|
@@ -765,8 +870,8 @@ export async function authorizeBatch<HostRejection>(
|
|
|
765
870
|
if (finalClaimTaskId && !finalOwnClaim)
|
|
766
871
|
return { outcome: "rejected", rejection: batchRejection("claim_appeared_during_confirmation", finalClaimTaskId) };
|
|
767
872
|
|
|
768
|
-
// Confirmation cannot adopt a changed record
|
|
769
|
-
//
|
|
873
|
+
// Confirmation cannot adopt a changed record after the user answers. Re-read
|
|
874
|
+
// the record and branch after the async checks.
|
|
770
875
|
if (isResuming) {
|
|
771
876
|
const current = findExistingActiveBatch(root, initiativeSlug);
|
|
772
877
|
if (!current || current.corrupt || JSON.stringify(current.record) !== JSON.stringify(existingBatch))
|
|
@@ -775,9 +880,11 @@ export async function authorizeBatch<HostRejection>(
|
|
|
775
880
|
const branchAfter = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
|
|
776
881
|
if (branchAfter.status !== 0 || branchAfter.stdout !== branchBefore.stdout)
|
|
777
882
|
return { outcome: "rejected", rejection: batchRejection("confirmation_failed", "Git branch moved after native confirmation") };
|
|
778
|
-
if (Date.parse(expiresAt) <= Date.now())
|
|
779
|
-
return { outcome: "rejected", rejection: batchRejection("confirmation_failed", "confirmed batch deadline expired before authorization issuance") };
|
|
780
883
|
|
|
884
|
+
if (projection.reconfirmation) {
|
|
885
|
+
try { projection.reconfirmation.assertUnchanged(); }
|
|
886
|
+
catch { return { outcome: "rejected", rejection: batchRejection("plan_changed") }; }
|
|
887
|
+
}
|
|
781
888
|
const binding: BatchAuthorizationBinding = {
|
|
782
889
|
batch_id: batchId,
|
|
783
890
|
initiative_slug: initiativeSlug,
|
|
@@ -787,15 +894,14 @@ export async function authorizeBatch<HostRejection>(
|
|
|
787
894
|
budget,
|
|
788
895
|
actor_id: LITERAL_USER_ACTOR_ID,
|
|
789
896
|
confirmation_ref: options.confirmationRef({ batch_id: batchId, request_id: requestId }),
|
|
790
|
-
expires_at: expiresAt,
|
|
791
897
|
nonce: options.nonce,
|
|
792
898
|
};
|
|
899
|
+
if (projection.reconfirmation) retainReconfirmation(binding.nonce, projection.reconfirmation);
|
|
793
900
|
return {
|
|
794
901
|
outcome: "authorized",
|
|
795
902
|
batch_id: batchId,
|
|
796
903
|
reuse_authorization: reuseAuthorization,
|
|
797
904
|
reuse_blockers: reuseBlockers,
|
|
798
|
-
expires_at: expiresAt,
|
|
799
905
|
binding,
|
|
800
906
|
};
|
|
801
907
|
}
|
|
@@ -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",
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Read-only eligibility for a first, zero-commit plan reconfirmation.
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { computeBatchPlanDigest, type BatchPlanChild as DigestChild } from "../kernel/batch_authority";
|
|
4
|
+
import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
|
|
5
|
+
import { projectAssurance } from "../kernel/assurance_projection";
|
|
6
|
+
import { localRunId, readAuditTaskPair, readSecureProjectBytes, readWorkspaceStateRaw } from "../kernel/storage";
|
|
7
|
+
import { readRunRowByTask, withKernelRead } from "../kernel/sqlite_store";
|
|
8
|
+
import { taskRevisionIdentity, taskCommitRevisionIdentity, pathMatchesScope } from "../workspace_scope";
|
|
9
|
+
import { projectTask } from "../kernel/completion";
|
|
10
|
+
import { isTerminalBatchState, type BatchRunStateRecord } from "./batch_state";
|
|
11
|
+
import type { BatchPlanChild } from "./types";
|
|
12
|
+
|
|
13
|
+
const oid = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
14
|
+
const id = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
15
|
+
const batchAuthor = process.env.GIT_AUTHOR_NAME || "Immune-Brain Batch";
|
|
16
|
+
const decode = (bytes: Uint8Array) => new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
17
|
+
function refuse(): never { throw new Error("plan_digest mismatch: batch plan reconfirmation is not eligible"); }
|
|
18
|
+
function matchesLocalProof(localJson: string | null, exported: object): boolean {
|
|
19
|
+
return localJson !== null && JSON.stringify(Object.entries(JSON.parse(localJson)).sort()) === JSON.stringify(Object.entries(exported).sort());
|
|
20
|
+
}
|
|
21
|
+
function git(root: string, args: string[]): string {
|
|
22
|
+
const r = spawnSync("git", ["-C", root, ...args], { encoding: "buffer", maxBuffer: 262144, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } });
|
|
23
|
+
if (r.status !== 0) refuse();
|
|
24
|
+
return decode(r.stdout);
|
|
25
|
+
}
|
|
26
|
+
// Recognize only the first commit/persist crash window; never query Git with
|
|
27
|
+
// an OID read from mutable evidence. The existing Git port performs final adoption.
|
|
28
|
+
export function ownUnpersistedBatchHead(root: string, record: BatchRunStateRecord, head: string): boolean {
|
|
29
|
+
try {
|
|
30
|
+
if (!oid.test(head) || !oid.test(record.base_head) || !id.test(record.batch_id) || record.commits.length ||
|
|
31
|
+
record.children[0]?.state !== "settled" || record.children.some(c => c.commit !== null)) return false;
|
|
32
|
+
const task = record.children[0].task_id;
|
|
33
|
+
if (!id.test(task)) return false;
|
|
34
|
+
const evidence = JSON.parse(decode(readSecureProjectBytes(root, `.imm/state/batches/commits/${record.batch_id}-${task}.json`)));
|
|
35
|
+
if (evidence.contract !== "assurance_kernel/batch_commit_evidence/v1" || evidence.batch_id !== record.batch_id ||
|
|
36
|
+
evidence.task_id !== task || evidence.commit !== head || evidence.parent_head !== record.base_head) return false;
|
|
37
|
+
if (git(root, ["symbolic-ref", "--short", "HEAD"]).trim() !== record.branch) return false;
|
|
38
|
+
const parents = git(root, ["rev-list", "--parents", "-n", "1", head]).trim().split(/\s+/).slice(1);
|
|
39
|
+
if (parents.length !== 1 || parents[0] !== record.base_head) return false;
|
|
40
|
+
const metadata = git(root, ["show", "-s", "--format=%an%x00%s%x00%(trailers:key=Immune-Brain-Batch,valueonly)", head]).split("\0");
|
|
41
|
+
if (metadata[0] !== batchAuthor || !metadata[1]?.startsWith(`imm(${task}):`) || metadata[2]?.trim() !== record.batch_id) return false;
|
|
42
|
+
const run = localRunId(root, task);
|
|
43
|
+
if (!run || !id.test(run)) return false;
|
|
44
|
+
const pair = readAuditTaskPair(root, task, run);
|
|
45
|
+
if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4" || pair.record.lifecycle !== "done" || pair.proof.terminal_lifecycle !== "done") return false;
|
|
46
|
+
const local = withKernelRead(root, db => readRunRowByTask(db, task));
|
|
47
|
+
if (!local || local.run_id !== run || local.state !== "done" || !matchesLocalProof(local.terminal_proof_json, pair.proof)) return false;
|
|
48
|
+
for (const file of ["task-record.json", "terminal-proof.json"]) {
|
|
49
|
+
const path = `.imm/audit/${task}/${run}/${file}`;
|
|
50
|
+
if (!readSecureProjectBytes(root, path).equals(Buffer.from(git(root, ["show", `${head}:${path}`])))) return false;
|
|
51
|
+
}
|
|
52
|
+
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(`.imm/audit/${task}/${run}/`) &&
|
|
54
|
+
!pair.record.intent_snapshot.scope_hint.some(s => pathMatchesScope(path, s)))) return false;
|
|
55
|
+
const identity = taskCommitRevisionIdentity(root, pair.record.intent_snapshot.scope_hint, pair.record.git_base_head, head);
|
|
56
|
+
if (!projectTask(pair.record.intent_snapshot, pair.record, identity.diff_hash, pair.record.intent_ref.content_hash, identity.changed_paths).complete) return false;
|
|
57
|
+
return git(root, ["rev-parse", "HEAD"]).trim() === head;
|
|
58
|
+
} catch { return false; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ReconfirmationSnapshot {
|
|
62
|
+
root: string;
|
|
63
|
+
plan_digest: string;
|
|
64
|
+
stateBytes: Buffer;
|
|
65
|
+
/** Synchronous read-only check repeated under the state owner's lock. */
|
|
66
|
+
assertUnchanged(): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Ephemeral captured observations, not authority: the runner independently
|
|
70
|
+
// validates the native capability. Entries are discarded on application.
|
|
71
|
+
const confirmations = new Map<string, ReconfirmationSnapshot>();
|
|
72
|
+
export function retainReconfirmation(nonce: string, snapshot: ReconfirmationSnapshot): void {
|
|
73
|
+
if (confirmations.has(nonce)) refuse();
|
|
74
|
+
confirmations.set(nonce, snapshot);
|
|
75
|
+
}
|
|
76
|
+
export function takeReconfirmation(nonce: string): ReconfirmationSnapshot {
|
|
77
|
+
const found = confirmations.get(nonce); confirmations.delete(nonce);
|
|
78
|
+
if (!found) refuse();
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function captureBatchReconfirmation(root: string, record: BatchRunStateRecord, children: BatchPlanChild[]): Promise<ReconfirmationSnapshot> {
|
|
83
|
+
if (!id.test(record.batch_id) || !oid.test(record.base_head) || isTerminalBatchState(record.batch_state) || record.commits.length ||
|
|
84
|
+
record.children.some(c => c.commit !== null || c.state === "committed") || record.children.length !== children.length || !children.length) refuse();
|
|
85
|
+
const assertNoCommitEvidence = () => {
|
|
86
|
+
for (const child of record.children) {
|
|
87
|
+
if (!id.test(child.task_id)) refuse();
|
|
88
|
+
const path = `.imm/state/batches/commits/${record.batch_id}-${child.task_id}.json`;
|
|
89
|
+
let missing = false;
|
|
90
|
+
try { readSecureProjectBytes(root, path); }
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error instanceof Error && error.message === `source_missing: ${path}`) missing = true;
|
|
93
|
+
else refuse();
|
|
94
|
+
}
|
|
95
|
+
if (!missing) refuse();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
assertNoCommitEvidence();
|
|
99
|
+
const statePath = `.imm/state/batches/${record.batch_id}.json`;
|
|
100
|
+
const stateBytes = readSecureProjectBytes(root, statePath);
|
|
101
|
+
if (JSON.stringify(JSON.parse(decode(stateBytes))) !== JSON.stringify(record)) refuse();
|
|
102
|
+
const files = new Map<string, Buffer>();
|
|
103
|
+
const capture = (path: string) => { const bytes = readSecureProjectBytes(root, path); files.set(path, bytes); return bytes; };
|
|
104
|
+
const oldChildren: DigestChild[] = [];
|
|
105
|
+
const changed: Array<{ task: string; run: string; identity: string; local: string }> = [];
|
|
106
|
+
let scope: string[] | null = null;
|
|
107
|
+
const workspace = readWorkspaceStateRaw(root);
|
|
108
|
+
if (workspace.state.current_working !== null) refuse();
|
|
109
|
+
for (const [index, child] of children.entries()) {
|
|
110
|
+
const previous = record.children[index]!;
|
|
111
|
+
if (!id.test(child.task_id) || child.task_id !== previous.task_id || child.slice_id !== previous.slice_id ||
|
|
112
|
+
JSON.stringify(child.blocked_by) !== JSON.stringify(previous.blocked_by) || child.intent_path !== `docs/plans/${child.task_id}.intent.json`) refuse();
|
|
113
|
+
const old = parseTaskIntentV1(JSON.parse(git(root, ["show", `${record.base_head}:${child.intent_path}`])));
|
|
114
|
+
if (old.task_id !== child.task_id) refuse();
|
|
115
|
+
const oldHash = canonicalIntentHash(old);
|
|
116
|
+
oldChildren.push({ task_id: child.task_id, blocked_by: [...child.blocked_by], intent_path: child.intent_path, intent_revision: old.revision, intent_content_hash: oldHash });
|
|
117
|
+
const currentBytes = capture(child.intent_path);
|
|
118
|
+
const current = parseTaskIntentV1(JSON.parse(decode(currentBytes)));
|
|
119
|
+
const currentHash = canonicalIntentHash(current);
|
|
120
|
+
const tracked = readTaskIntent(root, child.task_id, child.intent_path);
|
|
121
|
+
if (currentHash !== tracked.content_hash || currentHash !== child.intent_content_hash || current.revision !== child.intent_revision) refuse();
|
|
122
|
+
if (currentHash === oldHash) continue;
|
|
123
|
+
if (scope !== null || previous.state === "pending" || previous.state === "skipped_blocked" || current.revision <= old.revision || current.goal !== old.goal || current.owner !== old.owner) refuse();
|
|
124
|
+
const run = localRunId(root, child.task_id);
|
|
125
|
+
if (!run || !id.test(run)) refuse();
|
|
126
|
+
const local = withKernelRead(root, db => readRunRowByTask(db, child.task_id));
|
|
127
|
+
if (!local || local.run_id !== run || local.state !== "done" || local.enrollment_event_id !== `enroll-${child.task_id}-${local.created_at}` ||
|
|
128
|
+
Date.parse(local.created_at) > Date.parse(record.updated_at)) refuse();
|
|
129
|
+
const pair = readAuditTaskPair(root, child.task_id, run);
|
|
130
|
+
if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4") refuse();
|
|
131
|
+
const r = pair.record;
|
|
132
|
+
if (r.lifecycle !== "done" || r.intent_ref.content_hash !== currentHash || canonicalIntentHash(r.intent_snapshot) !== currentHash ||
|
|
133
|
+
r.git_base_head !== record.base_head || pair.proof.terminal_lifecycle !== "done") refuse();
|
|
134
|
+
const terminal = r.history.at(-1);
|
|
135
|
+
if (terminal?.type !== "complete" || terminal.from_state !== "active:frozen" || terminal.to_state !== "done:frozen" || terminal.id !== pair.proof.terminal_event_id) refuse();
|
|
136
|
+
const recordPath = `.imm/audit/${child.task_id}/${run}/task-record.json`;
|
|
137
|
+
const proofPath = `.imm/audit/${child.task_id}/${run}/terminal-proof.json`;
|
|
138
|
+
if (!capture(recordPath).equals(Buffer.from(local.record_json))) refuse();
|
|
139
|
+
const capturedProof = JSON.parse(decode(capture(proofPath)));
|
|
140
|
+
if (!matchesLocalProof(local.terminal_proof_json, capturedProof) || !matchesLocalProof(local.terminal_proof_json, pair.proof)) refuse();
|
|
141
|
+
const identity = taskRevisionIdentity(root, r.intent_snapshot.scope_hint, r.git_base_head, r.task_id);
|
|
142
|
+
const projection = await projectAssurance(root, child.task_id, (cwd, task) => {
|
|
143
|
+
if (task.contract !== "assurance_kernel/task_record/v4" || !task.git_base_head) refuse();
|
|
144
|
+
return taskRevisionIdentity(cwd, task.intent_snapshot.scope_hint, task.git_base_head, task.task_id);
|
|
145
|
+
});
|
|
146
|
+
if (projection.error || projection.claim || projection.projection.run_id !== run || projection.projection.lifecycle !== "done" ||
|
|
147
|
+
!projection.projection.completion_ready || projection.projection.intent_content_hash !== currentHash || projection.projection.diff_hash !== identity.diff_hash ||
|
|
148
|
+
!projection.projection.fresh_approval_kinds.includes("qa") ||
|
|
149
|
+
(projection.projection.risk !== "routine" && !projection.projection.fresh_approval_kinds.includes("review"))) refuse();
|
|
150
|
+
scope = r.intent_snapshot.scope_hint;
|
|
151
|
+
changed.push({ task: child.task_id, run, identity: JSON.stringify(identity), local: JSON.stringify(local) });
|
|
152
|
+
}
|
|
153
|
+
if (computeBatchPlanDigest(oldChildren) !== record.plan_digest || changed.length !== 1 || scope === null) refuse();
|
|
154
|
+
const confirmedScope = scope;
|
|
155
|
+
const owner = changed[0]!;
|
|
156
|
+
const status = () => git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none"]);
|
|
157
|
+
const statusBytes = status();
|
|
158
|
+
const entries = statusBytes.split("\0").filter(Boolean);
|
|
159
|
+
// This bounded path refuses renames; staged regular edits/additions/deletions
|
|
160
|
+
// are enough for a revised child and cannot hide a second source path.
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
const code = entry.slice(0, 2), path = entry.slice(3);
|
|
163
|
+
if (code[1] !== " " || !["A", "M", "D"].includes(code[0]!) ||
|
|
164
|
+
(!path.startsWith(`.imm/audit/${owner.task}/${owner.run}/`) &&
|
|
165
|
+
!confirmedScope.some(s => pathMatchesScope(path, s)))) refuse();
|
|
166
|
+
}
|
|
167
|
+
const flagsBytes = git(root, ["ls-files", "-v"]);
|
|
168
|
+
const flags = flagsBytes.split("\n").filter(Boolean);
|
|
169
|
+
if (flags.some(line => line[0] === "S" || line[0] !== line[0]?.toUpperCase())) refuse();
|
|
170
|
+
const head = () => git(root, ["rev-parse", "HEAD"]).trim();
|
|
171
|
+
const branch = () => git(root, ["symbolic-ref", "--short", "HEAD"]).trim();
|
|
172
|
+
if (head() !== record.base_head || branch() !== record.branch) refuse();
|
|
173
|
+
const assertDeliveryUnchanged = () => {
|
|
174
|
+
for (const c of changed) {
|
|
175
|
+
const pair = readAuditTaskPair(root, c.task, c.run);
|
|
176
|
+
if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4" || !pair.record.git_base_head) refuse();
|
|
177
|
+
const local = withKernelRead(root, db => readRunRowByTask(db, c.task));
|
|
178
|
+
const diff = taskRevisionIdentity(root, pair.record.intent_snapshot.scope_hint, pair.record.git_base_head, c.task);
|
|
179
|
+
if (localRunId(root, c.task) !== c.run || JSON.stringify(local) !== c.local || JSON.stringify(diff) !== c.identity) refuse();
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const assertUnchanged = () => {
|
|
183
|
+
assertNoCommitEvidence();
|
|
184
|
+
if (!readSecureProjectBytes(root, statePath).equals(stateBytes) || head() !== record.base_head || branch() !== record.branch || status() !== statusBytes || git(root, ["ls-files", "-v"]) !== flagsBytes ||
|
|
185
|
+
readWorkspaceStateRaw(root).revision !== workspace.revision) refuse();
|
|
186
|
+
for (const [path, bytes] of files) if (!readSecureProjectBytes(root, path).equals(bytes)) refuse();
|
|
187
|
+
assertDeliveryUnchanged();
|
|
188
|
+
};
|
|
189
|
+
assertUnchanged();
|
|
190
|
+
const plan_digest = computeBatchPlanDigest(oldChildren.map((old, index) => ({ ...old,
|
|
191
|
+
intent_revision: children[index]!.intent_revision!, intent_content_hash: children[index]!.intent_content_hash! })));
|
|
192
|
+
return { root, plan_digest, stateBytes, assertUnchanged };
|
|
193
|
+
}
|