immune-brain 5.1.0 → 5.1.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/package.json +1 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +17 -5
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +165 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +66 -3
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +11 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +542 -79
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +8 -6
- package/plugins/immune-brain/dist/imm-planner.md +4 -1
- package/plugins/immune-brain/dist/role-prompts/code-review.md +10 -0
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +22 -4
- package/plugins/immune-brain/runtime/assurance/review_mediation.ts +107 -0
- package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +3 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +10 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +89 -54
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +76 -25
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -2
- package/plugins/immune-brain/runtime/kernel/completion.ts +24 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +15 -2
- package/plugins/immune-brain/runtime/kernel/spec_binding.ts +6 -0
- package/plugins/immune-brain/runtime/kernel/storage.ts +20 -4
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +33 -3
- package/plugins/immune-brain/runtime/kernel/types.ts +7 -0
- package/plugins/immune-brain/runtime/kernel/validation.ts +13 -2
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +10 -0
- package/plugins/immune-brain/runtime/role_prompt_bridge.ts +1 -1
- package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +222 -0
- package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +51 -14
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +8 -12
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +13 -1
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +2 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +44 -6
|
@@ -118,9 +118,11 @@ Agent defaults to the current Pi session model. Only an explicit Parent requirem
|
|
|
118
118
|
selects another Pi-configured model through host-native `Agent.model`. Kernel
|
|
119
119
|
Review returns a complete foreground `Agent` envelope with empty `name`, `model`,
|
|
120
120
|
`thinking`, `resume`, and `schedule` fields so Pi Host resolves the Review agent
|
|
121
|
-
configuration.
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
configuration. A compatible foreground Agent adapter must expose the reserved
|
|
122
|
+
dispatch and its paired result to the host's receipt observer. The Parent submits
|
|
123
|
+
without a verdict to apply those observed bytes; a provided verdict must match
|
|
124
|
+
the correlated receipt. Dispatch/result correlation is part of the local authority
|
|
125
|
+
contract.
|
|
124
126
|
|
|
125
127
|
## Pi Agent Invocation
|
|
126
128
|
|
|
@@ -144,12 +146,12 @@ Pi `Agent` has no `readonly` parameter; the empty tool policy, child type, and p
|
|
|
144
146
|
|
|
145
147
|
1. Planning、repository exploration、specialist advisory Review 和 work probe 必须 foreground 执行。Parent launches one child at a time,消费 direct terminal result,并在每个结果后 re-evaluates the remaining dispatch budget;不得把多个 foreground Agent 假定为并发 batch。
|
|
146
148
|
2. 普通 interactive advisory 不建立 acknowledgement deadline、后台 progress UI、completion push 或 late-notification recovery。活跃 Agent 使用 Pi 原生 foreground Tool row、host cancellation 和 steer。Footer 保持严格为空;No defined-value `setStatus` call is allowed。
|
|
147
|
-
3. Kernel Assurance runs in the foreground Tool call. `advance_assurance` awaits deterministic QA, emits bounded native updates, and returns `review_ready` with foreground `Agent` parameters. The Parent invokes a reviewer,
|
|
149
|
+
3. Kernel Assurance runs in the foreground Tool call. `advance_assurance` awaits deterministic QA, emits bounded native updates, and returns `review_ready` with foreground `Agent` parameters. The Parent invokes a reviewer, then calls `submit_review`: omitting the verdict applies the host-observed reviewer receipt, or the Parent may relay the reviewer's structured verdict for fingerprint comparison. `request_authorization` is the only literal-user confirmation path. AbortSignal cancellation before authority commit and snapshot/CAS revalidation remain mandatory.
|
|
148
150
|
4. Kernel Assurance chat uses native Tool rendering only. It does not publish completion messages or wake a later parent turn; the direct Tool result is the continuation boundary. `awaiting_user` remains valid until the host-built authorization operation is confirmed and the immutable snapshot is revalidated.
|
|
149
151
|
5. Child 不得再次派发 child,nested delegation 一律禁止。Parent 保留综合与最终判断责任。
|
|
150
152
|
6. Kernel authority Review 对每个 immutable snapshot 恰好一个 primary reviewer,turn 预算按 workload 缩放(Quick 12 / Standard 16 / Heavy 24),并使用该 snapshot 对应的 Quick/Standard/Heavy 执行档位;不存在从 initial dispatch 起算的单一端到端总预算。Reviewer 必须先验证 immutable v5 manifest 的 `base_head`、`review_commit`、单一 parent、`review_tree` 与 `manifest_digest`,再用只读 Git 命令从 synthetic revision 获取源码;manifest 只有 metadata,不复制 source bytes,也不枚举 neighborhood files。审查只围绕 acceptance assertions 与 `changed_paths`,未变更路径只有在 acceptance、changed caller 或同一 state machine 直接需要时按需读取并注明理由;Reviewer 不探索无关 repository paths。对 settlement-class change,Reviewer 必须先枚举 immutable revision 内每条 terminal、cancellation、timeout 与 race path,再对全部路径给出判断;finding summary 必须以受影响路径开头。每个 acceptance 的执行结果已由 deterministic QA 在 review 前验证并内嵌于 manifest 的 `outcomes` 字段(acceptance_id -> {status, summary});Reviewer 不得重跑 descriptor,也不得把本地没有测试运行当作 finding——Review 只审 revision provenance、代码正确性、回归、安全与缺失测试。除该 reviewer 外,同一触发点最多两个相互独立的 advisory/discovery children;它们只能并行读,不能写 workflow state、关闭 QA 或产生 authority。
|
|
151
153
|
7. Foreground assurance never sleeps, polls, or schedules a completion callback. One Tool call owns QA; one Parent turn obtains and submits the reviewer verdict. User authorization is reserved for unresolved decisions, explicit stop, and breaking Intent revisions; no risk tier adds a generic final confirmation.
|
|
152
|
-
8. `submit_review` validates the verdict contract, task identity, immutable snapshot digest, and fresh record/Intent/workspace/diff revisions before applying Review authority. Malformed verdicts are retryable without rebuilding evidence. Stale verdicts leave the TaskRecord unchanged and release the stale reservation.
|
|
154
|
+
8. `submit_review` validates the verdict contract, task identity, immutable snapshot digest, and fresh record/Intent/workspace/diff revisions before applying Review authority. An omitted verdict applies the host-observed reviewer receipt bytes (ADR 0017); a relayed verdict must fingerprint-match the observed receipt. Malformed verdicts are retryable without rebuilding evidence. Stale verdicts leave the TaskRecord unchanged and release the stale reservation. Each host adapter observes its own reviewer receipt edge — the Claude host via the dispatch hook log, the Pi host via the generic `Agent` tool events correlated by the reserved prompt digest — and a submission with no recorded receipt fails closed.
|
|
153
155
|
9. The assurance Tool checks the immutable snapshot before each phase and returns a terminal structured state directly: `cancelled`, `rework`, `review_ready`, `awaiting_user`, `blocked`, or `settlement_unknown`. The commit boundary is non-cancellable. No silent task, status timer, completion notification, or result retrieval path is permitted.
|
|
154
156
|
|
|
155
157
|
## Result Synthesis
|
|
@@ -165,6 +167,6 @@ Parent workflow role 必须:
|
|
|
165
167
|
|
|
166
168
|
普通 advisory/discovery 的每次启动都消耗一个 candidate budget slot;失败、取消、timeout 或 result_untrusted 均丢弃该输出且不得自动重试。Parent 仅在剩余候选仍独立有用且 evidence budget 仍需要时继续,否则转 solo/fail-closed fallback,并记录 `dispatch_failed` 或 `child_timeout`。Read-only eligibility 与 Pi 的 one-foreground-child 调度限制是两回事:只读调查可同时存在多个待派发候选,但实际执行仍逐个 foreground child 串行消费,不得把多个 foreground Agent 假定为并发 batch。该规则不改变 Kernel authority Review 的显式恢复协议。Child 永远不获得实现、Plan write、workflow mutation 或 QA closure authority。
|
|
167
169
|
|
|
168
|
-
If Kernel Review dispatch fails, the Parent does not call `submit_review`; the existing Review reservation and immutable evidence remain available for a later foreground retry. A malformed verdict may be corrected and resubmitted. A stale snapshot, explicit release, successful settlement, or session shutdown removes the reservation and evidence. There is no retry counter
|
|
170
|
+
If Kernel Review dispatch fails, the Parent does not call `submit_review`; the existing Review reservation and immutable evidence remain available for a later foreground retry. A malformed verdict may be corrected and resubmitted. A stale snapshot, explicit release, successful settlement, or session shutdown removes the reservation and evidence. There is no retry counter or provider-specific recovery path; the only dispatch record is the receipt a host adapter binds to the reservation for receipt-bound submission.
|
|
169
171
|
|
|
170
172
|
This protocol is provider- and host-adapter-agnostic for local execution.
|
|
@@ -175,7 +175,10 @@ and clarification. It does not apply to Enrolled Intent Revision.
|
|
|
175
175
|
Before authoring a TaskIntent, trace each expected behavior from its public or
|
|
176
176
|
runtime entry point through existing imports and callers to the highest focused
|
|
177
177
|
behavioral tests. Include generated or packaged mirrors and every owner of the
|
|
178
|
-
same state machine.
|
|
178
|
+
same state machine. For configuration changes, follow deployment copies and
|
|
179
|
+
integration assertions; for runtime changes, include the generated Claude bundle
|
|
180
|
+
when it inlines that runtime and the required release changeset. Resolve these
|
|
181
|
+
paths from the build and test references before Enrollment. Record the concrete paths in the Spec's discovery evidence when the work is
|
|
179
182
|
complex; simple TaskIntent-only work records them in `scope_hint`.
|
|
180
183
|
Do not author while a referenced sibling is unresolved. Use the smallest
|
|
181
184
|
coherent module directory for ordinary implementation scope. Keep Kernel,
|
|
@@ -53,6 +53,16 @@ seam, or a seam silently replaced by a different one, is a finding carrying
|
|
|
53
53
|
named, its absence is not a finding: candidates written before this rule carries
|
|
54
54
|
no defect for lacking one.
|
|
55
55
|
|
|
56
|
+
Before pass, enumerate the immutable change set and read each complete per-file
|
|
57
|
+
diff, including deletions. Page bounded reads until every hunk is visible; a
|
|
58
|
+
truncated aggregate output or summary is incomplete coverage. Trace changed
|
|
59
|
+
high-risk behavior through its callers and state owner, and inspect a concrete
|
|
60
|
+
negative case against the embedded acceptance. If immutable evidence remains
|
|
61
|
+
unavailable or incomplete, report the concrete acceptance coverage gap in a
|
|
62
|
+
rework finding with required evidence. `inspected_paths` declares coverage; it
|
|
63
|
+
does not itself prove tool reading. Judge quality by evidence, not elapsed time
|
|
64
|
+
or tool-call count.
|
|
65
|
+
|
|
56
66
|
A required check that is skipped, that matches zero tests, or whose prerequisite
|
|
57
67
|
is absent does not prove the acceptance; do not accept it as evidence. Full
|
|
58
68
|
suite coverage reported at a coarse pass count is not per-invariant evidence.
|
|
@@ -350,6 +350,18 @@ export type AssuranceSubmitReviewResult = AssuranceRecoveryFields & (
|
|
|
350
350
|
| { state: "settlement_unknown"; operation: "qa" | "review"; operation_id: string; reason: string }
|
|
351
351
|
| { state: "blocked"; reason: string; code?: "verdict_invalid"; recovery_action?: string });
|
|
352
352
|
|
|
353
|
+
/**
|
|
354
|
+
* ADR 0017: optional metadata a host adapter supplies alongside the verdict
|
|
355
|
+
* input. The coordinator threads it opaquely; it never resolves verdict sources.
|
|
356
|
+
*/
|
|
357
|
+
export interface ReviewSubmissionOptions {
|
|
358
|
+
/** sha256 over the reviewer's own result bytes, bound into the review attestation. */
|
|
359
|
+
reviewer_verdict_sha256?: string;
|
|
360
|
+
/** Synchronous host check immediately before the reservation is consumed. */
|
|
361
|
+
validateReceipt?: () => string | null;
|
|
362
|
+
receiptRecoveryAction?: string;
|
|
363
|
+
}
|
|
364
|
+
|
|
353
365
|
export type ActiveAssuranceState =
|
|
354
366
|
| { state: "running"; operation: "qa"; operation_id: string; deadline_seconds: number }
|
|
355
367
|
| { state: "review_ready"; operation: "review"; operation_id: string }
|
|
@@ -559,7 +571,10 @@ function parseVerdictEvidence(value: unknown, index: number): FindingEvidence {
|
|
|
559
571
|
export function parseAssuranceVerdict(input: unknown, snapshot: SnapshotDescriptor): AssuranceVerdict {
|
|
560
572
|
let raw: Record<string, unknown>;
|
|
561
573
|
if (typeof input === "string") {
|
|
562
|
-
|
|
574
|
+
// Agent transport prefixes a status line to the strict JSON verdict.
|
|
575
|
+
const first = input.indexOf("{");
|
|
576
|
+
const last = input.lastIndexOf("}");
|
|
577
|
+
const cleaned = first >= 0 && last >= first ? input.slice(first, last + 1) : "";
|
|
563
578
|
if (!cleaned) throw new Error("reviewer returned no strict JSON verdict");
|
|
564
579
|
try { raw = JSON.parse(cleaned) as Record<string, unknown>; } catch { throw new Error("reviewer verdict is not valid JSON"); }
|
|
565
580
|
} else if (typeof input === "object" && input !== null && !Array.isArray(input)) {
|
|
@@ -1192,11 +1207,11 @@ export class AssuranceCoordinator {
|
|
|
1192
1207
|
}
|
|
1193
1208
|
}
|
|
1194
1209
|
|
|
1195
|
-
async submitReview(taskId: string, ctx: HostContext, verdictInput: unknown): Promise<AssuranceSubmitReviewResult> {
|
|
1196
|
-
return this.withRecovery(taskId, ctx, await this.submitReviewOnce(taskId, ctx, verdictInput));
|
|
1210
|
+
async submitReview(taskId: string, ctx: HostContext, verdictInput: unknown, options?: ReviewSubmissionOptions): Promise<AssuranceSubmitReviewResult> {
|
|
1211
|
+
return this.withRecovery(taskId, ctx, await this.submitReviewOnce(taskId, ctx, verdictInput, options));
|
|
1197
1212
|
}
|
|
1198
1213
|
|
|
1199
|
-
private async submitReviewOnce(taskId: string, ctx: HostContext, verdictInput: unknown): Promise<AssuranceSubmitReviewResult> {
|
|
1214
|
+
private async submitReviewOnce(taskId: string, ctx: HostContext, verdictInput: unknown, options?: ReviewSubmissionOptions): Promise<AssuranceSubmitReviewResult> {
|
|
1200
1215
|
const unknown = this.unknownOperations.get(taskId);
|
|
1201
1216
|
if (unknown) return { state: "settlement_unknown", operation: unknown.operation, operation_id: unknown.operationId, reason: unknown.reason };
|
|
1202
1217
|
const rejected = this.rejectedReviewOperations.get(taskId);
|
|
@@ -1233,6 +1248,8 @@ export class AssuranceCoordinator {
|
|
|
1233
1248
|
reservation.verdictCorrectionRequired = true;
|
|
1234
1249
|
return { state: "blocked", code: "verdict_invalid", reason: boundedAssuranceError(error) };
|
|
1235
1250
|
}
|
|
1251
|
+
const receiptError = options?.validateReceipt?.();
|
|
1252
|
+
if (receiptError) return { state: "blocked", reason: receiptError, ...(options?.receiptRecoveryAction ? { recovery_action: options.receiptRecoveryAction } : {}) };
|
|
1236
1253
|
const invocation = this.openInvocation(taskId);
|
|
1237
1254
|
this.releaseReviewReservation(taskId, reservation);
|
|
1238
1255
|
try {
|
|
@@ -1242,6 +1259,7 @@ export class AssuranceCoordinator {
|
|
|
1242
1259
|
verdict,
|
|
1243
1260
|
invocation,
|
|
1244
1261
|
actorId: "parent-mediated-review",
|
|
1262
|
+
...(options?.reviewer_verdict_sha256 ? { reviewer_verdict_sha256: options.reviewer_verdict_sha256 } : {}),
|
|
1245
1263
|
});
|
|
1246
1264
|
} catch (error) {
|
|
1247
1265
|
const reason = boundedAssuranceError(error);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Host-neutral Review submission mediation (ADR 0017).
|
|
2
|
+
//
|
|
3
|
+
// Both host adapters resolve the verdict source before the shared submit path:
|
|
4
|
+
// the Parent's relayed bytes when provided, or the host-observed reviewer
|
|
5
|
+
// receipt when the verdict is omitted. The Parent keeps exclusive trigger
|
|
6
|
+
// authority; only the transcription step is optional. The coordinator itself
|
|
7
|
+
// stays single-verdict-input and never knows where the bytes came from.
|
|
8
|
+
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import type { AssuranceCoordinator, AssuranceSubmitReviewResult, HostContext } from "./coordinator";
|
|
11
|
+
|
|
12
|
+
export function extractVerdictJson(input: unknown): Record<string, unknown> | null {
|
|
13
|
+
if (typeof input === "string") {
|
|
14
|
+
// Agent transport prefixes a status line to the strict JSON verdict.
|
|
15
|
+
const first = input.indexOf("{");
|
|
16
|
+
const last = input.lastIndexOf("}");
|
|
17
|
+
const cleaned = first >= 0 && last >= first ? input.slice(first, last + 1) : "";
|
|
18
|
+
if (!cleaned) return null;
|
|
19
|
+
try { return JSON.parse(cleaned) as Record<string, unknown>; } catch { return null; }
|
|
20
|
+
}
|
|
21
|
+
if (typeof input === "object" && input !== null && !Array.isArray(input)) return input as Record<string, unknown>;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function verdictFingerprint(raw: Record<string, unknown>): string {
|
|
26
|
+
return JSON.stringify({
|
|
27
|
+
contract: raw.contract ?? null,
|
|
28
|
+
role: raw.role ?? null,
|
|
29
|
+
task_id: raw.task_id ?? null,
|
|
30
|
+
snapshot_digest: raw.snapshot_digest ?? null,
|
|
31
|
+
decision: raw.decision ?? null,
|
|
32
|
+
approval: raw.approval ?? null,
|
|
33
|
+
findings: raw.findings ?? null,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** sha256 over the reviewer's own result bytes, bound into the review attestation. */
|
|
38
|
+
export function digestOfReviewerBytes(bytes: string): string {
|
|
39
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The host-observation shape both adapters supply; mirrors ClaudeReviewHost's. */
|
|
43
|
+
export type ReviewObservation =
|
|
44
|
+
| { ok: true; receipt: { actorId: string; result: string } }
|
|
45
|
+
| { ok: false; release: boolean; reason: string };
|
|
46
|
+
|
|
47
|
+
const RELEASED_REVIEW_RECOVERY =
|
|
48
|
+
"Call advance_assurance to obtain a new Review reservation, then dispatch one fresh reviewer with the returned envelope unchanged";
|
|
49
|
+
const RETAINED_REVIEW_RECOVERY =
|
|
50
|
+
"Wait for the dispatched reviewer to finish, then call submit_review again with its verdict; do not dispatch or continue another reviewer";
|
|
51
|
+
const MISMATCH_REVIEW_RECOVERY =
|
|
52
|
+
"Resubmit without a verdict to apply the observed reviewer receipt, or resubmit the reviewer's verdict exactly as the reviewer returned it";
|
|
53
|
+
|
|
54
|
+
function withReviewRecovery(
|
|
55
|
+
result: AssuranceSubmitReviewResult,
|
|
56
|
+
recovery_action: string,
|
|
57
|
+
): AssuranceSubmitReviewResult {
|
|
58
|
+
if (result.state !== "blocked" || result.code === "verdict_invalid") return result;
|
|
59
|
+
return { ...result, recovery_action };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function submitMediatedReview(
|
|
63
|
+
coordinator: AssuranceCoordinator,
|
|
64
|
+
ctx: HostContext,
|
|
65
|
+
taskId: string,
|
|
66
|
+
verdictInput: unknown,
|
|
67
|
+
inspect: () => ReviewObservation,
|
|
68
|
+
): Promise<AssuranceSubmitReviewResult> {
|
|
69
|
+
// Preserve uncertain authority settlement before inspecting a released receipt.
|
|
70
|
+
if (coordinator.active(taskId)?.state === "settlement_unknown") {
|
|
71
|
+
return coordinator.submitReview(taskId, ctx, verdictInput);
|
|
72
|
+
}
|
|
73
|
+
const observed = inspect();
|
|
74
|
+
if (!observed.ok) {
|
|
75
|
+
if (observed.release) return withReviewRecovery(coordinator.abandonReview(taskId, observed.reason), RELEASED_REVIEW_RECOVERY);
|
|
76
|
+
return { state: "blocked", reason: observed.reason, recovery_action: RETAINED_REVIEW_RECOVERY };
|
|
77
|
+
}
|
|
78
|
+
const receiptBytes = observed.receipt.result;
|
|
79
|
+
const options = {
|
|
80
|
+
receiptRecoveryAction: RETAINED_REVIEW_RECOVERY,
|
|
81
|
+
reviewer_verdict_sha256: digestOfReviewerBytes(receiptBytes),
|
|
82
|
+
validateReceipt: () => {
|
|
83
|
+
const current = inspect();
|
|
84
|
+
if (!current.ok) return current.reason;
|
|
85
|
+
return current.receipt.actorId === observed.receipt.actorId && current.receipt.result === receiptBytes
|
|
86
|
+
? null : "reviewer receipt changed during submission";
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
if (verdictInput === undefined) {
|
|
90
|
+
// ADR 0017: the host-observed reviewer bytes are the submission.
|
|
91
|
+
if (!coordinator.isReviewVerdictValid(taskId, receiptBytes)) {
|
|
92
|
+
return withReviewRecovery(coordinator.abandonReview(taskId, "reviewer receipt is not a valid verdict"), RELEASED_REVIEW_RECOVERY);
|
|
93
|
+
}
|
|
94
|
+
return coordinator.submitReview(taskId, ctx, receiptBytes, options);
|
|
95
|
+
}
|
|
96
|
+
const parentValid = coordinator.isReviewVerdictValid(taskId, verdictInput);
|
|
97
|
+
if (!parentValid) return coordinator.submitReview(taskId, ctx, verdictInput);
|
|
98
|
+
if (!coordinator.isReviewVerdictValid(taskId, receiptBytes)) {
|
|
99
|
+
return withReviewRecovery(coordinator.abandonReview(taskId, "reviewer receipt is not a valid verdict"), RELEASED_REVIEW_RECOVERY);
|
|
100
|
+
}
|
|
101
|
+
const parentJson = extractVerdictJson(verdictInput);
|
|
102
|
+
const receiptJson = extractVerdictJson(receiptBytes);
|
|
103
|
+
if (!parentJson || !receiptJson || verdictFingerprint(parentJson) !== verdictFingerprint(receiptJson)) {
|
|
104
|
+
return { state: "blocked", reason: "parent verdict does not match reviewer receipt", recovery_action: MISMATCH_REVIEW_RECOVERY };
|
|
105
|
+
}
|
|
106
|
+
return coordinator.submitReview(taskId, ctx, verdictInput, options);
|
|
107
|
+
}
|
|
@@ -272,6 +272,8 @@ export interface VerdictApplicationInput {
|
|
|
272
272
|
verdict: AssuranceVerdict;
|
|
273
273
|
invocation: InvocationToken;
|
|
274
274
|
actorId: string;
|
|
275
|
+
/** ADR 0017: sha256 over the reviewer's own result bytes, bound only on review attestations. */
|
|
276
|
+
reviewer_verdict_sha256?: string;
|
|
275
277
|
hooks?: { beforeCommit?: () => Promise<void>; onCommit?: () => void; afterCommit?: () => Promise<void> };
|
|
276
278
|
}
|
|
277
279
|
export interface VerdictAuthority {
|
|
@@ -310,6 +312,7 @@ export function createVerdictAuthority(options: {
|
|
|
310
312
|
diff_hash: snapshot.diff_hash, actor_id: actorId, summary: verdict.approval!.summary,
|
|
311
313
|
...(snapshot.role === "review" && snapshot.review_revision ? { review_revision: snapshot.review_revision } : {}),
|
|
312
314
|
...(snapshot.role === "review" && advisories.length ? { advisory_findings: advisories } : {}),
|
|
315
|
+
...(snapshot.role === "review" && input.reviewer_verdict_sha256 ? { reviewer_verdict_sha256: input.reviewer_verdict_sha256 } : {}),
|
|
313
316
|
} : undefined;
|
|
314
317
|
const op = verdict.decision === "rework" ? "request_rework" : "record_approval";
|
|
315
318
|
const payload = approval ? { approval } : { findings };
|
|
@@ -6,6 +6,7 @@ export const PRIVILEGED_OPERATIONS = [
|
|
|
6
6
|
"approve_breaking_intent_revision",
|
|
7
7
|
"stop",
|
|
8
8
|
"start_unattended_batch",
|
|
9
|
+
"retire_stale_batch",
|
|
9
10
|
] as const;
|
|
10
11
|
|
|
11
12
|
export type PrivilegedOperation = (typeof PRIVILEGED_OPERATIONS)[number];
|
|
@@ -89,6 +90,15 @@ export interface NativeConfirmationInput {
|
|
|
89
90
|
serialized: Array<{ task_id: string; overlaps_with: string[] }>;
|
|
90
91
|
};
|
|
91
92
|
};
|
|
93
|
+
/** Present only for a batch disposition: the record the user is asked to retire. */
|
|
94
|
+
batchDisposition?: {
|
|
95
|
+
batch_id: string;
|
|
96
|
+
batch_state: string;
|
|
97
|
+
batch_branch: string;
|
|
98
|
+
plan_digest: string;
|
|
99
|
+
recorded_commits: number;
|
|
100
|
+
children: Array<{ task_id: string; slice_id: string; state: string; commit: string | null; lane_branch: string | null }>;
|
|
101
|
+
};
|
|
92
102
|
signal?: AbortSignal;
|
|
93
103
|
}
|
|
94
104
|
|
|
@@ -58,6 +58,8 @@ import {
|
|
|
58
58
|
} from "../unattended/batch_runner";
|
|
59
59
|
import { createBatchKernelPort } from "../unattended/batch_kernel_port";
|
|
60
60
|
import type { LaneOffer } from "../unattended/batch_lanes";
|
|
61
|
+
import { retireStaleBatch } from "../unattended/batch_disposition";
|
|
62
|
+
import type { BatchLaneRunReport } from "../unattended/batch_state";
|
|
61
63
|
import {
|
|
62
64
|
createBatchAuthorityRegistry,
|
|
63
65
|
} from "../kernel/batch_authority";
|
|
@@ -107,42 +109,11 @@ function readTaskIntentForRecord(root: string, taskId: string) {
|
|
|
107
109
|
return readTaskIntent(root, taskId, currentPath);
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
if (typeof input === "object" && input !== null && !Array.isArray(input)) return input as Record<string, unknown>;
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function verdictFingerprint(raw: Record<string, unknown>): string {
|
|
121
|
-
return JSON.stringify({
|
|
122
|
-
contract: raw.contract ?? null,
|
|
123
|
-
role: raw.role ?? null,
|
|
124
|
-
task_id: raw.task_id ?? null,
|
|
125
|
-
snapshot_digest: raw.snapshot_digest ?? null,
|
|
126
|
-
decision: raw.decision ?? null,
|
|
127
|
-
approval: raw.approval ?? null,
|
|
128
|
-
findings: raw.findings ?? null,
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const RELEASED_REVIEW_RECOVERY =
|
|
133
|
-
"Call advance_assurance to obtain a new Review reservation, then dispatch one fresh reviewer with the returned envelope unchanged";
|
|
134
|
-
const RETAINED_REVIEW_RECOVERY =
|
|
135
|
-
"Wait for the dispatched reviewer to finish, then call submit_review again with its verdict; do not dispatch or continue another reviewer";
|
|
136
|
-
const MISMATCH_REVIEW_RECOVERY =
|
|
137
|
-
"Resubmit the reviewer's verdict exactly as the reviewer returned it";
|
|
138
|
-
|
|
139
|
-
function withReviewRecovery(
|
|
140
|
-
result: AssuranceSubmitReviewResult,
|
|
141
|
-
recovery_action: string,
|
|
142
|
-
): AssuranceSubmitReviewResult {
|
|
143
|
-
if (result.state !== "blocked" || result.code === "verdict_invalid") return result;
|
|
144
|
-
return { ...result, recovery_action };
|
|
145
|
-
}
|
|
112
|
+
import {
|
|
113
|
+
extractVerdictJson,
|
|
114
|
+
submitMediatedReview,
|
|
115
|
+
} from "../assurance/review_mediation";
|
|
116
|
+
export { extractVerdictJson };
|
|
146
117
|
|
|
147
118
|
export async function submitClaudeReview(
|
|
148
119
|
host: ClaudeReviewHost,
|
|
@@ -151,24 +122,8 @@ export async function submitClaudeReview(
|
|
|
151
122
|
taskId: string,
|
|
152
123
|
verdictInput: unknown,
|
|
153
124
|
): Promise<AssuranceSubmitReviewResult> {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (!observed.ok) {
|
|
157
|
-
if (observed.release) return withReviewRecovery(coordinator.abandonReview(taskId, observed.reason), RELEASED_REVIEW_RECOVERY);
|
|
158
|
-
return { state: "blocked", reason: observed.reason, recovery_action: RETAINED_REVIEW_RECOVERY };
|
|
159
|
-
}
|
|
160
|
-
const parentValid = coordinator.isReviewVerdictValid(taskId, verdictInput);
|
|
161
|
-
if (!parentValid) return coordinator.submitReview(taskId, ctx, verdictInput);
|
|
162
|
-
const receiptValid = coordinator.isReviewVerdictValid(taskId, observed.receipt.result);
|
|
163
|
-
if (!receiptValid) {
|
|
164
|
-
return withReviewRecovery(coordinator.abandonReview(taskId, "reviewer receipt is not a valid verdict"), RELEASED_REVIEW_RECOVERY);
|
|
165
|
-
}
|
|
166
|
-
const parentJson = extractVerdictJson(verdictInput)!;
|
|
167
|
-
const receiptJson = extractVerdictJson(observed.receipt.result)!;
|
|
168
|
-
if (verdictFingerprint(parentJson) !== verdictFingerprint(receiptJson)) {
|
|
169
|
-
return { state: "blocked", reason: "parent verdict does not match reviewer receipt", recovery_action: MISMATCH_REVIEW_RECOVERY };
|
|
170
|
-
}
|
|
171
|
-
return coordinator.submitReview(taskId, ctx, verdictInput);
|
|
125
|
+
// ADR 0017: an omitted verdict applies the hook-observed reviewer receipt.
|
|
126
|
+
return submitMediatedReview(coordinator, ctx, taskId, verdictInput, () => host.inspectReviewForTask(taskId));
|
|
172
127
|
}
|
|
173
128
|
|
|
174
129
|
/**
|
|
@@ -285,6 +240,7 @@ export interface ClaudeRuntimeOptions {
|
|
|
285
240
|
*/
|
|
286
241
|
type ClaudeBatchStartResult =
|
|
287
242
|
| { state: "started"; batch_id: string; report: BatchRunReport }
|
|
243
|
+
| { state: "retired"; batch_id: string; report: BatchRunReport | BatchLaneRunReport }
|
|
288
244
|
| { state: "rejected"; reason: string; recovery_action: string }
|
|
289
245
|
| { state: "cancelled"; reason: string; recovery_action: string }
|
|
290
246
|
| { state: "blocked"; reason: string; recovery_action: string };
|
|
@@ -994,6 +950,85 @@ export class ClaudeRuntime {
|
|
|
994
950
|
report,
|
|
995
951
|
};
|
|
996
952
|
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* The batch disposition. One explicit literal-user decision retires a record
|
|
956
|
+
* the plan moved past: the terminal superseded state and its report preserve
|
|
957
|
+
* every child state, recorded commit and the batch branch, and no child is
|
|
958
|
+
* re-enrolled, handed off, or credited with delivery it never had.
|
|
959
|
+
*/
|
|
960
|
+
async retireStaleBatch(initiativeSlug: string, meta: ToolMeta): Promise<ClaudeBatchStartResult> {
|
|
961
|
+
throwIfCancelled(meta.signal);
|
|
962
|
+
const probe = probeHost(this.env, process.platform, this.hostVersion);
|
|
963
|
+
if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
|
|
964
|
+
// The disposition always opens a gate, so it needs a confirmation port.
|
|
965
|
+
if (!this.requestConfirmation)
|
|
966
|
+
throw new NativeAuthorityError("interaction_not_opened", batchReason("confirmation_port_unavailable").reason);
|
|
967
|
+
const outcome = await retireStaleBatch<ClaudeBatchStartResult>({
|
|
968
|
+
root: this.cwd,
|
|
969
|
+
initiative_slug: initiativeSlug,
|
|
970
|
+
now: new Date().toISOString(),
|
|
971
|
+
gate: async (facts) => {
|
|
972
|
+
let confirmationResult: { decision: NativeDecision; requestId: string };
|
|
973
|
+
try {
|
|
974
|
+
confirmationResult = await this.requestConfirmation!({
|
|
975
|
+
operation: "retire_stale_batch",
|
|
976
|
+
initiativeSlug,
|
|
977
|
+
toolCallId: meta.toolCallId,
|
|
978
|
+
batchDisposition: {
|
|
979
|
+
batch_id: facts.batch_id,
|
|
980
|
+
batch_state: facts.batch_state,
|
|
981
|
+
batch_branch: facts.branch,
|
|
982
|
+
plan_digest: facts.plan_digest,
|
|
983
|
+
recorded_commits: facts.recorded_commits.length,
|
|
984
|
+
children: facts.children.map((child) => ({
|
|
985
|
+
task_id: child.task_id,
|
|
986
|
+
slice_id: child.slice_id,
|
|
987
|
+
state: child.state,
|
|
988
|
+
commit: child.commit,
|
|
989
|
+
lane_branch: child.lane_branch,
|
|
990
|
+
})),
|
|
991
|
+
},
|
|
992
|
+
signal: meta.signal,
|
|
993
|
+
});
|
|
994
|
+
} catch (err) {
|
|
995
|
+
if (meta.signal?.aborted)
|
|
996
|
+
return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
|
|
997
|
+
if (err instanceof NativeAuthorityError) {
|
|
998
|
+
if (err.reasonCode === "unsupported_host") throw err;
|
|
999
|
+
if (err.reasonCode === "user_cancelled") {
|
|
1000
|
+
return {
|
|
1001
|
+
kind: "host_rejection",
|
|
1002
|
+
value: { state: "cancelled", reason: err.message, recovery_action: err.recoveryAction },
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
return {
|
|
1006
|
+
kind: "host_rejection",
|
|
1007
|
+
value: { state: "rejected", reason: err.message, recovery_action: err.recoveryAction },
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
return {
|
|
1011
|
+
kind: "host_rejection",
|
|
1012
|
+
value: batchReason("confirmation_failed", err instanceof Error ? err.message : String(err)),
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
if (confirmationResult.decision === "cancel" && meta.signal?.aborted)
|
|
1016
|
+
return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
|
|
1017
|
+
if (meta.signal?.aborted)
|
|
1018
|
+
return { kind: "host_rejection", value: batchReason("cancelled_before_execution") };
|
|
1019
|
+
if (confirmationResult.decision === "decline")
|
|
1020
|
+
return { kind: "host_rejection", value: batchReason("confirmation_declined") };
|
|
1021
|
+
if (confirmationResult.decision === "cancel")
|
|
1022
|
+
return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
|
|
1023
|
+
if (confirmationResult.decision !== "accept")
|
|
1024
|
+
return { kind: "host_rejection", value: batchReason("confirmation_no_decision") };
|
|
1025
|
+
return { kind: "confirmed", request_id: confirmationResult.requestId };
|
|
1026
|
+
},
|
|
1027
|
+
});
|
|
1028
|
+
if (outcome.outcome === "host_rejection") return outcome.value;
|
|
1029
|
+
if (outcome.outcome === "rejected") return outcome.rejection;
|
|
1030
|
+
return { state: "retired", batch_id: outcome.batch_id, report: outcome.report };
|
|
1031
|
+
}
|
|
997
1032
|
}
|
|
998
1033
|
|
|
999
1034
|
export interface ToolMeta {
|
|
@@ -22,12 +22,13 @@ export const TOOLS = [
|
|
|
22
22
|
{ name: "status", description: "Read the Kernel Assurance Projection for an exact task.", privileged: false },
|
|
23
23
|
{ name: "enroll", description: "Enroll a Git-tracked TaskIntent after native confirmation.", privileged: true },
|
|
24
24
|
{ name: "advance_assurance", description: "Advance frozen Assurance through deterministic QA and Review reservation.", privileged: false },
|
|
25
|
-
{ name: "submit_review", description: "Submit the
|
|
25
|
+
{ name: "submit_review", description: "Submit the Review verdict; omit the verdict to apply the correlated reviewer receipt, or relay the reviewer's structured verdict for fingerprint comparison.", privileged: false },
|
|
26
26
|
{ name: "request_authorization", description: "Apply exact literal-user authorization.", privileged: true },
|
|
27
27
|
{ name: "revise_intent", description: "Apply a compatible TaskIntent revision.", privileged: false },
|
|
28
28
|
{ name: "approve_breaking_intent_revision", description: "Approve a breaking TaskIntent revision.", privileged: true },
|
|
29
29
|
{ name: "stop", description: "Stop the active task with literal-user authority.", privileged: true },
|
|
30
30
|
{ name: "start_unattended_batch", description: "Start an unattended serial batch run for an Initiative after native confirmation.", privileged: true },
|
|
31
|
+
{ name: "retire_stale_batch", description: "Retire an Initiative's parked or drifted batch record to the terminal superseded state after native confirmation.", privileged: true },
|
|
31
32
|
{ name: "repair_authority_state", description: "Repair a proven recoverable stale backend claim.", privileged: false },
|
|
32
33
|
{ name: "resolve_finding", description: "Resolve one open blocking or advisory finding whose cause is fixed and verified.", privileged: false },
|
|
33
34
|
{ name: "refute_finding", description: "Refute one open finding by binding the fresh passing QA attestation that contradicts it.", privileged: false },
|
|
@@ -40,20 +41,24 @@ export function listMcpTools() {
|
|
|
40
41
|
inputSchema: {
|
|
41
42
|
type: "object",
|
|
42
43
|
properties: {
|
|
43
|
-
...(tool.name === "start_unattended_batch"
|
|
44
|
+
...(tool.name === "start_unattended_batch" || tool.name === "retire_stale_batch"
|
|
44
45
|
? {
|
|
45
46
|
initiative_slug: { type: "string" },
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
47
|
+
...(tool.name === "start_unattended_batch"
|
|
48
|
+
? {
|
|
49
|
+
max_parallel: { type: "integer", minimum: 1 },
|
|
50
|
+
lane_offers: {
|
|
51
|
+
type: "array",
|
|
52
|
+
items: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: { task_id: { type: "string" }, path: { type: "string" } },
|
|
55
|
+
required: ["task_id", "path"],
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
: {}),
|
|
61
|
+
}
|
|
57
62
|
: {
|
|
58
63
|
task_id: { type: "string" },
|
|
59
64
|
...(tool.name === "approve_breaking_intent_revision" || tool.name === "revise_intent" ? { next_intent: { type: "object" } } : {}),
|
|
@@ -63,17 +68,15 @@ export function listMcpTools() {
|
|
|
63
68
|
...(tool.name === "refute_finding" ? { finding_id: { type: "string" }, attestation_id: { type: "string" } } : {}),
|
|
64
69
|
}),
|
|
65
70
|
},
|
|
66
|
-
required: tool.name === "start_unattended_batch"
|
|
71
|
+
required: tool.name === "start_unattended_batch" || tool.name === "retire_stale_batch"
|
|
67
72
|
? ["initiative_slug"]
|
|
68
|
-
: tool.name === "
|
|
69
|
-
? ["task_id", "
|
|
70
|
-
: tool.name === "
|
|
71
|
-
? ["task_id", "finding_id"]
|
|
72
|
-
: tool.name === "
|
|
73
|
-
? ["task_id", "
|
|
74
|
-
:
|
|
75
|
-
? ["task_id", "next_intent"]
|
|
76
|
-
: ["task_id"],
|
|
73
|
+
: tool.name === "resolve_finding"
|
|
74
|
+
? ["task_id", "finding_id"]
|
|
75
|
+
: tool.name === "refute_finding"
|
|
76
|
+
? ["task_id", "finding_id", "attestation_id"]
|
|
77
|
+
: tool.name === "revise_intent"
|
|
78
|
+
? ["task_id", "next_intent"]
|
|
79
|
+
: ["task_id"],
|
|
77
80
|
},
|
|
78
81
|
annotations: tool.privileged ? privilegedAnnotations() : { readOnlyHint: tool.name === "status" },
|
|
79
82
|
}));
|
|
@@ -156,6 +159,26 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
|
|
|
156
159
|
...(laneOffers !== undefined ? { lane_offers: laneOffers } : {}),
|
|
157
160
|
});
|
|
158
161
|
}
|
|
162
|
+
if (name === "retire_stale_batch") {
|
|
163
|
+
const initiativeSlug = String(args.initiative_slug ?? "");
|
|
164
|
+
if (!initiativeSlug) throw new Error("initiative_slug is required");
|
|
165
|
+
if ("native_decision" in args) throw new Error("native_decision cannot be supplied in tool arguments");
|
|
166
|
+
if (!negotiatedVersion) throw new NativeAuthorityError("unsupported_host", "Claude Code version is unavailable");
|
|
167
|
+
if (!negotiatedInteractive) {
|
|
168
|
+
throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
|
|
169
|
+
}
|
|
170
|
+
const probe = probeHost(options.env ?? process.env, process.platform, negotiatedVersion);
|
|
171
|
+
if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
|
|
172
|
+
const toolMeta: ToolMeta = {
|
|
173
|
+
sessionId: meta.sessionId ?? connectionId,
|
|
174
|
+
toolCallId: meta.toolCallId ?? `call-${name}`,
|
|
175
|
+
taskId: initiativeSlug,
|
|
176
|
+
initiativeSlug,
|
|
177
|
+
interactive: meta.interactive ?? options.interactive ?? negotiatedInteractive,
|
|
178
|
+
signal: meta.signal,
|
|
179
|
+
};
|
|
180
|
+
return runtime.retireStaleBatch(initiativeSlug, toolMeta);
|
|
181
|
+
}
|
|
159
182
|
const taskId = String(args.task_id ?? "");
|
|
160
183
|
if (!taskId) throw new Error("task_id is required");
|
|
161
184
|
if ("native_decision" in args) throw new Error("native_decision cannot be supplied in tool arguments");
|
|
@@ -183,8 +206,8 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
|
|
|
183
206
|
if (name === "enroll") return runtime.enroll(taskId, toolMeta);
|
|
184
207
|
if (name === "advance_assurance") return runtime.advance(taskId, toolMeta.signal, toolMeta);
|
|
185
208
|
if (name === "submit_review") {
|
|
186
|
-
|
|
187
|
-
return runtime.submitReview(taskId, args.verdict, toolMeta);
|
|
209
|
+
// ADR 0017: an absent verdict is the receipt-bound submission.
|
|
210
|
+
return runtime.submitReview(taskId, Object.hasOwn(args, "verdict") ? args.verdict : undefined, toolMeta);
|
|
188
211
|
}
|
|
189
212
|
if (name === "revise_intent") {
|
|
190
213
|
if (!Object.hasOwn(args, "next_intent")) throw new Error("next_intent is required");
|
|
@@ -381,6 +404,34 @@ async function writeReply(output: Writable, reply: JsonRpc): Promise<void> {
|
|
|
381
404
|
}
|
|
382
405
|
|
|
383
406
|
export function elicitationParams(input: NativeConfirmationInput) {
|
|
407
|
+
if (input.operation === "retire_stale_batch") {
|
|
408
|
+
const d = input.batchDisposition;
|
|
409
|
+
const details = [
|
|
410
|
+
`Operation: retire_stale_batch`,
|
|
411
|
+
`Initiative: ${input.initiativeSlug}`,
|
|
412
|
+
d ? `Batch: ${d.batch_id}` : null,
|
|
413
|
+
d ? `Batch state: ${d.batch_state}` : null,
|
|
414
|
+
d ? `Batch branch: ${d.batch_branch}` : null,
|
|
415
|
+
d ? `Plan digest: ${d.plan_digest}` : null,
|
|
416
|
+
d ? `Recorded child commits: ${d.recorded_commits}` : null,
|
|
417
|
+
d
|
|
418
|
+
? `Children (${d.children.length}):\n${d.children
|
|
419
|
+
.map(
|
|
420
|
+
(child) =>
|
|
421
|
+
` - ${child.task_id} (${child.slice_id}) [state: ${child.state}]${child.commit ? ` [commit: ${child.commit}]` : " [no commit]"}${child.lane_branch ? ` [lane: ${child.lane_branch}]` : ""}`,
|
|
422
|
+
)
|
|
423
|
+
.join("\n")}`
|
|
424
|
+
: null,
|
|
425
|
+
"Retiring writes this record to the terminal superseded state. Child states, recorded commits and the batch branch are preserved as evidence; nothing is deleted and no batch trailer or approval is manufactured.",
|
|
426
|
+
"A child without a commit was never executed by this batch: it carries no delivery evidence, and the retired authorization is never reused for a revised plan.",
|
|
427
|
+
"After retiring, run each remaining child with imm-run under its own authorization, or start a new batch for this Initiative.",
|
|
428
|
+
].filter(Boolean);
|
|
429
|
+
return {
|
|
430
|
+
mode: "form",
|
|
431
|
+
message: `Retire this stale batch record for ${input.initiativeSlug}?\n\n${details.join("\n")}`,
|
|
432
|
+
requestedSchema: { type: "object", properties: {} },
|
|
433
|
+
};
|
|
434
|
+
}
|
|
384
435
|
if (input.operation === "start_unattended_batch") {
|
|
385
436
|
const b = input.batchDetails;
|
|
386
437
|
const details = [
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
reconcileKernelAuthority,
|
|
22
22
|
} from "./storage";
|
|
23
23
|
import { projectTask, resolveProjectedRisk } from "./completion";
|
|
24
|
+
import { inspectSpecBinding } from "./spec_binding";
|
|
24
25
|
import { taskDeliveryIdentity } from "../workspace_scope";
|
|
25
26
|
import type { AssuranceObligation, TaskIntentV1, TaskRecord, TaskRecordV3 } from "./types";
|
|
26
27
|
import type { TaskRecordV2 } from "./legacy_task_record";
|
|
@@ -236,7 +237,9 @@ function projectFromRecord(
|
|
|
236
237
|
independence_violations: decision.independence_violations,
|
|
237
238
|
open_user_decision_count: openUserDecisionCount,
|
|
238
239
|
completion_ready: decision.complete,
|
|
239
|
-
authorization:
|
|
240
|
+
authorization: !inspectSpecBinding(intent).ok
|
|
241
|
+
? { state: "none", blocked: "invalid Spec binding requires a valid breaking intent revision" }
|
|
242
|
+
: deriveAssuranceAuthorization({
|
|
240
243
|
next_obligation: decision.next_obligation,
|
|
241
244
|
open_user_decision_count: openUserDecisionCount,
|
|
242
245
|
}),
|