immune-brain 2.8.3 → 3.0.2
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/.claude-plugin/marketplace.json +16 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/package.json +9 -2
- package/plugins/immune-brain/.claude-plugin/plugin.json +8 -0
- package/plugins/immune-brain/.mcp.json +8 -0
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +354 -64
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +74 -706
- package/plugins/immune-brain/.pi-extension/pi-canary-invocations.ts +1 -90
- package/plugins/immune-brain/.pi-extension/pi-canary-native-review.ts +13 -160
- package/plugins/immune-brain/.pi-extension/pi-canary-qa-findings.ts +1 -50
- package/plugins/immune-brain/.pi-extension/pi-canary-review-bundle.ts +1 -262
- package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +3 -2
- package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +8 -229
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +23 -5
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +11 -0
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +7514 -0
- package/plugins/immune-brain/dist/docs/reference/code-quality-guard.md +58 -0
- package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +1 -1
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +19 -13
- package/plugins/immune-brain/dist/imm-loop.md +6 -7
- package/plugins/immune-brain/dist/imm-planner.md +1 -1
- package/plugins/immune-brain/dist/imm-pr-fix.md +9 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +33 -13
- package/plugins/immune-brain/dist/role-prompts/executor.md +14 -0
- package/plugins/immune-brain/dist/role-prompts/pr-fix.md +9 -0
- package/plugins/immune-brain/dist/role-prompts/test-fixer.md +7 -0
- package/plugins/immune-brain/hooks/hooks.json +55 -0
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +836 -0
- package/plugins/immune-brain/runtime/assurance/enrollment.ts +6 -0
- package/plugins/immune-brain/runtime/assurance/host_port.ts +18 -0
- package/plugins/immune-brain/runtime/assurance/invocations.ts +90 -0
- package/plugins/immune-brain/runtime/assurance/qa_findings.ts +50 -0
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +596 -0
- package/plugins/immune-brain/runtime/assurance/verification.ts +233 -0
- package/plugins/immune-brain/runtime/claude/capability.ts +67 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +70 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +789 -0
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +363 -0
- package/plugins/immune-brain/runtime/claude/review_host.ts +645 -0
- package/plugins/immune-brain/runtime/commands/kernel.ts +221 -3
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +2 -2
- package/plugins/immune-brain/runtime/kernel/application.ts +8 -5
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +24 -13
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +78 -115
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +6 -4
- package/plugins/immune-brain/runtime/kernel/capability_registry.ts +89 -0
- package/plugins/immune-brain/runtime/kernel/completion.ts +64 -6
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +189 -100
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +37 -80
- package/plugins/immune-brain/runtime/kernel/intent.ts +24 -0
- package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +31 -0
- package/plugins/immune-brain/runtime/kernel/reducer.ts +36 -13
- package/plugins/immune-brain/runtime/kernel/storage.ts +16 -16
- package/plugins/immune-brain/runtime/kernel/types.ts +32 -2
- package/plugins/immune-brain/runtime/kernel/validation.ts +107 -16
- package/plugins/immune-brain/runtime/loop_contract.ts +17 -2
- package/plugins/immune-brain/runtime/prompts/code-review.md +33 -13
- package/plugins/immune-brain/runtime/prompts/executor.md +14 -0
- package/plugins/immune-brain/runtime/prompts/pr-fix.md +9 -0
- package/plugins/immune-brain/runtime/prompts/test-fixer.md +7 -0
- package/plugins/immune-brain/runtime/v4_runtime.ts +5 -2
- package/plugins/immune-brain/runtime/workspace_scope.ts +191 -5
- package/plugins/immune-brain/skills/imm-loop/SKILL.md +3 -4
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +2 -0
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// P2B1 enrollment authority. NOT exported from kernel/index.ts.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// The production registry is created only inside the Pi extension activation
|
|
5
|
-
// closure; tests create isolated registries via the fixture seam.
|
|
2
|
+
// Uses the shared capability registry factory; enrollment-specific binding
|
|
3
|
+
// validation and projection remain here.
|
|
6
4
|
|
|
5
|
+
import { createCapabilityRegistry } from "./capability_registry";
|
|
7
6
|
import { MUTATION_AUTHORITY_CAPABILITY_BRAND } from "./types";
|
|
8
7
|
|
|
9
8
|
export const ENROLLMENT_CAPABILITY_BRAND = Symbol.for("assurance-kernel.enrollment-capability-brand");
|
|
@@ -20,11 +19,6 @@ export interface EnrollmentCapabilityBinding {
|
|
|
20
19
|
nonce: string;
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
interface EnrollmentCapabilityState extends EnrollmentCapabilityBinding {
|
|
24
|
-
issued_at: string;
|
|
25
|
-
consumed: boolean;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
22
|
export interface ValidatedEnrollment {
|
|
29
23
|
task_id: string;
|
|
30
24
|
intent_path: string;
|
|
@@ -48,76 +42,39 @@ export interface EnrollmentAuthorityRegistry {
|
|
|
48
42
|
}
|
|
49
43
|
|
|
50
44
|
export function createEnrollmentAuthorityRegistry(): EnrollmentAuthorityRegistry {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
{
|
|
84
|
-
[ENROLLMENT_CAPABILITY_BRAND]: { value: true, enumerable: false, writable: false, configurable: false },
|
|
85
|
-
[brand]: { value: true, enumerable: false, writable: false, configurable: false },
|
|
86
|
-
},
|
|
87
|
-
),
|
|
88
|
-
);
|
|
89
|
-
states.set(capability, { ...binding, issued_at: issuedAt, consumed: false });
|
|
90
|
-
return capability;
|
|
91
|
-
},
|
|
92
|
-
inspect(capability: object, expected: EnrollmentCapabilityBinding, now = Date.now()): ValidatedEnrollment {
|
|
93
|
-
if (!isEnrollmentCapability(capability)) throw new Error("enrollment capability required");
|
|
94
|
-
const state = stateOf(capability);
|
|
95
|
-
if (state.consumed) throw new Error("enrollment capability already consumed");
|
|
96
|
-
if (Date.parse(state.expires_at) <= now) throw new Error("enrollment capability has expired");
|
|
97
|
-
for (const key of Object.keys(expected) as Array<keyof EnrollmentCapabilityBinding>) {
|
|
98
|
-
if (state[key] !== expected[key])
|
|
99
|
-
throw new Error(`enrollment capability ${key} mismatch`);
|
|
100
|
-
}
|
|
101
|
-
return {
|
|
102
|
-
task_id: state.task_id,
|
|
103
|
-
intent_path: state.intent_path,
|
|
104
|
-
intent_revision: state.intent_revision,
|
|
105
|
-
intent_content_hash: state.intent_content_hash,
|
|
106
|
-
preparation_digest: state.preparation_digest,
|
|
107
|
-
actor_id: state.actor_id,
|
|
108
|
-
confirmation_ref: state.confirmation_ref,
|
|
109
|
-
issued_at: state.issued_at,
|
|
110
|
-
expires_at: state.expires_at,
|
|
111
|
-
nonce: state.nonce,
|
|
112
|
-
};
|
|
113
|
-
},
|
|
114
|
-
consume(capability: object, expected: EnrollmentCapabilityBinding, now = Date.now()): ValidatedEnrollment {
|
|
115
|
-
const validated = this.inspect(capability, expected, now);
|
|
116
|
-
stateOf(capability).consumed = true;
|
|
117
|
-
return validated;
|
|
118
|
-
},
|
|
119
|
-
isConsumed(capability: object): boolean {
|
|
120
|
-
return stateOf(capability).consumed;
|
|
45
|
+
return createCapabilityRegistry<EnrollmentCapabilityBinding, EnrollmentCapabilityBinding, ValidatedEnrollment>(
|
|
46
|
+
ENROLLMENT_CAPABILITY_BRAND,
|
|
47
|
+
{
|
|
48
|
+
validateBinding(binding, issuedAt) {
|
|
49
|
+
const missing: string[] = [];
|
|
50
|
+
for (const [key, value] of Object.entries(binding)) {
|
|
51
|
+
if (value === undefined || value === null || value === "") missing.push(key);
|
|
52
|
+
}
|
|
53
|
+
if (missing.length > 0)
|
|
54
|
+
throw new Error(`enrollment capability binding is incomplete: ${missing.join(", ")}`);
|
|
55
|
+
if (Number.isNaN(Date.parse(binding.expires_at)) || Date.parse(binding.expires_at) <= Date.parse(issuedAt))
|
|
56
|
+
throw new Error("enrollment capability must have a future expiry");
|
|
57
|
+
},
|
|
58
|
+
validateAndProject(state, expected, now) {
|
|
59
|
+
if (Date.parse(state.expires_at) <= now) throw new Error("enrollment capability has expired");
|
|
60
|
+
for (const key of Object.keys(expected) as Array<keyof EnrollmentCapabilityBinding>) {
|
|
61
|
+
if (state[key] !== expected[key])
|
|
62
|
+
throw new Error(`enrollment capability ${key} mismatch`);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
task_id: state.task_id,
|
|
66
|
+
intent_path: state.intent_path,
|
|
67
|
+
intent_revision: state.intent_revision,
|
|
68
|
+
intent_content_hash: state.intent_content_hash,
|
|
69
|
+
preparation_digest: state.preparation_digest,
|
|
70
|
+
actor_id: state.actor_id,
|
|
71
|
+
confirmation_ref: state.confirmation_ref,
|
|
72
|
+
issued_at: state.issued_at,
|
|
73
|
+
expires_at: state.expires_at,
|
|
74
|
+
nonce: state.nonce,
|
|
75
|
+
};
|
|
76
|
+
},
|
|
121
77
|
},
|
|
122
|
-
|
|
78
|
+
"enrollment",
|
|
79
|
+
);
|
|
123
80
|
}
|
|
@@ -46,6 +46,16 @@ export const RISK_FLOOR_SCOPE_PREFIXES = [
|
|
|
46
46
|
"plugins/immune-brain/.pi-extension",
|
|
47
47
|
] as const;
|
|
48
48
|
|
|
49
|
+
// Concrete changed paths use a narrower, segment-safe prefix policy. Keep this
|
|
50
|
+
// separate from the glob-aware scope_hint policy above: the two inputs have
|
|
51
|
+
// different contracts and docs-only scope remains routine today.
|
|
52
|
+
export const CHANGED_PATH_RISK_FLOOR_PREFIXES = [
|
|
53
|
+
"plugins/immune-brain/runtime/kernel",
|
|
54
|
+
"plugins/immune-brain/.pi-extension",
|
|
55
|
+
"docs/specs",
|
|
56
|
+
"docs/plans",
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
49
59
|
function segmentMatches(e: string, s: string): boolean {
|
|
50
60
|
if (e === "*") return true;
|
|
51
61
|
if (!e.includes("*") && !e.includes("?")) return e === s;
|
|
@@ -330,6 +340,20 @@ export function canonicalIntentHash(intent: unknown): string {
|
|
|
330
340
|
|
|
331
341
|
const RISK_RANK: Record<TaskRisk, number> = { routine: 0, material: 1, critical: 2 };
|
|
332
342
|
|
|
343
|
+
export function classifyTaskRisk(
|
|
344
|
+
changedPaths: readonly string[],
|
|
345
|
+
declaredRisk: TaskRisk,
|
|
346
|
+
): TaskRisk {
|
|
347
|
+
const pathFloor = changedPaths.some((path) =>
|
|
348
|
+
CHANGED_PATH_RISK_FLOOR_PREFIXES.some(
|
|
349
|
+
(prefix) => path === prefix || path.startsWith(`${prefix}/`),
|
|
350
|
+
),
|
|
351
|
+
)
|
|
352
|
+
? "material"
|
|
353
|
+
: "routine";
|
|
354
|
+
return RISK_RANK[declaredRisk] >= RISK_RANK[pathFloor] ? declaredRisk : pathFloor;
|
|
355
|
+
}
|
|
356
|
+
|
|
333
357
|
function contentHashWithoutRevision(intent: TaskIntentV1): string {
|
|
334
358
|
const { revision: _revision, ...rest } = intent;
|
|
335
359
|
return canonicalIntentHash(rest);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// enrollment call, or writer. Direct import cannot authorize or mutate.
|
|
5
5
|
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
7
8
|
import { resolve } from "node:path";
|
|
8
9
|
import { readBackendClaim, readTaskTombstone } from "./backend_claim";
|
|
9
10
|
import { readTaskIntent } from "./intent";
|
|
@@ -13,6 +14,21 @@ import {
|
|
|
13
14
|
} from "./storage";
|
|
14
15
|
|
|
15
16
|
const SOURCE_PATH = ".imm/state/workspace.json";
|
|
17
|
+
const GIT_OBJECT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The Enrollment base commit. v4 TaskRecords bind this exact commit so QA,
|
|
21
|
+
* Review, authorization, and completion can all derive one scoped revision.
|
|
22
|
+
*/
|
|
23
|
+
export function readGitHead(root: string): string {
|
|
24
|
+
const result = spawnSync("git", ["-C", root, "rev-parse", "--verify", "HEAD^{commit}"], {
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
});
|
|
27
|
+
const head = typeof result.stdout === "string" ? result.stdout.trim() : "";
|
|
28
|
+
if (result.status !== 0 || !GIT_OBJECT_ID.test(head))
|
|
29
|
+
throw new Error("enrollment requires a committed Git HEAD in the project root");
|
|
30
|
+
return head.toLowerCase();
|
|
31
|
+
}
|
|
16
32
|
|
|
17
33
|
export interface PiCanaryPrepareInput {
|
|
18
34
|
task_id: string;
|
|
@@ -43,6 +59,9 @@ export interface PiCanaryPreparation {
|
|
|
43
59
|
lifecycle: string | null;
|
|
44
60
|
artifact_state: string | null;
|
|
45
61
|
} | null;
|
|
62
|
+
/** Commit the confirmation is bound to; becomes TaskRecord v4 `git_base_head`. */
|
|
63
|
+
git_base_head: string | null;
|
|
64
|
+
git_error: string | null;
|
|
46
65
|
workspace: {
|
|
47
66
|
current_working: string | null;
|
|
48
67
|
};
|
|
@@ -139,6 +158,16 @@ export function preparePiCanary(root: string, input: PiCanaryPrepareInput): PiCa
|
|
|
139
158
|
`workspace owner ${state.state.current_working} contradicts backend claim task ${claim.task_id}`,
|
|
140
159
|
);
|
|
141
160
|
|
|
161
|
+
// A missing HEAD is a readiness blocker, not a crash: the rehearsal and the
|
|
162
|
+
// native gate must be able to report it while still binding the digest.
|
|
163
|
+
let gitBaseHead: string | null = null;
|
|
164
|
+
let gitError: string | null = null;
|
|
165
|
+
try {
|
|
166
|
+
gitBaseHead = readGitHead(canonicalRoot);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
gitError = error instanceof Error ? error.message : String(error);
|
|
169
|
+
}
|
|
170
|
+
|
|
142
171
|
const preparation: PiCanaryPreparation = {
|
|
143
172
|
contract: "assurance_kernel/pi_canary_preparation/v1",
|
|
144
173
|
task_id: input.task_id,
|
|
@@ -148,6 +177,8 @@ export function preparePiCanary(root: string, input: PiCanaryPrepareInput): PiCa
|
|
|
148
177
|
backend_claim: backend,
|
|
149
178
|
task_tombstone: taskTombstone,
|
|
150
179
|
task_record_v3: record,
|
|
180
|
+
git_base_head: gitBaseHead,
|
|
181
|
+
git_error: gitError,
|
|
151
182
|
workspace,
|
|
152
183
|
digest: "",
|
|
153
184
|
};
|
|
@@ -6,20 +6,21 @@ import { createHash } from "node:crypto";
|
|
|
6
6
|
import { completionDecision } from "./completion";
|
|
7
7
|
import {
|
|
8
8
|
REDUCED_MUTATION_BRAND,
|
|
9
|
+
TASK_RECORD_CONTRACT_V4,
|
|
9
10
|
type AuthorityAuditDescriptor,
|
|
10
11
|
type ReducedTaskMutation,
|
|
11
12
|
type TaskAction,
|
|
12
13
|
type TaskFinding,
|
|
13
14
|
type TaskIntentRefV3,
|
|
14
15
|
type TaskIntentV1,
|
|
15
|
-
type
|
|
16
|
+
type TaskRecord,
|
|
16
17
|
} from "./types";
|
|
17
18
|
import {
|
|
18
19
|
KernelInvariantError,
|
|
19
20
|
assertKernelInvariantsV3,
|
|
20
21
|
assertTaskRecordUpdateV3,
|
|
21
22
|
parseTaskAction,
|
|
22
|
-
|
|
23
|
+
parseTaskRecord,
|
|
23
24
|
} from "./validation";
|
|
24
25
|
import { classifyIntentRevision, canonicalIntentHash } from "./intent";
|
|
25
26
|
|
|
@@ -29,7 +30,7 @@ const RISK_RANK: Record<TaskIntentV1["risk"], number> = {
|
|
|
29
30
|
critical: 2,
|
|
30
31
|
};
|
|
31
32
|
|
|
32
|
-
function transitionLifecycle(record:
|
|
33
|
+
function transitionLifecycle(record: TaskRecord, to: "done" | "stopped"): void {
|
|
33
34
|
if (record.lifecycle !== "active")
|
|
34
35
|
throw new KernelInvariantError([
|
|
35
36
|
`illegal lifecycle transition: ${record.lifecycle} -> ${to}`,
|
|
@@ -37,7 +38,7 @@ function transitionLifecycle(record: TaskRecordV3, to: "done" | "stopped"): void
|
|
|
37
38
|
record.lifecycle = to;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
function stateOf(record:
|
|
41
|
+
function stateOf(record: TaskRecord): string {
|
|
41
42
|
return `${record.lifecycle}:${record.artifact_state}`;
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -54,7 +55,7 @@ function stableJson(value: unknown): string {
|
|
|
54
55
|
return primitive === undefined ? "null" : primitive;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
export function canonicalRecordHash(record:
|
|
58
|
+
export function canonicalRecordHash(record: TaskRecord): string {
|
|
58
59
|
// Must equal the storage CAS revision: the exact serialized file bytes.
|
|
59
60
|
return `sha256:${createHash("sha256")
|
|
60
61
|
.update(`${JSON.stringify(record, null, 2)}\n`)
|
|
@@ -99,7 +100,7 @@ export function recordedActionFingerprint(reason: string | null): string | null
|
|
|
99
100
|
return matched?.[1] ?? null;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
function copyRecord(record:
|
|
103
|
+
function copyRecord(record: TaskRecord): TaskRecord {
|
|
103
104
|
return {
|
|
104
105
|
...record,
|
|
105
106
|
intent_snapshot: { ...record.intent_snapshot },
|
|
@@ -113,7 +114,7 @@ function copyRecord(record: TaskRecordV3): TaskRecordV3 {
|
|
|
113
114
|
};
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
function reviewRound(record:
|
|
117
|
+
function reviewRound(record: TaskRecord): number {
|
|
117
118
|
return (
|
|
118
119
|
Math.max(
|
|
119
120
|
0,
|
|
@@ -125,7 +126,7 @@ function reviewRound(record: TaskRecordV3): number {
|
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
function appendHistory(
|
|
128
|
-
record:
|
|
129
|
+
record: TaskRecord,
|
|
129
130
|
action: TaskAction,
|
|
130
131
|
from: string,
|
|
131
132
|
detail: string | null,
|
|
@@ -200,11 +201,12 @@ export function findingsDigestV2(findings: TaskFinding[]): string {
|
|
|
200
201
|
}
|
|
201
202
|
|
|
202
203
|
export function reduceTask(
|
|
203
|
-
recordRaw:
|
|
204
|
+
recordRaw: TaskRecord,
|
|
204
205
|
actionRaw: TaskAction,
|
|
205
206
|
authorityAudit: AuthorityAuditDescriptor | null = null,
|
|
207
|
+
changedPaths?: readonly string[],
|
|
206
208
|
): ReducedTaskMutation {
|
|
207
|
-
const previous =
|
|
209
|
+
const previous = parseTaskRecord(recordRaw);
|
|
208
210
|
assertKernelInvariantsV3(previous.intent_snapshot, previous);
|
|
209
211
|
const action = parseTaskAction(actionRaw);
|
|
210
212
|
const record = copyRecord(previous);
|
|
@@ -343,8 +345,23 @@ export function reduceTask(
|
|
|
343
345
|
throw new KernelInvariantError([
|
|
344
346
|
`attestations contains duplicate id ${approval.id}`,
|
|
345
347
|
]);
|
|
348
|
+
// A v4 review attestation must pin the exact immutable revision the
|
|
349
|
+
// reviewer analyzed; nothing else may carry that identity.
|
|
350
|
+
const reviewRevision = approval.review_revision;
|
|
351
|
+
if (reviewRevision && approval.kind !== "review")
|
|
352
|
+
throw new KernelInvariantError(["review_revision is only valid on review approvals"]);
|
|
353
|
+
if (record.contract !== TASK_RECORD_CONTRACT_V4) {
|
|
354
|
+
if (reviewRevision)
|
|
355
|
+
throw new KernelInvariantError(["review_revision requires a TaskRecord v4"]);
|
|
356
|
+
} else if (approval.kind === "review") {
|
|
357
|
+
if (!reviewRevision)
|
|
358
|
+
throw new KernelInvariantError(["v4 review approval requires review_revision"]);
|
|
359
|
+
if (reviewRevision.base_head !== record.git_base_head)
|
|
360
|
+
throw new KernelInvariantError(["review_revision.base_head must equal the Enrollment git_base_head"]);
|
|
361
|
+
}
|
|
362
|
+
const { review_revision: storedReviewRevision, ...approvalWithoutRevision } = approval;
|
|
346
363
|
record.attestations.push({
|
|
347
|
-
...
|
|
364
|
+
...approvalWithoutRevision,
|
|
348
365
|
acceptance_results: approval.kind === "qa"
|
|
349
366
|
? record.intent_snapshot.acceptance.map((item) => ({
|
|
350
367
|
acceptance_id: item.id,
|
|
@@ -352,6 +369,7 @@ export function reduceTask(
|
|
|
352
369
|
summary: `host-attested QA: ${approval.summary}`,
|
|
353
370
|
}))
|
|
354
371
|
: [],
|
|
372
|
+
...(storedReviewRevision ? { review_revision: storedReviewRevision } : {}),
|
|
355
373
|
});
|
|
356
374
|
appendHistory(record, action, from, approval.id, authorityAudit);
|
|
357
375
|
break;
|
|
@@ -380,6 +398,8 @@ export function reduceTask(
|
|
|
380
398
|
throw new KernelInvariantError([
|
|
381
399
|
`attestations contains duplicate id ${approval.id}`,
|
|
382
400
|
]);
|
|
401
|
+
if (approval.review_revision)
|
|
402
|
+
throw new KernelInvariantError(["review_revision is only valid on review approvals"]);
|
|
383
403
|
record.attestations.push({ ...approval, acceptance_results: [] });
|
|
384
404
|
appendHistory(record, action, from, approval.id, authorityAudit);
|
|
385
405
|
break;
|
|
@@ -518,11 +538,14 @@ export function reduceTask(
|
|
|
518
538
|
throw new KernelInvariantError([
|
|
519
539
|
`cannot complete while state is ${stateOf(record)}`,
|
|
520
540
|
]);
|
|
541
|
+
if (!Array.isArray(changedPaths))
|
|
542
|
+
throw new KernelInvariantError(["complete requires trusted changed paths"]);
|
|
521
543
|
const decision = completionDecision(
|
|
522
544
|
record.intent_snapshot,
|
|
523
545
|
record,
|
|
524
546
|
diffHash,
|
|
525
547
|
record.intent_ref.content_hash,
|
|
548
|
+
changedPaths,
|
|
526
549
|
);
|
|
527
550
|
if (!decision.complete)
|
|
528
551
|
throw new KernelInvariantError([
|
|
@@ -587,12 +610,12 @@ export function reduceTask(
|
|
|
587
610
|
return brandResult(record, nextWorkingFor(record));
|
|
588
611
|
}
|
|
589
612
|
|
|
590
|
-
function nextWorkingFor(record:
|
|
613
|
+
function nextWorkingFor(record: TaskRecord): string | null {
|
|
591
614
|
return record.lifecycle === "active" ? record.task_id : null;
|
|
592
615
|
}
|
|
593
616
|
|
|
594
617
|
function brandResult(
|
|
595
|
-
record:
|
|
618
|
+
record: TaskRecord,
|
|
596
619
|
nextWorking: string | null,
|
|
597
620
|
): ReducedTaskMutation {
|
|
598
621
|
const target = {} as ReducedTaskMutation;
|
|
@@ -33,12 +33,12 @@ import {
|
|
|
33
33
|
stateTransactionPath,
|
|
34
34
|
stateWorkspacePath,
|
|
35
35
|
} from "./storage_paths";
|
|
36
|
-
import {
|
|
36
|
+
import { parseTaskRecord, parseTaskRecordV2 } from "./validation";
|
|
37
37
|
import type {
|
|
38
38
|
TaskLifecycle,
|
|
39
39
|
TaskPhase,
|
|
40
40
|
TaskRecordV2,
|
|
41
|
-
|
|
41
|
+
TaskRecord,
|
|
42
42
|
StoredTaskMutationV3,
|
|
43
43
|
V3AuthorityObservation,
|
|
44
44
|
} from "./types";
|
|
@@ -649,7 +649,7 @@ function parseWorkspaceTransactionV2(raw: Record<string, unknown>): WorkspaceTra
|
|
|
649
649
|
...(artifactRelocations.length > 0 ? { artifact_relocations: artifactRelocations } : {}),
|
|
650
650
|
};
|
|
651
651
|
validateTaskId(transaction.task_id);
|
|
652
|
-
const record =
|
|
652
|
+
const record = parseTaskRecord(
|
|
653
653
|
JSON.parse(transaction.next_record_content),
|
|
654
654
|
);
|
|
655
655
|
if (record.task_id !== transaction.task_id)
|
|
@@ -736,7 +736,7 @@ function completeTransactionV2Locked(
|
|
|
736
736
|
transaction.expected_workspace_hash,
|
|
737
737
|
transaction.next_workspace_content,
|
|
738
738
|
);
|
|
739
|
-
const record =
|
|
739
|
+
const record = parseTaskRecord(
|
|
740
740
|
JSON.parse(transaction.next_record_content),
|
|
741
741
|
);
|
|
742
742
|
const workspace = parseWorkspaceContent(transaction.next_workspace_content);
|
|
@@ -791,7 +791,7 @@ function recoverAnyPendingTransactionLocked(root: string): void {
|
|
|
791
791
|
export function readTaskRecordRaw(
|
|
792
792
|
root: string,
|
|
793
793
|
taskId: string,
|
|
794
|
-
): { revision: string; record:
|
|
794
|
+
): { revision: string; record: TaskRecord | null } {
|
|
795
795
|
validateTaskId(taskId);
|
|
796
796
|
const relativePath = stateTaskRecordPath(taskId);
|
|
797
797
|
if (currentRevision(root, relativePath) === MISSING_REVISION)
|
|
@@ -802,7 +802,7 @@ export function readTaskRecordRaw(
|
|
|
802
802
|
throw new KernelStoreSecurityError(
|
|
803
803
|
"TaskRecord v2 is not supported in the state layout; v2 records belong to the historical audit layout",
|
|
804
804
|
);
|
|
805
|
-
const record =
|
|
805
|
+
const record = parseTaskRecord(raw);
|
|
806
806
|
if (record.task_id !== taskId)
|
|
807
807
|
throw new KernelStoreSecurityError("task record v3 identity is inconsistent");
|
|
808
808
|
return { revision: revisionFor(content), record };
|
|
@@ -821,7 +821,7 @@ export function readAuditTaskPair(
|
|
|
821
821
|
taskId: string,
|
|
822
822
|
): {
|
|
823
823
|
recordRevision: string;
|
|
824
|
-
record:
|
|
824
|
+
record: TaskRecord | TaskRecordV2;
|
|
825
825
|
proof: TaskTombstone;
|
|
826
826
|
} | null {
|
|
827
827
|
validateTaskId(taskId);
|
|
@@ -846,7 +846,7 @@ export function readAuditTaskPair(
|
|
|
846
846
|
"terminal audit proof does not match its task record",
|
|
847
847
|
);
|
|
848
848
|
const raw = JSON.parse(recordContent) as { contract?: unknown };
|
|
849
|
-
let record:
|
|
849
|
+
let record: TaskRecord | TaskRecordV2;
|
|
850
850
|
if (raw.contract === "assurance_kernel/task_record/v2") {
|
|
851
851
|
const legacy = parseTaskRecordV2(raw);
|
|
852
852
|
if (
|
|
@@ -858,7 +858,7 @@ export function readAuditTaskPair(
|
|
|
858
858
|
);
|
|
859
859
|
record = legacy;
|
|
860
860
|
} else {
|
|
861
|
-
const current =
|
|
861
|
+
const current = parseTaskRecord(raw);
|
|
862
862
|
if (
|
|
863
863
|
current.task_id !== taskId ||
|
|
864
864
|
(current.lifecycle !== "done" && current.lifecycle !== "stopped")
|
|
@@ -874,7 +874,7 @@ export function readAuditTaskPair(
|
|
|
874
874
|
export function readTaskRecord(
|
|
875
875
|
root: string,
|
|
876
876
|
taskId: string,
|
|
877
|
-
): { revision: string; record:
|
|
877
|
+
): { revision: string; record: TaskRecord | null } {
|
|
878
878
|
return withKernelStoreLock(root, () => readTaskRecordRaw(root, taskId));
|
|
879
879
|
}
|
|
880
880
|
|
|
@@ -883,7 +883,7 @@ export function commitTaskRecordLocked(
|
|
|
883
883
|
root: string,
|
|
884
884
|
taskId: string,
|
|
885
885
|
expectedRecordHash: string,
|
|
886
|
-
nextRecord:
|
|
886
|
+
nextRecord: TaskRecord,
|
|
887
887
|
expectedWorkspaceHash: string,
|
|
888
888
|
nextWorkspace: WorkspaceState,
|
|
889
889
|
artifactRelocations: ArtifactRelocationV1[] = [],
|
|
@@ -1358,7 +1358,7 @@ export function commitEnrollmentLocked(
|
|
|
1358
1358
|
);
|
|
1359
1359
|
}
|
|
1360
1360
|
return {
|
|
1361
|
-
record:
|
|
1361
|
+
record: parseTaskRecord(JSON.parse(transaction.next_record_content)),
|
|
1362
1362
|
workspace: parseWorkspaceContent(transaction.next_workspace_content),
|
|
1363
1363
|
};
|
|
1364
1364
|
}
|
|
@@ -1572,7 +1572,7 @@ function parseTerminalMarker(raw: Record<string, unknown>): TerminalMarker {
|
|
|
1572
1572
|
expected_claim_sha256: expectedClaim,
|
|
1573
1573
|
at: raw.at as string,
|
|
1574
1574
|
};
|
|
1575
|
-
const record =
|
|
1575
|
+
const record = parseTaskRecord(JSON.parse(marker.audit_record_content));
|
|
1576
1576
|
if (record.task_id !== marker.task_id)
|
|
1577
1577
|
throw new KernelStoreSecurityError("terminal marker task identity is inconsistent");
|
|
1578
1578
|
if (record.lifecycle !== "done" && record.lifecycle !== "stopped")
|
|
@@ -1709,7 +1709,7 @@ export function commitTerminalLocked(
|
|
|
1709
1709
|
taskId: string,
|
|
1710
1710
|
transaction: WorkspaceTransactionV2,
|
|
1711
1711
|
tombstone: TaskTombstone,
|
|
1712
|
-
): { record:
|
|
1712
|
+
): { record: TaskRecord; workspace: WorkspaceState } {
|
|
1713
1713
|
validateTaskId(taskId);
|
|
1714
1714
|
if (transaction.task_id !== taskId)
|
|
1715
1715
|
throw new KernelStoreSecurityError(
|
|
@@ -1728,7 +1728,7 @@ export function commitTerminalLocked(
|
|
|
1728
1728
|
throw new KernelStoreSecurityError(
|
|
1729
1729
|
"terminal proof must match the terminal record bytes",
|
|
1730
1730
|
);
|
|
1731
|
-
const terminalRecord =
|
|
1731
|
+
const terminalRecord = parseTaskRecord(
|
|
1732
1732
|
JSON.parse(transaction.next_record_content) as Record<string, unknown>,
|
|
1733
1733
|
);
|
|
1734
1734
|
if (tombstone.terminal_lifecycle !== terminalRecord.lifecycle)
|
|
@@ -1774,7 +1774,7 @@ export function commitTerminalLocked(
|
|
|
1774
1774
|
);
|
|
1775
1775
|
}
|
|
1776
1776
|
return {
|
|
1777
|
-
record:
|
|
1777
|
+
record: parseTaskRecord(JSON.parse(transaction.next_record_content)),
|
|
1778
1778
|
workspace: parseWorkspaceContent(transaction.next_workspace_content),
|
|
1779
1779
|
};
|
|
1780
1780
|
}
|
|
@@ -99,6 +99,9 @@ export interface V3AuthorityObservation {
|
|
|
99
99
|
export const TASK_INTENT_CONTRACT_V1 = "assurance_kernel/task_intent/v1" as const;
|
|
100
100
|
export const TASK_RECORD_CONTRACT_V2 = "assurance_kernel/task_record/v2" as const;
|
|
101
101
|
export const TASK_RECORD_CONTRACT_V3 = "assurance_kernel/task_record/v3" as const;
|
|
102
|
+
export const TASK_RECORD_CONTRACT_V4 = "assurance_kernel/task_record/v4" as const;
|
|
103
|
+
export const REVIEW_REVISION_IDENTITY_CONTRACT =
|
|
104
|
+
"assurance_kernel/review_revision_identity/v1" as const;
|
|
102
105
|
|
|
103
106
|
export interface TaskIntentAcceptanceItemV1 {
|
|
104
107
|
id: string;
|
|
@@ -148,6 +151,20 @@ export interface TaskApprovalV2 {
|
|
|
148
151
|
diff_hash: string;
|
|
149
152
|
actor_id: string;
|
|
150
153
|
summary: string;
|
|
154
|
+
/**
|
|
155
|
+
* Immutable Git revision identity the reviewer actually analyzed. Legal only
|
|
156
|
+
* on a review attestation of a TaskRecord v4; the strict parser rejects it
|
|
157
|
+
* anywhere else, so QA and user attestations never carry revision bytes.
|
|
158
|
+
*/
|
|
159
|
+
review_revision?: ReviewRevisionIdentityV1;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ReviewRevisionIdentityV1 {
|
|
163
|
+
contract: typeof REVIEW_REVISION_IDENTITY_CONTRACT;
|
|
164
|
+
base_head: string;
|
|
165
|
+
review_commit: string;
|
|
166
|
+
review_tree: string;
|
|
167
|
+
manifest_digest: string;
|
|
151
168
|
}
|
|
152
169
|
|
|
153
170
|
export interface TaskHistoryEntryV2 {
|
|
@@ -206,6 +223,19 @@ export interface TaskRecordV3 {
|
|
|
206
223
|
history: TaskHistoryEntryV3[];
|
|
207
224
|
}
|
|
208
225
|
|
|
226
|
+
/**
|
|
227
|
+
* TaskRecord v4 binds the Enrollment commit so Review, QA, authorization, and
|
|
228
|
+
* completion can all derive one scoped revision from base to final index tree.
|
|
229
|
+
* `git_base_head` is immutable from Enrollment through terminal settlement.
|
|
230
|
+
*/
|
|
231
|
+
export interface TaskRecordV4 extends Omit<TaskRecordV3, "contract"> {
|
|
232
|
+
contract: typeof TASK_RECORD_CONTRACT_V4;
|
|
233
|
+
git_base_head: string;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The record shape every Kernel owner passes around during the v3 drain window. */
|
|
237
|
+
export type TaskRecord = TaskRecordV3 | TaskRecordV4;
|
|
238
|
+
|
|
209
239
|
export interface TaskProjectionV3 extends CompletionDecision {
|
|
210
240
|
contract: "assurance_kernel/projection/v3";
|
|
211
241
|
task_id: string;
|
|
@@ -268,13 +298,13 @@ export const REDUCED_MUTATION_BRAND = Symbol("assurance-kernel-reduced-mutation-
|
|
|
268
298
|
|
|
269
299
|
export interface ReducedTaskMutation {
|
|
270
300
|
readonly [REDUCED_MUTATION_BRAND]: true;
|
|
271
|
-
readonly record:
|
|
301
|
+
readonly record: TaskRecord;
|
|
272
302
|
readonly next_workspace_working: string | null;
|
|
273
303
|
}
|
|
274
304
|
|
|
275
305
|
export interface StoredTaskMutationV3 {
|
|
276
306
|
revision: string;
|
|
277
|
-
record:
|
|
307
|
+
record: TaskRecord;
|
|
278
308
|
workspace: {
|
|
279
309
|
revision: string;
|
|
280
310
|
state: WorkspaceStateLike;
|