@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 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.13**. Follow the
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
- - Existing test/lint/build and arbitrary commands can record results while
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]':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.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.13
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.13
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
@@ -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.15",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
@@ -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 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, 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
- `Before you run, skip or report a typecheck or other configured check, read\n`+
99
- `${hosts[host].skill}.\n`)+
100
- `<!-- REDUE:${host}:end -->\n`;}
101
- // One form per typecheck: the npm script when there is one, since that is what
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 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);
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 commands=rows.map(row=>`- ${row.check==='typecheck'?'Typecheck':`Typecheck \`${row.check}\``}: `+
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
- 'A plain typecheck needs only the REDUE section of CLAUDE.md.':
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,'');
@@ -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.
@@ -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. 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
  }
@@ -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')),match=matched(command,parsed,record.config);
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){