@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 +51 -0
- package/README.md +28 -21
- package/bin/redue.mjs +23 -11
- package/docs/user/agents.md +14 -7
- package/docs/user/getting-started.md +4 -3
- package/docs/user/json.md +2 -2
- package/docs/user/sandbox.md +71 -43
- package/docs/user/shell.md +3 -3
- package/docs/user/supported-projects.md +4 -2
- package/package.json +1 -1
- package/src/claude-hook.mjs +49 -29
- package/src/cli.mjs +27 -18
- package/src/daemon.mjs +4 -2
- package/src/node-onboarding.mjs +3 -1
- package/src/presentation.mjs +4 -4
- package/src/sandbox.mjs +97 -24
- package/src/setup.mjs +26 -4
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).
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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.
|
|
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 |
|
|
98
|
-
| Lint |
|
|
99
|
-
| Tests |
|
|
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
|
-
|
|
102
|
-
|
|
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 (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
44
|
-
status --
|
|
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
|
-
|
|
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(
|
|
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
|
|
169
|
-
if(!
|
|
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")
|
|
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('
|
|
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};
|
package/docs/user/agents.md
CHANGED
|
@@ -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,
|
|
16
|
-
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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`
|
|
121
|
-
|
|
122
|
-
|
|
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`
|
|
4
|
-
|
|
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
|
package/docs/user/sandbox.md
CHANGED
|
@@ -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
|
|
12
|
-
way CI does. It does not say that a run with
|
|
13
|
-
service on your machine or the network would
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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-
|
|
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-
|
|
60
|
-
`sandbox-
|
|
61
|
-
the whole
|
|
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
|
|
72
|
-
git ignores
|
|
73
|
-
checkout (reusing the plain run). The first
|
|
74
|
-
result is written: `sandbox-
|
|
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
|
|
84
|
-
missing file counts only when it names one, since Linux tools
|
|
85
|
-
unreadable file as missing), REDUE runs the check again in the
|
|
86
|
-
without hiding anything. That result is kept and compared on every
|
|
87
|
-
git-ignored ones included. Run `redue init` again to
|
|
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
|
-
- **
|
|
135
|
-
connections, and connect only to the local host (`127.0.0.0/8`,
|
|
136
|
-
`0.0.0.0`, `::`) and Unix sockets.
|
|
137
|
-
-
|
|
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
|
|
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.
|
|
249
|
-
|
|
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
|
|
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
|
package/docs/user/shell.md
CHANGED
|
@@ -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
|
|
15
|
-
status
|
|
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,
|
|
25
|
-
|
|
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
package/src/claude-hook.mjs
CHANGED
|
@@ -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'
|
|
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
|
-
//
|
|
460
|
-
// completed run
|
|
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
|
-
|
|
504
|
-
|
|
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'
|
|
507
|
-
|
|
508
|
-
|
|
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
|
|
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,
|
|
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}}}:
|
|
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
|
|
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
|
|
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
|
|
543
|
-
const status=await synchronizedStatus(entry,config,root,
|
|
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}}:
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
342
|
-
// cannot list them, nothing is
|
|
343
|
-
|
|
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],
|
|
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
|
|
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?
|
|
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)
|
|
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) {
|
package/src/node-onboarding.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/presentation.mjs
CHANGED
|
@@ -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.
|
|
16
|
-
if(/requires? a synchronized content read/.test(reason))return 'Cached status cannot establish applicability here.
|
|
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('
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
36
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
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
|
|
350
|
-
//
|
|
351
|
-
// the whole run (supervisorIssue).
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
532
|
-
|
|
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
|
|
535
|
-
if(!
|
|
536
|
-
|
|
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
|
-
...
|
|
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
|
|
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
|
-
|
|
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'?'':
|
|
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
|
}
|