Repository navigation
Expand file tree
/
Copy pathlink-estates
More file actions
executable file
·448 lines (425 loc) · 21.2 KB
/
Copy pathlink-estates
File metadata and controls
executable file
·448 lines (425 loc) · 21.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
#!/usr/bin/env bash
#
# link-estates — place the symlinks that let every path a lane already uses
# resolve into this repository. Idempotent; rehearses with --dry-run; never
# deletes a real file.
#
# What it links:
# ~/projects/<estate>/handoffs -> <workspace>/handoffs/<estate>
# for every handoffs/<estate>/ there whose
# ~/projects/<estate> exists on this
# workstation. The rest are reported and
# skipped: an estate this machine has not
# cloned gets no link.
# ~/projects/xFactory/LANES.md -> <workspace>/lanes/LANES.md
# ~/projects/xFactory/lanes-edit.sh -> <the installed lanes-edit.sh>
#
# WHAT IT NO LONGER LINKS, and why (Amendment 9(f)(5)). It used to place
# `~/.local/bin/lane-start` and `~/.local/bin/lane-end` as symlinks into the
# workspace repository. Those two are INSTALLED COMMANDS now — regular files
# `openRepoTools --install` places — and this script places neither. It is not
# only redundancy: workBenches' `setup-estate-commands.sh` refuses the whole
# estate install (exit 1) when any target "already exists as a symlink", and
# `setup.sh` swallows that exit into one ⚠ line, so a host still carrying the
# old symlinks looks set up and has no estate commands on it.
#
# The `lanes-edit.sh` link is KEPT AND REPOINTED at the installed command
# rather than removed, because Amendment 8(e)'s SessionStart hook names
# `~/projects/xFactory/lanes-edit.sh` as its command string and that string has
# to keep working across the move.
#
# Why: lane-collision-protocol Rule 4 / Amendment 4 (handoffs live in the
# person's workspace repository, reached through the estate-folder path) and
# Rule 9 / Amendment 5 (the lane register lives there too). Every existing
# path in every protocol, memory and habit keeps working because of these
# links — nothing else has to be rewritten.
#
# Adopting a workspace repository on a new workstation is ONE command, and it
# is not this one: `openRepoTools wip init` creates or clones it, writes the
# pointer file and then runs this (Amendment 9(c) step 10). This is safe to run
# by hand any time afterwards.
#
# TWO POINTERS, TWO QUESTIONS — and Amendment 9(f)(5) is careful about the
# difference. `$AGENT_PROTOCOL_ROOT/workspace.yaml` answers *where the person's
# data is*; `$OPENREPOTOOLS_BIN_DIR` answers *where the commands were
# installed*. What Amendment 9(a) refuses is two answers to the SAME question,
# which is exactly what deriving the workspace from this script's own location
# was.
#
# Environment: PROJECTS_ROOT overrides ~/projects; OPENREPOTOOLS_BIN_DIR
# overrides ~/.local/bin (where lanes-edit.sh was installed);
# AGENT_PROTOCOL_ROOT overrides ~/.agents; LANES_WORKSPACE_ROOT overrides the
# workspace checkout itself (the test seam).
set -euo pipefail
# --- THE WORKSPACE REPOSITORY (lane-collision-protocol Amendment 9(a)) -------
#
# The person's data — the register `lanes/LANES.md`, the object logs
# `lanes/log/`, the handoffs, the manifests `park` writes, and a
# `lanes/repos.tsv` override — is found through
# `$AGENT_PROTOCOL_ROOT/workspace.yaml`, and NEVER from this file's own
# location. `resume:466` and `status:1673` already read that same file under
# that same name, and Amendment 9(a) mints no second one: a second way to find
# it is a second answer.
#
# Deriving it from here stopped being POSSIBLE the moment this became an
# installed regular file in `~/.local/bin` with no register anywhere near it,
# and it stopped being WANTED for a subtler reason: a file's location on disk
# is evidence about the file, not about a person's data.
#
# THESE FOUR FUNCTIONS ARE A DELIBERATE COPY, byte for byte, across
# `lanes-edit.sh`, `lane-start`, `lane-end` and `link-estates` — the same trade
# openRepoTools already makes for its own fetch shim (`openRepoTools:93-100`).
# A shared `lanes-common.sh` would be a TENTH file for `--install` to place and
# a broken helper the first time somebody copied only some of them. Each of
# these is one file a person has on PATH.
#
# FAILING TO FIND IT IS A REFUSAL, NEVER A GUESS. No file, no `path:`, no
# `repository:`, or a `path:` that is not the root of a checkout of the
# `repository:` named beside it → the caller refuses, exit 1 — the code
# `lanes-edit.sh` has always used for *registry not found* — with the fix
# named: `openRepoTools wip init`. Nothing here ever creates that file, writes
# a register, or falls back to a path derived from its own location. THE
# CHECKOUT TEST IS NEW: nothing validated it before this, so a `path:` pointing
# at some other repository used to be accepted and now is not.
#
# `LANES_WORKSPACE_ROOT` is the test seam and the only override — the seam
# `lane-start` already spelled for handoff resolution, widened to the whole
# question so `test_lane_helpers.sh` can point every helper at one sandbox.
# --- BEGIN shared workspace resolver (Amendment 9(a)) ----------------------
# BYTE-IDENTICAL IN ALL FOUR LANE HELPERS, and held there by
# `tests/test_repo_hygiene.py`. The estate commands carry the same discipline
# for their own estate resolver and for the same reason its guard gives: a
# drifted copy is TWO ANSWERS to one question, and "where is this person's
# workspace" is exactly the question Amendment 9(a) exists to give one answer
# to. Edit it in one file and the test names the other three.
LANES_WS_WHY=""
lanes_ws_yaml() { printf '%s\n' "${AGENT_PROTOCOL_ROOT:-$HOME/.agents}/workspace.yaml"; }
# `key: value` at the TOP LEVEL only, first occurrence, with an inline `#`
# comment and surrounding quotes stripped and a leading `~` expanded against
# $HOME (nothing expands a bare `~` inside a file). The anchored `^key:` never
# reaches into the INDENTED `orgs:` map, which Amendment 9(a) puts out of
# scope: these helpers read `repository:` and `path:` and nothing else, because
# one register in the first workspace repository is the whole rule — the
# register never follows that map. Two lines of sed, because a YAML parser is
# not a dependency any of these files will take for one scalar.
lanes_ws_field() {
local v
v="$(sed -n -e "s/^$1:[[:space:]]*//p" "$2" 2>/dev/null | head -n 1 |
sed -e 's/[[:space:]]*#.*$//' -e 's/[[:space:]]*$//')"
v="${v%\"}"; v="${v#\"}"
v="${v%\'}"; v="${v#\'}"
case "$v" in
'~') v="$HOME" ;;
'~/'*) v="$HOME/${v#'~/'}" ;;
esac
printf '%s\n' "$v"
}
# THE WORKSTATION'S NAME IS READ, NEVER INVENTED — AND NEVER `hostname` INSIDE A
# CONTAINER (lane-collision-protocol Amendment 11, ratified decision 8(d), as
# corrected by A11 Addendum 3's `R-A11-14`).
#
# WHAT WENT WRONG. `opensoft/brett-wip` `origin/main` carries SIX lines in
# `lanes/log/openRepoProject-1.md` whose workstation is a container id — five
# `@0e7d1a79a07e`, one `@55cceb3e5bc9` — in an append-only file, written by every
# writer inside that container and not only by Evidence 6's fork. A row on a
# workstation that does not exist is a row no reader can match, no crossing can
# detect and no `swapped <ws>` can answer for; and the log is never rewritten, so
# each one is wrong for ever.
#
# WHO OWNS THE VALUE. `R-A11-14`: the **workBenches launcher**, which runs on the
# host and exports `LANES_WORKSTATION=<the host's own hostname -s>` into every
# session AND every container it starts, beside the `CLAUDE_PROFILE_NAME` it
# already exports. These helpers READ it. They do not write it, they do not
# derive it from a second file, and THEY NEVER SUBSTITUTE A PLACEHOLDER: an
# `@unknown-workstation` is the same defect one field along from the `unknown`
# clause (e) refuses in the session field, and the rejected alternative — a
# `workstation:` key in `workspace.yaml` — is a SECOND PLACE FOR THE TRUTH TO BE
# WRONG beside the variable the launcher already sets.
#
# THE THREE ANSWERS, as `<name><TAB><source>`:
#
# seam `$LANES_WORKSTATION` is set. This is the ordinary case
# wherever the launcher started the session.
# hostname no seam, and this is NOT a container: `hostname -s`, which
# is what the seam has defaulted to since Amendment 6 and
# what every row written before this rung was written by.
# container-unset no seam, and this IS a container. The NAME still answers,
# because a read in front of every launch may not refuse —
# it will simply match nothing, which is the honest answer —
# but every WRITER refuses, exit 2, naming the variable and
# the launcher that sets it.
#
# IT ANSWERS AS A PAIR, AND THAT IS NOT A STYLE CHOICE. A caller reads the name
# with `$(lanes_workstation)`, which is a COMMAND SUBSTITUTION and therefore a
# subshell: a global the function set there dies with it. The source is on the
# same line as the name, which is the only way out of a subshell there is.
#
# $LANES_IN_CONTAINER 1 or 0, forcing the probe. THE TEST SEAM, and nothing
# else sets it: the suite must be able to exercise both
# halves of this rule on one host, and the host it runs
# on is a container exactly when it is.
lanes_in_container() {
case "${LANES_IN_CONTAINER:-}" in
1) return 0 ;;
0) return 1 ;;
esac
[ -e /.dockerenv ] || [ -e /run/.containerenv ]
}
lanes_workstation_pair() {
local v
if [ -n "${LANES_WORKSTATION:-}" ]; then
printf '%s\t%s\n' "$LANES_WORKSTATION" seam
return 0
fi
v="$(hostname -s 2>/dev/null || hostname 2>/dev/null || printf 'unknown')"
if lanes_in_container; then
printf '%s\t%s\n' "$v" container-unset
else
printf '%s\t%s\n' "$v" hostname
fi
}
# THE NAME ALONE, which is what every writer of the workstation column wants.
lanes_workstation() { lanes_workstation_pair | cut -f1; }
# The ONE SENTENCE a writer refuses with, in one place, so the three writers
# cannot word it three ways. Empty on every other source, so
# `[ -n "$(lanes_workstation_why "$src")" ]` is the whole of the test.
lanes_workstation_why() { # <source>
[ "${1:-}" = container-unset ] || return 0
printf '%s\n' "this is a container and \$LANES_WORKSTATION is not set, so there is no workstation name to write. This host's own \`hostname\` is the CONTAINER'S id, not a workstation, and a row on a workstation that does not exist is a row no reader can match — in a log nothing ever rewrites (Amendment 11, decision 8(d); R-A11-14). The value is the workBenches launcher's to export into every session and container it starts. Set it for this one and re-run: export LANES_WORKSTATION=<this host's name>"
}
# One of the spellings `git remote get-url origin` prints for one GitHub
# repository, reduced to a bare lowercased `<owner>/<name>` so it can be
# compared against workspace.yaml's own `<owner>/<name>`. A URL that is not
# GitHub's is compared as it stands, lowercased — which is what lets a sandbox
# name a bare local origin and still be checked.
lanes_ws_norm() {
local u="${1%.git}"
case "$u" in
https://github.com/*) u="${u#https://github.com/}" ;;
ssh://git@github.com/*) u="${u#ssh://git@github.com/}" ;;
git@github.com:*) u="${u#git@github.com:}" ;;
esac
printf '%s' "$u" | tr '[:upper:]' '[:lower:]'
}
# Do these two name one repository? Equal after normalisation, OR the origin's
# path ENDS in the `<owner>/<name>` the pointer file spells — which is how a
# GitHub Enterprise host, an ssh alias and a local bare mirror laid out by
# owner and name all say the same thing as `https://github.com/<owner>/<name>`.
# The pointer file spells `<owner>/<name>` because that is what a person types;
# `git remote get-url` spells a URL because that is what git stores.
lanes_ws_same_repo() {
local a b
a="$(lanes_ws_norm "$1")"
b="$(lanes_ws_norm "$2")"
[ -n "$a" ] && [ -n "$b" ] || return 1
[ "$a" = "$b" ] && return 0
case "$a" in */"$b") return 0 ;; esac
return 1
}
# Prints the workspace checkout's real root, or returns 1 having set
# $LANES_WS_WHY to the one sentence that says which of the five ways it failed.
# ROOT-NESS IS ITS OWN CHECK: `rev-parse --is-inside-work-tree` and
# `remote get-url origin` both succeed from any SUBDIRECTORY of a work tree, so
# a `path:` naming a subdirectory would otherwise be accepted and resolve
# `<path>/lanes/LANES.md` inside the wrong directory.
lanes_workspace_root() {
local yaml repo path origin toplevel real
LANES_WS_WHY=""
if [ -n "${LANES_WORKSPACE_ROOT:-}" ]; then
printf '%s\n' "$LANES_WORKSPACE_ROOT"
return 0
fi
yaml="$(lanes_ws_yaml)"
if [ ! -r "$yaml" ]; then
LANES_WS_WHY="there is no $yaml"
return 1
fi
repo="$(lanes_ws_field repository "$yaml")"
path="$(lanes_ws_field path "$yaml")"
if [ -z "$repo" ]; then
LANES_WS_WHY="$yaml names no \`repository:\`"
return 1
fi
if [ -z "$path" ]; then
LANES_WS_WHY="$yaml names no \`path:\`"
return 1
fi
if ! git -C "$path" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
LANES_WS_WHY="$yaml names path: $path, which is not a git checkout"
return 1
fi
origin="$(git -C "$path" remote get-url origin 2>/dev/null || :)"
if ! lanes_ws_same_repo "$origin" "$repo"; then
LANES_WS_WHY="$yaml names repository: $repo, but $path is a checkout of ${origin:-no origin at all}"
return 1
fi
toplevel="$(git -C "$path" rev-parse --show-toplevel 2>/dev/null || :)"
real="$(cd -- "$path" 2>/dev/null && pwd -P)" || real=""
if [ -z "$real" ] || [ "$toplevel" != "$real" ]; then
LANES_WS_WHY="$yaml names path: $path, which is not the ROOT of that checkout (${toplevel:-unknown} is)"
return 1
fi
printf '%s\n' "$real"
}
# The refusal's body, so all four helpers say the same thing in the same words.
#
# THE REASON IS RE-DERIVED HERE, IN THE PARENT SHELL, and that is the whole
# point of this line. Every caller reaches the resolver through a command
# substitution — `LANES_WS_ROOT="$(lanes_workspace_root || :)"` — which runs it
# in a SUBSHELL, so the $LANES_WS_WHY it set died with that subshell and the
# parent still holds the empty string it started with. Six distinct diagnoses
# were being computed and all six were being thrown away; every one of the
# refusals Amendment 9(a) enumerates printed the same "no reason recorded".
# The resolver is read-only and cheap — two `sed`s and three `git` reads — and
# it is reached on the refusal path only, so running it again HERE is the one
# place its answer survives to be printed.
lanes_workspace_why() {
[ -n "$LANES_WS_WHY" ] || lanes_workspace_root >/dev/null 2>&1 || :
printf '%s' "the workspace repository could not be found — ${LANES_WS_WHY:-no reason recorded}.
Amendment 9(a): every lane helper reads \`repository:\` and \`path:\` from
$(lanes_ws_yaml), never from its own location. One command writes that
file, creates or clones the repository and places the symlinks:
openRepoTools wip init"
}
# --- END shared workspace resolver ------------------------------------------
projects_root="${PROJECTS_ROOT:-$HOME/projects}"
bin_dir="${OPENREPOTOOLS_BIN_DIR:-$HOME/.local/bin}"
dry_run=0
case "${1:-}" in
"") ;;
--dry-run) dry_run=1 ;;
-h|--help)
echo "Usage: link-estates [--dry-run]"
exit 0
;;
*) echo "Unknown option: $1" >&2; exit 2 ;;
esac
# THE WORKSPACE, not this file's parent. `:33` used to be
# `repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"`, and it
# did not even `readlink -f` first, unlike its two siblings — so as an
# installed command in `~/.local/bin` that resolved to `$HOME/.local` and it
# died at the handoffs check. Amendment 9(a) answers the question properly.
repo_dir="$(lanes_workspace_root || :)"
if [[ -z "$repo_dir" ]]; then
printf 'Error: %s\n' "$(lanes_workspace_why)" >&2
exit 1
fi
# THE LINK TARGET FOR `lanes-edit.sh` IS THE INSTALLED COMMAND, and its absence
# is a refusal that places NOTHING (Amendment 9(f)(5)). Linking to a file that
# is not there would leave `~/projects/xFactory/lanes-edit.sh` dangling — which
# is also the hook's command string, so the failure would be met at every
# session start rather than here.
lanes_edit_cmd="$bin_dir/lanes-edit.sh"
if [[ ! -x "$lanes_edit_cmd" ]]; then
echo "Error: no lanes-edit.sh at $lanes_edit_cmd, so the register's writer cannot be linked." >&2
echo " It is placed there by \`openRepoTools --install\`. Run that first; nothing was linked." >&2
exit 1
fi
[[ -d "$repo_dir/handoffs" ]] || { echo "Error: no handoffs/ in $repo_dir" >&2; exit 1; }
[[ -d "$repo_dir/lanes" ]] || { echo "Error: no lanes/ in $repo_dir" >&2; exit 1; }
[[ -d "$projects_root" ]] || { echo "Error: no projects root: $projects_root" >&2; exit 1; }
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
linked=0; kept=0; skipped=0; aside=0; stale_links=0
# Never delete a real file, a real directory, or anything whose bytes differ
# from what is tracked here. Move it out of the way with a stamped suffix so
# the previous state stays recoverable by hand — the habit scripts/link-home
# established in brettheap/new-workstation, and the one Amendment 4(f) names.
move_aside() {
local live=$1
local target="$live.pre-link-estates-$stamp"
if (( dry_run )); then
printf 'PLAN move aside %s -> %s\n' "$live" "$target"
else
mv -- "$live" "$target"
printf 'ASIDE %s -> %s\n' "$live" "$target"
fi
aside=$((aside + 1))
}
link() {
local target=$1 live=$2
if [[ -L "$live" ]]; then
if [[ -e "$live" && "$(readlink -f -- "$live")" == "$(readlink -f -- "$target")" ]]; then
printf 'KEEP %s -> %s\n' "$live" "$target"
kept=$((kept + 1))
return
fi
# A symlink pointing somewhere else, or a dangling one. Relinking loses
# nothing: the bytes are wherever it pointed, and this prints where.
printf 'RELINK %s (was -> %s)\n' "$live" "$(readlink -- "$live")"
if (( dry_run )); then
printf 'PLAN relink %s -> %s\n' "$live" "$target"
return
fi
rm -- "$live"
elif [[ -e "$live" ]]; then
if [[ -f "$live" && -f "$target" ]] && cmp -s -- "$live" "$target"; then
if (( dry_run )); then
printf 'PLAN replace identical file with link %s\n' "$live"
return
fi
rm -- "$live"
else
move_aside "$live"
(( dry_run )) && { printf 'PLAN link %s -> %s\n' "$live" "$target"; return; }
fi
elif (( dry_run )); then
printf 'PLAN link %s -> %s\n' "$live" "$target"
return
fi
ln -s -- "$target" "$live"
printf 'LINK %s -> %s\n' "$live" "$target"
linked=$((linked + 1))
}
# ---- handoffs: one link per estate this workstation actually has ----------
# A GLOB, not `find … -print0 | sort -z` (A9 Addendum 4, R-A9-11): `sort -z` is
# GNU-only and there is no portable NUL sort to swap it for, while a glob is
# ordered by the shell itself, needs no second process, and carries a name with
# a newline in it — which is the only thing the NUL was ever protecting. An
# empty or absent `handoffs/` leaves the pattern unexpanded and `-d` refuses it.
for estate_dir in "$repo_dir"/handoffs/*/; do
[[ -d "$estate_dir" ]] || continue
estate_dir="${estate_dir%/}"
estate="$(basename -- "$estate_dir")"
if [[ ! -d "$projects_root/$estate" ]]; then
printf 'SKIP %s — no %s on this workstation\n' "$estate" "$projects_root/$estate"
skipped=$((skipped + 1))
continue
fi
link "$estate_dir" "$projects_root/$estate/handoffs"
done
# ---- the lane register, reached from the aggregation folder ---------------
# Rule 9's paths, unchanged since 2026-09-09 and unchanged by Amendment 5 —
# only what they point AT changed.
if [[ -d "$projects_root/xFactory" ]]; then
link "$repo_dir/lanes/LANES.md" "$projects_root/xFactory/LANES.md"
# THE WRITER'S LINK NOW CROSSES THE SEAM: the register is data in the
# workspace repository, its writer is a command in the bin directory, and
# this one link is what keeps Amendment 8(e)'s hook string
# `~/projects/xFactory/lanes-edit.sh session-start || true` working after
# the move.
link "$lanes_edit_cmd" "$projects_root/xFactory/lanes-edit.sh"
else
printf 'SKIP the lane register — no %s on this workstation\n' "$projects_root/xFactory"
skipped=$((skipped + 1))
fi
# ---- the two ends of a lane are NOT linked any more ------------------------
# `openRepoTools --install` places `lane-start` and `lane-end` as regular files
# in the bin directory. A host upgrading from the pre-move world still carries
# this script's old symlinks at those paths; they are reported here, with the
# one command that replaces them, because leaving them is what makes
# workBenches' estate install refuse the whole set (Amendment 9(f)(3)).
for stale in lane-start lane-end; do
if [[ -L "$bin_dir/$stale" ]]; then
printf 'STALE %s is still a symlink -> %s\n' "$bin_dir/$stale" "$(readlink -- "$bin_dir/$stale")"
printf ' It is an installed command now. Remove it and run `openRepoTools --install`:\n'
printf ' rm %s && openRepoTools --install\n' "$bin_dir/$stale"
stale_links=$((stale_links + 1))
fi
done
case ":${PATH:-}:" in
*":$bin_dir:"*) : ;;
*) printf 'NOTE %s is not on this shell'"'"'s PATH — add it, or call the commands by path\n' "$bin_dir" ;;
esac
printf -- '----\n%s linked, %s already right, %s skipped, %s moved aside, %s stale%s\n' \
"$linked" "$kept" "$skipped" "$aside" "$stale_links" \
"$( (( dry_run )) && printf ' (PLAN only — nothing was written)' )"