diff --git a/AGENTS.md b/AGENTS.md index d0677a6b..5c852edb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,11 @@ amendments — and `docs/README-lanes.md` is its manual. **The code is here; the data is not.** Every one of the four finds the register, the logs and the handoffs through `$AGENT_PROTOCOL_ROOT/workspace.yaml`'s `repository:` and `path:`, never from its own location on disk, and refuses with exit 1 naming -`openRepoTools wip init` where that file does not answer. Their exit codes are +`openRepoTools wip init` where that file does not answer — with ONE exception, +and it is a mechanism and not a taste: `lanes-edit.sh guard`, the +`UserPromptSubmit` name guard Amendment 12 adds, answers that same unreadable +workspace with **2**, because only a 2 blocks a prompt and a 1 would print the +refusal and let the work through it. Their exit codes are the protocol's and not this toolset's — 1 is *registry not found* there and *findings were printed* here — so never read a number without knowing which command produced it. diff --git a/README.md b/README.md index 034b2df2..88c24ff3 100644 --- a/README.md +++ b/README.md @@ -241,33 +241,41 @@ a symlink, and an install through one leaves the command uninstalled and writes these bytes into whatever it points at. Then an `11 of 11 placed in ` line, and the `export PATH=…` line if that directory is not on your `PATH`. -It also places **seven things that are not files in that directory**: TWO +It also places **eight things that are not files in that directory**: TWO SKILLS, `/lane-swap` and `/restart`, each at `${CLAUDE_PROFILES_HOME:-~/.claude-profiles}/shared/skills//SKILL.md` (one write every profile reads through its own symlink) and at `~/.claude/skills//SKILL.md` for a bare `claude` run outside the launcher; **one command file**, `/swap`, at that same pair of paths (`…/shared/commands/swap.md` and `~/.claude/commands/swap.md`), because -`opensoft/workBenches#74` deletes the launcher's copy; and **one merged entry** -under `hooks.SessionStart` in `~/.claude/settings.json`. That merge needs -`jq`, never writes the file whole, writes it back at mode 600, and is -idempotent by exact match on the entry's command string. That path must be a +`opensoft/workBenches#74` deletes the launcher's copy; and **two merged +entries** in `~/.claude/settings.json` — `lanes-edit.sh session-start` under +`hooks.SessionStart`, and `lanes-edit.sh guard` under `hooks.UserPromptSubmit`, +which is lane-collision-protocol Amendment 12's NAME GUARD: at every prompt it +checks that the tmux window, the session's own name and the register row are +one lane, and refuses the prompt when they are not. Those merges need +`jq`, never write the file whole, write it back at mode 600, and are +idempotent by exact match on each entry's command string. That path must be a REGULAR FILE: a symlink there — into a dotfiles checkout, say — is a refusal in the same planning phase, because `chmod` follows a link and the merge replaces what is at that path. The skills and the command file are 644 and that file 600 -on every run — including the run that finds the entry already there and changes -no byte of it. An entry that runs `session-start` with a DIFFERENT string — a -second writer of this very hook — a file it cannot parse, or a `hooks` that is -not an object → it **refuses, prints the exact block, and places nothing at -all**, because the merge is computed with the eleven files in hand before any -of them is placed. An installer that repairs a file it does not understand is -how you lose a setting you meant. It never writes a profile's own -`settings.json`: the launcher owns that one. - -Eighteen artifacts, and the count is the invariant. It was sixteen until A11 +on every run — including the run that finds both entries already there and +changes no byte of them. An entry that runs +`session-start`, or one that runs `guard` as a whole argument, with a DIFFERENT +string — a second writer of one of these hooks — a +file it cannot parse, or a `hooks` that is not an object → it **refuses, prints +the exact block, and places nothing at all**, because both merges are computed +with the eleven files in hand before either is placed. An installer that +repairs a file it does not understand is how you lose a setting you meant. Your +own `UserPromptSubmit` hooks are left exactly where they are, which is why that +arm keys on the VERB and not on the word anywhere in a path. It +never writes a profile's own `settings.json`: the launcher owns that one. + +Nineteen artifacts, and the count is the invariant. It was sixteen until A11 Addendum 4 ruling 9 gave `--install` a command-file list and `commands/swap.md` in it, at the same pair of paths a skill takes — because `opensoft/workBenches#74` -deletes the launcher's copy and `/swap` would otherwise be installed by nobody. +deletes the launcher's copy and `/swap` would otherwise be installed by nobody; +and eighteen until Amendment 12 adoption act 3 added the name guard's entry. Run from a checkout it copies the files beside it and needs no network and no `gh` at all; run from stdin, as above, it fetches all of them at the same ref. diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 01ffec30..3a969e7d 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -1488,6 +1488,228 @@ not a workstation, a row on a workstation that does not exist is a row no reader can match, and the log is never rewritten. `lanes-edit.sh` already carries six such lines in one lane's log, and they stay where they are. +## The name guard and the lock (Amendment 12) + +**A lane session works only while three names are one**, and otherwise the +prompt is refused. Ratified by Brett Heap 2026-09-13T18:20:44Z — *"Ratify +revision 2"*, with M1 *"Type /rename into the pane"* — on his ruling of the same +day, verbatim: *"we need the session name and the lane name always the same. we +need a gaurd on this and is must stop and refuse to work if they are not the +same"*. + +The three: + +1. the tmux **WINDOW** it runs in is named for a lane; +2. the **SESSION's own name** — the live record's `name` — is that same name; +3. the register **ROW** keyed by that name records this session's id as the + **LAST** id of its session cell (Amendment 6(b)). + +`lanes-edit.sh guard` is the `UserPromptSubmit` hook that reads them. Where they +agree it is **silent and exits 0**; where they do not it **exits 2**, which is +what blocks a prompt, and prints the triple as it stands and the ONE command +that cures it, filled in. It is the only subcommand in that file that refuses a +person's work. + +```console +$ # what the person sees on a blocked prompt +lanes-edit: THE NAME GUARD REFUSES THIS PROMPT (lane-collision-protocol Amendment 12). The three names: +lanes-edit: window eagle:@12 'claude' +lanes-edit: session d1ac715c-… 'openRepoTools-3' (nameSource user) pid 1924546 profile team-05f +lanes-edit: row this window names no lane, so no row is keyed by it +lanes-edit: this window is named 'claude', which is no lane, while the SESSION is named for lane openRepoTools-3 — … +lanes-edit: run: lane-start --no-launch openRepoTools 3 +``` + +### The table — one row per state, one cure each + +Every message prints the register's own spelling of the lane (Amendment 15) and +every command is filled in, never ` `. + +| what it finds | what it does | +|---|---| +| the window is not a lane, the session name parses as `-` | `lane-start --no-launch ` — **the 2026-09-10 case**, which ran for three days unrecorded | +| neither name is a lane | refuses and says so: WHICH lane this work is is yours to name, and a guard that guessed would bind a window to a row nobody chose | +| the window is a lane, this uuid is the row's last id, the session name is not a lane name | **THE LOCK RENAMES IT** (below) | +| … and the session name differs from the row's **only by case** | the lock renames it to the ROW's spelling — a lane name is ONE name under any case (Amendment 15) | +| … and the session name is ANOTHER lane's, set by a **person** after this window's binding | **THE OFFER** (below) | +| the window is a lane, this uuid is IN the cell but not last | a SUPERSEDED transcript: exit, `lane-start `, which resumes the id the row ends on | +| the window is a lane, this uuid is in NO row | the harness minted a transcript with nobody acting: `lane-start --no-launch `, the recording act, no relaunch | +| the window's name matches TWO rows differing only by case | refuses naming both spellings and the merge, which is a person's act (Amendment 15(d)) | +| not inside tmux at all | refuses: a lane RUNS in a tmux window named for it (Rule 4, Amendment 2) | + +### The lock + +Under the projects root the session name is **not the person's to set freely; it +is the lane's** (clause (h), Brett Heap's D5). `lane-start` sets it at every +launch (`--name "$LANE"` on all three branches), and where it drifts to a +non-lane word the guard **renames it for you**: it types `/rename ` into +this session's own tmux pane, which is the only path a running session's name +has (M1 — the docs name `--name` at launch, `/rename`, and `Ctrl+R` in the +picker, and nothing else). That one prompt is refused so the rename lands first; +send it again. + +The pane's **current command is asked first** and nothing is typed into a pane +running anything else — `claude` and nothing else, which is M1's own word: +`/rename openRepoTools-3` typed at a shell is a command that does not exist, +typed into an editor it is text nobody wrote, and typed into a Node REPL it is +input somebody has to clear. A pane whose command cannot be read is not typed +into either — fail closed. Every outcome but a successful typing prints the line +for you to type yourself, which is the cure and not a failure: on Eagle today +two panes report `bash` (a session under a launcher wrapper), and those are +exactly the windows where you will read it. + +**Which pane**: the one the live record names, and where the record names none +— the harness has been seen to write no `tmux` field for a process plainly in a +window — the pane this hook is itself running in, `tmux display-message -p +'#{pane_id}'`. Both hooks run inside the session's own pane, so that is the same +pane, asked of tmux rather than guessed. + +A ` (N)` title is a **mismatch** (ratified decision D4) and the suffix is +evidence: it is exactly what a rename into a title something else still holds +mints, so another holder of that name was live. The note names +`lanes-edit.sh forks ` and `lane-end --retire ` — never a +`kill`, because stopping a process is the person's act and stays theirs +(Amendment 11 clause (k) rule (e)). + +### The offer + +*"if the user does a rename, then we should offer to move to that lane or create +a new lane if we do not have one as that name"* (D5, verbatim). A record whose +`nameSource` is `user`, whose `name` is another lane's, and whose `nameSince` is +later than this window's binding is a person saying **"this is that lane now"** +— so the guard **asks** rather than guessing: + +```console +lanes-edit: you renamed this session to openRepoShape-2 — lane openRepoShape-2 EXISTS, last session 8c31…. +lanes-edit: Reply `yes` to move this window to it (creating the lane if there is none: `lane-start --no-launch openRepoShape 2` is run for you, this window is renamed, the row created or this uuid appended, and openRepoTools-3 is marked MOVED). +lanes-edit: Reply `no` to stay openRepoTools-3 — the session is renamed back with `/rename openRepoTools-3`. +``` + +The answer is the **next prompt**; it is consumed by the guard and never reaches +the model, and anything but `yes` or `no` asks again. The pending offer is held +per session id under `$CLAUDE_CONFIG_DIR/lanes/offers/` and expires with +the session. **An answer is a prompt**, so it waits behind the duplicate read +below: while a second live process carries this transcript the answer is +refused with that, and the offer is kept for the first prompt after the other +process is retired — a `yes` consumed there would run `lane-start` out of a +process that may not be writing at all, on a question the other one could +equally have answered. + +**A lane named before Rule 4's `-` form is offered, not run.** The +register carries 22 of them, and `lane-start` needs such a lane's DIRECTORY, +which nothing in the register, the window or the session says. So the offer +says what it cannot fill in and `yes` refuses rather than running a command with +a `` placeholder in it: the move is +`lane-start --no-launch --dir `, yours to run with +the path filled in, and the offer is kept so that `no` still answers it. On `yes` the **window is renamed first** and that order is +load-bearing: `lane-start`'s step 3b veto 2 refuses to take the session live in +a window named for another lane, which after a `yes` is exactly what this window +is — without the rename the lane would be started and stamped with this +transcript left out of its session cell. + +**And the LAST write of the `yes` is the guard's own**, because `lane-start` +may not make it: its step 3b **veto 1** never takes a uuid that belongs to +ANOTHER row (Amendment 11 clause (d) rule 1), and after the rename this uuid +still belongs to the lane being left. So `lane-start` mints a fresh id for the +new lane and the person's own transcript stays out of the cell — and the next +prompt then finds a lane window whose row does not name this transcript and +refuses, with a cure that vetoes for the same reason and changes nothing. A +blocking hook that refuses for ever, on a state it created by obeying the +person, is the worst outcome this surface has, so after `lane-start` returns +the guard appends this uuid to the new lane's cell itself — with `lane-start`'s +own anchor discipline, the PUBLISHED last id, so a stale copy of the row +refuses rather than writing a cell that no longer matches. That is not a hole +in veto 1: the veto exists for the take nobody asked for, and clause (h) rule +4's limit is that the lock never moves a uuid between rows **without the +person's `yes`** — here there is one, answered at this very prompt. + +### The duplicate read (Amendment 18(h)) + +**A transcript is held by ONE live process.** The harness can fork one +(`--fork-session`, minting a new id in a background pty host) or resume one +twice, and the tooling refuses to build on either. Measured four times on +2026-09-14, the last at 18:14Z: a `bg` record in one profile's `sessions/` +beside the interactive record in ANOTHER's, both live, both carrying one +`sessionId` — so the sweep is of **every** profile's directory, not the asking +session's. + +The one live record this read passes over in silence is the harness's own +**companion**: `kind: bg`, beside this window's own record, in the same +profile's `sessions/` (Amendment 8, ruling (g) — *"records that share a +`sessionId` are one session, not a queue of rival holders"*). The **kind** is +what tells it from a second live process, not the profile: one profile can +resume one transcript twice — no move, no swap, one command — and the +interactive record that makes is 18(h)'s own case. + +`lanes-edit.sh transcript-holders ` is the read, and three surfaces share +it rather than implementing the rule three times: + +* **the guard** refuses every prompt in a process that shares its id with + another live one, naming the pid, its window or `bg`, its profile, and the + retire act `lane-end --retire `; +* **`lane-start`** counts the holders of the id it is about to resume or bind — + at the window-binding step and in the row-resume branch — and refuses rather + than appending such an id to the row or launching a second resume of it; +* **`lane-end --retire `** retires a duplicate whose id **IS** the + row's own, which `forks` cannot see because its criterion is "an id the row + does NOT record". A duplicate is named by its **pid** and never by a uuid — + its uuid is the row's own and could not pick between the processes carrying + it; the uuid form is for a fork. The window's own session is refused **by + name**: retiring the session that IS the lane is how a lane loses the + conversation it is, and ending the lane is the bare `lane-end `. The + harness's own **companion** of that session is refused by name too: it is one + half of the live session, not a rival to it. + +A window is matched by **id AND session name** (Amendment 11(h)'s agreement +rule) because tmux reuses window ids once a window is gone. + +### The `SessionStart` block's own line (clause (f)) + +The `SessionStart` hook gains one line — `session name '' was not the lane +'' — renamed` — and types the rename itself, so the first prompt already +finds the three agreeing. It types over DRIFT only: a name a PERSON set to +another lane's, newer than this window's binding, is clause (h) rule 2's +instruction and this hook leaves it alone, says so, and lets the guard put the +offer at the next prompt — otherwise a rename followed by a `/clear` would be +undone before anyone was asked. It is that hook's **one act on a pane and its only act +of any kind**: it stays read-only against the register and **always exits 0**, +because a hook that fails is a hook that breaks the session it was meant to +orient (R-A8-1). Where another live process carries this session id it **SAYS +so** and names the retire act; the refusal is the guard's. + +### Fail closed, and the one bypass + +A mismatch refuses, and so does an **indeterminate read** — session records +unreadable, tmux not answering, the register unreadable, a payload naming no +`cwd` — naming the read, because a triple that cannot be verified is not a +triple that agrees (clause (d)). The row is read from `origin/` as this +checkout last had it (R19) and **the guard never fetches**: this hook runs at +every prompt, and putting the network there would be R-A8-1's objection several +times over. + +**There is no environment flag that turns it off.** `claude --safe-mode` runs +with every hook disabled and is the one bypass — deliberate, visible in the +prompt box, and **a session started that way is not a lane session**: it may not +write the register or claim an object. The hook's `timeout 5` is the other way +it can fail open, and it is the amendment's own number: a hook that exceeds its +timeout is killed, and a killed hook does not exit 2. + +### Scope + +The guard applies to every session whose `cwd` is under `$PROJECTS_ROOT` +(default `~/projects`), which is every estate session, and is **silent +elsewhere** (D1): a name there is a title and nothing more. A **subagent's** +prompt — hook input carrying `agent_id` — is exempt, because subagents do not +submit prompts and the lane that runs them has already passed. + +Installing it is `openRepoTools --install`'s: the entry +`~/projects/xFactory/lanes-edit.sh guard`, timeout 5, under +`hooks.UserPromptSubmit` in `~/.claude/settings.json`, beside the `SessionStart` +one. It carries **no `|| true`** — on `SessionStart` that suffix is the whole +safety property, and here it would be the opposite of the mechanism, since +`cmd || true` exits 0 for every code the command can produce and a guard that +refuses nothing silently is the exact state this amendment exists to end. + ## Hand edits After **any** hand edit made with an allowed tool (python read/write, `sed -i @@ -1585,7 +1807,7 @@ link-estates # repoints ~/projects/xFac ``` **You are not asked to remember it: `--install` refuses** (A9 Addendum 4, -R-A9-12). In its planning phase, before any of the eighteen artifacts is placed, +R-A9-12). In its planning phase, before any of the nineteen artifacts is placed, it walks all eleven targets and dies naming every one that is not a regular file, what it is, and the one `rm` that clears them. `cp` FOLLOWS A SYMLINK, so an install over these would leave the two commands UNINSTALLED — the targets stay diff --git a/lane-end b/lane-end index e1a08b31..f04efd53 100755 --- a/lane-end +++ b/lane-end @@ -23,7 +23,8 @@ # # USAGE # lane-end [options] # lane-end openRepoShape-2 -# lane-end --retire # retire a live FORK; ends nothing +# lane-end --retire # retire a live FORK (pid or uuid) or a +# # DUPLICATE (pid); ends nothing # lane-end [options] # the split form works too # lane-end [options] # = basename of the cwd's git root # @@ -51,6 +52,27 @@ # not a live fork of this lane, and on a `forks` read # that failed — a read that failed is not "it is not a # fork". `lanes-edit.sh forks ` names them all. +# AND SINCE AMENDMENT 18(h) IT TAKES A SECOND POPULATION +# (opensoft/openRepoTools#39): a live DUPLICATE of the +# row's OWN id — a second process carrying the transcript +# the row records, which `forks` cannot see, because its +# criterion is "an id the row does NOT record" and +# `lane-start` had just appended that id to the cell. The +# lane's OWN session, in its own window, is refused by +# name: retiring the session that IS the lane is how a +# lane loses the conversation it is, and ending the lane +# is the bare form above, and the harness's OWN COMPANION +# of that session — the `kind: bg` record beside it in the +# same profile, Amendment 8 ruling (g) — is refused by +# name too: it is one half of the live session and not a +# rival to it. +# A DUPLICATE IS NAMED BY ITS PID and never by a uuid, +# because its uuid IS the row's own and could not pick +# between the processes carrying it; the uuid form above +# is for a FORK, whose id is one the row does not record. +# `lanes-edit.sh transcript-holders ` lists every +# live process carrying one transcript, with its window, +# its kind, its profile and which of the three it is. # --state-was the exact text --retire should replace, for a row whose # state cell does not open with a single state word # --force end the lane even though its object log still shows @@ -598,7 +620,17 @@ if [ -n "$retire_target" ]; then fk_rows="$("$LANES_EDIT" forks "$LANE" 2>/dev/null)" || fk_rc=$? case "$fk_rc" in 0) : ;; - 8) die "no fork of lane $LANE's transcript is live on this workstation, so there is nothing to retire. A fork is found by its transcript's TITLE under an id the row does not record (ratified decision 8(e)); \`$LANES_EDIT forks $LANE\` is the read that names them." 8 ;; + # AMENDMENT 18(h) / opensoft/openRepoTools#39 — AN 8 IS NO LONGER THE END OF + # THIS ACT. `forks` finds a fork by its transcript's TITLE under an id THE + # ROW DOES NOT RECORD, and on 2026-09-14 that criterion made the very + # process the launcher had just named invisible: a `--fork-session` whose id + # `lane-start --no-launch` had since appended to the row's session cell was + # no longer "an id the row does not record", so this act answered *"nothing + # to retire"* about a second live writer of the lane's own transcript and + # the retire had to be a bare `kill`, outside the tooling. So an 8 falls + # through to the duplicate read below, and only the two of them together + # refuse. + 8) fk_rows="" ;; *) die "$LANES_EDIT forks $LANE failed (exit $fk_rc). That is NOT 'no fork of it is live', and this script will not name an act for a fork it could not prove is one. Run it by hand to see what it says, then re-run." 1 ;; esac @@ -614,13 +646,149 @@ if [ -n "$retire_target" ]; then $fk_rows FORKS + # ---------------- A DUPLICATE OF THE ROW'S OWN ID (opensoft/openRepoTools#39) + # + # THE SECOND POPULATION, AND THE ONE THE FIRST BUILD OF THIS ACT COULD NOT + # SEE. Amendment 6(d) allows exactly one live conversation per lane and + # Amendment 18(h) sharpens it to one live PROCESS per transcript; `forks` + # answers about ids the row does NOT record, and this answers about the id it + # DOES. Measured 2026-09-14T12:05–12:30Z on lane openRepoTools-3: a harness + # `--fork-session` in a background pty host, whose record sat in ANOTHER + # profile's `sessions/` directory, beside the interactive resume of the same + # transcript in a tmux window — three live processes on one file at the peak. + # + # THE WINDOW'S OWN SESSION IS THE ONE IT REFUSES TO RETIRE, and it is read out + # of the same rows rather than out of a second helper and a second fetch: the + # record that is an interactive session in a window tmux still resolves — by + # `<@id>` AND session name, Amendment 11(h)'s agreement rule, which + # `transcript-holders` owns — is the lane's session. Every OTHER live process + # carrying that id is the duplicate. + dup_pid=""; dup_kind=""; dup_where=""; dup_prof="" + dup_own=""; dup_rows=""; dup_id="" + dup_comp=""; dup_comp_kind=""; dup_comp_prof="" if [ -z "$fk_sid" ]; then - note "the live forks of lane $LANE's transcript are:" - printf '%s\n' "$fk_rows" | while IFS="$(printf '\t')" read -r fr_sid fr_pid fr_kind fr_cwd; do - [ -n "${fr_sid:-}" ] || continue - note " $fr_sid pid $fr_pid ${fr_kind:-interactive} $fr_cwd" - done - die "'$retire_target' is neither the session id nor the pid of a live fork of lane $LANE. Naming an act against something that is not a fork of this lane is how the wrong conversation gets retitled and the wrong process gets stopped, so nothing is named. Pick one of the ids or pids above." 2 + du_rc=0 + dup_id="$("$LANES_EDIT" last-session "$LANE" 2>/dev/null)" || du_rc=$? + case "$du_rc" in + 0 | 8) : ;; + *) die "$LANES_EDIT last-session $LANE exited $du_rc, so the id the row records could not be read and whether '$retire_target' is a DUPLICATE of it is not known. That is not 'it is not one', and this script will not name an act against a process it could not prove is one." 1 ;; + esac + if [ "$du_rc" = 0 ] && [ -n "$dup_id" ]; then + dh_rc=0 + dup_rows="$("$LANES_EDIT" transcript-holders "$dup_id" 2>/dev/null)" || dh_rc=$? + case "$dh_rc" in + 0 | 8) : ;; + 2) note "this \`lanes-edit.sh\` has no \`transcript-holders\` (exit 2), so a duplicate of lane $LANE's own transcript cannot be looked for here. Install the one beside this script (\`openRepoTools --install\`)." ;; + *) die "$LANES_EDIT transcript-holders $dup_id exited $dh_rc, so whether '$retire_target' is a live duplicate of lane $LANE's own transcript could NOT be read — which is not 'it is not one'." 1 ;; + esac + if [ "$dh_rc" = 0 ]; then + while IFS=$'\037' read -r dr_pid dr_kind dr_tgt dr_prof dr_where dr_verdict dr_file; do + [ -n "${dr_pid:-}" ] || continue + # A WINDOW THAT COULD NOT BE ASKED ABOUT IS UNKNOWN AND NOT DEAD + # (Amendment 18(b)): `(not asked)` — no tmux here, or a record with + # no `<@id>` in it — PROTECTS the process, and only a `(gone)` this + # server actually refuted makes it retirable. + case "$dr_where" in + "window "*"(gone)") dr_own=0 ;; + "window "*) dr_own=1 ;; + *) dr_own=0 ;; + esac + if [ "$dr_own" = 1 ] && [ "$dr_kind" != bg ]; then + dup_own="${dup_own:+$dup_own }$dr_pid" + fi + if [ "$dr_pid" = "$retire_target" ]; then + # THE HARNESS'S OWN COMPANION IS NOT A DUPLICATE AND MAY NOT BE + # NAMED AS ONE. `transcript_holders` marks it `companion` — the + # `kind: bg` record beside THIS window's own record of this id, + # Amendment 8 ruling (g)'s *"records that share a `sessionId` are + # one session, not a queue of rival holders"* — and the guard and + # `lane-start` both pass it over in silence. Selecting it here would + # have this act print Amendment 6(d)'s ending for one half of a live + # session that IS the lane (Copilot round 2 on this PR). It is held + # separately so the refusal below can say WHICH thing the pid is, + # rather than falling through to "neither" and naming nothing. + if [ "$dr_verdict" = companion ]; then + dup_comp="$dr_pid"; dup_comp_kind="${dr_kind:-interactive}" + dup_comp_prof="$dr_prof" + else + dup_pid="$dr_pid"; dup_kind="${dr_kind:-interactive}" + dup_where="$dr_where"; dup_prof="$dr_prof" + fi + fi + done <<<"$dup_rows" + fi + fi + fi + + # THE COMPANION IS REFUSED BY NAME TOO, and before the rest, because it is + # the one pid a person can reach for here that is neither a duplicate nor the + # lane's own session: it is the OTHER HALF of the session that is. Ending it + # is not Amendment 6(d)'s act on anything — 6(d) ends an idle background + # session still holding a lane NAME, and this one holds the live session's own + # id, which ruling (g) reads as one session and not as a rival. + if [ -z "$fk_sid" ] && [ -z "$dup_pid" ] && [ -n "$dup_comp" ]; then + die "pid $dup_comp is the HARNESS'S OWN COMPANION of lane $LANE's live session (kind ${dup_comp_kind:-bg}; profile ${dup_comp_prof:-unknown}; the same transcript ${dup_id:-unknown} and the same profile as this window's own record of it). Amendment 8 ruling (g): \"records that share a \`sessionId\` are one session, not a queue of rival holders, and there is no contest between them to win\" — so it is not a duplicate of anything, and ending it would end one half of the session that IS this lane. Nothing is named. To retire a real duplicate pass ITS pid (\`$LANES_EDIT transcript-holders $dup_id\` lists every live process carrying this transcript and says which is which); to end the lane, run: $prog $LANE" 2 + fi + + # THE WINDOW'S OWN SESSION IS REFUSED BY NAME, and the refusal names the act + # that DOES end a lane, because an operator who typed this meant one of the + # two and there is no third thing to mean. + if [ -z "$fk_sid" ] && [ -n "$dup_pid" ]; then + case " $dup_own " in + *" $dup_pid "*) + die "pid $dup_pid IS lane $LANE's own live session, in its own window ($dup_where) — not a duplicate of it. Retiring the session that IS the lane is how a lane loses the conversation it is, so nothing is named. To end the lane, run: $prog $LANE · to retire a duplicate, pass ITS pid (\`$LANES_EDIT transcript-holders $dup_id\` lists every live process carrying this transcript)." 2 ;; + esac + fi + + if [ -z "$fk_sid" ] && [ -n "$dup_pid" ]; then + fk_today="$(date -u +%Y-%m-%d)" + step "pid $dup_pid IS a live DUPLICATE of lane $LANE's own transcript $dup_id — proved, and NOTHING has been written or stopped" + note " $dup_where kind $dup_kind profile $dup_prof" + note "A transcript is held by ONE live process (Amendment 18(h), ratified 2026-09-14): two of them append to one file, each overwriting what the other wrote. The lane's own session is${dup_own:+ pid $dup_own} in its window." + case "$dup_kind" in + bg) + note "Amendment 6(d)'s retire act for a BACKGROUND holder: \"an idle background session still holding a lane name is ended\". It has no prompt to type into, so the retitle below is not available to it." + note "Ending it is YOUR act and no tool's (Amendment 8(f) refuses it by name, and A11 clause (k) rule (e) repeats the refusal for this surface): the pid above is the process." ;; + *) + note "Amendment 6(d)'s retire act, typed IN THAT SESSION — there is no API to rename one from outside it:" + note " /rename $LANE · retired $fk_today" ;; + esac + note "Until it is retired, every surface goes on naming it — which is true, because it is still a second live writer of this lane's transcript until one of the two acts above is taken." + note "The lane itself is untouched: this ended nothing, wrote nothing and killed nothing." + if (( dry_run )); then + note "--dry-run changes nothing here: this act writes nothing on any path, so the run above IS the whole of it." + fi + exit 0 + fi + + if [ -z "$fk_sid" ]; then + if [ -n "$fk_rows" ]; then + note "the live forks of lane $LANE's transcript are:" + printf '%s\n' "$fk_rows" | while IFS="$(printf '\t')" read -r fr_sid fr_pid fr_kind fr_cwd; do + [ -n "${fr_sid:-}" ] || continue + note " $fr_sid pid $fr_pid ${fr_kind:-interactive} $fr_cwd" + done + else + note "no fork of lane $LANE's transcript is live on this workstation. A fork is found by its transcript's TITLE under an id the row does not record (ratified decision 8(e)); \`$LANES_EDIT forks $LANE\` is the read that names them." + fi + if [ -n "$dup_rows" ]; then + note "and the live processes carrying lane $LANE's OWN transcript ${dup_id:-(none recorded)} are:" + printf '%s\n' "$dup_rows" | while IFS=$'\037' read -r dr_pid dr_kind dr_tgt dr_prof dr_where dr_verdict dr_file; do + [ -n "${dr_pid:-}" ] || continue + note " pid $dr_pid ${dr_kind:-interactive} $dr_where profile $dr_prof" + done + else + note "and no second live process carries lane $LANE's own transcript ${dup_id:-(none recorded)} either (Amendment 18(h))." + fi + # 8 IS KEPT FOR "THERE IS NOTHING OF EITHER KIND", which is the code this + # act has answered since it was written and the one a caller reads as "no + # fork is live". The 2 is for a target that is not one of the things that + # ARE there — a stranger's pid, another lane's uuid — and the two were one + # code until this round gave the act a second population to look in. + if [ -z "$fk_rows" ] && [ -z "$dup_rows" ]; then + die "nothing of either kind is live for lane $LANE on this workstation, so there is nothing to retire: no FORK of its transcript (an id the row does not record, ratified decision 8(e)) and no live DUPLICATE of the id it does (Amendment 18(h))." 8 + fi + die "'$retire_target' is neither the session id nor the pid of a live fork of lane $LANE, nor the pid of a live duplicate of its own transcript. Naming an act against something that is neither is how the wrong conversation gets retitled and the wrong process gets stopped, so nothing is named. Pick one of the ids or pids above." 2 fi # AMENDMENT 6(d), FILLED IN — and it is TWO acts because 6(d) is two, one per diff --git a/lane-start b/lane-start index 12b5e75d..8785f9ae 100755 --- a/lane-start +++ b/lane-start @@ -1369,6 +1369,88 @@ row_sid="" # means the records went unreadable between two calls a second apart, and the # honest response is to say so and carry on rather than to refuse a lane its # start. +# ------------------- AMENDMENT 18(h): ONE LIVE PROCESS PER TRANSCRIPT +# +# *"`lane-start` — every launch branch and `--no-launch`'s window-binding step — +# counts the live records carrying the id it is about to resume or bind, across +# every profile's `sessions/`, and REFUSES when a process other than this +# window's holds it, naming the pid(s), their windows or `bg`, and the retire +# act; it never appends such an id to the row and never launches a second resume +# of it."* Ratified 2026-09-14T13:15:18Z as revision 4 of Amendment 18; +# opensoft/openRepoTools#39. +# +# MEASURED, AND THAT IS WHY IT IS A REFUSAL AND NOT A NOTE. On 2026-09-14 at +# 12:05Z the harness spawned `claude --session-id d1ac715c… --fork-session` under +# a `bg-pty-host`; at 12:07Z the launcher NAMED it as a defect; at 12:10Z a +# `lane-start --no-launch openRepoTools 3` in a new window bound THAT SAME +# transcript as the row's session, seeing nothing; at 12:25Z a second `pclaude` +# resumed it interactively. Three live processes, one transcript file, and the +# retire act could not see any of them BECAUSE the row now recorded the id. +# The count below is the read that was missing, and it is asked of the id this +# run is about to write down or resume — never of the lane. +# +# IT FAILS CLOSED, AND THE THREE CODES ARE THE CONTRACT: 0 with rows, 8 with +# none, 64 a usage error, anything else a read that did not happen. A 2 is a +# `lanes-edit.sh` that predates this read — the un-upgraded workstation — and +# THAT one falls through with a note, exactly as clause (h)'s other reads do, +# because refusing every lane start on every workstation that has not installed +# this yet is not a safety property. +dup_check() { # + local dc_id="${1-}" dc_why="${2-}" dc_rows="" dc_rc=0 dc_other="" + local dc_pid dc_kind dc_tgt dc_prof dc_where dc_verdict dc_file + [ -n "$dc_id" ] || return 0 + : > "$LS_TMP" + dc_rows="$("$LANES_EDIT" transcript-holders "$dc_id" 2>"$LS_TMP")" || dc_rc=$? + case "$dc_rc" in + 0 | 8) : ;; + 2) [ -s "$LS_TMP" ] && cat -- "$LS_TMP" >&2 + note "this \`lanes-edit.sh\` has no \`transcript-holders\` (exit 2), so whether another live process already carries $dc_id is NOT known here. Install the one beside this script (\`openRepoTools --install\`); carrying on." + return 0 ;; + *) [ -s "$LS_TMP" ] && cat -- "$LS_TMP" >&2 + die "$LANES_EDIT transcript-holders $dc_id exited $dc_rc, so whether another live process already carries that transcript could NOT be read — and that is not 'no other process does'. A transcript is held by ONE live process (Amendment 18(h)); nothing has been renamed, written or launched." 1 ;; + esac + [ "$dc_rc" = 0 ] || return 0 + while IFS=$'\037' read -r dc_pid dc_kind dc_tgt dc_prof dc_where dc_verdict dc_file; do + [ -n "${dc_pid:-}" ] || continue + # `unrelated` BLOCKS HERE AND NOWHERE ELSE, and the difference is the + # question being asked. `transcript_holders` answers "who holds this + # transcript, and what is this window's relation to them", and its + # `unrelated` means THIS WINDOW CARRIES NO RECORD OF IT AT ALL — kept + # silent there so that a rule about a TRANSCRIPT cannot overturn + # `live_holder`'s verdicts about a LANE, or `forks`, from a read that was + # asked about neither. THE QUESTION HERE IS 18(h)'s OWN and is narrower: + # *"the id it is about to resume or bind"*. The ROW-RESUME branch reaches + # this call only when `row_sid != win_sid`, so this window has no record of + # that id and EVERY live holder of it comes back `unrelated` — counting + # only `duplicate` made that branch's check one that could never fire + # (Copilot round 1 on opensoft/openRepoTools#52). At the window-binding call + # the id IS this window's own session, so a `here` record exists and nothing + # is `unrelated`; one rule, two call sites, no second definition of a + # duplicate. + # + # AND IT DOES NOT DECIDE WHETHER THE LANE IS TAKEN. An ORPHAN — a live but + # idle holder of the row's id — is still reported and never refused as a + # rival (Amendment 8(f), through `live_holder`'s own verdict, printed above + # this with 6(d)'s act), and a `bg` companion still holds no lane. What + # 18(h) adds is about the FILE: this branch's next act is `claude --resume + # `, and the clause says *"it never appends such an id to the row + # and never launches a second resume of it"*. 8(f)'s own sentence does not + # reach that state either — it rules on *"a row's EARLIER session ids … + # still held by live but idle sessions"*, and this is the id the row ENDS + # on, which is the one a resume opens. + case "$dc_verdict" in + duplicate | unrelated) : ;; + *) continue ;; + esac + dc_other="${dc_other:+$dc_other, }pid $dc_pid ($dc_where, kind $dc_kind, profile $dc_prof)" + done <<<"$dc_rows" + [ -n "$dc_other" ] || return 0 + note "another live process already carries transcript $dc_id: $dc_other" + note "A transcript is held by ONE live process (Amendment 18(h)): two of them append to one file, each overwriting what the other wrote, and a row that records it has two processes answering for one lane. This run was about to $dc_why, and it has done NOTHING — no window renamed, no id appended, no session launched." + die "retire the other one first — \`lane-end\` proves it is this lane's duplicate and prints Amendment 6(d)'s act for it, and it ends, writes and kills nothing: + lane-end $LANE --retire " 1 +} + win_sid=""; win_target=""; win_name=""; win_pid=""; win_profile="" : > "$LS_TMP" win_rec=""; ws_rc=0 @@ -1383,6 +1465,12 @@ case "$ws_rc" in note "$LANES_EDIT window-session $this_window failed (exit $ws_rc) — carrying on without it. This read only ever ADDS the harness's new transcript uuid to the cell, and live-holder has already read the same records; if the cell is behind, stamp it yourself." ;; esac +# AMENDMENT 18(h), THE WINDOW-BINDING STEP. Asked BEFORE the vetoes and before +# anything is taken: an id a second live process already carries is one this run +# must neither append to a cell nor resume, whatever the vetoes below decide +# about whose it is. +[ -n "$win_sid" ] && dup_check "$win_sid" "bind $win_sid as lane $LANE's session" + # The id the cell does not carry. `$session_ids` is the PUBLISHED set (step 3), # so an id only this checkout knows is not treated as recorded either. # @@ -1706,6 +1794,17 @@ lane_sid="" # and there is no API to rename a running one from inside. new_sid="" if [ -n "$row_sid" ] && rec_file="$(recorded_transcript "$row_sid")"; then + # AMENDMENT 18(h), THE LAUNCH BRANCHES, AND THIS IS THE ONLY ONE WITH AN ID TO + # COUNT. The clause is *"the id it is about to resume or bind"*: this branch is + # the one that resumes an id, and it is asked INSIDE the branch rather than + # above the `if`, because above it the row's id is merely a candidate — the + # test on its own next line is what decides whether anything resumes it at all, + # and counting the holders of a transcript nobody is about to open would refuse + # a lane for a fact about a conversation it is not going to touch. The + # title-fallback branch resumes through the PICKER, whose transcript is not + # knowable here, and the new-session branch MINTS a fresh uuid; neither has an + # id, which is stated rather than left to be noticed. + [ "$row_sid" != "$win_sid" ] && dup_check "$row_sid" "resume transcript $row_sid as lane $LANE" step "the row's current session is $row_sid and its transcript is here ($(basename -- "$rec_file")) — resuming it by id" (( holder_here )) && step " (it is the session live in this window; --resume attaches to the same conversation)" cmd=("$CLAUDE_BIN" --name "$LANE" --resume "$row_sid") diff --git a/lanes-edit.sh b/lanes-edit.sh index ed9a8722..df5f22e3 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -70,6 +70,28 @@ # lanes-edit.sh swapped [] # lanes-edit.sh session-start # the SessionStart hook; JSON on stdin # +# AMENDMENT 12 — the name guard and the lock (the blocking hook) +# lanes-edit.sh guard # the UserPromptSubmit hook; JSON on stdin +# +# `guard` is the ONLY verb here that refuses a person's work. It reads the +# hook's JSON, takes the WINDOW from tmux, the SESSION's own name from the +# live record and the ROW from `origin/`, and where the three are not +# one name it REFUSES THE PROMPT (exit 2) with the triple and the one command +# that cures it. THE LOCK types `/rename ` into this pane where the name +# has merely drifted; a PERSON's rename to another lane is an OFFER, answered +# `yes` or `no` at the next prompt. It is silent and cheap where the three +# agree, silent outside `$PROJECTS_ROOT` and silent for a subagent's prompt. +# +# AMENDMENT 18(h) — one live process per transcript +# lanes-edit.sh transcript-holders # every live process carrying it +# +# A transcript is held by ONE live process; the harness can fork one or resume +# one twice. This read is what `lane-start` asks before it binds or resumes an +# id, what `lane-end --retire ` proves a duplicate with, and what +# `guard` refuses a prompt on. It sweeps EVERY profile's `sessions/`, because a +# duplicate's record sits in another profile's — measured four times on +# 2026-09-14. +# # A SWAP is a planned stop — a usage reset, a profile switch — and a RESTART # is the launch that follows it. The RECORD a swap leaves is not a new file: # it is the lane's own `PAUSED` line, payload @@ -161,9 +183,10 @@ # 0 done # 1 environment (no register, no writer) # 2 refusal: bad arguments, the object is held, an unknown alias, a -# checkout that cannot be rebased, or — Amendment 15 — a register holding +# checkout that cannot be rebased, — Amendment 15 — a register holding # two rows whose lane names differ only by case, which every writer and -# `canon-lane` refuse until 15(d)'s merge. FIVE MEANINGS ON ONE NUMBER, and they +# `canon-lane` refuse until 15(d)'s merge, or AMENDMENT 12's BLOCKED +# PROMPT. SIX MEANINGS ON ONE NUMBER, and they # stay readable only because of where each can occur: bad arguments to a # subcommand that predates Amendment 11; `register-row`'s "not lane-shaped"; # AN UNKNOWN SUBCOMMAND (the `*)` arm below), which is how a caller detects @@ -171,6 +194,11 @@ # Amendment 11's container refusal. The last is on WRITERS ONLY (the # dispatcher guard), so no READ can return it and clause (h)'s # fall-to-the-next-rung rule is unaffected by it. +# THE SIXTH IS NOT THIS FILE'S NUMBER AT ALL: 2 is what a `UserPromptSubmit` +# hook must exit to BLOCK a prompt (code.claude.com/docs/en/hooks), so +# `guard` spends the one number the harness reads, and every refusal it +# makes — a mismatch, an unreadable read, a pending offer's question and +# its answer — is that 2. `guard` never exits anything else but 0. # 64 usage — a caller's bad arguments to one of the reads Amendment 11 added # (`window-lane`, `lane-dir`, `lane-profile`, `last-session`, `forks`, # `workstation`, `fetch-age`, `lanes`, `session-lane`, and `swapped`, which @@ -206,7 +234,8 @@ # them holds it; `swapped` found no lane swapped on the workstation; # `window-session` found no live session in the window. A read that could # not be performed is never 8 (R22). `session-start` never exits 8, or -# anything but 0: it is a hook. +# anything but 0: it is a hook. `guard` never exits 8 either — it is a hook +# too, and a BLOCKING one, so its two codes are 0 and 2 (see 2 above). # # --no-sweep (DEFAULT, added 2026-09-09 after 0d84d34/a1f2438 swept another # lane's uncommitted hand edit into an unrelated commit): every mutating @@ -2978,6 +3007,13 @@ row_cell() { printf '%s\n' "$1" | awk -F'|' -v i="$2" '{print $i}'; } # the same question asked for one answer and no refusal — it is the hook's read # and the hook never refuses — so the two are one parse in two shapes rather # than two rules. +# +# AND A THIRD SHAPE SINCE AMENDMENT 12: the prompt guard asks this one because +# it COUNTS. `canon_lane` below answers a caller that wants a name back and +# refuses two with `note` + 2 in this file's own voice; the guard has to print +# its triple FIRST and then say what is wrong, in its own shape, so it takes the +# spellings from here and makes the refusal itself. One parse, three readings, +# and no second rule about what a lane name is. rows_named_ci() { # register_text | awk -v want="$1" ' substr($0,1,1) == "|" { @@ -5046,12 +5082,25 @@ session_start_block() { # the row's token — so `$ssb_lane` is canonical from here down, which is what # makes `lane_start_args` below hand a reader the commands filled in with the # spelling their window and their session are about to be named. - # AMENDMENT 12'S GUARD AND LOCK PLUG IN HERE (opensoft/openRepoTools#25, not - # built): the lock renames a session whose name differs from `$ssb_lane` only - # by case, with a `/rename` to that spelling. + # AMENDMENT 12'S GUARD AND LOCK PLUG IN HERE, and since #25 they ARE built — + # `ssb_name_line` immediately below is clause (f), and the lock it types is + # the same `guard_type` the `UserPromptSubmit` guard uses. A session whose + # name differs from `$ssb_lane` only by case is renamed to the ROW's spelling + # like any other drift, which is what makes this line and Amendment 15 one + # rule rather than two. ssb_lane="" [ -n "$ssb_win" ] && ssb_lane="$(lane_named_ci "$ssb_win" 2>/dev/null || :)" [ -n "$ssb_lane" ] || [ -z "$ssb_id" ] || ssb_lane="$(lane_of_session "$ssb_id" 2>/dev/null || :)" + # AMENDMENT 12 CLAUSE (f) AND AMENDMENT 18(h)'s READ — the name line and the + # duplicate line, printed HERE rather than inside each of the three branches + # below, because both are facts about this session and not about which of them + # applies. `|| :` for the reason the whole block has one: this hook never + # fails and always exits 0 (R-A8-1). + # THE SUBSHELL IS THE `|| :` MADE TRUE OF MORE THAN A RETURN — see + # `ssb_name_line`'s own header. It is the one line of this block that reads + # every profile's session records, so it is the one most able to meet + # something this hook may not die on. + ( ssb_name_line "$ssb_id" "$ssb_lane" ) || : if [ -z "$ssb_lane" ]; then # THE ONE PLACE THE PLACEHOLDER IS RIGHT: no lane is known here, so there is # nothing to fill in. It is also the estate's ONE no-lane notice — the @@ -5180,6 +5229,1083 @@ EOF return 0 } +# ============================================================================ +# AMENDMENT 12 — THE NAME GUARD AND THE LOCK, WITH AMENDMENT 18(h)'S ONE READ +# ============================================================================ +# +# **A LANE SESSION WORKS ONLY WHILE THREE NAMES ARE ONE** — the tmux WINDOW it +# runs in, the SESSION's own name, and the register ROW whose session cell ends +# on this transcript — **and otherwise the prompt is REFUSED** (exit 2), with +# the triple as it stands and the ONE command that cures it, filled in. Ratified +# 2026-09-13T18:20:44Z, "Ratify revision 2", with M1 "Type /rename into the +# pane"; `brettheap/new-workstation#22`/#23, opensoft/openRepoTools#25. +# +# WHY THE RULE NEEDED AN ENFORCEMENT. Amendment 2 has said since 2026-09-05 that +# a lane is its window's name and its session's name; nothing refused anything +# for eight days, and between 2026-09-10 and 2026-09-12 this very lane ran as +# `openRepoTools-3`, `-4`, … `-14` — a fresh title per `/resume`, in a window +# still named `claude`, in profiles that changed at every usage reset — and no +# row was ever written. `session_start_block` above says which lane a window is +# and "ALWAYS exits 0"; `window_session` "decides nothing". This is the surface +# that decides. +# +# THE LOCK (clause (h), Brett Heap's D5). Under the projects root the session +# name is not the person's to set freely; it is the lane's. The tooling sets it +# at every launch (`lane-start --name "$LANE"`, clause (c)) and again whenever it +# drifts — by TYPING `/rename ` into the session's own tmux pane, which is +# the only mechanism a running session has (M1, "Type /rename into the pane"): +# the docs name `--name` at launch, `/rename`, and `Ctrl+R` in the picker, and +# nothing else — no hook output field, no environment variable, no settings key. +# A PERSON'S rename to another lane's name is not drift but an INSTRUCTION, and +# it is OFFERED back before it is obeyed: yes moves this window to that lane, no +# renames the session back. +# +# FAIL CLOSED (clause (d)). A mismatch refuses; so does an INDETERMINATE read — +# session records unreadable, tmux not answering, the register unreadable — +# naming the read, because a triple that cannot be verified is not a triple that +# agrees. There is no environment flag that turns this off: `claude --safe-mode` +# disables every hook and is the one bypass, deliberate and visible in the +# prompt box, and a session started that way is not a lane session and may not +# write the register or claim an object. +# +# WHAT IT COSTS, AND THE ONE WAY IT FAILS OPEN. Measured on Eagle 2026-09-14 +# against 329 session records and a 1.3 MB register: the register read is ~2 s +# wall and the `grep -l` over every profile's records ~0.4 s, so a refusal lands +# inside the ratified `timeout 5`. A hook that EXCEEDS its timeout is killed, and +# a killed hook does not exit 2 — so the timeout is the one path on which this +# guard fails open. It is the amendment's own number and is left at it; the +# cheap tests are made first so that the two reads happen only where they decide +# something. +# +# IT NEVER FETCHES. `session-start`'s R-A8-1 argument applies here several times +# over — that hook runs once per session and this one runs at EVERY PROMPT — so +# the row is read from `origin/` AS THIS CHECKOUT LAST HAD IT (R19, the +# same `register_text` every other state read takes) and the network is never +# opened. "The register not fetchable" (clause (d)) is therefore the READ +# failing, and that refuses. + +# A LANE NAME IS `-` (THE LANE RULE), and this is the predicate +# for it: `check_lane_name`'s character fence, plus a LAST `-` followed by +# digits. It agrees with `lane_start_args` by construction — that function +# prints `--dir ` for exactly the names this refuses — and the two +# exist separately because one answers a question and the other builds a command +# line. A name the register has a row for is a lane whatever its shape +# (`browser-ui-repair` is one); this asks only whether the two arguments of +# `lane-start` can be read OUT of a name nobody has a row for yet, which is what +# rows 1 and 6 of the table below need. +lane_shaped() { # + lsh_n="${1-}" + case "$lsh_n" in ''|*[!A-Za-z0-9._-]*|.*|-*) return 1 ;; esac + case "$lsh_n" in *-*) : ;; *) return 1 ;; esac + lsh_pos="${lsh_n##*-}" + case "$lsh_pos" in ''|*[!0-9]*) return 1 ;; esac + return 0 +} + +# CLAUSE (e)'s SCOPE, AND IT IS ASKED OF BOTH SPELLINGS OF A PATH. The guard +# applies to every session whose `cwd` is under the projects root and is silent +# elsewhere (D1, "Projects root only"). On a launcher-configured workstation +# `~/projects` is a SYMLINK — every profile's is one link to +# `~/.claude-profiles/state/opensoft/projects` — and the harness reports a `cwd` +# that has already been resolved through it, so a literal prefix test answers +# "outside the projects root" about every estate session there is. Both the +# typed path and its `pwd -P` are compared, and a match on either is inside: +# the direction that errs toward the guard APPLYING is the safe one, because +# the other direction is the guard never firing at all. +under_projects_root() { # + upr_c="${1-}"; upr_r="${2-}" + [ -n "$upr_c" ] && [ -n "$upr_r" ] || return 1 + case "$upr_c/" in "$upr_r"/*) return 0 ;; esac + upr_cr="$(cd -- "$upr_c" 2>/dev/null && pwd -P)" || upr_cr="" + upr_rr="$(cd -- "$upr_r" 2>/dev/null && pwd -P)" || upr_rr="" + [ -n "$upr_cr" ] && [ -n "$upr_rr" ] || return 1 + case "$upr_cr/" in "$upr_rr"/*) return 0 ;; esac + return 1 +} + +# THE WINDOW A RECORD NAMES, UNDER AMENDMENT 11(h)'s AGREEMENT RULE. A record's +# `tmux` is `:<@id>.<%pane>`, and tmux REUSES window ids once a window +# is gone — measured 2026-09-14, when a stale record's `@1` resolved to a window +# of another session. So the id alone is not the window: the id must resolve +# HERE and the session it resolves in must be the one the record wrote down. +# 0 the window exists and agrees +# 1 the id resolves nowhere here, or resolves in another tmux session +# 8 there is nothing to test — no target on the record, no `@id` in it, or no +# tmux to ask (a record with no window is not thereby a record nowhere) +record_window_state() { # + rws_t="${1-}" + [ -n "$rws_t" ] && [ "$rws_t" != none ] || return 8 + rws_ref="${rws_t%.*}" + rws_id="${rws_ref##*:}" + rws_sess="${rws_ref%:*}" + case "$rws_id" in @*) : ;; *) return 8 ;; esac + command -v tmux >/dev/null 2>&1 || return 8 + rws_now="$(tmux_window_field "$rws_id" '#{session_name}' 2>/dev/null || :)" + [ -n "$rws_now" ] || return 1 + [ "$rws_now" = "$rws_sess" ] || return 1 + return 0 +} + +# ------------------------------------- AMENDMENT 18(h): ONE LIVE PROCESS +# +# *"A transcript (one session id) is held by ONE live process. The harness can +# fork one (`--fork-session`, minting a new id in a background pty host) or +# resume one twice, and the tooling refuses to build on either."* Ratified +# 2026-09-14T13:15:18Z as revision 4 of Amendment 18; +# `brettheap/new-workstation#34`, opensoft/openRepoTools#39. +# +# MEASURED FOUR TIMES ON 2026-09-14, the fourth at 18:14Z: a `bg` record in ONE +# profile's `sessions/` and an interactive record in ANOTHER's, both live, both +# carrying one `sessionId` — which is why this sweeps EVERY profile's directory +# and not the asking session's. On a launcher-configured workstation every +# profile's `projects/` is one shared directory, so a transcript needs no move +# to be resumed under another profile and a duplicate is one `--resume` away. +# +# THREE CALLERS, ONE IMPLEMENTATION, for the reason clause (h) already gives +# `window-lane`: `lane-start` refuses to bind or resume an id another process +# holds, `lane-end --retire ` retires that process, and the prompt guard +# refuses every prompt inside it. A rule implemented three times is a rule three +# surfaces come to disagree about. +# +# IT IS BUILT ON A `grep -l`, NOT ON `session_records`. This runs at every prompt +# under a five-second timeout, and the one-pass parse is a per-process CACHE that +# the first caller pays for in full — 329 records here. The question asked is +# about ONE id the harness has already handed the hook, so the cheap shape is the +# one `live_holder` uses: one grep for the id, then a parse of the one or two +# files that match. Measured at ~0.4 s against those 329. +# +# +# +# `where` is for a person to read — `window :<@id>`, the same with +# ` (gone)` where the agreement rule above refuses it, or `bg`. +# +# THE VERDICT IS THE WHOLE OF THE JUDGEMENT, and it has four values because +# THREE IN-FORCE RULES MEET HERE AND NONE OF THEM MAY BE OVERTURNED BY THIS ONE: +# +# here the record is THIS window's own process — its `tmux` names this +# window, or its pid is this pane's or below it: `record_is_here`'s +# TIERS 2 AND 3, and its tier 1 is left out because it cannot +# discriminate here. That tier asks whether the record's +# `sessionId` is `$CLAUDE_CODE_SESSION_ID`, and every candidate in +# this set carries the very id being tested — so on the asking +# session's own transcript it would answer `here` for the +# duplicate too, which is the one reading this function exists to +# prevent. +# companion not this window's, in the SAME profile's `sessions/` as this +# window's own record of this id, AND NOT A SESSION RECORD. +# AMENDMENT 8, RULING (g) names exactly one such thing: *"Beside +# the interactive process in the pane the harness runs a companion: +# `kind: bg`, no `tmux`, no `nameSource`, its own pid, and THE SAME +# `sessionId` … Records that share a `sessionId` are one session, +# not a queue of rival holders, and there is no contest between +# them to win."* The harness writes a session's own records under +# that session's own `$CLAUDE_CONFIG_DIR`, so same-profile is what +# "the harness's own second record of one session" looks like — +# and the `kind` is what tells that record apart from a SECOND +# INTERACTIVE PROCESS in the same directory, which is one +# transcript resumed twice and is 18(h)'s own case rather than the +# harness's companion. The profile alone read a second `--resume` +# UNDER ONE PROFILE as benign (Copilot round 1 on this PR), and +# that is the cheapest way there is to make two live processes on +# one transcript: no move, no swap, one command. +# THE KIND IS `bg` AND NOTHING ELSE IS READ AS ONE. `not a session +# record` was the first spelling of this test and it was too wide +# by exactly the kinds nobody has ruled on: this suite's own +# interactive fork carries `kind: user` +# (`tests/test_lane_helpers.sh`, Amendment 11 clause (k)), and any +# kind a later harness invents would have arrived here as the +# harness's benign companion (Copilot round 2 on this PR). Ruling +# (g) names ONE shape to pass over, so that shape is what this +# matches; every other live record on this id — known kind or not, +# and a record too old to carry a `kind` at all, which pass 1 +# writes down as `interactive` — is a duplicate, which is the +# closed direction. +# duplicate EVERY OTHER live record carrying this id: a session record in +# another window of this same profile, or any record at all in +# ANOTHER profile's `sessions/` beside this window's own. That is +# AMENDMENT 18(h)'s second live process. The MEASURED shape was the +# second one — four times on 2026-09-14, the last at 18:14Z, the +# second record sat in another profile's directory, and 18(h) says +# why, *"on a launcher-configured workstation every profile's +# `projects/` is one shared directory … which is also why a +# duplicate is one `--resume` away"* — but the rule it states is +# one live PROCESS per transcript, not one per profile. +# unrelated THIS WINDOW CARRIES NO RECORD OF THIS TRANSCRIPT AT ALL, so this +# read says nothing about the processes that do. That is not +# timidity: `live_holder` answers "is any session this row names +# alive" and Amendment 8(f) rules an ORPHAN reported and never +# refused, while `lane_forks` answers about a fork. A rule that +# counted every live record here would overturn both from a read +# that was not asked about either. ONE CALLER TAKES IT AS A +# BLOCKER ANYWAY AND SAYS SO WHERE IT DOES: `lane-start`'s +# `dup_check`, asked about the ONE id a run is about to resume, +# where an `unrelated` holder is a second live process on the very +# file that run is about to open (Amendment 18(h), *"never +# launches a second resume of it"*). That is a narrower question +# than this verdict answers, so the narrowing lives at that call +# site and not in this table. +# +# 0 with rows, 8 with none, 1 where the records could not be read, 64 no uuid. +transcript_holders() { # + th_id="${1-}"; [ -n "$th_id" ] || return 64 + th_files=""; th_match=""; th_err=""; th_f=""; th_blob="" + th_pid=""; th_kind=""; th_tgt=""; th_where=""; th_verdict=""; th_out="" + # `th_here_prof` AND `th_here` AMONG THEM, and the omission was not cosmetic: + # this file runs under `set -u` (line 292), pass 2's `[ -z "$th_here_prof" ]` + # is reached for the FIRST record that is not this window's, and an unset + # variable there exits the shell with `unbound variable` — a 1 every caller + # reads as "the records could not be read" and refuses on. It cost the suite's + # `the same lane live in THIS window is not a collision`, which asks + # `lane-start` for an exit 0 and got `dup_check`'s fail-closed 1. + th_here=""; th_here_prof=""; th_prof=""; th_wrc=0; th_final="" + SESSION_FILES_ERR="" + here_context + th_files="$(mktemp "${TMPDIR:-/tmp}/lanes-edit-tf.XXXXXX" 2>/dev/null || printf '')" + th_match="$(mktemp "${TMPDIR:-/tmp}/lanes-edit-tm.XXXXXX" 2>/dev/null || printf '')" + th_err="$(mktemp "${TMPDIR:-/tmp}/lanes-edit-te.XXXXXX" 2>/dev/null || printf '')" + if [ -z "$th_files" ] || [ -z "$th_match" ] || [ -z "$th_err" ]; then + rm -f -- "$th_files" "$th_match" "$th_err" 2>/dev/null || : + SESSION_FILES_ERR="could not create a temporary file under ${TMPDIR:-/tmp}" + return 1 + fi + # NOT `$( )`: session_files sets SESSION_FILES_ERR, and a subshell would keep + # the reason for the failure to itself. + if ! session_files > "$th_files"; then + rm -f -- "$th_files" "$th_match" "$th_err" + return 1 + fi + if [ ! -s "$th_files" ]; then + rm -f -- "$th_files" "$th_match" "$th_err" + return 8 + fi + # The locale and the NUL separator for the reason `live_holder` states above: + # these are arbitrary bytes and BSD `tr` prints nothing at all for a multibyte + # character in a UTF-8 locale. + LC_ALL=C tr '\n' '\0' < "$th_files" | xargs -0 grep -l -F -e "\"sessionId\":\"$th_id\"" > "$th_match" 2>"$th_err" || : + if [ -s "$th_err" ]; then + SESSION_FILES_ERR="$(LC_ALL=C tr '\n' ';' < "$th_err" | LC_ALL=C cut -c1-300)" + rm -f -- "$th_files" "$th_match" "$th_err" + return 1 + fi + rm -f -- "$th_files" "$th_err" + while IFS= read -r th_f; do + [ -n "$th_f" ] || continue + th_blob="$(cat -- "$th_f" 2>/dev/null || :)" + [ -n "$th_blob" ] || continue + # The id is matched EXACTLY and not by the grep alone: a record naming this + # uuid inside some other field would otherwise be counted as a holder. + [ "$(lc "$(jstr "$th_blob" sessionId)")" = "$(lc "$th_id")" ] || continue + record_is_live "$th_blob" || continue + th_pid="$(jnum "$th_blob" pid)" + th_kind="$(jstr "$th_blob" kind)" + th_tgt="$(jstr "$th_blob" tmux)" + th_where=bg + if record_fields_are_session "$th_kind" && [ -n "$th_tgt" ]; then + th_wrc=0 + record_window_state "$th_tgt" || th_wrc=$? + case "$th_wrc" in + 0) th_where="window ${th_tgt%.*}" ;; + 1) th_where="window ${th_tgt%.*} (gone)" ;; + *) th_where="window ${th_tgt%.*} (not asked)" ;; + esac + elif record_fields_are_session "$th_kind"; then + th_where="no window" + fi + th_here=no + if [ -n "$th_tgt" ] && [ -n "$LANES_THIS_WINDOW" ] && [ "${th_tgt%.*}" = "$LANES_THIS_WINDOW" ]; then + th_here=yes + elif pid_under "$th_pid" "$LANES_PANE_PID"; then + th_here=yes + fi + th_prof="$(record_profile "$th_f")" + [ "$th_here" = yes ] && [ -z "$th_here_prof" ] && th_here_prof="$th_prof" + th_out="${th_out}${th_pid:-unknown}${US}${th_kind:-interactive}${US}${th_tgt:-none}${US}${th_prof}${US}${th_where}${US}${th_here}${US}${th_f} +" + done < "$th_match" + rm -f -- "$th_match" + [ -n "$th_out" ] || return 8 + # PASS 2 — THE VERDICT, WHICH NEEDS THE WHOLE SET AND SO CANNOT BE MADE ABOVE. + th_final="" + while IFS="$US" read -r tv_pid tv_kind tv_tgt tv_prof tv_where tv_here tv_file; do + [ -n "${tv_pid:-}" ] || continue + if [ "$tv_here" = yes ]; then + tv_v=here + elif [ -z "$th_here_prof" ]; then + tv_v=unrelated + elif [ "$tv_prof" = "$th_here_prof" ] && [ "$tv_kind" = bg ]; then + tv_v=companion + else + tv_v=duplicate + fi + th_final="${th_final}${tv_pid}${US}${tv_kind}${US}${tv_tgt}${US}${tv_prof}${US}${tv_where}${US}${tv_v}${US}${tv_file} +" + done <` like every other state +# read. Clause (h) rule 2 compares a record's `nameSince` against it: a rename +# OLDER than the binding is a title this window inherited, not an instruction +# somebody has just given. 0 with the stamp, 8 where the log names none, 1 where +# it could not be read. +lane_binding_utc() { # + lbu_lines=""; lbu_rc=0 + lbu_lines="$(lane_log_events "$1")" || lbu_rc=$? + [ "$lbu_rc" = 0 ] || return 1 + lbu_v="$(printf '%s\n' "$lbu_lines" | awk -F"$US" '$3 == "STARTED" || $3 == "RESUMED" { v = $1 } END { if (v != "") print v }')" + [ -n "$lbu_v" ] || return 8 + printf '%s\n' "$lbu_v" +} + +# ------------------------------------------------- (h)'s ONE ACT ON A PANE +# +# M1, ratified: *"the guard and the `SessionStart` hook rename by TYPING +# `/rename ` into the session's own tmux pane with `tmux send-keys`: the +# only path that exists, run from inside the pane, only after the prompt has +# been refused and only while the pane's current command is `claude`."* +# +# THE PANE'S CURRENT COMMAND IS ASKED FIRST, and a pane that is running anything +# else is NOT typed into: `/rename openRepoTools-3` typed at a shell is a command +# that does not exist, and typed into an editor it is text somebody did not +# write. +# +# `claude` AND NOTHING ELSE, which is M1's own word — *"only while the pane's +# current command is `claude`"*. This test read `claude|node` for one round, on +# the reasoning that the harness is a node program and this format reports the +# process rather than the wrapper; MEASURED on Eagle 2026-09-14T23:4xZ, across +# the twelve panes tmux had, it is not: eleven lane panes report `claude`, two +# report `bash` (a pane whose session is under a launcher wrapper) and one +# `zsh`, and no pane reports `node` at all. So `node` bought nothing here and +# would have let a stray Node REPL in a pane take `/rename ` as input +# (Copilot round 4 on this PR). A pane this refuses is not a lock that failed: +# the caller prints the line for the person to type, which is the cure — and on +# this estate today the `bash` panes are exactly where they will read it. +# +# 0 typed +# 1 no tmux, or `send-keys` itself refused +# 8 the pane's current command could not be read — fail closed, type nothing +# 9 the pane is running something else +guard_type() { # + gt_pane="${1-}"; gt_text="${2-}" + [ -n "$gt_text" ] || return 1 + command -v tmux >/dev/null 2>&1 || return 1 + # THE RECORD'S PANE, ELSE THIS ONE — AND THIS ONE IS THE RIGHT FALLBACK + # BECAUSE OF WHERE THIS CODE RUNS. The pane comes from the live record's + # `tmux` field, and the harness has been seen to write NO `tmux` at all for a + # process plainly in a window (Amendment 8, ruling (g)'s third fact) — a + # record `transcript_holders` and `record_is_here` still place HERE, by the + # pane's own process tree. With an empty target the lock used to print "tmux + # would not take the keys" and type nothing, for a session whose name it could + # have fixed (Copilot round 2 on this PR). Both callers of this function run + # INSIDE the session's own pane — a `UserPromptSubmit` hook and a + # `SessionStart` hook do — so `#{pane_id}` with no `-t` is that session's + # pane, asked of tmux rather than guessed, and M1's condition below is still + # asked of whatever pane this resolves to. + [ -n "$gt_pane" ] || gt_pane="$(tmux display-message -p '#{pane_id}' 2>/dev/null || :)" + [ -n "$gt_pane" ] || return 1 + gt_cmd="$(tmux_window_field "$gt_pane" '#{pane_current_command}' 2>/dev/null || :)" + [ -n "$gt_cmd" ] || return 8 + case "$gt_cmd" in claude) : ;; *) return 9 ;; esac + tmux send-keys -t "$gt_pane" "$gt_text" Enter 2>/dev/null || return 1 + return 0 +} + +# THE PENDING OFFER, held per session id and expiring with the session (clause +# (h) rule 2). It is a file and not a variable because the next prompt is a +# different process: `$CLAUDE_CONFIG_DIR/lanes/offers/`, the harness's own +# per-profile configuration directory, which is where a fact about one session +# belongs and is not the register. +guard_offer_file() { # + printf '%s/lanes/offers/%s\n' "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" "${1-}" +} + +# `lane-start`, found the way every word in this toolset finds its siblings: +# beside this file first — `--install` places them in one directory — then on +# PATH. One resolution, so a workstation with two copies never reads one and +# runs the other. `$LANES_LANE_START` is the seam the suite sets. +# +# NAMED AS A SIBLING OF THIS FILE AND NOT OF ANY PARTICULAR WORD, because +# Amendment 18 Addendum 2 (in force 2026-09-14T16:50:32Z) takes `restart` off a +# person's PATH altogether — "i think we can drop restart as a cli command and +# keep it inside a claude session with /restart … lane does all the things a +# user wants" — and opensoft/openRepoTools#43 removes the file. A comment that +# pointed at it for its resolution order would name a file that is going. +guard_lane_start() { + if [ -n "${LANES_LANE_START:-}" ]; then printf '%s\n' "$LANES_LANE_START"; return 0; fi + if [ -x "$SCRIPT_DIR/lane-start" ]; then printf '%s\n' "$SCRIPT_DIR/lane-start"; return 0; fi + command -v lane-start 2>/dev/null || printf '' +} + +# ------------------------------------------------------- the triple, printed +# +# EVERY REFUSAL PRINTS THE THREE NAMES AS THEY STAND, and prints them ONCE +# however many things are wrong. What a person sees is the state and then the +# one command, which is F-B6's rule — filled in, never ` `. +G_WINREF=""; G_WINNAME=""; G_ID=""; G_NAME=""; G_SRC=""; G_PID=""; G_PROF="" +G_ROW=""; G_LANE=""; G_SES_LANE="" +GUARD_TRIPLE_DONE=0 +guard_triple() { + [ "$GUARD_TRIPLE_DONE" = 0 ] || return 0 + GUARD_TRIPLE_DONE=1 + gt_s="${G_ID:-unknown} '${G_NAME:-none}'" + [ -n "$G_SRC" ] && gt_s="$gt_s (nameSource $G_SRC)" + [ -n "$G_PID" ] && gt_s="$gt_s pid $G_PID" + [ -n "$G_PROF" ] && gt_s="$gt_s profile $G_PROF" + note "THE NAME GUARD REFUSES THIS PROMPT (lane-collision-protocol Amendment 12). The three names:" + note " window ${G_WINREF:-not in tmux} '${G_WINNAME:-none}'" + note " session $gt_s" + note " row ${G_ROW:-not read}" + return 0 +} + +# THE ONE BYPASS, NAMED WHERE A PERSON COULD OTHERWISE BE STUCK (clause (d)). +# There is no environment flag that turns this guard off, deliberately; Claude's +# own `--safe-mode` disables every hook and is visible in the prompt box, and a +# session started that way is not a lane session and may not write the register +# or claim an object. It is printed by the INDETERMINATE refusals — the reads +# that could not be made — and not by the mismatches, which have a cure of their +# own one line above. +guard_bypass() { + printf '%s' "If this workstation cannot answer at all, \`claude --safe-mode\` runs with every hook off — and a session started that way is NOT a lane session: it may not write the register or claim an object." +} + +# [] — the shape of every row of the (b) +# table: the triple, one sentence saying what is wrong, and one line to type. +guard_refuse() { + guard_triple + note "$1" + [ -n "${2-}" ] && note "run: $2" + return 2 +} + +# ============================== `guard` ============================== +# +# THE (b) TABLE, ONE ROW PER STATE AND ONE CURE EACH, in the amendment's own +# order. Every message prints the register's own spelling of the lane +# (Amendment 15) and every command is filled in. +# +# the window is not a lane, the session name parses as `-` +# -> `lane-start --no-launch ` THE 2026-09-10 CASE +# the window is not a lane, and neither is the session name +# -> `lane-start ` for the lane this work is — the guard cannot +# name it +# the window is a lane, this uuid is the row's LAST id, the session name is +# not a lane name (untitled, a fallback, ` (N)`, any word that does not +# parse) -> THE LOCK RENAMES IT, and says so +# … the session name is ANOTHER lane's and a PERSON set it after the binding +# -> THE OFFER (D5) +# the window is a lane, this uuid is IN the cell but not last +# -> Amendment 8's cure: exit, `lane-start ` +# the window is a lane, this uuid is in NO row +# -> Amendment 8's cure: `lane-start --no-launch ` +# the window is a lane whose name matches TWO rows, or the register cannot be +# read -> refuse naming the read +# not inside tmux at all -> refuse (D2): a lane runs in a tmux window +# named for it +# +# AND THE AGREEING CASE IS SILENT AND COSTS NOTHING, which is the property that +# makes a hook at every prompt bearable at all. +guard_run() { # + gr_json="${1-}" + + # ---- (e) SCOPE, and the two exemptions are the cheapest tests there are. + # + # A SUBAGENT IS EXEMPT: subagents do not submit prompts, and the lane that + # runs them has already passed this guard. The hook input carries `agent_id` + # for one and nothing else does. + [ -z "$(jstr "$gr_json" agent_id)" ] || return 0 + # OUTSIDE THE PROJECTS ROOT THE GUARD IS SILENT (D1). A name there is a title + # and nothing more. INPUT THAT NAMES NO `cwd` AT ALL is not "outside": it is a + # read that did not happen, and clause (d) refuses those. + gr_cwd="$(jstr "$gr_json" cwd)" + if [ -z "$gr_cwd" ]; then + guard_refuse "the hook input carried no \`cwd\`, so whether this session is under the projects root — the whole of clause (e)'s scope — could not be read, and an indeterminate read refuses (Amendment 12(d)). $(guard_bypass)" + return 2 + fi + under_projects_root "$gr_cwd" "${PROJECTS_ROOT:-$HOME/projects}" || return 0 + + G_ID="$(lc "$(jstr "$gr_json" session_id)")" + gr_prompt="$(jstr "$gr_json" prompt)" + + # ---- THE WORKSPACE. Every other subcommand dies 1 here (the dispatcher's + # own guard, which this verb is exempt from); this one refuses with 2, + # because 1 does not block a prompt and clause (d) is fail CLOSED: a register + # that cannot be located is not a register that agrees. + if [ -z "$LANES_REPO" ] || [ -z "$LANES_DIR" ] || [ ! -f "$LANES_FILE" ]; then + guard_refuse "the ROW cannot be read, so the triple cannot be verified — and a triple that cannot be verified is not a triple that agrees (Amendment 12(d)). $(lanes_workspace_why) $(guard_bypass)" + return 2 + fi + + # ---- (b)'s LAST ROW: NOT INSIDE TMUX AT ALL (D2, "Refuse"). + here_context + if [ -z "$LANES_THIS_WINDOW" ]; then + if [ -z "${TMUX:-}" ]; then + guard_refuse "this session is not in a tmux window at all, and a lane RUNS in a tmux window named for it (Rule 4, Amendment 2). Open one and start the lane there." "lane-start (in a tmux window)" + else + guard_refuse "tmux did not answer \`display-message -p '#{session_name}:#{window_id}'\`, so the WINDOW half of the triple could not be read — and an indeterminate read refuses (Amendment 12(d)). $(guard_bypass)" + fi + return 2 + fi + G_WINREF="$LANES_THIS_WINDOW" + G_WINNAME="$(tmux display-message -p '#W' 2>/dev/null || :)" + if [ -z "$G_WINNAME" ]; then + guard_refuse "tmux did not answer \`display-message -p '#W'\` for $G_WINREF, so the WINDOW's name could not be read — and an indeterminate read refuses (Amendment 12(d)). $(guard_bypass)" + return 2 + fi + + # ---- THE LIVE RECORD FOR THIS SESSION, AND AMENDMENT 18(h)'s COUNT, IN ONE + # READ. The hook is handed its own `session_id` by the harness, so the + # question asked is "which live processes carry THIS transcript", which is + # both halves at once and is the cheap shape (`transcript_holders` states the + # measurement). `window_session` asks the other question — "what is live in + # this window" — by parsing every record on the workstation, and at 11.8 s + # against 329 of them on 2026-09-14 it cannot run inside a five-second hook. + # + # NOT `$( )`: `transcript_holders` sets `SESSION_FILES_ERR`, and a subshell + # would keep the reason for the failure to itself. + gr_tmp="$(mktemp "${TMPDIR:-/tmp}/lanes-edit-gd.XXXXXX" 2>/dev/null || printf '')" + if [ -z "$gr_tmp" ]; then + guard_refuse "no temporary file could be made under ${TMPDIR:-/tmp}, so this workstation's session records could not be read (Amendment 12(d)). $(guard_bypass)" + return 2 + fi + gr_rc=0 + transcript_holders "$G_ID" > "$gr_tmp" || gr_rc=$? + if [ "$gr_rc" != 0 ] && [ "$gr_rc" != 8 ]; then + rm -f -- "$gr_tmp" + guard_refuse "this workstation's session records could not be read (${SESSION_FILES_ERR:-unknown error}), so the SESSION's own name could not be read — and an indeterminate read refuses (Amendment 12(d)). $(guard_bypass)" + return 2 + fi + gr_here=""; gr_here_tgt=""; gr_others="" + while IFS="$US" read -r gh_pid gh_kind gh_tgt gh_prof gh_where gh_verdict gh_file; do + [ -n "${gh_pid:-}" ] || continue + # A COMPANION IS NEITHER — not this window's record and not a second + # process — so it is passed over in silence, which is Amendment 8 ruling + # (g)'s own posture. Two records of ONE session in ONE pane are that + # session seen twice, and the WINDOWED one is the better witness of it. + case "$gh_verdict" in + here) + if [ -z "$gr_here" ] || { [ "$gr_here_tgt" = none ] && [ "$gh_tgt" != none ]; }; then + gr_here="$gh_pid$US$gh_kind$US$gh_tgt$US$gh_prof$US$gh_file"; gr_here_tgt="$gh_tgt" + fi ;; + duplicate) + gr_others="${gr_others}${gr_others:+, }pid $gh_pid ($gh_where, kind $gh_kind, profile $gh_prof)" ;; + esac + done < "$gr_tmp" + rm -f -- "$gr_tmp" + if [ -z "$gr_here" ]; then + guard_refuse "no live session record in THIS window ($G_WINREF) carries this session id, so the SESSION's own name could not be read — and an indeterminate read refuses (Amendment 12(d)). The records were read; none of them is this window's. $(guard_bypass)" + return 2 + fi + IFS="$US" read -r G_PID gr_kind gr_tgt G_PROF gr_file <<<"$gr_here" + gr_blob="$(cat -- "$gr_file" 2>/dev/null || :)" + G_NAME="$(jstr "$gr_blob" name)" + G_SRC="$(jstr "$gr_blob" nameSource)" + gr_since="$(jnum "$gr_blob" nameSince)" + # THE PANE ONLY FROM A TARGET THAT NAMES THIS WINDOW. `here` is true by the + # record's target OR by the pane's own process tree, and tmux REUSES window + # ids — so a record that is here BY ANCESTRY can carry a target another + # window now answers to, and `/rename` typed into it would land in somebody + # else's pane. M1's gate cannot catch that one: the other pane is running + # `claude` too (Copilot round 3 on this PR). Where the record's target does + # not name this window the pane is left EMPTY, and `guard_type` asks tmux for + # the pane this hook is running in, which is the session's own. + gr_pane="" + if [ -n "$gr_tgt" ] && [ -n "$LANES_THIS_WINDOW" ] && [ "${gr_tgt%.*}" = "$LANES_THIS_WINDOW" ]; then + case "$gr_tgt" in *.%*) gr_pane="${gr_tgt##*.}" ;; esac + fi + + # ---- THE ROW, from `origin/` as this checkout last had it (R19), and + # every comparison case-insensitive with the register's spelling printed + # (Amendment 15). + gr_hits="$(rows_named_ci "$G_WINNAME" 2>/dev/null || :)" + gr_n="$(printf '%s' "$gr_hits" | grep -c . || :)" + if [ "$gr_n" -gt 1 ]; then + guard_refuse "the register holds $gr_n rows whose lane names differ only by case for this window's name '$G_WINNAME': $(printf '%s' "$gr_hits" | tr '\n' ' '). A lane name is ONE name under any case (Amendment 15), so no read under it is unambiguous and a register that cannot be read is not a register that agrees (Amendment 12(b))." "merge them into one row (Amendment 15(d)) — append the newer row's session id(s) to the older row's session cell, in order, and remove the newer row in the SAME commit" + return 2 + fi + if [ "$gr_n" = 1 ]; then G_LANE="$gr_hits" + elif lane_shaped "$G_WINNAME"; then G_LANE="$G_WINNAME" + fi + + gr_snhits="$(rows_named_ci "$G_NAME" 2>/dev/null || :)" + gr_snn="$(printf '%s' "$gr_snhits" | grep -c . || :)" + if [ "$gr_snn" -gt 1 ]; then + guard_refuse "the register holds $gr_snn rows whose lane names differ only by case for this session's name '$G_NAME': $(printf '%s' "$gr_snhits" | tr '\n' ' '). A lane name is ONE name under any case (Amendment 15)." "merge them into one row (Amendment 15(d))" + return 2 + fi + if [ "$gr_snn" = 1 ]; then G_SES_LANE="$gr_snhits" + elif lane_shaped "$G_NAME"; then G_SES_LANE="$G_NAME" + fi + + gr_ids=""; gr_last="" + if [ -n "$G_LANE" ]; then + gr_ids="$(session_ids_of_lane "$G_LANE" 2>/dev/null || :)" + gr_last="$(printf '%s\n' "$gr_ids" | grep . | tail -n1 || :)" + if [ -n "$(row_of_lane "$G_LANE" 2>/dev/null || :)" ]; then + G_ROW="\`$G_LANE\` — last session ${gr_last:-none recorded}" + else + G_ROW="the register has no row for \`$G_LANE\`" + fi + else + G_ROW="this window names no lane, so no row is keyed by it" + fi + + # ---- AMENDMENT 18(h) — ONE LIVE PROCESS PER TRANSCRIPT, ASKED BEFORE THE + # PENDING OFFER IS ANSWERED. Clause (h) refuses *"every prompt in a process + # that shares its session id with another live one"*, and THE ANSWER TO THIS + # GUARD'S OWN QUESTION IS A PROMPT: a `yes` consumed here runs `lane-start`, + # writes a register row and appends a uuid to a cell, out of a process that + # may not be writing anything at all — and the offer is held per SESSION ID, + # so the second process answers the first one's question. The offer is KEPT + # while the duplicate stands and is answered at the first prompt after the + # other process is retired (Copilot round 1 on this PR). + if [ -n "$gr_others" ]; then + guard_triple + note "ANOTHER LIVE PROCESS CARRIES THIS SESSION ID ($G_ID): $gr_others." + note "A transcript is held by ONE live process (Amendment 18(h), ratified 2026-09-14): two of them append to one file, each overwriting what the other wrote, and the row records an id that two processes answer for. Measured four times on 2026-09-14, the last at 18:14Z — a harness \`--fork-session\` in a background pty host beside the interactive resume, its record in ANOTHER profile's sessions/ directory." + if [ -n "$G_LANE" ]; then + note "retire the other one — it proves the process is this lane's duplicate and prints Amendment 6(d)'s act for it, and it ends, writes and kills nothing:" + note "run: lane-end $G_LANE --retire " + else + note "retire the other one: lane-end --retire . This window names no lane, so which lane it is is yours to name." + fi + return 2 + fi + + # ---- THE PENDING OFFER, ANSWERED BEFORE THE TABLE BELOW IS CONSULTED + # (clause (h) rule 2). The answer prompt is the person's reply to a question + # this guard asked, it is consumed here and never reaches the model, and the + # triple still disagrees BECAUSE of the rename being answered — so this + # cannot wait behind the table below. It DOES wait behind 18(h)'s count + # above, for the reason stated there. + gr_off="$(guard_offer_file "$G_ID")" + if [ -f "$gr_off" ]; then + guard_answer "$gr_off" "$gr_prompt" "$gr_pane" + return 2 + fi + + # ---- AMENDMENT 18 CLAUSES (d) AND (e) PLUG IN HERE, AND NOT IN THIS PULL + # REQUEST. Both are reads of THIS lane's own object log, made at this point + # for the same reason (h)'s count is: the lane is known, nothing has been + # judged yet, and a refusal here costs the prompt and nothing else. + # + # (d) a `HANDOFF-REQUESTED` newer than this session's binding and not yet + # answered by a `PAUSED` of its own -> refuse this one prompt, naming + # who asked and from where, and type `/handoff --exit requested by …` + # into this pane ((h)1's mechanism, which `guard_type` already is); + # (e) a `PAUSED … on behalf of ` newer than this session's + # binding that THIS session did not write -> refuse EVERY prompt from + # then on, naming the line and who forced it, until the person here + # runs the handoff themselves or ends the session. + # + # NEITHER CAN BE BUILT YET AND THAT IS A DEPENDENCY, not a deferral: (d)'s + # answer IS Amendment 17(a)'s handoff under `--exit` (opensoft/openRepoTools#36) + # and (e)'s line is written by the `--force` writer Amendment 18 adoption act 1 + # adds (#38). A guard that refused on a line no writer in this estate can yet + # produce would be refusing on a read of a log that never carries it — and a + # guard that TYPED `/handoff --exit` into a pane where that flag does not exist + # would type a command into somebody's session that does nothing. #38 is where + # both land, and Amendment 18's own adoption list puts it after this one. + + # ---- (b) ROWS 1 AND 2: THE WINDOW IS NOT A LANE. + if [ -z "$G_LANE" ]; then + if [ -n "$G_SES_LANE" ]; then + guard_refuse "this window is named '$G_WINNAME', which is no lane, while the SESSION is named for lane $G_SES_LANE — so nothing binds this conversation to a row, no surface would refuse a second window taking the same lane, and the handoff this session writes is one only this profile can find. THAT IS THE 2026-09-10 CASE, which ran for three days." "lane-start --no-launch $(lane_start_args "$G_SES_LANE")" + else + guard_refuse "neither this window ('$G_WINNAME') nor this session ('${G_NAME:-none}') names a lane, so the guard cannot name one either: WHICH lane this work is is yours to say, and a guard that guessed would bind a window to a row nobody chose." "lane-start (for the lane this work is)" + fi + return 2 + fi + + # ---- THE WINDOW IS A LANE. Where the uuid sits in the row's cell decides + # which of the next four rows applies, and the cell is a HISTORY written + # oldest first whose LAST id is the lane (Amendment 6(b)). + if [ -n "$gr_last" ] && [ "$G_ID" = "$gr_last" ]; then + if [ "$(lc "$G_NAME")" = "$(lc "$G_LANE")" ]; then + # THE THREE AGREE. Under Amendment 15 they agree under any case — and the + # ROW's spelling is the one the session carries, so a name that differs + # only by case is renamed to it rather than refused for ever. + [ "$G_NAME" = "$G_LANE" ] && return 0 + guard_lock_rename "$gr_pane" "spells the lane '$G_NAME' where the register's row spells it '$G_LANE', and a lane name is ONE name under any case whose canonical spelling is the row's (Amendment 15)" + return 2 + fi + if [ -n "$G_SES_LANE" ] && [ "$(lc "$G_SES_LANE")" != "$(lc "$G_LANE")" ] \ + && [ "${G_SRC:-}" = user ] && guard_rename_is_newer "$G_LANE" "$gr_since"; then + guard_offer "$gr_off" "$gr_pane" + return 2 + fi + guard_lock_rename "$gr_pane" "is '${G_NAME:-none}', which is not the lane's name" + return 2 + fi + + if [ -n "$gr_ids" ] && printf '%s\n' "$gr_ids" | grep -qx -F -- "$G_ID"; then + guard_refuse "this is a SUPERSEDED transcript of lane $G_LANE: the row's session cell carries $G_ID but ENDS on $gr_last, so the conversation in this window is not the lane's and renaming it would not make it one. Exit this session and start the lane, which resumes the id the row ends on." "lane-start $(lane_start_args "$G_LANE")" + return 2 + fi + + guard_refuse "this window is lane $G_LANE, whose row ${gr_last:+ends on $gr_last and }does not name $G_ID at all — the harness minted a new transcript with nobody acting (a /clear, a usage reset, a profile switch). The lane is right and the ROW is behind, so the cure is the recording act and no relaunch: it reads the live record in this window and appends that uuid to the cell." "lane-start --no-launch $(lane_start_args "$G_LANE")" + return 2 +} + +# (h) rule 2's third test: is the rename NEWER than this window's binding? +# Amendment 18(b) makes the binding the lane's last `STARTED`/`RESUMED`, and +# `nameSince` is an epoch in MILLISECONDS (measured on this estate's records, +# 2026-09-14). A rename OLDER than the binding is a title this window inherited +# and not an instruction somebody has just given, so it is drift and the lock +# renames it back. A record with NO `nameSince` — an older harness — cannot be +# told apart either way and is drift too, which is (h)1's default and the +# closed direction. A lane whose log records no binding at all cannot make a +# rename older than one, so there the rename is the instruction. +guard_rename_is_newer() { # + grn_lane="${1-}"; grn_since="${2-}" + case "$grn_since" in ''|*[!0-9]*) return 1 ;; esac + grn_utc="$(lane_binding_utc "$grn_lane" 2>/dev/null || :)" + [ -n "$grn_utc" ] || return 0 + grn_e="$(epoch_of "$grn_utc")" + [ -n "$grn_e" ] || return 0 + [ "$grn_since" -gt "$((grn_e * 1000))" ] +} + +# ------------------------------------------------------ (h)1: THE LOCK RENAMES +# +# The guard refuses THIS ONE PROMPT so the rename lands first, types +# `/rename ` into this pane, and says so. M1's conditions are +# `guard_type`'s; every outcome but a successful typing prints the line for the +# person to type themselves, because a lock that silently did nothing is a lock +# nobody knows is broken. +guard_lock_rename() { # + glr_pane="${1-}"; glr_why="${2-}" + glr_rc=0 + guard_type "$glr_pane" "/rename $G_LANE" || glr_rc=$? + guard_triple + note "the session name $glr_why, and under the projects root the session name is not the person's to set freely — it is the LANE's (Amendment 12(h), THE LOCK)." + case "$glr_rc" in + 0) note "SO IT HAS BEEN RENAMED FOR YOU: \`/rename $G_LANE\` was typed into this pane ($glr_pane), which is the only path a running session's name has (M1). This one prompt is refused so the rename lands first — send it again." ;; + 8) note "the pane's current command could not be read, so NOTHING was typed: a \`/rename\` typed at a shell is a command that does not exist and typed into an editor is text nobody wrote. Type it yourself: /rename $G_LANE" ;; + 9) note "this pane is not running claude, so NOTHING was typed, for the reason above. Type it in the lane's own pane: /rename $G_LANE" ;; + *) note "tmux would not take the keys, so NOTHING was typed. Type it yourself: /rename $G_LANE" ;; + esac + glr_base="${G_NAME% (*)}" + if [ -n "$glr_base" ] && [ "$(lc "$glr_base")" = "$(lc "$G_LANE")" ] && [ "$glr_base" != "$G_NAME" ]; then + note "AND THE ' (N)' SUFFIX IS EVIDENCE: it is exactly what a rename into a title something else still holds mints, so another holder of '$G_LANE' was live when this session was named (Amendment 6(d), ratified decision D4). Naming them is a read of its own, kept off this hook's path because it costs about three seconds: \`lanes-edit.sh forks $G_LANE\`. Retiring one is \`lane-end $G_LANE --retire \`, which proves it is a fork, prints Amendment 6(d)'s act filled in, and ends, writes and kills nothing." + fi + return 2 +} + +# F-B6 PRINTS EVERY COMMAND FILLED IN, AND THERE IS ONE LANE NAME THAT CANNOT +# BE. `lane_start_args` answers ` ` for a lane named under Rule 4's +# `-` form and `--dir ` for one named before it — and that +# `` is a PLACEHOLDER, because nothing in the register, the window or this +# session says where such a lane's checkout is. Printing it in a diagnostic is +# honest; RUNNING it is not. `lane-start --no-launch --dir '' legacy-ui` +# refuses on a directory that does not exist, and the offer that ran it would +# ask the same question at every prompt afterwards (Copilot round 1 on this PR). +# So the offer SAYS what it cannot fill in and the `yes` refuses instead of +# running it, with the offer kept so that `no` still answers. +guard_args_filled() { # + gaf_a="$(lane_start_args "$1")" + case "$gaf_a" in *''*) return 1 ;; esac + return 0 +} + +# ------------------------------------------------------------ (h)2: THE OFFER +# +# *"if the user does a rename, then we should offer to move to that lane or +# create a new lane if we do not have one as that name"* (D5, verbatim). The +# one thing the guard never does silently is decide, with nobody acting, which +# of two disagreeing LANES a window belongs to. +guard_offer() { # + go_f="${1-}"; go_pane="${2-}" + mkdir -p -- "${go_f%/*}" 2>/dev/null || : + { printf 'from=%s\n' "$G_LANE" + printf 'to=%s\n' "$G_SES_LANE" + printf 'uuid=%s\n' "$G_ID" + printf 'utc=%s\n' "$(utc_now)" + printf 'window=%s\n' "$G_WINREF" + printf 'pane=%s\n' "$go_pane" + } > "$go_f" 2>/dev/null || : + guard_triple + go_row="$(row_of_lane "$G_SES_LANE" 2>/dev/null || :)" + if [ -n "$go_row" ]; then + go_last="$(last_session_id_of_lane "$G_SES_LANE" 2>/dev/null || :)" + note "you renamed this session to $G_SES_LANE — lane $G_SES_LANE EXISTS, last session ${go_last:-none recorded}." + else + note "you renamed this session to $G_SES_LANE — lane $G_SES_LANE DOES NOT EXIST yet." + fi + if guard_args_filled "$G_SES_LANE"; then + note "Reply \`yes\` to move this window to it (creating the lane if there is none: \`lane-start --no-launch $(lane_start_args "$G_SES_LANE")\` is run for you, this window is renamed, the row created or this uuid appended, and $G_LANE is marked MOVED)." + else + note "Reply \`yes\` and it will say what it cannot do: $G_SES_LANE is named before Rule 4's \`-\` form, so \`lane-start\` needs that lane's DIRECTORY and nothing here knows which one it is. Moving this window to it is \`lane-start --no-launch --dir $G_SES_LANE\`, yours to run with the path filled in." + fi + note "Reply \`no\` to stay $G_LANE — the session is renamed back with \`/rename $G_LANE\`." + note "The answer is the NEXT prompt, it is consumed here and never reaches the model, and anything but yes or no asks again. The offer expires with this session." + [ -f "$go_f" ] || note "(the offer could not be written to $go_f, so the answer will be read as a fresh prompt and this question asked again — which is the safe direction)" + return 2 +} + +# ------------- THE UUID INTO THE NEW LANE'S CELL, WHICH `lane-start` MAY NOT DO +# +# CLAUSE (h) RULE 2 NAMES THE END STATE AND RULE 3 REPEATS IT: after a `yes`, +# *"window X, session X, ROW X STAMPED WITH THIS UUID"*. `lane-start --no-launch` +# performs every other part of that and CANNOT perform this one, by a fence that +# is right and stays: its step 3b VETO 1 — Amendment 11 clause (d) rule 1 — +# never takes a uuid that belongs to ANOTHER ROW, and after a rename this uuid +# belongs to `$ga_from`'s. So it mints a fresh id for the new lane instead and +# the person's own transcript is left out of the cell the next resume follows. +# +# MEASURED, AND IT IS A LOOP AND NOT A BLEMISH. In the suite: `yes` moved the +# window to `repoGD-2`, whose row then read `STARTED by 7a01ae69…` while this +# session was `aaaa0012-1111…`. The NEXT prompt therefore finds a lane window +# whose row does not name this transcript — the last row of the (b) table — and +# refuses with `lane-start --no-launch repoGD 2`, which vetoes for the same +# reason and changes nothing. A blocking hook that refuses for ever, on a state +# it created by obeying the person, is the worst outcome this surface has. +# +# SO THE GUARD MAKES THE LAST WRITE ITSELF, and only after the person's `yes`. +# That is not a hole in veto 1: the veto exists for the take nobody asked for — +# *"`lane-start openXfactory-5` typed from a window named `openRepoProject-1`"*, +# Evidence 2(b) — and clause (h) rule 4's own limit is that the lock *"never +# moves a uuid between rows WITHOUT the person's `yes`"*. Here there is one, on +# the record, answered at this very prompt. +# +# THE ANCHOR DISCIPLINE IS `lane-start`'s, unvaried: the anchor is the PUBLISHED +# last id, so a checkout whose copy of the row is older than the one that landed +# REFUSES rather than writing a cell that no longer matches; a row whose cell +# records no uuid at all is anchored on `none recorded`, the one other text a +# cell this act can meet carries, and anything else prints the act and guesses +# at nothing. +# +# 0 the cell now ends on this uuid (it already did, or this appended it) +# 1 it does not, and the reason has been printed with the act that fixes it +guard_bind_uuid() { # + gbu_to="${1-}"; gbu_from="${2-}" + # THE CACHED REGISTER IS THE ONE FROM BEFORE `lane-start` RAN, and this act is + # the one place in the file that reads it AFTER a writer has moved it: a row + # `lane-start` has just CREATED is not in it at all, and the anchor would then + # be read as "no cell to append to" on the one path that most needs one. Both + # copies are dropped — the variable this shell holds and the file every + # subshell reads (A11 Addendum 4 ruling 12) — exactly as `log_sync` drops them + # after a fetch, and for the same reason: the ref has moved under them. + LANES_REGISTER_CACHE="" + gbu_rc="${SE_CACHE_FILE:+$SE_CACHE_FILE.register}"; [ -n "$gbu_rc" ] && rm -f -- "$gbu_rc" + gbu_last="$(last_session_id_of_lane "$gbu_to" 2>/dev/null || :)" + [ "$(lc "$gbu_last")" = "$(lc "$G_ID")" ] && return 0 + gbu_anchor="$gbu_last" + if [ -z "$gbu_anchor" ]; then + gbu_cell="$(row_cell "$(row_of_lane "$gbu_to" 2>/dev/null || :)" 3 | sed -e 's/^ *//' -e 's/ *$//')" + # THE TWO TEXTS A CELL THIS ACT CAN MEET CARRIES INSTEAD OF A UUID, and + # both come from `lane-start` itself: `none recorded` for a row a person + # wrote by hand, and `pending — set by the session's first act` for a row + # `lane-start` has just CREATED with neither a minted uuid nor one it was + # allowed to take (`lane-start:1914`). The second is exactly the state a + # `yes` to a brand-new lane can leave — veto 1 refuses this window's uuid + # because the source row still records it, and a run that minted none has + # nothing else to write — so anchoring only on the first left the cure for + # the loop unreachable in the one case the loop most needs it (Copilot + # round 3 on this PR). + case "$gbu_cell" in + "none recorded") gbu_anchor="none recorded" ;; + "pending — set by the session's first act") gbu_anchor="$gbu_cell" ;; + esac + fi + gbu_add="→ harness $G_ID (transcript uuid; profile ${G_PROF:-unknown})" + gbu_hint="LANES_LANE=$gbu_to lanes-edit.sh append-session-id $gbu_to \"\" \"$gbu_add\"" + if [ -z "$gbu_anchor" ]; then + note "…but $gbu_to's session cell carries no uuid to append after, and no text this act is willing to anchor on, so THIS TRANSCRIPT IS NOT IN IT. Stamp it as this session's first act, which is Amendment 6(c)'s own remedy:" + note " $gbu_hint" + return 1 + fi + if LANES_LANE="$gbu_to" "$RESOLVED" append-session-id "$gbu_to" "$gbu_anchor" "$gbu_add" "session cell: the name guard moved this window from $gbu_from on the person's yes (Amendment 12(h) rule 2)" >&2; then + note "…and $gbu_to's session cell now ends on $G_ID, which is what the next \`lane-start $gbu_to\` resumes." + return 0 + fi + note "…but $gbu_to's session cell was NOT extended with $G_ID (this checkout's copy of the row does not carry '$gbu_anchor' exactly once in its SESSION CELL — it may be older than the published one). Its writer's words are above. Stamp it as this session's first act:" + note " $gbu_hint" + return 1 +} + +# The answer, and it is the whole of clause (h) rule 2's second half. +guard_answer() { # + ga_f="${1-}"; ga_p="${2-}"; ga_pane="${3-}" + ga_from="$(sed -n -e 's/^from=//p' "$ga_f" 2>/dev/null | head -n1)" + ga_to="$(sed -n -e 's/^to=//p' "$ga_f" 2>/dev/null | head -n1)" + ga_ans="$(printf '%s' "$ga_p" | tr 'A-Z' 'a-z' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + if [ -z "$ga_from" ] || [ -z "$ga_to" ]; then + rm -f -- "$ga_f" + guard_refuse "the pending offer at $ga_f could not be read, so it has been dropped rather than answered on a guess. Send your prompt again and the question will be asked afresh." + return 2 + fi + case "$ga_ans" in + y|yes) + if ! guard_args_filled "$ga_to"; then + guard_refuse "lane $ga_to is named before Rule 4's \`-\` form, so the act that moves this window to it cannot be filled in here: \`lane-start\` needs that lane's DIRECTORY and nothing in the register, the window or this session says which one it is. NOTHING has been renamed, moved or written, and the offer is KEPT — run it yourself with the path, or answer \`no\` to stay $ga_from." "lane-start --no-launch --dir $ga_to" + return 2 + fi + ga_start="$(guard_lane_start)" + if [ -z "$ga_start" ] || [ ! -x "$ga_start" ]; then + guard_refuse "there is no \`lane-start\` beside this file or on PATH, so this window has NOT moved to $ga_to and NOTHING has been written. The offer is kept: answer \`yes\` again once it is installed (\`openRepoTools --install\`)." + return 2 + fi + # THE WINDOW IS RENAMED FIRST, AND THAT ORDER IS LOAD-BEARING. Clause (h) + # rule 2 lists the acts in it — *"window renamed, row created or this uuid + # appended to its cell"* — and `lane-start` will not do the second while the + # first is undone: its step 3b VETO 2 refuses to take the session live in a + # window NAMED FOR ANOTHER LANE (Amendment 11 clause (d) rule 1, veto 2, + # `R-A11-12`), which after a `yes` is exactly what this window is. Without + # the rename the lane would be started and stamped and the person's + # transcript left OUT of its session cell — the one thing the next resume + # follows. The veto is right and stays: what changes is that the person has + # just said this window is the other lane. + if command -v tmux >/dev/null 2>&1; then + tmux rename-window "$ga_to" 2>/dev/null || + note "the window could not be renamed to $ga_to, so lane-start may refuse to take this session as that lane's (its step 3b veto 2)." + fi + ga_rc=0 + # ITS STDOUT IS THE COMMAND IT WOULD HAVE LAUNCHED, and a hook's stdout is + # not what a person reads: everything this act says goes to stderr, where + # the blocking reason is shown. + # UNQUOTED ON PURPOSE: `lane_start_args` prints TWO words, ` ` — + # or `--dir ` for a lane named before the `-` rule. + # shellcheck disable=SC2046 + "$ga_start" --no-launch $(lane_start_args "$ga_to") >&2 || ga_rc=$? + if [ "$ga_rc" != 0 ]; then + guard_refuse "\`lane-start --no-launch $(lane_start_args "$ga_to")\` exited $ga_rc (its own words are above), so this window has NOT moved and $ga_from has NOT been marked MOVED. The offer is KEPT, so answer \`yes\` again once that refusal is settled, or \`no\` to stay $ga_from." + return 2 + fi + rm -f -- "$ga_f" + guard_triple + note "MOVED: this window is now lane $ga_to — renamed, and stamped. The prompt that answered the question is consumed; send your work again." + guard_bind_uuid "$ga_to" "$ga_from" || : + if [ -n "$(row_of_lane "$ga_from" 2>/dev/null || :)" ]; then + if "$RESOLVED" append-row-status "$ga_from" "MOVED → $ga_to" >&2; then + note "…and $ga_from's row records \`MOVED → $ga_to\`." + else + note "…but $ga_from's row could NOT be marked (its writer's words are above). Run: lanes-edit.sh append-row-status $ga_from \"MOVED → $ga_to\"" + fi + else + note "…and the register has no row for $ga_from, so there is nothing to mark MOVED there." + fi + return 2 ;; + n|no) + ga_trc=0 + guard_type "$ga_pane" "/rename $ga_from" || ga_trc=$? + rm -f -- "$ga_f" + guard_triple + case "$ga_trc" in + 0) note "STAYING $ga_from: \`/rename $ga_from\` was typed into this pane ($ga_pane) and the window is untouched. The prompt that answered the question is consumed; send your work again." ;; + *) note "STAYING $ga_from, but the rename could NOT be typed into this pane, so the session is still named $ga_to and the guard will ask again at the next prompt. Type it yourself: /rename $ga_from" ;; + esac + return 2 ;; + *) + guard_triple + note "that is not an answer to the pending question, so it has been refused rather than acted on: this window is lane $ga_from and this session is named $ga_to." + note "Reply \`yes\` to move this window to $ga_to, or \`no\` to stay $ga_from." + return 2 ;; + esac +} + + +# ------------------------------- CLAUSE (f): THE `SessionStart` BLOCK'S LINE +# +# *"Where the record's name differs from the window's lane it prints +# `session name '' was not the lane '' — renamed` and types the rename +# ((h)1), so the first prompt already finds the three agreeing. It stays +# read-only against the register and non-blocking, as Amendment 8 made it; the +# refusal is the `UserPromptSubmit` hook's."* +# +# AND AMENDMENT 18(h)'s READ BESIDE IT, SAID AND NOT ACTED ON: where another +# live process carries this session id, the block NAMES it and the retire act. +# The guard refuses on that; this hook refuses nothing and never will — a hook +# that fails is a hook that breaks the session it was meant to orient (R-A8-1), +# and typing into its own pane writes nothing anywhere, which is why (h)1 gives +# it that one act and no other. +# +# EVERY PATH RETURNS 0. A read that failed prints nothing rather than guessing: +# the guard is the surface that refuses on an indeterminate read, and doing it +# twice, in the hook that may not, would be the same failure one layer up. +ssb_name_line() { # + snl_id="${1-}"; snl_lane="${2-}" + [ -n "$snl_id" ] || return 0 + snl_tmp="$(mktemp "${TMPDIR:-/tmp}/lanes-edit-sn.XXXXXX" 2>/dev/null || printf '')" + [ -n "$snl_tmp" ] || return 0 + snl_rc=0 + # IN A SUBSHELL, AND THAT IS R-A8-1 AND NOT TIDINESS. *"This hook never fails + # and always exits 0"*; `|| snl_rc=$?` catches a RETURN and catches nothing + # else, because a fatal inside a function called in this shell — an unbound + # variable under `set -u`, a `die` on a path nobody expected to reach one — + # exits the shell itself, `|| :` at the call site and all. Measured: with + # `th_here_prof` unset, `lanes-edit.sh session-start` exited 1 and printed no + # block at all, which is precisely the hook breaking the session it was + # written to orient. Nothing here needs state from the call — the rows come + # back through a file — so the subshell costs nothing and bounds it. + ( transcript_holders "$snl_id" ) > "$snl_tmp" 2>/dev/null || snl_rc=$? + if [ "$snl_rc" != 0 ]; then rm -f -- "$snl_tmp"; return 0; fi + snl_file=""; snl_tgt=none; snl_others="" + while IFS="$US" read -r sn_pid sn_kind sn_tgt sn_prof sn_where sn_verdict sn_file; do + [ -n "${sn_pid:-}" ] || continue + # The same three-way reading the guard makes, and for the same reasons. + case "$sn_verdict" in + here) + if [ -z "$snl_file" ] || { [ "$snl_tgt" = none ] && [ "$sn_tgt" != none ]; }; then + snl_file="$sn_file"; snl_tgt="$sn_tgt" + fi ;; + duplicate) + snl_others="${snl_others}${snl_others:+, }pid $sn_pid ($sn_where, kind $sn_kind, profile $sn_prof)" ;; + esac + done < "$snl_tmp" + rm -f -- "$snl_tmp" + if [ -n "$snl_others" ]; then + printf 'DEFECT: another live process carries this session id (%s): %s — a transcript is held by ONE live process (Amendment 18(h)), and two of them append to one file. Retire it: lane-end %s --retire \n' \ + "$snl_id" "$snl_others" "${snl_lane:-}" + fi + [ -n "$snl_lane" ] && [ -n "$snl_file" ] || return 0 + snl_blob="$(cat -- "$snl_file" 2>/dev/null || :)" + snl_name="$(jstr "$snl_blob" name)" + [ -n "$snl_name" ] || snl_name=none + [ "$snl_name" = "$snl_lane" ] && return 0 + # THE SAME FENCE AS THE GUARD'S, for the same reason and in the same words: + # a record that is here by ancestry can carry another window's target, and + # this hook types into a pane. `here_context` is asked HERE because the read + # above it runs in a subshell, which is where its answer would otherwise have + # stayed. + # A PERSON'S RENAME TO ANOTHER LANE IS THE GUARD'S TO OFFER, NOT THIS HOOK'S + # TO UNDO. Clause (f) types the rename for (h)1's case — DRIFT, a name that is + # no lane's — while (h) rule 2 makes a record whose `nameSource` is `user`, + # whose name is another lane's, and whose `nameSince` is later than this + # window's binding an INSTRUCTION, answered `yes` or `no` at the next prompt. + # This hook runs on every startup, resume, clear and fork, so typing over such + # a name would erase the person's choice before the guard could put the + # question (Copilot round 4 on this PR): a rename, then a `/clear`, and the + # offer never happens. It is SAID here and left to the prompt that follows. + snl_src="$(jstr "$snl_blob" nameSource)" + snl_since="$(jnum "$snl_blob" nameSince)" + if [ "${snl_src:-}" = user ] && [ "$(lc "$snl_name")" != "$(lc "$snl_lane")" ] \ + && { [ -n "$(rows_named_ci "$snl_name" 2>/dev/null || :)" ] || lane_shaped "$snl_name"; } \ + && guard_rename_is_newer "$snl_lane" "$snl_since"; then + printf "session name '%s' is a lane name YOU set, newer than this window's binding to '%s' — the name guard offers the move at your next prompt (Amendment 12(h) rule 2); nothing was renamed here\n" \ + "$snl_name" "$snl_lane" + return 0 + fi + here_context + snl_pane="" + if [ -n "$snl_tgt" ] && [ "$snl_tgt" != none ] && [ -n "$LANES_THIS_WINDOW" ] \ + && [ "${snl_tgt%.*}" = "$LANES_THIS_WINDOW" ]; then + case "$snl_tgt" in *.%*) snl_pane="${snl_tgt##*.}" ;; esac + fi + snl_trc=0 + guard_type "$snl_pane" "/rename $snl_lane" || snl_trc=$? + if [ "$snl_trc" = 0 ]; then + printf "session name '%s' was not the lane '%s' — renamed\n" "$snl_name" "$snl_lane" + else + printf "session name '%s' is not the lane '%s' — run: /rename %s\n" "$snl_name" "$snl_lane" "$snl_lane" + fi + return 0 +} + # ----------------------------------------------------------------- GitHub # # Rule 1 is one act: the three reads, the local line, and the comment that @@ -5386,8 +6512,13 @@ case "$cmd" in [ -z "$ws_why" ] || die "$ws_why" 2 ;; esac +# `guard` JOINS THAT EXEMPTION AND REFUSES FOR ITSELF (Amendment 12(d)). It is +# a hook too, and a blocking one: `die … 1` here would print the workspace +# refusal and let the prompt THROUGH, because only exit 2 blocks a +# `UserPromptSubmit`. So the verb takes the same reads itself and answers them +# with a 2 — fail CLOSED, which is the clause. case "$cmd" in - session-start) : ;; + session-start|guard) : ;; *) if [ -z "$LANES_REPO" ] || [ -z "$LANES_DIR" ]; then die "$(lanes_workspace_why)" 1 @@ -5965,6 +7096,44 @@ EOF exit 0 ;; + # AMENDMENT 12 — THE `UserPromptSubmit` HOOK. The ONE surface in this file + # that refuses a person's work, and the only one whose exit code is read by + # the harness rather than by a script: 2 BLOCKS the prompt and its stderr is + # what the person sees (code.claude.com/docs/en/hooks). Every other code lets + # the prompt through, which is why every refusal below is a 2 and why this + # verb is exempt from the dispatcher's `die … 1` above. + # + # AN ARGUMENT IS A USAGE REFUSAL AND NOT A SHRUG: a hook configured with a + # stray word is a hook nobody has checked, and passing the prompt through on + # one is exactly the silence Amendment 12 exists to end. + guard) + [ "$#" -eq 0 ] || die "usage: guard (the UserPromptSubmit hook; the hook's JSON on stdin)" 2 + if [ -t 0 ]; then g_json=""; else g_json="$(cat 2>/dev/null || :)"; fi + # IN A SUBSHELL, AND EVERY CODE BUT 0 IS A 2. This is clause (d) — *"fail + # CLOSED"* — made true of the guard's OWN failures and not only of the + # reads it makes, and it is here because the alternative was measured: + # `transcript_holders` left `th_here_prof` unset, `set -u` (line 292) took + # the whole shell down at the first record that was not this window's, and + # `lanes-edit.sh guard` exited 1. A 1 does not block a `UserPromptSubmit` + # — only 2 does — so the one defect this guard cannot have, a guard that + # lets a prompt through in silence, is exactly what a fatal inside it + # produced. The subshell contains it: `guard_run`'s effects are on the + # filesystem (the offer file, `lane-start`, the register) and on stderr, + # all of which cross it, and the only thing lost is shell state nothing + # reads afterwards. + # + # A `die` reached from one of the reads it makes is caught the same way, + # which is why the arm maps EVERY unexpected code rather than listing the + # ones known today. + g_rc=0 + ( guard_run "$g_json" ) || g_rc=$? + case "$g_rc" in + 0 | 2) exit "$g_rc" ;; + *) note "THE NAME GUARD ITSELF FAILED (exit $g_rc), so the three names were not verified — and a triple that cannot be verified is not a triple that agrees (Amendment 12(d)). This prompt is refused rather than let through, because only a 2 blocks one and a guard that fails open is the silence this amendment exists to end. $(guard_bypass)" + exit 2 ;; + esac + ;; + # --- internal reads, for lane-start and lane-end ------------------------- # Not part of the protocol's surface: they exist so that the two boundary # scripts share ONE implementation of liveness and ONE parser of the log, @@ -6062,6 +7231,30 @@ EOF esac ;; + # ------------------------- AMENDMENT 18(h): ONE LIVE PROCESS PER TRANSCRIPT + # + # THREE CALLERS AND ONE IMPLEMENTATION — `lane-start` before it binds or + # resumes an id, `lane-end --retire ` when the duplicate's id is the + # ROW'S OWN (opensoft/openRepoTools#39), and the prompt `guard`. It answers + # about ONE transcript and says, per live process, where it is and whether it + # is THIS window's: + # + # + # + # 0 with rows, 8 with none, 1 where the records could not be read, 64 usage. + # The 1 is the fail-closed one: a caller that read it as 8 would bind a + # transcript two processes hold, which is the whole defect. + transcript-holders) + thub="${1-}"; [ -n "$thub" ] || die "usage: transcript-holders " 64 + transcript_holders "$thub"; thub_rc=$? + case "$thub_rc" in + 0) : ;; + 8) exit 8 ;; + 64) die "usage: transcript-holders " 64 ;; + *) die "could not read this workstation's session records for session $thub: ${SESSION_FILES_ERR:-unknown error}. That is NOT 'no other live process carries it', and a caller that read it as one would bind a transcript two processes hold." 1 ;; + esac + ;; + # ------------------------------ AMENDMENT 11, clause (h): the new reads # # ALL READ-ONLY; all answer out of `origin/` as every Amendment 7 @@ -6508,6 +7701,6 @@ EOF ;; *) - die "unknown subcommand '$cmd' (verify-row|append-row-status|replace-in-row|append-session-id|append-line|add-row|commit|log|claim|release|who|swapped|session-start|idle-holders|live-holder|window-session|session-lane|window-lane|lane-dir|lane-profile|last-session|forks|workstation|fetch-age|lanes|sibling-filter|resolve-repo|lane-objects|register-row|canon-lane|resolve-home)" 2 + die "unknown subcommand '$cmd' (verify-row|append-row-status|replace-in-row|append-session-id|append-line|add-row|commit|log|claim|release|who|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|session-lane|window-lane|lane-dir|lane-profile|last-session|forks|workstation|fetch-age|lanes|sibling-filter|resolve-repo|lane-objects|register-row|canon-lane|resolve-home)" 2 ;; esac diff --git a/openRepoTools b/openRepoTools index cb84a7f0..e8a21b51 100755 --- a/openRepoTools +++ b/openRepoTools @@ -35,8 +35,10 @@ # person's workspace repository holds their DATA and the code belongs where # code goes. It also places TWO skills, `/lane-swap` and `/restart`, at two # paths each, ONE command file, `/swap`, at that same pair of paths (A11 -# Addendum 4 ruling 9), and merges one SessionStart entry into -# `~/.claude/settings.json` (Amendment 9(b), A8 Addendum 2 R-A8-5): EIGHTEEN +# Addendum 4 ruling 9), and merges TWO hook entries into +# `~/.claude/settings.json` — the `SessionStart` one (Amendment 9(b), A8 +# Addendum 2 R-A8-5) and, since lane-collision-protocol Amendment 12 adoption +# act 3, the `UserPromptSubmit` NAME GUARD beside it: NINETEEN # artifacts, and the count is the invariant. # # One install line for the whole toolset, so a second engineer gets the estate @@ -67,7 +69,8 @@ usage() { openRepoTools --install install (or update) the eleven estate and lane commands into ~/.local/bin, plus the /lane-swap and /restart skills, the /swap - command file and the SessionStart hook entry + command file, and the SessionStart hook + entry and the UserPromptSubmit name guard openRepoTools wip init create your workspace repository, clone it, seed it, and point this workstation at it openRepoTools --help | --version @@ -109,10 +112,13 @@ lane words, the four lane helpers and the alias table they read: `~/.claude/skills//SKILL.md`, for `lane-swap` and `restart` — and ONE COMMAND FILE at the matching pair, `~/.claude-profiles/shared/commands/swap.md` and `~/.claude/commands/swap.md`, which is clause (g)'s ratified `/swap` alias -of `/lane-swap` and adds nothing to it. It merges ONE `SessionStart` entry into -`~/.claude/settings.json` (needs `jq`, and refuses rather than rewriting a file -it cannot parse). It never writes a profile's own settings.json: the launcher -owns that one. +of `/lane-swap` and adds nothing to it. It merges TWO entries into +`~/.claude/settings.json` — the `SessionStart` one and, since +lane-collision-protocol Amendment 12, the `UserPromptSubmit` NAME GUARD, which +refuses a prompt whose window, session name and register row are not one lane +(needs `jq`, refuses rather than rewriting a file it cannot parse, and leaves +every `UserPromptSubmit` hook of your own exactly where it is). It never writes +a profile's own settings.json: the launcher owns that one. `/restart` is the INSIDE half of `restart` and has to be a skill: a running session cannot `exec` a launcher over itself, so it binds the window, stamps @@ -280,9 +286,9 @@ mode_is() { # install_commands() { local dir="${OPENREPOTOOLS_BIN_DIR:-$HOME/.local/bin}" local name target source verb placed=0 - # ALL EIGHTEEN IN HAND BEFORE ANY IS PLACED. The eleven files, the two - # skills, the one command file, and the ONE artifact that is not a whole - # file — the merged `settings.json` — + # ALL NINETEEN IN HAND BEFORE ANY IS PLACED. The eleven files, the two + # skills, the one command file, and the TWO artifacts that are not whole + # files — the two hook entries merged into one `settings.json` — # are all computed here, so a fetch that failed replaces nothing and a merge # that cannot be computed refuses having placed nothing. That last one is # the same rule `--install` already had, extended to cover a merge: an @@ -345,7 +351,7 @@ install_commands() { # ========================================================================= -# THE SEVEN ARTIFACTS THAT ARE NOT ONE OF THE ELEVEN FILES +# THE EIGHT ARTIFACTS THAT ARE NOT ONE OF THE ELEVEN FILES # ========================================================================= # # lane-collision-protocol Amendment 9(b), inheriting A8 Addendum 2's R-A8-5 @@ -363,13 +369,14 @@ install_commands() { # run and preserves hook keys. This command never writes a profile's # settings.json at all. One path, one writer, in both halves. # -# EIGHTEEN ARTIFACTS, AND THE COUNT IS THE INVARIANT: eleven files in the bin +# NINETEEN ARTIFACTS, AND THE COUNT IS THE INVARIANT: eleven files in the bin # directory, two skills in the shared skills directory, their two bare-run -# copies, ONE COMMAND FILE at those same two paths, and one merged entry in +# copies, ONE COMMAND FILE at those same two paths, and TWO merged entries in # `~/.claude/settings.json`. It was twelve before Amendment 11 added `restart`, -# `lanes` and the `/restart` skill, and sixteen before ruling 9 added -# `commands/swap.md`; the count moves with the lists rather than being restated -# from memory. +# `lanes` and the `/restart` skill, sixteen before ruling 9 added +# `commands/swap.md`, and eighteen before Amendment 12 adoption act 3 added the +# `UserPromptSubmit` name guard beside the `SessionStart` entry; the count moves +# with the lists rather than being restated from memory. # # WHY A COMMAND FILE IS HERE AT ALL, WHEN ADOPTION ACT 6 SAYS THE LAUNCHER # VENDORS THEM (F-X28, A11 Addendum 4 ruling 9, ratified "a11 addendum 4 yes"). @@ -433,6 +440,36 @@ hook_block_text() { BLOCK } +#: THE SECOND HOOK — lane-collision-protocol AMENDMENT 12's NAME GUARD, adoption +#: act 3 (opensoft/openRepoTools#25). Same shape, same file, same all-or-nothing +#: merge, and TWO deliberate differences from the entry above: +#: +#: * NO `|| true`. On `SessionStart` that suffix is the whole safety property — +#: a hook that dies must not break the session it was meant to orient. On +#: `UserPromptSubmit` it would be the OPPOSITE of the mechanism: exit 2 is +#: what BLOCKS a prompt (code.claude.com/docs/en/hooks), and `cmd || true` +#: exits 0 for every code the command can produce. Copying the string with +#: its suffix would have installed a guard that refuses nothing, silently, +#: which is the exact state Amendment 12 exists to end. +#: * NO MATCHER. `UserPromptSubmit` has no source to match on; the `matcher` +#: key belongs to the events that do. +#: +#: The `~/projects/xFactory/lanes-edit.sh` spelling is the estate's convention +#: and `link-estates` places that link — the same path the entry above uses, so +#: one workstation has one spelling of one file. +GUARD_COMMAND='~/projects/xFactory/lanes-edit.sh guard' +GUARD_TIMEOUT=5 + +guard_block_text() { + cat <<'BLOCK' + { + "hooks": [ + { "type": "command", "command": "~/projects/xFactory/lanes-edit.sh guard", "timeout": 5 } + ] + } +BLOCK +} + # EVERY SKILL'S BYTES, by `--install`'s own rule: from the checkout beside this # file when run from one, else fetched at this same ref. ALL OF THEM IN HAND # BEFORE ANY IS PLACED, which is the same all-or-nothing rule the eleven files @@ -466,23 +503,30 @@ collect_skills() { return 0 } -# THE MERGE INTO `~/.claude/settings.json`, COMPUTED BEFORE ANYTHING IS PLACED. -# It is the same all-or-nothing rule `--install` already has, extended to the -# one artifact that is not a whole file: a merge that cannot be computed -# refuses having placed nothing. +# THE TWO MERGES INTO `~/.claude/settings.json`, COMPUTED BEFORE ANYTHING IS +# PLACED. It is the same all-or-nothing rule `--install` already has, extended +# to the two artifacts that are not whole files: a merge that cannot be +# computed refuses having placed nothing — and since Amendment 12 adoption act +# 3 the rule covers the PAIR, because a settings file that took the +# `SessionStart` entry and refused the `UserPromptSubmit` guard is a half +# install of a hook pair and nothing on screen would say which half. # # * it is written with `jq`, and refuses naming `jq` where it is absent. This # repository carries no JSON-handling code otherwise, so the dependency is # named rather than assumed. It is not bash, so the macOS bash-3.2 parse # gate is unaffected; # * it NEVER writes the file whole and never replaces it: the file already -# carries a `UserPromptSubmit` hook, and a user's settings are not an -# installer's to own; +# carries a `UserPromptSubmit` hook of the person's own — the usage guard +# — and a user's settings are not an installer's to own. THAT IS WHY THE +# `UserPromptSubmit` ARM KEYS ON THE VERB `guard` AS A WHOLE ARGUMENT and +# not as a substring: this command adds an entry to an event a person is +# already using, and every entry already there stays exactly where it is; # * it is idempotent by EXACT MATCH on the entry's `command` string; -# * an entry under SessionStart whose `command` DIFFERS, a file it cannot -# parse, or a `hooks` value that is not an object → refuse, print the exact -# block, place nothing. An installer that repairs a file it does not -# understand is how a person loses a setting they meant; +# * an entry under SessionStart whose `command` DIFFERS, one under +# UserPromptSubmit that RUNS `guard` with a different string, a file it +# cannot parse, or a `hooks` value that is not an object → refuse, print +# the exact block, place nothing. An installer that repairs a file it does +# not understand is how a person loses a setting they meant; # * it WRITES THE FILE BACK AT MODE 600 — it sets that mode, it does not # preserve one (F8 of the #24 review: a pre-existing file at 640 comes out # 600, measured). workBenches' setup-claude-profiles.sh writes this same @@ -491,6 +535,7 @@ collect_skills() { # an installer that widened it would be silently undone or would widen it # behind that script's back. HOOK_PLAN="" # "absent" | "present" | "nofile" +GUARD_PLAN="" # the same three, for Amendment 12's UserPromptSubmit entry HOOK_MERGED="" # a staged whole file, ready to move into place # THE DESTINATIONS, PROVED WRITABLE BEFORE ANY OF THE ELEVEN IS PLACED — every @@ -577,7 +622,7 @@ $bad rm -f -- $paths" } -# THE ELEVEN TARGETS, PROVED PLACEABLE BEFORE ANY OF THE EIGHTEEN IS PLACED +# THE ELEVEN TARGETS, PROVED PLACEABLE BEFORE ANY OF THE NINETEEN IS PLACED # (A9 Addendum 4, R-A9-12, ruled on F5 of the #24 review). # # `cp` FOLLOWS A SYMLINK. The loop below tests `[ -e "$target" ]`, compares @@ -639,7 +684,7 @@ path_kind() { # # `plan_skill_targets` let a READ-ONLY `SKILL.md` or command file through. # `install_commands` has already copied the eleven PATH files by the time # `place_skill_and_hook` reaches the `cp`, so an ordinary permission failure -# there left a host with eleven of the eighteen artifacts placed, on the one +# there left a host with eleven of the nineteen artifacts placed, on the one # path in this file that promises all or none. A mode is not an exotic state # either: `chmod 444` on a file a person did not want overwritten is what a # person does. @@ -778,15 +823,20 @@ $bad plan_hook_merge() { local settings existing kind settings="$(claude_home)/settings.json" - command -v jq >/dev/null 2>&1 || die "\`--install\` needs \`jq\` to merge one SessionStart entry into + command -v jq >/dev/null 2>&1 || die "\`--install\` needs \`jq\` to merge its TWO hook entries into $settings, and there is no \`jq\` on this PATH. It refuses rather than rewriting a JSON file by hand, and NOTHING was installed. Install jq, then - re-run. The entry it would have merged: -$(hook_block_text)" + re-run. The entries it would have merged — the \`SessionStart\` one and + Amendment 12's \`UserPromptSubmit\` name guard, which are ONE all-or-nothing + merge: +$(hook_block_text) +$(guard_block_text)" # WHAT IS AT THAT PATH, BEFORE A BYTE OF IT IS READ (#44 round 1, - # `openRepoTools:878`). This file is the eighteenth artifact and the only - # one no walk asked about, and the `present` arm now CHMODs it: `chmod` + # `openRepoTools:878`). This file carries the eighteenth AND NINETEENTH + # artifacts since Amendment 12 adoption act 3 put the name guard beside the + # `SessionStart` entry, and it is the only path no walk asked about; the + # `present` arm now CHMODs it, and `chmod` # follows a symlink, so a `~/.claude/settings.json` linked into a dotfiles # checkout had the 600 stamped on the file at the far end — an unrelated # file re-moded by an installer that reported `already installed @@ -800,7 +850,7 @@ $(hook_block_text)" # place, which takes the DIRECTORY's write bit and not the file's, so a # read-only `settings.json` is one this command may still merge into. if kind="$(path_kind "$settings")"; then - die "\`--install\` merges one SessionStart entry into a REGULAR FILE, and this is not + die "\`--install\` merges its two hook entries into a REGULAR FILE, and this is not one: $settings — $kind A \`chmod\` follows a link, so the mode this installer stamps would land on @@ -814,24 +864,29 @@ $(hook_block_text)" workdir HOOK_MERGED="$WORKDIR/settings.json" + local staged="$WORKDIR/settings.stage.json" if [ ! -e "$settings" ]; then - HOOK_PLAN="nofile" + HOOK_PLAN="nofile"; GUARD_PLAN="nofile" jq -n --arg c "$HOOK_COMMAND" --arg m "$HOOK_MATCHER" --argjson t "$HOOK_TIMEOUT" \ - '{hooks: {SessionStart: [{matcher: $m, hooks: [{type: "command", command: $c, timeout: $t}]}]}}' \ + --arg g "$GUARD_COMMAND" --argjson gt "$GUARD_TIMEOUT" \ + '{hooks: {SessionStart: [{matcher: $m, hooks: [{type: "command", command: $c, timeout: $t}]}], + UserPromptSubmit: [{hooks: [{type: "command", command: $g, timeout: $gt}]}]}}' \ >"$HOOK_MERGED" || die "could not compose $settings; nothing was installed." return 0 fi - jq -e . "$settings" >/dev/null 2>&1 || die "$settings is not valid JSON, so \`--install\` cannot merge one entry into it - and will not rewrite it. NOTHING was installed and nothing already installed was - replaced. Fix that file, then re-run. The entry it would have merged: -$(hook_block_text)" + jq -e . "$settings" >/dev/null 2>&1 || die "$settings is not valid JSON, so \`--install\` cannot merge its two entries into + it and will not rewrite it. NOTHING was installed and nothing already installed was + replaced. Fix that file, then re-run. The entries it would have merged: +$(hook_block_text) +$(guard_block_text)" jq -e '(has("hooks") | not) or (.hooks | type == "object")' "$settings" >/dev/null 2>&1 || - die "$settings has a \`hooks\` that is not an object, so there is nothing to merge an - entry into. NOTHING was installed. The entry it would have merged: -$(hook_block_text)" + die "$settings has a \`hooks\` that is not an object, so there is nothing to merge + either entry into. NOTHING was installed. The entries it would have merged: +$(hook_block_text) +$(guard_block_text)" # THE THREE ANSWERS, in one read: our exact command already there; some # OTHER SessionStart entry that runs `session-start` (a second writer of @@ -863,11 +918,25 @@ $(hook_block_text)" elif ($cmds | map(select(test("session-start"))) | length) > 0 then "conflict" else "absent" end' "$settings" 2>/dev/null || printf 'error')" + # AND THE SAME THREE ANSWERS FOR AMENDMENT 12's ENTRY, keyed on its own verb. + # + # `guard` AS A WHOLE ARGUMENT, and not as a substring — the one place this + # read differs from the one above, and the difference is load-bearing. The + # `SessionStart` arm keys on `session-start`, a word that appears in nothing + # else; `guard` appears in `~/projects/xFactory/guard.sh`, a plain + # `UserPromptSubmit` hook a person's settings may already carry and one this + # installer must leave exactly where it is. So the test is the verb with a + # boundary on both sides: a command RUNNING `guard` collides, a file merely + # NAMED guard does not. That is R-A9-14's rule one event over — the verb is + # what collides; the file that carries it is not. + local existing_guard + existing_guard="$(jq -r --arg c "$GUARD_COMMAND" ' + [ (.hooks.UserPromptSubmit // [])[]? | (.hooks // [])[]? | .command // empty ] as $cmds + | if ($cmds | index($c)) then "present" + elif ($cmds | map(select(test("(^|\\s)guard(\\s|$)"))) | length) > 0 then "conflict" + else "absent" end' "$settings" 2>/dev/null || printf 'error')" + case "$existing" in - present) - HOOK_PLAN="present" - cp -- "$settings" "$HOOK_MERGED" - ;; conflict) die "$settings already carries a SessionStart entry that runs \`session-start\` with a DIFFERENT command string. Exact-string match is the only idempotence this entry @@ -878,20 +947,51 @@ $(hook_block_text) (Amendment 9's adoption act 6 removes the hand-installed entry on each workstation before the first \`--install\` that carries this.)" ;; - absent) - HOOK_PLAN="absent" + present | absent) HOOK_PLAN="$existing" ;; + *) die "$settings could not be read for a SessionStart entry; nothing was installed." ;; + esac + + case "$existing_guard" in + conflict) + die "$settings already carries a UserPromptSubmit entry that runs \`guard\` with a + DIFFERENT command string. Exact-string match is the only idempotence this entry + has, so merging would fire the name guard twice — two refusals for one prompt. + Repairing a setting a person meant is not an installer's to do, so + NOTHING was installed. Remove that entry, then re-run. + The entry this places, byte for byte: +$(guard_block_text)" + ;; + present | absent) GUARD_PLAN="$existing_guard" ;; + *) die "$settings could not be read for a UserPromptSubmit entry; nothing was installed." ;; + esac + + # BOTH MERGES ARE COMPUTED BEFORE EITHER IS PLACED, into one staged file, so + # the all-or-nothing rule covers the PAIR: a settings file that took the + # SessionStart entry and refused the guard is a half-install of a hook pair, + # and nothing on screen would say which half. + cp -- "$settings" "$HOOK_MERGED" || + die "could not stage $settings for merging; nothing was installed." + if [ "$HOOK_PLAN" = absent ]; then jq --arg c "$HOOK_COMMAND" --arg m "$HOOK_MATCHER" --argjson t "$HOOK_TIMEOUT" ' .hooks = (.hooks // {}) | .hooks.SessionStart = ((.hooks.SessionStart // []) + [{ matcher: $m, hooks: [{type: "command", command: $c, timeout: $t}] - }])' "$settings" >"$HOOK_MERGED" || + }])' "$HOOK_MERGED" >"$staged" || die "could not merge the SessionStart entry into $settings; nothing was installed." - ;; - *) - die "$settings could not be read for a SessionStart entry; nothing was installed." - ;; - esac + mv -f -- "$staged" "$HOOK_MERGED" || + die "could not stage the merged $settings; nothing was installed." + fi + if [ "$GUARD_PLAN" = absent ]; then + jq --arg c "$GUARD_COMMAND" --argjson t "$GUARD_TIMEOUT" ' + .hooks = (.hooks // {}) + | .hooks.UserPromptSubmit = ((.hooks.UserPromptSubmit // []) + [{ + hooks: [{type: "command", command: $c, timeout: $t}] + }])' "$HOOK_MERGED" >"$staged" || + die "could not merge the UserPromptSubmit entry into $settings; nothing was installed." + mv -f -- "$staged" "$HOOK_MERGED" || + die "could not stage the merged $settings; nothing was installed." + fi } # The seven placements, AFTER the eleven files and the merge are all in hand. @@ -905,7 +1005,7 @@ place_skill_and_hook() { # EVERY FAILURE HERE IS A `die`, never the shell's own 1. `--install`'s # codes are this toolset's — 0 done, 1 findings printed, 2 a refusal — and # an unguarded `mkdir` or `cp` under `set -e` exits 1, which reads as a - # report of findings from a command that placed eleven of eighteen artifacts. + # report of findings from a command that placed eleven of nineteen artifacts. for target in "$shared_dir/SKILL.md" "$bare_dir/SKILL.md"; do mkdir -p -- "$(dirname -- "$target")" || die "could not create $(dirname -- "$target") for the $name skill. @@ -997,7 +1097,7 @@ place_skill_and_hook() { # `settings.json` this euid can write but does not own takes `jq` and # every check `plan_hook_merge` puts to it, then refuses the mode # stamp, with the eleven commands, the skills and the command files — - # seventeen of the eighteen artifacts — already placed. The guard that + # eighteen of the nineteen artifacts — already placed. The guard that # would catch it first is the same `[ -O ]` `unplaceable_kind`'s own # comment declines, for the same reason: no test in this suite can # make a second account to own this file, and the check would refuse a @@ -1012,9 +1112,21 @@ place_skill_and_hook() { say "SessionStart hook: already installed in $settings (mode restored to 600)" fi ;; - nofile | absent) + esac + case "$GUARD_PLAN" in + present) + # THE GUARD ENTRY'S OWN LINE, AND NO SECOND `chmod`: the mode belongs to + # the FILE and not to either entry, so the arm above has already asked + # for it on every run that reaches this one. Saying it twice would tell + # a person their settings were re-moded twice for one file. + say "UserPromptSubmit guard: already installed in $settings (unchanged)" + ;; + esac + # ONE WRITE FOR THE TWO ENTRIES: they live in one file, and writing it twice + # would move a person's settings through a state carrying one of the pair. + if [ "$HOOK_PLAN" != present ] || [ "$GUARD_PLAN" != present ]; then mkdir -p -- "$(dirname -- "$settings")" || - die "could not create $(dirname -- "$settings") for the SessionStart entry." + die "could not create $(dirname -- "$settings") for the hook entries." # WRITTEN THROUGH A TEMPORARY IN THE SAME DIRECTORY, then moved into # place: `mktemp` + `chmod 600` + `mv -f`, which is what workBenches' # `scripts/setup-claude-profiles.sh:167-194` does to this very file on @@ -1033,13 +1145,15 @@ place_skill_and_hook() { { rm -f -- "$hook_tmp"; die "could not set mode 600 on the merged $settings."; } mv -f -- "$hook_tmp" "$settings" || { rm -f -- "$hook_tmp"; die "could not move the merged settings into $settings."; } - if [ "$HOOK_PLAN" = nofile ]; then - say "SessionStart hook: installed in $settings (the file was created, mode 600)" - else - say "SessionStart hook: merged into $settings (one entry added; written back at mode 600)" - fi - ;; - esac + case "$HOOK_PLAN" in + nofile) say "SessionStart hook: installed in $settings (the file was created, mode 600)" ;; + absent) say "SessionStart hook: merged into $settings (one entry added; written back at mode 600)" ;; + esac + case "$GUARD_PLAN" in + nofile) say "UserPromptSubmit guard: installed in $settings (the file was created, mode 600)" ;; + absent) say "UserPromptSubmit guard: merged into $settings (one entry added; written back at mode 600)" ;; + esac + fi } # ========================================================================= # `wip init` — THE ONE ACT THAT CREATES THE WORKSPACE THOSE COMMANDS READ diff --git a/tests/test_install_skill_and_hook.py b/tests/test_install_skill_and_hook.py index a599ad13..77e492e7 100644 --- a/tests/test_install_skill_and_hook.py +++ b/tests/test_install_skill_and_hook.py @@ -1,22 +1,25 @@ # SPDX-License-Identifier: Apache-2.0 """`--install`'s artifacts that are not one of the ELEVEN files: the two -skills at two paths each, the `/swap` command file at two more, and the one -merged `SessionStart` entry. +skills at two paths each, the `/swap` command file at two more, and the TWO +merged hook entries. lane-collision-protocol Amendment 9(b), inheriting A8 Addendum 2's R-A8-5 unvaried — the skill into the SHARED skills directory every profile reads through its own symlink, a copy into `~/.claude` for a bare `claude` run -outside the launcher, and ONE merged entry in `~/.claude/settings.json`. +outside the launcher, and the merged entries in `~/.claude/settings.json`: +the `SessionStart` one, and — since Amendment 12 adoption act 3 +(opensoft/openRepoTools#25) — the `UserPromptSubmit` NAME GUARD beside it. -WHY THE HOOK IS THE HARD ONE, and why most of this file is about it. Every +WHY THE HOOKS ARE THE HARD ONES, and why most of this file is about them. Every other thing `--install` places is a whole file, so "already there and -identical" is a `cmp`. The hook is one entry inside a file somebody else owns +identical" is a `cmp`. A hook is one entry inside a file somebody else owns and two other programs also write, and its ONLY idempotence is an exact match on the command string. So the tests below are about the four answers that -string can have — present, absent, differing, unreadable — and about the one -rule that makes a wrong answer survivable: the merge is computed with the eleven -files in hand, BEFORE any of them is placed, so a refusal costs a whole install -rather than half of one. +string can have — present, absent, differing, unreadable — asked of EACH entry, +and about the one rule that makes a wrong answer survivable: both merges are +computed with the eleven files in hand, BEFORE any of them is placed, so a +refusal costs a whole install rather than half of one — and, since the pair, +a settings file never passes through a state carrying one entry of the two. NO NETWORK: every run here is from the checkout, so nothing is fetched at all. """ @@ -52,6 +55,22 @@ HOOK_MATCHER = "startup|resume|clear|fork" HOOK_TIMEOUT = 5 +#: AMENDMENT 12's SECOND ENTRY, adoption act 3: the NAME GUARD under +#: `UserPromptSubmit`, `~/projects/xFactory/lanes-edit.sh guard`, timeout 5. +#: Spelled once here for the reason the string above is, and with TWO +#: deliberate differences from it that the tests below assert separately: +#: +#: * NO `|| true`. On `SessionStart` that suffix is the whole safety property +#: — a hook that dies must not break the session it was meant to orient. On +#: `UserPromptSubmit` it would be the OPPOSITE of the mechanism: exit 2 is +#: what BLOCKS a prompt (code.claude.com/docs/en/hooks), and `cmd || true` +#: exits 0 for every code the command can produce. An entry copied WITH its +#: suffix is a guard that refuses nothing, silently, which is the exact +#: state Amendment 12 exists to end. +#: * NO MATCHER. `UserPromptSubmit` has no source to match on. +GUARD_COMMAND = "~/projects/xFactory/lanes-edit.sh guard" +GUARD_TIMEOUT = 5 + def skill_paths(home: Path) -> tuple[Path, Path]: return (home / ".claude-profiles" / "shared" / "skills" / "lane-swap" / "SKILL.md", home / ".claude" / "skills" / "lane-swap" / "SKILL.md") @@ -61,16 +80,24 @@ def settings_of(home: Path) -> Path: return home / ".claude" / "settings.json" -def session_start_commands(home: Path) -> list[str]: +def hook_commands(home: Path, event: str) -> list[str]: data = json.loads(settings_of(home).read_text(encoding="utf-8")) out = [] - for entry in data.get("hooks", {}).get("SessionStart", []): + for entry in data.get("hooks", {}).get(event, []): for hook in entry.get("hooks", []): if "command" in hook: out.append(hook["command"]) return out +def session_start_commands(home: Path) -> list[str]: + return hook_commands(home, "SessionStart") + + +def user_prompt_commands(home: Path) -> list[str]: + return hook_commands(home, "UserPromptSubmit") + + # --- the skill -------------------------------------------------------------- @NEEDS_JQ @@ -475,10 +502,10 @@ def test_a_differing_session_start_entry_refuses_and_places_nothing(tmp_path): (e)'s string, so merging would quietly add a SECOND entry and fire the hook twice. - AND THE COST IS A WHOLE INSTALL, NOT HALF OF ONE: the merge is computed + AND THE COST IS A WHOLE INSTALL, NOT HALF OF ONE: both merges are computed with the eleven files in hand, before any of them is placed, so the bin directory is untouched. That is the same all-or-nothing rule `--install` - already had, extended to the one artifact that is not a whole file. + already had, extended to the two artifacts that are not whole files. """ hand = ('bash -lc \'"$HOME/projects/xFactory/lanes-edit.sh" session-start ' '2>/dev/null || true\'') @@ -589,7 +616,7 @@ def test_a_destination_that_cannot_be_written_refuses_before_anything_is_placed( Amendment 9(b) computes the merge in hand "so a merge that cannot be computed refuses having placed nothing". A filesystem offers no transaction - across eighteen artifacts, so nothing can make the last seven atomic with + across nineteen artifacts, so nothing can make the last eight atomic with the first eleven — but the failure that actually happens is not an exotic one, it is a directory that is not this installer's to write, and that question can @@ -597,7 +624,7 @@ def test_a_destination_that_cannot_be_written_refuses_before_anything_is_placed( Without the check the run places eleven files and some of the remaining artifacts, then dies — leaving a host with commands installed, - no `SessionStart` entry, and an installer that reports the same "already + neither hook entry, and an installer that reports the same "already installed (unchanged)" for the eleven on every re-run while never reaching the one that failed. """ @@ -1162,3 +1189,153 @@ def test_an_entry_that_merely_names_the_same_file_is_no_conflict(tmp_path): result = run_cmd("--install", home=tmp_path) assert result.returncode == 0, result.stdout + result.stderr assert session_start_commands(tmp_path) == [other, HOOK_COMMAND] + + +# --- Amendment 12's second entry: the UserPromptSubmit name guard ------------ +# +# ADOPTION ACT 3, ratified 2026-09-13T18:20:44Z ("Ratify revision 2"): +# "`openRepoTools --install` places the hook string under `UserPromptSubmit` in +# the profile's `settings.json` beside the `SessionStart` one — +# `~/projects/xFactory/lanes-edit.sh guard`, timeout 5". The cases below are +# the pair's own, and each of them is a way the pair can go wrong that a single +# entry could not. + +@NEEDS_JQ +def test_the_guard_entry_is_byte_identical_to_amendment_12(tmp_path): + """THE STRING IS THE WHOLE MECHANISM HERE TOO, and the SHAPE is half of it. + + `UserPromptSubmit` takes no `matcher` — there is no source to match on — + so the entry is `{"hooks": [ ... ]}` and nothing else, and an installer + that copied the `SessionStart` block wholesale would write a key the event + does not have. + """ + result = run_cmd("--install", home=tmp_path) + assert result.returncode == 0, result.stderr + data = json.loads(settings_of(tmp_path).read_text(encoding="utf-8")) + entries = data["hooks"]["UserPromptSubmit"] + assert len(entries) == 1, entries + assert "matcher" not in entries[0], ( + "UserPromptSubmit has no source to match on; the key belongs to the " + "events that do") + assert entries[0]["hooks"] == [{"type": "command", + "command": GUARD_COMMAND, + "timeout": GUARD_TIMEOUT}] + assert GUARD_COMMAND in COMMAND.read_text(encoding="utf-8"), ( + "the command string this test asserts on is the one the file writes") + + +@NEEDS_JQ +def test_the_guard_entry_carries_no_or_true_and_that_is_the_mechanism(tmp_path): + """`|| true` ON THIS ENTRY WOULD INSTALL A GUARD THAT REFUSES NOTHING. + + Exit 2 is what BLOCKS a `UserPromptSubmit` prompt + (code.claude.com/docs/en/hooks) and `cmd || true` exits 0 for every code the + command can produce, so the suffix that is the `SessionStart` entry's whole + safety property is this one's whole defeat — and silently, which is the + exact state Amendment 12 exists to end. Asserted separately from the shape + above because copying the neighbouring string is the obvious way to write + this and the result would pass every other test in this file. + """ + assert run_cmd("--install", home=tmp_path).returncode == 0 + assert user_prompt_commands(tmp_path) == [GUARD_COMMAND] + assert "|| true" not in GUARD_COMMAND + assert "|| true" in HOOK_COMMAND, ( + "the SessionStart entry keeps its suffix; only this one must not") + + +@NEEDS_JQ +def test_installing_twice_adds_one_guard_entry_and_not_two(tmp_path): + """Idempotent by EXACT MATCH, the same as the entry beside it — and firing + this hook twice is two refusals for one prompt.""" + assert run_cmd("--install", home=tmp_path).returncode == 0 + second = run_cmd("--install", home=tmp_path) + assert second.returncode == 0, second.stderr + assert user_prompt_commands(tmp_path) == [GUARD_COMMAND] + assert "UserPromptSubmit guard: already installed" in second.stdout + + +@NEEDS_JQ +def test_a_differing_guard_entry_refuses_and_places_nothing(tmp_path): + """A SECOND WRITER OF THIS VERY HOOK, and the same refusal its neighbour + makes: merging beside it would fire the name guard twice, which is two + refusals for one prompt, and repairing a setting a person meant is not an + installer's to do.""" + rival = "~/bin/lane-hook.sh guard" + settings = settings_of(tmp_path) + settings.parent.mkdir(parents=True) + settings.write_text(json.dumps({"hooks": {"UserPromptSubmit": [ + {"hooks": [{"type": "command", "command": rival, "timeout": 5}]}]}}), + encoding="utf-8") + result = run_cmd("--install", home=tmp_path) + assert result.returncode == 2, result.stdout + result.stderr + assert "a UserPromptSubmit entry that runs `guard` with a" in result.stderr + assert "DIFFERENT command string" in result.stderr + assert "NOTHING was installed" in result.stderr + assert GUARD_COMMAND in result.stderr, "the refusal prints the exact block" + assert user_prompt_commands(tmp_path) == [rival], "it changed the file" + assert not (tmp_path / ".local" / "bin").exists(), ( + "the refusal must cost a whole install, not half of one") + + +@NEEDS_JQ +def test_an_entry_that_merely_names_guard_in_a_path_is_no_conflict(tmp_path): + """THE VERB IS WHAT COLLIDES, NOT THE FILE THAT CARRIES IT — R-A9-14's rule + one event over, and this is where it BITES rather than being a nicety. + + `~/projects/xFactory/guard.sh` is a plain `UserPromptSubmit` hook a person's + settings may already carry, and this estate's do: the usage guard. Keyed on + `guard` as a substring it would be read as a competing writer of Amendment + 12's hook and the whole install would refuse; keyed on the verb with a + boundary on both sides it is what it is — somebody's own setting, kept + exactly where it is, with ours added beside it. + """ + other = "~/projects/xFactory/guard.sh" + settings = settings_of(tmp_path) + settings.parent.mkdir(parents=True) + settings.write_text(json.dumps({"hooks": {"UserPromptSubmit": [ + {"hooks": [{"type": "command", "command": other, "timeout": 5}]}]}}), + encoding="utf-8") + result = run_cmd("--install", home=tmp_path) + assert result.returncode == 0, result.stdout + result.stderr + assert user_prompt_commands(tmp_path) == [other, GUARD_COMMAND] + + +@NEEDS_JQ +def test_the_two_entries_are_one_all_or_nothing_merge(tmp_path): + """A SETTINGS FILE THAT TOOK ONE OF THE PAIR IS A HALF-INSTALL OF A HOOK + PAIR, and nothing on screen would say which half. + + The conflict here is on the GUARD entry alone — the file's `SessionStart` + is empty and would merge cleanly — so this is the case that proves the + all-or-nothing rule covers the pair and not each entry separately. Both + merges are computed into one staged file before either is placed, so the + refusal leaves the settings file exactly as it was: no `SessionStart` entry + either. + """ + rival = "~/bin/other.sh guard" + settings = settings_of(tmp_path) + settings.parent.mkdir(parents=True) + before = json.dumps({"model": "opus", "hooks": {"UserPromptSubmit": [ + {"hooks": [{"type": "command", "command": rival, "timeout": 5}]}]}}) + settings.write_text(before, encoding="utf-8") + result = run_cmd("--install", home=tmp_path) + assert result.returncode == 2, result.stdout + result.stderr + assert settings.read_text(encoding="utf-8") == before, ( + "the refusal wrote nothing at all") + assert session_start_commands(tmp_path) == [], ( + "the SessionStart entry would have merged cleanly, and must not be " + "placed while its pair refuses") + assert not (tmp_path / ".local" / "bin").exists() + + +@NEEDS_JQ +def test_one_write_places_both_entries_in_one_file(tmp_path): + """ONE WRITE FOR THE TWO ENTRIES. They live in one file, and writing it + twice would move a person's settings through a state carrying one of the + pair — which is the half-install the test above refuses, reached by the + happy path instead of by a conflict.""" + result = run_cmd("--install", home=tmp_path) + assert result.returncode == 0, result.stderr + assert session_start_commands(tmp_path) == [HOOK_COMMAND] + assert user_prompt_commands(tmp_path) == [GUARD_COMMAND] + assert stat.S_IMODE(settings_of(tmp_path).stat().st_mode) == 0o600 diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 1c063ece..400736d9 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -83,6 +83,7 @@ cleanup() { [ -n "${LIVE_PID:-}" ] && kill "$LIVE_PID" 2>/dev/null [ -n "${G_PANE:-}" ] && kill "$G_PANE" 2>/dev/null [ -n "${G_OUT:-}" ] && kill "$G_OUT" 2>/dev/null + [ -n "${GD_DUP:-}" ] && kill "$GD_DUP" 2>/dev/null [ -n "${SANDBOX:-}" ] && [ -d "$SANDBOX" ] && rm -rf -- "$SANDBOX" return 0 } @@ -251,6 +252,26 @@ cat > "$SANDBOX/fakebin/tmux" <<'FAKE' # The untargeted reads keep their old seams and DERIVE the two new formats from # `$FAKE_TMUX_WINDOW`, so every case written before Amendment 11 goes on # describing one window rather than three variables that could disagree. +# +# AMENDMENT 12 ADDS TWO COLUMNS AND TWO FORMATS, both optional and both inert +# for every line written before it. The columns are `<%pane>`; a targeted read now matches a `%pane` as well as a ref and +# an `@id`, because the lock types into a PANE. The formats are: +# +# '#{session_name}' Amendment 11(h)'s agreement rule, asked of an +# `@id`: tmux REUSES window ids, so a record's id +# resolving is not the record's window unless the +# session it resolves in is the one the record +# wrote down. +# '#{pane_current_command}' M1's condition on typing at all — `/rename` typed +# at a shell is a command that does not exist. It +# answers `claude` for a line that carries no fifth +# column, so every case written before this one +# describes a pane running claude, which is what +# they all were. +# +# AND `send-keys` IS LOGGED, exactly as `rename-window` always was: the lock and +# the offer's `no` are acts on a pane and this is how a case sees them. case "${1-}" in display-message) shift @@ -263,12 +284,16 @@ case "${1-}" in esac done if [ -n "$fake_t" ]; then - fake_l="$(printf '%s\n' "${FAKE_TMUX_WINDOWS:-}" | awk -F'\t' -v t="$fake_t" '$1 == t || $2 == t { print; exit }')" + fake_l="$(printf '%s\n' "${FAKE_TMUX_WINDOWS:-}" | awk -F'\t' -v t="$fake_t" '$1 == t || $2 == t || ($4 != "" && $4 == t) { print; exit }')" [ -n "$fake_l" ] || exit 1 + fake_sess="$(printf '%s' "$fake_l" | cut -f1)" + fake_cmd="$(printf '%s' "$fake_l" | cut -f5)" case "$fake_f" in - '#{window_id}') printf '%s\n' "$(printf '%s' "$fake_l" | cut -f2)" ;; - '#{window_name}') printf '%s\n' "$(printf '%s' "$fake_l" | cut -f3)" ;; - *) printf '\n' ;; + '#{window_id}') printf '%s\n' "$(printf '%s' "$fake_l" | cut -f2)" ;; + '#{window_name}') printf '%s\n' "$(printf '%s' "$fake_l" | cut -f3)" ;; + '#{session_name}') printf '%s\n' "${fake_sess%%:*}" ;; + '#{pane_current_command}') printf '%s\n' "${fake_cmd:-claude}" ;; + *) printf '\n' ;; esac exit 0 fi @@ -278,11 +303,38 @@ case "${1-}" in '#{session_name}:#{window_index}') printf '%s\n' "${fake_w%%:*}:${FAKE_TMUX_WINDOW_INDEX:-0}" ;; '#S:#I') printf '%s\n' "${fake_w%%:*}:${FAKE_TMUX_WINDOW_INDEX:-0}" ;; '#{window_id}') case "${fake_w#*:}" in @*) printf '%s\n' "${fake_w#*:}" ;; *) printf '%s\n' "${FAKE_TMUX_WINDOW_ID:-@1}" ;; esac ;; - '#W'|'#{window_name}') printf '%s\n' "${FAKE_TMUX_WINDOW_NAME:-claude}" ;; + '#W'|'#{window_name}') + # AMENDMENT 12 — AN OPT-IN STICKY RENAME, and it is opt-in because the + # fake's `#W` has been a CONSTANT for every case written before it: a + # `rename-window` that started changing the answer would rewrite the + # premise of every case that renames and then reads it again. Under + # `$FAKE_TMUX_RENAME_STICKS=1` the rename lands, which is what real tmux + # does and what Amendment 12(h)'s offer needs: `lane-start`'s veto 2 + # reads `#W`, and after a `yes` this window really has been renamed. + fake_n="${FAKE_TMUX_WINDOW_NAME:-claude}" + if [ "${FAKE_TMUX_RENAME_STICKS:-0}" = 1 ] && [ -s "${FAKE_TMUX_NAME_FILE:-/nonexistent}" ]; then + fake_n="$(cat "$FAKE_TMUX_NAME_FILE")" + fi + printf '%s\n' "$fake_n" ;; + '#{pane_id}') printf '%s\n' "${FAKE_TMUX_PANE_ID:-}" ;; '#{pane_pid}') printf '%s\n' "${FAKE_TMUX_PANE_PID:-}" ;; *) printf '\n' ;; esac ;; - rename-window) printf 'rename-window %s\n' "${2-}" >> "${FAKE_TMUX_LOG:-/dev/null}" ;; + rename-window) + printf 'rename-window %s\n' "${2-}" >> "${FAKE_TMUX_LOG:-/dev/null}" + if [ "${FAKE_TMUX_RENAME_STICKS:-0}" = 1 ] && [ -n "${FAKE_TMUX_NAME_FILE:-}" ]; then + printf '%s\n' "${2-}" > "$FAKE_TMUX_NAME_FILE" + fi ;; + send-keys) + shift + fake_st=""; fake_sk="" + while [ $# -gt 0 ]; do + case "$1" in + -t) fake_st="${2-}"; shift 2 ;; + *) fake_sk="${fake_sk}${fake_sk:+ }$1"; shift ;; + esac + done + printf 'send-keys -t %s %s\n' "$fake_st" "$fake_sk" >> "${FAKE_TMUX_LOG:-/dev/null}" ;; *) : ;; esac FAKE @@ -3320,9 +3372,27 @@ write_record_g "$sessions_dir/g-companion.json" "$GH_ID" "$G_OUT" "$g_out_start" bg - "repoGH-1" - 1789235975177 run "$E" live-holder repoGH-1 is "a bg record ALONE is not a holder at all" "$rc" 8 +# AMENDMENT 18(h) MOVED THIS ONE'S EXIT CODE AND NOTHING ELSE IT SAYS, and the +# two rules meeting here are both still true of it. A `bg` record is still NOT A +# HOLDER — `live-holder` answers 8 one case above, nothing says the lane is live, +# and the rival refusal is still absent (Amendment 8, ruling (g)) — and that is +# a fact about the LANE. 18(h) is about the TRANSCRIPT: this run's next act is +# `claude --resume `, a live process already carries that file, +# and the clause says *"it never appends such an id to the row and never +# launches a second resume of it"* (ratified 2026-09-14T13:15:18Z as revision 4; +# opensoft/openRepoTools#39's own incident IS a resume of a transcript a live +# process held). Amendment 8(f)'s orphan sentence is not overturned by this and +# does not reach it either: it rules on *"a row's EARLIER session ids … still +# held by live but idle sessions"*, which nothing resumes; this is the id the +# row ENDS on, which is the one the next `lane-start` opens. The cure is 6(d)'s +# act through the tooling — `lane-end --retire `, which proves the +# process is this lane's duplicate and kills nothing — where 8(f) printed a bare +# `kill`, and #39 is the issue that asked for the difference. run "$START" --dir "$HOME/projects/repoA" repoGH-1 --no-launch -is "…so it cannot refuse a lane either" "$rc" 0 +is "…so it cannot refuse the LANE either — but its transcript is not resumed under one (18(h))" "$rc" 1 hasnt "…and nothing claims the lane is live" "$err" "is live in session" +has "…the refusal being about the file two processes would write" "$err" "another live process already carries transcript $GH_ID" +has "…and naming the retire act, never a kill" "$err" "lane-end repoGH-1 --retire " # -- (g)(1) the env var the harness exports beats every other test ------------ g_clear @@ -3352,12 +3422,19 @@ write_record_g "$sessions_dir/g-orphan.json" "$GH_ID" "$G_OUT" "$g_out_start" \ run "$E" live-holder repoGH-1 is "a live record in no window and no pane tree: still exit 0" "$rc" 0 has "…but the verdict is 'orphan'" "$out" "${US_SEP}orphan" +# THE SAME LINE MOVED BY THE SAME CLAUSE, and Amendment 8(f)'s whole reading is +# still asserted below it: the orphan is REPORTED, named as "not a rival", given +# 6(d)'s act, and the rival refusal ("take repoGH-2") is still absent — the lane +# is NOT taken by it. What 18(h) stops is the line after that report, where this +# run used to go on and `--resume` the very transcript the orphan is holding. run "$START" --dir "$HOME/projects/repoA" repoGH-1 --no-launch -is "lane-start does not refuse a lane over an orphan" "$rc" 0 +is "lane-start does not refuse a LANE over an orphan — and does not resume the transcript it holds (18(h))" "$rc" 1 has "…it reports it with Amendment 6(d)'s act, which is the real one" "$err" "retire it: kill $G_OUT" hasnt "…and never an \`end pid\` subcommand, because there is none" "$err" "end pid" has "…saying what it is" "$err" "orphaned holder, not a rival" hasnt "…and never the rival refusal" "$err" "take repoGH-2" +has "…while the refusal it DOES make names the transcript, not the lane" "$err" "another live process already carries transcript $GH_ID" +has "…and the retire act, which is 6(d)'s through the tooling" "$err" "lane-end repoGH-1 --retire " # -- (g)(4) a REAL rival in another window is still a refusal ----------------- g_clear @@ -5662,6 +5739,625 @@ hasnt "…nor the source token nothing in the estate emits" "$out" "hostname-in- run "$LANES_CMD" /dev/null || printf '')" +GD_SAVE_WINDOWS="${FAKE_TMUX_WINDOWS-}" +GD_SAVE_WINDOW="${FAKE_TMUX_WINDOW-}" +GD_SAVE_NAME="${FAKE_TMUX_WINDOW_NAME-}" +GD_OLD_ID="aaaa0012-0000-4000-8000-aaaa00120000" +GD_LANE_ID="aaaa0012-1111-4000-8000-aaaa00121111" +GD_FREE_ID="aaaa0012-2222-4000-8000-aaaa00122222" +"$E" add-row "| \`repoGD-1\` | harness \`$GD_OLD_ID\` → harness $GD_LANE_ID (transcript uuid; profile t1) | Eagle / test / brett | 2026-09-11T00:00Z | none | handoffs/repoGD/x.md | ACTIVE |" >/dev/null 2>&1 +"$E" add-row "| \`repoGD-2\` | none recorded | Eagle / test / brett | 2026-09-11T00:00Z | none | handoffs/repoGD/y.md | ACTIVE |" >/dev/null 2>&1 +mkdir -p "$HOME/projects/repoGD" +git init -q -b main "$HOME/projects/repoGD" +git -C "$HOME/projects/repoGD" remote add origin "https://github.com/opensoft/repoGD.git" +# THE BINDING (Amendment 18(b)) — the lane's last STARTED, which clause (h) +# rule 2 compares a person's `nameSince` against. +run env LANES_LANE=repoGD-1 LANES_SESSION="$GD_LANE_ID" "$E" log STARTED "lane:repoGD-1" --home opensoft/repoGD +is "the guard lane has a STARTED line, which is its binding" "$rc" 0 + +# ANOTHER PROFILE'S `sessions/` DIRECTORY, MADE ONCE AT THE TOP OF THE SECTION: +# Amendment 18(h)'s duplicate sat in one when it was measured, and three blocks +# below need it — the offer's answer, the duplicate refusals and clause (f)'s +# line. It is a variable this file reads under `set -u`, so it is defined before +# the first case rather than beside the first one that happened to need it. +gd_t2="$HOME/.claude-profiles/profiles/opensoft/team/t2/sessions" +mkdir -p "$gd_t2" + +# The sandbox's window: `gdsess:@12`, pane `%12`, running claude. +export FAKE_TMUX_WINDOW="gdsess:@12" +export FAKE_TMUX_WINDOW_NAME=repoGD-1 +export FAKE_TMUX_WINDOWS="gdsess:0 @12 repoGD-1 %12 claude" + +# The fields the older writers never wrote: `nameSince`, which is an epoch in +# MILLISECONDS on this estate's real records (measured 2026-09-14), and `kind`. +write_record_a12() { # + a_f="$1"; a_sid="$2"; a_pid="$3"; a_start="$4"; a_kind="$5" + a_tgt="$6"; a_name="$7"; a_src="$8"; a_since="$9" + a_t=""; [ "$a_tgt" = "-" ] || a_t="$(printf ',"tmux":"%s"' "$a_tgt")" + a_ns=""; [ "$a_src" = "-" ] || a_ns="$(printf ',"nameSource":"%s"' "$a_src")" + a_si=""; [ "$a_since" = "-" ] || a_si="$(printf ',"nameSince":%s' "$a_since")" + printf '{"pid":%s,"sessionId":"%s","cwd":"%s/projects/repoGD","procStart":"%s","kind":"%s"%s,"name":"%s"%s%s,"status":"busy","updatedAt":9000}\n' \ + "$a_pid" "$a_sid" "$HOME" "$a_start" "$a_kind" "$a_t" "$a_name" "$a_ns" "$a_si" > "$a_f" + return 0 +} +gd_rec() { # + write_record_a12 "$sessions_dir/gd.json" "$1" "$LIVE_PID" "$live_start" interactive "gdsess:@12.%12" "$2" "$3" "$4" +} +gd_hook='{"session_id":"%s","cwd":"%s","prompt":"%s","hook_event_name":"UserPromptSubmit"}' +# `${3-…}` AND NOT `${3:-…}`: an EMPTY cwd is a case of its own — a payload +# that names none is an indeterminate read, not "outside the projects root" — +# and `:-` would substitute the default for it and test nothing. +gd_run() { # [] [] + out="$(printf "$gd_hook" "$1" "${3-$HOME/projects/repoGD}" "${2-do the work}" | "$E" guard 2>"$SANDBOX/stderr")"; rc=$? + err="$(cat "$SANDBOX/stderr")" + return 0 +} +gd_keys() { grep -c 'send-keys' "$FAKE_TMUX_LOG" || :; } +# A rename a person made an hour ago is OLDER than the binding written a moment +# ago; one an hour from now is newer. The clock is read once, in milliseconds. +gd_now_ms="$(( $(date -u +%s) * 1000 ))" +GD_OLD_MS="$(( gd_now_ms - 3600000 ))" +GD_NEW_MS="$(( gd_now_ms + 3600000 ))" + +# ---- THE AGREEING CASE IS SILENT, AND THAT IS THE PROPERTY THAT MAKES A HOOK +# AT EVERY PROMPT BEARABLE AT ALL. +gd_rec "$GD_LANE_ID" repoGD-1 user "$GD_OLD_MS" +gd_before="$(gd_keys)" +gd_run "$GD_LANE_ID" +is "the three names agreeing exits 0" "$rc" 0 +is "…silently: a guard that speaks when nothing is wrong is a guard people turn off" "$err" "" +is "…and types nothing into the pane" "$(gd_keys)" "$gd_before" + +# ---- CLAUSE (e)'s TWO EXEMPTIONS. +gd_run "$GD_LANE_ID" "do the work" "$SANDBOX/elsewhere" +is "a cwd outside the projects root exits 0 — a name there is a title and nothing more (D1)" "$rc" 0 +is "…silently" "$err" "" +out="$(printf '{"session_id":"%s","cwd":"%s/projects/repoGD","agent_id":"agent-7","prompt":"x"}' "$GD_LANE_ID" "$HOME" | "$E" guard 2>"$SANDBOX/stderr")"; rc=$? +err="$(cat "$SANDBOX/stderr")" +is "a SUBAGENT's prompt exits 0 — subagents do not submit prompts and the lane that runs them has passed" "$rc" 0 +is "…silently" "$err" "" + +# ---- (b) ROW 3: THE LOCK RENAMES IT, AND SAYS SO. +gd_rec "$GD_LANE_ID" repogd-7e derived "$GD_OLD_MS" +gd_before="$(gd_keys)" +gd_run "$GD_LANE_ID" +is "a session named something that is no lane BLOCKS the prompt" "$rc" 2 +has "…printing the triple as it stands" "$err" "THE NAME GUARD REFUSES THIS PROMPT" +has "…the window" "$err" "window gdsess:@12 'repoGD-1'" +has "…the session, with the nameSource the lock turns on" "$err" "session $GD_LANE_ID 'repogd-7e' (nameSource derived)" +has "…and the row it is measured against" "$err" "row \`repoGD-1\` — last session $GD_LANE_ID" +has "…and THE LOCK renames it rather than asking a person to" "$err" "SO IT HAS BEEN RENAMED FOR YOU" +is "…by typing into this pane, once (M1: the only path a running session's name has)" "$(( $(gd_keys) - gd_before ))" 1 +has "…the one line there is to type" "$(tail -n1 "$FAKE_TMUX_LOG")" "send-keys -t %12 /rename repoGD-1 Enter" + +# ---- D4: ` (N)` IS A MISMATCH, AND THE SUFFIX IS EVIDENCE. +gd_rec "$GD_LANE_ID" "repoGD-1 (2)" user "$GD_OLD_MS" +gd_run "$GD_LANE_ID" +is "a \` (N)\` title is a mismatch, not the lane (ratified decision D4)" "$rc" 2 +has "…and the lock renames it to the lane" "$err" "SO IT HAS BEEN RENAMED FOR YOU" +has "…with the note that a stale holder of the title was live when it was minted" "$err" "another holder of 'repoGD-1' was live" +has "…naming the retire act and never a kill (A11 clause (k) rule (e))" "$err" "lane-end repoGD-1 --retire " +hasnt "…so no surface offers to kill a process" "$err" "kill $LIVE_PID" + +# ---- AMENDMENT 15: A LOWERCASE WINDOW IS THE SAME LANE, AND THE ROW'S +# SPELLING IS WHAT EVERY MESSAGE AND EVERY TYPED COMMAND CARRIES. +export FAKE_TMUX_WINDOW_NAME=repogd-1 +export FAKE_TMUX_WINDOWS="gdsess:0 @12 repogd-1 %12 claude" +gd_rec "$GD_LANE_ID" repogd-7e derived "$GD_OLD_MS" +gd_before="$(gd_keys)" +gd_run "$GD_LANE_ID" +is "a window spelled in another case resolves to the same row (Amendment 15)" "$rc" 2 +has "…and every message prints the REGISTER's spelling" "$err" "row \`repoGD-1\` — last session" +has "…including the command typed into the pane" "$(tail -n1 "$FAKE_TMUX_LOG")" "/rename repoGD-1" +gd_rec "$GD_LANE_ID" repogd-1 user "$GD_OLD_MS" +gd_run "$GD_LANE_ID" +is "a session name that differs from the row ONLY by case is still renamed to the row's spelling" "$rc" 2 +has "…saying why, in Amendment 15's own terms" "$err" "a lane name is ONE name under any case" +export FAKE_TMUX_WINDOW_NAME=repoGD-1 +export FAKE_TMUX_WINDOWS="gdsess:0 @12 repoGD-1 %12 claude" + +# ---- (b) ROWS 1 AND 2: THE WINDOW IS NOT A LANE. +export FAKE_TMUX_WINDOW_NAME=claude +export FAKE_TMUX_WINDOWS="gdsess:0 @12 claude %12 claude" +gd_rec "$GD_LANE_ID" repoGD-1 user "$GD_OLD_MS" +gd_before="$(gd_keys)" +gd_run "$GD_LANE_ID" +is "a window named \`claude\` holding a session named for a lane BLOCKS the prompt" "$rc" 2 +has "…and names the one command, filled in (F-B6)" "$err" "run: lane-start --no-launch repoGD 1" +has "…saying that this is the case that ran for three days unrecorded" "$err" "THAT IS THE 2026-09-10 CASE" +is "…and types nothing: a window that is not a lane is not renamed without a person" "$(gd_keys)" "$gd_before" +gd_rec "$GD_LANE_ID" zsh derived "$GD_OLD_MS" +gd_run "$GD_LANE_ID" +is "neither name being a lane BLOCKS the prompt too" "$rc" 2 +has "…and the guard refuses to name the lane, because it cannot" "$err" "the guard cannot name one either" +has "…printing the form a person fills in themselves" "$err" "run: lane-start " +export FAKE_TMUX_WINDOW_NAME=repoGD-1 +export FAKE_TMUX_WINDOWS="gdsess:0 @12 repoGD-1 %12 claude" + +# ---- (b) ROWS 5 AND 6: WHERE THE UUID SITS IN THE CELL. +gd_rec "$GD_OLD_ID" repoGD-1 user "$GD_OLD_MS" +gd_run "$GD_OLD_ID" +is "an id IN the cell but not LAST is a superseded transcript and is blocked" "$rc" 2 +has "…named as one" "$err" "this is a SUPERSEDED transcript of lane repoGD-1" +has "…and cured by a real lane-start, which resumes the id the row ends on" "$err" "run: lane-start repoGD 1" +hasnt "…never by the recording act, which would record the WRONG conversation" "$err" "--no-launch" +gd_rec "$GD_FREE_ID" repoGD-1 user "$GD_OLD_MS" +gd_run "$GD_FREE_ID" +is "an id in NO row is the OTHER mismatch and is blocked" "$rc" 2 +has "…named as the harness minting a transcript with nobody acting" "$err" "the harness minted a new transcript" +has "…and cured by the recording act, with no relaunch" "$err" "run: lane-start --no-launch repoGD 1" + +# ---- (b) ROW 7: TWO ROWS FOR ONE NAME, AND A REGISTER THAT CANNOT BE READ. +# SEEDED AND NOT ADDED, because `add-row` is one of the writers Amendment 15(b) +# has REFUSED this very state since #41 landed — which is the point of the +# guard's own row for it: the pair can no longer be created by the tooling, only +# met, exactly as `openXfactory-2`'s was met on 2026-09-13. So it is written the +# way that state actually arrives, straight into the register and pushed, the +# same `add_seed_row` + commit the Amendment 15 section below uses. +add_seed_row "| \`repoGC-1\` | harness \`$GD_FREE_ID\` | Eagle / test / brett | 2026-09-11T00:00Z | none | handoffs/repoGC/x.md | ACTIVE |" +add_seed_row "| \`repogc-1\` | harness \`$GD_LANE_ID\` | Eagle / test / brett | 2026-09-11T00:00Z | none | handoffs/repoGC/y.md | ACTIVE |" +git -C "$WIP" add -A -- lanes >/dev/null 2>&1 +git -C "$WIP" commit -q -m "seed Amendment 12's case pair: two rows for one lane name, which add-row refuses" +git -C "$WIP" pull -q --rebase origin main 2>/dev/null || : +git -C "$WIP" push -q origin main +export FAKE_TMUX_WINDOW_NAME=repoGC-1 +export FAKE_TMUX_WINDOWS="gdsess:0 @12 repoGC-1 %12 claude" +gd_rec "$GD_LANE_ID" repoGC-1 user "$GD_OLD_MS" +gd_run "$GD_LANE_ID" +is "a window whose name matches TWO rows that differ only by case is blocked" "$rc" 2 +has "…naming both spellings" "$err" "repoGC-1 repogc-1" +has "…and the merge that is a person's act, never a tool's (Amendment 15(d))" "$err" "merge them into one row" +export FAKE_TMUX_WINDOW_NAME=repoGD-1 +export FAKE_TMUX_WINDOWS="gdsess:0 @12 repoGD-1 %12 claude" + +gd_rec "$GD_LANE_ID" repoGD-1 user "$GD_OLD_MS" +chmod 000 "$profiles_root" +gd_run "$GD_LANE_ID" +chmod 755 "$profiles_root" +is "session records that cannot be READ block the prompt — fail CLOSED (clause (d))" "$rc" 2 +has "…naming the read that failed" "$err" "session records could not be read" +has "…and the one bypass, which is not an environment flag" "$err" "claude --safe-mode" +# AND THE `SessionStart` HOOK MEETS THE SAME UNREADABLE RECORDS AND STILL EXITS +# 0. The two hooks read the same directories through the same function and are +# held to OPPOSITE contracts — one refuses on a read it could not make (clause +# (d)), the other may never fail at all (R-A8-1) — so the one fixture is asked +# of both. This is the case that was RED while `transcript_holders` left +# `th_here_prof` unset: an unbound variable under `set -u` exits the shell where +# it stands, so `|| :` at the call site caught nothing and `session-start` +# exited 1 with no block at all — the hook breaking the session it exists to +# orient. The read now happens in a subshell, which is what bounds it. +chmod 000 "$profiles_root" +out="$(printf '{"session_id":"%s","source":"resume","cwd":"%s/projects/repoGD"}' "$GD_LANE_ID" "$HOME" | "$E" session-start 2>/dev/null)"; rc=$? +chmod 755 "$profiles_root" +is "…while the SessionStart hook, on the very same unreadable records, still exits 0 (R-A8-1)" "$rc" 0 +# THE TAIL AND NOT THE HEAD: `ssb_tail` prints UNCONDITIONALLY as the last line +# of every block, whichever branch wrote the rest (Amendment 8(e)) — so it is +# the one assertion that says "a WHOLE block came out" without pinning which +# branch this fixture happens to take. +has "…having printed its block down to the tail every branch ends with, because a hook that dies breaks the session it orients" "$out" "(no fetch)" + +# ---- THE WORKSPACE THAT CANNOT BE READ, AND THE ONE NUMBER THAT DECIDES. +# +# EVERY OTHER SUBCOMMAND DIES 1 HERE — the dispatcher's own guard — and `guard` +# is exempt from it and refuses for itself with a 2. That is not a preference: +# only a 2 blocks a `UserPromptSubmit`, so a 1 would print the workspace refusal +# and let the prompt THROUGH, which is the silence Amendment 12 exists to end. +# AGENTS.md's lane paragraph carries the exception for the same reason. +out="$(printf "$gd_hook" "$GD_LANE_ID" "$HOME/projects/repoGD" "do the work" | LANES_FILE="$SANDBOX/no-such-register.md" "$E" guard 2>"$SANDBOX/stderr")"; rc=$? +err="$(cat "$SANDBOX/stderr")" +is "a register this workstation cannot read blocks the prompt with 2, never the dispatcher's 1" "$rc" 2 +has "…saying it is the ROW half of the triple that could not be read" "$err" "the ROW cannot be read" +has "…and the one bypass" "$err" "claude --safe-mode" +out="$(printf '' | LANES_FILE="$SANDBOX/no-such-register.md" "$E" session-start 2>/dev/null)"; rc=$? +is "…while session-start on the same missing register still exits 0, as it always has" "$rc" 0 +gd_run "$GD_LANE_ID" "do the work" "" +is "a payload naming no cwd at all is an indeterminate read, not 'outside the root'" "$rc" 2 +has "…and says which read did not happen" "$err" "carried no \`cwd\`" + +# ---- (b)'s LAST ROW: NOT INSIDE TMUX AT ALL (ratified decision D2, "Refuse"), +# AND THE READ THAT DID NOT HAPPEN IS NOT THE SAME STATE. +# +# A lane RUNS in a tmux window named for it (Rule 4, Amendment 2), so a session +# with no window has no WINDOW half to compare — and this is the one row of the +# table whose command cannot be filled in, because which lane a windowless +# session is is exactly what nothing here knows. `$TMUX` unset is that row; the +# same guard with `$TMUX` set and tmux answering NOTHING is clause (d)'s +# indeterminate read instead, and the two say different things because a person +# who is not in tmux and a person whose tmux is broken have different work to do. +gd_rec "$GD_LANE_ID" repoGD-1 user "$GD_OLD_MS" +gd_before="$(gd_keys)" +out="$(printf "$gd_hook" "$GD_LANE_ID" "$HOME/projects/repoGD" "do the work" | TMUX= "$E" guard 2>"$SANDBOX/stderr")"; rc=$? +err="$(cat "$SANDBOX/stderr")" +is "a session in no tmux window at all is refused (ratified decision D2)" "$rc" 2 +has "…saying a lane RUNS in a window named for it" "$err" "not in a tmux window at all" +has "…printing the triple with nothing in its window half" "$err" "window not in tmux 'none'" +has "…and naming the act, the one cure in this table that cannot be filled in" "$err" "run: lane-start (in a tmux window)" +is "…and typing nothing, because there is no pane to type into" "$(gd_keys)" "$gd_before" +# A `tmux` ON PATH THAT ANSWERS NOTHING, which is what a dead server looks like +# from inside a pane whose session has gone: the WINDOW half was not read, so +# nothing is judged against it (clause (d)) and the bypass is printed. +mkdir -p "$SANDBOX/deadtmux" +printf '#!/usr/bin/env bash\nexit 1\n' > "$SANDBOX/deadtmux/tmux" +chmod +x "$SANDBOX/deadtmux/tmux" +out="$(printf "$gd_hook" "$GD_LANE_ID" "$HOME/projects/repoGD" "do the work" | PATH="$SANDBOX/deadtmux:$PATH" "$E" guard 2>"$SANDBOX/stderr")"; rc=$? +err="$(cat "$SANDBOX/stderr")" +is "…while a tmux that answers nothing is an indeterminate read and not 'no window'" "$rc" 2 +has "…naming the read that did not happen" "$err" "tmux did not answer" +has "…and the one bypass" "$err" "claude --safe-mode" + +# ---- CLAUSE (h) RULE 2: THE OFFER, AND ITS THREE ANSWERS. +GD_OFFER="$CLAUDE_CONFIG_DIR/lanes/offers/$GD_LANE_ID" +gd_rec "$GD_LANE_ID" repoGD-2 user "$GD_NEW_MS" +gd_before="$(gd_keys)" +gd_run "$GD_LANE_ID" +is "a PERSON's rename to another lane's name is blocked, not obeyed" "$rc" 2 +has "…and read as an instruction about the LANE (D5)" "$err" "you renamed this session to repoGD-2" +has "…saying whether that lane exists" "$err" "lane repoGD-2 EXISTS" +has "…with the two answers, and no third" "$err" "Reply \`yes\` to move this window to it" +has "…the other one" "$err" "Reply \`no\` to stay repoGD-1" +is "…and the offer is written under the harness's own config directory, per session" "$( [ -f "$GD_OFFER" ] && echo yes || echo no )" yes +is "…and NOTHING is typed into the pane until a person answers" "$(gd_keys)" "$gd_before" +gd_run "$GD_LANE_ID" "what does that mean?" +is "anything that is not an answer is refused, and the offer is asked again" "$rc" 2 +has "…in the same two words" "$err" "Reply \`yes\` to move this window to repoGD-2" +is "…and the offer is still pending" "$( [ -f "$GD_OFFER" ] && echo yes || echo no )" yes + +# AN ANSWER IS A PROMPT, AND A PROMPT IN A DUPLICATED PROCESS IS REFUSED. +# Clause (h) refuses "every prompt in a process that shares its session id with +# another live one", and consuming a `yes` here would run `lane-start` — a +# register write — out of a process that may not be writing at all, on an offer +# file the OTHER process could equally have answered. So 18(h)'s count is asked +# in front of the answer, and the offer is KEPT for the prompt after the +# duplicate is retired (Copilot round 1 on this PR). +write_record_a12 "$gd_t2/dup-answer.json" "$GD_LANE_ID" "$GD_DUP" "$gd_dup_start" bg "-" "repoGD-1" - "$GD_OLD_MS" +gd_run "$GD_LANE_ID" "yes" +is "a pending offer is NOT answered while a second live process holds this transcript" "$rc" 2 +has "…the duplicate being what the refusal names" "$err" "ANOTHER LIVE PROCESS CARRIES THIS SESSION ID" +hasnt "…so nothing moved" "$err" "MOVED: this window is now lane" +is "…and the offer is kept, to be answered when the other process is retired" "$( [ -f "$GD_OFFER" ] && echo yes || echo no )" yes +rm -f "$gd_t2/dup-answer.json" + +gd_run "$GD_LANE_ID" "no" +is "\`no\` is consumed and blocks that prompt too — an answer is not work" "$rc" 2 +has "…renaming the session back" "$err" "STAYING repoGD-1" +has "…by typing it into the pane" "$(tail -n1 "$FAKE_TMUX_LOG")" "send-keys -t %12 /rename repoGD-1 Enter" +is "…and the offer is consumed" "$( [ -f "$GD_OFFER" ] && echo yes || echo no )" no + +gd_rec "$GD_LANE_ID" repoGD-2 user "$GD_NEW_MS" +gd_run "$GD_LANE_ID" +is "the offer is made again at the next prompt, because the rename still stands" "$rc" 2 +# THE RENAME HAS TO LAND FOR THIS ONE, so the fake's opt-in sticky rename is on: +# `lane-start`'s step 3b veto 2 reads `#W`, and the whole point of the guard +# renaming the window BEFORE it runs lane-start is that the veto then does not +# fire and this session reaches the new lane's session cell. +export FAKE_TMUX_RENAME_STICKS=1 FAKE_TMUX_NAME_FILE="$SANDBOX/tmux-window-name" +rm -f "$FAKE_TMUX_NAME_FILE" +gd_run "$GD_LANE_ID" "yes" +is "\`yes\` is consumed and blocks that prompt" "$rc" 2 +has "…and this window is the other lane now" "$err" "MOVED: this window is now lane repoGD-2" +has "…the window renamed by lane-start's own act" "$(grep 'rename-window' "$FAKE_TMUX_LOG" | tail -n1)" "rename-window repoGD-2" +has "…this session appended to that lane's cell, which is what the next resume follows" \ + "$("$E" register-row repoGD-2 2>/dev/null || :)" "$GD_LANE_ID" +# AND THE GUARD SAYS IT MADE THAT WRITE, because `lane-start` cannot: its step +# 3b veto 1 never takes a uuid another row records, and after the rename this +# one is repoGD-1's. Without this the new row would carry a FRESH id, the next +# prompt would find a lane whose row does not name this transcript, and the +# guard would refuse for ever on a state it created by obeying the person. +has "…the guard having made that one write itself, on the person's yes" "$err" "session cell now ends on $GD_LANE_ID" +has "…and the lane it LEFT marked, so the register says where the work went" \ + "$("$E" register-row repoGD-1 2>/dev/null || :)" "MOVED → repoGD-2" +is "…and the offer is consumed" "$( [ -f "$GD_OFFER" ] && echo yes || echo no )" no +unset FAKE_TMUX_RENAME_STICKS FAKE_TMUX_NAME_FILE + +# ---- AMENDMENT 18(h): ONE LIVE PROCESS PER TRANSCRIPT. +# +# THE SHAPE MEASURED FOUR TIMES ON 2026-09-14, the last at 18:14Z: a `bg` record +# in ONE profile's `sessions/` beside the interactive record in ANOTHER's, both +# live, both carrying one session id. The second profile is the point — the +# sweep is of every profile's directory, not of the asking session's. +export FAKE_TMUX_WINDOW_NAME=repoGD-1 +export FAKE_TMUX_WINDOWS="gdsess:0 @12 repoGD-1 %12 claude" +gd_rec "$GD_LANE_ID" repoGD-1 user "$GD_OLD_MS" +write_record_a12 "$gd_t2/fork.json" "$GD_LANE_ID" "$GD_DUP" "$gd_dup_start" bg "-" "repoGD-1" - "$GD_OLD_MS" +gd_run "$GD_LANE_ID" +is "a second LIVE process on one transcript blocks every prompt (Amendment 18(h))" "$rc" 2 +has "…naming it" "$err" "ANOTHER LIVE PROCESS CARRIES THIS SESSION ID" +has "…by pid, and as the \`bg\` it is" "$err" "pid $GD_DUP (bg, kind bg" +has "…in the profile whose sessions/ directory it sits in — the sweep is of every one" "$err" "profile t2" +has "…and names the retire act, filled in" "$err" "run: lane-end repoGD-1 --retire " +is "…and the read that finds them is a subcommand two other surfaces share" \ + "$("$E" transcript-holders "$GD_LANE_ID" >/dev/null 2>&1; echo $?)" 0 + +run "$START" repoGD 1 --no-launch +is "…and lane-start REFUSES to bind a transcript two processes hold" "$rc" 1 +has "…naming the pid and where it is" "$err" "another live process already carries transcript $GD_LANE_ID" +has "…and the same one act" "$err" "lane-end repoGD-1 --retire " +hasnt "…having launched nothing" "$err" "exec: " + +run "$END" repoGD-1 --retire "$GD_DUP" +is "lane-end --retire retires it, where the row records its id (#39)" "$rc" 0 +has "…proving it first, and writing nothing" "$err" "IS a live DUPLICATE of lane repoGD-1's own transcript" +has "…and naming Amendment 6(d)'s act for a background holder, which has no prompt to type into" "$err" "an idle background session still holding a lane name is ended" +has "…the lane itself untouched" "$err" "ended nothing, wrote nothing and killed nothing" +run "$END" repoGD-1 --retire "$LIVE_PID" +is "…while the WINDOW's own session is refused: retiring the session that IS the lane is not an act" "$rc" 2 +has "…saying so" "$err" "IS lane repoGD-1's own live session" +has "…and naming the act that DOES end a lane" "$err" "To end the lane, run: lane-end repoGD-1" +rm -f "$gd_t2/fork.json" + +# AND THE SECOND PROCESS IS NOT ALWAYS IN ANOTHER PROFILE. One profile can +# resume one transcript twice — no move, no swap, one command — and the record +# that makes is an INTERACTIVE one beside this window's own. Amendment 8 ruling +# (g)'s companion, which this read passes over in silence, is the harness's +# `kind: bg` record and only that; a second interactive holder in the same +# directory is 18(h)'s own case (Copilot round 1 on this PR: the profile alone +# read the cheapest duplicate there is as benign). +write_record_a12 "$sessions_dir/twin.json" "$GD_LANE_ID" "$GD_DUP" "$gd_dup_start" interactive "gdsess:@77.%77" "repoGD-1" - "$GD_OLD_MS" +gd_run "$GD_LANE_ID" +is "a second INTERACTIVE process on one transcript in the SAME profile is a duplicate too" "$rc" 2 +has "…named as one" "$err" "ANOTHER LIVE PROCESS CARRIES THIS SESSION ID" +has "…by pid, and as the interactive record it is" "$err" "pid $GD_DUP (window gdsess:@77" +has "…in this window's own profile, which is no longer a reason to pass it over" "$err" "profile t1" +rm -f "$sessions_dir/twin.json" +# AND THE COMPANION IS THE `bg` RECORD AND NOTHING ELSE IS READ AS ONE: this +# suite's own interactive fork carries `kind: user` (Amendment 11 clause (k)'s +# section above), and "not a session record" — the first spelling of this test +# — would have taken that, and every kind a later harness invents, as the +# harness's benign companion (Copilot round 2 on this PR). +write_record_a12 "$sessions_dir/usertwin.json" "$GD_LANE_ID" "$GD_DUP" "$gd_dup_start" user "-" "repoGD-1" - "$GD_OLD_MS" +gd_run "$GD_LANE_ID" +is "a kind this file has no rule for is a duplicate and not the companion" "$rc" 2 +has "…named with the kind as found" "$err" "kind user" +rm -f "$sessions_dir/usertwin.json" +# THE HARNESS'S OWN COMPANION IS STILL SILENT, and this is the case that says +# the verdict turns on the record's KIND and not on the profile: the same +# profile, the same id, live — and `kind: bg` (Amendment 8 ruling (g), "records +# that share a sessionId are one session, not a queue of rival holders"). +write_record_a12 "$sessions_dir/bgtwin.json" "$GD_LANE_ID" "$GD_DUP" "$gd_dup_start" bg "-" "repoGD-1" - "$GD_OLD_MS" +gd_run "$GD_LANE_ID" +is "…while the harness's own \`bg\` companion beside it is no duplicate at all" "$rc" 0 +is "…and says nothing" "$err" "" +# AND `--retire` MAY NOT NAME IT EITHER, which is the same rule one surface +# over: the pid a person could reach for here is the OTHER HALF of the session +# that IS the lane, so ending it is not Amendment 6(d)'s act on anything +# (Copilot round 2 on this PR — the target was selected by pid alone and the +# verdict was not read). +run "$END" repoGD-1 --retire "$GD_DUP" +is "lane-end refuses to retire the harness's own companion of this lane's live session" "$rc" 2 +has "…naming what the pid actually is" "$err" "is the HARNESS'S OWN COMPANION" +has "…in ruling (g)'s own words" "$err" "not a queue of rival holders" +has "…and naming the read that says which process is which" "$err" "transcript-holders $GD_LANE_ID" +hasnt "…never 6(d)'s ending for it" "$err" "an idle background session still holding a lane" +rm -f "$sessions_dir/bgtwin.json" + +# ---- AMENDMENT 18(h) IN THE LAUNCH BRANCH: THE ID A RUN IS ABOUT TO RESUME. +# The clause counts the holders of "the id it is about to resume or bind", and +# the two are different ids: the binding step asks about THIS WINDOW's session, +# the row-resume branch about the id the ROW records. This window carries no +# record of that second one — the branch is reached only when they differ — so +# every live holder of it is `unrelated` to this window, which is exactly the +# process that must stop the resume (Copilot round 1 on this PR: counting only +# `duplicate` made this branch's check one that could never fire). +GD_ROW3_ID="aaaa0012-3333-4000-8000-aaaa00123333" +"$E" add-row "| \`repoGD-3\` | harness $GD_ROW3_ID (transcript uuid; profile t1) | Eagle / test / brett | 2026-09-11T00:00Z | none | handoffs/repoGD/z.md | ACTIVE |" >/dev/null 2>&1 +gd_tdir="$HOME/.claude/projects/$(sanitize "$HOME/projects/repoGD")" +mkdir -p "$gd_tdir" +printf '{"type":"user"}\n' > "$gd_tdir/$GD_ROW3_ID.jsonl" +write_record_a12 "$gd_t2/fork3.json" "$GD_ROW3_ID" "$GD_DUP" "$gd_dup_start" bg "-" "repoGD-3" - "$GD_OLD_MS" +export FAKE_TMUX_WINDOW_NAME=repoGD-3 +export FAKE_TMUX_WINDOWS="gdsess:0 @12 repoGD-3 %12 claude" +run "$START" repoGD 3 --no-launch +is "lane-start REFUSES to RESUME an id another live process carries (the launch branch)" "$rc" 1 +has "…naming the transcript and the pid" "$err" "another live process already carries transcript $GD_ROW3_ID" +has "…and the retire act for the lane it was starting" "$err" "lane-end repoGD-3 --retire " +hasnt "…having launched nothing" "$out" "--resume $GD_ROW3_ID" +rm -f "$gd_t2/fork3.json" +run "$START" repoGD 3 --no-launch +is "…and resumes it once the other process is gone" "$out" "claude --name repoGD-3 --resume $GD_ROW3_ID" +export FAKE_TMUX_WINDOW_NAME=repoGD-1 +export FAKE_TMUX_WINDOWS="gdsess:0 @12 repoGD-1 %12 claude" + +# ---- THE ONE COMMAND THE OFFER CANNOT FILL IN (F-B6). +# +# A lane named before Rule 4's `-` form — the register carries 22 of +# them — has no ` ` to pass, and `lane_start_args` answers `--dir +# ` for it: a PLACEHOLDER, because nothing in the register, the +# window or this session says where that lane's checkout is. Printing it in a +# diagnostic is honest; RUNNING it would refuse on a directory that does not +# exist and ask the same question at every prompt afterwards (Copilot round 1 +# on this PR). +"$E" add-row "| \`legacy-ui\` | none recorded | Eagle / test / brett | 2026-09-11T00:00Z | none | handoffs/legacy/z.md | ACTIVE |" >/dev/null 2>&1 +gd_rec "$GD_LANE_ID" legacy-ui user "$GD_NEW_MS" +gd_before="$(gd_keys)" +gd_run "$GD_LANE_ID" +is "a rename to a lane named before the \`-\` rule is the offer too" "$rc" 2 +has "…saying that lane exists" "$err" "lane legacy-ui EXISTS" +has "…and saying what it cannot fill in, rather than printing a placeholder as a command" "$err" "needs that lane's DIRECTORY" +gd_run "$GD_LANE_ID" "yes" +is "\`yes\` refuses rather than running a command with a \`\` in it" "$rc" 2 +has "…naming the act with the one word left to the person" "$err" "run: lane-start --no-launch --dir legacy-ui" +has "…having moved and written nothing" "$err" "NOTHING has been renamed, moved or written" +is "…and typing nothing into the pane" "$(gd_keys)" "$gd_before" +is "…with the offer KEPT, because \`no\` still answers it" "$( [ -f "$GD_OFFER" ] && echo yes || echo no )" yes +gd_run "$GD_LANE_ID" "no" +is "…which it does" "$rc" 2 +has "…staying the lane this window is" "$err" "STAYING repoGD-1" +is "…and the offer is consumed" "$( [ -f "$GD_OFFER" ] && echo yes || echo no )" no + +# ---- THE OTHER TEXT A SESSION CELL CAN CARRY INSTEAD OF A UUID. +# +# `lane-start` writes `pending — set by the session's first act` into a row it +# creates with neither a minted uuid nor one it was allowed to take +# (`lane-start:1914`), and after a `yes` that is exactly the shape the +# destination can be in: veto 1 refuses this window's uuid because the row being +# LEFT still records it. Anchored only on `none recorded`, the guard's own last +# write could not be made there and the next prompt refused for ever on a state +# it had created by obeying the person (Copilot round 3 on this PR). +"$E" add-row "| \`repoGD-4\` | pending — set by the session's first act | Eagle / test / brett | 2026-09-11T00:00Z | none | handoffs/repoGD/w.md | ACTIVE |" >/dev/null 2>&1 +export FAKE_TMUX_RENAME_STICKS=1 FAKE_TMUX_NAME_FILE="$SANDBOX/tmux-window-name" +printf 'repoGD-1\n' > "$SANDBOX/tmux-window-name" +gd_rec "$GD_LANE_ID" repoGD-4 user "$GD_NEW_MS" +gd_run "$GD_LANE_ID" +is "a rename to a lane whose cell carries no uuid at all is the offer" "$rc" 2 +has "…naming the lane it would move to" "$err" "you renamed this session to repoGD-4" +gd_run "$GD_LANE_ID" "yes" +is "…and \`yes\` moves the window" "$rc" 2 +has "…saying so" "$err" "MOVED: this window is now lane repoGD-4" +has "…with the guard's own last write anchored on the cell's \`pending\` text" "$err" "session cell now ends on $GD_LANE_ID" +has "…which the register carries" "$("$E" register-row repoGD-4 2>/dev/null || :)" "$GD_LANE_ID" +unset FAKE_TMUX_RENAME_STICKS FAKE_TMUX_NAME_FILE +export FAKE_TMUX_WINDOW_NAME=repoGD-1 +export FAKE_TMUX_WINDOWS="gdsess:0 @12 repoGD-1 %12 claude" + +# ---- M1's TWO CONDITIONS ON TYPING AT ALL, AND THE PANE IT TYPES INTO. +# +# (a) THE PANE IS THE RECORD'S, ELSE THIS ONE. The harness has been seen to +# write no `tmux` at all for a process plainly in a window (Amendment 8, ruling +# (g)'s third fact), and `record_is_here`'s tier 3 still places such a record +# HERE by the pane's own process tree — so the lock had a session to rename and +# no pane to type into, and printed "tmux would not take the keys" for a name it +# could have fixed (Copilot round 2 on this PR). Both hooks run INSIDE the +# session's own pane, so `#{pane_id}` with no `-t` is that pane, asked of tmux +# rather than guessed. The fake answers it only for a case that opts in, so +# every case written before this one still describes a record with a target. +# THE PANE THE FALLBACK FINDS IS A REAL ONE AND NOT THE RECORD'S: a second +# window in the fake's table, so that the pane typed into can only have come +# from tmux's own `#{pane_id}`. M1's gate is asked of it like any other, which +# is why it has to be a pane the table knows: a `%99` in no window at all is +# read as "the pane's current command could not be read" and nothing is typed, +# which is the case below this one and not this one. +export FAKE_TMUX_WINDOWS="gdsess:0 @12 repoGD-1 %12 claude +gdsess:9 @99 repoGD-1 %99 claude" +# THE PANE PID IS THE RECORD'S OWN, AND THAT IS A PORTABILITY FACT AND NOT A +# convenience: `pid_under` answers 0 at its FIRST step when the pid IS the +# ancestor, and walks `/proc` only to climb. macOS has no `/proc`, so a +# record whose pid is a CHILD of this pane is "here" on Linux and nowhere on +# the macOS job — which is what `tests-macos` said at `6cf5359`, three red +# lines under a green Linux run. A record whose pid IS the pane's needs no +# walk at all and is this window's on both. +export FAKE_TMUX_PANE_PID="$LIVE_PID" FAKE_TMUX_PANE_ID="%99" +write_record_a12 "$sessions_dir/gd.json" "$GD_LANE_ID" "$LIVE_PID" "$live_start" interactive "-" repogd-7e derived "$GD_OLD_MS" +gd_before="$(gd_keys)" +gd_run "$GD_LANE_ID" +is "a live record with NO tmux target is still this window's, and the lock still renames it" "$rc" 2 +has "…saying it did" "$err" "SO IT HAS BEEN RENAMED FOR YOU" +is "…typing once" "$(( $(gd_keys) - gd_before ))" 1 +has "…into the pane tmux itself names, the record having named none" "$(tail -n1 "$FAKE_TMUX_LOG")" "send-keys -t %99 /rename repoGD-1 Enter" +# AND A TARGET THAT NAMES ANOTHER WINDOW IS NOT A PANE THIS HOOK MAY TYPE INTO. +# `here` is true by the record's target OR by the pane's process tree, and tmux +# REUSES window ids — so a record that is here BY ANCESTRY can carry a target +# some other window now answers to, and M1's gate cannot catch that one: the +# other pane is running `claude` too (Copilot round 3 on this PR). +export FAKE_TMUX_WINDOWS="gdsess:0 @12 repoGD-1 %12 claude +othersess:0 @5 repoGD-1 %5 claude +gdsess:9 @99 repoGD-1 %99 claude" +export FAKE_TMUX_PANE_PID="$LIVE_PID" FAKE_TMUX_PANE_ID="%99" +write_record_a12 "$sessions_dir/gd.json" "$GD_LANE_ID" "$LIVE_PID" "$live_start" interactive "othersess:@5.%5" repogd-7e derived "$GD_OLD_MS" +gd_before="$(gd_keys)" +gd_run "$GD_LANE_ID" +is "a stale target naming another window is not the pane the lock types into" "$(( $(gd_keys) - gd_before ))" 1 +has "…which is the one tmux names for THIS window" "$(tail -n1 "$FAKE_TMUX_LOG")" "send-keys -t %99 /rename repoGD-1 Enter" +hasnt "…and never the pane the record named, which another session is sitting in" "$(tail -n1 "$FAKE_TMUX_LOG")" "-t %5 " +unset FAKE_TMUX_PANE_ID FAKE_TMUX_PANE_PID +export FAKE_TMUX_WINDOWS="gdsess:0 @12 repoGD-1 %12 claude" + +# (b) AND NOTHING ELSE IS TYPED INTO. M1 ratified the mechanism with its +# condition: the keys go in *"only while the pane's current command is +# `claude`"* — a `/rename` typed at a shell is a command that does not exist and +# typed into an editor is text nobody wrote. Neither half of that gate had a +# case, and it is the only barrier between this hook and somebody else's process +# (Copilot round 2 on this PR). +export FAKE_TMUX_WINDOWS="gdsess:0 @12 repoGD-1 %12 bash" +gd_rec "$GD_LANE_ID" repogd-7e derived "$GD_OLD_MS" +gd_before="$(gd_keys)" +gd_run "$GD_LANE_ID" +is "a pane running something other than claude is NOT typed into" "$(gd_keys)" "$gd_before" +is "…and the prompt is still refused" "$rc" 2 +has "…saying why nothing was typed" "$err" "this pane is not running claude, so NOTHING was typed" +has "…with the line for the person to type in the lane's own pane" "$err" "/rename repoGD-1" +# AND `node` IS NOT `claude` EITHER, which is M1's own word: *"only while the +# pane's current command is `claude`"*. This gate read `claude|node` for one +# round, on the reasoning that the harness is a node program — measured on Eagle +# 2026-09-14T23:4xZ across the twelve panes tmux had, eleven lane panes report +# `claude`, two `bash` and one `zsh`, and none reports `node`. So the second +# word bought nothing and would have let a stray Node REPL take `/rename ` +# as input (Copilot round 4 on this PR). +export FAKE_TMUX_WINDOWS="gdsess:0 @12 repoGD-1 %12 node" +gd_rec "$GD_LANE_ID" repogd-7e derived "$GD_OLD_MS" +gd_before="$(gd_keys)" +gd_run "$GD_LANE_ID" +is "a pane running \`node\` is not typed into either" "$(gd_keys)" "$gd_before" +has "…for the reason M1 gives, in one word" "$err" "this pane is not running claude" +export FAKE_TMUX_WINDOWS="gdsess:0 @12 repoGD-1 %12 claude" +# A PANE THAT COULD NOT BE ASKED IS THE OTHER HALF, and it fails the same way: +# the record names a pane this window does not have, so the targeted read +# resolves nothing and the gate has no answer to test. +write_record_a12 "$sessions_dir/gd.json" "$GD_LANE_ID" "$LIVE_PID" "$live_start" interactive "gdsess:@12.%77" repogd-7e derived "$GD_OLD_MS" +gd_before="$(gd_keys)" +gd_run "$GD_LANE_ID" +is "a pane whose current command could not be read is not typed into either" "$(gd_keys)" "$gd_before" +is "…and that prompt is refused too" "$rc" 2 +has "…fail closed, and saying what a stray \`/rename\` would have landed in" "$err" "the pane's current command could not be read" + +# ---- CLAUSE (f): THE `SessionStart` BLOCK'S OWN LINE, WHICH REFUSES NOTHING. +gd_rec "$GD_LANE_ID" repogd-7e derived "$GD_OLD_MS" +gd_before="$(gd_keys)" +out="$(printf '{"session_id":"%s","source":"resume","cwd":"%s/projects/repoGD"}' "$GD_LANE_ID" "$HOME" | "$E" session-start 2>/dev/null)"; rc=$? +is "the SessionStart hook still exits 0 with the name wrong — it never refuses (R-A8-1)" "$rc" 0 +has "…and says the name was not the lane's, in clause (f)'s own words" "$out" "session name 'repogd-7e' was not the lane 'repoGD-1' — renamed" +is "…having typed the rename itself ((h)1: its ONE act on the pane)" "$(( $(gd_keys) - gd_before ))" 1 +write_record_a12 "$gd_t2/fork.json" "$GD_LANE_ID" "$GD_DUP" "$gd_dup_start" bg "-" "repoGD-1" - "$GD_OLD_MS" +out="$(printf '{"session_id":"%s","source":"resume","cwd":"%s/projects/repoGD"}' "$GD_LANE_ID" "$HOME" | "$E" session-start 2>/dev/null)"; rc=$? +is "…and a duplicate process is SAID and not acted on there" "$rc" 0 +has "…named as the defect it is, with the retire act" "$out" "another live process carries this session id" +has "…and never a kill" "$out" "lane-end repoGD-1 --retire " +rm -f "$gd_t2/fork.json" +# AND IT LEAVES A PERSON'S OWN RENAME TO ANOTHER LANE ALONE. Clause (f) types +# the rename for (h)1's case — DRIFT, a name that is no lane's — while (h) rule +# 2 makes a `nameSource: user` rename to another lane's name an INSTRUCTION, +# answered at the next prompt. This hook runs on every startup, resume, clear +# and fork, so typing over such a name would erase the person's choice before +# the guard could put the question: a rename, then a `/clear`, and the offer +# never happens (Copilot round 4 on this PR). +gd_rec "$GD_LANE_ID" repoGD-2 user "$GD_NEW_MS" +gd_before="$(gd_keys)" +out="$(printf '{"session_id":"%s","source":"resume","cwd":"%s/projects/repoGD"}' "$GD_LANE_ID" "$HOME" | "$E" session-start 2>/dev/null)"; rc=$? +is "the SessionStart hook does not type over a rename a PERSON made to another lane" "$(gd_keys)" "$gd_before" +is "…still exiting 0, as it always does" "$rc" 0 +has "…and saying the guard will put the question at the next prompt" "$out" "the name guard offers the move at your next prompt" +rm -f "$sessions_dir/gd.json" +kill "$GD_DUP" 2>/dev/null || : +# THE FIXTURE PUT BACK EXACTLY AS IT WAS FOUND — an empty capture means the +# variable was unset here and is unset again, which `${x:+…}` alone could not +# express. +if [ -n "$GD_SAVE_WINDOWS" ]; then export FAKE_TMUX_WINDOWS="$GD_SAVE_WINDOWS"; else unset FAKE_TMUX_WINDOWS; fi +if [ -n "$GD_SAVE_WINDOW" ]; then export FAKE_TMUX_WINDOW="$GD_SAVE_WINDOW"; else unset FAKE_TMUX_WINDOW; fi +if [ -n "$GD_SAVE_NAME" ]; then export FAKE_TMUX_WINDOW_NAME="$GD_SAVE_NAME"; else unset FAKE_TMUX_WINDOW_NAME; fi + echo "== Amendment 15: a lane name is one name under any case ==" # RATIFIED BY BRETT HEAP 2026-09-14T00:07:18Z, verbatim *"Ratify as drafted diff --git a/tests/test_openrepotools_command.py b/tests/test_openrepotools_command.py index c810187e..f4039857 100644 --- a/tests/test_openrepotools_command.py +++ b/tests/test_openrepotools_command.py @@ -71,12 +71,23 @@ #: the command file. FETCHED = INSTALLED + SKILL_PATHS + COMMAND_PATHS -#: EIGHTEEN ARTIFACTS, AND THE COUNT IS THE INVARIANT: eleven files in the bin +#: NINETEEN ARTIFACTS, AND THE COUNT IS THE INVARIANT: eleven files in the bin #: directory, two skills in the shared skills directory, their two bare-run -#: copies, one command file at that same pair of destinations, and one merged -#: entry in `~/.claude/settings.json`. Derived from the three lists rather than -#: restated, so adding a skill or a command moves it. -ARTIFACTS = len(INSTALLED) + 2 * len(SKILL_NAMES) + 2 * len(COMMAND_NAMES) + 1 +#: copies, one command file at that same pair of destinations, and TWO merged +#: entries in `~/.claude/settings.json`. Derived from the three lists rather +#: than restated, so adding a skill or a command moves it. +#: +#: THE `+ 2` IS THE ONE TERM THAT IS NOT A LIST, and it moved from `+ 1` on +#: 2026-09-14 for lane-collision-protocol Amendment 12 adoption act 3: the +#: `UserPromptSubmit` NAME GUARD entry is placed beside the `SessionStart` one, +#: in the same file, under the same all-or-nothing merge. There is no list of +#: hook entries to derive it from — the two are different events with different +#: shapes, one with a matcher and one without, and one carrying `|| true` where +#: the other must not — so this is the number that is hand-kept, and the +#: assertions below are what notice when it stops being true. +HOOK_ENTRIES = 2 +ARTIFACTS = (len(INSTALLED) + 2 * len(SKILL_NAMES) + 2 * len(COMMAND_NAMES) + + HOOK_ENTRIES) USAGE_LINES = ( "openRepoTools --install install (or update) the eleven estate and", @@ -92,7 +103,7 @@ WINDOWS_SKIP] #: `--install` HARD-REQUIRES `jq` SINCE lane-collision-protocol AMENDMENT 9(b): -#: one of its eighteen artifacts is a merged entry inside a JSON file somebody +#: two of its nineteen artifacts are merged entries inside a JSON file somebody #: else owns, and the clause has it refuse naming `jq` rather than rewriting #: that file by hand. So a run of `--install` on a host without `jq` is a #: REFUSAL BY DESIGN, and a test that asserts a successful placement there is @@ -107,7 +118,7 @@ #: same fact and is deliberately unmarked. NEEDS_JQ = pytest.mark.skipif( shutil.which("jq") is None, - reason="`--install` merges one SessionStart entry with jq (Amendment 9(b))") + reason="`--install` merges two hook entries with jq (Amendment 9(b), Amendment 12 act 3)") def command_env(home: Path | None = None, env: dict | None = None) -> dict: @@ -352,10 +363,12 @@ def test_installing_twice_changes_nothing(tmp_path): assert second.returncode == 0, second.stderr for name in INSTALLED: assert f"{name}: already installed at" in second.stdout, name - # EIGHTEEN, not eleven: the two skill copies, the two command-file copies - # and the hook entry each report `unchanged` too, and the count is the + # NINETEEN, not eleven: the two skill copies, the two command-file copies + # and BOTH hook entries each report `unchanged` too, and the count is the # invariant Amendment 9(b) names — derived from the three lists, never - # restated, so a new skill or command moves it. + # restated, so a new skill or command moves it. It was eighteen until + # Amendment 12 adoption act 3 put the name guard beside the SessionStart + # entry, which is the `HOOK_ENTRIES` term above. assert second.stdout.count("unchanged") == ARTIFACTS diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 42d45881..9eb79416 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -1406,9 +1406,25 @@ def test_agents_md_is_short_enough_to_be_read(): the merge rather than the branch's own carried forward, which is the only way either of them is ever allowed to be right, and the entry above stays because its ten lines are still the ten in rule 4. + + 224 -> 228 on 2026-09-14, for THE ONE EXIT CODE THIS FILE NOW STATES + WRONG (lane-collision-protocol Amendment 12, opensoft/openRepoTools#25). + Four lines, and every one of them is behaviour rather than prose about + it. The paragraph promised, without qualification, that each of the four + "refuses with exit 1 naming `openRepoTools wip init` where that file does + not answer" — and `lanes-edit.sh guard` deliberately does not: it is a + `UserPromptSubmit` hook, only a 2 blocks a prompt + (code.claude.com/docs/en/hooks), and a 1 there would print the workspace + refusal and let the prompt THROUGH, which is the one failure clause (d) + forbids by name. An assistant reading the old sentence and getting a 2 + back has no way to tell a blocked prompt from the "the object is held" + that 2 has meant here since Amendment 5, which is exactly what the + sentence beneath it — "never read a number without knowing which command + produced it" — exists to prevent, and it could not do that while the + clause above it named the wrong number for one of them. """ lines = (REPO / "AGENTS.md").read_text().splitlines() - assert len(lines) <= 224, f"AGENTS.md is {len(lines)} lines; the cap is 224" + assert len(lines) <= 228, f"AGENTS.md is {len(lines)} lines; the cap is 228" def test_readme_is_short_enough_to_be_read(): @@ -1770,6 +1786,24 @@ def test_readme_is_short_enough_to_be_read(): is this file — and both dated entries above stay, because each still names the lines it bought. + 409 -> 417 on 2026-09-14, for lane-collision-protocol AMENDMENT 12's + SECOND HOOK ENTRY (ratified 2026-09-13T18:20:44Z, "Ratify revision 2"; + adoption act 3, opensoft/openRepoTools#25). Eight lines, and the cap moves + with the TOOLSET as every entry above it does: `--install` now merges TWO + entries into `~/.claude/settings.json` rather than one, so the install + paragraph's "seven things that are not files in that directory" is eight, + the sentence that named one merged entry names two and says what the second + one DOES — at every prompt it checks that the tmux window, the session's own + name and the register row are one lane, and refuses the prompt when they are + not — the conflict sentence gains the `guard` arm, and one line says that a + person's own `UserPromptSubmit` hooks are left where they are, which is the + half of the contract the count cannot show. That last line is not prose + about the install: it is the reason the arm keys on the VERB and not on the + word anywhere in a path, and a reader whose settings already carry + `~/projects/xFactory/guard.sh` — this estate's usage guard, in every + profile — needs it before they run the command. The count below the + paragraph moves 18 -> 19 with them, in the one place it is stated here and + derived in `test_openrepotools_command.py`. 409 -> 422 on 2026-09-14, for the FOUR FINDINGS of #40 — Copilot's review of the re-vendor at `8a36eb3`, filed here rather than taken there because they predate the pin move. Thirteen lines, and every one of them is @@ -1808,9 +1842,21 @@ def test_readme_is_short_enough_to_be_read(): file at the FAR END, and the merge arms replace such a link with the file they wrote. Every dated entry above stays, and none of these six is over a line one of them bought. + + 417 and 428 -> 436 on 2026-09-14, at the merge of this branch with `main` + @ `b5244e2`. Each side raised this cap for its own lines and every dated + entry above stays: Amendment 12's second hook entry bought 8 on this + branch, and #40's four findings and #44's first round bought 13 and 6 on + `main`. They meet in ONE paragraph, the install one, which both sides + rewrote — so the merged sentence carries both facts, the TWO merged + entries with the name guard's own sentence AND the regular-file rule with + the modes `--install` stamps — and a paragraph that says both is longer + than either half of it. The cap is the count of WHAT MERGED and not + either side's number, which is the rule the 2026-09-13 entry above states + in its own words. """ lines = (REPO / "README.md").read_text().splitlines() - assert len(lines) <= 428, f"README.md is {len(lines)} lines; the cap is 428" + assert len(lines) <= 436, f"README.md is {len(lines)} lines; the cap is 436" #: A host-absolute path baked into a committed file (the estate's Rule 1):