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.
Files changed (123) hide show
  1. package/README.md +347 -0
  2. package/dist/adapters/claude-code.d.ts +4 -0
  3. package/dist/adapters/claude-code.js +191 -0
  4. package/dist/adapters/codex.d.ts +8 -0
  5. package/dist/adapters/codex.js +257 -0
  6. package/dist/adapters/cursor-agent.d.ts +4 -0
  7. package/dist/adapters/cursor-agent.js +62 -0
  8. package/dist/adapters/fake.d.ts +45 -0
  9. package/dist/adapters/fake.js +115 -0
  10. package/dist/adapters/grok.d.ts +4 -0
  11. package/dist/adapters/grok.js +122 -0
  12. package/dist/adapters/model-lints.d.ts +6 -0
  13. package/dist/adapters/model-lints.js +130 -0
  14. package/dist/adapters/opencode.d.ts +3 -0
  15. package/dist/adapters/opencode.js +108 -0
  16. package/dist/adapters/pi.d.ts +3 -0
  17. package/dist/adapters/pi.js +191 -0
  18. package/dist/adapters/prompt.d.ts +6 -0
  19. package/dist/adapters/prompt.js +84 -0
  20. package/dist/adapters/registry.d.ts +31 -0
  21. package/dist/adapters/registry.js +165 -0
  22. package/dist/adapters/types.d.ts +98 -0
  23. package/dist/adapters/types.js +56 -0
  24. package/dist/cli/commands/approve.d.ts +1 -0
  25. package/dist/cli/commands/approve.js +72 -0
  26. package/dist/cli/commands/compile.d.ts +1 -0
  27. package/dist/cli/commands/compile.js +23 -0
  28. package/dist/cli/commands/doctor.d.ts +2 -0
  29. package/dist/cli/commands/doctor.js +103 -0
  30. package/dist/cli/commands/init.d.ts +1 -0
  31. package/dist/cli/commands/init.js +32 -0
  32. package/dist/cli/commands/plan.d.ts +2 -0
  33. package/dist/cli/commands/plan.js +101 -0
  34. package/dist/cli/commands/profile.d.ts +1 -0
  35. package/dist/cli/commands/profile.js +56 -0
  36. package/dist/cli/commands/report.d.ts +4 -0
  37. package/dist/cli/commands/report.js +306 -0
  38. package/dist/cli/commands/resume.d.ts +1 -0
  39. package/dist/cli/commands/resume.js +11 -0
  40. package/dist/cli/commands/run.d.ts +1 -0
  41. package/dist/cli/commands/run.js +35 -0
  42. package/dist/cli/commands/scope.d.ts +3 -0
  43. package/dist/cli/commands/scope.js +22 -0
  44. package/dist/cli/commands/status.d.ts +9 -0
  45. package/dist/cli/commands/status.js +162 -0
  46. package/dist/cli/commands/unlock.d.ts +1 -0
  47. package/dist/cli/commands/unlock.js +11 -0
  48. package/dist/cli/index.d.ts +8 -0
  49. package/dist/cli/index.js +65 -0
  50. package/dist/compile/collateral.d.ts +6 -0
  51. package/dist/compile/collateral.js +128 -0
  52. package/dist/compile/common.d.ts +12 -0
  53. package/dist/compile/common.js +56 -0
  54. package/dist/compile/gsd.d.ts +3 -0
  55. package/dist/compile/gsd.js +222 -0
  56. package/dist/compile/index.d.ts +4 -0
  57. package/dist/compile/index.js +33 -0
  58. package/dist/compile/native.d.ts +4 -0
  59. package/dist/compile/native.js +196 -0
  60. package/dist/compile/prd.d.ts +2 -0
  61. package/dist/compile/prd.js +64 -0
  62. package/dist/compile/speckit.d.ts +2 -0
  63. package/dist/compile/speckit.js +66 -0
  64. package/dist/config/config.d.ts +184 -0
  65. package/dist/config/config.js +329 -0
  66. package/dist/drivers/herdr.d.ts +44 -0
  67. package/dist/drivers/herdr.js +394 -0
  68. package/dist/drivers/index.d.ts +3 -0
  69. package/dist/drivers/index.js +13 -0
  70. package/dist/drivers/subprocess.d.ts +25 -0
  71. package/dist/drivers/subprocess.js +106 -0
  72. package/dist/drivers/types.d.ts +61 -0
  73. package/dist/drivers/types.js +77 -0
  74. package/dist/gates/acceptance.d.ts +19 -0
  75. package/dist/gates/acceptance.js +92 -0
  76. package/dist/gates/baseline.d.ts +12 -0
  77. package/dist/gates/baseline.js +97 -0
  78. package/dist/gates/evidence.d.ts +4 -0
  79. package/dist/gates/evidence.js +12 -0
  80. package/dist/gates/llm.d.ts +25 -0
  81. package/dist/gates/llm.js +101 -0
  82. package/dist/gates/review.d.ts +12 -0
  83. package/dist/gates/review.js +80 -0
  84. package/dist/gates/run-gates.d.ts +34 -0
  85. package/dist/gates/run-gates.js +105 -0
  86. package/dist/gates/scope.d.ts +8 -0
  87. package/dist/gates/scope.js +32 -0
  88. package/dist/gates/types.d.ts +6 -0
  89. package/dist/gates/types.js +1 -0
  90. package/dist/graph/graph.d.ts +20 -0
  91. package/dist/graph/graph.js +148 -0
  92. package/dist/graph/schema.d.ts +185 -0
  93. package/dist/graph/schema.js +141 -0
  94. package/dist/index.d.ts +1 -0
  95. package/dist/index.js +1 -0
  96. package/dist/plan/prompt.d.ts +4 -0
  97. package/dist/plan/prompt.js +31 -0
  98. package/dist/plan/scope.d.ts +17 -0
  99. package/dist/plan/scope.js +141 -0
  100. package/dist/report/cost.d.ts +24 -0
  101. package/dist/report/cost.js +89 -0
  102. package/dist/route/preference.d.ts +27 -0
  103. package/dist/route/preference.js +49 -0
  104. package/dist/route/profile.d.ts +65 -0
  105. package/dist/route/profile.js +197 -0
  106. package/dist/route/router.d.ts +26 -0
  107. package/dist/route/router.js +200 -0
  108. package/dist/run/consult.d.ts +23 -0
  109. package/dist/run/consult.js +80 -0
  110. package/dist/run/daemon.d.ts +23 -0
  111. package/dist/run/daemon.js +728 -0
  112. package/dist/run/git.d.ts +12 -0
  113. package/dist/run/git.js +56 -0
  114. package/dist/run/journal.d.ts +91 -0
  115. package/dist/run/journal.js +287 -0
  116. package/dist/run/lock.d.ts +29 -0
  117. package/dist/run/lock.js +182 -0
  118. package/dist/run/merge.d.ts +12 -0
  119. package/dist/run/merge.js +44 -0
  120. package/dist/run/reconcile.d.ts +4 -0
  121. package/dist/run/reconcile.js +95 -0
  122. package/package.json +39 -0
  123. package/schema/rungraph.schema.json +306 -0
@@ -0,0 +1,130 @@
1
+ import { MODEL_ID_RE } from "./types.js";
2
+ // date the tiers seeds were last live-verified (Phase 6/7 reseed) — surfaced when an adapter has no list surface.
3
+ export const SEED_STAMPED = "2026-07-09";
4
+ // knowledge past this age gets a "rerun drovr doctor" nudge (BLOCKED_POLL_MS-style named constant).
5
+ export const MODEL_STALE_DAYS = 30;
6
+ const DAY_MS = 86400000;
7
+ // cursor-agent 2026.07.08 reports 193 mostly-parameterized ids (e.g. gpt-5.3-codex-high-fast); filter the `auto`
8
+ // pseudo-model + effort/speed variant suffixes from the unconfigured-lint aggregation ONLY — doctor.json keeps the
9
+ // raw list (verified 2026-07-10). Data stays raw; lints stay signal.
10
+ const LINT_VARIANT_RE = /^auto$|-(fast|minimal|low|medium|high|xhigh)$/;
11
+ const LINT_CAP = 5;
12
+ // Diffs detected models (doctor.json) against configured tiers, both directions, per adapter id in cfg.tiers.
13
+ // No ` ! ` prefix here — the consumer (doctor rows / plan lints) owns that. Pre-v1.5 doctor.json (models:[], no
14
+ // modelsDetectedAt) is the compat baseline: `?.`/`?? []` everywhere, no zod (would reject old files).
15
+ export function modelLints(cfg, health, adapters) {
16
+ const lints = [];
17
+ for (const id of Object.keys(cfg.tiers)) {
18
+ const adapter = adapters.find((a) => a.id === id);
19
+ if (!adapter)
20
+ continue; // fake/overlay-only tier entry with no adapter — nothing to diff against
21
+ if (!adapter.listModels) {
22
+ lints.push(`${id}: no model-list surface — seeds stamped ${SEED_STAMPED}; verify manually`);
23
+ continue;
24
+ }
25
+ const h = health[id];
26
+ const detected = h?.models ?? []; // MANDATORY default: pre-v1.5 files lack a populated models array
27
+ if (detected.length === 0) {
28
+ if (h?.installed)
29
+ lints.push(`${id}: no detection data — run drovr doctor`);
30
+ continue; // no data to diff or age
31
+ }
32
+ const configured = Object.keys(cfg.tiers[id].models);
33
+ for (const model of configured) {
34
+ if (!detected.includes(model)) {
35
+ lints.push(`${id}: tiers lists ${model} — CLI no longer reports it; tombstone it (${model}: null overlay) or verify the id`);
36
+ }
37
+ }
38
+ const extra = detected.filter((m) => !configured.includes(m) && !LINT_VARIANT_RE.test(m));
39
+ if (extra.length) {
40
+ const shown = extra.slice(0, LINT_CAP).join(", ");
41
+ const tail = extra.length > LINT_CAP ? `, +${extra.length - LINT_CAP} more` : "";
42
+ lints.push(`${id}: reports ${extra.length} model(s) not in tiers (${shown}${tail}) — classify before routing (benchmark policy)`);
43
+ }
44
+ const at = h?.modelsDetectedAt;
45
+ if (at) {
46
+ const days = Math.floor((Date.now() - Date.parse(at)) / DAY_MS); // completed days — never overstate age
47
+ if (days >= MODEL_STALE_DAYS)
48
+ lints.push(`${id}: model knowledge is ${days} days old — rerun drovr doctor`);
49
+ }
50
+ }
51
+ return lints;
52
+ }
53
+ // MODEL-05/06: render detected-vs-configured drift as a paste-ready config.yaml fragment. Locked v1.5
54
+ // decision: detection is strictly advisory — doctor prints, a human pastes; NO --write/--apply exists.
55
+ // Additions render WHOLE-LINE-COMMENTED with a `???` tier placeholder (a tier is a benchmark claim; the
56
+ // machine never fabricates one — auto-tiering reopens the NaN-routing class). Removals render as LIVE
57
+ // `<id>: null` tombstones (deepMerge deletes the key). Pure function: no fs, no routing contact.
58
+ // Returns "" when no adapter has a delta. Mirrors modelLints' per-adapter guards exactly.
59
+ export function suggestOverlay(cfg, health, adapters) {
60
+ const blocks = [];
61
+ for (const id of Object.keys(cfg.tiers)) {
62
+ const adapter = adapters.find((a) => a.id === id);
63
+ if (!adapter?.listModels)
64
+ continue; // no adapter / no list surface → nothing to diff (mirror modelLints)
65
+ const h = health[id];
66
+ const detected = h?.models ?? [];
67
+ if (detected.length === 0)
68
+ continue; // no detection data → don't guess a delta
69
+ const configured = Object.keys(cfg.tiers[id].models);
70
+ const date = h?.modelsDetectedAt?.split("T")[0]; // best-effort day stamp
71
+ const detNote = date ? ` (detected ${date})` : "";
72
+ const lines = [];
73
+ // Tombstones: configured ids the CLI no longer reports. Ids are operator-authored (from cfg) → MODEL_ID_RE only.
74
+ for (const model of configured) {
75
+ if (detected.includes(model) || !MODEL_ID_RE.test(model))
76
+ continue;
77
+ lines.push(` ${model}: null # tombstone: ${id} no longer reports this id${detNote}${referenceWarning(cfg, id, model)}`);
78
+ }
79
+ // Additions: detected ids not in cfg. WHOLE line commented, no tier (MODEL-06). Ids come from an external
80
+ // CLI → MODEL_ID_RE (defense-in-depth, T-21-01) + the variant filter (cursor's ~193 parameterized ids).
81
+ // RELATIONAL gate (no capability judgment — "looks like an embedding model" is auto-tiering's cousin, the
82
+ // NaN-routing class the v1.5 decision forbids): a detected id is suggested iff it shares a provider prefix
83
+ // (clause a) OR a canonical segment (clause b, the RENAME case: opencode/glm-5.2 ⇒ zai-coding-plan/glm-5.2)
84
+ // with some configured id. Everything else collapses into ONE counted summary — never dropped silently.
85
+ // NB: the bare "" prefix is a real match key on purpose. codex's whole namespace is unprefixed
86
+ // (gpt-5.6-sol, gpt-5.5), so "" is how a detected gpt-5.7-nova surfaces as an upgrade of a configured
87
+ // gpt-5.6-sol — the MODEL-05 worked example. The cost is that an adapter with ONE bare configured id
88
+ // (cursor's composer-2.5) admits every unprefixed detected id; those collapse into the counted summary,
89
+ // not silently. ponytail: not worth a per-adapter heuristic to quiet cursor at the price of codex signal.
90
+ const cfgPrefixes = new Set(configured.map(providerPrefix));
91
+ const cfgCanon = new Set(configured.map(canonical));
92
+ let omitted = 0;
93
+ for (const model of detected) {
94
+ if (configured.includes(model) || !MODEL_ID_RE.test(model) || LINT_VARIANT_RE.test(model))
95
+ continue;
96
+ if (!cfgPrefixes.has(providerPrefix(model)) && !cfgCanon.has(canonical(model))) {
97
+ omitted++;
98
+ continue;
99
+ }
100
+ lines.push(` # ${model}: ??? #${date ? ` detected ${date} —` : ""} classify per benchmark policy (AA Index + SWE-bench Pro, dated), then uncomment`);
101
+ }
102
+ if (omitted)
103
+ lines.push(` # (+${omitted} other detected id${omitted === 1 ? "" : "s"} not related to your configured models — see .drovr/doctor.json)`);
104
+ if (lines.length)
105
+ blocks.push(` ${id}:\n models:\n${lines.join("\n")}`);
106
+ }
107
+ if (blocks.length === 0)
108
+ return "";
109
+ return `# paste into .drovr/config.yaml — drovr prints this, it never applies it\ntiers:\n${blocks.join("\n")}\n`;
110
+ }
111
+ // Purely relational id split for the addition gate (see suggestOverlay). Local, not exported: this is NOT a
112
+ // global identity concept — src/gates/review.ts has its own local modelId(), deliberately not shared.
113
+ // providerPrefix("zai/glm-5.2") === "zai"; providerPrefix("gpt-5.5") === "" (bare ids share the "" prefix).
114
+ const providerPrefix = (id) => { const i = id.lastIndexOf("/"); return i < 0 ? "" : id.slice(0, i); };
115
+ // canonical("zai-coding-plan/glm-5.2") === "glm-5.2" — the rename-detecting segment.
116
+ const canonical = (id) => { const i = id.lastIndexOf("/"); return i < 0 ? id : id.slice(i + 1); };
117
+ // A tombstoned id that a routing.map pin / judge / consult still names (on the same adapter) would leave a
118
+ // dangling reference — surface it inline so the operator remaps before deleting the seed.
119
+ function referenceWarning(cfg, adapterId, model) {
120
+ const refs = [];
121
+ for (const [shape, entry] of Object.entries(cfg.routing.map)) {
122
+ if (entry.pin?.via === adapterId && entry.pin.model === model)
123
+ refs.push(`routing.map.${shape}.pin`);
124
+ }
125
+ if (cfg.judge.adapter === adapterId && cfg.judge.model === model)
126
+ refs.push("judge");
127
+ if (cfg.consult.adapter === adapterId && cfg.consult.model === model)
128
+ refs.push("consult");
129
+ return refs.length ? ` # WARNING: still referenced by ${refs.join(", ")} — remap before removing` : "";
130
+ }
@@ -0,0 +1,3 @@
1
+ import { type WorkerAdapter } from "./types.js";
2
+ export declare function parseOpencodeModels(raw: string): string[];
3
+ export declare const opencode: WorkerAdapter;
@@ -0,0 +1,108 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { probeVersion } from "./claude-code.js";
6
+ import { parseWorkerResult } from "./prompt.js";
7
+ import { channelsFromConfig, MODEL_ID_RE, shq, TokenUsageSchema } from "./types.js";
8
+ // v1.5 MODEL-01: pure parser for `opencode models` (one provider/model id per line, no header).
9
+ // Live-verified 2026-07-10, opencode 1.17.15. Lines are already the seed id format; drop blanks.
10
+ export function parseOpencodeModels(raw) {
11
+ return raw.trim().split("\n")
12
+ .map((l) => l.trim())
13
+ .filter((l) => l.length > 0)
14
+ .filter((id) => MODEL_ID_RE.test(id));
15
+ }
16
+ export const opencode = {
17
+ id: "opencode",
18
+ vendor: "mixed",
19
+ probe: async () => probeVersion("opencode"),
20
+ channels: (cfg) => channelsFromConfig("opencode", cfg),
21
+ headlessCommand: (promptFile, model) => `opencode run -m ${shq(model)} "$(cat ${shq(promptFile)})"`,
22
+ interactiveCommand: (promptFile, model) => `opencode -m ${shq(model)} --prompt "$(cat ${shq(promptFile)})"`,
23
+ invoke(task, _cwd, a, ctx) {
24
+ return { command: this.headlessCommand(ctx.promptFile, a.model) };
25
+ },
26
+ parse: parseWorkerResult,
27
+ // v1.5 MODEL-01: fail OPEN to [] (advisory detection, unlike gates). Plain `models` (NOT --refresh):
28
+ // opencode reads its own cache offline; --refresh adds an avoidable network dependency (RESEARCH
29
+ // anti-pattern). Live-verified 2026-07-10, opencode 1.17.15.
30
+ listModels: async () => {
31
+ const r = spawnSync("opencode", ["models"], { encoding: "utf8", timeout: 15000 });
32
+ return r.error || r.status !== 0 ? [] : parseOpencodeModels(r.stdout || "");
33
+ },
34
+ // SPEND-09: opencode 1.x writes per-message token deltas to SQLite opencode.db (NOT the dead 0.7.x
35
+ // storage/message/*.json legacy — those files carry zero'd tokens forever). We read POST-HOC via
36
+ // spawnSync sqlite3 -json, sum message-row tokens whose path.cwd matches this worktree AND whose
37
+ // time.created (epoch-ms NUMBER) is at/after sinceMs. NEVER read session.tokens_* (session-cumulative
38
+ // 3A+2B+C trap) and NEVER read rec.cost (opencode reports cost:0 on sub plans; money is the existing
39
+ // v1.7 sub-channel subscription bucket). NEVER populate tokens.reasoning — it is ⊂ output and
40
+ // report total() cross-sums reasoning, so setting it double-counts. FAIL OPEN everywhere.
41
+ collectUsage(cwd, sinceMs) {
42
+ try {
43
+ const real = realpathSync(cwd);
44
+ const dbPath = join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "opencode", "opencode.db");
45
+ const r = spawnSync("sqlite3", ["-json", dbPath, "SELECT data FROM message WHERE time_created >= " + Math.floor(sinceMs)], { encoding: "utf8", timeout: 10000 });
46
+ if (r.error || r.status !== 0)
47
+ return undefined;
48
+ const raw = (r.stdout || "").trim();
49
+ if (!raw)
50
+ return undefined;
51
+ let rows;
52
+ try {
53
+ rows = JSON.parse(raw);
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ if (!Array.isArray(rows))
59
+ return undefined;
60
+ let input = 0, output = 0, kept = false;
61
+ let cacheRead, cacheWrite;
62
+ for (const row of rows) {
63
+ if (!row || typeof row !== "object")
64
+ continue;
65
+ const dataStr = row.data;
66
+ if (typeof dataStr !== "string")
67
+ continue;
68
+ let recRaw;
69
+ try {
70
+ recRaw = JSON.parse(dataStr);
71
+ }
72
+ catch {
73
+ continue;
74
+ }
75
+ const rec = recRaw;
76
+ if (rec.path?.cwd !== real)
77
+ continue;
78
+ const created = rec.time?.created;
79
+ if (typeof created !== "number" || !Number.isFinite(created) || created < sinceMs)
80
+ continue;
81
+ const t = rec.tokens;
82
+ if (!t || typeof t !== "object")
83
+ continue;
84
+ const tt = t;
85
+ const n = (k) => (typeof tt[k] === "number" ? tt[k] : 0);
86
+ input += n("input");
87
+ output += n("output");
88
+ const cache = tt.cache;
89
+ if (cache && typeof cache === "object") {
90
+ const cc = cache;
91
+ if ("read" in cc)
92
+ cacheRead = (cacheRead ?? 0) + (typeof cc.read === "number" ? cc.read : 0);
93
+ if ("write" in cc)
94
+ cacheWrite = (cacheWrite ?? 0) + (typeof cc.write === "number" ? cc.write : 0);
95
+ }
96
+ kept = true;
97
+ }
98
+ if (!kept)
99
+ return undefined;
100
+ const out = { input, output, ...(cacheRead !== undefined ? { cacheRead } : {}), ...(cacheWrite !== undefined ? { cacheWrite } : {}) };
101
+ const p = TokenUsageSchema.safeParse(out);
102
+ return p.success ? p.data : undefined;
103
+ }
104
+ catch {
105
+ return undefined;
106
+ }
107
+ },
108
+ };
@@ -0,0 +1,3 @@
1
+ import { type WorkerAdapter } from "./types.js";
2
+ export declare function parsePiModels(raw: string): string[];
3
+ export declare const pi: WorkerAdapter;
@@ -0,0 +1,191 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { probeVersion } from "./claude-code.js";
6
+ import { parseWorkerResult } from "./prompt.js";
7
+ import { channelsFromConfig, MODEL_ID_RE, shq, TokenUsageSchema } from "./types.js";
8
+ // SPEND-10: pi writes per-session JSONL to ~/.pi/agent/sessions/<slug>/ where slug is EMPIRICAL
9
+ // (reverse-engineered: "-" + realpath'd cwd with every "/" replaced by "-" + "--", 29-RESEARCH.md Pitfall 7).
10
+ // Session header (type:"session", version:3) carries cwd; assistant messages carry message.usage with
11
+ // per-record ISO `timestamp`. Read POST-HOC; cursor is per-record timestamp (never file mtime).
12
+ // Header cwd === realpath(cwd) guard makes slug-formula drift fail SAFE (unmetered, never mis-metered).
13
+ // FAIL OPEN everywhere: missing dir / unreadable file / torn line / no match ⇒ undefined.
14
+ // SPEND-10: sum ONLY input/output/cacheRead/cacheWrite. usage.reasoning ⊂ usage.output — omit it
15
+ // (report total() cross-sums reasoning; populating it double-counts). NEVER read usage.cost.
16
+ const MAX_SESSION_FILES = 20;
17
+ const MAX_SESSION_BYTES = 8_000_000;
18
+ // v1.5 MODEL-01: pure parser for `pi --list-models` (table: header row + provider/model/... columns).
19
+ // Live-verified 2026-07-10, pi 0.80.3. Columns 0-1 join to the seed id format.
20
+ // MODEL-05 WR-02: anchor on the header by CONTENT, not position — pi emits non-blocking update banners
21
+ // that can precede the table (see banner note below), so slice(1) would drop a banner and parse the real
22
+ // header row as a bogus provider/model id. Find the "provider model ..." header and parse rows after it;
23
+ // fail-open to [] if no header is found.
24
+ export function parsePiModels(raw) {
25
+ const lines = raw.trim().split("\n");
26
+ const headerIdx = lines.findIndex((l) => /^provider\s+model\b/.test(l.trim()));
27
+ if (headerIdx === -1)
28
+ return [];
29
+ return lines.slice(headerIdx + 1)
30
+ .map((l) => l.trim().split(/\s+/))
31
+ .filter((c) => c.length >= 2)
32
+ .map(([provider, model]) => `${provider}/${model}`)
33
+ .filter((id) => MODEL_ID_RE.test(id));
34
+ }
35
+ export const pi = {
36
+ id: "pi",
37
+ // FLEET-04: cross-vendor review honesty — GLM's provider (pi's own label is "zai"; either is
38
+ // diversity-distinct from anthropic/openai/cursor/mixed). Must equal tiers.pi.vendor.
39
+ vendor: "zhipu",
40
+ probe: async () => {
41
+ const h = probeVersion("pi");
42
+ if (!h.installed)
43
+ return h;
44
+ const r = spawnSync("pi", ["--list-models"], { encoding: "utf8", timeout: 15000 });
45
+ if (r.error || r.status !== 0)
46
+ return h;
47
+ return { ...h, servable: parsePiModels(r.stdout || ""), note: "auth verified via pi --list-models (free; auth-filtered by pi)" };
48
+ },
49
+ channels: (cfg) => channelsFromConfig("pi", cfg),
50
+ // --approve: pi's per-directory trust prompt would stall fresh worktrees (herdr scrapes the dialog
51
+ // as idle — cursor/claude incident class, milestone PITFALLS #2). Global option, legal in BOTH modes
52
+ // per pi --help v0.80.3 (2026-07-10) — NOT print-only like cursor's --trust. Chosen over the more
53
+ // hermetic --no-approve so repo-local pi config behaves normally (research Pitfall 5 decision).
54
+ // Live-checked 2026-07-10, pi 0.80.3 — headless trailer intact, interactive --approve accepted,
55
+ // no trust prompt; non-blocking update banners render in output (trailer parse tolerates chrome).
56
+ headlessCommand: (promptFile, model) => `pi -p --approve --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
57
+ interactiveCommand: (promptFile, model) => `pi --approve --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
58
+ invoke(task, _cwd, a, ctx) {
59
+ return { command: this.headlessCommand(ctx.promptFile, a.model) };
60
+ },
61
+ parse: parseWorkerResult,
62
+ // v1.5 MODEL-01: fail OPEN to [] (detection is advisory — unlike gates' fail-closed posture).
63
+ // spawnSync mirrors probeVersion; 15s timeout. Live-verified 2026-07-10, pi 0.80.3.
64
+ listModels: async () => {
65
+ const r = spawnSync("pi", ["--list-models"], { encoding: "utf8", timeout: 15000 });
66
+ return r.error || r.status !== 0 ? [] : parsePiModels(r.stdout || "");
67
+ },
68
+ collectUsage(cwd, sinceMs) {
69
+ try {
70
+ const real = realpathSync(cwd);
71
+ const slug = "-" + real.replaceAll("/", "-") + "--";
72
+ const dir = join(homedir(), ".pi", "agent", "sessions", slug);
73
+ const files = readdirSync(dir)
74
+ .filter((f) => f.endsWith(".jsonl"))
75
+ .map((f) => {
76
+ try {
77
+ return { f, m: statSync(join(dir, f)).mtimeMs };
78
+ }
79
+ catch {
80
+ return undefined;
81
+ }
82
+ })
83
+ .filter((x) => x !== undefined)
84
+ .sort((a, b) => b.m - a.m)
85
+ .slice(0, MAX_SESSION_FILES);
86
+ let input = 0, output = 0, kept = false;
87
+ let cacheRead, cacheWrite;
88
+ for (const { f } of files) {
89
+ let text;
90
+ try {
91
+ text = readFileSync(join(dir, f), "utf8").slice(0, MAX_SESSION_BYTES);
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ let headerCwdOk = false;
97
+ for (const line of text.split("\n")) {
98
+ if (!line.trim())
99
+ continue;
100
+ let recRaw;
101
+ try {
102
+ recRaw = JSON.parse(line);
103
+ }
104
+ catch {
105
+ continue;
106
+ }
107
+ const rec = recRaw;
108
+ if (rec.type === "session") {
109
+ headerCwdOk = rec.cwd === real;
110
+ continue;
111
+ }
112
+ if (!headerCwdOk)
113
+ continue;
114
+ if (rec.type !== "message" || rec.message?.role !== "assistant")
115
+ continue;
116
+ const u = rec.message?.usage;
117
+ if (!u || typeof u !== "object")
118
+ continue;
119
+ const ts = Date.parse(String(rec.timestamp));
120
+ if (!Number.isFinite(ts) || ts < sinceMs)
121
+ continue;
122
+ const uu = u;
123
+ const n = (k) => (typeof uu[k] === "number" ? uu[k] : 0);
124
+ input += n("input");
125
+ output += n("output");
126
+ if ("cacheRead" in uu)
127
+ cacheRead = (cacheRead ?? 0) + n("cacheRead");
128
+ if ("cacheWrite" in uu)
129
+ cacheWrite = (cacheWrite ?? 0) + n("cacheWrite");
130
+ kept = true;
131
+ }
132
+ }
133
+ if (!kept)
134
+ return undefined;
135
+ const out = { input, output, ...(cacheRead !== undefined ? { cacheRead } : {}), ...(cacheWrite !== undefined ? { cacheWrite } : {}) };
136
+ const p = TokenUsageSchema.safeParse(out);
137
+ return p.success ? p.data : undefined;
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
142
+ },
143
+ // v1.23 T1: last-turn context fill from ~/.pi/agent/sessions/<slug>/<sessionId>.jsonl ONLY.
144
+ // tokens = input + cacheWrite + cacheRead of the LAST assistant usage record (not a sum over turns).
145
+ // Disk read only: no pi spawn, no pane, no network. null = unknown.
146
+ contextUsage(session) {
147
+ try {
148
+ const real = realpathSync(session.cwd);
149
+ const slug = "-" + real.replaceAll("/", "-") + "--";
150
+ const sid = session.id.replace(/\.jsonl$/i, "");
151
+ if (!sid || sid.includes("/") || sid.includes("\\") || sid.includes(".."))
152
+ return null;
153
+ const file = join(homedir(), ".pi", "agent", "sessions", slug, `${sid}.jsonl`);
154
+ let text;
155
+ try {
156
+ text = readFileSync(file, "utf8").slice(0, MAX_SESSION_BYTES);
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ let last;
162
+ for (const line of text.split("\n")) {
163
+ if (!line.trim())
164
+ continue;
165
+ let recRaw;
166
+ try {
167
+ recRaw = JSON.parse(line);
168
+ }
169
+ catch {
170
+ continue;
171
+ }
172
+ const rec = recRaw;
173
+ if (rec.type !== "message" || rec.message?.role !== "assistant")
174
+ continue;
175
+ const u = rec.message?.usage;
176
+ if (!u || typeof u !== "object")
177
+ continue;
178
+ const uu = u;
179
+ const n = (k) => (typeof uu[k] === "number" ? uu[k] : 0);
180
+ // last turn wins — overwrite, never accumulate
181
+ last = n("input") + n("cacheWrite") + n("cacheRead");
182
+ }
183
+ if (last === undefined)
184
+ return null;
185
+ return { tokens: last };
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ },
191
+ };
@@ -0,0 +1,6 @@
1
+ import { type Task } from "../graph/schema.js";
2
+ import type { WorkerResult } from "./types.js";
3
+ export declare function buildTaskPrompt(task: Task, feedback?: string, nonce?: string): string;
4
+ export declare function writePrompt(dir: string, task: Task, attempt: number, feedback?: string, nonce?: string): string;
5
+ export declare function trailerPattern(nonce: string): string;
6
+ export declare function parseWorkerResult(raw: string, nonce: string): WorkerResult;
@@ -0,0 +1,84 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { renderAcceptanceItem } from "../graph/schema.js";
4
+ export function buildTaskPrompt(task, feedback = "", nonce = "") {
5
+ const list = (xs) => xs.map((x) => `- ${x}`).join("\n");
6
+ return `You are an autonomous coding worker dispatched by drovr into an isolated git worktree.
7
+
8
+ ## Task ${task.id}: ${task.title}
9
+ Goal: ${task.goal}
10
+
11
+ ## Acceptance criteria (you will be judged against these, verbatim)
12
+ ${list(task.acceptance.map(renderAcceptanceItem))}
13
+ ${task.files.length ? `\n## File scope — touch ONLY paths matching:\n${list(task.files)}\n` : ""}${task.context.length ? `\n## Context (read these first)\n${list(task.context)}\n` : ""}
14
+ ## Rules
15
+ - Work only inside the current directory (your isolated worktree). Never push. Never switch branches.
16
+ - Make small atomic git commits as you go (git add + git commit, conventional messages).
17
+ - Touch ONLY paths matching the file scope. Out-of-scope edits FAIL the scope gate unless the operator's config allowlists that path — declaring a deviation never passes the gate. If you cannot complete the task without an out-of-scope edit, stop and report ok:false explaining why in "summary". List any out-of-scope paths you did touch, each with a reason, in "deviations" (journaled for the operator's audit).
18
+ - Do not ask questions; you are unattended. Make the smallest correct change.
19
+ ${feedback ? `\n## Previous attempt failed gates — fix these specifically\n${feedback}\n` : ""}
20
+ When finished, end your final message with exactly one line (no code fence):
21
+ DROVR_RESULT_${nonce} {"ok":true|false,"summary":"<one sentence>","deviations":["<path or reason>"]}
22
+ `;
23
+ }
24
+ export function writePrompt(dir, task, attempt, feedback = "", nonce = "") {
25
+ const p = join(dir, "prompts", `${task.id}-a${attempt}.md`);
26
+ mkdirSync(join(dir, "prompts"), { recursive: true });
27
+ writeFileSync(p, buildTaskPrompt(task, feedback, nonce));
28
+ return p;
29
+ }
30
+ // v1.2 interactive completion anchor: matches a real trailer in any key order — even hard-wrapped
31
+ // across lines by a TUI renderer ([\s\S] bridges newlines between tokens) — but never the template
32
+ // line above, whose literal `true|false` is not a bool followed by , or }. v1.4: the run-tagged nonce
33
+ // anchors it so displayed source/diffs (which can't know the nonce) can never premature-harvest.
34
+ // v1.16 (REN-02): dual-parse — accepts both the new marker spelling and the legacy alternation, so a
35
+ // worker/fixture still emitting the old marker keeps parsing through the rename.
36
+ export function trailerPattern(nonce) {
37
+ return `(?:DROVR|DROVER)_RESULT_${nonce} \\{[\\s\\S]{0,120}?"ok":\\s*(true|false)\\s*[,}]`;
38
+ }
39
+ // all start positions of either marker spelling, ascending — merged so "last occurrence" and
40
+ // "scan backward for the first parseable one" work identically across both spellings.
41
+ function trailerTokenPositions(raw, nonce) {
42
+ const positions = [];
43
+ for (const token of [`DROVR_RESULT_${nonce}`, `DROVER_RESULT_${nonce}`]) {
44
+ for (let i = raw.indexOf(token); i !== -1; i = raw.indexOf(token, i + 1))
45
+ positions.push(i);
46
+ }
47
+ return positions.sort((a, b) => a - b);
48
+ }
49
+ export function parseWorkerResult(raw, nonce) {
50
+ const fail = (summary) => ({ ok: false, summary, deviations: [], raw });
51
+ const positions = trailerTokenPositions(raw, nonce);
52
+ if (positions.length === 0)
53
+ return fail("worker produced no DROVR_RESULT trailer");
54
+ // TUIs echo the prompt template, redraw lines, and HARD-wrap the JSON with per-line margins
55
+ // (cursor does; recent-unwrapped can't rejoin hard newlines). Scan occurrences backward — last
56
+ // parseable wins — joining wrapped lines, stripping margin/box chrome, and growing the candidate
57
+ // one closing brace at a time (summaries may themselves contain `}`).
58
+ for (let idx = positions.length - 1; idx >= 0; idx--) {
59
+ const at = positions[idx];
60
+ const open = raw.indexOf("{", at);
61
+ if (open !== -1) {
62
+ const joined = raw
63
+ .slice(open, open + 2000)
64
+ .split("\n")
65
+ .map((l) => l.replace(/^[\s│|]+/, "").replace(/[\s│|]+$/, ""))
66
+ .join("");
67
+ for (let end = joined.indexOf("}"); end !== -1; end = joined.indexOf("}", end + 1)) {
68
+ try {
69
+ const j = JSON.parse(joined.slice(0, end + 1));
70
+ return {
71
+ ok: j.ok === true,
72
+ summary: String(j.summary ?? ""),
73
+ deviations: Array.isArray(j.deviations) ? j.deviations.map(String) : [],
74
+ raw,
75
+ };
76
+ }
77
+ catch {
78
+ /* not yet balanced, or template/garbage — grow the candidate or scan back */
79
+ }
80
+ }
81
+ }
82
+ }
83
+ return fail("unparseable DROVR_RESULT trailer");
84
+ }
@@ -0,0 +1,31 @@
1
+ import type { DrovrConfig } from "../config/config.js";
2
+ import { type AuthHealth, type BillingChannel, type WorkerAdapter } from "./types.js";
3
+ export declare function allAdapters(opts?: {
4
+ fakeScriptPath?: string;
5
+ }): WorkerAdapter[];
6
+ export declare function getAdapter(id: string, adapters: WorkerAdapter[]): WorkerAdapter;
7
+ export declare function probeAll(adapters: WorkerAdapter[]): Promise<Record<string, AuthHealth>>;
8
+ export declare function probeModels(cfg: DrovrConfig, repoRoot: string, adapters: WorkerAdapter[], health: Record<string, AuthHealth>): Promise<void>;
9
+ export declare function writeDoctor(repoRoot: string, health: Record<string, AuthHealth>): void;
10
+ export declare function readDoctor(repoRoot: string): Record<string, AuthHealth> | null;
11
+ export declare function discoverChannels(cfg: DrovrConfig, adapters: WorkerAdapter[], health: Record<string, AuthHealth>): BillingChannel[];
12
+ export declare function servableExclusions(cfg: DrovrConfig, adapters: WorkerAdapter[], health: Record<string, AuthHealth>): {
13
+ key: string;
14
+ adapter: string;
15
+ }[];
16
+ export declare function servabilityLine(excluded: {
17
+ key: string;
18
+ adapter: string;
19
+ }[]): string;
20
+ export declare function modelAuthExclusions(cfg: DrovrConfig, adapters: WorkerAdapter[], health: Record<string, AuthHealth>): {
21
+ key: string;
22
+ adapter: string;
23
+ reason: string;
24
+ probedAt: string;
25
+ }[];
26
+ export declare function modelAuthLine(excluded: {
27
+ key: string;
28
+ reason: string;
29
+ probedAt: string;
30
+ }[]): string;
31
+ export declare function doctorAgeMs(repoRoot: string): number | null;