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 CHANGED
@@ -2,29 +2,68 @@
2
2
  <img src="https://cdn.jsdelivr.net/gh/ohm41321/luciazero@37cb470e2b7c704ff32f3a46dbb125e312875960/docs/assets/lucia.png" width="220" alt="Lucia — Luciazero's mascot">
3
3
  <h1>Luciazero</h1>
4
4
  <p>
5
- <strong>Evidence-first discipline for coding agents.</strong><br>
5
+ <strong>Make coding agents prove their work.</strong><br>
6
6
  <code>plan → change → verify → fix</code>
7
7
  </p>
8
8
  <p>
9
9
  <a href="https://www.npmjs.com/package/luciazero"><img src="https://img.shields.io/npm/v/luciazero" alt="npm version"></a>
10
+ <a href="https://www.npmjs.com/package/luciazero"><img src="https://img.shields.io/npm/dw/luciazero" alt="npm weekly downloads"></a>
10
11
  <a href="https://github.com/ohm41321/luciazero/actions/workflows/ci.yml"><img src="https://github.com/ohm41321/luciazero/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
12
+ <a href="https://github.com/ohm41321/luciazero/stargazers"><img src="https://img.shields.io/github/stars/ohm41321/luciazero?style=flat" alt="GitHub stars"></a>
13
+ <a href="https://github.com/ohm41321/luciazero/network/members"><img src="https://img.shields.io/github/forks/ohm41321/luciazero?style=flat" alt="GitHub forks"></a>
11
14
  <a href="https://github.com/ohm41321/luciazero/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ohm41321/luciazero" alt="MIT license"></a>
12
15
  </p>
13
16
  </div>
14
17
 
15
18
  **English** · [ภาษาไทย](https://github.com/ohm41321/luciazero/blob/main/README.th.md)
16
19
 
17
- Luciazero is a lightweight discipline layer for coding agents. It works with
18
- Claude Code, Codex CLI, and compatible skill runtimes through plugin, CLI, or
19
- skills-only installs.
20
+ <p align="center">
21
+ <strong>13 skills</strong> · <strong>Relay fixture 6/6</strong> · <strong>Claude + Codex</strong> · <strong>MIT</strong>
22
+ </p>
23
+ <p align="center"><sub>Relay 6/6 is a mechanical protocol check; behavioral results are reported separately.</sub></p>
24
+
25
+ Luciazero is the verification and handoff layer for Claude Code, Codex CLI, and
26
+ compatible skill runtimes. It helps agents prove tests, preserve scope, and
27
+ move unfinished work with evidence.
20
28
 
21
29
  > Done is proven by a command, not by my judgment. If no verification command
22
30
  > exists, that is the first bug.
23
31
 
24
- It ships a compact [9-rule doctrine](https://github.com/ohm41321/luciazero/blob/main/claude/luciazero.md), 11 on-demand skills,
25
- verification hooks, a risk-routed reviewer, and an eval harness. It is designed
26
- to make completion claims auditable—not to replace an agent runtime or become an
27
- overnight orchestrator.
32
+ Luciazero is for teams that want coding agents to prove work instead of merely
33
+ report it. It helps prevent false-green verification and dropped scope,
34
+ preserves lessons from repeated dead ends, and carries context across handoffs
35
+ when `/lucia-relay` is used. It is a discipline layer, not an agent runtime.
36
+
37
+ ## Start in 30 seconds
38
+
39
+ Choose the path that matches your agent, then start a new session:
40
+
41
+ **Claude Code · full pack**
42
+
43
+ ```text
44
+ /plugin marketplace add ohm41321/luciazero
45
+ /plugin install luciazero@luciazero
46
+ ```
47
+
48
+ Run `/luciazero:ready` in the new session.
49
+
50
+ **Codex CLI · doctrine + skills**
51
+
52
+ ```bash
53
+ npx luciazero codex
54
+ ```
55
+
56
+ Run `$ready` in the new session.
57
+
58
+ **Skills-only · any compatible agent**
59
+
60
+ ```bash
61
+ npx skills add ohm41321/luciazero
62
+ ```
63
+
64
+ Invoke the installed `ready` skill using your agent's syntax (for Codex CLI,
65
+ use `$ready`). This path intentionally does not install the doctrine, reviewer,
66
+ or hooks.
28
67
 
29
68
  ## See the loop
30
69
 
@@ -40,6 +79,65 @@ This GIF is driven by the shipped hooks, not a mockup:
40
79
  ✅ verify 3m → the latest check passed three minutes ago
41
80
  ```
42
81
 
82
+ Run the same shipped hook driver locally—no model or API is required:
83
+
84
+ ```bash
85
+ bash docs/assets/statusline-demo.sh
86
+ ```
87
+
88
+ For the cross-session handoff demo:
89
+
90
+ ```bash
91
+ bash docs/assets/relay-demo.sh
92
+ ```
93
+
94
+ Both scripts use throwaway directories and exercise the real implementation.
95
+
96
+ ## Let two agents hand work to each other
97
+
98
+ **Agent Bus is beta, opt-in, and checkout only.** It does not come with
99
+ `npx luciazero` and never starts during a normal npm, plugin, or skills-only
100
+ install. After the one-time checkout setup, the ordinary path is one command
101
+ in each agent window:
102
+
103
+ ```text
104
+ window A window B
105
+ $ lucia claude $ lucia codex
106
+
107
+ task_create + message_send ───────► queued durably in the local bus
108
+ nudge ─► check your bus inbox (1 new task from claude)
109
+ message_ack + task_claim
110
+ work + verify
111
+ artifact_publish + task_complete
112
+ result in the inbox ◄─────── message_send
113
+ ```
114
+
115
+ The daemon starts when the first `lucia` session needs it, each CLI receives
116
+ an MCP configuration for that run only, and the user's central Claude or
117
+ Codex configuration is not edited. Messages and task state stay in local
118
+ SQLite; peer message text is never typed into another session's prompt. The
119
+ proxy types only the daemon-built inbox notice after the provider is quiet.
120
+
121
+ Try the shipped fake-provider demo—no model, login, or quota required:
122
+
123
+ ```bash
124
+ bash docs/assets/agent-bus-demo.sh
125
+ ```
126
+
127
+ To open real Claude Code and Codex sessions, first follow the one-time
128
+ [checkout setup](docs/agent-bus.md#start-here), then run `lucia claude` and
129
+ `lucia codex`. See the [Agent Bus guide](docs/agent-bus.md) for worktree
130
+ ownership, `--no-nudge`, security boundaries, and cleanup.
131
+
132
+ Inside the Bus workflow, the skill stops before risky work: delete, deploy,
133
+ production access, spending, force-push, public-contract changes, and scope
134
+ expansion need a single-use nonce you mint in your own terminal. No bus tool
135
+ can create one, and a nonce put into a bus message, task, or artifact is
136
+ scrubbed or refused ([approvals](docs/agent-bus.md#approvals)). This records
137
+ approval provenance; it is not a host sandbox against another process running
138
+ as you. The project trust boundary is in
139
+ [SECURITY.md](https://github.com/ohm41321/luciazero/blob/main/SECURITY.md).
140
+
43
141
  ## What it protects
44
142
 
45
143
  | Failure mode | Mechanism |
@@ -110,7 +208,7 @@ cannot set one.
110
208
  npx skills add ohm41321/luciazero
111
209
  ```
112
210
 
113
- This installs the 11 skills: no doctrine, reviewer, or hooks.
211
+ This installs the 13 skills: no doctrine, reviewer, or hooks.
114
212
 
115
213
  </details>
116
214
 
@@ -151,11 +249,13 @@ Other install channels use their own updater:
151
249
 
152
250
  ```bash
153
251
  claude plugin update luciazero@luciazero # then run /reload-plugins
154
- npx skills update # review the scope prompt
252
+ npx skills update # every installed skill in the selected scope
253
+ npx skills update done -g # only the global "done" skill
155
254
  ```
156
255
 
157
256
  The skills command updates every installed skill in the selected scope, not
158
- only Luciazero; review its prompt before confirming.
257
+ only Luciazero; review its prompt before confirming. Use the targeted form when
258
+ you only want to refresh one skill.
159
259
 
160
260
  Claude Code can auto-update the plugin at startup: open `/plugin` →
161
261
  **Marketplaces** → **luciazero** → **Enable auto-update**. Third-party
@@ -164,7 +264,8 @@ GitHub **Watch → Custom → Releases**.
164
264
 
165
265
  ## Skills at a glance
166
266
 
167
- Run `/ready` first; the rest activate when their moment arrives.
267
+ Invoke `ready` first (`/ready` for slash-based agents, `$ready` in Codex); the
268
+ rest activate when their moment arrives.
168
269
 
169
270
  | Moment | Skill | Result |
170
271
  |---|---|---|
@@ -176,6 +277,8 @@ Run `/ready` first; the rest activate when their moment arrives.
176
277
  | Good and bad revisions are known | `/bisect` | Finds the first bad commit in a temporary worktree |
177
278
  | Before claiming completion | `/done` | Full verify, skeptic review, scope report |
178
279
  | Work must move elsewhere | `/lucia-relay` | Portable JSON + Markdown state with drift inspection |
280
+ | Another agent's work is queued for you (beta) | `/lucia-bus` | Register, read the inbox, claim, work, publish the result through the local Agent Bus ([setup and demo](docs/agent-bus.md)) |
281
+ | Want two agent sessions talking (beta) | `/lucia-chat` | Shows what is waiting, opens one window per agent, and optionally watches the conversation |
179
282
  | Optimizing performance | `/experiment` | Baseline, threshold, controlled measurement |
180
283
  | Reviewing local verify habits | `/discipline-report` | Time/project-filtered local outcome report |
181
284
  | After difficult work | `/retro` | Stores reusable lessons and disproved approaches |
@@ -191,21 +294,31 @@ separate passes.
191
294
 
192
295
  ## Evidence & limitations
193
296
 
297
+ The measurements below are early and model/task-specific. The linked raw rows
298
+ and methodology are the source of truth; they are not a promise of uplift on
299
+ every repository or model.
300
+
194
301
  <!-- BEGIN GENERATED: benchmark-evidence -->
195
302
 
196
303
  ### Claude results
197
304
 
198
- Snapshot: 2026-08-11. All-criteria pass rate generated from checked-in raw rows:
305
+ Snapshots: 2026-08-11 for Haiku and the Sonnet pilot, 2026-09-02 for Sonnet.
306
+ All-criteria pass rate generated from checked-in raw rows:
199
307
 
200
- | Claude model | Luciazero | Bare | Difference |
201
- |---|---:|---:|---:|
202
- | Haiku†, 10 valid/task | 36/60 (60%) | 27/60 (45%) | +15pp |
203
- | Sonnet, 4–5 valid/task* | 25/27 (93%) | 16/26 (62%) | +31pp |
308
+ | Claude model | Tasks | Luciazero | Bare | Difference |
309
+ |---|---:|---:|---:|---:|
310
+ | Haiku†, 10 valid/task | 6 | 36/60 (60%) | 27/60 (45%) | +15pp |
311
+ | Sonnet (2026-08-11 pilot), 4–5 valid/task* | 6 | 25/27 (93%) | 16/26 (62%) | +31pp |
312
+ | Sonnet, 5 valid/task | 10 | 39/50 (78%) | 23/50 (46%) | +32pp |
204
313
 
205
314
  The `Luciazero` arm installs the classic pack without hooks; it is not a clean
206
- doctrine-only ablation. *Sonnet is preliminary because eight invalid rows leave
207
- several arms at four valid runs. The previously stated `+37pp` top-up is retired
208
- because its replacement raw rows could not be recovered.
315
+ doctrine-only ablation. Rows are not like-for-like: the 2026-09-02 Sonnet
316
+ campaign adds four harder tasks that the 2026-08-11 campaigns never ran, so
317
+ compare each row against its own bare arm rather than across models.
318
+ *The Sonnet pilot stays preliminary because eight invalid rows leave several
319
+ arms at four valid runs; the 2026-09-02 campaign supersedes it with five valid
320
+ runs in every cell and no invalid rows. The previously stated `+37pp` top-up
321
+ remains retired because its replacement raw rows could not be recovered.
209
322
 
210
323
  †Model provenance is incomplete for Haiku: only 70/140 rows encode model
211
324
  identity. The other 70 are attributed at campaign-file/report level and
@@ -232,6 +345,12 @@ only one run per arm per task. See the [full benchmark](https://github.com/ohm41
232
345
  - Node.js 18+ for the CLI and discipline report.
233
346
  - Bash for classic installers; Python 3.9+ for hooks and Lucia Relay
234
347
  (`install.sh --with-hooks` refuses anything older).
348
+ - The Agent Bus daemon (beta, opt-in) needs Python 3.10+ and a checkout: it is
349
+ not in the npm payload and `npx luciazero` never starts it. From a checkout,
350
+ `./install.sh` adds the `luciazero-agentd` launcher to `~/.claude/bin`
351
+ (`LUCIAZERO_BIN_DIR` chooses another directory), and
352
+ `luciazero-agentd service install` runs the daemon under launchd or systemd
353
+ `--user`. macOS, Linux and WSL2 only. See [docs/agent-bus.md](docs/agent-bus.md).
235
354
  - Core installers, hooks, helpers, and graders are offline. Real behavioral
236
355
  evals invoke a model CLI and consume API credit or subscription quota.
237
356
  - Hooks run commands on your machine. Read them before enabling them.
@@ -273,6 +392,7 @@ More detail:
273
392
  - [Benchmark results and GPT plan](https://github.com/ohm41321/luciazero/blob/main/docs/benchmark.md)
274
393
  - [Raw campaign registry](https://github.com/ohm41321/luciazero/blob/main/eval/results/campaigns.json)
275
394
  - [Experiment log](https://github.com/ohm41321/luciazero/blob/main/docs/experiments.md)
395
+ - [Launch kit](https://github.com/ohm41321/luciazero/blob/main/docs/launch-kit.md)
276
396
  - [Contributing](https://github.com/ohm41321/luciazero/blob/main/CONTRIBUTING.md)
277
397
  - [Publishing](https://github.com/ohm41321/luciazero/blob/main/docs/publishing.md)
278
398
  - [Changelog](https://github.com/ohm41321/luciazero/blob/main/CHANGELOG.md)
package/bin/bus.js ADDED
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ // `npx luciazero bus status [--json]`: show what is waiting on whom in the
3
+ // local Agent Bus. Talks to the running luciazero-agentd over loopback with
4
+ // the capability token; never starts a daemon and works without one
5
+ // installed (it just reports that none is running).
6
+ const fs = require("node:fs");
7
+ const os = require("node:os");
8
+ const path = require("node:path");
9
+
10
+ function stateDir() {
11
+ const env = process.env.LUCIAZERO_AGENT_BUS_HOME;
12
+ return env ? env : path.join(os.homedir(), ".luciazero", "agent-bus");
13
+ }
14
+
15
+ // Peer-supplied strings never reach the terminal unfiltered.
16
+ const clean = (value) => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, "?");
17
+
18
+ function usage(code) {
19
+ const out = code === 0 ? console.log : console.error;
20
+ out("usage: luciazero bus status [--json]");
21
+ out(" Reads endpoint.json and token from " + stateDir() + " (LUCIAZERO_AGENT_BUS_HOME).");
22
+ process.exit(code);
23
+ }
24
+
25
+ async function status(json) {
26
+ const dir = stateDir();
27
+ let endpoint;
28
+ try {
29
+ endpoint = JSON.parse(fs.readFileSync(path.join(dir, "endpoint.json"), "utf8"));
30
+ } catch (err) {
31
+ console.error(`luciazero bus: no running daemon recorded in ${dir}`);
32
+ console.error(" the bus is an opt-in beta that ships only in a checkout, not with npx luciazero:");
33
+ console.error(" clone https://github.com/ohm41321/luciazero, run ./install.sh, then: lucia claude");
34
+ process.exit(2);
35
+ }
36
+ let token;
37
+ try {
38
+ token = fs.readFileSync(path.join(dir, "token"), "utf8").trim();
39
+ } catch (err) {
40
+ console.error(`luciazero bus: cannot read the capability token in ${dir}`);
41
+ process.exit(2);
42
+ }
43
+ const base = endpoint.url.replace(/\/mcp$/, "");
44
+ let response;
45
+ try {
46
+ response = await fetch(base + "/status", { headers: { Authorization: `Bearer ${token}` } });
47
+ } catch (err) {
48
+ console.error(`luciazero bus: daemon at ${endpoint.url} is not answering (${err.message})`);
49
+ process.exit(2);
50
+ }
51
+ if (!response.ok) {
52
+ console.error(`luciazero bus: daemon answered HTTP ${response.status}`);
53
+ process.exit(2);
54
+ }
55
+ const body = await response.json();
56
+ if (json) {
57
+ console.log(JSON.stringify(body, null, 2));
58
+ return;
59
+ }
60
+ const tasks = Object.entries(body.tasks).map(([k, v]) => `${clean(k)} ${Number(v)}`).join(", ");
61
+ console.log(`agent bus: ${clean(body.server.name)} ${clean(body.server.version)} since ${clean(body.server.started_at)}`);
62
+ console.log(`queued deliveries: ${Number(body.queued_deliveries)} tasks: ${tasks}`);
63
+ for (const agent of body.agents) {
64
+ const wt = agent.worktree;
65
+ const where = wt ? ` on ${clean(wt.branch)}${wt.dirty ? " (dirty)" : ""}` : "";
66
+ // ADR 0004: an agent with no terminal binding says so on its own line.
67
+ const who = agent.binding ? ` ${clean(agent.binding.tty || "no tty")}` : " unverified";
68
+ console.log(
69
+ ` ${clean(agent.id).padEnd(24)} ${clean(agent.provider).padEnd(7)} ${clean(agent.role).padEnd(14)} ` +
70
+ `inbox ${String(Number(agent.queued_deliveries)).padStart(3)} claimed ${String(Number(agent.claimed_tasks)).padStart(3)} seen ${clean(agent.last_seen_at)}${where}${who}`
71
+ );
72
+ }
73
+ for (const task of body.open_tasks) {
74
+ const needs = task.requires_worktree ? " needs worktree" : "";
75
+ console.log(` open task ${clean(task.id)} p${Number(task.priority)} ${clean(task.assigned_to || "unassigned")}: ${clean(task.title)}${needs}`);
76
+ }
77
+ const workers = Array.isArray(body.workers) ? body.workers : [];
78
+ for (const worker of workers) {
79
+ console.log(` managed worker ${clean(worker.agent_id).padEnd(20)} ${clean(worker.provider).padEnd(7)} ${worker.enabled ? "enabled" : "paused"}`);
80
+ }
81
+ const running = Array.isArray(body.running_runs) ? body.running_runs : [];
82
+ for (const run of running) {
83
+ console.log(` turn running ${clean(run.agent_id).padEnd(20)} attempt ${Number(run.attempt)} since ${clean(run.started_at)}`);
84
+ }
85
+ const stopped = Array.isArray(body.stopped_tasks) ? body.stopped_tasks : [];
86
+ for (const task of stopped) {
87
+ console.log(` stopped task ${clean(task.id)} spent its ${clean(task.dimension || "budget")} budget: ${clean(task.title)}`);
88
+ }
89
+ const unverified = Array.isArray(body.unverified_agents) ? body.unverified_agents : [];
90
+ if (unverified.length > 0) {
91
+ console.log(
92
+ `unverified: ${unverified.map(clean).join(", ")} (no terminal binding; these sessions act as whoever they claim to be)`
93
+ );
94
+ }
95
+ if (Number(body.approvals_pending) > 0) {
96
+ console.log(`approvals pending: ${Number(body.approvals_pending)} (unused nonces; each is bound to one task and operation)`);
97
+ }
98
+ if (body.queued_deliveries > 0 || body.tasks.open > 0) {
99
+ console.log("next: start the agent's session and run /lucia-bus (Codex: $lucia-bus)");
100
+ }
101
+ }
102
+
103
+ const args = process.argv.slice(2);
104
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") usage(args.length === 0 ? 64 : 0);
105
+ if (args[0] !== "status") {
106
+ console.error(`luciazero bus: unknown subcommand '${args[0]}'`);
107
+ usage(64);
108
+ }
109
+ const extra = args.slice(1).filter((a) => a !== "--json");
110
+ if (extra.length > 0) {
111
+ console.error(`luciazero bus: unknown option '${extra[0]}'`);
112
+ usage(64);
113
+ }
114
+ if (typeof fetch !== "function") {
115
+ console.error("luciazero bus: needs Node 18+ (global fetch)");
116
+ process.exit(1);
117
+ }
118
+ status(args.includes("--json")).catch((err) => {
119
+ console.error("luciazero bus: " + err.message);
120
+ process.exit(1);
121
+ });
@@ -0,0 +1,107 @@
1
+ #!/bin/sh
2
+ # luciazero-agentd -- run the Agent Bus daemon from anywhere.
3
+ # lucia -- the same program under a shorter name, installed as a second copy.
4
+ #
5
+ # luciazero-managed: agentd-launcher
6
+ #
7
+ # The daemon is a Python package that is neither on PATH nor pip-installed: it
8
+ # lives in `agentd/` inside a checkout (ADR 0002 keeps it out of the npm
9
+ # payload). This shim finds it from where the shim itself is, never from the
10
+ # caller's working directory -- the working directory is meaningful to the
11
+ # daemon, since `attach` records it as the session's, so this must not change
12
+ # it the way `cd agentd && python3 -m luciazero_agentd` did.
13
+ set -eu
14
+
15
+ fail() { printf 'luciazero-agentd: %s\n' "$*" >&2; exit 127; }
16
+
17
+ # Resolve this script through any symlinks. `readlink -f` is GNU-only, so the
18
+ # loop is hand-rolled; every expansion is quoted, so a path with spaces (and
19
+ # an installed copy reached through a symlink) resolves correctly.
20
+ # The name typed, before any symlink is followed: `lucia` and
21
+ # `luciazero-agentd` are one program, and only the messages that tell the user
22
+ # what to type next need to know which name they are speaking under.
23
+ argv0="$(basename "$0")"
24
+ self="$0"
25
+ hops=0
26
+ while [ -L "$self" ]; do
27
+ hops=$((hops + 1))
28
+ [ "$hops" -le 40 ] || fail "symlink loop while resolving $0"
29
+ target="$(readlink "$self")"
30
+ case "$target" in
31
+ /*) self="$target" ;;
32
+ *) self="$(dirname "$self")/$target" ;;
33
+ esac
34
+ done
35
+ here="$(cd "$(dirname "$self")" && pwd -P)"
36
+
37
+ config_dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
38
+ home_file="${config_dir}/.luciazero-agentd-home"
39
+
40
+ # Checkout layout wins over the recorded one: running `bin/luciazero-agentd`
41
+ # out of a second clone must use that clone, not whichever one was installed.
42
+ package_home=""
43
+ if [ -n "${LUCIAZERO_AGENTD_HOME:-}" ]; then
44
+ package_home="${LUCIAZERO_AGENTD_HOME}"
45
+ elif [ -d "${here}/../agentd/luciazero_agentd" ]; then
46
+ package_home="$(cd "${here}/.." && pwd -P)/agentd"
47
+ elif [ -f "${home_file}" ]; then
48
+ package_home="$(cat "${home_file}")"
49
+ fi
50
+
51
+ if [ -z "${package_home}" ]; then
52
+ fail "cannot find the luciazero_agentd package.
53
+ It ships in a checkout of https://github.com/ohm41321/luciazero, not in the
54
+ npm package. Clone it and run ./install.sh from the checkout, or point
55
+ LUCIAZERO_AGENTD_HOME at its agentd/ directory."
56
+ fi
57
+ case "${package_home}" in
58
+ *"
59
+ "*) fail "the package path contains a newline: ${package_home}" ;;
60
+ esac
61
+ if [ ! -d "${package_home}/luciazero_agentd" ]; then
62
+ fail "no luciazero_agentd package in ${package_home} (moved or deleted checkout?).
63
+ Re-run ./install.sh from the checkout, or set LUCIAZERO_AGENTD_HOME."
64
+ fi
65
+
66
+ # ADR 0002: try python3, then python, then the Windows launcher, and take the
67
+ # first one that reports 3.10 or newer. A wrong pick here is worse than none:
68
+ # the daemon would fail deep inside the package instead of at the door.
69
+ usable() {
70
+ "$@" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)' >/dev/null 2>&1
71
+ }
72
+ python=""
73
+ for candidate in python3 python; do
74
+ if command -v "${candidate}" >/dev/null 2>&1 && usable "${candidate}"; then
75
+ python="${candidate}"
76
+ break
77
+ fi
78
+ done
79
+ if [ -z "${python}" ] && command -v py >/dev/null 2>&1 && usable py -3; then
80
+ python="py -3"
81
+ fi
82
+ [ -n "${python}" ] || fail "needs Python 3.10 or newer; tried python3, python and py -3"
83
+
84
+ # The package path travels in an environment variable and is put on sys.path
85
+ # by the interpreter itself. Two things this avoids, both of which let code
86
+ # from the caller's working directory run instead of the daemon:
87
+ #
88
+ # * PYTHONPATH is colon-separated, so a checkout at a path containing ":"
89
+ # would split into two entries, the tail of which resolves relative to the
90
+ # caller's directory.
91
+ # * `python -m pkg` puts the working directory FIRST on sys.path, ahead of
92
+ # PYTHONPATH, so a directory named luciazero_agentd next to the caller
93
+ # would shadow the real package.
94
+ #
95
+ # The variable is popped before the daemon does anything, so no provider
96
+ # subprocess inherits it, and the -c program below is a fixed literal: nothing
97
+ # a caller supplies is ever parsed as code.
98
+ LUCIAZERO_AGENTD_PACKAGE="${package_home}"
99
+ export LUCIAZERO_AGENTD_PACKAGE
100
+ LUCIAZERO_ARGV0="${argv0}"
101
+ export LUCIAZERO_ARGV0
102
+ # ${python} is unquoted on purpose -- "py -3" is two words. The value is one of
103
+ # this script's own literals, never anything a caller supplied.
104
+ exec ${python} -c 'import os, sys, runpy
105
+ sys.path = [entry for entry in sys.path if entry not in ("", ".", os.getcwd())]
106
+ sys.path.insert(0, os.environ.pop("LUCIAZERO_AGENTD_PACKAGE"))
107
+ runpy.run_module("luciazero_agentd", run_name="__main__", alter_sys=True)' "$@"
package/bin/luciazero.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // npx luciazero discipline [options] -> local stats report
10
10
  // npx luciazero check-update [--json] -> explicit npm version check
11
11
  // npx luciazero update -> update detected classic installs
12
+ // npx luciazero bus status [--json] -> Agent Bus queue summary (beta)
12
13
  const { spawnSync } = require("node:child_process");
13
14
  const path = require("node:path");
14
15
 
@@ -20,6 +21,7 @@ const ROUTES = {
20
21
  discipline: { runtime: process.execPath, script: "bin/discipline-report.js" },
21
22
  "check-update": { runtime: process.execPath, script: "bin/update.js", args: ["check"] },
22
23
  update: { runtime: process.execPath, script: "bin/update.js", args: ["update"] },
24
+ bus: { runtime: process.execPath, script: "bin/bus.js" },
23
25
  };
24
26
 
25
27
  const args = process.argv.slice(2);
@@ -28,7 +30,7 @@ if (args[0] && !args[0].startsWith("-")) {
28
30
  if (!Object.prototype.hasOwnProperty.call(ROUTES, args[0])) {
29
31
  console.error(
30
32
  `luciazero: unknown command '${args[0]}' ` +
31
- "(install, codex, discipline, check-update, update, uninstall, uninstall-codex)"
33
+ "(install, codex, discipline, check-update, update, bus, uninstall, uninstall-codex)"
32
34
  );
33
35
  process.exit(64);
34
36
  }
package/install-codex.sh CHANGED
@@ -19,6 +19,9 @@ CODEX_DIR="${CODEX_HOME:-$HOME/.codex}"
19
19
  AGENTS_MD="${CODEX_DIR}/AGENTS.md"
20
20
  START='<!-- luciazero:start -->'
21
21
  END='<!-- luciazero:end -->'
22
+ # written inside the block, under the start marker, when the install had to add
23
+ # a final newline to the user's content to make room for that marker
24
+ ADDED_NL_MARK='<!-- luciazero:added-final-newline -->'
22
25
  MANAGED_DIR="${CODEX_DIR}/.luciazero-managed"
23
26
  BACKUP_DIR="${CODEX_DIR}/.luciazero-backups"
24
27
 
@@ -32,11 +35,18 @@ version_of() {
32
35
  "${SRC}/package.json" 2>/dev/null || true
33
36
  }
34
37
 
35
- # collision-proof backup path for $1 (two runs in the same second must not overwrite)
38
+ # A free backup name for $1. Two runs in the same second must not overwrite
39
+ # each other, and a name a symlink already holds is taken too: `-e` follows
40
+ # the name and answers false for a symlink whose target is missing, which
41
+ # would send the `cp` below straight through that symlink and out of the
42
+ # config directory. This is a check, not a reservation -- the name is still
43
+ # free to be taken between the test and the `cp` (roadmap R24). The
44
+ # uninstaller's settings backup reserves its name with `O_CREAT | O_EXCL`
45
+ # instead, which the shell has no portable equivalent for.
36
46
  bakpath() {
37
47
  B="$1.bak.$(date +%Y%m%d%H%M%S)"
38
48
  N=1
39
- while [ -e "${B}" ]; do B="$1.bak.$(date +%Y%m%d%H%M%S).${N}"; N=$((N+1)); done
49
+ while [ -e "${B}" ] || [ -L "${B}" ]; do B="$1.bak.$(date +%Y%m%d%H%M%S).${N}"; N=$((N+1)); done
40
50
  printf '%s' "${B}"
41
51
  }
42
52
 
@@ -90,26 +100,108 @@ remove_legacy_tree() {
90
100
  fi
91
101
  }
92
102
 
103
+ # Does $1 end in a newline? A last line without one is content like any other,
104
+ # and `awk` cannot pass it through: print terminates every record it writes, so
105
+ # a rewrite that goes through awk hands such a file back one byte longer.
106
+ ends_with_newline() {
107
+ [ -s "$1" ] && [ -z "$(tail -c 1 "$1")" ]
108
+ }
109
+
110
+ # Write $1 with its marker block removed and every other byte kept, including a
111
+ # last line that carries no newline.
112
+ #
113
+ # The newline directly above the start marker is removed with the block when
114
+ # the block says the installer put it there. The install has to: a start marker
115
+ # only counts on a line of its own, so a file whose last line was unterminated
116
+ # needs one before the block can be appended. That newline is the installer's,
117
+ # not the user's, and nothing in the finished file distinguishes it from a
118
+ # newline the user typed -- so the installer records it, on the line under the
119
+ # start marker, where the markers are its provenance exactly as they are the
120
+ # blank line's. The record is honoured only while the block is still the last
121
+ # thing in the file, which is where the install put it; a user who has moved
122
+ # the block since has moved that newline into the middle of their own text,
123
+ # where it is no longer provably ours and stays.
124
+ strip_marker_block() {
125
+ if ends_with_newline "$1"; then SMB_SRC_NL=1; else SMB_SRC_NL=0; fi
126
+ awk -v s="${START}" -v e="${END}" -v mark="${ADDED_NL_MARK}" -v srcnl="${SMB_SRC_NL}" '
127
+ $0==s {inblock=1; head=1; blockend=NR; next}
128
+ $0==e {inblock=0; blockend=NR; next}
129
+ inblock {if (head && $0==mark) added=1; head=0; blockend=NR; next}
130
+ {n++; keep[n]=$0; lastkept=NR}
131
+ END {
132
+ chop = (added && n > 0 && blockend == NR)
133
+ for (i = 1; i <= n; i++) {
134
+ printf "%s", keep[i]
135
+ if (i < n) printf "\n"
136
+ }
137
+ if (n > 0 && !chop && !(lastkept == NR && srcnl == 0)) printf "\n"
138
+ }
139
+ ' "$1"
140
+ }
141
+
142
+ # Exactly one well-formed marker pair, or none at all. Anything else — a start
143
+ # with no end, a second pair, a pair nested inside another — has no defined
144
+ # meaning, and the awk rewrites below would answer it by dropping whatever
145
+ # follows the opening marker. AGENTS.md is the user's file; an ambiguous one is
146
+ # left exactly as it is, down to the byte, rather than repaired by guesswork.
147
+ # Markers count only on a line of their own, which is what the rewrites match.
148
+ marker_block_ok() {
149
+ MB_FILE="$1"
150
+ [ -f "${MB_FILE}" ] || return 0
151
+ MB_S="$(grep -cxF "${START}" "${MB_FILE}" || true)"
152
+ MB_E="$(grep -cxF "${END}" "${MB_FILE}" || true)"
153
+ [ "${MB_S}" = 0 ] && [ "${MB_E}" = 0 ] && return 0
154
+ if [ "${MB_S}" = 1 ] && [ "${MB_E}" = 1 ]; then
155
+ MB_SL="$(grep -nxF "${START}" "${MB_FILE}" | cut -d: -f1)"
156
+ MB_EL="$(grep -nxF "${END}" "${MB_FILE}" | cut -d: -f1)"
157
+ [ "${MB_SL}" -lt "${MB_EL}" ] && return 0
158
+ fi
159
+ return 1
160
+ }
161
+
162
+ if ! marker_block_ok "${AGENTS_MD}"; then
163
+ echo "AGENTS.md carries ambiguous Luciazero markers; nothing was installed" >&2
164
+ echo " expected exactly one '${START}' ... '${END}' pair, on their own lines" >&2
165
+ echo " fix ${AGENTS_MD} and run this again" >&2
166
+ exit 1
167
+ fi
168
+
93
169
  echo "Installing into ${CODEX_DIR}"
94
170
  mkdir -p "${CODEX_DIR}/skills"
95
171
 
96
172
  # 1. doctrine as a marker block in global AGENTS.md (replaced in place on reinstall)
173
+ #
174
+ # This rewrite and the uninstaller's are exact inverses: it strips the marker
175
+ # block and nothing else, and appends the block back with no separator of its
176
+ # own. That is the whole of what makes a full cycle return AGENTS.md to its
177
+ # original bytes.
178
+ #
179
+ # It used to write a blank separator above the start marker, and then trim
180
+ # trailing blank lines here so that separator would not accumulate one line
181
+ # per reinstall. The trim could not tell a blank line this installer had added
182
+ # from one the user wrote, so it spent theirs to pay for ours: a file ending in
183
+ # no blank line came back from a cycle one line longer, and one ending in
184
+ # several came back shorter. The blank line that keeps the doctrine readable
185
+ # now lives INSIDE the block, under the start marker, where the markers are its
186
+ # provenance and the uninstaller takes it away without having to guess. The
187
+ # newline this installer has to add to an unterminated last line is recorded in
188
+ # the same place, for the same reason.
97
189
  TMP="$(mktemp)"
98
190
  if [ -f "${AGENTS_MD}" ]; then
99
191
  cp "${AGENTS_MD}" "$(bakpath "${AGENTS_MD}")"
100
- # strip the old block AND trailing blank lines, so reinstalls do not
101
- # accumulate one separator blank line per run
102
- awk -v s="${START}" -v e="${END}" '
103
- $0==s {inblock=1; next}
104
- $0==e {inblock=0; next}
105
- inblock {next}
106
- NF {for (i=0; i<blank; i++) print ""; blank=0; print; next}
107
- {blank++}
108
- ' "${AGENTS_MD}" > "${TMP}"
192
+ strip_marker_block "${AGENTS_MD}" > "${TMP}"
109
193
  fi
194
+ # A start marker counts only on a line of its own, so content whose last line
195
+ # has no newline needs one before the block can follow it. That newline is the
196
+ # only byte of the user's file this installer changes, and it is recorded under
197
+ # the start marker so the uninstall takes it back with the block.
198
+ if [ -s "${TMP}" ] && ! ends_with_newline "${TMP}"; then ADDED_NL=1; else ADDED_NL=0; fi
110
199
  {
111
- if [ -s "${TMP}" ]; then cat "${TMP}"; echo; fi
200
+ if [ -s "${TMP}" ]; then cat "${TMP}"; fi
201
+ if [ "${ADDED_NL}" = 1 ]; then printf '\n'; fi
112
202
  echo "${START}"
203
+ if [ "${ADDED_NL}" = 1 ]; then echo "${ADDED_NL_MARK}"; fi
204
+ echo
113
205
  cat "${SRC}/claude/luciazero.md"
114
206
  echo "${END}"
115
207
  } > "${AGENTS_MD}"