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 +44 -4
- package/dist/brand.d.ts +62 -0
- package/dist/brand.js +74 -2
- package/dist/cli/commands/doctor.js +33 -46
- package/dist/cli/commands/fleet.d.ts +15 -1
- package/dist/cli/commands/fleet.js +215 -91
- package/dist/cli/commands/init.js +22 -2
- package/dist/cli/commands/plan.js +18 -1
- package/dist/cli/commands/report.js +13 -1
- package/dist/cli/commands/run.d.ts +2 -0
- package/dist/cli/commands/run.js +17 -1
- package/dist/cli/commands/status.js +38 -41
- package/dist/drivers/herdr.js +6 -2
- package/dist/run/daemon.js +6 -2
- package/package.json +1 -1
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`,
|
|
55
|
-
`
|
|
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
|
|
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
|
-
//
|
|
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,
|
|
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
|
|
13
|
-
|
|
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
|
|
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(
|
|
70
|
-
|
|
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
|
|
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(
|
|
64
|
+
rows.push(alignedStatusRow("pass", a.id, "trust: trusted"));
|
|
85
65
|
else if (v.status === "seeded")
|
|
86
|
-
rows.push(
|
|
66
|
+
rows.push(alignedStatusRow("pass", a.id, "trust: seeded"));
|
|
87
67
|
else
|
|
88
|
-
rows.push(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 = `\
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
? `\
|
|
153
|
+
? `\n${legend("model status:")}\n${[...modelStatus, ...preferStatus].join("\n")}`
|
|
170
154
|
: "";
|
|
171
|
-
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
173
|
+
const term = openTerm(input, output);
|
|
91
174
|
try {
|
|
92
|
-
//
|
|
175
|
+
// step 1/4 — probe data
|
|
93
176
|
const ageLine = formatAge(doctorAgeMs(cwd));
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
//
|
|
188
|
+
// step 2/4 — agent CLIs (space toggles adapter deny)
|
|
101
189
|
const installed = adapters.filter((a) => health[a.id]?.installed);
|
|
102
|
-
const
|
|
190
|
+
const adapterRows = () => installed.map((a) => {
|
|
103
191
|
const h = health[a.id];
|
|
104
192
|
const on = !isDeniedAdapter(a.id, editable);
|
|
105
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
115
|
-
|
|
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
|
-
//
|
|
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
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
184
|
-
|
|
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.
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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 (
|
|
208
|
-
const pin = await
|
|
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 (
|
|
215
|
-
const tier = await
|
|
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 (
|
|
223
|
-
const pref = await
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
236
|
-
const confirm = await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
23
|
-
const taskBox = (status
|
|
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
|
-
|
|
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
|
|
64
|
-
const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) =>
|
|
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
|
|
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
|
|
175
|
-
return [header,
|
|
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:
|
|
178
|
-
//
|
|
179
|
-
// zero display width and would corrupt
|
|
180
|
-
|
|
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 ?
|
|
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/,
|
|
188
|
-
.replace(/\bfinished\b/,
|
|
189
|
-
.replace(/\balive\b/,
|
|
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 = ` ${
|
|
187
|
+
const header = ` ${title(runId ? `run ${runId}` : "tickmarkr")}${dot}` +
|
|
193
188
|
(runId
|
|
194
|
-
?
|
|
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 ?
|
|
197
|
-
const
|
|
198
|
-
const
|
|
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
|
|
208
|
-
|
|
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 ` ${
|
|
206
|
+
return ` ${statusRow(taskVerdict(st), `${t.id.padEnd(idW)} ${goal} ${gateChain(states, true)} ${statusCell} ${dim(assignCol)}`)}`;
|
|
211
207
|
});
|
|
212
|
-
return [header,
|
|
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
|
|
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)
|
package/dist/drivers/herdr.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/run/daemon.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|