@redue/cli 0.1.0-alpha.13 → 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 CHANGED
@@ -1,5 +1,42 @@
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
+
3
40
  ## 0.1.0-alpha.13 — 2026-10-19
4
41
 
5
42
  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.13**. Follow the
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
 
@@ -133,6 +133,31 @@ 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. Cost was the same ($0.54 without, $0.56 with).
160
+
136
161
  ## What the states mean
137
162
 
138
163
  | State | Meaning | Normal next step |
@@ -156,7 +181,9 @@ has been verified.
156
181
  [direct local compiler recipe](docs/user/direct-typescript.md) for the reviewed
157
182
  npm/TypeScript boundary. Workspace
158
183
  boundaries follow the files actually consumed, including built declarations.
159
- - Existing test/lint/build and arbitrary commands can record results while
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
160
187
  their applicability remains unqualified.
161
188
 
162
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]':value.startsWith('@typescript-bin:')?
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
- if(check.script&&check.command&&!pnpmTypecheck&&!npmDirect)
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};
@@ -1,4 +1,4 @@
1
- # Opt-in development pilot: alpha.13
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.13
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.13
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@redue/cli",
3
3
  "private": false,
4
- "version": "0.1.0-alpha.13",
4
+ "version": "0.1.0-alpha.14",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
@@ -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 command the hook matches and what a skip
92
- // means. The hook does the rest, so the block does not point to the skill;
93
- // the skill stays for agents that look for it.
94
- function blockFor(host,forms=[]){return `\n\n<!-- REDUE:${host}:begin -->\n`+
95
- (forms.length?`Typecheck with ${forms.map(form=>`\`${form}\``).join(' and ')}. If its last pass still applies to\n`+
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
- `Before you run, skip or report a typecheck or other configured check, read\n`+
99
- `${hosts[host].skill}.\n`)+
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 typecheck: the npm script when there is one, since that is what
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 named=/^\n\n<!-- REDUE:[a-z]+:begin -->\nTypecheck with ((?:`[A-Za-z0-9_./:=,+ -]+`(?: and )?)+)\. /.exec(block);
124
- const forms=named?[...named[1].matchAll(/`([^`]+)`/g)].map(m=>m[1]):[];
125
- return block===blockFor(host,forms)||priorBlocks(host,relative).includes(block);
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 commands=rows.map(row=>`- ${row.check==='typecheck'?'Typecheck':`Typecheck \`${row.check}\``}: `+
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
- 'A plain typecheck needs only the REDUE section of CLAUDE.md.':
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,'');
@@ -1,5 +1,5 @@
1
- // Claude Code PreToolUse hook: skip an exact typecheck rerun when REDUE reports
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. Names that would need
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
- if(forms.length)rows.push({check:check.name,forms:[...new Set(forms)]});
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
  }