tickmarkr 2.5.0 → 2.5.1
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.d.ts +1 -0
- package/dist/adapters/fake.js +2 -1
- package/dist/adapters/opencode.d.ts +1 -0
- package/dist/adapters/opencode.js +4 -1
- package/dist/adapters/types.d.ts +14 -0
- package/dist/adapters/types.js +25 -0
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.js +45 -5
- package/dist/cli/commands/plan.js +58 -6
- package/dist/cli/commands/report.js +3 -1
- package/dist/cli/commands/run.js +3 -0
- package/dist/gates/llm.d.ts +5 -0
- package/dist/gates/llm.js +188 -3
- package/dist/gates/review.d.ts +6 -3
- package/dist/gates/review.js +79 -33
- package/dist/gates/run-gates.d.ts +3 -0
- package/dist/gates/run-gates.js +14 -4
- package/dist/run/daemon.d.ts +2 -1
- package/dist/run/daemon.js +150 -36
- package/dist/tui/ink/init-app.d.ts +4 -0
- package/dist/tui/ink/init-app.js +18 -7
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +13 -2
package/dist/adapters/fake.d.ts
CHANGED
package/dist/adapters/fake.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { parseWorkerResult } from "./prompt.js";
|
|
4
|
-
import { shq, TokenUsageSchema } from "./types.js";
|
|
4
|
+
import { OPENCODE_BUSY_FRAME_MARKERS, shq, TokenUsageSchema } from "./types.js";
|
|
5
5
|
// ponytail: deterministic scripted adapter — the whole integration suite runs on it, zero tokens.
|
|
6
6
|
export class FakeAdapter {
|
|
7
7
|
scriptPath;
|
|
8
8
|
id = "fake";
|
|
9
9
|
vendor = "fake-a";
|
|
10
|
+
busyFrameMarkers = OPENCODE_BUSY_FRAME_MARKERS;
|
|
10
11
|
// v1.89 T1 / OBS-414: the scripted adapter runs `bash -c` — it has no TUI, so no prompt of any
|
|
11
12
|
// kind can render. Daemon tests that exercise the auto-answer seam assign a captured declaration
|
|
12
13
|
// to this field explicitly; that assignment is the falsifier for the claim made here.
|
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { probeVersion } from "./claude-code.js";
|
|
6
6
|
import { parseWorkerResult } from "./prompt.js";
|
|
7
|
-
import { channelsFromConfig, MODEL_ID_RE, shq, TokenUsageSchema } from "./types.js";
|
|
7
|
+
import { channelsFromConfig, MODEL_ID_RE, OPENCODE_BUSY_FRAME_MARKERS, shq, TokenUsageSchema } from "./types.js";
|
|
8
8
|
// v1.5 MODEL-01: pure parser for `opencode models` (one provider/model id per line, no header).
|
|
9
9
|
// Live-verified 2026-07-10, opencode 1.17.15. Lines are already the seed id format; drop blanks.
|
|
10
10
|
export function parseOpencodeModels(raw) {
|
|
@@ -13,9 +13,12 @@ export function parseOpencodeModels(raw) {
|
|
|
13
13
|
.filter((l) => l.length > 0)
|
|
14
14
|
.filter((id) => MODEL_ID_RE.test(id));
|
|
15
15
|
}
|
|
16
|
+
// Keep the adapter export available without making the fake adapter import probe code.
|
|
17
|
+
export { OPENCODE_BUSY_FRAME_MARKERS } from "./types.js";
|
|
16
18
|
export const opencode = {
|
|
17
19
|
id: "opencode",
|
|
18
20
|
vendor: "mixed",
|
|
21
|
+
busyFrameMarkers: OPENCODE_BUSY_FRAME_MARKERS,
|
|
19
22
|
probeCwd: "neutral",
|
|
20
23
|
probe: async () => probeVersion("opencode"),
|
|
21
24
|
channels: (cfg) => channelsFromConfig("opencode", cfg),
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -150,6 +150,19 @@ export declare function matchesInputBox(paneText: string, inputBox: InputBox): b
|
|
|
150
150
|
export declare function matchesEmptyInputBox(paneText: string, inputBox: InputBox): boolean;
|
|
151
151
|
export declare function matchesOccupiedInputBox(paneText: string, inputBox: InputBox): boolean;
|
|
152
152
|
export declare function missingInputStateDeclarations(inputBox: InputBox | undefined): string[];
|
|
153
|
+
export interface BusyFrameMarker {
|
|
154
|
+
name: string;
|
|
155
|
+
match(paneText: string): boolean;
|
|
156
|
+
}
|
|
157
|
+
export declare const OPENCODE_BUSY_FRAME_MARKERS: readonly BusyFrameMarker[];
|
|
158
|
+
export declare class SettledTrailerTracker {
|
|
159
|
+
private readonly markers;
|
|
160
|
+
private idleSamples;
|
|
161
|
+
readonly busyMarkersSeen: Set<string>;
|
|
162
|
+
settled: boolean;
|
|
163
|
+
constructor(markers: readonly BusyFrameMarker[]);
|
|
164
|
+
sample(paneText: string, trailer: boolean): boolean;
|
|
165
|
+
}
|
|
153
166
|
export interface WorkerAdapter {
|
|
154
167
|
id: string;
|
|
155
168
|
vendor: string;
|
|
@@ -174,6 +187,7 @@ export interface WorkerAdapter {
|
|
|
174
187
|
trust?(repoRoot: string): TrustVerdict;
|
|
175
188
|
trustDialog: TrustDialog;
|
|
176
189
|
inputBox?: InputBox;
|
|
190
|
+
busyFrameMarkers?: readonly BusyFrameMarker[];
|
|
177
191
|
hardcodedFlags?: {
|
|
178
192
|
binary: string;
|
|
179
193
|
flags: string[];
|
package/dist/adapters/types.js
CHANGED
|
@@ -207,6 +207,31 @@ export function missingInputStateDeclarations(inputBox) {
|
|
|
207
207
|
missing.push("occupiedMatch");
|
|
208
208
|
return missing;
|
|
209
209
|
}
|
|
210
|
+
// OBS-957 capture retained under fixtures/opencode (the captured UI identifies as omp).
|
|
211
|
+
// Anchor activity glyphs to chrome/tool rows so a quoted spinner in a summary is harmless.
|
|
212
|
+
export const OPENCODE_BUSY_FRAME_MARKERS = [
|
|
213
|
+
{ name: "unchecked TODO", match: (text) => /(?:^|\n)[^\n]*[├└]─[^\n]*☐/.test(text) || /(?:^|\n)\s*[-*] \[ \]/.test(text) },
|
|
214
|
+
{ name: "in-flight tool", match: (text) => /(?:^|\n)\s*⎋ [^\n]+/.test(text) },
|
|
215
|
+
{ name: "spinner", match: (text) => /(?:^|\n)(?:[╭│][─ ]*|\s*)[⠁-⣿](?:\s|$)/.test(text) },
|
|
216
|
+
];
|
|
217
|
+
export class SettledTrailerTracker {
|
|
218
|
+
markers;
|
|
219
|
+
idleSamples = 0;
|
|
220
|
+
busyMarkersSeen = new Set();
|
|
221
|
+
settled = false;
|
|
222
|
+
constructor(markers) {
|
|
223
|
+
this.markers = markers;
|
|
224
|
+
}
|
|
225
|
+
sample(paneText, trailer) {
|
|
226
|
+
const busy = this.markers.filter((marker) => marker.match(paneText));
|
|
227
|
+
if (trailer)
|
|
228
|
+
for (const marker of busy)
|
|
229
|
+
this.busyMarkersSeen.add(marker.name);
|
|
230
|
+
this.idleSamples = trailer && busy.length === 0 ? this.idleSamples + 1 : 0;
|
|
231
|
+
this.settled = this.idleSamples >= 2;
|
|
232
|
+
return this.settled;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
210
235
|
export function channelsFromConfig(adapterId, cfg) {
|
|
211
236
|
const e = cfg.tiers[adapterId];
|
|
212
237
|
if (!e)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export type DetectedHost = "herdr" | "orca" | "subprocess";
|
|
2
|
+
export declare function detectedHost(env?: NodeJS.ProcessEnv): DetectedHost;
|
|
1
3
|
/** Test seam mirroring FleetIO: production callers omit it and get the process streams. */
|
|
2
4
|
export type InitIO = {
|
|
3
5
|
input?: NodeJS.ReadStream;
|
|
@@ -4,6 +4,7 @@ import { join, relative } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
|
+
import { parse as parseYaml } from "yaml";
|
|
7
8
|
import { allAdapters, formatDoctorAgeForInit, formatDoctorReport, initDoctorReuse } from "../../adapters/registry.js";
|
|
8
9
|
import { configTemplate, DEFAULT_CONFIG, globalConfigDir, loadConfig } from "../../config/config.js";
|
|
9
10
|
import { LEGACY_PREFIX, specTemplate } from "../../compile/native.js";
|
|
@@ -218,7 +219,14 @@ const describeDrift = (d) => [
|
|
|
218
219
|
// should not be asked again just because a copy drifted. Staleness is a different question with a
|
|
219
220
|
// different answer (`--agent --force`), and it is reported by name on every init path below.
|
|
220
221
|
const skillsInstalled = (cwd) => hostTargets(cwd).every((t) => AGENT_SKILLS.every((s) => existsSync(join(t.skillsDir, s, "SKILL.md"))));
|
|
221
|
-
|
|
222
|
+
export function detectedHost(env = process.env) {
|
|
223
|
+
if (env.HERDR_ENV === "1")
|
|
224
|
+
return "herdr";
|
|
225
|
+
if (orcaHostDetected(env))
|
|
226
|
+
return "orca";
|
|
227
|
+
return "subprocess";
|
|
228
|
+
}
|
|
229
|
+
const wizardDriverDefault = () => "auto";
|
|
222
230
|
function packageName(cwd) {
|
|
223
231
|
try {
|
|
224
232
|
const parsed = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
|
|
@@ -323,6 +331,7 @@ async function runInitWizard(cwd, input, output) {
|
|
|
323
331
|
visibilityLlm: DEFAULT_CONFIG.visibility.llm,
|
|
324
332
|
offerSkills: !skillsInstalled(cwd),
|
|
325
333
|
skillsDefault: existsSync(join(cwd, ".claude", "skills")) && !skillsInstalled(cwd),
|
|
334
|
+
detectedHost: detectedHost(),
|
|
326
335
|
},
|
|
327
336
|
input,
|
|
328
337
|
output,
|
|
@@ -368,8 +377,37 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
|
|
|
368
377
|
}
|
|
369
378
|
const repoConfigPath = join(tickmarkrDir(cwd), "config.yaml");
|
|
370
379
|
const repoConfigExists = existsSync(repoConfigPath);
|
|
371
|
-
if (repoConfigExists)
|
|
372
|
-
|
|
380
|
+
if (repoConfigExists) {
|
|
381
|
+
if (values.force) {
|
|
382
|
+
const currentRaw = readFileSync(repoConfigPath, "utf8");
|
|
383
|
+
let parsedDriver;
|
|
384
|
+
try {
|
|
385
|
+
const parsed = parseYaml(currentRaw);
|
|
386
|
+
if (typeof parsed?.driver === "string")
|
|
387
|
+
parsedDriver = parsed.driver;
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
// malformed: fall back to regex
|
|
391
|
+
}
|
|
392
|
+
if (!parsedDriver) {
|
|
393
|
+
const m = currentRaw.match(/^\s*driver:\s*([^\s#]+)/m);
|
|
394
|
+
if (m)
|
|
395
|
+
parsedDriver = m[1];
|
|
396
|
+
}
|
|
397
|
+
const host = detectedHost();
|
|
398
|
+
if (parsedDriver && parsedDriver === host) {
|
|
399
|
+
const rewritten = currentRaw.replace(new RegExp(`^(\\s*driver:\\s*)${parsedDriver}\\b.*$`, "m"), `$1auto # auto — resolves to ${host} here`);
|
|
400
|
+
writeFileSync(repoConfigPath, rewritten);
|
|
401
|
+
notes.push(`rewrote driver: ${parsedDriver} to auto in ${repoConfigPath}`);
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
notes.push(`kept existing ${repoConfigPath}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
notes.push(`kept existing ${repoConfigPath}`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
373
411
|
const specPath = join(cwd, SCAFFOLD_SPEC);
|
|
374
412
|
if (existsSync(specPath)) {
|
|
375
413
|
notes.push(`kept existing ${specPath}`);
|
|
@@ -417,7 +455,9 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
|
|
|
417
455
|
// so costs one clause; everything else stops here. Esc and ctrl+c both land here.
|
|
418
456
|
return `init: wizard quit — nothing further run (scaffolding kept: ${notes.filter((n) => n.startsWith("wrote")).length} file(s) written above). Re-run tickmarkr init to continue.`;
|
|
419
457
|
}
|
|
420
|
-
|
|
458
|
+
const host = detectedHost();
|
|
459
|
+
const renderedConfig = configTemplate(wizard.overlay).replace(/^driver: auto$/m, `driver: auto # auto — resolves to ${host} here`);
|
|
460
|
+
writeFileSync(repoConfigPath, renderedConfig);
|
|
421
461
|
notes.push(`wrote ${repoConfigPath}`);
|
|
422
462
|
if (wizard.installSkills) {
|
|
423
463
|
await installAgentFiles(cwd, values.force ?? false, wizard.installDocs || (values.docs ?? false), notes);
|
|
@@ -462,7 +502,7 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
|
|
|
462
502
|
const next = nextSteps(cwd, SCAFFOLD_SPEC);
|
|
463
503
|
if (!visual())
|
|
464
504
|
return `${notes.join("\n")}\n${doc}\n${next}\n${ENVIRONMENTS_FOOTER}`;
|
|
465
|
-
const noteRows = notes.map((note) => ` ${statusRow(/^(?:wrote|overwrote|appended)/.test(note) ? "pass" : note.startsWith("skipped") ? "warn" : "neutral", note)}`);
|
|
505
|
+
const noteRows = notes.map((note) => ` ${statusRow(/^(?:wrote|overwrote|appended|rewrote)/.test(note) ? "pass" : note.startsWith("skipped") ? "warn" : "neutral", note)}`);
|
|
466
506
|
const footerRows = ENVIRONMENTS_FOOTER.split("\n").slice(1).map((line) => {
|
|
467
507
|
const separator = line.indexOf(" — ");
|
|
468
508
|
return kvRow(line.slice(2, separator), line.slice(separator + 1), 12);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
4
|
import { allAdapters, doctorAgeMs, modelAuthExclusions, probeAll, readDoctor, rolePools, servableExclusions, servabilityLine } from "../../adapters/registry.js";
|
|
@@ -7,7 +7,7 @@ import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
|
|
|
7
7
|
import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
|
|
8
8
|
import { classifyContextPath } from "../../compile/native.js";
|
|
9
9
|
import { DEFAULT_CONFIG, effectiveReviewPolicy, overlayPreferShapes, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
|
|
10
|
-
import { loadGraph } from "../../graph/graph.js";
|
|
10
|
+
import { graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
|
|
11
11
|
import { renderAcceptanceItem } from "../../graph/schema.js";
|
|
12
12
|
import { resolveRunMode } from "../../run/daemon.js";
|
|
13
13
|
import { disallowedBy, excludedChannels, exclusionLine, routingEntrySeatLines } from "../../route/preference.js";
|
|
@@ -15,7 +15,7 @@ import { staffLedEvidence } from "../../route/profile.js";
|
|
|
15
15
|
import { route, RoutingError } from "../../route/router.js";
|
|
16
16
|
import { auditNamedTestOracles, listVitestTests } from "../../gates/acceptance.js";
|
|
17
17
|
import { modelId, modelProvider, pickReviewer } from "../../gates/review.js";
|
|
18
|
-
import { Journal, loadRoutingProfile } from "../../run/journal.js";
|
|
18
|
+
import { Journal, loadRoutingProfile, recordedGraphDefinitionHash } from "../../run/journal.js";
|
|
19
19
|
import { harnessLine, resolveHarness } from "../harness.js";
|
|
20
20
|
import { channelKey, shq } from "../../adapters/types.js";
|
|
21
21
|
import { shGit } from "../../run/git.js";
|
|
@@ -40,7 +40,10 @@ const stylizePlan = (out) => {
|
|
|
40
40
|
const fleetCanCrossVendorReview = (channels) => {
|
|
41
41
|
for (let i = 0; i < channels.length; i++)
|
|
42
42
|
for (let j = 0; j < channels.length; j++)
|
|
43
|
-
if (i !== j
|
|
43
|
+
if (i !== j
|
|
44
|
+
&& channels[i].vendor !== channels[j].vendor
|
|
45
|
+
&& modelProvider(channels[i].model, channels[i].vendor) !== modelProvider(channels[j].model, channels[j].vendor)
|
|
46
|
+
&& modelId(channels[i].model) !== modelId(channels[j].model))
|
|
44
47
|
return true;
|
|
45
48
|
return false;
|
|
46
49
|
};
|
|
@@ -121,6 +124,43 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
121
124
|
throw new Error(`--mode must be one of ${ROUTING_MODES.join(" | ")} (got ${values.mode})`);
|
|
122
125
|
}
|
|
123
126
|
const g = loadGraph(cwd);
|
|
127
|
+
const gHash = graphDefinitionHash(g);
|
|
128
|
+
const runsRoot = join(cwd, stateDirName(cwd), "runs");
|
|
129
|
+
let matchingEvents;
|
|
130
|
+
let matchingRunId;
|
|
131
|
+
if (existsSync(runsRoot)) {
|
|
132
|
+
const locked = runLockRunId(cwd);
|
|
133
|
+
const candidates = readdirSync(runsRoot)
|
|
134
|
+
.filter((d) => d.startsWith("run-") && existsSync(join(runsRoot, d, "journal.jsonl")))
|
|
135
|
+
.sort();
|
|
136
|
+
const ordered = locked && candidates.includes(locked)
|
|
137
|
+
? [locked, ...candidates.filter((id) => id !== locked).reverse()]
|
|
138
|
+
: candidates.reverse();
|
|
139
|
+
for (const runId of ordered) {
|
|
140
|
+
try {
|
|
141
|
+
const events = Journal.open(cwd, runId).read();
|
|
142
|
+
if (recordedGraphDefinitionHash(events) === gHash) {
|
|
143
|
+
matchingEvents = events;
|
|
144
|
+
matchingRunId = runId;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// ignore unreadable or torn journals
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const hasRun = matchingEvents !== undefined;
|
|
154
|
+
const reviewHistory = matchingEvents
|
|
155
|
+
? matchingEvents
|
|
156
|
+
.filter((event) => event.event === "gate-result" && event.data.gate === "review" && typeof event.data.reviewer === "string")
|
|
157
|
+
.map((event) => event.data.reviewer)
|
|
158
|
+
: [];
|
|
159
|
+
// OBS-947 repair: a resumed run re-dispatches only tasks whose replayed status is pending (or that the
|
|
160
|
+
// journal never mentions). done, failed and human are all skipped alike, so rendering their preview
|
|
161
|
+
// picks must not advance the shared reviewHistory — a phantom draw wraps the LRU and makes a later
|
|
162
|
+
// pending task print the ranked-first seat while the journal's actual next draw is a later channel.
|
|
163
|
+
const taskStatuses = matchingRunId ? Journal.open(cwd, matchingRunId).replayStatuses() : new Map();
|
|
124
164
|
const oracleRefusals = new Map();
|
|
125
165
|
const oracleAdvisories = new Map();
|
|
126
166
|
if (g.tasks.some((task) => task.acceptance.some((item) => typeof item === "object" && item.oracle === "test"))) {
|
|
@@ -306,10 +346,22 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
306
346
|
const policy = effectiveReviewPolicy(task.files, cfg.review);
|
|
307
347
|
if (policy === "judge-only")
|
|
308
348
|
return { line: "none — reviewPolicy judge-only: declared leaf paths", cost: 0 };
|
|
309
|
-
|
|
349
|
+
let rotationSeat = 1;
|
|
350
|
+
let rotationCount = 0;
|
|
351
|
+
const reviewer = pickReviewer(author, pools.review, [], cfg.review.prefer ?? [], task.routingHints?.floor, reviewHistory, (seat, count) => { rotationSeat = seat; rotationCount = count; });
|
|
310
352
|
if (!reviewer)
|
|
311
353
|
return { line: "none — no cross-vendor reviewer available", cost: 0 };
|
|
312
|
-
|
|
354
|
+
// Advance the simulated rotation only for tasks the run would actually dispatch next — the same
|
|
355
|
+
// rule as readyTasks (src/graph/graph.ts): pending, with every dependency already done. A pending
|
|
356
|
+
// task behind a parked/failed/pending dependency cannot run, so it consumes no draw (RULING-229-14).
|
|
357
|
+
const isPending = (id) => { const s = taskStatuses.get(id); return s === undefined || s === "pending"; };
|
|
358
|
+
const ready = isPending(task.id) && task.deps.every((d) => taskStatuses.get(d) === "done");
|
|
359
|
+
if (hasRun && ready)
|
|
360
|
+
reviewHistory.push(channelKey(reviewer));
|
|
361
|
+
const rotationNote = rotationCount > 1
|
|
362
|
+
? (hasRun ? ` (seat ${rotationSeat} of ${rotationCount})` : ` (seat 1 of ${rotationCount} — rotation applies at run time)`)
|
|
363
|
+
: "";
|
|
364
|
+
return { line: `${channelKey(reviewer)}${rotationNote} — reviewPolicy full`, cost: seatCost(reviewer) };
|
|
313
365
|
};
|
|
314
366
|
const routed = [];
|
|
315
367
|
for (const t of g.tasks) {
|
|
@@ -268,7 +268,9 @@ export function renderMarkdownRecord(runId, events, prices = [], rows = []) {
|
|
|
268
268
|
pass = "pass (same-provider — independence not established)";
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
|
-
|
|
271
|
+
const resolved = gate === "review" && g.data.pass === true && Array.isArray(g.data.resolved)
|
|
272
|
+
? g.data.resolved.filter((id) => typeof id === "string") : [];
|
|
273
|
+
lines.push(` - ${gate}: ${pass} — ${firstLine(g.data.details)}${resolved.length ? `; resolved: ${resolved.join(", ")}` : ""}`);
|
|
272
274
|
}
|
|
273
275
|
for (const row of leg2) {
|
|
274
276
|
const pass = row.data.pass === true ? "pass" : row.data.pass === false ? "fail" : EM;
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -118,6 +118,9 @@ export const RAIL_ROWS = {
|
|
|
118
118
|
"gate-result": { label: "gate", tone: "pass" },
|
|
119
119
|
"gate-reused": { label: "gate reused", tone: "neutral" },
|
|
120
120
|
"judge-retry": { label: "judge retry", tone: "attention" },
|
|
121
|
+
"review-no-verdict": { label: "review unavailable", tone: "attention" },
|
|
122
|
+
"review-pool-demotion": { label: "review seat demoted", tone: "attention" },
|
|
123
|
+
"review-infra-retry": { label: "review infra retry", tone: "attention" },
|
|
121
124
|
"review-retry": { label: "review retry", tone: "attention" },
|
|
122
125
|
// repairs and escalations
|
|
123
126
|
"repair-dispatch": { label: "repair", tone: "attention" },
|
package/dist/gates/llm.d.ts
CHANGED
|
@@ -61,7 +61,12 @@ export interface LlmRunResult {
|
|
|
61
61
|
output: string;
|
|
62
62
|
exitCode?: number;
|
|
63
63
|
timedOut: boolean;
|
|
64
|
+
launchNeverStarted?: boolean;
|
|
65
|
+
seatAuthoredBytes?: number;
|
|
64
66
|
}
|
|
67
|
+
export declare const PROMPT_GLYPHS: readonly ["➜", "❯", "$", "%", ">>", ">"];
|
|
68
|
+
export declare function reviewSeatOutput(raw: string, nonce: string): string;
|
|
69
|
+
export declare const REVIEW_FIRST_LIVENESS_MS = 30000;
|
|
65
70
|
export declare function runHeadless(adapter: WorkerAdapter, model: string, prompt: string, cwd: string, timeoutMs?: number): Promise<string>;
|
|
66
71
|
export declare function runViaDriver(adapter: WorkerAdapter, model: string, prompt: string, cwd: string, via: LlmVia, timeoutMs?: number): Promise<string>;
|
|
67
72
|
export declare function dewrapPaneVerdict(out: string, nonce: string): string;
|
package/dist/gates/llm.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
1
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
3
|
import { randomBytes } from "node:crypto";
|
|
3
4
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -5,7 +6,7 @@ import { tmpdir } from "node:os";
|
|
|
5
6
|
import { join } from "node:path";
|
|
6
7
|
import { matchesTrustDialog } from "../adapters/types.js";
|
|
7
8
|
import { formatOwnedName, parseOwnedName } from "../drivers/types.js";
|
|
8
|
-
import { bannerShell, paneDispatchCommand } from "../brand.js";
|
|
9
|
+
import { bannerShell, paneDispatchCommand, PLAIN_BANNER } from "../brand.js";
|
|
9
10
|
import { sh } from "../run/git.js";
|
|
10
11
|
import { harvestCpuFlatWindowMs, normalizeStallSnapshot, WorkerTreeCpuAccountant, } from "../run/stall.js";
|
|
11
12
|
export const GATE_PANE_SEP = " · ";
|
|
@@ -129,13 +130,164 @@ export async function captureLlmOutput(run) {
|
|
|
129
130
|
const value = await llmOutputCapture.run(outputs, run);
|
|
130
131
|
return { value, outputs };
|
|
131
132
|
}
|
|
133
|
+
// The harness preamble has a fixed row shape, in order: the dispatch echo rows (the identity export
|
|
134
|
+
// and the START printf, each possibly re-echoed behind the shell prompt), the START acknowledgement
|
|
135
|
+
// row, the banner rows, blank rows, and the identity row. RULING-229-15 add.1 makes the boundary
|
|
136
|
+
// STRUCTURAL: a pane read is line-terminated, so only the LAST row of a capture can be a partial
|
|
137
|
+
// paint. Every row before it is complete, and a complete row is harness only when it EQUALS a full
|
|
138
|
+
// harness row in the position the preamble allows. A complete row that merely starts with "T",
|
|
139
|
+
// "export", "review" or a banner glyph is seat text, and seat prose that MENTIONS a marker mid-row
|
|
140
|
+
// counts in full — "contains TICKMARKR_START_ somewhere" is never by itself a reason to measure zero.
|
|
141
|
+
const BANNER_ROWS = PLAIN_BANNER.replace(/\n$/, "").split("\n");
|
|
142
|
+
const ECHO_OPENER = "export HERDR_WORKSPACE_ID=";
|
|
143
|
+
const ECHO_START_OPENER = "printf '%s%s\\n' 'TICKMARKR_START_' '";
|
|
144
|
+
const ECHO_OPENERS = [ECHO_OPENER, ECHO_START_OPENER];
|
|
145
|
+
// RULING-229-15 add.5: the prompt glyph set is this CLOSED list — the corpus sweeps it, the grammar
|
|
146
|
+
// consumes the LONGEST glyph that matches (so ">>" is one glyph and ">" is still one).
|
|
147
|
+
export const PROMPT_GLYPHS = ["➜", "❯", "$", "%", ">>", ">"];
|
|
148
|
+
const GIT_SEGMENT_OPEN = "git:(";
|
|
149
|
+
function echoRowMatch(row) {
|
|
150
|
+
const opener = (at) => {
|
|
151
|
+
const body = row.slice(at);
|
|
152
|
+
if (ECHO_OPENERS.some((o) => body.startsWith(o)))
|
|
153
|
+
return "complete";
|
|
154
|
+
return ECHO_OPENERS.some((o) => o.startsWith(body)) ? "partial" : "none";
|
|
155
|
+
};
|
|
156
|
+
const ws = (at) => /^\s+/.exec(row.slice(at))?.[0].length;
|
|
157
|
+
const bare = opener(0);
|
|
158
|
+
if (bare !== "none")
|
|
159
|
+
return bare;
|
|
160
|
+
const glyph = PROMPT_GLYPHS.find((g) => row.startsWith(g));
|
|
161
|
+
if (glyph === undefined)
|
|
162
|
+
return "none";
|
|
163
|
+
let i = glyph.length;
|
|
164
|
+
const ws1 = ws(i);
|
|
165
|
+
if (ws1 === undefined)
|
|
166
|
+
return row.length === i ? "partial" : "none";
|
|
167
|
+
i += ws1;
|
|
168
|
+
const dir = /^\S+/.exec(row.slice(i));
|
|
169
|
+
if (!dir)
|
|
170
|
+
return "partial";
|
|
171
|
+
i += dir[0].length;
|
|
172
|
+
const ws2 = ws(i);
|
|
173
|
+
if (ws2 === undefined)
|
|
174
|
+
return "partial";
|
|
175
|
+
i += ws2;
|
|
176
|
+
const rest = row.slice(i);
|
|
177
|
+
if (rest.startsWith(GIT_SEGMENT_OPEN)) {
|
|
178
|
+
const close = row.indexOf(")", i + GIT_SEGMENT_OPEN.length);
|
|
179
|
+
if (close < 0)
|
|
180
|
+
return "partial";
|
|
181
|
+
i = close + 1;
|
|
182
|
+
const ws3 = ws(i);
|
|
183
|
+
if (ws3 === undefined)
|
|
184
|
+
return row.length === i ? "partial" : "none";
|
|
185
|
+
return opener(i + ws3);
|
|
186
|
+
}
|
|
187
|
+
if (GIT_SEGMENT_OPEN.startsWith(rest))
|
|
188
|
+
return "partial"; // "", "g", "gi", "git", "git:"
|
|
189
|
+
return opener(i);
|
|
190
|
+
}
|
|
191
|
+
// A complete dispatch echo row: the grammar reaches the opener. Never "contains the opener" — a row
|
|
192
|
+
// that mentions it mid-prose is the seat's.
|
|
193
|
+
function completeEchoRow(row) {
|
|
194
|
+
return echoRowMatch(row) === "complete";
|
|
195
|
+
}
|
|
196
|
+
// A LAST row still being painted: any prefix of a row in the grammar.
|
|
197
|
+
function partialEchoRow(row) {
|
|
198
|
+
return echoRowMatch(row) !== "none";
|
|
199
|
+
}
|
|
200
|
+
const START_MARKER = "TICKMARKR_START_";
|
|
201
|
+
const START_ROW = /^TICKMARKR_START_[\w-]+$/;
|
|
202
|
+
const IDENTITY_LINE = /^(?:review\s*·|tickmarkr(?::|$))/;
|
|
203
|
+
const IDENTITY_OPENERS = ["review ·", "tickmarkr"];
|
|
204
|
+
// Stages of the preamble walk. Each complete harness row is accepted only at or after its stage.
|
|
205
|
+
const ECHO = 0, START = 1, BANNER = 2, IDENTITY = 3, SEAT = 4;
|
|
206
|
+
// The char offset where the seat's own text begins, or -1 when the capture ends inside the preamble.
|
|
207
|
+
function seatStart(output) {
|
|
208
|
+
const rows = output.split("\n");
|
|
209
|
+
if (rows.length > 1 && rows[rows.length - 1] === "")
|
|
210
|
+
rows.pop(); // the read's own line terminator
|
|
211
|
+
let stage = ECHO;
|
|
212
|
+
let bannerAt; // next banner row expected once the banner has begun
|
|
213
|
+
let offset = 0;
|
|
214
|
+
for (let i = 0; i < rows.length; i++) {
|
|
215
|
+
const row = rows[i].replace(/[ \t]+$/, "");
|
|
216
|
+
const t = row.trim();
|
|
217
|
+
const last = i === rows.length - 1;
|
|
218
|
+
// Complete harness rows: equality against the shape the preamble allows at this stage.
|
|
219
|
+
let accepted = false;
|
|
220
|
+
if (stage < SEAT && t.length === 0)
|
|
221
|
+
accepted = true; // blank rows between preamble rows
|
|
222
|
+
else if (stage <= ECHO && completeEchoRow(row))
|
|
223
|
+
accepted = true;
|
|
224
|
+
else if (stage <= START && START_ROW.test(row)) {
|
|
225
|
+
stage = BANNER;
|
|
226
|
+
accepted = true;
|
|
227
|
+
}
|
|
228
|
+
else if (stage <= BANNER && bannerAt === undefined && BANNER_ROWS.includes(row)) {
|
|
229
|
+
stage = BANNER;
|
|
230
|
+
bannerAt = BANNER_ROWS.indexOf(row) + 1;
|
|
231
|
+
accepted = true;
|
|
232
|
+
}
|
|
233
|
+
else if (stage <= BANNER && bannerAt !== undefined && row === BANNER_ROWS[bannerAt]) {
|
|
234
|
+
bannerAt++;
|
|
235
|
+
accepted = true;
|
|
236
|
+
}
|
|
237
|
+
else if (stage <= IDENTITY && IDENTITY_LINE.test(t)) {
|
|
238
|
+
stage = SEAT;
|
|
239
|
+
accepted = true;
|
|
240
|
+
}
|
|
241
|
+
if (accepted) {
|
|
242
|
+
offset += rows[i].length + 1;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (!last)
|
|
246
|
+
return offset;
|
|
247
|
+
// The last row may be a partial paint: a prefix of the next harness row the preamble allows.
|
|
248
|
+
if (stage <= ECHO && partialEchoRow(row))
|
|
249
|
+
return -1;
|
|
250
|
+
if (stage <= START && (START_MARKER.startsWith(row) || /^TICKMARKR_START_[\w-]*$/.test(row)))
|
|
251
|
+
return -1;
|
|
252
|
+
if (stage <= BANNER && (bannerAt === undefined
|
|
253
|
+
? BANNER_ROWS.some((b) => b.startsWith(row))
|
|
254
|
+
: BANNER_ROWS[bannerAt]?.startsWith(row) === true))
|
|
255
|
+
return -1;
|
|
256
|
+
// The identity row is painted right after the banner, so its prefix is a partial paint only there;
|
|
257
|
+
// with no banner in the capture, "review" or "tick" alone is the seat's own first row.
|
|
258
|
+
if (stage <= IDENTITY && bannerAt === BANNER_ROWS.length && IDENTITY_OPENERS.some((o) => o.startsWith(t)))
|
|
259
|
+
return -1;
|
|
260
|
+
return offset;
|
|
261
|
+
}
|
|
262
|
+
return -1; // every row was harness — the seat has not taken its turn
|
|
263
|
+
}
|
|
264
|
+
// A pane's dispatch echo, start acknowledgement, banner and identity are harness bytes.
|
|
265
|
+
// Remove only that leading preamble, never matching text later in the seat's response.
|
|
266
|
+
// Every capture taken before the preamble finishes therefore measures ZERO seat-authored bytes,
|
|
267
|
+
// which is what makes the caller's running Math.max safe: a partial banner counted once would be
|
|
268
|
+
// retained for the whole call and buy a silent seat its full ceiling.
|
|
269
|
+
export function reviewSeatOutput(raw, nonce) {
|
|
270
|
+
const output = stripVTControlCharacters(raw).replace(/\r\n?/g, "\n");
|
|
271
|
+
const start = seatStart(output);
|
|
272
|
+
if (start < 0)
|
|
273
|
+
return "";
|
|
274
|
+
const seat = output.slice(start);
|
|
275
|
+
// Anything after the nonce-bound trailer belongs to the terminal (typically the next shell
|
|
276
|
+
// prompt), not the reviewer. Deleting only the marker would count that postlude as seat output and
|
|
277
|
+
// let a zero-byte seat escape first-silence demotion.
|
|
278
|
+
const trailer = new RegExp(`(?:^|\\n)TICKMARKR_EXIT_${nonce}:\\d+[^\\n]*(?:\\n|$)`).exec(seat);
|
|
279
|
+
// A trailer row still being typed is not stripped: after the preamble a last row "T" is far more
|
|
280
|
+
// often the seat's first byte than the harness's exit marker, and the next read completes either.
|
|
281
|
+
return trailer ? seat.slice(0, trailer.index) : seat;
|
|
282
|
+
}
|
|
283
|
+
export const REVIEW_FIRST_LIVENESS_MS = 30_000;
|
|
132
284
|
async function runHeadlessDetailed(adapter, model, prompt, cwd, timeoutMs = 300000) {
|
|
133
285
|
const dir = mkdtempSync(join(tmpdir(), "tickmarkr-llm-"));
|
|
134
286
|
try {
|
|
135
287
|
const pf = join(dir, "prompt.md");
|
|
136
288
|
writeFileSync(pf, prompt);
|
|
137
289
|
const r = await sh(adapter.headlessCommand(pf, model), cwd, timeoutMs);
|
|
138
|
-
return { output: r.stdout + "\n" + r.stderr, exitCode: r.code, timedOut: r.timedOut === true };
|
|
290
|
+
return { output: r.stdout + "\n" + r.stderr, exitCode: r.code, timedOut: r.timedOut === true, seatAuthoredBytes: Buffer.byteLength(r.stdout + r.stderr) };
|
|
139
291
|
}
|
|
140
292
|
finally {
|
|
141
293
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -150,6 +302,7 @@ async function runViaDriverDetailed(adapter, model, prompt, cwd, via, timeoutMs
|
|
|
150
302
|
const dir = mkdtempSync(join(tmpdir(), "tickmarkr-llm-"));
|
|
151
303
|
let slot;
|
|
152
304
|
let accountant;
|
|
305
|
+
let forceClose = false;
|
|
153
306
|
try {
|
|
154
307
|
const pf = join(dir, "prompt.md");
|
|
155
308
|
writeFileSync(pf, prompt);
|
|
@@ -180,6 +333,8 @@ async function runViaDriverDetailed(adapter, model, prompt, cwd, via, timeoutMs
|
|
|
180
333
|
const exitPattern = `TICKMARKR_EXIT_${nonce}:\\d`;
|
|
181
334
|
let out;
|
|
182
335
|
let timedOut = false;
|
|
336
|
+
let launchNeverStarted = false;
|
|
337
|
+
let seatAuthoredBytes = 0;
|
|
183
338
|
const gatePrompt = prompt.startsWith("TICKMARKR-JUDGE") || prompt.startsWith("TICKMARKR-REVIEW");
|
|
184
339
|
if (!gatePrompt) {
|
|
185
340
|
await via.driver.waitOutput(slot, exitPattern, timeoutMs, { regex: true });
|
|
@@ -193,6 +348,9 @@ async function runViaDriverDetailed(adapter, model, prompt, cwd, via, timeoutMs
|
|
|
193
348
|
await accountant.start();
|
|
194
349
|
const startedAt = Date.now();
|
|
195
350
|
out = await via.driver.read(slot, 400);
|
|
351
|
+
const reviewing = prompt.startsWith("TICKMARKR-REVIEW");
|
|
352
|
+
seatAuthoredBytes = Buffer.byteLength(reviewSeatOutput(out, nonce));
|
|
353
|
+
let firstLivenessObserved = false;
|
|
196
354
|
let priorSnapshot = normalizeStallSnapshot(out);
|
|
197
355
|
const anchoredAt = Date.now();
|
|
198
356
|
let quietSince = anchoredAt;
|
|
@@ -207,12 +365,35 @@ async function runViaDriverDetailed(adapter, model, prompt, cwd, via, timeoutMs
|
|
|
207
365
|
const matched = await via.driver.waitOutput(slot, exitPattern, sliceMs, { regex: true });
|
|
208
366
|
const raw = await via.driver.read(slot, 400);
|
|
209
367
|
out = raw;
|
|
368
|
+
seatAuthoredBytes = Math.max(seatAuthoredBytes, Buffer.byteLength(reviewSeatOutput(raw, nonce)));
|
|
210
369
|
// waitOutput is the driver's authoritative marker match. The raw check covers drivers whose
|
|
211
370
|
// wait timed out at the same boundary the marker landed; either way a trailer completes
|
|
212
371
|
// normally and is never mistaken for inactivity.
|
|
213
372
|
if (matched || new RegExp(exitPattern).test(raw))
|
|
214
373
|
break;
|
|
215
374
|
const now = Date.now();
|
|
375
|
+
// The ceiling wins if it coincides with the first beat (or the read crosses it).
|
|
376
|
+
// That seat was killed by its configured timeout, not an early launch reroute.
|
|
377
|
+
if (now - startedAt >= timeoutMs)
|
|
378
|
+
break;
|
|
379
|
+
if (reviewing && !firstLivenessObserved && now - startedAt >= REVIEW_FIRST_LIVENESS_MS) {
|
|
380
|
+
firstLivenessObserved = true;
|
|
381
|
+
// RS-2: the beat reads seat-authored bytes ALONE. CPU evidence never holds a preamble-only
|
|
382
|
+
// capture open to the ceiling; a seat that has not written one byte of its own is re-routed.
|
|
383
|
+
// OBS-944 is why a buffering runner earns no exemption here: the claude-code seat's 901 s
|
|
384
|
+
// capture was byte-identical across two legs and ended at the pane-identity line — that
|
|
385
|
+
// seat never started, it was not quietly working. RULING-229-06 puts the beat on the PANE
|
|
386
|
+
// path only; a headless `-p` runner (runHeadlessDetailed, no pane, no beat) buffers every
|
|
387
|
+
// byte until completion and keeps its full ceiling.
|
|
388
|
+
if (seatAuthoredBytes === 0) {
|
|
389
|
+
launchNeverStarted = true;
|
|
390
|
+
forceClose = true;
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// Producing reviews own their full ceiling; inactivity is not a review verdict.
|
|
395
|
+
if (reviewing)
|
|
396
|
+
continue;
|
|
216
397
|
const snapshot = normalizeStallSnapshot(raw);
|
|
217
398
|
if (snapshot !== priorSnapshot) {
|
|
218
399
|
priorSnapshot = snapshot;
|
|
@@ -246,18 +427,22 @@ async function runViaDriverDetailed(adapter, model, prompt, cwd, via, timeoutMs
|
|
|
246
427
|
}
|
|
247
428
|
}
|
|
248
429
|
timedOut = Date.now() - startedAt >= timeoutMs && !new RegExp(exitPattern).test(out);
|
|
430
|
+
if (timedOut)
|
|
431
|
+
forceClose = true;
|
|
249
432
|
}
|
|
250
433
|
const exitCode = Number(new RegExp(`TICKMARKR_EXIT_${nonce}:(\\d+)`).exec(out)?.[1]);
|
|
251
434
|
return {
|
|
252
435
|
output: dewrapPaneVerdict(out, nonce),
|
|
253
436
|
...(Number.isFinite(exitCode) ? { exitCode } : {}),
|
|
254
437
|
timedOut,
|
|
438
|
+
launchNeverStarted,
|
|
439
|
+
seatAuthoredBytes,
|
|
255
440
|
};
|
|
256
441
|
}
|
|
257
442
|
finally {
|
|
258
443
|
try {
|
|
259
444
|
await accountant?.stop();
|
|
260
|
-
if (slot && !via.keep)
|
|
445
|
+
if (slot && (forceClose || !via.keep))
|
|
261
446
|
await via.driver.close(slot);
|
|
262
447
|
}
|
|
263
448
|
finally {
|