@redue/cli 0.1.0-alpha.13 → 0.1.0-alpha.15
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/CHANGELOG.md +54 -0
- package/README.md +31 -2
- package/bin/redue.mjs +19 -6
- package/docs/user/pilot.md +3 -3
- package/docs/user/supported-projects.md +4 -1
- package/docs/user/tests-direct.md +106 -0
- package/package.json +1 -1
- package/src/agent-integration.mjs +45 -18
- package/src/claude-hook.mjs +38 -11
- package/src/cli.mjs +79 -12
- package/src/daemon.mjs +34 -5
- package/src/declared-plan.mjs +3 -2
- package/src/direct-run-certificate.mjs +1 -1
- package/src/installed-inputs.mjs +3 -1
- package/src/linux-inotify.py +29 -10
- package/src/node-onboarding.mjs +15 -1
- package/src/test-direct.mjs +365 -0
- package/src/test-recorder.cjs +318 -0
- package/src/typescript-list.mjs +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.15 — 2026-10-22
|
|
4
|
+
|
|
5
|
+
No change to observation, status or `redue run` since alpha.14; the changes
|
|
6
|
+
are to the Claude Code integration.
|
|
7
|
+
|
|
8
|
+
- The CLAUDE.md block now tells agents to run the typecheck or test command
|
|
9
|
+
before saying it passes, even after an edit that seems irrelevant, and to
|
|
10
|
+
leave skipping to REDUE's hook. In the alpha.14 eval, two of six mixed-task
|
|
11
|
+
sessions skipped their final test run on their own after a README edit;
|
|
12
|
+
with the new block, none of six did (twilio and SwitchHosts, CI
|
|
13
|
+
39702429765), at the same token cost. Run `redue agent setup claude --apply`
|
|
14
|
+
again to update the block; the earlier wording is recognized and replaced.
|
|
15
|
+
- The hook also matches a typecheck or test command after a leading
|
|
16
|
+
`cd <project root>;` or `cd <project root> &&`. A skip denies the whole
|
|
17
|
+
command; a rewrite keeps the `cd`. A `cd` to any other directory matches
|
|
18
|
+
nothing.
|
|
19
|
+
|
|
20
|
+
## 0.1.0-alpha.14 — 2026-10-21
|
|
21
|
+
|
|
22
|
+
Bench numbers below are from one 800-file project on macOS arm64, Node 22.13.0.
|
|
23
|
+
Released alpha.13 and this build ran interleaved on the same machine; the
|
|
24
|
+
typecheck decision path is unchanged within noise (warm `status --sync` 162/157
|
|
25
|
+
ms on alpha.13, 169/166/289 ms on alpha.14; `redue run` overhead 12/14% and
|
|
26
|
+
12/7/18%; states correct, no idle probe launches).
|
|
27
|
+
|
|
28
|
+
- New opt-in recipe for unit tests: `redue init --recipe tests-direct --check
|
|
29
|
+
test` records a single-package npm project's Vitest 4/5 or Jest 29 run on
|
|
30
|
+
macOS and Linux. The check's inputs are the whole checkout (minus `*.md`,
|
|
31
|
+
`*.mdx` and, with `--coverage`, `coverage/`), the installed files, the Node
|
|
32
|
+
binary, the exact command, and every environment variable the run's
|
|
33
|
+
JavaScript reads directly, compared by value. A recorder loaded into every
|
|
34
|
+
Node process and worker thread of the run guards the rest: reading an
|
|
35
|
+
excluded or git-ignored file, a directory listing or large file outside the
|
|
36
|
+
checkout, network use other than loopback, or a process that escaped the
|
|
37
|
+
recorder makes the result UNVERIFIED with the reason. Lookups outside the
|
|
38
|
+
checkout become run facts that status checks again. Two runs on the same
|
|
39
|
+
inputs with different results make the check UNVERIFIED until the inputs
|
|
40
|
+
change. See [direct tests](docs/user/tests-direct.md) for the claim and its
|
|
41
|
+
limits.
|
|
42
|
+
- Claude Code: the hook skips or records a direct test check's runs (`npm run
|
|
43
|
+
<script>`, `npm test`, the script's own command) like the typecheck's.
|
|
44
|
+
- Linux: a directory created or deleted in the checkout is now an event, not
|
|
45
|
+
an observation gap. Before, every new directory forced a full reconciliation,
|
|
46
|
+
and a second one during it left the observer UNVERIFIED (`plan_gap`).
|
|
47
|
+
- An excluded installed path appearing (a runner cache, a TypeScript build-info
|
|
48
|
+
file) no longer changes the input plan. Checks with such an exclusion are
|
|
49
|
+
STALE with "input plan changed" once after the upgrade, until their next run.
|
|
50
|
+
- Measured with Claude Code on three public projects (Sonnet 5.5, CI Linux,
|
|
51
|
+
72 sessions): agents skipped 21 test runs, every skip confirmed by three
|
|
52
|
+
plain reruns on its tree, and session time fell from 515 to 295 s summed over
|
|
53
|
+
the task medians. In two sessions the agent itself left out its final test
|
|
54
|
+
run after a README-only edit, without asking REDUE; both said so, and the
|
|
55
|
+
final trees passed.
|
|
56
|
+
|
|
3
57
|
## 0.1.0-alpha.13 — 2026-10-19
|
|
4
58
|
|
|
5
59
|
Bench numbers below are from one 800-file project on macOS arm64, Node 22.13.0.
|
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ it; relevant source edits make it stale. [Run the reproducible demo](docs/contri
|
|
|
37
37
|
|
|
38
38
|
## Install the alpha
|
|
39
39
|
|
|
40
|
-
The opt-in pilot uses **0.1.0-alpha.
|
|
40
|
+
The opt-in pilot uses **0.1.0-alpha.15**. Follow the
|
|
41
41
|
[fixed-version pilot guide](docs/user/pilot.md) for supported projects, setup,
|
|
42
42
|
updates and voluntary feedback. No external usage results are claimed yet.
|
|
43
43
|
|
|
@@ -133,6 +133,33 @@ these projects. Medians per task, summed over the four projects:
|
|
|
133
133
|
of 38 screened projects, 19 qualified at alpha.12 and 22 at alpha.13. Times are agent session
|
|
134
134
|
time only, on one runner type.
|
|
135
135
|
|
|
136
|
+
### Unit tests, measured with Claude Code
|
|
137
|
+
|
|
138
|
+
Three public projects (cheerio with Vitest 4, SwitchHosts with Vitest 5, twilio
|
|
139
|
+
with Jest 29), four tasks each, three sessions per task with and without REDUE:
|
|
140
|
+
72 headless Claude Code sessions (Sonnet 5.5, CI Linux runner, alpha.14 code,
|
|
141
|
+
October 2026), using the opt-in [direct test recipe](docs/user/tests-direct.md).
|
|
142
|
+
Medians per task, summed over the three projects:
|
|
143
|
+
|
|
144
|
+
| Task | Session time, without → with REDUE | Input tokens | Test runs skipped |
|
|
145
|
+
|---|---|---|---|
|
|
146
|
+
| Docs edit, then "make sure the unit tests still pass" | 95 → 41 s | 131k → 121k | 9 of 9 |
|
|
147
|
+
| "Do the unit tests still pass?" after a pull | 80 → 22 s | 62k → 63k | 9 of 9 |
|
|
148
|
+
| Source change, tests, then a docs edit | 171 → 111 s | 224k → 213k | 3 of 9 |
|
|
149
|
+
| Source change with tests | 169 → 120 s | 172k → 164k | 0 of 9 |
|
|
150
|
+
| **All tasks** | **515 → 295 s** | **589k → 559k** | 21 |
|
|
151
|
+
|
|
152
|
+
- **Correctness:** every skipped test run was rerun plainly three times on the
|
|
153
|
+
exact tree it was skipped on; all 21 passed every time. All 72 final trees
|
|
154
|
+
passed the tests and the task checks in both arms.
|
|
155
|
+
- **Limits:** one run of three projects shows direction, not a rate. The
|
|
156
|
+
source-change task skipped nothing; its time difference is session variance.
|
|
157
|
+
In two mixed-task sessions the agent left out its final test run on its own
|
|
158
|
+
after a README-only edit, without asking REDUE, and said so; the eval report
|
|
159
|
+
lists such sessions. Since alpha.15 the agent instructions ask agents to run
|
|
160
|
+
the check and leave skipping to REDUE; in a 12-session rerun of that task,
|
|
161
|
+
no agent left it out. Cost was the same ($0.54 without, $0.56 with).
|
|
162
|
+
|
|
136
163
|
## What the states mean
|
|
137
164
|
|
|
138
165
|
| State | Meaning | Normal next step |
|
|
@@ -156,7 +183,9 @@ has been verified.
|
|
|
156
183
|
[direct local compiler recipe](docs/user/direct-typescript.md) for the reviewed
|
|
157
184
|
npm/TypeScript boundary. Workspace
|
|
158
185
|
boundaries follow the files actually consumed, including built declarations.
|
|
159
|
-
-
|
|
186
|
+
- Opt-in [direct test recipe](docs/user/tests-direct.md) for Vitest 4/5 and
|
|
187
|
+
Jest 29 in single-package npm projects on macOS and Linux.
|
|
188
|
+
- Other test/lint/build and arbitrary commands can record results while
|
|
160
189
|
their applicability remains unqualified.
|
|
161
190
|
|
|
162
191
|
The [supported-project boundary](docs/user/supported-projects.md) is deliberately
|
package/bin/redue.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import {mark,traceBase,traceContext,traceEnvironment,traceId} from '../src/decis
|
|
|
4
4
|
traceBase({...traceContext(),request:traceId(),work:'client'});
|
|
5
5
|
mark('client.loaded');
|
|
6
6
|
import {directCompiler,directQualification,directEnvironment,directInterpretation} from '../src/direct-typescript.mjs';
|
|
7
|
+
import {testEnvironment,testRunnerEntry,testsInterpretation,testsQualification} from '../src/test-direct.mjs';
|
|
7
8
|
import os from 'node:os';
|
|
8
9
|
import path from 'node:path';
|
|
9
10
|
import {createHash} from 'node:crypto';
|
|
@@ -25,7 +26,7 @@ const help=`REDUE ${version}
|
|
|
25
26
|
Usage: redue [--config FILE] [--state-dir DIR] COMMAND [OPTIONS]
|
|
26
27
|
|
|
27
28
|
Commands:
|
|
28
|
-
init [--dry-run] [--workspace NAME] [--check KIND] [--recipe typescript-direct]
|
|
29
|
+
init [--dry-run] [--workspace NAME] [--check KIND] [--recipe typescript-direct|tests-direct]
|
|
29
30
|
Discover existing scripts; optionally select one workspace/check
|
|
30
31
|
agent setup codex|claude [--dry-run|--apply] [--json]
|
|
31
32
|
agent doctor [codex|claude] [--json]
|
|
@@ -128,7 +129,8 @@ if(command==='init'){
|
|
|
128
129
|
console.log(` ${row.name} (${row.script||row.command?.join(' ')}) — `+
|
|
129
130
|
`${{ready:'ready to qualify',recording:'recording-only',unsupported:'unsupported'}[row.level]||row.level}: ${row.reason}`);
|
|
130
131
|
if(row.script&&row.command)console.log(` Recorded invocation: ${row.command.map(value=>
|
|
131
|
-
value==='@node'?'node':value==='@typescript-compiler:.'?'[reviewed local TypeScript compiler implementation]':
|
|
132
|
+
value==='@node'?'node':value==='@typescript-compiler:.'?'[reviewed local TypeScript compiler implementation]':
|
|
133
|
+
value.startsWith('@test-runner:')?`[local ${value.slice('@test-runner:'.length)}]`:value.startsWith('@typescript-bin:')?
|
|
132
134
|
`[installed TypeScript in ${value.slice('@typescript-bin:'.length)}]`:value).join(' ')}`);
|
|
133
135
|
}
|
|
134
136
|
for(const row of summary.ambiguous)
|
|
@@ -198,6 +200,11 @@ function token(value){
|
|
|
198
200
|
if(value==='@typescript-compiler:.'){
|
|
199
201
|
try{return directCompiler(root,{verify:false}).entry;}catch(e){error(`direct compiler unavailable: ${e.code||e.message}`);}
|
|
200
202
|
}
|
|
203
|
+
if(value.startsWith('@test-runner:')){
|
|
204
|
+
// Qualification reports a missing runner; status and stop still work.
|
|
205
|
+
try{return testRunnerEntry(root,value.slice('@test-runner:'.length));}
|
|
206
|
+
catch{return path.join(root,'node_modules',value.slice('@test-runner:'.length),'<unavailable>');}
|
|
207
|
+
}
|
|
201
208
|
if(value==='@vstate/direct-typescript-probe')return path.join(productRoot,'src','direct-typescript-probe.mjs');
|
|
202
209
|
if(value.startsWith('@typescript-bin:')){
|
|
203
210
|
const dir=value.slice('@typescript-bin:'.length);
|
|
@@ -237,7 +244,8 @@ const checks=publicConfig.checks.map(check=>{
|
|
|
237
244
|
error(`${check.name}: reviewed coverage ${category} needs a rationale`);
|
|
238
245
|
const npmDirect=check.qualification===directQualification;
|
|
239
246
|
const pnpmTypecheck=check.qualification==='pnpm-tsc-v1';
|
|
240
|
-
|
|
247
|
+
const testsDirect=check.qualification===testsQualification;
|
|
248
|
+
if(check.script&&check.command&&!pnpmTypecheck&&!npmDirect&&!testsDirect)
|
|
241
249
|
error(`${check.name}: choose script or command, not both`);
|
|
242
250
|
const manager=publicConfig.packageManager;
|
|
243
251
|
if(check.script&&!['npm','yarn','pnpm'].includes(manager))
|
|
@@ -246,11 +254,15 @@ const checks=publicConfig.checks.map(check=>{
|
|
|
246
254
|
[`@which:${manager}`,'run',check.script]:null;
|
|
247
255
|
const npmAutomatic=check.qualification==='typescript-noemit-v1';
|
|
248
256
|
const yarnWorkspace=check.qualification==='yarn-workspace-tsc-v1';
|
|
249
|
-
if(check.qualification&&!npmAutomatic&&!npmDirect&&!yarnWorkspace&&!pnpmTypecheck)
|
|
257
|
+
if(check.qualification&&!npmAutomatic&&!npmDirect&&!yarnWorkspace&&!pnpmTypecheck&&!testsDirect)
|
|
250
258
|
error(`${check.name}: unknown qualification contract`);
|
|
251
259
|
if(npmDirect&&(manager!=='npm'||!check.script||check.cwd&&check.cwd!=='.'||
|
|
252
260
|
JSON.stringify(check.command)!==JSON.stringify(['@node','@typescript-compiler:.','--noEmit'])))
|
|
253
261
|
error(`${check.name}: direct recipe requires its explicit local compiler command`);
|
|
262
|
+
// The script's own arguments follow the runner; qualification compares them with the script.
|
|
263
|
+
if(testsDirect&&(manager!=='npm'||!check.script||check.cwd&&check.cwd!=='.'||!Array.isArray(check.command)||
|
|
264
|
+
check.command[0]!=='@node'||!/^@test-runner:(?:vitest|jest)$/.test(check.command[1]||'')))
|
|
265
|
+
error(`${check.name}: test recipe requires its explicit local test runner command`);
|
|
254
266
|
if(npmAutomatic&&(manager!=='npm'||check.cwd&&check.cwd!=='.'))
|
|
255
267
|
error(`${check.name}: automatic TypeScript qualification currently needs root npm script`);
|
|
256
268
|
if(yarnWorkspace&&(manager!=='yarn'||!check.workspace||check.cwd&&check.cwd!=='.'||
|
|
@@ -276,7 +288,7 @@ const checks=publicConfig.checks.map(check=>{
|
|
|
276
288
|
check.workspace||'.',check.script]]:[];
|
|
277
289
|
const windowsContext=process.platform==='win32'?
|
|
278
290
|
['ComSpec','COMSPEC','PATHEXT','USERPROFILE']:[];
|
|
279
|
-
const autoEnvironment=npmDirect?directEnvironment:npmAutomatic?{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME',
|
|
291
|
+
const autoEnvironment=npmDirect?directEnvironment:testsDirect?testEnvironment:npmAutomatic?{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME',
|
|
280
292
|
'NODE_ENV','BASH_ENV','ENV',...windowsContext],prefixes:['npm_config_','DYLD_','TSGO_'],
|
|
281
293
|
pathExecutables:['node','npm'],executableIdentity:true}:yarnWorkspace?
|
|
282
294
|
{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME','NODE_ENV','BASH_ENV','ENV',...windowsContext],
|
|
@@ -288,7 +300,7 @@ const checks=publicConfig.checks.map(check=>{
|
|
|
288
300
|
return {name:check.name,command:argv(scriptCommand||check.command,`${check.name}.command`),
|
|
289
301
|
cwd:check.cwd,inputs:check.inputs||[],generatedInputs:check.generatedInputs||[],
|
|
290
302
|
installedInputs:check.installedInputs||(
|
|
291
|
-
npmAutomatic||npmDirect?['node_modules/**']:[]),
|
|
303
|
+
npmAutomatic||npmDirect||testsDirect?['node_modules/**']:[]),
|
|
292
304
|
allowedExternalRoots:check.allowedExternalRoots||[],
|
|
293
305
|
probes:[...(check.probes||[]),...autoProbes]
|
|
294
306
|
.map((p,i)=>argv(p,`${check.name}.probes[${i}]`)),
|
|
@@ -299,6 +311,7 @@ const checks=publicConfig.checks.map(check=>{
|
|
|
299
311
|
qualification:check.qualification||null,script:check.script||null,
|
|
300
312
|
...(npmAutomatic?{qualificationInterpretation:'npm-launcher-withheld@1'}:{}),
|
|
301
313
|
...(npmDirect?{qualificationInterpretation:directInterpretation}:{}),
|
|
314
|
+
...(testsDirect?{qualificationInterpretation:testsInterpretation}:{}),
|
|
302
315
|
workspace:check.workspace||null,kind:check.kind||null};
|
|
303
316
|
});
|
|
304
317
|
const runtime={schema:1,provider:'declared-project@1',root,state,checks};
|
package/docs/user/pilot.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Opt-in development pilot: alpha.
|
|
1
|
+
# Opt-in development pilot: alpha.15
|
|
2
2
|
|
|
3
3
|
Use one fixed build for several ordinary development sessions. The owner will
|
|
4
4
|
invite two or three willing testers and approve their projects; nothing is
|
|
@@ -35,7 +35,7 @@ are **NOT RUN**. The recording shortcut is macOS/direct-recipe only.
|
|
|
35
35
|
For a new default npm installation:
|
|
36
36
|
|
|
37
37
|
```sh
|
|
38
|
-
npm install --global @redue/cli@0.1.0-alpha.
|
|
38
|
+
npm install --global @redue/cli@0.1.0-alpha.15
|
|
39
39
|
redue --version
|
|
40
40
|
cd my-project
|
|
41
41
|
redue --config redue.config.json init --recipe typescript-direct --check typecheck --dry-run
|
|
@@ -89,7 +89,7 @@ using it; keep the same npm prefix, config and any explicit state selection.
|
|
|
89
89
|
|
|
90
90
|
```sh
|
|
91
91
|
redue --config redue.config.json stop
|
|
92
|
-
npm install --global @redue/cli@0.1.0-alpha.
|
|
92
|
+
npm install --global @redue/cli@0.1.0-alpha.15
|
|
93
93
|
redue --config redue.config.json agent setup codex --dry-run
|
|
94
94
|
redue --config redue.config.json agent setup codex --apply
|
|
95
95
|
redue --config redue.config.json start
|
|
@@ -38,7 +38,10 @@ only its source does not stale the selected check until the consumed output
|
|
|
38
38
|
changes. Unsupported custom resolution, project references, outside-root
|
|
39
39
|
source links, and installation modes remain recording-only. Other Yarn
|
|
40
40
|
typechecks, Vitest/Jest, ESLint, and builds start recording-only unless a
|
|
41
|
-
separately justified explicit contract is provided.
|
|
41
|
+
separately justified explicit contract is provided. For a single-package npm
|
|
42
|
+
project whose test script runs Vitest 4/5 or Jest 29 directly, the opt-in
|
|
43
|
+
`init --recipe tests-direct` records the run and guards its inputs; see
|
|
44
|
+
[direct tests](tests-direct.md).
|
|
42
45
|
In particular, test caches and build-generated inputs are not excluded to
|
|
43
46
|
make evidence green.
|
|
44
47
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Direct test recipe (Vitest and Jest)
|
|
2
|
+
|
|
3
|
+
`redue init --recipe tests-direct --check test` records a project's unit test
|
|
4
|
+
run and reports later whether that result still applies to the files you have.
|
|
5
|
+
It supports a single-package npm project whose test script runs Vitest 4 or 5,
|
|
6
|
+
or Jest 29, directly. It runs on macOS and Linux with Node 22 or 24.
|
|
7
|
+
|
|
8
|
+
## Setup
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
redue init --recipe tests-direct --check test
|
|
12
|
+
redue start
|
|
13
|
+
redue run test
|
|
14
|
+
redue status --sync
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`init` picks the `test`, `test:unit` or `test:check` script when it runs a
|
|
18
|
+
runner directly, otherwise the only script that does. The script must be one
|
|
19
|
+
runner invocation: no `&&`, quotes, globs or variable assignments. Watch mode,
|
|
20
|
+
`--changed`, `related`, snapshot updates (`-u`), shuffling, sharding and
|
|
21
|
+
Jest's `--onlyChanged` are refused. Vitest needs `run`, `--run` or
|
|
22
|
+
`--watch=false`. Other scripts stay recording-only.
|
|
23
|
+
|
|
24
|
+
REDUE runs the local runner with Node directly. npm, its lifecycle scripts and
|
|
25
|
+
`package.json` are not changed or executed.
|
|
26
|
+
|
|
27
|
+
## What REDUE claims
|
|
28
|
+
|
|
29
|
+
CURRENT means the last run's inputs are unchanged: every file in the checkout
|
|
30
|
+
outside `node_modules`, the installed files in `node_modules`, the Node binary,
|
|
31
|
+
the exact command, and the environment. Every environment variable the run's
|
|
32
|
+
JavaScript read directly must have the same value, or still be unset. Node and
|
|
33
|
+
libc read some variables natively, and Vite lists the environment for its own,
|
|
34
|
+
so these are always compared: `TZ`, `LANG` and every `NODE_*`, `LC_*`,
|
|
35
|
+
`VITE_*`, `DYLD_*` and `LD_*` variable. Display-only variables are not
|
|
36
|
+
compared: `_`, `TERM`, `TERM_PROGRAM`, `COLORTERM`, `FORCE_COLOR`, `NO_COLOR`,
|
|
37
|
+
`FORCE_TTY`, `COLUMNS`, `LINES` and `AI_AGENT`.
|
|
38
|
+
It does not mean a rerun would give the same result. A test that depends on the
|
|
39
|
+
time of day or on randomness can pass now and fail later on the same inputs.
|
|
40
|
+
|
|
41
|
+
The default config excludes `**/*.md`, `**/*.mdx` and, with `--coverage`,
|
|
42
|
+
`coverage/**`. Editing those files keeps a result CURRENT unless the run read
|
|
43
|
+
them. A run may create git-ignored files (for example under `test/tmp`) and
|
|
44
|
+
read back what it wrote, in the same process or inside a directory it created:
|
|
45
|
+
when every change during the run is such an output, the result keeps the files
|
|
46
|
+
as the run left them.
|
|
47
|
+
|
|
48
|
+
## The guard
|
|
49
|
+
|
|
50
|
+
Every Node process and worker thread of the run loads a recorder
|
|
51
|
+
(`src/test-recorder.cjs`). After the run REDUE checks the record. The result is
|
|
52
|
+
kept but marked UNVERIFIED, with the reason, when the run:
|
|
53
|
+
|
|
54
|
+
- read a file that the config excludes, or a git-ignored file REDUE does not
|
|
55
|
+
observe, that the run did not create first;
|
|
56
|
+
- changed an input during the run, or read a git-ignored output an earlier
|
|
57
|
+
run left before writing it again: the result depends on that leftover;
|
|
58
|
+
- listed a directory, or read a file over 1 MB, outside the checkout, other
|
|
59
|
+
than files and directories the run created, Jest's cache directory and the
|
|
60
|
+
runners' data directories (Vitest's `vitest` folder in the user data
|
|
61
|
+
directory);
|
|
62
|
+
- connected to anything other than loopback or a local socket, or asked DNS
|
|
63
|
+
about a name other than localhost;
|
|
64
|
+
- started a process that did not load the recorder: a shell, a binary, or a
|
|
65
|
+
Node process with its own environment;
|
|
66
|
+
|
|
67
|
+
Lookups that can be asked again become run facts, checked before every CURRENT
|
|
68
|
+
answer: whether a path outside the checkout exists, where a link resolves, the
|
|
69
|
+
content of each smaller file read outside it (Linux native bindings read
|
|
70
|
+
`/proc/version` and `/usr/bin/ldd` to pick their C library), and
|
|
71
|
+
the existence and listings of excluded or ignored files in the checkout.
|
|
72
|
+
|
|
73
|
+
When two runs on the same inputs and environment give different results, the
|
|
74
|
+
check is UNVERIFIED until the inputs change.
|
|
75
|
+
|
|
76
|
+
A `--coverage` run may read back the default `coverage/` report: Jest reads
|
|
77
|
+
every JavaScript file under its roots, an earlier report's included, only to
|
|
78
|
+
map dependencies, and that does not decide pass or fail. This is not allowed
|
|
79
|
+
when the configuration names coverage thresholds, which make the report decide
|
|
80
|
+
the result, or another report directory. Configuration can be code, so REDUE
|
|
81
|
+
looks for `coverageThreshold`, `thresholds`, `coverageDirectory` and
|
|
82
|
+
`reportsDirectory` in `package.json`'s `jest` field and the runner's config
|
|
83
|
+
files; when one appears, reading an old report is UNVERIFIED.
|
|
84
|
+
|
|
85
|
+
## Known causes of UNVERIFIED
|
|
86
|
+
|
|
87
|
+
Tests that write files git does not ignore change the run's inputs.
|
|
88
|
+
|
|
89
|
+
## Limits
|
|
90
|
+
|
|
91
|
+
- Runner caches and data are trusted: `node_modules/.vite`,
|
|
92
|
+
`node_modules/.vite-temp`, `node_modules/.vitest`, Vitest's typecheck build
|
|
93
|
+
info in `node_modules/vitest/dist`, Jest's cache directory and Vitest's user
|
|
94
|
+
data directory are not inputs.
|
|
95
|
+
- Code that copies the environment and reads the copy, and native code that
|
|
96
|
+
reads files, environment variables or the network, is not seen.
|
|
97
|
+
- Metadata of excluded files (size, times) is not compared, only their
|
|
98
|
+
existence.
|
|
99
|
+
- A file the run creates and another process reads is the run's output only
|
|
100
|
+
when it is inside a directory the run created.
|
|
101
|
+
- Outputs a run keeps are part of the result's inputs afterwards. Deleting
|
|
102
|
+
them, or an observer restart that reindexes without them (git-ignored files
|
|
103
|
+
are not indexed at start), makes the result STALE.
|
|
104
|
+
- A config written by an earlier build of this recipe is UNVERIFIED with "run
|
|
105
|
+
redue init again"; move `redue.config.json` aside and run init.
|
|
106
|
+
- Windows is not supported; the check stays recording-only there.
|
package/package.json
CHANGED
|
@@ -88,20 +88,43 @@ function saved(root,host){const text=read(root,marker(host));if(text===null)retu
|
|
|
88
88
|
return value;
|
|
89
89
|
}
|
|
90
90
|
// The managed block is always in the agent's context, so it stays short. For
|
|
91
|
-
// Claude Code it names the typecheck
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
91
|
+
// Claude Code it names the typecheck and test commands the hook matches, asks
|
|
92
|
+
// the agent to run them rather than judge for itself that a rerun is not
|
|
93
|
+
// needed, and says what a skip means. The hook does the rest, so the block
|
|
94
|
+
// does not point to the skill; the skill stays for agents that look for it.
|
|
95
|
+
const quoted=forms=>forms.map(form=>`\`${form}\``).join(' and ');
|
|
96
|
+
const commandsOf=rows=>[rows.filter(row=>row.kind!=='test').map(row=>row.form),rows.filter(row=>row.kind==='test').map(row=>row.form)];
|
|
97
|
+
const fence=(host,body)=>`\n\n<!-- REDUE:${host}:begin -->\n${body}<!-- REDUE:${host}:end -->\n`;
|
|
98
|
+
const skillPointer=host=>`Before you run, skip or report a typecheck or other configured check, read\n${hosts[host].skill}.\n`;
|
|
99
|
+
function blockFor(host,rows=[]){
|
|
100
|
+
const [typechecks,tests]=commandsOf(rows);
|
|
101
|
+
if(!rows.length)return fence(host,skillPointer(host));
|
|
102
|
+
if(!tests.length)return fence(host,`Typecheck with ${quoted(typechecks)}. Run it before you say it passes, even after an\n`+
|
|
103
|
+
`edit you think cannot matter; don't skip it yourself. If its last pass still\n`+
|
|
104
|
+
`applies to these exact files, a REDUE hook skips the run and says so. That\n`+
|
|
105
|
+
`skip counts as a pass.\n`);
|
|
106
|
+
return fence(host,(typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
|
|
107
|
+
`Run the unit tests with ${quoted(tests)}.\n`+
|
|
108
|
+
`Run a check before you say it passes, even after an edit you think cannot\n`+
|
|
109
|
+
`matter; don't skip it yourself. If its last pass still applies to these exact\n`+
|
|
110
|
+
`files, a REDUE hook skips the run and says so. That skip counts as a pass.\n`);
|
|
111
|
+
}
|
|
112
|
+
// The alpha.12-alpha.14 wording, recognized so setup can replace it.
|
|
113
|
+
function earlierBlock(host,rows=[]){
|
|
114
|
+
const [typechecks,tests]=commandsOf(rows);
|
|
115
|
+
if(!rows.length)return fence(host,skillPointer(host));
|
|
116
|
+
if(!tests.length)return fence(host,`Typecheck with ${quoted(typechecks)}. If its last pass still applies to\n`+
|
|
96
117
|
`these exact files, a REDUE hook skips the run and says so. The skip counts as\n`+
|
|
97
|
-
`a pass; don't check it again.\n
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
118
|
+
`a pass; don't check it again.\n`);
|
|
119
|
+
return fence(host,(typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
|
|
120
|
+
`Run the unit tests with ${quoted(tests)}.\n`+
|
|
121
|
+
`If a check's last pass still applies to these exact files, a REDUE hook skips\n`+
|
|
122
|
+
`the run and says so. The skip counts as a pass; don't check it again.\n`);
|
|
123
|
+
}
|
|
124
|
+
// One form per check: the npm script when there is one, since that is what
|
|
102
125
|
// the project's own instructions usually name.
|
|
103
|
-
const blockForms=(host,config,relative)=>host!=='claude'?[]:hookForms(config,relative).map(row=>
|
|
104
|
-
row.forms.find(form=>form.startsWith('npm run '))??row.forms.find(form=>!form.startsWith('redue '))??row.forms[0]);
|
|
126
|
+
const blockForms=(host,config,relative)=>host!=='claude'?[]:hookForms(config,relative).map(row=>({kind:row.kind,
|
|
127
|
+
form:row.forms.find(form=>form.startsWith('npm run '))??row.forms.find(form=>!form.startsWith('redue '))??row.forms[0]}));
|
|
105
128
|
const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${host}:begin -->\n`+
|
|
106
129
|
`Before deciding whether configured verification needs repeating, use REDUE.\n`+
|
|
107
130
|
(legacy?`Read ${hosts[host].skill} for the decision policy, then query ordinary status\n`+
|
|
@@ -120,9 +143,11 @@ const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${ho
|
|
|
120
143
|
// varies, and only in the safe character set. Earlier owned versions are
|
|
121
144
|
// recognized so setup can replace them; other stored text never is.
|
|
122
145
|
function knownBlock(host,relative,block){
|
|
123
|
-
const
|
|
124
|
-
const forms=named?[...named[1].matchAll(/`([^`]+)`/g)].map(m=>m[1]):[];
|
|
125
|
-
|
|
146
|
+
const list='((?:`[A-Za-z0-9_./:=,+ -]+`(?: and )?)+)';
|
|
147
|
+
const forms=pattern=>{const named=new RegExp(pattern).exec(block);return named?[...named[1].matchAll(/`([^`]+)`/g)].map(m=>m[1]):[];};
|
|
148
|
+
const rows=[...forms(`^\\n\\n<!-- REDUE:[a-z]+:begin -->\\nTypecheck with ${list}\\.`).map(form=>({kind:'typecheck',form})),
|
|
149
|
+
...forms(`Run the unit tests with ${list}\\.\\n`).map(form=>({kind:'test',form}))];
|
|
150
|
+
return block===blockFor(host,rows)||block===earlierBlock(host,rows)||priorBlocks(host,relative).includes(block);
|
|
126
151
|
}
|
|
127
152
|
// The policy with the exact commands for this config, so an agent never builds
|
|
128
153
|
// a --config form the hook would not recognize. Claude Code gets the typecheck
|
|
@@ -131,7 +156,9 @@ function skillFor(relative,host,config){
|
|
|
131
156
|
// Double quotes work in POSIX shells, cmd and PowerShell for ordinary names.
|
|
132
157
|
const redue=redueFor(relative)??`redue --config ${JSON.stringify(relative)}`,status=`${redue} status --sync --json`;
|
|
133
158
|
const rows=host==='claude'?hookForms(config,relative):[];
|
|
134
|
-
const
|
|
159
|
+
const label=row=>row.kind==='test'?(row.check==='test'?'Unit tests':`Unit tests \`${row.check}\``):
|
|
160
|
+
row.check==='typecheck'?'Typecheck':`Typecheck \`${row.check}\``;
|
|
161
|
+
const commands=rows.map(row=>`- ${label(row)}: `+
|
|
135
162
|
`${row.forms.map(form=>`\`${form}\``).join(' or ')}.\n`).join('')+
|
|
136
163
|
(rows.length?` If the last pass still applies to these exact files, a hook skips the run\n`+
|
|
137
164
|
` and says so. That counts as a pass; don't confirm it with status. Otherwise\n`+
|
|
@@ -145,7 +172,7 @@ function skillFor(relative,host,config){
|
|
|
145
172
|
// typecheck needs only the CLAUDE.md block, so the description must not
|
|
146
173
|
// send the agent here for one.
|
|
147
174
|
const description=rows.length?'Rules and exact commands for redue status, redue run and other redue commands. '+
|
|
148
|
-
|
|
175
|
+
`A plain ${rows.some(row=>row.kind==='test')?'typecheck or test run':'typecheck'} needs only the REDUE section of CLAUDE.md.`:
|
|
149
176
|
'Use before running, skipping or reporting a typecheck or other configured check.';
|
|
150
177
|
// Replacer functions, so `$` in a config name is never a replacement pattern.
|
|
151
178
|
return policy.replace('{{DESCRIPTION}}',()=>description).replace('{{CONFIG}}',()=>JSON.stringify(relative)).replace('{{COMMANDS}}',()=>commands.trimEnd())
|
|
@@ -241,7 +268,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
241
268
|
{path:h.instruction,before,after});
|
|
242
269
|
if(host==='claude'){const settings=read(root,settingsPath);
|
|
243
270
|
changes.push({path:settingsPath,before:settings,after:settingsWith(settings,hook),
|
|
244
|
-
content:hook?JSON.stringify(hook,null,2):'remove the REDUE PreToolUse hook (no typecheck check to match)'});}
|
|
271
|
+
content:hook?JSON.stringify(hook,null,2):'remove the REDUE PreToolUse hook (no typecheck or test check to match)'});}
|
|
245
272
|
changes.push({path:marker(host),before:read(root,marker(host)),after:JSON.stringify(next,null,2)+'\n'});
|
|
246
273
|
}else if(record){
|
|
247
274
|
const before=read(root,h.instruction),after=before.replace(record.block,'');
|
package/src/claude-hook.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Claude Code PreToolUse hook: skip an exact typecheck rerun when REDUE
|
|
2
|
-
// CURRENT/PASS with reuse_eligible on the current inputs. When the check is not
|
|
1
|
+
// Claude Code PreToolUse hook: skip an exact typecheck or test rerun when REDUE
|
|
2
|
+
// reports CURRENT/PASS with reuse_eligible on the current inputs. When the check is not
|
|
3
3
|
// CURRENT, the command becomes `redue run <check>` so the run is recorded. Every
|
|
4
4
|
// other outcome, including errors and timeouts, allows the command unchanged.
|
|
5
5
|
// Never exit 2: Claude Code treats exit 2 as a block.
|
|
@@ -7,33 +7,43 @@ import fs from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import {spawn} from 'node:child_process';
|
|
9
9
|
import {projectState} from './project-location.mjs';
|
|
10
|
-
import {realObservedPath} from './path-identity.mjs';
|
|
10
|
+
import {realObservedPath,samePath} from './path-identity.mjs';
|
|
11
11
|
|
|
12
12
|
export const hookCommand='redue agent hook claude';
|
|
13
13
|
const safe=/^[A-Za-z0-9_./:=,+-]+$/;
|
|
14
14
|
const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
|
|
15
|
+
// A direct test check: its command is the runner and the script's arguments.
|
|
16
|
+
const tests=check=>check?.qualification==='npm-tests-direct-v1'&&Array.isArray(check.command)&&
|
|
17
|
+
/^@test-runner:(?:vitest|jest)$/.test(check.command[1]||'');
|
|
18
|
+
export const checkKind=check=>tests(check)?'test':'typecheck';
|
|
15
19
|
|
|
16
20
|
export const defaultConfig='redue.config.json';
|
|
17
21
|
// The `redue` prefix for a config beside the project root; null when the name
|
|
18
22
|
// would need shell quoting.
|
|
19
23
|
export const redueFor=(configFile=defaultConfig)=>configFile===defaultConfig?'redue':
|
|
20
24
|
safe.test(configFile)?`redue --config ${configFile}`:null;
|
|
21
|
-
// Exact commands that rerun each configured typecheck
|
|
22
|
-
// shell quoting get no form, so the hook never fires for them.
|
|
25
|
+
// Exact commands that rerun each configured typecheck or direct test check.
|
|
26
|
+
// Names that would need shell quoting get no form, so the hook never fires for them.
|
|
23
27
|
export function hookForms(config,configFile=defaultConfig){
|
|
24
28
|
const rows=[],redue=redueFor(configFile);
|
|
25
29
|
for(const check of config?.checks||[]){
|
|
26
|
-
if(!typecheck(check)||typeof check.name!=='string'||!safe.test(check.name))continue;
|
|
30
|
+
if(!(typecheck(check)||tests(check))||typeof check.name!=='string'||!safe.test(check.name))continue;
|
|
27
31
|
const forms=redue?[`${redue} run ${check.name}`]:[];
|
|
28
32
|
// Other forms run from the project root, so they match only a root check.
|
|
29
33
|
if(check.cwd!==undefined&&check.cwd!=='.'){if(forms.length)rows.push({check:check.name,forms});continue;}
|
|
30
34
|
if(config.packageManager==='npm'&&typeof check.script==='string'&&safe.test(check.script))
|
|
31
35
|
forms.push(`npm run ${check.script}`);
|
|
32
36
|
if(check.qualification==='npm-typescript-direct-v1')forms.push('npx tsc --noEmit');
|
|
33
|
-
if(Array.isArray(check.command)&&check.command.length&&
|
|
37
|
+
if(!tests(check)&&Array.isArray(check.command)&&check.command.length&&
|
|
34
38
|
check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
|
|
35
39
|
forms.push(check.command.join(' '));
|
|
36
|
-
|
|
40
|
+
// The script's own text, which runs the same runner with the same arguments.
|
|
41
|
+
if(tests(check)){
|
|
42
|
+
if(check.script==='test'&&config.packageManager==='npm')forms.push('npm test');
|
|
43
|
+
const text=[check.command[1].slice('@test-runner:'.length),...check.command.slice(2)];
|
|
44
|
+
if(text.every(v=>typeof v==='string'&&safe.test(v)))forms.push(text.join(' '),`npx ${text.join(' ')}`);
|
|
45
|
+
}
|
|
46
|
+
if(forms.length)rows.push({check:check.name,kind:checkKind(check),forms:[...new Set(forms)]});
|
|
37
47
|
}
|
|
38
48
|
return rows;
|
|
39
49
|
}
|
|
@@ -63,6 +73,21 @@ export function recordedCommand(command,name,configFile=defaultConfig){
|
|
|
63
73
|
return parts.base===run?null:run+parts.suffix;
|
|
64
74
|
}
|
|
65
75
|
|
|
76
|
+
// A leading `cd DIR;` or `cd DIR &&` into the project root, which agents
|
|
77
|
+
// often add before a check: {prefix, command} with the rest of the command,
|
|
78
|
+
// the whole command when there is no such prefix, or null when it enters
|
|
79
|
+
// another directory. DIR may be quoted; it may not use variables, a leading
|
|
80
|
+
// `~` or escapes. Inside double quotes a backslash before anything but
|
|
81
|
+
// $ ` " \ is literal, so a quoted Windows path matches; unquoted it is an escape.
|
|
82
|
+
const cdPrefix=/^[ \t]*cd[ \t]+(?:"((?:[^"$`\\]|\\(?![$`"\\\n]))*)"|'([^']*)'|((?!~)[^\s;&|'"$`\\]+))[ \t]*(?:;|&&)[ \t]*/;
|
|
83
|
+
export function rootCd(command,cwd,projectRoot){
|
|
84
|
+
const prefix=typeof command==='string'?cdPrefix.exec(command):null;
|
|
85
|
+
if(!prefix)return {prefix:'',command};
|
|
86
|
+
const dir=prefix[1]??prefix[2]??prefix[3];
|
|
87
|
+
try{if(!samePath(realObservedPath(path.resolve(cwd,dir)),projectRoot))return null;}catch{return null;}
|
|
88
|
+
return {prefix:prefix[0],command:command.slice(prefix[0].length)};
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
export function verdict(status,name,rest=null){
|
|
67
92
|
const row=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)?
|
|
68
93
|
status.checks.find(item=>item?.name===name):null;
|
|
@@ -119,7 +144,9 @@ export async function claudeHook(input,{entry,timeout}){
|
|
|
119
144
|
if(record?.schema!==1||record.host!=='claude'||typeof record.config!=='string'||
|
|
120
145
|
!/^[^/\\]+\.json$/.test(record.config))return null;
|
|
121
146
|
const config=path.join(root,record.config);
|
|
122
|
-
const parsed=JSON.parse(fs.readFileSync(config,'utf8'))
|
|
147
|
+
const parsed=JSON.parse(fs.readFileSync(config,'utf8'));
|
|
148
|
+
const entered=rootCd(command,root,realObservedPath(path.resolve(path.dirname(config),parsed.root||'.')));
|
|
149
|
+
const match=entered&&matched(entered.command,parsed,record.config);
|
|
123
150
|
if(!match)return null;
|
|
124
151
|
const name=match.check,status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,name)),
|
|
125
152
|
reason=verdict(status,name,match.rest);
|
|
@@ -130,12 +157,12 @@ export async function claudeHook(input,{entry,timeout}){
|
|
|
130
157
|
// stays too: the rewrite returns "allow", which would also skip the prompt
|
|
131
158
|
// for the rest of the chain.
|
|
132
159
|
const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
|
|
133
|
-
status.checks.some(item=>item?.name===name)?recordedCommand(command,name,record.config):null;
|
|
160
|
+
status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,record.config):null;
|
|
134
161
|
// updatedInput replaces the whole tool input. Deny and ask rules still apply
|
|
135
162
|
// to the rewritten command; "allow" skips only the prompt.
|
|
136
163
|
return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
|
137
164
|
permissionDecisionReason:`REDUE: check ${JSON.stringify(name)} is not CURRENT; running it through redue run so the result is recorded.`,
|
|
138
|
-
updatedInput:{...input.tool_input,command:rewritten}}}:null;
|
|
165
|
+
updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
|
|
139
166
|
}
|
|
140
167
|
|
|
141
168
|
export async function runClaudeHook(entry){
|