immune-brain 5.1.1 → 5.2.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.
Files changed (53) hide show
  1. package/README.md +13 -6
  2. package/README.zh-CN.md +12 -5
  3. package/package.json +1 -1
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +14 -2
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +93 -0
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +81 -13
  8. package/plugins/immune-brain/agents/immune-brain-reviewer.md +20 -0
  9. package/plugins/immune-brain/dist/BASELINE.md +1 -1
  10. package/plugins/immune-brain/dist/claude/mcp-server.mjs +8573 -7449
  11. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  12. package/plugins/immune-brain/dist/imm-brainstorm.md +6 -6
  13. package/plugins/immune-brain/dist/imm-doc-prune.md +2 -1
  14. package/plugins/immune-brain/dist/imm-planner.md +63 -37
  15. package/plugins/immune-brain/dist/imm-run.md +137 -12
  16. package/plugins/immune-brain/dist/imm-test-prune.md +233 -0
  17. package/plugins/immune-brain/dist/registry.yaml +9 -0
  18. package/plugins/immune-brain/dist/role-prompts/code-review.md +20 -0
  19. package/plugins/immune-brain/dist/role-prompts/executor.md +15 -0
  20. package/plugins/immune-brain/hooks/hooks.json +31 -1
  21. package/plugins/immune-brain/runtime/assurance/active_task_notice.ts +42 -0
  22. package/plugins/immune-brain/runtime/assurance/alias_file.ts +24 -0
  23. package/plugins/immune-brain/runtime/assurance/coordinator.ts +9 -0
  24. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
  25. package/plugins/immune-brain/runtime/assurance/phase_entry.ts +143 -0
  26. package/plugins/immune-brain/runtime/claude/interaction.ts +10 -1
  27. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +32 -6
  28. package/plugins/immune-brain/runtime/claude/lane_guard.ts +210 -0
  29. package/plugins/immune-brain/runtime/claude/mcp_server.ts +85 -7
  30. package/plugins/immune-brain/runtime/claude/review_host.ts +6 -6
  31. package/plugins/immune-brain/runtime/commands/kernel.ts +54 -1
  32. package/plugins/immune-brain/runtime/github_issue_tracker.ts +235 -7
  33. package/plugins/immune-brain/runtime/kernel/runtime_contracts.json +7 -0
  34. package/plugins/immune-brain/runtime/kernel/runtime_contracts.ts +75 -0
  35. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  36. package/plugins/immune-brain/runtime/prompts/code-review.md +20 -0
  37. package/plugins/immune-brain/runtime/prompts/executor.md +15 -0
  38. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +160 -0
  39. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +72 -0
  40. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +396 -23
  41. package/plugins/immune-brain/runtime/unattended/batch_leak_restore.ts +131 -0
  42. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +25 -4
  43. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +3 -2
  44. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +42 -0
  45. package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +40 -0
  46. package/plugins/immune-brain/runtime/unattended/batch_state.ts +155 -0
  47. package/plugins/immune-brain/runtime/unattended/lane_workspace.ts +58 -0
  48. package/plugins/immune-brain/skills/BASELINE.md +1 -1
  49. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +3 -2
  50. package/plugins/immune-brain/skills/imm-planner/SKILL.md +8 -6
  51. package/plugins/immune-brain/skills/imm-run/SKILL.md +2 -1
  52. package/plugins/immune-brain/skills/imm-test-prune/SKILL.md +28 -0
  53. package/plugins/immune-brain/skills/registry.yaml +9 -0
@@ -0,0 +1,233 @@
1
+ ---
2
+ name: imm-test-prune
3
+ description: Use when the user explicitly requests Immune-Brain audit or pruning of redundant, bloated, or orphaned tests.
4
+ ---
5
+
6
+ # Immune-Brain: Test Prune
7
+
8
+ Reduce test-suite bloat in one Git repository. The Skill works through explicit
9
+ invocation, a comprehensive test inventory, evidence-based candidate narrowing,
10
+ one exact user-approved manifest, and bounded test-only mutation that never
11
+ weakens a live guarantee. It is language- and runner-agnostic: it uses the
12
+ target repository's own test, coverage, and static-check commands. This is a
13
+ standalone host-native maintenance entry, not a Managed Path continuation and
14
+ not an `imm-run` internal-role dispatch.
15
+
16
+ ## Authority Boundary
17
+
18
+ This Skill restructures and prunes tests without creating or mutating
19
+ TaskIntent, TaskRecord, Kernel, Spec, or Plan authority. An already active
20
+ Managed task remains owned by `imm-run`. Read-only inventory and manifest
21
+ production remain available.
22
+
23
+ Classify each of the following candidates as `BLOCKED_ACTIVE_SCOPE` and
24
+ continue auditing unaffected candidates:
25
+ - a candidate overlapping the active TaskIntent `scope_hint`;
26
+ - a candidate named by an active acceptance descriptor's verification `argv`.
27
+
28
+ If the routing owner or scope cannot be read reliably, fail closed for
29
+ mutation. This Skill does not invoke `imm-doc-prune`. Tests whose only behavior
30
+ is asserting document existence or wording remain that Skill's job and are
31
+ reported as `DEFER_DOC_PRUNE`.
32
+
33
+ ## Invocation
34
+
35
+ Requires explicit invocation: `imm-test-prune` or `/imm-test-prune`. Ordinary
36
+ "is this test redundant?" questions and routine test maintenance during a
37
+ change stay host-native and do not enter this Skill.
38
+
39
+ - `imm-test-prune audit`: read-only. Produce the manifest and stop.
40
+ - `imm-test-prune`: produce the manifest, then wait for exact manifest
41
+ approval before any mutation.
42
+ - An optional path argument narrows inventory to the named test files or
43
+ directories. The evidence rules still read the whole repository.
44
+
45
+ No automatic invocation. No daemon, no cron, no CI gate, no allowlist, no
46
+ runtime, no persistent report, no dependency installation, and no automatic
47
+ commit.
48
+
49
+ ## Ordered Pruning Protocol
50
+
51
+ ### Inventory and Manifest
52
+
53
+ 1. **Establish repository safety.** Mutation requires a Git worktree. A
54
+ non-Git repository or an untracked candidate is audit-only. For each
55
+ candidate, record:
56
+ - path;
57
+ - blob/content hash;
58
+ - tracked status;
59
+ - candidate-local worktree status;
60
+ - line count;
61
+ - test-case count.
62
+
63
+ Unrelated dirty files do not block the run; a dirty candidate is `BLOCKED`.
64
+
65
+ 2. **Discover the repository's commands.** Read existing scripts, task files,
66
+ CI configuration, and runner configuration to identify the following,
67
+ without installing anything:
68
+ - the test command;
69
+ - its single-file and single-test selection syntax;
70
+ - its coverage command and report format;
71
+ - the static checks a contributor runs locally.
72
+
73
+ Record the result as the coverage capability:
74
+ - `isolated`: coverage can be collected for one selected test;
75
+ - `file`: coverage can be collected only for one test file;
76
+ - `unavailable`: no coverage tool is already configured or installed, or
77
+ collection fails.
78
+
79
+ Never add a coverage tool, plugin, or configuration to obtain coverage.
80
+
81
+ 3. **Record the baseline.** Run the discovered test command over the
82
+ inventoried scope once and record pass/fail per file and wall time. A file
83
+ already failing at baseline is `BLOCKED` for mutation and reported. This
84
+ Skill never repairs or deletes a failing test to make the suite green. When
85
+ coverage capability is not `unavailable`, also record baseline line
86
+ coverage for the source files the candidates exercise.
87
+
88
+ 4. **Inventory tests and support code.** Enumerate tracked test files,
89
+ fixtures, helpers, and snapshots. Exclude dependency, vendor, build-output,
90
+ and cache trees.
91
+
92
+ 5. **Classify test roles.**
93
+ - behavior: asserts runtime or public-surface behavior;
94
+ - documentation contract: asserts document existence, wording, links, or
95
+ sections;
96
+ - packaging and mirror consistency: asserts that registries, generated
97
+ output, and packaged copies agree;
98
+ - historical evidence: protects readers of frozen or archived records;
99
+ - `UNCLASSIFIED`: grouped, not interrogated file by file.
100
+
101
+ Historical-evidence and packaging-consistency tests are `KEEP` unless the
102
+ protected reader or consumer is proven retired.
103
+
104
+ 6. **Mechanically narrow candidates.**
105
+ - Find structurally identical cases that differ only in literal inputs.
106
+ - Find repeated setup blocks across cases or files.
107
+ - Find hard-coded lists that duplicate a canonical registry or manifest.
108
+ - Find assertions whose subject (symbol, path, command, or public surface)
109
+ no longer exists in current source.
110
+ - Find orphaned fixtures and helpers with no remaining importer.
111
+ - Find tests that assert private implementation detail where a
112
+ public-surface assertion already covers the same behavior.
113
+
114
+ 7. **Apply evidence rules.** Age, file length, test count, and intermittent
115
+ failure alone never prove a test redundant.
116
+
117
+ `DELETE` requires one of the following:
118
+ - **Retirement**: the protected behavior is retired, with the retiring
119
+ commit or a current owner showing it removed.
120
+ - **Subsumption**: a named surviving test covers the same behavior with
121
+ equal or stronger assertions, and coverage evidence supports it. With
122
+ `isolated` or `file` capability, the surviving test's covered lines must
123
+ be a superset of the removed test's covered lines. Collect this only for
124
+ candidate entries, at the finest available granularity.
125
+
126
+ Line coverage is necessary, not sufficient: the assertion comparison is
127
+ still required.
128
+
129
+ A subsumption claim is `UNVERIFIED` and not mutable when any of these holds:
130
+ - coverage capability is `unavailable`;
131
+ - collection fails;
132
+ - repeated runs disagree.
133
+
134
+ A `MERGE` that drops any case follows the same subsumption rule. `MERGE` and
135
+ `PARAMETERIZE` that keep every distinct input and assertion need no coverage
136
+ evidence.
137
+
138
+ `RELAX_TO_STRUCTURE` is allowed only where the asserted text is not a
139
+ machine contract consumed by code, a host, or a packaged surface.
140
+
141
+ 8. **Produce one exact manifest.** State the discovered commands and coverage
142
+ capability first. Classify each entry as one of:
143
+ - `MERGE`
144
+ - `PARAMETERIZE`
145
+ - `EXTRACT_HELPER`
146
+ - `DERIVE_FROM_SOURCE`
147
+ - `RELAX_TO_STRUCTURE`
148
+ - `DELETE`
149
+ - `KEEP`
150
+ - `BLOCKED`
151
+ - `BLOCKED_ACTIVE_SCOPE`
152
+ - `DEFER_DOC_PRUNE`
153
+ - `UNVERIFIED`
154
+
155
+ Each entry includes:
156
+ - evidence;
157
+ - the exact file, case, or helper action;
158
+ - for every `DELETE`, the retirement proof, or the surviving owner with its
159
+ coverage evidence;
160
+ - the expected change in case count and line count;
161
+ - the candidate hash.
162
+
163
+ Group `UNCLASSIFIED` files.
164
+
165
+ 9. **Gate mutation.** `audit` mode stops after the manifest. Mutation mode
166
+ also stops until the literal user approves exact manifest entries (for
167
+ example, "all recommendations except 4 and 7"). Broad approval such as
168
+ "clean up the tests" is insufficient. Interruption starts a fresh scan; no
169
+ manifest is persisted.
170
+
171
+ ### Approved Mutation
172
+
173
+ 10. **Revalidate and mutate minimally.** Before each approved change, re-read
174
+ the candidate bytes, Git status, importers, and active scope. Drift blocks
175
+ that item. Apply one manifest entry at a time, then run that entry's
176
+ focused tests before the next. A failing focused check stops further
177
+ mutation and leaves the inspectable diff.
178
+
179
+ ## Mutation Envelope
180
+
181
+ This Skill may modify, merge, parameterize, or delete approved test files,
182
+ fixtures, helpers, and snapshots, and may add shared helpers beside the
183
+ repository's existing test helpers. It must stop when completion requires
184
+ changing any of the following:
185
+ - production source;
186
+ - public API;
187
+ - package exports;
188
+ - registries or generated output;
189
+ - dependencies, lockfiles, or coverage configuration;
190
+ - credentials or network writes;
191
+ - Git history;
192
+ - any Managed authority mutation.
193
+
194
+ **Never weaken a live guarantee.** Never do any of the following to make a
195
+ check pass or to reduce counts:
196
+ - skip, focus, or mark a test pending;
197
+ - loosen an assertion's strength (exact to partial, equality to existence,
198
+ specific error to any error);
199
+ - widen a timeout;
200
+ - delete a negative assertion that still guards a live boundary.
201
+
202
+ Snapshot updates are allowed only for cases whose structure the approved entry
203
+ changed, never as a blanket refresh.
204
+
205
+ ## Verify and Report
206
+
207
+ 11. **Verify and report.**
208
+ - Re-run every touched test file plus the baseline scope, the discovered
209
+ static checks, and `git diff --check`.
210
+ - The baseline pass set must be preserved: every previously passing
211
+ behavior keeps a passing owner.
212
+ - When coverage capability is not `unavailable`, re-collect line coverage
213
+ for the baseline's source files. Any decrease blocks the run: name the
214
+ responsible entry and leave the diff for the user. Report coverage as
215
+ unavailable rather than inferring it.
216
+ - This Skill does not commit.
217
+ - Report only these sections:
218
+ - `Changed` (with entry IDs);
219
+ - `Deleted` (each with its retirement proof, or its surviving owner and
220
+ coverage evidence);
221
+ - `Blocked`;
222
+ - `Deferred to imm-doc-prune`;
223
+ - `Unverified`;
224
+ - `Delta` (case count, line count, wall time, and line coverage before
225
+ and after, or `coverage unavailable`);
226
+ - `Verification`;
227
+ - `Recovery: git log -- <path>`.
228
+
229
+ ## Recovery
230
+
231
+ Git history is the archive for removed tests. Every `DELETE` entry reports
232
+ `Recovery: git log -- <path>`. Untracked, dirty, baseline-failing, or non-Git
233
+ candidates are never mutated.
@@ -56,6 +56,15 @@ skills:
56
56
  output_artifacts: [maintain_report]
57
57
  next_actions: []
58
58
  boundary: Minimize tracked agent-instruction context after explicit manifest approval; no Managed authority mutation, contract installation, or reference-document creation.
59
+ - name: imm-test-prune
60
+ path: skills/imm-test-prune/SKILL.md
61
+ role: execute
62
+ title: Test Prune
63
+ role_class: repair
64
+ canonical: true
65
+ output_artifacts: [test_prune_report]
66
+ next_actions: []
67
+ boundary: Audit and prune redundant tests after explicit manifest approval; no Managed authority mutation, production-source change, or assertion weakening.
59
68
  - name: imm-retro
60
69
  path: skills/imm-retro/SKILL.md
61
70
  role: execute
@@ -26,6 +26,11 @@ material task-local maintenance risks. Pure naming, length, complexity
26
26
  thresholds, formatting, and design preference are not findings and must not
27
27
  cause style-only rework.
28
28
 
29
+ Test-suite growth is advisory, never blocking: note a delivered test that
30
+ duplicates an existing assertion, asserts only private implementation, or
31
+ leaves a test protecting behavior the change retired. Weakened or missing
32
+ tests for an acceptance remain blocking under the rules above.
33
+
29
34
  When a finding repeats an acceptance id or anchor that already produced rework in
30
35
  this task, do not file it as a fresh single-trigger defect. Name the invariant
31
36
  class it belongs to and state in `evidence.trigger` why the previously accepted
@@ -34,6 +39,13 @@ about the fix's scope, not a new bug. A trigger the accepted contract explicitly
34
39
  excludes is reported as an advisory note against that boundary, never as blocking
35
40
  rework.
36
41
 
42
+ A defect that lies inside the TaskIntent goal and `scope_hint` is a blocking
43
+ finding even when no acceptance assertion names its exact case word for word:
44
+ cite the acceptance whose principle it violates as `acceptance_id` and
45
+ `violated.ref`. Ask for the fix and a test that pins the case, never for the case
46
+ to be written into the acceptance text; a missing literal clause is not itself a
47
+ finding and does not call for an Intent revision.
48
+
37
49
  ## Invariant and Evidence Coverage
38
50
 
39
51
  Judge whether the change actually closes the invariant its acceptance names,
@@ -53,6 +65,14 @@ seam, or a seam silently replaced by a different one, is a finding carrying
53
65
  named, its absence is not a finding: candidates written before this rule carries
54
66
  no defect for lacking one.
55
67
 
68
+ When the Spec referenced by an acceptance maps an `automated` user scenario to
69
+ it, check that the delivered tests at that acceptance's agreed seam assert the
70
+ scenario's `Then`: a scenario whose observable result is asserted nowhere is a
71
+ finding carrying `violated.kind` of `acceptance` with `ref` the acceptance id.
72
+ A `manual` scenario, and an acceptance with no mapped scenario, produce no such
73
+ finding. This adds no finding kind and no verdict branch: it is the same
74
+ `acceptance` finding the seam rule above already defines.
75
+
56
76
  Before pass, enumerate the immutable change set and read each complete per-file
57
77
  diff, including deletions. Page bounded reads until every hunk is visible; a
58
78
  truncated aggregate output or summary is incomplete coverage. Trace changed
@@ -54,6 +54,13 @@ Record missing evidence as blocked and repair it before handoff. The Loop owns
54
54
  verdict submission, finding disposition and the second-rework generalize-or-refute
55
55
  rule.
56
56
 
57
+ A finding whose defect lies inside the TaskIntent goal and `scope_hint` is
58
+ ordinary rework even when no acceptance names its exact case: fix the code and
59
+ add the test that pins the case. Do not propose an Intent revision for it, and
60
+ never append a single input case to an acceptance assertion to close a finding;
61
+ acceptance states principles, tests enumerate cases. Route to `imm-planner` only
62
+ when the goal, the scope, or a user-visible commitment must change.
63
+
57
64
  ## Code Quality Guard
58
65
 
59
66
  Before handoff, check the implementation for real implementation rather than
@@ -61,6 +68,14 @@ mock or hard-coded success, swallowed unexpected errors, missing validation at
61
68
  external trust boundaries, invented dependencies or APIs, unauthorized
62
69
  observable behavior changes, and production paths without a current caller.
63
70
  Do not weaken tests or hide an incomplete result to make Verification pass.
71
+
72
+ Keep the test suite lean. Each added test pins one acceptance invariant or
73
+ finding case at its public seam; do not mirror private calls, mock order or
74
+ internal return values that a public assertion already covers. Before adding a
75
+ test, search existing tests for the same behavior and extend or parameterize
76
+ them instead of duplicating setup. When the change retires or replaces a
77
+ behavior, update or delete the tests that protected it within scope and name
78
+ the retired behavior or continuing owner in Delivery Evidence.
64
79
  Treat naming, function length, parameter count, nesting, and abstraction taste
65
80
  as contextual signals, never as automatic failure thresholds.
66
81
 
@@ -1,6 +1,36 @@
1
1
  {
2
- "description": "Observe Claude Code Review and session lifecycle for Immune-Brain. Hooks never mint authority.",
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 a phase Skill, and announce an active Managed task without resuming it. Hooks never mint authority.",
3
3
  "hooks": {
4
+ "PreToolUse": [
5
+ {
6
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/claude/mcp-server.mjs\" --hook"
11
+ }
12
+ ]
13
+ },
14
+ {
15
+ "matcher": "Skill|mcp__.*immune-brain.*__(enroll|start_unattended_batch)",
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/claude/mcp-server.mjs\" --hook"
20
+ }
21
+ ]
22
+ }
23
+ ],
24
+ "UserPromptSubmit": [
25
+ {
26
+ "hooks": [
27
+ {
28
+ "type": "command",
29
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/claude/mcp-server.mjs\" --hook"
30
+ }
31
+ ]
32
+ }
33
+ ],
4
34
  "SubagentStart": [
5
35
  {
6
36
  "hooks": [
@@ -0,0 +1,42 @@
1
+ // A read-only Host notice for ordinary input while a Managed task is active in
2
+ // the workspace. It tells the model, and through it the user, that the task
3
+ // exists and what it is waiting for, so a fresh session does not commit or edit
4
+ // inside that task's scope blind. It never resumes the task: ordinary input
5
+ // stays host-native, and only an explicit `imm-run` entry continues it.
6
+ import { readTaskRecordRaw } from "../kernel/storage";
7
+ import { readActiveClaimTaskId } from "../unattended/batch_preflight";
8
+
9
+ const MAX_SCOPE_ENTRIES = 12;
10
+
11
+ export async function activeTaskNotice(root: string): Promise<string | null> {
12
+ let taskId: string | null;
13
+ try {
14
+ taskId = readActiveClaimTaskId(root);
15
+ } catch {
16
+ return null;
17
+ }
18
+ if (!taskId) return null;
19
+ // One TaskRecord read, no workspace diff: this runs on every prompt, and the
20
+ // exact next obligation is what the model reads from `status` afterwards.
21
+ let lifecycle = "unknown";
22
+ let artifactState = "unknown";
23
+ let scope: string[] = [];
24
+ try {
25
+ const record = readTaskRecordRaw(root, taskId).record;
26
+ if (record) {
27
+ lifecycle = record.lifecycle;
28
+ artifactState = record.artifact_state;
29
+ scope = [...record.intent_snapshot.scope_hint];
30
+ }
31
+ } catch {
32
+ // The notice is advisory; an unreadable projection still names the task.
33
+ }
34
+ if (lifecycle === "done" || lifecycle === "stopped") return null;
35
+ const shown = scope.slice(0, MAX_SCOPE_ENTRIES).join(", ") + (scope.length > MAX_SCOPE_ENTRIES ? ", ..." : "");
36
+ return [
37
+ `Immune-Brain: Managed task ${taskId} is active in this workspace (lifecycle ${lifecycle}, artifacts ${artifactState}).`,
38
+ "Ordinary input does not resume it, and another session may be running its assurance right now.",
39
+ `Before committing or modifying files inside its scope${shown ? ` (${shown})` : ""}, read its status and tell the user that this task is active and which obligation it is waiting on.`,
40
+ "Only an explicit imm-run entry continues it.",
41
+ ].join(" ");
42
+ }
@@ -0,0 +1,24 @@
1
+ // Reads one user command alias for phase entry recognition. An alias may be a
2
+ // symlink, so the size bound is taken from the opened file, never from the link.
3
+
4
+ import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
5
+
6
+ export const MAX_ALIAS_BYTES = 64 * 1024;
7
+
8
+ /** The alias body, or null when it is absent, not a regular file, or over the bound. */
9
+ export function readAliasFile(path: string, maxBytes = MAX_ALIAS_BYTES): string | null {
10
+ let fd: number;
11
+ try { fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK); } catch { return null; }
12
+ try {
13
+ const stat = fstatSync(fd);
14
+ if (!stat.isFile() || stat.size > maxBytes) return null;
15
+ // One byte past the bound: a file that grew after the stat is refused too.
16
+ const buf = Buffer.alloc(maxBytes + 1);
17
+ const read = readSync(fd, buf, 0, buf.length, 0);
18
+ return read > maxBytes ? null : buf.toString("utf8", 0, read);
19
+ } catch {
20
+ return null;
21
+ } finally {
22
+ closeSync(fd);
23
+ }
24
+ }
@@ -23,6 +23,7 @@ import type { AssuranceProjectionResult } from "../kernel/assurance_projection";
23
23
  import type { TaskIntentIdentityToken } from "../kernel/intent_token_registry";
24
24
  import type { AssuranceHostPort, HostReviewReservation } from "./host_port";
25
25
  import type { GithubTrackerResult as GithubTrackerProjectionResult } from "../github_issue_tracker";
26
+ import { isLaneWorkspaceForTask } from "../unattended/lane_workspace";
26
27
 
27
28
 
28
29
  export type AssuranceRole = "qa" | "review";
@@ -87,6 +88,10 @@ export function deriveGithubTerminalProjectionInput(
87
88
  * null otherwise), a tracker failure is reported as a retryable failure rather
88
89
  * than evidence or a blocker, and a failure never repeats the settling Kernel
89
90
  * mutation.
91
+ *
92
+ * A completed settlement inside a batch Lane writes nothing to the tracker: the
93
+ * Lane's delivery is not on the batch branch yet, so the coordinator closes the
94
+ * Child only after it integrates that child's commit.
90
95
  */
91
96
  export async function projectTerminalTrackerState(input: {
92
97
  root: string;
@@ -94,10 +99,14 @@ export async function projectTerminalTrackerState(input: {
94
99
  projection: AssuranceProjectionResult;
95
100
  tombstone: TaskTombstone | null;
96
101
  markTerminal: (root: string, projection: GithubTerminalProjectionInput) => Promise<GithubTrackerProjectionResult>;
102
+ isLaneWorkspace?: (root: string, taskId: string) => boolean;
97
103
  }): Promise<GithubTrackerProjectionResult | undefined> {
98
104
  if (input.projection.error) return undefined;
99
105
  const terminal = deriveGithubTerminalProjectionInput(input.task_id, input.projection, input.tombstone);
100
106
  if (!terminal) return undefined;
107
+ // Only a completed delivery waits for integration; an explicit stop inside a
108
+ // Lane is never integrated and still projects `not planned` here.
109
+ if (terminal.phase === "done" && (input.isLaneWorkspace ?? isLaneWorkspaceForTask)(input.root, input.task_id)) return undefined;
101
110
  try {
102
111
  return await input.markTerminal(input.root, terminal);
103
112
  } catch {
@@ -54,8 +54,11 @@ function isolatedGitEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
54
54
  };
55
55
  }
56
56
 
57
+ // `maintenance.auto=false`: fetch otherwise starts `maintenance run --auto`,
58
+ // which newer Git detaches; the detached child still owns
59
+ // objects/maintenance.lock after fetch returns and mutates the sealed .git.
57
60
  function git(cwd: string, args: string[], extra: Record<string, string> = {}): string {
58
- return execFileSync("git", args, {
61
+ return execFileSync("git", ["-c", "maintenance.auto=false", ...args], {
59
62
  cwd,
60
63
  encoding: "utf8",
61
64
  stdio: ["ignore", "pipe", "pipe"],
@@ -0,0 +1,143 @@
1
+ // Host-neutral phase entry: which literal user input enters planning or
2
+ // execution, the per-session entry state, and whether an operation is admitted.
3
+ // Pure: a Host supplies the alias reader and owns where the state is stored.
4
+ // A live entry mints no authority; it only lets the existing native gate open.
5
+
6
+ export type PhaseEntry = "plan" | "run";
7
+
8
+ /** Returns the body of a user command alias, or nothing when it does not exist. */
9
+ export type AliasReader = (name: string) => string | null | undefined;
10
+
11
+ export const RUN_ENTRY_REQUIRED = "run_entry_required";
12
+ export const PHASE_ENTRY_REQUIRED = "phase_entry_required";
13
+
14
+ export const PHASE_ENTRY_RECOVERY: Record<typeof RUN_ENTRY_REQUIRED | typeof PHASE_ENTRY_REQUIRED, string> = {
15
+ [RUN_ENTRY_REQUIRED]: "ask the user to enter imm-run in the current Host",
16
+ [PHASE_ENTRY_REQUIRED]: "ask the user to enter the Skill themselves in the current Host",
17
+ };
18
+
19
+ const PACKAGED: Record<string, PhaseEntry> = {
20
+ "imm-planner": "plan",
21
+ "imm-brainstorm": "plan",
22
+ "imm-run": "run",
23
+ };
24
+ // An alias body may still name the retired Loop skill.
25
+ const ALIAS_TARGETS: Record<string, PhaseEntry> = { ...PACKAGED, "imm-loop": "run" };
26
+ const PREFIXES = ["immune-brain:", "skill:"];
27
+ const ALIAS_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
28
+ const SKILL_MENTION = /(?<![A-Za-z0-9_-])imm-(?:planner|brainstorm|run|loop)(?![A-Za-z0-9_-])/g;
29
+ // The Host-expanded form of a slash command, anchored at the start of the prompt.
30
+ const EXPANDED = /^<command-message>[^<]*<\/command-message>\s*<command-name>\/([^\s<]+)<\/command-name>/;
31
+ const RAW = /^\/(\S+)/;
32
+
33
+ function stripPrefix(name: string): string {
34
+ for (const prefix of PREFIXES) if (name.startsWith(prefix)) return name.slice(prefix.length);
35
+ return name;
36
+ }
37
+
38
+ /** The packaged phase Skill a Skill-tool name refers to, or null for any other Skill. */
39
+ export function packagedPhaseSkill(name: string): keyof typeof PACKAGED | null {
40
+ // Trimmed first: a Host that resolves a padded Skill name must not skip the gate.
41
+ const trimmed = name.trim();
42
+ const bare = stripPrefix(trimmed.startsWith("/") ? trimmed.slice(1) : trimmed);
43
+ return Object.hasOwn(PACKAGED, bare) ? bare : null;
44
+ }
45
+
46
+ /**
47
+ * The phase a literal prompt enters, or null. Only a leading slash command
48
+ * counts: a packaged name, a prefixed packaged name, or a user alias whose body
49
+ * names exactly one phase Skill. A mention anywhere else is not an entry.
50
+ */
51
+ export function recognizePhaseEntry(text: string, readAlias: AliasReader): PhaseEntry | null {
52
+ const command = (RAW.exec(text) ?? EXPANDED.exec(text))?.[1];
53
+ if (!command) return null;
54
+ const packaged = packagedPhaseSkill(command);
55
+ if (packaged) return PACKAGED[packaged];
56
+ if (!ALIAS_NAME.test(command)) return null;
57
+ let body: string | null | undefined;
58
+ try { body = readAlias(command); } catch { return null; }
59
+ if (!body) return null;
60
+ const targets = new Set(body.match(SKILL_MENTION) ?? []);
61
+ if (targets.size !== 1) return null;
62
+ return ALIAS_TARGETS[[...targets][0]] ?? null;
63
+ }
64
+
65
+ export interface PhaseEntryState {
66
+ /** Count of literal prompts observed in the session. */
67
+ prompt_seq: number;
68
+ run_live: boolean;
69
+ /** The prompt that recorded the live run entry, or 0. */
70
+ run_prompt_seq: number;
71
+ /** The prompt that recorded the latest plan entry, or 0. */
72
+ plan_prompt_seq: number;
73
+ }
74
+
75
+ export const NO_PHASE_ENTRY: PhaseEntryState = { prompt_seq: 0, run_live: false, run_prompt_seq: 0, plan_prompt_seq: 0 };
76
+
77
+ /** One literal prompt: a run entry goes live, a plan entry revokes it. */
78
+ export function applyPrompt(state: PhaseEntryState, entry: PhaseEntry | null): PhaseEntryState {
79
+ const prompt_seq = state.prompt_seq + 1;
80
+ if (entry === "run") return { ...state, prompt_seq, run_live: true, run_prompt_seq: prompt_seq };
81
+ if (entry === "plan") return { prompt_seq, run_live: false, run_prompt_seq: 0, plan_prompt_seq: prompt_seq };
82
+ return { ...state, prompt_seq };
83
+ }
84
+
85
+ export function phaseEntryLogLine(entry: PhaseEntry | null): string {
86
+ return `${JSON.stringify({ v: 1, entry })}\n`;
87
+ }
88
+
89
+ /** Folds a session's entry log. Any malformed line makes the whole log unreadable. */
90
+ export function reducePhaseEntryLog(text: string): PhaseEntryState | null {
91
+ let state = NO_PHASE_ENTRY;
92
+ for (const line of text.split("\n")) {
93
+ if (!line) continue;
94
+ let record: unknown;
95
+ try { record = JSON.parse(line); } catch { return null; }
96
+ if (!record || typeof record !== "object") return null;
97
+ const { v, entry } = record as { v?: unknown; entry?: unknown };
98
+ if (v !== 1 || (entry !== null && entry !== "plan" && entry !== "run")) return null;
99
+ state = applyPrompt(state, entry);
100
+ }
101
+ return state;
102
+ }
103
+
104
+ export type PhaseOperation =
105
+ | { kind: "tool"; name: "enroll" | "start_unattended_batch" }
106
+ | { kind: "skill"; skill: keyof typeof PACKAGED };
107
+
108
+ export type PhaseAdmission =
109
+ | { ok: true }
110
+ | { ok: false; reason_code: typeof RUN_ENTRY_REQUIRED | typeof PHASE_ENTRY_REQUIRED; detail: string; recovery_action: string };
111
+
112
+ function refuse(reason_code: typeof RUN_ENTRY_REQUIRED | typeof PHASE_ENTRY_REQUIRED, detail: string): PhaseAdmission {
113
+ return { ok: false, reason_code, detail, recovery_action: PHASE_ENTRY_RECOVERY[reason_code] };
114
+ }
115
+
116
+ /**
117
+ * Whether the session's entry state admits one operation. `state` is null when
118
+ * the store could not be read, which refuses everything.
119
+ */
120
+ export function admitPhaseOperation(
121
+ operation: PhaseOperation,
122
+ state: PhaseEntryState | null,
123
+ context: { activeClaim: boolean },
124
+ ): PhaseAdmission {
125
+ if (operation.kind === "tool") {
126
+ if (state?.run_live) return { ok: true };
127
+ return refuse(RUN_ENTRY_REQUIRED, `${operation.name} needs a literal-user imm-run entry in this session`);
128
+ }
129
+ const latest = state !== null && state.prompt_seq > 0 ? state.prompt_seq : -1;
130
+ if (operation.skill === "imm-run") {
131
+ if (state?.run_live && state.run_prompt_seq === latest) return { ok: true };
132
+ return refuse(PHASE_ENTRY_REQUIRED, "imm-run is entered by the user, not by a model Skill call");
133
+ }
134
+ if (state && state.plan_prompt_seq === latest) return { ok: true };
135
+ // Enrolled Intent Revision: the Loop routes to the Planner inside a run.
136
+ if (operation.skill === "imm-planner" && state?.run_live && context.activeClaim) return { ok: true };
137
+ return refuse(PHASE_ENTRY_REQUIRED, `${operation.skill} is entered by the user, not by a model Skill call`);
138
+ }
139
+
140
+ /** The single-line message both Hosts return for a refusal. */
141
+ export function phaseRefusalMessage(refusal: Extract<PhaseAdmission, { ok: false }>): string {
142
+ return `${refusal.reason_code}: ${refusal.detail}; recovery: ${refusal.recovery_action}`;
143
+ }
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { PHASE_ENTRY_RECOVERY } from "../assurance/phase_entry";
2
3
 
3
4
  export const PRIVILEGED_OPERATIONS = [
4
5
  "enroll",
@@ -17,7 +18,9 @@ export type NativeFailureCode =
17
18
  | "user_cancelled"
18
19
  | "correlation_missing"
19
20
  | "unsupported_host"
20
- | "workspace_changed";
21
+ | "workspace_changed"
22
+ | "run_entry_required"
23
+ | "phase_entry_required";
21
24
 
22
25
  const RECOVERY_ACTIONS: Record<NativeFailureCode, string> = {
23
26
  interaction_not_opened: "retry through a fresh native gate in the current Host",
@@ -26,6 +29,7 @@ const RECOVERY_ACTIONS: Record<NativeFailureCode, string> = {
26
29
  correlation_missing: "retry through a fresh native gate in the current Host",
27
30
  unsupported_host: "upgrade to a supported Claude Code version and retry in the current Host",
28
31
  workspace_changed: "review the current workspace and retry through a fresh native gate",
32
+ ...PHASE_ENTRY_RECOVERY,
29
33
  };
30
34
 
31
35
  export class NativeAuthorityError extends Error {
@@ -88,6 +92,9 @@ export interface NativeConfirmationInput {
88
92
  max_parallel: number;
89
93
  parallel_groups: string[][];
90
94
  serialized: Array<{ task_id: string; overlaps_with: string[] }>;
95
+ final_verification?: string[];
96
+ /** ADR 0018: the gate offers the opt-in delegation of in-envelope Lane Intent revisions. */
97
+ offer_revision_delegation?: boolean;
91
98
  };
92
99
  };
93
100
  /** Present only for a batch disposition: the record the user is asked to retire. */
@@ -105,6 +112,8 @@ export interface NativeConfirmationInput {
105
112
  export interface NativeConfirmationResult {
106
113
  decision: NativeDecision;
107
114
  requestId: string;
115
+ /** ADR 0018: true only when the user ticked the delegation in a lane batch gate. */
116
+ delegateRevisions?: boolean;
108
117
  }
109
118
 
110
119
  export type NativeConfirmationPort = (input: NativeConfirmationInput) => Promise<NativeConfirmationResult>;