tickmarkr 2.2.1 → 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 (48) hide show
  1. package/README.md +10 -9
  2. package/dist/adapters/types.d.ts +20 -1
  3. package/dist/adapters/types.js +42 -2
  4. package/dist/cli/commands/approve.js +5 -4
  5. package/dist/cli/commands/beat.js +7 -4
  6. package/dist/cli/commands/doctor.d.ts +5 -1
  7. package/dist/cli/commands/doctor.js +67 -7
  8. package/dist/cli/commands/init.js +36 -21
  9. package/dist/cli/commands/plan.js +16 -2
  10. package/dist/cli/commands/report.js +37 -1
  11. package/dist/cli/commands/verify.d.ts +5 -0
  12. package/dist/cli/commands/verify.js +140 -25
  13. package/dist/compile/collateral.js +15 -9
  14. package/dist/compile/native.js +3 -3
  15. package/dist/config/config.js +1 -1
  16. package/dist/drivers/index.d.ts +6 -0
  17. package/dist/drivers/index.js +19 -4
  18. package/dist/drivers/orca.d.ts +22 -1
  19. package/dist/drivers/orca.js +147 -6
  20. package/dist/drivers/subprocess.d.ts +3 -3
  21. package/dist/drivers/subprocess.js +16 -9
  22. package/dist/drivers/types.d.ts +2 -0
  23. package/dist/gates/baseline.d.ts +2 -0
  24. package/dist/gates/baseline.js +21 -4
  25. package/dist/gates/llm.d.ts +6 -0
  26. package/dist/gates/llm.js +25 -9
  27. package/dist/gates/review.d.ts +3 -1
  28. package/dist/gates/review.js +40 -12
  29. package/dist/gates/run-gates.js +17 -10
  30. package/dist/gates/verdict-cause.d.ts +6 -2
  31. package/dist/gates/verdict-cause.js +8 -4
  32. package/dist/run/consult.js +5 -1
  33. package/dist/run/daemon.d.ts +12 -0
  34. package/dist/run/daemon.js +218 -25
  35. package/dist/run/git.d.ts +1 -0
  36. package/dist/run/git.js +4 -0
  37. package/dist/run/journal.d.ts +15 -2
  38. package/dist/run/journal.js +70 -12
  39. package/dist/run/supervision.d.ts +6 -0
  40. package/dist/run/supervision.js +29 -1
  41. package/dist/tui/ink/init-app.js +4 -4
  42. package/package.json +1 -1
  43. package/skills/tickmarkr-overseer/SKILL.md +77 -18
  44. package/skills/tickmarkr-overseer/scripts/seat-send.sh +88 -18
  45. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +36 -2
  46. package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +36 -15
  47. package/skills/tickmarkr-overseer/scripts/watch-context.sh +33 -7
  48. 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,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, {
@@ -212,12 +312,27 @@ export async function verify(argv, cwd = process.cwd()) {
212
312
  },
213
313
  });
214
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
+ }
215
330
  if (values.json) {
216
- 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 };
217
332
  }
218
333
  const lines = results.map((r) => `${r.pass ? "PASS" : "FAIL"} ${r.gate}\n${r.details.split("\n").map((l) => ` ${l}`).join("\n")}`);
219
334
  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}`;
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}`;
222
337
  return { out: [...lines, "", verdict].join("\n"), code: green ? 0 : 2 };
223
338
  }
@@ -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
  }
@@ -805,9 +805,9 @@ acceptance is required on every task (a nested list of observable outcomes).
805
805
  set closed — this milestone paid a halted run to learn that the two populations are not identical.
806
806
  - SPIKE-THE-CONTRACT-THEN-SCOPE trigger question: COULD A TEST THIS TASK DOES NOT OWN BE ASSERTING THE
807
807
  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[].
808
+ execution order, event-stream order, diagnostics/output sets, CLI surface, serialised formats,
809
+ timing measurements, or adding or removing a shipped file. If yes, implement the change as a
810
+ throwaway spike, run the full suite, read the reds, THEN scope files[].
811
811
  - Caveat: a spike measures ONE implementation. It converts unknown collateral into
812
812
  measured-for-one-specimen collateral; it does NOT make its reds the closed blocker set for every
813
813
  route. A worker taking a different route can still red on unowned collateral; that remains a PLAN
@@ -713,7 +713,7 @@ export function overlayBytesLoadError(repoRoot, bytes, opts = {}) {
713
713
  export function configTemplate(overlay) {
714
714
  const base = `# tickmarkr config overlay — merges over built-in defaults (repo beats global beats defaults)
715
715
  # concurrency: 3
716
- # driver: auto # auto | herdr | subprocess | orca
716
+ # driver: auto # auto: herdr when HERDR_ENV=1, then orca when TERM_PROGRAM=Orca + ORCA_TERMINAL_HANDLE, else subprocess; name orca explicitly outside an Orca terminal
717
717
  # taskTimeoutMinutes: 30
718
718
  # contextWarnTokens: 170000 # v1.23: journal+notify once per attempt when live worker context crosses this (status shows the sample)
719
719
  # setup: npm ci --prefer-offline # run in each fresh task worktree before dispatch
@@ -2,6 +2,12 @@ import type { TickmarkrConfig } from "../config/config.js";
2
2
  import type { ExecutorDriver } from "./types.js";
3
3
  export declare const DRIVER_CHOICES: readonly ["auto", "herdr", "subprocess", "orca"];
4
4
  export type DriverChoice = (typeof DRIVER_CHOICES)[number];
5
+ /**
6
+ * Orca authors both markers on every terminal it creates. Requiring the pair avoids treating an
7
+ * unrelated TERM_PROGRAM value or a copied terminal handle as host identity. This is deliberately
8
+ * environment-only: selection must not execute a binary or contact the Orca runtime.
9
+ */
10
+ export declare function orcaHostDetected(env?: NodeJS.ProcessEnv): boolean;
5
11
  /** Validate argv at the CLI boundary rather than casting an arbitrary string into a driver choice. */
6
12
  export declare function parseDriverOverride(override?: string): DriverChoice | undefined;
7
13
  export declare function driverEvidence(cfg: TickmarkrConfig, driver: ExecutorDriver, override?: string): string;
@@ -3,6 +3,14 @@ import { OrcaDriver } from "./orca.js";
3
3
  import { SubprocessDriver } from "./subprocess.js";
4
4
  export const DRIVER_CHOICES = ["auto", "herdr", "subprocess", "orca"];
5
5
  const overrideByDriver = new WeakMap();
6
+ /**
7
+ * Orca authors both markers on every terminal it creates. Requiring the pair avoids treating an
8
+ * unrelated TERM_PROGRAM value or a copied terminal handle as host identity. This is deliberately
9
+ * environment-only: selection must not execute a binary or contact the Orca runtime.
10
+ */
11
+ export function orcaHostDetected(env = process.env) {
12
+ return env.TERM_PROGRAM === "Orca" && env.ORCA_TERMINAL_HANDLE !== undefined;
13
+ }
6
14
  /** Validate argv at the CLI boundary rather than casting an arbitrary string into a driver choice. */
7
15
  export function parseDriverOverride(override) {
8
16
  if (override === undefined)
@@ -20,8 +28,13 @@ export function driverEvidence(cfg, driver, override) {
20
28
  if (want !== "auto")
21
29
  return `${driver.id} (config)`;
22
30
  const herdrAvailable = process.env.HERDR_ENV === "1";
23
- if (driver.id === (herdrAvailable ? "herdr" : "subprocess")) {
24
- return `auto → ${driver.id} (${herdrAvailable ? "HERDR_ENV=1" : "HERDR_ENV unset"})`;
31
+ if (herdrAvailable && driver.id === "herdr")
32
+ return "auto → herdr (HERDR_ENV=1)";
33
+ if (!herdrAvailable && orcaHostDetected() && driver.id === "orca") {
34
+ return "auto → orca (TERM_PROGRAM+ORCA_TERMINAL_HANDLE)";
35
+ }
36
+ if (!herdrAvailable && !orcaHostDetected() && driver.id === "subprocess") {
37
+ return "auto → subprocess (HERDR_ENV unset)";
25
38
  }
26
39
  return `auto → ${driver.id} (runtime)`;
27
40
  }
@@ -34,9 +47,11 @@ export function pickDriver(cfg, override) {
34
47
  const driver = want === "herdr" ? new HerdrDriver("herdr", cfg.visibility.workersPerTab)
35
48
  : want === "subprocess" ? new SubprocessDriver()
36
49
  // Orca is an operator-selected execution surface. Its runtime failure stays on Orca; selection
37
- // must never substitute a hidden subprocess worker after this explicit choice.
50
+ // must never substitute a hidden subprocess worker after an explicit or detected choice.
38
51
  : want === "orca" ? new OrcaDriver()
39
- : HerdrDriver.available() ? new HerdrDriver("herdr", cfg.visibility.workersPerTab) : new SubprocessDriver();
52
+ : HerdrDriver.available() ? new HerdrDriver("herdr", cfg.visibility.workersPerTab)
53
+ : orcaHostDetected() ? new OrcaDriver()
54
+ : new SubprocessDriver();
40
55
  if (selectedOverride !== undefined)
41
56
  overrideByDriver.set(driver, selectedOverride);
42
57
  return driver;
@@ -1,7 +1,8 @@
1
1
  import { type ShResult } from "../run/git.js";
2
+ import { type JournalEvent } from "../run/journal.js";
2
3
  import { type ExecutorDriver, type NotifyOpts, type Slot, type SlotOpts } from "./types.js";
3
4
  /** The response families the ONE shared envelope parser serves. There is no second JSON seam. */
4
- export declare const ORCA_RESPONSE_FAMILIES: readonly ["status", "create", "list", "read", "send", "wait", "show", "close"];
5
+ export declare const ORCA_RESPONSE_FAMILIES: readonly ["status", "create", "list", "read", "send", "wait", "show", "close", "worktree-current", "hooks-status"];
5
6
  export type OrcaFamily = (typeof ORCA_RESPONSE_FAMILIES)[number];
6
7
  export declare const ORCA_FIXTURE_VERSION = "1.4.195";
7
8
  export declare const ORCA_CLI_COMMAND_ENV = "ORCA_CLI_COMMAND";
@@ -13,6 +14,7 @@ export declare const NOT_WRITABLE_CODE = "terminal_not_writable";
13
14
  /** The ONLY terminal status that licenses reading a terminal's bytes or its agent state. */
14
15
  export declare const RUNNING_STATUS = "running";
15
16
  export declare const STATUS_GOVERNED_METHODS: readonly ["read", "waitOutput", "status", "waitAgentStatus"];
17
+ export declare const WORKTREE_ADOPTION_TIMEOUT_MS = 60000;
16
18
  export interface OrcaExec {
17
19
  (args: string[], cwd: string, timeoutMs?: number): Promise<ShResult>;
18
20
  }
@@ -113,6 +115,9 @@ export declare class OrcaDriver implements ExecutorDriver {
113
115
  private pageLines;
114
116
  private pollMs;
115
117
  private probeStalenessMs;
118
+ private journalRoots;
119
+ private narrate?;
120
+ private hookCoverage?;
116
121
  constructor(opts?: OrcaDriverOpts);
117
122
  private call;
118
123
  /** The live runtime's identity, or an explicit failure. A missing or unreachable runtime is a
@@ -128,6 +133,15 @@ export declare class OrcaDriver implements ExecutorDriver {
128
133
  private assertAvailable;
129
134
  run(slot: Slot, cmd: string): Promise<void>;
130
135
  private create;
136
+ /**
137
+ * A freshly-created git checkout does not become a valid Orca selector atomically. Ask
138
+ * `worktree current` FROM that checkout until Orca itself resolves the exact filesystem identity;
139
+ * an enclosing checkout is still not adoption. Only selector_not_found is a retryable refusal —
140
+ * malformed envelopes and every other refusal remain explicit driver failures.
141
+ */
142
+ private awaitWorktreeAdoption;
143
+ /** Same repo/run/narration path Herdr uses for its driver-owned dispatch-retry row. */
144
+ private appendAdoptionWait;
131
145
  /**
132
146
  * Every terminal-addressed call — read AND write — goes through here, and the runtime identity is
133
147
  * established BEFORE the runtime-scoped handle goes on the wire. Discarding a lookalike's answer
@@ -149,6 +163,8 @@ export declare class OrcaDriver implements ExecutorDriver {
149
163
  private liveShowTerm;
150
164
  private tailText;
151
165
  private readPage;
166
+ /** A rendered-frame liveness read. `--screen` and `--cursor` are mutually exclusive in Orca. */
167
+ private readScreen;
152
168
  /** A single UNPAGED tail read — exactly what the caller asked for and nothing more. Markers split
153
169
  * across cursor pages are not reassembled here; that is waitOutput's job. */
154
170
  read(slot: Slot, lines: number): Promise<string>;
@@ -163,12 +179,17 @@ export declare class OrcaDriver implements ExecutorDriver {
163
179
  regex?: boolean;
164
180
  }): Promise<boolean>;
165
181
  status(slot: Slot): Promise<string>;
182
+ private hookAgent;
183
+ /** true = hooked, false = definitively unhooked, undefined = agent absent from Orca's table. */
184
+ private agentHookAvailable;
185
+ private loadHookCoverage;
166
186
  /** One `terminal wait` through the full identity machinery. The recorded 1.4.186 elapsed answer
167
187
  * is rc 1 + ok:true + {handle, condition, satisfied:false, status:"running"}; it is "not yet"
168
188
  * only after this method validates all four fields. Any malformed/refused wait remains explicit. */
169
189
  private waitCondition;
170
190
  waitAgentStatus(slot: Slot, status: string, timeoutMs: number): Promise<boolean>;
171
191
  notify(msg: string, opts?: NotifyOpts): Promise<void>;
192
+ narrateWith(narrate: (event: JournalEvent) => void): void;
172
193
  close(slot: Slot): Promise<void>;
173
194
  /**
174
195
  * The one destructive call in this driver, for a slot's own terminal AND for a reconcile candidate