tickmarkr 1.30.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 +347 -0
- package/dist/adapters/claude-code.d.ts +4 -0
- package/dist/adapters/claude-code.js +191 -0
- package/dist/adapters/codex.d.ts +8 -0
- package/dist/adapters/codex.js +257 -0
- package/dist/adapters/cursor-agent.d.ts +4 -0
- package/dist/adapters/cursor-agent.js +62 -0
- package/dist/adapters/fake.d.ts +45 -0
- package/dist/adapters/fake.js +115 -0
- package/dist/adapters/grok.d.ts +4 -0
- package/dist/adapters/grok.js +122 -0
- package/dist/adapters/model-lints.d.ts +6 -0
- package/dist/adapters/model-lints.js +130 -0
- package/dist/adapters/opencode.d.ts +3 -0
- package/dist/adapters/opencode.js +108 -0
- package/dist/adapters/pi.d.ts +3 -0
- package/dist/adapters/pi.js +191 -0
- package/dist/adapters/prompt.d.ts +6 -0
- package/dist/adapters/prompt.js +84 -0
- package/dist/adapters/registry.d.ts +31 -0
- package/dist/adapters/registry.js +165 -0
- package/dist/adapters/types.d.ts +98 -0
- package/dist/adapters/types.js +56 -0
- package/dist/cli/commands/approve.d.ts +1 -0
- package/dist/cli/commands/approve.js +72 -0
- package/dist/cli/commands/compile.d.ts +1 -0
- package/dist/cli/commands/compile.js +23 -0
- package/dist/cli/commands/doctor.d.ts +2 -0
- package/dist/cli/commands/doctor.js +103 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/init.js +32 -0
- package/dist/cli/commands/plan.d.ts +2 -0
- package/dist/cli/commands/plan.js +101 -0
- package/dist/cli/commands/profile.d.ts +1 -0
- package/dist/cli/commands/profile.js +56 -0
- package/dist/cli/commands/report.d.ts +4 -0
- package/dist/cli/commands/report.js +306 -0
- package/dist/cli/commands/resume.d.ts +1 -0
- package/dist/cli/commands/resume.js +11 -0
- package/dist/cli/commands/run.d.ts +1 -0
- package/dist/cli/commands/run.js +35 -0
- package/dist/cli/commands/scope.d.ts +3 -0
- package/dist/cli/commands/scope.js +22 -0
- package/dist/cli/commands/status.d.ts +9 -0
- package/dist/cli/commands/status.js +162 -0
- package/dist/cli/commands/unlock.d.ts +1 -0
- package/dist/cli/commands/unlock.js +11 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.js +65 -0
- package/dist/compile/collateral.d.ts +6 -0
- package/dist/compile/collateral.js +128 -0
- package/dist/compile/common.d.ts +12 -0
- package/dist/compile/common.js +56 -0
- package/dist/compile/gsd.d.ts +3 -0
- package/dist/compile/gsd.js +222 -0
- package/dist/compile/index.d.ts +4 -0
- package/dist/compile/index.js +33 -0
- package/dist/compile/native.d.ts +4 -0
- package/dist/compile/native.js +196 -0
- package/dist/compile/prd.d.ts +2 -0
- package/dist/compile/prd.js +64 -0
- package/dist/compile/speckit.d.ts +2 -0
- package/dist/compile/speckit.js +66 -0
- package/dist/config/config.d.ts +184 -0
- package/dist/config/config.js +329 -0
- package/dist/drivers/herdr.d.ts +44 -0
- package/dist/drivers/herdr.js +394 -0
- package/dist/drivers/index.d.ts +3 -0
- package/dist/drivers/index.js +13 -0
- package/dist/drivers/subprocess.d.ts +25 -0
- package/dist/drivers/subprocess.js +106 -0
- package/dist/drivers/types.d.ts +61 -0
- package/dist/drivers/types.js +77 -0
- package/dist/gates/acceptance.d.ts +19 -0
- package/dist/gates/acceptance.js +92 -0
- package/dist/gates/baseline.d.ts +12 -0
- package/dist/gates/baseline.js +97 -0
- package/dist/gates/evidence.d.ts +4 -0
- package/dist/gates/evidence.js +12 -0
- package/dist/gates/llm.d.ts +25 -0
- package/dist/gates/llm.js +101 -0
- package/dist/gates/review.d.ts +12 -0
- package/dist/gates/review.js +80 -0
- package/dist/gates/run-gates.d.ts +34 -0
- package/dist/gates/run-gates.js +105 -0
- package/dist/gates/scope.d.ts +8 -0
- package/dist/gates/scope.js +32 -0
- package/dist/gates/types.d.ts +6 -0
- package/dist/gates/types.js +1 -0
- package/dist/graph/graph.d.ts +20 -0
- package/dist/graph/graph.js +148 -0
- package/dist/graph/schema.d.ts +185 -0
- package/dist/graph/schema.js +141 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/plan/prompt.d.ts +4 -0
- package/dist/plan/prompt.js +31 -0
- package/dist/plan/scope.d.ts +17 -0
- package/dist/plan/scope.js +141 -0
- package/dist/report/cost.d.ts +24 -0
- package/dist/report/cost.js +89 -0
- package/dist/route/preference.d.ts +27 -0
- package/dist/route/preference.js +49 -0
- package/dist/route/profile.d.ts +65 -0
- package/dist/route/profile.js +197 -0
- package/dist/route/router.d.ts +26 -0
- package/dist/route/router.js +200 -0
- package/dist/run/consult.d.ts +23 -0
- package/dist/run/consult.js +80 -0
- package/dist/run/daemon.d.ts +23 -0
- package/dist/run/daemon.js +728 -0
- package/dist/run/git.d.ts +12 -0
- package/dist/run/git.js +56 -0
- package/dist/run/journal.d.ts +91 -0
- package/dist/run/journal.js +287 -0
- package/dist/run/lock.d.ts +29 -0
- package/dist/run/lock.js +182 -0
- package/dist/run/merge.d.ts +12 -0
- package/dist/run/merge.js +44 -0
- package/dist/run/reconcile.d.ts +4 -0
- package/dist/run/reconcile.js +95 -0
- package/package.json +39 -0
- package/schema/rungraph.schema.json +306 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// ---- Ownership contract (T1: OBS-17 pane-hygiene) --------------------------------------------
|
|
2
|
+
// Every pane/tab drovr creates is identified by exactly one parseable token:
|
|
3
|
+
// drovr:<role>:<taskId>:<attempt>:<runId>
|
|
4
|
+
// role ∈ OWNED_ROLES; attempt is a non-negative integer; taskId/runId never contain ":" (task and
|
|
5
|
+
// run ids are alphanumeric/dash by construction elsewhere). formatOwnedName/parseOwnedName round-trip
|
|
6
|
+
// exactly. parseOwnedName (or isForeignName) is the ONLY way reconcile.ts may decide a live pane name
|
|
7
|
+
// is drovr-owned — anything that doesn't parse is foreign and is never a candidate for closing.
|
|
8
|
+
export const OWNED_ROLES = ["worker", "judge", "review", "consult", "watch", "other"];
|
|
9
|
+
const OWNED_PREFIX = "drovr";
|
|
10
|
+
const OWNED_RE = new RegExp(`^${OWNED_PREFIX}:(${OWNED_ROLES.join("|")}):([^:]+):(\\d+):([^:]+)$`);
|
|
11
|
+
export function formatOwnedName(o) {
|
|
12
|
+
return `${OWNED_PREFIX}:${o.role}:${o.taskId}:${o.attempt}:${o.runId}`;
|
|
13
|
+
}
|
|
14
|
+
export function parseOwnedName(name) {
|
|
15
|
+
const m = OWNED_RE.exec(name);
|
|
16
|
+
if (!m)
|
|
17
|
+
return null;
|
|
18
|
+
return { role: m[1], taskId: m[2], attempt: Number(m[3]), runId: m[4] };
|
|
19
|
+
}
|
|
20
|
+
export function isForeignName(name) {
|
|
21
|
+
return parseOwnedName(name) === null;
|
|
22
|
+
}
|
|
23
|
+
// v1.22b T1: workspace-aware fold over a fleet snapshot — decides which owned panes are garbage
|
|
24
|
+
// right now. In-workspace: the existing desired-set/spareLiveLlm sweep (OBS-17 T2). Out-of-workspace:
|
|
25
|
+
// an owned pane from a DIFFERENT run is a misplaced leftover (bug, foreign actor, pre-VIS-10 relic)
|
|
26
|
+
// and closes regardless of `desired`; an owned pane from THIS run elsewhere is left alone — a live
|
|
27
|
+
// run can legitimately hold panes across workspaces, so only run age marks a misplaced pane garbage.
|
|
28
|
+
// Foreign names (parseOwnedName fails) are never candidates, in any workspace.
|
|
29
|
+
export function panesToClose(agents, desired, ws, runId, opts) {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const a of agents) {
|
|
32
|
+
if (typeof a.name !== "string" || typeof a.paneId !== "string")
|
|
33
|
+
continue;
|
|
34
|
+
const owned = parseOwnedName(a.name);
|
|
35
|
+
if (!owned)
|
|
36
|
+
continue;
|
|
37
|
+
if (a.workspaceId === ws) {
|
|
38
|
+
if (desired.has(a.name))
|
|
39
|
+
continue;
|
|
40
|
+
if (opts?.spareLiveLlm && owned.runId === runId && (owned.role === "judge" || owned.role === "review" || owned.role === "consult"))
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
else if (owned.runId === runId) {
|
|
44
|
+
continue; // this run's own pane in another workspace — never touched
|
|
45
|
+
}
|
|
46
|
+
out.push({ paneId: a.paneId, tabId: a.tabId });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
// Legacy raw name shapes daemon.ts/gates/llm.ts/consult.ts/herdr.ts's narrator() build today (daemon.ts
|
|
51
|
+
// and gates/llm.ts are out of this task's file scope): "<taskId>-worker-<adapter>-a<attempt>-<runTag>"
|
|
52
|
+
// (daemon.ts), "<role> · <taskId>[-r1]" (gates/llm.ts gatePaneName, also covers consult.ts), and
|
|
53
|
+
// "narrator-watch-<pid>" (herdr.ts). This is the ONE place that knows their shapes — used by
|
|
54
|
+
// HerdrDriver's internal bookkeeping (renameGroupTab/glyphFor) and by reconcile.ts to recognize what
|
|
55
|
+
// a live pane's REAL name decodes to, without requiring a call-site migration yet. Callers that
|
|
56
|
+
// already have the structured fields should pass `owned` directly instead (T2 retires this parsing).
|
|
57
|
+
// runId is supplied by the caller's own run context, not read out of the string — none of today's
|
|
58
|
+
// legacy shapes lexically carry it except the worker shape's runTag.
|
|
59
|
+
const WORKER_RE = /^(.+)-worker-.+-a(\d+)-(.+)$/;
|
|
60
|
+
const GATE_ROLE_RE = /^(judge|review|consult) · (.+)$/;
|
|
61
|
+
const NARRATOR_RE = /^narrator-watch-\d+$/;
|
|
62
|
+
export function canonicalizeLegacyName(name, runId) {
|
|
63
|
+
const already = parseOwnedName(name);
|
|
64
|
+
if (already)
|
|
65
|
+
return already;
|
|
66
|
+
const w = WORKER_RE.exec(name);
|
|
67
|
+
if (w)
|
|
68
|
+
return { role: "worker", taskId: w[1], attempt: Number(w[2]), runId: `run-${w[3]}` };
|
|
69
|
+
const g = GATE_ROLE_RE.exec(name);
|
|
70
|
+
if (g) {
|
|
71
|
+
const retry = g[2].endsWith("-r1");
|
|
72
|
+
return { role: g[1], taskId: retry ? g[2].slice(0, -3) : g[2], attempt: retry ? 1 : 0, runId };
|
|
73
|
+
}
|
|
74
|
+
if (NARRATOR_RE.test(name))
|
|
75
|
+
return { role: "watch", taskId: "run", attempt: 0, runId };
|
|
76
|
+
return { role: "other", taskId: name, attempt: 0, runId };
|
|
77
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type WorkerAdapter } from "../adapters/types.js";
|
|
2
|
+
import { type Task } from "../graph/schema.js";
|
|
3
|
+
import { type LlmVia } from "./llm.js";
|
|
4
|
+
import type { GateResult } from "./types.js";
|
|
5
|
+
export interface JudgeVerdict {
|
|
6
|
+
pass: boolean;
|
|
7
|
+
criteria: Array<{
|
|
8
|
+
criterion: string;
|
|
9
|
+
met: boolean;
|
|
10
|
+
reason: string;
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
13
|
+
export interface AcceptanceGateOpts {
|
|
14
|
+
testCmd?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function acceptanceGate(task: Task, worktree: string, baseRef: string, judge: {
|
|
17
|
+
adapter: WorkerAdapter;
|
|
18
|
+
model: string;
|
|
19
|
+
}, via?: LlmVia, opts?: AcceptanceGateOpts): Promise<GateResult>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { channelKey, shq } from "../adapters/types.js";
|
|
2
|
+
import { renderAcceptanceItem } from "../graph/schema.js";
|
|
3
|
+
import { sh, shOk } from "../run/git.js";
|
|
4
|
+
import { extractJson, runLlm } from "./llm.js";
|
|
5
|
+
const DIFF_CAP = 60_000; // judge sees diff + criteria only, and not unboundedly (spec §12)
|
|
6
|
+
const isCommand = (a) => typeof a === "object" && a.oracle === "command";
|
|
7
|
+
const isTest = (a) => typeof a === "object" && a.oracle === "test";
|
|
8
|
+
const isJudge = (a) => typeof a === "string" || (typeof a === "object" && a.oracle === "judge");
|
|
9
|
+
// npm/yarn/pnpm/npx script wrappers need `--` to forward -t to the underlying vitest/jest runner; a bare
|
|
10
|
+
// runner (vitest/jest) takes -t directly. -t is the shared testNamePattern shorthand both honor.
|
|
11
|
+
function testFiltered(testCmd, name) {
|
|
12
|
+
const fwd = /^\s*(npm|yarn|pnpm|npx)\b/.test(testCmd) ? "-- " : "";
|
|
13
|
+
return `${testCmd} ${fwd}-t ${shq(name)}`;
|
|
14
|
+
}
|
|
15
|
+
// last few non-empty output lines, for context on a failing oracle (capped so details stay readable)
|
|
16
|
+
function tail(out, n = 8) {
|
|
17
|
+
const t = out.trim();
|
|
18
|
+
if (!t)
|
|
19
|
+
return "";
|
|
20
|
+
return "\n" + t.split("\n").slice(-n).join("\n");
|
|
21
|
+
}
|
|
22
|
+
export async function acceptanceGate(task, worktree, baseRef, judge, via, opts = {}) {
|
|
23
|
+
// 1. deterministic oracles — exit code decides, fail-closed, zero LLM calls (spec §2, T2).
|
|
24
|
+
// A failure returns here, before any runLlm() call: a judge can never override it.
|
|
25
|
+
const passedDet = [];
|
|
26
|
+
for (const a of task.acceptance) {
|
|
27
|
+
if (isCommand(a)) {
|
|
28
|
+
const r = await sh(a.command, worktree);
|
|
29
|
+
if (r.code !== 0) {
|
|
30
|
+
return { gate: "acceptance", pass: false,
|
|
31
|
+
details: `oracle failed: $ ${a.command} (exit ${r.code})${tail(r.stderr || r.stdout)}` };
|
|
32
|
+
}
|
|
33
|
+
passedDet.push(`✓ $ ${a.command} (exit 0)`);
|
|
34
|
+
}
|
|
35
|
+
else if (isTest(a)) {
|
|
36
|
+
if (!opts.testCmd) {
|
|
37
|
+
return { gate: "acceptance", pass: false,
|
|
38
|
+
details: `oracle failed: test "${a.test}" — no test command configured to run it (failing closed)` };
|
|
39
|
+
}
|
|
40
|
+
const r = await sh(testFiltered(opts.testCmd, a.test), worktree);
|
|
41
|
+
if (r.code !== 0) {
|
|
42
|
+
return { gate: "acceptance", pass: false,
|
|
43
|
+
details: `oracle failed: test "${a.test}" (exit ${r.code})${tail(r.stderr || r.stdout)}` };
|
|
44
|
+
}
|
|
45
|
+
passedDet.push(`✓ test: ${a.test} (exit 0)`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// 2. judge items — only after every deterministic oracle passed (a failed one returned above).
|
|
49
|
+
const judgeItems = task.acceptance.filter(isJudge);
|
|
50
|
+
const detBlock = passedDet.length ? passedDet.join("\n") + "\n" : "";
|
|
51
|
+
if (!judgeItems.length) {
|
|
52
|
+
// deterministic oracles alone decided the gate — no LLM spend.
|
|
53
|
+
return { gate: "acceptance", pass: true, details: passedDet.join("\n") || "no acceptance oracles" };
|
|
54
|
+
}
|
|
55
|
+
// spec §2: deterministic oracles preferred; an only-judge task gets an explicit warning in details.
|
|
56
|
+
const onlyJudge = passedDet.length === 0;
|
|
57
|
+
const warn = onlyJudge
|
|
58
|
+
? "WARNING: only judge oracles — no deterministic command/test oracle guards this task (deterministic preferred, spec §2).\n"
|
|
59
|
+
: "";
|
|
60
|
+
const diff = (await shOk(`git diff '${baseRef}..HEAD'`, worktree)).slice(0, DIFF_CAP);
|
|
61
|
+
const prompt = `DROVR-JUDGE
|
|
62
|
+
You are a strict acceptance judge. Decide whether the diff satisfies EVERY acceptance criterion.
|
|
63
|
+
Judge only what the diff proves — plausible-but-wrong must fail. Do not award partial credit.
|
|
64
|
+
Deterministic command/test oracles have already passed mechanically; judge ONLY the rubric items below.
|
|
65
|
+
|
|
66
|
+
## Task ${task.id}: ${task.title}
|
|
67
|
+
Goal: ${task.goal}
|
|
68
|
+
|
|
69
|
+
## Acceptance criteria (judge)
|
|
70
|
+
${judgeItems.map((a) => `- ${renderAcceptanceItem(a)}`).join("\n")}
|
|
71
|
+
|
|
72
|
+
## Diff (vs base)
|
|
73
|
+
\`\`\`diff
|
|
74
|
+
${diff}
|
|
75
|
+
\`\`\`
|
|
76
|
+
|
|
77
|
+
Respond with ONLY this JSON (no prose before or after):
|
|
78
|
+
{"pass": true|false, "criteria": [{"criterion": "...", "met": true|false, "reason": "..."}]}
|
|
79
|
+
`;
|
|
80
|
+
const raw = await runLlm(judge.adapter, judge.model, prompt, worktree, via);
|
|
81
|
+
const v = extractJson(raw);
|
|
82
|
+
if (!v || typeof v.pass !== "boolean" || !Array.isArray(v.criteria)) {
|
|
83
|
+
// GATE-09: structured meta names the flaked judge channel (mirrors review.ts:99 meta precedent) so
|
|
84
|
+
// run-gates can retry the judge on a failover channel without string-matching details (D-03).
|
|
85
|
+
// The parsed-verdict paths below are untouched — the flake signal is exactly extractJson→null here.
|
|
86
|
+
return { gate: "acceptance", pass: false,
|
|
87
|
+
details: warn + detBlock + "judge output unparseable — failing closed",
|
|
88
|
+
meta: { unparseable: true, judge: channelKey({ adapter: judge.adapter.id, model: judge.model }) } };
|
|
89
|
+
}
|
|
90
|
+
const lines = v.criteria.map((c) => `${c.met ? "✓" : "✗"} ${c.criterion}: ${c.reason}`);
|
|
91
|
+
return { gate: "acceptance", pass: v.pass, details: warn + detBlock + (lines.join("\n") || (v.pass ? "judge passed" : "judge failed")) };
|
|
92
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DrovrConfig } from "../config/config.js";
|
|
2
|
+
import type { GateResult } from "./types.js";
|
|
3
|
+
export interface Baseline {
|
|
4
|
+
commands: Record<string, {
|
|
5
|
+
exitCode: number;
|
|
6
|
+
fingerprints: string[];
|
|
7
|
+
}>;
|
|
8
|
+
}
|
|
9
|
+
export declare function fingerprint(output: string): string[];
|
|
10
|
+
export declare function detectGateCommands(repoRoot: string, cfg: DrovrConfig): Record<string, string>;
|
|
11
|
+
export declare function captureBaseline(cwd: string, commands: Record<string, string>): Promise<Baseline>;
|
|
12
|
+
export declare function compareToBaseline(cwd: string, commands: Record<string, string>, baseline: Baseline, enabled: string[]): Promise<GateResult[]>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { sh } from "../run/git.js";
|
|
4
|
+
// incident #2 (run-20260709-104447): a vitest ✓ PASS line with "error" in the test NAME, wrapped in ANSI
|
|
5
|
+
// codes that varied between baseline and worktree runs, was reported as a "new failure". Strip ANSI first;
|
|
6
|
+
// a pass-marker line is never a failure. [\d;#] covers raw ANSI and digit-normalized ANSI ("\x1b[#m") from
|
|
7
|
+
// baselines stored by pre-hardening code.
|
|
8
|
+
const ANSI_RE = /\x1b\[[\d;#]*[A-Za-z]/g;
|
|
9
|
+
// ponytail: only leading ✓/✔ after optional "label:" prefixes (turbo/vitest) counts as a pass line — other
|
|
10
|
+
// runners' pass markers (PASS, ok) stay fingerprintable; extend the regex when a real one bites
|
|
11
|
+
const PASS_LINE_RE = /^\s*(?:[\w@./-]+:\s*)*[✓✔]/;
|
|
12
|
+
// HYG-08 (D-01, incident run-20260711-154920): a failing test went unnamed for 3 attempts because details
|
|
13
|
+
// headlined benign fingerprint-diff noise. These anchors harvest the runner's OWN failure naming from fresh
|
|
14
|
+
// output to headline it. \s is fine in a TS regex — the BSD [[:space:]] rule binds shell grep only.
|
|
15
|
+
const FAIL_ANCHOR_RE = /^\s*FAIL\s+/; // vitest/jest: " FAIL <file> > <suite> > <test>"
|
|
16
|
+
const SUMMARY_FAIL_RE = /^\s*Tests?\s+(?:Files?\s+)?\d+\s+failed/; // " Tests N failed | M passed (T)"
|
|
17
|
+
const normalizeLine = (l) => l.replace(/\d+/g, "#").replace(/\s+/g, " ").trim();
|
|
18
|
+
export function fingerprint(output) {
|
|
19
|
+
const lines = output
|
|
20
|
+
.split("\n")
|
|
21
|
+
.map((l) => l.replace(ANSI_RE, ""))
|
|
22
|
+
.filter((l) => !PASS_LINE_RE.test(l) && /\b(error|fail(ed|ure|ing)?)\b/i.test(l))
|
|
23
|
+
.map(normalizeLine);
|
|
24
|
+
return [...new Set(lines)];
|
|
25
|
+
}
|
|
26
|
+
// stored baselines may predate ANSI/pass-marker hardening — renormalize at compare time so existing
|
|
27
|
+
// on-disk baseline.json files stay comparable without recapture (compat invariant, CLAUDE.md)
|
|
28
|
+
const renormalize = (fp) => normalizeLine(fp.replace(ANSI_RE, ""));
|
|
29
|
+
export function detectGateCommands(repoRoot, cfg) {
|
|
30
|
+
const out = {};
|
|
31
|
+
const pkgPath = join(repoRoot, "package.json");
|
|
32
|
+
const scripts = existsSync(pkgPath)
|
|
33
|
+
? (JSON.parse(readFileSync(pkgPath, "utf8")).scripts ?? {})
|
|
34
|
+
: {};
|
|
35
|
+
for (const name of ["build", "test", "lint"]) {
|
|
36
|
+
if (cfg.gates[name])
|
|
37
|
+
out[name] = cfg.gates[name];
|
|
38
|
+
else if (scripts[name])
|
|
39
|
+
out[name] = `npm run -s ${name}`;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
export async function captureBaseline(cwd, commands) {
|
|
44
|
+
const base = { commands: {} };
|
|
45
|
+
for (const [name, cmd] of Object.entries(commands)) {
|
|
46
|
+
const r = await sh(cmd, cwd);
|
|
47
|
+
// ponytail: strip the executing cwd so repo-root capture and worktree compare fingerprint identically; /private-vs-/tmp symlink variance is out of scope
|
|
48
|
+
base.commands[name] = { exitCode: r.code, fingerprints: fingerprint((r.stdout + "\n" + r.stderr).split(cwd).join("")) };
|
|
49
|
+
}
|
|
50
|
+
return base;
|
|
51
|
+
}
|
|
52
|
+
// HYG-08 (D-01): headline the runner's own failure naming; demote the fingerprint diff to a secondary
|
|
53
|
+
// section. Extracts from `raw` — the SAME cwd-stripped, per-line ANSI_RE-stripped string that was
|
|
54
|
+
// fingerprinted, digits UN-normalized (Pitfall 2: normalization mangles test names, and the diff set could
|
|
55
|
+
// drop a FAIL line that fingerprint-collides with baseline noise). No headline anchors → byte-identical
|
|
56
|
+
// fallback to today's text (non-vitest runners lose nothing). RED-pinned by tests/gates/baseline.test.ts
|
|
57
|
+
// "HYG-08: details headlines the failing test, not the noise".
|
|
58
|
+
function headlineDetails(raw, fresh) {
|
|
59
|
+
const headlines = raw
|
|
60
|
+
.split("\n")
|
|
61
|
+
.map((l) => l.replace(ANSI_RE, ""))
|
|
62
|
+
.filter((l) => FAIL_ANCHOR_RE.test(l) || SUMMARY_FAIL_RE.test(l));
|
|
63
|
+
if (!headlines.length)
|
|
64
|
+
return { details: `new failures vs baseline:\n${fresh.join("\n")}` };
|
|
65
|
+
return {
|
|
66
|
+
details: `failing tests:\n${headlines.join("\n")}\n\nnew failure fingerprints vs baseline (secondary):\n${fresh.join("\n")}`,
|
|
67
|
+
meta: { failingTests: headlines.filter((l) => FAIL_ANCHOR_RE.test(l)) },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export async function compareToBaseline(cwd, commands, baseline, enabled) {
|
|
71
|
+
const results = [];
|
|
72
|
+
for (const name of enabled) {
|
|
73
|
+
const cmd = commands[name];
|
|
74
|
+
if (!cmd)
|
|
75
|
+
continue; // nothing detected for this gate in the target repo
|
|
76
|
+
const r = await sh(cmd, cwd);
|
|
77
|
+
if (r.code === 0) {
|
|
78
|
+
results.push({ gate: name, pass: true, details: "exit 0" });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const raw = (r.stdout + "\n" + r.stderr).split(cwd).join("");
|
|
82
|
+
const known = new Set((baseline.commands[name]?.fingerprints ?? []).map(renormalize));
|
|
83
|
+
const fresh = fingerprint(raw).filter((f) => !known.has(f));
|
|
84
|
+
if (!fresh.length && (baseline.commands[name]?.exitCode ?? 1) === 0) {
|
|
85
|
+
results.push({
|
|
86
|
+
gate: name,
|
|
87
|
+
pass: false,
|
|
88
|
+
details: `command was green at baseline but now exits ${r.code} with no recognizable failure lines — failing closed`,
|
|
89
|
+
});
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
results.push(fresh.length
|
|
93
|
+
? { gate: name, pass: false, ...headlineDetails(raw, fresh) }
|
|
94
|
+
: { gate: name, pass: true, details: `exit ${r.code} but only pre-existing failures (forgiven)` });
|
|
95
|
+
}
|
|
96
|
+
return results;
|
|
97
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { shOk } from "../run/git.js";
|
|
2
|
+
export async function evidenceGate(worktree, baseRef) {
|
|
3
|
+
const commits = (await shOk(`git rev-list '${baseRef}..HEAD'`, worktree)).trim().split("\n").filter(Boolean);
|
|
4
|
+
if (!commits.length) {
|
|
5
|
+
return { gate: "evidence", pass: false, details: "no commits — worker claimed work but committed nothing", commits: [] };
|
|
6
|
+
}
|
|
7
|
+
const stat = (await shOk(`git diff --stat '${baseRef}..HEAD'`, worktree)).trim();
|
|
8
|
+
if (!stat) {
|
|
9
|
+
return { gate: "evidence", pass: false, details: "commits exist but the cumulative diff is empty", commits };
|
|
10
|
+
}
|
|
11
|
+
return { gate: "evidence", pass: true, details: `${commits.length} commit(s):\n${stat}`, commits };
|
|
12
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { WorkerAdapter } from "../adapters/types.js";
|
|
2
|
+
import { type ExecutorDriver, type Slot } from "../drivers/types.js";
|
|
3
|
+
export declare const GATE_PANE_SEP = " \u00B7 ";
|
|
4
|
+
export type GatePaneRole = "judge" | "review" | "consult";
|
|
5
|
+
/** T8: role-first pane name for fleet visibility — judge · T4, review · T3, consult · T2. */
|
|
6
|
+
export declare function gatePaneName(role: GatePaneRole, taskId: string, suffix?: string): string;
|
|
7
|
+
export declare function rolePaneNameFromPrompt(prompt: string, fallback: string): string;
|
|
8
|
+
export interface LlmVia {
|
|
9
|
+
driver: ExecutorDriver;
|
|
10
|
+
name: string;
|
|
11
|
+
label?: string;
|
|
12
|
+
keep?: boolean;
|
|
13
|
+
onSlot?: (slot: Slot) => void;
|
|
14
|
+
}
|
|
15
|
+
export interface GateVia {
|
|
16
|
+
driver: ExecutorDriver;
|
|
17
|
+
keep?: boolean;
|
|
18
|
+
onSlot?: (slot: Slot) => void;
|
|
19
|
+
nameFor: (role: "judge" | "review", adapter: string) => string;
|
|
20
|
+
labelFor: (role: "judge" | "review") => string;
|
|
21
|
+
}
|
|
22
|
+
export declare function runHeadless(adapter: WorkerAdapter, model: string, prompt: string, cwd: string, timeoutMs?: number): Promise<string>;
|
|
23
|
+
export declare function runViaDriver(adapter: WorkerAdapter, model: string, prompt: string, cwd: string, via: LlmVia, timeoutMs?: number): Promise<string>;
|
|
24
|
+
export declare function runLlm(adapter: WorkerAdapter, model: string, prompt: string, cwd: string, via?: LlmVia, timeoutMs?: number): Promise<string>;
|
|
25
|
+
export declare function extractJson<T>(raw: string): T | null;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { formatOwnedName, parseOwnedName } from "../drivers/types.js";
|
|
5
|
+
import { sh } from "../run/git.js";
|
|
6
|
+
export const GATE_PANE_SEP = " · ";
|
|
7
|
+
/** T8: role-first pane name for fleet visibility — judge · T4, review · T3, consult · T2. */
|
|
8
|
+
export function gatePaneName(role, taskId, suffix = "") {
|
|
9
|
+
return `${role}${GATE_PANE_SEP}${taskId}${suffix}`;
|
|
10
|
+
}
|
|
11
|
+
// T8: gate prompts carry ## Task <id>: — derive the role-first pane name from the marker + header;
|
|
12
|
+
// via.name remains the fallback for non-gate runViaDriver callers and carries -r1 retry suffixes.
|
|
13
|
+
// T2 ownership contract: a canonical owned fallback (the daemon's nameFor now emits one) passes
|
|
14
|
+
// through untouched; run-gates' "-r1" judge-retry suffix becomes attempt+1 so the retry pane's name
|
|
15
|
+
// stays contract-parseable (drovr:judge:<task>:1:<runId>) instead of a corrupted-runId shape.
|
|
16
|
+
export function rolePaneNameFromPrompt(prompt, fallback) {
|
|
17
|
+
const retry = fallback.endsWith("-r1");
|
|
18
|
+
const base = retry ? fallback.slice(0, -3) : fallback;
|
|
19
|
+
const owned = parseOwnedName(base);
|
|
20
|
+
if (owned)
|
|
21
|
+
return retry ? formatOwnedName({ ...owned, attempt: owned.attempt + 1 }) : base;
|
|
22
|
+
const id = prompt.match(/## Task ([^\n:]+):/)?.[1];
|
|
23
|
+
if (!id)
|
|
24
|
+
return fallback;
|
|
25
|
+
if (prompt.startsWith("DROVR-JUDGE"))
|
|
26
|
+
return gatePaneName("judge", id, retry ? "-r1" : "");
|
|
27
|
+
if (prompt.startsWith("DROVR-REVIEW"))
|
|
28
|
+
return gatePaneName("review", id, retry ? "-r1" : "");
|
|
29
|
+
return fallback;
|
|
30
|
+
}
|
|
31
|
+
export async function runHeadless(adapter, model, prompt, cwd, timeoutMs = 300000) {
|
|
32
|
+
const pf = join(mkdtempSync(join(tmpdir(), "drovr-llm-")), "prompt.md");
|
|
33
|
+
writeFileSync(pf, prompt);
|
|
34
|
+
const r = await sh(adapter.headlessCommand(pf, model), cwd, timeoutMs);
|
|
35
|
+
return r.stdout + "\n" + r.stderr;
|
|
36
|
+
}
|
|
37
|
+
// v1.1 default path: the same headless CLI call, but dispatched through the driver
|
|
38
|
+
// as a visible named agent (herdr pane), with the quote-split completion wrapper.
|
|
39
|
+
export async function runViaDriver(adapter, model, prompt, cwd, via, timeoutMs = 300000) {
|
|
40
|
+
const pf = join(mkdtempSync(join(tmpdir(), "drovr-llm-")), "prompt.md");
|
|
41
|
+
writeFileSync(pf, prompt);
|
|
42
|
+
const slot = await via.driver.slot(cwd, rolePaneNameFromPrompt(prompt, via.name), via.label ? { label: via.label } : undefined);
|
|
43
|
+
via.onSlot?.(slot);
|
|
44
|
+
await via.driver.run(slot, `${adapter.headlessCommand(pf, model)}; printf '\\nDROVR_''EXIT:%s\\n' $?`);
|
|
45
|
+
// wait for the digit-suffixed marker (a real $? exit code), never a bare "DROVR_EXIT:" that a
|
|
46
|
+
// self-referential diff under review merely DISPLAYS — same guard the worker path uses (daemon.ts:193).
|
|
47
|
+
// Without it, a judge/review pane echoing drovr's own source false-completes before its verdict lands.
|
|
48
|
+
await via.driver.waitOutput(slot, "DROVR_EXIT:\\d", timeoutMs, { regex: true });
|
|
49
|
+
const out = await via.driver.read(slot, 400);
|
|
50
|
+
if (!via.keep)
|
|
51
|
+
await via.driver.close(slot);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
export function runLlm(adapter, model, prompt, cwd, via, timeoutMs = 300000) {
|
|
55
|
+
return via
|
|
56
|
+
? runViaDriver(adapter, model, prompt, cwd, via, timeoutMs)
|
|
57
|
+
: runHeadless(adapter, model, prompt, cwd, timeoutMs);
|
|
58
|
+
}
|
|
59
|
+
export function extractJson(raw) {
|
|
60
|
+
const fenced = [...raw.matchAll(/```json\s*\n([\s\S]*?)```/g)].at(-1);
|
|
61
|
+
if (fenced) {
|
|
62
|
+
try {
|
|
63
|
+
const v = JSON.parse(fenced[1]);
|
|
64
|
+
if (v && typeof v === "object")
|
|
65
|
+
return v;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* fall through to bare objects */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Find the last balanced {...} object by scanning backwards from the last }
|
|
72
|
+
let pos = raw.length - 1;
|
|
73
|
+
while (pos >= 0) {
|
|
74
|
+
const end = raw.lastIndexOf("}", pos);
|
|
75
|
+
if (end === -1)
|
|
76
|
+
return null;
|
|
77
|
+
let depth = 1;
|
|
78
|
+
for (let i = end - 1; i >= 0; i--) {
|
|
79
|
+
if (raw[i] === "}")
|
|
80
|
+
depth++;
|
|
81
|
+
else if (raw[i] === "{") {
|
|
82
|
+
depth--;
|
|
83
|
+
if (depth === 0) {
|
|
84
|
+
try {
|
|
85
|
+
const v = JSON.parse(raw.slice(i, end + 1));
|
|
86
|
+
if (v && typeof v === "object")
|
|
87
|
+
return v;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
/* keep scanning */
|
|
91
|
+
}
|
|
92
|
+
pos = i - 1;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (depth !== 0)
|
|
98
|
+
return null; // No matching brace found
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Assignment, type BillingChannel, type WorkerAdapter } from "../adapters/types.js";
|
|
2
|
+
import { type DrovrConfig } from "../config/config.js";
|
|
3
|
+
import { type Task } from "../graph/schema.js";
|
|
4
|
+
import { type GateVia } from "./llm.js";
|
|
5
|
+
import type { GateResult } from "./types.js";
|
|
6
|
+
export interface ReviewVerdict {
|
|
7
|
+
approve: boolean;
|
|
8
|
+
issues: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare function modelId(model: string): string;
|
|
11
|
+
export declare function pickReviewer(author: Assignment, channels: BillingChannel[], exclude?: string[]): BillingChannel | null;
|
|
12
|
+
export declare function reviewGate(task: Task, worktree: string, baseRef: string, author: Assignment, channels: BillingChannel[], adapters: WorkerAdapter[], cfg: DrovrConfig, via?: GateVia, excludeReviewers?: string[]): Promise<GateResult>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { channelKey } from "../adapters/types.js";
|
|
2
|
+
import { TIER_RANK } from "../config/config.js";
|
|
3
|
+
import { renderAcceptanceItem } from "../graph/schema.js";
|
|
4
|
+
import { getAdapter } from "../adapters/registry.js";
|
|
5
|
+
import { shOk } from "../run/git.js";
|
|
6
|
+
import { marginalCostRank } from "../route/router.js";
|
|
7
|
+
import { extractJson, runLlm } from "./llm.js";
|
|
8
|
+
// FLEET-05: canonical model identity = the segment after the last "/". Provider-prefixed ids name ONE
|
|
9
|
+
// base model behind two harnesses (zai-coding-plan/glm-5.2, zai/glm-5.2 → "glm-5.2"); vendor alone (mixed vs
|
|
10
|
+
// zhipu) is not a diversity signal. Suffix-stripping over-excludes only if two genuinely different
|
|
11
|
+
// models share a bare suffix — that errs fail-closed, acceptable per "gates never trust". Local to
|
|
12
|
+
// review.ts (not a global identity concept).
|
|
13
|
+
export function modelId(model) {
|
|
14
|
+
return model.slice(model.lastIndexOf("/") + 1);
|
|
15
|
+
}
|
|
16
|
+
export function pickReviewer(author, channels, exclude = []) {
|
|
17
|
+
// FLEET-05 success criterion 2: an author not resolvable in the channel list yields NO reviewer.
|
|
18
|
+
// The old `?? author.adapter` fallback compared an adapter id to vendor names, matched nothing, and
|
|
19
|
+
// admitted every reviewer — including the author's own channel (fail-OPEN). null lands on reviewGate's
|
|
20
|
+
// fail-closed branch under review.required.
|
|
21
|
+
const authorChannel = channels.find((c) => c.adapter === author.adapter && c.model === author.model);
|
|
22
|
+
if (!authorChannel)
|
|
23
|
+
return null;
|
|
24
|
+
return (channels
|
|
25
|
+
// two independent axes: different vendor AND different base-model identity (ADDED TO the vendor
|
|
26
|
+
// rule, never replacing it — a future edit can't silently drop either).
|
|
27
|
+
.filter((c) => c.vendor !== authorChannel.vendor && modelId(c.model) !== modelId(author.model) && !exclude.includes(channelKey(c)))
|
|
28
|
+
.sort((a, b) => TIER_RANK[b.tier] - TIER_RANK[a.tier] || marginalCostRank(a) - marginalCostRank(b))[0] ?? null);
|
|
29
|
+
}
|
|
30
|
+
const DIFF_CAP = 60_000;
|
|
31
|
+
export async function reviewGate(task, worktree, baseRef, author, channels, adapters, cfg, via, excludeReviewers) {
|
|
32
|
+
if (task.complexity < cfg.review.complexityThreshold) {
|
|
33
|
+
return { gate: "review", pass: true, details: `skipped — complexity ${task.complexity} < threshold ${cfg.review.complexityThreshold}` };
|
|
34
|
+
}
|
|
35
|
+
const reviewer = pickReviewer(author, channels, excludeReviewers ?? []);
|
|
36
|
+
if (!reviewer) {
|
|
37
|
+
return cfg.review.required
|
|
38
|
+
? { gate: "review", pass: false, details: "no cross-vendor reviewer available (diversity rule); set review.required:false to waive" }
|
|
39
|
+
: { gate: "review", pass: true, details: "WARNING: no cross-vendor reviewer available — review waived by config" };
|
|
40
|
+
}
|
|
41
|
+
const diff = (await shOk(`git diff '${baseRef}..HEAD'`, worktree)).slice(0, DIFF_CAP);
|
|
42
|
+
const prompt = `DROVR-REVIEW
|
|
43
|
+
You are a skeptical cross-vendor code reviewer. Another agent (vendor: ${author.adapter}) authored this diff.
|
|
44
|
+
Look for correctness bugs, security issues, and acceptance-criteria gaps. Approve only if you would merge it.
|
|
45
|
+
|
|
46
|
+
## Task ${task.id}: ${task.title} (complexity ${task.complexity})
|
|
47
|
+
## Acceptance criteria
|
|
48
|
+
${task.acceptance.map((a) => `- ${renderAcceptanceItem(a)}`).join("\n")}
|
|
49
|
+
|
|
50
|
+
## Diff
|
|
51
|
+
\`\`\`diff
|
|
52
|
+
${diff}
|
|
53
|
+
\`\`\`
|
|
54
|
+
|
|
55
|
+
Respond with ONLY this JSON:
|
|
56
|
+
{"approve": true|false, "issues": ["..."]}
|
|
57
|
+
`;
|
|
58
|
+
const raw = await runLlm(getAdapter(reviewer.adapter, adapters), reviewer.model, prompt, worktree, via ? { driver: via.driver, keep: via.keep, onSlot: via.onSlot, name: via.nameFor("review", reviewer.adapter), label: via.labelFor("review") } : undefined,
|
|
59
|
+
// frontier reviewers routinely need >5min on a DIFF_CAP-sized diff, and `claude -p` buffers all
|
|
60
|
+
// output until completion — runLlm's 300s default killed reviews mid-flight, returning empty
|
|
61
|
+
// stdout that read as "unparseable" and escalated to re-implementation of green code
|
|
62
|
+
// (run-20260709-104447 P87-09). ponytail: literal 15min; make it cfg.review.timeoutMs if a
|
|
63
|
+
// second knob-turner appears.
|
|
64
|
+
900_000);
|
|
65
|
+
const v = extractJson(raw);
|
|
66
|
+
if (!v || typeof v.approve !== "boolean") {
|
|
67
|
+
return {
|
|
68
|
+
gate: "review",
|
|
69
|
+
pass: false,
|
|
70
|
+
details: `review output unparseable (reviewer ${reviewer.adapter}:${reviewer.model}) — failing closed`,
|
|
71
|
+
meta: { reviewer: channelKey(reviewer) },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
gate: "review",
|
|
76
|
+
pass: v.approve,
|
|
77
|
+
details: `reviewer ${reviewer.adapter}:${reviewer.model} (${reviewer.vendor}): ${v.approve ? "approved" : "requested changes"}${v.issues?.length ? "\n" + v.issues.map((i) => `- ${i}`).join("\n") : ""}`,
|
|
78
|
+
meta: { reviewer: channelKey(reviewer) },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type Assignment, type BillingChannel, type WorkerAdapter, type WorkerResult } from "../adapters/types.js";
|
|
2
|
+
import { type DrovrConfig } from "../config/config.js";
|
|
3
|
+
import { type GateName, type Task } from "../graph/schema.js";
|
|
4
|
+
import { type Baseline } from "./baseline.js";
|
|
5
|
+
import type { GateVia } from "./llm.js";
|
|
6
|
+
import type { GateResult } from "./types.js";
|
|
7
|
+
export type GateEvent = {
|
|
8
|
+
phase: "start";
|
|
9
|
+
gate: GateName;
|
|
10
|
+
index: number;
|
|
11
|
+
total: number;
|
|
12
|
+
} | {
|
|
13
|
+
phase: "end";
|
|
14
|
+
gate: GateName;
|
|
15
|
+
result: GateResult;
|
|
16
|
+
};
|
|
17
|
+
export interface GateContext {
|
|
18
|
+
worktree: string;
|
|
19
|
+
baseRef: string;
|
|
20
|
+
result: WorkerResult;
|
|
21
|
+
author: Assignment;
|
|
22
|
+
commands: Record<string, string>;
|
|
23
|
+
baseline: Baseline;
|
|
24
|
+
channels: BillingChannel[];
|
|
25
|
+
adapters: WorkerAdapter[];
|
|
26
|
+
cfg: DrovrConfig;
|
|
27
|
+
via?: GateVia;
|
|
28
|
+
excludeReviewers?: string[];
|
|
29
|
+
onGate?: (e: GateEvent) => void | Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export declare function runGates(task: Task, ctx: GateContext): Promise<{
|
|
32
|
+
results: GateResult[];
|
|
33
|
+
commits: string[];
|
|
34
|
+
}>;
|