immune-brain 3.6.9 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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 +7587 -5049
- 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 +33 -25
- package/plugins/immune-brain/dist/imm-planner.md +41 -27
- package/plugins/immune-brain/dist/imm-review-retro.md +2 -2
- package/plugins/immune-brain/dist/role-prompts/code-review.md +11 -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 +1 -1
- 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 +11 -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/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,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
|
}
|
|
@@ -3,8 +3,17 @@
|
|
|
3
3
|
// eligible when its Git-tracked TaskIntent is readable and no Kernel owner
|
|
4
4
|
// (workspace, backend claim, TaskRecord v2, tombstone) blocks enrollment.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
// The readiness/evidence pipeline is retired with the authority-observation
|
|
7
|
+
// island. These two derived inputs stay on the eligibility input for caller
|
|
8
|
+
// compatibility only: they are ignored below and are never authority, so they
|
|
9
|
+
// are read structurally instead of importing the deleted modules.
|
|
10
|
+
export interface RetiredReadinessInput {
|
|
11
|
+
status: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RetiredReadinessEvidenceInput {
|
|
15
|
+
status: string;
|
|
16
|
+
}
|
|
8
17
|
|
|
9
18
|
export type WaivableGate = "observation_window_days";
|
|
10
19
|
|
|
@@ -26,8 +35,8 @@ export interface CanaryTaskIdentity {
|
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
export interface CanaryEligibilityInput {
|
|
29
|
-
readiness?:
|
|
30
|
-
evidence?:
|
|
38
|
+
readiness?: RetiredReadinessInput;
|
|
39
|
+
evidence?: RetiredReadinessEvidenceInput;
|
|
31
40
|
task: CanaryTaskIdentity;
|
|
32
41
|
waiver?: CanaryWaiver;
|
|
33
42
|
now: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { classifyTaskRisk } from "./intent";
|
|
2
|
+
import { archivePath, boundSpecPath } from "./spec_binding";
|
|
2
3
|
import type {
|
|
3
4
|
ApprovalKind,
|
|
4
5
|
CompletionDecision,
|
|
@@ -7,7 +8,7 @@ import type {
|
|
|
7
8
|
TaskProjectionV3,
|
|
8
9
|
TaskRecord,
|
|
9
10
|
} from "./types";
|
|
10
|
-
import { assertKernelInvariantsV3
|
|
11
|
+
import { assertKernelInvariantsV3 } from "./validation";
|
|
11
12
|
import { refutationIsLive } from "./refutation";
|
|
12
13
|
|
|
13
14
|
const REQUIRED_ATTESTATIONS: Record<TaskIntentV1["risk"], ApprovalKind[]> = {
|
|
@@ -23,23 +24,13 @@ function archiveActivePlanningPath(path: string): string | null {
|
|
|
23
24
|
|
|
24
25
|
function ownSidecarPaths(intent: TaskIntentV1): Set<string> {
|
|
25
26
|
const activeIntent = `docs/plans/${intent.task_id}.intent.json`;
|
|
26
|
-
const archivedIntent = archiveActivePlanningPath(activeIntent);
|
|
27
27
|
const excluded = new Set<string>([activeIntent]);
|
|
28
|
+
const archivedIntent = archiveActivePlanningPath(activeIntent);
|
|
28
29
|
if (archivedIntent) excluded.add(archivedIntent);
|
|
29
|
-
const
|
|
30
|
-
const archived = archiveActivePlanningPath(path);
|
|
31
|
-
return archived !== null && /^docs\/specs\/[^/]+\.spec\.md$/.test(path) && intent.scope_hint.includes(archived);
|
|
32
|
-
});
|
|
33
|
-
if (specs.length > 1) {
|
|
34
|
-
throw new KernelInvariantError([
|
|
35
|
-
`artifact transition requires at most one scope-bound Spec; found ${specs.length}`,
|
|
36
|
-
]);
|
|
37
|
-
}
|
|
38
|
-
const spec = specs[0];
|
|
30
|
+
const spec = boundSpecPath(intent);
|
|
39
31
|
if (spec) {
|
|
40
32
|
excluded.add(spec);
|
|
41
|
-
|
|
42
|
-
if (archived) excluded.add(archived);
|
|
33
|
+
excluded.add(archivePath(spec));
|
|
43
34
|
}
|
|
44
35
|
return excluded;
|
|
45
36
|
}
|