immune-brain 4.4.1 → 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/dist/BASELINE.md +18 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +675 -292
- 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 +41 -2
- 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/qa.md +13 -1
- 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/qa.md +13 -1
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +35 -18
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +194 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +42 -5
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +23 -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`,
|
|
@@ -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`.
|
|
@@ -22,6 +22,7 @@ import { readGitHead } from "../kernel/pi_canary_prepare";
|
|
|
22
22
|
import { readTaskIntent } from "../kernel/intent";
|
|
23
23
|
import { localRunId, readAuditTaskPair, readTaskRecordRaw, readWorkspaceStateRaw, reconcileKernelAuthority } from "../kernel/storage";
|
|
24
24
|
import { pathMatchesScope } from "../workspace_scope";
|
|
25
|
+
import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHead, type ReconfirmationSnapshot } from "./batch_reconfirmation";
|
|
25
26
|
import { DEFAULT_DEADLINE_MS, projectBatchPlan } from "./batch_plan";
|
|
26
27
|
import { batchReason, type BatchReasonKey } from "./batch_reasons";
|
|
27
28
|
import { isTerminalBatchState, readBatchRunState, type BatchRunStateRecord } from "./batch_state";
|
|
@@ -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;
|
|
@@ -291,8 +294,8 @@ function porcelainEntries(root: string): Array<{ code: string; path: string }> |
|
|
|
291
294
|
// an unstaged modification (" M path") and silently mis-scoped the path.
|
|
292
295
|
const statusProc = spawnSync(
|
|
293
296
|
"git",
|
|
294
|
-
["-C", root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"],
|
|
295
|
-
{ 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" } },
|
|
296
299
|
);
|
|
297
300
|
if (statusProc.status !== 0) return null;
|
|
298
301
|
const entries: Array<{ code: string; path: string }> = [];
|
|
@@ -552,22 +555,26 @@ export async function projectBatchPreflight(
|
|
|
552
555
|
});
|
|
553
556
|
if (!planSurface.ok)
|
|
554
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
|
+
}
|
|
555
563
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
risk_by_task: planSurface.surface.risk_by_task,
|
|
568
|
-
excluded: planSurface.surface.excluded,
|
|
569
|
-
},
|
|
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,
|
|
570
575
|
};
|
|
576
|
+
if (reconfirmation) Object.defineProperty(projection, "reconfirmation", { value: reconfirmation });
|
|
577
|
+
return { ok: true, projection };
|
|
571
578
|
}
|
|
572
579
|
|
|
573
580
|
/**
|
|
@@ -700,6 +707,11 @@ export async function authorizeBatch<HostRejection>(
|
|
|
700
707
|
// ADR-0005 Decision 1: a resume of an intact, still-binding authorization
|
|
701
708
|
// reuses it instead of opening a second native gate. Anything that no longer
|
|
702
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") };
|
|
703
715
|
const reuseBlockers: string[] = [];
|
|
704
716
|
if (isResuming && existingBatch) {
|
|
705
717
|
if (isExistingExpired) reuseBlockers.push("batch_authorization_expired");
|
|
@@ -707,7 +719,7 @@ export async function authorizeBatch<HostRejection>(
|
|
|
707
719
|
if (existingBatch.batch_state !== "running") reuseBlockers.push("batch_not_running");
|
|
708
720
|
if (existingBatch.plan_digest !== planDigest) reuseBlockers.push("batch_plan_digest_changed");
|
|
709
721
|
if (existingBatch.branch !== batchBranch) reuseBlockers.push("batch_branch_changed");
|
|
710
|
-
if (expectedBatchHead(existingBatch) !== baseHead) reuseBlockers.push("batch_head_lineage_moved");
|
|
722
|
+
if (expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead) reuseBlockers.push("batch_head_lineage_moved");
|
|
711
723
|
}
|
|
712
724
|
const reuseAuthorization = isResuming && reuseBlockers.length === 0;
|
|
713
725
|
const expiresAt = reuseAuthorization ? existingBatch!.authorization_expires_at : budget.deadline_at;
|
|
@@ -753,7 +765,7 @@ export async function authorizeBatch<HostRejection>(
|
|
|
753
765
|
if (drift.plan_digest !== planDigest)
|
|
754
766
|
return { outcome: "rejected", rejection: batchRejection("plan_changed", drift.plan_digest) };
|
|
755
767
|
if (drift.base_head === null) return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
|
|
756
|
-
if (drift.base_head !== baseHead)
|
|
768
|
+
if (drift.base_head !== baseHead || (ownsUnpersistedHead && !ownUnpersistedBatchHead(root, existingBatch!, baseHead)))
|
|
757
769
|
return { outcome: "rejected", rejection: batchRejection("head_moved", drift.base_head) };
|
|
758
770
|
|
|
759
771
|
// The drift projection's own claim read happened before its plan read; this
|
|
@@ -778,6 +790,10 @@ export async function authorizeBatch<HostRejection>(
|
|
|
778
790
|
if (Date.parse(expiresAt) <= Date.now())
|
|
779
791
|
return { outcome: "rejected", rejection: batchRejection("confirmation_failed", "confirmed batch deadline expired before authorization issuance") };
|
|
780
792
|
|
|
793
|
+
if (projection.reconfirmation) {
|
|
794
|
+
try { projection.reconfirmation.assertUnchanged(); }
|
|
795
|
+
catch { return { outcome: "rejected", rejection: batchRejection("plan_changed") }; }
|
|
796
|
+
}
|
|
781
797
|
const binding: BatchAuthorizationBinding = {
|
|
782
798
|
batch_id: batchId,
|
|
783
799
|
initiative_slug: initiativeSlug,
|
|
@@ -790,6 +806,7 @@ export async function authorizeBatch<HostRejection>(
|
|
|
790
806
|
expires_at: expiresAt,
|
|
791
807
|
nonce: options.nonce,
|
|
792
808
|
};
|
|
809
|
+
if (projection.reconfirmation) retainReconfirmation(binding.nonce, binding.expires_at, projection.reconfirmation);
|
|
793
810
|
return {
|
|
794
811
|
outcome: "authorized",
|
|
795
812
|
batch_id: batchId,
|
|
@@ -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
|
+
}
|
|
@@ -23,6 +23,7 @@ export function batchQaFailureFacts(record: Pick<TaskRecord, "history"> & { find
|
|
|
23
23
|
recovery_findings: record.findings.filter(f => f.status === "open").map(f => ({ id: f.id, kind: f.kind, status: f.status, acceptance_id: f.acceptance_id })) };
|
|
24
24
|
}
|
|
25
25
|
import type { BatchPlanChild } from "./types";
|
|
26
|
+
import { ownUnpersistedBatchHead, takeReconfirmation } from "./batch_reconfirmation";
|
|
26
27
|
import {
|
|
27
28
|
type BatchRunnerGitPort,
|
|
28
29
|
type BatchGitPreflightResult,
|
|
@@ -38,6 +39,7 @@ import {
|
|
|
38
39
|
prepareBatchRunState,
|
|
39
40
|
readBatchRunState,
|
|
40
41
|
writeBatchRunState,
|
|
42
|
+
replaceBatchRunState,
|
|
41
43
|
writeBatchRunReport,
|
|
42
44
|
} from "./batch_state";
|
|
43
45
|
|
|
@@ -322,6 +324,11 @@ function externalHeadDriftMessage(root: string, record: BatchRunStateRecord): st
|
|
|
322
324
|
}
|
|
323
325
|
|
|
324
326
|
async function validatePersistedRun(input: StartBatchInput, record: BatchRunStateRecord): Promise<void> {
|
|
327
|
+
if (!record.commits.length && record.children[0]?.state === "settled" && existsSync(join(input.root, ".git"))) {
|
|
328
|
+
const head = spawnSync("git", ["-C", input.root, "rev-parse", "HEAD"], { encoding: "utf8" });
|
|
329
|
+
if (head.status !== 0 || (head.stdout.trim() !== record.base_head && !ownUnpersistedBatchHead(input.root, record, head.stdout.trim())))
|
|
330
|
+
throw new Error("first unpersisted batch commit provenance is invalid");
|
|
331
|
+
}
|
|
325
332
|
const plan = input.registry.children(input.capability);
|
|
326
333
|
if (record.plan_digest !== computeBatchPlanDigest(plan) ||
|
|
327
334
|
record.children.length !== plan.length || record.children.some((child, index) => {
|
|
@@ -384,7 +391,7 @@ function replayTerminal(
|
|
|
384
391
|
);
|
|
385
392
|
}
|
|
386
393
|
|
|
387
|
-
function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStateRecord | null):
|
|
394
|
+
function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStateRecord | null): ValidatedBatchAuthorization {
|
|
388
395
|
const authorized = input.kernel.validateBatchAuthorization({
|
|
389
396
|
registry: input.registry,
|
|
390
397
|
capability: input.capability,
|
|
@@ -409,6 +416,32 @@ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStat
|
|
|
409
416
|
if (computeBatchPlanDigest(children) !== authorized.plan_digest ||
|
|
410
417
|
input.plan_digest !== authorized.plan_digest || input.base_head !== authorized.base_head)
|
|
411
418
|
throw new Error("batch run input does not match the authorized plan or base_head");
|
|
419
|
+
return authorized;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function applyPlanReconfirmation(input: StartBatchInput, existing: BatchRunStateRecord): BatchRunStateRecord {
|
|
423
|
+
if (existing.plan_digest === input.plan_digest) return existing;
|
|
424
|
+
const next = { ...existing, plan_digest: input.plan_digest, confirmation_time: input.confirmation_time,
|
|
425
|
+
authorization_expires_at: input.authorization_expires_at, budget: input.budget };
|
|
426
|
+
if (!existing.branch || input.batch_id !== existing.batch_id || input.initiative_slug !== existing.initiative_slug || input.base_head !== existing.base_head ||
|
|
427
|
+
input.budget.max_children !== existing.budget.max_children || input.budget.qa_failure_limit !== existing.budget.qa_failure_limit)
|
|
428
|
+
throw new Error("plan_digest mismatch: reconfirmation binding changed");
|
|
429
|
+
const validate = () => {
|
|
430
|
+
const plan = input.registry.children(input.capability);
|
|
431
|
+
if (existing.children.length !== plan.length || plan.some((child, index) => {
|
|
432
|
+
const old = existing.children[index]!, nextChild = input.children[index];
|
|
433
|
+
return !nextChild || old.task_id !== child.task_id || old.slice_id !== nextChild.slice_id ||
|
|
434
|
+
JSON.stringify(old.blocked_by) !== JSON.stringify(child.blocked_by) || JSON.stringify(nextChild.blocked_by) !== JSON.stringify(child.blocked_by);
|
|
435
|
+
})) throw new Error("plan_digest mismatch: reconfirmation topology changed");
|
|
436
|
+
const { issued_at: _issuedAt, ...binding } = validateRunAuthorization(input, { ...existing, plan_digest: input.plan_digest });
|
|
437
|
+
if (binding.branch !== existing.branch) throw new Error("plan_digest mismatch: reconfirmation branch changed");
|
|
438
|
+
prepareBatchRunState(input);
|
|
439
|
+
return input.registry.inspect(input.capability, binding);
|
|
440
|
+
};
|
|
441
|
+
const authority = validate();
|
|
442
|
+
const captured = takeReconfirmation(authority.nonce);
|
|
443
|
+
if (captured.root !== input.root || captured.plan_digest !== input.plan_digest) throw new Error("plan_digest mismatch: captured reconfirmation does not match run input");
|
|
444
|
+
return replaceBatchRunState(input.root, captured.stateBytes, next, () => { captured.assertUnchanged(); validate(); });
|
|
412
445
|
}
|
|
413
446
|
|
|
414
447
|
export async function startBatch(input: StartBatchInput): Promise<BatchRunReport> {
|
|
@@ -460,7 +493,9 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
460
493
|
}
|
|
461
494
|
|
|
462
495
|
let existing = readBatchRunState(input.root, input.batch_id);
|
|
496
|
+
const reconfirmed = existing !== null && existing.plan_digest !== input.plan_digest;
|
|
463
497
|
if (existing) {
|
|
498
|
+
existing = applyPlanReconfirmation(input, existing);
|
|
464
499
|
try {
|
|
465
500
|
await validatePersistedRun(input, existing);
|
|
466
501
|
} catch (error) {
|
|
@@ -487,7 +522,7 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
487
522
|
nextConfirmation > priorConfirmation &&
|
|
488
523
|
Number.isFinite(nextExpiry) &&
|
|
489
524
|
nextExpiry > Date.now();
|
|
490
|
-
if (!freshAuthorization) {
|
|
525
|
+
if (!freshAuthorization && !reconfirmed) {
|
|
491
526
|
return finalize(
|
|
492
527
|
input.root,
|
|
493
528
|
existing,
|
|
@@ -496,7 +531,7 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
496
531
|
);
|
|
497
532
|
}
|
|
498
533
|
|
|
499
|
-
validateRunAuthorization(input, existing);
|
|
534
|
+
if (!reconfirmed) validateRunAuthorization(input, existing);
|
|
500
535
|
prepareBatchRunState({ ...input, now: input.now });
|
|
501
536
|
const remapped = await Promise.all(
|
|
502
537
|
existing.children.map(async (child) => {
|
|
@@ -863,7 +898,8 @@ export async function resumeBatch(
|
|
|
863
898
|
projection: (root: string, taskId: string) => Promise<AssuranceProjectionResult>,
|
|
864
899
|
): Promise<BatchRunReport> {
|
|
865
900
|
let existing = readBatchRunState(input.root, input.batch_id);
|
|
866
|
-
if (!existing) return startBatch(input);
|
|
901
|
+
if (!existing || existing.batch_state === "needs_human") return startBatch(input);
|
|
902
|
+
existing = applyPlanReconfirmation(input, existing);
|
|
867
903
|
try {
|
|
868
904
|
await validatePersistedRun(input, existing);
|
|
869
905
|
} catch (error) {
|
|
@@ -877,7 +913,6 @@ export async function resumeBatch(
|
|
|
877
913
|
}
|
|
878
914
|
if (isTerminalBatchState(existing.batch_state))
|
|
879
915
|
return replayTerminal(input.root, existing);
|
|
880
|
-
if (existing.batch_state === "needs_human") return startBatch(input);
|
|
881
916
|
|
|
882
917
|
// An enrolled-but-unsettled child must reach its own Kernel terminal
|
|
883
918
|
// settlement before anything else, even under an expired authorization:
|
|
@@ -1103,6 +1138,8 @@ async function driveInterruptedChild(
|
|
|
1103
1138
|
intentPath,
|
|
1104
1139
|
});
|
|
1105
1140
|
};
|
|
1141
|
+
if (existing && !record.commits.length && existsSync(join(input.root, ".git")) && !ownUnpersistedBatchHead(input.root, record, existing.commit))
|
|
1142
|
+
throw new Error("first unpersisted batch commit provenance is invalid");
|
|
1106
1143
|
const adopted =
|
|
1107
1144
|
existing ??
|
|
1108
1145
|
(await doCommit().catch((error: unknown) => {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { existsSync, mkdirSync, openSync, closeSync, writeFileSync, renameSync, lstatSync, constants, rmSync } from "node:fs";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
|
-
import { readSecureProjectFile, withKernelStoreLock } from "../kernel/storage";
|
|
7
|
+
import { readSecureProjectFile, readSecureProjectBytes, withKernelStoreLock } from "../kernel/storage";
|
|
8
8
|
import type { BatchPlanChild } from "./types";
|
|
9
9
|
|
|
10
10
|
export type BatchRunState =
|
|
@@ -243,12 +243,18 @@ export function prepareBatchRunState(input: {
|
|
|
243
243
|
return prepared;
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
/** Validate a captured snapshot without reading or writing filesystem state. */
|
|
247
|
+
export function parseBatchRunState(raw: string, batchId: string): BatchRunStateRecord {
|
|
248
|
+
validateBatchId(batchId);
|
|
249
|
+
const parsed: unknown = JSON.parse(raw);
|
|
250
|
+
validateRecordShape(parsed, batchId);
|
|
251
|
+
return parsed;
|
|
252
|
+
}
|
|
253
|
+
|
|
246
254
|
export function readBatchRunState(root: string, batchId: string): BatchRunStateRecord | null {
|
|
247
255
|
const path = statePath(batchId);
|
|
248
256
|
if (!existsSync(join(root, path))) return null;
|
|
249
|
-
|
|
250
|
-
validateRecordShape(parsed, batchId);
|
|
251
|
-
return parsed;
|
|
257
|
+
return parseBatchRunState(readSecureProjectFile(root, path), batchId);
|
|
252
258
|
}
|
|
253
259
|
|
|
254
260
|
function ensureSecureDirectory(root: string, relative: string): string {
|
|
@@ -287,6 +293,19 @@ function writeFileAtomically(root: string, relative: string, bytes: string): voi
|
|
|
287
293
|
}
|
|
288
294
|
}
|
|
289
295
|
|
|
296
|
+
/** Replace an existing authorization only after a locked expected-byte check. */
|
|
297
|
+
export function replaceBatchRunState(root: string, expected: Buffer, next: BatchRunStateRecord, validate: () => void): BatchRunStateRecord {
|
|
298
|
+
const path = statePath(next.batch_id);
|
|
299
|
+
validateRecordShape(next, next.batch_id);
|
|
300
|
+
return withKernelStoreLock(root, () => {
|
|
301
|
+
if (!readSecureProjectBytes(root, path).equals(expected)) throw new Error("batch state CAS mismatch");
|
|
302
|
+
validate();
|
|
303
|
+
const stored = { ...next, updated_at: new Date().toISOString() };
|
|
304
|
+
writeFileAtomically(root, path, canonicalBytes(stored));
|
|
305
|
+
return stored;
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
290
309
|
export function writeBatchRunState(
|
|
291
310
|
root: string,
|
|
292
311
|
record: BatchRunStateRecord,
|
|
@@ -176,6 +176,15 @@ async function runKernelCli(args: string[], root: string): Promise<{
|
|
|
176
176
|
// status --json, and the explicit audit command. All other kernel
|
|
177
177
|
// subcommands (readiness, journal, migrate) are retired.
|
|
178
178
|
const sub = args[0] ?? "";
|
|
179
|
+
// The canonical author shape is discoverable before any error probing: a
|
|
180
|
+
// caller who does not know the destination/stdin contract reads it here
|
|
181
|
+
// instead of discovering it through a failed call.
|
|
182
|
+
if (sub === "intent" && (args[1] === "--help" || args[1] === "help" || args[1] === undefined))
|
|
183
|
+
return {
|
|
184
|
+
stdout: `${intentAuthorHelp()}\n`,
|
|
185
|
+
stderr: "",
|
|
186
|
+
returncode: 0,
|
|
187
|
+
};
|
|
179
188
|
// The explicit claimless storage-layout migration is reachable here: a
|
|
180
189
|
// worktree still on the retired file store has no other entry point, and
|
|
181
190
|
// every mutating subcommand stays fail-closed behind it.
|
|
@@ -224,6 +233,51 @@ async function runCli(command: string, args: string[], root: string): Promise<{
|
|
|
224
233
|
};
|
|
225
234
|
}
|
|
226
235
|
|
|
236
|
+
/**
|
|
237
|
+
* The canonical TaskIntent author contract, printed by `imm-kernel intent` and
|
|
238
|
+
* `imm-kernel intent --help`. It states destination, stdin, and the strict v2
|
|
239
|
+
* descriptor shape before a caller has to discover them by failing a call.
|
|
240
|
+
*/
|
|
241
|
+
export function intentAuthorHelp(): string {
|
|
242
|
+
return [
|
|
243
|
+
"imm-kernel intent author <destination> --stdin --json",
|
|
244
|
+
" destination: docs/plans/<task-id>.intent.json (created exclusively; never overwritten)",
|
|
245
|
+
" stdin: the complete candidate TaskIntent JSON on file descriptor 0",
|
|
246
|
+
" stdout: { valid, enrollment_ready, intent_ref, git_base_initialization_required, ... }",
|
|
247
|
+
"",
|
|
248
|
+
"imm-kernel intent validate <path> --json",
|
|
249
|
+
"",
|
|
250
|
+
"Candidate shape (assurance_kernel/task_intent/v1):",
|
|
251
|
+
JSON.stringify(
|
|
252
|
+
{
|
|
253
|
+
contract: "assurance_kernel/task_intent/v1",
|
|
254
|
+
task_id: "<task-id>",
|
|
255
|
+
owner: "user",
|
|
256
|
+
goal: "<one closable outcome>",
|
|
257
|
+
risk: "routine|material|critical",
|
|
258
|
+
revision: 1,
|
|
259
|
+
scope_hint: ["<every file the slice touches>"],
|
|
260
|
+
acceptance: [
|
|
261
|
+
{
|
|
262
|
+
id: "<ID>",
|
|
263
|
+
assertion: "<observable invariant>",
|
|
264
|
+
verification: JSON.stringify({
|
|
265
|
+
contract: "assurance_kernel/verification_descriptor/v2",
|
|
266
|
+
command: { executable: "bun", argv: ["test", "tests/<focused>.test.ts"], cwd: ".", timeout_ms: 180000, max_output_bytes: 196608 },
|
|
267
|
+
environment: { prepare: null, writable_paths: [] },
|
|
268
|
+
}),
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
null,
|
|
273
|
+
2,
|
|
274
|
+
),
|
|
275
|
+
"",
|
|
276
|
+
"Unknown fields are rejected; the descriptor parser is strict v2 and never infers a runner.",
|
|
277
|
+
"A v1 descriptor is historical-only and needs an explicit Intent revision before execution.",
|
|
278
|
+
].join("\n");
|
|
279
|
+
}
|
|
280
|
+
|
|
227
281
|
async function main(argv: string[]): Promise<number> {
|
|
228
282
|
const mode = argv[0];
|
|
229
283
|
const root = process.cwd();
|