@redue/cli 0.1.0-alpha.14 → 0.1.0-alpha.16
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 +46 -0
- package/README.md +4 -2
- package/bin/redue.mjs +8 -2
- package/docs/user/agents.md +46 -11
- package/docs/user/pilot.md +3 -3
- package/package.json +1 -1
- package/src/agent-integration.mjs +72 -41
- package/src/claude-hook.mjs +236 -30
- package/src/node-onboarding.mjs +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.16 — 2026-10-22
|
|
4
|
+
|
|
5
|
+
Changes to the Claude Code integration, and one fix for commands that meet
|
|
6
|
+
on fresh state. Run `redue agent setup claude --apply` again to get the new
|
|
7
|
+
hook entries.
|
|
8
|
+
|
|
9
|
+
- A SessionStart hook starts the project's observer when a session starts,
|
|
10
|
+
resumes, clears or compacts, if none is running. It returns at once and
|
|
11
|
+
prints nothing. Without a running observer every check was UNVERIFIED, so
|
|
12
|
+
the hook could neither skip nor record. `agent doctor` reports it.
|
|
13
|
+
- The hook matches more command forms, each mapping to one configured check:
|
|
14
|
+
`pnpm SCRIPT`, `pnpm run SCRIPT`, `yarn SCRIPT`, `yarn run SCRIPT`,
|
|
15
|
+
`pnpm exec tsc ...`; for a workspace package's check, `pnpm --filter NAME
|
|
16
|
+
SCRIPT`, `yarn workspace NAME SCRIPT`, `npm run SCRIPT -w NAME`, and its
|
|
17
|
+
script after a `cd` into the package. In the census of 108 public
|
|
18
|
+
TypeScript repos, 61 use pnpm or Yarn.
|
|
19
|
+
- `turbo run TASK` and, in pnpm projects, `pnpm -r TASK` (or a root script
|
|
20
|
+
that is one of them) are skipped only when every workspace package with the
|
|
21
|
+
task's script has a check and all are CURRENT; otherwise they run as typed,
|
|
22
|
+
never rewritten. No skip when turbo may run more (other `dependsOn` tasks,
|
|
23
|
+
`with`, package-scoped keys, a package turbo.json), when pnpm includes the
|
|
24
|
+
workspace root, or for a root script with pre/post scripts. Filters and nx
|
|
25
|
+
are not matched.
|
|
26
|
+
- Fixed: when two commands met on fresh state (SessionStart's `start` and the
|
|
27
|
+
first `status`), the second failed and no observer started.
|
|
28
|
+
|
|
29
|
+
Pilot eval on alpha.16 code (CI 39799994225, 96 sessions over twilio,
|
|
30
|
+
semiotic, Ultra-Card and SwitchHosts): 0 critical findings; 29 skipped
|
|
31
|
+
typechecks, each confirmed by a plain rerun.
|
|
32
|
+
## 0.1.0-alpha.15 — 2026-10-22
|
|
33
|
+
|
|
34
|
+
No change to observation, status or `redue run` since alpha.14; the changes
|
|
35
|
+
are to the Claude Code integration.
|
|
36
|
+
|
|
37
|
+
- The CLAUDE.md block now tells agents to run the typecheck or test command
|
|
38
|
+
before saying it passes, even after an edit that seems irrelevant, and to
|
|
39
|
+
leave skipping to REDUE's hook. In the alpha.14 eval, two of six mixed-task
|
|
40
|
+
sessions skipped their final test run on their own after a README edit;
|
|
41
|
+
with the new block, none of six did (twilio and SwitchHosts, CI
|
|
42
|
+
39702429765), at the same token cost. Run `redue agent setup claude --apply`
|
|
43
|
+
again to update the block; the earlier wording is recognized and replaced.
|
|
44
|
+
- The hook also matches a typecheck or test command after a leading
|
|
45
|
+
`cd <project root>;` or `cd <project root> &&`. A skip denies the whole
|
|
46
|
+
command; a rewrite keeps the `cd`. A `cd` to any other directory matches
|
|
47
|
+
nothing.
|
|
48
|
+
|
|
3
49
|
## 0.1.0-alpha.14 — 2026-10-21
|
|
4
50
|
|
|
5
51
|
Bench numbers below are from one 800-file project on macOS arm64, Node 22.13.0.
|
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ it; relevant source edits make it stale. [Run the reproducible demo](docs/contri
|
|
|
37
37
|
|
|
38
38
|
## Install the alpha
|
|
39
39
|
|
|
40
|
-
The opt-in pilot uses **0.1.0-alpha.
|
|
40
|
+
The opt-in pilot uses **0.1.0-alpha.16**. 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
|
|
|
@@ -156,7 +156,9 @@ Medians per task, summed over the three projects:
|
|
|
156
156
|
source-change task skipped nothing; its time difference is session variance.
|
|
157
157
|
In two mixed-task sessions the agent left out its final test run on its own
|
|
158
158
|
after a README-only edit, without asking REDUE, and said so; the eval report
|
|
159
|
-
lists such sessions.
|
|
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).
|
|
160
162
|
|
|
161
163
|
## What the states mean
|
|
162
164
|
|
package/bin/redue.mjs
CHANGED
|
@@ -323,7 +323,13 @@ if(maintenance&&!fs.existsSync(runtimeFile)){
|
|
|
323
323
|
process.exit(0);
|
|
324
324
|
}
|
|
325
325
|
if(!maintenance)fs.mkdirSync(state,{recursive:true,mode:0o700});
|
|
326
|
-
|
|
326
|
+
// Two commands can meet on fresh state (SessionStart's start and the first
|
|
327
|
+
// status): whichever creates the runtime file first wins, and the other
|
|
328
|
+
// treats it as existing.
|
|
329
|
+
let created=false;
|
|
330
|
+
if(!maintenance&&!fs.existsSync(runtimeFile))
|
|
331
|
+
try{fs.writeFileSync(runtimeFile,serialized,{flag:'wx',mode:0o600});created=true;}catch(e){if(e.code!=='EEXIST')throw e;}
|
|
332
|
+
if(!maintenance&&!created){
|
|
327
333
|
const previous=fs.readFileSync(runtimeFile,'utf8');
|
|
328
334
|
if(previous!==serialized){
|
|
329
335
|
const pidFile=path.join(state,'observer.lock','pid');
|
|
@@ -333,7 +339,7 @@ if(!maintenance&&fs.existsSync(runtimeFile)){
|
|
|
333
339
|
}
|
|
334
340
|
fs.writeFileSync(runtimeFile,serialized,{mode:0o600});
|
|
335
341
|
}
|
|
336
|
-
}
|
|
342
|
+
}
|
|
337
343
|
|
|
338
344
|
if(command==='status'&&!sync){
|
|
339
345
|
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.16
|
|
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.16
|
|
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.16
|
|
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
|
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){
|
|
@@ -88,27 +99,43 @@ function saved(root,host){const text=read(root,marker(host));if(text===null)retu
|
|
|
88
99
|
return value;
|
|
89
100
|
}
|
|
90
101
|
// The managed block is always in the agent's context, so it stays short. For
|
|
91
|
-
// Claude Code it names the typecheck and test commands the hook matches
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
102
|
+
// Claude Code it names the typecheck and test commands the hook matches, asks
|
|
103
|
+
// the agent to run them rather than judge for itself that a rerun is not
|
|
104
|
+
// needed, and says what a skip means. The hook does the rest, so the block
|
|
105
|
+
// does not point to the skill; the skill stays for agents that look for it.
|
|
95
106
|
const quoted=forms=>forms.map(form=>`\`${form}\``).join(' and ');
|
|
107
|
+
const commandsOf=rows=>[rows.filter(row=>row.kind!=='test').map(row=>row.form),rows.filter(row=>row.kind==='test').map(row=>row.form)];
|
|
108
|
+
const fence=(host,body)=>`\n\n<!-- REDUE:${host}:begin -->\n${body}<!-- REDUE:${host}:end -->\n`;
|
|
109
|
+
const skillPointer=host=>`Before you run, skip or report a typecheck or other configured check, read\n${hosts[host].skill}.\n`;
|
|
96
110
|
function blockFor(host,rows=[]){
|
|
97
|
-
const typechecks
|
|
98
|
-
return
|
|
99
|
-
(!
|
|
100
|
-
|
|
111
|
+
const [typechecks,tests]=commandsOf(rows);
|
|
112
|
+
if(!rows.length)return fence(host,skillPointer(host));
|
|
113
|
+
if(!tests.length)return fence(host,`Typecheck with ${quoted(typechecks)}. Run it before you say it passes, even after an\n`+
|
|
114
|
+
`edit you think cannot matter; don't skip it yourself. If its last pass still\n`+
|
|
115
|
+
`applies to these exact files, a REDUE hook skips the run and says so. That\n`+
|
|
116
|
+
`skip counts as a pass.\n`);
|
|
117
|
+
return fence(host,(typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
|
|
118
|
+
`Run the unit tests with ${quoted(tests)}.\n`+
|
|
119
|
+
`Run a check before you say it passes, even after an edit you think cannot\n`+
|
|
120
|
+
`matter; don't skip it yourself. If its last pass still applies to these exact\n`+
|
|
121
|
+
`files, a REDUE hook skips the run and says so. That skip counts as a pass.\n`);
|
|
122
|
+
}
|
|
123
|
+
// The alpha.12-alpha.14 wording, recognized so setup can replace it.
|
|
124
|
+
function earlierBlock(host,rows=[]){
|
|
125
|
+
const [typechecks,tests]=commandsOf(rows);
|
|
126
|
+
if(!rows.length)return fence(host,skillPointer(host));
|
|
127
|
+
if(!tests.length)return fence(host,`Typecheck with ${quoted(typechecks)}. If its last pass still applies to\n`+
|
|
101
128
|
`these exact files, a REDUE hook skips the run and says so. The skip counts as\n`+
|
|
102
|
-
`a pass; don't check it again.\n
|
|
103
|
-
|
|
129
|
+
`a pass; don't check it again.\n`);
|
|
130
|
+
return fence(host,(typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
|
|
104
131
|
`Run the unit tests with ${quoted(tests)}.\n`+
|
|
105
132
|
`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`)
|
|
107
|
-
|
|
108
|
-
// One form per check: the
|
|
109
|
-
// the project's own instructions usually name.
|
|
133
|
+
`the run and says so. The skip counts as a pass; don't check it again.\n`);
|
|
134
|
+
}
|
|
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.
|
|
110
137
|
const blockForms=(host,config,relative)=>host!=='claude'?[]:hookForms(config,relative).map(row=>({kind:row.kind,
|
|
111
|
-
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]}));
|
|
112
139
|
const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${host}:begin -->\n`+
|
|
113
140
|
`Before deciding whether configured verification needs repeating, use REDUE.\n`+
|
|
114
141
|
(legacy?`Read ${hosts[host].skill} for the decision policy, then query ordinary status\n`+
|
|
@@ -127,11 +154,11 @@ const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${ho
|
|
|
127
154
|
// varies, and only in the safe character set. Earlier owned versions are
|
|
128
155
|
// recognized so setup can replace them; other stored text never is.
|
|
129
156
|
function knownBlock(host,relative,block){
|
|
130
|
-
const list='((?:`[A-Za-z0-9_
|
|
157
|
+
const list='((?:`[A-Za-z0-9_./:=,+@ -]+`(?: and )?)+)';
|
|
131
158
|
const forms=pattern=>{const named=new RegExp(pattern).exec(block);return named?[...named[1].matchAll(/`([^`]+)`/g)].map(m=>m[1]):[];};
|
|
132
159
|
const rows=[...forms(`^\\n\\n<!-- REDUE:[a-z]+:begin -->\\nTypecheck with ${list}\\.`).map(form=>({kind:'typecheck',form})),
|
|
133
160
|
...forms(`Run the unit tests with ${list}\\.\\n`).map(form=>({kind:'test',form}))];
|
|
134
|
-
return block===blockFor(host,rows)||priorBlocks(host,relative).includes(block);
|
|
161
|
+
return block===blockFor(host,rows)||block===earlierBlock(host,rows)||priorBlocks(host,relative).includes(block);
|
|
135
162
|
}
|
|
136
163
|
// The policy with the exact commands for this config, so an agent never builds
|
|
137
164
|
// a --config form the hook would not recognize. Claude Code gets the typecheck
|
|
@@ -229,7 +256,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
229
256
|
if(value.state_access)console.log(`State access: ${value.state_access.status}${value.state_access.reason?' — '+value.state_access.reason:''}`);
|
|
230
257
|
if(value.compatibility?.reason)console.log(`Observer compatibility: ${value.compatibility.reason}`);
|
|
231
258
|
if(value.applicability?.requires_reassessment)console.log('Applicability: cached evidence needs reassessment; choose synchronization or execution deliberately.');
|
|
232
|
-
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:''}`);
|
|
233
260
|
console.log('Doctor does not start observers, execute checks, or prove agent behavior.');
|
|
234
261
|
for(const warning of value.warnings)console.log(`Review: ${warning}`);
|
|
235
262
|
}return value;
|
|
@@ -245,14 +272,16 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
245
272
|
if(!record&&(before?.includes('<!-- REDUE:')||read(root,h.skill)!==null))
|
|
246
273
|
throw Error('unowned REDUE instruction or skill already exists; inspect it before setup');
|
|
247
274
|
const after=record?before.replace(record.block,block):(before||'')+block;
|
|
248
|
-
|
|
275
|
+
let scripts={};try{scripts=JSON.parse(read(root,'package.json')??'{}').scripts||{};}catch{}
|
|
276
|
+
hook=host==='claude'?hookFor(config,relative,scripts):null;
|
|
249
277
|
const next={schema:1,host,config:relative,instruction:h.instruction,skill:h.skill,
|
|
250
278
|
block,skillHash:hash(skill),instructionCreated:record?.instructionCreated??before===null};
|
|
251
279
|
changes.push({path:h.skill,before:read(root,h.skill),after:skill},
|
|
252
280
|
{path:h.instruction,before,after});
|
|
253
|
-
if(host==='claude'){const settings=read(root,settingsPath);
|
|
254
|
-
changes.push({path:settingsPath,before:settings,after:settingsWith(settings,
|
|
255
|
-
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')});}
|
|
256
285
|
changes.push({path:marker(host),before:read(root,marker(host)),after:JSON.stringify(next,null,2)+'\n'});
|
|
257
286
|
}else if(record){
|
|
258
287
|
const before=read(root,h.instruction),after=before.replace(record.block,'');
|
|
@@ -266,7 +295,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
266
295
|
const summary={schema:1,action,host,config:configFile,applied:false,
|
|
267
296
|
changes:actual.map(c=>({path:c.path,operation:c.after===null?'remove':c.before===null?'create':'update',
|
|
268
297
|
...(action==='setup'?{content:c.path===h.instruction?block:c.content??c.after}: {})})),
|
|
269
|
-
warnings:notes,hooks:
|
|
298
|
+
warnings:notes,hooks:host!=='claude'||action!=='setup'?'none':`${settingsPath} ${hook?'PreToolUse, ':''}SessionStart`,behavior_verified:false};
|
|
270
299
|
if(!json){console.log(`${action} ${host}: ${configFile}`);
|
|
271
300
|
for(const c of summary.changes)console.log(` ${c.operation} ${c.path}${c.content?'\n'+c.content:''}`);
|
|
272
301
|
if(!actual.length)console.log('Already in the requested state.');
|
|
@@ -286,7 +315,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
286
315
|
export async function doctor({configFile,stateOverride,entry,cwd,host}){
|
|
287
316
|
const value={schema:1,command:{path:findExecutable('redue'),invoked:entry},project:{config:configFile},
|
|
288
317
|
observation:null,control:null,state_access:null,observer_process:null,compatibility:null,
|
|
289
|
-
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};
|
|
290
319
|
let p;try{p=project(configFile);value.project.root=p.root;
|
|
291
320
|
const state=stateOverride||projectState(p.root,configFile);value.project.state=state;
|
|
292
321
|
const cached=readCachedStatus(state,p.config.checks);value.observation=cached.observation;
|
|
@@ -317,12 +346,14 @@ export async function doctor({configFile,stateOverride,entry,cwd,host}){
|
|
|
317
346
|
if(executable){const launch=windowsLaunch([executable,'--version']);
|
|
318
347
|
const out=spawnSync(launch.file,launch.args,{...launch.options,encoding:'utf8',timeout:2000,maxBuffer:4096,stdio:['ignore','pipe','pipe']});
|
|
319
348
|
if(out.status===0)version=out.stdout.trim();}
|
|
320
|
-
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';
|
|
321
351
|
if(p)try{const record=saved(p.root,name);integration=inspect(p.root,name,record);
|
|
322
|
-
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();
|
|
323
354
|
value.warnings.push(...warnings(p.root,name,cwd));
|
|
324
355
|
}catch(e){integration={installed:false,integrity:'conflict',reason:e.message};}
|
|
325
|
-
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,
|
|
326
357
|
loading:name==='codex'?'AGENTS.md at session start; .agents/skills; overrides and size limits apply':
|
|
327
358
|
'CLAUDE.md at session start; .claude/skills; managed/user policies and exclusions apply'});
|
|
328
359
|
}
|
package/src/claude-hook.mjs
CHANGED
|
@@ -2,15 +2,18 @@
|
|
|
2
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
|
+
// The same command handles SessionStart: it starts the project's observer.
|
|
5
6
|
// Never exit 2: Claude Code treats exit 2 as a block.
|
|
6
7
|
import fs from 'node:fs';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import {spawn} from 'node:child_process';
|
|
9
10
|
import {projectState} from './project-location.mjs';
|
|
10
11
|
import {realObservedPath} from './path-identity.mjs';
|
|
12
|
+
import {processAlive} from './process-liveness.mjs';
|
|
11
13
|
|
|
12
14
|
export const hookCommand='redue agent hook claude';
|
|
13
|
-
|
|
15
|
+
// No shell metacharacters; `@` starts scoped package names.
|
|
16
|
+
const safe=/^[A-Za-z0-9_./:=,+@-]+$/;
|
|
14
17
|
const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
|
|
15
18
|
// A direct test check: its command is the runner and the script's arguments.
|
|
16
19
|
const tests=check=>check?.qualification==='npm-tests-direct-v1'&&Array.isArray(check.command)&&
|
|
@@ -22,32 +25,155 @@ export const defaultConfig='redue.config.json';
|
|
|
22
25
|
// would need shell quoting.
|
|
23
26
|
export const redueFor=(configFile=defaultConfig)=>configFile===defaultConfig?'redue':
|
|
24
27
|
safe.test(configFile)?`redue --config ${configFile}`:null;
|
|
28
|
+
|
|
29
|
+
// Package-manager commands that a script of the same name would not reach.
|
|
30
|
+
const builtins={pnpm:new Set(['add','audit','bin','config','create','dedupe','deploy','dlx','doctor','env','exec','fetch',
|
|
31
|
+
'i','import','init','install','link','list','ln','ls','outdated','pack','patch','prune','publish','rebuild','recursive',
|
|
32
|
+
'remove','rm','root','run','self-update','server','setup','store','uninstall','unlink','up','update','why','x']),
|
|
33
|
+
// Yarn 4 and Yarn 1 together.
|
|
34
|
+
yarn:new Set(['access','add','audit','autoclean','bin','cache','check','config','constraints','create','dedupe','dir','dlx',
|
|
35
|
+
'exec','explain','generate-lock-entry','global','help','import','info','init','install','licenses','link','list','login',
|
|
36
|
+
'logout','node','npm','outdated','owner','pack','patch','plugin','policies','publish','rebuild','remove','run','set',
|
|
37
|
+
'stage','tag','team','unlink','unplug','up','upgrade','upgrade-interactive','version','versions','why','workspace','workspaces'])};
|
|
38
|
+
// A package script run by the project's package manager, in the package's directory.
|
|
39
|
+
function scriptForms(pm,script){
|
|
40
|
+
if(typeof script!=='string'||!safe.test(script))return [];
|
|
41
|
+
if(pm==='npm')return [`npm run ${script}`,...(script==='test'?['npm test']:[])];
|
|
42
|
+
if(pm==='pnpm'||pm==='yarn')return [...(builtins[pm].has(script)?[]:[`${pm} ${script}`]),`${pm} run ${script}`];
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
// A workspace package's script, run from the project root.
|
|
46
|
+
function workspaceForms(pm,name,script){
|
|
47
|
+
if(typeof name!=='string'||!safe.test(name)||typeof script!=='string'||!safe.test(script))return [];
|
|
48
|
+
if(pm==='pnpm')return ['--filter ','--filter=','-F '].flatMap(flag=>
|
|
49
|
+
[...(builtins.pnpm.has(script)?[]:[`pnpm ${flag}${name} ${script}`]),`pnpm ${flag}${name} run ${script}`]);
|
|
50
|
+
if(pm==='yarn')return [...(builtins.yarn.has(script)?[]:[`yarn workspace ${name} ${script}`]),`yarn workspace ${name} run ${script}`];
|
|
51
|
+
if(pm==='npm')return [`npm run ${script} -w ${name}`,`npm run ${script} --workspace ${name}`,
|
|
52
|
+
`npm run ${script} --workspace=${name}`,`npm -w ${name} run ${script}`];
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
// The tool a check runs, as typed: `tsc ARGS` or the test runner's command.
|
|
56
|
+
function toolText(check){
|
|
57
|
+
const command=check.command;
|
|
58
|
+
if(!Array.isArray(command)||command[0]!=='@node')return null;
|
|
59
|
+
const words=/^@typescript-(?:compiler|bin):/.test(command[1]||'')?['tsc',...command.slice(2)]:
|
|
60
|
+
tests(check)?[command[1].slice('@test-runner:'.length),...command.slice(2)]:null;
|
|
61
|
+
return words?.every(word=>typeof word==='string'&&safe.test(word))?words.join(' '):null;
|
|
62
|
+
}
|
|
63
|
+
// A bare `tsc` is rarely on PATH; a test runner's own text is what the script says.
|
|
64
|
+
const toolForms=(pm,text)=>!text?[]:[...(/^tsc(?: |$)/.test(text)?[]:[text]),`npx ${text}`,...(pm==='pnpm'?[`pnpm exec ${text}`]:[])];
|
|
65
|
+
// A workspace check's package: its `workspace`, or the package its generated
|
|
66
|
+
// `npm run S --workspace W`, `yarn workspace W run S` or `pnpm --filter W run S`
|
|
67
|
+
// command names; null for any other check.
|
|
68
|
+
function workspaceName(check){
|
|
69
|
+
if(typeof check.workspace==='string')return check.workspace;
|
|
70
|
+
const command=check.command,flag={'@which:npm':'--workspace','@which:yarn':'workspace','@which:pnpm':'--filter'}[command?.[0]];
|
|
71
|
+
const at=flag&&Array.isArray(command)?command.indexOf(flag):-1;
|
|
72
|
+
return at>0&&typeof command[at+1]==='string'?command[at+1]:null;
|
|
73
|
+
}
|
|
74
|
+
// Where a check runs: '.' for a root check, the package directory for a
|
|
75
|
+
// workspace check, or null. A workspace check's directory is its `cwd`, or the
|
|
76
|
+
// one directory its inputs name as `DIR/package.json` or `DIR/**` with
|
|
77
|
+
// `!DIR/node_modules/**`, as init writes them.
|
|
78
|
+
export function checkDir(check){
|
|
79
|
+
if(!workspaceName(check))return check.cwd===undefined||check.cwd==='.'?'.':null;
|
|
80
|
+
if(typeof check.cwd==='string'&&check.cwd!=='.')return check.cwd;
|
|
81
|
+
const inputs=(check.inputs||[]).filter(value=>typeof value==='string');
|
|
82
|
+
const dirs=new Set(inputs.flatMap(value=>{
|
|
83
|
+
const manifest=/^([^!*]+)\/package\.json$/.exec(value)?.[1],tree=/^([^!*]+)\/\*\*$/.exec(value)?.[1];
|
|
84
|
+
return manifest?[manifest]:tree&&inputs.includes(`!${tree}/node_modules/**`)?[tree]:[];
|
|
85
|
+
}));
|
|
86
|
+
return dirs.size===1?[...dirs][0]:null;
|
|
87
|
+
}
|
|
88
|
+
// The package script a check runs: its `script`, or the last word of a
|
|
89
|
+
// generated `pnpm --filter NAME run SCRIPT`-style command.
|
|
90
|
+
const checkScript=check=>typeof check.script==='string'?check.script:
|
|
91
|
+
Array.isArray(check.command)&&/^@which:(?:npm|pnpm|yarn)$/.test(check.command[0])&&check.command.includes('run')?
|
|
92
|
+
check.command[check.command.indexOf('run')+1]:null;
|
|
93
|
+
|
|
25
94
|
// Exact commands that rerun each configured typecheck or direct test check.
|
|
26
|
-
//
|
|
95
|
+
// `forms` work from the project root; `local` ones work in the check's own
|
|
96
|
+
// directory `dir`, after a `cd` into it. Names that would need shell quoting
|
|
97
|
+
// get no form, so the hook never fires for them.
|
|
27
98
|
export function hookForms(config,configFile=defaultConfig){
|
|
28
|
-
const rows=[],redue=redueFor(configFile);
|
|
99
|
+
const rows=[],redue=redueFor(configFile),pm=config?.packageManager;
|
|
29
100
|
for(const check of config?.checks||[]){
|
|
30
101
|
if(!(typecheck(check)||tests(check))||typeof check.name!=='string'||!safe.test(check.name))continue;
|
|
31
|
-
const forms=redue?[`${redue} run ${check.name}`]:[];
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if(
|
|
38
|
-
|
|
39
|
-
|
|
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(' ')}`);
|
|
102
|
+
const forms=redue?[`${redue} run ${check.name}`]:[],local=[],dir=checkDir(check),script=checkScript(check);
|
|
103
|
+
if(dir==='.'){
|
|
104
|
+
forms.push(...scriptForms(pm,script),...toolForms(pm,toolText(check)));
|
|
105
|
+
if(!tests(check)&&Array.isArray(check.command)&&check.command.length&&
|
|
106
|
+
check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
|
|
107
|
+
forms.push(check.command.join(' '));
|
|
108
|
+
}else if(dir!==null&&safe.test(dir)){
|
|
109
|
+
forms.push(...workspaceForms(pm,workspaceName(check),script));
|
|
110
|
+
local.push(...scriptForms(pm,script),...(check.cwd===dir?toolForms(pm,toolText(check)):[]));
|
|
45
111
|
}
|
|
46
|
-
if(forms.length)rows.push({check:check.name,kind:checkKind(check),
|
|
112
|
+
if(forms.length||local.length)rows.push({check:check.name,kind:checkKind(check),dir,
|
|
113
|
+
forms:[...new Set(forms)],local:[...new Set(local)]});
|
|
47
114
|
}
|
|
48
115
|
return rows;
|
|
49
116
|
}
|
|
50
117
|
|
|
118
|
+
// A turbo or `pnpm -r` command that runs one task in every workspace package:
|
|
119
|
+
// {tool, task}. No filters and no nx: both can add packages or tasks that only
|
|
120
|
+
// a fuller model of the tool would see. `pnpm turbo` is left out because a root
|
|
121
|
+
// script named turbo would run instead.
|
|
122
|
+
const turboPrefix='(?:npx |pnpm exec )?',task='([A-Za-z0-9_:.-]+)';
|
|
123
|
+
const fanouts=[['turbo',new RegExp(`^${turboPrefix}turbo(?: run)? ${task}$`)],
|
|
124
|
+
['pnpm',new RegExp(`^pnpm (?:-r|--recursive)(?: run)? ${task}$`)]];
|
|
125
|
+
export function fanout(text){
|
|
126
|
+
for(const [tool,pattern] of fanouts){const match=pattern.exec(text);
|
|
127
|
+
if(match)return {tool,task:match[1]};}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
// Bash `if` rules for fan-out commands of a task some workspace check runs,
|
|
131
|
+
// and for root scripts that run one; other turbo or nx commands never start the hook.
|
|
132
|
+
export function fanoutRules(config,scripts={}){
|
|
133
|
+
const tasks=new Set((config?.checks||[]).filter(check=>(typecheck(check)||tests(check))&&typeof check.name==='string'&&
|
|
134
|
+
![null,'.'].includes(checkDir(check))).map(checkScript).filter(task=>typeof task==='string'&&/^[A-Za-z0-9_:.-]+$/.test(task)));
|
|
135
|
+
const rules=[];
|
|
136
|
+
for(const task of tasks){
|
|
137
|
+
for(const prefix of ['','npx ','pnpm exec '])rules.push(`${prefix}turbo run ${task}`,`${prefix}turbo ${task}`);
|
|
138
|
+
if(config.packageManager==='pnpm')rules.push(`pnpm -r ${task}`,`pnpm -r run ${task}`,`pnpm --recursive ${task}`,`pnpm --recursive run ${task}`);
|
|
139
|
+
}
|
|
140
|
+
for(const [name,text] of Object.entries(scripts||{})){
|
|
141
|
+
const fan=typeof text==='string'&&!scripts['pre'+name]&&!scripts['post'+name]?fanout(text.trim()):null;
|
|
142
|
+
if(fan&&tasks.has(fan.task))rules.push(...scriptForms(config.packageManager,name));
|
|
143
|
+
}
|
|
144
|
+
return rules;
|
|
145
|
+
}
|
|
146
|
+
// The checks a fan-out runs: one configured check for every workspace package
|
|
147
|
+
// with the task's script, or null when any package has none, or when the
|
|
148
|
+
// tool may run more: another task first (turbo `dependsOn` other than ^TASK,
|
|
149
|
+
// `with`, package-scoped keys, package turbo.json), a task turbo does not
|
|
150
|
+
// define, or pnpm's workspace root.
|
|
151
|
+
export async function fanoutChecks(fan,config,projectRoot){
|
|
152
|
+
const {workspacePackages}=await import('./node-onboarding.mjs');
|
|
153
|
+
const packages=workspacePackages(projectRoot).filter(item=>typeof item.scripts[fan.task]==='string');
|
|
154
|
+
if(!packages.length)return null;
|
|
155
|
+
const text=file=>{try{return fs.readFileSync(path.join(projectRoot,file),'utf8');}catch(e){return e.code==='ENOENT'?undefined:null;}};
|
|
156
|
+
if(fan.tool==='turbo'){
|
|
157
|
+
let tasks;try{const turbo=JSON.parse(text('turbo.json'));tasks=turbo?.tasks??turbo?.pipeline;}catch{return null;}
|
|
158
|
+
const own=tasks?.[fan.task];
|
|
159
|
+
if(!own||typeof own!=='object'||own.with!==undefined||
|
|
160
|
+
own.dependsOn!==undefined&&!(Array.isArray(own.dependsOn)&&own.dependsOn.every(item=>item===`^${fan.task}`))||
|
|
161
|
+
Object.keys(tasks).some(key=>key.endsWith(`#${fan.task}`))||
|
|
162
|
+
packages.some(item=>text(`${item.dir}/turbo.json`)!==undefined||text(`${item.dir}/turbo.jsonc`)!==undefined))return null;
|
|
163
|
+
}
|
|
164
|
+
if(fan.tool==='pnpm'&&(config.packageManager!=='pnpm'||
|
|
165
|
+
['.npmrc','pnpm-workspace.yaml'].some(file=>{const value=text(file);return value===null||/include-?workspace-?root/i.test(value??'');})))
|
|
166
|
+
return null;
|
|
167
|
+
const names=[];
|
|
168
|
+
for(const item of packages){
|
|
169
|
+
const check=config.checks.find(c=>(typecheck(c)||tests(c))&&typeof c.name==='string'&&
|
|
170
|
+
workspaceName(c)===item.name&&checkScript(c)===fan.task&&checkDir(c)===item.dir);
|
|
171
|
+
if(!check)return null;
|
|
172
|
+
names.push(check.name);
|
|
173
|
+
}
|
|
174
|
+
return names;
|
|
175
|
+
}
|
|
176
|
+
|
|
51
177
|
// Accept only an optional trailing `2>&1`, then an optional `| tail|head -n N`
|
|
52
178
|
// or `-N`, then optionally `;` or `&&` and the rest of the command. A form has
|
|
53
179
|
// no shell metacharacters, so the first separator after it ends it. Newlines
|
|
@@ -59,11 +185,25 @@ function split(command){
|
|
|
59
185
|
const one=text=>text.replace(/[ \t]+/g,' ');
|
|
60
186
|
return {base:one(base),suffix:one(suffix),rest};
|
|
61
187
|
}
|
|
62
|
-
|
|
63
|
-
|
|
188
|
+
// {check, rest}, or {fanout, rest} for a turbo/nx/`pnpm -r` command or a root
|
|
189
|
+
// script that runs one; null when nothing matches. `dir` is where the command
|
|
190
|
+
// runs, relative to the project root.
|
|
191
|
+
function matched(command,config,configFile,dir='.',scripts={}){
|
|
192
|
+
const parts=split(command);
|
|
193
|
+
if(!parts)return null;
|
|
194
|
+
if(dir==='.'){
|
|
195
|
+
// A root script that runs a fan-out is that fan-out, unless pre/post scripts
|
|
196
|
+
// run around it: then it matches nothing.
|
|
197
|
+
for(const [name,text] of Object.entries(scripts||{}))
|
|
198
|
+
if(typeof text==='string'&&scriptForms(config.packageManager,name).includes(parts.base)&&fanout(text.trim()))
|
|
199
|
+
return scripts['pre'+name]||scripts['post'+name]?null:{fanout:fanout(text.trim()),rest:parts.rest};
|
|
200
|
+
if(fanout(parts.base))return {fanout:fanout(parts.base),rest:parts.rest};
|
|
201
|
+
}
|
|
202
|
+
const check=hookForms(config,configFile).find(row=>dir==='.'?row.forms.includes(parts.base):
|
|
203
|
+
row.dir===dir&&row.local.includes(parts.base))?.check;
|
|
64
204
|
return check?{check,rest:parts.rest}:null;
|
|
65
205
|
}
|
|
66
|
-
export function matchCheck(command,config,configFile){return matched(command,config,configFile)?.check||null;}
|
|
206
|
+
export function matchCheck(command,config,configFile,dir,scripts){return matched(command,config,configFile,dir,scripts)?.check||null;}
|
|
67
207
|
// The same check through REDUE, keeping the output suffix; null if it already
|
|
68
208
|
// is, or if more commands follow it.
|
|
69
209
|
export function recordedCommand(command,name,configFile=defaultConfig){
|
|
@@ -73,6 +213,30 @@ export function recordedCommand(command,name,configFile=defaultConfig){
|
|
|
73
213
|
return parts.base===run?null:run+parts.suffix;
|
|
74
214
|
}
|
|
75
215
|
|
|
216
|
+
// A leading `cd DIR;` or `cd DIR &&`, which agents often add before a check:
|
|
217
|
+
// {prefix, command, dir} with the rest of the command and DIR relative to the
|
|
218
|
+
// project root ('.' for the root or no prefix), or null when DIR is outside
|
|
219
|
+
// the project or missing. DIR may be quoted; it may not use variables, a leading
|
|
220
|
+
// `~` or escapes. Inside double quotes a backslash before anything but
|
|
221
|
+
// $ ` " \ is literal, so a quoted Windows path matches; unquoted it is an escape.
|
|
222
|
+
const cdPrefix=/^[ \t]*cd[ \t]+(?:"((?:[^"$`\\]|\\(?![$`"\\\n]))*)"|'([^']*)'|((?!~)[^\s;&|'"$`\\]+))[ \t]*(?:;|&&)[ \t]*/;
|
|
223
|
+
export function enteredDir(command,cwd,projectRoot){
|
|
224
|
+
const prefix=typeof command==='string'?cdPrefix.exec(command):null;
|
|
225
|
+
if(!prefix)return {prefix:'',command,dir:'.'};
|
|
226
|
+
let relative;
|
|
227
|
+
try{relative=path.relative(projectRoot,realObservedPath(path.resolve(cwd,prefix[1]??prefix[2]??prefix[3])));}catch{return null;}
|
|
228
|
+
if(relative.split(path.sep)[0]==='..'||path.isAbsolute(relative))return null;
|
|
229
|
+
return {prefix:prefix[0],command:command.slice(prefix[0].length),dir:relative?relative.split(path.sep).join('/'):'.'};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// The fan-out's skip message when every check it runs is CURRENT/PASS, else null.
|
|
233
|
+
export function fanoutVerdict(status,names,rest=null){
|
|
234
|
+
if(!names.length||!names.every(name=>verdict(status,name)))return null;
|
|
235
|
+
return `REDUE: all ${names.length} checks this command runs (${names.join(', ')}) passed on these exact inputs; `+
|
|
236
|
+
'rerun skipped. This counts as a pass for the current files; no status check is needed. '+
|
|
237
|
+
'To run it anyway, prefix the command with REDUE_FORCE=1.'+
|
|
238
|
+
(rest===null?'':` Nothing else in the command ran either; run the rest on its own: ${rest}`);
|
|
239
|
+
}
|
|
76
240
|
export function verdict(status,name,rest=null){
|
|
77
241
|
const row=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)?
|
|
78
242
|
status.checks.find(item=>item?.name===name):null;
|
|
@@ -119,18 +283,56 @@ export function lastRunTimeout(config,parsed,name){
|
|
|
119
283
|
catch{return 2000;}
|
|
120
284
|
}
|
|
121
285
|
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const command=input.tool_input?.command,root=input.cwd;
|
|
126
|
-
if(typeof command!=='string'||/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return null;
|
|
286
|
+
// The project set up in the session's directory: its setup record, the config
|
|
287
|
+
// beside it and the project root. null when there is none; throws on bad files.
|
|
288
|
+
function hookProject(root){
|
|
127
289
|
if(typeof root!=='string'||!path.isAbsolute(root))return null;
|
|
128
290
|
const record=JSON.parse(fs.readFileSync(path.join(root,'.redue','agents','claude.json'),'utf8'));
|
|
129
291
|
if(record?.schema!==1||record.host!=='claude'||typeof record.config!=='string'||
|
|
130
292
|
!/^[^/\\]+\.json$/.test(record.config))return null;
|
|
131
293
|
const config=path.join(root,record.config);
|
|
132
|
-
const parsed=JSON.parse(fs.readFileSync(config,'utf8'))
|
|
294
|
+
const parsed=JSON.parse(fs.readFileSync(config,'utf8'));
|
|
295
|
+
return {record,config,parsed,projectRoot:realObservedPath(path.resolve(path.dirname(config),parsed.root||'.'))};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// SessionStart: start the observer when none is running, so the session's
|
|
299
|
+
// checks can be skipped or recorded without a manual `redue start`. It
|
|
300
|
+
// launches `redue start` detached and returns at once, and prints nothing:
|
|
301
|
+
// SessionStart output becomes agent context. Returns what it did.
|
|
302
|
+
export function sessionStart(input,{entry}){
|
|
303
|
+
const project=hookProject(input?.cwd);
|
|
304
|
+
if(!project)return null;
|
|
305
|
+
try{const pid=Number(fs.readFileSync(path.join(projectState(project.projectRoot,project.config),'observer.lock','pid'),'utf8'));
|
|
306
|
+
if(Number.isSafeInteger(pid)&&pid>0&&processAlive(pid))return 'running';}catch{}
|
|
307
|
+
const child=spawn(process.execPath,[entry,'--config',project.config,'start'],
|
|
308
|
+
{cwd:input.cwd,detached:true,stdio:'ignore',windowsHide:true,env:process.env});
|
|
309
|
+
child.on('error',()=>{});child.unref();
|
|
310
|
+
return 'started';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Returns the hook's stdout object, or null to allow the command.
|
|
314
|
+
export async function claudeHook(input,{entry,timeout}){
|
|
315
|
+
if(input?.tool_name!=='Bash')return null;
|
|
316
|
+
const command=input.tool_input?.command,root=input.cwd;
|
|
317
|
+
if(typeof command!=='string'||/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return null;
|
|
318
|
+
const project=hookProject(root);
|
|
319
|
+
if(!project)return null;
|
|
320
|
+
const {record,config,parsed}=project;
|
|
321
|
+
const entered=enteredDir(command,root,project.projectRoot);
|
|
322
|
+
if(!entered)return null;
|
|
323
|
+
let scripts={};
|
|
324
|
+
if(entered.dir==='.')try{scripts=JSON.parse(fs.readFileSync(path.join(project.projectRoot,'package.json'),'utf8')).scripts||{};}catch{}
|
|
325
|
+
const match=matched(entered.command,parsed,record.config,entered.dir,scripts);
|
|
133
326
|
if(!match)return null;
|
|
327
|
+
// turbo, nx and `pnpm -r` cache their own results: skip only when every
|
|
328
|
+
// check the command runs is CURRENT, and otherwise leave it alone.
|
|
329
|
+
if(match.fanout){
|
|
330
|
+
const names=await fanoutChecks(match.fanout,parsed,project.projectRoot);
|
|
331
|
+
if(!names)return null;
|
|
332
|
+
const status=await synchronizedStatus(entry,config,root,timeout??Math.max(...names.map(name=>lastRunTimeout(config,parsed,name)))),
|
|
333
|
+
reason=fanoutVerdict(status,names,match.rest);
|
|
334
|
+
return reason?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}:null;
|
|
335
|
+
}
|
|
134
336
|
const name=match.check,status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,name)),
|
|
135
337
|
reason=verdict(status,name,match.rest);
|
|
136
338
|
if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',
|
|
@@ -140,19 +342,23 @@ export async function claudeHook(input,{entry,timeout}){
|
|
|
140
342
|
// stays too: the rewrite returns "allow", which would also skip the prompt
|
|
141
343
|
// for the rest of the chain.
|
|
142
344
|
const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
|
|
143
|
-
status.checks.some(item=>item?.name===name)?recordedCommand(command,name,
|
|
345
|
+
status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,
|
|
346
|
+
// Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
|
|
347
|
+
!entered.prefix?record.config:path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/')):null;
|
|
144
348
|
// updatedInput replaces the whole tool input. Deny and ask rules still apply
|
|
145
349
|
// to the rewritten command; "allow" skips only the prompt.
|
|
146
350
|
return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
|
147
351
|
permissionDecisionReason:`REDUE: check ${JSON.stringify(name)} is not CURRENT; running it through redue run so the result is recorded.`,
|
|
148
|
-
updatedInput:{...input.tool_input,command:rewritten}}}:null;
|
|
352
|
+
updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
|
|
149
353
|
}
|
|
150
354
|
|
|
151
355
|
export async function runClaudeHook(entry){
|
|
152
356
|
try{
|
|
153
357
|
let text='';process.stdin.setEncoding('utf8');
|
|
154
358
|
for await(const chunk of process.stdin){text+=chunk;if(text.length>1024*1024)return;}
|
|
155
|
-
const
|
|
359
|
+
const input=JSON.parse(text);
|
|
360
|
+
if(input?.hook_event_name==='SessionStart'){sessionStart(input,{entry});return;}
|
|
361
|
+
const output=await claudeHook(input,{entry});
|
|
156
362
|
if(output)await new Promise(resolve=>process.stdout.write(JSON.stringify(output)+'\n',resolve));
|
|
157
363
|
}catch{}
|
|
158
364
|
}
|
package/src/node-onboarding.mjs
CHANGED
|
@@ -161,6 +161,15 @@ function workspaceManifests(root,patterns) {
|
|
|
161
161
|
if(matched.length>50)throw Error('more than 50 workspace packages found; select checks explicitly');
|
|
162
162
|
return matched.map(file=>({file,pkg:readJson(path.join(root,file))}));
|
|
163
163
|
}
|
|
164
|
+
// Workspace packages as init finds them, with their scripts; [] without workspaces.
|
|
165
|
+
export function workspacePackages(root){
|
|
166
|
+
root=realObservedPath(root);
|
|
167
|
+
const pkg=readJson(path.join(root,'package.json')),pm=manager(root,pkg);
|
|
168
|
+
const patterns=pm.name==='pnpm'?pm.workspacePatterns:Array.isArray(pkg.workspaces)?pkg.workspaces:pkg.workspaces?.packages;
|
|
169
|
+
if(!Array.isArray(patterns)||!patterns.length)return [];
|
|
170
|
+
return workspaceManifests(root,patterns).map(({file,pkg:member})=>
|
|
171
|
+
({name:member.name,dir:path.posix.dirname(file),scripts:member.scripts&&typeof member.scripts==='object'?member.scripts:{}}));
|
|
172
|
+
}
|
|
164
173
|
export function discoverNodeProject(root,{recipe=null}={}) {
|
|
165
174
|
if(recipe&&!['typescript-direct','tests-direct'].includes(recipe))throw Error('unknown recipe; use typescript-direct or tests-direct');
|
|
166
175
|
root=realObservedPath(root);
|