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 +46 -10
- package/agents/reviewer.md +4 -2
- package/bin/discipline-report.js +31 -2
- package/bin/luciazero.js +6 -2
- package/claude/agents/reviewer.md +4 -2
- package/claude/hooks/luciazero-verify.sh +244 -120
- package/install.sh +14 -0
- package/package.json +1 -1
- package/skills/done/SKILL.md +12 -11
- package/skills/lucia-relay/SKILL.md +20 -18
- package/skills/lucia-relay/scripts/relay.py +195 -22
- package/skills/ready/references/smart-verification.md +1 -1
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
|
|
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
|
|
312
|
-
|
|
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
|
|
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 --
|
|
397
|
-
./test.sh
|
|
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
|
|
401
|
-
|
|
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
|
|
package/agents/reviewer.md
CHANGED
|
@@ -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.
|
|
40
|
-
|
|
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
|
package/bin/discipline-report.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
40
|
-
|
|
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 (
|
|
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
|
-
#
|
|
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
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
directory
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
193
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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":
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="$
|
|
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
|
-
#
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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' "${
|
|
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
|
-
|
|
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 $
|
|
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="$
|
|
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.
|
|
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",
|
package/skills/done/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
11
|
-
the
|
|
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
|
-
|
|
28
|
-
`<this-skill-dir>/scripts/revert-probe.sh "<verify-cmd>"
|
|
29
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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),
|
|
47
|
-
command merely because the relay
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
--trusted-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
--
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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 {
|
|
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
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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`
|
|
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
|
|