@redue/cli 0.1.0-alpha.15 → 0.1.0-alpha.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,73 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.17 — 2026-10-22
4
+
5
+ Workspaces and package managers beyond npm. In the census of 108 public
6
+ TypeScript repos, the ones `init` makes ready went from 5 to 8 for typecheck
7
+ and from 6 to 22 for tests.
8
+
9
+ - pnpm 9-12 for the pnpm TypeScript contract (it took only an exact pnpm 12
10
+ pin). The installed `.modules.yaml` names the version; a pin is optional,
11
+ with or without Corepack's `+sha512` hash (pins with a hash never matched
12
+ before). `.npmrc` settings that only change what gets installed are
13
+ accepted; settings that move or copy installed files, or run code during
14
+ install, are refused in either file: custom modules, virtual-store or
15
+ lockfile directories, the global virtual store, injected workspace
16
+ packages, separate lockfiles, pnpmfile hooks, `configDependencies`.
17
+ - The tests recipe takes single-package pnpm 9-12 and Yarn `node-modules`
18
+ installs, and gives each workspace package (npm or Yarn `workspaces`, pnpm
19
+ `packages`) whose test script runs Vitest directly its own `PACKAGE:test`
20
+ check, run in the package directory. Inputs stay the whole checkout; every
21
+ package's node_modules is an installed input. Jest and root test scripts in
22
+ workspaces stay recording-only. A package added later makes the
23
+ workspace's checks UNVERIFIED until `init` runs again.
24
+ - A recorded test run is UNVERIFIED when an installed file has another hard
25
+ link: a package store outside the checkout (pnpm's default on Linux, Yarn's
26
+ hardlinks modes) could change it unseen. pnpm on macOS clones files and is
27
+ not affected; on Linux, pnpm test checks qualify but are not reused unless
28
+ pnpm copies files.
29
+ - Observer: an installed link to a directory (pnpm links every package) is
30
+ observed by its link chain when the target is itself observed installed;
31
+ any other directory link still makes observation unavailable.
32
+ - Workspace patterns honour `!pattern` exclusions; before, a negated pattern
33
+ matched every directory.
34
+ - Fixed: a new workspace package manifest did not re-plan a test check, for
35
+ npm workspaces too.
36
+
37
+ Eval on alpha.17 code (CI 39828002753, 84 sessions, test tasks on twilio,
38
+ cheerio and SwitchHosts plus the pnpm project devops-daily): 0 critical
39
+ findings; 23 skipped test runs, each passing three plain reruns; nothing was
40
+ skipped on devops-daily, whose recorded runs were UNVERIFIED on the Linux
41
+ runner.
42
+ ## 0.1.0-alpha.16 — 2026-10-22
43
+
44
+ Changes to the Claude Code integration, and one fix for commands that meet
45
+ on fresh state. Run `redue agent setup claude --apply` again to get the new
46
+ hook entries.
47
+
48
+ - A SessionStart hook starts the project's observer when a session starts,
49
+ resumes, clears or compacts, if none is running. It returns at once and
50
+ prints nothing. Without a running observer every check was UNVERIFIED, so
51
+ the hook could neither skip nor record. `agent doctor` reports it.
52
+ - The hook matches more command forms, each mapping to one configured check:
53
+ `pnpm SCRIPT`, `pnpm run SCRIPT`, `yarn SCRIPT`, `yarn run SCRIPT`,
54
+ `pnpm exec tsc ...`; for a workspace package's check, `pnpm --filter NAME
55
+ SCRIPT`, `yarn workspace NAME SCRIPT`, `npm run SCRIPT -w NAME`, and its
56
+ script after a `cd` into the package. In the census of 108 public
57
+ TypeScript repos, 61 use pnpm or Yarn.
58
+ - `turbo run TASK` and, in pnpm projects, `pnpm -r TASK` (or a root script
59
+ that is one of them) are skipped only when every workspace package with the
60
+ task's script has a check and all are CURRENT; otherwise they run as typed,
61
+ never rewritten. No skip when turbo may run more (other `dependsOn` tasks,
62
+ `with`, package-scoped keys, a package turbo.json), when pnpm includes the
63
+ workspace root, or for a root script with pre/post scripts. Filters and nx
64
+ are not matched.
65
+ - Fixed: when two commands met on fresh state (SessionStart's `start` and the
66
+ first `status`), the second failed and no observer started.
67
+
68
+ Pilot eval on alpha.16 code (CI 39799994225, 96 sessions over twilio,
69
+ semiotic, Ultra-Card and SwitchHosts): 0 critical findings; 29 skipped
70
+ typechecks, each confirmed by a plain rerun.
3
71
  ## 0.1.0-alpha.15 — 2026-10-22
4
72
 
5
73
  No change to observation, status or `redue run` since alpha.14; the changes
package/README.md CHANGED
@@ -37,7 +37,7 @@ it; relevant source edits make it stale. [Run the reproducible demo](docs/contri
37
37
 
38
38
  ## Install the alpha
39
39
 
40
- The opt-in pilot uses **0.1.0-alpha.15**. Follow the
40
+ The opt-in pilot uses **0.1.0-alpha.17**. Follow the
41
41
  [fixed-version pilot guide](docs/user/pilot.md) for supported projects, setup,
42
42
  updates and voluntary feedback. No external usage results are claimed yet.
43
43
 
package/bin/redue.mjs CHANGED
@@ -4,7 +4,7 @@ import {mark,traceBase,traceContext,traceEnvironment,traceId} from '../src/decis
4
4
  traceBase({...traceContext(),request:traceId(),work:'client'});
5
5
  mark('client.loaded');
6
6
  import {directCompiler,directQualification,directEnvironment,directInterpretation} from '../src/direct-typescript.mjs';
7
- import {testEnvironment,testRunnerEntry,testsInterpretation,testsQualification} from '../src/test-direct.mjs';
7
+ import {testEnvironment,testRunnerAt,testsInterpretation,testsQualification} from '../src/test-direct.mjs';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
10
  import {createHash} from 'node:crypto';
@@ -194,7 +194,7 @@ function which(name){
194
194
  if(found)return found;
195
195
  error(`cannot resolve executable ${name} on PATH`);
196
196
  }
197
- function token(value){
197
+ function token(value,cwd='.'){
198
198
  if(value==='@node')return process.execPath;
199
199
  if(value==='@project')return root;
200
200
  if(value==='@typescript-compiler:.'){
@@ -202,8 +202,8 @@ function token(value){
202
202
  }
203
203
  if(value.startsWith('@test-runner:')){
204
204
  // Qualification reports a missing runner; status and stop still work.
205
- try{return testRunnerEntry(root,value.slice('@test-runner:'.length));}
206
- catch{return path.join(root,'node_modules',value.slice('@test-runner:'.length),'<unavailable>');}
205
+ try{return testRunnerAt(root,cwd,value.slice('@test-runner:'.length)).entry;}
206
+ catch{return path.join(root,cwd,'node_modules',value.slice('@test-runner:'.length),'<unavailable>');}
207
207
  }
208
208
  if(value==='@vstate/direct-typescript-probe')return path.join(productRoot,'src','direct-typescript-probe.mjs');
209
209
  if(value.startsWith('@typescript-bin:')){
@@ -226,10 +226,10 @@ function token(value){
226
226
  if(value.startsWith('@which:'))return which(value.slice(7));
227
227
  return value;
228
228
  }
229
- function argv(values,label){
229
+ function argv(values,label,cwd){
230
230
  if(!Array.isArray(values)||!values.length||values.some(v=>typeof v!=='string'||!v))
231
231
  error(`${label} must be nonempty exact argv`);
232
- return values.map(token);
232
+ return values.map(value=>token(value,cwd));
233
233
  }
234
234
  const categories=['source','generated','installedDependencies','environment','toolchain','runtime',
235
235
  'probeContinuity'];
@@ -260,7 +260,7 @@ const checks=publicConfig.checks.map(check=>{
260
260
  JSON.stringify(check.command)!==JSON.stringify(['@node','@typescript-compiler:.','--noEmit'])))
261
261
  error(`${check.name}: direct recipe requires its explicit local compiler command`);
262
262
  // The script's own arguments follow the runner; qualification compares them with the script.
263
- if(testsDirect&&(manager!=='npm'||!check.script||check.cwd&&check.cwd!=='.'||!Array.isArray(check.command)||
263
+ if(testsDirect&&(!['npm','pnpm','yarn'].includes(manager)||!check.script||check.cwd&&check.cwd!=='.'&&!check.workspace||!Array.isArray(check.command)||
264
264
  check.command[0]!=='@node'||!/^@test-runner:(?:vitest|jest)$/.test(check.command[1]||'')))
265
265
  error(`${check.name}: test recipe requires its explicit local test runner command`);
266
266
  if(npmAutomatic&&(manager!=='npm'||check.cwd&&check.cwd!=='.'))
@@ -297,7 +297,8 @@ const checks=publicConfig.checks.map(check=>{
297
297
  {variables:['NODE_OPTIONS','NODE_PATH','CI','HOME','NODE_ENV','BASH_ENV','ENV',...windowsContext],
298
298
  prefixes:['PNPM_','COREPACK_','npm_config_','DYLD_','TSGO_'],
299
299
  pathExecutables:['node'],executableIdentity:true}:{};
300
- return {name:check.name,command:argv(scriptCommand||check.command,`${check.name}.command`),
300
+ return {name:check.name,command:argv(scriptCommand||check.command,`${check.name}.command`,
301
+ typeof check.cwd==='string'&&check.cwd&&!path.isAbsolute(check.cwd)&&!check.cwd.split('/').includes('..')?check.cwd:'.'),
301
302
  cwd:check.cwd,inputs:check.inputs||[],generatedInputs:check.generatedInputs||[],
302
303
  installedInputs:check.installedInputs||(
303
304
  npmAutomatic||npmDirect||testsDirect?['node_modules/**']:[]),
@@ -323,7 +324,13 @@ if(maintenance&&!fs.existsSync(runtimeFile)){
323
324
  process.exit(0);
324
325
  }
325
326
  if(!maintenance)fs.mkdirSync(state,{recursive:true,mode:0o700});
326
- if(!maintenance&&fs.existsSync(runtimeFile)){
327
+ // Two commands can meet on fresh state (SessionStart's start and the first
328
+ // status): whichever creates the runtime file first wins, and the other
329
+ // treats it as existing.
330
+ let created=false;
331
+ if(!maintenance&&!fs.existsSync(runtimeFile))
332
+ try{fs.writeFileSync(runtimeFile,serialized,{flag:'wx',mode:0o600});created=true;}catch(e){if(e.code!=='EEXIST')throw e;}
333
+ if(!maintenance&&!created){
327
334
  const previous=fs.readFileSync(runtimeFile,'utf8');
328
335
  if(previous!==serialized){
329
336
  const pidFile=path.join(state,'observer.lock','pid');
@@ -333,7 +340,7 @@ if(!maintenance&&fs.existsSync(runtimeFile)){
333
340
  }
334
341
  fs.writeFileSync(runtimeFile,serialized,{mode:0o600});
335
342
  }
336
- }else if(!maintenance)fs.writeFileSync(runtimeFile,serialized,{flag:'wx',mode:0o600});
343
+ }
337
344
 
338
345
  if(command==='status'&&!sync){
339
346
  const value=readCachedStatus(state,publicConfig.checks);
@@ -69,25 +69,58 @@ Start a fresh agent session in this checkout, then give it your ordinary task.
69
69
  Interactive setup shows the changes and asks before applying; noninteractive
70
70
  setup requires `--apply`. `--dry-run` never writes. Installing REDUE or running
71
71
  `init` does not connect an agent automatically. Setup does not start an observer
72
- or execute verification. Use `redue start` when you want observation.
72
+ or execute verification. With Claude Code, a SessionStart hook starts the
73
+ observer when a session starts (below); otherwise use `redue start`.
73
74
 
74
75
  ## Claude Code typecheck hook
75
76
 
76
- `redue agent setup claude --apply` also adds a PreToolUse hook to
77
- `.claude/settings.local.json`. The hook needs a local REDUE install, so it is
77
+ `redue agent setup claude --apply` also adds a PreToolUse hook and a
78
+ SessionStart hook to `.claude/settings.local.json`. Both run
79
+ `redue agent hook claude`, which needs a local REDUE install, so they are
78
80
  per-developer; keep that file out of commits. Other settings in the file are kept,
79
81
  but the file is rewritten as 2-space JSON. `.claude/settings.json` is not touched.
80
- `redue agent remove claude --apply` removes only the entry REDUE added.
82
+ `redue agent remove claude --apply` removes only the entries REDUE added.
81
83
 
82
- The hook skips an exact rerun of a configured typecheck: a check with
83
- `kind: "typecheck"` or named `typecheck`. It matches these commands, run from the
84
- project root:
84
+ When a session starts, resumes, clears or compacts, the SessionStart hook starts
85
+ this project's observer if none is running. It launches `redue start` in the
86
+ background and returns at once, and prints nothing, so it adds nothing to the
87
+ agent's context. Until the observer's first index is done, checks are
88
+ UNVERIFIED and run as typed. An observer you stopped with `redue stop` starts
89
+ again with the next session; remove the hook to prevent that. Setups from
90
+ alpha.15 or earlier get the hook by running `redue agent setup claude --apply`
91
+ again.
92
+
93
+ The hook skips an exact rerun of a configured typecheck (a check with
94
+ `kind: "typecheck"` or named `typecheck`) or direct test check. It matches
95
+ these commands, run from the project root:
85
96
 
86
97
  - `redue run CHECK`, or `redue --config FILE run CHECK` when the config is not
87
98
  `redue.config.json`
88
- - `npm run SCRIPT`, when the check has an npm script
89
- - `npx tsc --noEmit`, for the direct-TypeScript recipe
99
+ - the check's script through the project's package manager: `npm run SCRIPT`
100
+ (and `npm test`), `pnpm SCRIPT`, `pnpm run SCRIPT`, `yarn SCRIPT` or
101
+ `yarn run SCRIPT`. The short form is left out when the script is named like
102
+ one of the package manager's own commands.
103
+ - the tool the check runs: `npx tsc ARGS` (and `pnpm exec tsc ARGS` with pnpm)
104
+ for a direct TypeScript check, or the runner's command, alone or after `npx`,
105
+ for a direct test check
90
106
  - the check's `command`, when it has no `@` tokens
107
+ - for a workspace package's check: `pnpm --filter NAME SCRIPT` (also
108
+ `--filter=NAME`, `-F NAME`, and with `run`), `yarn workspace NAME SCRIPT`, or
109
+ `npm run SCRIPT -w NAME` (also `--workspace NAME`, `--workspace=NAME` and
110
+ `npm -w NAME run SCRIPT`). After `cd` into the package's directory, the
111
+ package-manager script forms and tool forms match that check.
112
+
113
+ A `turbo run TASK` (or `turbo TASK`, alone or after `npx` or `pnpm exec`) or,
114
+ in a pnpm project, `pnpm -r TASK` (`--recursive`, with or without `run`) that
115
+ runs a checked task in every workspace package is skipped only when every check
116
+ it would run is CURRENT, PASS and `reuse_eligible`. Every package with the
117
+ task's script must have a check. turbo must define the task, with `dependsOn`
118
+ listing at most `^TASK`, no `with`, no `PACKAGE#TASK` entries and no package
119
+ `turbo.json`; `pnpm -r` must not include the workspace root. A root script
120
+ whose text is such a command counts as that command, unless it has pre- or
121
+ post-scripts. In every other case the command runs as typed: turbo caches its
122
+ own results, so the hook never rewrites it. Filters (`--filter`) and nx are not
123
+ matched.
91
124
 
92
125
  Each form may end with `2>&1` and then one `| tail -n N`, `| head -n N`,
93
126
  `| tail -N` or `| head -N`. It may also be the first command of a `;` or `&&`
@@ -121,8 +154,10 @@ Limits:
121
154
 
122
155
  - The hook judges its own environment, which Claude Code passes to it. The Bash
123
156
  tool can add variables from your shell profile. The hook cannot see those.
124
- - Commands run from a subdirectory, after another command (`cd x && tsc`), or
125
- combined with `||`, `&` or other pipes, are not matched.
157
+ - Commands run from a session that started in a subdirectory, after a `cd`
158
+ outside the project, or combined with `||`, `&` or other pipes, are not
159
+ matched. After a `cd` into any other directory than the root or a checked
160
+ package's, nothing matches.
126
161
  - A skip writes nothing. If a typecheck command also emits files, later
127
162
  commands see the files from its last run.
128
163
  - Only the Bash tool is covered, not PowerShell.
@@ -1,4 +1,4 @@
1
- # Opt-in development pilot: alpha.15
1
+ # Opt-in development pilot: alpha.17
2
2
 
3
3
  Use one fixed build for several ordinary development sessions. The owner will
4
4
  invite two or three willing testers and approve their projects; nothing is
@@ -35,7 +35,7 @@ are **NOT RUN**. The recording shortcut is macOS/direct-recipe only.
35
35
  For a new default npm installation:
36
36
 
37
37
  ```sh
38
- npm install --global @redue/cli@0.1.0-alpha.15
38
+ npm install --global @redue/cli@0.1.0-alpha.17
39
39
  redue --version
40
40
  cd my-project
41
41
  redue --config redue.config.json init --recipe typescript-direct --check typecheck --dry-run
@@ -89,7 +89,7 @@ using it; keep the same npm prefix, config and any explicit state selection.
89
89
 
90
90
  ```sh
91
91
  redue --config redue.config.json stop
92
- npm install --global @redue/cli@0.1.0-alpha.15
92
+ npm install --global @redue/cli@0.1.0-alpha.17
93
93
  redue --config redue.config.json agent setup codex --dry-run
94
94
  redue --config redue.config.json agent setup codex --apply
95
95
  redue --config redue.config.json start
@@ -45,13 +45,26 @@ project whose test script runs Vitest 4/5 or Jest 29 directly, the opt-in
45
45
  In particular, test caches and build-generated inputs are not excluded to
46
46
  make evidence green.
47
47
 
48
- Pinned pnpm 12 projects are read from `packageManager`, `pnpm-workspace.yaml`,
49
- the lockfile, and the installed `node_modules/.modules.yaml` metadata. The
48
+ pnpm 9-12 projects are read from `packageManager`, `pnpm-workspace.yaml`,
49
+ `.npmrc`, the lockfile, and the installed `node_modules/.modules.yaml`
50
+ metadata. The installed metadata names the pnpm version; a `packageManager`
51
+ pin is optional, and when present (with or without Corepack's `+sha512` hash)
52
+ it must name the installed version. Settings that only change what gets
53
+ installed are accepted, because the installed files are what REDUE observes.
54
+ Settings that move or copy installed files, or run code during install, are
55
+ not: custom `modulesDir`, `virtualStoreDir` or `lockfileDir`, the global
56
+ virtual store, injected workspace packages, separate workspace lockfiles,
57
+ pnpmfile hooks and `configDependencies`. They are refused when either
58
+ `pnpm-workspace.yaml` or `.npmrc` sets them. The linker is read from the file
59
+ the installed major reads (`.npmrc` for pnpm 9, `pnpm-workspace.yaml` then
60
+ `.npmrc` for pnpm 10, `pnpm-workspace.yaml` for pnpm 11 and 12) and must match
61
+ the installed one. Only the project's files are read: settings from a user or
62
+ global rc file, or `npm_config_*` variables during install, are not. The
50
63
  supported local layouts are pnpm's standard isolated and hoisted
51
64
  `node_modules` with a project-local `.pnpm` virtual store. `init` can discover
52
65
  root and workspace scripts. For a standalone `tsc --noEmit` or `tsc -p ...`
53
66
  script in the narrow TypeScript contract, it records the exact installed
54
- compiler invocation directly. This avoids pnpm 12's task-run bookkeeping
67
+ compiler invocation directly. This avoids pnpm's task-run bookkeeping
55
68
  writes during `pnpm run`; it does not alter the project's script. Test, lint,
56
69
  build, and ambiguous TypeScript scripts still execute through `pnpm run` and
57
70
  start recording-only. The generated `@typescript-bin:WORKSPACE` token resolves
@@ -2,8 +2,24 @@
2
2
 
3
3
  `redue init --recipe tests-direct --check test` records a project's unit test
4
4
  run and reports later whether that result still applies to the files you have.
5
- It supports a single-package npm project whose test script runs Vitest 4 or 5,
6
- or Jest 29, directly. It runs on macOS and Linux with Node 22 or 24.
5
+ It supports a single-package project installed by npm, pnpm 9-12 (isolated or
6
+ hoisted `node_modules`) or Yarn with the `node-modules` linker, whose test
7
+ script runs Vitest 4 or 5, or Jest 29, directly. It runs on macOS and Linux with
8
+ Node 22 or 24. The project has one lockfile kind, matching any
9
+ `packageManager` declaration; Yarn needs that declaration. With pnpm, the
10
+ runner is the package `node_modules/RUNNER` links to, inside the checkout.
11
+ Jest also needs jest-util 29 at `node_modules/jest-util`, which pnpm does not
12
+ place there unless it hoists.
13
+
14
+ In a workspace (npm or Yarn `workspaces`, or `packages` in
15
+ `pnpm-workspace.yaml`), `init` gives each package whose test script runs
16
+ Vitest directly its own check, `PACKAGE:test`, run in the package's directory
17
+ with the Vitest that Node finds from there. Its inputs are still the whole
18
+ checkout, so an edit in any package makes it STALE. The root's and every
19
+ package's `node_modules` are observed as installed files, without Vite's
20
+ caches in them. Jest is not supported in workspace packages, and neither is a
21
+ test script at the workspace root. A package added later needs `init` again:
22
+ its manifest appearing makes the workspace's checks UNVERIFIED until then.
7
23
 
8
24
  ## Setup
9
25
 
@@ -21,8 +37,8 @@ runner invocation: no `&&`, quotes, globs or variable assignments. Watch mode,
21
37
  Jest's `--onlyChanged` are refused. Vitest needs `run`, `--run` or
22
38
  `--watch=false`. Other scripts stay recording-only.
23
39
 
24
- REDUE runs the local runner with Node directly. npm, its lifecycle scripts and
25
- `package.json` are not changed or executed.
40
+ REDUE runs the local runner with Node directly. The package manager, its
41
+ lifecycle scripts and `package.json` are not changed or executed.
26
42
 
27
43
  ## What REDUE claims
28
44
 
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.15",
4
+ "version": "0.1.0-alpha.17",
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 {hookCommand,hookForms,redueFor} from './claude-hook.mjs';
14
+ import {fanoutRules,hookCommand,hookForms,redueFor} 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'},
@@ -23,10 +23,16 @@ const settingsPath='.claude/settings.local.json';
23
23
  const sameJson=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
24
24
  // One handler per exact command form; `if` keeps other Bash calls from starting Node.
25
25
  // The hook waits for status up to 10 s; 15 s leaves room for starting Node.
26
- function hookFor(config,relative){
27
- const rules=[...new Set(hookForms(config,relative).flatMap(row=>row.forms))].map(form=>`Bash(${form}*)`);
26
+ // Fan-out commands (turbo, nx, `pnpm -r`) and root scripts that run one get
27
+ // prefix rules; the hook decides from the full command.
28
+ function hookFor(config,relative,scripts){
29
+ const rules=[...new Set([...hookForms(config,relative).flatMap(row=>[...row.forms,...row.local]),
30
+ ...fanoutRules(config,scripts)])].map(form=>`Bash(${form}*)`);
28
31
  return rules.length?{matcher:'Bash',hooks:rules.map(rule=>({type:'command',if:rule,command:hookCommand,timeout:15}))}:null;
29
32
  }
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}]};
35
+ const events=['PreToolUse','SessionStart'];
30
36
  // REDUE's entry is recognized by its command, not by a committed record: the
31
37
  // local file differs between checkouts, and users may edit the entry.
32
38
  const ownedHook=entry=>Array.isArray(entry?.hooks)&&entry.hooks.length>0&&entry.hooks.every(h=>h?.command===hookCommand);
@@ -35,23 +41,28 @@ function parseSettings(text){
35
41
  let value;try{value=JSON.parse(text);}catch{throw Error(`${settingsPath} is not valid JSON; fix it before agent setup or removal`);}
36
42
  if(!value||typeof value!=='object'||Array.isArray(value)||value.hooks!==undefined&&
37
43
  (!value.hooks||typeof value.hooks!=='object'||Array.isArray(value.hooks)||
38
- value.hooks.PreToolUse!==undefined&&!Array.isArray(value.hooks.PreToolUse)))
44
+ events.some(event=>value.hooks[event]!==undefined&&!Array.isArray(value.hooks[event]))))
39
45
  throw Error(`${settingsPath} has an unexpected shape; fix it before agent setup or removal`);
40
46
  return value;
41
47
  }
42
- // Replace REDUE's entries with `next` (or remove them). Unchanged input returns
43
- // the same text, so idempotent setup does not rewrite the file.
48
+ // Replace REDUE's entry for each event with next[event] (or remove it; a null
49
+ // `next` removes them all). Unchanged input returns the same text, so
50
+ // idempotent setup does not rewrite the file.
44
51
  function settingsWith(text,next){
45
- const value=parseSettings(text),old=value.hooks?.PreToolUse||[];
46
- const at=old.findIndex(ownedHook),list=old.filter(entry=>!ownedHook(entry));
47
- if(next)list.splice(at<0?list.length:at,0,next);
48
- if(sameJson(list,old))return text;
49
- if(list.length)value.hooks={...value.hooks,PreToolUse:list};
50
- else if(value.hooks){delete value.hooks.PreToolUse;if(!Object.keys(value.hooks).length)delete value.hooks;}
52
+ const value=parseSettings(text);let changed=false;
53
+ for(const event of events){
54
+ const old=value.hooks?.[event]||[],at=old.findIndex(ownedHook),list=old.filter(entry=>!ownedHook(entry));
55
+ if(next?.[event])list.splice(at<0?list.length:at,0,next[event]);
56
+ if(sameJson(list,old))continue;
57
+ changed=true;
58
+ if(list.length)value.hooks={...value.hooks,[event]:list};
59
+ else if(value.hooks){delete value.hooks[event];if(!Object.keys(value.hooks).length)delete value.hooks;}
60
+ }
61
+ if(!changed)return text;
51
62
  return Object.keys(value).length?JSON.stringify(value,null,2)+'\n':null;
52
63
  }
53
- function hookInstalled(root){
54
- try{return (parseSettings(read(root,settingsPath)).hooks?.PreToolUse||[]).some(ownedHook);}
64
+ function hookInstalled(root,event='PreToolUse'){
65
+ try{return (parseSettings(read(root,settingsPath)).hooks?.[event]||[]).some(ownedHook);}
55
66
  catch{return false;}
56
67
  }
57
68
  function safeFile(root,relative){
@@ -121,10 +132,10 @@ function earlierBlock(host,rows=[]){
121
132
  `If a check's last pass still applies to these exact files, a REDUE hook skips\n`+
122
133
  `the run and says so. The skip counts as a pass; don't check it again.\n`);
123
134
  }
124
- // One form per check: the npm script when there is one, since that is what
125
- // the project's own instructions usually name.
135
+ // One form per check: the package manager's script form when there is one,
136
+ // since that is what the project's own instructions usually name.
126
137
  const blockForms=(host,config,relative)=>host!=='claude'?[]:hookForms(config,relative).map(row=>({kind:row.kind,
127
- form:row.forms.find(form=>form.startsWith('npm run '))??row.forms.find(form=>!form.startsWith('redue '))??row.forms[0]}));
138
+ form:row.forms.find(form=>/^(?:npm run|pnpm|yarn) /.test(form))??row.forms.find(form=>!form.startsWith('redue '))??row.forms[0]}));
128
139
  const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${host}:begin -->\n`+
129
140
  `Before deciding whether configured verification needs repeating, use REDUE.\n`+
130
141
  (legacy?`Read ${hosts[host].skill} for the decision policy, then query ordinary status\n`+
@@ -143,7 +154,7 @@ const priorBlocks=(host,relative)=>[false,true].map(legacy=>`\n\n<!-- REDUE:${ho
143
154
  // varies, and only in the safe character set. Earlier owned versions are
144
155
  // recognized so setup can replace them; other stored text never is.
145
156
  function knownBlock(host,relative,block){
146
- const list='((?:`[A-Za-z0-9_./:=,+ -]+`(?: and )?)+)';
157
+ const list='((?:`[A-Za-z0-9_./:=,+@ -]+`(?: and )?)+)';
147
158
  const forms=pattern=>{const named=new RegExp(pattern).exec(block);return named?[...named[1].matchAll(/`([^`]+)`/g)].map(m=>m[1]):[];};
148
159
  const rows=[...forms(`^\\n\\n<!-- REDUE:[a-z]+:begin -->\\nTypecheck with ${list}\\.`).map(form=>({kind:'typecheck',form})),
149
160
  ...forms(`Run the unit tests with ${list}\\.\\n`).map(form=>({kind:'test',form}))];
@@ -245,7 +256,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
245
256
  if(value.state_access)console.log(`State access: ${value.state_access.status}${value.state_access.reason?' — '+value.state_access.reason:''}`);
246
257
  if(value.compatibility?.reason)console.log(`Observer compatibility: ${value.compatibility.reason}`);
247
258
  if(value.applicability?.requires_reassessment)console.log('Applicability: cached evidence needs reassessment; choose synchronization or execution deliberately.');
248
- for(const row of value.hosts)console.log(`${row.host}: ${row.version||'host unavailable'}; integration ${row.integrity}; hook ${row.hook}; behavioral validation NOT VERIFIED by doctor${row.reason?' — '+row.reason:''}`);
259
+ for(const row of value.hosts)console.log(`${row.host}: ${row.version||'host unavailable'}; integration ${row.integrity}; hook ${row.hook}${row.host==='claude'?`; session start ${row.session_start}`:''}; behavioral validation NOT VERIFIED by doctor${row.reason?' — '+row.reason:''}`);
249
260
  console.log('Doctor does not start observers, execute checks, or prove agent behavior.');
250
261
  for(const warning of value.warnings)console.log(`Review: ${warning}`);
251
262
  }return value;
@@ -261,14 +272,16 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
261
272
  if(!record&&(before?.includes('<!-- REDUE:')||read(root,h.skill)!==null))
262
273
  throw Error('unowned REDUE instruction or skill already exists; inspect it before setup');
263
274
  const after=record?before.replace(record.block,block):(before||'')+block;
264
- hook=host==='claude'?hookFor(config,relative):null;
275
+ let scripts={};try{scripts=JSON.parse(read(root,'package.json')??'{}').scripts||{};}catch{}
276
+ hook=host==='claude'?hookFor(config,relative,scripts):null;
265
277
  const next={schema:1,host,config:relative,instruction:h.instruction,skill:h.skill,
266
278
  block,skillHash:hash(skill),instructionCreated:record?.instructionCreated??before===null};
267
279
  changes.push({path:h.skill,before:read(root,h.skill),after:skill},
268
280
  {path:h.instruction,before,after});
269
- if(host==='claude'){const settings=read(root,settingsPath);
270
- changes.push({path:settingsPath,before:settings,after:settingsWith(settings,hook),
271
- content:hook?JSON.stringify(hook,null,2):'remove the REDUE PreToolUse hook (no typecheck or test check to match)'});}
281
+ if(host==='claude'){const settings=read(root,settingsPath),next={PreToolUse:hook,SessionStart:sessionHook};
282
+ changes.push({path:settingsPath,before:settings,after:settingsWith(settings,next),
283
+ content:JSON.stringify(hook?next:{SessionStart:sessionHook},null,2)+
284
+ (hook?'':'\nno PreToolUse hook: no typecheck or test check to match')});}
272
285
  changes.push({path:marker(host),before:read(root,marker(host)),after:JSON.stringify(next,null,2)+'\n'});
273
286
  }else if(record){
274
287
  const before=read(root,h.instruction),after=before.replace(record.block,'');
@@ -282,7 +295,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
282
295
  const summary={schema:1,action,host,config:configFile,applied:false,
283
296
  changes:actual.map(c=>({path:c.path,operation:c.after===null?'remove':c.before===null?'create':'update',
284
297
  ...(action==='setup'?{content:c.path===h.instruction?block:c.content??c.after}: {})})),
285
- warnings:notes,hooks:hook?`${settingsPath} PreToolUse`:'none',behavior_verified:false};
298
+ warnings:notes,hooks:host!=='claude'||action!=='setup'?'none':`${settingsPath} ${hook?'PreToolUse, ':''}SessionStart`,behavior_verified:false};
286
299
  if(!json){console.log(`${action} ${host}: ${configFile}`);
287
300
  for(const c of summary.changes)console.log(` ${c.operation} ${c.path}${c.content?'\n'+c.content:''}`);
288
301
  if(!actual.length)console.log('Already in the requested state.');
@@ -302,7 +315,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
302
315
  export async function doctor({configFile,stateOverride,entry,cwd,host}){
303
316
  const value={schema:1,command:{path:findExecutable('redue'),invoked:entry},project:{config:configFile},
304
317
  observation:null,control:null,state_access:null,observer_process:null,compatibility:null,
305
- applicability:null,hosts:[],warnings:[],hooks:`claude only: PreToolUse typecheck hook in ${settingsPath}`,behavior_verified:false};
318
+ applicability:null,hosts:[],warnings:[],hooks:`claude only: PreToolUse check hook and SessionStart observer start in ${settingsPath}`,behavior_verified:false};
306
319
  let p;try{p=project(configFile);value.project.root=p.root;
307
320
  const state=stateOverride||projectState(p.root,configFile);value.project.state=state;
308
321
  const cached=readCachedStatus(state,p.config.checks);value.observation=cached.observation;
@@ -333,12 +346,14 @@ export async function doctor({configFile,stateOverride,entry,cwd,host}){
333
346
  if(executable){const launch=windowsLaunch([executable,'--version']);
334
347
  const out=spawnSync(launch.file,launch.args,{...launch.options,encoding:'utf8',timeout:2000,maxBuffer:4096,stdio:['ignore','pipe','pipe']});
335
348
  if(out.status===0)version=out.stdout.trim();}
336
- let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none';
349
+ let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none',sessionHook=hook;
350
+ const installed=()=>value.command.path?'installed':'installed, but redue is not on PATH, so it never runs';
337
351
  if(p)try{const record=saved(p.root,name);integration=inspect(p.root,name,record);
338
- if(name==='claude'&&hookInstalled(p.root))hook=value.command.path?'installed':'installed, but redue is not on PATH, so it never runs';
352
+ if(name==='claude'&&hookInstalled(p.root))hook=installed();
353
+ if(name==='claude'&&hookInstalled(p.root,'SessionStart'))sessionHook=installed();
339
354
  value.warnings.push(...warnings(p.root,name,cwd));
340
355
  }catch(e){integration={installed:false,integrity:'conflict',reason:e.message};}
341
- value.hosts.push({host:name,executable,version,...integration,hook,behavior_verified:false,
356
+ value.hosts.push({host:name,executable,version,...integration,hook,session_start:sessionHook,behavior_verified:false,
342
357
  loading:name==='codex'?'AGENTS.md at session start; .agents/skills; overrides and size limits apply':
343
358
  'CLAUDE.md at session start; .claude/skills; managed/user policies and exclusions apply'});
344
359
  }