tickmarkr 1.45.0 → 1.47.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/dist/adapters/fake.js +2 -2
- package/dist/adapters/model-lints.d.ts +19 -0
- package/dist/adapters/model-lints.js +74 -1
- package/dist/adapters/registry.js +12 -3
- package/dist/brand.js +2 -1
- package/dist/cli/commands/approve.js +6 -7
- package/dist/cli/commands/doctor.js +6 -2
- package/dist/cli/commands/fleet.d.ts +5 -0
- package/dist/cli/commands/fleet.js +249 -0
- package/dist/cli/commands/init.js +55 -3
- package/dist/cli/commands/plan.js +7 -1
- package/dist/cli/commands/profile.js +129 -7
- package/dist/cli/commands/run.js +34 -17
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +3 -1
- package/dist/compile/collateral.js +4 -2
- package/dist/compile/native.js +16 -1
- package/dist/config/config.d.ts +42 -0
- package/dist/config/config.js +288 -1
- package/dist/drivers/herdr.d.ts +1 -0
- package/dist/drivers/herdr.js +12 -2
- package/dist/gates/acceptance.js +7 -2
- package/dist/gates/scope.d.ts +2 -1
- package/dist/gates/scope.js +13 -3
- package/dist/plan/prompt.js +1 -1
- package/dist/route/profile.d.ts +25 -1
- package/dist/route/profile.js +47 -13
- package/dist/route/router.d.ts +10 -3
- package/dist/route/router.js +81 -15
- package/dist/run/consult.d.ts +5 -0
- package/dist/run/consult.js +35 -0
- package/dist/run/daemon.js +145 -25
- package/dist/run/git.d.ts +2 -0
- package/dist/run/git.js +20 -7
- package/dist/run/journal.d.ts +39 -2
- package/dist/run/journal.js +96 -4
- package/dist/run/merge.js +8 -8
- package/package.json +1 -1
package/dist/adapters/fake.js
CHANGED
|
@@ -37,7 +37,7 @@ export class FakeAdapter {
|
|
|
37
37
|
let isJudge = false;
|
|
38
38
|
try {
|
|
39
39
|
prompt = readFileSync(promptFile, "utf8");
|
|
40
|
-
isJudge = /TICKMARKR-JUDGE/.test(prompt);
|
|
40
|
+
isJudge = /TICKMARKR-(?:JUDGE|SCOPE)/.test(prompt);
|
|
41
41
|
}
|
|
42
42
|
catch {
|
|
43
43
|
// unreadable promptFile: can't detect role; serve static values (legacy headless-call behavior)
|
|
@@ -61,7 +61,7 @@ export class FakeAdapter {
|
|
|
61
61
|
writeFileSync(p, JSON.stringify(val ?? {}, null, 1));
|
|
62
62
|
return p;
|
|
63
63
|
};
|
|
64
|
-
return `bash -c 'grep -
|
|
64
|
+
return `bash -c 'grep -Eq "TICKMARKR-(JUDGE|SCOPE)" ${shq(promptFile)} && cat ${shq(serve("judge"))}; grep -q TICKMARKR-REVIEW ${shq(promptFile)} && cat ${shq(serve("review"))}; grep -q TICKMARKR-CONSULT ${shq(promptFile)} && cat ${shq(serve("consult"))}; true'`;
|
|
65
65
|
}
|
|
66
66
|
resumeCommand(_sessionId, promptFile, model) {
|
|
67
67
|
return this.interactiveCommand(promptFile, model) ?? this.headlessCommand(promptFile, model);
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { type TickmarkrConfig } from "../config/config.js";
|
|
2
|
+
import type { Task } from "../graph/schema.js";
|
|
2
3
|
import { type AuthHealth, type WorkerAdapter } from "./types.js";
|
|
3
4
|
export declare const SEED_STAMPED = "2026-07-09";
|
|
4
5
|
export declare const MODEL_STALE_DAYS = 30;
|
|
5
6
|
export declare const ttyVisual: () => boolean;
|
|
7
|
+
/** True when any tier entry declares at least one model window. */
|
|
8
|
+
export declare function hasWindowsConfig(cfg: TickmarkrConfig): boolean;
|
|
9
|
+
export declare function declaredModelWindow(cfg: TickmarkrConfig, adapter: string, model: string): number | undefined;
|
|
10
|
+
/** Best-effort plan-time payload estimate: prompt shell + context + files[] byte sizes. */
|
|
11
|
+
export declare function estimateTaskPayloadTokens(task: Task, repoRoot: string, feedback?: string): number;
|
|
12
|
+
export type RoutedAssignment = {
|
|
13
|
+
taskId: string;
|
|
14
|
+
adapter: string;
|
|
15
|
+
model: string;
|
|
16
|
+
};
|
|
17
|
+
/** Advisory only — absent windows config or undeclared model window ⇒ no lint. */
|
|
18
|
+
export declare function contextWindowLints(tasks: ReadonlyArray<Task>, assignments: ReadonlyArray<RoutedAssignment>, cfg: TickmarkrConfig, repoRoot: string): string[];
|
|
6
19
|
export declare function seedPreferLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], overlayPreferShapes?: ReadonlySet<string>): string[];
|
|
7
20
|
export declare function modelLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
|
|
8
21
|
tty?: boolean;
|
|
@@ -15,3 +28,9 @@ export declare function formatModelAuthLine(excluded: {
|
|
|
15
28
|
probedAt: string;
|
|
16
29
|
}[], tty?: boolean, stateDir?: string): string;
|
|
17
30
|
export declare function suggestOverlay(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], stateDir?: string): string;
|
|
31
|
+
/** Unclassified models surfaced for fleet screen 2 (doctor matrix math, no tier fabrication). */
|
|
32
|
+
export declare function fleetUnclassifiedModels(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[]): {
|
|
33
|
+
adapter: string;
|
|
34
|
+
model: string;
|
|
35
|
+
detectedAt?: string;
|
|
36
|
+
}[];
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
import { DEFAULT_CONFIG, TIER_RANK } from "../config/config.js";
|
|
4
|
+
import { buildTaskPrompt } from "./prompt.js";
|
|
2
5
|
import { MODEL_ID_RE } from "./types.js";
|
|
3
|
-
// date the tiers seeds were last live-verified (Phase 6/7 reseed) — surfaced when an adapter has no list surface.
|
|
4
6
|
export const SEED_STAMPED = "2026-07-09";
|
|
5
7
|
// knowledge past this age gets a "rerun tickmarkr doctor" nudge (BLOCKED_POLL_MS-style named constant).
|
|
6
8
|
export const MODEL_STALE_DAYS = 30;
|
|
@@ -14,6 +16,57 @@ const TTY_LINT_CAP = 3;
|
|
|
14
16
|
const DEFAULT_STATE_DIR = ".tickmarkr";
|
|
15
17
|
const doctorJsonRef = (stateDir) => ` — see ${stateDir}/doctor.json`;
|
|
16
18
|
export const ttyVisual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
|
19
|
+
// ponytail: chars/4 token heuristic — good enough for advisory plan lint; no tokenizer dep.
|
|
20
|
+
const CHARS_PER_TOKEN = 4;
|
|
21
|
+
/** True when any tier entry declares at least one model window. */
|
|
22
|
+
export function hasWindowsConfig(cfg) {
|
|
23
|
+
return Object.values(cfg.tiers).some((t) => t.windows && Object.keys(t.windows).length > 0);
|
|
24
|
+
}
|
|
25
|
+
export function declaredModelWindow(cfg, adapter, model) {
|
|
26
|
+
return cfg.tiers[adapter]?.windows?.[model];
|
|
27
|
+
}
|
|
28
|
+
function fileBytes(repoRoot, rel) {
|
|
29
|
+
if (rel.includes("*") || rel.includes("?") || rel.includes("{"))
|
|
30
|
+
return 0; // glob — not measurable at plan time
|
|
31
|
+
try {
|
|
32
|
+
const p = join(repoRoot, rel);
|
|
33
|
+
if (!existsSync(p))
|
|
34
|
+
return 0;
|
|
35
|
+
return statSync(p).size;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Best-effort plan-time payload estimate: prompt shell + context + files[] byte sizes. */
|
|
42
|
+
export function estimateTaskPayloadTokens(task, repoRoot, feedback = "") {
|
|
43
|
+
let bytes = buildTaskPrompt(task, feedback).length;
|
|
44
|
+
for (const p of task.context)
|
|
45
|
+
bytes += fileBytes(repoRoot, p);
|
|
46
|
+
for (const p of task.files)
|
|
47
|
+
bytes += fileBytes(repoRoot, p);
|
|
48
|
+
return Math.ceil(bytes / CHARS_PER_TOKEN);
|
|
49
|
+
}
|
|
50
|
+
/** Advisory only — absent windows config or undeclared model window ⇒ no lint. */
|
|
51
|
+
export function contextWindowLints(tasks, assignments, cfg, repoRoot) {
|
|
52
|
+
if (!hasWindowsConfig(cfg))
|
|
53
|
+
return [];
|
|
54
|
+
const byId = new Map(assignments.map((a) => [a.taskId, a]));
|
|
55
|
+
const lints = [];
|
|
56
|
+
for (const t of tasks) {
|
|
57
|
+
const a = byId.get(t.id);
|
|
58
|
+
if (!a)
|
|
59
|
+
continue;
|
|
60
|
+
const window = declaredModelWindow(cfg, a.adapter, a.model);
|
|
61
|
+
if (window === undefined)
|
|
62
|
+
continue;
|
|
63
|
+
const est = estimateTaskPayloadTokens(t, repoRoot);
|
|
64
|
+
if (est > window) {
|
|
65
|
+
lints.push(`${t.id}: payload ~${est} tokens exceeds ${a.adapter}:${a.model} window ${window}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return lints;
|
|
69
|
+
}
|
|
17
70
|
const adapterHasAuthedChannel = (adapterId, shape, cfg, health, adapters) => {
|
|
18
71
|
const entry = cfg.routing.map[shape];
|
|
19
72
|
const minTier = entry?.tier ?? cfg.routing.floors[shape] ?? "cheap";
|
|
@@ -175,3 +228,23 @@ function referenceWarning(cfg, adapterId, model) {
|
|
|
175
228
|
refs.push("consult");
|
|
176
229
|
return refs.length ? ` # WARNING: still referenced by ${refs.join(", ")} — remap before removing` : "";
|
|
177
230
|
}
|
|
231
|
+
/** Unclassified models surfaced for fleet screen 2 (doctor matrix math, no tier fabrication). */
|
|
232
|
+
export function fleetUnclassifiedModels(cfg, health, adapters) {
|
|
233
|
+
const out = [];
|
|
234
|
+
for (const id of Object.keys(cfg.tiers)) {
|
|
235
|
+
if (!adapters.some((a) => a.id === id))
|
|
236
|
+
continue;
|
|
237
|
+
const h = health[id];
|
|
238
|
+
const detected = h?.models ?? [];
|
|
239
|
+
if (!detected.length)
|
|
240
|
+
continue;
|
|
241
|
+
const configured = new Set(Object.keys(cfg.tiers[id].models));
|
|
242
|
+
const date = h?.modelsDetectedAt?.split("T")[0];
|
|
243
|
+
for (const model of detected) {
|
|
244
|
+
if (configured.has(model) || LINT_VARIANT_RE.test(model))
|
|
245
|
+
continue;
|
|
246
|
+
out.push({ adapter: id, model, detectedAt: date });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdtempSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { overlayPreferShapes, TIER_RANK } from "../config/config.js";
|
|
@@ -178,7 +178,13 @@ async function mapLimit(items, limit, fn) {
|
|
|
178
178
|
}
|
|
179
179
|
const doctorPath = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "doctor.json");
|
|
180
180
|
function readDoctorFile(repoRoot) {
|
|
181
|
-
|
|
181
|
+
try {
|
|
182
|
+
return existsSync(doctorPath(repoRoot)) ? JSON.parse(readFileSync(doctorPath(repoRoot), "utf8")) : null;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// A torn cache is disposable: callers already re-probe when this returns null.
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
182
188
|
}
|
|
183
189
|
export function writeDoctor(repoRoot, health) {
|
|
184
190
|
tickmarkrDir(repoRoot);
|
|
@@ -187,7 +193,10 @@ export function writeDoctor(repoRoot, health) {
|
|
|
187
193
|
delete payload[pendingAutoPreferKey];
|
|
188
194
|
if (pending)
|
|
189
195
|
payload.autoPrefer = pending;
|
|
190
|
-
|
|
196
|
+
const path = doctorPath(repoRoot);
|
|
197
|
+
const tmp = `${path}.tmp`;
|
|
198
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2) + "\n");
|
|
199
|
+
renameSync(tmp, path);
|
|
191
200
|
}
|
|
192
201
|
export function readDoctor(repoRoot) {
|
|
193
202
|
const raw = readDoctorFile(repoRoot);
|
package/dist/brand.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { shq } from "./adapters/types.js";
|
|
1
2
|
// TTY-only pixel-tick logo (assets/mark.svg is the image twin — same block geometry).
|
|
2
3
|
// Never printed to pipes — the non-TTY stdout surface is byte-pinned by tests and consumed by machines.
|
|
3
4
|
const B = "\x1b[1m", R = "\x1b[0m";
|
|
@@ -26,5 +27,5 @@ export function paneDispatchScript(body) {
|
|
|
26
27
|
}
|
|
27
28
|
/** OBS-50: one short herdr pane-run line; bootstrap lives in the script file beside the prompt. */
|
|
28
29
|
export function paneDispatchCommand(scriptPath) {
|
|
29
|
-
return `bash ${
|
|
30
|
+
return `bash ${shq(scriptPath)}`;
|
|
30
31
|
}
|
|
@@ -6,10 +6,10 @@ import { ATTEMPT_CAP_RELEASE, Journal } from "../../run/journal.js";
|
|
|
6
6
|
// append-only journal. Writing it into tickmarkr's compiled graph artifact would be silently erased by
|
|
7
7
|
// the next recompile (which re-emits humanGate:true from the plan frontmatter) — Phase 42 D-02.
|
|
8
8
|
//
|
|
9
|
-
// v1.24 OBS-18: when the park
|
|
10
|
-
// carries `release: "attempt-cap"`. replayResumeState zeros the attempt budget on that marker so
|
|
11
|
-
// resume dispatches instead of re-parking in the same tick; tried-list is preserved.
|
|
12
|
-
//
|
|
9
|
+
// v1.24 OBS-18: when the park kind is attempt-cap (not a humanGate pre-dispatch park), the event
|
|
10
|
+
// also carries `release: "attempt-cap"`. replayResumeState zeros the attempt budget on that marker so
|
|
11
|
+
// resume dispatches instead of re-parking in the same tick; tried-list is preserved. Unknown kinds
|
|
12
|
+
// receive no release and remain fail-closed to a human rather than being inferred from prose.
|
|
13
13
|
//
|
|
14
14
|
// Fail-closed (D-05): unknown runId, unknown taskId, a not-parked task, and a double-approve are all
|
|
15
15
|
// LOUD refusals that name the reason and append NO event — never a silent no-op. A handler throw
|
|
@@ -30,10 +30,9 @@ export async function approve(argv, cwd = process.cwd()) {
|
|
|
30
30
|
throw new Error(`task ${taskId} is ${status}, not a parked human gate — refusing (a silent no-op would be worse)`);
|
|
31
31
|
}
|
|
32
32
|
// OBS-18: only the most recent task-human for this task decides whether this approval grants a
|
|
33
|
-
// fresh attempt budget.
|
|
33
|
+
// fresh attempt budget. The closed daemon-issued kind, never a human prose string, controls release.
|
|
34
34
|
const lastHuman = journal.read().filter((e) => e.event === "task-human" && e.taskId === taskId).at(-1);
|
|
35
|
-
const capPark =
|
|
36
|
-
&& /attempt cap \(\d+\) reached/.test(lastHuman.data.reason);
|
|
35
|
+
const capPark = lastHuman?.data.kind === ATTEMPT_CAP_RELEASE;
|
|
37
36
|
journal.append("task-approved", taskId, {
|
|
38
37
|
by,
|
|
39
38
|
...(reason ? { reason } : {}),
|
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { allAdapters, probeAll, probeModels, readAutoPrefer, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
|
|
4
4
|
import { BANNER } from "../../brand.js";
|
|
5
5
|
import { tickmarkrDir, stateDirName } from "../../graph/graph.js";
|
|
6
|
-
import { modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
|
|
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";
|
|
@@ -121,6 +121,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
121
121
|
// per adapter — they aren't routable, so they don't earn table rows.
|
|
122
122
|
const trunc = (s, n) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`);
|
|
123
123
|
const dateOf = (iso) => iso.slice(0, 10);
|
|
124
|
+
const showWindows = hasWindowsConfig(cfg);
|
|
124
125
|
const modelStatus = adapters.flatMap((a) => {
|
|
125
126
|
const h = health[a.id];
|
|
126
127
|
if (!h?.installed)
|
|
@@ -138,7 +139,10 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
138
139
|
const d = disallowedBy({ adapter: a.id, model: m }, cfg.routing);
|
|
139
140
|
const denied = d?.by === "deny" ? d.entry : "—";
|
|
140
141
|
const pref = preferRanks({ adapter: a.id, model: m }, cfg).map((p) => `${p.shape}#${p.rank}`).join(",") || "—";
|
|
141
|
-
|
|
142
|
+
const windowCol = showWindows
|
|
143
|
+
? ` ${String(declaredModelWindow(cfg, a.id, m) ?? "—").padEnd(8)}`
|
|
144
|
+
: "";
|
|
145
|
+
rows.push(` ${m.padEnd(w)} ${classified[m].padEnd(8)}${windowCol} ${auth} denied=${denied} prefer=${pref}`);
|
|
142
146
|
}
|
|
143
147
|
if (unclassified.length)
|
|
144
148
|
rows.push(` (${unclassified.length} more listed, unclassified)`);
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, readAutoPrefer } from "../../adapters/registry.js";
|
|
6
|
+
import { fleetUnclassifiedModels } from "../../adapters/model-lints.js";
|
|
7
|
+
import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, formatFleetPrint, globalConfigDir, loadConfig, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, unifiedYamlDiff, } from "../../config/config.js";
|
|
8
|
+
import { SHAPES, TIERS } from "../../graph/schema.js";
|
|
9
|
+
import { route } from "../../route/router.js";
|
|
10
|
+
import { loadRoutingProfile } from "../../run/journal.js";
|
|
11
|
+
const NON_TTY_MSG = "tickmarkr fleet: interactive fleet editor requires a TTY — use `tickmarkr fleet --print` for non-interactive output";
|
|
12
|
+
const isDeniedAdapter = (id, editable) => editable.denyAdapters.includes(id);
|
|
13
|
+
const isDeniedModel = (adapter, model, editable) => editable.denyModels.includes(`${adapter}:${model}`);
|
|
14
|
+
function previewTask(shape) {
|
|
15
|
+
return {
|
|
16
|
+
id: "fleet-preview",
|
|
17
|
+
title: "fleet preview",
|
|
18
|
+
goal: "preview",
|
|
19
|
+
shape,
|
|
20
|
+
complexity: 3,
|
|
21
|
+
acceptance: ["done"],
|
|
22
|
+
deps: [],
|
|
23
|
+
files: [],
|
|
24
|
+
context: [],
|
|
25
|
+
gates: ["build", "test", "lint", "evidence", "scope", "acceptance", "review"],
|
|
26
|
+
humanGate: false,
|
|
27
|
+
status: "pending",
|
|
28
|
+
evidence: { commits: [], artifacts: [], gateResults: [] },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function formatAge(ageMs) {
|
|
32
|
+
if (ageMs === null)
|
|
33
|
+
return "no probe data";
|
|
34
|
+
const mins = Math.floor(ageMs / 60_000);
|
|
35
|
+
if (mins < 60)
|
|
36
|
+
return `${mins}m old`;
|
|
37
|
+
return `${Math.floor(mins / 60)}h old`;
|
|
38
|
+
}
|
|
39
|
+
function currentRepoOverlayText(repoRoot) {
|
|
40
|
+
const p = repoOverlayPath(repoRoot);
|
|
41
|
+
return existsSync(p) ? readFileSync(p, "utf8") : "";
|
|
42
|
+
}
|
|
43
|
+
function provenanceMap(editable) {
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const [adapter, models] of Object.entries(editable.tiers)) {
|
|
46
|
+
for (const [model, v] of Object.entries(models)) {
|
|
47
|
+
if (v?.provenance) {
|
|
48
|
+
out[adapter] ??= {};
|
|
49
|
+
out[adapter][model] = v.provenance;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
function proposedOverlayText(initial, editable, repoRoot) {
|
|
56
|
+
const merged = fleetRepoOverlayFromDelta(initial, editable, readOverlayFile(repoOverlayPath(repoRoot)));
|
|
57
|
+
return repoOverlayYaml(merged, provenanceMap(editable));
|
|
58
|
+
}
|
|
59
|
+
async function ask(rl, prompt) {
|
|
60
|
+
return (await rl.question(prompt)).trim();
|
|
61
|
+
}
|
|
62
|
+
export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters()) {
|
|
63
|
+
const { values } = parseArgs({
|
|
64
|
+
args: argv,
|
|
65
|
+
options: {
|
|
66
|
+
print: { type: "boolean" },
|
|
67
|
+
"global-dir": { type: "string" },
|
|
68
|
+
fresh: { type: "boolean" },
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
const globalDir = values["global-dir"] ?? globalConfigDir();
|
|
72
|
+
const print = values.print ?? false;
|
|
73
|
+
const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
74
|
+
if (print)
|
|
75
|
+
return formatFleetPrint(cwd, { globalDir });
|
|
76
|
+
if (!interactive)
|
|
77
|
+
return { out: NON_TTY_MSG, code: 1 };
|
|
78
|
+
const fresh = values.fresh ?? false;
|
|
79
|
+
const { reuse, health: cached } = initDoctorReuse(cwd, fresh);
|
|
80
|
+
if (!reuse || !cached) {
|
|
81
|
+
return {
|
|
82
|
+
out: "tickmarkr fleet: probe data missing or stale — run `tickmarkr doctor` first (fleet never re-probes; doctor is the sensor)",
|
|
83
|
+
code: 1,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const cfg = loadConfig(cwd, { globalDir });
|
|
87
|
+
const initial = fleetEditableFromConfig(cfg);
|
|
88
|
+
const editable = structuredClone(initial);
|
|
89
|
+
const health = cached;
|
|
90
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
91
|
+
try {
|
|
92
|
+
// screen 0 — sensor freshness
|
|
93
|
+
const ageLine = formatAge(doctorAgeMs(cwd));
|
|
94
|
+
let step = await ask(rl, `tickmarkr fleet — scope your fleet\n probe data: ${ageLine} (.tickmarkr/doctor.json)\n [Enter] use it [r] refresh via doctor [q] quit\n> `);
|
|
95
|
+
if (/^q$/i.test(step))
|
|
96
|
+
return "fleet: quit without writing";
|
|
97
|
+
if (/^r$/i.test(step)) {
|
|
98
|
+
return "fleet: run `tickmarkr doctor` to refresh probe data, then re-run `tickmarkr fleet` (doctor is the sensor; fleet never re-probes)";
|
|
99
|
+
}
|
|
100
|
+
// screen 1 — adapter membership (deny adapters)
|
|
101
|
+
const installed = adapters.filter((a) => health[a.id]?.installed);
|
|
102
|
+
const adapterLines = installed.map((a, i) => {
|
|
103
|
+
const h = health[a.id];
|
|
104
|
+
const on = !isDeniedAdapter(a.id, editable);
|
|
105
|
+
const mark = on ? "x" : " ";
|
|
106
|
+
const auth = h?.authed ? "authed" : "unauthed";
|
|
107
|
+
return ` ${i + 1}. [${mark}] ${a.id} ${h?.version ?? "installed"} ${auth}`;
|
|
108
|
+
});
|
|
109
|
+
step = await ask(rl, `screen 1/4 — agent CLIs [number]=toggle [Enter]=next\n${adapterLines.join("\n")}\n> `);
|
|
110
|
+
if (/^q$/i.test(step))
|
|
111
|
+
return "fleet: quit without writing";
|
|
112
|
+
if (step && step !== "") {
|
|
113
|
+
const n = Number.parseInt(step, 10);
|
|
114
|
+
if (Number.isInteger(n) && n >= 1 && n <= installed.length) {
|
|
115
|
+
const id = installed[n - 1].id;
|
|
116
|
+
const idx = editable.denyAdapters.indexOf(id);
|
|
117
|
+
if (idx === -1)
|
|
118
|
+
editable.denyAdapters.push(id);
|
|
119
|
+
else
|
|
120
|
+
editable.denyAdapters.splice(idx, 1);
|
|
121
|
+
editable.denyAdapters.sort();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// screen 2 — models per enabled adapter
|
|
125
|
+
const enabledAdapters = installed.filter((a) => !isDeniedAdapter(a.id, editable));
|
|
126
|
+
for (const a of enabledAdapters) {
|
|
127
|
+
const classified = editable.tiers[a.id] ?? {};
|
|
128
|
+
const models = Object.keys(classified);
|
|
129
|
+
const unclassified = fleetUnclassifiedModels(cfg, health, adapters).filter((u) => u.adapter === a.id);
|
|
130
|
+
const rows = [];
|
|
131
|
+
let row = 1;
|
|
132
|
+
const indexFor = new Map();
|
|
133
|
+
for (const m of models) {
|
|
134
|
+
const tier = classified[m];
|
|
135
|
+
const denied = isDeniedModel(a.id, m, editable) ? "deny" : "allow";
|
|
136
|
+
const mark = denied === "deny" ? " " : "x";
|
|
137
|
+
rows.push(` ${row}. [${mark}] ${m} ${tier?.tier ?? "???"} ${denied === "deny" ? "denied" : "allowed"}`);
|
|
138
|
+
indexFor.set(String(row), { kind: "classified", model: m });
|
|
139
|
+
row++;
|
|
140
|
+
}
|
|
141
|
+
for (const u of unclassified) {
|
|
142
|
+
rows.push(` ${row}. ( ) ${u.model} ??? unclassified${u.detectedAt ? ` (${u.detectedAt})` : ""}`);
|
|
143
|
+
indexFor.set(String(row), { kind: "unclassified", model: u.model });
|
|
144
|
+
row++;
|
|
145
|
+
}
|
|
146
|
+
step = await ask(rl, `screen 2/4 — models · ${a.id} [number]=toggle deny [t <n> <tier>]=assign tier [Enter]=next\n${rows.join("\n")}\n> `);
|
|
147
|
+
if (/^q$/i.test(step))
|
|
148
|
+
return "fleet: quit without writing";
|
|
149
|
+
if (step.startsWith("t ")) {
|
|
150
|
+
const [, numRaw, tierRaw] = step.split(/\s+/);
|
|
151
|
+
const tier = tierRaw;
|
|
152
|
+
if (!indexFor.has(numRaw) || !TIERS.includes(tier)) {
|
|
153
|
+
return { out: "fleet: tier assignment requires a valid model number and tier (cheap|mid|frontier)", code: 1 };
|
|
154
|
+
}
|
|
155
|
+
const target = indexFor.get(numRaw);
|
|
156
|
+
if (target.kind !== "unclassified") {
|
|
157
|
+
return { out: "fleet: tier reassignment on classified models is not supported in v1 — edit config directly", code: 1 };
|
|
158
|
+
}
|
|
159
|
+
const note = await ask(rl, "benchmark provenance note (required): ");
|
|
160
|
+
if (!note.trim())
|
|
161
|
+
return { out: "fleet: assigning a tier to an unclassified model requires a typed benchmark-provenance note", code: 1 };
|
|
162
|
+
editable.tiers[a.id] ??= {};
|
|
163
|
+
editable.tiers[a.id][target.model] = { tier, provenance: note.trim() };
|
|
164
|
+
}
|
|
165
|
+
else if (step && /^\d+$/.test(step)) {
|
|
166
|
+
const target = indexFor.get(step);
|
|
167
|
+
if (target?.kind === "classified") {
|
|
168
|
+
const key = `${a.id}:${target.model}`;
|
|
169
|
+
const idx = editable.denyModels.indexOf(key);
|
|
170
|
+
if (idx === -1)
|
|
171
|
+
editable.denyModels.push(key);
|
|
172
|
+
else
|
|
173
|
+
editable.denyModels.splice(idx, 1);
|
|
174
|
+
editable.denyModels.sort();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// screen 3 — per-shape routing
|
|
179
|
+
const channels = discoverChannels(cfg, adapters, health);
|
|
180
|
+
const profile = loadRoutingProfile(cwd, cfg, { preview: true });
|
|
181
|
+
const autoPrefer = readAutoPrefer(cwd);
|
|
182
|
+
const overlayShapes = overlayPreferShapes(cwd, { globalDir });
|
|
183
|
+
for (let si = 0; si < SHAPES.length; si++) {
|
|
184
|
+
const shape = SHAPES[si];
|
|
185
|
+
const entry = editable.map[shape] ?? {};
|
|
186
|
+
let now = "";
|
|
187
|
+
try {
|
|
188
|
+
const r = route(previewTask(shape), { ...cfg, routing: { ...cfg.routing, map: editable.map, floors: editable.floors } }, channels, profile);
|
|
189
|
+
now = `${r.provenance}\n → ${r.assignment.adapter}:${r.assignment.model} (${r.assignment.channel}, ${r.assignment.tier})`;
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
now = e.message;
|
|
193
|
+
}
|
|
194
|
+
const autoNote = autoPrefer?.[shape] && !overlayShapes.has(shape)
|
|
195
|
+
? `\n auto-prefer active — explicit prefer overrides it`
|
|
196
|
+
: "";
|
|
197
|
+
step = await ask(rl, `screen 3/4 — shape routing · ${shape} (${si + 1}/${SHAPES.length})\n now: ${now}\n [1] auto within tier [2] pin channel [3] edit tier [4] edit prefer [Enter]=keep${autoNote}\n> `);
|
|
198
|
+
if (/^q$/i.test(step))
|
|
199
|
+
return "fleet: quit without writing";
|
|
200
|
+
if (!step)
|
|
201
|
+
continue;
|
|
202
|
+
if (step === "1") {
|
|
203
|
+
const next = { ...entry };
|
|
204
|
+
delete next.pin;
|
|
205
|
+
editable.map[shape] = next;
|
|
206
|
+
}
|
|
207
|
+
else if (step === "2") {
|
|
208
|
+
const pin = await ask(rl, "pin adapter:model> ");
|
|
209
|
+
const i = pin.indexOf(":");
|
|
210
|
+
if (i < 1)
|
|
211
|
+
return { out: "fleet: pin must be adapter:model", code: 1 };
|
|
212
|
+
editable.map[shape] = { pin: { via: pin.slice(0, i), model: pin.slice(i + 1) } };
|
|
213
|
+
}
|
|
214
|
+
else if (step === "3") {
|
|
215
|
+
const tier = await ask(rl, `tier (${TIERS.join("|")})> `);
|
|
216
|
+
if (!TIERS.includes(tier))
|
|
217
|
+
return { out: `fleet: invalid tier ${tier}`, code: 1 };
|
|
218
|
+
const next = { ...entry, tier };
|
|
219
|
+
delete next.pin;
|
|
220
|
+
editable.map[shape] = next;
|
|
221
|
+
}
|
|
222
|
+
else if (step === "4") {
|
|
223
|
+
const pref = await ask(rl, "prefer (comma-separated adapters or adapter:model)> ");
|
|
224
|
+
const prefer = pref.split(",").map((s) => s.trim()).filter(Boolean);
|
|
225
|
+
editable.map[shape] = { ...entry, prefer };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// review — unified diff + confirm
|
|
229
|
+
const before = currentRepoOverlayText(cwd);
|
|
230
|
+
const after = proposedOverlayText(initial, editable, cwd);
|
|
231
|
+
if (fleetEditableEquals(initial, editable) || before === after) {
|
|
232
|
+
return "fleet: no overlay changes (empty diff)";
|
|
233
|
+
}
|
|
234
|
+
const diff = unifiedYamlDiff(before, after, repoOverlayPath(cwd));
|
|
235
|
+
process.stdout.write(`\n${diff}\n`);
|
|
236
|
+
const confirm = await ask(rl, "write overlay? [y/N] ");
|
|
237
|
+
if (!/^y(?:es)?$/i.test(confirm))
|
|
238
|
+
return "fleet: discarded overlay changes";
|
|
239
|
+
const merged = fleetRepoOverlayFromDelta(initial, editable, readOverlayFile(repoOverlayPath(cwd)));
|
|
240
|
+
const body = repoOverlayYaml(merged, provenanceMap(editable));
|
|
241
|
+
const path = repoOverlayPath(cwd);
|
|
242
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
243
|
+
writeFileSync(path, body);
|
|
244
|
+
return `fleet: wrote ${path}`;
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
rl.close();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
@@ -7,7 +7,59 @@ import { configTemplate, DEFAULT_CONFIG, globalConfigDir, loadConfig } from "../
|
|
|
7
7
|
import { LEGACY_PREFIX, specTemplate } from "../../compile/native.js";
|
|
8
8
|
import { BANNER } from "../../brand.js";
|
|
9
9
|
import { tickmarkrDir } from "../../graph/graph.js";
|
|
10
|
+
import { Journal } from "../../run/journal.js";
|
|
10
11
|
import { doctor } from "./doctor.js";
|
|
12
|
+
const SCAFFOLD_SPEC = "tickmarkr.spec.md";
|
|
13
|
+
// Operator-approved (2026-07-17) environments footer — three rows; no npm install for herdr
|
|
14
|
+
// (npm package "herdr" is a reserved 0.0.0 placeholder as of that date).
|
|
15
|
+
const ENVIRONMENTS_FOOTER = [
|
|
16
|
+
"environments:",
|
|
17
|
+
" herdr — the full cockpit — every worker, judge, and consult is a visible pane you can watch and unblock · https://herdr.dev",
|
|
18
|
+
" claude code — tickmarkr init --agent installs the /tkr skills + AGENTS.md so Claude Code (or any agent CLI) drives the loop natively",
|
|
19
|
+
" anywhere — no herdr? same fail-closed gates, headless subprocess driver",
|
|
20
|
+
].join("\n");
|
|
21
|
+
/** Latest journal without a run-end event, if any. */
|
|
22
|
+
function activeRunId(cwd) {
|
|
23
|
+
const runId = Journal.latestRunId(cwd, { withJournal: true });
|
|
24
|
+
if (!runId)
|
|
25
|
+
return null;
|
|
26
|
+
try {
|
|
27
|
+
const events = Journal.open(cwd, runId).read();
|
|
28
|
+
if (events.some((e) => e.event === "run-end"))
|
|
29
|
+
return null;
|
|
30
|
+
return runId;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Relative paths of specs/*.spec.md already in the repo. */
|
|
37
|
+
function existingSpecs(cwd) {
|
|
38
|
+
const dir = join(cwd, "specs");
|
|
39
|
+
if (!existsSync(dir))
|
|
40
|
+
return [];
|
|
41
|
+
try {
|
|
42
|
+
return readdirSync(dir)
|
|
43
|
+
.filter((f) => f.endsWith(".spec.md"))
|
|
44
|
+
.sort()
|
|
45
|
+
.map((f) => `specs/${f}`);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Context-aware next-steps line (operator-approved 2026-07-17). */
|
|
52
|
+
function nextSteps(cwd, scaffoldedSpec) {
|
|
53
|
+
const runId = activeRunId(cwd);
|
|
54
|
+
if (runId)
|
|
55
|
+
return `run ${runId} active — tickmarkr status`;
|
|
56
|
+
const specs = existingSpecs(cwd);
|
|
57
|
+
if (specs.length > 0) {
|
|
58
|
+
const listed = specs.length <= 3 ? specs.join(", ") : `${specs.slice(0, 3).join(", ")}, …`;
|
|
59
|
+
return `next: existing specs under specs/ (${listed}) — tickmarkr compile <spec> && tickmarkr plan && tickmarkr run`;
|
|
60
|
+
}
|
|
61
|
+
return `next: edit ${scaffoldedSpec}, then tickmarkr compile ${scaffoldedSpec} && tickmarkr plan && tickmarkr run`;
|
|
62
|
+
}
|
|
11
63
|
const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
|
12
64
|
const AGENT_SKILLS = ["tickmarkr-loop", "tickmarkr-auto"];
|
|
13
65
|
const DOCS_BEGIN = "<!-- tickmarkr:agent-docs begin -->";
|
|
@@ -173,7 +225,7 @@ export async function init(argv, cwd = process.cwd()) {
|
|
|
173
225
|
const repoConfigExists = existsSync(repoConfigPath);
|
|
174
226
|
if (repoConfigExists)
|
|
175
227
|
notes.push(`kept existing ${repoConfigPath}`);
|
|
176
|
-
const specPath = join(cwd,
|
|
228
|
+
const specPath = join(cwd, SCAFFOLD_SPEC);
|
|
177
229
|
if (existsSync(specPath)) {
|
|
178
230
|
notes.push(`kept existing ${specPath}`);
|
|
179
231
|
}
|
|
@@ -210,7 +262,7 @@ export async function init(argv, cwd = process.cwd()) {
|
|
|
210
262
|
}
|
|
211
263
|
if (values.agent)
|
|
212
264
|
await installAgentFiles(cwd, values.force ?? false, values.docs ?? false, notes);
|
|
213
|
-
const body = `${notes.join("\n")}\n${doc}\
|
|
265
|
+
const body = `${notes.join("\n")}\n${doc}\n${nextSteps(cwd, SCAFFOLD_SPEC)}\n${ENVIRONMENTS_FOOTER}`;
|
|
214
266
|
if (visual() && !bannerEmitted)
|
|
215
267
|
return BANNER + body;
|
|
216
268
|
return body;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { allAdapters, discoverChannels, doctorAgeMs, modelAuthExclusions, probeAll, readDoctor, servableExclusions, servabilityLine } from "../../adapters/registry.js";
|
|
2
|
-
import { formatModelAuthLine, modelLints, ttyVisual } from "../../adapters/model-lints.js";
|
|
2
|
+
import { formatModelAuthLine, contextWindowLints, modelLints, ttyVisual } from "../../adapters/model-lints.js";
|
|
3
3
|
import { collateralLints } from "../../compile/collateral.js";
|
|
4
4
|
import { loadConfig } from "../../config/config.js";
|
|
5
5
|
import { loadGraph } from "../../graph/graph.js";
|
|
@@ -71,9 +71,11 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
71
71
|
lints.push("review: no cross-vendor reviewer pair in fleet — set review.required: false to waive");
|
|
72
72
|
}
|
|
73
73
|
let cost = 0;
|
|
74
|
+
const routed = [];
|
|
74
75
|
for (const t of g.tasks) {
|
|
75
76
|
try {
|
|
76
77
|
const r = route(t, cfg, channels, profile);
|
|
78
|
+
routed.push({ taskId: t.id, adapter: r.assignment.adapter, model: r.assignment.model });
|
|
77
79
|
for (const l of r.lints) {
|
|
78
80
|
const suf = exclusionReason(l);
|
|
79
81
|
lints.push(suf ? `${l}${suf}` : l);
|
|
@@ -104,6 +106,10 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
104
106
|
}
|
|
105
107
|
if (lints.length)
|
|
106
108
|
lines.push("", "routing lints:", ...lints.map((l) => ` ! ${l}`));
|
|
109
|
+
// v1.47 T3: advisory only — never blocks plan or --route-strict (run refuses on route().lints only)
|
|
110
|
+
const windowLints = contextWindowLints(g.tasks, routed, cfg, cwd);
|
|
111
|
+
if (windowLints.length)
|
|
112
|
+
lines.push("", "context window lints:", ...windowLints.map((l) => ` ! ${l}`));
|
|
107
113
|
// OBS-12/13/14/21: advisory only — never blocks --route-strict (run refuses on routing lints only)
|
|
108
114
|
const scopeLints = collateralLints(g.tasks, cwd);
|
|
109
115
|
if (scopeLints.length)
|