@redue/cli 0.1.0-alpha.2 → 0.1.0-alpha.20

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/README.md +107 -2
  3. package/bin/redue.mjs +130 -49
  4. package/docs/README.md +2 -1
  5. package/docs/user/agents.md +299 -15
  6. package/docs/user/configuration.md +28 -2
  7. package/docs/user/direct-typescript.md +209 -0
  8. package/docs/user/getting-started.md +23 -2
  9. package/docs/user/json.md +2 -0
  10. package/docs/user/node-script.md +153 -0
  11. package/docs/user/npm-launcher-notice.md +42 -0
  12. package/docs/user/pilot.md +146 -0
  13. package/docs/user/receipt-selection-notice.md +60 -0
  14. package/docs/user/supported-projects.md +49 -21
  15. package/docs/user/tests-direct.md +132 -0
  16. package/package.json +4 -3
  17. package/src/agent-integration.mjs +388 -0
  18. package/src/agent-policy.md +28 -0
  19. package/src/cached-state.mjs +46 -8
  20. package/src/claude-hook.mjs +449 -0
  21. package/src/cli.mjs +263 -40
  22. package/src/daemon.mjs +433 -94
  23. package/src/decision-profile.mjs +15 -1
  24. package/src/decision-validation.mjs +10 -0
  25. package/src/declared-plan.mjs +29 -6
  26. package/src/direct-run-certificate.mjs +252 -0
  27. package/src/direct-typescript-probe.mjs +24 -0
  28. package/src/direct-typescript.mjs +185 -0
  29. package/src/glob.mjs +8 -0
  30. package/src/history-worker.mjs +21 -13
  31. package/src/index.mjs +72 -19
  32. package/src/installed-inputs.mjs +7 -1
  33. package/src/linux-inotify.py +39 -10
  34. package/src/node-onboarding.mjs +157 -48
  35. package/src/node-script.mjs +362 -0
  36. package/src/npm-integrity.mjs +124 -0
  37. package/src/npm-typecheck-environment.mjs +36 -0
  38. package/src/npmrc.mjs +16 -0
  39. package/src/observation.mjs +34 -12
  40. package/src/path-identity.mjs +8 -0
  41. package/src/plan-worker.mjs +17 -17
  42. package/src/pnpm-inputs.mjs +114 -54
  43. package/src/pnpm-shim-templates.json +180 -0
  44. package/src/pnpm-shims.mjs +54 -0
  45. package/src/pnpm-typecheck-probe.mjs +11 -6
  46. package/src/presentation.mjs +9 -1
  47. package/src/probe-deadline.mjs +37 -0
  48. package/src/probe-result.mjs +7 -3
  49. package/src/project-location.mjs +34 -0
  50. package/src/run-recorder.cjs +91 -0
  51. package/src/state-store.mjs +37 -1
  52. package/src/test-direct.mjs +560 -0
  53. package/src/test-recorder.cjs +406 -0
  54. package/src/typescript-contract-probe.mjs +4 -187
  55. package/src/typescript-input-facts.mjs +92 -0
  56. package/src/typescript-invocation.mjs +92 -0
  57. package/src/typescript-list.mjs +108 -13
  58. package/src/typescript-qualification.mjs +29 -49
  59. package/src/workspace-manifests.mjs +22 -0
  60. package/src/yarn-workspace-typecheck-probe.mjs +2 -1
@@ -0,0 +1,449 @@
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} 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
+
16
+ export const hookCommand='redue agent hook claude';
17
+ // No shell metacharacters; `@` starts scoped package names.
18
+ const safe=/^[A-Za-z0-9_./:=,+@-]+$/;
19
+ const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
20
+ // A direct test check: its command is the runner and the script's arguments.
21
+ const tests=check=>check?.qualification==='npm-tests-direct-v1'&&Array.isArray(check.command)&&
22
+ /^@test-runner:(?:vitest|jest)$/.test(check.command[1]||'');
23
+ // A general recipe check: a root typecheck, test or lint script run through the package manager.
24
+ const general=check=>check?.qualification==='node-script-v1'&&['typecheck','test','lint'].includes(check.kind)&&
25
+ typeof check.script==='string';
26
+ const eligibleCheck=check=>typecheck(check)||tests(check)||general(check);
27
+ export const checkKind=check=>tests(check)?'test':general(check)?check.kind:'typecheck';
28
+
29
+ export const defaultConfig='redue.config.json';
30
+ // The `redue` prefix for a config beside the project root; null when the name
31
+ // would need shell quoting.
32
+ export const redueFor=(configFile=defaultConfig)=>configFile===defaultConfig?'redue':
33
+ safe.test(configFile)?`redue --config ${configFile}`:null;
34
+
35
+ // Package-manager commands that a script of the same name would not reach.
36
+ const builtins={pnpm:new Set(['add','audit','bin','config','create','dedupe','deploy','dlx','doctor','env','exec','fetch',
37
+ 'i','import','init','install','link','list','ln','ls','outdated','pack','patch','prune','publish','rebuild','recursive',
38
+ 'remove','rm','root','run','self-update','server','setup','store','uninstall','unlink','up','update','why','x']),
39
+ // Yarn 4 and Yarn 1 together.
40
+ yarn:new Set(['access','add','audit','autoclean','bin','cache','check','config','constraints','create','dedupe','dir','dlx',
41
+ 'exec','explain','generate-lock-entry','global','help','import','info','init','install','licenses','link','list','login',
42
+ 'logout','node','npm','outdated','owner','pack','patch','plugin','policies','publish','rebuild','remove','run','set',
43
+ 'stage','tag','team','unlink','unplug','up','upgrade','upgrade-interactive','version','versions','why','workspace','workspaces'])};
44
+ // A package script run by the project's package manager, in the package's directory.
45
+ function scriptForms(pm,script){
46
+ if(typeof script!=='string'||!safe.test(script))return [];
47
+ if(pm==='npm')return [`npm run ${script}`,...(script==='test'?['npm test']:[])];
48
+ if(pm==='pnpm'||pm==='yarn')return [...(builtins[pm].has(script)?[]:[`${pm} ${script}`]),`${pm} run ${script}`];
49
+ return [];
50
+ }
51
+ // A workspace package's script, run from the project root.
52
+ function workspaceForms(pm,name,script){
53
+ if(typeof name!=='string'||!safe.test(name)||typeof script!=='string'||!safe.test(script))return [];
54
+ if(pm==='pnpm')return ['--filter ','--filter=','-F '].flatMap(flag=>
55
+ [...(builtins.pnpm.has(script)?[]:[`pnpm ${flag}${name} ${script}`]),`pnpm ${flag}${name} run ${script}`]);
56
+ if(pm==='yarn')return [...(builtins.yarn.has(script)?[]:[`yarn workspace ${name} ${script}`]),`yarn workspace ${name} run ${script}`];
57
+ if(pm==='npm')return [`npm run ${script} -w ${name}`,`npm run ${script} --workspace ${name}`,
58
+ `npm run ${script} --workspace=${name}`,`npm -w ${name} run ${script}`];
59
+ return [];
60
+ }
61
+ // The tool a check runs, as typed: `tsc ARGS` or the test runner's command;
62
+ // for a general check, its script when the script is one plain command.
63
+ function toolText(check,scripts){
64
+ if(general(check)){const text=typeof scripts?.[check.script]==='string'?scripts[check.script].trim().replace(/\s+/g,' '):'';
65
+ return safe.test(text.replace(/ /g,''))&&!/^(?:npm|npx|pnpm|pnpx|yarn|node)(?: |$)/.test(text)&&
66
+ !scripts['pre'+check.script]&&!scripts['post'+check.script]?text:null;}
67
+ const command=check.command;
68
+ if(!Array.isArray(command)||command[0]!=='@node')return null;
69
+ const words=/^@typescript-(?:compiler|bin):/.test(command[1]||'')?['tsc',...command.slice(2)]:
70
+ tests(check)?[command[1].slice('@test-runner:'.length),...command.slice(2)]:null;
71
+ return words?.every(word=>typeof word==='string'&&safe.test(word))?words.join(' '):null;
72
+ }
73
+ // A bare `tsc` is rarely on PATH; a test runner's own text is what the script says.
74
+ const toolForms=(pm,text)=>!text?[]:[...(/^tsc(?: |$)/.test(text)?[]:[text]),`npx ${text}`,...(pm==='pnpm'?[`pnpm exec ${text}`]:[])];
75
+ // A workspace check's package: its `workspace`, or the package its generated
76
+ // `npm run S --workspace W`, `yarn workspace W run S` or `pnpm --filter W run S`
77
+ // command names; null for any other check.
78
+ function workspaceName(check){
79
+ if(typeof check.workspace==='string')return check.workspace;
80
+ const command=check.command,flag={'@which:npm':'--workspace','@which:yarn':'workspace','@which:pnpm':'--filter'}[command?.[0]];
81
+ const at=flag&&Array.isArray(command)?command.indexOf(flag):-1;
82
+ return at>0&&typeof command[at+1]==='string'?command[at+1]:null;
83
+ }
84
+ // Where a check runs: '.' for a root check, the package directory for a
85
+ // workspace check, or null. A workspace check's directory is its `cwd`, or the
86
+ // one directory its inputs name as `DIR/package.json` or `DIR/**` with
87
+ // `!DIR/node_modules/**`, as init writes them.
88
+ export function checkDir(check){
89
+ if(!workspaceName(check))return check.cwd===undefined||check.cwd==='.'?'.':null;
90
+ if(typeof check.cwd==='string'&&check.cwd!=='.')return check.cwd;
91
+ const inputs=(check.inputs||[]).filter(value=>typeof value==='string');
92
+ const dirs=new Set(inputs.flatMap(value=>{
93
+ const manifest=/^([^!*]+)\/package\.json$/.exec(value)?.[1],tree=/^([^!*]+)\/\*\*$/.exec(value)?.[1];
94
+ return manifest?[manifest]:tree&&inputs.includes(`!${tree}/node_modules/**`)?[tree]:[];
95
+ }));
96
+ return dirs.size===1?[...dirs][0]:null;
97
+ }
98
+ // The package script a check runs: its `script`, or the last word of a
99
+ // generated `pnpm --filter NAME run SCRIPT`-style command.
100
+ const checkScript=check=>typeof check.script==='string'?check.script:
101
+ Array.isArray(check.command)&&/^@which:(?:npm|pnpm|yarn)$/.test(check.command[0])&&check.command.includes('run')?
102
+ check.command[check.command.indexOf('run')+1]:null;
103
+
104
+ // A typecheck check whose command is the compiler: its arguments are one tsc
105
+ // run of its script (typescript-invocation.mjs).
106
+ function tscCheck(check){return typecheck(check)&&/^tsc(?: |$)/.test(toolText(check)||'');}
107
+ // Exact commands that rerun each configured typecheck or direct test check.
108
+ // `forms` work from the project root; `local` ones work in the check's own
109
+ // directory `dir`, after a `cd` into it. Names that would need shell quoting
110
+ // get no form, so the hook never fires for them. The script forms of compiler
111
+ // checks name the script's checks as {checks, script}: the hook acts on them
112
+ // only when the script's tsc runs are those checks' runs, one each. With one
113
+ // check they stay on its row (`scriptForms`); a chain's checks get a row for
114
+ // the script, and their own rows (`part`) keep only their own forms.
115
+ export function hookForms(config,configFile=defaultConfig,packageScripts=null){
116
+ const rows=[],redue=redueFor(configFile),pm=config?.packageManager,scripts=new Map();
117
+ const eligible=(config?.checks||[]).filter(check=>eligibleCheck(check)&&typeof check.name==='string'&&safe.test(check.name));
118
+ const groupOf=check=>tscCheck(check)&&typeof checkScript(check)==='string'&&checkDir(check)!==null?
119
+ JSON.stringify([checkDir(check),checkScript(check),workspaceName(check)]):null;
120
+ for(const check of eligible){const key=groupOf(check);if(key)scripts.set(key,[...scripts.get(key)||[],check.name]);}
121
+ const scriptRow=(dir,script,workspace)=>({forms:dir==='.'?scriptForms(pm,script):dir!==null&&safe.test(dir)?workspaceForms(pm,workspace,script):[],
122
+ local:dir!=='.'&&dir!==null&&safe.test(dir)?scriptForms(pm,script):[]});
123
+ for(const check of eligible){
124
+ const forms=redue?[`${redue} run ${check.name}`]:[],local=[],dir=checkDir(check),key=groupOf(check),
125
+ group=key?scripts.get(key):null,part=group?.length>1;
126
+ const own=group?null:checkScript(check);
127
+ if(dir==='.'){
128
+ forms.push(...scriptForms(pm,own),...toolForms(pm,toolText(check,packageScripts)));
129
+ if(!tests(check)&&Array.isArray(check.command)&&check.command.length&&
130
+ check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
131
+ forms.push(check.command.join(' '));
132
+ }else if(dir!==null&&safe.test(dir)){
133
+ forms.push(...workspaceForms(pm,workspaceName(check),own));
134
+ local.push(...scriptForms(pm,own),...(check.cwd===dir?toolForms(pm,toolText(check)):[]));
135
+ }
136
+ let extra={};
137
+ if(group&&!part){
138
+ const {forms:scriptOnly,local:scriptLocal}=scriptRow(dir,checkScript(check),workspaceName(check));
139
+ forms.splice(redue?1:0,0,...scriptOnly);local.unshift(...scriptLocal);
140
+ extra={checks:group,script:checkScript(check),scriptForms:[...scriptOnly,...scriptLocal]};
141
+ }
142
+ if(part)extra={part:true};
143
+ if(forms.length||local.length)rows.push({check:check.name,kind:checkKind(check),dir,
144
+ forms:[...new Set(forms)],local:[...new Set(local)],...extra});
145
+ }
146
+ for(const [key,checks] of scripts){
147
+ if(checks.length<2)continue;
148
+ const [dir,script,workspace]=JSON.parse(key),{forms,local}=scriptRow(dir,script,workspace);
149
+ if(forms.length||local.length)rows.push({check:checks[0].replace(/\.\d+$/,''),checks,script,
150
+ scriptForms:[...forms,...local],kind:'typecheck',dir,forms,local});
151
+ }
152
+ return rows;
153
+ }
154
+ // The checks a compiler script row runs, in the order of the script's runs in
155
+ // the package's package.json now; null when they no longer correspond (the
156
+ // script changed since init), so the hook leaves the command alone.
157
+ export function scriptChecks(row,config,projectRoot){
158
+ let scripts;try{scripts=JSON.parse(fs.readFileSync(path.join(projectRoot,row.dir,'package.json'),'utf8')).scripts;}catch{return null;}
159
+ if(scripts?.['pre'+row.script]||scripts?.['post'+row.script])return null;
160
+ const args=name=>config.checks.find(check=>check.name===name)?.command?.slice(2);
161
+ return scriptRunChecks(scripts?.[row.script],row.checks.map(name=>({name,args:args(name)})));
162
+ }
163
+
164
+ // A turbo or `pnpm -r` command that runs one task in every workspace package:
165
+ // {tool, task}. No filters and no nx: both can add packages or tasks that only
166
+ // a fuller model of the tool would see. `pnpm turbo` is left out because a root
167
+ // script named turbo would run instead.
168
+ const turboPrefix='(?:npx |pnpm exec )?',task='([A-Za-z0-9_:.-]+)';
169
+ const fanouts=[['turbo',new RegExp(`^${turboPrefix}turbo(?: run)? ${task}$`)],
170
+ ['pnpm',new RegExp(`^pnpm (?:-r|--recursive)(?: run)? ${task}$`)]];
171
+ export function fanout(text){
172
+ for(const [tool,pattern] of fanouts){const match=pattern.exec(text);
173
+ if(match)return {tool,task:match[1]};}
174
+ return null;
175
+ }
176
+ // Bash `if` rules for fan-out commands of a task some workspace check runs,
177
+ // and for root scripts that run one; other turbo or nx commands never start the hook.
178
+ export function fanoutRules(config,scripts={}){
179
+ const tasks=new Set((config?.checks||[]).filter(check=>eligibleCheck(check)&&typeof check.name==='string'&&
180
+ ![null,'.'].includes(checkDir(check))).map(checkScript).filter(task=>typeof task==='string'&&/^[A-Za-z0-9_:.-]+$/.test(task)));
181
+ const rules=[];
182
+ for(const task of tasks){
183
+ for(const prefix of ['','npx ','pnpm exec '])rules.push(`${prefix}turbo run ${task}`,`${prefix}turbo ${task}`);
184
+ if(config.packageManager==='pnpm')rules.push(`pnpm -r ${task}`,`pnpm -r run ${task}`,`pnpm --recursive ${task}`,`pnpm --recursive run ${task}`);
185
+ }
186
+ for(const [name,text] of Object.entries(scripts||{})){
187
+ const fan=typeof text==='string'&&!scripts['pre'+name]&&!scripts['post'+name]?fanout(text.trim()):null;
188
+ if(fan&&tasks.has(fan.task))rules.push(...scriptForms(config.packageManager,name));
189
+ }
190
+ return rules;
191
+ }
192
+ // The checks a fan-out runs: one configured check for every workspace package
193
+ // with the task's script, or null when any package has none, or when the
194
+ // tool may run more: another task first (turbo `dependsOn` other than ^TASK,
195
+ // `with`, package-scoped keys, package turbo.json), a task turbo does not
196
+ // define, or pnpm's workspace root.
197
+ export async function fanoutChecks(fan,config,projectRoot){
198
+ const {workspacePackages}=await import('./node-onboarding.mjs');
199
+ const packages=workspacePackages(projectRoot).filter(item=>typeof item.scripts[fan.task]==='string');
200
+ if(!packages.length)return null;
201
+ const text=file=>{try{return fs.readFileSync(path.join(projectRoot,file),'utf8');}catch(e){return e.code==='ENOENT'?undefined:null;}};
202
+ if(fan.tool==='turbo'){
203
+ let tasks;try{const turbo=JSON.parse(text('turbo.json'));tasks=turbo?.tasks??turbo?.pipeline;}catch{return null;}
204
+ const own=tasks?.[fan.task];
205
+ if(!own||typeof own!=='object'||own.with!==undefined||
206
+ own.dependsOn!==undefined&&!(Array.isArray(own.dependsOn)&&own.dependsOn.every(item=>item===`^${fan.task}`))||
207
+ Object.keys(tasks).some(key=>key.endsWith(`#${fan.task}`))||
208
+ packages.some(item=>text(`${item.dir}/turbo.json`)!==undefined||text(`${item.dir}/turbo.jsonc`)!==undefined))return null;
209
+ }
210
+ if(fan.tool==='pnpm'&&(config.packageManager!=='pnpm'||
211
+ ['.npmrc','pnpm-workspace.yaml'].some(file=>{const value=text(file);return value===null||/include-?workspace-?root/i.test(value??'');})))
212
+ return null;
213
+ const names=[];
214
+ for(const item of packages){
215
+ const own=config.checks.filter(c=>eligibleCheck(c)&&typeof c.name==='string'&&
216
+ workspaceName(c)===item.name&&checkScript(c)===fan.task&&checkDir(c)===item.dir);
217
+ if(!own.length)return null;
218
+ // A compiler script's checks: one per tsc run it has now.
219
+ if(own.every(tscCheck)){
220
+ const ordered=scriptRunChecks(item.scripts[fan.task],own.map(c=>({name:c.name,args:c.command.slice(2)})));
221
+ if(!ordered)return null;
222
+ names.push(...ordered);continue;
223
+ }
224
+ if(own.length!==1)return null;
225
+ names.push(own[0].name);
226
+ }
227
+ return names;
228
+ }
229
+
230
+ // Accept only an optional trailing `2>&1`, then an optional `| tail|head -n N`
231
+ // or `-N`, then optionally `;` or `&&` and the rest of the command. A form has
232
+ // no shell metacharacters, so the first separator after it ends it. Newlines
233
+ // separate commands, so they never match. The rest keeps its own spacing.
234
+ function split(command){
235
+ if(typeof command!=='string'||/[\r\n]/.test(command))return null;
236
+ const [,base,suffix,rest=null]=command.trim()
237
+ .match(/^(.*?)((?:[ \t]+2>&1)?(?:[ \t]+\|[ \t]+(?:tail|head)[ \t]+(?:-n[ \t]+\d+|-\d+))?)(?:[ \t]*(?:;|&&)[ \t]*([^\s;&|].*))?$/);
238
+ const one=text=>text.replace(/[ \t]+/g,' ');
239
+ return {base:one(base),suffix:one(suffix),rest};
240
+ }
241
+ // {check, rest}, or {fanout, rest} for a turbo/nx/`pnpm -r` command or a root
242
+ // script that runs one; null when nothing matches. `dir` is where the command
243
+ // runs, relative to the project root.
244
+ function matched(command,config,configFile,dir='.',scripts={}){
245
+ const parts=split(command);
246
+ if(!parts)return null;
247
+ const row=hookForms(config,configFile,dir==='.'?scripts:null).find(row=>dir==='.'?row.forms.includes(parts.base):
248
+ row.dir===dir&&row.local.includes(parts.base));
249
+ // A root script that a general check runs is that check, even when the script fans out.
250
+ if(dir==='.'&&row&&general(config.checks.find(check=>check.name===row.check)))return {check:row.check,rest:parts.rest};
251
+ if(dir==='.'){
252
+ // A root script that runs a fan-out is that fan-out, unless pre/post scripts
253
+ // run around it: then it matches nothing.
254
+ for(const [name,text] of Object.entries(scripts||{}))
255
+ if(typeof text==='string'&&scriptForms(config.packageManager,name).includes(parts.base)&&fanout(text.trim()))
256
+ return scripts['pre'+name]||scripts['post'+name]?null:{fanout:fanout(text.trim()),rest:parts.rest};
257
+ if(fanout(parts.base))return {fanout:fanout(parts.base),rest:parts.rest};
258
+ }
259
+ // A script form of compiler checks is checked against the script first.
260
+ return row?.scriptForms?.includes(parts.base)?{row,rest:parts.rest}:row?{check:row.check,rest:parts.rest}:null;
261
+ }
262
+ export function matchCheck(command,config,configFile,dir,scripts){
263
+ const match=matched(command,config,configFile,dir,scripts);
264
+ return match?.row?match.row.checks.join(' && '):match?.check||null;
265
+ }
266
+ // The same check (or checks, in order) through REDUE, keeping the output
267
+ // suffix; null if it already is, or if more commands follow it.
268
+ export function recordedCommand(command,name,configFile=defaultConfig){
269
+ const names=Array.isArray(name)?name:[name];
270
+ const parts=split(command),redue=redueFor(configFile);
271
+ if(!parts||parts.rest!==null||!names.length||!names.every(value=>safe.test(value))||!redue)return null;
272
+ const run=names.map(value=>`${redue} run ${value}`).join(' && ');
273
+ return parts.base===run?null:names.length>1&&parts.suffix?`(${run})${parts.suffix}`:run+parts.suffix;
274
+ }
275
+
276
+ // A leading `cd DIR;` or `cd DIR &&`, which agents often add before a check:
277
+ // {prefix, command, dir} with the rest of the command and DIR relative to the
278
+ // project root ('.' for the root or no prefix), or null when DIR is outside
279
+ // the project or missing. DIR may be quoted; it may not use variables, a leading
280
+ // `~` or escapes. Inside double quotes a backslash before anything but
281
+ // $ ` " \ is literal, so a quoted Windows path matches; unquoted it is an escape.
282
+ const cdPrefix=/^[ \t]*cd[ \t]+(?:"((?:[^"$`\\]|\\(?![$`"\\\n]))*)"|'([^']*)'|((?!~)[^\s;&|'"$`\\]+))[ \t]*(?:;|&&)[ \t]*/;
283
+ export function enteredDir(command,cwd,projectRoot){
284
+ const prefix=typeof command==='string'?cdPrefix.exec(command):null;
285
+ if(!prefix)return {prefix:'',command,dir:'.'};
286
+ let relative;
287
+ try{relative=path.relative(projectRoot,realObservedPath(path.resolve(cwd,prefix[1]??prefix[2]??prefix[3])));}catch{return null;}
288
+ if(relative.split(path.sep)[0]==='..'||path.isAbsolute(relative))return null;
289
+ return {prefix:prefix[0],command:command.slice(prefix[0].length),dir:relative?relative.split(path.sep).join('/'):'.'};
290
+ }
291
+
292
+ // The fan-out's skip message when every check it runs is CURRENT/PASS, else null.
293
+ export function fanoutVerdict(status,names,rest=null){
294
+ if(!names.length||!names.every(name=>verdict(status,name)))return null;
295
+ return `REDUE: all ${names.length} checks this command runs (${names.join(', ')}) passed on these exact inputs; `+
296
+ 'rerun skipped. This counts as a pass for the current files; no status check is needed. '+
297
+ 'To run it anyway, prefix the command with REDUE_FORCE=1.'+
298
+ (rest===null?'':` Nothing else in the command ran either; run the rest on its own: ${rest}`);
299
+ }
300
+ export function verdict(status,name,rest=null){
301
+ const row=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)?
302
+ status.checks.find(item=>item?.name===name):null;
303
+ const runId=row?.invocation?.runId;
304
+ if(!row||row.freshness!=='CURRENT'||row.result!=='PASS'||row.reuse_eligible!==true||
305
+ typeof runId!=='string'||!/^[0-9a-f-]{36}$/.test(runId))return null;
306
+ const recipe=recipeLabel(row.recipe);
307
+ return `REDUE: check ${JSON.stringify(name)} passed on these exact inputs (run ${runId}${recipe?`, ${recipe}`:''}); `+
308
+ 'rerun skipped. This counts as a pass for the current files; no status check is needed. '+
309
+ 'To run it anyway, prefix the command with REDUE_FORCE=1.'+
310
+ (rest===null?'':` Nothing else in the command ran either; run the rest on its own: ${rest}`);
311
+ }
312
+
313
+ function synchronizedStatus(entry,config,root,timeout){
314
+ return new Promise(resolve=>{
315
+ let child,out='',done=false;
316
+ const finish=value=>{if(done)return;done=true;clearTimeout(timer);process.off('SIGTERM',stop);
317
+ if(child?.pid&&child.exitCode===null)try{process.platform==='win32'?child.kill('SIGKILL'):process.kill(-child.pid,'SIGKILL');}catch{}
318
+ resolve(value);};
319
+ const timer=setTimeout(()=>finish(null),timeout);
320
+ // Claude Code ends a hook that outlives its own timeout; end the status call with it.
321
+ const stop=()=>finish(null);process.once('SIGTERM',stop);
322
+ // Its own process group on POSIX, so a timeout also ends the engine it starts.
323
+ try{child=spawn(process.execPath,[entry,'--config',config,'status','--sync','--json'],
324
+ {cwd:root,stdio:['ignore','pipe','ignore'],env:process.env,windowsHide:true,detached:process.platform!=='win32'});}
325
+ catch{return finish(null);}
326
+ child.on('error',()=>finish(null));
327
+ child.stdout.on('data',chunk=>{out+=chunk;if(out.length>4*1024*1024)finish(null);});
328
+ child.on('close',code=>{if(code!==0)return finish(null);
329
+ try{finish(JSON.parse(out));}catch{finish(null);}});
330
+ });
331
+ }
332
+
333
+ // Waiting for status never costs more than half the check: half the last
334
+ // completed run's duration, at most 10 s. With no completed run, 2 s.
335
+ export function statusTimeout(status,name){
336
+ const run=Array.isArray(status?.checks)?status.checks.find(row=>row?.name===name)?.invocation:null;
337
+ return run?.status==='exited'&&Number.isFinite(run.durationMs)&&run.durationMs>=0?
338
+ Math.min(run.durationMs/2,10000):2000;
339
+ }
340
+ // From the observer's last published status, which is small, not the receipts.
341
+ // The longest wait among `names`, read once.
342
+ export function lastRunTimeout(config,parsed,...names){
343
+ try{const root=realObservedPath(path.resolve(path.dirname(config),parsed.root||'.'));
344
+ const status=JSON.parse(fs.readFileSync(path.join(projectState(root,config),'status.json'),'utf8'));
345
+ return Math.max(...names.map(name=>statusTimeout(status,name)));}
346
+ catch{return 2000;}
347
+ }
348
+
349
+ // The project set up in the session's directory: its setup record, the config
350
+ // beside it and the project root. null when there is none; throws on bad files.
351
+ function hookProject(root){
352
+ if(typeof root!=='string'||!path.isAbsolute(root))return null;
353
+ const record=JSON.parse(fs.readFileSync(path.join(root,'.redue','agents','claude.json'),'utf8'));
354
+ if(record?.schema!==1||record.host!=='claude'||typeof record.config!=='string'||
355
+ !/^[^/\\]+\.json$/.test(record.config))return null;
356
+ const config=path.join(root,record.config);
357
+ const parsed=JSON.parse(fs.readFileSync(config,'utf8'));
358
+ return {record,config,parsed,projectRoot:realObservedPath(path.resolve(path.dirname(config),parsed.root||'.'))};
359
+ }
360
+
361
+ // SessionStart: start the observer when none is running, so the session's
362
+ // checks can be skipped or recorded without a manual `redue start`. It
363
+ // launches `redue start` detached and returns at once, and prints nothing:
364
+ // SessionStart output becomes agent context. Returns what it did.
365
+ export function sessionStart(input,{entry}){
366
+ const project=hookProject(input?.cwd);
367
+ if(!project)return null;
368
+ try{const pid=Number(fs.readFileSync(path.join(projectState(project.projectRoot,project.config),'observer.lock','pid'),'utf8'));
369
+ if(Number.isSafeInteger(pid)&&pid>0&&processAlive(pid))return 'running';}catch{}
370
+ const child=spawn(process.execPath,[entry,'--config',project.config,'start'],
371
+ {cwd:input.cwd,detached:true,stdio:'ignore',windowsHide:true,env:process.env});
372
+ child.on('error',()=>{});child.unref();
373
+ return 'started';
374
+ }
375
+
376
+ // Returns the hook's stdout object, or null to allow the command.
377
+ export async function claudeHook(input,{entry,timeout}){
378
+ if(input?.tool_name!=='Bash')return null;
379
+ const command=input.tool_input?.command,root=input.cwd;
380
+ if(typeof command!=='string'||/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return null;
381
+ const project=hookProject(root);
382
+ if(!project)return null;
383
+ const {record,config,parsed}=project;
384
+ const entered=enteredDir(command,root,project.projectRoot);
385
+ if(!entered)return null;
386
+ let scripts={};
387
+ if(entered.dir==='.')try{scripts=JSON.parse(fs.readFileSync(path.join(project.projectRoot,'package.json'),'utf8')).scripts||{};}catch{}
388
+ const match=matched(entered.command,parsed,record.config,entered.dir,scripts);
389
+ if(!match)return null;
390
+ // turbo, nx and `pnpm -r` cache their own results: skip only when every
391
+ // check the command runs is CURRENT, and otherwise leave it alone.
392
+ if(match.fanout){
393
+ const names=await fanoutChecks(match.fanout,parsed,project.projectRoot);
394
+ if(!names)return null;
395
+ const status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,...names)),
396
+ reason=fanoutVerdict(status,names,match.rest);
397
+ return reason?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}:null;
398
+ }
399
+ // The configured config path as the command's directory sees it.
400
+ // Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
401
+ const configPath=!entered.prefix?record.config:
402
+ path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/');
403
+ // A compiler script: its checks in the order of its runs, or nothing when
404
+ // they no longer correspond. One run is an ordinary check. A chain is
405
+ // skipped when every run is CURRENT, else the runs that are not run through
406
+ // redue run, in order.
407
+ if(match.row){
408
+ const names=scriptChecks(match.row,parsed,project.projectRoot);
409
+ if(!names)return null;
410
+ if(names.length===1)match.check=names[0];
411
+ else{
412
+ const status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,...names)),
413
+ reason=fanoutVerdict(status,names,match.rest);
414
+ if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}};
415
+ const pending=names.filter(name=>!verdict(status,name));
416
+ const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
417
+ names.every(name=>status.checks.some(item=>item?.name===name))?recordedCommand(entered.command,pending,configPath):null;
418
+ return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
419
+ 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.`,
420
+ updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
421
+ }
422
+ }
423
+ const name=match.check,status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,name)),
424
+ reason=verdict(status,name,match.rest);
425
+ if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',
426
+ permissionDecision:'deny',permissionDecisionReason:reason}};
427
+ // Rewrite only on a healthy answer for this check: without an observer,
428
+ // `redue run` cannot record, so the command stays as Claude sent it. A chain
429
+ // stays too: the rewrite returns "allow", which would also skip the prompt
430
+ // for the rest of the chain.
431
+ const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
432
+ status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath):null;
433
+ // updatedInput replaces the whole tool input. Deny and ask rules still apply
434
+ // to the rewritten command; "allow" skips only the prompt.
435
+ return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
436
+ permissionDecisionReason:`REDUE: check ${JSON.stringify(name)} is not CURRENT; running it through redue run so the result is recorded.`,
437
+ updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
438
+ }
439
+
440
+ export async function runClaudeHook(entry){
441
+ try{
442
+ let text='';process.stdin.setEncoding('utf8');
443
+ for await(const chunk of process.stdin){text+=chunk;if(text.length>1024*1024)return;}
444
+ const input=JSON.parse(text);
445
+ if(input?.hook_event_name==='SessionStart'){sessionStart(input,{entry});return;}
446
+ const output=await claudeHook(input,{entry});
447
+ if(output)await new Promise(resolve=>process.stdout.write(JSON.stringify(output)+'\n',resolve));
448
+ }catch{}
449
+ }