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.
- package/README.md +13 -6
- package/README.zh-CN.md +12 -5
- package/package.json +1 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +14 -2
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +93 -0
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +81 -13
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +20 -0
- package/plugins/immune-brain/dist/BASELINE.md +1 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +8573 -7449
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
- package/plugins/immune-brain/dist/imm-brainstorm.md +6 -6
- package/plugins/immune-brain/dist/imm-doc-prune.md +2 -1
- package/plugins/immune-brain/dist/imm-planner.md +63 -37
- package/plugins/immune-brain/dist/imm-run.md +137 -12
- package/plugins/immune-brain/dist/imm-test-prune.md +233 -0
- package/plugins/immune-brain/dist/registry.yaml +9 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +20 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +15 -0
- package/plugins/immune-brain/hooks/hooks.json +31 -1
- package/plugins/immune-brain/runtime/assurance/active_task_notice.ts +42 -0
- package/plugins/immune-brain/runtime/assurance/alias_file.ts +24 -0
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +9 -0
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
- package/plugins/immune-brain/runtime/assurance/phase_entry.ts +143 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +10 -1
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +32 -6
- package/plugins/immune-brain/runtime/claude/lane_guard.ts +210 -0
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +85 -7
- package/plugins/immune-brain/runtime/claude/review_host.ts +6 -6
- package/plugins/immune-brain/runtime/commands/kernel.ts +54 -1
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +235 -7
- package/plugins/immune-brain/runtime/kernel/runtime_contracts.json +7 -0
- package/plugins/immune-brain/runtime/kernel/runtime_contracts.ts +75 -0
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +20 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +15 -0
- package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +160 -0
- package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +72 -0
- package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +396 -23
- package/plugins/immune-brain/runtime/unattended/batch_leak_restore.ts +131 -0
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +25 -4
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +3 -2
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +42 -0
- package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +40 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +155 -0
- package/plugins/immune-brain/runtime/unattended/lane_workspace.ts +58 -0
- package/plugins/immune-brain/skills/BASELINE.md +1 -1
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +3 -2
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +8 -6
- package/plugins/immune-brain/skills/imm-run/SKILL.md +2 -1
- package/plugins/immune-brain/skills/imm-test-prune/SKILL.md +28 -0
- 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>;
|