@redue/cli 0.1.0-alpha.22 → 0.1.0-alpha.24

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.
@@ -90,8 +90,12 @@ kept but marked UNVERIFIED, with the reason, when the run:
90
90
  `--expose-internals`: the recorder cannot follow those;
91
91
  - had an installed file with another hard link, other than a link into the
92
92
  pnpm store the install recorded in `node_modules/.modules.yaml` (pnpm's
93
- default on Linux). A change through a link elsewhere, such as Yarn's
94
- hardlinks modes, is not seen.
93
+ default on Linux) or, without a pnpm store, links that are all installed
94
+ files the check observes (esbuild's install links its binary into its
95
+ platform package). The link count of each such file is checked again
96
+ before every answer, so a link added later is noticed. A change through a
97
+ link elsewhere, such as Yarn's hardlinks modes, is not seen. This applies
98
+ to the [general recipe](node-script.md) too.
95
99
 
96
100
  Lookups that can be asked again become run facts, checked before every CURRENT
97
101
  answer: whether a path outside the checkout exists, where a link resolves, the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@redue/cli",
3
3
  "private": false,
4
- "version": "0.1.0-alpha.22",
4
+ "version": "0.1.0-alpha.24",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
@@ -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 {fanoutRules,hookCommand,hookForms,redueFor} from './claude-hook.mjs';
14
+ import {fanoutRules,hookCommand,hookForms,redueFor,runsEntry,shellQuote as quote} 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'},
@@ -25,17 +25,53 @@ const sameJson=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
25
25
  // The hook waits for status up to 10 s; 15 s leaves room for starting Node.
26
26
  // Fan-out commands (turbo, nx, `pnpm -r`) and root scripts that run one get
27
27
  // prefix rules; the hook decides from the full command.
28
- function hookFor(config,relative,scripts){
28
+ function hookFor(config,relative,scripts,command=hookCommand){
29
29
  const rules=[...new Set([...hookForms(config,relative,scripts).flatMap(row=>[...row.forms,...row.local]),
30
30
  ...fanoutRules(config,scripts)])].map(form=>`Bash(${form}*)`);
31
- return rules.length?{matcher:'Bash',hooks:rules.map(rule=>({type:'command',if:rule,command:hookCommand,timeout:15}))}:null;
31
+ return rules.length?{matcher:'Bash',hooks:rules.map(rule=>({type:'command',if:rule,command,timeout:15}))}:null;
32
32
  }
33
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}]};
34
+ const sessionHookFor=(command=hookCommand)=>({hooks:[{type:'command',command,timeout:10}]});
35
35
  const events=['PreToolUse','SessionStart'];
36
+ // The absolute form: Node and REDUE's entry (redue.mjs), each quoted as quote()
37
+ // writes it. [node, entry] or null.
38
+ function absoluteHook(command){
39
+ const word="'((?:[^']|'\\\\'')*)'";
40
+ const match=new RegExp(`^${word} ${word} agent hook claude$`).exec(command??'');
41
+ if(!match)return null;
42
+ const [node,entry]=[match[1],match[2]].map(value=>value.replaceAll("'\\''","'"));
43
+ // REDUE's entry file, or the bin link npm installs for it.
44
+ return /^redue(\.mjs)?$/.test(path.basename(entry))?[node,entry]:null;
45
+ }
46
+ export const isHookCommand=command=>command===hookCommand||Boolean(absoluteHook(command));
47
+ // The command Claude Code runs for the hook. Plain `redue` when this install
48
+ // is the one both this shell and a login shell find on PATH. Otherwise this
49
+ // install's Node and entry by absolute path, so a Claude Code started from
50
+ // another shell, an IDE or with a shorter PATH still runs it, and an older
51
+ // install on PATH does not answer for this one. The settings file is
52
+ // per-developer, so a machine path is fine there.
53
+ export function hookInvocation(entry,{env=process.env,platform=process.platform}={}){
54
+ if(platform==='win32')return {command:hookCommand,reason:'Windows: plain redue on PATH'};
55
+ let target;try{target=fs.realpathSync(entry);}catch{target=entry;}
56
+ // Paths that survive upgrades where they can: the entry as invoked (a
57
+ // global bin link, not the versioned file behind it) and the node on PATH
58
+ // (Homebrew's link, not its versioned cellar).
59
+ const real=file=>{try{return fs.realpathSync(file);}catch{return null;}};
60
+ const invoked=typeof process.argv[1]==='string'&&path.isAbsolute(process.argv[1])&&real(process.argv[1])===target?process.argv[1]:target;
61
+ const execReal=real(process.execPath);
62
+ const node=(env.PATH||'').split(path.delimiter).map(dir=>path.join(dir||'.','node')).find(file=>real(file)===execReal)??process.execPath;
63
+ const same=file=>runsEntry(file,target,env);
64
+ const here=findExecutable('redue',{env});
65
+ const login=spawnSync(env.SHELL||'/bin/sh',['-lc','command -v redue'],{env,encoding:'utf8',timeout:5000,stdio:['ignore','pipe','ignore']});
66
+ const loginPath=login.status===0?login.stdout.trim().split('\n').pop():null;
67
+ if(same(here)&&same(loginPath))return {command:hookCommand,reason:`redue on PATH is this install (${here})`};
68
+ return {command:`${quote(node)} ${quote(invoked)} agent hook claude`,
69
+ reason:!here&&!loginPath?'redue is not on PATH; the hook names this install by its absolute path':
70
+ `redue on PATH is ${here||loginPath||'missing'} in ${same(here)?'a login shell':'this shell'}, not this install; the hook names this install by its absolute path`};
71
+ }
36
72
  // REDUE's entry is recognized by its command, not by a committed record: the
37
73
  // local file differs between checkouts, and users may edit the entry.
38
- const ownedHook=entry=>Array.isArray(entry?.hooks)&&entry.hooks.length>0&&entry.hooks.every(h=>h?.command===hookCommand);
74
+ const ownedHook=entry=>Array.isArray(entry?.hooks)&&entry.hooks.length>0&&entry.hooks.every(h=>isHookCommand(h?.command));
39
75
  function parseSettings(text){
40
76
  if(text===null)return {};
41
77
  let value;try{value=JSON.parse(text);}catch{throw Error(`${settingsPath} is not valid JSON; fix it before agent setup or removal`);}
@@ -65,6 +101,45 @@ function hookInstalled(root,event='PreToolUse'){
65
101
  try{return (parseSettings(read(root,settingsPath)).hooks?.[event]||[]).some(ownedHook);}
66
102
  catch{return false;}
67
103
  }
104
+ // What removing a host's integration changes, from its setup record: the
105
+ // managed block, the skill, the hooks and the record.
106
+ function removalChanges(root,host,record){
107
+ const h=hosts[host],before=read(root,h.instruction),after=before===null?null:before.replace(record.block,'');
108
+ const changes=[{path:h.instruction,before,after:record.instructionCreated&&after===''?null:after},
109
+ {path:h.skill,before:read(root,h.skill),after:null}];
110
+ if(host==='claude'){const settings=read(root,settingsPath);
111
+ changes.push({path:settingsPath,before:settings,after:settingsWith(settings,null)});}
112
+ changes.push({path:marker(host),before:read(root,marker(host)),after:null});
113
+ return changes;
114
+ }
115
+ // Removes a host's integration without needing the project config (redue
116
+ // remove after the config is gone): the changed paths, each with its operation.
117
+ export function removeIntegration(root,host='claude'){
118
+ const record=saved(root,host);if(!record)return [];
119
+ const health=inspect(root,host,record);if(health.integrity==='conflict')throw Error(health.reason);
120
+ const changes=removalChanges(root,host,record).filter(c=>c.before!==c.after);
121
+ apply(root,changes);
122
+ return changes.map(c=>`${c.after===null?'remove':'update'} ${c.path}`);
123
+ }
124
+ // Whether REDUE's Claude Code hooks are in the local settings.
125
+ export const claudeHooksInstalled=root=>hookInstalled(root,'SessionStart')||hookInstalled(root);
126
+ // Removes REDUE's Claude Code hooks from the local settings and keeps the
127
+ // rest; the file's path when it changed, else null.
128
+ export function removeClaudeHooks(root){
129
+ const before=read(root,settingsPath);if(before===null)return null;
130
+ const after=settingsWith(before,null);if(after===before)return null;
131
+ apply(root,[{path:settingsPath,before,after}]);return settingsPath;
132
+ }
133
+ // Whether the installed hook's command can start: an absolute one by its
134
+ // files, plain redue by PATH.
135
+ function hookRunnable(root){
136
+ let command=null;
137
+ try{command=(parseSettings(read(root,settingsPath)).hooks?.SessionStart||[]).find(ownedHook)?.hooks[0]?.command??null;}catch{}
138
+ const absolute=absoluteHook(command);
139
+ if(absolute)return fs.existsSync(absolute[0])&&fs.existsSync(absolute[1])?'installed (absolute path)':
140
+ 'installed, but its Node or REDUE path no longer exists; run redue agent setup claude again';
141
+ return findExecutable('redue')?'installed':'installed, but redue is not on PATH, so it never runs';
142
+ }
68
143
  function safeFile(root,relative){
69
144
  if(path.isAbsolute(relative)||relative.split(/[\\/]/).some(p=>p==='..'||p===''))throw Error('unsafe integration path');
70
145
  const file=path.resolve(root,relative);
@@ -108,23 +183,28 @@ const commandsOf=rows=>[rows.filter(row=>row.kind!=='test'&&row.kind!=='lint').m
108
183
  rows.filter(row=>row.kind==='test').map(row=>row.form),rows.filter(row=>row.kind==='lint').map(row=>row.form)];
109
184
  const fence=(host,body)=>`\n\n<!-- REDUE:${host}:begin -->\n${body}<!-- REDUE:${host}:end -->\n`;
110
185
  const skillPointer=host=>`Before you run, skip or report a typecheck or other configured check, read\n${hosts[host].skill}.\n`;
111
- function blockFor(host,rows=[]){
186
+ // `own`: the line asking for each check as a command of its own, added in
187
+ // alpha.24; the alpha.15-alpha.23 wording without it is still recognized.
188
+ function blockFor(host,rows=[],own=true){
112
189
  const [typechecks,tests,lints]=commandsOf(rows);
113
190
  if(!rows.length)return fence(host,skillPointer(host));
191
+ const ownLine=own?`Run each check as a command of its own, not chained after an edit or another\n`+
192
+ `command.\n`:'';
114
193
  if(lints.length)return fence(host,(typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
115
194
  (tests.length?`Run the unit tests with ${quoted(tests)}.\n`:'')+`Lint with ${quoted(lints)}.\n`+
116
195
  `Run a check before you say it passes, even after an edit you think cannot\n`+
117
196
  `matter; don't skip it yourself. If its last pass still applies to these exact\n`+
118
- `files, a REDUE hook skips the run and says so. That skip counts as a pass.\n`);
197
+ `files, a REDUE hook skips the run and says so. That skip counts as a pass.\n`+ownLine);
119
198
  if(!tests.length)return fence(host,`Typecheck with ${quoted(typechecks)}. Run it before you say it passes, even after an\n`+
120
199
  `edit you think cannot matter; don't skip it yourself. If its last pass still\n`+
121
200
  `applies to these exact files, a REDUE hook skips the run and says so. That\n`+
122
- `skip counts as a pass.\n`);
201
+ (own?`skip counts as a pass. Run it as a command of its own, not chained after an\n`+
202
+ `edit or another command.\n`:`skip counts as a pass.\n`));
123
203
  return fence(host,(typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
124
204
  `Run the unit tests with ${quoted(tests)}.\n`+
125
205
  `Run a check before you say it passes, even after an edit you think cannot\n`+
126
206
  `matter; don't skip it yourself. If its last pass still applies to these exact\n`+
127
- `files, a REDUE hook skips the run and says so. That skip counts as a pass.\n`);
207
+ `files, a REDUE hook skips the run and says so. That skip counts as a pass.\n`+ownLine);
128
208
  }
129
209
  // The alpha.12-alpha.14 wording, recognized so setup can replace it.
130
210
  function earlierBlock(host,rows=[]){
@@ -165,7 +245,8 @@ function knownBlock(host,relative,block){
165
245
  const rows=[...forms(`^\\n\\n<!-- REDUE:[a-z]+:begin -->\\nTypecheck with ${list}\\.`).map(form=>({kind:'typecheck',form})),
166
246
  ...forms(`Run the unit tests with ${list}\\.\\n`).map(form=>({kind:'test',form})),
167
247
  ...forms(`Lint with ${list}\\.\\n`).map(form=>({kind:'lint',form}))];
168
- return block===blockFor(host,rows)||block===earlierBlock(host,rows)||priorBlocks(host,relative).includes(block);
248
+ return block===blockFor(host,rows)||block===blockFor(host,rows,false)||block===earlierBlock(host,rows)||
249
+ priorBlocks(host,relative).includes(block);
169
250
  }
170
251
  // The policy with the exact commands for this config, so an agent never builds
171
252
  // a --config form the hook would not recognize. Claude Code gets the typecheck
@@ -273,7 +354,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
273
354
  const {root,relative,config}=project(configFile),h=hosts[host],record=saved(root,host),health=inspect(root,host,record);
274
355
  if(health.integrity==='conflict')throw Error(health.reason);
275
356
  const changes=[],notes=action==='setup'?warnings(root,host,cwd):[];
276
- let hook=null,block=null;
357
+ let hook=null,sessionHook=null,block=null;
277
358
  if(action==='setup'){
278
359
  const before=read(root,h.instruction),skill=skillFor(relative,host,config);
279
360
  block=blockFor(host,blockForms(host,config,relative));
@@ -281,7 +362,12 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
281
362
  throw Error('unowned REDUE instruction or skill already exists; inspect it before setup');
282
363
  const after=record?before.replace(record.block,block):(before||'')+block;
283
364
  let scripts={};try{scripts=JSON.parse(read(root,'package.json')??'{}').scripts||{};}catch{}
284
- hook=host==='claude'?hookFor(config,relative,scripts):null;
365
+ const invocation=host==='claude'?hookInvocation(entry):null;
366
+ if(invocation)notes.push(`Hook command: ${invocation.command} (${invocation.reason})`);
367
+ if(invocation&&invocation.command!==hookCommand)
368
+ notes.push('The agent instructions say `redue ...`: put this install on PATH (npm install --global @redue/cli@alpha) so the agent\'s shell can run them. The hook runs the checks it rewrites through this install either way.');
369
+ hook=host==='claude'?hookFor(config,relative,scripts,invocation.command):null;
370
+ sessionHook=host==='claude'?sessionHookFor(invocation.command):null;
285
371
  const next={schema:1,host,config:relative,instruction:h.instruction,skill:h.skill,
286
372
  block,skillHash:hash(skill),instructionCreated:record?.instructionCreated??before===null};
287
373
  changes.push({path:h.skill,before:read(root,h.skill),after:skill},
@@ -291,14 +377,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
291
377
  content:JSON.stringify(hook?next:{SessionStart:sessionHook},null,2)+
292
378
  (hook?'':'\nno PreToolUse hook: no typecheck or test check to match')});}
293
379
  changes.push({path:marker(host),before:read(root,marker(host)),after:JSON.stringify(next,null,2)+'\n'});
294
- }else if(record){
295
- const before=read(root,h.instruction),after=before.replace(record.block,'');
296
- changes.push({path:h.instruction,before,after:record.instructionCreated&&after===''?null:after},
297
- {path:h.skill,before:read(root,h.skill),after:null});
298
- if(host==='claude'){const settings=read(root,settingsPath);
299
- changes.push({path:settingsPath,before:settings,after:settingsWith(settings,null)});}
300
- changes.push({path:marker(host),before:read(root,marker(host)),after:null});
301
- }
380
+ }else if(record)changes.push(...removalChanges(root,host,record));
302
381
  const actual=changes.filter(c=>c.before!==c.after);
303
382
  const summary={schema:1,action,host,config:configFile,applied:false,
304
383
  changes:actual.map(c=>({path:c.path,operation:c.after===null?'remove':c.before===null?'create':'update',
@@ -355,7 +434,7 @@ export async function doctor({configFile,stateOverride,entry,cwd,host}){
355
434
  const out=spawnSync(launch.file,launch.args,{...launch.options,encoding:'utf8',timeout:2000,maxBuffer:4096,stdio:['ignore','pipe','pipe']});
356
435
  if(out.status===0)version=out.stdout.trim();}
357
436
  let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none',sessionHook=hook;
358
- const installed=()=>value.command.path?'installed':'installed, but redue is not on PATH, so it never runs';
437
+ const installed=()=>hookRunnable(p.root);
359
438
  if(p)try{const record=saved(p.root,name);integration=inspect(p.root,name,record);
360
439
  if(name==='claude'&&hookInstalled(p.root))hook=installed();
361
440
  if(name==='claude'&&hookInstalled(p.root,'SessionStart'))sessionHook=installed();
@@ -6,12 +6,13 @@
6
6
  // Never exit 2: Claude Code treats exit 2 as a block.
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import {spawn} from 'node:child_process';
9
+ import {spawn,spawnSync} from 'node:child_process';
10
10
  import {projectState} from './project-location.mjs';
11
11
  import {realObservedPath} from './path-identity.mjs';
12
12
  import {processAlive} from './process-liveness.mjs';
13
13
  import {scriptRunChecks} from './typescript-invocation.mjs';
14
14
  import {recipeLabel} from './presentation.mjs';
15
+ import {findExecutable} from './executable-lookup.mjs';
15
16
 
16
17
  export const hookCommand='redue agent hook claude';
17
18
  // No shell metacharacters; `@` starts scoped package names.
@@ -31,6 +32,22 @@ export const defaultConfig='redue.config.json';
31
32
  // would need shell quoting.
32
33
  export const redueFor=(configFile=defaultConfig)=>configFile===defaultConfig?'redue':
33
34
  safe.test(configFile)?`redue --config ${configFile}`:null;
35
+ export const shellQuote=value=>`'${String(value).replaceAll("'","'\\''")}'`;
36
+ // How a rewritten command starts REDUE: plain `redue` when the hook's PATH
37
+ // finds this install, else this install by absolute path. An agent shell
38
+ // without redue on PATH cannot run the plain form.
39
+ export function redueInvocation(entry,{env=process.env,platform=process.platform}={}){
40
+ if(platform==='win32'||runsEntry(findExecutable('redue',{env}),entry,env))return 'redue';
41
+ return `${shellQuote(process.execPath)} ${shellQuote(entry)}`;
42
+ }
43
+ // Whether the command at `file` runs this install's entry: the same file, or
44
+ // a shim or wrapper that reports it (redue --entry).
45
+ export function runsEntry(file,entry,env=process.env){
46
+ if(!file)return false;
47
+ try{const target=fs.realpathSync(entry);if(fs.realpathSync(file)===target)return true;
48
+ const out=spawnSync(file,['--entry'],{env,encoding:'utf8',timeout:5000,stdio:['ignore','pipe','ignore']});
49
+ return out.status===0&&fs.realpathSync(out.stdout.trim())===target;}catch{return false;}
50
+ }
34
51
 
35
52
  // Package-manager commands that a script of the same name would not reach.
36
53
  const builtins={pnpm:new Set(['add','audit','bin','config','create','dedupe','deploy','dlx','doctor','env','exec','fetch',
@@ -259,15 +276,83 @@ function matched(command,config,configFile,dir='.',scripts={}){
259
276
  // A script form of compiler checks is checked against the script first.
260
277
  return row?.scriptForms?.includes(parts.base)?{row,rest:parts.rest}:row?{check:row.check,rest:parts.rest}:null;
261
278
  }
279
+ // A simple chain: commands joined by &&, || or ;, outside quotes, with no
280
+ // line break, subshell, command or process substitution, backtick,
281
+ // here-document or background job. [{separator, text}] with the separator
282
+ // before each part ('' for the first), or null.
283
+ export function chainParts(command){
284
+ if(typeof command!=='string'||/[\r\n`]|\$\(|[<>]\(|<<|[()]/.test(command))return null;
285
+ const parts=[];let quote=null,start=0,separator='';
286
+ for(let i=0;i<command.length;i++){
287
+ const c=command[i];
288
+ if(quote){if(c===quote)quote=null;else if(c==='\\'&&quote==='"')i++;continue;}
289
+ if(c==='\\'){i++;continue;}
290
+ if(c==="'"||c==='"'){quote=c;continue;}
291
+ const two=command.slice(i,i+2);
292
+ if(two==='&&'||two==='||'||c===';'){
293
+ parts.push({separator,text:command.slice(start,i)});separator=c===';'?';':two;
294
+ start=i+(c===';'?1:2);if(c!==';')i++;continue;}
295
+ // A lone & runs a job in the background; >&, &> and |& redirect.
296
+ if(c==='&'&&command[i-1]!=='>'&&command[i+1]!=='>')return null;
297
+ if(two==='|&')return null;
298
+ }
299
+ if(quote)return null;
300
+ parts.push({separator,text:command.slice(start)});
301
+ return parts.every(part=>part.text.trim())?parts:null;
302
+ }
303
+ // A simple chain with each check in it run through REDUE when its turn comes:
304
+ // `redue run CHECK --if-due` decides then, after earlier parts may have
305
+ // changed files. Null when the command is not such a chain or runs no check.
306
+ // The rest of the chain is unchanged, and so is its output and exit status
307
+ // when a check runs.
308
+ // Shell builtins that change the shell for the parts after them.
309
+ const shellBuiltins=new Set(['cd','pushd','popd','export','unset','set','source','.','eval','exec','alias','unalias','shopt',
310
+ 'ulimit','umask','trap','builtin','command','declare','typeset','local','readonly','hash','enable','let','read','wait']);
311
+ // A part that cannot change the directory or environment of the parts after
312
+ // it: a program found on PATH (or by path), not a builtin, function or bare
313
+ // assignment.
314
+ function plainProgram(body){
315
+ const words=body.split(/\s+/).filter(Boolean);
316
+ while(words.length&&/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))words.shift();
317
+ const word=words[0];
318
+ return Boolean(word)&&!shellBuiltins.has(word)&&/^[A-Za-z0-9_./+@-]+$/.test(word)&&(word.includes('/')||Boolean(findExecutable(word)));
319
+ }
320
+ // A simple chain with each check in it run through REDUE when its turn comes:
321
+ // `redue run CHECK --if-due` decides then, after earlier parts may have
322
+ // changed files. {command, names}, or null when the command is not such a
323
+ // chain or runs no check. The rest of the chain is unchanged, and so are its
324
+ // output and exit status when a check runs. Every part before a check must be
325
+ // a plain program, so the check runs where and as it would have.
326
+ export function chainCommand(command,config,configFile,dir='.',scripts={},invoke='redue',runConfig=configFile,projectRoot=null){
327
+ const parts=chainParts(command);
328
+ if(!parts||parts.length<2)return null;
329
+ const redue=redueFor(runConfig)?.replace(/^redue/,()=>invoke);
330
+ if(!redue)return null;
331
+ const names=[];let plain=true;
332
+ const text=parts.map(({separator,text})=>{
333
+ const lead=text.match(/^\s*/)[0],trail=text.match(/\s*$/)[0],body=text.trim();
334
+ const match=matched(body,config,configFile,dir,scripts),split_=split(body);
335
+ // A compiler script is its checks in the order of its runs.
336
+ const checks=match?.check?[match.check]:match?.row&&projectRoot?scriptChecks(match.row,config,projectRoot):null;
337
+ if(!checks?.length||match.rest!==null||!split_||!checks.every(name=>safe.test(name))){
338
+ plain&&=plainProgram(body);return separator+text;}
339
+ if(!plain)return null;
340
+ names.push(...checks);
341
+ // Grouped, so a separator before it applies to all its runs.
342
+ const runs=checks.map(name=>`${redue} run ${name} --if-due`).join(' && ');
343
+ return `${separator}${lead}${checks.length>1?`{ ${runs}; }`:runs}${split_.suffix}${trail}`;
344
+ });
345
+ return names.length&&!text.includes(null)?{command:text.join(''),names}:null;
346
+ }
262
347
  export function matchCheck(command,config,configFile,dir,scripts){
263
348
  const match=matched(command,config,configFile,dir,scripts);
264
349
  return match?.row?match.row.checks.join(' && '):match?.check||null;
265
350
  }
266
351
  // The same check (or checks, in order) through REDUE, keeping the output
267
352
  // suffix; null if it already is, or if more commands follow it.
268
- export function recordedCommand(command,name,configFile=defaultConfig){
353
+ export function recordedCommand(command,name,configFile=defaultConfig,invoke='redue'){
269
354
  const names=Array.isArray(name)?name:[name];
270
- const parts=split(command),redue=redueFor(configFile);
355
+ const parts=split(command),redue=redueFor(configFile)?.replace(/^redue/,()=>invoke);
271
356
  if(!parts||parts.rest!==null||!names.length||!names.every(value=>safe.test(value))||!redue)return null;
272
357
  const run=names.map(value=>`${redue} run ${value}`).join(' && ');
273
358
  return parts.base===run?null:names.length>1&&parts.suffix?`(${run})${parts.suffix}`:run+parts.suffix;
@@ -310,7 +395,8 @@ export function verdict(status,name,rest=null){
310
395
  (rest===null?'':` Nothing else in the command ran either; run the rest on its own: ${rest}`);
311
396
  }
312
397
 
313
- function synchronizedStatus(entry,config,root,timeout){
398
+ // The status answer, synchronized unless `sync` is false (the cached one).
399
+ function synchronizedStatus(entry,config,root,timeout,sync=true){
314
400
  return new Promise(resolve=>{
315
401
  let child,out='',done=false;
316
402
  const finish=value=>{if(done)return;done=true;clearTimeout(timer);process.off('SIGTERM',stop);
@@ -320,7 +406,7 @@ function synchronizedStatus(entry,config,root,timeout){
320
406
  // Claude Code ends a hook that outlives its own timeout; end the status call with it.
321
407
  const stop=()=>finish(null);process.once('SIGTERM',stop);
322
408
  // 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'],
409
+ try{child=spawn(process.execPath,[entry,'--config',config,'status',...(sync?['--sync']:[]),'--json'],
324
410
  {cwd:root,stdio:['ignore','pipe','ignore'],env:process.env,windowsHide:true,detached:process.platform!=='win32'});}
325
411
  catch{return finish(null);}
326
412
  child.on('error',()=>finish(null));
@@ -374,7 +460,7 @@ export function sessionStart(input,{entry}){
374
460
  }
375
461
 
376
462
  // Returns the hook's stdout object, or null to allow the command.
377
- export async function claudeHook(input,{entry,timeout}){
463
+ export async function claudeHook(input,{entry,timeout,invoke=null}){
378
464
  if(input?.tool_name!=='Bash')return null;
379
465
  const command=input.tool_input?.command,root=input.cwd;
380
466
  if(typeof command!=='string'||/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return null;
@@ -385,8 +471,27 @@ export async function claudeHook(input,{entry,timeout}){
385
471
  if(!entered)return null;
386
472
  let scripts={};
387
473
  if(entered.dir==='.')try{scripts=JSON.parse(fs.readFileSync(path.join(project.projectRoot,'package.json'),'utf8')).scripts||{};}catch{}
474
+ // The configured config path as the command's directory sees it.
475
+ // Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
476
+ const configPath=!entered.prefix?record.config:
477
+ path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/');
478
+ // A simple chain: each check in it runs through REDUE when its turn comes,
479
+ // after the parts before it (an edit) have run. The command is changed,
480
+ // not approved: Claude Code's permission rules apply to it as rewritten.
481
+ // A chain with no check of its own (a leading fan-out) goes on below.
482
+ // Rewritten only on a healthy answer: without an observer, redue run
483
+ // cannot run the check, and the chain would not run as written.
484
+ // The observer's cached health is enough here: each check asks again when it runs.
485
+ const chained=chainCommand(entered.command,parsed,record.config,entered.dir,scripts,invoke??redueInvocation(entry),configPath,project.projectRoot);
486
+ if(chained){
487
+ const cached=await synchronizedStatus(entry,config,root,timeout??5000,false);
488
+ return cached?.observation?.healthy===true&&chained.names.every(name=>cached.checks?.some(row=>row?.name===name))?
489
+ {hookSpecificOutput:{hookEventName:'PreToolUse',updatedInput:{...input.tool_input,command:entered.prefix+chained.command}}}:null;
490
+ }
388
491
  const match=matched(entered.command,parsed,record.config,entered.dir,scripts);
389
492
  if(!match)return null;
493
+ // A check followed by more than a simple chain runs untouched.
494
+ if(match.rest!==null&&!match.fanout)return null;
390
495
  // turbo, nx and `pnpm -r` cache their own results: skip only when every
391
496
  // check the command runs is CURRENT, and otherwise leave it alone.
392
497
  if(match.fanout){
@@ -396,10 +501,6 @@ export async function claudeHook(input,{entry,timeout}){
396
501
  reason=fanoutVerdict(status,names,match.rest);
397
502
  return reason?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}:null;
398
503
  }
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
504
  // A compiler script: its checks in the order of its runs, or nothing when
404
505
  // they no longer correspond. One run is an ordinary check. A chain is
405
506
  // skipped when every run is CURRENT, else the runs that are not run through
@@ -414,7 +515,7 @@ export async function claudeHook(input,{entry,timeout}){
414
515
  if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}};
415
516
  const pending=names.filter(name=>!verdict(status,name));
416
517
  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;
518
+ names.every(name=>status.checks.some(item=>item?.name===name))?recordedCommand(entered.command,pending,configPath,invoke??redueInvocation(entry)):null;
418
519
  return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
419
520
  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
521
  updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
@@ -429,7 +530,7 @@ export async function claudeHook(input,{entry,timeout}){
429
530
  // stays too: the rewrite returns "allow", which would also skip the prompt
430
531
  // for the rest of the chain.
431
532
  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;
533
+ status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath,invoke??redueInvocation(entry)):null;
433
534
  // updatedInput replaces the whole tool input. Deny and ask rules still apply
434
535
  // to the rewritten command; "allow" skips only the prompt.
435
536
  return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
package/src/cli.mjs CHANGED
@@ -4,6 +4,8 @@ import fs from 'node:fs';
4
4
  import {directQualification,directInterpretation,observedNpmContext} from './direct-typescript.mjs';
5
5
  import {directRunCertificateAllowed,directRunCertificate,directRunRecordable,directRunRecording,probeQuestions,validateRunRecord} from './direct-run-certificate.mjs';
6
6
  import {queryAnswer,runFactChange} from './typescript-list.mjs';
7
+ import {patternMatcher} from './glob.mjs';
8
+ import {observedBy} from './installed-observation.mjs';
7
9
  import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
8
10
  import {childProgram,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,pnpmNameless,toolOutput,toolchainDirs} from './node-script.mjs';
9
11
  import path from 'node:path';
@@ -377,8 +379,13 @@ async function execute() {
377
379
  // The pnpm executable reads its configuration natively: kept by content.
378
380
  if(guard.opaque)for(const file of pnpmConfigFiles())try{runFacts.push(['readFile',file,queryAnswer('readFile',file)]);}
379
381
  catch{guard.issues.push(`pnpm configuration ${file} cannot be read`);}
380
- const shared=sharedInstalledFile(root,testFacts,path.join(state,'pnpm-store-links-v1.json'));
382
+ // Hard links count only where the check observes them.
383
+ // As the index matches: source patterns, or additive (installed) ones.
384
+ const source=patternMatcher(plan.sourcePatterns||plan.patterns||[]),additive=patternMatcher(plan.additivePatterns||[]);
385
+ const linkFacts=[],observed=observedBy(root,rel=>source(rel)||additive(rel));
386
+ const shared=sharedInstalledFile(root,testFacts,path.join(state,'pnpm-store-links-v1.json'),{observed,facts:linkFacts});
381
387
  if(shared)guard.issues.push(`installed file ${shared.file} ${shared.reason}; a change through it is not observed`);
388
+ else runFacts.push(...linkFacts);
382
389
  fs.rmSync(testRecord,{recursive:true,force:true});
383
390
  }
384
391
  // The daemon reads the recording from owned state; control requests are small.
@@ -0,0 +1,23 @@
1
+ // Whether a check observes an installed file: the file is in the checkout
2
+ // and matched by the check's patterns, and a link it goes through that leads
3
+ // elsewhere in the checkout leads to a matched path too. A recorded run's
4
+ // hard-linked installed files are accepted only when every link is one the
5
+ // check observes, so a pattern that leaves a folder out (a runner cache in
6
+ // node_modules) cannot hide one.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import {observedRelative} from './path-identity.mjs';
10
+
11
+ // `matches(rel)`: the check's pattern matcher over checkout-relative paths.
12
+ // A link that leads outside the checkout is the plan's to resolve (a
13
+ // permitted root or a refusal).
14
+ export function observedBy(root,matches){
15
+ let realRoot=root;try{realRoot=fs.realpathSync(root);}catch{}
16
+ return file=>{
17
+ const logical=observedRelative(root,path.resolve(file));
18
+ if(logical===null||!matches(logical))return false;
19
+ let real;try{real=fs.realpathSync(file);}catch{return false;}
20
+ const physical=observedRelative(realRoot,real);
21
+ return physical===null||physical===logical||matches(physical);
22
+ };
23
+ }
@@ -35,12 +35,15 @@ const modulesDirs=dirs=>['node_modules',...dirs.map(dir=>`${dir}/node_modules`)]
35
35
  // Inputs: the whole checkout but node_modules, .git and runner caches. Every
36
36
  // node_modules of the workspace is observed as installed files, without the
37
37
  // caches tools keep there.
38
+ // Runner caches at the top of a node_modules, which Vite, Vitest and build
39
+ // loaders rewrite as they run: not installed inputs.
40
+ export const installedRunnerCaches=['.cache','.vite','.vite-temp','.vitest','.vitest-cache'];
38
41
  export function scriptCheckInputs(dirs=[]){
39
42
  const modules=modulesDirs(dirs);
40
43
  return {inputs:['**',...modules.map(at=>`!${at}/**`),'!.git/**',...installState.map(file=>`!${file}`),
41
44
  ...scriptCaches.map(pattern=>`!${pattern}`)],
42
- installedInputs:[...modules.flatMap(at=>[`${at}/**`,`!${at}/.cache/**`,`!${at}/.vite/**`,`!${at}/.vite-temp/**`,
43
- `!${at}/.vitest/**`,`!${at}/.vitest-cache/**`,`!${at}/**/*.tsbuildinfo`]),...installState]};
45
+ installedInputs:[...modules.flatMap(at=>[`${at}/**`,...installedRunnerCaches.map(dir=>`!${at}/${dir}/**`),
46
+ `!${at}/**/*.tsbuildinfo`]),...installState]};
44
47
  }
45
48
  // Installed files a tool rewrites during a run. pnpm writes its workspace
46
49
  // state in the root node_modules at the first run after an install, through
@@ -236,6 +239,19 @@ export const scriptPath=(dir,callerPath)=>{
236
239
  // Why a shell command line could start a program REDUE does not record, or
237
240
  // null. Names are looked up along `searchPath` from `cwd`. A script the pnpm
238
241
  // executable runs is read the same way, since the recorder cannot see it.
242
+ // TypeScript 7's Node launchers (tsc in typescript@7, tsgo in
243
+ // @typescript/native-preview) replace themselves with a native compiler REDUE
244
+ // does not record: the package and version, or null.
245
+ export function nativeTypeScriptLauncher(file){
246
+ let dir;try{dir=path.dirname(fs.realpathSync(file));}catch{return null;}
247
+ for(let n=0;n<3;n++,dir=path.dirname(dir)){
248
+ let pkg;try{pkg=JSON.parse(fs.readFileSync(path.join(dir,'package.json'),'utf8'));}catch{continue;}
249
+ if(pkg.name==='typescript'&&Number(String(pkg.version).split('.')[0])>=7||pkg.name==='@typescript/native-preview')
250
+ return `${pkg.name} ${pkg.version}`;
251
+ return null;
252
+ }
253
+ return null;
254
+ }
239
255
  export function commandLineIssue(text,{cwd,searchPath,depth=0}={}){
240
256
  if(depth>8)return 'the scripts run each other too deeply for REDUE to read';
241
257
  const parsed=parseCommandLine(text);
@@ -270,6 +286,8 @@ export function commandLineIssue(text,{cwd,searchPath,depth=0}={}){
270
286
  if(kind==='node'){
271
287
  const flag=['node','nodejs'].includes(path.basename(file))&&args.find(arg=>nativeReads.test(arg.text));
272
288
  if(flag)return `runs node ${flag.text.split('=')[0]}, which reads a file REDUE cannot see`;
289
+ const native=nativeTypeScriptLauncher(file);
290
+ if(native)return `runs ${path.basename(name)} from ${native}, a native compiler REDUE does not record`;
273
291
  break;
274
292
  }
275
293
  if(kind==='pnpm'){const issue=pnpmRunIssue(args.map(arg=>arg.text),{cwd:dir,searchPath,depth});if(issue)return issue;break;}
@@ -2,7 +2,7 @@
2
2
  // repository-relative input sets and resolution relationships, never pnpm
3
3
  // virtual-store names as semantic identities.
4
4
  import {probeTimeLeft} from './probe-deadline.mjs';
5
- import {compilerFiles} from './typescript-list.mjs';
5
+ import {compilerApiIssue,compilerFiles} from './typescript-list.mjs';
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import {createRequire} from 'node:module';
@@ -298,7 +298,9 @@ export function pnpmTypecheckInputs(root,workspace,run,{captureQueries=false}={}
298
298
  let tsPackage;try{tsPackage=realObservedPath(lookup.resolve('typescript/package.json'));}
299
299
  catch{throw Error('installed TypeScript compiler cannot be resolved');}
300
300
  if(!within(tsPackage,root))throw Error('TypeScript compiler is outside the observed checkout');
301
- const tsRoot=path.dirname(tsPackage),ts=lookup('typescript');
301
+ const tsRoot=path.dirname(tsPackage),apiIssue=compilerApiIssue(tsRoot);
302
+ if(apiIssue)throw Error(apiIssue);
303
+ const ts=lookup('typescript');
302
304
  const tsc=path.join(tsRoot,'bin','tsc');
303
305
  if(!exists(tsc))throw Error('installed TypeScript executable is missing');
304
306
  const configStarted=performance.now();