immune-brain 5.2.0 → 5.5.1
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 +4 -4
- package/README.zh-CN.md +4 -4
- 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 +54 -70
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +163 -28
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +71 -10
- package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +1 -1
- package/plugins/immune-brain/.pi-extension/pi-run-continuation.ts +161 -0
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +26 -2
- package/plugins/immune-brain/dist/BASELINE.md +17 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +16145 -12745
- package/plugins/immune-brain/dist/docs/reference/code-quality-guard.md +3 -1
- package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +41 -1
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
- package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
- package/plugins/immune-brain/dist/imm-planner.md +63 -19
- package/plugins/immune-brain/dist/imm-run-batch.md +398 -0
- package/plugins/immune-brain/dist/imm-run.md +89 -344
- package/plugins/immune-brain/dist/role-prompts/code-review.md +26 -2
- package/plugins/immune-brain/dist/role-prompts/executor.md +35 -10
- package/plugins/immune-brain/hooks/hooks.json +22 -2
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +203 -13
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +2 -1
- package/plugins/immune-brain/runtime/assurance/enrollment_git_base.ts +99 -14
- package/plugins/immune-brain/runtime/assurance/phase_entry.ts +10 -26
- package/plugins/immune-brain/runtime/assurance/qa.ts +126 -43
- package/plugins/immune-brain/runtime/assurance/qa_findings.ts +8 -0
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +48 -0
- package/plugins/immune-brain/runtime/assurance/run_continuation.ts +284 -0
- package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +46 -4
- package/plugins/immune-brain/runtime/assurance/verification.ts +83 -35
- package/plugins/immune-brain/runtime/assurance/workflow_autocommit.ts +222 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +11 -2
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +199 -48
- package/plugins/immune-brain/runtime/claude/lane_guard.ts +30 -20
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +105 -9
- package/plugins/immune-brain/runtime/claude/run_continuation.ts +274 -0
- package/plugins/immune-brain/runtime/commands/kernel.ts +41 -103
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +60 -2
- package/plugins/immune-brain/runtime/kernel/application.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +103 -20
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +57 -7
- package/plugins/immune-brain/runtime/kernel/completion.ts +28 -4
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +48 -8
- package/plugins/immune-brain/runtime/kernel/intent.ts +169 -104
- package/plugins/immune-brain/runtime/kernel/reducer.ts +136 -6
- package/plugins/immune-brain/runtime/kernel/risk_floor.ts +286 -0
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +83 -1
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +1 -1
- package/plugins/immune-brain/runtime/kernel/types.ts +49 -3
- package/plugins/immune-brain/runtime/kernel/validation.ts +147 -7
- package/plugins/immune-brain/runtime/loop_contract.ts +5 -0
- package/plugins/immune-brain/runtime/plan_core.ts +15 -8
- package/plugins/immune-brain/runtime/plugin_source_skew.ts +58 -0
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +26 -2
- package/plugins/immune-brain/runtime/prompts/executor.md +35 -10
- package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +5 -3
- package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +40 -10
- package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +57 -3
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +424 -66
- package/plugins/immune-brain/runtime/unattended/batch_integration.ts +2 -1
- package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +13 -0
- package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +43 -24
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +6 -2
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +102 -41
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +9 -3
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +9 -9
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +90 -32
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +102 -4
- package/plugins/immune-brain/runtime/unattended/parent_delivery_consolidation.ts +248 -0
- package/plugins/immune-brain/runtime/workspace_scope.ts +126 -18
- package/plugins/immune-brain/skills/BASELINE.md +17 -1
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +4 -3
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +8 -2
- package/plugins/immune-brain/skills/imm-run/SKILL.md +12 -8
|
@@ -63,23 +63,16 @@ export function recognizePhaseEntry(text: string, readAlias: AliasReader): Phase
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export interface PhaseEntryState {
|
|
66
|
-
/** Count of literal prompts observed in the session. */
|
|
67
|
-
prompt_seq: number;
|
|
68
66
|
run_live: boolean;
|
|
69
|
-
/** The prompt that recorded the live run entry, or 0. */
|
|
70
|
-
run_prompt_seq: number;
|
|
71
|
-
/** The prompt that recorded the latest plan entry, or 0. */
|
|
72
|
-
plan_prompt_seq: number;
|
|
73
67
|
}
|
|
74
68
|
|
|
75
|
-
export const NO_PHASE_ENTRY: PhaseEntryState = {
|
|
69
|
+
export const NO_PHASE_ENTRY: PhaseEntryState = { run_live: false };
|
|
76
70
|
|
|
77
71
|
/** One literal prompt: a run entry goes live, a plan entry revokes it. */
|
|
78
72
|
export function applyPrompt(state: PhaseEntryState, entry: PhaseEntry | null): PhaseEntryState {
|
|
79
|
-
|
|
80
|
-
if (entry === "
|
|
81
|
-
|
|
82
|
-
return { ...state, prompt_seq };
|
|
73
|
+
if (entry === "run") return { run_live: true };
|
|
74
|
+
if (entry === "plan") return { run_live: false };
|
|
75
|
+
return state;
|
|
83
76
|
}
|
|
84
77
|
|
|
85
78
|
export function phaseEntryLogLine(entry: PhaseEntry | null): string {
|
|
@@ -115,26 +108,17 @@ function refuse(reason_code: typeof RUN_ENTRY_REQUIRED | typeof PHASE_ENTRY_REQU
|
|
|
115
108
|
|
|
116
109
|
/**
|
|
117
110
|
* Whether the session's entry state admits one operation. `state` is null when
|
|
118
|
-
* the store could not be read, which refuses
|
|
111
|
+
* the store could not be read, which refuses `enroll`, `start_unattended_batch`
|
|
112
|
+
* and the `imm-run` Skill. The Planner and Brainstorm Skills are never refused.
|
|
119
113
|
*/
|
|
120
|
-
export function admitPhaseOperation(
|
|
121
|
-
operation: PhaseOperation,
|
|
122
|
-
state: PhaseEntryState | null,
|
|
123
|
-
context: { activeClaim: boolean },
|
|
124
|
-
): PhaseAdmission {
|
|
114
|
+
export function admitPhaseOperation(operation: PhaseOperation, state: PhaseEntryState | null): PhaseAdmission {
|
|
125
115
|
if (operation.kind === "tool") {
|
|
126
116
|
if (state?.run_live) return { ok: true };
|
|
127
117
|
return refuse(RUN_ENTRY_REQUIRED, `${operation.name} needs a literal-user imm-run entry in this session`);
|
|
128
118
|
}
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
return refuse(PHASE_ENTRY_REQUIRED, "imm-run is entered by the user, not by a model Skill call");
|
|
133
|
-
}
|
|
134
|
-
if (state && state.plan_prompt_seq === latest) return { ok: true };
|
|
135
|
-
// Enrolled Intent Revision: the Loop routes to the Planner inside a run.
|
|
136
|
-
if (operation.skill === "imm-planner" && state?.run_live && context.activeClaim) return { ok: true };
|
|
137
|
-
return refuse(PHASE_ENTRY_REQUIRED, `${operation.skill} is entered by the user, not by a model Skill call`);
|
|
119
|
+
if (operation.skill !== "imm-run") return { ok: true };
|
|
120
|
+
if (state?.run_live) return { ok: true };
|
|
121
|
+
return refuse(PHASE_ENTRY_REQUIRED, "imm-run is entered by the user, not by a model Skill call");
|
|
138
122
|
}
|
|
139
123
|
|
|
140
124
|
/** The single-line message both Hosts return for a refusal. */
|
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { snapshotDigest, QaPreparationError, type AssuranceVerdict, type SnapshotDescriptor, type QaCheckDiagnostic, type QaPreparationReason, type QaVerificationProgressInput } from "./coordinator";
|
|
2
|
+
import { snapshotDigest, QaPreparationError, qaPrepareObservation, type AssuranceVerdict, type SnapshotDescriptor, type QaCheckDiagnostic, type QaPreparationReason, type QaVerificationProgressInput } from "./coordinator";
|
|
3
3
|
export { QaPreparationError, type QaCheckDiagnostic, type QaPreparationReason, type QaVerificationProgressInput } from "./coordinator";
|
|
4
|
-
import { assertDeliveryClean, materializeDeliveryWorkspace,
|
|
5
|
-
import { runFixedVerification, resolveVerificationCommand, assertCommandIdentity, verificationPath, VerificationAbortedError, VerificationCleanupError, VerificationLaunchError, type FrozenCommand, type VerificationDescriptor, type VerificationCommand } from "./verification";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { join, sep } from "node:path";
|
|
9
|
-
import { qaFindingId } from "./qa_findings";
|
|
10
|
-
import { captureGitTaskRevisionSnapshot } from "../workspace_scope";
|
|
4
|
+
import { assertDeliveryClean, materializeDeliveryWorkspace, writeDeliveryTree } from "./delivery_workspace";
|
|
5
|
+
import { runFixedVerification, resolveVerificationCommand, assertCommandIdentity, verificationPath, readQaBaseline, VerificationAbortedError, VerificationCleanupError, VerificationLaunchError, type FrozenCommand, type VerificationDescriptor, type VerificationCommand, type QaBaseline } from "./verification";
|
|
6
|
+
import { qaFindingId, qaBaselineFindingId, PRE_EXISTING_BASELINE_PREFIX } from "./qa_findings";
|
|
7
|
+
import { captureGitTaskRevisionSnapshot, inScopeTrackedDrift } from "../workspace_scope";
|
|
11
8
|
import { readTaskRecordRaw } from "../kernel/storage";
|
|
12
9
|
|
|
13
|
-
function
|
|
14
|
-
snapshot: SnapshotDescriptor,
|
|
15
|
-
writeTree: typeof writeDeliveryTree,
|
|
16
|
-
): string {
|
|
17
|
-
if (snapshot.review_revision?.review_tree) return snapshot.review_revision.review_tree;
|
|
10
|
+
function recordForSnapshot(snapshot: SnapshotDescriptor) {
|
|
18
11
|
const record = readTaskRecordRaw(snapshot.root, snapshot.task_id).record;
|
|
19
12
|
if (!record || record.contract !== "assurance_kernel/task_record/v4" || !record.git_base_head)
|
|
20
13
|
throw new Error("QA delivery requires a TaskRecord v4 git_base_head");
|
|
14
|
+
return record;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function deliveryTreeForSnapshot(snapshot: SnapshotDescriptor): string {
|
|
18
|
+
if (snapshot.review_revision?.review_tree) return snapshot.review_revision.review_tree;
|
|
19
|
+
const record = recordForSnapshot(snapshot);
|
|
21
20
|
const captured = captureGitTaskRevisionSnapshot(
|
|
22
21
|
snapshot.root,
|
|
23
22
|
record.intent_snapshot.scope_hint,
|
|
@@ -36,17 +35,27 @@ export async function runDeterministicQa(
|
|
|
36
35
|
options: {
|
|
37
36
|
signal?: AbortSignal;
|
|
38
37
|
onProgress?: (progress: QaVerificationProgressInput) => void;
|
|
39
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Task QA runs in the task's working directory. Only the Lane batch
|
|
40
|
+
* integration recheck asks for a copy: its merge candidate is never
|
|
41
|
+
* checked out, so there is no working directory to run in.
|
|
42
|
+
*/
|
|
43
|
+
delivery?: "materialized";
|
|
44
|
+
// Coordination tests inject a fake executor without a real repository.
|
|
40
45
|
_runFixedVerification?: typeof runFixedVerification;
|
|
41
46
|
_resolveVerificationCommand?: typeof resolveVerificationCommand;
|
|
42
47
|
_materializeDeliveryWorkspace?: typeof materializeDeliveryWorkspace;
|
|
43
|
-
|
|
48
|
+
/** Replaces the frozen tree and, in place, the comparison with it. */
|
|
49
|
+
_frozenTree?: { tree: string; drift?: () => string[] };
|
|
50
|
+
/** Replaces the Enrollment `base_head` the project baseline is read from. */
|
|
51
|
+
_baseHead?: string;
|
|
44
52
|
} = {},
|
|
45
53
|
): Promise<AssuranceVerdict> {
|
|
46
54
|
if (snapshot.role !== "qa") throw new Error("deterministic QA requires qa role");
|
|
47
55
|
const live = () => { if (options.signal?.aborted) throw new VerificationAbortedError(); };
|
|
48
56
|
live();
|
|
49
|
-
|
|
57
|
+
type QaItem = { id: string; index: number; descriptor: VerificationDescriptor; baseline?: string };
|
|
58
|
+
const groups = new Map<string, QaItem[]>();
|
|
50
59
|
for (const [index, item] of snapshot.acceptance.entries()) {
|
|
51
60
|
const descriptor = descriptors.get(item.id);
|
|
52
61
|
if (!descriptor) throw new Error(`verification descriptor missing for ${item.id}`);
|
|
@@ -55,12 +64,66 @@ export async function runDeterministicQa(
|
|
|
55
64
|
group.push({ id: item.id, index, descriptor });
|
|
56
65
|
groups.set(key, group);
|
|
57
66
|
}
|
|
58
|
-
const
|
|
59
|
-
|
|
67
|
+
const materialized = options.delivery === "materialized";
|
|
68
|
+
// The project baseline comes from the Enrollment base_head tree, never the
|
|
69
|
+
// delivery. Only task QA in the working directory carries it: the Lane batch
|
|
70
|
+
// integration recheck reruns acceptance descriptors on a merge candidate.
|
|
71
|
+
const baselineHead = materialized ? undefined : options._baseHead
|
|
72
|
+
?? (options._frozenTree ? undefined : recordForSnapshot(snapshot).git_base_head);
|
|
73
|
+
const baseline: QaBaseline | null = baselineHead ? readQaBaseline(snapshot.root, baselineHead) : null;
|
|
74
|
+
const totalChecks = snapshot.acceptance.length + (baseline?.checks.length ?? 0);
|
|
75
|
+
for (const [offset, check] of (baseline?.checks ?? []).entries()) {
|
|
76
|
+
// Baseline checks run after every acceptance descriptor, so they never share
|
|
77
|
+
// a group with one. Diagnostics reuse the acceptance index space past the
|
|
78
|
+
// acceptance count and name the check as `baseline:<id>`.
|
|
79
|
+
const key = `baseline:${JSON.stringify(check.descriptor.environment)}`;
|
|
80
|
+
const group = groups.get(key) ?? [];
|
|
81
|
+
group.push({ id: `baseline:${check.id}`, index: snapshot.acceptance.length + offset, descriptor: check.descriptor, baseline: check.id });
|
|
82
|
+
groups.set(key, group);
|
|
83
|
+
}
|
|
84
|
+
const tree = options._frozenTree?.tree ?? deliveryTreeForSnapshot(snapshot);
|
|
85
|
+
const drift = materialized ? null : options._frozenTree
|
|
86
|
+
? options._frozenTree.drift ?? (() => [])
|
|
87
|
+
: ((scope: unknown) => () => inScopeTrackedDrift(snapshot.root, tree, scope))(recordForSnapshot(snapshot).intent_snapshot.scope_hint);
|
|
60
88
|
const path = verificationPath();
|
|
89
|
+
// Rerun one failed baseline check against the materialized base_head tree,
|
|
90
|
+
// under the same descriptor. Anything short of the identical failure, a
|
|
91
|
+
// materialization or prepare problem included, leaves the finding blocking.
|
|
92
|
+
const failsIdenticallyAtBase = async (item: QaItem, observed: { exit_code: number; timed_out: boolean; output_limited: boolean }, baseTree: string): Promise<boolean> => {
|
|
93
|
+
live();
|
|
94
|
+
let copy: ReturnType<typeof materializeDeliveryWorkspace>;
|
|
95
|
+
try { copy = (options._materializeDeliveryWorkspace ?? materializeDeliveryWorkspace)(snapshot.root, baseTree); }
|
|
96
|
+
catch { return false; }
|
|
97
|
+
const diagnostic = (reason: QaPreparationReason, stage: QaCheckDiagnostic["stage"]): QaCheckDiagnostic[] => [{ acceptance_id: item.id,
|
|
98
|
+
descriptor_ref: `acceptance/${item.index}/verification/command`,
|
|
99
|
+
descriptor_digest: `sha256:${createHash("sha256").update(JSON.stringify(item.descriptor)).digest("hex")}`,
|
|
100
|
+
stage, outcome: reason, elapsed_ms: 0, exit_code: null, stdout_bytes: null, stderr_bytes: null }];
|
|
101
|
+
const run = async (command: VerificationCommand) => {
|
|
102
|
+
const frozen = (options._resolveVerificationCommand ?? resolveVerificationCommand)(copy.root, command, path);
|
|
103
|
+
return (options._runFixedVerification ?? runFixedVerification)(copy.root, command, frozen, { signal: options.signal, path });
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
const prepare = item.descriptor.environment.prepare;
|
|
107
|
+
if (prepare) {
|
|
108
|
+
const prepared = await run(prepare);
|
|
109
|
+
if (prepared.exit_code !== 0 || prepared.timed_out || prepared.output_limited) return false;
|
|
110
|
+
}
|
|
111
|
+
const result = await run(item.descriptor.command);
|
|
112
|
+
live();
|
|
113
|
+
return result.exit_code === observed.exit_code && result.timed_out === observed.timed_out && result.output_limited === observed.output_limited;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error instanceof VerificationAbortedError) throw error;
|
|
116
|
+
if (error instanceof VerificationCleanupError) throw new QaPreparationError("resolution", [item.id], "process_cleanup_failed", diagnostic("process_cleanup_failed", "resolution"));
|
|
117
|
+
return false;
|
|
118
|
+
} finally {
|
|
119
|
+
try { copy.cleanup(); }
|
|
120
|
+
catch { throw new QaPreparationError("integrity", [item.id], "delivery_cleanup_failed", diagnostic("delivery_cleanup_failed", "integrity")); }
|
|
121
|
+
}
|
|
122
|
+
};
|
|
61
123
|
const findings: NonNullable<AssuranceVerdict["findings"]> = [];
|
|
62
124
|
const evidence: unknown[] = [];
|
|
63
125
|
const allCommands: Array<{ frozen: FrozenCommand; diagnostics: QaCheckDiagnostic[] }> = [];
|
|
126
|
+
const baselineFailures: Array<{ item: QaItem; finding: (typeof findings)[number]; observed: { exit_code: number; timed_out: boolean; output_limited: boolean } }> = [];
|
|
64
127
|
for (const [environmentKey, group] of groups) {
|
|
65
128
|
live();
|
|
66
129
|
const ids = group.map(item => item.id);
|
|
@@ -72,23 +135,30 @@ export async function runDeterministicQa(
|
|
|
72
135
|
descriptor_digest: `sha256:${createHash("sha256").update(JSON.stringify(item.descriptor)).digest("hex")}`,
|
|
73
136
|
stage, outcome, elapsed_ms: elapsed, exit_code: result?.exit_code ?? null,
|
|
74
137
|
stdout_bytes: result ? Buffer.byteLength(result.stdout) : null, stderr_bytes: result ? Buffer.byteLength(result.stderr) : null }));
|
|
75
|
-
const
|
|
76
|
-
|
|
138
|
+
const delivery = (() => {
|
|
139
|
+
if (!materialized) return { root: snapshot.root, seal: undefined, cleanup: () => {} };
|
|
140
|
+
let copy: ReturnType<typeof materializeDeliveryWorkspace> | undefined;
|
|
77
141
|
try {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return { delivery, home, deliveryPrefix: `${realpathSync(delivery.root)}${sep}` };
|
|
142
|
+
copy = (options._materializeDeliveryWorkspace ?? materializeDeliveryWorkspace)(snapshot.root, tree);
|
|
143
|
+
return { root: copy.root, seal: copy.seal as string | undefined, cleanup: copy.cleanup };
|
|
81
144
|
} catch {
|
|
82
145
|
let reason: QaPreparationReason = "delivery_unavailable";
|
|
83
|
-
try {
|
|
146
|
+
try { copy?.cleanup(); }
|
|
84
147
|
catch { reason = "delivery_cleanup_failed"; }
|
|
85
148
|
throw new QaPreparationError("prepare", ids, reason, diagnostics("prepare", reason));
|
|
86
149
|
}
|
|
87
150
|
})();
|
|
88
151
|
const clean = () => {
|
|
89
|
-
|
|
90
|
-
try {
|
|
91
|
-
|
|
152
|
+
let paths: string[] | undefined;
|
|
153
|
+
try {
|
|
154
|
+
if (drift) { paths = drift(); if (!paths.length) return; }
|
|
155
|
+
else {
|
|
156
|
+
if (!options._materializeDeliveryWorkspace) assertDeliveryClean(delivery.root, tree, delivery.seal, environment.writable_paths);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
} catch { paths = undefined; }
|
|
160
|
+
throw new QaPreparationError("integrity", ids, "protected_input_or_output_drift", diagnostics("integrity", "protected_input_or_output_drift"),
|
|
161
|
+
paths ? qaPrepareObservation(0, "", "working_directory", paths) : undefined);
|
|
92
162
|
};
|
|
93
163
|
const groupCommands: FrozenCommand[] = [];
|
|
94
164
|
const execute = async (command: VerificationCommand, stage: "prepare" | "check", affected = group) => {
|
|
@@ -100,7 +170,7 @@ export async function runDeterministicQa(
|
|
|
100
170
|
try { frozen = (options._resolveVerificationCommand ?? resolveVerificationCommand)(delivery.root, command, path); }
|
|
101
171
|
catch { throw fail("resolution", "executable_or_cwd_unavailable"); }
|
|
102
172
|
let result;
|
|
103
|
-
try { result = await (options._runFixedVerification ?? runFixedVerification)(delivery.root, command, frozen, { signal: options.signal,
|
|
173
|
+
try { result = await (options._runFixedVerification ?? runFixedVerification)(delivery.root, command, frozen, { signal: options.signal, path }); }
|
|
104
174
|
catch (error) {
|
|
105
175
|
if (error instanceof VerificationAbortedError) throw error;
|
|
106
176
|
if (error instanceof VerificationLaunchError) throw fail("resolution", "process_launch_failed");
|
|
@@ -109,17 +179,13 @@ export async function runDeterministicQa(
|
|
|
109
179
|
}
|
|
110
180
|
live(); clean();
|
|
111
181
|
groupCommands.push(frozen);
|
|
112
|
-
if (frozen?.entry)
|
|
113
|
-
|
|
114
|
-
if (!command.executable.startsWith("./")) allCommands.push({ frozen, diagnostics: identityDiagnostics });
|
|
115
|
-
else if (frozen.interpreter && !frozen.interpreter.path.startsWith(deliveryPrefix))
|
|
116
|
-
allCommands.push({ frozen: { entry: frozen.interpreter, interpreter: null, interpreter_args: [] }, diagnostics: identityDiagnostics });
|
|
117
|
-
}
|
|
182
|
+
if (frozen?.entry && !command.executable.startsWith("./"))
|
|
183
|
+
allCommands.push({ frozen, diagnostics: diagnostics("integrity", "command_identity_changed", affected, stage, Math.round(performance.now() - started), result) });
|
|
118
184
|
// Paths and process output are not persisted. Identity hashes describe
|
|
119
185
|
// observed entries, not a claim about every transitive dependency.
|
|
120
186
|
evidence.push({ stage, environment: createHash("sha256").update(environmentKey).digest("hex"),
|
|
121
187
|
command: createHash("sha256").update(JSON.stringify(command)).digest("hex"),
|
|
122
|
-
entry: frozen?.entry?.content_hash ?? null,
|
|
188
|
+
entry: frozen?.entry?.content_hash ?? null,
|
|
123
189
|
exit_code: result.exit_code, timed_out: result.timed_out, output_limited: result.output_limited });
|
|
124
190
|
if (Buffer.byteLength(JSON.stringify(evidence)) > 16_384)
|
|
125
191
|
throw fail("prepare", "execution_metadata_limit_exceeded");
|
|
@@ -132,32 +198,46 @@ export async function runDeterministicQa(
|
|
|
132
198
|
const result = await execute(environment.prepare, "prepare");
|
|
133
199
|
if (result.exit_code !== 0 || result.timed_out || result.output_limited)
|
|
134
200
|
throw new QaPreparationError("prepare", ids, result.timed_out ? "timeout" : result.output_limited ? "output_limit" : "nonzero_exit",
|
|
135
|
-
diagnostics("prepare", result.timed_out ? "timeout" : result.output_limited ? "output_limit" : "nonzero_exit", group, "prepare", Math.round(performance.now() - prepareStarted), result)
|
|
201
|
+
diagnostics("prepare", result.timed_out ? "timeout" : result.output_limited ? "output_limit" : "nonzero_exit", group, "prepare", Math.round(performance.now() - prepareStarted), result),
|
|
202
|
+
qaPrepareObservation(environment.prepare.timeout_ms, result.stderr, materialized ? "isolated_materialization" : "working_directory"));
|
|
136
203
|
}
|
|
137
204
|
for (const item of group) {
|
|
138
205
|
const started = performance.now();
|
|
139
206
|
const progress = (phase: QaVerificationProgressInput["phase"], outcome: QaCheckDiagnostic["outcome"], result?: { exit_code: number; stdout: string; stderr: string }) => options.onProgress?.({
|
|
140
|
-
index: item.index + 1, total:
|
|
207
|
+
index: item.index + 1, total: totalChecks, acceptance_id: item.id, phase, elapsed_ms: Math.round(performance.now() - started),
|
|
141
208
|
diagnostic: diagnostics("check", outcome, [item], "check", Math.round(performance.now() - started), result)[0],
|
|
142
209
|
});
|
|
143
210
|
progress("running", "running");
|
|
144
211
|
const result = await execute(item.descriptor.command, "check", [item]);
|
|
145
212
|
const failed = result.exit_code !== 0 || result.timed_out || result.output_limited;
|
|
146
213
|
progress(failed ? "failed" : "passed", result.timed_out ? "timeout" : result.output_limited ? "output_limit" : failed ? "nonzero_exit" : "passed", result);
|
|
147
|
-
if (failed)
|
|
148
|
-
|
|
214
|
+
if (failed) {
|
|
215
|
+
const finding = { id: item.baseline ? qaBaselineFindingId(item.baseline, snapshotDigest(snapshot)) : qaFindingId(item.id, snapshotDigest(snapshot)),
|
|
216
|
+
kind: "blocking" as const, acceptance_id: item.baseline ? null : item.id,
|
|
217
|
+
summary: `${item.baseline ? `baseline check ${item.baseline} ` : ""}verification failed (exit ${result.exit_code}${result.timed_out ? ", timed out" : ""}${result.output_limited ? ", output limit" : ""}) stdout=${Buffer.byteLength(result.stdout)}B stderr=${Buffer.byteLength(result.stderr)}B`, findings_digest: "" };
|
|
218
|
+
findings.push(finding);
|
|
219
|
+
if (item.baseline) baselineFailures.push({ item, finding, observed: { exit_code: result.exit_code, timed_out: result.timed_out, output_limited: result.output_limited } });
|
|
220
|
+
}
|
|
149
221
|
}
|
|
150
|
-
// Recheck while
|
|
222
|
+
// Recheck while a materialized copy still exists; its cleanup removes project entries.
|
|
151
223
|
try {
|
|
152
224
|
for (const frozen of groupCommands) if (frozen?.entry) assertCommandIdentity(frozen);
|
|
153
225
|
} catch { throw new QaPreparationError("integrity", ids, "command_identity_changed", diagnostics("integrity", "command_identity_changed")); }
|
|
154
226
|
} finally {
|
|
155
|
-
try {
|
|
227
|
+
try { delivery.cleanup(); }
|
|
156
228
|
catch { throw new QaPreparationError("integrity", ids, "delivery_cleanup_failed", diagnostics("integrity", "delivery_cleanup_failed")); }
|
|
157
229
|
}
|
|
158
230
|
}
|
|
159
231
|
live();
|
|
160
|
-
//
|
|
232
|
+
// A baseline failure also present at base_head is disclosed, not blocking. Only
|
|
233
|
+
// baseline checks get this tolerance; an acceptance descriptor never does.
|
|
234
|
+
const disclosed: NonNullable<AssuranceVerdict["findings"]> = [];
|
|
235
|
+
for (const failure of baselineFailures) {
|
|
236
|
+
if (!baseline || !(await failsIdenticallyAtBase(failure.item, failure.observed, baseline.tree))) continue;
|
|
237
|
+
findings.splice(findings.indexOf(failure.finding), 1);
|
|
238
|
+
disclosed.push({ ...failure.finding, kind: "advisory", summary: `${PRE_EXISTING_BASELINE_PREFIX} ${failure.finding.summary}` });
|
|
239
|
+
}
|
|
240
|
+
// Host entries must still match before approval.
|
|
161
241
|
for (const entry of allCommands) {
|
|
162
242
|
try { if (entry.frozen?.entry) assertCommandIdentity(entry.frozen); }
|
|
163
243
|
catch { throw new QaPreparationError("integrity", entry.diagnostics.map(item => item.acceptance_id), "command_identity_changed", entry.diagnostics); }
|
|
@@ -165,10 +245,13 @@ export async function runDeterministicQa(
|
|
|
165
245
|
const base = { contract: "assurance_kernel/assurance_verdict/v2" as const, role: "qa" as const,
|
|
166
246
|
task_id: snapshot.task_id, snapshot_digest: snapshotDigest(snapshot) };
|
|
167
247
|
if (findings.length) return { ...base, decision: "rework", findings };
|
|
248
|
+
const disclosure = disclosed.length
|
|
249
|
+
? `; ${disclosed.map(item => item.summary).join("; ")}` : "";
|
|
168
250
|
const executionDigest = createHash("sha256").update(JSON.stringify({
|
|
169
251
|
tree, platform: process.platform, arch: process.arch, observations: evidence,
|
|
170
252
|
})).digest("hex");
|
|
171
253
|
return { ...base, decision: "pass", approval: { kind: "qa", authority_role: "qa",
|
|
172
|
-
summary: `all ${
|
|
254
|
+
summary: `all ${totalChecks} project verification check(s) passed${disclosure}; execution=sha256:${executionDigest}` },
|
|
255
|
+
...(disclosed.length ? { findings: disclosed } : {}) };
|
|
173
256
|
}
|
|
174
257
|
|
|
@@ -19,6 +19,14 @@ export function qaEvidenceFreshnessId(snapshotDigest: string): string {
|
|
|
19
19
|
return `qa-evidence-freshness-${digest16}-${attemptRef(snapshotDigest)}`;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Summary prefix that marks a baseline failure also present at `base_head`. */
|
|
23
|
+
export const PRE_EXISTING_BASELINE_PREFIX = "pre-existing baseline failure:";
|
|
24
|
+
|
|
25
|
+
/** Digest-scoped id for a project baseline check finding; `acceptance_id` stays null. */
|
|
26
|
+
export function qaBaselineFindingId(checkId: string, snapshotDigest: string): string {
|
|
27
|
+
return `qa-baseline-${checkId}-${attemptRef(snapshotDigest)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
function attemptRef(snapshotDigest: string): string {
|
|
23
31
|
const digest8 = snapshotDigest.slice("sha256:".length, "sha256:".length + 8);
|
|
24
32
|
return `${digest8}-${randomUUID().slice(0, 6)}`;
|
|
@@ -287,9 +287,54 @@ export interface ReviewManifestV5 {
|
|
|
287
287
|
lifecycle: string;
|
|
288
288
|
artifact_state: string;
|
|
289
289
|
risk: string;
|
|
290
|
+
/** Round 3 and later only: the prior round's reviewed commit. */
|
|
291
|
+
prior_review_commit?: string;
|
|
292
|
+
/** Paths whose tree entries differ between the prior and current review trees. */
|
|
293
|
+
delta_paths?: string[];
|
|
294
|
+
/** Round 3 and later only: why no delta is available, so the rule is skipped. */
|
|
295
|
+
delta_skipped?: "prior_review_revision_absent" | "prior_review_revision_unresolvable";
|
|
290
296
|
manifest_digest: string;
|
|
291
297
|
}
|
|
292
298
|
|
|
299
|
+
/** The first Review round that judges only the rework delta. */
|
|
300
|
+
export const REVIEW_DELTA_FROM_ROUND = 3;
|
|
301
|
+
|
|
302
|
+
export interface ReviewDeltaInput {
|
|
303
|
+
/** The round this Review opens, derived from the Kernel's review findings. */
|
|
304
|
+
round: number;
|
|
305
|
+
/** The revision the previous round reviewed; null for a legacy record. */
|
|
306
|
+
prior: { review_commit: string; review_tree: string } | null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function reviewDeltaFields(
|
|
310
|
+
root: string,
|
|
311
|
+
delta: ReviewDeltaInput | undefined,
|
|
312
|
+
currentTree: string,
|
|
313
|
+
): Pick<ReviewManifestV5, "prior_review_commit" | "delta_paths" | "delta_skipped"> {
|
|
314
|
+
if (!delta || delta.round < REVIEW_DELTA_FROM_ROUND) return {};
|
|
315
|
+
if (!delta.prior) return { delta_skipped: "prior_review_revision_absent" };
|
|
316
|
+
try {
|
|
317
|
+
gitEvidence(root, ["cat-file", "-e", `${delta.prior.review_commit}^{commit}`]);
|
|
318
|
+
gitEvidence(root, ["cat-file", "-e", `${delta.prior.review_tree}^{tree}`]);
|
|
319
|
+
const names = gitEvidenceBytes(root, [
|
|
320
|
+
"diff-tree",
|
|
321
|
+
"-r",
|
|
322
|
+
"--name-only",
|
|
323
|
+
"--no-renames",
|
|
324
|
+
"-z",
|
|
325
|
+
delta.prior.review_tree,
|
|
326
|
+
currentTree,
|
|
327
|
+
]).toString("utf8");
|
|
328
|
+
return {
|
|
329
|
+
prior_review_commit: delta.prior.review_commit,
|
|
330
|
+
delta_paths: names.split("\0").filter(Boolean).sort(),
|
|
331
|
+
};
|
|
332
|
+
} catch {
|
|
333
|
+
// A pruned or unreachable prior revision must never block Review.
|
|
334
|
+
return { delta_skipped: "prior_review_revision_unresolvable" };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
293
338
|
function manifestDigest(manifest: Omit<ReviewManifestV5, "manifest_digest">): string {
|
|
294
339
|
return `sha256:${createHash("sha256").update(JSON.stringify(manifest)).digest("hex")}`;
|
|
295
340
|
}
|
|
@@ -485,6 +530,8 @@ export function captureReviewManifest(
|
|
|
485
530
|
artifactState: string;
|
|
486
531
|
risk: string;
|
|
487
532
|
outcomes: Record<string, ReviewOutcome>;
|
|
533
|
+
/** Present once the task has a Review history; absent keeps rounds 1-2 byte-identical. */
|
|
534
|
+
delta?: ReviewDeltaInput;
|
|
488
535
|
},
|
|
489
536
|
): ReviewManifestV5 {
|
|
490
537
|
const { snapshot, revision } = publishInput(root, input);
|
|
@@ -508,6 +555,7 @@ export function captureReviewManifest(
|
|
|
508
555
|
lifecycle: input.lifecycle,
|
|
509
556
|
artifact_state: input.artifactState,
|
|
510
557
|
risk: input.risk,
|
|
558
|
+
...reviewDeltaFields(root, input.delta, revision.review_tree),
|
|
511
559
|
};
|
|
512
560
|
const manifest = { ...unsigned, manifest_digest: manifestDigest(unsigned) };
|
|
513
561
|
if (Buffer.byteLength(JSON.stringify(manifest)) > MAX_REVIEW_BUNDLE_BYTES)
|