tickmarkr 1.97.0 → 2.0.0
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/brand.d.ts +28 -0
- package/dist/brand.js +41 -0
- package/dist/cli/commands/compile.js +32 -1
- package/dist/cli/commands/resume.js +9 -3
- package/dist/cli/commands/run.d.ts +61 -1
- package/dist/cli/commands/run.js +368 -17
- package/dist/cli/commands/status.js +145 -28
- package/dist/compile/collateral.d.ts +25 -0
- package/dist/compile/collateral.js +46 -11
- package/dist/compile/native.js +10 -0
- package/dist/drivers/herdr.d.ts +19 -13
- package/dist/drivers/herdr.js +88 -26
- package/dist/drivers/types.d.ts +2 -0
- package/dist/gates/acceptance.js +17 -7
- package/dist/gates/llm.d.ts +19 -0
- package/dist/gates/llm.js +104 -6
- package/dist/gates/run-gates.d.ts +18 -0
- package/dist/gates/run-gates.js +195 -29
- package/dist/gates/scope.d.ts +9 -1
- package/dist/gates/scope.js +22 -2
- package/dist/graph/graph.d.ts +1 -0
- package/dist/graph/graph.js +19 -2
- package/dist/report/compare.js +17 -2
- package/dist/run/daemon.d.ts +1 -8
- package/dist/run/daemon.js +231 -246
- package/dist/run/environment.d.ts +18 -1
- package/dist/run/environment.js +19 -2
- package/dist/run/journal.d.ts +50 -3
- package/dist/run/journal.js +181 -5
- package/dist/run/protocol.d.ts +4 -4
- package/dist/run/stall.d.ts +30 -0
- package/dist/run/stall.js +173 -0
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +20 -15
package/dist/run/stall.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { shGit } from "./git.js";
|
|
1
3
|
// OBS-82: normalize known presentation tokens before measuring transcript extent or filtering an
|
|
2
4
|
// LLM-bound transcript. This remains a closed allowlist — ANSI/VT escapes, braille-range spinner
|
|
3
5
|
// glyphs, and elapsed-time tokens bound to time-unit suffixes. Every other byte passes through
|
|
@@ -18,6 +20,177 @@ const ELAPSED_RE = /(?<![\w.])\d+(?:\.\d+)?(?:ms|[hms])(?!\w)/g;
|
|
|
18
20
|
export function normalizeStallSnapshot(text) {
|
|
19
21
|
return text.replace(ANSI_RE, "").replace(SPINNER_RE, "").replace(ELAPSED_RE, "");
|
|
20
22
|
}
|
|
23
|
+
// T2 (OBS-264): a CPU delta needs two samples separated in WALL CLOCK, and the CPU clock is
|
|
24
|
+
// QUANTIZED: darwin's `ps` prints hundredths ("0:00.03"), linux's precise /proc clock advances in
|
|
25
|
+
// jiffies. Equality across a window shorter than the quantum is not evidence of rest. Crossing 30
|
|
26
|
+
// ticks means the tree burned <1 tick in 30, i.e. under ~3% of one core. This process-tree liveness
|
|
27
|
+
// primitive lives here rather than in daemon.ts so lower-level gate waits can use it without closing
|
|
28
|
+
// the daemon -> run-gates -> llm -> daemon dependency cycle.
|
|
29
|
+
const HARVEST_CPU_FLAT_MS = 3_000;
|
|
30
|
+
const HARVEST_CPU_FLAT_TICKS = 30;
|
|
31
|
+
const WORKER_TREE_CPU_ACCOUNTING_POLL_MS = 100;
|
|
32
|
+
const WORKER_TREE_CPU_UNMEASURABLE_SAMPLE_CAP = 20;
|
|
33
|
+
let harvestCpuFlatMs;
|
|
34
|
+
export function harvestCpuFlatWindowMs(resolutionMs) {
|
|
35
|
+
return harvestCpuFlatMs ?? Math.max(HARVEST_CPU_FLAT_MS, resolutionMs * HARVEST_CPU_FLAT_TICKS);
|
|
36
|
+
}
|
|
37
|
+
/** Test seam — pin the quantization-aware flat window without changing production policy. */
|
|
38
|
+
export function setHarvestCpuFlatMsForTests(ms) {
|
|
39
|
+
harvestCpuFlatMs = ms;
|
|
40
|
+
}
|
|
41
|
+
export function resetHarvestCpuFlatMsForTests() {
|
|
42
|
+
harvestCpuFlatMs = undefined;
|
|
43
|
+
}
|
|
44
|
+
// `ps` CPU time: "[[dd-]hh:]mm:ss[.frac]". Anything else is a header or a row this parser must
|
|
45
|
+
// not guess at. `frac` reports whether this host exposes sub-second digits so callers use the
|
|
46
|
+
// sampled clock's quantum rather than assuming one.
|
|
47
|
+
function parsePsCpu(raw) {
|
|
48
|
+
const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/.exec(raw);
|
|
49
|
+
if (!m)
|
|
50
|
+
return undefined;
|
|
51
|
+
const ms = ((Number(m[1] ?? 0) * 24 + Number(m[2] ?? 0)) * 60 + Number(m[3])) * 60_000
|
|
52
|
+
+ Math.round(Number(m[4]) * 1000);
|
|
53
|
+
return { ms, frac: m[4].includes(".") };
|
|
54
|
+
}
|
|
55
|
+
let linuxClockTickMs;
|
|
56
|
+
function linuxProcessCpuMs(pid, cwd) {
|
|
57
|
+
if (!existsSync("/proc/self/stat"))
|
|
58
|
+
return Promise.resolve(undefined);
|
|
59
|
+
// shGit, not sh: the accountant samples at 100ms cadence and must not run the operator's login
|
|
60
|
+
// profile (nvm/pyenv/direnv side effects included) on every sample.
|
|
61
|
+
linuxClockTickMs ??= shGit("getconf CLK_TCK", cwd, 15_000).then((r) => {
|
|
62
|
+
const ticks = r.code === 0 ? Number(r.stdout.trim()) : Number.NaN;
|
|
63
|
+
return Number.isFinite(ticks) && ticks > 0 ? 1_000 / ticks : undefined;
|
|
64
|
+
});
|
|
65
|
+
return linuxClockTickMs.then((resolutionMs) => {
|
|
66
|
+
if (resolutionMs === undefined)
|
|
67
|
+
return undefined;
|
|
68
|
+
try {
|
|
69
|
+
// `/proc/<pid>/stat` fields 14-17 are user/system jiffies for the process and its waited-for
|
|
70
|
+
// children. Child totals retain tools that start and exit wholly between live-tree polls.
|
|
71
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
72
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/);
|
|
73
|
+
const ticks = Number(fields[11]) + Number(fields[12]) + Number(fields[13]) + Number(fields[14]);
|
|
74
|
+
return Number.isFinite(ticks) ? { ms: ticks * resolutionMs, resolutionMs } : undefined;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// Every non-seeded worker process descends from its attempt-unique dispatch script. One ps snapshot
|
|
82
|
+
// finds that root and its descendants. An empty tree is measurable zero; a failed or unparseable
|
|
83
|
+
// snapshot is undefined because missing evidence can never prove inactivity.
|
|
84
|
+
async function workerTreeCpuSnapshot(marker, cwd) {
|
|
85
|
+
const snapshot = await shGit("ps -Awwo pid=,ppid=,time=,command=", cwd, 15_000);
|
|
86
|
+
if (snapshot.code !== 0)
|
|
87
|
+
return undefined;
|
|
88
|
+
const rows = [];
|
|
89
|
+
for (const line of snapshot.stdout.split("\n")) {
|
|
90
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
|
|
91
|
+
if (!m)
|
|
92
|
+
continue;
|
|
93
|
+
const cpu = parsePsCpu(m[3]);
|
|
94
|
+
if (cpu !== undefined)
|
|
95
|
+
rows.push({ pid: m[1], ppid: m[2], cpuMs: cpu.ms, frac: cpu.frac, cmd: m[4] });
|
|
96
|
+
}
|
|
97
|
+
if (rows.length === 0)
|
|
98
|
+
return undefined;
|
|
99
|
+
const tree = new Set(rows.filter((p) => p.cmd.includes(marker)).map((p) => p.pid));
|
|
100
|
+
// ps output is not topologically ordered; relax the parent -> child closure until stable.
|
|
101
|
+
for (let grew = true; grew;) {
|
|
102
|
+
grew = false;
|
|
103
|
+
for (const p of rows) {
|
|
104
|
+
if (!tree.has(p.pid) && tree.has(p.ppid)) {
|
|
105
|
+
tree.add(p.pid);
|
|
106
|
+
grew = true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const precise = new Map();
|
|
111
|
+
let preciseResolutionMs;
|
|
112
|
+
for (const p of rows) {
|
|
113
|
+
if (!tree.has(p.pid))
|
|
114
|
+
continue;
|
|
115
|
+
const cpu = await linuxProcessCpuMs(p.pid, cwd);
|
|
116
|
+
precise.set(p.pid, cpu?.ms ?? p.cpuMs);
|
|
117
|
+
if (cpu !== undefined)
|
|
118
|
+
preciseResolutionMs = cpu.resolutionMs;
|
|
119
|
+
}
|
|
120
|
+
if (preciseResolutionMs === undefined && existsSync("/proc/self/stat")) {
|
|
121
|
+
preciseResolutionMs = (await linuxProcessCpuMs(String(process.pid), cwd))?.resolutionMs;
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
processes: precise,
|
|
125
|
+
resolutionMs: preciseResolutionMs ?? (rows.some((p) => p.frac) ? 10 : 1_000),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export async function workerTreeCpuMs(marker, cwd) {
|
|
129
|
+
const snapshot = await workerTreeCpuSnapshot(marker, cwd);
|
|
130
|
+
if (snapshot === undefined)
|
|
131
|
+
return undefined;
|
|
132
|
+
return {
|
|
133
|
+
ms: [...snapshot.processes.values()].reduce((sum, cpuMs) => sum + cpuMs, 0),
|
|
134
|
+
resolutionMs: snapshot.resolutionMs,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// Sparse live-tree totals forget a tool's CPU as soon as it exits. This attempt-local accountant
|
|
138
|
+
// instead accumulates each observed PID's delta and replaces only the live-PID cursor. Daemon workers
|
|
139
|
+
// and gate workers deliberately share these semantics: the marker is the unique dispatch script,
|
|
140
|
+
// an unreadable sample clears the current evidence, and stopped descendants remain in the total.
|
|
141
|
+
export class WorkerTreeCpuAccountant {
|
|
142
|
+
marker;
|
|
143
|
+
cwd;
|
|
144
|
+
active = false;
|
|
145
|
+
loop;
|
|
146
|
+
live = new Map();
|
|
147
|
+
totalMs = 0;
|
|
148
|
+
gaps = 0;
|
|
149
|
+
consecutiveGaps = 0;
|
|
150
|
+
latest;
|
|
151
|
+
constructor(marker, cwd) {
|
|
152
|
+
this.marker = marker;
|
|
153
|
+
this.cwd = cwd;
|
|
154
|
+
}
|
|
155
|
+
async sample() {
|
|
156
|
+
const snapshot = await workerTreeCpuSnapshot(this.marker, this.cwd);
|
|
157
|
+
if (snapshot === undefined) {
|
|
158
|
+
this.gaps++;
|
|
159
|
+
this.live.clear();
|
|
160
|
+
this.latest = undefined;
|
|
161
|
+
if (++this.consecutiveGaps >= WORKER_TREE_CPU_UNMEASURABLE_SAMPLE_CAP)
|
|
162
|
+
this.active = false;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.consecutiveGaps = 0;
|
|
166
|
+
for (const [pid, cpuMs] of snapshot.processes) {
|
|
167
|
+
const prior = this.live.get(pid);
|
|
168
|
+
this.totalMs += prior === undefined || cpuMs < prior ? cpuMs : cpuMs - prior;
|
|
169
|
+
}
|
|
170
|
+
this.live = snapshot.processes;
|
|
171
|
+
this.latest = { ms: this.totalMs, resolutionMs: snapshot.resolutionMs };
|
|
172
|
+
}
|
|
173
|
+
async start() {
|
|
174
|
+
if (this.active)
|
|
175
|
+
return;
|
|
176
|
+
this.active = true;
|
|
177
|
+
await this.sample();
|
|
178
|
+
this.loop = (async () => {
|
|
179
|
+
while (this.active) {
|
|
180
|
+
await new Promise((resolve) => setTimeout(resolve, WORKER_TREE_CPU_ACCOUNTING_POLL_MS));
|
|
181
|
+
if (this.active)
|
|
182
|
+
await this.sample();
|
|
183
|
+
}
|
|
184
|
+
})();
|
|
185
|
+
}
|
|
186
|
+
read() {
|
|
187
|
+
return { cpu: this.latest, gaps: this.gaps };
|
|
188
|
+
}
|
|
189
|
+
async stop() {
|
|
190
|
+
this.active = false;
|
|
191
|
+
await this.loop;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
21
194
|
// T1 (OBS-262): the rescue nudge's adapter scope — claude-code only (steering path proven,
|
|
22
195
|
// OBS-122). Widening it is a future fixture-capture chore (an occupied-frame capture per adapter,
|
|
23
196
|
// OBS-181 scar), never a drive-by edit. Lives in the stall module so the watchdog's policy and its
|
package/package.json
CHANGED
|
@@ -64,13 +64,15 @@ through brief lineage. **An executor choice nobody made is still an executor cho
|
|
|
64
64
|
**FIVE-TAB CANON (standing operator layout — corrected three times on 2026-07-27, layout approved
|
|
65
65
|
2026-07-29, re-earned 2026-08-17):**
|
|
66
66
|
- `OVERSEER` — you. Do not add a second live run surface: the daemon self-places the shipped board
|
|
67
|
-
|
|
68
|
-
- `ORCH` — the orchestrator
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
ABOVE the supervising seat that invokes the run.
|
|
68
|
+
- `ORCH` — the orchestrator with the daemon-placed, run-id-pinned shipped board STACKED ABOVE it: the
|
|
69
|
+
board owns the tab's full width and the top 72% of its height, and the orchestrator's own narration
|
|
70
|
+
is the rail underneath. **Look for that `role: "watch"` pane; never hand-place or hand-roll a live
|
|
71
|
+
run surface. Nothing else, ever: a work seat NEVER splits into the ORCH tab.** Operator verbatim:
|
|
72
|
+
*"in orch tab should be the orch and the watcher only."* Re-earned 2026-08-17: a planning seat split
|
|
73
|
+
beside the orchestrator, and the operator caught it, again. The daemon owns this vertical stack and
|
|
74
|
+
places it the same way at every terminal width; neither the worker-pane halving floor, nor a
|
|
75
|
+
measured column count, nor an overseer split command places this pane.
|
|
74
76
|
- Worker/seat tabs — tickmarkr opens ONE TAB PER TASK itself; GSD-leg seats get the same treatment
|
|
75
77
|
(own tab, or a shared WORKERS tab), never the ORCH tab.
|
|
76
78
|
- `CONSULT · <topic>` — ONE shared tab for ALL consultants of a round, side-by-side splits; never one
|
|
@@ -134,10 +136,12 @@ journal tail to decide what happens next, or sweeping orphans — you have taken
|
|
|
134
136
|
### What the ORCHESTRATOR does, and what you require of it
|
|
135
137
|
|
|
136
138
|
- **The live surface arrives with the run.** `tickmarkr run` is stdout-silent until run-end by design;
|
|
137
|
-
its daemon self-places one shipped `role: "watch"` board
|
|
138
|
-
board to the daemon's run id. **Look for the matching
|
|
139
|
-
as a daemon/run liveness fault and use the normal
|
|
140
|
-
a replacement live surface.
|
|
139
|
+
its daemon self-places one shipped `role: "watch"` board ABOVE the supervising seat — full width, top
|
|
140
|
+
72% of the height, narration below — and pins that board to the daemon's run id. **Look for the matching
|
|
141
|
+
daemon-placed pane.** If it is absent, treat that as a daemon/run liveness fault and use the normal
|
|
142
|
+
recovery path; never hand-place, hand-roll, or launch a replacement live surface. A board the daemon
|
|
143
|
+
could not stack is not silently re-arranged: the split is closed and the run continues BOARDLESS, so an
|
|
144
|
+
absent board means the placement failed, never that it landed somewhere else in the tab.
|
|
141
145
|
- **The journal is the source of truth**, not panes. Watchers go on `run-end` / `task-human` /
|
|
142
146
|
`task-failed` / `consult-verdict`; never sleep-poll inside an agent turn. **Never key a watcher on an
|
|
143
147
|
agent's `done`** — that is turn end and fires the moment a seat finishes acknowledging you.
|
|
@@ -551,10 +555,11 @@ orchestrator turn boundary.
|
|
|
551
555
|
safe 53 → floor 108"*), and it splits right only while `paneWidth/2 ≥ 108 + 2` (`herdr.ts:494`),
|
|
552
556
|
otherwise **down**. Apply the same test by hand: `herdr pane layout --pane <id>`, halve the width,
|
|
553
557
|
and if the halves fall under the floor, split `--direction down`.
|
|
554
|
-
- **The daemon-placed ORCH board is outside this manual split rule
|
|
555
|
-
worker-pane trailers; the daemon, not the overseer,
|
|
556
|
-
the supervising seat
|
|
557
|
-
|
|
558
|
+
- **The daemon-placed ORCH board is outside this manual split rule — width does not place it at all.**
|
|
559
|
+
The halving bound protects worker-pane trailers; the board carries none. The daemon, not the overseer,
|
|
560
|
+
splits the supervising seat DOWN at ratio 0.72 and swaps the new pane ABOVE it (`boardSplitPlan`,
|
|
561
|
+
`src/drivers/herdr.ts`), so the board is full width and the narration is the rail beneath it at every
|
|
562
|
+
terminal width. Look for that pane and do not split, place, resize, or recreate it.
|
|
558
563
|
- **Binary splits cannot produce an even 3-column row at any width.** 220 goes to 110/55/55 whichever
|
|
559
564
|
pane you split. **At a 220-col terminal the width-derived cap is TWO side-by-side panes**; a third
|
|
560
565
|
seat goes below one of them, or into its own tab. "Three panes" is a *height* heuristic
|