@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.
@@ -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 {fanoutRules,hookCommand,hookForms,redueFor,runsEntry,shellQuote as quote} from './claude-hook.mjs';
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
- const events=['PreToolUse','SessionStart'];
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 hook claude$`).exec(command??'');
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). Unchanged input returns the same text, so
86
- // idempotent setup does not rewrite the file.
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),next={PreToolUse:hook,SessionStart:sessionHook};
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?next:{SessionStart:sessionHook},null,2)+
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
  }
@@ -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
- const rows=(value.checks||[]).map(row=>{
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 count=test=>checks.filter(test).length,failed=count(r=>r.freshness==='CURRENT'&&r.result==='FAIL'),
34
- stale=count(r=>r.freshness==='STALE'),unverified=count(r=>r.freshness==='UNVERIFIED')||(!checks.length?1:0);
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:count(r=>r.result==='FAIL'),stale,unverified};
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
@@ -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
- mark('cli.loaded');
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
- function contextHashes() {const reads=config.checks.some(check=>recordedRecipes.includes(check.qualification)||isSandboxed(check.qualification))?environmentReads():{};
91
- return Object.fromEntries(config.checks.map(check=>[
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
- try{fs.appendFileSync(path.join(state,'events.jsonl'),JSON.stringify(diagnostic)+'\n');}catch{}
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: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: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=readReceipts(state).checks[name];
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
- const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,
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
- files:(outputs?end:snap)?.files??null,observerGeneration:before?.observation?.generation??null,
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);