immune-brain 5.3.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 +13 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +16130 -12734
- 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-planner.md +57 -16
- 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 +21 -1
- 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 +1 -7
- 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 +8 -15
- 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 +13 -1
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +5 -0
- package/plugins/immune-brain/skills/imm-run/SKILL.md +12 -8
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Read-only response continuation. Kernel/batch facts own every action and settlement.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import type { StatusProjectionResult } from "./coordinator";
|
|
4
|
+
import { captureGitWorkspaceSnapshot, pathMatchesScope } from "../workspace_scope";
|
|
5
|
+
import { startableChildren } from "../unattended/batch_schedule";
|
|
6
|
+
import { readAnyBatchRunState, isLaneBatchRecord } from "../unattended/batch_state";
|
|
7
|
+
import { observeInitiative } from "../unattended/batch_plan";
|
|
8
|
+
import { observeParentCompletion, observeNativeParent } from "../unattended/batch_git";
|
|
9
|
+
import { projectTaskStatus } from "./coordinator";
|
|
10
|
+
import { readActiveClaimTaskId } from "../unattended/batch_preflight";
|
|
11
|
+
import { readTaskIntent } from "../kernel/intent";
|
|
12
|
+
import { readSettledTaskEvidence, readTaskRecordRaw } from "../kernel/storage";
|
|
13
|
+
|
|
14
|
+
export interface RunSessionIdentity {
|
|
15
|
+
workspace: string;
|
|
16
|
+
session_id: string;
|
|
17
|
+
}
|
|
18
|
+
export type RunBinding = RunSessionIdentity & (
|
|
19
|
+
| { kind: "task"; task_id: string; run_id: string }
|
|
20
|
+
| { kind: "parent"; initiative_slug: string; batch_id: string | null; plan_digest: string; final_verification?: string[]; blocked_native_tasks?: string[] }
|
|
21
|
+
);
|
|
22
|
+
export type ReviewContinuationState = "unobserved" | "ready" | "running" | "returned" | "rejected";
|
|
23
|
+
export interface RunContinuationState {
|
|
24
|
+
binding: RunBinding;
|
|
25
|
+
progress_digest: string | null;
|
|
26
|
+
unchanged_observations: number;
|
|
27
|
+
boundary_id: string | null;
|
|
28
|
+
}
|
|
29
|
+
export interface TaskContinuationObservation extends RunSessionIdentity {
|
|
30
|
+
kind: "task";
|
|
31
|
+
boundary_id: string;
|
|
32
|
+
status: StatusProjectionResult;
|
|
33
|
+
/** Source byte digest, including unstaged scope edits; never a delivery proof. */
|
|
34
|
+
source_digest: string;
|
|
35
|
+
review: ReviewContinuationState;
|
|
36
|
+
recovery_action?: string;
|
|
37
|
+
/** A concrete failed/cancelled Host operation; it must never be auto-retried. */
|
|
38
|
+
host_failure?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface ParentContinuationObservation extends RunSessionIdentity {
|
|
41
|
+
kind: "parent";
|
|
42
|
+
boundary_id: string;
|
|
43
|
+
initiative_slug: string;
|
|
44
|
+
batch_id: string | null;
|
|
45
|
+
plan_digest: string;
|
|
46
|
+
/** Includes dependency readiness, integrations, corrections and final verification. */
|
|
47
|
+
progress_digest: string;
|
|
48
|
+
all_required_integrated: boolean;
|
|
49
|
+
final_passed: boolean;
|
|
50
|
+
pending_repair: boolean;
|
|
51
|
+
runnable: boolean;
|
|
52
|
+
executors_running: boolean;
|
|
53
|
+
next_action: string;
|
|
54
|
+
blocked_reason?: string;
|
|
55
|
+
host_failure?: string;
|
|
56
|
+
}
|
|
57
|
+
export type RunContinuationDecision = {
|
|
58
|
+
action: "continue" | "wait" | "pause" | "settled" | "unbound";
|
|
59
|
+
reason: string;
|
|
60
|
+
next_action: string | null;
|
|
61
|
+
state: RunContinuationState;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const UNCHANGED_CONTINUATION_LIMIT = 2;
|
|
65
|
+
|
|
66
|
+
export function bindRun(binding: RunBinding): RunContinuationState {
|
|
67
|
+
if (!binding.workspace || !binding.session_id ||
|
|
68
|
+
(binding.kind === "task" ? !binding.task_id || !binding.run_id : !binding.initiative_slug || (binding.batch_id !== null && !binding.batch_id) || !binding.plan_digest))
|
|
69
|
+
throw new Error("run continuation requires an exact session and committed run identity");
|
|
70
|
+
if (binding.kind === "parent" && binding.blocked_native_tasks !== undefined &&
|
|
71
|
+
(!Array.isArray(binding.blocked_native_tasks) || !binding.blocked_native_tasks.every(id => typeof id === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id))))
|
|
72
|
+
throw new Error("invalid failed native task observations");
|
|
73
|
+
return { binding, progress_digest: null, unchanged_observations: 0, boundary_id: null };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Suppress only this rejected gate; independently authorized branches remain runnable. */
|
|
77
|
+
export async function parkFailedCriticalGate(binding: RunBinding, taskId: string | undefined): Promise<boolean> {
|
|
78
|
+
if (binding.kind !== "parent" || !taskId || readActiveClaimTaskId(binding.workspace) === taskId) return false;
|
|
79
|
+
const parent = await observeInitiative(binding.workspace, binding.initiative_slug);
|
|
80
|
+
if (!parent.tasks.some(task => task.task_id === taskId) || readTaskIntent(binding.workspace, taskId).intent.risk !== "critical") return false;
|
|
81
|
+
binding.blocked_native_tasks = [...new Set([...(binding.blocked_native_tasks ?? []), taskId])];
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Explicit cancellation or a planning entry releases conversational continuation only. */
|
|
86
|
+
export function releasesRunBinding(prompt: string, phase: "run" | "planner" | "brainstorm" | null): boolean {
|
|
87
|
+
return phase === "planner" || phase === "brainstorm" ||
|
|
88
|
+
/^(?:stop|cancel|pause)(?:\s+(?:execution|the task|this run))?[.!\s]*$/i.test(prompt.trim()) ||
|
|
89
|
+
/^(?:请)?(?:停止|取消|暂停)(?:执行|任务|本次运行|这个任务)?[。!!\s]*$/.test(prompt.trim());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function digest(value: unknown): string {
|
|
93
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function scopedSourceDigest(root: string, scope: readonly string[]): string {
|
|
97
|
+
const snapshot = captureGitWorkspaceSnapshot(root);
|
|
98
|
+
if (!snapshot) throw new Error("cannot observe source progress without a Git workspace");
|
|
99
|
+
return digest(Object.entries(snapshot.dirty_files).filter(([path]) => scope.some(entry => pathMatchesScope(path, entry))));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function finalProgress(report: import("../unattended/batch_final_verification").FinalVerificationReport | undefined): unknown {
|
|
103
|
+
return report ? { passed: report.passed, head: report.head,
|
|
104
|
+
results: report.results.map(result => ({ command: result.command, passed: result.passed, exit_code: result.exit_code, signal: result.signal })) } : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function observeParentRun(binding: RunBinding & { kind: "parent" },
|
|
108
|
+
boundaryId: string, review: ReviewContinuationState = "unobserved",
|
|
109
|
+
recoveryAction?: string): Promise<ParentContinuationObservation> {
|
|
110
|
+
const root = binding.workspace;
|
|
111
|
+
if (binding.batch_id === null) {
|
|
112
|
+
const facts = await observeNativeParent(root, binding.initiative_slug, undefined, binding.blocked_native_tasks);
|
|
113
|
+
if (!facts || facts.plan_digest !== binding.plan_digest) throw new Error("native Parent plan identity changed");
|
|
114
|
+
let nextAction = ["verify", "repair"].includes(facts.action) ?
|
|
115
|
+
`Call start_unattended_batch for ${binding.initiative_slug} with final_verification ${JSON.stringify(binding.final_verification ?? facts.native?.final_verification.results.map(result => result.command) ?? [])}; if it fails, diagnose and canonically prepare/validate the stable original-goal repair before fresh native Enrollment` : facts.next_action;
|
|
116
|
+
let runnable = ["verify", "repair", "integrate"].includes(facts.action) || (facts.action === "enroll" && facts.task_id !== null);
|
|
117
|
+
let running = false;
|
|
118
|
+
let taskProgress: unknown = null;
|
|
119
|
+
if (facts.action === "drive" && facts.task_id) {
|
|
120
|
+
const status = await projectTaskStatus(root, facts.task_id);
|
|
121
|
+
const task = readTaskRecordRaw(root, facts.task_id).record;
|
|
122
|
+
if (status.error || !task || !status.projection.run_id) throw new Error(status.error ?? "native Parent has no exact child run");
|
|
123
|
+
const source = scopedSourceDigest(root, task.intent_snapshot.scope_hint);
|
|
124
|
+
const decision = decideRunContinuation(bindRun({ kind: "task", workspace: root, session_id: binding.session_id,
|
|
125
|
+
task_id: facts.task_id, run_id: status.projection.run_id }), { kind: "task", workspace: root, session_id: binding.session_id,
|
|
126
|
+
boundary_id: boundaryId, status, source_digest: source, review, recovery_action: recoveryAction });
|
|
127
|
+
runnable = decision.action === "continue"; running = decision.action === "wait";
|
|
128
|
+
nextAction = decision.next_action ?? decision.reason; taskProgress = decision.state.progress_digest;
|
|
129
|
+
}
|
|
130
|
+
if (facts.action === "repair" && (facts.native?.repair_tasks?.length ?? 0) >= 2) {
|
|
131
|
+
runnable = false; nextAction = "Parent coordinator correction budget exhausted; request a user decision";
|
|
132
|
+
}
|
|
133
|
+
return { kind: "parent", workspace: root, session_id: binding.session_id, boundary_id: boundaryId,
|
|
134
|
+
initiative_slug: binding.initiative_slug, batch_id: null, plan_digest: facts.plan_digest,
|
|
135
|
+
progress_digest: digest({ proof: facts.proof, action: facts.action, repair_tasks: facts.native?.repair_tasks,
|
|
136
|
+
final: finalProgress(facts.native?.final_verification), taskProgress }),
|
|
137
|
+
all_required_integrated: facts.proof.missing_task_ids.length === 0, final_passed: facts.proof.final_passed,
|
|
138
|
+
pending_repair: facts.action === "repair", runnable, executors_running: running,
|
|
139
|
+
next_action: nextAction, blocked_reason: facts.proof.reason ?? undefined };
|
|
140
|
+
}
|
|
141
|
+
const record = readAnyBatchRunState(root, binding.batch_id);
|
|
142
|
+
if (!record || record.initiative_slug !== binding.initiative_slug || record.plan_digest !== binding.plan_digest)
|
|
143
|
+
throw new Error("Parent continuation has no matching persisted batch identity");
|
|
144
|
+
const plan = await observeInitiative(root, binding.initiative_slug);
|
|
145
|
+
const completion = await observeParentCompletion(root, binding.initiative_slug, plan.tasks.map(child => child.task_id));
|
|
146
|
+
let runnable = false;
|
|
147
|
+
let running = false;
|
|
148
|
+
let nextAction = `Call start_unattended_batch for ${binding.initiative_slug} to consume the current authorized handoff`;
|
|
149
|
+
let childProgress: unknown = null;
|
|
150
|
+
const activeTask = readActiveClaimTaskId(root);
|
|
151
|
+
const nativeTask = activeTask && !record.children.some(child => child.task_id === activeTask) &&
|
|
152
|
+
(plan.tasks.some(child => child.task_id === activeTask) || record.repair_tasks?.some(task => task.task_id === activeTask));
|
|
153
|
+
const activeChild = nativeTask ? { task_id: activeTask } : record.children.find(child => child.state === "enrolled" || child.state === "settled");
|
|
154
|
+
if (activeChild && (!isLaneBatchRecord(record) || nativeTask)) {
|
|
155
|
+
const status = await projectTaskStatus(root, activeChild.task_id);
|
|
156
|
+
if (status.error) throw new Error(status.error);
|
|
157
|
+
if (status.projection.lifecycle === "active" && status.projection.run_id) {
|
|
158
|
+
const task = readTaskRecordRaw(root, activeChild.task_id).record;
|
|
159
|
+
if (!task) throw new Error("Parent's active child has no TaskRecord");
|
|
160
|
+
const source = scopedSourceDigest(root, task.intent_snapshot.scope_hint);
|
|
161
|
+
const taskBinding = bindRun({ kind: "task", workspace: root, session_id: binding.session_id,
|
|
162
|
+
task_id: activeChild.task_id, run_id: status.projection.run_id });
|
|
163
|
+
const decision = decideRunContinuation(taskBinding, { kind: "task", workspace: root, session_id: binding.session_id,
|
|
164
|
+
boundary_id: boundaryId, status, source_digest: source, review, recovery_action: recoveryAction });
|
|
165
|
+
runnable = decision.action === "continue";
|
|
166
|
+
running = decision.action === "wait";
|
|
167
|
+
nextAction = decision.next_action ?? decision.reason;
|
|
168
|
+
childProgress = decision.state.progress_digest;
|
|
169
|
+
} else if (status.projection.lifecycle === "done") runnable = true;
|
|
170
|
+
} else {
|
|
171
|
+
const live = isLaneBatchRecord(record) ? record.children.filter(child => ["lane_admitted", "enrolled", "settled", "lane_committed"].includes(child.state)) : [];
|
|
172
|
+
running = live.length > 0;
|
|
173
|
+
const schedule = plan.tasks.map(task => ({ task_id: task.task_id, blocked_by: task.blocked_by,
|
|
174
|
+
scope_hint: readTaskIntent(root, task.task_id).intent.scope_hint,
|
|
175
|
+
state: completion.integrated_task_ids.includes(task.task_id) ? "committed" as const :
|
|
176
|
+
record.children.find(child => child.task_id === task.task_id)?.state ?? "needs_human" as const }));
|
|
177
|
+
runnable = record.batch_state === "running" && (startableChildren(schedule, isLaneBatchRecord(record) ? record.max_parallel : 1).length > 0 ||
|
|
178
|
+
live.length > 0);
|
|
179
|
+
if (running && runnable) nextAction = `Supervise the observed Lane Executor handoff for ${binding.initiative_slug}; use the Host's live-session wait/observation before resuming the batch, rather than repeatedly observing an unchanged batch`;
|
|
180
|
+
}
|
|
181
|
+
if (!activeChild && !running) {
|
|
182
|
+
for (const child of plan.tasks) {
|
|
183
|
+
if (binding.blocked_native_tasks?.includes(child.task_id)) continue;
|
|
184
|
+
if (record.children.some(candidate => candidate.task_id === child.task_id) || completion.integrated_task_ids.includes(child.task_id)) continue;
|
|
185
|
+
const intent = readTaskIntent(root, child.task_id);
|
|
186
|
+
if (intent.intent.risk !== "critical") continue;
|
|
187
|
+
if (readSettledTaskEvidence(root, child.task_id)?.proof.terminal_lifecycle === "done") {
|
|
188
|
+
runnable = true; nextAction = `Call start_unattended_batch for ${binding.initiative_slug} to integrate the settled critical Slice and resume the intact ordinary batch authorization`; break;
|
|
189
|
+
}
|
|
190
|
+
if (child.blocked_by.every(id => completion.integrated_task_ids.includes(id))) {
|
|
191
|
+
runnable = true; nextAction = `Invoke native single-task Enrollment for critical Slice ${child.task_id}; it consumes no ordinary batch slot. Drive its Loop to settlement, then resume Parent ${binding.initiative_slug}`; break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const failedFinal = record.final_verification_result?.passed === false && plan.tasks.every(child => completion.integrated_task_ids.includes(child.task_id));
|
|
196
|
+
const pendingRepair = record.repair_tasks?.some(task => !completion.integrated_task_ids.includes(task.task_id));
|
|
197
|
+
const exhausted = failedFinal && !activeChild && !pendingRepair && (record.repair_tasks?.length ?? 0) >= 2;
|
|
198
|
+
if (failedFinal && !activeChild) nextAction = pendingRepair
|
|
199
|
+
? `Call start_unattended_batch for ${binding.initiative_slug} to integrate the settled repair and run recorded final verification again`
|
|
200
|
+
: "Diagnose final verification, call prepareFinalRepairCandidate with the original goal, diagnosed minimum scope, recorded failure and spent repair count; canonically author/validate its stable candidate, then request fresh native Enrollment. Preserve settled deliveries";
|
|
201
|
+
if (exhausted) nextAction = "Parent coordinator correction budget exhausted; resolve the final failure with a user decision before further repair Enrollment";
|
|
202
|
+
return { kind: "parent", workspace: root, session_id: binding.session_id, boundary_id: boundaryId,
|
|
203
|
+
initiative_slug: record.initiative_slug, batch_id: record.batch_id, plan_digest: record.plan_digest,
|
|
204
|
+
progress_digest: digest({ children: record.children.map(child => ({ task_id: child.task_id, state: child.state, commit: child.commit })),
|
|
205
|
+
commits: record.commits, completion, childProgress, final: finalProgress(record.final_verification_result) }), all_required_integrated: completion.missing_task_ids.length === 0,
|
|
206
|
+
final_passed: completion.final_passed, pending_repair: failedFinal, runnable: !exhausted && (runnable || (failedFinal && !activeChild)),
|
|
207
|
+
executors_running: running, next_action: nextAction, blocked_reason: completion.reason ?? undefined };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function decideRunContinuation(previous: RunContinuationState,
|
|
211
|
+
observation: TaskContinuationObservation | ParentContinuationObservation): RunContinuationDecision {
|
|
212
|
+
const result = (action: RunContinuationDecision["action"], reason: string, next_action: string | null,
|
|
213
|
+
state = previous): RunContinuationDecision => ({ action, reason, next_action, state });
|
|
214
|
+
const binding = previous.binding;
|
|
215
|
+
if (binding.workspace !== observation.workspace || binding.session_id !== observation.session_id)
|
|
216
|
+
return result("unbound", "Host session or workspace changed", null);
|
|
217
|
+
if (binding.kind !== observation.kind)
|
|
218
|
+
return result("pause", "run observation does not match the bound entry", null);
|
|
219
|
+
if (observation.host_failure)
|
|
220
|
+
return result("pause", observation.host_failure, null);
|
|
221
|
+
|
|
222
|
+
let progressDigest: string;
|
|
223
|
+
let nextAction: string;
|
|
224
|
+
let wait = false;
|
|
225
|
+
if (binding.kind === "parent" && observation.kind === "parent") {
|
|
226
|
+
if (binding.initiative_slug !== observation.initiative_slug || binding.batch_id !== observation.batch_id || binding.plan_digest !== observation.plan_digest)
|
|
227
|
+
return result("pause", "batch identity changed; reread its authorized plan", null);
|
|
228
|
+
if (observation.all_required_integrated && observation.final_passed && !observation.pending_repair)
|
|
229
|
+
return result("settled", "every required delivery is integrated and final verification passed", null);
|
|
230
|
+
if (!observation.runnable && !observation.executors_running)
|
|
231
|
+
return result("pause", observation.blocked_reason ?? "no executable Parent work remains", observation.next_action);
|
|
232
|
+
progressDigest = observation.progress_digest;
|
|
233
|
+
nextAction = observation.next_action;
|
|
234
|
+
wait = !observation.runnable && observation.executors_running;
|
|
235
|
+
} else if (binding.kind === "task" && observation.kind === "task") {
|
|
236
|
+
const { status } = observation;
|
|
237
|
+
const p = status.projection;
|
|
238
|
+
if (status.error || status.task_id !== binding.task_id || p.run_id !== binding.run_id)
|
|
239
|
+
return result("pause", status.error ?? "Kernel task/run identity changed", null);
|
|
240
|
+
if (p.lifecycle === "done" && p.completion_ready && status.claim === null)
|
|
241
|
+
return result("settled", "Kernel terminal settlement confirmed", null);
|
|
242
|
+
if (p.lifecycle === "stopped" && status.claim === null)
|
|
243
|
+
return result("pause", "Kernel task stopped", null);
|
|
244
|
+
if (status.claim?.task_id !== binding.task_id || status.claim.lifecycle_status !== "active" || p.lifecycle !== "active")
|
|
245
|
+
return result("pause", "Kernel authority is missing or contradictory", null);
|
|
246
|
+
if (p.unresolved_user_decision_ids.length || p.replan_required_ids.length || p.open_user_decision_count ||
|
|
247
|
+
p.next_obligation === "resolve_user_decision" || p.next_obligation === "revise_intent" || p.authorization.state !== "none" || p.authorization.blocked)
|
|
248
|
+
return result("pause", "Kernel requires a concrete user decision or authorization", "Use the existing native authorization operation");
|
|
249
|
+
progressDigest = digest({ lifecycle: p.lifecycle, artifact_state: p.artifact_state, intent_content_hash: p.intent_content_hash,
|
|
250
|
+
diff_hash: p.diff_hash, next_obligation: p.next_obligation, fresh_acceptance_ids: [...p.fresh_acceptance_ids].sort(),
|
|
251
|
+
fresh_approval_kinds: [...p.fresh_approval_kinds].sort(), blocking_finding_ids: [...p.blocking_finding_ids].sort(),
|
|
252
|
+
source_digest: observation.source_digest, review: observation.review });
|
|
253
|
+
if (p.next_obligation === "run_review") {
|
|
254
|
+
switch (observation.review) {
|
|
255
|
+
case "ready": nextAction = "Invoke the exact reserved foreground reviewer once"; break;
|
|
256
|
+
case "running": nextAction = "Await the observed foreground reviewer; do not dispatch another"; wait = true; break;
|
|
257
|
+
case "returned": nextAction = "Call submit_review immediately with the observed verdict before editing"; break;
|
|
258
|
+
case "rejected":
|
|
259
|
+
if (!observation.recovery_action) return result("pause", "Review submission failed without an observed recovery action", null);
|
|
260
|
+
nextAction = observation.recovery_action; break;
|
|
261
|
+
case "unobserved": nextAction = "Call advance_assurance to recover or prepare the current Review reservation"; break;
|
|
262
|
+
}
|
|
263
|
+
} else if (status.warnings?.some(warning => warning.code === "unstaged_scope_paths")) {
|
|
264
|
+
nextAction = "Finish the in-scope implementation and stage task-owned changes before advance_assurance";
|
|
265
|
+
} else {
|
|
266
|
+
switch (p.next_obligation) {
|
|
267
|
+
case "submit_assurance": case "run_qa": nextAction = "Complete in-scope implementation and focused checks, then call advance_assurance"; break;
|
|
268
|
+
case "resolve_findings": nextAction = "Repair the open findings, verify, resolve them and call advance_assurance"; break;
|
|
269
|
+
case "complete": nextAction = "Call advance_assurance to finish Kernel settlement"; break;
|
|
270
|
+
default: return result("pause", "Kernel has no actionable obligation", null);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
return result("pause", "run observation does not match the bound entry", null);
|
|
275
|
+
}
|
|
276
|
+
if (previous.boundary_id === observation.boundary_id)
|
|
277
|
+
return result("wait", "duplicate Host boundary", null);
|
|
278
|
+
const unchanged = wait ? previous.unchanged_observations : previous.progress_digest === progressDigest ? previous.unchanged_observations + 1 : 0;
|
|
279
|
+
const state = { ...previous, progress_digest: progressDigest, unchanged_observations: unchanged, boundary_id: observation.boundary_id };
|
|
280
|
+
// Running reviewers/executors progress through their own supervised exit boundary.
|
|
281
|
+
if (!wait && unchanged >= UNCHANGED_CONTINUATION_LIMIT)
|
|
282
|
+
return result("pause", "no substantive progress across continuation boundaries", nextAction, state);
|
|
283
|
+
return result(wait ? "wait" : "continue", wait ? "observed foreground work is still running" : "authorized work remains", nextAction, state);
|
|
284
|
+
}
|
|
@@ -13,7 +13,7 @@ import type { TaskRecord, TaskApprovalV2 } from "../kernel/types";
|
|
|
13
13
|
import { captureGitTaskSnapshot } from "../workspace_scope";
|
|
14
14
|
import { parseVerificationDescriptor } from "../verification_descriptor";
|
|
15
15
|
import type { VerificationDescriptor } from "./verification";
|
|
16
|
-
import { captureReviewBundle, captureReviewManifest, type ReviewBundle, type ReviewManifestV5, type ReviewRevision } from "./review_evidence";
|
|
16
|
+
import { captureReviewBundle, captureReviewManifest, REVIEW_DELTA_FROM_ROUND, type ReviewBundle, type ReviewDeltaInput, type ReviewManifestV5, type ReviewRevision } from "./review_evidence";
|
|
17
17
|
import { reviewReworkFindings, reviewAdvisoryRecords, type SnapshotDescriptor, type AssuranceRole, type AssuranceVerdict, type HostContext } from "./coordinator";
|
|
18
18
|
import type { InvocationToken } from "./invocations";
|
|
19
19
|
import type { ConfirmationReferenceSource } from "./host_port";
|
|
@@ -37,6 +37,7 @@ export interface SnapshotDescriptorInput {
|
|
|
37
37
|
acceptance: Array<{ id: string; assertion: string; verification: string }>;
|
|
38
38
|
dirty_files?: string[];
|
|
39
39
|
review_bundle_digest?: string | null;
|
|
40
|
+
review_delta?: SnapshotDescriptor["review_delta"];
|
|
40
41
|
/** Present only when Review authority binds an immutable Git revision (v4). */
|
|
41
42
|
review_revision?: {
|
|
42
43
|
contract: "assurance_kernel/review_revision_identity/v1";
|
|
@@ -68,11 +69,34 @@ export function buildSnapshot(input: SnapshotDescriptorInput): SnapshotDescripto
|
|
|
68
69
|
dirty_files: [...(input.dirty_files ?? [])].sort(),
|
|
69
70
|
review_bundle_digest: input.review_bundle_digest ?? null,
|
|
70
71
|
...(input.review_revision ? { review_revision: input.review_revision } : {}),
|
|
72
|
+
...(input.review_delta ? { review_delta: input.review_delta } : {}),
|
|
71
73
|
root: resolve(input.root),
|
|
72
74
|
};
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The Review round the next dispatch opens and the revision the previous round
|
|
80
|
+
* analyzed. Both ensureReviewRevision and the snapshot read it through here so
|
|
81
|
+
* the manifest digest they bind cannot diverge. A record with no persisted
|
|
82
|
+
* prior revision yields `prior: null`, which skips the delta rule.
|
|
83
|
+
*/
|
|
84
|
+
function reviewDeltaInput(record: TaskRecord): ReviewDeltaInput {
|
|
85
|
+
const round = Math.max(
|
|
86
|
+
0,
|
|
87
|
+
...record.findings.filter((item) => item.source === "review").map((item) => item.review_round ?? 0),
|
|
88
|
+
) + 1;
|
|
89
|
+
const prior = [...record.history]
|
|
90
|
+
.reverse()
|
|
91
|
+
.find((entry) => entry.type === "request_rework" && entry.reason.split("\n")[0] === `review_round_${round - 1}`);
|
|
92
|
+
return {
|
|
93
|
+
round,
|
|
94
|
+
prior: prior?.review_revision
|
|
95
|
+
? { review_commit: prior.review_revision.review_commit, review_tree: prior.review_revision.review_tree }
|
|
96
|
+
: null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
76
100
|
export async function ensureReviewRevision(
|
|
77
101
|
root: string,
|
|
78
102
|
taskId: string,
|
|
@@ -103,6 +127,7 @@ export async function ensureReviewRevision(
|
|
|
103
127
|
// write that exact summary, so the submit-time digest comparison held by
|
|
104
128
|
// coincidence rather than by construction.
|
|
105
129
|
outcomes: qaOutcomes(record),
|
|
130
|
+
delta: reviewDeltaInput(record),
|
|
106
131
|
});
|
|
107
132
|
return {
|
|
108
133
|
contract: "assurance_kernel/review_revision/v1",
|
|
@@ -182,6 +207,7 @@ export async function buildAssuranceSnapshot(
|
|
|
182
207
|
artifactState: projection.projection.artifact_state,
|
|
183
208
|
risk: intent.risk,
|
|
184
209
|
outcomes: qaOutcomes(record.record),
|
|
210
|
+
delta: reviewDeltaInput(record.record),
|
|
185
211
|
})
|
|
186
212
|
: null;
|
|
187
213
|
const taskSnapshot = !reviewBundle && !reviewManifest
|
|
@@ -221,6 +247,7 @@ export async function buildAssuranceSnapshot(
|
|
|
221
247
|
manifest_digest: reviewManifest.manifest_digest,
|
|
222
248
|
}
|
|
223
249
|
: undefined,
|
|
250
|
+
review_delta: reviewManifest ? reviewDeltaOf(reviewManifest, record.record) : undefined,
|
|
224
251
|
}),
|
|
225
252
|
descriptors,
|
|
226
253
|
reviewBundle,
|
|
@@ -228,6 +255,17 @@ export async function buildAssuranceSnapshot(
|
|
|
228
255
|
};
|
|
229
256
|
}
|
|
230
257
|
|
|
258
|
+
function reviewDeltaOf(
|
|
259
|
+
manifest: ReviewManifestV5,
|
|
260
|
+
record: TaskRecord,
|
|
261
|
+
): SnapshotDescriptor["review_delta"] {
|
|
262
|
+
const { round } = reviewDeltaInput(record);
|
|
263
|
+
if (round < REVIEW_DELTA_FROM_ROUND) return undefined;
|
|
264
|
+
return manifest.delta_paths
|
|
265
|
+
? { round, prior_review_commit: manifest.prior_review_commit!, delta_paths: manifest.delta_paths }
|
|
266
|
+
: { round, skipped: manifest.delta_skipped ?? "prior_review_revision_absent" };
|
|
267
|
+
}
|
|
268
|
+
|
|
231
269
|
function qaOutcomes(
|
|
232
270
|
record: TaskRecord,
|
|
233
271
|
): Record<string, { status: "passed" | "failed" | "blocked"; summary: string }> {
|
|
@@ -301,7 +339,7 @@ export function createVerdictAuthority(options: {
|
|
|
301
339
|
recoverKernelStoreFollowUps(ctx.cwd, snapshot.task_id);
|
|
302
340
|
const record = readTaskRecordRaw(ctx.cwd, snapshot.task_id).record;
|
|
303
341
|
if (!record) throw new Error("TaskRecord disappeared before authority application");
|
|
304
|
-
const priorIntentToken = readTaskIntent(ctx.cwd, snapshot.task_id, record.intent_ref.path).token;
|
|
342
|
+
const priorIntentToken = readTaskIntent(ctx.cwd, snapshot.task_id, record.intent_ref.path, record.intent_snapshot).token;
|
|
305
343
|
const now = new Date().toISOString();
|
|
306
344
|
const findings = verdict.decision === "rework" ? reviewReworkFindings(verdict) : [];
|
|
307
345
|
const advisories = verdict.decision === "pass" ? reviewAdvisoryRecords(verdict) : [];
|
|
@@ -316,7 +354,11 @@ export function createVerdictAuthority(options: {
|
|
|
316
354
|
} : undefined;
|
|
317
355
|
const op = verdict.decision === "rework" ? "request_rework" : "record_approval";
|
|
318
356
|
const payload = approval ? { approval } : { findings };
|
|
319
|
-
|
|
357
|
+
// Only a Review-authored rework persists the revision it analyzed; the next
|
|
358
|
+
// round derives its delta from it. QA rework never carries one.
|
|
359
|
+
const reworkRevision = verdict.decision === "rework" && snapshot.role === "review" && snapshot.review_revision
|
|
360
|
+
? { review_revision: snapshot.review_revision } : {};
|
|
361
|
+
const action = capabilityActionFor({ op, task_id: snapshot.task_id, at: now, actor_id: actorId, ...payload, ...reworkRevision });
|
|
320
362
|
const capability = registry.issue({
|
|
321
363
|
authority_kind: snapshot.role, task_id: snapshot.task_id,
|
|
322
364
|
...(snapshot.run_id ? { run_id: snapshot.run_id } : {}),
|
|
@@ -332,7 +374,7 @@ export function createVerdictAuthority(options: {
|
|
|
332
374
|
// then afterCommit. A thrown hook never undoes the committed transition.
|
|
333
375
|
const settlement = (async () => app.execute({ root: ctx.cwd, task_id: snapshot.task_id,
|
|
334
376
|
operation: approval ? { op: "record_approval", capability, approval, actor_id: actorId }
|
|
335
|
-
: { op: "request_rework", capability, findings, actor_id: actorId }, prior_intent_token: priorIntentToken, now }))();
|
|
377
|
+
: { op: "request_rework", capability, findings, actor_id: actorId, ...reworkRevision }, prior_intent_token: priorIntentToken, now }))();
|
|
336
378
|
let hookFailed = false;
|
|
337
379
|
let hookError: unknown;
|
|
338
380
|
try { hooks.onCommit?.(); } catch (error) { hookFailed = true; hookError = error; }
|
|
@@ -3,7 +3,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
3
3
|
import { execFileSync, spawn } from "node:child_process";
|
|
4
4
|
import { accessSync, constants, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
5
5
|
import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
-
import { VERIFICATION_DESCRIPTOR_BOUNDS, VerificationDescriptorError, type VerificationCommand } from "../verification_descriptor";
|
|
6
|
+
import { VERIFICATION_DESCRIPTOR_BOUNDS, VerificationDescriptorError, parseVerificationDescriptor, type VerificationCommand, type VerificationDescriptor } from "../verification_descriptor";
|
|
7
7
|
export * from "../verification_descriptor";
|
|
8
8
|
|
|
9
9
|
export interface ExecutableIdentity {
|
|
@@ -15,8 +15,6 @@ export interface ExecutableIdentity {
|
|
|
15
15
|
}
|
|
16
16
|
export interface FrozenCommand {
|
|
17
17
|
entry: ExecutableIdentity;
|
|
18
|
-
interpreter: ExecutableIdentity | null;
|
|
19
|
-
interpreter_args: string[];
|
|
20
18
|
}
|
|
21
19
|
export interface VerificationResult {
|
|
22
20
|
exit_code: number;
|
|
@@ -54,6 +52,21 @@ export function verificationPath(): string {
|
|
|
54
52
|
return (process.env.PATH ?? "").split(delimiter).filter(path => isAbsolute(path)).join(delimiter);
|
|
55
53
|
}
|
|
56
54
|
|
|
55
|
+
// A hosting process that carries one of these would point a command's Git calls
|
|
56
|
+
// at its own repository instead of the delivery materialization. Every other
|
|
57
|
+
// variable, including Git identity and transport settings, is user environment.
|
|
58
|
+
const GIT_REPOSITORY_BINDINGS: ReadonlySet<string> = new Set([
|
|
59
|
+
"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY",
|
|
60
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_NAMESPACE", "GIT_PREFIX",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
function verificationEnvironment(path: string, processToken: string): Record<string, string> {
|
|
64
|
+
const env: Record<string, string> = {};
|
|
65
|
+
for (const [name, value] of Object.entries(process.env))
|
|
66
|
+
if (value !== undefined && !GIT_REPOSITORY_BINDINGS.has(name)) env[name] = value;
|
|
67
|
+
return { ...env, PATH: path, IMM_VERIFICATION_PROCESS_TOKEN: processToken };
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
function toolPath(name: string, path: string): string {
|
|
58
71
|
for (const dir of path.split(delimiter).filter(isAbsolute)) {
|
|
59
72
|
const candidate = join(dir, name);
|
|
@@ -77,42 +90,19 @@ export function resolveVerificationCommand(root: string, command: VerificationCo
|
|
|
77
90
|
insideVerificationRoot(root, resolve(root, command.cwd));
|
|
78
91
|
const entry = identity(command.executable.startsWith("./")
|
|
79
92
|
? insideVerificationRoot(root, resolve(root, command.executable)) : toolPath(command.executable, path));
|
|
80
|
-
|
|
81
|
-
let interpreter: ExecutableIdentity | null = null;
|
|
82
|
-
let interpreter_args: string[] = [];
|
|
83
|
-
if (prefix.startsWith("#!")) {
|
|
84
|
-
const lineEnd = prefix.indexOf("\n");
|
|
85
|
-
if (lineEnd < 0) throw new VerificationDescriptorError("verification interpreter declaration is unbounded");
|
|
86
|
-
const parts = prefix.slice(2, lineEnd).trim().split(/\s+/);
|
|
87
|
-
const name = parts.shift()!;
|
|
88
|
-
if (!isAbsolute(name)) throw new VerificationDescriptorError("verification interpreter must be absolute");
|
|
89
|
-
if (name === "/usr/bin/env" || name === "/bin/env") {
|
|
90
|
-
if (parts.length !== 1 || !/^[A-Za-z0-9_][A-Za-z0-9_.+-]*$/.test(parts[0]!))
|
|
91
|
-
throw new VerificationDescriptorError("verification interpreter resolution is ambiguous; use a direct interpreter command");
|
|
92
|
-
interpreter = identity(toolPath(parts[0]!, path));
|
|
93
|
-
} else {
|
|
94
|
-
if (parts.length > 1) throw new VerificationDescriptorError("verification interpreter arguments are ambiguous");
|
|
95
|
-
interpreter = identity(name);
|
|
96
|
-
interpreter_args = parts;
|
|
97
|
-
}
|
|
98
|
-
if (readFileSync(interpreter.path).subarray(0, 2).toString() === "#!")
|
|
99
|
-
throw new VerificationDescriptorError("verification interpreter is itself a script; resolve the host tool explicitly");
|
|
100
|
-
}
|
|
101
|
-
return { entry, interpreter, interpreter_args };
|
|
93
|
+
return { entry };
|
|
102
94
|
}
|
|
103
95
|
|
|
104
96
|
export function assertCommandIdentity(frozen: FrozenCommand): void {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
throw new VerificationDescriptorError("verification executable identity changed");
|
|
108
|
-
}
|
|
97
|
+
if (JSON.stringify(identity(frozen.entry.invocation_path)) !== JSON.stringify(frozen.entry))
|
|
98
|
+
throw new VerificationDescriptorError("verification executable identity changed");
|
|
109
99
|
}
|
|
110
100
|
|
|
111
101
|
export async function runFixedVerification(
|
|
112
102
|
root: string,
|
|
113
103
|
command: VerificationCommand,
|
|
114
104
|
frozen: FrozenCommand,
|
|
115
|
-
options: { signal?: AbortSignal;
|
|
105
|
+
options: { signal?: AbortSignal; path: string; _processScanner?: string; _procRoot?: string },
|
|
116
106
|
): Promise<VerificationResult> {
|
|
117
107
|
if (options.signal?.aborted) throw new VerificationAbortedError();
|
|
118
108
|
if (process.platform === "win32") throw new VerificationDescriptorError("fixed verification process-group isolation requires a POSIX host");
|
|
@@ -129,12 +119,9 @@ export async function runFixedVerification(
|
|
|
129
119
|
let stdout = Buffer.alloc(0), stderr = Buffer.alloc(0), captured = 0;
|
|
130
120
|
let aborted = false, timed_out = false, output_limited = false, settled = false;
|
|
131
121
|
const processToken = randomBytes(24).toString("hex");
|
|
132
|
-
const
|
|
133
|
-
? [...frozen.interpreter_args, frozen.entry.path, ...command.argv] : command.argv;
|
|
134
|
-
const child = spawn(frozen.interpreter?.invocation_path ?? frozen.entry.invocation_path, args, {
|
|
122
|
+
const child = spawn(frozen.entry.invocation_path, command.argv, {
|
|
135
123
|
cwd, shell: false, detached: true,
|
|
136
|
-
env:
|
|
137
|
-
IMM_VERIFICATION_PROCESS_TOKEN: processToken },
|
|
124
|
+
env: verificationEnvironment(options.path, processToken),
|
|
138
125
|
stdio: ["ignore", "pipe", "pipe"],
|
|
139
126
|
});
|
|
140
127
|
const killGroup = () => {
|
|
@@ -282,3 +269,64 @@ export function findingsDigest(findings: Array<{ id: string; kind: string; accep
|
|
|
282
269
|
const normalized = findings.map(f => JSON.stringify({ acceptance_id: f.acceptance_id, id: f.id, kind: f.kind, summary: f.summary }));
|
|
283
270
|
return `sha256:${createHash("sha256").update(`[${normalized.join(",")}]`).digest("hex")}`;
|
|
284
271
|
}
|
|
272
|
+
|
|
273
|
+
export const QA_BASELINE_PATH = "docs/agents/qa-baseline.json";
|
|
274
|
+
export const QA_BASELINE_CONTRACT = "assurance_kernel/qa_baseline/v1";
|
|
275
|
+
const QA_BASELINE_MAX_BYTES = 262_144;
|
|
276
|
+
const QA_BASELINE_MAX_CHECKS = 16;
|
|
277
|
+
|
|
278
|
+
export interface QaBaselineCheck {
|
|
279
|
+
id: string;
|
|
280
|
+
descriptor: VerificationDescriptor;
|
|
281
|
+
}
|
|
282
|
+
export interface QaBaseline {
|
|
283
|
+
/** Tree of the Enrollment `base_head`, the one a pre-existing failure is rerun against. */
|
|
284
|
+
tree: string;
|
|
285
|
+
checks: QaBaselineCheck[];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function baselineGit(root: string, args: string[]): string {
|
|
289
|
+
const env: Record<string, string> = {};
|
|
290
|
+
for (const [name, value] of Object.entries(process.env))
|
|
291
|
+
if (value !== undefined && !GIT_REPOSITORY_BINDINGS.has(name)) env[name] = value;
|
|
292
|
+
return execFileSync("git", ["-C", root, ...args], {
|
|
293
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: QA_BASELINE_MAX_BYTES * 2,
|
|
294
|
+
env: { ...env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0" },
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Read the project QA baseline from the Enrollment `base_head` tree, never from
|
|
300
|
+
* the delivery, so a task cannot weaken its own baseline. An absent file adds
|
|
301
|
+
* no checks; an unreadable commit or an invalid file fails closed.
|
|
302
|
+
*/
|
|
303
|
+
export function readQaBaseline(root: string, baseHead: string): QaBaseline {
|
|
304
|
+
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(baseHead))
|
|
305
|
+
throw new VerificationDescriptorError("qa baseline base_head has an invalid identity");
|
|
306
|
+
const tree = baselineGit(root, ["rev-parse", `${baseHead}^{tree}`]).trim();
|
|
307
|
+
if (!baselineGit(root, ["ls-tree", "--name-only", tree, "--", QA_BASELINE_PATH]).trim()) return { tree, checks: [] };
|
|
308
|
+
const text = baselineGit(root, ["cat-file", "blob", `${tree}:${QA_BASELINE_PATH}`]);
|
|
309
|
+
if (Buffer.byteLength(text) > QA_BASELINE_MAX_BYTES)
|
|
310
|
+
throw new VerificationDescriptorError("qa baseline exceeds the byte bound");
|
|
311
|
+
let value: unknown;
|
|
312
|
+
try { value = JSON.parse(text); }
|
|
313
|
+
catch { throw new VerificationDescriptorError("qa baseline is not valid JSON"); }
|
|
314
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
315
|
+
|| Object.keys(value).some(key => key !== "contract" && key !== "checks")
|
|
316
|
+
|| (value as { contract?: unknown }).contract !== QA_BASELINE_CONTRACT
|
|
317
|
+
|| !Array.isArray((value as { checks?: unknown }).checks)
|
|
318
|
+
|| (value as { checks: unknown[] }).checks.length > QA_BASELINE_MAX_CHECKS)
|
|
319
|
+
throw new VerificationDescriptorError("qa baseline contract is invalid");
|
|
320
|
+
const seen = new Set<string>();
|
|
321
|
+
const checks = (value as { checks: unknown[] }).checks.map((item): QaBaselineCheck => {
|
|
322
|
+
const check = item as Record<string, unknown> | null;
|
|
323
|
+
if (!check || typeof check !== "object" || Array.isArray(check)
|
|
324
|
+
|| Object.keys(check).some(key => key !== "id" && key !== "verification")
|
|
325
|
+
|| typeof check.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(check.id) || seen.has(check.id)
|
|
326
|
+
|| !check.verification || typeof check.verification !== "object" || Array.isArray(check.verification))
|
|
327
|
+
throw new VerificationDescriptorError("qa baseline check is invalid");
|
|
328
|
+
seen.add(check.id);
|
|
329
|
+
return { id: check.id, descriptor: parseVerificationDescriptor(JSON.stringify(check.verification)) };
|
|
330
|
+
});
|
|
331
|
+
return { tree, checks };
|
|
332
|
+
}
|