@redue/cli 0.1.0-alpha.21 → 0.1.0-alpha.23

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@redue/cli",
3
3
  "private": false,
4
- "version": "0.1.0-alpha.21",
4
+ "version": "0.1.0-alpha.23",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
@@ -11,7 +11,7 @@ import {readProject,projectState,canonicalConfig} from './project-location.mjs';
11
11
  import {readCachedStatus} from './cached-state.mjs';
12
12
  import {samePath,withinPath} from './path-identity.mjs';
13
13
  import {processAlive} from './process-liveness.mjs';
14
- import {fanoutRules,hookCommand,hookForms,redueFor} from './claude-hook.mjs';
14
+ import {fanoutRules,hookCommand,hookForms,redueFor,runsEntry,shellQuote as quote} from './claude-hook.mjs';
15
15
 
16
16
  const policy=fs.readFileSync(new URL('./agent-policy.md',import.meta.url),'utf8');
17
17
  const hosts={codex:{instruction:'AGENTS.md',skill:'.agents/skills/redue-verification/SKILL.md'},
@@ -25,17 +25,53 @@ const sameJson=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
25
25
  // The hook waits for status up to 10 s; 15 s leaves room for starting Node.
26
26
  // Fan-out commands (turbo, nx, `pnpm -r`) and root scripts that run one get
27
27
  // prefix rules; the hook decides from the full command.
28
- function hookFor(config,relative,scripts){
28
+ function hookFor(config,relative,scripts,command=hookCommand){
29
29
  const rules=[...new Set([...hookForms(config,relative,scripts).flatMap(row=>[...row.forms,...row.local]),
30
30
  ...fanoutRules(config,scripts)])].map(form=>`Bash(${form}*)`);
31
- return rules.length?{matcher:'Bash',hooks:rules.map(rule=>({type:'command',if:rule,command:hookCommand,timeout:15}))}:null;
31
+ return rules.length?{matcher:'Bash',hooks:rules.map(rule=>({type:'command',if:rule,command,timeout:15}))}:null;
32
32
  }
33
33
  // The same command on SessionStart starts the observer; it needs no check to match.
34
- const sessionHook={hooks:[{type:'command',command:hookCommand,timeout:10}]};
34
+ const sessionHookFor=(command=hookCommand)=>({hooks:[{type:'command',command,timeout:10}]});
35
35
  const events=['PreToolUse','SessionStart'];
36
+ // The absolute form: Node and REDUE's entry (redue.mjs), each quoted as quote()
37
+ // writes it. [node, entry] or null.
38
+ function absoluteHook(command){
39
+ const word="'((?:[^']|'\\\\'')*)'";
40
+ const match=new RegExp(`^${word} ${word} agent hook claude$`).exec(command??'');
41
+ if(!match)return null;
42
+ const [node,entry]=[match[1],match[2]].map(value=>value.replaceAll("'\\''","'"));
43
+ // REDUE's entry file, or the bin link npm installs for it.
44
+ return /^redue(\.mjs)?$/.test(path.basename(entry))?[node,entry]:null;
45
+ }
46
+ export const isHookCommand=command=>command===hookCommand||Boolean(absoluteHook(command));
47
+ // The command Claude Code runs for the hook. Plain `redue` when this install
48
+ // is the one both this shell and a login shell find on PATH. Otherwise this
49
+ // install's Node and entry by absolute path, so a Claude Code started from
50
+ // another shell, an IDE or with a shorter PATH still runs it, and an older
51
+ // install on PATH does not answer for this one. The settings file is
52
+ // per-developer, so a machine path is fine there.
53
+ export function hookInvocation(entry,{env=process.env,platform=process.platform}={}){
54
+ if(platform==='win32')return {command:hookCommand,reason:'Windows: plain redue on PATH'};
55
+ let target;try{target=fs.realpathSync(entry);}catch{target=entry;}
56
+ // Paths that survive upgrades where they can: the entry as invoked (a
57
+ // global bin link, not the versioned file behind it) and the node on PATH
58
+ // (Homebrew's link, not its versioned cellar).
59
+ const real=file=>{try{return fs.realpathSync(file);}catch{return null;}};
60
+ const invoked=typeof process.argv[1]==='string'&&path.isAbsolute(process.argv[1])&&real(process.argv[1])===target?process.argv[1]:target;
61
+ const execReal=real(process.execPath);
62
+ const node=(env.PATH||'').split(path.delimiter).map(dir=>path.join(dir||'.','node')).find(file=>real(file)===execReal)??process.execPath;
63
+ const same=file=>runsEntry(file,target,env);
64
+ const here=findExecutable('redue',{env});
65
+ const login=spawnSync(env.SHELL||'/bin/sh',['-lc','command -v redue'],{env,encoding:'utf8',timeout:5000,stdio:['ignore','pipe','ignore']});
66
+ const loginPath=login.status===0?login.stdout.trim().split('\n').pop():null;
67
+ if(same(here)&&same(loginPath))return {command:hookCommand,reason:`redue on PATH is this install (${here})`};
68
+ return {command:`${quote(node)} ${quote(invoked)} agent hook claude`,
69
+ reason:!here&&!loginPath?'redue is not on PATH; the hook names this install by its absolute path':
70
+ `redue on PATH is ${here||loginPath||'missing'} in ${same(here)?'a login shell':'this shell'}, not this install; the hook names this install by its absolute path`};
71
+ }
36
72
  // REDUE's entry is recognized by its command, not by a committed record: the
37
73
  // local file differs between checkouts, and users may edit the entry.
38
- const ownedHook=entry=>Array.isArray(entry?.hooks)&&entry.hooks.length>0&&entry.hooks.every(h=>h?.command===hookCommand);
74
+ const ownedHook=entry=>Array.isArray(entry?.hooks)&&entry.hooks.length>0&&entry.hooks.every(h=>isHookCommand(h?.command));
39
75
  function parseSettings(text){
40
76
  if(text===null)return {};
41
77
  let value;try{value=JSON.parse(text);}catch{throw Error(`${settingsPath} is not valid JSON; fix it before agent setup or removal`);}
@@ -65,6 +101,45 @@ function hookInstalled(root,event='PreToolUse'){
65
101
  try{return (parseSettings(read(root,settingsPath)).hooks?.[event]||[]).some(ownedHook);}
66
102
  catch{return false;}
67
103
  }
104
+ // What removing a host's integration changes, from its setup record: the
105
+ // managed block, the skill, the hooks and the record.
106
+ function removalChanges(root,host,record){
107
+ const h=hosts[host],before=read(root,h.instruction),after=before===null?null:before.replace(record.block,'');
108
+ const changes=[{path:h.instruction,before,after:record.instructionCreated&&after===''?null:after},
109
+ {path:h.skill,before:read(root,h.skill),after:null}];
110
+ if(host==='claude'){const settings=read(root,settingsPath);
111
+ changes.push({path:settingsPath,before:settings,after:settingsWith(settings,null)});}
112
+ changes.push({path:marker(host),before:read(root,marker(host)),after:null});
113
+ return changes;
114
+ }
115
+ // Removes a host's integration without needing the project config (redue
116
+ // remove after the config is gone): the changed paths, each with its operation.
117
+ export function removeIntegration(root,host='claude'){
118
+ const record=saved(root,host);if(!record)return [];
119
+ const health=inspect(root,host,record);if(health.integrity==='conflict')throw Error(health.reason);
120
+ const changes=removalChanges(root,host,record).filter(c=>c.before!==c.after);
121
+ apply(root,changes);
122
+ return changes.map(c=>`${c.after===null?'remove':'update'} ${c.path}`);
123
+ }
124
+ // Whether REDUE's Claude Code hooks are in the local settings.
125
+ export const claudeHooksInstalled=root=>hookInstalled(root,'SessionStart')||hookInstalled(root);
126
+ // Removes REDUE's Claude Code hooks from the local settings and keeps the
127
+ // rest; the file's path when it changed, else null.
128
+ export function removeClaudeHooks(root){
129
+ const before=read(root,settingsPath);if(before===null)return null;
130
+ const after=settingsWith(before,null);if(after===before)return null;
131
+ apply(root,[{path:settingsPath,before,after}]);return settingsPath;
132
+ }
133
+ // Whether the installed hook's command can start: an absolute one by its
134
+ // files, plain redue by PATH.
135
+ function hookRunnable(root){
136
+ let command=null;
137
+ try{command=(parseSettings(read(root,settingsPath)).hooks?.SessionStart||[]).find(ownedHook)?.hooks[0]?.command??null;}catch{}
138
+ const absolute=absoluteHook(command);
139
+ if(absolute)return fs.existsSync(absolute[0])&&fs.existsSync(absolute[1])?'installed (absolute path)':
140
+ 'installed, but its Node or REDUE path no longer exists; run redue agent setup claude again';
141
+ return findExecutable('redue')?'installed':'installed, but redue is not on PATH, so it never runs';
142
+ }
68
143
  function safeFile(root,relative){
69
144
  if(path.isAbsolute(relative)||relative.split(/[\\/]/).some(p=>p==='..'||p===''))throw Error('unsafe integration path');
70
145
  const file=path.resolve(root,relative);
@@ -273,7 +348,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
273
348
  const {root,relative,config}=project(configFile),h=hosts[host],record=saved(root,host),health=inspect(root,host,record);
274
349
  if(health.integrity==='conflict')throw Error(health.reason);
275
350
  const changes=[],notes=action==='setup'?warnings(root,host,cwd):[];
276
- let hook=null,block=null;
351
+ let hook=null,sessionHook=null,block=null;
277
352
  if(action==='setup'){
278
353
  const before=read(root,h.instruction),skill=skillFor(relative,host,config);
279
354
  block=blockFor(host,blockForms(host,config,relative));
@@ -281,7 +356,12 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
281
356
  throw Error('unowned REDUE instruction or skill already exists; inspect it before setup');
282
357
  const after=record?before.replace(record.block,block):(before||'')+block;
283
358
  let scripts={};try{scripts=JSON.parse(read(root,'package.json')??'{}').scripts||{};}catch{}
284
- hook=host==='claude'?hookFor(config,relative,scripts):null;
359
+ const invocation=host==='claude'?hookInvocation(entry):null;
360
+ if(invocation)notes.push(`Hook command: ${invocation.command} (${invocation.reason})`);
361
+ if(invocation&&invocation.command!==hookCommand)
362
+ notes.push('The agent instructions say `redue ...`: put this install on PATH (npm install --global @redue/cli@alpha) so the agent\'s shell can run them. The hook runs the checks it rewrites through this install either way.');
363
+ hook=host==='claude'?hookFor(config,relative,scripts,invocation.command):null;
364
+ sessionHook=host==='claude'?sessionHookFor(invocation.command):null;
285
365
  const next={schema:1,host,config:relative,instruction:h.instruction,skill:h.skill,
286
366
  block,skillHash:hash(skill),instructionCreated:record?.instructionCreated??before===null};
287
367
  changes.push({path:h.skill,before:read(root,h.skill),after:skill},
@@ -291,14 +371,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
291
371
  content:JSON.stringify(hook?next:{SessionStart:sessionHook},null,2)+
292
372
  (hook?'':'\nno PreToolUse hook: no typecheck or test check to match')});}
293
373
  changes.push({path:marker(host),before:read(root,marker(host)),after:JSON.stringify(next,null,2)+'\n'});
294
- }else if(record){
295
- const before=read(root,h.instruction),after=before.replace(record.block,'');
296
- changes.push({path:h.instruction,before,after:record.instructionCreated&&after===''?null:after},
297
- {path:h.skill,before:read(root,h.skill),after:null});
298
- if(host==='claude'){const settings=read(root,settingsPath);
299
- changes.push({path:settingsPath,before:settings,after:settingsWith(settings,null)});}
300
- changes.push({path:marker(host),before:read(root,marker(host)),after:null});
301
- }
374
+ }else if(record)changes.push(...removalChanges(root,host,record));
302
375
  const actual=changes.filter(c=>c.before!==c.after);
303
376
  const summary={schema:1,action,host,config:configFile,applied:false,
304
377
  changes:actual.map(c=>({path:c.path,operation:c.after===null?'remove':c.before===null?'create':'update',
@@ -355,7 +428,7 @@ export async function doctor({configFile,stateOverride,entry,cwd,host}){
355
428
  const out=spawnSync(launch.file,launch.args,{...launch.options,encoding:'utf8',timeout:2000,maxBuffer:4096,stdio:['ignore','pipe','pipe']});
356
429
  if(out.status===0)version=out.stdout.trim();}
357
430
  let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none',sessionHook=hook;
358
- const installed=()=>value.command.path?'installed':'installed, but redue is not on PATH, so it never runs';
431
+ const installed=()=>hookRunnable(p.root);
359
432
  if(p)try{const record=saved(p.root,name);integration=inspect(p.root,name,record);
360
433
  if(name==='claude'&&hookInstalled(p.root))hook=installed();
361
434
  if(name==='claude'&&hookInstalled(p.root,'SessionStart'))sessionHook=installed();
@@ -6,12 +6,13 @@
6
6
  // Never exit 2: Claude Code treats exit 2 as a block.
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import {spawn} from 'node:child_process';
9
+ import {spawn,spawnSync} from 'node:child_process';
10
10
  import {projectState} from './project-location.mjs';
11
11
  import {realObservedPath} from './path-identity.mjs';
12
12
  import {processAlive} from './process-liveness.mjs';
13
13
  import {scriptRunChecks} from './typescript-invocation.mjs';
14
14
  import {recipeLabel} from './presentation.mjs';
15
+ import {findExecutable} from './executable-lookup.mjs';
15
16
 
16
17
  export const hookCommand='redue agent hook claude';
17
18
  // No shell metacharacters; `@` starts scoped package names.
@@ -31,6 +32,22 @@ export const defaultConfig='redue.config.json';
31
32
  // would need shell quoting.
32
33
  export const redueFor=(configFile=defaultConfig)=>configFile===defaultConfig?'redue':
33
34
  safe.test(configFile)?`redue --config ${configFile}`:null;
35
+ export const shellQuote=value=>`'${String(value).replaceAll("'","'\\''")}'`;
36
+ // How a rewritten command starts REDUE: plain `redue` when the hook's PATH
37
+ // finds this install, else this install by absolute path. An agent shell
38
+ // without redue on PATH cannot run the plain form.
39
+ export function redueInvocation(entry,{env=process.env,platform=process.platform}={}){
40
+ if(platform==='win32'||runsEntry(findExecutable('redue',{env}),entry,env))return 'redue';
41
+ return `${shellQuote(process.execPath)} ${shellQuote(entry)}`;
42
+ }
43
+ // Whether the command at `file` runs this install's entry: the same file, or
44
+ // a shim or wrapper that reports it (redue --entry).
45
+ export function runsEntry(file,entry,env=process.env){
46
+ if(!file)return false;
47
+ try{const target=fs.realpathSync(entry);if(fs.realpathSync(file)===target)return true;
48
+ const out=spawnSync(file,['--entry'],{env,encoding:'utf8',timeout:5000,stdio:['ignore','pipe','ignore']});
49
+ return out.status===0&&fs.realpathSync(out.stdout.trim())===target;}catch{return false;}
50
+ }
34
51
 
35
52
  // Package-manager commands that a script of the same name would not reach.
36
53
  const builtins={pnpm:new Set(['add','audit','bin','config','create','dedupe','deploy','dlx','doctor','env','exec','fetch',
@@ -265,9 +282,9 @@ export function matchCheck(command,config,configFile,dir,scripts){
265
282
  }
266
283
  // The same check (or checks, in order) through REDUE, keeping the output
267
284
  // suffix; null if it already is, or if more commands follow it.
268
- export function recordedCommand(command,name,configFile=defaultConfig){
285
+ export function recordedCommand(command,name,configFile=defaultConfig,invoke='redue'){
269
286
  const names=Array.isArray(name)?name:[name];
270
- const parts=split(command),redue=redueFor(configFile);
287
+ const parts=split(command),redue=redueFor(configFile)?.replace(/^redue/,()=>invoke);
271
288
  if(!parts||parts.rest!==null||!names.length||!names.every(value=>safe.test(value))||!redue)return null;
272
289
  const run=names.map(value=>`${redue} run ${value}`).join(' && ');
273
290
  return parts.base===run?null:names.length>1&&parts.suffix?`(${run})${parts.suffix}`:run+parts.suffix;
@@ -374,7 +391,7 @@ export function sessionStart(input,{entry}){
374
391
  }
375
392
 
376
393
  // Returns the hook's stdout object, or null to allow the command.
377
- export async function claudeHook(input,{entry,timeout}){
394
+ export async function claudeHook(input,{entry,timeout,invoke=null}){
378
395
  if(input?.tool_name!=='Bash')return null;
379
396
  const command=input.tool_input?.command,root=input.cwd;
380
397
  if(typeof command!=='string'||/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return null;
@@ -414,7 +431,7 @@ export async function claudeHook(input,{entry,timeout}){
414
431
  if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}};
415
432
  const pending=names.filter(name=>!verdict(status,name));
416
433
  const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
417
- names.every(name=>status.checks.some(item=>item?.name===name))?recordedCommand(entered.command,pending,configPath):null;
434
+ names.every(name=>status.checks.some(item=>item?.name===name))?recordedCommand(entered.command,pending,configPath,invoke??redueInvocation(entry)):null;
418
435
  return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
419
436
  permissionDecisionReason:`REDUE: ${pending.map(name=>JSON.stringify(name)).join(', ')} of this script's checks ${pending.length===1?'is':'are'} not CURRENT; running ${pending.length===1?'it':'them'} through redue run so the result is recorded.`,
420
437
  updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
@@ -429,7 +446,7 @@ export async function claudeHook(input,{entry,timeout}){
429
446
  // stays too: the rewrite returns "allow", which would also skip the prompt
430
447
  // for the rest of the chain.
431
448
  const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
432
- status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath):null;
449
+ status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath,invoke??redueInvocation(entry)):null;
433
450
  // updatedInput replaces the whole tool input. Deny and ask rules still apply
434
451
  // to the rewritten command; "allow" skips only the prompt.
435
452
  return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
package/src/cli.mjs CHANGED
@@ -5,7 +5,7 @@ import {directQualification,directInterpretation,observedNpmContext} from './dir
5
5
  import {directRunCertificateAllowed,directRunCertificate,directRunRecordable,directRunRecording,probeQuestions,validateRunRecord} from './direct-run-certificate.mjs';
6
6
  import {queryAnswer,runFactChange} from './typescript-list.mjs';
7
7
  import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
8
- import {childProgram,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,toolOutput,toolchainDirs} from './node-script.mjs';
8
+ import {childProgram,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,pnpmNameless,toolOutput,toolchainDirs} from './node-script.mjs';
9
9
  import path from 'node:path';
10
10
  import net from 'node:net';
11
11
  import os from 'node:os';
@@ -400,7 +400,7 @@ async function execute() {
400
400
  // what was there (test/tmp). When they are the only observed change, the
401
401
  // run is kept with the end checkpoint, which includes them.
402
402
  let outputs=null;
403
- let eventsExplained=false;
403
+ let eventsExplained=false,unexplained=end?.inputEventPaths||null;
404
404
  if(guard&&snap&&end&&snap.planId===end.planId&&snap.probeHash===end.probeHash&&snap.observationHealthy&&end.observationHealthy&&
405
405
  (snap.inputEventSerial===end.inputEventSerial||end.inputEventPathsComplete===true)){
406
406
  // A directory event is not an input change; a change to its files shows
@@ -408,9 +408,10 @@ async function execute() {
408
408
  const directory=rel=>{try{return fs.lstatSync(path.join(root,rel)).isDirectory();}catch{return false;}};
409
409
  const changed=changedFiles(snap.files,end.files),events=(end.inputEventPaths||[]).filter(rel=>!directory(rel));
410
410
  const touched=[...new Set([...changed,...events])].sort();
411
- const output=rel=>guard.isOutput(rel)||general&&toolOutput(rel,guard);
411
+ const output=rel=>guard.isOutput(rel)||general&&(toolOutput(rel,guard)||pnpmNameless(rel,guard,root,[snap.files,end.files]));
412
412
  const ignored=touched.length&&touched.every(output)?gitIgnored(root,touched):new Set();
413
- if(touched.every(file=>ignored.has(file))){eventsExplained=true;if(changed.length)outputs=changed;}
413
+ unexplained=touched.filter(file=>!ignored.has(file));
414
+ if(!unexplained.length){eventsExplained=true;if(changed.length)outputs=changed;}
414
415
  }
415
416
  // The end certificate, recorded or probed, must cover what this compile received.
416
417
  // With no input event, gap or content change in between, it also holds at the start.
@@ -442,8 +443,13 @@ async function execute() {
442
443
  captureIssues.push('observer generation changed during execution');
443
444
  if(snap?.planGeneration!==end?.planGeneration)
444
445
  captureIssues.push('input plan rebuilt during execution');
445
- if(snap?.inputEventSerial!==end?.inputEventSerial&&!eventsExplained)
446
- captureIssues.push('declared input event occurred during execution');
446
+ // The reason names up to five files the run did not explain, and a count of the rest.
447
+ if(snap?.inputEventSerial!==end?.inputEventSerial&&!eventsExplained){
448
+ const named=[...new Set(unexplained||[])],more=named.length-5,
449
+ unrecorded=end?.inputEventPathsComplete===false?' (not every event path was kept)':'';
450
+ captureIssues.push('declared input event occurred during execution'+(named.length?
451
+ `: ${named.slice(0,5).join(', ')}${more>0?` and ${more} more`:''}`:'')+unrecorded);
452
+ }
447
453
  // A recorded certificate changes only the probe part of the fingerprint.
448
454
  if(!outputs&&(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
449
455
  snap.revision!==end.revision))captureIssues.push('declared inputs changed or were not observed');
@@ -57,6 +57,15 @@ export const toolOutputPath=rel=>pnpmState(rel)||pnpmTemp(rel)||vueTypes(rel);
57
57
  // The run wrote it: a recorded process did, or, for pnpm's files, the pnpm
58
58
  // executable ran, which writes them natively.
59
59
  export const toolOutput=(rel,guard)=>toolOutputPath(rel)&&(guard.wroteInstalled(rel)||(pnpmState(rel)||pnpmTemp(rel))&&guard.opaque);
60
+ // On Linux, pnpm 12 also writes a file with no name (O_TMPFILE) in the root
61
+ // node_modules, whose writes inotify reports as '#<inode>'. Such an event is
62
+ // the run's output when the pnpm executable ran and no file has that name, at
63
+ // either checkpoint or now. Limit: another program's file with no name there,
64
+ // during a run that started the pnpm executable, is taken as pnpm's too; once
65
+ // linked in under any name, its writes through the open file are missed.
66
+ export const pnpmNameless=(rel,guard,root,checkpoints,platform=process.platform)=>platform==='linux'&&/^node_modules\/#\d+$/.test(rel)&&
67
+ guard.opaque&&checkpoints.every(files=>files&&!Object.hasOwn(files,rel))&&
68
+ (()=>{try{fs.lstatSync(path.join(root,rel));return false;}catch(error){return error.code==='ENOENT';}})();
60
69
  // The installed paths the recorder treats as observed by identity.
61
70
  export const scriptInstalledPaths=(root,dirs=[])=>[...modulesDirs(dirs),...installState].map(at=>path.join(root,at));
62
71
  // Variables a recorded run cannot be seen reading: Node's and libc's (as in
@@ -227,6 +236,19 @@ export const scriptPath=(dir,callerPath)=>{
227
236
  // Why a shell command line could start a program REDUE does not record, or
228
237
  // null. Names are looked up along `searchPath` from `cwd`. A script the pnpm
229
238
  // executable runs is read the same way, since the recorder cannot see it.
239
+ // TypeScript 7's Node launchers (tsc in typescript@7, tsgo in
240
+ // @typescript/native-preview) replace themselves with a native compiler REDUE
241
+ // does not record: the package and version, or null.
242
+ export function nativeTypeScriptLauncher(file){
243
+ let dir;try{dir=path.dirname(fs.realpathSync(file));}catch{return null;}
244
+ for(let n=0;n<3;n++,dir=path.dirname(dir)){
245
+ let pkg;try{pkg=JSON.parse(fs.readFileSync(path.join(dir,'package.json'),'utf8'));}catch{continue;}
246
+ if(pkg.name==='typescript'&&Number(String(pkg.version).split('.')[0])>=7||pkg.name==='@typescript/native-preview')
247
+ return `${pkg.name} ${pkg.version}`;
248
+ return null;
249
+ }
250
+ return null;
251
+ }
230
252
  export function commandLineIssue(text,{cwd,searchPath,depth=0}={}){
231
253
  if(depth>8)return 'the scripts run each other too deeply for REDUE to read';
232
254
  const parsed=parseCommandLine(text);
@@ -261,6 +283,8 @@ export function commandLineIssue(text,{cwd,searchPath,depth=0}={}){
261
283
  if(kind==='node'){
262
284
  const flag=['node','nodejs'].includes(path.basename(file))&&args.find(arg=>nativeReads.test(arg.text));
263
285
  if(flag)return `runs node ${flag.text.split('=')[0]}, which reads a file REDUE cannot see`;
286
+ const native=nativeTypeScriptLauncher(file);
287
+ if(native)return `runs ${path.basename(name)} from ${native}, a native compiler REDUE does not record`;
264
288
  break;
265
289
  }
266
290
  if(kind==='pnpm'){const issue=pnpmRunIssue(args.map(arg=>arg.text),{cwd:dir,searchPath,depth});if(issue)return issue;break;}
@@ -2,7 +2,7 @@
2
2
  // repository-relative input sets and resolution relationships, never pnpm
3
3
  // virtual-store names as semantic identities.
4
4
  import {probeTimeLeft} from './probe-deadline.mjs';
5
- import {compilerFiles} from './typescript-list.mjs';
5
+ import {compilerApiIssue,compilerFiles} from './typescript-list.mjs';
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import {createRequire} from 'node:module';
@@ -298,7 +298,9 @@ export function pnpmTypecheckInputs(root,workspace,run,{captureQueries=false}={}
298
298
  let tsPackage;try{tsPackage=realObservedPath(lookup.resolve('typescript/package.json'));}
299
299
  catch{throw Error('installed TypeScript compiler cannot be resolved');}
300
300
  if(!within(tsPackage,root))throw Error('TypeScript compiler is outside the observed checkout');
301
- const tsRoot=path.dirname(tsPackage),ts=lookup('typescript');
301
+ const tsRoot=path.dirname(tsPackage),apiIssue=compilerApiIssue(tsRoot);
302
+ if(apiIssue)throw Error(apiIssue);
303
+ const ts=lookup('typescript');
302
304
  const tsc=path.join(tsRoot,'bin','tsc');
303
305
  if(!exists(tsc))throw Error('installed TypeScript executable is missing');
304
306
  const configStarted=performance.now();
package/src/setup.mjs ADDED
@@ -0,0 +1,178 @@
1
+ // `redue setup claude` and `redue remove`: one command from a project with
2
+ // installed dependencies to a working Claude Code integration, and one to
3
+ // undo it. Setup runs init when the project has no config, agent setup
4
+ // claude, start, and one recorded run of each check init can qualify that is
5
+ // not CURRENT. It prints every file it writes. What it created is kept in a
6
+ // record in the clone's Git directory (never committed); a later setup adds
7
+ // to that account and never forgets it. Remove undoes exactly what the record
8
+ // says setup created: the agent files or just this machine's hooks, the
9
+ // observer state, the local ignore line, and the config if it is unchanged
10
+ // since setup wrote it.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import {createHash} from 'node:crypto';
14
+ import {spawnSync} from 'node:child_process';
15
+ import {createInterface} from 'node:readline/promises';
16
+ import {projectState} from './project-location.mjs';
17
+ import {claudeHooksInstalled,removeClaudeHooks,removeIntegration} from './agent-integration.mjs';
18
+
19
+ const configName='redue.config.json',localSettings='.claude/settings.local.json',agentRecord='.redue/agents/claude.json';
20
+ const hash=text=>createHash('sha256').update(text).digest('hex');
21
+ const readText=file=>{try{return fs.readFileSync(file,'utf8');}catch{return null;}};
22
+ const git=(root,...args)=>spawnSync('git',args,{cwd:root,encoding:'utf8',stdio:['ignore','pipe','ignore']});
23
+ const ignoreLine=`/${localSettings}`;
24
+
25
+ async function confirm(question,yes,{defaultYes}){
26
+ if(yes)return true;
27
+ if(!process.stdin.isTTY||!process.stdout.isTTY)throw Error('not a terminal; run again with --yes to proceed without prompts');
28
+ const prompt=createInterface({input:process.stdin,output:process.stdout});
29
+ try{const answer=(await prompt.question(`${question} ${defaultYes?'[Y/n]':'[y/N]'} `)).trim();
30
+ return defaultYes?!/^n/i.test(answer):/^y/i.test(answer);}finally{prompt.close();}
31
+ }
32
+
33
+ export async function setupCommand(args,{entry,cwd=process.cwd()}){
34
+ const action=args.shift();
35
+ const yes=args.includes('--yes'),rest=args.filter(arg=>arg!=='--yes');
36
+ if(action==='setup'&&(rest.length!==1||rest[0]!=='claude'))throw Error('use redue setup claude [--yes]');
37
+ if(action==='remove'&&rest.length)throw Error('use redue remove [--yes]');
38
+ const top=git(cwd,'rev-parse','--show-toplevel');
39
+ if(top.status!==0)throw Error('run this inside a Git checkout');
40
+ const root=fs.realpathSync(top.stdout.trim());
41
+ if(!fs.existsSync(path.join(root,'package.json')))throw Error(`no package.json at the repository root (${root})`);
42
+ // The entry as invoked, so a hook written by a child names the install's
43
+ // stable bin link rather than the versioned file behind it.
44
+ const invoked=typeof process.argv[1]==='string'&&path.isAbsolute(process.argv[1])&&
45
+ (()=>{try{return fs.realpathSync(process.argv[1])===fs.realpathSync(entry);}catch{return false;}})()?process.argv[1]:entry;
46
+ // Every step is the ordinary command, so its own output says what it did.
47
+ const redue=(argv,options={})=>spawnSync(process.execPath,[invoked,...argv],{cwd:root,encoding:'utf8',
48
+ stdio:options.capture?['ignore','pipe','pipe']:'inherit',timeout:options.timeout});
49
+ // One record per checkout, beside the others in the shared Git directory:
50
+ // linked worktrees share .git/info/exclude.
51
+ const common=path.resolve(root,git(root,'rev-parse','--git-common-dir').stdout.trim());
52
+ const recordFile=path.join(common,`redue-setup-${hash(root).slice(0,16)}.json`);
53
+ // Other checkouts' records, for checkouts that still exist.
54
+ const others=()=>{try{return fs.readdirSync(common).filter(name=>/^redue-setup-[0-9a-f]{16}\.json$/.test(name)&&path.join(common,name)!==recordFile)
55
+ .map(name=>JSON.parse(readText(path.join(common,name))??'null')).filter(other=>other&&fs.existsSync(other.root));}catch{return [];}};
56
+ const context={root,redue,yes,record:JSON.parse(readText(recordFile)??'null'),recordFile,
57
+ exclude:path.join(common,'info','exclude'),others,save:next=>fs.writeFileSync(recordFile,JSON.stringify(next,null,2)+'\n')};
58
+ return action==='setup'?setup(context):remove(context);
59
+ }
60
+
61
+ async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
62
+ if(!fs.existsSync(path.join(root,'node_modules')))
63
+ throw Error('dependencies are not installed: run your package manager\'s install (npm install, pnpm install, yarn) first');
64
+ if(fs.existsSync(path.join(root,'vstate.config.json'))&&!fs.existsSync(path.join(root,configName)))
65
+ throw Error('a legacy vstate.config.json exists; rename it to redue.config.json or remove it first');
66
+ if(git(root,'ls-files','--error-unmatch',localSettings).status===0)
67
+ throw Error(`${localSettings} is committed; the hooks REDUE adds there name this machine's paths. Untrack it (git rm --cached) first`);
68
+ const configPath=path.join(root,configName),configExisted=fs.existsSync(configPath);
69
+ // init's account of the checks: which can qualify, also with a config.
70
+ const preview=redue(['init','--dry-run','--json'],{capture:true});
71
+ if(preview.status!==0)throw Error(preview.stderr.trim().replace(/^redue: /,'')||'init failed');
72
+ const found=JSON.parse(preview.stdout);
73
+ 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')}`);
74
+ const levels=new Map(found.checks.map(check=>[check.name,check.level]));
75
+ const names=configExisted?JSON.parse(readText(configPath)).checks.map(check=>check.name):found.checks.map(check=>check.name);
76
+ const ready=names.filter(name=>levels.get(name)==='ready');
77
+ console.log(`Setting up REDUE for Claude Code in ${root}`);
78
+ console.log(configExisted?` keep ${configName} (exists)`:` create ${configName}: ${names.map(name=>`${name} (${levels.get(name)==='ready'?'can qualify':'recording-only'})`).join(', ')}`);
79
+ console.log(` write the Claude Code integration: CLAUDE.md section, .claude/skills/redue-verification/SKILL.md, ${localSettings} hooks, ${agentRecord}`);
80
+ console.log(` start the observer and record ${ready.length?ready.join(', '):'no check'} once, unless already CURRENT`);
81
+ if(!await confirm('Proceed?',yes,{defaultYes:true})){console.log('Nothing written.');return {applied:false};}
82
+ // What this run creates is added to what earlier runs created.
83
+ const state=projectState(root,configPath);
84
+ const own={schema:1,host:'claude',root,config:configName,state,
85
+ configCreated:Boolean(previous?.configCreated),configHash:previous?.configHash??null,
86
+ agentCreated:Boolean(previous?.agentCreated)||!fs.existsSync(path.join(root,agentRecord)),
87
+ hooksAdded:Boolean(previous?.hooksAdded)||!claudeHooksInstalled(root),
88
+ stateCreated:Boolean(previous?.stateCreated)||!fs.existsSync(state),
89
+ excludeAdded:Boolean(previous?.excludeAdded)};
90
+ if(!configExisted){
91
+ const init=redue(['init']);
92
+ if(init.status!==0||!fs.existsSync(configPath))throw Error(`init did not write ${configName}; nothing else was written`);
93
+ Object.assign(own,{configCreated:true,configHash:hash(readText(configPath))});
94
+ }
95
+ save(own);
96
+ console.log(`${previous?'update':'create'} ${recordFile}: what setup created, for redue remove`);
97
+ const agent=redue(['agent','setup','claude','--apply']);
98
+ if(agent.status!==0)throw Error('agent setup failed; see above. redue remove undoes what setup wrote');
99
+ // The hooks' settings hold machine paths: keep them out of commits in this
100
+ // clone when Git does not ignore them.
101
+ if(git(root,'check-ignore','-q',localSettings).status===1){
102
+ fs.mkdirSync(path.dirname(exclude),{recursive:true});
103
+ const text=readText(exclude)??'';
104
+ fs.writeFileSync(exclude,`${text}${text&&!text.endsWith('\n')?'\n':''}${ignoreLine}\n`);
105
+ console.log(`update ${exclude}: ignore ${ignoreLine} in this clone`);
106
+ own.excludeAdded=true;save(own);
107
+ }
108
+ // An observer that is already running is what start would give.
109
+ const started=redue(['start'],{capture:true});
110
+ process.stdout.write(started.stdout);
111
+ if(started.status!==0){
112
+ if(!/already running/.test(started.stderr))throw Error(`the observer did not start: ${started.stderr.trim().replace(/^redue: /,'')}`);
113
+ console.log('Observer already running.');
114
+ }
115
+ // The observer indexes the project first; a run before that cannot be
116
+ // recorded as reusable. Wait while it plans and indexes; once its plan is
117
+ // ready, an observer that is still unhealthy says why in the status below.
118
+ // Nothing to wait for when no check can qualify.
119
+ const observation=()=>{const out=redue(['status','--json'],{capture:true});try{return JSON.parse(out.stdout).observation??null;}catch{return null;}};
120
+ // An answer without a phase (a stopped observer) for 10 s in a row ends the
121
+ // wait too.
122
+ const begun=Date.now();let told=-1,unread=0,phaseless=0;
123
+ for(let state=observation();ready.length&&!(state?.phase==='ready'||state?.phase==='unavailable')&&phaseless<10;state=observation()){
124
+ const seconds=Math.round((Date.now()-begun)/1000);
125
+ unread=state===null?unread+1:0;phaseless=state&&!state.phase?phaseless+1:0;
126
+ if(unread>=30)throw Error('the observer\'s status cannot be read; run redue status to see why');
127
+ if(seconds>15*60)throw Error(`the observer is not ready after 15 minutes (${state?.reason||state?.phase||'unknown'}); run redue status, then redue setup claude again`);
128
+ if(Math.floor(seconds/15)>told){told=Math.floor(seconds/15);console.log(seconds?`Still indexing (${seconds} s)...`:'Waiting for the observer to index the project...');}
129
+ await new Promise(resolve=>setTimeout(resolve,1000));
130
+ }
131
+ // One recorded run of each check that can qualify and is not CURRENT.
132
+ const statusOf=()=>{const out=redue(['status','--sync','--json'],{capture:true,timeout:600_000});try{return JSON.parse(out.stdout).checks;}catch{return [];}};
133
+ const rows=statusOf(),due=ready.filter(name=>rows.find(row=>row.name===name)?.freshness!=='CURRENT');
134
+ for(const name of due){console.log(`Recording ${name} once:`);redue(['run',name]);}
135
+ if(ready.length&&!due.length)console.log(`Already CURRENT: ${ready.join(', ')}.`);
136
+ for(const row of statusOf())console.log(` ${row.name}: ${row.freshness}${row.result?' / '+row.result:''}${row.freshness==='CURRENT'?'':' — '+row.reason}`);
137
+ console.log('Done. Start a new Claude Code session in this project. Undo with redue remove.');
138
+ return {applied:true};
139
+ }
140
+
141
+ async function remove({root,redue,yes,record:own,recordFile,exclude,others}){
142
+ if(!own){console.log('No setup record for this checkout: nothing redue setup created to remove.');
143
+ console.log('To undo a setup made step by step: redue agent remove claude --apply, then redue remove-state.');return {removed:false};}
144
+ const configPath=path.join(root,configName),configText=readText(configPath);
145
+ const removeAgent=own.agentCreated&&fs.existsSync(path.join(root,agentRecord));
146
+ const removeHooks=!removeAgent&&own.hooksAdded&&claudeHooksInstalled(root);
147
+ const removeState=own.stateCreated&&fs.existsSync(own.state);
148
+ const deleteConfig=own.configCreated&&configText!==null&&hash(configText)===own.configHash;
149
+ // The ignore line goes only when no other checkout of this repository is
150
+ // set up: those rely on it too, whichever added it.
151
+ const removeIgnore=own.excludeAdded&&!others().length;
152
+ console.log(`Removing what redue setup created in ${root}`);
153
+ if(removeAgent)console.log(' remove the Claude Code integration');
154
+ if(removeHooks)console.log(` remove REDUE's hooks from ${localSettings} (the integration predates setup)`);
155
+ if(removeState)console.log(' stop the observer and remove its local state (receipts included)');
156
+ else if(!own.stateCreated)console.log(' keep the observer state (it predates setup)');
157
+ if(own.configCreated&&configText!==null)console.log(deleteConfig?` delete ${configName}`:` keep ${configName} (edited since setup)`);
158
+ if(removeIgnore)console.log(` remove ${ignoreLine} from ${exclude}`);
159
+ console.log(` delete ${recordFile}`);
160
+ if(!await confirm('Proceed?',yes,{defaultYes:false})){console.log('Nothing removed.');return {removed:false};}
161
+ if(removeAgent)for(const line of removeIntegration(root))console.log(line);
162
+ if(removeHooks){const changed=removeClaudeHooks(root);if(changed)console.log(`update ${changed}`);}
163
+ if(removeState&&redue(['--state-dir',own.state,'remove-state']).status!==0)
164
+ throw Error('the observer state was not removed; see above. The config and setup record are kept so you can retry');
165
+ if(deleteConfig){fs.unlinkSync(configPath);console.log(`remove ${configName}`);}
166
+ if(removeIgnore){
167
+ const text=readText(exclude);
168
+ if(text!==null&&text.split('\n').includes(ignoreLine)){
169
+ fs.writeFileSync(exclude,text.split('\n').filter(line=>line!==ignoreLine).join('\n'));
170
+ console.log(`update ${exclude}`);}
171
+ }
172
+ fs.unlinkSync(recordFile);console.log(`remove ${recordFile}`);
173
+ // Folders the integration lived in, when nothing else is left in them.
174
+ for(const dir of ['.redue/agents','.redue','.claude/skills/redue-verification','.claude/skills','.claude'])
175
+ try{fs.rmdirSync(path.join(root,dir));console.log(`remove ${dir}/`);}catch{}
176
+ console.log('Done.');
177
+ return {removed:true};
178
+ }
@@ -16,6 +16,8 @@ import {queryAnswer} from './typescript-list.mjs';
16
16
  import {pnpmInstallInfo,pnpmProjectInfo,yaml} from './pnpm-inputs.mjs';
17
17
  import {workspaceManifests} from './workspace-manifests.mjs';
18
18
  import {atomicJson} from './state-store.mjs';
19
+ // node-script.mjs imports this module too; childProgram is only called at run time.
20
+ import {childProgram} from './node-script.mjs';
19
21
  import {racyUntil} from './path-identity.mjs';
20
22
 
21
23
  const digest=value=>createHash('sha256').update(value).digest('hex');
@@ -73,7 +75,7 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
73
75
  const issues=[],add=issue=>{if(!issues.includes(issue))issues.push(issue);};
74
76
  const included=patternMatcher(sourcePatterns);
75
77
  const observed=rel=>Object.hasOwn(files||{},rel);
76
- const headers=new Map(),records=[];
78
+ const headers=new Map(),records=[],headerCount=new Map(),threadsStarted=new Map();
77
79
  let names=[];
78
80
  try{names=fs.readdirSync(dir).filter(name=>name.endsWith('.jsonl'));}
79
81
  catch{return {issues:['the run record is unavailable'],runFacts:[],environmentReads:{},isOutput:()=>false};}
@@ -84,6 +86,9 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
84
86
  const header=rows[0]?.[0]==='H'?rows[0][1]:null;
85
87
  if(!header||!Number.isSafeInteger(header.pid)||!Number.isSafeInteger(header.threadId)){add('a run record is unreadable');continue;}
86
88
  headers.set(`${header.pid}-${header.threadId}`,header);
89
+ // After process.execve, the new program's threads append to the same
90
+ // records: each thread started needs its own header.
91
+ headerCount.set(`${header.pid}-${header.threadId}`,rows.filter(row=>row?.[0]==='H'&&!row[1]?.internal).length);
87
92
  records.push({header,rows:rows.slice(1)});
88
93
  }
89
94
  let opaque=false;
@@ -101,7 +106,24 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
101
106
  // PIDs is that child, not an execSync child (which reports no PID).
102
107
  const installedWrites=new Set();
103
108
  const reads=new Map(),produced=new Set(),created=new Set(),environmentReads={},spawned=new Map(),childPids=new Map();
109
+ // Whether a started program is accounted for: {issue} or {needsHeader}.
110
+ // The tests recipe wants every program to become a recorded process; a
111
+ // shell, which can run other programs first, is read by its command as
112
+ // the general recipe reads it.
113
+ const judge=(child,main)=>{
114
+ if(!programs){
115
+ const judged=/^(sh|bash|dash|zsh|ksh|fish|csh|tcsh)$/.test(path.basename(String(child.file)))?childProgram(child,{parentMain:main}):{};
116
+ return judged.issue?judged:{needsHeader:true};}
117
+ const judged=programs.judge(child,main);
118
+ if(!judged.issue)opaque||=Boolean(judged.opaque);
119
+ return judged;
120
+ };
104
121
  for(const {header,rows} of records){
122
+ // A program this process replaced itself with (process.execve) that must
123
+ // load the recorder: its header follows in this record, from the same
124
+ // parent. Later rows come from the program now running. (A reused PID
125
+ // also appends a header to an old record.)
126
+ let replaced=null,main=header.main??null;
105
127
  for(const row of rows){
106
128
  const [kind,...value]=Array.isArray(row)?row:[];
107
129
  if(kind==='U')add(`recorder: ${value[0]}`);
@@ -121,16 +143,26 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
121
143
  if(kind==='C')childPids.set(header.pid,[...childPids.get(header.pid)||[],value[0]]);
122
144
  // The general recipe accounts for each program; one that needs no
123
145
  // recorder (a shell, the pnpm executable) is not matched to a header.
124
- if(programs){const judged=programs.judge({file,args,cwd,searchPath,env,script},header.main??null);
125
- if(judged.issue){add(judged.issue);continue;}
126
- opaque||=Boolean(judged.opaque);
127
- if(!judged.needsHeader)continue;}
146
+ const judged=judge({file,args,cwd,searchPath,env,script},main);
147
+ if(judged.issue){add(judged.issue);continue;}
148
+ if(!judged.needsHeader)continue;
128
149
  const list=spawned.get(header.pid)||[];list.push({pid:kind==='C'?value[0]:null,file});
129
150
  spawned.set(header.pid,list);}
130
- else if(kind==='T'){if(!headers.has(`${header.pid}-${value[0]}`))add('a worker thread did not load the REDUE recorder');}
131
- else if(kind!=='H')add('a run record is unreadable');
151
+ else if(kind==='X'){
152
+ const [file,args,cwd,searchPath,env,script]=value;
153
+ const judged=judge({file,args,cwd,searchPath,env,script},main);
154
+ if(judged.issue){add(judged.issue);replaced=null;continue;}
155
+ replaced=judged.needsHeader?file:null;}
156
+ else if(kind==='Y')replaced=null;
157
+ else if(kind==='T'){const key=`${header.pid}-${value[0]}`;threadsStarted.set(key,(threadsStarted.get(key)||0)+1);}
158
+ else if(kind==='H'){
159
+ if(replaced!==null&&value[0]?.ppid!==header.ppid)add(`replaced itself with ${path.basename(String(replaced))}, which REDUE does not record`);
160
+ replaced=null;main=value[0]?.main??null;}
161
+ else add('a run record is unreadable');
132
162
  }
163
+ if(replaced!==null)add(`replaced itself with ${path.basename(String(replaced))}, which REDUE does not record`);
133
164
  }
165
+ for(const [key,count] of threadsStarted)if((headerCount.get(key)||0)<count)add('a worker thread did not load the REDUE recorder');
134
166
  // Every child must be a recorded Node process: by PID when known, otherwise
135
167
  // by the number of recorded processes whose parent started it.
136
168
  for(const [parent,children] of spawned){