luciazero 2.4.2 → 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.2",
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"
@@ -32,10 +32,17 @@
32
32
  "keywords": [
33
33
  "claude-code",
34
34
  "codex",
35
+ "ai-agent",
36
+ "ai-agents",
35
37
  "agent-skills",
38
+ "coding-agent",
39
+ "claude-plugin",
40
+ "context-handoff",
36
41
  "verification",
37
42
  "doctrine",
38
43
  "hooks",
39
- "agentic-engineering"
44
+ "agentic-engineering",
45
+ "agent-workflow",
46
+ "test-discipline"
40
47
  ]
41
48
  }
@@ -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