immune-brain 4.6.0 → 5.1.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 +374 -46
- package/README.zh-CN.md +384 -47
- package/package.json +2 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +42 -44
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +143 -474
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +78 -87
- 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 +8601 -6975
- package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +27 -0
- 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 +23 -8
- 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} +231 -10
- 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/dist/role-prompts/lane-steward.md +44 -0
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +83 -67
- 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/verdict_authority.ts +347 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +6 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +78 -373
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +26 -2
- package/plugins/immune-brain/runtime/claude/review_host.ts +48 -16
- package/plugins/immune-brain/runtime/claude/role_agents.ts +84 -0
- package/plugins/immune-brain/runtime/commands/kernel.ts +3 -6
- package/plugins/immune-brain/runtime/kernel/application.ts +6 -3
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +15 -8
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +5 -3
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +149 -5
- 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/local_initiative.ts +94 -0
- package/plugins/immune-brain/runtime/loop_contract.ts +77 -24
- 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/prompts/lane-steward.md +44 -0
- package/plugins/immune-brain/runtime/role_prompt_bridge.ts +42 -2
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +57 -27
- package/plugins/immune-brain/runtime/unattended/batch_integration.ts +387 -0
- package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +106 -0
- package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +909 -0
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +36 -11
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +53 -36
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +34 -29
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +46 -91
- package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +162 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +356 -8
- package/plugins/immune-brain/runtime/unattended/types.ts +21 -2
- package/plugins/immune-brain/runtime/workspace_scope.ts +34 -0
- 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 +11 -7
- package/plugins/immune-brain/skills/registry.yaml +9 -9
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -581
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { stableStringify } from "../canonical_json";
|
|
3
|
-
import { observeGithubInitiative
|
|
3
|
+
import { observeGithubInitiative } from "../github_issue_tracker";
|
|
4
|
+
import { hasLocalInitiative, observeLocalInitiative } from "../local_initiative";
|
|
4
5
|
import { readTaskTombstone } from "../kernel/backend_claim";
|
|
5
6
|
import {
|
|
6
7
|
observeTaskIntent,
|
|
@@ -8,12 +9,14 @@ import {
|
|
|
8
9
|
} from "../kernel/intent";
|
|
9
10
|
import { readTaskRecordRaw } from "../kernel/storage";
|
|
10
11
|
import { inspectSpecBinding, type SpecBindingInspection } from "../kernel/spec_binding";
|
|
12
|
+
import { projectParallelGroups } from "./batch_schedule";
|
|
11
13
|
import type {
|
|
12
14
|
BatchPlan,
|
|
13
15
|
BatchPlanBudget,
|
|
14
16
|
BatchPlanChild,
|
|
15
17
|
BatchPlanChildReason,
|
|
16
18
|
BatchPlanDigestChild,
|
|
19
|
+
InitiativeObservation,
|
|
17
20
|
InitiativeObservationReader,
|
|
18
21
|
ProjectBatchPlanInput,
|
|
19
22
|
} from "./types";
|
|
@@ -57,12 +60,22 @@ function timestamp(value: unknown, name: string): { milliseconds: number; iso: s
|
|
|
57
60
|
return { milliseconds, iso: new Date(milliseconds).toISOString() };
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
/**
|
|
64
|
+
* The Initiative's carrier decides the reader: a Local carrier file owns its
|
|
65
|
+
* slug and is read with no GitHub operation; every other slug is a GitHub one.
|
|
66
|
+
*/
|
|
67
|
+
export const observeInitiative: InitiativeObservationReader = async (root, initiativeSlug) =>
|
|
68
|
+
hasLocalInitiative(root, initiativeSlug)
|
|
69
|
+
? observeLocalInitiative(root, initiativeSlug)
|
|
70
|
+
: observeGithubInitiative(root, initiativeSlug);
|
|
71
|
+
|
|
72
|
+
function normalizeObservation(value: InitiativeObservation, initiativeSlug: string): InitiativeObservation {
|
|
73
|
+
const local = value?.contract === "immune_brain/local_initiative_observation/v1";
|
|
74
|
+
if (!value || (!local && value.contract !== "immune_brain/github_initiative_observation/v1"))
|
|
62
75
|
throw new Error("tracker returned an invalid Initiative observation contract");
|
|
63
76
|
if (value.initiative_id !== initiativeSlug)
|
|
64
77
|
throw new Error("tracker returned an observation for another Initiative");
|
|
65
|
-
if (!Number.isSafeInteger(value.issue_number) || value.issue_number <= 0)
|
|
78
|
+
if (!local && (!Number.isSafeInteger(value.issue_number) || value.issue_number <= 0))
|
|
66
79
|
throw new Error("tracker returned an invalid Initiative Issue number");
|
|
67
80
|
if (!Array.isArray(value.tasks)) throw new Error("tracker returned an invalid Initiative Task list");
|
|
68
81
|
const taskIds = new Set<string>();
|
|
@@ -71,7 +84,8 @@ function normalizeObservation(value: GithubInitiativeObservation, initiativeSlug
|
|
|
71
84
|
if (!task || typeof task !== "object") throw new Error(`tracker Task ${index} is invalid`);
|
|
72
85
|
if (!ID_PATTERN.test(task.task_id)) throw new Error(`tracker Task ${index} has an invalid task_id`);
|
|
73
86
|
if (!ID_PATTERN.test(task.slice_id)) throw new Error(`tracker Task ${index} has an invalid slice_id`);
|
|
74
|
-
|
|
87
|
+
const issueNumber = (task as { issue_number?: number }).issue_number;
|
|
88
|
+
if (!local && (!Number.isSafeInteger(issueNumber) || issueNumber! <= 0))
|
|
75
89
|
throw new Error(`tracker Task ${task.task_id} has an invalid Issue number`);
|
|
76
90
|
if (!Array.isArray(task.blocked_by) || task.blocked_by.some((id) => typeof id !== "string" || !ID_PATTERN.test(id)))
|
|
77
91
|
throw new Error(`tracker Task ${task.task_id} has invalid blocked_by dependencies`);
|
|
@@ -88,13 +102,15 @@ function normalizeObservation(value: GithubInitiativeObservation, initiativeSlug
|
|
|
88
102
|
const unknown = task.blocked_by.find((id) => !taskIds.has(id));
|
|
89
103
|
if (unknown) throw new Error(`tracker Task ${task.task_id} depends on unknown Task ${unknown}`);
|
|
90
104
|
}
|
|
91
|
-
return { ...value, tasks: tasks.sort((left, right) => compareIds(left.task_id, right.task_id)) };
|
|
105
|
+
return { ...value, tasks: tasks.sort((left, right) => compareIds(left.task_id, right.task_id)) } as InitiativeObservation;
|
|
92
106
|
}
|
|
93
107
|
|
|
94
|
-
|
|
95
|
-
|
|
108
|
+
type ObservedTask = { task_id: string; slice_id: string; blocked_by: string[] };
|
|
109
|
+
|
|
110
|
+
function dependencyOrder(observation: InitiativeObservation): ObservedTask[] {
|
|
111
|
+
const remaining = new Map<string, ObservedTask>(observation.tasks.map((task) => [task.task_id, task]));
|
|
96
112
|
const done = new Set<string>();
|
|
97
|
-
const order:
|
|
113
|
+
const order: ObservedTask[] = [];
|
|
98
114
|
while (remaining.size) {
|
|
99
115
|
const ready = [...remaining.values()]
|
|
100
116
|
.filter((task) => task.blocked_by.every((id) => done.has(id)))
|
|
@@ -109,7 +125,7 @@ function dependencyOrder(observation: GithubInitiativeObservation): GithubInitia
|
|
|
109
125
|
return order;
|
|
110
126
|
}
|
|
111
127
|
|
|
112
|
-
function dependencyClosures(order:
|
|
128
|
+
function dependencyClosures(order: ObservedTask[]): Map<string, string[]> {
|
|
113
129
|
const closures = new Map<string, string[]>();
|
|
114
130
|
for (const task of order) {
|
|
115
131
|
const closure = new Set<string>();
|
|
@@ -138,7 +154,7 @@ export async function projectBatchPlan(
|
|
|
138
154
|
root: string,
|
|
139
155
|
initiativeSlug: string,
|
|
140
156
|
input: ProjectBatchPlanInput,
|
|
141
|
-
readInitiative: InitiativeObservationReader =
|
|
157
|
+
readInitiative: InitiativeObservationReader = observeInitiative,
|
|
142
158
|
): Promise<BatchPlan> {
|
|
143
159
|
if (!ID_PATTERN.test(initiativeSlug)) throw new Error("initiative_slug is invalid");
|
|
144
160
|
if (!input || typeof input !== "object") throw new Error("batch plan input is required");
|
|
@@ -147,6 +163,7 @@ export async function projectBatchPlan(
|
|
|
147
163
|
const order = dependencyOrder(trackerObservation);
|
|
148
164
|
const closures = dependencyClosures(order);
|
|
149
165
|
const children: BatchPlanChild[] = [];
|
|
166
|
+
const scopeHints = new Map<string, string[]>();
|
|
150
167
|
for (const task of order) {
|
|
151
168
|
const blockedBy = closures.get(task.task_id)!;
|
|
152
169
|
const child = { task_id: task.task_id, slice_id: task.slice_id, blocked_by: blockedBy };
|
|
@@ -187,6 +204,7 @@ export async function projectBatchPlan(
|
|
|
187
204
|
});
|
|
188
205
|
continue;
|
|
189
206
|
}
|
|
207
|
+
scopeHints.set(task.task_id, [...read.intent.scope_hint]);
|
|
190
208
|
children.push({
|
|
191
209
|
...child,
|
|
192
210
|
status: "enrollable",
|
|
@@ -233,5 +251,12 @@ export async function projectBatchPlan(
|
|
|
233
251
|
enrollable,
|
|
234
252
|
plan_digest: planDigest,
|
|
235
253
|
budget: budget(input, enrollable.length),
|
|
254
|
+
...projectParallelGroups(
|
|
255
|
+
enrollable.map((child) => ({
|
|
256
|
+
task_id: child.task_id,
|
|
257
|
+
blocked_by: child.blocked_by,
|
|
258
|
+
scope_hint: scopeHints.get(child.task_id)!,
|
|
259
|
+
})),
|
|
260
|
+
),
|
|
236
261
|
};
|
|
237
262
|
}
|
|
@@ -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
27
|
import { projectBatchPlan } from "./batch_plan";
|
|
27
28
|
import { batchReason, type BatchReasonKey } from "./batch_reasons";
|
|
28
|
-
import { isTerminalBatchState,
|
|
29
|
-
import { readRunRowByTask, withKernelRead } from "../kernel/sqlite_store";
|
|
29
|
+
import { isLaneBatchRecord, isTerminalBatchState, readAnyBatchRunState, type AnyBatchRunStateRecord, type BatchRunStateRecord } from "./batch_state";
|
|
30
30
|
import type {
|
|
31
31
|
BatchPlanBudget,
|
|
32
32
|
BatchPlanChild,
|
|
@@ -50,7 +50,7 @@ export interface BatchPreflightProjection {
|
|
|
50
50
|
initiative_slug: string;
|
|
51
51
|
batch_branch: string;
|
|
52
52
|
is_resuming: boolean;
|
|
53
|
-
existing_batch:
|
|
53
|
+
existing_batch: AnyBatchRunStateRecord | null;
|
|
54
54
|
base_head: string;
|
|
55
55
|
budget: BatchPlanBudget;
|
|
56
56
|
plan_digest: string;
|
|
@@ -59,6 +59,9 @@ export interface BatchPreflightProjection {
|
|
|
59
59
|
risk_by_task: Record<string, string>;
|
|
60
60
|
/** Children the plan excluded, in plan order. Empty on a resume. */
|
|
61
61
|
excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
|
|
62
|
+
/** Start waves and serialized-by-scope children; empty on a resume. */
|
|
63
|
+
parallel_groups: string[][];
|
|
64
|
+
scope_conflicts: Array<{ task_id: string; overlaps_with: string[] }>;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
export type BatchPreflightOutcome =
|
|
@@ -109,8 +112,8 @@ export function findResumableBatchSlugForTask(root: string, taskId: string): str
|
|
|
109
112
|
for (const file of readdirSync(batchesDir)) {
|
|
110
113
|
if (!file.endsWith(".json")) continue;
|
|
111
114
|
try {
|
|
112
|
-
const record = JSON.parse(readFileSync(join(batchesDir, file), "utf8")) as
|
|
113
|
-
if (record?.contract !== "assurance_kernel/batch_run_state/v1") continue;
|
|
115
|
+
const record = JSON.parse(readFileSync(join(batchesDir, file), "utf8")) as AnyBatchRunStateRecord;
|
|
116
|
+
if (record?.contract !== "assurance_kernel/batch_run_state/v1" && record?.contract !== "assurance_kernel/batch_run_state/v2") continue;
|
|
114
117
|
if (record.batch_state !== "running" && record.batch_state !== "needs_human") continue;
|
|
115
118
|
if (record.children.some((child) => child.task_id === taskId && (child.state === "enrolled" || child.state === "settled")))
|
|
116
119
|
return record.initiative_slug;
|
|
@@ -128,7 +131,7 @@ export function findResumableBatchSlugForTask(root: string, taskId: string): str
|
|
|
128
131
|
*/
|
|
129
132
|
export type BatchRecordLookup =
|
|
130
133
|
| { corrupt: true; path: string }
|
|
131
|
-
| { corrupt: false; record:
|
|
134
|
+
| { corrupt: false; record: AnyBatchRunStateRecord }
|
|
132
135
|
| null;
|
|
133
136
|
|
|
134
137
|
export function findExistingActiveBatch(root: string, initiativeSlug: string): BatchRecordLookup {
|
|
@@ -144,8 +147,8 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
|
|
|
144
147
|
// initiative as batchless could authorize a parallel run.
|
|
145
148
|
return { corrupt: true, path: file };
|
|
146
149
|
}
|
|
147
|
-
const candidate = record as
|
|
148
|
-
if (candidate?.contract !== "assurance_kernel/batch_run_state/v1") continue;
|
|
150
|
+
const candidate = record as AnyBatchRunStateRecord;
|
|
151
|
+
if (candidate?.contract !== "assurance_kernel/batch_run_state/v1" && candidate?.contract !== "assurance_kernel/batch_run_state/v2") continue;
|
|
149
152
|
if (candidate.initiative_slug !== initiativeSlug) continue;
|
|
150
153
|
const validStates = new Set([
|
|
151
154
|
"prepared",
|
|
@@ -166,7 +169,7 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
|
|
|
166
169
|
}
|
|
167
170
|
if (isTerminalBatchState(candidate.batch_state)) continue;
|
|
168
171
|
try {
|
|
169
|
-
const record =
|
|
172
|
+
const record = readAnyBatchRunState(root, file.slice(0, -5));
|
|
170
173
|
if (record) return { corrupt: false, record };
|
|
171
174
|
} catch {
|
|
172
175
|
// Reuse the state owner's full validation before offering a renewal.
|
|
@@ -181,10 +184,10 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
|
|
|
181
184
|
* not a batch to resume. Its branch and commit lineage may be reused by a new,
|
|
182
185
|
* explicitly confirmed run, while its record and terminal report stay intact.
|
|
183
186
|
*/
|
|
184
|
-
export function findSettledBatchRecord(root: string, initiativeSlug: string):
|
|
187
|
+
export function findSettledBatchRecord(root: string, initiativeSlug: string): AnyBatchRunStateRecord | null {
|
|
185
188
|
const batchesDir = join(root, ".imm", "state", "batches");
|
|
186
189
|
if (!existsSync(batchesDir)) return null;
|
|
187
|
-
let newest:
|
|
190
|
+
let newest: AnyBatchRunStateRecord | null = null;
|
|
188
191
|
for (const file of readdirSync(batchesDir).sort()) {
|
|
189
192
|
if (!file.endsWith(".json")) continue;
|
|
190
193
|
let record: unknown;
|
|
@@ -194,8 +197,8 @@ export function findSettledBatchRecord(root: string, initiativeSlug: string): Ba
|
|
|
194
197
|
// The active lookup already fails closed on an unreadable record.
|
|
195
198
|
continue;
|
|
196
199
|
}
|
|
197
|
-
const candidate = record as
|
|
198
|
-
if (candidate?.contract !== "assurance_kernel/batch_run_state/v1") continue;
|
|
200
|
+
const candidate = record as AnyBatchRunStateRecord;
|
|
201
|
+
if (candidate?.contract !== "assurance_kernel/batch_run_state/v1" && candidate?.contract !== "assurance_kernel/batch_run_state/v2") continue;
|
|
199
202
|
if (candidate.initiative_slug !== initiativeSlug) continue;
|
|
200
203
|
if (!isTerminalBatchState(candidate.batch_state)) continue;
|
|
201
204
|
if (typeof candidate.batch_id !== "string" || typeof candidate.base_head !== "string") continue;
|
|
@@ -294,7 +297,7 @@ export function expectedBatchHead(record: {
|
|
|
294
297
|
*/
|
|
295
298
|
export function isOwnBatchClaim(
|
|
296
299
|
root: string,
|
|
297
|
-
existingBatch:
|
|
300
|
+
existingBatch: AnyBatchRunStateRecord,
|
|
298
301
|
taskId: string,
|
|
299
302
|
batchBranch: string,
|
|
300
303
|
): boolean {
|
|
@@ -317,10 +320,11 @@ export function isOwnBatchClaim(
|
|
|
317
320
|
if (!childInBatch || !(childInBatch.state === "enrolled" || childInBatch.state === "needs_human")) {
|
|
318
321
|
return false;
|
|
319
322
|
}
|
|
320
|
-
let rec:
|
|
323
|
+
let rec: TaskRecord | null = null;
|
|
321
324
|
try {
|
|
322
|
-
|
|
323
|
-
|
|
325
|
+
// Through the store read: the record is identity-validated against its
|
|
326
|
+
// run instead of being parsed from a raw row.
|
|
327
|
+
rec = readTaskRecordRaw(root, taskId).record;
|
|
324
328
|
} catch {
|
|
325
329
|
return false;
|
|
326
330
|
}
|
|
@@ -329,7 +333,7 @@ export function isOwnBatchClaim(
|
|
|
329
333
|
.concat(Array.isArray(existingBatch.commits) ? existingBatch.commits : [])
|
|
330
334
|
.concat((existingBatch.adopted_heads ?? []).map((adoption) => adoption.to));
|
|
331
335
|
if (!lineageHeads.includes(rec.git_base_head)) return false;
|
|
332
|
-
if (claim.enrollment_event_id !==
|
|
336
|
+
if (claim.enrollment_event_id !== enrollmentEventIdFor(taskId, claim.created_at)) return false;
|
|
333
337
|
const createdAt = Date.parse(claim.created_at);
|
|
334
338
|
if (!Number.isFinite(createdAt) || createdAt > Date.parse(existingBatch.updated_at)) return false;
|
|
335
339
|
if (claim.task_id !== taskId || claim.lifecycle_status !== "active") return false;
|
|
@@ -355,16 +359,13 @@ function authorizedScopeOf(root: string, taskId: string, state: string): string[
|
|
|
355
359
|
// fallback below
|
|
356
360
|
}
|
|
357
361
|
if (scope.length === 0 && state === "settled") {
|
|
358
|
-
// A settled child has no live state record;
|
|
359
|
-
//
|
|
362
|
+
// A settled child has no live state record; the Authority Store's settled
|
|
363
|
+
// Run read is its authority. Read-only, so a refusal still writes nothing.
|
|
360
364
|
try {
|
|
361
|
-
const
|
|
362
|
-
const
|
|
363
|
-
const snapshot = (settled?.record as { intent_snapshot?: { scope_hint?: string[] } } | null | undefined)
|
|
364
|
-
?.intent_snapshot;
|
|
365
|
+
const settled = readSettledTaskEvidence(root, taskId);
|
|
366
|
+
const snapshot = settled?.record.intent_snapshot;
|
|
365
367
|
scope = snapshot?.scope_hint ?? [];
|
|
366
|
-
recordedIntentPath = recordedIntentPath ??
|
|
367
|
-
?.intent_ref?.path;
|
|
368
|
+
recordedIntentPath = recordedIntentPath ?? settled?.record.intent_ref?.path;
|
|
368
369
|
} catch {
|
|
369
370
|
// fallback below
|
|
370
371
|
}
|
|
@@ -409,6 +410,8 @@ interface PlanSurface {
|
|
|
409
410
|
recovery_children: BatchPlanChild[];
|
|
410
411
|
risk_by_task: Record<string, string>;
|
|
411
412
|
excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
|
|
413
|
+
parallel_groups: string[][];
|
|
414
|
+
scope_conflicts: Array<{ task_id: string; overlaps_with: string[] }>;
|
|
412
415
|
}
|
|
413
416
|
|
|
414
417
|
type PlanSurfaceOutcome =
|
|
@@ -424,7 +427,7 @@ async function projectPlanSurface(input: {
|
|
|
424
427
|
root: string;
|
|
425
428
|
initiative_slug: string;
|
|
426
429
|
is_resuming: boolean;
|
|
427
|
-
existing_batch:
|
|
430
|
+
existing_batch: AnyBatchRunStateRecord | null;
|
|
428
431
|
now: string;
|
|
429
432
|
readInitiative?: InitiativeObservationReader;
|
|
430
433
|
}): Promise<PlanSurfaceOutcome> {
|
|
@@ -432,6 +435,8 @@ async function projectPlanSurface(input: {
|
|
|
432
435
|
let recoveryChildren: BatchPlanChild[] = [];
|
|
433
436
|
let planDigest: string;
|
|
434
437
|
let excluded: Array<{ task_id: string; slice_id: string; reason: string }> = [];
|
|
438
|
+
let parallelGroups: string[][] = [];
|
|
439
|
+
let scopeConflicts: Array<{ task_id: string; overlaps_with: string[] }> = [];
|
|
435
440
|
const riskByTask = new Map<string, string>();
|
|
436
441
|
// Only an active run supplies children and budget; a terminal record supplies
|
|
437
442
|
// branch provenance, never the plan or authorization of the next run.
|
|
@@ -471,7 +476,8 @@ async function projectPlanSurface(input: {
|
|
|
471
476
|
// Keep the risk captured by the authoritative read above; a stale
|
|
472
477
|
// reconstructed path must never fabricate a risk later.
|
|
473
478
|
riskByTask.set(c.task_id, read.intent?.risk ?? "material");
|
|
474
|
-
|
|
479
|
+
// A lane child integrated or released is already on the batch branch.
|
|
480
|
+
const isDone = ["committed", "settled", "integrated", "released"].includes(c.state);
|
|
475
481
|
return {
|
|
476
482
|
task_id: c.task_id,
|
|
477
483
|
slice_id: c.slice_id,
|
|
@@ -510,6 +516,8 @@ async function projectPlanSurface(input: {
|
|
|
510
516
|
return { ok: false, key: "empty_enrollable_set", detail: "" };
|
|
511
517
|
|
|
512
518
|
budget = plan.budget;
|
|
519
|
+
parallelGroups = plan.parallel_groups;
|
|
520
|
+
scopeConflicts = plan.scope_conflicts;
|
|
513
521
|
const enrollableChildById = new Map(plan.enrollable.map((c) => [c.task_id, c]));
|
|
514
522
|
recoveryChildren = plan.children
|
|
515
523
|
.filter((c) => c.status === "enrollable")
|
|
@@ -546,6 +554,8 @@ async function projectPlanSurface(input: {
|
|
|
546
554
|
[...riskByTask.entries()].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
|
|
547
555
|
),
|
|
548
556
|
excluded,
|
|
557
|
+
parallel_groups: parallelGroups,
|
|
558
|
+
scope_conflicts: scopeConflicts,
|
|
549
559
|
},
|
|
550
560
|
};
|
|
551
561
|
}
|
|
@@ -562,8 +572,8 @@ export async function projectBatchPreflight(
|
|
|
562
572
|
if (found?.corrupt)
|
|
563
573
|
return reject("batch_state_unreadable", found.path);
|
|
564
574
|
// Settled records retain branch provenance; only active records are resumed.
|
|
565
|
-
const activeRecord:
|
|
566
|
-
const existingBatch:
|
|
575
|
+
const activeRecord: AnyBatchRunStateRecord | null = found ? found.record : null;
|
|
576
|
+
const existingBatch: AnyBatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
|
|
567
577
|
const isResuming = activeRecord !== null;
|
|
568
578
|
const batchBranch = `imm/${initiativeSlug}`;
|
|
569
579
|
|
|
@@ -647,7 +657,7 @@ export async function projectBatchPreflight(
|
|
|
647
657
|
if (!planSurface.ok)
|
|
648
658
|
return reject(planSurface.key, planSurface.detail);
|
|
649
659
|
let reconfirmation: ReconfirmationSnapshot | undefined;
|
|
650
|
-
if (activeRecord && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
|
|
660
|
+
if (activeRecord && !isLaneBatchRecord(activeRecord) && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
|
|
651
661
|
try { reconfirmation = await captureBatchReconfirmation(root, activeRecord, planSurface.surface.recovery_children); }
|
|
652
662
|
catch { return reject("plan_projection_failed", "batch plan reconfirmation is not eligible"); }
|
|
653
663
|
}
|
|
@@ -663,6 +673,8 @@ export async function projectBatchPreflight(
|
|
|
663
673
|
recovery_children: planSurface.surface.recovery_children,
|
|
664
674
|
risk_by_task: planSurface.surface.risk_by_task,
|
|
665
675
|
excluded: planSurface.surface.excluded,
|
|
676
|
+
parallel_groups: planSurface.surface.parallel_groups,
|
|
677
|
+
scope_conflicts: planSurface.surface.scope_conflicts,
|
|
666
678
|
};
|
|
667
679
|
if (reconfirmation) Object.defineProperty(projection, "reconfirmation", { value: reconfirmation });
|
|
668
680
|
return { ok: true, projection };
|
|
@@ -685,8 +697,8 @@ export interface BatchDriftProjection {
|
|
|
685
697
|
export async function projectBatchDrift(options: BatchPreflightOptions): Promise<BatchDriftProjection> {
|
|
686
698
|
const { root, initiative_slug: initiativeSlug, readInitiative } = options;
|
|
687
699
|
const found = findExistingActiveBatch(root, initiativeSlug);
|
|
688
|
-
const activeRecord:
|
|
689
|
-
const existingBatch:
|
|
700
|
+
const activeRecord: AnyBatchRunStateRecord | null = found && !found.corrupt ? found.record : null;
|
|
701
|
+
const existingBatch: AnyBatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
|
|
690
702
|
const isResuming = activeRecord !== null;
|
|
691
703
|
const batchBranch = `imm/${initiativeSlug}`;
|
|
692
704
|
const activeClaimTaskId = readActiveClaimTaskId(root);
|
|
@@ -729,6 +741,9 @@ export interface BatchConfirmationFacts {
|
|
|
729
741
|
reuse_blockers: string[];
|
|
730
742
|
children: Array<{ task_id: string; slice_id: string; risk: string; status: BatchPlanChildStatus }>;
|
|
731
743
|
excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
|
|
744
|
+
/** Start waves and the children that serialize because their scopes overlap. */
|
|
745
|
+
parallel_groups: string[][];
|
|
746
|
+
scope_conflicts: Array<{ task_id: string; overlaps_with: string[] }>;
|
|
732
747
|
}
|
|
733
748
|
|
|
734
749
|
/**
|
|
@@ -792,7 +807,7 @@ export async function authorizeBatch<HostRejection>(
|
|
|
792
807
|
// ADR-0005 Decision 1: a resume of an intact, still-binding authorization
|
|
793
808
|
// reuses it instead of opening a second native gate. Anything that no longer
|
|
794
809
|
// binds falls through to the gate below, which names the reason.
|
|
795
|
-
const ownsUnpersistedHead = isResuming && existingBatch !== null && existingBatch.plan_digest === planDigest &&
|
|
810
|
+
const ownsUnpersistedHead = isResuming && existingBatch !== null && !isLaneBatchRecord(existingBatch) && existingBatch.plan_digest === planDigest &&
|
|
796
811
|
expectedBatchHead(existingBatch) !== baseHead && ownUnpersistedBatchHead(root, existingBatch, baseHead);
|
|
797
812
|
// A fast-forward on the batch branch is the user's own work: the runner adopts
|
|
798
813
|
// it, so it neither blocks reuse nor counts as moved provenance.
|
|
@@ -832,6 +847,8 @@ export async function authorizeBatch<HostRejection>(
|
|
|
832
847
|
status: child.status,
|
|
833
848
|
})),
|
|
834
849
|
excluded: projection.excluded,
|
|
850
|
+
parallel_groups: projection.parallel_groups,
|
|
851
|
+
scope_conflicts: projection.scope_conflicts,
|
|
835
852
|
};
|
|
836
853
|
|
|
837
854
|
let requestId: string | null = null;
|
|
@@ -858,7 +875,7 @@ export async function authorizeBatch<HostRejection>(
|
|
|
858
875
|
if (drift.plan_digest !== planDigest)
|
|
859
876
|
return { outcome: "rejected", rejection: batchRejection("plan_changed", drift.plan_digest) };
|
|
860
877
|
if (drift.base_head === null) return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
|
|
861
|
-
if (drift.base_head !== baseHead || (ownsUnpersistedHead && !ownUnpersistedBatchHead(root, existingBatch
|
|
878
|
+
if (drift.base_head !== baseHead || (ownsUnpersistedHead && !ownUnpersistedBatchHead(root, existingBatch as BatchRunStateRecord, baseHead)))
|
|
862
879
|
return { outcome: "rejected", rejection: batchRejection("head_moved", drift.base_head) };
|
|
863
880
|
|
|
864
881
|
// The drift projection's own claim read happened before its plan read; this
|
|
@@ -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;
|
|
@@ -85,7 +86,7 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
|
|
|
85
86
|
const assertNoCommitEvidence = () => {
|
|
86
87
|
for (const child of record.children) {
|
|
87
88
|
if (!id.test(child.task_id)) refuse();
|
|
88
|
-
const path =
|
|
89
|
+
const path = batchCommitEvidencePath(record.batch_id, child.task_id);
|
|
89
90
|
let missing = false;
|
|
90
91
|
try { readSecureProjectBytes(root, path); }
|
|
91
92
|
catch (error) {
|
|
@@ -96,13 +97,13 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
|
|
|
96
97
|
}
|
|
97
98
|
};
|
|
98
99
|
assertNoCommitEvidence();
|
|
99
|
-
const statePath =
|
|
100
|
+
const statePath = batchStatePath(record.batch_id);
|
|
100
101
|
const stateBytes = readSecureProjectBytes(root, statePath);
|
|
101
102
|
if (JSON.stringify(JSON.parse(decode(stateBytes))) !== JSON.stringify(record)) refuse();
|
|
102
103
|
const files = new Map<string, Buffer>();
|
|
103
104
|
const capture = (path: string) => { const bytes = readSecureProjectBytes(root, path); files.set(path, bytes); return bytes; };
|
|
104
105
|
const oldChildren: DigestChild[] = [];
|
|
105
|
-
const changed: Array<{ task: string; run: string; identity: string;
|
|
106
|
+
const changed: Array<{ task: string; run: string; identity: string; settled: string }> = [];
|
|
106
107
|
let scope: string[] | null = null;
|
|
107
108
|
const workspace = readWorkspaceStateRaw(root);
|
|
108
109
|
if (workspace.state.current_working !== null) refuse();
|
|
@@ -121,11 +122,11 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
|
|
|
121
122
|
if (currentHash !== tracked.content_hash || currentHash !== child.intent_content_hash || current.revision !== child.intent_revision) refuse();
|
|
122
123
|
if (currentHash === oldHash) continue;
|
|
123
124
|
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
|
|
125
|
-
if (!
|
|
126
|
-
const
|
|
127
|
-
if (!
|
|
128
|
-
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();
|
|
129
130
|
const pair = readAuditTaskPair(root, child.task_id, run);
|
|
130
131
|
if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4") refuse();
|
|
131
132
|
const r = pair.record;
|
|
@@ -133,22 +134,22 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
|
|
|
133
134
|
r.git_base_head !== record.base_head || pair.proof.terminal_lifecycle !== "done") refuse();
|
|
134
135
|
const terminal = r.history.at(-1);
|
|
135
136
|
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 =
|
|
137
|
-
const proofPath =
|
|
138
|
-
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();
|
|
139
140
|
const capturedProof = JSON.parse(decode(capture(proofPath)));
|
|
140
|
-
if (!matchesLocalProof(
|
|
141
|
-
const identity =
|
|
141
|
+
if (!matchesLocalProof(settled.proof_json, capturedProof) || !matchesLocalProof(settled.proof_json, pair.proof)) refuse();
|
|
142
|
+
const identity = taskDeliveryIdentity(root, r);
|
|
142
143
|
const projection = await projectAssurance(root, child.task_id, (cwd, task) => {
|
|
143
144
|
if (task.contract !== "assurance_kernel/task_record/v4" || !task.git_base_head) refuse();
|
|
144
|
-
return
|
|
145
|
+
return taskDeliveryIdentity(cwd, task);
|
|
145
146
|
});
|
|
146
147
|
if (projection.error || projection.claim || projection.projection.run_id !== run || projection.projection.lifecycle !== "done" ||
|
|
147
148
|
!projection.projection.completion_ready || projection.projection.intent_content_hash !== currentHash || projection.projection.diff_hash !== identity.diff_hash ||
|
|
148
149
|
!projection.projection.fresh_approval_kinds.includes("qa") ||
|
|
149
150
|
(projection.projection.risk !== "routine" && !projection.projection.fresh_approval_kinds.includes("review"))) refuse();
|
|
150
151
|
scope = r.intent_snapshot.scope_hint;
|
|
151
|
-
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) });
|
|
152
153
|
}
|
|
153
154
|
if (computeBatchPlanDigest(oldChildren) !== record.plan_digest || changed.length !== 1 || scope === null) refuse();
|
|
154
155
|
const confirmedScope = scope;
|
|
@@ -161,7 +162,7 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
|
|
|
161
162
|
for (const entry of entries) {
|
|
162
163
|
const code = entry.slice(0, 2), path = entry.slice(3);
|
|
163
164
|
if (code[1] !== " " || !["A", "M", "D"].includes(code[0]!) ||
|
|
164
|
-
(!path.startsWith(
|
|
165
|
+
(!path.startsWith(`${auditRunDirPath(owner.task, owner.run)}/`) &&
|
|
165
166
|
!confirmedScope.some(s => pathMatchesScope(path, s)))) refuse();
|
|
166
167
|
}
|
|
167
168
|
const flagsBytes = git(root, ["ls-files", "-v"]);
|
|
@@ -172,11 +173,15 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
|
|
|
172
173
|
if (head() !== record.base_head || branch() !== record.branch) refuse();
|
|
173
174
|
const assertDeliveryUnchanged = () => {
|
|
174
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();
|
|
175
181
|
const pair = readAuditTaskPair(root, c.task, c.run);
|
|
176
182
|
if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4" || !pair.record.git_base_head) refuse();
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
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();
|
|
180
185
|
}
|
|
181
186
|
};
|
|
182
187
|
const assertUnchanged = () => {
|