@redue/cli 0.1.0-alpha.31 → 0.1.0-alpha.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.32 — 2026-11-05
4
+
5
+ REDUE's status is now the point: what is verified on the code as it is
6
+ now, what is stale, what is due.
7
+
8
+ - **`redue verify`.** Runs every check that is due (STALE, or UNVERIFIED
9
+ with a program that exists), starting the observer if needed, then shows
10
+ the full status. It exits 0 only when every check that can be verified
11
+ ends CURRENT / PASS. `--json` gives `verified`, the checks it ran and the
12
+ status. It runs what is due up to three rounds, since a run can make
13
+ another check due.
14
+ - **Not verifiable.** A check no run can confirm shows as NOT VERIFIABLE
15
+ with the reason: a recording-only check, or one whose last two passing
16
+ runs changed the same inputs while they ran (a test script that rebuilds
17
+ a tracked bundle). `verify`, its exit code, the end-of-turn check and the
18
+ status line's due count leave it out; the status line shows it ("✓ 3
19
+ verified · 1 not verifiable"). Any other change, or a failing run, makes
20
+ it due again. JSON: `"verifiable": false` and a `not_verifiable` count.
21
+ - **A status line in Claude Code.** `redue setup claude` adds one:
22
+ "REDUE ✓ 3 verified", "REDUE ✓ 1 verified · 2 due: typecheck, test",
23
+ "REDUE ✗ 1 failing: lint". A status line you already have stays; setup
24
+ prints the command instead.
25
+ - **End-of-turn check.** When the agent changed files during a turn and a
26
+ check is due, the hook asks it once to run `redue verify` before it
27
+ finishes. It never asks twice in a turn. `"endOfTurnCheck": false` in
28
+ the config turns it off.
29
+ - **Faster sandboxed runs.** Receipts and checkpoints keep each installed
30
+ tree (`.venv`, `node_modules`) as one digest, a run computes only its own
31
+ check's context, and Linux keeps the Landlock probe's answer. What `redue
32
+ run` adds to a sub-second check on GitHub's Linux runners fell from
33
+ 270-514 ms to 125-205 ms (lighter's ruff 514 to 155 ms); on macOS from
34
+ 390-710 ms to 310-435 ms, where an FSEvents history stall (150-190 ms in
35
+ about a third of checkpoints) remains. The Landlock launcher now refuses
36
+ a network-less run on a kernel below ABI 4.
37
+ - **Windows.** A status call before the first `redue start` no longer
38
+ leaves a file that makes `start` refuse the state directory.
39
+
40
+ Measured with the verification-gap study (21 tasks on 7 repositories, 2
41
+ runs, Claude Code headless): sessions that ended with every check verified
42
+ on the final code went from 5 of 42 to 41 of 42 with Sonnet 5.5, and from
43
+ 20 of 42 to 42 of 42 with Opus 5.5, with no check REDUE called CURRENT /
44
+ PASS failing on the final code. In the product census, 73% of 330 checks
45
+ had a definite answer (CURRENT or STALE) after their second run (alpha.29:
46
+ 71%); 97% of those on the sandboxed recipe with ignored files and
47
+ documentation hidden. `init` on the 13 slowest census repos took 3874 s on
48
+ Linux and 4797 s on macOS (alpha.31: 4139 s and 5517 s), with the same
49
+ checks chosen.
50
+
3
51
  ## 0.1.0-alpha.31 — 2026-11-03
4
52
 
5
53
  A check that runs longer than init's trial limit no longer holds up
package/README.md CHANGED
@@ -31,8 +31,10 @@ stops before writing anything if the dependencies are not installed. It:
31
31
  workflows, Makefile, justfile, tox, nox or scripts give them: mypy,
32
32
  pyright and ty (typecheck), ruff (lint) and pytest (test). It says which
33
33
  checks it left out and why, and which it can only record, not reuse;
34
- - connects Claude Code: a short section in `CLAUDE.md`, a skill, and two hooks
35
- in `.claude/settings.local.json`;
34
+ - connects Claude Code: a short section in `CLAUDE.md`, a skill, hooks in
35
+ `.claude/settings.local.json`, and a status line there that shows what is
36
+ verified on the code as it is now ("REDUE ✓ 3 verified", "REDUE 2 due:
37
+ typecheck, test"). A status line you already have stays;
36
38
  - starts REDUE's file observer, waits while it indexes the project (a minute
37
39
  or more in a large one), then runs each check that can qualify once, so
38
40
  there is a first result to reuse, and prints one line per run (the run's
@@ -42,7 +44,10 @@ stops before writing anything if the dependencies are not installed. It:
42
44
  Then start a new Claude Code session in the project and work as usual. When
43
45
  the agent is about to rerun a check whose last pass still applies, the hook
44
46
  skips it and tells the agent why. After an edit, the hook runs the check
45
- through REDUE so the new result is recorded.
47
+ through REDUE so the new result is recorded. When the agent is about to finish a
48
+ turn in which files changed while a check is due, the hook asks it once to
49
+ run `redue verify` first (`"endOfTurnCheck": false` in the config turns
50
+ this off).
46
51
 
47
52
  To look yourself:
48
53
 
@@ -63,6 +68,15 @@ the agent's hook does. `redue status --cached` gives the last answer the
63
68
  observer published without checking; it can say UNVERIFIED ("cannot confirm
64
69
  your execution context") where `redue status` would say CURRENT.
65
70
 
71
+ `redue verify` runs every check that is due (STALE, or UNVERIFIED with a
72
+ command it can run), then prints the status. It exits 0 only when every check
73
+ ends CURRENT / PASS, so it answers "is everything verified on the code as it
74
+ is now?" in one command. A check no run can confirm is shown as NOT
75
+ VERIFIABLE with the reason, and left out: a recording-only check, or one
76
+ whose runs change its own inputs (two passing runs in a row changed the same
77
+ files, such as a test script that rebuilds a tracked bundle). Any other
78
+ change, or a failing run, makes it due again.
79
+
66
80
  `redue stats` shows, per check, the reruns the hook skipped, the time saved,
67
81
  the recorded runs and the top reasons a check was UNVERIFIED, from a log kept
68
82
  on your machine.
@@ -171,6 +185,7 @@ redue init # write redue.config.json from your scripts
171
185
  redue start # start the file observer
172
186
  redue run typecheck # run a check through REDUE and record the result
173
187
  redue status # what still holds
188
+ redue verify # run what is due; exit 0 when everything is CURRENT / PASS
174
189
  ```
175
190
 
176
191
  Codex is supported too (`redue agent setup codex --apply`); see
package/bin/redue.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import {mark,traceBase,traceContext,traceEnvironment,traceId} from '../src/decision-profile.mjs';
4
4
  traceBase({...traceContext(),request:traceId(),work:'client'});
5
- mark('client.loaded');
5
+ mark('client.loaded',{sinceStartMs:Math.round(performance.now())});
6
6
  import {directCompiler,directQualification,directEnvironment,directInstalledInputs,directInterpretation} from '../src/direct-typescript.mjs';
7
7
  import {runArgv,tscRun} from '../src/typescript-invocation.mjs';
8
8
  import {testEnvironment,testRunnerAt,testsInterpretation,testsQualification} from '../src/test-direct.mjs';
@@ -49,6 +49,8 @@ Commands:
49
49
  detail [CHECK] [--json] Alias for explain
50
50
  run CHECK [--if-due] Execute one configured check and record its outcome; with --if-due,
51
51
  only when its last pass does not apply to the files as they are now
52
+ verify [--json] Run every check that is due (STALE, or UNVERIFIED with a runnable command),
53
+ then show the status; exit 0 only when every check is CURRENT / PASS
52
54
  stats [--json] Per check: skips, time saved, recorded runs, why checks were UNVERIFIED
53
55
  remove-state Stop and remove only this configuration's owned local state
54
56
 
@@ -66,6 +68,12 @@ if(args.length===3&&args[0]==='agent'&&args[1]==='hook'&&args[2]==='claude'){
66
68
  const {runClaudeHook}=await import('../src/claude-hook.mjs');
67
69
  await runClaudeHook(fileURLToPath(import.meta.url));process.exit(0);
68
70
  }
71
+ // Claude Code's status line, also before config discovery: it prints one
72
+ // line, or nothing where REDUE is not set up.
73
+ if(args.length===3&&args[0]==='agent'&&args[1]==='statusline'&&args[2]==='claude'){
74
+ const {runStatusLine}=await import('../src/claude-hook.mjs');
75
+ await runStatusLine(fileURLToPath(import.meta.url));await exit(0);
76
+ }
69
77
  if(args.length===1&&args[0]==='--version'){console.log(version);process.exit(0);}
70
78
  // Internal: the entry this command runs, so setup and the hook can tell
71
79
  // whether the `redue` on PATH (a link, a package manager's shim, a wrapper)
@@ -99,7 +107,7 @@ if(command==='agent'){
99
107
  catch(e){error(e.message);}
100
108
  await exit(0);
101
109
  }
102
- if(!['init','start','stop','status','detail','explain','run','stats','remove-state'].includes(command))
110
+ if(!['init','start','stop','status','detail','explain','run','verify','stats','remove-state'].includes(command))
103
111
  error(`unknown command ${command||'<none>'}; use --help`);
104
112
  const json=args.includes('--json');
105
113
  // run --if-due: run the check only when its last pass does not apply now.
@@ -319,7 +327,9 @@ if(publicConfig.schema!==1)error('config schema must be 1');
319
327
  if(!Array.isArray(publicConfig.checks)||!publicConfig.checks.length)
320
328
  error('config needs at least one explicitly defined check');
321
329
  for(const key of Object.keys(publicConfig))
322
- if(!['schema','root','checks','packageManager'].includes(key))error(`unsupported config field ${key}`);
330
+ if(!['schema','root','checks','packageManager','endOfTurnCheck'].includes(key))error(`unsupported config field ${key}`);
331
+ // The Claude Code hook's end-of-turn check; only the hook reads it.
332
+ if(publicConfig.endOfTurnCheck!==undefined&&typeof publicConfig.endOfTurnCheck!=='boolean')error('endOfTurnCheck must be true or false');
323
333
  if(check&&!publicConfig.checks.some(row=>row.name===check))error(`unknown check ${check}; use redue status to list configured checks`);
324
334
  const root=realObservedPath(path.resolve(path.dirname(configFile),publicConfig.root||'.'));
325
335
  const state=stateOverride||projectState(root,configFile);
@@ -507,6 +517,75 @@ if(!maintenance&&!created){
507
517
  }
508
518
  }
509
519
 
520
+ if(command==='verify'){
521
+ const self=(more,options)=>spawnSync(process.execPath,[fileURLToPath(import.meta.url),'--config',configFile,
522
+ ...(stateOverride?['--state-dir',stateOverride]:[]),...more],{env:process.env,...options});
523
+ const ask=()=>{const r=self(['status','--sync','--json'],{encoding:'utf8',stdio:['ignore','pipe','ignore'],timeout:600_000});
524
+ try{return JSON.parse(r.stdout);}catch{return null;}};
525
+ const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
526
+ let status=ask();
527
+ // Nothing is confirmed without the observer: start it as the session hook
528
+ // does, and wait while it indexes.
529
+ if(!status?.observation?.healthy){
530
+ let pid=null;try{pid=Number(fs.readFileSync(path.join(state,'observer.lock','pid'),'utf8'));}catch{}
531
+ if(!(Number.isSafeInteger(pid)&&pid>0&&processAlive(pid))){
532
+ if(!json)console.error('redue verify: starting the observer');
533
+ self(['start'],{stdio:['ignore','ignore','inherit']});status=ask();
534
+ }
535
+ for(const until=Date.now()+120_000;!status?.observation?.healthy&&
536
+ ['discovery','indexing','reconciling','validating','initializing'].includes(status?.observation?.phase)&&Date.now()<until;){
537
+ await sleep(1000);status=ask();}
538
+ }
539
+ // A check is due when its last result does not apply to the files as they
540
+ // are now, and it can run: its program is there.
541
+ const runnable=row=>{const command=checks.find(item=>item.name===row.name)?.command?.[0];
542
+ if(typeof command!=='string'||command.endsWith('<unavailable>'))return false;
543
+ if(command.includes('/'))return fs.existsSync(path.resolve(root,checks.find(item=>item.name===row.name).cwd||'.',command));
544
+ return Boolean(findExecutable(command));};
545
+ // A check no run can confirm (recording-only, or one that rewrites its own
546
+ // inputs: verifiable false) is never due. A run can make another check due
547
+ // (a suite that writes an input), and a check that rewrites its own inputs
548
+ // shows that on its second run: verify runs what is due again, three rounds
549
+ // at most.
550
+ // In a round, a check whose last run changed its inputs runs first, so the
551
+ // checks its run makes due run after it.
552
+ // A check that already ran here is due again only when it is STALE or its
553
+ // inputs changed during its run: another UNVERIFIED reason would not clear.
554
+ const changedOwn=row=>/declared input event occurred during execution/.test(row.reason||'');
555
+ const ranNames=new Set();
556
+ const dueOf=value=>(value?.checks||[]).filter(row=>row.verifiable!==false&&
557
+ (row.freshness==='STALE'||row.freshness==='UNVERIFIED'&&runnable(row)&&(!ranNames.has(row.name)||changedOwn(row))))
558
+ .sort((a,b)=>changedOwn(b)-changedOwn(a));
559
+ // While the observer indexes or reconciles (a run may have changed a manifest).
560
+ const settle=async()=>{for(const until=Date.now()+120_000;!status?.observation?.healthy&&
561
+ ['discovery','indexing','reconciling','validating','initializing'].includes(status?.observation?.phase)&&Date.now()<until;){
562
+ await sleep(1000);status=ask();}};
563
+ const {note,dueNote}=await import('../src/stats.mjs');
564
+ const ran=[];
565
+ for(let round=0,due=dueOf(status);round<3&&due.length&&status?.observation?.healthy;round++,due=dueOf(status)){
566
+ for(const row of due){
567
+ const entry=dueNote(status,row.name);if(entry)note(state,entry);
568
+ if(!json)console.error(`redue verify: running ${row.name} (${row.freshness}: ${row.reason})`);
569
+ // With --json, the check's output goes to stderr; stdout carries the answer.
570
+ const r=self(['run',row.name],{stdio:json?['ignore',2,2]:'inherit'});
571
+ ran.push({check:row.name,was:row.freshness,exit:r.status});ranNames.add(row.name);
572
+ }
573
+ status=ask();await settle();
574
+ }
575
+ const final=status;
576
+ const counted=(final?.checks||[]).filter(row=>row.verifiable!==false);
577
+ const verified=Boolean(final?.observation?.healthy)&&counted.length>0&&counted.every(row=>row.freshness==='CURRENT'&&row.result==='PASS');
578
+ if(json)console.log(JSON.stringify({schema:1,verified,ran,status:final}));
579
+ else{
580
+ if(ran.length)console.log('');
581
+ console.log(final?renderStatus(final):'UNVERIFIED — status did not answer.');
582
+ const other=final?.checks?.length-counted.length;
583
+ console.log((verified?`Verified: every check that can be verified (${counted.length}) passed on the files as they are now.`:
584
+ counted.length?'Not verified: some checks did not end CURRENT / PASS.':'Not verified: no check can be verified here.')+
585
+ (other?` ${other} not verifiable, left out.`:''));
586
+ }
587
+ await exit(verified?0:1);
588
+ }
510
589
  if(command==='status'&&!sync){
511
590
  const value=readCachedStatus(state,publicConfig.checks);
512
591
  console.log(json?JSON.stringify(value):renderStatus(value,{short}));
@@ -87,6 +87,32 @@ paths no longer exist. When the hook runs a check through REDUE for the
87
87
  agent, it likewise writes plain `redue run` only if its own PATH finds this
88
88
  install, else the absolute form.
89
89
 
90
+ The same command also runs on UserPromptSubmit and Stop, for the end-of-turn
91
+ check. At the start of a turn it notes the observer's count of file events
92
+ and which checks are CURRENT (on Windows, from a synchronized status). When
93
+ the agent stops, it asks status: if a check is due (STALE or UNVERIFIED) and
94
+ files changed during the turn (the count moved, a check CURRENT at the start
95
+ is due now, or the turn's transcript has an Edit, Write, MultiEdit or
96
+ NotebookEdit call), it
97
+ blocks the stop once with "Before you finish, run `redue verify` and report
98
+ what it says". It never asks twice in a turn, and never on the stop that
99
+ follows its reminder (`stop_hook_active`), so it cannot loop. A turn that
100
+ changed nothing ends quietly even when a check is due, and so does any turn
101
+ where the observer is not healthy. `"endOfTurnCheck": false` in the config
102
+ turns it off.
103
+
104
+ Setup also adds Claude Code's status line, `redue agent statusline claude`,
105
+ in the same file and the same form. It prints one line from a synchronized
106
+ status (the observer's cached one when that takes over 3 s): `REDUE ✓ 3
107
+ verified`, `REDUE ✓ 1 verified · 2 due: typecheck, test`, `REDUE ✗ 1 failing:
108
+ lint`, `REDUE ✓ 3 verified · 1 not verifiable`, `REDUE … checking files` while
109
+ the observer indexes, or `REDUE ? observer not running`. A check that is not
110
+ verifiable (see [the JSON interface](json.md)) is never due, here or for the
111
+ end-of-turn check. Where Claude Code already has a status line (in the
112
+ local or shared project settings, or your user settings), setup leaves it and
113
+ prints the command, so you can add REDUE's line to yours. `redue agent remove
114
+ claude --apply` and `redue remove` take out only REDUE's status line.
115
+
90
116
  Agents often chain an edit and a check (`printf … >> README.md && npm run
91
117
  lint`). In a simple chain (commands joined by `&&`, `||` or `;`, with no
92
118
  subshell, here-document, command substitution, background job or shell
@@ -33,6 +33,14 @@ For the explicit direct compiler choice and generated exact argv, see
33
33
  A general check (`"qualification": "node-script-v1"`) has a `script` and no
34
34
  `command`; see the [general recipe](node-script.md).
35
35
 
36
+ ## End-of-turn check
37
+
38
+ With Claude Code, REDUE's hook checks at the end of each agent turn: when the
39
+ turn changed files and a check is due, it asks the agent once to run
40
+ `redue verify` before it finishes ([agents](agents.md)). To turn this off,
41
+ add `"endOfTurnCheck": false` at the top level of the config. Only the hook
42
+ reads it; the observer does not need a restart.
43
+
36
44
  ## Arbitrary commands
37
45
 
38
46
  An unrecognized ecosystem can still record ordinary execution:
package/docs/user/json.md CHANGED
@@ -59,3 +59,21 @@ JSON summary. **Its stdout is not a JSON-only stream.** Query status/details aft
59
59
  completion for machine-readable evidence. Normal check exit codes are preserved;
60
60
  interruption/process-start failures are nonzero. REDUE retains metadata and receipts,
61
61
  not arbitrary terminal output. The CLI does not infer tests executed from prose.
62
+
63
+ A check no run can confirm has `"verifiable": false` (its `freshness` stays
64
+ UNVERIFIED and `reason` says why): a recording-only check (no qualification
65
+ contract, inputs it cannot resolve), or one whose last two runs passed while
66
+ the same inputs changed during them, with its plan, environment and every
67
+ other input unchanged since. Anything else changing, a failing run or a
68
+ clean run makes it verifiable (and due) again. Such checks are counted in
69
+ `not_verifiable`, not in the other counts, and do not set `state`. A cached
70
+ answer (`--cached`) never marks a check this way.
71
+
72
+ `verify --json` writes one JSON object to stdout: `verified` (true only when the
73
+ observer is healthy, at least one check is verifiable, and every verifiable
74
+ check is CURRENT / PASS), `ran` (each check it ran, in order: `check`, `was`
75
+ (STALE or UNVERIFIED) and the run's `exit`), and `status`, the synchronized
76
+ status after those runs. It runs what is due up to three rounds, since a run
77
+ can make another check due; a check whose last run changed its inputs runs
78
+ first in a round. It never runs a check that is not verifiable. The checks'
79
+ own output goes to stderr. It exits 0 when `verified` is true, else 1.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@redue/cli",
3
3
  "private": false,
4
- "version": "0.1.0-alpha.31",
4
+ "version": "0.1.0-alpha.32",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
@@ -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);
package/src/daemon.mjs CHANGED
@@ -25,7 +25,7 @@ import {npmCacheDir} from './npm-integrity.mjs';
25
25
  import {runFromArgv} from './typescript-invocation.mjs';
26
26
  import {buildInfoPath,recordedCertificate} from './direct-run-certificate.mjs';
27
27
  import {timing,mark,traceBase,traceContext,traceScope,traceEnvironment,traceId} from './decision-profile.mjs';
28
- import {isSandboxed,sandboxChange,visibleChange as sandboxVisibleChange} from './sandbox.mjs';
28
+ import {compactKey,installedTreeOf,isSandboxed,sandboxChange,visibleChange as sandboxVisibleChange} from './sandbox.mjs';
29
29
 
30
30
  const configFile=path.resolve(process.argv[2]);
31
31
  const config=JSON.parse(fs.readFileSync(configFile));
@@ -338,7 +338,49 @@ function combinedFiles(name) {
338
338
  Object.entries(entry.index.exportFiles(name)))files[`external:${entry.root}:${rel}`]=hash;
339
339
  return files;
340
340
  }
341
+ // The files of a sandboxed run's snapshot and receipt, smaller: each
342
+ // installed tree (a node_modules or .venv directory, outermost) as one entry,
343
+ // `<tree>/:installed:`, with a digest of its files and their identities, and
344
+ // every other file as it is. The fingerprint still covers every file; this map
345
+ // names a changed input and decides which changes a hidden run could see. A
346
+ // receipt says it holds this form (filesCompact), and is compared with it.
347
+ // The entry's name is a path inside its tree, so whether a hidden run could
348
+ // see a change to it is decided as for the files it stands for: beneath a
349
+ // hidden entry, or new where git ignores it now.
350
+ const compactCache=new Map();
351
+ function compactFiles(name) {
352
+ const summary=combinedSummary(name),key=`${summary.revision}\0${summary.fingerprint}`;
353
+ const cached=compactCache.get(name);
354
+ if(cached?.key===key)return {...cached.files};
355
+ const out={},trees=new Map();
356
+ for(const [rel,hash] of Object.entries(combinedFiles(name))){
357
+ const tree=installedTreeOf(rel);
358
+ if(tree===null){out[rel]=hash;continue;}
359
+ let list=trees.get(tree);if(!list)trees.set(tree,list=[]);
360
+ list.push([rel,hash]);
361
+ }
362
+ for(const [tree,list] of trees){list.sort(([a],[b])=>a<b?-1:a>b?1:0);out[compactKey(tree)]=sha(JSON.stringify(list));}
363
+ compactCache.set(name,{key,files:out});
364
+ return {...out};
365
+ }
366
+ // The current files in the form receipt `r` holds them.
367
+ const filesFor=(name,r)=>r?.filesCompact===true?compactFiles(name):combinedFiles(name);
368
+ // Whether a receipt's run, which passed, is the second in a row during which
369
+ // the same inputs changed (selfChange), and its plan, environment and every
370
+ // other input are as they were at its start.
371
+ function onlySelfChanged(name,row,r,contextHashes) {
372
+ const self=r.selfChange,context=contextHashes?.[name];
373
+ if(r.result!=='PASS'||!(self?.streak>=2)||!Array.isArray(self.files)||r.planId!==row.plan.id||!r.files||!context)return false;
374
+ for(const [key,value] of Object.entries(context))if(!key.startsWith('__redue_env_')&&r.environmentHashes?.[key]!==value)return false;
375
+ const skip=new Set(self.files),strip=files=>Object.fromEntries(Object.entries(files).filter(([file])=>!skip.has(file)));
376
+ const before=strip(r.files),now=strip(filesFor(name,r)),paths=[...new Set([...Object.keys(before),...Object.keys(now)])];
377
+ if(hiddenRun(r))return sandboxVisibleChange(root,r.sandbox.hidden,before,now,paths,{docs:Array.isArray(r.sandbox.docs)?r.sandbox.docs:null})===null;
378
+ return paths.every(file=>before[file]===now[file]);
379
+ }
380
+ // A changed input as status names it: an installed tree by its path.
381
+ const inputName=file=>file?.endsWith('/:installed:')?file.slice(0,-'/:installed:'.length):file;
341
382
  function describeChangedInput(plan,file) {
383
+ if(file.endsWith('/:installed:'))return `${file.slice(0,-'/:installed:'.length)} (installed files) changed`;
342
384
  const boundary=[...external.keys()].find(value=>file.startsWith(`external:${value}:`));
343
385
  const physical=boundary?path.join(boundary,file.slice(`external:${boundary}:`.length)):
344
386
  path.join(root,file);
@@ -355,7 +397,7 @@ function describeChangedInput(plan,file) {
355
397
  const hiddenRun=r=>Array.isArray(r?.sandbox?.hidden)&&Boolean(r.files);
356
398
  function visibleChange(row,r,name) {
357
399
  const key=(r.invocation?.runId||'')+'\0'+r.fingerprint+'\0'+combinedSummary(name).fingerprint;
358
- if(row.visibleKey!==key){const current=combinedFiles(name);
400
+ if(row.visibleKey!==key){const current=filesFor(name,r);
359
401
  row.visibleKey=key;row.visibleChange=sandboxVisibleChange(root,r.sandbox.hidden,r.files,current,[...Object.keys(r.files),...Object.keys(current)],
360
402
  {docs:Array.isArray(r.sandbox.docs)?r.sandbox.docs:null});}
361
403
  return row.visibleChange;
@@ -389,6 +431,10 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
389
431
  `; declared input checkpoint changed (${row.lastReason||'content or membership'})`:'';
390
432
  const probeIssue=row.probeError?`; state probe unavailable: ${row.probeError}`:'';
391
433
  item.reason='input coverage unresolved: '+row.plan.unresolved.join('; ')+checkpoint+probeIssue;
434
+ // Recording-only (no qualification contract, inputs it cannot resolve):
435
+ // no run can make it CURRENT. A qualified check's unresolved inputs can
436
+ // be fixed, so it stays due.
437
+ if(!row.plan.qualification)item.verifiable=false;
392
438
  return item;
393
439
  }
394
440
  if(row.probeError){item.reason=row.probeError;return item;}
@@ -410,6 +456,13 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
410
456
  item.reason=`target execution ${r.target?.status||'unavailable'}`;return item;}
411
457
  if(r.target.status==='failed'&&result!=='FAIL'){
412
458
  item.reason='target failure conflicts with outer invocation outcome';return item;}
459
+ // The same inputs changed during its last two passing runs, and nothing
460
+ // else changed since: no run of it can be confirmed. Anything else
461
+ // changing makes it due again.
462
+ if(!r.stable&&onlySelfChanged(name,row,r,contextHashes)){
463
+ item.verifiable=false;
464
+ item.reason=`the same inputs changed during its last two runs (${r.selfChange.files.slice(0,5).join(', ')}${r.selfChange.files.length>5?' and more':''}), so no result of it can be confirmed; it is due again when anything else changes`;
465
+ return item;}
413
466
  if(!r.stable){const issues=r.checkpoint?.issues?.length?` (${r.checkpoint.issues.join('; ')})`:'';
414
467
  // A sandboxed run that could not start, whose result the sandbox changed, or whose rerun outside it did not finish, says so alone.
415
468
  const sandbox=r.checkpoint?.issues?.length&&r.checkpoint.issues.every(issue=>/^the (?:sandbox changed|run was not sandboxed|run outside the sandbox|network supervisor)/.test(issue));
@@ -446,16 +499,16 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
446
499
  else if(r.fingerprint!==combinedSummary(name).fingerprint&&!(hiddenRun(r)&&visibleChange(row,r,name)===null)) {
447
500
  const key=r.fingerprint+'\0'+combinedSummary(name).fingerprint;
448
501
  if(row.staleReasonKey!==key&&hiddenRun(r)){
449
- row.staleInputs=[visibleChange(row,r,name)];row.staleReason=describeChangedInput(row.plan,row.staleInputs[0]);row.staleReasonKey=key;}
502
+ const seen=visibleChange(row,r,name);row.staleInputs=[inputName(seen)];row.staleReason=describeChangedInput(row.plan,seen);row.staleReasonKey=key;}
450
503
  if(row.staleReasonKey!==key){
451
504
  let changed=row.lastReason;row.staleInputs=[];
452
505
  const candidate=changed?.endsWith(' changed')?changed.slice(0,-8):null;
453
- if(candidate&&r.files?.[candidate]!==index.files.get(candidate)){
506
+ if(candidate&&!(r.filesCompact===true&&installedTreeOf(candidate)!==null)&&r.files?.[candidate]!==index.files.get(candidate)){
454
507
  changed=describeChangedInput(row.plan,candidate);row.staleInputs=[candidate];}
455
508
  else {
456
- const current=combinedFiles(name);
509
+ const current=filesFor(name,r);
457
510
  if(r.files)for(const f of new Set([...Object.keys(r.files),...Object.keys(current)]))
458
- if(r.files[f]!==current[f]){changed=describeChangedInput(row.plan,f);row.staleInputs=[f];break;}
511
+ if(r.files[f]!==current[f]){changed=describeChangedInput(row.plan,f);row.staleInputs=[inputName(f)];break;}
459
512
  }
460
513
  row.staleReason=changed||
461
514
  (['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
@@ -500,7 +553,7 @@ function knownStale(name,row) {
500
553
  const r=receipts[name];
501
554
  if(!r?.files||r.planId!==row.plan.id||row.probeError||pendingChecks.has(name)||
502
555
  row.probeCache?.input===inputKey(row))return false;
503
- const current=combinedFiles(name),files=Object.keys(current);
556
+ const current=filesFor(name,r),files=Object.keys(current);
504
557
  return files.length!==Object.keys(r.files).length||files.some(file=>r.files[file]!==current[file]);
505
558
  }
506
559
  // A run can rebuild a certificate that no longer applies only because indexed
@@ -1174,7 +1227,8 @@ async function request(message) {
1174
1227
  if(message.action==='snapshot'||message.action==='prelaunch') {
1175
1228
  data.snapshots={};for(const [name,row] of index?.checks||[])
1176
1229
  if(!message.name||message.name===name)
1177
- data.snapshots[name]={...combinedSummary(name),files:combinedFiles(name),
1230
+ data.snapshots[name]={...combinedSummary(name),files:message.compact===1?compactFiles(name):combinedFiles(name),
1231
+ ...message.compact===1?{filesCompact:true}:{},
1178
1232
  planGeneration:row.plan.id,
1179
1233
  inputEventSerial:inputEventSerial.get(name)||0,
1180
1234
  // Complete only when the oldest kept event follows the given serial.
package/src/landlock.py CHANGED
@@ -321,6 +321,9 @@ def main(argv):
321
321
  version, err = abi()
322
322
  if version is None:
323
323
  sys.exit(f'landlock.py: Landlock unavailable (errno {err})')
324
+ # Below ABI 4 Landlock cannot refuse TCP: a run that must be off the network does not start.
325
+ if no_tcp and version < 4:
326
+ sys.exit(f'landlock.py: Landlock ABI {version} cannot keep a run off the network')
324
327
  reading = EXECUTE | READ_FILE | READ_DIR
325
328
  handled = reading | (WRITE if deny_write else 0)
326
329
  attr = RulesetAttr(handled, (BIND_TCP | CONNECT_TCP) if no_tcp and version >= 4 else 0,
@@ -6,7 +6,7 @@ const recipes={'npm-typescript-direct-v1':'direct TypeScript recipe','npm-tests-
6
6
  'sandbox-v2':'sandboxed recipe, git-ignored files hidden','sandbox-v3':'sandboxed recipe, git-ignored files and docs hidden'};
7
7
  export const recipeLabel=recipe=>recipes[recipe]??null;
8
8
  export function checkLabel(row) {
9
- return row.result==='FAIL'?'FAILED':row.freshness;
9
+ return row.verifiable===false?'NOT VERIFIABLE':row.result==='FAIL'?'FAILED':row.freshness;
10
10
  }
11
11
  export function explanation(row) {
12
12
  const reason=row.reason||'No verification evidence is available.';
@@ -25,11 +25,12 @@ export function renderStatus(value,{short=false}={}) {
25
25
  const lines=[];
26
26
  for(const row of value.checks||[]) {
27
27
  const label=checkLabel(row),symbol=label==='CURRENT'&&row.result==='PASS'?'✓':label==='FAILED'?'✗':'?';
28
- const mark=label==='STALE'?'⚠':symbol;
28
+ const mark=label==='STALE'?'⚠':label==='NOT VERIFIABLE'?'–':symbol;
29
29
  const historical=row.result?` / ${row.result}`:'';
30
30
  const recipe=label==='CURRENT'?recipeLabel(row.recipe):null;
31
31
  lines.push(`${mark} ${row.name} ${label}${historical}${recipe?` (${recipe})`:''}`);
32
- if(label!=='CURRENT')lines.push(` ${row.result==='FAIL'?`Historical failure; applicability ${row.freshness}. `:''}${explanation(row)}`);
32
+ if(label==='NOT VERIFIABLE')lines.push(` Not verifiable: ${explanation(row)}`);
33
+ else if(label!=='CURRENT')lines.push(` ${row.result==='FAIL'?`Historical failure; applicability ${row.freshness}. `:''}${explanation(row)}`);
33
34
  }
34
35
  if(!lines.length)lines.push('UNVERIFIED — observation is initializing; no check state is available yet.');
35
36
  if(!value.observation?.healthy)lines.push('Observation unavailable or pending. Previous results are not reusable.');
package/src/sandbox.mjs CHANGED
@@ -100,8 +100,15 @@ export function sandboxEnvironment(python=false,manager=null,kind=null){
100
100
  }
101
101
 
102
102
  // Whether this machine can sandbox a run: {mode} or {issue}. Asked once per process.
103
+ // On Linux, `cache` (a file in REDUE's state) keeps a Landlock answer for the
104
+ // same kernel, launcher Python and launcher, so a run does not start Python to
105
+ // ask again. The launcher checks again when it starts a run, and a run it
106
+ // cannot sandbox is not reused (launcherFailure).
103
107
  let support=null;
104
- export function sandboxSupport(){
108
+ const launcherKey=python=>{try{const st=fs.statSync(fs.realpathSync(python),{bigint:true});
109
+ return digest(JSON.stringify([os.release(),python,...['dev','ino','size','mtimeNs'].map(key=>String(st[key])),digest(fs.readFileSync(landlockScript))]));}
110
+ catch{return null;}};
111
+ export function sandboxSupport({cache=null}={}){
105
112
  if(support)return support;
106
113
  if(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_SANDBOX==='unavailable')
107
114
  return support={mode:null,issue:'the sandbox is turned off for this test'};
@@ -114,11 +121,15 @@ export function sandboxSupport(){
114
121
  if(process.platform==='linux'){
115
122
  const python=launcherPython();
116
123
  if(!python)return support={mode:null,issue:'the Landlock launcher needs Python 3'};
124
+ const key=cache?launcherKey(python):null;
125
+ if(key)try{const kept=JSON.parse(fs.readFileSync(cache,'utf8'));
126
+ if(kept?.key===key&&Number.isInteger(kept.abi)&&kept.abi>=4)return support={mode:'landlock',abi:kept.abi,python};}catch{}
117
127
  let abi=null;
118
128
  try{abi=JSON.parse(spawnSync(python,['-I','-W','ignore',landlockScript,'--probe'],{encoding:'utf8',timeout:10000}).stdout).abi;}catch{}
119
129
  if(!Number.isInteger(abi))return support={mode:null,issue:'Landlock is unavailable in this kernel'};
120
130
  // ABI 4 (Linux 6.7) is the first that can refuse TCP connections.
121
131
  if(abi<4)return support={mode:null,issue:`Landlock ABI ${abi} cannot keep a run off the network; it needs ABI 4 (Linux 6.7)`};
132
+ if(key)try{const temp=`${cache}.${process.pid}.tmp`;fs.writeFileSync(temp,JSON.stringify({key,abi}),{mode:0o600});fs.renameSync(temp,cache);}catch{}
122
133
  return support={mode:'landlock',abi,python};
123
134
  }
124
135
  return support={mode:null,issue:'the sandboxed recipe runs on macOS and Linux only'};
@@ -267,14 +278,19 @@ export function sandboxDirs(state,name){
267
278
  // git cannot list them. Only the project's rules count (.gitignore files and
268
279
  // .git/info/exclude), not a user's global excludes file, as on CI.
269
280
  const installedTree=/(?:^|\/)(?:node_modules|\.venv)(?:\/|$)/;
281
+ // The outermost installed tree a checkout path is in (`a/node_modules`), or
282
+ // null. Never hidden itself; the observer keeps such a tree as one entry in a
283
+ // sandboxed run's files, `<tree>/:installed:` (compactKey).
284
+ export const installedTreeOf=rel=>rel.startsWith('external:')?null:/^(.*?(?:^|\/)(?:node_modules|\.venv))(?:\/|$)/.exec(rel)?.[1]??null;
285
+ export const compactKey=tree=>`${tree}/:installed:`;
286
+ const compactTree=key=>key.endsWith('/:installed:')?key.slice(0,-':installed:'.length):null;
270
287
  const runnerCache=picomatch(sandboxCaches,{dot:true});
271
288
  const projectGit=['-c','core.excludesFile=/dev/null'],gitEnv=()=>({...process.env,GIT_OPTIONAL_LOCKS:'0'});
289
+ const hiddenArgs=[...projectGit,'ls-files','-z','--others','--ignored','--exclude-standard','--directory'];
290
+ const hiddenOf=r=>r.status!==0?null:r.stdout.split('\0').filter(Boolean).filter(rel=>!installedTree.test(rel)&&
291
+ !runnerCache(rel)&&!runnerCache(rel.replace(/\/$/,'')+'/x'));
272
292
  export function hiddenEntries(root){
273
- const r=spawnSync('git',[...projectGit,'ls-files','-z','--others','--ignored','--exclude-standard','--directory'],
274
- {cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:60000,stdio:['ignore','pipe','ignore'],env:gitEnv()});
275
- if(r.status!==0)return null;
276
- return r.stdout.split('\0').filter(Boolean).filter(rel=>!installedTree.test(rel)&&
277
- !runnerCache(rel)&&!runnerCache(rel.replace(/\/$/,'')+'/x'));
293
+ return hiddenOf(spawnSync('git',hiddenArgs,{cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:60000,stdio:['ignore','pipe','ignore'],env:gitEnv()}));
278
294
  }
279
295
 
280
296
  // The documentation files docPatterns names among the files git lists and
@@ -283,9 +299,11 @@ export function hiddenEntries(root){
283
299
  // Relative; null when git cannot tell. git filters by pathspec, so a large
284
300
  // checkout is not listed whole.
285
301
  const docMatch=picomatch(docPatterns,{dot:true});
302
+ const docsArgs=()=>[...projectGit,'ls-files','-z','--cached','--others','--exclude-standard','--',...docPatterns.map(pattern=>`:(glob)${pattern}`)];
286
303
  export function hiddenDocs(root){
287
- const r=spawnSync('git',[...projectGit,'ls-files','-z','--cached','--others','--exclude-standard','--',...docPatterns.map(pattern=>`:(glob)${pattern}`)],
288
- {cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:60000,stdio:['ignore','pipe','ignore'],env:gitEnv()});
304
+ return docsOf(root,spawnSync('git',docsArgs(),{cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:60000,stdio:['ignore','pipe','ignore'],env:gitEnv()}));
305
+ }
306
+ function docsOf(root,r){
289
307
  if(r.status!==0)return null;
290
308
  const out=new Set();
291
309
  for(const rel of r.stdout.split('\0'))if(rel&&!out.has(rel)&&docMatch(rel))
@@ -343,7 +361,8 @@ export function visibleChange(root,hidden,before,current,paths,{during=false,doc
343
361
  const covered=changed.filter(beneath),fresh=changed.filter(file=>!(file in before)&&!beneath(file));
344
362
  if(covered.length){
345
363
  const tracked=trackedBeneath(root,entries.filter(entry=>covered.some(file=>file===entry||file.startsWith(entry+'/'))));
346
- const found=covered.find(file=>!tracked||tracked.has(file));
364
+ // An installed tree kept as one entry counts when git tracks any file in it.
365
+ const found=covered.find(file=>!tracked||tracked.has(file)||compactTree(file)!==null&&[...tracked].some(rel=>rel.startsWith(compactTree(file))));
347
366
  if(found)return found;
348
367
  }
349
368
  if(!fresh.length)return null;
@@ -556,6 +575,11 @@ export function stopGroup(pid){
556
575
  export function endWithChild(child){
557
576
  child.on('exit',()=>{stopGroup(child.pid);setTimeout(()=>{child.stdout?.destroy();child.stderr?.destroy();},1000).unref();});
558
577
  }
578
+ // The launcher's own line when it could not sandbox a run (Landlock
579
+ // unavailable, or a rule it could not add), or null.
580
+ // Only its refusals before the command starts, and only as the run's whole output.
581
+ export const launcherFailure=text=>/^landlock\.py: (?:Landlock unavailable|Landlock ABI \d+ cannot|create_ruleset|add_rule|no_new_privs|restrict_self)[^\n]*$/
582
+ .exec(String(text).trim())?.[0].slice(0,200)??null;
559
583
  // Runs argv in its own process group to the end, with no terminal input:
560
584
  // {status, signal, error, timedOut, stdout, stderr}, each output kept up to
561
585
  // 64 MB and decoded at the end. `echo` also passes the output through.