immune-brain 3.6.8 → 4.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 +11 -4
- package/README.zh-CN.md +10 -3
- package/package.json +3 -2
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +7581 -5042
- package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
- package/plugins/immune-brain/dist/imm-loop.md +27 -25
- package/plugins/immune-brain/dist/imm-planner.md +53 -32
- package/plugins/immune-brain/dist/imm-review-retro.md +123 -0
- package/plugins/immune-brain/dist/registry.yaml +9 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +3 -1
- package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
- package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
- package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
- package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
- package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +254 -29
- package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
- package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
- package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
- package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
- package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
- package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
- package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
- package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
- package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
- package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
- package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
- package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
- package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
- package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
- package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
- package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
- package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
- package/plugins/immune-brain/runtime/plan_core.ts +27 -65
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +3 -1
- package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
- package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
- package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
- package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
- package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
- package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
- package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
- package/plugins/immune-brain/skills/imm-review-retro/SKILL.md +23 -0
- package/plugins/immune-brain/skills/imm-review-retro/scripts/review_retro.ts +355 -0
- package/plugins/immune-brain/skills/registry.yaml +9 -0
- package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
- package/plugins/immune-brain/bin/imm-retired +0 -4
- package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
- package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
- package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
- package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
- package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
- package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// the dedicated recoverable workspace transaction. Consumed by the Pi canary
|
|
5
5
|
// host adapter via canary_application.
|
|
6
6
|
|
|
7
|
+
import { terminalRequestDigest } from "./run_identity";
|
|
7
8
|
import {
|
|
8
9
|
type MutationAuthorityRegistry,
|
|
9
|
-
type ValidatedAuthorityV2,
|
|
10
10
|
} from "./authority_port";
|
|
11
11
|
import { asTaskDiffSnapshot } from "./completion";
|
|
12
12
|
import { canonicalIntentHash, readTaskIntent } from "./intent";
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
commitTerminalLocked,
|
|
27
27
|
readTaskRecordRaw,
|
|
28
28
|
readWorkspaceStateRaw,
|
|
29
|
+
currentRunId,
|
|
29
30
|
revisionForContent,
|
|
30
31
|
serializeWorkspace,
|
|
31
32
|
withKernelStoreLock,
|
|
@@ -37,8 +38,6 @@ import type {
|
|
|
37
38
|
AuthorityAuditDescriptor,
|
|
38
39
|
MutationAuthorityCapabilityV2,
|
|
39
40
|
StoredTaskMutationV3,
|
|
40
|
-
TaskAction,
|
|
41
|
-
TaskIntentV1,
|
|
42
41
|
TaskRecord,
|
|
43
42
|
} from "./types";
|
|
44
43
|
import { TASK_TOMBSTONE_CONTRACT, type TaskTombstone } from "./backend_claim";
|
|
@@ -164,9 +163,15 @@ export function applyTaskAction(
|
|
|
164
163
|
action.type === "stop" ||
|
|
165
164
|
action.type === "resolve_user_decision";
|
|
166
165
|
|
|
166
|
+
// A capability is opaque: its binding lives in the issuing registry, so
|
|
167
|
+
// the run this worktree holds is read here and handed to the registry as
|
|
168
|
+
// the expected identity. Authority issued for another run is refused
|
|
169
|
+
// before any write, which is what keeps it inside the worktree that
|
|
170
|
+
// issued it even when task, record, intent and diff content are identical.
|
|
167
171
|
const expectedAuthority = privileged
|
|
168
172
|
? {
|
|
169
173
|
task_id,
|
|
174
|
+
run_id: currentRunId(root, task_id) ?? undefined,
|
|
170
175
|
action,
|
|
171
176
|
expected_record_hash: current.revision,
|
|
172
177
|
intent_revision: isRevisionAction
|
|
@@ -189,6 +194,7 @@ export function applyTaskAction(
|
|
|
189
194
|
const inspectedAudit = expectedAuthority
|
|
190
195
|
? registry.inspect(capability, expectedAuthority, now)
|
|
191
196
|
: null;
|
|
197
|
+
const authorityRunId = inspectedAudit?.run_id;
|
|
192
198
|
const mutation = reduceTask(
|
|
193
199
|
current.record,
|
|
194
200
|
action,
|
|
@@ -227,9 +233,8 @@ export function applyTaskAction(
|
|
|
227
233
|
// All preflight passed: consume tokens and authority, then commit.
|
|
228
234
|
consumeIntentToken(prior_intent_token);
|
|
229
235
|
consumeIntentToken(freshRead.token);
|
|
230
|
-
let consumedAudit: ValidatedAuthorityV2 | null = null;
|
|
231
236
|
if (expectedAuthority) {
|
|
232
|
-
|
|
237
|
+
registry.consume(
|
|
233
238
|
capability as MutationAuthorityCapabilityV2,
|
|
234
239
|
expectedAuthority,
|
|
235
240
|
now,
|
|
@@ -284,7 +289,17 @@ export function applyTaskAction(
|
|
|
284
289
|
next_workspace_content: serializeWorkspace(nextWorkspaceState),
|
|
285
290
|
...(input.artifact_transition ? { artifact_relocations: input.artifact_transition.relocations } : {}),
|
|
286
291
|
};
|
|
287
|
-
commitTerminalLocked(
|
|
292
|
+
commitTerminalLocked(
|
|
293
|
+
root,
|
|
294
|
+
task_id,
|
|
295
|
+
transaction,
|
|
296
|
+
tombstone,
|
|
297
|
+
authorityRunId,
|
|
298
|
+
// The request this settlement answers: a retry with a different
|
|
299
|
+
// reason, actor or event time is a different decision and must be
|
|
300
|
+
// authorized again rather than replayed.
|
|
301
|
+
terminalRequestDigest(action),
|
|
302
|
+
);
|
|
288
303
|
return {
|
|
289
304
|
revision: canonicalRecordHash(nextRecord),
|
|
290
305
|
record: nextRecord,
|
|
@@ -303,6 +318,7 @@ export function applyTaskAction(
|
|
|
303
318
|
workspace.revision,
|
|
304
319
|
nextWorkspaceState,
|
|
305
320
|
input.artifact_transition?.relocations,
|
|
321
|
+
authorityRunId,
|
|
306
322
|
);
|
|
307
323
|
});
|
|
308
324
|
}
|
|
@@ -12,9 +12,16 @@
|
|
|
12
12
|
// "start QA" or "start Review"; it returns Kernel-owned facts only.
|
|
13
13
|
|
|
14
14
|
import { readBackendClaim, readTaskTombstone } from "./backend_claim";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
readTaskRecord,
|
|
17
|
+
readAuditTaskPair,
|
|
18
|
+
readCommittedRecord,
|
|
19
|
+
readWorkspaceStateRaw,
|
|
20
|
+
reconcileKernelAuthority,
|
|
21
|
+
} from "./storage";
|
|
16
22
|
import { projectTask, resolveProjectedRisk } from "./completion";
|
|
17
|
-
import type { AssuranceObligation, TaskIntentV1, TaskRecord,
|
|
23
|
+
import type { AssuranceObligation, TaskIntentV1, TaskRecord, TaskRecordV3 } from "./types";
|
|
24
|
+
import type { TaskRecordV2 } from "./legacy_task_record";
|
|
18
25
|
|
|
19
26
|
export interface TaskDiffSnapshot {
|
|
20
27
|
diff_hash: string;
|
|
@@ -34,6 +41,12 @@ export interface AssuranceAuthorizationReadiness {
|
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
export interface AssuranceProjection {
|
|
44
|
+
/**
|
|
45
|
+
* The exact run this projection describes: one worktree owns one store and
|
|
46
|
+
* one active run, so the run id is the authority identity a capability must
|
|
47
|
+
* bind. Null when no run is committed.
|
|
48
|
+
*/
|
|
49
|
+
run_id: string | null;
|
|
37
50
|
record_revision: string;
|
|
38
51
|
workspace_revision: string;
|
|
39
52
|
intent_revision: number;
|
|
@@ -83,6 +96,7 @@ export function deriveAssuranceAuthorization(input: {
|
|
|
83
96
|
|
|
84
97
|
function emptyProjection(): AssuranceProjection {
|
|
85
98
|
return {
|
|
99
|
+
run_id: null,
|
|
86
100
|
record_revision: "",
|
|
87
101
|
workspace_revision: "",
|
|
88
102
|
intent_revision: 0,
|
|
@@ -128,6 +142,36 @@ function projectHistoricalTerminal(
|
|
|
128
142
|
};
|
|
129
143
|
}
|
|
130
144
|
|
|
145
|
+
function projectHistoricalTerminalV3(
|
|
146
|
+
record: TaskRecordV3,
|
|
147
|
+
recordRevision: string,
|
|
148
|
+
workspaceRevision: string,
|
|
149
|
+
): AssuranceProjection {
|
|
150
|
+
const freshAcceptanceIds = [
|
|
151
|
+
...new Set(
|
|
152
|
+
record.attestations
|
|
153
|
+
.filter((item) => item.kind === "qa")
|
|
154
|
+
.flatMap((item) => item.acceptance_results)
|
|
155
|
+
.filter((item) => item.status === "passed")
|
|
156
|
+
.map((item) => item.acceptance_id),
|
|
157
|
+
),
|
|
158
|
+
];
|
|
159
|
+
const approvalKinds = [...new Set(record.attestations.map((item) => item.kind))];
|
|
160
|
+
return {
|
|
161
|
+
...emptyProjection(),
|
|
162
|
+
record_revision: recordRevision,
|
|
163
|
+
workspace_revision: workspaceRevision,
|
|
164
|
+
intent_revision: record.intent_snapshot.revision,
|
|
165
|
+
intent_content_hash: record.intent_ref.content_hash,
|
|
166
|
+
lifecycle: record.lifecycle,
|
|
167
|
+
artifact_state: "frozen",
|
|
168
|
+
risk: record.intent_snapshot.risk,
|
|
169
|
+
fresh_acceptance_ids: freshAcceptanceIds,
|
|
170
|
+
fresh_approval_kinds: approvalKinds,
|
|
171
|
+
completion_ready: record.lifecycle === "done",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
131
175
|
function freshApprovalKinds(
|
|
132
176
|
record: TaskRecord,
|
|
133
177
|
currentIntentContentHash: string,
|
|
@@ -168,6 +212,8 @@ function projectFromRecord(
|
|
|
168
212
|
(finding) => finding.kind === "unresolved_user_decision" && finding.status === "open",
|
|
169
213
|
).length;
|
|
170
214
|
return {
|
|
215
|
+
// Filled by the caller from the committed run row.
|
|
216
|
+
run_id: null,
|
|
171
217
|
record_revision: recordRevision,
|
|
172
218
|
workspace_revision: workspaceRevision,
|
|
173
219
|
intent_revision: record.intent_snapshot.revision,
|
|
@@ -232,9 +278,19 @@ export async function projectAssurance(
|
|
|
232
278
|
const tombstone = readTaskTombstone(root, taskId);
|
|
233
279
|
if (tombstone) {
|
|
234
280
|
const authority = reconcileKernelAuthority(root, taskId);
|
|
235
|
-
|
|
281
|
+
// A terminal proof keyed by task id may have been exported by
|
|
282
|
+
// another worktree's run of this same task: it is that run's
|
|
283
|
+
// evidence, not a conflict with this worktree's own run. The local
|
|
284
|
+
// store decides, and an active local run keeps its authority.
|
|
285
|
+
if (authority.state === "active_owner") {
|
|
286
|
+
// fall through: the local run owns this task here
|
|
287
|
+
} else if (authority.state === "repairable_stale_claim")
|
|
236
288
|
return fail(`task ${taskId} has a repairable stale backend claim`, claim);
|
|
237
|
-
|
|
289
|
+
else
|
|
290
|
+
return fail(
|
|
291
|
+
authority.diagnostic ?? `authority state conflicts for ${taskId}`,
|
|
292
|
+
claim,
|
|
293
|
+
);
|
|
238
294
|
}
|
|
239
295
|
} else {
|
|
240
296
|
const authority = reconcileKernelAuthority(root, taskId);
|
|
@@ -254,6 +310,28 @@ export async function projectAssurance(
|
|
|
254
310
|
}
|
|
255
311
|
if (!read.record) {
|
|
256
312
|
if (!terminalOwner) return fail(`task ${taskId} has no TaskRecord v3`, claim);
|
|
313
|
+
const committedRunId = reconcileKernelAuthority(root, taskId).owner_run_id;
|
|
314
|
+
// The store keeps the terminal record itself, so a settled task projects
|
|
315
|
+
// from committed facts even while its audit export is still in flight.
|
|
316
|
+
const committed = await readCommittedRecord(root, taskId);
|
|
317
|
+
if (committed) {
|
|
318
|
+
const workspace = await readWorkspaceStateRaw(root);
|
|
319
|
+
return {
|
|
320
|
+
contract: "assurance_kernel/assurance_projection/v1",
|
|
321
|
+
task_id: taskId,
|
|
322
|
+
error: null,
|
|
323
|
+
claim: null,
|
|
324
|
+
projection: {
|
|
325
|
+
...projectFromRecord(
|
|
326
|
+
committed.record,
|
|
327
|
+
committed.revision,
|
|
328
|
+
workspace.revision,
|
|
329
|
+
diffProvider(root, committed.record),
|
|
330
|
+
),
|
|
331
|
+
run_id: committedRunId,
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
257
335
|
const auditPair = await readAuditTaskPair(root, taskId);
|
|
258
336
|
if (!auditPair) return fail(`task ${taskId} has no terminal audit pair`, claim);
|
|
259
337
|
const workspace = await readWorkspaceStateRaw(root);
|
|
@@ -265,6 +343,14 @@ export async function projectAssurance(
|
|
|
265
343
|
claim: null,
|
|
266
344
|
projection: projectHistoricalTerminal(auditPair.record, auditPair.recordRevision, workspace.revision),
|
|
267
345
|
};
|
|
346
|
+
if (auditPair.record.contract === "assurance_kernel/task_record/v3")
|
|
347
|
+
return {
|
|
348
|
+
contract: "assurance_kernel/assurance_projection/v1",
|
|
349
|
+
task_id: taskId,
|
|
350
|
+
error: null,
|
|
351
|
+
claim: null,
|
|
352
|
+
projection: projectHistoricalTerminalV3(auditPair.record, auditPair.recordRevision, workspace.revision),
|
|
353
|
+
};
|
|
268
354
|
return {
|
|
269
355
|
contract: "assurance_kernel/assurance_projection/v1",
|
|
270
356
|
task_id: taskId,
|
|
@@ -284,7 +370,10 @@ export async function projectAssurance(
|
|
|
284
370
|
task_id: taskId,
|
|
285
371
|
error: null,
|
|
286
372
|
claim,
|
|
287
|
-
projection:
|
|
373
|
+
projection: {
|
|
374
|
+
...projectFromRecord(read.record, read.revision, workspace.revision, snapshot),
|
|
375
|
+
run_id: reconcileKernelAuthority(root, taskId).owner_run_id,
|
|
376
|
+
},
|
|
288
377
|
};
|
|
289
378
|
} catch (error) {
|
|
290
379
|
return fail(error instanceof Error ? error.message : String(error));
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// remain here.
|
|
5
5
|
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
|
+
import { canonicalActorId } from "./actor_identity";
|
|
7
8
|
|
|
8
9
|
import {
|
|
9
10
|
MUTATION_AUTHORITY_CAPABILITY_BRAND,
|
|
@@ -13,18 +14,23 @@ import {
|
|
|
13
14
|
type TaskAction,
|
|
14
15
|
} from "./types";
|
|
15
16
|
import { createCapabilityRegistry } from "./capability_registry";
|
|
17
|
+
import type { BaseCapabilityBinding } from "./enrollment_authority";
|
|
16
18
|
|
|
17
|
-
export interface CapabilityBindingV2 {
|
|
19
|
+
export interface CapabilityBindingV2 extends BaseCapabilityBinding {
|
|
18
20
|
authority_kind: MutationAuthorityKind;
|
|
19
21
|
task_id: string;
|
|
22
|
+
/**
|
|
23
|
+
* The exact run this authority was issued for. Two worktrees may each run
|
|
24
|
+
* the same logical task with identical record, intent and diff content but
|
|
25
|
+
* different run identities, so the task and content bindings alone cannot
|
|
26
|
+
* tell them apart.
|
|
27
|
+
*/
|
|
28
|
+
run_id?: string;
|
|
20
29
|
action_digest: string;
|
|
21
30
|
expected_record_hash: string;
|
|
22
31
|
intent_revision: number;
|
|
23
32
|
intent_content_hash: string;
|
|
24
33
|
diff_hash: string;
|
|
25
|
-
actor_id: string;
|
|
26
|
-
confirmation_ref: string;
|
|
27
|
-
expires_at: string;
|
|
28
34
|
/** Normalized findings digest required for request_rework; null for other operations. */
|
|
29
35
|
findings_digest: string | null;
|
|
30
36
|
}
|
|
@@ -32,6 +38,7 @@ export interface CapabilityBindingV2 {
|
|
|
32
38
|
export interface ValidatedAuthorityV2 {
|
|
33
39
|
audit: AuthorityAuditDescriptor;
|
|
34
40
|
action_digest: string;
|
|
41
|
+
run_id?: string;
|
|
35
42
|
expected_record_hash: string;
|
|
36
43
|
intent_revision: number;
|
|
37
44
|
intent_content_hash: string;
|
|
@@ -41,6 +48,7 @@ export interface ValidatedAuthorityV2 {
|
|
|
41
48
|
|
|
42
49
|
export interface MutationAuthorityInspection {
|
|
43
50
|
task_id: string;
|
|
51
|
+
run_id?: string;
|
|
44
52
|
action: TaskAction;
|
|
45
53
|
expected_record_hash: string;
|
|
46
54
|
intent_revision: number;
|
|
@@ -82,8 +90,10 @@ export function createMutationAuthorityRegistry(): MutationAuthorityRegistry {
|
|
|
82
90
|
{
|
|
83
91
|
validateBinding(binding, issuedAt) {
|
|
84
92
|
const missing: string[] = [];
|
|
93
|
+
if (binding.run_id !== undefined && binding.run_id.length === 0)
|
|
94
|
+
throw new Error("authority capability run_id must not be empty");
|
|
85
95
|
for (const [key, value] of Object.entries(binding)) {
|
|
86
|
-
if (key === "findings_digest") continue;
|
|
96
|
+
if (key === "findings_digest" || key === "run_id") continue;
|
|
87
97
|
if (value === undefined || value === null || value === "") missing.push(key);
|
|
88
98
|
}
|
|
89
99
|
if (missing.length > 0)
|
|
@@ -101,6 +111,12 @@ export function createMutationAuthorityRegistry(): MutationAuthorityRegistry {
|
|
|
101
111
|
throw new Error("authority capability action digest mismatch");
|
|
102
112
|
if (state.task_id !== expected.task_id)
|
|
103
113
|
throw new Error("authority capability task mismatch");
|
|
114
|
+
if (
|
|
115
|
+
state.run_id !== undefined &&
|
|
116
|
+
expected.run_id !== undefined &&
|
|
117
|
+
state.run_id !== expected.run_id
|
|
118
|
+
)
|
|
119
|
+
throw new Error("authority capability run mismatch");
|
|
104
120
|
if (state.expected_record_hash !== expected.expected_record_hash)
|
|
105
121
|
throw new Error("authority capability record hash mismatch");
|
|
106
122
|
if (state.intent_revision !== expected.intent_revision)
|
|
@@ -118,12 +134,17 @@ export function createMutationAuthorityRegistry(): MutationAuthorityRegistry {
|
|
|
118
134
|
return {
|
|
119
135
|
audit: {
|
|
120
136
|
authority_kind: state.authority_kind,
|
|
121
|
-
|
|
137
|
+
// One recorded spelling: this projection is the single point where
|
|
138
|
+
// an authority's actor becomes the history entry's audit identity,
|
|
139
|
+
// so a Host that still supplies the historical `user` spelling is
|
|
140
|
+
// recorded as the canonical literal user.
|
|
141
|
+
actor_id: canonicalActorId(state.actor_id),
|
|
122
142
|
confirmation_ref: state.confirmation_ref,
|
|
123
143
|
issued_at: state.issued_at,
|
|
124
144
|
expires_at: state.expires_at,
|
|
125
145
|
},
|
|
126
146
|
action_digest: actionDigest,
|
|
147
|
+
...(state.run_id !== undefined ? { run_id: state.run_id } : {}),
|
|
127
148
|
expected_record_hash: state.expected_record_hash,
|
|
128
149
|
intent_revision: state.intent_revision,
|
|
129
150
|
intent_content_hash: state.intent_content_hash,
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
// P2B2 backend claim ownership. NOT exported from kernel/index.ts.
|
|
2
|
-
// The
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// The workspace-active claim is DERIVED from the single active run in the
|
|
3
|
+
// worktree authority store (`.imm/state/kernel.sqlite`); terminal state lives
|
|
4
|
+
// exclusively in the immutable tracked audit proof
|
|
5
5
|
// `.imm/audit/<task-id>/terminal-proof.json`. No writer is exported from this
|
|
6
|
-
// module: every claim change goes through the
|
|
7
|
-
//
|
|
6
|
+
// module: every claim change goes through the Kernel store transactions owned
|
|
7
|
+
// by storage.ts (enrollment, drain, terminalization).
|
|
8
8
|
|
|
9
9
|
import { lstatSync, readFileSync } from "node:fs";
|
|
10
10
|
import { join, resolve } from "node:path";
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import { auditEvidencePaths, auditRunTerminalProofPath } from "./storage_paths";
|
|
13
|
+
import {
|
|
14
|
+
activeRunId,
|
|
15
|
+
readRunRowById,
|
|
16
|
+
readRunRowByTask,
|
|
17
|
+
withKernelRead,
|
|
18
|
+
type KernelRunRow,
|
|
19
|
+
} from "./sqlite_store";
|
|
13
20
|
import type { TaskLifecycle } from "./types";
|
|
14
21
|
|
|
15
|
-
const CLAIM_PATH = stateClaimPath();
|
|
16
|
-
|
|
17
22
|
export type BackendLifecycleStatus = "active" | "draining";
|
|
18
23
|
|
|
19
24
|
export interface BackendClaim {
|
|
@@ -114,9 +119,28 @@ export function parseBackendClaim(raw: Record<string, unknown>): BackendClaim {
|
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
export function readBackendClaim(root: string): BackendClaim | null {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
return (
|
|
123
|
+
withKernelRead(root, (db) => {
|
|
124
|
+
const run = readRunRowById(db, activeRunId(db) ?? "");
|
|
125
|
+
if (!run || run.state !== "active" || run.claim_status === null) return null;
|
|
126
|
+
return claimFromRunRow(run);
|
|
127
|
+
}) ?? null
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Project the workspace claim from the committed run row that owns it. */
|
|
132
|
+
export function claimFromRunRow(run: KernelRunRow): BackendClaim {
|
|
133
|
+
return parseBackendClaim({
|
|
134
|
+
contract: "assurance_kernel/backend_claim/v2",
|
|
135
|
+
backend: "kernel",
|
|
136
|
+
task_id: run.task_id,
|
|
137
|
+
intent_revision: run.intent_revision,
|
|
138
|
+
intent_content_hash: run.intent_content_hash,
|
|
139
|
+
enrollment_event_id: run.enrollment_event_id,
|
|
140
|
+
lifecycle_status: run.claim_status ?? "active",
|
|
141
|
+
created_at: run.created_at,
|
|
142
|
+
updated_at: run.updated_at,
|
|
143
|
+
});
|
|
120
144
|
}
|
|
121
145
|
|
|
122
146
|
/** Serialize a validated claim to the canonical file bytes. Transaction-only writer consumers. */
|
|
@@ -163,7 +187,14 @@ export function parseTaskTombstone(raw: Record<string, unknown>): TaskTombstone
|
|
|
163
187
|
/** Fail-closed task-scoped tombstone read. Malformed/unreadable/symlinked state throws; only ENOENT means absent. */
|
|
164
188
|
export function readTaskTombstone(root: string, taskId: string): TaskTombstone | null {
|
|
165
189
|
validateTaskId(taskId);
|
|
166
|
-
|
|
190
|
+
// A local run's proof is that run's evidence. Directory counting cannot
|
|
191
|
+
// choose among several run directories, and a foreign run must not be
|
|
192
|
+
// adopted while this worktree has its own run.
|
|
193
|
+
const localRun = withKernelRead(root, (db) => readRunRowByTask(db, taskId));
|
|
194
|
+
const proofPath = localRun
|
|
195
|
+
? auditRunTerminalProofPath(taskId, localRun.run_id)
|
|
196
|
+
: auditEvidencePaths(root, taskId).proof;
|
|
197
|
+
const raw = readJsonOrNull(join(resolve(root), proofPath));
|
|
167
198
|
if (!raw) return null;
|
|
168
199
|
const tombstone = parseTaskTombstone(raw);
|
|
169
200
|
if (tombstone.task_id !== taskId)
|
|
@@ -191,7 +222,3 @@ export function assertNoKernelBackendForV3(root: string, _commandTask?: string):
|
|
|
191
222
|
`backend-owned workspace: task ${claim.task_id} is managed by the Kernel backend (${claim.lifecycle_status}); v3 managed mutation is rejected`,
|
|
192
223
|
);
|
|
193
224
|
}
|
|
194
|
-
|
|
195
|
-
export function backendClaimPath(root: string): string {
|
|
196
|
-
return join(resolve(root), stateClaimPath());
|
|
197
|
-
}
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { createCapabilityRegistry } from "./capability_registry";
|
|
12
|
-
import
|
|
12
|
+
import { isLiteralUserActor } from "./actor_identity";
|
|
13
|
+
import type { BaseCapabilityBinding, EnrollmentCapabilityBinding } from "./enrollment_authority";
|
|
13
14
|
import { preparePiCanary, type PiCanaryPreparation } from "./pi_canary_prepare";
|
|
14
15
|
|
|
15
16
|
export const BATCH_AUTHORITY_CAPABILITY_BRAND = Symbol.for(
|
|
@@ -42,16 +43,13 @@ export interface BatchBudget {
|
|
|
42
43
|
qa_failure_limit: number;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
export interface BatchAuthorizationBinding {
|
|
46
|
+
export interface BatchAuthorizationBinding extends BaseCapabilityBinding {
|
|
46
47
|
batch_id: string;
|
|
47
48
|
initiative_slug: string;
|
|
48
49
|
plan_digest: string;
|
|
49
50
|
branch: string;
|
|
50
51
|
base_head: string;
|
|
51
52
|
budget: BatchBudget;
|
|
52
|
-
actor_id: string;
|
|
53
|
-
confirmation_ref: string;
|
|
54
|
-
expires_at: string;
|
|
55
53
|
nonce: string;
|
|
56
54
|
}
|
|
57
55
|
|
|
@@ -214,7 +212,10 @@ export function createBatchAuthorityRegistry(): BatchAuthorityRegistry {
|
|
|
214
212
|
{
|
|
215
213
|
validateBinding(binding, issuedAt) {
|
|
216
214
|
requireNonEmpty(binding);
|
|
217
|
-
|
|
215
|
+
// Both the canonical literal-user spelling and the historical `user`
|
|
216
|
+
// spelling are read, so a batch authorized before the convergence keeps
|
|
217
|
+
// validating; new bindings are written canonically.
|
|
218
|
+
if (!isLiteralUserActor(binding.actor_id))
|
|
218
219
|
throw new Error("batch authorization requires a literal-user actor_id");
|
|
219
220
|
if (!GIT_COMMIT_ID.test(binding.base_head))
|
|
220
221
|
throw new Error("batch authorization base_head must be a committed 40-hex commit id");
|
|
@@ -256,6 +257,9 @@ export function createBatchAuthorityRegistry(): BatchAuthorityRegistry {
|
|
|
256
257
|
branch: state.branch,
|
|
257
258
|
base_head: state.base_head,
|
|
258
259
|
budget: { ...state.budget },
|
|
260
|
+
// Kept faithful to the stored state: a batch authorized before the
|
|
261
|
+
// convergence resumes with the spelling it was issued under, and the
|
|
262
|
+
// recorded audit identity is canonicalized once, where it is written.
|
|
259
263
|
actor_id: state.actor_id,
|
|
260
264
|
confirmation_ref: state.confirmation_ref,
|
|
261
265
|
issued_at: state.issued_at,
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// once inside the Pi lifecycle extension activation closure; tests use
|
|
9
9
|
// tests/fixtures/mutation-authority-test-seam.ts.
|
|
10
10
|
|
|
11
|
+
import { terminalRequestDigest } from "./run_identity";
|
|
11
12
|
import {
|
|
12
13
|
type MutationAuthorityRegistry,
|
|
13
14
|
} from "./authority_port";
|
|
@@ -24,13 +25,15 @@ import {
|
|
|
24
25
|
type BackendClaim,
|
|
25
26
|
} from "./backend_claim";
|
|
26
27
|
import { canonicalIntentHash } from "./intent";
|
|
28
|
+
import { boundSpecPath, readBoundActiveSpec } from "./spec_binding";
|
|
27
29
|
import { parseTaskRecord } from "./validation";
|
|
28
30
|
import type { TaskIntentIdentityToken } from "./intent_token_registry";
|
|
29
31
|
import {
|
|
30
32
|
commitDrainLocked,
|
|
31
33
|
commitTaskRecordLocked,
|
|
32
|
-
readSecureProjectFile,
|
|
33
34
|
readTaskRecordRaw,
|
|
35
|
+
currentRunId,
|
|
36
|
+
readCommittedTerminalResult,
|
|
34
37
|
readWorkspaceStateRaw,
|
|
35
38
|
revisionForContent,
|
|
36
39
|
withKernelStoreLock,
|
|
@@ -167,69 +170,29 @@ export function capabilityActionFor(input: {
|
|
|
167
170
|
}
|
|
168
171
|
}
|
|
169
172
|
|
|
170
|
-
function archivePath(path: string): string {
|
|
171
|
-
const matched = path.match(/^docs\/(plans|specs)\/([^/]+)$/);
|
|
172
|
-
if (!matched) throw new KernelInvariantError([`artifact path is not active: ${path}`]);
|
|
173
|
-
return `docs/${matched[1]}/archive/${matched[2]}`;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function boundSpecPath(intent: TaskIntentV1): string | undefined {
|
|
177
|
-
const candidates = intent.scope_hint.filter(
|
|
178
|
-
(path) => /^docs\/specs\/(?!archive\/)[^/]+\.spec\.md$/.test(path) && intent.scope_hint.includes(archivePath(path)),
|
|
179
|
-
);
|
|
180
|
-
if (candidates.length > 1)
|
|
181
|
-
throw new KernelInvariantError([`artifact transition requires at most one scope-bound Spec; found ${candidates.length}`]);
|
|
182
|
-
return candidates[0];
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function readBoundActiveSpec(
|
|
186
|
-
root: string,
|
|
187
|
-
intent: TaskIntentV1,
|
|
188
|
-
required = true,
|
|
189
|
-
): { path: string; content: string } | undefined {
|
|
190
|
-
const specPath = boundSpecPath(intent);
|
|
191
|
-
if (!specPath) {
|
|
192
|
-
if (required) throw new KernelInvariantError(["artifact freeze requires one scope-bound active Spec"]);
|
|
193
|
-
return undefined;
|
|
194
|
-
}
|
|
195
|
-
try {
|
|
196
|
-
return { path: specPath, content: readSecureProjectFile(root, specPath) };
|
|
197
|
-
} catch (error) {
|
|
198
|
-
if (error instanceof Error && error.message.startsWith("source_missing:") && !required) return undefined;
|
|
199
|
-
throw error;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
173
|
function transitionFor(
|
|
204
174
|
root: string,
|
|
205
175
|
record: { intent_ref: { path: string }; intent_snapshot: TaskIntentV1; artifact_state: "active" | "frozen" },
|
|
206
176
|
direction: "freeze" | "restore",
|
|
207
|
-
|
|
177
|
+
allowMissingSpec = false,
|
|
208
178
|
) {
|
|
209
179
|
const activeIntent = `docs/plans/${record.intent_snapshot.task_id}.intent.json`;
|
|
210
|
-
const frozenIntent = archivePath(activeIntent);
|
|
211
180
|
if (direction === "freeze") {
|
|
212
181
|
if (record.intent_ref.path !== activeIntent)
|
|
213
182
|
throw new KernelInvariantError(["artifact freeze requires the active intent path"]);
|
|
214
|
-
const spec = readBoundActiveSpec(root, record.intent_snapshot
|
|
215
|
-
|
|
183
|
+
const spec = readBoundActiveSpec(root, record.intent_snapshot);
|
|
184
|
+
if (boundSpecPath(record.intent_snapshot) && !spec && !allowMissingSpec)
|
|
185
|
+
throw new KernelInvariantError([`source_missing: ${boundSpecPath(record.intent_snapshot)}`]);
|
|
216
186
|
return {
|
|
217
|
-
relocations: [
|
|
218
|
-
|
|
219
|
-
...(spec ? [{ from_path: spec.path, to_path: archivePath(spec.path), content_hash: revisionForContent(spec.content) }] : []),
|
|
220
|
-
],
|
|
221
|
-
next_intent_path: frozenIntent,
|
|
187
|
+
relocations: [] as { from_path: string; to_path: string; content_hash: string }[],
|
|
188
|
+
next_intent_path: activeIntent,
|
|
222
189
|
next_artifact_state: "frozen" as const,
|
|
223
190
|
};
|
|
224
191
|
}
|
|
225
|
-
if (record.
|
|
192
|
+
if (record.artifact_state !== "frozen")
|
|
226
193
|
throw new KernelInvariantError(["artifact restore requires a frozen TaskRecord"]);
|
|
227
|
-
const specPath = boundSpecPath(record.intent_snapshot);
|
|
228
194
|
return {
|
|
229
|
-
relocations: [
|
|
230
|
-
{ from_path: frozenIntent, to_path: activeIntent, content_hash: revisionForContent(readSecureProjectFile(root, frozenIntent)) },
|
|
231
|
-
...(specPath ? [{ from_path: archivePath(specPath), to_path: specPath, content_hash: revisionForContent(readSecureProjectFile(root, archivePath(specPath))) }] : []),
|
|
232
|
-
],
|
|
195
|
+
relocations: [] as { from_path: string; to_path: string; content_hash: string }[],
|
|
233
196
|
next_intent_path: activeIntent,
|
|
234
197
|
next_artifact_state: "active" as const,
|
|
235
198
|
};
|
|
@@ -279,7 +242,7 @@ export function createCanaryApplication(
|
|
|
279
242
|
type: "freeze_artifacts",
|
|
280
243
|
from_state: "active:active",
|
|
281
244
|
to_state: "active:frozen",
|
|
282
|
-
reason: `TaskIntent
|
|
245
|
+
reason: `TaskIntent frozen in place at ${transition.next_intent_path}`,
|
|
283
246
|
},
|
|
284
247
|
],
|
|
285
248
|
});
|
|
@@ -302,6 +265,35 @@ export function createCanaryApplication(
|
|
|
302
265
|
const now = input.now ?? new Date().toISOString();
|
|
303
266
|
const operation = input.operation;
|
|
304
267
|
const at = now;
|
|
268
|
+
// A settled run clears the active record, so a lost settlement response can
|
|
269
|
+
// only be answered from the committed operation — and only with the
|
|
270
|
+
// committed result. This runs before the record, token and capability
|
|
271
|
+
// checks, which a terminal run can no longer satisfy.
|
|
272
|
+
if (operation.op === "complete" || operation.op === "stop") {
|
|
273
|
+
const replayed = readCommittedTerminalResult(
|
|
274
|
+
input.root,
|
|
275
|
+
input.task_id,
|
|
276
|
+
`${operation.op}:${input.task_id}:${at}`,
|
|
277
|
+
terminalRequestDigest({
|
|
278
|
+
type: operation.op,
|
|
279
|
+
event_id: `${operation.op}:${input.task_id}:${at}`,
|
|
280
|
+
at,
|
|
281
|
+
actor_id: operation.actor_id,
|
|
282
|
+
reason: "reason" in operation ? operation.reason : undefined,
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
if (replayed)
|
|
286
|
+
return {
|
|
287
|
+
// The committed result is the answer: same record identity, same
|
|
288
|
+
// cleared workspace, no second write.
|
|
289
|
+
revision: revisionForContent(`${JSON.stringify(replayed.record, null, 2)}\n`),
|
|
290
|
+
record: replayed.record,
|
|
291
|
+
workspace: {
|
|
292
|
+
revision: revisionForContent(`${JSON.stringify(replayed.workspace, null, 2)}\n`),
|
|
293
|
+
state: replayed.workspace,
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
305
297
|
// Preflight snapshot inside the store lock derives the exact current
|
|
306
298
|
// identities; authoritative CAS revalidation still happens inside the
|
|
307
299
|
// locked application port, so any concurrent change fails closed.
|
|
@@ -324,10 +316,7 @@ export function createCanaryApplication(
|
|
|
324
316
|
const diffHash = asTaskDiffSnapshot(input.diffProvider(input.root, snapshot.record)).diff_hash;
|
|
325
317
|
if (operation.op === "stop" && !("capability" in operation))
|
|
326
318
|
throw new KernelInvariantError(["stop requires user authority capability"]);
|
|
327
|
-
const hasBoundSpec = snapshot.intent_snapshot
|
|
328
|
-
(path) => /^docs\/specs\/(?!archive\/)[^/]+\.spec\.md$/.test(path)
|
|
329
|
-
&& snapshot.intent_snapshot.scope_hint.includes(archivePath(path)),
|
|
330
|
-
);
|
|
319
|
+
const hasBoundSpec = boundSpecPath(snapshot.intent_snapshot) !== undefined;
|
|
331
320
|
if (operation.op === "complete" && hasBoundSpec && snapshot.record.artifact_state !== "frozen")
|
|
332
321
|
throw new KernelInvariantError(["complete requires frozen planning artifacts"]);
|
|
333
322
|
const artifactTransition =
|
|
@@ -390,13 +379,7 @@ export function createCanaryApplication(
|
|
|
390
379
|
type: "revise_intent",
|
|
391
380
|
next_intent: operation.next_intent,
|
|
392
381
|
next_intent_ref: {
|
|
393
|
-
|
|
394
|
-
// while frozen must keep the archived sidecar path that
|
|
395
|
-
// record validation enforces (active path otherwise).
|
|
396
|
-
path:
|
|
397
|
-
snapshot.record.artifact_state === "frozen"
|
|
398
|
-
? `docs/plans/archive/${operation.next_intent.task_id}.intent.json`
|
|
399
|
-
: `docs/plans/${operation.next_intent.task_id}.intent.json`,
|
|
382
|
+
path: `docs/plans/${operation.next_intent.task_id}.intent.json`,
|
|
400
383
|
content_hash: canonicalIntentHash(operation.next_intent),
|
|
401
384
|
},
|
|
402
385
|
};
|
|
@@ -460,6 +443,7 @@ export function createCanaryApplication(
|
|
|
460
443
|
root: string;
|
|
461
444
|
task_id: string;
|
|
462
445
|
capability: object;
|
|
446
|
+
run_id?: string | null;
|
|
463
447
|
now?: string;
|
|
464
448
|
}): BackendClaim {
|
|
465
449
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(input.task_id))
|
|
@@ -498,9 +482,11 @@ export function createCanaryApplication(
|
|
|
498
482
|
throw new KernelInvariantError([
|
|
499
483
|
`workspace is not owned by task ${input.task_id}`,
|
|
500
484
|
]);
|
|
501
|
-
// The capability is bound to the canonical begin_drain action
|
|
502
|
-
|
|
485
|
+
// The capability is bound to the canonical begin_drain action *and* to
|
|
486
|
+
// the run this worktree holds.
|
|
487
|
+
const validated = registry.consume(input.capability as never, {
|
|
503
488
|
task_id: input.task_id,
|
|
489
|
+
run_id: currentRunId(input.root, input.task_id) ?? undefined,
|
|
504
490
|
action: beginDrainCapabilityAction(input.task_id, now),
|
|
505
491
|
expected_record_hash: current.revision,
|
|
506
492
|
intent_revision: current.record.intent_snapshot.revision,
|
|
@@ -518,6 +504,7 @@ export function createCanaryApplication(
|
|
|
518
504
|
serializeBackendClaim(claim),
|
|
519
505
|
serializeBackendClaim(nextClaim),
|
|
520
506
|
now,
|
|
507
|
+
validated.run_id,
|
|
521
508
|
);
|
|
522
509
|
});
|
|
523
510
|
}
|