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/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
  **English** · [ภาษาไทย](https://github.com/ohm41321/luciazero/blob/main/README.th.md)
19
19
 
20
20
  <p align="center">
21
- <strong>11 skills</strong> · <strong>Relay fixture 6/6</strong> · <strong>Claude + Codex</strong> · <strong>MIT</strong>
21
+ <strong>13 skills</strong> · <strong>Relay fixture 6/6</strong> · <strong>Claude + Codex</strong> · <strong>MIT</strong>
22
22
  </p>
23
23
  <p align="center"><sub>Relay 6/6 is a mechanical protocol check; behavioral results are reported separately.</sub></p>
24
24
 
@@ -93,6 +93,51 @@ bash docs/assets/relay-demo.sh
93
93
 
94
94
  Both scripts use throwaway directories and exercise the real implementation.
95
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
+
96
141
  ## What it protects
97
142
 
98
143
  | Failure mode | Mechanism |
@@ -163,7 +208,7 @@ cannot set one.
163
208
  npx skills add ohm41321/luciazero
164
209
  ```
165
210
 
166
- This installs the 11 skills: no doctrine, reviewer, or hooks.
211
+ This installs the 13 skills: no doctrine, reviewer, or hooks.
167
212
 
168
213
  </details>
169
214
 
@@ -232,6 +277,8 @@ rest activate when their moment arrives.
232
277
  | Good and bad revisions are known | `/bisect` | Finds the first bad commit in a temporary worktree |
233
278
  | Before claiming completion | `/done` | Full verify, skeptic review, scope report |
234
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 |
235
282
  | Optimizing performance | `/experiment` | Baseline, threshold, controlled measurement |
236
283
  | Reviewing local verify habits | `/discipline-report` | Time/project-filtered local outcome report |
237
284
  | After difficult work | `/retro` | Stores reusable lessons and disproved approaches |
@@ -255,17 +302,23 @@ every repository or model.
255
302
 
256
303
  ### Claude results
257
304
 
258
- 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:
259
307
 
260
- | Claude model | Luciazero | Bare | Difference |
261
- |---|---:|---:|---:|
262
- | Haiku†, 10 valid/task | 36/60 (60%) | 27/60 (45%) | +15pp |
263
- | 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 |
264
313
 
265
314
  The `Luciazero` arm installs the classic pack without hooks; it is not a clean
266
- doctrine-only ablation. *Sonnet is preliminary because eight invalid rows leave
267
- several arms at four valid runs. The previously stated `+37pp` top-up is retired
268
- 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.
269
322
 
270
323
  †Model provenance is incomplete for Haiku: only 70/140 rows encode model
271
324
  identity. The other 70 are attributed at campaign-file/report level and
@@ -292,6 +345,12 @@ only one run per arm per task. See the [full benchmark](https://github.com/ohm41
292
345
  - Node.js 18+ for the CLI and discipline report.
293
346
  - Bash for classic installers; Python 3.9+ for hooks and Lucia Relay
294
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).
295
354
  - Core installers, hooks, helpers, and graders are offline. Real behavioral
296
355
  evals invoke a model CLI and consume API credit or subscription quota.
297
356
  - Hooks run commands on your machine. Read them before enabling them.
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}"