@redue/cli 0.1.0-alpha.31 → 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 +48 -0
- package/README.md +18 -3
- package/bin/redue.mjs +82 -3
- package/docs/user/agents.md +26 -0
- package/docs/user/configuration.md +8 -0
- package/docs/user/json.md +18 -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 +33 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
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
|
+
|
|
3
51
|
## 0.1.0-alpha.31 — 2026-11-03
|
|
4
52
|
|
|
5
53
|
A check that runs longer than init's trial limit no longer holds up
|
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';
|
|
@@ -49,6 +49,8 @@ Commands:
|
|
|
49
49
|
detail [CHECK] [--json] Alias for explain
|
|
50
50
|
run CHECK [--if-due] Execute one configured check and record its outcome; with --if-due,
|
|
51
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
|
|
52
54
|
stats [--json] Per check: skips, time saved, recorded runs, why checks were UNVERIFIED
|
|
53
55
|
remove-state Stop and remove only this configuration's owned local state
|
|
54
56
|
|
|
@@ -66,6 +68,12 @@ if(args.length===3&&args[0]==='agent'&&args[1]==='hook'&&args[2]==='claude'){
|
|
|
66
68
|
const {runClaudeHook}=await import('../src/claude-hook.mjs');
|
|
67
69
|
await runClaudeHook(fileURLToPath(import.meta.url));process.exit(0);
|
|
68
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
|
+
}
|
|
69
77
|
if(args.length===1&&args[0]==='--version'){console.log(version);process.exit(0);}
|
|
70
78
|
// Internal: the entry this command runs, so setup and the hook can tell
|
|
71
79
|
// whether the `redue` on PATH (a link, a package manager's shim, a wrapper)
|
|
@@ -99,7 +107,7 @@ if(command==='agent'){
|
|
|
99
107
|
catch(e){error(e.message);}
|
|
100
108
|
await exit(0);
|
|
101
109
|
}
|
|
102
|
-
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))
|
|
103
111
|
error(`unknown command ${command||'<none>'}; use --help`);
|
|
104
112
|
const json=args.includes('--json');
|
|
105
113
|
// run --if-due: run the check only when its last pass does not apply now.
|
|
@@ -319,7 +327,9 @@ if(publicConfig.schema!==1)error('config schema must be 1');
|
|
|
319
327
|
if(!Array.isArray(publicConfig.checks)||!publicConfig.checks.length)
|
|
320
328
|
error('config needs at least one explicitly defined check');
|
|
321
329
|
for(const key of Object.keys(publicConfig))
|
|
322
|
-
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');
|
|
323
333
|
if(check&&!publicConfig.checks.some(row=>row.name===check))error(`unknown check ${check}; use redue status to list configured checks`);
|
|
324
334
|
const root=realObservedPath(path.resolve(path.dirname(configFile),publicConfig.root||'.'));
|
|
325
335
|
const state=stateOverride||projectState(root,configFile);
|
|
@@ -507,6 +517,75 @@ if(!maintenance&&!created){
|
|
|
507
517
|
}
|
|
508
518
|
}
|
|
509
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
|
+
}
|
|
510
589
|
if(command==='status'&&!sync){
|
|
511
590
|
const value=readCachedStatus(state,publicConfig.checks);
|
|
512
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:
|
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/package.json
CHANGED
|
@@ -11,7 +11,8 @@ import {readProject,projectState,canonicalConfig} from './project-location.mjs';
|
|
|
11
11
|
import {readCachedStatus} from './cached-state.mjs';
|
|
12
12
|
import {samePath,withinPath} from './path-identity.mjs';
|
|
13
13
|
import {processAlive} from './process-liveness.mjs';
|
|
14
|
-
import
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import {fanoutRules,hookCommand,hookForms,redueFor,runsEntry,shellQuote as quote,statusLineCommand} from './claude-hook.mjs';
|
|
15
16
|
|
|
16
17
|
const policy=fs.readFileSync(new URL('./agent-policy.md',import.meta.url),'utf8');
|
|
17
18
|
const hosts={codex:{instruction:'AGENTS.md',skill:'.agents/skills/redue-verification/SKILL.md'},
|
|
@@ -32,18 +33,35 @@ function hookFor(config,relative,scripts,command=hookCommand){
|
|
|
32
33
|
}
|
|
33
34
|
// The same command on SessionStart starts the observer; it needs no check to match.
|
|
34
35
|
const sessionHookFor=(command=hookCommand)=>({hooks:[{type:'command',command,timeout:10}]});
|
|
35
|
-
|
|
36
|
+
// And makes the end-of-turn check: UserPromptSubmit notes where the turn
|
|
37
|
+
// starts, Stop asks status (up to 10 s).
|
|
38
|
+
const turnHooksFor=(command=hookCommand)=>({UserPromptSubmit:{hooks:[{type:'command',command,timeout:10}]},
|
|
39
|
+
Stop:{hooks:[{type:'command',command,timeout:20}]}});
|
|
40
|
+
const events=['PreToolUse','SessionStart','UserPromptSubmit','Stop'];
|
|
36
41
|
// The absolute form: Node and REDUE's entry (redue.mjs), each quoted as quote()
|
|
37
|
-
// writes it. [node, entry] or null.
|
|
38
|
-
function absoluteHook(command){
|
|
42
|
+
// writes it. [node, entry] or null. `use`: hook, or statusline.
|
|
43
|
+
function absoluteHook(command,use='hook'){
|
|
39
44
|
const word="'((?:[^']|'\\\\'')*)'";
|
|
40
|
-
const match=new RegExp(`^${word} ${word} agent
|
|
45
|
+
const match=new RegExp(`^${word} ${word} agent ${use} claude$`).exec(command??'');
|
|
41
46
|
if(!match)return null;
|
|
42
47
|
const [node,entry]=[match[1],match[2]].map(value=>value.replaceAll("'\\''","'"));
|
|
43
48
|
// REDUE's entry file, or the bin link npm installs for it.
|
|
44
49
|
return /^redue(\.mjs)?$/.test(path.basename(entry))?[node,entry]:null;
|
|
45
50
|
}
|
|
46
51
|
export const isHookCommand=command=>command===hookCommand||Boolean(absoluteHook(command));
|
|
52
|
+
// REDUE's status line, by its command in either form.
|
|
53
|
+
const ownedStatusLine=value=>value?.type==='command'&&(value.command===statusLineCommand||Boolean(absoluteHook(value.command,'statusline')));
|
|
54
|
+
const statusLineFor=command=>({type:'command',command:command.replace(/ agent hook claude$/,' agent statusline claude'),padding:0});
|
|
55
|
+
// Where Claude Code already has a status line that is not REDUE's, which
|
|
56
|
+
// setup leaves alone: the local or shared project settings, or the user's.
|
|
57
|
+
function otherStatusLine(root){
|
|
58
|
+
const user=path.join(process.env.CLAUDE_CONFIG_DIR||path.join(os.homedir(),'.claude'),'settings.json');
|
|
59
|
+
for(const [label,text] of [[settingsPath,read(root,settingsPath)],['.claude/settings.json',read(root,'.claude/settings.json')],[user,(()=>{try{return fs.readFileSync(user,'utf8');}catch{return null;}})()]]){
|
|
60
|
+
let value=null;try{value=JSON.parse(text);}catch{}
|
|
61
|
+
if(value?.statusLine&&!ownedStatusLine(value.statusLine))return label;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
47
65
|
// The command Claude Code runs for the hook. Plain `redue` when this install
|
|
48
66
|
// is the one both this shell and a login shell find on PATH. Otherwise this
|
|
49
67
|
// install's Node and entry by absolute path, so a Claude Code started from
|
|
@@ -82,10 +100,15 @@ function parseSettings(text){
|
|
|
82
100
|
return value;
|
|
83
101
|
}
|
|
84
102
|
// Replace REDUE's entry for each event with next[event] (or remove it; a null
|
|
85
|
-
// `next` removes them all)
|
|
86
|
-
//
|
|
103
|
+
// `next` removes them all), and REDUE's status line with next.statusLine. A
|
|
104
|
+
// status line that is not REDUE's stays. Unchanged input returns the same
|
|
105
|
+
// text, so idempotent setup does not rewrite the file.
|
|
87
106
|
function settingsWith(text,next){
|
|
88
107
|
const value=parseSettings(text);let changed=false;
|
|
108
|
+
if(value.statusLine===undefined||ownedStatusLine(value.statusLine)){
|
|
109
|
+
const line=next?.statusLine??undefined;
|
|
110
|
+
if(!sameJson(line,value.statusLine)){changed=true;if(line)value.statusLine=line;else delete value.statusLine;}
|
|
111
|
+
}
|
|
89
112
|
for(const event of events){
|
|
90
113
|
const old=value.hooks?.[event]||[],at=old.findIndex(ownedHook),list=old.filter(entry=>!ownedHook(entry));
|
|
91
114
|
if(next?.[event])list.splice(at<0?list.length:at,0,next[event]);
|
|
@@ -345,7 +368,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
345
368
|
if(value.state_access)console.log(`State access: ${value.state_access.status}${value.state_access.reason?' — '+value.state_access.reason:''}`);
|
|
346
369
|
if(value.compatibility?.reason)console.log(`Observer compatibility: ${value.compatibility.reason}`);
|
|
347
370
|
if(value.applicability?.requires_reassessment)console.log('Applicability: cached evidence needs reassessment; choose synchronization or execution deliberately.');
|
|
348
|
-
for(const row of value.hosts)console.log(`${row.host}: ${row.version||'host unavailable'}; integration ${row.integrity}; hook ${row.hook}${row.host==='claude'?`; session start ${row.session_start}`:''}; behavioral validation NOT VERIFIED by doctor${row.reason?' — '+row.reason:''}`);
|
|
371
|
+
for(const row of value.hosts)console.log(`${row.host}: ${row.version||'host unavailable'}; integration ${row.integrity}; hook ${row.hook}${row.host==='claude'?`; session start ${row.session_start}; status line ${row.status_line}`:''}; behavioral validation NOT VERIFIED by doctor${row.reason?' — '+row.reason:''}`);
|
|
349
372
|
console.log('Doctor does not start observers, execute checks, or prove agent behavior.');
|
|
350
373
|
for(const warning of value.warnings)console.log(`Review: ${warning}`);
|
|
351
374
|
}return value;
|
|
@@ -354,7 +377,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
354
377
|
const {root,relative,config}=project(configFile),h=hosts[host],record=saved(root,host),health=inspect(root,host,record);
|
|
355
378
|
if(health.integrity==='conflict')throw Error(health.reason);
|
|
356
379
|
const changes=[],notes=action==='setup'?warnings(root,host,cwd):[];
|
|
357
|
-
let hook=null,sessionHook=null,block=null;
|
|
380
|
+
let hook=null,sessionHook=null,statusLine=null,block=null;
|
|
358
381
|
if(action==='setup'){
|
|
359
382
|
const before=read(root,h.instruction),skill=skillFor(relative,host,config);
|
|
360
383
|
block=blockFor(host,blockForms(host,config,relative));
|
|
@@ -368,13 +391,19 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
368
391
|
notes.push('The agent instructions say `redue ...`: put this install on PATH (npm install --global @redue/cli@alpha) so the agent\'s shell can run them. The hook runs the checks it rewrites through this install either way.');
|
|
369
392
|
hook=host==='claude'?hookFor(config,relative,scripts,invocation.command):null;
|
|
370
393
|
sessionHook=host==='claude'?sessionHookFor(invocation.command):null;
|
|
394
|
+
// The status line, unless Claude Code already shows another one.
|
|
395
|
+
const other=host==='claude'?otherStatusLine(root):null;
|
|
396
|
+
if(other)notes.push(`Claude Code already has a status line (${other}); REDUE did not replace it. `+
|
|
397
|
+
`\`${statusLineFor(invocation.command).command}\` prints REDUE's from the same input, if you want to add it to yours.`);
|
|
398
|
+
statusLine=host==='claude'&&!other?statusLineFor(invocation.command):null;
|
|
371
399
|
const next={schema:1,host,config:relative,instruction:h.instruction,skill:h.skill,
|
|
372
400
|
block,skillHash:hash(skill),instructionCreated:record?.instructionCreated??before===null};
|
|
373
401
|
changes.push({path:h.skill,before:read(root,h.skill),after:skill},
|
|
374
402
|
{path:h.instruction,before,after});
|
|
375
|
-
if(host==='claude'){const settings=read(root,settingsPath),
|
|
403
|
+
if(host==='claude'){const settings=read(root,settingsPath),turn=turnHooksFor(invocation.command),
|
|
404
|
+
next={PreToolUse:hook,SessionStart:sessionHook,...turn,statusLine};
|
|
376
405
|
changes.push({path:settingsPath,before:settings,after:settingsWith(settings,next),
|
|
377
|
-
content:JSON.stringify(hook?
|
|
406
|
+
content:JSON.stringify({...hook?{PreToolUse:hook}:{},SessionStart:sessionHook,...turn,...statusLine?{statusLine}:{}},null,2)+
|
|
378
407
|
(hook?'':'\nno PreToolUse hook: no typecheck or test check to match')});}
|
|
379
408
|
changes.push({path:marker(host),before:read(root,marker(host)),after:JSON.stringify(next,null,2)+'\n'});
|
|
380
409
|
}else if(record)changes.push(...removalChanges(root,host,record));
|
|
@@ -382,7 +411,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
382
411
|
const summary={schema:1,action,host,config:configFile,applied:false,
|
|
383
412
|
changes:actual.map(c=>({path:c.path,operation:c.after===null?'remove':c.before===null?'create':'update',
|
|
384
413
|
...(action==='setup'?{content:c.path===h.instruction?block:c.content??c.after}: {})})),
|
|
385
|
-
warnings:notes,hooks:host!=='claude'||action!=='setup'?'none':`${settingsPath} ${hook?'PreToolUse, ':''}SessionStart`,behavior_verified:false};
|
|
414
|
+
warnings:notes,hooks:host!=='claude'||action!=='setup'?'none':`${settingsPath} ${hook?'PreToolUse, ':''}SessionStart, UserPromptSubmit, Stop${statusLine?', statusLine':''}`,behavior_verified:false};
|
|
386
415
|
if(!json){console.log(`${action} ${host}: ${configFile}`);
|
|
387
416
|
for(const c of summary.changes)console.log(` ${c.operation} ${c.path}${c.content?'\n'+c.content:''}`);
|
|
388
417
|
if(!actual.length)console.log('Already in the requested state.');
|
|
@@ -433,14 +462,16 @@ export async function doctor({configFile,stateOverride,entry,cwd,host}){
|
|
|
433
462
|
if(executable){const launch=windowsLaunch([executable,'--version']);
|
|
434
463
|
const out=spawnSync(launch.file,launch.args,{...launch.options,encoding:'utf8',timeout:2000,maxBuffer:4096,stdio:['ignore','pipe','pipe']});
|
|
435
464
|
if(out.status===0)version=out.stdout.trim();}
|
|
436
|
-
let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none',sessionHook=hook;
|
|
465
|
+
let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none',sessionHook=hook,statusLineState=hook;
|
|
437
466
|
const installed=()=>hookRunnable(p.root);
|
|
438
467
|
if(p)try{const record=saved(p.root,name);integration=inspect(p.root,name,record);
|
|
439
468
|
if(name==='claude'&&hookInstalled(p.root))hook=installed();
|
|
440
469
|
if(name==='claude'&&hookInstalled(p.root,'SessionStart'))sessionHook=installed();
|
|
470
|
+
if(name==='claude'){let local=null;try{local=parseSettings(read(p.root,settingsPath)).statusLine;}catch{}
|
|
471
|
+
statusLineState=ownedStatusLine(local)?'installed':otherStatusLine(p.root)?'another status line':'absent';}
|
|
441
472
|
value.warnings.push(...warnings(p.root,name,cwd));
|
|
442
473
|
}catch(e){integration={installed:false,integrity:'conflict',reason:e.message};}
|
|
443
|
-
value.hosts.push({host:name,executable,version,...integration,hook,session_start:sessionHook,behavior_verified:false,
|
|
474
|
+
value.hosts.push({host:name,executable,version,...integration,hook,session_start:sessionHook,...name==='claude'?{status_line:statusLineState}:{},behavior_verified:false,
|
|
444
475
|
loading:name==='codex'?'AGENTS.md at session start; .agents/skills; overrides and size limits apply':
|
|
445
476
|
'CLAUDE.md at session start; .claude/skills; managed/user policies and exclusions apply'});
|
|
446
477
|
}
|
package/src/cached-state.mjs
CHANGED
|
@@ -17,7 +17,8 @@ export function unavailableCached(state,reason,checks=[]) {
|
|
|
17
17
|
try{const snapshot=readReceiptSnapshot(state);receipts=snapshot.value.checks;revision=snapshot.revision;}
|
|
18
18
|
catch(error){reason+=`; latest receipt unavailable; prior outcome only: ${error.message}`;
|
|
19
19
|
try{receipts=readReceipts(state).checks;}catch{/* retain cached historical rows */}}
|
|
20
|
-
|
|
20
|
+
// Whether a check can be verified is part of a live answer, not of this one.
|
|
21
|
+
const rows=(value.checks||[]).map(({verifiable,...row})=>{
|
|
21
22
|
const latest=receipts?.[row.name];
|
|
22
23
|
return {...row,...(receipts?{result:latest?.result||null,invocation:latest?.invocation||null,
|
|
23
24
|
target_provenance:latest?.target||null,coverage_at_run:latest?.coverage||null}:{}),
|
|
@@ -25,16 +26,18 @@ export function unavailableCached(state,reason,checks=[]) {
|
|
|
25
26
|
});
|
|
26
27
|
return {...value,receipt_revision:revision,state:'unverified',current:0,stale:0,failed:0,
|
|
27
28
|
observed_failed:rows.filter(row=>row.result==='FAIL').length,
|
|
28
|
-
unverified:Math.max(1,rows.length),checks:rows,
|
|
29
|
+
unverified:Math.max(1,rows.length),not_verifiable:0,checks:rows,
|
|
29
30
|
observation:{...value.observation,healthy:false,reason}};
|
|
30
31
|
}
|
|
31
|
-
// The summary counts of a status answer, from its rows.
|
|
32
|
+
// The summary counts of a status answer, from its rows. A check no run can
|
|
33
|
+
// confirm (verifiable false) is counted apart and does not set the state.
|
|
32
34
|
export function statusSummary(checks) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
+
const counted=checks.filter(r=>r.verifiable!==false);
|
|
36
|
+
const count=test=>counted.filter(test).length,failed=count(r=>r.freshness==='CURRENT'&&r.result==='FAIL'),
|
|
37
|
+
stale=count(r=>r.freshness==='STALE'),unverified=count(r=>r.freshness==='UNVERIFIED')||(!counted.length?1:0);
|
|
35
38
|
return {state:failed?'failed':unverified?'unverified':stale?'stale':'current',
|
|
36
39
|
current:count(r=>r.freshness==='CURRENT'&&r.result==='PASS'),failed,
|
|
37
|
-
observed_failed:
|
|
40
|
+
observed_failed:checks.filter(r=>r.result==='FAIL').length,stale,unverified,not_verifiable:checks.length-counted.length};
|
|
38
41
|
}
|
|
39
42
|
// A stored CURRENT answer is only as old as its publish. Run facts are asked
|
|
40
43
|
// again here, from the receipts the answer was about, so no cached answer says
|
package/src/claude-hook.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// CURRENT, the command becomes `redue run <check>` so the run is recorded. Every
|
|
4
4
|
// other outcome, including errors and timeouts, allows the command unchanged.
|
|
5
5
|
// The same command handles SessionStart: it starts the project's observer.
|
|
6
|
+
// UserPromptSubmit and Stop make the end-of-turn check (turnStart, turnEnd).
|
|
7
|
+
// `redue agent statusline claude` prints the status line (statusLine below).
|
|
6
8
|
// Never exit 2: Claude Code treats exit 2 as a block.
|
|
7
9
|
import fs from 'node:fs';
|
|
8
10
|
import path from 'node:path';
|
|
@@ -16,6 +18,7 @@ import {findExecutable} from './executable-lookup.mjs';
|
|
|
16
18
|
import {note,skipNote,dueNote} from './stats.mjs';
|
|
17
19
|
|
|
18
20
|
export const hookCommand='redue agent hook claude';
|
|
21
|
+
export const statusLineCommand='redue agent statusline claude';
|
|
19
22
|
// No shell metacharacters; `@` starts scoped package names.
|
|
20
23
|
const safe=/^[A-Za-z0-9_./:=,+@-]+$/;
|
|
21
24
|
const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
|
|
@@ -599,12 +602,143 @@ export async function claudeHook(input,{entry,timeout,invoke=null,explain=null})
|
|
|
599
602
|
updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null);
|
|
600
603
|
}
|
|
601
604
|
|
|
605
|
+
// The end-of-turn check. UserPromptSubmit notes the observer's event count
|
|
606
|
+
// and generation for the session, and the checks that were CURRENT
|
|
607
|
+
// (turnStart); Stop (turnEnd) asks status, and when a check is due and files
|
|
608
|
+
// changed this turn (the count moved, a check CURRENT at the start is due now,
|
|
609
|
+
// or the turn's transcript has an Edit, Write, MultiEdit or NotebookEdit
|
|
610
|
+
// call), it
|
|
611
|
+
// asks the agent once to run `redue verify` before it finishes. Claude Code
|
|
612
|
+
// marks the Stop that follows with stop_hook_active, which never asks again;
|
|
613
|
+
// the note that it asked does the same. `"endOfTurnCheck": false` in the
|
|
614
|
+
// config turns it off.
|
|
615
|
+
const turnsFile=state=>path.join(state,'turns-v1.json');
|
|
616
|
+
function readTurns(state){
|
|
617
|
+
try{const value=JSON.parse(fs.readFileSync(turnsFile(state),'utf8'));
|
|
618
|
+
if(value?.schema===1&&value.sessions&&typeof value.sessions==='object'&&!Array.isArray(value.sessions))return value;}catch{}
|
|
619
|
+
return {schema:1,sessions:{}};
|
|
620
|
+
}
|
|
621
|
+
// The 20 sessions noted last.
|
|
622
|
+
function writeTurns(state,value){
|
|
623
|
+
const sessions=Object.fromEntries(Object.entries(value.sessions).sort(([,a],[,b])=>(b?.at??0)-(a?.at??0)).slice(0,20));
|
|
624
|
+
const temp=`${turnsFile(state)}.${process.pid}.tmp`;
|
|
625
|
+
try{fs.writeFileSync(temp,JSON.stringify({schema:1,sessions}),{mode:0o600});fs.renameSync(temp,turnsFile(state));}
|
|
626
|
+
catch{try{fs.rmSync(temp,{force:true});}catch{}}
|
|
627
|
+
}
|
|
628
|
+
const sessionId=input=>typeof input?.session_id==='string'&&/^[A-Za-z0-9_-]{1,128}$/.test(input.session_id)?input.session_id:null;
|
|
629
|
+
// The observer's published status is enough on macOS and Linux. Windows
|
|
630
|
+
// answers from a content reconciliation, not counted events, and its cached
|
|
631
|
+
// status confirms nothing: there the turn starts with a synchronized status.
|
|
632
|
+
export async function turnStart(input,{entry,timeout=5000}={}){
|
|
633
|
+
let project=null;try{project=hookProject(input?.cwd);}catch{}
|
|
634
|
+
const id=sessionId(input);
|
|
635
|
+
if(!project||!id)return null;
|
|
636
|
+
let state;try{state=projectState(project.projectRoot,project.config);}catch{return null;}
|
|
637
|
+
let status=null;
|
|
638
|
+
if(process.platform==='win32'&&entry)status=await synchronizedStatus(entry,project.config,project.projectRoot,timeout);
|
|
639
|
+
else try{status=JSON.parse(fs.readFileSync(path.join(state,'status.json'),'utf8'));}catch{}
|
|
640
|
+
const observation=status?.observation;
|
|
641
|
+
const turns=readTurns(state);
|
|
642
|
+
turns.sessions[id]={at:Date.now(),generation:typeof observation?.generation==='string'?observation.generation:null,
|
|
643
|
+
events:Number.isSafeInteger(observation?.events)?observation.events:null,
|
|
644
|
+
current:observation?.healthy===true&&Array.isArray(status.checks)?status.checks.filter(row=>row?.freshness==='CURRENT').map(row=>row.name):[]};
|
|
645
|
+
writeTurns(state,turns);
|
|
646
|
+
return 'noted';
|
|
647
|
+
}
|
|
648
|
+
const editTools=['Edit','Write','MultiEdit','NotebookEdit'];
|
|
649
|
+
// Whether the transcript's last turn, after the user's last prompt, has a
|
|
650
|
+
// call that edits a file. Its last 4 MB are read.
|
|
651
|
+
export function editedThisTurn(transcript){
|
|
652
|
+
let text='';
|
|
653
|
+
try{const fd=fs.openSync(transcript,'r');
|
|
654
|
+
try{const size=fs.fstatSync(fd).size,length=Math.min(size,4*1024*1024),buffer=Buffer.alloc(length);
|
|
655
|
+
fs.readSync(fd,buffer,0,length,size-length);text=buffer.toString('utf8');}finally{fs.closeSync(fd);}}
|
|
656
|
+
catch{return false;}
|
|
657
|
+
const lines=text.split('\n');
|
|
658
|
+
for(let i=lines.length-1;i>=0;i--){
|
|
659
|
+
let entry;try{entry=JSON.parse(lines[i]);}catch{continue;}
|
|
660
|
+
const content=entry?.message?.content;
|
|
661
|
+
if(entry?.type==='assistant'&&Array.isArray(content)&&content.some(part=>part?.type==='tool_use'&&editTools.includes(part.name)))return true;
|
|
662
|
+
// The user's prompt: text, not a tool's result.
|
|
663
|
+
if(entry?.type==='user'&&!entry.isMeta&&(typeof content==='string'||Array.isArray(content)&&content.length&&content.every(part=>part?.type==='text')))return false;
|
|
664
|
+
}
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
export async function turnEnd(input,{entry,timeout=10000,invoke=null}){
|
|
668
|
+
if(input?.stop_hook_active===true)return null;
|
|
669
|
+
let project=null;try{project=hookProject(input?.cwd);}catch{}
|
|
670
|
+
const id=sessionId(input);
|
|
671
|
+
if(!project||!id||project.parsed.endOfTurnCheck===false)return null;
|
|
672
|
+
let state;try{state=projectState(project.projectRoot,project.config);}catch{return null;}
|
|
673
|
+
const turn=readTurns(state).sessions[id];
|
|
674
|
+
if(turn?.asked)return null;
|
|
675
|
+
const status=await synchronizedStatus(entry,project.config,project.projectRoot,timeout);
|
|
676
|
+
if(status?.observation?.healthy!==true||!Array.isArray(status.checks))return null;
|
|
677
|
+
// A check no run can confirm (verifiable false) is never due.
|
|
678
|
+
const due=status.checks.filter(row=>row?.verifiable!==false&&(row?.freshness==='STALE'||row?.freshness==='UNVERIFIED'));
|
|
679
|
+
if(!due.length)return null;
|
|
680
|
+
const counted=Number.isSafeInteger(turn?.events)&&Number.isSafeInteger(status.observation.events);
|
|
681
|
+
const changed=counted&&(status.observation.events!==turn.events||status.observation.generation!==turn.generation)||
|
|
682
|
+
Array.isArray(turn?.current)&&due.some(row=>turn.current.includes(row.name))||
|
|
683
|
+
typeof input.transcript_path==='string'&&editedThisTurn(input.transcript_path);
|
|
684
|
+
if(!changed)return null;
|
|
685
|
+
const turns=readTurns(state);turns.sessions[id]={...turns.sessions[id],at:Date.now(),asked:true};writeTurns(state,turns);
|
|
686
|
+
const redue=(invoke??redueInvocation(entry))+(project.record.config===defaultConfig?'':` --config ${shellQuote(project.record.config)}`);
|
|
687
|
+
return {decision:'block',reason:`REDUE: files changed this turn, and ${due.length===1?'a check is':`${due.length} checks are`} due: `+
|
|
688
|
+
`${due.map(row=>`${row.name} (${row.freshness})`).join(', ')}. Before you finish, run \`${redue} verify\` and report what it says. `+
|
|
689
|
+
'REDUE asks this once per turn.'};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Claude Code's status line: one short line from a synchronized status,
|
|
693
|
+
// such as "REDUE ✓ 3 verified" or "REDUE ✓ 1 verified · 2 due: typecheck, test".
|
|
694
|
+
export function statusLineText(status,{observerRunning=true}={}){
|
|
695
|
+
if(status?.schema!==1||!Array.isArray(status.checks))return 'REDUE ? status unavailable';
|
|
696
|
+
const observation=status.observation||{};
|
|
697
|
+
if(observation.healthy!==true)return !observerRunning?'REDUE ? observer not running':
|
|
698
|
+
['discovery','indexing','reconciling','validating','initializing'].includes(observation.phase)?'REDUE … checking files':'REDUE ? unverified';
|
|
699
|
+
const names=rows=>rows.map(row=>row.name).join(', ');
|
|
700
|
+
const checks=status.checks.filter(row=>row.verifiable!==false),unverifiable=status.checks.length-checks.length;
|
|
701
|
+
const verified=checks.filter(row=>row.freshness==='CURRENT'&&row.result==='PASS'),
|
|
702
|
+
failing=checks.filter(row=>row.freshness==='CURRENT'&&row.result!=='PASS'),
|
|
703
|
+
due=checks.filter(row=>row.freshness!=='CURRENT');
|
|
704
|
+
const parts=[...verified.length?[`✓ ${verified.length} verified`]:[],...failing.length?[`✗ ${failing.length} failing: ${names(failing)}`]:[],
|
|
705
|
+
...due.length?[`${due.length} due: ${names(due)}`]:[],...unverifiable?[`${unverifiable} not verifiable`]:[]];
|
|
706
|
+
return `REDUE ${parts.join(' · ')||'no checks'}`;
|
|
707
|
+
}
|
|
708
|
+
// The line for the project in the session's directory, or null when REDUE is
|
|
709
|
+
// not set up there. A synchronized status, else the observer's cached one.
|
|
710
|
+
export async function statusLine(input,{entry,timeout=3000}){
|
|
711
|
+
let project=null;
|
|
712
|
+
try{project=hookProject(input?.workspace?.project_dir??input?.cwd);}catch{}
|
|
713
|
+
if(!project)return null;
|
|
714
|
+
const {config,projectRoot}=project;
|
|
715
|
+
const status=await synchronizedStatus(entry,config,projectRoot,timeout)??await synchronizedStatus(entry,config,projectRoot,1000,false);
|
|
716
|
+
let observerRunning=false;
|
|
717
|
+
try{const pid=Number(fs.readFileSync(path.join(projectState(projectRoot,config),'observer.lock','pid'),'utf8'));
|
|
718
|
+
observerRunning=Number.isSafeInteger(pid)&&pid>0&&processAlive(pid);}catch{}
|
|
719
|
+
return statusLineText(status,{observerRunning});
|
|
720
|
+
}
|
|
721
|
+
export async function runStatusLine(entry){
|
|
722
|
+
try{
|
|
723
|
+
let text='';process.stdin.setEncoding('utf8');
|
|
724
|
+
for await(const chunk of process.stdin){text+=chunk;if(text.length>1024*1024)return;}
|
|
725
|
+
const line=await statusLine(JSON.parse(text),{entry});
|
|
726
|
+
if(line)await new Promise(resolve=>process.stdout.write(line+'\n',resolve));
|
|
727
|
+
}catch{}
|
|
728
|
+
}
|
|
729
|
+
|
|
602
730
|
export async function runClaudeHook(entry){
|
|
603
731
|
try{
|
|
604
732
|
let text='';process.stdin.setEncoding('utf8');
|
|
605
733
|
for await(const chunk of process.stdin){text+=chunk;if(text.length>1024*1024)return;}
|
|
606
734
|
const input=JSON.parse(text);
|
|
607
735
|
if(input?.hook_event_name==='SessionStart'){sessionStart(input,{entry});return;}
|
|
736
|
+
if(input?.hook_event_name==='UserPromptSubmit'){await turnStart(input,{entry});return;}
|
|
737
|
+
if(input?.hook_event_name==='Stop'){
|
|
738
|
+
const output=await turnEnd(input,{entry});
|
|
739
|
+
if(output)await new Promise(resolve=>process.stdout.write(JSON.stringify(output)+'\n',resolve));
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
608
742
|
// REDUE_HOOK_LOG (an absolute file): one JSON line per decision, with why.
|
|
609
743
|
const log=process.env.REDUE_HOOK_LOG,started=Date.now();let why=null;
|
|
610
744
|
const output=await claudeHook(input,{entry,explain:text=>{why=text;}});
|
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,docsQualification,endWithChild,hiddenDocs,hiddenEntries,hiddenQualification,hiddenRefusal,hidesIgnored,isSandboxed,runToEnd,supervisorIssue,visibleChange,testRun as sandboxTestRun,sandboxArgv,sandboxContext,sandboxDirs,sandboxEnv,
|
|
11
|
+
import {denialLine,docsQualification,endWithChild,launcherFailure,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';
|
|
@@ -31,7 +31,8 @@ import {readCachedStatus,unavailableCached} from './cached-state.mjs';
|
|
|
31
31
|
import {renderRun} from './presentation.mjs';
|
|
32
32
|
import {note} from './stats.mjs';
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// When the engine finished loading, from the process's start.
|
|
35
|
+
mark('cli.loaded',{sinceStartMs:Math.round(performance.now())});
|
|
35
36
|
const here=path.dirname(fileURLToPath(import.meta.url));
|
|
36
37
|
const [configArg,action,name]=process.argv.slice(2);
|
|
37
38
|
if(!configArg||!action)throw Error('internal usage: cli.mjs CONFIG ACTION [check]');
|
|
@@ -87,8 +88,10 @@ function environmentReads() {
|
|
|
87
88
|
}
|
|
88
89
|
const recordedRecipes=[testsQualification,scriptQualification];
|
|
89
90
|
// A sandboxed check's run is recorded where its sandbox cannot start (execute).
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
// `only`: just that check's (a run's checkpoints answer for its own check).
|
|
92
|
+
function contextHashes(only=null) {const checks=only?config.checks.filter(check=>check.name===only):config.checks;
|
|
93
|
+
const reads=checks.some(check=>recordedRecipes.includes(check.qualification)||isSandboxed(check.qualification))?environmentReads():{};
|
|
94
|
+
return Object.fromEntries(checks.map(check=>[
|
|
92
95
|
check.name,Object.fromEntries([...(check.environment?.variables||[])
|
|
93
96
|
.map(key=>[key,digest(process.env[key]??'<unset>')]),
|
|
94
97
|
...(check.environment?.prefixes||[]).map(prefix=>[
|
|
@@ -163,7 +166,8 @@ async function decisionCall(message,{timeout,deadline}) {
|
|
|
163
166
|
const alive=Number.isSafeInteger(pid)&&pid>0?processAlive(pid):null;
|
|
164
167
|
const diagnostic={at:Date.now(),kind:'control_connection_unavailable',code:error.code,
|
|
165
168
|
pid,alive,remainingMs:remaining(),cached:cached().observation};
|
|
166
|
-
|
|
169
|
+
// Only into state this observer owns: start refuses a directory with other files.
|
|
170
|
+
if(fs.existsSync(ownerFile))try{fs.appendFileSync(path.join(state,'events.jsonl'),JSON.stringify(diagnostic)+'\n');}catch{}
|
|
167
171
|
if(alive){
|
|
168
172
|
// Exactly one reconnect. The sync operation still validates contents;
|
|
169
173
|
// connection recovery by itself is never evidence of applicability.
|
|
@@ -253,7 +257,7 @@ async function execute() {
|
|
|
253
257
|
timing('cli.command_resolution',resolutionStarted);
|
|
254
258
|
const runId=randomUUID();
|
|
255
259
|
const contextStarted=performance.now();
|
|
256
|
-
const environmentBefore=contextHashes()[name];
|
|
260
|
+
const environmentBefore=contextHashes(name)[name];
|
|
257
261
|
const observedContext=selected.qualification===directQualification?{interpretation:directInterpretation,npmProxyDigest:observedNpmContext()}:null;
|
|
258
262
|
timing('cli.context_before',contextStarted);
|
|
259
263
|
const captureWait=decisionWait(process.env.VSTATE_CAPTURE_TIMEOUT_MS);
|
|
@@ -264,9 +268,13 @@ async function execute() {
|
|
|
264
268
|
const callerCertificate=directRunCertificateAllowed(selected);
|
|
265
269
|
const probePlan={cwd:plan.cwd,probes:selected.probes||[],qualification:selected.qualification};
|
|
266
270
|
let probeBefore=callerCertificate?null:probeHash(probePlan);
|
|
271
|
+
// A run the sandbox will run asks for the smaller file map: each
|
|
272
|
+
// installed tree as one entry (the observer's compactFiles).
|
|
273
|
+
const supportCache=path.join(state,'sandbox-support-v1.json');
|
|
274
|
+
const compact=isSandboxed(selected.qualification)&&!sandboxSupport({cache:supportCache}).issue;
|
|
267
275
|
const startSnapshotStarted=performance.now();
|
|
268
|
-
try{before=await call({action:'snapshot',name,contextHashes:
|
|
269
|
-
...(callerCertificate?{runCertificate:1}:{})},captureWait.timeout,captureWait.deadline);}
|
|
276
|
+
try{before=await call({action:'snapshot',name,contextHashes:{[name]:environmentBefore},
|
|
277
|
+
...(compact?{compact:1}:{}),...(callerCertificate?{runCertificate:1}:{})},captureWait.timeout,captureWait.deadline);}
|
|
270
278
|
catch(error){captureIssues.push('start observation unavailable'+
|
|
271
279
|
(typeof error.code==='string'&&/^[A-Z0-9_]+$/.test(error.code)?` (${error.code})`:''));}
|
|
272
280
|
timing('cli.start_checkpoint',startSnapshotStarted,{available:before?.snapshots?.[name]?1:0});
|
|
@@ -326,7 +334,7 @@ async function execute() {
|
|
|
326
334
|
if(general)launchEnv.npm_config_update_notifier='false';
|
|
327
335
|
}
|
|
328
336
|
// The sandboxed run, with the environment REDUE gives it.
|
|
329
|
-
let sandbox=null;
|
|
337
|
+
let sandbox=null;const sandboxSetupStarted=performance.now();
|
|
330
338
|
if(sandboxed&&!fallback){
|
|
331
339
|
if(support.issue)captureIssues.push(`the run was not sandboxed: ${support.issue}`);
|
|
332
340
|
else{
|
|
@@ -352,6 +360,7 @@ async function execute() {
|
|
|
352
360
|
env:sandboxEnv(selected,process.env,{root,...dirs})};
|
|
353
361
|
}
|
|
354
362
|
}
|
|
363
|
+
if(sandbox)timing('cli.sandbox_setup',sandboxSetupStarted,{hidden:sandbox.hidden?.length??0,docs:sandbox.docs?.length??0});
|
|
355
364
|
const plainEnv={...process.env,...selected.script?{npm_config_update_notifier:'false'}:{}};
|
|
356
365
|
// Output kept, and its own process group, for a run as this recipe makes it.
|
|
357
366
|
const own=sandboxed&&!fallback;
|
|
@@ -428,7 +437,7 @@ async function execute() {
|
|
|
428
437
|
invocation.reporting='normal';
|
|
429
438
|
let result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
|
|
430
439
|
const target={source:'direct-command',status:invocation.status==='exited'?'direct_executed':'unknown'};
|
|
431
|
-
const afterContextStarted=performance.now(),environmentAfter=contextHashes()[name];
|
|
440
|
+
const afterContextStarted=performance.now(),environmentAfter=contextHashes(name)[name];
|
|
432
441
|
timing('cli.context_after',afterContextStarted);
|
|
433
442
|
let probeAfter=null;
|
|
434
443
|
const recordText=Buffer.concat(record).toString('utf8');
|
|
@@ -467,7 +476,7 @@ async function execute() {
|
|
|
467
476
|
if(!unobservedDirectRun){
|
|
468
477
|
// The start checkpoint's probe may have raised the deadline.
|
|
469
478
|
const endWait=decisionWait(process.env.VSTATE_CAPTURE_TIMEOUT_MS);
|
|
470
|
-
try{after=await call({action:'snapshot',name,contextHashes:
|
|
479
|
+
try{after=await call({action:'snapshot',name,contextHashes:{[name]:environmentAfter},...(compact?{compact:1}:{}),
|
|
471
480
|
...((testRun||sandboxed)&&Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}),
|
|
472
481
|
...(recording?{runCertificate:1,adopt:recording}:recorded&&!certificate?{runCertificate:1,runFacts:1}:{})},
|
|
473
482
|
endWait.timeout,endWait.deadline);}
|
|
@@ -549,6 +558,10 @@ async function execute() {
|
|
|
549
558
|
// one runs again in the sandbox without hiding them: the check needs
|
|
550
559
|
// them. That run's result is kept, compared on every file, when the
|
|
551
560
|
// inputs held through it too. Its own refusal is handled as below.
|
|
561
|
+
// The Landlock launcher could not sandbox the run: nothing it says is a
|
|
562
|
+
// result, and its kept answer is asked again next time.
|
|
563
|
+
const unsandboxed=sandbox?.mode==='landlock'&&invocation.exitCode!==0?launcherFailure(output.head):null;
|
|
564
|
+
if(unsandboxed){captureIssues.push(`the run was not sandboxed: ${unsandboxed}`);result=null;fs.rmSync(supportCache,{force:true});}
|
|
552
565
|
let hiddenRerun=null;
|
|
553
566
|
if((sandbox?.hidden?.length||sandbox?.docs?.length)&&result==='FAIL'&&!requestedSignal){
|
|
554
567
|
const line=hiddenRefusal(output.head+'\n'+output.tail,[...sandbox.hidden||[],...sandbox.docs||[]]);
|
|
@@ -567,8 +580,8 @@ async function execute() {
|
|
|
567
580
|
else{
|
|
568
581
|
const wait=decisionWait(process.env.VSTATE_CAPTURE_TIMEOUT_MS);
|
|
569
582
|
// Inputs, plan, observer and environment held through it as through the first run.
|
|
570
|
-
let confirmed=null;const environmentNow=contextHashes()[name];
|
|
571
|
-
try{confirmed=await call({action:'snapshot',name,contextHashes:contextHashes(),
|
|
583
|
+
let confirmed=null;const environmentNow=contextHashes(name)[name];
|
|
584
|
+
try{confirmed=await call({action:'snapshot',name,contextHashes:contextHashes(name),
|
|
572
585
|
...Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}},wait.timeout,wait.deadline);}catch{}
|
|
573
586
|
const last=confirmed?.snapshots?.[name];
|
|
574
587
|
if(!last?.observationHealthy||!snap||last.planId!==snap.planId||last.fingerprint!==snap.fingerprint||last.revision!==snap.revision||
|
|
@@ -612,16 +625,18 @@ async function execute() {
|
|
|
612
625
|
}
|
|
613
626
|
}
|
|
614
627
|
// A test run whose network was not supervised throughout is not reused.
|
|
628
|
+
const cleanupStarted=performance.now();
|
|
615
629
|
if(sandbox?.stats){const issue=supervisorIssue(sandbox.stats);if(issue)captureIssues.push(issue);fs.rmSync(sandbox.stats,{force:true});}
|
|
616
|
-
if(sandbox)fs.rmSync(sandbox.dirs.tmp,{recursive:true,force:true});
|
|
630
|
+
if(sandbox){fs.rmSync(sandbox.dirs.tmp,{recursive:true,force:true});timing('cli.sandbox_cleanup',cleanupStarted);}
|
|
617
631
|
const stable=captureIssues.length===0;
|
|
618
632
|
// Two runs on the same inputs, environment and plan that disagree make the
|
|
619
633
|
// check nondeterministic until its inputs change.
|
|
620
634
|
let nondeterministic=false;
|
|
621
635
|
const declared=hashes=>Object.entries(hashes||{}).filter(([key])=>!key.startsWith('__redue_env_'));
|
|
622
636
|
if(sandboxRerun)result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
|
|
637
|
+
const priorReceipt=readReceipts(state).checks[name];
|
|
623
638
|
if((testRun||sandboxed)&&stable&&result){
|
|
624
|
-
const prior=
|
|
639
|
+
const prior=priorReceipt;
|
|
625
640
|
// A difference explained by inputs REDUE did not observe proves nothing.
|
|
626
641
|
nondeterministic=Boolean(!guard?.issues.length&&prior?.stable&&!prior.guardIssues?.length&&prior.result&&prior.planId===(snap?.planId??plan.id)&&
|
|
627
642
|
prior.fingerprint===((recording||outputs?end:snap)?.fingerprint??null)&&
|
|
@@ -634,7 +649,19 @@ async function execute() {
|
|
|
634
649
|
// predate run facts do not reuse version 2.
|
|
635
650
|
// The recipe that made the receipt, which status and the hook name.
|
|
636
651
|
// A sandboxed receipt names what the run did: hid the git-ignored files or not.
|
|
637
|
-
|
|
652
|
+
// A run whose only issue is that inputs changed while it ran keeps the
|
|
653
|
+
// files that did, and how many runs in a row changed the same ones: a
|
|
654
|
+
// suite that rewrites its own inputs does so on every run (selfChange).
|
|
655
|
+
let selfChange=null;
|
|
656
|
+
const eventIssue=captureIssues.find(issue=>issue.startsWith('declared input event occurred during execution'));
|
|
657
|
+
if(eventIssue&&captureIssues.every(issue=>issue===eventIssue||issue==='declared inputs changed or were not observed')){
|
|
658
|
+
const files=[...new Set(unexplained||[])].sort().slice(0,20);
|
|
659
|
+
// Counted only after a passing run of the same plan that changed the same files.
|
|
660
|
+
if(files.length){const prior=priorReceipt?.selfChange;
|
|
661
|
+
selfChange={files,streak:prior&&priorReceipt.result==='PASS'&&priorReceipt.planId===(snap?.planId??plan.id)&&
|
|
662
|
+
JSON.stringify(prior.files)===JSON.stringify(files)?prior.streak+1:1};}
|
|
663
|
+
}
|
|
664
|
+
const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,...selfChange?{selfChange}:{},
|
|
638
665
|
recipe:(fallback?scriptQualification:own?(sandbox?.docs?docsQualification:sandbox?.hidden?hiddenQualification:sandboxQualification):selected.qualification)||null,
|
|
639
666
|
...(recorded||testRun?{runFacts}:{}),
|
|
640
667
|
...(testRun?{verificationRecipe:general?scriptInterpretation:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
|
|
@@ -653,7 +680,8 @@ async function execute() {
|
|
|
653
680
|
...(own?{verificationRecipe:sandboxInterpretationOf(sandbox?.docs?docsQualification:sandbox?.hidden?hiddenQualification:sandboxQualification,selected.kind),
|
|
654
681
|
sandbox:{mode:sandbox?.mode??null,hidden:sandbox?.hidden??null,...sandbox?.docs?{docs:sandbox.docs}:{},...hiddenRerun?{hiddenRerun}:{},...sandboxRerun?{rerun:sandboxRerun}:{}}}:{}),
|
|
655
682
|
...(fallback?{sandbox:{mode:null,fallback:'node-script',issue:support.issue}}:{}),
|
|
656
|
-
|
|
683
|
+
// The form the observer says it returned: an older one ignores the request.
|
|
684
|
+
files:(outputs?end:snap)?.files??null,...(outputs?end:snap)?.filesCompact===true?{filesCompact:true}:{},observerGeneration:before?.observation?.generation??null,
|
|
657
685
|
startRevision:snap?.revision??null,endRevision:end?.revision??null};
|
|
658
686
|
const persistenceStarted=performance.now();
|
|
659
687
|
commitReceipt(state,name,receipt);
|
package/src/daemon.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import {npmCacheDir} from './npm-integrity.mjs';
|
|
|
25
25
|
import {runFromArgv} from './typescript-invocation.mjs';
|
|
26
26
|
import {buildInfoPath,recordedCertificate} from './direct-run-certificate.mjs';
|
|
27
27
|
import {timing,mark,traceBase,traceContext,traceScope,traceEnvironment,traceId} from './decision-profile.mjs';
|
|
28
|
-
import {isSandboxed,sandboxChange,visibleChange as sandboxVisibleChange} from './sandbox.mjs';
|
|
28
|
+
import {compactKey,installedTreeOf,isSandboxed,sandboxChange,visibleChange as sandboxVisibleChange} from './sandbox.mjs';
|
|
29
29
|
|
|
30
30
|
const configFile=path.resolve(process.argv[2]);
|
|
31
31
|
const config=JSON.parse(fs.readFileSync(configFile));
|
|
@@ -338,7 +338,49 @@ function combinedFiles(name) {
|
|
|
338
338
|
Object.entries(entry.index.exportFiles(name)))files[`external:${entry.root}:${rel}`]=hash;
|
|
339
339
|
return files;
|
|
340
340
|
}
|
|
341
|
+
// The files of a sandboxed run's snapshot and receipt, smaller: each
|
|
342
|
+
// installed tree (a node_modules or .venv directory, outermost) as one entry,
|
|
343
|
+
// `<tree>/:installed:`, with a digest of its files and their identities, and
|
|
344
|
+
// every other file as it is. The fingerprint still covers every file; this map
|
|
345
|
+
// names a changed input and decides which changes a hidden run could see. A
|
|
346
|
+
// receipt says it holds this form (filesCompact), and is compared with it.
|
|
347
|
+
// The entry's name is a path inside its tree, so whether a hidden run could
|
|
348
|
+
// see a change to it is decided as for the files it stands for: beneath a
|
|
349
|
+
// hidden entry, or new where git ignores it now.
|
|
350
|
+
const compactCache=new Map();
|
|
351
|
+
function compactFiles(name) {
|
|
352
|
+
const summary=combinedSummary(name),key=`${summary.revision}\0${summary.fingerprint}`;
|
|
353
|
+
const cached=compactCache.get(name);
|
|
354
|
+
if(cached?.key===key)return {...cached.files};
|
|
355
|
+
const out={},trees=new Map();
|
|
356
|
+
for(const [rel,hash] of Object.entries(combinedFiles(name))){
|
|
357
|
+
const tree=installedTreeOf(rel);
|
|
358
|
+
if(tree===null){out[rel]=hash;continue;}
|
|
359
|
+
let list=trees.get(tree);if(!list)trees.set(tree,list=[]);
|
|
360
|
+
list.push([rel,hash]);
|
|
361
|
+
}
|
|
362
|
+
for(const [tree,list] of trees){list.sort(([a],[b])=>a<b?-1:a>b?1:0);out[compactKey(tree)]=sha(JSON.stringify(list));}
|
|
363
|
+
compactCache.set(name,{key,files:out});
|
|
364
|
+
return {...out};
|
|
365
|
+
}
|
|
366
|
+
// The current files in the form receipt `r` holds them.
|
|
367
|
+
const filesFor=(name,r)=>r?.filesCompact===true?compactFiles(name):combinedFiles(name);
|
|
368
|
+
// Whether a receipt's run, which passed, is the second in a row during which
|
|
369
|
+
// the same inputs changed (selfChange), and its plan, environment and every
|
|
370
|
+
// other input are as they were at its start.
|
|
371
|
+
function onlySelfChanged(name,row,r,contextHashes) {
|
|
372
|
+
const self=r.selfChange,context=contextHashes?.[name];
|
|
373
|
+
if(r.result!=='PASS'||!(self?.streak>=2)||!Array.isArray(self.files)||r.planId!==row.plan.id||!r.files||!context)return false;
|
|
374
|
+
for(const [key,value] of Object.entries(context))if(!key.startsWith('__redue_env_')&&r.environmentHashes?.[key]!==value)return false;
|
|
375
|
+
const skip=new Set(self.files),strip=files=>Object.fromEntries(Object.entries(files).filter(([file])=>!skip.has(file)));
|
|
376
|
+
const before=strip(r.files),now=strip(filesFor(name,r)),paths=[...new Set([...Object.keys(before),...Object.keys(now)])];
|
|
377
|
+
if(hiddenRun(r))return sandboxVisibleChange(root,r.sandbox.hidden,before,now,paths,{docs:Array.isArray(r.sandbox.docs)?r.sandbox.docs:null})===null;
|
|
378
|
+
return paths.every(file=>before[file]===now[file]);
|
|
379
|
+
}
|
|
380
|
+
// A changed input as status names it: an installed tree by its path.
|
|
381
|
+
const inputName=file=>file?.endsWith('/:installed:')?file.slice(0,-'/:installed:'.length):file;
|
|
341
382
|
function describeChangedInput(plan,file) {
|
|
383
|
+
if(file.endsWith('/:installed:'))return `${file.slice(0,-'/:installed:'.length)} (installed files) changed`;
|
|
342
384
|
const boundary=[...external.keys()].find(value=>file.startsWith(`external:${value}:`));
|
|
343
385
|
const physical=boundary?path.join(boundary,file.slice(`external:${boundary}:`.length)):
|
|
344
386
|
path.join(root,file);
|
|
@@ -355,7 +397,7 @@ function describeChangedInput(plan,file) {
|
|
|
355
397
|
const hiddenRun=r=>Array.isArray(r?.sandbox?.hidden)&&Boolean(r.files);
|
|
356
398
|
function visibleChange(row,r,name) {
|
|
357
399
|
const key=(r.invocation?.runId||'')+'\0'+r.fingerprint+'\0'+combinedSummary(name).fingerprint;
|
|
358
|
-
if(row.visibleKey!==key){const current=
|
|
400
|
+
if(row.visibleKey!==key){const current=filesFor(name,r);
|
|
359
401
|
row.visibleKey=key;row.visibleChange=sandboxVisibleChange(root,r.sandbox.hidden,r.files,current,[...Object.keys(r.files),...Object.keys(current)],
|
|
360
402
|
{docs:Array.isArray(r.sandbox.docs)?r.sandbox.docs:null});}
|
|
361
403
|
return row.visibleChange;
|
|
@@ -389,6 +431,10 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
389
431
|
`; declared input checkpoint changed (${row.lastReason||'content or membership'})`:'';
|
|
390
432
|
const probeIssue=row.probeError?`; state probe unavailable: ${row.probeError}`:'';
|
|
391
433
|
item.reason='input coverage unresolved: '+row.plan.unresolved.join('; ')+checkpoint+probeIssue;
|
|
434
|
+
// Recording-only (no qualification contract, inputs it cannot resolve):
|
|
435
|
+
// no run can make it CURRENT. A qualified check's unresolved inputs can
|
|
436
|
+
// be fixed, so it stays due.
|
|
437
|
+
if(!row.plan.qualification)item.verifiable=false;
|
|
392
438
|
return item;
|
|
393
439
|
}
|
|
394
440
|
if(row.probeError){item.reason=row.probeError;return item;}
|
|
@@ -410,6 +456,13 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
410
456
|
item.reason=`target execution ${r.target?.status||'unavailable'}`;return item;}
|
|
411
457
|
if(r.target.status==='failed'&&result!=='FAIL'){
|
|
412
458
|
item.reason='target failure conflicts with outer invocation outcome';return item;}
|
|
459
|
+
// The same inputs changed during its last two passing runs, and nothing
|
|
460
|
+
// else changed since: no run of it can be confirmed. Anything else
|
|
461
|
+
// changing makes it due again.
|
|
462
|
+
if(!r.stable&&onlySelfChanged(name,row,r,contextHashes)){
|
|
463
|
+
item.verifiable=false;
|
|
464
|
+
item.reason=`the same inputs changed during its last two runs (${r.selfChange.files.slice(0,5).join(', ')}${r.selfChange.files.length>5?' and more':''}), so no result of it can be confirmed; it is due again when anything else changes`;
|
|
465
|
+
return item;}
|
|
413
466
|
if(!r.stable){const issues=r.checkpoint?.issues?.length?` (${r.checkpoint.issues.join('; ')})`:'';
|
|
414
467
|
// A sandboxed run that could not start, whose result the sandbox changed, or whose rerun outside it did not finish, says so alone.
|
|
415
468
|
const sandbox=r.checkpoint?.issues?.length&&r.checkpoint.issues.every(issue=>/^the (?:sandbox changed|run was not sandboxed|run outside the sandbox|network supervisor)/.test(issue));
|
|
@@ -446,16 +499,16 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
446
499
|
else if(r.fingerprint!==combinedSummary(name).fingerprint&&!(hiddenRun(r)&&visibleChange(row,r,name)===null)) {
|
|
447
500
|
const key=r.fingerprint+'\0'+combinedSummary(name).fingerprint;
|
|
448
501
|
if(row.staleReasonKey!==key&&hiddenRun(r)){
|
|
449
|
-
|
|
502
|
+
const seen=visibleChange(row,r,name);row.staleInputs=[inputName(seen)];row.staleReason=describeChangedInput(row.plan,seen);row.staleReasonKey=key;}
|
|
450
503
|
if(row.staleReasonKey!==key){
|
|
451
504
|
let changed=row.lastReason;row.staleInputs=[];
|
|
452
505
|
const candidate=changed?.endsWith(' changed')?changed.slice(0,-8):null;
|
|
453
|
-
if(candidate&&r.files?.[candidate]!==index.files.get(candidate)){
|
|
506
|
+
if(candidate&&!(r.filesCompact===true&&installedTreeOf(candidate)!==null)&&r.files?.[candidate]!==index.files.get(candidate)){
|
|
454
507
|
changed=describeChangedInput(row.plan,candidate);row.staleInputs=[candidate];}
|
|
455
508
|
else {
|
|
456
|
-
const current=
|
|
509
|
+
const current=filesFor(name,r);
|
|
457
510
|
if(r.files)for(const f of new Set([...Object.keys(r.files),...Object.keys(current)]))
|
|
458
|
-
if(r.files[f]!==current[f]){changed=describeChangedInput(row.plan,f);row.staleInputs=[f];break;}
|
|
511
|
+
if(r.files[f]!==current[f]){changed=describeChangedInput(row.plan,f);row.staleInputs=[inputName(f)];break;}
|
|
459
512
|
}
|
|
460
513
|
row.staleReason=changed||
|
|
461
514
|
(['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
|
|
@@ -500,7 +553,7 @@ function knownStale(name,row) {
|
|
|
500
553
|
const r=receipts[name];
|
|
501
554
|
if(!r?.files||r.planId!==row.plan.id||row.probeError||pendingChecks.has(name)||
|
|
502
555
|
row.probeCache?.input===inputKey(row))return false;
|
|
503
|
-
const current=
|
|
556
|
+
const current=filesFor(name,r),files=Object.keys(current);
|
|
504
557
|
return files.length!==Object.keys(r.files).length||files.some(file=>r.files[file]!==current[file]);
|
|
505
558
|
}
|
|
506
559
|
// A run can rebuild a certificate that no longer applies only because indexed
|
|
@@ -1174,7 +1227,8 @@ async function request(message) {
|
|
|
1174
1227
|
if(message.action==='snapshot'||message.action==='prelaunch') {
|
|
1175
1228
|
data.snapshots={};for(const [name,row] of index?.checks||[])
|
|
1176
1229
|
if(!message.name||message.name===name)
|
|
1177
|
-
data.snapshots[name]={...combinedSummary(name),files:combinedFiles(name),
|
|
1230
|
+
data.snapshots[name]={...combinedSummary(name),files:message.compact===1?compactFiles(name):combinedFiles(name),
|
|
1231
|
+
...message.compact===1?{filesCompact:true}:{},
|
|
1178
1232
|
planGeneration:row.plan.id,
|
|
1179
1233
|
inputEventSerial:inputEventSerial.get(name)||0,
|
|
1180
1234
|
// Complete only when the oldest kept event follows the given serial.
|
package/src/landlock.py
CHANGED
|
@@ -321,6 +321,9 @@ def main(argv):
|
|
|
321
321
|
version, err = abi()
|
|
322
322
|
if version is None:
|
|
323
323
|
sys.exit(f'landlock.py: Landlock unavailable (errno {err})')
|
|
324
|
+
# Below ABI 4 Landlock cannot refuse TCP: a run that must be off the network does not start.
|
|
325
|
+
if no_tcp and version < 4:
|
|
326
|
+
sys.exit(f'landlock.py: Landlock ABI {version} cannot keep a run off the network')
|
|
324
327
|
reading = EXECUTE | READ_FILE | READ_DIR
|
|
325
328
|
handled = reading | (WRITE if deny_write else 0)
|
|
326
329
|
attr = RulesetAttr(handled, (BIND_TCP | CONNECT_TCP) if no_tcp and version >= 4 else 0,
|
package/src/presentation.mjs
CHANGED
|
@@ -6,7 +6,7 @@ const recipes={'npm-typescript-direct-v1':'direct TypeScript recipe','npm-tests-
|
|
|
6
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
|
-
return row.result==='FAIL'?'FAILED':row.freshness;
|
|
9
|
+
return row.verifiable===false?'NOT VERIFIABLE':row.result==='FAIL'?'FAILED':row.freshness;
|
|
10
10
|
}
|
|
11
11
|
export function explanation(row) {
|
|
12
12
|
const reason=row.reason||'No verification evidence is available.';
|
|
@@ -25,11 +25,12 @@ export function renderStatus(value,{short=false}={}) {
|
|
|
25
25
|
const lines=[];
|
|
26
26
|
for(const row of value.checks||[]) {
|
|
27
27
|
const label=checkLabel(row),symbol=label==='CURRENT'&&row.result==='PASS'?'✓':label==='FAILED'?'✗':'?';
|
|
28
|
-
const mark=label==='STALE'?'⚠':symbol;
|
|
28
|
+
const mark=label==='STALE'?'⚠':label==='NOT VERIFIABLE'?'–':symbol;
|
|
29
29
|
const historical=row.result?` / ${row.result}`:'';
|
|
30
30
|
const recipe=label==='CURRENT'?recipeLabel(row.recipe):null;
|
|
31
31
|
lines.push(`${mark} ${row.name} ${label}${historical}${recipe?` (${recipe})`:''}`);
|
|
32
|
-
if(label
|
|
32
|
+
if(label==='NOT VERIFIABLE')lines.push(` Not verifiable: ${explanation(row)}`);
|
|
33
|
+
else if(label!=='CURRENT')lines.push(` ${row.result==='FAIL'?`Historical failure; applicability ${row.freshness}. `:''}${explanation(row)}`);
|
|
33
34
|
}
|
|
34
35
|
if(!lines.length)lines.push('UNVERIFIED — observation is initializing; no check state is available yet.');
|
|
35
36
|
if(!value.observation?.healthy)lines.push('Observation unavailable or pending. Previous results are not reusable.');
|
package/src/sandbox.mjs
CHANGED
|
@@ -100,8 +100,15 @@ export function sandboxEnvironment(python=false,manager=null,kind=null){
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
// Whether this machine can sandbox a run: {mode} or {issue}. Asked once per process.
|
|
103
|
+
// On Linux, `cache` (a file in REDUE's state) keeps a Landlock answer for the
|
|
104
|
+
// same kernel, launcher Python and launcher, so a run does not start Python to
|
|
105
|
+
// ask again. The launcher checks again when it starts a run, and a run it
|
|
106
|
+
// cannot sandbox is not reused (launcherFailure).
|
|
103
107
|
let support=null;
|
|
104
|
-
|
|
108
|
+
const launcherKey=python=>{try{const st=fs.statSync(fs.realpathSync(python),{bigint:true});
|
|
109
|
+
return digest(JSON.stringify([os.release(),python,...['dev','ino','size','mtimeNs'].map(key=>String(st[key])),digest(fs.readFileSync(landlockScript))]));}
|
|
110
|
+
catch{return null;}};
|
|
111
|
+
export function sandboxSupport({cache=null}={}){
|
|
105
112
|
if(support)return support;
|
|
106
113
|
if(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_SANDBOX==='unavailable')
|
|
107
114
|
return support={mode:null,issue:'the sandbox is turned off for this test'};
|
|
@@ -114,11 +121,15 @@ export function sandboxSupport(){
|
|
|
114
121
|
if(process.platform==='linux'){
|
|
115
122
|
const python=launcherPython();
|
|
116
123
|
if(!python)return support={mode:null,issue:'the Landlock launcher needs Python 3'};
|
|
124
|
+
const key=cache?launcherKey(python):null;
|
|
125
|
+
if(key)try{const kept=JSON.parse(fs.readFileSync(cache,'utf8'));
|
|
126
|
+
if(kept?.key===key&&Number.isInteger(kept.abi)&&kept.abi>=4)return support={mode:'landlock',abi:kept.abi,python};}catch{}
|
|
117
127
|
let abi=null;
|
|
118
128
|
try{abi=JSON.parse(spawnSync(python,['-I','-W','ignore',landlockScript,'--probe'],{encoding:'utf8',timeout:10000}).stdout).abi;}catch{}
|
|
119
129
|
if(!Number.isInteger(abi))return support={mode:null,issue:'Landlock is unavailable in this kernel'};
|
|
120
130
|
// ABI 4 (Linux 6.7) is the first that can refuse TCP connections.
|
|
121
131
|
if(abi<4)return support={mode:null,issue:`Landlock ABI ${abi} cannot keep a run off the network; it needs ABI 4 (Linux 6.7)`};
|
|
132
|
+
if(key)try{const temp=`${cache}.${process.pid}.tmp`;fs.writeFileSync(temp,JSON.stringify({key,abi}),{mode:0o600});fs.renameSync(temp,cache);}catch{}
|
|
122
133
|
return support={mode:'landlock',abi,python};
|
|
123
134
|
}
|
|
124
135
|
return support={mode:null,issue:'the sandboxed recipe runs on macOS and Linux only'};
|
|
@@ -267,14 +278,19 @@ export function sandboxDirs(state,name){
|
|
|
267
278
|
// git cannot list them. Only the project's rules count (.gitignore files and
|
|
268
279
|
// .git/info/exclude), not a user's global excludes file, as on CI.
|
|
269
280
|
const installedTree=/(?:^|\/)(?:node_modules|\.venv)(?:\/|$)/;
|
|
281
|
+
// The outermost installed tree a checkout path is in (`a/node_modules`), or
|
|
282
|
+
// null. Never hidden itself; the observer keeps such a tree as one entry in a
|
|
283
|
+
// sandboxed run's files, `<tree>/:installed:` (compactKey).
|
|
284
|
+
export const installedTreeOf=rel=>rel.startsWith('external:')?null:/^(.*?(?:^|\/)(?:node_modules|\.venv))(?:\/|$)/.exec(rel)?.[1]??null;
|
|
285
|
+
export const compactKey=tree=>`${tree}/:installed:`;
|
|
286
|
+
const compactTree=key=>key.endsWith('/:installed:')?key.slice(0,-':installed:'.length):null;
|
|
270
287
|
const runnerCache=picomatch(sandboxCaches,{dot:true});
|
|
271
288
|
const projectGit=['-c','core.excludesFile=/dev/null'],gitEnv=()=>({...process.env,GIT_OPTIONAL_LOCKS:'0'});
|
|
289
|
+
const hiddenArgs=[...projectGit,'ls-files','-z','--others','--ignored','--exclude-standard','--directory'];
|
|
290
|
+
const hiddenOf=r=>r.status!==0?null:r.stdout.split('\0').filter(Boolean).filter(rel=>!installedTree.test(rel)&&
|
|
291
|
+
!runnerCache(rel)&&!runnerCache(rel.replace(/\/$/,'')+'/x'));
|
|
272
292
|
export function hiddenEntries(root){
|
|
273
|
-
|
|
274
|
-
{cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:60000,stdio:['ignore','pipe','ignore'],env:gitEnv()});
|
|
275
|
-
if(r.status!==0)return null;
|
|
276
|
-
return r.stdout.split('\0').filter(Boolean).filter(rel=>!installedTree.test(rel)&&
|
|
277
|
-
!runnerCache(rel)&&!runnerCache(rel.replace(/\/$/,'')+'/x'));
|
|
293
|
+
return hiddenOf(spawnSync('git',hiddenArgs,{cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:60000,stdio:['ignore','pipe','ignore'],env:gitEnv()}));
|
|
278
294
|
}
|
|
279
295
|
|
|
280
296
|
// The documentation files docPatterns names among the files git lists and
|
|
@@ -283,9 +299,11 @@ export function hiddenEntries(root){
|
|
|
283
299
|
// Relative; null when git cannot tell. git filters by pathspec, so a large
|
|
284
300
|
// checkout is not listed whole.
|
|
285
301
|
const docMatch=picomatch(docPatterns,{dot:true});
|
|
302
|
+
const docsArgs=()=>[...projectGit,'ls-files','-z','--cached','--others','--exclude-standard','--',...docPatterns.map(pattern=>`:(glob)${pattern}`)];
|
|
286
303
|
export function hiddenDocs(root){
|
|
287
|
-
|
|
288
|
-
|
|
304
|
+
return docsOf(root,spawnSync('git',docsArgs(),{cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:60000,stdio:['ignore','pipe','ignore'],env:gitEnv()}));
|
|
305
|
+
}
|
|
306
|
+
function docsOf(root,r){
|
|
289
307
|
if(r.status!==0)return null;
|
|
290
308
|
const out=new Set();
|
|
291
309
|
for(const rel of r.stdout.split('\0'))if(rel&&!out.has(rel)&&docMatch(rel))
|
|
@@ -343,7 +361,8 @@ export function visibleChange(root,hidden,before,current,paths,{during=false,doc
|
|
|
343
361
|
const covered=changed.filter(beneath),fresh=changed.filter(file=>!(file in before)&&!beneath(file));
|
|
344
362
|
if(covered.length){
|
|
345
363
|
const tracked=trackedBeneath(root,entries.filter(entry=>covered.some(file=>file===entry||file.startsWith(entry+'/'))));
|
|
346
|
-
|
|
364
|
+
// An installed tree kept as one entry counts when git tracks any file in it.
|
|
365
|
+
const found=covered.find(file=>!tracked||tracked.has(file)||compactTree(file)!==null&&[...tracked].some(rel=>rel.startsWith(compactTree(file))));
|
|
347
366
|
if(found)return found;
|
|
348
367
|
}
|
|
349
368
|
if(!fresh.length)return null;
|
|
@@ -556,6 +575,11 @@ export function stopGroup(pid){
|
|
|
556
575
|
export function endWithChild(child){
|
|
557
576
|
child.on('exit',()=>{stopGroup(child.pid);setTimeout(()=>{child.stdout?.destroy();child.stderr?.destroy();},1000).unref();});
|
|
558
577
|
}
|
|
578
|
+
// The launcher's own line when it could not sandbox a run (Landlock
|
|
579
|
+
// unavailable, or a rule it could not add), or null.
|
|
580
|
+
// Only its refusals before the command starts, and only as the run's whole output.
|
|
581
|
+
export const launcherFailure=text=>/^landlock\.py: (?:Landlock unavailable|Landlock ABI \d+ cannot|create_ruleset|add_rule|no_new_privs|restrict_self)[^\n]*$/
|
|
582
|
+
.exec(String(text).trim())?.[0].slice(0,200)??null;
|
|
559
583
|
// Runs argv in its own process group to the end, with no terminal input:
|
|
560
584
|
// {status, signal, error, timedOut, stdout, stderr}, each output kept up to
|
|
561
585
|
// 64 MB and decoded at the end. `echo` also passes the output through.
|