@redue/cli 0.1.0-alpha.15 → 0.1.0-alpha.17
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 +68 -0
- package/README.md +1 -1
- package/bin/redue.mjs +17 -10
- package/docs/user/agents.md +46 -11
- package/docs/user/pilot.md +3 -3
- package/docs/user/supported-projects.md +16 -3
- package/docs/user/tests-direct.md +20 -4
- package/package.json +1 -1
- package/src/agent-integration.mjs +43 -28
- package/src/claude-hook.mjs +229 -40
- package/src/cli.mjs +9 -4
- package/src/daemon.mjs +1 -1
- package/src/declared-plan.mjs +8 -1
- package/src/index.mjs +29 -12
- package/src/installed-inputs.mjs +4 -0
- package/src/node-onboarding.mjs +21 -17
- package/src/pnpm-inputs.mjs +62 -31
- package/src/test-direct.mjs +145 -32
- package/src/test-recorder.cjs +3 -2
- package/src/workspace-manifests.mjs +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,73 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.17 — 2026-10-22
|
|
4
|
+
|
|
5
|
+
Workspaces and package managers beyond npm. In the census of 108 public
|
|
6
|
+
TypeScript repos, the ones `init` makes ready went from 5 to 8 for typecheck
|
|
7
|
+
and from 6 to 22 for tests.
|
|
8
|
+
|
|
9
|
+
- pnpm 9-12 for the pnpm TypeScript contract (it took only an exact pnpm 12
|
|
10
|
+
pin). The installed `.modules.yaml` names the version; a pin is optional,
|
|
11
|
+
with or without Corepack's `+sha512` hash (pins with a hash never matched
|
|
12
|
+
before). `.npmrc` settings that only change what gets installed are
|
|
13
|
+
accepted; settings that move or copy installed files, or run code during
|
|
14
|
+
install, are refused in either file: custom modules, virtual-store or
|
|
15
|
+
lockfile directories, the global virtual store, injected workspace
|
|
16
|
+
packages, separate lockfiles, pnpmfile hooks, `configDependencies`.
|
|
17
|
+
- The tests recipe takes single-package pnpm 9-12 and Yarn `node-modules`
|
|
18
|
+
installs, and gives each workspace package (npm or Yarn `workspaces`, pnpm
|
|
19
|
+
`packages`) whose test script runs Vitest directly its own `PACKAGE:test`
|
|
20
|
+
check, run in the package directory. Inputs stay the whole checkout; every
|
|
21
|
+
package's node_modules is an installed input. Jest and root test scripts in
|
|
22
|
+
workspaces stay recording-only. A package added later makes the
|
|
23
|
+
workspace's checks UNVERIFIED until `init` runs again.
|
|
24
|
+
- A recorded test run is UNVERIFIED when an installed file has another hard
|
|
25
|
+
link: a package store outside the checkout (pnpm's default on Linux, Yarn's
|
|
26
|
+
hardlinks modes) could change it unseen. pnpm on macOS clones files and is
|
|
27
|
+
not affected; on Linux, pnpm test checks qualify but are not reused unless
|
|
28
|
+
pnpm copies files.
|
|
29
|
+
- Observer: an installed link to a directory (pnpm links every package) is
|
|
30
|
+
observed by its link chain when the target is itself observed installed;
|
|
31
|
+
any other directory link still makes observation unavailable.
|
|
32
|
+
- Workspace patterns honour `!pattern` exclusions; before, a negated pattern
|
|
33
|
+
matched every directory.
|
|
34
|
+
- Fixed: a new workspace package manifest did not re-plan a test check, for
|
|
35
|
+
npm workspaces too.
|
|
36
|
+
|
|
37
|
+
Eval on alpha.17 code (CI 39828002753, 84 sessions, test tasks on twilio,
|
|
38
|
+
cheerio and SwitchHosts plus the pnpm project devops-daily): 0 critical
|
|
39
|
+
findings; 23 skipped test runs, each passing three plain reruns; nothing was
|
|
40
|
+
skipped on devops-daily, whose recorded runs were UNVERIFIED on the Linux
|
|
41
|
+
runner.
|
|
42
|
+
## 0.1.0-alpha.16 — 2026-10-22
|
|
43
|
+
|
|
44
|
+
Changes to the Claude Code integration, and one fix for commands that meet
|
|
45
|
+
on fresh state. Run `redue agent setup claude --apply` again to get the new
|
|
46
|
+
hook entries.
|
|
47
|
+
|
|
48
|
+
- A SessionStart hook starts the project's observer when a session starts,
|
|
49
|
+
resumes, clears or compacts, if none is running. It returns at once and
|
|
50
|
+
prints nothing. Without a running observer every check was UNVERIFIED, so
|
|
51
|
+
the hook could neither skip nor record. `agent doctor` reports it.
|
|
52
|
+
- The hook matches more command forms, each mapping to one configured check:
|
|
53
|
+
`pnpm SCRIPT`, `pnpm run SCRIPT`, `yarn SCRIPT`, `yarn run SCRIPT`,
|
|
54
|
+
`pnpm exec tsc ...`; for a workspace package's check, `pnpm --filter NAME
|
|
55
|
+
SCRIPT`, `yarn workspace NAME SCRIPT`, `npm run SCRIPT -w NAME`, and its
|
|
56
|
+
script after a `cd` into the package. In the census of 108 public
|
|
57
|
+
TypeScript repos, 61 use pnpm or Yarn.
|
|
58
|
+
- `turbo run TASK` and, in pnpm projects, `pnpm -r TASK` (or a root script
|
|
59
|
+
that is one of them) are skipped only when every workspace package with the
|
|
60
|
+
task's script has a check and all are CURRENT; otherwise they run as typed,
|
|
61
|
+
never rewritten. No skip when turbo may run more (other `dependsOn` tasks,
|
|
62
|
+
`with`, package-scoped keys, a package turbo.json), when pnpm includes the
|
|
63
|
+
workspace root, or for a root script with pre/post scripts. Filters and nx
|
|
64
|
+
are not matched.
|
|
65
|
+
- Fixed: when two commands met on fresh state (SessionStart's `start` and the
|
|
66
|
+
first `status`), the second failed and no observer started.
|
|
67
|
+
|
|
68
|
+
Pilot eval on alpha.16 code (CI 39799994225, 96 sessions over twilio,
|
|
69
|
+
semiotic, Ultra-Card and SwitchHosts): 0 critical findings; 29 skipped
|
|
70
|
+
typechecks, each confirmed by a plain rerun.
|
|
3
71
|
## 0.1.0-alpha.15 — 2026-10-22
|
|
4
72
|
|
|
5
73
|
No change to observation, status or `redue run` since alpha.14; the changes
|
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.17**. 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
|
|
package/bin/redue.mjs
CHANGED
|
@@ -4,7 +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,
|
|
7
|
+
import {testEnvironment,testRunnerAt,testsInterpretation,testsQualification} from '../src/test-direct.mjs';
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import {createHash} from 'node:crypto';
|
|
@@ -194,7 +194,7 @@ function which(name){
|
|
|
194
194
|
if(found)return found;
|
|
195
195
|
error(`cannot resolve executable ${name} on PATH`);
|
|
196
196
|
}
|
|
197
|
-
function token(value){
|
|
197
|
+
function token(value,cwd='.'){
|
|
198
198
|
if(value==='@node')return process.execPath;
|
|
199
199
|
if(value==='@project')return root;
|
|
200
200
|
if(value==='@typescript-compiler:.'){
|
|
@@ -202,8 +202,8 @@ function token(value){
|
|
|
202
202
|
}
|
|
203
203
|
if(value.startsWith('@test-runner:')){
|
|
204
204
|
// Qualification reports a missing runner; status and stop still work.
|
|
205
|
-
try{return
|
|
206
|
-
catch{return path.join(root,'node_modules',value.slice('@test-runner:'.length),'<unavailable>');}
|
|
205
|
+
try{return testRunnerAt(root,cwd,value.slice('@test-runner:'.length)).entry;}
|
|
206
|
+
catch{return path.join(root,cwd,'node_modules',value.slice('@test-runner:'.length),'<unavailable>');}
|
|
207
207
|
}
|
|
208
208
|
if(value==='@vstate/direct-typescript-probe')return path.join(productRoot,'src','direct-typescript-probe.mjs');
|
|
209
209
|
if(value.startsWith('@typescript-bin:')){
|
|
@@ -226,10 +226,10 @@ function token(value){
|
|
|
226
226
|
if(value.startsWith('@which:'))return which(value.slice(7));
|
|
227
227
|
return value;
|
|
228
228
|
}
|
|
229
|
-
function argv(values,label){
|
|
229
|
+
function argv(values,label,cwd){
|
|
230
230
|
if(!Array.isArray(values)||!values.length||values.some(v=>typeof v!=='string'||!v))
|
|
231
231
|
error(`${label} must be nonempty exact argv`);
|
|
232
|
-
return values.map(token);
|
|
232
|
+
return values.map(value=>token(value,cwd));
|
|
233
233
|
}
|
|
234
234
|
const categories=['source','generated','installedDependencies','environment','toolchain','runtime',
|
|
235
235
|
'probeContinuity'];
|
|
@@ -260,7 +260,7 @@ const checks=publicConfig.checks.map(check=>{
|
|
|
260
260
|
JSON.stringify(check.command)!==JSON.stringify(['@node','@typescript-compiler:.','--noEmit'])))
|
|
261
261
|
error(`${check.name}: direct recipe requires its explicit local compiler command`);
|
|
262
262
|
// The script's own arguments follow the runner; qualification compares them with the script.
|
|
263
|
-
if(testsDirect&&(
|
|
263
|
+
if(testsDirect&&(!['npm','pnpm','yarn'].includes(manager)||!check.script||check.cwd&&check.cwd!=='.'&&!check.workspace||!Array.isArray(check.command)||
|
|
264
264
|
check.command[0]!=='@node'||!/^@test-runner:(?:vitest|jest)$/.test(check.command[1]||'')))
|
|
265
265
|
error(`${check.name}: test recipe requires its explicit local test runner command`);
|
|
266
266
|
if(npmAutomatic&&(manager!=='npm'||check.cwd&&check.cwd!=='.'))
|
|
@@ -297,7 +297,8 @@ const checks=publicConfig.checks.map(check=>{
|
|
|
297
297
|
{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME','NODE_ENV','BASH_ENV','ENV',...windowsContext],
|
|
298
298
|
prefixes:['PNPM_','COREPACK_','npm_config_','DYLD_','TSGO_'],
|
|
299
299
|
pathExecutables:['node'],executableIdentity:true}:{};
|
|
300
|
-
return {name:check.name,command:argv(scriptCommand||check.command,`${check.name}.command
|
|
300
|
+
return {name:check.name,command:argv(scriptCommand||check.command,`${check.name}.command`,
|
|
301
|
+
typeof check.cwd==='string'&&check.cwd&&!path.isAbsolute(check.cwd)&&!check.cwd.split('/').includes('..')?check.cwd:'.'),
|
|
301
302
|
cwd:check.cwd,inputs:check.inputs||[],generatedInputs:check.generatedInputs||[],
|
|
302
303
|
installedInputs:check.installedInputs||(
|
|
303
304
|
npmAutomatic||npmDirect||testsDirect?['node_modules/**']:[]),
|
|
@@ -323,7 +324,13 @@ if(maintenance&&!fs.existsSync(runtimeFile)){
|
|
|
323
324
|
process.exit(0);
|
|
324
325
|
}
|
|
325
326
|
if(!maintenance)fs.mkdirSync(state,{recursive:true,mode:0o700});
|
|
326
|
-
|
|
327
|
+
// Two commands can meet on fresh state (SessionStart's start and the first
|
|
328
|
+
// status): whichever creates the runtime file first wins, and the other
|
|
329
|
+
// treats it as existing.
|
|
330
|
+
let created=false;
|
|
331
|
+
if(!maintenance&&!fs.existsSync(runtimeFile))
|
|
332
|
+
try{fs.writeFileSync(runtimeFile,serialized,{flag:'wx',mode:0o600});created=true;}catch(e){if(e.code!=='EEXIST')throw e;}
|
|
333
|
+
if(!maintenance&&!created){
|
|
327
334
|
const previous=fs.readFileSync(runtimeFile,'utf8');
|
|
328
335
|
if(previous!==serialized){
|
|
329
336
|
const pidFile=path.join(state,'observer.lock','pid');
|
|
@@ -333,7 +340,7 @@ if(!maintenance&&fs.existsSync(runtimeFile)){
|
|
|
333
340
|
}
|
|
334
341
|
fs.writeFileSync(runtimeFile,serialized,{mode:0o600});
|
|
335
342
|
}
|
|
336
|
-
}
|
|
343
|
+
}
|
|
337
344
|
|
|
338
345
|
if(command==='status'&&!sync){
|
|
339
346
|
const value=readCachedStatus(state,publicConfig.checks);
|
package/docs/user/agents.md
CHANGED
|
@@ -69,25 +69,58 @@ Start a fresh agent session in this checkout, then give it your ordinary task.
|
|
|
69
69
|
Interactive setup shows the changes and asks before applying; noninteractive
|
|
70
70
|
setup requires `--apply`. `--dry-run` never writes. Installing REDUE or running
|
|
71
71
|
`init` does not connect an agent automatically. Setup does not start an observer
|
|
72
|
-
or execute verification.
|
|
72
|
+
or execute verification. With Claude Code, a SessionStart hook starts the
|
|
73
|
+
observer when a session starts (below); otherwise use `redue start`.
|
|
73
74
|
|
|
74
75
|
## Claude Code typecheck hook
|
|
75
76
|
|
|
76
|
-
`redue agent setup claude --apply` also adds a PreToolUse hook
|
|
77
|
-
`.claude/settings.local.json`.
|
|
77
|
+
`redue agent setup claude --apply` also adds a PreToolUse hook and a
|
|
78
|
+
SessionStart hook to `.claude/settings.local.json`. Both run
|
|
79
|
+
`redue agent hook claude`, which needs a local REDUE install, so they are
|
|
78
80
|
per-developer; keep that file out of commits. Other settings in the file are kept,
|
|
79
81
|
but the file is rewritten as 2-space JSON. `.claude/settings.json` is not touched.
|
|
80
|
-
`redue agent remove claude --apply` removes only the
|
|
82
|
+
`redue agent remove claude --apply` removes only the entries REDUE added.
|
|
81
83
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
When a session starts, resumes, clears or compacts, the SessionStart hook starts
|
|
85
|
+
this project's observer if none is running. It launches `redue start` in the
|
|
86
|
+
background and returns at once, and prints nothing, so it adds nothing to the
|
|
87
|
+
agent's context. Until the observer's first index is done, checks are
|
|
88
|
+
UNVERIFIED and run as typed. An observer you stopped with `redue stop` starts
|
|
89
|
+
again with the next session; remove the hook to prevent that. Setups from
|
|
90
|
+
alpha.15 or earlier get the hook by running `redue agent setup claude --apply`
|
|
91
|
+
again.
|
|
92
|
+
|
|
93
|
+
The hook skips an exact rerun of a configured typecheck (a check with
|
|
94
|
+
`kind: "typecheck"` or named `typecheck`) or direct test check. It matches
|
|
95
|
+
these commands, run from the project root:
|
|
85
96
|
|
|
86
97
|
- `redue run CHECK`, or `redue --config FILE run CHECK` when the config is not
|
|
87
98
|
`redue.config.json`
|
|
88
|
-
-
|
|
89
|
-
|
|
99
|
+
- the check's script through the project's package manager: `npm run SCRIPT`
|
|
100
|
+
(and `npm test`), `pnpm SCRIPT`, `pnpm run SCRIPT`, `yarn SCRIPT` or
|
|
101
|
+
`yarn run SCRIPT`. The short form is left out when the script is named like
|
|
102
|
+
one of the package manager's own commands.
|
|
103
|
+
- the tool the check runs: `npx tsc ARGS` (and `pnpm exec tsc ARGS` with pnpm)
|
|
104
|
+
for a direct TypeScript check, or the runner's command, alone or after `npx`,
|
|
105
|
+
for a direct test check
|
|
90
106
|
- the check's `command`, when it has no `@` tokens
|
|
107
|
+
- for a workspace package's check: `pnpm --filter NAME SCRIPT` (also
|
|
108
|
+
`--filter=NAME`, `-F NAME`, and with `run`), `yarn workspace NAME SCRIPT`, or
|
|
109
|
+
`npm run SCRIPT -w NAME` (also `--workspace NAME`, `--workspace=NAME` and
|
|
110
|
+
`npm -w NAME run SCRIPT`). After `cd` into the package's directory, the
|
|
111
|
+
package-manager script forms and tool forms match that check.
|
|
112
|
+
|
|
113
|
+
A `turbo run TASK` (or `turbo TASK`, alone or after `npx` or `pnpm exec`) or,
|
|
114
|
+
in a pnpm project, `pnpm -r TASK` (`--recursive`, with or without `run`) that
|
|
115
|
+
runs a checked task in every workspace package is skipped only when every check
|
|
116
|
+
it would run is CURRENT, PASS and `reuse_eligible`. Every package with the
|
|
117
|
+
task's script must have a check. turbo must define the task, with `dependsOn`
|
|
118
|
+
listing at most `^TASK`, no `with`, no `PACKAGE#TASK` entries and no package
|
|
119
|
+
`turbo.json`; `pnpm -r` must not include the workspace root. A root script
|
|
120
|
+
whose text is such a command counts as that command, unless it has pre- or
|
|
121
|
+
post-scripts. In every other case the command runs as typed: turbo caches its
|
|
122
|
+
own results, so the hook never rewrites it. Filters (`--filter`) and nx are not
|
|
123
|
+
matched.
|
|
91
124
|
|
|
92
125
|
Each form may end with `2>&1` and then one `| tail -n N`, `| head -n N`,
|
|
93
126
|
`| tail -N` or `| head -N`. It may also be the first command of a `;` or `&&`
|
|
@@ -121,8 +154,10 @@ Limits:
|
|
|
121
154
|
|
|
122
155
|
- The hook judges its own environment, which Claude Code passes to it. The Bash
|
|
123
156
|
tool can add variables from your shell profile. The hook cannot see those.
|
|
124
|
-
- Commands run from a subdirectory, after
|
|
125
|
-
combined with `||`, `&` or other pipes, are not
|
|
157
|
+
- Commands run from a session that started in a subdirectory, after a `cd`
|
|
158
|
+
outside the project, or combined with `||`, `&` or other pipes, are not
|
|
159
|
+
matched. After a `cd` into any other directory than the root or a checked
|
|
160
|
+
package's, nothing matches.
|
|
126
161
|
- A skip writes nothing. If a typecheck command also emits files, later
|
|
127
162
|
commands see the files from its last run.
|
|
128
163
|
- Only the Bash tool is covered, not PowerShell.
|
package/docs/user/pilot.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Opt-in development pilot: alpha.
|
|
1
|
+
# Opt-in development pilot: alpha.17
|
|
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.17
|
|
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.17
|
|
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
|
|
@@ -45,13 +45,26 @@ project whose test script runs Vitest 4/5 or Jest 29 directly, the opt-in
|
|
|
45
45
|
In particular, test caches and build-generated inputs are not excluded to
|
|
46
46
|
make evidence green.
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
the lockfile, and the installed `node_modules/.modules.yaml`
|
|
48
|
+
pnpm 9-12 projects are read from `packageManager`, `pnpm-workspace.yaml`,
|
|
49
|
+
`.npmrc`, the lockfile, and the installed `node_modules/.modules.yaml`
|
|
50
|
+
metadata. The installed metadata names the pnpm version; a `packageManager`
|
|
51
|
+
pin is optional, and when present (with or without Corepack's `+sha512` hash)
|
|
52
|
+
it must name the installed version. Settings that only change what gets
|
|
53
|
+
installed are accepted, because the installed files are what REDUE observes.
|
|
54
|
+
Settings that move or copy installed files, or run code during install, are
|
|
55
|
+
not: custom `modulesDir`, `virtualStoreDir` or `lockfileDir`, the global
|
|
56
|
+
virtual store, injected workspace packages, separate workspace lockfiles,
|
|
57
|
+
pnpmfile hooks and `configDependencies`. They are refused when either
|
|
58
|
+
`pnpm-workspace.yaml` or `.npmrc` sets them. The linker is read from the file
|
|
59
|
+
the installed major reads (`.npmrc` for pnpm 9, `pnpm-workspace.yaml` then
|
|
60
|
+
`.npmrc` for pnpm 10, `pnpm-workspace.yaml` for pnpm 11 and 12) and must match
|
|
61
|
+
the installed one. Only the project's files are read: settings from a user or
|
|
62
|
+
global rc file, or `npm_config_*` variables during install, are not. The
|
|
50
63
|
supported local layouts are pnpm's standard isolated and hoisted
|
|
51
64
|
`node_modules` with a project-local `.pnpm` virtual store. `init` can discover
|
|
52
65
|
root and workspace scripts. For a standalone `tsc --noEmit` or `tsc -p ...`
|
|
53
66
|
script in the narrow TypeScript contract, it records the exact installed
|
|
54
|
-
compiler invocation directly. This avoids pnpm
|
|
67
|
+
compiler invocation directly. This avoids pnpm's task-run bookkeeping
|
|
55
68
|
writes during `pnpm run`; it does not alter the project's script. Test, lint,
|
|
56
69
|
build, and ambiguous TypeScript scripts still execute through `pnpm run` and
|
|
57
70
|
start recording-only. The generated `@typescript-bin:WORKSPACE` token resolves
|
|
@@ -2,8 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
`redue init --recipe tests-direct --check test` records a project's unit test
|
|
4
4
|
run and reports later whether that result still applies to the files you have.
|
|
5
|
-
It supports a single-package
|
|
6
|
-
|
|
5
|
+
It supports a single-package project installed by npm, pnpm 9-12 (isolated or
|
|
6
|
+
hoisted `node_modules`) or Yarn with the `node-modules` linker, whose test
|
|
7
|
+
script runs Vitest 4 or 5, or Jest 29, directly. It runs on macOS and Linux with
|
|
8
|
+
Node 22 or 24. The project has one lockfile kind, matching any
|
|
9
|
+
`packageManager` declaration; Yarn needs that declaration. With pnpm, the
|
|
10
|
+
runner is the package `node_modules/RUNNER` links to, inside the checkout.
|
|
11
|
+
Jest also needs jest-util 29 at `node_modules/jest-util`, which pnpm does not
|
|
12
|
+
place there unless it hoists.
|
|
13
|
+
|
|
14
|
+
In a workspace (npm or Yarn `workspaces`, or `packages` in
|
|
15
|
+
`pnpm-workspace.yaml`), `init` gives each package whose test script runs
|
|
16
|
+
Vitest directly its own check, `PACKAGE:test`, run in the package's directory
|
|
17
|
+
with the Vitest that Node finds from there. Its inputs are still the whole
|
|
18
|
+
checkout, so an edit in any package makes it STALE. The root's and every
|
|
19
|
+
package's `node_modules` are observed as installed files, without Vite's
|
|
20
|
+
caches in them. Jest is not supported in workspace packages, and neither is a
|
|
21
|
+
test script at the workspace root. A package added later needs `init` again:
|
|
22
|
+
its manifest appearing makes the workspace's checks UNVERIFIED until then.
|
|
7
23
|
|
|
8
24
|
## Setup
|
|
9
25
|
|
|
@@ -21,8 +37,8 @@ runner invocation: no `&&`, quotes, globs or variable assignments. Watch mode,
|
|
|
21
37
|
Jest's `--onlyChanged` are refused. Vitest needs `run`, `--run` or
|
|
22
38
|
`--watch=false`. Other scripts stay recording-only.
|
|
23
39
|
|
|
24
|
-
REDUE runs the local runner with Node directly.
|
|
25
|
-
`package.json` are not changed or executed.
|
|
40
|
+
REDUE runs the local runner with Node directly. The package manager, its
|
|
41
|
+
lifecycle scripts and `package.json` are not changed or executed.
|
|
26
42
|
|
|
27
43
|
## What REDUE claims
|
|
28
44
|
|
package/package.json
CHANGED
|
@@ -11,7 +11,7 @@ import {readProject,projectState,canonicalConfig} from './project-location.mjs';
|
|
|
11
11
|
import {readCachedStatus} from './cached-state.mjs';
|
|
12
12
|
import {samePath,withinPath} from './path-identity.mjs';
|
|
13
13
|
import {processAlive} from './process-liveness.mjs';
|
|
14
|
-
import {hookCommand,hookForms,redueFor} from './claude-hook.mjs';
|
|
14
|
+
import {fanoutRules,hookCommand,hookForms,redueFor} from './claude-hook.mjs';
|
|
15
15
|
|
|
16
16
|
const policy=fs.readFileSync(new URL('./agent-policy.md',import.meta.url),'utf8');
|
|
17
17
|
const hosts={codex:{instruction:'AGENTS.md',skill:'.agents/skills/redue-verification/SKILL.md'},
|
|
@@ -23,10 +23,16 @@ const settingsPath='.claude/settings.local.json';
|
|
|
23
23
|
const sameJson=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
|
|
24
24
|
// One handler per exact command form; `if` keeps other Bash calls from starting Node.
|
|
25
25
|
// The hook waits for status up to 10 s; 15 s leaves room for starting Node.
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// Fan-out commands (turbo, nx, `pnpm -r`) and root scripts that run one get
|
|
27
|
+
// prefix rules; the hook decides from the full command.
|
|
28
|
+
function hookFor(config,relative,scripts){
|
|
29
|
+
const rules=[...new Set([...hookForms(config,relative).flatMap(row=>[...row.forms,...row.local]),
|
|
30
|
+
...fanoutRules(config,scripts)])].map(form=>`Bash(${form}*)`);
|
|
28
31
|
return rules.length?{matcher:'Bash',hooks:rules.map(rule=>({type:'command',if:rule,command:hookCommand,timeout:15}))}:null;
|
|
29
32
|
}
|
|
33
|
+
// The same command on SessionStart starts the observer; it needs no check to match.
|
|
34
|
+
const sessionHook={hooks:[{type:'command',command:hookCommand,timeout:10}]};
|
|
35
|
+
const events=['PreToolUse','SessionStart'];
|
|
30
36
|
// REDUE's entry is recognized by its command, not by a committed record: the
|
|
31
37
|
// local file differs between checkouts, and users may edit the entry.
|
|
32
38
|
const ownedHook=entry=>Array.isArray(entry?.hooks)&&entry.hooks.length>0&&entry.hooks.every(h=>h?.command===hookCommand);
|
|
@@ -35,23 +41,28 @@ function parseSettings(text){
|
|
|
35
41
|
let value;try{value=JSON.parse(text);}catch{throw Error(`${settingsPath} is not valid JSON; fix it before agent setup or removal`);}
|
|
36
42
|
if(!value||typeof value!=='object'||Array.isArray(value)||value.hooks!==undefined&&
|
|
37
43
|
(!value.hooks||typeof value.hooks!=='object'||Array.isArray(value.hooks)||
|
|
38
|
-
value.hooks
|
|
44
|
+
events.some(event=>value.hooks[event]!==undefined&&!Array.isArray(value.hooks[event]))))
|
|
39
45
|
throw Error(`${settingsPath} has an unexpected shape; fix it before agent setup or removal`);
|
|
40
46
|
return value;
|
|
41
47
|
}
|
|
42
|
-
// Replace REDUE's
|
|
43
|
-
//
|
|
48
|
+
// Replace REDUE's entry for each event with next[event] (or remove it; a null
|
|
49
|
+
// `next` removes them all). Unchanged input returns the same text, so
|
|
50
|
+
// idempotent setup does not rewrite the file.
|
|
44
51
|
function settingsWith(text,next){
|
|
45
|
-
const value=parseSettings(text)
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
const value=parseSettings(text);let changed=false;
|
|
53
|
+
for(const event of events){
|
|
54
|
+
const old=value.hooks?.[event]||[],at=old.findIndex(ownedHook),list=old.filter(entry=>!ownedHook(entry));
|
|
55
|
+
if(next?.[event])list.splice(at<0?list.length:at,0,next[event]);
|
|
56
|
+
if(sameJson(list,old))continue;
|
|
57
|
+
changed=true;
|
|
58
|
+
if(list.length)value.hooks={...value.hooks,[event]:list};
|
|
59
|
+
else if(value.hooks){delete value.hooks[event];if(!Object.keys(value.hooks).length)delete value.hooks;}
|
|
60
|
+
}
|
|
61
|
+
if(!changed)return text;
|
|
51
62
|
return Object.keys(value).length?JSON.stringify(value,null,2)+'\n':null;
|
|
52
63
|
}
|
|
53
|
-
function hookInstalled(root){
|
|
54
|
-
try{return (parseSettings(read(root,settingsPath)).hooks?.
|
|
64
|
+
function hookInstalled(root,event='PreToolUse'){
|
|
65
|
+
try{return (parseSettings(read(root,settingsPath)).hooks?.[event]||[]).some(ownedHook);}
|
|
55
66
|
catch{return false;}
|
|
56
67
|
}
|
|
57
68
|
function safeFile(root,relative){
|
|
@@ -121,10 +132,10 @@ function earlierBlock(host,rows=[]){
|
|
|
121
132
|
`If a check's last pass still applies to these exact files, a REDUE hook skips\n`+
|
|
122
133
|
`the run and says so. The skip counts as a pass; don't check it again.\n`);
|
|
123
134
|
}
|
|
124
|
-
// One form per check: the
|
|
125
|
-
// the project's own instructions usually name.
|
|
135
|
+
// One form per check: the package manager's script form when there is one,
|
|
136
|
+
// since that is what the project's own instructions usually name.
|
|
126
137
|
const blockForms=(host,config,relative)=>host!=='claude'?[]:hookForms(config,relative).map(row=>({kind:row.kind,
|
|
127
|
-
form:row.forms.find(form
|
|
138
|
+
form:row.forms.find(form=>/^(?:npm run|pnpm|yarn) /.test(form))??row.forms.find(form=>!form.startsWith('redue '))??row.forms[0]}));
|
|
128
139
|
const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${host}:begin -->\n`+
|
|
129
140
|
`Before deciding whether configured verification needs repeating, use REDUE.\n`+
|
|
130
141
|
(legacy?`Read ${hosts[host].skill} for the decision policy, then query ordinary status\n`+
|
|
@@ -143,7 +154,7 @@ const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${ho
|
|
|
143
154
|
// varies, and only in the safe character set. Earlier owned versions are
|
|
144
155
|
// recognized so setup can replace them; other stored text never is.
|
|
145
156
|
function knownBlock(host,relative,block){
|
|
146
|
-
const list='((?:`[A-Za-z0-9_
|
|
157
|
+
const list='((?:`[A-Za-z0-9_./:=,+@ -]+`(?: and )?)+)';
|
|
147
158
|
const forms=pattern=>{const named=new RegExp(pattern).exec(block);return named?[...named[1].matchAll(/`([^`]+)`/g)].map(m=>m[1]):[];};
|
|
148
159
|
const rows=[...forms(`^\\n\\n<!-- REDUE:[a-z]+:begin -->\\nTypecheck with ${list}\\.`).map(form=>({kind:'typecheck',form})),
|
|
149
160
|
...forms(`Run the unit tests with ${list}\\.\\n`).map(form=>({kind:'test',form}))];
|
|
@@ -245,7 +256,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
245
256
|
if(value.state_access)console.log(`State access: ${value.state_access.status}${value.state_access.reason?' — '+value.state_access.reason:''}`);
|
|
246
257
|
if(value.compatibility?.reason)console.log(`Observer compatibility: ${value.compatibility.reason}`);
|
|
247
258
|
if(value.applicability?.requires_reassessment)console.log('Applicability: cached evidence needs reassessment; choose synchronization or execution deliberately.');
|
|
248
|
-
for(const row of value.hosts)console.log(`${row.host}: ${row.version||'host unavailable'}; integration ${row.integrity}; hook ${row.hook}; behavioral validation NOT VERIFIED by doctor${row.reason?' — '+row.reason:''}`);
|
|
259
|
+
for(const row of value.hosts)console.log(`${row.host}: ${row.version||'host unavailable'}; integration ${row.integrity}; hook ${row.hook}${row.host==='claude'?`; session start ${row.session_start}`:''}; behavioral validation NOT VERIFIED by doctor${row.reason?' — '+row.reason:''}`);
|
|
249
260
|
console.log('Doctor does not start observers, execute checks, or prove agent behavior.');
|
|
250
261
|
for(const warning of value.warnings)console.log(`Review: ${warning}`);
|
|
251
262
|
}return value;
|
|
@@ -261,14 +272,16 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
261
272
|
if(!record&&(before?.includes('<!-- REDUE:')||read(root,h.skill)!==null))
|
|
262
273
|
throw Error('unowned REDUE instruction or skill already exists; inspect it before setup');
|
|
263
274
|
const after=record?before.replace(record.block,block):(before||'')+block;
|
|
264
|
-
|
|
275
|
+
let scripts={};try{scripts=JSON.parse(read(root,'package.json')??'{}').scripts||{};}catch{}
|
|
276
|
+
hook=host==='claude'?hookFor(config,relative,scripts):null;
|
|
265
277
|
const next={schema:1,host,config:relative,instruction:h.instruction,skill:h.skill,
|
|
266
278
|
block,skillHash:hash(skill),instructionCreated:record?.instructionCreated??before===null};
|
|
267
279
|
changes.push({path:h.skill,before:read(root,h.skill),after:skill},
|
|
268
280
|
{path:h.instruction,before,after});
|
|
269
|
-
if(host==='claude'){const settings=read(root,settingsPath);
|
|
270
|
-
changes.push({path:settingsPath,before:settings,after:settingsWith(settings,
|
|
271
|
-
content:
|
|
281
|
+
if(host==='claude'){const settings=read(root,settingsPath),next={PreToolUse:hook,SessionStart:sessionHook};
|
|
282
|
+
changes.push({path:settingsPath,before:settings,after:settingsWith(settings,next),
|
|
283
|
+
content:JSON.stringify(hook?next:{SessionStart:sessionHook},null,2)+
|
|
284
|
+
(hook?'':'\nno PreToolUse hook: no typecheck or test check to match')});}
|
|
272
285
|
changes.push({path:marker(host),before:read(root,marker(host)),after:JSON.stringify(next,null,2)+'\n'});
|
|
273
286
|
}else if(record){
|
|
274
287
|
const before=read(root,h.instruction),after=before.replace(record.block,'');
|
|
@@ -282,7 +295,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
282
295
|
const summary={schema:1,action,host,config:configFile,applied:false,
|
|
283
296
|
changes:actual.map(c=>({path:c.path,operation:c.after===null?'remove':c.before===null?'create':'update',
|
|
284
297
|
...(action==='setup'?{content:c.path===h.instruction?block:c.content??c.after}: {})})),
|
|
285
|
-
warnings:notes,hooks:
|
|
298
|
+
warnings:notes,hooks:host!=='claude'||action!=='setup'?'none':`${settingsPath} ${hook?'PreToolUse, ':''}SessionStart`,behavior_verified:false};
|
|
286
299
|
if(!json){console.log(`${action} ${host}: ${configFile}`);
|
|
287
300
|
for(const c of summary.changes)console.log(` ${c.operation} ${c.path}${c.content?'\n'+c.content:''}`);
|
|
288
301
|
if(!actual.length)console.log('Already in the requested state.');
|
|
@@ -302,7 +315,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
302
315
|
export async function doctor({configFile,stateOverride,entry,cwd,host}){
|
|
303
316
|
const value={schema:1,command:{path:findExecutable('redue'),invoked:entry},project:{config:configFile},
|
|
304
317
|
observation:null,control:null,state_access:null,observer_process:null,compatibility:null,
|
|
305
|
-
applicability:null,hosts:[],warnings:[],hooks:`claude only: PreToolUse
|
|
318
|
+
applicability:null,hosts:[],warnings:[],hooks:`claude only: PreToolUse check hook and SessionStart observer start in ${settingsPath}`,behavior_verified:false};
|
|
306
319
|
let p;try{p=project(configFile);value.project.root=p.root;
|
|
307
320
|
const state=stateOverride||projectState(p.root,configFile);value.project.state=state;
|
|
308
321
|
const cached=readCachedStatus(state,p.config.checks);value.observation=cached.observation;
|
|
@@ -333,12 +346,14 @@ export async function doctor({configFile,stateOverride,entry,cwd,host}){
|
|
|
333
346
|
if(executable){const launch=windowsLaunch([executable,'--version']);
|
|
334
347
|
const out=spawnSync(launch.file,launch.args,{...launch.options,encoding:'utf8',timeout:2000,maxBuffer:4096,stdio:['ignore','pipe','pipe']});
|
|
335
348
|
if(out.status===0)version=out.stdout.trim();}
|
|
336
|
-
let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none';
|
|
349
|
+
let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none',sessionHook=hook;
|
|
350
|
+
const installed=()=>value.command.path?'installed':'installed, but redue is not on PATH, so it never runs';
|
|
337
351
|
if(p)try{const record=saved(p.root,name);integration=inspect(p.root,name,record);
|
|
338
|
-
if(name==='claude'&&hookInstalled(p.root))hook=
|
|
352
|
+
if(name==='claude'&&hookInstalled(p.root))hook=installed();
|
|
353
|
+
if(name==='claude'&&hookInstalled(p.root,'SessionStart'))sessionHook=installed();
|
|
339
354
|
value.warnings.push(...warnings(p.root,name,cwd));
|
|
340
355
|
}catch(e){integration={installed:false,integrity:'conflict',reason:e.message};}
|
|
341
|
-
value.hosts.push({host:name,executable,version,...integration,hook,behavior_verified:false,
|
|
356
|
+
value.hosts.push({host:name,executable,version,...integration,hook,session_start:sessionHook,behavior_verified:false,
|
|
342
357
|
loading:name==='codex'?'AGENTS.md at session start; .agents/skills; overrides and size limits apply':
|
|
343
358
|
'CLAUDE.md at session start; .claude/skills; managed/user policies and exclusions apply'});
|
|
344
359
|
}
|