tickmarkr 1.85.0 → 1.87.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.
Files changed (78) hide show
  1. package/README.md +4 -2
  2. package/dist/adapters/catalog-remote.d.ts +64 -0
  3. package/dist/adapters/catalog-remote.js +287 -0
  4. package/dist/adapters/catalog.d.ts +108 -0
  5. package/dist/adapters/catalog.js +189 -0
  6. package/dist/adapters/claude-code.js +5 -3
  7. package/dist/adapters/fake.js +33 -4
  8. package/dist/adapters/model-lints.d.ts +25 -5
  9. package/dist/adapters/model-lints.js +184 -50
  10. package/dist/adapters/model-windows.d.ts +31 -0
  11. package/dist/adapters/model-windows.js +69 -0
  12. package/dist/adapters/prompt.d.ts +5 -1
  13. package/dist/adapters/prompt.js +13 -4
  14. package/dist/adapters/registry.d.ts +34 -27
  15. package/dist/adapters/registry.js +215 -112
  16. package/dist/adapters/types.js +15 -3
  17. package/dist/brand.d.ts +5 -1
  18. package/dist/brand.js +18 -2
  19. package/dist/cli/commands/doctor.d.ts +3 -0
  20. package/dist/cli/commands/doctor.js +43 -21
  21. package/dist/cli/commands/fleet.d.ts +7 -0
  22. package/dist/cli/commands/fleet.js +94 -74
  23. package/dist/cli/commands/init.js +118 -5
  24. package/dist/cli/commands/plan.js +11 -1
  25. package/dist/cli/commands/resume.js +7 -1
  26. package/dist/cli/commands/status.js +44 -18
  27. package/dist/compile/gsd.d.ts +2 -1
  28. package/dist/compile/gsd.js +68 -2
  29. package/dist/compile/native.d.ts +14 -0
  30. package/dist/compile/native.js +168 -12
  31. package/dist/config/config.d.ts +20 -5
  32. package/dist/config/config.js +96 -64
  33. package/dist/config/fleet-overlay.d.ts +25 -20
  34. package/dist/config/fleet-overlay.js +195 -77
  35. package/dist/config/fleet-why.d.ts +23 -0
  36. package/dist/config/fleet-why.js +42 -0
  37. package/dist/drivers/herdr.d.ts +1 -0
  38. package/dist/drivers/herdr.js +70 -19
  39. package/dist/gates/acceptance.js +7 -2
  40. package/dist/gates/llm.d.ts +0 -1
  41. package/dist/gates/llm.js +5 -30
  42. package/dist/gates/review.d.ts +2 -1
  43. package/dist/gates/review.js +9 -7
  44. package/dist/gates/run-gates.d.ts +1 -0
  45. package/dist/gates/run-gates.js +21 -2
  46. package/dist/gates/verdict-cause.d.ts +4 -0
  47. package/dist/gates/verdict-cause.js +63 -0
  48. package/dist/graph/schema.d.ts +6 -0
  49. package/dist/graph/schema.js +8 -5
  50. package/dist/route/preference.d.ts +1 -1
  51. package/dist/route/preference.js +8 -1
  52. package/dist/route/router.d.ts +0 -5
  53. package/dist/route/router.js +16 -20
  54. package/dist/run/consult.d.ts +6 -0
  55. package/dist/run/consult.js +49 -26
  56. package/dist/run/daemon.js +81 -20
  57. package/dist/run/journal.js +87 -7
  58. package/dist/tui/cockpit/capture.d.ts +12 -0
  59. package/dist/tui/cockpit/capture.js +37 -1
  60. package/dist/tui/cockpit/components.js +8 -8
  61. package/dist/tui/cockpit/theme.d.ts +32 -26
  62. package/dist/tui/cockpit/theme.js +11 -5
  63. package/dist/tui/ink/components.d.ts +0 -15
  64. package/dist/tui/ink/components.js +0 -17
  65. package/dist/tui/ink/fleet-app.d.ts +4 -1
  66. package/dist/tui/ink/fleet-app.js +134 -13
  67. package/fixtures/gateway-models.json +1 -0
  68. package/fixtures/sample.native.md +1 -1
  69. package/package.json +1 -1
  70. package/skills/tickmarkr-overseer/SKILL.md +464 -34
  71. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +86 -0
  72. package/skills/tickmarkr-overseer/scripts/watch-panes.sh +1 -1
  73. package/dist/tui/ink/studio-app.d.ts +0 -59
  74. package/dist/tui/ink/studio-app.js +0 -320
  75. package/dist/tui/save.d.ts +0 -38
  76. package/dist/tui/save.js +0 -96
  77. package/dist/tui/staging.d.ts +0 -29
  78. package/dist/tui/staging.js +0 -78
@@ -9,6 +9,7 @@ import { foldActivity } from "../../run/activity.js";
9
9
  import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFailureKind, runHasEnded, } from "../../run/journal.js";
10
10
  import { normalizeStallSnapshot } from "../../run/stall.js";
11
11
  import { deriveRunCockpitData, } from "../../tui/cockpit/derive.js";
12
+ import { cellWidth, fitCells } from "../../tui/cockpit/width.js";
12
13
  // ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
13
14
  const REFRESH_MS = 2000;
14
15
  const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; resume with `--graph-changed` to audit this recompile";
@@ -132,6 +133,20 @@ const optionValue = (argv, name) => {
132
133
  const value = index >= 0 ? argv[index + 1] : undefined;
133
134
  return value && !value.startsWith("-") ? value : undefined;
134
135
  };
136
+ // The one positional this command takes: an explicit run id. Everything else is a flag or a flag's
137
+ // value (`--webhook <url>`), so the first bare token that is not a --webhook value is the run id.
138
+ const positionalRunId = (argv) => {
139
+ for (let i = 0; i < argv.length; i++) {
140
+ const arg = argv[i];
141
+ if (arg === "--webhook") {
142
+ i += 1;
143
+ continue;
144
+ }
145
+ if (!arg.startsWith("-"))
146
+ return arg;
147
+ }
148
+ return undefined;
149
+ };
135
150
  const defaultPostWebhook = (url, event) => fetch(url, {
136
151
  method: "POST",
137
152
  headers: { "content-type": "application/json" },
@@ -307,13 +322,18 @@ const failedSuffix = (states) => {
307
322
  // cell when present: a post-approval park is not awaiting the designed gate. Plain text for column
308
323
  // math; rendered with a dim dot + warn words. TTY-only — the non-TTY surface stays byte-pinned.
309
324
  export const humanGateSuffix = (t, st, states) => st === "human" && t.humanGate && failedGates(states).length === 0 ? " · awaiting approval" : "";
325
+ // Task titles are graph-authored data. Remove terminal controls and row-breaking whitespace before
326
+ // either width measurement or rendering so zero-cell ECMA-48 bytes cannot escape the task column.
327
+ const ECMA_48_CONTROL = /(?:\u001B\][\s\S]*?(?:\u0007|\u001B\\)|\u009D[\s\S]*?(?:\u0007|\u009C)|\u001B[P^_X][\s\S]*?\u001B\\|[\u0090\u0098\u009E\u009F][\s\S]*?\u009C|(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]|\u001B[@-_])/gu;
328
+ const sanitizeTaskText = (text) => text.replace(ECMA_48_CONTROL, "").replace(/\p{Cc}/gu, " ").replace(/\s+/gu, " ").trim();
310
329
  export const shortGoal = (goal, max) => {
311
- const clause = goal.split(/[,;.?!]/, 1)[0].trim();
312
- if (clause.length <= max)
330
+ const clause = sanitizeTaskText(goal).split(/[,;.?!]/, 1)[0].trim();
331
+ const cells = Math.max(0, Math.floor(max));
332
+ if (cellWidth(clause) <= cells)
313
333
  return clause;
314
- if (max <= 3)
315
- return clause.slice(0, Math.max(0, max));
316
- return `${clause.slice(0, max - 3).trimEnd()}...`;
334
+ if (cells <= 3)
335
+ return fitCells(clause, cells).trimEnd();
336
+ return `${fitCells(clause, cells - 3).trimEnd()}...`;
317
337
  };
318
338
  // VIS-11 (v1.13): a liveness header for renderFrame — last journal event age + whether the recorded
319
339
  // daemon pid is still alive. Honest about unknowns: a pre-v1.13 journal with no pid renders "unknown",
@@ -487,9 +507,11 @@ const parseJournalSnapshot = (raw) => raw.split("\n").flatMap((line) => {
487
507
  return [];
488
508
  }
489
509
  });
490
- const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness = new Map(), journalRowsOnly = false) => {
510
+ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness = new Map(), journalRowsOnly = false, namedRunId) => {
491
511
  const g = loadGraph(cwd);
492
- const runId = Journal.latestRunId(cwd, { withJournal: true });
512
+ // An explicit <runId> is a resolution, not a hint: Journal.open below refuses an id without a
513
+ // readable journal, so status fails loudly naming that id instead of rendering any other run.
514
+ const runId = namedRunId ?? Journal.latestRunId(cwd, { withJournal: true });
493
515
  const assignments = new Map();
494
516
  let replayed = null;
495
517
  let cockpit;
@@ -619,7 +641,7 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
619
641
  const chain = gateChain(states, false);
620
642
  const prefix = livePhase ? ` ${ASCII_SPINNER[animationFrame % ASCII_SPINNER.length]} ${t.id} ` : ` ${surfaceTaskBox(st, merged)} ${t.id} `;
621
643
  const suffix = ` ${chain}${priorGraph ? ` ${PRIOR_GRAPH_MARKER}` : ""} ${livePhase ? "running" : surfaceStatusWord(st)}${label} ${assignCol}`;
622
- return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
644
+ return `${prefix}${shortGoal(t.title, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
623
645
  });
624
646
  const zone = journalRowsOnly ? `${divider}zone ${localZoneLabel(zoneReference)}` : "";
625
647
  const header = runId
@@ -668,9 +690,9 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
668
690
  const nowLine = activity.now ? [legend(` now: ${activity.now}`)] : [];
669
691
  const gatesLegend = legend(` gates: ${GATE_NAMES.join(" · ")}`);
670
692
  // Two-line card per task (operator request, v1.67): line 1 carries identity + verdict — glyph,
671
- // id, goal at full width, and only SHORT status words. Line 2 carries the machinery, dim and
672
- // aligned under the goal: gate chain, live activity phrase (or channel), ctx. Long channel names
673
- // and activity phrases live on line 2 only, so they can never squeeze the goal or wrap line 1.
693
+ // id, title at full width, and only SHORT status words. Line 2 carries the machinery, dim and
694
+ // aligned under the title: gate chain, live activity phrase (or channel), ctx. Long channel names
695
+ // and activity phrases live on line 2 only, so they can never squeeze the title or wrap line 1.
674
696
  const taskVerdict = (c) => c.merged ? "pass" : c.redTier ? "fail" : c.st === "failed" || c.st === "human" ? "warn" : "neutral";
675
697
  const statusWord = (c) => c.livePhase ? "running" : c.redTier ? "failed" : c.st === "failed" ? "warn" : surfaceStatusWord(c.st);
676
698
  const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
@@ -679,9 +701,9 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
679
701
  + humanGateSuffix(c.t, graphTaskStatus(c.st, c.t.status), c.states);
680
702
  const stW = Math.max(...cells.map((c) => statusWord(c).length + suffixPlain(c).length));
681
703
  const avail = Math.max(8, width - (5 + idW) - 2 - stW);
682
- const goals = cells.map((c) => shortGoal(c.t.goal, avail));
683
- const goalW = Math.max(8, ...goals.map((s) => s.length));
684
- const indent = " ".repeat(idW + 5); // line 2 starts under the goal column
704
+ const titles = cells.map((c) => shortGoal(c.t.title, avail));
705
+ const titleW = Math.max(8, ...titles.map(cellWidth));
706
+ const indent = " ".repeat(idW + 5); // line 2 starts under the title column
685
707
  const rows = cells.map((c, i) => {
686
708
  const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, channel, ctx, livePhase } = c;
687
709
  const word = statusWord(c);
@@ -695,7 +717,8 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
695
717
  (isStarved ? dot + fail("starved") : "") +
696
718
  (f.length ? dot + fail(f.join(", ")) : "") +
697
719
  (human ? dot + warn("awaiting approval") : "");
698
- const taskLabel = `${t.id.padEnd(idW)} ${goals[i].padEnd(goalW)} ${statusCell}`;
720
+ const taskTitle = titles[i];
721
+ const taskLabel = `${t.id.padEnd(idW)} ${taskTitle}${" ".repeat(titleW - cellWidth(taskTitle))} ${statusCell}`;
699
722
  const line1 = livePhase
700
723
  ? ` ${(staleWorker ? warn : dim)(SPINNER[animationFrame % SPINNER.length])} ${taskLabel}`
701
724
  : ` ${statusRow(taskVerdict(c), taskLabel)}`;
@@ -718,9 +741,10 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
718
741
  };
719
742
  };
720
743
  export async function status(argv, cwd = process.cwd(), opts = {}) {
744
+ const namedRunId = positionalRunId(argv);
721
745
  // cockpit surface: banner + frame on a TTY (doctor's pattern); pipes get the bare frame
722
746
  if (!argv.includes("--watch")) {
723
- const { content } = renderFrame(cwd, opts.now?.() ?? Date.now());
747
+ const { content } = renderFrame(cwd, opts.now?.() ?? Date.now(), 0, undefined, false, namedRunId);
724
748
  return visual() ? BANNER + content : content;
725
749
  }
726
750
  const eventStream = argv.some((arg) => arg === "--events" || arg === "--jsonl" || arg === "--decision-events");
@@ -782,7 +806,9 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
782
806
  let decisionRunId;
783
807
  let journalCursor = 0;
784
808
  const consumeDecisionEvents = () => {
785
- const runId = Journal.latestRunId(cwd, { withJournal: true });
809
+ // A named run is followed, never re-resolved: --watch <runId> keeps reporting that run even as
810
+ // newer runs start. Only the no-argument form tracks latest, as before.
811
+ const runId = namedRunId ?? Journal.latestRunId(cwd, { withJournal: true });
786
812
  if (!runId)
787
813
  return [];
788
814
  if (decisionRunId !== runId) {
@@ -839,7 +865,7 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
839
865
  }
840
866
  }
841
867
  else {
842
- frame = renderFrame(cwd, nowMs, i, workerLiveness, true);
868
+ frame = renderFrame(cwd, nowMs, i, workerLiveness, true, namedRunId);
843
869
  if (tty) {
844
870
  updateTitle(frame.hotPhase, nowMs);
845
871
  process.stdout.write(`\x1b[2J\x1b[H${BANNER}${frame.content}\n${legend(` watching · refresh ${REFRESH_MS / 1000}s · ^C to quit`)}`);
@@ -1,3 +1,4 @@
1
- import { type RunGraph } from "../graph/schema.js";
1
+ import { type AcceptanceItem, type RunGraph } from "../graph/schema.js";
2
2
  export declare function isGsdPhaseDir(dir: string): boolean;
3
+ export declare function parseTruths(file: string, raw: unknown): AcceptanceItem[] | undefined;
3
4
  export declare function compileGsd(src: string, root?: string): RunGraph;
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { basename, dirname, isAbsolute, join, relative } from "node:path";
3
3
  import { parse as parseYaml } from "yaml";
4
- import { TIERS, validateGraph } from "../graph/schema.js";
4
+ import { AcceptanceItemSchema, TIERS, validateGraph } from "../graph/schema.js";
5
5
  import { CompileError, assertWriteScope, inferShape, sha256 } from "./common.js";
6
6
  // GSD artifact front-end (spec v1.3): one GSD *plan* is one tickmarkr *task* — a plan is
7
7
  // worktree-sized; its inner <task> steps stay in the worker prompt via context[0] = the plan file.
@@ -16,6 +16,72 @@ export function isGsdPhaseDir(dir) {
16
16
  }
17
17
  }
18
18
  const strings = (v) => (Array.isArray(v) ? v.map(String) : []);
19
+ // Cycle-safe by construction: a recursive YAML alias (`&a [*a]`) is a legal value the parser hands
20
+ // back as a circular object, and a bare JSON.stringify throws a native TypeError on it — which would
21
+ // REPLACE the indexed CompileError this module owes the author with an unrelated crash. Every value
22
+ // the YAML parser can produce renders here. (A repeated non-recursive alias also renders [circular];
23
+ // this is a diagnostic string, not a round-trippable encoding.)
24
+ const show = (v) => {
25
+ const seen = new WeakSet();
26
+ const cycleSafe = (_k, x) => {
27
+ if (typeof x !== "object" || x === null)
28
+ return x;
29
+ if (seen.has(x))
30
+ return "[circular]";
31
+ seen.add(x);
32
+ return x;
33
+ };
34
+ return JSON.stringify(v, cycleSafe) ?? String(v);
35
+ };
36
+ // A truth IS an acceptance item, not a string to coerce. strings()' `.map(String)` turned a typed
37
+ // object truth into the literal "[object Object]" — schema-legal in the judge-compat string form, so
38
+ // it passed validateGraph, reached the worker prompt verbatim and became a judge rubric item; a YAML
39
+ // scalar `- 2.0` silently became "2". Nothing downstream catches either. So fail closed (house
40
+ // precedent: the routing block in compileOne) on anything that is not text and not a typed oracle.
41
+ // The CONTAINER is reported, not collapsed: an absent key returns undefined (nothing was declared) and
42
+ // a present-but-empty list returns [] (declared, contributed nothing) — strings() flattened both, plus
43
+ // a non-list, into the same silent []. Only the non-list fails closed; `truths: []` is real producer
44
+ // output whose plan can still be carried by its <done> lines, and compileOne already rejects a task
45
+ // whose acceptance ends up genuinely empty.
46
+ // Parser liberal, template strict: a string truth stays exactly what strings() made of it — the GSD
47
+ // planner template teaches prose-only and lives in another repo, so this parser must never be
48
+ // stricter than the thing that produced its input. A non-string must satisfy AcceptanceItemSchema
49
+ // EXACTLY — accepted with nothing dropped — which is where the dual-key `text:` beside
50
+ // `command:`/`test:` is DECLARED (schema.ts:25-30); being declared is what makes it survive
51
+ // loadGraph's revalidation, so nothing is ever reattached after.
52
+ export function parseTruths(file, raw) {
53
+ if (raw === undefined)
54
+ return undefined; // key absent: nothing was declared
55
+ if (!Array.isArray(raw)) {
56
+ throw new CompileError(`${file} has a must_haves.truths that is not a list (got ${show(raw)}).\n` +
57
+ ` remedy: write each truth as a "- " list entry, or drop the key.`);
58
+ }
59
+ return raw.map((v, i) => {
60
+ if (typeof v === "string")
61
+ return v;
62
+ const parsed = AcceptanceItemSchema.safeParse(v);
63
+ if (!parsed.success) {
64
+ throw new CompileError(`${file} has a must_haves.truths[${i}] that is neither prose nor a typed acceptance oracle: ${show(v)}\n` +
65
+ ` remedy: write it as plain text, or as {oracle: command, command: <shell>} / {oracle: test, test: <name>} /\n` +
66
+ ` {oracle: judge, text: <rubric>} — command and test may carry a "text:" beside the oracle.`);
67
+ }
68
+ // A SUCCESSFUL parse is lossy too: z.object strips undeclared keys, so {oracle, command, text,
69
+ // severity} validates and compiles with `severity` gone — the same silent coercion in the other
70
+ // direction, and invisible because nothing downstream ever saw the key. Accepting less than the
71
+ // author supplied is not acceptance, so fail closed on whatever validation dropped.
72
+ // Object.hasOwn, never `k in`: the YAML parser hands back `constructor`, `toString`,
73
+ // `hasOwnProperty` and `__proto__` as OWN keys, but `in` finds all four on Object.prototype of the
74
+ // validated output, so each read as "kept" and was stripped in silence — the exact coercion this
75
+ // check exists to stop.
76
+ const dropped = Object.keys(v).filter((k) => !Object.hasOwn(parsed.data, k));
77
+ if (dropped.length) {
78
+ throw new CompileError(`${file} has a must_haves.truths[${i}] carrying key(s) no acceptance oracle declares: ${dropped.join(", ")}\n` +
79
+ ` remedy: drop ${dropped.length === 1 ? "that key" : "those keys"}, or fold the intent into "text:" — a\n` +
80
+ ` compile that kept ${dropped.length === 1 ? "it" : "them"} would discard ${dropped.length === 1 ? "it" : "them"} at the next graph load, unread.`);
81
+ }
82
+ return parsed.data;
83
+ });
84
+ }
19
85
  const WRITE_DIRECTIVE = /\b(?:Create|Write|Add|Emit|Generate)\s+`([^`\s]+)`/gi;
20
86
  const isPathish = (s) => /^[\w./*-]+$/.test(s) && /\.[a-z]{2,4}$/i.test(s);
21
87
  const stripFences = (s) => s.replace(/^```[\s\S]*?^```/gm, "");
@@ -89,7 +155,7 @@ function compileOne(file, storedPath) {
89
155
  .trim();
90
156
  const title = firstSentence || key;
91
157
  const dones = [...body.matchAll(/<done>\s*([\s\S]*?)\s*<\/done>/g)].map((m) => m[1].replace(/\s+/g, " ").trim());
92
- const truths = strings(fm.must_haves?.truths);
158
+ const truths = parseTruths(file, fm.must_haves?.truths) ?? [];
93
159
  const acceptance = [...dones, ...truths].filter(Boolean);
94
160
  if (!acceptance.length) {
95
161
  throw new CompileError(`${file} has no acceptance criteria — every tickmarkr task needs them.\n` +
@@ -1,4 +1,18 @@
1
1
  import { type RunGraph } from "../graph/schema.js";
2
+ /**
3
+ * Classify one `context:` entry against the tree a worker's worktree is built from.
4
+ * ok — reachable
5
+ * untracked — in the author's checkout, invisible to every worker. WARNS, permanently: per-file
6
+ * force-add is the deliberate opt-in, so this is a standing prompt, not a migration.
7
+ * missing — absent from both. FAILS compile; carries a repair when exactly one tracked file
8
+ * shares the basename (ambiguous matches suggest nothing rather than guess).
9
+ * RULING 2026-08-03 (.overseer/OVERSEER-RULING-20260803-context-visibility.md): OBS-170's
10
+ * fail-closed-on-untracked is retired — oracle upheld, policy overruled (it blocked 11 of 13).
11
+ */
12
+ export declare function classifyContextPath(entry: string, tracked: Set<string>, repoDir: string): {
13
+ kind: "ok" | "untracked" | "missing";
14
+ suggestion?: string;
15
+ };
2
16
  export declare const COLLECTABLE_TESTS = "tests/**/*.test.ts";
3
17
  export declare const LEGACY_PREFIX: string;
4
18
  export declare const TICKMARKR_NATIVE_MARKER: RegExp;
@@ -1,7 +1,61 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { existsSync, readFileSync } from "node:fs";
3
+ import { basename, dirname } from "node:path";
2
4
  import picomatch from "picomatch";
3
5
  import { GATE_NAMES, GRAPH_ROUTING_MODES, ORACLES, SHAPES, TIERS, validateGraph } from "../graph/schema.js";
4
6
  import { CompileError, inferShape, sha256 } from "./common.js";
7
+ // OBS-170/OBS-184: `context:` is a promise to the worker, and nothing ever checked it could be kept.
8
+ // Workers run in `git worktree add <baseRef>` (run/git.ts:169-176), which materialises the base
9
+ // TREE and copies nothing in — so the oracle is that tree, and NEVER two things that look like it:
10
+ // · existsSync reads the AUTHOR's checkout. Measured on v1.85: 10 of 16 unreachable entries are
11
+ // plainly present there (.overseer/ is 21 tracked files out of 480 on disk).
12
+ // · `git ls-files` reads the INDEX. `git add -f` writes the index, so a staged-uncommitted file
13
+ // reports present while still being invisible to every worker — the lint would then certify
14
+ // exactly the failure it exists to catch (RULING 2026-08-03 rider; the absent-vs-empty shape
15
+ // again, cf. fileBytes). Hence the opt-in is "add AND commit", and the resolver reads HEAD.
16
+ // `ls-tree -r HEAD` enumerates that tree once; per-path `cat-file -e HEAD:<path>` is the same oracle
17
+ // one path at a time, and one spawn beats N.
18
+ function trackedAtHead(dir) {
19
+ const git = (...args) => spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", maxBuffer: 1 << 28 });
20
+ const top = git("rev-parse", "--show-toplevel");
21
+ // --full-tree is load-bearing: without it `ls-tree` scopes to the CWD's subtree and prints paths
22
+ // relative to it, so a spec in specs/ would see only specs/-relative names and misjudge every
23
+ // path outside it. Caught only by a fixture whose spec is NOT at the repo root.
24
+ const r = git("ls-tree", "--full-tree", "-r", "--name-only", "HEAD");
25
+ // not a repo, or no commits yet — fail open, so non-repo fixtures and fresh inits stay silent
26
+ if (top.status !== 0 || r.status !== 0 || typeof r.stdout !== "string")
27
+ return undefined;
28
+ return { root: top.stdout.trim(), tracked: new Set(r.stdout.split("\n").filter(Boolean)) };
29
+ }
30
+ const GLOB_CHARS = /[*?{[]/;
31
+ /**
32
+ * Classify one `context:` entry against the tree a worker's worktree is built from.
33
+ * ok — reachable
34
+ * untracked — in the author's checkout, invisible to every worker. WARNS, permanently: per-file
35
+ * force-add is the deliberate opt-in, so this is a standing prompt, not a migration.
36
+ * missing — absent from both. FAILS compile; carries a repair when exactly one tracked file
37
+ * shares the basename (ambiguous matches suggest nothing rather than guess).
38
+ * RULING 2026-08-03 (.overseer/OVERSEER-RULING-20260803-context-visibility.md): OBS-170's
39
+ * fail-closed-on-untracked is retired — oracle upheld, policy overruled (it blocked 11 of 13).
40
+ */
41
+ export function classifyContextPath(entry, tracked, repoDir) {
42
+ if (GLOB_CHARS.test(entry))
43
+ return { kind: "ok" }; // a glob is not a promise about one file
44
+ const q = entry.replace(/\/+$/, "");
45
+ if (tracked.has(q))
46
+ return { kind: "ok" };
47
+ for (const t of tracked)
48
+ if (t.startsWith(`${q}/`))
49
+ return { kind: "ok" }; // directory with tracked children
50
+ if (existsSync(`${repoDir}/${q}`))
51
+ return { kind: "untracked" };
52
+ // Measured: 5 of v1.85's 6 absent entries are a tracked file written without its directory
53
+ // (`V185-SEEDS.md` for `.overseer/V185-SEEDS.md`) — the opt-in had worked, only the check was
54
+ // missing. Suggest ONLY on an unambiguous basename: two candidates is a guess, not a repair.
55
+ const base = basename(q);
56
+ const hits = [...tracked].filter((t) => basename(t) === base);
57
+ return hits.length === 1 ? { kind: "missing", suggestion: hits[0] } : { kind: "missing" };
58
+ }
5
59
  // OBS-97: mirror of the vitest.config.ts suite include — the only path class vitest collects.
6
60
  export const COLLECTABLE_TESTS = "tests/**/*.test.ts";
7
61
  export const LEGACY_PREFIX = ["dro", "vr"].join("");
@@ -31,7 +85,7 @@ export function compileNative(file) {
31
85
  for (const line of content.split("\n")) {
32
86
  const heading = line.match(HEAD_RE);
33
87
  if (heading) {
34
- drafts.push({ id: heading[1], title: heading[2].trim(), fields: {}, acceptance: [], gates: [], hasGates: false, list: null, goalContinuation: false });
88
+ drafts.push({ id: heading[1], title: heading[2].trim(), fields: {}, acceptance: [], gates: [], hasGates: false, list: null, continuationField: null });
35
89
  continue;
36
90
  }
37
91
  const draft = drafts.at(-1);
@@ -59,29 +113,42 @@ export function compileNative(file) {
59
113
  draft.list = name;
60
114
  if (name === "gates")
61
115
  draft.hasGates = true;
62
- draft.goalContinuation = false;
116
+ draft.continuationField = null;
63
117
  }
64
118
  else {
65
119
  if (name === "goal" && !value)
66
120
  invalid(draft.id, field[1], "must not be empty");
67
121
  draft.fields[name] = value;
68
- // OBS-60: carry every indented continuation line after `- goal:` into the compiled goal.
69
- draft.goalContinuation = name === "goal";
122
+ // OBS-60/OBS-308: carry wrapped physical lines for prose and comma-separated fields. A
123
+ // worker must receive the whole logical field, regardless of the author's editor width.
124
+ draft.continuationField = name === "goal" || name === "deps" || name === "files" || name === "context"
125
+ ? name
126
+ : null;
70
127
  draft.list = null;
71
128
  }
72
129
  continue;
73
130
  }
74
- // OBS-60: multiline goals — indented prose after `- goal:` is appended until the next field.
75
- if (draft.goalContinuation) {
131
+ // OBS-60/OBS-308: indented physical lines continue the preceding logical field until the next
132
+ // field/list/heading. List continuations join with a space so the existing comma parser sees the
133
+ // exact same value it would have received on one line.
134
+ if (draft.continuationField) {
135
+ const continuation = draft.continuationField;
76
136
  if ((line.startsWith(" ") || line.startsWith("\t")) && !NESTED_RE.test(line)) {
77
- draft.fields.goal += `\n${line.trim()}`;
78
- continue;
137
+ const value = line.trim();
138
+ if (continuation === "goal") {
139
+ draft.fields.goal += `\n${value}`;
140
+ continue;
141
+ }
142
+ if (value) {
143
+ draft.fields[continuation] += ` ${value}`;
144
+ continue;
145
+ }
79
146
  }
80
- if (!line.trim()) {
147
+ if (continuation === "goal" && !line.trim()) {
81
148
  draft.fields.goal += "\n";
82
149
  continue;
83
150
  }
84
- draft.goalContinuation = false;
151
+ draft.continuationField = null;
85
152
  }
86
153
  const nested = line.match(NESTED_RE);
87
154
  if (nested && draft.list) {
@@ -124,12 +191,16 @@ export function compileNative(file) {
124
191
  }
125
192
  // v1.62 (OBS-97): commas inside {a,b} alternatives are part of one glob entry, not separators —
126
193
  // split only at brace depth 0 so a brace glob reaches the lint (and the scope gate) intact.
194
+ // OBS-170/OBS-184: the same is true of a citation annotation. `.planning/OBSERVATIONS.md (OBS-262,
195
+ // OBS-263)` split at the inner comma into two bullets, and workers were dispatched a context line
196
+ // reading `OBS-263)`. Track paren depth alongside brace depth for exactly the OBS-97 reason.
127
197
  const splitTop = (value) => {
128
198
  const parts = [];
129
199
  let depth = 0;
200
+ let paren = 0;
130
201
  let current = "";
131
202
  for (const ch of value) {
132
- if (ch === "," && depth === 0) {
203
+ if (ch === "," && depth === 0 && paren === 0) {
133
204
  parts.push(current);
134
205
  current = "";
135
206
  continue;
@@ -138,11 +209,34 @@ export function compileNative(file) {
138
209
  depth++;
139
210
  else if (ch === "}" && depth > 0)
140
211
  depth--;
212
+ else if (ch === "(")
213
+ paren++;
214
+ else if (ch === ")" && paren > 0)
215
+ paren--;
141
216
  current += ch;
142
217
  }
143
218
  return [...parts, current];
144
219
  };
145
- const csv = (value) => (value && value.toLowerCase() !== "none" ? splitTop(value).map((item) => item.trim()).filter(Boolean) : []);
220
+ // Keeping the comma is only half of it: the merged entry still carries the annotation, so it
221
+ // resolves no better than the fragments did (measured: 24 → 19 unresolvable, zero rescued).
222
+ // Strip one trailing parenthetical so the entry is the path its author meant.
223
+ const stripAnnotation = (item) => {
224
+ const trimmed = item.trim();
225
+ if (!trimmed.endsWith(")"))
226
+ return trimmed;
227
+ let paren = 0;
228
+ for (let i = trimmed.length - 1; i >= 0; i--) {
229
+ if (trimmed[i] === ")")
230
+ paren++;
231
+ else if (trimmed[i] === "(") {
232
+ paren--;
233
+ if (paren === 0)
234
+ return trimmed.slice(0, i).trim();
235
+ }
236
+ }
237
+ return trimmed;
238
+ };
239
+ const csv = (value) => value && value.toLowerCase() !== "none" ? splitTop(value).map((item) => stripAnnotation(item.trim())).filter(Boolean) : [];
146
240
  // OBS-97: a typed test: oracle needs a collectable home. vitest only collects COLLECTABLE_TESTS
147
241
  // paths, so a task whose non-empty files[] cannot host one makes scope-green and acceptance-green
148
242
  // mutually exclusive by construction — run-20260719-210434 burned two dispatch attempts before a
@@ -281,6 +375,31 @@ export function compileNative(file) {
281
375
  }
282
376
  }
283
377
  }
378
+ // OBS-170/OBS-184: warn per unreachable context: entry, classified by the action its author must
379
+ // take. Warn-only by operator ruling (2026-08-03) — fail-closed would have refused to compile the
380
+ // spec that shipped green. Fails open when git cannot answer, so non-repo fixtures stay silent.
381
+ const repo = trackedAtHead(dirname(file));
382
+ if (repo) {
383
+ const unreachable = [];
384
+ for (const t of tasks) {
385
+ for (const entry of t.context) {
386
+ const { kind, suggestion } = classifyContextPath(entry, repo.tracked, repo.root);
387
+ if (kind === "untracked") {
388
+ // Permanent, by ruling: the per-file force-add IS the opt-in, so this stays a standing
389
+ // prompt. "and commit" is load-bearing — staging alone leaves it out of the base tree.
390
+ console.warn(`tickmarkr: OBS-170: task ${t.id} context ${JSON.stringify(entry)} exists in your checkout but is NOT in a worker's worktree. To make it worker context: git add -f ${entry} && git commit. Staging alone is not enough.`);
391
+ }
392
+ else if (kind === "missing") {
393
+ unreachable.push(` ${t.id}: ${JSON.stringify(entry)}${suggestion ? `\n → did you mean ${suggestion} ?` : "\n → not found in the repository, and not a path."}`);
394
+ }
395
+ }
396
+ }
397
+ if (unreachable.length) {
398
+ throw new CompileError(`context: paths that do not exist in ${file}:\n${unreachable.join("\n")}\n\n` +
399
+ `A context: entry is a promise the worker can read it. These resolve to nothing in the repository,\n` +
400
+ `so the worker would be told to read a file that is not there. Fix the paths and recompile.`);
401
+ }
402
+ }
284
403
  return result;
285
404
  }
286
405
  // Commented native spec written to tickmarkr.spec.md by `tickmarkr init`. Documented via HTML comments (which the
@@ -319,6 +438,43 @@ acceptance is required on every task (a nested list of observable outcomes).
319
438
  - test: <name> (oracle: test — named test)
320
439
  - judge: <rubric> (oracle: judge — LLM-judged, free text)
321
440
  - <plain text> (compat: compiles as judge oracle, warns)
441
+
442
+ HARD BOUNDS — these FAIL the compile, they do not warn:
443
+ - at most 6 acceptance items per task (no exception path)
444
+ - at most 8 files[] patterns; a {a,b} brace group counts as ONE pattern
445
+ - acceptance items x files[] patterns must be <= 24 ("surface")
446
+ - at most 60 goal words per acceptance criterion ("density")
447
+ Density is goal words DIVIDED BY items, so REMOVING a criterion RAISES it. Compress the goal in the
448
+ same edit, or a task that was comfortably inside the bound breaches it while you are tidying up.
449
+
450
+ WHAT MAKES A CRITERION REAL:
451
+ - "test:" must name a real test asserting on recorded state, journal lines, or drawn frames, and its
452
+ title must match the criterion string verbatim. It also needs a collectable test path in files[].
453
+ - NO criterion may be satisfiable by an absence, a rename, a source-text grep, or an empty collection.
454
+ "no file references X" is not a criterion — it passes in a repo where the feature was never built.
455
+ - "goal:" is NEVER verification. Prose in the goal enforces nothing: every obligation needs an
456
+ acceptance item, or a NAMED independent gate that actually applies. A gate is not independent of a
457
+ task that owns both the thing it checks and the fixture it checks against.
458
+ - A source-only obligation (a comment or doc that a change makes false) has no lawful "test:" — verify
459
+ it with "judge:", which reads the DIFF and must cite a changed line in a file the task owns.
460
+ - A criterion that pins the SHAPE of a fix must also pin the CONDITIONS under which it runs, or a
461
+ correctly-shaped fix that runs SOMETIMES satisfies it. "cleanup is try/finally at all four sites"
462
+ is satisfied by \`finally { if (cond) cleanup() }\` — the shape is present and the fix is inert in
463
+ production. Say UNCONDITIONAL, or name the branch that may not exist.
464
+ - Enumerating one axis exhaustively is what hides the others. A spec that guards PARTIAL coverage
465
+ site-by-site, member-by-member, can be defeated wholesale by CONDITIONAL coverage, which leaves
466
+ every enumeration satisfied. After you enumerate, ask what a single flag would do to the whole set.
467
+
468
+ ORDERING AND OWNERSHIP:
469
+ - Every path has exactly ONE owning task. Two tasks writing one file must be ORDERED by deps, or the
470
+ loser's work is silently dropped when the integration tip advances.
471
+ - A file one task CREATES cannot be "context:" for another — only deps: carries it, and that extends to
472
+ the task that PRODUCES a value, not just the file's existence.
473
+ - Deleting or renaming a symbol is a cross-task contract. Sweep for consumers by symbol AND by what the
474
+ mechanism DOES for them; the most dangerous consumer is a file no task owns, because nothing fixes it
475
+ and no edge can order it.
476
+
477
+ "timeout:" IS A KILL CEILING, NOT AN ESTIMATE. Never read a sum of timeouts as a predicted duration.
322
478
  -->
323
479
 
324
480
  ## T1: Scaffold the feature
@@ -7,6 +7,7 @@ declare const TierEnum: z.ZodEnum<{
7
7
  export type Tier = z.infer<typeof TierEnum>;
8
8
  export declare const TIER_RANK: Record<Tier, number>;
9
9
  export declare const DEFAULT_DIFF_CAP = 60000;
10
+ export declare const MODEL_WINDOW_PLAUSIBILITY_FLOOR = 1024;
10
11
  export declare const ROUTING_MODES: readonly ["partner-led", "risk-based", "staff-led"];
11
12
  declare const ModeEnum: z.ZodEnum<{
12
13
  "partner-led": "partner-led";
@@ -26,7 +27,7 @@ export declare const MapEntrySchema: z.ZodObject<{
26
27
  }, z.core.$strip>;
27
28
  export type MapEntry = z.infer<typeof MapEntrySchema>;
28
29
  export declare const TierEntrySchema: z.ZodObject<{
29
- vendor: z.ZodString;
30
+ vendor: z.ZodNullable<z.ZodString>;
30
31
  channel: z.ZodEnum<{
31
32
  sub: "sub";
32
33
  api: "api";
@@ -36,6 +37,13 @@ export declare const TierEntrySchema: z.ZodObject<{
36
37
  mid: "mid";
37
38
  frontier: "frontier";
38
39
  }>>;
40
+ modelOverrides: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
41
+ vendor: z.ZodOptional<z.ZodString>;
42
+ channel: z.ZodOptional<z.ZodEnum<{
43
+ sub: "sub";
44
+ api: "api";
45
+ }>>;
46
+ }, z.core.$strip>>>;
39
47
  windows: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
40
48
  }, z.core.$strip>;
41
49
  export type TierEntry = z.infer<typeof TierEntrySchema>;
@@ -173,7 +181,7 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
173
181
  }, z.core.$strip>>;
174
182
  }, z.core.$strip>;
175
183
  tiers: z.ZodRecord<z.ZodString, z.ZodObject<{
176
- vendor: z.ZodString;
184
+ vendor: z.ZodNullable<z.ZodString>;
177
185
  channel: z.ZodEnum<{
178
186
  sub: "sub";
179
187
  api: "api";
@@ -183,6 +191,13 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
183
191
  mid: "mid";
184
192
  frontier: "frontier";
185
193
  }>>;
194
+ modelOverrides: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
195
+ vendor: z.ZodOptional<z.ZodString>;
196
+ channel: z.ZodOptional<z.ZodEnum<{
197
+ sub: "sub";
198
+ api: "api";
199
+ }>>;
200
+ }, z.core.$strip>>>;
186
201
  windows: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
187
202
  }, z.core.$strip>>;
188
203
  pricing: z.ZodRecord<z.ZodString, z.ZodNumber>;
@@ -301,8 +316,8 @@ export type InitConfigOverlay = {
301
316
  };
302
317
  };
303
318
  export declare function configTemplate(overlay?: InitConfigOverlay): string;
304
- export { FLEET_OVERLAY_KEYS, fleetEditableEquals, fleetRepoOverlayFromDelta, harvestFleetProvenance, repoOverlayYaml, serializeFleetOverlay, unifiedYamlDiff, } from "./fleet-overlay.js";
305
- export type { FleetDenyNotes, HarvestedProvenance } from "./fleet-overlay.js";
319
+ export { FLEET_OVERLAY_KEYS, fleetEditableEquals, fleetRepoOverlayFromDelta, renderFleetOverlayWrite, repoOverlayYaml, serializeFleetOverlay, unifiedYamlDiff, } from "./fleet-overlay.js";
320
+ export type { FleetOverlayWrite } from "./fleet-overlay.js";
306
321
  export type FleetTierAssignment = {
307
322
  tier: Tier;
308
323
  provenance?: string;
@@ -316,7 +331,7 @@ export type FleetEditable = {
316
331
  };
317
332
  export declare function repoOverlayPath(repoRoot: string): string;
318
333
  export declare function readOverlayFile(path: string): Record<string, unknown>;
319
- export declare function fleetEditableFromConfig(cfg: TickmarkrConfig, provenance?: Record<string, Record<string, string>>): FleetEditable;
334
+ export declare function fleetEditableFromConfig(cfg: TickmarkrConfig): FleetEditable;
320
335
  /** Resolve which layer last set a dotted fleet path (defaults < global < repo). */
321
336
  export declare function fleetKeyLayer(repoRoot: string, dotted: string, opts?: {
322
337
  globalDir?: string;