@redue/cli 0.1.0-alpha.31 → 0.1.0-alpha.33
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 +81 -0
- package/README.md +117 -43
- package/bin/redue.mjs +82 -3
- package/docs/user/agents.md +38 -1
- package/docs/user/configuration.md +8 -0
- package/docs/user/direct-typescript.md +3 -2
- package/docs/user/json.md +18 -0
- package/docs/user/sandbox.md +7 -3
- package/package.json +1 -1
- package/src/agent-integration.mjs +61 -17
- package/src/cached-state.mjs +9 -6
- package/src/claude-hook.mjs +185 -5
- 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/python-onboarding.mjs +54 -12
- package/src/sandbox.mjs +33 -9
|
@@ -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,triggerRule,uncoveredForms} 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'},
|
|
@@ -21,29 +22,47 @@ const marker=host=>`.redue/agents/${host}.json`;
|
|
|
21
22
|
// The hook needs a local REDUE install, so it lives in per-developer settings.
|
|
22
23
|
const settingsPath='.claude/settings.local.json';
|
|
23
24
|
const sameJson=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
|
|
24
|
-
// One handler per
|
|
25
|
+
// One handler per command form (triggerRule: up to its first quote or other
|
|
26
|
+
// character a rule cannot hold); `if` keeps other Bash calls from starting Node.
|
|
25
27
|
// The hook waits for status up to 10 s; 15 s leaves room for starting Node.
|
|
26
28
|
// Fan-out commands (turbo, nx, `pnpm -r`) and root scripts that run one get
|
|
27
29
|
// prefix rules; the hook decides from the full command.
|
|
28
30
|
function hookFor(config,relative,scripts,command=hookCommand){
|
|
29
31
|
const rules=[...new Set([...hookForms(config,relative,scripts).flatMap(row=>[...row.forms,...row.local]),
|
|
30
|
-
...fanoutRules(config,scripts)]
|
|
32
|
+
...fanoutRules(config,scripts)].map(triggerRule).filter(Boolean))];
|
|
31
33
|
return rules.length?{matcher:'Bash',hooks:rules.map(rule=>({type:'command',if:rule,command,timeout:15}))}:null;
|
|
32
34
|
}
|
|
33
35
|
// The same command on SessionStart starts the observer; it needs no check to match.
|
|
34
36
|
const sessionHookFor=(command=hookCommand)=>({hooks:[{type:'command',command,timeout:10}]});
|
|
35
|
-
|
|
37
|
+
// And makes the end-of-turn check: UserPromptSubmit notes where the turn
|
|
38
|
+
// starts, Stop asks status (up to 10 s).
|
|
39
|
+
const turnHooksFor=(command=hookCommand)=>({UserPromptSubmit:{hooks:[{type:'command',command,timeout:10}]},
|
|
40
|
+
Stop:{hooks:[{type:'command',command,timeout:20}]}});
|
|
41
|
+
const events=['PreToolUse','SessionStart','UserPromptSubmit','Stop'];
|
|
36
42
|
// 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){
|
|
43
|
+
// writes it. [node, entry] or null. `use`: hook, or statusline.
|
|
44
|
+
function absoluteHook(command,use='hook'){
|
|
39
45
|
const word="'((?:[^']|'\\\\'')*)'";
|
|
40
|
-
const match=new RegExp(`^${word} ${word} agent
|
|
46
|
+
const match=new RegExp(`^${word} ${word} agent ${use} claude$`).exec(command??'');
|
|
41
47
|
if(!match)return null;
|
|
42
48
|
const [node,entry]=[match[1],match[2]].map(value=>value.replaceAll("'\\''","'"));
|
|
43
49
|
// REDUE's entry file, or the bin link npm installs for it.
|
|
44
50
|
return /^redue(\.mjs)?$/.test(path.basename(entry))?[node,entry]:null;
|
|
45
51
|
}
|
|
46
52
|
export const isHookCommand=command=>command===hookCommand||Boolean(absoluteHook(command));
|
|
53
|
+
// REDUE's status line, by its command in either form.
|
|
54
|
+
const ownedStatusLine=value=>value?.type==='command'&&(value.command===statusLineCommand||Boolean(absoluteHook(value.command,'statusline')));
|
|
55
|
+
const statusLineFor=command=>({type:'command',command:command.replace(/ agent hook claude$/,' agent statusline claude'),padding:0});
|
|
56
|
+
// Where Claude Code already has a status line that is not REDUE's, which
|
|
57
|
+
// setup leaves alone: the local or shared project settings, or the user's.
|
|
58
|
+
function otherStatusLine(root){
|
|
59
|
+
const user=path.join(process.env.CLAUDE_CONFIG_DIR||path.join(os.homedir(),'.claude'),'settings.json');
|
|
60
|
+
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;}})()]]){
|
|
61
|
+
let value=null;try{value=JSON.parse(text);}catch{}
|
|
62
|
+
if(value?.statusLine&&!ownedStatusLine(value.statusLine))return label;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
47
66
|
// The command Claude Code runs for the hook. Plain `redue` when this install
|
|
48
67
|
// is the one both this shell and a login shell find on PATH. Otherwise this
|
|
49
68
|
// install's Node and entry by absolute path, so a Claude Code started from
|
|
@@ -82,10 +101,15 @@ function parseSettings(text){
|
|
|
82
101
|
return value;
|
|
83
102
|
}
|
|
84
103
|
// Replace REDUE's entry for each event with next[event] (or remove it; a null
|
|
85
|
-
// `next` removes them all)
|
|
86
|
-
//
|
|
104
|
+
// `next` removes them all), and REDUE's status line with next.statusLine. A
|
|
105
|
+
// status line that is not REDUE's stays. Unchanged input returns the same
|
|
106
|
+
// text, so idempotent setup does not rewrite the file.
|
|
87
107
|
function settingsWith(text,next){
|
|
88
108
|
const value=parseSettings(text);let changed=false;
|
|
109
|
+
if(value.statusLine===undefined||ownedStatusLine(value.statusLine)){
|
|
110
|
+
const line=next?.statusLine??undefined;
|
|
111
|
+
if(!sameJson(line,value.statusLine)){changed=true;if(line)value.statusLine=line;else delete value.statusLine;}
|
|
112
|
+
}
|
|
89
113
|
for(const event of events){
|
|
90
114
|
const old=value.hooks?.[event]||[],at=old.findIndex(ownedHook),list=old.filter(entry=>!ownedHook(entry));
|
|
91
115
|
if(next?.[event])list.splice(at<0?list.length:at,0,next[event]);
|
|
@@ -240,7 +264,7 @@ const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${ho
|
|
|
240
264
|
// varies, and only in the safe character set. Earlier owned versions are
|
|
241
265
|
// recognized so setup can replace them; other stored text never is.
|
|
242
266
|
function knownBlock(host,relative,block){
|
|
243
|
-
const list='((?:`[A-Za-z0-9_./:=,+@ -]+`(?: and )?)+)';
|
|
267
|
+
const list='((?:`[A-Za-z0-9_./:=,+@ "\'()-]+`(?: and )?)+)';
|
|
244
268
|
const forms=pattern=>{const named=new RegExp(pattern).exec(block);return named?[...named[1].matchAll(/`([^`]+)`/g)].map(m=>m[1]):[];};
|
|
245
269
|
const rows=[...forms(`^\\n\\n<!-- REDUE:[a-z]+:begin -->\\nTypecheck with ${list}\\.`).map(form=>({kind:'typecheck',form})),
|
|
246
270
|
...forms(`Run the unit tests with ${list}\\.\\n`).map(form=>({kind:'test',form})),
|
|
@@ -345,7 +369,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
345
369
|
if(value.state_access)console.log(`State access: ${value.state_access.status}${value.state_access.reason?' — '+value.state_access.reason:''}`);
|
|
346
370
|
if(value.compatibility?.reason)console.log(`Observer compatibility: ${value.compatibility.reason}`);
|
|
347
371
|
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:''}`);
|
|
372
|
+
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}; trigger rules ${!row.trigger_rules?(row.hook==='absent'?'not installed':'not checked'):row.trigger_rules.covered?'match every check form (as REDUE models Claude Code)':`miss ${row.trigger_rules.count} check form${row.trigger_rules.count>1?'s':''}`}`:''}; behavioral validation NOT VERIFIED by doctor${row.reason?' — '+row.reason:''}`);
|
|
349
373
|
console.log('Doctor does not start observers, execute checks, or prove agent behavior.');
|
|
350
374
|
for(const warning of value.warnings)console.log(`Review: ${warning}`);
|
|
351
375
|
}return value;
|
|
@@ -354,7 +378,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
354
378
|
const {root,relative,config}=project(configFile),h=hosts[host],record=saved(root,host),health=inspect(root,host,record);
|
|
355
379
|
if(health.integrity==='conflict')throw Error(health.reason);
|
|
356
380
|
const changes=[],notes=action==='setup'?warnings(root,host,cwd):[];
|
|
357
|
-
let hook=null,sessionHook=null,block=null;
|
|
381
|
+
let hook=null,sessionHook=null,statusLine=null,block=null;
|
|
358
382
|
if(action==='setup'){
|
|
359
383
|
const before=read(root,h.instruction),skill=skillFor(relative,host,config);
|
|
360
384
|
block=blockFor(host,blockForms(host,config,relative));
|
|
@@ -368,13 +392,19 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
368
392
|
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
393
|
hook=host==='claude'?hookFor(config,relative,scripts,invocation.command):null;
|
|
370
394
|
sessionHook=host==='claude'?sessionHookFor(invocation.command):null;
|
|
395
|
+
// The status line, unless Claude Code already shows another one.
|
|
396
|
+
const other=host==='claude'?otherStatusLine(root):null;
|
|
397
|
+
if(other)notes.push(`Claude Code already has a status line (${other}); REDUE did not replace it. `+
|
|
398
|
+
`\`${statusLineFor(invocation.command).command}\` prints REDUE's from the same input, if you want to add it to yours.`);
|
|
399
|
+
statusLine=host==='claude'&&!other?statusLineFor(invocation.command):null;
|
|
371
400
|
const next={schema:1,host,config:relative,instruction:h.instruction,skill:h.skill,
|
|
372
401
|
block,skillHash:hash(skill),instructionCreated:record?.instructionCreated??before===null};
|
|
373
402
|
changes.push({path:h.skill,before:read(root,h.skill),after:skill},
|
|
374
403
|
{path:h.instruction,before,after});
|
|
375
|
-
if(host==='claude'){const settings=read(root,settingsPath),
|
|
404
|
+
if(host==='claude'){const settings=read(root,settingsPath),turn=turnHooksFor(invocation.command),
|
|
405
|
+
next={PreToolUse:hook,SessionStart:sessionHook,...turn,statusLine};
|
|
376
406
|
changes.push({path:settingsPath,before:settings,after:settingsWith(settings,next),
|
|
377
|
-
content:JSON.stringify(hook?
|
|
407
|
+
content:JSON.stringify({...hook?{PreToolUse:hook}:{},SessionStart:sessionHook,...turn,...statusLine?{statusLine}:{}},null,2)+
|
|
378
408
|
(hook?'':'\nno PreToolUse hook: no typecheck or test check to match')});}
|
|
379
409
|
changes.push({path:marker(host),before:read(root,marker(host)),after:JSON.stringify(next,null,2)+'\n'});
|
|
380
410
|
}else if(record)changes.push(...removalChanges(root,host,record));
|
|
@@ -382,7 +412,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
382
412
|
const summary={schema:1,action,host,config:configFile,applied:false,
|
|
383
413
|
changes:actual.map(c=>({path:c.path,operation:c.after===null?'remove':c.before===null?'create':'update',
|
|
384
414
|
...(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};
|
|
415
|
+
warnings:notes,hooks:host!=='claude'||action!=='setup'?'none':`${settingsPath} ${hook?'PreToolUse, ':''}SessionStart, UserPromptSubmit, Stop${statusLine?', statusLine':''}`,behavior_verified:false};
|
|
386
416
|
if(!json){console.log(`${action} ${host}: ${configFile}`);
|
|
387
417
|
for(const c of summary.changes)console.log(` ${c.operation} ${c.path}${c.content?'\n'+c.content:''}`);
|
|
388
418
|
if(!actual.length)console.log('Already in the requested state.');
|
|
@@ -433,14 +463,28 @@ export async function doctor({configFile,stateOverride,entry,cwd,host}){
|
|
|
433
463
|
if(executable){const launch=windowsLaunch([executable,'--version']);
|
|
434
464
|
const out=spawnSync(launch.file,launch.args,{...launch.options,encoding:'utf8',timeout:2000,maxBuffer:4096,stdio:['ignore','pipe','pipe']});
|
|
435
465
|
if(out.status===0)version=out.stdout.trim();}
|
|
436
|
-
let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none',sessionHook=hook;
|
|
466
|
+
let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none',sessionHook=hook,statusLineState=hook,triggers=null;
|
|
437
467
|
const installed=()=>hookRunnable(p.root);
|
|
438
468
|
if(p)try{const record=saved(p.root,name);integration=inspect(p.root,name,record);
|
|
439
469
|
if(name==='claude'&&hookInstalled(p.root))hook=installed();
|
|
440
470
|
if(name==='claude'&&hookInstalled(p.root,'SessionStart'))sessionHook=installed();
|
|
471
|
+
// Every check's command forms must start the hook: a form no trigger
|
|
472
|
+
// rule matches runs plainly, neither skipped nor recorded.
|
|
473
|
+
if(name==='claude'&&hookInstalled(p.root)){
|
|
474
|
+
let scripts={};try{scripts=JSON.parse(read(p.root,'package.json')??'{}').scripts||{};}catch{}
|
|
475
|
+
// REDUE's entries for Bash: a hook without `if` starts for every command.
|
|
476
|
+
const rules=(parseSettings(read(p.root,settingsPath)).hooks?.PreToolUse||[]).filter(entry=>ownedHook(entry)&&['Bash','*','',undefined].includes(entry.matcher))
|
|
477
|
+
.flatMap(entry=>entry.hooks.map(h=>typeof h.if==='string'?h.if:'Bash'));
|
|
478
|
+
const missing=uncoveredForms(rules,p.config,p.relative,scripts);
|
|
479
|
+
triggers=missing.length?{covered:false,missing:missing.slice(0,10),count:missing.length}:{covered:true};
|
|
480
|
+
if(missing.length)value.warnings.push(`The hook's trigger rules do not match ${missing.length} command form${missing.length>1?'s':''} of configured checks `+
|
|
481
|
+
`(such as ${missing[0]}): those runs are neither skipped nor recorded. Run redue agent setup claude --apply again.`);
|
|
482
|
+
}
|
|
483
|
+
if(name==='claude'){let local=null;try{local=parseSettings(read(p.root,settingsPath)).statusLine;}catch{}
|
|
484
|
+
statusLineState=ownedStatusLine(local)?'installed':otherStatusLine(p.root)?'another status line':'absent';}
|
|
441
485
|
value.warnings.push(...warnings(p.root,name,cwd));
|
|
442
486
|
}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,
|
|
487
|
+
value.hosts.push({host:name,executable,version,...integration,hook,session_start:sessionHook,...name==='claude'?{status_line:statusLineState,trigger_rules:triggers}:{},behavior_verified:false,
|
|
444
488
|
loading:name==='codex'?'AGENTS.md at session start; .agents/skills; overrides and size limits apply':
|
|
445
489
|
'CLAUDE.md at session start; .claude/skills; managed/user policies and exclusions apply'});
|
|
446
490
|
}
|
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,8 +18,11 @@ 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_./:=,+@-]+$/;
|
|
24
|
+
// Those characters and a space: what a trigger rule may hold (triggerRule).
|
|
25
|
+
const ruleText=/^[A-Za-z0-9_./:=,+@ -]*$/,ruleStop=/[^A-Za-z0-9_./:=,+@ -]/;
|
|
21
26
|
const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
|
|
22
27
|
// A direct test check: its command is the runner and the script's arguments.
|
|
23
28
|
const tests=check=>check?.qualification==='npm-tests-direct-v1'&&Array.isArray(check.command)&&
|
|
@@ -28,8 +33,9 @@ const general=check=>(check?.qualification==='node-script-v1'&&['typecheck','tes
|
|
|
28
33
|
// A sandboxed check: a root package script, or a tool from the project's .venv.
|
|
29
34
|
// sandbox.mjs's isSandboxed, written out: the hook does not load the sandbox module.
|
|
30
35
|
const sandboxed=check=>['sandbox-v1','sandbox-v2','sandbox-v3'].includes(check?.qualification)&&['typecheck','lint','test'].includes(check.kind);
|
|
36
|
+
// An argument may hold spaces and parentheses (`-m "not slow"`): the forms quote it.
|
|
31
37
|
const python=check=>sandboxed(check)&&Array.isArray(check.command)&&/^\.venv\/bin\/[A-Za-z0-9_.-]+$/.test(check.command[0]||'')&&
|
|
32
|
-
check.command.every(word=>typeof word==='string'&&safe.test(word));
|
|
38
|
+
check.command.every((word,i)=>typeof word==='string'&&(safe.test(word)||i>0&&/^[A-Za-z0-9_./:=,@+% ()-]+$/.test(word)&&/\S/.test(word)));
|
|
33
39
|
const eligibleCheck=check=>typecheck(check)||tests(check)||general(check)||python(check);
|
|
34
40
|
export const checkKind=check=>tests(check)?'test':general(check)||python(check)?check.kind:'typecheck';
|
|
35
41
|
// The ways an agent runs a Python check's tool: through uv or Poetry, with
|
|
@@ -37,11 +43,45 @@ export const checkKind=check=>tests(check)?'test':general(check)||python(check)?
|
|
|
37
43
|
// or the tool's name depends on PATH, so the hook takes it only when PATH
|
|
38
44
|
// finds that program in the project's .venv/bin first (pathForm).
|
|
39
45
|
const wrappers=['uv run ','poetry run '];
|
|
46
|
+
// The `if` rules setup writes start the hook for a check's command forms.
|
|
47
|
+
// As REDUE models Claude Code's matching (doctor's check; a stranger session
|
|
48
|
+
// confirms it for real): `Bash(PREFIX*)` (or the legacy `Bash(PREFIX:*)`)
|
|
49
|
+
// matches a simple command that starts with PREFIX, `Bash(TEXT)` exactly
|
|
50
|
+
// TEXT, and plain `Bash` every command; compound commands are matched part by
|
|
51
|
+
// part. Only the characters in ruleText are known to match in real sessions:
|
|
52
|
+
// a rule with a quote in it never started the hook (alpha.32, `-m "not
|
|
53
|
+
// slow"`). So a rule ends before the first other character; it may start the
|
|
54
|
+
// hook for more commands, and the hook then matches each exactly. A rule
|
|
55
|
+
// that would end before the program's first argument is not written: it
|
|
56
|
+
// would start the hook for every run of the program.
|
|
57
|
+
export function triggerRule(form){
|
|
58
|
+
const cut=form.search(ruleStop),prefix=cut<0?form:form.slice(0,cut);
|
|
59
|
+
return cut<0||/^\S+ \S/.test(prefix)?`Bash(${prefix}*)`:null;
|
|
60
|
+
}
|
|
61
|
+
// Whether a rule matches a simple command, as modeled above.
|
|
62
|
+
export function triggerMatches(rule,command){
|
|
63
|
+
if(typeof rule!=='string'||typeof command!=='string')return false;
|
|
64
|
+
if(rule==='Bash')return true;
|
|
65
|
+
const body=/^Bash\((.*)\)$/s.exec(rule)?.[1];
|
|
66
|
+
if(body===undefined)return false;
|
|
67
|
+
const prefix=body.endsWith(':*')?body.slice(0,-2):body.endsWith('*')?body.slice(0,-1):null;
|
|
68
|
+
if(prefix!==null)return ruleText.test(prefix)&&command.startsWith(prefix);
|
|
69
|
+
return ruleText.test(body)&&command===body;
|
|
70
|
+
}
|
|
71
|
+
// The command forms of configured checks (as typed at the root, after a cd
|
|
72
|
+
// into a check's directory, and the fan-out commands that run them) that no
|
|
73
|
+
// rule in `rules` matches.
|
|
74
|
+
export function uncoveredForms(rules,config,configFile=defaultConfig,packageScripts=null){
|
|
75
|
+
return [...new Set([...hookForms(config,configFile,packageScripts).flatMap(row=>[...row.forms,...row.local]),
|
|
76
|
+
...fanoutRules(config,packageScripts||{})])].filter(form=>!rules.some(rule=>triggerMatches(rule,form)));
|
|
77
|
+
}
|
|
78
|
+
// An argument with a space (`-m "not slow"`) is written quoted, in double and in single quotes.
|
|
40
79
|
function pythonForms(check){
|
|
41
|
-
const [file,...args]=check.command,tool=file.split('/').at(-1)
|
|
42
|
-
|
|
80
|
+
const [file,...args]=check.command,tool=file.split('/').at(-1);
|
|
81
|
+
const rests=[...new Set(['"',"'"].map(q=>args.length?' '+args.map(arg=>/\s/.test(arg)?`${q}${arg}${q}`:arg).join(' '):''))];
|
|
82
|
+
return rests.flatMap(rest=>[`.venv/bin/${tool}${rest}`,`./.venv/bin/${tool}${rest}`,`.venv/bin/python -m ${tool}${rest}`,
|
|
43
83
|
...wrappers.flatMap(wrapper=>[`${wrapper}${tool}${rest}`,`${wrapper}python -m ${tool}${rest}`]),
|
|
44
|
-
`python -m ${tool}${rest}`,`python3 -m ${tool}${rest}`,`${tool}${rest}`];
|
|
84
|
+
`python -m ${tool}${rest}`,`python3 -m ${tool}${rest}`,`${tool}${rest}`]);
|
|
45
85
|
}
|
|
46
86
|
// For a form that names a program PATH finds (`mypy .`, `python -m mypy .`),
|
|
47
87
|
// whether PATH finds it first in the project's .venv/bin.
|
|
@@ -286,7 +326,16 @@ function split(command){
|
|
|
286
326
|
if(typeof command!=='string'||/[\r\n]/.test(command))return null;
|
|
287
327
|
const [,base,suffix,rest=null]=command.trim()
|
|
288
328
|
.match(/^(.*?)((?:[ \t]+2>&1)?(?:[ \t]+\|[ \t]+(?:tail|head)[ \t]+(?:-n[ \t]+\d+|-\d+))?)(?:[ \t]*(?:;|&&)[ \t]*([^\s;&|].*))?$/);
|
|
289
|
-
|
|
329
|
+
// Runs of spaces are one, but not inside quotes: `-m "not slow"` is not `-m "not slow"`.
|
|
330
|
+
const one=text=>{let out='',quote=null,space=false;
|
|
331
|
+
for(const c of text){
|
|
332
|
+
if(quote){out+=c;if(c===quote)quote=null;continue;}
|
|
333
|
+
if(c===' '||c==='\t'){space=true;continue;}
|
|
334
|
+
if(space){out+=' ';space=false;}
|
|
335
|
+
if(c==='"'||c==="'")quote=c;
|
|
336
|
+
out+=c;
|
|
337
|
+
}
|
|
338
|
+
return out+(space?' ':'');};
|
|
290
339
|
return {base:one(base),suffix:one(suffix),rest};
|
|
291
340
|
}
|
|
292
341
|
// {check, rest}, or {fanout, rest} for a turbo/nx/`pnpm -r` command or a root
|
|
@@ -599,12 +648,143 @@ export async function claudeHook(input,{entry,timeout,invoke=null,explain=null})
|
|
|
599
648
|
updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null);
|
|
600
649
|
}
|
|
601
650
|
|
|
651
|
+
// The end-of-turn check. UserPromptSubmit notes the observer's event count
|
|
652
|
+
// and generation for the session, and the checks that were CURRENT
|
|
653
|
+
// (turnStart); Stop (turnEnd) asks status, and when a check is due and files
|
|
654
|
+
// changed this turn (the count moved, a check CURRENT at the start is due now,
|
|
655
|
+
// or the turn's transcript has an Edit, Write, MultiEdit or NotebookEdit
|
|
656
|
+
// call), it
|
|
657
|
+
// asks the agent once to run `redue verify` before it finishes. Claude Code
|
|
658
|
+
// marks the Stop that follows with stop_hook_active, which never asks again;
|
|
659
|
+
// the note that it asked does the same. `"endOfTurnCheck": false` in the
|
|
660
|
+
// config turns it off.
|
|
661
|
+
const turnsFile=state=>path.join(state,'turns-v1.json');
|
|
662
|
+
function readTurns(state){
|
|
663
|
+
try{const value=JSON.parse(fs.readFileSync(turnsFile(state),'utf8'));
|
|
664
|
+
if(value?.schema===1&&value.sessions&&typeof value.sessions==='object'&&!Array.isArray(value.sessions))return value;}catch{}
|
|
665
|
+
return {schema:1,sessions:{}};
|
|
666
|
+
}
|
|
667
|
+
// The 20 sessions noted last.
|
|
668
|
+
function writeTurns(state,value){
|
|
669
|
+
const sessions=Object.fromEntries(Object.entries(value.sessions).sort(([,a],[,b])=>(b?.at??0)-(a?.at??0)).slice(0,20));
|
|
670
|
+
const temp=`${turnsFile(state)}.${process.pid}.tmp`;
|
|
671
|
+
try{fs.writeFileSync(temp,JSON.stringify({schema:1,sessions}),{mode:0o600});fs.renameSync(temp,turnsFile(state));}
|
|
672
|
+
catch{try{fs.rmSync(temp,{force:true});}catch{}}
|
|
673
|
+
}
|
|
674
|
+
const sessionId=input=>typeof input?.session_id==='string'&&/^[A-Za-z0-9_-]{1,128}$/.test(input.session_id)?input.session_id:null;
|
|
675
|
+
// The observer's published status is enough on macOS and Linux. Windows
|
|
676
|
+
// answers from a content reconciliation, not counted events, and its cached
|
|
677
|
+
// status confirms nothing: there the turn starts with a synchronized status.
|
|
678
|
+
export async function turnStart(input,{entry,timeout=5000}={}){
|
|
679
|
+
let project=null;try{project=hookProject(input?.cwd);}catch{}
|
|
680
|
+
const id=sessionId(input);
|
|
681
|
+
if(!project||!id)return null;
|
|
682
|
+
let state;try{state=projectState(project.projectRoot,project.config);}catch{return null;}
|
|
683
|
+
let status=null;
|
|
684
|
+
if(process.platform==='win32'&&entry)status=await synchronizedStatus(entry,project.config,project.projectRoot,timeout);
|
|
685
|
+
else try{status=JSON.parse(fs.readFileSync(path.join(state,'status.json'),'utf8'));}catch{}
|
|
686
|
+
const observation=status?.observation;
|
|
687
|
+
const turns=readTurns(state);
|
|
688
|
+
turns.sessions[id]={at:Date.now(),generation:typeof observation?.generation==='string'?observation.generation:null,
|
|
689
|
+
events:Number.isSafeInteger(observation?.events)?observation.events:null,
|
|
690
|
+
current:observation?.healthy===true&&Array.isArray(status.checks)?status.checks.filter(row=>row?.freshness==='CURRENT').map(row=>row.name):[]};
|
|
691
|
+
writeTurns(state,turns);
|
|
692
|
+
return 'noted';
|
|
693
|
+
}
|
|
694
|
+
const editTools=['Edit','Write','MultiEdit','NotebookEdit'];
|
|
695
|
+
// Whether the transcript's last turn, after the user's last prompt, has a
|
|
696
|
+
// call that edits a file. Its last 4 MB are read.
|
|
697
|
+
export function editedThisTurn(transcript){
|
|
698
|
+
let text='';
|
|
699
|
+
try{const fd=fs.openSync(transcript,'r');
|
|
700
|
+
try{const size=fs.fstatSync(fd).size,length=Math.min(size,4*1024*1024),buffer=Buffer.alloc(length);
|
|
701
|
+
fs.readSync(fd,buffer,0,length,size-length);text=buffer.toString('utf8');}finally{fs.closeSync(fd);}}
|
|
702
|
+
catch{return false;}
|
|
703
|
+
const lines=text.split('\n');
|
|
704
|
+
for(let i=lines.length-1;i>=0;i--){
|
|
705
|
+
let entry;try{entry=JSON.parse(lines[i]);}catch{continue;}
|
|
706
|
+
const content=entry?.message?.content;
|
|
707
|
+
if(entry?.type==='assistant'&&Array.isArray(content)&&content.some(part=>part?.type==='tool_use'&&editTools.includes(part.name)))return true;
|
|
708
|
+
// The user's prompt: text, not a tool's result.
|
|
709
|
+
if(entry?.type==='user'&&!entry.isMeta&&(typeof content==='string'||Array.isArray(content)&&content.length&&content.every(part=>part?.type==='text')))return false;
|
|
710
|
+
}
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
export async function turnEnd(input,{entry,timeout=10000,invoke=null}){
|
|
714
|
+
if(input?.stop_hook_active===true)return null;
|
|
715
|
+
let project=null;try{project=hookProject(input?.cwd);}catch{}
|
|
716
|
+
const id=sessionId(input);
|
|
717
|
+
if(!project||!id||project.parsed.endOfTurnCheck===false)return null;
|
|
718
|
+
let state;try{state=projectState(project.projectRoot,project.config);}catch{return null;}
|
|
719
|
+
const turn=readTurns(state).sessions[id];
|
|
720
|
+
if(turn?.asked)return null;
|
|
721
|
+
const status=await synchronizedStatus(entry,project.config,project.projectRoot,timeout);
|
|
722
|
+
if(status?.observation?.healthy!==true||!Array.isArray(status.checks))return null;
|
|
723
|
+
// A check no run can confirm (verifiable false) is never due.
|
|
724
|
+
const due=status.checks.filter(row=>row?.verifiable!==false&&(row?.freshness==='STALE'||row?.freshness==='UNVERIFIED'));
|
|
725
|
+
if(!due.length)return null;
|
|
726
|
+
const counted=Number.isSafeInteger(turn?.events)&&Number.isSafeInteger(status.observation.events);
|
|
727
|
+
const changed=counted&&(status.observation.events!==turn.events||status.observation.generation!==turn.generation)||
|
|
728
|
+
Array.isArray(turn?.current)&&due.some(row=>turn.current.includes(row.name))||
|
|
729
|
+
typeof input.transcript_path==='string'&&editedThisTurn(input.transcript_path);
|
|
730
|
+
if(!changed)return null;
|
|
731
|
+
const turns=readTurns(state);turns.sessions[id]={...turns.sessions[id],at:Date.now(),asked:true};writeTurns(state,turns);
|
|
732
|
+
const redue=(invoke??redueInvocation(entry))+(project.record.config===defaultConfig?'':` --config ${shellQuote(project.record.config)}`);
|
|
733
|
+
return {decision:'block',reason:`REDUE: files changed this turn, and ${due.length===1?'a check is':`${due.length} checks are`} due: `+
|
|
734
|
+
`${due.map(row=>`${row.name} (${row.freshness})`).join(', ')}. Before you finish, run \`${redue} verify\` and report what it says. `+
|
|
735
|
+
'REDUE asks this once per turn.'};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Claude Code's status line: one short line from a synchronized status,
|
|
739
|
+
// such as "REDUE ✓ 3 verified" or "REDUE ✓ 1 verified · 2 due: typecheck, test".
|
|
740
|
+
export function statusLineText(status,{observerRunning=true}={}){
|
|
741
|
+
if(status?.schema!==1||!Array.isArray(status.checks))return 'REDUE ? status unavailable';
|
|
742
|
+
const observation=status.observation||{};
|
|
743
|
+
if(observation.healthy!==true)return !observerRunning?'REDUE ? observer not running':
|
|
744
|
+
['discovery','indexing','reconciling','validating','initializing'].includes(observation.phase)?'REDUE … checking files':'REDUE ? unverified';
|
|
745
|
+
const names=rows=>rows.map(row=>row.name).join(', ');
|
|
746
|
+
const checks=status.checks.filter(row=>row.verifiable!==false),unverifiable=status.checks.length-checks.length;
|
|
747
|
+
const verified=checks.filter(row=>row.freshness==='CURRENT'&&row.result==='PASS'),
|
|
748
|
+
failing=checks.filter(row=>row.freshness==='CURRENT'&&row.result!=='PASS'),
|
|
749
|
+
due=checks.filter(row=>row.freshness!=='CURRENT');
|
|
750
|
+
const parts=[...verified.length?[`✓ ${verified.length} verified`]:[],...failing.length?[`✗ ${failing.length} failing: ${names(failing)}`]:[],
|
|
751
|
+
...due.length?[`${due.length} due: ${names(due)}`]:[],...unverifiable?[`${unverifiable} not verifiable`]:[]];
|
|
752
|
+
return `REDUE ${parts.join(' · ')||'no checks'}`;
|
|
753
|
+
}
|
|
754
|
+
// The line for the project in the session's directory, or null when REDUE is
|
|
755
|
+
// not set up there. A synchronized status, else the observer's cached one.
|
|
756
|
+
export async function statusLine(input,{entry,timeout=3000}){
|
|
757
|
+
let project=null;
|
|
758
|
+
try{project=hookProject(input?.workspace?.project_dir??input?.cwd);}catch{}
|
|
759
|
+
if(!project)return null;
|
|
760
|
+
const {config,projectRoot}=project;
|
|
761
|
+
const status=await synchronizedStatus(entry,config,projectRoot,timeout)??await synchronizedStatus(entry,config,projectRoot,1000,false);
|
|
762
|
+
let observerRunning=false;
|
|
763
|
+
try{const pid=Number(fs.readFileSync(path.join(projectState(projectRoot,config),'observer.lock','pid'),'utf8'));
|
|
764
|
+
observerRunning=Number.isSafeInteger(pid)&&pid>0&&processAlive(pid);}catch{}
|
|
765
|
+
return statusLineText(status,{observerRunning});
|
|
766
|
+
}
|
|
767
|
+
export async function runStatusLine(entry){
|
|
768
|
+
try{
|
|
769
|
+
let text='';process.stdin.setEncoding('utf8');
|
|
770
|
+
for await(const chunk of process.stdin){text+=chunk;if(text.length>1024*1024)return;}
|
|
771
|
+
const line=await statusLine(JSON.parse(text),{entry});
|
|
772
|
+
if(line)await new Promise(resolve=>process.stdout.write(line+'\n',resolve));
|
|
773
|
+
}catch{}
|
|
774
|
+
}
|
|
775
|
+
|
|
602
776
|
export async function runClaudeHook(entry){
|
|
603
777
|
try{
|
|
604
778
|
let text='';process.stdin.setEncoding('utf8');
|
|
605
779
|
for await(const chunk of process.stdin){text+=chunk;if(text.length>1024*1024)return;}
|
|
606
780
|
const input=JSON.parse(text);
|
|
607
781
|
if(input?.hook_event_name==='SessionStart'){sessionStart(input,{entry});return;}
|
|
782
|
+
if(input?.hook_event_name==='UserPromptSubmit'){await turnStart(input,{entry});return;}
|
|
783
|
+
if(input?.hook_event_name==='Stop'){
|
|
784
|
+
const output=await turnEnd(input,{entry});
|
|
785
|
+
if(output)await new Promise(resolve=>process.stdout.write(JSON.stringify(output)+'\n',resolve));
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
608
788
|
// REDUE_HOOK_LOG (an absolute file): one JSON line per decision, with why.
|
|
609
789
|
const log=process.env.REDUE_HOOK_LOG,started=Date.now();let why=null;
|
|
610
790
|
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);
|