tickmarkr 1.57.0 → 1.59.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
@@ -415,9 +415,15 @@ These are optional — the CLI works standalone. Skills are repo-scoped and ship
415
415
 
416
416
  ## Contributing
417
417
 
418
- See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the green bar (build/test/lint), and
418
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the green bar (build/test/lint), and
419
419
  design invariants. Pull requests welcome; non-trivial changes should include test coverage.
420
420
 
421
+ **Boundaries:** this repo is a squashed export of private development — each release is a verified
422
+ snapshot, not a live mirror of every commit. Before tickmarkr 2.0, minor versions may break with every
423
+ break noted in [CHANGELOG.md](CHANGELOG.md). Support is best effort for the latest version only;
424
+ accepted contributions are credited via `Co-authored-by:` on the release commit. Details in
425
+ [CONTRIBUTING.md](CONTRIBUTING.md).
426
+
421
427
  ## Documentation
422
428
 
423
429
  - **[LICENSE](LICENSE)** — MIT license
@@ -1,16 +1,33 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { readFileSync } from "node:fs";
2
+ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { probeVersion } from "./claude-code.js";
6
6
  import { parseWorkerResult } from "./prompt.js";
7
- import { channelsFromConfig, MODEL_ID_RE, shq } from "./types.js";
8
- // KIMI-03: collectUsage is DELIBERATELY ABSENT documented won't-implement, consistent with the
9
- // metering-honesty invariant (adapters/types.ts). Sessions live under ~/.kimi-code/sessions/ with
10
- // kimi export (ZIP) + session_index.jsonl; no harness-readable per-attempt token counter was found
11
- // in the session store (research F-6, 2026-07-17). Consequence: kimi channels report honestly
12
- // `unmetered` same class as grok (grok.ts:11-39). Revisit if kimi ships usage in the
13
- // `-p --output-format stream-json` envelope or a counter in the session store.
7
+ import { channelsFromConfig, MODEL_ID_RE, shq, TokenUsageSchema } from "./types.js";
8
+ // KIMI-03 → v1.58 T5: the "no harness-readable counter" block (research F-6, 2026-07-17) is
9
+ // LIFTED for collectUsage — kimi 0.27.0 writes a wire journal per agent at
10
+ // ~/.kimi-code/sessions/<wd>/session_<uuid>/agents/<agent>/wire.jsonl, and ~/.kimi-code/
11
+ // workspaces.json maps each wd_* dir to its realpath'd workspace root. Field mapping pinned ONLY
12
+ // after live verification of the real store (2026-07-18, 53 wire files / 178 usage rows, zero
13
+ // tokens spent):
14
+ // - the canonical row is top-level `{"type":"usage.record","usage":{...},"usageScope":"turn",
15
+ // "time":<epoch ms>}` — 178/178 rows carried scope "turn" and the identical key set
16
+ // {inputOther, output, inputCacheRead, inputCacheCreation};
17
+ // - per-turn DELTA semantics verified arithmetically: each turn's inputCacheRead ≈ prior
18
+ // inputCacheRead + inputOther + output (the grown context re-read from cache — claude's
19
+ // per-message convention), and inputOther/output are non-monotonic per-turn counts, so
20
+ // SUMMING rows is the correct fold (never a cumulative 3A+2B+C trap);
21
+ // - the SAME usage object is echoed inside the step.end loop event
22
+ // (`type:"context.append_loop_event"`, event.usage) — folding anything but usage.record
23
+ // rows double-counts every turn, hence the exact type+scope pin below;
24
+ // - mapping: inputOther→input, output→output, inputCacheRead→cacheRead,
25
+ // inputCacheCreation→cacheWrite (creation observed live only as 0; mapped by name).
26
+ // Absent or ambiguous usage resolves to UNMETERED, never an invented count: unknown usageScope,
27
+ // non-numeric core fields, torn lines, or no post-cursor row all skip/return undefined — the
28
+ // "?? 0" poisoning class stays banned. contextUsage remains absent (resumeUnknownContext below).
29
+ const MAX_SESSION_FILES = 20;
30
+ const MAX_SESSION_BYTES = 8_000_000;
14
31
  const CREDENTIALS_PATH = join(homedir(), ".kimi-code", "credentials", "kimi-code.json");
15
32
  // KIMI-01. Auth verdict from ~/.kimi-code/credentials/kimi-code.json ONLY — flat JSON, never a
16
33
  // network call. expires_at is epoch SECONDS (not ISO like grok). Non-empty refresh_token dominates
@@ -101,6 +118,102 @@ export const kimi = {
101
118
  return { command: this.headlessCommand(ctx.promptFile, a.model) };
102
119
  },
103
120
  parse: parseKimiResult,
121
+ collectUsage(cwd, sinceMs) {
122
+ try {
123
+ const real = realpathSync(cwd);
124
+ const home = join(homedir(), ".kimi-code");
125
+ const ws = JSON.parse(readFileSync(join(home, "workspaces.json"), "utf8"));
126
+ // a recreated workspace gets a new wd_* hash for the same root — fold every match
127
+ const wdDirs = Object.entries(ws.workspaces ?? {})
128
+ .filter(([, v]) => v && typeof v === "object" && v.root === real)
129
+ .map(([k]) => k);
130
+ // newest-first by mtime, bounded — mtime picks WHICH files to scan, never a record's cursor
131
+ const files = [];
132
+ for (const wd of wdDirs) {
133
+ const wdPath = join(home, "sessions", wd);
134
+ let sessions;
135
+ try {
136
+ sessions = readdirSync(wdPath);
137
+ }
138
+ catch {
139
+ continue;
140
+ }
141
+ for (const s of sessions) {
142
+ if (!s.startsWith("session_"))
143
+ continue;
144
+ const agentsPath = join(wdPath, s, "agents");
145
+ let agents;
146
+ try {
147
+ agents = readdirSync(agentsPath);
148
+ }
149
+ catch {
150
+ continue;
151
+ }
152
+ for (const a of agents) {
153
+ const p = join(agentsPath, a, "wire.jsonl");
154
+ try {
155
+ files.push({ path: p, m: statSync(p).mtimeMs });
156
+ }
157
+ catch {
158
+ continue;
159
+ }
160
+ }
161
+ }
162
+ }
163
+ files.sort((a, b) => b.m - a.m);
164
+ let input = 0, output = 0, kept = false;
165
+ let cacheRead, cacheWrite;
166
+ for (const { path: fp } of files.slice(0, MAX_SESSION_FILES)) {
167
+ let text;
168
+ try {
169
+ text = readFileSync(fp, "utf8").slice(0, MAX_SESSION_BYTES);
170
+ }
171
+ catch {
172
+ continue;
173
+ }
174
+ for (const line of text.split("\n")) {
175
+ if (!line.trim())
176
+ continue;
177
+ let recRaw;
178
+ try {
179
+ recRaw = JSON.parse(line);
180
+ }
181
+ catch {
182
+ continue;
183
+ }
184
+ const rec = recRaw;
185
+ // exact type+scope pin: the step.end loop event ECHOES the same usage (double-count),
186
+ // and any scope other than the live-verified "turn" has unknown fold semantics — skip
187
+ if (rec.type !== "usage.record" || rec.usageScope !== "turn")
188
+ continue;
189
+ if (typeof rec.time !== "number" || !Number.isFinite(rec.time) || rec.time < sinceMs)
190
+ continue;
191
+ const u = rec.usage;
192
+ if (!u || typeof u !== "object")
193
+ continue;
194
+ const uu = u;
195
+ // ambiguous row (core fields missing/non-numeric) resolves to unmetered, never 0
196
+ if (typeof uu.inputOther !== "number" || typeof uu.output !== "number")
197
+ continue;
198
+ input += uu.inputOther;
199
+ output += uu.output;
200
+ if (typeof uu.inputCacheRead === "number")
201
+ cacheRead = (cacheRead ?? 0) + uu.inputCacheRead;
202
+ if (typeof uu.inputCacheCreation === "number")
203
+ cacheWrite = (cacheWrite ?? 0) + uu.inputCacheCreation;
204
+ kept = true;
205
+ }
206
+ }
207
+ if (!kept)
208
+ return undefined; // nothing matched ⇒ unmetered, never {input:0,…}
209
+ const out = { input, output, ...(cacheRead !== undefined ? { cacheRead } : {}), ...(cacheWrite !== undefined ? { cacheWrite } : {}) };
210
+ const p = TokenUsageSchema.safeParse(out);
211
+ return p.success ? p.data : undefined;
212
+ }
213
+ catch {
214
+ return undefined; // missing home/workspaces.json / any throw ⇒ fail open
215
+ }
216
+ },
104
217
  listModels: async () => {
105
218
  const r = spawnSync("kimi", ["provider", "list", "--json"], { encoding: "utf8", timeout: 15000 });
106
219
  return r.error || r.status !== 0 ? [] : parseKimiModels(r.stdout || "");
@@ -1,5 +1,6 @@
1
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { createInterface } from "node:readline/promises";
4
5
  import { parseArgs } from "node:util";
5
6
  import { allAdapters, formatDoctorAgeForInit, formatDoctorReport, initDoctorReuse } from "../../adapters/registry.js";
@@ -61,7 +62,7 @@ function nextSteps(cwd, scaffoldedSpec) {
61
62
  return `next: edit ${scaffoldedSpec}, then tickmarkr compile ${scaffoldedSpec} && tickmarkr plan && tickmarkr run`;
62
63
  }
63
64
  const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
64
- const AGENT_SKILLS = ["tickmarkr-loop", "tickmarkr-auto"];
65
+ const AGENT_SKILLS = ["tickmarkr-loop", "tickmarkr-auto", "tickmarkr-overseer"];
65
66
  const DOCS_BEGIN = "<!-- tickmarkr:agent-docs begin -->";
66
67
  const DOCS_END = "<!-- tickmarkr:agent-docs end -->";
67
68
  const AGENT_DOCS = `${DOCS_BEGIN}
@@ -110,9 +111,16 @@ A run is green only when the run-end event exists in the journal AND tip verify
110
111
  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.
111
112
  ${DOCS_END}
112
113
  `;
113
- const skillsRoot = (cwd) => existsSync(join(cwd, ".claude", "skills")) ? join(cwd, ".claude", "skills") : join(cwd, ".agents", "skills");
114
- const skillPath = (cwd, skill) => join(skillsRoot(cwd), skill, "SKILL.md");
115
- const skillsInstalled = (cwd) => AGENT_SKILLS.every((s) => existsSync(skillPath(cwd, s)));
114
+ // Every applicable host location gets the skills, each paired with its own repository guidance
115
+ // file: codex discovers .agents/skills + AGENTS.md (always applicable); claude discovers
116
+ // .claude/skills + CLAUDE.md (applicable when the repo already shows claude usage).
117
+ const hostTargets = (cwd) => {
118
+ const targets = [{ skillsDir: join(cwd, ".agents", "skills"), docPath: join(cwd, "AGENTS.md") }];
119
+ if (existsSync(join(cwd, ".claude")) || existsSync(join(cwd, "CLAUDE.md")))
120
+ targets.push({ skillsDir: join(cwd, ".claude", "skills"), docPath: join(cwd, "CLAUDE.md") });
121
+ return targets;
122
+ };
123
+ const skillsInstalled = (cwd) => hostTargets(cwd).every((t) => AGENT_SKILLS.every((s) => existsSync(join(t.skillsDir, s, "SKILL.md"))));
116
124
  const wizardDriverDefault = () => process.env.HERDR_ENV === "1" ? "herdr" : "auto";
117
125
  async function installAgentFiles(cwd, force, docs, notes) {
118
126
  const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
@@ -124,31 +132,30 @@ async function installAgentFiles(cwd, force, docs, notes) {
124
132
  return /^(?:y|yes)$/i.test((await prompt.question(`${question} [y/N] `)).trim());
125
133
  };
126
134
  try {
127
- for (const skill of AGENT_SKILLS) {
128
- const dest = skillPath(cwd, skill);
129
- const exists = existsSync(dest);
130
- if (exists && !force && !(await confirm(`Overwrite ${dest}?`))) {
131
- notes.push(`skipped existing ${dest}; pass --force to overwrite it`);
132
- continue;
135
+ for (const { skillsDir, docPath } of hostTargets(cwd)) {
136
+ for (const skill of AGENT_SKILLS) {
137
+ const dest = join(skillsDir, skill, "SKILL.md");
138
+ const exists = existsSync(dest);
139
+ if (exists && !force && !(await confirm(`Overwrite ${dest}?`))) {
140
+ notes.push(`skipped existing ${dest}; pass --force to overwrite it`);
141
+ continue;
142
+ }
143
+ // whole skill dir, not just SKILL.md — the overseer ships its pane-watcher script
144
+ cpSync(fileURLToPath(new URL(`../../../skills/${skill}`, import.meta.url)), join(skillsDir, skill), { recursive: true });
145
+ notes.push(`${exists ? "overwrote" : "wrote"} ${dest}`);
146
+ }
147
+ const current = existsSync(docPath) ? readFileSync(docPath, "utf8") : "";
148
+ if (current.includes(DOCS_BEGIN) || current.includes(`<!-- ${LEGACY_PREFIX}:agent-docs begin -->`)
149
+ || current.includes(DOCS_END) || current.includes(`<!-- ${LEGACY_PREFIX}:agent-docs end -->`)) {
150
+ notes.push(`kept existing tickmarkr agent docs in ${docPath}`);
151
+ }
152
+ else if (docs || await confirm(`Append tickmarkr agent docs to ${docPath}?`)) {
153
+ appendFileSync(docPath, `${current ? current.endsWith("\n") ? "\n" : "\n\n" : ""}${AGENT_DOCS}`);
154
+ notes.push(`appended tickmarkr agent docs to ${docPath}`);
155
+ }
156
+ else {
157
+ notes.push(`skipped agent docs for ${docPath}; pass --docs to append them`);
133
158
  }
134
- mkdirSync(join(skillsRoot(cwd), skill), { recursive: true });
135
- writeFileSync(dest, readFileSync(new URL(`../../../skills/${skill}/SKILL.md`, import.meta.url)), exists ? undefined : { flag: "wx" });
136
- notes.push(`${exists ? "overwrote" : "wrote"} ${dest}`);
137
- }
138
- const claude = join(cwd, "CLAUDE.md");
139
- const agents = join(cwd, "AGENTS.md");
140
- const docPath = existsSync(claude) || !existsSync(agents) ? claude : agents;
141
- const current = existsSync(docPath) ? readFileSync(docPath, "utf8") : "";
142
- if (current.includes(DOCS_BEGIN) || current.includes(`<!-- ${LEGACY_PREFIX}:agent-docs begin -->`)
143
- || current.includes(DOCS_END) || current.includes(`<!-- ${LEGACY_PREFIX}:agent-docs end -->`)) {
144
- notes.push(`kept existing tickmarkr agent docs in ${docPath}`);
145
- }
146
- else if (docs || await confirm(`Append tickmarkr agent docs to ${docPath}?`)) {
147
- appendFileSync(docPath, `${current ? current.endsWith("\n") ? "\n" : "\n\n" : ""}${AGENT_DOCS}`);
148
- notes.push(`appended tickmarkr agent docs to ${docPath}`);
149
- }
150
- else {
151
- notes.push(`skipped agent docs for ${docPath}; pass --docs to append them`);
152
159
  }
153
160
  }
154
161
  finally {
@@ -183,7 +190,7 @@ async function runInitWizard(cwd) {
183
190
  let installSkills = false;
184
191
  if (!skillsInstalled(cwd)) {
185
192
  const skillsDef = existsSync(join(cwd, ".claude", "skills")) && !skillsInstalled(cwd);
186
- installSkills = await askYesNo(rl, "Install agent skills (tickmarkr-loop/tickmarkr-auto)?", skillsDef);
193
+ installSkills = await askYesNo(rl, "Install agent skills (tickmarkr-loop/tickmarkr-auto/tickmarkr-overseer)?", skillsDef);
187
194
  }
188
195
  return { overlay: { driver, concurrency, visibility: { llm } }, installSkills };
189
196
  }
@@ -67,6 +67,43 @@ function ladderFor(task, entry) {
67
67
  const escalate = task.routingHints?.escalate ?? entry?.escalate ?? true;
68
68
  return escalate ? ["retry", "escalate", "consult", "human"] : ["retry", "consult", "human"];
69
69
  }
70
+ // v1.58 frontier spread (operator ruling .planning/rulings/2026-07-18-frontier-spread-credits.md):
71
+ // every sub channel marginal-cost-ranks 0, so tier-equal frontier ties fell through all sort keys
72
+ // to discovery order — the first sub channel (claude-code:fable) served every frontier auto pick,
73
+ // and same-day quota exhaustion on that one channel interrupted two live pipeline agents. A
74
+ // deterministic task-keyed rotation now spreads each residual frontier tie across its whole tie
75
+ // group, so sol/k3-class channels serve frontier work as first-class candidates. Strictly the LAST
76
+ // tiebreak: it permutes only maximal runs already tied on every key above it (prefer band,
77
+ // marginal cost, tier — plus exploration bonus and learned score on the learned path), and only
78
+ // runs of zero-marginal-cost frontier SUB channels outside any prefer entry. Pins return upstream,
79
+ // denies filter upstream, prefer order is operator-explicit — none are touched.
80
+ // ponytail: frontier runs only — the ruled scope (ruling §1); spreading mid/cheap ties too = drop
81
+ // the tier guard. Rotation, not utilization: profile-driven spread would be inert exactly where
82
+ // the concentration bites (cold profiles, plan-time static routing, learned:off).
83
+ const spreadOffset = (task) => {
84
+ let h = 0;
85
+ for (const ch of `${task.id}\n${task.goal}`)
86
+ h = (h * 31 + ch.charCodeAt(0)) >>> 0;
87
+ return h;
88
+ };
89
+ function spreadFrontierTies(sorted, task, prefer, tied) {
90
+ const out = [...sorted];
91
+ const preferLen = prefer?.length ?? 0;
92
+ let i = 0;
93
+ while (i < out.length) {
94
+ let j = i + 1;
95
+ while (j < out.length && tied(out[i], out[j]))
96
+ j++;
97
+ const len = j - i;
98
+ if (len > 1 && out[i].tier === "frontier" && marginalCostRank(out[i]) === 0 && preferIndex(out[i], prefer) === preferLen) {
99
+ const run = out.slice(i, j);
100
+ const k = spreadOffset(task) % len;
101
+ out.splice(i, len, ...run.slice(k), ...run.slice(0, k));
102
+ }
103
+ i = j;
104
+ }
105
+ return out;
106
+ }
70
107
  function withoutExcluded(channels, exclude) {
71
108
  if (!exclude?.size)
72
109
  return channels;
@@ -174,8 +211,9 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
174
211
  for (const p of prefer ?? [])
175
212
  preflightPrefer(p);
176
213
  // key order is a contract: prefer > marginal cost > tier (cheapest sufficient) > learned score (v1.6 ROUTE-06)
177
- // > discovery order (same-tier fairness, D2). The learned score is the LAST key — it decides only the
178
- // discovery-order tail, never a pin/floor/prefer/cost/tier boundary (they all sort above it, ROUTE-08).
214
+ // > frontier spread (v1.58) > discovery order (same-tier fairness, D2). The learned score decides only the
215
+ // discovery-order tail, never a pin/floor/prefer/cost/tier boundary (they all sort above it, ROUTE-08);
216
+ // the spread rotates only what would otherwise fall to discovery order.
179
217
  const staticCmp = (a, b) => preferIndex(a, prefer) - preferIndex(b, prefer) || marginalCostRank(a) - marginalCostRank(b) || TIER_RANK[a.tier] - TIER_RANK[b.tier];
180
218
  const eligibleRaw = channels.filter((c) => TIER_RANK[c.tier] >= TIER_RANK[minTier]);
181
219
  if (!eligibleRaw.length) {
@@ -192,6 +230,11 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
192
230
  let eligible;
193
231
  let deviation;
194
232
  let learnedChosen = "";
233
+ let spreadDecided = false;
234
+ // v1.58: the spread is applied identically on both sort paths (and to the deviation baseline), so
235
+ // an empty/cold profile stays byte-identical to the 3-arg call (ROUTE-07) and a deviation always
236
+ // means learned evidence moved the pick — never the spread mislabeled as learning.
237
+ const spreadStatic = (sorted) => spreadFrontierTies(sorted, task, prefer, (a, b) => staticCmp(a, b) === 0);
195
238
  if (profile) {
196
239
  // scores precomputed ONCE over the eligible set — never inside the comparator (Pitfall 1).
197
240
  // ponytail: no epsilon — two finite scores subtract to a finite number (Phase 12 totality); an
@@ -210,7 +253,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
210
253
  const off = exploreOff(task, cfg, exploreCtx);
211
254
  const bonuses = new Map(eligibleRaw.map((c) => [channelKey(c), off ? 0 : explorationBonus(cellOf(profile, task.shape, channelKey(c), c.channel), cap)]));
212
255
  const bonusOf = (c) => bonuses.get(channelKey(c));
213
- const staticWinner = [...eligibleRaw].sort(staticCmp)[0];
256
+ const staticWinner = spreadStatic([...eligibleRaw].sort(staticCmp))[0];
214
257
  // Phase 34 ROUTE-17: prefer becomes a BAND + group-rep keys so exploration fires ACROSS prefer
215
258
  // entries, while intra-entry order (cost > tier > bonus > score) and every cold path stay byte-identical.
216
259
  // Cold reduces to preferIndex || cost || tier ≡ staticCmp (proof P1); rep keys use the group HEAD so
@@ -232,7 +275,11 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
232
275
  }));
233
276
  const kOf = (c) => keyOf.get(channelKey(c));
234
277
  const firstDiff = (a, b) => kOf(a).findIndex((v, i) => v !== kOf(b)[i]);
235
- eligible = [...eligibleRaw].sort((a, b) => { const i = firstDiff(a, b); return i === -1 ? 0 : kOf(a)[i] - kOf(b)[i]; });
278
+ const sortedByKey = [...eligibleRaw].sort((a, b) => { const i = firstDiff(a, b); return i === -1 ? 0 : kOf(a)[i] - kOf(b)[i]; });
279
+ // spread runs tied on the FULL learned key (bonus/score included) — a warm score or live probe
280
+ // bonus still decides its tie exactly as before; the spread rotates only the residual all-equal runs.
281
+ eligible = spreadFrontierTies(sortedByKey, task, prefer, (a, b) => firstDiff(a, b) === -1);
282
+ spreadDecided = channelKey(eligible[0]) !== channelKey(sortedByKey[0]);
236
283
  const w = eligible[0];
237
284
  const ru = channelKey(staticWinner) !== channelKey(w) ? staticWinner : eligible[1];
238
285
  // Phase 34 ROUTE-17: first-differing-key markers — probe when a rep or intra bonus key decided.
@@ -250,7 +297,9 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
250
297
  }
251
298
  }
252
299
  else {
253
- eligible = eligibleRaw.sort(staticCmp); // ROUTE-07/09: literally the v1.5 code path dead code cannot deviate
300
+ const sortedStatic = eligibleRaw.sort(staticCmp); // ROUTE-07/09: the v1.5 key order, spread applied as the last key
301
+ eligible = spreadStatic(sortedStatic);
302
+ spreadDecided = channelKey(eligible[0]) !== channelKey(sortedStatic[0]);
254
303
  }
255
304
  const bound = qualityBound(quality, advisoryFloor, taskFloorRaw) ??
256
305
  (taskFloor && TIER_RANK[taskFloor] >= TIER_RANK[baseTier] ? `floor ${taskFloor} (task hint${src})` :
@@ -261,8 +310,10 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
261
310
  const preferVia = preferFromAuto(task.shape, preferCtx)
262
311
  ? `via prefer (auto-modernized ${preferCtx.autoPrefer.derivedAt.slice(0, 10)})`
263
312
  : "via prefer";
264
- const chosenBy = learnedChosen || (prefer && preferIndex(eligible[0], prefer) < prefer.length
265
- ? preferVia : "cheapest sufficient tier");
313
+ // a spread-decided winner is never inside a prefer band (the spread skips those runs), so the
314
+ // three arms below are mutually exclusive by construction
315
+ const chosenBy = learnedChosen || (spreadDecided ? "via frontier spread"
316
+ : prefer && preferIndex(eligible[0], prefer) < prefer.length ? preferVia : "cheapest sufficient tier");
266
317
  maybeSlaLint(lints, task, profile, slaMinutes, eligible[0]);
267
318
  return { assignment: toAssignment(eligible[0]), ladder: ladderFor(task, entry), lints, provenance: `${degraded}${bound}, marginal-cost auto (${chosenBy})`, ...(deviation ? { deviation } : {}) };
268
319
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.57.0",
3
+ "version": "1.59.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,6 +31,7 @@
31
31
  "scripts": {
32
32
  "build": "tsc -p tsconfig.json",
33
33
  "lint": "oxlint src tests scripts",
34
+ "pretest": "npm run build",
34
35
  "test": "vitest run",
35
36
  "test:coverage": "vitest run --coverage",
36
37
  "schema": "tsx scripts/emit-schema.ts",
@@ -14,7 +14,9 @@ When working in a multi-agent terminal environment, decide your role before star
14
14
 
15
15
  - **Orchestrator:** your session was started to execute the mission. Rename your own tab/pane `ORCH · <version>` (short labels: ≤20 chars, `ROLE · token`) and run the loop below.
16
16
  - **Supervisor with a live orchestrator:** do not start a second run. Relay the mission to the existing orchestrator with a [verified handoff](#verified-handoffs-agent-to-agent-messaging), then supervise it as OVERSEER.
17
- - **Primary session without an orchestrator:** rename your own tab `OVERSEER · <version>` and your agent `overseer`, spawn one child orchestration session with `--permission-mode bypassPermissions`, label its tab `ORCH · <version>` and name its agent, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself. Before spawning, confirm any PREVIOUS orchestrator has stood down (monitors stopped, input box empty — dim ghost-text suggestions are UI, not queued input; ANSI-verify before alarming) and close its tab — the journal, records, and ledger hold the story; scrollback is disposable.
17
+ - **Primary session without an orchestrator:** rename your own tab `OVERSEER · <version>` and your agent `overseer`, spawn one child orchestration session with your host's launch form, label its tab `ORCH · <version>` and name its agent, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself. Before spawning, confirm any PREVIOUS orchestrator has stood down (monitors stopped, input box empty — dim ghost-text suggestions are UI, not queued input; ANSI-verify before alarming) and close its tab — the journal, records, and ledger hold the story; scrollback is disposable.
18
+ - **Claude Code:** `herdr agent start orchestrator --cwd <repo> --no-focus -- claude --permission-mode bypassPermissions`
19
+ - **Codex:** `herdr agent start orchestrator --cwd <repo> --no-focus -- codex --ask-for-approval never --sandbox workspace-write`
18
20
 
19
21
  Outside a multi-agent terminal environment, run the loop directly.
20
22
 
@@ -13,7 +13,9 @@ When working in a multi-agent terminal environment, decide your role before star
13
13
 
14
14
  - **Orchestrator:** your session was started to execute the mission. Rename your own tab/pane `ORCH · <version>` (short labels: ≤20 chars, `ROLE · token`) and run the loop below.
15
15
  - **Supervisor with a live orchestrator:** do not start a second run. Relay the mission to the existing orchestrator with a [verified handoff](#verified-handoffs-agent-to-agent-messaging), then supervise it as OVERSEER.
16
- - **Primary session without an orchestrator:** rename your own tab `OVERSEER · <version>` and your agent `overseer`, spawn one child orchestration session with `--permission-mode bypassPermissions`, label its tab `ORCH · <version>` and name its agent, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself. Before spawning, confirm any PREVIOUS orchestrator has [stood down](#stand-down-mission-end-and-retirement) and close its tab.
16
+ - **Primary session without an orchestrator:** rename your own tab `OVERSEER · <version>` and your agent `overseer`, spawn one child orchestration session with your host's launch form, label its tab `ORCH · <version>` and name its agent, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself. Before spawning, confirm any PREVIOUS orchestrator has [stood down](#stand-down-mission-end-and-retirement) and close its tab.
17
+ - **Claude Code:** `herdr agent start orchestrator --cwd <repo> --no-focus -- claude --permission-mode bypassPermissions`
18
+ - **Codex:** `herdr agent start orchestrator --cwd <repo> --no-focus -- codex --ask-for-approval never --sandbox workspace-write`
17
19
 
18
20
  Outside a multi-agent terminal environment, run the loop directly.
19
21
 
@@ -0,0 +1,110 @@
1
+ ---
2
+ name: tickmarkr-overseer
3
+ description: "Use when the user asks to oversee/supervise/babysit an autonomous tickmarkr run in a Herdr workspace (e.g. '/tickmarkr-overseer run the milestone', 'supervise this tickmarkr run', 'babysit this pipeline'). Requires HERDR_ENV=1. The skill argument is the mission (what to run end-to-end)."
4
+ ---
5
+
6
+ # Overseer (tickmarkr)
7
+
8
+ Become the OVERSEER for this workspace. Do no heavy work directly — build and supervise a two-tier
9
+ hierarchy of VISIBLE agents (you → orchestrator → tickmarkr's own worker fleet), and route human decisions
10
+ to the user with evidence.
11
+
12
+ The mission is the skill argument. If empty, ask the user what to run end-to-end before doing anything else.
13
+ Requires `HERDR_ENV=1`; if unset, say so and stop.
14
+
15
+ ## Setup
16
+
17
+ 0. **Adopt before you build.** If this workspace already has a supervision hierarchy — an
18
+ OVERSEER/ORCHESTRATOR tab, a live agent named `*orch*`, or a `<repo>/<state-dir>/overseer/` dir
19
+ (state dir = `.tickmarkr/`; legacy standalone `<repo>/.overseer/` counts
20
+ too) — do NOT spawn a duplicate (two orchestrators risk two concurrent tickmarkr runs in one repo,
21
+ which tickmarkr forbids). Read that dir's `DECISIONS.md` + `ORCH-BRIEF.md`, check the existing agents'
22
+ status, and either ADOPT the
23
+ existing orchestrator (updated brief, re-armed watchers) or, if the old hierarchy is dead, archive the
24
+ stale brief and build fresh.
25
+ 1. Load the `herdr` skill. `herdr pane list` to map the workspace — the focused pane is yours. Rename your
26
+ tab OVERSEER; create ONE tab ORCHESTRATOR.
27
+ **Live tab labels (standing operator rule, 2026-07-12):** on every decision or state change (role
28
+ handoff, task done/merged, run end) rename the affected tabs — and keep labels SHORT: the role as the
29
+ main name plus at most ONE hot-state token. Vocabulary: ORCH carries the milestone and progress
30
+ fraction (`ORCH · v1.19 4/5`, updated on every task-done); WORKERS carries the task token (tickmarkr
31
+ updates it). Never long context strings or ✓-chains.
32
+ 2. **Orchestrator**: Launch the orchestrator with your agent host. For Claude Code, use `herdr agent start orchestrator --cwd <repo> --no-focus -- claude --permission-mode bypassPermissions` (pin a strong model with `--model <m>` if the operator has a policy). For Codex, use `herdr agent start orchestrator --cwd <repo> --no-focus -- codex --ask-for-approval never --sandbox workspace-write` (add `--model <m>` to specify the model). Workers you never spawn — tickmarkr spawns its own visible worker panes.
33
+ 3. **Standing instructions travel as a brief FILE, never as pane text** — PTY input truncates at ~1024B and a
34
+ truncated brief silently drops policy. Write the full brief to `<repo>/.tickmarkr/overseer/ORCH-BRIEF.md`
35
+ (inside the tickmarkr state dir — already self-gitignored, no exclude step needed), then send one line:
36
+ `herdr pane run <orch> "Read .tickmarkr/overseer/ORCH-BRIEF.md and follow it exactly."` The brief MUST contain: the
37
+ mission, the pane mechanics below, rules 1–2, and require a verbatim one-sentence acknowledgment of the
38
+ human-checkpoint rule before anything is dispatched. Delete the dir at mission end.
39
+ 4. Arm the watcher (Supervision). Report the hierarchy map (pane ids + names) to the user.
40
+
41
+ ## Supervising tickmarkr as the executor
42
+
43
+ When the mission runs `/tickmarkr-auto` (tickmarkr dispatches the workers), supervision changes shape:
44
+
45
+ - **Give the run a live surface.** `tickmarkr run` is stdout-silent until run-end by design — split a pane in the
46
+ ORCHESTRATOR tab running `tickmarkr status --watch`. Narration also arrives as herdr notifications.
47
+ - **Watch the journal, not the panes.** The append-only journal
48
+ (`.tickmarkr/runs/<runId>/journal.jsonl`) is the
49
+ source of truth. Arm a background watcher on `run-end` / `task-human` / `task-failed` / `consult-verdict`
50
+ events; never sleep-poll inside an agent turn.
51
+ - **Daemon liveness ≠ journal activity.** A dead daemon emits no events, so journal watchers sleep through
52
+ its death. `tickmarkr status` prints last-event age + daemon pid liveness; check it before diagnosing a stall.
53
+ Recovery is `tickmarkr resume <runId>` — crash-safe by design (journal replay restores attempt counts and
54
+ consult channel bans).
55
+ - **Gate quiet ≠ idle.** Between `worker-result` and the batched `gate-result`s tickmarkr runs shell gates plus a
56
+ headless LLM judge/review with little visible signal — check the journal timestamps before intervening.
57
+ - **Classify gate failures before reacting.** The same fingerprint failing across DIFFERENT workers, or a
58
+ scope/test catch-22 (attempt N edits a file → scope gate fails; attempt N+1 leaves it → test gate fails),
59
+ is a PLAN defect: widen `files_modified` in the phase PLAN, recompile the phase dir after the run ends or
60
+ the task parks, release (`human → pending`), resume. A cross-vendor review rejection with concrete findings
61
+ is a REAL defect — let the escalation ladder work.
62
+ - **Dialog watchers go stale per attempt.** Every retry/escalation may spawn a new pane; re-arm dialog
63
+ watchers on each `task-dispatch` journal event.
64
+
65
+ ## Pane mechanics that bite
66
+
67
+ - **Verified send protocol**: `herdr agent send` writes WITHOUT Enter, and `pane run`'s Enter can be swallowed
68
+ by bracketed-paste on long payloads. Robust sequence: read the pane (bare prompt required) → send-text →
69
+ sleep 2–3s → send-keys Enter → read back (input empty / agent `working`). Never report "briefed" without
70
+ the read-back. Long content goes in a brief file, never pane text.
71
+ - **Guard-before-Enter** (race-safe prompt answering): chain with `&&` — pane get shows `blocked` && pane
72
+ read shows the expected option under the cursor && only then send-keys. If no longer `blocked`, someone
73
+ already answered; do nothing.
74
+ - `herdr wait agent-status` exits 1 on timeout, 0 on match — but ALSO 0 (with an error JSON) when the pane is
75
+ GONE. Never chain `wait && act` without confirming the pane exists.
76
+ - Stale typed input is unclearable via CLI — supersede it:
77
+ `pane run "<-- disregard everything before this arrow (stale draft). ACTUAL: <message>"`.
78
+
79
+ ## Supervision watcher
80
+
81
+ Arm the bundled watcher as its OWN Bash call with `run_in_background` — chaining it after other commands
82
+ with `&` orphans it from the wake chain. It prints one wake reason and exits; re-arm after every wake.
83
+
84
+ ```bash
85
+ .claude/skills/tickmarkr-overseer/scripts/watch-panes.sh WORKER_PANE ORCH_PANE [--fast-blocked]
86
+ ```
87
+
88
+ Default mode wakes only when both panes are quiet (dropped handoff) or the orchestrator blocks; the
89
+ orchestrator gets a 90s grace window to handle worker blocks first. For long parked stretches a targeted
90
+ `herdr wait agent-status <pane> --status <s> --timeout <ms>` beats the watcher. When parking a human
91
+ checkpoint, also fire `herdr notification show "HUMAN CHECKPOINT: <gate>" --sound request`.
92
+
93
+ ## Specialist pipeline rules
94
+
95
+ - **Dedicated consultant tab**: Consultants (agents spawned to gather synthesis input for decisions like SCOPER analysis or architectural reviews) must run in a DEDICATED tab separate from the ORCHESTRATOR tab. When the orchestrator stands down, the consultant panes should persist so their assessments remain available for review and reference.
96
+ - **Scoper worktree rule**: The SCOPER (or any worktree-based specialist synthesizing into the spec pipeline) must do ALL git operations in a dedicated worktree (e.g., `git worktree add /private/tmp/tkr-scoper-v155 -b spec/...`), never switching the main checkout's branch. This prevents race conditions between the specialist's branch operations and the orchestrator's shipping logic.
97
+
98
+ ## Non-negotiable rules
99
+
100
+ 1. **Takeover rule**: only act on a worker if it needs input AND the orchestrator is not `working`.
101
+ 2. **Human checkpoints (absolute)**: any gate marked `autonomous: false` or asking for product/visual
102
+ sign-off is NEVER auto-answered — regardless of how obviously correct the highlighted option looks. Leave
103
+ it blocked and bring the user the decision WITH evidence. If the mission explicitly delegates authority,
104
+ routine-class gates may be overseer-decided after polling the operator first — but spend and ship gates
105
+ NEVER self-decide.
106
+ 3. **Trust disk over transcripts**: verify artifacts on disk before building on them; a subagent killed
107
+ mid-flight still renders "Done" without writing its artifact.
108
+ 4. **Report concisely on every state change**: what happened, who handled it, what's next. Lead with the
109
+ outcome. Surface product decisions; never make them.
110
+ 5. **Log every abnormality** to `.planning/OBSERVATIONS.md` (or the project's ledger), even mid-run.
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env bash
2
+ # watch-panes.sh — Herdr overseer watcher.
3
+ # Polls worker + orchestrator agent_status via `herdr pane get`, prints ONE wake
4
+ # reason to stdout, then exits. Run it via the Bash tool with run_in_background
5
+ # so the overseer is re-invoked when it fires. Re-run (re-arm) after every wake.
6
+ #
7
+ # Usage:
8
+ # watch-panes.sh WORKER_PANE ORCH_PANE [--fast-blocked] [--cap-seconds N] [--settle-seconds N]
9
+ #
10
+ # Options:
11
+ # --fast-blocked Wake fast (20s debounce) whenever the WORKER blocks, even
12
+ # if the orchestrator is working. Use during stretches with
13
+ # human checkpoints. Without it, a blocked worker gets a 90s
14
+ # grace window for the orchestrator to handle it first
15
+ # (takeover rule), and only wakes if BOTH panes are quiet.
16
+ # --cap-seconds N Max watch duration (default 14400 = 4h).
17
+ # --settle-seconds N Initial sleep before polling (default 60) so a
18
+ # just-dispatched turn can start without an instant refire.
19
+ #
20
+ # Wake reasons printed:
21
+ # WORKER_PANE_GONE / ORCH_PANE_GONE — a pane disappeared
22
+ # ORCH:blocked WORKER:<s> — orchestrator itself needs input
23
+ # WORKER:blocked ORCH:<s> — worker blocked (fast-blocked mode)
24
+ # WORKER:<s> ORCH:<s> — both quiet: dropped handoff / done
25
+ # WATCH_CAP_REACHED ... — cap hit, everything still working
26
+ set -u
27
+ WORKER="${1:?worker pane id required}"
28
+ ORCH="${2:?orchestrator pane id required}"
29
+ shift 2
30
+ FAST_BLOCKED=false
31
+ CAP=14400
32
+ SETTLE=60
33
+ while [ $# -gt 0 ]; do
34
+ case "$1" in
35
+ --fast-blocked) FAST_BLOCKED=true ;;
36
+ --cap-seconds) CAP="$2"; shift ;;
37
+ --settle-seconds) SETTLE="$2"; shift ;;
38
+ esac
39
+ shift
40
+ done
41
+
42
+ get_status() {
43
+ herdr pane get "$1" 2>/dev/null | python3 -c '
44
+ import sys, json
45
+ try:
46
+ d = json.load(sys.stdin)
47
+ p = d.get("result", {}).get("pane", d.get("result", {}))
48
+ print(p.get("agent_status", "unknown"))
49
+ except Exception:
50
+ print("gone")
51
+ ' 2>/dev/null || echo gone
52
+ }
53
+
54
+ # Transient CLI/server failures also read as "gone" — recheck before declaring.
55
+ confirmed_gone() {
56
+ sleep 5
57
+ [ "$(get_status "$1")" = "gone" ]
58
+ }
59
+
60
+ END=$((SECONDS + CAP))
61
+ sleep "$SETTLE"
62
+ while [ $SECONDS -lt $END ]; do
63
+ WS=$(get_status "$WORKER")
64
+ OS=$(get_status "$ORCH")
65
+ if [ "$WS" = "gone" ]; then
66
+ confirmed_gone "$WORKER" && { echo "WORKER_PANE_GONE ORCH:$(get_status "$ORCH")"; exit 0; }
67
+ continue
68
+ fi
69
+ if [ "$OS" = "gone" ]; then
70
+ confirmed_gone "$ORCH" && { echo "ORCH_PANE_GONE WORKER:$(get_status "$WORKER")"; exit 0; }
71
+ continue
72
+ fi
73
+ # orchestrator itself stuck on a prompt (debounced)
74
+ if [ "$OS" = "blocked" ]; then
75
+ sleep 15
76
+ [ "$(get_status "$ORCH")" = "blocked" ] && { echo "ORCH:blocked WORKER:$WS"; exit 0; }
77
+ fi
78
+ # human-checkpoint mode: wake fast on any persisting worker block
79
+ if $FAST_BLOCKED && [ "$WS" = "blocked" ]; then
80
+ sleep 20
81
+ [ "$(get_status "$WORKER")" = "blocked" ] && { echo "WORKER:blocked ORCH:$(get_status "$ORCH")"; exit 0; }
82
+ fi
83
+ # standard mode: worker needs attention AND orchestrator is not driving
84
+ if [ "$WS" != "working" ]; then
85
+ sleep 90
86
+ WS2=$(get_status "$WORKER"); OS2=$(get_status "$ORCH")
87
+ if [ "$WS2" != "working" ] && [ "$OS2" != "working" ]; then
88
+ echo "WORKER:$WS2 ORCH:$OS2"; exit 0
89
+ fi
90
+ fi
91
+ sleep 20
92
+ done
93
+ echo "WATCH_CAP_REACHED WORKER:$(get_status "$WORKER") ORCH:$(get_status "$ORCH")"