tickmarkr 2.1.2 → 2.1.4

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.
@@ -1,4 +1,5 @@
1
- import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { dirname, join } from "node:path";
3
4
  import { stateDirName, tickmarkrDir } from "../graph/graph.js";
4
5
  // SUP-01: supervision liveness as FILE STATE, not as a report — lock.ts's proven shape, one file per
@@ -29,20 +30,88 @@ export const SUPERVISION_FUTURE_GRACE_MS = 1_000;
29
30
  // The supervision seats this harness has. An unlisted tier is an INVISIBLE tier, which is the failure
30
31
  // mode itself — an auditor read "no watchers were ever armed" off a surface that named none. Adding a
31
32
  // seat means adding it here, and `status` then renders it whether or not it has ever beaten.
32
- export const SUPERVISION_TIERS = ["orchestrator", "overseer", "watch"];
33
+ // SUP-05: CONTEXT is per SEAT, so its tiers are per seat too. One shared `context` tier would be read
34
+ // by both supervising seats and beaten by whichever of them still had a watcher, so a live overseer
35
+ // watcher would render the dead orchestrator one as armed — the mask this whole instrument exists to
36
+ // remove. The enumeration is CLOSED at one supervision tier and one context tier per supervising
37
+ // seat: orchestrator and overseer. `watch` is the sole process-owned tier and is armed seatlessly by
38
+ // the live unbounded board.
39
+ export const SUPERVISION_TIERS = [
40
+ "orchestrator", "orchestrator-context", "overseer", "overseer-context", "watch",
41
+ ];
42
+ // The tiers whose records must NAME the seat behind them. A one-shot `tickmarkr beat` records a pid
43
+ // that has already exited by the time anyone reads it, and an instant — nothing a reader can attribute
44
+ // to a seat. Measured 2026-08-26: a consult seat of another tier ran the documented beat loop and the
45
+ // board read that tier armed with no seat of that tier having armed anything. ARMED-and-seatless reads
46
+ // as coverage, which is worse than ABSENT, so on these tiers a record that names no seat is not a beat.
47
+ export const SUPERVISION_SEAT_TIERS = [
48
+ "orchestrator", "orchestrator-context", "overseer", "overseer-context",
49
+ ];
50
+ /** Does this tier's record have to name the seat it speaks for? */
51
+ export const isSeatTier = (tier) => SUPERVISION_SEAT_TIERS.includes(tier);
33
52
  // PURE path math: stateDirName, never tickmarkrDir — the latter mkdirs the state dir and writes its
34
53
  // .gitignore, so routing a READER through it would make status create the very tree it reports on.
35
- export const supervisionBeatPath = (repoRoot, tier) => join(repoRoot, stateDirName(repoRoot), "supervision", `${tier}.beat`);
54
+ const supervisionDir = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "supervision");
55
+ export const supervisionBeatPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.beat`);
36
56
  /** Where a watcher records that it STOOD DOWN. Its own file: the beat keeps meaning only "alive". */
37
- export const supervisionStandDownPath = (repoRoot, tier) => join(repoRoot, stateDirName(repoRoot), "supervision", `${tier}.standdown`);
57
+ export const supervisionStandDownPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.standdown`);
58
+ // SUP-06: PRESENCE — one file per ARMED WATCHER, because a tier may legitimately have more than one.
59
+ // Two boards watch one repo the moment an operator opens a second pane, and the tier is armed while
60
+ // EITHER of them lives. The stand-down marker speaks for the whole tier, so the first board out
61
+ // writing it renders the second board's own tier DISARMED until that board's next beat — a live seat
62
+ // reported down, which is the under-claiming half of exactly the lie this instrument exists to remove.
63
+ // So the marker is written only by the LAST watcher out, and these files are how it knows it is last.
64
+ // Freshness decides presence, never a process table (SUP-02): a watcher that is killed cannot remove
65
+ // its own file, and an unremoved file ages past the same ceiling a beat does and stops counting.
66
+ const presencePrefix = (tier) => `${tier}.live.`;
67
+ const supervisionPresencePath = (repoRoot, tier, id) => join(supervisionDir(repoRoot), `${presencePrefix(tier)}${id}`);
68
+ /** Every presence file on this tier, by name. Missing directory ⇒ nobody is present. */
69
+ const presenceNames = (repoRoot, tier) => {
70
+ try {
71
+ return readdirSync(supervisionDir(repoRoot)).filter((n) => n.startsWith(presencePrefix(tier)));
72
+ }
73
+ catch {
74
+ return [];
75
+ }
76
+ };
77
+ /**
78
+ * Stale peers observed in ONE directory snapshot, or undefined when that same snapshot saw a live
79
+ * one. A later arm has a new id and is deliberately absent from the returned cleanup set.
80
+ */
81
+ function stalePeersIfLast(repoRoot, tier, id, now = Date.now()) {
82
+ const own = `${presencePrefix(tier)}${id}`;
83
+ const stale = [];
84
+ for (const name of presenceNames(repoRoot, tier)) {
85
+ if (name === own)
86
+ continue;
87
+ try {
88
+ if (now - statSync(join(supervisionDir(repoRoot), name)).mtimeMs <= SUPERVISION_STALE_MS)
89
+ return undefined;
90
+ stale.push(name);
91
+ }
92
+ catch { /* a vanished peer needs no cleanup and is not evidence of a live watcher */ }
93
+ }
94
+ return stale;
95
+ }
38
96
  // WRITER — a watcher's own call, on its own tier, every SUPERVISION_BEAT_MS. Never a reader's: the
39
97
  // purity fence (status --watch leaves the state dir byte-identical) is the test that catches a reader
40
98
  // that beats on the watcher's behalf, which would report every dead tier as healthy.
41
- export function beatSupervision(repoRoot, tier) {
99
+ function writeSupervisionBeat(repoRoot, tier, seat, armId) {
100
+ // A seat tier may not be armed anonymously, and the refusal belongs HERE rather than only in the
101
+ // verb: any caller that could write a seatless record could arm a tier nobody occupies.
102
+ if (isSeatTier(tier) && !seat?.trim()) {
103
+ throw new Error(`${tier} is a per-seat tier — a beat must declare the seat identity it speaks for`);
104
+ }
42
105
  tickmarkrDir(repoRoot); // the write path DOES create — beats land inside the gitignored state dir
43
106
  const p = supervisionBeatPath(repoRoot, tier);
44
107
  mkdirSync(dirname(p), { recursive: true });
45
- writeFileSync(p, JSON.stringify({ tier, pid: process.pid, beatAt: new Date().toISOString() }) + "\n");
108
+ writeFileSync(p, JSON.stringify({
109
+ tier, ...(seat ? { seat } : {}), ...(armId ? { armId } : {}),
110
+ pid: process.pid, beatAt: new Date().toISOString(),
111
+ }) + "\n");
112
+ }
113
+ export function beatSupervision(repoRoot, tier, seat) {
114
+ writeSupervisionBeat(repoRoot, tier, seat);
46
115
  }
47
116
  // THE WATCHER-FACING ENTRY POINT — the loop SUPERVISION_BEAT_MS actually drives. A supervising seat
48
117
  // calls this once at the top of its watch and holds the handle for the duration; a seat that dies,
@@ -51,13 +120,15 @@ export function beatSupervision(repoRoot, tier) {
51
120
  // an unref'd interval that never holds the watcher's event loop open, and a beat failure that is
52
121
  // swallowed rather than crashing the watcher — an unwritten beat ages out and reads STALE, which is
53
122
  // the truth. The FIRST beat is swallowed on the same rule: a cosmetic instrument that could not write
54
- // must not take the run down with it. NOT called from `status`: status is a reader (its purity fence
55
- // is a test); the in-repo callsite is runDaemon, which arms the orchestrator tier for the life of
56
- // the run. A tier nobody arms reads ABSENT — exactly what ABSENT means, not a false "healthy".
123
+ // must not take the watcher down with it. The sole production in-repo callsite is `status --watch`
124
+ // when UNBOUNDED, which arms `watch` seatlessly for the life of the board — a bounded render is a
125
+ // reader and arms nothing, which is the purity fence D-02 tests. Supervising seats write their named
126
+ // tiers through the shipped beat verb instead. A tier nobody arms reads ABSENT — exactly what ABSENT
127
+ // means, not a false "healthy".
57
128
  //
58
129
  // Arming CLEARS any prior stand-down record: a tier that stood down and armed again is armed, and a
59
130
  // marker left behind by the last run would otherwise report the live one as stood down forever.
60
- export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS) {
131
+ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, seat) {
61
132
  // The clearing gets its OWN try: a cleanup that cannot complete (a directory dropped at the marker
62
133
  // path, a permission) must not cost the first beat. Sharing one try did exactly that — the tier armed
63
134
  // with NO beat while the old marker stayed on disk, the one combination that reports a live watcher
@@ -66,38 +137,64 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS) {
66
137
  rmSync(supervisionStandDownPath(repoRoot, tier), { force: true, recursive: true });
67
138
  }
68
139
  catch { /* uncleared: the reader validates the marker and a newer beat outranks it — never masked */ }
69
- try {
70
- beatSupervision(repoRoot, tier);
71
- }
72
- catch { /* repo gone / disk full — the tier reads ABSENT rather than crashing its watcher */ }
73
- const timer = setInterval(() => {
140
+ // This watcher's own identity fences BOTH its presence and its stand-down against every later arm.
141
+ // pid alone collides between two boards in one host (and after pid reuse); a UUID never aliases the
142
+ // stale presence of a killed process that a later last-one-out cleanup may already have observed.
143
+ const id = `${process.pid}.${randomUUID()}`;
144
+ const presence = supervisionPresencePath(repoRoot, tier, id);
145
+ // Presence is refreshed with the beat, so it ages by the same clock and needs no separate loop.
146
+ const mark = () => {
74
147
  try {
75
- beatSupervision(repoRoot, tier);
148
+ writeSupervisionBeat(repoRoot, tier, seat, id); // creates the directory presence is written into
149
+ writeFileSync(presence, JSON.stringify({ tier, pid: process.pid, id }) + "\n");
76
150
  }
77
- catch { /* repo gone / disk full — let the beat expire */ }
78
- }, beatMs);
151
+ catch { /* repo gone / disk full / no seat — the tier ages out rather than crashing its watcher */ }
152
+ };
153
+ mark();
154
+ const timer = setInterval(mark, beatMs);
79
155
  timer.unref();
80
156
  let stoodDown = false;
81
157
  return {
82
- // Stand down: stop beating AND say so. Idempotent because the daemon disarms from more than one
83
- // exit path (its signal reaper exits the process before the finally can run), and the recorded
84
- // instant belongs to the first stand-down.
158
+ // Stand down: stop beating AND say so. Idempotence lets a watcher safely share cleanup across
159
+ // multiple exit paths; the recorded instant belongs to the first stand-down.
85
160
  disarm: () => {
86
161
  if (stoodDown)
87
162
  return;
88
163
  stoodDown = true;
89
164
  clearInterval(timer);
165
+ try {
166
+ rmSync(presence, { force: true, recursive: true });
167
+ }
168
+ catch { /* ages out on its own */ }
169
+ // The marker speaks for the TIER, so only the last watcher out may write one: a peer still
170
+ // present means the tier is not down, and saying it is would render that live board's own tier
171
+ // DISARMED. The snapshot also fixes the cleanup set: a board arming after this decision receives
172
+ // a new id, so this older board can neither sweep its presence nor claim its beat stood down.
173
+ const stalePeers = stalePeersIfLast(repoRoot, tier, id);
174
+ if (stalePeers === undefined)
175
+ return;
90
176
  // Published ATOMICALLY — written aside, renamed over — so no reader can ever meet a half-written
91
177
  // marker. A torn marker is rejected anyway (see readStandDown), but a stand-down that reads as
92
178
  // garbage is a stand-down that reports as a death, and the rename costs one line.
93
179
  const p = supervisionStandDownPath(repoRoot, tier);
94
- const tmp = `${p}.${process.pid}.tmp`;
180
+ const tmp = `${p}.${id}.tmp`;
95
181
  try {
96
182
  mkdirSync(dirname(p), { recursive: true });
97
- writeFileSync(tmp, JSON.stringify({ tier, pid: process.pid, disarmedAt: new Date().toISOString() }) + "\n");
183
+ writeFileSync(tmp, JSON.stringify({
184
+ tier, ...(seat ? { seat } : {}), armId: id,
185
+ pid: process.pid, disarmedAt: new Date().toISOString(),
186
+ }) + "\n");
98
187
  renameSync(tmp, p);
99
188
  }
100
189
  catch { /* unrecordable stand-down ages out as STALE — pessimistic, which is the safe way to fail */ }
190
+ // Sweep only stale names in the pre-publication snapshot. Re-reading here used to catch and
191
+ // delete a newer board that armed between the peer check and this older board's rename.
192
+ for (const name of stalePeers) {
193
+ try {
194
+ rmSync(join(supervisionDir(repoRoot), name), { force: true, recursive: true });
195
+ }
196
+ catch { /* next sweep */ }
197
+ }
101
198
  },
102
199
  };
103
200
  }
@@ -109,25 +206,54 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS) {
109
206
  // never silently ARMED. Callers wanting the TIER's state want supervisionStatus below; this answers
110
207
  // the narrower question "does the beat say alive", which is all a beat can ever say.
111
208
  export function readTierLiveness(repoRoot, tier, now = Date.now()) {
112
- return beatLiveness(tier, beatMtimeMs(repoRoot, tier), now);
209
+ return beatLiveness(tier, readBeat(repoRoot, tier), now);
113
210
  }
114
211
  // The beat's inode, or why there is no age to derive from it. Split out so the stand-down ranking below
115
212
  // reads the SAME mtime this derivation does rather than a second, later stat of a moving record.
116
- function beatMtimeMs(repoRoot, tier) {
213
+ function readBeat(repoRoot, tier) {
214
+ const p = supervisionBeatPath(repoRoot, tier);
117
215
  let st;
118
216
  try {
119
- st = statSync(supervisionBeatPath(repoRoot, tier));
217
+ st = statSync(p);
120
218
  }
121
219
  catch (e) {
122
220
  const code = e.code;
123
221
  // never armed — reachable without anything having been written
124
222
  return code === "ENOENT" || code === "ENOTDIR" ? "ABSENT" : "UNREADABLE";
125
223
  }
126
- return st.isFile() ? st.mtimeMs : "UNREADABLE"; // a directory at the beat path is not a heartbeat
224
+ if (!st.isFile())
225
+ return "UNREADABLE"; // a directory at the beat path is not a heartbeat
226
+ // SUP-05: the payload is read for ONE field — the seat — and never for the age, which stays the
227
+ // mtime. On the legacy tiers an unparseable payload is still a beat (a record that cannot be parsed
228
+ // is not evidence that nobody armed the tier). On a SEAT tier it is the opposite: a record naming no
229
+ // seat leaves the tier armed and unattributable, which reads as coverage no seat is providing, so
230
+ // it is UNREADABLE — something is there and no beat any reader can attribute comes out of it.
231
+ const { seat, armId } = beatMetadata(p);
232
+ if (isSeatTier(tier) && seat === undefined)
233
+ return "UNREADABLE";
234
+ return {
235
+ mtimeMs: st.mtimeMs,
236
+ ...(seat !== undefined ? { seat } : {}),
237
+ ...(armId !== undefined ? { armId } : {}),
238
+ };
127
239
  }
128
- function beatLiveness(tier, mtimeMs, now) {
129
- if (typeof mtimeMs !== "number")
130
- return { tier, state: mtimeMs };
240
+ /** Optional metadata declared by a beat; its mtime remains the only source of age. */
241
+ function beatMetadata(path) {
242
+ try {
243
+ const rec = JSON.parse(readFileSync(path, "utf8"));
244
+ const seat = typeof rec?.seat === "string" && rec.seat.trim() ? rec.seat : undefined;
245
+ const armId = typeof rec?.armId === "string" && rec.armId.trim() ? rec.armId : undefined;
246
+ return { ...(seat !== undefined ? { seat } : {}), ...(armId !== undefined ? { armId } : {}) };
247
+ }
248
+ catch {
249
+ return {};
250
+ } // unparseable bytes name no seat or arm — the caller decides what that means
251
+ }
252
+ function beatLiveness(tier, beat, now) {
253
+ if (typeof beat !== "object")
254
+ return { tier, state: beat };
255
+ const { mtimeMs, seat } = beat;
256
+ const named = seat !== undefined ? { seat } : {};
131
257
  const age = now - mtimeMs;
132
258
  // SUP-03: a beat dated AHEAD of the reader's clock past the grace above is not a fresh beat — it is
133
259
  // a record whose age cannot be derived. Clamping it to zero (what this line used to do) made any
@@ -138,7 +264,7 @@ function beatLiveness(tier, mtimeMs, now) {
138
264
  if (age < -SUPERVISION_FUTURE_GRACE_MS)
139
265
  return { tier, state: "UNREADABLE" };
140
266
  const beatAgeMs = Math.max(0, age); // inside the grace: the two clocks' resolutions, not skew
141
- return { tier, state: beatAgeMs > SUPERVISION_STALE_MS ? "STALE" : "ARMED", beatAgeMs };
267
+ return { tier, state: beatAgeMs > SUPERVISION_STALE_MS ? "STALE" : "ARMED", beatAgeMs, ...named };
142
268
  }
143
269
  // A stand-down is only what a watcher RECORDED, so the record has to READ as one: a regular file whose
144
270
  // payload names this tier and the instant it stood down. Path existence is not proof — a directory, a
@@ -157,33 +283,54 @@ function readStandDown(repoRoot, tier) {
157
283
  }
158
284
  if (!st.isFile())
159
285
  return "UNREADABLE";
286
+ let seat;
287
+ let armId;
160
288
  try {
161
289
  const rec = JSON.parse(readFileSync(p, "utf8"));
162
290
  if (rec?.tier !== tier)
163
291
  return "UNREADABLE";
164
292
  if (typeof rec.disarmedAt !== "string" || Number.isNaN(Date.parse(rec.disarmedAt)))
165
293
  return "UNREADABLE";
294
+ seat = typeof rec.seat === "string" && rec.seat.trim() ? rec.seat : undefined;
295
+ armId = typeof rec.armId === "string" && rec.armId.trim() ? rec.armId : undefined;
296
+ // A seat tier's hand-off names WHICH seat left, on the same rule as its beat: an anonymous
297
+ // stand-down on a per-seat tier says a watcher left without saying whose, so it is no record.
298
+ if (isSeatTier(tier) && seat === undefined)
299
+ return "UNREADABLE";
166
300
  }
167
301
  catch {
168
302
  return "UNREADABLE";
169
303
  } // unparseable or unreadable bytes — not a stand-down anyone can read
170
- return { mtimeMs: st.mtimeMs };
304
+ return {
305
+ mtimeMs: st.mtimeMs,
306
+ ...(seat !== undefined ? { seat } : {}),
307
+ ...(armId !== undefined ? { armId } : {}),
308
+ };
171
309
  }
172
310
  // THE TIER'S STATE — what every surface and every operator reads. A valid stand-down outranks the beat:
173
311
  // the watcher that wrote it is gone ON PURPOSE, and its last beat ages out exactly like a dead one's
174
- // would. It outranks the beat it FOLLOWED and no other — a beat stamped after the marker was written by
175
- // a watcher that armed again, so a marker some failed cleanup left behind can never mask a live tier.
312
+ // would. It outranks the beat it FOLLOWED and no other — a later timestamp OR a FRESH different
313
+ // armed-watcher identity is another arm, so a marker whose rename lost that race cannot mask a live
314
+ // watcher. Once that foreign beat is stale, a newer clean hand-off must win: otherwise overlapping
315
+ // boards closed in last-beater-first order would leave the tier reporting a death forever.
176
316
  export function supervisionStatus(repoRoot, tier, now = Date.now()) {
177
- const beat = beatMtimeMs(repoRoot, tier);
317
+ const beat = readBeat(repoRoot, tier);
178
318
  const standDown = readStandDown(repoRoot, tier);
179
319
  if (standDown === "UNREADABLE")
180
320
  return { tier, state: "UNREADABLE" };
181
- if (standDown !== "NONE" && !(typeof beat === "number" && beat > standDown.mtimeMs)) {
182
- return { tier, state: "DISARMED" };
321
+ const beatOutranksStandDown = standDown !== "NONE" && typeof beat === "object" && (beat.mtimeMs > standDown.mtimeMs || (now - beat.mtimeMs <= SUPERVISION_STALE_MS &&
322
+ beat.armId !== undefined && standDown.armId !== undefined && beat.armId !== standDown.armId));
323
+ if (standDown !== "NONE" && !beatOutranksStandDown) {
324
+ // the seat that stood down is named by the marker, falling back to whatever its last beat named
325
+ const seat = standDown.seat ?? (typeof beat === "object" ? beat.seat : undefined);
326
+ return { tier, state: "DISARMED", ...(seat !== undefined ? { seat } : {}) };
183
327
  }
184
328
  return beatLiveness(tier, beat, now);
185
329
  }
186
330
  /** Every KNOWN tier, always — a tier omitted from this list would read as one that is fine. */
187
331
  export const readSupervision = (repoRoot, now = Date.now()) => SUPERVISION_TIERS.map((tier) => supervisionStatus(repoRoot, tier, now));
188
332
  /** One line, one word per tier. Shared by both status surfaces so neither can render a state twice. */
189
- export const supervisionText = (tiers, divider = " · ") => `supervision: ${tiers.map((t) => `${t.tier} ${t.state}`).join(divider)}`;
333
+ // The seat is rendered BESIDE the state, never instead of it: `overseer-context ARMED (w3:p2)` says
334
+ // both that something is beating and who is behind it, which is the pair an operator needs to act. A
335
+ // row with no seat to name renders exactly as it always did.
336
+ export const supervisionText = (tiers, divider = " · ") => `supervision: ${tiers.map((t) => `${t.tier} ${t.state}${t.seat ? ` (${t.seat})` : ""}`).join(divider)}`;
@@ -1,4 +1,5 @@
1
1
  import { channelKey } from "../../adapters/types.js";
2
+ import { isPidLive } from "../../run/lock.js";
2
3
  export const SPARKLINE_BUCKET_WINDOW = 12;
3
4
  const MINUTE_MS = 60_000;
4
5
  const SPARKLINE_BUCKET_WIDTH_LADDER_MINUTES = [
@@ -85,15 +86,6 @@ function elapsedReading(first, last) {
85
86
  const padded = (part) => String(part).padStart(2, "0");
86
87
  return `${padded(hours)}:${padded(minutes)}:${padded(remainder)}`;
87
88
  }
88
- function defaultDaemonLiveness(pid) {
89
- try {
90
- process.kill(pid, 0);
91
- return true;
92
- }
93
- catch {
94
- return false;
95
- }
96
- }
97
89
  function latestLifecycle(events) {
98
90
  for (let index = events.length - 1; index >= 0; index -= 1) {
99
91
  const event = events[index].event;
@@ -700,7 +692,10 @@ export function deriveRunCockpitData(source, binaryVersion, options = {}) {
700
692
  const pid = daemonPid(events);
701
693
  const alive = lifecycle !== "active"
702
694
  ? false
703
- : pid !== undefined && (options.isDaemonAlive ?? defaultDaemonLiveness)(pid);
695
+ // lock.ts's isPidLive is the ONE pid-liveness table. The local copy this replaced treated ANY
696
+ // thrown probe error as death, so a daemon owned by another user (EPERM) read dead here and
697
+ // alive in the lock — the cockpit called a live run interrupted.
698
+ : pid !== undefined && (options.isDaemonAlive ?? isPidLive)(pid);
704
699
  const interrupted = !alive;
705
700
  const tasks = deriveTasks(events, interrupted);
706
701
  const taskFacts = [...tasks.values()];
@@ -1 +1,5 @@
1
+ ---
2
+ status: complete
3
+ ---
4
+
1
5
  # done earlier
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.1.2",
3
+ "version": "2.1.4",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,7 +32,7 @@
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
34
34
  "build:clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && npm run build",
35
- "lint": "oxlint src tests scripts",
35
+ "lint": "oxlint src tests scripts --format stylish",
36
36
  "pretest": "npm run build",
37
37
  "test": "vitest run",
38
38
  "test:coverage": "vitest run --coverage",
@@ -83,6 +83,6 @@ After sending, **confirm delivery** by reading the target pane and verifying the
83
83
  2. **Compile** — run `tickmarkr compile <spec-or-directory>`. Fix source-spec defects instead of editing the generated graph.
84
84
  3. **Plan** — run `tickmarkr plan`. Review routes, capability-floor warnings, and human gates before execution.
85
85
  4. **Run** — run `tickmarkr run`. Watch the run journal for its terminal event rather than polling agents — a self-terminating poll (`until grep -q '"event":"run-end"' <state-dir>/runs/<runId>/journal.jsonl; do sleep 20; done`), never `tail -F | grep -m1` (wedges on the journal's final line) and never a pane-level done wait (turn-end flaps). Resolve blocked interactions in the relevant agent session.
86
- 5. **Verify and consolidate** — continue only after a green run. A run is green when the run-end event exists in the journal AND the tip verify is not "failed". Tickmarkr consolidates accepted work on `tickmarkr/<runId>` and never signs off to the main branch. A human controls any later release merge.
86
+ 5. **Verify and consolidate** — continue only after a green run. A run is green when the run-end event exists in the journal, the tip verify is not "failed", and the summary's `failed`, `human`, `blocked` and `pending` buckets are all empty — a run with a parked task is partial, not green. Tickmarkr consolidates accepted work on `tickmarkr/<runId>` and never signs off to the main branch. A human controls any later release merge.
87
87
  6. **Record** — write `tickmarkr report <runId> --md` beside the source spec and commit the execution record when the repository tracks those records.
88
88
  7. **Continue** — move to the next requested target. If a target fails or is parked, stop with the journal evidence rather than silently skipping it.
@@ -87,5 +87,5 @@ When spawning consultants (agents gathering synthesis input for decisions like S
87
87
  2. **Compile** — run `tickmarkr compile <spec>`. Correct compilation errors in the spec, never in the generated graph.
88
88
  3. **Plan** — run `tickmarkr plan`. Review the routing table, capability-floor warnings, and every human gate, including work that each gate blocks.
89
89
  4. **Run** — run `tickmarkr run`. Watch the run journal for its terminal event rather than repeatedly polling agents. Use a self-terminating poll — `until grep -q '"event":"run-end"' <state-dir>/runs/<runId>/journal.jsonl; do sleep 20; done` — never `tail -F | grep -m1` (run-end is the journal's last line, so tail never notices the broken pipe and the watcher hangs forever) and never a pane-level done wait (it fires on every agent turn end, not mission end). Resolve blocked interactions in the agent session; do not turn them into proxy questions.
90
- 5. **Verify and consolidate** — accept only a green run. A run is green when the run-end event exists in the journal AND the tip verify is not "failed". Tickmarkr consolidates accepted task work on `tickmarkr/<runId>`; it never signs off to the main branch. A human may later merge that integration branch through the repository's normal release process.
90
+ 5. **Verify and consolidate** — accept only a green run. A run is green when the run-end event exists in the journal, the tip verify is not "failed", and the summary's `failed`, `human`, `blocked` and `pending` buckets are all empty — a run with a parked task is partial, not green. Tickmarkr consolidates accepted task work on `tickmarkr/<runId>`; it never signs off to the main branch. A human may later merge that integration branch through the repository's normal release process.
91
91
  6. **Record** — write `tickmarkr report <runId> --md` beside the source spec and commit the execution record when the repository tracks those records. Then [stand down](#stand-down-mission-end-and-retirement).
@@ -103,7 +103,7 @@ through brief lineage. **An executor choice nobody made is still an executor cho
103
103
  fraction (`ORCH · v1.19 4/5`, updated on every task-done); tickmarkr opens ONE TAB PER TASK, labelled
104
104
  with the task id and holding that task's worker plus its judge/review/consult panes (tickmarkr
105
105
  updates it). Never long context strings or ✓-chains.
106
- 2. **Orchestrator**: Launch the orchestrator with your agent host. Spawning on current herdr is two-step — the one-shot `agent start --cwd` form was removed in the herdr CLI redesign and now fails with `unknown option` (OBS-138): first create the pane with `herdr tab create --workspace <ws> --cwd <repo> --label "ORCH · <version>"` and parse `result.root_pane.pane_id` from its JSON, then start the agent in it. For Claude Code, use `herdr agent start orchestrator --kind claude --pane <root-pane-id> -- --permission-mode bypassPermissions` (append `--model <m>` after the `--` if the operator has a policy). For Codex, use `herdr agent start orchestrator --kind codex --pane <root-pane-id> -- --dangerously-bypass-approvals-and-sandbox` (add `--model <m>` to specify the model). The unsandboxed flag is REQUIRED: codex's `workspace-write` sandbox keeps `.git` refs read-only, so a sandboxed orchestrator's `tickmarkr run` dies at integration-branch creation — do not downgrade it. Workers you never spawn — tickmarkr spawns its own visible worker panes. Auxiliary agents you do spawn (consultants, reviewers, scouts) follow the same forms: never launch a claude session in plan mode or default permission mode for autonomous work — both stall on per-command approval prompts nobody is watching; claude is always `--permission-mode bypassPermissions`, and a read-only codex consultant may use `--sandbox read-only`.
106
+ 2. **Orchestrator**: Launch the orchestrator with your agent host. Spawning on current herdr is two-step — the one-shot `agent start --cwd` form was removed in the herdr CLI redesign and now fails with `unknown option` (OBS-138): first create the pane with `herdr tab create --workspace <ws> --cwd <repo> --label "ORCH · <version>"` and parse `result.root_pane.pane_id` from its JSON, then start the agent in it. For Claude Code, use `herdr agent start orchestrator --kind claude --pane <root-pane-id> -- --permission-mode bypassPermissions` (append `--model <m>` after the `--` if the operator has a policy). For Codex, use `herdr agent start orchestrator --kind codex --pane <root-pane-id> -- --dangerously-bypass-approvals-and-sandbox` (add `--model <m>` to specify the model). The unsandboxed flag is REQUIRED: codex's `workspace-write` sandbox keeps `.git` refs read-only, so a sandboxed orchestrator's `tickmarkr run` dies at integration-branch creation — do not downgrade it. Workers you never spawn — tickmarkr spawns its own visible worker panes. Auxiliary agents you do spawn (consultants, reviewers, scouts) follow the same forms: never launch a claude session in plan mode or default permission mode for autonomous work — both stall on per-command approval prompts nobody is watching; claude is always `--permission-mode bypassPermissions --settings '{"promptSuggestionEnabled":false}'`, and a read-only codex consultant may use `--sandbox read-only`. **That `--settings` pair is not cosmetic and it is not optional:** claude-code's AUTOSUGGEST renders context-plausible ghost text into an idle seat's prompt line that is BYTE-IDENTICAL to a typed draft in text-format reads (OBS-482), so a supervising tier cannot tell a seat's own unsent work from a rendering artifact without `agent read --format ansi`. Turning the suggester off at spawn removes the ambiguity at its source instead of paying for the discrimination at every read. Verified against the shipped binary: `claude --settings '{"promptSuggestionEnabled":false}' -p …` exits 0 with a real response, and the key appears in the binary's own settings schema. **For kimi, pass `-y`** (`herdr agent start <name> --kind kimi --pane <id> -- -y`) — the adapter already launches its own workers that way (`src/adapters/kimi.ts:204`), and a kimi seat spawned without it sits on an approval prompt having done nothing. **Herdr cannot see that state**: it reports a kimi pane as `agent_status: working` with `screen_detection_skipped: true` while the prompt is up, so the BLOCKED-STATE watcher below is blind on this vendor and the spawn flag is the ONLY control. Every vendor you spawn needs its auto-approve form named here; a vendor absent from this list is a seat that will hang.
107
107
  3. **Standing instructions travel as a brief FILE, never as pane text** — PTY input truncates at ~1024B and a
108
108
  truncated brief silently drops policy. Write the full brief to `<repo>/.tickmarkr/overseer/ORCH-BRIEF.md`
109
109
  (inside the tickmarkr state dir — already self-gitignored, no exclude step needed), then send one line:
@@ -255,6 +255,17 @@ is a lossy summary nobody trusts while a clean session re-oriented from disk-ver
255
255
  **Do the same for yourself before you are forced to**: write the handoff while your judgment is still
256
256
  good, not after. If your own context cannot be read by the watcher, say so to the operator and ask for the
257
257
  number — an unmeasured budget is not a small budget.
258
+
259
+ ```bash
260
+ .claude/skills/tickmarkr-overseer/scripts/watch-context.sh orchestrator <orchestrator-agent-or-pane> 60 75 <handoff-file>
261
+ .claude/skills/tickmarkr-overseer/scripts/watch-context.sh overseer <overseer-agent-or-pane> 60 75 <handoff-file>
262
+ ```
263
+
264
+ The first argument chooses the closed per-seat tier (`orchestrator-context` or `overseer-context`),
265
+ and every beat names the second argument as that tier's seat. The watcher beats only after reading a
266
+ rendered percentage, keeps beating on the supervision cadence even when its requested poll is slower,
267
+ continues past WARN to ACT, and records a stand-down on each controlled exit. A killed watcher alone
268
+ leaves its last beat to age into `STALE`.
258
269
  **Every handoff's re-arm list ends with the announce step from Setup 0** — inform the surviving
259
270
  orchestrator the fresh seat is live — or the next seat re-arms silently beside a tier that still
260
271
  believes it is alone.
@@ -406,7 +417,7 @@ they are left implicit:
406
417
  authors** — the seat's own draft, an operator, another agent's `agent send` (writes WITHOUT Enter),
407
418
  and claude-code's AUTOSUGGEST, which renders context-plausible ghost text BYTE-IDENTICAL to a typed
408
419
  draft in text-format reads (OBS-482). The check is mechanical and only works at observation time:
409
- `agent read --format ansi` — dim/grey SGR around the text = autosuggest ghost, NOT input. Measured
420
+ `agent read --format ansi --source visible` — dim/grey SGR (`ESC[2m`) around the text = autosuggest ghost, NOT input. ⚠ **`--source` is load-bearing and its natural choice is the wrong one.** `--source detection` is the plain-text buffer used for agent detection: it strips ANSI *entirely*, so `--format ansi --source detection` returns ZERO escape sequences and every string reads as un-styled — i.e. as real typed input. Measured 2026-08-26 on a live orchestrator: `detection` returned 0 escapes and the ghost read as a genuine unsubmitted draft; `visible` returned 100 escapes and the same line came back `ESC[0mESC[2m…`, dim, ghost. **A probe that cannot render the evidence cannot fail**, so check the capture contains escapes at all before believing its answer — that is rule 11 aimed at your own instrument. Measured
410
421
  2026-08-17 (D-206): an unattributed instruction was found in an orchestrator's box, superseded
411
422
  defensively, and its origin stayed UNRESOLVED — the one probe that discriminates was not taken while
412
423
  the text still sat there. An origin question you can close in ten seconds at the pane becomes
@@ -424,17 +435,24 @@ The beat is one shipped command and the loop is yours, run from the repo root as
424
435
  `run_in_background` Bash call:
425
436
 
426
437
  ```bash
427
- cd <repo> && while :; do tickmarkr beat overseer; sleep 10; done # 10s = SUPERVISION_BEAT_MS
428
- tickmarkr beat overseer --stand-down # at stand-down, in the same act
438
+ cd <repo> && while :; do tickmarkr beat overseer --seat <overseer-agent-or-pane>; sleep 10; done
439
+ tickmarkr beat overseer --seat <overseer-agent-or-pane> --stand-down # after stopping that loop
429
440
  ```
430
441
 
442
+ The pre-2.1.3 forms `while :; do tickmarkr beat overseer; sleep 10; done` and
443
+ `tickmarkr beat overseer --stand-down` are preserved here only as migration warnings: both are now
444
+ rejected because neither declares which seat the tier speaks for. Do not copy or run them.
445
+
431
446
  One beat per invocation, deliberately: the loop is what proves the seat is alive, so a command that
432
447
  kept beating on its own would keep reporting a dead seat as healthy. Stop the loop — or die — and the
433
448
  tier ages to `STALE` (never `ABSENT`) within six beats, which is the state that says *armed, then lost*.
434
449
  Stand down explicitly when you hand off, or a deliberate exit reads as a death. Same rule as rule 29
435
450
  below, now with a conventional path the other tier already reads: `tickmarkr status` shows it.
436
451
 
437
- ⚠ **THE LOOP ABOVE BINDS TO A PROCESS, NOT TO A SEAT — and that is a defect this skill shipped.**
452
+ ⚠ **THE LOOP ABOVE NAMES A SEAT BUT STILL BINDS ITS LIFETIME TO A PROCESS — and that distinction is
453
+ load-bearing.** The command refuses an anonymous beat, and `status` renders the declared seat beside
454
+ the tier state; a legacy tier+pid+instant record cannot be attributed and reads `UNREADABLE`, never
455
+ `ARMED`. Naming the seat does not make the shell loop stop when that seat leaves.
438
456
  The beat keeps running while its *session* lives, so a loop started by a seat that has since been
439
457
  cleared, re-briefed, or replaced keeps beating that tier's file forever. Measured 2026-08-24
440
458
  (OBS-583): a **2d20h** orphan loop from a predecessor seat held `orchestrator ARMED` through a
@@ -444,13 +462,13 @@ one owned by an unrelated session. So:
444
462
  - **At every adopt, clear, or re-brief, sweep for pre-existing loops on YOUR tier before arming one**
445
463
  (`pgrep -f "tickmarkr beat <tier>"`), trace each to its parent session, and kill the **loop only**
446
464
  — never the parent — then verify the parent survived.
447
- - **`ARMED` is a claim about a process, not about a seat.** Before trusting any tier's `ARMED`, ask
448
- whose session owns the beater; a tier can be armed and seatless, which is *worse* than ABSENT
449
- because it reads as coverage (rule 11's outliving-its-trigger failure, in beat form).
465
+ - **`ARMED (<seat>)` is an attributable claim, not proof that the named seat is still alive.** Before
466
+ trusting it, ask whose session owns the beater; an orphan loop can keep naming a departed seat
467
+ (rule 11's outliving-its-trigger failure, in beat form).
450
468
  - Stand-down must kill the loop **and** run `--stand-down`; the second without the first is undone
451
469
  by the next tick.
452
- The product fix (a seat-bound or sentinel-terminated beat, armed and stood down in one act) is
453
- queued; until it ships, this sweep is the guard.
470
+ The remaining product fix (a sentinel-terminated beat, armed and stood down in one act) is queued;
471
+ until it ships, this sweep is the guard.
454
472
 
455
473
  Arm the bundled watcher as its OWN Bash call with `run_in_background` — chaining it after other commands
456
474
  with `&` orphans it from the wake chain. It prints one wake reason and exits; re-arm after every wake.
@@ -499,10 +517,15 @@ Two keys that do not lie, in order of strength:
499
517
  will keep producing this stall, and a sweeper that has been running since 04:40 is evidence the gap was
500
518
  visible and got swept instead of fixed.
501
519
 
502
- **Every seat you spawn gets THREE watchers armed in the SAME call that spawns it — ARTIFACT,
503
- BLOCKED-STATE, and PENDING-INPUT.** Each is blind to what the others catch: the artifact watcher cannot see
504
- a stall, the blocked watcher cannot see a finish, and neither can see a seat sitting **idle with
505
- unsubmitted text in its own prompt**.
520
+ **Every seat you spawn gets FOUR watchers armed in the SAME call that spawns it — ARTIFACT,
521
+ BLOCKED-STATE, PENDING-INPUT, and CONTEXT.** Each is blind to what the others catch: the artifact watcher cannot
522
+ see a stall, the blocked watcher cannot see a finish, neither can see a seat sitting **idle with
523
+ unsubmitted text in its own prompt**, and none of them can see a seat running out of context.
524
+ **CONTEXT was mandated in prose above and omitted from this list, so it shipped in 2.1.2 and was never armed
525
+ once** — an overseer ran nine hours at 86% unable to read its own number. Arm
526
+ `scripts/watch-context.sh` here, by name, like the other three. Note also that BLOCKED-STATE relies on
527
+ Herdr's `agent_status`, which is unreliable for vendors whose screen detection is skipped (kimi) — for
528
+ those, the spawn-time auto-approve flag is the control, not this watcher.
506
529
 
507
530
  ```bash
508
531
  .claude/skills/tickmarkr-overseer/scripts/watch-pending-input.sh <agent|pane> [poll-s] [cap-s] [confirm-polls]