immune-brain 4.4.0 → 4.5.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 +1 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +19 -2
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +10 -2
- package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +6 -1
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +11 -6
- package/plugins/immune-brain/dist/BASELINE.md +18 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +1039 -465
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -0
- package/plugins/immune-brain/dist/imm-brainstorm.md +21 -0
- package/plugins/immune-brain/dist/imm-loop.md +76 -4
- package/plugins/immune-brain/dist/imm-planner.md +65 -1
- package/plugins/immune-brain/dist/role-prompts/code-review.md +19 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +6 -0
- package/plugins/immune-brain/dist/role-prompts/qa.md +13 -1
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +160 -12
- package/plugins/immune-brain/runtime/assurance/qa.ts +57 -42
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +20 -8
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +321 -60
- package/plugins/immune-brain/runtime/kernel/storage.ts +8 -3
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +19 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +6 -0
- package/plugins/immune-brain/runtime/prompts/qa.md +13 -1
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +1 -1
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +73 -26
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +6 -0
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +194 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +126 -151
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +34 -4
- package/plugins/immune-brain/runtime/v4_runtime.ts +54 -0
- package/plugins/immune-brain/runtime/workspace_scope.ts +31 -4
- package/plugins/immune-brain/skills/BASELINE.md +18 -1
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +5 -0
- package/plugins/immune-brain/skills/imm-loop/SKILL.md +4 -2
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +10 -1
|
@@ -480,10 +480,15 @@ function ensureSecureDirectory(root: string, relativePath: string): string {
|
|
|
480
480
|
return target.path;
|
|
481
481
|
}
|
|
482
482
|
|
|
483
|
-
export function readSecureProjectFile(
|
|
483
|
+
export function readSecureProjectFile(root: string, relativePath: string): string {
|
|
484
|
+
return readSecureProjectBytes(root, relativePath).toString("utf8");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Preserve byte identity while retaining the same secure filesystem checks. */
|
|
488
|
+
export function readSecureProjectBytes(
|
|
484
489
|
root: string,
|
|
485
490
|
relativePath: string,
|
|
486
|
-
):
|
|
491
|
+
): Buffer {
|
|
487
492
|
const candidate = safeCandidate(root, relativePath);
|
|
488
493
|
assertNoSymlinkSegments(candidate.root, candidate.path);
|
|
489
494
|
const before = pathStatOrNull(candidate.path);
|
|
@@ -498,7 +503,7 @@ export function readSecureProjectFile(
|
|
|
498
503
|
const opened = fstatSync(fd);
|
|
499
504
|
if (opened.dev !== before.dev || opened.ino !== before.ino)
|
|
500
505
|
throw new KernelStoreSecurityError(`source identity changed: ${relativePath}`);
|
|
501
|
-
const content = readFileSync(fd
|
|
506
|
+
const content = readFileSync(fd);
|
|
502
507
|
const after = lstatSync(candidate.path);
|
|
503
508
|
if (after.dev !== opened.dev || after.ino !== opened.ino)
|
|
504
509
|
throw new KernelStoreSecurityError(`source identity changed: ${relativePath}`);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by scripts/plugin_versioning.ts from the root package.json.
|
|
2
|
-
export const PLUGIN_VERSION = "4.
|
|
2
|
+
export const PLUGIN_VERSION = "4.5.0" as const;
|
|
@@ -34,6 +34,25 @@ about the fix's scope, not a new bug. A trigger the accepted contract explicitly
|
|
|
34
34
|
excludes is reported as an advisory note against that boundary, never as blocking
|
|
35
35
|
rework.
|
|
36
36
|
|
|
37
|
+
## Invariant and Evidence Coverage
|
|
38
|
+
|
|
39
|
+
Judge whether the change actually closes the invariant its acceptance names,
|
|
40
|
+
not only the path the report mentions. A fix that covers one trigger but leaves
|
|
41
|
+
another trigger of the same invariant is a coverage gap in the same class, not a
|
|
42
|
+
new independent bug: say so and name the class. Check the negative and bound
|
|
43
|
+
behavior, not only the happy path. The six generalized classes worth checking
|
|
44
|
+
are: normalization precedes retrieval, validation precedes merge, the deadline
|
|
45
|
+
covers the whole lifecycle including cleanup, ranking tolerates missing
|
|
46
|
+
embeddings, the budget covers the complete serialized request, and each success,
|
|
47
|
+
failure, and timeout outcome carries its own duration evidence.
|
|
48
|
+
|
|
49
|
+
A required check that is skipped, that matches zero tests, or whose prerequisite
|
|
50
|
+
is absent does not prove the acceptance; do not accept it as evidence. Full
|
|
51
|
+
suite coverage reported at a coarse pass count is not per-invariant evidence.
|
|
52
|
+
Environment, preparation, and cleanup breakdowns are environment findings, not
|
|
53
|
+
assertion findings. Keep automated behavioral or geometry evidence separated
|
|
54
|
+
from a human quality judgment.
|
|
55
|
+
|
|
37
56
|
Return exactly one JSON object with the fields required by the Loop review
|
|
38
57
|
contract: `contract`, `role`, `task_id`, `snapshot_digest`, `decision` (`pass`
|
|
39
58
|
or `rework`), and for `pass` include `approval` (`kind`, `authority_role`,
|
|
@@ -6,6 +6,12 @@ same-boundary follow-up) in the current Parent conversation. New helpers or
|
|
|
6
6
|
tests inside an approved directory or glob do not require a revision. Keep
|
|
7
7
|
every edit inside the authorized envelope; do not stage unrelated user files. Do not discover or load a Pi Skill.
|
|
8
8
|
|
|
9
|
+
On a batch `handoff.role=executor`, implement only its named enrolled child;
|
|
10
|
+
the batch stays `running` with that child `enrolled`. Return to the Parent after
|
|
11
|
+
scoped implementation, staging and focused diagnostics. Enrollment itself does
|
|
12
|
+
not start QA; the Parent owns the subsequent Kernel Assurance and foreground
|
|
13
|
+
Review handoff under the same still-valid batch authorization.
|
|
14
|
+
|
|
9
15
|
Before handoff, run the permitted diagnostic checks and return commands and
|
|
10
16
|
outcomes to the Parent as structured diagnostic evidence. The read-only Loop
|
|
11
17
|
runtime action only constructs the dispatch envelope; it does not store
|
|
@@ -20,4 +20,16 @@ Plan remains a literal-user decision.
|
|
|
20
20
|
For elevated-risk work, check the latest referenced Spec's Design Conformance
|
|
21
21
|
against implementation evidence. A local implementation mismatch is `rework`;
|
|
22
22
|
return `rework` with bounded repair evidence. A structural or intended design
|
|
23
|
-
change is `replan`; return `replan` with the missing design fact. QA must not approve a changed design or silently accept a deviation.
|
|
23
|
+
change is `replan`; return `replan` with the missing design fact. QA must not approve a changed design or silently accept a deviation.
|
|
24
|
+
|
|
25
|
+
Judge the recorded execution evidence, not the descriptor text. A required check
|
|
26
|
+
proves an acceptance invariant only when it actually executed and passed: an
|
|
27
|
+
absent dependency, a prerequisite preparation failure, a skipped required check,
|
|
28
|
+
or a check that matched zero tests is a failure, not a pass. Structural
|
|
29
|
+
"Enrollment ready" or "descriptor valid" is not execution evidence, and a
|
|
30
|
+
contract string proves nothing about provider behavior. Automated behavioral or
|
|
31
|
+
geometry evidence cannot stand in for a human motion-quality judgment, and vice
|
|
32
|
+
versa; keep the two claims separate. Environment, preparation, and cleanup
|
|
33
|
+
breakdowns are environment findings, not assertion findings: report them with
|
|
34
|
+
the descriptor reference and observed outcome rather than as a code defect.
|
|
35
|
+
If the checkpoint is `awaiting_user_successor_decision`, stop without dispatch; only a literal user may invoke `--approve-successor`.
|
|
@@ -20,11 +20,12 @@ import { readBackendClaim } from "../kernel/backend_claim";
|
|
|
20
20
|
import { computeBatchPlanDigest, type BatchAuthorizationBinding } from "../kernel/batch_authority";
|
|
21
21
|
import { readGitHead } from "../kernel/pi_canary_prepare";
|
|
22
22
|
import { readTaskIntent } from "../kernel/intent";
|
|
23
|
-
import { localRunId, readAuditTaskPair, readTaskRecordRaw, readWorkspaceStateRaw } from "../kernel/storage";
|
|
23
|
+
import { localRunId, readAuditTaskPair, readTaskRecordRaw, readWorkspaceStateRaw, reconcileKernelAuthority } from "../kernel/storage";
|
|
24
24
|
import { pathMatchesScope } from "../workspace_scope";
|
|
25
|
-
import {
|
|
25
|
+
import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHead, type ReconfirmationSnapshot } from "./batch_reconfirmation";
|
|
26
|
+
import { DEFAULT_DEADLINE_MS, projectBatchPlan } from "./batch_plan";
|
|
26
27
|
import { batchReason, type BatchReasonKey } from "./batch_reasons";
|
|
27
|
-
import { isTerminalBatchState, type BatchRunStateRecord } from "./batch_state";
|
|
28
|
+
import { isTerminalBatchState, readBatchRunState, type BatchRunStateRecord } from "./batch_state";
|
|
28
29
|
import { readRunRowByTask, withKernelRead } from "../kernel/sqlite_store";
|
|
29
30
|
import type {
|
|
30
31
|
BatchPlanBudget,
|
|
@@ -44,6 +45,8 @@ export interface BatchPreflightOptions {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export interface BatchPreflightProjection {
|
|
48
|
+
/** Captured observations only; never serialized or used as capability. */
|
|
49
|
+
reconfirmation?: ReconfirmationSnapshot;
|
|
47
50
|
initiative_slug: string;
|
|
48
51
|
batch_branch: string;
|
|
49
52
|
is_resuming: boolean;
|
|
@@ -139,7 +142,13 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
|
|
|
139
142
|
return { corrupt: true, path: file };
|
|
140
143
|
}
|
|
141
144
|
if (isTerminalBatchState(candidate.batch_state)) continue;
|
|
142
|
-
|
|
145
|
+
try {
|
|
146
|
+
const record = readBatchRunState(root, file.slice(0, -5));
|
|
147
|
+
if (record) return { corrupt: false, record };
|
|
148
|
+
} catch {
|
|
149
|
+
// Reuse the state owner's full validation before offering a renewal.
|
|
150
|
+
}
|
|
151
|
+
return { corrupt: true, path: file };
|
|
143
152
|
}
|
|
144
153
|
return null;
|
|
145
154
|
}
|
|
@@ -285,8 +294,8 @@ function porcelainEntries(root: string): Array<{ code: string; path: string }> |
|
|
|
285
294
|
// an unstaged modification (" M path") and silently mis-scoped the path.
|
|
286
295
|
const statusProc = spawnSync(
|
|
287
296
|
"git",
|
|
288
|
-
["-C", root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"],
|
|
289
|
-
{ encoding: "utf8" },
|
|
297
|
+
["-C", root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all", "--ignore-submodules=none"],
|
|
298
|
+
{ encoding: "utf8", env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } },
|
|
290
299
|
);
|
|
291
300
|
if (statusProc.status !== 0) return null;
|
|
292
301
|
const entries: Array<{ code: string; path: string }> = [];
|
|
@@ -338,7 +347,11 @@ async function projectPlanSurface(input: {
|
|
|
338
347
|
let budget: BatchPlanBudget;
|
|
339
348
|
|
|
340
349
|
if (isResuming && existingBatch) {
|
|
341
|
-
|
|
350
|
+
// Candidate only: renewal uses the same default window as a fresh plan,
|
|
351
|
+
// while preserving the confirmed count limits and the persisted record.
|
|
352
|
+
budget = { ...existingBatch.budget };
|
|
353
|
+
if (Date.parse(budget.deadline_at) <= Date.parse(now))
|
|
354
|
+
budget.deadline_at = new Date(Date.parse(now) + DEFAULT_DEADLINE_MS).toISOString();
|
|
342
355
|
try {
|
|
343
356
|
recoveryChildren = existingBatch.children.map((c) => {
|
|
344
357
|
const intentPath = `docs/plans/${c.task_id}.intent.json`;
|
|
@@ -465,6 +478,11 @@ export async function projectBatchPreflight(
|
|
|
465
478
|
|
|
466
479
|
// 1. Active workspace claim (pre-confirmation).
|
|
467
480
|
const activeTaskId = readActiveClaimTaskId(root);
|
|
481
|
+
if (activeTaskId) {
|
|
482
|
+
const authority = reconcileKernelAuthority(root, activeTaskId);
|
|
483
|
+
if (authority.state === "authority_conflict")
|
|
484
|
+
return reject("kernel_authority_conflict", authority.diagnostic ?? "unverifiable workspace authority");
|
|
485
|
+
}
|
|
468
486
|
const ownClaim =
|
|
469
487
|
isResuming && activeTaskId !== null && isOwnBatchClaim(root, activeRecord!, activeTaskId, batchBranch);
|
|
470
488
|
if (activeTaskId && !ownClaim)
|
|
@@ -537,22 +555,26 @@ export async function projectBatchPreflight(
|
|
|
537
555
|
});
|
|
538
556
|
if (!planSurface.ok)
|
|
539
557
|
return reject(planSurface.key, planSurface.detail);
|
|
558
|
+
let reconfirmation: ReconfirmationSnapshot | undefined;
|
|
559
|
+
if (activeRecord && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
|
|
560
|
+
try { reconfirmation = await captureBatchReconfirmation(root, activeRecord, planSurface.surface.recovery_children); }
|
|
561
|
+
catch { return reject("plan_projection_failed", "batch plan reconfirmation is not eligible"); }
|
|
562
|
+
}
|
|
540
563
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
risk_by_task: planSurface.surface.risk_by_task,
|
|
553
|
-
excluded: planSurface.surface.excluded,
|
|
554
|
-
},
|
|
564
|
+
const projection: BatchPreflightProjection = {
|
|
565
|
+
initiative_slug: initiativeSlug,
|
|
566
|
+
batch_branch: batchBranch,
|
|
567
|
+
is_resuming: isResuming,
|
|
568
|
+
existing_batch: existingBatch,
|
|
569
|
+
base_head: baseHead,
|
|
570
|
+
budget: planSurface.surface.budget,
|
|
571
|
+
plan_digest: planSurface.surface.plan_digest,
|
|
572
|
+
recovery_children: planSurface.surface.recovery_children,
|
|
573
|
+
risk_by_task: planSurface.surface.risk_by_task,
|
|
574
|
+
excluded: planSurface.surface.excluded,
|
|
555
575
|
};
|
|
576
|
+
if (reconfirmation) Object.defineProperty(projection, "reconfirmation", { value: reconfirmation });
|
|
577
|
+
return { ok: true, projection };
|
|
556
578
|
}
|
|
557
579
|
|
|
558
580
|
/**
|
|
@@ -678,22 +700,29 @@ export async function authorizeBatch<HostRejection>(
|
|
|
678
700
|
// so its expiry is the deadline the literal user confirmed rather than a fixed
|
|
679
701
|
// window that lapses while a child is parked on a foreground Review.
|
|
680
702
|
const isExistingExpired = isResuming && Date.parse(existingBatch!.authorization_expires_at) <= Date.now();
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
:
|
|
703
|
+
const branchBefore = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
|
|
704
|
+
if (branchBefore.status !== 0)
|
|
705
|
+
return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
|
|
684
706
|
|
|
685
707
|
// ADR-0005 Decision 1: a resume of an intact, still-binding authorization
|
|
686
708
|
// reuses it instead of opening a second native gate. Anything that no longer
|
|
687
709
|
// binds falls through to the gate below, which names the reason.
|
|
710
|
+
const ownsUnpersistedHead = isResuming && existingBatch !== null && existingBatch.plan_digest === planDigest &&
|
|
711
|
+
expectedBatchHead(existingBatch) !== baseHead && ownUnpersistedBatchHead(root, existingBatch, baseHead);
|
|
712
|
+
if (isResuming && existingBatch && !existingBatch.commits.length && existingBatch.children[0]?.state === "settled" &&
|
|
713
|
+
expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead)
|
|
714
|
+
return { outcome: "rejected", rejection: batchRejection("head_moved", "first unpersisted commit provenance is invalid") };
|
|
688
715
|
const reuseBlockers: string[] = [];
|
|
689
716
|
if (isResuming && existingBatch) {
|
|
690
717
|
if (isExistingExpired) reuseBlockers.push("batch_authorization_expired");
|
|
718
|
+
if (Date.parse(existingBatch.budget.deadline_at) <= Date.now()) reuseBlockers.push("batch_budget_expired");
|
|
691
719
|
if (existingBatch.batch_state !== "running") reuseBlockers.push("batch_not_running");
|
|
692
720
|
if (existingBatch.plan_digest !== planDigest) reuseBlockers.push("batch_plan_digest_changed");
|
|
693
721
|
if (existingBatch.branch !== batchBranch) reuseBlockers.push("batch_branch_changed");
|
|
694
|
-
if (expectedBatchHead(existingBatch) !== baseHead) reuseBlockers.push("batch_head_lineage_moved");
|
|
722
|
+
if (expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead) reuseBlockers.push("batch_head_lineage_moved");
|
|
695
723
|
}
|
|
696
724
|
const reuseAuthorization = isResuming && reuseBlockers.length === 0;
|
|
725
|
+
const expiresAt = reuseAuthorization ? existingBatch!.authorization_expires_at : budget.deadline_at;
|
|
697
726
|
|
|
698
727
|
const batchId = isResuming && existingBatch ? existingBatch.batch_id : `batch-${initiativeSlug}-${randomUUID()}`;
|
|
699
728
|
const facts: BatchConfirmationFacts = {
|
|
@@ -736,7 +765,7 @@ export async function authorizeBatch<HostRejection>(
|
|
|
736
765
|
if (drift.plan_digest !== planDigest)
|
|
737
766
|
return { outcome: "rejected", rejection: batchRejection("plan_changed", drift.plan_digest) };
|
|
738
767
|
if (drift.base_head === null) return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
|
|
739
|
-
if (drift.base_head !== baseHead)
|
|
768
|
+
if (drift.base_head !== baseHead || (ownsUnpersistedHead && !ownUnpersistedBatchHead(root, existingBatch!, baseHead)))
|
|
740
769
|
return { outcome: "rejected", rejection: batchRejection("head_moved", drift.base_head) };
|
|
741
770
|
|
|
742
771
|
// The drift projection's own claim read happened before its plan read; this
|
|
@@ -748,6 +777,23 @@ export async function authorizeBatch<HostRejection>(
|
|
|
748
777
|
if (finalClaimTaskId && !finalOwnClaim)
|
|
749
778
|
return { outcome: "rejected", rejection: batchRejection("claim_appeared_during_confirmation", finalClaimTaskId) };
|
|
750
779
|
|
|
780
|
+
// Confirmation cannot adopt a changed record or extend its displayed window
|
|
781
|
+
// after the user answers. Re-read the clock and branch after the async checks.
|
|
782
|
+
if (isResuming) {
|
|
783
|
+
const current = findExistingActiveBatch(root, initiativeSlug);
|
|
784
|
+
if (!current || current.corrupt || JSON.stringify(current.record) !== JSON.stringify(existingBatch))
|
|
785
|
+
return { outcome: "rejected", rejection: batchRejection("plan_changed") };
|
|
786
|
+
}
|
|
787
|
+
const branchAfter = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
|
|
788
|
+
if (branchAfter.status !== 0 || branchAfter.stdout !== branchBefore.stdout)
|
|
789
|
+
return { outcome: "rejected", rejection: batchRejection("confirmation_failed", "Git branch moved after native confirmation") };
|
|
790
|
+
if (Date.parse(expiresAt) <= Date.now())
|
|
791
|
+
return { outcome: "rejected", rejection: batchRejection("confirmation_failed", "confirmed batch deadline expired before authorization issuance") };
|
|
792
|
+
|
|
793
|
+
if (projection.reconfirmation) {
|
|
794
|
+
try { projection.reconfirmation.assertUnchanged(); }
|
|
795
|
+
catch { return { outcome: "rejected", rejection: batchRejection("plan_changed") }; }
|
|
796
|
+
}
|
|
751
797
|
const binding: BatchAuthorizationBinding = {
|
|
752
798
|
batch_id: batchId,
|
|
753
799
|
initiative_slug: initiativeSlug,
|
|
@@ -760,6 +806,7 @@ export async function authorizeBatch<HostRejection>(
|
|
|
760
806
|
expires_at: expiresAt,
|
|
761
807
|
nonce: options.nonce,
|
|
762
808
|
};
|
|
809
|
+
if (projection.reconfirmation) retainReconfirmation(binding.nonce, binding.expires_at, projection.reconfirmation);
|
|
763
810
|
return {
|
|
764
811
|
outcome: "authorized",
|
|
765
812
|
batch_id: batchId,
|
|
@@ -13,6 +13,7 @@ export type BatchReasonKey =
|
|
|
13
13
|
| "invalid_slug"
|
|
14
14
|
| "batch_state_unreadable"
|
|
15
15
|
| "claim_already_active"
|
|
16
|
+
| "kernel_authority_conflict"
|
|
16
17
|
| "git_head_unreadable"
|
|
17
18
|
| "branch_already_exists"
|
|
18
19
|
| "git_status_unreadable"
|
|
@@ -59,6 +60,11 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
|
|
|
59
60
|
reason: (detail: string) => `an active workspace claim already exists for task: ${detail}`,
|
|
60
61
|
recovery_action: "resolve or stop the active task before starting a batch in the current Host",
|
|
61
62
|
},
|
|
63
|
+
kernel_authority_conflict: {
|
|
64
|
+
state: "rejected",
|
|
65
|
+
reason: (detail: string) => `Kernel authority conflict: ${detail}`,
|
|
66
|
+
recovery_action: "resolve the Kernel storage conflict before resuming in the current Host",
|
|
67
|
+
},
|
|
62
68
|
git_head_unreadable: {
|
|
63
69
|
state: "rejected",
|
|
64
70
|
reason: (detail: string) => detail,
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Read-only eligibility for a first, zero-commit plan reconfirmation.
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { computeBatchPlanDigest, type BatchPlanChild as DigestChild } from "../kernel/batch_authority";
|
|
4
|
+
import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
|
|
5
|
+
import { projectAssurance } from "../kernel/assurance_projection";
|
|
6
|
+
import { localRunId, readAuditTaskPair, readSecureProjectBytes, readWorkspaceStateRaw } from "../kernel/storage";
|
|
7
|
+
import { readRunRowByTask, withKernelRead } from "../kernel/sqlite_store";
|
|
8
|
+
import { taskRevisionIdentity, taskCommitRevisionIdentity, pathMatchesScope } from "../workspace_scope";
|
|
9
|
+
import { projectTask } from "../kernel/completion";
|
|
10
|
+
import { isTerminalBatchState, type BatchRunStateRecord } from "./batch_state";
|
|
11
|
+
import type { BatchPlanChild } from "./types";
|
|
12
|
+
|
|
13
|
+
const oid = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
14
|
+
const id = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
15
|
+
const batchAuthor = process.env.GIT_AUTHOR_NAME || "Immune-Brain Batch";
|
|
16
|
+
const decode = (bytes: Uint8Array) => new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
17
|
+
function refuse(): never { throw new Error("plan_digest mismatch: batch plan reconfirmation is not eligible"); }
|
|
18
|
+
function matchesLocalProof(localJson: string | null, exported: object): boolean {
|
|
19
|
+
return localJson !== null && JSON.stringify(Object.entries(JSON.parse(localJson)).sort()) === JSON.stringify(Object.entries(exported).sort());
|
|
20
|
+
}
|
|
21
|
+
function git(root: string, args: string[]): string {
|
|
22
|
+
const r = spawnSync("git", ["-C", root, ...args], { encoding: "buffer", maxBuffer: 262144, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } });
|
|
23
|
+
if (r.status !== 0) refuse();
|
|
24
|
+
return decode(r.stdout);
|
|
25
|
+
}
|
|
26
|
+
// Recognize only the first commit/persist crash window; never query Git with
|
|
27
|
+
// an OID read from mutable evidence. The existing Git port performs final adoption.
|
|
28
|
+
export function ownUnpersistedBatchHead(root: string, record: BatchRunStateRecord, head: string): boolean {
|
|
29
|
+
try {
|
|
30
|
+
if (!oid.test(head) || !oid.test(record.base_head) || !id.test(record.batch_id) || record.commits.length ||
|
|
31
|
+
record.children[0]?.state !== "settled" || record.children.some(c => c.commit !== null)) return false;
|
|
32
|
+
const task = record.children[0].task_id;
|
|
33
|
+
if (!id.test(task)) return false;
|
|
34
|
+
const evidence = JSON.parse(decode(readSecureProjectBytes(root, `.imm/state/batches/commits/${record.batch_id}-${task}.json`)));
|
|
35
|
+
if (evidence.contract !== "assurance_kernel/batch_commit_evidence/v1" || evidence.batch_id !== record.batch_id ||
|
|
36
|
+
evidence.task_id !== task || evidence.commit !== head || evidence.parent_head !== record.base_head) return false;
|
|
37
|
+
if (git(root, ["symbolic-ref", "--short", "HEAD"]).trim() !== record.branch) return false;
|
|
38
|
+
const parents = git(root, ["rev-list", "--parents", "-n", "1", head]).trim().split(/\s+/).slice(1);
|
|
39
|
+
if (parents.length !== 1 || parents[0] !== record.base_head) return false;
|
|
40
|
+
const metadata = git(root, ["show", "-s", "--format=%an%x00%s%x00%(trailers:key=Immune-Brain-Batch,valueonly)", head]).split("\0");
|
|
41
|
+
if (metadata[0] !== batchAuthor || !metadata[1]?.startsWith(`imm(${task}):`) || metadata[2]?.trim() !== record.batch_id) return false;
|
|
42
|
+
const run = localRunId(root, task);
|
|
43
|
+
if (!run || !id.test(run)) return false;
|
|
44
|
+
const pair = readAuditTaskPair(root, task, run);
|
|
45
|
+
if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4" || pair.record.lifecycle !== "done" || pair.proof.terminal_lifecycle !== "done") return false;
|
|
46
|
+
const local = withKernelRead(root, db => readRunRowByTask(db, task));
|
|
47
|
+
if (!local || local.run_id !== run || local.state !== "done" || !matchesLocalProof(local.terminal_proof_json, pair.proof)) return false;
|
|
48
|
+
for (const file of ["task-record.json", "terminal-proof.json"]) {
|
|
49
|
+
const path = `.imm/audit/${task}/${run}/${file}`;
|
|
50
|
+
if (!readSecureProjectBytes(root, path).equals(Buffer.from(git(root, ["show", `${head}:${path}`])))) return false;
|
|
51
|
+
}
|
|
52
|
+
const paths = git(root, ["diff-tree", "--no-commit-id", "--name-only", "--no-renames", "-z", "-r", head]).split("\0").filter(Boolean);
|
|
53
|
+
if (!paths.length || paths.some(path => !path.startsWith(`.imm/audit/${task}/${run}/`) &&
|
|
54
|
+
!pair.record.intent_snapshot.scope_hint.some(s => pathMatchesScope(path, s)))) return false;
|
|
55
|
+
const identity = taskCommitRevisionIdentity(root, pair.record.intent_snapshot.scope_hint, pair.record.git_base_head, head);
|
|
56
|
+
if (!projectTask(pair.record.intent_snapshot, pair.record, identity.diff_hash, pair.record.intent_ref.content_hash, identity.changed_paths).complete) return false;
|
|
57
|
+
return git(root, ["rev-parse", "HEAD"]).trim() === head;
|
|
58
|
+
} catch { return false; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ReconfirmationSnapshot {
|
|
62
|
+
root: string;
|
|
63
|
+
plan_digest: string;
|
|
64
|
+
stateBytes: Buffer;
|
|
65
|
+
/** Synchronous read-only check repeated under the state owner's lock. */
|
|
66
|
+
assertUnchanged(): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Ephemeral captured observations, not authority: the runner independently
|
|
70
|
+
// validates the native capability. Entries expire and are discarded on application.
|
|
71
|
+
const confirmations = new Map<string, { snapshot: ReconfirmationSnapshot; expires: number }>();
|
|
72
|
+
export function retainReconfirmation(nonce: string, expires: string, snapshot: ReconfirmationSnapshot): void {
|
|
73
|
+
for (const [key, value] of confirmations) if (value.expires <= Date.now()) confirmations.delete(key);
|
|
74
|
+
if (confirmations.has(nonce)) refuse();
|
|
75
|
+
confirmations.set(nonce, { snapshot, expires: Date.parse(expires) });
|
|
76
|
+
}
|
|
77
|
+
export function takeReconfirmation(nonce: string): ReconfirmationSnapshot {
|
|
78
|
+
const found = confirmations.get(nonce); confirmations.delete(nonce);
|
|
79
|
+
if (!found || found.expires <= Date.now()) refuse();
|
|
80
|
+
return found.snapshot;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function captureBatchReconfirmation(root: string, record: BatchRunStateRecord, children: BatchPlanChild[]): Promise<ReconfirmationSnapshot> {
|
|
84
|
+
if (!id.test(record.batch_id) || !oid.test(record.base_head) || isTerminalBatchState(record.batch_state) || record.commits.length ||
|
|
85
|
+
record.children.some(c => c.commit !== null || c.state === "committed") || record.children.length !== children.length || !children.length) refuse();
|
|
86
|
+
const assertNoCommitEvidence = () => {
|
|
87
|
+
for (const child of record.children) {
|
|
88
|
+
if (!id.test(child.task_id)) refuse();
|
|
89
|
+
const path = `.imm/state/batches/commits/${record.batch_id}-${child.task_id}.json`;
|
|
90
|
+
let missing = false;
|
|
91
|
+
try { readSecureProjectBytes(root, path); }
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error instanceof Error && error.message === `source_missing: ${path}`) missing = true;
|
|
94
|
+
else refuse();
|
|
95
|
+
}
|
|
96
|
+
if (!missing) refuse();
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
assertNoCommitEvidence();
|
|
100
|
+
const statePath = `.imm/state/batches/${record.batch_id}.json`;
|
|
101
|
+
const stateBytes = readSecureProjectBytes(root, statePath);
|
|
102
|
+
if (JSON.stringify(JSON.parse(decode(stateBytes))) !== JSON.stringify(record)) refuse();
|
|
103
|
+
const files = new Map<string, Buffer>();
|
|
104
|
+
const capture = (path: string) => { const bytes = readSecureProjectBytes(root, path); files.set(path, bytes); return bytes; };
|
|
105
|
+
const oldChildren: DigestChild[] = [];
|
|
106
|
+
const changed: Array<{ task: string; run: string; identity: string; local: string }> = [];
|
|
107
|
+
let scope: string[] | null = null;
|
|
108
|
+
const workspace = readWorkspaceStateRaw(root);
|
|
109
|
+
if (workspace.state.current_working !== null) refuse();
|
|
110
|
+
for (const [index, child] of children.entries()) {
|
|
111
|
+
const previous = record.children[index]!;
|
|
112
|
+
if (!id.test(child.task_id) || child.task_id !== previous.task_id || child.slice_id !== previous.slice_id ||
|
|
113
|
+
JSON.stringify(child.blocked_by) !== JSON.stringify(previous.blocked_by) || child.intent_path !== `docs/plans/${child.task_id}.intent.json`) refuse();
|
|
114
|
+
const old = parseTaskIntentV1(JSON.parse(git(root, ["show", `${record.base_head}:${child.intent_path}`])));
|
|
115
|
+
if (old.task_id !== child.task_id) refuse();
|
|
116
|
+
const oldHash = canonicalIntentHash(old);
|
|
117
|
+
oldChildren.push({ task_id: child.task_id, blocked_by: [...child.blocked_by], intent_path: child.intent_path, intent_revision: old.revision, intent_content_hash: oldHash });
|
|
118
|
+
const currentBytes = capture(child.intent_path);
|
|
119
|
+
const current = parseTaskIntentV1(JSON.parse(decode(currentBytes)));
|
|
120
|
+
const currentHash = canonicalIntentHash(current);
|
|
121
|
+
const tracked = readTaskIntent(root, child.task_id, child.intent_path);
|
|
122
|
+
if (currentHash !== tracked.content_hash || currentHash !== child.intent_content_hash || current.revision !== child.intent_revision) refuse();
|
|
123
|
+
if (currentHash === oldHash) continue;
|
|
124
|
+
if (scope !== null || previous.state === "pending" || previous.state === "skipped_blocked" || current.revision <= old.revision || current.goal !== old.goal || current.owner !== old.owner) refuse();
|
|
125
|
+
const run = localRunId(root, child.task_id);
|
|
126
|
+
if (!run || !id.test(run)) refuse();
|
|
127
|
+
const local = withKernelRead(root, db => readRunRowByTask(db, child.task_id));
|
|
128
|
+
if (!local || local.run_id !== run || local.state !== "done" || local.enrollment_event_id !== `enroll-${child.task_id}-${local.created_at}` ||
|
|
129
|
+
Date.parse(local.created_at) > Date.parse(record.updated_at)) refuse();
|
|
130
|
+
const pair = readAuditTaskPair(root, child.task_id, run);
|
|
131
|
+
if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4") refuse();
|
|
132
|
+
const r = pair.record;
|
|
133
|
+
if (r.lifecycle !== "done" || r.intent_ref.content_hash !== currentHash || canonicalIntentHash(r.intent_snapshot) !== currentHash ||
|
|
134
|
+
r.git_base_head !== record.base_head || pair.proof.terminal_lifecycle !== "done") refuse();
|
|
135
|
+
const terminal = r.history.at(-1);
|
|
136
|
+
if (terminal?.type !== "complete" || terminal.from_state !== "active:frozen" || terminal.to_state !== "done:frozen" || terminal.id !== pair.proof.terminal_event_id) refuse();
|
|
137
|
+
const recordPath = `.imm/audit/${child.task_id}/${run}/task-record.json`;
|
|
138
|
+
const proofPath = `.imm/audit/${child.task_id}/${run}/terminal-proof.json`;
|
|
139
|
+
if (!capture(recordPath).equals(Buffer.from(local.record_json))) refuse();
|
|
140
|
+
const capturedProof = JSON.parse(decode(capture(proofPath)));
|
|
141
|
+
if (!matchesLocalProof(local.terminal_proof_json, capturedProof) || !matchesLocalProof(local.terminal_proof_json, pair.proof)) refuse();
|
|
142
|
+
const identity = taskRevisionIdentity(root, r.intent_snapshot.scope_hint, r.git_base_head, r.task_id);
|
|
143
|
+
const projection = await projectAssurance(root, child.task_id, (cwd, task) => {
|
|
144
|
+
if (task.contract !== "assurance_kernel/task_record/v4" || !task.git_base_head) refuse();
|
|
145
|
+
return taskRevisionIdentity(cwd, task.intent_snapshot.scope_hint, task.git_base_head, task.task_id);
|
|
146
|
+
});
|
|
147
|
+
if (projection.error || projection.claim || projection.projection.run_id !== run || projection.projection.lifecycle !== "done" ||
|
|
148
|
+
!projection.projection.completion_ready || projection.projection.intent_content_hash !== currentHash || projection.projection.diff_hash !== identity.diff_hash ||
|
|
149
|
+
!projection.projection.fresh_approval_kinds.includes("qa") ||
|
|
150
|
+
(projection.projection.risk !== "routine" && !projection.projection.fresh_approval_kinds.includes("review"))) refuse();
|
|
151
|
+
scope = r.intent_snapshot.scope_hint;
|
|
152
|
+
changed.push({ task: child.task_id, run, identity: JSON.stringify(identity), local: JSON.stringify(local) });
|
|
153
|
+
}
|
|
154
|
+
if (computeBatchPlanDigest(oldChildren) !== record.plan_digest || changed.length !== 1 || scope === null) refuse();
|
|
155
|
+
const confirmedScope = scope;
|
|
156
|
+
const owner = changed[0]!;
|
|
157
|
+
const status = () => git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none"]);
|
|
158
|
+
const statusBytes = status();
|
|
159
|
+
const entries = statusBytes.split("\0").filter(Boolean);
|
|
160
|
+
// This bounded path refuses renames; staged regular edits/additions/deletions
|
|
161
|
+
// are enough for a revised child and cannot hide a second source path.
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
const code = entry.slice(0, 2), path = entry.slice(3);
|
|
164
|
+
if (code[1] !== " " || !["A", "M", "D"].includes(code[0]!) ||
|
|
165
|
+
(!path.startsWith(`.imm/audit/${owner.task}/${owner.run}/`) &&
|
|
166
|
+
!confirmedScope.some(s => pathMatchesScope(path, s)))) refuse();
|
|
167
|
+
}
|
|
168
|
+
const flagsBytes = git(root, ["ls-files", "-v"]);
|
|
169
|
+
const flags = flagsBytes.split("\n").filter(Boolean);
|
|
170
|
+
if (flags.some(line => line[0] === "S" || line[0] !== line[0]?.toUpperCase())) refuse();
|
|
171
|
+
const head = () => git(root, ["rev-parse", "HEAD"]).trim();
|
|
172
|
+
const branch = () => git(root, ["symbolic-ref", "--short", "HEAD"]).trim();
|
|
173
|
+
if (head() !== record.base_head || branch() !== record.branch) refuse();
|
|
174
|
+
const assertDeliveryUnchanged = () => {
|
|
175
|
+
for (const c of changed) {
|
|
176
|
+
const pair = readAuditTaskPair(root, c.task, c.run);
|
|
177
|
+
if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4" || !pair.record.git_base_head) refuse();
|
|
178
|
+
const local = withKernelRead(root, db => readRunRowByTask(db, c.task));
|
|
179
|
+
const diff = taskRevisionIdentity(root, pair.record.intent_snapshot.scope_hint, pair.record.git_base_head, c.task);
|
|
180
|
+
if (localRunId(root, c.task) !== c.run || JSON.stringify(local) !== c.local || JSON.stringify(diff) !== c.identity) refuse();
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const assertUnchanged = () => {
|
|
184
|
+
assertNoCommitEvidence();
|
|
185
|
+
if (!readSecureProjectBytes(root, statePath).equals(stateBytes) || head() !== record.base_head || branch() !== record.branch || status() !== statusBytes || git(root, ["ls-files", "-v"]) !== flagsBytes ||
|
|
186
|
+
readWorkspaceStateRaw(root).revision !== workspace.revision) refuse();
|
|
187
|
+
for (const [path, bytes] of files) if (!readSecureProjectBytes(root, path).equals(bytes)) refuse();
|
|
188
|
+
assertDeliveryUnchanged();
|
|
189
|
+
};
|
|
190
|
+
assertUnchanged();
|
|
191
|
+
const plan_digest = computeBatchPlanDigest(oldChildren.map((old, index) => ({ ...old,
|
|
192
|
+
intent_revision: children[index]!.intent_revision!, intent_content_hash: children[index]!.intent_content_hash! })));
|
|
193
|
+
return { root, plan_digest, stateBytes, assertUnchanged };
|
|
194
|
+
}
|