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
@@ -724,6 +724,7 @@ acceptance is required on every task (a nested list of observable outcomes).
724
724
  - command: <shell> (oracle: command — exit code)
725
725
  - test: <name> (oracle: test — named test)
726
726
  - judge: <rubric> (oracle: judge — LLM-judged, free text)
727
+ A judge criterion carries ONE claim; a semicolon-joined criterion warns — split its clauses.
727
728
  - <plain text> (compat: compiles as judge oracle, warns)
728
729
 
729
730
  HARD BOUNDS — these FAIL the compile, they do not warn:
@@ -804,9 +805,9 @@ acceptance is required on every task (a nested list of observable outcomes).
804
805
  set closed — this milestone paid a halted run to learn that the two populations are not identical.
805
806
  - SPIKE-THE-CONTRACT-THEN-SCOPE trigger question: COULD A TEST THIS TASK DOES NOT OWN BE ASSERTING THE
806
807
  THING I AM CHANGING? "I'D HAVE TO GREP TO KNOW" IS YES. This applies to observable contracts:
807
- execution order, event-stream order, diagnostics/output sets, CLI surface, serialised formats, or
808
- timing measurements. If yes, implement the change as a throwaway spike, run the full suite, read the
809
- 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[].
810
811
  - Caveat: a spike measures ONE implementation. It converts unknown collateral into
811
812
  measured-for-one-specimen collateral; it does NOT make its reds the closed blocker set for every
812
813
  route. A worker taking a different route can still red on unowned collateral; that remains a PLAN
@@ -839,6 +840,7 @@ acceptance is required on every task (a nested list of observable outcomes).
839
840
  ORDERING AND OWNERSHIP:
840
841
  - Every path has exactly ONE owning task. Two tasks writing one file must be ORDERED by deps, or the
841
842
  loser's work is silently dropped when the integration tip advances.
843
+ - A task changing what the daemon DOES must own every surface that TELLS the operator what the daemon does.
842
844
  - A file one task CREATES cannot be "context:" for another — only deps: carries it, and that extends to
843
845
  the task that PRODUCES a value, not just the file's existence.
844
846
  - Deleting or renaming a symbol is a cross-task contract. Sweep for consumers by symbol AND by what the
@@ -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,13 @@ 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;
13
+ export declare function driverEvidence(cfg: TickmarkrConfig, driver: ExecutorDriver, override?: string): string;
7
14
  export declare function pickDriver(cfg: TickmarkrConfig, override?: string): ExecutorDriver;
@@ -2,6 +2,15 @@ import { HerdrDriver } from "./herdr.js";
2
2
  import { OrcaDriver } from "./orca.js";
3
3
  import { SubprocessDriver } from "./subprocess.js";
4
4
  export const DRIVER_CHOICES = ["auto", "herdr", "subprocess", "orca"];
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
+ }
5
14
  /** Validate argv at the CLI boundary rather than casting an arbitrary string into a driver choice. */
6
15
  export function parseDriverOverride(override) {
7
16
  if (override === undefined)
@@ -11,18 +20,39 @@ export function parseDriverOverride(override) {
11
20
  return choice;
12
21
  throw new Error(`usage: --driver must be one of ${DRIVER_CHOICES.join(" | ")} (got ${override})`);
13
22
  }
23
+ export function driverEvidence(cfg, driver, override) {
24
+ const selectedOverride = override ?? overrideByDriver.get(driver);
25
+ const want = parseDriverOverride(selectedOverride) ?? cfg.driver;
26
+ if (selectedOverride !== undefined)
27
+ return `${driver.id} (--driver)`;
28
+ if (want !== "auto")
29
+ return `${driver.id} (config)`;
30
+ const herdrAvailable = process.env.HERDR_ENV === "1";
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)";
38
+ }
39
+ return `auto → ${driver.id} (runtime)`;
40
+ }
14
41
  export function pickDriver(cfg, override) {
15
- const want = parseDriverOverride(override) ?? cfg.driver;
42
+ const selectedOverride = parseDriverOverride(override);
43
+ const want = selectedOverride ?? cfg.driver;
16
44
  // VIS-09 item 2: plumb the per-tab cap into the HerdrDriver — the driver takes it as a constructor
17
45
  // param and never imports config (cfg is the only seam). Guaranteed present: DEFAULT_CONFIG seeds
18
46
  // workersPerTab:3 and deepMerge overlays on top, so a missing overlay key still resolves.
19
- if (want === "herdr")
20
- return new HerdrDriver("herdr", cfg.visibility.workersPerTab);
21
- if (want === "subprocess")
22
- return new SubprocessDriver();
23
- // Orca is an operator-selected execution surface. Its runtime failure stays on Orca; selection
24
- // must never substitute a hidden subprocess worker after this explicit choice.
25
- if (want === "orca")
26
- return new OrcaDriver();
27
- return HerdrDriver.available() ? new HerdrDriver("herdr", cfg.visibility.workersPerTab) : new SubprocessDriver();
47
+ const driver = want === "herdr" ? new HerdrDriver("herdr", cfg.visibility.workersPerTab)
48
+ : want === "subprocess" ? new SubprocessDriver()
49
+ // Orca is an operator-selected execution surface. Its runtime failure stays on Orca; selection
50
+ // must never substitute a hidden subprocess worker after an explicit or detected choice.
51
+ : want === "orca" ? new OrcaDriver()
52
+ : HerdrDriver.available() ? new HerdrDriver("herdr", cfg.visibility.workersPerTab)
53
+ : orcaHostDetected() ? new OrcaDriver()
54
+ : new SubprocessDriver();
55
+ if (selectedOverride !== undefined)
56
+ overrideByDriver.set(driver, selectedOverride);
57
+ return driver;
28
58
  }
@@ -1,13 +1,20 @@
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];
7
+ export declare const ORCA_FIXTURE_VERSION = "1.4.195";
8
+ export declare const ORCA_CLI_COMMAND_ENV = "ORCA_CLI_COMMAND";
6
9
  export declare const STALE_HANDLE_CODE = "terminal_handle_stale";
10
+ export declare const TERMINAL_GONE_CODE = "terminal_gone";
11
+ export declare const STALE_HANDLE_CODES: Set<string>;
12
+ export declare const WAIT_TIMEOUT_CODE = "timeout";
7
13
  export declare const NOT_WRITABLE_CODE = "terminal_not_writable";
8
14
  /** The ONLY terminal status that licenses reading a terminal's bytes or its agent state. */
9
15
  export declare const RUNNING_STATUS = "running";
10
16
  export declare const STATUS_GOVERNED_METHODS: readonly ["read", "waitOutput", "status", "waitAgentStatus"];
17
+ export declare const WORKTREE_ADOPTION_TIMEOUT_MS = 60000;
11
18
  export interface OrcaExec {
12
19
  (args: string[], cwd: string, timeoutMs?: number): Promise<ShResult>;
13
20
  }
@@ -38,6 +45,18 @@ export interface OrcaEnvelope {
38
45
  runtimeId: string;
39
46
  raw: string;
40
47
  }
48
+ export interface OrcaBinaryResolverOpts {
49
+ env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
50
+ platform?: NodeJS.Platform;
51
+ resolve?: (bin: string, cwd: string) => {
52
+ resolved?: string;
53
+ };
54
+ }
55
+ /**
56
+ * One Orca CLI name decision for both the driver and doctor. Linux desktop systems can have
57
+ * GNOME's screen-reader `orca` on PATH, so outside an Orca terminal the app CLI is `orca-ide`.
58
+ */
59
+ export declare function resolveOrcaCliBinary(cwd?: string, opts?: OrcaBinaryResolverOpts): string | undefined;
41
60
  /**
42
61
  * The one JSON seam. Fails CLOSED on every degenerate response — empty, unparseable (a truncated
43
62
  * body lands here), non-object, no boolean `ok`, `ok:false`, `ok:true` with no result object, or a
@@ -76,6 +95,8 @@ export declare function mapAgentState(term: Record<string, unknown>, tuiIdle: bo
76
95
  export declare function joinWrapped(raw: string): string;
77
96
  export interface OrcaDriverOpts {
78
97
  bin?: string;
98
+ env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
99
+ platform?: NodeJS.Platform;
79
100
  exec?: OrcaExec;
80
101
  time?: OrcaTimeSource;
81
102
  pageLines?: number;
@@ -94,6 +115,9 @@ export declare class OrcaDriver implements ExecutorDriver {
94
115
  private pageLines;
95
116
  private pollMs;
96
117
  private probeStalenessMs;
118
+ private journalRoots;
119
+ private narrate?;
120
+ private hookCoverage?;
97
121
  constructor(opts?: OrcaDriverOpts);
98
122
  private call;
99
123
  /** The live runtime's identity, or an explicit failure. A missing or unreachable runtime is a
@@ -109,6 +133,15 @@ export declare class OrcaDriver implements ExecutorDriver {
109
133
  private assertAvailable;
110
134
  run(slot: Slot, cmd: string): Promise<void>;
111
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;
112
145
  /**
113
146
  * Every terminal-addressed call — read AND write — goes through here, and the runtime identity is
114
147
  * established BEFORE the runtime-scoped handle goes on the wire. Discarding a lookalike's answer
@@ -130,6 +163,8 @@ export declare class OrcaDriver implements ExecutorDriver {
130
163
  private liveShowTerm;
131
164
  private tailText;
132
165
  private readPage;
166
+ /** A rendered-frame liveness read. `--screen` and `--cursor` are mutually exclusive in Orca. */
167
+ private readScreen;
133
168
  /** A single UNPAGED tail read — exactly what the caller asked for and nothing more. Markers split
134
169
  * across cursor pages are not reassembled here; that is waitOutput's job. */
135
170
  read(slot: Slot, lines: number): Promise<string>;
@@ -144,12 +179,17 @@ export declare class OrcaDriver implements ExecutorDriver {
144
179
  regex?: boolean;
145
180
  }): Promise<boolean>;
146
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;
147
186
  /** One `terminal wait` through the full identity machinery. The recorded 1.4.186 elapsed answer
148
187
  * is rc 1 + ok:true + {handle, condition, satisfied:false, status:"running"}; it is "not yet"
149
188
  * only after this method validates all four fields. Any malformed/refused wait remains explicit. */
150
189
  private waitCondition;
151
190
  waitAgentStatus(slot: Slot, status: string, timeoutMs: number): Promise<boolean>;
152
191
  notify(msg: string, opts?: NotifyOpts): Promise<void>;
192
+ narrateWith(narrate: (event: JournalEvent) => void): void;
153
193
  close(slot: Slot): Promise<void>;
154
194
  /**
155
195
  * The one destructive call in this driver, for a slot's own terminal AND for a reconcile candidate
@@ -2,13 +2,14 @@ import { realpathSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { shq } from "../adapters/types.js";
4
4
  import { createWorktree, sh } from "../run/git.js";
5
+ import { Journal } from "../run/journal.js";
5
6
  import { MAX_BUF } from "./subprocess.js";
6
- import { formatOwnedName, panesToClose } from "./types.js";
7
+ import { formatOwnedName, panesToClose, parseOwnedName } from "./types.js";
7
8
  // Orca (onorca.dev) as a third execution surface beside herdr and subprocess. tickmarkr keeps
8
9
  // worktrees, routing, gates, journal and merges; orca supplies visible terminals only. Everything
9
10
  // here is bound by the 1.4.186 conformance spike
10
11
  // (.planning/assessments/2026-08-21-orca-driver-conformance.md, CONFORMANCE-END) and the
11
- // recorded refusal transport (process rc 1 + ok:false on stdout):
12
+ // 1.4.195 drift capture (.planning/assessments/2026-09-02-orca-1.4.195-capture/):
12
13
  //
13
14
  // - reads arrive as `result.terminal.tail` line arrays with line-indexed cursors and a `status`
14
15
  // field on the same object (C2); a CLOSED terminal answers ok:true with its own dead record
@@ -31,8 +32,16 @@ import { formatOwnedName, panesToClose } from "./types.js";
31
32
  // - `terminal list`'s `--worktree` is OPTIONAL (same help): the reconcile sweep omits it, because
32
33
  // an older run's leftover sits in a checkout this run never knew (T2).
33
34
  /** The response families the ONE shared envelope parser serves. There is no second JSON seam. */
34
- export const ORCA_RESPONSE_FAMILIES = ["status", "create", "list", "read", "send", "wait", "show", "close"];
35
+ export const ORCA_RESPONSE_FAMILIES = [
36
+ "status", "create", "list", "read", "send", "wait", "show", "close",
37
+ "worktree-current", "hooks-status",
38
+ ];
39
+ export const ORCA_FIXTURE_VERSION = "1.4.195";
40
+ export const ORCA_CLI_COMMAND_ENV = "ORCA_CLI_COMMAND";
35
41
  export const STALE_HANDLE_CODE = "terminal_handle_stale";
42
+ export const TERMINAL_GONE_CODE = "terminal_gone";
43
+ export const STALE_HANDLE_CODES = new Set([STALE_HANDLE_CODE, TERMINAL_GONE_CODE]);
44
+ export const WAIT_TIMEOUT_CODE = "timeout";
36
45
  export const NOT_WRITABLE_CODE = "terminal_not_writable";
37
46
  /** The ONLY terminal status that licenses reading a terminal's bytes or its agent state. */
38
47
  export const RUNNING_STATUS = "running";
@@ -45,6 +54,9 @@ const PAGE_LINES = 500; // per-page ask; orca caps server-side and reports `limi
45
54
  const LIST_LIMIT = 10000; // well past orca's own row default; `truncated` still decides (listAll)
46
55
  const MAX_PAGES = 400; // runaway guard: a cursor that stops advancing ends the sweep, never loops
47
56
  const POLL_MS = 200;
57
+ export const WORKTREE_ADOPTION_TIMEOUT_MS = 60_000;
58
+ const WORKTREE_ADOPTION_POLL_MS = 1_000;
59
+ const WORKTREE_ADOPTION_JOURNAL_MS = 2_000;
48
60
  const SYSTEM_TIME = {
49
61
  now: () => Date.now(),
50
62
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -79,6 +91,19 @@ export class OrcaUnavailableError extends OrcaError {
79
91
  function str(v) {
80
92
  return typeof v === "string" && v ? v : undefined;
81
93
  }
94
+ /**
95
+ * One Orca CLI name decision for both the driver and doctor. Linux desktop systems can have
96
+ * GNOME's screen-reader `orca` on PATH, so outside an Orca terminal the app CLI is `orca-ide`.
97
+ */
98
+ export function resolveOrcaCliBinary(cwd = process.cwd(), opts = {}) {
99
+ const env = opts.env ?? process.env;
100
+ const explicit = env[ORCA_CLI_COMMAND_ENV]?.trim();
101
+ if (explicit)
102
+ return explicit;
103
+ const platform = opts.platform ?? process.platform;
104
+ const selected = platform === "linux" && env.TERM_PROGRAM !== "Orca" ? "orca-ide" : "orca";
105
+ return opts.resolve ? opts.resolve(selected, cwd).resolved : selected;
106
+ }
82
107
  /**
83
108
  * The one JSON seam. Fails CLOSED on every degenerate response — empty, unparseable (a truncated
84
109
  * body lands here), non-object, no boolean `ok`, `ok:false`, `ok:true` with no result object, or a
@@ -234,8 +259,11 @@ export class OrcaDriver {
234
259
  pageLines;
235
260
  pollMs;
236
261
  probeStalenessMs;
262
+ journalRoots = new Map();
263
+ narrate;
264
+ hookCoverage;
237
265
  constructor(opts = {}) {
238
- this.bin = opts.bin ?? "orca";
266
+ this.bin = opts.bin ?? resolveOrcaCliBinary(process.cwd(), { env: opts.env, platform: opts.platform }) ?? "orca";
239
267
  // Config values flow into a shell here: every argv element is quoted, always.
240
268
  this.exec = opts.exec ?? ((args, cwd, timeoutMs) => {
241
269
  return sh([this.bin, ...args].map(shq).join(" "), cwd, timeoutMs);
@@ -257,14 +285,13 @@ export class OrcaDriver {
257
285
  // diagnostic is never discarded before the one shared parser reports it.
258
286
  const raw = [r.stdout, r.stderr ? `STDERR: ${r.stderr}` : ""].filter(Boolean).join("\n");
259
287
  if (r.code !== 0) {
260
- // Recorded 1.4.186 refusal transport: the process exits rc 1 with the STRUCTURED ok:false
288
+ // Recorded refusal transport: the process exits rc 1 with the STRUCTURED ok:false
261
289
  // body on stdout. Parse it so the refusal CODE survives (terminal_handle_stale,
262
290
  // terminal_not_writable) — everything downstream that recovers on a code depends on this
263
- // branch. The one documented exception is an elapsed `terminal wait`: it exits 1 but carries
264
- // an ok:true, `wait.satisfied:false` receipt. It remains the shared parser's success path;
265
- // waitCondition() validates its identity, handle, condition and running status before it
266
- // becomes the normal `false` result. Any other ok:true body on a nonzero exit stays a
267
- // transport failure (a shell/runtime crash can leave stale stdout behind).
291
+ // branch. Elapsed `terminal wait` has two recorded transports: 1.4.186's ok:true,
292
+ // `wait.satisfied:false` receipt and 1.4.195's ok:false/code:timeout refusal. Each remains
293
+ // scoped to wait only; any other ok:true body on a nonzero exit stays a transport failure (a
294
+ // shell/runtime crash can leave stale stdout behind).
268
295
  try {
269
296
  const env = parseEnvelope(family, r.stdout, raw);
270
297
  const wait = env.result.wait;
@@ -279,6 +306,21 @@ export class OrcaDriver {
279
306
  }
280
307
  }
281
308
  catch (e) {
309
+ if (family === "wait"
310
+ && r.code === 1
311
+ && !r.timedOut
312
+ && e instanceof OrcaError
313
+ && e.code === WAIT_TIMEOUT_CODE
314
+ && e.runtimeId
315
+ && e.runtimeId !== "none") {
316
+ const handle = args[args.indexOf("--terminal") + 1];
317
+ const condition = args[args.indexOf("--for") + 1];
318
+ return {
319
+ result: { wait: { handle, condition, satisfied: false, status: RUNNING_STATUS } },
320
+ runtimeId: e.runtimeId,
321
+ raw,
322
+ };
323
+ }
282
324
  // The refusal code lives in stdout alone, but the raw bytes propagated to the caller must
283
325
  // still be the COMBINED stream — stderr can carry the diagnostic that explains the refusal.
284
326
  if (e instanceof OrcaError && !(e instanceof OrcaUnavailableError) && e.code !== undefined) {
@@ -315,7 +357,7 @@ export class OrcaDriver {
315
357
  // Canonical: git and Orca can spell one checkout two ways, and every later comparison — create
316
358
  // receipt, relist, reconcile — is against THIS value.
317
359
  const worktree = canonicalWorktreePath(cwd);
318
- this.slots.set(id, { title, cwd: worktree, buf: "", recoveries: 0, recovering: false });
360
+ this.slots.set(id, { title, cwd: worktree, agent: opts?.agent, buf: "", recoveries: 0, recovering: false });
319
361
  return { id, name: title, cwd: worktree, group: opts?.group };
320
362
  }
321
363
  /** Where to invoke the CLI for this slot's calls (see OrcaSlotState.dir). */
@@ -364,6 +406,7 @@ export class OrcaDriver {
364
406
  }
365
407
  async create(st, cmd) {
366
408
  await this.probeRuntime(this.cliCwd(st));
409
+ await this.awaitWorktreeAdoption(st);
367
410
  // The selector names THIS slot's checkout outright, and the CLI child is bound to it too, so
368
411
  // neither the UI's active worktree (`active`/`current`) nor the daemon's cwd can place it.
369
412
  const env = await this.call("create", [
@@ -392,6 +435,68 @@ export class OrcaDriver {
392
435
  st.handle = handle;
393
436
  // The handle is bound to the runtime identity that ANSWERED its create.
394
437
  st.runtimeId = env.runtimeId;
438
+ const surface = str(term.surface);
439
+ if (surface !== undefined && surface !== "visible") {
440
+ await this.notify(`tickmarkr orca terminal created on ${surface} surface`, { tier: "attention" });
441
+ }
442
+ }
443
+ /**
444
+ * A freshly-created git checkout does not become a valid Orca selector atomically. Ask
445
+ * `worktree current` FROM that checkout until Orca itself resolves the exact filesystem identity;
446
+ * an enclosing checkout is still not adoption. Only selector_not_found is a retryable refusal —
447
+ * malformed envelopes and every other refusal remain explicit driver failures.
448
+ */
449
+ async awaitWorktreeAdoption(st) {
450
+ const started = this.time.now();
451
+ const deadline = started + WORKTREE_ADOPTION_TIMEOUT_MS;
452
+ let lastReported;
453
+ for (;;) {
454
+ const left = deadline - this.time.now();
455
+ if (left < 0)
456
+ break;
457
+ try {
458
+ const env = await this.call("worktree-current", ["worktree", "current"], st.cwd, Math.max(1, left));
459
+ const worktree = env.result.worktree;
460
+ if (typeof worktree !== "object" || worktree === null || Array.isArray(worktree)) {
461
+ throw new OrcaError("worktree-current", "response carries no worktree record", env.raw, { runtimeId: env.runtimeId });
462
+ }
463
+ const reported = terminalWorktree(worktree);
464
+ if (!reported) {
465
+ throw new OrcaError("worktree-current", "worktree record carries no path", env.raw, { runtimeId: env.runtimeId });
466
+ }
467
+ lastReported = canonicalWorktreePath(reported);
468
+ if (lastReported === st.cwd) {
469
+ const waitedMs = this.time.now() - started;
470
+ if (waitedMs > WORKTREE_ADOPTION_JOURNAL_MS)
471
+ this.appendAdoptionWait(st, waitedMs);
472
+ return;
473
+ }
474
+ }
475
+ catch (error) {
476
+ if (!(error instanceof OrcaError) || error.code !== "selector_not_found")
477
+ throw error;
478
+ lastReported = undefined;
479
+ }
480
+ const remaining = deadline - this.time.now();
481
+ if (remaining <= 0)
482
+ break;
483
+ await this.time.sleep(Math.min(WORKTREE_ADOPTION_POLL_MS, remaining));
484
+ }
485
+ const waitedMs = this.time.now() - started;
486
+ throw new OrcaUnavailableError("worktree-current", `Orca did not adopt ${st.cwd} within ${WORKTREE_ADOPTION_TIMEOUT_MS}ms${lastReported ? ` (last answered ${lastReported})` : ""}`, `waitedMs=${waitedMs}`);
487
+ }
488
+ /** Same repo/run/narration path Herdr uses for its driver-owned dispatch-retry row. */
489
+ appendAdoptionWait(st, waitedMs) {
490
+ const owned = parseOwnedName(st.title);
491
+ if (!owned)
492
+ throw new Error(`cannot journal worktree-adoption-wait: slot ${st.title} carries no run identity`);
493
+ const repoRoot = this.journalRoots.get(st.cwd);
494
+ if (!repoRoot) {
495
+ throw new Error(`cannot journal worktree-adoption-wait: slot ${st.title} has no daemon repo binding for ${st.cwd}`);
496
+ }
497
+ Journal.open(repoRoot, owned.runId, this.narrate).append("worktree-adoption-wait", owned.taskId, {
498
+ milliseconds: waitedMs,
499
+ });
395
500
  }
396
501
  // ---- handle identity and restart recovery ----------------------------------------------------
397
502
  /**
@@ -450,7 +555,7 @@ export class OrcaDriver {
450
555
  await relist(e.runtimeId, e.raw);
451
556
  continue;
452
557
  }
453
- if (e.code === STALE_HANDLE_CODE) {
558
+ if (STALE_HANDLE_CODES.has(e.code)) {
454
559
  await relist(e.runtimeId, e.raw);
455
560
  continue;
456
561
  }
@@ -592,6 +697,18 @@ export class OrcaDriver {
592
697
  ], this.cliCwd(st)), { onRecovered: () => { recovered = true; } });
593
698
  return { term: this.validated(family, st, env), raw: env.raw, recovered };
594
699
  }
700
+ /** A rendered-frame liveness read. `--screen` and `--cursor` are mutually exclusive in Orca. */
701
+ async readScreen(st) {
702
+ const env = await this.terminalOp("status", st, (h) => this.call("read", [
703
+ "terminal", "read", "--terminal", h, "--screen",
704
+ ], this.cliCwd(st)));
705
+ const term = this.validated("status", st, env);
706
+ const source = str(term.source);
707
+ if (source !== "screen" && source !== "screen-unavailable") {
708
+ throw new OrcaError("read", `screen read reports source ${source ?? "absent"}, not screen or screen-unavailable`, env.raw);
709
+ }
710
+ return { term, source };
711
+ }
595
712
  /** A single UNPAGED tail read — exactly what the caller asked for and nothing more. Markers split
596
713
  * across cursor pages are not reassembled here; that is waitOutput's job. */
597
714
  async read(slot, lines) {
@@ -668,10 +785,14 @@ export class OrcaDriver {
668
785
  // reporting even when the show record alone would still look connected (show carries no
669
786
  // status field of its own, so a terminal can report "unknown"/"exited" on read while its
670
787
  // show row still says connected — the read leg is the only place that catches that).
671
- await this.readPage("status", st, undefined, 1);
788
+ const screen = await this.readScreen(st);
672
789
  if (`${st.runtimeId}:${st.handle}:${st.recoveries}` !== gen) {
673
790
  continue;
674
791
  }
792
+ // No rendered frame means no trustworthy TUI state. In particular, the stream fragments this
793
+ // call replaced cannot license an idle verdict or a wait probe.
794
+ if (screen.source === "screen-unavailable")
795
+ return "unknown";
675
796
  const env = await this.terminalOp("show", st, (h) => this.call("show", ["terminal", "show", "--terminal", h], this.cliCwd(st)));
676
797
  if (`${st.runtimeId}:${st.handle}:${st.recoveries}` !== gen) {
677
798
  continue;
@@ -679,6 +800,11 @@ export class OrcaDriver {
679
800
  const term = this.liveShowTerm("status", st, env);
680
801
  if (term.agentWait === true)
681
802
  return "blocked";
803
+ // Orca can only report tui-idle/agentWait for agents whose managed hook is installed. A
804
+ // definitively unhooked adapter is unknown; an agent absent from Orca's table keeps the
805
+ // legacy probe because absence is not proof that the CLI has no compatible status surface.
806
+ if (await this.agentHookAvailable(st) === false)
807
+ return "unknown";
682
808
  // `idle` is proven only by orca's own tui-idle condition: a 1ms wait is a point-in-time probe —
683
809
  // satisfied now → idle; elapsed (the recorded `timeout` refusal) → not idle.
684
810
  const isIdle = await this.waitCondition(st, "tui-idle", 1);
@@ -688,6 +814,50 @@ export class OrcaDriver {
688
814
  return mapAgentState(term, isIdle);
689
815
  }
690
816
  }
817
+ hookAgent(adapter) {
818
+ if (adapter === "claude-code")
819
+ return "claude";
820
+ if (adapter === "cursor-agent")
821
+ return "cursor";
822
+ return adapter;
823
+ }
824
+ /** true = hooked, false = definitively unhooked, undefined = agent absent from Orca's table. */
825
+ async agentHookAvailable(st) {
826
+ if (!st.agent)
827
+ return undefined;
828
+ this.hookCoverage ??= this.loadHookCoverage(st.cwd);
829
+ const coverage = await this.hookCoverage;
830
+ const state = coverage.states.get(this.hookAgent(st.agent));
831
+ // The table's omission is deliberately inconclusive even when managed hooks are disabled:
832
+ // Orca may not know this agent, so preserve the legacy probe exactly as an unlisted row does.
833
+ if (state === undefined)
834
+ return undefined;
835
+ return coverage.enabled && state === "installed";
836
+ }
837
+ async loadHookCoverage(cwd) {
838
+ const env = await this.call("hooks-status", ["agent", "hooks", "status"], cwd);
839
+ if (typeof env.result.enabled !== "boolean") {
840
+ throw new OrcaError("hooks-status", "response carries no boolean enabled", env.raw, { runtimeId: env.runtimeId });
841
+ }
842
+ const statuses = env.result.statuses;
843
+ if (!Array.isArray(statuses)) {
844
+ throw new OrcaError("hooks-status", "response carries no statuses array", env.raw, { runtimeId: env.runtimeId });
845
+ }
846
+ const states = new Map();
847
+ const allowed = new Set(["installed", "not_installed", "partial", "error"]);
848
+ for (const row of statuses) {
849
+ if (typeof row !== "object" || row === null || Array.isArray(row)) {
850
+ throw new OrcaError("hooks-status", "statuses carries a non-object row", env.raw, { runtimeId: env.runtimeId });
851
+ }
852
+ const agent = str(row.agent);
853
+ const state = str(row.state);
854
+ if (!agent || !state || !allowed.has(state)) {
855
+ throw new OrcaError("hooks-status", "status row carries no valid agent/state pair", env.raw, { runtimeId: env.runtimeId });
856
+ }
857
+ states.set(agent, state);
858
+ }
859
+ return { enabled: env.result.enabled, states };
860
+ }
691
861
  /** One `terminal wait` through the full identity machinery. The recorded 1.4.186 elapsed answer
692
862
  * is rc 1 + ok:true + {handle, condition, satisfied:false, status:"running"}; it is "not yet"
693
863
  * only after this method validates all four fields. Any malformed/refused wait remains explicit. */
@@ -755,6 +925,9 @@ export class OrcaDriver {
755
925
  return;
756
926
  console.log(`[tickmarkr] ${msg}`); // console fallback only — notification injection is out of scope
757
927
  }
928
+ narrateWith(narrate) {
929
+ this.narrate = narrate;
930
+ }
758
931
  async close(slot) {
759
932
  const st = this.slots.get(slot.id);
760
933
  if (!st)
@@ -873,7 +1046,11 @@ export class OrcaDriver {
873
1046
  catch { /* cosmetic — visibility hygiene never fails the run */ }
874
1047
  }
875
1048
  // tickmarkr's own createWorktree stays the sole checkout authority — orca never makes worktrees.
876
- worktree(repo, branch, baseRef) {
877
- return createWorktree(repo, branch, baseRef);
1049
+ async worktree(repo, branch, baseRef) {
1050
+ const worktree = await createWorktree(repo, branch, baseRef);
1051
+ const repoRoot = canonicalWorktreePath(repo);
1052
+ this.journalRoots.set(repoRoot, repoRoot);
1053
+ this.journalRoots.set(canonicalWorktreePath(worktree), repoRoot);
1054
+ return worktree;
878
1055
  }
879
1056
  }
@@ -1,14 +1,14 @@
1
1
  import type { ExecutorDriver, NotifyOpts, Slot } from "./types.js";
2
2
  export declare const MAX_BUF: number;
3
- export declare const HERDR_CONTROL_VARS: readonly ["HERDR_ENV", "HERDR_SOCKET_PATH"];
3
+ export declare const HERDR_CONTROL_VARS: readonly ["HERDR_ENV", "HERDR_SOCKET_PATH", "ORCA_TERMINAL_HANDLE", "ORCA_PANE_KEY", "ORCA_TAB_ID"];
4
4
  /**
5
- * Copy of worker env with the fork cap applied and herdr control-plane vars stripped.
5
+ * Copy of worker env with the fork cap applied and host control-plane vars stripped.
6
6
  * The cap is the one the enclosing run resolved (resolvedForkCap) — a worker's suites divide the
7
7
  * same machine the gate shells do, so both seams have to read the same run-owned number rather
8
8
  * than a flat constant. The operator's own export still wins.
9
9
  */
10
10
  export declare function sealHerdrEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
11
- /** Pane/login-shell form of the same worker env seal (herdr seed + daemon setup). */
11
+ /** Pane/login-shell form of the same host-neutral worker env seal (herdr seed + daemon setup). */
12
12
  export declare function herdrSealShellPrefix(env?: NodeJS.ProcessEnv): string;
13
13
  export declare class SubprocessDriver implements ExecutorDriver {
14
14
  id: string;
@@ -8,13 +8,20 @@ import { createWorktree, FORK_CAP_ENV, resolvedForkCap } from "../run/git.js";
8
8
  // waitOutput poll (~10MB/s sustained, far beyond agent-CLI rates). Tail-truncate, never head:
9
9
  // consumers only ever tail-read.
10
10
  export const MAX_BUF = 2 * 1024 * 1024;
11
- // OBS-17 / v1.22 T3: control-plane vars that let a process talk to the operator's herdr.
12
- // Workers, judges, reviewers, and consults must never inherit them — only the daemon process
13
- // (and its own herdr driver CLI calls) keep the live session. Socket path is the wire; HERDR_ENV
14
- // is the "I am inside herdr" gate every agent skill checks before mutating panes.
15
- export const HERDR_CONTROL_VARS = ["HERDR_ENV", "HERDR_SOCKET_PATH"];
11
+ // OBS-17 / v1.22 T3 and OBS-843: host control-plane vars that let a process address the operator's
12
+ // herdr or Orca UI. Workers, judges, reviewers, and consults must never inherit them — only the
13
+ // daemon process and its driver calls keep the live session. TERM_PROGRAM is host description, not
14
+ // an addressing capability, and ORCA_AGENT_HOOK_* belongs to agent status transport, so both stay.
15
+ // Keep the exported name for compatibility even though the list is now host-neutral.
16
+ export const HERDR_CONTROL_VARS = [
17
+ "HERDR_ENV",
18
+ "HERDR_SOCKET_PATH",
19
+ "ORCA_TERMINAL_HANDLE",
20
+ "ORCA_PANE_KEY",
21
+ "ORCA_TAB_ID",
22
+ ];
16
23
  /**
17
- * Copy of worker env with the fork cap applied and herdr control-plane vars stripped.
24
+ * Copy of worker env with the fork cap applied and host control-plane vars stripped.
18
25
  * The cap is the one the enclosing run resolved (resolvedForkCap) — a worker's suites divide the
19
26
  * same machine the gate shells do, so both seams have to read the same run-owned number rather
20
27
  * than a flat constant. The operator's own export still wins.
@@ -27,7 +34,7 @@ export function sealHerdrEnv(env = process.env) {
27
34
  delete out[k];
28
35
  return out;
29
36
  }
30
- /** Pane/login-shell form of the same worker env seal (herdr seed + daemon setup). */
37
+ /** Pane/login-shell form of the same host-neutral worker env seal (herdr seed + daemon setup). */
31
38
  export function herdrSealShellPrefix(env = process.env) {
32
39
  const forkCap = sealHerdrEnv(env)[FORK_CAP_ENV] ?? resolvedForkCap();
33
40
  return `export ${FORK_CAP_ENV}=${shq(forkCap)}; ` +
@@ -56,8 +63,8 @@ export class SubprocessDriver {
56
63
  // HARD-05: interactive=false — no operator, so an open stdin pipe is a promise tickmarkr can never
57
64
  // keep; codex exec appends a piped stdin as a <stdin> block (`codex exec --help`) and blocks on a
58
65
  // read that never EOFs. One spawn site covers every adapter (D-06).
59
- // v1.22 T3: seal herdr control vars so worker/judge/review/consult children cannot reach the
60
- // operator's herdr (OBS-17 watch-tab leak class). process.env of the daemon is untouched.
66
+ // v1.22 T3 / OBS-843: seal host control vars so worker/judge/review/consult children cannot
67
+ // address the operator's herdr or Orca UI. process.env of the daemon is untouched.
61
68
  const p = spawn("bash", ["-lc", cmd], {
62
69
  cwd: slot.cwd,
63
70
  stdio: ["ignore", "pipe", "pipe"],
@@ -36,6 +36,8 @@ export interface SlotOpts {
36
36
  group?: string;
37
37
  label?: string;
38
38
  owned?: OwnedName;
39
+ /** Adapter id for execution surfaces whose liveness support is agent-specific. */
40
+ agent?: string;
39
41
  }
40
42
  export declare const OWNED_ROLES: readonly ["worker", "judge", "review", "consult", "watch", "other"];
41
43
  export type OwnedRole = (typeof OWNED_ROLES)[number];