@redue/cli 0.1.0-alpha.15 → 0.1.0-alpha.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,15 +2,18 @@
2
2
  // reports CURRENT/PASS with reuse_eligible on the current inputs. When the check is not
3
3
  // CURRENT, the command becomes `redue run <check>` so the run is recorded. Every
4
4
  // other outcome, including errors and timeouts, allows the command unchanged.
5
+ // The same command handles SessionStart: it starts the project's observer.
5
6
  // Never exit 2: Claude Code treats exit 2 as a block.
6
7
  import fs from 'node:fs';
7
8
  import path from 'node:path';
8
9
  import {spawn} from 'node:child_process';
9
10
  import {projectState} from './project-location.mjs';
10
- import {realObservedPath,samePath} from './path-identity.mjs';
11
+ import {realObservedPath} from './path-identity.mjs';
12
+ import {processAlive} from './process-liveness.mjs';
11
13
 
12
14
  export const hookCommand='redue agent hook claude';
13
- const safe=/^[A-Za-z0-9_./:=,+-]+$/;
15
+ // No shell metacharacters; `@` starts scoped package names.
16
+ const safe=/^[A-Za-z0-9_./:=,+@-]+$/;
14
17
  const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
15
18
  // A direct test check: its command is the runner and the script's arguments.
16
19
  const tests=check=>check?.qualification==='npm-tests-direct-v1'&&Array.isArray(check.command)&&
@@ -22,32 +25,155 @@ export const defaultConfig='redue.config.json';
22
25
  // would need shell quoting.
23
26
  export const redueFor=(configFile=defaultConfig)=>configFile===defaultConfig?'redue':
24
27
  safe.test(configFile)?`redue --config ${configFile}`:null;
28
+
29
+ // Package-manager commands that a script of the same name would not reach.
30
+ const builtins={pnpm:new Set(['add','audit','bin','config','create','dedupe','deploy','dlx','doctor','env','exec','fetch',
31
+ 'i','import','init','install','link','list','ln','ls','outdated','pack','patch','prune','publish','rebuild','recursive',
32
+ 'remove','rm','root','run','self-update','server','setup','store','uninstall','unlink','up','update','why','x']),
33
+ // Yarn 4 and Yarn 1 together.
34
+ yarn:new Set(['access','add','audit','autoclean','bin','cache','check','config','constraints','create','dedupe','dir','dlx',
35
+ 'exec','explain','generate-lock-entry','global','help','import','info','init','install','licenses','link','list','login',
36
+ 'logout','node','npm','outdated','owner','pack','patch','plugin','policies','publish','rebuild','remove','run','set',
37
+ 'stage','tag','team','unlink','unplug','up','upgrade','upgrade-interactive','version','versions','why','workspace','workspaces'])};
38
+ // A package script run by the project's package manager, in the package's directory.
39
+ function scriptForms(pm,script){
40
+ if(typeof script!=='string'||!safe.test(script))return [];
41
+ if(pm==='npm')return [`npm run ${script}`,...(script==='test'?['npm test']:[])];
42
+ if(pm==='pnpm'||pm==='yarn')return [...(builtins[pm].has(script)?[]:[`${pm} ${script}`]),`${pm} run ${script}`];
43
+ return [];
44
+ }
45
+ // A workspace package's script, run from the project root.
46
+ function workspaceForms(pm,name,script){
47
+ if(typeof name!=='string'||!safe.test(name)||typeof script!=='string'||!safe.test(script))return [];
48
+ if(pm==='pnpm')return ['--filter ','--filter=','-F '].flatMap(flag=>
49
+ [...(builtins.pnpm.has(script)?[]:[`pnpm ${flag}${name} ${script}`]),`pnpm ${flag}${name} run ${script}`]);
50
+ if(pm==='yarn')return [...(builtins.yarn.has(script)?[]:[`yarn workspace ${name} ${script}`]),`yarn workspace ${name} run ${script}`];
51
+ if(pm==='npm')return [`npm run ${script} -w ${name}`,`npm run ${script} --workspace ${name}`,
52
+ `npm run ${script} --workspace=${name}`,`npm -w ${name} run ${script}`];
53
+ return [];
54
+ }
55
+ // The tool a check runs, as typed: `tsc ARGS` or the test runner's command.
56
+ function toolText(check){
57
+ const command=check.command;
58
+ if(!Array.isArray(command)||command[0]!=='@node')return null;
59
+ const words=/^@typescript-(?:compiler|bin):/.test(command[1]||'')?['tsc',...command.slice(2)]:
60
+ tests(check)?[command[1].slice('@test-runner:'.length),...command.slice(2)]:null;
61
+ return words?.every(word=>typeof word==='string'&&safe.test(word))?words.join(' '):null;
62
+ }
63
+ // A bare `tsc` is rarely on PATH; a test runner's own text is what the script says.
64
+ const toolForms=(pm,text)=>!text?[]:[...(/^tsc(?: |$)/.test(text)?[]:[text]),`npx ${text}`,...(pm==='pnpm'?[`pnpm exec ${text}`]:[])];
65
+ // A workspace check's package: its `workspace`, or the package its generated
66
+ // `npm run S --workspace W`, `yarn workspace W run S` or `pnpm --filter W run S`
67
+ // command names; null for any other check.
68
+ function workspaceName(check){
69
+ if(typeof check.workspace==='string')return check.workspace;
70
+ const command=check.command,flag={'@which:npm':'--workspace','@which:yarn':'workspace','@which:pnpm':'--filter'}[command?.[0]];
71
+ const at=flag&&Array.isArray(command)?command.indexOf(flag):-1;
72
+ return at>0&&typeof command[at+1]==='string'?command[at+1]:null;
73
+ }
74
+ // Where a check runs: '.' for a root check, the package directory for a
75
+ // workspace check, or null. A workspace check's directory is its `cwd`, or the
76
+ // one directory its inputs name as `DIR/package.json` or `DIR/**` with
77
+ // `!DIR/node_modules/**`, as init writes them.
78
+ export function checkDir(check){
79
+ if(!workspaceName(check))return check.cwd===undefined||check.cwd==='.'?'.':null;
80
+ if(typeof check.cwd==='string'&&check.cwd!=='.')return check.cwd;
81
+ const inputs=(check.inputs||[]).filter(value=>typeof value==='string');
82
+ const dirs=new Set(inputs.flatMap(value=>{
83
+ const manifest=/^([^!*]+)\/package\.json$/.exec(value)?.[1],tree=/^([^!*]+)\/\*\*$/.exec(value)?.[1];
84
+ return manifest?[manifest]:tree&&inputs.includes(`!${tree}/node_modules/**`)?[tree]:[];
85
+ }));
86
+ return dirs.size===1?[...dirs][0]:null;
87
+ }
88
+ // The package script a check runs: its `script`, or the last word of a
89
+ // generated `pnpm --filter NAME run SCRIPT`-style command.
90
+ const checkScript=check=>typeof check.script==='string'?check.script:
91
+ Array.isArray(check.command)&&/^@which:(?:npm|pnpm|yarn)$/.test(check.command[0])&&check.command.includes('run')?
92
+ check.command[check.command.indexOf('run')+1]:null;
93
+
25
94
  // Exact commands that rerun each configured typecheck or direct test check.
26
- // Names that would need shell quoting get no form, so the hook never fires for them.
95
+ // `forms` work from the project root; `local` ones work in the check's own
96
+ // directory `dir`, after a `cd` into it. Names that would need shell quoting
97
+ // get no form, so the hook never fires for them.
27
98
  export function hookForms(config,configFile=defaultConfig){
28
- const rows=[],redue=redueFor(configFile);
99
+ const rows=[],redue=redueFor(configFile),pm=config?.packageManager;
29
100
  for(const check of config?.checks||[]){
30
101
  if(!(typecheck(check)||tests(check))||typeof check.name!=='string'||!safe.test(check.name))continue;
31
- const forms=redue?[`${redue} run ${check.name}`]:[];
32
- // Other forms run from the project root, so they match only a root check.
33
- if(check.cwd!==undefined&&check.cwd!=='.'){if(forms.length)rows.push({check:check.name,forms});continue;}
34
- if(config.packageManager==='npm'&&typeof check.script==='string'&&safe.test(check.script))
35
- forms.push(`npm run ${check.script}`);
36
- if(check.qualification==='npm-typescript-direct-v1')forms.push('npx tsc --noEmit');
37
- if(!tests(check)&&Array.isArray(check.command)&&check.command.length&&
38
- check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
39
- forms.push(check.command.join(' '));
40
- // The script's own text, which runs the same runner with the same arguments.
41
- if(tests(check)){
42
- if(check.script==='test'&&config.packageManager==='npm')forms.push('npm test');
43
- const text=[check.command[1].slice('@test-runner:'.length),...check.command.slice(2)];
44
- if(text.every(v=>typeof v==='string'&&safe.test(v)))forms.push(text.join(' '),`npx ${text.join(' ')}`);
102
+ const forms=redue?[`${redue} run ${check.name}`]:[],local=[],dir=checkDir(check),script=checkScript(check);
103
+ if(dir==='.'){
104
+ forms.push(...scriptForms(pm,script),...toolForms(pm,toolText(check)));
105
+ if(!tests(check)&&Array.isArray(check.command)&&check.command.length&&
106
+ check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
107
+ forms.push(check.command.join(' '));
108
+ }else if(dir!==null&&safe.test(dir)){
109
+ forms.push(...workspaceForms(pm,workspaceName(check),script));
110
+ local.push(...scriptForms(pm,script),...(check.cwd===dir?toolForms(pm,toolText(check)):[]));
45
111
  }
46
- if(forms.length)rows.push({check:check.name,kind:checkKind(check),forms:[...new Set(forms)]});
112
+ if(forms.length||local.length)rows.push({check:check.name,kind:checkKind(check),dir,
113
+ forms:[...new Set(forms)],local:[...new Set(local)]});
47
114
  }
48
115
  return rows;
49
116
  }
50
117
 
118
+ // A turbo or `pnpm -r` command that runs one task in every workspace package:
119
+ // {tool, task}. No filters and no nx: both can add packages or tasks that only
120
+ // a fuller model of the tool would see. `pnpm turbo` is left out because a root
121
+ // script named turbo would run instead.
122
+ const turboPrefix='(?:npx |pnpm exec )?',task='([A-Za-z0-9_:.-]+)';
123
+ const fanouts=[['turbo',new RegExp(`^${turboPrefix}turbo(?: run)? ${task}$`)],
124
+ ['pnpm',new RegExp(`^pnpm (?:-r|--recursive)(?: run)? ${task}$`)]];
125
+ export function fanout(text){
126
+ for(const [tool,pattern] of fanouts){const match=pattern.exec(text);
127
+ if(match)return {tool,task:match[1]};}
128
+ return null;
129
+ }
130
+ // Bash `if` rules for fan-out commands of a task some workspace check runs,
131
+ // and for root scripts that run one; other turbo or nx commands never start the hook.
132
+ export function fanoutRules(config,scripts={}){
133
+ const tasks=new Set((config?.checks||[]).filter(check=>(typecheck(check)||tests(check))&&typeof check.name==='string'&&
134
+ ![null,'.'].includes(checkDir(check))).map(checkScript).filter(task=>typeof task==='string'&&/^[A-Za-z0-9_:.-]+$/.test(task)));
135
+ const rules=[];
136
+ for(const task of tasks){
137
+ for(const prefix of ['','npx ','pnpm exec '])rules.push(`${prefix}turbo run ${task}`,`${prefix}turbo ${task}`);
138
+ if(config.packageManager==='pnpm')rules.push(`pnpm -r ${task}`,`pnpm -r run ${task}`,`pnpm --recursive ${task}`,`pnpm --recursive run ${task}`);
139
+ }
140
+ for(const [name,text] of Object.entries(scripts||{})){
141
+ const fan=typeof text==='string'&&!scripts['pre'+name]&&!scripts['post'+name]?fanout(text.trim()):null;
142
+ if(fan&&tasks.has(fan.task))rules.push(...scriptForms(config.packageManager,name));
143
+ }
144
+ return rules;
145
+ }
146
+ // The checks a fan-out runs: one configured check for every workspace package
147
+ // with the task's script, or null when any package has none, or when the
148
+ // tool may run more: another task first (turbo `dependsOn` other than ^TASK,
149
+ // `with`, package-scoped keys, package turbo.json), a task turbo does not
150
+ // define, or pnpm's workspace root.
151
+ export async function fanoutChecks(fan,config,projectRoot){
152
+ const {workspacePackages}=await import('./node-onboarding.mjs');
153
+ const packages=workspacePackages(projectRoot).filter(item=>typeof item.scripts[fan.task]==='string');
154
+ if(!packages.length)return null;
155
+ const text=file=>{try{return fs.readFileSync(path.join(projectRoot,file),'utf8');}catch(e){return e.code==='ENOENT'?undefined:null;}};
156
+ if(fan.tool==='turbo'){
157
+ let tasks;try{const turbo=JSON.parse(text('turbo.json'));tasks=turbo?.tasks??turbo?.pipeline;}catch{return null;}
158
+ const own=tasks?.[fan.task];
159
+ if(!own||typeof own!=='object'||own.with!==undefined||
160
+ own.dependsOn!==undefined&&!(Array.isArray(own.dependsOn)&&own.dependsOn.every(item=>item===`^${fan.task}`))||
161
+ Object.keys(tasks).some(key=>key.endsWith(`#${fan.task}`))||
162
+ packages.some(item=>text(`${item.dir}/turbo.json`)!==undefined||text(`${item.dir}/turbo.jsonc`)!==undefined))return null;
163
+ }
164
+ if(fan.tool==='pnpm'&&(config.packageManager!=='pnpm'||
165
+ ['.npmrc','pnpm-workspace.yaml'].some(file=>{const value=text(file);return value===null||/include-?workspace-?root/i.test(value??'');})))
166
+ return null;
167
+ const names=[];
168
+ for(const item of packages){
169
+ const check=config.checks.find(c=>(typecheck(c)||tests(c))&&typeof c.name==='string'&&
170
+ workspaceName(c)===item.name&&checkScript(c)===fan.task&&checkDir(c)===item.dir);
171
+ if(!check)return null;
172
+ names.push(check.name);
173
+ }
174
+ return names;
175
+ }
176
+
51
177
  // Accept only an optional trailing `2>&1`, then an optional `| tail|head -n N`
52
178
  // or `-N`, then optionally `;` or `&&` and the rest of the command. A form has
53
179
  // no shell metacharacters, so the first separator after it ends it. Newlines
@@ -59,11 +185,25 @@ function split(command){
59
185
  const one=text=>text.replace(/[ \t]+/g,' ');
60
186
  return {base:one(base),suffix:one(suffix),rest};
61
187
  }
62
- function matched(command,config,configFile){
63
- const parts=split(command),check=parts&&hookForms(config,configFile).find(row=>row.forms.includes(parts.base))?.check;
188
+ // {check, rest}, or {fanout, rest} for a turbo/nx/`pnpm -r` command or a root
189
+ // script that runs one; null when nothing matches. `dir` is where the command
190
+ // runs, relative to the project root.
191
+ function matched(command,config,configFile,dir='.',scripts={}){
192
+ const parts=split(command);
193
+ if(!parts)return null;
194
+ if(dir==='.'){
195
+ // A root script that runs a fan-out is that fan-out, unless pre/post scripts
196
+ // run around it: then it matches nothing.
197
+ for(const [name,text] of Object.entries(scripts||{}))
198
+ if(typeof text==='string'&&scriptForms(config.packageManager,name).includes(parts.base)&&fanout(text.trim()))
199
+ return scripts['pre'+name]||scripts['post'+name]?null:{fanout:fanout(text.trim()),rest:parts.rest};
200
+ if(fanout(parts.base))return {fanout:fanout(parts.base),rest:parts.rest};
201
+ }
202
+ const check=hookForms(config,configFile).find(row=>dir==='.'?row.forms.includes(parts.base):
203
+ row.dir===dir&&row.local.includes(parts.base))?.check;
64
204
  return check?{check,rest:parts.rest}:null;
65
205
  }
66
- export function matchCheck(command,config,configFile){return matched(command,config,configFile)?.check||null;}
206
+ export function matchCheck(command,config,configFile,dir,scripts){return matched(command,config,configFile,dir,scripts)?.check||null;}
67
207
  // The same check through REDUE, keeping the output suffix; null if it already
68
208
  // is, or if more commands follow it.
69
209
  export function recordedCommand(command,name,configFile=defaultConfig){
@@ -73,21 +213,30 @@ export function recordedCommand(command,name,configFile=defaultConfig){
73
213
  return parts.base===run?null:run+parts.suffix;
74
214
  }
75
215
 
76
- // A leading `cd DIR;` or `cd DIR &&` into the project root, which agents
77
- // often add before a check: {prefix, command} with the rest of the command,
78
- // the whole command when there is no such prefix, or null when it enters
79
- // another directory. DIR may be quoted; it may not use variables, a leading
216
+ // A leading `cd DIR;` or `cd DIR &&`, which agents often add before a check:
217
+ // {prefix, command, dir} with the rest of the command and DIR relative to the
218
+ // project root ('.' for the root or no prefix), or null when DIR is outside
219
+ // the project or missing. DIR may be quoted; it may not use variables, a leading
80
220
  // `~` or escapes. Inside double quotes a backslash before anything but
81
221
  // $ ` " \ is literal, so a quoted Windows path matches; unquoted it is an escape.
82
222
  const cdPrefix=/^[ \t]*cd[ \t]+(?:"((?:[^"$`\\]|\\(?![$`"\\\n]))*)"|'([^']*)'|((?!~)[^\s;&|'"$`\\]+))[ \t]*(?:;|&&)[ \t]*/;
83
- export function rootCd(command,cwd,projectRoot){
223
+ export function enteredDir(command,cwd,projectRoot){
84
224
  const prefix=typeof command==='string'?cdPrefix.exec(command):null;
85
- if(!prefix)return {prefix:'',command};
86
- const dir=prefix[1]??prefix[2]??prefix[3];
87
- try{if(!samePath(realObservedPath(path.resolve(cwd,dir)),projectRoot))return null;}catch{return null;}
88
- return {prefix:prefix[0],command:command.slice(prefix[0].length)};
225
+ if(!prefix)return {prefix:'',command,dir:'.'};
226
+ let relative;
227
+ try{relative=path.relative(projectRoot,realObservedPath(path.resolve(cwd,prefix[1]??prefix[2]??prefix[3])));}catch{return null;}
228
+ if(relative.split(path.sep)[0]==='..'||path.isAbsolute(relative))return null;
229
+ return {prefix:prefix[0],command:command.slice(prefix[0].length),dir:relative?relative.split(path.sep).join('/'):'.'};
89
230
  }
90
231
 
232
+ // The fan-out's skip message when every check it runs is CURRENT/PASS, else null.
233
+ export function fanoutVerdict(status,names,rest=null){
234
+ if(!names.length||!names.every(name=>verdict(status,name)))return null;
235
+ return `REDUE: all ${names.length} checks this command runs (${names.join(', ')}) passed on these exact inputs; `+
236
+ 'rerun skipped. This counts as a pass for the current files; no status check is needed. '+
237
+ 'To run it anyway, prefix the command with REDUE_FORCE=1.'+
238
+ (rest===null?'':` Nothing else in the command ran either; run the rest on its own: ${rest}`);
239
+ }
91
240
  export function verdict(status,name,rest=null){
92
241
  const row=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)?
93
242
  status.checks.find(item=>item?.name===name):null;
@@ -134,20 +283,56 @@ export function lastRunTimeout(config,parsed,name){
134
283
  catch{return 2000;}
135
284
  }
136
285
 
137
- // Returns the hook's stdout object, or null to allow the command.
138
- export async function claudeHook(input,{entry,timeout}){
139
- if(input?.tool_name!=='Bash')return null;
140
- const command=input.tool_input?.command,root=input.cwd;
141
- if(typeof command!=='string'||/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return null;
286
+ // The project set up in the session's directory: its setup record, the config
287
+ // beside it and the project root. null when there is none; throws on bad files.
288
+ function hookProject(root){
142
289
  if(typeof root!=='string'||!path.isAbsolute(root))return null;
143
290
  const record=JSON.parse(fs.readFileSync(path.join(root,'.redue','agents','claude.json'),'utf8'));
144
291
  if(record?.schema!==1||record.host!=='claude'||typeof record.config!=='string'||
145
292
  !/^[^/\\]+\.json$/.test(record.config))return null;
146
293
  const config=path.join(root,record.config);
147
294
  const parsed=JSON.parse(fs.readFileSync(config,'utf8'));
148
- const entered=rootCd(command,root,realObservedPath(path.resolve(path.dirname(config),parsed.root||'.')));
149
- const match=entered&&matched(entered.command,parsed,record.config);
295
+ return {record,config,parsed,projectRoot:realObservedPath(path.resolve(path.dirname(config),parsed.root||'.'))};
296
+ }
297
+
298
+ // SessionStart: start the observer when none is running, so the session's
299
+ // checks can be skipped or recorded without a manual `redue start`. It
300
+ // launches `redue start` detached and returns at once, and prints nothing:
301
+ // SessionStart output becomes agent context. Returns what it did.
302
+ export function sessionStart(input,{entry}){
303
+ const project=hookProject(input?.cwd);
304
+ if(!project)return null;
305
+ try{const pid=Number(fs.readFileSync(path.join(projectState(project.projectRoot,project.config),'observer.lock','pid'),'utf8'));
306
+ if(Number.isSafeInteger(pid)&&pid>0&&processAlive(pid))return 'running';}catch{}
307
+ const child=spawn(process.execPath,[entry,'--config',project.config,'start'],
308
+ {cwd:input.cwd,detached:true,stdio:'ignore',windowsHide:true,env:process.env});
309
+ child.on('error',()=>{});child.unref();
310
+ return 'started';
311
+ }
312
+
313
+ // Returns the hook's stdout object, or null to allow the command.
314
+ export async function claudeHook(input,{entry,timeout}){
315
+ if(input?.tool_name!=='Bash')return null;
316
+ const command=input.tool_input?.command,root=input.cwd;
317
+ if(typeof command!=='string'||/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return null;
318
+ const project=hookProject(root);
319
+ if(!project)return null;
320
+ const {record,config,parsed}=project;
321
+ const entered=enteredDir(command,root,project.projectRoot);
322
+ if(!entered)return null;
323
+ let scripts={};
324
+ if(entered.dir==='.')try{scripts=JSON.parse(fs.readFileSync(path.join(project.projectRoot,'package.json'),'utf8')).scripts||{};}catch{}
325
+ const match=matched(entered.command,parsed,record.config,entered.dir,scripts);
150
326
  if(!match)return null;
327
+ // turbo, nx and `pnpm -r` cache their own results: skip only when every
328
+ // check the command runs is CURRENT, and otherwise leave it alone.
329
+ if(match.fanout){
330
+ const names=await fanoutChecks(match.fanout,parsed,project.projectRoot);
331
+ if(!names)return null;
332
+ const status=await synchronizedStatus(entry,config,root,timeout??Math.max(...names.map(name=>lastRunTimeout(config,parsed,name)))),
333
+ reason=fanoutVerdict(status,names,match.rest);
334
+ return reason?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}:null;
335
+ }
151
336
  const name=match.check,status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,name)),
152
337
  reason=verdict(status,name,match.rest);
153
338
  if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',
@@ -157,7 +342,9 @@ export async function claudeHook(input,{entry,timeout}){
157
342
  // stays too: the rewrite returns "allow", which would also skip the prompt
158
343
  // for the rest of the chain.
159
344
  const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
160
- status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,record.config):null;
345
+ status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,
346
+ // Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
347
+ !entered.prefix?record.config:path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/')):null;
161
348
  // updatedInput replaces the whole tool input. Deny and ask rules still apply
162
349
  // to the rewritten command; "allow" skips only the prompt.
163
350
  return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
@@ -169,7 +356,9 @@ export async function runClaudeHook(entry){
169
356
  try{
170
357
  let text='';process.stdin.setEncoding('utf8');
171
358
  for await(const chunk of process.stdin){text+=chunk;if(text.length>1024*1024)return;}
172
- const output=await claudeHook(JSON.parse(text),{entry});
359
+ const input=JSON.parse(text);
360
+ if(input?.hook_event_name==='SessionStart'){sessionStart(input,{entry});return;}
361
+ const output=await claudeHook(input,{entry});
173
362
  if(output)await new Promise(resolve=>process.stdout.write(JSON.stringify(output)+'\n',resolve));
174
363
  }catch{}
175
364
  }
package/src/cli.mjs CHANGED
@@ -4,7 +4,7 @@ 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 {runFactChange} from './typescript-list.mjs';
7
- import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testProject,testEnvironmentIssue,testGuardConfig,testRunGuard,testsInterpretation,testsQualification} from './test-direct.mjs';
7
+ import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
8
8
  import path from 'node:path';
9
9
  import net from 'node:net';
10
10
  import os from 'node:os';
@@ -273,14 +273,17 @@ async function execute() {
273
273
  ...command.slice(1)]:command;
274
274
  // A test run's processes record what the guard checks (test-recorder.cjs).
275
275
  const testRun=selected.qualification===testsQualification&&['darwin','linux'].includes(process.platform);
276
- let testRecord=null,launchEnv=process.env;
276
+ let testRecord=null,launchEnv=process.env,testFacts={};
277
277
  if(testRun){
278
+ // The package directory and installed node_modules, as the plan has them.
279
+ try{testFacts=testRunFacts(root,selected);}
280
+ catch(e){captureIssues.push(`test run facts unavailable (${e.code||e.message})`);}
278
281
  const environmentIssue=testEnvironmentIssue();
279
282
  if(environmentIssue)captureIssues.push(environmentIssue);
280
283
  testRecord=path.join(state,`test-record-${runId}`);
281
284
  fs.mkdirSync(testRecord,{mode:0o700});
282
285
  const guardFile=path.join(testRecord,'guard.json');
283
- fs.writeFileSync(guardFile,JSON.stringify(testGuardConfig(testRecord,root)),{mode:0o600});
286
+ fs.writeFileSync(guardFile,JSON.stringify(testGuardConfig(testRecord,root,process.env,testFacts)),{mode:0o600});
284
287
  launchEnv=recordedEnvironment(guardFile);
285
288
  }
286
289
  let runnerPid=null;
@@ -354,10 +357,12 @@ async function execute() {
354
357
  }else if(!unobservedDirectRun&&!recording)probeAfter=probeHash(probePlan);
355
358
  let guard=null;
356
359
  if(testRecord){
357
- let coverage=null;try{coverage=coverageReadable(root,testProject(root,selected.script));}catch{}
360
+ let coverage=null;try{coverage=coverageReadable(root,testFacts);}catch{}
358
361
  guard=testRunGuard(testRecord,{root,sourcePatterns:plan.sourcePatterns||plan.patterns,files:snap?.files,
359
362
  runner:runnerPid,environment:selected.environment||{},coverage});
360
363
  runFacts=guard.runFacts;
364
+ const shared=sharedInstalledFile(root,testFacts);
365
+ if(shared)guard.issues.push(`installed file ${shared} has another hard link, likely into a package store; a change through it is not observed`);
361
366
  fs.rmSync(testRecord,{recursive:true,force:true});
362
367
  }
363
368
  // The daemon reads the recording from owned state; control requests are small.
package/src/daemon.mjs CHANGED
@@ -271,7 +271,7 @@ function probe(row,allowReuse=false,{launch=true}={}) {
271
271
  'workspace-typecheck-contract-unavailable':'workspace TypeScript applicability probe could not establish inputs'
272
272
  ,'pnpm-execution-environment-unsupported':'pnpm or Node launcher environment override needs review'
273
273
  ,'pnpm-distribution-unobservable':'pnpm executable contents cannot be observed'
274
- ,'pnpm-version-mismatch':'pnpm executable differs from the pinned version'
274
+ ,'pnpm-version-mismatch':'pnpm executable differs from the installed version'
275
275
  ,'typescript-input-outside-pnpm-contract':'TypeScript reads outside the selected workspace, linked outputs, or installed inputs'
276
276
  ,'pnpm-input-coverage-unavailable':'pnpm installed-input coverage became unavailable; inspect check details'
277
277
  ,'pnpm-typecheck-contract-unavailable':'pnpm TypeScript input contract could not be established'
@@ -6,7 +6,7 @@ import {createHash} from 'node:crypto';
6
6
  import {permittedRoots,resolveDeclaredInstalled} from './installed-inputs.mjs';
7
7
  import {qualifyTypeScript} from './typescript-qualification.mjs';
8
8
  import {directBuildInfo} from './direct-typescript.mjs';
9
- import {qualifyTests,testsQualification} from './test-direct.mjs';
9
+ import {qualifyTests,testWorkspacePatterns,testsQualification} from './test-direct.mjs';
10
10
  import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
11
11
  import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
12
12
  import {observedRelative,withinPath,realObservedPath} from './path-identity.mjs';
@@ -92,6 +92,13 @@ export function discoverDeclared(config) {
92
92
  ...(workspaceIssue?[workspaceIssue]:[])];
93
93
  for(const item of workspace?.configurationInputs||[])configurationInputs.add(item);
94
94
  for(const item of workspace?.workspacePatterns||[])workspacePatterns.add(item);
95
+ // A workspace test check is qualified against the current package list: a
96
+ // package manifest appearing or pnpm's package list changing re-plans.
97
+ if(selected.qualification===testsQualification&&selected.workspace)try{
98
+ for(const item of testWorkspacePatterns(root,JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'))))
99
+ workspacePatterns.add(item);
100
+ if(fs.existsSync(path.join(root,'pnpm-workspace.yaml')))configurationInputs.add('pnpm-workspace.yaml');
101
+ }catch{}
95
102
  for(const item of workspace?.closure||[])workspaceManifests.add(item.manifest);
96
103
  if(!automatic)for(const category of ['source','generated','installedDependencies',
97
104
  'environment','toolchain','runtime'])if(!selected.coverage?.[category])
package/src/index.mjs CHANGED
@@ -42,24 +42,33 @@ const pathPrefixes = rel => {
42
42
  // tick at the same size; such files are tracked in `racy`, and the daemon waits
43
43
  // the window out and records them again before it writes a run checkpoint.
44
44
  const identity=st=>identityFields.map(key=>String(st[key])).join(':');
45
+ // An installed link to a directory (pnpm links every package this way) is
46
+ // observed by its link chain alone, when `observedDir` says the directory it
47
+ // resolves to is itself observed installed: its own entries carry its contents.
45
48
  function fileHash(root, rel,installed=false,before=fs.lstatSync(path.join(root,rel),
46
- {bigint:true})) {
49
+ {bigint:true}),observedDir=null) {
47
50
  const file=path.join(root, rel);
48
51
  let value,references=[],bytes=installed?0:Number(before.size),racy=0;
49
52
  if(before.isSymbolicLink()){
50
53
  const resolution=resolveLinks(file,root,[],true);
51
54
  if(!resolution.physical)throw Error(`symlink input target observation unavailable: ${rel}: ${resolution.reason}`);
52
55
  const target=fs.statSync(resolution.physical,{bigint:true});
53
- if(!target.isFile())throw Error(`directory input symlink unsupported: ${rel}`);
54
- if(installed)racy=racyUntil(target);else bytes=Number(target.size);
55
- value='link:'+JSON.stringify(resolution.links)+':'+(installed?'stat:'+identity(target):
56
- target.mode+':'+hashRegular(resolution.physical).hash);
56
+ const physical=path.relative(root,resolution.physical).split(path.sep).join('/');
57
+ const directory=target.isDirectory()&&installed&&observedDir?.(physical);
58
+ if(!target.isFile()&&!directory)throw Error(`directory input symlink unsupported: ${rel}`);
59
+ if(directory)value='dirlink:'+JSON.stringify(resolution.links)+':'+physical;
60
+ else{
61
+ if(installed)racy=racyUntil(target);else bytes=Number(target.size);
62
+ value='link:'+JSON.stringify(resolution.links)+':'+(installed?'stat:'+identity(target):
63
+ target.mode+':'+hashRegular(resolution.physical).hash);
64
+ }
57
65
  references=[...new Set([...resolution.links.map(link=>path.relative(root,link.path)
58
66
  .split(path.sep).join('/')),path.relative(root,resolution.physical)
59
67
  .split(path.sep).join('/')])];
60
- const targetAfter=fs.statSync(resolution.physical,{bigint:true});
61
- for(const key of identityFields)
62
- if(target[key]!==targetAfter[key])throw Error(`input changed during inspection: ${rel}`);
68
+ // A directory's own timestamps follow its entries, which are observed separately.
69
+ if(!directory){const targetAfter=fs.statSync(resolution.physical,{bigint:true});
70
+ for(const key of identityFields)
71
+ if(target[key]!==targetAfter[key])throw Error(`input changed during inspection: ${rel}`);}
63
72
  const resolutionAfter=resolveLinks(file,root,[],true);
64
73
  if(resolutionAfter.physical!==resolution.physical||
65
74
  JSON.stringify(resolutionAfter.links)!==JSON.stringify(resolution.links))
@@ -181,7 +190,14 @@ export class InputIndex {
181
190
  catch(e){if(e.code!=='ENOENT'){this.unavailable=String(e);return false;}}
182
191
  if(stat&&(stat.isFile()||stat.isSymbolicLink())) {
183
192
  const installed=this.installedPath(rel);
184
- try{const started=performance.now();inspection=fileHash(this.root,rel,installed,stat);
193
+ try{const started=performance.now();
194
+ // A linked directory counts as observed only strictly inside an
195
+ // installed root, and only if every check matching the link also
196
+ // observes the directory's own files (its package.json stands for them).
197
+ const observedDir=installed?physical=>!this.includeInstalled&&
198
+ [...this.installedRootSet].some(root=>physical.startsWith(root+'/'))&&
199
+ matched.every(name=>this.matches(this.checks.get(name),physical+'/package.json')):null;
200
+ inspection=fileHash(this.root,rel,installed,stat,observedDir);
185
201
  fresh=inspection.hash;
186
202
  this.profile.hashingMs+=performance.now()-started;
187
203
  this.rehashedFiles++;this.rehashedBytes+=inspection.bytes;}
@@ -302,9 +318,10 @@ export class InputIndex {
302
318
  const full=path.join(this.root,rel);
303
319
  let subtree=false;
304
320
  try {
305
- if(fs.lstatSync(full).isSymbolicLink()&&fs.statSync(full).isDirectory()){
306
- this.unavailable=`directory symlink input unsupported: ${rel}`;return;}
307
- if(fs.statSync(full).isDirectory()) {this.scanSubtree(rel);subtree=true;}
321
+ // A link is an entry of its own: record() accepts an observed installed
322
+ // directory link and refuses any other.
323
+ if(fs.lstatSync(full).isSymbolicLink())this.record(rel);
324
+ else if(fs.statSync(full).isDirectory()) {this.scanSubtree(rel);subtree=true;}
308
325
  else this.record(rel);
309
326
  } catch(e) {
310
327
  if(e.code==='ENOENT') {
@@ -84,6 +84,10 @@ export function resolveDeclaredInstalled(root,patterns,allowed=[]) {
84
84
  if(!resolved.physical){
85
85
  if(resolved.reason==='resolved target missing'&&!resolved.links.length){
86
86
  internal.add(pattern);continue;}
87
+ // An exclusion of a path that does not exist yet, through links (a runner
88
+ // cache inside a linked package): exclude it where the links lead.
89
+ if(negative&&resolved.reason==='resolved target missing'&&resolved.prospective&&within(resolved.prospective,root)){
90
+ internal.add('!'+posix(path.relative(root,resolved.prospective))+(suffix?'/'+suffix:''));continue;}
87
91
  result.unresolved.push(`${prefix}: ${resolved.reason}`);continue;
88
92
  }
89
93
  const boundary=within(resolved.physical,root)?root:
@@ -3,11 +3,11 @@ import {testProject,testRecipeScript,testCheckInputs,testsQualification} from '.
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import {spawnSync} from 'node:child_process';
6
- import micromatch from './glob.mjs';
7
6
  import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
8
7
  import {pnpmInstallInfo,pnpmProjectInfo,pnpmTypecheckInputs} from './pnpm-inputs.mjs';
9
8
  import {realObservedPath} from './path-identity.mjs';
10
9
  import {findExecutable} from './executable-lookup.mjs';
10
+ import {workspaceManifests} from './workspace-manifests.mjs';
11
11
 
12
12
  const exists=file=>{try{fs.accessSync(file);return true;}catch{return false;}};
13
13
  const readJson=file=>JSON.parse(fs.readFileSync(file,'utf8'));
@@ -147,19 +147,14 @@ function selectScript(kind,names,scripts) {
147
147
  return candidates.length===1?{script:candidates[0]}:
148
148
  candidates.length>1?{ambiguous:candidates}:{};
149
149
  }
150
- function workspaceManifests(root,patterns) {
151
- const listing=spawnSync('git',['ls-files','--cached','--others','--exclude-standard',
152
- '-z','--',':(glob)**/package.json'],{cwd:root,encoding:'utf8',timeout:5000,
153
- maxBuffer:32*1024*1024,stdio:['ignore','pipe','ignore'],
154
- env:{...process.env,GIT_OPTIONAL_LOCKS:'0'}});
155
- if(listing.status!==0||listing.error)throw Error('workspace package manifests cannot be inspected');
156
- const matched=listing.stdout.split('\0').filter(Boolean).filter(file=>{
157
- const dir=path.posix.dirname(file);
158
- return dir!=='.'&&!dir.split('/').includes('node_modules')&&
159
- micromatch.isMatch(dir,patterns,{dot:true});
160
- }).sort();
161
- if(matched.length>50)throw Error('more than 50 workspace packages found; select checks explicitly');
162
- return matched.map(file=>({file,pkg:readJson(path.join(root,file))}));
150
+ // Workspace packages as init finds them, with their scripts; [] without workspaces.
151
+ export function workspacePackages(root){
152
+ root=realObservedPath(root);
153
+ const pkg=readJson(path.join(root,'package.json')),pm=manager(root,pkg);
154
+ const patterns=pm.name==='pnpm'?pm.workspacePatterns:Array.isArray(pkg.workspaces)?pkg.workspaces:pkg.workspaces?.packages;
155
+ if(!Array.isArray(patterns)||!patterns.length)return [];
156
+ return workspaceManifests(root,patterns).map(({file,pkg:member})=>
157
+ ({name:member.name,dir:path.posix.dirname(file),scripts:member.scripts&&typeof member.scripts==='object'?member.scripts:{}}));
163
158
  }
164
159
  export function discoverNodeProject(root,{recipe=null}={}) {
165
160
  if(recipe&&!['typescript-direct','tests-direct'].includes(recipe))throw Error('unknown recipe; use typescript-direct or tests-direct');
@@ -200,7 +195,8 @@ export function discoverNodeProject(root,{recipe=null}={}) {
200
195
  }
201
196
  if(recipe==='tests-direct'&&kind==='test'){
202
197
  try{
203
- if(pm.name!=='npm'||workspaces||observationIssue||pm.issue)throw Error('test recipe requires a supported single-package npm checkout');
198
+ if(!['npm','pnpm','yarn'].includes(pm.name)||workspaces||observationIssue||pm.issue)
199
+ throw Error('test recipe requires a supported single-package npm, pnpm or Yarn checkout');
204
200
  const chosen=testRecipeScript(scripts);
205
201
  if(chosen.ambiguous)throw Error(`several scripts run Vitest or Jest directly (${chosen.ambiguous.join(', ')})`);
206
202
  if(!chosen.script)throw Error(chosen.issue||'no script runs Vitest or Jest directly');
@@ -221,7 +217,7 @@ export function discoverNodeProject(root,{recipe=null}={}) {
221
217
  check.inputs=['package.json','pnpm-lock.yaml',
222
218
  ...(exists(path.join(root,'pnpm-workspace.yaml'))?['pnpm-workspace.yaml']:[]),
223
219
  'tsconfig.json','src/**'];
224
- classification={level:'ready',reason:'pinned pnpm TypeScript inputs and installation; checked again before reuse',
220
+ classification={level:'ready',reason:'pnpm TypeScript inputs and installation; checked again before reuse',
225
221
  qualification:'pnpm-tsc-v1'};}
226
222
  catch(e){classification={level:'recording',reason:`pnpm TypeScript contract needs review: ${e.message}`};}
227
223
  }
@@ -275,9 +271,17 @@ export function discoverNodeProject(root,{recipe=null}={}) {
275
271
  ...member.scripts[selected.script].trim().split(/\s+/).slice(1)];
276
272
  check.inputs=['package.json','pnpm-lock.yaml','pnpm-workspace.yaml',file,
277
273
  `${dir}/src/**`];
278
- level='ready';reason='pinned pnpm workspace TypeScript inputs; checked again before reuse';
274
+ level='ready';reason='pnpm workspace TypeScript inputs; checked again before reuse';
279
275
  }catch(e){reason=`pnpm TypeScript contract needs review: ${e.message}`;}
280
276
  }
277
+ if(recipe==='tests-direct'&&kind==='test'&&!pm.issue){
278
+ try{const project=testProject(root,selected.script,dir);
279
+ Object.assign(check,{workspace:name,script:selected.script,cwd:dir,
280
+ command:['@node',`@test-runner:${project.runner}`,...project.args],...testCheckInputs(project),
281
+ qualification:testsQualification});
282
+ level='ready';reason=`explicit local ${project.runner} ${project.version} run of ${name}'s script ${JSON.stringify(selected.script)}; the whole checkout is its input; the package manager and lifecycle scripts are not executed`;
283
+ }catch(e){reason=`Test recipe unavailable (${e.code||e.message}); original script retained`;}
284
+ }
281
285
  checks.push({config:check,level,reason});
282
286
  }
283
287
  }