tickmarkr 2.1.2 → 2.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude-code.js +18 -3
- package/dist/adapters/pi.d.ts +6 -0
- package/dist/adapters/pi.js +99 -18
- package/dist/cli/commands/beat.js +28 -8
- package/dist/cli/commands/init.js +28 -1
- package/dist/cli/commands/status.d.ts +3 -0
- package/dist/cli/commands/status.js +40 -16
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/compile/gsd.js +21 -1
- package/dist/gates/baseline.d.ts +10 -0
- package/dist/gates/baseline.js +46 -3
- package/dist/run/daemon.d.ts +0 -1
- package/dist/run/daemon.js +54 -19
- package/dist/run/git.d.ts +20 -0
- package/dist/run/git.js +114 -8
- package/dist/run/journal.d.ts +24 -0
- package/dist/run/journal.js +62 -1
- package/dist/run/lock.d.ts +1 -0
- package/dist/run/lock.js +23 -13
- package/dist/run/supervision.d.ts +9 -3
- package/dist/run/supervision.js +185 -38
- package/dist/tui/cockpit/derive.js +5 -10
- package/fixtures/gsd-sample/07-live-check/07-03-SUMMARY.md +4 -0
- package/package.json +2 -2
- package/skills/tickmarkr-auto/SKILL.md +1 -1
- package/skills/tickmarkr-loop/SKILL.md +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +37 -14
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +80 -31
|
@@ -209,7 +209,7 @@ export const claudeCode = {
|
|
|
209
209
|
channels: (cfg) => channelsFromConfig("claude-code", cfg),
|
|
210
210
|
// v1.65 T3: every flag the command builders below hardcode — doctor checks `claude --help` still
|
|
211
211
|
// lists each (all present on claude 2.x, verified 2026-07-22). Advisory only, never routing.
|
|
212
|
-
hardcodedFlags: { binary: "claude", flags: ["-p", "--model", "--permission-mode", "--strict-mcp-config", "--mcp-config", "--output-format", "-r"] },
|
|
212
|
+
hardcodedFlags: { binary: "claude", flags: ["-p", "--model", "--permission-mode", "--strict-mcp-config", "--mcp-config", "--output-format", "-r", "--prompt-suggestions"] },
|
|
213
213
|
// --strict-mcp-config --mcp-config '{"mcpServers":{}}': pin the MCP surface to empty so fresh-worktree
|
|
214
214
|
// workers/gates don't load project .mcp.json servers (herdr scrapes dialogs as idle — v1.4 incident,
|
|
215
215
|
// memory tickmarkr-worker-mcp-dialog-stall). Live-verified 2026-07-10 on claude 2.1.205 (operator check):
|
|
@@ -223,10 +223,25 @@ export const claudeCode = {
|
|
|
223
223
|
// HYG-03 / OBS-137: the residual first-entry dialog is workspace trust, not MCP config loading.
|
|
224
224
|
// Claude's only store is global last-writer-wins ~/.claude.json, so tickmarkr still does not seed it;
|
|
225
225
|
// the daemon safely answers only the exact adapter-declared dialog once per slot.
|
|
226
|
-
|
|
226
|
+
//
|
|
227
|
+
// v2.1.3 T7 --prompt-suggestions false: claude paints a PREDICTED next prompt into the editor after
|
|
228
|
+
// a turn. On a pane it is pixel-identical to a seat's own unsubmitted draft, so every occupancy
|
|
229
|
+
// reader downstream — the input-box occupiedMatch above, the stall watchdog that sweeps for a
|
|
230
|
+
// parked turn — reads the vendor's ghost text as work in progress and waits out its window on a
|
|
231
|
+
// worker that is idle. Suppressed at the SOURCE instead: the two builders that seed a real terminal
|
|
232
|
+
// are exactly these (interactive, resume); the -p builder has no editor to paint into and is out of
|
|
233
|
+
// scope. Live-checked against claude 2.1.246: the flag parses and `false` is an allowed choice
|
|
234
|
+
// ("Allowed choices are true, false, 1, 0, yes, no, on, off").
|
|
235
|
+
// Placement is NOT free: --mcp-config above is variadic and eats the next positional (2026-07-10
|
|
236
|
+
// live check ate the prompt), and --prompt-suggestions takes an OPTIONAL value — appended directly
|
|
237
|
+
// before the prompt it would swallow it the same way. So the setting's value is always followed by
|
|
238
|
+
// another flag, never by the prompt positional.
|
|
239
|
+
interactiveCommand: (promptFile, model) => `claude --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --prompt-suggestions false --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
|
|
227
240
|
trustDialog: CLAUDE_TRUST_DIALOG,
|
|
228
241
|
inputBox: CLAUDE_INPUT_BOX,
|
|
229
|
-
|
|
242
|
+
// A resumed attempt lands in the same painted editor, so it carries the same ghost-text suppression
|
|
243
|
+
// and the same value-then-flag placement.
|
|
244
|
+
resumeCommand: (sessionId, promptFile, model) => `claude -r ${shq(sessionId)} --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --prompt-suggestions false --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
|
|
230
245
|
invoke(task, _cwd, a, ctx) {
|
|
231
246
|
return { command: this.headlessCommand(ctx.promptFile, a.model) };
|
|
232
247
|
},
|
package/dist/adapters/pi.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
import { type WorkerAdapter } from "./types.js";
|
|
2
2
|
export declare function parsePiModels(raw: string): string[];
|
|
3
|
+
export interface ServedModelDrift {
|
|
4
|
+
pinned: string;
|
|
5
|
+
served: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function readPiServedModels(): ServedModelDrift[];
|
|
8
|
+
export declare function servedModelNote(drifts?: ServedModelDrift[]): string;
|
|
3
9
|
export declare const pi: WorkerAdapter;
|
package/dist/adapters/pi.js
CHANGED
|
@@ -32,6 +32,92 @@ export function parsePiModels(raw) {
|
|
|
32
32
|
.map(([provider, model]) => `${provider}/${model}`)
|
|
33
33
|
.filter((id) => MODEL_ID_RE.test(id));
|
|
34
34
|
}
|
|
35
|
+
// Newest-first by mtime, capped — mtime picks WHICH files to scan, never a record's cursor.
|
|
36
|
+
// Shared by collectUsage (spend) and readPiServedModels (v2.1.3 T7): same store, same bounds.
|
|
37
|
+
function sessionFileStats(dir) {
|
|
38
|
+
return readdirSync(dir)
|
|
39
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
40
|
+
.map((f) => {
|
|
41
|
+
const path = join(dir, f);
|
|
42
|
+
try {
|
|
43
|
+
return { f: path, m: statSync(path).mtimeMs };
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined; // a stat failure just drops that file
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
.filter((x) => x !== undefined);
|
|
50
|
+
}
|
|
51
|
+
function newestSessionFiles(entries) {
|
|
52
|
+
return [...entries].sort((a, b) => b.m - a.m).slice(0, MAX_SESSION_FILES).map((x) => x.f);
|
|
53
|
+
}
|
|
54
|
+
// ponytail: three distinct pairs is the note's ceiling — raise it if a real store ever shows more.
|
|
55
|
+
const MAX_DRIFT_PAIRS = 3;
|
|
56
|
+
export function readPiServedModels() {
|
|
57
|
+
const root = join(homedir(), ".pi", "agent", "sessions");
|
|
58
|
+
let files;
|
|
59
|
+
try {
|
|
60
|
+
files = newestSessionFiles(readdirSync(root).flatMap((slug) => {
|
|
61
|
+
try {
|
|
62
|
+
return sessionFileStats(join(root, slug));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return []; // not a directory / unreadable ⇒ skip
|
|
66
|
+
}
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return []; // no store ⇒ nothing to report
|
|
71
|
+
}
|
|
72
|
+
const out = [];
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
let text;
|
|
76
|
+
try {
|
|
77
|
+
text = readFileSync(file, "utf8").slice(0, MAX_SESSION_BYTES);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
for (const line of text.split("\n")) {
|
|
83
|
+
if (!line.trim())
|
|
84
|
+
continue;
|
|
85
|
+
let recRaw;
|
|
86
|
+
try {
|
|
87
|
+
recRaw = JSON.parse(line);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (recRaw === null || typeof recRaw !== "object" || Array.isArray(recRaw))
|
|
93
|
+
continue;
|
|
94
|
+
const rec = recRaw;
|
|
95
|
+
if (rec.type !== "message" || rec.message?.role !== "assistant")
|
|
96
|
+
continue;
|
|
97
|
+
const pinned = rec.message?.model;
|
|
98
|
+
const served = rec.message?.responseModel;
|
|
99
|
+
// absent responseModel === the two agree. Never a mismatch, never an alarm.
|
|
100
|
+
if (typeof served !== "string" || typeof pinned !== "string" || served === pinned)
|
|
101
|
+
continue;
|
|
102
|
+
if (!MODEL_ID_RE.test(served) || !MODEL_ID_RE.test(pinned))
|
|
103
|
+
continue;
|
|
104
|
+
const key = `${pinned}\u0000${served}`;
|
|
105
|
+
if (seen.has(key))
|
|
106
|
+
continue;
|
|
107
|
+
seen.add(key);
|
|
108
|
+
out.push({ pinned, served });
|
|
109
|
+
if (out.length >= MAX_DRIFT_PAIRS)
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
// The probe's note clause, or "" when the store reports no discrepancy at all.
|
|
116
|
+
export function servedModelNote(drifts = readPiServedModels()) {
|
|
117
|
+
if (drifts.length === 0)
|
|
118
|
+
return "";
|
|
119
|
+
return `served-model drift: ${drifts.map((d) => `pinned ${d.pinned} served ${d.served}`).join(", ")}`;
|
|
120
|
+
}
|
|
35
121
|
export const pi = {
|
|
36
122
|
id: "pi",
|
|
37
123
|
// FLEET-04: cross-vendor review honesty — GLM's provider (pi's own label is "zai"; either is
|
|
@@ -42,10 +128,17 @@ export const pi = {
|
|
|
42
128
|
const h = probeVersion("pi");
|
|
43
129
|
if (!h.installed)
|
|
44
130
|
return h;
|
|
131
|
+
// The store is local and independently useful: preserve its advisory even when the network/auth
|
|
132
|
+
// capability call below fails. A failed model listing must not erase evidence of a substitution.
|
|
133
|
+
const drift = servedModelNote();
|
|
45
134
|
const r = spawnSync("pi", ["--list-models"], { encoding: "utf8", timeout: 15000 });
|
|
46
|
-
if (r.error || r.status !== 0)
|
|
47
|
-
return h;
|
|
48
|
-
|
|
135
|
+
if (r.error || r.status !== 0) {
|
|
136
|
+
return drift ? { ...h, note: `${h.note ? `${h.note}; ` : ""}${drift}` } : h;
|
|
137
|
+
}
|
|
138
|
+
// v2.1.3 T7: the probe is the production caller that already reads this store, and doctor/fleet
|
|
139
|
+
// already render its note — so the served-model discrepancy is reported where it is already read.
|
|
140
|
+
const note = `auth verified via pi --list-models (free; auth-filtered by pi)${drift ? `; ${drift}` : ""}`;
|
|
141
|
+
return { ...h, servable: parsePiModels(r.stdout || ""), note };
|
|
49
142
|
},
|
|
50
143
|
channels: (cfg) => channelsFromConfig("pi", cfg),
|
|
51
144
|
// v1.65 T3: every flag the command builders below hardcode — verified in `pi --help` 2026-07-22.
|
|
@@ -84,25 +177,13 @@ export const pi = {
|
|
|
84
177
|
const real = realpathSync(cwd);
|
|
85
178
|
const slug = "-" + real.replaceAll("/", "-") + "--";
|
|
86
179
|
const dir = join(homedir(), ".pi", "agent", "sessions", slug);
|
|
87
|
-
const files =
|
|
88
|
-
.filter((f) => f.endsWith(".jsonl"))
|
|
89
|
-
.map((f) => {
|
|
90
|
-
try {
|
|
91
|
-
return { f, m: statSync(join(dir, f)).mtimeMs };
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
return undefined;
|
|
95
|
-
}
|
|
96
|
-
})
|
|
97
|
-
.filter((x) => x !== undefined)
|
|
98
|
-
.sort((a, b) => b.m - a.m)
|
|
99
|
-
.slice(0, MAX_SESSION_FILES);
|
|
180
|
+
const files = newestSessionFiles(sessionFileStats(dir));
|
|
100
181
|
let input = 0, output = 0, kept = false;
|
|
101
182
|
let cacheRead, cacheWrite;
|
|
102
|
-
for (const
|
|
183
|
+
for (const f of files) {
|
|
103
184
|
let text;
|
|
104
185
|
try {
|
|
105
|
-
text = readFileSync(
|
|
186
|
+
text = readFileSync(f, "utf8").slice(0, MAX_SESSION_BYTES);
|
|
106
187
|
}
|
|
107
188
|
catch {
|
|
108
189
|
continue;
|
|
@@ -20,31 +20,51 @@ import { SUPERVISION_BEAT_MS, SUPERVISION_STALE_MS, SUPERVISION_TIERS, beatSuper
|
|
|
20
20
|
// evidence: stop calling it and the tier ages into STALE on its own.
|
|
21
21
|
export async function beat(argv, cwd = process.cwd()) {
|
|
22
22
|
const standDown = argv.includes("--stand-down");
|
|
23
|
-
const
|
|
23
|
+
const seat = seatOf(argv);
|
|
24
|
+
const named = argv.find((a, i) => !a.startsWith("--") && argv[i - 1] !== "--seat");
|
|
24
25
|
if (!isTier(named)) {
|
|
25
|
-
throw new Error(`usage: tickmarkr beat <${SUPERVISION_TIERS.join("|")}> [--stand-down] — got ${named ? `\`${named}\`` : "no tier"}`);
|
|
26
|
+
throw new Error(`usage: tickmarkr beat <${SUPERVISION_TIERS.join("|")}> --seat <identity> [--stand-down] — got ${named ? `\`${named}\`` : "no tier"}`);
|
|
27
|
+
}
|
|
28
|
+
// SUP-05: NO SEAT, NO BEAT — and the refusal comes before any write, so a refused invocation leaves
|
|
29
|
+
// the tier exactly as it found it. This verb is one-shot: the pid it would otherwise record has
|
|
30
|
+
// already exited by the time anyone reads the record, so tier + pid + instant is a beat nobody can
|
|
31
|
+
// attribute to a seat. Measured 2026-08-26: a consult seat ran the documented loop verbatim and the
|
|
32
|
+
// board read that tier ARMED with no seat of that tier having armed anything, and a seatless ARMED
|
|
33
|
+
// reads as coverage — worse than ABSENT, because ABSENT sends someone to look.
|
|
34
|
+
if (!seat) {
|
|
35
|
+
throw new Error(`${named} needs --seat <identity> — a beat that names no seat arms a tier nobody occupies` +
|
|
36
|
+
" (pass the seat's own pane id or agent name)");
|
|
26
37
|
}
|
|
27
38
|
if (standDown)
|
|
28
|
-
return standDownTier(cwd, named);
|
|
39
|
+
return standDownTier(cwd, named, seat);
|
|
29
40
|
// Clear a stand-down marker left by an earlier session BEFORE beating: a valid marker outranks every
|
|
30
41
|
// beat that does not strictly follow it, and two writes landing in the same millisecond do not. An
|
|
31
42
|
// uncleared marker would render DISARMED while this verb claimed ARMED, so the removal is unguarded
|
|
32
43
|
// too — `force` makes the ordinary "no marker" case a no-op, and anything else is a real failure.
|
|
33
44
|
rmSync(supervisionStandDownPath(cwd, named), { force: true, recursive: true });
|
|
34
|
-
beatSupervision(cwd, named);
|
|
35
|
-
return `${named} ARMED — beat again every ${SUPERVISION_BEAT_MS / 1_000}s; the tier reads STALE ${SUPERVISION_STALE_MS / 1_000}s after the last beat`;
|
|
45
|
+
beatSupervision(cwd, named, seat);
|
|
46
|
+
return `${named} ARMED as ${seat} — beat again every ${SUPERVISION_BEAT_MS / 1_000}s; the tier reads STALE ${SUPERVISION_STALE_MS / 1_000}s after the last beat`;
|
|
47
|
+
}
|
|
48
|
+
/** `--seat <identity>` or `--seat=<identity>`; blank and missing are the same answer — none. */
|
|
49
|
+
function seatOf(argv) {
|
|
50
|
+
const inline = argv.find((a) => a.startsWith("--seat="))?.slice("--seat=".length);
|
|
51
|
+
const spaced = argv[argv.indexOf("--seat") + 1];
|
|
52
|
+
const seat = (inline ?? (argv.includes("--seat") ? spaced : undefined))?.trim();
|
|
53
|
+
return seat && !seat.startsWith("--") ? seat : undefined;
|
|
36
54
|
}
|
|
37
55
|
// Stand-down is a RECORDED act, not a silence: the marker tells a reader this watcher left on purpose,
|
|
38
56
|
// so the tier reads DISARMED rather than ageing out as a death. Published atomically — written aside,
|
|
39
57
|
// renamed over — because a torn marker is rejected by the reader, and a rejected stand-down reports a
|
|
40
58
|
// deliberate hand-off as a death.
|
|
41
|
-
function standDownTier(repoRoot, tier) {
|
|
59
|
+
function standDownTier(repoRoot, tier, seat) {
|
|
42
60
|
tickmarkrDir(repoRoot); // the write path DOES create — markers land inside the gitignored state dir
|
|
43
61
|
const p = supervisionStandDownPath(repoRoot, tier);
|
|
44
62
|
const tmp = `${p}.${process.pid}.tmp`;
|
|
45
63
|
mkdirSync(dirname(p), { recursive: true });
|
|
46
|
-
|
|
64
|
+
// The marker names the seat for the same reason the beat does: "someone stood this tier down" is not
|
|
65
|
+
// a hand-off anyone can act on, and on a seat tier the reader rejects an anonymous one outright.
|
|
66
|
+
writeFileSync(tmp, JSON.stringify({ tier, seat, pid: process.pid, disarmedAt: new Date().toISOString() }) + "\n");
|
|
47
67
|
renameSync(tmp, p);
|
|
48
|
-
return `${tier} DISARMED —
|
|
68
|
+
return `${tier} DISARMED — ${seat} handed off; status reads it stood down, not dead`;
|
|
49
69
|
}
|
|
50
70
|
const isTier = (v) => SUPERVISION_TIERS.includes(v ?? "");
|
|
@@ -109,7 +109,7 @@ Before \`tickmarkr compile\` or \`tickmarkr run\`: run \`tickmarkr version\`, re
|
|
|
109
109
|
|
|
110
110
|
### Tip-verify-before-green
|
|
111
111
|
|
|
112
|
-
A run is green only when the run-end event exists in the journal
|
|
112
|
+
A run is green only when ALL of these hold: the run-end event exists in the journal, tip verify is not "failed", AND the run-end summary's \`failed\`, \`human\`, \`blocked\` and \`pending\` buckets are every one of them empty. The shorter "run-end plus tip verify" form is NOT sufficient: a run ending \`done=[T1,T3,T4] human=[T2] tipVerify=passed\` is a closed PARTIAL run — three of four delivered, one parked — and calling it green is how a park becomes invisible. Never report green to the operator, tab titles, or records until every clause holds; when one does not, name the bucket that is not empty.
|
|
113
113
|
|
|
114
114
|
### Verified handoffs
|
|
115
115
|
|
|
@@ -203,6 +203,24 @@ function skillDrift(cwd) {
|
|
|
203
203
|
}
|
|
204
204
|
// Name what drifted. "stale" alone sends the reader to diff three directories by hand, and the file that
|
|
205
205
|
// matters is usually the one they never knew shipped.
|
|
206
|
+
// The GENERATED GUIDANCE BLOCK goes stale by exactly the shape skillDrift exists for: the package
|
|
207
|
+
// moves, the rendered copy does not, and nothing in the repo changes to hint at it. Worse than a skill,
|
|
208
|
+
// because a doc the host reads as law can be gitignored — this project's own AGENTS.md kept two
|
|
209
|
+
// superseded preflight rules (patch-version drift tolerated, a machine-wide process match required)
|
|
210
|
+
// through the very release that fixed the tracked copy, and a consultation was launched under the stale
|
|
211
|
+
// text with nobody able to see it. Same rule as skillDrift: a doc that was never written is not drift.
|
|
212
|
+
const docsDrift = (cwd) => hostTargets(cwd).map(({ docPath }) => docPath).filter((docPath) => {
|
|
213
|
+
if (!existsSync(docPath))
|
|
214
|
+
return false;
|
|
215
|
+
const current = readFileSync(docPath, "utf8");
|
|
216
|
+
const begin = current.indexOf(DOCS_BEGIN);
|
|
217
|
+
if (begin < 0)
|
|
218
|
+
return false; // no block installed here — --docs writes one, that is not drift
|
|
219
|
+
const end = current.indexOf(DOCS_END, begin);
|
|
220
|
+
if (end < 0)
|
|
221
|
+
return true; // a truncated block is stale by definition
|
|
222
|
+
return current.slice(begin, end + DOCS_END.length) !== AGENT_DOCS.trimEnd();
|
|
223
|
+
});
|
|
206
224
|
const describeDrift = (d) => [
|
|
207
225
|
d.missing.length ? `missing ${d.missing.join(", ")}` : "",
|
|
208
226
|
d.modified.length ? `modified ${d.modified.join(", ")}` : "",
|
|
@@ -361,6 +379,15 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
|
|
|
361
379
|
notes.push(`stale ${join(d.dir, "SKILL.md")} — ${describeDrift(d)}; run tickmarkr init --agent --force to refresh it`);
|
|
362
380
|
}
|
|
363
381
|
}
|
|
382
|
+
// Same reasoning, one layer up — and the refresh command is `--agent --force`, the same one the skill
|
|
383
|
+
// note names. `--docs` governs only the APPEND path for a repo with no block yet; replacing an existing
|
|
384
|
+
// block is gated on force (see the marked-region branch in installAgentFiles). Naming `--docs` here
|
|
385
|
+
// would send the reader to a flag that silently does nothing for the case they are in.
|
|
386
|
+
if (!(values.agent && values.force)) {
|
|
387
|
+
for (const docPath of docsDrift(cwd)) {
|
|
388
|
+
notes.push(`stale ${docPath} — its tickmarkr guidance block does not match this version; run tickmarkr init --agent --force to refresh it`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
364
391
|
const input = io.input ?? process.stdin;
|
|
365
392
|
const output = io.output ?? process.stdout;
|
|
366
393
|
const interactive = input.isTTY === true && output.isTTY === true && !(values.yes ?? false);
|
|
@@ -8,6 +8,9 @@ export type StatusOpts = {
|
|
|
8
8
|
readWorkerOutput?: (taskId: string, attempt: number, runId: string) => Promise<string | undefined>;
|
|
9
9
|
webhookUrl?: string;
|
|
10
10
|
postWebhook?: DecisionWebhookPost;
|
|
11
|
+
/** How often the live board rewrites its own `watch` beat. Fixtures choose it so a death drill can
|
|
12
|
+
* watch the record STOP inside a test's patience; unset means SUPERVISION_BEAT_MS. */
|
|
13
|
+
supervisionBeatMs?: number;
|
|
11
14
|
};
|
|
12
15
|
export declare const GATE_KEYS: {
|
|
13
16
|
readonly build: "B";
|
|
@@ -8,10 +8,11 @@ import { blockedTasks, graphDefinitionHash, loadGraph, stateDirName } from "../.
|
|
|
8
8
|
import { GATE_NAMES } from "../../graph/schema.js";
|
|
9
9
|
import { foldActivity } from "../../run/activity.js";
|
|
10
10
|
import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFailureKind, runHasEnded, } from "../../run/journal.js";
|
|
11
|
+
import { isPidLive } from "../../run/lock.js";
|
|
11
12
|
import { normalizeGateOutcome } from "../../run/outcome.js";
|
|
12
13
|
import { desiredPanes } from "../../run/reconcile.js";
|
|
13
14
|
import { normalizeStallSnapshot } from "../../run/stall.js";
|
|
14
|
-
import { readSupervision, supervisionText } from "../../run/supervision.js";
|
|
15
|
+
import { armSupervision, readSupervision, supervisionText } from "../../run/supervision.js";
|
|
15
16
|
import { deriveRunCockpitData, } from "../../tui/cockpit/derive.js";
|
|
16
17
|
import { COCKPIT_COLUMN_FLOOR } from "../../tui/cockpit/layout.js";
|
|
17
18
|
import { cellWidth, fitCells, wrapCells } from "../../tui/cockpit/width.js";
|
|
@@ -617,6 +618,12 @@ const daemonPid = (events) => {
|
|
|
617
618
|
}
|
|
618
619
|
return undefined;
|
|
619
620
|
};
|
|
621
|
+
const daemonLiveness = (events) => {
|
|
622
|
+
const pid = daemonPid(events);
|
|
623
|
+
if (pid === undefined)
|
|
624
|
+
return { state: "unknown" };
|
|
625
|
+
return { state: isPidLive(pid) ? "alive" : "dead", pid };
|
|
626
|
+
};
|
|
620
627
|
const terminalFailureCause = (events) => {
|
|
621
628
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
622
629
|
const e = events[i];
|
|
@@ -792,27 +799,19 @@ const keepPanesSetting = (cwd) => {
|
|
|
792
799
|
return DEFAULT_CONFIG.visibility.keepPanes;
|
|
793
800
|
}
|
|
794
801
|
};
|
|
795
|
-
const liveness = (events, now = Date.now()) => {
|
|
802
|
+
const liveness = (events, daemon, now = Date.now()) => {
|
|
796
803
|
const last = events.at(-1);
|
|
797
804
|
if (!last)
|
|
798
805
|
return "last event unknown · daemon pid unknown";
|
|
799
806
|
const age = fmtAge(now - Date.parse(last.ts));
|
|
800
|
-
const pid = daemonPid(events);
|
|
801
807
|
const cause = terminalFailureCause(events);
|
|
802
|
-
if (
|
|
808
|
+
if (daemon.state === "unknown")
|
|
803
809
|
return `last event ${age} ago · daemon pid unknown${cause ? ` · ${cause}` : ""}`;
|
|
804
810
|
// a dead pid after run-end is a clean exit, not a crash — "dead" is only alarming (red) while
|
|
805
811
|
// the run is still incomplete (operator's crash indicator)
|
|
806
812
|
const ended = events.some((e) => e.event === "run-end");
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
process.kill(pid, 0);
|
|
810
|
-
state = "alive";
|
|
811
|
-
} // no throw ⇒ alive
|
|
812
|
-
catch (k) {
|
|
813
|
-
state = k.code === "ESRCH" ? (ended ? "finished" : "dead") : "alive";
|
|
814
|
-
} // EPERM ⇒ alive
|
|
815
|
-
return `last event ${age} ago · daemon pid ${pid} ${state}${cause ? ` · ${cause}` : ""}`;
|
|
813
|
+
const state = daemon.state === "alive" ? "alive" : ended ? "finished" : "dead";
|
|
814
|
+
return `last event ${age} ago · daemon pid ${daemon.pid} ${state}${cause ? ` · ${cause}` : ""}`;
|
|
816
815
|
};
|
|
817
816
|
// Resume's shared comparator remains the fail-closed baseline. Status may additionally accept the
|
|
818
817
|
// daemon's audited graph-rehash release, but only when its `from` names that baseline identity (or
|
|
@@ -899,6 +898,9 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
899
898
|
const record = readRunRecord(cwd, g, namedRunId);
|
|
900
899
|
const runId = record?.runId;
|
|
901
900
|
const events = record?.events ?? [];
|
|
901
|
+
// The header and every live-phase consumer share this exact process reading. Proved death retires
|
|
902
|
+
// stale phase markers; unknown identity deliberately does not, preserving pre-identity journals.
|
|
903
|
+
const daemon = daemonLiveness(events);
|
|
902
904
|
const comparable = record?.comparable ?? false;
|
|
903
905
|
const rehashAt = record?.rehashAt;
|
|
904
906
|
const assignments = new Map();
|
|
@@ -972,7 +974,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
972
974
|
? workerPaneLocators(events, runId, keepPanesSetting(cwd))
|
|
973
975
|
: new Map();
|
|
974
976
|
const taskIds = new Set(g.tasks.map((task) => task.id));
|
|
975
|
-
const phases = comparable ? livePhases(events) : new Map();
|
|
977
|
+
const phases = comparable && daemon.state !== "dead" ? livePhases(events) : new Map();
|
|
976
978
|
for (const taskId of phases.keys())
|
|
977
979
|
if (!taskIds.has(taskId))
|
|
978
980
|
phases.delete(taskId);
|
|
@@ -1022,7 +1024,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1022
1024
|
});
|
|
1023
1025
|
const zone = journalRowsOnly ? `${divider}zone ${localZoneLabel(zoneReference)}` : "";
|
|
1024
1026
|
const header = runId
|
|
1025
|
-
? `tickmarkr status${divider}run ${runId}${zone}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events, now).replaceAll(" · ", divider)}${verify ? `${divider}${verify}` : ""}${divider}${tipPhase ? `${done}/${total} tasks done${divider}run not verified` : `${done}/${total} done`}`
|
|
1027
|
+
? `tickmarkr status${divider}run ${runId}${zone}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events, daemon, now).replaceAll(" · ", divider)}${verify ? `${divider}${verify}` : ""}${divider}${tipPhase ? `${done}/${total} tasks done${divider}run not verified` : `${done}/${total} done`}`
|
|
1026
1028
|
: `tickmarkr status${zone}${divider}no runs yet${divider}${done}/${total} done`;
|
|
1027
1029
|
const legendLine = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
|
|
1028
1030
|
return {
|
|
@@ -1046,7 +1048,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
|
|
|
1046
1048
|
const tally = tipPhase
|
|
1047
1049
|
? `${progressTone(`${progressGlyph} ${done}/${total} tasks done`)}${dot}${progressTone("run not verified")}`
|
|
1048
1050
|
: done === total && total > 0 ? ok(`${done}/${total} done`) : information(`${done}/${total} done`);
|
|
1049
|
-
const live = liveness(events, now)
|
|
1051
|
+
const live = liveness(events, daemon, now)
|
|
1050
1052
|
.replace(/\bdead\b/u, fail("dead"))
|
|
1051
1053
|
.replace(/\bfinished\b/u, dim("finished"))
|
|
1052
1054
|
.replace(/\balive\b/u, running("alive"))
|
|
@@ -1368,6 +1370,20 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
|
|
|
1368
1370
|
}
|
|
1369
1371
|
return fresh;
|
|
1370
1372
|
};
|
|
1373
|
+
// SUP-06: the live board IS the `watch` tier, so it is the only process that can beat it — which is
|
|
1374
|
+
// why that tier could only ever print ABSENT about itself while every other seat had a beater. A word
|
|
1375
|
+
// one tier can only ever say in one of its five states trains every reader to skip it.
|
|
1376
|
+
//
|
|
1377
|
+
// Armed ONLY when UNBOUNDED. A bounded render is a READER, and the purity fence (D-02: a bounded
|
|
1378
|
+
// --watch leaves .tickmarkr/ byte-identical) is exactly the test that catches a reader beating on a
|
|
1379
|
+
// watcher's behalf, which would report every dead tier healthy. Armed BEFORE the first frame, so the
|
|
1380
|
+
// board's own first print already names its tier armed rather than one interval later.
|
|
1381
|
+
//
|
|
1382
|
+
// The handle is HELD and stood down in the finally. Discarding it is not a smaller version of this —
|
|
1383
|
+
// it INVERTS it: armSupervision's interval outlives the board in any host that outlives one board, so
|
|
1384
|
+
// a dead board keeps writing and reads ARMED. That is the over-claiming direction, the one an operator
|
|
1385
|
+
// acts on, and the one this instrument exists to close.
|
|
1386
|
+
const armed = bounded ? undefined : armSupervision(cwd, "watch", opts.supervisionBeatMs);
|
|
1371
1387
|
let titleSaved = false;
|
|
1372
1388
|
const restoreTitle = () => {
|
|
1373
1389
|
if (!titleSaved)
|
|
@@ -1425,6 +1441,14 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
|
|
|
1425
1441
|
}
|
|
1426
1442
|
}
|
|
1427
1443
|
finally {
|
|
1444
|
+
// Stops the interval as well as recording the hand-off: a board that returns must not leave a
|
|
1445
|
+
// timer beating for it. A board that is KILLED never reaches here, its beat ages out, and the
|
|
1446
|
+
// tier reads STALE — armed-then-lost, which is the truth about a killed board.
|
|
1447
|
+
// Boards OVERLAP — a second pane is one keystroke away — and the stand-down marker speaks for the
|
|
1448
|
+
// whole tier, so this hand-off is recorded only when no other board is still present (see
|
|
1449
|
+
// armSupervision's presence files). Otherwise the first pane closed would render the second
|
|
1450
|
+
// pane's own tier down while it is drawing frames.
|
|
1451
|
+
armed?.disarm();
|
|
1428
1452
|
if (titleSaved) {
|
|
1429
1453
|
process.removeListener("exit", restoreTitle);
|
|
1430
1454
|
restoreTitle();
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export type CommandResult = string | {
|
|
|
5
5
|
};
|
|
6
6
|
export type CommandMap = Record<string, (argv: string[]) => Promise<CommandResult>>;
|
|
7
7
|
export declare const COMMANDS: CommandMap;
|
|
8
|
-
export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval run checked-in fixtures against every channel in isolated temp repos\n run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)\n status live run state\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> record one supervision beat for orchestrator|overseer|watch (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume";
|
|
8
|
+
export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval run checked-in fixtures against every channel in isolated temp repos\n run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)\n status live run state\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> record one supervision beat for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume";
|
|
9
9
|
export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
|
|
10
10
|
out: string;
|
|
11
11
|
code: number;
|
package/dist/cli/index.js
CHANGED
|
@@ -43,7 +43,7 @@ usage: tickmarkr <command>
|
|
|
43
43
|
profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)
|
|
44
44
|
ui open the Fleet Studio TUI (full-screen tabbed cockpit)
|
|
45
45
|
unlock remove a stale/garbage run lock (refuses if the holder is alive)
|
|
46
|
-
beat <tier> record one supervision beat for orchestrator|overseer|watch (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop
|
|
46
|
+
beat <tier> record one supervision beat for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop
|
|
47
47
|
approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume`;
|
|
48
48
|
// pure, testable dispatcher: resolves a command, forwards argv, shapes the result — no side effects.
|
|
49
49
|
// unknown/missing cmd → USAGE (exit 1 if a cmd was typed, 0 for bare `tickmarkr`); a handler throw becomes
|
package/dist/compile/gsd.js
CHANGED
|
@@ -106,6 +106,22 @@ function extractWriteDirectives(body, storedPath) {
|
|
|
106
106
|
return writes;
|
|
107
107
|
}
|
|
108
108
|
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
109
|
+
function summaryCompletionStatus(planFile) {
|
|
110
|
+
const summaryFile = join(dirname(planFile), `${basename(planFile).replace(/-PLAN\.md$/, "")}-SUMMARY.md`);
|
|
111
|
+
if (!existsSync(summaryFile))
|
|
112
|
+
return undefined;
|
|
113
|
+
try {
|
|
114
|
+
const content = readFileSync(summaryFile, "utf8");
|
|
115
|
+
const fmMatch = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
|
|
116
|
+
if (!fmMatch)
|
|
117
|
+
return undefined;
|
|
118
|
+
const fm = parseYaml(fmMatch[1]);
|
|
119
|
+
return isPlainObject(fm) ? fm.status : undefined;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
109
125
|
// schema-legal, branch-safe id segment: dots etc. → dashes; no "--" runs (task-branch separator)
|
|
110
126
|
// or trailing dash — the id schema rejects both
|
|
111
127
|
const sanitize = (s) => s
|
|
@@ -170,7 +186,11 @@ function compileOne(file, storedPath) {
|
|
|
170
186
|
.filter((p) => !p.includes("$") && !p.startsWith("~") && !p.startsWith("/") && !p.split("/").includes(".."));
|
|
171
187
|
const taskCount = [...body.matchAll(/<task[\s>]/g)].length;
|
|
172
188
|
const humanGate = fm.autonomous === false || /<task type="checkpoint:/.test(body);
|
|
173
|
-
const
|
|
189
|
+
const summaryStatus = summaryCompletionStatus(file);
|
|
190
|
+
// GSD source rule (`/gsd:quick status`): a SUMMARY is complete only when its frontmatter says
|
|
191
|
+
// `status: complete`; when `status` is absent it reports `INCOMPLETE`. The compiler must preserve
|
|
192
|
+
// that distinction because an artifact can record a reviewed failure rather than completion.
|
|
193
|
+
const done = summaryStatus === "complete";
|
|
174
194
|
const files = strings(fm.files_modified).map((f) => f.replace(/^\.\//, ""));
|
|
175
195
|
assertWriteScope(file, `P${key}`, files, extractWriteDirectives(body, storedPath));
|
|
176
196
|
// fail closed (D-03): a silently dropped floor/pin routes the task cheap instead of erroring
|
package/dist/gates/baseline.d.ts
CHANGED
|
@@ -12,6 +12,12 @@ export interface BaselineCommand {
|
|
|
12
12
|
missingCommand?: boolean;
|
|
13
13
|
/** What this command actually took at capture, on a pristine tree. Absent in pre-v1.90 baselines. */
|
|
14
14
|
durationMs?: number;
|
|
15
|
+
/** Sum of the per-file durations named by the runner; null when its output names none. */
|
|
16
|
+
fileDurationSumMs?: number | null;
|
|
17
|
+
/** fileDurationSumMs / durationMs — average implied file concurrency, not a configured fork count. */
|
|
18
|
+
impliedParallelism?: number | null;
|
|
19
|
+
/** The slowest per-file entry named by the runner; null when per-file timing is unavailable. */
|
|
20
|
+
longestFile?: BaselineFileDuration | null;
|
|
15
21
|
/** The ceiling that measurement implies, persisted so every later battery uses the same number. */
|
|
16
22
|
ceilingMs?: number;
|
|
17
23
|
/**
|
|
@@ -20,6 +26,10 @@ export interface BaselineCommand {
|
|
|
20
26
|
*/
|
|
21
27
|
infra?: true;
|
|
22
28
|
}
|
|
29
|
+
export interface BaselineFileDuration {
|
|
30
|
+
file: string;
|
|
31
|
+
durationMs: number;
|
|
32
|
+
}
|
|
23
33
|
export interface Baseline {
|
|
24
34
|
commands: Record<string, BaselineCommand>;
|
|
25
35
|
warnings?: BaselineWarning[];
|
package/dist/gates/baseline.js
CHANGED
|
@@ -24,6 +24,12 @@ const SUMMARY_FAIL_RE = /^\s*(?:Tests?\s+(?:Files?\s+)?(?:\d+|#)\s+failed|#\s+fa
|
|
|
24
24
|
const ERROR_ANCHOR_RE = /^\s*(?:Error|[A-Za-z_$][\w$]*Error):\s+\S/;
|
|
25
25
|
const TSC_ERROR_RE = /^\s*\S.*\((?:\d+|#),(?:\d+|#)\):\s+error\s+[A-Z]+(?:\d+|#):/i; // tsc
|
|
26
26
|
const LINTER_ERROR_RE = /^\s*(?:\d+|#):(?:\d+|#)\s+error\s+\S/; // eslint stylish
|
|
27
|
+
// T5: package.json selecting oxlint's stylish format is this repository's local remedy because the
|
|
28
|
+
// existing LINTER_ERROR_RE already reads it. PATH_FIRST_LINTER_ERROR_RE is the shipped fix: it reads
|
|
29
|
+
// the default `path:line:column: error …` form for every user without requiring their script to change.
|
|
30
|
+
// Keep the path at line start — accepting a position mid-sentence turns ordinary runner prose into a
|
|
31
|
+
// manufactured fresh failure.
|
|
32
|
+
const PATH_FIRST_LINTER_ERROR_RE = /^\s*\S+:(?:\d+|#):(?:\d+|#):\s+error\b(?:\s+\S)?/;
|
|
27
33
|
// The failure shapes non-Vitest runners name their tests with: pytest's short summary
|
|
28
34
|
// (`FAILED tests/t.py::test_x - AssertionError`, `ERROR tests/t.py::fixture`), go test
|
|
29
35
|
// (`--- FAIL: TestFoo (0.00s)`), TAP / node:test (`not ok 1 - name`, whose summary line lands in
|
|
@@ -97,7 +103,7 @@ const namesFailureEitherForm = (l) => {
|
|
|
97
103
|
return stripped !== undefined && namesFailure(stripped);
|
|
98
104
|
};
|
|
99
105
|
const isFailureShaped = (l) => namesFailure(l) || SUMMARY_FAIL_RE.test(l) || ERROR_ANCHOR_RE.test(l)
|
|
100
|
-
|| TSC_ERROR_RE.test(l) || LINTER_ERROR_RE.test(l) || X_GLYPH_FAIL_RE.test(l);
|
|
106
|
+
|| TSC_ERROR_RE.test(l) || LINTER_ERROR_RE.test(l) || PATH_FIRST_LINTER_ERROR_RE.test(l) || X_GLYPH_FAIL_RE.test(l);
|
|
101
107
|
const VOCAB_RE = /\b(?:error|fail(?:ed|ure|ing)?)\b/i;
|
|
102
108
|
// T9 — the infra/regression discriminator. A runner that died because the MACHINE ran out of
|
|
103
109
|
// processes, file descriptors or memory never finished asking the question, so its nonzero exit is
|
|
@@ -128,6 +134,33 @@ export function classifyFailureOutput(output) {
|
|
|
128
134
|
return lines.some(isInfraLine) ? "infra" : undefined;
|
|
129
135
|
}
|
|
130
136
|
const normalizeLine = (l) => l.replace(/\d+/g, "#").replace(/\s+/g, " ").trim();
|
|
137
|
+
// Vitest's default reporter names file durations as
|
|
138
|
+
// `✓ |project| tests/example.test.ts (12 tests) 1.23s` (❯ for a red file). The runner may be
|
|
139
|
+
// turbo-prefixed, but a test-name timing has no parenthesized file tally and therefore cannot enter
|
|
140
|
+
// this measurement. These are observations of runner output, not a promise that every runner exposes
|
|
141
|
+
// them — callers record null, never zero, when no line matches.
|
|
142
|
+
const FILE_DURATION_RE = /^\s*(?:(?:[\w@./-]+:\s*)*)[✓✔×❯]\s+(?:\|[^|\r\n]+\|\s+)?(\S+)\s+\([^\r\n)]*\)\s+(\d+(?:\.\d+)?)\s*(ms|s|m)\b/;
|
|
143
|
+
const durationUnitMs = (unit) => unit === "m" ? 60_000 : unit === "s" ? 1_000 : 1;
|
|
144
|
+
function fileTiming(output, wallClockMs) {
|
|
145
|
+
const files = [];
|
|
146
|
+
for (const line of output.split("\n")) {
|
|
147
|
+
const match = FILE_DURATION_RE.exec(line.replace(ANSI_RE, ""));
|
|
148
|
+
if (!match)
|
|
149
|
+
continue;
|
|
150
|
+
const durationMs = Number(match[2]) * durationUnitMs(match[3]);
|
|
151
|
+
if (Number.isFinite(durationMs))
|
|
152
|
+
files.push({ file: match[1], durationMs });
|
|
153
|
+
}
|
|
154
|
+
if (!files.length)
|
|
155
|
+
return { fileDurationSumMs: null, impliedParallelism: null, longestFile: null };
|
|
156
|
+
const fileDurationSumMs = files.reduce((sum, entry) => sum + entry.durationMs, 0);
|
|
157
|
+
const longestFile = files.reduce((longest, entry) => entry.durationMs > longest.durationMs ? entry : longest);
|
|
158
|
+
return {
|
|
159
|
+
fileDurationSumMs,
|
|
160
|
+
impliedParallelism: wallClockMs > 0 ? fileDurationSumMs / wallClockMs : null,
|
|
161
|
+
longestFile,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
131
164
|
// A failing command whose output holds no shape any runner here names. The marker is content-free and
|
|
132
165
|
// constant: downstream consumers (tip verify journals fingerprint counts) still see that the command
|
|
133
166
|
// failed, while no line of the output — narration, status strip, stack trace — can enter the set. Being
|
|
@@ -363,16 +396,26 @@ export async function captureBaseline(cwd, commands) {
|
|
|
363
396
|
console.error(`tickmarkr: baseline capture for "${name}" was killed at its ${CAPTURE_CEILING_MS}ms ceiling — `
|
|
364
397
|
+ `it recorded NO fingerprints, so nothing is forgiven and every gate will treat a pre-existing `
|
|
365
398
|
+ `failure as a fresh one. Raise the ceiling or shorten the command.`);
|
|
366
|
-
base.commands[name] = {
|
|
399
|
+
base.commands[name] = {
|
|
400
|
+
infra: true,
|
|
401
|
+
fingerprints: [],
|
|
402
|
+
durationMs,
|
|
403
|
+
fileDurationSumMs: null,
|
|
404
|
+
impliedParallelism: null,
|
|
405
|
+
longestFile: null,
|
|
406
|
+
ceilingMs: effectiveCeilingMs({ durationMs }),
|
|
407
|
+
};
|
|
367
408
|
continue;
|
|
368
409
|
}
|
|
410
|
+
const raw = (r.stdout + "\n" + r.stderr).split(cwd).join("");
|
|
369
411
|
base.commands[name] = {
|
|
370
412
|
exitCode: r.code,
|
|
371
413
|
// a command that exits 0 has no failures to fingerprint — recording any would be a lie the
|
|
372
414
|
// compare step then has to forgive
|
|
373
|
-
fingerprints: r.code === 0 ? [] : fingerprint(
|
|
415
|
+
fingerprints: r.code === 0 ? [] : fingerprint(raw),
|
|
374
416
|
missingCommand: missingConfiguredCommand(cmd, r),
|
|
375
417
|
durationMs,
|
|
418
|
+
...fileTiming(raw, durationMs),
|
|
376
419
|
ceilingMs: effectiveCeilingMs({ durationMs }),
|
|
377
420
|
};
|
|
378
421
|
}
|
package/dist/run/daemon.d.ts
CHANGED
|
@@ -18,7 +18,6 @@ export interface RunOptions {
|
|
|
18
18
|
mode?: RoutingMode;
|
|
19
19
|
narrate?: (event: JournalEvent) => void;
|
|
20
20
|
exit?: (code: number) => void;
|
|
21
|
-
supervise?: boolean;
|
|
22
21
|
}
|
|
23
22
|
export type ModeSource = "run flag" | "spec" | "repo config" | "global config" | "default";
|
|
24
23
|
export interface ResolvedRunMode {
|