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
|
@@ -10,23 +10,41 @@ On a batch `handoff.role=executor`, implement only its named enrolled child;
|
|
|
10
10
|
the batch stays `running` with that child `enrolled`. Return to the Parent after
|
|
11
11
|
scoped implementation, staging and focused diagnostics. Enrollment itself does
|
|
12
12
|
not start QA; the Parent owns the subsequent Kernel Assurance and foreground
|
|
13
|
-
Review handoff under the same still-valid batch authorization.
|
|
13
|
+
Review handoff under the same still-valid batch authorization. A child settlement
|
|
14
|
+
returns execution to the bound Parent; continue dependency-ready independent
|
|
15
|
+
work until whole-plan integration and final verification pass. Critical and new
|
|
16
|
+
repair tasks use their own native Enrollment; stopped or excluded work never
|
|
17
|
+
counts as Parent success.
|
|
14
18
|
|
|
15
|
-
Before handoff, run the permitted diagnostic checks
|
|
16
|
-
|
|
17
|
-
evidence. The read-only Loop
|
|
19
|
+
Before handoff, run the permitted diagnostic checks and stage the task-owned
|
|
20
|
+
changes. On rework, also complete the Delivery Evidence check below and return
|
|
21
|
+
its table to the Parent as structured diagnostic evidence. The read-only Loop
|
|
18
22
|
runtime action only constructs the dispatch envelope; it does not store
|
|
19
23
|
evidence or change task state. Preserve failed and blocked attempts. Do not
|
|
20
24
|
perform QA,
|
|
21
25
|
review, plan mutation, successor approval, Compounder work, or authority
|
|
22
|
-
writes.
|
|
23
|
-
|
|
26
|
+
writes. A sibling file in a directory the scope already names a file in is a
|
|
27
|
+
same-directory addition: submit `revise_intent` with the complete next intent
|
|
28
|
+
and continue, without a Planner route or a native gate. If the requested change
|
|
29
|
+
needs scope expansion beyond that (a glob, a new directory, a removed entry, a
|
|
30
|
+
path the project's risk floor names in `.imm/risk-floor.json`), stop and return an `imm-planner` route
|
|
31
|
+
with the concrete missing scope and verification reason.
|
|
24
32
|
|
|
25
33
|
## Delivery Evidence
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
Prefer direct checks. For truncated display, use an explicit shell with
|
|
36
|
+
`pipefail`, preserve the check's real exit status and a task-owned full log;
|
|
37
|
+
display success is not check success. Keep global shell settings unchanged.
|
|
38
|
+
When the Spec or user requests test-first, observe a business-assertion red
|
|
39
|
+
before implementation, then the corresponding green at the same seam; missing
|
|
40
|
+
modules, syntax errors or harness failures are not that red. A known omitted
|
|
41
|
+
generated output blocks handoff: fix it within scope or use the existing Intent
|
|
42
|
+
revision path before delivery.
|
|
43
|
+
|
|
44
|
+
Reuse the TaskIntent and any bound Spec's agreed seams and controls. Return one
|
|
45
|
+
compact row per violated acceptance or invariant only when handling rework; a
|
|
46
|
+
first delivery needs no table. This is handoff evidence, not a new artifact or
|
|
47
|
+
approval gate.
|
|
30
48
|
|
|
31
49
|
| Acceptance / invariant | Entry and consumers | Positive / negative / boundary controls | Command, outcome and input | Finding coverage |
|
|
32
50
|
|---|---|---|---|---|
|
|
@@ -37,6 +55,13 @@ both affected hosts and recovery/replay branches. An async, return-type or
|
|
|
37
55
|
exception-contract change requires checking every affected caller's await,
|
|
38
56
|
cancellation and error propagation.
|
|
39
57
|
|
|
58
|
+
Before Assurance, run the existing tests that call a changed entry or assert
|
|
59
|
+
the changed contract, found by searching the tests for its exported names,
|
|
60
|
+
including tests outside `scope_hint`. Focused acceptance descriptors do not run
|
|
61
|
+
them, so a stale caller test otherwise surfaces only at final verification. A
|
|
62
|
+
failing caller test outside the scope is a scope gap: add it through the Intent
|
|
63
|
+
revision path before delivery, never leave it for final verification.
|
|
64
|
+
|
|
40
65
|
Use the real producer for the success control where available. Derive rejection
|
|
41
66
|
controls from a successful state, change only the target condition, and assert
|
|
42
67
|
the intended rejection reason and required side effects. A control must fail
|
|
@@ -75,7 +100,7 @@ internal return values that a public assertion already covers. Before adding a
|
|
|
75
100
|
test, search existing tests for the same behavior and extend or parameterize
|
|
76
101
|
them instead of duplicating setup. When the change retires or replaces a
|
|
77
102
|
behavior, update or delete the tests that protected it within scope and name
|
|
78
|
-
the retired behavior or continuing owner in
|
|
103
|
+
the retired behavior or continuing owner in the handoff.
|
|
79
104
|
Treat naming, function length, parameter count, nesting, and abstraction taste
|
|
80
105
|
as contextual signals, never as automatic failure thresholds.
|
|
81
106
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Observe Claude Code Review and session lifecycle for Immune-Brain, keep a batch Lane's file edits inside its Lane, require a literal-user phase entry before Enrollment, batch start or
|
|
2
|
+
"description": "Observe Claude Code Review and session lifecycle for Immune-Brain, keep a batch Lane's file edits inside its Lane, require a literal-user phase entry before Enrollment, batch start or the imm-run Skill, and announce an active Managed task without resuming it. Hooks never mint authority.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PreToolUse": [
|
|
5
5
|
{
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
]
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
|
-
"matcher": "Skill|mcp__.*immune-brain.*__
|
|
15
|
+
"matcher": "Skill|Agent|mcp__.*immune-brain.*__.*",
|
|
16
16
|
"hooks": [
|
|
17
17
|
{
|
|
18
18
|
"type": "command",
|
|
@@ -61,6 +61,26 @@
|
|
|
61
61
|
]
|
|
62
62
|
}
|
|
63
63
|
],
|
|
64
|
+
"Stop": [
|
|
65
|
+
{
|
|
66
|
+
"hooks": [
|
|
67
|
+
{
|
|
68
|
+
"type": "command",
|
|
69
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/claude/mcp-server.mjs\" --hook"
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
"PostToolUseFailure": [
|
|
75
|
+
{
|
|
76
|
+
"hooks": [
|
|
77
|
+
{
|
|
78
|
+
"type": "command",
|
|
79
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/claude/mcp-server.mjs\" --hook"
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
],
|
|
64
84
|
"SessionEnd": [
|
|
65
85
|
{
|
|
66
86
|
"hooks": [
|
|
@@ -19,8 +19,11 @@ import type { ReviewBundle, ReviewManifestV5 } from "./review_evidence";
|
|
|
19
19
|
import { createVerdictAuthority, type VerdictAuthority } from "./verdict_authority";
|
|
20
20
|
import type { ConfirmationReferenceSource } from "./host_port";
|
|
21
21
|
import { buildRoleDelegationPacket, STATIC_REVIEW_RULES } from "../role_prompt_bridge";
|
|
22
|
-
import type
|
|
22
|
+
import { projectAssurance, type AssuranceProjectionResult } from "../kernel/assurance_projection";
|
|
23
|
+
import { taskStatusIdentity } from "../workspace_scope";
|
|
24
|
+
import { pluginSourceSkewWarning, type PluginSourceSkewWarning } from "../plugin_source_skew";
|
|
23
25
|
import type { TaskIntentIdentityToken } from "../kernel/intent_token_registry";
|
|
26
|
+
import { scopeAdditionsFromHistory } from "../kernel/intent";
|
|
24
27
|
import type { AssuranceHostPort, HostReviewReservation } from "./host_port";
|
|
25
28
|
import type { GithubTrackerResult as GithubTrackerProjectionResult } from "../github_issue_tracker";
|
|
26
29
|
import { isLaneWorkspaceForTask } from "../unattended/lane_workspace";
|
|
@@ -43,7 +46,12 @@ export interface TaskTombstone {
|
|
|
43
46
|
}
|
|
44
47
|
export interface TaskRecordRead {
|
|
45
48
|
revision?: string;
|
|
46
|
-
record?: {
|
|
49
|
+
record?: {
|
|
50
|
+
contract?: string;
|
|
51
|
+
findings: Array<{ kind: string; status: string; id?: string; acceptance_id?: string | null }>;
|
|
52
|
+
/** Kernel history; Review disclosure reads `revise_intent` reasons from it. */
|
|
53
|
+
history?: Array<{ type: string; reason: string }>;
|
|
54
|
+
} | null;
|
|
47
55
|
}
|
|
48
56
|
export interface TaskIntentRead {
|
|
49
57
|
/**
|
|
@@ -159,9 +167,13 @@ export interface AssuranceVerdict {
|
|
|
159
167
|
export function reviewReworkFindings(verdict: AssuranceVerdict): TaskFinding[] {
|
|
160
168
|
if (verdict.decision !== "rework" || !verdict.findings?.length)
|
|
161
169
|
throw new Error("review rework findings require a rework verdict");
|
|
170
|
+
// Advisory notes belong to a pass attestation; one that reached a rework
|
|
171
|
+
// verdict is a contract violation, never Executor work to filter away.
|
|
172
|
+
if (verdict.findings.some((finding) => finding.kind !== "blocking"))
|
|
173
|
+
throw new Error("review rework findings must all be blocking");
|
|
162
174
|
return verdict.findings.map((finding) => ({
|
|
163
175
|
id: finding.id,
|
|
164
|
-
kind:
|
|
176
|
+
kind: "blocking" as const,
|
|
165
177
|
status: "open" as const,
|
|
166
178
|
acceptance_id: finding.acceptance_id,
|
|
167
179
|
source: "review" as const,
|
|
@@ -221,6 +233,14 @@ export interface SnapshotDescriptor {
|
|
|
221
233
|
review_bundle_digest: string | null;
|
|
222
234
|
/** Present only when Review authority binds an immutable Git revision (v4). */
|
|
223
235
|
review_revision?: ReviewRevisionIdentity;
|
|
236
|
+
/**
|
|
237
|
+
* Review round 3 and later: the paths the latest rework changed. `skipped`
|
|
238
|
+
* means the prior revision was absent or unresolvable and the admission rule
|
|
239
|
+
* does not apply to this round.
|
|
240
|
+
*/
|
|
241
|
+
review_delta?:
|
|
242
|
+
| { round: number; prior_review_commit: string; delta_paths: string[] }
|
|
243
|
+
| { round: number; skipped: "prior_review_revision_absent" | "prior_review_revision_unresolvable" };
|
|
224
244
|
root: string;
|
|
225
245
|
}
|
|
226
246
|
|
|
@@ -257,6 +277,56 @@ export function qaDiagnosticMetadata(d: QaCheckDiagnostic): QaCheckDiagnostic {
|
|
|
257
277
|
return safe;
|
|
258
278
|
}
|
|
259
279
|
|
|
280
|
+
/**
|
|
281
|
+
* What a failed `prepare` stage ran under, plus the end of its stderr; for an
|
|
282
|
+
* in-place run that found the working directory different from the frozen
|
|
283
|
+
* tree, the differing in-scope paths. It travels only in the direct Assurance
|
|
284
|
+
* Tool result: it is not part of `QaCheckDiagnostic` and is never persisted
|
|
285
|
+
* (no TaskRecord, finding, audit evidence, batch state or tracker projection).
|
|
286
|
+
*/
|
|
287
|
+
export type QaDeliveryConditions =
|
|
288
|
+
| { delivery: "working_directory" }
|
|
289
|
+
| { delivery: "isolated_materialization"; worktree_node_modules: "absent" };
|
|
290
|
+
export interface QaPrepareObservation {
|
|
291
|
+
conditions: QaDeliveryConditions;
|
|
292
|
+
declared_timeout_ms: number;
|
|
293
|
+
stderr_tail: string;
|
|
294
|
+
drift_paths?: string[];
|
|
295
|
+
}
|
|
296
|
+
export const QA_DRIFT_PATH_LIMIT = 64;
|
|
297
|
+
export const QA_PREPARE_STDERR_TAIL_BYTES = 2048;
|
|
298
|
+
export const QA_PREPARE_REPRODUCTION_NOTE = "a reproduction in the live worktree is not equivalent: this run used an isolated delivery with no worktree node_modules";
|
|
299
|
+
export const QA_DRIFT_NOTE = "in-scope tracked content of the working directory differs from the frozen snapshot; see prepare_observation.drift_paths";
|
|
300
|
+
const QA_PREPARE_REASONS: QaPreparationReason[] = ["timeout", "nonzero_exit", "output_limit"];
|
|
301
|
+
// Control characters other than newline and tab: C0, DEL and C1.
|
|
302
|
+
const QA_TAIL_CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
|
|
303
|
+
/** The last bytes of stderr, whole UTF-8 characters only, without terminal control sequences. */
|
|
304
|
+
export function qaPrepareStderrTail(stderr: string): string {
|
|
305
|
+
const bytes = Buffer.from(stderr, "utf8");
|
|
306
|
+
let start = Math.max(0, bytes.length - QA_PREPARE_STDERR_TAIL_BYTES);
|
|
307
|
+
// Do not begin inside a multi-byte character.
|
|
308
|
+
while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start++;
|
|
309
|
+
return bytes.subarray(start).toString("utf8").replace(QA_TAIL_CONTROL, "");
|
|
310
|
+
}
|
|
311
|
+
export function qaPrepareObservation(declaredTimeoutMs: number, stderr: string,
|
|
312
|
+
delivery: QaDeliveryConditions["delivery"] = "working_directory", driftPaths?: string[]): QaPrepareObservation {
|
|
313
|
+
return {
|
|
314
|
+
conditions: delivery === "isolated_materialization" ? { delivery, worktree_node_modules: "absent" } : { delivery: "working_directory" },
|
|
315
|
+
declared_timeout_ms: declaredTimeoutMs,
|
|
316
|
+
stderr_tail: qaPrepareStderrTail(stderr),
|
|
317
|
+
...(driftPaths ? { drift_paths: driftPaths.slice(0, QA_DRIFT_PATH_LIMIT).map(path => path.replace(QA_TAIL_CONTROL, "").slice(0, 512)) } : {}),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/** The Tool boundary never spreads arbitrary executor fields. */
|
|
321
|
+
export function qaPrepareObservationMetadata(o: QaPrepareObservation): QaPrepareObservation {
|
|
322
|
+
const delivery = o.conditions?.delivery;
|
|
323
|
+
if (!Number.isSafeInteger(o.declared_timeout_ms) || o.declared_timeout_ms < 0 || typeof o.stderr_tail !== "string"
|
|
324
|
+
|| (delivery !== "working_directory" && delivery !== "isolated_materialization")
|
|
325
|
+
|| (o.drift_paths !== undefined && (!Array.isArray(o.drift_paths) || o.drift_paths.some(path => typeof path !== "string"))))
|
|
326
|
+
throw new Error("invalid QA prepare observation");
|
|
327
|
+
return qaPrepareObservation(o.declared_timeout_ms, o.stderr_tail, delivery, o.drift_paths);
|
|
328
|
+
}
|
|
329
|
+
|
|
260
330
|
export interface QaVerificationProgressInput {
|
|
261
331
|
index: number;
|
|
262
332
|
total: number;
|
|
@@ -267,13 +337,18 @@ export interface QaVerificationProgressInput {
|
|
|
267
337
|
}
|
|
268
338
|
|
|
269
339
|
export class QaPreparationError extends Error {
|
|
340
|
+
/** Present for a `prepare` stage ending in timeout, nonzero exit or output limit, and for in-place drift. */
|
|
341
|
+
public readonly prepare_observation?: QaPrepareObservation;
|
|
270
342
|
constructor(public readonly stage: QaCheckDiagnostic["stage"], public readonly acceptance_ids: string[], public readonly reason: QaPreparationReason,
|
|
271
|
-
public readonly diagnostics: QaCheckDiagnostic[] = []) {
|
|
343
|
+
public readonly diagnostics: QaCheckDiagnostic[] = [], prepareObservation?: QaPrepareObservation) {
|
|
272
344
|
if (!QA_STAGES.includes(stage) || !QA_OUTCOMES.includes(reason)) throw new Error("invalid QA preparation classification");
|
|
273
345
|
const safe = diagnostics.map(qaDiagnosticMetadata);
|
|
274
346
|
if (Buffer.byteLength(JSON.stringify(safe)) > 16_384) throw new QaPreparationError("prepare", [], "execution_metadata_limit_exceeded");
|
|
275
347
|
super(`QA ${stage} failed (${reason}); affected checks=${acceptance_ids.length}`);
|
|
276
348
|
this.diagnostics = safe;
|
|
349
|
+
if (prepareObservation && ((stage === "prepare" && QA_PREPARE_REASONS.includes(reason))
|
|
350
|
+
|| (reason === "protected_input_or_output_drift" && prepareObservation.drift_paths)))
|
|
351
|
+
this.prepare_observation = qaPrepareObservationMetadata(prepareObservation);
|
|
277
352
|
this.name = "QaPreparationError";
|
|
278
353
|
}
|
|
279
354
|
}
|
|
@@ -291,6 +366,38 @@ export interface AssuranceRecovery {
|
|
|
291
366
|
next_action: string;
|
|
292
367
|
}
|
|
293
368
|
|
|
369
|
+
export interface StatusProjectionWarning {
|
|
370
|
+
code: "unstaged_scope_paths" | PluginSourceSkewWarning["code"];
|
|
371
|
+
paths: string[];
|
|
372
|
+
message: string;
|
|
373
|
+
}
|
|
374
|
+
export type StatusProjectionResult = AssuranceProjectionResult & { warnings?: StatusProjectionWarning[] };
|
|
375
|
+
/**
|
|
376
|
+
* The read-only status projection both Hosts return from their status Tool.
|
|
377
|
+
* Unstaged or untracked in-scope paths, and a loaded plugin build that differs
|
|
378
|
+
* from this workspace's own plugin source, come back as warnings beside the
|
|
379
|
+
* projection instead of replacing it. Every path that freezes a snapshot or
|
|
380
|
+
* binds authority keeps projecting through the refusing delivery identity.
|
|
381
|
+
*/
|
|
382
|
+
export async function projectTaskStatus(root: string, taskId: string, loadedFrom?: string): Promise<StatusProjectionResult> {
|
|
383
|
+
let unstaged: string[] = [];
|
|
384
|
+
const result = await projectAssurance(root, taskId, (dir, record) => {
|
|
385
|
+
const identity = taskStatusIdentity(dir, record);
|
|
386
|
+
unstaged = identity.unstaged_scope_paths;
|
|
387
|
+
return identity;
|
|
388
|
+
});
|
|
389
|
+
const warnings: StatusProjectionWarning[] = [];
|
|
390
|
+
if (!result.error && unstaged.length > 0) warnings.push({
|
|
391
|
+
code: "unstaged_scope_paths",
|
|
392
|
+
paths: unstaged,
|
|
393
|
+
message: `task scope contains unstaged or untracked changes: ${unstaged.join(", ")}; advance_assurance refuses until they are staged or removed`,
|
|
394
|
+
});
|
|
395
|
+
// Reported beside errors too: a stale loaded build is the likely cause of one.
|
|
396
|
+
const skew = pluginSourceSkewWarning(root, loadedFrom);
|
|
397
|
+
if (skew) warnings.push(skew);
|
|
398
|
+
return warnings.length === 0 ? result : { ...result, warnings };
|
|
399
|
+
}
|
|
400
|
+
|
|
294
401
|
/** Observation only; fresh Kernel facts remain the sole owner of the obligation. */
|
|
295
402
|
export function deriveAssuranceRecovery(taskId: string, fresh: AssuranceProjectionResult,
|
|
296
403
|
result: { state?: string; environment_failure?: boolean; diagnostics?: QaCheckDiagnostic[]; reason?: string },
|
|
@@ -332,6 +439,8 @@ export interface AssuranceTimings { total_ms: number; stage_ms: Record<string, n
|
|
|
332
439
|
type AssuranceRecoveryFields = {
|
|
333
440
|
timings?: AssuranceTimings;
|
|
334
441
|
diagnostics?: QaCheckDiagnostic[];
|
|
442
|
+
/** Direct Tool result only; never persisted (see `QaPrepareObservation`). */
|
|
443
|
+
prepare_observation?: QaPrepareObservation;
|
|
335
444
|
environment_failure?: boolean;
|
|
336
445
|
recovery?: AssuranceRecovery;
|
|
337
446
|
recovery_error?: "fresh_kernel_projection_unavailable";
|
|
@@ -467,6 +576,22 @@ export function snapshotDigest(snapshot: SnapshotDescriptor): string {
|
|
|
467
576
|
return `sha256:${createHash("sha256").update(JSON.stringify(snapshot)).digest("hex")}`;
|
|
468
577
|
}
|
|
469
578
|
|
|
579
|
+
export interface ReviewPromptOptions {
|
|
580
|
+
/**
|
|
581
|
+
* Paths added by compatible scope revisions, derived from Kernel history.
|
|
582
|
+
* The user confirmed none of them, so Review is told to treat each as task
|
|
583
|
+
* work and to check it against the goal (ADR 0021).
|
|
584
|
+
*/
|
|
585
|
+
selfServedScopeAdditions?: readonly string[];
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function selfServedScopeLines(added: readonly string[] | undefined): string[] {
|
|
589
|
+
if (!added?.length) return [];
|
|
590
|
+
return [
|
|
591
|
+
`Self-served scope additions (added by compatible Intent revisions with no user confirmation; judge each against the goal and its acceptance, not as pre-approved): ${added.join(", ")}`,
|
|
592
|
+
];
|
|
593
|
+
}
|
|
594
|
+
|
|
470
595
|
/**
|
|
471
596
|
* The per-dispatch Review facts: which evidence to read, which revision it pins,
|
|
472
597
|
* what the acceptances assert, and the exact verdict shapes.
|
|
@@ -476,7 +601,20 @@ export function snapshotDigest(snapshot: SnapshotDescriptor): string {
|
|
|
476
601
|
* dispatched agent carries no instructions (Pi) gets `buildReviewPrompt`, which
|
|
477
602
|
* prepends both.
|
|
478
603
|
*/
|
|
479
|
-
|
|
604
|
+
function reviewDeltaLines(delta: SnapshotDescriptor["review_delta"]): string[] {
|
|
605
|
+
if (!delta) return [];
|
|
606
|
+
if ("skipped" in delta)
|
|
607
|
+
return [`Review round ${delta.round}: no rework delta is available (${delta.skipped}); the delta admission rule does not apply to this round.`];
|
|
608
|
+
return [
|
|
609
|
+
`Review round ${delta.round}: review only the latest rework delta and its direct interactions. Prior reviewed commit ${delta.prior_review_commit}; delta_paths: ${delta.delta_paths.length ? delta.delta_paths.join(", ") : "(none)"}. A blocking finding must have a caller_chain path in delta_paths or violated.kind "security_boundary"; otherwise it is advisory on a pass verdict.`,
|
|
610
|
+
];
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export function buildReviewSnapshotPrompt(
|
|
614
|
+
snapshot: SnapshotDescriptor,
|
|
615
|
+
evidencePath?: string,
|
|
616
|
+
options: ReviewPromptOptions = {},
|
|
617
|
+
): string {
|
|
480
618
|
if (snapshot.role !== "review") throw new Error("native review prompt requires review role");
|
|
481
619
|
const acceptance = snapshot.acceptance.map((item) => `- ${item.id}: ${item.assertion}`).join("\n");
|
|
482
620
|
const digest = snapshotDigest(snapshot);
|
|
@@ -501,11 +639,13 @@ export function buildReviewSnapshotPrompt(snapshot: SnapshotDescriptor, evidence
|
|
|
501
639
|
revision
|
|
502
640
|
? `Intent revision ${snapshot.intent_revision} (hash ${snapshot.intent_content_hash}), diff ${snapshot.diff_hash}, review revision ${revision.review_commit} (base ${revision.base_head}, tree ${revision.review_tree}, manifest ${revision.manifest_digest}), state ${snapshot.lifecycle}:${snapshot.artifact_state}.`
|
|
503
641
|
: `Intent revision ${snapshot.intent_revision} (hash ${snapshot.intent_content_hash}), diff ${snapshot.diff_hash}, review bundle ${snapshot.review_bundle_digest}, state ${snapshot.lifecycle}:${snapshot.artifact_state}.`,
|
|
642
|
+
...selfServedScopeLines(options.selfServedScopeAdditions),
|
|
643
|
+
...reviewDeltaLines(snapshot.review_delta),
|
|
504
644
|
"Acceptance assertions:", acceptance,
|
|
505
645
|
`PASS shape: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"pass","approval":{"kind":"review","authority_role":"reviewer","summary":"<one line>","inspected_paths":["<every changed path of the reviewed change set>"]}}`,
|
|
506
646
|
`A pass verdict may carry non-blocking notes as findings, but every one of them must set kind "advisory"; a blocking finding is a rework verdict and must omit approval: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"pass","approval":{"kind":"review","authority_role":"reviewer","summary":"<one line>","inspected_paths":["<every changed path of the reviewed change set>"]},"findings":[{"id":"review-1","kind":"advisory","acceptance_id":"<id|null>","summary":"<one line>","evidence":{"trigger":"<concrete inputs or state>","caller_chain":["<path-or-symbol>"],"violated":{"kind":"acceptance|security_boundary","ref":"<acceptance id or boundary>"}}}]}`,
|
|
507
|
-
`REWORK shape: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"rework","findings":[{"id":"review-1","kind":"blocking
|
|
508
|
-
`REWORK verdicts must omit the approval field entirely; do not emit "approval": null.`,
|
|
647
|
+
`REWORK shape: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"rework","findings":[{"id":"review-1","kind":"blocking","acceptance_id":"<id|null>","summary":"<one line>","evidence":{"trigger":"<concrete input or state → observable wrong result>","caller_chain":["<path-or-symbol>"],"violated":{"kind":"acceptance|security_boundary","ref":"<acceptance id or boundary>"}}}]}`,
|
|
648
|
+
`REWORK verdicts must omit the approval field entirely; do not emit "approval": null. A rework carries only blocking findings, at least one; a note that blocks nothing is an advisory finding on a pass verdict, never on rework.`,
|
|
509
649
|
].join("\n");
|
|
510
650
|
}
|
|
511
651
|
|
|
@@ -514,7 +654,11 @@ export function buildReviewSnapshotPrompt(snapshot: SnapshotDescriptor, evidence
|
|
|
514
654
|
* rules, and the per-dispatch facts. Dispatched by a Host whose agent carries no
|
|
515
655
|
* instructions of its own.
|
|
516
656
|
*/
|
|
517
|
-
export function buildReviewPrompt(
|
|
657
|
+
export function buildReviewPrompt(
|
|
658
|
+
snapshot: SnapshotDescriptor,
|
|
659
|
+
evidencePath?: string,
|
|
660
|
+
options: ReviewPromptOptions = {},
|
|
661
|
+
): string {
|
|
518
662
|
if (snapshot.role !== "review") throw new Error("native review prompt requires review role");
|
|
519
663
|
const digest = snapshotDigest(snapshot);
|
|
520
664
|
const rolePacket = buildRoleDelegationPacket({
|
|
@@ -529,7 +673,7 @@ export function buildReviewPrompt(snapshot: SnapshotDescriptor, evidencePath?: s
|
|
|
529
673
|
return [
|
|
530
674
|
rolePacket.prompt,
|
|
531
675
|
...STATIC_REVIEW_RULES,
|
|
532
|
-
buildReviewSnapshotPrompt(snapshot, evidencePath),
|
|
676
|
+
buildReviewSnapshotPrompt(snapshot, evidencePath, options),
|
|
533
677
|
].join("\n");
|
|
534
678
|
}
|
|
535
679
|
|
|
@@ -642,9 +786,46 @@ export function parseAssuranceVerdict(input: unknown, snapshot: SnapshotDescript
|
|
|
642
786
|
if (!Array.isArray(raw.findings) || raw.findings.length === 0) throw new Error("rework verdict findings are invalid");
|
|
643
787
|
if (raw.approval !== undefined && raw.approval !== null) throw new Error("rework verdict must omit approval");
|
|
644
788
|
const findings = parseVerdictFindings(raw.findings, snapshot);
|
|
789
|
+
// The verdict bytes stay the Reviewer's: a Review rework that mixes in
|
|
790
|
+
// advisory notes, or blocks on nothing, is rejected for resubmission and
|
|
791
|
+
// never demoted, filtered or synthesized here.
|
|
792
|
+
if (snapshot.role === "review") {
|
|
793
|
+
const outOfDelta = outOfDeltaBlockingFindings(findings, snapshot);
|
|
794
|
+
if (outOfDelta.length > 0)
|
|
795
|
+
throw new Error(`review round ${snapshot.review_delta!.round} admits a blocking finding only when a caller_chain path is in the rework delta or it is a security_boundary violation; finding(s) ${outOfDelta.map((finding) => finding.id).join(", ")} are outside the delta (${(snapshot.review_delta as { delta_paths: string[] }).delta_paths.join(", ") || "no changed paths"}); resubmit each as an advisory finding on a pass verdict, or justify it with violated.kind "security_boundary"`);
|
|
796
|
+
const advisory = findings.filter((finding) => finding.kind === "advisory");
|
|
797
|
+
if (advisory.length > 0 || !findings.some((finding) => finding.kind === "blocking"))
|
|
798
|
+
throw new Error(`review rework verdict must contain only blocking findings and at least one: ${advisory.length > 0 ? `advisory finding(s) ${advisory.map((finding) => finding.id).join(", ")} are not allowed on rework; ` : "it has no blocking finding; "}resubmit decision "pass" with approval.inspected_paths listing every reviewed changed path when nothing blocks, carrying any non-blocking notes as advisory findings`);
|
|
799
|
+
}
|
|
645
800
|
return { contract: "assurance_kernel/assurance_verdict/v2", role: snapshot.role, task_id: snapshot.task_id, snapshot_digest: snapshotDigest(snapshot), decision: "rework", findings };
|
|
646
801
|
}
|
|
647
802
|
|
|
803
|
+
/**
|
|
804
|
+
* Review round 3 and later admits a blocking finding only when it touches the
|
|
805
|
+
* latest rework delta or crosses a security boundary. The rule is skipped, never
|
|
806
|
+
* blocking, when no delta was computed for the round.
|
|
807
|
+
*/
|
|
808
|
+
function outOfDeltaBlockingFindings(
|
|
809
|
+
findings: ReturnType<typeof parseVerdictFindings>,
|
|
810
|
+
snapshot: SnapshotDescriptor,
|
|
811
|
+
): Array<{ id: string }> {
|
|
812
|
+
const delta = snapshot.review_delta;
|
|
813
|
+
if (!delta || !("delta_paths" in delta)) return [];
|
|
814
|
+
const changed = new Set(delta.delta_paths);
|
|
815
|
+
return findings.filter((finding) => {
|
|
816
|
+
if (finding.kind !== "blocking") return false;
|
|
817
|
+
const evidence = (finding as { evidence?: { violated: { kind: string }; caller_chain: string[] } }).evidence;
|
|
818
|
+
if (!evidence) return false;
|
|
819
|
+
if (evidence.violated.kind === "security_boundary") return false;
|
|
820
|
+
return !evidence.caller_chain.some((entry) => changed.has(callerChainPath(entry)));
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** The path component of a caller_chain entry: drops a `:line`, `#symbol` or `(` suffix. */
|
|
825
|
+
function callerChainPath(entry: string): string {
|
|
826
|
+
return entry.trim().replace(/^\.\//, "").split(/[:#(\s]/, 1)[0] ?? "";
|
|
827
|
+
}
|
|
828
|
+
|
|
648
829
|
/**
|
|
649
830
|
* Parse one verdict's findings. Review findings carry evidence and derive their
|
|
650
831
|
* anchor here so a capability minted for one anchor set cannot be spent on
|
|
@@ -1151,11 +1332,17 @@ export class AssuranceCoordinator {
|
|
|
1151
1332
|
try {
|
|
1152
1333
|
ensureOperationLive();
|
|
1153
1334
|
const workload = classifyReviewWorkload(review.snapshot, review.reviewManifest ?? review.reviewBundle!);
|
|
1335
|
+
// Derived from Kernel history on every dispatch; nothing is stored.
|
|
1336
|
+
const promptOptions: ReviewPromptOptions = {
|
|
1337
|
+
selfServedScopeAdditions: scopeAdditionsFromHistory(
|
|
1338
|
+
(await this.ports.readTaskRecord(ctx.cwd, taskId)).record?.history ?? [],
|
|
1339
|
+
),
|
|
1340
|
+
};
|
|
1154
1341
|
hostReservation = this.ports.host.prepareReview({
|
|
1155
1342
|
taskId,
|
|
1156
1343
|
operationId,
|
|
1157
|
-
prompt: buildReviewPrompt(review.snapshot, evidence.path),
|
|
1158
|
-
snapshotPrompt: buildReviewSnapshotPrompt(review.snapshot, evidence.path),
|
|
1344
|
+
prompt: buildReviewPrompt(review.snapshot, evidence.path, promptOptions),
|
|
1345
|
+
snapshotPrompt: buildReviewSnapshotPrompt(review.snapshot, evidence.path, promptOptions),
|
|
1159
1346
|
evidencePath: evidence.path,
|
|
1160
1347
|
maxTurns: reviewTurnBudget(workload),
|
|
1161
1348
|
});
|
|
@@ -1182,8 +1369,11 @@ export class AssuranceCoordinator {
|
|
|
1182
1369
|
return { state: "failed", operation: "qa", operation_id: operationId, reason: `${phase}: ${boundedAssuranceError(error)}`,
|
|
1183
1370
|
recovery_error: "fresh_kernel_projection_unavailable" };
|
|
1184
1371
|
if (error instanceof QaPreparationError && !authorityCommitted && !authorityBoundaryStarted && !aborted())
|
|
1185
|
-
return { state: "failed", operation: "qa", operation_id: operationId,
|
|
1186
|
-
|
|
1372
|
+
return { state: "failed", operation: "qa", operation_id: operationId,
|
|
1373
|
+
reason: error.prepare_observation?.drift_paths ? `${error.message}; ${QA_DRIFT_NOTE}`
|
|
1374
|
+
: error.prepare_observation?.conditions.delivery === "isolated_materialization" ? `${error.message}; ${QA_PREPARE_REPRODUCTION_NOTE}` : error.message,
|
|
1375
|
+
environment_failure: true, diagnostics: error.diagnostics.map(qaDiagnosticMetadata),
|
|
1376
|
+
...(error.prepare_observation ? { prepare_observation: qaPrepareObservationMetadata(error.prepare_observation) } : {}) };
|
|
1187
1377
|
if (phase === "verifying" && !authorityCommitted && !authorityBoundaryStarted && !aborted() && !(error instanceof VerificationAbortedError))
|
|
1188
1378
|
return { state: "failed", operation: "qa", operation_id: operationId,
|
|
1189
1379
|
reason: "QA execution failed before attestation (execution_failed)", environment_failure: true };
|
|
@@ -118,7 +118,8 @@ function assertNoEscapingSymlinks(root: string, dir = root): void {
|
|
|
118
118
|
|
|
119
119
|
|
|
120
120
|
export function writeDeliveryTree(sourceRoot: string, snapshot: GitTaskRevisionSnapshot): string {
|
|
121
|
-
|
|
121
|
+
// Inside the Git directory: a task QA run creates nothing outside the repository.
|
|
122
|
+
const indexDirectory = mkdtempSync(join(git(sourceRoot, ["rev-parse", "--absolute-git-dir"]), "imm-delivery-index-"));
|
|
122
123
|
try {
|
|
123
124
|
git(sourceRoot, ["read-tree", snapshot.base_tree], {
|
|
124
125
|
GIT_INDEX_FILE: join(indexDirectory, "index"),
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import { observeEnrollmentTaskIntent } from "../kernel/intent";
|
|
3
|
+
import { inspectSpecBinding } from "../kernel/spec_binding";
|
|
4
|
+
import { dirtyScopePaths } from "../workspace_scope";
|
|
5
|
+
import { commitWorkflowPaths, identityEnv, type WorkflowCommitResult } from "./workflow_autocommit";
|
|
2
6
|
import { preparePiCanary, readGitHead, revalidatePiCanary, type PiCanaryPreparation, type PiCanaryPrepareInput } from "../kernel/pi_canary_prepare";
|
|
3
|
-
import { withKernelStoreLock } from "../kernel/storage";
|
|
7
|
+
import { reconcileKernelAuthority, withKernelStoreLock } from "../kernel/storage";
|
|
4
8
|
|
|
5
9
|
export type EnrollmentGitBase = { state: "committed"; head: string } | { state: "unborn"; branch: string };
|
|
6
10
|
|
|
@@ -28,18 +32,100 @@ export function enrollmentGitBaseNotice(base: EnrollmentGitBase): string | undef
|
|
|
28
32
|
: undefined;
|
|
29
33
|
}
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
35
|
+
/** Subject of the commit Enrollment makes for its own preconditions. */
|
|
36
|
+
export function enrollmentPreconditionSubject(taskId: string): string {
|
|
37
|
+
return `chore(imm): commit Enrollment preconditions for ${taskId}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The paths Enrollment commits for `taskId`, empty when no precondition would
|
|
42
|
+
* fail. On a committed HEAD an untracked TaskIntent is the one failing state:
|
|
43
|
+
* the set is then the task's own TaskIntent and the Spec its binding names.
|
|
44
|
+
* In-scope dirt is not committed on a committed HEAD — the confirmed
|
|
45
|
+
* Enrollment gate claims it as task work (host-owned-environment D8), and a
|
|
46
|
+
* staged TaskIntent over a clean scope fails nothing and yields no commit. The
|
|
47
|
+
* `unbornScope` form is the exception: there the empty root commit is what
|
|
48
|
+
* makes the Planner's staged and untracked in-scope edits dirty, and the
|
|
49
|
+
* disclosure could not list them because no HEAD existed to compare against,
|
|
50
|
+
* so the commit carries them. The TaskIntent is read from the worktree file,
|
|
51
|
+
* tracked or not. Returns null when no set can be derived (no sidecar, an
|
|
52
|
+
* invalid one, an unborn HEAD); the ordinary Enrollment checks then report
|
|
53
|
+
* that condition themselves.
|
|
54
|
+
*/
|
|
55
|
+
export function enrollmentCommittablePaths(root: string, taskId: string, unbornScope = false): string[] | null {
|
|
56
|
+
const intentPath = `docs/plans/${taskId}.intent.json`;
|
|
57
|
+
try {
|
|
58
|
+
if (inspectEnrollmentGitBase(root).state !== "committed") return null;
|
|
59
|
+
const { intent } = observeEnrollmentTaskIntent(root, taskId);
|
|
60
|
+
const binding = inspectSpecBinding(intent);
|
|
61
|
+
if (!binding.ok) return null;
|
|
62
|
+
const dirtyScope = unbornScope ? dirtyScopePaths(root, intent.scope_hint, taskId) : [];
|
|
63
|
+
const untrackedIntent = spawnSync("git", ["ls-files", "--error-unmatch", "--", intentPath], { cwd: root }).status === 1;
|
|
64
|
+
if (dirtyScope.length === 0 && !untrackedIntent) return [];
|
|
65
|
+
return [...new Set([intentPath, ...(binding.binding ? [binding.binding.active] : []), ...dirtyScope])];
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read-only. True when no Kernel owner would refuse a fresh Enrollment of
|
|
73
|
+
* `taskId`: the authority projection is unowned (or carries only historical
|
|
74
|
+
* audit evidence with no local run), and no backend claim, TaskRecord or
|
|
75
|
+
* workspace owner exists. An unreadable owner state does not admit.
|
|
76
|
+
*/
|
|
77
|
+
function freshEnrollmentAdmitted(root: string, taskId: string): boolean {
|
|
78
|
+
try {
|
|
79
|
+
const authority = reconcileKernelAuthority(root, taskId);
|
|
80
|
+
if (authority.state !== "unowned" && !(authority.state === "terminal_owner" && authority.owner_run_id === null))
|
|
81
|
+
return false;
|
|
82
|
+
const owners = preparePiCanary(root, { task_id: taskId, now: new Date().toISOString() });
|
|
83
|
+
return !owners.backend_claim.present && !owners.task_record_v3?.present && owners.workspace.current_working === null;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Commit the Enrollment committable set when a precondition would fail, so the
|
|
91
|
+
* preparation that follows binds the resulting HEAD. A clean set makes no
|
|
92
|
+
* commit, and neither does an Enrollment an owner check would refuse anyway:
|
|
93
|
+
* an owned workspace keeps its owner's undelivered work uncommitted. A refused
|
|
94
|
+
* commit throws with Git's message.
|
|
95
|
+
*/
|
|
96
|
+
export function commitEnrollmentPreconditions(root: string, taskId: string, signal?: AbortSignal, unbornScope = false): WorkflowCommitResult {
|
|
97
|
+
return withKernelStoreLock(root, () => {
|
|
98
|
+
const paths = enrollmentCommittablePaths(root, taskId, unbornScope);
|
|
99
|
+
if (!paths || paths.length === 0 || !freshEnrollmentAdmitted(root, taskId)) return { committed: false };
|
|
100
|
+
return commitWorkflowPaths(root, { paths, subject: enrollmentPreconditionSubject(taskId), signal });
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A preparation's owner facts, without the fields a new Git base legitimately moves. */
|
|
105
|
+
const withoutGit = ({ digest, git_base_head, git_error, ...owners }: PiCanaryPreparation) => owners;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Unborn-HEAD path only, called after `initializeEnrollmentGitBase`: the scope
|
|
109
|
+
* could not be compared with an unborn HEAD, so commit it now that a base
|
|
110
|
+
* exists, in-scope edits included. The re-preparation must equal the confirmed
|
|
111
|
+
* one in everything but the Git base, exactly as the empty base commit
|
|
112
|
+
* requires; a TaskIntent or owner changed while the commit ran is refused, not
|
|
113
|
+
* enrolled.
|
|
114
|
+
*/
|
|
115
|
+
export function commitUnbornEnrollmentPreconditions(
|
|
116
|
+
root: string, input: PiCanaryPrepareInput, confirmed: PiCanaryPreparation, signal?: AbortSignal,
|
|
117
|
+
): { result: WorkflowCommitResult; preparation: PiCanaryPreparation } {
|
|
118
|
+
const result = commitEnrollmentPreconditions(root, input.task_id, signal, true);
|
|
119
|
+
if (!result.committed) return { result, preparation: confirmed };
|
|
120
|
+
const current = preparePiCanary(root, input);
|
|
121
|
+
if (current.git_base_head !== result.commit || JSON.stringify(withoutGit(current)) !== JSON.stringify(withoutGit(confirmed)))
|
|
122
|
+
throw new Error(`Workspace changed during the Enrollment precondition commit; retry Enrollment${preconditionCommitNote(result)}`);
|
|
123
|
+
return { result, preparation: current };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Suffix naming a precondition commit that outlives a failed or declined Enrollment. */
|
|
127
|
+
export function preconditionCommitNote(result: WorkflowCommitResult): string {
|
|
128
|
+
return result.committed ? `; Enrollment precondition commit ${result.commit} remains` : "";
|
|
43
129
|
}
|
|
44
130
|
|
|
45
131
|
/** Called only after native confirmation. Never stages files or writes Git config. */
|
|
@@ -70,7 +156,6 @@ export function initializeEnrollmentGitBase(
|
|
|
70
156
|
git(root, ["update-ref", "--no-deref", base.branch, commit, "0".repeat(commit.length)]);
|
|
71
157
|
created = commit;
|
|
72
158
|
const current = preparePiCanary(root, input);
|
|
73
|
-
const withoutGit = ({ digest, git_base_head, git_error, ...owners }: PiCanaryPreparation) => owners;
|
|
74
159
|
if (git(root, ["symbolic-ref", "--no-recurse", "HEAD"]) !== base.branch || current.git_base_head !== commit
|
|
75
160
|
|| JSON.stringify(withoutGit(current)) !== JSON.stringify(withoutGit(previous)))
|
|
76
161
|
throw new Error("Workspace changed during empty Git base initialization; retry Enrollment");
|