@redue/cli 0.1.0-alpha.12 → 0.1.0-alpha.14
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 +57 -0
- package/README.md +65 -5
- package/bin/redue.mjs +19 -6
- package/docs/user/json.md +1 -0
- 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 +27 -16
- package/src/claude-hook.mjs +17 -7
- package/src/cli.mjs +96 -20
- package/src/daemon.mjs +57 -12
- 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/pnpm-inputs.mjs +3 -2
- package/src/probe-deadline.mjs +37 -0
- package/src/test-direct.mjs +365 -0
- package/src/test-recorder.cjs +318 -0
- package/src/typescript-input-facts.mjs +3 -2
- package/src/typescript-list.mjs +3 -2
- package/src/yarn-workspace-typecheck-probe.mjs +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.14 — 2026-10-21
|
|
4
|
+
|
|
5
|
+
Bench numbers below are from one 800-file project on macOS arm64, Node 22.13.0.
|
|
6
|
+
Released alpha.13 and this build ran interleaved on the same machine; the
|
|
7
|
+
typecheck decision path is unchanged within noise (warm `status --sync` 162/157
|
|
8
|
+
ms on alpha.13, 169/166/289 ms on alpha.14; `redue run` overhead 12/14% and
|
|
9
|
+
12/7/18%; states correct, no idle probe launches).
|
|
10
|
+
|
|
11
|
+
- New opt-in recipe for unit tests: `redue init --recipe tests-direct --check
|
|
12
|
+
test` records a single-package npm project's Vitest 4/5 or Jest 29 run on
|
|
13
|
+
macOS and Linux. The check's inputs are the whole checkout (minus `*.md`,
|
|
14
|
+
`*.mdx` and, with `--coverage`, `coverage/`), the installed files, the Node
|
|
15
|
+
binary, the exact command, and every environment variable the run's
|
|
16
|
+
JavaScript reads directly, compared by value. A recorder loaded into every
|
|
17
|
+
Node process and worker thread of the run guards the rest: reading an
|
|
18
|
+
excluded or git-ignored file, a directory listing or large file outside the
|
|
19
|
+
checkout, network use other than loopback, or a process that escaped the
|
|
20
|
+
recorder makes the result UNVERIFIED with the reason. Lookups outside the
|
|
21
|
+
checkout become run facts that status checks again. Two runs on the same
|
|
22
|
+
inputs with different results make the check UNVERIFIED until the inputs
|
|
23
|
+
change. See [direct tests](docs/user/tests-direct.md) for the claim and its
|
|
24
|
+
limits.
|
|
25
|
+
- Claude Code: the hook skips or records a direct test check's runs (`npm run
|
|
26
|
+
<script>`, `npm test`, the script's own command) like the typecheck's.
|
|
27
|
+
- Linux: a directory created or deleted in the checkout is now an event, not
|
|
28
|
+
an observation gap. Before, every new directory forced a full reconciliation,
|
|
29
|
+
and a second one during it left the observer UNVERIFIED (`plan_gap`).
|
|
30
|
+
- An excluded installed path appearing (a runner cache, a TypeScript build-info
|
|
31
|
+
file) no longer changes the input plan. Checks with such an exclusion are
|
|
32
|
+
STALE with "input plan changed" once after the upgrade, until their next run.
|
|
33
|
+
- Measured with Claude Code on three public projects (Sonnet 5.5, CI Linux,
|
|
34
|
+
72 sessions): agents skipped 21 test runs, every skip confirmed by three
|
|
35
|
+
plain reruns on its tree, and session time fell from 515 to 295 s summed over
|
|
36
|
+
the task medians. In two sessions the agent itself left out its final test
|
|
37
|
+
run after a README-only edit, without asking REDUE; both said so, and the
|
|
38
|
+
final trees passed.
|
|
39
|
+
|
|
40
|
+
## 0.1.0-alpha.13 — 2026-10-19
|
|
41
|
+
|
|
42
|
+
Bench numbers below are from one 800-file project on macOS arm64, Node 22.13.0.
|
|
43
|
+
Released alpha.12 and this build ran interleaved on the same machine; the
|
|
44
|
+
decision path is unchanged within noise (warm `status --sync` 192/197 ms on
|
|
45
|
+
alpha.12, 174/198 ms on alpha.13; states correct, no idle probe launches).
|
|
46
|
+
On the CI screen of 38 public TypeScript projects, 22 qualify for the direct
|
|
47
|
+
recipe, up from 19 at alpha.12: worldmonitor, Recordly and open-cowork, which
|
|
48
|
+
the fixed 5 s probe deadline blocked.
|
|
49
|
+
|
|
50
|
+
- A full state probe may run longer on large projects. Its deadline was a
|
|
51
|
+
fixed 5 s (10 s on Windows and for pnpm); now it is the larger of twice the
|
|
52
|
+
last successful probe and the last completed run, at least that floor and at
|
|
53
|
+
most 60 s, doubling after a timeout. A probe that never finishes is still
|
|
54
|
+
killed at its deadline and the check stays UNVERIFIED. `redue status --sync`
|
|
55
|
+
and `redue run` wait for the observer up to the sum of its checks' probe
|
|
56
|
+
deadlines plus 10 s (`probe_deadline_ms` in status JSON), never less than
|
|
57
|
+
before. While a full probe runs the observer does not answer, so with a hung
|
|
58
|
+
probe a decision can wait up to 60 s per check before it is UNVERIFIED.
|
|
59
|
+
|
|
3
60
|
## 0.1.0-alpha.12 — 2026-10-19
|
|
4
61
|
|
|
5
62
|
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.14**. 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
|
|
|
@@ -96,9 +96,67 @@ Setup is explicit, reversible, and does not change tool permissions or run check
|
|
|
96
96
|
|
|
97
97
|
The experimental claim is **opt-in project-local agent activation and direct TypeScript
|
|
98
98
|
evidence, with fresh-session reuse demonstrated in the tested Codex/macOS
|
|
99
|
-
configuration
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
configuration and measured for Claude Code below**. Cross-host reuse is not
|
|
100
|
+
tested. This is experimental guidance, not guaranteed agent compliance, general
|
|
101
|
+
sandbox compatibility, or a promise of time savings.
|
|
102
|
+
|
|
103
|
+
### Measured with Claude Code
|
|
104
|
+
|
|
105
|
+
Four public TypeScript projects, four tasks each, three sessions per task with
|
|
106
|
+
and without REDUE: 96 headless Claude Code sessions (Sonnet 5.5, CI Linux
|
|
107
|
+
runner, alpha.12 code, October 2026). Plain `tsc --noEmit` takes 10–19 s on
|
|
108
|
+
these projects. Medians per task, summed over the four projects:
|
|
109
|
+
|
|
110
|
+
| Task | Session time, without → with REDUE | Input tokens | Typechecks skipped |
|
|
111
|
+
|---|---|---|---|
|
|
112
|
+
| Docs edit, then "make sure it still typechecks" | 97 → 45 s | 169k → 162k | 12 of 12 |
|
|
113
|
+
| "Does it still typecheck?" after a pull | 74 → 22 s | 117k → 89k | 12 of 12 |
|
|
114
|
+
| Source change, typecheck, then a docs edit | 141 → 112 s | 210k → 200k | 9 of 12 |
|
|
115
|
+
| Source change with tests | 152 → 151 s | 269k → 234k | 0 of 12 |
|
|
116
|
+
| **All tasks** | **464 → 330 s** | **765k → 685k** | 33 |
|
|
117
|
+
|
|
118
|
+
- **Correctness:** every typecheck REDUE skipped was checked afterwards with
|
|
119
|
+
plain tsc on the exact tree it was skipped on. All 33 agreed, and all 96
|
|
120
|
+
final trees passed plain tsc and the task checks in both arms.
|
|
121
|
+
- **Cost:** API cost was the same ($0.65 without, $0.66 with). One-time setup
|
|
122
|
+
per checkout was 3–5 s, plus one recorded typecheck; the CLAUDE.md section
|
|
123
|
+
REDUE adds is about 60 tokens.
|
|
124
|
+
- **How:** each session gets a fresh clone at a pinned commit; the REDUE arm
|
|
125
|
+
first runs `redue init`, `agent setup claude`, `start` and one
|
|
126
|
+
`redue run typecheck`, standing for an earlier session's passing check.
|
|
127
|
+
The harness, tasks and raw results are in [`eval/`](eval/README.md).
|
|
128
|
+
- **Limits:** four projects and three runs per cell show direction, not a
|
|
129
|
+
general rate. Savings come only when an earlier pass still applies: tasks
|
|
130
|
+
that change source code save nothing, and on one project the docs edit came
|
|
131
|
+
after source edits that made the result STALE, so nothing was skipped. The
|
|
132
|
+
projects were chosen because they qualify for the direct TypeScript recipe;
|
|
133
|
+
of 38 screened projects, 19 qualified at alpha.12 and 22 at alpha.13. Times are agent session
|
|
134
|
+
time only, on one runner type.
|
|
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. Cost was the same ($0.54 without, $0.56 with).
|
|
102
160
|
|
|
103
161
|
## What the states mean
|
|
104
162
|
|
|
@@ -123,7 +181,9 @@ has been verified.
|
|
|
123
181
|
[direct local compiler recipe](docs/user/direct-typescript.md) for the reviewed
|
|
124
182
|
npm/TypeScript boundary. Workspace
|
|
125
183
|
boundaries follow the files actually consumed, including built declarations.
|
|
126
|
-
-
|
|
184
|
+
- Opt-in [direct test recipe](docs/user/tests-direct.md) for Vitest 4/5 and
|
|
185
|
+
Jest 29 in single-package npm projects on macOS and Linux.
|
|
186
|
+
- Other test/lint/build and arbitrary commands can record results while
|
|
127
187
|
their applicability remains unqualified.
|
|
128
188
|
|
|
129
189
|
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/json.md
CHANGED
|
@@ -31,6 +31,7 @@ Stable-enough-for-alpha fields:
|
|
|
31
31
|
| `freshness` | `CURRENT`, `STALE`, or `UNVERIFIED`, independent of result. |
|
|
32
32
|
| `reuse_eligible` | True only for qualified, applicable passing evidence. Never derive permission to reuse from outcome alone. Missing means false. |
|
|
33
33
|
| `run_facts` | Number of lookups outside the discovery certificate that the receipt's run made (direct TypeScript recipe). Status asks each again before answering CURRENT. Absent when there are none. |
|
|
34
|
+
| `probe_deadline_ms` | Top level. How long the full state probes of one decision may take in total: each check's deadline (5 s to 60 s, from earlier probes and runs) times its probe count, summed. Callers wait at least this long plus 10 s for a synchronized answer. |
|
|
34
35
|
| `reason` | Human explanation; not a stable machine enum. Do not parse it to authorize reuse. |
|
|
35
36
|
| `changed_inputs` | Known changed relevant paths when available, possibly only the first difference; empty/missing is not proof of no changes. |
|
|
36
37
|
| `invocation` | Null without a receipt; otherwise run ID, completion `status`, duration, exit code/signal when known. Status distinguishes exited, interrupted, and start/capture failure. |
|
package/docs/user/pilot.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Opt-in development pilot: alpha.
|
|
1
|
+
# Opt-in development pilot: alpha.14
|
|
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.14
|
|
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.14
|
|
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,27 @@ 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
|
-
// means. The hook does the rest, so the block does not point to
|
|
93
|
-
// the skill stays for agents that look for it.
|
|
94
|
-
|
|
95
|
-
|
|
91
|
+
// Claude Code it names the typecheck and test commands the hook matches and
|
|
92
|
+
// what a skip means. The hook does the rest, so the block does not point to
|
|
93
|
+
// the skill; the skill stays for agents that look for it. A typecheck-only
|
|
94
|
+
// block keeps its earlier wording.
|
|
95
|
+
const quoted=forms=>forms.map(form=>`\`${form}\``).join(' and ');
|
|
96
|
+
function blockFor(host,rows=[]){
|
|
97
|
+
const typechecks=rows.filter(row=>row.kind!=='test').map(row=>row.form),tests=rows.filter(row=>row.kind==='test').map(row=>row.form);
|
|
98
|
+
return `\n\n<!-- REDUE:${host}:begin -->\n`+
|
|
99
|
+
(!rows.length?`Before you run, skip or report a typecheck or other configured check, read\n`+
|
|
100
|
+
`${hosts[host].skill}.\n`:!tests.length?`Typecheck with ${quoted(typechecks)}. If its last pass still applies to\n`+
|
|
96
101
|
`these exact files, a REDUE hook skips the run and says so. The skip counts as\n`+
|
|
97
102
|
`a pass; don't check it again.\n`:
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
(typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
|
|
104
|
+
`Run the unit tests with ${quoted(tests)}.\n`+
|
|
105
|
+
`If a check's last pass still applies to these exact files, a REDUE hook skips\n`+
|
|
106
|
+
`the run and says so. The skip counts as a pass; don't check it again.\n`)+
|
|
100
107
|
`<!-- REDUE:${host}:end -->\n`;}
|
|
101
|
-
// One form per
|
|
108
|
+
// One form per check: the npm script when there is one, since that is what
|
|
102
109
|
// 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]);
|
|
110
|
+
const blockForms=(host,config,relative)=>host!=='claude'?[]:hookForms(config,relative).map(row=>({kind:row.kind,
|
|
111
|
+
form:row.forms.find(form=>form.startsWith('npm run '))??row.forms.find(form=>!form.startsWith('redue '))??row.forms[0]}));
|
|
105
112
|
const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${host}:begin -->\n`+
|
|
106
113
|
`Before deciding whether configured verification needs repeating, use REDUE.\n`+
|
|
107
114
|
(legacy?`Read ${hosts[host].skill} for the decision policy, then query ordinary status\n`+
|
|
@@ -120,9 +127,11 @@ const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${ho
|
|
|
120
127
|
// varies, and only in the safe character set. Earlier owned versions are
|
|
121
128
|
// recognized so setup can replace them; other stored text never is.
|
|
122
129
|
function knownBlock(host,relative,block){
|
|
123
|
-
const
|
|
124
|
-
const forms=named?[...named[1].matchAll(/`([^`]+)`/g)].map(m=>m[1]):[];
|
|
125
|
-
|
|
130
|
+
const list='((?:`[A-Za-z0-9_./:=,+ -]+`(?: and )?)+)';
|
|
131
|
+
const forms=pattern=>{const named=new RegExp(pattern).exec(block);return named?[...named[1].matchAll(/`([^`]+)`/g)].map(m=>m[1]):[];};
|
|
132
|
+
const rows=[...forms(`^\\n\\n<!-- REDUE:[a-z]+:begin -->\\nTypecheck with ${list}\\.`).map(form=>({kind:'typecheck',form})),
|
|
133
|
+
...forms(`Run the unit tests with ${list}\\.\\n`).map(form=>({kind:'test',form}))];
|
|
134
|
+
return block===blockFor(host,rows)||priorBlocks(host,relative).includes(block);
|
|
126
135
|
}
|
|
127
136
|
// The policy with the exact commands for this config, so an agent never builds
|
|
128
137
|
// a --config form the hook would not recognize. Claude Code gets the typecheck
|
|
@@ -131,7 +140,9 @@ function skillFor(relative,host,config){
|
|
|
131
140
|
// Double quotes work in POSIX shells, cmd and PowerShell for ordinary names.
|
|
132
141
|
const redue=redueFor(relative)??`redue --config ${JSON.stringify(relative)}`,status=`${redue} status --sync --json`;
|
|
133
142
|
const rows=host==='claude'?hookForms(config,relative):[];
|
|
134
|
-
const
|
|
143
|
+
const label=row=>row.kind==='test'?(row.check==='test'?'Unit tests':`Unit tests \`${row.check}\``):
|
|
144
|
+
row.check==='typecheck'?'Typecheck':`Typecheck \`${row.check}\``;
|
|
145
|
+
const commands=rows.map(row=>`- ${label(row)}: `+
|
|
135
146
|
`${row.forms.map(form=>`\`${form}\``).join(' or ')}.\n`).join('')+
|
|
136
147
|
(rows.length?` If the last pass still applies to these exact files, a hook skips the run\n`+
|
|
137
148
|
` and says so. That counts as a pass; don't confirm it with status. Otherwise\n`+
|
|
@@ -145,7 +156,7 @@ function skillFor(relative,host,config){
|
|
|
145
156
|
// typecheck needs only the CLAUDE.md block, so the description must not
|
|
146
157
|
// send the agent here for one.
|
|
147
158
|
const description=rows.length?'Rules and exact commands for redue status, redue run and other redue commands. '+
|
|
148
|
-
|
|
159
|
+
`A plain ${rows.some(row=>row.kind==='test')?'typecheck or test run':'typecheck'} needs only the REDUE section of CLAUDE.md.`:
|
|
149
160
|
'Use before running, skipping or reporting a typecheck or other configured check.';
|
|
150
161
|
// Replacer functions, so `$` in a config name is never a replacement pattern.
|
|
151
162
|
return policy.replace('{{DESCRIPTION}}',()=>description).replace('{{CONFIG}}',()=>JSON.stringify(relative)).replace('{{COMMANDS}}',()=>commands.trimEnd())
|
|
@@ -241,7 +252,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
241
252
|
{path:h.instruction,before,after});
|
|
242
253
|
if(host==='claude'){const settings=read(root,settingsPath);
|
|
243
254
|
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)'});}
|
|
255
|
+
content:hook?JSON.stringify(hook,null,2):'remove the REDUE PreToolUse hook (no typecheck or test check to match)'});}
|
|
245
256
|
changes.push({path:marker(host),before:read(root,marker(host)),after:JSON.stringify(next,null,2)+'\n'});
|
|
246
257
|
}else if(record){
|
|
247
258
|
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.
|
|
@@ -12,28 +12,38 @@ import {realObservedPath} from './path-identity.mjs';
|
|
|
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
|
}
|