@redue/cli 0.1.0-alpha.14 → 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,51 @@
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.
32
+ ## 0.1.0-alpha.15 — 2026-10-22
33
+
34
+ No change to observation, status or `redue run` since alpha.14; the changes
35
+ are to the Claude Code integration.
36
+
37
+ - The CLAUDE.md block now tells agents to run the typecheck or test command
38
+ before saying it passes, even after an edit that seems irrelevant, and to
39
+ leave skipping to REDUE's hook. In the alpha.14 eval, two of six mixed-task
40
+ sessions skipped their final test run on their own after a README edit;
41
+ with the new block, none of six did (twilio and SwitchHosts, CI
42
+ 39702429765), at the same token cost. Run `redue agent setup claude --apply`
43
+ again to update the block; the earlier wording is recognized and replaced.
44
+ - The hook also matches a typecheck or test command after a leading
45
+ `cd <project root>;` or `cd <project root> &&`. A skip denies the whole
46
+ command; a rewrite keeps the `cd`. A `cd` to any other directory matches
47
+ nothing.
48
+
3
49
  ## 0.1.0-alpha.14 — 2026-10-21
4
50
 
5
51
  Bench numbers below are from one 800-file project on macOS arm64, Node 22.13.0.
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.14**. 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
 
@@ -156,7 +156,9 @@ Medians per task, summed over the three projects:
156
156
  source-change task skipped nothing; its time difference is session variance.
157
157
  In two mixed-task sessions the agent left out its final test run on its own
158
158
  after a README-only edit, without asking REDUE, and said so; the eval report
159
- lists such sessions. Cost was the same ($0.54 without, $0.56 with).
159
+ lists such sessions. Since alpha.15 the agent instructions ask agents to run
160
+ the check and leave skipping to REDUE; in a 12-session rerun of that task,
161
+ no agent left it out. Cost was the same ($0.54 without, $0.56 with).
160
162
 
161
163
  ## What the states mean
162
164
 
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.14
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.14
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.14
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.14",
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){
@@ -88,27 +99,43 @@ function saved(root,host){const text=read(root,marker(host));if(text===null)retu
88
99
  return value;
89
100
  }
90
101
  // The managed block is always in the agent's context, so it stays short. For
91
- // Claude Code it names the typecheck and test commands the hook matches and
92
- // what a skip means. The hook does the rest, so the block does not point to
93
- // the skill; the skill stays for agents that look for it. A typecheck-only
94
- // block keeps its earlier wording.
102
+ // Claude Code it names the typecheck and test commands the hook matches, asks
103
+ // the agent to run them rather than judge for itself that a rerun is not
104
+ // needed, and says what a skip means. The hook does the rest, so the block
105
+ // does not point to the skill; the skill stays for agents that look for it.
95
106
  const quoted=forms=>forms.map(form=>`\`${form}\``).join(' and ');
107
+ const commandsOf=rows=>[rows.filter(row=>row.kind!=='test').map(row=>row.form),rows.filter(row=>row.kind==='test').map(row=>row.form)];
108
+ const fence=(host,body)=>`\n\n<!-- REDUE:${host}:begin -->\n${body}<!-- REDUE:${host}:end -->\n`;
109
+ const skillPointer=host=>`Before you run, skip or report a typecheck or other configured check, read\n${hosts[host].skill}.\n`;
96
110
  function blockFor(host,rows=[]){
97
- const typechecks=rows.filter(row=>row.kind!=='test').map(row=>row.form),tests=rows.filter(row=>row.kind==='test').map(row=>row.form);
98
- return `\n\n<!-- REDUE:${host}:begin -->\n`+
99
- (!rows.length?`Before you run, skip or report a typecheck or other configured check, read\n`+
100
- `${hosts[host].skill}.\n`:!tests.length?`Typecheck with ${quoted(typechecks)}. If its last pass still applies to\n`+
111
+ const [typechecks,tests]=commandsOf(rows);
112
+ if(!rows.length)return fence(host,skillPointer(host));
113
+ if(!tests.length)return fence(host,`Typecheck with ${quoted(typechecks)}. Run it before you say it passes, even after an\n`+
114
+ `edit you think cannot matter; don't skip it yourself. If its last pass still\n`+
115
+ `applies to these exact files, a REDUE hook skips the run and says so. That\n`+
116
+ `skip counts as a pass.\n`);
117
+ return fence(host,(typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
118
+ `Run the unit tests with ${quoted(tests)}.\n`+
119
+ `Run a check before you say it passes, even after an edit you think cannot\n`+
120
+ `matter; don't skip it yourself. If its last pass still applies to these exact\n`+
121
+ `files, a REDUE hook skips the run and says so. That skip counts as a pass.\n`);
122
+ }
123
+ // The alpha.12-alpha.14 wording, recognized so setup can replace it.
124
+ function earlierBlock(host,rows=[]){
125
+ const [typechecks,tests]=commandsOf(rows);
126
+ if(!rows.length)return fence(host,skillPointer(host));
127
+ if(!tests.length)return fence(host,`Typecheck with ${quoted(typechecks)}. If its last pass still applies to\n`+
101
128
  `these exact files, a REDUE hook skips the run and says so. The skip counts as\n`+
102
- `a pass; don't check it again.\n`:
103
- (typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
129
+ `a pass; don't check it again.\n`);
130
+ return fence(host,(typechecks.length?`Typecheck with ${quoted(typechecks)}.\n`:'')+
104
131
  `Run the unit tests with ${quoted(tests)}.\n`+
105
132
  `If a check's last pass still applies to these exact files, a REDUE hook skips\n`+
106
- `the run and says so. The skip counts as a pass; don't check it again.\n`)+
107
- `<!-- REDUE:${host}:end -->\n`;}
108
- // One form per check: the npm script when there is one, since that is what
109
- // the project's own instructions usually name.
133
+ `the run and says so. The skip counts as a pass; don't check it again.\n`);
134
+ }
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.
110
137
  const blockForms=(host,config,relative)=>host!=='claude'?[]:hookForms(config,relative).map(row=>({kind:row.kind,
111
- 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]}));
112
139
  const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${host}:begin -->\n`+
113
140
  `Before deciding whether configured verification needs repeating, use REDUE.\n`+
114
141
  (legacy?`Read ${hosts[host].skill} for the decision policy, then query ordinary status\n`+
@@ -127,11 +154,11 @@ const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${ho
127
154
  // varies, and only in the safe character set. Earlier owned versions are
128
155
  // recognized so setup can replace them; other stored text never is.
129
156
  function knownBlock(host,relative,block){
130
- const list='((?:`[A-Za-z0-9_./:=,+ -]+`(?: and )?)+)';
157
+ const list='((?:`[A-Za-z0-9_./:=,+@ -]+`(?: and )?)+)';
131
158
  const forms=pattern=>{const named=new RegExp(pattern).exec(block);return named?[...named[1].matchAll(/`([^`]+)`/g)].map(m=>m[1]):[];};
132
159
  const rows=[...forms(`^\\n\\n<!-- REDUE:[a-z]+:begin -->\\nTypecheck with ${list}\\.`).map(form=>({kind:'typecheck',form})),
133
160
  ...forms(`Run the unit tests with ${list}\\.\\n`).map(form=>({kind:'test',form}))];
134
- return block===blockFor(host,rows)||priorBlocks(host,relative).includes(block);
161
+ return block===blockFor(host,rows)||block===earlierBlock(host,rows)||priorBlocks(host,relative).includes(block);
135
162
  }
136
163
  // The policy with the exact commands for this config, so an agent never builds
137
164
  // a --config form the hook would not recognize. Claude Code gets the typecheck
@@ -229,7 +256,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
229
256
  if(value.state_access)console.log(`State access: ${value.state_access.status}${value.state_access.reason?' — '+value.state_access.reason:''}`);
230
257
  if(value.compatibility?.reason)console.log(`Observer compatibility: ${value.compatibility.reason}`);
231
258
  if(value.applicability?.requires_reassessment)console.log('Applicability: cached evidence needs reassessment; choose synchronization or execution deliberately.');
232
- 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:''}`);
233
260
  console.log('Doctor does not start observers, execute checks, or prove agent behavior.');
234
261
  for(const warning of value.warnings)console.log(`Review: ${warning}`);
235
262
  }return value;
@@ -245,14 +272,16 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
245
272
  if(!record&&(before?.includes('<!-- REDUE:')||read(root,h.skill)!==null))
246
273
  throw Error('unowned REDUE instruction or skill already exists; inspect it before setup');
247
274
  const after=record?before.replace(record.block,block):(before||'')+block;
248
- 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;
249
277
  const next={schema:1,host,config:relative,instruction:h.instruction,skill:h.skill,
250
278
  block,skillHash:hash(skill),instructionCreated:record?.instructionCreated??before===null};
251
279
  changes.push({path:h.skill,before:read(root,h.skill),after:skill},
252
280
  {path:h.instruction,before,after});
253
- if(host==='claude'){const settings=read(root,settingsPath);
254
- changes.push({path:settingsPath,before:settings,after:settingsWith(settings,hook),
255
- 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')});}
256
285
  changes.push({path:marker(host),before:read(root,marker(host)),after:JSON.stringify(next,null,2)+'\n'});
257
286
  }else if(record){
258
287
  const before=read(root,h.instruction),after=before.replace(record.block,'');
@@ -266,7 +295,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
266
295
  const summary={schema:1,action,host,config:configFile,applied:false,
267
296
  changes:actual.map(c=>({path:c.path,operation:c.after===null?'remove':c.before===null?'create':'update',
268
297
  ...(action==='setup'?{content:c.path===h.instruction?block:c.content??c.after}: {})})),
269
- 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};
270
299
  if(!json){console.log(`${action} ${host}: ${configFile}`);
271
300
  for(const c of summary.changes)console.log(` ${c.operation} ${c.path}${c.content?'\n'+c.content:''}`);
272
301
  if(!actual.length)console.log('Already in the requested state.');
@@ -286,7 +315,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
286
315
  export async function doctor({configFile,stateOverride,entry,cwd,host}){
287
316
  const value={schema:1,command:{path:findExecutable('redue'),invoked:entry},project:{config:configFile},
288
317
  observation:null,control:null,state_access:null,observer_process:null,compatibility:null,
289
- 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};
290
319
  let p;try{p=project(configFile);value.project.root=p.root;
291
320
  const state=stateOverride||projectState(p.root,configFile);value.project.state=state;
292
321
  const cached=readCachedStatus(state,p.config.checks);value.observation=cached.observation;
@@ -317,12 +346,14 @@ export async function doctor({configFile,stateOverride,entry,cwd,host}){
317
346
  if(executable){const launch=windowsLaunch([executable,'--version']);
318
347
  const out=spawnSync(launch.file,launch.args,{...launch.options,encoding:'utf8',timeout:2000,maxBuffer:4096,stdio:['ignore','pipe','pipe']});
319
348
  if(out.status===0)version=out.stdout.trim();}
320
- 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';
321
351
  if(p)try{const record=saved(p.root,name);integration=inspect(p.root,name,record);
322
- 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();
323
354
  value.warnings.push(...warnings(p.root,name,cwd));
324
355
  }catch(e){integration={installed:false,integrity:'conflict',reason:e.message};}
325
- 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,
326
357
  loading:name==='codex'?'AGENTS.md at session start; .agents/skills; overrides and size limits apply':
327
358
  'CLAUDE.md at session start; .claude/skills; managed/user policies and exclusions apply'});
328
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
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,6 +213,30 @@ export function recordedCommand(command,name,configFile=defaultConfig){
73
213
  return parts.base===run?null:run+parts.suffix;
74
214
  }
75
215
 
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
220
+ // `~` or escapes. Inside double quotes a backslash before anything but
221
+ // $ ` " \ is literal, so a quoted Windows path matches; unquoted it is an escape.
222
+ const cdPrefix=/^[ \t]*cd[ \t]+(?:"((?:[^"$`\\]|\\(?![$`"\\\n]))*)"|'([^']*)'|((?!~)[^\s;&|'"$`\\]+))[ \t]*(?:;|&&)[ \t]*/;
223
+ export function enteredDir(command,cwd,projectRoot){
224
+ const prefix=typeof command==='string'?cdPrefix.exec(command):null;
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('/'):'.'};
230
+ }
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
+ }
76
240
  export function verdict(status,name,rest=null){
77
241
  const row=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)?
78
242
  status.checks.find(item=>item?.name===name):null;
@@ -119,18 +283,56 @@ export function lastRunTimeout(config,parsed,name){
119
283
  catch{return 2000;}
120
284
  }
121
285
 
122
- // Returns the hook's stdout object, or null to allow the command.
123
- export async function claudeHook(input,{entry,timeout}){
124
- if(input?.tool_name!=='Bash')return null;
125
- const command=input.tool_input?.command,root=input.cwd;
126
- 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){
127
289
  if(typeof root!=='string'||!path.isAbsolute(root))return null;
128
290
  const record=JSON.parse(fs.readFileSync(path.join(root,'.redue','agents','claude.json'),'utf8'));
129
291
  if(record?.schema!==1||record.host!=='claude'||typeof record.config!=='string'||
130
292
  !/^[^/\\]+\.json$/.test(record.config))return null;
131
293
  const config=path.join(root,record.config);
132
- const parsed=JSON.parse(fs.readFileSync(config,'utf8')),match=matched(command,parsed,record.config);
294
+ const parsed=JSON.parse(fs.readFileSync(config,'utf8'));
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);
133
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
+ }
134
336
  const name=match.check,status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,name)),
135
337
  reason=verdict(status,name,match.rest);
136
338
  if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',
@@ -140,19 +342,23 @@ export async function claudeHook(input,{entry,timeout}){
140
342
  // stays too: the rewrite returns "allow", which would also skip the prompt
141
343
  // for the rest of the chain.
142
344
  const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
143
- status.checks.some(item=>item?.name===name)?recordedCommand(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;
144
348
  // updatedInput replaces the whole tool input. Deny and ask rules still apply
145
349
  // to the rewritten command; "allow" skips only the prompt.
146
350
  return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
147
351
  permissionDecisionReason:`REDUE: check ${JSON.stringify(name)} is not CURRENT; running it through redue run so the result is recorded.`,
148
- updatedInput:{...input.tool_input,command:rewritten}}}:null;
352
+ updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
149
353
  }
150
354
 
151
355
  export async function runClaudeHook(entry){
152
356
  try{
153
357
  let text='';process.stdin.setEncoding('utf8');
154
358
  for await(const chunk of process.stdin){text+=chunk;if(text.length>1024*1024)return;}
155
- 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});
156
362
  if(output)await new Promise(resolve=>process.stdout.write(JSON.stringify(output)+'\n',resolve));
157
363
  }catch{}
158
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);