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

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +806 -0
  2. package/README.md +157 -99
  3. package/bin/redue.mjs +267 -61
  4. package/docs/README.md +3 -1
  5. package/docs/user/agents.md +333 -15
  6. package/docs/user/configuration.md +28 -2
  7. package/docs/user/direct-typescript.md +219 -0
  8. package/docs/user/getting-started.md +49 -7
  9. package/docs/user/json.md +4 -2
  10. package/docs/user/node-script.md +177 -0
  11. package/docs/user/npm-launcher-notice.md +42 -0
  12. package/docs/user/pilot.md +146 -0
  13. package/docs/user/sandbox.md +312 -0
  14. package/docs/user/shell.md +3 -3
  15. package/docs/user/supported-projects.md +69 -23
  16. package/docs/user/tests-direct.md +143 -0
  17. package/package.json +4 -3
  18. package/src/agent-integration.mjs +467 -0
  19. package/src/agent-policy.md +28 -0
  20. package/src/cached-state.mjs +24 -1
  21. package/src/claude-hook.mjs +616 -0
  22. package/src/cli.mjs +449 -54
  23. package/src/daemon.mjs +442 -93
  24. package/src/decision-profile.mjs +15 -1
  25. package/src/decision-validation.mjs +12 -1
  26. package/src/declared-plan.mjs +38 -6
  27. package/src/direct-run-certificate.mjs +254 -0
  28. package/src/direct-typescript-probe.mjs +27 -0
  29. package/src/direct-typescript.mjs +205 -0
  30. package/src/glob.mjs +8 -0
  31. package/src/history-worker.mjs +21 -13
  32. package/src/index.mjs +108 -21
  33. package/src/installed-inputs.mjs +7 -1
  34. package/src/installed-observation.mjs +23 -0
  35. package/src/landlock.py +527 -0
  36. package/src/linux-inotify.py +39 -10
  37. package/src/node-onboarding.mjs +203 -54
  38. package/src/node-script.mjs +406 -0
  39. package/src/npm-integrity.mjs +124 -0
  40. package/src/npm-typecheck-environment.mjs +36 -0
  41. package/src/npmrc.mjs +16 -0
  42. package/src/observation.mjs +34 -12
  43. package/src/path-identity.mjs +8 -0
  44. package/src/plan-worker.mjs +17 -17
  45. package/src/pnpm-inputs.mjs +122 -57
  46. package/src/pnpm-shim-templates.json +180 -0
  47. package/src/pnpm-shims.mjs +54 -0
  48. package/src/pnpm-typecheck-probe.mjs +11 -6
  49. package/src/presentation.mjs +13 -4
  50. package/src/probe-deadline.mjs +37 -0
  51. package/src/probe-result.mjs +7 -3
  52. package/src/project-location.mjs +34 -0
  53. package/src/python-onboarding.mjs +89 -0
  54. package/src/run-recorder.cjs +91 -0
  55. package/src/sandbox.mjs +653 -0
  56. package/src/setup.mjs +205 -0
  57. package/src/stats.mjs +72 -0
  58. package/src/test-direct.mjs +608 -0
  59. package/src/test-recorder.cjs +435 -0
  60. package/src/typescript-contract-probe.mjs +4 -187
  61. package/src/typescript-input-facts.mjs +92 -0
  62. package/src/typescript-invocation.mjs +92 -0
  63. package/src/typescript-list.mjs +118 -13
  64. package/src/typescript-qualification.mjs +29 -49
  65. package/src/workspace-manifests.mjs +24 -0
  66. package/src/yarn-workspace-inputs.mjs +3 -0
  67. package/src/yarn-workspace-typecheck-probe.mjs +2 -1
package/bin/redue.mjs CHANGED
@@ -1,15 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs';
3
+ import {mark,traceBase,traceContext,traceEnvironment,traceId} from '../src/decision-profile.mjs';
4
+ traceBase({...traceContext(),request:traceId(),work:'client'});
5
+ mark('client.loaded');
6
+ import {directCompiler,directQualification,directEnvironment,directInstalledInputs,directInterpretation} from '../src/direct-typescript.mjs';
7
+ import {runArgv,tscRun} from '../src/typescript-invocation.mjs';
8
+ import {testEnvironment,testRunnerAt,testsInterpretation,testsQualification} from '../src/test-direct.mjs';
9
+ import {scriptEnvironment,scriptInterpretation,scriptKinds,scriptQualification} from '../src/node-script.mjs';
10
+ import {docsQualification,hiddenDocsIssue,hiddenListIssue,isSandboxed,pythonTool,sandboxEnvironment,sandboxInterpretationOf,sandboxKinds,sandboxQualification} from '../src/sandbox.mjs';
3
11
  import os from 'node:os';
4
12
  import path from 'node:path';
5
13
  import {createHash} from 'node:crypto';
6
14
  import {spawn,spawnSync} from 'node:child_process';
7
15
  import {createRequire} from 'node:module';
8
- import {fileURLToPath} from 'node:url';
9
- import {discoverNodeProject} from '../src/node-onboarding.mjs';
16
+ import {fileURLToPath,pathToFileURL} from 'node:url';
10
17
  import {processAlive} from '../src/process-liveness.mjs';
11
18
  import {samePath,withinPath,realObservedPath} from '../src/path-identity.mjs';
12
- import {stateIdentity,userStateBase} from '../src/platform-state.mjs';
19
+ import {discoverConfig,projectState,canonicalConfig} from '../src/project-location.mjs';
13
20
  import {findExecutable} from '../src/executable-lookup.mjs';
14
21
  import {readCachedStatus} from '../src/cached-state.mjs';
15
22
  import {assertOwnedStatePlacement} from '../src/owned-state.mjs';
@@ -22,27 +29,54 @@ const help=`REDUE ${version}
22
29
  Usage: redue [--config FILE] [--state-dir DIR] COMMAND [OPTIONS]
23
30
 
24
31
  Commands:
25
- init [--dry-run] [--workspace NAME] [--check KIND]
32
+ setup claude [--yes] Set up REDUE and Claude Code in this project in one step:
33
+ init, agent setup claude, start, one recorded run per check
34
+ remove [--yes] Undo setup: integration, observer state, and the config setup created
35
+ init [--dry-run] [--workspace NAME] [--check KIND] [--recipe typescript-direct|tests-direct|node-script|sandbox]
26
36
  Discover existing scripts; optionally select one workspace/check
37
+ agent setup codex|claude [--dry-run|--apply] [--json]
38
+ agent doctor [codex|claude] [--json]
39
+ agent remove codex|claude [--dry-run|--apply] [--json]
40
+ Connect project instructions; explicit setup only
27
41
  start Start the local observer
28
42
  stop Stop this observer
29
- status [--json|--short] Read conservative cached status
30
- status --sync Reconcile inputs and caller context before answering
43
+ status [--json] Reconcile inputs and caller context, then answer (--sync is the same)
44
+ status --cached [--json] | --short The last answer the observer published, without reconciling
31
45
  explain [CHECK] [--json] Explain applicability (synchronized; may cost more than a rerun)
32
46
  detail [CHECK] [--json] Alias for explain
33
- run CHECK Execute one configured check and record its outcome
47
+ run CHECK [--if-due] Execute one configured check and record its outcome; with --if-due,
48
+ only when its last pass does not apply to the files as they are now
49
+ stats [--json] Per check: skips, time saved, recorded runs, why checks were UNVERIFIED
34
50
  remove-state Stop and remove only this configuration's owned local state
35
51
 
36
52
  No checks run in the background. Direct commands outside redue create no receipts.`;
37
53
 
38
54
  function error(message){console.error(`redue: ${message}`);process.exit(2);}
55
+ // Exits once queued output is written. On macOS, output to a pipe is written
56
+ // asynchronously; exiting with it queued cuts it (at 8 KB through the pipe a
57
+ // Node program, such as an agent's shell tool, reads).
58
+ async function exit(code){await new Promise(resolve=>process.stdout.write('',resolve));process.exit(code);}
39
59
  const args=process.argv.slice(2);
60
+ if(args.length===3&&args[0]==='agent'&&args[1]==='hook'&&args[2]==='claude'){
61
+ // Runs before config discovery: the hook must exit 0 on every failure,
62
+ // because Claude Code treats exit 2 as a block.
63
+ const {runClaudeHook}=await import('../src/claude-hook.mjs');
64
+ await runClaudeHook(fileURLToPath(import.meta.url));process.exit(0);
65
+ }
40
66
  if(args.length===1&&args[0]==='--version'){console.log(version);process.exit(0);}
41
- if(args.includes('--help')||args.includes('-h')||!args.length){console.log(help);process.exit(0);}
42
- const preferredConfig=path.resolve('redue.config.json');
43
- const legacyConfig=path.resolve('vstate.config.json');
44
- let configFile=fs.existsSync(preferredConfig)||!fs.existsSync(legacyConfig)?
45
- preferredConfig:legacyConfig,stateOverride=null;
67
+ // Internal: the entry this command runs, so setup and the hook can tell
68
+ // whether the `redue` on PATH (a link, a package manager's shim, a wrapper)
69
+ // is this install.
70
+ if(args.length===1&&args[0]==='--entry'){console.log(fs.realpathSync(fileURLToPath(import.meta.url)));process.exit(0);}
71
+ if(args.includes('--help')||args.includes('-h')||!args.length){console.log(help);await exit(0);}
72
+ if(args[0]==='setup'||args[0]==='remove'){
73
+ // Loaded on demand; setup works before a config exists.
74
+ const {setupCommand}=await import('../src/setup.mjs');
75
+ try{await setupCommand(args,{entry:fileURLToPath(import.meta.url)});}
76
+ catch(e){error(e.message);}
77
+ await exit(0);
78
+ }
79
+ let configFile=null,stateOverride=null;
46
80
  for(let i=0;i<args.length;){
47
81
  if(args[i]==='--config'||args[i]==='--state-dir'){
48
82
  const flag=args[i],value=args[i+1];if(!value)error(`${flag} requires a path`);
@@ -51,30 +85,68 @@ for(let i=0;i<args.length;){
51
85
  }else i++;
52
86
  }
53
87
  const command=args.shift();
54
- if(!['init','start','stop','status','detail','explain','run','remove-state'].includes(command))
88
+ try{configFile??=command==='init'||stateOverride&&['stop','remove-state'].includes(command)?
89
+ path.resolve(fs.existsSync('redue.config.json')||!fs.existsSync('vstate.config.json')?'redue.config.json':'vstate.config.json'):discoverConfig();}
90
+ catch(e){error(e.message);}
91
+ if(fs.existsSync(path.dirname(configFile)))configFile=canonicalConfig(configFile);
92
+ if(command==='agent'){
93
+ // Loaded on demand: onboarding and agent setup are not on the run/status path.
94
+ const {agentCommand}=await import('../src/agent-integration.mjs');
95
+ try{await agentCommand(args,{configFile,stateOverride,entry:fileURLToPath(import.meta.url)});}
96
+ catch(e){error(e.message);}
97
+ await exit(0);
98
+ }
99
+ if(!['init','start','stop','status','detail','explain','run','stats','remove-state'].includes(command))
55
100
  error(`unknown command ${command||'<none>'}; use --help`);
56
101
  const json=args.includes('--json');
57
- const sync=args.includes('--sync');
102
+ // run --if-due: run the check only when its last pass does not apply now.
103
+ const ifDue=command==='run'&&args.includes('--if-due');
104
+ if(ifDue)args.splice(args.indexOf('--if-due'),1);
105
+ // status syncs by default (warm, it costs well under 100 ms more than the
106
+ // cached answer); --cached and --short read the cached answer.
107
+ const cachedOnly=args.includes('--cached');
58
108
  const dryRun=args.includes('--dry-run');
59
109
  const short=args.includes('--short');
60
- let workspaceSelection=null,checkSelection=null;
110
+ const sync=args.includes('--sync')||command==='status'&&!cachedOnly&&!short;
111
+ let workspaceSelection=null,checkSelection=null,recipeSelection=null;
61
112
  if(command==='init')for(let i=0;i<args.length;){
62
- if(args[i]==='--workspace'||args[i]==='--check'){
113
+ if(args[i]==='--workspace'||args[i]==='--check'||args[i]==='--recipe'){
63
114
  const flag=args[i],value=args[i+1];if(!value||value.startsWith('--'))error(`${flag} requires a name`);
64
- if(flag==='--workspace')workspaceSelection=value;else checkSelection=value;
115
+ if(flag==='--workspace')workspaceSelection=value;else if(flag==='--recipe')recipeSelection=value;else checkSelection=value;
65
116
  args.splice(i,2);
66
117
  }else i++;
67
118
  }
68
119
  const check=['run','detail','explain'].includes(command)&&args[0]&&!args[0].startsWith('--')?args.shift():null;
69
120
  if(command==='run'&&!check)error('run requires a check name');
70
- if(args.some(value=>!['--json','--sync','--dry-run','--short'].includes(value)))error('unknown option; use --help');
121
+ if(args.some(value=>!['--json','--sync','--cached','--dry-run','--short'].includes(value)))error('unknown option; use --help');
71
122
  if(sync&&!['status','detail','explain'].includes(command))error('--sync is for status or detail');
72
- if(short&&(command!=='status'||json||sync))error('--short is a cached status option; do not combine it with --sync or --json');
123
+ if(cachedOnly&&(command!=='status'||args.includes('--sync')))error('--cached is a status option; do not combine it with --sync');
124
+ if(short&&(command!=='status'||json||args.includes('--sync')))error('--short is a cached status option; do not combine it with --sync or --json');
73
125
  if(dryRun&&command!=='init')error('--dry-run is for init');
74
126
 
75
127
  if(command==='init'){
76
- let discovery;try{discovery=discoverNodeProject(process.cwd());}
77
- catch(e){error(e.message);}
128
+ const {discoverNodeProject,sourceLinkIssue}=await import('../src/node-onboarding.mjs');
129
+ const {discoverPythonProject}=await import('../src/python-onboarding.mjs');
130
+ const {sandboxTrial}=await import('../src/sandbox.mjs');
131
+ // A Node project, a Python project, or both in one checkout.
132
+ const python=!recipeSelection||recipeSelection==='sandbox'?discoverPythonProject(process.cwd()):null;
133
+ let discovery;
134
+ if(fs.existsSync('package.json')||!python)try{discovery=discoverNodeProject(process.cwd(),{recipe:recipeSelection});}
135
+ catch(e){error(e.message);}
136
+ else{const root=realObservedPath(process.cwd());
137
+ const git=spawnSync('git',['rev-parse','--show-toplevel'],{cwd:root,encoding:'utf8',timeout:3000,stdio:['ignore','pipe','ignore']});
138
+ discovery={schema:1,root,manager:{name:null,version:null,layout:null},observationIssue:git.status===0?sourceLinkIssue(root):
139
+ 'Git checkout is unavailable; filesystem observation needs a checkout',workspaces:false,node:process.version,
140
+ checks:[],ambiguous:[],skipped:[],config:{schema:1,checks:[]}};}
141
+ if(python){
142
+ // A Python check takes its tool's name where the name is taken.
143
+ const taken=new Set(discovery.checks.map(row=>row.config.name));
144
+ for(const row of python.checks){if(taken.has(row.config.name))row.config.name=`${row.config.kind}:${row.config.command[0].split('/').at(-1)}`;
145
+ taken.add(row.config.name);}
146
+ if(!discovery.observationIssue)discovery.checks.push(...python.checks);
147
+ discovery.skipped.push(...python.skipped);
148
+ discovery.python=true;
149
+ }
78
150
  if(workspaceSelection||checkSelection){
79
151
  const chosen=discovery.checks.filter(row=>
80
152
  (!workspaceSelection||row.config.workspace===workspaceSelection||
@@ -83,6 +155,44 @@ if(command==='init'){
83
155
  if(!chosen.length)error('no discovered check matches the selected workspace and kind');
84
156
  discovery.checks=chosen;discovery.config.checks=chosen.map(row=>row.config);
85
157
  }
158
+ // A sandboxed check is proposed only when it gives the same exit status and
159
+ // output inside the sandbox as outside it, with the checkout's git-ignored
160
+ // files and documentation hidden (sandbox-v3). When the plain run passed
161
+ // and the sandboxed one failed only because it was refused a file it hid
162
+ // (sandboxTrial's refusedHidden), the check gets the whole checkout
163
+ // (sandbox-v1) without another run: without the hiding it can only fail
164
+ // where the plain run passes. Any other difference is tried once with the
165
+ // whole checkout, reusing the plain run. Otherwise init writes what it
166
+ // would have without the sandboxed recipe, with the reason. Each check
167
+ // costs one plain run and one or two sandboxed runs.
168
+ const trialIssue=trial=>trial.issue?`the sandbox cannot run here (${trial.issue})`:
169
+ // A failure both ways that shows a refusal cannot be told from a real one.
170
+ trial.same?null:trial.plain!==trial.sandboxed?`exit ${trial.sandboxed} inside the sandbox, ${trial.plain} outside it${trial.line?` (${trial.line})`:''}`:
171
+ trial.line?`it fails both ways (exit ${trial.plain}), with a sign of a refusal inside the sandbox (${trial.line})`:
172
+ `the output differs inside the sandbox: ${JSON.stringify(trial.output.sandboxed)} instead of ${JSON.stringify(trial.output.plain)}`;
173
+ let trials=0;
174
+ for(const [at,row] of [...discovery.checks.entries()]){
175
+ if(row.qualification!==sandboxQualification||row.level!=='ready')continue;
176
+ 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'});
178
+ if(!docs.issue)trials++;
179
+ if(!docs.issue&&docs.same){
180
+ 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;}
184
+ // 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;
187
+ if(trial!==docs&&!trial.issue)trials++;
188
+ const issue=trialIssue(trial);
189
+ 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}`});}
193
+ }
194
+ discovery.checks=discovery.checks.filter(Boolean).map(({fallback,...row})=>row);
195
+ discovery.config.checks=discovery.checks.map(row=>row.config);
86
196
  const configured=fs.existsSync(configFile),unsupported=Boolean(discovery.observationIssue||
87
197
  discovery.manager.issue&&!['npm','yarn','pnpm'].includes(discovery.manager.name)||
88
198
  discovery.manager.name==='yarn'&&!discovery.manager.version);
@@ -91,24 +201,27 @@ if(command==='init'){
91
201
  package_manager:discovery.manager.name,version:discovery.manager.version,
92
202
  installation_layout:discovery.manager.layout,
93
203
  issue:discovery.observationIssue||discovery.manager.issue,workspaces:discovery.workspaces,
94
- ambiguous:discovery.ambiguous,
204
+ ambiguous:discovery.ambiguous,skipped:discovery.skipped||[],
95
205
  checks:discovery.checks.map(({config:check,level,reason})=>({name:check.name,
96
206
  script:check.script||null,command:check.command||null,
97
207
  workspace:check.workspace||null,level,reason})),config:configFile,
98
- written:willWrite,existing:configured};
208
+ written:willWrite,existing:configured,writable:!configured&&!unsupported&&discovery.checks.length>0};
99
209
  if(!json) {
100
- console.log(`Node ${summary.node}; ${summary.package_manager||'unknown package manager'}`+
210
+ if(summary.package_manager||!discovery.python)console.log(`Node ${summary.node}; ${summary.package_manager||'unknown package manager'}`+
101
211
  `${summary.version?' '+summary.version:''}; ${summary.installation_layout} installation.`);
212
+ if(discovery.python)console.log('Python project; tools run from .venv.');
102
213
  if(summary.issue)console.log(`Boundary: ${summary.issue}`);
103
214
  for(const row of summary.checks){
104
215
  console.log(` ${row.name} (${row.script||row.command?.join(' ')}) — `+
105
216
  `${{ready:'ready to qualify',recording:'recording-only',unsupported:'unsupported'}[row.level]||row.level}: ${row.reason}`);
106
217
  if(row.script&&row.command)console.log(` Recorded invocation: ${row.command.map(value=>
107
- value==='@node'?'node':value.startsWith('@typescript-bin:')?
218
+ value==='@node'?'node':value==='@typescript-compiler:.'?'[reviewed local TypeScript compiler implementation]':
219
+ value.startsWith('@test-runner:')?`[local ${value.slice('@test-runner:'.length)}]`:value.startsWith('@typescript-bin:')?
108
220
  `[installed TypeScript in ${value.slice('@typescript-bin:'.length)}]`:value).join(' ')}`);
109
221
  }
110
222
  for(const row of summary.ambiguous)
111
223
  console.log(` ${row.kind}: ambiguous scripts (${row.scripts.join(', ')}); choose one explicitly`);
224
+ for(const row of summary.skipped)console.log(` Skipped: ${row.reason}`);
112
225
  if(!summary.checks.length)console.log('No useful existing verification scripts found.');
113
226
  console.log(configured?`Existing ${configFile} left unchanged.`:
114
227
  dryRun?`Preview only; ${configFile} was not written.`:
@@ -118,13 +231,14 @@ if(command==='init'){
118
231
  if(!samePath(realObservedPath(path.dirname(configFile)),discovery.root))
119
232
  error('init writes only at the repository root; use --config there or --dry-run');
120
233
  fs.writeFileSync(configFile,JSON.stringify(discovery.config,null,2)+'\n',{flag:'wx'});
121
- if(!json){console.log(`Created ${configFile}. No checks were executed.`);
234
+ if(!json){console.log(`Created ${configFile}. ${trials?'Checks ran inside and outside the sandbox to choose their recipe; no results were recorded.':'No checks were executed.'}`);
122
235
  console.log('Ready to qualify means the supported contract will be validated; it is not verification evidence.');
123
236
  console.log(`Next: review the config, then redue start and redue run ${JSON.stringify(discovery.checks[0].config.name)}.`);
124
- console.log('Use redue status first. --sync can establish applicability, but may cost more than rerunning a cheap check.');}
237
+ console.log('Connect your agent: redue agent setup codex (or claude). Preview with --dry-run.');
238
+ console.log('Then redue status says whether each result applies to the files as they are now.');}
125
239
  } else if(unsupported&&!json)console.log(`No config written: ${summary.issue||'unsupported discovery boundary'}.`);
126
240
  if(json)console.log(JSON.stringify({...summary,proposed_config:discovery.config}));
127
- process.exit(0);
241
+ await exit(0);
128
242
  }
129
243
  function printLifecycle(output,action,machine){
130
244
  if(machine){process.stdout.write(output);return;}
@@ -141,11 +255,12 @@ function printLifecycle(output,action,machine){
141
255
  if(stateOverride&&['stop','remove-state'].includes(command)){
142
256
  const runtime=path.join(stateOverride,'project-runtime-v1.json');
143
257
  if(!fs.existsSync(runtime)){console.log('No REDUE state exists at this path.');process.exit(0);}
258
+ mark('client.engine_submit');
144
259
  const result=spawnSync(process.execPath,[engine,runtime,command==='stop'?'stop':'uninstall'],
145
260
  {cwd:os.tmpdir(),stdio:['inherit','pipe','inherit'],encoding:'utf8',env:process.env});
146
261
  if(result.error)error(result.error.message);
147
262
  if(result.stdout)printLifecycle(result.stdout,command,json);
148
- process.exit(result.status??2);
263
+ await exit(result.status??2);
149
264
  }
150
265
  let publicConfig;
151
266
  try{publicConfig=JSON.parse(fs.readFileSync(configFile,'utf8'));}
@@ -157,10 +272,7 @@ for(const key of Object.keys(publicConfig))
157
272
  if(!['schema','root','checks','packageManager'].includes(key))error(`unsupported config field ${key}`);
158
273
  if(check&&!publicConfig.checks.some(row=>row.name===check))error(`unknown check ${check}; use redue status to list configured checks`);
159
274
  const root=realObservedPath(path.resolve(path.dirname(configFile),publicConfig.root||'.'));
160
- const stateBase=userStateBase(process.platform,process.env,os.homedir());
161
- const state=stateOverride||path.join(stateBase,'vstate',
162
- `vstate-${createHash('sha256').update(stateIdentity(root)+'\0'+
163
- stateIdentity(configFile)).digest('hex').slice(0,16)}`);
275
+ const state=stateOverride||projectState(root,configFile);
164
276
  if(!/^(?:vstate|redue)-/.test(path.basename(state)))
165
277
  error('--state-dir must name a dedicated redue-* directory outside the checkout (legacy vstate-* is also supported)');
166
278
  const runtimeFile=path.join(state,'project-runtime-v1.json');
@@ -169,9 +281,18 @@ function which(name){
169
281
  if(found)return found;
170
282
  error(`cannot resolve executable ${name} on PATH`);
171
283
  }
172
- function token(value){
284
+ function token(value,cwd='.'){
173
285
  if(value==='@node')return process.execPath;
174
286
  if(value==='@project')return root;
287
+ if(value==='@typescript-compiler:.'){
288
+ try{return directCompiler(root,{verify:false}).entry;}catch(e){error(`direct compiler unavailable: ${e.code||e.message}`);}
289
+ }
290
+ if(value.startsWith('@test-runner:')){
291
+ // Qualification reports a missing runner; status and stop still work.
292
+ try{return testRunnerAt(root,cwd,value.slice('@test-runner:'.length)).entry;}
293
+ catch{return path.join(root,cwd,'node_modules',value.slice('@test-runner:'.length),'<unavailable>');}
294
+ }
295
+ if(value==='@vstate/direct-typescript-probe')return path.join(productRoot,'src','direct-typescript-probe.mjs');
175
296
  if(value.startsWith('@typescript-bin:')){
176
297
  const dir=value.slice('@typescript-bin:'.length);
177
298
  if(!dir||path.isAbsolute(dir)||dir.split('/').includes('..'))
@@ -192,10 +313,10 @@ function token(value){
192
313
  if(value.startsWith('@which:'))return which(value.slice(7));
193
314
  return value;
194
315
  }
195
- function argv(values,label){
316
+ function argv(values,label,cwd){
196
317
  if(!Array.isArray(values)||!values.length||values.some(v=>typeof v!=='string'||!v))
197
318
  error(`${label} must be nonempty exact argv`);
198
- return values.map(token);
319
+ return values.map(value=>token(value,cwd));
199
320
  }
200
321
  const categories=['source','generated','installedDependencies','environment','toolchain','runtime',
201
322
  'probeContinuity'];
@@ -208,8 +329,12 @@ const checks=publicConfig.checks.map(check=>{
208
329
  for(const category of categories)if(check.coverage?.[category]&&
209
330
  typeof check.coverageReview?.[category]!=='string')
210
331
  error(`${check.name}: reviewed coverage ${category} needs a rationale`);
332
+ const npmDirect=check.qualification===directQualification;
211
333
  const pnpmTypecheck=check.qualification==='pnpm-tsc-v1';
212
- if(check.script&&check.command&&!pnpmTypecheck)
334
+ const testsDirect=check.qualification===testsQualification;
335
+ const generalScript=check.qualification===scriptQualification;
336
+ const sandboxed=isSandboxed(check.qualification);
337
+ if(check.script&&check.command&&!pnpmTypecheck&&!npmDirect&&!testsDirect)
213
338
  error(`${check.name}: choose script or command, not both`);
214
339
  const manager=publicConfig.packageManager;
215
340
  if(check.script&&!['npm','yarn','pnpm'].includes(manager))
@@ -218,8 +343,24 @@ const checks=publicConfig.checks.map(check=>{
218
343
  [`@which:${manager}`,'run',check.script]:null;
219
344
  const npmAutomatic=check.qualification==='typescript-noemit-v1';
220
345
  const yarnWorkspace=check.qualification==='yarn-workspace-tsc-v1';
221
- if(check.qualification&&!npmAutomatic&&!yarnWorkspace&&!pnpmTypecheck)
346
+ if(check.qualification&&!npmAutomatic&&!npmDirect&&!yarnWorkspace&&!pnpmTypecheck&&!testsDirect&&!generalScript&&!sandboxed)
222
347
  error(`${check.name}: unknown qualification contract`);
348
+ // The general recipe runs the root package script through the package manager.
349
+ if(generalScript&&(!['npm','pnpm','yarn'].includes(manager)||!check.script||check.command||check.cwd&&check.cwd!=='.'||
350
+ check.workspace||!scriptKinds.includes(check.kind)))
351
+ error(`${check.name}: the general recipe needs a root typecheck, test or lint script, without command or cwd`);
352
+ // The sandboxed recipe runs a root package script, or a tool from the project's .venv.
353
+ if(sandboxed&&(check.cwd&&check.cwd!=='.'||check.workspace||!sandboxKinds.includes(check.kind)||
354
+ (check.script?!['npm','pnpm','yarn'].includes(manager):!pythonTool(check))))
355
+ error(`${check.name}: the sandboxed recipe needs a root ${sandboxKinds.join(' or ')} script, or a command that runs a tool from .venv/bin, without cwd`);
356
+ if(npmDirect&&(manager!=='npm'||check.cwd&&check.cwd!=='.'||!Array.isArray(check.command)||
357
+ check.command[0]!=='@node'||check.command[1]!=='@typescript-compiler:.'||
358
+ !tscRun({},{script:null,args:check.command.slice(2)}).run?.noEmit))
359
+ error(`${check.name}: direct recipe requires its explicit local compiler command`);
360
+ // The script's own arguments follow the runner; qualification compares them with the script.
361
+ if(testsDirect&&(!['npm','pnpm','yarn'].includes(manager)||!check.script||check.cwd&&check.cwd!=='.'&&!check.workspace||!Array.isArray(check.command)||
362
+ check.command[0]!=='@node'||!/^@test-runner:(?:vitest|jest)$/.test(check.command[1]||'')))
363
+ error(`${check.name}: test recipe requires its explicit local test runner command`);
223
364
  if(npmAutomatic&&(manager!=='npm'||check.cwd&&check.cwd!=='.'))
224
365
  error(`${check.name}: automatic TypeScript qualification currently needs root npm script`);
225
366
  if(yarnWorkspace&&(manager!=='yarn'||!check.workspace||check.cwd&&check.cwd!=='.'||
@@ -227,25 +368,33 @@ const checks=publicConfig.checks.map(check=>{
227
368
  check.command[1]!=='workspace'||check.command[2]!==check.workspace||
228
369
  check.command[3]!=='run'))
229
370
  error(`${check.name}: Yarn workspace qualification needs its discovered workspace command`);
230
- if(pnpmTypecheck&&(manager!=='pnpm'||!check.script||!check.command||
371
+ if(pnpmTypecheck&&(manager!=='pnpm'||!Array.isArray(check.command)||
231
372
  check.command[0]!=='@node'||
232
373
  check.command[1]!==`@typescript-bin:${check.cwd||'.'}`||
233
374
  check.workspace&&check.cwd==='.'||
234
- !check.command.slice(2).every(value=>typeof value==='string'&&value)))
375
+ !tscRun({},{script:null,args:check.command.slice(2)}).run))
235
376
  error(`${check.name}: pnpm qualification needs its discovered standalone TypeScript command`);
236
377
  const reason=check.kind==='test'?'test runtime, transforms and cache inputs need review':
237
378
  check.kind==='lint'?'lint plugins, config and resolver inputs need review':
238
379
  check.kind==='build'?'build environment and generated inputs need review':
239
380
  'check inputs need review';
240
- const autoProbes=npmAutomatic?[['@node','@vstate/typescript-contract-probe',
381
+ // A chained script has one check per tsc run: each check's run counts them,
382
+ // so a run added to or removed from the script invalidates them all.
383
+ const runCount=(npmDirect||pnpmTypecheck)&&check.script?publicConfig.checks.filter(other=>
384
+ other.qualification===check.qualification&&other.script===check.script&&
385
+ (other.cwd||'.')===(check.cwd||'.')&&(other.workspace||null)===(check.workspace||null)).length:1;
386
+ if(runCount>16)error(`${check.name}: more than 16 checks for script ${check.script}`);
387
+ const run=npmDirect||pnpmTypecheck?runArgv({script:check.script??null,args:check.command.slice(2),count:runCount}):null;
388
+ const autoProbes=npmDirect?[['@node','@vstate/direct-typescript-probe','@project',...run]]:npmAutomatic?[['@node','@vstate/typescript-contract-probe',
241
389
  '@project',check.script,'@which:npm']]:yarnWorkspace?
242
390
  [['@node','@vstate/yarn-workspace-typecheck-probe','@project',
243
391
  check.workspace,check.command[4],'@which:yarn']]:pnpmTypecheck?
244
392
  [['@node','@vstate/pnpm-typecheck-probe','@project',
245
- check.workspace||'.',check.script]]:[];
393
+ check.workspace||'.',...run]]:[];
246
394
  const windowsContext=process.platform==='win32'?
247
395
  ['ComSpec','COMSPEC','PATHEXT','USERPROFILE']:[];
248
- const autoEnvironment=npmAutomatic?{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME',
396
+ const autoEnvironment=npmDirect?directEnvironment:testsDirect?testEnvironment:generalScript?scriptEnvironment(manager):
397
+ sandboxed?sandboxEnvironment(Boolean(pythonTool(check)),check.script?manager:null,check.kind):npmAutomatic?{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME',
249
398
  'NODE_ENV','BASH_ENV','ENV',...windowsContext],prefixes:['npm_config_','DYLD_','TSGO_'],
250
399
  pathExecutables:['node','npm'],executableIdentity:true}:yarnWorkspace?
251
400
  {variables:['NODE_OPTIONS','NODE_PATH','CI','HOME','NODE_ENV','BASH_ENV','ENV',...windowsContext],
@@ -254,10 +403,11 @@ const checks=publicConfig.checks.map(check=>{
254
403
  {variables:['NODE_OPTIONS','NODE_PATH','CI','HOME','NODE_ENV','BASH_ENV','ENV',...windowsContext],
255
404
  prefixes:['PNPM_','COREPACK_','npm_config_','DYLD_','TSGO_'],
256
405
  pathExecutables:['node'],executableIdentity:true}:{};
257
- return {name:check.name,command:argv(scriptCommand||check.command,`${check.name}.command`),
406
+ return {name:check.name,command:argv(scriptCommand||check.command,`${check.name}.command`,
407
+ typeof check.cwd==='string'&&check.cwd&&!path.isAbsolute(check.cwd)&&!check.cwd.split('/').includes('..')?check.cwd:'.'),
258
408
  cwd:check.cwd,inputs:check.inputs||[],generatedInputs:check.generatedInputs||[],
259
409
  installedInputs:check.installedInputs||(
260
- npmAutomatic?['node_modules/**']:[]),
410
+ npmDirect?[...directInstalledInputs]:npmAutomatic||testsDirect?['node_modules/**']:[]),
261
411
  allowedExternalRoots:check.allowedExternalRoots||[],
262
412
  probes:[...(check.probes||[]),...autoProbes]
263
413
  .map((p,i)=>argv(p,`${check.name}.probes[${i}]`)),
@@ -266,18 +416,36 @@ const checks=publicConfig.checks.map(check=>{
266
416
  coverageReasons:check.coverageReasons||Object.fromEntries(
267
417
  categories.map(category=>[category,reason])),
268
418
  qualification:check.qualification||null,script:check.script||null,
419
+ ...(runCount>1?{runCount}:{}),
420
+ ...(npmAutomatic?{qualificationInterpretation:'npm-launcher-withheld@1'}:{}),
421
+ ...(npmDirect?{qualificationInterpretation:directInterpretation}:{}),
422
+ ...(testsDirect?{qualificationInterpretation:testsInterpretation}:{}),
423
+ ...(generalScript?{qualificationInterpretation:scriptInterpretation}:{}),
424
+ ...(sandboxed?{qualificationInterpretation:sandboxInterpretationOf(check.qualification,check.kind)}:{}),
269
425
  workspace:check.workspace||null,kind:check.kind||null};
270
426
  });
271
427
  const runtime={schema:1,provider:'declared-project@1',root,state,checks};
272
428
  try{assertOwnedStatePlacement(runtime);}catch(e){error(e.message);}
273
429
  const serialized=JSON.stringify(runtime,null,2)+'\n';
430
+ if(command==='stats'){
431
+ const {readStats,renderStats}=await import('../src/stats.mjs');
432
+ const value=readStats(state,publicConfig.checks.map(item=>item.name));
433
+ console.log(json?JSON.stringify(value):renderStats(value));
434
+ await exit(0);
435
+ }
274
436
  const maintenance=command==='stop'||command==='remove-state';
275
437
  if(maintenance&&!fs.existsSync(runtimeFile)){
276
438
  console.log(`No REDUE state exists for ${configFile}.`);
277
439
  process.exit(0);
278
440
  }
279
441
  if(!maintenance)fs.mkdirSync(state,{recursive:true,mode:0o700});
280
- if(!maintenance&&fs.existsSync(runtimeFile)){
442
+ // Two commands can meet on fresh state (SessionStart's start and the first
443
+ // status): whichever creates the runtime file first wins, and the other
444
+ // treats it as existing.
445
+ let created=false;
446
+ if(!maintenance&&!fs.existsSync(runtimeFile))
447
+ try{fs.writeFileSync(runtimeFile,serialized,{flag:'wx',mode:0o600});created=true;}catch(e){if(e.code!=='EEXIST')throw e;}
448
+ if(!maintenance&&!created){
281
449
  const previous=fs.readFileSync(runtimeFile,'utf8');
282
450
  if(previous!==serialized){
283
451
  const pidFile=path.join(state,'observer.lock','pid');
@@ -287,18 +455,41 @@ if(!maintenance&&fs.existsSync(runtimeFile)){
287
455
  }
288
456
  fs.writeFileSync(runtimeFile,serialized,{mode:0o600});
289
457
  }
290
- }else if(!maintenance)fs.writeFileSync(runtimeFile,serialized,{flag:'wx',mode:0o600});
458
+ }
291
459
 
292
460
  if(command==='status'&&!sync){
293
461
  const value=readCachedStatus(state,publicConfig.checks);
294
462
  console.log(json?JSON.stringify(value):renderStatus(value,{short}));
295
- process.exit(0);
463
+ await exit(0);
296
464
  }
297
465
 
298
466
  const details=command==='detail'||command==='explain';
299
467
  const internal=command==='remove-state'?'uninstall':
300
468
  details?'sync':command==='status'&&sync?'sync':command;
301
- if(command==='run'){
469
+ // On macOS and Linux the engine runs in this process, which saves a Node startup
470
+ // on every run; its own handlers receive SIGINT and SIGTERM. Windows keeps a
471
+ // child engine because cancellation reaches it over IPC.
472
+ // Asked when this part of a command runs, after earlier parts may have
473
+ // changed files. REDUE_FORCE=1 runs it anyway.
474
+ if(ifDue&&process.env.REDUE_FORCE!=='1'){
475
+ const [{verdict},{note,skipNote,dueNote}]=await Promise.all([import('../src/claude-hook.mjs'),import('../src/stats.mjs')]);
476
+ const asked=spawnSync(process.execPath,[fileURLToPath(import.meta.url),'--config',configFile,...(stateOverride?['--state-dir',stateOverride]:[]),
477
+ 'status','--sync','--json'],{encoding:'utf8',stdio:['ignore','pipe','ignore'],env:process.env,timeout:600_000});
478
+ let status=null;try{status=JSON.parse(asked.stdout);}catch{}
479
+ const due=dueNote(status,check);
480
+ if(due)note(state,due);
481
+ if(verdict(status,check)){
482
+ note(state,skipNote(status,check,'if-due'));
483
+ const row=status.checks.find(item=>item.name===check);
484
+ if(json)console.log(JSON.stringify({check,skipped:true,result:'PASS',run_id:row.invocation.runId}));
485
+ else console.error(`REDUE: ${check} passed on these exact inputs (run ${row.invocation.runId}); not run again. REDUE_FORCE=1 runs it anyway.`);
486
+ await exit(0);
487
+ }
488
+ }
489
+ if(command==='run'&&process.platform!=='win32'){
490
+ process.argv=[process.argv[0],engine,runtimeFile,internal,check,...(!json?['--human']:[])];
491
+ await import(pathToFileURL(engine).href);
492
+ }else if(command==='run'){
302
493
  const child=spawn(process.execPath,[engine,runtimeFile,internal,check,...(!json?['--human']:[])],
303
494
  {stdio:['inherit','inherit','inherit','ipc'],env:process.env});
304
495
  const forward=signal=>{if(child.exitCode!==null)return;
@@ -313,17 +504,32 @@ if(command==='run'){
313
504
  process.off('SIGINT',interrupt);process.off('SIGTERM',terminate);
314
505
  if(outcome.error)error(outcome.error.message);
315
506
  process.exit(outcome.status??(outcome.signal?128+os.constants.signals[outcome.signal]:1));
507
+ }else if(internal==='sync'&&process.platform!=='win32'){
508
+ // As for run, the synchronized status engine runs in this process.
509
+ process.argv=[process.argv[0],engine,runtimeFile,internal,'','--value'];
510
+ mark('client.engine_submit',{inProcess:true});
511
+ const synchronized=await import(pathToFileURL(engine).href);
512
+ await synchronized.finished;
513
+ mark('client.engine_complete',{exit:process.exitCode??0});
514
+ if(synchronized.syncValue)showSynchronized(synchronized.syncValue);
515
+ await exit(process.exitCode??0);
516
+ }else{
517
+ mark('client.engine_submit');
518
+ const result=spawnSync(process.execPath,[engine,runtimeFile,internal,...(check?[check]:[])],
519
+ {stdio:['inherit','pipe','inherit'],
520
+ encoding:'utf8',env:traceEnvironment()});
521
+ mark('client.engine_complete',{exit:result.status,signal:result.signal});
522
+ if(result.error)error(result.error.message);
523
+ if(result.stdout){
524
+ if(['status','detail','explain'].includes(command)){
525
+ let value;try{value=JSON.parse(result.stdout);}catch{error('status response could not be read; applicability is unknown');}
526
+ showSynchronized(value);
527
+ }else printLifecycle(result.stdout,command,json);
528
+ }
529
+ await exit(result.status??1);
316
530
  }
317
- const result=spawnSync(process.execPath,[engine,runtimeFile,internal,...(check?[check]:[])],
318
- {stdio:['inherit','pipe','inherit'],
319
- encoding:'utf8',env:process.env});
320
- if(result.error)error(result.error.message);
321
- if(result.stdout){
322
- if(['status','detail','explain'].includes(command)){
323
- let value;try{value=JSON.parse(result.stdout);}catch{error('status response could not be read; applicability is unknown');}
324
- if(details&&check)value={...value,checks:value.checks.filter(row=>row.name===check)};
325
- if(json)console.log(JSON.stringify(value));
326
- else console.log(details?renderExplain(value,check):renderStatus(value,{short}));
327
- }else printLifecycle(result.stdout,command,json);
531
+ function showSynchronized(value){
532
+ if(details&&check)value={...value,checks:value.checks.filter(row=>row.name===check)};
533
+ if(json)console.log(JSON.stringify(value));
534
+ else console.log(details?renderExplain(value,check):renderStatus(value,{short}));
328
535
  }
329
- process.exit(result.status??1);
package/docs/README.md CHANGED
@@ -3,9 +3,11 @@
3
3
  For users:
4
4
 
5
5
  - [Install, initialize, run, and remove](user/getting-started.md)
6
+ - [Fixed-build opt-in development pilot](user/pilot.md)
6
7
  - [Supported projects and conservative boundaries](user/supported-projects.md)
8
+ - [Sandboxed recipe: typecheck and lint in any language, Python included](user/sandbox.md)
7
9
  - [Configuration](user/configuration.md)
8
- - [Coding-agent decision policy](user/agents.md)
10
+ - [Connect coding agents and apply the decision policy](user/agents.md)
9
11
  - [JSON contract](user/json.md)
10
12
  - [Shell usage](user/shell.md)
11
13