luciazero 2.4.3 → 2.5.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/install.sh CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  # Install the Luciazero doctrine + skills into ~/.claude/
3
3
  # Idempotent. Backs up CLAUDE.md (and settings.json when --with-hooks)
4
- # before editing. Writes nothing outside ~/.claude/.
4
+ # before editing. Writes nothing outside ~/.claude/ unless LUCIAZERO_BIN_DIR
5
+ # names another directory for the Agent Bus launcher.
5
6
  #
6
7
  # ./install.sh doctrine + skills + reviewer agent
7
8
  # ./install.sh --with-hooks also wire the enforcement pack: verify-tracking
@@ -9,6 +10,10 @@
9
10
  # (Claude Code only; requires python3)
10
11
  # ./install.sh --status read-only health check of an existing install;
11
12
  # exits non-zero if a core piece is missing
13
+ #
14
+ # LUCIAZERO_BIN_DIR=<dir> where `luciazero-agentd` goes (default
15
+ # ~/.claude/bin; a checkout only -- the daemon
16
+ # is not in the npm payload)
12
17
  set -euo pipefail
13
18
 
14
19
  WITH_HOOKS=0
@@ -27,6 +32,40 @@ DOCTRINE="luciazero.md"
27
32
  IMPORT_LINE="@${DOCTRINE}"
28
33
  MANAGED_DIR="${CLAUDE_DIR}/.luciazero-managed"
29
34
  BACKUP_DIR="${CLAUDE_DIR}/.luciazero-backups"
35
+ # Agent Bus launcher (checkouts only -- ADR 0002 keeps the daemon out of the
36
+ # npm payload). Default target stays inside CLAUDE_DIR so this script keeps
37
+ # its promise to write nowhere else; LUCIAZERO_BIN_DIR points it at a PATH
38
+ # directory such as ~/.local/bin when the user wants one.
39
+ AGENTD_MARKER="luciazero-managed: agentd-launcher"
40
+ AGENTD_BIN_DIR="${LUCIAZERO_BIN_DIR:-${CLAUDE_DIR}/bin}"
41
+ AGENTD_LAUNCHER="${AGENTD_BIN_DIR}/luciazero-agentd"
42
+ # One script, installed twice. `lucia claude` is the whole of the ordinary
43
+ # path and the long name is what the subcommands were documented under, so
44
+ # both are installed and both work; a copy rather than a symlink so that the
45
+ # ownership marker is in the file itself and every check below reads it the
46
+ # same way for either name.
47
+ AGENTD_NAMES="luciazero-agentd lucia"
48
+ AGENTD_HOME_FILE="${CLAUDE_DIR}/.luciazero-agentd-home"
49
+
50
+ # Ours, someone else's, or absent. A launcher we did not write is never
51
+ # replaced: it is on PATH under a name we chose, and it may be a symlink the
52
+ # user made to a checkout of their own.
53
+ launcher_kind() {
54
+ if [ -L "$1" ]; then
55
+ if grep -qF "${AGENTD_MARKER}" "$1" 2>/dev/null; then echo symlink; else echo foreign; fi
56
+ elif [ -e "$1" ]; then
57
+ if [ -f "$1" ] && grep -qF "${AGENTD_MARKER}" "$1" 2>/dev/null; then echo ours; else echo foreign; fi
58
+ else
59
+ echo absent
60
+ fi
61
+ }
62
+
63
+ on_path() {
64
+ case ":${PATH}:" in
65
+ *":$1:"*) return 0 ;;
66
+ *) return 1 ;;
67
+ esac
68
+ }
30
69
 
31
70
  catalog() { sed '/^[[:space:]]*#/d; /^[[:space:]]*$/d' "$1"; }
32
71
  skill_inventory() {
@@ -63,6 +102,29 @@ if [ "${STATUS_ONLY}" = 1 ]; then
63
102
  while IFS= read -r AGENT_NAME; do
64
103
  check -f "${CLAUDE_DIR}/agents/${AGENT_NAME}.md" "agent ${AGENT_NAME}"
65
104
  done < <(catalog "${SRC}/claude/agents/catalog.txt")
105
+ if [ -f "${SRC}/agentd/luciazero_agentd/__init__.py" ]; then
106
+ AGENTD_ANY=0
107
+ for AGENTD_NAME in ${AGENTD_NAMES}; do
108
+ case "$(launcher_kind "${AGENTD_BIN_DIR}/${AGENTD_NAME}")" in
109
+ ours|symlink)
110
+ echo " ok agent bus launcher ${AGENTD_BIN_DIR}/${AGENTD_NAME}"
111
+ AGENTD_ANY=1 ;;
112
+ foreign)
113
+ echo " MISS ${AGENTD_BIN_DIR}/${AGENTD_NAME} is not the Luciazero launcher (left untouched)"; STATUS_RC=1 ;;
114
+ *)
115
+ echo " -- ${AGENTD_NAME} not installed (optional; ./install.sh installs it)" ;;
116
+ esac
117
+ done
118
+ if [ "${AGENTD_ANY}" = 1 ]; then
119
+ on_path "${AGENTD_BIN_DIR}" \
120
+ || echo " (not on PATH: export PATH=\"${AGENTD_BIN_DIR}:\$PATH\")"
121
+ if [ -f "${AGENTD_HOME_FILE}" ] && [ -d "$(cat "${AGENTD_HOME_FILE}")/luciazero_agentd" ]; then
122
+ echo " ok agentd package recorded at $(cat "${AGENTD_HOME_FILE}")"
123
+ else
124
+ echo " MISS ${AGENTD_HOME_FILE} does not point at an agentd package — re-run ./install.sh"; STATUS_RC=1
125
+ fi
126
+ fi
127
+ fi
66
128
  GLOBAL_MD="${CLAUDE_DIR}/CLAUDE.md"
67
129
  N="$(grep -cxF "${IMPORT_LINE}" "${GLOBAL_MD}" 2>/dev/null || true)"
68
130
  if [ "${N:-0}" = 1 ]; then
@@ -91,12 +153,68 @@ if [ "${STATUS_ONLY}" = 1 ]; then
91
153
  echo " MISS hooks/${HFILE} differs from this checkout (stale or customized) — re-run ./install.sh --with-hooks"; STATUS_RC=1
92
154
  fi
93
155
  done
156
+ # The stored command is a shell string whose path may be quoted, so
157
+ # whether a subcommand is wired is asked of the parsed command rather
158
+ # than of the bytes: a `grep` for the bare path stops seeing a
159
+ # correctly quoted entry the moment the path needs quoting.
94
160
  WIRE_MISS=""
95
- for SUB in prompt skill-prompt bash-start edit bash bash-failure skill stop session; do
96
- grep -qF "${CLAUDE_DIR}/hooks/luciazero-verify.sh ${SUB}\"" "${CLAUDE_DIR}/settings.json" 2>/dev/null \
97
- || WIRE_MISS="${WIRE_MISS} ${SUB}"
98
- done
99
- if [ -z "${WIRE_MISS}" ]; then
161
+ WIRE_UNCHECKED=""
162
+ if command -v python3 >/dev/null 2>&1; then
163
+ # a reader that crashed printed nothing, and nothing is what a fully
164
+ # wired settings.json prints too -- so its exit status decides
165
+ WIRE_MISS="$(python3 - "${CLAUDE_DIR}/settings.json" "${CLAUDE_DIR}/hooks/luciazero-verify.sh" <<'WIREPY'
166
+ import json, shlex, sys
167
+
168
+ path, verify = sys.argv[1], sys.argv[2]
169
+ try:
170
+ with open(path) as f:
171
+ settings = json.load(f)
172
+ except (OSError, ValueError):
173
+ settings = {}
174
+
175
+ def sub_of(cmd):
176
+ """Which subcommand of ours this entry runs, or None if it is not ours."""
177
+ if not isinstance(cmd, str):
178
+ return None
179
+ if cmd == verify:
180
+ return ""
181
+ if cmd.startswith(verify + " "):
182
+ return cmd[len(verify) + 1:].strip()
183
+ try:
184
+ parts = shlex.split(cmd)
185
+ except ValueError:
186
+ return None
187
+ if parts and parts[0] == verify:
188
+ return " ".join(parts[1:])
189
+ return None
190
+
191
+ hooks = settings.get("hooks") if isinstance(settings, dict) else None
192
+ wired = set()
193
+ for entries in (hooks or {}).values() if isinstance(hooks, dict) else ():
194
+ for entry in entries if isinstance(entries, list) else ():
195
+ inner = entry.get("hooks") if isinstance(entry, dict) else None
196
+ for hook in inner if isinstance(inner, list) else ():
197
+ if not isinstance(hook, dict):
198
+ continue
199
+ sub = sub_of(hook.get("command", ""))
200
+ if sub is not None:
201
+ wired.add(sub)
202
+
203
+ want = "prompt skill-prompt bash-start edit bash bash-failure skill stop session".split()
204
+ sys.stdout.write("".join(" " + s for s in want if s not in wired))
205
+ WIREPY
206
+ )" || WIRE_UNCHECKED=reader
207
+ else
208
+ WIRE_UNCHECKED=python3
209
+ fi
210
+ # Two different unknowns, and neither is "wired": no python3 to ask with,
211
+ # and a reader that could not answer. Both are reported as what they are,
212
+ # because a status that says "ok" here is the one nobody re-checks.
213
+ if [ "${WIRE_UNCHECKED}" = python3 ]; then
214
+ echo " MISS hook wiring not checked — python3 not found"; STATUS_RC=1
215
+ elif [ "${WIRE_UNCHECKED}" = reader ]; then
216
+ echo " MISS hook wiring not checked — settings.json could not be read"; STATUS_RC=1
217
+ elif [ -z "${WIRE_MISS}" ]; then
100
218
  echo " ok hooks wired in settings.json (prompt/skill-prompt/bash-start/edit/bash/bash-failure/skill/stop/session)"
101
219
  else
102
220
  echo " MISS settings.json missing hook entries:${WIRE_MISS} (re-run ./install.sh --with-hooks)"; STATUS_RC=1
@@ -121,11 +239,18 @@ if [ "${STATUS_ONLY}" = 1 ]; then
121
239
  exit "${STATUS_RC}"
122
240
  fi
123
241
 
124
- # collision-proof backup path for $1 (two runs in the same second must not overwrite)
242
+ # A free backup name for $1. Two runs in the same second must not overwrite
243
+ # each other, and a name a symlink already holds is taken too: `-e` follows
244
+ # the name and answers false for a symlink whose target is missing, which
245
+ # would send the `cp` below straight through that symlink and out of the
246
+ # config directory. This is a check, not a reservation -- the name is still
247
+ # free to be taken between the test and the `cp` (roadmap R24). The
248
+ # uninstaller's settings backup reserves its name with `O_CREAT | O_EXCL`
249
+ # instead, which the shell has no portable equivalent for.
125
250
  bakpath() {
126
251
  B="$1.bak.$(date +%Y%m%d%H%M%S)"
127
252
  N=1
128
- while [ -e "${B}" ]; do B="$1.bak.$(date +%Y%m%d%H%M%S).${N}"; N=$((N+1)); done
253
+ while [ -e "${B}" ] || [ -L "${B}" ]; do B="$1.bak.$(date +%Y%m%d%H%M%S).${N}"; N=$((N+1)); done
129
254
  printf '%s' "${B}"
130
255
  }
131
256
 
@@ -253,6 +378,44 @@ while IFS= read -r AGENT_NAME; do
253
378
  echo " ok agents/${AGENT_NAME}.md"
254
379
  done < <(catalog "${SRC}/claude/agents/catalog.txt")
255
380
 
381
+ # 3b. Agent Bus launcher, so the daemon has a public command instead of
382
+ # `cd agentd && python3 -m luciazero_agentd`. Only from a checkout: the npm
383
+ # payload ships this shim but not the package it runs.
384
+ if [ -f "${SRC}/agentd/luciazero_agentd/__init__.py" ] && [ -f "${SRC}/bin/luciazero-agentd" ]; then
385
+ # `ours` for the pair: one foreign name must not stop the other being
386
+ # installed, and must not stop the package pointer both of them read.
387
+ AGENTD_KIND=absent
388
+ for AGENTD_NAME in ${AGENTD_NAMES}; do
389
+ AGENTD_TARGET="${AGENTD_BIN_DIR}/${AGENTD_NAME}"
390
+ AGENTD_ONE="$(launcher_kind "${AGENTD_TARGET}")"
391
+ case "${AGENTD_ONE}" in
392
+ foreign)
393
+ echo " !! ${AGENTD_TARGET} exists and is not the Luciazero launcher; left untouched" >&2
394
+ echo " install it elsewhere with: LUCIAZERO_BIN_DIR=<dir> ./install.sh" >&2 ;;
395
+ symlink)
396
+ echo " ok bin/${AGENTD_NAME} (symlink to a Luciazero launcher; left as is)"
397
+ AGENTD_KIND=ours ;;
398
+ *)
399
+ mkdir -p "${AGENTD_BIN_DIR}"
400
+ if [ "${AGENTD_ONE}" = ours ] && cmp -s "${SRC}/bin/luciazero-agentd" "${AGENTD_TARGET}"; then
401
+ echo " ok bin/${AGENTD_NAME} (unchanged)"
402
+ else
403
+ cp "${SRC}/bin/luciazero-agentd" "${AGENTD_TARGET}"
404
+ echo " ok bin/${AGENTD_NAME} -> ${AGENTD_TARGET}"
405
+ fi
406
+ chmod +x "${AGENTD_TARGET}"
407
+ AGENTD_KIND=ours ;;
408
+ esac
409
+ done
410
+ if [ "${AGENTD_KIND}" != absent ]; then
411
+ # The installed copy is no longer next to the package, so it is told
412
+ # where the package went. Nothing here depends on the caller's cwd.
413
+ printf '%s\n' "${SRC}/agentd" > "${AGENTD_HOME_FILE}"
414
+ on_path "${AGENTD_BIN_DIR}" \
415
+ || echo " add to PATH: export PATH=\"${AGENTD_BIN_DIR}:\$PATH\""
416
+ fi
417
+ fi
418
+
256
419
  # 4. version sidecar — lets --status and future installs tell what is installed
257
420
  V_NEW="$(version_of)"
258
421
  V_OLD="$(cat "${CLAUDE_DIR}/.luciazero-version" 2>/dev/null || true)"
@@ -265,6 +428,46 @@ fi
265
428
 
266
429
  # 5. import line in global CLAUDE.md
267
430
  GLOBAL_MD="${CLAUDE_DIR}/CLAUDE.md"
431
+ IMPORT_PROVENANCE="${CLAUDE_DIR}/.luciazero-import"
432
+
433
+ # Empty when neither hasher is present, which makes the record unusable and
434
+ # sends the uninstaller down its conservative path. That is the right failure.
435
+ sha_of() {
436
+ if command -v shasum >/dev/null 2>&1; then shasum -a 256 < "$1" | cut -d' ' -f1
437
+ elif command -v sha256sum >/dev/null 2>&1; then sha256sum < "$1" | cut -d' ' -f1
438
+ else echo ""; fi
439
+ }
440
+
441
+ # Anything already at this path belongs to whoever put it there until it
442
+ # proves otherwise, and "it is a regular file" proves nothing -- a plain file
443
+ # somebody keeps notes in would pass that and be overwritten here and deleted
444
+ # by the uninstaller. Ownership is the marker in the first line, the same rule
445
+ # the launcher lives under. A symlink, a directory, or a regular file without
446
+ # the marker: the installer writes nothing and says so, and the uninstaller,
447
+ # finding no record of its own, stays conservative.
448
+ IMPORT_MARKER="luciazero-managed: import-provenance"
449
+ provenance_is_ours() {
450
+ [ -f "${IMPORT_PROVENANCE}" ] && [ ! -L "${IMPORT_PROVENANCE}" ] \
451
+ && [ "$(head -n 1 "${IMPORT_PROVENANCE}" 2>/dev/null)" = "${IMPORT_MARKER}" ]
452
+ }
453
+ write_provenance() {
454
+ if [ -e "${IMPORT_PROVENANCE}" ] || [ -L "${IMPORT_PROVENANCE}" ]; then
455
+ if ! provenance_is_ours; then
456
+ echo " !! ${IMPORT_PROVENANCE} exists and is not ours; left untouched" >&2
457
+ echo " uninstall will remove the import line and nothing else" >&2
458
+ return 0
459
+ fi
460
+ fi
461
+ # mktemp creates the file itself, exclusively and under a name nobody can
462
+ # guess, so there is no window in which a symlink planted at a predictable
463
+ # path could take the write. The rename is what publishes it.
464
+ IMPORT_TMP="$(mktemp "${CLAUDE_DIR}/.luciazero-import.XXXXXX")" || return 0
465
+ if printf '%s\n%s\n' "${IMPORT_MARKER}" "$1" > "${IMPORT_TMP}"; then
466
+ mv -f "${IMPORT_TMP}" "${IMPORT_PROVENANCE}" || rm -f "${IMPORT_TMP}"
467
+ else
468
+ rm -f "${IMPORT_TMP}"
469
+ fi
470
+ }
268
471
  if [ -f "${GLOBAL_MD}" ] && grep -qF "${IMPORT_LINE}" "${GLOBAL_MD}"; then
269
472
  echo " ok CLAUDE.md already imports ${DOCTRINE}"
270
473
  else
@@ -273,8 +476,18 @@ else
273
476
  cp "${GLOBAL_MD}" "${BACKUP}"
274
477
  echo " ok backed up CLAUDE.md -> $(basename "${BACKUP}")"
275
478
  printf '\n%s\n' "${IMPORT_LINE}" >> "${GLOBAL_MD}"
479
+ # Provenance for the uninstaller, and the only reason it may remove the
480
+ # blank line above the import line. A CLAUDE.md can arrive at that same
481
+ # shape without this branch running -- somebody writes the import line
482
+ # themselves, blank line and all, and the check above then leaves the file
483
+ # alone -- and in that case the blank is theirs. The hash is what makes the
484
+ # record about THIS file rather than about this installer's habits: edit
485
+ # the file afterwards, move the import line, add a blank of your own, and
486
+ # the hash stops matching and the uninstaller keeps its hands off.
487
+ write_provenance "appended $(sha_of "${GLOBAL_MD}")"
276
488
  else
277
489
  printf '%s\n' "${IMPORT_LINE}" > "${GLOBAL_MD}"
490
+ write_provenance "created $(sha_of "${GLOBAL_MD}")"
278
491
  fi
279
492
  echo " ok CLAUDE.md imports ${DOCTRINE}"
280
493
  fi
@@ -301,11 +514,38 @@ if [ "${WITH_HOOKS}" = 1 ]; then
301
514
  cp "${SETTINGS}" "$(bakpath "${SETTINGS}")"
302
515
  fi
303
516
  python3 - "${SETTINGS}" "${CLAUDE_DIR}/hooks" <<'PY' || { echo "FAIL: could not update settings.json (invalid JSON?) — hook files copied but not wired" >&2; exit 1; }
304
- import json, os, sys
517
+ import json, os, shlex, sys
305
518
 
306
519
  path, hooks_dir = sys.argv[1], sys.argv[2]
307
520
  verify_cmd = os.path.join(hooks_dir, "luciazero-verify.sh")
308
521
  status_cmd = os.path.join(hooks_dir, "luciazero-statusline.sh")
522
+ MARKERS = (verify_cmd, status_cmd)
523
+
524
+ # A hook command is a shell string, not an argv. A hooks directory whose name
525
+ # contains a space ends the command at that byte -- the stored command runs a
526
+ # prefix of the path and the shell answers 127 -- and a quote or a `$` in it
527
+ # would be worse than a broken hook. `shlex.quote` leaves a path that needs
528
+ # nothing exactly as it was, so an install that already worked keeps every
529
+ # byte and only the ones that were broken change.
530
+ def command(script, sub=""):
531
+ return shlex.quote(script) + (" " + sub if sub else "")
532
+
533
+ # Ours in either spelling: what `command` writes now, and the bare path older
534
+ # versions wrote -- including a bare path with a space in it, which shlex
535
+ # cannot parse back because it was never a valid command in the first place.
536
+ def parse(cmd):
537
+ for m in MARKERS:
538
+ if cmd == m:
539
+ return (m, "")
540
+ if cmd.startswith(m + " "):
541
+ return (m, cmd[len(m) + 1:].strip())
542
+ try:
543
+ parts = shlex.split(cmd)
544
+ except ValueError:
545
+ return None
546
+ if parts and parts[0] in MARKERS:
547
+ return (parts[0], " ".join(parts[1:]))
548
+ return None
309
549
 
310
550
  settings = {}
311
551
  if os.path.exists(path):
@@ -315,39 +555,49 @@ if os.path.exists(path):
315
555
  changed = False
316
556
  hooks = settings.setdefault("hooks", {})
317
557
 
318
- def ensure(event, matcher, command):
558
+ def ensure(event, matcher, sub):
319
559
  global changed
560
+ want = command(verify_cmd, sub)
320
561
  entries = hooks.setdefault(event, [])
321
562
  for e in entries:
322
563
  for h in e.get("hooks", []):
323
- if h.get("command") == command:
564
+ if parse(h.get("command", "")) == (verify_cmd, sub):
565
+ if h.get("command") != want:
566
+ h["command"] = want # an older install's unquoted entry
567
+ changed = True
324
568
  return
325
- entry = {"hooks": [{"type": "command", "command": command}]}
569
+ entry = {"hooks": [{"type": "command", "command": want}]}
326
570
  if matcher is not None:
327
571
  entry["matcher"] = matcher
328
572
  entries.append(entry)
329
573
  changed = True
330
574
 
331
- ensure("PostToolUse", "Edit|Write|NotebookEdit", verify_cmd + " edit")
332
- ensure("PostToolUse", "Bash", verify_cmd + " bash")
333
- ensure("PostToolUse", "Skill", verify_cmd + " skill")
334
- ensure("PostToolUseFailure", "Bash", verify_cmd + " bash-failure")
335
- ensure("PreToolUse", "Bash", verify_cmd + " bash-start")
336
- ensure("UserPromptSubmit", None, verify_cmd + " prompt")
337
- ensure("UserPromptExpansion", None, verify_cmd + " skill-prompt")
338
- ensure("Stop", None, verify_cmd + " stop")
339
- ensure("SessionStart", None, verify_cmd + " session")
575
+ ensure("PostToolUse", "Edit|Write|NotebookEdit", "edit")
576
+ ensure("PostToolUse", "Bash", "bash")
577
+ ensure("PostToolUse", "Skill", "skill")
578
+ ensure("PostToolUseFailure", "Bash", "bash-failure")
579
+ ensure("PreToolUse", "Bash", "bash-start")
580
+ ensure("UserPromptSubmit", None, "prompt")
581
+ ensure("UserPromptExpansion", None, "skill-prompt")
582
+ ensure("Stop", None, "stop")
583
+ ensure("SessionStart", None, "session")
340
584
 
341
585
  sl = settings.get("statusLine")
586
+ want_sl = command(status_cmd)
342
587
  if sl is None:
343
- settings["statusLine"] = {"type": "command", "command": status_cmd}
588
+ settings["statusLine"] = {"type": "command", "command": want_sl}
344
589
  changed = True
345
590
  print(" ok statusline wired")
346
- elif sl.get("command") == status_cmd:
347
- print(" ok statusline already wired")
591
+ elif isinstance(sl, dict) and parse(sl.get("command", "")) == (status_cmd, ""):
592
+ if sl.get("command") != want_sl:
593
+ sl["command"] = want_sl
594
+ changed = True
595
+ print(" ok statusline rewritten so its path survives the shell")
596
+ else:
597
+ print(" ok statusline already wired")
348
598
  else:
349
599
  print(" !! statusline SKIPPED — a custom statusLine exists; to use ours, set")
350
- print(" settings.json statusLine.command to: " + status_cmd)
600
+ print(" settings.json statusLine.command to: " + want_sl)
351
601
 
352
602
  if changed:
353
603
  with open(path, "w") as f:
@@ -368,6 +618,14 @@ echo
368
618
  SKILL_SUMMARY="$(catalog "${SRC}/skills/catalog.txt" | awk 'BEGIN{s=""} {s=s (s ? ", " : "") "/" $0} END{print s}')"
369
619
  AGENT_SUMMARY="$(catalog "${SRC}/claude/agents/catalog.txt" | awk 'BEGIN{s=""} {s=s (s ? ", " : "") $0} END{print s}')"
370
620
  echo "Skills: ${SKILL_SUMMARY}. Agents: ${AGENT_SUMMARY}."
621
+ if [ -f "${SRC}/agentd/luciazero_agentd/__init__.py" ] && [ -x "${AGENTD_BIN_DIR}/lucia" ] \
622
+ && [ "$(launcher_kind "${AGENTD_BIN_DIR}/lucia")" != foreign ]; then
623
+ echo "Agent Bus: lucia claude in one window, lucia codex in another (${AGENTD_BIN_DIR}/lucia)."
624
+ echo " the long name luciazero-agentd answers to every subcommand as before."
625
+ elif [ -f "${SRC}/agentd/luciazero_agentd/__init__.py" ] && [ -x "${AGENTD_LAUNCHER}" ] \
626
+ && [ "$(launcher_kind "${AGENTD_LAUNCHER}")" != foreign ]; then
627
+ echo "Agent Bus: luciazero-agentd next | watch | chat | run (${AGENTD_LAUNCHER})."
628
+ fi
371
629
  if [ "${WITH_HOOKS}" = 1 ]; then
372
630
  echo "Enforcement pack installed: verify-tracking hooks + statusline (see settings.json)."
373
631
  else
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "luciazero",
3
- "version": "2.4.3",
4
- "description": "Verification-first discipline for coding agents (Claude Code + Codex CLI): 9-rule doctrine, 11 skills, risk-routed reviewer, fail-open enforcement hooks. npx luciazero installs it.",
3
+ "version": "2.5.0",
4
+ "description": "Verification-first discipline for coding agents (Claude Code + Codex CLI): 9-rule doctrine, 13 skills, risk-routed reviewer, fail-open enforcement hooks. npx luciazero installs it.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ohm41321/luciazero.git"
@@ -7,6 +7,8 @@ debug
7
7
  bisect
8
8
  done
9
9
  lucia-relay
10
+ lucia-bus
11
+ lucia-chat
10
12
  experiment
11
13
  discipline-report
12
14
  retro
@@ -25,8 +25,9 @@ Re-read the final diff as a hostile reviewer. Check:
25
25
  - **Test honesty**: would changed tests fail if implementation is reverted?
26
26
 
27
27
  When applicable run
28
- `<this-skill-dir>/scripts/revert-probe.sh "<verify-cmd>"`. Weakened checks are
29
- findings. Fix findings and repeat full verify.
28
+ `<this-skill-dir>/scripts/revert-probe.sh "<verify-cmd>"`, preferring a command
29
+ aimed at the changed tests. Exit 2 is UNASSESSABLE: report it as no proof, not
30
+ as a pass. Weakened checks are findings. Fix findings and repeat full verify.
30
31
 
31
32
  ## 3. Risk-routed independent review
32
33
 
@@ -47,7 +48,9 @@ well-understood diff with no routed risk may stop after the skeptic pass.
47
48
  ## 4. Scope check
48
49
 
49
50
  Re-read the original request. Every item is delivered, or named as left out with
50
- a reason. Never drop scope silently.
51
+ a reason. Never drop scope silently. A bus task you claimed through
52
+ `/lucia-bus` must be `completed` or `blocked`, with its result published,
53
+ unless the user cancelled it.
51
54
 
52
55
  ## 5. Lessons
53
56
 
@@ -6,13 +6,35 @@
6
6
  # verify command there. The result is INVERTED: old code failing the new
7
7
  # tests is the PASS.
8
8
  #
9
+ # A red old-code run is not proof by itself. Plenty of things fail only on the
10
+ # old tree without saying anything about the change: a module the change adds,
11
+ # a command that is not installed there, a denied execution, an unrelated
12
+ # broken test. So a red run has to survive three more checks before it counts:
13
+ # * its fingerprint must be a test verdict, not infrastructure — a shell that
14
+ # could not run or execute the command (exit 127/126) and a run that failed
15
+ # to load the tests at all (import/collection errors) are refused;
16
+ # * it must be attributable to the changed tests — either the verify command
17
+ # targets one of them, or the failure output names one;
18
+ # * the same command must PASS against the current state (the base plus every
19
+ # changed file), so a command that is red everywhere cannot be read as a
20
+ # regression.
21
+ # What it still cannot see: a flake that only reproduces on the old tree; a
22
+ # command the change itself adds when a wrapper swallows the shell's exit 127;
23
+ # and, in the other direction, a suite whose own output quotes a loader error is
24
+ # read as one (this repository's revert-probe fixtures do exactly that, so
25
+ # probing a change to this script needs the manual comparison instead).
26
+ #
9
27
  # Usage: revert-probe.sh "<verify-cmd>" [base-ref] (base-ref default: HEAD)
10
28
  # Run it BEFORE committing — the fix and its new tests sit in the working
11
29
  # tree while HEAD is still the old code. For an already-committed fix, pass
12
- # the pre-fix ref (e.g. HEAD~1) as base-ref.
30
+ # the pre-fix ref (e.g. HEAD~1) as base-ref. Prefer a verify command aimed at
31
+ # the tests the change adds; a whole-suite command works but attributes the
32
+ # failure only through the output.
13
33
  #
14
34
  # Exit: 0 tests bite · 1 tests stay green on old code, or no changed test
15
- # files · 2 UNASSESSABLE (not a git repo, no commits, invalid base).
35
+ # files · 2 UNASSESSABLE (not a git repo, no commits, invalid base, an
36
+ # infrastructure failure, a failure that cannot be attributed to the changed
37
+ # tests, or a verify command that does not pass on the current code).
16
38
  # Pure bash + git; never touches the caller's working tree.
17
39
  set -euo pipefail
18
40
 
@@ -41,22 +63,32 @@ is_test_file() {
41
63
  return 1
42
64
  }
43
65
 
44
- # scratch space first — the changed-file list is stored NUL-delimited in a
45
- # file, because git C-quotes non-ASCII/backslash names in its plain output
66
+ # scratch space first — the changed-file lists are stored NUL-delimited in
67
+ # files, because git C-quotes non-ASCII/backslash names in its plain output
46
68
  # (-z emits them raw) and bash variables cannot hold NUL bytes
47
69
  TMP="$(mktemp -d)"
48
- WT="${TMP}/worktree"
70
+ WT="${TMP}/old"
71
+ WT_NEW="${TMP}/new"
49
72
  trap 'git worktree remove --force "${WT}" >/dev/null 2>&1 || true
73
+ git worktree remove --force "${WT_NEW}" >/dev/null 2>&1 || true
50
74
  rm -rf "${TMP}"
51
75
  git worktree prune >/dev/null 2>&1 || true' EXIT
52
76
 
53
- # changed vs base (tracked) plus untracked — the two sets are disjoint —
54
- # filtered to test files that still exist (a deleted test cannot bite)
77
+ # changed vs base (tracked) plus untracked — the two sets are disjoint.
78
+ # TEST_LIST drives the old-code overlay; CHANGED_LIST and GONE_LIST rebuild the
79
+ # current state for the control run (a deleted test cannot bite, but a deleted
80
+ # source file is part of the change).
55
81
  TEST_LIST="${TMP}/tests"
56
- : > "${TEST_LIST}"
82
+ CHANGED_LIST="${TMP}/changed"
83
+ GONE_LIST="${TMP}/gone"
84
+ : > "${TEST_LIST}"; : > "${CHANGED_LIST}"; : > "${GONE_LIST}"
57
85
  COUNT=0
58
86
  while IFS= read -r -d '' F; do
59
- [ -f "${F}" ] || continue
87
+ if [ ! -f "${F}" ]; then
88
+ printf '%s\0' "${F}" >> "${GONE_LIST}"
89
+ continue
90
+ fi
91
+ printf '%s\0' "${F}" >> "${CHANGED_LIST}"
60
92
  if is_test_file "${F}"; then
61
93
  printf '%s\0' "${F}" >> "${TEST_LIST}"
62
94
  COUNT=$((COUNT + 1))
@@ -68,29 +100,108 @@ if [ "${COUNT}" -eq 0 ]; then
68
100
  exit 1
69
101
  fi
70
102
 
103
+ # copy the files named in $2 out of the working tree into the worktree $1
104
+ overlay() {
105
+ local target="$1" list="$2" f
106
+ while IFS= read -r -d '' f; do
107
+ mkdir -p "${target}/$(dirname "${f}")"
108
+ cp "${f}" "${target}/${f}"
109
+ done < "${list}"
110
+ }
111
+
112
+ # shortest decisive line: the last line that is not blank
113
+ last_line() {
114
+ local line last=""
115
+ while IFS= read -r line; do
116
+ case "${line}" in *[![:space:]]*) last="${line}" ;; esac
117
+ done <<EOF
118
+ $1
119
+ EOF
120
+ printf '%s' "${last:-<no output>}"
121
+ }
122
+
123
+ RC=0
124
+ OUT=""
125
+ run_verify() {
126
+ RC=0
127
+ OUT="$(cd "$1" && sh -c "${VERIFY}" 2>&1)" || RC=$?
128
+ }
129
+
130
+ # a run that never loaded the tests judged nothing. Only loader failures are
131
+ # matched here: an environment that cannot run the command at all shows up as
132
+ # exit 127/126, or fails the current-code control run below as well. Matching
133
+ # shell-level phrases too would flag any suite whose own output quotes them.
134
+ load_marker() {
135
+ printf '%s\n' "$1" | grep -m1 -E \
136
+ 'ModuleNotFoundError|ImportError|error while loading shared libraries|[Cc]annot find module|MODULE_NOT_FOUND|ERROR collecting|errors? during collection|INTERNALERROR'
137
+ }
138
+
139
+ # does $1 name one of the changed test files, by path or by basename?
140
+ names_changed_test() {
141
+ local f
142
+ while IFS= read -r -d '' f; do
143
+ case "$1" in *"${f}"*) return 0 ;; esac
144
+ case "$1" in *"${f##*/}"*) return 0 ;; esac
145
+ done < "${TEST_LIST}"
146
+ return 1
147
+ }
148
+
71
149
  # old code in a throwaway worktree; cleanup runs on every exit path
72
150
  git worktree add --detach "${WT}" "${BASE}" >/dev/null 2>&1 \
73
151
  || unassessable "git worktree add failed for ${BASE}"
74
152
 
75
153
  # overlay ONLY the changed test files from the working tree
76
- while IFS= read -r -d '' F; do
77
- mkdir -p "${WT}/$(dirname "${F}")"
78
- cp "${F}" "${WT}/${F}"
79
- done < "${TEST_LIST}"
154
+ overlay "${WT}" "${TEST_LIST}"
80
155
 
81
- RC=0
82
- OUT="$(cd "${WT}" && sh -c "${VERIFY}" 2>&1)" || RC=$?
156
+ run_verify "${WT}"
157
+ RC_OLD="${RC}"
158
+ OUT_OLD="${OUT}"
159
+
160
+ if [ "${RC_OLD}" -eq 0 ]; then
161
+ echo "FAIL: the changed tests stay green against the old code — they do not cover the change"
162
+ exit 1
163
+ fi
164
+
165
+ # --- the red run has to earn the word "regression" -------------------------
166
+ if [ "${RC_OLD}" -eq 127 ]; then
167
+ unassessable "the verify command could not be run on ${BASE} (exit 127): $(last_line "${OUT_OLD}")"
168
+ fi
169
+ if [ "${RC_OLD}" -eq 126 ]; then
170
+ unassessable "the verify command was not executable on ${BASE} (exit 126): $(last_line "${OUT_OLD}")"
171
+ fi
172
+ if MARK="$(load_marker "${OUT_OLD}")"; then
173
+ unassessable "the old-code run never loaded the tests: ${MARK}
174
+ that is an import failure on ${BASE}, not a regression — the change may
175
+ simply add code the tests import. Point the verify command at a test that
176
+ fails on its assertion instead."
177
+ fi
83
178
 
179
+ NOTE=""
180
+ if ! names_changed_test "${VERIFY}"; then
181
+ names_changed_test "${OUT_OLD}" \
182
+ || unassessable "the verify command does not target any changed test file and
183
+ its failure output does not name one, so the failure cannot be attributed to
184
+ the changed tests. Re-run with a command that targets them."
185
+ NOTE="note: the verify command is not targeted at the changed tests; attribution comes from the failure output"
186
+ fi
187
+
188
+ # control: the same command must pass on the current state (base + every
189
+ # changed file), or the failure says nothing about the change
190
+ git worktree add --detach "${WT_NEW}" "${BASE}" >/dev/null 2>&1 \
191
+ || unassessable "git worktree add failed for ${BASE}"
192
+ overlay "${WT_NEW}" "${CHANGED_LIST}"
193
+ while IFS= read -r -d '' F; do
194
+ rm -f "${WT_NEW}/${F}"
195
+ done < "${GONE_LIST}"
196
+
197
+ run_verify "${WT_NEW}"
84
198
  if [ "${RC}" -ne 0 ]; then
85
- LAST=""
86
- while IFS= read -r LINE; do
87
- case "${LINE}" in *[![:space:]]*) LAST="${LINE}" ;; esac
88
- done <<EOF
89
- ${OUT}
90
- EOF
91
- echo "PASS: regression tests bite — old code fails the new tests"
92
- echo " evidence (exit ${RC}): ${LAST:-<no output>}"
93
- exit 0
199
+ unassessable "the same command also fails on the current code (exit ${RC}): $(last_line "${OUT}")
200
+ the old-code failure cannot be attributed to reverting the change. Make the
201
+ verify command pass on the current tree first."
94
202
  fi
95
- echo "FAIL: the changed tests stay green against the old code — they do not cover the change"
96
- exit 1
203
+
204
+ echo "PASS: regression tests bite — old code fails the new tests, the same command passes on the current code"
205
+ echo " evidence (exit ${RC_OLD} on ${BASE}): $(last_line "${OUT_OLD}")"
206
+ [ -n "${NOTE}" ] && echo " ${NOTE}"
207
+ exit 0