@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 +53 -0
- package/README.md +1 -1
- package/bin/redue.mjs +35 -19
- package/docs/user/agents.md +47 -1
- package/docs/user/direct-typescript.md +18 -0
- package/docs/user/pilot.md +9 -6
- package/package.json +1 -1
- package/src/agent-integration.mjs +59 -13
- package/src/claude-hook.mjs +115 -0
- package/src/cli.mjs +51 -11
- package/src/daemon.mjs +236 -82
- package/src/decision-validation.mjs +10 -0
- package/src/direct-run-certificate.mjs +161 -16
- package/src/direct-typescript-probe.mjs +6 -40
- package/src/direct-typescript.mjs +48 -15
- package/src/history-worker.mjs +19 -16
- package/src/observation.mjs +32 -12
- package/src/probe-result.mjs +7 -3
- package/src/run-recorder.cjs +78 -0
- package/src/typescript-input-facts.mjs +5 -2
- package/src/typescript-list.mjs +76 -7
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.
|
|
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
|
-
|
|
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);
|
package/docs/user/agents.md
CHANGED
|
@@ -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.
|
|
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`.
|
package/docs/user/pilot.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Opt-in development pilot: alpha.
|
|
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.
|
|
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
|
|
28
|
-
|
|
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.
|
|
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.
|
|
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
|
@@ -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('
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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{
|
|
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
|
+
}
|