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.
- package/dist/adapters/claude-code.js +18 -3
- package/dist/adapters/pi.d.ts +6 -0
- package/dist/adapters/pi.js +99 -18
- package/dist/cli/commands/beat.js +28 -8
- package/dist/cli/commands/init.js +28 -1
- package/dist/cli/commands/status.d.ts +3 -0
- package/dist/cli/commands/status.js +40 -16
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/compile/gsd.js +21 -1
- package/dist/gates/baseline.d.ts +10 -0
- package/dist/gates/baseline.js +46 -3
- package/dist/run/daemon.d.ts +0 -1
- package/dist/run/daemon.js +54 -19
- package/dist/run/git.d.ts +20 -0
- package/dist/run/git.js +114 -8
- package/dist/run/journal.d.ts +24 -0
- package/dist/run/journal.js +62 -1
- package/dist/run/lock.d.ts +1 -0
- package/dist/run/lock.js +23 -13
- package/dist/run/supervision.d.ts +9 -3
- package/dist/run/supervision.js +185 -38
- package/dist/tui/cockpit/derive.js +5 -10
- package/fixtures/gsd-sample/07-live-check/07-03-SUMMARY.md +4 -0
- package/package.json +2 -2
- package/skills/tickmarkr-auto/SKILL.md +1 -1
- package/skills/tickmarkr-loop/SKILL.md +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +37 -14
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +80 -31
package/dist/run/supervision.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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({
|
|
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
|
|
55
|
-
//
|
|
56
|
-
//
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
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
|
-
|
|
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 —
|
|
78
|
-
}
|
|
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.
|
|
83
|
-
// exit
|
|
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}.${
|
|
180
|
+
const tmp = `${p}.${id}.tmp`;
|
|
95
181
|
try {
|
|
96
182
|
mkdirSync(dirname(p), { recursive: true });
|
|
97
|
-
writeFileSync(tmp, JSON.stringify({
|
|
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,
|
|
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
|
|
213
|
+
function readBeat(repoRoot, tier) {
|
|
214
|
+
const p = supervisionBeatPath(repoRoot, tier);
|
|
117
215
|
let st;
|
|
118
216
|
try {
|
|
119
|
-
st = statSync(
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 {
|
|
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
|
|
175
|
-
//
|
|
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 =
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
428
|
-
tickmarkr beat overseer --
|
|
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
|
|
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
|
|
448
|
-
whose session owns the beater;
|
|
449
|
-
|
|
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
|
|
453
|
-
|
|
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
|
|
503
|
-
BLOCKED-STATE,
|
|
504
|
-
a stall, the blocked watcher cannot see a finish,
|
|
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]
|