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.
@@ -5,7 +5,8 @@
5
5
  # ("done is proven by a command") at the exact moment it is most violated.
6
6
  #
7
7
  # Subcommands (wired in settings.json):
8
- # prompt — UserPromptSubmit: start privacy-preserving turn telemetry
8
+ # prompt — UserPromptSubmit: start privacy-preserving turn telemetry (a
9
+ # prompt inside an open turn is a background-task notice: kept)
9
10
  # bash-start — PreToolUse on Bash: start shell-command timing
10
11
  # edit — PostToolUse on Edit|Write|NotebookEdit : record "an edit happened"
11
12
  # bash — PostToolUse on Bash: record duration, verify runs, and status
@@ -28,14 +29,17 @@
28
29
  # A blocked stop's continuation is never re-blocked (stop_hook_active),
29
30
  # so this is a speed bump with evidence attached, not a wall.
30
31
  #
31
- # Requires python3 (for JSON parsing). FAILS OPEN: any internal error exits 0,
32
+ # Requires python3 (one run per event: JSON fields, hashes, the settings scan).
33
+ # FAILS OPEN: any internal error exits 0,
32
34
  # so a broken hook can never block real work. Per-project state lives under
33
35
  # $TMPDIR and never touches the repo. One exception, documented honestly: the
34
36
  # stop hook appends one schema-versioned JSON line per stop outcome
35
37
  # (stop-clean / nudge / strict-block) to luciazero-stats.log in the harness
36
38
  # config dir — local only, capped at ~250 lines, fail-open. It records a
37
- # privacy-preserving project hash, verify mode, and aggregate latency/counts;
38
- # never the project path, command, or skill name. Uninstall keeps it.
39
+ # privacy-preserving project hash, verify mode, and aggregate latency/counts —
40
+ # including how long verify commands ran and how many came back green with no
41
+ # edit since the previous green (schema 3) — never the project path, command,
42
+ # or skill name. Uninstall keeps it.
39
43
  set -u
40
44
 
41
45
  MODE="${1:-}"
@@ -63,20 +67,6 @@ hook_path() { # canonical path of $1; empty when its directory does not exist
63
67
  # do not hang waiting for EOF that never comes.
64
68
  if [ -t 0 ]; then IN=""; else IN="$(cat 2>/dev/null || true)"; fi
65
69
 
66
- pyfield() { # pyfield '<python expr over dict d>' — empty string on any error
67
- printf '%s' "${IN}" | python3 -c "
68
- import json, sys
69
- try:
70
- d = json.load(sys.stdin)
71
- v = ${1}
72
- print('' if v is None else v)
73
- except Exception:
74
- print('')" 2>/dev/null || true
75
- }
76
-
77
- CWD="$(pyfield "d.get('cwd')")"
78
- [ -n "${CWD}" ] || CWD="${PWD}"
79
-
80
70
  # A repository's COMMITTED .claude/settings.json can put anything in its `env`
81
71
  # block, and that env reaches this hook — so NO LUCIAZERO_* knob is accepted
82
72
  # from that scope. Each one is a way to disable enforcement while the
@@ -97,10 +87,19 @@ CWD="$(pyfield "d.get('cwd')")"
97
87
  # Refusal only ever falls back to this file's own defaults, never to a block,
98
88
  # and a parse error leaves the configured values untouched. Only the modes that
99
89
  # consume a knob pay for the lookup.
100
- # The scanner program lives in a variable, not a here-document inside
101
- # $( ): bash 3.2 (still the /bin/bash on macOS) cannot parse that
102
- # combination and fails the whole file at load time.
103
- REFUSED_SCAN_PY='import json, os, stat, sys
90
+ #
91
+ # The scan runs inside the ONE python3 program this hook starts per event.
92
+ # Every field derived from stdin — cwd, the state key, session and tool keys,
93
+ # the command and its digest, the tool status, the relay age — comes out of
94
+ # this program as one value per line in a fixed order, the (possibly
95
+ # multi-line) Bash command last. A python3 start costs ~40 ms; one field per
96
+ # process spent 5–9 of them on every tool call. Any error leaves a field
97
+ # empty, and an empty state key fails the hook open below.
98
+ # The program lives in a variable, not a here-document inside $( ): bash 3.2
99
+ # (still the /bin/bash on macOS) cannot parse that combination and fails the
100
+ # whole file at load time.
101
+ PRELUDE_PY='import hashlib, json, os, stat, sys, time
102
+ ppid, mode = sys.argv[1], sys.argv[2]
104
103
  LIMIT = 1000000 # a settings file is kilobytes; this runs on every tool call
105
104
  MAX_DEPTH = 40 # ancestor walk is bounded, never unbounded I/O
106
105
 
@@ -135,63 +134,139 @@ def real(path):
135
134
  except OSError:
136
135
  return path
137
136
 
138
- home = real(os.path.expanduser("~"))
139
- # Only the DEFAULT config directory counts as user scope. CLAUDE_CONFIG_DIR is
140
- # attacker-reachable: pointed at the project itself, it would mark the
141
- # repository settings file as user scope and skip the very file that declares
142
- # it, and the dedupe below would then trust a classic install inside the repo.
143
- user_config = real(os.path.join(home, ".claude"))
144
- project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
145
- project_dir = real(project_dir) if project_dir else None
137
+ def refused_keys(start):
138
+ home = real(os.path.expanduser("~"))
139
+ # Only the DEFAULT config directory counts as user scope. CLAUDE_CONFIG_DIR
140
+ # is attacker-reachable: pointed at the project itself, it would mark the
141
+ # repository settings file as user scope and skip the very file that
142
+ # declares it, and the dedupe below would then trust a classic install
143
+ # inside the repo.
144
+ user_config = real(os.path.join(home, ".claude"))
145
+ project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
146
+ project_dir = real(project_dir) if project_dir else None
147
+ found, seen = [], set()
148
+ directory = real(start or ".")
149
+ for _ in range(MAX_DEPTH):
150
+ claude_dir = os.path.join(directory, ".claude")
151
+ if directory != home and real(claude_dir) != user_config:
152
+ for key in keys_in(os.path.join(claude_dir, "settings.json")):
153
+ if key not in seen:
154
+ seen.add(key)
155
+ found.append(key)
156
+ if directory == home:
157
+ break
158
+ if os.path.exists(os.path.join(directory, ".git")):
159
+ break # repository root: project scope ends here
160
+ if project_dir is not None and directory == project_dir:
161
+ break
162
+ parent = os.path.dirname(directory)
163
+ if parent == directory:
164
+ break
165
+ directory = parent
166
+ return found
167
+
168
+ json_ok = "no"
169
+ try:
170
+ d = json.load(sys.stdin)
171
+ json_ok = "yes"
172
+ except Exception:
173
+ d = None
174
+
175
+ def field(*path):
176
+ # the value at d[path...] as text; empty on any missing or mis-shaped level
177
+ v = d
178
+ try:
179
+ for p in path:
180
+ v = v.get(p)
181
+ return "" if v is None else str(v).rstrip("\n")
182
+ except Exception:
183
+ return ""
184
+
185
+ def digest(v, size):
186
+ return hashlib.sha256(v.encode("utf-8", "replace")).hexdigest()[:size]
146
187
 
147
- found, seen = [], set()
148
- directory = real(sys.argv[1] or ".")
149
- for _ in range(MAX_DEPTH):
150
- claude_dir = os.path.join(directory, ".claude")
151
- if directory != home and real(claude_dir) != user_config:
152
- for key in keys_in(os.path.join(claude_dir, "settings.json")):
153
- if key not in seen:
154
- seen.add(key)
155
- found.append(key)
156
- if directory == home:
157
- break
158
- if os.path.exists(os.path.join(directory, ".git")):
159
- break # repository root: project scope ends here
160
- if project_dir is not None and directory == project_dir:
161
- break
162
- parent = os.path.dirname(directory)
163
- if parent == directory:
164
- break
165
- directory = parent
166
- print("\n".join(found))
188
+ cwd = field("cwd") or os.environ.get("PWD") or os.getcwd()
189
+ # md5 here names a state directory; it is never a security decision. Saying so
190
+ # explicitly keeps the hook alive on a FIPS-enforcing python3, where a bare
191
+ # md5() call raises and the tracker would fail open (silently doing nothing).
192
+ key = hashlib.md5(cwd.encode("utf-8", "replace"), usedforsecurity=False).hexdigest()[:12]
193
+ session = field("session_id") or "parent-" + ppid
194
+ # stable opaque tool key; raw tool input never leaves temporary state
195
+ raw = (field("tool_use_id") or field("tool_input", "command") or field("tool_input", "skill")
196
+ or field("command_name") or field("prompt") or field("command") or "unknown")
197
+ cmd = field("tool_input", "command")
198
+ # best-effort red/green from the tool response. The Bash response of Claude
199
+ # Code carries no exit code (stdout, stderr, interrupted, isImage): PostToolUse
200
+ # only fires for a command that finished with exit 0, a non-zero exit reaches
201
+ # PostToolUseFailure (mode bash-failure) instead, so a completed, uninterrupted
202
+ # response in mode bash is a green. An explicit exit code still wins.
203
+ try:
204
+ r = d.get("tool_response") or {}
205
+ c = r.get("exit_code", r.get("exitCode"))
206
+ if isinstance(c, int) and not isinstance(c, bool):
207
+ status = "ok" if c == 0 else "fail"
208
+ elif r.get("is_error") is True:
209
+ status = "fail"
210
+ elif r.get("interrupted") is True:
211
+ status = "ran"
212
+ else:
213
+ status = "ok" if mode == "bash" else "ran"
214
+ except Exception:
215
+ status = ""
216
+ refused = refused_keys(cwd) if mode in ("edit", "bash", "bash-failure", "stop", "session") else []
217
+ age = ""
218
+ if mode == "session":
219
+ try:
220
+ age = str(int((time.time() - os.path.getmtime(os.path.join(cwd, "LUCIA_RELAY.json"))) // 86400))
221
+ except OSError:
222
+ pass
223
+ tool_input = d.get("tool_input")
224
+ fp_state = ("missing" if not isinstance(tool_input, dict) or "file_path" not in tool_input
225
+ else "empty" if tool_input["file_path"] in ("", None) else "present")
226
+ lines = [cwd, key, digest(session, 16), digest(raw, 16), str(int(time.time() * 1000)),
227
+ " ".join(refused), field("tool_input", "file_path"), field("expansion_type"),
228
+ field("stop_hook_active"), status, json_ok, digest(cmd, 64) if cmd else "", age,
229
+ field("source"), field("tool_name"), fp_state]
230
+ sys.stdout.write("\n".join(v.replace("\n", " ") for v in lines) + "\n" + cmd)
167
231
  '
168
- REFUSED_ENV_KEYS=""
169
- case "${MODE}" in
170
- edit|bash|bash-failure|stop|session)
171
- REFUSED_ENV_KEYS="$(printf '%s' "${REFUSED_SCAN_PY}" \
172
- | python3 - "${CWD}" 2>/dev/null || true)"
173
- ;;
174
- esac
232
+ PRE="$(printf '%s' "${IN}" | python3 -c "${PRELUDE_PY}" "${PPID}" "${MODE}" 2>/dev/null || true)"
233
+ {
234
+ IFS= read -r CWD
235
+ IFS= read -r KEY
236
+ IFS= read -r SESSION_KEY
237
+ IFS= read -r TK
238
+ IFS= read -r NOW_MS
239
+ IFS= read -r REFUSED_ENV_KEYS
240
+ IFS= read -r FP
241
+ IFS= read -r EXPANSION_TYPE
242
+ IFS= read -r ACTIVE
243
+ IFS= read -r STATUS
244
+ IFS= read -r JSON_OK
245
+ IFS= read -r CMD_HASH
246
+ IFS= read -r RELAY_AGE
247
+ IFS= read -r SESSION_SOURCE
248
+ IFS= read -r TOOL_NAME
249
+ IFS= read -r FP_STATE
250
+ CMD="$(cat)"
251
+ } <<EOF
252
+ ${PRE}
253
+ EOF
254
+ [ -n "${CWD}" ] || CWD="${PWD}"
175
255
  if [ -n "${REFUSED_ENV_KEYS}" ]; then
176
256
  # `LUCIAZERO_*` is the oversized-file marker: drop every knob this hook reads
177
257
  case "${REFUSED_ENV_KEYS}" in
178
258
  *'LUCIAZERO_*'*)
179
- REFUSED_ENV_KEYS='LUCIAZERO_VERIFY_CMD
180
- LUCIAZERO_VERIFY_REGEX
181
- LUCIAZERO_DOC_REGEX
182
- LUCIAZERO_STRICT_VERIFY_CMD
183
- LUCIAZERO_STRICT_TIMEOUT
184
- LUCIAZERO_RELAY_STALE_DAYS
185
- LUCIAZERO_HANDOFF_STALE_DAYS
186
- CLAUDE_CONFIG_DIR' ;;
259
+ REFUSED_ENV_KEYS='LUCIAZERO_VERIFY_CMD LUCIAZERO_VERIFY_REGEX LUCIAZERO_DOC_REGEX LUCIAZERO_STRICT_VERIFY_CMD LUCIAZERO_STRICT_TIMEOUT LUCIAZERO_RELAY_STALE_DAYS LUCIAZERO_HANDOFF_STALE_DAYS LUCIAZERO_EDIT_DIAG CLAUDE_CONFIG_DIR' ;;
187
260
  esac
188
- while IFS= read -r RK; do
261
+ # names are [A-Z_]* only, so splitting the line on spaces is exact; globbing
262
+ # is off for the loop so a key that carries a wildcard never names a file
263
+ set -f
264
+ for RK in ${REFUSED_ENV_KEYS}; do
189
265
  case "${RK}" in
190
266
  LUCIAZERO_[A-Z_]*|CLAUDE_CONFIG_DIR) unset "${RK}" 2>/dev/null || true ;;
191
267
  esac
192
- done <<EOF
193
- ${REFUSED_ENV_KEYS}
194
- EOF
268
+ done
269
+ set +f
195
270
  fi
196
271
 
197
272
  # Channel dedupe: when `install.sh --with-hooks` wiring is ALSO present, the
@@ -212,10 +287,7 @@ if [ -n "${CLASSIC_HOOK}" ] && [ "${SELF_HOOK}" != "${CLASSIC_HOOK}" ] \
212
287
  exit 0
213
288
  fi
214
289
 
215
- # md5 here names a state directory; it is never a security decision. Saying so
216
- # explicitly keeps the hook alive on a FIPS-enforcing python3, where a bare
217
- # md5() call raises and the tracker would fail open (silently doing nothing).
218
- KEY="$(printf '%s' "${CWD}" | python3 -c 'import sys,hashlib;print(hashlib.md5(sys.stdin.buffer.read(), usedforsecurity=False).hexdigest()[:12])' 2>/dev/null)" || exit 0
290
+ # The state key is the prelude's md5 of cwd; empty means python3 failed.
219
291
  [ -n "${KEY}" ] || exit 0
220
292
  BASE="${TMPDIR:-/tmp}/luciazero-verify-state-$(id -u 2>/dev/null || echo unknown)"
221
293
  # The base name is predictable, so validate ownership/type before touching it.
@@ -229,18 +301,18 @@ chmod 700 "${BASE}" 2>/dev/null || exit 0
229
301
  STATE="${BASE}/${KEY}"
230
302
  mkdir -p "${STATE}" 2>/dev/null || exit 0
231
303
  chmod 700 "${STATE}" 2>/dev/null || exit 0
232
- SESSION_RAW="$(pyfield "d.get('session_id')")"
233
- [ -n "${SESSION_RAW}" ] || SESSION_RAW="parent-${PPID}"
234
- SESSION_KEY="$(printf '%s' "${SESSION_RAW}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:16])' 2>/dev/null)" || exit 0
235
304
  TELEMETRY="${STATE}/telemetry/${SESSION_KEY}"
236
305
 
237
- tool_key() { # stable opaque key; raw tool input never leaves temporary state
238
- RAW="$(pyfield "d.get('tool_use_id') or d.get('tool_input', {}).get('command') or d.get('tool_input', {}).get('skill') or d.get('command_name') or d.get('prompt') or d.get('command')")"
239
- [ -n "${RAW}" ] || RAW=unknown
240
- printf '%s' "${RAW}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:16])' 2>/dev/null
241
- }
306
+ # A turn is open from its first prompt until a stop lets it end. The harness
307
+ # delivers a background task's completion (a forked skill, a subagent, a
308
+ # run_in_background command) as one more UserPromptSubmit, and before this
309
+ # marker every one of those wiped the turn's counters: a turn that waited on
310
+ # a background job reported a few seconds and one Bash call. A stop that
311
+ # blocks (nudge, strict red) keeps the turn open, because the model continues
312
+ # it; every stop that lets the turn end closes it. Fail-open, like all state.
313
+ end_turn() { rm -f "${TELEMETRY}/turn_open" 2>/dev/null || true; }
242
314
 
243
- now_ms() {
315
+ now_ms() { # strict gate only; every other mode uses the prelude's NOW_MS
244
316
  python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null
245
317
  }
246
318
 
@@ -270,7 +342,7 @@ path, cwd, event, mode, telemetry_dir = sys.argv[1:]
270
342
  os.makedirs(os.path.dirname(path), exist_ok=True)
271
343
  real = os.path.realpath(cwd)
272
344
  row = {
273
- "schema": 2,
345
+ "schema": 3,
274
346
  "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
275
347
  "event": event,
276
348
  "project_id": hashlib.sha256(real.encode()).hexdigest()[:12],
@@ -289,10 +361,19 @@ def count_files(name):
289
361
  for item in os.listdir(os.path.join(telemetry_dir, name)))
290
362
  except OSError:
291
363
  return 0
364
+ def merged_ms(intervals):
365
+ merged = []
366
+ for a, b in sorted((a, b) for a, b in intervals if a <= b):
367
+ if not merged or a > merged[-1][1]:
368
+ merged.append([a, b])
369
+ else:
370
+ merged[-1][1] = max(merged[-1][1], b)
371
+ return sum(b - a for a, b in merged)
292
372
  start = read_int(os.path.join(telemetry_dir, "turn_start_ms"))
293
373
  if start is not None:
294
374
  now = int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000)
295
- intervals = []
375
+ intervals, verify_intervals = [], []
376
+ verify_dir = os.path.join(telemetry_dir, "verify_count")
296
377
  try:
297
378
  interval_dir = os.path.join(telemetry_dir, "bash_intervals")
298
379
  for item in os.listdir(interval_dir):
@@ -302,21 +383,20 @@ if start is not None:
302
383
  continue
303
384
  if 0 <= a <= b:
304
385
  intervals.append((max(start, a), min(now, b)))
386
+ # the interval's tool key is the verify marker's name: same
387
+ # opaque digest, so no command is read to tell the two apart
388
+ if os.path.isfile(os.path.join(verify_dir, item)):
389
+ verify_intervals.append(intervals[-1])
305
390
  except OSError:
306
391
  pass
307
- merged = []
308
- for a, b in sorted((a, b) for a, b in intervals if a <= b):
309
- if not merged or a > merged[-1][1]:
310
- merged.append([a, b])
311
- else:
312
- merged[-1][1] = max(merged[-1][1], b)
313
- bash_ms = sum(b - a for a, b in merged)
314
392
  row["telemetry"] = {
315
393
  "turn_ms": max(0, now - start),
316
- "bash_ms": bash_ms,
394
+ "bash_ms": merged_ms(intervals),
317
395
  "bash_count": count_files("bash_count"),
318
396
  "verify_count": count_files("verify_count"),
319
397
  "skill_count": count_files("skill_count"),
398
+ "verify_ms": merged_ms(verify_intervals),
399
+ "redundant_green_count": count_files("redundant_green"),
320
400
  }
321
401
  with open(path, "a", encoding="utf-8") as handle:
322
402
  handle.write(json.dumps(row, separators=(",", ":")) + "\n")
@@ -335,47 +415,69 @@ PY
335
415
  # When LUCIAZERO_VERIFY_CMD is set (the repo's exact verify command, e.g.
336
416
  # "./test.sh"), only commands that ARE it or START with it count — the broad
337
417
  # regex also marks `cat test.sh` or `grep pytest README` as a verify run,
338
- # flipping the state green without any test having run.
339
- VERIFY_RE="${LUCIAZERO_VERIFY_REGEX:-verify|test\.sh|pytest|npm (run )?test|pnpm test|yarn test|cargo test|go test|vitest|jest|make (test|check)|tox|rake test|mix test|dotnet test|gradlew? (test|check)}"
418
+ # flipping the state green without any test having run. The default also
419
+ # knows `python -m unittest` and this repository's timing collector,
420
+ # scripts/test-timings.sh, which runs a tier -- except with --report, which
421
+ # only reads the samples kept so far; that case is carved out of the default
422
+ # below and is no concern of a regex somebody set themselves.
423
+ VERIFY_RE="${LUCIAZERO_VERIFY_REGEX:-verify|test\.sh|python[0-9.]* -m unittest|test-timings\.sh|pytest|npm (run )?test|pnpm test|yarn test|cargo test|go test|vitest|jest|make (test|check)|tox|rake test|mix test|dotnet test|gradlew? (test|check)}"
340
424
  VERIFY_CMD="${LUCIAZERO_VERIFY_CMD:-}"
341
425
 
342
426
  case "${MODE}" in
343
427
  prompt)
344
428
  # Per-turn scratch data is ephemeral. Persistent rows keep aggregates only.
429
+ # Inside an open turn this prompt is a notification, not a new turn: the
430
+ # counters and turn_start_ms stay exactly as they are.
431
+ [ -f "${TELEMETRY}/turn_open" ] && exit 0
345
432
  rm -rf "${TELEMETRY}" 2>/dev/null || exit 0
346
433
  mkdir -p "${TELEMETRY}" 2>/dev/null || exit 0
347
- now_ms > "${TELEMETRY}/turn_start_ms" 2>/dev/null || true
434
+ printf '%s\n' "${NOW_MS}" > "${TELEMETRY}/turn_start_ms" 2>/dev/null || true
435
+ : > "${TELEMETRY}/turn_open" 2>/dev/null || true
348
436
  ;;
349
437
  bash-start)
350
- TK="$(tool_key)" || exit 0
351
438
  mkdir -p "${TELEMETRY}/bash_start_ms" "${TELEMETRY}/bash_count" 2>/dev/null || exit 0
352
- now_ms > "${TELEMETRY}/bash_start_ms/${TK}" 2>/dev/null || true
439
+ printf '%s\n' "${NOW_MS}" > "${TELEMETRY}/bash_start_ms/${TK}" 2>/dev/null || true
353
440
  : > "${TELEMETRY}/bash_count/${TK}" 2>/dev/null || true
354
441
  ;;
355
442
  edit)
356
443
  # Documentation writes do not re-arm the nudge: the closeout skills
357
444
  # Closeout skills write docs AFTER the final green verify. Relay's JSON is
358
445
  # also a transient knowledge artifact, not implementation code.
359
- FP="$(pyfield "d.get('tool_input', {}).get('file_path')")"
360
446
  DOC_RE="${LUCIAZERO_DOC_REGEX:-\.(md|markdown|rst|txt)\$}"
447
+ COUNTED=yes
361
448
  case "${FP}" in
362
- */LUCIA_RELAY.json|*/LUCIA_RELAY.md) : ;;
449
+ */LUCIA_RELAY.json|*/LUCIA_RELAY.md) COUNTED=no ;;
363
450
  *)
364
451
  if [ -n "${FP}" ] && printf '%s' "${FP}" | grep -qE "${DOC_RE}"; then
365
- : # doc-only write; verify state unchanged
452
+ COUNTED=no # doc-only write; verify state unchanged
366
453
  else
367
454
  touch "${STATE}/last_edit"
368
455
  rm -f "${STATE}/nudged" # new code edits re-arm the one-shot nudge
369
456
  fi
370
457
  ;;
371
458
  esac
459
+ # Opt-in diagnostic (LUCIAZERO_EDIT_DIAG=1): one line per edit event in
460
+ # the state directory, next to last_edit, saying what the event carried
461
+ # and what the hook made of it -- the tool's name, the opaque tool key,
462
+ # whether file_path was missing, empty or present, its suffix, whether it
463
+ # lay under cwd, and whether the edit counted. Never the path, never the
464
+ # content. For finding out what touched last_edit when no visible edit did.
465
+ if [ "${LUCIAZERO_EDIT_DIAG:-}" = 1 ]; then
466
+ IN_CWD="-"; EXT="-"
467
+ if [ -n "${FP}" ]; then
468
+ case "${FP}" in "${CWD}"/*) IN_CWD=yes ;; *) IN_CWD=no ;; esac
469
+ case "${FP##*/}" in *.*) EXT="${FP##*.}" ;; esac
470
+ fi
471
+ printf 'ts=%s mode=%s tool=%s key=%s file_path=%s ext=%s in_cwd=%s counted=%s\n' \
472
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${MODE}" "${TOOL_NAME:--}" "${TK}" "${FP_STATE:-unknown}" \
473
+ "${EXT}" "${IN_CWD}" "${COUNTED}" >> "${STATE}/edit-diag.log" 2>/dev/null || true
474
+ fi
372
475
  ;;
373
476
  bash|bash-failure)
374
- TK="$(tool_key)" || TK=unknown
375
477
  mkdir -p "${TELEMETRY}/bash_count" "${TELEMETRY}/bash_intervals" 2>/dev/null || true
376
478
  : > "${TELEMETRY}/bash_count/${TK}" 2>/dev/null || true
377
479
  START_MS="$(cat "${TELEMETRY}/bash_start_ms/${TK}" 2>/dev/null || true)"
378
- END_MS="$(now_ms || true)"
480
+ END_MS="${NOW_MS}"
379
481
  case "${START_MS}:${END_MS}" in
380
482
  *[!0-9:]*|:|*:|*::* ) : ;;
381
483
  *)
@@ -384,7 +486,6 @@ case "${MODE}" in
384
486
  fi
385
487
  ;;
386
488
  esac
387
- CMD="$(pyfield "d.get('tool_input', {}).get('command')")"
388
489
  IS_VERIFY=no
389
490
  if [ -n "${CMD}" ]; then
390
491
  if [ -n "${VERIFY_CMD}" ]; then
@@ -394,38 +495,55 @@ case "${MODE}" in
394
495
  esac
395
496
  elif printf '%s' "${CMD}" | grep -qE "${VERIFY_RE}"; then
396
497
  IS_VERIFY=yes
498
+ if [ -z "${LUCIAZERO_VERIFY_REGEX:-}" ] && printf '%s' "${CMD}" | grep -qE 'test-timings\.sh +--report'; then
499
+ IS_VERIFY=no # the collector's report runs nothing
500
+ fi
397
501
  fi
398
502
  fi
399
503
  if [ "${IS_VERIFY}" = yes ]; then
400
504
  mkdir -p "${TELEMETRY}/verify_count" 2>/dev/null || true
401
505
  : > "${TELEMETRY}/verify_count/${TK}" 2>/dev/null || true
402
- # Best-effort red/green from the tool response; failure hooks are red.
403
- if [ "${MODE}" = bash-failure ]; then
404
- STATUS=fail
405
- else
406
- STATUS="$(pyfield "(lambda r, c=None: (lambda c: 'ok' if c == 0 else ('fail' if isinstance(c, int) else ('fail' if r.get('is_error') is True else 'ran')))(r.get('exit_code', r.get('exitCode'))))(d.get('tool_response') or {})")"
506
+ # Red/green came from the tool response in the prelude; failure hooks are red.
507
+ [ "${MODE}" = bash-failure ] && STATUS=fail
508
+ # A green that follows a green with no code edit between them proved
509
+ # nothing new: count it (schema 3 `redundant_green_count`) before the
510
+ # state below overwrites the previous result. Float mtimes for the
511
+ # same sub-second reason as the stop nudge; one python3, verify runs only.
512
+ if [ "${STATUS}" = ok ] && [ "$(python3 -c '
513
+ import os, sys
514
+ state = sys.argv[1]
515
+ def m(name):
516
+ try:
517
+ return os.path.getmtime(os.path.join(state, name))
518
+ except OSError:
519
+ return None
520
+ try:
521
+ last = open(os.path.join(state, "last_verify")).read().strip()
522
+ except OSError:
523
+ last = ""
524
+ e, v = m("last_edit"), m("last_verify")
525
+ print("yes" if last == "ok" and v is not None and (e is None or e <= v) else "no")' "${STATE}" 2>/dev/null || echo no)" = yes ]; then
526
+ mkdir -p "${TELEMETRY}/redundant_green" 2>/dev/null || true
527
+ : > "${TELEMETRY}/redundant_green/${TK}" 2>/dev/null || true
407
528
  fi
408
529
  printf '%s\n' "${STATUS:-ran}" > "${STATE}/last_verify"
409
530
  # Keep only an opaque digest for strict-gate equality; raw commands may
410
531
  # contain paths or secrets and must never persist in shared state.
411
- printf '%s' "${CMD}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' \
412
- > "${STATE}/last_verify_cmd_hash" 2>/dev/null || true
532
+ printf '%s\n' "${CMD_HASH}" > "${STATE}/last_verify_cmd_hash" 2>/dev/null || true
413
533
  rm -f "${STATE}/nudged"
414
534
  fi
415
535
  ;;
416
536
  skill|skill-prompt)
417
537
  if [ "${MODE}" = skill-prompt ]; then
418
- EXPANSION_TYPE="$(pyfield "d.get('expansion_type')")"
419
538
  [ "${EXPANSION_TYPE}" = slash_command ] || exit 0
420
539
  fi
421
- TK="$(tool_key)" || TK=unknown
422
540
  mkdir -p "${TELEMETRY}/skill_count" 2>/dev/null || true
423
541
  : > "${TELEMETRY}/skill_count/${TK}" 2>/dev/null || true
424
542
  ;;
425
543
  stop)
426
- # Never re-block a continuation that a stop hook itself caused
427
- ACTIVE="$(pyfield "d.get('stop_hook_active')")"
428
- if [ "${ACTIVE}" = "True" ] || [ "${ACTIVE}" = "true" ]; then exit 0; fi
544
+ # Never re-block a continuation that a stop hook itself caused; that
545
+ # continuation is the turn ending, so the turn closes here.
546
+ if [ "${ACTIVE}" = "True" ] || [ "${ACTIVE}" = "true" ]; then end_turn; exit 0; fi
429
547
  # Strict gate (opt-in, see header): actually run the user's verify command
430
548
  # unless the tracked state is already green-after-last-edit. Any internal
431
549
  # error — timeout, missing command, unparseable state — degrades to the
@@ -434,7 +552,6 @@ case "${MODE}" in
434
552
  # Strict gate only on well-formed input: unparseable stdin means we know
435
553
  # neither cwd nor stop_hook_active — running a command on guesses would
436
554
  # break both the fail-open and the never-re-block guarantees.
437
- JSON_OK="$(printf '%s' "${IN}" | python3 -c 'import json,sys; json.load(sys.stdin); print("yes")' 2>/dev/null || echo no)"
438
555
  if [ -n "${STRICT_CMD}" ] && [ "${JSON_OK}" = yes ]; then
439
556
  STRICT_START_MS="$(now_ms || true)"
440
557
  OUT="$(python3 -c '
@@ -476,7 +593,7 @@ else:
476
593
  print("\n".join(tail))
477
594
  ' "${STATE}" "${CWD}" "${STRICT_CMD}" "${LUCIAZERO_STRICT_TIMEOUT:-120}" 2>/dev/null || echo error)"
478
595
  case "${OUT%%$'\n'*}" in
479
- green) stat_log stop-clean; exit 0 ;;
596
+ green) stat_log stop-clean; end_turn; exit 0 ;;
480
597
  ok)
481
598
  record_strict_telemetry "${STRICT_START_MS}"
482
599
  printf 'ok\n' > "${STATE}/last_verify" 2>/dev/null || true
@@ -484,6 +601,7 @@ else:
484
601
  > "${STATE}/last_verify_cmd_hash" 2>/dev/null || true
485
602
  rm -f "${STATE}/nudged"
486
603
  stat_log stop-clean
604
+ end_turn
487
605
  exit 0 ;;
488
606
  red)
489
607
  record_strict_telemetry "${STRICT_START_MS}"
@@ -517,15 +635,21 @@ print("yes" if e is not None and (v is None or e > v) else "no")' "${STATE}" 2>/
517
635
  exit 2
518
636
  fi
519
637
  # NUDGE=no -> genuinely clean stop; yes-but-already-nudged logs nothing
520
- # (that nudge was counted when it fired)
638
+ # (that nudge was counted when it fired). Either way the turn ends here.
521
639
  [ "${NUDGE}" = no ] && stat_log stop-clean
640
+ end_turn
522
641
  ;;
523
642
  session)
643
+ # A marker left behind by a session that never reached its stop (crash,
644
+ # kill, resume) would make the first real prompt look like a notification
645
+ # and keep stale counters. Compaction is the one start that happens inside
646
+ # a live session, possibly mid-turn, so it leaves the marker alone.
647
+ [ "${SESSION_SOURCE}" = compact ] || end_turn
524
648
  # A committed settings env block that reconfigures this hook is worth one
525
649
  # loud line: the refusal above is silent, and a repository that ships these
526
650
  # keys is either mistaken or hostile. Names the keys, never their values.
527
651
  if [ -n "${REFUSED_ENV_KEYS}" ]; then
528
- echo "This repository's committed .claude/settings.json sets $(printf '%s' "${REFUSED_ENV_KEYS}" | tr '\n' ' ')— Luciazero refuses those keys from project scope (they can disable verify tracking or run a command at every stop). Review that env block before trusting this repo."
652
+ echo "This repository's committed .claude/settings.json sets ${REFUSED_ENV_KEYS} — Luciazero refuses those keys from project scope (they can disable verify tracking or run a command at every stop). Review that env block before trusting this repo."
529
653
  fi
530
654
  # SessionStart emits ONE pointer, never the relay contents. A legacy
531
655
  # HANDOFF.md gets a migration warning but is not silently rewritten.
@@ -536,7 +660,7 @@ print("yes" if e is not None and (v is None or e > v) else "no")' "${STATE}" 2>/
536
660
  fi
537
661
  exit 0
538
662
  fi
539
- AGE="$(python3 -c 'import os,sys,time;print(int((time.time()-os.path.getmtime(sys.argv[1]))//86400))' "${CAP}" 2>/dev/null || echo '')"
663
+ AGE="${RELAY_AGE}"
540
664
  STALE="${LUCIAZERO_RELAY_STALE_DAYS:-${LUCIAZERO_HANDOFF_STALE_DAYS:-7}}"
541
665
  if [ -n "${AGE}" ] && [ "${AGE}" -ge "${STALE}" ] 2>/dev/null; then
542
666
  echo "LUCIA_RELAY.json exists but is ${AGE} days old — likely stale. Run /lucia-relay inspect, verify its claims with extra suspicion, then consume or replace it."
package/install.sh CHANGED
@@ -78,6 +78,18 @@ version_of() {
78
78
  awk -F '"' '/^[[:space:]]*"version"[[:space:]]*:/ { print $4; exit }' \
79
79
  "${SRC}/package.json" 2>/dev/null || true
80
80
  }
81
+ # A plugin install of Luciazero beside this classic one loads every skill and
82
+ # the reviewer agent twice in each session, as `/x` and `/luciazero:x` (the
83
+ # hook and the doctrine dedupe themselves; skills and agents cannot). Only the
84
+ # harness's own registry is consulted, read-only; when it is absent or says
85
+ # nothing, nothing is printed.
86
+ plugin_double_install_note() {
87
+ REGISTRY="${CLAUDE_DIR}/plugins/installed_plugins.json"
88
+ [ -f "${REGISTRY}" ] && grep -q '"luciazero@' "${REGISTRY}" 2>/dev/null || return 0
89
+ echo " !! Luciazero is also installed as a Claude Code plugin: every skill and the"
90
+ echo " reviewer agent load twice per session. Keep one channel — /plugin uninstall"
91
+ echo " luciazero@luciazero for the plugin, or ./uninstall.sh for this copy."
92
+ }
81
93
 
82
94
  if [ "${STATUS_ONLY}" = 1 ]; then
83
95
  echo "Status of ${CLAUDE_DIR} (read-only)"
@@ -132,6 +144,7 @@ if [ "${STATUS_ONLY}" = 1 ]; then
132
144
  else
133
145
  echo " MISS CLAUDE.md import line (${IMPORT_LINE} exactly once; found ${N:-0})"; STATUS_RC=1
134
146
  fi
147
+ plugin_double_install_note
135
148
  V_SRC="$(version_of)"
136
149
  V_INST="$(cat "${CLAUDE_DIR}/.luciazero-version" 2>/dev/null || true)"
137
150
  if [ -z "${V_INST}" ]; then
@@ -631,4 +644,5 @@ if [ "${WITH_HOOKS}" = 1 ]; then
631
644
  else
632
645
  echo "Optional: ./install.sh --with-hooks adds the verify-nudge hooks + statusline."
633
646
  fi
647
+ plugin_double_install_note
634
648
  echo "The doctrine applies from the next Claude Code session."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "luciazero",
3
- "version": "2.5.0",
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",