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/README.md +140 -20
- package/bin/bus.js +121 -0
- package/bin/luciazero-agentd +107 -0
- package/bin/luciazero.js +3 -1
- package/install-codex.sh +104 -12
- package/install.sh +283 -25
- package/package.json +10 -3
- package/skills/catalog.txt +2 -0
- package/skills/done/SKILL.md +6 -3
- package/skills/done/scripts/revert-probe.sh +137 -26
- package/skills/imouto-mode/SKILL.md +15 -1
- package/skills/lucia-bus/SKILL.md +75 -0
- package/skills/lucia-chat/SKILL.md +152 -0
- package/skills/lucia-relay/SKILL.md +5 -5
- package/uninstall-codex.sh +129 -15
- package/uninstall.sh +308 -27
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
#
|
|
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,
|
|
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") ==
|
|
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":
|
|
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",
|
|
332
|
-
ensure("PostToolUse", "Bash",
|
|
333
|
-
ensure("PostToolUse", "Skill",
|
|
334
|
-
ensure("PostToolUseFailure", "Bash",
|
|
335
|
-
ensure("PreToolUse", "Bash",
|
|
336
|
-
ensure("UserPromptSubmit", None,
|
|
337
|
-
ensure("UserPromptExpansion", None,
|
|
338
|
-
ensure("Stop", None,
|
|
339
|
-
ensure("SessionStart", None,
|
|
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":
|
|
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
|
-
|
|
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: " +
|
|
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
|
-
"description": "Verification-first discipline for coding agents (Claude Code + Codex CLI): 9-rule doctrine,
|
|
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
|
}
|
package/skills/catalog.txt
CHANGED
package/skills/done/SKILL.md
CHANGED
|
@@ -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>"
|
|
29
|
-
|
|
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
|
|