@redue/cli 0.1.0-alpha.30 → 0.1.0-alpha.32
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 +73 -0
- package/README.md +18 -3
- package/bin/redue.mjs +148 -19
- package/docs/user/agents.md +26 -0
- package/docs/user/configuration.md +8 -0
- package/docs/user/getting-started.md +2 -1
- package/docs/user/json.md +18 -0
- package/docs/user/sandbox.md +14 -0
- package/package.json +1 -1
- package/src/agent-integration.mjs +45 -14
- package/src/cached-state.mjs +9 -6
- package/src/claude-hook.mjs +134 -0
- package/src/cli.mjs +45 -17
- package/src/daemon.mjs +62 -8
- package/src/landlock.py +3 -0
- package/src/presentation.mjs +4 -3
- package/src/sandbox.mjs +43 -13
- package/src/setup.mjs +22 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,78 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.32 — 2026-11-05
|
|
4
|
+
|
|
5
|
+
REDUE's status is now the point: what is verified on the code as it is
|
|
6
|
+
now, what is stale, what is due.
|
|
7
|
+
|
|
8
|
+
- **`redue verify`.** Runs every check that is due (STALE, or UNVERIFIED
|
|
9
|
+
with a program that exists), starting the observer if needed, then shows
|
|
10
|
+
the full status. It exits 0 only when every check that can be verified
|
|
11
|
+
ends CURRENT / PASS. `--json` gives `verified`, the checks it ran and the
|
|
12
|
+
status. It runs what is due up to three rounds, since a run can make
|
|
13
|
+
another check due.
|
|
14
|
+
- **Not verifiable.** A check no run can confirm shows as NOT VERIFIABLE
|
|
15
|
+
with the reason: a recording-only check, or one whose last two passing
|
|
16
|
+
runs changed the same inputs while they ran (a test script that rebuilds
|
|
17
|
+
a tracked bundle). `verify`, its exit code, the end-of-turn check and the
|
|
18
|
+
status line's due count leave it out; the status line shows it ("✓ 3
|
|
19
|
+
verified · 1 not verifiable"). Any other change, or a failing run, makes
|
|
20
|
+
it due again. JSON: `"verifiable": false` and a `not_verifiable` count.
|
|
21
|
+
- **A status line in Claude Code.** `redue setup claude` adds one:
|
|
22
|
+
"REDUE ✓ 3 verified", "REDUE ✓ 1 verified · 2 due: typecheck, test",
|
|
23
|
+
"REDUE ✗ 1 failing: lint". A status line you already have stays; setup
|
|
24
|
+
prints the command instead.
|
|
25
|
+
- **End-of-turn check.** When the agent changed files during a turn and a
|
|
26
|
+
check is due, the hook asks it once to run `redue verify` before it
|
|
27
|
+
finishes. It never asks twice in a turn. `"endOfTurnCheck": false` in
|
|
28
|
+
the config turns it off.
|
|
29
|
+
- **Faster sandboxed runs.** Receipts and checkpoints keep each installed
|
|
30
|
+
tree (`.venv`, `node_modules`) as one digest, a run computes only its own
|
|
31
|
+
check's context, and Linux keeps the Landlock probe's answer. What `redue
|
|
32
|
+
run` adds to a sub-second check on GitHub's Linux runners fell from
|
|
33
|
+
270-514 ms to 125-205 ms (lighter's ruff 514 to 155 ms); on macOS from
|
|
34
|
+
390-710 ms to 310-435 ms, where an FSEvents history stall (150-190 ms in
|
|
35
|
+
about a third of checkpoints) remains. The Landlock launcher now refuses
|
|
36
|
+
a network-less run on a kernel below ABI 4.
|
|
37
|
+
- **Windows.** A status call before the first `redue start` no longer
|
|
38
|
+
leaves a file that makes `start` refuse the state directory.
|
|
39
|
+
|
|
40
|
+
Measured with the verification-gap study (21 tasks on 7 repositories, 2
|
|
41
|
+
runs, Claude Code headless): sessions that ended with every check verified
|
|
42
|
+
on the final code went from 5 of 42 to 41 of 42 with Sonnet 5.5, and from
|
|
43
|
+
20 of 42 to 42 of 42 with Opus 5.5, with no check REDUE called CURRENT /
|
|
44
|
+
PASS failing on the final code. In the product census, 73% of 330 checks
|
|
45
|
+
had a definite answer (CURRENT or STALE) after their second run (alpha.29:
|
|
46
|
+
71%); 97% of those on the sandboxed recipe with ignored files and
|
|
47
|
+
documentation hidden. `init` on the 13 slowest census repos took 3874 s on
|
|
48
|
+
Linux and 4797 s on macOS (alpha.31: 4139 s and 5517 s), with the same
|
|
49
|
+
checks chosen.
|
|
50
|
+
|
|
51
|
+
## 0.1.0-alpha.31 — 2026-11-03
|
|
52
|
+
|
|
53
|
+
A check that runs longer than init's trial limit no longer holds up
|
|
54
|
+
`init` for three runs, and can be set up in the sandbox later.
|
|
55
|
+
|
|
56
|
+
- **A run past the trial limit never agrees.** Each trial run at `init`
|
|
57
|
+
stops after 5 minutes. alpha.30 counted a timeout on both sides as
|
|
58
|
+
agreement and ran every trial of a check that timed out. Now, when the
|
|
59
|
+
plain run reaches the limit, `init` runs nothing in the sandbox and
|
|
60
|
+
writes the fallback; when a sandboxed run reaches it, no further trial
|
|
61
|
+
follows. On the 13 census repos with the slowest checks, `init --dry-run`
|
|
62
|
+
took 6224 s in all with alpha.30 and 3670 s now on Linux, 8331 s and
|
|
63
|
+
4776 s on macOS. ADOSMissionControl went from 1138 s to 581 s (Linux),
|
|
64
|
+
agent-afk from 936 s to 323 s, Nomi from 860 s to 528 s (macOS). Checks
|
|
65
|
+
that moved from the sandbox to their fallback: 2 of 210 on Linux
|
|
66
|
+
(ts-liveview typecheck, open-science lint), 3 of 210 on macOS (Nomi and
|
|
67
|
+
ts-liveview typecheck, OneUptime lint).
|
|
68
|
+
- **`redue init --no-time-limit`.** `redue setup` names the checks that
|
|
69
|
+
ran past the limit and prints one command, such as
|
|
70
|
+
`redue init --check typecheck --no-time-limit`. It runs their trials with
|
|
71
|
+
no limit and updates only their rows in the existing config (a row that
|
|
72
|
+
runs the same script), restarts a running observer, and leaves the
|
|
73
|
+
config one `redue remove` still deletes. `init --json` gives such a
|
|
74
|
+
check's kind as `time_limit`.
|
|
75
|
+
|
|
3
76
|
## 0.1.0-alpha.30 — 2026-11-03
|
|
4
77
|
|
|
5
78
|
`init` runs a check fewer times, and tests try the sandbox first.
|
package/README.md
CHANGED
|
@@ -31,8 +31,10 @@ stops before writing anything if the dependencies are not installed. It:
|
|
|
31
31
|
workflows, Makefile, justfile, tox, nox or scripts give them: mypy,
|
|
32
32
|
pyright and ty (typecheck), ruff (lint) and pytest (test). It says which
|
|
33
33
|
checks it left out and why, and which it can only record, not reuse;
|
|
34
|
-
- connects Claude Code: a short section in `CLAUDE.md`, a skill,
|
|
35
|
-
|
|
34
|
+
- connects Claude Code: a short section in `CLAUDE.md`, a skill, hooks in
|
|
35
|
+
`.claude/settings.local.json`, and a status line there that shows what is
|
|
36
|
+
verified on the code as it is now ("REDUE ✓ 3 verified", "REDUE 2 due:
|
|
37
|
+
typecheck, test"). A status line you already have stays;
|
|
36
38
|
- starts REDUE's file observer, waits while it indexes the project (a minute
|
|
37
39
|
or more in a large one), then runs each check that can qualify once, so
|
|
38
40
|
there is a first result to reuse, and prints one line per run (the run's
|
|
@@ -42,7 +44,10 @@ stops before writing anything if the dependencies are not installed. It:
|
|
|
42
44
|
Then start a new Claude Code session in the project and work as usual. When
|
|
43
45
|
the agent is about to rerun a check whose last pass still applies, the hook
|
|
44
46
|
skips it and tells the agent why. After an edit, the hook runs the check
|
|
45
|
-
through REDUE so the new result is recorded.
|
|
47
|
+
through REDUE so the new result is recorded. When the agent is about to finish a
|
|
48
|
+
turn in which files changed while a check is due, the hook asks it once to
|
|
49
|
+
run `redue verify` first (`"endOfTurnCheck": false` in the config turns
|
|
50
|
+
this off).
|
|
46
51
|
|
|
47
52
|
To look yourself:
|
|
48
53
|
|
|
@@ -63,6 +68,15 @@ the agent's hook does. `redue status --cached` gives the last answer the
|
|
|
63
68
|
observer published without checking; it can say UNVERIFIED ("cannot confirm
|
|
64
69
|
your execution context") where `redue status` would say CURRENT.
|
|
65
70
|
|
|
71
|
+
`redue verify` runs every check that is due (STALE, or UNVERIFIED with a
|
|
72
|
+
command it can run), then prints the status. It exits 0 only when every check
|
|
73
|
+
ends CURRENT / PASS, so it answers "is everything verified on the code as it
|
|
74
|
+
is now?" in one command. A check no run can confirm is shown as NOT
|
|
75
|
+
VERIFIABLE with the reason, and left out: a recording-only check, or one
|
|
76
|
+
whose runs change its own inputs (two passing runs in a row changed the same
|
|
77
|
+
files, such as a test script that rebuilds a tracked bundle). Any other
|
|
78
|
+
change, or a failing run, makes it due again.
|
|
79
|
+
|
|
66
80
|
`redue stats` shows, per check, the reruns the hook skipped, the time saved,
|
|
67
81
|
the recorded runs and the top reasons a check was UNVERIFIED, from a log kept
|
|
68
82
|
on your machine.
|
|
@@ -171,6 +185,7 @@ redue init # write redue.config.json from your scripts
|
|
|
171
185
|
redue start # start the file observer
|
|
172
186
|
redue run typecheck # run a check through REDUE and record the result
|
|
173
187
|
redue status # what still holds
|
|
188
|
+
redue verify # run what is due; exit 0 when everything is CURRENT / PASS
|
|
174
189
|
```
|
|
175
190
|
|
|
176
191
|
Codex is supported too (`redue agent setup codex --apply`); see
|
package/bin/redue.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import {mark,traceBase,traceContext,traceEnvironment,traceId} from '../src/decision-profile.mjs';
|
|
4
4
|
traceBase({...traceContext(),request:traceId(),work:'client'});
|
|
5
|
-
mark('client.loaded');
|
|
5
|
+
mark('client.loaded',{sinceStartMs:Math.round(performance.now())});
|
|
6
6
|
import {directCompiler,directQualification,directEnvironment,directInstalledInputs,directInterpretation} from '../src/direct-typescript.mjs';
|
|
7
7
|
import {runArgv,tscRun} from '../src/typescript-invocation.mjs';
|
|
8
8
|
import {testEnvironment,testRunnerAt,testsInterpretation,testsQualification} from '../src/test-direct.mjs';
|
|
@@ -33,7 +33,10 @@ Commands:
|
|
|
33
33
|
init, agent setup claude, start, one recorded run per check
|
|
34
34
|
remove [--yes] Undo setup: integration, observer state, and the config setup created
|
|
35
35
|
init [--dry-run] [--workspace NAME] [--check KIND] [--recipe typescript-direct|tests-direct|node-script|sandbox]
|
|
36
|
-
|
|
36
|
+
[--no-time-limit]
|
|
37
|
+
Discover existing scripts; optionally select one workspace/check.
|
|
38
|
+
--no-time-limit: trial runs have no 5-minute limit; with a config, the checks
|
|
39
|
+
it does not run in the sandbox are tried, and those that qualify are updated
|
|
37
40
|
agent setup codex|claude [--dry-run|--apply] [--json]
|
|
38
41
|
agent doctor [codex|claude] [--json]
|
|
39
42
|
agent remove codex|claude [--dry-run|--apply] [--json]
|
|
@@ -46,6 +49,8 @@ Commands:
|
|
|
46
49
|
detail [CHECK] [--json] Alias for explain
|
|
47
50
|
run CHECK [--if-due] Execute one configured check and record its outcome; with --if-due,
|
|
48
51
|
only when its last pass does not apply to the files as they are now
|
|
52
|
+
verify [--json] Run every check that is due (STALE, or UNVERIFIED with a runnable command),
|
|
53
|
+
then show the status; exit 0 only when every check is CURRENT / PASS
|
|
49
54
|
stats [--json] Per check: skips, time saved, recorded runs, why checks were UNVERIFIED
|
|
50
55
|
remove-state Stop and remove only this configuration's owned local state
|
|
51
56
|
|
|
@@ -63,6 +68,12 @@ if(args.length===3&&args[0]==='agent'&&args[1]==='hook'&&args[2]==='claude'){
|
|
|
63
68
|
const {runClaudeHook}=await import('../src/claude-hook.mjs');
|
|
64
69
|
await runClaudeHook(fileURLToPath(import.meta.url));process.exit(0);
|
|
65
70
|
}
|
|
71
|
+
// Claude Code's status line, also before config discovery: it prints one
|
|
72
|
+
// line, or nothing where REDUE is not set up.
|
|
73
|
+
if(args.length===3&&args[0]==='agent'&&args[1]==='statusline'&&args[2]==='claude'){
|
|
74
|
+
const {runStatusLine}=await import('../src/claude-hook.mjs');
|
|
75
|
+
await runStatusLine(fileURLToPath(import.meta.url));await exit(0);
|
|
76
|
+
}
|
|
66
77
|
if(args.length===1&&args[0]==='--version'){console.log(version);process.exit(0);}
|
|
67
78
|
// Internal: the entry this command runs, so setup and the hook can tell
|
|
68
79
|
// whether the `redue` on PATH (a link, a package manager's shim, a wrapper)
|
|
@@ -96,7 +107,7 @@ if(command==='agent'){
|
|
|
96
107
|
catch(e){error(e.message);}
|
|
97
108
|
await exit(0);
|
|
98
109
|
}
|
|
99
|
-
if(!['init','start','stop','status','detail','explain','run','stats','remove-state'].includes(command))
|
|
110
|
+
if(!['init','start','stop','status','detail','explain','run','verify','stats','remove-state'].includes(command))
|
|
100
111
|
error(`unknown command ${command||'<none>'}; use --help`);
|
|
101
112
|
const json=args.includes('--json');
|
|
102
113
|
// run --if-due: run the check only when its last pass does not apply now.
|
|
@@ -118,11 +129,13 @@ if(command==='init')for(let i=0;i<args.length;){
|
|
|
118
129
|
}
|
|
119
130
|
const check=['run','detail','explain'].includes(command)&&args[0]&&!args[0].startsWith('--')?args.shift():null;
|
|
120
131
|
if(command==='run'&&!check)error('run requires a check name');
|
|
121
|
-
if(args.some(value=>!['--json','--sync','--cached','--dry-run','--short'].includes(value)))error('unknown option; use --help');
|
|
132
|
+
if(args.some(value=>!['--json','--sync','--cached','--dry-run','--short','--no-time-limit'].includes(value)))error('unknown option; use --help');
|
|
122
133
|
if(sync&&!['status','detail','explain'].includes(command))error('--sync is for status or detail');
|
|
123
134
|
if(cachedOnly&&(command!=='status'||args.includes('--sync')))error('--cached is a status option; do not combine it with --sync');
|
|
124
135
|
if(short&&(command!=='status'||json||args.includes('--sync')))error('--short is a cached status option; do not combine it with --sync or --json');
|
|
125
136
|
if(dryRun&&command!=='init')error('--dry-run is for init');
|
|
137
|
+
const noTimeLimit=args.includes('--no-time-limit');
|
|
138
|
+
if(noTimeLimit&&command!=='init')error('--no-time-limit is for init');
|
|
126
139
|
|
|
127
140
|
if(command==='init'){
|
|
128
141
|
const {discoverNodeProject,sourceLinkIssue}=await import('../src/node-onboarding.mjs');
|
|
@@ -164,36 +177,63 @@ if(command==='init'){
|
|
|
164
177
|
// where the plain run passes. Any other difference is tried once with the
|
|
165
178
|
// whole checkout, reusing the plain run. Otherwise init writes what it
|
|
166
179
|
// would have without the sandboxed recipe, with the reason. Each check
|
|
167
|
-
// costs one plain run and one or two sandboxed runs.
|
|
180
|
+
// costs one plain run and one or two sandboxed runs. A run past the trial
|
|
181
|
+
// limit ends the check's trials (time_limit); --no-time-limit sets none.
|
|
168
182
|
const trialIssue=trial=>trial.issue?`the sandbox cannot run here (${trial.issue})`:
|
|
183
|
+
trial.timedOut?`it ran past init's 5-minute trial limit${trial.timedOut==='sandboxed'?' inside the sandbox':''}`+
|
|
184
|
+
` (redue init --check ${trial.kind} --no-time-limit tries it without the limit)`:
|
|
169
185
|
// A failure both ways that shows a refusal cannot be told from a real one.
|
|
170
186
|
trial.same?null:trial.plain!==trial.sandboxed?`exit ${trial.sandboxed} inside the sandbox, ${trial.plain} outside it${trial.line?` (${trial.line})`:''}`:
|
|
171
187
|
trial.line?`it fails both ways (exit ${trial.plain}), with a sign of a refusal inside the sandbox (${trial.line})`:
|
|
172
188
|
`the output differs inside the sandbox: ${JSON.stringify(trial.output.sandboxed)} instead of ${JSON.stringify(trial.output.plain)}`;
|
|
189
|
+
// With --no-time-limit and a config, the checks the config has and does
|
|
190
|
+
// not run in the sandbox are tried, and one it lacks when --check names
|
|
191
|
+
// it. Those that qualify replace their rows (a row that runs the same
|
|
192
|
+
// script) or are added.
|
|
193
|
+
const configured=fs.existsSync(configFile);
|
|
194
|
+
let existing=null,existingText=null;
|
|
195
|
+
if(configured&&noTimeLimit){
|
|
196
|
+
try{existingText=fs.readFileSync(configFile,'utf8');existing=JSON.parse(existingText);}catch(e){error(`cannot read ${configFile}: ${e.message}`);}
|
|
197
|
+
if(!Array.isArray(existing?.checks))error(`cannot read ${configFile}: it has no checks`);
|
|
198
|
+
if((existing.root??'.')!=='.'||!samePath(realObservedPath(path.dirname(configFile)),discovery.root))
|
|
199
|
+
error('init updates only a config at the repository root, without root; run it there');
|
|
200
|
+
}
|
|
201
|
+
const timeout=noTimeLimit?0:undefined,improved=new Map();
|
|
173
202
|
let trials=0;
|
|
174
203
|
for(const [at,row] of [...discovery.checks.entries()]){
|
|
175
204
|
if(row.qualification!==sandboxQualification||row.level!=='ready')continue;
|
|
205
|
+
const old=existing?.checks.find(check=>check.name===row.config.name);
|
|
206
|
+
const sameRun=old&&old.kind===row.config.kind&&(row.config.script?old.script===row.config.script:
|
|
207
|
+
JSON.stringify(old.command)===JSON.stringify(row.config.command));
|
|
208
|
+
const notTried=!existing?null:!old?(checkSelection?null:'the config does not have it; name its kind with --check to add it'):
|
|
209
|
+
isSandboxed(old.qualification)?'the config already runs it in the sandbox':sameRun?null:'the config runs something else under that name';
|
|
210
|
+
if(notTried){discovery.checks[at]=null;discovery.skipped.push({kind:row.config.name,reason:`${row.config.name}: not tried; ${notTried}`});continue;}
|
|
176
211
|
if(!json)console.error(`Running ${row.config.name} inside and outside the sandbox to confirm it...`);
|
|
177
|
-
const docs=await sandboxTrial(discovery.root,row.config,{hide:'docs'});
|
|
212
|
+
const docs={...await sandboxTrial(discovery.root,row.config,{hide:'docs',timeout}),kind:row.config.kind};
|
|
178
213
|
if(!docs.issue)trials++;
|
|
179
214
|
if(!docs.issue&&docs.same){
|
|
180
215
|
row.config={...row.config,qualification:docsQualification};row.qualification=docsQualification;
|
|
181
|
-
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}`;
|
|
182
|
-
|
|
183
|
-
|
|
216
|
+
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}`;
|
|
217
|
+
improved.set(row.config.name,row.config);continue;}
|
|
218
|
+
if(!docs.issue&&!docs.timedOut&&docs.refusedHidden){
|
|
219
|
+
row.reason+=`; at init it passed, then failed reading a file that was hidden (${docs.refusedHidden}), so the whole checkout is its input, not run that way at init`;
|
|
220
|
+
improved.set(row.config.name,row.config);continue;}
|
|
184
221
|
// Without a list of the ignored files or the documentation, the whole checkout is tried on its own.
|
|
185
|
-
const trial=docs.plainRun?await sandboxTrial(discovery.root,row.config,{plain:docs.plainRun}):
|
|
186
|
-
[hiddenListIssue,hiddenDocsIssue].includes(docs.issue)?await sandboxTrial(discovery.root,row.config):docs;
|
|
222
|
+
const trial=docs.timedOut?docs:docs.plainRun?{...await sandboxTrial(discovery.root,row.config,{plain:docs.plainRun,timeout}),kind:row.config.kind}:
|
|
223
|
+
[hiddenListIssue,hiddenDocsIssue].includes(docs.issue)?{...await sandboxTrial(discovery.root,row.config,{timeout}),kind:row.config.kind}:docs;
|
|
187
224
|
if(trial!==docs&&!trial.issue)trials++;
|
|
188
225
|
const issue=trialIssue(trial);
|
|
189
226
|
if(!issue){row.reason+=`; ran both ways at init, exit ${trial.plain}; the whole checkout is its input: `+
|
|
190
|
-
(docs.issue?docs.issue:`with the files git ignores and the documentation hidden, ${trialIssue(docs)}`);
|
|
191
|
-
|
|
192
|
-
|
|
227
|
+
(docs.issue?docs.issue:`with the files git ignores and the documentation hidden, ${trialIssue(docs)}`);
|
|
228
|
+
improved.set(row.config.name,row.config);continue;}
|
|
229
|
+
// time_limit: the kind that --check takes to try it without the limit.
|
|
230
|
+
const limited=trial.timedOut?{timeLimit:row.config.kind}:{};
|
|
231
|
+
if(row.fallback)discovery.checks[at]={...row.fallback,...limited,reason:`Sandboxed recipe unavailable: ${issue}; ${row.fallback.reason}`};
|
|
232
|
+
else{discovery.checks[at]=null;discovery.skipped.push({kind:row.config.name,...trial.timedOut?{time_limit:row.config.kind}:{},reason:`${row.config.name}: sandboxed recipe unavailable: ${issue}`});}
|
|
193
233
|
}
|
|
194
234
|
discovery.checks=discovery.checks.filter(Boolean).map(({fallback,...row})=>row);
|
|
195
235
|
discovery.config.checks=discovery.checks.map(row=>row.config);
|
|
196
|
-
const
|
|
236
|
+
const unsupported=Boolean(discovery.observationIssue||
|
|
197
237
|
discovery.manager.issue&&!['npm','yarn','pnpm'].includes(discovery.manager.name)||
|
|
198
238
|
discovery.manager.name==='yarn'&&!discovery.manager.version);
|
|
199
239
|
const willWrite=!dryRun&&!configured&&!unsupported&&discovery.checks.length>0;
|
|
@@ -202,9 +242,9 @@ if(command==='init'){
|
|
|
202
242
|
installation_layout:discovery.manager.layout,
|
|
203
243
|
issue:discovery.observationIssue||discovery.manager.issue,workspaces:discovery.workspaces,
|
|
204
244
|
ambiguous:discovery.ambiguous,skipped:discovery.skipped||[],
|
|
205
|
-
checks:discovery.checks.map(({config:check,level,reason})=>({name:check.name,
|
|
245
|
+
checks:discovery.checks.map(({config:check,level,reason,timeLimit})=>({name:check.name,
|
|
206
246
|
script:check.script||null,command:check.command||null,
|
|
207
|
-
workspace:check.workspace||null,level,reason})),config:configFile,
|
|
247
|
+
workspace:check.workspace||null,level,reason,...timeLimit?{time_limit:timeLimit}:{}})),config:configFile,
|
|
208
248
|
written:willWrite,existing:configured,writable:!configured&&!unsupported&&discovery.checks.length>0};
|
|
209
249
|
if(!json) {
|
|
210
250
|
if(summary.package_manager||!discovery.python)console.log(`Node ${summary.node}; ${summary.package_manager||'unknown package manager'}`+
|
|
@@ -223,7 +263,8 @@ if(command==='init'){
|
|
|
223
263
|
console.log(` ${row.kind}: ambiguous scripts (${row.scripts.join(', ')}); choose one explicitly`);
|
|
224
264
|
for(const row of summary.skipped)console.log(` Skipped: ${row.reason}`);
|
|
225
265
|
if(!summary.checks.length)console.log('No useful existing verification scripts found.');
|
|
226
|
-
console.log(
|
|
266
|
+
console.log(existing&&improved.size?(dryRun?`Preview only; ${configFile} was not updated.`:`Updating ${[...improved.keys()].join(', ')} in ${configFile}.`):
|
|
267
|
+
configured?`Existing ${configFile} left unchanged.`:
|
|
227
268
|
dryRun?`Preview only; ${configFile} was not written.`:
|
|
228
269
|
willWrite?`Writing ${configFile}; inspect it before running checks.`:'No configuration will be written.');
|
|
229
270
|
}
|
|
@@ -236,6 +277,23 @@ if(command==='init'){
|
|
|
236
277
|
console.log(`Next: review the config, then redue start and redue run ${JSON.stringify(discovery.checks[0].config.name)}.`);
|
|
237
278
|
console.log('Connect your agent: redue agent setup codex (or claude). Preview with --dry-run.');
|
|
238
279
|
console.log('Then redue status says whether each result applies to the files as they are now.');}
|
|
280
|
+
} else if(existing&&improved.size&&!dryRun){
|
|
281
|
+
if(fs.readFileSync(configFile,'utf8')!==existingText)error(`${configFile} changed while the checks ran; nothing was written. Run init again`);
|
|
282
|
+
// The observer keeps the config it started with: it is restarted around the update.
|
|
283
|
+
const state=stateOverride||projectState(discovery.root,configFile);
|
|
284
|
+
let running=false;try{running=processAlive(Number(fs.readFileSync(path.join(state,'observer.lock','pid'),'utf8')));}catch{}
|
|
285
|
+
const self=action=>spawnSync(process.execPath,[process.argv[1],'--config',configFile,...stateOverride?['--state-dir',stateOverride]:[],action],
|
|
286
|
+
{stdio:['ignore','pipe','pipe'],encoding:'utf8'});
|
|
287
|
+
if(running&&self('stop').status!==0)error('the observer did not stop; nothing was written');
|
|
288
|
+
const names=new Set(existing.checks.map(check=>check.name));
|
|
289
|
+
existing.checks=[...existing.checks.map(check=>improved.get(check.name)??check),...[...improved.values()].filter(check=>!names.has(check.name))];
|
|
290
|
+
const text=JSON.stringify(existing,null,2)+'\n';
|
|
291
|
+
fs.writeFileSync(configFile,text);
|
|
292
|
+
// A config setup created is still one redue remove deletes.
|
|
293
|
+
(await import('../src/setup.mjs')).configRewritten(discovery.root,existingText,text);
|
|
294
|
+
Object.assign(summary,{written:true,updated:[...improved.keys()],observer:!running?'not running':self('start').status===0?'restarted':'not started'});
|
|
295
|
+
if(!json)console.log(`Updated ${[...improved.keys()].join(', ')} in ${configFile}.`+
|
|
296
|
+
{restarted:' Restarted the observer with it.','not started':' The observer did not start again: run redue start.','not running':''}[summary.observer]);
|
|
239
297
|
} else if(unsupported&&!json)console.log(`No config written: ${summary.issue||'unsupported discovery boundary'}.`);
|
|
240
298
|
if(json)console.log(JSON.stringify({...summary,proposed_config:discovery.config}));
|
|
241
299
|
await exit(0);
|
|
@@ -269,7 +327,9 @@ if(publicConfig.schema!==1)error('config schema must be 1');
|
|
|
269
327
|
if(!Array.isArray(publicConfig.checks)||!publicConfig.checks.length)
|
|
270
328
|
error('config needs at least one explicitly defined check');
|
|
271
329
|
for(const key of Object.keys(publicConfig))
|
|
272
|
-
if(!['schema','root','checks','packageManager'].includes(key))error(`unsupported config field ${key}`);
|
|
330
|
+
if(!['schema','root','checks','packageManager','endOfTurnCheck'].includes(key))error(`unsupported config field ${key}`);
|
|
331
|
+
// The Claude Code hook's end-of-turn check; only the hook reads it.
|
|
332
|
+
if(publicConfig.endOfTurnCheck!==undefined&&typeof publicConfig.endOfTurnCheck!=='boolean')error('endOfTurnCheck must be true or false');
|
|
273
333
|
if(check&&!publicConfig.checks.some(row=>row.name===check))error(`unknown check ${check}; use redue status to list configured checks`);
|
|
274
334
|
const root=realObservedPath(path.resolve(path.dirname(configFile),publicConfig.root||'.'));
|
|
275
335
|
const state=stateOverride||projectState(root,configFile);
|
|
@@ -457,6 +517,75 @@ if(!maintenance&&!created){
|
|
|
457
517
|
}
|
|
458
518
|
}
|
|
459
519
|
|
|
520
|
+
if(command==='verify'){
|
|
521
|
+
const self=(more,options)=>spawnSync(process.execPath,[fileURLToPath(import.meta.url),'--config',configFile,
|
|
522
|
+
...(stateOverride?['--state-dir',stateOverride]:[]),...more],{env:process.env,...options});
|
|
523
|
+
const ask=()=>{const r=self(['status','--sync','--json'],{encoding:'utf8',stdio:['ignore','pipe','ignore'],timeout:600_000});
|
|
524
|
+
try{return JSON.parse(r.stdout);}catch{return null;}};
|
|
525
|
+
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
|
526
|
+
let status=ask();
|
|
527
|
+
// Nothing is confirmed without the observer: start it as the session hook
|
|
528
|
+
// does, and wait while it indexes.
|
|
529
|
+
if(!status?.observation?.healthy){
|
|
530
|
+
let pid=null;try{pid=Number(fs.readFileSync(path.join(state,'observer.lock','pid'),'utf8'));}catch{}
|
|
531
|
+
if(!(Number.isSafeInteger(pid)&&pid>0&&processAlive(pid))){
|
|
532
|
+
if(!json)console.error('redue verify: starting the observer');
|
|
533
|
+
self(['start'],{stdio:['ignore','ignore','inherit']});status=ask();
|
|
534
|
+
}
|
|
535
|
+
for(const until=Date.now()+120_000;!status?.observation?.healthy&&
|
|
536
|
+
['discovery','indexing','reconciling','validating','initializing'].includes(status?.observation?.phase)&&Date.now()<until;){
|
|
537
|
+
await sleep(1000);status=ask();}
|
|
538
|
+
}
|
|
539
|
+
// A check is due when its last result does not apply to the files as they
|
|
540
|
+
// are now, and it can run: its program is there.
|
|
541
|
+
const runnable=row=>{const command=checks.find(item=>item.name===row.name)?.command?.[0];
|
|
542
|
+
if(typeof command!=='string'||command.endsWith('<unavailable>'))return false;
|
|
543
|
+
if(command.includes('/'))return fs.existsSync(path.resolve(root,checks.find(item=>item.name===row.name).cwd||'.',command));
|
|
544
|
+
return Boolean(findExecutable(command));};
|
|
545
|
+
// A check no run can confirm (recording-only, or one that rewrites its own
|
|
546
|
+
// inputs: verifiable false) is never due. A run can make another check due
|
|
547
|
+
// (a suite that writes an input), and a check that rewrites its own inputs
|
|
548
|
+
// shows that on its second run: verify runs what is due again, three rounds
|
|
549
|
+
// at most.
|
|
550
|
+
// In a round, a check whose last run changed its inputs runs first, so the
|
|
551
|
+
// checks its run makes due run after it.
|
|
552
|
+
// A check that already ran here is due again only when it is STALE or its
|
|
553
|
+
// inputs changed during its run: another UNVERIFIED reason would not clear.
|
|
554
|
+
const changedOwn=row=>/declared input event occurred during execution/.test(row.reason||'');
|
|
555
|
+
const ranNames=new Set();
|
|
556
|
+
const dueOf=value=>(value?.checks||[]).filter(row=>row.verifiable!==false&&
|
|
557
|
+
(row.freshness==='STALE'||row.freshness==='UNVERIFIED'&&runnable(row)&&(!ranNames.has(row.name)||changedOwn(row))))
|
|
558
|
+
.sort((a,b)=>changedOwn(b)-changedOwn(a));
|
|
559
|
+
// While the observer indexes or reconciles (a run may have changed a manifest).
|
|
560
|
+
const settle=async()=>{for(const until=Date.now()+120_000;!status?.observation?.healthy&&
|
|
561
|
+
['discovery','indexing','reconciling','validating','initializing'].includes(status?.observation?.phase)&&Date.now()<until;){
|
|
562
|
+
await sleep(1000);status=ask();}};
|
|
563
|
+
const {note,dueNote}=await import('../src/stats.mjs');
|
|
564
|
+
const ran=[];
|
|
565
|
+
for(let round=0,due=dueOf(status);round<3&&due.length&&status?.observation?.healthy;round++,due=dueOf(status)){
|
|
566
|
+
for(const row of due){
|
|
567
|
+
const entry=dueNote(status,row.name);if(entry)note(state,entry);
|
|
568
|
+
if(!json)console.error(`redue verify: running ${row.name} (${row.freshness}: ${row.reason})`);
|
|
569
|
+
// With --json, the check's output goes to stderr; stdout carries the answer.
|
|
570
|
+
const r=self(['run',row.name],{stdio:json?['ignore',2,2]:'inherit'});
|
|
571
|
+
ran.push({check:row.name,was:row.freshness,exit:r.status});ranNames.add(row.name);
|
|
572
|
+
}
|
|
573
|
+
status=ask();await settle();
|
|
574
|
+
}
|
|
575
|
+
const final=status;
|
|
576
|
+
const counted=(final?.checks||[]).filter(row=>row.verifiable!==false);
|
|
577
|
+
const verified=Boolean(final?.observation?.healthy)&&counted.length>0&&counted.every(row=>row.freshness==='CURRENT'&&row.result==='PASS');
|
|
578
|
+
if(json)console.log(JSON.stringify({schema:1,verified,ran,status:final}));
|
|
579
|
+
else{
|
|
580
|
+
if(ran.length)console.log('');
|
|
581
|
+
console.log(final?renderStatus(final):'UNVERIFIED — status did not answer.');
|
|
582
|
+
const other=final?.checks?.length-counted.length;
|
|
583
|
+
console.log((verified?`Verified: every check that can be verified (${counted.length}) passed on the files as they are now.`:
|
|
584
|
+
counted.length?'Not verified: some checks did not end CURRENT / PASS.':'Not verified: no check can be verified here.')+
|
|
585
|
+
(other?` ${other} not verifiable, left out.`:''));
|
|
586
|
+
}
|
|
587
|
+
await exit(verified?0:1);
|
|
588
|
+
}
|
|
460
589
|
if(command==='status'&&!sync){
|
|
461
590
|
const value=readCachedStatus(state,publicConfig.checks);
|
|
462
591
|
console.log(json?JSON.stringify(value):renderStatus(value,{short}));
|
package/docs/user/agents.md
CHANGED
|
@@ -87,6 +87,32 @@ paths no longer exist. When the hook runs a check through REDUE for the
|
|
|
87
87
|
agent, it likewise writes plain `redue run` only if its own PATH finds this
|
|
88
88
|
install, else the absolute form.
|
|
89
89
|
|
|
90
|
+
The same command also runs on UserPromptSubmit and Stop, for the end-of-turn
|
|
91
|
+
check. At the start of a turn it notes the observer's count of file events
|
|
92
|
+
and which checks are CURRENT (on Windows, from a synchronized status). When
|
|
93
|
+
the agent stops, it asks status: if a check is due (STALE or UNVERIFIED) and
|
|
94
|
+
files changed during the turn (the count moved, a check CURRENT at the start
|
|
95
|
+
is due now, or the turn's transcript has an Edit, Write, MultiEdit or
|
|
96
|
+
NotebookEdit call), it
|
|
97
|
+
blocks the stop once with "Before you finish, run `redue verify` and report
|
|
98
|
+
what it says". It never asks twice in a turn, and never on the stop that
|
|
99
|
+
follows its reminder (`stop_hook_active`), so it cannot loop. A turn that
|
|
100
|
+
changed nothing ends quietly even when a check is due, and so does any turn
|
|
101
|
+
where the observer is not healthy. `"endOfTurnCheck": false` in the config
|
|
102
|
+
turns it off.
|
|
103
|
+
|
|
104
|
+
Setup also adds Claude Code's status line, `redue agent statusline claude`,
|
|
105
|
+
in the same file and the same form. It prints one line from a synchronized
|
|
106
|
+
status (the observer's cached one when that takes over 3 s): `REDUE ✓ 3
|
|
107
|
+
verified`, `REDUE ✓ 1 verified · 2 due: typecheck, test`, `REDUE ✗ 1 failing:
|
|
108
|
+
lint`, `REDUE ✓ 3 verified · 1 not verifiable`, `REDUE … checking files` while
|
|
109
|
+
the observer indexes, or `REDUE ? observer not running`. A check that is not
|
|
110
|
+
verifiable (see [the JSON interface](json.md)) is never due, here or for the
|
|
111
|
+
end-of-turn check. Where Claude Code already has a status line (in the
|
|
112
|
+
local or shared project settings, or your user settings), setup leaves it and
|
|
113
|
+
prints the command, so you can add REDUE's line to yours. `redue agent remove
|
|
114
|
+
claude --apply` and `redue remove` take out only REDUE's status line.
|
|
115
|
+
|
|
90
116
|
Agents often chain an edit and a check (`printf … >> README.md && npm run
|
|
91
117
|
lint`). In a simple chain (commands joined by `&&`, `||` or `;`, with no
|
|
92
118
|
subshell, here-document, command substitution, background job or shell
|
|
@@ -33,6 +33,14 @@ For the explicit direct compiler choice and generated exact argv, see
|
|
|
33
33
|
A general check (`"qualification": "node-script-v1"`) has a `script` and no
|
|
34
34
|
`command`; see the [general recipe](node-script.md).
|
|
35
35
|
|
|
36
|
+
## End-of-turn check
|
|
37
|
+
|
|
38
|
+
With Claude Code, REDUE's hook checks at the end of each agent turn: when the
|
|
39
|
+
turn changed files and a check is due, it asks the agent once to run
|
|
40
|
+
`redue verify` before it finishes ([agents](agents.md)). To turn this off,
|
|
41
|
+
add `"endOfTurnCheck": false` at the top level of the config. Only the hook
|
|
42
|
+
reads it; the observer does not need a restart.
|
|
43
|
+
|
|
36
44
|
## Arbitrary commands
|
|
37
45
|
|
|
38
46
|
An unrecognized ecosystem can still record ordinary execution:
|
|
@@ -100,7 +100,8 @@ redue explain typecheck
|
|
|
100
100
|
Use a check actually printed by `init`. For a workspace:
|
|
101
101
|
`redue init --workspace @example/package --check typecheck`.
|
|
102
102
|
Initialization previews detected commands and qualification confidence before writing
|
|
103
|
-
`redue.config.json`. An existing config is
|
|
103
|
+
`redue.config.json`. An existing config is not overwritten (`init --no-time-limit`
|
|
104
|
+
updates the rows of checks it [qualifies](sandbox.md#files-git-ignores-and-documentation)). No dependencies are
|
|
104
105
|
installed; a check proposed for the sandboxed recipe runs once plainly and once or
|
|
105
106
|
twice in the sandbox, and no results are recorded. A missing/unsupported boundary is explained. `init` tries
|
|
106
107
|
the strict recipes first and uses the [general recipe](node-script.md) for a root
|
package/docs/user/json.md
CHANGED
|
@@ -59,3 +59,21 @@ JSON summary. **Its stdout is not a JSON-only stream.** Query status/details aft
|
|
|
59
59
|
completion for machine-readable evidence. Normal check exit codes are preserved;
|
|
60
60
|
interruption/process-start failures are nonzero. REDUE retains metadata and receipts,
|
|
61
61
|
not arbitrary terminal output. The CLI does not infer tests executed from prose.
|
|
62
|
+
|
|
63
|
+
A check no run can confirm has `"verifiable": false` (its `freshness` stays
|
|
64
|
+
UNVERIFIED and `reason` says why): a recording-only check (no qualification
|
|
65
|
+
contract, inputs it cannot resolve), or one whose last two runs passed while
|
|
66
|
+
the same inputs changed during them, with its plan, environment and every
|
|
67
|
+
other input unchanged since. Anything else changing, a failing run or a
|
|
68
|
+
clean run makes it verifiable (and due) again. Such checks are counted in
|
|
69
|
+
`not_verifiable`, not in the other counts, and do not set `state`. A cached
|
|
70
|
+
answer (`--cached`) never marks a check this way.
|
|
71
|
+
|
|
72
|
+
`verify --json` writes one JSON object to stdout: `verified` (true only when the
|
|
73
|
+
observer is healthy, at least one check is verifiable, and every verifiable
|
|
74
|
+
check is CURRENT / PASS), `ran` (each check it ran, in order: `check`, `was`
|
|
75
|
+
(STALE or UNVERIFIED) and the run's `exit`), and `status`, the synchronized
|
|
76
|
+
status after those runs. It runs what is due up to three rounds, since a run
|
|
77
|
+
can make another check due; a check whose last run changed its inputs runs
|
|
78
|
+
first in a round. It never runs a check that is not verifiable. The checks'
|
|
79
|
+
own output goes to stderr. It exits 0 when `verified` is true, else 1.
|
package/docs/user/sandbox.md
CHANGED
|
@@ -87,6 +87,20 @@ reusing the plain run, and writes `sandbox-v1` only if that agrees. So a
|
|
|
87
87
|
check runs two or three times at init, not four. `init` no longer writes
|
|
88
88
|
`sandbox-v2`; a check already set up with it keeps it.
|
|
89
89
|
|
|
90
|
+
Each trial run stops after 5 minutes. A run that stops there never counts as
|
|
91
|
+
agreeing: when the plain run reaches the limit, `init` runs nothing in the
|
|
92
|
+
sandbox and writes the fallback; when a sandboxed run reaches it, no further
|
|
93
|
+
trial follows. `init` marks such a check (`time_limit` in `--json` gives
|
|
94
|
+
its kind), and `redue setup` names them and prints one command:
|
|
95
|
+
`redue init --check typecheck --no-time-limit` (or `redue init
|
|
96
|
+
--no-time-limit` for several kinds). With an existing config, that command
|
|
97
|
+
runs with no limit the checks the config has and does not run in the
|
|
98
|
+
sandbox, and a check it lacks when `--check` names its kind. It replaces
|
|
99
|
+
the row of each that qualifies, if the row runs the same script, or adds
|
|
100
|
+
it, and leaves the rest of the config as it was. It restarts a running
|
|
101
|
+
observer with the new config, and `redue remove` still deletes a config
|
|
102
|
+
`setup` created.
|
|
103
|
+
|
|
90
104
|
With `sandbox-v3`, each run also hides the documentation: the files git
|
|
91
105
|
lists (and does not ignore) named `*.md`, `*.mdx`, `*.rst`, `LICENSE*` or
|
|
92
106
|
`CHANGELOG*` anywhere, and text, image and PDF files (`.txt`, `.adoc`,
|