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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,78 @@
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
+
51
+ ## 0.1.0-alpha.31 — 2026-11-03
52
+
53
+ A check that runs longer than init's trial limit no longer holds up
54
+ `init` for three runs, and can be set up in the sandbox later.
55
+
56
+ - **A run past the trial limit never agrees.** Each trial run at `init`
57
+ stops after 5 minutes. alpha.30 counted a timeout on both sides as
58
+ agreement and ran every trial of a check that timed out. Now, when the
59
+ plain run reaches the limit, `init` runs nothing in the sandbox and
60
+ writes the fallback; when a sandboxed run reaches it, no further trial
61
+ follows. On the 13 census repos with the slowest checks, `init --dry-run`
62
+ took 6224 s in all with alpha.30 and 3670 s now on Linux, 8331 s and
63
+ 4776 s on macOS. ADOSMissionControl went from 1138 s to 581 s (Linux),
64
+ agent-afk from 936 s to 323 s, Nomi from 860 s to 528 s (macOS). Checks
65
+ that moved from the sandbox to their fallback: 2 of 210 on Linux
66
+ (ts-liveview typecheck, open-science lint), 3 of 210 on macOS (Nomi and
67
+ ts-liveview typecheck, OneUptime lint).
68
+ - **`redue init --no-time-limit`.** `redue setup` names the checks that
69
+ ran past the limit and prints one command, such as
70
+ `redue init --check typecheck --no-time-limit`. It runs their trials with
71
+ no limit and updates only their rows in the existing config (a row that
72
+ runs the same script), restarts a running observer, and leaves the
73
+ config one `redue remove` still deletes. `init --json` gives such a
74
+ check's kind as `time_limit`.
75
+
3
76
  ## 0.1.0-alpha.30 — 2026-11-03
4
77
 
5
78
  `init` runs a check fewer times, and tests try the sandbox first.
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';
@@ -33,7 +33,10 @@ Commands:
33
33
  init, agent setup claude, start, one recorded run per check
34
34
  remove [--yes] Undo setup: integration, observer state, and the config setup created
35
35
  init [--dry-run] [--workspace NAME] [--check KIND] [--recipe typescript-direct|tests-direct|node-script|sandbox]
36
- Discover existing scripts; optionally select one workspace/check
36
+ [--no-time-limit]
37
+ Discover existing scripts; optionally select one workspace/check.
38
+ --no-time-limit: trial runs have no 5-minute limit; with a config, the checks
39
+ it does not run in the sandbox are tried, and those that qualify are updated
37
40
  agent setup codex|claude [--dry-run|--apply] [--json]
38
41
  agent doctor [codex|claude] [--json]
39
42
  agent remove codex|claude [--dry-run|--apply] [--json]
@@ -46,6 +49,8 @@ Commands:
46
49
  detail [CHECK] [--json] Alias for explain
47
50
  run CHECK [--if-due] Execute one configured check and record its outcome; with --if-due,
48
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
49
54
  stats [--json] Per check: skips, time saved, recorded runs, why checks were UNVERIFIED
50
55
  remove-state Stop and remove only this configuration's owned local state
51
56
 
@@ -63,6 +68,12 @@ if(args.length===3&&args[0]==='agent'&&args[1]==='hook'&&args[2]==='claude'){
63
68
  const {runClaudeHook}=await import('../src/claude-hook.mjs');
64
69
  await runClaudeHook(fileURLToPath(import.meta.url));process.exit(0);
65
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
+ }
66
77
  if(args.length===1&&args[0]==='--version'){console.log(version);process.exit(0);}
67
78
  // Internal: the entry this command runs, so setup and the hook can tell
68
79
  // whether the `redue` on PATH (a link, a package manager's shim, a wrapper)
@@ -96,7 +107,7 @@ if(command==='agent'){
96
107
  catch(e){error(e.message);}
97
108
  await exit(0);
98
109
  }
99
- 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))
100
111
  error(`unknown command ${command||'<none>'}; use --help`);
101
112
  const json=args.includes('--json');
102
113
  // run --if-due: run the check only when its last pass does not apply now.
@@ -118,11 +129,13 @@ if(command==='init')for(let i=0;i<args.length;){
118
129
  }
119
130
  const check=['run','detail','explain'].includes(command)&&args[0]&&!args[0].startsWith('--')?args.shift():null;
120
131
  if(command==='run'&&!check)error('run requires a check name');
121
- if(args.some(value=>!['--json','--sync','--cached','--dry-run','--short'].includes(value)))error('unknown option; use --help');
132
+ if(args.some(value=>!['--json','--sync','--cached','--dry-run','--short','--no-time-limit'].includes(value)))error('unknown option; use --help');
122
133
  if(sync&&!['status','detail','explain'].includes(command))error('--sync is for status or detail');
123
134
  if(cachedOnly&&(command!=='status'||args.includes('--sync')))error('--cached is a status option; do not combine it with --sync');
124
135
  if(short&&(command!=='status'||json||args.includes('--sync')))error('--short is a cached status option; do not combine it with --sync or --json');
125
136
  if(dryRun&&command!=='init')error('--dry-run is for init');
137
+ const noTimeLimit=args.includes('--no-time-limit');
138
+ if(noTimeLimit&&command!=='init')error('--no-time-limit is for init');
126
139
 
127
140
  if(command==='init'){
128
141
  const {discoverNodeProject,sourceLinkIssue}=await import('../src/node-onboarding.mjs');
@@ -164,36 +177,63 @@ if(command==='init'){
164
177
  // where the plain run passes. Any other difference is tried once with the
165
178
  // whole checkout, reusing the plain run. Otherwise init writes what it
166
179
  // would have without the sandboxed recipe, with the reason. Each check
167
- // costs one plain run and one or two sandboxed runs.
180
+ // costs one plain run and one or two sandboxed runs. A run past the trial
181
+ // limit ends the check's trials (time_limit); --no-time-limit sets none.
168
182
  const trialIssue=trial=>trial.issue?`the sandbox cannot run here (${trial.issue})`:
183
+ trial.timedOut?`it ran past init's 5-minute trial limit${trial.timedOut==='sandboxed'?' inside the sandbox':''}`+
184
+ ` (redue init --check ${trial.kind} --no-time-limit tries it without the limit)`:
169
185
  // A failure both ways that shows a refusal cannot be told from a real one.
170
186
  trial.same?null:trial.plain!==trial.sandboxed?`exit ${trial.sandboxed} inside the sandbox, ${trial.plain} outside it${trial.line?` (${trial.line})`:''}`:
171
187
  trial.line?`it fails both ways (exit ${trial.plain}), with a sign of a refusal inside the sandbox (${trial.line})`:
172
188
  `the output differs inside the sandbox: ${JSON.stringify(trial.output.sandboxed)} instead of ${JSON.stringify(trial.output.plain)}`;
189
+ // With --no-time-limit and a config, the checks the config has and does
190
+ // not run in the sandbox are tried, and one it lacks when --check names
191
+ // it. Those that qualify replace their rows (a row that runs the same
192
+ // script) or are added.
193
+ const configured=fs.existsSync(configFile);
194
+ let existing=null,existingText=null;
195
+ if(configured&&noTimeLimit){
196
+ try{existingText=fs.readFileSync(configFile,'utf8');existing=JSON.parse(existingText);}catch(e){error(`cannot read ${configFile}: ${e.message}`);}
197
+ if(!Array.isArray(existing?.checks))error(`cannot read ${configFile}: it has no checks`);
198
+ if((existing.root??'.')!=='.'||!samePath(realObservedPath(path.dirname(configFile)),discovery.root))
199
+ error('init updates only a config at the repository root, without root; run it there');
200
+ }
201
+ const timeout=noTimeLimit?0:undefined,improved=new Map();
173
202
  let trials=0;
174
203
  for(const [at,row] of [...discovery.checks.entries()]){
175
204
  if(row.qualification!==sandboxQualification||row.level!=='ready')continue;
205
+ const old=existing?.checks.find(check=>check.name===row.config.name);
206
+ const sameRun=old&&old.kind===row.config.kind&&(row.config.script?old.script===row.config.script:
207
+ JSON.stringify(old.command)===JSON.stringify(row.config.command));
208
+ const notTried=!existing?null:!old?(checkSelection?null:'the config does not have it; name its kind with --check to add it'):
209
+ isSandboxed(old.qualification)?'the config already runs it in the sandbox':sameRun?null:'the config runs something else under that name';
210
+ if(notTried){discovery.checks[at]=null;discovery.skipped.push({kind:row.config.name,reason:`${row.config.name}: not tried; ${notTried}`});continue;}
176
211
  if(!json)console.error(`Running ${row.config.name} inside and outside the sandbox to confirm it...`);
177
- const docs=await sandboxTrial(discovery.root,row.config,{hide:'docs'});
212
+ const docs={...await sandboxTrial(discovery.root,row.config,{hide:'docs',timeout}),kind:row.config.kind};
178
213
  if(!docs.issue)trials++;
179
214
  if(!docs.issue&&docs.same){
180
215
  row.config={...row.config,qualification:docsQualification};row.qualification=docsQualification;
181
- row.reason=row.reason.replace('the whole checkout is its input',"the checkout's files git doesn't ignore, except its documentation, are its input")+`; ran both ways at init, exit ${docs.plain}`;continue;}
182
- if(!docs.issue&&docs.refusedHidden){
183
- row.reason+=`; at init it passed, then failed reading a file that was hidden (${docs.refusedHidden}), so the whole checkout is its input, not run that way at init`;continue;}
216
+ row.reason=row.reason.replace('the whole checkout is its input',"the checkout's files git doesn't ignore, except its documentation, are its input")+`; ran both ways at init, exit ${docs.plain}`;
217
+ improved.set(row.config.name,row.config);continue;}
218
+ if(!docs.issue&&!docs.timedOut&&docs.refusedHidden){
219
+ row.reason+=`; at init it passed, then failed reading a file that was hidden (${docs.refusedHidden}), so the whole checkout is its input, not run that way at init`;
220
+ improved.set(row.config.name,row.config);continue;}
184
221
  // Without a list of the ignored files or the documentation, the whole checkout is tried on its own.
185
- const trial=docs.plainRun?await sandboxTrial(discovery.root,row.config,{plain:docs.plainRun}):
186
- [hiddenListIssue,hiddenDocsIssue].includes(docs.issue)?await sandboxTrial(discovery.root,row.config):docs;
222
+ const trial=docs.timedOut?docs:docs.plainRun?{...await sandboxTrial(discovery.root,row.config,{plain:docs.plainRun,timeout}),kind:row.config.kind}:
223
+ [hiddenListIssue,hiddenDocsIssue].includes(docs.issue)?{...await sandboxTrial(discovery.root,row.config,{timeout}),kind:row.config.kind}:docs;
187
224
  if(trial!==docs&&!trial.issue)trials++;
188
225
  const issue=trialIssue(trial);
189
226
  if(!issue){row.reason+=`; ran both ways at init, exit ${trial.plain}; the whole checkout is its input: `+
190
- (docs.issue?docs.issue:`with the files git ignores and the documentation hidden, ${trialIssue(docs)}`);continue;}
191
- if(row.fallback)discovery.checks[at]={...row.fallback,reason:`Sandboxed recipe unavailable: ${issue}; ${row.fallback.reason}`};
192
- else{discovery.checks[at]=null;discovery.skipped.push({kind:row.config.name,reason:`${row.config.name}: sandboxed recipe unavailable: ${issue}`});}
227
+ (docs.issue?docs.issue:`with the files git ignores and the documentation hidden, ${trialIssue(docs)}`);
228
+ improved.set(row.config.name,row.config);continue;}
229
+ // time_limit: the kind that --check takes to try it without the limit.
230
+ const limited=trial.timedOut?{timeLimit:row.config.kind}:{};
231
+ if(row.fallback)discovery.checks[at]={...row.fallback,...limited,reason:`Sandboxed recipe unavailable: ${issue}; ${row.fallback.reason}`};
232
+ else{discovery.checks[at]=null;discovery.skipped.push({kind:row.config.name,...trial.timedOut?{time_limit:row.config.kind}:{},reason:`${row.config.name}: sandboxed recipe unavailable: ${issue}`});}
193
233
  }
194
234
  discovery.checks=discovery.checks.filter(Boolean).map(({fallback,...row})=>row);
195
235
  discovery.config.checks=discovery.checks.map(row=>row.config);
196
- const configured=fs.existsSync(configFile),unsupported=Boolean(discovery.observationIssue||
236
+ const unsupported=Boolean(discovery.observationIssue||
197
237
  discovery.manager.issue&&!['npm','yarn','pnpm'].includes(discovery.manager.name)||
198
238
  discovery.manager.name==='yarn'&&!discovery.manager.version);
199
239
  const willWrite=!dryRun&&!configured&&!unsupported&&discovery.checks.length>0;
@@ -202,9 +242,9 @@ if(command==='init'){
202
242
  installation_layout:discovery.manager.layout,
203
243
  issue:discovery.observationIssue||discovery.manager.issue,workspaces:discovery.workspaces,
204
244
  ambiguous:discovery.ambiguous,skipped:discovery.skipped||[],
205
- checks:discovery.checks.map(({config:check,level,reason})=>({name:check.name,
245
+ checks:discovery.checks.map(({config:check,level,reason,timeLimit})=>({name:check.name,
206
246
  script:check.script||null,command:check.command||null,
207
- workspace:check.workspace||null,level,reason})),config:configFile,
247
+ workspace:check.workspace||null,level,reason,...timeLimit?{time_limit:timeLimit}:{}})),config:configFile,
208
248
  written:willWrite,existing:configured,writable:!configured&&!unsupported&&discovery.checks.length>0};
209
249
  if(!json) {
210
250
  if(summary.package_manager||!discovery.python)console.log(`Node ${summary.node}; ${summary.package_manager||'unknown package manager'}`+
@@ -223,7 +263,8 @@ if(command==='init'){
223
263
  console.log(` ${row.kind}: ambiguous scripts (${row.scripts.join(', ')}); choose one explicitly`);
224
264
  for(const row of summary.skipped)console.log(` Skipped: ${row.reason}`);
225
265
  if(!summary.checks.length)console.log('No useful existing verification scripts found.');
226
- console.log(configured?`Existing ${configFile} left unchanged.`:
266
+ console.log(existing&&improved.size?(dryRun?`Preview only; ${configFile} was not updated.`:`Updating ${[...improved.keys()].join(', ')} in ${configFile}.`):
267
+ configured?`Existing ${configFile} left unchanged.`:
227
268
  dryRun?`Preview only; ${configFile} was not written.`:
228
269
  willWrite?`Writing ${configFile}; inspect it before running checks.`:'No configuration will be written.');
229
270
  }
@@ -236,6 +277,23 @@ if(command==='init'){
236
277
  console.log(`Next: review the config, then redue start and redue run ${JSON.stringify(discovery.checks[0].config.name)}.`);
237
278
  console.log('Connect your agent: redue agent setup codex (or claude). Preview with --dry-run.');
238
279
  console.log('Then redue status says whether each result applies to the files as they are now.');}
280
+ } else if(existing&&improved.size&&!dryRun){
281
+ if(fs.readFileSync(configFile,'utf8')!==existingText)error(`${configFile} changed while the checks ran; nothing was written. Run init again`);
282
+ // The observer keeps the config it started with: it is restarted around the update.
283
+ const state=stateOverride||projectState(discovery.root,configFile);
284
+ let running=false;try{running=processAlive(Number(fs.readFileSync(path.join(state,'observer.lock','pid'),'utf8')));}catch{}
285
+ const self=action=>spawnSync(process.execPath,[process.argv[1],'--config',configFile,...stateOverride?['--state-dir',stateOverride]:[],action],
286
+ {stdio:['ignore','pipe','pipe'],encoding:'utf8'});
287
+ if(running&&self('stop').status!==0)error('the observer did not stop; nothing was written');
288
+ const names=new Set(existing.checks.map(check=>check.name));
289
+ existing.checks=[...existing.checks.map(check=>improved.get(check.name)??check),...[...improved.values()].filter(check=>!names.has(check.name))];
290
+ const text=JSON.stringify(existing,null,2)+'\n';
291
+ fs.writeFileSync(configFile,text);
292
+ // A config setup created is still one redue remove deletes.
293
+ (await import('../src/setup.mjs')).configRewritten(discovery.root,existingText,text);
294
+ Object.assign(summary,{written:true,updated:[...improved.keys()],observer:!running?'not running':self('start').status===0?'restarted':'not started'});
295
+ if(!json)console.log(`Updated ${[...improved.keys()].join(', ')} in ${configFile}.`+
296
+ {restarted:' Restarted the observer with it.','not started':' The observer did not start again: run redue start.','not running':''}[summary.observer]);
239
297
  } else if(unsupported&&!json)console.log(`No config written: ${summary.issue||'unsupported discovery boundary'}.`);
240
298
  if(json)console.log(JSON.stringify({...summary,proposed_config:discovery.config}));
241
299
  await exit(0);
@@ -269,7 +327,9 @@ if(publicConfig.schema!==1)error('config schema must be 1');
269
327
  if(!Array.isArray(publicConfig.checks)||!publicConfig.checks.length)
270
328
  error('config needs at least one explicitly defined check');
271
329
  for(const key of Object.keys(publicConfig))
272
- 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');
273
333
  if(check&&!publicConfig.checks.some(row=>row.name===check))error(`unknown check ${check}; use redue status to list configured checks`);
274
334
  const root=realObservedPath(path.resolve(path.dirname(configFile),publicConfig.root||'.'));
275
335
  const state=stateOverride||projectState(root,configFile);
@@ -457,6 +517,75 @@ if(!maintenance&&!created){
457
517
  }
458
518
  }
459
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
+ }
460
589
  if(command==='status'&&!sync){
461
590
  const value=readCachedStatus(state,publicConfig.checks);
462
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:
@@ -100,7 +100,8 @@ redue explain typecheck
100
100
  Use a check actually printed by `init`. For a workspace:
101
101
  `redue init --workspace @example/package --check typecheck`.
102
102
  Initialization previews detected commands and qualification confidence before writing
103
- `redue.config.json`. An existing config is never overwritten. No dependencies are
103
+ `redue.config.json`. An existing config is not overwritten (`init --no-time-limit`
104
+ updates the rows of checks it [qualifies](sandbox.md#files-git-ignores-and-documentation)). No dependencies are
104
105
  installed; a check proposed for the sandboxed recipe runs once plainly and once or
105
106
  twice in the sandbox, and no results are recorded. A missing/unsupported boundary is explained. `init` tries
106
107
  the strict recipes first and uses the [general recipe](node-script.md) for a root
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.
@@ -87,6 +87,20 @@ reusing the plain run, and writes `sandbox-v1` only if that agrees. So a
87
87
  check runs two or three times at init, not four. `init` no longer writes
88
88
  `sandbox-v2`; a check already set up with it keeps it.
89
89
 
90
+ Each trial run stops after 5 minutes. A run that stops there never counts as
91
+ agreeing: when the plain run reaches the limit, `init` runs nothing in the
92
+ sandbox and writes the fallback; when a sandboxed run reaches it, no further
93
+ trial follows. `init` marks such a check (`time_limit` in `--json` gives
94
+ its kind), and `redue setup` names them and prints one command:
95
+ `redue init --check typecheck --no-time-limit` (or `redue init
96
+ --no-time-limit` for several kinds). With an existing config, that command
97
+ runs with no limit the checks the config has and does not run in the
98
+ sandbox, and a check it lacks when `--check` names its kind. It replaces
99
+ the row of each that qualifies, if the row runs the same script, or adds
100
+ it, and leaves the rest of the config as it was. It restarts a running
101
+ observer with the new config, and `redue remove` still deletes a config
102
+ `setup` created.
103
+
90
104
  With `sandbox-v3`, each run also hides the documentation: the files git
91
105
  lists (and does not ignore) named `*.md`, `*.mdx`, `*.rst`, `LICENSE*` or
92
106
  `CHANGELOG*` anywhere, and text, image and PDF files (`.txt`, `.adoc`,
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.30",
4
+ "version": "0.1.0-alpha.32",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"