@redue/cli 0.1.0-alpha.32 → 0.1.0-alpha.33
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 +33 -0
- package/README.md +110 -51
- package/docs/user/agents.md +12 -1
- package/docs/user/direct-typescript.md +3 -2
- package/docs/user/sandbox.md +7 -3
- package/package.json +1 -1
- package/src/agent-integration.mjs +20 -7
- package/src/claude-hook.mjs +51 -5
- package/src/python-onboarding.mjs +54 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.33 — 2026-11-06
|
|
4
|
+
|
|
5
|
+
pytest checks follow the project's quick suite, and the hook starts for
|
|
6
|
+
every check form.
|
|
7
|
+
|
|
8
|
+
- **The pull-request workflow's narrowed suite.** For pytest, `init` now
|
|
9
|
+
reads `coverage run -m pytest`, quoted arguments (`-m "not slow"`) and
|
|
10
|
+
lines continued with a backslash, and leaves out options that only write
|
|
11
|
+
a report (`--junit-xml`, `--html`, `--durations`). When a workflow that
|
|
12
|
+
runs on pull requests narrows the suite (`-m`, `-k`, `--ignore`,
|
|
13
|
+
`--deselect`), its line wins. On project-lighter/lighter the test check is
|
|
14
|
+
now `pytest tests -m "not slow"` (4.7 s), not the full suite with its
|
|
15
|
+
CIFAR download: setup took 68 s instead of 10.5 minutes, and all three
|
|
16
|
+
checks qualify.
|
|
17
|
+
- **Trigger rules that Claude Code matches.** A hook trigger rule with a
|
|
18
|
+
quote in it never started the hook in a real session, so such a check's
|
|
19
|
+
runs were neither skipped nor recorded. A rule now ends before the first
|
|
20
|
+
character outside letters, digits, spaces and `_./:=,+@-`, and the hook
|
|
21
|
+
matches the whole command itself, spaces inside quotes included.
|
|
22
|
+
- **`redue agent doctor` checks the trigger rules.** `trigger_rules` says
|
|
23
|
+
whether the installed rules start the hook for every command form of
|
|
24
|
+
every configured check, as REDUE models Claude Code's matching, and names
|
|
25
|
+
the forms they miss.
|
|
26
|
+
- **README.** Setup can take longer with a large environment or a slow
|
|
27
|
+
suite; trial runs stop at 5 minutes and `redue init --check test
|
|
28
|
+
--no-time-limit` tries a check without the limit.
|
|
29
|
+
|
|
30
|
+
Measured: stranger sessions on lighter (alpha.32 code with these changes)
|
|
31
|
+
skipped mypy and pytest after a docs edit and ran both through REDUE after
|
|
32
|
+
a source change, 0 critical. Python product census: 0 critical; 17 test,
|
|
33
|
+
15 typecheck and 22 lint checks qualify, as in alpha.32. `init` on the 13
|
|
34
|
+
slowest census repos chose the same checks as alpha.32.
|
|
35
|
+
|
|
3
36
|
## 0.1.0-alpha.32 — 2026-11-05
|
|
4
37
|
|
|
5
38
|
REDUE's status is now the point: what is verified on the code as it is
|
package/README.md
CHANGED
|
@@ -1,14 +1,36 @@
|
|
|
1
1
|
# REDUE
|
|
2
2
|
|
|
3
|
-
**Know
|
|
3
|
+
**Know what is verified on the code you have now.**
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
records each check's result with the exact inputs it ran on, and
|
|
8
|
-
your agent
|
|
9
|
-
|
|
5
|
+
Your typecheck, linter and tests passed at some point. Then the code
|
|
6
|
+
changed, or a new agent session started with no memory of what ran. REDUE
|
|
7
|
+
records each check's result with the exact inputs it ran on, and gives you
|
|
8
|
+
and your coding agent one dependable answer: which checks are verified on
|
|
9
|
+
the code as it is now, which are stale, and which are due. When it cannot
|
|
10
|
+
tell, it says so and why; it never guesses a result into place.
|
|
10
11
|
|
|
11
|
-
##
|
|
12
|
+
## What it changes
|
|
13
|
+
|
|
14
|
+
Coding agents often finish without running every check on their final code.
|
|
15
|
+
In headless Claude Code sessions on realistic tasks whose prompts never
|
|
16
|
+
mention checks (bug fixes, small features and refactors; 21 tasks on 7
|
|
17
|
+
TypeScript and Python projects, two runs each), the share of sessions that
|
|
18
|
+
ended with every check verified on the final code:
|
|
19
|
+
|
|
20
|
+
| Model | Without REDUE | With REDUE |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| Sonnet 5.5 | 12% (5 of 42) | 98% (41 of 42) |
|
|
23
|
+
| Opus 5.5 | 48% (20 of 42) | 100% (42 of 42) |
|
|
24
|
+
|
|
25
|
+
A check counts as verified when the session ran all of it on the final code,
|
|
26
|
+
or REDUE said CURRENT for it at the end; counting only the agents' own check
|
|
27
|
+
commands, it was 90% and 98% with REDUE. REDUE never called a failing check
|
|
28
|
+
verified: in no session did it say CURRENT / PASS for a check that failed
|
|
29
|
+
when run plainly on the final code. Without REDUE, 4 Sonnet sessions ended
|
|
30
|
+
with a failing check they had not run; with it, none did. The harness and
|
|
31
|
+
tasks are in [`eval/`](eval/README.md).
|
|
32
|
+
|
|
33
|
+
## Quickstart
|
|
12
34
|
|
|
13
35
|
You need macOS or Linux, Node 22 or 24, Git, Claude Code, and either a
|
|
14
36
|
JavaScript or TypeScript project with its dependencies installed
|
|
@@ -32,50 +54,85 @@ stops before writing anything if the dependencies are not installed. It:
|
|
|
32
54
|
pyright and ty (typecheck), ruff (lint) and pytest (test). It says which
|
|
33
55
|
checks it left out and why, and which it can only record, not reuse;
|
|
34
56
|
- connects Claude Code: a short section in `CLAUDE.md`, a skill, hooks in
|
|
35
|
-
`.claude/settings.local.json`, and a status line there
|
|
36
|
-
|
|
37
|
-
typecheck, test"). A status line you already have stays;
|
|
57
|
+
`.claude/settings.local.json`, and a status line there. A status line you
|
|
58
|
+
already have stays;
|
|
38
59
|
- starts REDUE's file observer, waits while it indexes the project (a minute
|
|
39
60
|
or more in a large one), then runs each check that can qualify once, so
|
|
40
|
-
there is a first result
|
|
41
|
-
|
|
61
|
+
there is a first result, and prints one line per run (the run's own output
|
|
62
|
+
only when it fails);
|
|
42
63
|
- prints every file it writes. Running it again changes nothing.
|
|
43
64
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
65
|
+
Setup takes a minute or two in most projects. It can take longer in a large
|
|
66
|
+
environment, such as a `.venv` with PyTorch or a workspace with many
|
|
67
|
+
packages, because the observer indexes every installed file first, and with
|
|
68
|
+
a slow test suite, because `init` runs each check once plainly and once in
|
|
69
|
+
the sandbox before it chooses how to run it. Each of those trial runs stops
|
|
70
|
+
after 5 minutes; a check that runs longer is left out of the sandbox, and
|
|
71
|
+
setup names it and prints the command that tries it without the limit, such
|
|
72
|
+
as `redue init --check test --no-time-limit`. For pytest, `init` takes the
|
|
73
|
+
arguments the project's own workflows run it with, and prefers a
|
|
74
|
+
pull-request workflow that leaves slow tests out (`-m "not slow"`).
|
|
75
|
+
|
|
76
|
+
Then start a new Claude Code session in the project and work as usual. The
|
|
77
|
+
status line shows where things stand, updated as the agent works:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
REDUE ✓ 3 verified
|
|
81
|
+
REDUE ✓ 2 verified · 1 due: typecheck
|
|
82
|
+
REDUE ✓ 2 verified · 1 not verifiable
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
When the agent runs a check whose last pass still applies, the hook skips
|
|
86
|
+
the rerun and tells it why; after an edit, the hook runs the check through
|
|
87
|
+
REDUE so the new result is recorded. When the agent is about to finish a
|
|
48
88
|
turn in which files changed while a check is due, the hook asks it once to
|
|
49
89
|
run `redue verify` first (`"endOfTurnCheck": false` in the config turns
|
|
50
90
|
this off).
|
|
51
91
|
|
|
52
|
-
To
|
|
92
|
+
To ask yourself:
|
|
53
93
|
|
|
54
94
|
```text
|
|
55
95
|
$ redue status
|
|
56
96
|
✓ typecheck CURRENT / PASS
|
|
57
97
|
✓ lint CURRENT / PASS
|
|
98
|
+
✓ test CURRENT / PASS
|
|
58
99
|
|
|
59
100
|
# after editing src/main.ts
|
|
60
|
-
$ redue
|
|
61
|
-
typecheck
|
|
62
|
-
src/main.ts changed
|
|
63
|
-
|
|
101
|
+
$ redue status
|
|
102
|
+
⚠ typecheck STALE / PASS
|
|
103
|
+
src/main.ts changed
|
|
104
|
+
⚠ lint STALE / PASS
|
|
105
|
+
src/main.ts changed
|
|
106
|
+
✓ test CURRENT / PASS
|
|
107
|
+
|
|
108
|
+
$ redue verify
|
|
109
|
+
redue verify: running typecheck (STALE: src/main.ts changed)
|
|
110
|
+
typecheck: PASS recorded.
|
|
111
|
+
Check redue status before reusing this evidence.
|
|
112
|
+
redue verify: running lint (STALE: src/main.ts changed)
|
|
113
|
+
lint: PASS recorded.
|
|
114
|
+
Check redue status before reusing this evidence.
|
|
115
|
+
|
|
116
|
+
✓ typecheck CURRENT / PASS
|
|
117
|
+
✓ lint CURRENT / PASS
|
|
118
|
+
✓ test CURRENT / PASS
|
|
119
|
+
Verified: every check that can be verified (3) passed on the files as they are now.
|
|
64
120
|
```
|
|
65
121
|
|
|
66
122
|
`redue status` checks the files and your environment before it answers, as
|
|
67
123
|
the agent's hook does. `redue status --cached` gives the last answer the
|
|
68
124
|
observer published without checking; it can say UNVERIFIED ("cannot confirm
|
|
69
125
|
your execution context") where `redue status` would say CURRENT.
|
|
126
|
+
`redue explain CHECK` says why a check is in its state.
|
|
70
127
|
|
|
71
128
|
`redue verify` runs every check that is due (STALE, or UNVERIFIED with a
|
|
72
|
-
command it can run), then prints the status. It exits 0 only when every
|
|
73
|
-
ends CURRENT / PASS, so it answers "is everything
|
|
74
|
-
is now?" in one command. A check no run can
|
|
75
|
-
VERIFIABLE with the reason, and left out: a
|
|
76
|
-
whose runs change its own inputs (two passing
|
|
77
|
-
files, such as a test script that rebuilds a
|
|
78
|
-
change, or a failing run, makes it due again.
|
|
129
|
+
command it can run), then prints the status. It exits 0 only when every
|
|
130
|
+
check that can be verified ends CURRENT / PASS, so it answers "is everything
|
|
131
|
+
verified on the code as it is now?" in one command. A check no run can
|
|
132
|
+
confirm is shown as NOT VERIFIABLE with the reason, and left out: a
|
|
133
|
+
recording-only check, or one whose runs change its own inputs (two passing
|
|
134
|
+
runs in a row changed the same files, such as a test script that rebuilds a
|
|
135
|
+
tracked bundle). Any other change, or a failing run, makes it due again.
|
|
79
136
|
|
|
80
137
|
`redue stats` shows, per check, the reruns the hook skipped, the time saved,
|
|
81
138
|
the recorded runs and the top reasons a check was UNVERIFIED, from a log kept
|
|
@@ -83,6 +140,26 @@ on your machine.
|
|
|
83
140
|
|
|
84
141
|
To undo everything setup did: `redue remove`.
|
|
85
142
|
|
|
143
|
+
## Time saved, as a bonus
|
|
144
|
+
|
|
145
|
+
A check whose last pass still applies does not run again. Headless Claude
|
|
146
|
+
Code sessions (Sonnet 5.5, CI Linux runner) on the same tasks with and
|
|
147
|
+
without REDUE, three sessions per task. Session time, medians per task
|
|
148
|
+
summed over the projects:
|
|
149
|
+
|
|
150
|
+
- **Typecheck** (twilio-node, semiotic, Ultra-Card, SwitchHosts; four tasks;
|
|
151
|
+
96 sessions; 0.1.0-alpha.12 code): 464 s without, 330 s with. REDUE skipped
|
|
152
|
+
33 typechecks. Each was checked afterwards with plain `tsc` on the exact tree
|
|
153
|
+
it was skipped on, and all 33 agreed.
|
|
154
|
+
- **Unit tests** (cheerio with Vitest 4, SwitchHosts with Vitest 5, twilio-node
|
|
155
|
+
with Jest 29; four tasks; 72 sessions; 0.1.0-alpha.14 code): 515 s without,
|
|
156
|
+
295 s with. REDUE skipped 21 test runs. Each was rerun plainly three times on
|
|
157
|
+
the exact tree, and all passed every time.
|
|
158
|
+
|
|
159
|
+
API cost was the same in both arms. The savings come only when an earlier
|
|
160
|
+
pass still applies: a task that changes source code saves nothing. A handful
|
|
161
|
+
of projects and three runs per task show direction, not a general rate.
|
|
162
|
+
|
|
86
163
|
## Still CURRENT after your build and tests
|
|
87
164
|
|
|
88
165
|
Running the project's build or tests writes files (`dist/`, `.next/`,
|
|
@@ -136,26 +213,6 @@ into and the sandbox can't run (inside Claude Code's macOS sandbox, on
|
|
|
136
213
|
Windows), the project uses Bun, or a check writes into its own inputs.
|
|
137
214
|
[Supported projects](docs/user/supported-projects.md) has the details.
|
|
138
215
|
|
|
139
|
-
## Measured with Claude Code
|
|
140
|
-
|
|
141
|
-
Headless Claude Code sessions (Sonnet 5.5, CI Linux runner) on the same tasks
|
|
142
|
-
with and without REDUE, three sessions per task. Session time, medians per
|
|
143
|
-
task summed over the projects:
|
|
144
|
-
|
|
145
|
-
- **Typecheck** (twilio-node, semiotic, Ultra-Card, SwitchHosts; four tasks;
|
|
146
|
-
96 sessions; 0.1.0-alpha.12 code): 464 s without, 330 s with. REDUE skipped
|
|
147
|
-
33 typechecks. Each was checked afterwards with plain `tsc` on the exact tree
|
|
148
|
-
it was skipped on, and all 33 agreed.
|
|
149
|
-
- **Unit tests** (cheerio with Vitest 4, SwitchHosts with Vitest 5, twilio-node
|
|
150
|
-
with Jest 29; four tasks; 72 sessions; 0.1.0-alpha.14 code): 515 s without,
|
|
151
|
-
295 s with. REDUE skipped 21 test runs. Each was rerun plainly three times on
|
|
152
|
-
the exact tree, and all passed every time.
|
|
153
|
-
|
|
154
|
-
API cost was the same in both arms. The savings come only when an earlier
|
|
155
|
-
pass still applies: a task that changes source code saves nothing. A handful
|
|
156
|
-
of projects and three runs per task show direction, not a general rate. The
|
|
157
|
-
harness, tasks and raw results are in [`eval/`](eval/README.md).
|
|
158
|
-
|
|
159
216
|
## What REDUE won't do
|
|
160
217
|
|
|
161
218
|
- **Prove your code is correct.** CURRENT means the inputs of the recorded
|
|
@@ -165,9 +222,10 @@ harness, tasks and raw results are in [`eval/`](eval/README.md).
|
|
|
165
222
|
- **Trust what it cannot see.** A program it cannot account for, a file it
|
|
166
223
|
does not observe, or a gap in observation makes the result UNVERIFIED and
|
|
167
224
|
says why. It never turns that into CURRENT by guessing.
|
|
168
|
-
- **Support yet:**
|
|
169
|
-
|
|
170
|
-
|
|
225
|
+
- **Support yet:** Bun, per-package checks in large monorepos, results shared
|
|
226
|
+
across machines, and the general recipe on Windows (Windows has the strict
|
|
227
|
+
recipes only). TypeScript 7's native compiler is covered: a typecheck that
|
|
228
|
+
runs it gets the [sandboxed recipe](docs/user/sandbox.md).
|
|
171
229
|
|
|
172
230
|
## What the states mean
|
|
173
231
|
|
|
@@ -177,6 +235,7 @@ harness, tasks and raw results are in [`eval/`](eval/README.md).
|
|
|
177
235
|
| STALE | An input changed since the recorded result. | Rerun when you need the answer. |
|
|
178
236
|
| UNVERIFIED | REDUE cannot tell: no result yet, an input it cannot see, or observation not ready. | Read the reason; rerun if it matters. |
|
|
179
237
|
| FAILED | The last run failed: the check ran and reported a failure, or could not run. | Fix and rerun. |
|
|
238
|
+
| NOT VERIFIABLE | No run can confirm a result: a recording-only check, or one whose runs change its own inputs. | Read the reason; `redue verify` leaves it out. |
|
|
180
239
|
|
|
181
240
|
## Without Claude Code, or step by step
|
|
182
241
|
|
package/docs/user/agents.md
CHANGED
|
@@ -307,7 +307,18 @@ config resolution, cached observer health, a bounded read-only control-access pr
|
|
|
307
307
|
managed-file integrity, detected host
|
|
308
308
|
version, and loading guidance. `behavior_verified: false` is deliberate: intact
|
|
309
309
|
files do not prove the agent followed them. For Claude, doctor reports whether the
|
|
310
|
-
typecheck hook is installed
|
|
310
|
+
typecheck hook is installed, the status line, and `trigger_rules`: whether the
|
|
311
|
+
`if` rules in the settings start the hook for every command form of every
|
|
312
|
+
configured check (`covered`, or the forms they miss, which would run plainly,
|
|
313
|
+
neither skipped nor recorded), including the fan-out commands and the forms
|
|
314
|
+
after a `cd` into a check's directory. It applies the rules as REDUE models
|
|
315
|
+
Claude Code's matching, not by asking Claude Code: `Bash(PREFIX*)` (or the
|
|
316
|
+
legacy `Bash(PREFIX:*)`) matches a simple command that starts with PREFIX in
|
|
317
|
+
the characters known to match (letters, digits, spaces and `_./:=,+@-`), and a
|
|
318
|
+
hook with no `if` matches every command. Setup ends a rule before any other
|
|
319
|
+
character, such as the quote in `-m "not slow"`, and the hook then matches
|
|
320
|
+
the whole command itself, spaces inside quotes included; a rule that would
|
|
321
|
+
end before the program's first argument is not written. Doctor neither
|
|
311
322
|
executes checks nor starts observers, reconciles inputs, or installs a host.
|
|
312
323
|
|
|
313
324
|
Doctor separates missing/unreadable state, a missing or refused endpoint, an
|
|
@@ -64,8 +64,9 @@ byte for byte, in one of two ways:
|
|
|
64
64
|
|
|
65
65
|
CI runs the same contract suite against the last release of each minor from
|
|
66
66
|
5.0 to 5.8 that the reviewed releases do not cover. Later minors (6.1 and up)
|
|
67
|
-
wait until CI covers them. TypeScript 7 (the
|
|
68
|
-
|
|
67
|
+
wait until CI covers them. This recipe does not take TypeScript 7 (the
|
|
68
|
+
native compiler); a typecheck that runs it gets the
|
|
69
|
+
[sandboxed recipe](sandbox.md).
|
|
69
70
|
|
|
70
71
|
## Script forms
|
|
71
72
|
|
package/docs/user/sandbox.md
CHANGED
|
@@ -41,9 +41,13 @@ built declarations) gets the whole checkout. See
|
|
|
41
41
|
`.venv` (`uv sync` makes one; Poetry does with `virtualenvs.in-project`).
|
|
42
42
|
`init` uses the arguments your CI workflow, Makefile, tox, nox, justfile or
|
|
43
43
|
shell scripts in `scripts/` run the tool with, through `uv run`, `poetry run`,
|
|
44
|
-
`python -m` or `.venv/bin
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
`python -m`, `coverage run -m` or `.venv/bin`, including quoted arguments
|
|
45
|
+
(`-m "not slow"`) and lines continued with a backslash. It takes the first
|
|
46
|
+
such line, but a pull-request workflow's when it narrows the test suite
|
|
47
|
+
(`-m`, `-k`, `--ignore`, `--deselect`), and leaves out options that only
|
|
48
|
+
write a report (`--junit-xml`, `--html`, `--durations`). Without them it
|
|
49
|
+
uses the tool's default: `ruff check`, `ty check`, `pyright`, `pytest`, and
|
|
50
|
+
`mypy` (with `.` when mypy's configuration names no `files`).
|
|
47
51
|
- **Platforms:** macOS (Seatbelt, `sandbox-exec`) and Linux 6.7 or later
|
|
48
52
|
(Landlock; the launcher needs Python 3).
|
|
49
53
|
|
package/package.json
CHANGED
|
@@ -12,7 +12,7 @@ import {readCachedStatus} from './cached-state.mjs';
|
|
|
12
12
|
import {samePath,withinPath} from './path-identity.mjs';
|
|
13
13
|
import {processAlive} from './process-liveness.mjs';
|
|
14
14
|
import os from 'node:os';
|
|
15
|
-
import {fanoutRules,hookCommand,hookForms,redueFor,runsEntry,shellQuote as quote,statusLineCommand} from './claude-hook.mjs';
|
|
15
|
+
import {fanoutRules,hookCommand,hookForms,redueFor,runsEntry,shellQuote as quote,statusLineCommand,triggerRule,uncoveredForms} from './claude-hook.mjs';
|
|
16
16
|
|
|
17
17
|
const policy=fs.readFileSync(new URL('./agent-policy.md',import.meta.url),'utf8');
|
|
18
18
|
const hosts={codex:{instruction:'AGENTS.md',skill:'.agents/skills/redue-verification/SKILL.md'},
|
|
@@ -22,13 +22,14 @@ const marker=host=>`.redue/agents/${host}.json`;
|
|
|
22
22
|
// The hook needs a local REDUE install, so it lives in per-developer settings.
|
|
23
23
|
const settingsPath='.claude/settings.local.json';
|
|
24
24
|
const sameJson=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
|
|
25
|
-
// One handler per
|
|
25
|
+
// One handler per command form (triggerRule: up to its first quote or other
|
|
26
|
+
// character a rule cannot hold); `if` keeps other Bash calls from starting Node.
|
|
26
27
|
// The hook waits for status up to 10 s; 15 s leaves room for starting Node.
|
|
27
28
|
// Fan-out commands (turbo, nx, `pnpm -r`) and root scripts that run one get
|
|
28
29
|
// prefix rules; the hook decides from the full command.
|
|
29
30
|
function hookFor(config,relative,scripts,command=hookCommand){
|
|
30
31
|
const rules=[...new Set([...hookForms(config,relative,scripts).flatMap(row=>[...row.forms,...row.local]),
|
|
31
|
-
...fanoutRules(config,scripts)]
|
|
32
|
+
...fanoutRules(config,scripts)].map(triggerRule).filter(Boolean))];
|
|
32
33
|
return rules.length?{matcher:'Bash',hooks:rules.map(rule=>({type:'command',if:rule,command,timeout:15}))}:null;
|
|
33
34
|
}
|
|
34
35
|
// The same command on SessionStart starts the observer; it needs no check to match.
|
|
@@ -263,7 +264,7 @@ const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${ho
|
|
|
263
264
|
// varies, and only in the safe character set. Earlier owned versions are
|
|
264
265
|
// recognized so setup can replace them; other stored text never is.
|
|
265
266
|
function knownBlock(host,relative,block){
|
|
266
|
-
const list='((?:`[A-Za-z0-9_./:=,+@ -]+`(?: and )?)+)';
|
|
267
|
+
const list='((?:`[A-Za-z0-9_./:=,+@ "\'()-]+`(?: and )?)+)';
|
|
267
268
|
const forms=pattern=>{const named=new RegExp(pattern).exec(block);return named?[...named[1].matchAll(/`([^`]+)`/g)].map(m=>m[1]):[];};
|
|
268
269
|
const rows=[...forms(`^\\n\\n<!-- REDUE:[a-z]+:begin -->\\nTypecheck with ${list}\\.`).map(form=>({kind:'typecheck',form})),
|
|
269
270
|
...forms(`Run the unit tests with ${list}\\.\\n`).map(form=>({kind:'test',form})),
|
|
@@ -368,7 +369,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
368
369
|
if(value.state_access)console.log(`State access: ${value.state_access.status}${value.state_access.reason?' — '+value.state_access.reason:''}`);
|
|
369
370
|
if(value.compatibility?.reason)console.log(`Observer compatibility: ${value.compatibility.reason}`);
|
|
370
371
|
if(value.applicability?.requires_reassessment)console.log('Applicability: cached evidence needs reassessment; choose synchronization or execution deliberately.');
|
|
371
|
-
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}; status line ${row.status_line}`:''}; behavioral validation NOT VERIFIED by doctor${row.reason?' — '+row.reason:''}`);
|
|
372
|
+
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}; status line ${row.status_line}; trigger rules ${!row.trigger_rules?(row.hook==='absent'?'not installed':'not checked'):row.trigger_rules.covered?'match every check form (as REDUE models Claude Code)':`miss ${row.trigger_rules.count} check form${row.trigger_rules.count>1?'s':''}`}`:''}; behavioral validation NOT VERIFIED by doctor${row.reason?' — '+row.reason:''}`);
|
|
372
373
|
console.log('Doctor does not start observers, execute checks, or prove agent behavior.');
|
|
373
374
|
for(const warning of value.warnings)console.log(`Review: ${warning}`);
|
|
374
375
|
}return value;
|
|
@@ -462,16 +463,28 @@ export async function doctor({configFile,stateOverride,entry,cwd,host}){
|
|
|
462
463
|
if(executable){const launch=windowsLaunch([executable,'--version']);
|
|
463
464
|
const out=spawnSync(launch.file,launch.args,{...launch.options,encoding:'utf8',timeout:2000,maxBuffer:4096,stdio:['ignore','pipe','pipe']});
|
|
464
465
|
if(out.status===0)version=out.stdout.trim();}
|
|
465
|
-
let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none',sessionHook=hook,statusLineState=hook;
|
|
466
|
+
let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none',sessionHook=hook,statusLineState=hook,triggers=null;
|
|
466
467
|
const installed=()=>hookRunnable(p.root);
|
|
467
468
|
if(p)try{const record=saved(p.root,name);integration=inspect(p.root,name,record);
|
|
468
469
|
if(name==='claude'&&hookInstalled(p.root))hook=installed();
|
|
469
470
|
if(name==='claude'&&hookInstalled(p.root,'SessionStart'))sessionHook=installed();
|
|
471
|
+
// Every check's command forms must start the hook: a form no trigger
|
|
472
|
+
// rule matches runs plainly, neither skipped nor recorded.
|
|
473
|
+
if(name==='claude'&&hookInstalled(p.root)){
|
|
474
|
+
let scripts={};try{scripts=JSON.parse(read(p.root,'package.json')??'{}').scripts||{};}catch{}
|
|
475
|
+
// REDUE's entries for Bash: a hook without `if` starts for every command.
|
|
476
|
+
const rules=(parseSettings(read(p.root,settingsPath)).hooks?.PreToolUse||[]).filter(entry=>ownedHook(entry)&&['Bash','*','',undefined].includes(entry.matcher))
|
|
477
|
+
.flatMap(entry=>entry.hooks.map(h=>typeof h.if==='string'?h.if:'Bash'));
|
|
478
|
+
const missing=uncoveredForms(rules,p.config,p.relative,scripts);
|
|
479
|
+
triggers=missing.length?{covered:false,missing:missing.slice(0,10),count:missing.length}:{covered:true};
|
|
480
|
+
if(missing.length)value.warnings.push(`The hook's trigger rules do not match ${missing.length} command form${missing.length>1?'s':''} of configured checks `+
|
|
481
|
+
`(such as ${missing[0]}): those runs are neither skipped nor recorded. Run redue agent setup claude --apply again.`);
|
|
482
|
+
}
|
|
470
483
|
if(name==='claude'){let local=null;try{local=parseSettings(read(p.root,settingsPath)).statusLine;}catch{}
|
|
471
484
|
statusLineState=ownedStatusLine(local)?'installed':otherStatusLine(p.root)?'another status line':'absent';}
|
|
472
485
|
value.warnings.push(...warnings(p.root,name,cwd));
|
|
473
486
|
}catch(e){integration={installed:false,integrity:'conflict',reason:e.message};}
|
|
474
|
-
value.hosts.push({host:name,executable,version,...integration,hook,session_start:sessionHook,...name==='claude'?{status_line:statusLineState}:{},behavior_verified:false,
|
|
487
|
+
value.hosts.push({host:name,executable,version,...integration,hook,session_start:sessionHook,...name==='claude'?{status_line:statusLineState,trigger_rules:triggers}:{},behavior_verified:false,
|
|
475
488
|
loading:name==='codex'?'AGENTS.md at session start; .agents/skills; overrides and size limits apply':
|
|
476
489
|
'CLAUDE.md at session start; .claude/skills; managed/user policies and exclusions apply'});
|
|
477
490
|
}
|
package/src/claude-hook.mjs
CHANGED
|
@@ -21,6 +21,8 @@ export const hookCommand='redue agent hook claude';
|
|
|
21
21
|
export const statusLineCommand='redue agent statusline claude';
|
|
22
22
|
// No shell metacharacters; `@` starts scoped package names.
|
|
23
23
|
const safe=/^[A-Za-z0-9_./:=,+@-]+$/;
|
|
24
|
+
// Those characters and a space: what a trigger rule may hold (triggerRule).
|
|
25
|
+
const ruleText=/^[A-Za-z0-9_./:=,+@ -]*$/,ruleStop=/[^A-Za-z0-9_./:=,+@ -]/;
|
|
24
26
|
const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
|
|
25
27
|
// A direct test check: its command is the runner and the script's arguments.
|
|
26
28
|
const tests=check=>check?.qualification==='npm-tests-direct-v1'&&Array.isArray(check.command)&&
|
|
@@ -31,8 +33,9 @@ const general=check=>(check?.qualification==='node-script-v1'&&['typecheck','tes
|
|
|
31
33
|
// A sandboxed check: a root package script, or a tool from the project's .venv.
|
|
32
34
|
// sandbox.mjs's isSandboxed, written out: the hook does not load the sandbox module.
|
|
33
35
|
const sandboxed=check=>['sandbox-v1','sandbox-v2','sandbox-v3'].includes(check?.qualification)&&['typecheck','lint','test'].includes(check.kind);
|
|
36
|
+
// An argument may hold spaces and parentheses (`-m "not slow"`): the forms quote it.
|
|
34
37
|
const python=check=>sandboxed(check)&&Array.isArray(check.command)&&/^\.venv\/bin\/[A-Za-z0-9_.-]+$/.test(check.command[0]||'')&&
|
|
35
|
-
check.command.every(word=>typeof word==='string'&&safe.test(word));
|
|
38
|
+
check.command.every((word,i)=>typeof word==='string'&&(safe.test(word)||i>0&&/^[A-Za-z0-9_./:=,@+% ()-]+$/.test(word)&&/\S/.test(word)));
|
|
36
39
|
const eligibleCheck=check=>typecheck(check)||tests(check)||general(check)||python(check);
|
|
37
40
|
export const checkKind=check=>tests(check)?'test':general(check)||python(check)?check.kind:'typecheck';
|
|
38
41
|
// The ways an agent runs a Python check's tool: through uv or Poetry, with
|
|
@@ -40,11 +43,45 @@ export const checkKind=check=>tests(check)?'test':general(check)||python(check)?
|
|
|
40
43
|
// or the tool's name depends on PATH, so the hook takes it only when PATH
|
|
41
44
|
// finds that program in the project's .venv/bin first (pathForm).
|
|
42
45
|
const wrappers=['uv run ','poetry run '];
|
|
46
|
+
// The `if` rules setup writes start the hook for a check's command forms.
|
|
47
|
+
// As REDUE models Claude Code's matching (doctor's check; a stranger session
|
|
48
|
+
// confirms it for real): `Bash(PREFIX*)` (or the legacy `Bash(PREFIX:*)`)
|
|
49
|
+
// matches a simple command that starts with PREFIX, `Bash(TEXT)` exactly
|
|
50
|
+
// TEXT, and plain `Bash` every command; compound commands are matched part by
|
|
51
|
+
// part. Only the characters in ruleText are known to match in real sessions:
|
|
52
|
+
// a rule with a quote in it never started the hook (alpha.32, `-m "not
|
|
53
|
+
// slow"`). So a rule ends before the first other character; it may start the
|
|
54
|
+
// hook for more commands, and the hook then matches each exactly. A rule
|
|
55
|
+
// that would end before the program's first argument is not written: it
|
|
56
|
+
// would start the hook for every run of the program.
|
|
57
|
+
export function triggerRule(form){
|
|
58
|
+
const cut=form.search(ruleStop),prefix=cut<0?form:form.slice(0,cut);
|
|
59
|
+
return cut<0||/^\S+ \S/.test(prefix)?`Bash(${prefix}*)`:null;
|
|
60
|
+
}
|
|
61
|
+
// Whether a rule matches a simple command, as modeled above.
|
|
62
|
+
export function triggerMatches(rule,command){
|
|
63
|
+
if(typeof rule!=='string'||typeof command!=='string')return false;
|
|
64
|
+
if(rule==='Bash')return true;
|
|
65
|
+
const body=/^Bash\((.*)\)$/s.exec(rule)?.[1];
|
|
66
|
+
if(body===undefined)return false;
|
|
67
|
+
const prefix=body.endsWith(':*')?body.slice(0,-2):body.endsWith('*')?body.slice(0,-1):null;
|
|
68
|
+
if(prefix!==null)return ruleText.test(prefix)&&command.startsWith(prefix);
|
|
69
|
+
return ruleText.test(body)&&command===body;
|
|
70
|
+
}
|
|
71
|
+
// The command forms of configured checks (as typed at the root, after a cd
|
|
72
|
+
// into a check's directory, and the fan-out commands that run them) that no
|
|
73
|
+
// rule in `rules` matches.
|
|
74
|
+
export function uncoveredForms(rules,config,configFile=defaultConfig,packageScripts=null){
|
|
75
|
+
return [...new Set([...hookForms(config,configFile,packageScripts).flatMap(row=>[...row.forms,...row.local]),
|
|
76
|
+
...fanoutRules(config,packageScripts||{})])].filter(form=>!rules.some(rule=>triggerMatches(rule,form)));
|
|
77
|
+
}
|
|
78
|
+
// An argument with a space (`-m "not slow"`) is written quoted, in double and in single quotes.
|
|
43
79
|
function pythonForms(check){
|
|
44
|
-
const [file,...args]=check.command,tool=file.split('/').at(-1)
|
|
45
|
-
|
|
80
|
+
const [file,...args]=check.command,tool=file.split('/').at(-1);
|
|
81
|
+
const rests=[...new Set(['"',"'"].map(q=>args.length?' '+args.map(arg=>/\s/.test(arg)?`${q}${arg}${q}`:arg).join(' '):''))];
|
|
82
|
+
return rests.flatMap(rest=>[`.venv/bin/${tool}${rest}`,`./.venv/bin/${tool}${rest}`,`.venv/bin/python -m ${tool}${rest}`,
|
|
46
83
|
...wrappers.flatMap(wrapper=>[`${wrapper}${tool}${rest}`,`${wrapper}python -m ${tool}${rest}`]),
|
|
47
|
-
`python -m ${tool}${rest}`,`python3 -m ${tool}${rest}`,`${tool}${rest}`];
|
|
84
|
+
`python -m ${tool}${rest}`,`python3 -m ${tool}${rest}`,`${tool}${rest}`]);
|
|
48
85
|
}
|
|
49
86
|
// For a form that names a program PATH finds (`mypy .`, `python -m mypy .`),
|
|
50
87
|
// whether PATH finds it first in the project's .venv/bin.
|
|
@@ -289,7 +326,16 @@ function split(command){
|
|
|
289
326
|
if(typeof command!=='string'||/[\r\n]/.test(command))return null;
|
|
290
327
|
const [,base,suffix,rest=null]=command.trim()
|
|
291
328
|
.match(/^(.*?)((?:[ \t]+2>&1)?(?:[ \t]+\|[ \t]+(?:tail|head)[ \t]+(?:-n[ \t]+\d+|-\d+))?)(?:[ \t]*(?:;|&&)[ \t]*([^\s;&|].*))?$/);
|
|
292
|
-
|
|
329
|
+
// Runs of spaces are one, but not inside quotes: `-m "not slow"` is not `-m "not slow"`.
|
|
330
|
+
const one=text=>{let out='',quote=null,space=false;
|
|
331
|
+
for(const c of text){
|
|
332
|
+
if(quote){out+=c;if(c===quote)quote=null;continue;}
|
|
333
|
+
if(c===' '||c==='\t'){space=true;continue;}
|
|
334
|
+
if(space){out+=' ';space=false;}
|
|
335
|
+
if(c==='"'||c==="'")quote=c;
|
|
336
|
+
out+=c;
|
|
337
|
+
}
|
|
338
|
+
return out+(space?' ':'');};
|
|
293
339
|
return {base:one(base),suffix:one(suffix),rest};
|
|
294
340
|
}
|
|
295
341
|
// {check, rest}, or {fanout, rest} for a turbo/nx/`pnpm -r` command or a root
|
|
@@ -20,9 +20,11 @@ export const pythonTools=[{tool:'mypy',kind:'typecheck',sub:[]},{tool:'pyright',
|
|
|
20
20
|
const section=(text,header)=>{const at=text.split(/\r?\n/).findIndex(line=>line.trim()===header);
|
|
21
21
|
if(at<0)return null;const rest=text.split(/\r?\n/).slice(at+1);const end=rest.findIndex(line=>/^\s*\[/.test(line));
|
|
22
22
|
return (end<0?rest:rest.slice(0,end)).join('\n');};
|
|
23
|
-
// Ways a project starts a tool: through uv, poetry or pdm, `python -m`,
|
|
23
|
+
// Ways a project starts a tool: through uv, poetry or pdm, `python -m`,
|
|
24
|
+
// coverage (`coverage run -m pytest`), or from .venv/bin.
|
|
24
25
|
const wrappers=[/^uv run(?: --(?:frozen|locked|no-sync|offline|quiet|all-extras|all-groups))*(?: --(?:group|extra|with|python|package) [^ ]+)* /,
|
|
25
26
|
/^poetry run /,/^pdm run /,/^python3?(?:\.\d+)? -m /,/^(?:\.\/)?\.venv\/bin\//,
|
|
27
|
+
/^coverage run(?: --?[A-Za-z][\w-]*(?:=[^ "']+)?)* -m /,
|
|
26
28
|
// A variable that names the environment's bin directory: `${PREFIX}mypy`.
|
|
27
29
|
/^\$\{?[A-Za-z_]+\}?(?=[A-Za-z])/];
|
|
28
30
|
// A tool's arguments in one line of a script or workflow, or null. The line
|
|
@@ -30,22 +32,51 @@ const wrappers=[/^uv run(?: --(?:frozen|locked|no-sync|offline|quiet|all-extras|
|
|
|
30
32
|
// ones that change files or install packages, and pytest's that run a set
|
|
31
33
|
// chosen from its cache (--lf, --ff, --sw, --nf), show the cache, loop
|
|
32
34
|
// (-f), rewrite snapshots or add to earlier coverage data, are not taken.
|
|
35
|
+
// A quoted argument (`-m "not slow"`) is one argument, and may hold spaces
|
|
36
|
+
// and parentheses, nothing a shell would expand. Options that only write a
|
|
37
|
+
// report (JUnit XML, HTML, durations) are left out: they change no result,
|
|
38
|
+
// and a report written into the checkout would be an input the run changes.
|
|
39
|
+
const reportOption=/^--(?:junit-?xml|html|self-contained-html|durations|durations-min|junit-prefix|report-log)(?:=|$)/;
|
|
40
|
+
const reportValue=/^--(?:junit-?xml|html|durations|durations-min|junit-prefix|report-log)$/;
|
|
41
|
+
function words(text){
|
|
42
|
+
const out=[];let current=null,quote=null;
|
|
43
|
+
for(const c of text){
|
|
44
|
+
if(quote){if(c===quote)quote=null;else current+=c;continue;}
|
|
45
|
+
if(c==='"'||c==="'"){quote=c;current??='';current+=`\u0000`;continue;}
|
|
46
|
+
if(c===' '){if(current!==null)out.push(current);current=null;continue;}
|
|
47
|
+
current=(current??'')+c;
|
|
48
|
+
}
|
|
49
|
+
if(quote)return null;
|
|
50
|
+
if(current!==null)out.push(current);
|
|
51
|
+
// A quoted word is marked with NUL while it is read: its text must be plain.
|
|
52
|
+
return out.map(word=>word.includes('\u0000')?{quoted:true,text:word.replaceAll('\u0000','')}:{quoted:false,text:word});
|
|
53
|
+
}
|
|
33
54
|
export function toolArgs(line,{tool,sub}){
|
|
34
55
|
let text=line.trim().replace(/^-\s+/,'').replace(/^run:\s*/,'').replace(/^[@-]+/,'');
|
|
35
56
|
text=text.split(/\s(?:&&|\|\||;|\|)\s/)[0].replace(/\s+/g,' ').trim();
|
|
36
57
|
for(let changed=true;changed;){changed=false;for(const wrapper of wrappers)if(wrapper.test(text)){text=text.replace(wrapper,'');changed=true;}}
|
|
37
|
-
const words
|
|
38
|
-
if(
|
|
39
|
-
const
|
|
40
|
-
if(
|
|
58
|
+
const parsed=words(text);
|
|
59
|
+
if(!parsed)return null;
|
|
60
|
+
const names=parsed.map(word=>word.text);
|
|
61
|
+
if(parsed.slice(0,1+sub.length).some(word=>word.quoted)||names[0]!==tool||JSON.stringify(names.slice(1,1+sub.length))!==JSON.stringify(sub))return null;
|
|
62
|
+
const given=parsed.slice(1+sub.length);
|
|
63
|
+
if(given.some(({quoted,text:arg})=>!(quoted?/^[A-Za-z0-9_./:=,@+% ()-]+$/:/^[A-Za-z0-9_./:=,@+%-]+$/).test(arg)||
|
|
64
|
+
/^--(?:fix|unsafe-fixes|write|install-types|watch|lf|last-failed|ff|failed-first|sw|sw-skip|sw-reset|stepwise|stepwise-skip|stepwise-reset|nf|new-first|cache-show|looponfail|snapshot-update|update-snapshots|inline-snapshot|cov-append)(?:=|$)/.test(arg)||
|
|
65
|
+
arg==='-f'||arg.startsWith('..')||arg.startsWith('/')))return null;
|
|
66
|
+
const args=[];
|
|
67
|
+
for(let i=0;i<given.length;i++){
|
|
68
|
+
if(reportOption.test(given[i].text)){if(reportValue.test(given[i].text))i++;continue;}
|
|
69
|
+
args.push(given[i].text);
|
|
70
|
+
}
|
|
41
71
|
return args;
|
|
42
72
|
}
|
|
43
73
|
// The places a project writes how it runs its tools, in order of preference.
|
|
74
|
+
// A workflow that runs on pull requests is marked `pr`.
|
|
44
75
|
function scriptTexts(root){
|
|
45
76
|
const texts=[];
|
|
46
77
|
const workflows=path.join(root,'.github','workflows');
|
|
47
78
|
let files=[];try{files=fs.readdirSync(workflows).filter(file=>/\.ya?ml$/.test(file)).sort();}catch{}
|
|
48
|
-
for(const file of files)
|
|
79
|
+
for(const file of files){const text=read(path.join(workflows,file));texts.push({text,pr:/\bpull_request(?:_target)?\b/.test(text)});}
|
|
49
80
|
for(const file of ['Makefile','makefile','justfile','tox.ini','noxfile.py'])texts.push(read(path.join(root,file)));
|
|
50
81
|
// Shell scripts: *.sh, or a file whose #! line runs a shell (scripts/check).
|
|
51
82
|
for(const dir of ['scripts','script','bin','ci']){let names=[];try{names=fs.readdirSync(path.join(root,dir)).sort();}catch{}
|
|
@@ -53,13 +84,24 @@ function scriptTexts(root){
|
|
|
53
84
|
if(name.endsWith('.sh')||/^#!\S*(?:\/|env\s+)(?:ba|z|da)?sh\b/.test(text))texts.push(text);}}
|
|
54
85
|
return texts;
|
|
55
86
|
}
|
|
56
|
-
// The arguments the project runs `tool` with, or its default
|
|
87
|
+
// The arguments the project runs `tool` with, or its default: the first line
|
|
88
|
+
// that runs it, but a pull-request workflow's when it narrows the suite (-m,
|
|
89
|
+
// -k, --ignore, --deselect), as a project's quick tests leave out slow ones.
|
|
90
|
+
// A line continued with a backslash is one line.
|
|
91
|
+
const narrows=args=>args.some(arg=>/^(?:-m|-k|--ignore|--ignore-glob|--deselect)(?:=|$)/.test(arg));
|
|
57
92
|
export function projectArgs(root,spec,texts=scriptTexts(root)){
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
93
|
+
let first=null;
|
|
94
|
+
for(const entry of texts){
|
|
95
|
+
const {text,pr}=typeof entry==='string'?{text:entry,pr:false}:entry;
|
|
96
|
+
for(const line of text.replace(/\\\r?\n[ \t]*/g,' ').split(/\r?\n/)){
|
|
97
|
+
if(/^\s*#/.test(line))continue;
|
|
98
|
+
const args=toolArgs(line,spec);
|
|
99
|
+
if(!args)continue;
|
|
100
|
+
if(pr&&narrows(args))return {args,source:'project'};
|
|
101
|
+
first??={args,source:'project'};
|
|
102
|
+
}
|
|
62
103
|
}
|
|
104
|
+
if(first)return first;
|
|
63
105
|
if(spec.tool!=='mypy')return {args:[],source:'default'};
|
|
64
106
|
// mypy without targets needs `files` in its configuration.
|
|
65
107
|
const configured=[section(read(path.join(root,'pyproject.toml')),'[tool.mypy]'),section(read(path.join(root,'mypy.ini')),'[mypy]'),
|
|
@@ -83,7 +125,7 @@ export function discoverPythonProject(root){
|
|
|
83
125
|
const several=found.filter(other=>other.kind===spec.kind).length>1;
|
|
84
126
|
checks.push({config:{name:several?`${spec.kind}:${spec.tool}`:spec.kind,kind:spec.kind,qualification:sandboxQualification,
|
|
85
127
|
command:[`.venv/bin/${spec.tool}`,...spec.sub,...args],...sandboxCheckInputs()},level:'ready',qualification:sandboxQualification,
|
|
86
|
-
reason:`sandboxed recipe: ${[spec.tool,...spec.sub,...args].join(' ')} from .venv${source==='project'?' as the project runs it':''}; the whole checkout is its input`});
|
|
128
|
+
reason:`sandboxed recipe: ${[spec.tool,...spec.sub,...args].map(arg=>/\s/.test(arg)?JSON.stringify(arg):arg).join(' ')} from .venv${source==='project'?' as the project runs it':''}; the whole checkout is its input`});
|
|
87
129
|
}
|
|
88
130
|
return {root,checks,skipped};
|
|
89
131
|
}
|