@redue/cli 0.1.0-alpha.30 → 0.1.0-alpha.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/README.md +18 -3
- package/bin/redue.mjs +148 -19
- package/docs/user/agents.md +26 -0
- package/docs/user/configuration.md +8 -0
- package/docs/user/getting-started.md +2 -1
- package/docs/user/json.md +18 -0
- package/docs/user/sandbox.md +14 -0
- package/package.json +1 -1
- package/src/agent-integration.mjs +45 -14
- package/src/cached-state.mjs +9 -6
- package/src/claude-hook.mjs +134 -0
- package/src/cli.mjs +45 -17
- package/src/daemon.mjs +62 -8
- package/src/landlock.py +3 -0
- package/src/presentation.mjs +4 -3
- package/src/sandbox.mjs +43 -13
- package/src/setup.mjs +22 -3
|
@@ -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);
|