immune-brain 5.2.0 → 5.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/README.zh-CN.md +4 -4
- package/package.json +2 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +54 -70
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +163 -28
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +71 -10
- package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +1 -1
- package/plugins/immune-brain/.pi-extension/pi-run-continuation.ts +161 -0
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +26 -2
- package/plugins/immune-brain/dist/BASELINE.md +17 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +16145 -12745
- package/plugins/immune-brain/dist/docs/reference/code-quality-guard.md +3 -1
- package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +41 -1
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
- package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
- package/plugins/immune-brain/dist/imm-planner.md +63 -19
- package/plugins/immune-brain/dist/imm-run-batch.md +398 -0
- package/plugins/immune-brain/dist/imm-run.md +89 -344
- package/plugins/immune-brain/dist/role-prompts/code-review.md +26 -2
- package/plugins/immune-brain/dist/role-prompts/executor.md +35 -10
- package/plugins/immune-brain/hooks/hooks.json +22 -2
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +203 -13
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +2 -1
- package/plugins/immune-brain/runtime/assurance/enrollment_git_base.ts +99 -14
- package/plugins/immune-brain/runtime/assurance/phase_entry.ts +10 -26
- package/plugins/immune-brain/runtime/assurance/qa.ts +126 -43
- package/plugins/immune-brain/runtime/assurance/qa_findings.ts +8 -0
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +48 -0
- package/plugins/immune-brain/runtime/assurance/run_continuation.ts +284 -0
- package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +46 -4
- package/plugins/immune-brain/runtime/assurance/verification.ts +83 -35
- package/plugins/immune-brain/runtime/assurance/workflow_autocommit.ts +222 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +11 -2
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +199 -48
- package/plugins/immune-brain/runtime/claude/lane_guard.ts +30 -20
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +105 -9
- package/plugins/immune-brain/runtime/claude/run_continuation.ts +274 -0
- package/plugins/immune-brain/runtime/commands/kernel.ts +41 -103
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +60 -2
- package/plugins/immune-brain/runtime/kernel/application.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +103 -20
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +57 -7
- package/plugins/immune-brain/runtime/kernel/completion.ts +28 -4
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +48 -8
- package/plugins/immune-brain/runtime/kernel/intent.ts +169 -104
- package/plugins/immune-brain/runtime/kernel/reducer.ts +136 -6
- package/plugins/immune-brain/runtime/kernel/risk_floor.ts +286 -0
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +83 -1
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +1 -1
- package/plugins/immune-brain/runtime/kernel/types.ts +49 -3
- package/plugins/immune-brain/runtime/kernel/validation.ts +147 -7
- package/plugins/immune-brain/runtime/loop_contract.ts +5 -0
- package/plugins/immune-brain/runtime/plan_core.ts +15 -8
- package/plugins/immune-brain/runtime/plugin_source_skew.ts +58 -0
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +26 -2
- package/plugins/immune-brain/runtime/prompts/executor.md +35 -10
- package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +5 -3
- package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +40 -10
- package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +57 -3
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +424 -66
- package/plugins/immune-brain/runtime/unattended/batch_integration.ts +2 -1
- package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +13 -0
- package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +43 -24
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +6 -2
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +102 -41
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +9 -3
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +9 -9
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +90 -32
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +102 -4
- package/plugins/immune-brain/runtime/unattended/parent_delivery_consolidation.ts +248 -0
- package/plugins/immune-brain/runtime/workspace_scope.ts +126 -18
- package/plugins/immune-brain/skills/BASELINE.md +17 -1
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +4 -3
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +8 -2
- package/plugins/immune-brain/skills/imm-run/SKILL.md +12 -8
|
@@ -24,6 +24,15 @@ import {
|
|
|
24
24
|
type TaskIntentV1,
|
|
25
25
|
type TaskRisk,
|
|
26
26
|
} from "./types";
|
|
27
|
+
import {
|
|
28
|
+
applyScopeRiskFloor,
|
|
29
|
+
changedPathRiskFloor,
|
|
30
|
+
NO_RISK_FLOOR,
|
|
31
|
+
raiseRisk,
|
|
32
|
+
readRiskFloor,
|
|
33
|
+
touchesChangedPathFloor,
|
|
34
|
+
type RiskFloor,
|
|
35
|
+
} from "./risk_floor";
|
|
27
36
|
import {
|
|
28
37
|
mintToken,
|
|
29
38
|
type TaskIntentIdentityToken,
|
|
@@ -33,91 +42,6 @@ import {
|
|
|
33
42
|
export const INTENT_MAX_BYTES = 64 * 1024;
|
|
34
43
|
export const INTENT_SIDECAR_RELATIVE_PREFIX = "docs/plans/";
|
|
35
44
|
|
|
36
|
-
// Deterministic risk-tier floor: an intent whose scope_hint touches kernel or
|
|
37
|
-
// authority runtime paths is forced to at least `material` regardless of the
|
|
38
|
-
// tier the author declared, so self-graded `routine` can never buy weaker
|
|
39
|
-
// gating over the paths where gating matters most. The floor is derived solely
|
|
40
|
-
// from scope_hint, never from prose fields. The .pi-extension directory is the
|
|
41
|
-
// human gate itself (enrollment confirmation, capability minting, and review
|
|
42
|
-
// authorization), so a self-graded routine over it must not weaken gating over
|
|
43
|
-
// the code that implements gating.
|
|
44
|
-
export const RISK_FLOOR_SCOPE_PREFIXES = [
|
|
45
|
-
"plugins/immune-brain/runtime/kernel",
|
|
46
|
-
"plugins/immune-brain/runtime/authority_commit_receipts.ts",
|
|
47
|
-
"plugins/immune-brain/runtime/assurance",
|
|
48
|
-
"plugins/immune-brain/runtime/claude/interaction.ts",
|
|
49
|
-
"plugins/immune-brain/runtime/claude/capability.ts",
|
|
50
|
-
"plugins/immune-brain/runtime/claude/review_host.ts",
|
|
51
|
-
"plugins/immune-brain/runtime/claude/kernel_ports.ts",
|
|
52
|
-
"plugins/immune-brain/runtime/claude/mcp_server.ts",
|
|
53
|
-
"plugins/immune-brain/.pi-extension",
|
|
54
|
-
] as const;
|
|
55
|
-
|
|
56
|
-
// Concrete changed paths use a narrower, segment-safe prefix policy. Keep this
|
|
57
|
-
// separate from the glob-aware scope_hint policy above: the two inputs have
|
|
58
|
-
// different contracts and docs-only scope remains routine today.
|
|
59
|
-
export const CHANGED_PATH_RISK_FLOOR_PREFIXES = [
|
|
60
|
-
...RISK_FLOOR_SCOPE_PREFIXES,
|
|
61
|
-
"docs/specs",
|
|
62
|
-
"docs/plans",
|
|
63
|
-
] as const;
|
|
64
|
-
|
|
65
|
-
function segmentMatches(e: string, s: string): boolean {
|
|
66
|
-
if (e === "*") return true;
|
|
67
|
-
if (!e.includes("*") && !e.includes("?")) return e === s;
|
|
68
|
-
const rx = new RegExp(
|
|
69
|
-
`^${e.split("*").map((p) => p.split("?").map((q) => q.replace(/[.\\+^${}()|[\]\\/]/g, "\\$&")).join(".")).join(".*")}$`,
|
|
70
|
-
);
|
|
71
|
-
return rx.test(s);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Decide whether a scope entry can match the kernel/authority prefix itself,
|
|
75
|
-
// any of its ancestor prefixes, or any descendant path beneath it. Glob `**`
|
|
76
|
-
// spans zero or more segments, `*` matches one segment, `?` one character.
|
|
77
|
-
// A match on any of those relations means the entry touches the floored path,
|
|
78
|
-
// so a routine-declared intent cannot hide behind wildcard-leading patterns.
|
|
79
|
-
function scopeEntryTouchesPrefixSegments(es: string[], ps: string[]): boolean {
|
|
80
|
-
const memo = new Map<string, boolean>();
|
|
81
|
-
const m = (pi: number, ei: number): boolean => {
|
|
82
|
-
const key = `${pi}:${ei}`;
|
|
83
|
-
const cached = memo.get(key);
|
|
84
|
-
if (cached !== undefined) return cached;
|
|
85
|
-
let result: boolean;
|
|
86
|
-
if (ei === es.length) {
|
|
87
|
-
result = true; // es matched a prefix of ps, ps itself, or a descendant
|
|
88
|
-
} else {
|
|
89
|
-
const e = es[ei];
|
|
90
|
-
if (e === "**") {
|
|
91
|
-
if (pi >= ps.length) result = true; // beyond ps, descendant always exists
|
|
92
|
-
else result = m(pi, ei + 1) || m(pi + 1, ei);
|
|
93
|
-
} else if (pi < ps.length && segmentMatches(e, ps[pi])) {
|
|
94
|
-
result = m(pi + 1, ei + 1);
|
|
95
|
-
} else if (pi >= ps.length) {
|
|
96
|
-
result = m(pi + 1, ei + 1); // remaining literal segments match descendant segments
|
|
97
|
-
} else {
|
|
98
|
-
result = false;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
memo.set(key, result);
|
|
102
|
-
return result;
|
|
103
|
-
};
|
|
104
|
-
return m(0, 0);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function scopeEntryTouchesRiskFloorPaths(entry: string): boolean {
|
|
108
|
-
if (!entry) return false;
|
|
109
|
-
const es = entry.split("/");
|
|
110
|
-
return RISK_FLOOR_SCOPE_PREFIXES.some((prefix) =>
|
|
111
|
-
scopeEntryTouchesPrefixSegments(es, prefix.split("/")),
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function riskFloorForScope(scopeHint: string[]): TaskRisk | null {
|
|
116
|
-
return scopeHint.some(scopeEntryTouchesRiskFloorPaths)
|
|
117
|
-
? "material"
|
|
118
|
-
: null;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
45
|
const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
122
46
|
const portablePathCollator = new Intl.Collator("und", {
|
|
123
47
|
usage: "search",
|
|
@@ -318,18 +242,16 @@ export function parseTaskIntentV1(raw: unknown): TaskIntentV1 {
|
|
|
318
242
|
const acceptance = (acceptanceRaw as unknown[]).map((item, index) =>
|
|
319
243
|
parseAcceptanceItemV1(item, index, []),
|
|
320
244
|
);
|
|
321
|
-
|
|
322
|
-
|
|
245
|
+
// The declared risk is returned as written. The project's risk floor is
|
|
246
|
+
// applied where a risk is decided, by a caller that knows the project root
|
|
247
|
+
// (ADR 0023): `readTaskIntent`, `parseProjectTaskIntent`, `classifyTaskRisk`.
|
|
323
248
|
return {
|
|
324
249
|
contract: TASK_INTENT_CONTRACT_V1,
|
|
325
250
|
task_id: taskId,
|
|
326
251
|
goal,
|
|
327
252
|
acceptance,
|
|
328
253
|
scope_hint: scopeHint,
|
|
329
|
-
risk:
|
|
330
|
-
flooredRisk !== null && RISK_RANK[declaredRisk] < RISK_RANK[flooredRisk]
|
|
331
|
-
? flooredRisk
|
|
332
|
-
: declaredRisk,
|
|
254
|
+
risk: risk as TaskRisk,
|
|
333
255
|
revision,
|
|
334
256
|
owner: "user",
|
|
335
257
|
};
|
|
@@ -345,18 +267,27 @@ export function canonicalIntentHash(intent: unknown): string {
|
|
|
345
267
|
|
|
346
268
|
const RISK_RANK: Record<TaskRisk, number> = { routine: 0, material: 1, critical: 2 };
|
|
347
269
|
|
|
270
|
+
/**
|
|
271
|
+
* A candidate intent parsed for one project: the strict parse, then that
|
|
272
|
+
* project's scope floor. Every caller that turns candidate JSON into an intent
|
|
273
|
+
* the Kernel will hash or record uses this, so the recorded risk and the risk
|
|
274
|
+
* `readTaskIntent` later reads back agree.
|
|
275
|
+
*/
|
|
276
|
+
export function parseProjectTaskIntent(root: string, raw: unknown, recordedIntent?: TaskIntentV1): TaskIntentV1 {
|
|
277
|
+
const parsed = parseTaskIntentV1(raw);
|
|
278
|
+
// An enrolled revision keeps its recorded risk; today's policy only changes gating.
|
|
279
|
+
if (recordedIntent && parsed.revision === recordedIntent.revision &&
|
|
280
|
+
raiseRisk(recordedIntent.risk, parsed.risk) === recordedIntent.risk)
|
|
281
|
+
return { ...parsed, risk: recordedIntent.risk };
|
|
282
|
+
return applyScopeRiskFloor(parsed, readRiskFloor(root));
|
|
283
|
+
}
|
|
284
|
+
|
|
348
285
|
export function classifyTaskRisk(
|
|
349
286
|
changedPaths: readonly string[],
|
|
350
287
|
declaredRisk: TaskRisk,
|
|
288
|
+
floor: RiskFloor = NO_RISK_FLOOR,
|
|
351
289
|
): TaskRisk {
|
|
352
|
-
|
|
353
|
-
CHANGED_PATH_RISK_FLOOR_PREFIXES.some(
|
|
354
|
-
(prefix) => path === prefix || path.startsWith(`${prefix}/`),
|
|
355
|
-
),
|
|
356
|
-
)
|
|
357
|
-
? "material"
|
|
358
|
-
: "routine";
|
|
359
|
-
return RISK_RANK[declaredRisk] >= RISK_RANK[pathFloor] ? declaredRisk : pathFloor;
|
|
290
|
+
return raiseRisk(declaredRisk, changedPathRiskFloor(changedPaths, floor));
|
|
360
291
|
}
|
|
361
292
|
|
|
362
293
|
function contentHashWithoutRevision(intent: TaskIntentV1): string {
|
|
@@ -364,19 +295,99 @@ function contentHashWithoutRevision(intent: TaskIntentV1): string {
|
|
|
364
295
|
return canonicalIntentHash(rest);
|
|
365
296
|
}
|
|
366
297
|
|
|
298
|
+
function isConcreteScopeEntry(entry: string): boolean {
|
|
299
|
+
return !entry.includes("*") && !entry.includes("?");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function scopeEntryParent(entry: string): string {
|
|
303
|
+
const slash = entry.lastIndexOf("/");
|
|
304
|
+
return slash < 0 ? "" : entry.slice(0, slash);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The entries a same-directory additive scope revision adds, or null when the
|
|
309
|
+
* delta is anything else (ADR 0021). Additive means the next scope contains
|
|
310
|
+
* every previous entry; same-directory means each added entry is a concrete
|
|
311
|
+
* path whose parent directory already holds a concrete previous entry, outside
|
|
312
|
+
* every path the project's risk floor names, and not an ancestor of any
|
|
313
|
+
* previous entry. The check is purely textual: it cannot tell a file from a
|
|
314
|
+
* directory written without a trailing slash, so each Host also refuses a
|
|
315
|
+
* directory in the worktree (see `assertScopeAdditionsAreFiles`).
|
|
316
|
+
*/
|
|
317
|
+
export function sameDirectoryScopeAdditions(
|
|
318
|
+
previous: readonly string[],
|
|
319
|
+
next: readonly string[],
|
|
320
|
+
floor: RiskFloor = NO_RISK_FLOOR,
|
|
321
|
+
): string[] | null {
|
|
322
|
+
const prior = new Set(previous);
|
|
323
|
+
const retained = new Set(next);
|
|
324
|
+
if (previous.some((entry) => !retained.has(entry))) return null;
|
|
325
|
+
const added = next.filter((entry) => !prior.has(entry));
|
|
326
|
+
const anchors = new Set(previous.filter(isConcreteScopeEntry).map(scopeEntryParent));
|
|
327
|
+
for (const entry of added) {
|
|
328
|
+
if (!isConcreteScopeEntry(entry) || touchesChangedPathFloor(entry, floor)) return null;
|
|
329
|
+
if (!anchors.has(scopeEntryParent(entry))) return null;
|
|
330
|
+
if (previous.some((existing) => existing.startsWith(`${entry}/`))) return null;
|
|
331
|
+
}
|
|
332
|
+
return added;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The previous concrete entries that anchor an added entry's directory. */
|
|
336
|
+
export function scopeAdditionAnchors(
|
|
337
|
+
previous: readonly string[],
|
|
338
|
+
added: readonly string[],
|
|
339
|
+
): string[] {
|
|
340
|
+
const parents = new Set(added.map(scopeEntryParent));
|
|
341
|
+
return previous.filter(
|
|
342
|
+
(entry) => isConcreteScopeEntry(entry) && parents.has(scopeEntryParent(entry)),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The revise_intent history entry carries the paths a compatible scope revision
|
|
347
|
+
// added, one `scope_added:<path>` line each, so Review can name them without a
|
|
348
|
+
// TaskRecord field (ADR 0021, D5).
|
|
349
|
+
const SCOPE_ADDED_PREFIX = "scope_added:";
|
|
350
|
+
|
|
351
|
+
export function scopeAdditionsReasonLines(added: readonly string[]): string[] {
|
|
352
|
+
return added.map((entry) => `${SCOPE_ADDED_PREFIX}${entry}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function scopeAdditionsFromHistory(
|
|
356
|
+
history: ReadonlyArray<{ type: string; reason: string }>,
|
|
357
|
+
): string[] {
|
|
358
|
+
const added: string[] = [];
|
|
359
|
+
for (const entry of history) {
|
|
360
|
+
if (entry.type !== "revise_intent") continue;
|
|
361
|
+
for (const line of entry.reason.split("\n"))
|
|
362
|
+
if (line.startsWith(SCOPE_ADDED_PREFIX)) added.push(line.slice(SCOPE_ADDED_PREFIX.length));
|
|
363
|
+
}
|
|
364
|
+
return [...new Set(added)].sort();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
367
|
export function classifyIntentRevision(
|
|
368
368
|
previous: TaskIntentV1,
|
|
369
369
|
next: TaskIntentV1,
|
|
370
|
+
floor: RiskFloor = NO_RISK_FLOOR,
|
|
370
371
|
): "unchanged" | "compatible" | "breaking" {
|
|
371
372
|
if (contentHashWithoutRevision(previous) === contentHashWithoutRevision(next))
|
|
372
373
|
return "unchanged";
|
|
373
374
|
|
|
375
|
+
const scopeChanged =
|
|
376
|
+
JSON.stringify(previous.scope_hint) !== JSON.stringify(next.scope_hint);
|
|
377
|
+
// Narrowing removes authority: a next scope made only of previous entries
|
|
378
|
+
// needs no confirmation. An empty scope is not a narrowing, and any added
|
|
379
|
+
// entry is classified by the same-directory rule below, as before.
|
|
380
|
+
const priorScope = new Set(previous.scope_hint);
|
|
381
|
+
const scopeNarrowed =
|
|
382
|
+
next.scope_hint.length > 0 && next.scope_hint.every((entry) => priorScope.has(entry));
|
|
374
383
|
const breaking =
|
|
375
384
|
previous.contract !== next.contract ||
|
|
376
385
|
previous.task_id !== next.task_id ||
|
|
377
386
|
previous.owner !== next.owner ||
|
|
378
387
|
previous.goal !== next.goal ||
|
|
379
|
-
|
|
388
|
+
(scopeChanged &&
|
|
389
|
+
!scopeNarrowed &&
|
|
390
|
+
sameDirectoryScopeAdditions(previous.scope_hint, next.scope_hint, floor) === null) ||
|
|
380
391
|
RISK_RANK[next.risk] < RISK_RANK[previous.risk] ||
|
|
381
392
|
previous.acceptance.some((prior) => {
|
|
382
393
|
const current = next.acceptance.find((item) => item.id === prior.id);
|
|
@@ -387,6 +398,48 @@ export function classifyIntentRevision(
|
|
|
387
398
|
return next.revision > previous.revision ? "compatible" : "breaking";
|
|
388
399
|
}
|
|
389
400
|
|
|
401
|
+
/**
|
|
402
|
+
* The Host-side half of the same-directory rule (ADR 0021, D4). The classifier
|
|
403
|
+
* is pure and cannot tell a file from a directory, so before a Host submits a
|
|
404
|
+
* compatible scope revision it refuses one whose added entries, or the previous
|
|
405
|
+
* entries that anchor them, are directories in the worktree.
|
|
406
|
+
*/
|
|
407
|
+
export function assertScopeAdditionsAreFiles(
|
|
408
|
+
root: string,
|
|
409
|
+
previous: readonly string[],
|
|
410
|
+
next: readonly string[],
|
|
411
|
+
): void {
|
|
412
|
+
const added = sameDirectoryScopeAdditions(previous, next, readRiskFloor(root));
|
|
413
|
+
if (!added || added.length === 0) return;
|
|
414
|
+
for (const entry of [...added, ...scopeAdditionAnchors(previous, added)]) {
|
|
415
|
+
let isDirectory = false;
|
|
416
|
+
try {
|
|
417
|
+
isDirectory = statSync(join(root, entry)).isDirectory();
|
|
418
|
+
} catch {
|
|
419
|
+
// An absent or unreadable entry is not a directory.
|
|
420
|
+
}
|
|
421
|
+
if (isDirectory)
|
|
422
|
+
throw new Error(
|
|
423
|
+
`scope entry ${entry} is a directory, so adding it is not a same-directory file addition; name concrete files, or widen with a glob entry such as ${entry}/** through approve_breaking_intent_revision`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The record as if the next intent's scope were already in force. A revision is
|
|
430
|
+
* prepared, checked and bound against the scope it asks for, not the scope it
|
|
431
|
+
* replaces (ADR 0021, D6).
|
|
432
|
+
*/
|
|
433
|
+
export function withNextScope<R extends { intent_snapshot: { scope_hint: string[] } }>(
|
|
434
|
+
record: R,
|
|
435
|
+
nextScope: readonly string[],
|
|
436
|
+
): R {
|
|
437
|
+
return {
|
|
438
|
+
...record,
|
|
439
|
+
intent_snapshot: { ...record.intent_snapshot, scope_hint: [...nextScope] },
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
390
443
|
// ---------------------------------------------------------------------------
|
|
391
444
|
// Opaque file identity token.
|
|
392
445
|
// ---------------------------------------------------------------------------
|
|
@@ -545,6 +598,8 @@ function readTaskIntentSource(
|
|
|
545
598
|
root: string,
|
|
546
599
|
taskId: string,
|
|
547
600
|
requestedPath?: string,
|
|
601
|
+
requireTracked = true,
|
|
602
|
+
recordedIntent?: TaskIntentV1,
|
|
548
603
|
): TaskIntentReadSource {
|
|
549
604
|
validateTaskId(taskId);
|
|
550
605
|
|
|
@@ -568,7 +623,7 @@ function readTaskIntentSource(
|
|
|
568
623
|
const fileIdentity = pathIdentities[pathIdentities.length - 1];
|
|
569
624
|
|
|
570
625
|
// Git tracking is an ownership convention, not user authentication.
|
|
571
|
-
try {
|
|
626
|
+
if (requireTracked) try {
|
|
572
627
|
execFileSync(
|
|
573
628
|
"git",
|
|
574
629
|
["ls-files", "--error-unmatch", "--", sidecarPath],
|
|
@@ -615,7 +670,7 @@ function readTaskIntentSource(
|
|
|
615
670
|
const sourceBytesSha256 = sha256Hex(bytes);
|
|
616
671
|
let intent: TaskIntentV1;
|
|
617
672
|
try {
|
|
618
|
-
intent =
|
|
673
|
+
intent = parseProjectTaskIntent(canonicalRoot, JSON.parse(bytes.toString("utf8")), recordedIntent);
|
|
619
674
|
} catch (error) {
|
|
620
675
|
throw new TaskIntentObservationError(
|
|
621
676
|
"invalid",
|
|
@@ -649,12 +704,21 @@ function readTaskIntentSource(
|
|
|
649
704
|
};
|
|
650
705
|
}
|
|
651
706
|
|
|
707
|
+
/** Validate the active sidecar before Enrollment can commit its untracked preconditions. */
|
|
708
|
+
export function observeEnrollmentTaskIntent(root: string, taskId: string): ObservedTaskIntent {
|
|
709
|
+
const { identity: _identity, ...observed } = readTaskIntentSource(
|
|
710
|
+
root, taskId, `${INTENT_SIDECAR_RELATIVE_PREFIX}${taskId}.intent.json`, false,
|
|
711
|
+
);
|
|
712
|
+
return observed;
|
|
713
|
+
}
|
|
714
|
+
|
|
652
715
|
export function observeTaskIntent(
|
|
653
716
|
root: string,
|
|
654
717
|
taskId: string,
|
|
655
718
|
requestedPath?: string,
|
|
719
|
+
recordedIntent?: TaskIntentV1,
|
|
656
720
|
): ObservedTaskIntent {
|
|
657
|
-
const { identity: _identity, ...observed } = readTaskIntentSource(root, taskId, requestedPath);
|
|
721
|
+
const { identity: _identity, ...observed } = readTaskIntentSource(root, taskId, requestedPath, true, recordedIntent);
|
|
658
722
|
return observed;
|
|
659
723
|
}
|
|
660
724
|
|
|
@@ -662,7 +726,8 @@ export function readTaskIntent(
|
|
|
662
726
|
root: string,
|
|
663
727
|
taskId: string,
|
|
664
728
|
requestedPath?: string,
|
|
729
|
+
recordedIntent?: TaskIntentV1,
|
|
665
730
|
): ReadTaskIntentResult {
|
|
666
|
-
const { identity, ...observed } = readTaskIntentSource(root, taskId, requestedPath);
|
|
731
|
+
const { identity, ...observed } = readTaskIntentSource(root, taskId, requestedPath, true, recordedIntent);
|
|
667
732
|
return { ...observed, token: mintToken(identity) };
|
|
668
733
|
}
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { completionDecision } from "./completion";
|
|
7
|
+
import { NO_RISK_FLOOR, type RiskFloor } from "./risk_floor";
|
|
7
8
|
import { anchorForEvidence, isFreshPassingQaAttestation, refutationIdentity, refutationIsLive } from "./refutation";
|
|
8
9
|
import {
|
|
9
10
|
REDUCED_MUTATION_BRAND,
|
|
10
11
|
type AuthorityAuditDescriptor,
|
|
11
12
|
type ReducedTaskMutation,
|
|
13
|
+
type ReviewRevisionIdentityV1,
|
|
12
14
|
type TaskAction,
|
|
13
15
|
type TaskFinding,
|
|
14
16
|
type TaskIntentRefV3,
|
|
@@ -22,7 +24,12 @@ import {
|
|
|
22
24
|
parseTaskAction,
|
|
23
25
|
parseTaskRecord,
|
|
24
26
|
} from "./validation";
|
|
25
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
canonicalIntentHash,
|
|
29
|
+
classifyIntentRevision,
|
|
30
|
+
sameDirectoryScopeAdditions,
|
|
31
|
+
scopeAdditionsReasonLines,
|
|
32
|
+
} from "./intent";
|
|
26
33
|
import { assertValidSpecBinding } from "./spec_binding";
|
|
27
34
|
|
|
28
35
|
const RISK_RANK: Record<TaskIntentV1["risk"], number> = {
|
|
@@ -132,6 +139,7 @@ function appendHistory(
|
|
|
132
139
|
from: string,
|
|
133
140
|
detail: string | null,
|
|
134
141
|
audit: AuthorityAuditDescriptor | null,
|
|
142
|
+
reviewRevision?: ReviewRevisionIdentityV1,
|
|
135
143
|
): void {
|
|
136
144
|
if (record.history.some((entry) => entry.id === action.event_id))
|
|
137
145
|
throw new KernelInvariantError([
|
|
@@ -145,6 +153,7 @@ function appendHistory(
|
|
|
145
153
|
to_state: string;
|
|
146
154
|
reason: string;
|
|
147
155
|
authority?: AuthorityAuditDescriptor;
|
|
156
|
+
review_revision?: ReviewRevisionIdentityV1;
|
|
148
157
|
} = {
|
|
149
158
|
id: action.event_id,
|
|
150
159
|
at: action.at,
|
|
@@ -160,6 +169,7 @@ function appendHistory(
|
|
|
160
169
|
),
|
|
161
170
|
};
|
|
162
171
|
if (audit) entry.authority = { ...audit };
|
|
172
|
+
if (reviewRevision) entry.review_revision = { ...reviewRevision };
|
|
163
173
|
record.history.push(entry as (typeof record.history)[number]);
|
|
164
174
|
}
|
|
165
175
|
|
|
@@ -178,6 +188,7 @@ function hasPrivilegedKind(action: TaskAction): boolean {
|
|
|
178
188
|
action.type === "approve_breaking_intent_revision" ||
|
|
179
189
|
action.type === "request_rework" ||
|
|
180
190
|
action.type === "authorize_rework" ||
|
|
191
|
+
action.type === "accept_residual_risk" ||
|
|
181
192
|
action.type === "stop" ||
|
|
182
193
|
action.type === "resolve_user_decision"
|
|
183
194
|
);
|
|
@@ -207,15 +218,22 @@ export function findingsDigestV2(findings: TaskFinding[]): string {
|
|
|
207
218
|
/**
|
|
208
219
|
* Rework rounds one Review run may demand before the Kernel pauses the task for
|
|
209
220
|
* a human decision. Ordinary defects return to execution without a user gate,
|
|
210
|
-
* but an unbounded repair/review cycle is not progress either.
|
|
221
|
+
* but an unbounded repair/review cycle is not progress either. The literal user
|
|
222
|
+
* may extend it with `authorize_rework` rounds; the grant is cumulative and
|
|
223
|
+
* persisted as `review_round_budget_extension`.
|
|
211
224
|
*/
|
|
212
225
|
export const REVIEW_REWORK_ROUND_BUDGET = 5;
|
|
213
226
|
|
|
227
|
+
export function effectiveReviewRoundBudget(record: TaskRecord): number {
|
|
228
|
+
return REVIEW_REWORK_ROUND_BUDGET + (record.review_round_budget_extension ?? 0);
|
|
229
|
+
}
|
|
230
|
+
|
|
214
231
|
export function reduceTask(
|
|
215
232
|
recordRaw: TaskRecord,
|
|
216
233
|
actionRaw: TaskAction,
|
|
217
234
|
authorityAudit: AuthorityAuditDescriptor | null = null,
|
|
218
235
|
changedPaths?: readonly string[],
|
|
236
|
+
floor: RiskFloor = NO_RISK_FLOOR,
|
|
219
237
|
): ReducedTaskMutation {
|
|
220
238
|
const previous = parseTaskRecord(recordRaw);
|
|
221
239
|
assertKernelInvariantsV3(previous.intent_snapshot, previous);
|
|
@@ -474,6 +492,7 @@ export function reduceTask(
|
|
|
474
492
|
const revisionClass = classifyIntentRevision(
|
|
475
493
|
record.intent_snapshot,
|
|
476
494
|
action.next_intent,
|
|
495
|
+
floor,
|
|
477
496
|
);
|
|
478
497
|
if (action.type === "revise_intent" && revisionClass !== "compatible")
|
|
479
498
|
throw new KernelInvariantError([
|
|
@@ -507,6 +526,16 @@ export function reduceTask(
|
|
|
507
526
|
throw new KernelInvariantError([
|
|
508
527
|
"next intent ref must match the next intent",
|
|
509
528
|
]);
|
|
529
|
+
// A compatible scope revision is self-served: the user confirmed nothing,
|
|
530
|
+
// so the added paths ride in this entry's reason for Review to name.
|
|
531
|
+
const selfServedAdditions =
|
|
532
|
+
action.type === "revise_intent"
|
|
533
|
+
? sameDirectoryScopeAdditions(
|
|
534
|
+
record.intent_snapshot.scope_hint,
|
|
535
|
+
action.next_intent.scope_hint,
|
|
536
|
+
floor,
|
|
537
|
+
) ?? []
|
|
538
|
+
: [];
|
|
510
539
|
record.intent_snapshot = { ...action.next_intent };
|
|
511
540
|
record.intent_ref = { ...action.next_intent_ref };
|
|
512
541
|
if (action.type === "approve_breaking_intent_revision") {
|
|
@@ -516,7 +545,16 @@ export function reduceTask(
|
|
|
516
545
|
}
|
|
517
546
|
if (record.artifact_state === "frozen") record.artifact_state = "active";
|
|
518
547
|
}
|
|
519
|
-
appendHistory(
|
|
548
|
+
appendHistory(
|
|
549
|
+
record,
|
|
550
|
+
action,
|
|
551
|
+
from,
|
|
552
|
+
[
|
|
553
|
+
`intent_revision_${record.intent_snapshot.revision}`,
|
|
554
|
+
...scopeAdditionsReasonLines(selfServedAdditions),
|
|
555
|
+
].join("\n"),
|
|
556
|
+
authorityAudit,
|
|
557
|
+
);
|
|
520
558
|
break;
|
|
521
559
|
}
|
|
522
560
|
case "request_rework": {
|
|
@@ -541,6 +579,23 @@ export function reduceTask(
|
|
|
541
579
|
"request_rework requires review, qa, or user authority",
|
|
542
580
|
]);
|
|
543
581
|
const round = reviewRound(record);
|
|
582
|
+
// The reviewed revision is audit data for the next round's delta, not
|
|
583
|
+
// authority: it is legal only on Review rework and, on a v4 record, must
|
|
584
|
+
// name the Enrollment base exactly as an approval does. Its absence is a
|
|
585
|
+
// legacy record and never blocks.
|
|
586
|
+
if (action.review_revision) {
|
|
587
|
+
if (authorityAudit.authority_kind !== "review")
|
|
588
|
+
throw new KernelInvariantError([
|
|
589
|
+
`${authorityAudit.authority_kind} rework cannot carry a review revision`,
|
|
590
|
+
]);
|
|
591
|
+
if (
|
|
592
|
+
record.contract === "assurance_kernel/task_record/v4" &&
|
|
593
|
+
action.review_revision.base_head !== record.git_base_head
|
|
594
|
+
)
|
|
595
|
+
throw new KernelInvariantError([
|
|
596
|
+
"review_revision.base_head must equal the Enrollment git_base_head",
|
|
597
|
+
]);
|
|
598
|
+
}
|
|
544
599
|
// An anchor is authority: it decides whether this claim inherits an
|
|
545
600
|
// existing refutation. The Kernel recomputes it from the finding's own
|
|
546
601
|
// evidence, so a caller cannot bind one claim's identity to another
|
|
@@ -622,7 +677,7 @@ export function reduceTask(
|
|
|
622
677
|
const parkForReplan =
|
|
623
678
|
authorityAudit.authority_kind === "review" &&
|
|
624
679
|
(disputed !== undefined ||
|
|
625
|
-
(hasEffectiveBlockingNow && effectiveBlockingRounds >=
|
|
680
|
+
(hasEffectiveBlockingNow && effectiveBlockingRounds >= effectiveReviewRoundBudget(record)));
|
|
626
681
|
if (!parkForReplan) {
|
|
627
682
|
record.artifact_state = "active";
|
|
628
683
|
record.intent_ref.path = `docs/plans/${record.task_id}.intent.json`;
|
|
@@ -660,10 +715,14 @@ export function reduceTask(
|
|
|
660
715
|
acceptance_id: disputed?.acceptance_id ?? null,
|
|
661
716
|
source: "kernel" as const,
|
|
662
717
|
review_round: round,
|
|
718
|
+
replan_cause:
|
|
719
|
+
disputed !== undefined
|
|
720
|
+
? ("repeated_security_boundary" as const)
|
|
721
|
+
: ("budget_exhausted" as const),
|
|
663
722
|
summary:
|
|
664
723
|
disputed !== undefined
|
|
665
724
|
? "Review returned the same security boundary twice; a durable replan is required."
|
|
666
|
-
: `Review exhausted its ${
|
|
725
|
+
: `Review exhausted its ${effectiveReviewRoundBudget(record)}-round rework budget; a durable replan is required.`,
|
|
667
726
|
};
|
|
668
727
|
if (findingIds.has(boundary.id))
|
|
669
728
|
throw new KernelInvariantError([
|
|
@@ -671,7 +730,14 @@ export function reduceTask(
|
|
|
671
730
|
]);
|
|
672
731
|
record.findings.push(boundary);
|
|
673
732
|
}
|
|
674
|
-
appendHistory(
|
|
733
|
+
appendHistory(
|
|
734
|
+
record,
|
|
735
|
+
action,
|
|
736
|
+
from,
|
|
737
|
+
`review_round_${round}`,
|
|
738
|
+
authorityAudit,
|
|
739
|
+
action.review_revision,
|
|
740
|
+
);
|
|
675
741
|
break;
|
|
676
742
|
}
|
|
677
743
|
case "authorize_rework": {
|
|
@@ -691,11 +757,74 @@ export function reduceTask(
|
|
|
691
757
|
"authorize_rework requires an open replan boundary",
|
|
692
758
|
]);
|
|
693
759
|
for (const finding of open) finding.status = "resolved";
|
|
760
|
+
// The grant is cumulative so the budget is a single derivable number;
|
|
761
|
+
// a legacy caller without `rounds` grants exactly one further round.
|
|
762
|
+
record.review_round_budget_extension =
|
|
763
|
+
(record.review_round_budget_extension ?? 0) + (action.rounds ?? 1);
|
|
694
764
|
record.artifact_state = "active";
|
|
695
765
|
record.intent_ref.path = `docs/plans/${record.task_id}.intent.json`;
|
|
696
766
|
appendHistory(record, action, from, open.map((finding) => finding.id).join(","), authorityAudit);
|
|
697
767
|
break;
|
|
698
768
|
}
|
|
769
|
+
case "accept_residual_risk": {
|
|
770
|
+
// A parked record has already restored its artifacts for replan, so the
|
|
771
|
+
// exit cannot require a frozen snapshot; the open budget boundary below
|
|
772
|
+
// is the real gate and completion re-freezes through the normal path.
|
|
773
|
+
if (record.lifecycle !== "active")
|
|
774
|
+
throw new KernelInvariantError([
|
|
775
|
+
`cannot accept residual risk while lifecycle is ${record.lifecycle}`,
|
|
776
|
+
]);
|
|
777
|
+
if (authorityAudit?.authority_kind !== "user")
|
|
778
|
+
throw new KernelInvariantError([
|
|
779
|
+
"accept_residual_risk requires literal-user authority",
|
|
780
|
+
]);
|
|
781
|
+
const boundaries = record.findings.filter(
|
|
782
|
+
(finding) => finding.kind === "replan_required" && finding.status === "open",
|
|
783
|
+
);
|
|
784
|
+
// Only a budget exhaustion offers this exit. A boundary without a
|
|
785
|
+
// recorded cause predates the field and a repeated security boundary is
|
|
786
|
+
// a dispute about the design: both stay replan or stop only.
|
|
787
|
+
if (
|
|
788
|
+
boundaries.length === 0 ||
|
|
789
|
+
boundaries.some((finding) => finding.replan_cause !== "budget_exhausted")
|
|
790
|
+
)
|
|
791
|
+
throw new KernelInvariantError([
|
|
792
|
+
"accept_residual_risk requires an open replan boundary caused by budget exhaustion",
|
|
793
|
+
]);
|
|
794
|
+
if (record.findings.some((finding) => finding.kind === "unresolved_user_decision" && finding.status === "open"))
|
|
795
|
+
throw new KernelInvariantError([
|
|
796
|
+
"accept_residual_risk cannot settle a task with an open user decision",
|
|
797
|
+
]);
|
|
798
|
+
const accepted = record.findings.filter(
|
|
799
|
+
(finding) =>
|
|
800
|
+
finding.kind === "blocking" &&
|
|
801
|
+
finding.source === "review" &&
|
|
802
|
+
finding.status === "open",
|
|
803
|
+
);
|
|
804
|
+
if (accepted.length === 0)
|
|
805
|
+
throw new KernelInvariantError([
|
|
806
|
+
"accept_residual_risk requires at least one open blocking Review finding",
|
|
807
|
+
]);
|
|
808
|
+
for (const finding of boundaries) finding.status = "resolved";
|
|
809
|
+
for (const finding of accepted) finding.status = "accepted_risk";
|
|
810
|
+
record.residual_risk_acceptance = {
|
|
811
|
+
intent_revision: record.intent_snapshot.revision,
|
|
812
|
+
intent_content_hash: record.intent_ref.content_hash,
|
|
813
|
+
diff_hash: diffHash,
|
|
814
|
+
finding_ids: accepted.map((finding) => finding.id),
|
|
815
|
+
reason: action.reason,
|
|
816
|
+
actor_id: action.actor_id,
|
|
817
|
+
accepted_at: action.at,
|
|
818
|
+
};
|
|
819
|
+
appendHistory(
|
|
820
|
+
record,
|
|
821
|
+
action,
|
|
822
|
+
from,
|
|
823
|
+
`accepted_risk:${accepted.map((finding) => finding.id).join(",")}\n${action.reason}`,
|
|
824
|
+
authorityAudit,
|
|
825
|
+
);
|
|
826
|
+
break;
|
|
827
|
+
}
|
|
699
828
|
case "complete": {
|
|
700
829
|
if (record.lifecycle !== "active" || record.artifact_state !== "frozen")
|
|
701
830
|
throw new KernelInvariantError([
|
|
@@ -709,6 +838,7 @@ export function reduceTask(
|
|
|
709
838
|
diffHash,
|
|
710
839
|
record.intent_ref.content_hash,
|
|
711
840
|
changedPaths,
|
|
841
|
+
floor,
|
|
712
842
|
);
|
|
713
843
|
if (!decision.complete)
|
|
714
844
|
throw new KernelInvariantError([
|