tickmarkr 2.5.5 → 2.5.7

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 (69) hide show
  1. package/README.md +3 -1
  2. package/dist/adapters/prompt.js +21 -1
  3. package/dist/cli/commands/approve.js +69 -8
  4. package/dist/cli/commands/doctor.d.ts +10 -0
  5. package/dist/cli/commands/fleet.js +4 -0
  6. package/dist/cli/commands/plan.js +20 -4
  7. package/dist/cli/commands/status.js +95 -34
  8. package/dist/cli/commands/verify.js +108 -85
  9. package/dist/config/config.d.ts +11 -0
  10. package/dist/config/config.js +21 -12
  11. package/dist/config/fleet-overlay.js +55 -17
  12. package/dist/drivers/index.js +2 -1
  13. package/dist/drivers/orca.d.ts +21 -1
  14. package/dist/drivers/orca.js +209 -27
  15. package/dist/gates/baseline.d.ts +21 -5
  16. package/dist/gates/baseline.js +67 -17
  17. package/dist/gates/cache.d.ts +14 -13
  18. package/dist/gates/cache.js +17 -5
  19. package/dist/gates/llm.d.ts +3 -0
  20. package/dist/gates/llm.js +11 -0
  21. package/dist/gates/review.d.ts +28 -3
  22. package/dist/gates/review.js +118 -15
  23. package/dist/gates/run-gates.d.ts +10 -2
  24. package/dist/gates/run-gates.js +362 -108
  25. package/dist/gates/test-manifest.d.ts +33 -1
  26. package/dist/gates/test-manifest.js +132 -40
  27. package/dist/gates/test-reporter.js +20 -7
  28. package/dist/graph/graph.d.ts +4 -0
  29. package/dist/graph/graph.js +50 -1
  30. package/dist/run/activity.d.ts +28 -0
  31. package/dist/run/activity.js +194 -0
  32. package/dist/run/consult.js +5 -4
  33. package/dist/run/daemon.d.ts +11 -0
  34. package/dist/run/daemon.js +663 -128
  35. package/dist/run/execution-budget.d.ts +25 -0
  36. package/dist/run/execution-budget.js +142 -0
  37. package/dist/run/git.d.ts +46 -1
  38. package/dist/run/git.js +149 -12
  39. package/dist/run/journal.d.ts +23 -5
  40. package/dist/run/journal.js +98 -25
  41. package/dist/run/lease.d.ts +44 -0
  42. package/dist/run/lease.js +226 -3
  43. package/dist/run/operator-page-summary.d.ts +56 -0
  44. package/dist/run/operator-page-summary.js +68 -0
  45. package/dist/run/operator-summary.d.ts +69 -0
  46. package/dist/run/operator-summary.js +77 -0
  47. package/dist/run/protocol.d.ts +71 -0
  48. package/dist/run/protocol.js +32 -0
  49. package/dist/run/recovery.d.ts +8 -0
  50. package/dist/run/recovery.js +25 -0
  51. package/dist/run/repair-selection.d.ts +12 -0
  52. package/dist/run/repair-selection.js +56 -0
  53. package/dist/run/stall.d.ts +6 -1
  54. package/dist/run/stall.js +60 -3
  55. package/dist/tui/cockpit/board.d.ts +9 -0
  56. package/dist/tui/cockpit/board.js +10 -0
  57. package/dist/tui/cockpit/derive.d.ts +35 -0
  58. package/dist/tui/cockpit/derive.js +152 -10
  59. package/dist/tui/cockpit/evidence-view.d.ts +2 -0
  60. package/dist/tui/cockpit/evidence-view.js +42 -12
  61. package/dist/tui/cockpit/run-cockpit.d.ts +8 -1
  62. package/dist/tui/cockpit/run-cockpit.js +95 -1
  63. package/dist/tui/cockpit/run-view.d.ts +37 -0
  64. package/dist/tui/cockpit/run-view.js +189 -2
  65. package/dist/tui/ink/fleet-app.d.ts +10 -2
  66. package/dist/tui/ink/fleet-app.js +33 -15
  67. package/package.json +2 -2
  68. package/skills/tickmarkr-overseer/SKILL.md +55 -3
  69. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
@@ -6,13 +6,15 @@ 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
- import { captureBaseline, detectGateCommands } from "../../gates/baseline.js";
9
+ import { captureBaseline, detectGateCommands, staleFileCountCommands } from "../../gates/baseline.js";
10
10
  import { modelProvider } from "../../gates/review.js";
11
11
  import { runGates } from "../../gates/run-gates.js";
12
12
  import { getTask, loadGraph } from "../../graph/graph.js";
13
13
  import { GATE_NAMES } from "../../graph/schema.js";
14
+ import { executionSignal } from "../../run/execution-budget.js";
14
15
  import { linkNodeModules, removeWorktree, shGit, shGitOk } from "../../run/git.js";
15
16
  import { Journal } from "../../run/journal.js";
17
+ import { withRepositoryLease } from "../../run/lease.js";
16
18
  /**
17
19
  * tickmarkr verify — the gate battery as a standalone command (OPERATING-MODEL-2026-08-11 item 3).
18
20
  *
@@ -148,10 +150,12 @@ export async function verify(argv, cwd = process.cwd()) {
148
150
  allowPositionals: false,
149
151
  });
150
152
  const stateRoot = await verifyStateRoot(cwd);
153
+ const fileRoot = (file) => existsSync(join(cwd, ".tickmarkr", file)) ? cwd : stateRoot;
151
154
  if (resolve(stateRoot) !== resolve(cwd)) {
152
- console.error(`verify: state files graph.json, doctor.json and config.yaml resolved read-only from ${join(stateRoot, ".tickmarkr")} (linked worktree)`);
155
+ console.error(`verify: state files resolved read-only from ${join(stateRoot, ".tickmarkr")} (linked worktree; per-file origins: `
156
+ + STATE_FILES.map(file => `${file}: ${join(fileRoot(file), ".tickmarkr", file)} (${fileRoot(file) === cwd ? "local" : "common root"})`).join("; ") + ")");
153
157
  }
154
- const cfg = loadConfig(stateRoot);
158
+ const cfg = loadConfig(fileRoot("config.yaml"));
155
159
  const head = (await shGitOk("git rev-parse HEAD", cwd)).trim();
156
160
  const baseTip = (await shGitOk(`git rev-parse '${values.base}'`, cwd).catch(() => {
157
161
  throw new Error(`--base ${values.base} is not a resolvable ref — pass --base <ref> naming the branch this diff targets`);
@@ -165,7 +169,7 @@ export async function verify(argv, cwd = process.cwd()) {
165
169
  let files = values.files ?? [];
166
170
  let goal = `independent verification of the ${values.base}..HEAD diff`;
167
171
  if (values.task) {
168
- const t = getTask(loadGraph(stateRoot), values.task);
172
+ const t = getTask(loadGraph(fileRoot("graph.json")), values.task);
169
173
  acceptance = t.acceptance;
170
174
  if (!files.length)
171
175
  files = t.files;
@@ -221,7 +225,7 @@ export async function verify(argv, cwd = process.cwd()) {
221
225
  let author = HUMAN_AUTHOR;
222
226
  const adapters = allAdapters();
223
227
  if (wantAcceptance || wantReview) {
224
- const health = readDoctor(stateRoot) ?? (await probeAll(adapters));
228
+ const health = readDoctor(fileRoot("doctor.json")) ?? (await probeAll(adapters));
225
229
  const pools = rolePools(cfg, adapters, health);
226
230
  judgeChannels = pools.judge;
227
231
  channels = pools.review;
@@ -249,95 +253,114 @@ export async function verify(argv, cwd = process.cwd()) {
249
253
  // ponytail: tmpdir means the baseline cache dies on reboot/cleanup — worst case is one re-capture.
250
254
  const stateDir = verifyStateDir(cwd);
251
255
  mkdirSync(stateDir, { recursive: true });
252
- let baseline;
253
- const cachePath = baselineCachePath(cwd, mergeBase, commands);
254
- const verdictlessMarker = `${cachePath}.verdictless`;
255
- mkdirSync(dirname(cachePath), { recursive: true });
256
- if (values.baseline) {
257
- baseline = JSON.parse(readFileSync(values.baseline, "utf8"));
258
- }
259
- else {
260
- let cached;
261
- if (existsSync(cachePath)) {
262
- cached = JSON.parse(readFileSync(cachePath, "utf8"));
263
- const missing = verdictlessCommands(cached, commands);
264
- if (missing.length) {
265
- console.error(`verify: cached baseline recorded no verdict for ${missing.join(", ")}; it was not reusable and will be recaptured`);
266
- rmSync(cachePath, { force: true });
267
- cached = undefined;
268
- }
269
- }
270
- if (cached) {
271
- console.error(`verify: reusing cached baseline for ${mergeBase.slice(0, 12)} (${cachePath})`);
272
- baseline = cached;
256
+ const recordJournal = values.record ? Journal.open(stateRoot, values.record) : undefined;
257
+ // OBS-1042: one runner root per repository across every linked worktree — the capture and the
258
+ // battery below run under a reservation keyed on the common git dir, and a waiter says who holds it.
259
+ return withRepositoryLease(cwd, () => leasedVerify(), {
260
+ signal: executionSignal(),
261
+ onWait: (holder) => {
262
+ console.error(`verify: waiting for the repository's runner lease held by pid ${holder.pid} in ${holder.cwd}`);
263
+ recordJournal?.append("suite-wait", values.task ?? "VERIFY", { holderPid: holder.pid, holderCwd: holder.cwd });
264
+ },
265
+ });
266
+ async function leasedVerify() {
267
+ let baseline;
268
+ const cachePath = baselineCachePath(cwd, mergeBase, commands);
269
+ const verdictlessMarker = `${cachePath}.verdictless`;
270
+ mkdirSync(dirname(cachePath), { recursive: true });
271
+ if (values.baseline) {
272
+ baseline = JSON.parse(readFileSync(values.baseline, "utf8"));
273
273
  }
274
274
  else {
275
- if (existsSync(verdictlessMarker)) {
276
- console.error(`verify: prior baseline recorded no verdict and was not cached; recapturing merge-base ${mergeBase.slice(0, 12)}`);
277
- }
278
- else {
279
- console.error(`verify: capturing baseline at merge-base ${mergeBase.slice(0, 12)} (cached by base, lockfile and command hashes at ${cachePath})`);
280
- }
281
- const baseDir = join(stateDir, `base-${mergeBase.slice(0, 12)}`);
282
- await shGit(`git worktree remove --force '${baseDir}'`, cwd); // stale leftover from an interrupted run
283
- await shGitOk(`git worktree add --detach '${baseDir}' '${mergeBase}'`, cwd);
284
- try {
285
- linkNodeModules(cwd, baseDir, { force: true });
286
- baseline = await captureBaseline(baseDir, commands);
287
- const missing = verdictlessCommands(baseline, commands);
275
+ let cached;
276
+ if (existsSync(cachePath)) {
277
+ cached = JSON.parse(readFileSync(cachePath, "utf8"));
278
+ const missing = verdictlessCommands(cached, commands);
288
279
  if (missing.length) {
289
- writeFileSync(verdictlessMarker, JSON.stringify({ base: mergeBase, commands: missing }) + "\n");
280
+ console.error(`verify: cached baseline recorded no verdict for ${missing.join(", ")}; it was not reusable and will be recaptured`);
290
281
  rmSync(cachePath, { force: true });
282
+ cached = undefined;
291
283
  }
292
- else {
293
- writeFileSync(cachePath, JSON.stringify(baseline, null, 2));
294
- rmSync(verdictlessMarker, { force: true });
284
+ // OBS-1044: a cached vitest count that is a stdout sum may be inflated by nested runner echo;
285
+ // applying it would manufacture a deficit. Only a manifest-derived count is a floor.
286
+ const stale = cached ? staleFileCountCommands(cached, commands, cwd) : [];
287
+ if (stale.length) {
288
+ console.error(`verify: cached baseline's file count for ${stale.join(", ")} is not manifest-derived; it will be recaptured`);
289
+ rmSync(cachePath, { force: true });
290
+ cached = undefined;
295
291
  }
296
292
  }
297
- finally {
298
- await removeWorktree(cwd, baseDir);
293
+ if (cached) {
294
+ console.error(`verify: reusing cached baseline for ${mergeBase.slice(0, 12)} (${cachePath})`);
295
+ baseline = cached;
296
+ }
297
+ else {
298
+ if (existsSync(verdictlessMarker)) {
299
+ console.error(`verify: prior baseline recorded no verdict and was not cached; recapturing merge-base ${mergeBase.slice(0, 12)}`);
300
+ }
301
+ else {
302
+ console.error(`verify: capturing baseline at merge-base ${mergeBase.slice(0, 12)} (cached by base, lockfile and command hashes at ${cachePath})`);
303
+ }
304
+ const baseDir = join(stateDir, `base-${mergeBase.slice(0, 12)}`);
305
+ await shGit(`git worktree remove --force '${baseDir}'`, cwd); // stale leftover from an interrupted run
306
+ await shGitOk(`git worktree add --detach '${baseDir}' '${mergeBase}'`, cwd);
307
+ try {
308
+ linkNodeModules(cwd, baseDir, { force: true });
309
+ baseline = await captureBaseline(baseDir, commands);
310
+ const missing = verdictlessCommands(baseline, commands);
311
+ if (missing.length) {
312
+ writeFileSync(verdictlessMarker, JSON.stringify({ base: mergeBase, commands: missing }) + "\n");
313
+ rmSync(cachePath, { force: true });
314
+ }
315
+ else {
316
+ writeFileSync(cachePath, JSON.stringify(baseline, null, 2));
317
+ rmSync(verdictlessMarker, { force: true });
318
+ }
319
+ }
320
+ finally {
321
+ await removeWorktree(cwd, baseDir);
322
+ }
299
323
  }
300
324
  }
301
- }
302
- const recordJournal = values.record ? Journal.open(stateRoot, values.record) : undefined;
303
- const artifactDir = join(stateDir, new Date().toISOString().replace(/[:.]/g, "-"));
304
- mkdirSync(artifactDir, { recursive: true });
305
- const { results } = await runGates(task, {
306
- worktree: cwd, baseRef: mergeBase,
307
- result: { ok: true, summary: "standalone verify — no worker claims to trust", deviations: [], raw: "" },
308
- author, commands, baseline, channels, ...(judgeChannels ? { judgeChannels } : {}), adapters, cfg,
309
- pipeline: "v185", verificationScope: "standalone", artifactDir, stateDir: join(stateRoot, ".tickmarkr"),
310
- onGate: (e) => {
311
- if (e.phase === "start")
312
- console.error(`verify: → ${e.gate} (${e.index}/${e.total})`);
313
- else if (e.phase === "note")
314
- console.error(`verify: note ${e.gate} ${e.name} ${JSON.stringify(e.payload)}`);
315
- else
316
- console.error(`verify: ${e.result.pass ? "✓" : "✗"} ${e.result.gate} — ${e.result.details.split("\n")[0] ?? ""}`);
317
- },
318
- });
319
- const green = results.length > 0 && results.every((r) => r.pass || r.meta?.skipped === true);
320
- const reviewRows = results.filter((result) => result.gate === "review");
321
- const reviewFindings = reviewRows.flatMap((result) => result.details.split("\n").flatMap((line) => {
322
- const match = /^- \[([^\]]+)\] (.*)$/.exec(line);
323
- return match ? [{ classification: match[1], note: match[2], reviewer: result.meta?.reviewer }] : [];
324
- }));
325
- const artifactPath = join(artifactDir, "verify-results.json");
326
- writeFileSync(artifactPath, JSON.stringify({ base: baseTip, head, mergeBase, green, gateRows: results, reviewFindings }, null, 2) + "\n");
327
- console.error(`verify: artifacts written to ${artifactPath}`);
328
- const review = reviewRows.at(-1);
329
- if (recordJournal && review) {
330
- recordJournal.append("review-leg2", values.task ?? "VERIFY", {
331
- base: baseTip, head, mergeBase, author: channelKey(author), artifactPath,
332
- ...review,
325
+ const artifactDir = join(stateDir, new Date().toISOString().replace(/[:.]/g, "-"));
326
+ mkdirSync(artifactDir, { recursive: true });
327
+ const { results } = await runGates(task, {
328
+ worktree: cwd, baseRef: mergeBase,
329
+ result: { ok: true, summary: "standalone verify — no worker claims to trust", deviations: [], raw: "" },
330
+ author, commands, baseline, channels, ...(judgeChannels ? { judgeChannels } : {}), adapters, cfg,
331
+ verificationScope: "standalone", artifactDir, stateDir: join(stateRoot, ".tickmarkr"),
332
+ onGate: (e) => {
333
+ if (e.phase === "start")
334
+ console.error(`verify: → ${e.gate} (${e.index}/${e.total})`);
335
+ else if (e.phase === "note")
336
+ console.error(`verify: note ${e.gate} ${e.name} ${JSON.stringify(e.payload)}`);
337
+ else
338
+ console.error(`verify: ${e.result.pass ? "✓" : "✗"} ${e.result.gate} — ${e.result.details.split("\n")[0] ?? ""}`);
339
+ },
333
340
  });
341
+ const green = results.length > 0 && results.every((r) => r.pass || r.meta?.skipped === true);
342
+ const reviewRows = results.filter((result) => result.gate === "review");
343
+ const reviewFindings = reviewRows.flatMap((result) => result.details.split("\n").flatMap((line) => {
344
+ const match = /^- \[([^\]]+)\] (.*)$/.exec(line);
345
+ return match ? [{ classification: match[1], note: match[2], reviewer: result.meta?.reviewer }] : [];
346
+ }));
347
+ const artifactPath = join(artifactDir, "verify-results.json");
348
+ writeFileSync(artifactPath, JSON.stringify({ base: baseTip, head, mergeBase, green, gateRows: results, reviewFindings }, null, 2) + "\n");
349
+ console.error(`verify: artifacts written to ${artifactPath}`);
350
+ const review = reviewRows.at(-1);
351
+ if (recordJournal && review) {
352
+ recordJournal.append("review-leg2", values.task ?? "VERIFY", {
353
+ base: baseTip, head, mergeBase, author: channelKey(author), artifactPath,
354
+ ...review,
355
+ });
356
+ }
357
+ if (values.json) {
358
+ return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, results }, null, 2), code: green ? 0 : 2 };
359
+ }
360
+ const lines = results.map((r) => `${r.pass ? "PASS" : "FAIL"} ${r.gate}\n${r.details.split("\n").map((l) => ` ${l}`).join("\n")}`);
361
+ const verdict = green
362
+ ? `verify GREEN — ${results.length} gate(s) passed on ${mergeBase.slice(0, 12)}..${head.slice(0, 12)} (merge is a human decision; artifacts: ${artifactPath})`
363
+ : `verify RED — first failure decides; artifacts: ${artifactPath}`;
364
+ return { out: [...lines, "", verdict].join("\n"), code: green ? 0 : 2 };
334
365
  }
335
- if (values.json) {
336
- return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, results }, null, 2), code: green ? 0 : 2 };
337
- }
338
- const lines = results.map((r) => `${r.pass ? "PASS" : "FAIL"} ${r.gate}\n${r.details.split("\n").map((l) => ` ${l}`).join("\n")}`);
339
- const verdict = green
340
- ? `verify GREEN — ${results.length} gate(s) passed on ${mergeBase.slice(0, 12)}..${head.slice(0, 12)} (merge is a human decision; artifacts: ${artifactPath})`
341
- : `verify RED — first failure decides; artifacts: ${artifactPath}`;
342
- return { out: [...lines, "", verdict].join("\n"), code: green ? 0 : 2 };
343
366
  }
@@ -132,7 +132,17 @@ export declare function effectiveReviewPolicy(files: ReadonlyArray<string>, revi
132
132
  * under-flagged one skips the only gate that exists for it.
133
133
  */
134
134
  export declare function criticalPathHits(files: ReadonlyArray<string>, criticalPaths?: ReadonlyArray<string>): string[];
135
+ export declare const ExecutionPolicySchema: z.ZodObject<{
136
+ boundedInfrastructure: z.ZodDefault<z.ZodBoolean>;
137
+ repairSelection: z.ZodDefault<z.ZodBoolean>;
138
+ taskExecutionLimitMs: z.ZodNumber;
139
+ }, z.core.$strict>;
135
140
  export declare const TickmarkrConfigSchema: z.ZodObject<{
141
+ executionPolicy: z.ZodOptional<z.ZodObject<{
142
+ boundedInfrastructure: z.ZodDefault<z.ZodBoolean>;
143
+ repairSelection: z.ZodDefault<z.ZodBoolean>;
144
+ taskExecutionLimitMs: z.ZodNumber;
145
+ }, z.core.$strict>>;
136
146
  concurrency: z.ZodNumber;
137
147
  driver: z.ZodEnum<{
138
148
  auto: "auto";
@@ -366,6 +376,7 @@ export type FleetEditable = {
366
376
  denyModels: string[];
367
377
  denyWorkersAdapters?: string[];
368
378
  denyWorkersModels?: string[];
379
+ allowOut?: string[];
369
380
  tiers: Record<string, Record<string, FleetTierAssignment | null>>;
370
381
  map: Record<string, MapEntry>;
371
382
  floors: Record<string, Tier>;
@@ -264,7 +264,14 @@ const ShapeGateParticipationSchema = z
264
264
  });
265
265
  }
266
266
  });
267
+ export const ExecutionPolicySchema = z.object({
268
+ boundedInfrastructure: z.boolean().default(false),
269
+ repairSelection: z.boolean().default(false),
270
+ taskExecutionLimitMs: z.number().int().positive(),
271
+ }).strict();
267
272
  export const TickmarkrConfigSchema = z.object({
273
+ // Opt-in recovery experiment. The original run snapshot owns this across resumes.
274
+ executionPolicy: ExecutionPolicySchema.optional(),
268
275
  concurrency: z.number().int().positive(),
269
276
  driver: z.enum(["auto", "herdr", "subprocess", "orca"]),
270
277
  integrationBranchPrefix: z
@@ -902,12 +909,13 @@ export function universeEntryMatches(row, model, entry) {
902
909
  export function universeCovers(universe, entry) {
903
910
  return universe.some((row) => entry === row.adapter || row.models.some((m) => entry === m || entry === `${row.adapter}:${m}`));
904
911
  }
905
- // v1.92 fleet membership: with a discovered `universe` (classified models only) the deny sets become
906
- // the EFFECTIVE out-of-fleet set — the routing.allow complement (fail-closed) as adapter ids and
907
- // adapter:model keys, UNION every authored routing.deny adapters/models entry VERBATIM in its own list
908
- // (never workers). LEG2-T3 round 2 finding 2: authored entries are never collapsed into a membership
909
- // key, so one Space press clears one reason; finding 3: allow admission honours recorded identity.
910
- // Absent universe ⇒ deny arrays verbatim, so non-UI callers stay byte-identical.
912
+ // v1.92 fleet membership: with a discovered `universe` (classified models only) the routing.allow
913
+ // complement (fail-closed) rides as `allowOut` — adapter ids and adapter:model keys — BESIDE the
914
+ // authored routing.deny adapters/models entries, which ride VERBATIM in their own lists (never
915
+ // workers). LEG2-T3 round 2 finding 2: authored entries are never collapsed into a membership key;
916
+ // OBS-1046: nor is an allow exclusion folded into a deny list — the two reasons stay distinct so
917
+ // one Space press clears one; finding 3: allow admission honours recorded identity.
918
+ // Absent universe ⇒ deny arrays verbatim and no allowOut, so non-UI callers stay byte-identical.
911
919
  export function fleetEditableFromConfig(cfg, universe) {
912
920
  const tiers = {};
913
921
  for (const [adapter, entry] of Object.entries(cfg.tiers)) {
@@ -916,8 +924,9 @@ export function fleetEditableFromConfig(cfg, universe) {
916
924
  tiers[adapter][model] = { tier };
917
925
  }
918
926
  }
919
- let denyAdapters = [...(cfg.routing.deny?.adapters ?? [])].sort();
920
- let denyModels = [...(cfg.routing.deny?.models ?? [])].sort();
927
+ const denyAdapters = [...(cfg.routing.deny?.adapters ?? [])].sort();
928
+ const denyModels = [...(cfg.routing.deny?.models ?? [])].sort();
929
+ let allowOut;
921
930
  if (universe !== undefined) {
922
931
  const { allow } = cfg.routing;
923
932
  const allowEntries = allow ? [...(allow.adapters ?? []), ...(allow.models ?? [])] : null;
@@ -933,14 +942,14 @@ export function fleetEditableFromConfig(cfg, universe) {
933
942
  modelsOut.push(...out.map((m) => `${row.adapter}:${m}`));
934
943
  }
935
944
  }
936
- // OBS-517: authored deny entries — covered by the probe universe or not — ride verbatim, so a
937
- // transient probe failure can never un-deny one on the next write (fail-open on the ban list).
938
- denyAdapters = [...new Set([...adaptersOut, ...denyAdapters])].sort();
939
- denyModels = [...new Set([...modelsOut, ...denyModels])].sort();
945
+ // OBS-517: authored deny entries — covered by the probe universe or not — ride verbatim (above),
946
+ // so a transient probe failure can never un-deny one on the next write (fail-open on the ban list).
947
+ allowOut = [...new Set([...adaptersOut, ...modelsOut])].sort();
940
948
  }
941
949
  return {
942
950
  denyAdapters,
943
951
  denyModels,
952
+ ...(allowOut !== undefined ? { allowOut } : {}),
944
953
  // OBS-994: workers-only deny is never folded into the all-seats complement above — it is
945
954
  // returned verbatim beside the flat scopes so the fleet browser can show and toggle it.
946
955
  denyWorkersAdapters: [...(cfg.routing.deny?.workers?.adapters ?? [])].sort(),
@@ -23,7 +23,8 @@ function allowFormFromExclusions(universe, edited) {
23
23
  }
24
24
  // LEG2-T3 round 2 finding 1: every staged entry excludes what it NAMES — a bare model id every
25
25
  // adapter serving it, an identity its alias — never only an adapter id or an adapter:model key.
26
- const entries = [...edited.denyAdapters, ...edited.denyModels];
26
+ // OBS-1046: the staged allow complement is a reason of its own, beside the authored deny lists.
27
+ const entries = [...edited.denyAdapters, ...edited.denyModels, ...(edited.allowOut ?? [])];
27
28
  const adapters = [];
28
29
  const models = [];
29
30
  let excluded = false;
@@ -56,9 +57,12 @@ function residualDeny(universe, entries) {
56
57
  // list keeps its node. A staged entry the allow form cannot express as a membership key (outside the
57
58
  // probe universe, or a bare-model/identity spelling) is written verbatim too; canonical keys the
58
59
  // session added ride the allow form alone.
59
- function flatDenyAfterWrite(doc, path, after, universe) {
60
+ function authoredEntries(doc, path) {
60
61
  const node = doc.getIn(path, true);
61
- const authored = isSeq(node) ? node.items.flatMap((item) => (isScalar(item) ? [String(item.value)] : [])) : [];
62
+ return isSeq(node) ? node.items.flatMap((item) => (isScalar(item) ? [String(item.value)] : [])) : [];
63
+ }
64
+ function flatDenyAfterWrite(doc, path, after, universe) {
65
+ const authored = authoredEntries(doc, path);
62
66
  const canonical = (entry) => universe.some((row) => entry === row.adapter || row.models.some((m) => entry === `${row.adapter}:${m}`));
63
67
  const kept = authored.filter((entry) => after.includes(entry));
64
68
  const verbatim = sortedUnique(after.filter((entry) => !kept.includes(entry)
@@ -183,9 +187,12 @@ export function renderFleetOverlayWrite(priorBytes, write) {
183
187
  if (doc.errors.length)
184
188
  throw doc.errors[0];
185
189
  const { initial, edited } = write;
186
- const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
187
- || sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
188
- if (denyChanged) {
190
+ // OBS-1046: each flat scope (and the allow complement) is compared on its own — a scope the
191
+ // session never edited keeps its node byte for byte, whatever its sibling did.
192
+ const changed = (before = [], after = []) => sortedUnique(before).join() !== sortedUnique(after).join();
193
+ const adaptersChanged = changed(initial.denyAdapters, edited.denyAdapters);
194
+ const modelsChanged = changed(initial.denyModels, edited.denyModels);
195
+ if (adaptersChanged || modelsChanged || changed(initial.allowOut, edited.allowOut)) {
189
196
  if (write.universe) {
190
197
  // Membership write: the allow form IS the fleet; deny adapters/models scopes are tombstoned
191
198
  // so a lower layer can never re-exclude behind the operator's back (workers untouched).
@@ -215,18 +222,44 @@ export function renderFleetOverlayWrite(priorBytes, write) {
215
222
  // Whole fleet in: no restriction to express — the allow block goes away entirely.
216
223
  deleteAt(doc, ["routing", "allow"]);
217
224
  }
218
- // Authored and non-canonical entries stay in deny (LEG2-T3 finding 4, round 2 finding 2); a
219
- // list left with nothing is tombstoned so a lower layer can never re-exclude behind the
220
- // operator's back (workers untouched).
221
- for (const [scope, after] of [["adapters", edited.denyAdapters], ["models", edited.denyModels]]) {
225
+ // Authored and non-canonical entries stay in deny (LEG2-T3 finding 4, round 2 finding 2).
226
+ // OBS-1046: only an EDITED scope is rewritten, and only when its bytes must change — an
227
+ // addition the allow form carries leaves the list (an explicit `[]` included) untouched; a
228
+ // scope the press CLEARED down to nothing is tombstoned so a lower layer can never
229
+ // re-exclude behind the operator's back (workers untouched).
230
+ // LEG2-T3 finding 4: an authored entry the edit admits (staged nowhere any more) must go
231
+ // even from a scope whose own set did not change, or the admitted channel stays excluded
232
+ // behind the preserved bytes.
233
+ const stagedAfter = new Set([...edited.denyAdapters, ...edited.denyModels, ...(edited.allowOut ?? [])]);
234
+ const admitted = [...initial.denyAdapters, ...initial.denyModels, ...(initial.allowOut ?? [])]
235
+ .filter((entry) => !stagedAfter.has(entry));
236
+ const scopes = [
237
+ ["adapters", initial.denyAdapters, edited.denyAdapters, adaptersChanged],
238
+ ["models", initial.denyModels, edited.denyModels, modelsChanged],
239
+ ];
240
+ for (const [scope, before, after, touched] of scopes) {
222
241
  const path = ["routing", "deny", scope];
242
+ const authored = authoredEntries(doc, path);
243
+ const stale = authored.some((entry) => admitted.includes(entry));
244
+ if (!touched && !stale)
245
+ continue;
223
246
  const remaining = flatDenyAfterWrite(doc, path, after, write.universe);
224
- setStringSequencePreservingComments(doc, path, remaining.length ? remaining : null);
247
+ if (remaining.length) {
248
+ if (remaining.join("\n") !== authored.join("\n"))
249
+ setStringSequencePreservingComments(doc, path, remaining);
250
+ }
251
+ else if (stale || before.some((entry) => !after.includes(entry))) {
252
+ setStringSequencePreservingComments(doc, path, null);
253
+ }
225
254
  }
226
255
  }
227
256
  else {
228
- setStringSequencePreservingComments(doc, ["routing", "deny", "adapters"], edited.denyAdapters.length ? sortedUnique(edited.denyAdapters) : null);
229
- setStringSequencePreservingComments(doc, ["routing", "deny", "models"], edited.denyModels.length ? sortedUnique(edited.denyModels) : null);
257
+ if (adaptersChanged) {
258
+ setStringSequencePreservingComments(doc, ["routing", "deny", "adapters"], edited.denyAdapters.length ? sortedUnique(edited.denyAdapters) : null);
259
+ }
260
+ if (modelsChanged) {
261
+ setStringSequencePreservingComments(doc, ["routing", "deny", "models"], edited.denyModels.length ? sortedUnique(edited.denyModels) : null);
262
+ }
230
263
  }
231
264
  }
232
265
  // OBS-994/FL-1: routing.deny.workers is a literal deny list, never a universe-derived
@@ -360,15 +393,19 @@ export function renderFleetOverlayWrite(priorBytes, write) {
360
393
  // operator reviews. commentString has no position context, but this writer owns the document:
361
394
  // scalar-trailing single-line comments (the only inline form fleet emits) are marked with a
362
395
  // private-use sentinel (the FIRST_TOUCH envelope precedent above), everything else renders
363
- // byte-identical to yaml's own stringifyComment.
396
+ // byte-identical to yaml's own stringifyComment. OBS-1046: a scalar parsed from the prior bytes
397
+ // keeps the exact whitespace it had before its hash sign (yaml itself emits one space, so the
398
+ // sentinel carries the rest); only a comment fleet authored gets the two-space style.
364
399
  visit(doc, (_key, node) => {
365
400
  if (isScalar(node) && typeof node.comment === "string" && !node.comment.includes("\n")) {
366
- node.comment = `${INLINE_COMMENT_SENTINEL}${node.comment}`;
401
+ const tail = node.range ? priorBytes.slice(node.range[1], node.range[2]) : "";
402
+ const gap = /^([ \t]+)#/.exec(tail)?.[1] ?? " ";
403
+ node.comment = `${INLINE_COMMENT_SENTINEL}${gap.slice(1)}#${node.comment}`;
367
404
  }
368
405
  });
369
406
  return doc.toString({
370
407
  commentString: (comment) => comment.startsWith(INLINE_COMMENT_SENTINEL)
371
- ? ` #${comment.slice(1)}`
408
+ ? comment.slice(1)
372
409
  : comment.replace(/^(?!$)(?: $)?/gm, "#"),
373
410
  // OBS-518: yaml's default pads flow collections (`[kimi]` → `[ kimi ]`), churning untouched
374
411
  // lines on the one confirmation surface an operator reviews. Hand-written overlays use the
@@ -384,7 +421,8 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, fi
384
421
  const routing = { ...out.routing };
385
422
  let routingTouched = false;
386
423
  const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
387
- || sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
424
+ || sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join()
425
+ || sortedUnique(initial.allowOut ?? []).join() !== sortedUnique(edited.allowOut ?? []).join();
388
426
  if (denyChanged) {
389
427
  if (universe) {
390
428
  const form = allowFormFromExclusions(universe, edited);
@@ -15,7 +15,8 @@ const hostByDriver = new WeakMap();
15
15
  export function classifyHost(env = process.env) {
16
16
  if (env.HERDR_ENV === "1")
17
17
  return "herdr";
18
- if (env.TERM_PROGRAM === "Orca" && env.ORCA_TERMINAL_HANDLE !== undefined && env.ORCA_TERMINAL_HANDLE !== "")
18
+ // A whitespace-only handle is no handle: the narrator trims it and would refuse the split.
19
+ if (env.TERM_PROGRAM === "Orca" && (env.ORCA_TERMINAL_HANDLE ?? "").trim() !== "")
19
20
  return "orca";
20
21
  return "none";
21
22
  }
@@ -124,19 +124,28 @@ export declare function mapAgentState(term: Record<string, unknown>, tuiIdle: bo
124
124
  * is proven gone. Everything else — a reservation or claim with no bound pane, a failed cleanup, a
125
125
  * record another driver holds with a live observer — refuses and keeps the record exactly as it is.
126
126
  */
127
+ /** `placer` is the pid of the narrator that wrote the reservation: a reserved or claimed record is a
128
+ * normal intermediate state while that pid lives (another driver awaiting listing or bind) and a
129
+ * crash to recover only once it is dead. */
127
130
  type BoardRecord = WatchBoardOwner & {
128
131
  retired?: true;
129
132
  runtimeId?: string;
133
+ placer?: number;
130
134
  };
131
135
  /**
132
136
  * Whole-record compare-and-swap. The canonical path stays readable until commit: a mkdir lock
133
137
  * excludes other writers, then create-only `link`s the new inode (fails if anything exists) or
134
138
  * `rename`s the new file over the live path (POSIX atomic replace — readers see old or new, never
135
139
  * absence). A crash that leaves a `.tmp` or `.lock` does not drop the previous record.
140
+ * Each lock generation publishes its pid and nonce atomically in a symlink target. A holder that died
141
+ * between taking and releasing it is recovered at once (its lock is taken over in place); a live
142
+ * holder bounds the wait on injected time and is then refused with the record untouched. A legacy
143
+ * pid-less or malformed generation is recoverable too: a contender atomically creates the next owner
144
+ * generation, and release removes the directory only while that exact generation is still current.
136
145
  * ponytail: observeNamedRun (supervision.ts) renames without CAS. It cannot interleave with a swap
137
146
  * because the narrator writes nothing between reserve and claim, and no transition here swaps one.
138
147
  */
139
- export declare function casBoard(family: string, path: string, expected: string | undefined, next: BoardRecord): string;
148
+ export declare function casBoard(family: string, path: string, expected: string | undefined, next: BoardRecord, time?: OrcaTimeSource): Promise<string>;
140
149
  /**
141
150
  * The renderer hard-wraps long lines, paints margin chrome, and a cursor page boundary splits a
142
151
  * marker exactly like a wrap does. `parseWorkerResult` (src/adapters/prompt.ts) already de-wraps
@@ -146,6 +155,8 @@ export declare function casBoard(family: string, path: string, expected: string
146
155
  * never depends on this.
147
156
  */
148
157
  export declare function joinWrapped(raw: string): string;
158
+ /** The OBS-1011 add.1 capture: `status:"exited", tail:[], returnedLineCount 0` from a stream read. */
159
+ export declare function isBlindStreamPage(term: Record<string, unknown>): boolean;
149
160
  export interface OrcaDriverOpts {
150
161
  bin?: string;
151
162
  env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
@@ -242,6 +253,15 @@ export declare class OrcaDriver implements ExecutorDriver {
242
253
  private liveShowTerm;
243
254
  private tailText;
244
255
  private readPage;
256
+ /**
257
+ * OBS-1011 add.1 / OBS-1016: the captured incident shape — a stream page answering status exited
258
+ * with an empty tail on a terminal that accepted a send seconds earlier — is BLIND, not dead, when
259
+ * the same handle's show record on the same runtime reports connected and not orphaned (show carries
260
+ * no status field; none is demanded) and its screen read reports running. Then the rendered frame is
261
+ * the terminal's bytes. Anything less — disconnected, orphaned, another handle or runtime, a screen
262
+ * that is unavailable or exited — is refused as unavailable, exactly as the dead record would be.
263
+ */
264
+ private screenBehindBlindStream;
245
265
  /** A rendered-frame liveness read. `--screen` and `--cursor` are mutually exclusive in Orca. */
246
266
  private readScreen;
247
267
  /** A single UNPAGED tail read — exactly what the caller asked for and nothing more. Markers split