tickmarkr 1.30.0 → 1.33.1
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/README.md +112 -20
- package/dist/adapters/cursor-agent.js +1 -1
- package/dist/adapters/model-lints.js +3 -3
- package/dist/adapters/prompt.js +1 -1
- package/dist/cli/commands/approve.js +4 -4
- package/dist/cli/commands/compile.js +1 -1
- package/dist/cli/commands/doctor.js +44 -2
- package/dist/cli/commands/init.js +79 -8
- package/dist/cli/commands/plan.js +2 -2
- package/dist/cli/commands/profile.js +1 -1
- package/dist/cli/commands/report.js +2 -2
- package/dist/cli/commands/resume.js +1 -1
- package/dist/cli/commands/scope.js +1 -1
- package/dist/cli/commands/status.d.ts +0 -4
- package/dist/cli/commands/status.js +116 -59
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +4 -4
- package/dist/compile/gsd.js +3 -3
- package/dist/compile/native.js +6 -6
- package/dist/compile/speckit.js +1 -1
- package/dist/config/config.js +5 -5
- package/dist/drivers/herdr.d.ts +5 -0
- package/dist/drivers/herdr.js +107 -16
- package/dist/drivers/subprocess.js +2 -2
- package/dist/drivers/types.js +4 -3
- package/dist/gates/baseline.js +6 -2
- package/dist/graph/graph.js +1 -1
- package/dist/plan/prompt.js +2 -2
- package/dist/plan/scope.js +4 -4
- package/dist/run/consult.js +1 -1
- package/dist/run/daemon.js +13 -21
- package/dist/run/git.js +26 -5
- package/dist/run/journal.d.ts +3 -1
- package/dist/run/journal.js +8 -2
- package/dist/run/reconcile.js +1 -0
- package/package.json +4 -4
- package/skills/tickmarkr-auto/SKILL.md +41 -0
- package/skills/tickmarkr-loop/SKILL.md +39 -0
|
@@ -6,6 +6,7 @@ const REFRESH_MS = 2000;
|
|
|
6
6
|
// The timer must keep the process ALIVE: an unref'd timer here let the event loop drain after the
|
|
7
7
|
// first frame, so a live `--watch` printed once and exited 0 (OBS-11). Never unref this.
|
|
8
8
|
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
9
|
+
const GATE_KEYS = { build: "B", test: "T", lint: "L", evidence: "E", scope: "S", acceptance: "A", review: "R" };
|
|
9
10
|
const attemptStartIdx = (events, taskId) => {
|
|
10
11
|
let idx = -1;
|
|
11
12
|
for (let i = 0; i < events.length; i++) {
|
|
@@ -15,39 +16,58 @@ const attemptStartIdx = (events, taskId) => {
|
|
|
15
16
|
}
|
|
16
17
|
return idx;
|
|
17
18
|
};
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return "
|
|
29
|
-
if (!attempt.some((e) => e.event === "worker-result"))
|
|
30
|
-
return channel ? `worker · ${channel}` : "worker";
|
|
31
|
-
const enabled = GATE_NAMES.filter((g) => task.gates.includes(g));
|
|
32
|
-
const n = enabled.length;
|
|
33
|
-
const gateResults = attempt.filter((e) => e.event === "gate-result");
|
|
34
|
-
if (gateResults.length > 0) {
|
|
35
|
-
const last = gateResults[gateResults.length - 1];
|
|
36
|
-
if (!last.data.pass)
|
|
37
|
-
return `gate ✗ ${last.data.gate}`;
|
|
19
|
+
const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
|
20
|
+
const color = (text, code, enabled) => enabled ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
21
|
+
const taskBox = (status, unicode) => {
|
|
22
|
+
if (unicode) {
|
|
23
|
+
if (status === "done")
|
|
24
|
+
return "✓";
|
|
25
|
+
if (status === "failed")
|
|
26
|
+
return "✗";
|
|
27
|
+
if (status === "human")
|
|
28
|
+
return "⏸";
|
|
29
|
+
return "☐";
|
|
38
30
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
31
|
+
if (status === "done")
|
|
32
|
+
return "[x]";
|
|
33
|
+
if (status === "failed" || status === "human")
|
|
34
|
+
return "[!]";
|
|
35
|
+
return "[ ]";
|
|
36
|
+
};
|
|
37
|
+
const gateBox = (state, unicode) => {
|
|
38
|
+
if (unicode)
|
|
39
|
+
return state === "pass" ? "✓" : state === "fail" ? "✗" : state === "skip" ? "·" : "☐";
|
|
40
|
+
return state === "pass" ? "[x]" : state === "fail" ? "[!]" : state === "skip" ? "." : "[ ]";
|
|
41
|
+
};
|
|
42
|
+
const gateStates = (task, events) => {
|
|
43
|
+
const outcomes = new Map();
|
|
44
|
+
const start = attemptStartIdx(events, task.id);
|
|
45
|
+
if (start >= 0) {
|
|
46
|
+
for (const e of events.slice(start)) {
|
|
47
|
+
if (e.taskId !== task.id || e.event !== "gate-result" || typeof e.data.gate !== "string")
|
|
48
|
+
continue;
|
|
49
|
+
if (e.data.skipped === true)
|
|
50
|
+
outcomes.set(e.data.gate, "skip");
|
|
51
|
+
else if (e.data.pass === true)
|
|
52
|
+
outcomes.set(e.data.gate, "pass");
|
|
53
|
+
else if (e.data.pass === false)
|
|
54
|
+
outcomes.set(e.data.gate, "fail");
|
|
55
|
+
}
|
|
49
56
|
}
|
|
50
|
-
return
|
|
57
|
+
return GATE_NAMES.map((gate) => task.gates.includes(gate) ? outcomes.get(gate) ?? "open" : "skip");
|
|
58
|
+
};
|
|
59
|
+
// verdict semantics only: pass green, fail red, skip/open dim chrome — everything else stays quiet
|
|
60
|
+
const GATE_STATE_COLOR = { pass: 32, fail: 31, skip: 2, open: 2 };
|
|
61
|
+
const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => color(`${GATE_KEYS[gate]}${gateBox(states[i], unicode)}`, GATE_STATE_COLOR[states[i]], unicode)).join(" ");
|
|
62
|
+
// plain (uncolored) chip width for column math — ANSI codes have zero display width
|
|
63
|
+
const gateChainWidth = (unicode) => GATE_NAMES.reduce((w, gate) => w + GATE_KEYS[gate].length + (unicode ? 1 : 3) + 1, -1);
|
|
64
|
+
const shortGoal = (goal, max) => {
|
|
65
|
+
const clause = goal.split(/[,;.?!]/, 1)[0].trim();
|
|
66
|
+
if (clause.length <= max)
|
|
67
|
+
return clause;
|
|
68
|
+
if (max <= 3)
|
|
69
|
+
return clause.slice(0, Math.max(0, max));
|
|
70
|
+
return `${clause.slice(0, max - 3).trimEnd()}...`;
|
|
51
71
|
};
|
|
52
72
|
// VIS-11 (v1.13): a liveness header for renderFrame — last journal event age + whether the recorded
|
|
53
73
|
// daemon pid is still alive. Honest about unknowns: a pre-v1.13 journal with no pid renders "unknown",
|
|
@@ -72,14 +92,14 @@ const daemonPid = (events) => {
|
|
|
72
92
|
}
|
|
73
93
|
return undefined;
|
|
74
94
|
};
|
|
75
|
-
const
|
|
95
|
+
const liveness = (events) => {
|
|
76
96
|
const last = events.at(-1);
|
|
77
97
|
if (!last)
|
|
78
|
-
return
|
|
98
|
+
return "last event unknown · daemon pid unknown";
|
|
79
99
|
const age = fmtAge(Date.now() - Date.parse(last.ts));
|
|
80
100
|
const pid = daemonPid(events);
|
|
81
101
|
if (pid === undefined)
|
|
82
|
-
return `
|
|
102
|
+
return `last event ${age} ago · daemon pid unknown`;
|
|
83
103
|
let state;
|
|
84
104
|
try {
|
|
85
105
|
process.kill(pid, 0);
|
|
@@ -88,17 +108,14 @@ const livenessLine = (events) => {
|
|
|
88
108
|
catch (k) {
|
|
89
109
|
state = k.code === "ESRCH" ? "dead" : "alive";
|
|
90
110
|
} // EPERM ⇒ alive
|
|
91
|
-
return `
|
|
111
|
+
return `last event ${age} ago · daemon pid ${pid} ${state}`;
|
|
92
112
|
};
|
|
93
113
|
const renderFrame = (cwd) => {
|
|
94
114
|
const g = loadGraph(cwd);
|
|
95
|
-
const runId = Journal.latestRunId(cwd);
|
|
115
|
+
const runId = Journal.latestRunId(cwd, { withJournal: true });
|
|
96
116
|
const assignments = new Map();
|
|
97
|
-
const gates = new Map();
|
|
98
117
|
let replayed = null;
|
|
99
118
|
let events = [];
|
|
100
|
-
// v1.23 T2: latest known context tokens per task (from context-sample journal events). Informational
|
|
101
|
-
// only — never feeds status/phase/gate classification (derivePhase and replayStatuses ignore them).
|
|
102
119
|
const contexts = new Map();
|
|
103
120
|
if (runId) {
|
|
104
121
|
const j = Journal.open(cwd, runId);
|
|
@@ -107,11 +124,8 @@ const renderFrame = (cwd) => {
|
|
|
107
124
|
for (const e of events) {
|
|
108
125
|
if (e.event === "task-dispatch" && e.taskId) {
|
|
109
126
|
const a = e.data.assignment;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (e.event === "gate-result" && e.taskId) {
|
|
113
|
-
const prev = gates.get(e.taskId) ?? "";
|
|
114
|
-
gates.set(e.taskId, `${prev}${e.data.pass ? "✓" : "✗"}${e.data.gate} `);
|
|
127
|
+
if (typeof a.adapter === "string" && typeof a.model === "string")
|
|
128
|
+
assignments.set(e.taskId, `${a.adapter}:${a.model}`);
|
|
115
129
|
}
|
|
116
130
|
if (e.event === "context-sample" && e.taskId && typeof e.data.tokens === "number" && Number.isFinite(e.data.tokens)) {
|
|
117
131
|
contexts.set(e.taskId, e.data.tokens); // last write wins
|
|
@@ -121,22 +135,65 @@ const renderFrame = (cwd) => {
|
|
|
121
135
|
const effective = { ...g, tasks: g.tasks.map((t) => ({ ...t, status: replayed?.get(t.id) ?? t.status })) };
|
|
122
136
|
const starved = new Set(blockedTasks(effective).map((t) => t.id));
|
|
123
137
|
const waiting = new Set(pendingTasks(effective).map((t) => t.id));
|
|
124
|
-
const
|
|
138
|
+
const unicode = visual();
|
|
139
|
+
const divider = unicode ? " · " : " / ";
|
|
140
|
+
const width = process.stdout.columns ?? 120;
|
|
141
|
+
const done = effective.tasks.filter((t) => t.status === "done").length;
|
|
142
|
+
const cells = g.tasks.map((t) => {
|
|
125
143
|
const st = replayed?.get(t.id) ?? t.status;
|
|
126
144
|
const label = starved.has(t.id) ? " starved" : waiting.has(t.id) ? " dep-waiting" : "";
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
145
|
+
const channel = assignments.get(t.id) ?? "-";
|
|
146
|
+
const assignCol = contexts.has(t.id) ? `${channel}${divider}ctx ${contexts.get(t.id)}` : channel;
|
|
147
|
+
return { t, st, label, assignCol, states: gateStates(t, events) };
|
|
148
|
+
});
|
|
149
|
+
const statusColor = (st) => st === "done" ? 32 : st === "failed" ? 31 : st === "human" ? 33 : 36;
|
|
150
|
+
if (!unicode) {
|
|
151
|
+
// machine/CI surface — layout unchanged (pipes, greps, and the golden pins depend on it)
|
|
152
|
+
const rows = cells.map(({ t, st, label, assignCol, states }) => {
|
|
153
|
+
const chain = gateChain(states, false);
|
|
154
|
+
const prefix = ` ${taskBox(st, false)} ${t.id} `;
|
|
155
|
+
const suffix = ` ${chain} ${String(st)}${label} ${assignCol}`;
|
|
156
|
+
return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
|
|
157
|
+
});
|
|
158
|
+
const header = runId
|
|
159
|
+
? `tickmarkr status${divider}run ${runId}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
|
|
160
|
+
: `tickmarkr status${divider}no runs yet${divider}${done}/${g.tasks.length} done`;
|
|
161
|
+
const legend = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
|
|
162
|
+
return [header, legend, ...rows].join("\n");
|
|
163
|
+
}
|
|
164
|
+
// TTY: audit-ledger frame — bold brand mark, dim chrome, semantic color only on verdicts,
|
|
165
|
+
// completion gauge, and column-aligned rows (pad plain text FIRST, colorize after: ANSI has
|
|
166
|
+
// zero display width and would corrupt padEnd math)
|
|
167
|
+
const dim = (s) => color(s, 2, true);
|
|
168
|
+
const dot = dim(" · ");
|
|
169
|
+
const anyFailed = cells.some((c) => c.st === "failed");
|
|
170
|
+
const gaugeCells = 10;
|
|
171
|
+
const fill = g.tasks.length ? Math.round((done / g.tasks.length) * gaugeCells) : 0;
|
|
172
|
+
const gauge = (fill ? color("█".repeat(fill), anyFailed ? 31 : 32, true) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
|
|
173
|
+
const live = liveness(events)
|
|
174
|
+
.replace(/\bdead\b/, color("dead", 31, true))
|
|
175
|
+
.replace(/\balive\b/, color("alive", 32, true))
|
|
176
|
+
.replaceAll(" · ", dot);
|
|
177
|
+
const tally = `${done}/${g.tasks.length} done`;
|
|
178
|
+
const header = ` ${color("✓", 32, true)} ${color("tickmarkr", 1, true)}${dot}` +
|
|
179
|
+
(runId ? `run ${runId}${dot}${live}${dot}` : `no runs yet${dot}`) +
|
|
180
|
+
`${gauge} ${done === g.tasks.length && g.tasks.length > 0 ? color(tally, 32, true) : tally}`;
|
|
181
|
+
const rule = dim("─".repeat(Math.min(width, 100)));
|
|
182
|
+
const legend = dim(` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(" · ")}`);
|
|
183
|
+
const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
|
|
184
|
+
const stW = Math.max(...cells.map((c) => (String(c.st) + c.label).length));
|
|
185
|
+
const chainW = gateChainWidth(true);
|
|
186
|
+
const assignW = Math.max(...cells.map((c) => c.assignCol.length));
|
|
187
|
+
const goalW = Math.max(8, width - (5 + idW) - 2 - chainW - 2 - stW - 2 - assignW);
|
|
188
|
+
const rows = cells.map(({ t, st, label, assignCol, states }) => {
|
|
189
|
+
const box = color(taskBox(st, true), st === "done" ? 32 : st === "failed" ? 31 : st === "human" ? 33 : 2, true);
|
|
190
|
+
const goal = shortGoal(t.goal, goalW).padEnd(goalW);
|
|
191
|
+
const statusCell = color(String(st), statusColor(st), true) +
|
|
192
|
+
(label ? (label === " starved" ? color(label, 31, true) : dim(label)) : "") +
|
|
193
|
+
" ".repeat(stW - (String(st) + label).length);
|
|
194
|
+
return ` ${box} ${t.id.padEnd(idW)} ${goal} ${gateChain(states, true)} ${statusCell} ${dim(assignCol)}`;
|
|
133
195
|
});
|
|
134
|
-
return [
|
|
135
|
-
`drovr status${runId ? ` — run ${runId}` : " — no runs yet"}`,
|
|
136
|
-
...(runId ? [livenessLine(events)].filter((l) => l !== null) : []),
|
|
137
|
-
" task status phase assignment gates",
|
|
138
|
-
...rows,
|
|
139
|
-
].join("\n");
|
|
196
|
+
return [header, rule, legend, ...rows].join("\n");
|
|
140
197
|
};
|
|
141
198
|
export async function status(argv, cwd = process.cwd(), opts = {}) {
|
|
142
199
|
if (!argv.includes("--watch"))
|
|
@@ -146,11 +203,11 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
|
|
|
146
203
|
const bounded = Number.isFinite(iterations);
|
|
147
204
|
const frames = [];
|
|
148
205
|
const sep = "\n---\n";
|
|
149
|
-
const tty =
|
|
206
|
+
const tty = visual();
|
|
150
207
|
for (let i = 0; i < iterations; i++) {
|
|
151
208
|
const frame = renderFrame(cwd);
|
|
152
209
|
if (tty)
|
|
153
|
-
process.stdout.write(`\x1b[2J\x1b[H${frame}`);
|
|
210
|
+
process.stdout.write(`\x1b[2J\x1b[H${frame}\n\x1b[2m watching · refresh ${REFRESH_MS / 1000}s · ^C to quit\x1b[0m`);
|
|
154
211
|
else
|
|
155
212
|
process.stdout.write(frame + sep);
|
|
156
213
|
if (bounded)
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
export type CommandMap = Record<string, (argv: string[]) => Promise<string>>;
|
|
3
3
|
export declare const COMMANDS: CommandMap;
|
|
4
|
-
export declare const USAGE = "
|
|
4
|
+
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\n compile <src> spec \u2192 .drovr/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 run execute the graph (--concurrency N --driver herdr|subprocess --route-strict)\n status live run state\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 unlock remove a stale/garbage run lock (refuses if the holder is alive)\n approve <id> <task> approve a parked human gate (--by <name> --reason <text>); takes effect on resume";
|
|
5
5
|
export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
|
|
6
6
|
out: string;
|
|
7
7
|
code: number;
|
package/dist/cli/index.js
CHANGED
|
@@ -16,9 +16,9 @@ import { unlock } from "./commands/unlock.js";
|
|
|
16
16
|
export const COMMANDS = {
|
|
17
17
|
init, doctor, compile, scope, plan, run, status, resume, report, profile, unlock, approve,
|
|
18
18
|
};
|
|
19
|
-
export const USAGE = `
|
|
20
|
-
usage:
|
|
21
|
-
init guided setup + doctor
|
|
19
|
+
export const USAGE = `tickmarkr — spec-driven orchestration harness for AI coding agents
|
|
20
|
+
usage: tickmarkr <command>
|
|
21
|
+
init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs
|
|
22
22
|
doctor re-probe adapters, herdr, auth; print capability matrix
|
|
23
23
|
compile <src> spec → .drovr/graph.json (fails without acceptance criteria)
|
|
24
24
|
scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)
|
|
@@ -41,7 +41,7 @@ export async function dispatch(cmd, argv, commands = COMMANDS) {
|
|
|
41
41
|
return { out: await fn(argv), code: 0 };
|
|
42
42
|
}
|
|
43
43
|
catch (err) {
|
|
44
|
-
return { out: `
|
|
44
|
+
return { out: `tickmarkr ${cmd}: ${err.message}`, code: 1 };
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
/* v8 ignore start -- binary entry: printing + process.exit side effects, not unit-testable (ROADMAP crit 2) */
|
package/dist/compile/gsd.js
CHANGED
|
@@ -92,7 +92,7 @@ function compileOne(file, storedPath) {
|
|
|
92
92
|
const truths = strings(fm.must_haves?.truths);
|
|
93
93
|
const acceptance = [...dones, ...truths].filter(Boolean);
|
|
94
94
|
if (!acceptance.length) {
|
|
95
|
-
throw new CompileError(`${file} has no acceptance criteria — every
|
|
95
|
+
throw new CompileError(`${file} has no acceptance criteria — every tickmarkr task needs them.\n` +
|
|
96
96
|
`GSD source: add <done> lines to the plan's tasks, or must_haves.truths in its frontmatter.`);
|
|
97
97
|
}
|
|
98
98
|
// @-refs from the <context> block only; keep repo-relative paths (no $VAR, ~, absolute, or ..-escape)
|
|
@@ -151,7 +151,7 @@ function compileOne(file, storedPath) {
|
|
|
151
151
|
}
|
|
152
152
|
export function compileGsd(src, root) {
|
|
153
153
|
if (!existsSync(src))
|
|
154
|
-
throw new CompileError(`${src} does not exist — point
|
|
154
|
+
throw new CompileError(`${src} does not exist — point tickmarkr compile at a GSD phase directory or a *-PLAN.md`);
|
|
155
155
|
const isDir = statSync(src).isDirectory();
|
|
156
156
|
const files = isDir
|
|
157
157
|
? readdirSync(src)
|
|
@@ -160,7 +160,7 @@ export function compileGsd(src, root) {
|
|
|
160
160
|
.map((f) => join(src, f))
|
|
161
161
|
: [src];
|
|
162
162
|
if (!files.length) {
|
|
163
|
-
throw new CompileError(`no *-PLAN.md files in ${src} — point
|
|
163
|
+
throw new CompileError(`no *-PLAN.md files in ${src} — point tickmarkr compile at a GSD phase directory`);
|
|
164
164
|
}
|
|
165
165
|
// context[0] must be readable from an isolated worktree: store repo-relative, never absolute
|
|
166
166
|
const rel = (f) => {
|
package/dist/compile/native.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { GATE_NAMES, ORACLES, SHAPES, TIERS, validateGraph } from "../graph/schema.js";
|
|
3
3
|
import { CompileError, inferShape, sha256 } from "./common.js";
|
|
4
|
-
export const NATIVE_MARKER = /^<!--\s*drovr:spec(?:\s+v1)?\s*-->\s*$/m;
|
|
4
|
+
export const NATIVE_MARKER = /^<!--\s*(?:tickmarkr|drovr):spec(?:\s+v1)?\s*-->\s*$/m;
|
|
5
5
|
const HEAD_RE = /^## (T\d+):\s*(.+)$/;
|
|
6
6
|
const FIELD_RE = /^- (\w+):\s*(.*)$/;
|
|
7
7
|
const NESTED_RE = /^\s+- (.+)$/;
|
|
@@ -135,7 +135,7 @@ export function compileNative(file) {
|
|
|
135
135
|
// v1.19 read-old/write-new: a plain-string acceptance item compiles as a judge oracle. This is the
|
|
136
136
|
// one-time nudge toward typed oracles (command/test/judge); PRD/Spec Kit/GSD stay silent (untouched).
|
|
137
137
|
if (plainCount > 0) {
|
|
138
|
-
console.warn(`
|
|
138
|
+
console.warn(`tickmarkr: ${plainCount} acceptance item${plainCount === 1 ? "" : "s"} in ${file} ${plainCount === 1 ? "is a plain string" : "are plain strings"} — compiled as judge oracle${plainCount === 1 ? "" : "s"}. Prefix with command:/test:/judge: to make the oracle explicit.`);
|
|
139
139
|
}
|
|
140
140
|
return result;
|
|
141
141
|
}
|
|
@@ -143,11 +143,11 @@ export function compileNative(file) {
|
|
|
143
143
|
// parser ignores) so the template itself round-trips through compileSource() unchanged. Every field is
|
|
144
144
|
// documented; the two example tasks illustrate plain vs deps+routing-hint, each with acceptance[].
|
|
145
145
|
export function specTemplate() {
|
|
146
|
-
return `<!--
|
|
147
|
-
#
|
|
146
|
+
return `<!-- tickmarkr:spec -->
|
|
147
|
+
# tickmarkr native spec
|
|
148
148
|
|
|
149
|
-
Your starting point for a
|
|
150
|
-
|
|
149
|
+
Your starting point for a tickmarkr native spec. Edit this file, then run:
|
|
150
|
+
tickmarkr compile tickmarkr.spec.md && tickmarkr plan && tickmarkr run
|
|
151
151
|
|
|
152
152
|
Each task is a "## Tn: Title" heading with "- field: value" bullets.
|
|
153
153
|
acceptance is required on every task (a nested list of observable outcomes).
|
package/dist/compile/speckit.js
CHANGED
|
@@ -7,7 +7,7 @@ const SUB_RE = /^\s+- (\w+):\s*(.+)$/;
|
|
|
7
7
|
export function compileSpecKit(dir) {
|
|
8
8
|
const path = join(dir, "tasks.md");
|
|
9
9
|
if (!existsSync(path)) {
|
|
10
|
-
throw new CompileError(`no tasks.md in ${dir} — point
|
|
10
|
+
throw new CompileError(`no tasks.md in ${dir} — point tickmarkr compile at a Spec Kit feature directory`);
|
|
11
11
|
}
|
|
12
12
|
const content = readFileSync(path, "utf8");
|
|
13
13
|
const drafts = [];
|
package/dist/config/config.js
CHANGED
|
@@ -269,7 +269,7 @@ export function loadConfig(repoRoot, opts = {}) {
|
|
|
269
269
|
return r.data;
|
|
270
270
|
}
|
|
271
271
|
export function configTemplate() {
|
|
272
|
-
return `#
|
|
272
|
+
return `# tickmarkr config overlay — merges over built-in defaults (repo beats global beats defaults)
|
|
273
273
|
# concurrency: 3
|
|
274
274
|
# driver: auto # auto | herdr | subprocess
|
|
275
275
|
# taskTimeoutMinutes: 30
|
|
@@ -279,9 +279,9 @@ export function configTemplate() {
|
|
|
279
279
|
# map: # per-shape routing; pin an exact CLI+model, or tier+prefer
|
|
280
280
|
# implement: { tier: mid, prefer: [cursor-agent, codex] }
|
|
281
281
|
# migration: { pin: { via: claude-code, model: fable } }
|
|
282
|
-
# floors: # advisory minimum tiers; '
|
|
282
|
+
# floors: # advisory minimum tiers; 'tickmarkr plan' lints violations
|
|
283
283
|
# migration: frontier
|
|
284
|
-
# learned: on # default ON (ROUTE-14); cold profile = exact v1.5 static routing, warms per workspace. Set 'off' to pin static routing; preview with '
|
|
284
|
+
# learned: on # default ON (ROUTE-14); cold profile = exact v1.5 static routing, warms per workspace. Set 'off' to pin static routing; preview with 'tickmarkr plan'
|
|
285
285
|
# learnedTuning: { halfLifeRuns: 5, availWeight: 0.05 } # optional; defaults byte-identical
|
|
286
286
|
# allow: { adapters: [claude-code, codex] } # optional fleet allowlist; presence activates even if empty (fail-closed)
|
|
287
287
|
# deny: { models: [codex:gpt-5.5] } # optional fleet denylist; deny beats allow on conflict
|
|
@@ -321,9 +321,9 @@ export function configTemplate() {
|
|
|
321
321
|
# dialog stalling unattended HEADLESS runs (live-verified on claude 2.1.205). Interactive TUIs still
|
|
322
322
|
# show a first-entry dialog — but it is the workspace TRUST dialog, not MCP config loading (live-drilled
|
|
323
323
|
# claude 2.1.206, 2026-07-10). There is NO CLI flag to pre-accept trust; the only store is claude's global
|
|
324
|
-
# ~/.claude.json keyed on the EXACT worktree path.
|
|
324
|
+
# ~/.claude.json keyed on the EXACT worktree path. tickmarkr does NOT write it (HYG-03 won't-fix, decision B:
|
|
325
325
|
# claude's own last-writer-wins persistence races any seed). Cost is ~one dismissal per worktree path,
|
|
326
|
-
# ever —
|
|
326
|
+
# ever — tickmarkr reuses stable worktree paths so the accept persists across runs; blocked-pane paging
|
|
327
327
|
# surfaces each first-time dialog.
|
|
328
328
|
`;
|
|
329
329
|
}
|
package/dist/drivers/herdr.d.ts
CHANGED
|
@@ -11,10 +11,13 @@ export declare class HerdrDriver implements ExecutorDriver {
|
|
|
11
11
|
private groups;
|
|
12
12
|
private groupSerial;
|
|
13
13
|
private ws;
|
|
14
|
+
private callerPane;
|
|
15
|
+
private watches;
|
|
14
16
|
constructor(bin?: string, workersPerTab?: number);
|
|
15
17
|
private serial;
|
|
16
18
|
static available(): boolean;
|
|
17
19
|
private herdr;
|
|
20
|
+
private namedPaneId;
|
|
18
21
|
private paneId;
|
|
19
22
|
private paneWidth;
|
|
20
23
|
slot(cwd: string, name: string, opts?: SlotOpts): Promise<Slot>;
|
|
@@ -36,6 +39,8 @@ export declare class HerdrDriver implements ExecutorDriver {
|
|
|
36
39
|
notify(msg: string, opts?: NotifyOpts): Promise<void>;
|
|
37
40
|
close(slot: Slot): Promise<void>;
|
|
38
41
|
private closeGrouped;
|
|
42
|
+
private priorWatch;
|
|
43
|
+
private watchSlot;
|
|
39
44
|
narrator(cwd: string, command: string, runId?: string): Promise<Slot>;
|
|
40
45
|
reconcile(desired: Set<string>, runId: string, opts?: {
|
|
41
46
|
spareLiveLlm?: boolean;
|
package/dist/drivers/herdr.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { shq } from "../adapters/types.js";
|
|
2
2
|
import { createWorktree, sh } from "../run/git.js";
|
|
3
3
|
import { herdrSealShellPrefix } from "./subprocess.js";
|
|
4
|
-
import { canonicalizeLegacyName, formatOwnedName, panesToClose } from "./types.js";
|
|
4
|
+
import { canonicalizeLegacyName, formatOwnedName, panesToClose, parseOwnedName } from "./types.js";
|
|
5
5
|
// VIS-09 P43-03: adopted safety floor from 43-MEASUREMENT.md (narrowest safe 53 → floor 108).
|
|
6
6
|
export const TRAILER_SAFE_FLOOR_COLS = 108;
|
|
7
7
|
export const TRAILER_WIDTH_MARGIN = 2; // cols below (floor + margin) refuse a rightward first split
|
|
@@ -24,6 +24,8 @@ export class HerdrDriver {
|
|
|
24
24
|
// operator's env before the driver is built). Required at slot() time, never in the constructor —
|
|
25
25
|
// pickDriver and its unit test construct HerdrDriver without env, so slot() is the trust gate.
|
|
26
26
|
ws = process.env.HERDR_WORKSPACE_ID;
|
|
27
|
+
callerPane = process.env.HERDR_PANE_ID;
|
|
28
|
+
watches = new Map();
|
|
27
29
|
constructor(bin = "herdr", workersPerTab = 3) {
|
|
28
30
|
this.bin = bin;
|
|
29
31
|
this.workersPerTab = workersPerTab;
|
|
@@ -39,18 +41,21 @@ export class HerdrDriver {
|
|
|
39
41
|
herdr(args, cwd = process.cwd(), timeoutMs) {
|
|
40
42
|
return sh(`${shq(this.bin)} ${args}`, cwd, timeoutMs);
|
|
41
43
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
async namedPaneId(name) {
|
|
45
|
+
const r = await this.herdr(`agent get ${shq(name)}`);
|
|
46
|
+
if (r.code !== 0)
|
|
47
|
+
return null;
|
|
45
48
|
try {
|
|
46
49
|
const id = JSON.parse(r.stdout).result?.agent?.pane_id;
|
|
47
|
-
|
|
48
|
-
return id;
|
|
50
|
+
return typeof id === "string" && id ? id : null;
|
|
49
51
|
}
|
|
50
52
|
catch {
|
|
51
|
-
|
|
53
|
+
return null;
|
|
52
54
|
}
|
|
53
|
-
|
|
55
|
+
}
|
|
56
|
+
// pane ids compact when panes close — resolve fresh via the durable agent name (spec §5)
|
|
57
|
+
async paneId(slot) {
|
|
58
|
+
return await this.namedPaneId(slot.name) ?? slot.id;
|
|
54
59
|
}
|
|
55
60
|
// VIS-09 P43-03: runtime width for the layout gate (43-MEASUREMENT.md licensing condition 2).
|
|
56
61
|
async paneWidth(paneId) {
|
|
@@ -310,6 +315,13 @@ export class HerdrDriver {
|
|
|
310
315
|
await this.herdr(`notification show ${shq(msg)} --sound ${opts?.tier === "attention" ? "request" : opts?.sound ?? "request"}`);
|
|
311
316
|
}
|
|
312
317
|
async close(slot) {
|
|
318
|
+
if (this.watches.get(slot.name)?.id === slot.id) {
|
|
319
|
+
this.watches.delete(slot.name);
|
|
320
|
+
const pane = await this.namedPaneId(slot.name);
|
|
321
|
+
if (pane)
|
|
322
|
+
await this.herdr(`pane close ${shq(pane)}`);
|
|
323
|
+
return; // run-end reconcile may already have reaped it; never close a compacted stale id
|
|
324
|
+
}
|
|
313
325
|
if (slot.group && this.groups.has(slot.group)) {
|
|
314
326
|
return this.serial(() => this.closeGrouped(slot));
|
|
315
327
|
}
|
|
@@ -342,16 +354,95 @@ export class HerdrDriver {
|
|
|
342
354
|
this.groups.delete(slot.group); // group dies when all generations gone
|
|
343
355
|
}
|
|
344
356
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
357
|
+
async priorWatch(runId) {
|
|
358
|
+
if (!this.ws)
|
|
359
|
+
throw new Error("herdr watch placement requires HERDR_WORKSPACE_ID — refusing unseeded pane");
|
|
360
|
+
const list = await this.herdr("agent list");
|
|
361
|
+
if (list.code !== 0)
|
|
362
|
+
throw new Error(`herdr agent list failed: ${list.stderr || list.stdout}`);
|
|
363
|
+
let agents;
|
|
364
|
+
try {
|
|
365
|
+
agents = JSON.parse(list.stdout).result?.agents;
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
throw new Error(`herdr agent list returned unparseable JSON: ${list.stdout}`);
|
|
369
|
+
}
|
|
370
|
+
if (!Array.isArray(agents))
|
|
371
|
+
throw new Error(`herdr agent list returned no agents: ${list.stdout}`);
|
|
372
|
+
const prior = agents.find((a) => {
|
|
373
|
+
const owned = typeof a.name === "string" ? parseOwnedName(a.name) : null;
|
|
374
|
+
return a.workspace_id === this.ws && typeof a.pane_id === "string" && owned?.role === "watch" && owned.taskId === "run" && owned.runId !== runId;
|
|
375
|
+
});
|
|
376
|
+
return prior?.pane_id ?? null;
|
|
377
|
+
}
|
|
378
|
+
// T2: the watch is a rightward sibling of the daemon's own pane, never a separate tab. Its durable
|
|
379
|
+
// owned name lets a resumed daemon find an already-running watch instead of stacking another one.
|
|
380
|
+
async watchSlot(cwd, name) {
|
|
381
|
+
if (!this.ws)
|
|
382
|
+
throw new Error("herdr watch placement requires HERDR_WORKSPACE_ID — refusing unseeded pane");
|
|
383
|
+
if (!this.callerPane)
|
|
384
|
+
throw new Error("herdr watch placement requires HERDR_PANE_ID — refusing untargeted split");
|
|
385
|
+
const sp = await this.herdr(`pane split ${shq(this.callerPane)} --direction right --no-focus`);
|
|
386
|
+
if (sp.code !== 0)
|
|
387
|
+
throw new Error(`herdr watch split failed: ${sp.stderr || sp.stdout}`);
|
|
388
|
+
let pane;
|
|
389
|
+
try {
|
|
390
|
+
pane = JSON.parse(sp.stdout).result?.pane?.pane_id;
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
/* fail closed below */
|
|
394
|
+
}
|
|
395
|
+
if (typeof pane !== "string" || !pane)
|
|
396
|
+
throw new Error(`herdr watch split returned no pane id: ${sp.stdout}`);
|
|
397
|
+
const renamed = await this.herdr(`agent rename ${shq(pane)} ${shq(name)}`);
|
|
398
|
+
if (renamed.code !== 0 || await this.namedPaneId(name) !== pane) {
|
|
399
|
+
await this.herdr(`pane close ${shq(pane)}`);
|
|
400
|
+
throw new Error(`herdr watch rename failed: ${renamed.stderr || renamed.stdout}`);
|
|
401
|
+
}
|
|
402
|
+
const seed = await this.herdr(`pane run ${shq(pane)} ${shq(`cd ${shq(cwd)}; export HERDR_WORKSPACE_ID=${shq(this.ws)}; ${herdrSealShellPrefix()}`)}`, cwd);
|
|
403
|
+
if (seed.code !== 0) {
|
|
404
|
+
await this.herdr(`pane close ${shq(pane)}`);
|
|
405
|
+
throw new Error(`herdr watch seed failed: ${seed.stderr || seed.stdout}`);
|
|
406
|
+
}
|
|
407
|
+
return { id: pane, name, cwd };
|
|
408
|
+
}
|
|
409
|
+
// T6 narrator: the run's single live status surface. Reuse a local or already-running owned watch;
|
|
410
|
+
// a new run reowns its prior watch, and only a newly split pane receives the watch command. The
|
|
411
|
+
// status command reads the latest run every frame, so the renamed pane follows the new run without
|
|
412
|
+
// interrupting the operator's watch loop. Failures propagate — the daemon swallows.
|
|
350
413
|
async narrator(cwd, command, runId) {
|
|
351
414
|
const name = runId ? formatOwnedName({ role: "watch", taskId: "run", attempt: 0, runId }) : `narrator-watch-${process.pid}`;
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
415
|
+
return this.serial(async () => {
|
|
416
|
+
const cached = this.watches.get(name);
|
|
417
|
+
if (cached)
|
|
418
|
+
return cached;
|
|
419
|
+
const existing = await this.namedPaneId(name);
|
|
420
|
+
if (existing) {
|
|
421
|
+
const s = { id: existing, name, cwd };
|
|
422
|
+
this.watches.set(name, s);
|
|
423
|
+
return s;
|
|
424
|
+
}
|
|
425
|
+
const prior = runId ? await this.priorWatch(runId) : null;
|
|
426
|
+
if (prior) {
|
|
427
|
+
const renamed = await this.herdr(`agent rename ${shq(prior)} ${shq(name)}`);
|
|
428
|
+
if (renamed.code !== 0 || await this.namedPaneId(name) !== prior) {
|
|
429
|
+
throw new Error(`herdr watch reclaim failed: ${renamed.stderr || renamed.stdout}`);
|
|
430
|
+
}
|
|
431
|
+
const s = { id: prior, name, cwd };
|
|
432
|
+
this.watches.set(name, s);
|
|
433
|
+
return s;
|
|
434
|
+
}
|
|
435
|
+
const s = await this.watchSlot(cwd, name);
|
|
436
|
+
this.watches.set(name, s);
|
|
437
|
+
try {
|
|
438
|
+
await this.run(s, command);
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
this.watches.delete(name);
|
|
442
|
+
throw err;
|
|
443
|
+
}
|
|
444
|
+
return s;
|
|
445
|
+
});
|
|
355
446
|
}
|
|
356
447
|
// OBS-17 T2 / v1.22b T1: close every drovr-owned pane that should not exist (superseded attempts,
|
|
357
448
|
// killed-daemon orphans, leftovers from OLDER runs) — in this run's workspace OR misplaced in any
|
|
@@ -57,7 +57,7 @@ export class SubprocessDriver {
|
|
|
57
57
|
p.stdout.on("data", (d) => (s.buf = (s.buf + d).slice(-MAX_BUF)));
|
|
58
58
|
p.stderr.on("data", (d) => (s.buf = (s.buf + d).slice(-MAX_BUF)));
|
|
59
59
|
p.on("close", () => (s.exited = true));
|
|
60
|
-
p.on("error", (e) => { s.buf = (s.buf + `\n[
|
|
60
|
+
p.on("error", (e) => { s.buf = (s.buf + `\n[tickmarkr subprocess error] ${e}\n`).slice(-MAX_BUF); s.exited = true; });
|
|
61
61
|
}
|
|
62
62
|
async waitOutput(slot, pattern, timeoutMs, opts) {
|
|
63
63
|
const s = this.state(slot);
|
|
@@ -92,7 +92,7 @@ export class SubprocessDriver {
|
|
|
92
92
|
async notify(msg, _opts) {
|
|
93
93
|
if (_opts?.tier === "routine")
|
|
94
94
|
return;
|
|
95
|
-
console.log(`[
|
|
95
|
+
console.log(`[tickmarkr] ${msg}`);
|
|
96
96
|
}
|
|
97
97
|
async close(slot) {
|
|
98
98
|
const s = this.slots.get(slot.id);
|
package/dist/drivers/types.js
CHANGED
|
@@ -20,19 +20,20 @@ export function parseOwnedName(name) {
|
|
|
20
20
|
export function isForeignName(name) {
|
|
21
21
|
return parseOwnedName(name) === null;
|
|
22
22
|
}
|
|
23
|
-
// v1.22b T1: workspace-aware fold over a fleet snapshot — decides which owned panes are garbage
|
|
23
|
+
// v1.22b T1: workspace-aware fold over a fleet snapshot — decides which owned task panes are garbage
|
|
24
24
|
// right now. In-workspace: the existing desired-set/spareLiveLlm sweep (OBS-17 T2). Out-of-workspace:
|
|
25
25
|
// an owned pane from a DIFFERENT run is a misplaced leftover (bug, foreign actor, pre-VIS-10 relic)
|
|
26
26
|
// and closes regardless of `desired`; an owned pane from THIS run elsewhere is left alone — a live
|
|
27
27
|
// run can legitimately hold panes across workspaces, so only run age marks a misplaced pane garbage.
|
|
28
|
-
//
|
|
28
|
+
// Watch panes are operator-owned after run end and are reclaimed by the next run; foreign names
|
|
29
|
+
// (parseOwnedName fails) are never candidates, in any workspace.
|
|
29
30
|
export function panesToClose(agents, desired, ws, runId, opts) {
|
|
30
31
|
const out = [];
|
|
31
32
|
for (const a of agents) {
|
|
32
33
|
if (typeof a.name !== "string" || typeof a.paneId !== "string")
|
|
33
34
|
continue;
|
|
34
35
|
const owned = parseOwnedName(a.name);
|
|
35
|
-
if (!owned)
|
|
36
|
+
if (!owned || owned.role === "watch")
|
|
36
37
|
continue;
|
|
37
38
|
if (a.workspaceId === ws) {
|
|
38
39
|
if (desired.has(a.name))
|
package/dist/gates/baseline.js
CHANGED
|
@@ -71,8 +71,12 @@ export async function compareToBaseline(cwd, commands, baseline, enabled) {
|
|
|
71
71
|
const results = [];
|
|
72
72
|
for (const name of enabled) {
|
|
73
73
|
const cmd = commands[name];
|
|
74
|
-
if (!cmd)
|
|
75
|
-
|
|
74
|
+
if (!cmd) {
|
|
75
|
+
// nothing detected for this gate in the target repo — journal an explicit skip instead of
|
|
76
|
+
// vanishing silently (a lint gate with no lint script rendered as forever-open in status)
|
|
77
|
+
results.push({ gate: name, pass: true, details: `no ${name} command detected — skipped`, meta: { skipped: true } });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
76
80
|
const r = await sh(cmd, cwd);
|
|
77
81
|
if (r.code === 0) {
|
|
78
82
|
results.push({ gate: name, pass: true, details: "exit 0" });
|