tickmarkr 1.38.0 → 1.41.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/README.md CHANGED
@@ -6,12 +6,16 @@
6
6
 
7
7
  tickmarkr is a spec-driven orchestration harness for AI coding agent CLIs. You write a spec with
8
8
  acceptance criteria; the engine routes tasks to the best installed agent CLI (claude-code, codex,
9
- cursor-agent, opencode, grok, pi) by cost and capability, dispatches work in isolated git worktrees
10
- — as interactive TUIs when running under [herdr](https://herdr.dev), headless subprocesses otherwise
11
- — and independently verifies every result before merge. Green tasks consolidate onto a
9
+ cursor-agent, opencode, grok, pi) by cost and capability, dispatches work in git worktrees for
10
+ change isolation — as interactive TUIs when running under [herdr](https://herdr.dev), headless
11
+ subprocesses otherwise — and independently verifies each committed result by checking for no new
12
+ baseline failures per task, then strictly verifying the integration tip. Green tasks consolidate onto a
12
13
  `tickmarkr/<runId>` branch; merging to your mainline is always your call, never automated. Engage
13
14
  with full visibility into routing decisions, worker progress, and gate verdicts — or run headless
14
- and review the audit trail afterward.
15
+ and review the local execution log afterward.
16
+
17
+ Here, **isolated git worktrees** means change isolation: each task gets its own worktree and branch
18
+ so its diff is separated from sibling tasks. It is not a process sandbox or host-containment boundary.
15
19
 
16
20
  ## Invariants
17
21
 
@@ -19,7 +23,8 @@ These are law; the codebase fails closed around them:
19
23
 
20
24
  - `acceptance[]` required on every task; compile fails without it
21
25
  - new engagements consolidate to a `tickmarkr/<runId>` branch — never main
22
- - gates never trust worker claims; tickmarkr verifies everything itself
26
+ - gates never trust worker claims; each task is checked for no new baseline failures and the merged
27
+ integration tip is strictly re-verified
23
28
  - state is files + git only; no DB, no services
24
29
  - worker/judge/review/consult prompts end with machine-parseable trailers
25
30
  (structured JSON verdicts); parse defensively, fail closed
@@ -68,7 +73,8 @@ tickmarkr report <runId> --md # engagement record in Markdown
68
73
 
69
74
  That's the flow: `init` scaffolds config, you write tasks with `acceptance[]` criteria, `compile`
70
75
  validates and builds the graph, `plan` shows routing decisions and cost, `run` dispatches work to
71
- installed CLIs and independently verifies every result, and `report` documents the outcome. All
76
+ installed CLIs and checks each task for no new baseline failures before strictly verifying the
77
+ integration tip, and `report` documents the outcome. All
72
78
  green tasks land on `tickmarkr/<runId>` — merge to your mainline is your decision.
73
79
 
74
80
  ## Agent-ready repos: `tickmarkr init --agent`
@@ -98,7 +104,7 @@ Consent rules — every write is additive, never destructive:
98
104
 
99
105
  ```bash
100
106
  tickmarkr status # engagement state (--watch to follow live)
101
- tickmarkr resume <runId> # continue an engagement from the audit trail
107
+ tickmarkr resume <runId> # continue an engagement from the local execution log
102
108
  tickmarkr approve <runId> <taskId> # approve a parked task (--reason to document)
103
109
  tickmarkr report <runId> # cost/quality report
104
110
  tickmarkr profile # show the learned routing profile
@@ -160,11 +166,11 @@ routing:
160
166
  ```
161
167
  `tickmarkr plan` lints denied models identically to unauthed ones. Re-enable by deleting one line.
162
168
 
163
- ## Run output and audit trail
169
+ ## Run output and local execution log
164
170
 
165
- When you execute `tickmarkr run`, the daemon records every event in an append-only ledger (the audit trail):
171
+ When you execute `tickmarkr run`, the daemon records every event in an append-only local journal:
166
172
  task dispatch, gate verdicts, worker status, and merges. Narration streams to stdout; you can also
167
- `--watch` or tail the ledger directly. If interrupted, `tickmarkr resume <runId>` replays the ledger
173
+ `--watch` or tail the journal directly. If interrupted, `tickmarkr resume <runId>` replays the journal
168
174
  and continues from the last stable state — deterministic, not ephemeral.
169
175
 
170
176
  ## Usage and cost
@@ -193,7 +199,7 @@ and first-attempt success rate. Cost reporting follows strict honesty rules and
193
199
  - **Basis always shown**: every cost figure prints the token count, rate used, and date so estimates can be audited
194
200
  - **No network calls**: pricing config is operator-maintained locally, seeded with dated
195
201
  comments and LiteLLM's JSON file named as the copy-from source; tickmarkr never calls home to fetch rates
196
- - **Attribution from journal**: token counts come from tickmarkr's own telemetry spans in the audit trail, never from provider invoices or dashboards
202
+ - **Attribution from journal**: token counts come from tickmarkr's own telemetry spans in the local journal, never from provider invoices or dashboards
197
203
 
198
204
  ## Visibility: optional supervised workspace
199
205
 
@@ -248,7 +254,7 @@ Every pane and tab tickmarkr creates receives a **parseable ownership name** enc
248
254
 
249
255
  tickmarkr creates all owned panes only within the run's workspace; any tickmarkr-owned panes discovered outside the run's workspace (from prior runs or placement bugs) are reconciled and closed. Any pane not matching the ownership contract is **foreign** — created by you or another tool — and is never closed automatically.
250
256
 
251
- **Desired-state reconciliation**: A pure function computes the exact set of panes that should exist from the audit trail at any moment:
257
+ **Desired-state reconciliation**: A pure function computes the exact set of panes that should exist from the local journal at any moment:
252
258
  - Worker panes for all in-flight task attempts
253
259
  - Gate panes for unread judge/review/consult verdicts
254
260
  - The watch pane (if running)
@@ -264,11 +270,11 @@ Reconciliation failures (herdr unavailable, a pane vanished mid-sweep) never fai
264
270
 
265
271
  ### Workspace trust
266
272
 
267
- tickmarkr runs work in fresh git worktrees cloned from your repo. Before dispatch, workers check
268
- whether the repo is trusted by their CLI:
273
+ tickmarkr creates fresh git worktrees for change isolation. That is separate from each CLI's own
274
+ workspace-trust behavior:
269
275
 
270
- - `tickmarkr doctor` probes each CLI's trust status and auto-seeds trust where possible
271
- - Some CLIs show a "Workspace Trust" dialog; tickmarkr detects and auto-answers it once per task
276
+ - `tickmarkr doctor` invokes each installed adapter's trust hook where supported and seeds trust where possible
277
+ - Some CLIs show a "Workspace Trust" dialog; tickmarkr auto-answers only a recognized fingerprint once per slot
272
278
  - Other blocked dialogs page you for manual approval
273
279
 
274
280
  ## Spec formats
@@ -293,8 +299,9 @@ Every task requires explicit `acceptance[]` criteria; compile fails without them
293
299
  - **`test <name>`** — run a named test from your suite (must exist)
294
300
  - **`judge <rubric>`** — LLM verdict against your rubric; fail-closed, never overrides failed command/test
295
301
 
296
- At runtime, the **scope gate** verifies that workers only touched files declared in the spec. If a
297
- worker must change out-of-scope paths, the task pauses as a human gate — your approval is required.
302
+ At runtime, the **scope gate** derives `git diff --name-only` from the task base and compares it with
303
+ the spec-declared files. Out-of-scope edits fail unless the operator config explicitly allowlists
304
+ them; worker-declared deviations are recorded as notes, not authority.
298
305
 
299
306
  ## Claude Code integration
300
307
 
@@ -14,7 +14,13 @@ export class FakeAdapter {
14
14
  this.script = JSON.parse(readFileSync(scriptPath, "utf8"));
15
15
  }
16
16
  async probe() {
17
- return { installed: true, authed: true, version: "fake", models: ["fake-1", "fake-2"] };
17
+ return {
18
+ installed: true, authed: true, version: "fake", models: ["fake-1", "fake-2"],
19
+ modelAuth: {
20
+ "fake-1": { authed: true, probedAt: "1970-01-01T00:00:00.000Z" },
21
+ "fake-2": { authed: true, probedAt: "1970-01-01T00:00:00.000Z" },
22
+ },
23
+ };
18
24
  }
19
25
  channels(_cfg) {
20
26
  return [
@@ -27,9 +33,11 @@ export class FakeAdapter {
27
33
  // Phase 47 (GATE-09): detect role at COMMAND-BUILD time. runHeadless/runViaDriver write the prompt
28
34
  // file BEFORE calling headlessCommand, so it exists here. The counter advances ONLY when the prompt
29
35
  // contains TICKMARKR-JUDGE — a review/consult prompt never touches it (research Pitfall 3).
36
+ let prompt = "";
30
37
  let isJudge = false;
31
38
  try {
32
- isJudge = /TICKMARKR-JUDGE/.test(readFileSync(promptFile, "utf8"));
39
+ prompt = readFileSync(promptFile, "utf8");
40
+ isJudge = /TICKMARKR-JUDGE/.test(prompt);
33
41
  }
34
42
  catch {
35
43
  // unreadable promptFile: can't detect role; serve static values (legacy headless-call behavior)
@@ -41,6 +49,14 @@ export class FakeAdapter {
41
49
  val = val[Math.min(this.judgeIdx, val.length - 1)];
42
50
  this.judgeIdx++;
43
51
  }
52
+ if (key === "judge" && isJudge && val && typeof val === "object" && !Array.isArray(val)) {
53
+ const verdict = val;
54
+ if (verdict.pass === true && Array.isArray(verdict.criteria) && verdict.criteria.length === 0) {
55
+ const items = prompt.match(/## Acceptance criteria \(judge\)\n([\s\S]*?)\n\n## Diff/)?.[1]
56
+ .split("\n").filter((line) => line.startsWith("- ")).map((line) => line.slice(2)) ?? [];
57
+ val = { ...verdict, criteria: items.map((criterion) => ({ criterion, met: true, reason: "scripted fake pass" })) };
58
+ }
59
+ }
44
60
  const p = join(dirname(this.scriptPath), `${key}.json`);
45
61
  writeFileSync(p, JSON.stringify(val ?? {}, null, 1));
46
62
  return p;
@@ -59,14 +75,16 @@ export class FakeAdapter {
59
75
  // SPEND-01: synthetic usage as a TIMESTAMPED disk record the fake's own reader picks up post-hoc —
60
76
  // never a trailer, never pane text. node stamps a real ms-precision ISO time (bash 3.2 on darwin has
61
77
  // no ms clock) so the record lands at/after this attempt's dispatch and clears the sinceMs cursor.
62
- // Written AFTER the step's `git add`/commit stays untracked invisible to the scope/evidence gates.
78
+ // Written BEFORE the trailer echo (same order real CLIs use: session-store rows land during work,
79
+ // completion marker last) so an interactive harvest on the trailer never races the usage writer.
80
+ // Still after the step's `git add`/commit ⇒ stays untracked ⇒ invisible to scope/evidence gates.
63
81
  const usageWrite = step.usage
64
82
  ? `; node -e ${shq(`require("fs").writeFileSync(".tickmarkr-usage.json", JSON.stringify({timestamp:new Date().toISOString(), usage:${JSON.stringify(step.usage)}}))`)}`
65
83
  : "";
66
84
  const base = !step.result
67
85
  ? `bash -c ${shq(step.shell)}` // no trailer: scripted stall/quota
68
- : `bash -c ${shq(step.shell)}; echo ${shq(`TICKMARKR_RESULT_${nonce} ` + JSON.stringify({ deviations: [], ...step.result }))}`;
69
- return base + usageWrite;
86
+ : `bash -c ${shq(step.shell)}${usageWrite}; echo ${shq(`TICKMARKR_RESULT_${nonce} ` + JSON.stringify({ deviations: [], ...step.result }))}`;
87
+ return base;
70
88
  }
71
89
  // SPEND-01: read the cwd-keyed usage record from DISK (never a pane), honoring the attempt cursor.
72
90
  // A record stamped before sinceMs, an unparseable stamp, or a bad usage shape all fail OPEN to
@@ -202,10 +202,10 @@ export function discoverChannels(cfg, adapters, health) {
202
202
  .flatMap((a) => {
203
203
  const h = health[a.id];
204
204
  const s = h?.servable;
205
- // T2 (2026-07-13): a model doctor marked unauthed (modelAuth[model].authed===false) advertises NO
206
- // channel — routing a 403 into dispatch was the v1.10 regression. modelAuthed is true when no verdict
207
- // is recorded (absent modelAuth unknown ⇒ routable), so an all-authed/pre-v1.5 fleet is byte-identical.
208
- return a.channels(cfg).filter((c) => modelAuthed(h, c.model) && (!s || s.includes(c.model)));
205
+ // T2 (2026-07-13): only a model doctor marked authed (modelAuth[model].authed===true) advertises a
206
+ // channel — routing an unknown or 403 into dispatch is fail-closed. Operators with pre-v1.21 doctor.json
207
+ // can opt into the prior unknown-is-routable behavior with routing.allowUnverifiedModels.
208
+ return a.channels(cfg).filter((c) => modelAuthed(h, c.model, cfg.routing.allowUnverifiedModels) && (!s || s.includes(c.model)));
209
209
  });
210
210
  if (!cfg.routing.allow && !cfg.routing.deny)
211
211
  return base;
@@ -233,23 +233,23 @@ export function servabilityLine(excluded) {
233
233
  const parts = excluded.map(({ key, adapter }) => `${key} (not in ${adapter}'s served model list)`);
234
234
  return `servability: ${excluded.length} channel(s) unservable — ${parts.join(", ")}`;
235
235
  }
236
- // T2 (2026-07-13): channels discoverChannels dropped because doctor marked their model unauthed
237
- // (modelAuth[model].authed===false). Computed from the SAME inputs as the drop (a.channels(cfg)) so
238
- // attribution can never drift from behavior. installed+authed mirrors the discoverChannels adapter gate;
239
- // a missing modelAuth record ⇒ unknown ⇒ NOT excluded (compat with pre-v1.5 doctor.json + probeAll fallback).
236
+ // T2 (2026-07-13): channels discoverChannels dropped because doctor marked their model unauthed or left it
237
+ // unverified. Computed from the SAME inputs as the drop (a.channels(cfg)) so attribution can never drift from
238
+ // behavior. installed+authed mirrors the discoverChannels adapter gate.
240
239
  export function modelAuthExclusions(cfg, adapters, health) {
241
240
  const out = [];
242
241
  for (const a of adapters) {
243
242
  const h = health[a.id];
244
243
  if (!h?.installed || !h?.authed)
245
244
  continue;
246
- const verdicts = h.modelAuth;
247
- if (!verdicts)
248
- continue;
249
245
  for (const c of a.channels(cfg)) {
250
- const v = verdicts[c.model];
246
+ const v = h.modelAuth?.[c.model];
247
+ if (modelAuthed(h, c.model, cfg.routing.allowUnverifiedModels))
248
+ continue;
251
249
  if (v?.authed === false)
252
250
  out.push({ key: channelKey(c), adapter: a.id, reason: v.reason ?? "probe failed", probedAt: v.probedAt });
251
+ else
252
+ out.push({ key: channelKey(c), adapter: a.id, reason: "no model auth verdict — run tickmarkr doctor", probedAt: "not recorded" });
253
253
  }
254
254
  }
255
255
  return out;
@@ -38,7 +38,7 @@ export interface AuthHealth {
38
38
  servable?: string[];
39
39
  modelAuth?: Record<string, ModelAuth>;
40
40
  }
41
- export declare function modelAuthed(health: AuthHealth | undefined, model: string): boolean;
41
+ export declare function modelAuthed(health: AuthHealth | undefined, model: string, allowUnverifiedModels?: boolean): boolean;
42
42
  export interface Invocation {
43
43
  command: string;
44
44
  }
@@ -23,8 +23,9 @@ export function addUsage(a, b) {
23
23
  reasoning: add(a.reasoning, b.reasoning),
24
24
  };
25
25
  }
26
- export function modelAuthed(health, model) {
27
- return health?.modelAuth?.[model]?.authed !== false;
26
+ export function modelAuthed(health, model, allowUnverifiedModels = false) {
27
+ const authed = health?.modelAuth?.[model]?.authed;
28
+ return authed === true || (authed === undefined && allowUnverifiedModels);
28
29
  }
29
30
  export function matchesTrustDialog(paneText, dialog) {
30
31
  return paneText.includes(dialog.fingerprint);
@@ -13,18 +13,51 @@ const DOCS_END = "<!-- tickmarkr:agent-docs end -->";
13
13
  const AGENT_DOCS = `${DOCS_BEGIN}
14
14
  ## tickmarkr
15
15
 
16
- tickmarkr turns repository specs into isolated, independently verified agent work. Use \`/tickmarkr-loop\` for one spec or \`/tickmarkr-auto\` for several.
16
+ tickmarkr compiles repository specs into isolated, independently verified agent work.
17
17
 
18
- Loop: \`tickmarkr compile <spec>\` → \`tickmarkr plan\` → \`tickmarkr run\` → \`tickmarkr report <runId> --md\`.
18
+ ### Invariants
19
19
 
20
20
  - Never run two tickmarkr runs in the same repository concurrently.
21
- - Never merge tickmarkr work directly to main; new runs consolidate on \`tickmarkr/<runId>\`.
22
- - Fix source specs instead of editing generated graphs.
23
- - Never trust a worker's completion claim; gates independently verify the work.
24
- - Treat missing or unparseable results as failures.
21
+ - Never let tickmarkr merge work to main; new runs consolidate on \`tickmarkr/<runId>\`.
22
+ - Do not edit compiled graphs to force outcomes; fix source specs and recompile.
23
+ - Gates verify commits, diffs, acceptance criteria, and reviews independently — never trust a worker's completion claim.
24
+ - Treat missing or unparseable machine results and verdicts as failures.
25
+
26
+ ### Commands
27
+
28
+ - \`tickmarkr compile <spec>\` — spec → RunGraph
29
+ - \`tickmarkr plan\` — routing table and human gates
30
+ - \`tickmarkr run\` — execute the graph
31
+ - \`tickmarkr status <runId>\` — run progress
32
+ - \`tickmarkr resume <runId>\` — continue a paused or failed run
33
+ - \`tickmarkr approve <runId> <taskId>\` — release a human gate
34
+ - \`tickmarkr report <runId> --md\` — execution record beside the spec
35
+
36
+ Loop: compile → plan → run → report. Watch the journal for run-end rather than polling workers.
37
+
38
+ ### Role check (multi-agent environments)
39
+
40
+ - **Orchestrator:** run the loop in your session; do not start a second run.
41
+ - **Supervisor with a live orchestrator:** relay the mission via verified handoff (below), then supervise — do not duplicate the loop.
42
+ - **Primary session without an orchestrator:** spawn one child orchestration session, give it the mission and these rules, then supervise.
43
+
44
+ Outside multi-agent environments, run the loop directly.
45
+
46
+ ### Version preflight
47
+
48
+ Before \`tickmarkr compile\` or \`tickmarkr run\`: run \`tickmarkr version\`, read \`package.json\` version, and if the binary is older on major.minor, stop and tell the operator to update. Never proceed on hope — stale binaries silently skip daemon gates.
49
+
50
+ ### Tip-verify-before-green
51
+
52
+ A run is green only when the run-end event exists in the journal AND tip verify is not "failed". Never report green to the operator, tab titles, or records until both hold.
53
+
54
+ ### Verified handoffs
55
+
56
+ When relaying missions between agents, never use bare send-text (\`herdr agent send\` / pane send-text) — it omits Enter. Use \`herdr pane run <pane> "<message>"\` or \`herdr notification show "<message>"\`. Confirm delivery by reading the target pane afterward; never report "relayed" without read-back.
25
57
  ${DOCS_END}
26
58
  `;
27
- const skillPath = (cwd, skill) => join(cwd, ".claude", "skills", skill, "SKILL.md");
59
+ const skillsRoot = (cwd) => existsSync(join(cwd, ".claude", "skills")) ? join(cwd, ".claude", "skills") : join(cwd, ".agents", "skills");
60
+ const skillPath = (cwd, skill) => join(skillsRoot(cwd), skill, "SKILL.md");
28
61
  const skillsInstalled = (cwd) => AGENT_SKILLS.every((s) => existsSync(skillPath(cwd, s)));
29
62
  const wizardDriverDefault = () => process.env.HERDR_ENV === "1" ? "herdr" : "auto";
30
63
  async function installAgentFiles(cwd, force, docs, notes) {
@@ -44,7 +77,7 @@ async function installAgentFiles(cwd, force, docs, notes) {
44
77
  notes.push(`skipped existing ${dest}; pass --force to overwrite it`);
45
78
  continue;
46
79
  }
47
- mkdirSync(join(cwd, ".claude", "skills", skill), { recursive: true });
80
+ mkdirSync(join(skillsRoot(cwd), skill), { recursive: true });
48
81
  writeFileSync(dest, readFileSync(new URL(`../../../skills/${skill}/SKILL.md`, import.meta.url)), exists ? undefined : { flag: "wx" });
49
82
  notes.push(`${exists ? "overwrote" : "wrote"} ${dest}`);
50
83
  }
@@ -95,8 +128,8 @@ async function runInitWizard(cwd) {
95
128
  const llm = llmRaw === "pane" || llmRaw === "headless" ? llmRaw : llmDef;
96
129
  let installSkills = false;
97
130
  if (!skillsInstalled(cwd)) {
98
- const skillsDef = existsSync(join(cwd, ".claude")) && !skillsInstalled(cwd);
99
- installSkills = await askYesNo(rl, "Install agent skills (tickmarkr-loop/tickmarkr-auto) into .claude/skills?", skillsDef);
131
+ const skillsDef = existsSync(join(cwd, ".claude", "skills")) && !skillsInstalled(cwd);
132
+ installSkills = await askYesNo(rl, "Install agent skills (tickmarkr-loop/tickmarkr-auto)?", skillsDef);
100
133
  }
101
134
  return { overlay: { driver, concurrency, visibility: { llm } }, installSkills };
102
135
  }
@@ -5,7 +5,15 @@ import { loadConfig } from "../../config/config.js";
5
5
  import { loadGraph } from "../../graph/graph.js";
6
6
  import { excludedChannels, exclusionLine } from "../../route/preference.js";
7
7
  import { route, RoutingError } from "../../route/router.js";
8
+ import { modelId } from "../../gates/review.js";
8
9
  import { loadRoutingProfile } from "../../run/journal.js";
10
+ const fleetCanCrossVendorReview = (channels) => {
11
+ for (let i = 0; i < channels.length; i++)
12
+ for (let j = 0; j < channels.length; j++)
13
+ if (i !== j && channels[i].vendor !== channels[j].vendor && modelId(channels[i].model) !== modelId(channels[j].model))
14
+ return true;
15
+ return false;
16
+ };
9
17
  export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters()) {
10
18
  // ponytail: hardcoded 24h TTL — promote to config when an operator asks. mtime is the signal because
11
19
  // doctor.json has no probe timestamp and a schema field would break the existing-files compat invariant.
@@ -59,6 +67,9 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
59
67
  lints.push(`${role}: ${sel.adapter}:${sel.model} not installed — that gate/consult will fail closed`);
60
68
  }
61
69
  lints.push(...modelLints(cfg, health, adapters, { tty: ttyVisual() })); // health may be pre-v1.5/probeAll-fallback — no-detection branch covers both
70
+ if (cfg.review.required && channels.length && !fleetCanCrossVendorReview(channels)) {
71
+ lints.push("review: no cross-vendor reviewer pair in fleet — set review.required: false to waive");
72
+ }
62
73
  let cost = 0;
63
74
  for (const t of g.tasks) {
64
75
  try {
@@ -69,7 +80,7 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
69
80
  }
70
81
  const est = r.assignment.channel === "sub" ? 0 : cfg.pricing[r.assignment.tier];
71
82
  cost += est;
72
- lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} c${String(t.complexity).padEnd(3)}→ ${r.assignment.adapter}:${r.assignment.model} [${r.assignment.channel}/${r.assignment.tier}]${t.humanGate ? " (human gate)" : ""}${est ? ` ~$${est.toFixed(2)}` : ""} — ${r.provenance}`);
83
+ lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} c${String(t.complexity).padEnd(3)}→ ${r.assignment.adapter}:${r.assignment.model} [${r.assignment.channel}/${r.assignment.tier}]${t.timeoutMinutes !== undefined ? ` (timeout ${t.timeoutMinutes}m)` : ""}${t.humanGate ? " (human gate)" : ""}${est ? ` ~$${est.toFixed(2)}` : ""} — ${r.provenance}`);
73
84
  if (r.deviation) {
74
85
  deviations++;
75
86
  const d = r.deviation;
@@ -1 +1,4 @@
1
- export declare function resume(argv: string[], cwd?: string): Promise<string>;
1
+ export declare function resume(argv: string[], cwd?: string): Promise<{
2
+ out: string;
3
+ code: number;
4
+ }>;
@@ -2,10 +2,13 @@ import { loadConfig } from "../../config/config.js";
2
2
  import { pickDriver } from "../../drivers/index.js";
3
3
  import { formatSummary, runDaemon } from "../../run/daemon.js";
4
4
  import { formatJournalNarration } from "../../run/journal.js";
5
+ const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.blocked.length === 0 && s.pending.length === 0
6
+ && s.tipVerify !== "failed";
5
7
  export async function resume(argv, cwd = process.cwd()) {
6
8
  const runId = argv[0];
7
9
  if (!runId)
8
10
  throw new Error("usage: tickmarkr resume <run-id>");
9
11
  const s = await runDaemon(cwd, { runId, resume: true, driver: pickDriver(loadConfig(cwd)), narrate: (event) => console.log(formatJournalNarration(event)) });
10
- return `resumed ${s.runId} — ${formatSummary(s)}`;
12
+ const out = `resumed ${s.runId} — ${formatSummary(s)}`;
13
+ return { out, code: summaryGreen(s) ? 0 : 2 };
11
14
  }
@@ -1 +1,4 @@
1
- export declare function run(argv: string[], cwd?: string): Promise<string>;
1
+ export declare function run(argv: string[], cwd?: string): Promise<{
2
+ out: string;
3
+ code: number;
4
+ }>;
@@ -6,6 +6,8 @@ import { loadGraph } from "../../graph/graph.js";
6
6
  import { formatSummary, runDaemon } from "../../run/daemon.js";
7
7
  import { route } from "../../route/router.js";
8
8
  import { formatJournalNarration, loadRoutingProfile } from "../../run/journal.js";
9
+ const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.blocked.length === 0 && s.pending.length === 0
10
+ && s.tipVerify !== "failed";
9
11
  export async function run(argv, cwd = process.cwd()) {
10
12
  const { values } = parseArgs({
11
13
  args: argv,
@@ -15,6 +17,11 @@ export async function run(argv, cwd = process.cwd()) {
15
17
  "route-strict": { type: "boolean" },
16
18
  },
17
19
  });
20
+ if (values.concurrency !== undefined) {
21
+ const n = Number(values.concurrency);
22
+ if (!Number.isInteger(n) || n <= 0)
23
+ throw new Error(`--concurrency must be a positive integer (got ${values.concurrency})`);
24
+ }
18
25
  const cfg = loadConfig(cwd);
19
26
  if (values["route-strict"]) {
20
27
  const adapters = allAdapters();
@@ -31,5 +38,6 @@ export async function run(argv, cwd = process.cwd()) {
31
38
  driver: pickDriver(cfg, values.driver),
32
39
  narrate: (event) => console.log(formatJournalNarration(event)),
33
40
  });
34
- return `run ${s.runId} finished — ${formatSummary(s)} (merge to main is a human decision)`;
41
+ const out = `run ${s.runId} finished — ${formatSummary(s)} (merge to main is a human decision)`;
42
+ return { out, code: summaryGreen(s) ? 0 : 2 };
35
43
  }
@@ -0,0 +1 @@
1
+ export declare function version(_argv?: string[]): Promise<string>;
@@ -0,0 +1,8 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "../../../package.json");
5
+ export async function version(_argv = []) {
6
+ const { version: v } = JSON.parse(readFileSync(pkgPath, "utf8"));
7
+ return v;
8
+ }
@@ -1,5 +1,9 @@
1
1
  #!/usr/bin/env node
2
- export type CommandMap = Record<string, (argv: string[]) => Promise<string>>;
2
+ export type CommandResult = string | {
3
+ out: string;
4
+ code: number;
5
+ };
6
+ export type CommandMap = Record<string, (argv: string[]) => Promise<CommandResult>>;
3
7
  export declare const COMMANDS: CommandMap;
4
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\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 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
9
  export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
package/dist/cli/index.js CHANGED
@@ -13,9 +13,12 @@ import { run } from "./commands/run.js";
13
13
  import { scope } from "./commands/scope.js";
14
14
  import { status } from "./commands/status.js";
15
15
  import { unlock } from "./commands/unlock.js";
16
+ import { version } from "./commands/version.js";
17
+ const normalize = (r) => typeof r === "string" ? { out: r, code: 0 } : r;
16
18
  export const COMMANDS = {
17
- init, doctor, compile, scope, plan, run, status, resume, report, profile, unlock, approve,
19
+ init, doctor, compile, scope, plan, run, status, resume, report, profile, unlock, approve, version,
18
20
  };
21
+ const VERSION_FLAGS = new Set(["version", "--version", "-v"]);
19
22
  const HELP_CMDS = new Set(["help", "-h", "--help"]);
20
23
  import { BANNER } from "../brand.js";
21
24
  export const USAGE = `tickmarkr — spec-driven orchestration harness for AI coding agents
@@ -36,6 +39,8 @@ usage: tickmarkr <command>
36
39
  // unknown/missing cmd → USAGE (exit 1 if a cmd was typed, 0 for bare `tickmarkr`); a handler throw becomes
37
40
  // a one-line `tickmarkr <cmd>: <message>` (never a raw stack) at exit 1.
38
41
  export async function dispatch(cmd, argv, commands = COMMANDS) {
42
+ if (cmd && VERSION_FLAGS.has(cmd))
43
+ return { out: await version(argv), code: 0 };
39
44
  const usage = process.stdout.isTTY ? BANNER + USAGE : USAGE;
40
45
  if (!cmd || HELP_CMDS.has(cmd))
41
46
  return { out: usage, code: 0 };
@@ -43,7 +48,7 @@ export async function dispatch(cmd, argv, commands = COMMANDS) {
43
48
  if (!fn)
44
49
  return { out: usage, code: 1 };
45
50
  try {
46
- return { out: await fn(argv), code: 0 };
51
+ return normalize(await fn(argv));
47
52
  }
48
53
  catch (err) {
49
54
  return { out: `tickmarkr ${cmd}: ${err.message}`, code: 1 };
@@ -62,7 +67,7 @@ if (argv1Real && import.meta.url === pathToFileURL(argv1Real).href) {
62
67
  const [cmd, ...argv] = process.argv.slice(2);
63
68
  dispatch(cmd, argv).then(({ out, code }) => {
64
69
  // byte-identical streams: usage + success → stdout; a handler throw → stderr (original behavior)
65
- (out.endsWith(USAGE) || code === 0 ? console.log : console.error)(out);
70
+ (code === 1 && !out.endsWith(USAGE) ? console.error : console.log)(out);
66
71
  if (code !== 0)
67
72
  process.exit(code);
68
73
  });
@@ -10,7 +10,7 @@ const NESTED_RE = /^\s+- (.+)$/;
10
10
  // v1.19: a typed acceptance oracle line — "command: ...", "test: ...", or "judge: ...". Anything
11
11
  // without one of these prefixes is a plain-string judge criterion (compat path, emits a warning).
12
12
  const ORACLE_RE = new RegExp(`^(${ORACLES.join("|")}):\\s*(.*)$`);
13
- const FIELDS = new Set(["goal", "shape", "deps", "files", "context", "complexity", "humangate", "pin", "floor", "gates", "acceptance"]);
13
+ const FIELDS = new Set(["goal", "shape", "deps", "files", "context", "complexity", "humangate", "pin", "floor", "gates", "acceptance", "timeout"]);
14
14
  function invalid(task, field, detail) {
15
15
  throw new CompileError(`Task ${task} field "${field}" ${detail}`);
16
16
  }
@@ -110,6 +110,10 @@ export function compileNative(file) {
110
110
  const badGate = draft.gates.find((gate) => !GATE_NAMES.includes(gate));
111
111
  if (badGate)
112
112
  invalid(draft.id, "gates", `contains invalid gate "${badGate}"`);
113
+ const timeoutMinutes = fields.timeout === undefined ? undefined : Number(fields.timeout);
114
+ if (fields.timeout !== undefined && (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0)) {
115
+ invalid(draft.id, "timeout", "must be a positive number");
116
+ }
113
117
  const routingHints = pin || fields.floor ? {
114
118
  ...(pin ? { pin: { via: pin[0], model: pin[1] } } : {}),
115
119
  ...(fields.floor ? { floor: fields.floor } : {}),
@@ -125,6 +129,7 @@ export function compileNative(file) {
125
129
  context: csv(fields.context),
126
130
  acceptance: draft.acceptance,
127
131
  ...(fields.humangate === "true" ? { humanGate: true } : {}),
132
+ ...(timeoutMinutes !== undefined ? { timeoutMinutes } : {}),
128
133
  ...(routingHints ? { routingHints } : {}),
129
134
  ...(draft.hasGates ? { gates: draft.gates } : {}),
130
135
  };
@@ -166,7 +171,8 @@ acceptance is required on every task (a nested list of observable outcomes).
166
171
  humanGate: true | false — pauses for a human review before merging this task
167
172
  pin: "via model" — pin an exact channel, e.g. "claude-code opus"
168
173
  floor: cheap | mid | frontier — minimum capability tier for routing
169
- gates: nested list, any of build | test | lint | evidence | scope | acceptance | review
174
+ gates: nested list; build | test | lint | evidence | scope are mandatory;
175
+ acceptance | review may be omitted
170
176
  acceptance: nested list (REQUIRED, non-empty). Each item is either a typed oracle or plain text:
171
177
  - command: <shell> (oracle: command — exit code)
172
178
  - test: <name> (oracle: test — named test)
@@ -6,6 +6,7 @@ declare const TierEnum: z.ZodEnum<{
6
6
  }>;
7
7
  export type Tier = z.infer<typeof TierEnum>;
8
8
  export declare const TIER_RANK: Record<Tier, number>;
9
+ export declare const DEFAULT_DIFF_CAP = 60000;
9
10
  export declare const MapEntrySchema: z.ZodObject<{
10
11
  pin: z.ZodOptional<z.ZodObject<{
11
12
  via: z.ZodString;
@@ -84,6 +85,7 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
84
85
  halfLifeRuns: z.ZodOptional<z.ZodNumber>;
85
86
  availWeight: z.ZodOptional<z.ZodNumber>;
86
87
  }, z.core.$strip>>;
88
+ allowUnverifiedModels: z.ZodBoolean;
87
89
  allow: z.ZodOptional<z.ZodObject<{
88
90
  adapters: z.ZodOptional<z.ZodArray<z.ZodString>>;
89
91
  models: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -123,6 +125,7 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
123
125
  build: z.ZodOptional<z.ZodString>;
124
126
  test: z.ZodOptional<z.ZodString>;
125
127
  lint: z.ZodOptional<z.ZodString>;
128
+ diffCap: z.ZodOptional<z.ZodNumber>;
126
129
  byShape: z.ZodOptional<z.ZodOptional<z.ZodRecord<z.ZodEnum<{
127
130
  plan: "plan";
128
131
  spec: "spec";
@@ -9,6 +9,7 @@ const TierEnum = z.enum(TIERS, {
9
9
  error: (iss) => `Invalid option: expected one of "cheap"|"mid"|"frontier" (got ${JSON.stringify(iss.input)})`,
10
10
  });
11
11
  export const TIER_RANK = { cheap: 0, mid: 1, frontier: 2 };
12
+ export const DEFAULT_DIFF_CAP = 60_000;
12
13
  export const MapEntrySchema = z.object({
13
14
  pin: z.object({ via: z.string(), model: z.string() }).optional(),
14
15
  tier: TierEnum.optional(),
@@ -86,6 +87,8 @@ export const TickmarkrConfigSchema = z.object({
86
87
  halfLifeRuns: z.number().positive().optional(),
87
88
  availWeight: z.number().nonnegative().optional(),
88
89
  }).optional(),
90
+ // Pre-v1.21 doctor.json lacks per-model verdicts. Keep legacy unknown-is-routable behavior opt-in.
91
+ allowUnverifiedModels: z.boolean(),
89
92
  allow: PrefBlockSchema.optional(),
90
93
  deny: PrefBlockSchema.optional(),
91
94
  }),
@@ -105,6 +108,7 @@ export const TickmarkrConfigSchema = z.object({
105
108
  build: z.string(),
106
109
  test: z.string(),
107
110
  lint: z.string(),
111
+ diffCap: z.number().int().positive(),
108
112
  byShape: z.partialRecord(z.enum(SHAPES), ShapeGateParticipationSchema).optional(),
109
113
  }).partial(),
110
114
  scope: z.object({
@@ -160,6 +164,7 @@ export const DEFAULT_CONFIG = {
160
164
  // a workspace that has accumulated ≥MIN_SAMPLES warm telemetry per cell. Preview any workspace's effect
161
165
  // first with `tickmarkr plan` / `tickmarkr report`; flip to "off" to pin exact static routing (the kill switch stands).
162
166
  learned: "on",
167
+ allowUnverifiedModels: false,
163
168
  },
164
169
  // Seed table (spec §13). New models = edit this (or your config.yaml), never code.
165
170
  tiers: {
@@ -230,7 +235,7 @@ export const DEFAULT_CONFIG = {
230
235
  },
231
236
  },
232
237
  pricing: { cheap: 0.1, mid: 0.5, frontier: 2.5 },
233
- gates: {},
238
+ gates: { diffCap: DEFAULT_DIFF_CAP },
234
239
  judge: { adapter: "claude-code", model: "fable" },
235
240
  review: { complexityThreshold: 7, required: true },
236
241
  consult: { adapter: "claude-code", model: "fable", stallMinutes: 15 },
@@ -316,6 +321,7 @@ export function configTemplate(overlay) {
316
321
  # scope:
317
322
  # allowDeviations: [] # globs an operator permits out-of-scope edits into, e.g. ["package-lock.json"]
318
323
  # gates: # override auto-detected commands; per-shape participation may only skip LLM gates
324
+ # diffCap: 60000 # fail closed before an LLM gate; split the task or raise this positive integer
319
325
  # test: npm test
320
326
  # byShape:
321
327
  # docs: { acceptance: false, review: false } # baseline, evidence, and scope are mandatory
@@ -52,6 +52,7 @@ export class SubprocessDriver {
52
52
  cwd: slot.cwd,
53
53
  stdio: ["ignore", "pipe", "pipe"],
54
54
  env: sealHerdrEnv(process.env),
55
+ detached: true,
55
56
  });
56
57
  s.proc = p;
57
58
  p.stdout.on("data", (d) => (s.buf = (s.buf + d).slice(-MAX_BUF)));
@@ -96,8 +97,14 @@ export class SubprocessDriver {
96
97
  }
97
98
  async close(slot) {
98
99
  const s = this.slots.get(slot.id);
99
- if (s?.proc && !s.exited)
100
- s.proc.kill("SIGKILL");
100
+ if (s?.proc && !s.exited) {
101
+ try {
102
+ process.kill(-s.proc.pid, "SIGKILL");
103
+ }
104
+ catch {
105
+ s.proc.kill("SIGKILL");
106
+ }
107
+ }
101
108
  this.slots.delete(slot.id);
102
109
  }
103
110
  worktree(repo, branch, baseRef) {