tickmarkr 1.85.0 → 1.86.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 +4 -2
- package/dist/adapters/catalog-remote.d.ts +64 -0
- package/dist/adapters/catalog-remote.js +287 -0
- package/dist/adapters/catalog.d.ts +96 -0
- package/dist/adapters/catalog.js +176 -0
- package/dist/adapters/claude-code.js +5 -3
- package/dist/adapters/fake.js +33 -4
- package/dist/adapters/model-lints.d.ts +25 -5
- package/dist/adapters/model-lints.js +184 -50
- package/dist/adapters/model-windows.d.ts +31 -0
- package/dist/adapters/model-windows.js +69 -0
- package/dist/adapters/prompt.d.ts +5 -1
- package/dist/adapters/prompt.js +13 -4
- package/dist/adapters/registry.d.ts +25 -26
- package/dist/adapters/registry.js +173 -110
- package/dist/adapters/types.js +15 -3
- package/dist/brand.d.ts +5 -1
- package/dist/brand.js +18 -2
- package/dist/cli/commands/doctor.d.ts +3 -0
- package/dist/cli/commands/doctor.js +43 -21
- package/dist/cli/commands/fleet.d.ts +7 -0
- package/dist/cli/commands/fleet.js +94 -74
- package/dist/cli/commands/init.js +118 -5
- package/dist/cli/commands/status.js +44 -18
- package/dist/compile/gsd.d.ts +2 -1
- package/dist/compile/gsd.js +68 -2
- package/dist/compile/native.d.ts +14 -0
- package/dist/compile/native.js +161 -12
- package/dist/config/config.d.ts +20 -5
- package/dist/config/config.js +96 -64
- package/dist/config/fleet-overlay.d.ts +25 -20
- package/dist/config/fleet-overlay.js +195 -77
- package/dist/config/fleet-why.d.ts +23 -0
- package/dist/config/fleet-why.js +42 -0
- package/dist/drivers/herdr.d.ts +1 -0
- package/dist/drivers/herdr.js +70 -19
- package/dist/gates/acceptance.js +7 -2
- package/dist/gates/llm.d.ts +0 -1
- package/dist/gates/llm.js +5 -30
- package/dist/gates/review.d.ts +2 -1
- package/dist/gates/review.js +9 -7
- package/dist/gates/verdict-cause.d.ts +4 -0
- package/dist/gates/verdict-cause.js +63 -0
- package/dist/graph/schema.d.ts +6 -0
- package/dist/graph/schema.js +8 -5
- package/dist/route/router.d.ts +0 -5
- package/dist/route/router.js +16 -20
- package/dist/run/consult.d.ts +6 -0
- package/dist/run/consult.js +35 -25
- package/dist/run/daemon.js +71 -15
- package/dist/run/journal.js +87 -7
- package/dist/tui/cockpit/capture.d.ts +12 -0
- package/dist/tui/cockpit/capture.js +37 -1
- package/dist/tui/cockpit/components.js +8 -8
- package/dist/tui/cockpit/theme.d.ts +32 -26
- package/dist/tui/cockpit/theme.js +11 -5
- package/dist/tui/ink/components.d.ts +0 -15
- package/dist/tui/ink/components.js +0 -17
- package/dist/tui/ink/fleet-app.d.ts +4 -1
- package/dist/tui/ink/fleet-app.js +134 -13
- package/fixtures/sample.native.md +1 -1
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +354 -34
- package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +70 -0
- package/skills/tickmarkr-overseer/scripts/watch-panes.sh +1 -1
- package/dist/tui/ink/studio-app.d.ts +0 -59
- package/dist/tui/ink/studio-app.js +0 -320
- package/dist/tui/save.d.ts +0 -38
- package/dist/tui/save.js +0 -96
- package/dist/tui/staging.d.ts +0 -29
- package/dist/tui/staging.js +0 -78
package/dist/gates/llm.js
CHANGED
|
@@ -69,29 +69,6 @@ export function extractPromptNonce(prompt) {
|
|
|
69
69
|
export function gateExitTrailer(nonce) {
|
|
70
70
|
return `printf '\\nTICKMARKR_''EXIT_${nonce}:%s\\n' $?`;
|
|
71
71
|
}
|
|
72
|
-
// v1.64: scripted fake judge verdicts predate the required per-criterion evidence field — quote the
|
|
73
|
-
// first line of the prompt's own diff block into rows lacking one so zero-token fixtures keep their
|
|
74
|
-
// outcomes. Rows scripting an explicit evidence value pass through verbatim (tests exercise both paths).
|
|
75
|
-
function injectFakeEvidence(obj, prompt) {
|
|
76
|
-
if (!prompt.startsWith("TICKMARKR-JUDGE") || !Array.isArray(obj.criteria))
|
|
77
|
-
return obj;
|
|
78
|
-
const line = /```diff\n([\s\S]*?)```/.exec(prompt)?.[1].split("\n").find((l) => l.trim());
|
|
79
|
-
if (!line)
|
|
80
|
-
return obj;
|
|
81
|
-
const criteria = obj.criteria.map((row) => row && typeof row === "object" && !("evidence" in row) ? { ...row, evidence: line } : row);
|
|
82
|
-
return { ...obj, criteria };
|
|
83
|
-
}
|
|
84
|
-
// ponytail: fake adapter serves static verdict JSON without nonce; append a bound copy for zero-token tests.
|
|
85
|
-
export function augmentFakeVerdictOutput(adapter, out, nonce, prompt = "") {
|
|
86
|
-
if (adapter.id !== "fake")
|
|
87
|
-
return out;
|
|
88
|
-
const obj = extractJson(out);
|
|
89
|
-
if (!obj || typeof obj !== "object" || obj.nonce === nonce)
|
|
90
|
-
return out;
|
|
91
|
-
if (typeof obj.nonce === "string")
|
|
92
|
-
return out;
|
|
93
|
-
return `${out}\n${JSON.stringify(injectFakeEvidence({ ...obj, nonce }, prompt))}`;
|
|
94
|
-
}
|
|
95
72
|
/** T8: role-first pane name for fleet visibility — judge · T4, review · T3, consult · T2. */
|
|
96
73
|
export function gatePaneName(role, taskId, suffix = "") {
|
|
97
74
|
return `${role}${GATE_PANE_SEP}${taskId}${suffix}`;
|
|
@@ -130,11 +107,7 @@ export async function runHeadless(adapter, model, prompt, cwd, timeoutMs = 30000
|
|
|
130
107
|
const pf = join(mkdtempSync(join(tmpdir(), "tickmarkr-llm-")), "prompt.md");
|
|
131
108
|
writeFileSync(pf, prompt);
|
|
132
109
|
const r = await sh(adapter.headlessCommand(pf, model), cwd, timeoutMs);
|
|
133
|
-
|
|
134
|
-
let out = r.stdout + "\n" + r.stderr;
|
|
135
|
-
if (nonce)
|
|
136
|
-
out = augmentFakeVerdictOutput(adapter, out, nonce, prompt);
|
|
137
|
-
return out;
|
|
110
|
+
return r.stdout + "\n" + r.stderr;
|
|
138
111
|
}
|
|
139
112
|
// v1.1 default path: the same headless CLI call, but dispatched through the driver
|
|
140
113
|
// as a visible named agent (herdr pane), with the quote-split completion wrapper.
|
|
@@ -157,10 +130,9 @@ export async function runViaDriver(adapter, model, prompt, cwd, via, timeoutMs =
|
|
|
157
130
|
// nonce-suffixed exit only: a displayed bare "TICKMARKR_EXIT:" or another call's marker must not
|
|
158
131
|
// false-complete — same guard the worker path uses (daemon.ts:330-331).
|
|
159
132
|
await via.driver.waitOutput(slot, `TICKMARKR_EXIT_${nonce}:\\d`, timeoutMs, { regex: true });
|
|
160
|
-
|
|
133
|
+
const out = await via.driver.read(slot, 400);
|
|
161
134
|
if (!via.keep)
|
|
162
135
|
await via.driver.close(slot);
|
|
163
|
-
out = augmentFakeVerdictOutput(adapter, out, nonce, prompt);
|
|
164
136
|
return dewrapPaneVerdict(out, nonce);
|
|
165
137
|
}
|
|
166
138
|
// OBS-155: a TUI renders the verdict as a bullet and HARD-wraps it at pane width with a 2-space
|
|
@@ -179,6 +151,9 @@ export async function runViaDriver(adapter, model, prompt, cwd, via, timeoutMs =
|
|
|
179
151
|
export function dewrapPaneVerdict(out, nonce) {
|
|
180
152
|
if (!out.includes(nonce))
|
|
181
153
|
return out;
|
|
154
|
+
// Preserve already-readable responder bytes; only a genuinely wrapped verdict needs reconstruction.
|
|
155
|
+
if (extractVerdictJson(out, nonce))
|
|
156
|
+
return out;
|
|
182
157
|
const lines = out.split("\n");
|
|
183
158
|
// OBS-209: EVERY brace-start is a candidate, scanned newest-first. findIndex took only the first,
|
|
184
159
|
// so any earlier line beginning with `{` — a quoted snippet, a lone brace in the reviewer's own
|
package/dist/gates/review.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type TickmarkrConfig } from "../config/config.js";
|
|
|
3
3
|
import { type Task } from "../graph/schema.js";
|
|
4
4
|
import { type GateVia } from "./llm.js";
|
|
5
5
|
import type { GateResult } from "./types.js";
|
|
6
|
+
import { type VerdictUnparseableCause } from "./verdict-cause.js";
|
|
6
7
|
export type ReviewSeverity = "material" | "minor";
|
|
7
8
|
export interface ReviewFinding {
|
|
8
9
|
note: string;
|
|
@@ -44,5 +45,5 @@ export declare function diffCapParkReason(results: GateResult[]): string | null;
|
|
|
44
45
|
export declare function modelId(model: string): string;
|
|
45
46
|
export declare function pickReviewer(author: Assignment, channels: BillingChannel[], exclude?: string[], // v1.1 failover: reviewer channels that already produced garbage for this task
|
|
46
47
|
prefer?: string[]): BillingChannel | null;
|
|
47
|
-
export type ReviewUnparseableCause =
|
|
48
|
+
export type ReviewUnparseableCause = VerdictUnparseableCause;
|
|
48
49
|
export declare function reviewGate(task: Task, worktree: string, baseRef: string, author: Assignment, channels: BillingChannel[], adapters: WorkerAdapter[], cfg: TickmarkrConfig, via?: GateVia, excludeReviewers?: string[], artifactDir?: string): Promise<GateResult>;
|
package/dist/gates/review.js
CHANGED
|
@@ -8,6 +8,7 @@ import { shOk } from "../run/git.js";
|
|
|
8
8
|
import { redactSecrets } from "../run/redact.js";
|
|
9
9
|
import { marginalCostRank } from "../route/router.js";
|
|
10
10
|
import { appendAnchoredReview, COMPLETION_FAKING_CHECKLIST, extractVerdictJson, generateVerdictNonce, runLlm, verdictNonceLine } from "./llm.js";
|
|
11
|
+
import { classifyVerdictCause } from "./verdict-cause.js";
|
|
11
12
|
// legacy flat `issues` shape — every issue blocks; the approve flag must agree with the list.
|
|
12
13
|
function classifyReviewIssues(approve, issues) {
|
|
13
14
|
const inconsistencies = [];
|
|
@@ -303,9 +304,9 @@ artifactDir) {
|
|
|
303
304
|
// "a law caps complexity at 3, the gate starts at 7" unreachability OBS-186 measured.
|
|
304
305
|
//
|
|
305
306
|
// COLLATERAL this rescoped task closed: the run-gates/daemon participation assertions are rewritten
|
|
306
|
-
// path-keyed, the NamedFake review fixtures
|
|
307
|
-
//
|
|
308
|
-
// weakened, the fixture is fixed), the merge decision reads `gateSatisfied`, and the daemon writes a
|
|
307
|
+
// path-keyed, the NamedFake review fixtures author their own nonce-bound verdict (a renamed fake is
|
|
308
|
+
// a distinct responder and does not inherit the registered fake's producer contract — the check is
|
|
309
|
+
// not weakened, the fixture is fixed), the merge decision reads `gateSatisfied`, and the daemon writes a
|
|
309
310
|
// parallel round's gate-result rows in GATE_NAMES order (src/run/daemon.ts).
|
|
310
311
|
//
|
|
311
312
|
// That last one is why: retiring the switch makes fixtures that used to SKIP review journal TWO
|
|
@@ -419,9 +420,7 @@ The top-level comments array is optional. Use it only for actionable line-anchor
|
|
|
419
420
|
if (!v || (findings === null && (typeof v.approve !== "boolean" || !Array.isArray(v.issues)))) {
|
|
420
421
|
// OBS-196: name the cause and persist the raw bytes — a ruled-on "unparseable" without its
|
|
421
422
|
// evidence cannot be audited, and a cutoff must never be indistinguishable from a parse defect.
|
|
422
|
-
const cause = raw
|
|
423
|
-
? "empty-output"
|
|
424
|
-
: !raw.includes(nonce) ? "no-verdict" : "malformed-verdict";
|
|
423
|
+
const cause = classifyVerdictCause(raw, nonce, "approve");
|
|
425
424
|
let saved;
|
|
426
425
|
if (artifactDir) {
|
|
427
426
|
try {
|
|
@@ -432,10 +431,13 @@ The top-level comments array is optional. Use it only for actionable line-anchor
|
|
|
432
431
|
saved = undefined; // persistence is evidence, not a gate input — never fail the gate on it
|
|
433
432
|
}
|
|
434
433
|
}
|
|
434
|
+
const failure = cause === "malformed-verdict"
|
|
435
|
+
? "review output unparseable"
|
|
436
|
+
: "review dispatch failed — no structurally valid nonce-bound response; output unparseable";
|
|
435
437
|
return {
|
|
436
438
|
gate: "review",
|
|
437
439
|
pass: false,
|
|
438
|
-
details:
|
|
440
|
+
details: `${failure} (reviewer ${reviewer.adapter}:${reviewer.model}; cause: ${cause}${saved ? `; raw saved: ${saved}` : ""}) — failing closed`,
|
|
439
441
|
meta: { ...policyMeta, reviewer: channelKey(reviewer), unparseable: true, cause },
|
|
440
442
|
};
|
|
441
443
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type VerdictDiscriminator = "approve" | "pass" | "ok" | "action";
|
|
2
|
+
export type VerdictUnparseableCause = "empty-output" | "no-verdict" | "malformed-verdict";
|
|
3
|
+
export declare function hasVerdictParticipationWitness(raw: string, nonce: string, discriminator: VerdictDiscriminator): boolean;
|
|
4
|
+
export declare function classifyVerdictCause(raw: string, nonce: string, discriminator: VerdictDiscriminator): VerdictUnparseableCause;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const MAX_WITNESS_BYTES = 4096;
|
|
2
|
+
const JSON_STRING_VALUE = String.raw `"(?:\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4})|[^"\\\u0000-\u001F])*"`;
|
|
3
|
+
function escapeRegex(value) {
|
|
4
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5
|
+
}
|
|
6
|
+
// Pane renderers hard-wrap at physical columns and prefix continuation lines with whitespace or box
|
|
7
|
+
// chrome. Joining those renderer lines is deliberately narrower than parsing: it restores split keys,
|
|
8
|
+
// values and delimiters, but it does not require the response to be complete or valid JSON.
|
|
9
|
+
function joinRendererLines(raw) {
|
|
10
|
+
return raw.replace(/\r\n?/g, "\n").split("\n")
|
|
11
|
+
.map((line) => line.replace(/^[\t │|]+/, "").replace(/[\t │|]+$/, ""))
|
|
12
|
+
.join("");
|
|
13
|
+
}
|
|
14
|
+
// Yield only the first structural prefix of each object candidate. Stopping at the next unquoted
|
|
15
|
+
// opening brace prevents a nonce from one object binding a discriminator from another; the prompts
|
|
16
|
+
// put their discriminator before any nested object, so no valid boundary shape is lost.
|
|
17
|
+
function objectPrefixes(raw) {
|
|
18
|
+
const joined = joinRendererLines(raw);
|
|
19
|
+
const prefixes = [];
|
|
20
|
+
for (let open = joined.indexOf("{"); open !== -1; open = joined.indexOf("{", open + 1)) {
|
|
21
|
+
const ceiling = Math.min(joined.length, open + MAX_WITNESS_BYTES);
|
|
22
|
+
let end = ceiling;
|
|
23
|
+
let quoted = false;
|
|
24
|
+
let escaped = false;
|
|
25
|
+
for (let i = open + 1; i < ceiling; i++) {
|
|
26
|
+
const char = joined[i];
|
|
27
|
+
if (quoted) {
|
|
28
|
+
if (escaped)
|
|
29
|
+
escaped = false;
|
|
30
|
+
else if (char === "\\")
|
|
31
|
+
escaped = true;
|
|
32
|
+
else if (char === '"')
|
|
33
|
+
quoted = false;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (char === '"')
|
|
37
|
+
quoted = true;
|
|
38
|
+
else if (char === "{") {
|
|
39
|
+
end = i;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
else if (char === "}") {
|
|
43
|
+
end = i + 1;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
prefixes.push(joined.slice(open, end));
|
|
48
|
+
}
|
|
49
|
+
return prefixes;
|
|
50
|
+
}
|
|
51
|
+
export function hasVerdictParticipationWitness(raw, nonce, discriminator) {
|
|
52
|
+
const noncePattern = new RegExp(String.raw `"nonce"\s*:\s*${escapeRegex(JSON.stringify(nonce))}\s*[,]`);
|
|
53
|
+
const valuePattern = discriminator === "action" ? JSON_STRING_VALUE : "(?:true|false)";
|
|
54
|
+
const discriminatorPattern = new RegExp(String.raw `"${discriminator}"\s*:\s*${valuePattern}\s*[,}]`);
|
|
55
|
+
return objectPrefixes(raw).some((prefix) => noncePattern.test(prefix) && discriminatorPattern.test(prefix));
|
|
56
|
+
}
|
|
57
|
+
export function classifyVerdictCause(raw, nonce, discriminator) {
|
|
58
|
+
if (raw.trim().length === 0)
|
|
59
|
+
return "empty-output";
|
|
60
|
+
return hasVerdictParticipationWitness(raw, nonce, discriminator)
|
|
61
|
+
? "malformed-verdict"
|
|
62
|
+
: "no-verdict";
|
|
63
|
+
}
|
package/dist/graph/schema.d.ts
CHANGED
|
@@ -12,9 +12,11 @@ export type Oracle = (typeof ORACLES)[number];
|
|
|
12
12
|
export declare const AcceptanceItemSchema: z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
13
13
|
oracle: z.ZodLiteral<"command">;
|
|
14
14
|
command: z.ZodString;
|
|
15
|
+
text: z.ZodOptional<z.ZodString>;
|
|
15
16
|
}, z.core.$strip>, z.ZodObject<{
|
|
16
17
|
oracle: z.ZodLiteral<"test">;
|
|
17
18
|
test: z.ZodString;
|
|
19
|
+
text: z.ZodOptional<z.ZodString>;
|
|
18
20
|
}, z.core.$strip>, z.ZodObject<{
|
|
19
21
|
oracle: z.ZodLiteral<"judge">;
|
|
20
22
|
text: z.ZodString;
|
|
@@ -43,9 +45,11 @@ export declare const TaskSchema: z.ZodObject<{
|
|
|
43
45
|
acceptance: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
44
46
|
oracle: z.ZodLiteral<"command">;
|
|
45
47
|
command: z.ZodString;
|
|
48
|
+
text: z.ZodOptional<z.ZodString>;
|
|
46
49
|
}, z.core.$strip>, z.ZodObject<{
|
|
47
50
|
oracle: z.ZodLiteral<"test">;
|
|
48
51
|
test: z.ZodString;
|
|
52
|
+
text: z.ZodOptional<z.ZodString>;
|
|
49
53
|
}, z.core.$strip>, z.ZodObject<{
|
|
50
54
|
oracle: z.ZodLiteral<"judge">;
|
|
51
55
|
text: z.ZodString;
|
|
@@ -133,9 +137,11 @@ export declare const RunGraphSchema: z.ZodObject<{
|
|
|
133
137
|
acceptance: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
134
138
|
oracle: z.ZodLiteral<"command">;
|
|
135
139
|
command: z.ZodString;
|
|
140
|
+
text: z.ZodOptional<z.ZodString>;
|
|
136
141
|
}, z.core.$strip>, z.ZodObject<{
|
|
137
142
|
oracle: z.ZodLiteral<"test">;
|
|
138
143
|
test: z.ZodString;
|
|
144
|
+
text: z.ZodOptional<z.ZodString>;
|
|
139
145
|
}, z.core.$strip>, z.ZodObject<{
|
|
140
146
|
oracle: z.ZodLiteral<"judge">;
|
|
141
147
|
text: z.ZodString;
|
package/dist/graph/schema.js
CHANGED
|
@@ -11,11 +11,14 @@ export const TIERS = ["cheap", "mid", "frontier"];
|
|
|
11
11
|
// A plain string is the read-old/write-new compat form — semantically a judge oracle (spec §2).
|
|
12
12
|
export const ORACLES = ["command", "test", "judge"];
|
|
13
13
|
// Typed acceptance oracle: command carries the thing to run, test the test name, judge free text.
|
|
14
|
-
//
|
|
14
|
+
// text is OPTIONAL non-empty on command/test (declared prose beside the oracle; judge requires it).
|
|
15
|
+
// Declaring it matters: z.object strips unknown keys, and loadGraph revalidates on every read —
|
|
16
|
+
// an undeclared text would be silently discarded at the next load. Anything else (a typed object
|
|
17
|
+
// naming an unknown oracle, or a text that is not a non-empty string) fails validation loudly here.
|
|
15
18
|
export const AcceptanceItemSchema = z.union([
|
|
16
19
|
z.string().min(1),
|
|
17
|
-
z.object({ oracle: z.literal("command"), command: z.string().min(1) }),
|
|
18
|
-
z.object({ oracle: z.literal("test"), test: z.string().min(1) }),
|
|
20
|
+
z.object({ oracle: z.literal("command"), command: z.string().min(1), text: z.string().min(1).optional() }),
|
|
21
|
+
z.object({ oracle: z.literal("test"), test: z.string().min(1), text: z.string().min(1).optional() }),
|
|
19
22
|
z.object({ oracle: z.literal("judge"), text: z.string().min(1) }),
|
|
20
23
|
]);
|
|
21
24
|
// Shared text rendering of one acceptance item — every consumer (worker prompt, acceptance gate,
|
|
@@ -24,9 +27,9 @@ export function renderAcceptanceItem(item) {
|
|
|
24
27
|
if (typeof item === "string")
|
|
25
28
|
return item;
|
|
26
29
|
if (item.oracle === "command")
|
|
27
|
-
return `$ ${item.command}`;
|
|
30
|
+
return item.text ?? `$ ${item.command}`;
|
|
28
31
|
if (item.oracle === "test")
|
|
29
|
-
return `test: ${item.test}`;
|
|
32
|
+
return item.text ?? `test: ${item.test}`;
|
|
30
33
|
return item.text; // judge — bare text, byte-identical to a plain-string judge criterion
|
|
31
34
|
}
|
|
32
35
|
export const TaskSchema = z.object({
|
package/dist/route/router.d.ts
CHANGED
|
@@ -19,11 +19,6 @@ export interface Route {
|
|
|
19
19
|
deviation?: RouteDeviation;
|
|
20
20
|
}
|
|
21
21
|
export interface RoutingPreferContext {
|
|
22
|
-
autoPrefer?: {
|
|
23
|
-
derivedAt: string;
|
|
24
|
-
[shape: string]: string[] | string;
|
|
25
|
-
};
|
|
26
|
-
doctorFresh: boolean;
|
|
27
22
|
overlayPreferShapes: ReadonlySet<string>;
|
|
28
23
|
}
|
|
29
24
|
export interface ExploreContext {
|
package/dist/route/router.js
CHANGED
|
@@ -28,19 +28,9 @@ const exploreOff = (task, cfg, exploreCtx) => {
|
|
|
28
28
|
return true;
|
|
29
29
|
return false;
|
|
30
30
|
};
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
};
|
|
35
|
-
const preferFromAuto = (shape, preferCtx) => !!preferCtx?.doctorFresh && !!preferCtx.autoPrefer && !preferCtx.overlayPreferShapes.has(shape) &&
|
|
36
|
-
autoPreferList(preferCtx.autoPrefer, shape) !== undefined;
|
|
37
|
-
const effectivePrefer = (shape, entry, preferCtx) => {
|
|
38
|
-
if (preferCtx?.overlayPreferShapes.has(shape))
|
|
39
|
-
return entry?.prefer;
|
|
40
|
-
if (preferCtx?.doctorFresh && preferCtx.autoPrefer)
|
|
41
|
-
return autoPreferList(preferCtx.autoPrefer, shape) ?? entry?.prefer;
|
|
42
|
-
return entry?.prefer;
|
|
43
|
-
};
|
|
31
|
+
// v1.86 T3: autoPrefer is deleted — prefer is operator-declared only. Nothing is ever derived
|
|
32
|
+
// from probe health, tier data, or a machine-built shape table.
|
|
33
|
+
const effectivePrefer = (entry) => entry?.prefer;
|
|
44
34
|
export class RoutingError extends Error {
|
|
45
35
|
constructor(msg) {
|
|
46
36
|
super(msg);
|
|
@@ -134,7 +124,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
134
124
|
// ponytail: sla is plan-time advisory only — never thread into learnedScore (would reroute warm rivals).
|
|
135
125
|
const scoreOpts = { availWeight: cfg.routing.learnedTuning?.availWeight };
|
|
136
126
|
const entry = cfg.routing.map[task.shape];
|
|
137
|
-
const prefer = effectivePrefer(
|
|
127
|
+
const prefer = effectivePrefer(entry);
|
|
138
128
|
const prefActive = !!(cfg.routing.allow || cfg.routing.deny);
|
|
139
129
|
const disallowedPin = (via, model, kind) => {
|
|
140
130
|
const d = disallowedBy({ adapter: via, model }, cfg.routing);
|
|
@@ -167,6 +157,14 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
167
157
|
}
|
|
168
158
|
};
|
|
169
159
|
const taskFloor = task.routingHints?.floor;
|
|
160
|
+
const mapPinFloor = taskFloor && (!advisoryFloor || TIER_RANK[taskFloor] >= TIER_RANK[advisoryFloor])
|
|
161
|
+
? { tier: taskFloor, source: "task" }
|
|
162
|
+
: advisoryFloor ? { tier: advisoryFloor, source: "config" } : undefined;
|
|
163
|
+
const lintMapPinFloor = (tier) => {
|
|
164
|
+
if (mapPinFloor && TIER_RANK[tier] < TIER_RANK[mapPinFloor.tier]) {
|
|
165
|
+
lints.push(`${task.id} (${task.shape}): map pin routes ${tier}, below ${mapPinFloor.source} floor ${mapPinFloor.tier} — map pins are supreme`);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
170
168
|
const source = task.routingHints?.source;
|
|
171
169
|
const src = source ? `, ${source}` : ""; // never interpolate a possibly-undefined source
|
|
172
170
|
// task pin: planner-authored, try-first — degrades on miss or below-floor (D-05, research A3), never throws
|
|
@@ -188,12 +186,13 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
188
186
|
if (entry?.pin) {
|
|
189
187
|
disallowedPin(entry.pin.via, entry.pin.model, "map pin (config routing.map)");
|
|
190
188
|
const c = resolvePin(entry.pin, channels);
|
|
191
|
-
|
|
189
|
+
lintMapPinFloor(c.tier);
|
|
192
190
|
maybeSlaLint(lints, task, profile, slaMinutes, c);
|
|
193
191
|
return { assignment: toAssignment(c), ladder: ladderFor(task, entry), lints, provenance: `${degraded}pin ${entry.pin.via}:${entry.pin.model} (config routing.map)` };
|
|
194
192
|
}
|
|
195
193
|
const baseTier = floor ?? "cheap";
|
|
196
|
-
|
|
194
|
+
// D-04: a task floor is hard only on floor/auto paths; the map-pin branch above stays supreme.
|
|
195
|
+
const minTier = taskFloor && TIER_RANK[taskFloor] > TIER_RANK[baseTier] ? taskFloor : baseTier;
|
|
197
196
|
if (prefActive)
|
|
198
197
|
for (const p of prefer ?? [])
|
|
199
198
|
preflightPrefer(p);
|
|
@@ -293,13 +292,10 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
293
292
|
"tier cheap (default)";
|
|
294
293
|
// name the key that actually broke the tie: prefer outranks the marginal-cost/tier keys, so if the
|
|
295
294
|
// winner matched a prefer entry, prefer decided it — not "cheapest sufficient tier" (ROUTE-03, WR-01)
|
|
296
|
-
const preferVia = preferFromAuto(task.shape, preferCtx)
|
|
297
|
-
? `via prefer (auto-modernized ${preferCtx.autoPrefer.derivedAt.slice(0, 10)})`
|
|
298
|
-
: "via prefer";
|
|
299
295
|
// a spread-decided winner is never inside a prefer band (the spread skips those runs), so the
|
|
300
296
|
// three arms below are mutually exclusive by construction
|
|
301
297
|
const chosenBy = learnedChosen || (spreadDecided ? "via frontier spread"
|
|
302
|
-
: prefer && preferIndex(eligible[0], prefer) < prefer.length ?
|
|
298
|
+
: prefer && preferIndex(eligible[0], prefer) < prefer.length ? "via prefer" : "cheapest sufficient tier");
|
|
303
299
|
maybeSlaLint(lints, task, profile, slaMinutes, eligible[0]);
|
|
304
300
|
return { assignment: toAssignment(eligible[0]), ladder: ladderFor(task, entry), lints, provenance: `${degraded}${bound}, marginal-cost auto (${chosenBy})`, ...(deviation ? { deviation } : {}) };
|
|
305
301
|
}
|
package/dist/run/consult.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { WorkerAdapter } from "../adapters/types.js";
|
|
|
2
2
|
import type { TickmarkrConfig } from "../config/config.js";
|
|
3
3
|
import type { ExecutorDriver, Slot } from "../drivers/types.js";
|
|
4
4
|
import type { GateResult } from "../gates/types.js";
|
|
5
|
+
import { type VerdictUnparseableCause } from "../gates/verdict-cause.js";
|
|
5
6
|
export interface ConsultVerdict {
|
|
6
7
|
action: "retry" | "reroute" | "decompose" | "human";
|
|
7
8
|
notes: string;
|
|
@@ -23,6 +24,11 @@ export interface Dossier {
|
|
|
23
24
|
diff: string;
|
|
24
25
|
gates: GateResult[];
|
|
25
26
|
}
|
|
27
|
+
export interface ConsultParseResult {
|
|
28
|
+
verdict: ConsultVerdict | null;
|
|
29
|
+
cause?: VerdictUnparseableCause;
|
|
30
|
+
}
|
|
31
|
+
export declare function parseConsultVerdict(out: string, nonce: string): ConsultParseResult;
|
|
26
32
|
export declare function buildDossierPrompt(d: Dossier, nonce: string): string;
|
|
27
33
|
export declare function consult(d: Dossier, cfg: TickmarkrConfig, adapters: WorkerAdapter[], driver: ExecutorDriver, cwd: string, runDir: string, opts?: {
|
|
28
34
|
keep?: boolean;
|
package/dist/run/consult.js
CHANGED
|
@@ -2,7 +2,8 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getAdapter } from "../adapters/registry.js";
|
|
4
4
|
import { bannerShell, paneDispatchCommand } from "../brand.js";
|
|
5
|
-
import {
|
|
5
|
+
import { extractVerdictJson, gateExitTrailer, gatePaneName, generateVerdictNonce, verdictNonceLine } from "../gates/llm.js";
|
|
6
|
+
import { classifyVerdictCause } from "../gates/verdict-cause.js";
|
|
6
7
|
import { sh } from "./git.js";
|
|
7
8
|
import { redactSecrets } from "./redact.js";
|
|
8
9
|
import { filterLlmTranscript } from "./stall.js";
|
|
@@ -57,6 +58,33 @@ export function augmentRetryBrief(feedback, opts) {
|
|
|
57
58
|
return parts.join("\n\n");
|
|
58
59
|
}
|
|
59
60
|
const ACTIONS = ["retry", "reroute", "decompose", "human"];
|
|
61
|
+
export function parseConsultVerdict(out, nonce) {
|
|
62
|
+
const v = extractVerdictJson(out, nonce);
|
|
63
|
+
if (!v)
|
|
64
|
+
return { verdict: null, cause: classifyVerdictCause(out, nonce, "action") };
|
|
65
|
+
// A parsed object with an unknown action is a content rejection, not silence. It keeps the existing
|
|
66
|
+
// null result without manufacturing a classifier cause for a state the classifier never saw.
|
|
67
|
+
if (!ACTIONS.includes(v.action))
|
|
68
|
+
return { verdict: null };
|
|
69
|
+
// fail-closed exclusion: only a non-empty string survives. Malformed values (number/array/object/
|
|
70
|
+
// empty) are dropped — the verdict stays a normal channel-level reroute/retry/…, never a crash
|
|
71
|
+
// and never silently forced to human. Unknown adapter ids pass through; the daemon treats a
|
|
72
|
+
// zero-match expansion as channel-level reroute.
|
|
73
|
+
const raw = v.excludeAdapter;
|
|
74
|
+
const excludeAdapter = typeof raw === "string" && raw.length > 0 ? raw : undefined;
|
|
75
|
+
const reason = typeof v.reason === "string" ? v.reason : undefined;
|
|
76
|
+
const guidance = typeof v.guidance === "string" ? v.guidance : undefined;
|
|
77
|
+
const notes = String(v.notes ?? guidance ?? reason ?? "");
|
|
78
|
+
return {
|
|
79
|
+
verdict: {
|
|
80
|
+
action: v.action,
|
|
81
|
+
notes,
|
|
82
|
+
...(reason ? { reason } : {}),
|
|
83
|
+
...(guidance ? { guidance } : {}),
|
|
84
|
+
...(excludeAdapter ? { excludeAdapter } : {}),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
60
88
|
// v1.65 T2: transcript noise (spinner repaints, CR churn, pass-run spam) is squashed at build time —
|
|
61
89
|
// the filtered form is what persists to the consults/ artifact AND what the model reads; fail-open.
|
|
62
90
|
export function buildDossierPrompt(d, nonce) {
|
|
@@ -108,7 +136,8 @@ opts = {}) {
|
|
|
108
136
|
// consult model reads) is masked at this seam; the in-memory Dossier stays untouched.
|
|
109
137
|
writeFileSync(promptFile, redactSecrets(buildDossierPrompt(d, nonce)));
|
|
110
138
|
// One seat = the WHOLE invoke-and-parse unit, both visibility branches (OBS-69 class: a headless-only
|
|
111
|
-
// failover would leave the production pane path hard-failing on seat one). null
|
|
139
|
+
// failover would leave the production pane path hard-failing on seat one). A null verdict retains
|
|
140
|
+
// the classifier's cause when extraction failed; parsed content rejections deliberately have none.
|
|
112
141
|
const invokeSeat = async (seatAdapter, seatModel, seatIdx) => {
|
|
113
142
|
const adapter = getAdapter(seatAdapter, adapters);
|
|
114
143
|
let out;
|
|
@@ -143,26 +172,7 @@ opts = {}) {
|
|
|
143
172
|
await driver.close(slot);
|
|
144
173
|
}
|
|
145
174
|
}
|
|
146
|
-
|
|
147
|
-
const v = extractVerdictJson(out, nonce);
|
|
148
|
-
if (!v || !ACTIONS.includes(v.action))
|
|
149
|
-
return null;
|
|
150
|
-
// fail-closed exclusion: only a non-empty string survives. Malformed values (number/array/object/
|
|
151
|
-
// empty) are dropped — the verdict stays a normal channel-level reroute/retry/…, never a crash
|
|
152
|
-
// and never silently forced to human. Unknown adapter ids pass through; the daemon treats a
|
|
153
|
-
// zero-match expansion as channel-level reroute.
|
|
154
|
-
const raw = v.excludeAdapter;
|
|
155
|
-
const excludeAdapter = typeof raw === "string" && raw.length > 0 ? raw : undefined;
|
|
156
|
-
const reason = typeof v.reason === "string" ? v.reason : undefined;
|
|
157
|
-
const guidance = typeof v.guidance === "string" ? v.guidance : undefined;
|
|
158
|
-
const notes = String(v.notes ?? guidance ?? reason ?? "");
|
|
159
|
-
return {
|
|
160
|
-
action: v.action,
|
|
161
|
-
notes,
|
|
162
|
-
...(reason ? { reason } : {}),
|
|
163
|
-
...(guidance ? { guidance } : {}),
|
|
164
|
-
...(excludeAdapter ? { excludeAdapter } : {}),
|
|
165
|
-
};
|
|
175
|
+
return parseConsultVerdict(out, nonce);
|
|
166
176
|
};
|
|
167
177
|
// v1.54 T1: ranked seat failover. Walk consult.prefer (adapter:model entries) to the first entry
|
|
168
178
|
// whose adapter is in the live channel set; a failed seat or unparseable verdict falls to the next;
|
|
@@ -176,9 +186,9 @@ opts = {}) {
|
|
|
176
186
|
seats.push({ adapter: cfg.consult.adapter, model: cfg.consult.model });
|
|
177
187
|
for (const [i, seat] of seats.entries()) {
|
|
178
188
|
try {
|
|
179
|
-
const
|
|
180
|
-
if (
|
|
181
|
-
return
|
|
189
|
+
const parsed = await invokeSeat(seat.adapter, seat.model, i);
|
|
190
|
+
if (parsed.verdict)
|
|
191
|
+
return parsed.verdict;
|
|
182
192
|
}
|
|
183
193
|
catch {
|
|
184
194
|
// failed seat (unknown adapter, dead driver/pane, shell error) — fall to the next entry
|
package/dist/run/daemon.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { shq } from "../adapters/types.js";
|
|
3
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { stringify } from "yaml";
|
|
@@ -631,6 +631,71 @@ async function cherryPickCommits(wt, commits) {
|
|
|
631
631
|
}
|
|
632
632
|
return carried;
|
|
633
633
|
}
|
|
634
|
+
// T7 (v1.86): a first run-end append that fails AFTER partial bytes landed leaves a torn tail at
|
|
635
|
+
// EOF with no newline; a blind retry would glue the run-end line onto those bytes and readJsonl's
|
|
636
|
+
// torn-line tolerance would drop the retry too — no terminal record despite a successful write.
|
|
637
|
+
// Terminating the torn fragment keeps it on disk (dropped as a malformed line, never truncated) so
|
|
638
|
+
// the retried run-end lands on a line of its own.
|
|
639
|
+
const terminateTornJournalTail = (journalPath) => {
|
|
640
|
+
if (!existsSync(journalPath))
|
|
641
|
+
return;
|
|
642
|
+
const size = statSync(journalPath).size;
|
|
643
|
+
if (size === 0)
|
|
644
|
+
return;
|
|
645
|
+
const fd = openSync(journalPath, "r");
|
|
646
|
+
try {
|
|
647
|
+
const tail = Buffer.alloc(1);
|
|
648
|
+
if (readSync(fd, tail, 0, 1, size - 1) === 1 && tail[0] !== 0x0a)
|
|
649
|
+
appendFileSync(journalPath, "\n");
|
|
650
|
+
}
|
|
651
|
+
finally {
|
|
652
|
+
closeSync(fd);
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
// T7 (v1.86): the fatal handler must never eat the error it reports. Both journal calls are guarded,
|
|
656
|
+
// so a sink failure is reported ALONGSIDE the original error (console.error — the dispatcher's
|
|
657
|
+
// operator-visible line stays the one-line form), never instead of it: a read failure degrades the
|
|
658
|
+
// duplicate-run-end check to "unknown" and fails toward recording; the append is retried ONCE; and
|
|
659
|
+
// a persistently unwritable sink reports a crash naming the journal path and carrying NO terminal
|
|
660
|
+
// record, rather than fabricating an ended run on evidence the harness could not write. (OBS-313:
|
|
661
|
+
// with the sink dead the crash CAUSE is unrecordable — recorded as an observation, not papered over.)
|
|
662
|
+
function recordFatalRunEnd(journal, runId, branch, err) {
|
|
663
|
+
const original = err instanceof Error ? err.message : String(err);
|
|
664
|
+
try {
|
|
665
|
+
if (journal.read().some((e) => e.event === "run-end"))
|
|
666
|
+
return; // already terminal — nothing to add
|
|
667
|
+
}
|
|
668
|
+
catch (readErr) {
|
|
669
|
+
console.error(`tickmarkr ${runId}: journal read failed while recording the fatal run-end (${readErr instanceof Error ? readErr.message : String(readErr)}) — original error: ${original}`);
|
|
670
|
+
}
|
|
671
|
+
const record = {
|
|
672
|
+
runId,
|
|
673
|
+
branch,
|
|
674
|
+
done: [],
|
|
675
|
+
failed: [],
|
|
676
|
+
human: [],
|
|
677
|
+
blocked: [],
|
|
678
|
+
pending: [],
|
|
679
|
+
phase: "setup",
|
|
680
|
+
fatal: true,
|
|
681
|
+
error: original,
|
|
682
|
+
};
|
|
683
|
+
try {
|
|
684
|
+
journal.append("run-end", undefined, record);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
// one retry below — the first failure is reported only if the retry also fails
|
|
689
|
+
}
|
|
690
|
+
const journalPath = join(journal.dir, "journal.jsonl");
|
|
691
|
+
try {
|
|
692
|
+
terminateTornJournalTail(journalPath);
|
|
693
|
+
journal.append("run-end", undefined, record);
|
|
694
|
+
}
|
|
695
|
+
catch (retryErr) {
|
|
696
|
+
console.error(`tickmarkr ${runId}: run crashed — no terminal record written; journal sink unwritable at ${journalPath} (${retryErr instanceof Error ? retryErr.message : String(retryErr)}) — original error: ${original}`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
634
699
|
export async function runDaemon(repoRoot, opts = {}) {
|
|
635
700
|
// v1.51 T2 / OBS-89 (v1.60): retired --quality env seam. Mode resolution owns premium routing;
|
|
636
701
|
// route() no longer reads the retired env at all, so the old entrypoint scrub is gone with it.
|
|
@@ -2719,20 +2784,11 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2719
2784
|
return summary;
|
|
2720
2785
|
}
|
|
2721
2786
|
catch (err) {
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
failed: [],
|
|
2728
|
-
human: [],
|
|
2729
|
-
blocked: [],
|
|
2730
|
-
pending: [],
|
|
2731
|
-
phase: "setup",
|
|
2732
|
-
fatal: true,
|
|
2733
|
-
error: err instanceof Error ? err.message : String(err),
|
|
2734
|
-
});
|
|
2735
|
-
}
|
|
2787
|
+
// T7 (v1.86): guarded — a journal read/append failure while recording the fatal run-end is
|
|
2788
|
+
// reported alongside err, never instead of it; recordFatalRunEnd never throws, so the original
|
|
2789
|
+
// error (message, stack, cause) always reaches the caller verbatim.
|
|
2790
|
+
if (runStarted && !taskLoopStarted)
|
|
2791
|
+
recordFatalRunEnd(journal, runId, branch, err);
|
|
2736
2792
|
throw err;
|
|
2737
2793
|
}
|
|
2738
2794
|
finally {
|