@redue/cli 0.1.0-alpha.3 → 0.1.0-alpha.30
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 +806 -0
- package/README.md +157 -99
- package/bin/redue.mjs +267 -61
- package/docs/README.md +3 -1
- package/docs/user/agents.md +333 -15
- package/docs/user/configuration.md +28 -2
- package/docs/user/direct-typescript.md +219 -0
- package/docs/user/getting-started.md +49 -7
- package/docs/user/json.md +4 -2
- package/docs/user/node-script.md +177 -0
- package/docs/user/npm-launcher-notice.md +42 -0
- package/docs/user/pilot.md +146 -0
- package/docs/user/sandbox.md +312 -0
- package/docs/user/shell.md +3 -3
- package/docs/user/supported-projects.md +69 -23
- package/docs/user/tests-direct.md +143 -0
- package/package.json +4 -3
- package/src/agent-integration.mjs +467 -0
- package/src/agent-policy.md +28 -0
- package/src/cached-state.mjs +24 -1
- package/src/claude-hook.mjs +616 -0
- package/src/cli.mjs +449 -54
- package/src/daemon.mjs +442 -93
- package/src/decision-profile.mjs +15 -1
- package/src/decision-validation.mjs +12 -1
- package/src/declared-plan.mjs +38 -6
- package/src/direct-run-certificate.mjs +254 -0
- package/src/direct-typescript-probe.mjs +27 -0
- package/src/direct-typescript.mjs +205 -0
- package/src/glob.mjs +8 -0
- package/src/history-worker.mjs +21 -13
- package/src/index.mjs +108 -21
- package/src/installed-inputs.mjs +7 -1
- package/src/installed-observation.mjs +23 -0
- package/src/landlock.py +527 -0
- package/src/linux-inotify.py +39 -10
- package/src/node-onboarding.mjs +203 -54
- package/src/node-script.mjs +406 -0
- package/src/npm-integrity.mjs +124 -0
- package/src/npm-typecheck-environment.mjs +36 -0
- package/src/npmrc.mjs +16 -0
- package/src/observation.mjs +34 -12
- package/src/path-identity.mjs +8 -0
- package/src/plan-worker.mjs +17 -17
- package/src/pnpm-inputs.mjs +122 -57
- package/src/pnpm-shim-templates.json +180 -0
- package/src/pnpm-shims.mjs +54 -0
- package/src/pnpm-typecheck-probe.mjs +11 -6
- package/src/presentation.mjs +13 -4
- package/src/probe-deadline.mjs +37 -0
- package/src/probe-result.mjs +7 -3
- package/src/project-location.mjs +34 -0
- package/src/python-onboarding.mjs +89 -0
- package/src/run-recorder.cjs +91 -0
- package/src/sandbox.mjs +653 -0
- package/src/setup.mjs +205 -0
- package/src/stats.mjs +72 -0
- package/src/test-direct.mjs +608 -0
- package/src/test-recorder.cjs +435 -0
- package/src/typescript-contract-probe.mjs +4 -187
- package/src/typescript-input-facts.mjs +92 -0
- package/src/typescript-invocation.mjs +92 -0
- package/src/typescript-list.mjs +118 -13
- package/src/typescript-qualification.mjs +29 -49
- package/src/workspace-manifests.mjs +24 -0
- package/src/yarn-workspace-inputs.mjs +3 -0
- package/src/yarn-workspace-typecheck-probe.mjs +2 -1
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
// Claude Code PreToolUse hook: skip an exact typecheck, test or lint rerun when REDUE
|
|
2
|
+
// reports CURRENT/PASS with reuse_eligible on the current inputs. When the check is not
|
|
3
|
+
// CURRENT, the command becomes `redue run <check>` so the run is recorded. Every
|
|
4
|
+
// other outcome, including errors and timeouts, allows the command unchanged.
|
|
5
|
+
// The same command handles SessionStart: it starts the project's observer.
|
|
6
|
+
// Never exit 2: Claude Code treats exit 2 as a block.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import {spawn,spawnSync} from 'node:child_process';
|
|
10
|
+
import {projectState} from './project-location.mjs';
|
|
11
|
+
import {realObservedPath} from './path-identity.mjs';
|
|
12
|
+
import {processAlive} from './process-liveness.mjs';
|
|
13
|
+
import {scriptRunChecks} from './typescript-invocation.mjs';
|
|
14
|
+
import {recipeLabel} from './presentation.mjs';
|
|
15
|
+
import {findExecutable} from './executable-lookup.mjs';
|
|
16
|
+
import {note,skipNote,dueNote} from './stats.mjs';
|
|
17
|
+
|
|
18
|
+
export const hookCommand='redue agent hook claude';
|
|
19
|
+
// No shell metacharacters; `@` starts scoped package names.
|
|
20
|
+
const safe=/^[A-Za-z0-9_./:=,+@-]+$/;
|
|
21
|
+
const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
|
|
22
|
+
// A direct test check: its command is the runner and the script's arguments.
|
|
23
|
+
const tests=check=>check?.qualification==='npm-tests-direct-v1'&&Array.isArray(check.command)&&
|
|
24
|
+
/^@test-runner:(?:vitest|jest)$/.test(check.command[1]||'');
|
|
25
|
+
// A general recipe check: a root typecheck, test or lint script run through the package manager.
|
|
26
|
+
const general=check=>(check?.qualification==='node-script-v1'&&['typecheck','test','lint'].includes(check.kind)||
|
|
27
|
+
sandboxed(check))&&typeof check.script==='string';
|
|
28
|
+
// A sandboxed check: a root package script, or a tool from the project's .venv.
|
|
29
|
+
// sandbox.mjs's isSandboxed, written out: the hook does not load the sandbox module.
|
|
30
|
+
const sandboxed=check=>['sandbox-v1','sandbox-v2','sandbox-v3'].includes(check?.qualification)&&['typecheck','lint','test'].includes(check.kind);
|
|
31
|
+
const python=check=>sandboxed(check)&&Array.isArray(check.command)&&/^\.venv\/bin\/[A-Za-z0-9_.-]+$/.test(check.command[0]||'')&&
|
|
32
|
+
check.command.every(word=>typeof word==='string'&&safe.test(word));
|
|
33
|
+
const eligibleCheck=check=>typecheck(check)||tests(check)||general(check)||python(check);
|
|
34
|
+
export const checkKind=check=>tests(check)?'test':general(check)||python(check)?check.kind:'typecheck';
|
|
35
|
+
// The ways an agent runs a Python check's tool: through uv or Poetry, with
|
|
36
|
+
// `python -m`, from .venv/bin, or by name. A form that starts with `python`
|
|
37
|
+
// or the tool's name depends on PATH, so the hook takes it only when PATH
|
|
38
|
+
// finds that program in the project's .venv/bin first (pathForm).
|
|
39
|
+
const wrappers=['uv run ','poetry run '];
|
|
40
|
+
function pythonForms(check){
|
|
41
|
+
const [file,...args]=check.command,tool=file.split('/').at(-1),rest=args.length?' '+args.join(' '):'';
|
|
42
|
+
return [`.venv/bin/${tool}${rest}`,`./.venv/bin/${tool}${rest}`,`.venv/bin/python -m ${tool}${rest}`,
|
|
43
|
+
...wrappers.flatMap(wrapper=>[`${wrapper}${tool}${rest}`,`${wrapper}python -m ${tool}${rest}`]),
|
|
44
|
+
`python -m ${tool}${rest}`,`python3 -m ${tool}${rest}`,`${tool}${rest}`];
|
|
45
|
+
}
|
|
46
|
+
// For a form that names a program PATH finds (`mypy .`, `python -m mypy .`),
|
|
47
|
+
// whether PATH finds it first in the project's .venv/bin.
|
|
48
|
+
export function pathForm(form,projectRoot,env=process.env){
|
|
49
|
+
const word=form.split(' ')[0];
|
|
50
|
+
if(word.includes('/')||wrappers.some(wrapper=>form.startsWith(wrapper)))return true;
|
|
51
|
+
if(!projectRoot)return false;
|
|
52
|
+
for(const dir of String(env.PATH??'').split(path.delimiter)){
|
|
53
|
+
if(!dir||!path.isAbsolute(dir))continue;
|
|
54
|
+
try{fs.accessSync(path.join(dir,word),fs.constants.X_OK);}catch{continue;}
|
|
55
|
+
try{return fs.realpathSync(dir)===fs.realpathSync(path.join(projectRoot,'.venv','bin'));}catch{return false;}
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
// The wrapper a matched form starts with, kept when the hook runs the check
|
|
60
|
+
// through REDUE: `uv run` and `poetry run` sync the environment first.
|
|
61
|
+
const wrapperOf=form=>wrappers.find(wrapper=>form.startsWith(wrapper))||'';
|
|
62
|
+
|
|
63
|
+
export const defaultConfig='redue.config.json';
|
|
64
|
+
// The `redue` prefix for a config beside the project root; null when the name
|
|
65
|
+
// would need shell quoting.
|
|
66
|
+
export const redueFor=(configFile=defaultConfig)=>configFile===defaultConfig?'redue':
|
|
67
|
+
safe.test(configFile)?`redue --config ${configFile}`:null;
|
|
68
|
+
export const shellQuote=value=>`'${String(value).replaceAll("'","'\\''")}'`;
|
|
69
|
+
// How a rewritten command starts REDUE: plain `redue` when the hook's PATH
|
|
70
|
+
// finds this install, else this install by absolute path. An agent shell
|
|
71
|
+
// without redue on PATH cannot run the plain form.
|
|
72
|
+
export function redueInvocation(entry,{env=process.env,platform=process.platform}={}){
|
|
73
|
+
if(platform==='win32'||runsEntry(findExecutable('redue',{env}),entry,env))return 'redue';
|
|
74
|
+
return `${shellQuote(process.execPath)} ${shellQuote(entry)}`;
|
|
75
|
+
}
|
|
76
|
+
// Whether the command at `file` runs this install's entry: the same file, or
|
|
77
|
+
// a shim or wrapper that reports it (redue --entry).
|
|
78
|
+
export function runsEntry(file,entry,env=process.env){
|
|
79
|
+
if(!file)return false;
|
|
80
|
+
try{const target=fs.realpathSync(entry);if(fs.realpathSync(file)===target)return true;
|
|
81
|
+
const out=spawnSync(file,['--entry'],{env,encoding:'utf8',timeout:5000,stdio:['ignore','pipe','ignore']});
|
|
82
|
+
return out.status===0&&fs.realpathSync(out.stdout.trim())===target;}catch{return false;}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Package-manager commands that a script of the same name would not reach.
|
|
86
|
+
const builtins={pnpm:new Set(['add','audit','bin','config','create','dedupe','deploy','dlx','doctor','env','exec','fetch',
|
|
87
|
+
'i','import','init','install','link','list','ln','ls','outdated','pack','patch','prune','publish','rebuild','recursive',
|
|
88
|
+
'remove','rm','root','run','self-update','server','setup','store','uninstall','unlink','up','update','why','x']),
|
|
89
|
+
// Yarn 4 and Yarn 1 together.
|
|
90
|
+
yarn:new Set(['access','add','audit','autoclean','bin','cache','check','config','constraints','create','dedupe','dir','dlx',
|
|
91
|
+
'exec','explain','generate-lock-entry','global','help','import','info','init','install','licenses','link','list','login',
|
|
92
|
+
'logout','node','npm','outdated','owner','pack','patch','plugin','policies','publish','rebuild','remove','run','set',
|
|
93
|
+
'stage','tag','team','unlink','unplug','up','upgrade','upgrade-interactive','version','versions','why','workspace','workspaces'])};
|
|
94
|
+
// A package script run by the project's package manager, in the package's directory.
|
|
95
|
+
function scriptForms(pm,script){
|
|
96
|
+
if(typeof script!=='string'||!safe.test(script))return [];
|
|
97
|
+
if(pm==='npm')return [`npm run ${script}`,...(script==='test'?['npm test']:[])];
|
|
98
|
+
if(pm==='pnpm'||pm==='yarn')return [...(builtins[pm].has(script)?[]:[`${pm} ${script}`]),`${pm} run ${script}`];
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
// A workspace package's script, run from the project root.
|
|
102
|
+
function workspaceForms(pm,name,script){
|
|
103
|
+
if(typeof name!=='string'||!safe.test(name)||typeof script!=='string'||!safe.test(script))return [];
|
|
104
|
+
if(pm==='pnpm')return ['--filter ','--filter=','-F '].flatMap(flag=>
|
|
105
|
+
[...(builtins.pnpm.has(script)?[]:[`pnpm ${flag}${name} ${script}`]),`pnpm ${flag}${name} run ${script}`]);
|
|
106
|
+
if(pm==='yarn')return [...(builtins.yarn.has(script)?[]:[`yarn workspace ${name} ${script}`]),`yarn workspace ${name} run ${script}`];
|
|
107
|
+
if(pm==='npm')return [`npm run ${script} -w ${name}`,`npm run ${script} --workspace ${name}`,
|
|
108
|
+
`npm run ${script} --workspace=${name}`,`npm -w ${name} run ${script}`];
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
// The tool a check runs, as typed: `tsc ARGS` or the test runner's command;
|
|
112
|
+
// for a general check, its script when the script is one plain command.
|
|
113
|
+
function toolText(check,scripts){
|
|
114
|
+
if(general(check)){const text=typeof scripts?.[check.script]==='string'?scripts[check.script].trim().replace(/\s+/g,' '):'';
|
|
115
|
+
return safe.test(text.replace(/ /g,''))&&!/^(?:npm|npx|pnpm|pnpx|yarn|node)(?: |$)/.test(text)&&
|
|
116
|
+
!scripts['pre'+check.script]&&!scripts['post'+check.script]?text:null;}
|
|
117
|
+
const command=check.command;
|
|
118
|
+
if(!Array.isArray(command)||command[0]!=='@node')return null;
|
|
119
|
+
const words=/^@typescript-(?:compiler|bin):/.test(command[1]||'')?['tsc',...command.slice(2)]:
|
|
120
|
+
tests(check)?[command[1].slice('@test-runner:'.length),...command.slice(2)]:null;
|
|
121
|
+
return words?.every(word=>typeof word==='string'&&safe.test(word))?words.join(' '):null;
|
|
122
|
+
}
|
|
123
|
+
// A bare `tsc` is rarely on PATH; a test runner's own text is what the script says.
|
|
124
|
+
const toolForms=(pm,text)=>!text?[]:[...(/^tsc(?: |$)/.test(text)?[]:[text]),`npx ${text}`,...(pm==='pnpm'?[`pnpm exec ${text}`]:[])];
|
|
125
|
+
// A workspace check's package: its `workspace`, or the package its generated
|
|
126
|
+
// `npm run S --workspace W`, `yarn workspace W run S` or `pnpm --filter W run S`
|
|
127
|
+
// command names; null for any other check.
|
|
128
|
+
function workspaceName(check){
|
|
129
|
+
if(typeof check.workspace==='string')return check.workspace;
|
|
130
|
+
const command=check.command,flag={'@which:npm':'--workspace','@which:yarn':'workspace','@which:pnpm':'--filter'}[command?.[0]];
|
|
131
|
+
const at=flag&&Array.isArray(command)?command.indexOf(flag):-1;
|
|
132
|
+
return at>0&&typeof command[at+1]==='string'?command[at+1]:null;
|
|
133
|
+
}
|
|
134
|
+
// Where a check runs: '.' for a root check, the package directory for a
|
|
135
|
+
// workspace check, or null. A workspace check's directory is its `cwd`, or the
|
|
136
|
+
// one directory its inputs name as `DIR/package.json` or `DIR/**` with
|
|
137
|
+
// `!DIR/node_modules/**`, as init writes them.
|
|
138
|
+
export function checkDir(check){
|
|
139
|
+
if(!workspaceName(check))return check.cwd===undefined||check.cwd==='.'?'.':null;
|
|
140
|
+
if(typeof check.cwd==='string'&&check.cwd!=='.')return check.cwd;
|
|
141
|
+
const inputs=(check.inputs||[]).filter(value=>typeof value==='string');
|
|
142
|
+
const dirs=new Set(inputs.flatMap(value=>{
|
|
143
|
+
const manifest=/^([^!*]+)\/package\.json$/.exec(value)?.[1],tree=/^([^!*]+)\/\*\*$/.exec(value)?.[1];
|
|
144
|
+
return manifest?[manifest]:tree&&inputs.includes(`!${tree}/node_modules/**`)?[tree]:[];
|
|
145
|
+
}));
|
|
146
|
+
return dirs.size===1?[...dirs][0]:null;
|
|
147
|
+
}
|
|
148
|
+
// The package script a check runs: its `script`, or the last word of a
|
|
149
|
+
// generated `pnpm --filter NAME run SCRIPT`-style command.
|
|
150
|
+
const checkScript=check=>typeof check.script==='string'?check.script:
|
|
151
|
+
Array.isArray(check.command)&&/^@which:(?:npm|pnpm|yarn)$/.test(check.command[0])&&check.command.includes('run')?
|
|
152
|
+
check.command[check.command.indexOf('run')+1]:null;
|
|
153
|
+
|
|
154
|
+
// A typecheck check whose command is the compiler: its arguments are one tsc
|
|
155
|
+
// run of its script (typescript-invocation.mjs).
|
|
156
|
+
function tscCheck(check){return typecheck(check)&&/^tsc(?: |$)/.test(toolText(check)||'');}
|
|
157
|
+
// Exact commands that rerun each configured typecheck or direct test check.
|
|
158
|
+
// `forms` work from the project root; `local` ones work in the check's own
|
|
159
|
+
// directory `dir`, after a `cd` into it. Names that would need shell quoting
|
|
160
|
+
// get no form, so the hook never fires for them. The script forms of compiler
|
|
161
|
+
// checks name the script's checks as {checks, script}: the hook acts on them
|
|
162
|
+
// only when the script's tsc runs are those checks' runs, one each. With one
|
|
163
|
+
// check they stay on its row (`scriptForms`); a chain's checks get a row for
|
|
164
|
+
// the script, and their own rows (`part`) keep only their own forms.
|
|
165
|
+
export function hookForms(config,configFile=defaultConfig,packageScripts=null){
|
|
166
|
+
const rows=[],redue=redueFor(configFile),pm=config?.packageManager,scripts=new Map();
|
|
167
|
+
const eligible=(config?.checks||[]).filter(check=>eligibleCheck(check)&&typeof check.name==='string'&&safe.test(check.name));
|
|
168
|
+
const groupOf=check=>tscCheck(check)&&typeof checkScript(check)==='string'&&checkDir(check)!==null?
|
|
169
|
+
JSON.stringify([checkDir(check),checkScript(check),workspaceName(check)]):null;
|
|
170
|
+
for(const check of eligible){const key=groupOf(check);if(key)scripts.set(key,[...scripts.get(key)||[],check.name]);}
|
|
171
|
+
const scriptRow=(dir,script,workspace)=>({forms:dir==='.'?scriptForms(pm,script):dir!==null&&safe.test(dir)?workspaceForms(pm,workspace,script):[],
|
|
172
|
+
local:dir!=='.'&&dir!==null&&safe.test(dir)?scriptForms(pm,script):[]});
|
|
173
|
+
for(const check of eligible){
|
|
174
|
+
const forms=redue?[`${redue} run ${check.name}`]:[],local=[],dir=checkDir(check),key=groupOf(check),
|
|
175
|
+
group=key?scripts.get(key):null,part=group?.length>1;
|
|
176
|
+
const own=group?null:checkScript(check);
|
|
177
|
+
if(dir==='.'&&python(check))forms.push(...pythonForms(check));
|
|
178
|
+
else if(dir==='.'){
|
|
179
|
+
forms.push(...scriptForms(pm,own),...toolForms(pm,toolText(check,packageScripts)));
|
|
180
|
+
if(!tests(check)&&Array.isArray(check.command)&&check.command.length&&
|
|
181
|
+
check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
|
|
182
|
+
forms.push(check.command.join(' '));
|
|
183
|
+
}else if(dir!==null&&safe.test(dir)){
|
|
184
|
+
forms.push(...workspaceForms(pm,workspaceName(check),own));
|
|
185
|
+
local.push(...scriptForms(pm,own),...(check.cwd===dir?toolForms(pm,toolText(check)):[]));
|
|
186
|
+
}
|
|
187
|
+
let extra={};
|
|
188
|
+
if(group&&!part){
|
|
189
|
+
const {forms:scriptOnly,local:scriptLocal}=scriptRow(dir,checkScript(check),workspaceName(check));
|
|
190
|
+
forms.splice(redue?1:0,0,...scriptOnly);local.unshift(...scriptLocal);
|
|
191
|
+
extra={checks:group,script:checkScript(check),scriptForms:[...scriptOnly,...scriptLocal]};
|
|
192
|
+
}
|
|
193
|
+
if(part)extra={part:true};
|
|
194
|
+
if(forms.length||local.length)rows.push({check:check.name,kind:checkKind(check),dir,
|
|
195
|
+
forms:[...new Set(forms)],local:[...new Set(local)],...extra});
|
|
196
|
+
}
|
|
197
|
+
for(const [key,checks] of scripts){
|
|
198
|
+
if(checks.length<2)continue;
|
|
199
|
+
const [dir,script,workspace]=JSON.parse(key),{forms,local}=scriptRow(dir,script,workspace);
|
|
200
|
+
if(forms.length||local.length)rows.push({check:checks[0].replace(/\.\d+$/,''),checks,script,
|
|
201
|
+
scriptForms:[...forms,...local],kind:'typecheck',dir,forms,local});
|
|
202
|
+
}
|
|
203
|
+
return rows;
|
|
204
|
+
}
|
|
205
|
+
// The checks a compiler script row runs, in the order of the script's runs in
|
|
206
|
+
// the package's package.json now; null when they no longer correspond (the
|
|
207
|
+
// script changed since init), so the hook leaves the command alone.
|
|
208
|
+
export function scriptChecks(row,config,projectRoot){
|
|
209
|
+
let scripts;try{scripts=JSON.parse(fs.readFileSync(path.join(projectRoot,row.dir,'package.json'),'utf8')).scripts;}catch{return null;}
|
|
210
|
+
if(scripts?.['pre'+row.script]||scripts?.['post'+row.script])return null;
|
|
211
|
+
const args=name=>config.checks.find(check=>check.name===name)?.command?.slice(2);
|
|
212
|
+
return scriptRunChecks(scripts?.[row.script],row.checks.map(name=>({name,args:args(name)})));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// A turbo or `pnpm -r` command that runs one task in every workspace package:
|
|
216
|
+
// {tool, task}. No filters and no nx: both can add packages or tasks that only
|
|
217
|
+
// a fuller model of the tool would see. `pnpm turbo` is left out because a root
|
|
218
|
+
// script named turbo would run instead.
|
|
219
|
+
const turboPrefix='(?:npx |pnpm exec )?',task='([A-Za-z0-9_:.-]+)';
|
|
220
|
+
const fanouts=[['turbo',new RegExp(`^${turboPrefix}turbo(?: run)? ${task}$`)],
|
|
221
|
+
['pnpm',new RegExp(`^pnpm (?:-r|--recursive)(?: run)? ${task}$`)]];
|
|
222
|
+
export function fanout(text){
|
|
223
|
+
for(const [tool,pattern] of fanouts){const match=pattern.exec(text);
|
|
224
|
+
if(match)return {tool,task:match[1]};}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
// Bash `if` rules for fan-out commands of a task some workspace check runs,
|
|
228
|
+
// and for root scripts that run one; other turbo or nx commands never start the hook.
|
|
229
|
+
export function fanoutRules(config,scripts={}){
|
|
230
|
+
const tasks=new Set((config?.checks||[]).filter(check=>eligibleCheck(check)&&typeof check.name==='string'&&
|
|
231
|
+
![null,'.'].includes(checkDir(check))).map(checkScript).filter(task=>typeof task==='string'&&/^[A-Za-z0-9_:.-]+$/.test(task)));
|
|
232
|
+
const rules=[];
|
|
233
|
+
for(const task of tasks){
|
|
234
|
+
for(const prefix of ['','npx ','pnpm exec '])rules.push(`${prefix}turbo run ${task}`,`${prefix}turbo ${task}`);
|
|
235
|
+
if(config.packageManager==='pnpm')rules.push(`pnpm -r ${task}`,`pnpm -r run ${task}`,`pnpm --recursive ${task}`,`pnpm --recursive run ${task}`);
|
|
236
|
+
}
|
|
237
|
+
for(const [name,text] of Object.entries(scripts||{})){
|
|
238
|
+
const fan=typeof text==='string'&&!scripts['pre'+name]&&!scripts['post'+name]?fanout(text.trim()):null;
|
|
239
|
+
if(fan&&tasks.has(fan.task))rules.push(...scriptForms(config.packageManager,name));
|
|
240
|
+
}
|
|
241
|
+
return rules;
|
|
242
|
+
}
|
|
243
|
+
// The checks a fan-out runs: one configured check for every workspace package
|
|
244
|
+
// with the task's script, or null when any package has none, or when the
|
|
245
|
+
// tool may run more: another task first (turbo `dependsOn` other than ^TASK,
|
|
246
|
+
// `with`, package-scoped keys, package turbo.json), a task turbo does not
|
|
247
|
+
// define, or pnpm's workspace root.
|
|
248
|
+
export async function fanoutChecks(fan,config,projectRoot){
|
|
249
|
+
const {workspacePackages}=await import('./node-onboarding.mjs');
|
|
250
|
+
const packages=workspacePackages(projectRoot).filter(item=>typeof item.scripts[fan.task]==='string');
|
|
251
|
+
if(!packages.length)return null;
|
|
252
|
+
const text=file=>{try{return fs.readFileSync(path.join(projectRoot,file),'utf8');}catch(e){return e.code==='ENOENT'?undefined:null;}};
|
|
253
|
+
if(fan.tool==='turbo'){
|
|
254
|
+
let tasks;try{const turbo=JSON.parse(text('turbo.json'));tasks=turbo?.tasks??turbo?.pipeline;}catch{return null;}
|
|
255
|
+
const own=tasks?.[fan.task];
|
|
256
|
+
if(!own||typeof own!=='object'||own.with!==undefined||
|
|
257
|
+
own.dependsOn!==undefined&&!(Array.isArray(own.dependsOn)&&own.dependsOn.every(item=>item===`^${fan.task}`))||
|
|
258
|
+
Object.keys(tasks).some(key=>key.endsWith(`#${fan.task}`))||
|
|
259
|
+
packages.some(item=>text(`${item.dir}/turbo.json`)!==undefined||text(`${item.dir}/turbo.jsonc`)!==undefined))return null;
|
|
260
|
+
}
|
|
261
|
+
if(fan.tool==='pnpm'&&(config.packageManager!=='pnpm'||
|
|
262
|
+
['.npmrc','pnpm-workspace.yaml'].some(file=>{const value=text(file);return value===null||/include-?workspace-?root/i.test(value??'');})))
|
|
263
|
+
return null;
|
|
264
|
+
const names=[];
|
|
265
|
+
for(const item of packages){
|
|
266
|
+
const own=config.checks.filter(c=>eligibleCheck(c)&&typeof c.name==='string'&&
|
|
267
|
+
workspaceName(c)===item.name&&checkScript(c)===fan.task&&checkDir(c)===item.dir);
|
|
268
|
+
if(!own.length)return null;
|
|
269
|
+
// A compiler script's checks: one per tsc run it has now.
|
|
270
|
+
if(own.every(tscCheck)){
|
|
271
|
+
const ordered=scriptRunChecks(item.scripts[fan.task],own.map(c=>({name:c.name,args:c.command.slice(2)})));
|
|
272
|
+
if(!ordered)return null;
|
|
273
|
+
names.push(...ordered);continue;
|
|
274
|
+
}
|
|
275
|
+
if(own.length!==1)return null;
|
|
276
|
+
names.push(own[0].name);
|
|
277
|
+
}
|
|
278
|
+
return names;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Accept only an optional trailing `2>&1`, then an optional `| tail|head -n N`
|
|
282
|
+
// or `-N`, then optionally `;` or `&&` and the rest of the command. A form has
|
|
283
|
+
// no shell metacharacters, so the first separator after it ends it. Newlines
|
|
284
|
+
// separate commands, so they never match. The rest keeps its own spacing.
|
|
285
|
+
function split(command){
|
|
286
|
+
if(typeof command!=='string'||/[\r\n]/.test(command))return null;
|
|
287
|
+
const [,base,suffix,rest=null]=command.trim()
|
|
288
|
+
.match(/^(.*?)((?:[ \t]+2>&1)?(?:[ \t]+\|[ \t]+(?:tail|head)[ \t]+(?:-n[ \t]+\d+|-\d+))?)(?:[ \t]*(?:;|&&)[ \t]*([^\s;&|].*))?$/);
|
|
289
|
+
const one=text=>text.replace(/[ \t]+/g,' ');
|
|
290
|
+
return {base:one(base),suffix:one(suffix),rest};
|
|
291
|
+
}
|
|
292
|
+
// {check, rest}, or {fanout, rest} for a turbo/nx/`pnpm -r` command or a root
|
|
293
|
+
// script that runs one; null when nothing matches. `dir` is where the command
|
|
294
|
+
// runs, relative to the project root.
|
|
295
|
+
function matched(command,config,configFile,dir='.',scripts={},projectRoot=null){
|
|
296
|
+
const parts=split(command);
|
|
297
|
+
if(!parts)return null;
|
|
298
|
+
const row=hookForms(config,configFile,dir==='.'?scripts:null).find(row=>dir==='.'?row.forms.includes(parts.base):
|
|
299
|
+
row.dir===dir&&row.local.includes(parts.base));
|
|
300
|
+
// A Python check's form that depends on PATH matches only where PATH finds
|
|
301
|
+
// the project's .venv/bin.
|
|
302
|
+
const check=row&&config.checks.find(item=>item.name===row.check);
|
|
303
|
+
if(python(check)&&!parts.base.startsWith('redue'))
|
|
304
|
+
return pathForm(parts.base,projectRoot)?{check:row.check,rest:parts.rest,wrapper:wrapperOf(parts.base)}:null;
|
|
305
|
+
// A root script that a general check runs is that check, even when the script fans out.
|
|
306
|
+
if(dir==='.'&&row&&general(config.checks.find(check=>check.name===row.check)))return {check:row.check,rest:parts.rest};
|
|
307
|
+
if(dir==='.'){
|
|
308
|
+
// A root script that runs a fan-out is that fan-out, unless pre/post scripts
|
|
309
|
+
// run around it: then it matches nothing.
|
|
310
|
+
for(const [name,text] of Object.entries(scripts||{}))
|
|
311
|
+
if(typeof text==='string'&&scriptForms(config.packageManager,name).includes(parts.base)&&fanout(text.trim()))
|
|
312
|
+
return scripts['pre'+name]||scripts['post'+name]?null:{fanout:fanout(text.trim()),rest:parts.rest};
|
|
313
|
+
if(fanout(parts.base))return {fanout:fanout(parts.base),rest:parts.rest};
|
|
314
|
+
}
|
|
315
|
+
// A script form of compiler checks is checked against the script first.
|
|
316
|
+
return row?.scriptForms?.includes(parts.base)?{row,rest:parts.rest}:row?{check:row.check,rest:parts.rest}:null;
|
|
317
|
+
}
|
|
318
|
+
// A simple chain: commands joined by &&, || or ;, outside quotes, with no
|
|
319
|
+
// line break, subshell, command or process substitution, backtick,
|
|
320
|
+
// here-document or background job. [{separator, text}] with the separator
|
|
321
|
+
// before each part ('' for the first), or null.
|
|
322
|
+
export function chainParts(command){
|
|
323
|
+
if(typeof command!=='string'||/[\r\n`]|\$\(|[<>]\(|<<|[()]/.test(command))return null;
|
|
324
|
+
const parts=[];let quote=null,start=0,separator='';
|
|
325
|
+
for(let i=0;i<command.length;i++){
|
|
326
|
+
const c=command[i];
|
|
327
|
+
if(quote){if(c===quote)quote=null;else if(c==='\\'&"e==='"')i++;continue;}
|
|
328
|
+
if(c==='\\'){i++;continue;}
|
|
329
|
+
if(c==="'"||c==='"'){quote=c;continue;}
|
|
330
|
+
const two=command.slice(i,i+2);
|
|
331
|
+
if(two==='&&'||two==='||'||c===';'){
|
|
332
|
+
parts.push({separator,text:command.slice(start,i)});separator=c===';'?';':two;
|
|
333
|
+
start=i+(c===';'?1:2);if(c!==';')i++;continue;}
|
|
334
|
+
// A lone & runs a job in the background; >&, &> and |& redirect.
|
|
335
|
+
if(c==='&'&&command[i-1]!=='>'&&command[i+1]!=='>')return null;
|
|
336
|
+
if(two==='|&')return null;
|
|
337
|
+
}
|
|
338
|
+
if(quote)return null;
|
|
339
|
+
parts.push({separator,text:command.slice(start)});
|
|
340
|
+
return parts.every(part=>part.text.trim())?parts:null;
|
|
341
|
+
}
|
|
342
|
+
// A simple chain with each check in it run through REDUE when its turn comes:
|
|
343
|
+
// `redue run CHECK --if-due` decides then, after earlier parts may have
|
|
344
|
+
// changed files. Null when the command is not such a chain or runs no check.
|
|
345
|
+
// The rest of the chain is unchanged, and so is its output and exit status
|
|
346
|
+
// when a check runs.
|
|
347
|
+
// Shell builtins that change the shell for the parts after them.
|
|
348
|
+
const shellBuiltins=new Set(['cd','pushd','popd','export','unset','set','source','.','eval','exec','alias','unalias','shopt',
|
|
349
|
+
'ulimit','umask','trap','builtin','command','declare','typeset','local','readonly','hash','enable','let','read','wait']);
|
|
350
|
+
// A part that cannot change the directory or environment of the parts after
|
|
351
|
+
// it: a program found on PATH (or by path), not a builtin, function or bare
|
|
352
|
+
// assignment.
|
|
353
|
+
function plainProgram(body){
|
|
354
|
+
const words=body.split(/\s+/).filter(Boolean);
|
|
355
|
+
while(words.length&&/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))words.shift();
|
|
356
|
+
const word=words[0];
|
|
357
|
+
return Boolean(word)&&!shellBuiltins.has(word)&&/^[A-Za-z0-9_./+@-]+$/.test(word)&&(word.includes('/')||Boolean(findExecutable(word)));
|
|
358
|
+
}
|
|
359
|
+
// A simple chain with each check in it run through REDUE when its turn comes:
|
|
360
|
+
// `redue run CHECK --if-due` decides then, after earlier parts may have
|
|
361
|
+
// changed files. {command, names}, or null when the command is not such a
|
|
362
|
+
// chain or runs no check. The rest of the chain is unchanged, and so are its
|
|
363
|
+
// output and exit status when a check runs. Every part before a check must be
|
|
364
|
+
// a plain program, so the check runs where and as it would have.
|
|
365
|
+
export function chainCommand(command,config,configFile,dir='.',scripts={},invoke='redue',runConfig=configFile,projectRoot=null){
|
|
366
|
+
const parts=chainParts(command);
|
|
367
|
+
if(!parts||parts.length<2)return null;
|
|
368
|
+
const redue=redueFor(runConfig)?.replace(/^redue/,()=>invoke);
|
|
369
|
+
if(!redue)return null;
|
|
370
|
+
const names=[];let plain=true;
|
|
371
|
+
const text=parts.map(({separator,text})=>{
|
|
372
|
+
const lead=text.match(/^\s*/)[0],trail=text.match(/\s*$/)[0],body=text.trim();
|
|
373
|
+
const match=matched(body,config,configFile,dir,scripts,projectRoot),split_=split(body);
|
|
374
|
+
// A compiler script is its checks in the order of its runs.
|
|
375
|
+
const checks=match?.check?[match.check]:match?.row&&projectRoot?scriptChecks(match.row,config,projectRoot):null;
|
|
376
|
+
if(!checks?.length||match.rest!==null||!split_||!checks.every(name=>safe.test(name))){
|
|
377
|
+
plain&&=plainProgram(body);return separator+text;}
|
|
378
|
+
if(!plain)return null;
|
|
379
|
+
names.push(...checks);
|
|
380
|
+
// Grouped, so a separator before it applies to all its runs.
|
|
381
|
+
const runs=checks.map(name=>`${match.wrapper||''}${redue} run ${name} --if-due`).join(' && ');
|
|
382
|
+
return `${separator}${lead}${checks.length>1?`{ ${runs}; }`:runs}${split_.suffix}${trail}`;
|
|
383
|
+
});
|
|
384
|
+
return names.length&&!text.includes(null)?{command:text.join(''),names}:null;
|
|
385
|
+
}
|
|
386
|
+
export function matchCheck(command,config,configFile,dir,scripts,projectRoot=null){
|
|
387
|
+
const match=matched(command,config,configFile,dir,scripts,projectRoot);
|
|
388
|
+
return match?.row?match.row.checks.join(' && '):match?.check||null;
|
|
389
|
+
}
|
|
390
|
+
// The same check (or checks, in order) through REDUE, keeping the output
|
|
391
|
+
// suffix; null if it already is, or if more commands follow it.
|
|
392
|
+
// A `uv run` or `poetry run` wrapper stays in front, so the environment syncs first.
|
|
393
|
+
export function recordedCommand(command,name,configFile=defaultConfig,invoke='redue',wrapper=''){
|
|
394
|
+
const names=Array.isArray(name)?name:[name];
|
|
395
|
+
const parts=split(command),redue=redueFor(configFile)?.replace(/^redue/,()=>invoke);
|
|
396
|
+
if(!parts||parts.rest!==null||!names.length||!names.every(value=>safe.test(value))||!redue)return null;
|
|
397
|
+
const run=names.map(value=>`${wrapper}${redue} run ${value}`).join(' && ');
|
|
398
|
+
return parts.base===run?null:names.length>1&&parts.suffix?`(${run})${parts.suffix}`:run+parts.suffix;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// A leading `cd DIR;` or `cd DIR &&`, which agents often add before a check:
|
|
402
|
+
// {prefix, command, dir} with the rest of the command and DIR relative to the
|
|
403
|
+
// project root ('.' for the root or no prefix), or null when DIR is outside
|
|
404
|
+
// the project or missing. DIR may be quoted; it may not use variables, a leading
|
|
405
|
+
// `~` or escapes. Inside double quotes a backslash before anything but
|
|
406
|
+
// $ ` " \ is literal, so a quoted Windows path matches; unquoted it is an escape.
|
|
407
|
+
const cdPrefix=/^[ \t]*cd[ \t]+(?:"((?:[^"$`\\]|\\(?![$`"\\\n]))*)"|'([^']*)'|((?!~)[^\s;&|'"$`\\]+))[ \t]*(?:;|&&)[ \t]*/;
|
|
408
|
+
export function enteredDir(command,cwd,projectRoot){
|
|
409
|
+
const prefix=typeof command==='string'?cdPrefix.exec(command):null;
|
|
410
|
+
if(!prefix)return {prefix:'',command,dir:'.'};
|
|
411
|
+
let relative;
|
|
412
|
+
try{relative=path.relative(projectRoot,realObservedPath(path.resolve(cwd,prefix[1]??prefix[2]??prefix[3])));}catch{return null;}
|
|
413
|
+
if(relative.split(path.sep)[0]==='..'||path.isAbsolute(relative))return null;
|
|
414
|
+
return {prefix:prefix[0],command:command.slice(prefix[0].length),dir:relative?relative.split(path.sep).join('/'):'.'};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// The fan-out's skip message when every check it runs is CURRENT/PASS, else null.
|
|
418
|
+
export function fanoutVerdict(status,names,rest=null){
|
|
419
|
+
if(!names.length||!names.every(name=>verdict(status,name)))return null;
|
|
420
|
+
return `REDUE: all ${names.length} checks this command runs (${names.join(', ')}) passed on these exact inputs; `+
|
|
421
|
+
'rerun skipped. This counts as a pass for the current files; no status check is needed. '+
|
|
422
|
+
'To run it anyway, prefix the command with REDUE_FORCE=1.'+
|
|
423
|
+
(rest===null?'':` Nothing else in the command ran either; run the rest on its own: ${rest}`);
|
|
424
|
+
}
|
|
425
|
+
export function verdict(status,name,rest=null){
|
|
426
|
+
const row=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)?
|
|
427
|
+
status.checks.find(item=>item?.name===name):null;
|
|
428
|
+
const runId=row?.invocation?.runId;
|
|
429
|
+
if(!row||row.freshness!=='CURRENT'||row.result!=='PASS'||row.reuse_eligible!==true||
|
|
430
|
+
typeof runId!=='string'||!/^[0-9a-f-]{36}$/.test(runId))return null;
|
|
431
|
+
const recipe=recipeLabel(row.recipe);
|
|
432
|
+
return `REDUE: check ${JSON.stringify(name)} passed on these exact inputs (run ${runId}${recipe?`, ${recipe}`:''}); `+
|
|
433
|
+
'rerun skipped. This counts as a pass for the current files; no status check is needed. '+
|
|
434
|
+
'To run it anyway, prefix the command with REDUE_FORCE=1.'+
|
|
435
|
+
(rest===null?'':` Nothing else in the command ran either; run the rest on its own: ${rest}`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// The status answer, synchronized unless `sync` is false (the cached one).
|
|
439
|
+
function synchronizedStatus(entry,config,root,timeout,sync=true){
|
|
440
|
+
return new Promise(resolve=>{
|
|
441
|
+
let child,out='',done=false;
|
|
442
|
+
const finish=value=>{if(done)return;done=true;clearTimeout(timer);process.off('SIGTERM',stop);
|
|
443
|
+
if(child?.pid&&child.exitCode===null)try{process.platform==='win32'?child.kill('SIGKILL'):process.kill(-child.pid,'SIGKILL');}catch{}
|
|
444
|
+
resolve(value);};
|
|
445
|
+
const timer=setTimeout(()=>finish(null),timeout);
|
|
446
|
+
// Claude Code ends a hook that outlives its own timeout; end the status call with it.
|
|
447
|
+
const stop=()=>finish(null);process.once('SIGTERM',stop);
|
|
448
|
+
// Its own process group on POSIX, so a timeout also ends the engine it starts.
|
|
449
|
+
try{child=spawn(process.execPath,[entry,'--config',config,'status',sync?'--sync':'--cached','--json'],
|
|
450
|
+
{cwd:root,stdio:['ignore','pipe','ignore'],env:process.env,windowsHide:true,detached:process.platform!=='win32'});}
|
|
451
|
+
catch{return finish(null);}
|
|
452
|
+
child.on('error',()=>finish(null));
|
|
453
|
+
child.stdout.on('data',chunk=>{out+=chunk;if(out.length>4*1024*1024)finish(null);});
|
|
454
|
+
child.on('close',code=>{if(code!==0)return finish(null);
|
|
455
|
+
try{finish(JSON.parse(out));}catch{finish(null);}});
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Half the last completed run's duration, at least 2 s and at most 10 s; with
|
|
460
|
+
// no completed run, 2 s. A shorter wait misses a status call (70-250 ms here,
|
|
461
|
+
// over 1.5 s on a loaded CI or Windows runner) for a fast check, and the
|
|
462
|
+
// agent's run would go unrecorded.
|
|
463
|
+
export function statusTimeout(status,name){
|
|
464
|
+
const run=Array.isArray(status?.checks)?status.checks.find(row=>row?.name===name)?.invocation:null;
|
|
465
|
+
return run?.status==='exited'&&Number.isFinite(run.durationMs)&&run.durationMs>=0?
|
|
466
|
+
Math.min(Math.max(run.durationMs/2,2000),10000):2000;
|
|
467
|
+
}
|
|
468
|
+
// From the observer's last published status, which is small, not the receipts.
|
|
469
|
+
// The longest wait among `names`, read once.
|
|
470
|
+
export function lastRunTimeout(config,parsed,...names){
|
|
471
|
+
try{const root=realObservedPath(path.resolve(path.dirname(config),parsed.root||'.'));
|
|
472
|
+
const status=JSON.parse(fs.readFileSync(path.join(projectState(root,config),'status.json'),'utf8'));
|
|
473
|
+
return Math.max(...names.map(name=>statusTimeout(status,name)));}
|
|
474
|
+
catch{return 2000;}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// The project set up in the session's directory: its setup record, the config
|
|
478
|
+
// beside it and the project root. null when there is none; throws on bad files.
|
|
479
|
+
function hookProject(root){
|
|
480
|
+
if(typeof root!=='string'||!path.isAbsolute(root))return null;
|
|
481
|
+
const record=JSON.parse(fs.readFileSync(path.join(root,'.redue','agents','claude.json'),'utf8'));
|
|
482
|
+
if(record?.schema!==1||record.host!=='claude'||typeof record.config!=='string'||
|
|
483
|
+
!/^[^/\\]+\.json$/.test(record.config))return null;
|
|
484
|
+
const config=path.join(root,record.config);
|
|
485
|
+
const parsed=JSON.parse(fs.readFileSync(config,'utf8'));
|
|
486
|
+
return {record,config,parsed,projectRoot:realObservedPath(path.resolve(path.dirname(config),parsed.root||'.'))};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// SessionStart: start the observer when none is running, so the session's
|
|
490
|
+
// checks can be skipped or recorded without a manual `redue start`. It
|
|
491
|
+
// launches `redue start` detached and returns at once, and prints nothing:
|
|
492
|
+
// SessionStart output becomes agent context. Returns what it did.
|
|
493
|
+
export function sessionStart(input,{entry}){
|
|
494
|
+
const project=hookProject(input?.cwd);
|
|
495
|
+
if(!project)return null;
|
|
496
|
+
try{const pid=Number(fs.readFileSync(path.join(projectState(project.projectRoot,project.config),'observer.lock','pid'),'utf8'));
|
|
497
|
+
if(Number.isSafeInteger(pid)&&pid>0&&processAlive(pid))return 'running';}catch{}
|
|
498
|
+
const child=spawn(process.execPath,[entry,'--config',project.config,'start'],
|
|
499
|
+
{cwd:input.cwd,detached:true,stdio:'ignore',windowsHide:true,env:process.env});
|
|
500
|
+
child.on('error',()=>{});child.unref();
|
|
501
|
+
return 'started';
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Returns the hook's stdout object, or null to allow the command.
|
|
505
|
+
// `explain(text)` hears why, also when the command is left as it is.
|
|
506
|
+
export async function claudeHook(input,{entry,timeout,invoke=null,explain=null}){
|
|
507
|
+
// A failing explain never changes the decision.
|
|
508
|
+
const why=(text,value=null)=>{try{explain?.(text);}catch{}return value;};
|
|
509
|
+
if(input?.tool_name!=='Bash')return why('not a Bash command');
|
|
510
|
+
const command=input.tool_input?.command,root=input.cwd;
|
|
511
|
+
if(typeof command!=='string')return why('no command');
|
|
512
|
+
if(/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return why('REDUE_FORCE=1');
|
|
513
|
+
let project;
|
|
514
|
+
try{project=hookProject(root);}catch(error){return why(`no readable setup record in this directory (${error.code??error.message})`);}
|
|
515
|
+
if(!project)return why('REDUE is not set up for Claude Code in this directory');
|
|
516
|
+
// What a status answer said about `names`, when the hook does not skip.
|
|
517
|
+
const said=(status,names,waited)=>!status?`status did not answer within ${waited} ms`:
|
|
518
|
+
status.observation?.healthy!==true?'the observer is not healthy':
|
|
519
|
+
names.map(name=>{const row=Array.isArray(status.checks)?status.checks.find(item=>item?.name===name):null;
|
|
520
|
+
return !row?`${name} not in status`:`${name} ${row.freshness}/${row.result??'-'}${row.freshness==='CURRENT'&&row.result==='PASS'?
|
|
521
|
+
` but not reusable (reuse_eligible ${row.reuse_eligible}, run ${row.invocation?.runId??'none'})`:row.reason?` (${row.reason})`:''}`;}).join('; ');
|
|
522
|
+
const {record,config,parsed}=project;
|
|
523
|
+
// For `redue stats`: the checks a decision skipped, or the ones that were due.
|
|
524
|
+
const tally=(status,names,skipped)=>{let state;try{state=projectState(project.projectRoot,config);}catch{return;}
|
|
525
|
+
for(const name of names){const entry=skipped?skipNote(status,name,'hook'):dueNote(status,name);if(entry)note(state,entry);}};
|
|
526
|
+
const entered=enteredDir(command,root,project.projectRoot);
|
|
527
|
+
if(!entered)return why('the command enters a directory outside the project');
|
|
528
|
+
let scripts={};
|
|
529
|
+
if(entered.dir==='.')try{scripts=JSON.parse(fs.readFileSync(path.join(project.projectRoot,'package.json'),'utf8')).scripts||{};}catch{}
|
|
530
|
+
// The configured config path as the command's directory sees it.
|
|
531
|
+
// Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
|
|
532
|
+
const configPath=!entered.prefix?record.config:
|
|
533
|
+
path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/');
|
|
534
|
+
// A simple chain: each check in it runs through REDUE when its turn comes,
|
|
535
|
+
// after the parts before it (an edit) have run. The command is changed,
|
|
536
|
+
// not approved: Claude Code's permission rules apply to it as rewritten.
|
|
537
|
+
// A chain with no check of its own (a leading fan-out) goes on below.
|
|
538
|
+
// Rewritten only on a healthy answer: without an observer, redue run
|
|
539
|
+
// cannot run the check, and the chain would not run as written.
|
|
540
|
+
// The observer's cached health is enough here: each check asks again when it runs.
|
|
541
|
+
const chained=chainCommand(entered.command,parsed,record.config,entered.dir,scripts,invoke??redueInvocation(entry),configPath,project.projectRoot);
|
|
542
|
+
if(chained){
|
|
543
|
+
const wait=timeout??5000,cached=await synchronizedStatus(entry,config,root,wait,false);
|
|
544
|
+
return cached?.observation?.healthy===true&&chained.names.every(name=>cached.checks?.some(row=>row?.name===name))?
|
|
545
|
+
why(`a chain: ${chained.names.join(', ')} through redue run`,{hookSpecificOutput:{hookEventName:'PreToolUse',updatedInput:{...input.tool_input,command:entered.prefix+chained.command}}}):
|
|
546
|
+
why(`a chain left as written: ${said(cached,chained.names,wait)}`);
|
|
547
|
+
}
|
|
548
|
+
const match=matched(entered.command,parsed,record.config,entered.dir,scripts,project.projectRoot);
|
|
549
|
+
if(!match)return why('no check matches the command');
|
|
550
|
+
// A check followed by more than a simple chain runs untouched.
|
|
551
|
+
if(match.rest!==null&&!match.fanout)return why('more commands follow the check');
|
|
552
|
+
// turbo, nx and `pnpm -r` cache their own results: skip only when every
|
|
553
|
+
// check the command runs is CURRENT, and otherwise leave it alone.
|
|
554
|
+
if(match.fanout){
|
|
555
|
+
const names=await fanoutChecks(match.fanout,parsed,project.projectRoot);
|
|
556
|
+
if(!names)return why('a fan-out whose checks are not all configured');
|
|
557
|
+
const wait=timeout??lastRunTimeout(config,parsed,...names),status=await synchronizedStatus(entry,config,root,wait),
|
|
558
|
+
reason=fanoutVerdict(status,names,match.rest);
|
|
559
|
+
tally(status,names,Boolean(reason));
|
|
560
|
+
return reason?why('every check CURRENT/PASS',{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}):
|
|
561
|
+
why(`a fan-out left to run: ${said(status,names,wait)}`);
|
|
562
|
+
}
|
|
563
|
+
// A compiler script: its checks in the order of its runs, or nothing when
|
|
564
|
+
// they no longer correspond. One run is an ordinary check. A chain is
|
|
565
|
+
// skipped when every run is CURRENT, else the runs that are not run through
|
|
566
|
+
// redue run, in order.
|
|
567
|
+
if(match.row){
|
|
568
|
+
const names=scriptChecks(match.row,parsed,project.projectRoot);
|
|
569
|
+
if(!names)return why('the script no longer runs its configured checks');
|
|
570
|
+
if(names.length===1)match.check=names[0];
|
|
571
|
+
else{
|
|
572
|
+
const wait=timeout??lastRunTimeout(config,parsed,...names),status=await synchronizedStatus(entry,config,root,wait),
|
|
573
|
+
reason=fanoutVerdict(status,names,match.rest);
|
|
574
|
+
tally(status,names,Boolean(reason));
|
|
575
|
+
if(reason)return why('every check CURRENT/PASS',{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}});
|
|
576
|
+
const pending=names.filter(name=>!verdict(status,name));
|
|
577
|
+
const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
|
|
578
|
+
names.every(name=>status.checks.some(item=>item?.name===name))?recordedCommand(entered.command,pending,configPath,invoke??redueInvocation(entry)):null;
|
|
579
|
+
return why(`${rewritten?'through redue run':'left as written'}: ${said(status,names,wait)}`,rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
|
580
|
+
permissionDecisionReason:`REDUE: ${pending.map(name=>JSON.stringify(name)).join(', ')} of this script's checks ${pending.length===1?'is':'are'} not CURRENT; running ${pending.length===1?'it':'them'} through redue run so the result is recorded.`,
|
|
581
|
+
updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const name=match.check,wait=timeout??lastRunTimeout(config,parsed,name),status=await synchronizedStatus(entry,config,root,wait),
|
|
585
|
+
reason=verdict(status,name,match.rest);
|
|
586
|
+
tally(status,[name],Boolean(reason));
|
|
587
|
+
if(reason)return why(`${name} CURRENT/PASS`,{hookSpecificOutput:{hookEventName:'PreToolUse',
|
|
588
|
+
permissionDecision:'deny',permissionDecisionReason:reason}});
|
|
589
|
+
// Rewrite only on a healthy answer for this check: without an observer,
|
|
590
|
+
// `redue run` cannot record, so the command stays as Claude sent it. A chain
|
|
591
|
+
// stays too: the rewrite returns "allow", which would also skip the prompt
|
|
592
|
+
// for the rest of the chain.
|
|
593
|
+
const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
|
|
594
|
+
status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath,invoke??redueInvocation(entry),match.wrapper):null;
|
|
595
|
+
// updatedInput replaces the whole tool input. Deny and ask rules still apply
|
|
596
|
+
// to the rewritten command; "allow" skips only the prompt.
|
|
597
|
+
return why(`${rewritten?'through redue run':'left as written'}: ${said(status,[name],wait)}`,rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
|
598
|
+
permissionDecisionReason:`REDUE: check ${JSON.stringify(name)} is not CURRENT; running it through redue run so the result is recorded.`,
|
|
599
|
+
updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export async function runClaudeHook(entry){
|
|
603
|
+
try{
|
|
604
|
+
let text='';process.stdin.setEncoding('utf8');
|
|
605
|
+
for await(const chunk of process.stdin){text+=chunk;if(text.length>1024*1024)return;}
|
|
606
|
+
const input=JSON.parse(text);
|
|
607
|
+
if(input?.hook_event_name==='SessionStart'){sessionStart(input,{entry});return;}
|
|
608
|
+
// REDUE_HOOK_LOG (an absolute file): one JSON line per decision, with why.
|
|
609
|
+
const log=process.env.REDUE_HOOK_LOG,started=Date.now();let why=null;
|
|
610
|
+
const output=await claudeHook(input,{entry,explain:text=>{why=text;}});
|
|
611
|
+
if(output)await new Promise(resolve=>process.stdout.write(JSON.stringify(output)+'\n',resolve));
|
|
612
|
+
if(log&&path.isAbsolute(log))try{fs.appendFileSync(log,JSON.stringify({at:started,ms:Date.now()-started,cwd:input?.cwd??null,
|
|
613
|
+
command:input?.tool_input?.command??null,decision:!output?'allow':output.hookSpecificOutput?.updatedInput?'rewrite':output.hookSpecificOutput?.permissionDecision??'allow',
|
|
614
|
+
why})+'\n');}catch{}
|
|
615
|
+
}catch{}
|
|
616
|
+
}
|