@redue/cli 0.1.0-alpha.10 → 0.1.0-alpha.11

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,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.11 — 2026-10-18
4
+
5
+ Bench numbers below are from one 800-file project on macOS arm64, Node 22.13.0.
6
+ Released alpha.10 and this build ran back to back on the same machine.
7
+
8
+ - Faster status decisions for the direct TypeScript recipe on macOS and Linux.
9
+ The observer checks the compiler's recorded filesystem questions in-process
10
+ instead of starting a probe that rebuilds the whole program. A full probe runs
11
+ only when that check fails, the plan or toolchain changes, or observation was
12
+ interrupted. While idle it no longer starts any probe process.
13
+ - warm `status --sync`: 741 → 194 ms (+683 → +154 ms over cached `status`)
14
+ - `status --sync` after an unrelated edit: 619 → 219 ms
15
+ - `status --sync` after a relevant edit: 1472 → 321 ms. It answers STALE without
16
+ a full probe when the edited inputs already differ from the receipt.
17
+ - probe processes started in 20 s idle: 4 → 0
18
+ - Cheaper `redue run` on macOS and Linux. The compiler records its own inputs
19
+ while it runs, so REDUE no longer probes before and after the compile. After
20
+ a relevant edit, the run builds the new certificate from that recording instead
21
+ of running a full probe. Output and exit code are those of plain `tsc`.
22
+ - repeat run: 4143 → 2752 ms, overhead over plain `tsc` 72% → 16%
23
+ - run right after a relevant edit: 3990 → 2802 ms, overhead 65% → 18%
24
+ - The direct TypeScript recipe accepts inert Node settings. Before, any non-empty
25
+ `NODE_*` setting other than `NODE_ENV` kept a check from becoming CURRENT.
26
+ Now accepted: `NODE_EXTRA_CA_CERTS`, `NODE_PATH`, and `NODE_OPTIONS` made only of
27
+ `--max-old-space-size`, `--max-semi-space-size`, `--use-openssl-ca`,
28
+ `--use-bundled-ca`, `--use-system-ca` and `--dns-result-order`. A run under one
29
+ value is not reused under another. See [Node settings](docs/user/direct-typescript.md#node-settings).
30
+ - `redue agent setup claude --apply` adds a Claude Code hook to
31
+ `.claude/settings.local.json`. When the agent reruns a configured typecheck
32
+ that is CURRENT and PASS, the hook blocks it and names the passing run. When
33
+ the check is not CURRENT, it rewrites the command to `redue run CHECK`, so the
34
+ result is recorded and the next identical command can be skipped. Prefix a
35
+ command with `REDUE_FORCE=1` to always run it. On the bench: a skip decision
36
+ takes about 155 ms; a forced command adds 39 ms. Tested live with Claude Code
37
+ 2.1.302 (`claude -p`). See [the hook](docs/user/agents.md#claude-code-typecheck-hook).
38
+ - Unstable-run and observation-gap reasons now name the failed check or operation.
39
+ - macOS counts each input event once, which removes an intermittent UNVERIFIED
40
+ after an edit.
41
+
42
+ Known limits:
43
+
44
+ - Two bench targets are still missed: warm sync adds 154 ms over cached status
45
+ (target < 100 ms), and `redue run` adds 16–18% over plain `tsc` (target < 10%).
46
+ - An edit that changes imports, configuration or lib files still costs one full
47
+ probe, at the end of the next run.
48
+ - Windows keeps the earlier probe-based path, except the STALE shortcut. Yarn
49
+ and pnpm recipes still probe on each decision. The bench was not run on Linux.
50
+ - The Claude hook covers the Bash tool only, from the project root. It allows the
51
+ rewritten command without a permission prompt. It judges its own environment,
52
+ not variables the Bash tool adds from your shell profile.
53
+ - To upgrade, stop observers, install into the same prefix, and keep config, state
54
+ and receipts. Rerun `redue agent setup claude --apply` to add the hook.
55
+
3
56
  ## 0.1.0-alpha.10 — 2026-10-10
4
57
 
5
58
  - Combine guarded caller-discovery reuse and cheaper non-reusable recording on
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.10**. Follow the
40
+ The opt-in pilot uses **0.1.0-alpha.11**. 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
@@ -9,12 +9,10 @@ import path from 'node:path';
9
9
  import {createHash} from 'node:crypto';
10
10
  import {spawn,spawnSync} from 'node:child_process';
11
11
  import {createRequire} from 'node:module';
12
- import {fileURLToPath} from 'node:url';
13
- import {discoverNodeProject} from '../src/node-onboarding.mjs';
12
+ import {fileURLToPath,pathToFileURL} from 'node:url';
14
13
  import {processAlive} from '../src/process-liveness.mjs';
15
14
  import {samePath,withinPath,realObservedPath} from '../src/path-identity.mjs';
16
15
  import {discoverConfig,projectState,canonicalConfig} from '../src/project-location.mjs';
17
- import {agentCommand} from '../src/agent-integration.mjs';
18
16
  import {findExecutable} from '../src/executable-lookup.mjs';
19
17
  import {readCachedStatus} from '../src/cached-state.mjs';
20
18
  import {assertOwnedStatePlacement} from '../src/owned-state.mjs';
@@ -46,6 +44,12 @@ No checks run in the background. Direct commands outside redue create no receipt
46
44
 
47
45
  function error(message){console.error(`redue: ${message}`);process.exit(2);}
48
46
  const args=process.argv.slice(2);
47
+ if(args.length===3&&args[0]==='agent'&&args[1]==='hook'&&args[2]==='claude'){
48
+ // Runs before config discovery: the hook must exit 0 on every failure,
49
+ // because Claude Code treats exit 2 as a block.
50
+ const {runClaudeHook}=await import('../src/claude-hook.mjs');
51
+ await runClaudeHook(fileURLToPath(import.meta.url));process.exit(0);
52
+ }
49
53
  if(args.length===1&&args[0]==='--version'){console.log(version);process.exit(0);}
50
54
  if(args.includes('--help')||args.includes('-h')||!args.length){console.log(help);process.exit(0);}
51
55
  let configFile=null,stateOverride=null;
@@ -62,8 +66,12 @@ try{configFile??=command==='init'||stateOverride&&['stop','remove-state'].includ
62
66
  catch(e){error(e.message);}
63
67
  if(fs.existsSync(path.dirname(configFile)))configFile=canonicalConfig(configFile);
64
68
  if(command==='agent'){
69
+ // Loaded on demand: onboarding and agent setup are not on the run/status path.
70
+ const {agentCommand}=await import('../src/agent-integration.mjs');
65
71
  try{await agentCommand(args,{configFile,stateOverride,entry:fileURLToPath(import.meta.url)});}
66
72
  catch(e){error(e.message);}
73
+ // Exiting with piped output still queued truncates it (8 KB on macOS).
74
+ await new Promise(resolve=>process.stdout.write('',resolve));
67
75
  process.exit(0);
68
76
  }
69
77
  if(!['init','start','stop','status','detail','explain','run','remove-state'].includes(command))
@@ -88,6 +96,7 @@ if(short&&(command!=='status'||json||sync))error('--short is a cached status opt
88
96
  if(dryRun&&command!=='init')error('--dry-run is for init');
89
97
 
90
98
  if(command==='init'){
99
+ const {discoverNodeProject}=await import('../src/node-onboarding.mjs');
91
100
  let discovery;try{discovery=discoverNodeProject(process.cwd(),{recipe:recipeSelection});}
92
101
  catch(e){error(e.message);}
93
102
  if(workspaceSelection||checkSelection){
@@ -322,7 +331,13 @@ if(command==='status'&&!sync){
322
331
  const details=command==='detail'||command==='explain';
323
332
  const internal=command==='remove-state'?'uninstall':
324
333
  details?'sync':command==='status'&&sync?'sync':command;
325
- if(command==='run'){
334
+ // On macOS and Linux the engine runs in this process, which saves a Node startup
335
+ // on every run; its own handlers receive SIGINT and SIGTERM. Windows keeps a
336
+ // child engine because cancellation reaches it over IPC.
337
+ if(command==='run'&&process.platform!=='win32'){
338
+ process.argv=[process.argv[0],engine,runtimeFile,internal,check,...(!json?['--human']:[])];
339
+ await import(pathToFileURL(engine).href);
340
+ }else if(command==='run'){
326
341
  const child=spawn(process.execPath,[engine,runtimeFile,internal,check,...(!json?['--human']:[])],
327
342
  {stdio:['inherit','inherit','inherit','ipc'],env:process.env});
328
343
  const forward=signal=>{if(child.exitCode!==null)return;
@@ -337,19 +352,20 @@ if(command==='run'){
337
352
  process.off('SIGINT',interrupt);process.off('SIGTERM',terminate);
338
353
  if(outcome.error)error(outcome.error.message);
339
354
  process.exit(outcome.status??(outcome.signal?128+os.constants.signals[outcome.signal]:1));
355
+ }else{
356
+ mark('client.engine_submit');
357
+ const result=spawnSync(process.execPath,[engine,runtimeFile,internal,...(check?[check]:[])],
358
+ {stdio:['inherit','pipe','inherit'],
359
+ encoding:'utf8',env:traceEnvironment()});
360
+ mark('client.engine_complete',{exit:result.status,signal:result.signal});
361
+ if(result.error)error(result.error.message);
362
+ if(result.stdout){
363
+ if(['status','detail','explain'].includes(command)){
364
+ let value;try{value=JSON.parse(result.stdout);}catch{error('status response could not be read; applicability is unknown');}
365
+ if(details&&check)value={...value,checks:value.checks.filter(row=>row.name===check)};
366
+ if(json)console.log(JSON.stringify(value));
367
+ else console.log(details?renderExplain(value,check):renderStatus(value,{short}));
368
+ }else printLifecycle(result.stdout,command,json);
369
+ }
370
+ process.exit(result.status??1);
340
371
  }
341
- mark('client.engine_submit');
342
- const result=spawnSync(process.execPath,[engine,runtimeFile,internal,...(check?[check]:[])],
343
- {stdio:['inherit','pipe','inherit'],
344
- encoding:'utf8',env:traceEnvironment()});
345
- mark('client.engine_complete',{exit:result.status,signal:result.signal});
346
- if(result.error)error(result.error.message);
347
- if(result.stdout){
348
- if(['status','detail','explain'].includes(command)){
349
- let value;try{value=JSON.parse(result.stdout);}catch{error('status response could not be read; applicability is unknown');}
350
- if(details&&check)value={...value,checks:value.checks.filter(row=>row.name===check)};
351
- if(json)console.log(JSON.stringify(value));
352
- else console.log(details?renderExplain(value,check):renderStatus(value,{short}));
353
- }else printLifecycle(result.stdout,command,json);
354
- }
355
- process.exit(result.status??1);
@@ -71,6 +71,51 @@ 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
72
  or execute verification. Use `redue start` when you want observation.
73
73
 
74
+ ## Claude Code typecheck hook
75
+
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
78
+ per-developer; keep that file out of commits. Other settings in the file are kept,
79
+ 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.
81
+
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:
85
+
86
+ - `redue run CHECK`
87
+ - `npm run SCRIPT`, when the check has an npm script
88
+ - `npx tsc --noEmit`, for the direct-TypeScript recipe
89
+ - the check's `command`, when it has no `@` tokens
90
+
91
+ Each form may end with `2>&1` and then one `| tail -n N`, `| head -n N`,
92
+ `| tail -N` or `| head -N`. Nothing else matches. Each form has its own `if`
93
+ rule, so other Bash commands never start the hook.
94
+
95
+ On a match, the hook runs `redue status --sync --json` with a 2-second timeout.
96
+ It blocks the command only when the check is CURRENT, PASS and
97
+ `reuse_eligible: true`. The message names the check and the receipt's run ID. Every
98
+ other result allows the command: STALE, UNVERIFIED, FAILED, a timeout, an error, or
99
+ a missing `redue`. A command that starts with `REDUE_FORCE=1` always runs.
100
+
101
+ When the check is not CURRENT but the observer answered for it, the hook rewrites
102
+ the command to `redue run CHECK` and allows it, keeping any `2>&1` and
103
+ `| tail`/`| head` suffix. It adds `--config FILE` when the config is not
104
+ `redue.config.json`. The run is recorded, so the next identical command can be
105
+ skipped. Without a healthy answer (timeout, error, no observer) the command runs
106
+ as typed. Claude Code still applies deny and ask rules to the rewritten command.
107
+
108
+ Limits:
109
+
110
+ - The hook judges its own environment, which Claude Code passes to it. The Bash
111
+ tool can add variables from your shell profile. The hook cannot see those.
112
+ - Commands run from a subdirectory, or combined with `&&`, `;` or other pipes,
113
+ are not matched.
114
+ - Only the Bash tool is covered, not PowerShell.
115
+ - Run setup again after you rename a check or script.
116
+ - The rewrite is allowed without a permission prompt, even where the original
117
+ command would have prompted.
118
+
74
119
  ## Update an existing connection
75
120
 
76
121
  Installing a newer package does **not** rewrite project instructions. From the
@@ -138,7 +183,8 @@ request; it does not synchronize or run these probes.
138
183
  config resolution, cached observer health, a bounded read-only control-access probe,
139
184
  managed-file integrity, detected host
140
185
  version, and loading guidance. `behavior_verified: false` is deliberate: intact
141
- files do not prove the agent followed them. No hooks are installed. Doctor neither
186
+ files do not prove the agent followed them. For Claude, doctor reports whether the
187
+ typecheck hook is installed. Doctor neither
142
188
  executes checks nor starts observers, reconciles inputs, or installs a host.
143
189
 
144
190
  Doctor separates missing/unreadable state, a missing or refused endpoint, an
@@ -94,3 +94,21 @@ explicit request for `npm run typecheck` is not satisfied by this different comm
94
94
  The injected `NODE_USE_ENV_PROXY=1` setting is admitted only on Node 22.13.0,
95
95
  where it is inert; it remains strictly tracked. Other Node versions with this
96
96
  setting need review and stay unqualified. REDUE does not alter the setting.
97
+
98
+ ## Node settings
99
+
100
+ Since alpha.11 the direct recipe accepts these `NODE_*` settings:
101
+
102
+ - `NODE_OPTIONS`, when every token is one of `--max-old-space-size=<n>`,
103
+ `--max-semi-space-size=<n>`, `--use-openssl-ca`, `--use-bundled-ca`,
104
+ `--use-system-ca` or `--dns-result-order=ipv4first|ipv6first|verbatim`.
105
+ Any other flag, quotes, backslashes or tabs are refused.
106
+ - `NODE_EXTRA_CA_CERTS`.
107
+ - `NODE_PATH`.
108
+
109
+ `NODE_ENV` set to `production`, `test` or empty was already accepted. Every other non-empty
110
+ `NODE_*` setting withholds applicability. The values are part of the recorded
111
+ execution context: a run under one value is not reusable under another. A
112
+ `--require` in `NODE_OPTIONS` still runs inside REDUE's own process before REDUE
113
+ refuses it. The npm, pnpm and Yarn recipes still refuse `NODE_OPTIONS` and
114
+ `NODE_PATH`.
@@ -1,4 +1,4 @@
1
- # Opt-in development pilot: alpha.10
1
+ # Opt-in development pilot: alpha.11
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
@@ -16,7 +16,8 @@ outside-root inputs are added by this pilot. Do not downgrade or change your
16
16
  project to fit; record an unsupported setup as feedback.
17
17
 
18
18
  Development-mode source-map support, Node preloads/inspectors/compile-cache
19
- overrides and `TS_ETW_MODULE_PATH` are unsupported. The injected
19
+ overrides and `TS_ETW_MODULE_PATH` are unsupported. A few inert Node settings
20
+ are accepted; see [Node settings](direct-typescript.md#node-settings). The injected
20
21
  `NODE_USE_ENV_PROXY=1` context is reviewed only on Node **22.13.0**; leave the
21
22
  environment and security controls intact. See the [full recipe boundary](direct-typescript.md).
22
23
 
@@ -24,15 +25,17 @@ CLI/package mechanics are tested on macOS/APFS, native Linux and Windows/NTFS;
24
25
  Linux also needs Python 3 and [supported native storage](getting-started.md).
25
26
  Actual fresh-session behavior was tested with Codex **0.162.0 on macOS**, Node
26
27
  22.13.0 and the approved exact checkout/state/socket access. This is not general
27
- sandbox compatibility. Claude mechanics are tested; Claude behavior and cross-host
28
- handoff are **NOT RUN**. The recording shortcut is macOS/direct-recipe only.
28
+ sandbox compatibility. Claude mechanics are tested, and the
29
+ [Claude Code typecheck hook](agents.md#claude-code-typecheck-hook) was exercised in
30
+ Claude Code 2.1.302 (`claude -p`). Broader Claude behavior and cross-host handoff
31
+ are **NOT RUN**. The recording shortcut is macOS/direct-recipe only.
29
32
 
30
33
  ## Install and connect explicitly
31
34
 
32
35
  For a new default npm installation:
33
36
 
34
37
  ```sh
35
- npm install --global @redue/cli@0.1.0-alpha.10
38
+ npm install --global @redue/cli@0.1.0-alpha.11
36
39
  redue --version
37
40
  cd my-project
38
41
  redue --config redue.config.json init --recipe typescript-direct --check typecheck --dry-run
@@ -86,7 +89,7 @@ using it; keep the same npm prefix, config and any explicit state selection.
86
89
 
87
90
  ```sh
88
91
  redue --config redue.config.json stop
89
- npm install --global @redue/cli@0.1.0-alpha.10
92
+ npm install --global @redue/cli@0.1.0-alpha.11
90
93
  redue --config redue.config.json agent setup codex --dry-run
91
94
  redue --config redue.config.json agent setup codex --apply
92
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.10",
4
+ "version": "0.1.0-alpha.11",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
@@ -11,12 +11,48 @@ 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} from './claude-hook.mjs';
14
15
 
15
16
  const policy=fs.readFileSync(new URL('./agent-policy.md',import.meta.url),'utf8');
16
17
  const hosts={codex:{instruction:'AGENTS.md',skill:'.agents/skills/redue-verification/SKILL.md'},
17
18
  claude:{instruction:'CLAUDE.md',skill:'.claude/skills/redue-verification/SKILL.md'}};
18
19
  const hash=s=>createHash('sha256').update(s).digest('hex');
19
20
  const marker=host=>`.redue/agents/${host}.json`;
21
+ // The hook needs a local REDUE install, so it lives in per-developer settings.
22
+ const settingsPath='.claude/settings.local.json';
23
+ const sameJson=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
24
+ // One handler per exact command form; `if` keeps other Bash calls from starting Node.
25
+ function hookFor(config){
26
+ const rules=[...new Set(hookForms(config).flatMap(row=>row.forms))].map(form=>`Bash(${form}*)`);
27
+ return rules.length?{matcher:'Bash',hooks:rules.map(rule=>({type:'command',if:rule,command:hookCommand,timeout:5}))}:null;
28
+ }
29
+ // REDUE's entry is recognized by its command, not by a committed record: the
30
+ // local file differs between checkouts, and users may edit the entry.
31
+ const ownedHook=entry=>Array.isArray(entry?.hooks)&&entry.hooks.length>0&&entry.hooks.every(h=>h?.command===hookCommand);
32
+ function parseSettings(text){
33
+ if(text===null)return {};
34
+ let value;try{value=JSON.parse(text);}catch{throw Error(`${settingsPath} is not valid JSON; fix it before agent setup or removal`);}
35
+ if(!value||typeof value!=='object'||Array.isArray(value)||value.hooks!==undefined&&
36
+ (!value.hooks||typeof value.hooks!=='object'||Array.isArray(value.hooks)||
37
+ value.hooks.PreToolUse!==undefined&&!Array.isArray(value.hooks.PreToolUse)))
38
+ throw Error(`${settingsPath} has an unexpected shape; fix it before agent setup or removal`);
39
+ return value;
40
+ }
41
+ // Replace REDUE's entries with `next` (or remove them). Unchanged input returns
42
+ // the same text, so idempotent setup does not rewrite the file.
43
+ function settingsWith(text,next){
44
+ const value=parseSettings(text),old=value.hooks?.PreToolUse||[];
45
+ const at=old.findIndex(ownedHook),list=old.filter(entry=>!ownedHook(entry));
46
+ if(next)list.splice(at<0?list.length:at,0,next);
47
+ if(sameJson(list,old))return text;
48
+ if(list.length)value.hooks={...value.hooks,PreToolUse:list};
49
+ else if(value.hooks){delete value.hooks.PreToolUse;if(!Object.keys(value.hooks).length)delete value.hooks;}
50
+ return Object.keys(value).length?JSON.stringify(value,null,2)+'\n':null;
51
+ }
52
+ function hookInstalled(root){
53
+ try{return (parseSettings(read(root,settingsPath)).hooks?.PreToolUse||[]).some(ownedHook);}
54
+ catch{return false;}
55
+ }
20
56
  function safeFile(root,relative){
21
57
  if(path.isAbsolute(relative)||relative.split(/[\\/]/).some(p=>p==='..'||p===''))throw Error('unsafe integration path');
22
58
  const file=path.resolve(root,relative);
@@ -88,6 +124,8 @@ function warnings(root,host,cwd){
88
124
  }
89
125
  if(host==='claude'&&read(root,'.claude/CLAUDE.md')!==null)
90
126
  throw Error('.claude/CLAUDE.md already exists; reconcile its project policy with root CLAUDE.md before setup');
127
+ if(host==='claude'&&spawnSync('git',['check-ignore','-q',settingsPath],{cwd:root,stdio:'ignore'}).status===1)
128
+ result.push(`${settingsPath} is not ignored by Git; the hook needs a local REDUE install, so keep it out of commits.`);
91
129
  let dir=path.resolve(cwd);
92
130
  while(withinPath(dir,root)&&!samePath(dir,root)){
93
131
  for(const name of ['AGENTS.md','AGENTS.override.md','CLAUDE.md','CLAUDE.local.md'])
@@ -137,36 +175,43 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
137
175
  if(value.state_access)console.log(`State access: ${value.state_access.status}${value.state_access.reason?' — '+value.state_access.reason:''}`);
138
176
  if(value.compatibility?.reason)console.log(`Observer compatibility: ${value.compatibility.reason}`);
139
177
  if(value.applicability?.requires_reassessment)console.log('Applicability: cached evidence needs reassessment; choose synchronization or execution deliberately.');
140
- for(const row of value.hosts)console.log(`${row.host}: ${row.version||'host unavailable'}; integration ${row.integrity}; behavioral validation NOT VERIFIED by doctor${row.reason?' — '+row.reason:''}`);
141
- console.log('Hooks: none installed by REDUE. Doctor does not start observers, execute checks, or prove agent behavior.');
178
+ 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:''}`);
179
+ console.log('Doctor does not start observers, execute checks, or prove agent behavior.');
142
180
  for(const warning of value.warnings)console.log(`Review: ${warning}`);
143
181
  }return value;
144
182
  }
145
183
  if(stateOverride)throw Error('agent setup/removal uses the selected project config, not a committed machine-local --state-dir; use the generic path for custom state');
146
- const {root,relative}=project(configFile),h=hosts[host],record=saved(root,host),health=inspect(root,host,record);
184
+ const {root,relative,config}=project(configFile),h=hosts[host],record=saved(root,host),health=inspect(root,host,record);
147
185
  if(health.integrity==='conflict')throw Error(health.reason);
148
186
  const changes=[],notes=action==='setup'?warnings(root,host,cwd):[];
187
+ let hook=null;
149
188
  if(action==='setup'){
150
189
  const before=read(root,h.instruction),skill=skillFor(relative),block=blockFor(host,relative);
151
190
  if(!record&&(before?.includes('<!-- REDUE:')||read(root,h.skill)!==null))
152
191
  throw Error('unowned REDUE instruction or skill already exists; inspect it before setup');
153
192
  const after=record?before.replace(record.block,block):(before||'')+block;
193
+ hook=host==='claude'?hookFor(config):null;
154
194
  const next={schema:1,host,config:relative,instruction:h.instruction,skill:h.skill,
155
195
  block,skillHash:hash(skill),instructionCreated:record?.instructionCreated??before===null};
156
196
  changes.push({path:h.skill,before:read(root,h.skill),after:skill},
157
- {path:h.instruction,before,after},
158
- {path:marker(host),before:read(root,marker(host)),after:JSON.stringify(next,null,2)+'\n'});
197
+ {path:h.instruction,before,after});
198
+ if(host==='claude'){const settings=read(root,settingsPath);
199
+ changes.push({path:settingsPath,before:settings,after:settingsWith(settings,hook),
200
+ content:hook?JSON.stringify(hook,null,2):'remove the REDUE PreToolUse hook (no typecheck check to match)'});}
201
+ changes.push({path:marker(host),before:read(root,marker(host)),after:JSON.stringify(next,null,2)+'\n'});
159
202
  }else if(record){
160
203
  const before=read(root,h.instruction),after=before.replace(record.block,'');
161
204
  changes.push({path:h.instruction,before,after:record.instructionCreated&&after===''?null:after},
162
- {path:h.skill,before:read(root,h.skill),after:null},
163
- {path:marker(host),before:read(root,marker(host)),after:null});
205
+ {path:h.skill,before:read(root,h.skill),after:null});
206
+ if(host==='claude'){const settings=read(root,settingsPath);
207
+ changes.push({path:settingsPath,before:settings,after:settingsWith(settings,null)});}
208
+ changes.push({path:marker(host),before:read(root,marker(host)),after:null});
164
209
  }
165
210
  const actual=changes.filter(c=>c.before!==c.after);
166
211
  const summary={schema:1,action,host,config:configFile,applied:false,
167
212
  changes:actual.map(c=>({path:c.path,operation:c.after===null?'remove':c.before===null?'create':'update',
168
- ...(action==='setup'?{content:c.path===h.instruction?blockFor(host,relative):c.after}: {})})),
169
- warnings:notes,hooks:'none',behavior_verified:false};
213
+ ...(action==='setup'?{content:c.path===h.instruction?blockFor(host,relative):c.content??c.after}: {})})),
214
+ warnings:notes,hooks:hook?`${settingsPath} PreToolUse`:'none',behavior_verified:false};
170
215
  if(!json){console.log(`${action} ${host}: ${configFile}`);
171
216
  for(const c of summary.changes)console.log(` ${c.operation} ${c.path}${c.content?'\n'+c.content:''}`);
172
217
  if(!actual.length)console.log('Already in the requested state.');
@@ -186,7 +231,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
186
231
  export async function doctor({configFile,stateOverride,entry,cwd,host}){
187
232
  const value={schema:1,command:{path:findExecutable('redue'),invoked:entry},project:{config:configFile},
188
233
  observation:null,control:null,state_access:null,observer_process:null,compatibility:null,
189
- applicability:null,hosts:[],warnings:[],hooks:'none installed by REDUE',behavior_verified:false};
234
+ applicability:null,hosts:[],warnings:[],hooks:`claude only: PreToolUse typecheck hook in ${settingsPath}`,behavior_verified:false};
190
235
  let p;try{p=project(configFile);value.project.root=p.root;
191
236
  const state=stateOverride||projectState(p.root,configFile);value.project.state=state;
192
237
  const cached=readCachedStatus(state,p.config.checks);value.observation=cached.observation;
@@ -217,11 +262,12 @@ export async function doctor({configFile,stateOverride,entry,cwd,host}){
217
262
  if(executable){const launch=windowsLaunch([executable,'--version']);
218
263
  const out=spawnSync(launch.file,launch.args,{...launch.options,encoding:'utf8',timeout:2000,maxBuffer:4096,stdio:['ignore','pipe','pipe']});
219
264
  if(out.status===0)version=out.stdout.trim();}
220
- let integration={installed:false,integrity:'unknown'};
221
- if(p)try{integration=inspect(p.root,name,saved(p.root,name));
265
+ let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none';
266
+ if(p)try{const record=saved(p.root,name);integration=inspect(p.root,name,record);
267
+ if(name==='claude'&&hookInstalled(p.root))hook=value.command.path?'installed':'installed, but redue is not on PATH, so it never runs';
222
268
  value.warnings.push(...warnings(p.root,name,cwd));
223
269
  }catch(e){integration={installed:false,integrity:'conflict',reason:e.message};}
224
- value.hosts.push({host:name,executable,version,...integration,behavior_verified:false,
270
+ value.hosts.push({host:name,executable,version,...integration,hook,behavior_verified:false,
225
271
  loading:name==='codex'?'AGENTS.md at session start; .agents/skills; overrides and size limits apply':
226
272
  'CLAUDE.md at session start; .claude/skills; managed/user policies and exclusions apply'});
227
273
  }
@@ -0,0 +1,115 @@
1
+ // Claude Code PreToolUse hook: skip an exact typecheck rerun when REDUE reports
2
+ // CURRENT/PASS with reuse_eligible on the current inputs. When the check is not
3
+ // CURRENT, the command becomes `redue run <check>` so the run is recorded. Every
4
+ // other outcome, including errors and timeouts, allows the command unchanged.
5
+ // Never exit 2: Claude Code treats exit 2 as a block.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import {spawn} from 'node:child_process';
9
+
10
+ export const hookCommand='redue agent hook claude';
11
+ const safe=/^[A-Za-z0-9_./:=,+-]+$/;
12
+ const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
13
+
14
+ // Exact commands that rerun each configured typecheck. Names that would need
15
+ // shell quoting get no form, so the hook never fires for them.
16
+ export function hookForms(config){
17
+ const rows=[];
18
+ for(const check of config?.checks||[]){
19
+ if(!typecheck(check)||typeof check.name!=='string'||!safe.test(check.name))continue;
20
+ const forms=[`redue run ${check.name}`];
21
+ // Other forms run from the project root, so they match only a root check.
22
+ if(check.cwd!==undefined&&check.cwd!=='.'){rows.push({check:check.name,forms});continue;}
23
+ if(config.packageManager==='npm'&&typeof check.script==='string'&&safe.test(check.script))
24
+ forms.push(`npm run ${check.script}`);
25
+ if(check.qualification==='npm-typescript-direct-v1')forms.push('npx tsc --noEmit');
26
+ if(Array.isArray(check.command)&&check.command.length&&
27
+ check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
28
+ forms.push(check.command.join(' '));
29
+ rows.push({check:check.name,forms:[...new Set(forms)]});
30
+ }
31
+ return rows;
32
+ }
33
+
34
+ // Accept only an optional trailing `2>&1`, then an optional `| tail|head -n N`
35
+ // or `-N`. Newlines separate commands, so they never match.
36
+ function split(command){
37
+ if(typeof command!=='string'||/[\r\n]/.test(command))return null;
38
+ const [,base,suffix]=command.trim().replace(/[ \t]+/g,' ')
39
+ .match(/^(.*?)((?: 2>&1)?(?: \| (?:tail|head) (?:-n \d+|-\d+))?)$/);
40
+ return {base,suffix};
41
+ }
42
+ export function matchCheck(command,config){
43
+ const parts=split(command);
44
+ return parts&&hookForms(config).find(row=>row.forms.includes(parts.base))?.check||null;
45
+ }
46
+ // The same check through REDUE, keeping the output suffix; null if it already is.
47
+ export function recordedCommand(command,name,configFile='redue.config.json'){
48
+ const parts=split(command);
49
+ if(!parts||!safe.test(name)||!safe.test(configFile))return null;
50
+ const run=`redue ${configFile==='redue.config.json'?'':`--config ${configFile} `}run ${name}`;
51
+ return parts.base===run||parts.base===`redue run ${name}`?null:run+parts.suffix;
52
+ }
53
+
54
+ export function verdict(status,name){
55
+ const row=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)?
56
+ status.checks.find(item=>item?.name===name):null;
57
+ const runId=row?.invocation?.runId;
58
+ if(!row||row.freshness!=='CURRENT'||row.result!=='PASS'||row.reuse_eligible!==true||
59
+ typeof runId!=='string'||!/^[0-9a-f-]{36}$/.test(runId))return null;
60
+ return `REDUE: check ${JSON.stringify(name)} passed on these exact inputs (run ${runId}); `+
61
+ 'rerun skipped. To run it anyway, prefix the command with REDUE_FORCE=1.';
62
+ }
63
+
64
+ function synchronizedStatus(entry,config,root,timeout){
65
+ return new Promise(resolve=>{
66
+ let child,out='',done=false;
67
+ const finish=value=>{if(done)return;done=true;clearTimeout(timer);
68
+ if(child?.pid&&child.exitCode===null)try{process.platform==='win32'?child.kill('SIGKILL'):process.kill(-child.pid,'SIGKILL');}catch{}
69
+ resolve(value);};
70
+ const timer=setTimeout(()=>finish(null),timeout);
71
+ // Its own process group on POSIX, so a timeout also ends the engine it starts.
72
+ try{child=spawn(process.execPath,[entry,'--config',config,'status','--sync','--json'],
73
+ {cwd:root,stdio:['ignore','pipe','ignore'],env:process.env,windowsHide:true,detached:process.platform!=='win32'});}
74
+ catch{return finish(null);}
75
+ child.on('error',()=>finish(null));
76
+ child.stdout.on('data',chunk=>{out+=chunk;if(out.length>4*1024*1024)finish(null);});
77
+ child.on('close',code=>{if(code!==0)return finish(null);
78
+ try{finish(JSON.parse(out));}catch{finish(null);}});
79
+ });
80
+ }
81
+
82
+ // Returns the hook's stdout object, or null to allow the command.
83
+ export async function claudeHook(input,{entry,timeout=2000}){
84
+ if(input?.tool_name!=='Bash')return null;
85
+ const command=input.tool_input?.command,root=input.cwd;
86
+ if(typeof command!=='string'||/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return null;
87
+ if(typeof root!=='string'||!path.isAbsolute(root))return null;
88
+ const record=JSON.parse(fs.readFileSync(path.join(root,'.redue','agents','claude.json'),'utf8'));
89
+ if(record?.schema!==1||record.host!=='claude'||typeof record.config!=='string'||
90
+ !/^[^/\\]+\.json$/.test(record.config))return null;
91
+ const config=path.join(root,record.config);
92
+ const name=matchCheck(command,JSON.parse(fs.readFileSync(config,'utf8')));
93
+ if(!name)return null;
94
+ const status=await synchronizedStatus(entry,config,root,timeout),reason=verdict(status,name);
95
+ if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',
96
+ permissionDecision:'deny',permissionDecisionReason:reason}};
97
+ // Rewrite only on a healthy answer for this check: without an observer,
98
+ // `redue run` cannot record, so the command stays as Claude sent it.
99
+ const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
100
+ status.checks.some(item=>item?.name===name)?recordedCommand(command,name,record.config):null;
101
+ // updatedInput replaces the whole tool input. Deny and ask rules still apply
102
+ // to the rewritten command; "allow" skips only the prompt.
103
+ return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
104
+ permissionDecisionReason:`REDUE: check ${JSON.stringify(name)} is not CURRENT; running it through redue run so the result is recorded.`,
105
+ updatedInput:{...input.tool_input,command:rewritten}}}:null;
106
+ }
107
+
108
+ export async function runClaudeHook(entry){
109
+ try{
110
+ let text='';process.stdin.setEncoding('utf8');
111
+ for await(const chunk of process.stdin){text+=chunk;if(text.length>1024*1024)return;}
112
+ const output=await claudeHook(JSON.parse(text),{entry});
113
+ if(output)await new Promise(resolve=>process.stdout.write(JSON.stringify(output)+'\n',resolve));
114
+ }catch{}
115
+ }