@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 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 which checks still hold. Redo what's due.**
3
+ **Know what is verified on the code you have now.**
4
4
 
5
- You or your coding agent ran the typecheck, the linter or the tests. Then the
6
- code changed, or a new agent session started with no memory of it. REDUE
7
- records each check's result with the exact inputs it ran on, and tells you and
8
- your agent whether that result still applies to the code you have now, so a
9
- check that still holds is not run again and one that is stale is.
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
- ## Quickstart (about 5 minutes)
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 that shows what is
36
- verified on the code as it is now ("REDUE ✓ 3 verified", "REDUE 2 due:
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 to reuse, and prints one line per run (the run's
41
- own output only when it fails);
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
- Then start a new Claude Code session in the project and work as usual. When
45
- the agent is about to rerun a check whose last pass still applies, the hook
46
- skips it and tells the agent why. After an edit, the hook runs the check
47
- through REDUE so the new result is recorded. When the agent is about to finish a
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 look yourself:
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 explain typecheck
61
- typecheck: STALE / PASS
62
- src/main.ts changed
63
- Due again when verification is needed: redue run "typecheck"
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 check
73
- ends CURRENT / PASS, so it answers "is everything verified on the code as it
74
- is now?" in one command. A check no run can confirm is shown as NOT
75
- VERIFIABLE with the reason, and left out: a recording-only check, or one
76
- whose runs change its own inputs (two passing runs in a row changed the same
77
- files, such as a test script that rebuilds a tracked bundle). Any other
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:** TypeScript 7's native compiler, Bun, per-package checks in
169
- large monorepos, results shared across machines, and the general recipe on
170
- Windows (Windows has the strict recipes only).
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
 
@@ -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. Doctor neither
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 native compiler) is not
68
- supported.
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
 
@@ -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`. Without them it uses the tool's default:
45
- `ruff check`, `ty check`, `pyright`, and `mypy` (with `.` when mypy's
46
- configuration names no `files`).
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@redue/cli",
3
3
  "private": false,
4
- "version": "0.1.0-alpha.32",
4
+ "version": "0.1.0-alpha.33",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
@@ -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 exact command form; `if` keeps other Bash calls from starting Node.
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)])].map(form=>`Bash(${form}*)`);
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
  }
@@ -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),rest=args.length?' '+args.join(' '):'';
45
- return [`.venv/bin/${tool}${rest}`,`./.venv/bin/${tool}${rest}`,`.venv/bin/python -m ${tool}${rest}`,
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
- const one=text=>text.replace(/[ \t]+/g,' ');
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`, or from .venv/bin.
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=text.split(' ');
38
- if(words[0]!==tool||JSON.stringify(words.slice(1,1+sub.length))!==JSON.stringify(sub))return null;
39
- const args=words.slice(1+sub.length);
40
- if(args.some(arg=>!/^[A-Za-z0-9_./:=,@+%-]+$/.test(arg)||/^--(?: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)||arg==='-f'||arg.startsWith('..')||arg.startsWith('/')))return null;
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)texts.push(read(path.join(workflows,file)));
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
- for(const text of texts)for(const line of text.split(/\r?\n/)){
59
- if(/^\s*#/.test(line))continue;
60
- const args=toolArgs(line,spec);
61
- if(args)return {args,source:'project'};
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
  }