immune-brain 4.5.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -39
- package/README.zh-CN.md +160 -40
- package/package.json +1 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +42 -45
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +189 -476
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +77 -102
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +7 -2
- package/plugins/immune-brain/.pi-extension/tsconfig.json +2 -2
- package/plugins/immune-brain/agents/immune-brain-advisory-reviewer.md +23 -0
- package/plugins/immune-brain/agents/immune-brain-qa.md +42 -0
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +90 -1
- package/plugins/immune-brain/agents/immune-brain-ui-review.md +21 -0
- package/plugins/immune-brain/dist/BASELINE.md +4 -4
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +9924 -9654
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +3 -3
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -1
- package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
- package/plugins/immune-brain/dist/imm-doc-prune.md +2 -2
- package/plugins/immune-brain/dist/{imm-agent-doc-maintain.md → imm-doc-slim.md} +9 -9
- package/plugins/immune-brain/dist/imm-planner.md +60 -20
- package/plugins/immune-brain/dist/imm-pr-fix.md +1 -1
- package/plugins/immune-brain/dist/{imm-review-retro.md → imm-retro.md} +26 -13
- package/plugins/immune-brain/dist/{imm-loop.md → imm-run.md} +44 -11
- package/plugins/immune-brain/dist/registry.yaml +9 -9
- package/plugins/immune-brain/dist/role-prompts/code-review.md +7 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +35 -2
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +138 -69
- package/plugins/immune-brain/runtime/assurance/host_port.ts +20 -0
- package/plugins/immune-brain/runtime/assurance/qa.ts +2 -2
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
- package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +347 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +135 -396
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +5 -4
- package/plugins/immune-brain/runtime/claude/review_host.ts +63 -16
- package/plugins/immune-brain/runtime/claude/role_agents.ts +83 -0
- package/plugins/immune-brain/runtime/commands/kernel.ts +3 -6
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +738 -677
- package/plugins/immune-brain/runtime/kernel/application.ts +7 -7
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +15 -8
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +5 -14
- package/plugins/immune-brain/runtime/kernel/batch_authority.ts +30 -49
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +6 -5
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +0 -1
- package/plugins/immune-brain/runtime/kernel/capability_registry.ts +7 -9
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +170 -17
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
- package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
- package/plugins/immune-brain/runtime/kernel/storage.ts +170 -11
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +13 -0
- package/plugins/immune-brain/runtime/kernel/types.ts +2 -1
- package/plugins/immune-brain/runtime/kernel/validation.ts +3 -1
- package/plugins/immune-brain/runtime/loop_contract.ts +22 -22
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +7 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +35 -2
- package/plugins/immune-brain/runtime/role_prompt_bridge.ts +27 -0
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +66 -28
- package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +106 -0
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +132 -45
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +40 -36
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +98 -209
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +36 -14
- package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
- package/plugins/immune-brain/runtime/workspace_scope.ts +78 -8
- package/plugins/immune-brain/skills/BASELINE.md +4 -4
- package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +1 -1
- package/plugins/immune-brain/skills/{imm-agent-doc-maintain → imm-doc-slim}/SKILL.md +9 -9
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +4 -2
- package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +1 -1
- package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/SKILL.md +7 -7
- package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/scripts/review_retro.ts +46 -21
- package/plugins/immune-brain/skills/{imm-loop → imm-run}/SKILL.md +7 -7
- package/plugins/immune-brain/skills/registry.yaml +9 -9
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -584
- package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +0 -33
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Runtime validation for `imm-
|
|
2
|
+
* Runtime validation for `imm-run` subagent output.
|
|
3
3
|
*
|
|
4
4
|
* The loop parent collects QA and reviewer decisions from isolated children.
|
|
5
5
|
* Those decisions are untrusted input: they arrive as free-form JSON produced
|
|
@@ -42,7 +42,7 @@ export type LoopRouteNext =
|
|
|
42
42
|
| "imm-planner";
|
|
43
43
|
|
|
44
44
|
export interface LoopRoute {
|
|
45
|
-
entry: "imm-
|
|
45
|
+
entry: "imm-run";
|
|
46
46
|
next: LoopRouteNext;
|
|
47
47
|
}
|
|
48
48
|
|
|
@@ -55,27 +55,27 @@ export function resolveLoopRoute(input: {
|
|
|
55
55
|
target: LoopRouteTarget;
|
|
56
56
|
scope_expansion?: boolean;
|
|
57
57
|
}): LoopRoute {
|
|
58
|
-
if (input.scope_expansion) return { entry: "imm-
|
|
58
|
+
if (input.scope_expansion) return { entry: "imm-run", next: "imm-planner" };
|
|
59
59
|
if (
|
|
60
60
|
input.target === "architecture-exploration" &&
|
|
61
61
|
(input.ownership === "brainstorm" || input.ownership === "planner")
|
|
62
62
|
) {
|
|
63
|
-
return { entry: "imm-
|
|
63
|
+
return { entry: "imm-run", next: "arch-explorer" };
|
|
64
64
|
}
|
|
65
65
|
if (
|
|
66
66
|
input.target === "advisory-review" &&
|
|
67
67
|
(input.ownership === "brainstorm" || input.ownership === "planner")
|
|
68
68
|
) {
|
|
69
|
-
return { entry: "imm-
|
|
69
|
+
return { entry: "imm-run", next: "advisory-reviewer" };
|
|
70
70
|
}
|
|
71
71
|
if (input.target === "compounder") {
|
|
72
72
|
if (input.ownership !== "loop") {
|
|
73
73
|
throw new Error("Compounder routing requires Loop ownership");
|
|
74
74
|
}
|
|
75
|
-
return { entry: "imm-
|
|
75
|
+
return { entry: "imm-run", next: "compounder" };
|
|
76
76
|
}
|
|
77
77
|
if (input.ownership === "kernel") {
|
|
78
|
-
return { entry: "imm-
|
|
78
|
+
return { entry: "imm-run", next: "imm_kernel_canary" };
|
|
79
79
|
}
|
|
80
80
|
const nextByTarget: Record<"step" | "test-repair" | "pr-repair", LoopRouteNext> = {
|
|
81
81
|
step: "executor",
|
|
@@ -88,7 +88,7 @@ export function resolveLoopRoute(input: {
|
|
|
88
88
|
);
|
|
89
89
|
}
|
|
90
90
|
return {
|
|
91
|
-
entry: "imm-
|
|
91
|
+
entry: "imm-run",
|
|
92
92
|
next: nextByTarget[
|
|
93
93
|
input.target as "step" | "test-repair" | "pr-repair"
|
|
94
94
|
],
|
|
@@ -96,14 +96,14 @@ export function resolveLoopRoute(input: {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
export type LoopAction =
|
|
99
|
-
| { entry: "imm-
|
|
99
|
+
| { entry: "imm-run"; next: "executor"; context: LoopRoleContext }
|
|
100
100
|
| {
|
|
101
|
-
entry: "imm-
|
|
101
|
+
entry: "imm-run";
|
|
102
102
|
next: "test-fixer" | "pr-fix" | "arch-explorer" | "advisory-reviewer" | "compounder";
|
|
103
103
|
dispatch: LoopRoleDispatch;
|
|
104
104
|
}
|
|
105
105
|
| {
|
|
106
|
-
entry: "imm-
|
|
106
|
+
entry: "imm-run";
|
|
107
107
|
next: "imm_kernel_canary";
|
|
108
108
|
tool: {
|
|
109
109
|
name: "imm_kernel_canary";
|
|
@@ -116,8 +116,8 @@ export type LoopAction =
|
|
|
116
116
|
| "complete";
|
|
117
117
|
};
|
|
118
118
|
}
|
|
119
|
-
| { entry: "imm-
|
|
120
|
-
| { entry: "imm-
|
|
119
|
+
| { entry: "imm-run"; next: "imm-planner"; reason: "scope_expansion" }
|
|
120
|
+
| { entry: "imm-run"; next: "none"; reason: "no_reusable_learning" };
|
|
121
121
|
|
|
122
122
|
function hasReusableLearningEvidence(context: RoleDelegationContext): boolean {
|
|
123
123
|
if (
|
|
@@ -149,18 +149,18 @@ export function buildLoopAction(input: {
|
|
|
149
149
|
}): LoopAction {
|
|
150
150
|
const route = resolveLoopRoute(input);
|
|
151
151
|
if (route.next === "imm-planner") {
|
|
152
|
-
return { entry: "imm-
|
|
152
|
+
return { entry: "imm-run", next: "imm-planner", reason: "scope_expansion" };
|
|
153
153
|
}
|
|
154
154
|
if (route.next === "compounder") {
|
|
155
155
|
if (!input.context || !hasReusableLearningEvidence(input.context)) {
|
|
156
156
|
return {
|
|
157
|
-
entry: "imm-
|
|
157
|
+
entry: "imm-run",
|
|
158
158
|
next: "none",
|
|
159
159
|
reason: "no_reusable_learning",
|
|
160
160
|
};
|
|
161
161
|
}
|
|
162
162
|
return {
|
|
163
|
-
entry: "imm-
|
|
163
|
+
entry: "imm-run",
|
|
164
164
|
next: "compounder",
|
|
165
165
|
dispatch: buildLoopRoleDispatch({ role: "compounder", context: input.context }),
|
|
166
166
|
};
|
|
@@ -168,28 +168,28 @@ export function buildLoopAction(input: {
|
|
|
168
168
|
if (!input.context) throw new Error(`Loop ${route.next} action requires context`);
|
|
169
169
|
if (route.next === "executor") {
|
|
170
170
|
return {
|
|
171
|
-
entry: "imm-
|
|
171
|
+
entry: "imm-run",
|
|
172
172
|
next: "executor",
|
|
173
173
|
context: buildRoleDelegationPacket({ role: "executor", context: input.context }),
|
|
174
174
|
};
|
|
175
175
|
}
|
|
176
176
|
if (route.next === "test-fixer") {
|
|
177
177
|
return {
|
|
178
|
-
entry: "imm-
|
|
178
|
+
entry: "imm-run",
|
|
179
179
|
next: "test-fixer",
|
|
180
180
|
dispatch: buildLoopRoleDispatch({ role: "test-fixer", context: input.context }),
|
|
181
181
|
};
|
|
182
182
|
}
|
|
183
183
|
if (route.next === "pr-fix") {
|
|
184
184
|
return {
|
|
185
|
-
entry: "imm-
|
|
185
|
+
entry: "imm-run",
|
|
186
186
|
next: "pr-fix",
|
|
187
187
|
dispatch: buildLoopRoleDispatch({ role: "pr-fix", context: input.context }),
|
|
188
188
|
};
|
|
189
189
|
}
|
|
190
190
|
if (route.next === "arch-explorer") {
|
|
191
191
|
return {
|
|
192
|
-
entry: "imm-
|
|
192
|
+
entry: "imm-run",
|
|
193
193
|
next: "arch-explorer",
|
|
194
194
|
dispatch: buildLoopRoleDispatch({ role: "arch-explorer", context: input.context }),
|
|
195
195
|
};
|
|
@@ -199,13 +199,13 @@ export function buildLoopAction(input: {
|
|
|
199
199
|
throw new Error("advisory review requires an explicit lens");
|
|
200
200
|
}
|
|
201
201
|
return {
|
|
202
|
-
entry: "imm-
|
|
202
|
+
entry: "imm-run",
|
|
203
203
|
next: "advisory-reviewer",
|
|
204
204
|
dispatch: buildLoopRoleDispatch({ role: "advisory-reviewer", context: input.context }),
|
|
205
205
|
};
|
|
206
206
|
}
|
|
207
207
|
return {
|
|
208
|
-
entry: "imm-
|
|
208
|
+
entry: "imm-run",
|
|
209
209
|
next: "imm_kernel_canary",
|
|
210
210
|
tool: {
|
|
211
211
|
name: "imm_kernel_canary",
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by scripts/plugin_versioning.ts from the root package.json.
|
|
2
|
-
export const PLUGIN_VERSION = "
|
|
2
|
+
export const PLUGIN_VERSION = "5.0.0" as const;
|
|
@@ -46,6 +46,13 @@ covers the whole lifecycle including cleanup, ranking tolerates missing
|
|
|
46
46
|
embeddings, the budget covers the complete serialized request, and each success,
|
|
47
47
|
failure, and timeout outcome carries its own duration evidence.
|
|
48
48
|
|
|
49
|
+
When an acceptance assertion, or the Spec it references, names an agreed seam,
|
|
50
|
+
check that the delivered tests exercise that acceptance at that seam. A missing
|
|
51
|
+
seam, or a seam silently replaced by a different one, is a finding carrying
|
|
52
|
+
`violated.kind` of `acceptance` with `ref` the acceptance id. When no seam is
|
|
53
|
+
named, its absence is not a finding: candidates written before this rule carries
|
|
54
|
+
no defect for lacking one.
|
|
55
|
+
|
|
49
56
|
A required check that is skipped, that matches zero tests, or whose prerequisite
|
|
50
57
|
is absent does not prove the acceptance; do not accept it as evidence. Full
|
|
51
58
|
suite coverage reported at a coarse pass count is not per-invariant evidence.
|
|
@@ -12,8 +12,9 @@ scoped implementation, staging and focused diagnostics. Enrollment itself does
|
|
|
12
12
|
not start QA; the Parent owns the subsequent Kernel Assurance and foreground
|
|
13
13
|
Review handoff under the same still-valid batch authorization.
|
|
14
14
|
|
|
15
|
-
Before handoff, run the permitted diagnostic checks
|
|
16
|
-
|
|
15
|
+
Before handoff, run the permitted diagnostic checks, complete the Delivery
|
|
16
|
+
Evidence check below and return its table to the Parent as structured diagnostic
|
|
17
|
+
evidence. The read-only Loop
|
|
17
18
|
runtime action only constructs the dispatch envelope; it does not store
|
|
18
19
|
evidence or change task state. Preserve failed and blocked attempts. Do not
|
|
19
20
|
perform QA,
|
|
@@ -21,6 +22,38 @@ review, plan mutation, successor approval, Compounder work, or authority
|
|
|
21
22
|
writes. If the requested change needs scope expansion, stop and return an
|
|
22
23
|
`imm-planner` route with the concrete missing scope and verification reason.
|
|
23
24
|
|
|
25
|
+
## Delivery Evidence
|
|
26
|
+
|
|
27
|
+
Reuse the TaskIntent and any bound Spec's agreed seams and controls. Before the
|
|
28
|
+
first Assurance attempt, return one compact row per acceptance; update affected
|
|
29
|
+
rows after repair. This is handoff evidence, not a new artifact or approval gate.
|
|
30
|
+
|
|
31
|
+
| Acceptance / invariant | Entry and consumers | Positive / negative / boundary controls | Command, outcome and input | Finding coverage |
|
|
32
|
+
|---|---|---|---|---|
|
|
33
|
+
|
|
34
|
+
For changed shared behavior, trace the real entry through its state owner,
|
|
35
|
+
write, serialization, reload and downstream consumer as applicable. Include
|
|
36
|
+
both affected hosts and recovery/replay branches. An async, return-type or
|
|
37
|
+
exception-contract change requires checking every affected caller's await,
|
|
38
|
+
cancellation and error propagation.
|
|
39
|
+
|
|
40
|
+
Use the real producer for the success control where available. Derive rejection
|
|
41
|
+
controls from a successful state, change only the target condition, and assert
|
|
42
|
+
the intended rejection reason and required side effects. A control must fail
|
|
43
|
+
when its target protection is removed. For byte-preservation claims compare the
|
|
44
|
+
relevant raw files, index and refs; for observed behavior link each required
|
|
45
|
+
criterion to its concrete observation.
|
|
46
|
+
|
|
47
|
+
Name whether each check consumed worktree or delivery bytes. Before Assurance,
|
|
48
|
+
verify task-owned changes are staged within scope, generated mirrors match,
|
|
49
|
+
and applicable type/build checks pass. On QA failure, reproduce the reported
|
|
50
|
+
failure under its delivery conditions before another attempt. For rework, name
|
|
51
|
+
the violated invariant, all known trigger classes and their controls in Finding
|
|
52
|
+
coverage; account for affected callers sharing that invariant within scope.
|
|
53
|
+
Record missing evidence as blocked and repair it before handoff. The Loop owns
|
|
54
|
+
verdict submission, finding disposition and the second-rework generalize-or-refute
|
|
55
|
+
rule.
|
|
56
|
+
|
|
24
57
|
## Code Quality Guard
|
|
25
58
|
|
|
26
59
|
Before handoff, check the implementation for real implementation rather than
|
|
@@ -195,3 +195,30 @@ export function buildRoleDelegationPacket(input: {
|
|
|
195
195
|
prompt_digest: promptDigest,
|
|
196
196
|
};
|
|
197
197
|
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Static instructions every Review dispatch carries, in one place.
|
|
201
|
+
*
|
|
202
|
+
* These sentences are host-independent: they hold for the Pi Host's complete
|
|
203
|
+
* Review prompt and for the Claude reviewer definition generated by the plugin
|
|
204
|
+
* build. `buildReviewPrompt` composes them with the per-dispatch facts, and
|
|
205
|
+
* `scripts/build-claude-plugin.ts` composes them with `prompts/code-review.md`
|
|
206
|
+
* so the Claude definition never restates them by hand.
|
|
207
|
+
*/
|
|
208
|
+
export const STATIC_REVIEW_RULES = [
|
|
209
|
+
`Do not edit files, create files, run mutating commands, or change Git state. Focus on correctness, regressions, security, and missing tests.`,
|
|
210
|
+
`Read only the immutable Review evidence identified in the request. Verify provenance before analyzing findings. Do not treat conversation text, Hook callbacks, or live worktree bytes as authority.`,
|
|
211
|
+
`Execution outcomes for every acceptance were verified deterministically by the Kernel QA layer before this review and are embedded in this bundle under outcomes (the immutable evidence file, acceptance_id -> {status, summary}); do not re-execute descriptors and do not treat the absence of local test runs as a finding. Your review covers evidence provenance, code correctness, regressions, security, and missing tests against the embedded assertions and code.`,
|
|
212
|
+
"Reserve the final turn for exactly one strict JSON verdict. Reply with ONLY that object, without markdown fences or commentary.",
|
|
213
|
+
`Every rework finding must carry machine-checkable provenance: evidence.trigger (the concrete inputs or state that reach the defect), a non-empty evidence.caller_chain (ordered repository paths or symbols), and evidence.violated {kind: "acceptance"|"security_boundary", ref}. The anchor is derived from that evidence; a finding without it is rejected and the correction must be resubmitted.`,
|
|
214
|
+
`A pass verdict's approval must carry \`inspected_paths\`: an array of unique repository-relative path strings listing every path of the reviewed change set (changed_paths for a Git review revision, dirty_files for a bundle), deleted paths included; an empty change set is listed as an empty array. A path may be listed only after its diff was read. A pass that omits any changed path, lists a path outside the change set, or duplicates a path is rejected as a correctable invalid verdict.`,
|
|
215
|
+
] as const;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The BR-REQ-3 sentences: what the Parent owes a dispatched reviewer, and what
|
|
219
|
+
* a blocked `submit_review` means. They belong to the reviewer definition, not
|
|
220
|
+
* to a per-dispatch prompt, because they describe the dispatch itself.
|
|
221
|
+
*/
|
|
222
|
+
export const REVIEWER_DISPATCH_RULES = [
|
|
223
|
+
"A dispatched reviewer is never continued or re-prompted, including through SendMessage. The reserved prompt is dispatched verbatim. A blocked `submit_review` is recovered only through its returned `recovery_action`.",
|
|
224
|
+
] as const;
|
|
@@ -17,9 +17,11 @@ import {
|
|
|
17
17
|
writeFileSync,
|
|
18
18
|
} from "node:fs";
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
|
-
import {
|
|
20
|
+
import { readSettledTaskEvidence, readAuditTaskPair, readSecureProjectFile } from "../kernel/storage";
|
|
21
|
+
import { serializeTaskTombstone, type TaskTombstone } from "../kernel/backend_claim";
|
|
21
22
|
import { captureGitTaskRevisionSnapshot, pathMatchesScope } from "../workspace_scope";
|
|
22
23
|
import { expectedBatchHead, findExistingActiveBatch, findSettledBatchRecord } from "./batch_preflight";
|
|
24
|
+
export { classifyBatchLineage, type BatchLineage } from "./batch_preflight";
|
|
23
25
|
|
|
24
26
|
export type BatchGitPreflightRejectReason =
|
|
25
27
|
| "batch_branch_exists"
|
|
@@ -443,6 +445,22 @@ function readBatchCommitEvidence(
|
|
|
443
445
|
* Preserves files on failure without destructive rollback (Finding 1).
|
|
444
446
|
* Records durable commit evidence for tamper-proof crash recovery (Finding 1).
|
|
445
447
|
*/
|
|
448
|
+
/** Structural proof equality: every field, in every order. */
|
|
449
|
+
function proofEquals(a: TaskTombstone, b: TaskTombstone): boolean {
|
|
450
|
+
return serializeTaskTombstone(a) === serializeTaskTombstone(b);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function requireMatchingSettledAudit(
|
|
454
|
+
auditPair: NonNullable<ReturnType<typeof readAuditTaskPair>>,
|
|
455
|
+
settled: NonNullable<ReturnType<typeof readSettledTaskEvidence>>,
|
|
456
|
+
): void {
|
|
457
|
+
// Legacy evidence has no local Run; its audit pair is its authority.
|
|
458
|
+
if (settled.runId === null) return;
|
|
459
|
+
if (auditPair.recordRevision !== settled.recordRevision || !proofEquals(auditPair.proof, settled.proof)) {
|
|
460
|
+
throw new Error("batch commit exported audit pair does not match the settled run");
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
446
464
|
export async function commitBatchChild(input: {
|
|
447
465
|
root: string;
|
|
448
466
|
taskId: string;
|
|
@@ -453,28 +471,38 @@ export async function commitBatchChild(input: {
|
|
|
453
471
|
}): Promise<{ commit: string }> {
|
|
454
472
|
const { root, taskId, batchId, expectedHead, branch: expectedBranch } = input;
|
|
455
473
|
|
|
456
|
-
// 1. Verify
|
|
457
|
-
// The committed local run owns this task's evidence:
|
|
458
|
-
// directory for the same task is not this batch's
|
|
459
|
-
// clears the active run, so the
|
|
460
|
-
|
|
461
|
-
const
|
|
474
|
+
// 1. Verify the Authority Store reports that child done through its settled
|
|
475
|
+
// Run read (Finding 2). The committed local run owns this task's evidence:
|
|
476
|
+
// another worktree's run directory for the same task is not this batch's
|
|
477
|
+
// audit pair. Settlement clears the active run, so the read includes
|
|
478
|
+
// terminal runs, and the exported pair is what the commit stages.
|
|
479
|
+
const settled = readSettledTaskEvidence(root, taskId);
|
|
480
|
+
if (!settled) {
|
|
481
|
+
throw new Error(`cannot commit child ${taskId}: task is not settled done (no settled run)`);
|
|
482
|
+
}
|
|
483
|
+
// The export is what the commit stages: a terminal run must have reached it.
|
|
484
|
+
const auditPair = readAuditTaskPair(root, taskId, settled.runId ?? undefined);
|
|
462
485
|
if (!auditPair) {
|
|
463
486
|
throw new Error(`cannot commit child ${taskId}: task is not settled done (audit pair missing)`);
|
|
464
487
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
488
|
+
requireMatchingSettledAudit(auditPair, settled);
|
|
489
|
+
const settledLifecycle =
|
|
490
|
+
"lifecycle" in settled.record ? settled.record.lifecycle : (settled.record as { phase: string }).phase;
|
|
491
|
+
if (settledLifecycle !== "done") {
|
|
492
|
+
throw new Error(`cannot commit child ${taskId}: task is not settled done (lifecycle is ${settledLifecycle})`);
|
|
468
493
|
}
|
|
469
|
-
if (
|
|
494
|
+
if (settled.proof.terminal_lifecycle !== "done") {
|
|
470
495
|
throw new Error(`cannot commit child ${taskId}: terminal proof lifecycle is not done`);
|
|
471
496
|
}
|
|
472
|
-
if (
|
|
497
|
+
if (settled.record.task_id !== taskId || settled.proof.task_id !== taskId) {
|
|
473
498
|
throw new Error(`cannot commit child ${taskId}: audit task id mismatch`);
|
|
474
499
|
}
|
|
475
500
|
|
|
476
501
|
// 2. Read scope_hint and goal from the verified terminal TaskRecord.intent_snapshot (Finding 1)
|
|
477
|
-
|
|
502
|
+
// The store-validated record decides scope and attestation once the run is
|
|
503
|
+
// known; the export decides them only in the pre-store legacy layout.
|
|
504
|
+
const authoritative = settled.runId === null ? auditPair.record : settled.record;
|
|
505
|
+
const intentSnapshot = authoritative.intent_snapshot;
|
|
478
506
|
const goal = typeof intentSnapshot.goal === "string" ? intentSnapshot.goal : "";
|
|
479
507
|
const scopeHint = Array.isArray(intentSnapshot.scope_hint)
|
|
480
508
|
? intentSnapshot.scope_hint.filter((s): s is string => typeof s === "string")
|
|
@@ -548,7 +576,7 @@ export async function commitBatchChild(input: {
|
|
|
548
576
|
spawnSync("git", ["-C", root, "reset", "--quiet"], { stdio: ["ignore", "ignore", "ignore"] });
|
|
549
577
|
throw new Error("dirty_outside_scope");
|
|
550
578
|
}
|
|
551
|
-
const record =
|
|
579
|
+
const record = authoritative as {
|
|
552
580
|
contract?: string;
|
|
553
581
|
git_base_head?: string;
|
|
554
582
|
attestations?: Array<{ kind: string; diff_hash: string }>;
|
|
@@ -737,6 +765,20 @@ export async function lookupBatchCommit(input: {
|
|
|
737
765
|
}
|
|
738
766
|
}
|
|
739
767
|
|
|
768
|
+
// Evidence identity is required even when the caller omits lineage checks.
|
|
769
|
+
const adopted = readSettledTaskEvidence(root, taskId);
|
|
770
|
+
const auditPair = adopted ? readAuditTaskPair(root, taskId, adopted.runId ?? undefined) : null;
|
|
771
|
+
if (!adopted || !auditPair) {
|
|
772
|
+
throw new Error(`batch_head_lineage_broken: adopted commit lacks terminal audit pair for ${taskId}`);
|
|
773
|
+
}
|
|
774
|
+
requireMatchingSettledAudit(auditPair, adopted);
|
|
775
|
+
const authoritative = adopted.runId !== null ? adopted.record : auditPair.record;
|
|
776
|
+
const lifecycle =
|
|
777
|
+
"lifecycle" in authoritative ? authoritative.lifecycle : (authoritative as { phase: string }).phase;
|
|
778
|
+
if (lifecycle !== "done") {
|
|
779
|
+
throw new Error(`batch_head_lineage_broken: adopted commit task lifecycle is not done: ${lifecycle}`);
|
|
780
|
+
}
|
|
781
|
+
|
|
740
782
|
// Finding 3: Verify terminal audit, expectedHead, single parent, and commit tree delta within authorized scope
|
|
741
783
|
if (expectedHead !== undefined) {
|
|
742
784
|
const currentHeadResult = spawnSync("git", ["-C", root, "rev-parse", "HEAD"], {
|
|
@@ -744,7 +786,14 @@ export async function lookupBatchCommit(input: {
|
|
|
744
786
|
stdio: ["ignore", "pipe", "pipe"],
|
|
745
787
|
});
|
|
746
788
|
const currentHead = currentHeadResult.stdout.trim();
|
|
747
|
-
|
|
789
|
+
// HEAD is the own commit, or a fast-forward the user added on top of it
|
|
790
|
+
// before the runner persisted it.
|
|
791
|
+
if (
|
|
792
|
+
currentHead !== commit &&
|
|
793
|
+
spawnSync("git", ["-C", root, "merge-base", "--is-ancestor", commit, currentHead], {
|
|
794
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
795
|
+
}).status !== 0
|
|
796
|
+
) {
|
|
748
797
|
throw new Error(`batch_head_lineage_broken: current HEAD ${currentHead} diverged from adopted commit ${commit}`);
|
|
749
798
|
}
|
|
750
799
|
const parentsResult = spawnSync("git", ["-C", root, "rev-parse", `${commit}^@`], {
|
|
@@ -758,20 +807,9 @@ export async function lookupBatchCommit(input: {
|
|
|
758
807
|
);
|
|
759
808
|
}
|
|
760
809
|
|
|
761
|
-
// Verify terminal audit exists and is settled done
|
|
762
|
-
const adoptedRun = localRunId(root, taskId);
|
|
763
|
-
const auditPair = readAuditTaskPair(root, taskId, adoptedRun ?? undefined);
|
|
764
|
-
if (!auditPair) {
|
|
765
|
-
throw new Error(`batch_head_lineage_broken: adopted commit lacks terminal audit pair for ${taskId}`);
|
|
766
|
-
}
|
|
767
|
-
const lifecycle = "lifecycle" in auditPair.record ? auditPair.record.lifecycle : auditPair.record.phase;
|
|
768
|
-
if (lifecycle !== "done") {
|
|
769
|
-
throw new Error(`batch_head_lineage_broken: adopted commit task lifecycle is not done: ${lifecycle}`);
|
|
770
|
-
}
|
|
771
|
-
|
|
772
810
|
// Verify commit tree delta is strictly within authorized scope_hint
|
|
773
|
-
const scopeHint = Array.isArray(
|
|
774
|
-
?
|
|
811
|
+
const scopeHint = Array.isArray(authoritative.intent_snapshot.scope_hint)
|
|
812
|
+
? authoritative.intent_snapshot.scope_hint.filter((s): s is string => typeof s === "string")
|
|
775
813
|
: [];
|
|
776
814
|
const deltaResult = spawnSync(
|
|
777
815
|
"git",
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The one production child Kernel port for unattended batch runs (D5 of
|
|
2
|
+
// docs/specs/deepen-authority-seams.spec.md). runtime/unattended owns every
|
|
3
|
+
// Kernel-facing member; a Host supplies only advanceTask, its own progression
|
|
4
|
+
// seam. Git operations are not Kernel-port members: the runner drives them
|
|
5
|
+
// through BatchRunnerGitPort alone.
|
|
6
|
+
import { projectAssurance } from "../kernel/assurance_projection";
|
|
7
|
+
import { deriveChildEnrollment, type BatchAuthorizationBinding, type BatchAuthorityRegistry } from "../kernel/batch_authority";
|
|
8
|
+
import { enrollTask } from "../kernel/enrollment";
|
|
9
|
+
import type { EnrollmentAuthorityRegistry } from "../kernel/enrollment_authority";
|
|
10
|
+
import { readTaskRecordRaw } from "../kernel/storage";
|
|
11
|
+
import { readActiveClaimTaskId, isOwnBatchClaim } from "./batch_preflight";
|
|
12
|
+
import type { BatchRunStateRecord } from "./batch_state";
|
|
13
|
+
import { batchQaFailureFacts, type BatchChildAdvanceResult, type BatchRunnerKernelPort } from "./batch_runner";
|
|
14
|
+
|
|
15
|
+
/** Inputs the shared port closes over. Only advanceTask is Host-owned. */
|
|
16
|
+
export interface BatchKernelPortInput {
|
|
17
|
+
/** Worktree root the batch runs in. */
|
|
18
|
+
root: string;
|
|
19
|
+
/** Enrollment authority registry shared with the single Enrollment entry (D3). */
|
|
20
|
+
enrollmentRegistry: EnrollmentAuthorityRegistry;
|
|
21
|
+
/** The batch's registry, derived capability and validated binding. */
|
|
22
|
+
registry: BatchAuthorityRegistry;
|
|
23
|
+
capability: object;
|
|
24
|
+
binding: BatchAuthorizationBinding;
|
|
25
|
+
/** The Host's own child progression seam; the only member the Host supplies. */
|
|
26
|
+
advanceTask(root: string, taskId: string): Promise<BatchChildAdvanceResult>;
|
|
27
|
+
/** Resume ownership context straight from the batch preflight projection. */
|
|
28
|
+
resume: {
|
|
29
|
+
isResuming: boolean;
|
|
30
|
+
existingBatch: BatchRunStateRecord | null;
|
|
31
|
+
batchBranch: string;
|
|
32
|
+
};
|
|
33
|
+
/** Optional test overrides for the remaining members (invariant I5). */
|
|
34
|
+
overrides?: Partial<BatchRunnerKernelPort>;
|
|
35
|
+
/** Test seam for the shared port's Kernel endpoints; production uses the real ones. */
|
|
36
|
+
deps?: {
|
|
37
|
+
deriveChildEnrollment?: typeof deriveChildEnrollment;
|
|
38
|
+
enrollTask?: typeof enrollTask;
|
|
39
|
+
projectAssurance?: typeof projectAssurance;
|
|
40
|
+
readTaskRecordRaw?: typeof readTaskRecordRaw;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the production child Kernel port. A Host contributes only advanceTask
|
|
46
|
+
* (plus its test overrides); enrollment goes through the single Enrollment
|
|
47
|
+
* entry, projection and ownership are shared, and batch authorization is
|
|
48
|
+
* validated against the authoritative registry.
|
|
49
|
+
*/
|
|
50
|
+
export function createBatchKernelPort(input: BatchKernelPortInput): BatchRunnerKernelPort {
|
|
51
|
+
const project = input.deps?.projectAssurance ?? projectAssurance;
|
|
52
|
+
const readRecord = input.deps?.readTaskRecordRaw ?? readTaskRecordRaw;
|
|
53
|
+
const deriveEnrollment = input.deps?.deriveChildEnrollment ?? deriveChildEnrollment;
|
|
54
|
+
const enrollKernelTask = input.deps?.enrollTask ?? enrollTask;
|
|
55
|
+
const base: BatchRunnerKernelPort = {
|
|
56
|
+
enrollTask: async ({ root, task_id, batch }) => {
|
|
57
|
+
const now = new Date().toISOString();
|
|
58
|
+
const derived = deriveEnrollment(root, batch.registry, {
|
|
59
|
+
capability: batch.capability,
|
|
60
|
+
binding: input.binding,
|
|
61
|
+
task_id,
|
|
62
|
+
expected_head: batch.binding.expected_head,
|
|
63
|
+
now,
|
|
64
|
+
});
|
|
65
|
+
await enrollKernelTask(root, input.enrollmentRegistry, {
|
|
66
|
+
binding: derived.binding,
|
|
67
|
+
batch: {
|
|
68
|
+
registry: batch.registry,
|
|
69
|
+
capability: batch.capability,
|
|
70
|
+
binding: input.binding,
|
|
71
|
+
expected_head: batch.binding.expected_head,
|
|
72
|
+
},
|
|
73
|
+
now,
|
|
74
|
+
});
|
|
75
|
+
// The enrollment itself is the effect; its record is re-read below.
|
|
76
|
+
const recordRaw = readRecord(root, task_id);
|
|
77
|
+
return { record_revision: recordRaw.revision };
|
|
78
|
+
},
|
|
79
|
+
advanceTask: input.advanceTask,
|
|
80
|
+
projectTask: async (root, taskId) => {
|
|
81
|
+
const fresh = await project(root, taskId);
|
|
82
|
+
if (!fresh.claim || fresh.projection.lifecycle !== "active" || fresh.error !== null) return fresh;
|
|
83
|
+
const { record, revision } = readRecord(root, taskId);
|
|
84
|
+
if (!record || revision !== fresh.projection.record_revision)
|
|
85
|
+
throw new Error(`Kernel recovery projection changed for ${taskId}`);
|
|
86
|
+
return { ...fresh, ...batchQaFailureFacts(record) };
|
|
87
|
+
},
|
|
88
|
+
ownsTaskClaim: (taskId) =>
|
|
89
|
+
input.registry.isChildConsumed(input.capability, taskId),
|
|
90
|
+
validateBatchAuthorization: (request) =>
|
|
91
|
+
request.registry.inspect(request.capability, request.binding as never),
|
|
92
|
+
};
|
|
93
|
+
const port: BatchRunnerKernelPort = { ...base, ...(input.overrides ?? {}) };
|
|
94
|
+
const overriddenOwnsTaskClaim = port.ownsTaskClaim;
|
|
95
|
+
return {
|
|
96
|
+
...port,
|
|
97
|
+
ownsTaskClaim: (taskId) => {
|
|
98
|
+
if (input.resume.isResuming && taskId === readActiveClaimTaskId(input.root)) {
|
|
99
|
+
// Re-verify the CURRENT claim identity synchronously: a claim swapped
|
|
100
|
+
// during confirmation is never adopted.
|
|
101
|
+
return isOwnBatchClaim(input.root, input.resume.existingBatch!, taskId, input.resume.batchBranch);
|
|
102
|
+
}
|
|
103
|
+
return overriddenOwnsTaskClaim(taskId);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -19,7 +19,6 @@ import type {
|
|
|
19
19
|
} from "./types";
|
|
20
20
|
|
|
21
21
|
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
22
|
-
export const DEFAULT_DEADLINE_MS = 8 * 60 * 60 * 1_000;
|
|
23
22
|
const DEFAULT_QA_FAILURE_LIMIT = 2;
|
|
24
23
|
|
|
25
24
|
/**
|
|
@@ -123,7 +122,7 @@ function dependencyClosures(order: GithubInitiativeObservation["tasks"]): Map<st
|
|
|
123
122
|
return closures;
|
|
124
123
|
}
|
|
125
124
|
|
|
126
|
-
function budget(input: ProjectBatchPlanInput, enrollableCount: number
|
|
125
|
+
function budget(input: ProjectBatchPlanInput, enrollableCount: number): BatchPlanBudget {
|
|
127
126
|
const maxChildren = input.budget?.max_children === undefined
|
|
128
127
|
? positiveSafeInteger(enrollableCount, "budget.max_children")
|
|
129
128
|
: positiveSafeInteger(input.budget.max_children, "budget.max_children");
|
|
@@ -132,12 +131,7 @@ function budget(input: ProjectBatchPlanInput, enrollableCount: number, confirmat
|
|
|
132
131
|
const qaFailureLimit = input.budget?.qa_failure_limit === undefined
|
|
133
132
|
? DEFAULT_QA_FAILURE_LIMIT
|
|
134
133
|
: positiveSafeInteger(input.budget.qa_failure_limit, "budget.qa_failure_limit");
|
|
135
|
-
|
|
136
|
-
? new Date(confirmationTime.milliseconds + DEFAULT_DEADLINE_MS).toISOString()
|
|
137
|
-
: timestamp(input.budget.deadline_at, "budget.deadline_at").iso;
|
|
138
|
-
if (Date.parse(deadline) <= confirmationTime.milliseconds)
|
|
139
|
-
throw new Error("budget.deadline_at must be later than confirmation_time");
|
|
140
|
-
return { max_children: maxChildren, deadline_at: deadline, qa_failure_limit: qaFailureLimit };
|
|
134
|
+
return { max_children: maxChildren, qa_failure_limit: qaFailureLimit };
|
|
141
135
|
}
|
|
142
136
|
|
|
143
137
|
export async function projectBatchPlan(
|
|
@@ -238,6 +232,6 @@ export async function projectBatchPlan(
|
|
|
238
232
|
children,
|
|
239
233
|
enrollable,
|
|
240
234
|
plan_digest: planDigest,
|
|
241
|
-
budget: budget(input, enrollable.length
|
|
235
|
+
budget: budget(input, enrollable.length),
|
|
242
236
|
};
|
|
243
237
|
}
|