luciazero 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -17
- package/agents/reviewer.md +4 -2
- package/bin/discipline-report.js +31 -2
- package/bin/global.js +191 -0
- package/bin/luciazero.js +10 -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
|
@@ -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",
|