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

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,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.31 — 2026-11-03
4
+
5
+ A check that runs longer than init's trial limit no longer holds up
6
+ `init` for three runs, and can be set up in the sandbox later.
7
+
8
+ - **A run past the trial limit never agrees.** Each trial run at `init`
9
+ stops after 5 minutes. alpha.30 counted a timeout on both sides as
10
+ agreement and ran every trial of a check that timed out. Now, when the
11
+ plain run reaches the limit, `init` runs nothing in the sandbox and
12
+ writes the fallback; when a sandboxed run reaches it, no further trial
13
+ follows. On the 13 census repos with the slowest checks, `init --dry-run`
14
+ took 6224 s in all with alpha.30 and 3670 s now on Linux, 8331 s and
15
+ 4776 s on macOS. ADOSMissionControl went from 1138 s to 581 s (Linux),
16
+ agent-afk from 936 s to 323 s, Nomi from 860 s to 528 s (macOS). Checks
17
+ that moved from the sandbox to their fallback: 2 of 210 on Linux
18
+ (ts-liveview typecheck, open-science lint), 3 of 210 on macOS (Nomi and
19
+ ts-liveview typecheck, OneUptime lint).
20
+ - **`redue init --no-time-limit`.** `redue setup` names the checks that
21
+ ran past the limit and prints one command, such as
22
+ `redue init --check typecheck --no-time-limit`. It runs their trials with
23
+ no limit and updates only their rows in the existing config (a row that
24
+ runs the same script), restarts a running observer, and leaves the
25
+ config one `redue remove` still deletes. `init --json` gives such a
26
+ check's kind as `time_limit`.
27
+
3
28
  ## 0.1.0-alpha.30 — 2026-11-03
4
29
 
5
30
  `init` runs a check fewer times, and tests try the sandbox first.
package/bin/redue.mjs CHANGED
@@ -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]
@@ -118,11 +121,13 @@ if(command==='init')for(let i=0;i<args.length;){
118
121
  }
119
122
  const check=['run','detail','explain'].includes(command)&&args[0]&&!args[0].startsWith('--')?args.shift():null;
120
123
  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');
124
+ if(args.some(value=>!['--json','--sync','--cached','--dry-run','--short','--no-time-limit'].includes(value)))error('unknown option; use --help');
122
125
  if(sync&&!['status','detail','explain'].includes(command))error('--sync is for status or detail');
123
126
  if(cachedOnly&&(command!=='status'||args.includes('--sync')))error('--cached is a status option; do not combine it with --sync');
124
127
  if(short&&(command!=='status'||json||args.includes('--sync')))error('--short is a cached status option; do not combine it with --sync or --json');
125
128
  if(dryRun&&command!=='init')error('--dry-run is for init');
129
+ const noTimeLimit=args.includes('--no-time-limit');
130
+ if(noTimeLimit&&command!=='init')error('--no-time-limit is for init');
126
131
 
127
132
  if(command==='init'){
128
133
  const {discoverNodeProject,sourceLinkIssue}=await import('../src/node-onboarding.mjs');
@@ -164,36 +169,63 @@ if(command==='init'){
164
169
  // where the plain run passes. Any other difference is tried once with the
165
170
  // whole checkout, reusing the plain run. Otherwise init writes what it
166
171
  // would have without the sandboxed recipe, with the reason. Each check
167
- // costs one plain run and one or two sandboxed runs.
172
+ // costs one plain run and one or two sandboxed runs. A run past the trial
173
+ // limit ends the check's trials (time_limit); --no-time-limit sets none.
168
174
  const trialIssue=trial=>trial.issue?`the sandbox cannot run here (${trial.issue})`:
175
+ trial.timedOut?`it ran past init's 5-minute trial limit${trial.timedOut==='sandboxed'?' inside the sandbox':''}`+
176
+ ` (redue init --check ${trial.kind} --no-time-limit tries it without the limit)`:
169
177
  // A failure both ways that shows a refusal cannot be told from a real one.
170
178
  trial.same?null:trial.plain!==trial.sandboxed?`exit ${trial.sandboxed} inside the sandbox, ${trial.plain} outside it${trial.line?` (${trial.line})`:''}`:
171
179
  trial.line?`it fails both ways (exit ${trial.plain}), with a sign of a refusal inside the sandbox (${trial.line})`:
172
180
  `the output differs inside the sandbox: ${JSON.stringify(trial.output.sandboxed)} instead of ${JSON.stringify(trial.output.plain)}`;
181
+ // With --no-time-limit and a config, the checks the config has and does
182
+ // not run in the sandbox are tried, and one it lacks when --check names
183
+ // it. Those that qualify replace their rows (a row that runs the same
184
+ // script) or are added.
185
+ const configured=fs.existsSync(configFile);
186
+ let existing=null,existingText=null;
187
+ if(configured&&noTimeLimit){
188
+ try{existingText=fs.readFileSync(configFile,'utf8');existing=JSON.parse(existingText);}catch(e){error(`cannot read ${configFile}: ${e.message}`);}
189
+ if(!Array.isArray(existing?.checks))error(`cannot read ${configFile}: it has no checks`);
190
+ if((existing.root??'.')!=='.'||!samePath(realObservedPath(path.dirname(configFile)),discovery.root))
191
+ error('init updates only a config at the repository root, without root; run it there');
192
+ }
193
+ const timeout=noTimeLimit?0:undefined,improved=new Map();
173
194
  let trials=0;
174
195
  for(const [at,row] of [...discovery.checks.entries()]){
175
196
  if(row.qualification!==sandboxQualification||row.level!=='ready')continue;
197
+ const old=existing?.checks.find(check=>check.name===row.config.name);
198
+ const sameRun=old&&old.kind===row.config.kind&&(row.config.script?old.script===row.config.script:
199
+ JSON.stringify(old.command)===JSON.stringify(row.config.command));
200
+ const notTried=!existing?null:!old?(checkSelection?null:'the config does not have it; name its kind with --check to add it'):
201
+ isSandboxed(old.qualification)?'the config already runs it in the sandbox':sameRun?null:'the config runs something else under that name';
202
+ if(notTried){discovery.checks[at]=null;discovery.skipped.push({kind:row.config.name,reason:`${row.config.name}: not tried; ${notTried}`});continue;}
176
203
  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'});
204
+ const docs={...await sandboxTrial(discovery.root,row.config,{hide:'docs',timeout}),kind:row.config.kind};
178
205
  if(!docs.issue)trials++;
179
206
  if(!docs.issue&&docs.same){
180
207
  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;}
208
+ 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}`;
209
+ improved.set(row.config.name,row.config);continue;}
210
+ if(!docs.issue&&!docs.timedOut&&docs.refusedHidden){
211
+ 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`;
212
+ improved.set(row.config.name,row.config);continue;}
184
213
  // 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;
214
+ const trial=docs.timedOut?docs:docs.plainRun?{...await sandboxTrial(discovery.root,row.config,{plain:docs.plainRun,timeout}),kind:row.config.kind}:
215
+ [hiddenListIssue,hiddenDocsIssue].includes(docs.issue)?{...await sandboxTrial(discovery.root,row.config,{timeout}),kind:row.config.kind}:docs;
187
216
  if(trial!==docs&&!trial.issue)trials++;
188
217
  const issue=trialIssue(trial);
189
218
  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}`});}
219
+ (docs.issue?docs.issue:`with the files git ignores and the documentation hidden, ${trialIssue(docs)}`);
220
+ improved.set(row.config.name,row.config);continue;}
221
+ // time_limit: the kind that --check takes to try it without the limit.
222
+ const limited=trial.timedOut?{timeLimit:row.config.kind}:{};
223
+ if(row.fallback)discovery.checks[at]={...row.fallback,...limited,reason:`Sandboxed recipe unavailable: ${issue}; ${row.fallback.reason}`};
224
+ 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
225
  }
194
226
  discovery.checks=discovery.checks.filter(Boolean).map(({fallback,...row})=>row);
195
227
  discovery.config.checks=discovery.checks.map(row=>row.config);
196
- const configured=fs.existsSync(configFile),unsupported=Boolean(discovery.observationIssue||
228
+ const unsupported=Boolean(discovery.observationIssue||
197
229
  discovery.manager.issue&&!['npm','yarn','pnpm'].includes(discovery.manager.name)||
198
230
  discovery.manager.name==='yarn'&&!discovery.manager.version);
199
231
  const willWrite=!dryRun&&!configured&&!unsupported&&discovery.checks.length>0;
@@ -202,9 +234,9 @@ if(command==='init'){
202
234
  installation_layout:discovery.manager.layout,
203
235
  issue:discovery.observationIssue||discovery.manager.issue,workspaces:discovery.workspaces,
204
236
  ambiguous:discovery.ambiguous,skipped:discovery.skipped||[],
205
- checks:discovery.checks.map(({config:check,level,reason})=>({name:check.name,
237
+ checks:discovery.checks.map(({config:check,level,reason,timeLimit})=>({name:check.name,
206
238
  script:check.script||null,command:check.command||null,
207
- workspace:check.workspace||null,level,reason})),config:configFile,
239
+ workspace:check.workspace||null,level,reason,...timeLimit?{time_limit:timeLimit}:{}})),config:configFile,
208
240
  written:willWrite,existing:configured,writable:!configured&&!unsupported&&discovery.checks.length>0};
209
241
  if(!json) {
210
242
  if(summary.package_manager||!discovery.python)console.log(`Node ${summary.node}; ${summary.package_manager||'unknown package manager'}`+
@@ -223,7 +255,8 @@ if(command==='init'){
223
255
  console.log(` ${row.kind}: ambiguous scripts (${row.scripts.join(', ')}); choose one explicitly`);
224
256
  for(const row of summary.skipped)console.log(` Skipped: ${row.reason}`);
225
257
  if(!summary.checks.length)console.log('No useful existing verification scripts found.');
226
- console.log(configured?`Existing ${configFile} left unchanged.`:
258
+ console.log(existing&&improved.size?(dryRun?`Preview only; ${configFile} was not updated.`:`Updating ${[...improved.keys()].join(', ')} in ${configFile}.`):
259
+ configured?`Existing ${configFile} left unchanged.`:
227
260
  dryRun?`Preview only; ${configFile} was not written.`:
228
261
  willWrite?`Writing ${configFile}; inspect it before running checks.`:'No configuration will be written.');
229
262
  }
@@ -236,6 +269,23 @@ if(command==='init'){
236
269
  console.log(`Next: review the config, then redue start and redue run ${JSON.stringify(discovery.checks[0].config.name)}.`);
237
270
  console.log('Connect your agent: redue agent setup codex (or claude). Preview with --dry-run.');
238
271
  console.log('Then redue status says whether each result applies to the files as they are now.');}
272
+ } else if(existing&&improved.size&&!dryRun){
273
+ if(fs.readFileSync(configFile,'utf8')!==existingText)error(`${configFile} changed while the checks ran; nothing was written. Run init again`);
274
+ // The observer keeps the config it started with: it is restarted around the update.
275
+ const state=stateOverride||projectState(discovery.root,configFile);
276
+ let running=false;try{running=processAlive(Number(fs.readFileSync(path.join(state,'observer.lock','pid'),'utf8')));}catch{}
277
+ const self=action=>spawnSync(process.execPath,[process.argv[1],'--config',configFile,...stateOverride?['--state-dir',stateOverride]:[],action],
278
+ {stdio:['ignore','pipe','pipe'],encoding:'utf8'});
279
+ if(running&&self('stop').status!==0)error('the observer did not stop; nothing was written');
280
+ const names=new Set(existing.checks.map(check=>check.name));
281
+ existing.checks=[...existing.checks.map(check=>improved.get(check.name)??check),...[...improved.values()].filter(check=>!names.has(check.name))];
282
+ const text=JSON.stringify(existing,null,2)+'\n';
283
+ fs.writeFileSync(configFile,text);
284
+ // A config setup created is still one redue remove deletes.
285
+ (await import('../src/setup.mjs')).configRewritten(discovery.root,existingText,text);
286
+ Object.assign(summary,{written:true,updated:[...improved.keys()],observer:!running?'not running':self('start').status===0?'restarted':'not started'});
287
+ if(!json)console.log(`Updated ${[...improved.keys()].join(', ')} in ${configFile}.`+
288
+ {restarted:' Restarted the observer with it.','not started':' The observer did not start again: run redue start.','not running':''}[summary.observer]);
239
289
  } else if(unsupported&&!json)console.log(`No config written: ${summary.issue||'unsupported discovery boundary'}.`);
240
290
  if(json)console.log(JSON.stringify({...summary,proposed_config:discovery.config}));
241
291
  await exit(0);
@@ -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
@@ -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.31",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
package/src/sandbox.mjs CHANGED
@@ -595,13 +595,17 @@ function refusedHidden(plain,sandboxed,entries,supervisor){
595
595
  const others=sandboxed.output.split(/\r?\n/).filter(text=>!hiddenRefusal(text,entries)).join('\n');
596
596
  return denialLine(others)?null:line;
597
597
  }
598
- // {same, plain, sandboxed, line, refusedHidden} or {issue} when the sandbox cannot run here.
598
+ // init's limit on each trial run, in ms; a test can shorten it.
599
+ export const trialLimit=process.env.VSTATE_TEST_FAULTS==='1'&&Number(process.env.VSTATE_TEST_TRIAL_LIMIT)||300000;
600
+ // {same, plain, sandboxed, line, refusedHidden, timedOut} or {issue} when the sandbox cannot run here.
601
+ // A run that reaches `timeout` (0: none) is never the same as another:
602
+ // `timedOut` says which, and when the plain run does, no sandboxed run follows.
599
603
  // With `hide` 'docs', the documentation (hiddenDocs) is hidden too.
600
604
  // With `hide`, the git-ignored entries hiddenEntries lists are kept from the
601
605
  // sandboxed run, and it also returns them, their count, the run times and, on
602
606
  // Linux, the launcher's rule count and setup time.
603
607
  // `plain`: the plain run of an earlier trial of the same check, reused.
604
- export async function sandboxTrial(root,check,{timeout=300000,hide=false,plain:earlier=null}={}){
608
+ export async function sandboxTrial(root,check,{timeout=trialLimit,hide=false,plain:earlier=null}={}){
605
609
  const supported=sandboxSupport();
606
610
  if(supported.issue)return {issue:supported.issue};
607
611
  const python=Boolean(pythonTool(check));
@@ -621,6 +625,7 @@ export async function sandboxTrial(root,check,{timeout=300000,hide=false,plain:e
621
625
  const run=async(argv,env)=>{const started=Date.now(),r=await runToEnd(argv,{cwd:root,env,timeout});
622
626
  return {status:r.timedOut?'timeout':r.status??r.signal??r.error?.code,output:`${r.stdout}\n${r.stderr}`,ms:Date.now()-started};};
623
627
  const plain=earlier??await run(command,{...process.env,...python?{}:{npm_config_update_notifier:'false'}});
628
+ if(plain.status==='timeout')return {same:false,plain:'timeout',sandboxed:null,timedOut:'plain',line:null,refusedHidden:null,plainRun:plain};
624
629
  const reads=[root,home,tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
625
630
  const sandboxed=await run(sandboxArgv(supported.mode,command,{reads,tmp,python:supported.python,hidden,stats,
626
631
  ...hide==='docs'?{docs:{root,entries:docEntries}}:{},
@@ -644,8 +649,9 @@ export async function sandboxTrial(root,check,{timeout=300000,hide=false,plain:e
644
649
  const counts=tests?[plain,sandboxed].map(r=>testCounts(r.output)):null;
645
650
  const read=counts?.some(c=>Object.values(c).some(value=>value!==null));
646
651
  const differs=tests&&read?(JSON.stringify(counts[0])===JSON.stringify(counts[1])?null:{plain:JSON.stringify(counts[0]),sandboxed:JSON.stringify(counts[1])}):output;
647
- const same=plain.status===sandboxed.status&&!denied&&!differs&&!supervisor;
648
- return {same,plain:plain.status,sandboxed:sandboxed.status,output:differs,plainRun:plain,
652
+ const timedOut=sandboxed.status==='timeout'?'sandboxed':null;
653
+ const same=!timedOut&&plain.status===sandboxed.status&&!denied&&!differs&&!supervisor;
654
+ return {same,plain:plain.status,sandboxed:sandboxed.status,output:differs,plainRun:plain,timedOut,
649
655
  line:supervisor||denied||(plain.status===sandboxed.status?null:sandboxed.output.trim().split('\n').at(-1)?.slice(0,200)||null),
650
656
  refusedHidden:same?null:refusedHidden(plain,sandboxed,entries,supervisor),
651
657
  ...hide?{hidden:entries.length,entries,docs:docEntries.length,plainMs:plain.ms,sandboxedMs:sandboxed.ms,...launcher?{rules:launcher.rules,setupMs:launcher.ms}:{}}:{}};
package/src/setup.mjs CHANGED
@@ -12,7 +12,7 @@ import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import {createHash} from 'node:crypto';
14
14
  import {spawnSync} from 'node:child_process';
15
- import {testCounts} from './sandbox.mjs';
15
+ import {isSandboxed,testCounts} from './sandbox.mjs';
16
16
  import {createInterface} from 'node:readline/promises';
17
17
  import {projectState} from './project-location.mjs';
18
18
  import {claudeHooksInstalled,removeClaudeHooks,removeIntegration} from './agent-integration.mjs';
@@ -31,6 +31,18 @@ async function confirm(question,yes,{defaultYes}){
31
31
  return defaultYes?!/^n/i.test(answer):/^y/i.test(answer);}finally{prompt.close();}
32
32
  }
33
33
 
34
+ // The setup record of the checkout at `root`, in its shared Git directory.
35
+ function recordOf(root){
36
+ const common=git(root,'rev-parse','--git-common-dir');
37
+ return common.status===0?path.join(path.resolve(root,common.stdout.trim()),`redue-setup-${hash(root).slice(0,16)}.json`):null;
38
+ }
39
+ // When init updates the config setup created, the record follows, so that
40
+ // redue remove still deletes it.
41
+ export function configRewritten(root,before,after){
42
+ const file=recordOf(root),record=file&&JSON.parse(readText(file)??'null');
43
+ if(record?.configCreated&&record.configHash===hash(before))fs.writeFileSync(file,JSON.stringify({...record,configHash:hash(after)},null,2)+'\n');
44
+ }
45
+
34
46
  export async function setupCommand(args,{entry,cwd=process.cwd()}){
35
47
  const action=args.shift();
36
48
  const yes=args.includes('--yes'),rest=args.filter(arg=>arg!=='--yes');
@@ -50,8 +62,7 @@ export async function setupCommand(args,{entry,cwd=process.cwd()}){
50
62
  stdio:options.capture?['ignore','pipe','pipe']:'inherit',timeout:options.timeout});
51
63
  // One record per checkout, beside the others in the shared Git directory:
52
64
  // linked worktrees share .git/info/exclude.
53
- const common=path.resolve(root,git(root,'rev-parse','--git-common-dir').stdout.trim());
54
- const recordFile=path.join(common,`redue-setup-${hash(root).slice(0,16)}.json`);
65
+ const recordFile=recordOf(root),common=path.dirname(recordFile);
55
66
  // Other checkouts' records, for checkouts that still exist.
56
67
  const others=()=>{try{return fs.readdirSync(common).filter(name=>/^redue-setup-[0-9a-f]{16}\.json$/.test(name)&&path.join(common,name)!==recordFile)
57
68
  .map(name=>JSON.parse(readText(path.join(common,name))??'null')).filter(other=>other&&fs.existsSync(other.root));}catch{return [];}};
@@ -78,6 +89,12 @@ async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
78
89
  const found=JSON.parse(preview.stdout);
79
90
  if(!configExisted&&!found.writable)throw Error(`nothing to set up: ${found.issue||(found.checks.length?found.checks.map(check=>`${check.name}: ${check.reason}`).join('; '):'no typecheck, lint or test script REDUE can use')}`);
80
91
  const levels=new Map(found.checks.map(check=>[check.name,check.level]));
92
+ // The checks whose trial ran past init's time limit, unless the config
93
+ // already runs them in the sandbox, and the kinds init's --check takes.
94
+ const sandboxed=new Set(configExisted?JSON.parse(readText(configPath)).checks.filter(check=>isSandboxed(check.qualification)).map(check=>check.name):[]);
95
+ const limited=[...found.checks.map(check=>[check.name,check.time_limit]),...(found.skipped||[]).map(row=>[row.kind,row.time_limit])]
96
+ .filter(([name,kind])=>kind&&!sandboxed.has(name));
97
+ const limitedKinds=[...new Set(limited.map(([,kind])=>kind))];
81
98
  const names=configExisted?JSON.parse(readText(configPath)).checks.map(check=>check.name):found.checks.map(check=>check.name);
82
99
  const ready=names.filter(name=>levels.get(name)==='ready');
83
100
  console.log(`Setting up REDUE for Claude Code in ${root}`);
@@ -161,6 +178,8 @@ async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
161
178
  if(ready.length&&!due.length)console.log(`Already CURRENT: ${ready.join(', ')}.`);
162
179
  for(const row of statusOf())console.log(` ${row.name}: ${row.freshness}${row.result?' / '+row.result:''}${row.freshness==='CURRENT'?'':
163
180
  levels.get(row.name)!=='ready'?' — recording only':' — '+row.reason}`);
181
+ if(limited.length)console.log(`${limited.map(([name])=>name).join(', ')} ran past init's 5-minute trial limit, so ${limited.length>1?'they are':'it is'} not sandboxed. `+
182
+ `To try ${limited.length>1?'them':'it'} without the limit: redue init${limitedKinds.length>1?'':` --check ${limitedKinds[0]}`} --no-time-limit`);
164
183
  console.log('Done. Start a new Claude Code session in this project. Undo with redue remove.');
165
184
  return {applied:true};
166
185
  }