tickmarkr 1.30.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 (123) hide show
  1. package/README.md +347 -0
  2. package/dist/adapters/claude-code.d.ts +4 -0
  3. package/dist/adapters/claude-code.js +191 -0
  4. package/dist/adapters/codex.d.ts +8 -0
  5. package/dist/adapters/codex.js +257 -0
  6. package/dist/adapters/cursor-agent.d.ts +4 -0
  7. package/dist/adapters/cursor-agent.js +62 -0
  8. package/dist/adapters/fake.d.ts +45 -0
  9. package/dist/adapters/fake.js +115 -0
  10. package/dist/adapters/grok.d.ts +4 -0
  11. package/dist/adapters/grok.js +122 -0
  12. package/dist/adapters/model-lints.d.ts +6 -0
  13. package/dist/adapters/model-lints.js +130 -0
  14. package/dist/adapters/opencode.d.ts +3 -0
  15. package/dist/adapters/opencode.js +108 -0
  16. package/dist/adapters/pi.d.ts +3 -0
  17. package/dist/adapters/pi.js +191 -0
  18. package/dist/adapters/prompt.d.ts +6 -0
  19. package/dist/adapters/prompt.js +84 -0
  20. package/dist/adapters/registry.d.ts +31 -0
  21. package/dist/adapters/registry.js +165 -0
  22. package/dist/adapters/types.d.ts +98 -0
  23. package/dist/adapters/types.js +56 -0
  24. package/dist/cli/commands/approve.d.ts +1 -0
  25. package/dist/cli/commands/approve.js +72 -0
  26. package/dist/cli/commands/compile.d.ts +1 -0
  27. package/dist/cli/commands/compile.js +23 -0
  28. package/dist/cli/commands/doctor.d.ts +2 -0
  29. package/dist/cli/commands/doctor.js +103 -0
  30. package/dist/cli/commands/init.d.ts +1 -0
  31. package/dist/cli/commands/init.js +32 -0
  32. package/dist/cli/commands/plan.d.ts +2 -0
  33. package/dist/cli/commands/plan.js +101 -0
  34. package/dist/cli/commands/profile.d.ts +1 -0
  35. package/dist/cli/commands/profile.js +56 -0
  36. package/dist/cli/commands/report.d.ts +4 -0
  37. package/dist/cli/commands/report.js +306 -0
  38. package/dist/cli/commands/resume.d.ts +1 -0
  39. package/dist/cli/commands/resume.js +11 -0
  40. package/dist/cli/commands/run.d.ts +1 -0
  41. package/dist/cli/commands/run.js +35 -0
  42. package/dist/cli/commands/scope.d.ts +3 -0
  43. package/dist/cli/commands/scope.js +22 -0
  44. package/dist/cli/commands/status.d.ts +9 -0
  45. package/dist/cli/commands/status.js +162 -0
  46. package/dist/cli/commands/unlock.d.ts +1 -0
  47. package/dist/cli/commands/unlock.js +11 -0
  48. package/dist/cli/index.d.ts +8 -0
  49. package/dist/cli/index.js +65 -0
  50. package/dist/compile/collateral.d.ts +6 -0
  51. package/dist/compile/collateral.js +128 -0
  52. package/dist/compile/common.d.ts +12 -0
  53. package/dist/compile/common.js +56 -0
  54. package/dist/compile/gsd.d.ts +3 -0
  55. package/dist/compile/gsd.js +222 -0
  56. package/dist/compile/index.d.ts +4 -0
  57. package/dist/compile/index.js +33 -0
  58. package/dist/compile/native.d.ts +4 -0
  59. package/dist/compile/native.js +196 -0
  60. package/dist/compile/prd.d.ts +2 -0
  61. package/dist/compile/prd.js +64 -0
  62. package/dist/compile/speckit.d.ts +2 -0
  63. package/dist/compile/speckit.js +66 -0
  64. package/dist/config/config.d.ts +184 -0
  65. package/dist/config/config.js +329 -0
  66. package/dist/drivers/herdr.d.ts +44 -0
  67. package/dist/drivers/herdr.js +394 -0
  68. package/dist/drivers/index.d.ts +3 -0
  69. package/dist/drivers/index.js +13 -0
  70. package/dist/drivers/subprocess.d.ts +25 -0
  71. package/dist/drivers/subprocess.js +106 -0
  72. package/dist/drivers/types.d.ts +61 -0
  73. package/dist/drivers/types.js +77 -0
  74. package/dist/gates/acceptance.d.ts +19 -0
  75. package/dist/gates/acceptance.js +92 -0
  76. package/dist/gates/baseline.d.ts +12 -0
  77. package/dist/gates/baseline.js +97 -0
  78. package/dist/gates/evidence.d.ts +4 -0
  79. package/dist/gates/evidence.js +12 -0
  80. package/dist/gates/llm.d.ts +25 -0
  81. package/dist/gates/llm.js +101 -0
  82. package/dist/gates/review.d.ts +12 -0
  83. package/dist/gates/review.js +80 -0
  84. package/dist/gates/run-gates.d.ts +34 -0
  85. package/dist/gates/run-gates.js +105 -0
  86. package/dist/gates/scope.d.ts +8 -0
  87. package/dist/gates/scope.js +32 -0
  88. package/dist/gates/types.d.ts +6 -0
  89. package/dist/gates/types.js +1 -0
  90. package/dist/graph/graph.d.ts +20 -0
  91. package/dist/graph/graph.js +148 -0
  92. package/dist/graph/schema.d.ts +185 -0
  93. package/dist/graph/schema.js +141 -0
  94. package/dist/index.d.ts +1 -0
  95. package/dist/index.js +1 -0
  96. package/dist/plan/prompt.d.ts +4 -0
  97. package/dist/plan/prompt.js +31 -0
  98. package/dist/plan/scope.d.ts +17 -0
  99. package/dist/plan/scope.js +141 -0
  100. package/dist/report/cost.d.ts +24 -0
  101. package/dist/report/cost.js +89 -0
  102. package/dist/route/preference.d.ts +27 -0
  103. package/dist/route/preference.js +49 -0
  104. package/dist/route/profile.d.ts +65 -0
  105. package/dist/route/profile.js +197 -0
  106. package/dist/route/router.d.ts +26 -0
  107. package/dist/route/router.js +200 -0
  108. package/dist/run/consult.d.ts +23 -0
  109. package/dist/run/consult.js +80 -0
  110. package/dist/run/daemon.d.ts +23 -0
  111. package/dist/run/daemon.js +728 -0
  112. package/dist/run/git.d.ts +12 -0
  113. package/dist/run/git.js +56 -0
  114. package/dist/run/journal.d.ts +91 -0
  115. package/dist/run/journal.js +287 -0
  116. package/dist/run/lock.d.ts +29 -0
  117. package/dist/run/lock.js +182 -0
  118. package/dist/run/merge.d.ts +12 -0
  119. package/dist/run/merge.js +44 -0
  120. package/dist/run/reconcile.d.ts +4 -0
  121. package/dist/run/reconcile.js +95 -0
  122. package/package.json +39 -0
  123. package/schema/rungraph.schema.json +306 -0
@@ -0,0 +1,394 @@
1
+ import { shq } from "../adapters/types.js";
2
+ import { createWorktree, sh } from "../run/git.js";
3
+ import { herdrSealShellPrefix } from "./subprocess.js";
4
+ import { canonicalizeLegacyName, formatOwnedName, panesToClose } from "./types.js";
5
+ // VIS-09 P43-03: adopted safety floor from 43-MEASUREMENT.md (narrowest safe 53 → floor 108).
6
+ export const TRAILER_SAFE_FLOOR_COLS = 108;
7
+ export const TRAILER_WIDTH_MARGIN = 2; // cols below (floor + margin) refuse a rightward first split
8
+ /** First-generation join direction from measured trailer-safe floor (43-MEASUREMENT.md). */
9
+ export function workerSplitDirection(paneCols, safeFloor = TRAILER_SAFE_FLOOR_COLS, margin = TRAILER_WIDTH_MARGIN) {
10
+ if (paneCols == null || paneCols <= 0)
11
+ return "down";
12
+ return paneCols / 2 >= safeFloor + margin ? "right" : "down";
13
+ }
14
+ export class HerdrDriver {
15
+ bin;
16
+ workersPerTab;
17
+ id = "herdr";
18
+ interactive = true;
19
+ groups = new Map();
20
+ // grouped slot()/close() mutate shared group state across awaits — serialize them so two
21
+ // concurrent first members can never both create the group tab (mergeSerial idiom, daemon.ts)
22
+ groupSerial = Promise.resolve();
23
+ // VIS-10: the run's workspace id, captured once at construction (the daemon inherits it from the
24
+ // operator's env before the driver is built). Required at slot() time, never in the constructor —
25
+ // pickDriver and its unit test construct HerdrDriver without env, so slot() is the trust gate.
26
+ ws = process.env.HERDR_WORKSPACE_ID;
27
+ constructor(bin = "herdr", workersPerTab = 3) {
28
+ this.bin = bin;
29
+ this.workersPerTab = workersPerTab;
30
+ }
31
+ serial(fn) {
32
+ const p = this.groupSerial.then(fn, fn);
33
+ this.groupSerial = p.catch(() => undefined);
34
+ return p;
35
+ }
36
+ static available() {
37
+ return process.env.HERDR_ENV === "1";
38
+ }
39
+ herdr(args, cwd = process.cwd(), timeoutMs) {
40
+ return sh(`${shq(this.bin)} ${args}`, cwd, timeoutMs);
41
+ }
42
+ // pane ids compact when panes close — resolve fresh via the durable agent name (spec §5)
43
+ async paneId(slot) {
44
+ const r = await this.herdr(`agent get ${shq(slot.name)}`);
45
+ try {
46
+ const id = JSON.parse(r.stdout).result?.agent?.pane_id;
47
+ if (typeof id === "string" && id)
48
+ return id;
49
+ }
50
+ catch {
51
+ /* fall back to cached id */
52
+ }
53
+ return slot.id;
54
+ }
55
+ // VIS-09 P43-03: runtime width for the layout gate (43-MEASUREMENT.md licensing condition 2).
56
+ async paneWidth(paneId) {
57
+ const r = await this.herdr(`pane layout --pane ${shq(paneId)}`);
58
+ if (r.code !== 0)
59
+ return null;
60
+ try {
61
+ const layout = JSON.parse(r.stdout).result?.layout;
62
+ const pane = layout?.panes?.find((p) => p.pane_id === paneId);
63
+ const w = pane?.rect?.width ?? layout?.area?.width;
64
+ return typeof w === "number" && w > 0 ? w : null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ async slot(cwd, name, opts) {
71
+ // T1 ownership contract: `opts.owned` (T2 call sites) names the pane canonically —
72
+ // drovr:<role>:<taskId>:<attempt>:<runId>. Without it, `name` passes through byte-identical
73
+ // (today's legacy daemon/gates/consult shapes) — canonicalizeLegacyName (types.ts) is what lets
74
+ // reconcile.ts and this driver's own renameGroupTab/glyphFor decode role/taskId/attempt from
75
+ // those shapes without a call-site migration; T2 retires this branch by always passing `owned`.
76
+ const resolved = opts?.owned ? formatOwnedName(opts.owned) : name;
77
+ // group wins if both are set (a group tab is already stage-labeled; passing both is a caller bug).
78
+ // label (without group) → dedicated labeled tab via tabSlot's third param: no groups-map entry, no
79
+ // refcount, no groupSerial, no degrade latch — dedicated tabs have no shared state to guard (SUP-01).
80
+ if (opts?.group)
81
+ return this.serial(() => this.groupSlot(cwd, resolved, opts.group));
82
+ return this.tabSlot(cwd, resolved, opts?.label); // label undefined → defaults to name (today's behavior)
83
+ }
84
+ // today's per-slot tab path, plus the VIS-04 orphan reap
85
+ // label defaults to the slot name; group tabs pass the STAGE name instead — a first-member label
86
+ // outlives its member once keepPanes reaps it (run-20260709-104447: the codex pane sat in a tab
87
+ // named after a dead cursor worker and the operator read it as a mislabeled agent)
88
+ async tabSlot(cwd, name, label = name) {
89
+ // tab-per-slot: concurrent agents in one tab split it into sliver columns — TUIs exit or
90
+ // hard-wrap at COLUMNS≈2, shredding even the DROVR_RESULT marker (v1.4 phase-1 incident).
91
+ // A dedicated named tab gives every agent a full-width pane; tab close() reaps it.
92
+ // VIS-10 (operator ruling 2026-07-11): "pane placed by focus heuristic" is a DEFECT CLASS.
93
+ // Fail closed at every step — env unset, tab-create non-zero, unparseable stdout, or a parsed
94
+ // payload with no tab_id all REJECT. There is no path from here to an untargeted pane.
95
+ if (!this.ws)
96
+ throw new Error("herdr placement requires HERDR_WORKSPACE_ID — refusing untargeted pane (VIS-10: fail closed, never place by focus)");
97
+ // pin the tab to the RUN's workspace, UNCONDITIONALLY (inherited via HERDR_WORKSPACE_ID), never the
98
+ // operator's focused one (Intl-Dossier run-20260709-104447 incident: worker tabs opened in the drovr repo workspace)
99
+ const t = await this.herdr(`tab create --label ${shq(label)} --no-focus --workspace ${shq(this.ws)}`);
100
+ if (t.code !== 0)
101
+ throw new Error(`herdr tab create failed (exit ${t.code}, refusing untargeted placement): ${t.stderr || t.stdout}`);
102
+ let res;
103
+ try {
104
+ res = JSON.parse(t.stdout).result;
105
+ }
106
+ catch {
107
+ throw new Error(`herdr tab create returned unparseable JSON (refusing untargeted placement): ${t.stdout}`);
108
+ }
109
+ const tabId = res?.tab?.tab_id;
110
+ const rootPane = res?.root_pane?.pane_id; // auto-created shell pane (SKILL:343)
111
+ if (typeof tabId !== "string" || !tabId)
112
+ throw new Error(`herdr tab create returned no tab_id (refusing untargeted placement): ${t.stdout}`);
113
+ let r = await this.herdr(`agent start ${shq(name)} --cwd ${shq(cwd)} --tab ${shq(tabId)} --no-focus -- bash`);
114
+ if (r.code !== 0 && /agent_name_taken/.test(r.stderr + r.stdout)) {
115
+ // DEFECT-01: a prior (killed) process's kept pane still holds this durable name — reclaim it.
116
+ // The old attempt is void by construction: its worktree was rm -rf'd on re-dispatch (git.ts:34),
117
+ // so adoption is meaningless; close the stale PANE only (never its tab — sibling prior-run panes
118
+ // may still be under the operator's eye) and restart. Match BOTH streams (herdr's error-stream
119
+ // convention varies — Pitfall 6); an unrelated nonzero exit must never reach pane close (T-10-04).
120
+ const g = await this.herdr(`agent get ${shq(name)}`);
121
+ try {
122
+ const stale = JSON.parse(g.stdout).result?.agent?.pane_id;
123
+ if (typeof stale === "string" && stale)
124
+ await this.herdr(`pane close ${shq(stale)}`);
125
+ }
126
+ catch {
127
+ /* holder vanished between calls (agent_not_found) — retry the start regardless */
128
+ }
129
+ r = await this.herdr(`agent start ${shq(name)} --cwd ${shq(cwd)} --tab ${shq(tabId)} --no-focus -- bash`); // once
130
+ }
131
+ if (r.code !== 0)
132
+ throw new Error(`herdr agent start failed: ${r.stderr || r.stdout}`); // fail closed, no loop
133
+ const id = JSON.parse(r.stdout).result?.agent?.pane_id;
134
+ if (typeof id !== "string" || !id)
135
+ throw new Error(`herdr agent start returned no pane id: ${r.stdout}`);
136
+ // tab create auto-spawns a root shell pane and agent start --tab adds the agent as a SECOND
137
+ // pane — reap the idle shell so no tab shows a dead "drovr git:" prompt beside its agent
138
+ // (VIS-04 orphan fix). Best-effort: a failed reap costs cosmetics only.
139
+ if (typeof rootPane === "string" && rootPane && rootPane !== id) {
140
+ await this.herdr(`pane close ${shq(rootPane)}`);
141
+ }
142
+ // VIS-10 hole 3: seed the run's workspace id into the agent pane's shell so a worker's own ad-hoc
143
+ // `herdr agent start` is workspace-targeted BY CONSTRUCTION — correct placement is the default a
144
+ // worker GETS, not a rule it must remember (P40-02 probe leak). Fail closed: a failed seed rejects.
145
+ // v1.22 T3 / OBS-17: also strip HERDR_ENV + socket path so the worker cannot open/mutate panes in
146
+ // the operator's session. Daemon-side this.herdr() calls keep process.env (unsealed).
147
+ const seed = await this.herdr(`pane run ${shq(id)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; ${herdrSealShellPrefix()}`)}`);
148
+ if (seed.code !== 0)
149
+ throw new Error(`herdr workspace-id seed failed (refusing untargeted pane): ${seed.stderr || seed.stdout}`);
150
+ return { id, name, cwd, tabId };
151
+ }
152
+ // VIS-04 role-tab (extended VIS-09 item 2): first member bootstraps generation 1 (WORKERS);
153
+ // later members join the latest generation with live-member room via joinGroup, else a NEW
154
+ // overflow generation tab (cleanup, cleanup, …) opens — a second `tab create`, NOT a further
155
+ // split of tab 1. The cap is the constructor's workersPerTab (plumbed from config via pickDriver).
156
+ // splitUnsupported stays PER GROUP: a herdr that can't split can't split in any tab, so once it
157
+ // latches every later member degrades to a per-slot tab (no overflow tab can be populated by join).
158
+ async groupSlot(cwd, name, group) {
159
+ const state = this.groups.get(group);
160
+ if (state?.splitUnsupported)
161
+ return this.tabSlot(cwd, name); // D-09: degrade, NOT a shared-tab member
162
+ if (state) {
163
+ const latest = state.generations[state.generations.length - 1];
164
+ if (latest && latest.members.length < this.workersPerTab) {
165
+ const joined = await this.joinGroup(cwd, name, group, latest);
166
+ if (joined)
167
+ return joined;
168
+ state.splitUnsupported = true; // D-09 fail-safe: this and future members degrade to per-slot tabs
169
+ return this.tabSlot(cwd, name);
170
+ }
171
+ return this.newGeneration(cwd, name, group, state); // cap full → overflow to a new generation tab
172
+ }
173
+ const fresh = { generations: [], created: 0 };
174
+ this.groups.set(group, fresh);
175
+ return this.newGeneration(cwd, name, group, fresh); // first member ever → bootstrap generation 1
176
+ }
177
+ // bootstrap a fresh generation tab: gen 1 keeps the WORKERS stage label (today's primary tab);
178
+ // every overflow generation is a "cleanup" tab (VIS-13 amendment) — never a WORKERS-N numeric suffix.
179
+ async newGeneration(cwd, name, group, state) {
180
+ state.created++;
181
+ const label = state.created === 1 ? group.toUpperCase() : "cleanup";
182
+ const s = await this.tabSlot(cwd, name, label);
183
+ const entry = { tabId: s.tabId, label, members: [{ name, paneId: s.id }] };
184
+ state.generations.push(entry);
185
+ await this.renameGroupTab(entry);
186
+ return { ...s, group };
187
+ }
188
+ // Only GroupEntry instances originate in newGeneration(), after tabSlot created the tab. This never
189
+ // adopts or renames an operator tab; the newest live worker contributes at most one task-id token.
190
+ // VIS-13: that token carries ONE state glyph — ↻ for a retry attempt (attempt > 0 parsed from the
191
+ // member name), ✋ when the driver observes the member blocked (queried live); bare on normal running.
192
+ // T1: token/attempt now come from canonicalizeLegacyName (types.ts) instead of ad hoc regex — same
193
+ // extraction for today's legacy names ("T2-worker-...-a0-...") and, once T2 passes `owned`, for
194
+ // canonical names too — one parser, not two.
195
+ async renameGroupTab(entry) {
196
+ const newest = [...entry.members].reverse().find((m) => canonicalizeLegacyName(m.name, "").role === "worker");
197
+ const token = newest ? canonicalizeLegacyName(newest.name, "").taskId : undefined;
198
+ const glyph = newest ? await this.glyphFor(newest) : "";
199
+ const label = token ? `${entry.label} · ${token}${glyph}` : entry.label;
200
+ try {
201
+ await this.herdr(`tab rename ${shq(entry.tabId)} ${shq(label)}`);
202
+ }
203
+ catch {
204
+ /* cosmetic only — a relabel failure never blocks membership or teardown (v1.18 invariant) */
205
+ }
206
+ }
207
+ // VIS-13: at most one glyph on the hot token. ✋ wins — the driver observes the member blocked live
208
+ // (the actionable state the operator must clear); else ↻ for a retry attempt (attempt > 0 in the
209
+ // member's owned name); else bare. Status observation failure → "unknown" (never ✋), fail-safe.
210
+ async glyphFor(m) {
211
+ if ((await this.statusByName(m.name)) === "blocked")
212
+ return "✋";
213
+ return canonicalizeLegacyName(m.name, "").attempt > 0 ? "↻" : "";
214
+ }
215
+ // stack a subsequent member into the group tab; null → caller degrades (D-09)
216
+ async joinGroup(cwd, name, group, entry) {
217
+ // pane ids compact when panes close — resolve the split source fresh from the newest LIVE
218
+ // member's durable name (never a cached id: closing the newest member must not poison this)
219
+ const src = entry.members[entry.members.length - 1];
220
+ const srcPane = await this.paneId({ id: src.paneId, name: src.name, cwd });
221
+ // VIS-09 P43-03 (43-MEASUREMENT.md): first join in a generation may split right when
222
+ // paneWidth/2 ≥ TRAILER_SAFE_FLOOR_COLS + margin (measured floor 108, margin 2); later joins
223
+ // stack down. Rightward splits below the measured floor shred the marker (e8aa003 at COLUMNS≈2;
224
+ // unrecoverable at 25 cols per measurement). Introspection failure → down (fail closed).
225
+ const direction = entry.members.length === 1 ? workerSplitDirection(await this.paneWidth(srcPane)) : "down";
226
+ const sp = await this.herdr(`pane split ${shq(srcPane)} --direction ${direction} --no-focus`);
227
+ if (sp.code !== 0)
228
+ return null;
229
+ let pane;
230
+ try {
231
+ pane = JSON.parse(sp.stdout).result?.pane?.pane_id ?? undefined;
232
+ }
233
+ catch {
234
+ /* fall through to degrade */
235
+ }
236
+ if (typeof pane !== "string" || !pane)
237
+ return null;
238
+ // the split pane is a bare shell: give it a durable agent name (SKILL:197) and VERIFY the name
239
+ // resolves back to this pane (research A1 is checked live per join, never assumed) — then cd the
240
+ // shell into this member's own worktree (a split inherits its parent's cwd, not ours).
241
+ const rn = await this.herdr(`agent rename ${shq(pane)} ${shq(name)}`);
242
+ const verified = rn.code === 0 && (await this.paneId({ id: "", name, cwd })) === pane;
243
+ const cd = verified ? await this.herdr(`pane run ${shq(pane)} ${shq(`cd ${shq(cwd)}`)}`) : null;
244
+ if (!cd || cd.code !== 0) {
245
+ await this.herdr(`pane close ${shq(pane)}`); // reap the failed join, best-effort
246
+ return null;
247
+ }
248
+ // VIS-10 hole 3: seed the run's workspace id into the split pane's shell. A split pane is a bare
249
+ // shell with FRESH env from herdr (not the parent pane's exports), so without this a worker's
250
+ // ad-hoc herdr call from this pane would be untargeted. Fail closed: a failed seed reaps + degrades
251
+ // like a failed cd (return null → caller falls back to a per-slot tab). this.ws is guaranteed set —
252
+ // joinGroup runs only after the first member's tabSlot succeeded, which requires it (VIS-10).
253
+ // v1.22 T3: same env seal as tabSlot — strip control-plane vars after the workspace seed.
254
+ const seed = await this.herdr(`pane run ${shq(pane)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; ${herdrSealShellPrefix()}`)}`);
255
+ if (seed.code !== 0) {
256
+ await this.herdr(`pane close ${shq(pane)}`);
257
+ return null;
258
+ }
259
+ entry.members = [...entry.members, { name, paneId: pane }];
260
+ await this.renameGroupTab(entry);
261
+ return { id: pane, name, cwd, tabId: entry.tabId, group };
262
+ }
263
+ async run(slot, cmd) {
264
+ const pane = await this.paneId(slot);
265
+ const r = await this.herdr(`pane run ${shq(pane)} ${shq(cmd)}`, slot.cwd);
266
+ if (r.code !== 0)
267
+ throw new Error(`herdr pane run failed: ${r.stderr || r.stdout}`);
268
+ }
269
+ async waitOutput(slot, pattern, timeoutMs, opts) {
270
+ const pane = await this.paneId(slot);
271
+ const r = await this.herdr(`wait output ${shq(pane)} --match ${shq(pattern)}${opts?.regex ? " --regex" : ""} --timeout ${Math.floor(timeoutMs)}`, slot.cwd, timeoutMs + 15_000);
272
+ return r.code === 0 && !r.stdout.includes('"error"'); // dead pane: exit 0 + error json (herdr bite)
273
+ }
274
+ async waitAgentStatus(slot, status, timeoutMs) {
275
+ const pane = await this.paneId(slot);
276
+ const r = await this.herdr(`wait agent-status ${shq(pane)} --status ${shq(status)} --timeout ${Math.floor(timeoutMs)}`, slot.cwd, timeoutMs + 15_000);
277
+ return r.code === 0 && !r.stdout.includes('"error"');
278
+ }
279
+ async status(slot) {
280
+ return this.statusByName(slot.name);
281
+ }
282
+ // shared by status() and the VIS-13 blocked glyph (renameGroupTab): resolve the live agent_status
283
+ // by durable name; "unknown" on any failure (dead pane, unparseable json) — never throws.
284
+ async statusByName(name) {
285
+ const r = await this.herdr(`agent get ${shq(name)}`);
286
+ try {
287
+ const s = JSON.parse(r.stdout).result?.agent?.agent_status;
288
+ return typeof s === "string" ? s : "unknown";
289
+ }
290
+ catch {
291
+ return "unknown";
292
+ }
293
+ }
294
+ async read(slot, lines) {
295
+ const pane = await this.paneId(slot);
296
+ const r = await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${lines}`, slot.cwd);
297
+ return r.stdout;
298
+ }
299
+ // v1.22 T5 / OBS-19: raw keystroke into the pane TUI (trust-dialog auto-answer). Resolves the pane
300
+ // id fresh like every other pane-addressed call (ids compact). Fail closed on nonzero herdr exit.
301
+ async sendKey(slot, key) {
302
+ const pane = await this.paneId(slot);
303
+ const r = await this.herdr(`pane send-keys ${shq(pane)} ${shq(key)}`, slot.cwd);
304
+ if (r.code !== 0)
305
+ throw new Error(`herdr pane send-keys failed: ${r.stderr || r.stdout}`);
306
+ }
307
+ async notify(msg, opts) {
308
+ if (opts?.tier === "routine")
309
+ return;
310
+ await this.herdr(`notification show ${shq(msg)} --sound ${opts?.tier === "attention" ? "request" : opts?.sound ?? "request"}`);
311
+ }
312
+ async close(slot) {
313
+ if (slot.group && this.groups.has(slot.group)) {
314
+ return this.serial(() => this.closeGrouped(slot));
315
+ }
316
+ if (slot.tabId) {
317
+ await this.herdr(`tab close ${shq(slot.tabId)}`); // reaps the slot's whole tab, best-effort
318
+ return;
319
+ }
320
+ const pane = await this.paneId(slot);
321
+ await this.herdr(`pane close ${shq(pane)}`); // best-effort
322
+ }
323
+ // D-08 ref-counted teardown, PER GENERATION (VIS-09 item 2): pane close per member; a generation's
324
+ // tab closes only when ITS OWN last member leaves; the group entry dies when all generations are gone.
325
+ // C2's "resolve the newest LIVE member" rule applies per generation — members[] holds only live members.
326
+ async closeGrouped(slot) {
327
+ const state = this.groups.get(slot.group);
328
+ if (!state)
329
+ return; // group already torn down — its tabs are gone
330
+ // find THIS member's generation by tab id: each overflow tab closes when its own last member leaves
331
+ const gen = state.generations.find((g) => g.tabId === slot.tabId);
332
+ if (!gen)
333
+ return; // generation already torn down — its tab is gone
334
+ const pane = await this.paneId(slot);
335
+ await this.herdr(`pane close ${shq(pane)}`); // best-effort
336
+ gen.members = gen.members.filter((m) => m.name !== slot.name);
337
+ await this.renameGroupTab(gen);
338
+ if (gen.members.length === 0) {
339
+ await this.herdr(`tab close ${shq(gen.tabId)}`); // refcount 0 → reap THIS generation's tab only
340
+ state.generations = state.generations.filter((g) => g.tabId !== gen.tabId);
341
+ if (state.generations.length === 0)
342
+ this.groups.delete(slot.group); // group dies when all generations gone
343
+ }
344
+ }
345
+ // T6 narrator: the run's single live surface — a drovr-created "watch" tab running the daemon's
346
+ // status command. Reuses the dedicated-tab slot path (label → tabSlot); close() reaps the whole tab
347
+ // at run end. One per run (the daemon calls this once). Failures propagate — the daemon swallows.
348
+ // T2: with a runId the pane is named canonically (drovr:watch:run:0:<runId>) so reconcile owns it;
349
+ // the legacy `narrator-watch-<pid>` shape survives for runId-less callers (canonicalizeLegacyName).
350
+ async narrator(cwd, command, runId) {
351
+ const name = runId ? formatOwnedName({ role: "watch", taskId: "run", attempt: 0, runId }) : `narrator-watch-${process.pid}`;
352
+ const s = await this.slot(cwd, name, { label: "watch" });
353
+ await this.run(s, command);
354
+ return s;
355
+ }
356
+ // OBS-17 T2 / v1.22b T1: close every drovr-owned pane that should not exist (superseded attempts,
357
+ // killed-daemon orphans, leftovers from OLDER runs) — in this run's workspace OR misplaced in any
358
+ // other one — then reap the tabs those closes emptied. Ownership is decided ONLY by parseOwnedName
359
+ // (drivers/types.ts panesToClose) — foreign names never become candidates, in any workspace; a pane
360
+ // this same run legitimately holds elsewhere is left alone (only run age marks a misplaced pane
361
+ // garbage). spareLiveLlm: same-run judge/review/consult panes have no journal row while live (their
362
+ // events land after the verdict is read), so mid-run sweeps spare them; boundary sweeps (start/
363
+ // resume/end) run with nothing in flight and take them too. Cosmetic by contract: every failure —
364
+ // herdr gone, pane vanished mid-sweep, unparseable listing — is swallowed; this method never throws.
365
+ async reconcile(desired, runId, opts) {
366
+ try {
367
+ if (!this.ws)
368
+ return;
369
+ const list = await this.herdr("agent list");
370
+ const agents = JSON.parse(list.stdout).result?.agents ?? [];
371
+ const toClose = panesToClose(agents.map((a) => ({ name: a.name, paneId: a.pane_id, tabId: a.tab_id, workspaceId: a.workspace_id })), desired, this.ws, runId, opts);
372
+ const touched = new Set();
373
+ for (const c of toClose) {
374
+ if (typeof c.tabId === "string")
375
+ touched.add(c.tabId);
376
+ await this.herdr(`pane close ${shq(c.paneId)}`); // best-effort — a vanished pane is already reconciled
377
+ }
378
+ if (touched.size === 0)
379
+ return;
380
+ // a tab our closes emptied was ours by construction (a tab with operator panes still has panes)
381
+ const pl = await this.herdr("pane list");
382
+ const alive = new Set((JSON.parse(pl.stdout).result?.panes ?? []).map((p) => p.tab_id));
383
+ for (const tab of touched)
384
+ if (!alive.has(tab))
385
+ await this.herdr(`tab close ${shq(tab)}`);
386
+ }
387
+ catch {
388
+ /* cosmetic — visibility hygiene never fails the run */
389
+ }
390
+ }
391
+ worktree(repo, branch, baseRef) {
392
+ return createWorktree(repo, branch, baseRef);
393
+ }
394
+ }
@@ -0,0 +1,3 @@
1
+ import type { DrovrConfig } from "../config/config.js";
2
+ import type { ExecutorDriver } from "./types.js";
3
+ export declare function pickDriver(cfg: DrovrConfig, override?: "auto" | "herdr" | "subprocess"): ExecutorDriver;
@@ -0,0 +1,13 @@
1
+ import { HerdrDriver } from "./herdr.js";
2
+ import { SubprocessDriver } from "./subprocess.js";
3
+ export function pickDriver(cfg, override) {
4
+ const want = override ?? cfg.driver;
5
+ // VIS-09 item 2: plumb the per-tab cap into the HerdrDriver — the driver takes it as a constructor
6
+ // param and never imports config (cfg is the only seam). Guaranteed present: DEFAULT_CONFIG seeds
7
+ // workersPerTab:3 and deepMerge overlays on top, so a missing overlay key still resolves.
8
+ if (want === "herdr")
9
+ return new HerdrDriver("herdr", cfg.visibility.workersPerTab);
10
+ if (want === "subprocess")
11
+ return new SubprocessDriver();
12
+ return HerdrDriver.available() ? new HerdrDriver("herdr", cfg.visibility.workersPerTab) : new SubprocessDriver();
13
+ }
@@ -0,0 +1,25 @@
1
+ import type { ExecutorDriver, NotifyOpts, Slot } from "./types.js";
2
+ export declare const MAX_BUF: number;
3
+ export declare const HERDR_CONTROL_VARS: readonly ["HERDR_ENV", "HERDR_SOCKET_PATH"];
4
+ /** Copy of env with herdr control-plane vars stripped. Daemon process.env is left alone. */
5
+ export declare function sealHerdrEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
6
+ /** Shell prefix that unsets herdr control vars in a pane/login shell (herdr seed + daemon setup). */
7
+ export declare function herdrSealShellPrefix(): string;
8
+ export declare class SubprocessDriver implements ExecutorDriver {
9
+ id: string;
10
+ interactive: boolean;
11
+ private slots;
12
+ private n;
13
+ slot(cwd: string, name: string): Promise<Slot>;
14
+ private state;
15
+ run(slot: Slot, cmd: string): Promise<void>;
16
+ waitOutput(slot: Slot, pattern: string, timeoutMs: number, opts?: {
17
+ regex?: boolean;
18
+ }): Promise<boolean>;
19
+ waitAgentStatus(slot: Slot, status: string, timeoutMs: number): Promise<boolean>;
20
+ status(_slot: Slot): Promise<string>;
21
+ read(slot: Slot, lines: number): Promise<string>;
22
+ notify(msg: string, _opts?: NotifyOpts): Promise<void>;
23
+ close(slot: Slot): Promise<void>;
24
+ worktree(repo: string, branch: string, baseRef: string): Promise<string>;
25
+ }
@@ -0,0 +1,106 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createWorktree } from "../run/git.js";
3
+ // HARD-03: cap retained worker output so a chatty worker can't grow drovr's heap unbounded.
4
+ // 2MB is safe against BOTH consumers: the largest read() call site asks for 1000 lines
5
+ // (daemon.ts:217/247), and every marker (DROVR_RESULT, DROVR_EXIT_<nonce>) is emitted at the
6
+ // END of output — a trailer would only be evicted if >2MB arrived AFTER it inside one 200ms
7
+ // waitOutput poll (~10MB/s sustained, far beyond agent-CLI rates). Tail-truncate, never head:
8
+ // consumers only ever tail-read.
9
+ export const MAX_BUF = 2 * 1024 * 1024;
10
+ // OBS-17 / v1.22 T3: control-plane vars that let a process talk to the operator's herdr.
11
+ // Workers, judges, reviewers, and consults must never inherit them — only the daemon process
12
+ // (and its own herdr driver CLI calls) keep the live session. Socket path is the wire; HERDR_ENV
13
+ // is the "I am inside herdr" gate every agent skill checks before mutating panes.
14
+ export const HERDR_CONTROL_VARS = ["HERDR_ENV", "HERDR_SOCKET_PATH"];
15
+ /** Copy of env with herdr control-plane vars stripped. Daemon process.env is left alone. */
16
+ export function sealHerdrEnv(env = process.env) {
17
+ const out = { ...env };
18
+ for (const k of HERDR_CONTROL_VARS)
19
+ delete out[k];
20
+ return out;
21
+ }
22
+ /** Shell prefix that unsets herdr control vars in a pane/login shell (herdr seed + daemon setup). */
23
+ export function herdrSealShellPrefix() {
24
+ return HERDR_CONTROL_VARS.map((k) => `unset ${k}`).join("; ") + "; ";
25
+ }
26
+ export class SubprocessDriver {
27
+ id = "subprocess";
28
+ interactive = false; // no pane, no operator — interactive workers force print here
29
+ // OBS-17 T2: no `reconcile` — there are no panes/tabs to sweep; the daemon's optional-chain
30
+ // call is a no-op here by construction, so the oracle suite runs byte-identical.
31
+ slots = new Map();
32
+ n = 0;
33
+ async slot(cwd, name) {
34
+ const id = `sp-${++this.n}`;
35
+ this.slots.set(id, { buf: "", exited: false });
36
+ return { id, name, cwd };
37
+ }
38
+ state(slot) {
39
+ const s = this.slots.get(slot.id);
40
+ if (!s)
41
+ throw new Error(`unknown slot ${slot.id}`);
42
+ return s;
43
+ }
44
+ async run(slot, cmd) {
45
+ const s = this.state(slot);
46
+ // HARD-05: interactive=false — no operator, so an open stdin pipe is a promise drovr can never
47
+ // keep; codex exec appends a piped stdin as a <stdin> block (`codex exec --help`) and blocks on a
48
+ // read that never EOFs. One spawn site covers every adapter (D-06).
49
+ // v1.22 T3: seal herdr control vars so worker/judge/review/consult children cannot reach the
50
+ // operator's herdr (OBS-17 watch-tab leak class). process.env of the daemon is untouched.
51
+ const p = spawn("bash", ["-lc", cmd], {
52
+ cwd: slot.cwd,
53
+ stdio: ["ignore", "pipe", "pipe"],
54
+ env: sealHerdrEnv(process.env),
55
+ });
56
+ s.proc = p;
57
+ p.stdout.on("data", (d) => (s.buf = (s.buf + d).slice(-MAX_BUF)));
58
+ p.stderr.on("data", (d) => (s.buf = (s.buf + d).slice(-MAX_BUF)));
59
+ p.on("close", () => (s.exited = true));
60
+ p.on("error", (e) => { s.buf = (s.buf + `\n[drovr subprocess error] ${e}\n`).slice(-MAX_BUF); s.exited = true; });
61
+ }
62
+ async waitOutput(slot, pattern, timeoutMs, opts) {
63
+ const s = this.state(slot);
64
+ const re = opts?.regex ? new RegExp(pattern) : null; // compile once, not per 200ms poll
65
+ const hit = re ? (b) => re.test(b) : (b) => b.includes(pattern);
66
+ const deadline = Date.now() + timeoutMs;
67
+ while (Date.now() < deadline) {
68
+ if (hit(s.buf))
69
+ return true;
70
+ await new Promise((r) => setTimeout(r, 200)); // ponytail: 200ms poll; herdr driver has real event waits
71
+ }
72
+ return hit(s.buf);
73
+ }
74
+ async waitAgentStatus(slot, status, timeoutMs) {
75
+ if (status !== "done")
76
+ return false; // subprocess knows only process-exit
77
+ const s = this.state(slot);
78
+ const deadline = Date.now() + timeoutMs;
79
+ while (Date.now() < deadline) {
80
+ if (s.exited)
81
+ return true;
82
+ await new Promise((r) => setTimeout(r, 200));
83
+ }
84
+ return s.exited;
85
+ }
86
+ async status(_slot) {
87
+ return "unknown"; // no screen to scrape; never reports blocked
88
+ }
89
+ async read(slot, lines) {
90
+ return this.state(slot).buf.split("\n").slice(-lines).join("\n");
91
+ }
92
+ async notify(msg, _opts) {
93
+ if (_opts?.tier === "routine")
94
+ return;
95
+ console.log(`[drovr] ${msg}`);
96
+ }
97
+ async close(slot) {
98
+ const s = this.slots.get(slot.id);
99
+ if (s?.proc && !s.exited)
100
+ s.proc.kill("SIGKILL");
101
+ this.slots.delete(slot.id);
102
+ }
103
+ worktree(repo, branch, baseRef) {
104
+ return createWorktree(repo, branch, baseRef);
105
+ }
106
+ }
@@ -0,0 +1,61 @@
1
+ export interface Slot {
2
+ id: string;
3
+ name: string;
4
+ cwd: string;
5
+ tabId?: string;
6
+ group?: string;
7
+ }
8
+ export type NotifyTier = "routine" | "attention";
9
+ export interface NotifyOpts {
10
+ tier?: NotifyTier;
11
+ sound?: "none" | "done" | "request";
12
+ }
13
+ export interface SlotOpts {
14
+ group?: string;
15
+ label?: string;
16
+ owned?: OwnedName;
17
+ }
18
+ export declare const OWNED_ROLES: readonly ["worker", "judge", "review", "consult", "watch", "other"];
19
+ export type OwnedRole = (typeof OWNED_ROLES)[number];
20
+ export interface OwnedName {
21
+ role: OwnedRole;
22
+ taskId: string;
23
+ attempt: number;
24
+ runId: string;
25
+ }
26
+ export declare function formatOwnedName(o: OwnedName): string;
27
+ export declare function parseOwnedName(name: string): OwnedName | null;
28
+ export declare function isForeignName(name: string): boolean;
29
+ export interface FleetAgent {
30
+ name?: string;
31
+ paneId?: string;
32
+ tabId?: string;
33
+ workspaceId?: string;
34
+ }
35
+ export declare function panesToClose(agents: FleetAgent[], desired: Set<string>, ws: string, runId: string, opts?: {
36
+ spareLiveLlm?: boolean;
37
+ }): {
38
+ paneId: string;
39
+ tabId?: string;
40
+ }[];
41
+ export declare function canonicalizeLegacyName(name: string, runId: string): OwnedName;
42
+ export interface ExecutorDriver {
43
+ id: string;
44
+ interactive: boolean;
45
+ slot(cwd: string, name: string, opts?: SlotOpts): Promise<Slot>;
46
+ run(slot: Slot, cmd: string): Promise<void>;
47
+ waitOutput(slot: Slot, pattern: string, timeoutMs: number, opts?: {
48
+ regex?: boolean;
49
+ }): Promise<boolean>;
50
+ waitAgentStatus(slot: Slot, status: string, timeoutMs: number): Promise<boolean>;
51
+ status(slot: Slot): Promise<string>;
52
+ read(slot: Slot, lines: number): Promise<string>;
53
+ sendKey?(slot: Slot, key: string): Promise<void>;
54
+ notify(msg: string, opts?: NotifyOpts): Promise<void>;
55
+ close(slot: Slot): Promise<void>;
56
+ worktree(repo: string, branch: string, baseRef: string): Promise<string>;
57
+ narrator?: (cwd: string, command: string, runId?: string) => Promise<Slot>;
58
+ reconcile?: (desired: Set<string>, runId: string, opts?: {
59
+ spareLiveLlm?: boolean;
60
+ }) => Promise<void>;
61
+ }