tickmarkr 1.64.0 → 1.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/adapters/claude-code.js +3 -0
  2. package/dist/adapters/codex.js +3 -0
  3. package/dist/adapters/cursor-agent.js +2 -0
  4. package/dist/adapters/grok.js +2 -0
  5. package/dist/adapters/kimi.js +3 -0
  6. package/dist/adapters/opencode.js +3 -0
  7. package/dist/adapters/pi.js +2 -0
  8. package/dist/adapters/prompt.d.ts +4 -0
  9. package/dist/adapters/prompt.js +28 -2
  10. package/dist/adapters/registry.d.ts +3 -0
  11. package/dist/adapters/registry.js +38 -0
  12. package/dist/adapters/types.d.ts +4 -0
  13. package/dist/cli/commands/doctor.js +4 -1
  14. package/dist/cli/commands/status.js +12 -4
  15. package/dist/cli/commands/ui.d.ts +5 -0
  16. package/dist/cli/commands/ui.js +13 -0
  17. package/dist/cli/index.d.ts +1 -1
  18. package/dist/cli/index.js +3 -1
  19. package/dist/config/config.js +4 -1
  20. package/dist/run/activity.d.ts +15 -0
  21. package/dist/run/activity.js +96 -0
  22. package/dist/run/consult.js +4 -1
  23. package/dist/run/daemon.js +35 -1
  24. package/dist/run/stall.d.ts +7 -3
  25. package/dist/run/stall.js +71 -3
  26. package/dist/tui/app.d.ts +40 -0
  27. package/dist/tui/app.js +130 -0
  28. package/dist/tui/engine.d.ts +52 -0
  29. package/dist/tui/engine.js +92 -0
  30. package/dist/tui/frame.d.ts +22 -0
  31. package/dist/tui/frame.js +56 -0
  32. package/dist/tui/input.d.ts +41 -0
  33. package/dist/tui/input.js +100 -0
  34. package/dist/tui/views/fleet-view.d.ts +69 -0
  35. package/dist/tui/views/fleet-view.js +243 -0
  36. package/dist/tui/views/preview-view.d.ts +7 -0
  37. package/dist/tui/views/preview-view.js +80 -0
  38. package/dist/tui/views/profile-view.d.ts +15 -0
  39. package/dist/tui/views/profile-view.js +93 -0
  40. package/dist/tui/views/routing-view.d.ts +29 -0
  41. package/dist/tui/views/routing-view.js +98 -0
  42. package/package.json +1 -1
@@ -41,6 +41,9 @@ export const claudeCode = {
41
41
  probeCwd: "neutral",
42
42
  probe: async () => probeVersion("claude"),
43
43
  channels: (cfg) => channelsFromConfig("claude-code", cfg),
44
+ // v1.65 T3: every flag the command builders below hardcode — doctor checks `claude --help` still
45
+ // lists each (all present on claude 2.x, verified 2026-07-22). Advisory only, never routing.
46
+ hardcodedFlags: { binary: "claude", flags: ["-p", "--model", "--permission-mode", "--strict-mcp-config", "--mcp-config", "--output-format", "-r"] },
44
47
  // --strict-mcp-config --mcp-config '{"mcpServers":{}}': pin the MCP surface to empty so fresh-worktree
45
48
  // workers/gates don't load project .mcp.json servers (herdr scrapes dialogs as idle — v1.4 incident,
46
49
  // memory tickmarkr-worker-mcp-dialog-stall). Live-verified 2026-07-10 on claude 2.1.205 (operator check):
@@ -169,6 +169,9 @@ export const codex = {
169
169
  probeConcurrency: 1,
170
170
  probe: async () => probeVersion("codex"),
171
171
  channels: (cfg) => channelsFromConfig("codex", cfg),
172
+ // v1.65 T3: every flag the command builders below hardcode (incl. codexMcpSuppressionFlags' -c/
173
+ // --disable and GITDIR_WRITABLE's -c) — all listed by top-level `codex --help`, verified 2026-07-22.
174
+ hardcodedFlags: { binary: "codex", flags: ["--sandbox", "--model", "-a", "-s", "-c", "--disable"] },
172
175
  // --sandbox workspace-write is the autonomous sandbox mode (codex v0.144.1+)
173
176
  // MCP suppression built per dispatch (config can change between runs) — see codexMcpSuppressionFlags.
174
177
  headlessCommand: (promptFile, model) => `codex exec --sandbox workspace-write ${codexMcpSuppressionFlags()} ${GITDIR_WRITABLE} --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
@@ -36,6 +36,8 @@ export const cursorAgent = {
36
36
  vendor: "cursor",
37
37
  probe: async () => probeVersion("cursor-agent"),
38
38
  channels: (cfg) => channelsFromConfig("cursor-agent", cfg),
39
+ // v1.65 T3: every flag the command builders below hardcode — verified in `cursor-agent --help` 2026-07-22.
40
+ hardcodedFlags: { binary: "cursor-agent", flags: ["-p", "--model", "--force", "--output-format"] },
39
41
  headlessCommand: (promptFile, model) => `cursor-agent -p "$(cat ${shq(promptFile)})" --model ${shq(model)} --force --output-format text`,
40
42
  // NO --trust here: cursor rejects it outside --print ("--trust can only be used with --print/headless
41
43
  // mode", exit 1 — v1.4 phase-1 incident). Fresh worktrees therefore show the trust dialog; v1.22 T5
@@ -98,6 +98,8 @@ export const grok = {
98
98
  : { ...h, authed: false, note: "no valid ~/.grok/auth.json entry (grok login to fix)" };
99
99
  },
100
100
  channels: (cfg) => channelsFromConfig("grok", cfg),
101
+ // v1.65 T3: every flag the command builders below hardcode — verified in `grok --help` 2026-07-22.
102
+ hardcodedFlags: { binary: "grok", flags: ["-p", "--model", "--permission-mode", "--output-format"] },
101
103
  // GROK-02 headless. --output-format plain is the default but pin it explicitly; live-verified
102
104
  // 2026-07-11 (40-RESEARCH F-3): trailer intact + unwrapped, exit 0. "$(cat file)" matches every
103
105
  // other adapter and is already quoting-proven. --permission-mode bypassPermissions is the
@@ -96,6 +96,9 @@ export const kimi = {
96
96
  : { ...h, authed: false, note: "no valid ~/.kimi-code/credentials/kimi-code.json (kimi login to fix)" };
97
97
  },
98
98
  channels: (cfg) => channelsFromConfig("kimi", cfg),
99
+ // v1.65 T3: every flag the command builders below hardcode (-S is resumeCommand's) — verified in
100
+ // `kimi --help` 2026-07-22.
101
+ hardcodedFlags: { binary: "kimi", flags: ["-p", "--model", "--output-format", "-S"] },
99
102
  // KIMI-02 headless. -p prompt mode + explicit model, NO permission flag: kimi 0.26.0 rejects
100
103
  // -p combined with -y/--auto at argument parse time ("Cannot combine --prompt with --yolo",
101
104
  // OBS-67 — doctor probes all failed on it), and prompt mode is already non-interactive with
@@ -19,6 +19,9 @@ export const opencode = {
19
19
  probeCwd: "neutral",
20
20
  probe: async () => probeVersion("opencode"),
21
21
  channels: (cfg) => channelsFromConfig("opencode", cfg),
22
+ // v1.65 T3: every flag the command builders below hardcode (-m on run, --prompt on the TUI) —
23
+ // per the 2026-07-10 live verification, opencode 1.17.15.
24
+ hardcodedFlags: { binary: "opencode", flags: ["-m", "--prompt"] },
22
25
  headlessCommand: (promptFile, model) => `opencode run -m ${shq(model)} "$(cat ${shq(promptFile)})"`,
23
26
  interactiveCommand: (promptFile, model) => `opencode -m ${shq(model)} --prompt "$(cat ${shq(promptFile)})"`,
24
27
  invoke(task, _cwd, a, ctx) {
@@ -48,6 +48,8 @@ export const pi = {
48
48
  return { ...h, servable: parsePiModels(r.stdout || ""), note: "auth verified via pi --list-models (free; auth-filtered by pi)" };
49
49
  },
50
50
  channels: (cfg) => channelsFromConfig("pi", cfg),
51
+ // v1.65 T3: every flag the command builders below hardcode — verified in `pi --help` 2026-07-22.
52
+ hardcodedFlags: { binary: "pi", flags: ["-p", "--approve", "--model"] },
51
53
  // --approve: pi's per-directory trust prompt would stall fresh worktrees (herdr scrapes the dialog
52
54
  // as idle — cursor/claude incident class, milestone PITFALLS #2). Global option, legal in BOTH modes
53
55
  // per pi --help v0.80.3 (2026-07-10) — NOT print-only like cursor's --trust. Chosen over the more
@@ -3,4 +3,8 @@ import type { WorkerResult } from "./types.js";
3
3
  export declare function buildTaskPrompt(task: Task, feedback?: string, nonce?: string): string;
4
4
  export declare function writePrompt(dir: string, task: Task, attempt: number, feedback?: string, nonce?: string): string;
5
5
  export declare function trailerPattern(nonce: string): string;
6
+ export declare const NO_TRAILER_SUMMARY = "worker produced no TICKMARKR_RESULT trailer";
7
+ export declare const UNPARSEABLE_TRAILER_SUMMARY = "unparseable TICKMARKR_RESULT trailer";
6
8
  export declare function parseWorkerResult(raw: string, nonce: string): WorkerResult;
9
+ export type DeadChannelReason = "auth-required" | "setup-required" | "provider-outage" | "timeout";
10
+ export declare function classifyDeadChannel(result: WorkerResult): DeadChannelReason | undefined;
@@ -41,11 +41,15 @@ function trailerTokenPositions(raw, nonce) {
41
41
  positions.push(i);
42
42
  return positions;
43
43
  }
44
+ // v1.65 T1: the parse boundary's own no-trailer sentinel summaries. classifyDeadChannel keys on
45
+ // these — a result carrying any other summary is a PARSED trailer, i.e. the worker speaking.
46
+ export const NO_TRAILER_SUMMARY = "worker produced no TICKMARKR_RESULT trailer";
47
+ export const UNPARSEABLE_TRAILER_SUMMARY = "unparseable TICKMARKR_RESULT trailer";
44
48
  export function parseWorkerResult(raw, nonce) {
45
49
  const fail = (summary) => ({ ok: false, summary, deviations: [], raw });
46
50
  const positions = trailerTokenPositions(raw, nonce);
47
51
  if (positions.length === 0)
48
- return fail("worker produced no TICKMARKR_RESULT trailer");
52
+ return fail(NO_TRAILER_SUMMARY);
49
53
  // TUIs echo the prompt template, redraw lines, and HARD-wrap the JSON with per-line margins
50
54
  // (cursor does; recent-unwrapped can't rejoin hard newlines). Scan occurrences backward — last
51
55
  // parseable wins — joining wrapped lines, stripping margin/box chrome, and growing the candidate
@@ -75,5 +79,27 @@ export function parseWorkerResult(raw, nonce) {
75
79
  }
76
80
  }
77
81
  }
78
- return fail("unparseable TICKMARKR_RESULT trailer");
82
+ return fail(UNPARSEABLE_TRAILER_SUMMARY);
83
+ }
84
+ // Signatures anchor distinctive CLI-error phrasing — never bare fragments or bare status-code
85
+ // numbers (the QUOTA_RE Pitfall-3 lesson): these run only over no-trailer output, but a stalled
86
+ // worker's harvested pane can still contain ordinary work text.
87
+ const AUTH_RE = /not logged in|please (?:log ?in|sign in)|please run [^\n]{0,30}log ?in|authentication[ _](?:required|failed|error)|invalid (?:api key|credentials)|api key (?:is )?(?:not set|missing|invalid|required)|credentials? (?:have )?expired|401 unauthorized/i;
88
+ const SETUP_RE = /command not found|not recognized as an internal or external command|spawn \S+ ENOENT|missing required config|workspace trust (?:required|not granted)/i;
89
+ const OUTAGE_RE = /unable to reach the model provider|cannot reach the model provider|model provider.{0,40}(?:unavailable|unreachable)|service (?:is )?temporarily unavailable|upstream connect error|overloaded_error/i;
90
+ const TIMEOUT_RE = /\bETIMEDOUT\b|request timed out|connection timed out|deadline exceeded|timed out waiting for/i;
91
+ export function classifyDeadChannel(result) {
92
+ // A parsed trailer — ok:true OR ok:false — is the worker speaking: genuine work outcomes walk
93
+ // the normal gate/ladder path even when their transcript mentions auth/outage/timeout text.
94
+ if (result.ok || (result.summary !== NO_TRAILER_SUMMARY && result.summary !== UNPARSEABLE_TRAILER_SUMMARY))
95
+ return undefined;
96
+ if (AUTH_RE.test(result.raw))
97
+ return "auth-required";
98
+ if (SETUP_RE.test(result.raw))
99
+ return "setup-required";
100
+ if (OUTAGE_RE.test(result.raw))
101
+ return "provider-outage";
102
+ if (TIMEOUT_RE.test(result.raw))
103
+ return "timeout";
104
+ return undefined;
79
105
  }
@@ -12,6 +12,9 @@ export declare function detectCandidateClis(opts?: {
12
12
  pathEnv?: string;
13
13
  }): CandidateCliDetection[];
14
14
  export declare function formatCandidateCliRow({ binary, version }: CandidateCliDetection): string;
15
+ export declare function probeHelpText(bin: string): string | undefined;
16
+ export declare function missingDeclaredFlags(helpText: string, flags: string[]): string[];
17
+ export declare function flagDriftWarnings(adapters: WorkerAdapter[], health: Record<string, AuthHealth>, helpOf?: (bin: string) => string | undefined): string[];
15
18
  export declare function allAdapters(opts?: {
16
19
  fakeScriptPath?: string;
17
20
  }): WorkerAdapter[];
@@ -53,6 +53,44 @@ export function formatCandidateCliRow({ binary, version }) {
53
53
  const ver = version ?? "version unknown";
54
54
  return ` ! ${binary.padEnd(14)} detected: ${ver} (no tickmarkr adapter — not routable)`;
55
55
  }
56
+ // v1.65 T3: same guarded spawn pattern as probeCandidateVersion — bounded, error/status-checked,
57
+ // fail-open. undefined = binary unavailable/broken ⇒ no drift verdict (the existing auth reporting
58
+ // already names an uninstalled CLI; drift must never pile on).
59
+ export function probeHelpText(bin) {
60
+ try {
61
+ const r = spawnSync(bin, ["--help"], { encoding: "utf8", timeout: 10000 });
62
+ if (r.error || r.status !== 0)
63
+ return undefined;
64
+ return `${r.stdout || ""}\n${r.stderr || ""}`;
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
70
+ // Whole-token match only: "-p" must never count as present because "--print" contains it, and
71
+ // "--output" must not match a listed "--output-format". Splitting on non-flag chars keeps each
72
+ // help-listed flag intact as one token ("-p, --print <fmt>" → "-p", "--print", "fmt").
73
+ export function missingDeclaredFlags(helpText, flags) {
74
+ const tokens = new Set(helpText.split(/[^A-Za-z0-9_-]+/));
75
+ return flags.filter((f) => !tokens.has(f));
76
+ }
77
+ // v1.65 T3: advisory hardcoded-flag drift check. Reads help, mutates nothing — health, doctor.json,
78
+ // discoverChannels, and routing never see these strings; they are doctor display rows only.
79
+ export function flagDriftWarnings(adapters, health, helpOf = probeHelpText) {
80
+ const out = [];
81
+ for (const a of adapters) {
82
+ const decl = a.hardcodedFlags;
83
+ if (!decl || !health[a.id]?.installed)
84
+ continue;
85
+ const help = helpOf(decl.binary);
86
+ if (help === undefined)
87
+ continue;
88
+ for (const flag of missingDeclaredFlags(help, decl.flags)) {
89
+ out.push(`flag drift: ${a.id} hardcodes ${flag} but ${decl.binary} --help no longer lists it (advisory — routing unchanged)`);
90
+ }
91
+ }
92
+ return out;
93
+ }
56
94
  export function allAdapters(opts = {}) {
57
95
  // pi + grok + kimi appended LAST: same-tier ties resolve by discovery order (Phase 6 D2), so
58
96
  // appending keeps the Phase 6 shape→channel matrix byte-identical; inserting anywhere else
@@ -91,6 +91,10 @@ export interface WorkerAdapter {
91
91
  contextUsage?(session: SessionRef): ContextUsage | null;
92
92
  trust?(repoRoot: string): TrustVerdict;
93
93
  trustDialog?: TrustDialog;
94
+ hardcodedFlags?: {
95
+ binary: string;
96
+ flags: string[];
97
+ };
94
98
  }
95
99
  export declare function channelsFromConfig(adapterId: string, cfg: TickmarkrConfig): BillingChannel[];
96
100
  export declare function channelKey(c: {
@@ -1,6 +1,6 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { allAdapters, detectCandidateClis, probeAll, probeModels, readAutoPrefer, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
3
+ import { allAdapters, detectCandidateClis, flagDriftWarnings, probeAll, probeModels, readAutoPrefer, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
4
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";
@@ -85,6 +85,9 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
85
85
  const servable = servableExclusions(cfg, adapters, health);
86
86
  if (servable.length)
87
87
  rows.push(attentionRow(servabilityLine(servable)));
88
+ // v1.65 T3: hardcoded-flag drift — advisory warn rows only. Runs AFTER writeDoctor so the verdicts
89
+ // can never leak into doctor.json, and discoverChannels/routing never read them.
90
+ rows.push(...flagDriftWarnings(adapters, health).map(attentionRow));
88
91
  // MODEL-05/06: print-only drift fragment; advisory, whole-line-commented additions, tickmarkr NEVER applies it.
89
92
  // TTY gets a one-line summary + the fragment as a file (the full dump drowned everything else,
90
93
  // v1.33.1 onboarding); machine/CI surface keeps the inline dump — layout is pinned by tests.
@@ -1,6 +1,7 @@
1
1
  import { BANNER, GLYPHS, dim, fail, legend, ok, rule, statusRow, title, warn } from "../../brand.js";
2
- import { blockedTasks, graphDefinitionHash, loadGraph, pendingTasks } from "../../graph/graph.js";
2
+ import { blockedTasks, graphDefinitionHash, loadGraph } from "../../graph/graph.js";
3
3
  import { GATE_NAMES } from "../../graph/schema.js";
4
+ import { foldActivity } from "../../run/activity.js";
4
5
  import { Journal, engagementComparable } from "../../run/journal.js";
5
6
  // ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
6
7
  const REFRESH_MS = 2000;
@@ -171,14 +172,17 @@ const renderFrame = (cwd) => {
171
172
  }
172
173
  const effective = { ...g, tasks: g.tasks.map((t) => ({ ...t, status: replayed?.get(t.id) ?? t.status })) };
173
174
  const starved = new Set(blockedTasks(effective).map((t) => t.id));
174
- const waiting = new Set(pendingTasks(effective).map((t) => t.id));
175
+ // OBS-104: ONE activity fold feeds both surfaces — never re-derived here. Comparable events only
176
+ // (a recompiled graph's journal must not animate the wrong tasks); with no or stale journal the
177
+ // dep-waiting cells still derive from the effective graph statuses.
178
+ const activity = foldActivity(comparable ? events : [], effective.tasks);
175
179
  const unicode = visual();
176
180
  const divider = unicode ? " · " : " / ";
177
181
  const width = process.stdout.columns ?? 120;
178
182
  const done = effective.tasks.filter((t) => t.status === "done").length;
179
183
  const cells = g.tasks.map((t) => {
180
184
  const st = replayed?.get(t.id) ?? t.status;
181
- const label = starved.has(t.id) ? " starved" : waiting.has(t.id) ? " dep-waiting" : "";
185
+ const label = starved.has(t.id) ? " starved" : activity.cells.has(t.id) ? ` ${activity.cells.get(t.id)}` : "";
182
186
  const channel = assignments.get(t.id) ?? "-";
183
187
  const assignCol = contexts.has(t.id) ? `${channel}${divider}ctx ${contexts.get(t.id)}` : channel;
184
188
  return { t, st, label, assignCol, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
@@ -218,6 +222,10 @@ const renderFrame = (cwd) => {
218
222
  : `no runs yet${dot}`) +
219
223
  `${gauge} ${done === g.tasks.length && g.tasks.length > 0 ? ok(tally) : tally}`;
220
224
  const hr = rule(Math.min(width, 100));
225
+ // OBS-104 run-level now line: names the most recent journal event. TTY frame only — the non-TTY
226
+ // machine surface is byte-pinned (status-brand golden) and must not drift. Rendered BELOW the task
227
+ // rows: the line names task ids, and rows must stay the first id-bearing lines for grep consumers.
228
+ const nowLine = activity.now ? [legend(` now: ${activity.now}`)] : [];
221
229
  const gatesLegend = legend(` gates: ${GATE_NAMES.join(" · ")}`);
222
230
  const taskVerdict = (st) => st === "done" ? "pass" : st === "failed" ? "fail" : st === "human" ? "warn" : "neutral";
223
231
  const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
@@ -238,7 +246,7 @@ const renderFrame = (cwd) => {
238
246
  " ".repeat(stW - (String(st) + label + failedSuffix(states) + human).length);
239
247
  return ` ${statusRow(taskVerdict(st), `${t.id.padEnd(idW)} ${goal} ${gateChain(states, true)} ${statusCell} ${dim(assignCol)}`)}`;
240
248
  });
241
- return [header, hr, gatesLegend, ...rows].join("\n");
249
+ return [header, hr, gatesLegend, ...rows, ...nowLine].join("\n");
242
250
  };
243
251
  export async function status(argv, cwd = process.cwd(), opts = {}) {
244
252
  // cockpit surface: banner + frame on a TTY (doctor's pattern); pipes get the bare frame
@@ -0,0 +1,5 @@
1
+ import { type StudioOptions } from "../../tui/app.js";
2
+ export declare function ui(_argv: string[], io?: Partial<StudioOptions>): Promise<string | {
3
+ out: string;
4
+ code: number;
5
+ }>;
@@ -0,0 +1,13 @@
1
+ import { StudioApp } from "../../tui/app.js";
2
+ const NON_TTY_MSG = "tickmarkr ui: studio requires a TTY — use `tickmarkr fleet --print` or `tickmarkr status --watch` for line-mode output";
3
+ export async function ui(_argv, io = {}) {
4
+ const input = io.input ?? process.stdin;
5
+ const output = io.output ?? process.stdout;
6
+ if (input.isTTY !== true || output.isTTY !== true) {
7
+ return { out: NON_TTY_MSG, code: 1 };
8
+ }
9
+ const app = new StudioApp({ input, output });
10
+ app.start();
11
+ await app.exited;
12
+ return "ui: closed";
13
+ }
@@ -5,7 +5,7 @@ export type CommandResult = string | {
5
5
  };
6
6
  export type CommandMap = Record<string, (argv: string[]) => Promise<CommandResult>>;
7
7
  export declare const COMMANDS: CommandMap;
8
- export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n run execute the graph (--concurrency N --driver herdr|subprocess --route-strict)\n status live run state\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n approve <id> <task> approve a parked human gate (--by <name> --reason <text>); takes effect on resume";
8
+ export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n run execute the graph (--concurrency N --driver herdr|subprocess --route-strict)\n status live run state\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n approve <id> <task> approve a parked human gate (--by <name> --reason <text>); takes effect on resume";
9
9
  export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
10
10
  out: string;
11
11
  code: number;
package/dist/cli/index.js CHANGED
@@ -13,11 +13,12 @@ import { resume } from "./commands/resume.js";
13
13
  import { run } from "./commands/run.js";
14
14
  import { scope } from "./commands/scope.js";
15
15
  import { status } from "./commands/status.js";
16
+ import { ui } from "./commands/ui.js";
16
17
  import { unlock } from "./commands/unlock.js";
17
18
  import { version } from "./commands/version.js";
18
19
  const normalize = (r) => typeof r === "string" ? { out: r, code: 0 } : r;
19
20
  export const COMMANDS = {
20
- init, doctor, fleet, compile, scope, plan, run, status, resume, report, profile, unlock, approve, version,
21
+ init, doctor, fleet, compile, scope, plan, run, status, resume, report, profile, ui, unlock, approve, version,
21
22
  };
22
23
  const VERSION_FLAGS = new Set(["version", "--version", "-v"]);
23
24
  const HELP_CMDS = new Set(["help", "-h", "--help"]);
@@ -35,6 +36,7 @@ usage: tickmarkr <command>
35
36
  resume <id> continue a run from its journal
36
37
  report <id> cost/quality report (--md for committable execution record)
37
38
  profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)
39
+ ui open the Fleet Studio TUI (full-screen tabbed cockpit)
38
40
  unlock remove a stale/garbage run lock (refuses if the holder is alive)
39
41
  approve <id> <task> approve a parked human gate (--by <name> --reason <text>); takes effect on resume`;
40
42
  // pure, testable dispatcher: resolves a command, forwards argv, shapes the result — no side effects.
@@ -468,7 +468,10 @@ export function configTemplate(overlay) {
468
468
  # explore: { mode: on, excludeShapes: [], excludeComplexityAtOrAbove: null, cap: 5 } # optional; absent ⇒ byte-identical
469
469
  # sla: { implement: 15 } # optional per-shape minutes — advisory plan lint only; absent ⇒ no lint
470
470
  # allow: { adapters: [claude-code, codex] } # optional fleet allowlist; presence activates even if empty (fail-closed)
471
- # deny: { models: [codex:gpt-5.5] } # optional fleet denylist; deny beats allow on conflict
471
+ # deny: # optional fleet denylist; deny beats allow on conflict
472
+ # models:
473
+ # - pi:zai/glm-5.2 # OBS-57: pi passes run-start probe but hangs at finish without TICKMARKR_RESULT — remove after no-trailer demotion ships (v1.46 provider-outage taxonomy)
474
+ # # incident-born deny/pin entries MUST name OBS id + root cause + removal condition (see docs/codebase/CONVENTIONS.md)
472
475
  # # entry grammar: adapter id | model id | adapter:model (entries in either list accept all three forms)
473
476
  # # a hint pinning a denied channel FAILS at plan time (RoutingError) — never silent reroute
474
477
  # # tombstone: deny: null in a repo overlay removes a global deny (arrays replace wholesale, never merge)
@@ -0,0 +1,15 @@
1
+ import { type JournalEvent } from "./journal.js";
2
+ export interface ActivityTask {
3
+ id: string;
4
+ gates: readonly string[];
5
+ deps: readonly string[];
6
+ /** the surface's effective status for the task (replayed ?? graph) */
7
+ status: string;
8
+ }
9
+ export interface ActivitySnapshot {
10
+ /** run-level now line naming the most recent journal event; absent when there are no events */
11
+ now?: string;
12
+ /** taskId → current-activity phrase; absent = idle (terminal, or queued with met deps) */
13
+ cells: Map<string, string>;
14
+ }
15
+ export declare function foldActivity(events: JournalEvent[], tasks: readonly ActivityTask[]): ActivitySnapshot;
@@ -0,0 +1,96 @@
1
+ import { formatJournalNarration } from "./journal.js";
2
+ const channelOf = (assignment) => {
3
+ const a = assignment;
4
+ return typeof a?.adapter === "string" && typeof a.model === "string" ? `${a.adapter}:${a.model}` : "unknown channel";
5
+ };
6
+ const cellText = (st, task) => {
7
+ switch (st.kind) {
8
+ case "worker":
9
+ // since is the dispatch event's own ISO ts — sliced, never re-clocked (purity)
10
+ return `attempt ${st.attempt} in flight on ${st.channel} since ${st.since.slice(11, 19)}`;
11
+ case "gates": {
12
+ const next = task.gates.find((g) => !st.results.has(g));
13
+ if (next)
14
+ return `gate ${next} running`;
15
+ // every declared gate has a result: all pass ⇒ the daemon is merging; any fail ⇒ a retry decision is next
16
+ return [...st.results.values()].every(Boolean) ? "merging" : "retrying";
17
+ }
18
+ case "retrying":
19
+ return "retrying";
20
+ case "parked":
21
+ return st.note ? `parked (${st.note})` : "parked";
22
+ }
23
+ };
24
+ export function foldActivity(events, tasks) {
25
+ const live = new Map();
26
+ // a daemon (re)start or run-end means no attempt/gate is in flight — mirror reconcile.ts; parks persist
27
+ const clearTransient = () => {
28
+ // deleting the current entry mid-iteration is well-defined for Map
29
+ for (const [id, st] of live)
30
+ if (st.kind !== "parked")
31
+ live.delete(id);
32
+ };
33
+ for (const e of events) {
34
+ if (e.event === "run-start" || e.event === "run-resume" || e.event === "run-end") {
35
+ clearTransient();
36
+ continue;
37
+ }
38
+ const id = e.taskId;
39
+ if (!id)
40
+ continue;
41
+ switch (e.event) {
42
+ case "task-dispatch":
43
+ live.set(id, {
44
+ kind: "worker",
45
+ attempt: (Number.isInteger(e.data.attempt) ? e.data.attempt : 0) + 1,
46
+ channel: channelOf(e.data.assignment),
47
+ since: e.ts,
48
+ });
49
+ break;
50
+ case "worker-result":
51
+ // a clean trailer moves the task into gating; anything else is heading for a retry decision
52
+ live.set(id, e.data.ok === true && e.data.finished === true ? { kind: "gates", results: new Map() } : { kind: "retrying" });
53
+ break;
54
+ case "gate-result": {
55
+ const prev = live.get(id);
56
+ const results = prev?.kind === "gates" ? prev.results : new Map();
57
+ if (typeof e.data.gate === "string")
58
+ results.set(e.data.gate, e.data.pass === true || e.data.skipped === true);
59
+ live.set(id, { kind: "gates", results });
60
+ break;
61
+ }
62
+ case "escalation":
63
+ case "consult-verdict":
64
+ case "quota-failover":
65
+ case "provider-death-requeue":
66
+ case "merge-conflict":
67
+ live.set(id, { kind: "retrying" });
68
+ break;
69
+ case "task-human":
70
+ live.set(id, { kind: "parked", ...(typeof e.data.kind === "string" ? { note: e.data.kind } : {}) });
71
+ break;
72
+ case "task-done":
73
+ case "task-failed":
74
+ case "task-approved":
75
+ live.delete(id);
76
+ break;
77
+ }
78
+ }
79
+ const status = new Map(tasks.map((t) => [t.id, t.status]));
80
+ const cells = new Map();
81
+ for (const t of tasks) {
82
+ const st = live.get(t.id);
83
+ if (st) {
84
+ cells.set(t.id, cellText(st, t));
85
+ continue;
86
+ }
87
+ if (t.status !== "pending")
88
+ continue;
89
+ // dep-waiting is reserved for genuinely unmet deps — a bare pending task gets no cell (OBS-104 fix 1)
90
+ const unmet = t.deps.filter((d) => status.get(d) !== "done");
91
+ if (unmet.length)
92
+ cells.set(t.id, `dep-waiting on ${unmet.join(", ")}`);
93
+ }
94
+ const last = events.at(-1);
95
+ return { ...(last ? { now: formatJournalNarration(last) } : {}), cells };
96
+ }
@@ -5,6 +5,7 @@ import { bannerShell, paneDispatchCommand } from "../brand.js";
5
5
  import { augmentFakeVerdictOutput, extractVerdictJson, gateExitTrailer, gatePaneName, generateVerdictNonce, verdictNonceLine } from "../gates/llm.js";
6
6
  import { sh } from "./git.js";
7
7
  import { redactSecrets } from "./redact.js";
8
+ import { filterLlmTranscript } from "./stall.js";
8
9
  const MAX_RETRY_GUIDANCE_LINES = 10;
9
10
  function guidanceParts(text) {
10
11
  return text.split(/\n+/).flatMap((line) => line.split(/(?<=[.!?])\s+/)).map((s) => s.trim()).filter(Boolean);
@@ -56,6 +57,8 @@ export function augmentRetryBrief(feedback, opts) {
56
57
  return parts.join("\n\n");
57
58
  }
58
59
  const ACTIONS = ["retry", "reroute", "decompose", "human"];
60
+ // v1.65 T2: transcript noise (spinner repaints, CR churn, pass-run spam) is squashed at build time —
61
+ // the filtered form is what persists to the consults/ artifact AND what the model reads; fail-open.
59
62
  export function buildDossierPrompt(d, nonce) {
60
63
  return `TICKMARKR-CONSULT
61
64
  You are a senior engineering consult for the tickmarkr orchestrator. A worker task hit trouble.
@@ -70,7 +73,7 @@ ${d.gates.map((g) => `- [${g.pass ? "pass" : "FAIL"}] ${g.gate}: ${g.details}`).
70
73
  ${d.diff || "(none)"}
71
74
 
72
75
  ## Worker transcript (tail)
73
- ${d.transcript || "(none)"}
76
+ ${filterLlmTranscript(d.transcript) || "(none)"}
74
77
 
75
78
  ## Journal (recent events)
76
79
  ${d.journalTail}
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { stringify } from "yaml";
7
- import { trailerPattern, writePrompt } from "../adapters/prompt.js";
7
+ import { classifyDeadChannel, trailerPattern, writePrompt } from "../adapters/prompt.js";
8
8
  import { allAdapters, discoverChannels, getAdapter, probeAll, readDoctor } from "../adapters/registry.js";
9
9
  import { addUsage, channelKey, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
10
10
  import { bannerShell, paneDispatchCommand } from "../brand.js";
@@ -859,6 +859,40 @@ export async function runDaemon(repoRoot, opts = {}) {
859
859
  await park(t, "quota exhausted on every eligible channel", "quota", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
860
860
  return;
861
861
  }
862
+ // v1.65 T1: typed dead-channel failure — the parse boundary classified this no-trailer result
863
+ // as auth-required / setup-required / provider-outage / timeout (classifyDeadChannel; the
864
+ // daemon consumes the type, never re-derives it from raw text). Same free failover as quota —
865
+ // no escalation-ladder step — plus run-wide exclusion via demotedChannels: unlike a quota
866
+ // window that may reset, a dead channel stays dead for this run (OBS-57 class). Strictly
867
+ // AFTER the quota check so quota behavior stays byte-identical (a quota hit returns/continues
868
+ // before reaching here); provider-outage lands here only once the v1.46 same-channel requeue
869
+ // cap above is spent, so a transient blip still recovers in place.
870
+ const dead = classifyDeadChannel(result);
871
+ if (dead) {
872
+ const from = channelKey(assignment);
873
+ demotedChannels.add(from); // excluded for later attempts AND later tasks in this run
874
+ const next = failover("dead-channel");
875
+ journal.append("dead-channel-failover", t.id, { reason: dead, from, to: next ? channelKey(next) : null });
876
+ if (next) {
877
+ await driver.notify(`tickmarkr ${runId}: ${t.id} dead channel (${dead}) failover`, { tier: "attention" });
878
+ // OBS-17 T2 (quota parity): the superseded slot holds a dead-end, not failure context.
879
+ if (!keepForever) {
880
+ const idx = keptSlots.indexOf(slot);
881
+ if (idx >= 0) {
882
+ keptSlots.splice(idx, 1);
883
+ try {
884
+ await closeSlot(slot);
885
+ }
886
+ catch { /* cosmetic — reconcile is the backstop */ }
887
+ }
888
+ }
889
+ assignment = next;
890
+ tried.push(channelKey(next));
891
+ continue;
892
+ }
893
+ await park(t, `dead channel (${dead}) and no eligible channel remains`, "reroute-exhausted", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
894
+ return;
895
+ }
862
896
  if (!finished) {
863
897
  // ROUTE-18 (OBS-04): the channel burned a window without emitting a trailer (no-trailer timeout
864
898
  // OR trailer-less crash-exit — both finished:false). durationMs:0 marks a FACT row, not a timed
@@ -1,4 +1,8 @@
1
- /** Normalize one pane snapshot for the stall-inactivity compare (and ONLY that compare — trailer
2
- * parsing, harvest, waitOutput, and paging read the raw text). Two snapshots that normalize equal
3
- * are the same frame modulo spinner presentation; any other byte difference is worker activity. */
1
+ /** Normalize one pane snapshot for the stall-inactivity compare (trailer parsing, harvest,
2
+ * waitOutput, and paging read the raw text; the LLM transcript filter below reuses this to
3
+ * CLASSIFY presentation-only lines, never to rewrite kept bytes). Two snapshots that normalize
4
+ * equal are the same frame modulo spinner presentation; any other byte difference is activity. */
4
5
  export declare function normalizeStallSnapshot(text: string): string;
6
+ /** Filter transcript text bound for an LLM prompt (consult dossiers, gate prompts). The classify
7
+ * seam exists for fault injection in tests only — production callers pass text alone. */
8
+ export declare function filterLlmTranscript(text: string, classify?: (t: string) => string): string;
package/dist/run/stall.js CHANGED
@@ -16,9 +16,77 @@ const SPINNER_RE = /[⠀-⣿]/g;
16
16
  // A digit run (optionally decimal) bound directly to a time-unit suffix, standing alone as a
17
17
  // word: 9s, 41s, 3m, 1h, 800ms. Never bare digits — "(6/7)" and "5 of 7" stay change-sensitive.
18
18
  const ELAPSED_RE = /(?<![\w.])\d+(?:\.\d+)?(?:ms|[hms])(?!\w)/g;
19
- /** Normalize one pane snapshot for the stall-inactivity compare (and ONLY that compare — trailer
20
- * parsing, harvest, waitOutput, and paging read the raw text). Two snapshots that normalize equal
21
- * are the same frame modulo spinner presentation; any other byte difference is worker activity. */
19
+ /** Normalize one pane snapshot for the stall-inactivity compare (trailer parsing, harvest,
20
+ * waitOutput, and paging read the raw text; the LLM transcript filter below reuses this to
21
+ * CLASSIFY presentation-only lines, never to rewrite kept bytes). Two snapshots that normalize
22
+ * equal are the same frame modulo spinner presentation; any other byte difference is activity. */
22
23
  export function normalizeStallSnapshot(text) {
23
24
  return text.replace(ANSI_RE, "").replace(SPINNER_RE, "").replace(ELAPSED_RE, "");
24
25
  }
26
+ // ─── v1.65 T2: LLM-bound transcript filter ──────────────────────────────────────────────────────
27
+ // Consult dossiers and gate prompts pay tokens per transcript byte, so LLM-bound text runs through
28
+ // a per-line classifier: carriage-return overwrite churn keeps only the final paint, lines that are
29
+ // pure presentation (spinner/ANSI/elapsed only — classified via normalizeStallSnapshot above, never
30
+ // a parallel normalizer) drop, consecutive repaint frames that normalize equal squash to the last,
31
+ // and runs of passing-test lines collapse to a count line. Failure lines, exit codes, and summary
32
+ // lines always survive verbatim. Fail-open by contract: any internal error — or trivial savings —
33
+ // returns the original text; the filter may only ever cost noise, never evidence.
34
+ // Signal that must never drop: failure markers, exit codes, run summaries. Substring matches on
35
+ // purpose (AssertionError, FAILED) — over-keeping is the safe miss, same asymmetry as the allowlist.
36
+ const KEEP_RE = /[✗✖]|fail|error|exception|fatal|panic|exit\s*code|exit(?:ed)?\s+with|non-?zero|traceback|^\s*(?:tests?\b|test\s+(?:files|suites)|suites?\b|snapshots?\b|duration\b|summary\b)/i;
37
+ // Passing-test line shapes (vitest/jest/tap/go/pytest). Only KEEP-negative lines reach this class.
38
+ const PASS_RE = /^\s*(?:[✓✔√]\s|ok\s+\d|PASS\b|---\s*PASS:)|\bPASSED\b/;
39
+ const COLLAPSE_MIN = 3; // a 1–2 line run costs less than the count line that would replace it
40
+ const MIN_SAVINGS_RATIO = 0.1; // below 10% shrink the rewrite is not worth its risk — pass through
41
+ function classifyTranscript(text) {
42
+ const out = [];
43
+ let run = [];
44
+ let prevNorm = null;
45
+ const flush = () => {
46
+ if (run.length >= COLLAPSE_MIN)
47
+ out.push(`[${run.length} passing-test lines collapsed]`);
48
+ else
49
+ out.push(...run);
50
+ run = [];
51
+ };
52
+ for (const raw of text.split("\n")) {
53
+ // CR overwrite churn: the final paint wins; earlier paints carrying must-keep signal survive too.
54
+ const segs = raw.split("\r");
55
+ for (const line of segs.filter((s, i) => i === segs.length - 1 || KEEP_RE.test(s))) {
56
+ if (KEEP_RE.test(line)) {
57
+ flush();
58
+ out.push(line);
59
+ prevNorm = null;
60
+ continue;
61
+ }
62
+ if (PASS_RE.test(line)) {
63
+ run.push(line);
64
+ prevNorm = null;
65
+ continue;
66
+ }
67
+ flush();
68
+ const norm = normalizeStallSnapshot(line);
69
+ if (line.trim() !== "" && norm.trim() === "")
70
+ continue; // pure spinner/ANSI/elapsed frame
71
+ if (norm !== "" && norm === prevNorm) {
72
+ out[out.length - 1] = line;
73
+ continue;
74
+ } // repaint of the prior line — latest wins
75
+ out.push(line);
76
+ prevNorm = norm;
77
+ }
78
+ }
79
+ flush();
80
+ return out.join("\n");
81
+ }
82
+ /** Filter transcript text bound for an LLM prompt (consult dossiers, gate prompts). The classify
83
+ * seam exists for fault injection in tests only — production callers pass text alone. */
84
+ export function filterLlmTranscript(text, classify = classifyTranscript) {
85
+ try {
86
+ const filtered = classify(text);
87
+ return text.length - filtered.length < text.length * MIN_SAVINGS_RATIO ? text : filtered;
88
+ }
89
+ catch {
90
+ return text; // fail open — a filter defect must never cost the consult its evidence
91
+ }
92
+ }