tickmarkr 2.1.5 → 2.1.7
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/cli/commands/beat.js +37 -10
- package/dist/cli/commands/run.js +21 -2
- package/dist/cli/commands/stats.d.ts +21 -0
- package/dist/cli/commands/stats.js +200 -0
- package/dist/cli/commands/status.js +51 -19
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +3 -1
- package/dist/compile/index.d.ts +1 -1
- package/dist/compile/index.js +14 -5
- package/dist/compile/ownership.d.ts +25 -0
- package/dist/compile/ownership.js +142 -0
- package/dist/drivers/herdr.d.ts +1 -0
- package/dist/drivers/herdr.js +7 -6
- package/dist/drivers/types.d.ts +2 -0
- package/dist/drivers/types.js +54 -14
- package/dist/gates/baseline.js +39 -18
- package/dist/run/daemon.js +257 -12
- package/dist/run/journal.d.ts +5 -0
- package/dist/run/journal.js +62 -7
- package/dist/run/outcome.js +8 -1
- package/dist/run/supervision.d.ts +15 -1
- package/dist/run/supervision.js +112 -14
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +2 -1
- package/skills/tickmarkr-loop/SKILL.md +2 -1
- package/skills/tickmarkr-overseer/SKILL.md +21 -1
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +46 -32
package/dist/run/supervision.js
CHANGED
|
@@ -14,8 +14,8 @@ import { stateDirName, tickmarkrDir } from "../graph/graph.js";
|
|
|
14
14
|
//
|
|
15
15
|
// SUP-02: the beat mtime is the ONLY input. Nothing here reads a process table and nothing matches a
|
|
16
16
|
// process name — a poll-grep watcher carries `grep` in its own argv, so the filter that removes the
|
|
17
|
-
// probing grep removes the watched one. The payload
|
|
18
|
-
// record;
|
|
17
|
+
// probing grep removes the watched one. The payload's exitedWriterPid identifies the one-shot writer
|
|
18
|
+
// for an OPERATOR reading a STALE record; no derivation consults it, so its value changes no state.
|
|
19
19
|
// Zero new deps — node:fs stdlib, exactly as lock.ts.
|
|
20
20
|
/** How often a watcher rewrites its own tier's beat. */
|
|
21
21
|
export const SUPERVISION_BEAT_MS = 10_000;
|
|
@@ -27,6 +27,8 @@ export const SUPERVISION_STALE_MS = 6 * SUPERVISION_BEAT_MS;
|
|
|
27
27
|
// millisecond into the future. A second of slack covers even a coarse filesystem. Anything past it
|
|
28
28
|
// is SKEW, and skew is the one direction this instrument may not fail in: see readTierLiveness.
|
|
29
29
|
export const SUPERVISION_FUTURE_GRACE_MS = 1_000;
|
|
30
|
+
/** Context watchers act at 75% unless their invocation declares another threshold. */
|
|
31
|
+
export const SUPERVISION_DEFAULT_THRESHOLD_PCT = 75;
|
|
30
32
|
// The supervision seats this harness has. An unlisted tier is an INVISIBLE tier, which is the failure
|
|
31
33
|
// mode itself — an auditor read "no watchers were ever armed" off a surface that named none. Adding a
|
|
32
34
|
// seat means adding it here, and `status` then renders it whether or not it has ever beaten.
|
|
@@ -55,6 +57,8 @@ const supervisionDir = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "sup
|
|
|
55
57
|
export const supervisionBeatPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.beat`);
|
|
56
58
|
/** Where a watcher records that it STOOD DOWN. Its own file: the beat keeps meaning only "alive". */
|
|
57
59
|
export const supervisionStandDownPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.standdown`);
|
|
60
|
+
/** The independent latch a stand-down cannot overwrite or remove. */
|
|
61
|
+
const supervisionObligationPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.clear-owed`);
|
|
58
62
|
// SUP-06: PRESENCE — one file per ARMED WATCHER, because a tier may legitimately have more than one.
|
|
59
63
|
// Two boards watch one repo the moment an operator opens a second pane, and the tier is armed while
|
|
60
64
|
// EITHER of them lives. The stand-down marker speaks for the whole tier, so the first board out
|
|
@@ -93,25 +97,102 @@ function stalePeersIfLast(repoRoot, tier, id, now = Date.now()) {
|
|
|
93
97
|
}
|
|
94
98
|
return stale;
|
|
95
99
|
}
|
|
100
|
+
/** Read the latch without creating, touching or repairing it. */
|
|
101
|
+
function readClearObligation(repoRoot, tier) {
|
|
102
|
+
const p = supervisionObligationPath(repoRoot, tier);
|
|
103
|
+
let st;
|
|
104
|
+
try {
|
|
105
|
+
st = statSync(p);
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
const code = e.code;
|
|
109
|
+
return code === "ENOENT" || code === "ENOTDIR" ? "NONE" : "UNREADABLE";
|
|
110
|
+
}
|
|
111
|
+
if (!st.isFile())
|
|
112
|
+
return "UNREADABLE";
|
|
113
|
+
try {
|
|
114
|
+
const rec = JSON.parse(readFileSync(p, "utf8"));
|
|
115
|
+
if (rec?.tier !== tier)
|
|
116
|
+
return "UNREADABLE";
|
|
117
|
+
if (typeof rec.clearOwedSince !== "string" || Number.isNaN(Date.parse(rec.clearOwedSince)))
|
|
118
|
+
return "UNREADABLE";
|
|
119
|
+
if (typeof rec.armId !== "string" || !rec.armId.trim())
|
|
120
|
+
return "UNREADABLE";
|
|
121
|
+
if (typeof rec.thresholdPct !== "number" || !Number.isFinite(rec.thresholdPct)
|
|
122
|
+
|| rec.thresholdPct < 0 || rec.thresholdPct > 100)
|
|
123
|
+
return "UNREADABLE";
|
|
124
|
+
return { clearOwedSince: rec.clearOwedSince, armId: rec.armId, thresholdPct: rec.thresholdPct };
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return "UNREADABLE";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Publish a crossing atomically; a firing replacement inherits the original duty's instant. */
|
|
131
|
+
function raiseClearObligation(repoRoot, tier, armId, thresholdPct) {
|
|
132
|
+
const existing = readClearObligation(repoRoot, tier);
|
|
133
|
+
if (typeof existing === "object" && existing.armId === armId)
|
|
134
|
+
return;
|
|
135
|
+
if (existing === "UNREADABLE") {
|
|
136
|
+
throw new Error(`${tier} clear obligation is unreadable — refusing to overwrite a duty that may still be owed`);
|
|
137
|
+
}
|
|
138
|
+
const p = supervisionObligationPath(repoRoot, tier);
|
|
139
|
+
const tmp = `${p}.${process.pid}.${randomUUID()}.tmp`;
|
|
140
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
141
|
+
writeFileSync(tmp, JSON.stringify({
|
|
142
|
+
tier, armId, thresholdPct,
|
|
143
|
+
clearOwedSince: typeof existing === "object" ? existing.clearOwedSince : new Date().toISOString(),
|
|
144
|
+
}) + "\n");
|
|
145
|
+
renameSync(tmp, p);
|
|
146
|
+
}
|
|
147
|
+
/** Only a different arm observed below the firing threshold proves the old seat was cleared. */
|
|
148
|
+
function dischargeClearObligation(repoRoot, tier, observation) {
|
|
149
|
+
const existing = readClearObligation(repoRoot, tier);
|
|
150
|
+
if (existing === "UNREADABLE") {
|
|
151
|
+
throw new Error(`${tier} clear obligation is unreadable — refusing to erase a duty that may still be owed`);
|
|
152
|
+
}
|
|
153
|
+
if (typeof existing === "object" && existing.armId !== observation.armId
|
|
154
|
+
&& observation.pct < existing.thresholdPct) {
|
|
155
|
+
rmSync(supervisionObligationPath(repoRoot, tier), { force: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function validateObservation(observation) {
|
|
159
|
+
if (!observation.armId.trim())
|
|
160
|
+
throw new Error("a supervision observation needs a non-empty arm identity");
|
|
161
|
+
for (const [name, value] of [["pct", observation.pct], ["threshold-pct", observation.thresholdPct]]) {
|
|
162
|
+
if (!Number.isFinite(value) || value < 0 || value > 100) {
|
|
163
|
+
throw new Error(`--${name} must be a number from 0 through 100`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
96
167
|
// WRITER — a watcher's own call, on its own tier, every SUPERVISION_BEAT_MS. Never a reader's: the
|
|
97
168
|
// purity fence (status --watch leaves the state dir byte-identical) is the test that catches a reader
|
|
98
169
|
// that beats on the watcher's behalf, which would report every dead tier as healthy.
|
|
99
|
-
function writeSupervisionBeat(repoRoot, tier, seat, armId) {
|
|
170
|
+
function writeSupervisionBeat(repoRoot, tier, seat, armId, observation) {
|
|
100
171
|
// A seat tier may not be armed anonymously, and the refusal belongs HERE rather than only in the
|
|
101
172
|
// verb: any caller that could write a seatless record could arm a tier nobody occupies.
|
|
102
173
|
if (isSeatTier(tier) && !seat?.trim()) {
|
|
103
174
|
throw new Error(`${tier} is a per-seat tier — a beat must declare the seat identity it speaks for`);
|
|
104
175
|
}
|
|
176
|
+
if (observation !== undefined)
|
|
177
|
+
validateObservation(observation);
|
|
105
178
|
tickmarkrDir(repoRoot); // the write path DOES create — beats land inside the gitignored state dir
|
|
106
179
|
const p = supervisionBeatPath(repoRoot, tier);
|
|
107
180
|
mkdirSync(dirname(p), { recursive: true });
|
|
181
|
+
// Raise before the beat so a later beat failure cannot hide a duty; discharge only after the
|
|
182
|
+
// below-threshold observation exists on disk.
|
|
183
|
+
if (observation !== undefined && observation.pct >= observation.thresholdPct) {
|
|
184
|
+
raiseClearObligation(repoRoot, tier, observation.armId, observation.thresholdPct);
|
|
185
|
+
}
|
|
108
186
|
writeFileSync(p, JSON.stringify({
|
|
109
187
|
tier, ...(seat ? { seat } : {}), ...(armId ? { armId } : {}),
|
|
110
|
-
|
|
188
|
+
...(observation !== undefined ? { pct: observation.pct, thresholdPct: observation.thresholdPct } : {}),
|
|
189
|
+
exitedWriterPid: process.pid, beatAt: new Date().toISOString(),
|
|
111
190
|
}) + "\n");
|
|
191
|
+
if (observation !== undefined)
|
|
192
|
+
dischargeClearObligation(repoRoot, tier, observation);
|
|
112
193
|
}
|
|
113
|
-
export function beatSupervision(repoRoot, tier, seat) {
|
|
114
|
-
writeSupervisionBeat(repoRoot, tier, seat);
|
|
194
|
+
export function beatSupervision(repoRoot, tier, seat, observation) {
|
|
195
|
+
writeSupervisionBeat(repoRoot, tier, seat, undefined, observation);
|
|
115
196
|
}
|
|
116
197
|
// THE WATCHER-FACING ENTRY POINT — the loop SUPERVISION_BEAT_MS actually drives. A supervising seat
|
|
117
198
|
// calls this once at the top of its watch and holds the handle for the duration; a seat that dies,
|
|
@@ -138,7 +219,7 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, sea
|
|
|
138
219
|
}
|
|
139
220
|
catch { /* uncleared: the reader validates the marker and a newer beat outranks it — never masked */ }
|
|
140
221
|
// This watcher's own identity fences BOTH its presence and its stand-down against every later arm.
|
|
141
|
-
//
|
|
222
|
+
// A process id alone collides between two boards in one host (and after reuse); a UUID never aliases the
|
|
142
223
|
// stale presence of a killed process that a later last-one-out cleanup may already have observed.
|
|
143
224
|
const id = `${process.pid}.${randomUUID()}`;
|
|
144
225
|
const presence = supervisionPresencePath(repoRoot, tier, id);
|
|
@@ -146,7 +227,7 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, sea
|
|
|
146
227
|
const mark = () => {
|
|
147
228
|
try {
|
|
148
229
|
writeSupervisionBeat(repoRoot, tier, seat, id); // creates the directory presence is written into
|
|
149
|
-
writeFileSync(presence, JSON.stringify({ tier,
|
|
230
|
+
writeFileSync(presence, JSON.stringify({ tier, exitedWriterPid: process.pid, id }) + "\n");
|
|
150
231
|
}
|
|
151
232
|
catch { /* repo gone / disk full / no seat — the tier ages out rather than crashing its watcher */ }
|
|
152
233
|
};
|
|
@@ -182,7 +263,7 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, sea
|
|
|
182
263
|
mkdirSync(dirname(p), { recursive: true });
|
|
183
264
|
writeFileSync(tmp, JSON.stringify({
|
|
184
265
|
tier, ...(seat ? { seat } : {}), armId: id,
|
|
185
|
-
|
|
266
|
+
exitedWriterPid: process.pid, disarmedAt: new Date().toISOString(),
|
|
186
267
|
}) + "\n");
|
|
187
268
|
renameSync(tmp, p);
|
|
188
269
|
}
|
|
@@ -198,6 +279,10 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, sea
|
|
|
198
279
|
},
|
|
199
280
|
};
|
|
200
281
|
}
|
|
282
|
+
/** Arm the live status board's own tier through the normal supervision writer and lifecycle. */
|
|
283
|
+
export function armWatchSupervision(repoRoot, beatMs = SUPERVISION_BEAT_MS) {
|
|
284
|
+
return armSupervision(repoRoot, "watch", beatMs);
|
|
285
|
+
}
|
|
201
286
|
// BEAT DERIVATION — pure, and the only thing that reads the beat. One statSync: never creates,
|
|
202
287
|
// touches or reaps the record it reports on, and never creates the directory that holds it. ONLY a
|
|
203
288
|
// missing path is ABSENT: ENOENT (no beat file) and ENOTDIR (nothing that could hold one) mean no
|
|
@@ -206,7 +291,7 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, sea
|
|
|
206
291
|
// never silently ARMED. Callers wanting the TIER's state want supervisionStatus below; this answers
|
|
207
292
|
// the narrower question "does the beat say alive", which is all a beat can ever say.
|
|
208
293
|
export function readTierLiveness(repoRoot, tier, now = Date.now()) {
|
|
209
|
-
return beatLiveness(tier, readBeat(repoRoot, tier), now);
|
|
294
|
+
return withClearObligation(repoRoot, tier, beatLiveness(tier, readBeat(repoRoot, tier), now));
|
|
210
295
|
}
|
|
211
296
|
// The beat's inode, or why there is no age to derive from it. Split out so the stand-down ranking below
|
|
212
297
|
// reads the SAME mtime this derivation does rather than a second, later stat of a moving record.
|
|
@@ -266,6 +351,15 @@ function beatLiveness(tier, beat, now) {
|
|
|
266
351
|
const beatAgeMs = Math.max(0, age); // inside the grace: the two clocks' resolutions, not skew
|
|
267
352
|
return { tier, state: beatAgeMs > SUPERVISION_STALE_MS ? "STALE" : "ARMED", beatAgeMs, ...named };
|
|
268
353
|
}
|
|
354
|
+
/** Add the independent duty without replacing or reinterpreting the watcher's state. */
|
|
355
|
+
function withClearObligation(repoRoot, tier, liveness) {
|
|
356
|
+
const obligation = readClearObligation(repoRoot, tier);
|
|
357
|
+
if (obligation === "NONE")
|
|
358
|
+
return liveness;
|
|
359
|
+
return obligation === "UNREADABLE"
|
|
360
|
+
? { ...liveness, clearOwedUnreadable: true }
|
|
361
|
+
: { ...liveness, clearOwedSince: obligation.clearOwedSince };
|
|
362
|
+
}
|
|
269
363
|
// A stand-down is only what a watcher RECORDED, so the record has to READ as one: a regular file whose
|
|
270
364
|
// payload names this tier and the instant it stood down. Path existence is not proof — a directory, a
|
|
271
365
|
// torn write or a stray file at that path says nothing about any watcher, and calling one of those a
|
|
@@ -317,15 +411,17 @@ export function supervisionStatus(repoRoot, tier, now = Date.now()) {
|
|
|
317
411
|
const beat = readBeat(repoRoot, tier);
|
|
318
412
|
const standDown = readStandDown(repoRoot, tier);
|
|
319
413
|
if (standDown === "UNREADABLE")
|
|
320
|
-
return { tier, state: "UNREADABLE" };
|
|
414
|
+
return withClearObligation(repoRoot, tier, { tier, state: "UNREADABLE" });
|
|
321
415
|
const beatOutranksStandDown = standDown !== "NONE" && typeof beat === "object" && (beat.mtimeMs > standDown.mtimeMs || (now - beat.mtimeMs <= SUPERVISION_STALE_MS &&
|
|
322
416
|
beat.armId !== undefined && standDown.armId !== undefined && beat.armId !== standDown.armId));
|
|
323
417
|
if (standDown !== "NONE" && !beatOutranksStandDown) {
|
|
324
418
|
// the seat that stood down is named by the marker, falling back to whatever its last beat named
|
|
325
419
|
const seat = standDown.seat ?? (typeof beat === "object" ? beat.seat : undefined);
|
|
326
|
-
return
|
|
420
|
+
return withClearObligation(repoRoot, tier, {
|
|
421
|
+
tier, state: "DISARMED", ...(seat !== undefined ? { seat } : {}),
|
|
422
|
+
});
|
|
327
423
|
}
|
|
328
|
-
return beatLiveness(tier, beat, now);
|
|
424
|
+
return withClearObligation(repoRoot, tier, beatLiveness(tier, beat, now));
|
|
329
425
|
}
|
|
330
426
|
/** Every KNOWN tier, always — a tier omitted from this list would read as one that is fine. */
|
|
331
427
|
export const readSupervision = (repoRoot, now = Date.now()) => SUPERVISION_TIERS.map((tier) => supervisionStatus(repoRoot, tier, now));
|
|
@@ -333,4 +429,6 @@ export const readSupervision = (repoRoot, now = Date.now()) => SUPERVISION_TIERS
|
|
|
333
429
|
// The seat is rendered BESIDE the state, never instead of it: `overseer-context ARMED (w3:p2)` says
|
|
334
430
|
// both that something is beating and who is behind it, which is the pair an operator needs to act. A
|
|
335
431
|
// 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})` : ""}`
|
|
432
|
+
export const supervisionText = (tiers, divider = " · ") => `supervision: ${tiers.map((t) => `${t.tier} ${t.state}${t.seat ? ` (${t.seat})` : ""}` +
|
|
433
|
+
`${t.clearOwedSince ? ` CLEAR-OWED since ${t.clearOwedSince}` : ""}` +
|
|
434
|
+
`${t.clearOwedUnreadable ? " CLEAR-OWED unreadable" : ""}`).join(divider)}`;
|
package/package.json
CHANGED
|
@@ -45,7 +45,8 @@ Before `tickmarkr compile` or `tickmarkr run`, compare the installed binary agai
|
|
|
45
45
|
|
|
46
46
|
1. Run `tickmarkr version` (one line, machine-parseable).
|
|
47
47
|
2. Read the `version` field from the repository's `package.json`.
|
|
48
|
-
3. If the binary and repository do not **agree on the entire version** (including the patch; e.g. binary `2.1.0` vs repo `2.1.1`), **stop immediately** and tell the operator to update the global install (`npm i -g tickmarkr@latest`) or
|
|
48
|
+
3. If the binary and repository do not **agree on the entire version** (including the patch; e.g. binary `2.1.0` vs repo `2.1.1`), **stop immediately** and tell the operator to update the global install (`npm i -g tickmarkr@latest`), or to install this repository's build as a REAL COPY — `npm pack`, then `npm i -g ./<tarball>`. Do not compile, plan, or run on hope.
|
|
49
|
+
> ⚠ **Never `npm i -g .` on the repository directory, and never link it.** npm SYMLINKS a directory install, which makes the working tree itself the machine-wide binary: every later build — including a gate's own `npm run build` — silently hot-swaps the CLI for every repository on the machine, with no version change to notice it by. Measured 2026-08-29: a verify build gate rewrote the shared binary while another repository's daemon was mid-run against it, and a positive control that rebuilds at a pre-fix ref would have installed the very defect it was proving fixed, machine-wide (OBS-771). Verify an install by comparing the global and repo **inodes** — they must DIFFER — never by `tickmarkr version`, which cannot go red when nothing is bumped.
|
|
49
50
|
|
|
50
51
|
A stale binary silently skips daemon gates shipped in newer releases — the v1.38 run exposed this when a global `1.36.0` binary missed the daemon tip-verify gate entirely (OBS-38). Preflight failure is always stop-and-report; never proceed-and-hope.
|
|
51
52
|
|
|
@@ -40,7 +40,8 @@ Before `tickmarkr compile` or `tickmarkr run`, compare the installed binary agai
|
|
|
40
40
|
|
|
41
41
|
1. Run `tickmarkr version` (one line, machine-parseable).
|
|
42
42
|
2. Read the `version` field from the repository's `package.json`.
|
|
43
|
-
3. If the binary and repository do not **agree on the entire version** (including the patch; e.g. binary `2.1.0` vs repo `2.1.1`), **stop immediately** and tell the operator to update the global install (`npm i -g tickmarkr@latest`) or
|
|
43
|
+
3. If the binary and repository do not **agree on the entire version** (including the patch; e.g. binary `2.1.0` vs repo `2.1.1`), **stop immediately** and tell the operator to update the global install (`npm i -g tickmarkr@latest`), or to install this repository's build as a REAL COPY — `npm pack`, then `npm i -g ./<tarball>`. Do not compile, plan, or run on hope.
|
|
44
|
+
> ⚠ **Never `npm i -g .` on the repository directory, and never link it.** npm SYMLINKS a directory install, which makes the working tree itself the machine-wide binary: every later build — including a gate's own `npm run build` — silently hot-swaps the CLI for every repository on the machine, with no version change to notice it by. Measured 2026-08-29: a verify build gate rewrote the shared binary while another repository's daemon was mid-run against it, and a positive control that rebuilds at a pre-fix ref would have installed the very defect it was proving fixed, machine-wide (OBS-771). Verify an install by comparing the global and repo **inodes** — they must DIFFER — never by `tickmarkr version`, which cannot go red when nothing is bumped.
|
|
44
45
|
|
|
45
46
|
A stale binary silently skips daemon gates shipped in newer releases — the v1.38 run exposed this when a global `1.36.0` binary missed the daemon tip-verify gate entirely (OBS-38). Preflight failure is always stop-and-report; never proceed-and-hope.
|
|
46
47
|
|
|
@@ -60,6 +60,11 @@ through brief lineage. **An executor choice nobody made is still an executor cho
|
|
|
60
60
|
OPERATOR LAYOUT/CONVENTION rather than a shipped milestone — names like `*-discipline`, `*-drill`,
|
|
61
61
|
`*-parity`, `*-least-permission`, `context-reset-*`, `consults-*`, `agent-*`, `*-tab-layout`,
|
|
62
62
|
`*-visible-*`, `*-panes*`, `user-tabs-*`.
|
|
63
|
+
**A standing instruction carries its revocation premise.** Every standing rule you lift from memory,
|
|
64
|
+
handoff or a live correction states the premise that makes it true and the concrete observation that
|
|
65
|
+
would falsify that premise and revoke the rule. If you cannot name the falsifier, you have written a
|
|
66
|
+
preference, not standing supervision law. When the falsifier arrives, retire or amend the rule in the
|
|
67
|
+
shipped skill in the same act; do not leave successors to obey a rule whose reason is already false.
|
|
63
68
|
**Earned 2026-08-04, expensively.** That directory held `…-falsification-drill-discipline.md`, written
|
|
64
69
|
three weeks earlier: *"a gate or grep-pin is assumed WRONG until a falsification drill proves it bites…
|
|
65
70
|
run the drill that should redden it and SEE the red before trusting green."* That is Evidence discipline
|
|
@@ -103,7 +108,7 @@ through brief lineage. **An executor choice nobody made is still an executor cho
|
|
|
103
108
|
fraction (`ORCH · v1.19 4/5`, updated on every task-done); tickmarkr opens ONE TAB PER TASK, labelled
|
|
104
109
|
with the task id and holding that task's worker plus its judge/review/consult panes (tickmarkr
|
|
105
110
|
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 --settings '{"promptSuggestionEnabled":false}'
|
|
111
|
+
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}'`. **For a codex consultant, use `-a never --sandbox workspace-write` — NOT `--sandbox read-only`.** ⚠ **`--sandbox read-only` CONTRADICTS this skill's own completion protocol and will hang the seat.** Every seat you spawn is told to deliver an ARTIFACT ending in a terminal MARKER, because that is the only completion signal the artifact watcher can key on (`done` is turn end). A read-only sandbox cannot write that artifact, so codex blocks on `Would you like to make the following edits?` for its OWN report — and the report exists ONLY in the pending edit, so abandoning the prompt destroys the work rather than merely delaying it. Measured 2026-08-28: a consultant spawned `--sandbox read-only` finished a 14,604-byte verdict, sat blocked on the write, and the operator saw the prompt before the supervising tier did. `read-only` is correct ONLY for a seat that writes nothing at all — which, under the artifact+marker rule, is no seat this skill tells you to spawn. When the prompt does appear, answer **"Yes, and don't ask again for these files"** rather than plain yes: plain yes re-blocks on the next write of the same file. **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
112
|
3. **Standing instructions travel as a brief FILE, never as pane text** — PTY input truncates at ~1024B and a
|
|
108
113
|
truncated brief silently drops policy. Write the full brief to `<repo>/.tickmarkr/overseer/ORCH-BRIEF.md`
|
|
109
114
|
(inside the tickmarkr state dir — already self-gitignored, no exclude step needed), then send one line:
|
|
@@ -565,6 +570,10 @@ cleared, re-briefed, or replaced keeps beating that tier's file forever. Measure
|
|
|
565
570
|
**three-hour window in which no orchestrator was alive**, and it would have silently re-armed a
|
|
566
571
|
recorded stand-down within 10 seconds. On the same sweep the overseer tier had **three** beat loops,
|
|
567
572
|
one owned by an unrelated session. So:
|
|
573
|
+
- **Split the liveness reads.** A tier's liveness is read from beat freshness in the repository status
|
|
574
|
+
path; a loop's liveness is read from the live process payload that is emitting that beat (`tickmarkr
|
|
575
|
+
beat <tier> --seat <seat>` in this repo). Neither liveness claim is read from a recorded pid: a pid
|
|
576
|
+
recorded earlier can be stale, reused, or detached from the beat now holding the tier green.
|
|
568
577
|
- **At every adopt, clear, or re-brief, sweep for pre-existing loops on YOUR tier before arming one**
|
|
569
578
|
(`pgrep -f "tickmarkr beat <tier>"`), trace each to its parent session, and kill the **loop only**
|
|
570
579
|
— never the parent — then verify the parent survived.
|
|
@@ -1053,6 +1062,17 @@ twice.** They are mission-independent on purpose: nothing here names a task, a l
|
|
|
1053
1062
|
concluded both forms were valid. The tell is unavailable unless the tool volunteers it. Corollary —
|
|
1054
1063
|
an instrument that takes an input must be handed a DELIBERATELY BAD one before its clean runs are
|
|
1055
1064
|
worth anything (rule 11 applied to tools, not just to gates).
|
|
1065
|
+
⚠ **AND THE COMMONEST WRONG INPUT IS A BASE REF: after the first merge, a task's diff against the
|
|
1066
|
+
run's `baseRef` is NEVER that task's diff.** Workers branch from the INTEGRATION TIP, so once any task
|
|
1067
|
+
has merged, `git diff baseRef..HEAD` in a later worktree reports that task PLUS every task merged
|
|
1068
|
+
before it, and the number looks entirely plausible. Diff from the task's OWN base — the integration
|
|
1069
|
+
commit it branched from — and say which base you used whenever you quote a size.
|
|
1070
|
+
**Measured 2026-08-28 in one run, twice, in both directions.** A supervising seat quoted "712
|
|
1071
|
+
insertions across 7 files" for a task whose real contribution was **300 across 2**; the surplus was two
|
|
1072
|
+
other tasks' merged work. On the next task the same trap was **larger** — 920 across 11 versus a true
|
|
1073
|
+
167 across 4 — and it was caught only because the other tier had just been burned by it. A scope
|
|
1074
|
+
judgement, a cost claim, or a review-size argument built on the baseRef diff is measuring three tasks
|
|
1075
|
+
and calling it one.
|
|
1056
1076
|
14. **A unit is not a measurement.** A configured timeout is a KILL CEILING, not a duration — never compare
|
|
1057
1077
|
it to a wall clock or quote it to an operator as an estimate.
|
|
1058
1078
|
15. **Verify through the path that LOADS, not the path you edited.** Mirrored trees and symlinks mean your
|
|
@@ -20,15 +20,15 @@
|
|
|
20
20
|
# than absent. Four rules the shipped version broke, each of which made the tier lie:
|
|
21
21
|
# 1. BEAT ON THE SUPERVISION CADENCE, NOT ON THE POLL. Beats gap by TICK (below), never by POLL, so a
|
|
22
22
|
# poll interval above the supervision beat interval cannot leave the tier stale half of every cycle.
|
|
23
|
-
# 2. BEAT
|
|
24
|
-
#
|
|
23
|
+
# 2. BEAT AFTER EVERY SUCCESSFUL SCREEN READ. A missing percentage is an explicit UNREADABLE
|
|
24
|
+
# observation: it must keep the live watcher armed but can never reach warn or act. Only a failed
|
|
25
|
+
# screen read withholds the beat, because then the watcher cannot see its seat at all.
|
|
25
26
|
# 3. WARN DOES NOT EXIT. Warn precedes act, so exiting at warn meant the act was never reached and the
|
|
26
27
|
# last beat aged into a permanent stale — gradual growth never reached the auto-clear path at all.
|
|
27
28
|
# 4. EVERY TERMINAL EXIT STANDS THE TIER DOWN, so a watcher that finished reads DISARMED, not dead.
|
|
28
29
|
# Only a killed watcher reads STALE, which is exactly what STALE means.
|
|
29
|
-
# 5.
|
|
30
|
-
#
|
|
31
|
-
# must never look alike.
|
|
30
|
+
# 5. AN UNREADABLE FIELD REPORTS ITSELF (OBS-739/780). Silence, health and a trustworthy percentage
|
|
31
|
+
# are three different states. UNREADABLE stays supervised but never authorises a destructive act.
|
|
32
32
|
#
|
|
33
33
|
# usage: watch-context.sh <role-slug> <agent|pane> <warn-pct> <act-pct> [handoff-file] [poll-s] [cap-s]
|
|
34
34
|
# <role-slug> is ANY seat role — orchestrator, overseer, surgeon, consult — and names the tier
|
|
@@ -63,8 +63,8 @@ TIER="${ROLE}-context"
|
|
|
63
63
|
|
|
64
64
|
|
|
65
65
|
# The supervision beat interval (SUPERVISION_BEAT_MS = 10s). The loop ticks at the beat cadence or the
|
|
66
|
-
# caller's poll, whichever is SHORTER
|
|
67
|
-
#
|
|
66
|
+
# caller's poll, whichever is SHORTER. Every successful screen read beats; its result is either a
|
|
67
|
+
# percentage safe to compare or the explicit UNREADABLE state.
|
|
68
68
|
BEAT_EVERY=5
|
|
69
69
|
TICK=$(( POLL < BEAT_EVERY ? POLL : BEAT_EVERY ))
|
|
70
70
|
[ "$TICK" -ge 1 ] 2>/dev/null || TICK=1 # a zero or junk poll would spin, not watch
|
|
@@ -100,16 +100,18 @@ stand_down() { tickmarkr beat "$TIER" --stand-down --seat "$SEAT" >/dev/null 2>&
|
|
|
100
100
|
# record the hand-off. A killed watcher never runs it, which is the one case that must read STALE.
|
|
101
101
|
trap stand_down EXIT
|
|
102
102
|
|
|
103
|
-
# The seat's
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
# its cap while its tier claimed coverage. The last percentage in the statusline window is the fallback.
|
|
103
|
+
# The seat's rendered truth lives on the model banner. Select that line first, then read a percentage
|
|
104
|
+
# only from it: a bare numeric search across the visible window can borrow an old N% from scrollback
|
|
105
|
+
# when a long live-run segment pushes the real field past the pane's visible width (OBS-780).
|
|
107
106
|
context_pct() {
|
|
108
|
-
local screen
|
|
107
|
+
local screen banner pct
|
|
109
108
|
screen=$(herdr agent read "$TARGET" --source visible --lines 8 2>/dev/null) || return 1
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
banner=$(printf '%s\n' "$screen" |
|
|
110
|
+
grep -Ei '(^|[^[:alnum:]])(claude|opus|sonnet|haiku|gpt|gemini|glm|kimi|grok|composer|openai|zai)[[:alnum:]_./-]*([[:space:]]|$)' |
|
|
111
|
+
tail -1)
|
|
112
|
+
[ -n "$banner" ] || { printf 'UNREADABLE\n'; return 0; }
|
|
113
|
+
pct=$(printf '%s\n' "$banner" | grep -oE '[0-9]+%' | tail -1 | tr -d '%')
|
|
114
|
+
[ -n "$pct" ] && printf '%s\n' "$pct" || printf 'UNREADABLE\n'
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
handoff_fresh() {
|
|
@@ -144,35 +146,47 @@ act_on() {
|
|
|
144
146
|
|
|
145
147
|
warned=0
|
|
146
148
|
elapsed=0
|
|
147
|
-
blind=0 # seconds in the current
|
|
149
|
+
blind=0 # seconds in the current failed-read spell (OBS-739)
|
|
148
150
|
blind_alarmed=0
|
|
151
|
+
unreadable_reported=0
|
|
149
152
|
BLIND_ALARM_S="${TKR_BLIND_ALARM_S:-120}"
|
|
150
153
|
while [ "$elapsed" -lt "$CAP" ]; do
|
|
151
|
-
P=$(context_pct)
|
|
154
|
+
if ! P=$(context_pct); then P=""; fi
|
|
152
155
|
if [ -z "$P" ]; then
|
|
153
|
-
#
|
|
154
|
-
#
|
|
155
|
-
|
|
156
|
-
# Rule 5 (OBS-739): ALARM ON THE BLIND READ. Ageing a tier is not enough — it is only visible to
|
|
157
|
-
# someone already reading the beat table, and the measured failure was a watcher ALIVE AND BLIND for
|
|
158
|
-
# hours because the run's own status text pushed the percentage off the statusline. An instrument
|
|
159
|
-
# that cannot report its own absence is worse than none: the seat believes it has coverage and stops
|
|
160
|
-
# looking. So say so on stdout, where the supervising seat is actually woken. Once per blind spell,
|
|
161
|
-
# not every tick — a repeating alarm trains the reader to ignore it.
|
|
156
|
+
# The screen read itself failed: no observation, no beat. The tier ages to STALE and the watcher
|
|
157
|
+
# alarms once, because an instrument that cannot see its seat must never look healthy.
|
|
158
|
+
unreadable_reported=0
|
|
162
159
|
blind=$((blind + TICK))
|
|
163
160
|
if [ "$blind" -ge "$BLIND_ALARM_S" ] && [ "$blind_alarmed" -eq 0 ]; then
|
|
164
161
|
blind_alarmed=1
|
|
165
|
-
echo "CONTEXT_BLIND $TARGET —
|
|
166
|
-
echo " this watcher is NOT providing coverage: read the seat by hand and
|
|
167
|
-
echo " do NOT substitute the token total — '∑ Nk tok' is cumulative SPEND, not context fill"
|
|
162
|
+
echo "CONTEXT_BLIND $TARGET — screen unreadable for ${blind}s; tier ${TIER} is ALIVE AND BLIND"
|
|
163
|
+
echo " this watcher is NOT providing coverage: read the seat by hand and repair the screen read"
|
|
168
164
|
fi
|
|
169
165
|
sleep "$TICK"; elapsed=$((elapsed + TICK)); continue
|
|
170
166
|
fi
|
|
171
|
-
|
|
167
|
+
|
|
168
|
+
if [ "$P" = "UNREADABLE" ]; then
|
|
169
|
+
# The screen read succeeded, so keep the watcher armed. The absent field is still not a number:
|
|
170
|
+
# report it once per spell and never let it flow into warn or act.
|
|
171
|
+
if [ "$blind_alarmed" -eq 1 ]; then
|
|
172
|
+
echo "CONTEXT_BLIND_CLEARED $TARGET — screen readable again after ${blind}s blind"
|
|
173
|
+
fi
|
|
174
|
+
blind=0; blind_alarmed=0
|
|
175
|
+
beat
|
|
176
|
+
if [ "$unreadable_reported" -eq 0 ]; then
|
|
177
|
+
unreadable_reported=1
|
|
178
|
+
echo "CONTEXT_UNREADABLE $TARGET — model banner has no visible percentage; no warn or act authorised"
|
|
179
|
+
fi
|
|
180
|
+
sleep "$TICK"; elapsed=$((elapsed + TICK)); continue
|
|
181
|
+
fi
|
|
182
|
+
|
|
183
|
+
if [ "$unreadable_reported" -eq 1 ]; then
|
|
184
|
+
echo "CONTEXT_UNREADABLE_CLEARED $TARGET — percentage readable again at ${P}%"
|
|
185
|
+
fi
|
|
172
186
|
if [ "$blind_alarmed" -eq 1 ]; then
|
|
173
187
|
echo "CONTEXT_BLIND_CLEARED $TARGET — percentage readable again at ${P}% after ${blind}s blind"
|
|
174
188
|
fi
|
|
175
|
-
blind=0; blind_alarmed=0
|
|
189
|
+
blind=0; blind_alarmed=0; unreadable_reported=0
|
|
176
190
|
beat
|
|
177
191
|
|
|
178
192
|
if [ "$P" -ge "$ACT" ] 2>/dev/null; then
|
|
@@ -190,4 +204,4 @@ while [ "$elapsed" -lt "$CAP" ]; do
|
|
|
190
204
|
sleep "$TICK"; elapsed=$((elapsed + TICK))
|
|
191
205
|
done
|
|
192
206
|
|
|
193
|
-
echo "WATCH_CAP_REACHED $TARGET
|
|
207
|
+
echo "WATCH_CAP_REACHED $TARGET — no threshold crossed in ${CAP}s"
|