tickmarkr 2.5.4 → 2.5.5

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/dist/adapters/registry.js +6 -1
  2. package/dist/adapters/types.d.ts +3 -0
  3. package/dist/cli/commands/approve.d.ts +1 -0
  4. package/dist/cli/commands/approve.js +54 -6
  5. package/dist/cli/commands/doctor.js +44 -29
  6. package/dist/cli/commands/fleet.js +195 -33
  7. package/dist/cli/commands/init.js +196 -6
  8. package/dist/cli/commands/resume.js +4 -2
  9. package/dist/cli/commands/run.js +11 -2
  10. package/dist/cli/commands/verify.js +1 -1
  11. package/dist/cli/help.d.ts +2 -0
  12. package/dist/cli/help.js +3 -1
  13. package/dist/config/config.d.ts +14 -8
  14. package/dist/config/config.js +29 -26
  15. package/dist/config/fleet-overlay.d.ts +3 -9
  16. package/dist/config/fleet-overlay.js +68 -11
  17. package/dist/config/fleet-why.d.ts +7 -0
  18. package/dist/config/fleet-why.js +5 -0
  19. package/dist/drivers/index.d.ts +15 -1
  20. package/dist/drivers/index.js +38 -10
  21. package/dist/drivers/orca.d.ts +99 -10
  22. package/dist/drivers/orca.js +586 -97
  23. package/dist/gates/baseline.d.ts +3 -0
  24. package/dist/gates/baseline.js +2 -1
  25. package/dist/gates/cache.d.ts +100 -0
  26. package/dist/gates/cache.js +389 -0
  27. package/dist/gates/run-gates.d.ts +3 -0
  28. package/dist/gates/run-gates.js +117 -10
  29. package/dist/gates/test-manifest.d.ts +99 -0
  30. package/dist/gates/test-manifest.js +389 -0
  31. package/dist/gates/test-reporter.d.ts +4 -0
  32. package/dist/gates/test-reporter.js +49 -0
  33. package/dist/route/preference.d.ts +22 -1
  34. package/dist/route/preference.js +123 -25
  35. package/dist/route/router.js +31 -6
  36. package/dist/run/daemon.d.ts +13 -0
  37. package/dist/run/daemon.js +160 -70
  38. package/dist/run/git.d.ts +10 -1
  39. package/dist/run/git.js +43 -9
  40. package/dist/run/journal.d.ts +14 -2
  41. package/dist/run/journal.js +78 -10
  42. package/dist/run/lease.d.ts +14 -0
  43. package/dist/run/lease.js +87 -0
  44. package/dist/run/merge.d.ts +2 -0
  45. package/dist/run/merge.js +91 -3
  46. package/dist/run/operator-state.d.ts +11 -0
  47. package/dist/run/operator-state.js +17 -3
  48. package/dist/tui/cockpit/board.d.ts +96 -0
  49. package/dist/tui/cockpit/board.js +346 -0
  50. package/dist/tui/cockpit/decision-actions.js +2 -0
  51. package/dist/tui/cockpit/layout.d.ts +5 -1
  52. package/dist/tui/cockpit/layout.js +8 -3
  53. package/dist/tui/cockpit/live-runtime.js +71 -21
  54. package/dist/tui/cockpit/run-view.d.ts +7 -5
  55. package/dist/tui/cockpit/run-view.js +12 -11
  56. package/dist/tui/ink/fleet-app.d.ts +41 -27
  57. package/dist/tui/ink/fleet-app.js +204 -31
  58. package/package.json +1 -1
  59. package/skills/tickmarkr-overseer/SKILL.md +173 -113
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { join, relative } from "node:path";
2
+ import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { parseArgs } from "node:util";
@@ -9,10 +9,11 @@ import { allAdapters, formatDoctorAgeForInit, formatDoctorReport, initDoctorReus
9
9
  import { configTemplate, DEFAULT_CONFIG, globalConfigDir, loadConfig } from "../../config/config.js";
10
10
  import { LEGACY_PREFIX, specTemplate } from "../../compile/native.js";
11
11
  import { BANNER, kvRow, legend, rule, statusRow, title } from "../../brand.js";
12
- import { tickmarkrDir } from "../../graph/graph.js";
12
+ import { stateDirName, tickmarkrDir } from "../../graph/graph.js";
13
13
  import { orcaHostDetected } from "../../drivers/index.js";
14
+ import { WORKTREES_DIR } from "../../run/git.js";
14
15
  import { Journal } from "../../run/journal.js";
15
- import { runLockRunId, runStatusLine } from "../../run/lock.js";
16
+ import { runLockOwner, runLockRunId, runStatusLine } from "../../run/lock.js";
16
17
  import { doctor } from "./doctor.js";
17
18
  import { assembleFleetEditor } from "./fleet.js";
18
19
  const SCAFFOLD_SPEC = "tickmarkr.spec.md";
@@ -340,6 +341,191 @@ async function runInitWizard(cwd, input, output) {
340
341
  return null;
341
342
  return { overlay: result.overlay, installSkills: result.installSkills, installDocs: result.installDocs };
342
343
  }
344
+ function realpathSafe(p) {
345
+ try {
346
+ return realpathSync(p);
347
+ }
348
+ catch {
349
+ const parent = dirname(p);
350
+ if (parent === p)
351
+ return p;
352
+ return join(realpathSafe(parent), basename(p));
353
+ }
354
+ }
355
+ function isPathInside(parentDir, candidatePath) {
356
+ const resolvedParent = realpathSafe(resolve(parentDir));
357
+ const resolvedCandidate = realpathSafe(resolve(candidatePath));
358
+ const rel = relative(resolvedParent, resolvedCandidate);
359
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
360
+ }
361
+ /**
362
+ * The ONLY way the --remove path runs git: GIT_DIR, GIT_WORK_TREE and GIT_COMMON_DIR are stripped so the repository acted on
363
+ * is always cwd's own, and LC_ALL=C keeps stderr parseable. Throws execFileSync's error (status, stderr) unchanged.
364
+ */
365
+ function removeGit(cwd, args) {
366
+ const { GIT_DIR: _dir, GIT_WORK_TREE: _workTree, GIT_COMMON_DIR: _commonDir, ...env } = process.env;
367
+ return execFileSync("git", args, { cwd, env: { ...env, LC_ALL: "C" }, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
368
+ }
369
+ function isLinkedWorktree(cwd) {
370
+ try {
371
+ const gitDir = removeGit(cwd, ["rev-parse", "--git-dir"]).trim();
372
+ const gitCommonDir = removeGit(cwd, ["rev-parse", "--git-common-dir"]).trim();
373
+ return resolve(cwd, gitDir) !== resolve(cwd, gitCommonDir);
374
+ }
375
+ catch {
376
+ return false;
377
+ }
378
+ }
379
+ function checkLiveLock(cwd) {
380
+ const localOwner = runLockOwner(cwd);
381
+ if (localOwner?.live)
382
+ return localOwner;
383
+ try {
384
+ const gitDir = removeGit(cwd, ["rev-parse", "--git-dir"]).trim();
385
+ const gitCommonDir = removeGit(cwd, ["rev-parse", "--git-common-dir"]).trim();
386
+ if (resolve(cwd, gitDir) !== resolve(cwd, gitCommonDir)) {
387
+ const mainRepo = dirname(resolve(cwd, gitCommonDir));
388
+ const mainOwner = runLockOwner(mainRepo);
389
+ if (mainOwner?.live)
390
+ return mainOwner;
391
+ }
392
+ }
393
+ catch {
394
+ // not a git repo
395
+ }
396
+ return undefined;
397
+ }
398
+ /**
399
+ * Only git's discovery answer — exit 128, stderr `fatal: not a git repository (or any of the parent directories)` — means false.
400
+ * Every other failure (explicit bad GIT_DIR, invalid gitfile, permission, missing binary) refuses: unregistration could not be verified.
401
+ */
402
+ function isGitRepo(cwd) {
403
+ try {
404
+ removeGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
405
+ return true;
406
+ }
407
+ catch (err) {
408
+ const e = err;
409
+ const stderr = String(e.stderr ?? "").trim();
410
+ if (e.status === 128 && stderr.startsWith("fatal: not a git repository (or any of the parent directories)"))
411
+ return false;
412
+ throw new Error(`cannot remove: git repository probe failed: ${stderr || e.message || "unknown error"}`);
413
+ }
414
+ }
415
+ /** Runs git and throws `message` plus git's stderr on failure — a remove never proceeds past a failed step. */
416
+ function git(cwd, args, message) {
417
+ try {
418
+ return removeGit(cwd, args);
419
+ }
420
+ catch (err) {
421
+ const stderr = String(err.stderr ?? "").trim();
422
+ throw new Error(stderr ? `${message}: ${stderr}` : message);
423
+ }
424
+ }
425
+ function registeredWorktrees(cwd, worktreesDir) {
426
+ const out = git(cwd, ["worktree", "list", "--porcelain"], "cannot remove: git worktree list failed");
427
+ return out
428
+ .split("\n")
429
+ .filter((line) => line.startsWith("worktree "))
430
+ .map((line) => line.slice("worktree ".length).trim())
431
+ .filter((wt) => isPathInside(worktreesDir, wt));
432
+ }
433
+ function handleRemove(cwd) {
434
+ const owner = checkLiveLock(cwd);
435
+ if (owner?.live) {
436
+ const holder = typeof owner.pid === "number" ? `live pid ${owner.pid}` : (owner.pid ?? "unreadable payload");
437
+ throw new Error(`cannot remove: graph.lock held by ${holder}`);
438
+ }
439
+ if (isLinkedWorktree(cwd)) {
440
+ throw new Error("cannot remove from inside a linked worktree");
441
+ }
442
+ const notes = [];
443
+ const stateDir = join(cwd, stateDirName(cwd));
444
+ const worktreesDir = join(stateDir, WORKTREES_DIR);
445
+ // 1. Unregister every git worktree under stateDir/WORKTREES_DIR, and prove it, before deleting anything
446
+ if (isGitRepo(cwd)) {
447
+ for (const wt of registeredWorktrees(cwd, worktreesDir)) {
448
+ // double --force: git refuses a locked worktree (present or already deleted) with a single one
449
+ git(cwd, ["worktree", "remove", "--force", "--force", wt], `cannot remove: failed to unregister worktree ${wt}`);
450
+ }
451
+ git(cwd, ["worktree", "prune"], "cannot remove: git worktree prune failed");
452
+ const left = registeredWorktrees(cwd, worktreesDir);
453
+ if (left.length > 0) {
454
+ throw new Error(`cannot remove: worktrees still registered: ${left.join(", ")}`);
455
+ }
456
+ }
457
+ // 2. Delete .tickmarkr state directory
458
+ if (existsSync(stateDir)) {
459
+ rmSync(stateDir, { recursive: true, force: true });
460
+ notes.push(`deleted state directory ${stateDir}`);
461
+ }
462
+ // 3. Delete shipped skill directories at every host location
463
+ const targetSkillDirs = [
464
+ join(cwd, ".agents", "skills"),
465
+ join(cwd, ".claude", "skills"),
466
+ ];
467
+ for (const dir of targetSkillDirs) {
468
+ for (const skill of AGENT_SKILLS) {
469
+ const skillPath = join(dir, skill);
470
+ if (existsSync(skillPath)) {
471
+ rmSync(skillPath, { recursive: true, force: true });
472
+ notes.push(`deleted skill ${skillPath}`);
473
+ }
474
+ }
475
+ }
476
+ // 4. Delete marker-bounded guidance block in AGENTS.md and CLAUDE.md leaving every byte outside identical
477
+ const marked = new RegExp(`<!-- (?:tickmarkr|${LEGACY_PREFIX}):agent-docs begin -->[\\s\\S]*?`
478
+ + `<!-- (?:tickmarkr|${LEGACY_PREFIX}):agent-docs end -->`);
479
+ for (const doc of ["AGENTS.md", "CLAUDE.md"]) {
480
+ const docPath = join(cwd, doc);
481
+ if (existsSync(docPath)) {
482
+ const current = readFileSync(docPath, "utf8");
483
+ const m = current.match(marked);
484
+ if (m && m.index !== undefined) {
485
+ const before = current.slice(0, m.index);
486
+ const after = current.slice(m.index + m[0].length);
487
+ writeFileSync(docPath, before + after);
488
+ notes.push(`removed guidance block from ${docPath}`);
489
+ }
490
+ }
491
+ }
492
+ // 5. Delete scaffold spec only while it still equals the shipped template
493
+ const specPath = join(cwd, SCAFFOLD_SPEC);
494
+ if (existsSync(specPath)) {
495
+ const currentSpec = readFileSync(specPath, "utf8");
496
+ if (currentSpec === specTemplate()) {
497
+ rmSync(specPath, { force: true });
498
+ notes.push(`deleted scaffold spec ${specPath}`);
499
+ }
500
+ else {
501
+ notes.push(`kept edited spec ${specPath}`);
502
+ }
503
+ }
504
+ // 6. Query and print remaining branches and preserved refs
505
+ let branches = [];
506
+ try {
507
+ const out = removeGit(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads/tickmarkr/"]);
508
+ branches = out.split("\n").map((b) => b.trim()).filter(Boolean);
509
+ }
510
+ catch {
511
+ // ignore
512
+ }
513
+ let preservedRefs = [];
514
+ try {
515
+ const out = removeGit(cwd, ["for-each-ref", "--format=%(refname)", "refs/tickmarkr/preserved/"]);
516
+ preservedRefs = out.split("\n").map((r) => r.trim()).filter(Boolean);
517
+ }
518
+ catch {
519
+ // ignore
520
+ }
521
+ for (const b of branches) {
522
+ notes.push(`left branch: ${b}`);
523
+ }
524
+ for (const r of preservedRefs) {
525
+ notes.push(`left ref: ${r}`);
526
+ }
527
+ return notes.join("\n");
528
+ }
343
529
  export async function init(argv, cwd = process.cwd(), io = {}) {
344
530
  let bannerEmitted = false;
345
531
  const emitBanner = () => {
@@ -348,6 +534,8 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
348
534
  process.stdout.write(BANNER);
349
535
  }
350
536
  };
537
+ // banner at START on the visual surface — every init path, not just the wizard, and never trailing the probe (operator report 2026-07-17)
538
+ emitBanner();
351
539
  const { values } = parseArgs({
352
540
  args: argv,
353
541
  options: {
@@ -357,13 +545,15 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
357
545
  docs: { type: "boolean" },
358
546
  fresh: { type: "boolean" },
359
547
  yes: { type: "boolean" },
548
+ remove: { type: "boolean" },
360
549
  },
361
550
  });
362
- // banner at START on the visual surface — every init path, not just the wizard, and never trailing the probe (operator report 2026-07-17)
363
- emitBanner();
364
551
  if (packageName(cwd) === "tickmarkr") {
365
552
  throw new Error("OBS-584: tickmarkr init refuses to run inside the tickmarkr source repository");
366
553
  }
554
+ if (values.remove) {
555
+ return handleRemove(cwd);
556
+ }
367
557
  const gdir = values["global-dir"] ?? globalConfigDir();
368
558
  mkdirSync(gdir, { recursive: true });
369
559
  const notes = [];
@@ -1,6 +1,6 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { loadConfig } from "../../config/config.js";
3
- import { parseDriverOverride, pickDriver } from "../../drivers/index.js";
3
+ import { classifyHost, parseDriverOverride, pickDriver, preflightHostDriver } from "../../drivers/index.js";
4
4
  import { loadGraph } from "../../graph/graph.js";
5
5
  import { formatSummary, runDaemon } from "../../run/daemon.js";
6
6
  import { denyPreferCollisionLine, denyPreferCollisions } from "../../route/preference.js";
@@ -38,6 +38,8 @@ export async function resume(argv, cwd = process.cwd()) {
38
38
  throw new Error(collisions.map(denyPreferCollisionLine).join("; "));
39
39
  }
40
40
  await assertRefsWritable(cwd, "resume");
41
+ const host = classifyHost();
42
+ preflightHostDriver(cfg, driverOverride, host);
41
43
  const narrate = narrationSink(runId);
42
44
  const s = await runDaemon(cwd, {
43
45
  runId,
@@ -45,7 +47,7 @@ export async function resume(argv, cwd = process.cwd()) {
45
47
  graphChanged,
46
48
  retryFailed,
47
49
  // bound to the same sink the daemon gets, so a driver-journaled recovery reaches this rail too
48
- driver: bindNarration(pickDriver(cfg, driverOverride), narrate),
50
+ driver: bindNarration(pickDriver(cfg, driverOverride, host), narrate),
49
51
  // v1.99 T2: the ONE narration sink — the quiet rail on a TTY, the raw journal formatter on a
50
52
  // pipe. A resumed run meets the same surface a fresh one does; printing the raw formatter here
51
53
  // would leave `resume` as the last place the old unfiltered dump survives. Bound to the run id
@@ -1,7 +1,7 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { allAdapters, discoverChannels, probeAll, readDoctor } from "../../adapters/registry.js";
3
3
  import { ROUTING_MODES } from "../../config/config.js";
4
- import { parseDriverOverride, pickDriver } from "../../drivers/index.js";
4
+ import { classifyHost, parseDriverOverride, pickDriver, preflightHostDriver } from "../../drivers/index.js";
5
5
  import { loadGraph, onDiskSpecHash, readCompileRefusal } from "../../graph/graph.js";
6
6
  import { formatSummary, resolveRunMode, runDaemon } from "../../run/daemon.js";
7
7
  import { isRunLockLive } from "../../run/lock.js";
@@ -106,6 +106,7 @@ export const RAIL_ROWS = {
106
106
  "gate-fingerprint-cap": { label: "repeat capped", tone: "attention" },
107
107
  // a scope red every collateral prediction already named: the daemon parks the task WITHOUT
108
108
  // charging an attempt, which is the most consequential unchargeable decision it makes
109
+ "scope-request": { label: "scope requested", tone: "attention" },
109
110
  "scope-authoring": { label: "authoring defect", tone: "attention" },
110
111
  "session-reset": { label: "session reset", tone: "attention" },
111
112
  "worker-mode-fallback": { label: "dispatch mode", tone: "attention" },
@@ -131,6 +132,9 @@ export const RAIL_ROWS = {
131
132
  "phase-start": { label: "gate start", tone: "active" },
132
133
  "gate-result": { label: "gate", tone: "pass" },
133
134
  "baseline-wait": { label: "baseline wait", tone: "active" },
135
+ "suite-wait": { label: "suite wait", tone: "active" },
136
+ "suite-wait-ceiling": { label: "suite wait ceiling", tone: "attention" },
137
+ "worker-reaped-before-harvest": { label: "worker reaped", tone: "attention" },
134
138
  "suite-budget": { label: "suite budget", tone: "attention" },
135
139
  "gate-replayed": { label: "gate replayed", tone: "attention" },
136
140
  "gate-reused": { label: "gate reused", tone: "neutral" },
@@ -320,6 +324,7 @@ const RAIL_PROJECTION = {
320
324
  ? `to ${d.chosen}${typeof d.static === "string" ? ` over ${d.static}` : ""}`
321
325
  : undefined,
322
326
  // both rehash hashes: the ladder reaches `from` (null on an unbound journal) and never `to`
327
+ "scope-request": (d) => Array.isArray(d.paths) ? `files[]: ${d.paths.join(", ")}` : undefined,
323
328
  "graph-rehash": (d) => (typeof d.to === "string" ? `to ${d.to.slice(0, 12)}` : undefined),
324
329
  "repair-dispatch": (d) => typeof d.diffBytes === "number" ? `diff ${d.diffBytes}B${d.capped === true ? ", capped" : ""}` : undefined,
325
330
  "tip-moved": (d) => typeof d.gatedCommit === "string" && typeof d.branchTip === "string"
@@ -328,6 +333,8 @@ const RAIL_PROJECTION = {
328
333
  "trust-auto-answer": (d) => typeof d.adapter === "string" ? `${d.adapter}${typeof d.phase === "string" ? ` ${d.phase}` : ""}` : undefined,
329
334
  // SB-1: the census the ceiling released beside, and the budget the round ran under versus the one it
330
335
  // would have had on an empty census — none of `{count, occupancyCap, conservativeCap}` is on the ladder
336
+ "suite-wait": (d) => typeof d.count === "number" ? `waiting for ${d.count} command(s)` : undefined,
337
+ "suite-wait-ceiling": (d) => typeof d.count === "number" ? `${d.count} still live after ${d.waitedMs}ms` : undefined,
331
338
  "suite-budget": (d) => typeof d.count === "number" && typeof d.conservativeCap === "number" && typeof d.occupancyCap === "number"
332
339
  ? `beside ${d.count}, cap ${d.conservativeCap} not ${d.occupancyCap}`
333
340
  : undefined,
@@ -503,6 +510,8 @@ export async function run(argv, cwd = process.cwd()) {
503
510
  throw new Error(`--route-strict: routing lints present, refusing to dispatch:\n${lints.join("\n")}`);
504
511
  }
505
512
  await assertRefsWritable(cwd, "run");
513
+ const host = classifyHost();
514
+ preflightHostDriver(cfg, driverOverride, host);
506
515
  // The run id is minted HERE rather than inside the daemon, because the narration sink has to know
507
516
  // which run it is narrating before the first event arrives (the daemon's `narrate` callback is
508
517
  // handed an event and nothing else, and `run-start` carries no run id). `runDaemon` uses the id
@@ -512,7 +521,7 @@ export async function run(argv, cwd = process.cwd()) {
512
521
  const s = await runDaemon(cwd, {
513
522
  runId,
514
523
  concurrency: values.concurrency ? Number(values.concurrency) : undefined,
515
- driver: bindNarration(pickDriver(cfg, driverOverride), narrate),
524
+ driver: bindNarration(pickDriver(cfg, driverOverride, host), narrate),
516
525
  mode: flagMode,
517
526
  supersedes: values.supersedes,
518
527
  narrate,
@@ -306,7 +306,7 @@ export async function verify(argv, cwd = process.cwd()) {
306
306
  worktree: cwd, baseRef: mergeBase,
307
307
  result: { ok: true, summary: "standalone verify — no worker claims to trust", deviations: [], raw: "" },
308
308
  author, commands, baseline, channels, ...(judgeChannels ? { judgeChannels } : {}), adapters, cfg,
309
- pipeline: "v185", artifactDir,
309
+ pipeline: "v185", verificationScope: "standalone", artifactDir, stateDir: join(stateRoot, ".tickmarkr"),
310
310
  onGate: (e) => {
311
311
  if (e.phase === "start")
312
312
  console.error(`verify: → ${e.gate} (${e.index}/${e.total})`);
@@ -15,6 +15,7 @@ export declare const COMMAND_HELP: {
15
15
  "--docs": string;
16
16
  "--fresh": string;
17
17
  "--yes": string;
18
+ "--remove": string;
18
19
  };
19
20
  examples: string[];
20
21
  };
@@ -160,6 +161,7 @@ export declare const COMMAND_HELP: {
160
161
  usage: string;
161
162
  description: string;
162
163
  options: {
164
+ "--files <glob,\u2026>": string;
163
165
  "--by <name>": string;
164
166
  "--reason <text>": string;
165
167
  "--waive": string;
package/dist/cli/help.js CHANGED
@@ -10,7 +10,8 @@ export const COMMAND_HELP = {
10
10
  "--docs": "Include agent guidance documents with --agent.",
11
11
  "--fresh": "Force fresh adapter probes instead of reusing recent health.",
12
12
  "--yes": "Skip the interactive setup wizard and use defaults.",
13
- }, examples: ["init --yes", "init --agent --force --docs"],
13
+ "--remove": "Reverse init without scaffolding: delete state, skills, docs block, unedited spec, and prune worktrees.",
14
+ }, examples: ["init --yes", "init --agent --force --docs", "init --remove"],
14
15
  },
15
16
  doctor: {
16
17
  usage: "doctor [options]", description: "Probe configured adapters and models and print diagnostics. Default probing may make model calls and refresh a stale catalog.",
@@ -128,6 +129,7 @@ export const COMMAND_HELP = {
128
129
  approve: {
129
130
  usage: "approve <run-id> <task-id> [options]", description: "Append a validated park decision. A running owner may enact it; a closed run requires a separate resume. Decisions cannot be undone.",
130
131
  options: {
132
+ "--files <glob,…>": "Extend files[] for a scope-request park with comma-separated repository-relative globs; required for scope approval.",
131
133
  "--by <name>": "Name the actor (default: current OS user).",
132
134
  "--reason <text>": "Record the decision reason.",
133
135
  "--waive": "Waive only the identified failed gate.",
@@ -364,24 +364,30 @@ export type FleetTierAssignment = {
364
364
  export type FleetEditable = {
365
365
  denyAdapters: string[];
366
366
  denyModels: string[];
367
+ denyWorkersAdapters?: string[];
368
+ denyWorkersModels?: string[];
367
369
  tiers: Record<string, Record<string, FleetTierAssignment | null>>;
368
370
  map: Record<string, MapEntry>;
369
371
  floors: Record<string, Tier>;
370
372
  };
371
373
  export declare function repoOverlayPath(repoRoot: string): string;
372
374
  export declare function readOverlayFile(path: string): Record<string, unknown>;
375
+ /** A discovered fleet universe row: the served models of one adapter and, per model, the resolved
376
+ * identity doctor recorded for a floating alias (LEG2-T3 round 2 finding 3). */
377
+ export type FleetUniverseRow = {
378
+ adapter: string;
379
+ models: string[];
380
+ identities?: Record<string, string>;
381
+ };
382
+ /** Does a deny/allow entry name this universe channel? Grammar A1 (adapter id, bare model id,
383
+ * adapter:model) plus the recorded identity (bare or adapter:identity) — the collector's authority. */
384
+ export declare function universeEntryMatches(row: FleetUniverseRow, model: string, entry: string): boolean;
373
385
  /** Grammar A1 membership test: does any discovered universe row cover this deny/allow entry
374
386
  * (adapter id, bare model id, or adapter:model)? Uncovered entries name channels the probe
375
387
  * did not serve this session (failed auth, rate limit, retired sku) — they are NOT editable
376
388
  * membership state and must survive a membership write verbatim, never be re-derived away. */
377
- export declare function universeCovers(universe: {
378
- adapter: string;
379
- models: string[];
380
- }[], entry: string): boolean;
381
- export declare function fleetEditableFromConfig(cfg: TickmarkrConfig, universe?: {
382
- adapter: string;
383
- models: string[];
384
- }[]): FleetEditable;
389
+ export declare function universeCovers(universe: FleetUniverseRow[], entry: string): boolean;
390
+ export declare function fleetEditableFromConfig(cfg: TickmarkrConfig, universe?: FleetUniverseRow[]): FleetEditable;
385
391
  /** Resolve which layer last set a dotted fleet path (defaults < global < repo). */
386
392
  export declare function fleetKeyLayer(repoRoot: string, dotted: string, opts?: {
387
393
  globalDir?: string;
@@ -887,6 +887,14 @@ export function readOverlayFile(path) {
887
887
  throw new ConfigError(`invalid overlay at ${path}`);
888
888
  return raw;
889
889
  }
890
+ /** Does a deny/allow entry name this universe channel? Grammar A1 (adapter id, bare model id,
891
+ * adapter:model) plus the recorded identity (bare or adapter:identity) — the collector's authority. */
892
+ export function universeEntryMatches(row, model, entry) {
893
+ if (entry === row.adapter || entry === model || entry === `${row.adapter}:${model}`)
894
+ return true;
895
+ const identity = row.identities?.[model];
896
+ return identity !== undefined && (entry === identity || entry === `${row.adapter}:${identity}`);
897
+ }
890
898
  /** Grammar A1 membership test: does any discovered universe row cover this deny/allow entry
891
899
  * (adapter id, bare model id, or adapter:model)? Uncovered entries name channels the probe
892
900
  * did not serve this session (failed auth, rate limit, retired sku) — they are NOT editable
@@ -895,9 +903,11 @@ export function universeCovers(universe, entry) {
895
903
  return universe.some((row) => entry === row.adapter || row.models.some((m) => entry === m || entry === `${row.adapter}:${m}`));
896
904
  }
897
905
  // v1.92 fleet membership: with a discovered `universe` (classified models only) the deny sets become
898
- // the EFFECTIVE out-of-fleet complement — anything routing.allow does not admit (fail-closed) UNION
899
- // routing.deny adapters/models scopes (never workers). Absent universe ⇒ deny arrays verbatim, so
900
- // non-UI callers stay byte-identical.
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.
901
911
  export function fleetEditableFromConfig(cfg, universe) {
902
912
  const tiers = {};
903
913
  for (const [adapter, entry] of Object.entries(cfg.tiers)) {
@@ -909,43 +919,32 @@ export function fleetEditableFromConfig(cfg, universe) {
909
919
  let denyAdapters = [...(cfg.routing.deny?.adapters ?? [])].sort();
910
920
  let denyModels = [...(cfg.routing.deny?.models ?? [])].sort();
911
921
  if (universe !== undefined) {
912
- const { allow, deny } = cfg.routing;
913
- // Grammar A1 (disallowedBy precedent): entries match adapter id, bare model id, or adapter:model.
914
- // Inlined rather than imported — route/preference.ts imports this module.
915
- const denyEntries = [...(deny?.adapters ?? []), ...(deny?.models ?? [])];
922
+ const { allow } = cfg.routing;
916
923
  const allowEntries = allow ? [...(allow.adapters ?? []), ...(allow.models ?? [])] : null;
917
- const outOfFleet = (adapter, model) => {
918
- const matches = (e) => e === adapter || e === model || e === `${adapter}:${model}`;
919
- if (denyEntries.some(matches))
920
- return true;
921
- return allowEntries !== null && !allowEntries.some(matches);
922
- };
924
+ const notAdmitted = (row, model) => allowEntries !== null && !allowEntries.some((entry) => universeEntryMatches(row, model, entry));
923
925
  const adaptersOut = [];
924
926
  const modelsOut = [];
925
927
  for (const row of universe) {
926
- const out = row.models.filter((m) => outOfFleet(row.adapter, m));
927
- if (row.models.length ? out.length === row.models.length : outOfFleet(row.adapter, "")) {
928
+ const out = row.models.filter((m) => notAdmitted(row, m));
929
+ if (row.models.length ? out.length === row.models.length : notAdmitted(row, "")) {
928
930
  adaptersOut.push(row.adapter);
929
931
  }
930
932
  else {
931
933
  modelsOut.push(...out.map((m) => `${row.adapter}:${m}`));
932
934
  }
933
935
  }
934
- // OBS-517: deny entries the probe universe does not cover (failed/rate-limited probes drop the
935
- // channel from discoverChannels) are preserved VERBATIM — deriving membership purely from the
936
- // universe silently un-denied them on the next write (fail-open on the operator's own ban list).
937
- denyAdapters = [...new Set([
938
- ...adaptersOut,
939
- ...(deny?.adapters ?? []).filter((e) => !universeCovers(universe, e)),
940
- ])].sort();
941
- denyModels = [...new Set([
942
- ...modelsOut,
943
- ...(deny?.models ?? []).filter((e) => !universeCovers(universe, e)),
944
- ])].sort();
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
940
  }
946
941
  return {
947
942
  denyAdapters,
948
943
  denyModels,
944
+ // OBS-994: workers-only deny is never folded into the all-seats complement above — it is
945
+ // returned verbatim beside the flat scopes so the fleet browser can show and toggle it.
946
+ denyWorkersAdapters: [...(cfg.routing.deny?.workers?.adapters ?? [])].sort(),
947
+ denyWorkersModels: [...(cfg.routing.deny?.workers?.models ?? [])].sort(),
949
948
  tiers,
950
949
  map: structuredClone(cfg.routing.map),
951
950
  floors: { ...cfg.routing.floors },
@@ -988,6 +987,10 @@ export function formatFleetPrint(repoRoot, opts = {}) {
988
987
  annotate(["routing", "deny", "adapters"]);
989
988
  if (effective.routing.deny?.models !== undefined)
990
989
  annotate(["routing", "deny", "models"]);
990
+ if (effective.routing.deny?.workers?.adapters !== undefined)
991
+ annotate(["routing", "deny", "workers", "adapters"]);
992
+ if (effective.routing.deny?.workers?.models !== undefined)
993
+ annotate(["routing", "deny", "workers", "models"]);
991
994
  for (const shape of Object.keys(effective.routing.map))
992
995
  annotate(["routing", "map", shape]);
993
996
  annotate(["routing", "floors"]);
@@ -1,4 +1,4 @@
1
- import { type FleetEditable, type RoutingMode } from "./config.js";
1
+ import { type FleetEditable, type FleetUniverseRow, type RoutingMode } from "./config.js";
2
2
  /** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
3
3
  export declare const FLEET_OVERLAY_KEYS: readonly ["routing", "tiers"];
4
4
  export type FleetOverlayWrite = {
@@ -19,10 +19,7 @@ export type FleetOverlayWrite = {
19
19
  consult?: string[];
20
20
  };
21
21
  };
22
- universe?: {
23
- adapter: string;
24
- models: string[];
25
- }[];
22
+ universe?: FleetUniverseRow[];
26
23
  };
27
24
  export type FleetFirstTouch = {
28
25
  vendor: string;
@@ -33,10 +30,7 @@ export declare function fleetFirstTouchProvenance(note: string, firstTouch: Flee
33
30
  * keys, ordering, scalar style, and comments; fresh provenance is written directly on its tier node. */
34
31
  export declare function renderFleetOverlayWrite(priorBytes: string, write: FleetOverlayWrite): string;
35
32
  /** Build the repo overlay fragment fleet would write for edits since session start. */
36
- export declare function fleetRepoOverlayFromDelta(initial: FleetEditable, edited: FleetEditable, existingRepo?: Record<string, unknown>, firstTouches?: Readonly<Record<string, FleetFirstTouch>>, universe?: {
37
- adapter: string;
38
- models: string[];
39
- }[]): Record<string, unknown>;
33
+ export declare function fleetRepoOverlayFromDelta(initial: FleetEditable, edited: FleetEditable, existingRepo?: Record<string, unknown>, firstTouches?: Readonly<Record<string, FleetFirstTouch>>, universe?: FleetUniverseRow[]): Record<string, unknown>;
40
34
  export declare function repoOverlayYaml(overlay: Record<string, unknown>): string;
41
35
  export declare function serializeFleetOverlay(overlay: Record<string, unknown>): string;
42
36
  export declare function unifiedYamlDiff(before: string, after: string, label?: string): string;