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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.16 — 2026-10-22
4
+
5
+ Changes to the Claude Code integration, and one fix for commands that meet
6
+ on fresh state. Run `redue agent setup claude --apply` again to get the new
7
+ hook entries.
8
+
9
+ - A SessionStart hook starts the project's observer when a session starts,
10
+ resumes, clears or compacts, if none is running. It returns at once and
11
+ prints nothing. Without a running observer every check was UNVERIFIED, so
12
+ the hook could neither skip nor record. `agent doctor` reports it.
13
+ - The hook matches more command forms, each mapping to one configured check:
14
+ `pnpm SCRIPT`, `pnpm run SCRIPT`, `yarn SCRIPT`, `yarn run SCRIPT`,
15
+ `pnpm exec tsc ...`; for a workspace package's check, `pnpm --filter NAME
16
+ SCRIPT`, `yarn workspace NAME SCRIPT`, `npm run SCRIPT -w NAME`, and its
17
+ script after a `cd` into the package. In the census of 108 public
18
+ TypeScript repos, 61 use pnpm or Yarn.
19
+ - `turbo run TASK` and, in pnpm projects, `pnpm -r TASK` (or a root script
20
+ that is one of them) are skipped only when every workspace package with the
21
+ task's script has a check and all are CURRENT; otherwise they run as typed,
22
+ never rewritten. No skip when turbo may run more (other `dependsOn` tasks,
23
+ `with`, package-scoped keys, a package turbo.json), when pnpm includes the
24
+ workspace root, or for a root script with pre/post scripts. Filters and nx
25
+ are not matched.
26
+ - Fixed: when two commands met on fresh state (SessionStart's `start` and the
27
+ first `status`), the second failed and no observer started.
28
+
29
+ Pilot eval on alpha.16 code (CI 39799994225, 96 sessions over twilio,
30
+ semiotic, Ultra-Card and SwitchHosts): 0 critical findings; 29 skipped
31
+ typechecks, each confirmed by a plain rerun.
3
32
  ## 0.1.0-alpha.15 — 2026-10-22
4
33
 
5
34
  No change to observation, status or `redue run` since alpha.14; the changes
package/README.md CHANGED
@@ -37,7 +37,7 @@ it; relevant source edits make it stale. [Run the reproducible demo](docs/contri
37
37
 
38
38
  ## Install the alpha
39
39
 
40
- The opt-in pilot uses **0.1.0-alpha.15**. Follow the
40
+ The opt-in pilot uses **0.1.0-alpha.16**. Follow the
41
41
  [fixed-version pilot guide](docs/user/pilot.md) for supported projects, setup,
42
42
  updates and voluntary feedback. No external usage results are claimed yet.
43
43
 
package/bin/redue.mjs CHANGED
@@ -323,7 +323,13 @@ if(maintenance&&!fs.existsSync(runtimeFile)){
323
323
  process.exit(0);
324
324
  }
325
325
  if(!maintenance)fs.mkdirSync(state,{recursive:true,mode:0o700});
326
- if(!maintenance&&fs.existsSync(runtimeFile)){
326
+ // Two commands can meet on fresh state (SessionStart's start and the first
327
+ // status): whichever creates the runtime file first wins, and the other
328
+ // treats it as existing.
329
+ let created=false;
330
+ if(!maintenance&&!fs.existsSync(runtimeFile))
331
+ try{fs.writeFileSync(runtimeFile,serialized,{flag:'wx',mode:0o600});created=true;}catch(e){if(e.code!=='EEXIST')throw e;}
332
+ if(!maintenance&&!created){
327
333
  const previous=fs.readFileSync(runtimeFile,'utf8');
328
334
  if(previous!==serialized){
329
335
  const pidFile=path.join(state,'observer.lock','pid');
@@ -333,7 +339,7 @@ if(!maintenance&&fs.existsSync(runtimeFile)){
333
339
  }
334
340
  fs.writeFileSync(runtimeFile,serialized,{mode:0o600});
335
341
  }
336
- }else if(!maintenance)fs.writeFileSync(runtimeFile,serialized,{flag:'wx',mode:0o600});
342
+ }
337
343
 
338
344
  if(command==='status'&&!sync){
339
345
  const value=readCachedStatus(state,publicConfig.checks);
@@ -69,25 +69,58 @@ Start a fresh agent session in this checkout, then give it your ordinary task.
69
69
  Interactive setup shows the changes and asks before applying; noninteractive
70
70
  setup requires `--apply`. `--dry-run` never writes. Installing REDUE or running
71
71
  `init` does not connect an agent automatically. Setup does not start an observer
72
- or execute verification. Use `redue start` when you want observation.
72
+ or execute verification. With Claude Code, a SessionStart hook starts the
73
+ observer when a session starts (below); otherwise use `redue start`.
73
74
 
74
75
  ## Claude Code typecheck hook
75
76
 
76
- `redue agent setup claude --apply` also adds a PreToolUse hook to
77
- `.claude/settings.local.json`. The hook needs a local REDUE install, so it is
77
+ `redue agent setup claude --apply` also adds a PreToolUse hook and a
78
+ SessionStart hook to `.claude/settings.local.json`. Both run
79
+ `redue agent hook claude`, which needs a local REDUE install, so they are
78
80
  per-developer; keep that file out of commits. Other settings in the file are kept,
79
81
  but the file is rewritten as 2-space JSON. `.claude/settings.json` is not touched.
80
- `redue agent remove claude --apply` removes only the entry REDUE added.
82
+ `redue agent remove claude --apply` removes only the entries REDUE added.
81
83
 
82
- The hook skips an exact rerun of a configured typecheck: a check with
83
- `kind: "typecheck"` or named `typecheck`. It matches these commands, run from the
84
- project root:
84
+ When a session starts, resumes, clears or compacts, the SessionStart hook starts
85
+ this project's observer if none is running. It launches `redue start` in the
86
+ background and returns at once, and prints nothing, so it adds nothing to the
87
+ agent's context. Until the observer's first index is done, checks are
88
+ UNVERIFIED and run as typed. An observer you stopped with `redue stop` starts
89
+ again with the next session; remove the hook to prevent that. Setups from
90
+ alpha.15 or earlier get the hook by running `redue agent setup claude --apply`
91
+ again.
92
+
93
+ The hook skips an exact rerun of a configured typecheck (a check with
94
+ `kind: "typecheck"` or named `typecheck`) or direct test check. It matches
95
+ these commands, run from the project root:
85
96
 
86
97
  - `redue run CHECK`, or `redue --config FILE run CHECK` when the config is not
87
98
  `redue.config.json`
88
- - `npm run SCRIPT`, when the check has an npm script
89
- - `npx tsc --noEmit`, for the direct-TypeScript recipe
99
+ - the check's script through the project's package manager: `npm run SCRIPT`
100
+ (and `npm test`), `pnpm SCRIPT`, `pnpm run SCRIPT`, `yarn SCRIPT` or
101
+ `yarn run SCRIPT`. The short form is left out when the script is named like
102
+ one of the package manager's own commands.
103
+ - the tool the check runs: `npx tsc ARGS` (and `pnpm exec tsc ARGS` with pnpm)
104
+ for a direct TypeScript check, or the runner's command, alone or after `npx`,
105
+ for a direct test check
90
106
  - the check's `command`, when it has no `@` tokens
107
+ - for a workspace package's check: `pnpm --filter NAME SCRIPT` (also
108
+ `--filter=NAME`, `-F NAME`, and with `run`), `yarn workspace NAME SCRIPT`, or
109
+ `npm run SCRIPT -w NAME` (also `--workspace NAME`, `--workspace=NAME` and
110
+ `npm -w NAME run SCRIPT`). After `cd` into the package's directory, the
111
+ package-manager script forms and tool forms match that check.
112
+
113
+ A `turbo run TASK` (or `turbo TASK`, alone or after `npx` or `pnpm exec`) or,
114
+ in a pnpm project, `pnpm -r TASK` (`--recursive`, with or without `run`) that
115
+ runs a checked task in every workspace package is skipped only when every check
116
+ it would run is CURRENT, PASS and `reuse_eligible`. Every package with the
117
+ task's script must have a check. turbo must define the task, with `dependsOn`
118
+ listing at most `^TASK`, no `with`, no `PACKAGE#TASK` entries and no package
119
+ `turbo.json`; `pnpm -r` must not include the workspace root. A root script
120
+ whose text is such a command counts as that command, unless it has pre- or
121
+ post-scripts. In every other case the command runs as typed: turbo caches its
122
+ own results, so the hook never rewrites it. Filters (`--filter`) and nx are not
123
+ matched.
91
124
 
92
125
  Each form may end with `2>&1` and then one `| tail -n N`, `| head -n N`,
93
126
  `| tail -N` or `| head -N`. It may also be the first command of a `;` or `&&`
@@ -121,8 +154,10 @@ Limits:
121
154
 
122
155
  - The hook judges its own environment, which Claude Code passes to it. The Bash
123
156
  tool can add variables from your shell profile. The hook cannot see those.
124
- - Commands run from a subdirectory, after another command (`cd x && tsc`), or
125
- combined with `||`, `&` or other pipes, are not matched.
157
+ - Commands run from a session that started in a subdirectory, after a `cd`
158
+ outside the project, or combined with `||`, `&` or other pipes, are not
159
+ matched. After a `cd` into any other directory than the root or a checked
160
+ package's, nothing matches.
126
161
  - A skip writes nothing. If a typecheck command also emits files, later
127
162
  commands see the files from its last run.
128
163
  - Only the Bash tool is covered, not PowerShell.
@@ -1,4 +1,4 @@
1
- # Opt-in development pilot: alpha.15
1
+ # Opt-in development pilot: alpha.16
2
2
 
3
3
  Use one fixed build for several ordinary development sessions. The owner will
4
4
  invite two or three willing testers and approve their projects; nothing is
@@ -35,7 +35,7 @@ are **NOT RUN**. The recording shortcut is macOS/direct-recipe only.
35
35
  For a new default npm installation:
36
36
 
37
37
  ```sh
38
- npm install --global @redue/cli@0.1.0-alpha.15
38
+ npm install --global @redue/cli@0.1.0-alpha.16
39
39
  redue --version
40
40
  cd my-project
41
41
  redue --config redue.config.json init --recipe typescript-direct --check typecheck --dry-run
@@ -89,7 +89,7 @@ using it; keep the same npm prefix, config and any explicit state selection.
89
89
 
90
90
  ```sh
91
91
  redue --config redue.config.json stop
92
- npm install --global @redue/cli@0.1.0-alpha.15
92
+ npm install --global @redue/cli@0.1.0-alpha.16
93
93
  redue --config redue.config.json agent setup codex --dry-run
94
94
  redue --config redue.config.json agent setup codex --apply
95
95
  redue --config redue.config.json start
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@redue/cli",
3
3
  "private": false,
4
- "version": "0.1.0-alpha.15",
4
+ "version": "0.1.0-alpha.16",
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 {hookCommand,hookForms,redueFor} from './claude-hook.mjs';
14
+ import {fanoutRules,hookCommand,hookForms,redueFor} from './claude-hook.mjs';
15
15
 
16
16
  const policy=fs.readFileSync(new URL('./agent-policy.md',import.meta.url),'utf8');
17
17
  const hosts={codex:{instruction:'AGENTS.md',skill:'.agents/skills/redue-verification/SKILL.md'},
@@ -23,10 +23,16 @@ const settingsPath='.claude/settings.local.json';
23
23
  const sameJson=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
24
24
  // One handler per exact command form; `if` keeps other Bash calls from starting Node.
25
25
  // The hook waits for status up to 10 s; 15 s leaves room for starting Node.
26
- function hookFor(config,relative){
27
- const rules=[...new Set(hookForms(config,relative).flatMap(row=>row.forms))].map(form=>`Bash(${form}*)`);
26
+ // Fan-out commands (turbo, nx, `pnpm -r`) and root scripts that run one get
27
+ // prefix rules; the hook decides from the full command.
28
+ function hookFor(config,relative,scripts){
29
+ const rules=[...new Set([...hookForms(config,relative).flatMap(row=>[...row.forms,...row.local]),
30
+ ...fanoutRules(config,scripts)])].map(form=>`Bash(${form}*)`);
28
31
  return rules.length?{matcher:'Bash',hooks:rules.map(rule=>({type:'command',if:rule,command:hookCommand,timeout:15}))}:null;
29
32
  }
33
+ // The same command on SessionStart starts the observer; it needs no check to match.
34
+ const sessionHook={hooks:[{type:'command',command:hookCommand,timeout:10}]};
35
+ const events=['PreToolUse','SessionStart'];
30
36
  // REDUE's entry is recognized by its command, not by a committed record: the
31
37
  // local file differs between checkouts, and users may edit the entry.
32
38
  const ownedHook=entry=>Array.isArray(entry?.hooks)&&entry.hooks.length>0&&entry.hooks.every(h=>h?.command===hookCommand);
@@ -35,23 +41,28 @@ function parseSettings(text){
35
41
  let value;try{value=JSON.parse(text);}catch{throw Error(`${settingsPath} is not valid JSON; fix it before agent setup or removal`);}
36
42
  if(!value||typeof value!=='object'||Array.isArray(value)||value.hooks!==undefined&&
37
43
  (!value.hooks||typeof value.hooks!=='object'||Array.isArray(value.hooks)||
38
- value.hooks.PreToolUse!==undefined&&!Array.isArray(value.hooks.PreToolUse)))
44
+ events.some(event=>value.hooks[event]!==undefined&&!Array.isArray(value.hooks[event]))))
39
45
  throw Error(`${settingsPath} has an unexpected shape; fix it before agent setup or removal`);
40
46
  return value;
41
47
  }
42
- // Replace REDUE's entries with `next` (or remove them). Unchanged input returns
43
- // the same text, so idempotent setup does not rewrite the file.
48
+ // Replace REDUE's entry for each event with next[event] (or remove it; a null
49
+ // `next` removes them all). Unchanged input returns the same text, so
50
+ // idempotent setup does not rewrite the file.
44
51
  function settingsWith(text,next){
45
- const value=parseSettings(text),old=value.hooks?.PreToolUse||[];
46
- const at=old.findIndex(ownedHook),list=old.filter(entry=>!ownedHook(entry));
47
- if(next)list.splice(at<0?list.length:at,0,next);
48
- if(sameJson(list,old))return text;
49
- if(list.length)value.hooks={...value.hooks,PreToolUse:list};
50
- else if(value.hooks){delete value.hooks.PreToolUse;if(!Object.keys(value.hooks).length)delete value.hooks;}
52
+ const value=parseSettings(text);let changed=false;
53
+ for(const event of events){
54
+ const old=value.hooks?.[event]||[],at=old.findIndex(ownedHook),list=old.filter(entry=>!ownedHook(entry));
55
+ if(next?.[event])list.splice(at<0?list.length:at,0,next[event]);
56
+ if(sameJson(list,old))continue;
57
+ changed=true;
58
+ if(list.length)value.hooks={...value.hooks,[event]:list};
59
+ else if(value.hooks){delete value.hooks[event];if(!Object.keys(value.hooks).length)delete value.hooks;}
60
+ }
61
+ if(!changed)return text;
51
62
  return Object.keys(value).length?JSON.stringify(value,null,2)+'\n':null;
52
63
  }
53
- function hookInstalled(root){
54
- try{return (parseSettings(read(root,settingsPath)).hooks?.PreToolUse||[]).some(ownedHook);}
64
+ function hookInstalled(root,event='PreToolUse'){
65
+ try{return (parseSettings(read(root,settingsPath)).hooks?.[event]||[]).some(ownedHook);}
55
66
  catch{return false;}
56
67
  }
57
68
  function safeFile(root,relative){
@@ -121,10 +132,10 @@ function earlierBlock(host,rows=[]){
121
132
  `If a check's last pass still applies to these exact files, a REDUE hook skips\n`+
122
133
  `the run and says so. The skip counts as a pass; don't check it again.\n`);
123
134
  }
124
- // One form per check: the npm script when there is one, since that is what
125
- // the project's own instructions usually name.
135
+ // One form per check: the package manager's script form when there is one,
136
+ // since that is what the project's own instructions usually name.
126
137
  const blockForms=(host,config,relative)=>host!=='claude'?[]:hookForms(config,relative).map(row=>({kind:row.kind,
127
- form:row.forms.find(form=>form.startsWith('npm run '))??row.forms.find(form=>!form.startsWith('redue '))??row.forms[0]}));
138
+ form:row.forms.find(form=>/^(?:npm run|pnpm|yarn) /.test(form))??row.forms.find(form=>!form.startsWith('redue '))??row.forms[0]}));
128
139
  const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${host}:begin -->\n`+
129
140
  `Before deciding whether configured verification needs repeating, use REDUE.\n`+
130
141
  (legacy?`Read ${hosts[host].skill} for the decision policy, then query ordinary status\n`+
@@ -143,7 +154,7 @@ const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${ho
143
154
  // varies, and only in the safe character set. Earlier owned versions are
144
155
  // recognized so setup can replace them; other stored text never is.
145
156
  function knownBlock(host,relative,block){
146
- const list='((?:`[A-Za-z0-9_./:=,+ -]+`(?: and )?)+)';
157
+ const list='((?:`[A-Za-z0-9_./:=,+@ -]+`(?: and )?)+)';
147
158
  const forms=pattern=>{const named=new RegExp(pattern).exec(block);return named?[...named[1].matchAll(/`([^`]+)`/g)].map(m=>m[1]):[];};
148
159
  const rows=[...forms(`^\\n\\n<!-- REDUE:[a-z]+:begin -->\\nTypecheck with ${list}\\.`).map(form=>({kind:'typecheck',form})),
149
160
  ...forms(`Run the unit tests with ${list}\\.\\n`).map(form=>({kind:'test',form}))];
@@ -245,7 +256,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
245
256
  if(value.state_access)console.log(`State access: ${value.state_access.status}${value.state_access.reason?' — '+value.state_access.reason:''}`);
246
257
  if(value.compatibility?.reason)console.log(`Observer compatibility: ${value.compatibility.reason}`);
247
258
  if(value.applicability?.requires_reassessment)console.log('Applicability: cached evidence needs reassessment; choose synchronization or execution deliberately.');
248
- for(const row of value.hosts)console.log(`${row.host}: ${row.version||'host unavailable'}; integration ${row.integrity}; hook ${row.hook}; behavioral validation NOT VERIFIED by doctor${row.reason?' — '+row.reason:''}`);
259
+ for(const row of value.hosts)console.log(`${row.host}: ${row.version||'host unavailable'}; integration ${row.integrity}; hook ${row.hook}${row.host==='claude'?`; session start ${row.session_start}`:''}; behavioral validation NOT VERIFIED by doctor${row.reason?' — '+row.reason:''}`);
249
260
  console.log('Doctor does not start observers, execute checks, or prove agent behavior.');
250
261
  for(const warning of value.warnings)console.log(`Review: ${warning}`);
251
262
  }return value;
@@ -261,14 +272,16 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
261
272
  if(!record&&(before?.includes('<!-- REDUE:')||read(root,h.skill)!==null))
262
273
  throw Error('unowned REDUE instruction or skill already exists; inspect it before setup');
263
274
  const after=record?before.replace(record.block,block):(before||'')+block;
264
- hook=host==='claude'?hookFor(config,relative):null;
275
+ let scripts={};try{scripts=JSON.parse(read(root,'package.json')??'{}').scripts||{};}catch{}
276
+ hook=host==='claude'?hookFor(config,relative,scripts):null;
265
277
  const next={schema:1,host,config:relative,instruction:h.instruction,skill:h.skill,
266
278
  block,skillHash:hash(skill),instructionCreated:record?.instructionCreated??before===null};
267
279
  changes.push({path:h.skill,before:read(root,h.skill),after:skill},
268
280
  {path:h.instruction,before,after});
269
- if(host==='claude'){const settings=read(root,settingsPath);
270
- changes.push({path:settingsPath,before:settings,after:settingsWith(settings,hook),
271
- content:hook?JSON.stringify(hook,null,2):'remove the REDUE PreToolUse hook (no typecheck or test check to match)'});}
281
+ if(host==='claude'){const settings=read(root,settingsPath),next={PreToolUse:hook,SessionStart:sessionHook};
282
+ changes.push({path:settingsPath,before:settings,after:settingsWith(settings,next),
283
+ content:JSON.stringify(hook?next:{SessionStart:sessionHook},null,2)+
284
+ (hook?'':'\nno PreToolUse hook: no typecheck or test check to match')});}
272
285
  changes.push({path:marker(host),before:read(root,marker(host)),after:JSON.stringify(next,null,2)+'\n'});
273
286
  }else if(record){
274
287
  const before=read(root,h.instruction),after=before.replace(record.block,'');
@@ -282,7 +295,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
282
295
  const summary={schema:1,action,host,config:configFile,applied:false,
283
296
  changes:actual.map(c=>({path:c.path,operation:c.after===null?'remove':c.before===null?'create':'update',
284
297
  ...(action==='setup'?{content:c.path===h.instruction?block:c.content??c.after}: {})})),
285
- warnings:notes,hooks:hook?`${settingsPath} PreToolUse`:'none',behavior_verified:false};
298
+ warnings:notes,hooks:host!=='claude'||action!=='setup'?'none':`${settingsPath} ${hook?'PreToolUse, ':''}SessionStart`,behavior_verified:false};
286
299
  if(!json){console.log(`${action} ${host}: ${configFile}`);
287
300
  for(const c of summary.changes)console.log(` ${c.operation} ${c.path}${c.content?'\n'+c.content:''}`);
288
301
  if(!actual.length)console.log('Already in the requested state.');
@@ -302,7 +315,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
302
315
  export async function doctor({configFile,stateOverride,entry,cwd,host}){
303
316
  const value={schema:1,command:{path:findExecutable('redue'),invoked:entry},project:{config:configFile},
304
317
  observation:null,control:null,state_access:null,observer_process:null,compatibility:null,
305
- applicability:null,hosts:[],warnings:[],hooks:`claude only: PreToolUse typecheck hook in ${settingsPath}`,behavior_verified:false};
318
+ applicability:null,hosts:[],warnings:[],hooks:`claude only: PreToolUse check hook and SessionStart observer start in ${settingsPath}`,behavior_verified:false};
306
319
  let p;try{p=project(configFile);value.project.root=p.root;
307
320
  const state=stateOverride||projectState(p.root,configFile);value.project.state=state;
308
321
  const cached=readCachedStatus(state,p.config.checks);value.observation=cached.observation;
@@ -333,12 +346,14 @@ export async function doctor({configFile,stateOverride,entry,cwd,host}){
333
346
  if(executable){const launch=windowsLaunch([executable,'--version']);
334
347
  const out=spawnSync(launch.file,launch.args,{...launch.options,encoding:'utf8',timeout:2000,maxBuffer:4096,stdio:['ignore','pipe','pipe']});
335
348
  if(out.status===0)version=out.stdout.trim();}
336
- let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none';
349
+ let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none',sessionHook=hook;
350
+ const installed=()=>value.command.path?'installed':'installed, but redue is not on PATH, so it never runs';
337
351
  if(p)try{const record=saved(p.root,name);integration=inspect(p.root,name,record);
338
- if(name==='claude'&&hookInstalled(p.root))hook=value.command.path?'installed':'installed, but redue is not on PATH, so it never runs';
352
+ if(name==='claude'&&hookInstalled(p.root))hook=installed();
353
+ if(name==='claude'&&hookInstalled(p.root,'SessionStart'))sessionHook=installed();
339
354
  value.warnings.push(...warnings(p.root,name,cwd));
340
355
  }catch(e){integration={installed:false,integrity:'conflict',reason:e.message};}
341
- value.hosts.push({host:name,executable,version,...integration,hook,behavior_verified:false,
356
+ value.hosts.push({host:name,executable,version,...integration,hook,session_start:sessionHook,behavior_verified:false,
342
357
  loading:name==='codex'?'AGENTS.md at session start; .agents/skills; overrides and size limits apply':
343
358
  'CLAUDE.md at session start; .claude/skills; managed/user policies and exclusions apply'});
344
359
  }
@@ -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
  }
@@ -161,6 +161,15 @@ function workspaceManifests(root,patterns) {
161
161
  if(matched.length>50)throw Error('more than 50 workspace packages found; select checks explicitly');
162
162
  return matched.map(file=>({file,pkg:readJson(path.join(root,file))}));
163
163
  }
164
+ // Workspace packages as init finds them, with their scripts; [] without workspaces.
165
+ export function workspacePackages(root){
166
+ root=realObservedPath(root);
167
+ const pkg=readJson(path.join(root,'package.json')),pm=manager(root,pkg);
168
+ const patterns=pm.name==='pnpm'?pm.workspacePatterns:Array.isArray(pkg.workspaces)?pkg.workspaces:pkg.workspaces?.packages;
169
+ if(!Array.isArray(patterns)||!patterns.length)return [];
170
+ return workspaceManifests(root,patterns).map(({file,pkg:member})=>
171
+ ({name:member.name,dir:path.posix.dirname(file),scripts:member.scripts&&typeof member.scripts==='object'?member.scripts:{}}));
172
+ }
164
173
  export function discoverNodeProject(root,{recipe=null}={}) {
165
174
  if(recipe&&!['typescript-direct','tests-direct'].includes(recipe))throw Error('unknown recipe; use typescript-direct or tests-direct');
166
175
  root=realObservedPath(root);