tickmarkr 2.8.7 → 2.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,10 +15,9 @@
15
15
  #
16
16
  # Prints one wake reason and EXITS. Re-arm after every wake.
17
17
  #
18
- # TKR_CLOSE_PANES="w1:p1,w1:p2" closes those panes when every artifact completes — the answer to panes
19
- # accumulating because nobody was watching for "this seat is finished". It fires ONLY on completion,
20
- # never on timeout. Operator-observed 2026-08-06: five consult panes in one tab left each 14 columns
21
- # wide and unreadable, because closing was a step someone had to remember.
18
+ # Queue row 240; D-2138 add.1 R3, add.4: optional close lists carry ONE recorded address per
19
+ # artifact, in argument order. Completion wakes on delivery; closure additionally requires the
20
+ # current mission's stand-down and live identity matching the spawn ledger. Never close on timeout.
22
21
  set -u
23
22
  # macOS ships bash 3.2, where `set -u` makes "${arr[@]}" on an EMPTY array a fatal unbound-variable
24
23
  # error. Every expansion below therefore uses the ${arr[@]+"${arr[@]}"} guard. Caught by the timeout
@@ -38,6 +37,19 @@ POLL="${3:?}"
38
37
  shift 3
39
38
  [ "$#" -gt 0 ] || { echo "watch-artifacts: no files given" >&2; exit 2; }
40
39
 
40
+ # Refuse ambiguous arming before watching anything; preserve empty fields for count validation.
41
+ CLOSE_HOST=""
42
+ CLOSE_ADDRESSES="${TKR_CLOSE_TERMINALS:-${TKR_CLOSE_PANES:-}}"
43
+ if [ -n "${TKR_CLOSE_TERMINALS:-}" ] && [ -n "${TKR_CLOSE_PANES:-}" ]; then
44
+ echo "watch-artifacts: choose one host close list" >&2; exit 2
45
+ fi
46
+ if [ -n "$CLOSE_ADDRESSES" ]; then
47
+ if [ -n "${TKR_CLOSE_TERMINALS:-}" ]; then CLOSE_HOST=orca; else CLOSE_HOST=herdr; fi
48
+ node -e 'const a=process.argv[1].split(","); if(a.length!==Number(process.argv[2]) || a.some(x=>!x.trim())) process.exit(2)' "$CLOSE_ADDRESSES" "$#" || {
49
+ echo "watch-artifacts: close list requires one recorded address per artifact in argument order" >&2; exit 2;
50
+ }
51
+ fi
52
+
41
53
  # OBS-848: ownership is a pid-file fact, never an argv/path pattern. Two tiers intentionally watch
42
54
  # the same artifacts, so the journal/file argument cannot identify which watcher a seat may retire.
43
55
  PID_DIR="${TKR_STATE_DIR:-.tickmarkr}/overseer/pids"
@@ -55,7 +67,7 @@ trap clear_pid EXIT
55
67
  # two that happened to be in view at the time. Class, not instance.
56
68
  END=$((SECONDS + CAP))
57
69
 
58
- # A file is DONE when the marker appears in its last few lines AND the file has stopped growing.
70
+ # Queue row 240; D-2138 add.4: DONE requires the marker on the final line and stable content.
59
71
  #
60
72
  # Anchored to the tail on purpose: a report that merely *mentions* its own marker mid-body has not
61
73
  # finished, and grepping the whole file would call that done. This brief tells seats to end the file with
@@ -110,7 +122,7 @@ done
110
122
  done_file() { # $1 = index into "$@", $2 = path
111
123
  local i="$1" f="$2" sig
112
124
  [ -s "$f" ] || return 1
113
- tail -5 "$f" 2>/dev/null | grep -qF -- "$MARKER" || return 1
125
+ tail -n 1 "$f" 2>/dev/null | grep -qF -- "$MARKER" || return 1
114
126
  # CONTENT HASH, not size+mtime. The first version of this used `stat` size and mtime and the drill
115
127
  # killed it on the incident's own shape: the correction that cost a ruling was `23 WEAK · 19 SOUND`
116
128
  # -> `25 WEAK · 17 SOUND`, which is **byte-identical in length**, and mtime is whole seconds. A
@@ -124,12 +136,136 @@ done_file() { # $1 = index into "$@", $2 = path
124
136
  return 1
125
137
  }
126
138
 
139
+ # Queue row 240; D-2138 add.4 U04/U05/U08/U09, add.6 V01: both completion exits use
140
+ # this path. A bare address, title alone or old mission's stand-down never licenses a close.
141
+ close_completed() {
142
+ [ -n "$CLOSE_HOST" ] || return 0
143
+ node - "$PID_DIR" "$CLOSE_HOST" "$CLOSE_ADDRESSES" ${READY_INDEXES[@]+"${READY_INDEXES[@]}"} <<'NODE'
144
+ const fs = require("fs");
145
+ const path = require("path");
146
+ const { spawnSync } = require("child_process");
147
+ const [dir, host, addresses, ...ready] = process.argv.slice(2);
148
+ const canonical = p => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
149
+ const field = (o) => typeof o === "string" && o ? o : undefined;
150
+ const checkout = o => field(o.worktree) || field(o.worktreePath) || field(o.worktree_path)
151
+ || field(o.worktree?.path) || (field(o.worktreeId)?.split("::").slice(1).join("::"))
152
+ || field(o.path) || field(o.cwd);
153
+ const same = (a, b) => !!a && !!b && canonical(a) === canonical(b);
154
+ function record(file) {
155
+ const raw = fs.readFileSync(file, "utf8");
156
+ const entries = raw.trim().split("\n").map(line => { const i = line.indexOf("="); return [line.slice(0, i), line.slice(i + 1)]; });
157
+ if (entries.some(([k]) => !k) || new Set(entries.map(([k]) => k)).size !== entries.length) throw Error("malformed spawn/stand-down record");
158
+ return { raw, values: Object.fromEntries(entries) };
159
+ }
160
+ function call(args, refusal = false) {
161
+ const r = spawnSync(host, args, { encoding: "utf8", timeout: 10000 });
162
+ if (r.error || r.signal) throw Error("host call unreadable or timed out");
163
+ let body; try { body = JSON.parse(r.stdout); } catch { throw Error("host response is not JSON"); }
164
+ if (refusal && r.status === 1 && body.ok === false && body.error?.code === "runtime_error" && body.error?.message === "tab_not_found") return body;
165
+ if (r.status !== 0 || body.ok === false || !body.result) throw Error("host call refused");
166
+ return body;
167
+ }
168
+ function orcaList(visual = true) {
169
+ const body = call(["terminal", "list", ...(visual ? ["--include-visual-layouts"] : []), "--limit", "10000", "--json"]);
170
+ if (body.ok !== true || !body._meta?.runtimeId || body._meta.runtimeId === "none" || body.result.truncated === true
171
+ || !Array.isArray(body.result.terminals) || (visual && !Array.isArray(body.result.visualLayouts))) throw Error("unverifiable terminal list");
172
+ return body;
173
+ }
174
+ function tabs(node, out = []) {
175
+ if (Array.isArray(node)) node.forEach(n => tabs(n, out));
176
+ else if (node && typeof node === "object") {
177
+ if (Array.isArray(node.tabs)) out.push(...node.tabs);
178
+ tabs(node.first, out); tabs(node.second, out);
179
+ }
180
+ return out;
181
+ }
182
+ function leaves(node, out = []) {
183
+ if (Array.isArray(node)) node.forEach(n => leaves(n, out));
184
+ else if (node && typeof node === "object") {
185
+ if (node.type === "terminal" && field(node.handle)) out.push(node.handle);
186
+ else { leaves(node.panes, out); leaves(node.first, out); leaves(node.second, out); }
187
+ }
188
+ return out;
189
+ }
190
+ function liveTabs(body) {
191
+ return body.result.visualLayouts.flatMap(layout => tabs(layout.root).map(tab => ({ tab, checkout: checkout(layout), handles: leaves(tab.panes) })));
192
+ }
193
+ function paneList() {
194
+ const result = call(["pane", "list"]).result;
195
+ if (!Array.isArray(result.panes)) throw Error("unverifiable pane list");
196
+ return result.panes;
197
+ }
198
+ for (const index of ready) {
199
+ let attempted = false;
200
+ const address = addresses.split(",")[Number(index)];
201
+ try {
202
+ if (address === process.env.ORCA_TERMINAL_HANDLE || address === process.env.HERDR_PANE_ID) throw Error("own launching terminal");
203
+ const matches = fs.readdirSync(dir).filter(n => n.endsWith(".handle")).map(n => ({ file: path.join(dir, n), seat: n.slice(0, -7), ...record(path.join(dir, n)) }))
204
+ .filter(r => r.values.address === address && r.values.host === host);
205
+ if (matches.length !== 1) throw Error("no unique spawn ledger record names address");
206
+ const r = matches[0], v = r.values;
207
+ if (!v.title || !v.checkout || !v.mission) throw Error("incomplete spawn ledger");
208
+ if (r.seat === process.env.TKR_ARMING_SEAT) throw Error("own arming seat");
209
+ if (v.next_mission || v["next-mission"]) throw Error("named next mission keeps seat open until clear and reassignment");
210
+ const stood = path.join(dir, `${r.seat}.stood-down`);
211
+ const stoodDown = () => {
212
+ if (record(r.file).raw !== r.raw) throw Error("spawn ledger changed during verification");
213
+ if (record(stood).values.mission !== v.mission) throw Error("stand-down does not name current mission");
214
+ };
215
+ stoodDown();
216
+ if (host === "orca") {
217
+ const before = orcaList();
218
+ const rows = before.result.terminals.filter(t => t.handle === address);
219
+ const allTabs = liveTabs(before);
220
+ const owned = allTabs.filter(t => t.tab.title === v.title && same(t.checkout, v.checkout) && t.handles.includes(address));
221
+ if (rows.length !== 1 || !same(checkout(rows[0]), v.checkout) || owned.length !== 1) {
222
+ // Read-only re-resolution by title. Never substitute this candidate into a close command.
223
+ const candidates = allTabs.filter(t => t.tab.title === v.title && same(t.checkout, v.checkout)).flatMap(t => t.handles);
224
+ for (const handle of candidates) call(["terminal", "read", "--terminal", handle, "--screen", "--json"]);
225
+ throw Error("live tab title and checkout mismatch or stale handle; read-only re-resolution by title");
226
+ }
227
+ const tab = owned[0], tabId = tab.tab.id ?? tab.tab.tabId ?? rows[0].tabId;
228
+ if (!tabId) throw Error("owned tab id is unverifiable");
229
+ const finalTerminal = tab.handles.length === 1;
230
+ stoodDown();
231
+ attempted = true;
232
+ const receipt = call(["terminal", "close", "--terminal", address, "--json"], true);
233
+ if (receipt._meta?.runtimeId !== before._meta.runtimeId) throw Error("runtime changed at close");
234
+ if (receipt.ok !== false && (receipt.ok !== true || receipt.result?.close?.handle !== address)) throw Error("close receipt does not name recorded handle");
235
+ const after = orcaList(false), layoutAfter = orcaList();
236
+ if (after._meta.runtimeId !== before._meta.runtimeId || layoutAfter._meta.runtimeId !== before._meta.runtimeId
237
+ || after.result.terminals.some(t => t.handle === address)
238
+ || (finalTerminal && liveTabs(layoutAfter).some(t => (t.tab.id ?? t.tab.tabId) === tabId || (t.tab.title === v.title && same(t.checkout, v.checkout))))
239
+ || (!finalTerminal && tab.handles.filter(h => h !== address).some(h => !after.result.terminals.some(t => t.handle === h)))) throw Error("handle or final owned tab survives close, sibling disappeared, or runtime changed; spec question, never close by title");
240
+ console.log(` CLOSED ${address}: receipt ${receipt.ok === false ? "runtime_error: tab_not_found" : receipt.result.close.handle}; handle absent from terminal list; ${finalTerminal ? `owned tab ${tabId} absent from visual layout` : `shared tab ${tabId} kept for sibling terminals`}`);
241
+ } else {
242
+ const before = paneList(), rows = before.filter(p => p.pane_id === address);
243
+ if (rows.length !== 1 || rows[0].label !== v.title || !same(rows[0].cwd, v.checkout)) throw Error("live pane label and checkout mismatch");
244
+ stoodDown();
245
+ attempted = true;
246
+ call(["pane", "close", address]);
247
+ const after = paneList();
248
+ if (after.some(p => p.pane_id === address)) throw Error("pane survives close");
249
+ // A shared consultant tab belongs to several seats. Only a recorded sole owner reaps its tab.
250
+ if (v.tab && rows[0].tab_id === v.tab && !after.some(p => p.tab_id === v.tab)) {
251
+ call(["tab", "close", v.tab]);
252
+ const listed = call(["tab", "list"]).result.tabs;
253
+ if (!Array.isArray(listed) || listed.some(t => (t.tab_id ?? t.id) === v.tab)) throw Error("owned tab survives close");
254
+ }
255
+ console.log(` CLOSED ${address}: spawn record matched label and cwd; pane absent from pane list`);
256
+ }
257
+ } catch (error) { console.log(` ${attempted ? "CLOSE UNPROVEN" : "LEFT OPEN"} ${address}: ${error.code === "ENOENT" ? "missing spawn or stand-down record" : error.message}`); }
258
+ }
259
+ NODE
260
+ }
261
+
127
262
  while :; do
128
263
  pending=()
129
264
  ready=()
265
+ READY_INDEXES=()
130
266
  i=0
131
267
  for f in "$@"; do
132
- if done_file "$i" "$f"; then ready+=("$f"); else pending+=("$f"); fi
268
+ if done_file "$i" "$f"; then ready+=("$f"); READY_INDEXES+=("$i"); else pending+=("$f"); fi
133
269
  i=$((i + 1))
134
270
  done
135
271
 
@@ -152,6 +288,7 @@ while :; do
152
288
  if [ -s "$f" ]; then echo " PARTIAL $(wc -c <"$f" | tr -d ' ') bytes, no marker yet $f last-line: $(tail -n 1 "$f" | tr -d '\0' | cut -c1-72)"
153
289
  else echo " NOT STARTED $f <- check whether that seat is BLOCKED; a stalled seat writes nothing"; fi
154
290
  done
291
+ close_completed
155
292
  exit 0
156
293
  fi
157
294
 
@@ -161,17 +298,7 @@ while :; do
161
298
  echo " RE-HASH BEFORE YOU QUOTE IT. The marker means the file ENDS with '$MARKER', never that its"
162
299
  echo " author has stopped: a rewrite-in-place keeps the marker terminal at every instant. If shasum"
163
300
  echo " now differs from the value above, you are reading a superseded file."
164
- # A seat whose artifact is COMPLETE has nothing left to give: the report is the archive, the pane is
165
- # not. Closing here is safe precisely because the marker — not `done`, not a size — is the trigger,
166
- # so this can never reap a seat mid-write. Only on the COMPLETE path: on a timeout the seats are
167
- # still working and closing one would destroy the work being waited for.
168
- if [ -n "${TKR_CLOSE_PANES:-}" ]; then
169
- for pane in ${TKR_CLOSE_PANES//,/ }; do
170
- # `herdr pane close` exits 0 even for a pane that is already gone, so report the body rather
171
- # than the status — an exit code here would claim a close that may never have happened.
172
- printf ' CLOSED %s -> %s\n' "$pane" "$(herdr pane close "$pane" 2>&1 | head -c 80)"
173
- done
174
- fi
301
+ close_completed
175
302
  exit 0
176
303
  fi
177
304