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.
- 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.js +17 -15
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/gates/baseline.d.ts +10 -0
- package/dist/gates/baseline.js +46 -3
- package/dist/run/git.d.ts +5 -0
- package/dist/run/git.js +52 -4
- 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 +77 -21
- package/dist/tui/cockpit/derive.js +5 -10
- 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 +79 -30
package/dist/run/lock.d.ts
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
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)
|
|
226
|
-
// `process.kill(pid, 0)` anywhere else would be a second copy of
|
|
227
|
-
//
|
|
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. */
|
package/dist/run/supervision.js
CHANGED
|
@@ -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
|
-
|
|
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({
|
|
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,
|
|
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
|
|
138
|
+
function readBeat(repoRoot, tier) {
|
|
139
|
+
const p = supervisionBeatPath(repoRoot, tier);
|
|
117
140
|
let st;
|
|
118
141
|
try {
|
|
119
|
-
st = statSync(
|
|
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
|
-
|
|
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,
|
|
129
|
-
if (typeof
|
|
130
|
-
return { tier, state:
|
|
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 =
|
|
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 === "
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.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
|
|
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]
|