luciazero 2.5.0 → 2.6.0

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/README.md CHANGED
@@ -26,6 +26,11 @@ Luciazero is the verification and handoff layer for Claude Code, Codex CLI, and
26
26
  compatible skill runtimes. It helps agents prove tests, preserve scope, and
27
27
  move unfinished work with evidence.
28
28
 
29
+ This checkout is the **2.6.0** tree: the manifests and the top
30
+ [changelog](CHANGELOG.md) entry agree, and this document describes that
31
+ source. Its twelve eval fixtures pass offline under `./test.sh`; no real-model
32
+ skills-ablation pilot has run yet.
33
+
29
34
  > Done is proven by a command, not by my judgment. If no verification command
30
35
  > exists, that is the first bug.
31
36
 
@@ -170,6 +175,17 @@ forged artifact cannot downgrade validation. Detached checkouts are supported;
170
175
  Relay never executes artifact commands. The receiver runs them in its coding
171
176
  harness and passes `consume --verified` only after every result matches.
172
177
 
178
+ The whole exchange is two commands per side. The producer runs
179
+ `luciazero relay draft --write` (add `--recipient cross-machine --base <base>`
180
+ to publish the transfer tag), fills the JSON, then `luciazero relay finalize`,
181
+ which validates, renders the human view, and for cross-machine prints the
182
+ trusted envelope (`--envelope-out <file>` saves it). The receiver runs
183
+ `luciazero relay inspect --trusted-envelope <file>` and, after re-running the
184
+ evidence, `luciazero relay consume --verified --trusted-envelope <file>`. The
185
+ envelope is only trusted when it arrived through an authenticated channel and
186
+ is named explicitly from outside the clone; one shipped beside the artifact is
187
+ refused.
188
+
173
189
  <p align="center">
174
190
  <img src="https://cdn.jsdelivr.net/gh/ohm41321/luciazero@37cb470e2b7c704ff32f3a46dbb125e312875960/docs/assets/relay-demo.gif" width="720" alt="One session creates a Lucia Relay; another validates it, detects repository drift, re-runs evidence, and consumes it">
175
191
  </p>
@@ -215,16 +231,33 @@ This installs the 13 skills: no doctrine, reviewer, or hooks.
215
231
  <details>
216
232
  <summary><strong>Classic install · Claude Code or Codex CLI</strong></summary>
217
233
 
234
+ Install the command globally once, without `sudo`:
235
+
218
236
  ```bash
219
- npx luciazero # Claude Code
220
- npx luciazero --with-hooks # Claude Code + hooks/statusline; needs Python 3.9+
221
- npx luciazero codex # Codex CLI
237
+ npx luciazero@latest global-install
238
+ ```
222
239
 
223
- npx luciazero uninstall
224
- npx luciazero uninstall-codex
240
+ This installs the CLI under `~/.local/npm` and, after confirmation, adds its
241
+ bin directory to your zsh or bash PATH. Start a new shell, then use the global
242
+ command from any directory:
243
+
244
+ ```bash
245
+ luciazero # Claude Code
246
+ luciazero --with-hooks # Claude Code + hooks/statusline; needs Python 3.9+
247
+ luciazero codex # Codex CLI
248
+
249
+ luciazero uninstall # remove the Claude classic files
250
+ luciazero uninstall-codex # remove the Codex classic files
251
+ luciazero global-status # check the global command and PATH
252
+ luciazero global-uninstall # remove the command and its exact PATH block
225
253
  ```
226
254
 
227
- Pick either plugin or classic for Claude Code so hooks are not wired twice.
255
+ For a one-off install without keeping the command, `npx luciazero@latest`
256
+ continues to work. Automation may pass `global-install --yes`; interactive use
257
+ asks before installing the package and changing a shell startup file.
258
+
259
+ Pick either plugin or classic for Claude Code: installing both loads every
260
+ skill and the reviewer twice, even though hooks and doctrine deduplicate.
228
261
  Classic installs support `--status`; Codex receives the doctrine and skills but
229
262
  not Claude-only hooks/statusline. Installers back up name collisions and remove
230
263
  only exact Luciazero-managed copies on uninstall.
@@ -236,10 +269,13 @@ only exact Luciazero-managed copies on uninstall.
236
269
  Luciazero never changes classic or Codex files in the background.
237
270
 
238
271
  ```bash
239
- npx luciazero@latest check-update # read-only; contacts npm only now
240
- npx luciazero@latest update # updates every detected classic/Codex install
272
+ luciazero check-update # read-only; contacts npm only now
273
+ luciazero update # updates every detected classic/Codex install
241
274
  ```
242
275
 
276
+ If you chose the one-off path, use the same commands through
277
+ `npx luciazero@latest` instead.
278
+
243
279
  `update` preserves whether the Claude classic install uses hooks, repairs stale
244
280
  managed files, starts no fresh install when it cannot find one, and stops on a
245
281
  known newer version or malformed version metadata. Start a new agent session
@@ -289,8 +325,9 @@ technical evidence plain. Plugin users invoke `/luciazero:imouto-mode focus`;
289
325
  Codex users invoke `$imouto-mode focus`.
290
326
 
291
327
  Risky diffs also pass through one read-only `reviewer` with `security`,
292
- `contract`, or `general` focus. Security and contract risk together receive two
293
- separate passes.
328
+ `contract`, or `general` focus. Security and contract risk together get one
329
+ pass that names both focuses, never two. A small diff with no routed risk gets
330
+ no review.
294
331
 
295
332
  ## Evidence & limitations
296
333
 
@@ -355,9 +392,15 @@ only one run per arm per task. See the [full benchmark](https://github.com/ohm41
355
392
  evals invoke a model CLI and consume API credit or subscription quota.
356
393
  - Hooks run commands on your machine. Read them before enabling them.
357
394
  - Hook telemetry stays local in private per-session state and records aggregate
358
- turn/Bash wall time plus Bash, verify, and model/user skill counts—never raw
359
- commands, skill names, or paths.
395
+ turn/Bash/verify wall time, redundant-green counts, and Bash, verify, and
396
+ model/user skill counts—never raw commands, skill names, or paths. Older rows
397
+ without verify timing are reported as not measured, not zero.
360
398
  - Set `LUCIAZERO_VERIFY_CMD` to the repo's exact fast verify command.
399
+ - `LUCIAZERO_EDIT_DIAG=1` in your own shell makes the hook keep one line per
400
+ edit event in its state directory (tool name, opaque key, whether
401
+ `file_path` was missing, empty or present, its suffix, under cwd or not,
402
+ counted or not — never the path or the content), for finding out what
403
+ re-armed the nudge when no visible edit did.
361
404
  - Put `LUCIAZERO_STRICT_VERIFY_CMD` only in personal settings, never in a
362
405
  committed repository config. Strict mode fails open on internal errors.
363
406
  - A repository's committed `.claude/settings.json` cannot configure Luciazero
@@ -374,16 +417,28 @@ See [SECURITY.md](https://github.com/ohm41321/luciazero/blob/main/SECURITY.md) f
374
417
  ## Developing Luciazero
375
418
 
376
419
  ```bash
377
- ./test.sh --fast # intermediate loop: core doctrine/hooks/report/Relay checks
378
- ./test.sh # closeout/CI: full eval, packaging, and install coverage
420
+ ./test.sh --discipline # editing a hook, the report or a skill prompt: ~30 s
421
+ ./test.sh --fast # intermediate loop: adds the agentd suite, Relay, bisect, evidence
422
+ ./test.sh # closeout/CI: full eval, packaging, and install coverage
423
+ LZ_TEST_TIMINGS=1 ./test.sh --fast # plus one `TIMING gate=<name> seconds=<n>` per gate on stderr
424
+ scripts/test-timings.sh --fast # same run, and keeps stdout/stderr/meta under .test-timings/
425
+ scripts/test-timings.sh --report # median and p95 per gate over the green samples kept so far,
426
+ # and the commits they came from (a warning when they span more than one)
379
427
  ```
380
428
 
381
- The fast tier is the default intermediate check for this repository; use a
382
- more targeted command when changing a component it does not cover. The default
429
+ The discipline tier is the loop command for the enforcement pack, the
430
+ discipline report and the prompts: syntax, bash 3.2 and ShellCheck over every
431
+ shipped script, the prompt and doctrine contracts, and the hook state machine,
432
+ nothing else. The fast tier is the default intermediate check for everything
433
+ else; use a more targeted command when changing a component it does not cover. The default
383
434
  full tier (also `./test.sh --full`) covers scripts, hook state, Relay, bisect,
384
435
  plugin/npm manifests, self-proving eval graders, and sandboxed install →
385
436
  reinstall → uninstall for Claude Code and Codex. CI and `/done` use the full
386
- tier.
437
+ tier. `test.sh` is the dispatcher; the checks live in `tests/gates/*.sh`, one
438
+ file per subsystem, sourced in order — read the gate a change touches. In the
439
+ full tier the tiers, eval, packaging, install and codex-install gates run at
440
+ once, each in its own subshell, with their output replayed in order so it
441
+ reads as the serial run; `LZ_TEST_PARALLEL=0` runs them one at a time.
387
442
 
388
443
  More detail:
389
444
 
@@ -36,8 +36,10 @@ Confirm each suspected defect in source before reporting it. Read direct callers
36
36
  and consumers when they can prove reachability or compatibility. Use cheap,
37
37
  read-only commands when decisive. Never edit, commit, or push.
38
38
 
39
- Stay inside the diff's causal scope. No style or formatting findings unless they
40
- change behavior. Do not narrate the search. Report every verified
39
+ Stay inside the diff's causal scope. Read the changed hunks and their direct
40
+ callers and consumers, never a repository sweep or a module the diff does not
41
+ reach, and stop once every routed risk is checked. No style or formatting
42
+ findings unless they change behavior. Do not narrate the search. Report every verified
41
43
  `blocker`/`major`; report at most three `minor` findings, ranked by impact.
42
44
 
43
45
  ## Output
@@ -49,7 +49,10 @@ function parseLine(line) {
49
49
  if (trimmed.startsWith("{")) {
50
50
  let row;
51
51
  try { row = JSON.parse(trimmed); } catch (_) { return { malformed: true }; }
52
- if (row.schema !== 2 || !["stop-clean", "nudge", "strict-block"].includes(row.event)) {
52
+ // Schema 2 rows carry turn/Bash/skill aggregates; schema 3 adds how long
53
+ // verify commands ran and how many greens repeated a green with no edit
54
+ // between. Either is read; anything else is a row this reader cannot vouch for.
55
+ if (![2, 3].includes(row.schema) || !["stop-clean", "nudge", "strict-block"].includes(row.event)) {
53
56
  return { malformed: true };
54
57
  }
55
58
  const timestamp = new Date(row.timestamp);
@@ -58,9 +61,16 @@ function parseLine(line) {
58
61
  }
59
62
  let telemetry = null;
60
63
  if (row.telemetry && typeof row.telemetry === "object") {
64
+ const count = (key) => Number.isSafeInteger(row.telemetry[key]) && row.telemetry[key] >= 0;
61
65
  const keys = ["turn_ms", "bash_ms", "bash_count", "verify_count", "skill_count"];
62
- if (keys.every((key) => Number.isSafeInteger(row.telemetry[key]) && row.telemetry[key] >= 0)) {
66
+ if (keys.every(count)) {
63
67
  telemetry = Object.fromEntries(keys.map((key) => [key, row.telemetry[key]]));
68
+ // The verify aggregates are optional on purpose: a schema-2 row never
69
+ // had them, and a schema-3 row missing or garbling them still counts
70
+ // for everything else. `null` means "not measured", never zero.
71
+ const verifyKeys = ["verify_ms", "redundant_green_count"];
72
+ const measured = verifyKeys.every(count);
73
+ for (const key of verifyKeys) telemetry[key] = measured ? row.telemetry[key] : null;
64
74
  }
65
75
  }
66
76
  return {
@@ -123,6 +133,7 @@ const modes = { regex: 0, exact: 0, strict: 0, unknown: 0 };
123
133
  const telemetry = {
124
134
  measured_turns: 0, turn_ms: 0, bash_ms: 0, non_bash_ms: 0,
125
135
  bash_count: 0, verify_count: 0, skill_count: 0,
136
+ verify_measured_turns: 0, verify_ms: 0, redundant_green_count: 0,
126
137
  };
127
138
  const projects = new Map();
128
139
  for (const row of rows) {
@@ -136,6 +147,11 @@ for (const row of rows) {
136
147
  telemetry.bash_count += row.telemetry.bash_count;
137
148
  telemetry.verify_count += row.telemetry.verify_count;
138
149
  telemetry.skill_count += row.telemetry.skill_count;
150
+ if (row.telemetry.verify_ms !== null) {
151
+ telemetry.verify_measured_turns += 1;
152
+ telemetry.verify_ms += row.telemetry.verify_ms;
153
+ telemetry.redundant_green_count += row.telemetry.redundant_green_count;
154
+ }
139
155
  }
140
156
  const current = projects.get(row.projectId) || {
141
157
  project: row.project,
@@ -167,6 +183,13 @@ if (counts.nudge > 0) {
167
183
  if (counts["strict-block"] > 0) {
168
184
  recommendations.push(`Investigate the configured strict command: it returned red at ${counts["strict-block"]} stop attempt(s).`);
169
185
  }
186
+ if (telemetry.redundant_green_count > 0) {
187
+ recommendations.push(
188
+ `Likely: ${telemetry.redundant_green_count} verify run(s) came back green with no edit since the previous green` +
189
+ ` (${Math.round(telemetry.verify_ms / telemetry.verify_measured_turns)} ms of verify per measured turn);` +
190
+ " re-run the verify command after an edit, not to reconfirm a result nothing changed."
191
+ );
192
+ }
170
193
  if (total > 0 && recommendations.length === 0) {
171
194
  recommendations.push("No recurring gap is supported by the selected records.");
172
195
  }
@@ -215,6 +238,12 @@ if (telemetry.measured_turns === 0) {
215
238
  console.log(` Average Bash time: ${average(telemetry.bash_ms)} ms`);
216
239
  console.log(` Average non-Bash: ${average(telemetry.non_bash_ms)} ms`);
217
240
  console.log(` Bash / verify / skill calls: ${telemetry.bash_count} / ${telemetry.verify_count} / ${telemetry.skill_count}`);
241
+ if (telemetry.verify_measured_turns > 0) {
242
+ const perTurn = Math.round(telemetry.verify_ms / telemetry.verify_measured_turns);
243
+ console.log(` Verify time: ${perTurn} ms per turn over ${telemetry.verify_measured_turns} turn(s), ${telemetry.redundant_green_count} redundant green(s)`);
244
+ } else {
245
+ console.log(" Verify time: not measured yet (schema-3 hooks record it)");
246
+ }
218
247
  }
219
248
  console.log("");
220
249
  console.log("Top Nudged Repositories:");
package/bin/global.js ADDED
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("node:fs");
5
+ const os = require("node:os");
6
+ const path = require("node:path");
7
+ const readline = require("node:readline");
8
+ const { spawnSync } = require("node:child_process");
9
+
10
+ const START = "# luciazero:start global-npm-path";
11
+ const END = "# luciazero:end global-npm-path";
12
+ const BODY = `${START}\nexport PATH="$HOME/.local/npm/bin:$PATH"\n${END}\n`;
13
+
14
+ function locations(env = process.env) {
15
+ const home = env.HOME || os.homedir();
16
+ if (!path.isAbsolute(home)) throw new Error("HOME must be an absolute path");
17
+ const shell = path.basename(env.SHELL || "");
18
+ const rcName = shell === "zsh" ? ".zshrc" : shell === "bash" ? ".bashrc" : null;
19
+ if (!rcName) throw new Error("supported shells are zsh and bash; set SHELL to the shell whose PATH should be updated");
20
+ return { home, prefix: path.join(home, ".local", "npm"), rc: path.join(home, rcName) };
21
+ }
22
+
23
+ function readRc(file) {
24
+ try {
25
+ const stat = fs.lstatSync(file);
26
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${file} is not a regular file; left untouched`);
27
+ return { text: fs.readFileSync(file, "utf8"), mode: stat.mode & 0o777 };
28
+ } catch (error) {
29
+ if (error.code === "ENOENT") return { text: "", mode: 0o600 };
30
+ throw error;
31
+ }
32
+ }
33
+
34
+ function nextRc(current, remove = false) {
35
+ const starts = current.split(START).length - 1;
36
+ const ends = current.split(END).length - 1;
37
+ if (starts !== ends || starts > 1) throw new Error("shell config has malformed Luciazero PATH markers; left untouched");
38
+ if (starts === 1) {
39
+ const begin = current.indexOf(START);
40
+ const finish = current.indexOf("\n", current.indexOf(END, begin));
41
+ const owned = current.slice(begin, finish < 0 ? current.length : finish + 1);
42
+ if (owned !== BODY) throw new Error("shell config has a customized Luciazero PATH block; left untouched");
43
+ if (remove) return current.slice(0, begin) + current.slice(begin + owned.length);
44
+ return current;
45
+ }
46
+ if (remove) return current;
47
+ const separator = current.length && !current.endsWith("\n") ? "\n" : "";
48
+ return current + separator + BODY;
49
+ }
50
+
51
+ function writeRc(file, text, mode) {
52
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
53
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.luciazero-${process.pid}-${Date.now()}`);
54
+ const fd = fs.openSync(tmp, "wx", mode);
55
+ try {
56
+ // open(2)'s requested mode is filtered by umask. This is a replacement
57
+ // for an existing user file, so restore its exact permission bits before
58
+ // publishing the temporary file with rename(2).
59
+ fs.fchmodSync(fd, mode);
60
+ fs.writeFileSync(fd, text, "utf8");
61
+ fs.fsyncSync(fd);
62
+ fs.closeSync(fd);
63
+ fs.renameSync(tmp, file);
64
+ } catch (error) {
65
+ try { fs.closeSync(fd); } catch {}
66
+ try { fs.unlinkSync(tmp); } catch {}
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ function npm(args, env = process.env) {
72
+ const result = spawnSync("npm", args, { stdio: "inherit", env });
73
+ if (result.error) throw new Error(`could not run npm: ${result.error.message}`);
74
+ if (result.status !== 0) throw new Error(`npm exited ${result.status === null ? "without a status" : result.status}`);
75
+ }
76
+
77
+ function confirm(question) {
78
+ if (!process.stdin.isTTY) return Promise.resolve(false);
79
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
80
+ return new Promise((resolve) => rl.question(`${question} [y/N] `, (answer) => {
81
+ rl.close();
82
+ resolve(/^y(?:es)?$/i.test(answer.trim()));
83
+ }));
84
+ }
85
+
86
+ async function install(args) {
87
+ if (args.includes("--help")) {
88
+ console.log("Usage: luciazero global-install [--yes]\nInstalls luciazero@latest under ~/.local/npm and adds its bin to PATH.");
89
+ return 0;
90
+ }
91
+ const unknown = args.filter((arg) => arg !== "--yes");
92
+ if (unknown.length) throw new Error(`unknown option: ${unknown[0]}`);
93
+ const place = locations();
94
+ const before = readRc(place.rc);
95
+ const after = nextRc(before.text);
96
+ if (!args.includes("--yes") && !await confirm(`Install luciazero@latest globally in ${place.prefix}?`)) {
97
+ console.error("global install cancelled; nothing changed");
98
+ return 1;
99
+ }
100
+ npm(["install", "--global", "--prefix", place.prefix, "luciazero@latest"]);
101
+ if (after !== before.text) {
102
+ try {
103
+ writeRc(place.rc, after, before.mode);
104
+ } catch (error) {
105
+ throw new Error(
106
+ `the package was installed, but PATH was not changed: ${error.message}. ` +
107
+ `Run ${path.join(place.prefix, "bin", "luciazero")} directly or retry global-install`
108
+ );
109
+ }
110
+ }
111
+ console.log(`luciazero installed globally in ${place.prefix}`);
112
+ console.log(`PATH recorded in ${place.rc}; start a new shell or source that file`);
113
+ return 0;
114
+ }
115
+
116
+ function status(args) {
117
+ if (args.includes("--help")) {
118
+ console.log("Usage: luciazero global-status\nChecks the user-owned global command and its shell PATH block.");
119
+ return 0;
120
+ }
121
+ if (args.length) throw new Error(`unknown option: ${args[0]}`);
122
+ const place = locations();
123
+ const command = path.join(place.prefix, "bin", "luciazero");
124
+ let commandOk = false;
125
+ try {
126
+ const commandStat = fs.statSync(command);
127
+ commandOk = commandStat.isFile() && Boolean(commandStat.mode & 0o111);
128
+ } catch {}
129
+ let pathOk = false;
130
+ try {
131
+ const current = readRc(place.rc).text;
132
+ pathOk = nextRc(current) === current;
133
+ } catch (error) {
134
+ console.error(`luciazero: ${error.message}`);
135
+ return 1;
136
+ }
137
+ if (!commandOk || !pathOk) {
138
+ if (!commandOk) console.error(`MISS ${command}`);
139
+ if (!pathOk) console.error(`MISS Luciazero PATH block in ${place.rc}`);
140
+ return 1;
141
+ }
142
+ console.log(`luciazero is installed globally in ${place.prefix}`);
143
+ console.log(`PATH is recorded in ${place.rc}`);
144
+ return 0;
145
+ }
146
+
147
+ async function uninstall(args) {
148
+ if (args.includes("--help")) {
149
+ console.log("Usage: luciazero global-uninstall [--yes]\nRemoves the global npm package and only Luciazero's exact PATH block.");
150
+ return 0;
151
+ }
152
+ const unknown = args.filter((arg) => arg !== "--yes");
153
+ if (unknown.length) throw new Error(`unknown option: ${unknown[0]}`);
154
+ const place = locations();
155
+ const before = readRc(place.rc);
156
+ const after = nextRc(before.text, true);
157
+ if (!args.includes("--yes") && !await confirm(`Uninstall global luciazero from ${place.prefix}?`)) {
158
+ console.error("global uninstall cancelled; nothing changed");
159
+ return 1;
160
+ }
161
+ npm(["uninstall", "--global", "--prefix", place.prefix, "luciazero"]);
162
+ if (after !== before.text) {
163
+ try {
164
+ writeRc(place.rc, after, before.mode);
165
+ } catch (error) {
166
+ throw new Error(
167
+ `the package was removed, but its PATH block remains in ${place.rc}: ${error.message}. ` +
168
+ `Remove only the lines from '${START}' through '${END}'`
169
+ );
170
+ }
171
+ }
172
+ console.log(`global luciazero removed from ${place.prefix}`);
173
+ return 0;
174
+ }
175
+
176
+ async function main(argv) {
177
+ const [command, ...args] = argv;
178
+ if (command === "install") return install(args);
179
+ if (command === "status") return status(args);
180
+ if (command === "uninstall") return uninstall(args);
181
+ throw new Error(`${command || "command"} is not implemented`);
182
+ }
183
+
184
+ if (require.main === module) {
185
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => {
186
+ console.error(`luciazero: ${error.message}`);
187
+ process.exitCode = 1;
188
+ });
189
+ }
190
+
191
+ module.exports = { BODY, locations, nextRc, readRc, writeRc };
package/bin/luciazero.js CHANGED
@@ -9,7 +9,9 @@
9
9
  // npx luciazero discipline [options] -> local stats report
10
10
  // npx luciazero check-update [--json] -> explicit npm version check
11
11
  // npx luciazero update -> update detected classic installs
12
+ // npx luciazero global-install [--yes] -> persistent user-owned CLI
12
13
  // npx luciazero bus status [--json] -> Agent Bus queue summary (beta)
14
+ // npx luciazero relay <subcommand> [...] -> Lucia Relay (draft, finalize, inspect, consume, ...)
13
15
  const { spawnSync } = require("node:child_process");
14
16
  const path = require("node:path");
15
17
 
@@ -21,7 +23,13 @@ const ROUTES = {
21
23
  discipline: { runtime: process.execPath, script: "bin/discipline-report.js" },
22
24
  "check-update": { runtime: process.execPath, script: "bin/update.js", args: ["check"] },
23
25
  update: { runtime: process.execPath, script: "bin/update.js", args: ["update"] },
26
+ "global-install": { runtime: process.execPath, script: "bin/global.js", args: ["install"] },
27
+ "global-status": { runtime: process.execPath, script: "bin/global.js", args: ["status"] },
28
+ "global-uninstall": { runtime: process.execPath, script: "bin/global.js", args: ["uninstall"] },
24
29
  bus: { runtime: process.execPath, script: "bin/bus.js" },
30
+ // python.org installers on Windows ship python.exe, and python3 there is
31
+ // usually the Store alias, so the wrapper names the interpreter that exists.
32
+ relay: { runtime: process.platform === "win32" ? "python" : "python3", script: "skills/lucia-relay/scripts/relay.py" },
25
33
  };
26
34
 
27
35
  const args = process.argv.slice(2);
@@ -30,7 +38,7 @@ if (args[0] && !args[0].startsWith("-")) {
30
38
  if (!Object.prototype.hasOwnProperty.call(ROUTES, args[0])) {
31
39
  console.error(
32
40
  `luciazero: unknown command '${args[0]}' ` +
33
- "(install, codex, discipline, check-update, update, bus, uninstall, uninstall-codex)"
41
+ "(install, codex, discipline, check-update, update, global-install, global-status, global-uninstall, bus, relay, uninstall, uninstall-codex)"
34
42
  );
35
43
  process.exit(64);
36
44
  }
@@ -48,7 +56,7 @@ if (process.platform === "win32" && selected.runtime === "bash") {
48
56
 
49
57
  const result = spawnSync(selected.runtime, [script, ...(selected.args || []), ...args], { stdio: "inherit" });
50
58
  if (result.error) {
51
- console.error("luciazero: could not run bash: " + result.error.message);
59
+ console.error(`luciazero: could not run ${path.basename(selected.runtime)}: ${result.error.message}`);
52
60
  process.exit(1);
53
61
  }
54
62
  process.exit(result.status === null ? 1 : result.status);
@@ -36,8 +36,10 @@ Confirm each suspected defect in source before reporting it. Read direct callers
36
36
  and consumers when they can prove reachability or compatibility. Use cheap,
37
37
  read-only commands when decisive. Never edit, commit, or push.
38
38
 
39
- Stay inside the diff's causal scope. No style or formatting findings unless they
40
- change behavior. Do not narrate the search. Report every verified
39
+ Stay inside the diff's causal scope. Read the changed hunks and their direct
40
+ callers and consumers, never a repository sweep or a module the diff does not
41
+ reach, and stop once every routed risk is checked. No style or formatting
42
+ findings unless they change behavior. Do not narrate the search. Report every verified
41
43
  `blocker`/`major`; report at most three `minor` findings, ranked by impact.
42
44
 
43
45
  ## Output