@redue/cli 0.1.0-alpha.27 → 0.1.0-alpha.29

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.
@@ -1,24 +1,27 @@
1
- # Sandboxed recipe (typecheck and lint, any language)
1
+ # Sandboxed recipe (typecheck, lint and tests, any language)
2
2
 
3
- The sandboxed recipe runs a typecheck or lint command inside an operating
3
+ The sandboxed recipe runs a typecheck, lint or test command inside an operating
4
4
  system sandbox and reports later whether that result still applies to the
5
5
  files you have. The sandbox holds for every process the command starts, so it
6
6
  covers programs REDUE cannot record: TypeScript 7's native compiler, esbuild,
7
7
  oxlint and tsgolint, biome, turbo, and Python tools such as mypy, pyright, ty
8
- and ruff.
8
+ and ruff, and test runners such as pytest.
9
9
 
10
10
  **The claim.** A sandboxed result holds for a run that sees only the project's
11
- files that git doesn't ignore, its installed packages and the toolchain, the
12
- way CI does. It does not say that a run with your own configuration files, a
13
- service on your machine or the network would give the same result.
11
+ files that git doesn't ignore, except its documentation, its installed
12
+ packages and the toolchain, the way CI does. It does not say that a run with
13
+ your own configuration files, a service on your machine or the network would
14
+ give the same result.
14
15
 
15
- A check that needs files git ignores (generated code, another package's
16
- built declarations) gets the whole checkout instead: its result holds for a
17
- run that sees the project, git-ignored files included, its installed packages
18
- and the toolchain. See [files git ignores](#files-git-ignores).
16
+ A check that reads the documentation (a doctest over `README.md`) sees it
17
+ too. A check that needs files git ignores (generated code, another package's
18
+ built declarations) gets the whole checkout. See
19
+ [hidden files](#files-git-ignores-and-documentation).
19
20
 
20
21
  ## Where it applies
21
22
 
23
+ - **Tests:** a root `test` script the [direct test recipe](tests-direct.md)
24
+ does not take, and pytest in the project's `.venv`. See [tests](#tests).
22
25
  - **Node projects:** a root `typecheck` or `lint` script (npm, pnpm, or Yarn
23
26
  with the `node-modules` linker). `init` tries the
24
27
  [direct TypeScript recipe](direct-typescript.md) and the pnpm and Yarn
@@ -49,14 +52,15 @@ redue status --sync
49
52
  A Python check in the config:
50
53
 
51
54
  ```json
52
- {"name": "typecheck", "kind": "typecheck", "qualification": "sandbox-v2",
55
+ {"name": "typecheck", "kind": "typecheck", "qualification": "sandbox-v3",
53
56
  "command": [".venv/bin/mypy", "src"], "inputs": ["**", "..."], "installedInputs": [".venv/**", "..."]}
54
57
  ```
55
58
 
56
59
  A Node check names its script, as the general recipe's do:
57
- `{"name": "lint", "script": "lint", "kind": "lint", "qualification": "sandbox-v2", ...}`.
58
- `sandbox-v2` hides the files git ignores from the run; `sandbox-v1` runs it on
59
- the whole checkout.
60
+ `{"name": "lint", "script": "lint", "kind": "lint", "qualification": "sandbox-v3", ...}`.
61
+ `sandbox-v3` hides the files git ignores and the documentation from the run,
62
+ `sandbox-v2` only the files git ignores; `sandbox-v1` runs it on the whole
63
+ checkout.
60
64
 
61
65
  With the Claude Code hook, `uv run mypy src`, `poetry run mypy src`,
62
66
  `python -m mypy src`, `.venv/bin/mypy src` and `mypy src` are recognized as
@@ -64,12 +68,31 @@ that check. The last two count only when PATH finds the tool or Python in the
64
68
  project's `.venv/bin` first. When the hook runs the check through REDUE, it
65
69
  keeps `uv run` or `poetry run` in front, so the environment still syncs first.
66
70
 
67
- ## Files git ignores
68
-
69
- `init` runs each check three ways at most: plainly, sandboxed with the files
70
- git ignores hidden, and, when those two differ, sandboxed with the whole
71
- checkout (reusing the plain run). The first sandboxed way that gives the same
72
- result is written: `sandbox-v2` or `sandbox-v1`.
71
+ ## Files git ignores and documentation
72
+
73
+ `init` runs each check four ways at most: plainly, then sandboxed with the
74
+ files git ignores and the documentation hidden, with only the files git
75
+ ignores hidden, and with the whole checkout (reusing the plain run). The first
76
+ sandboxed way that gives the same result is written: `sandbox-v3`,
77
+ `sandbox-v2` or `sandbox-v1`.
78
+
79
+ With `sandbox-v3`, each run also hides the documentation: the files git
80
+ lists (and does not ignore) named `*.md`, `*.mdx`, `*.rst`, `LICENSE*` or
81
+ `CHANGELOG*` anywhere, and text, image and PDF files (`.txt`, `.adoc`,
82
+ `.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, `.webp`, `.pdf`) in the root `docs/`
83
+ folder. Code in `docs/` (examples a typecheck includes) is not hidden, and
84
+ folders stay listable. The receipt names the files the run hid. A later edit
85
+ to one of them, or its deletion, leaves the result CURRENT: editing the
86
+ README does not make the tests STALE. A new documentation file, a link named
87
+ like one, or a change made while the run ran still counts. On macOS the
88
+ files with those extensions are refused by name pattern, in any case and in
89
+ `node_modules` too: `README.MD` or `Docs/logo.svg` is refused but not
90
+ listed, so an edit to it counts, and a check that reads one gets
91
+ `sandbox-v2` on macOS. `LICENSE*` and `CHANGELOG*` files are refused by the
92
+ exact paths git lists, so `license-check.ts` stays readable. On Linux each
93
+ listed file is hidden like a git-ignored one. In a checkout with more than
94
+ 100 `LICENSE*` and `CHANGELOG*` files (vendored packages), those files are
95
+ not hidden on either platform and count as inputs.
73
96
 
74
97
  With `sandbox-v2`, each run hides what `git ls-files --others --ignored
75
98
  --exclude-standard --directory` lists, except `node_modules`, `.venv` and
@@ -78,25 +101,27 @@ Only the project's rules count: its `.gitignore` files and
78
101
  `.git/info/exclude`, not your global excludes file. Those files are not
79
102
  inputs, so a build or test run that writes them leaves the result CURRENT.
80
103
 
81
- When a `sandbox-v2` run fails with a refusal that names a hidden file (a
82
- missing file counts only when it names one, since Linux tools report an
83
- unreadable file as missing), REDUE runs the check again in the sandbox
84
- without hiding anything. That result is kept and compared on every file,
85
- git-ignored ones included. Run `redue init` again to write `sandbox-v1` for a
86
- check that keeps needing them.
104
+ When a `sandbox-v2` or `sandbox-v3` run fails with a refusal that names a
105
+ hidden file (a missing file counts only when it names one, since Linux tools
106
+ report an unreadable file as missing), REDUE runs the check again in the
107
+ sandbox without hiding anything. That result is kept and compared on every
108
+ file, git-ignored ones and documentation included. Run `redue init` again to
109
+ write another recipe for a check that keeps needing them.
87
110
 
88
111
  ## What the run sees
89
112
 
90
113
  It may read and execute only beneath:
91
114
 
92
- - the checkout, without the files git ignores for `sandbox-v2`;
115
+ - the checkout, without the files git ignores for `sandbox-v2`, and without
116
+ its documentation too for `sandbox-v3`;
93
117
  - the toolchain: Node's installation and the package managers' (with
94
118
  Corepack's downloads), or the Python installation the `.venv` was made from;
95
119
  - system directories (`/usr`, `/bin`, `/etc` and the like on Linux; `/usr`,
96
120
  `/System`, `/Library`, `/opt/homebrew` and the like on macOS);
97
121
  - a private home directory and a fresh temporary directory, in REDUE's state.
98
122
 
99
- It has no network: no TCP connection, loopback included. On macOS its only
123
+ A typecheck or lint has no network: no TCP connection, loopback included
124
+ (a test run on Linux gets loopback; see [tests](#tests)). On macOS its only
100
125
  Unix sockets are in its own temporary directory, which is new for each run.
101
126
  On Linux 6.12 and later it cannot reach an abstract Unix socket outside the
102
127
  run.
@@ -124,11 +149,56 @@ It gets a controlled environment, not yours:
124
149
  - `HOME`, `TMPDIR` and the XDG directories in its private directories;
125
150
  `VIRTUAL_ENV` for a Python check; Corepack's home where Corepack keeps it.
126
151
 
152
+ ## Tests
153
+
154
+ A test run gets what a typecheck or lint gets, and also:
155
+
156
+ - **On Linux, loopback, and no remote host.** It may listen on any address
157
+ and accept connections, and connect only to the local host (`127.0.0.0/8`,
158
+ `::1`, `0.0.0.0`, `::`) and Unix sockets.
159
+ - Landlock cannot tell a loopback address from a remote one, so a
160
+ seccomp filter in the test's processes does instead. It refuses sockets
161
+ other than Unix, netlink and TCP when they are created (UDP included),
162
+ TCP Fast Open (`MSG_FASTOPEN`) and `io_uring`. It hands each `connect` to
163
+ REDUE's launcher. The launcher allows a local port no process listens on
164
+ (the test gets "connection refused") or one the run's own processes
165
+ listen on, and a Unix socket file in the checkout, the run's home or its
166
+ temporary directory. It refuses a service started outside the run (your
167
+ local database) and every remote host. Nothing else goes through the
168
+ launcher, so worker processes talk to each other as usual. If the
169
+ launcher's supervisor cannot start, or stops before the run ends, the
170
+ result is UNVERIFIED, never CURRENT.
171
+ - **On macOS, no network.** No TCP or UDP, loopback included: only Unix
172
+ sockets in its own temporary directory (its home is beneath it), as a
173
+ typecheck or lint gets. Seatbelt cannot refuse a service started outside
174
+ the run while allowing the run's own servers: a rule that refuses one
175
+ loopback port under a loopback allow is not applied, and an allow list of
176
+ every other port does not compile. A suite that starts a server on
177
+ loopback, or reads a local database, fails init's trial: init leaves the
178
+ test check out, and the tests run without REDUE, as before. A test check
179
+ set up by alpha.28 on macOS is not CURRENT after the upgrade; if its runs
180
+ now fail inside the sandbox, run `redue init` again.
181
+ - **A fresh home.** Its home directory is new and empty for each run, as on
182
+ CI: what a test leaves there is not observed, so it must not reach a later
183
+ run.
184
+ - **A private `/tmp`.** Its `TMPDIR` is its own, new for each run, and it may
185
+ not write in `/tmp` or `/var/tmp` outside it. A test that writes to a fixed
186
+ path there is refused.
187
+
188
+ `init` compares a test run's exit status and the counts its summary prints
189
+ (passed, failed, skipped), not every line: test runners print in the order
190
+ their workers finish. Git-ignored files are hidden from a test run when the
191
+ trial agrees, as for typecheck and lint. A suite that reads back git-ignored
192
+ files it writes in the same run (a coverage database, a build it starts
193
+ itself) gets the whole checkout. Files a hidden run writes beneath hidden
194
+ folders, or new files git ignores, are not changes to its inputs.
195
+
127
196
  ## What REDUE claims
128
197
 
129
198
  CURRENT means the last sandboxed run's inputs are unchanged:
130
199
 
131
- - every file in the checkout git doesn't ignore (`sandbox-v2`), or every file
200
+ - every file in the checkout git doesn't ignore but its documentation
201
+ (`sandbox-v3`), every file git doesn't ignore (`sandbox-v2`), or every file
132
202
  in it, git-ignored files and git-ignored links to files in it included
133
203
  (`sandbox-v1`), except `.git` and runner caches (the general recipe's, plus
134
204
  `__pycache__`, `.mypy_cache`, `.ruff_cache` and `.pytest_cache`);
@@ -154,9 +224,9 @@ every failure twice to find out:
154
224
  timings, the temporary and home directories, and lines about caches.
155
225
  Otherwise `init` writes what it would have without this recipe and says why.
156
226
  These are the only runs `init` makes.
157
- - When a `sandbox-v2` run fails with a refusal that names a hidden file, it
227
+ - When a `sandbox-v2` or `sandbox-v3` run fails with a refusal that names a hidden file, it
158
228
  runs again in the sandbox without hiding (see
159
- [files git ignores](#files-git-ignores)).
229
+ [files git ignores](#files-git-ignores-and-documentation)).
160
230
  - When a sandboxed run fails and its output has a line that looks like a
161
231
  refusal ("Operation not permitted", "Permission denied", "Read-only file
162
232
  system", a name lookup or a connection that failed), REDUE runs the check
@@ -166,7 +236,7 @@ every failure twice to find out:
166
236
 
167
237
  ## The private home
168
238
 
169
- The run's home directory is kept between runs, per check, so caches tools
239
+ A typecheck or lint's home directory is kept between runs, per check, so caches tools
170
240
  build there (font caches, a type checker's cache) are not rebuilt every run.
171
241
  It starts empty: your configuration files are not in it. What a run leaves
172
242
  there can change a later run, and REDUE trusts it like a runner cache. Remove
@@ -200,17 +270,24 @@ it with `redue remove-state`.
200
270
  nested example project's).
201
271
  - A script that changes files (`--fix`, `--write`) does not get this recipe:
202
272
  `init` would run it twice.
203
- - With `sandbox-v2`, a run cannot read a hidden file, but it can still see
204
- that one exists. On macOS it sees the names of hidden files and folders at
205
- the level where they start (`dist` in the checkout's root). On Linux it can
206
- also list a hidden folder and see its files' sizes and times: Landlock
207
- restricts neither beneath a folder the run may list. A tool that acts on
273
+ - With `sandbox-v2` or `sandbox-v3`, a run cannot read a hidden file, but it can still see
274
+ that one exists, its size and its times, and write beneath it. On Linux it
275
+ can also list a hidden folder; on macOS it cannot. A tool that acts on
208
276
  whether a git-ignored file exists, without reading it, is not covered: a
209
- new or removed one does not make the result STALE. Typecheckers and
210
- linters read what they act on; a test that skips itself when a built file
211
- is missing does not, which is why hidden files are not used for tests yet.
212
- - On Linux, a file a `sandbox-v2` run creates in a folder that holds hidden
213
- files (the checkout's root, when anything in it is hidden) cannot be read
277
+ new or removed one does not make the result STALE. In a census of 136
278
+ test suites with ignored files after their build, none did.
279
+ - On Linux, a test run's UDP is refused, loopback included: the filter
280
+ decides when the socket is made, before its address is known. A test that
281
+ needs it fails its trial and keeps another recipe.
282
+ - On Linux, the launcher decides a `connect` from the address it reads in
283
+ the process's memory, and the kernel reads it again after. A process that
284
+ changes the address in between, from another thread, could reach another
285
+ host. Honest test code does not.
286
+ - On Linux, a typecheck or lint can still connect to a Unix socket file
287
+ outside it (a local database's).
288
+ - On Linux, a file a `sandbox-v2` or `sandbox-v3` run creates in a folder that holds hidden
289
+ files (the checkout's root, when anything in it is hidden; with `sandbox-v3`,
290
+ any folder with a README) cannot be read
214
291
  back in the same run. `init`'s trial and the rerun without hiding catch a
215
292
  check that needs to.
216
293
  - A file whose git status changes without a change to its content (`git add
@@ -11,9 +11,9 @@ REDUE UNVERIFIED
11
11
  ```
12
12
 
13
13
  It reads the cached snapshot, checks its expiry and observer liveness, and never
14
- synchronizes, traverses installed packages, or runs probes/checks. Like ordinary
15
- status, it can remain conservative when caller context or installed hardlinks
16
- need a synchronized read. Do not turn that uncertainty into a green badge.
14
+ synchronizes, traverses installed packages, or runs probes/checks. Like
15
+ `status --cached`, it can remain conservative when caller context or installed
16
+ hardlinks need a synchronized read. Do not turn that uncertainty into a green badge.
17
17
 
18
18
  For an existing async/bounded prompt framework, use `redue status --short` from
19
19
  the project root as the segment command and display unavailable on errors or
@@ -19,13 +19,16 @@ build scripts. It prefers familiar script names (for typecheck: `typecheck`,
19
19
  `tsc`, Vitest/Jest, ESLint, or Vite commands under other names. Multiple
20
20
  matches are reported as ambiguous rather than guessed. It never creates a new
21
21
  project script. Without `--recipe`, `init` tries the strict recipes below first, then,
22
- for a root typecheck or lint script, the [sandboxed recipe](sandbox.md) on
22
+ for a root typecheck, lint or test script, the [sandboxed recipe](sandbox.md) on
23
23
  macOS and Linux (the script run inside an OS sandbox, with the files git
24
- ignores hidden when it gives the same result that way, else on the whole
25
- checkout), then the [general recipe](node-script.md): the script run
24
+ ignores and the documentation hidden when it gives the same result that way,
25
+ else with only the files git ignores hidden, else on the whole checkout; a
26
+ test run also gets a private `/tmp`, and loopback-only network on Linux or no
27
+ network on macOS),
28
+ then, for typecheck and lint, the [general recipe](node-script.md): the script run
26
29
  through the package manager with every Node process recorded and the whole
27
30
  checkout as its input. Python projects get the sandboxed recipe for mypy,
28
- pyright, ty and ruff in their `.venv`. `--recipe typescript-direct`,
31
+ pyright, ty, ruff and pytest in their `.venv`. `--recipe typescript-direct`,
29
32
  `tests-direct`, `node-script` or `sandbox` selects one recipe alone. The direct TypeScript recipe supports
30
33
  a typecheck script of `tsc --noEmit` runs in any argument order, chained runs,
31
34
  or no typecheck script at all (a proposed `tsc --noEmit`; see
@@ -56,7 +59,9 @@ typechecks, Vitest/Jest, ESLint, and builds in workspace packages start
56
59
  recording-only unless a separately justified explicit contract is provided.
57
60
  For a single-package project whose test script runs Vitest 4/5 or Jest 29
58
61
  directly, `init` uses the direct test recipe, which records the run and guards
59
- its inputs; see [direct tests](tests-direct.md).
62
+ its inputs; see [direct tests](tests-direct.md). Other root test scripts get
63
+ the sandboxed recipe when its trial gives the same exit status and test counts
64
+ as a plain run.
60
65
  In particular, test caches and build-generated inputs are not excluded to
61
66
  make evidence green.
62
67
 
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.27",
4
+ "version": "0.1.0-alpha.29",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
@@ -27,7 +27,7 @@ const general=check=>(check?.qualification==='node-script-v1'&&['typecheck','tes
27
27
  sandboxed(check))&&typeof check.script==='string';
28
28
  // A sandboxed check: a root package script, or a tool from the project's .venv.
29
29
  // sandbox.mjs's isSandboxed, written out: the hook does not load the sandbox module.
30
- const sandboxed=check=>['sandbox-v1','sandbox-v2'].includes(check?.qualification)&&['typecheck','lint'].includes(check.kind);
30
+ const sandboxed=check=>['sandbox-v1','sandbox-v2','sandbox-v3'].includes(check?.qualification)&&['typecheck','lint','test'].includes(check.kind);
31
31
  const python=check=>sandboxed(check)&&Array.isArray(check.command)&&/^\.venv\/bin\/[A-Za-z0-9_.-]+$/.test(check.command[0]||'')&&
32
32
  check.command.every(word=>typeof word==='string'&&safe.test(word));
33
33
  const eligibleCheck=check=>typecheck(check)||tests(check)||general(check)||python(check);
@@ -446,7 +446,7 @@ function synchronizedStatus(entry,config,root,timeout,sync=true){
446
446
  // Claude Code ends a hook that outlives its own timeout; end the status call with it.
447
447
  const stop=()=>finish(null);process.once('SIGTERM',stop);
448
448
  // Its own process group on POSIX, so a timeout also ends the engine it starts.
449
- try{child=spawn(process.execPath,[entry,'--config',config,'status',...(sync?['--sync']:[]),'--json'],
449
+ try{child=spawn(process.execPath,[entry,'--config',config,'status',sync?'--sync':'--cached','--json'],
450
450
  {cwd:root,stdio:['ignore','pipe','ignore'],env:process.env,windowsHide:true,detached:process.platform!=='win32'});}
451
451
  catch{return finish(null);}
452
452
  child.on('error',()=>finish(null));
@@ -456,12 +456,14 @@ function synchronizedStatus(entry,config,root,timeout,sync=true){
456
456
  });
457
457
  }
458
458
 
459
- // Waiting for status never costs more than half the check: half the last
460
- // completed run's duration, at most 10 s. With no completed run, 2 s.
459
+ // Half the last completed run's duration, at least 2 s and at most 10 s; with
460
+ // no completed run, 2 s. A shorter wait misses a status call (70-250 ms here,
461
+ // over 1.5 s on a loaded CI or Windows runner) for a fast check, and the
462
+ // agent's run would go unrecorded.
461
463
  export function statusTimeout(status,name){
462
464
  const run=Array.isArray(status?.checks)?status.checks.find(row=>row?.name===name)?.invocation:null;
463
465
  return run?.status==='exited'&&Number.isFinite(run.durationMs)&&run.durationMs>=0?
464
- Math.min(run.durationMs/2,10000):2000;
466
+ Math.min(Math.max(run.durationMs/2,2000),10000):2000;
465
467
  }
466
468
  // From the observer's last published status, which is small, not the receipts.
467
469
  // The longest wait among `names`, read once.
@@ -500,18 +502,29 @@ export function sessionStart(input,{entry}){
500
502
  }
501
503
 
502
504
  // Returns the hook's stdout object, or null to allow the command.
503
- export async function claudeHook(input,{entry,timeout,invoke=null}){
504
- if(input?.tool_name!=='Bash')return null;
505
+ // `explain(text)` hears why, also when the command is left as it is.
506
+ export async function claudeHook(input,{entry,timeout,invoke=null,explain=null}){
507
+ // A failing explain never changes the decision.
508
+ const why=(text,value=null)=>{try{explain?.(text);}catch{}return value;};
509
+ if(input?.tool_name!=='Bash')return why('not a Bash command');
505
510
  const command=input.tool_input?.command,root=input.cwd;
506
- if(typeof command!=='string'||/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return null;
507
- const project=hookProject(root);
508
- if(!project)return null;
511
+ if(typeof command!=='string')return why('no command');
512
+ if(/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return why('REDUE_FORCE=1');
513
+ let project;
514
+ try{project=hookProject(root);}catch(error){return why(`no readable setup record in this directory (${error.code??error.message})`);}
515
+ if(!project)return why('REDUE is not set up for Claude Code in this directory');
516
+ // What a status answer said about `names`, when the hook does not skip.
517
+ const said=(status,names,waited)=>!status?`status did not answer within ${waited} ms`:
518
+ status.observation?.healthy!==true?'the observer is not healthy':
519
+ names.map(name=>{const row=Array.isArray(status.checks)?status.checks.find(item=>item?.name===name):null;
520
+ return !row?`${name} not in status`:`${name} ${row.freshness}/${row.result??'-'}${row.freshness==='CURRENT'&&row.result==='PASS'?
521
+ ` but not reusable (reuse_eligible ${row.reuse_eligible}, run ${row.invocation?.runId??'none'})`:row.reason?` (${row.reason})`:''}`;}).join('; ');
509
522
  const {record,config,parsed}=project;
510
523
  // For `redue stats`: the checks a decision skipped, or the ones that were due.
511
524
  const tally=(status,names,skipped)=>{let state;try{state=projectState(project.projectRoot,config);}catch{return;}
512
525
  for(const name of names){const entry=skipped?skipNote(status,name,'hook'):dueNote(status,name);if(entry)note(state,entry);}};
513
526
  const entered=enteredDir(command,root,project.projectRoot);
514
- if(!entered)return null;
527
+ if(!entered)return why('the command enters a directory outside the project');
515
528
  let scripts={};
516
529
  if(entered.dir==='.')try{scripts=JSON.parse(fs.readFileSync(path.join(project.projectRoot,'package.json'),'utf8')).scripts||{};}catch{}
517
530
  // The configured config path as the command's directory sees it.
@@ -527,23 +540,25 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
527
540
  // The observer's cached health is enough here: each check asks again when it runs.
528
541
  const chained=chainCommand(entered.command,parsed,record.config,entered.dir,scripts,invoke??redueInvocation(entry),configPath,project.projectRoot);
529
542
  if(chained){
530
- const cached=await synchronizedStatus(entry,config,root,timeout??5000,false);
543
+ const wait=timeout??5000,cached=await synchronizedStatus(entry,config,root,wait,false);
531
544
  return cached?.observation?.healthy===true&&chained.names.every(name=>cached.checks?.some(row=>row?.name===name))?
532
- {hookSpecificOutput:{hookEventName:'PreToolUse',updatedInput:{...input.tool_input,command:entered.prefix+chained.command}}}:null;
545
+ why(`a chain: ${chained.names.join(', ')} through redue run`,{hookSpecificOutput:{hookEventName:'PreToolUse',updatedInput:{...input.tool_input,command:entered.prefix+chained.command}}}):
546
+ why(`a chain left as written: ${said(cached,chained.names,wait)}`);
533
547
  }
534
548
  const match=matched(entered.command,parsed,record.config,entered.dir,scripts,project.projectRoot);
535
- if(!match)return null;
549
+ if(!match)return why('no check matches the command');
536
550
  // A check followed by more than a simple chain runs untouched.
537
- if(match.rest!==null&&!match.fanout)return null;
551
+ if(match.rest!==null&&!match.fanout)return why('more commands follow the check');
538
552
  // turbo, nx and `pnpm -r` cache their own results: skip only when every
539
553
  // check the command runs is CURRENT, and otherwise leave it alone.
540
554
  if(match.fanout){
541
555
  const names=await fanoutChecks(match.fanout,parsed,project.projectRoot);
542
- if(!names)return null;
543
- const status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,...names)),
556
+ if(!names)return why('a fan-out whose checks are not all configured');
557
+ const wait=timeout??lastRunTimeout(config,parsed,...names),status=await synchronizedStatus(entry,config,root,wait),
544
558
  reason=fanoutVerdict(status,names,match.rest);
545
559
  tally(status,names,Boolean(reason));
546
- return reason?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}:null;
560
+ return reason?why('every check CURRENT/PASS',{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}):
561
+ why(`a fan-out left to run: ${said(status,names,wait)}`);
547
562
  }
548
563
  // A compiler script: its checks in the order of its runs, or nothing when
549
564
  // they no longer correspond. One run is an ordinary check. A chain is
@@ -551,26 +566,26 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
551
566
  // redue run, in order.
552
567
  if(match.row){
553
568
  const names=scriptChecks(match.row,parsed,project.projectRoot);
554
- if(!names)return null;
569
+ if(!names)return why('the script no longer runs its configured checks');
555
570
  if(names.length===1)match.check=names[0];
556
571
  else{
557
- const status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,...names)),
572
+ const wait=timeout??lastRunTimeout(config,parsed,...names),status=await synchronizedStatus(entry,config,root,wait),
558
573
  reason=fanoutVerdict(status,names,match.rest);
559
574
  tally(status,names,Boolean(reason));
560
- if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}};
575
+ if(reason)return why('every check CURRENT/PASS',{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}});
561
576
  const pending=names.filter(name=>!verdict(status,name));
562
577
  const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
563
578
  names.every(name=>status.checks.some(item=>item?.name===name))?recordedCommand(entered.command,pending,configPath,invoke??redueInvocation(entry)):null;
564
- return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
579
+ return why(`${rewritten?'through redue run':'left as written'}: ${said(status,names,wait)}`,rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
565
580
  permissionDecisionReason:`REDUE: ${pending.map(name=>JSON.stringify(name)).join(', ')} of this script's checks ${pending.length===1?'is':'are'} not CURRENT; running ${pending.length===1?'it':'them'} through redue run so the result is recorded.`,
566
- updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
581
+ updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null);
567
582
  }
568
583
  }
569
- const name=match.check,status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,name)),
584
+ const name=match.check,wait=timeout??lastRunTimeout(config,parsed,name),status=await synchronizedStatus(entry,config,root,wait),
570
585
  reason=verdict(status,name,match.rest);
571
586
  tally(status,[name],Boolean(reason));
572
- if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',
573
- permissionDecision:'deny',permissionDecisionReason:reason}};
587
+ if(reason)return why(`${name} CURRENT/PASS`,{hookSpecificOutput:{hookEventName:'PreToolUse',
588
+ permissionDecision:'deny',permissionDecisionReason:reason}});
574
589
  // Rewrite only on a healthy answer for this check: without an observer,
575
590
  // `redue run` cannot record, so the command stays as Claude sent it. A chain
576
591
  // stays too: the rewrite returns "allow", which would also skip the prompt
@@ -579,9 +594,9 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
579
594
  status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath,invoke??redueInvocation(entry),match.wrapper):null;
580
595
  // updatedInput replaces the whole tool input. Deny and ask rules still apply
581
596
  // to the rewritten command; "allow" skips only the prompt.
582
- return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
597
+ return why(`${rewritten?'through redue run':'left as written'}: ${said(status,[name],wait)}`,rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
583
598
  permissionDecisionReason:`REDUE: check ${JSON.stringify(name)} is not CURRENT; running it through redue run so the result is recorded.`,
584
- updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
599
+ updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null);
585
600
  }
586
601
 
587
602
  export async function runClaudeHook(entry){
@@ -590,7 +605,12 @@ export async function runClaudeHook(entry){
590
605
  for await(const chunk of process.stdin){text+=chunk;if(text.length>1024*1024)return;}
591
606
  const input=JSON.parse(text);
592
607
  if(input?.hook_event_name==='SessionStart'){sessionStart(input,{entry});return;}
593
- const output=await claudeHook(input,{entry});
608
+ // REDUE_HOOK_LOG (an absolute file): one JSON line per decision, with why.
609
+ const log=process.env.REDUE_HOOK_LOG,started=Date.now();let why=null;
610
+ const output=await claudeHook(input,{entry,explain:text=>{why=text;}});
594
611
  if(output)await new Promise(resolve=>process.stdout.write(JSON.stringify(output)+'\n',resolve));
612
+ if(log&&path.isAbsolute(log))try{fs.appendFileSync(log,JSON.stringify({at:started,ms:Date.now()-started,cwd:input?.cwd??null,
613
+ command:input?.tool_input?.command??null,decision:!output?'allow':output.hookSpecificOutput?.updatedInput?'rewrite':output.hookSpecificOutput?.permissionDecision??'allow',
614
+ why})+'\n');}catch{}
595
615
  }catch{}
596
616
  }
package/src/cli.mjs CHANGED
@@ -8,7 +8,7 @@ import {patternMatcher} from './glob.mjs';
8
8
  import {observedBy} from './installed-observation.mjs';
9
9
  import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
10
10
  import {childProgram,generalIssue,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,pnpmNameless,toolOutput,toolchainDirs} from './node-script.mjs';
11
- import {denialLine,endWithChild,hiddenEntries,hiddenQualification,hiddenRefusal,isSandboxed,runToEnd,sandboxArgv,sandboxContext,sandboxDirs,sandboxEnv,
11
+ import {denialLine,docsQualification,endWithChild,hiddenDocs,hiddenEntries,hiddenQualification,hiddenRefusal,hidesIgnored,isSandboxed,runToEnd,supervisorIssue,visibleChange,testRun as sandboxTestRun,sandboxArgv,sandboxContext,sandboxDirs,sandboxEnv,
12
12
  sandboxInterpretation,sandboxInterpretationOf,sandboxPlaces,sandboxQualification,sandboxSupport,systemPlaces} from './sandbox.mjs';
13
13
  import path from 'node:path';
14
14
  import net from 'node:net';
@@ -331,12 +331,24 @@ async function execute() {
331
331
  if(support.issue)captureIssues.push(`the run was not sandboxed: ${support.issue}`);
332
332
  else{
333
333
  const dirs=sandboxDirs(state,name);
334
+ // A test run: loopback networking on Linux, supervised (the launcher
335
+ // reports in REDUE's state, outside what the test may touch), a
336
+ // private /tmp, and a fresh home each run: what a test leaves in its
337
+ // home is not observed, so it must not reach a later run.
338
+ const tests=sandboxTestRun(selected),stats=tests&&support.mode==='landlock'?path.join(state,`launcher-${runId}.json`):null;
339
+ if(tests){dirs.home=path.join(dirs.tmp,'home');fs.mkdirSync(dirs.home,{mode:0o700});}
334
340
  const reads=[root,dirs.home,dirs.tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
335
- // sandbox-v2 hides the checkout's git-ignored entries. Where git
336
- // cannot list them, nothing is hidden, and the receipt compares every file.
337
- const hidden=selected.qualification===hiddenQualification?hiddenEntries(root):null;
338
- sandbox={mode:support.mode,dirs,reads,hidden,argv:sandboxArgv(support.mode,command,{reads,tmp:dirs.tmp,python:support.python,
339
- hidden:(hidden||[]).map(rel=>path.join(root,rel.replace(/\/$/,'')))}),
341
+ // sandbox-v2 hides the checkout's git-ignored entries, and sandbox-v3
342
+ // its documentation too. Where git cannot list them, nothing is
343
+ // hidden, and the receipt compares every file.
344
+ const hidden=hidesIgnored(selected.qualification)?hiddenEntries(root):null;
345
+ const docs=hidden&&selected.qualification===docsQualification?hiddenDocs(root):null;
346
+ const options={reads,tmp:dirs.tmp,python:support.python,stats,
347
+ ...tests?{...support.mode==='landlock'?{network:'loopback'}:{},writes:[root,dirs.home,dirs.tmp],
348
+ fault:process.env.VSTATE_TEST_FAULTS==='1'?process.env.VSTATE_TEST_SUPERVISOR||null:null}:{}};
349
+ sandbox={mode:support.mode,dirs,reads,hidden,docs,options,stats,
350
+ argv:sandboxArgv(support.mode,command,{...options,hidden:(hidden||[]).map(rel=>path.join(root,rel.replace(/\/$/,''))),
351
+ ...docs?{docs:{root,entries:docs}}:{}}),
340
352
  env:sandboxEnv(selected,process.env,{root,...dirs})};
341
353
  }
342
354
  }
@@ -481,6 +493,18 @@ async function execute() {
481
493
  unexplained=touched.filter(file=>!ignored.has(file));
482
494
  if(!unexplained.length){eventsExplained=true;if(changed.length)outputs=changed;}
483
495
  }
496
+ // A run that hid git-ignored files: what it wrote beneath them (test
497
+ // output, coverage data) is not a change to its inputs (visibleChange);
498
+ // anything else is named.
499
+ let hiddenOnly=false;
500
+ if(sandbox?.hidden&&snap&&end&&snap.planId===end.planId&&snap.observationHealthy&&end.observationHealthy&&
501
+ (snap.inputEventSerial===end.inputEventSerial||end.inputEventPathsComplete===true)){
502
+ const before=snap.files||{},after=end.files||{},events=end.inputEventPaths||[];
503
+ // During the run a new file outside the hidden entries was readable: it counts.
504
+ const found=visibleChange(root,sandbox.hidden,before,after,[...Object.keys(before),...Object.keys(after),...events],
505
+ {during:true,docs:sandbox.docs});
506
+ if(found===null){eventsExplained=true;hiddenOnly=true;}else unexplained=[found];
507
+ }
484
508
  // The end certificate, recorded or probed, must cover what this compile received.
485
509
  // With no input event, gap or content change in between, it also holds at the start.
486
510
  // A run checked by full probes is compared with it too, for its run facts.
@@ -519,18 +543,18 @@ async function execute() {
519
543
  `: ${named.slice(0,5).join(', ')}${more>0?` and ${more} more`:''}`:'')+unrecorded);
520
544
  }
521
545
  // A recorded certificate changes only the probe part of the fingerprint.
522
- if(!outputs&&(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
546
+ if(!outputs&&!hiddenOnly&&(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
523
547
  snap.revision!==end.revision))captureIssues.push('declared inputs changed or were not observed');
524
548
  // A run that hid git-ignored files and failed with a refusal that names
525
549
  // one runs again in the sandbox without hiding them: the check needs
526
550
  // them. That run's result is kept, compared on every file, when the
527
551
  // inputs held through it too. Its own refusal is handled as below.
528
552
  let hiddenRerun=null;
529
- if(sandbox?.hidden?.length&&result==='FAIL'&&!requestedSignal){
530
- const line=hiddenRefusal(output.head+'\n'+output.tail,sandbox.hidden);
553
+ if((sandbox?.hidden?.length||sandbox?.docs?.length)&&result==='FAIL'&&!requestedSignal){
554
+ const line=hiddenRefusal(output.head+'\n'+output.tail,[...sandbox.hidden||[],...sandbox.docs||[]]);
531
555
  if(line){
532
- console.error(`redue: the sandboxed run failed with what looks like a refusal of a file git ignores (${line}); running it again in the sandbox without hiding them`);
533
- const again=await runToEnd(sandboxArgv(sandbox.mode,command,{reads:sandbox.reads,tmp:sandbox.dirs.tmp,python:support.python}),
556
+ console.error(`redue: the sandboxed run failed with what looks like a refusal of a hidden file (${line}); running it again in the sandbox without hiding files`);
557
+ const again=await runToEnd(sandboxArgv(sandbox.mode,command,sandbox.options),
534
558
  {cwd:plan.cwd,env:sandbox.env,echo:true,started:child=>{activeChild=child;}});
535
559
  activeChild=null;
536
560
  hiddenRerun={line,exitCode:again.status,signal:again.signal||null,...again.error?{errorCode:again.error.code||again.error.name}:{}};
@@ -556,7 +580,7 @@ async function execute() {
556
580
  invocation.hiddenExitCode=invocation.exitCode;invocation.exitCode=again.status;
557
581
  result=again.status===0?'PASS':'FAIL';
558
582
  output.head=`${again.stdout}\n${again.stderr}`.slice(0,65536);output.tail='';
559
- sandbox.hidden=null;
583
+ sandbox.hidden=null;sandbox.docs=null;
560
584
  }
561
585
  }
562
586
  }
@@ -581,11 +605,14 @@ async function execute() {
581
605
  else if(plain.error||plain.status===null)
582
606
  captureIssues.push(`the run outside the sandbox did not finish (${plain.error?.code||plain.signal}), so a refusal by the sandbox is not ruled out (${line})`);
583
607
  else if(plain.status!==invocation.exitCode){
584
- captureIssues.push(`the sandbox changed the result: exit ${invocation.exitCode} inside it, ${plain.status??plain.signal} outside it (${line})`);
608
+ captureIssues.push(`the sandbox changed the result: exit ${invocation.exitCode} inside it, ${plain.status??plain.signal} outside it (${line}); `+
609
+ 'if this repeats, run redue init again to choose a recipe for this check');
585
610
  invocation.sandboxedExitCode=invocation.exitCode;invocation.exitCode=plain.status;
586
611
  }
587
612
  }
588
613
  }
614
+ // A test run whose network was not supervised throughout is not reused.
615
+ if(sandbox?.stats){const issue=supervisorIssue(sandbox.stats);if(issue)captureIssues.push(issue);fs.rmSync(sandbox.stats,{force:true});}
589
616
  if(sandbox)fs.rmSync(sandbox.dirs.tmp,{recursive:true,force:true});
590
617
  const stable=captureIssues.length===0;
591
618
  // Two runs on the same inputs, environment and plan that disagree make the
@@ -608,7 +635,7 @@ async function execute() {
608
635
  // The recipe that made the receipt, which status and the hook name.
609
636
  // A sandboxed receipt names what the run did: hid the git-ignored files or not.
610
637
  const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,
611
- recipe:(fallback?scriptQualification:own?(sandbox?.hidden?hiddenQualification:sandboxQualification):selected.qualification)||null,
638
+ recipe:(fallback?scriptQualification:own?(sandbox?.docs?docsQualification:sandbox?.hidden?hiddenQualification:sandboxQualification):selected.qualification)||null,
612
639
  ...(recorded||testRun?{runFacts}:{}),
613
640
  ...(testRun?{verificationRecipe:general?scriptInterpretation:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
614
641
  environmentReads:guard?.environmentReads||{},...(nondeterministic?{nondeterministic}:{})}:{}),
@@ -623,8 +650,8 @@ async function execute() {
623
650
  provider:plan.provider,environmentHashes:environmentBefore,
624
651
  ...(observedContext?{observedContext,verificationRecipe:directInterpretation}:{}),
625
652
  // `hidden`: the git-ignored entries the run did not see, which the observer does not compare.
626
- ...(own?{verificationRecipe:sandbox?.hidden?sandboxInterpretationOf(hiddenQualification):sandboxInterpretation,
627
- sandbox:{mode:sandbox?.mode??null,hidden:sandbox?.hidden??null,...hiddenRerun?{hiddenRerun}:{},...sandboxRerun?{rerun:sandboxRerun}:{}}}:{}),
653
+ ...(own?{verificationRecipe:sandboxInterpretationOf(sandbox?.docs?docsQualification:sandbox?.hidden?hiddenQualification:sandboxQualification,selected.kind),
654
+ sandbox:{mode:sandbox?.mode??null,hidden:sandbox?.hidden??null,...sandbox?.docs?{docs:sandbox.docs}:{},...hiddenRerun?{hiddenRerun}:{},...sandboxRerun?{rerun:sandboxRerun}:{}}}:{}),
628
655
  ...(fallback?{sandbox:{mode:null,fallback:'node-script',issue:support.issue}}:{}),
629
656
  files:(outputs?end:snap)?.files??null,observerGeneration:before?.observation?.generation??null,
630
657
  startRevision:snap?.revision??null,endRevision:end?.revision??null};
@@ -718,7 +745,11 @@ async function main(){
718
745
  if(processAlive(pid)){await call({action:'stop'});
719
746
  for(let i=0;i<100&&fs.existsSync(path.join(state,'observer.lock'));i++)
720
747
  await new Promise(r=>setTimeout(r,100));
721
- if(fs.existsSync(path.join(state,'observer.lock')))throw Error('observer did not stop');}
748
+ if(fs.existsSync(path.join(state,'observer.lock')))throw Error('observer did not stop');
749
+ // It releases the lock before it exits. On Windows a file it still
750
+ // holds open stays listed after deletion, and removing the
751
+ // directory fails with ENOTEMPTY until the process is gone.
752
+ for(let i=0;i<100&&processAlive(pid);i++)await new Promise(r=>setTimeout(r,100));}
722
753
  }
723
754
  // A stopping observer can still be closing its files (Windows: ENOTEMPTY, EBUSY).
724
755
  fs.rmSync(state,{recursive:true,force:true,maxRetries:10,retryDelay:200});