@redue/cli 0.1.0-alpha.28 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.29 — 2026-11-03
4
+
5
+ Editing documentation no longer makes sandboxed checks STALE, a fast check
6
+ is skipped or recorded as it should be, and macOS test runs can no longer
7
+ reach a service started outside them.
8
+
9
+ - **Documentation is hidden too (`sandbox-v3`).** A sandboxed run now also
10
+ hides the project's documentation: files named `*.md`, `*.mdx`, `*.rst`,
11
+ `LICENSE*` or `CHANGELOG*` anywhere, and text, image and PDF files in the
12
+ root `docs/` folder (code there stays visible). The receipt names the
13
+ files the run hid, and a later edit to one of them leaves the result
14
+ CURRENT: editing the README no longer makes the tests STALE. A new
15
+ documentation file still counts. `init` tries this first, then the
16
+ git-ignored files only (`sandbox-v2`), then the whole checkout. A check
17
+ that reads documentation (a doctest over the README) keeps `sandbox-v2`.
18
+ Run `redue init` again to move an existing check to `sandbox-v3`.
19
+ - **A fast check is no longer missed by the hook.** The hook waited half of
20
+ the check's last run for `redue status`, so a 0.2 s mypy got 0.1 s, the
21
+ status answer came too late, and the agent's run went unrecorded. The
22
+ wait is now at least 2 s. `REDUE_HOOK_LOG` (an absolute file path) makes
23
+ the hook write one line per Bash command with its decision and why.
24
+ - **macOS test runs get no network.** Seatbelt cannot refuse only the ports
25
+ that were already listening (a per-port rule under a loopback allow is not
26
+ applied; an allow list of every other port does not compile), so a macOS
27
+ test run now gets Unix sockets in its own temporary directory only, as a
28
+ typecheck or lint does. A suite that needs loopback fails `init`'s trial
29
+ and runs without REDUE. Test checks set up by alpha.28 on macOS are not
30
+ CURRENT after the upgrade.
31
+ - **`redue status` reconciles by default.** `redue status --cached` gives the
32
+ observer's last published answer; `--sync` is still accepted. Like
33
+ `--sync`, plain `status` exits 2 when it cannot reach the observer (after
34
+ `redue stop`, for example); `--cached` exits 0.
35
+ - **`redue setup` says more and prints less.** It lists the checks it left
36
+ out and why (a kind it found nothing for, a proposed `tsc --noEmit` no
37
+ recipe takes), says what recording only means, and prints one line per
38
+ recorded run instead of the check's whole output.
39
+ - `redue remove-state` waits for the observer to exit before removing its
40
+ state (a Windows ENOTEMPTY).
41
+ - **Measured.** In the product census, repositories whose qualifying
42
+ sandboxed check keeps the documentation hidden, out of those with one:
43
+ TypeScript on Linux, typecheck 17 of 19, lint 20 of 20, tests 7 of 8; on
44
+ macOS 12 of 13, 14 of 16, 6 of 9. Python on Linux 15 of 15, 22 of 22, 12
45
+ of 17; on macOS 11 of 13, 17 of 17, 8 of 11. No network for macOS test
46
+ runs cost 1 TypeScript suite (it starts a server on 127.0.0.1) and 2
47
+ Python suites: test suites qualifying on macOS went from 12 to 11
48
+ (TypeScript) and 14 to 11 (Python; one checkout no longer installs). The
49
+ strace cross-check found no read outside the recorded inputs. In a Claude
50
+ Code follow-up on starlette and nestjs-api-gateway, a README edit made no
51
+ check STALE; the hook skipped mypy, pytest and the lint, and each skipped
52
+ command passed when run plainly.
53
+
3
54
  ## 0.1.0-alpha.28 — 2026-11-02
4
55
 
5
56
  Test suites can run in the sandbox, so REDUE can reuse a passing test run
package/README.md CHANGED
@@ -26,13 +26,17 @@ redue setup claude
26
26
  stops before writing anything if the dependencies are not installed. It:
27
27
 
28
28
  - finds your typecheck, lint and test scripts and writes `redue.config.json`
29
- (it never changes your scripts, source or dependencies). Checks REDUE can
30
- only record, not reuse, are listed as recording-only;
29
+ (it never changes your scripts, source or dependencies). In a Python
30
+ project it finds these tools in `.venv/bin`, with the arguments your CI
31
+ workflows, Makefile, justfile, tox, nox or scripts give them: mypy,
32
+ pyright and ty (typecheck), ruff (lint) and pytest (test). It says which
33
+ checks it left out and why, and which it can only record, not reuse;
31
34
  - connects Claude Code: a short section in `CLAUDE.md`, a skill, and two hooks
32
35
  in `.claude/settings.local.json`;
33
36
  - starts REDUE's file observer, waits while it indexes the project (a minute
34
37
  or more in a large one), then runs each check that can qualify once, so
35
- there is a first result to reuse;
38
+ there is a first result to reuse, and prints one line per run (the run's
39
+ own output only when it fails);
36
40
  - prints every file it writes. Running it again changes nothing.
37
41
 
38
42
  Then start a new Claude Code session in the project and work as usual. When
@@ -43,7 +47,7 @@ through REDUE so the new result is recorded.
43
47
  To look yourself:
44
48
 
45
49
  ```text
46
- $ redue status --sync
50
+ $ redue status
47
51
  ✓ typecheck CURRENT / PASS
48
52
  ✓ lint CURRENT / PASS
49
53
 
@@ -54,10 +58,10 @@ src/main.ts changed
54
58
  Due again when verification is needed: redue run "typecheck"
55
59
  ```
56
60
 
57
- `--sync` checks the files and your environment before it answers; this is
58
- what the agent asks. Plain `redue status` is a fast cached answer and can say
59
- UNVERIFIED ("cannot confirm your execution context") where `--sync` would say
60
- CURRENT.
61
+ `redue status` checks the files and your environment before it answers, as
62
+ the agent's hook does. `redue status --cached` gives the last answer the
63
+ observer published without checking; it can say UNVERIFIED ("cannot confirm
64
+ your execution context") where `redue status` would say CURRENT.
61
65
 
62
66
  `redue stats` shows, per check, the reruns the hook skipped, the time saved,
63
67
  the recorded runs and the top reasons a check was UNVERIFIED, from a log kept
@@ -87,27 +91,30 @@ packages.
87
91
  ## What it covers today
88
92
 
89
93
  In a census of 120 active public TypeScript repositories (November 2026, REDUE
90
- at commit a2401cc, before 0.1.0-alpha.28), each installed as its users would,
94
+ at commit 8cb32d6, before 0.1.0-alpha.29), each installed as its users would,
91
95
  with dependencies' install scripts on, 89 installed cleanly on a macOS runner.
92
96
  A check counts when two recorded runs on the same code both came out CURRENT
93
- on the same inputs. 38 repositories had at least one such check:
97
+ on the same inputs. 34 repositories had at least one such check:
94
98
 
95
99
  | Check | Repositories | How |
96
100
  |---|---|---|
97
- | Typecheck | 20 | 14 by running the project's script in an OS sandbox ([sandboxed recipe](docs/user/sandbox.md)), 13 of them with the files git ignores hidden; 5 by running the local `tsc` directly ([direct recipe](docs/user/direct-typescript.md)); 1 by running the script with every Node process recorded ([general recipe](docs/user/node-script.md)) |
98
- | Lint | 22 | 19 sandboxed recipe (18 with ignored files hidden), 3 general recipe |
99
- | Tests | 12 | 10 sandboxed recipe, with ignored files hidden and loopback-only network; 2 Vitest run directly ([direct test recipe](docs/user/tests-direct.md)) |
101
+ | Typecheck | 20 | 13 by running the project's script in an OS sandbox ([sandboxed recipe](docs/user/sandbox.md)), 12 of them with the files git ignores and the documentation hidden; 5 by running the local `tsc` directly ([direct recipe](docs/user/direct-typescript.md)); 2 by running the script with every Node process recorded ([general recipe](docs/user/node-script.md)) |
102
+ | Lint | 20 | 16 sandboxed recipe (14 with ignored files and documentation hidden, 1 with ignored files hidden), 4 general recipe |
103
+ | Tests | 11 | 9 sandboxed recipe with no network (6 with ignored files and documentation hidden, 3 with ignored files hidden); 2 Vitest run directly ([direct test recipe](docs/user/tests-direct.md)) |
100
104
 
101
- In alpha.27 the same census found 2 repositories with a qualifying test check
102
- on macOS and on Linux; now 12 on macOS and 11 on Linux.
105
+ alpha.28 found 38 repositories on macOS. Test runs there no longer get
106
+ loopback, which cost 1 test suite (it starts a server on 127.0.0.1). Of the
107
+ other 3, one lint check was lost to a documentation-hiding bug fixed before
108
+ this release, one checkout now installs with pnpm in a way REDUE does not
109
+ take, and one `init` did not answer within the census's 10 minutes. On
110
+ Linux, where tests keep loopback, 10 test suites qualified.
103
111
 
104
112
  In a census of 120 Python repositories, 74 installed on a Linux runner
105
113
  and 31 had a qualifying check: typecheck (mypy, pyright, ty) 15, lint (ruff)
106
- 22, tests (pytest) 17, all through the sandboxed recipe (14 of the test
107
- suites with ignored files hidden, 3 with the whole checkout). In alpha.27, no
108
- Python test suite qualified. On Linux, every sandboxed run was traced
109
- with strace: each file it read in the checkout was a recorded input, a cache
110
- or its own output.
114
+ 22, tests (pytest) 17, all through the sandboxed recipe (15, 22 and 12 of
115
+ them with ignored files and documentation hidden). On Linux, every sandboxed
116
+ run was traced with strace: each file it read in the checkout was a recorded
117
+ input, a cache or its own output.
111
118
 
112
119
  Everything else stays UNVERIFIED, with a reason you can read in
113
120
  `redue explain`. The usual ones: a script starts a program REDUE cannot see
@@ -163,7 +170,7 @@ harness, tasks and raw results are in [`eval/`](eval/README.md).
163
170
  redue init # write redue.config.json from your scripts
164
171
  redue start # start the file observer
165
172
  redue run typecheck # run a check through REDUE and record the result
166
- redue status --sync # what still holds
173
+ redue status # what still holds
167
174
  ```
168
175
 
169
176
  Codex is supported too (`redue agent setup codex --apply`); see
package/bin/redue.mjs CHANGED
@@ -7,7 +7,7 @@ import {directCompiler,directQualification,directEnvironment,directInstalledInpu
7
7
  import {runArgv,tscRun} from '../src/typescript-invocation.mjs';
8
8
  import {testEnvironment,testRunnerAt,testsInterpretation,testsQualification} from '../src/test-direct.mjs';
9
9
  import {scriptEnvironment,scriptInterpretation,scriptKinds,scriptQualification} from '../src/node-script.mjs';
10
- import {hiddenListIssue,hiddenQualification,isSandboxed,pythonTool,sandboxEnvironment,sandboxInterpretationOf,sandboxKinds,sandboxQualification} from '../src/sandbox.mjs';
10
+ import {docsQualification,hiddenDocsIssue,hiddenListIssue,hiddenQualification,isSandboxed,pythonTool,sandboxEnvironment,sandboxInterpretationOf,sandboxKinds,sandboxQualification} from '../src/sandbox.mjs';
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import {createHash} from 'node:crypto';
@@ -40,8 +40,8 @@ Commands:
40
40
  Connect project instructions; explicit setup only
41
41
  start Start the local observer
42
42
  stop Stop this observer
43
- status [--json|--short] Read conservative cached status
44
- status --sync Reconcile inputs and caller context before answering
43
+ status [--json] Reconcile inputs and caller context, then answer (--sync is the same)
44
+ status --cached [--json] | --short The last answer the observer published, without reconciling
45
45
  explain [CHECK] [--json] Explain applicability (synchronized; may cost more than a rerun)
46
46
  detail [CHECK] [--json] Alias for explain
47
47
  run CHECK [--if-due] Execute one configured check and record its outcome; with --if-due,
@@ -102,9 +102,12 @@ const json=args.includes('--json');
102
102
  // run --if-due: run the check only when its last pass does not apply now.
103
103
  const ifDue=command==='run'&&args.includes('--if-due');
104
104
  if(ifDue)args.splice(args.indexOf('--if-due'),1);
105
- const sync=args.includes('--sync');
105
+ // status syncs by default (warm, it costs well under 100 ms more than the
106
+ // cached answer); --cached and --short read the cached answer.
107
+ const cachedOnly=args.includes('--cached');
106
108
  const dryRun=args.includes('--dry-run');
107
109
  const short=args.includes('--short');
110
+ const sync=args.includes('--sync')||command==='status'&&!cachedOnly&&!short;
108
111
  let workspaceSelection=null,checkSelection=null,recipeSelection=null;
109
112
  if(command==='init')for(let i=0;i<args.length;){
110
113
  if(args[i]==='--workspace'||args[i]==='--check'||args[i]==='--recipe'){
@@ -115,9 +118,10 @@ if(command==='init')for(let i=0;i<args.length;){
115
118
  }
116
119
  const check=['run','detail','explain'].includes(command)&&args[0]&&!args[0].startsWith('--')?args.shift():null;
117
120
  if(command==='run'&&!check)error('run requires a check name');
118
- if(args.some(value=>!['--json','--sync','--dry-run','--short'].includes(value)))error('unknown option; use --help');
121
+ if(args.some(value=>!['--json','--sync','--cached','--dry-run','--short'].includes(value)))error('unknown option; use --help');
119
122
  if(sync&&!['status','detail','explain'].includes(command))error('--sync is for status or detail');
120
- if(short&&(command!=='status'||json||sync))error('--short is a cached status option; do not combine it with --sync or --json');
123
+ if(cachedOnly&&(command!=='status'||args.includes('--sync')))error('--cached is a status option; do not combine it with --sync');
124
+ if(short&&(command!=='status'||json||args.includes('--sync')))error('--short is a cached status option; do not combine it with --sync or --json');
121
125
  if(dryRun&&command!=='init')error('--dry-run is for init');
122
126
 
123
127
  if(command==='init'){
@@ -153,6 +157,7 @@ if(command==='init'){
153
157
  }
154
158
  // A sandboxed check is proposed only when it gives the same exit status and
155
159
  // output inside the sandbox as outside it: first with the checkout's
160
+ // git-ignored files and documentation hidden (sandbox-v3), then with the
156
161
  // git-ignored files hidden (sandbox-v2), else with the whole checkout
157
162
  // (sandbox-v1), reusing the plain run. Otherwise init writes what it would
158
163
  // have without the sandboxed recipe, with the reason.
@@ -165,11 +170,18 @@ if(command==='init'){
165
170
  for(const [at,row] of [...discovery.checks.entries()]){
166
171
  if(row.qualification!==sandboxQualification||row.level!=='ready')continue;
167
172
  if(!json)console.error(`Running ${row.config.name} inside and outside the sandbox to confirm it...`);
168
- const hidden=await sandboxTrial(discovery.root,row.config,{hide:true});
169
- if(!hidden.issue)trials++;
173
+ const docs=await sandboxTrial(discovery.root,row.config,{hide:'docs'});
174
+ if(!docs.issue)trials++;
175
+ if(!docs.issue&&docs.same){
176
+ row.config={...row.config,qualification:docsQualification};row.qualification=docsQualification;
177
+ row.reason=row.reason.replace('the whole checkout is its input',"the checkout's files git doesn't ignore, except its documentation, are its input")+`; ran both ways at init, exit ${docs.plain}`;continue;}
178
+ // Without a list of the documentation, the git-ignored files are still tried.
179
+ const hidden=docs.plainRun?await sandboxTrial(discovery.root,row.config,{hide:true,plain:docs.plainRun}):
180
+ docs.issue===hiddenDocsIssue?await sandboxTrial(discovery.root,row.config,{hide:true}):docs;
170
181
  if(!hidden.issue&&hidden.same){
171
182
  row.config={...row.config,qualification:hiddenQualification};row.qualification=hiddenQualification;
172
- row.reason=row.reason.replace('the whole checkout is its input',"the checkout's files git doesn't ignore are its input")+`; ran both ways at init, exit ${hidden.plain}`;continue;}
183
+ row.reason=row.reason.replace('the whole checkout is its input',"the checkout's files git doesn't ignore are its input")+
184
+ `; ran both ways at init, exit ${hidden.plain}; it needs the documentation (${trialIssue(docs)})`;continue;}
173
185
  // Without git, nothing is hidden: the whole checkout is tried on its own.
174
186
  const trial=hidden.plainRun?await sandboxTrial(discovery.root,row.config,{plain:hidden.plainRun}):
175
187
  hidden.issue===hiddenListIssue?await sandboxTrial(discovery.root,row.config):hidden;
@@ -222,7 +234,7 @@ if(command==='init'){
222
234
  console.log('Ready to qualify means the supported contract will be validated; it is not verification evidence.');
223
235
  console.log(`Next: review the config, then redue start and redue run ${JSON.stringify(discovery.checks[0].config.name)}.`);
224
236
  console.log('Connect your agent: redue agent setup codex (or claude). Preview with --dry-run.');
225
- console.log('Use redue status first. --sync can establish applicability, but may cost more than rerunning a cheap check.');}
237
+ console.log('Then redue status says whether each result applies to the files as they are now.');}
226
238
  } else if(unsupported&&!json)console.log(`No config written: ${summary.issue||'unsupported discovery boundary'}.`);
227
239
  if(json)console.log(JSON.stringify({...summary,proposed_config:discovery.config}));
228
240
  await exit(0);
@@ -408,7 +420,7 @@ const checks=publicConfig.checks.map(check=>{
408
420
  ...(npmDirect?{qualificationInterpretation:directInterpretation}:{}),
409
421
  ...(testsDirect?{qualificationInterpretation:testsInterpretation}:{}),
410
422
  ...(generalScript?{qualificationInterpretation:scriptInterpretation}:{}),
411
- ...(sandboxed?{qualificationInterpretation:sandboxInterpretationOf(check.qualification)}:{}),
423
+ ...(sandboxed?{qualificationInterpretation:sandboxInterpretationOf(check.qualification,check.kind)}:{}),
412
424
  workspace:check.workspace||null,kind:check.kind||null};
413
425
  });
414
426
  const runtime={schema:1,provider:'declared-project@1',root,state,checks};
@@ -12,8 +12,8 @@ generic agent's instructions with the project owner's permission.
12
12
  `redue --config CONFIG status --sync --json`. Do not infer a recipe from its name.
13
13
  Cached status cannot establish its caller context. In mixed configurations,
14
14
  assess each check's contract separately; this is not a global sync-first policy.
15
- 3. For other/unknown recipes, query ordinary `status --json` first and decide whether
16
- synchronization is worthwhile. Ordinary status and `--short` remain cheap inspection.
15
+ 3. For other/unknown recipes, `status --json` synchronizes too (`--sync` is the
16
+ same); `status --cached --json` and `--short` remain cheap inspection.
17
17
  4. Reuse only schema 1, healthy observation and the selected check's CURRENT/PASS
18
18
  with `reuse_eligible: true`. Missing eligibility means false. A historical FAIL
19
19
  is never green evidence. Do not rerun eligible evidence merely for freshness.
@@ -167,11 +167,18 @@ commands never start the hook. The installed skill lists these exact commands,
167
167
  so the agent uses forms the hook recognizes.
168
168
 
169
169
  On a match, the hook runs `redue status --sync --json`. It waits up to half the
170
- duration of the check's last completed run, at most 10 seconds, so waiting never
171
- costs more than half a run. With no completed run it waits 2 seconds. A check
172
- that takes a few hundred milliseconds is therefore rarely skipped; skipping it
173
- would save almost nothing, and the hook does not rewrite it to `redue run`
174
- either. Claude Code's own limit for the hook is 15 seconds.
170
+ duration of the check's last completed run, at least 2 seconds and at most 10
171
+ seconds. A status call usually takes 70-250 ms, so a fast check (mypy in 0.2 s)
172
+ is still skipped when CURRENT, or run through `redue run` and recorded when not.
173
+ When the observer is slow or stuck, a matching command can wait the full 2
174
+ seconds before it runs as written. Claude Code's own limit for the hook is 15
175
+ seconds.
176
+
177
+ To see why the hook decided what it did, set `REDUE_HOOK_LOG` to an absolute
178
+ file path in the environment Claude Code starts with. The hook appends one JSON
179
+ line per Bash command: the command, the decision (`deny`, `rewrite` or
180
+ `allow`) and why, such as `no check matches the command` or `status did not
181
+ answer within 2000 ms`.
175
182
  It blocks the command only when the check is CURRENT, PASS and
176
183
  `reuse_eligible: true`. The message names the check and the receipt's run ID. Every
177
184
  other result allows the command: STALE, UNVERIFIED, FAILED, a timeout, an error, or
@@ -117,9 +117,10 @@ compiler invocation it selects, and for npm the
117
117
  run through the package manager. No existing configuration is silently migrated.
118
118
  Only use configs and probes from trusted repositories: these execute local code.
119
119
 
120
- `status` is cached and conservative. `status --sync` and `explain` revalidate caller
121
- context and may execute read-only probes. Synchronization can be slower than a cheap
122
- check, especially on Windows. Choose deliberately. A timeout is UNVERIFIED, not green.
120
+ `status` and `explain` revalidate caller context and may execute read-only probes
121
+ (`status --sync` is the same as `status`). `status --cached` is the cached,
122
+ conservative answer, without revalidating. Synchronization can be slower than a
123
+ cheap check, especially on Windows. A timeout is UNVERIFIED, not green.
123
124
 
124
125
  ## Lifecycle and removal
125
126
 
package/docs/user/json.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Alpha JSON interface (schema 1)
2
2
 
3
- Use `status --json` for cached state, `status --sync --json` for caller-aware
4
- applicability, and `explain CHECK --json` for selected synchronized detail.
3
+ Use `status --json` (or `status --sync --json`) for caller-aware applicability,
4
+ `status --cached --json` for cached state, and `explain CHECK --json` for selected synchronized detail.
5
5
  `detail` remains an alias. Successful reads emit one JSON object, no human prose.
6
6
 
7
7
  ```json
@@ -8,14 +8,15 @@ oxlint and tsgolint, biome, turbo, and Python tools such as mypy, pyright, ty
8
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
 
@@ -51,14 +52,15 @@ redue status --sync
51
52
  A Python check in the config:
52
53
 
53
54
  ```json
54
- {"name": "typecheck", "kind": "typecheck", "qualification": "sandbox-v2",
55
+ {"name": "typecheck", "kind": "typecheck", "qualification": "sandbox-v3",
55
56
  "command": [".venv/bin/mypy", "src"], "inputs": ["**", "..."], "installedInputs": [".venv/**", "..."]}
56
57
  ```
57
58
 
58
59
  A Node check names its script, as the general recipe's do:
59
- `{"name": "lint", "script": "lint", "kind": "lint", "qualification": "sandbox-v2", ...}`.
60
- `sandbox-v2` hides the files git ignores from the run; `sandbox-v1` runs it on
61
- 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.
62
64
 
63
65
  With the Claude Code hook, `uv run mypy src`, `poetry run mypy src`,
64
66
  `python -m mypy src`, `.venv/bin/mypy src` and `mypy src` are recognized as
@@ -66,12 +68,31 @@ that check. The last two count only when PATH finds the tool or Python in the
66
68
  project's `.venv/bin` first. When the hook runs the check through REDUE, it
67
69
  keeps `uv run` or `poetry run` in front, so the environment still syncs first.
68
70
 
69
- ## Files git ignores
70
-
71
- `init` runs each check three ways at most: plainly, sandboxed with the files
72
- git ignores hidden, and, when those two differ, sandboxed with the whole
73
- checkout (reusing the plain run). The first sandboxed way that gives the same
74
- 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.
75
96
 
76
97
  With `sandbox-v2`, each run hides what `git ls-files --others --ignored
77
98
  --exclude-standard --directory` lists, except `node_modules`, `.venv` and
@@ -80,18 +101,19 @@ Only the project's rules count: its `.gitignore` files and
80
101
  `.git/info/exclude`, not your global excludes file. Those files are not
81
102
  inputs, so a build or test run that writes them leaves the result CURRENT.
82
103
 
83
- When a `sandbox-v2` run fails with a refusal that names a hidden file (a
84
- missing file counts only when it names one, since Linux tools report an
85
- unreadable file as missing), REDUE runs the check again in the sandbox
86
- without hiding anything. That result is kept and compared on every file,
87
- git-ignored ones included. Run `redue init` again to write `sandbox-v1` for a
88
- 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.
89
110
 
90
111
  ## What the run sees
91
112
 
92
113
  It may read and execute only beneath:
93
114
 
94
- - 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`;
95
117
  - the toolchain: Node's installation and the package managers' (with
96
118
  Corepack's downloads), or the Python installation the `.venv` was made from;
97
119
  - system directories (`/usr`, `/bin`, `/etc` and the like on Linux; `/usr`,
@@ -99,7 +121,7 @@ It may read and execute only beneath:
99
121
  - a private home directory and a fresh temporary directory, in REDUE's state.
100
122
 
101
123
  A typecheck or lint has no network: no TCP connection, loopback included
102
- (a test run gets loopback; see [tests](#tests)). On macOS its only
124
+ (a test run on Linux gets loopback; see [tests](#tests)). On macOS its only
103
125
  Unix sockets are in its own temporary directory, which is new for each run.
104
126
  On Linux 6.12 and later it cannot reach an abstract Unix socket outside the
105
127
  run.
@@ -131,11 +153,10 @@ It gets a controlled environment, not yours:
131
153
 
132
154
  A test run gets what a typecheck or lint gets, and also:
133
155
 
134
- - **Loopback, and no remote host.** It may listen on any address and accept
135
- connections, and connect only to the local host (`127.0.0.0/8`, `::1`,
136
- `0.0.0.0`, `::`) and Unix sockets.
137
- - On macOS, Seatbelt enforces that.
138
- - On Linux, Landlock cannot tell a loopback address from a remote one, so a
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
139
160
  seccomp filter in the test's processes does instead. It refuses sockets
140
161
  other than Unix, netlink and TCP when they are created (UDP included),
141
162
  TCP Fast Open (`MSG_FASTOPEN`) and `io_uring`. It hands each `connect` to
@@ -147,6 +168,16 @@ A test run gets what a typecheck or lint gets, and also:
147
168
  launcher, so worker processes talk to each other as usual. If the
148
169
  launcher's supervisor cannot start, or stops before the run ends, the
149
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.
150
181
  - **A fresh home.** Its home directory is new and empty for each run, as on
151
182
  CI: what a test leaves there is not observed, so it must not reach a later
152
183
  run.
@@ -166,7 +197,8 @@ folders, or new files git ignores, are not changes to its inputs.
166
197
 
167
198
  CURRENT means the last sandboxed run's inputs are unchanged:
168
199
 
169
- - 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
170
202
  in it, git-ignored files and git-ignored links to files in it included
171
203
  (`sandbox-v1`), except `.git` and runner caches (the general recipe's, plus
172
204
  `__pycache__`, `.mypy_cache`, `.ruff_cache` and `.pytest_cache`);
@@ -192,9 +224,9 @@ every failure twice to find out:
192
224
  timings, the temporary and home directories, and lines about caches.
193
225
  Otherwise `init` writes what it would have without this recipe and says why.
194
226
  These are the only runs `init` makes.
195
- - 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
196
228
  runs again in the sandbox without hiding (see
197
- [files git ignores](#files-git-ignores)).
229
+ [files git ignores](#files-git-ignores-and-documentation)).
198
230
  - When a sandboxed run fails and its output has a line that looks like a
199
231
  refusal ("Operation not permitted", "Permission denied", "Read-only file
200
232
  system", a name lookup or a connection that failed), REDUE runs the check
@@ -238,28 +270,24 @@ it with `redue remove-state`.
238
270
  nested example project's).
239
271
  - A script that changes files (`--fix`, `--write`) does not get this recipe:
240
272
  `init` would run it twice.
241
- - With `sandbox-v2`, a run cannot read a hidden file, but it can still see
273
+ - With `sandbox-v2` or `sandbox-v3`, a run cannot read a hidden file, but it can still see
242
274
  that one exists, its size and its times, and write beneath it. On Linux it
243
275
  can also list a hidden folder; on macOS it cannot. A tool that acts on
244
276
  whether a git-ignored file exists, without reading it, is not covered: a
245
277
  new or removed one does not make the result STALE. In a census of 136
246
278
  test suites with ignored files after their build, none did.
247
279
  - On Linux, a test run's UDP is refused, loopback included: the filter
248
- decides when the socket is made, before its address is known. On macOS UDP
249
- to loopback works. A test that needs it fails its trial and keeps another
250
- recipe.
251
- - On macOS, a test run can connect to a service on loopback that it did not
252
- start (a local database on `127.0.0.1:5432`), and that service's state is
253
- not observed: a change to it does not make the result STALE. On Linux the
254
- launcher refuses it.
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.
255
282
  - On Linux, the launcher decides a `connect` from the address it reads in
256
283
  the process's memory, and the kernel reads it again after. A process that
257
284
  changes the address in between, from another thread, could reach another
258
285
  host. Honest test code does not.
259
286
  - On Linux, a typecheck or lint can still connect to a Unix socket file
260
287
  outside it (a local database's).
261
- - On Linux, a file a `sandbox-v2` run creates in a folder that holds hidden
262
- files (the checkout's root, when anything in it is hidden) cannot be read
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
263
291
  back in the same run. `init`'s trial and the rerun without hiding catch a
264
292
  check that needs to.
265
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
@@ -21,8 +21,10 @@ 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
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; a test run also gets loopback-only network and a private `/tmp`),
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),
26
28
  then, for typecheck and lint, the [general recipe](node-script.md): the script run
27
29
  through the package manager with every Node process recorded and the whole
28
30
  checkout as its input. Python projects get the sandboxed recipe for mypy,
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.28",
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','test'].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,supervisorIssue,visibleChange,testRun as sandboxTestRun,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,21 +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, supervised on Linux (the launcher
334
+ // A test run: loopback networking on Linux, supervised (the launcher
335
335
  // reports in REDUE's state, outside what the test may touch), a
336
336
  // private /tmp, and a fresh home each run: what a test leaves in its
337
337
  // home is not observed, so it must not reach a later run.
338
338
  const tests=sandboxTestRun(selected),stats=tests&&support.mode==='landlock'?path.join(state,`launcher-${runId}.json`):null;
339
339
  if(tests){dirs.home=path.join(dirs.tmp,'home');fs.mkdirSync(dirs.home,{mode:0o700});}
340
340
  const reads=[root,dirs.home,dirs.tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
341
- // sandbox-v2 hides the checkout's git-ignored entries. Where git
342
- // cannot list them, nothing is hidden, and the receipt compares every file.
343
- const hidden=selected.qualification===hiddenQualification?hiddenEntries(root):null;
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;
344
346
  const options={reads,tmp:dirs.tmp,python:support.python,stats,
345
- ...tests?{network:'loopback',writes:[root,dirs.home,dirs.tmp],
347
+ ...tests?{...support.mode==='landlock'?{network:'loopback'}:{},writes:[root,dirs.home,dirs.tmp],
346
348
  fault:process.env.VSTATE_TEST_FAULTS==='1'?process.env.VSTATE_TEST_SUPERVISOR||null:null}:{}};
347
- sandbox={mode:support.mode,dirs,reads,hidden,options,stats,
348
- argv:sandboxArgv(support.mode,command,{...options,hidden:(hidden||[]).map(rel=>path.join(root,rel.replace(/\/$/,'')))}),
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}}:{}}),
349
352
  env:sandboxEnv(selected,process.env,{root,...dirs})};
350
353
  }
351
354
  }
@@ -498,7 +501,8 @@ async function execute() {
498
501
  (snap.inputEventSerial===end.inputEventSerial||end.inputEventPathsComplete===true)){
499
502
  const before=snap.files||{},after=end.files||{},events=end.inputEventPaths||[];
500
503
  // During the run a new file outside the hidden entries was readable: it counts.
501
- const found=visibleChange(root,sandbox.hidden,before,after,[...Object.keys(before),...Object.keys(after),...events],{during:true});
504
+ const found=visibleChange(root,sandbox.hidden,before,after,[...Object.keys(before),...Object.keys(after),...events],
505
+ {during:true,docs:sandbox.docs});
502
506
  if(found===null){eventsExplained=true;hiddenOnly=true;}else unexplained=[found];
503
507
  }
504
508
  // The end certificate, recorded or probed, must cover what this compile received.
@@ -546,10 +550,10 @@ async function execute() {
546
550
  // them. That run's result is kept, compared on every file, when the
547
551
  // inputs held through it too. Its own refusal is handled as below.
548
552
  let hiddenRerun=null;
549
- if(sandbox?.hidden?.length&&result==='FAIL'&&!requestedSignal){
550
- 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||[]]);
551
555
  if(line){
552
- 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`);
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`);
553
557
  const again=await runToEnd(sandboxArgv(sandbox.mode,command,sandbox.options),
554
558
  {cwd:plan.cwd,env:sandbox.env,echo:true,started:child=>{activeChild=child;}});
555
559
  activeChild=null;
@@ -576,7 +580,7 @@ async function execute() {
576
580
  invocation.hiddenExitCode=invocation.exitCode;invocation.exitCode=again.status;
577
581
  result=again.status===0?'PASS':'FAIL';
578
582
  output.head=`${again.stdout}\n${again.stderr}`.slice(0,65536);output.tail='';
579
- sandbox.hidden=null;
583
+ sandbox.hidden=null;sandbox.docs=null;
580
584
  }
581
585
  }
582
586
  }
@@ -601,7 +605,8 @@ async function execute() {
601
605
  else if(plain.error||plain.status===null)
602
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})`);
603
607
  else if(plain.status!==invocation.exitCode){
604
- 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');
605
610
  invocation.sandboxedExitCode=invocation.exitCode;invocation.exitCode=plain.status;
606
611
  }
607
612
  }
@@ -630,7 +635,7 @@ async function execute() {
630
635
  // The recipe that made the receipt, which status and the hook name.
631
636
  // A sandboxed receipt names what the run did: hid the git-ignored files or not.
632
637
  const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,
633
- 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,
634
639
  ...(recorded||testRun?{runFacts}:{}),
635
640
  ...(testRun?{verificationRecipe:general?scriptInterpretation:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
636
641
  environmentReads:guard?.environmentReads||{},...(nondeterministic?{nondeterministic}:{})}:{}),
@@ -645,8 +650,8 @@ async function execute() {
645
650
  provider:plan.provider,environmentHashes:environmentBefore,
646
651
  ...(observedContext?{observedContext,verificationRecipe:directInterpretation}:{}),
647
652
  // `hidden`: the git-ignored entries the run did not see, which the observer does not compare.
648
- ...(own?{verificationRecipe:sandbox?.hidden?sandboxInterpretationOf(hiddenQualification):sandboxInterpretation,
649
- 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}:{}}}:{}),
650
655
  ...(fallback?{sandbox:{mode:null,fallback:'node-script',issue:support.issue}}:{}),
651
656
  files:(outputs?end:snap)?.files??null,observerGeneration:before?.observation?.generation??null,
652
657
  startRevision:snap?.revision??null,endRevision:end?.revision??null};
@@ -740,7 +745,11 @@ async function main(){
740
745
  if(processAlive(pid)){await call({action:'stop'});
741
746
  for(let i=0;i<100&&fs.existsSync(path.join(state,'observer.lock'));i++)
742
747
  await new Promise(r=>setTimeout(r,100));
743
- 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));}
744
753
  }
745
754
  // A stopping observer can still be closing its files (Windows: ENOTEMPTY, EBUSY).
746
755
  fs.rmSync(state,{recursive:true,force:true,maxRetries:10,retryDelay:200});
package/src/daemon.mjs CHANGED
@@ -348,14 +348,16 @@ function describeChangedInput(plan,file) {
348
348
  .map(mapping=>mapping.logical).slice(0,2);
349
349
  return file+' changed'+(matches.length?` (installed via ${matches.join(', ')})`:'');
350
350
  }
351
- // A run that hid the checkout's git-ignored entries (sandbox@2): the first
351
+ // A run that hid the checkout's git-ignored entries (sandbox@2), and maybe
352
+ // documentation files (sandbox@3, the receipt's sandbox.docs): the first
352
353
  // changed input it could see (sandbox.mjs's visibleChange), or null; once
353
354
  // per receipt and pair of fingerprints.
354
355
  const hiddenRun=r=>Array.isArray(r?.sandbox?.hidden)&&Boolean(r.files);
355
356
  function visibleChange(row,r,name) {
356
357
  const key=(r.invocation?.runId||'')+'\0'+r.fingerprint+'\0'+combinedSummary(name).fingerprint;
357
358
  if(row.visibleKey!==key){const current=combinedFiles(name);
358
- row.visibleKey=key;row.visibleChange=sandboxVisibleChange(root,r.sandbox.hidden,r.files,current,[...Object.keys(r.files),...Object.keys(current)]);}
359
+ row.visibleKey=key;row.visibleChange=sandboxVisibleChange(root,r.sandbox.hidden,r.files,current,[...Object.keys(r.files),...Object.keys(current)],
360
+ {docs:Array.isArray(r.sandbox.docs)?r.sandbox.docs:null});}
359
361
  return row.visibleChange;
360
362
  }
361
363
  function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=false) {
@@ -323,7 +323,9 @@ export function discoverNodeProject(root,{recipe=null}={}) {
323
323
  }catch(e){classification={...classification,reason:`Sandboxed recipe unavailable (${e.code?.replace(/^test-/,'')||e.message}); ${classification.reason}`};}
324
324
  }
325
325
  if(generalRow){checks.push(generalRow);continue;}
326
- if(!script&&classification.level!=='ready')continue;
326
+ // A proposed `tsc --noEmit` that no recipe takes is left out, with why.
327
+ if(!script&&classification.level!=='ready'){
328
+ skipped.push({kind,reason:`${kind}: no typecheck script, and a proposed tsc --noEmit was not taken (${classification.reason})`});continue;}
327
329
  if(parts&&classification.level==='ready'){
328
330
  for(const part of parts)checks.push({config:{...check,...part,qualification:classification.qualification},...classification});
329
331
  continue;
@@ -3,7 +3,7 @@
3
3
  const recipes={'npm-typescript-direct-v1':'direct TypeScript recipe','npm-tests-direct-v1':'direct test recipe',
4
4
  'node-script-v1':'general recipe','pnpm-tsc-v1':'pnpm TypeScript recipe','yarn-workspace-tsc-v1':'Yarn workspace TypeScript recipe',
5
5
  'typescript-noemit-v1':'npm TypeScript recipe','sandbox-v1':'sandboxed recipe',
6
- 'sandbox-v2':'sandboxed recipe, git-ignored files hidden'};
6
+ 'sandbox-v2':'sandboxed recipe, git-ignored files hidden','sandbox-v3':'sandboxed recipe, git-ignored files and docs hidden'};
7
7
  export const recipeLabel=recipe=>recipes[recipe]??null;
8
8
  export function checkLabel(row) {
9
9
  return row.result==='FAIL'?'FAILED':row.freshness;
@@ -12,8 +12,8 @@ export function explanation(row) {
12
12
  const reason=row.reason||'No verification evidence is available.';
13
13
  if(reason==='no receipt')return 'No execution has been recorded. Run this check when verification is needed.';
14
14
  if(reason==='declared inputs match')return 'Recorded evidence still applies to the declared inputs.';
15
- if(reason==='execution environment context not supplied')return 'Cached status cannot confirm your execution context. Use --sync if worthwhile, or rerun the check.';
16
- if(/requires? a synchronized content read/.test(reason))return 'Cached status cannot establish applicability here. Use --sync if worthwhile, or rerun the check.';
15
+ if(reason==='execution environment context not supplied')return 'Cached status cannot confirm your execution context. Run redue status without --cached, or rerun the check.';
16
+ if(/requires? a synchronized content read/.test(reason))return 'Cached status cannot establish applicability here. Run redue status without --cached, or rerun the check.';
17
17
  if(reason.startsWith('input coverage unresolved: '))return 'Recording-only: '+reason.slice(27);
18
18
  if(/input plan (rebuilding|indexing)|observer initialization|input observation reconciliation pending/.test(reason))
19
19
  return 'Checking inputs after startup or changes. Wait for observation to finish, then query again.';
@@ -56,6 +56,6 @@ export function renderRun(value) {
56
56
  if(value.invocation!=='exited')lines.push(`Invocation ${value.invocation}; no reusable passing evidence.`);
57
57
  else if(!value.coverage_qualified)lines.push('Applicability remains UNVERIFIED: input coverage is incomplete.');
58
58
  else if(!value.stable)lines.push('Applicability remains UNVERIFIED: inputs changed or observation was unavailable during execution.');
59
- else lines.push('Inspect redue status before reusing this evidence. Cached status may require --sync to establish applicability.');
59
+ else lines.push('Check redue status before reusing this evidence.');
60
60
  return lines.join('\n');
61
61
  }
package/src/sandbox.mjs CHANGED
@@ -29,11 +29,36 @@ export const sandboxInterpretation='sandbox@1';
29
29
  export const hiddenQualification='sandbox-v2';
30
30
  export const hiddenInterpretation='sandbox@2';
31
31
  export const hiddenListIssue='git cannot list the ignored files';
32
- export const isSandboxed=qualification=>qualification===sandboxQualification||qualification===hiddenQualification;
33
- export const sandboxInterpretationOf=qualification=>qualification===hiddenQualification?hiddenInterpretation:sandboxInterpretation;
32
+ // And the project's documentation hidden too (docPatterns): an agent edits it
33
+ // often, and a typecheck, lint or test suite seldom reads it.
34
+ export const docsQualification='sandbox-v3';
35
+ export const docsInterpretation='sandbox@3';
36
+ // Under docs/, only documentation files: code there (examples a typecheck
37
+ // includes) stays visible.
38
+ const docsFolderTypes=['txt','adoc','png','jpg','jpeg','gif','svg','webp','pdf'];
39
+ export const docPatterns=['**/*.md','**/*.mdx','**/*.rst','**/LICENSE*','**/CHANGELOG*',...docsFolderTypes.map(type=>`docs/**/*.${type}`)];
40
+ export const hiddenDocsIssue='git cannot list the documentation';
41
+ // What macOS refuses by path pattern: the extensions, in any case (Seatbelt
42
+ // matches regexes without regard to case). Every other listed file
43
+ // (LICENSE*, CHANGELOG*) it refuses by its path; a pattern would refuse
44
+ // license-check.ts too.
45
+ const docsByPattern=rel=>/^(.+\/)?[^/]*\.(md|mdx|rst)$/i.test(rel)||new RegExp(`^docs/(.+/)?[^/]*\\.(${docsFolderTypes.join('|')})$`,'i').test(rel);
46
+ // One path each makes the profile larger and slower to compile (1,000 add
47
+ // about 190 ms to a run; a few thousand do not compile). Past this many,
48
+ // those files stay visible and count as inputs, on every platform.
49
+ export const namedDocsLimit=100;
50
+ export const isSandboxed=qualification=>[sandboxQualification,hiddenQualification,docsQualification].includes(qualification);
51
+ // Whether the recipe hides the checkout's git-ignored entries (v2 and v3).
52
+ export const hidesIgnored=qualification=>qualification===hiddenQualification||qualification===docsQualification;
53
+ // A macOS test run has had no TCP since alpha.29: its own interpretation, so a
54
+ // result recorded with loopback is not the same plan.
55
+ export const sandboxInterpretationOf=(qualification,kind=null,platform=process.platform)=>
56
+ (qualification===docsQualification?docsInterpretation:qualification===hiddenQualification?hiddenInterpretation:sandboxInterpretation)+
57
+ (kind==='test'&&platform==='darwin'?'+no-tcp':'');
34
58
  export const sandboxKinds=['typecheck','lint','test'];
35
- // A test run gets loopback networking and a private /tmp (sandboxArgv's
36
- // `network` and `writes`); a typecheck or lint gets no network.
59
+ // A test run gets a private /tmp (sandboxArgv's `writes`) and, on Linux,
60
+ // supervised loopback networking (`network`); a typecheck or lint, and a
61
+ // test run on macOS, get no network but Unix sockets.
37
62
  export const testRun=check=>check?.kind==='test';
38
63
  const fail=code=>{throw Object.assign(Error(code),{code});};
39
64
  const digest=value=>createHash('sha256').update(value).digest('hex');
@@ -252,6 +277,24 @@ export function hiddenEntries(root){
252
277
  !runnerCache(rel)&&!runnerCache(rel.replace(/\/$/,'')+'/x'));
253
278
  }
254
279
 
280
+ // The documentation files docPatterns names among the files git lists and
281
+ // does not ignore (git-ignored ones are hidden as such), as regular files:
282
+ // a link is not hidden. Files, never folders, so a folder stays listable.
283
+ // Relative; null when git cannot tell. git filters by pathspec, so a large
284
+ // checkout is not listed whole.
285
+ const docMatch=picomatch(docPatterns,{dot:true});
286
+ export function hiddenDocs(root){
287
+ const r=spawnSync('git',[...projectGit,'ls-files','-z','--cached','--others','--exclude-standard','--',...docPatterns.map(pattern=>`:(glob)${pattern}`)],
288
+ {cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:60000,stdio:['ignore','pipe','ignore'],env:gitEnv()});
289
+ if(r.status!==0)return null;
290
+ const out=new Set();
291
+ for(const rel of r.stdout.split('\0'))if(rel&&!out.has(rel)&&docMatch(rel))
292
+ try{if(fs.lstatSync(path.join(root,rel)).isFile())out.add(rel);}catch{}
293
+ const named=[...out].filter(rel=>!docsByPattern(rel));
294
+ if(named.length>namedDocsLimit)for(const rel of named)out.delete(rel);
295
+ return [...out].sort();
296
+ }
297
+
255
298
  // What git lists now as ignored at or beneath `paths` (relative files, or
256
299
  // directories ending in /), as hiddenEntries would: nested repositories are
257
300
  // not entered, and an entry that is an installed tree is not ignored (one
@@ -286,10 +329,15 @@ export function trackedBeneath(root,entries){
286
329
  // cannot tell, every change counts. `external:` paths always count. With
287
330
  // `during` (changes while the run ran), a new file outside the hidden
288
331
  // entries counts too: the run could read it.
289
- export function visibleChange(root,hidden,before,current,paths,{during=false}={}){
332
+ // With `docs` (sandbox@3: the documentation files the run hid, relative), a
333
+ // later change to one of those files does not count. A new documentation
334
+ // file does, and so does any change while the run ran: the run is not shown
335
+ // to have been kept from it.
336
+ export function visibleChange(root,hidden,before,current,paths,{during=false,docs=null}={}){
290
337
  const entries=hidden.map(entry=>entry.replace(/\/$/,''));
291
338
  const beneath=file=>entries.some(entry=>file===entry||file.startsWith(entry+'/'));
292
- const changed=[...new Set(paths)].filter(file=>before[file]!==current[file]).sort();
339
+ const hiddenDoc=new Set(during?[]:docs||[]);
340
+ const changed=[...new Set(paths)].filter(file=>before[file]!==current[file]&&!hiddenDoc.has(file)).sort();
293
341
  const seen=changed.filter(file=>file in before&&!beneath(file));
294
342
  if(seen.length)return seen[0];
295
343
  const covered=changed.filter(beneath),fresh=changed.filter(file=>!(file in before)&&!beneath(file));
@@ -322,11 +370,20 @@ const ancestors=list=>[...new Set(list.flatMap(dir=>{const out=[];for(let at=pat
322
370
  // the run's own temporary directory (`own`), which is new for each run: a
323
371
  // daemon an earlier run left behind cannot answer a later one. `hidden`:
324
372
  // absolute paths whose contents it may not read or execute.
325
- // A test run (`network` "loopback") may bind and accept anywhere and
326
- // connect to loopback only; with `writes`, it writes nothing beneath /tmp
327
- // and /var/tmp but beneath those places (its checkout, home and own
328
- // temporary directory).
329
- export function seatbeltProfile(reads,own=null,hidden=[],{network='none',writes=null}={}){
373
+ // A test run gets no TCP either: Seatbelt cannot refuse one loopback port
374
+ // while allowing the others (a per-port deny under a loopback allow is not
375
+ // applied; an allow list of 65,000 ports does not compile), so a suite could
376
+ // reach a database started outside the run. A suite that needs loopback
377
+ // fails init's trial and runs plainly. With `writes`, a test run writes
378
+ // nothing beneath /tmp and /var/tmp but beneath those places (its checkout,
379
+ // home and own temporary directory).
380
+ // `docs` ({root, entries}): the checkout's documentation it may not read,
381
+ // regular files only, so folders stay listable. Extensions go by path
382
+ // pattern, so the profile stays small however many files match: every file
383
+ // hiddenDocs lists, and more (one in node_modules, README.MD). Seatbelt
384
+ // matches a regex without regard to case, so LICENSE* and CHANGELOG* go by
385
+ // the files hiddenDocs listed: a pattern would hide license-check.ts too.
386
+ export function seatbeltProfile(reads,own=null,hidden=[],{writes=null,docs=null}={}){
330
387
  const quote=value=>JSON.stringify(value);
331
388
  const both=list=>[...new Set(list.flatMap(dir=>[dir,real(dir)]).filter(Boolean))];
332
389
  const all=[...new Set(reads.flatMap(dir=>[dir,real(dir)]).filter(Boolean))];
@@ -337,20 +394,30 @@ export function seatbeltProfile(reads,own=null,hidden=[],{network='none',writes=
337
394
  // Its contents only, as on Linux: a run may still see that it exists, and write beneath it.
338
395
  ...hidden.length?[`(deny file-read-data process-exec* ${[...new Set(hidden.flatMap(file=>[file,path.join(real(path.dirname(file))??path.dirname(file),path.basename(file))]))]
339
396
  .map(file=>`(subpath ${quote(file)})`).join(' ')})`]:[],
397
+ ...docs?[docsRule(docs,both([docs.root]))]:[],
340
398
  '(deny network*)',...own?[`(allow network* ${[own,real(own)].filter(Boolean).map(dir=>`(subpath ${quote(dir)})`).join(' ')})`]:[],
341
- ...network==='loopback'?['(allow network-bind)','(allow network-inbound)','(allow network-outbound (remote ip "localhost:*"))']:[],
342
399
  ...writes?['(deny file-write* (subpath "/tmp") (subpath "/private/tmp") (subpath "/var/tmp") (subpath "/private/var/tmp"))',
343
400
  `(allow file-write* ${both(writes).map(dir=>`(subpath ${quote(dir)})`).join(' ')})`]:[]].join('\n');
344
401
  }
402
+ // docsByPattern's patterns for each root, and each listed file they do not cover by its path.
403
+ function docsRule(docs,roots){
404
+ const named=docs.entries.filter(rel=>!docsByPattern(rel));
405
+ const patterns=roots.map(dir=>escape(dir)).flatMap(dir=>[`^${dir}/(.+/)?[^/]*\\.(md|mdx|rst)$`,`^${dir}/docs/(.+/)?[^/]*\\.(${docsFolderTypes.join('|')})$`])
406
+ .map(pattern=>`(regex #"${pattern.replace(/"/g,'\\"')}")`);
407
+ const files=roots.flatMap(dir=>named.map(rel=>`(literal ${JSON.stringify(path.join(dir,rel))})`));
408
+ return `(deny file-read-data process-exec* (require-all (vnode-type REGULAR-FILE) (require-any ${[...patterns,...files].join(' ')})))`;
409
+ }
345
410
  // The argv that runs `argv` sandboxed: reads beneath `reads`, no network but
346
411
  // Unix sockets beneath `tmp` on macOS, nothing beneath `hidden` (absolute).
347
412
  // On Linux a hidden directory can still be listed and its entries stat'ed:
348
413
  // Landlock restricts neither beneath a listable parent.
349
- // `network` "loopback" and `writes`: a test run's (seatbeltProfile). On Linux
350
- // its network is supervised: `stats` then says whether the supervisor served
351
- // the whole run (supervisorIssue).
352
- export function sandboxArgv(mode,argv,{reads,tmp=null,python,hidden=[],stats=null,network='none',writes=null,fault=null}){
353
- if(mode==='seatbelt')return ['/usr/bin/sandbox-exec','-p',seatbeltProfile(reads,tmp,hidden,{network,writes}),...argv];
414
+ // `network` "loopback" and `writes`: a test run's. Only Linux gives it a
415
+ // network (seatbeltProfile says why macOS does not), supervised: `stats`
416
+ // then says whether the supervisor served the whole run (supervisorIssue).
417
+ // `docs` ({root, entries}): the checkout's documentation, hidden too.
418
+ export function sandboxArgv(mode,argv,{reads,tmp=null,python,hidden=[],stats=null,network='none',writes=null,fault=null,docs=null}){
419
+ if(mode==='seatbelt')return ['/usr/bin/sandbox-exec','-p',seatbeltProfile(reads,tmp,hidden,{writes,docs}),...argv];
420
+ if(docs)hidden=[...hidden,...docs.entries.map(rel=>path.join(docs.root,rel.replace(/\/$/,'')))];
354
421
  // The launcher's own warnings would become the check's output.
355
422
  if(mode==='landlock')return [python,'-I','-W','ignore',landlockScript,...reads.flatMap(dir=>['--read',dir]),
356
423
  ...hidden.flatMap(file=>['--hide',file]),...stats?['--stats',stats]:[],
@@ -517,6 +584,7 @@ export function runToEnd(argv,{cwd,env,timeout=0,echo=false,started=null}){
517
584
  });
518
585
  }
519
586
  // {same, plain, sandboxed, line} or {issue} when the sandbox cannot run here.
587
+ // With `hide` 'docs', the documentation (hiddenDocs) is hidden too.
520
588
  // With `hide`, the git-ignored entries hiddenEntries lists are kept from the
521
589
  // sandboxed run, and it also returns them, their count, the run times and, on
522
590
  // Linux, the launcher's rule count and setup time.
@@ -528,18 +596,23 @@ export async function sandboxTrial(root,check,{timeout=300000,hide=false,plain:e
528
596
  const command=python?check.command:[lookup(scriptProject(root,check.script).manager,process.env.PATH,root)||'npm','run',check.script];
529
597
  const selected={...check,command,environment:sandboxEnvironment(python,python?null:scriptProject(root,check.script).manager,check.kind)};
530
598
  const tests=testRun(check);
531
- const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'redue-trial-')),home=path.join(scratch,'home'),tmp=path.join(scratch,'tmp');
532
- fs.mkdirSync(home);fs.mkdirSync(tmp);
599
+ // A test run's home is beneath its temporary directory, as in cli.mjs: on
600
+ // macOS that is where its Unix sockets may be.
601
+ const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'redue-trial-')),tmp=path.join(scratch,'tmp'),home=tests?path.join(tmp,'home'):path.join(scratch,'home');
602
+ fs.mkdirSync(tmp);fs.mkdirSync(home);
533
603
  try{
534
- const entries=hide?hiddenEntries(root):[];
535
- if(!entries)return {issue:hiddenListIssue};
536
- const hidden=entries.map(rel=>path.join(root,rel.replace(/\/$/,""))),stats=(hide||tests)&&supported.mode==='landlock'?path.join(scratch,'stats.json'):null;
604
+ const ignored=hide?hiddenEntries(root):[],docEntries=hide==='docs'?hiddenDocs(root):[];
605
+ if(!ignored)return {issue:hiddenListIssue};
606
+ if(!docEntries)return {issue:hiddenDocsIssue};
607
+ const entries=[...ignored,...docEntries];
608
+ const hidden=ignored.map(rel=>path.join(root,rel.replace(/\/$/,""))),stats=(hide||tests)&&supported.mode==='landlock'?path.join(scratch,'stats.json'):null;
537
609
  const run=async(argv,env)=>{const started=Date.now(),r=await runToEnd(argv,{cwd:root,env,timeout});
538
610
  return {status:r.timedOut?'timeout':r.status??r.signal??r.error?.code,output:`${r.stdout}\n${r.stderr}`,ms:Date.now()-started};};
539
611
  const plain=earlier??await run(command,{...process.env,...python?{}:{npm_config_update_notifier:'false'}});
540
612
  const reads=[root,home,tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
541
613
  const sandboxed=await run(sandboxArgv(supported.mode,command,{reads,tmp,python:supported.python,hidden,stats,
542
- ...tests?{network:'loopback',writes:[root,home,tmp]}:{}}),sandboxEnv(selected,process.env,{root,home,tmp}));
614
+ ...hide==='docs'?{docs:{root,entries:docEntries}}:{},
615
+ ...tests?{...supported.mode==='landlock'?{network:'loopback'}:{},writes:[root,home,tmp]}:{}}),sandboxEnv(selected,process.env,{root,home,tmp}));
543
616
  // A test run's network was supervised throughout, on Linux.
544
617
  const supervisor=tests&&stats?supervisorIssue(stats):null;
545
618
  // A failure both ways counts as the same only without a sign of a refusal.
@@ -561,6 +634,6 @@ export async function sandboxTrial(root,check,{timeout=300000,hide=false,plain:e
561
634
  const differs=tests&&read?(JSON.stringify(counts[0])===JSON.stringify(counts[1])?null:{plain:JSON.stringify(counts[0]),sandboxed:JSON.stringify(counts[1])}):output;
562
635
  return {same:plain.status===sandboxed.status&&!denied&&!differs&&!supervisor,plain:plain.status,sandboxed:sandboxed.status,output:differs,plainRun:plain,
563
636
  line:supervisor||denied||(plain.status===sandboxed.status?null:sandboxed.output.trim().split('\n').at(-1)?.slice(0,200)||null),
564
- ...hide?{hidden:entries.length,entries,plainMs:plain.ms,sandboxedMs:sandboxed.ms,...launcher?{rules:launcher.rules,setupMs:launcher.ms}:{}}:{}};
637
+ ...hide?{hidden:entries.length,entries,docs:docEntries.length,plainMs:plain.ms,sandboxedMs:sandboxed.ms,...launcher?{rules:launcher.rules,setupMs:launcher.ms}:{}}:{}};
565
638
  }finally{fs.rmSync(scratch,{recursive:true,force:true});}
566
639
  }
package/src/setup.mjs CHANGED
@@ -12,6 +12,7 @@ import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import {createHash} from 'node:crypto';
14
14
  import {spawnSync} from 'node:child_process';
15
+ import {testCounts} from './sandbox.mjs';
15
16
  import {createInterface} from 'node:readline/promises';
16
17
  import {projectState} from './project-location.mjs';
17
18
  import {claudeHooksInstalled,removeClaudeHooks,removeIntegration} from './agent-integration.mjs';
@@ -80,7 +81,19 @@ async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
80
81
  const names=configExisted?JSON.parse(readText(configPath)).checks.map(check=>check.name):found.checks.map(check=>check.name);
81
82
  const ready=names.filter(name=>levels.get(name)==='ready');
82
83
  console.log(`Setting up REDUE for Claude Code in ${root}`);
83
- console.log(configExisted?` keep ${configName} (exists)`:` create ${configName}: ${names.map(name=>`${name} (${levels.get(name)==='ready'?'can qualify':'recording-only'})`).join(', ')}`);
84
+ console.log(configExisted?` keep ${configName} (exists)`:` create ${configName}: ${names.map(name=>`${name} (${levels.get(name)==='ready'?'can qualify':'recording only'})`).join(', ')}`);
85
+ // What the checks setup does not make reusable are, and why.
86
+ if(!configExisted){
87
+ const recording=found.checks.filter(check=>check.level!=='ready');
88
+ if(recording.length)console.log(' recording only: REDUE records these runs but never reports one CURRENT, because it cannot tell what the result depends on:');
89
+ for(const check of recording)console.log(` ${check.name}: ${check.reason}`);
90
+ const out=[...found.skipped||[]].filter(row=>row.reason);
91
+ const kinds=new Set([...found.checks.map(check=>check.kind??check.name.split(':')[0]),...out.map(row=>String(row.kind).split(':')[0])]);
92
+ for(const kind of ['typecheck','lint','test'])if(!kinds.has(kind)&&!found.checks.some(check=>check.name===kind))
93
+ out.push({kind,reason:`${kind}: none found (no script or tool REDUE recognizes)`});
94
+ if(out.length)console.log(' left out:');
95
+ for(const row of out)console.log(` ${row.reason.startsWith(String(row.kind))?row.reason:`${row.kind}: ${row.reason}`}`);
96
+ }
84
97
  console.log(` write the Claude Code integration: CLAUDE.md section, .claude/skills/redue-verification/SKILL.md, ${localSettings} hooks, ${agentRecord}`);
85
98
  console.log(` start the observer and record ${ready.length?ready.join(', '):'no check'} once, unless already CURRENT`);
86
99
  if(!await confirm('Proceed?',yes,{defaultYes:true})){console.log('Nothing written.');return {applied:false};}
@@ -121,7 +134,7 @@ async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
121
134
  // recorded as reusable. Wait while it plans and indexes; once its plan is
122
135
  // ready, an observer that is still unhealthy says why in the status below.
123
136
  // Nothing to wait for when no check can qualify.
124
- const observation=()=>{const out=redue(['status','--json'],{capture:true});try{return JSON.parse(out.stdout).observation??null;}catch{return null;}};
137
+ const observation=()=>{const out=redue(['status','--cached','--json'],{capture:true});try{return JSON.parse(out.stdout).observation??null;}catch{return null;}};
125
138
  // An answer without a phase (a stopped observer) for 10 s in a row ends the
126
139
  // wait too.
127
140
  const begun=Date.now();let told=-1,unread=0,phaseless=0;
@@ -136,9 +149,18 @@ async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
136
149
  // One recorded run of each check that can qualify and is not CURRENT.
137
150
  const statusOf=()=>{const out=redue(['status','--sync','--json'],{capture:true,timeout:600_000});try{return JSON.parse(out.stdout).checks;}catch{return [];}};
138
151
  const rows=statusOf(),due=ready.filter(name=>rows.find(row=>row.name===name)?.freshness!=='CURRENT');
139
- for(const name of due){console.log(`Recording ${name} once:`);redue(['run',name]);}
152
+ // One line per check; the run's own output only when it fails.
153
+ for(const name of due){
154
+ console.log(`Recording ${name} once (running it)...`);
155
+ const started=Date.now(),run=redue(['run',name],{capture:true});
156
+ const output=`${run.stdout??''}\n${run.stderr??''}`,counts=testCounts(output),seconds=((Date.now()-started)/1000).toFixed(1);
157
+ const tally=Object.entries(counts).filter(([,value])=>value!==null).map(([key,value])=>`${value} ${key}`).join(', ');
158
+ console.log(` ${name}: ${run.status===0?'PASS':`FAIL (exit ${run.status??run.signal})`} in ${seconds} s${tally?`, ${tally}`:''}`);
159
+ if(run.status!==0)console.log(output.trim().split('\n').slice(-15).map(line=>` ${line}`).join('\n'));
160
+ }
140
161
  if(ready.length&&!due.length)console.log(`Already CURRENT: ${ready.join(', ')}.`);
141
- for(const row of statusOf())console.log(` ${row.name}: ${row.freshness}${row.result?' / '+row.result:''}${row.freshness==='CURRENT'?'':' — '+row.reason}`);
162
+ for(const row of statusOf())console.log(` ${row.name}: ${row.freshness}${row.result?' / '+row.result:''}${row.freshness==='CURRENT'?'':
163
+ levels.get(row.name)!=='ready'?' — recording only':' — '+row.reason}`);
142
164
  console.log('Done. Start a new Claude Code session in this project. Undo with redue remove.');
143
165
  return {applied:true};
144
166
  }