immune-brain 3.6.9 → 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/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 +27 -25
- package/plugins/immune-brain/dist/imm-planner.md +34 -27
- package/plugins/immune-brain/dist/imm-review-retro.md +2 -2
- 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 +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 +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/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
|
@@ -12,8 +12,9 @@ import {
|
|
|
12
12
|
readTaskRecordRaw,
|
|
13
13
|
readWorkspaceStateRaw,
|
|
14
14
|
} from "./storage";
|
|
15
|
+
import { stateDatabasePath } from "./storage_paths";
|
|
15
16
|
|
|
16
|
-
const SOURCE_PATH =
|
|
17
|
+
const SOURCE_PATH = stateDatabasePath();
|
|
17
18
|
const GIT_OBJECT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -64,6 +65,13 @@ export interface PiCanaryPreparation {
|
|
|
64
65
|
git_error: string | null;
|
|
65
66
|
workspace: {
|
|
66
67
|
current_working: string | null;
|
|
68
|
+
/**
|
|
69
|
+
* The workspace CAS token at preparation time. A workspace can return to
|
|
70
|
+
* the same owner value at a different revision (another task enrolled and
|
|
71
|
+
* settled in between), so the owner alone cannot prove the confirmed
|
|
72
|
+
* state is still current.
|
|
73
|
+
*/
|
|
74
|
+
revision: string;
|
|
67
75
|
};
|
|
68
76
|
digest: string;
|
|
69
77
|
}
|
|
@@ -152,6 +160,7 @@ export function preparePiCanary(root: string, input: PiCanaryPrepareInput): PiCa
|
|
|
152
160
|
const state = readWorkspaceStateRaw(canonicalRoot);
|
|
153
161
|
const workspace: PiCanaryPreparation["workspace"] = {
|
|
154
162
|
current_working: state.state.current_working,
|
|
163
|
+
revision: state.revision,
|
|
155
164
|
};
|
|
156
165
|
if (claim && state.state.current_working !== claim.task_id)
|
|
157
166
|
throw new Error(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Pure TaskRecord
|
|
1
|
+
// Pure TaskRecord v4 reducer with a closed factual action vocabulary.
|
|
2
2
|
// Never reads files, Git, workspace, or host context. Returns a branded
|
|
3
3
|
// ReducedTaskMutation; the caller cannot construct or serialize it.
|
|
4
4
|
|
|
@@ -7,7 +7,6 @@ import { completionDecision } from "./completion";
|
|
|
7
7
|
import { anchorForEvidence, isFreshPassingQaAttestation, refutationIdentity, refutationIsLive } from "./refutation";
|
|
8
8
|
import {
|
|
9
9
|
REDUCED_MUTATION_BRAND,
|
|
10
|
-
TASK_RECORD_CONTRACT_V4,
|
|
11
10
|
type AuthorityAuditDescriptor,
|
|
12
11
|
type ReducedTaskMutation,
|
|
13
12
|
type TaskAction,
|
|
@@ -163,10 +162,6 @@ function appendHistory(
|
|
|
163
162
|
record.history.push(entry as (typeof record.history)[number]);
|
|
164
163
|
}
|
|
165
164
|
|
|
166
|
-
function sha256Hex(value: string): string {
|
|
167
|
-
return createHash("sha256").update(value).digest("hex");
|
|
168
|
-
}
|
|
169
|
-
|
|
170
165
|
function intentRefMatches(intent: TaskIntentV1, ref: TaskIntentRefV3): boolean {
|
|
171
166
|
const activePath = `docs/plans/${intent.task_id}.intent.json`;
|
|
172
167
|
const archivedPath = `docs/plans/archive/${intent.task_id}.intent.json`;
|
|
@@ -209,19 +204,11 @@ export function findingsDigestV2(findings: TaskFinding[]): string {
|
|
|
209
204
|
}
|
|
210
205
|
|
|
211
206
|
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* shared, so a second-round finding can no longer park the task by accident.
|
|
207
|
+
* Rework rounds one Review run may demand before the Kernel pauses the task for
|
|
208
|
+
* a human decision. Ordinary defects return to execution without a user gate,
|
|
209
|
+
* but an unbounded repair/review cycle is not progress either.
|
|
216
210
|
*/
|
|
217
|
-
|
|
218
|
-
if (left.acceptance_id !== null && right.acceptance_id !== null)
|
|
219
|
-
return left.acceptance_id === right.acceptance_id;
|
|
220
|
-
if (left.acceptance_id !== null || right.acceptance_id !== null) return false;
|
|
221
|
-
const leftRef = left.evidence?.violated.ref ?? null;
|
|
222
|
-
const rightRef = right.evidence?.violated.ref ?? null;
|
|
223
|
-
return leftRef !== null && leftRef === rightRef;
|
|
224
|
-
}
|
|
211
|
+
export const REVIEW_REWORK_ROUND_BUDGET = 5;
|
|
225
212
|
|
|
226
213
|
export function reduceTask(
|
|
227
214
|
recordRaw: TaskRecord,
|
|
@@ -444,10 +431,7 @@ export function reduceTask(
|
|
|
444
431
|
const reviewRevision = approval.review_revision;
|
|
445
432
|
if (reviewRevision && approval.kind !== "review")
|
|
446
433
|
throw new KernelInvariantError(["review_revision is only valid on review approvals"]);
|
|
447
|
-
if (
|
|
448
|
-
if (reviewRevision)
|
|
449
|
-
throw new KernelInvariantError(["review_revision requires a TaskRecord v4"]);
|
|
450
|
-
} else if (approval.kind === "review") {
|
|
434
|
+
if (approval.kind === "review") {
|
|
451
435
|
if (!reviewRevision)
|
|
452
436
|
throw new KernelInvariantError(["v4 review approval requires review_revision"]);
|
|
453
437
|
if (reviewRevision.base_head !== record.git_base_head)
|
|
@@ -567,9 +551,11 @@ export function reduceTask(
|
|
|
567
551
|
]);
|
|
568
552
|
}
|
|
569
553
|
const identity = refutationIdentity(record, action.diff_hash);
|
|
570
|
-
// A prior
|
|
571
|
-
//
|
|
572
|
-
//
|
|
554
|
+
// A prior blocking Review claim is only a repeat offence when it names the
|
|
555
|
+
// same security boundary. Ordinary defects on an already-covered
|
|
556
|
+
// acceptance return to execution: changing the plan is a human decision,
|
|
557
|
+
// fixing a bug is not. A claim still refuted by live evidence is not an
|
|
558
|
+
// outstanding dispute at all.
|
|
573
559
|
const priorBlockingReviewFindings = record.findings.filter(
|
|
574
560
|
(finding) =>
|
|
575
561
|
finding.source === "review" &&
|
|
@@ -604,12 +590,26 @@ export function reduceTask(
|
|
|
604
590
|
({ finding, inherited }) =>
|
|
605
591
|
finding.kind === "blocking" &&
|
|
606
592
|
inherited === undefined &&
|
|
607
|
-
|
|
608
|
-
|
|
593
|
+
finding.evidence?.violated.kind === "security_boundary" &&
|
|
594
|
+
priorBlockingReviewFindings.some(
|
|
595
|
+
(prior) =>
|
|
596
|
+
prior.evidence?.violated.kind === "security_boundary" &&
|
|
597
|
+
prior.evidence?.violated.ref === finding.evidence?.violated.ref,
|
|
609
598
|
),
|
|
610
599
|
)?.finding;
|
|
600
|
+
// Only rounds that actually raised an unrefuted blocking claim count
|
|
601
|
+
// against the budget: a claim live QA evidence already answers is not
|
|
602
|
+
// progress lost, so repeating it can never park the task.
|
|
603
|
+
const effectiveBlockingRounds = new Set(
|
|
604
|
+
priorBlockingReviewFindings.map((finding) => finding.review_round),
|
|
605
|
+
).size;
|
|
606
|
+
const hasEffectiveBlockingNow = admissions.some(
|
|
607
|
+
({ finding, inherited }) => finding.kind === "blocking" && inherited === undefined,
|
|
608
|
+
);
|
|
611
609
|
const parkForReplan =
|
|
612
|
-
authorityAudit.authority_kind === "review" &&
|
|
610
|
+
authorityAudit.authority_kind === "review" &&
|
|
611
|
+
(disputed !== undefined ||
|
|
612
|
+
(hasEffectiveBlockingNow && effectiveBlockingRounds >= REVIEW_REWORK_ROUND_BUDGET));
|
|
613
613
|
if (!parkForReplan) {
|
|
614
614
|
record.artifact_state = "active";
|
|
615
615
|
record.intent_ref.path = `docs/plans/${record.task_id}.intent.json`;
|
|
@@ -644,11 +644,13 @@ export function reduceTask(
|
|
|
644
644
|
id: `${action.event_id}:replan-required`,
|
|
645
645
|
kind: "replan_required" as const,
|
|
646
646
|
status: "open" as const,
|
|
647
|
-
acceptance_id: disputed
|
|
647
|
+
acceptance_id: disputed?.acceptance_id ?? null,
|
|
648
648
|
source: "kernel" as const,
|
|
649
649
|
review_round: round,
|
|
650
650
|
summary:
|
|
651
|
-
|
|
651
|
+
disputed !== undefined
|
|
652
|
+
? "Review returned the same security boundary twice; a durable replan is required."
|
|
653
|
+
: `Review exhausted its ${REVIEW_REWORK_ROUND_BUDGET}-round rework budget; a durable replan is required.`,
|
|
652
654
|
};
|
|
653
655
|
if (findingIds.has(boundary.id))
|
|
654
656
|
throw new KernelInvariantError([
|
|
@@ -708,7 +710,6 @@ export function reduceTask(
|
|
|
708
710
|
throw new KernelInvariantError(["stop requires a reason"]);
|
|
709
711
|
transitionLifecycle(record, "stopped");
|
|
710
712
|
record.artifact_state = "frozen";
|
|
711
|
-
record.intent_ref.path = `docs/plans/archive/${record.task_id}.intent.json`;
|
|
712
713
|
appendHistory(record, action, from, action.reason, authorityAudit);
|
|
713
714
|
break;
|
|
714
715
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact execution identity.
|
|
3
|
+
*
|
|
4
|
+
* A worktree owns one storage identity (`workspace_id`) and every enrolled
|
|
5
|
+
* execution owns one `run_id`. Authority mutations bind the exact run instead
|
|
6
|
+
* of resolving "the latest task occurrence", and every replayable operation
|
|
7
|
+
* derives its identity from committed facts so a replayed call returns the
|
|
8
|
+
* recorded result instead of writing again.
|
|
9
|
+
*
|
|
10
|
+
* `task_id` names the logical task: different worktrees may each run the same
|
|
11
|
+
* logical task with distinct run identities, and a terminal task cannot be
|
|
12
|
+
* re-enrolled in the same worktree.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
15
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
16
|
+
|
|
17
|
+
import { KernelStoreSecurityError, workspaceIdentity } from "./sqlite_store";
|
|
18
|
+
|
|
19
|
+
export interface RunIdentity {
|
|
20
|
+
workspace_id: string;
|
|
21
|
+
task_id: string;
|
|
22
|
+
run_id: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RunRowIdentity {
|
|
26
|
+
task_id: string;
|
|
27
|
+
run_id: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function mintRunId(): string {
|
|
31
|
+
return `run-${randomUUID()}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function runIdentity(db: DatabaseSync, row: RunRowIdentity): RunIdentity {
|
|
35
|
+
return { workspace_id: workspaceIdentity(db), task_id: row.task_id, run_id: row.run_id };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Bind a mutation to one exact run; a mismatch fails before any write. */
|
|
39
|
+
export function assertRunBinding(
|
|
40
|
+
identity: RunIdentity,
|
|
41
|
+
expected: RunRowIdentity,
|
|
42
|
+
operation: string,
|
|
43
|
+
): void {
|
|
44
|
+
if (identity.run_id !== expected.run_id || identity.task_id !== expected.task_id)
|
|
45
|
+
throw new KernelStoreSecurityError(
|
|
46
|
+
`${operation} is bound to run ${expected.run_id} (task ${expected.task_id}) but the store holds run ${identity.run_id} (task ${identity.task_id})`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Deterministic replay identity for the authority operations that can lose a
|
|
52
|
+
* response. The event identifier is part of the committed authority bytes, so
|
|
53
|
+
* an identical request maps to an identical operation id and a replayed call
|
|
54
|
+
* returns the recorded result instead of writing again.
|
|
55
|
+
*/
|
|
56
|
+
export function enrollmentOperationId(taskId: string, eventId: string): string {
|
|
57
|
+
return `enroll:${taskId}:${eventId}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Digest of an enrollment request's own content. The capability object is
|
|
62
|
+
* opaque and excluded, so a lost-response retry of the *same* confirmation
|
|
63
|
+
* matches, while a different path, digest, actor, nonce or event time is a
|
|
64
|
+
* different request and must not be answered from the committed operation.
|
|
65
|
+
*/
|
|
66
|
+
export function enrollmentRequestDigest(request: {
|
|
67
|
+
task_id: string;
|
|
68
|
+
intent_path: string;
|
|
69
|
+
intent_revision: number;
|
|
70
|
+
intent_content_hash: string;
|
|
71
|
+
preparation_digest: string;
|
|
72
|
+
enrollment_event_id: string;
|
|
73
|
+
actor_id: string;
|
|
74
|
+
confirmation_ref: string;
|
|
75
|
+
nonce: string;
|
|
76
|
+
}): string {
|
|
77
|
+
const canonical = JSON.stringify({
|
|
78
|
+
task_id: request.task_id,
|
|
79
|
+
intent_path: request.intent_path,
|
|
80
|
+
intent_revision: request.intent_revision,
|
|
81
|
+
intent_content_hash: request.intent_content_hash,
|
|
82
|
+
preparation_digest: request.preparation_digest,
|
|
83
|
+
enrollment_event_id: request.enrollment_event_id,
|
|
84
|
+
actor_id: request.actor_id,
|
|
85
|
+
confirmation_ref: request.confirmation_ref,
|
|
86
|
+
nonce: request.nonce,
|
|
87
|
+
});
|
|
88
|
+
return `sha256:${createHash("sha256").update(canonical).digest("hex")}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function drainOperationId(taskId: string, updatedAt: string): string {
|
|
92
|
+
return `drain:${taskId}:${updatedAt}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Digest of a terminal request's own content. The capability is deliberately
|
|
97
|
+
* opaque and excluded, so a retry that re-mints authority for the *same*
|
|
98
|
+
* request produces the same digest, while a different reason, actor or event
|
|
99
|
+
* time produces a different one and must be authorized again instead of being
|
|
100
|
+
* answered from the committed operation.
|
|
101
|
+
*/
|
|
102
|
+
export function terminalRequestDigest(action: {
|
|
103
|
+
type: string;
|
|
104
|
+
event_id: string;
|
|
105
|
+
at: string;
|
|
106
|
+
actor_id: string;
|
|
107
|
+
reason?: unknown;
|
|
108
|
+
}): string {
|
|
109
|
+
const canonical = JSON.stringify({
|
|
110
|
+
type: action.type,
|
|
111
|
+
event_id: action.event_id,
|
|
112
|
+
at: action.at,
|
|
113
|
+
actor_id: action.actor_id,
|
|
114
|
+
reason: typeof action.reason === "string" ? action.reason : null,
|
|
115
|
+
});
|
|
116
|
+
return `sha256:${createHash("sha256").update(canonical).digest("hex")}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function terminalOperationId(taskId: string, eventId: string): string {
|
|
120
|
+
return `terminal:${taskId}:${eventId}`;
|
|
121
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Spec binding ownership. A simple TaskIntent binds no Spec. A complex
|
|
2
|
+
// TaskIntent binds at most one active Spec by path; freeze records Git
|
|
3
|
+
// content identity without relocating source files. Archive paths are
|
|
4
|
+
// historical evidence, not a freeze requirement.
|
|
5
|
+
|
|
6
|
+
import type { TaskIntentV1 } from "./types";
|
|
7
|
+
import { readSecureProjectFile } from "./storage";
|
|
8
|
+
import { KernelInvariantError } from "./validation";
|
|
9
|
+
|
|
10
|
+
const ACTIVE_SPEC_RE = /^docs\/specs\/(?!archive\/)[^/]+\.spec\.md$/;
|
|
11
|
+
const ARCHIVED_SPEC_RE = /^docs\/specs\/archive\/[^/]+\.spec\.md$/;
|
|
12
|
+
|
|
13
|
+
/** The archive counterpart of an active planning-artifact path. */
|
|
14
|
+
export function archivePath(path: string): string {
|
|
15
|
+
const matched = path.match(/^docs\/(plans|specs)\/([^/]+)$/);
|
|
16
|
+
if (!matched) throw new KernelInvariantError([`artifact path is not active: ${path}`]);
|
|
17
|
+
return `docs/${matched[1]}/archive/${matched[2]}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The inverse of `archivePath`, for an already-archived Spec path. */
|
|
21
|
+
export function activePath(path: string): string {
|
|
22
|
+
const matched = path.match(/^docs\/(plans|specs)\/archive\/([^/]+)$/);
|
|
23
|
+
if (!matched) throw new KernelInvariantError([`artifact path is not archived: ${path}`]);
|
|
24
|
+
return `docs/${matched[1]}/${matched[2]}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The active Spec the intent binds, or `undefined` when it binds none.
|
|
29
|
+
* Archive counterparts are not part of the binding predicate.
|
|
30
|
+
*/
|
|
31
|
+
export function boundSpecPath(intent: TaskIntentV1): string | undefined {
|
|
32
|
+
const candidates = intent.scope_hint.filter((path) => ACTIVE_SPEC_RE.test(path));
|
|
33
|
+
if (candidates.length > 1)
|
|
34
|
+
throw new KernelInvariantError([`artifact transition requires at most one scope-bound Spec; found ${candidates.length}`]);
|
|
35
|
+
return candidates[0];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Bound Spec bytes when the intent names one. Simple intents have none.
|
|
40
|
+
* `required` still fails closed for callers that demand a Spec.
|
|
41
|
+
*/
|
|
42
|
+
export function readBoundActiveSpec(
|
|
43
|
+
root: string,
|
|
44
|
+
intent: TaskIntentV1,
|
|
45
|
+
required = false,
|
|
46
|
+
): { path: string; content: string } | undefined {
|
|
47
|
+
const specPath = boundSpecPath(intent);
|
|
48
|
+
if (!specPath) {
|
|
49
|
+
if (required) throw new KernelInvariantError(["artifact freeze requires one scope-bound active Spec"]);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return { path: specPath, content: readSecureProjectFile(root, specPath) };
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error instanceof Error && error.message.startsWith("source_missing:") && !required) return undefined;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface BoundSpec {
|
|
61
|
+
active: string;
|
|
62
|
+
archive: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type SpecBindingInspection =
|
|
66
|
+
| { ok: true; binding: BoundSpec | null }
|
|
67
|
+
| {
|
|
68
|
+
ok: false;
|
|
69
|
+
code: "binding_missing" | "binding_incomplete" | "binding_ambiguous";
|
|
70
|
+
missing: string[];
|
|
71
|
+
message: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Enrollment and validate caller: a missing Spec is a simple task; a
|
|
76
|
+
* malformed or incomplete complex binding is refused before any write.
|
|
77
|
+
*/
|
|
78
|
+
export function inspectSpecBinding(intent: TaskIntentV1): SpecBindingInspection {
|
|
79
|
+
const active = intent.scope_hint.filter((path) => ACTIVE_SPEC_RE.test(path));
|
|
80
|
+
const archived = intent.scope_hint.filter((path) => ARCHIVED_SPEC_RE.test(path));
|
|
81
|
+
if (active.length === 0 && archived.length === 0)
|
|
82
|
+
return { ok: true, binding: null };
|
|
83
|
+
if (active.length > 1)
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
code: "binding_ambiguous",
|
|
87
|
+
missing: [],
|
|
88
|
+
message: `enrollment requires at most one scope-bound Spec; found ${active.length}: ${active.join(", ")}`,
|
|
89
|
+
};
|
|
90
|
+
if (active.length === 0) {
|
|
91
|
+
const missing = archived.map((path) => activePath(path));
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
code: "binding_incomplete",
|
|
95
|
+
missing,
|
|
96
|
+
message: `complex Spec binding is incomplete; add ${missing.join(", ")}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, binding: { active: active[0]!, archive: archivePath(active[0]!) } };
|
|
100
|
+
}
|