tickmarkr 1.78.0 → 1.80.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 (39) hide show
  1. package/dist/adapters/claude-code.d.ts +11 -0
  2. package/dist/adapters/claude-code.js +105 -16
  3. package/dist/adapters/kimi.d.ts +6 -0
  4. package/dist/adapters/kimi.js +88 -26
  5. package/dist/adapters/registry.js +6 -1
  6. package/dist/brand.d.ts +4 -0
  7. package/dist/brand.js +4 -0
  8. package/dist/cli/commands/doctor.d.ts +2 -0
  9. package/dist/cli/commands/doctor.js +23 -0
  10. package/dist/cli/commands/status.d.ts +4 -0
  11. package/dist/cli/commands/status.js +237 -18
  12. package/dist/cli/commands/ui.d.ts +1 -1
  13. package/dist/cli/commands/ui.js +11 -1
  14. package/dist/drivers/herdr.js +36 -7
  15. package/dist/drivers/types.d.ts +22 -1
  16. package/dist/gates/acceptance.d.ts +5 -0
  17. package/dist/gates/acceptance.js +5 -3
  18. package/dist/gates/llm.d.ts +9 -0
  19. package/dist/gates/llm.js +74 -0
  20. package/dist/gates/review.d.ts +5 -0
  21. package/dist/gates/review.js +5 -3
  22. package/dist/run/journal.d.ts +1 -1
  23. package/dist/tui/cockpit/capture.d.ts +117 -0
  24. package/dist/tui/cockpit/capture.js +329 -0
  25. package/dist/tui/cockpit/components.d.ts +179 -0
  26. package/dist/tui/cockpit/components.js +280 -0
  27. package/dist/tui/cockpit/demo.d.ts +41 -0
  28. package/dist/tui/cockpit/demo.js +144 -0
  29. package/dist/tui/cockpit/derive.d.ts +39 -0
  30. package/dist/tui/cockpit/derive.js +390 -0
  31. package/dist/tui/cockpit/layout.d.ts +76 -0
  32. package/dist/tui/cockpit/layout.js +84 -0
  33. package/dist/tui/cockpit/run-cockpit.d.ts +19 -0
  34. package/dist/tui/cockpit/run-cockpit.js +194 -0
  35. package/dist/tui/cockpit/setup-cockpit.d.ts +41 -0
  36. package/dist/tui/cockpit/setup-cockpit.js +488 -0
  37. package/dist/tui/cockpit/theme.d.ts +161 -0
  38. package/dist/tui/cockpit/theme.js +170 -0
  39. package/package.json +2 -1
@@ -1,5 +1,16 @@
1
1
  import { type AuthHealth, type TrustDialog, type WorkerAdapter } from "./types.js";
2
+ export declare const CLAUDE_ALIAS_IDENTITY_STAMPS: {
3
+ readonly fable: "claude-fable-5";
4
+ readonly opus: "claude-opus-4-8";
5
+ readonly sonnet: "claude-sonnet-5";
6
+ readonly haiku: "claude-haiku-4-5-20251001";
7
+ };
8
+ export type ClaudeAlias = keyof typeof CLAUDE_ALIAS_IDENTITY_STAMPS;
9
+ export type ClaudeAliasIdentityProbe = (cwd: string, alias: ClaudeAlias) => string | undefined;
2
10
  export declare const CLAUDE_TRUST_DIALOG: TrustDialog;
3
11
  export declare function claudeSlug(real: string): string;
12
+ export declare function readClaudeAliasIdentity(cwd: string, alias: ClaudeAlias): string | undefined;
13
+ export declare function probeClaudeAliasIdentity(cwd: string, alias: ClaudeAlias): string | undefined;
14
+ export declare function resolveClaudeAliasIdentity(cwd: string, alias: ClaudeAlias, probe?: ClaudeAliasIdentityProbe): string | undefined;
4
15
  export declare function probeVersion(bin: string): AuthHealth;
5
16
  export declare const claudeCode: WorkerAdapter;
@@ -3,7 +3,7 @@ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { parseWorkerResult } from "./prompt.js";
6
- import { channelsFromConfig, shq, TokenUsageSchema } from "./types.js";
6
+ import { channelsFromConfig, MODEL_ID_RE, shq, TokenUsageSchema } from "./types.js";
7
7
  // SPEND-01/SPEND-11: claude writes a per-session JSONL to ~/.claude/projects/<slug>/ where slug is the
8
8
  // realpath'd cwd with every non-alphanumeric char replaced by "-" (verified 114/114 — 36-DIAGNOSIS.md).
9
9
  // The old `/`-only formula missed the "." in `.tickmarkr/worktrees/…` — ENOENT on every worktree dispatch.
@@ -20,6 +20,20 @@ import { channelsFromConfig, shq, TokenUsageSchema } from "./types.js";
20
20
  // Phase 18's operator-price × tokens derivation, not a CLI claim.
21
21
  const MAX_SESSION_FILES = 20; // newest-first; a long-lived project dir can hold many sessions
22
22
  const MAX_SESSION_BYTES = 8_000_000; // per-file cap; a runaway JSONL cannot make the read unbounded
23
+ // OBS-145: these aliases float at the Claude CLI layer, while their tier/pricing decisions were
24
+ // made for the dated identities below (claude-code seeds stamped 2026-07-09 — doctor's own lint).
25
+ // Updating a stamp is a deliberate benchmark-policy act; doctor only compares against it and never
26
+ // edits tiers, pricing, learned scores, or routing. `opus` is deliberately left at 4-8: the OBS-145
27
+ // drill probed the alias at claude-opus-4-8 matching its stamp, then captured it re-pointing to
28
+ // claude-opus-5 ~30 min later with zero events fired. The 2026-07-24 fit added an EXPLICIT
29
+ // claude-opus-5 channel to the repo overlay — it did not re-date this alias's stamps, so the
30
+ // alias channel still carries 4-8-dated tier/pricing while serving 5. That warning is true.
31
+ export const CLAUDE_ALIAS_IDENTITY_STAMPS = {
32
+ fable: "claude-fable-5",
33
+ opus: "claude-opus-4-8",
34
+ sonnet: "claude-sonnet-5",
35
+ haiku: "claude-haiku-4-5-20251001",
36
+ };
23
37
  // v1.75 T2 / OBS-137: current Claude Code workspace-trust prompt (2.1.218). The full question
24
38
  // distinguishes this startup gate from routine agent text; Enter accepts the selected trust option.
25
39
  export const CLAUDE_TRUST_DIALOG = {
@@ -29,6 +43,95 @@ export const CLAUDE_TRUST_DIALOG = {
29
43
  export function claudeSlug(real) {
30
44
  return real.replace(/[^A-Za-z0-9]/g, "-");
31
45
  }
46
+ // newest-first by mtime, bounded — mtime picks WHICH files to scan, never a record's cursor.
47
+ // Shared by collectUsage (spend) and readClaudeAliasIdentity (OBS-145): same store, same bounds.
48
+ function newestSessionFiles(dir) {
49
+ return readdirSync(dir)
50
+ .filter((f) => f.endsWith(".jsonl"))
51
+ .map((f) => {
52
+ try {
53
+ return { f, m: statSync(join(dir, f)).mtimeMs };
54
+ }
55
+ catch {
56
+ return undefined; // a stat failure just drops that file
57
+ }
58
+ })
59
+ .filter((x) => x !== undefined)
60
+ .sort((a, b) => b.m - a.m)
61
+ .slice(0, MAX_SESSION_FILES)
62
+ .map((x) => x.f);
63
+ }
64
+ const belongsToAliasFamily = (identity, alias) => identity === alias || identity.split(/[^A-Za-z0-9]+/).includes(alias);
65
+ // Zero-token first choice: Claude assistant records state the resolved message.model. The newest
66
+ // matching family record in this cwd wins. Every failure is advisory/unknown, never an exception.
67
+ export function readClaudeAliasIdentity(cwd, alias) {
68
+ try {
69
+ const real = realpathSync(cwd);
70
+ const dir = join(homedir(), ".claude", "projects", claudeSlug(real));
71
+ let newest;
72
+ for (const f of newestSessionFiles(dir)) {
73
+ let text;
74
+ try {
75
+ text = readFileSync(join(dir, f), "utf8").slice(0, MAX_SESSION_BYTES);
76
+ }
77
+ catch {
78
+ continue;
79
+ }
80
+ for (const line of text.split("\n")) {
81
+ if (!line.trim())
82
+ continue;
83
+ let raw;
84
+ try {
85
+ raw = JSON.parse(line);
86
+ }
87
+ catch {
88
+ continue;
89
+ }
90
+ const rec = raw;
91
+ if (rec.cwd !== real || typeof rec.message?.model !== "string")
92
+ continue;
93
+ const identity = rec.message.model;
94
+ const timestamp = Date.parse(String(rec.timestamp));
95
+ if (!MODEL_ID_RE.test(identity) || !belongsToAliasFamily(identity, alias) || !Number.isFinite(timestamp))
96
+ continue;
97
+ if (!newest || timestamp > newest.timestamp)
98
+ newest = { identity, timestamp };
99
+ }
100
+ }
101
+ return newest?.identity;
102
+ }
103
+ catch {
104
+ return undefined;
105
+ }
106
+ }
107
+ // Store silence earns one minimal stated-identity turn. The output contract is intentionally strict:
108
+ // one safe model id from the requested alias family, otherwise unknown/fail-open. Vitest never spawns
109
+ // a real agent CLI; tests inject the probe callback at resolveClaudeAliasIdentity instead.
110
+ export function probeClaudeAliasIdentity(cwd, alias) {
111
+ if (process.env.VITEST)
112
+ return undefined;
113
+ try {
114
+ const r = spawnSync("claude", [
115
+ "-p",
116
+ "State the exact model identifier serving this request. Reply with only that identifier.",
117
+ "--model", alias,
118
+ "--permission-mode", "bypassPermissions",
119
+ "--strict-mcp-config",
120
+ "--mcp-config", '{"mcpServers":{}}',
121
+ "--output-format", "text",
122
+ ], { cwd, encoding: "utf8", timeout: 60000, stdio: ["ignore", "pipe", "pipe"] });
123
+ if (r.error || r.status !== 0)
124
+ return undefined;
125
+ const identity = (r.stdout || "").trim();
126
+ return MODEL_ID_RE.test(identity) && belongsToAliasFamily(identity, alias) ? identity : undefined;
127
+ }
128
+ catch {
129
+ return undefined;
130
+ }
131
+ }
132
+ export function resolveClaudeAliasIdentity(cwd, alias, probe = probeClaudeAliasIdentity) {
133
+ return readClaudeAliasIdentity(cwd, alias) ?? probe(cwd, alias);
134
+ }
32
135
  export function probeVersion(bin) {
33
136
  const r = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 10000 });
34
137
  if (r.error || r.status !== 0)
@@ -75,24 +178,10 @@ export const claudeCode = {
75
178
  const real = realpathSync(cwd); // resolve symlinks (darwin /tmp → /private/tmp)
76
179
  const slug = claudeSlug(real);
77
180
  const dir = join(homedir(), ".claude", "projects", slug);
78
- // newest-first by mtime, bounded — mtime picks WHICH files to scan, never a record's cursor.
79
- const files = readdirSync(dir)
80
- .filter((f) => f.endsWith(".jsonl"))
81
- .map((f) => {
82
- try {
83
- return { f, m: statSync(join(dir, f)).mtimeMs };
84
- }
85
- catch {
86
- return undefined; // a stat failure just drops that file
87
- }
88
- })
89
- .filter((x) => x !== undefined)
90
- .sort((a, b) => b.m - a.m)
91
- .slice(0, MAX_SESSION_FILES);
92
181
  let input = 0, output = 0, kept = false;
93
182
  let cacheRead, cacheWrite;
94
183
  const seen = new Set(); // message.id is globally unique across session files in one call
95
- for (const { f } of files) {
184
+ for (const f of newestSessionFiles(dir)) {
96
185
  let text;
97
186
  try {
98
187
  text = readFileSync(join(dir, f), "utf8").slice(0, MAX_SESSION_BYTES);
@@ -13,9 +13,15 @@ export declare function kimiBannerModel(banner: string): string | undefined;
13
13
  export declare function kimiBannerSessionId(banner: string): string | undefined;
14
14
  export type KimiBannerConfirm = {
15
15
  ok: true;
16
+ status: "confirmed";
17
+ sessionId?: string;
18
+ } | {
19
+ status: "unknown";
20
+ printedModel?: string;
16
21
  sessionId?: string;
17
22
  } | {
18
23
  ok: false;
24
+ status: "mismatch";
19
25
  error: string;
20
26
  };
21
27
  export declare function confirmKimiSeedBanner(banner: string, assignedModel: string): KimiBannerConfirm;
@@ -96,11 +96,26 @@ export async function probeKimiDoctorTurn(cwd) {
96
96
  // Prefer a banner Session line when present so seed-mode captures the id from the launch banner
97
97
  // itself rather than waiting for a completion-time resume trailer (which the TUI may never emit).
98
98
  const RESUME_TRAILER_RE = /^\s*To resume this session: kimi -r (session_[0-9a-f-]+)\s*$/;
99
- const BANNER_SESSION_LINE_RE = /^\s*Session:\s*(session_[0-9a-f-]+)\s*$/;
99
+ const BANNER_SESSION_RE = /^Session:\s*(session_[0-9a-f-]+)$/;
100
+ const BANNER_MODEL_RE = /^Model:\s*(.+)$/;
101
+ function kimiBannerLines(banner) {
102
+ // The live TUI renders banner fields as indented rows inside `│ ... │`. Strip only that
103
+ // per-line margin/chrome, following prompt.ts's pane-text precedent; preserve field content.
104
+ return banner.split("\n")
105
+ .map((line) => line.replace(/^[\s│|]+/, "").replace(/[\s│|]+$/, ""));
106
+ }
107
+ function kimiBannerPrintedModel(banner) {
108
+ for (const line of kimiBannerLines(banner)) {
109
+ const printedModel = BANNER_MODEL_RE.exec(line)?.[1]?.trim();
110
+ if (printedModel)
111
+ return printedModel;
112
+ }
113
+ return undefined;
114
+ }
100
115
  export function kimiSessionId(output) {
101
116
  let id;
102
- for (const line of output.split("\n")) {
103
- const banner = BANNER_SESSION_LINE_RE.exec(line);
117
+ for (const line of kimiBannerLines(output)) {
118
+ const banner = BANNER_SESSION_RE.exec(line);
104
119
  if (banner) {
105
120
  id = banner[1];
106
121
  continue;
@@ -113,44 +128,84 @@ export function kimiSessionId(output) {
113
128
  }
114
129
  // v1.69 T7: the cold-start banner prints the model alias and session id. Parse them from the
115
130
  // banner text already captured for the readiness match — no new probe, no extra dispatch. Kimi
116
- // 0.29.0 may print either the full config key or its display suffix; normalize both idempotently
117
- // to the full channel identifier routing uses.
118
- const BANNER_MODEL_RE = /^Model:\s*(.+)$/m;
119
- const BANNER_SESSION_RE = /^Session:\s*(session_[0-9a-f-]+)$/m;
131
+ // may print a display name, the full config key, or its legacy suffix. Resolve only names with a
132
+ // known channel mapping: inventing `kimi-code/<future display>` would turn unknown into mismatch.
133
+ const KIMI_BANNER_MODEL_CHANNELS = new Map([
134
+ ["K3", "kimi-code/k3"],
135
+ ["k3", "kimi-code/k3"],
136
+ ["kimi-code/k3", "kimi-code/k3"],
137
+ ["K2.7 Coding", "kimi-code/kimi-for-coding"],
138
+ ["kimi-for-coding", "kimi-code/kimi-for-coding"],
139
+ ["kimi-code/kimi-for-coding", "kimi-code/kimi-for-coding"],
140
+ ["K2.7 Coding Highspeed", "kimi-code/kimi-for-coding-highspeed"],
141
+ ["kimi-for-coding-highspeed", "kimi-code/kimi-for-coding-highspeed"],
142
+ ["kimi-code/kimi-for-coding-highspeed", "kimi-code/kimi-for-coding-highspeed"],
143
+ ]);
120
144
  export function kimiBannerModel(banner) {
121
- const m = BANNER_MODEL_RE.exec(banner);
122
- if (!m)
123
- return undefined;
124
- const printedModel = m[1].trim();
125
- if (!printedModel)
126
- return undefined;
127
- return printedModel.startsWith("kimi-code/") ? printedModel : `kimi-code/${printedModel}`;
145
+ const printedModel = kimiBannerPrintedModel(banner);
146
+ return printedModel === undefined ? undefined : KIMI_BANNER_MODEL_CHANNELS.get(printedModel);
128
147
  }
129
148
  export function kimiBannerSessionId(banner) {
130
- return BANNER_SESSION_RE.exec(banner)?.[1];
149
+ for (const line of kimiBannerLines(banner)) {
150
+ const sessionId = BANNER_SESSION_RE.exec(line)?.[1];
151
+ if (sessionId)
152
+ return sessionId;
153
+ }
154
+ return undefined;
131
155
  }
132
- // Fail closed on a named-model mismatch; a missing Model line is not a mismatch (banner partial).
156
+ // Fail closed on a mapped-model mismatch. Unknown/missing names remain non-blocking for forward
157
+ // compatibility, but carry an explicit unknown status rather than masquerading as confirmation.
133
158
  export function confirmKimiSeedBanner(banner, assignedModel) {
159
+ const sessionId = kimiBannerSessionId(banner);
160
+ const printedModel = kimiBannerPrintedModel(banner);
134
161
  const saw = kimiBannerModel(banner);
135
- if (saw !== undefined && saw !== assignedModel) {
136
- return { ok: false, error: `model mismatch: expected ${assignedModel}, saw ${saw}` };
162
+ if (printedModel === undefined || saw === undefined) {
163
+ return {
164
+ status: "unknown",
165
+ ...(printedModel === undefined ? {} : { printedModel }),
166
+ ...(sessionId === undefined ? {} : { sessionId }),
167
+ };
168
+ }
169
+ if (saw !== assignedModel) {
170
+ return { ok: false, status: "mismatch", error: `model mismatch: expected ${assignedModel}, saw ${saw}` };
137
171
  }
138
- return { ok: true, sessionId: kimiBannerSessionId(banner) };
172
+ return { ok: true, status: "confirmed", ...(sessionId === undefined ? {} : { sessionId }) };
139
173
  }
140
174
  const kimiTuiLaunch = (model) => `kimi -y -m ${shq(model)}`;
141
175
  const KIMI_ANSI_SGR_RE = /\u001B\[[0-9;]*m/g;
142
176
  const KIMI_EDITOR_TOP_RE = /^╭─+╮$/;
143
177
  const KIMI_EDITOR_INPUT_RE = /^│ > .*│$/;
144
178
  const KIMI_EDITOR_EMPTY_RE = /^│ > +│$/;
179
+ // OBS-181: any interior row of the box. An editor carrying a prompt renders one or more blank
180
+ // continuation rows beneath it, so the bottom border is not always the very next line.
181
+ const KIMI_EDITOR_ROW_RE = /^│.*│$/;
145
182
  const KIMI_EDITOR_BOTTOM_RE = /^╰─+╯$/;
146
183
  function matchesKimiEditorBox(paneText, empty) {
147
- const lines = paneText.replace(KIMI_ANSI_SGR_RE, "").split("\n");
184
+ // OBS-152: kimi renders its whole TUI indented one column, so `^`-anchored row matches never fire
185
+ // on a LIVE editor box — probe 6 timed out readiness against a frame whose box was fully painted
186
+ // and interactive. Trim per line: the left margin is chrome, and what actually distinguishes the
187
+ // editor from the welcome panel (also a bordered box) is the border adjacency asserted below.
188
+ //
189
+ // `match` means THE EDITOR BOX IS PAINTED (readiness), and is deliberately true for an empty box.
190
+ // `emptyMatch` is the stricter "painted AND carrying nothing". Callers deciding submission must
191
+ // therefore test emptyMatch FIRST — see HerdrDriver.submissionRegistered.
192
+ //
193
+ // OBS-181: the adjacency used to demand the bottom border on the line directly below the prompt.
194
+ // A wedged worker pane rendered FOUR rows — top, prompt, a blank continuation row, bottom — so
195
+ // NEITHER matcher fired, and the driver's submission check fell through to a fallback that always
196
+ // answers "delivered". Walk the interior to the border instead of assuming a three-row box.
197
+ const lines = paneText.replace(KIMI_ANSI_SGR_RE, "").split("\n").map((line) => line.trim());
148
198
  const input = empty ? KIMI_EDITOR_EMPTY_RE : KIMI_EDITOR_INPUT_RE;
149
- return lines.some((line, index) => input.test(line)
150
- && index > 0
151
- && index + 1 < lines.length
152
- && KIMI_EDITOR_TOP_RE.test(lines[index - 1])
153
- && KIMI_EDITOR_BOTTOM_RE.test(lines[index + 1]));
199
+ return lines.some((line, index) => {
200
+ if (!input.test(line))
201
+ return false;
202
+ if (index === 0 || !KIMI_EDITOR_TOP_RE.test(lines[index - 1]))
203
+ return false;
204
+ let below = index + 1;
205
+ while (below < lines.length && KIMI_EDITOR_ROW_RE.test(lines[below]))
206
+ below++;
207
+ return below < lines.length && KIMI_EDITOR_BOTTOM_RE.test(lines[below]);
208
+ });
154
209
  }
155
210
  // v1.75 T1 / OBS-136: Kimi's actual editor is a three-line bordered box whose content row starts
156
211
  // `│ > `. The welcome panel separately contains "Send /help..." and is only launch-liveness
@@ -170,7 +225,14 @@ const KIMI_SEED = {
170
225
  launch: kimiTuiLaunch,
171
226
  readinessMatch: "Send /help for help information.",
172
227
  seedLine: (promptFile) => `Read ${promptFile} and do exactly what it says.`,
173
- confirmBanner: confirmKimiSeedBanner,
228
+ confirmBanner: (banner, assignedModel) => {
229
+ const confirmation = confirmKimiSeedBanner(banner, assignedModel);
230
+ // The generic seed contract is binary: unknown display names remain admissible while the
231
+ // Kimi-specific result above truthfully keeps them distinct from a confirmed identity.
232
+ return confirmation.status === "unknown"
233
+ ? { ok: true, status: confirmation.status, sessionId: confirmation.sessionId }
234
+ : confirmation;
235
+ },
174
236
  };
175
237
  // v1.69 T7 / v1.71 T2: thin wrapper over the daemon's generic dispatch path for kimi-only tests.
176
238
  export async function runKimiInteractiveSeed(opts) {
@@ -203,7 +203,12 @@ const MODEL_PROBE_PROMPT = "Reply with exactly OK and nothing else.";
203
203
  // 60s: a healthy MCP-free codex probe measured 29.9s round-trip (2026-07-15) — 30s had zero headroom.
204
204
  const MODEL_PROBE_TIMEOUT_MS = 60000;
205
205
  // Auth-words only match when tied to a failure word; bare "auth"/"OAuth"/"authored" never fail (v1.27 T2).
206
- const AUTH_FAILURE_RE = /\b4\d\d\b|\bauth(?:entication|orization)?\s+(?:error|failed|failure|denied)|unauthori[sz]ed|forbidden|access denied|credit(?:s)?\s+(?:exhausted|error|denied)/i;
206
+ // OBS-150: the 4xx alternative must refuse digit-group context. Bare `\b4\d\d\b` matched "485" inside the
207
+ // token footer "tokens used 26,485" and failed a HEALTHY probe closed — a ~1-in-10 dice roll on EVERY model
208
+ // probe of any adapter that prints a comma-grouped token count. Lookarounds reject a group that is part of a
209
+ // larger number (26,485 · 1.400 · 400.5) while keeping real bare codes (401 · "error 429" · "403 Forbidden."
210
+ // — trailing sentence punctuation still matches, only a following DIGIT means "decimal, not status code").
211
+ const AUTH_FAILURE_RE = /\b(?<![\d.,])4\d\d\b(?![.,]\d)|\bauth(?:entication|orization)?\s+(?:error|failed|failure|denied)|unauthori[sz]ed|forbidden|access denied|credit(?:s)?\s+(?:exhausted|error|denied)/i;
207
212
  const PROBE_REASON_CAP = 240;
208
213
  // v1.55 T3: the tail must open at a word boundary — a mid-word slice ("odel 'grok-…'") reads as
209
214
  // corruption in operator-facing diagnostics. Input is already space-normalized, so " " is the only
package/dist/brand.d.ts CHANGED
@@ -2,6 +2,10 @@ import type { OwnedName } from "./drivers/types.js";
2
2
  export declare const BANNER: string;
3
3
  /** ANSI-stripped, trailing-space-trimmed twin of BANNER — README hero and other plain surfaces. */
4
4
  export declare const PLAIN_BANNER: string;
5
+ /** Two-line run-cockpit lockup, sliced from BANNER so its mark and product name cannot drift. */
6
+ export declare const COMPACT_LOCKUP: string;
7
+ /** ANSI-stripped, trailing-space-trimmed twin of COMPACT_LOCKUP for colourless surfaces. */
8
+ export declare const PLAIN_COMPACT_LOCKUP: string;
5
9
  export declare const PANE_IDENTITY_ENV = "TICKMARKR_PANE_IDENTITY";
6
10
  /** One-line pane identity through the T1 ownership contract: role · task · attempt · run. */
7
11
  export declare function paneIdentityLine(o: OwnedName): string;
package/dist/brand.js CHANGED
@@ -12,6 +12,10 @@ 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
+ /** Two-line run-cockpit lockup, sliced from BANNER so its mark and product name cannot drift. */
16
+ export const COMPACT_LOCKUP = BANNER.split("\n").slice(1, 3).join("\n");
17
+ /** ANSI-stripped, trailing-space-trimmed twin of COMPACT_LOCKUP for colourless surfaces. */
18
+ export const PLAIN_COMPACT_LOCKUP = COMPACT_LOCKUP.replace(/\x1b\[[0-9;]*m/g, "").replace(/[ \t]+$/gm, "");
15
19
  // T5: the pane identity every visible pane announces under the logo. HerdrDriver seeds this env var
16
20
  // into each pane shell at slot() time (paneIdentityLine of the pane's T1 owned name); the banner's
17
21
  // identity line reads it at pane runtime, so every role — worker/judge/review/consult — wears the
@@ -1,7 +1,9 @@
1
+ import { type ClaudeAlias } from "../../adapters/claude-code.js";
1
2
  import type { WorkerAdapter } from "../../adapters/types.js";
2
3
  import { type KimiDoctorTurnResult } from "../../adapters/kimi.js";
3
4
  export type DoctorOpts = {
4
5
  banner?: boolean;
5
6
  kimiTurnProbe?: (cwd: string) => Promise<KimiDoctorTurnResult>;
7
+ resolveClaudeAliasIdentity?: (cwd: string, alias: ClaudeAlias) => string | undefined;
6
8
  };
7
9
  export declare function doctor(_argv: string[], cwd?: string, adapters?: WorkerAdapter[], opts?: DoctorOpts): Promise<string>;
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { allAdapters, binaryShadowWarnings, detectCandidateClis, flagDriftWarnings, modelAliasExclusions, modelAliasLine, probeAll, probeModels, readAutoPrefer, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
4
+ import { CLAUDE_ALIAS_IDENTITY_STAMPS, claudeCode, resolveClaudeAliasIdentity } from "../../adapters/claude-code.js";
4
5
  import { BANNER, dim, fail, kvRow, legend, ok, rule, statusRow, title } from "../../brand.js";
5
6
  import { tickmarkrDir, stateDirName } from "../../graph/graph.js";
6
7
  import { declaredModelWindow, hasWindowsConfig, modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
@@ -120,6 +121,28 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
120
121
  // v1.65 T3: hardcoded-flag drift — advisory warn rows only. Runs AFTER writeDoctor so the verdicts
121
122
  // can never leak into doctor.json, and discoverChannels/routing never read them.
122
123
  rows.push(...flagDriftWarnings(adapters, health).map(attentionRow));
124
+ // OBS-145: resolved-identity drift is the same class of display-only doctor warning. Stamps live
125
+ // beside the alias-owning adapter; the comparison runs only for configured floating aliases and
126
+ // never enters health/doctor.json, config, channel discovery, learned profiles, or route().
127
+ const identityResolver = opts.resolveClaudeAliasIdentity
128
+ ?? (adapters.includes(claudeCode) ? resolveClaudeAliasIdentity : undefined);
129
+ if (identityResolver && health["claude-code"]?.installed) {
130
+ const configured = cfg.tiers["claude-code"]?.models ?? {};
131
+ for (const [alias, stampedIdentity] of Object.entries(CLAUDE_ALIAS_IDENTITY_STAMPS)) {
132
+ if (!(alias in configured))
133
+ continue;
134
+ let resolvedIdentity;
135
+ try {
136
+ resolvedIdentity = identityResolver(cwd, alias);
137
+ }
138
+ catch {
139
+ continue; // advisory source failure is unknown, never a doctor failure
140
+ }
141
+ if (resolvedIdentity && resolvedIdentity !== stampedIdentity) {
142
+ rows.push(attentionRow(`resolved-identity drift: claude-code:${alias} resolved to ${resolvedIdentity}, stamped identity ${stampedIdentity} — reclassify per benchmark policy (advisory — routing unchanged)`));
143
+ }
144
+ }
145
+ }
123
146
  // MODEL-05/06: print-only drift fragment; advisory, whole-line-commented additions, tickmarkr NEVER applies it.
124
147
  // TTY gets a one-line summary + the fragment as a file (the full dump drowned everything else,
125
148
  // v1.33.1 onboarding); machine/CI surface keeps the inline dump — layout is pinned by tests.
@@ -1,3 +1,4 @@
1
+ import { type DecisionEvent, type DecisionWebhookPost } from "../../drivers/types.js";
1
2
  import { type Task, type TaskStatus } from "../../graph/schema.js";
2
3
  import { type JournalEvent } from "../../run/journal.js";
3
4
  export type StatusOpts = {
@@ -5,6 +6,8 @@ export type StatusOpts = {
5
6
  sleep?: (ms: number) => Promise<void>;
6
7
  now?: () => number;
7
8
  readWorkerOutput?: (taskId: string, attempt: number, runId: string) => Promise<string | undefined>;
9
+ webhookUrl?: string;
10
+ postWebhook?: DecisionWebhookPost;
8
11
  };
9
12
  export declare const GATE_KEYS: {
10
13
  readonly build: "B";
@@ -15,6 +18,7 @@ export declare const GATE_KEYS: {
15
18
  readonly acceptance: "A";
16
19
  readonly review: "R";
17
20
  };
21
+ export declare const decisionEventsFromJournal: (events: JournalEvent[], runId: string, stateDir?: string) => DecisionEvent[];
18
22
  export declare const taskBox: (status: TaskStatus) => string;
19
23
  export declare const gateBox: (state: "open" | "pass" | "fail" | "skip", unicode: boolean) => string;
20
24
  export type GateState = "open" | "pass" | "fail" | "skip";