tickmarkr 2.1.2 → 2.1.3

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.
@@ -10,6 +10,7 @@ export interface Inspection {
10
10
  ino: number;
11
11
  }
12
12
  export declare function shouldRefuse(i: Pick<Inspection, "garbage" | "dead">): boolean;
13
+ export declare function isPidLive(pid: number): boolean;
13
14
  export declare function acquireRunLock(repoRoot: string, runId: string): {
14
15
  reclaimed?: {
15
16
  pid: number;
package/dist/run/lock.js CHANGED
@@ -45,6 +45,23 @@ process.once("exit", () => { if (heldPath)
45
45
  export function shouldRefuse(i) {
46
46
  return i.garbage || !i.dead;
47
47
  }
48
+ // LOCK-04, PID-SCOPED: the same decision table, in the shape a caller holding only a pid can consume.
49
+ // Every other liveness export here takes a repository root, so a reader with a pid off a journal row
50
+ // had no seam to reach and wrote the four lines itself — twice. One copy was faithful; the other
51
+ // treated ANY thrown error as death, so a daemon owned by another user (EPERM) read dead there and
52
+ // alive here. A rule that forbids a second `process.kill(pid, 0)` without exporting a usable
53
+ // predicate produces exactly those copies, so this is the predicate. no throw ⇒ ALIVE; ESRCH ⇒ the
54
+ // only proof-positive death; ANY other errno (EPERM = alive-but-not-ours, EINVAL, …) ⇒ ALIVE,
55
+ // because none of them is evidence of death and this table fails closed toward alive.
56
+ export function isPidLive(pid) {
57
+ try {
58
+ process.kill(pid, 0);
59
+ return true;
60
+ }
61
+ catch (k) {
62
+ return k.code !== "ESRCH";
63
+ }
64
+ }
48
65
  // statSync throws ENOENT when no lock exists — callers treat that as "not held".
49
66
  function inspect(p) {
50
67
  const st = statSync(p); // single stat: both the heartbeat mtime and the reclaim-guard inode
@@ -53,16 +70,8 @@ function inspect(p) {
53
70
  const parsed = PayloadSchema.safeParse(readPayload(p));
54
71
  const garbage = !parsed.success; // LOCK-01: its own state — shouldRefuse refuses it unconditionally; only `tickmarkr unlock` removes it
55
72
  const pid = parsed.success ? parsed.data.pid : undefined;
56
- let dead = pid === undefined; // harmless fallback for the garbage row — garbage short-circuits shouldRefuse before this is read
57
- if (pid !== undefined) {
58
- try {
59
- process.kill(pid, 0);
60
- dead = false;
61
- } // no throw ⇒ ALIVE
62
- catch (k) {
63
- dead = k.code === "ESRCH";
64
- } // ESRCH ⇒ dead; EPERM ⇒ ALIVE
65
- }
73
+ // harmless fallback for the garbage row — garbage short-circuits shouldRefuse before this is read
74
+ const dead = pid === undefined ? true : !isPidLive(pid);
66
75
  return { pid, runId: parsed.success ? parsed.data.runId : undefined, garbage, dead, expired, mtimeMs, ino: st.ino };
67
76
  }
68
77
  function readPayload(p) {
@@ -222,9 +231,10 @@ export async function acquireApprovalSerialization(repoRoot, runId) {
222
231
  }
223
232
  }
224
233
  // LOCK-04: the owner the decision table sees, read-only, for callers that need the pid as well as
225
- // the answer. inspect() owns pid-liveness (ESRCH dead / EPERM alive / garbage fail-closed); a second
226
- // `process.kill(pid, 0)` anywhere else would be a second copy of that rule, free to drift. undefined
227
- // ⇒ no lock at all — never conflate that with a lock whose recorded owner is dead.
234
+ // the answer. inspect() owns pid-liveness (ESRCH dead / EPERM alive / garbage fail-closed) and reads
235
+ // it from isPidLive above; a second `process.kill(pid, 0)` anywhere else would be a second copy of
236
+ // that rule, free to drift — a caller holding only a pid consumes isPidLive, never its own probe.
237
+ // undefined ⇒ no lock at all — never conflate that with a lock whose recorded owner is dead.
228
238
  export function runLockOwner(repoRoot) {
229
239
  let insp;
230
240
  try {
@@ -3,24 +3,30 @@ export declare const SUPERVISION_BEAT_MS = 10000;
3
3
  /** Ceiling before a beat reads STALE: SIX beats, lock.ts's ratio — five may be missed before alarm. */
4
4
  export declare const SUPERVISION_STALE_MS: number;
5
5
  export declare const SUPERVISION_FUTURE_GRACE_MS = 1000;
6
- export declare const SUPERVISION_TIERS: readonly ["orchestrator", "overseer", "watch"];
6
+ export declare const SUPERVISION_TIERS: readonly ["orchestrator", "orchestrator-context", "overseer", "overseer-context", "watch"];
7
7
  export type SupervisionTier = (typeof SUPERVISION_TIERS)[number];
8
+ export declare const SUPERVISION_SEAT_TIERS: readonly ["orchestrator-context", "overseer-context"];
9
+ export type SeatTier = (typeof SUPERVISION_SEAT_TIERS)[number];
10
+ /** Does this tier's record have to name the seat it speaks for? */
11
+ export declare const isSeatTier: (tier: string) => tier is SeatTier;
8
12
  export type SupervisionState = "ABSENT" | "STALE" | "ARMED" | "UNREADABLE" | "DISARMED";
9
13
  export interface TierLiveness {
10
14
  tier: SupervisionTier;
11
15
  state: SupervisionState;
12
16
  /** Absent for ABSENT, UNREADABLE and DISARMED — no beat to age. Always present for STALE and ARMED. */
13
17
  beatAgeMs?: number;
18
+ /** The seat the record names, when it names one. Always present on a seat tier that is not ABSENT. */
19
+ seat?: string;
14
20
  }
15
21
  export declare const supervisionBeatPath: (repoRoot: string, tier: SupervisionTier) => string;
16
22
  /** Where a watcher records that it STOOD DOWN. Its own file: the beat keeps meaning only "alive". */
17
23
  export declare const supervisionStandDownPath: (repoRoot: string, tier: SupervisionTier) => string;
18
- export declare function beatSupervision(repoRoot: string, tier: SupervisionTier): void;
24
+ export declare function beatSupervision(repoRoot: string, tier: SupervisionTier, seat?: string): void;
19
25
  /** Handle a watcher holds for as long as it is supervising; disarm stands it down and is idempotent. */
20
26
  export interface ArmedSupervision {
21
27
  disarm: () => void;
22
28
  }
23
- export declare function armSupervision(repoRoot: string, tier: SupervisionTier, beatMs?: number): ArmedSupervision;
29
+ export declare function armSupervision(repoRoot: string, tier: SupervisionTier, beatMs?: number, seat?: string): ArmedSupervision;
24
30
  export declare function readTierLiveness(repoRoot: string, tier: SupervisionTier, now?: number): TierLiveness;
25
31
  export declare function supervisionStatus(repoRoot: string, tier: SupervisionTier, now?: number): TierLiveness;
26
32
  /** Every KNOWN tier, always — a tier omitted from this list would read as one that is fine. */
@@ -29,7 +29,22 @@ export const SUPERVISION_FUTURE_GRACE_MS = 1_000;
29
29
  // The supervision seats this harness has. An unlisted tier is an INVISIBLE tier, which is the failure
30
30
  // mode itself — an auditor read "no watchers were ever armed" off a surface that named none. Adding a
31
31
  // 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"];
32
+ // SUP-05: CONTEXT is per SEAT, so its tiers are per seat too. One shared `context` tier would be read
33
+ // by both supervising seats and beaten by whichever of them still had a watcher, so a live overseer
34
+ // watcher would render the dead orchestrator one as armed — the mask this whole instrument exists to
35
+ // remove. The enumeration is CLOSED at one tier per supervising seat: orchestrator and overseer.
36
+ // `watch` beats nowhere on this tree and stays ABSENT, which is what ABSENT means.
37
+ export const SUPERVISION_TIERS = [
38
+ "orchestrator", "orchestrator-context", "overseer", "overseer-context", "watch",
39
+ ];
40
+ // The tiers whose records must NAME the seat behind them. A one-shot `tickmarkr beat` records a pid
41
+ // that has already exited by the time anyone reads it, and an instant — nothing a reader can attribute
42
+ // to a seat. Measured 2026-08-26: a consult seat of another tier ran the documented beat loop and the
43
+ // board read that tier armed with no seat of that tier having armed anything. ARMED-and-seatless reads
44
+ // as coverage, which is worse than ABSENT, so on these tiers a record that names no seat is not a beat.
45
+ export const SUPERVISION_SEAT_TIERS = ["orchestrator-context", "overseer-context"];
46
+ /** Does this tier's record have to name the seat it speaks for? */
47
+ export const isSeatTier = (tier) => SUPERVISION_SEAT_TIERS.includes(tier);
33
48
  // PURE path math: stateDirName, never tickmarkrDir — the latter mkdirs the state dir and writes its
34
49
  // .gitignore, so routing a READER through it would make status create the very tree it reports on.
35
50
  export const supervisionBeatPath = (repoRoot, tier) => join(repoRoot, stateDirName(repoRoot), "supervision", `${tier}.beat`);
@@ -38,11 +53,16 @@ export const supervisionStandDownPath = (repoRoot, tier) => join(repoRoot, state
38
53
  // WRITER — a watcher's own call, on its own tier, every SUPERVISION_BEAT_MS. Never a reader's: the
39
54
  // purity fence (status --watch leaves the state dir byte-identical) is the test that catches a reader
40
55
  // that beats on the watcher's behalf, which would report every dead tier as healthy.
41
- export function beatSupervision(repoRoot, tier) {
56
+ export function beatSupervision(repoRoot, tier, seat) {
57
+ // A seat tier may not be armed anonymously, and the refusal belongs HERE rather than only in the
58
+ // verb: any caller that could write a seatless record could arm a tier nobody occupies.
59
+ if (isSeatTier(tier) && !seat?.trim()) {
60
+ throw new Error(`${tier} is a per-seat tier — a beat must declare the seat identity it speaks for`);
61
+ }
42
62
  tickmarkrDir(repoRoot); // the write path DOES create — beats land inside the gitignored state dir
43
63
  const p = supervisionBeatPath(repoRoot, tier);
44
64
  mkdirSync(dirname(p), { recursive: true });
45
- writeFileSync(p, JSON.stringify({ tier, pid: process.pid, beatAt: new Date().toISOString() }) + "\n");
65
+ writeFileSync(p, JSON.stringify({ tier, ...(seat ? { seat } : {}), pid: process.pid, beatAt: new Date().toISOString() }) + "\n");
46
66
  }
47
67
  // THE WATCHER-FACING ENTRY POINT — the loop SUPERVISION_BEAT_MS actually drives. A supervising seat
48
68
  // calls this once at the top of its watch and holds the handle for the duration; a seat that dies,
@@ -57,7 +77,7 @@ export function beatSupervision(repoRoot, tier) {
57
77
  //
58
78
  // Arming CLEARS any prior stand-down record: a tier that stood down and armed again is armed, and a
59
79
  // 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) {
80
+ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, seat) {
61
81
  // The clearing gets its OWN try: a cleanup that cannot complete (a directory dropped at the marker
62
82
  // path, a permission) must not cost the first beat. Sharing one try did exactly that — the tier armed
63
83
  // with NO beat while the old marker stayed on disk, the one combination that reports a live watcher
@@ -67,12 +87,12 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS) {
67
87
  }
68
88
  catch { /* uncleared: the reader validates the marker and a newer beat outranks it — never masked */ }
69
89
  try {
70
- beatSupervision(repoRoot, tier);
90
+ beatSupervision(repoRoot, tier, seat);
71
91
  }
72
- catch { /* repo gone / disk full — the tier reads ABSENT rather than crashing its watcher */ }
92
+ catch { /* repo gone / disk full / no seat — the tier reads ABSENT rather than crashing its watcher */ }
73
93
  const timer = setInterval(() => {
74
94
  try {
75
- beatSupervision(repoRoot, tier);
95
+ beatSupervision(repoRoot, tier, seat);
76
96
  }
77
97
  catch { /* repo gone / disk full — let the beat expire */ }
78
98
  }, beatMs);
@@ -94,7 +114,9 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS) {
94
114
  const tmp = `${p}.${process.pid}.tmp`;
95
115
  try {
96
116
  mkdirSync(dirname(p), { recursive: true });
97
- writeFileSync(tmp, JSON.stringify({ tier, pid: process.pid, disarmedAt: new Date().toISOString() }) + "\n");
117
+ writeFileSync(tmp, JSON.stringify({
118
+ tier, ...(seat ? { seat } : {}), pid: process.pid, disarmedAt: new Date().toISOString(),
119
+ }) + "\n");
98
120
  renameSync(tmp, p);
99
121
  }
100
122
  catch { /* unrecordable stand-down ages out as STALE — pessimistic, which is the safe way to fail */ }
@@ -109,25 +131,48 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS) {
109
131
  // never silently ARMED. Callers wanting the TIER's state want supervisionStatus below; this answers
110
132
  // the narrower question "does the beat say alive", which is all a beat can ever say.
111
133
  export function readTierLiveness(repoRoot, tier, now = Date.now()) {
112
- return beatLiveness(tier, beatMtimeMs(repoRoot, tier), now);
134
+ return beatLiveness(tier, readBeat(repoRoot, tier), now);
113
135
  }
114
136
  // The beat's inode, or why there is no age to derive from it. Split out so the stand-down ranking below
115
137
  // reads the SAME mtime this derivation does rather than a second, later stat of a moving record.
116
- function beatMtimeMs(repoRoot, tier) {
138
+ function readBeat(repoRoot, tier) {
139
+ const p = supervisionBeatPath(repoRoot, tier);
117
140
  let st;
118
141
  try {
119
- st = statSync(supervisionBeatPath(repoRoot, tier));
142
+ st = statSync(p);
120
143
  }
121
144
  catch (e) {
122
145
  const code = e.code;
123
146
  // never armed — reachable without anything having been written
124
147
  return code === "ENOENT" || code === "ENOTDIR" ? "ABSENT" : "UNREADABLE";
125
148
  }
126
- return st.isFile() ? st.mtimeMs : "UNREADABLE"; // a directory at the beat path is not a heartbeat
149
+ if (!st.isFile())
150
+ return "UNREADABLE"; // a directory at the beat path is not a heartbeat
151
+ // SUP-05: the payload is read for ONE field — the seat — and never for the age, which stays the
152
+ // mtime. On the legacy tiers an unparseable payload is still a beat (a record that cannot be parsed
153
+ // is not evidence that nobody armed the tier). On a SEAT tier it is the opposite: a record naming no
154
+ // seat leaves the tier armed and unattributable, which reads as coverage no seat is providing, so
155
+ // it is UNREADABLE — something is there and no beat any reader can attribute comes out of it.
156
+ const seat = beatSeat(p);
157
+ if (isSeatTier(tier) && seat === undefined)
158
+ return "UNREADABLE";
159
+ return { mtimeMs: st.mtimeMs, ...(seat !== undefined ? { seat } : {}) };
160
+ }
161
+ /** The seat a record declares, or undefined for any record that declares none this reader can use. */
162
+ function beatSeat(path) {
163
+ try {
164
+ const rec = JSON.parse(readFileSync(path, "utf8"));
165
+ return typeof rec?.seat === "string" && rec.seat.trim() ? rec.seat : undefined;
166
+ }
167
+ catch {
168
+ return undefined;
169
+ } // unparseable bytes name no seat — the caller decides what that means
127
170
  }
128
- function beatLiveness(tier, mtimeMs, now) {
129
- if (typeof mtimeMs !== "number")
130
- return { tier, state: mtimeMs };
171
+ function beatLiveness(tier, beat, now) {
172
+ if (typeof beat !== "object")
173
+ return { tier, state: beat };
174
+ const { mtimeMs, seat } = beat;
175
+ const named = seat !== undefined ? { seat } : {};
131
176
  const age = now - mtimeMs;
132
177
  // SUP-03: a beat dated AHEAD of the reader's clock past the grace above is not a fresh beat — it is
133
178
  // a record whose age cannot be derived. Clamping it to zero (what this line used to do) made any
@@ -138,7 +183,7 @@ function beatLiveness(tier, mtimeMs, now) {
138
183
  if (age < -SUPERVISION_FUTURE_GRACE_MS)
139
184
  return { tier, state: "UNREADABLE" };
140
185
  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 };
186
+ return { tier, state: beatAgeMs > SUPERVISION_STALE_MS ? "STALE" : "ARMED", beatAgeMs, ...named };
142
187
  }
143
188
  // A stand-down is only what a watcher RECORDED, so the record has to READ as one: a regular file whose
144
189
  // payload names this tier and the instant it stood down. Path existence is not proof — a directory, a
@@ -157,33 +202,44 @@ function readStandDown(repoRoot, tier) {
157
202
  }
158
203
  if (!st.isFile())
159
204
  return "UNREADABLE";
205
+ let seat;
160
206
  try {
161
207
  const rec = JSON.parse(readFileSync(p, "utf8"));
162
208
  if (rec?.tier !== tier)
163
209
  return "UNREADABLE";
164
210
  if (typeof rec.disarmedAt !== "string" || Number.isNaN(Date.parse(rec.disarmedAt)))
165
211
  return "UNREADABLE";
212
+ seat = typeof rec.seat === "string" && rec.seat.trim() ? rec.seat : undefined;
213
+ // A seat tier's hand-off names WHICH seat left, on the same rule as its beat: an anonymous
214
+ // stand-down on a per-seat tier says a watcher left without saying whose, so it is no record.
215
+ if (isSeatTier(tier) && seat === undefined)
216
+ return "UNREADABLE";
166
217
  }
167
218
  catch {
168
219
  return "UNREADABLE";
169
220
  } // unparseable or unreadable bytes — not a stand-down anyone can read
170
- return { mtimeMs: st.mtimeMs };
221
+ return { mtimeMs: st.mtimeMs, ...(seat !== undefined ? { seat } : {}) };
171
222
  }
172
223
  // THE TIER'S STATE — what every surface and every operator reads. A valid stand-down outranks the beat:
173
224
  // the watcher that wrote it is gone ON PURPOSE, and its last beat ages out exactly like a dead one's
174
225
  // would. It outranks the beat it FOLLOWED and no other — a beat stamped after the marker was written by
175
226
  // a watcher that armed again, so a marker some failed cleanup left behind can never mask a live tier.
176
227
  export function supervisionStatus(repoRoot, tier, now = Date.now()) {
177
- const beat = beatMtimeMs(repoRoot, tier);
228
+ const beat = readBeat(repoRoot, tier);
178
229
  const standDown = readStandDown(repoRoot, tier);
179
230
  if (standDown === "UNREADABLE")
180
231
  return { tier, state: "UNREADABLE" };
181
- if (standDown !== "NONE" && !(typeof beat === "number" && beat > standDown.mtimeMs)) {
182
- return { tier, state: "DISARMED" };
232
+ if (standDown !== "NONE" && !(typeof beat === "object" && beat.mtimeMs > standDown.mtimeMs)) {
233
+ // the seat that stood down is named by the marker, falling back to whatever its last beat named
234
+ const seat = standDown.seat ?? (typeof beat === "object" ? beat.seat : undefined);
235
+ return { tier, state: "DISARMED", ...(seat !== undefined ? { seat } : {}) };
183
236
  }
184
237
  return beatLiveness(tier, beat, now);
185
238
  }
186
239
  /** Every KNOWN tier, always — a tier omitted from this list would read as one that is fine. */
187
240
  export const readSupervision = (repoRoot, now = Date.now()) => SUPERVISION_TIERS.map((tier) => supervisionStatus(repoRoot, tier, now));
188
241
  /** 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)}`;
242
+ // The seat is rendered BESIDE the state, never instead of it: `overseer-context ARMED (w3:p2)` says
243
+ // both that something is beating and who is behind it, which is the pair an operator needs to act. A
244
+ // row with no seat to name renders exactly as it always did.
245
+ 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()];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
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]