immune-brain 4.6.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 +154 -37
- 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 -44
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +142 -474
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +54 -84
- 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 +7369 -7016
- 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 +23 -8
- 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} +33 -7
- 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 +83 -67
- 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/verdict_authority.ts +347 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +62 -369
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +2 -0
- package/plugins/immune-brain/runtime/claude/review_host.ts +48 -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/kernel/application.ts +6 -3
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +15 -8
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +5 -3
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +149 -5
- 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/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 +57 -27
- package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +106 -0
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +12 -14
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +34 -29
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +26 -90
- package/plugins/immune-brain/runtime/workspace_scope.ts +34 -0
- 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 -581
|
@@ -149,9 +149,9 @@ export type AsyncAgentLaunch = { agentId: string; outputFile: string };
|
|
|
149
149
|
/**
|
|
150
150
|
* Recognise the launch receipt this Claude Code build returns for `Agent`.
|
|
151
151
|
*
|
|
152
|
-
* Every `Agent` call here runs asynchronously
|
|
153
|
-
*
|
|
154
|
-
* tool result is therefore
|
|
152
|
+
* Every `Agent` call here runs asynchronously: this Host has no synchronous
|
|
153
|
+
* mode and no background switch, so the dispatch envelope carries nothing that
|
|
154
|
+
* would ask for one. The tool result is therefore
|
|
155
155
|
* `{"isAsync":true,"status":"async_launched","agentId":…,"outputFile":…}`:
|
|
156
156
|
* proof that a subagent started, never its answer. Treating it as the verdict
|
|
157
157
|
* would settle Review on a receipt for starting the reviewer, so the launch
|
|
@@ -277,6 +277,12 @@ export class FileHookEventLog implements HookEventLog {
|
|
|
277
277
|
|
|
278
278
|
interface PendingReview {
|
|
279
279
|
request: ReviewRequest;
|
|
280
|
+
/**
|
|
281
|
+
* The prompt the Host asked the Parent to dispatch, marker line included.
|
|
282
|
+
* Hook evidence carries Parent-authored text, so this is the only string a
|
|
283
|
+
* dispatched prompt is ever compared against.
|
|
284
|
+
*/
|
|
285
|
+
dispatchPrompt: string;
|
|
280
286
|
initialCursors: Map<string, number>;
|
|
281
287
|
sessionCursors: Map<string, number>;
|
|
282
288
|
startEvent?: Extract<ClaudeHookEvent, { type: "SubagentStart" }>;
|
|
@@ -286,18 +292,39 @@ interface PendingReview {
|
|
|
286
292
|
error?: string;
|
|
287
293
|
}
|
|
288
294
|
|
|
295
|
+
/**
|
|
296
|
+
* The reserved dispatch prompt: the reservation marker line followed by the
|
|
297
|
+
* per-dispatch snapshot facts, byte for byte.
|
|
298
|
+
*
|
|
299
|
+
* One place builds it and one place compares against it, so the marker format
|
|
300
|
+
* cannot drift from what the binding rule accepts. The static instructions are
|
|
301
|
+
* deliberately absent: the dispatched reviewer definition carries them, and they
|
|
302
|
+
* are generated from one source by the plugin build.
|
|
303
|
+
*/
|
|
304
|
+
function reservedDispatchPrompt(
|
|
305
|
+
request: Pick<ReviewRequest, "operationId" | "taskId" | "snapshotPrompt">,
|
|
306
|
+
): string {
|
|
307
|
+
return `<!-- immune-brain:operation_id=${request.operationId} task_id=${request.taskId} -->\n${request.snapshotPrompt}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Whether hook evidence carrying a prompt belongs to this reservation.
|
|
312
|
+
*
|
|
313
|
+
* The prompt is Parent-authored text, so the marker is not an acceptance path:
|
|
314
|
+
* a dispatch that keeps the matching `operation_id` and `task_id` marker but
|
|
315
|
+
* appends, truncates or rewrites the body does not bind and cannot settle. Only
|
|
316
|
+
* the exact reserved dispatch prompt does. Evidence with no prompt at all is
|
|
317
|
+
* judged by the identifiers the hook itself supplies.
|
|
318
|
+
*/
|
|
319
|
+
function bindsPrompt(prompt: string | undefined, pending: PendingReview): boolean {
|
|
320
|
+
return prompt === undefined || prompt === pending.dispatchPrompt;
|
|
321
|
+
}
|
|
322
|
+
|
|
289
323
|
function bindsStart(event: Extract<ClaudeHookEvent, { type: "SubagentStart" }>, pending: PendingReview): boolean {
|
|
290
324
|
if (event.taskId && event.taskId !== pending.request.taskId) return false;
|
|
291
|
-
if (event.prompt
|
|
292
|
-
const match = /<!-- immune-brain:operation_id=([^\s]+)\s+task_id=([^\s]+)\s+-->/.exec(event.prompt);
|
|
293
|
-
if (match) {
|
|
294
|
-
if (match[1] !== pending.request.operationId || match[2] !== pending.request.taskId) return false;
|
|
295
|
-
} else if (event.prompt !== pending.request.prompt) {
|
|
296
|
-
return false;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
325
|
+
if (!bindsPrompt(event.prompt, pending)) return false;
|
|
299
326
|
if (event.operationId) return event.operationId === pending.request.operationId;
|
|
300
|
-
return event.prompt !== undefined
|
|
327
|
+
return event.prompt !== undefined;
|
|
301
328
|
}
|
|
302
329
|
|
|
303
330
|
export class ClaudeReviewHost implements AssuranceHostPort {
|
|
@@ -321,17 +348,19 @@ export class ClaudeReviewHost implements AssuranceHostPort {
|
|
|
321
348
|
}
|
|
322
349
|
this.pending.set(request.operationId, {
|
|
323
350
|
request,
|
|
351
|
+
dispatchPrompt: reservedDispatchPrompt(request),
|
|
324
352
|
initialCursors,
|
|
325
353
|
sessionCursors,
|
|
326
354
|
consumed: false,
|
|
327
355
|
});
|
|
328
356
|
return {
|
|
329
357
|
id: request.operationId,
|
|
358
|
+
// Only parameters the Claude `Agent` tool accepts. The Host has no turn
|
|
359
|
+
// cap and no background switch, so `request.maxTurns` (which the Pi port
|
|
360
|
+
// forwards natively) has no receiver here and is not projected.
|
|
330
361
|
dispatch: {
|
|
331
362
|
name: CLAUDE_REVIEWER_AGENT,
|
|
332
|
-
prompt:
|
|
333
|
-
max_turns: request.maxTurns,
|
|
334
|
-
run_in_background: false,
|
|
363
|
+
prompt: reservedDispatchPrompt(request),
|
|
335
364
|
},
|
|
336
365
|
};
|
|
337
366
|
}
|
|
@@ -434,6 +463,9 @@ export class ClaudeReviewHost implements AssuranceHostPort {
|
|
|
434
463
|
if (event.toolName !== AGENT_TOOL) return;
|
|
435
464
|
if (!event.agentId || !event.operationId || event.operationId !== state.request.operationId) return;
|
|
436
465
|
if ("taskId" in event && event.taskId && event.taskId !== state.request.taskId) return;
|
|
466
|
+
// The `Agent` tool input is where this Host actually reports the
|
|
467
|
+
// dispatched prompt, so it is bound by the same exactness rule.
|
|
468
|
+
if (!bindsPrompt(event.prompt, state)) return;
|
|
437
469
|
if (state.startEvent && event.sessionId !== state.startEvent.sessionId) return;
|
|
438
470
|
if (state.startEvent && event.agentId !== state.startEvent.agentId) return;
|
|
439
471
|
if (state.stopEvent && event.sessionId !== state.stopEvent.sessionId) return;
|
|
@@ -458,7 +490,7 @@ export class ClaudeReviewHost implements AssuranceHostPort {
|
|
|
458
490
|
}
|
|
459
491
|
if (state.stopEvent) {
|
|
460
492
|
if (state.stopEvent === event) return;
|
|
461
|
-
state.error = "duplicate SubagentStop observed for review reservation";
|
|
493
|
+
state.error = "duplicate SubagentStop observed for review reservation: a reviewer was continued after it finished; a reviewer cannot be continued and a fresh reviewer must be dispatched";
|
|
462
494
|
return;
|
|
463
495
|
}
|
|
464
496
|
state.stopEvent = event;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { InternalRole } from "../role_prompt_bridge";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude plugin agent definitions for the read-only internal roles.
|
|
5
|
+
*
|
|
6
|
+
* A native definition is the strictest boundary the Host can express for these
|
|
7
|
+
* roles: `Read, Grep, Glob` grants no write, shell or dispatch tool, while the
|
|
8
|
+
* Pi mapping leaves the same boundary as prompt text. `INTERNAL_ROLE_PROMPTS`
|
|
9
|
+
* declares `no tools` or `read-only tools` for every role here; a native
|
|
10
|
+
* definition cannot express an empty allowlist without inheriting every tool,
|
|
11
|
+
* so read-only-without-shell is what ships.
|
|
12
|
+
*
|
|
13
|
+
* The bodies are generated from the matching `runtime/prompts/` source by
|
|
14
|
+
* `scripts/build-claude-plugin.ts`, never maintained by hand.
|
|
15
|
+
*/
|
|
16
|
+
export interface ClaudeReadonlyRoleAgent {
|
|
17
|
+
/** The plugin-qualified agent type the Claude `Agent` tool accepts. */
|
|
18
|
+
readonly agent: string;
|
|
19
|
+
/** Repository-relative definition the plugin build generates. */
|
|
20
|
+
readonly definition: string;
|
|
21
|
+
/** The role prompt source the definition body is generated from. */
|
|
22
|
+
readonly prompt: string;
|
|
23
|
+
/** Frontmatter `description`: when the Host may select this agent. */
|
|
24
|
+
readonly description: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const CLAUDE_READONLY_ROLE_AGENTS = {
|
|
28
|
+
qa: {
|
|
29
|
+
agent: "immune-brain:immune-brain-qa",
|
|
30
|
+
definition: "plugins/immune-brain/agents/immune-brain-qa.md",
|
|
31
|
+
prompt: "plugins/immune-brain/runtime/prompts/qa.md",
|
|
32
|
+
description:
|
|
33
|
+
"Immune-Brain QA authority. Judges recorded execution evidence against one active target and returns one decision.",
|
|
34
|
+
},
|
|
35
|
+
"ui-review": {
|
|
36
|
+
agent: "immune-brain:immune-brain-ui-review",
|
|
37
|
+
definition: "plugins/immune-brain/agents/immune-brain-ui-review.md",
|
|
38
|
+
prompt: "plugins/immune-brain/runtime/prompts/ui-review.md",
|
|
39
|
+
description:
|
|
40
|
+
"Immune-Brain UI Review authority. Read-only review of a bounded UI change surface and its evidence.",
|
|
41
|
+
},
|
|
42
|
+
"advisory-reviewer": {
|
|
43
|
+
agent: "immune-brain:immune-brain-advisory-reviewer",
|
|
44
|
+
definition: "plugins/immune-brain/agents/immune-brain-advisory-reviewer.md",
|
|
45
|
+
prompt: "plugins/immune-brain/runtime/prompts/advisory-reviewer.md",
|
|
46
|
+
description:
|
|
47
|
+
"Immune-Brain advisory reviewer for one explicit lens. Read-only advice; synthesis stays with the coordinating Parent.",
|
|
48
|
+
},
|
|
49
|
+
} as const satisfies Record<string, ClaudeReadonlyRoleAgent>;
|
|
50
|
+
|
|
51
|
+
/** The read-only roles that ship a generated native definition. */
|
|
52
|
+
export type ClaudeReadonlyRole = keyof typeof CLAUDE_READONLY_ROLE_AGENTS;
|
|
53
|
+
|
|
54
|
+
export const CLAUDE_READONLY_ROLES = Object.keys(
|
|
55
|
+
CLAUDE_READONLY_ROLE_AGENTS,
|
|
56
|
+
) as ClaudeReadonlyRole[];
|
|
57
|
+
|
|
58
|
+
/** The tool allowlist every generated read-only definition declares. */
|
|
59
|
+
export const CLAUDE_READONLY_TOOLS = ["Read", "Grep", "Glob"] as const;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The read-only internal roles that ship no definition because the Host already
|
|
63
|
+
* provides the agent. `arch-explorer` is architecture discovery: Claude's own
|
|
64
|
+
* `Explore` agent is the read-only research agent, and shipping a second
|
|
65
|
+
* definition would fork a boundary the Host owns.
|
|
66
|
+
*/
|
|
67
|
+
export const CLAUDE_HOST_PROVIDED_ROLE_AGENTS = {
|
|
68
|
+
"arch-explorer": { agent: "Explore" },
|
|
69
|
+
} as const satisfies Record<string, { agent: string }>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Roles that are not dispatched as native Claude agents.
|
|
73
|
+
*
|
|
74
|
+
* `code-review` ships its own definition with a wider allowlist (it must run
|
|
75
|
+
* read-only Git commands), so it is not part of this table. The remaining roles
|
|
76
|
+
* write: `executor`, `test-fixer` and `pr-fix` mutate the workspace, and
|
|
77
|
+
* `compounder` writes learning storage. A write-capable role stays bounded by
|
|
78
|
+
* its dispatch envelope and the Parent, not by an agent definition.
|
|
79
|
+
*/
|
|
80
|
+
export const CLAUDE_NATIVE_AGENT_ROLES = new Set<InternalRole>([
|
|
81
|
+
...CLAUDE_READONLY_ROLES,
|
|
82
|
+
...(Object.keys(CLAUDE_HOST_PROVIDED_ROLE_AGENTS) as InternalRole[]),
|
|
83
|
+
]);
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
import { inspectSpecBinding } from "../kernel/spec_binding";
|
|
30
30
|
import { projectTask, resolveProjectedRisk } from "../kernel/completion";
|
|
31
31
|
import { deriveAssuranceAuthorization } from "../kernel/assurance_projection";
|
|
32
|
-
import {
|
|
32
|
+
import { taskDeliveryIdentity } from "../workspace_scope";
|
|
33
33
|
import type { TaskIntentV1, TaskRisk } from "../kernel/types";
|
|
34
34
|
import {
|
|
35
35
|
canonicalDescriptorBytes,
|
|
@@ -273,10 +273,7 @@ function runInspect(root: string): KernelExecution {
|
|
|
273
273
|
const workspaceState = readWorkspaceStateRaw(root);
|
|
274
274
|
let identity;
|
|
275
275
|
try {
|
|
276
|
-
identity =
|
|
277
|
-
record.contract === "assurance_kernel/task_record/v4"
|
|
278
|
-
? taskRevisionIdentity(root, intent.scope_hint, record.git_base_head, intent.task_id)
|
|
279
|
-
: taskDiffIdentity(root, intent.scope_hint, intent.task_id);
|
|
276
|
+
identity = taskDeliveryIdentity(root, record);
|
|
280
277
|
} catch (error) {
|
|
281
278
|
return sourceFailure("inspect", error);
|
|
282
279
|
}
|
|
@@ -571,7 +568,7 @@ function runIntentAuthor(args: string[], root: string): KernelExecution {
|
|
|
571
568
|
"rejected",
|
|
572
569
|
"kernel_owner_active",
|
|
573
570
|
null,
|
|
574
|
-
"Route to imm-
|
|
571
|
+
"Route to imm-run instead of authoring a new draft.",
|
|
575
572
|
),
|
|
576
573
|
};
|
|
577
574
|
if (owner.v3)
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type MutationAuthorityRegistry,
|
|
10
10
|
} from "./authority_port";
|
|
11
11
|
import { asTaskDiffSnapshot } from "./completion";
|
|
12
|
+
import { taskDeliveryIdentity } from "../workspace_scope";
|
|
12
13
|
import { canonicalIntentHash, readTaskIntent } from "./intent";
|
|
13
14
|
import {
|
|
14
15
|
inspectIntentTokenPair,
|
|
@@ -51,8 +52,9 @@ export interface ApplyTaskActionInput {
|
|
|
51
52
|
/** Paired mutation authority registry that must recognize every supplied capability. */
|
|
52
53
|
registry: MutationAuthorityRegistry;
|
|
53
54
|
capability?: MutationAuthorityCapabilityV2;
|
|
54
|
-
/** Trusted injected diff provider; the action's diff_hash is only an expectation.
|
|
55
|
-
|
|
55
|
+
/** Trusted injected diff provider; the action's diff_hash is only an expectation.
|
|
56
|
+
* Defaults to the shared delivery identity selector when omitted. */
|
|
57
|
+
diffProvider?: (root: string, record: TaskRecord) => string | { diff_hash: string; changed_paths?: readonly string[] };
|
|
56
58
|
/**
|
|
57
59
|
* Terminal commit mode: record + workspace + active-claim removal + task
|
|
58
60
|
* tombstone converge through one recoverable terminal transaction marker
|
|
@@ -71,7 +73,8 @@ export interface ApplyTaskActionInput {
|
|
|
71
73
|
export function applyTaskAction(
|
|
72
74
|
input: ApplyTaskActionInput,
|
|
73
75
|
): StoredTaskMutationV3 {
|
|
74
|
-
const { root, task_id, prior_intent_token, registry, capability
|
|
76
|
+
const { root, task_id, prior_intent_token, registry, capability } = input;
|
|
77
|
+
const diffProvider = input.diffProvider ?? taskDeliveryIdentity;
|
|
75
78
|
return withKernelStoreLock(root, () => {
|
|
76
79
|
const current = readTaskRecordRaw(root, task_id);
|
|
77
80
|
if (!current.record)
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// projection that binds a task's backend claim, TaskRecord v3, workspace,
|
|
3
3
|
// attestations, findings, and completion facts into one closed result.
|
|
4
4
|
//
|
|
5
|
-
// This module is deliberately INTERNAL to the Kernel runtime:
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// This module is deliberately INTERNAL to the Kernel runtime: both host
|
|
6
|
+
// adapters import it statically and it is never re-exported from ./index.ts.
|
|
7
|
+
// Hosts consume the projection without
|
|
8
8
|
// reconstructing evidence/approval freshness or authorization readiness.
|
|
9
9
|
//
|
|
10
10
|
// It does not know about Pi session state, pending native Review verdicts,
|
|
@@ -13,13 +13,15 @@
|
|
|
13
13
|
|
|
14
14
|
import { readBackendClaim, readTaskTombstone } from "./backend_claim";
|
|
15
15
|
import {
|
|
16
|
-
|
|
16
|
+
readTaskRecordRaw,
|
|
17
17
|
readAuditTaskPair,
|
|
18
|
+
localRunId,
|
|
18
19
|
readCommittedRecord,
|
|
19
20
|
readWorkspaceStateRaw,
|
|
20
21
|
reconcileKernelAuthority,
|
|
21
22
|
} from "./storage";
|
|
22
23
|
import { projectTask, resolveProjectedRisk } from "./completion";
|
|
24
|
+
import { taskDeliveryIdentity } from "../workspace_scope";
|
|
23
25
|
import type { AssuranceObligation, TaskIntentV1, TaskRecord, TaskRecordV3 } from "./types";
|
|
24
26
|
import type { TaskRecordV2 } from "./legacy_task_record";
|
|
25
27
|
|
|
@@ -244,7 +246,9 @@ function projectFromRecord(
|
|
|
244
246
|
/**
|
|
245
247
|
* One closed read-only projection for a task. The injected diffProvider keeps
|
|
246
248
|
* this module free of git/worktree concerns; hosts supply it from their own
|
|
247
|
-
* task-scope diff implementation.
|
|
249
|
+
* task-scope diff implementation. When no provider is supplied the shared
|
|
250
|
+
* delivery identity selector (workspace_scope.taskDeliveryIdentity) computes
|
|
251
|
+
* it; a supplied provider still overrides it (I5).
|
|
248
252
|
*
|
|
249
253
|
* Error semantics:
|
|
250
254
|
* - no authority facts -> error null, claim null, empty projection
|
|
@@ -257,7 +261,7 @@ function projectFromRecord(
|
|
|
257
261
|
export async function projectAssurance(
|
|
258
262
|
root: string,
|
|
259
263
|
taskId: string,
|
|
260
|
-
diffProvider: (root: string, record: TaskRecord) => TaskDiffSnapshot,
|
|
264
|
+
diffProvider: (root: string, record: TaskRecord) => TaskDiffSnapshot = taskDeliveryIdentity,
|
|
261
265
|
): Promise<AssuranceProjectionResult> {
|
|
262
266
|
const fail = (
|
|
263
267
|
error: string,
|
|
@@ -303,7 +307,7 @@ export async function projectAssurance(
|
|
|
303
307
|
}
|
|
304
308
|
let read;
|
|
305
309
|
try {
|
|
306
|
-
read = await
|
|
310
|
+
read = await readTaskRecordRaw(root, taskId);
|
|
307
311
|
} catch (error) {
|
|
308
312
|
if (!terminalOwner || !(error instanceof Error) || !error.message.startsWith("TaskRecord v2")) throw error;
|
|
309
313
|
return fail(error instanceof Error ? error.message : String(error));
|
|
@@ -332,7 +336,10 @@ export async function projectAssurance(
|
|
|
332
336
|
},
|
|
333
337
|
};
|
|
334
338
|
}
|
|
335
|
-
|
|
339
|
+
// Run-aware: with a local run row the store already answered above;
|
|
340
|
+
// without one (a fresh clone or pre-run-layout evidence) only the
|
|
341
|
+
// resolved pair may serve, never another run's directory by drift.
|
|
342
|
+
const auditPair = await readAuditTaskPair(root, taskId, localRunId(root, taskId) ?? undefined);
|
|
336
343
|
if (!auditPair) return fail(`task ${taskId} has no terminal audit pair`, claim);
|
|
337
344
|
const workspace = await readWorkspaceStateRaw(root);
|
|
338
345
|
if (auditPair.record.contract === "assurance_kernel/task_record/v2")
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "./authority_port";
|
|
15
15
|
import { applyTaskAction } from "./application";
|
|
16
16
|
import { asTaskDiffSnapshot } from "./completion";
|
|
17
|
+
import { taskDeliveryIdentity } from "../workspace_scope";
|
|
17
18
|
import { readTaskIntent } from "./intent";
|
|
18
19
|
import {
|
|
19
20
|
inspectIntentTokenPair,
|
|
@@ -67,8 +68,9 @@ export interface CanaryExecuteInput {
|
|
|
67
68
|
task_id: string;
|
|
68
69
|
operation: CanaryOperation;
|
|
69
70
|
prior_intent_token: TaskIntentIdentityToken;
|
|
70
|
-
/** Trusted injected diff provider; the application derives the diff identity.
|
|
71
|
-
|
|
71
|
+
/** Trusted injected diff provider; the application derives the diff identity.
|
|
72
|
+
* Defaults to the shared delivery identity selector when omitted. */
|
|
73
|
+
diffProvider?: (root: string, record: TaskRecord) => string | { diff_hash: string; changed_paths?: readonly string[] };
|
|
72
74
|
now?: string;
|
|
73
75
|
}
|
|
74
76
|
|
|
@@ -313,7 +315,7 @@ export function createCanaryApplication(
|
|
|
313
315
|
record: current.record,
|
|
314
316
|
};
|
|
315
317
|
});
|
|
316
|
-
const diffHash = asTaskDiffSnapshot(input.diffProvider(input.root, snapshot.record)).diff_hash;
|
|
318
|
+
const diffHash = asTaskDiffSnapshot((input.diffProvider ?? taskDeliveryIdentity)(input.root, snapshot.record)).diff_hash;
|
|
317
319
|
if (operation.op === "stop" && !("capability" in operation))
|
|
318
320
|
throw new KernelInvariantError(["stop requires user authority capability"]);
|
|
319
321
|
const hasBoundSpec = boundSpecPath(snapshot.intent_snapshot) !== undefined;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Atomically creates TaskRecord
|
|
3
|
-
// for one confirmed canary task
|
|
4
|
-
//
|
|
1
|
+
// Canary enrollment core. NOT exported from kernel/index.ts.
|
|
2
|
+
// Atomically creates TaskRecord v4 + workspace working claim + backend claim
|
|
3
|
+
// for one confirmed canary task through a valid EnrollmentCapability.
|
|
4
|
+
// Production callers go through the single `enrollTask` entry (D3): it issues
|
|
5
|
+
// the capability, rehearses zero-write, runs the optional caller checkpoint,
|
|
6
|
+
// then commits. `runEnrollmentRehearsal` stays exported as a zero-write
|
|
7
|
+
// precheck for tests and diagnostics only.
|
|
5
8
|
|
|
6
9
|
import { readRunRowByTask, withKernelRead } from "./sqlite_store";
|
|
7
10
|
import { readTaskIntent } from "./intent";
|
|
@@ -21,6 +24,7 @@ import { dirtyScopePaths, writeEnrollmentBaseline } from "../workspace_scope";
|
|
|
21
24
|
import { enrollmentRequestDigest } from "./run_identity";
|
|
22
25
|
import {
|
|
23
26
|
commitEnrollmentLocked,
|
|
27
|
+
enrollmentEventIdFor,
|
|
24
28
|
readCommittedEnrollmentResult,
|
|
25
29
|
readTaskRecordRaw,
|
|
26
30
|
readWorkspaceStateRaw,
|
|
@@ -53,6 +57,48 @@ export interface EnrollCanaryInput {
|
|
|
53
57
|
now: string;
|
|
54
58
|
}
|
|
55
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A rehearsal that is not ready rejects through this single typed error so the
|
|
62
|
+
* "Kernel enrollment rehearsal failed" message is produced in exactly one place
|
|
63
|
+
* (deepen-authority-seams D3). Callers that decorate it — the Pi enroll Tool
|
|
64
|
+
* appends its empty-initial-commit note — read `message` or `blockers`.
|
|
65
|
+
*/
|
|
66
|
+
export class EnrollmentRehearsalError extends Error {
|
|
67
|
+
readonly blockers: readonly string[];
|
|
68
|
+
constructor(blockers: readonly string[]) {
|
|
69
|
+
super(`Kernel enrollment rehearsal failed: ${blockers.join("; ")}`);
|
|
70
|
+
this.name = "EnrollmentRehearsalError";
|
|
71
|
+
this.blockers = blockers;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The one Enrollment entry's request. The entry issues the capability for this
|
|
77
|
+
* binding itself, so a caller supplies the binding, not a pre-issued capability.
|
|
78
|
+
*/
|
|
79
|
+
export interface EnrollmentEntryRequest {
|
|
80
|
+
binding: EnrollmentCapabilityBinding;
|
|
81
|
+
/** Present only for a batch-derived child enrollment. */
|
|
82
|
+
batch?: EnrollBatchContext;
|
|
83
|
+
now: string;
|
|
84
|
+
/**
|
|
85
|
+
* Optional caller checkpoint run after a ready rehearsal and before the
|
|
86
|
+
* commit. Declining cancels the Enrollment with zero Kernel writes; once the
|
|
87
|
+
* commit has started, cancellation has no effect.
|
|
88
|
+
*/
|
|
89
|
+
checkpoint?: () => boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The same request with a mandatory checkpoint: the entry yields a discriminated outcome. */
|
|
93
|
+
export interface EnrollmentEntryRequestWithCheckpoint extends EnrollmentEntryRequest {
|
|
94
|
+
checkpoint: () => boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The entry's outcome when a checkpoint is supplied: enrolled, or cancelled. */
|
|
98
|
+
export type EnrollmentEntryOutcome =
|
|
99
|
+
| { outcome: "enrolled"; result: EnrollCanaryResult }
|
|
100
|
+
| { outcome: "cancelled" };
|
|
101
|
+
|
|
56
102
|
export interface EnrollCanaryResult {
|
|
57
103
|
record: TaskRecord;
|
|
58
104
|
backend_claim: BackendClaim;
|
|
@@ -255,7 +301,105 @@ export function runEnrollmentRehearsal(
|
|
|
255
301
|
}
|
|
256
302
|
|
|
257
303
|
function enrollmentEventId(taskId: string, now: string): string {
|
|
258
|
-
return
|
|
304
|
+
return enrollmentEventIdFor(taskId, now);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The single Enrollment entry (deepen-authority-seams D3): issues the
|
|
309
|
+
* capability for the supplied binding, runs the zero-write rehearsal, calls the
|
|
310
|
+
* optional caller checkpoint, and commits. A rehearsal that is not ready
|
|
311
|
+
* rejects with its blockers in the one shared message and writes nothing; a
|
|
312
|
+
* declined checkpoint cancels with zero writes; once commit has started,
|
|
313
|
+
* cancellation has no effect. Replay of a lost Enrollment keeps today's
|
|
314
|
+
* behavior because the commit step is the unchanged durable operation.
|
|
315
|
+
*/
|
|
316
|
+
/**
|
|
317
|
+
* A request that supplies no checkpoint, or one that is `undefined`. Only such
|
|
318
|
+
* a request is guaranteed to return the plain result: the entry has no caller
|
|
319
|
+
* window in which it could cancel.
|
|
320
|
+
*/
|
|
321
|
+
export interface EnrollmentEntryRequestWithoutCheckpoint extends EnrollmentEntryRequest {
|
|
322
|
+
checkpoint?: undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The one Cancellation boundary. Between the readiness decision and the
|
|
327
|
+
* caller checkpoint the entry yields once, so a cancellation already queued by
|
|
328
|
+
* the host (a microtask, an already-resolved signal) is observed by the
|
|
329
|
+
* checkpoint instead of losing the race against the synchronous step that
|
|
330
|
+
* follows. Zero writes either way.
|
|
331
|
+
*/
|
|
332
|
+
const CANCELLATION_BOUNDARY = Promise.resolve();
|
|
333
|
+
|
|
334
|
+
export async function enrollTask(
|
|
335
|
+
root: string,
|
|
336
|
+
registry: EnrollmentAuthorityRegistry,
|
|
337
|
+
request: EnrollmentEntryRequestWithCheckpoint,
|
|
338
|
+
): Promise<EnrollmentEntryOutcome>;
|
|
339
|
+
export async function enrollTask(
|
|
340
|
+
root: string,
|
|
341
|
+
registry: EnrollmentAuthorityRegistry,
|
|
342
|
+
request: EnrollmentEntryRequestWithoutCheckpoint,
|
|
343
|
+
): Promise<EnrollCanaryResult>;
|
|
344
|
+
/**
|
|
345
|
+
* The general interface shape, whose checkpoint is optional: the caller can
|
|
346
|
+
* still hold it back, so the return has to stay the full union. Without this
|
|
347
|
+
* overload a value typed `EnrollmentEntryRequest` matches neither of the two
|
|
348
|
+
* above and the call does not compile, even though the runtime answers it.
|
|
349
|
+
*/
|
|
350
|
+
export async function enrollTask(
|
|
351
|
+
root: string,
|
|
352
|
+
registry: EnrollmentAuthorityRegistry,
|
|
353
|
+
request: EnrollmentEntryRequest,
|
|
354
|
+
): Promise<EnrollCanaryResult | EnrollmentEntryOutcome>;
|
|
355
|
+
export async function enrollTask(
|
|
356
|
+
root: string,
|
|
357
|
+
registry: EnrollmentAuthorityRegistry,
|
|
358
|
+
request: EnrollmentEntryRequest,
|
|
359
|
+
): Promise<EnrollCanaryResult | EnrollmentEntryOutcome> {
|
|
360
|
+
const { binding, batch, now, checkpoint } = request;
|
|
361
|
+
// (1) Issue the capability for the supplied binding.
|
|
362
|
+
const capability = registry.issue(binding);
|
|
363
|
+
const input: EnrollCanaryInput = {
|
|
364
|
+
task_id: binding.task_id,
|
|
365
|
+
intent_path: binding.intent_path,
|
|
366
|
+
intent_revision: binding.intent_revision,
|
|
367
|
+
preparation_digest: binding.preparation_digest,
|
|
368
|
+
capability,
|
|
369
|
+
capability_binding: binding,
|
|
370
|
+
...(batch ? { batch } : {}),
|
|
371
|
+
now,
|
|
372
|
+
};
|
|
373
|
+
// Replay of a lost Enrollment: the exact request already committed here, so
|
|
374
|
+
// the durable operation is the only correct answer. This must precede the
|
|
375
|
+
// rehearsal, whose precondition checks would otherwise report the now-existing
|
|
376
|
+
// TaskRecord and workspace owner as blockers and make the durable replay
|
|
377
|
+
// unreachable. A request committed for a different binding throws, so the
|
|
378
|
+
// divergent-request refusal is preserved.
|
|
379
|
+
const { eventId, digest } = digestForEnrollment(input);
|
|
380
|
+
const committed = readCommittedEnrollmentResult(root, input.task_id, eventId, digest);
|
|
381
|
+
if (committed) {
|
|
382
|
+
const result: EnrollCanaryResult = {
|
|
383
|
+
record: committed.record,
|
|
384
|
+
backend_claim: committed.claim,
|
|
385
|
+
workspace: { revision: "", state: committed.workspace },
|
|
386
|
+
};
|
|
387
|
+
return checkpoint ? { outcome: "enrolled", result } : result;
|
|
388
|
+
}
|
|
389
|
+
// (2) Rehearse: not ready rejects with the blockers and writes nothing.
|
|
390
|
+
const rehearsal = runEnrollmentRehearsal(root, input, capability, registry);
|
|
391
|
+
if (!rehearsal.rehearsed || rehearsal.evidence.outcome !== "ready")
|
|
392
|
+
throw new EnrollmentRehearsalError(rehearsal.evidence.blockers);
|
|
393
|
+
// The boundary: a cancellation the host queued while the rehearsal ran is
|
|
394
|
+
// delivered here, before the checkpoint can start the commit.
|
|
395
|
+
await CANCELLATION_BOUNDARY;
|
|
396
|
+
// (3) Optional caller checkpoint: declining cancels before any write.
|
|
397
|
+
if (checkpoint && !checkpoint()) return { outcome: "cancelled" };
|
|
398
|
+
// (4) Commit; not cancellable from here. Without a caller checkpoint the
|
|
399
|
+
// entry is the plain durable operation the replay of a lost Enrollment
|
|
400
|
+
// already handles inside `enrollCanaryTask`.
|
|
401
|
+
const result = enrollCanaryTask(root, input, registry);
|
|
402
|
+
return checkpoint ? { outcome: "enrolled", result } : result;
|
|
259
403
|
}
|
|
260
404
|
|
|
261
405
|
function digestForEnrollment(input: EnrollCanaryInput): {
|
|
@@ -10,7 +10,9 @@ export * from "./completion";
|
|
|
10
10
|
export {
|
|
11
11
|
readWorkspaceStateRaw,
|
|
12
12
|
readTaskRecordRaw,
|
|
13
|
-
|
|
13
|
+
readSettledRunEvidence,
|
|
14
|
+
readSettledTaskEvidence,
|
|
15
|
+
enrollmentEventIdFor,
|
|
14
16
|
readSecureProjectFile,
|
|
15
17
|
commitTaskRecordLocked,
|
|
16
18
|
withKernelStoreLock,
|