tickmarkr 2.2.0 → 2.3.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 (59) hide show
  1. package/README.md +10 -9
  2. package/dist/adapters/model-lints.js +9 -0
  3. package/dist/adapters/pi.d.ts +1 -0
  4. package/dist/adapters/pi.js +15 -1
  5. package/dist/adapters/prompt.js +11 -3
  6. package/dist/adapters/registry.js +13 -4
  7. package/dist/adapters/types.d.ts +23 -1
  8. package/dist/adapters/types.js +43 -2
  9. package/dist/cli/commands/approve.d.ts +3 -7
  10. package/dist/cli/commands/approve.js +26 -20
  11. package/dist/cli/commands/beat.js +7 -4
  12. package/dist/cli/commands/doctor.d.ts +6 -2
  13. package/dist/cli/commands/doctor.js +79 -9
  14. package/dist/cli/commands/init.js +36 -21
  15. package/dist/cli/commands/plan.js +20 -3
  16. package/dist/cli/commands/report.js +37 -1
  17. package/dist/cli/commands/verify.d.ts +5 -0
  18. package/dist/cli/commands/verify.js +142 -25
  19. package/dist/cli/commands/version.d.ts +2 -1
  20. package/dist/cli/commands/version.js +25 -4
  21. package/dist/compile/collateral.js +15 -9
  22. package/dist/compile/native.js +5 -3
  23. package/dist/config/config.js +1 -1
  24. package/dist/drivers/index.d.ts +7 -0
  25. package/dist/drivers/index.js +40 -10
  26. package/dist/drivers/orca.d.ts +41 -1
  27. package/dist/drivers/orca.js +192 -15
  28. package/dist/drivers/subprocess.d.ts +3 -3
  29. package/dist/drivers/subprocess.js +16 -9
  30. package/dist/drivers/types.d.ts +2 -0
  31. package/dist/gates/baseline.d.ts +4 -0
  32. package/dist/gates/baseline.js +68 -16
  33. package/dist/gates/llm.d.ts +7 -1
  34. package/dist/gates/llm.js +66 -35
  35. package/dist/gates/review.d.ts +3 -1
  36. package/dist/gates/review.js +42 -12
  37. package/dist/gates/run-gates.d.ts +6 -0
  38. package/dist/gates/run-gates.js +25 -10
  39. package/dist/gates/verdict-cause.d.ts +6 -2
  40. package/dist/gates/verdict-cause.js +8 -4
  41. package/dist/run/consult.d.ts +7 -0
  42. package/dist/run/consult.js +21 -3
  43. package/dist/run/daemon.d.ts +14 -0
  44. package/dist/run/daemon.js +249 -27
  45. package/dist/run/git.d.ts +1 -0
  46. package/dist/run/git.js +4 -0
  47. package/dist/run/journal.d.ts +15 -2
  48. package/dist/run/journal.js +70 -12
  49. package/dist/run/supervision.d.ts +6 -0
  50. package/dist/run/supervision.js +29 -1
  51. package/dist/tui/ink/init-app.js +4 -4
  52. package/package.json +1 -1
  53. package/skills/tickmarkr-loop/SKILL.md +1 -0
  54. package/skills/tickmarkr-overseer/SKILL.md +88 -18
  55. package/skills/tickmarkr-overseer/scripts/seat-send.sh +88 -18
  56. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +36 -2
  57. package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +36 -15
  58. package/skills/tickmarkr-overseer/scripts/watch-context.sh +35 -9
  59. package/skills/tickmarkr-overseer/scripts/watch-pending-input.sh +32 -8
@@ -1,5 +1,6 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
+ import { join, relative } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { createInterface } from "node:readline/promises";
5
6
  import { parseArgs } from "node:util";
@@ -8,20 +9,20 @@ import { configTemplate, DEFAULT_CONFIG, globalConfigDir, loadConfig } from "../
8
9
  import { LEGACY_PREFIX, specTemplate } from "../../compile/native.js";
9
10
  import { BANNER, kvRow, legend, rule, statusRow, title } from "../../brand.js";
10
11
  import { tickmarkrDir } from "../../graph/graph.js";
12
+ import { orcaHostDetected } from "../../drivers/index.js";
11
13
  import { Journal } from "../../run/journal.js";
12
14
  import { doctor } from "./doctor.js";
13
15
  import { assembleFleetEditor } from "./fleet.js";
14
16
  const SCAFFOLD_SPEC = "tickmarkr.spec.md";
15
17
  // Operator-approved (2026-07-17) environments footer — no npm install for herdr (npm package
16
- // "herdr" is a reserved 0.0.0 placeholder as of that date). The orca row joins it with the v2.1
17
- // driver: it is a THIRD execution surface an operator selects outright — `auto` still resolves
18
- // herdr-else-subprocess and never picks it, so the footer names it beside the other two choices.
18
+ // "herdr" is a reserved 0.0.0 placeholder as of that date). Auto resolves herdr → orca →
19
+ // subprocess; the orca leg is environment-only, using the two markers Orca stamps in its terminals.
19
20
  const ENVIRONMENTS_FOOTER = [
20
21
  "environments:",
21
- " herdr — the full cockpit — every worker, judge, and consult is a visible pane you can watch and unblock · https://herdr.dev",
22
- " orca — visible terminals in the Orca app — an explicit driver choice: set driver: orca (auto never picks it) · https://onorca.dev",
22
+ " herdr — the full cockpit — every worker, judge, and consult is a visible pane you can watch and unblock · https://herdr.dev · auto picks it first when HERDR_ENV=1",
23
+ " orca — visible terminals in the Orca app — auto picks it inside an Orca terminal after herdr and before subprocess; set driver: orca to force it · https://onorca.dev",
23
24
  " claude code — tickmarkr init --agent installs the /tkr skills + AGENTS.md so Claude Code (or any agent CLI) drives the loop natively",
24
- " anywhere — no herdr? same fail-closed gates, headless subprocess driver",
25
+ " anywhere — no herdr or Orca terminal? same fail-closed gates, headless subprocess driver (legacy: no herdr? same fail-closed gates, headless subprocess driver when Orca markers are absent)",
25
26
  ].join("\n");
26
27
  /** Latest journal without a run-end event, if any. */
27
28
  function activeRunId(cwd) {
@@ -140,17 +141,6 @@ you were handed the whole picture — list the repository root, open every guida
140
141
  direction each moves, what makes a criterion real, and why an absence or a source-text grep is never a
141
142
  criterion. Read it before authoring or repairing acceptance items.
142
143
  - **Editing \`src/gates/\`, \`src/compile/\` or \`src/graph/\`?** Read \`docs/codebase/ARCHITECTURE.md\` first.
143
- - **EVERY fix gets a ship/no-ship decision, recorded, at the moment it is made.** Ask one question of each
144
- one: *does a user hit this defect?* If yes, the fix belongs in \`src/**\` or \`skills/**\` — the only trees
145
- the package carries (\`files: [dist, schema, skills, fixtures]\`). A script, overlay, config entry or
146
- operator-side workaround that resolves the symptom **locally is not the fix; it is a decision to leave
147
- every other user broken**, and it must say so in writing and name the condition that removes it.
148
- **The default answer is SHIP.** A local remedy is the exception and carries the burden of proof.
149
- Watch for the three shapes this hides in: a fix applied where you happened to be standing rather than
150
- where the defect lives; an observation filed with a product fix named in its own text and queued
151
- nowhere; and a local tool that quietly grows into a product feature nobody shipped. **A defect and its
152
- fix must be recorded in the same place, or the queue silently becomes a list of things everyone assumed
153
- someone else had shipped.**
154
144
  ${DOCS_END}
155
145
  `;
156
146
  // Every applicable host location gets the skills, each paired with its own repository guidance
@@ -229,7 +219,28 @@ const describeDrift = (d) => [
229
219
  // should not be asked again just because a copy drifted. Staleness is a different question with a
230
220
  // different answer (`--agent --force`), and it is reported by name on every init path below.
231
221
  const skillsInstalled = (cwd) => hostTargets(cwd).every((t) => AGENT_SKILLS.every((s) => existsSync(join(t.skillsDir, s, "SKILL.md"))));
232
- const wizardDriverDefault = () => process.env.HERDR_ENV === "1" ? "herdr" : "auto";
222
+ const wizardDriverDefault = () => process.env.HERDR_ENV === "1" ? "herdr" : orcaHostDetected() ? "orca" : "auto";
223
+ function packageName(cwd) {
224
+ try {
225
+ const parsed = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
226
+ return typeof parsed.name === "string" ? parsed.name : null;
227
+ }
228
+ catch {
229
+ return null;
230
+ }
231
+ }
232
+ function gitTracked(cwd, path) {
233
+ const rel = relative(cwd, path);
234
+ if (rel.startsWith(".."))
235
+ return false;
236
+ try {
237
+ execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd, stdio: "ignore" });
238
+ return true;
239
+ }
240
+ catch {
241
+ return false;
242
+ }
243
+ }
233
244
  async function installAgentFiles(cwd, force, docs, notes) {
234
245
  const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
235
246
  let prompt;
@@ -257,13 +268,14 @@ async function installAgentFiles(cwd, force, docs, notes) {
257
268
  notes.push(`kept current ${dest}`);
258
269
  continue;
259
270
  }
271
+ const tracked = exists && gitTracked(cwd, dest);
260
272
  if (exists && !force && !(await confirm(`Overwrite ${dest} (${describeDrift(stale)})?`))) {
261
- notes.push(`skipped existing ${dest} — ${describeDrift(stale)}; pass --force to overwrite it`);
273
+ notes.push(`skipped existing ${tracked ? "tracked " : ""}${dest} — ${describeDrift(stale)}; pass --force to overwrite it`);
262
274
  continue;
263
275
  }
264
276
  // whole skill dir, not just SKILL.md — the overseer ships its pane-watcher script
265
277
  cpSync(fileURLToPath(new URL(`../../../skills/${skill}`, import.meta.url)), join(skillsDir, skill), { recursive: true });
266
- notes.push(`${exists ? "overwrote" : "wrote"} ${dest}`);
278
+ notes.push(`${exists ? "overwrote" : "wrote"} ${tracked ? "tracked " : ""}${dest}`);
267
279
  }
268
280
  const current = existsSync(docPath) ? readFileSync(docPath, "utf8") : "";
269
281
  // The block is CANONICAL guidance ("stated once here, restated nowhere"), so a repo that has
@@ -341,6 +353,9 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
341
353
  });
342
354
  // banner at START on the visual surface — every init path, not just the wizard, and never trailing the probe (operator report 2026-07-17)
343
355
  emitBanner();
356
+ if (packageName(cwd) === "tickmarkr") {
357
+ throw new Error("OBS-584: tickmarkr init refuses to run inside the tickmarkr source repository");
358
+ }
344
359
  const gdir = values["global-dir"] ?? globalConfigDir();
345
360
  mkdirSync(gdir, { recursive: true });
346
361
  const notes = [];
@@ -1,7 +1,9 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseArgs } from "node:util";
1
4
  import { allAdapters, discoverChannels, doctorAgeMs, modelAuthExclusions, probeAll, readDoctor, servableExclusions, servabilityLine } from "../../adapters/registry.js";
2
5
  import { formatModelAuthLine, contextWindowLints, modelLints, preferEntryLints, ttyVisual } from "../../adapters/model-lints.js";
3
6
  import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
4
- import { parseArgs } from "node:util";
5
7
  import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
6
8
  import { classifyContextPath } from "../../compile/native.js";
7
9
  import { DEFAULT_CONFIG, overlayPreferShapes, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
@@ -17,6 +19,7 @@ import { loadRoutingProfile } from "../../run/journal.js";
17
19
  import { harnessLine, resolveHarness } from "../harness.js";
18
20
  import { shq } from "../../adapters/types.js";
19
21
  import { shGit } from "../../run/git.js";
22
+ import { driverEvidence, pickDriver } from "../../drivers/index.js";
20
23
  // T4 (v1.50): TTY-only brand pass — the title helper frames the routing table, lint/unroutable
21
24
  // markers carry the attention glyph, section labels dim to chrome (the doctor/status system).
22
25
  // Gated on ttyVisual(): the non-TTY surface returns untouched (byte-pinned, machine-consumable).
@@ -99,16 +102,21 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
99
102
  const DOCTOR_STALE_MS = 24 * 60 * 60 * 1000;
100
103
  // v1.51 T2: plan previews any mode without a config edit — same source precedence as run
101
104
  // (flag > spec front-matter > repo > global > default), same preset compiler.
102
- const { values } = parseArgs({ args: argv, options: { mode: { type: "string" } }, allowPositionals: true });
105
+ const { values } = parseArgs({ args: argv, options: { mode: { type: "string" }, driver: { type: "string" } }, allowPositionals: true });
103
106
  if (values.mode !== undefined && !ROUTING_MODES.includes(values.mode)) {
104
107
  throw new Error(`--mode must be one of ${ROUTING_MODES.join(" | ")} (got ${values.mode})`);
105
108
  }
106
109
  const g = loadGraph(cwd);
107
110
  const oracleRefusals = new Map();
111
+ const oracleAdvisories = new Map();
108
112
  if (g.tasks.some((task) => task.acceptance.some((item) => typeof item === "object" && item.oracle === "test"))) {
109
113
  const listing = await (opts.listTests ?? listVitestTests)(cwd);
110
114
  for (const task of g.tasks) {
111
115
  const refusals = [];
116
+ const advisories = [];
117
+ const authoredTest = task.files
118
+ .map((path) => path.replace(/^\.\//, ""))
119
+ .find((path) => /^tests\/.+\.test\.ts$/.test(path) && !/[?*{[]/.test(path) && !existsSync(join(cwd, path)));
112
120
  if (listing.status === "failed") {
113
121
  if (task.acceptance.some((item) => typeof item === "object" && item.oracle === "test")) {
114
122
  refusals.push(`acceptance oracle unresolved — runner listing failed: ${listing.error.split("\n")[0]}`);
@@ -116,7 +124,10 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
116
124
  }
117
125
  else {
118
126
  for (const audit of auditNamedTestOracles(task.acceptance, listing.tests)) {
119
- if (audit.matches.length === 0) {
127
+ if (audit.matches.length === 0 && authoredTest) {
128
+ advisories.push(`acceptance oracle ${JSON.stringify(audit.criterion)} not yet written (worker authors ${authoredTest})`);
129
+ }
130
+ else if (audit.matches.length === 0) {
120
131
  refusals.push(`acceptance oracle ${JSON.stringify(audit.criterion)} matches zero runner-listed test names`);
121
132
  }
122
133
  else if (audit.matches.length > 1) {
@@ -126,6 +137,8 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
126
137
  }
127
138
  if (refusals.length)
128
139
  oracleRefusals.set(task.id, refusals);
140
+ if (advisories.length)
141
+ oracleAdvisories.set(task.id, advisories);
129
142
  }
130
143
  }
131
144
  const inputFindings = await taskInputFindings(g.tasks, cwd);
@@ -136,6 +149,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
136
149
  inputRefusals.set(finding.taskId, [...(inputRefusals.get(finding.taskId) ?? []), finding.detail]);
137
150
  }
138
151
  const { cfg, mode, source } = resolveRunMode(cwd, { flag: values.mode, spec: g.mode });
152
+ const selectedDriver = pickDriver(cfg, values.driver);
139
153
  // readDoctor cache path: staleness line only fires here (probeAll fallback is fresh by construction).
140
154
  const cached = readDoctor(cwd);
141
155
  const health = cached ?? (await probeAll(adapters));
@@ -157,6 +171,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
157
171
  const lines = [
158
172
  `tickmarkr plan — dry run (${channels.length} channels available)`,
159
173
  `mode: ${mode.mode} (${source}) · explore ${cfg.routing.explore?.mode ?? "on"}`,
174
+ `driver: ${driverEvidence(cfg, selectedDriver, values.driver)}`,
160
175
  harnessLine(resolveHarness(harnessFrom)),
161
176
  "",
162
177
  ];
@@ -280,6 +295,8 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
280
295
  lines.push(d);
281
296
  lints.push(`${t.id}: unroutable — ${msg}`);
282
297
  }
298
+ for (const advisory of oracleAdvisories.get(t.id) ?? [])
299
+ lines.push(` ! ${advisory}`);
283
300
  }
284
301
  const inputWarnings = inputFindings.filter((finding) => finding.severity === "warn");
285
302
  if (inputWarnings.length) {
@@ -4,6 +4,7 @@ import { ttyVisual } from "../../adapters/model-lints.js";
4
4
  import { addUsage } from "../../adapters/types.js";
5
5
  import { dim, rule, title } from "../../brand.js";
6
6
  import { loadConfig } from "../../config/config.js";
7
+ import { modelProvider } from "../../gates/review.js";
7
8
  import { buildProofBundle, gateDeclined } from "../../report/bundle.js";
8
9
  import { compareRuns } from "../../report/compare.js";
9
10
  import { estimateCosts } from "../../report/cost.js";
@@ -35,6 +36,22 @@ const channelLabel = (data) => {
35
36
  const { adapter, model } = a;
36
37
  return typeof adapter === "string" && typeof model === "string" ? `${adapter}:${model}` : EM;
37
38
  };
39
+ const modelFromChannel = (channel) => channel.slice(channel.indexOf(":") + 1);
40
+ const reviewerIdentity = (data) => {
41
+ const details = typeof data.details === "string" ? data.details : "";
42
+ const fromDetails = /reviewer\s+([^\s;()]+:[^\s;()]+)/i.exec(details)?.[1];
43
+ const reviewer = typeof data.reviewer === "string" ? data.reviewer : fromDetails;
44
+ if (!reviewer || !reviewer.includes(":"))
45
+ return undefined;
46
+ const vendor = typeof data.vendor === "string"
47
+ ? data.vendor
48
+ : /vendor:\s*([^;()]+)/i.exec(details)?.[1]?.trim()
49
+ ?? /\(([^;:()]+)\):/.exec(details)?.[1]?.trim();
50
+ const provider = typeof data.provider === "string"
51
+ ? data.provider
52
+ : modelProvider(modelFromChannel(reviewer), vendor ?? "unknown");
53
+ return { reviewer, provider, ...(vendor ? { vendor } : {}) };
54
+ };
38
55
  const rateBasis = (row) => {
39
56
  if (!row.rate)
40
57
  return EM;
@@ -245,6 +262,7 @@ export function renderMarkdownRecord(runId, events, prices = [], rows = []) {
245
262
  const dispatches = events.filter((e) => e.taskId === taskId && e.event === "task-dispatch");
246
263
  const channels = dispatches.map((e) => channelLabel(e.data));
247
264
  const gates = events.filter((e) => e.taskId === taskId && e.event === "gate-result");
265
+ const leg2 = events.filter((e) => e.taskId === taskId && e.event === "review-leg2");
248
266
  const consults = events.filter((e) => e.taskId === taskId && e.event === "consult-verdict");
249
267
  const deviations = events.filter((e) => e.taskId === taskId && e.event === "route-deviation");
250
268
  const merge = [...events].reverse().find((e) => e.taskId === taskId && e.event === "merge");
@@ -267,10 +285,28 @@ export function renderMarkdownRecord(runId, events, prices = [], rows = []) {
267
285
  if (gates.length) {
268
286
  for (const g of gates) {
269
287
  // T11: a declined gate never ran — report it as declined, never as a pass.
270
- const pass = gateDeclined(g.data) ? "declined" : g.data.pass === true ? "pass" : g.data.pass === false ? "fail" : EM;
288
+ let pass = gateDeclined(g.data) ? "declined" : g.data.pass === true ? "pass" : g.data.pass === false ? "fail" : EM;
271
289
  const gate = typeof g.data.gate === "string" ? g.data.gate : EM;
290
+ if (gate === "review" && pass === "pass") {
291
+ const reviewer = reviewerIdentity(g.data);
292
+ const before = events.slice(0, events.indexOf(g) + 1).reverse()
293
+ .find((e) => e.taskId === taskId && e.event === "task-dispatch");
294
+ const author = channelLabel(before?.data ?? {});
295
+ const authorProvider = author === EM ? "unknown" : modelProvider(modelFromChannel(author));
296
+ if (reviewer && authorProvider !== "unknown" && authorProvider === reviewer.provider) {
297
+ pass = "pass (same-provider — independence not established)";
298
+ }
299
+ }
272
300
  lines.push(` - ${gate}: ${pass} — ${firstLine(g.data.details)}`);
273
301
  }
302
+ for (const row of leg2) {
303
+ const pass = row.data.pass === true ? "pass" : row.data.pass === false ? "fail" : EM;
304
+ const identity = reviewerIdentity(row.data);
305
+ const identityText = identity
306
+ ? `reviewer ${identity.reviewer}${identity.vendor ? ` (vendor: ${identity.vendor}; provider: ${identity.provider})` : ` (provider: ${identity.provider})`}`
307
+ : firstLine(row.data.details);
308
+ lines.push(` - review-leg2: ${pass} (supersedes daemon review) — ${identityText}`);
309
+ }
274
310
  }
275
311
  else
276
312
  lines.push(` - ${EM}`);
@@ -4,6 +4,11 @@ export declare function parseCriteria(text: string): AcceptanceItem[];
4
4
  export declare const HUMAN_CHANNEL: BillingChannel;
5
5
  export declare const HUMAN_AUTHOR: Assignment;
6
6
  export declare function verifyStateDir(cwd: string): string;
7
+ /** Linked worktrees share operational state with the repository that owns the common git dir. */
8
+ export declare function verifyStateRoot(cwd: string): Promise<string>;
9
+ /** Cache identity is repository content, never a checkout path. */
10
+ export declare function baselineCachePath(cwd: string, baseSha: string, commands: Record<string, string>): string;
11
+ export declare function excludeAuthorProvider(channels: BillingChannel[], author: BillingChannel): BillingChannel[];
7
12
  export declare function verify(argv: string[], cwd?: string): Promise<{
8
13
  out: string;
9
14
  code: number;
@@ -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,6 +61,70 @@ 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
+ }
62
128
  export async function verify(argv, cwd = process.cwd()) {
63
129
  const { values } = parseArgs({
64
130
  args: argv,
@@ -69,13 +135,18 @@ export async function verify(argv, cwd = process.cwd()) {
69
135
  files: { type: "string", multiple: true },
70
136
  author: { type: "string" },
71
137
  baseline: { type: "string" },
138
+ record: { type: "string" },
72
139
  json: { type: "boolean", default: false },
73
140
  "no-review": { type: "boolean", default: false },
74
141
  "no-acceptance": { type: "boolean", default: false },
75
142
  },
76
143
  allowPositionals: false,
77
144
  });
78
- const cfg = loadConfig(cwd);
145
+ const stateRoot = await verifyStateRoot(cwd);
146
+ if (resolve(stateRoot) !== resolve(cwd)) {
147
+ console.error(`verify: state files graph.json, doctor.json and config.yaml resolved read-only from ${join(stateRoot, ".tickmarkr")} (linked worktree)`);
148
+ }
149
+ const cfg = loadConfig(stateRoot);
79
150
  const head = (await shGitOk("git rev-parse HEAD", cwd)).trim();
80
151
  const baseTip = (await shGitOk(`git rev-parse '${values.base}'`, cwd).catch(() => {
81
152
  throw new Error(`--base ${values.base} is not a resolvable ref — pass --base <ref> naming the branch this diff targets`);
@@ -89,9 +160,7 @@ export async function verify(argv, cwd = process.cwd()) {
89
160
  let files = values.files ?? [];
90
161
  let goal = `independent verification of the ${values.base}..HEAD diff`;
91
162
  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`);
163
+ const t = getTask(loadGraph(stateRoot), values.task);
95
164
  acceptance = t.acceptance;
96
165
  if (!files.length)
97
166
  files = t.files;
@@ -102,6 +171,8 @@ export async function verify(argv, cwd = process.cwd()) {
102
171
  if (!acceptance.length)
103
172
  throw new Error(`--criteria ${values.criteria}: no criteria found (bullets or command:/test:/judge: lines)`);
104
173
  }
174
+ if (values.task)
175
+ await warnWideTaskRange(cwd, stateRoot, values.task, mergeBase);
105
176
  const wantAcceptance = acceptance.length > 0 && !values["no-acceptance"];
106
177
  const wantReview = !values["no-review"];
107
178
  // A gate that enforced nothing must not print a green row. `files` has exactly three sources —
@@ -145,7 +216,7 @@ export async function verify(argv, cwd = process.cwd()) {
145
216
  let author = HUMAN_AUTHOR;
146
217
  const adapters = allAdapters();
147
218
  if (wantAcceptance || wantReview) {
148
- const health = readDoctor(cwd) ?? (await probeAll(adapters));
219
+ const health = readDoctor(stateRoot) ?? (await probeAll(adapters));
149
220
  const pools = rolePools(cfg, adapters, health);
150
221
  judgeChannels = pools.judge;
151
222
  channels = pools.review;
@@ -157,6 +228,7 @@ export async function verify(argv, cwd = process.cwd()) {
157
228
  throw new Error(`--author ${values.author} does not name a discoverable review channel — one of: ${channels.map(channelKey).join(", ") || "(none)"}`);
158
229
  }
159
230
  author = { adapter: c.adapter, model: c.model, channel: c.channel, tier: c.tier };
231
+ channels = excludeAuthorProvider(channels, c);
160
232
  }
161
233
  else {
162
234
  channels = [...channels, HUMAN_CHANNEL];
@@ -173,28 +245,56 @@ export async function verify(argv, cwd = process.cwd()) {
173
245
  const stateDir = verifyStateDir(cwd);
174
246
  mkdirSync(stateDir, { recursive: true });
175
247
  let baseline;
176
- const cachePath = join(stateDir, `baseline-${mergeBase.slice(0, 12)}.json`);
248
+ const cachePath = baselineCachePath(cwd, mergeBase, commands);
249
+ const verdictlessMarker = `${cachePath}.verdictless`;
250
+ mkdirSync(dirname(cachePath), { recursive: true });
177
251
  if (values.baseline) {
178
252
  baseline = JSON.parse(readFileSync(values.baseline, "utf8"));
179
253
  }
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
254
  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));
255
+ let cached;
256
+ if (existsSync(cachePath)) {
257
+ cached = JSON.parse(readFileSync(cachePath, "utf8"));
258
+ const missing = verdictlessCommands(cached, commands);
259
+ if (missing.length) {
260
+ console.error(`verify: cached baseline recorded no verdict for ${missing.join(", ")}; it was not reusable and will be recaptured`);
261
+ rmSync(cachePath, { force: true });
262
+ cached = undefined;
263
+ }
193
264
  }
194
- finally {
195
- await removeWorktree(cwd, baseDir);
265
+ if (cached) {
266
+ console.error(`verify: reusing cached baseline for ${mergeBase.slice(0, 12)} (${cachePath})`);
267
+ baseline = cached;
268
+ }
269
+ else {
270
+ if (existsSync(verdictlessMarker)) {
271
+ console.error(`verify: prior baseline recorded no verdict and was not cached; recapturing merge-base ${mergeBase.slice(0, 12)}`);
272
+ }
273
+ else {
274
+ console.error(`verify: capturing baseline at merge-base ${mergeBase.slice(0, 12)} (cached by base, lockfile and command hashes at ${cachePath})`);
275
+ }
276
+ const baseDir = join(stateDir, `base-${mergeBase.slice(0, 12)}`);
277
+ await shGit(`git worktree remove --force '${baseDir}'`, cwd); // stale leftover from an interrupted run
278
+ await shGitOk(`git worktree add --detach '${baseDir}' '${mergeBase}'`, cwd);
279
+ try {
280
+ linkNodeModules(cwd, baseDir, { force: true });
281
+ baseline = await captureBaseline(baseDir, commands);
282
+ const missing = verdictlessCommands(baseline, commands);
283
+ if (missing.length) {
284
+ writeFileSync(verdictlessMarker, JSON.stringify({ base: mergeBase, commands: missing }) + "\n");
285
+ rmSync(cachePath, { force: true });
286
+ }
287
+ else {
288
+ writeFileSync(cachePath, JSON.stringify(baseline, null, 2));
289
+ rmSync(verdictlessMarker, { force: true });
290
+ }
291
+ }
292
+ finally {
293
+ await removeWorktree(cwd, baseDir);
294
+ }
196
295
  }
197
296
  }
297
+ const recordJournal = values.record ? Journal.open(stateRoot, values.record) : undefined;
198
298
  const artifactDir = join(stateDir, new Date().toISOString().replace(/[:.]/g, "-"));
199
299
  mkdirSync(artifactDir, { recursive: true });
200
300
  const { results } = await runGates(task, {
@@ -205,17 +305,34 @@ export async function verify(argv, cwd = process.cwd()) {
205
305
  onGate: (e) => {
206
306
  if (e.phase === "start")
207
307
  console.error(`verify: → ${e.gate} (${e.index}/${e.total})`);
308
+ else if (e.phase === "note")
309
+ console.error(`verify: note ${e.gate} ${e.name} ${JSON.stringify(e.payload)}`);
208
310
  else
209
311
  console.error(`verify: ${e.result.pass ? "✓" : "✗"} ${e.result.gate} — ${e.result.details.split("\n")[0] ?? ""}`);
210
312
  },
211
313
  });
212
314
  const green = results.length > 0 && results.every((r) => r.pass || r.meta?.skipped === true);
315
+ const reviewRows = results.filter((result) => result.gate === "review");
316
+ const reviewFindings = reviewRows.flatMap((result) => result.details.split("\n").flatMap((line) => {
317
+ const match = /^- \[([^\]]+)\] (.*)$/.exec(line);
318
+ return match ? [{ classification: match[1], note: match[2], reviewer: result.meta?.reviewer }] : [];
319
+ }));
320
+ const artifactPath = join(artifactDir, "verify-results.json");
321
+ writeFileSync(artifactPath, JSON.stringify({ base: baseTip, head, mergeBase, green, gateRows: results, reviewFindings }, null, 2) + "\n");
322
+ console.error(`verify: artifacts written to ${artifactPath}`);
323
+ const review = reviewRows.at(-1);
324
+ if (recordJournal && review) {
325
+ recordJournal.append("review-leg2", values.task ?? "VERIFY", {
326
+ base: baseTip, head, mergeBase, author: channelKey(author), artifactPath,
327
+ ...review,
328
+ });
329
+ }
213
330
  if (values.json) {
214
- return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, results }, null, 2), code: green ? 0 : 2 };
331
+ return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, results }, null, 2), code: green ? 0 : 2 };
215
332
  }
216
333
  const lines = results.map((r) => `${r.pass ? "PASS" : "FAIL"} ${r.gate}\n${r.details.split("\n").map((l) => ` ${l}`).join("\n")}`);
217
334
  const verdict = green
218
- ? `verify GREEN — ${results.length} gate(s) passed on ${mergeBase.slice(0, 12)}..${head.slice(0, 12)} (merge is a human decision)`
219
- : `verify RED — first failure decides; artifacts in ${artifactDir}`;
335
+ ? `verify GREEN — ${results.length} gate(s) passed on ${mergeBase.slice(0, 12)}..${head.slice(0, 12)} (merge is a human decision; artifacts: ${artifactPath})`
336
+ : `verify RED — first failure decides; artifacts: ${artifactPath}`;
220
337
  return { out: [...lines, "", verdict].join("\n"), code: green ? 0 : 2 };
221
338
  }
@@ -1 +1,2 @@
1
- export declare function version(_argv?: string[]): Promise<string>;
1
+ export declare function distFingerprint(dir?: string): string;
2
+ export declare function version(argv?: string[], dir?: string): Promise<string>;
@@ -1,8 +1,29 @@
1
- import { readFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
+ import { dirname, join, relative } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "../../../package.json");
5
- export async function version(_argv = []) {
6
+ const distDir = join(dirname(pkgPath), "dist");
7
+ export function distFingerprint(dir = distDir) {
8
+ if (!existsSync(dir))
9
+ return "missing";
10
+ const files = [];
11
+ const visit = (path) => {
12
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
13
+ const child = join(path, entry.name);
14
+ if (entry.isDirectory())
15
+ visit(child);
16
+ else if (entry.isFile())
17
+ files.push(child);
18
+ }
19
+ };
20
+ visit(dir);
21
+ const hash = createHash("sha256");
22
+ for (const file of files.sort())
23
+ hash.update(relative(dir, file)).update("\0").update(readFileSync(file)).update("\0");
24
+ return hash.digest("hex");
25
+ }
26
+ export async function version(argv = [], dir = distDir) {
6
27
  const { version: v } = JSON.parse(readFileSync(pkgPath, "utf8"));
7
- return v;
28
+ return argv.includes("--dist") ? `${v} dist:${distFingerprint(dir)}` : v;
8
29
  }
@@ -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
  }