tickmarkr 1.48.2 → 1.50.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.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  tickmarkr is a spec-driven orchestration harness for AI coding agent CLIs. You write a spec with
13
13
  acceptance criteria; the engine routes tasks to the best installed agent CLI (claude-code, codex,
14
- cursor-agent, opencode, grok, pi) by cost and capability, dispatches work in git worktrees for
14
+ cursor-agent, opencode, grok, pi, kimi) by cost and capability, dispatches work in git worktrees for
15
15
  change isolation — as interactive TUIs when running under [herdr](https://herdr.dev), headless
16
16
  subprocesses otherwise — and independently verifies each committed result by checking for no new
17
17
  baseline failures per task, then strictly verifying the integration tip. Green tasks consolidate onto a
@@ -51,8 +51,8 @@ prefer, every example below works with either.
51
51
  - **macOS or Linux.** Every command shells out through `bash`, so native Windows is not
52
52
  supported — use WSL (untested).
53
53
  - Node ≥ 20 and `git` on PATH.
54
- - At least one agent CLI on PATH (`claude`, `codex`, `cursor-agent`, `opencode`, `grok`, or
55
- `pi`), authenticated through its own login. tickmarkr never handles vendor API keys itself.
54
+ - At least one agent CLI on PATH (`claude`, `codex`, `cursor-agent`, `opencode`, `grok`, `pi`,
55
+ or `kimi`), authenticated through its own login. tickmarkr never handles vendor API keys itself.
56
56
 
57
57
  Then verify your fleet:
58
58
 
@@ -63,7 +63,11 @@ tickmarkr doctor # probes installed adapters, herdr, auth; prints the capabili
63
63
  **Expect `doctor` (and `init`, which runs it) to take a while on first run:** auth detection is
64
64
  one real, short LLM call per configured model per installed CLI — honest, but not instant. A
65
65
  machine with three CLIs and several models each can take a minute or more; each probe is capped
66
- at 30s.
66
+ at 60s.
67
+
68
+ `doctor` also sweeps a catalog of known agent CLIs that have no adapter yet and prints an
69
+ advisory `detected — no adapter; not routable` row for any found on PATH — a freshly installed
70
+ harness is visible the day it lands, and never routed until an adapter ships for it.
67
71
 
68
72
  ## Quickstart (5 minutes)
69
73
 
@@ -113,6 +117,7 @@ tickmarkr resume <runId> # continue an engagement from the local execution
113
117
  tickmarkr approve <runId> <taskId> # approve a parked task (--reason to document)
114
118
  tickmarkr report <runId> # cost/quality report
115
119
  tickmarkr profile # show the learned routing profile
120
+ tickmarkr profile --explain <shape> <channel> # why a channel ranks where it does for a shape
116
121
  ```
117
122
 
118
123
  Green tasks land on `tickmarkr/<runId>`; merge to your mainline is always your call.
@@ -136,6 +141,41 @@ away from a failing adapter will never retry it in subsequent `tickmarkr resume`
136
141
  - The task has burned its full attempt budget without reaching a conclusive result
137
142
  - Approving grants a fresh attempt budget, routing around all previously-failed channels and adapters
138
143
 
144
+ ## Choosing your fleet: `tickmarkr fleet`
145
+
146
+ `tickmarkr doctor` is a pure sensor; `tickmarkr fleet` is the actuator. It walks four screens —
147
+ fleet overview, membership (allow/deny), model tiers, and per-shape routing — and ends in a
148
+ unified diff of your config overlay that is written **only on explicit confirm**. Pressing Enter
149
+ through every screen writes nothing. Assigning a tier to a model that has no classification yet
150
+ requires a typed benchmark-provenance note, stored as a config comment beside the assignment.
151
+
152
+ ```bash
153
+ tickmarkr fleet # interactive editor (requires a TTY)
154
+ tickmarkr fleet --print # effective fleet state (repo > global > defaults), non-interactive — good for CI drift checks
155
+ ```
156
+
157
+ Routing obeys a strict precedence — **pin > prefer > floors > marginal-cost auto**:
158
+
159
+ - **pin** a shape to an exact channel: `map: { plan: { via: claude-code, model: fable } }`
160
+ - **prefer**-rank adapters per shape: `map: { implement: { prefer: [cursor-agent, codex] } }`
161
+ - **floors** set the minimum capability band per shape (`migration: frontier`, `tests: cheap`);
162
+ auto-routing then picks the cheapest authed channel at or above the floor
163
+ - **deny/allow** bench models or whole adapters without touching tiers (see `deny.models` below)
164
+ - **tiers** classify models into bands — only classified, doctor-authed models ever route
165
+
166
+ More levers, all optional and absent-safe (absent config keeps default behavior):
167
+
168
+ - `routing.explore` — fence the exploration budget: `mode: off`, `excludeShapes`,
169
+ `excludeComplexityAtOrAbove`, and a per-channel `cap`; `run --no-explore` disables
170
+ exploration probes for a single run
171
+ - `tiers.<adapter>.windows` — declare context-window sizes per model; doctor grows a window
172
+ column and `plan` warns (advisory, never blocking) when a task's payload estimate exceeds
173
+ the routed model's window
174
+ - `routing.sla` — per-shape latency expectations, surfaced as advisory plan lints against the
175
+ learned performance profile
176
+ - `run --quality` — raise every capability floor one band for that run (it never lowers one),
177
+ disable exploration, and record the original and raised floor in routing provenance
178
+
139
179
  ## Model scoping and auth detection
140
180
 
141
181
  Each agent CLI exposes a list of available models. tickmarkr's routing works only with **classified** models — those you explicitly enter into the config under `tiers`. The `tickmarkr doctor` command probes these models to detect auth status and records the results, which routing consumes to avoid 401/403 dispatch failures.
package/dist/brand.d.ts CHANGED
@@ -1,9 +1,71 @@
1
+ import type { OwnedName } from "./drivers/types.js";
1
2
  export declare const BANNER: string;
2
3
  /** ANSI-stripped, trailing-space-trimmed twin of BANNER — README hero and other plain surfaces. */
3
4
  export declare const PLAIN_BANNER: string;
5
+ export declare const PANE_IDENTITY_ENV = "TICKMARKR_PANE_IDENTITY";
6
+ /** One-line pane identity through the T1 ownership contract: role · task · attempt · run. */
7
+ export declare function paneIdentityLine(o: OwnedName): string;
4
8
  export declare function bannerShell(): string;
5
9
  export declare const TICKMARKR_EXIT_TRAILER = "printf '\\nTICKMARKR_''EXIT:%s\\n' $?";
6
10
  /** OBS-50: visible-pane bootstrap script — banner + agent command + byte-identical exit trailer. */
7
11
  export declare function paneDispatchScript(body: string[]): string;
8
12
  /** OBS-50: one short herdr pane-run line; bootstrap lives in the script file beside the prompt. */
9
13
  export declare function paneDispatchCommand(scriptPath: string): string;
14
+ /** The settled brand green ramp (256-color), bright → deep — the BANNER hues. */
15
+ export declare const BRAND_RAMP: readonly [84, 78, 41, 35];
16
+ /** Brand green (ramp anchor 41) — the tickmark hue; also the ok/pass/authed verdict color. */
17
+ export declare const brand: (s: string) => string;
18
+ /** Ok verdicts (pass/authed/green) render in the brand green ramp — same hue as the tickmark. */
19
+ export declare const ok: (s: string) => string;
20
+ /** Fail verdicts (unauthed/red) — red, always paired with the ✗ shape. */
21
+ export declare const fail: (s: string) => string;
22
+ /** Attention (warn/lint) — amber, always paired with the ! shape. */
23
+ export declare const warn: (s: string) => string;
24
+ /** Chrome (legends, rules, parentheticals, inactive state) — dim. */
25
+ export declare const dim: (s: string) => string;
26
+ /** Emphasis (titles, selection, the product name) — bold. */
27
+ export declare const bold: (s: string) => string;
28
+ /** Every semantic color token, for sweeps: each is TTY-gated and NO_COLOR-aware. */
29
+ export declare const TOKENS: {
30
+ readonly brand: (s: string) => string;
31
+ readonly ok: (s: string) => string;
32
+ readonly fail: (s: string) => string;
33
+ readonly warn: (s: string) => string;
34
+ readonly dim: (s: string) => string;
35
+ readonly bold: (s: string) => string;
36
+ };
37
+ /**
38
+ * The glyph vocabulary — plain characters only; color layers on via tokens so
39
+ * shape survives NO_COLOR. Bracket toggles ([x]/[ ]) are forbidden on every surface.
40
+ */
41
+ export declare const GLYPHS: {
42
+ /** Cursor row pointer in list pickers. */
43
+ readonly pointer: "❯";
44
+ /** Active toggle: THE brand tickmark — always rendered via brand(), never brackets. */
45
+ readonly toggleActive: "✓";
46
+ /** Inactive toggle: dim circle — always rendered via dim(). */
47
+ readonly toggleInactive: "○";
48
+ /** Pass verdict. */
49
+ readonly pass: "✓";
50
+ /** Fail verdict. */
51
+ readonly fail: "✗";
52
+ /** Attention/warn verdict. */
53
+ readonly attention: "!";
54
+ /** Neutral/skip — the dash. */
55
+ readonly neutral: "-";
56
+ };
57
+ /** The active toggle as rendered: brand green tickmark on a TTY, plain ✓ otherwise. */
58
+ export declare const toggleActive: () => string;
59
+ /** The inactive toggle as rendered: dim circle on a TTY, plain ○ otherwise. */
60
+ export declare const toggleInactive: () => string;
61
+ /** Dominant title line — one glance answers "what am I looking at". Bold on a TTY. */
62
+ export declare const title: (text: string) => string;
63
+ /** Single dim legend line under a title (key hints) — never scattered, never a paragraph. */
64
+ export declare const legend: (text: string) => string;
65
+ /** Horizontal rule sized to the terminal (capped at 100 cols) — dim chrome. */
66
+ export declare const rule: (width?: number) => string;
67
+ /** Aligned key-value row: dim key, plain value. Padding applied BEFORE styling so columns hold. */
68
+ export declare const kvRow: (key: string, value: string, keyWidth?: number) => string;
69
+ export type Verdict = "pass" | "fail" | "warn" | "neutral";
70
+ /** Status row: verdict glyph FIRST, then the label — glyph-first, distinct shape per verdict. */
71
+ export declare const statusRow: (verdict: Verdict, label: string) => string;
package/dist/brand.js CHANGED
@@ -12,11 +12,21 @@ export const BANNER = [
12
12
  ].join("\n");
13
13
  /** ANSI-stripped, trailing-space-trimmed twin of BANNER — README hero and other plain surfaces. */
14
14
  export const PLAIN_BANNER = BANNER.replace(/\x1b\[[0-9;]*m/g, "").replace(/[ \t]+$/gm, "");
15
- // Shell one-liner that prints the banner inside a pane before a gate command runs. ESC bytes are
15
+ // T5: the pane identity every visible pane announces under the logo. HerdrDriver seeds this env var
16
+ // into each pane shell at slot() time (paneIdentityLine of the pane's T1 owned name); the banner's
17
+ // identity line reads it at pane runtime, so every role — worker/judge/review/consult — wears the
18
+ // same header without the dispatch call sites threading identity through the script.
19
+ export const PANE_IDENTITY_ENV = "TICKMARKR_PANE_IDENTITY";
20
+ /** One-line pane identity through the T1 ownership contract: role · task · attempt · run. */
21
+ export function paneIdentityLine(o) {
22
+ return `${o.role} · ${o.taskId} · attempt ${o.attempt}${o.runId ? ` · ${o.runId}` : ""}`;
23
+ }
24
+ // Shell one-liner that prints the banner inside a pane before a gate command runs, followed by ONE
25
+ // dim identity line (the seeded $TICKMARKR_PANE_IDENTITY, else a bare "tickmarkr"). ESC bytes are
16
26
  // carried as printf %b escapes (never raw control bytes in a command string crossing the herdr socket).
17
27
  export function bannerShell() {
18
28
  const printable = BANNER.replaceAll("\x1b", "\\033").replaceAll("\n", "\\n");
19
- return `printf '%b\\n' '${printable}'`;
29
+ return `printf '%b\\n' '${printable}' "\\033[2m\${${PANE_IDENTITY_ENV}:-tickmarkr}\\033[0m"`;
20
30
  }
21
31
  // OBS-50: quote-split exit marker — herdr echoes the typed command into the transcript that
22
32
  // waitOutput matches, so the literal must not appear unsplit in the dispatch line.
@@ -29,3 +39,65 @@ export function paneDispatchScript(body) {
29
39
  export function paneDispatchCommand(scriptPath) {
30
40
  return `bash ${shq(scriptPath)}`;
31
41
  }
42
+ // ── design system (v1.50) ── contract: docs/codebase/CLI-DESIGN.md ──────────
43
+ // Every cockpit surface styles through these tokens/glyphs/helpers. Styled only
44
+ // on a real TTY with NO_COLOR unset; otherwise output is the plain text itself
45
+ // (non-TTY surfaces stay byte-pinned and machine-consumable).
46
+ /** The settled brand green ramp (256-color), bright → deep — the BANNER hues. */
47
+ export const BRAND_RAMP = [84, 78, 41, 35];
48
+ const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
49
+ const sgr = (code) => (s) => visual() ? `\x1b[${code}m${s}${R}` : s;
50
+ /** Brand green (ramp anchor 41) — the tickmark hue; also the ok/pass/authed verdict color. */
51
+ export const brand = sgr(`38;5;${BRAND_RAMP[2]}`);
52
+ /** Ok verdicts (pass/authed/green) render in the brand green ramp — same hue as the tickmark. */
53
+ export const ok = brand;
54
+ /** Fail verdicts (unauthed/red) — red, always paired with the ✗ shape. */
55
+ export const fail = sgr("31");
56
+ /** Attention (warn/lint) — amber, always paired with the ! shape. */
57
+ export const warn = sgr("33");
58
+ /** Chrome (legends, rules, parentheticals, inactive state) — dim. */
59
+ export const dim = sgr("2");
60
+ /** Emphasis (titles, selection, the product name) — bold. */
61
+ export const bold = sgr("1");
62
+ /** Every semantic color token, for sweeps: each is TTY-gated and NO_COLOR-aware. */
63
+ export const TOKENS = { brand, ok, fail, warn, dim, bold };
64
+ /**
65
+ * The glyph vocabulary — plain characters only; color layers on via tokens so
66
+ * shape survives NO_COLOR. Bracket toggles ([x]/[ ]) are forbidden on every surface.
67
+ */
68
+ export const GLYPHS = {
69
+ /** Cursor row pointer in list pickers. */
70
+ pointer: "❯",
71
+ /** Active toggle: THE brand tickmark — always rendered via brand(), never brackets. */
72
+ toggleActive: "✓",
73
+ /** Inactive toggle: dim circle — always rendered via dim(). */
74
+ toggleInactive: "○",
75
+ /** Pass verdict. */
76
+ pass: "✓",
77
+ /** Fail verdict. */
78
+ fail: "✗",
79
+ /** Attention/warn verdict. */
80
+ attention: "!",
81
+ /** Neutral/skip — the dash. */
82
+ neutral: "-",
83
+ };
84
+ /** The active toggle as rendered: brand green tickmark on a TTY, plain ✓ otherwise. */
85
+ export const toggleActive = () => brand(GLYPHS.toggleActive);
86
+ /** The inactive toggle as rendered: dim circle on a TTY, plain ○ otherwise. */
87
+ export const toggleInactive = () => dim(GLYPHS.toggleInactive);
88
+ /** Dominant title line — one glance answers "what am I looking at". Bold on a TTY. */
89
+ export const title = (text) => bold(text);
90
+ /** Single dim legend line under a title (key hints) — never scattered, never a paragraph. */
91
+ export const legend = (text) => dim(text);
92
+ /** Horizontal rule sized to the terminal (capped at 100 cols) — dim chrome. */
93
+ export const rule = (width = Math.min(process.stdout.columns ?? 80, 100)) => dim("─".repeat(width));
94
+ /** Aligned key-value row: dim key, plain value. Padding applied BEFORE styling so columns hold. */
95
+ export const kvRow = (key, value, keyWidth = 14) => ` ${dim(key.padEnd(keyWidth))} ${value}`;
96
+ const VERDICT = {
97
+ pass: () => ok(GLYPHS.pass),
98
+ fail: () => fail(GLYPHS.fail),
99
+ warn: () => warn(GLYPHS.attention),
100
+ neutral: () => dim(GLYPHS.neutral),
101
+ };
102
+ /** Status row: verdict glyph FIRST, then the label — glyph-first, distinct shape per verdict. */
103
+ export const statusRow = (verdict, label) => `${VERDICT[verdict]()} ${label}`;
@@ -1,36 +1,15 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { allAdapters, detectCandidateClis, formatCandidateCliRow, probeAll, probeModels, readAutoPrefer, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
4
- import { BANNER } from "../../brand.js";
3
+ import { allAdapters, detectCandidateClis, probeAll, probeModels, readAutoPrefer, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
4
+ import { BANNER, dim, fail, kvRow, legend, ok, rule, statusRow, title } from "../../brand.js";
5
5
  import { tickmarkrDir, stateDirName } from "../../graph/graph.js";
6
6
  import { declaredModelWindow, hasWindowsConfig, modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
7
7
  import { DEFAULT_CONFIG, loadConfig, overlayPreferShapes } from "../../config/config.js";
8
8
  import { HerdrDriver } from "../../drivers/herdr.js";
9
9
  import { disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../../route/preference.js";
10
- // same gate + palette as status's audit-ledger frame: styled only on a real TTY, plain otherwise
11
10
  const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
12
- const color = (text, code) => `\x1b[${code}m${text}\x1b[0m`;
13
- // TTY-only line pass: color the verdict symbols, dim the chrome (section titles, parentheticals,
14
- // n/a rows). Padding is untouched — every rewrite swaps equal-width text or wraps it in ANSI.
15
- function stylize(out) {
16
- if (!visual())
17
- return out;
18
- const rule = color("─".repeat(Math.min(process.stdout.columns ?? 80, 100)), 2);
19
- return out.split("\n").map((line) => {
20
- if (line.startsWith("tickmarkr doctor"))
21
- return ` ${color("✓", 32)} ${color("tickmarkr", 1)} doctor ${color("· capability matrix", 2)}\n${rule}`;
22
- if (/^(workspace trust|model status|model drift)/.test(line))
23
- return color(line, 2);
24
- return line
25
- .replace(/^(\s+)✓ /, (_, s) => `${s}${color("✓", 32)} `)
26
- .replace(/^(\s+)✗ /, (_, s) => `${s}${color("✗", 31)} `)
27
- .replace(/^(\s+)! /, (_, s) => `${s}${color("!", 33)} `)
28
- .replace(/^(\s+)= (.*)$/, (_, s, rest) => `${s}${color(`= ${rest}`, 2)}`)
29
- .replace(/(\(auth assumed; verified at dispatch \(failover on auth\/quota errors\)\))/, (m) => color(m, 2))
30
- .replace(/\bauthed\b(?!:)/, color("authed", 32))
31
- .replace(/\bunauthed:/, color("unauthed:", 33));
32
- }).join("\n");
33
- }
11
+ const alignedStatusRow = (verdict, key, value) => ` ${statusRow(verdict, kvRow(key, value).slice(2))}`;
12
+ const attentionRow = (text) => ` ${statusRow("warn", text)}`;
34
13
  export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(), opts = {}) {
35
14
  const cfg = loadConfig(cwd);
36
15
  // banner at START — the logo greets the operator before the ~60s probe wait, never trailing it (operator report 2026-07-17)
@@ -63,48 +42,49 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
63
42
  const rows = adapters.map((a) => {
64
43
  const h = health[a.id];
65
44
  const state = !h.installed ? "not installed" : `${h.version ?? "installed"}${h.note ? ` (${h.note})` : ""}`;
66
- return ` ${h.installed ? "" : ""} ${a.id.padEnd(14)} ${state}`;
45
+ return alignedStatusRow(h.installed ? "pass" : "fail", a.id, state);
67
46
  });
68
47
  // v1.48 T1: advisory sweep for known agent CLIs with no adapter — never written to doctor.json health.
69
- rows.push(...detectCandidateClis().map(formatCandidateCliRow));
70
- rows.push(` ${HerdrDriver.available() ? "✓" : "✗"} herdr ${HerdrDriver.available() ? "driver available (HERDR_ENV=1)" : "not detected — subprocess driver will be used"}`);
48
+ rows.push(...detectCandidateClis().map(({ binary, version }) => alignedStatusRow("warn", binary, `detected: ${version ?? "version unknown"} (no tickmarkr adapter — not routable)`)));
49
+ const herdr = HerdrDriver.available();
50
+ rows.push(alignedStatusRow(herdr ? "pass" : "fail", "herdr", herdr ? "driver available (HERDR_ENV=1)" : "not detected — subprocess driver will be used"));
71
51
  // v1.22 T5: workspace-trust pre-flight — per installed adapter: trusted | seeded | action-required | n/a.
72
52
  // action-required names the exact one-time command (or dialog) the operator must run once.
73
- rows.push("workspace trust:");
53
+ rows.push(legend("workspace trust:"));
74
54
  for (const a of adapters) {
75
55
  if (!health[a.id]?.installed)
76
56
  continue;
77
57
  if (!a.trust) {
78
- rows.push(` = ${a.id.padEnd(14)} trust: n/a`);
58
+ rows.push(` ${dim("=")} ${kvRow(a.id, "trust: n/a").slice(2)}`);
79
59
  continue;
80
60
  }
81
61
  try {
82
62
  const v = a.trust(cwd);
83
63
  if (v.status === "trusted")
84
- rows.push(` ✓ ${a.id.padEnd(14)} trust: trusted`);
64
+ rows.push(alignedStatusRow("pass", a.id, "trust: trusted"));
85
65
  else if (v.status === "seeded")
86
- rows.push(` ✓ ${a.id.padEnd(14)} trust: seeded`);
66
+ rows.push(alignedStatusRow("pass", a.id, "trust: seeded"));
87
67
  else
88
- rows.push(` ! ${a.id.padEnd(14)} trust: action-required — run ONCE: ${v.command}`);
68
+ rows.push(alignedStatusRow("warn", a.id, `trust: action-required — run ONCE: ${v.command}`));
89
69
  }
90
70
  catch (e) {
91
71
  // fail closed on the trust line only — never abort the rest of doctor
92
- rows.push(` ! ${a.id.padEnd(14)} trust: action-required — run ONCE: (trust check failed: ${e instanceof Error ? e.message : String(e)})`);
72
+ rows.push(alignedStatusRow("warn", a.id, `trust: action-required — run ONCE: (trust check failed: ${e instanceof Error ? e.message : String(e)})`));
93
73
  }
94
74
  }
95
75
  for (const [role, sel] of [["judge", cfg.judge], ["consult", cfg.consult]]) {
96
76
  if (!health[sel.adapter]?.installed) {
97
- rows.push(` ! ${role} runs on ${sel.adapter}:${sel.model} — NOT installed; that gate will fail closed until you install it or remap cfg.${role}`);
77
+ rows.push(attentionRow(`${role} runs on ${sel.adapter}:${sel.model} — NOT installed; that gate will fail closed until you install it or remap cfg.${role}`));
98
78
  }
99
79
  }
100
- rows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual(), stateDir: stateDirName(cwd), overlayPreferShapes: overlayPreferShapes(cwd) }).map((l) => ` ! ${l}`));
80
+ rows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual(), stateDir: stateDirName(cwd), overlayPreferShapes: overlayPreferShapes(cwd) }).map(attentionRow));
101
81
  const excluded = excludedChannels(cfg, adapters, health);
102
82
  if (excluded.length)
103
- rows.push(` ! ${exclusionLine(excluded)}`);
83
+ rows.push(attentionRow(exclusionLine(excluded)));
104
84
  // HYG-07(a): doctor just probed fresh (probeAll above), so servability attribution is current by construction.
105
85
  const servable = servableExclusions(cfg, adapters, health);
106
86
  if (servable.length)
107
- rows.push(` ! ${servabilityLine(servable)}`);
87
+ rows.push(attentionRow(servabilityLine(servable)));
108
88
  // MODEL-05/06: print-only drift fragment; advisory, whole-line-commented additions, tickmarkr NEVER applies it.
109
89
  // TTY gets a one-line summary + the fragment as a file (the full dump drowned everything else,
110
90
  // v1.33.1 onboarding); machine/CI surface keeps the inline dump — layout is pinned by tests.
@@ -114,7 +94,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
114
94
  if (visual()) {
115
95
  const overlayPath = join(tickmarkrDir(cwd), "doctor-overlay.yaml");
116
96
  writeFileSync(overlayPath, frag);
117
- drift = `\nmodel drift: unclassified models detected — paste-ready overlay written to ${overlayPath} (advisory; tickmarkr never applies it)`;
97
+ drift = `\n ${statusRow("warn", `model drift: unclassified models detected — paste-ready overlay written to ${overlayPath} ${dim("(advisory; tickmarkr never applies it)")}`)}`;
118
98
  }
119
99
  else {
120
100
  drift = `\nmodel drift — paste-ready overlay (advisory; tickmarkr never applies):\n${frag}`;
@@ -137,20 +117,24 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
137
117
  if (!models.length && !unclassified.length)
138
118
  return [];
139
119
  const w = Math.max(8, ...models.map((m) => m.length));
140
- const rows = [` ${a.id}`];
120
+ const rows = [` ${dim(a.id)}`];
141
121
  for (const m of models) {
142
122
  const v = h.modelAuth?.[m];
143
- const auth = !v ? "unknown" : v.authed ? "authed" : `unauthed: ${trunc(v.reason ?? "probe failed", 40)} (${dateOf(v.probedAt)})`;
123
+ const auth = !v
124
+ ? dim("unknown")
125
+ : v.authed
126
+ ? ok("authed")
127
+ : `${fail("unauthed:")} ${trunc(v.reason ?? "probe failed", 40)} (${dateOf(v.probedAt)})`;
144
128
  const d = disallowedBy({ adapter: a.id, model: m }, cfg.routing);
145
129
  const denied = d?.by === "deny" ? d.entry : "—";
146
130
  const pref = preferRanks({ adapter: a.id, model: m }, cfg).map((p) => `${p.shape}#${p.rank}`).join(",") || "—";
147
131
  const windowCol = showWindows
148
132
  ? ` ${String(declaredModelWindow(cfg, a.id, m) ?? "—").padEnd(8)}`
149
133
  : "";
150
- rows.push(` ${m.padEnd(w)} ${classified[m].padEnd(8)}${windowCol} ${auth} denied=${denied} prefer=${pref}`);
134
+ rows.push(` ${m.padEnd(w)} ${classified[m].padEnd(8)}${windowCol} ${auth} ${dim("denied=")}${denied} ${dim("prefer=")}${pref}`);
151
135
  }
152
136
  if (unclassified.length)
153
- rows.push(` (${unclassified.length} more listed, unclassified)`);
137
+ rows.push(` ${dim(`(${unclassified.length} more listed, unclassified)`)}`);
154
138
  return rows;
155
139
  });
156
140
  const autoPrefer = readAutoPrefer(cwd);
@@ -162,11 +146,14 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
162
146
  const seed = DEFAULT_CONFIG.routing.map[shape]?.prefer ?? [];
163
147
  if (!auto.length && !seed.length)
164
148
  return []; // nothing derived, nothing seeded — an empty line is noise
165
- return [` prefer ${shape} (auto): ${auto.join(" > ")} — seed was [${seed.join(", ")}]`];
149
+ return [` ${dim(`prefer ${shape} (auto):`)} ${auto.join(" > ")} ${dim("— seed was")} [${seed.join(", ")}]`];
166
150
  })
167
151
  : [];
168
152
  const modelSummary = modelStatus.length || preferStatus.length
169
- ? `\nmodel status:\n${[...modelStatus, ...preferStatus].join("\n")}`
153
+ ? `\n${legend("model status:")}\n${[...modelStatus, ...preferStatus].join("\n")}`
170
154
  : "";
171
- return stylize(`tickmarkr doctor — capability matrix:\n${rows.join("\n")}${modelSummary}${drift}\nwrote ${stateDirName(cwd)}/doctor.json`);
155
+ const header = visual()
156
+ ? `${statusRow("pass", `${title("tickmarkr doctor")} ${legend("· capability matrix")}`)}\n${rule()}`
157
+ : "tickmarkr doctor — capability matrix:";
158
+ return `${header}\n${rows.join("\n")}${modelSummary}${drift}\nwrote ${stateDirName(cwd)}/doctor.json`;
172
159
  }
@@ -1,5 +1,19 @@
1
1
  import type { WorkerAdapter } from "../../adapters/types.js";
2
- export declare function fleet(argv: string[], cwd?: string, adapters?: WorkerAdapter[]): Promise<string | {
2
+ export type FleetInput = NodeJS.ReadableStream & {
3
+ isTTY?: boolean;
4
+ setRawMode?: (mode: boolean) => unknown;
5
+ pause: () => unknown;
6
+ resume: () => unknown;
7
+ };
8
+ export type FleetOutput = {
9
+ isTTY?: boolean;
10
+ write: (chunk: string) => unknown;
11
+ };
12
+ export type FleetIO = {
13
+ input?: FleetInput;
14
+ output?: FleetOutput;
15
+ };
16
+ export declare function fleet(argv: string[], cwd?: string, adapters?: WorkerAdapter[], io?: FleetIO): Promise<string | {
3
17
  out: string;
4
18
  code: number;
5
19
  }>;
@@ -1,14 +1,17 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
+ import { emitKeypressEvents } from "node:readline";
3
4
  import { createInterface } from "node:readline/promises";
4
5
  import { parseArgs } from "node:util";
5
6
  import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, readAutoPrefer } from "../../adapters/registry.js";
6
7
  import { fleetUnclassifiedModels } from "../../adapters/model-lints.js";
8
+ import { GLYPHS, bold, legend as legendText, toggleActive, toggleInactive } from "../../brand.js";
7
9
  import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, formatFleetPrint, globalConfigDir, loadConfig, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, unifiedYamlDiff, } from "../../config/config.js";
8
10
  import { SHAPES, TIERS } from "../../graph/schema.js";
9
11
  import { route } from "../../route/router.js";
10
12
  import { loadRoutingProfile } from "../../run/journal.js";
11
13
  const NON_TTY_MSG = "tickmarkr fleet: interactive fleet editor requires a TTY — use `tickmarkr fleet --print` for non-interactive output";
14
+ const QUIT = "fleet: quit without writing";
12
15
  const isDeniedAdapter = (id, editable) => editable.denyAdapters.includes(id);
13
16
  const isDeniedModel = (adapter, model, editable) => editable.denyModels.includes(`${adapter}:${model}`);
14
17
  function previewTask(shape) {
@@ -56,10 +59,88 @@ function proposedOverlayText(initial, editable, repoRoot) {
56
59
  const merged = fleetRepoOverlayFromDelta(initial, editable, readOverlayFile(repoOverlayPath(repoRoot)));
57
60
  return repoOverlayYaml(merged, provenanceMap(editable));
58
61
  }
59
- async function ask(rl, prompt) {
60
- return (await rl.question(prompt)).trim();
62
+ const isAbort = (k) => k.name === "escape" || k.name === "q" || (k.ctrl === true && k.name === "c");
63
+ const isNext = (k) => k.name === "return" || k.name === "enter";
64
+ const isToggle = (k) => k.name === "space";
65
+ function moveCursor(k, cursor, count) {
66
+ if (count === 0)
67
+ return cursor;
68
+ if (k.name === "down" || k.name === "j")
69
+ return Math.min(cursor + 1, count - 1);
70
+ if (k.name === "up" || k.name === "k")
71
+ return Math.max(cursor - 1, 0);
72
+ return cursor;
61
73
  }
62
- export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters()) {
74
+ // step title (bold) + one dim key legend + rows; exactly one pointer glyph on the cursor row
75
+ function listFrame(title, legend, rows, cursor) {
76
+ const lines = [bold(title), legendText(legend)];
77
+ rows.forEach((r, i) => lines.push(i === cursor ? `${GLYPHS.pointer} ${bold(r)}` : ` ${r}`));
78
+ return lines;
79
+ }
80
+ // Keypress terminal over the (injectable) input stream. Keypress events are decoded by
81
+ // node's own emitKeypressEvents ON THE STREAM — no readline interface is held in keypress
82
+ // mode, so the production path and the test seam are the same decoder (OBS-69).
83
+ function openTerm(input, output) {
84
+ emitKeypressEvents(input);
85
+ const queue = [];
86
+ let pending = null;
87
+ const onKeypress = (str, key) => {
88
+ const k = key && (key.name !== undefined || key.sequence !== undefined) ? key : { name: str, sequence: str };
89
+ if (pending) {
90
+ const fn = pending;
91
+ pending = null;
92
+ fn(k);
93
+ }
94
+ else
95
+ queue.push(k);
96
+ };
97
+ const setRaw = (on) => void input.setRawMode?.(on);
98
+ input.on("keypress", onKeypress);
99
+ setRaw(true);
100
+ input.resume();
101
+ let prevLines = 0;
102
+ return {
103
+ key() {
104
+ const buffered = queue.shift();
105
+ if (buffered)
106
+ return Promise.resolve(buffered);
107
+ return new Promise((resolve) => {
108
+ pending = resolve;
109
+ });
110
+ },
111
+ frame(lines) {
112
+ const erase = prevLines > 0 ? `\x1b[${prevLines}F\x1b[0J` : "";
113
+ output.write(`${erase}${lines.join("\n")}\n`);
114
+ prevLines = lines.length;
115
+ },
116
+ // typed entry leaves keypress mode: raw off, transient line interface, closed after the
117
+ // question; keys the line editor leaked into the decoder are dropped (type-ahead kept)
118
+ async askTyped(prompt) {
119
+ const typedFrom = queue.length;
120
+ setRaw(false);
121
+ const rl = createInterface({
122
+ input: input,
123
+ output: output,
124
+ });
125
+ try {
126
+ return (await rl.question(prompt)).trim();
127
+ }
128
+ finally {
129
+ rl.close();
130
+ setRaw(true);
131
+ queue.splice(typedFrom);
132
+ prevLines = 0;
133
+ }
134
+ },
135
+ // every exit path: raw mode off, zero keypress listeners, stream paused (OBS-70)
136
+ close() {
137
+ input.off("keypress", onKeypress);
138
+ setRaw(false);
139
+ input.pause();
140
+ },
141
+ };
142
+ }
143
+ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(), io = {}) {
63
144
  const { values } = parseArgs({
64
145
  args: argv,
65
146
  options: {
@@ -70,7 +151,9 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters())
70
151
  });
71
152
  const globalDir = values["global-dir"] ?? globalConfigDir();
72
153
  const print = values.print ?? false;
73
- const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
154
+ const input = io.input ?? process.stdin;
155
+ const output = io.output ?? process.stdout;
156
+ const interactive = input.isTTY === true && output.isTTY === true;
74
157
  if (print)
75
158
  return formatFleetPrint(cwd, { globalDir });
76
159
  if (!interactive)
@@ -87,153 +170,194 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters())
87
170
  const initial = fleetEditableFromConfig(cfg);
88
171
  const editable = structuredClone(initial);
89
172
  const health = cached;
90
- const rl = createInterface({ input: process.stdin, output: process.stdout });
173
+ const term = openTerm(input, output);
91
174
  try {
92
- // screen 0sensor freshness
175
+ // step 1/4probe data
93
176
  const ageLine = formatAge(doctorAgeMs(cwd));
94
- let step = await ask(rl, `tickmarkr fleet scope your fleet\n probe data: ${ageLine} (.tickmarkr/doctor.json)\n [Enter] use it [r] refresh via doctor [q] quit\n> `);
95
- if (/^q$/i.test(step))
96
- return "fleet: quit without writing";
97
- if (/^r$/i.test(step)) {
98
- return "fleet: run `tickmarkr doctor` to refresh probe data, then re-run `tickmarkr fleet` (doctor is the sensor; fleet never re-probes)";
177
+ term.frame(listFrame("step 1/4 · probe data", "enter continue · r refresh via doctor · esc/q quit", [`probe data: ${ageLine} (.tickmarkr/doctor.json)`], 0));
178
+ for (;;) {
179
+ const k = await term.key();
180
+ if (isAbort(k))
181
+ return QUIT;
182
+ if (k.name === "r") {
183
+ return "fleet: run `tickmarkr doctor` to refresh probe data, then re-run `tickmarkr fleet` (doctor is the sensor; fleet never re-probes)";
184
+ }
185
+ if (isNext(k))
186
+ break;
99
187
  }
100
- // screen 1adapter membership (deny adapters)
188
+ // step 2/4agent CLIs (space toggles adapter deny)
101
189
  const installed = adapters.filter((a) => health[a.id]?.installed);
102
- const adapterLines = installed.map((a, i) => {
190
+ const adapterRows = () => installed.map((a) => {
103
191
  const h = health[a.id];
104
192
  const on = !isDeniedAdapter(a.id, editable);
105
- const mark = on ? "x" : " ";
106
- const auth = h?.authed ? "authed" : "unauthed";
107
- return ` ${i + 1}. [${mark}] ${a.id} ${h?.version ?? "installed"} ${auth}`;
193
+ return `${on ? toggleActive() : toggleInactive()} ${a.id} ${h?.version ?? "installed"} ${h?.authed ? "authed" : "unauthed"}`;
108
194
  });
109
- step = await ask(rl, `screen 1/4 agent CLIs [number]=toggle [Enter]=next\n${adapterLines.join("\n")}\n> `);
110
- if (/^q$/i.test(step))
111
- return "fleet: quit without writing";
112
- if (step && step !== "") {
113
- const n = Number.parseInt(step, 10);
114
- if (Number.isInteger(n) && n >= 1 && n <= installed.length) {
115
- const id = installed[n - 1].id;
195
+ const listLegend = "↑↓/jk move · space toggle · enter next · esc/q quit";
196
+ let aCursor = 0;
197
+ term.frame(listFrame("step 2/4 · agent CLIs", listLegend, adapterRows(), aCursor));
198
+ for (;;) {
199
+ const k = await term.key();
200
+ if (isAbort(k))
201
+ return QUIT;
202
+ if (isNext(k))
203
+ break;
204
+ let changed = false;
205
+ const moved = moveCursor(k, aCursor, installed.length);
206
+ if (moved !== aCursor) {
207
+ aCursor = moved;
208
+ changed = true;
209
+ }
210
+ else if (isToggle(k) && installed.length > 0) {
211
+ const id = installed[aCursor].id;
116
212
  const idx = editable.denyAdapters.indexOf(id);
117
213
  if (idx === -1)
118
214
  editable.denyAdapters.push(id);
119
215
  else
120
216
  editable.denyAdapters.splice(idx, 1);
121
217
  editable.denyAdapters.sort();
218
+ changed = true;
122
219
  }
220
+ if (changed)
221
+ term.frame(listFrame("step 2/4 · agent CLIs", listLegend, adapterRows(), aCursor));
123
222
  }
124
- // screen 2 — models per enabled adapter
223
+ // step 3/4 — models per enabled adapter (space toggles deny, t assigns tier from the row)
125
224
  const enabledAdapters = installed.filter((a) => !isDeniedAdapter(a.id, editable));
126
225
  for (const a of enabledAdapters) {
127
- const classified = editable.tiers[a.id] ?? {};
128
- const models = Object.keys(classified);
129
- const unclassified = fleetUnclassifiedModels(cfg, health, adapters).filter((u) => u.adapter === a.id);
130
- const rows = [];
131
- let row = 1;
132
- const indexFor = new Map();
133
- for (const m of models) {
134
- const tier = classified[m];
135
- const denied = isDeniedModel(a.id, m, editable) ? "deny" : "allow";
136
- const mark = denied === "deny" ? " " : "x";
137
- rows.push(` ${row}. [${mark}] ${m} ${tier?.tier ?? "???"} ${denied === "deny" ? "denied" : "allowed"}`);
138
- indexFor.set(String(row), { kind: "classified", model: m });
139
- row++;
140
- }
141
- for (const u of unclassified) {
142
- rows.push(` ${row}. ( ) ${u.model} ??? unclassified${u.detectedAt ? ` (${u.detectedAt})` : ""}`);
143
- indexFor.set(String(row), { kind: "unclassified", model: u.model });
144
- row++;
145
- }
146
- step = await ask(rl, `screen 2/4 — models · ${a.id} [number]=toggle deny [t <n> <tier>]=assign tier [Enter]=next\n${rows.join("\n")}\n> `);
147
- if (/^q$/i.test(step))
148
- return "fleet: quit without writing";
149
- if (step.startsWith("t ")) {
150
- const [, numRaw, tierRaw] = step.split(/\s+/);
151
- const tier = tierRaw;
152
- if (!indexFor.has(numRaw) || !TIERS.includes(tier)) {
153
- return { out: "fleet: tier assignment requires a valid model number and tier (cheap|mid|frontier)", code: 1 };
226
+ const unclassifiedAll = fleetUnclassifiedModels(cfg, health, adapters).filter((u) => u.adapter === a.id);
227
+ const rowsData = () => [
228
+ ...Object.keys(editable.tiers[a.id] ?? {}).map((m) => ({ kind: "classified", model: m })),
229
+ ...unclassifiedAll
230
+ .filter((u) => !editable.tiers[a.id]?.[u.model])
231
+ .map((u) => ({ kind: "unclassified", model: u.model, detectedAt: u.detectedAt })),
232
+ ];
233
+ const renderRows = (rows) => rows.map((r) => {
234
+ if (r.kind === "unclassified") {
235
+ return `( ) ${r.model} ??? unclassified${r.detectedAt ? ` (${r.detectedAt})` : ""}`;
154
236
  }
155
- const target = indexFor.get(numRaw);
156
- if (target.kind !== "unclassified") {
157
- return { out: "fleet: tier reassignment on classified models is not supported in v1 — edit config directly", code: 1 };
237
+ const tier = editable.tiers[a.id]?.[r.model];
238
+ const denied = isDeniedModel(a.id, r.model, editable);
239
+ return `${denied ? toggleInactive() : toggleActive()} ${r.model} ${tier?.tier ?? "???"} ${denied ? "denied" : "allowed"}`;
240
+ });
241
+ const title = `step 3/4 · models · ${a.id}`;
242
+ const modelsLegend = "↑↓/jk move · space toggle · t tier · enter next · esc/q quit";
243
+ let mCursor = 0;
244
+ term.frame(listFrame(title, modelsLegend, renderRows(rowsData()), mCursor));
245
+ for (;;) {
246
+ const k = await term.key();
247
+ if (isAbort(k))
248
+ return QUIT;
249
+ if (isNext(k))
250
+ break;
251
+ const rows = rowsData();
252
+ let changed = false;
253
+ const moved = moveCursor(k, mCursor, rows.length);
254
+ if (moved !== mCursor) {
255
+ mCursor = moved;
256
+ changed = true;
158
257
  }
159
- const note = await ask(rl, "benchmark provenance note (required): ");
160
- if (!note.trim())
161
- return { out: "fleet: assigning a tier to an unclassified model requires a typed benchmark-provenance note", code: 1 };
162
- editable.tiers[a.id] ??= {};
163
- editable.tiers[a.id][target.model] = { tier, provenance: note.trim() };
164
- }
165
- else if (step && /^\d+$/.test(step)) {
166
- const target = indexFor.get(step);
167
- if (target?.kind === "classified") {
168
- const key = `${a.id}:${target.model}`;
258
+ else if (isToggle(k) && rows[mCursor]?.kind === "classified") {
259
+ const key = `${a.id}:${rows[mCursor].model}`;
169
260
  const idx = editable.denyModels.indexOf(key);
170
261
  if (idx === -1)
171
262
  editable.denyModels.push(key);
172
263
  else
173
264
  editable.denyModels.splice(idx, 1);
174
265
  editable.denyModels.sort();
266
+ changed = true;
175
267
  }
268
+ else if (k.name === "t" && rows[mCursor]) {
269
+ if (rows[mCursor].kind === "classified") {
270
+ return { out: "fleet: tier reassignment on classified models is not supported in v1 — edit config directly", code: 1 };
271
+ }
272
+ const tier = (await term.askTyped(`tier (${TIERS.join("|")})> `));
273
+ if (!TIERS.includes(tier))
274
+ return { out: `fleet: invalid tier ${tier}`, code: 1 };
275
+ const note = await term.askTyped("benchmark provenance note (required): ");
276
+ if (!note)
277
+ return { out: "fleet: assigning a tier to an unclassified model requires a typed benchmark-provenance note", code: 1 };
278
+ editable.tiers[a.id] ??= {};
279
+ editable.tiers[a.id][rows[mCursor].model] = { tier, provenance: note };
280
+ changed = true;
281
+ }
282
+ if (changed)
283
+ term.frame(listFrame(title, modelsLegend, renderRows(rowsData()), mCursor));
176
284
  }
177
285
  }
178
- // screen 3per-shape routing
286
+ // step 4/4 — shape routing (typed entry for pin/tier/prefer from the highlighted row)
179
287
  const channels = discoverChannels(cfg, adapters, health);
180
288
  const profile = loadRoutingProfile(cwd, cfg, { preview: true });
181
289
  const autoPrefer = readAutoPrefer(cwd);
182
290
  const overlayShapes = overlayPreferShapes(cwd, { globalDir });
183
- for (let si = 0; si < SHAPES.length; si++) {
184
- const shape = SHAPES[si];
185
- const entry = editable.map[shape] ?? {};
186
- let now = "";
291
+ const shapeRows = () => SHAPES.map((shape) => {
292
+ let now;
187
293
  try {
188
294
  const r = route(previewTask(shape), { ...cfg, routing: { ...cfg.routing, map: editable.map, floors: editable.floors } }, channels, profile);
189
- now = `${r.provenance}\n → ${r.assignment.adapter}:${r.assignment.model} (${r.assignment.channel}, ${r.assignment.tier})`;
295
+ now = `${r.assignment.adapter}:${r.assignment.model} (${r.assignment.channel}, ${r.assignment.tier})`;
190
296
  }
191
297
  catch (e) {
192
298
  now = e.message;
193
299
  }
194
- const autoNote = autoPrefer?.[shape] && !overlayShapes.has(shape)
195
- ? `\n auto-prefer active — explicit prefer overrides it`
196
- : "";
197
- step = await ask(rl, `screen 3/4 shape routing · ${shape} (${si + 1}/${SHAPES.length})\n now: ${now}\n [1] auto within tier [2] pin channel [3] edit tier [4] edit prefer [Enter]=keep${autoNote}\n> `);
198
- if (/^q$/i.test(step))
199
- return "fleet: quit without writing";
200
- if (!step)
201
- continue;
202
- if (step === "1") {
300
+ const auto = autoPrefer?.[shape] && !overlayShapes.has(shape) ? " (auto-prefer active)" : "";
301
+ return `${shape} → ${now}${auto}`;
302
+ });
303
+ const shapesTitle = "step 4/4 · shape routing";
304
+ const shapesLegend = "↑↓/jk move · a auto · p pin · t tier · f prefer · enter next · esc/q quit";
305
+ let sCursor = 0;
306
+ term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
307
+ for (;;) {
308
+ const k = await term.key();
309
+ if (isAbort(k))
310
+ return QUIT;
311
+ if (isNext(k))
312
+ break;
313
+ const shape = SHAPES[sCursor];
314
+ const entry = editable.map[shape] ?? {};
315
+ let changed = false;
316
+ const moved = moveCursor(k, sCursor, SHAPES.length);
317
+ if (moved !== sCursor) {
318
+ sCursor = moved;
319
+ changed = true;
320
+ }
321
+ else if (k.name === "a") {
203
322
  const next = { ...entry };
204
323
  delete next.pin;
205
324
  editable.map[shape] = next;
325
+ changed = true;
206
326
  }
207
- else if (step === "2") {
208
- const pin = await ask(rl, "pin adapter:model> ");
327
+ else if (k.name === "p") {
328
+ const pin = await term.askTyped("pin adapter:model> ");
209
329
  const i = pin.indexOf(":");
210
330
  if (i < 1)
211
331
  return { out: "fleet: pin must be adapter:model", code: 1 };
212
332
  editable.map[shape] = { pin: { via: pin.slice(0, i), model: pin.slice(i + 1) } };
333
+ changed = true;
213
334
  }
214
- else if (step === "3") {
215
- const tier = await ask(rl, `tier (${TIERS.join("|")})> `);
335
+ else if (k.name === "t") {
336
+ const tier = (await term.askTyped(`tier (${TIERS.join("|")})> `));
216
337
  if (!TIERS.includes(tier))
217
338
  return { out: `fleet: invalid tier ${tier}`, code: 1 };
218
339
  const next = { ...entry, tier };
219
340
  delete next.pin;
220
341
  editable.map[shape] = next;
342
+ changed = true;
221
343
  }
222
- else if (step === "4") {
223
- const pref = await ask(rl, "prefer (comma-separated adapters or adapter:model)> ");
224
- const prefer = pref.split(",").map((s) => s.trim()).filter(Boolean);
225
- editable.map[shape] = { ...entry, prefer };
344
+ else if (k.name === "f") {
345
+ const pref = await term.askTyped("prefer (comma-separated adapters or adapter:model)> ");
346
+ editable.map[shape] = { ...entry, prefer: pref.split(",").map((s) => s.trim()).filter(Boolean) };
347
+ changed = true;
226
348
  }
349
+ if (changed)
350
+ term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
227
351
  }
228
- // review — unified diff + confirm
352
+ // review — unified diff + typed confirm
229
353
  const before = currentRepoOverlayText(cwd);
230
354
  const after = proposedOverlayText(initial, editable, cwd);
231
355
  if (fleetEditableEquals(initial, editable) || before === after) {
232
356
  return "fleet: no overlay changes (empty diff)";
233
357
  }
234
358
  const diff = unifiedYamlDiff(before, after, repoOverlayPath(cwd));
235
- process.stdout.write(`\n${diff}\n`);
236
- const confirm = await ask(rl, "write overlay? [y/N] ");
359
+ output.write(`\n${diff}\n`);
360
+ const confirm = await term.askTyped("write overlay? [y/N] ");
237
361
  if (!/^y(?:es)?$/i.test(confirm))
238
362
  return "fleet: discarded overlay changes";
239
363
  const merged = fleetRepoOverlayFromDelta(initial, editable, readOverlayFile(repoOverlayPath(cwd)));
@@ -244,6 +368,6 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters())
244
368
  return `fleet: wrote ${path}`;
245
369
  }
246
370
  finally {
247
- rl.close();
371
+ term.close();
248
372
  }
249
373
  }
@@ -5,7 +5,7 @@ import { parseArgs } from "node:util";
5
5
  import { allAdapters, formatDoctorAgeForInit, formatDoctorReport, initDoctorReuse } from "../../adapters/registry.js";
6
6
  import { configTemplate, DEFAULT_CONFIG, globalConfigDir, loadConfig } from "../../config/config.js";
7
7
  import { LEGACY_PREFIX, specTemplate } from "../../compile/native.js";
8
- import { BANNER } from "../../brand.js";
8
+ import { BANNER, kvRow, legend, rule, statusRow, title } from "../../brand.js";
9
9
  import { tickmarkrDir } from "../../graph/graph.js";
10
10
  import { Journal } from "../../run/journal.js";
11
11
  import { doctor } from "./doctor.js";
@@ -264,5 +264,25 @@ export async function init(argv, cwd = process.cwd()) {
264
264
  }
265
265
  if (values.agent)
266
266
  await installAgentFiles(cwd, values.force ?? false, values.docs ?? false, notes);
267
- return `${notes.join("\n")}\n${doc}\n${nextSteps(cwd, SCAFFOLD_SPEC)}\n${ENVIRONMENTS_FOOTER}`;
267
+ const next = nextSteps(cwd, SCAFFOLD_SPEC);
268
+ if (!visual())
269
+ return `${notes.join("\n")}\n${doc}\n${next}\n${ENVIRONMENTS_FOOTER}`;
270
+ const noteRows = notes.map((note) => ` ${statusRow(/^(?:wrote|overwrote|appended)/.test(note) ? "pass" : note.startsWith("skipped") ? "warn" : "neutral", note)}`);
271
+ const footerRows = ENVIRONMENTS_FOOTER.split("\n").slice(1).map((line) => {
272
+ const separator = line.indexOf(" — ");
273
+ return kvRow(line.slice(2, separator), line.slice(separator + 1), 12);
274
+ });
275
+ return [
276
+ title("tickmarkr init"),
277
+ legend("· setup notes"),
278
+ rule(),
279
+ ...noteRows,
280
+ doc,
281
+ title("next steps"),
282
+ legend("· continue from the repository's current state"),
283
+ rule(),
284
+ kvRow("next", next.replace(/^next:\s*/, "")),
285
+ legend("environments:"),
286
+ ...footerRows,
287
+ ].join("\n");
268
288
  }
@@ -1,5 +1,6 @@
1
1
  import { allAdapters, discoverChannels, doctorAgeMs, modelAuthExclusions, probeAll, readDoctor, servableExclusions, servabilityLine } from "../../adapters/registry.js";
2
2
  import { formatModelAuthLine, contextWindowLints, modelLints, ttyVisual } from "../../adapters/model-lints.js";
3
+ import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
3
4
  import { collateralLints } from "../../compile/collateral.js";
4
5
  import { loadConfig } from "../../config/config.js";
5
6
  import { loadGraph } from "../../graph/graph.js";
@@ -7,6 +8,22 @@ import { excludedChannels, exclusionLine } from "../../route/preference.js";
7
8
  import { route, RoutingError } from "../../route/router.js";
8
9
  import { modelId } from "../../gates/review.js";
9
10
  import { loadRoutingProfile } from "../../run/journal.js";
11
+ // T4 (v1.50): TTY-only brand pass — the title helper frames the routing table, lint/unroutable
12
+ // markers carry the attention glyph, section labels dim to chrome (the doctor/status system).
13
+ // Gated on ttyVisual(): the non-TTY surface returns untouched (byte-pinned, machine-consumable).
14
+ const stylizePlan = (out) => {
15
+ if (!ttyVisual())
16
+ return out;
17
+ return out.split("\n").map((line, i) => {
18
+ if (i === 0)
19
+ return `${title(line)}\n${rule()}`;
20
+ if (/^(routing|context window|scope) lints:$/.test(line))
21
+ return dim(line);
22
+ return line
23
+ .replace(/^(\s+)! /, (_, s) => `${s}${warn(GLYPHS.attention)} `)
24
+ .replace(/^( \S+\s+\S+\s+)!! /, (_, p) => `${p}${warn("!!")} `);
25
+ }).join("\n");
26
+ };
10
27
  const fleetCanCrossVendorReview = (channels) => {
11
28
  for (let i = 0; i < channels.length; i++)
12
29
  for (let j = 0; j < channels.length; j++)
@@ -114,5 +131,5 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
114
131
  const scopeLints = collateralLints(g.tasks, cwd);
115
132
  if (scopeLints.length)
116
133
  lines.push("", "scope lints:", ...scopeLints.map((l) => ` ! ${l}`));
117
- return lines.join("\n");
134
+ return stylizePlan(lines.join("\n"));
118
135
  }
@@ -1,5 +1,7 @@
1
1
  import { parseArgs } from "node:util";
2
+ import { ttyVisual } from "../../adapters/model-lints.js";
2
3
  import { addUsage } from "../../adapters/types.js";
4
+ import { dim, rule, title } from "../../brand.js";
3
5
  import { loadConfig } from "../../config/config.js";
4
6
  import { estimateCosts } from "../../report/cost.js";
5
7
  import { cellsOf, cellSummary } from "../../route/profile.js";
@@ -287,6 +289,16 @@ function textReport(runId, events, rows, cwd) {
287
289
  ` probes this run (route-deviation explore): ${probes}`,
288
290
  ].join("\n");
289
291
  }
292
+ // T4 (v1.50): TTY-only brand pass over the text report — title frame + dim section chrome; row
293
+ // text and alignment untouched (the doctor/status system). Gated on ttyVisual(): the non-TTY
294
+ // surface returns untouched, and --md never styles (the record is a document surface).
295
+ const stylizeReport = (out) => {
296
+ if (!ttyVisual())
297
+ return out;
298
+ return out
299
+ .replace(/^.*$/m, (first) => `${title(first)}\n${rule()}`) // non-global /m ⇒ first line only
300
+ .replace(/^(engagement summary — audit trail:|spend — tokens[^\n]*|spend — money:|learning \([^\n]*)$/gm, (l) => dim(l));
301
+ };
290
302
  export async function report(argv, cwd = process.cwd()) {
291
303
  const { values, positionals } = parseArgs({
292
304
  args: argv,
@@ -302,5 +314,5 @@ export async function report(argv, cwd = process.cwd()) {
302
314
  const rows = j.readTelemetry();
303
315
  return renderMarkdownRecord(runId, events, estimateCosts(rows, loadConfig(cwd).cost), rows);
304
316
  }
305
- return textReport(runId, events, j.readTelemetry(), cwd);
317
+ return stylizeReport(textReport(runId, events, j.readTelemetry(), cwd));
306
318
  }
@@ -1,3 +1,5 @@
1
+ import { type JournalEvent } from "../../run/journal.js";
2
+ export declare const narrationLine: (event: JournalEvent) => string;
1
3
  export declare function run(argv: string[], cwd?: string): Promise<{
2
4
  out: string;
3
5
  code: number;
@@ -6,6 +6,22 @@ import { loadGraph } from "../../graph/graph.js";
6
6
  import { formatSummary, runDaemon } from "../../run/daemon.js";
7
7
  import { route, QUALITY_ENV, NO_EXPLORE_ENV } from "../../route/router.js";
8
8
  import { formatJournalNarration, loadRoutingProfile } from "../../run/journal.js";
9
+ import { ttyVisual } from "../../adapters/model-lints.js";
10
+ import { statusRow } from "../../brand.js";
11
+ // T4 (v1.50): lifecycle verdict glyphs on the live narration stream — glyph-first, message text
12
+ // unchanged (the doctor/status visual system). TTY-gated: the piped narration surface stays
13
+ // byte-identical to formatJournalNarration.
14
+ const NARRATION_VERDICTS = {
15
+ "task-dispatch": "neutral",
16
+ "task-done": "pass",
17
+ "task-failed": "fail",
18
+ "task-human": "warn",
19
+ };
20
+ export const narrationLine = (event) => {
21
+ const line = formatJournalNarration(event);
22
+ const verdict = NARRATION_VERDICTS[event.event];
23
+ return verdict !== undefined && ttyVisual() ? statusRow(verdict, line) : line;
24
+ };
9
25
  const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.blocked.length === 0 && s.pending.length === 0
10
26
  && s.tipVerify !== "failed";
11
27
  export async function run(argv, cwd = process.cwd()) {
@@ -46,7 +62,7 @@ export async function run(argv, cwd = process.cwd()) {
46
62
  const s = await runDaemon(cwd, {
47
63
  concurrency: values.concurrency ? Number(values.concurrency) : undefined,
48
64
  driver: pickDriver(cfg, values.driver),
49
- narrate: (event) => console.log(formatJournalNarration(event)),
65
+ narrate: (event) => console.log(narrationLine(event)),
50
66
  });
51
67
  const out = `run ${s.runId} finished — ${formatSummary(s)} (merge to main is a human decision)`;
52
68
  return { out, code: summaryGreen(s) ? 0 : 2 };
@@ -1,4 +1,4 @@
1
- import { BANNER } from "../../brand.js";
1
+ import { BANNER, GLYPHS, dim, fail, legend, ok, rule, statusRow, title, warn } from "../../brand.js";
2
2
  import { blockedTasks, graphDefinitionHash, loadGraph, pendingTasks } from "../../graph/graph.js";
3
3
  import { GATE_NAMES } from "../../graph/schema.js";
4
4
  import { Journal, engagementComparable } from "../../run/journal.js";
@@ -19,17 +19,8 @@ const attemptStartIdx = (events, taskId) => {
19
19
  return idx;
20
20
  };
21
21
  const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
22
- const color = (text, code, enabled) => enabled ? `\x1b[${code}m${text}\x1b[0m` : text;
23
- const taskBox = (status, unicode) => {
24
- if (unicode) {
25
- if (status === "done")
26
- return "✓";
27
- if (status === "failed")
28
- return "✗";
29
- if (status === "human")
30
- return "⏸";
31
- return "☐";
32
- }
22
+ // non-TTY machine surface only the TTY frame draws task verdicts via statusRow
23
+ const taskBox = (status) => {
33
24
  if (status === "done")
34
25
  return "[x]";
35
26
  if (status === "failed" || status === "human")
@@ -37,8 +28,10 @@ const taskBox = (status, unicode) => {
37
28
  return "[ ]";
38
29
  };
39
30
  const gateBox = (state, unicode) => {
40
- if (unicode)
41
- return state === "pass" ? "✓" : state === "fail" ? "✗" : state === "skip" ? "·" : "☐";
31
+ if (unicode) {
32
+ // shared glyph vocabulary: pass/fail verdicts, dash for skip, dim circle for not-yet-run
33
+ return state === "pass" ? GLYPHS.pass : state === "fail" ? GLYPHS.fail : state === "skip" ? GLYPHS.neutral : GLYPHS.toggleInactive;
34
+ }
42
35
  return state === "pass" ? "[x]" : state === "fail" ? "[!]" : state === "skip" ? "." : "[ ]";
43
36
  };
44
37
  const defaultGateStates = (task) => GATE_NAMES.map((gate) => task.gates.includes(gate) ? "open" : "skip");
@@ -59,9 +52,12 @@ const gateStates = (task, events) => {
59
52
  }
60
53
  return GATE_NAMES.map((gate) => task.gates.includes(gate) ? outcomes.get(gate) ?? "open" : "skip");
61
54
  };
62
- // verdict semantics only: pass green, fail red, skip/open dim chrome — everything else stays quiet
63
- const GATE_STATE_COLOR = { pass: 32, fail: 31, skip: 2, open: 2 };
64
- const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => color(`${GATE_KEYS[gate]}${gateBox(states[i], unicode)}`, GATE_STATE_COLOR[states[i]], unicode)).join(" ");
55
+ // verdict semantics only: pass brand green, fail red, skip/open dim chrome — everything else stays quiet
56
+ const GATE_STATE_TOKEN = { pass: ok, fail, skip: dim, open: dim };
57
+ const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => {
58
+ const chip = `${GATE_KEYS[gate]}${gateBox(states[i], unicode)}`;
59
+ return unicode ? GATE_STATE_TOKEN[states[i]](chip) : chip;
60
+ }).join(" ");
65
61
  // plain (uncolored) chip width for column math — ANSI codes have zero display width
66
62
  const gateChainWidth = (unicode) => GATE_NAMES.reduce((w, gate) => w + GATE_KEYS[gate].length + (unicode ? 1 : 3) + 1, -1);
67
63
  const shortGoal = (goal, max) => {
@@ -159,61 +155,62 @@ const renderFrame = (cwd) => {
159
155
  const assignCol = contexts.has(t.id) ? `${channel}${divider}ctx ${contexts.get(t.id)}` : channel;
160
156
  return { t, st, label, assignCol, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
161
157
  });
162
- const statusColor = (st) => st === "done" ? 32 : st === "failed" ? 31 : st === "human" ? 33 : 36;
163
158
  if (!unicode) {
164
159
  // machine/CI surface — layout unchanged (pipes, greps, and the golden pins depend on it)
165
160
  const rows = cells.map(({ t, st, label, assignCol, states }) => {
166
161
  const chain = gateChain(states, false);
167
- const prefix = ` ${taskBox(st, false)} ${t.id} `;
162
+ const prefix = ` ${taskBox(st)} ${t.id} `;
168
163
  const suffix = ` ${chain} ${String(st)}${label} ${assignCol}`;
169
164
  return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
170
165
  });
171
166
  const header = runId
172
167
  ? `tickmarkr status${divider}run ${runId}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
173
168
  : `tickmarkr status${divider}no runs yet${divider}${done}/${g.tasks.length} done`;
174
- const legend = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
175
- return [header, legend, ...rows].join("\n");
169
+ const legendLine = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
170
+ return [header, legendLine, ...rows].join("\n");
176
171
  }
177
- // TTY: audit-ledger frame bold brand mark, dim chrome, semantic color only on verdicts,
178
- // completion gauge, and column-aligned rows (pad plain text FIRST, colorize after: ANSI has
179
- // zero display width and would corrupt padEnd math)
180
- const dim = (s) => color(s, 2, true);
172
+ // TTY: cockpit frame composed through src/brand.ts (CLI-DESIGN.md) dominant run title,
173
+ // dim chrome, semantic color only on verdicts, completion gauge, and column-aligned status
174
+ // rows (pad plain text FIRST, colorize after: ANSI has zero display width and would corrupt
175
+ // padEnd math)
181
176
  const dot = dim(" · ");
182
177
  const anyFailed = cells.some((c) => c.st === "failed");
183
178
  const gaugeCells = 10;
184
179
  const fill = g.tasks.length ? Math.round((done / g.tasks.length) * gaugeCells) : 0;
185
- const gauge = (fill ? color("█".repeat(fill), anyFailed ? 31 : 32, true) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
180
+ const gauge = (fill ? (anyFailed ? fail : ok)("█".repeat(fill)) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
186
181
  const live = liveness(events)
187
- .replace(/\bdead\b/, color("dead", 31, true))
188
- .replace(/\bfinished\b/, color("finished", 2, true))
189
- .replace(/\balive\b/, color("alive", 32, true))
182
+ .replace(/\bdead\b/, fail("dead"))
183
+ .replace(/\bfinished\b/, dim("finished"))
184
+ .replace(/\balive\b/, ok("alive"))
190
185
  .replaceAll(" · ", dot);
191
186
  const tally = `${done}/${g.tasks.length} done`;
192
- const header = ` ${color("✓", 32, true)} ${color("tickmarkr", 1, true)}${dot}` +
187
+ const header = ` ${title(runId ? `run ${runId}` : "tickmarkr")}${dot}` +
193
188
  (runId
194
- ? `run ${runId}${dot}${!comparable ? `${NOT_COMPARABLE_NOTICE}${dot}` : ""}${live}${dot}`
189
+ ? `${!comparable ? `${warn(NOT_COMPARABLE_NOTICE)}${dot}` : ""}${live}${dot}`
195
190
  : `no runs yet${dot}`) +
196
- `${gauge} ${done === g.tasks.length && g.tasks.length > 0 ? color(tally, 32, true) : tally}`;
197
- const rule = dim("─".repeat(Math.min(width, 100)));
198
- const legend = dim(` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(" · ")}`);
191
+ `${gauge} ${done === g.tasks.length && g.tasks.length > 0 ? ok(tally) : tally}`;
192
+ const hr = rule(Math.min(width, 100));
193
+ const gatesLegend = legend(` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(" · ")}`);
194
+ const taskVerdict = (st) => st === "done" ? "pass" : st === "failed" ? "fail" : st === "human" ? "warn" : "neutral";
199
195
  const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
200
196
  const stW = Math.max(...cells.map((c) => (String(c.st) + c.label).length));
201
197
  const chainW = gateChainWidth(true);
202
198
  const assignW = Math.max(...cells.map((c) => c.assignCol.length));
203
199
  const goalW = Math.max(8, width - (5 + idW) - 2 - chainW - 2 - stW - 2 - assignW);
204
200
  const rows = cells.map(({ t, st, label, assignCol, states }) => {
205
- const box = color(taskBox(st, true), st === "done" ? 32 : st === "failed" ? 31 : st === "human" ? 33 : 2, true);
206
201
  const goal = shortGoal(t.goal, goalW).padEnd(goalW);
207
- const statusCell = color(String(st), statusColor(st), true) +
208
- (label ? (label === " starved" ? color(label, 31, true) : dim(label)) : "") +
202
+ const stWord = st === "done" ? ok(String(st)) : st === "failed" ? fail(String(st)) : st === "human" ? warn(String(st)) : String(st);
203
+ const statusCell = stWord +
204
+ (label ? (label === " starved" ? fail(label) : dim(label)) : "") +
209
205
  " ".repeat(stW - (String(st) + label).length);
210
- return ` ${box} ${t.id.padEnd(idW)} ${goal} ${gateChain(states, true)} ${statusCell} ${dim(assignCol)}`;
206
+ return ` ${statusRow(taskVerdict(st), `${t.id.padEnd(idW)} ${goal} ${gateChain(states, true)} ${statusCell} ${dim(assignCol)}`)}`;
211
207
  });
212
- return [header, rule, legend, ...rows].join("\n");
208
+ return [header, hr, gatesLegend, ...rows].join("\n");
213
209
  };
214
210
  export async function status(argv, cwd = process.cwd(), opts = {}) {
211
+ // cockpit surface: banner + frame on a TTY (doctor's pattern); pipes get the bare frame
215
212
  if (!argv.includes("--watch"))
216
- return renderFrame(cwd);
213
+ return visual() ? BANNER + renderFrame(cwd) : renderFrame(cwd);
217
214
  const iterations = opts.iterations ?? Infinity;
218
215
  const sleep = opts.sleep ?? defaultSleep;
219
216
  const bounded = Number.isFinite(iterations);
@@ -223,7 +220,7 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
223
220
  for (let i = 0; i < iterations; i++) {
224
221
  const frame = renderFrame(cwd);
225
222
  if (tty)
226
- process.stdout.write(`\x1b[2J\x1b[H${BANNER}${frame}\n\x1b[2m watching · refresh ${REFRESH_MS / 1000}s · ^C to quit\x1b[0m`);
223
+ process.stdout.write(`\x1b[2J\x1b[H${BANNER}${frame}\n${legend(` watching · refresh ${REFRESH_MS / 1000}s · ^C to quit`)}`);
227
224
  else
228
225
  process.stdout.write(frame + sep);
229
226
  if (bounded)
@@ -1,4 +1,5 @@
1
1
  import { shq } from "../adapters/types.js";
2
+ import { PANE_IDENTITY_ENV, paneIdentityLine } from "../brand.js";
2
3
  import { createWorktree, sh } from "../run/git.js";
3
4
  import { herdrSealShellPrefix } from "./subprocess.js";
4
5
  import { canonicalizeLegacyName, formatOwnedName, panesToClose, parseOwnedName } from "./types.js";
@@ -115,6 +116,8 @@ export class HerdrDriver {
115
116
  const rootPane = res?.root_pane?.pane_id; // auto-created shell pane (SKILL:343)
116
117
  if (typeof tabId !== "string" || !tabId)
117
118
  throw new Error(`herdr tab create returned no tab_id (refusing untargeted placement): ${t.stdout}`);
119
+ // T5: the banner's pane identity line, derived from the T1 owned name (legacy names pass through).
120
+ const identity = shq(paneIdentityLine(canonicalizeLegacyName(name, "")));
118
121
  let r = await this.herdr(`agent start ${shq(name)} --cwd ${shq(cwd)} --tab ${shq(tabId)} --no-focus -- bash`);
119
122
  if (r.code !== 0 && /agent_name_taken/.test(r.stderr + r.stdout)) {
120
123
  // DEFECT-01: a prior (killed) process's kept pane still holds this durable name — reclaim it.
@@ -149,7 +152,7 @@ export class HerdrDriver {
149
152
  // worker GETS, not a rule it must remember (P40-02 probe leak). Fail closed: a failed seed rejects.
150
153
  // v1.22 T3 / OBS-17: also strip HERDR_ENV + socket path so the worker cannot open/mutate panes in
151
154
  // the operator's session. Daemon-side this.herdr() calls keep process.env (unsealed).
152
- const seed = await this.herdr(`pane run ${shq(id)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; ${herdrSealShellPrefix()}`)}`);
155
+ const seed = await this.herdr(`pane run ${shq(id)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${identity}; ${herdrSealShellPrefix()}`)}`);
153
156
  if (seed.code !== 0)
154
157
  throw new Error(`herdr workspace-id seed failed (refusing untargeted pane): ${seed.stderr || seed.stdout}`);
155
158
  return { id, name, cwd, tabId };
@@ -260,7 +263,8 @@ export class HerdrDriver {
260
263
  // like a failed cd (return null → caller falls back to a per-slot tab). this.ws is guaranteed set —
261
264
  // joinGroup runs only after the first member's tabSlot succeeded, which requires it (VIS-10).
262
265
  // v1.22 T3: same env seal as tabSlot — strip control-plane vars after the workspace seed.
263
- const seed = await this.herdr(`pane run ${shq(pane)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; ${herdrSealShellPrefix()}`)}`);
266
+ // T5: same brand identity seed as tabSlot every group member's banner announces its own name.
267
+ const seed = await this.herdr(`pane run ${shq(pane)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${shq(paneIdentityLine(canonicalizeLegacyName(name, "")))}; ${herdrSealShellPrefix()}`)}`);
264
268
  if (seed.code !== 0) {
265
269
  await this.herdr(`pane close ${shq(pane)}`);
266
270
  return null;
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { trailerPattern, writePrompt } from "../adapters/prompt.js";
6
6
  import { allAdapters, discoverChannels, getAdapter, probeAll, readDoctor } from "../adapters/registry.js";
7
7
  import { addUsage, channelKey, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
8
+ import { bannerShell } from "../brand.js";
8
9
  import { loadConfig } from "../config/config.js";
9
10
  import { herdrSealShellPrefix, SubprocessDriver } from "../drivers/subprocess.js";
10
11
  import { formatOwnedName } from "../drivers/types.js";
@@ -489,7 +490,9 @@ export async function runDaemon(repoRoot, opts = {}) {
489
490
  if (interactive) {
490
491
  // v1.2 interactive: the TUI doesn't exit on completion — the trailer is the finish line.
491
492
  // The exit wrapper still fires if the TUI dies (crash/quit): fast-fail instead of burning the timeout.
492
- await driver.run(slot, `${icmd}; ${exitMarkerCmd}`);
493
+ // T5: the pane announces itself first — same brand header (logo + dim identity) as the
494
+ // judge/review/consult dispatch scripts; one printf one-liner, dispatch mechanics unchanged.
495
+ await driver.run(slot, `${bannerShell()}; ${icmd}; ${exitMarkerCmd}`);
493
496
  let paged = false;
494
497
  // v1.22 T5 / OBS-19: auto-answer a fingerprint-matched trust dialog exactly once per slot.
495
498
  // Any other blocked/idle dialog still pages the operator (paged latch below).
@@ -576,7 +579,8 @@ export async function runDaemon(repoRoot, opts = {}) {
576
579
  }
577
580
  else {
578
581
  const inv = adapter.invoke(t, wt, assignment, { promptFile });
579
- await driver.run(slot, `${inv.command}; ${exitMarkerCmd}`);
582
+ // T5: same brand header as the interactive path and the gate/consult dispatch scripts.
583
+ await driver.run(slot, `${bannerShell()}; ${inv.command}; ${exitMarkerCmd}`);
580
584
  // OBS-54: headless workers have the same output-inactivity budget as visible panes.
581
585
  const stallWindowMs = taskTimeoutMinutes * 60_000;
582
586
  let lastOutput = await driver.read(slot, 500);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.48.2",
3
+ "version": "1.50.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",