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