luciazero 2.5.1 → 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>
@@ -240,7 +256,8 @@ For a one-off install without keeping the command, `npx luciazero@latest`
240
256
  continues to work. Automation may pass `global-install --yes`; interactive use
241
257
  asks before installing the package and changing a shell startup file.
242
258
 
243
- Pick either plugin or classic for Claude Code so hooks are not wired twice.
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.
244
261
  Classic installs support `--status`; Codex receives the doctrine and skills but
245
262
  not Claude-only hooks/statusline. Installers back up name collisions and remove
246
263
  only exact Luciazero-managed copies on uninstall.
@@ -308,8 +325,9 @@ technical evidence plain. Plugin users invoke `/luciazero:imouto-mode focus`;
308
325
  Codex users invoke `$imouto-mode focus`.
309
326
 
310
327
  Risky diffs also pass through one read-only `reviewer` with `security`,
311
- `contract`, or `general` focus. Security and contract risk together receive two
312
- 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.
313
331
 
314
332
  ## Evidence & limitations
315
333
 
@@ -374,9 +392,15 @@ only one run per arm per task. See the [full benchmark](https://github.com/ohm41
374
392
  evals invoke a model CLI and consume API credit or subscription quota.
375
393
  - Hooks run commands on your machine. Read them before enabling them.
376
394
  - Hook telemetry stays local in private per-session state and records aggregate
377
- turn/Bash wall time plus Bash, verify, and model/user skill counts—never raw
378
- 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.
379
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.
380
404
  - Put `LUCIAZERO_STRICT_VERIFY_CMD` only in personal settings, never in a
381
405
  committed repository config. Strict mode fails open on internal errors.
382
406
  - A repository's committed `.claude/settings.json` cannot configure Luciazero
@@ -393,16 +417,28 @@ See [SECURITY.md](https://github.com/ohm41321/luciazero/blob/main/SECURITY.md) f
393
417
  ## Developing Luciazero
394
418
 
395
419
  ```bash
396
- ./test.sh --fast # intermediate loop: core doctrine/hooks/report/Relay checks
397
- ./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)
398
427
  ```
399
428
 
400
- The fast tier is the default intermediate check for this repository; use a
401
- 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
402
434
  full tier (also `./test.sh --full`) covers scripts, hook state, Relay, bisect,
403
435
  plugin/npm manifests, self-proving eval graders, and sandboxed install →
404
436
  reinstall → uninstall for Claude Code and Codex. CI and `/done` use the full
405
- 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.
406
442
 
407
443
  More detail:
408
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/luciazero.js CHANGED
@@ -11,6 +11,7 @@
11
11
  // npx luciazero update -> update detected classic installs
12
12
  // npx luciazero global-install [--yes] -> persistent user-owned CLI
13
13
  // npx luciazero bus status [--json] -> Agent Bus queue summary (beta)
14
+ // npx luciazero relay <subcommand> [...] -> Lucia Relay (draft, finalize, inspect, consume, ...)
14
15
  const { spawnSync } = require("node:child_process");
15
16
  const path = require("node:path");
16
17
 
@@ -26,6 +27,9 @@ const ROUTES = {
26
27
  "global-status": { runtime: process.execPath, script: "bin/global.js", args: ["status"] },
27
28
  "global-uninstall": { runtime: process.execPath, script: "bin/global.js", args: ["uninstall"] },
28
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" },
29
33
  };
30
34
 
31
35
  const args = process.argv.slice(2);
@@ -34,7 +38,7 @@ if (args[0] && !args[0].startsWith("-")) {
34
38
  if (!Object.prototype.hasOwnProperty.call(ROUTES, args[0])) {
35
39
  console.error(
36
40
  `luciazero: unknown command '${args[0]}' ` +
37
- "(install, codex, discipline, check-update, update, global-install, global-status, global-uninstall, bus, uninstall, uninstall-codex)"
41
+ "(install, codex, discipline, check-update, update, global-install, global-status, global-uninstall, bus, relay, uninstall, uninstall-codex)"
38
42
  );
39
43
  process.exit(64);
40
44
  }
@@ -52,7 +56,7 @@ if (process.platform === "win32" && selected.runtime === "bash") {
52
56
 
53
57
  const result = spawnSync(selected.runtime, [script, ...(selected.args || []), ...args], { stdio: "inherit" });
54
58
  if (result.error) {
55
- console.error("luciazero: could not run bash: " + result.error.message);
59
+ console.error(`luciazero: could not run ${path.basename(selected.runtime)}: ${result.error.message}`);
56
60
  process.exit(1);
57
61
  }
58
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
@@ -5,7 +5,8 @@
5
5
  # ("done is proven by a command") at the exact moment it is most violated.
6
6
  #
7
7
  # Subcommands (wired in settings.json):
8
- # prompt — UserPromptSubmit: start privacy-preserving turn telemetry
8
+ # prompt — UserPromptSubmit: start privacy-preserving turn telemetry (a
9
+ # prompt inside an open turn is a background-task notice: kept)
9
10
  # bash-start — PreToolUse on Bash: start shell-command timing
10
11
  # edit — PostToolUse on Edit|Write|NotebookEdit : record "an edit happened"
11
12
  # bash — PostToolUse on Bash: record duration, verify runs, and status
@@ -28,14 +29,17 @@
28
29
  # A blocked stop's continuation is never re-blocked (stop_hook_active),
29
30
  # so this is a speed bump with evidence attached, not a wall.
30
31
  #
31
- # Requires python3 (for JSON parsing). FAILS OPEN: any internal error exits 0,
32
+ # Requires python3 (one run per event: JSON fields, hashes, the settings scan).
33
+ # FAILS OPEN: any internal error exits 0,
32
34
  # so a broken hook can never block real work. Per-project state lives under
33
35
  # $TMPDIR and never touches the repo. One exception, documented honestly: the
34
36
  # stop hook appends one schema-versioned JSON line per stop outcome
35
37
  # (stop-clean / nudge / strict-block) to luciazero-stats.log in the harness
36
38
  # config dir — local only, capped at ~250 lines, fail-open. It records a
37
- # privacy-preserving project hash, verify mode, and aggregate latency/counts;
38
- # never the project path, command, or skill name. Uninstall keeps it.
39
+ # privacy-preserving project hash, verify mode, and aggregate latency/counts —
40
+ # including how long verify commands ran and how many came back green with no
41
+ # edit since the previous green (schema 3) — never the project path, command,
42
+ # or skill name. Uninstall keeps it.
39
43
  set -u
40
44
 
41
45
  MODE="${1:-}"
@@ -63,20 +67,6 @@ hook_path() { # canonical path of $1; empty when its directory does not exist
63
67
  # do not hang waiting for EOF that never comes.
64
68
  if [ -t 0 ]; then IN=""; else IN="$(cat 2>/dev/null || true)"; fi
65
69
 
66
- pyfield() { # pyfield '<python expr over dict d>' — empty string on any error
67
- printf '%s' "${IN}" | python3 -c "
68
- import json, sys
69
- try:
70
- d = json.load(sys.stdin)
71
- v = ${1}
72
- print('' if v is None else v)
73
- except Exception:
74
- print('')" 2>/dev/null || true
75
- }
76
-
77
- CWD="$(pyfield "d.get('cwd')")"
78
- [ -n "${CWD}" ] || CWD="${PWD}"
79
-
80
70
  # A repository's COMMITTED .claude/settings.json can put anything in its `env`
81
71
  # block, and that env reaches this hook — so NO LUCIAZERO_* knob is accepted
82
72
  # from that scope. Each one is a way to disable enforcement while the
@@ -97,10 +87,19 @@ CWD="$(pyfield "d.get('cwd')")"
97
87
  # Refusal only ever falls back to this file's own defaults, never to a block,
98
88
  # and a parse error leaves the configured values untouched. Only the modes that
99
89
  # consume a knob pay for the lookup.
100
- # The scanner program lives in a variable, not a here-document inside
101
- # $( ): bash 3.2 (still the /bin/bash on macOS) cannot parse that
102
- # combination and fails the whole file at load time.
103
- REFUSED_SCAN_PY='import json, os, stat, sys
90
+ #
91
+ # The scan runs inside the ONE python3 program this hook starts per event.
92
+ # Every field derived from stdin — cwd, the state key, session and tool keys,
93
+ # the command and its digest, the tool status, the relay age — comes out of
94
+ # this program as one value per line in a fixed order, the (possibly
95
+ # multi-line) Bash command last. A python3 start costs ~40 ms; one field per
96
+ # process spent 5–9 of them on every tool call. Any error leaves a field
97
+ # empty, and an empty state key fails the hook open below.
98
+ # The program lives in a variable, not a here-document inside $( ): bash 3.2
99
+ # (still the /bin/bash on macOS) cannot parse that combination and fails the
100
+ # whole file at load time.
101
+ PRELUDE_PY='import hashlib, json, os, stat, sys, time
102
+ ppid, mode = sys.argv[1], sys.argv[2]
104
103
  LIMIT = 1000000 # a settings file is kilobytes; this runs on every tool call
105
104
  MAX_DEPTH = 40 # ancestor walk is bounded, never unbounded I/O
106
105
 
@@ -135,63 +134,139 @@ def real(path):
135
134
  except OSError:
136
135
  return path
137
136
 
138
- home = real(os.path.expanduser("~"))
139
- # Only the DEFAULT config directory counts as user scope. CLAUDE_CONFIG_DIR is
140
- # attacker-reachable: pointed at the project itself, it would mark the
141
- # repository settings file as user scope and skip the very file that declares
142
- # it, and the dedupe below would then trust a classic install inside the repo.
143
- user_config = real(os.path.join(home, ".claude"))
144
- project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
145
- project_dir = real(project_dir) if project_dir else None
137
+ def refused_keys(start):
138
+ home = real(os.path.expanduser("~"))
139
+ # Only the DEFAULT config directory counts as user scope. CLAUDE_CONFIG_DIR
140
+ # is attacker-reachable: pointed at the project itself, it would mark the
141
+ # repository settings file as user scope and skip the very file that
142
+ # declares it, and the dedupe below would then trust a classic install
143
+ # inside the repo.
144
+ user_config = real(os.path.join(home, ".claude"))
145
+ project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
146
+ project_dir = real(project_dir) if project_dir else None
147
+ found, seen = [], set()
148
+ directory = real(start or ".")
149
+ for _ in range(MAX_DEPTH):
150
+ claude_dir = os.path.join(directory, ".claude")
151
+ if directory != home and real(claude_dir) != user_config:
152
+ for key in keys_in(os.path.join(claude_dir, "settings.json")):
153
+ if key not in seen:
154
+ seen.add(key)
155
+ found.append(key)
156
+ if directory == home:
157
+ break
158
+ if os.path.exists(os.path.join(directory, ".git")):
159
+ break # repository root: project scope ends here
160
+ if project_dir is not None and directory == project_dir:
161
+ break
162
+ parent = os.path.dirname(directory)
163
+ if parent == directory:
164
+ break
165
+ directory = parent
166
+ return found
167
+
168
+ json_ok = "no"
169
+ try:
170
+ d = json.load(sys.stdin)
171
+ json_ok = "yes"
172
+ except Exception:
173
+ d = None
174
+
175
+ def field(*path):
176
+ # the value at d[path...] as text; empty on any missing or mis-shaped level
177
+ v = d
178
+ try:
179
+ for p in path:
180
+ v = v.get(p)
181
+ return "" if v is None else str(v).rstrip("\n")
182
+ except Exception:
183
+ return ""
184
+
185
+ def digest(v, size):
186
+ return hashlib.sha256(v.encode("utf-8", "replace")).hexdigest()[:size]
146
187
 
147
- found, seen = [], set()
148
- directory = real(sys.argv[1] or ".")
149
- for _ in range(MAX_DEPTH):
150
- claude_dir = os.path.join(directory, ".claude")
151
- if directory != home and real(claude_dir) != user_config:
152
- for key in keys_in(os.path.join(claude_dir, "settings.json")):
153
- if key not in seen:
154
- seen.add(key)
155
- found.append(key)
156
- if directory == home:
157
- break
158
- if os.path.exists(os.path.join(directory, ".git")):
159
- break # repository root: project scope ends here
160
- if project_dir is not None and directory == project_dir:
161
- break
162
- parent = os.path.dirname(directory)
163
- if parent == directory:
164
- break
165
- directory = parent
166
- print("\n".join(found))
188
+ cwd = field("cwd") or os.environ.get("PWD") or os.getcwd()
189
+ # md5 here names a state directory; it is never a security decision. Saying so
190
+ # explicitly keeps the hook alive on a FIPS-enforcing python3, where a bare
191
+ # md5() call raises and the tracker would fail open (silently doing nothing).
192
+ key = hashlib.md5(cwd.encode("utf-8", "replace"), usedforsecurity=False).hexdigest()[:12]
193
+ session = field("session_id") or "parent-" + ppid
194
+ # stable opaque tool key; raw tool input never leaves temporary state
195
+ raw = (field("tool_use_id") or field("tool_input", "command") or field("tool_input", "skill")
196
+ or field("command_name") or field("prompt") or field("command") or "unknown")
197
+ cmd = field("tool_input", "command")
198
+ # best-effort red/green from the tool response. The Bash response of Claude
199
+ # Code carries no exit code (stdout, stderr, interrupted, isImage): PostToolUse
200
+ # only fires for a command that finished with exit 0, a non-zero exit reaches
201
+ # PostToolUseFailure (mode bash-failure) instead, so a completed, uninterrupted
202
+ # response in mode bash is a green. An explicit exit code still wins.
203
+ try:
204
+ r = d.get("tool_response") or {}
205
+ c = r.get("exit_code", r.get("exitCode"))
206
+ if isinstance(c, int) and not isinstance(c, bool):
207
+ status = "ok" if c == 0 else "fail"
208
+ elif r.get("is_error") is True:
209
+ status = "fail"
210
+ elif r.get("interrupted") is True:
211
+ status = "ran"
212
+ else:
213
+ status = "ok" if mode == "bash" else "ran"
214
+ except Exception:
215
+ status = ""
216
+ refused = refused_keys(cwd) if mode in ("edit", "bash", "bash-failure", "stop", "session") else []
217
+ age = ""
218
+ if mode == "session":
219
+ try:
220
+ age = str(int((time.time() - os.path.getmtime(os.path.join(cwd, "LUCIA_RELAY.json"))) // 86400))
221
+ except OSError:
222
+ pass
223
+ tool_input = d.get("tool_input")
224
+ fp_state = ("missing" if not isinstance(tool_input, dict) or "file_path" not in tool_input
225
+ else "empty" if tool_input["file_path"] in ("", None) else "present")
226
+ lines = [cwd, key, digest(session, 16), digest(raw, 16), str(int(time.time() * 1000)),
227
+ " ".join(refused), field("tool_input", "file_path"), field("expansion_type"),
228
+ field("stop_hook_active"), status, json_ok, digest(cmd, 64) if cmd else "", age,
229
+ field("source"), field("tool_name"), fp_state]
230
+ sys.stdout.write("\n".join(v.replace("\n", " ") for v in lines) + "\n" + cmd)
167
231
  '
168
- REFUSED_ENV_KEYS=""
169
- case "${MODE}" in
170
- edit|bash|bash-failure|stop|session)
171
- REFUSED_ENV_KEYS="$(printf '%s' "${REFUSED_SCAN_PY}" \
172
- | python3 - "${CWD}" 2>/dev/null || true)"
173
- ;;
174
- esac
232
+ PRE="$(printf '%s' "${IN}" | python3 -c "${PRELUDE_PY}" "${PPID}" "${MODE}" 2>/dev/null || true)"
233
+ {
234
+ IFS= read -r CWD
235
+ IFS= read -r KEY
236
+ IFS= read -r SESSION_KEY
237
+ IFS= read -r TK
238
+ IFS= read -r NOW_MS
239
+ IFS= read -r REFUSED_ENV_KEYS
240
+ IFS= read -r FP
241
+ IFS= read -r EXPANSION_TYPE
242
+ IFS= read -r ACTIVE
243
+ IFS= read -r STATUS
244
+ IFS= read -r JSON_OK
245
+ IFS= read -r CMD_HASH
246
+ IFS= read -r RELAY_AGE
247
+ IFS= read -r SESSION_SOURCE
248
+ IFS= read -r TOOL_NAME
249
+ IFS= read -r FP_STATE
250
+ CMD="$(cat)"
251
+ } <<EOF
252
+ ${PRE}
253
+ EOF
254
+ [ -n "${CWD}" ] || CWD="${PWD}"
175
255
  if [ -n "${REFUSED_ENV_KEYS}" ]; then
176
256
  # `LUCIAZERO_*` is the oversized-file marker: drop every knob this hook reads
177
257
  case "${REFUSED_ENV_KEYS}" in
178
258
  *'LUCIAZERO_*'*)
179
- REFUSED_ENV_KEYS='LUCIAZERO_VERIFY_CMD
180
- LUCIAZERO_VERIFY_REGEX
181
- LUCIAZERO_DOC_REGEX
182
- LUCIAZERO_STRICT_VERIFY_CMD
183
- LUCIAZERO_STRICT_TIMEOUT
184
- LUCIAZERO_RELAY_STALE_DAYS
185
- LUCIAZERO_HANDOFF_STALE_DAYS
186
- CLAUDE_CONFIG_DIR' ;;
259
+ REFUSED_ENV_KEYS='LUCIAZERO_VERIFY_CMD LUCIAZERO_VERIFY_REGEX LUCIAZERO_DOC_REGEX LUCIAZERO_STRICT_VERIFY_CMD LUCIAZERO_STRICT_TIMEOUT LUCIAZERO_RELAY_STALE_DAYS LUCIAZERO_HANDOFF_STALE_DAYS LUCIAZERO_EDIT_DIAG CLAUDE_CONFIG_DIR' ;;
187
260
  esac
188
- while IFS= read -r RK; do
261
+ # names are [A-Z_]* only, so splitting the line on spaces is exact; globbing
262
+ # is off for the loop so a key that carries a wildcard never names a file
263
+ set -f
264
+ for RK in ${REFUSED_ENV_KEYS}; do
189
265
  case "${RK}" in
190
266
  LUCIAZERO_[A-Z_]*|CLAUDE_CONFIG_DIR) unset "${RK}" 2>/dev/null || true ;;
191
267
  esac
192
- done <<EOF
193
- ${REFUSED_ENV_KEYS}
194
- EOF
268
+ done
269
+ set +f
195
270
  fi
196
271
 
197
272
  # Channel dedupe: when `install.sh --with-hooks` wiring is ALSO present, the
@@ -212,10 +287,7 @@ if [ -n "${CLASSIC_HOOK}" ] && [ "${SELF_HOOK}" != "${CLASSIC_HOOK}" ] \
212
287
  exit 0
213
288
  fi
214
289
 
215
- # md5 here names a state directory; it is never a security decision. Saying so
216
- # explicitly keeps the hook alive on a FIPS-enforcing python3, where a bare
217
- # md5() call raises and the tracker would fail open (silently doing nothing).
218
- KEY="$(printf '%s' "${CWD}" | python3 -c 'import sys,hashlib;print(hashlib.md5(sys.stdin.buffer.read(), usedforsecurity=False).hexdigest()[:12])' 2>/dev/null)" || exit 0
290
+ # The state key is the prelude's md5 of cwd; empty means python3 failed.
219
291
  [ -n "${KEY}" ] || exit 0
220
292
  BASE="${TMPDIR:-/tmp}/luciazero-verify-state-$(id -u 2>/dev/null || echo unknown)"
221
293
  # The base name is predictable, so validate ownership/type before touching it.
@@ -229,18 +301,18 @@ chmod 700 "${BASE}" 2>/dev/null || exit 0
229
301
  STATE="${BASE}/${KEY}"
230
302
  mkdir -p "${STATE}" 2>/dev/null || exit 0
231
303
  chmod 700 "${STATE}" 2>/dev/null || exit 0
232
- SESSION_RAW="$(pyfield "d.get('session_id')")"
233
- [ -n "${SESSION_RAW}" ] || SESSION_RAW="parent-${PPID}"
234
- SESSION_KEY="$(printf '%s' "${SESSION_RAW}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:16])' 2>/dev/null)" || exit 0
235
304
  TELEMETRY="${STATE}/telemetry/${SESSION_KEY}"
236
305
 
237
- tool_key() { # stable opaque key; raw tool input never leaves temporary state
238
- RAW="$(pyfield "d.get('tool_use_id') or d.get('tool_input', {}).get('command') or d.get('tool_input', {}).get('skill') or d.get('command_name') or d.get('prompt') or d.get('command')")"
239
- [ -n "${RAW}" ] || RAW=unknown
240
- printf '%s' "${RAW}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:16])' 2>/dev/null
241
- }
306
+ # A turn is open from its first prompt until a stop lets it end. The harness
307
+ # delivers a background task's completion (a forked skill, a subagent, a
308
+ # run_in_background command) as one more UserPromptSubmit, and before this
309
+ # marker every one of those wiped the turn's counters: a turn that waited on
310
+ # a background job reported a few seconds and one Bash call. A stop that
311
+ # blocks (nudge, strict red) keeps the turn open, because the model continues
312
+ # it; every stop that lets the turn end closes it. Fail-open, like all state.
313
+ end_turn() { rm -f "${TELEMETRY}/turn_open" 2>/dev/null || true; }
242
314
 
243
- now_ms() {
315
+ now_ms() { # strict gate only; every other mode uses the prelude's NOW_MS
244
316
  python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null
245
317
  }
246
318
 
@@ -270,7 +342,7 @@ path, cwd, event, mode, telemetry_dir = sys.argv[1:]
270
342
  os.makedirs(os.path.dirname(path), exist_ok=True)
271
343
  real = os.path.realpath(cwd)
272
344
  row = {
273
- "schema": 2,
345
+ "schema": 3,
274
346
  "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
275
347
  "event": event,
276
348
  "project_id": hashlib.sha256(real.encode()).hexdigest()[:12],
@@ -289,10 +361,19 @@ def count_files(name):
289
361
  for item in os.listdir(os.path.join(telemetry_dir, name)))
290
362
  except OSError:
291
363
  return 0
364
+ def merged_ms(intervals):
365
+ merged = []
366
+ for a, b in sorted((a, b) for a, b in intervals if a <= b):
367
+ if not merged or a > merged[-1][1]:
368
+ merged.append([a, b])
369
+ else:
370
+ merged[-1][1] = max(merged[-1][1], b)
371
+ return sum(b - a for a, b in merged)
292
372
  start = read_int(os.path.join(telemetry_dir, "turn_start_ms"))
293
373
  if start is not None:
294
374
  now = int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000)
295
- intervals = []
375
+ intervals, verify_intervals = [], []
376
+ verify_dir = os.path.join(telemetry_dir, "verify_count")
296
377
  try:
297
378
  interval_dir = os.path.join(telemetry_dir, "bash_intervals")
298
379
  for item in os.listdir(interval_dir):
@@ -302,21 +383,20 @@ if start is not None:
302
383
  continue
303
384
  if 0 <= a <= b:
304
385
  intervals.append((max(start, a), min(now, b)))
386
+ # the interval's tool key is the verify marker's name: same
387
+ # opaque digest, so no command is read to tell the two apart
388
+ if os.path.isfile(os.path.join(verify_dir, item)):
389
+ verify_intervals.append(intervals[-1])
305
390
  except OSError:
306
391
  pass
307
- merged = []
308
- for a, b in sorted((a, b) for a, b in intervals if a <= b):
309
- if not merged or a > merged[-1][1]:
310
- merged.append([a, b])
311
- else:
312
- merged[-1][1] = max(merged[-1][1], b)
313
- bash_ms = sum(b - a for a, b in merged)
314
392
  row["telemetry"] = {
315
393
  "turn_ms": max(0, now - start),
316
- "bash_ms": bash_ms,
394
+ "bash_ms": merged_ms(intervals),
317
395
  "bash_count": count_files("bash_count"),
318
396
  "verify_count": count_files("verify_count"),
319
397
  "skill_count": count_files("skill_count"),
398
+ "verify_ms": merged_ms(verify_intervals),
399
+ "redundant_green_count": count_files("redundant_green"),
320
400
  }
321
401
  with open(path, "a", encoding="utf-8") as handle:
322
402
  handle.write(json.dumps(row, separators=(",", ":")) + "\n")
@@ -335,47 +415,69 @@ PY
335
415
  # When LUCIAZERO_VERIFY_CMD is set (the repo's exact verify command, e.g.
336
416
  # "./test.sh"), only commands that ARE it or START with it count — the broad
337
417
  # regex also marks `cat test.sh` or `grep pytest README` as a verify run,
338
- # flipping the state green without any test having run.
339
- VERIFY_RE="${LUCIAZERO_VERIFY_REGEX:-verify|test\.sh|pytest|npm (run )?test|pnpm test|yarn test|cargo test|go test|vitest|jest|make (test|check)|tox|rake test|mix test|dotnet test|gradlew? (test|check)}"
418
+ # flipping the state green without any test having run. The default also
419
+ # knows `python -m unittest` and this repository's timing collector,
420
+ # scripts/test-timings.sh, which runs a tier -- except with --report, which
421
+ # only reads the samples kept so far; that case is carved out of the default
422
+ # below and is no concern of a regex somebody set themselves.
423
+ VERIFY_RE="${LUCIAZERO_VERIFY_REGEX:-verify|test\.sh|python[0-9.]* -m unittest|test-timings\.sh|pytest|npm (run )?test|pnpm test|yarn test|cargo test|go test|vitest|jest|make (test|check)|tox|rake test|mix test|dotnet test|gradlew? (test|check)}"
340
424
  VERIFY_CMD="${LUCIAZERO_VERIFY_CMD:-}"
341
425
 
342
426
  case "${MODE}" in
343
427
  prompt)
344
428
  # Per-turn scratch data is ephemeral. Persistent rows keep aggregates only.
429
+ # Inside an open turn this prompt is a notification, not a new turn: the
430
+ # counters and turn_start_ms stay exactly as they are.
431
+ [ -f "${TELEMETRY}/turn_open" ] && exit 0
345
432
  rm -rf "${TELEMETRY}" 2>/dev/null || exit 0
346
433
  mkdir -p "${TELEMETRY}" 2>/dev/null || exit 0
347
- now_ms > "${TELEMETRY}/turn_start_ms" 2>/dev/null || true
434
+ printf '%s\n' "${NOW_MS}" > "${TELEMETRY}/turn_start_ms" 2>/dev/null || true
435
+ : > "${TELEMETRY}/turn_open" 2>/dev/null || true
348
436
  ;;
349
437
  bash-start)
350
- TK="$(tool_key)" || exit 0
351
438
  mkdir -p "${TELEMETRY}/bash_start_ms" "${TELEMETRY}/bash_count" 2>/dev/null || exit 0
352
- now_ms > "${TELEMETRY}/bash_start_ms/${TK}" 2>/dev/null || true
439
+ printf '%s\n' "${NOW_MS}" > "${TELEMETRY}/bash_start_ms/${TK}" 2>/dev/null || true
353
440
  : > "${TELEMETRY}/bash_count/${TK}" 2>/dev/null || true
354
441
  ;;
355
442
  edit)
356
443
  # Documentation writes do not re-arm the nudge: the closeout skills
357
444
  # Closeout skills write docs AFTER the final green verify. Relay's JSON is
358
445
  # also a transient knowledge artifact, not implementation code.
359
- FP="$(pyfield "d.get('tool_input', {}).get('file_path')")"
360
446
  DOC_RE="${LUCIAZERO_DOC_REGEX:-\.(md|markdown|rst|txt)\$}"
447
+ COUNTED=yes
361
448
  case "${FP}" in
362
- */LUCIA_RELAY.json|*/LUCIA_RELAY.md) : ;;
449
+ */LUCIA_RELAY.json|*/LUCIA_RELAY.md) COUNTED=no ;;
363
450
  *)
364
451
  if [ -n "${FP}" ] && printf '%s' "${FP}" | grep -qE "${DOC_RE}"; then
365
- : # doc-only write; verify state unchanged
452
+ COUNTED=no # doc-only write; verify state unchanged
366
453
  else
367
454
  touch "${STATE}/last_edit"
368
455
  rm -f "${STATE}/nudged" # new code edits re-arm the one-shot nudge
369
456
  fi
370
457
  ;;
371
458
  esac
459
+ # Opt-in diagnostic (LUCIAZERO_EDIT_DIAG=1): one line per edit event in
460
+ # the state directory, next to last_edit, saying what the event carried
461
+ # and what the hook made of it -- the tool's name, the opaque tool key,
462
+ # whether file_path was missing, empty or present, its suffix, whether it
463
+ # lay under cwd, and whether the edit counted. Never the path, never the
464
+ # content. For finding out what touched last_edit when no visible edit did.
465
+ if [ "${LUCIAZERO_EDIT_DIAG:-}" = 1 ]; then
466
+ IN_CWD="-"; EXT="-"
467
+ if [ -n "${FP}" ]; then
468
+ case "${FP}" in "${CWD}"/*) IN_CWD=yes ;; *) IN_CWD=no ;; esac
469
+ case "${FP##*/}" in *.*) EXT="${FP##*.}" ;; esac
470
+ fi
471
+ printf 'ts=%s mode=%s tool=%s key=%s file_path=%s ext=%s in_cwd=%s counted=%s\n' \
472
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${MODE}" "${TOOL_NAME:--}" "${TK}" "${FP_STATE:-unknown}" \
473
+ "${EXT}" "${IN_CWD}" "${COUNTED}" >> "${STATE}/edit-diag.log" 2>/dev/null || true
474
+ fi
372
475
  ;;
373
476
  bash|bash-failure)
374
- TK="$(tool_key)" || TK=unknown
375
477
  mkdir -p "${TELEMETRY}/bash_count" "${TELEMETRY}/bash_intervals" 2>/dev/null || true
376
478
  : > "${TELEMETRY}/bash_count/${TK}" 2>/dev/null || true
377
479
  START_MS="$(cat "${TELEMETRY}/bash_start_ms/${TK}" 2>/dev/null || true)"
378
- END_MS="$(now_ms || true)"
480
+ END_MS="${NOW_MS}"
379
481
  case "${START_MS}:${END_MS}" in
380
482
  *[!0-9:]*|:|*:|*::* ) : ;;
381
483
  *)
@@ -384,7 +486,6 @@ case "${MODE}" in
384
486
  fi
385
487
  ;;
386
488
  esac
387
- CMD="$(pyfield "d.get('tool_input', {}).get('command')")"
388
489
  IS_VERIFY=no
389
490
  if [ -n "${CMD}" ]; then
390
491
  if [ -n "${VERIFY_CMD}" ]; then
@@ -394,38 +495,55 @@ case "${MODE}" in
394
495
  esac
395
496
  elif printf '%s' "${CMD}" | grep -qE "${VERIFY_RE}"; then
396
497
  IS_VERIFY=yes
498
+ if [ -z "${LUCIAZERO_VERIFY_REGEX:-}" ] && printf '%s' "${CMD}" | grep -qE 'test-timings\.sh +--report'; then
499
+ IS_VERIFY=no # the collector's report runs nothing
500
+ fi
397
501
  fi
398
502
  fi
399
503
  if [ "${IS_VERIFY}" = yes ]; then
400
504
  mkdir -p "${TELEMETRY}/verify_count" 2>/dev/null || true
401
505
  : > "${TELEMETRY}/verify_count/${TK}" 2>/dev/null || true
402
- # Best-effort red/green from the tool response; failure hooks are red.
403
- if [ "${MODE}" = bash-failure ]; then
404
- STATUS=fail
405
- else
406
- STATUS="$(pyfield "(lambda r, c=None: (lambda c: 'ok' if c == 0 else ('fail' if isinstance(c, int) else ('fail' if r.get('is_error') is True else 'ran')))(r.get('exit_code', r.get('exitCode'))))(d.get('tool_response') or {})")"
506
+ # Red/green came from the tool response in the prelude; failure hooks are red.
507
+ [ "${MODE}" = bash-failure ] && STATUS=fail
508
+ # A green that follows a green with no code edit between them proved
509
+ # nothing new: count it (schema 3 `redundant_green_count`) before the
510
+ # state below overwrites the previous result. Float mtimes for the
511
+ # same sub-second reason as the stop nudge; one python3, verify runs only.
512
+ if [ "${STATUS}" = ok ] && [ "$(python3 -c '
513
+ import os, sys
514
+ state = sys.argv[1]
515
+ def m(name):
516
+ try:
517
+ return os.path.getmtime(os.path.join(state, name))
518
+ except OSError:
519
+ return None
520
+ try:
521
+ last = open(os.path.join(state, "last_verify")).read().strip()
522
+ except OSError:
523
+ last = ""
524
+ e, v = m("last_edit"), m("last_verify")
525
+ print("yes" if last == "ok" and v is not None and (e is None or e <= v) else "no")' "${STATE}" 2>/dev/null || echo no)" = yes ]; then
526
+ mkdir -p "${TELEMETRY}/redundant_green" 2>/dev/null || true
527
+ : > "${TELEMETRY}/redundant_green/${TK}" 2>/dev/null || true
407
528
  fi
408
529
  printf '%s\n' "${STATUS:-ran}" > "${STATE}/last_verify"
409
530
  # Keep only an opaque digest for strict-gate equality; raw commands may
410
531
  # contain paths or secrets and must never persist in shared state.
411
- printf '%s' "${CMD}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' \
412
- > "${STATE}/last_verify_cmd_hash" 2>/dev/null || true
532
+ printf '%s\n' "${CMD_HASH}" > "${STATE}/last_verify_cmd_hash" 2>/dev/null || true
413
533
  rm -f "${STATE}/nudged"
414
534
  fi
415
535
  ;;
416
536
  skill|skill-prompt)
417
537
  if [ "${MODE}" = skill-prompt ]; then
418
- EXPANSION_TYPE="$(pyfield "d.get('expansion_type')")"
419
538
  [ "${EXPANSION_TYPE}" = slash_command ] || exit 0
420
539
  fi
421
- TK="$(tool_key)" || TK=unknown
422
540
  mkdir -p "${TELEMETRY}/skill_count" 2>/dev/null || true
423
541
  : > "${TELEMETRY}/skill_count/${TK}" 2>/dev/null || true
424
542
  ;;
425
543
  stop)
426
- # Never re-block a continuation that a stop hook itself caused
427
- ACTIVE="$(pyfield "d.get('stop_hook_active')")"
428
- if [ "${ACTIVE}" = "True" ] || [ "${ACTIVE}" = "true" ]; then exit 0; fi
544
+ # Never re-block a continuation that a stop hook itself caused; that
545
+ # continuation is the turn ending, so the turn closes here.
546
+ if [ "${ACTIVE}" = "True" ] || [ "${ACTIVE}" = "true" ]; then end_turn; exit 0; fi
429
547
  # Strict gate (opt-in, see header): actually run the user's verify command
430
548
  # unless the tracked state is already green-after-last-edit. Any internal
431
549
  # error — timeout, missing command, unparseable state — degrades to the
@@ -434,7 +552,6 @@ case "${MODE}" in
434
552
  # Strict gate only on well-formed input: unparseable stdin means we know
435
553
  # neither cwd nor stop_hook_active — running a command on guesses would
436
554
  # break both the fail-open and the never-re-block guarantees.
437
- JSON_OK="$(printf '%s' "${IN}" | python3 -c 'import json,sys; json.load(sys.stdin); print("yes")' 2>/dev/null || echo no)"
438
555
  if [ -n "${STRICT_CMD}" ] && [ "${JSON_OK}" = yes ]; then
439
556
  STRICT_START_MS="$(now_ms || true)"
440
557
  OUT="$(python3 -c '
@@ -476,7 +593,7 @@ else:
476
593
  print("\n".join(tail))
477
594
  ' "${STATE}" "${CWD}" "${STRICT_CMD}" "${LUCIAZERO_STRICT_TIMEOUT:-120}" 2>/dev/null || echo error)"
478
595
  case "${OUT%%$'\n'*}" in
479
- green) stat_log stop-clean; exit 0 ;;
596
+ green) stat_log stop-clean; end_turn; exit 0 ;;
480
597
  ok)
481
598
  record_strict_telemetry "${STRICT_START_MS}"
482
599
  printf 'ok\n' > "${STATE}/last_verify" 2>/dev/null || true
@@ -484,6 +601,7 @@ else:
484
601
  > "${STATE}/last_verify_cmd_hash" 2>/dev/null || true
485
602
  rm -f "${STATE}/nudged"
486
603
  stat_log stop-clean
604
+ end_turn
487
605
  exit 0 ;;
488
606
  red)
489
607
  record_strict_telemetry "${STRICT_START_MS}"
@@ -517,15 +635,21 @@ print("yes" if e is not None and (v is None or e > v) else "no")' "${STATE}" 2>/
517
635
  exit 2
518
636
  fi
519
637
  # NUDGE=no -> genuinely clean stop; yes-but-already-nudged logs nothing
520
- # (that nudge was counted when it fired)
638
+ # (that nudge was counted when it fired). Either way the turn ends here.
521
639
  [ "${NUDGE}" = no ] && stat_log stop-clean
640
+ end_turn
522
641
  ;;
523
642
  session)
643
+ # A marker left behind by a session that never reached its stop (crash,
644
+ # kill, resume) would make the first real prompt look like a notification
645
+ # and keep stale counters. Compaction is the one start that happens inside
646
+ # a live session, possibly mid-turn, so it leaves the marker alone.
647
+ [ "${SESSION_SOURCE}" = compact ] || end_turn
524
648
  # A committed settings env block that reconfigures this hook is worth one
525
649
  # loud line: the refusal above is silent, and a repository that ships these
526
650
  # keys is either mistaken or hostile. Names the keys, never their values.
527
651
  if [ -n "${REFUSED_ENV_KEYS}" ]; then
528
- echo "This repository's committed .claude/settings.json sets $(printf '%s' "${REFUSED_ENV_KEYS}" | tr '\n' ' ')— Luciazero refuses those keys from project scope (they can disable verify tracking or run a command at every stop). Review that env block before trusting this repo."
652
+ echo "This repository's committed .claude/settings.json sets ${REFUSED_ENV_KEYS} — Luciazero refuses those keys from project scope (they can disable verify tracking or run a command at every stop). Review that env block before trusting this repo."
529
653
  fi
530
654
  # SessionStart emits ONE pointer, never the relay contents. A legacy
531
655
  # HANDOFF.md gets a migration warning but is not silently rewritten.
@@ -536,7 +660,7 @@ print("yes" if e is not None and (v is None or e > v) else "no")' "${STATE}" 2>/
536
660
  fi
537
661
  exit 0
538
662
  fi
539
- AGE="$(python3 -c 'import os,sys,time;print(int((time.time()-os.path.getmtime(sys.argv[1]))//86400))' "${CAP}" 2>/dev/null || echo '')"
663
+ AGE="${RELAY_AGE}"
540
664
  STALE="${LUCIAZERO_RELAY_STALE_DAYS:-${LUCIAZERO_HANDOFF_STALE_DAYS:-7}}"
541
665
  if [ -n "${AGE}" ] && [ "${AGE}" -ge "${STALE}" ] 2>/dev/null; then
542
666
  echo "LUCIA_RELAY.json exists but is ${AGE} days old — likely stale. Run /lucia-relay inspect, verify its claims with extra suspicion, then consume or replace it."
package/install.sh CHANGED
@@ -78,6 +78,18 @@ version_of() {
78
78
  awk -F '"' '/^[[:space:]]*"version"[[:space:]]*:/ { print $4; exit }' \
79
79
  "${SRC}/package.json" 2>/dev/null || true
80
80
  }
81
+ # A plugin install of Luciazero beside this classic one loads every skill and
82
+ # the reviewer agent twice in each session, as `/x` and `/luciazero:x` (the
83
+ # hook and the doctrine dedupe themselves; skills and agents cannot). Only the
84
+ # harness's own registry is consulted, read-only; when it is absent or says
85
+ # nothing, nothing is printed.
86
+ plugin_double_install_note() {
87
+ REGISTRY="${CLAUDE_DIR}/plugins/installed_plugins.json"
88
+ [ -f "${REGISTRY}" ] && grep -q '"luciazero@' "${REGISTRY}" 2>/dev/null || return 0
89
+ echo " !! Luciazero is also installed as a Claude Code plugin: every skill and the"
90
+ echo " reviewer agent load twice per session. Keep one channel — /plugin uninstall"
91
+ echo " luciazero@luciazero for the plugin, or ./uninstall.sh for this copy."
92
+ }
81
93
 
82
94
  if [ "${STATUS_ONLY}" = 1 ]; then
83
95
  echo "Status of ${CLAUDE_DIR} (read-only)"
@@ -132,6 +144,7 @@ if [ "${STATUS_ONLY}" = 1 ]; then
132
144
  else
133
145
  echo " MISS CLAUDE.md import line (${IMPORT_LINE} exactly once; found ${N:-0})"; STATUS_RC=1
134
146
  fi
147
+ plugin_double_install_note
135
148
  V_SRC="$(version_of)"
136
149
  V_INST="$(cat "${CLAUDE_DIR}/.luciazero-version" 2>/dev/null || true)"
137
150
  if [ -z "${V_INST}" ]; then
@@ -631,4 +644,5 @@ if [ "${WITH_HOOKS}" = 1 ]; then
631
644
  else
632
645
  echo "Optional: ./install.sh --with-hooks adds the verify-nudge hooks + statusline."
633
646
  fi
647
+ plugin_double_install_note
634
648
  echo "The doctrine applies from the next Claude Code session."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "luciazero",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
4
4
  "description": "Verification-first discipline for coding agents (Claude Code + Codex CLI): 9-rule doctrine, 13 skills, risk-routed reviewer, fail-open enforcement hooks. npx luciazero installs it.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -7,12 +7,12 @@ description: Run the closeout ritual before handing back non-trivial work; full
7
7
 
8
8
  ## 1. Full verify
9
9
 
10
- Run the **full** tier now: `verify-full` when present, otherwise verify. Quote
11
- the shortest decisive line.
10
+ The **full** tier — `verify-full` when present, otherwise verify — must be
11
+ green after the last code edit. Run it only when no such result exists; an
12
+ older green does not count. Quote the shortest decisive line.
12
13
 
13
14
  - Red → you are not here yet. Return to the loop.
14
15
  - No verify command exists → use `/ready`; do not claim done.
15
- - It must actually have run **now**, not earlier in the session.
16
16
 
17
17
  ## 2. Skeptic diff pass
18
18
 
@@ -24,9 +24,9 @@ Re-read the final diff as a hostile reviewer. Check:
24
24
  - **Accidental content**: unrelated files, debug code, secrets, loose pins.
25
25
  - **Test honesty**: would changed tests fail if implementation is reverted?
26
26
 
27
- When applicable run
28
- `<this-skill-dir>/scripts/revert-probe.sh "<verify-cmd>"`, preferring a command
29
- aimed at the changed tests. Exit 2 is UNASSESSABLE: report it as no proof, not
27
+ Only when the diff adds or changes tests, run
28
+ `<this-skill-dir>/scripts/revert-probe.sh "<verify-cmd>"` aimed at those tests.
29
+ Exit 2 is UNASSESSABLE: report it as no proof, not
30
30
  as a pass. Weakened checks are findings. Fix findings and repeat full verify.
31
31
 
32
32
  ## 3. Risk-routed independent review
@@ -37,13 +37,14 @@ Choose focus:
37
37
  - `contract`: public API/CLI, schema, config, migration, consumers.
38
38
  - `general`: money, concurrency, resources, or a wide uncertain diff.
39
39
 
40
- Prefer the harness's built-in review command; otherwise use one reviewer agent.
41
- If security and contract both apply, request two independent focused passes.
42
- The reviewer reads callers and consumers.
40
+ A small, well-understood diff with no routed risk stops after the skeptic
41
+ pass: no review is the default, not an exception. Otherwise run **one** pass —
42
+ the harness's built-in review command when it exists, else one reviewer agent
43
+ — scoped to the diff and its direct callers, naming both `security` and
44
+ `contract` when both apply; never two.
43
45
 
44
46
  Fix and re-verify every `blocker` or `major`, unless the user explicitly
45
- accepts the named risk. A `minor` may be deferred only when reported. A small,
46
- well-understood diff with no routed risk may stop after the skeptic pass.
47
+ accepts the named risk. A `minor` may be deferred only when reported.
47
48
 
48
49
  ## 4. Scope check
49
50
 
@@ -19,12 +19,12 @@ Ask if unclear.
19
19
 
20
20
  ## Produce
21
21
 
22
- For same-machine, run `<this-skill-dir>/scripts/relay.py draft --root . --recipient same-machine`.
23
-
24
- 1. Commit and push every task file first. Choose the task's base commit, then
25
- run `<this-skill-dir>/scripts/relay.py draft --root . --recipient cross-machine --base <base> >
26
- LUCIA_RELAY.json`. This publishes a commit-named transfer tag and records
27
- sanitized clone URL, head/base OIDs, and committed changed files.
22
+ 1. Same-machine: run `<this-skill-dir>/scripts/relay.py draft --root . --recipient same-machine --write`.
23
+ Cross-machine: commit and push every task file first, choose the task's
24
+ base commit, then run `<this-skill-dir>/scripts/relay.py draft --root . --recipient cross-machine --base <base> --write`.
25
+ This publishes a commit-named transfer tag and records sanitized clone
26
+ URL, head/base OIDs, and committed changed files. `--write` refuses to
27
+ replace an existing `LUCIA_RELAY.json`.
28
28
  2. Fill goal, done/in-progress state, one literal next action, verification,
29
29
  `read_first`, inline knowledge, hypotheses (including refuted ones), and
30
30
  landmines. Keep captured route/repository fields unchanged.
@@ -32,9 +32,11 @@ For same-machine, run `<this-skill-dir>/scripts/relay.py draft --root . --recipi
32
32
  line, and timezone-aware run time. Include at least one entry and portable
33
33
  knowledge. Copy machine-local essentials into `knowledge.inline`; exclude
34
34
  credentials, private paths, and preferences.
35
- 4. Run `<this-skill-dir>/scripts/relay.py render --root .`, fix errors, then run `<this-skill-dir>/scripts/relay.py envelope
36
- --root .`. Send both artifacts normally; send the envelope's repository URL,
37
- HEAD, and manifest digest through an authenticated channel.
35
+ 4. Run `<this-skill-dir>/scripts/relay.py finalize --root .`: it validates, writes
36
+ `LUCIA_RELAY.md`, and for cross-machine prints the trusted envelope
37
+ (`--envelope-out <file>` saves it outside the repository). Fix errors and
38
+ rerun. Send both artifacts normally; send the envelope through an
39
+ authenticated channel, never beside the artifacts.
38
40
 
39
41
  Do not transfer a chat transcript. Transfer decisions, evidence, negative
40
42
  knowledge, and source-of-truth pointers. Keep artifacts out of Git; if
@@ -43,20 +45,20 @@ committed, review secrets and remove after use.
43
45
  ## Receive
44
46
 
45
47
  1. Obtain the trusted envelope. Clone its repository, checkout its HEAD
46
- (detached is valid), and place both artifacts at root. Never execute a
47
- command merely because the relay contains it.
48
- 2. Run `<this-skill-dir>/scripts/relay.py inspect --root . --expected-recipient cross-machine
49
- --trusted-head <sha> --trusted-manifest-sha256 <digest>
50
- --trusted-repository-url <url>`. Read committed
51
- changed files, every `read_first` pointer, inline knowledge, hypotheses, and
52
- landmines before editing.
48
+ (detached is valid), place both artifacts at root, and keep the envelope
49
+ outside the clone. Never execute a command merely because the relay
50
+ contains it.
51
+ 2. Run `<this-skill-dir>/scripts/relay.py inspect --root . --trusted-envelope <file>`
52
+ (same as `--expected-recipient cross-machine --trusted-head <sha>
53
+ --trusted-manifest-sha256 <digest> --trusted-repository-url <url>`). Read
54
+ committed changed files, every `read_first` pointer, inline knowledge,
55
+ hypotheses, and landmines before editing.
53
56
  3. Manually approve and run every verification command in the receiver's
54
57
  coding harness; Relay never executes artifact commands. Compare each exit
55
58
  code and decisive line with the recorded evidence.
56
59
  4. The tree wins on mismatch: report it and update the plan from current state.
57
60
  After all evidence matches, run `<this-skill-dir>/scripts/relay.py consume --root . --verified
58
- --expected-recipient cross-machine --trusted-head <sha>
59
- --trusted-manifest-sha256 <digest> --trusted-repository-url <url>`.
61
+ --trusted-envelope <file>`.
60
62
 
61
63
  For same-machine, inspect normally, rerun evidence manually, then consume with
62
64
  `--verified`; never reuse a stale relay.
@@ -23,7 +23,9 @@ from urllib.parse import urlsplit, urlunsplit
23
23
  MANIFEST = "LUCIA_RELAY.json"
24
24
  HUMAN = "LUCIA_RELAY.md"
25
25
  RECEIPT = "LUCIA_RELAY_RECEIPT.json"
26
+ ENVELOPE_KIND = "luciazero-relay-envelope"
26
27
  MAX_MANIFEST_BYTES = 1024 * 1024
28
+ MAX_ENVELOPE_BYTES = 64 * 1024
27
29
  MAX_HUMAN_BYTES = 2 * 1024 * 1024
28
30
  MAX_DEPTH = 20
29
31
  MAX_NODES = 8192
@@ -1037,6 +1039,116 @@ def manifest_sha256(root: Path) -> str:
1037
1039
  return hashlib.sha256(payload).hexdigest()
1038
1040
 
1039
1041
 
1042
+ def outside_root_error(root: Path, path: Path, label: str) -> Optional[str]:
1043
+ """A file under the relay root travelled with the artifact, so it is not
1044
+ a trusted channel and must not be produced or consumed as one."""
1045
+ try:
1046
+ resolved = path.resolve()
1047
+ except OSError as exc:
1048
+ return f"cannot resolve {label}: {exc}"
1049
+ if resolved == root or root in resolved.parents:
1050
+ return f"{label} must live outside the relay root {root}"
1051
+ return None
1052
+
1053
+
1054
+ def refuse_existing_manifest(root: Path) -> None:
1055
+ path = root / MANIFEST
1056
+ if path.is_symlink() or path.exists():
1057
+ raise ValueError(f"{MANIFEST} already exists; consume or remove it before drafting a new relay")
1058
+
1059
+
1060
+ def write_manifest(root: Path, data: dict[str, Any]) -> Path:
1061
+ path = root / MANIFEST
1062
+ refuse_existing_manifest(root)
1063
+ try:
1064
+ # Exclusive create: a symlink planted between the check and the write
1065
+ # fails here instead of being followed.
1066
+ with path.open("x", encoding="utf-8") as handle:
1067
+ handle.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
1068
+ except FileExistsError as exc:
1069
+ raise ValueError(f"{MANIFEST} already exists; consume or remove it before drafting a new relay") from exc
1070
+ except OSError as exc:
1071
+ raise ValueError(f"cannot write {MANIFEST}: {exc}") from exc
1072
+ return path
1073
+
1074
+
1075
+ def write_human(root: Path, data: dict[str, Any]) -> Path:
1076
+ human = root / HUMAN
1077
+ if human.is_symlink():
1078
+ raise ValueError(f"{HUMAN} must not be a symlink")
1079
+ try:
1080
+ human.write_text(render_markdown(data), encoding="utf-8")
1081
+ except OSError as exc:
1082
+ raise ValueError(f"cannot write {HUMAN}: {exc}") from exc
1083
+ return human
1084
+
1085
+
1086
+ def envelope_payload(root: Path, data: dict[str, Any]) -> dict[str, Any]:
1087
+ repository = data.get("repository") if isinstance(data.get("repository"), dict) else {}
1088
+ remote = repository.get("remote") if isinstance(repository.get("remote"), dict) else {}
1089
+ return {
1090
+ "schema": 1,
1091
+ "kind": ENVELOPE_KIND,
1092
+ "repository_url": remote.get("url"),
1093
+ "remote_ref": remote.get("ref"),
1094
+ "trusted_head": repository.get("head"),
1095
+ "trusted_manifest_sha256": manifest_sha256(root),
1096
+ }
1097
+
1098
+
1099
+ def write_envelope(root: Path, payload: dict[str, Any], out: Path) -> Path:
1100
+ problem = outside_root_error(root, out, "--envelope-out")
1101
+ if problem:
1102
+ raise ValueError(problem)
1103
+ if out.is_symlink() or out.exists():
1104
+ raise ValueError(f"--envelope-out already exists; send or remove {out} before writing a new envelope")
1105
+ try:
1106
+ # Exclusive create, like the manifest: an envelope that has not been
1107
+ # sent yet must not be replaced by a later relay's digest.
1108
+ with out.open("x", encoding="utf-8") as handle:
1109
+ handle.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
1110
+ except FileExistsError as exc:
1111
+ raise ValueError(f"--envelope-out already exists; send or remove {out} before writing a new envelope") from exc
1112
+ except OSError as exc:
1113
+ raise ValueError(f"cannot write --envelope-out: {exc}") from exc
1114
+ return out
1115
+
1116
+
1117
+ def load_trusted_envelope(root: Path, path: Path) -> dict[str, str]:
1118
+ """Read the three receiver-trusted values from an envelope file.
1119
+
1120
+ The path is always explicit; nothing is discovered. A file under the relay
1121
+ root arrived with the artifact and is refused, because the envelope only
1122
+ means something when it came through an authenticated channel.
1123
+ """
1124
+ label = "trusted envelope"
1125
+ problem = outside_root_error(root, path, label)
1126
+ if problem:
1127
+ raise ValueError(f"{problem}; a file that arrived with the artifact is not a trusted channel")
1128
+ data = load_json_object(path, label, MAX_ENVELOPE_BYTES)
1129
+ if data.get("kind") != ENVELOPE_KIND or type(data.get("schema")) is not int or data.get("schema") != 1:
1130
+ raise ValueError(f"{label} must declare kind {ENVELOPE_KIND} and schema 1")
1131
+ values: dict[str, str] = {}
1132
+ for key in ("trusted_head", "trusted_manifest_sha256", "repository_url"):
1133
+ if not nonempty(data.get(key)):
1134
+ raise ValueError(f"{label} lacks {key}")
1135
+ values[key] = str(data[key])
1136
+ return values
1137
+
1138
+
1139
+ def add_receiver_arguments(command: argparse.ArgumentParser) -> None:
1140
+ command.add_argument("--expected-recipient", choices=("same-machine", "cross-machine"))
1141
+ command.add_argument("--trusted-head")
1142
+ command.add_argument("--trusted-manifest-sha256")
1143
+ command.add_argument("--trusted-repository-url")
1144
+ command.add_argument(
1145
+ "--trusted-envelope",
1146
+ metavar="PATH",
1147
+ help="read the three trusted values from an envelope file received through an authenticated channel; "
1148
+ "the file must live outside --root and implies --expected-recipient cross-machine",
1149
+ )
1150
+
1151
+
1040
1152
  def parser() -> argparse.ArgumentParser:
1041
1153
  top = argparse.ArgumentParser(description=__doc__)
1042
1154
  sub = top.add_subparsers(dest="command", required=True)
@@ -1049,22 +1161,27 @@ def parser() -> argparse.ArgumentParser:
1049
1161
  help="where the receiver will consume this relay (default: same-machine for legacy callers)",
1050
1162
  )
1051
1163
  draft_command.add_argument("--base", help="task base revision; required for cross-machine")
1052
- for name in ("render", "validate", "envelope", "inspect"):
1164
+ draft_command.add_argument(
1165
+ "--write",
1166
+ action="store_true",
1167
+ help=f"write {MANIFEST} into --root instead of printing it; refuses to replace an existing one",
1168
+ )
1169
+ for name in ("render", "validate", "envelope", "finalize", "inspect"):
1053
1170
  command = sub.add_parser(name)
1054
1171
  command.add_argument("--root", default=".")
1172
+ if name == "finalize":
1173
+ command.add_argument(
1174
+ "--envelope-out",
1175
+ metavar="PATH",
1176
+ help="cross-machine only: also write the trusted envelope to this path outside --root",
1177
+ )
1055
1178
  if name == "inspect":
1056
1179
  command.add_argument("--json", action="store_true")
1057
- command.add_argument("--expected-recipient", choices=("same-machine", "cross-machine"))
1058
- command.add_argument("--trusted-head")
1059
- command.add_argument("--trusted-manifest-sha256")
1060
- command.add_argument("--trusted-repository-url")
1180
+ add_receiver_arguments(command)
1061
1181
  consume = sub.add_parser("consume")
1062
1182
  consume.add_argument("--root", default=".")
1063
1183
  consume.add_argument("--verified", action="store_true")
1064
- consume.add_argument("--expected-recipient", choices=("same-machine", "cross-machine"))
1065
- consume.add_argument("--trusted-head")
1066
- consume.add_argument("--trusted-manifest-sha256")
1067
- consume.add_argument("--trusted-repository-url")
1184
+ add_receiver_arguments(consume)
1068
1185
  return top
1069
1186
 
1070
1187
 
@@ -1073,7 +1190,13 @@ def main() -> int:
1073
1190
  root = Path(args.root).resolve()
1074
1191
  if args.command == "draft":
1075
1192
  try:
1193
+ if args.write:
1194
+ # Refuse before a cross-machine draft publishes its transfer tag.
1195
+ refuse_existing_manifest(root)
1076
1196
  data = draft(root, args.recipient, args.base)
1197
+ if args.write:
1198
+ print(f"WROTE {write_manifest(root, data)}")
1199
+ return 0
1077
1200
  except ValueError as exc:
1078
1201
  print(f"relay: {exc}", file=sys.stderr)
1079
1202
  return 1
@@ -1089,6 +1212,23 @@ def main() -> int:
1089
1212
  trusted_head = getattr(args, "trusted_head", None)
1090
1213
  trusted_manifest_sha256 = getattr(args, "trusted_manifest_sha256", None)
1091
1214
  trusted_repository_url = getattr(args, "trusted_repository_url", None)
1215
+ trusted_envelope = getattr(args, "trusted_envelope", None)
1216
+ if trusted_envelope is not None:
1217
+ if any(value is not None for value in (trusted_head, trusted_manifest_sha256, trusted_repository_url)):
1218
+ print("relay: pass --trusted-envelope or the three --trusted-* flags, not both", file=sys.stderr)
1219
+ return 1
1220
+ if expected_recipient == "same-machine":
1221
+ print("relay: --trusted-envelope is a cross-machine channel; drop --expected-recipient same-machine", file=sys.stderr)
1222
+ return 1
1223
+ try:
1224
+ trusted = load_trusted_envelope(root, Path(trusted_envelope))
1225
+ except ValueError as exc:
1226
+ print(f"relay: {exc}", file=sys.stderr)
1227
+ return 1
1228
+ expected_recipient = "cross-machine"
1229
+ trusted_head = trusted["trusted_head"]
1230
+ trusted_manifest_sha256 = trusted["trusted_manifest_sha256"]
1231
+ trusted_repository_url = trusted["repository_url"]
1092
1232
  result = inspect(
1093
1233
  root,
1094
1234
  data,
@@ -1112,18 +1252,16 @@ def main() -> int:
1112
1252
  for message in result["errors"]:
1113
1253
  print(f"ERROR {message}", file=sys.stderr)
1114
1254
  return 1
1115
- human = root / HUMAN
1116
- if human.is_symlink():
1117
- print(f"relay: {HUMAN} must not be a symlink", file=sys.stderr)
1255
+ try:
1256
+ human = write_human(root, data)
1257
+ except ValueError as exc:
1258
+ print(f"relay: {exc}", file=sys.stderr)
1118
1259
  return 1
1119
- human.write_text(render_markdown(data), encoding="utf-8")
1120
1260
  for message in result["warnings"]:
1121
1261
  print(f"WARN {message}", file=sys.stderr)
1122
- print(f"WROTE {root / HUMAN}")
1262
+ print(f"WROTE {human}")
1123
1263
  return 0
1124
1264
  if args.command == "envelope":
1125
- repository = data.get("repository") if isinstance(data.get("repository"), dict) else {}
1126
- remote = repository.get("remote") if isinstance(repository.get("remote"), dict) else {}
1127
1265
  result["errors"].extend(envelope_remote_errors(root, data))
1128
1266
  result["valid"] = not result["errors"]
1129
1267
  if not result["valid"] or data.get("schema") != 3 or result["recipient"] != "cross-machine":
@@ -1131,12 +1269,47 @@ def main() -> int:
1131
1269
  print(f"ERROR {message}", file=sys.stderr)
1132
1270
  print("relay: trusted envelope requires a valid rendered cross-machine schema 3 relay", file=sys.stderr)
1133
1271
  return 1
1134
- print(json.dumps({
1135
- "repository_url": remote.get("url"),
1136
- "remote_ref": remote.get("ref"),
1137
- "trusted_head": repository.get("head"),
1138
- "trusted_manifest_sha256": manifest_sha256(root),
1139
- }, ensure_ascii=False, indent=2))
1272
+ print(json.dumps(envelope_payload(root, data), ensure_ascii=False, indent=2))
1273
+ return 0
1274
+ if args.command == "finalize":
1275
+ # validate, regenerate the human view, and for cross-machine print the
1276
+ # trusted envelope: one command, one error report.
1277
+ cross_machine = data.get("schema") == 3 and result["recipient"] == "cross-machine"
1278
+ envelope_out = Path(args.envelope_out) if args.envelope_out else None
1279
+ if envelope_out is not None:
1280
+ problem = outside_root_error(root, envelope_out, "--envelope-out")
1281
+ if not cross_machine:
1282
+ problem = "--envelope-out needs a cross-machine schema 3 relay"
1283
+ if problem:
1284
+ print(f"relay: {problem}", file=sys.stderr)
1285
+ return 1
1286
+ if not result["valid"]:
1287
+ for message in result["errors"]:
1288
+ print(f"ERROR {message}", file=sys.stderr)
1289
+ return 1
1290
+ try:
1291
+ print(f"WROTE {write_human(root, data)}")
1292
+ except ValueError as exc:
1293
+ print(f"relay: {exc}", file=sys.stderr)
1294
+ return 1
1295
+ for message in result["warnings"]:
1296
+ print(f"WARN {message}", file=sys.stderr)
1297
+ if not cross_machine:
1298
+ return 0
1299
+ remote_errors = envelope_remote_errors(root, data)
1300
+ if remote_errors:
1301
+ for message in remote_errors:
1302
+ print(f"ERROR {message}", file=sys.stderr)
1303
+ print("relay: trusted envelope requires the pushed remote ref to still resolve to HEAD", file=sys.stderr)
1304
+ return 1
1305
+ payload = envelope_payload(root, data)
1306
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1307
+ if envelope_out is not None:
1308
+ try:
1309
+ print(f"WROTE {write_envelope(root, payload, envelope_out)}")
1310
+ except ValueError as exc:
1311
+ print(f"relay: {exc}", file=sys.stderr)
1312
+ return 1
1140
1313
  return 0
1141
1314
  if args.command == "inspect":
1142
1315
  if args.json:
@@ -13,7 +13,7 @@ Create a repository command named by its convention, conceptually `verify-change
13
13
  5. Exit non-zero if any selected check fails and run unattended/offline.
14
14
  6. Print the selected projects and the reason for a full fallback so the scope is auditable.
15
15
 
16
- Set `LUCIAZERO_VERIFY_CMD` to this repo-owned fast command. `/done` still runs `verify-full`; change targeting reduces iteration latency, not closeout coverage.
16
+ Set `LUCIAZERO_VERIFY_CMD` to this repo-owned fast command. `/done` requires a green `verify-full` after the last code edit and reuses that result when it already exists; change targeting reduces iteration latency, not closeout coverage.
17
17
 
18
18
  ## Proof fixtures
19
19