tickmarkr 2.2.1 → 2.4.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 (81) hide show
  1. package/README.md +10 -9
  2. package/dist/adapters/catalog-remote.d.ts +1 -4
  3. package/dist/adapters/catalog-remote.js +52 -42
  4. package/dist/adapters/catalog.js +5 -3
  5. package/dist/adapters/claude-code.d.ts +1 -1
  6. package/dist/adapters/claude-code.js +8 -5
  7. package/dist/adapters/model-lints.d.ts +9 -5
  8. package/dist/adapters/model-lints.js +56 -15
  9. package/dist/adapters/model-windows.js +11 -0
  10. package/dist/adapters/prompt.js +1 -0
  11. package/dist/adapters/qwen.d.ts +5 -0
  12. package/dist/adapters/qwen.js +153 -0
  13. package/dist/adapters/types.d.ts +21 -1
  14. package/dist/adapters/types.js +43 -2
  15. package/dist/cli/commands/approve.js +5 -4
  16. package/dist/cli/commands/beat.js +7 -4
  17. package/dist/cli/commands/compile.js +32 -6
  18. package/dist/cli/commands/doctor.d.ts +9 -4
  19. package/dist/cli/commands/doctor.js +87 -13
  20. package/dist/cli/commands/fleet.d.ts +4 -0
  21. package/dist/cli/commands/fleet.js +53 -14
  22. package/dist/cli/commands/init.js +36 -21
  23. package/dist/cli/commands/plan.js +45 -7
  24. package/dist/cli/commands/report.js +37 -1
  25. package/dist/cli/commands/status.d.ts +1 -0
  26. package/dist/cli/commands/status.js +45 -1
  27. package/dist/cli/commands/verify.d.ts +6 -0
  28. package/dist/cli/commands/verify.js +145 -25
  29. package/dist/cli/index.d.ts +1 -1
  30. package/dist/cli/index.js +2 -2
  31. package/dist/compile/collateral.d.ts +2 -9
  32. package/dist/compile/collateral.js +17 -18
  33. package/dist/compile/index.d.ts +4 -1
  34. package/dist/compile/index.js +41 -7
  35. package/dist/compile/native.d.ts +4 -2
  36. package/dist/compile/native.js +58 -9
  37. package/dist/compile/ownership.js +34 -9
  38. package/dist/config/config.d.ts +1 -0
  39. package/dist/config/config.js +52 -6
  40. package/dist/drivers/herdr.d.ts +1 -0
  41. package/dist/drivers/herdr.js +11 -1
  42. package/dist/drivers/index.d.ts +6 -0
  43. package/dist/drivers/index.js +19 -4
  44. package/dist/drivers/orca.d.ts +35 -1
  45. package/dist/drivers/orca.js +260 -20
  46. package/dist/drivers/subprocess.d.ts +3 -3
  47. package/dist/drivers/subprocess.js +16 -9
  48. package/dist/drivers/types.d.ts +12 -0
  49. package/dist/gates/baseline.d.ts +2 -0
  50. package/dist/gates/baseline.js +47 -11
  51. package/dist/gates/llm.d.ts +6 -0
  52. package/dist/gates/llm.js +25 -9
  53. package/dist/gates/review.d.ts +7 -3
  54. package/dist/gates/review.js +61 -22
  55. package/dist/gates/run-gates.d.ts +5 -2
  56. package/dist/gates/run-gates.js +50 -26
  57. package/dist/gates/verdict-cause.d.ts +6 -2
  58. package/dist/gates/verdict-cause.js +8 -4
  59. package/dist/route/preference.d.ts +4 -0
  60. package/dist/route/preference.js +40 -0
  61. package/dist/route/router.js +15 -2
  62. package/dist/run/consult.d.ts +1 -0
  63. package/dist/run/consult.js +39 -8
  64. package/dist/run/daemon.d.ts +16 -0
  65. package/dist/run/daemon.js +345 -74
  66. package/dist/run/git.d.ts +3 -0
  67. package/dist/run/git.js +40 -5
  68. package/dist/run/journal.d.ts +15 -2
  69. package/dist/run/journal.js +73 -12
  70. package/dist/run/supervision.d.ts +6 -0
  71. package/dist/run/supervision.js +29 -1
  72. package/dist/tui/ink/fleet-app.d.ts +4 -0
  73. package/dist/tui/ink/fleet-app.js +45 -16
  74. package/dist/tui/ink/init-app.js +4 -4
  75. package/package.json +59 -1
  76. package/skills/tickmarkr-overseer/SKILL.md +77 -18
  77. package/skills/tickmarkr-overseer/scripts/seat-send.sh +88 -18
  78. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +36 -2
  79. package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +36 -15
  80. package/skills/tickmarkr-overseer/scripts/watch-context.sh +33 -7
  81. package/skills/tickmarkr-overseer/scripts/watch-pending-input.sh +32 -8
@@ -1,16 +1,18 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { parseArgs } from "node:util";
6
6
  import { allAdapters, probeAll, readDoctor, rolePools } from "../../adapters/registry.js";
7
7
  import { channelKey } from "../../adapters/types.js";
8
8
  import { loadConfig } from "../../config/config.js";
9
9
  import { captureBaseline, detectGateCommands } from "../../gates/baseline.js";
10
+ import { modelProvider } from "../../gates/review.js";
10
11
  import { runGates } from "../../gates/run-gates.js";
11
12
  import { getTask, loadGraph } from "../../graph/graph.js";
12
13
  import { GATE_NAMES } from "../../graph/schema.js";
13
14
  import { linkNodeModules, removeWorktree, shGit, shGitOk } from "../../run/git.js";
15
+ import { Journal } from "../../run/journal.js";
14
16
  /**
15
17
  * tickmarkr verify — the gate battery as a standalone command (OPERATING-MODEL-2026-08-11 item 3).
16
18
  *
@@ -59,7 +61,76 @@ const DIRTY_WHY = `refusing to gate a dirty worktree: the shell gates run agains
59
61
  export function verifyStateDir(cwd) {
60
62
  return join(realpathSync(tmpdir()), "tickmarkr-verify", createHash("sha256").update(cwd).digest("hex").slice(0, 12));
61
63
  }
64
+ const STATE_FILES = ["graph.json", "doctor.json", "config.yaml"];
65
+ const LOCKFILES = ["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"];
66
+ /** Linked worktrees share operational state with the repository that owns the common git dir. */
67
+ export async function verifyStateRoot(cwd) {
68
+ const commonDir = resolve(cwd, (await shGitOk("git rev-parse --git-common-dir", cwd)).trim());
69
+ const commonRoot = realpathSync(dirname(commonDir));
70
+ if (commonRoot === realpathSync(cwd))
71
+ return cwd;
72
+ const localState = join(cwd, ".tickmarkr");
73
+ const commonState = join(commonRoot, ".tickmarkr");
74
+ return STATE_FILES.some((file) => !existsSync(join(localState, file)) && existsSync(join(commonState, file)))
75
+ ? commonRoot
76
+ : cwd;
77
+ }
78
+ const hashParts = (parts) => {
79
+ const hash = createHash("sha256");
80
+ for (const part of parts)
81
+ hash.update(part);
82
+ return hash.digest("hex").slice(0, 12);
83
+ };
84
+ /** Cache identity is repository content, never a checkout path. */
85
+ export function baselineCachePath(cwd, baseSha, commands) {
86
+ const lockParts = [];
87
+ for (const file of LOCKFILES) {
88
+ const path = join(cwd, file);
89
+ lockParts.push(file, existsSync(path) ? readFileSync(path) : "<absent>");
90
+ }
91
+ const commandParts = Object.entries(commands).sort(([a], [b]) => a.localeCompare(b)).map(([name, command]) => `${name}\0${command}\0`);
92
+ return join(realpathSync(tmpdir()), "tickmarkr-verify", "cache", `baseline-${baseSha.slice(0, 12)}-${hashParts(lockParts)}-${hashParts(commandParts)}.json`);
93
+ }
94
+ const verdictlessCommands = (baseline, commands) => Object.keys(commands).filter((name) => baseline.commands[name]?.exitCode === undefined);
95
+ export function excludeAuthorProvider(channels, author) {
96
+ const provider = modelProvider(author.model, author.vendor);
97
+ return [author, ...channels.filter((candidate) => candidate !== author && modelProvider(candidate.model, candidate.vendor) !== provider)];
98
+ }
99
+ function recordedMerges(repoRoot) {
100
+ const runs = join(repoRoot, ".tickmarkr", "runs");
101
+ if (!existsSync(runs))
102
+ return [];
103
+ const rows = [];
104
+ for (const runId of readdirSync(runs).filter((name) => name.startsWith("run-")).sort().reverse()) {
105
+ const path = join(runs, runId, "journal.jsonl");
106
+ if (!existsSync(path))
107
+ continue;
108
+ for (const line of readFileSync(path, "utf8").split("\n").filter(Boolean)) {
109
+ try {
110
+ const row = JSON.parse(line);
111
+ if (row.event === "merge" && row.taskId && row.data?.commit)
112
+ rows.push({ runId, taskId: row.taskId, commit: row.data.commit });
113
+ }
114
+ catch { /* torn journal tail is not a merge record */ }
115
+ }
116
+ }
117
+ return rows;
118
+ }
119
+ async function warnWideTaskRange(cwd, stateRoot, taskId, mergeBase) {
120
+ const inRange = new Set((await shGitOk(`git rev-list --merges '${mergeBase}..HEAD'`, cwd)).trim().split("\n").filter(Boolean));
121
+ const merges = recordedMerges(stateRoot).filter((row) => inRange.has(row.commit));
122
+ const own = merges.find((row) => row.taskId === taskId);
123
+ const others = own ? merges.filter((row) => row.taskId !== taskId) : [];
124
+ if (own && others.length) {
125
+ console.error(`verify: WARNING --task ${taskId} range also carries merge commit(s) for ${others.map((row) => `${row.taskId} ${row.commit.slice(0, 12)}`).join(", ")}; ${taskId}'s own merge ${own.commit} is the intended HEAD`);
126
+ }
127
+ }
128
+ export const VERIFY_HELP = `usage: tickmarkr verify [--base <ref>] [--criteria <file> | --task <id>] [--json]
129
+ The final verdict and JSON result are written to stdout; progress and diagnostics are written to stderr.
130
+ Do not merge stdout and stderr (for example with 2>&1): doing so corrupts the verdict stream.`;
62
131
  export async function verify(argv, cwd = process.cwd()) {
132
+ if (argv.some((arg) => arg === "--help" || arg === "-h"))
133
+ return { out: VERIFY_HELP, code: 0 };
63
134
  const { values } = parseArgs({
64
135
  args: argv,
65
136
  options: {
@@ -69,13 +140,18 @@ export async function verify(argv, cwd = process.cwd()) {
69
140
  files: { type: "string", multiple: true },
70
141
  author: { type: "string" },
71
142
  baseline: { type: "string" },
143
+ record: { type: "string" },
72
144
  json: { type: "boolean", default: false },
73
145
  "no-review": { type: "boolean", default: false },
74
146
  "no-acceptance": { type: "boolean", default: false },
75
147
  },
76
148
  allowPositionals: false,
77
149
  });
78
- const cfg = loadConfig(cwd);
150
+ const stateRoot = await verifyStateRoot(cwd);
151
+ if (resolve(stateRoot) !== resolve(cwd)) {
152
+ console.error(`verify: state files graph.json, doctor.json and config.yaml resolved read-only from ${join(stateRoot, ".tickmarkr")} (linked worktree)`);
153
+ }
154
+ const cfg = loadConfig(stateRoot);
79
155
  const head = (await shGitOk("git rev-parse HEAD", cwd)).trim();
80
156
  const baseTip = (await shGitOk(`git rev-parse '${values.base}'`, cwd).catch(() => {
81
157
  throw new Error(`--base ${values.base} is not a resolvable ref — pass --base <ref> naming the branch this diff targets`);
@@ -89,9 +165,7 @@ export async function verify(argv, cwd = process.cwd()) {
89
165
  let files = values.files ?? [];
90
166
  let goal = `independent verification of the ${values.base}..HEAD diff`;
91
167
  if (values.task) {
92
- const t = getTask(loadGraph(cwd), values.task);
93
- if (!t)
94
- throw new Error(`--task ${values.task}: no such task in .tickmarkr/graph.json`);
168
+ const t = getTask(loadGraph(stateRoot), values.task);
95
169
  acceptance = t.acceptance;
96
170
  if (!files.length)
97
171
  files = t.files;
@@ -102,6 +176,8 @@ export async function verify(argv, cwd = process.cwd()) {
102
176
  if (!acceptance.length)
103
177
  throw new Error(`--criteria ${values.criteria}: no criteria found (bullets or command:/test:/judge: lines)`);
104
178
  }
179
+ if (values.task)
180
+ await warnWideTaskRange(cwd, stateRoot, values.task, mergeBase);
105
181
  const wantAcceptance = acceptance.length > 0 && !values["no-acceptance"];
106
182
  const wantReview = !values["no-review"];
107
183
  // A gate that enforced nothing must not print a green row. `files` has exactly three sources —
@@ -145,7 +221,7 @@ export async function verify(argv, cwd = process.cwd()) {
145
221
  let author = HUMAN_AUTHOR;
146
222
  const adapters = allAdapters();
147
223
  if (wantAcceptance || wantReview) {
148
- const health = readDoctor(cwd) ?? (await probeAll(adapters));
224
+ const health = readDoctor(stateRoot) ?? (await probeAll(adapters));
149
225
  const pools = rolePools(cfg, adapters, health);
150
226
  judgeChannels = pools.judge;
151
227
  channels = pools.review;
@@ -157,6 +233,7 @@ export async function verify(argv, cwd = process.cwd()) {
157
233
  throw new Error(`--author ${values.author} does not name a discoverable review channel — one of: ${channels.map(channelKey).join(", ") || "(none)"}`);
158
234
  }
159
235
  author = { adapter: c.adapter, model: c.model, channel: c.channel, tier: c.tier };
236
+ channels = excludeAuthorProvider(channels, c);
160
237
  }
161
238
  else {
162
239
  channels = [...channels, HUMAN_CHANNEL];
@@ -173,28 +250,56 @@ export async function verify(argv, cwd = process.cwd()) {
173
250
  const stateDir = verifyStateDir(cwd);
174
251
  mkdirSync(stateDir, { recursive: true });
175
252
  let baseline;
176
- const cachePath = join(stateDir, `baseline-${mergeBase.slice(0, 12)}.json`);
253
+ const cachePath = baselineCachePath(cwd, mergeBase, commands);
254
+ const verdictlessMarker = `${cachePath}.verdictless`;
255
+ mkdirSync(dirname(cachePath), { recursive: true });
177
256
  if (values.baseline) {
178
257
  baseline = JSON.parse(readFileSync(values.baseline, "utf8"));
179
258
  }
180
- else if (existsSync(cachePath)) {
181
- console.error(`verify: reusing cached baseline for ${mergeBase.slice(0, 12)} (${cachePath})`);
182
- baseline = JSON.parse(readFileSync(cachePath, "utf8"));
183
- }
184
259
  else {
185
- const baseDir = join(stateDir, `base-${mergeBase.slice(0, 12)}`);
186
- console.error(`verify: capturing baseline at merge-base ${mergeBase.slice(0, 12)} (one-time per base; cached at ${cachePath})`);
187
- await shGit(`git worktree remove --force '${baseDir}'`, cwd); // stale leftover from an interrupted run
188
- await shGitOk(`git worktree add --detach '${baseDir}' '${mergeBase}'`, cwd);
189
- try {
190
- linkNodeModules(cwd, baseDir, { force: true });
191
- baseline = await captureBaseline(baseDir, commands);
192
- writeFileSync(cachePath, JSON.stringify(baseline, null, 2));
260
+ let cached;
261
+ if (existsSync(cachePath)) {
262
+ cached = JSON.parse(readFileSync(cachePath, "utf8"));
263
+ const missing = verdictlessCommands(cached, commands);
264
+ if (missing.length) {
265
+ console.error(`verify: cached baseline recorded no verdict for ${missing.join(", ")}; it was not reusable and will be recaptured`);
266
+ rmSync(cachePath, { force: true });
267
+ cached = undefined;
268
+ }
193
269
  }
194
- finally {
195
- await removeWorktree(cwd, baseDir);
270
+ if (cached) {
271
+ console.error(`verify: reusing cached baseline for ${mergeBase.slice(0, 12)} (${cachePath})`);
272
+ baseline = cached;
273
+ }
274
+ else {
275
+ if (existsSync(verdictlessMarker)) {
276
+ console.error(`verify: prior baseline recorded no verdict and was not cached; recapturing merge-base ${mergeBase.slice(0, 12)}`);
277
+ }
278
+ else {
279
+ console.error(`verify: capturing baseline at merge-base ${mergeBase.slice(0, 12)} (cached by base, lockfile and command hashes at ${cachePath})`);
280
+ }
281
+ const baseDir = join(stateDir, `base-${mergeBase.slice(0, 12)}`);
282
+ await shGit(`git worktree remove --force '${baseDir}'`, cwd); // stale leftover from an interrupted run
283
+ await shGitOk(`git worktree add --detach '${baseDir}' '${mergeBase}'`, cwd);
284
+ try {
285
+ linkNodeModules(cwd, baseDir, { force: true });
286
+ baseline = await captureBaseline(baseDir, commands);
287
+ const missing = verdictlessCommands(baseline, commands);
288
+ if (missing.length) {
289
+ writeFileSync(verdictlessMarker, JSON.stringify({ base: mergeBase, commands: missing }) + "\n");
290
+ rmSync(cachePath, { force: true });
291
+ }
292
+ else {
293
+ writeFileSync(cachePath, JSON.stringify(baseline, null, 2));
294
+ rmSync(verdictlessMarker, { force: true });
295
+ }
296
+ }
297
+ finally {
298
+ await removeWorktree(cwd, baseDir);
299
+ }
196
300
  }
197
301
  }
302
+ const recordJournal = values.record ? Journal.open(stateRoot, values.record) : undefined;
198
303
  const artifactDir = join(stateDir, new Date().toISOString().replace(/[:.]/g, "-"));
199
304
  mkdirSync(artifactDir, { recursive: true });
200
305
  const { results } = await runGates(task, {
@@ -212,12 +317,27 @@ export async function verify(argv, cwd = process.cwd()) {
212
317
  },
213
318
  });
214
319
  const green = results.length > 0 && results.every((r) => r.pass || r.meta?.skipped === true);
320
+ const reviewRows = results.filter((result) => result.gate === "review");
321
+ const reviewFindings = reviewRows.flatMap((result) => result.details.split("\n").flatMap((line) => {
322
+ const match = /^- \[([^\]]+)\] (.*)$/.exec(line);
323
+ return match ? [{ classification: match[1], note: match[2], reviewer: result.meta?.reviewer }] : [];
324
+ }));
325
+ const artifactPath = join(artifactDir, "verify-results.json");
326
+ writeFileSync(artifactPath, JSON.stringify({ base: baseTip, head, mergeBase, green, gateRows: results, reviewFindings }, null, 2) + "\n");
327
+ console.error(`verify: artifacts written to ${artifactPath}`);
328
+ const review = reviewRows.at(-1);
329
+ if (recordJournal && review) {
330
+ recordJournal.append("review-leg2", values.task ?? "VERIFY", {
331
+ base: baseTip, head, mergeBase, author: channelKey(author), artifactPath,
332
+ ...review,
333
+ });
334
+ }
215
335
  if (values.json) {
216
- return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, results }, null, 2), code: green ? 0 : 2 };
336
+ return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, results }, null, 2), code: green ? 0 : 2 };
217
337
  }
218
338
  const lines = results.map((r) => `${r.pass ? "PASS" : "FAIL"} ${r.gate}\n${r.details.split("\n").map((l) => ` ${l}`).join("\n")}`);
219
339
  const verdict = green
220
- ? `verify GREEN — ${results.length} gate(s) passed on ${mergeBase.slice(0, 12)}..${head.slice(0, 12)} (merge is a human decision)`
221
- : `verify RED — first failure decides; artifacts in ${artifactDir}`;
340
+ ? `verify GREEN — ${results.length} gate(s) passed on ${mergeBase.slice(0, 12)}..${head.slice(0, 12)} (merge is a human decision; artifacts: ${artifactPath})`
341
+ : `verify RED — first failure decides; artifacts: ${artifactPath}`;
222
342
  return { out: [...lines, "", verdict].join("\n"), code: green ? 0 : 2 };
223
343
  }
@@ -5,7 +5,7 @@ export type CommandResult = string | {
5
5
  };
6
6
  export type CommandMap = Record<string, (argv: string[]) => Promise<CommandResult>>;
7
7
  export declare const COMMANDS: CommandMap;
8
- export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval run checked-in fixtures against every channel in isolated temp repos\n run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)\n status live run state\n stats all-run channel delivery, red, rescue, author and reviewer statistics\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> record one supervision beat for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume";
8
+ export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval run checked-in fixtures against every channel in isolated temp repos\n run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)\n status live run state (--watch --events: JSON documents on stdout, keepalives on stderr; 2>&1 corrupts the stream)\n stats all-run channel delivery, red, rescue, author and reviewer statistics\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 verdict/JSON on stdout, progress on stderr; 2>&1 corrupts the verdict stream (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> record one supervision beat for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume";
9
9
  export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
10
10
  out: string;
11
11
  code: number;
package/dist/cli/index.js CHANGED
@@ -37,9 +37,9 @@ usage: tickmarkr <command>
37
37
  plan dry-run routing table + cost estimate + floor lints
38
38
  eval run checked-in fixtures against every channel in isolated temp repos
39
39
  run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)
40
- status live run state
40
+ status live run state (--watch --events: JSON documents on stdout, keepalives on stderr; 2>&1 corrupts the stream)
41
41
  stats all-run channel delivery, red, rescue, author and reviewer statistics
42
- verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD — no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])
42
+ verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD — verdict/JSON on stdout, progress on stderr; 2>&1 corrupts the verdict stream (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])
43
43
  resume <id> continue a run from its journal
44
44
  report <id> cost/quality report (--md for committable execution record)
45
45
  profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)
@@ -149,14 +149,7 @@ export declare function reviewParticipationErrors(tasks: ReadonlyArray<Pick<Task
149
149
  * whose task objects carry no `context` gets byte-identical verdicts to the ones it got before the
150
150
  * field existed, because an absent declaration grants no authority at all.
151
151
  *
152
- * KNOWN GAP (R2 review, T6/T7 scope): the compile seam in src/compile/index.ts
153
- * (`enforceTaskUnitContract`) does not thread `compileSource`'s `root` argument into this call, so a
154
- * PROGRAMMATIC compile whose target repo differs from process.cwd() resolves the wrong root and can
155
- * miss that repo's own `review.criticalPaths`. Threading it is one line in src/compile/index.ts,
156
- * outside this task's file scope. Two things bound the exposure meanwhile, both live: the critical
157
- * set here is the UNION with the shipped defaults, so a wrong root can never lower enforcement below
158
- * the shipped floor; and the review GATE — which is handed the run's real config — refuses to skip a
159
- * critical path itself (src/gates/review.ts), so a lint this seam misses costs a late verdict rather
160
- * than an unreviewed one. The compile lint is the early warning; the gate is the fail-closed backstop.
152
+ * The compile seam in src/compile/index.ts threads `compileSource`'s repo root here, so CLI tests
153
+ * and programmatic compiles check the same repository overlay the later run will use.
161
154
  */
162
155
  export declare function taskUnitContractErrors(tasks: ReadonlyArray<Pick<Task, "id" | "goal" | "files" | "deps" | "acceptance" | "shape"> & Partial<Pick<Task, "context">>>, repoRoot?: string, review?: ReviewParticipation): string[];
@@ -1,4 +1,4 @@
1
- import { readdirSync, readFileSync, statSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { extname, join, relative } from "node:path";
3
3
  import { filesGlob } from "../graph/files-glob.js";
4
4
  import { criticalPathHits, DEFAULT_CONFIG, DEFAULT_REVIEW_CRITICAL_PATHS, effectiveReviewPolicy, loadConfig, } from "../config/config.js";
@@ -15,6 +15,7 @@ const MAX_WALK_FILES = 400;
15
15
  const MAX_HITS_PER_TASK = 20;
16
16
  /** Skip giant fixtures / snapshots. */
17
17
  const MAX_READ_BYTES = 512 * 1024;
18
+ const EXPORT_MANIFEST_TEST = "tests/repo/export-manifest.test.ts";
18
19
  const SRC_EXT = /\.(ts|tsx|js|jsx|mts|cts)$/;
19
20
  const CODE_EXT = /\.(ts|tsx|js|jsx|mts|cts)$/;
20
21
  function isSrcPath(p) {
@@ -112,15 +113,20 @@ export function collateralHits(tasks, repoRoot) {
112
113
  const read = makeReader(repoRoot);
113
114
  for (const t of tasks) {
114
115
  // OBS-22: scopeGate accepts picomatch globs; advisory collateral warnings must agree.
115
- const scoped = filesGlob(t.files.map((f) => f.replace(/^\.\//, "")));
116
- const srcFiles = t.files.map((f) => f.replace(/^\.\//, "")).filter(isSrcPath);
117
- if (!srcFiles.length)
118
- continue;
116
+ const files = t.files.map((f) => f.replace(/^\.\//, ""));
117
+ const scoped = filesGlob(files);
118
+ const srcFiles = files.filter(isSrcPath);
119
+ const hits = [];
120
+ // Exact-enumerated scripts are an export-set contract: a new path has no name or symbol for the
121
+ // ordinary sweep to find, but the manifest test will reject it unless the task owns that oracle.
122
+ if (testFiles.includes(EXPORT_MANIFEST_TEST)
123
+ && !scoped(EXPORT_MANIFEST_TEST)
124
+ && files.some((path) => path.startsWith("scripts/") && !/[?*{[]/.test(path) && !existsSync(join(repoRoot, path))))
125
+ hits.push(EXPORT_MANIFEST_TEST);
119
126
  // needles unioned across all src files in this task
120
127
  const needles = [...new Set(srcFiles.flatMap(needlesFor))];
121
- const hits = [];
122
128
  for (const tf of testFiles) {
123
- if (scoped(tf))
129
+ if (scoped(tf) || hits.includes(tf))
124
130
  continue;
125
131
  const text = read(tf);
126
132
  if (text === null)
@@ -128,9 +134,9 @@ export function collateralHits(tasks, repoRoot) {
128
134
  if (mentions(text, needles))
129
135
  hits.push(tf);
130
136
  }
131
- // deterministic: walk already sorted; stable list
137
+ // deterministic: the special hit and walk are stable; sort their union for one canonical map.
132
138
  if (hits.length)
133
- map.set(t.id, hits);
139
+ map.set(t.id, hits.sort());
134
140
  }
135
141
  return map;
136
142
  }
@@ -890,15 +896,8 @@ function activeReviewParticipation(repoRoot) {
890
896
  * whose task objects carry no `context` gets byte-identical verdicts to the ones it got before the
891
897
  * field existed, because an absent declaration grants no authority at all.
892
898
  *
893
- * KNOWN GAP (R2 review, T6/T7 scope): the compile seam in src/compile/index.ts
894
- * (`enforceTaskUnitContract`) does not thread `compileSource`'s `root` argument into this call, so a
895
- * PROGRAMMATIC compile whose target repo differs from process.cwd() resolves the wrong root and can
896
- * miss that repo's own `review.criticalPaths`. Threading it is one line in src/compile/index.ts,
897
- * outside this task's file scope. Two things bound the exposure meanwhile, both live: the critical
898
- * set here is the UNION with the shipped defaults, so a wrong root can never lower enforcement below
899
- * the shipped floor; and the review GATE — which is handed the run's real config — refuses to skip a
900
- * critical path itself (src/gates/review.ts), so a lint this seam misses costs a late verdict rather
901
- * than an unreviewed one. The compile lint is the early warning; the gate is the fail-closed backstop.
899
+ * The compile seam in src/compile/index.ts threads `compileSource`'s repo root here, so CLI tests
900
+ * and programmatic compiles check the same repository overlay the later run will use.
902
901
  */
903
902
  export function taskUnitContractErrors(tasks, repoRoot = process.cwd(), review = activeReviewParticipation(repoRoot)) {
904
903
  return [
@@ -10,6 +10,9 @@ export type PlanIR = {
10
10
  tasks: RunGraph["tasks"];
11
11
  };
12
12
  export type PlanFinalizationHook = (plan: PlanIR) => PlanIR;
13
+ export type CompileOptions = {
14
+ strict?: boolean;
15
+ };
13
16
  export declare function finalizePlan(plan: PlanIR, src: string, repoRoot?: string): RunGraph;
14
- export declare function compileSource(src: string, type?: SourceType, root?: string, beforeFinalize?: PlanFinalizationHook): RunGraph;
17
+ export declare function compileSource(src: string, type?: SourceType, root?: string, beforeFinalize?: PlanFinalizationHook, options?: CompileOptions): RunGraph;
15
18
  export {};
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { parse } from "yaml";
3
4
  import { validateGraph } from "../graph/schema.js";
4
5
  import { taskUnitContractErrors } from "./collateral.js";
5
6
  import { blocksCompile, ownershipFindings, renderOwnershipFinding } from "./ownership.js";
@@ -33,8 +34,40 @@ function detect(src) {
33
34
  // tasks are too large to converge. A violation is a compile error, never a warning — the failures it
34
35
  // prevents (silently dropped commits, a 28-dispatch task) are invisible until they have already cost
35
36
  // hours, which is exactly the class of thing that has to fail at authoring time.
36
- function enforceTaskUnitContract(g, src) {
37
- const errors = taskUnitContractErrors(g.tasks);
37
+ function repoOverlayMode(repoRoot) {
38
+ if (!repoRoot)
39
+ return undefined;
40
+ try {
41
+ const cfg = parse(readFileSync(join(repoRoot, ".tickmarkr", "config.yaml"), "utf8"));
42
+ return typeof cfg?.routing?.mode === "string" ? cfg.routing.mode : undefined;
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ }
48
+ function hasModeOverride(src) {
49
+ try {
50
+ for (const line of readFileSync(src, "utf8").split("\n")) {
51
+ if (/^##\s+T\d+:/i.test(line))
52
+ return false;
53
+ if (/^mode-override:\s*true\s*(?:#.*)?$/.test(line))
54
+ return true;
55
+ }
56
+ }
57
+ catch { /* unreadable specs fail elsewhere */ }
58
+ return false;
59
+ }
60
+ function enforceModeOverlay(graph, src, repoRoot) {
61
+ if (graph.spec.source !== "native" || graph.mode === undefined)
62
+ return;
63
+ const overlayMode = repoOverlayMode(repoRoot);
64
+ if (overlayMode === undefined || overlayMode === graph.mode || hasModeOverride(src))
65
+ return;
66
+ throw new CompileError(`${src} front-matter mode ${graph.mode} disagrees with repository routing.mode ${overlayMode}; `
67
+ + `write mode-override: true beside the mode line to make the override explicit.`);
68
+ }
69
+ function enforceTaskUnitContract(g, src, repoRoot) {
70
+ const errors = taskUnitContractErrors(g.tasks, repoRoot);
38
71
  if (errors.length > 0) {
39
72
  throw new CompileError(`${src} violates the task unit contract (${errors.length} error${errors.length > 1 ? "s" : ""}):\n`
40
73
  + errors.map((e) => ` - ${e}`).join("\n"));
@@ -52,7 +85,8 @@ export function finalizePlan(plan, src, repoRoot) {
52
85
  ...(plan.base !== undefined ? { base: plan.base } : {}),
53
86
  },
54
87
  tasks: plan.tasks,
55
- }), src);
88
+ }), src, repoRoot);
89
+ enforceModeOverlay(graph, src, repoRoot);
56
90
  // overseer-217 removal condition, now paid: on this milestone's authored graph the conventional
57
91
  // name map emitted 21 raw unowned-test findings; review found 1 real and 20 false, while intersecting
58
92
  // with a direct import or command-entry spawn retained the real one and left 0 false positives. That
@@ -73,11 +107,11 @@ export function finalizePlan(plan, src, repoRoot) {
73
107
  }
74
108
  return graph;
75
109
  }
76
- function compilePlan(src, type, root) {
110
+ function compilePlan(src, type, root, options = {}) {
77
111
  const kind = type ?? detect(src);
78
112
  const graph = kind === "speckit" ? compileSpecKit(src)
79
113
  : kind === "gsd" ? compileGsd(src, root)
80
- : kind === "native" ? compileNative(src)
114
+ : kind === "native" ? compileNative(src, { strict: options.strict })
81
115
  : kind === "prd" ? compilePrd(src)
82
116
  : null;
83
117
  if (!graph) {
@@ -93,6 +127,6 @@ function compilePlan(src, type, root) {
93
127
  tasks: graph.tasks,
94
128
  };
95
129
  }
96
- export function compileSource(src, type, root, beforeFinalize = (plan) => plan) {
97
- return finalizePlan(beforeFinalize(compilePlan(src, type, root)), src, root);
130
+ export function compileSource(src, type, root, beforeFinalize = (plan) => plan, options = {}) {
131
+ return finalizePlan(beforeFinalize(compilePlan(src, type, root, options)), src, root);
98
132
  }
@@ -17,7 +17,7 @@ export declare const COLLECTABLE_TESTS = "tests/**/*.test.ts";
17
17
  export declare const LEGACY_PREFIX: string;
18
18
  export declare const TICKMARKR_NATIVE_MARKER: RegExp;
19
19
  export declare const NATIVE_MARKER: RegExp;
20
- export declare const AUTHORING_LINT_CODES: readonly ["criterion-scope", "dependency-closure", "dependency-coupling", "proxy-metric", "external-referent", "closed-enumeration", "one-behavior", "concern-bundle", "seam-exists"];
20
+ export declare const AUTHORING_LINT_CODES: readonly ["criterion-scope", "dependency-closure", "dependency-coupling", "proxy-metric", "external-referent", "closed-enumeration", "one-behavior", "concern-bundle", "seam-exists", "fence-symbol-absent"];
21
21
  export type AuthoringLintCode = (typeof AUTHORING_LINT_CODES)[number];
22
22
  export interface AuthoringLintFinding {
23
23
  code: AuthoringLintCode;
@@ -31,5 +31,7 @@ export interface AuthoringLintFinding {
31
31
  * compile boundary; the remaining checks are review findings, emitted by compileNative below.
32
32
  */
33
33
  export declare function authoringLintFindings(tasks: readonly Task[], file: string): AuthoringLintFinding[];
34
- export declare function compileNative(file: string): RunGraph;
34
+ export declare function compileNative(file: string, options?: {
35
+ strict?: boolean;
36
+ }): RunGraph;
35
37
  export declare function specTemplate(): string;
@@ -83,6 +83,7 @@ export const AUTHORING_LINT_CODES = [
83
83
  "one-behavior",
84
84
  "concern-bundle",
85
85
  "seam-exists",
86
+ "fence-symbol-absent",
86
87
  ];
87
88
  // The authoring frame's observable oracle is the branch-tip test corpus, never the mutable index or
88
89
  // an author's untracked checkout. Load it once per repository: one bounded git read replaces a grep
@@ -217,6 +218,31 @@ function criterionScopeFinding(task, text, criterion, id, tests) {
217
218
  detail: `criterion names asserting file${missing.length === 1 ? "" : "s"} outside expanded files[] and context[]: ${missing.join(", ")} — ${remedy}`,
218
219
  };
219
220
  }
221
+ // OBS-898: the fence lint's corpus is listed ONCE per compile by git (tracked plus untracked-not-ignored,
222
+ // the same set a checkout walk sees minus the ignored trees) and filtered per task. The walk it replaces
223
+ // descended every dot-directory (.tickmarkr/runs alone held 3 799 files here) once PER TASK, so a
224
+ // 20-task spec compiled in seconds and the committed-spec corpus test timed out. Fail-open like
225
+ // testsAtHead: no git answer means no corpus, and the lint stays quiet rather than wrong.
226
+ function repoFileList(root) {
227
+ const ls = spawnSync("git", ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { encoding: "utf8", maxBuffer: 1 << 25 });
228
+ if (ls.status !== 0 || typeof ls.stdout !== "string")
229
+ return [];
230
+ return ls.stdout.split("\0").filter(Boolean).sort();
231
+ }
232
+ function repoFiles(files, entries) {
233
+ const scoped = filesGlob(entries.map((entry) => entry.replace(/^\.\//, "")));
234
+ return files.filter(scoped);
235
+ }
236
+ const PRESERVATION_RE = /\b(?:keeps?|preserves?|retains?|does\s+not\s+weaken|do\s+not\s+weaken|not\s+weaken)\b/i;
237
+ const IDENTIFIER_SHAPED_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
238
+ function fencedIdentifiers(text) {
239
+ if (!PRESERVATION_RE.test(text))
240
+ return [];
241
+ return [...new Set([...text.matchAll(/`([^`\n]{1,120})`/g)]
242
+ .map((match) => match[1].trim())
243
+ .filter((token) => IDENTIFIER_SHAPED_RE.test(token) && /[A-Z0-9_$]/.test(token)))]
244
+ .sort();
245
+ }
220
246
  function exportedIdentifier(root, files, identifier) {
221
247
  const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
222
248
  const declaration = new RegExp(`\\bexport\\s+(?:(?:declare|default|async)\\s+)*(?:function|class|const|let|var|interface|type)\\s+${escaped}\\b|\\bexport\\s*\\{[^}]*\\b${escaped}\\b`);
@@ -240,14 +266,33 @@ export function authoringLintFindings(tasks, file) {
240
266
  const root = repositoryRoot(file);
241
267
  let indexedTests;
242
268
  const testIndex = () => indexedTests ??= root ? testsAtHead(root) : [];
269
+ let indexedFiles;
270
+ const fileIndex = () => indexedFiles ??= root ? repoFileList(root) : [];
243
271
  const findings = [];
244
272
  for (const task of tasks) {
245
273
  const criteria = criterionTexts(task);
274
+ const taskFiles = root && task.files.length ? repoFiles(fileIndex(), task.files) : [];
275
+ const taskTexts = new Map(taskFiles.map((path) => {
276
+ try {
277
+ return [path, readFileSync(join(root, path), "utf8")];
278
+ }
279
+ catch {
280
+ return [path, ""];
281
+ }
282
+ }));
246
283
  for (const [index, text] of criteria.entries()) {
247
284
  const needsTestIndex = /`[^`\n]{2,120}`|\b\d+\s*(?:\/|of)\s*\d+\b|\b[A-Za-z0-9_.-]+\.test\.ts\b/.test(text);
248
285
  const scope = criterionScopeFinding(task, text, index + 1, id, needsTestIndex ? testIndex() : []);
249
286
  if (scope)
250
287
  findings.push(scope);
288
+ for (const symbol of fencedIdentifiers(text)) {
289
+ if (!taskTexts.size || [...taskTexts.values()].some((body) => body.includes(symbol)))
290
+ continue;
291
+ findings.push({
292
+ code: "fence-symbol-absent", fixtureId: id, taskId: task.id, criterion: index + 1,
293
+ detail: `preservation fence cites \`${symbol}\` but that symbol has zero hits in this task's files[] (${taskFiles.join(", ")}) — OBS-604`,
294
+ });
295
+ }
251
296
  if (/line[- ]count|physical line|not greater than (?:the|\d)|no larger than|at most \d+ (?:lines|bytes)/i.test(text)) {
252
297
  findings.push({ code: "proxy-metric", fixtureId: id, taskId: task.id, criterion: index + 1, detail: "criterion uses a proxy size metric; state the structural intent instead" });
253
298
  }
@@ -308,7 +353,7 @@ function renderAuthoringFinding(finding) {
308
353
  const criterion = finding.criterion === undefined ? "" : ` criterion ${finding.criterion}`;
309
354
  return `tickmarkr: authoring-lint[${finding.code}] fixture ${finding.fixtureId} task ${finding.taskId}${criterion}: ${finding.detail}`;
310
355
  }
311
- export function compileNative(file) {
356
+ export function compileNative(file, options = {}) {
312
357
  if (!existsSync(file))
313
358
  throw new CompileError(`no such native spec file: ${file}`);
314
359
  const content = readFileSync(file, "utf8");
@@ -638,13 +683,17 @@ export function compileNative(file) {
638
683
  tasks,
639
684
  });
640
685
  const authoringFindings = authoringLintFindings(result.tasks, file);
641
- for (const finding of authoringFindings.filter(({ code }) => code !== "criterion-scope")) {
686
+ const blockingCodes = new Set(["criterion-scope", "fence-symbol-absent"]);
687
+ const blockingFindings = authoringFindings.filter((finding) => options.strict || blockingCodes.has(finding.code));
688
+ for (const finding of authoringFindings.filter((finding) => !blockingFindings.includes(finding))) {
642
689
  console.warn(renderAuthoringFinding(finding));
643
690
  }
644
- const scopeErrors = authoringFindings.filter(({ code }) => code === "criterion-scope");
645
- if (scopeErrors.length > 0) {
646
- throw new CompileError(`${file} violates the criterion-scope authoring lint (${scopeErrors.length} error${scopeErrors.length === 1 ? "" : "s"}):\n`
647
- + scopeErrors.map((finding) => ` - ${renderAuthoringFinding(finding)}`).join("\n"));
691
+ if (blockingFindings.length > 0) {
692
+ const strict = options.strict ? " under --strict" : "";
693
+ const onlyCriterionScope = blockingFindings.every(({ code }) => code === "criterion-scope");
694
+ const label = onlyCriterionScope && !options.strict ? "the criterion-scope authoring lint" : `authoring lints${strict}`;
695
+ throw new CompileError(`${file} violates ${label} (${blockingFindings.length} error${blockingFindings.length === 1 ? "" : "s"}):\n`
696
+ + blockingFindings.map((finding) => ` - ${renderAuthoringFinding(finding)}`).join("\n"));
648
697
  }
649
698
  // v1.19 read-old/write-new: a plain-string acceptance item compiles as a judge oracle. This is the
650
699
  // one-time nudge toward typed oracles (command/test/judge); PRD/Spec Kit/GSD stay silent (untouched).
@@ -805,9 +854,9 @@ acceptance is required on every task (a nested list of observable outcomes).
805
854
  set closed — this milestone paid a halted run to learn that the two populations are not identical.
806
855
  - SPIKE-THE-CONTRACT-THEN-SCOPE trigger question: COULD A TEST THIS TASK DOES NOT OWN BE ASSERTING THE
807
856
  THING I AM CHANGING? "I'D HAVE TO GREP TO KNOW" IS YES. This applies to observable contracts:
808
- execution order, event-stream order, diagnostics/output sets, CLI surface, serialised formats, or
809
- timing measurements. If yes, implement the change as a throwaway spike, run the full suite, read the
810
- reds, THEN scope files[].
857
+ execution order, event-stream order, diagnostics/output sets, CLI surface, serialised formats,
858
+ timing measurements, or adding or removing a shipped file. If yes, implement the change as a
859
+ throwaway spike, run the full suite, read the reds, THEN scope files[].
811
860
  - Caveat: a spike measures ONE implementation. It converts unknown collateral into
812
861
  measured-for-one-specimen collateral; it does NOT make its reds the closed blocker set for every
813
862
  route. A worker taking a different route can still red on unowned collateral; that remains a PLAN