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,12 @@
1
+ export interface ShResult {
2
+ code: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ timedOut?: boolean;
6
+ }
7
+ export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
8
+ export declare function shOk(cmd: string, cwd: string): Promise<string>;
9
+ export declare function gitHead(cwd: string): Promise<string>;
10
+ export declare function resolveIntegrationBranch(repo: string, branch: string): Promise<string>;
11
+ export declare function createWorktree(repo: string, branch: string, baseRef: string): Promise<string>;
12
+ export declare function removeWorktree(repo: string, dir: string): Promise<void>;
@@ -0,0 +1,56 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { shq } from "../adapters/types.js";
5
+ import { drovrDir } from "../graph/graph.js";
6
+ // stdin "ignore": same class as HARD-05 / SubprocessDriver — never leave an open pipe a child can block on
7
+ // (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
8
+ export function sh(cmd, cwd, timeoutMs = 600000) {
9
+ return new Promise((resolve) => {
10
+ const p = spawn("bash", ["-lc", cmd], { cwd, stdio: ["ignore", "pipe", "pipe"] });
11
+ let stdout = "", stderr = "";
12
+ let timedOut = false;
13
+ const timer = setTimeout(() => { timedOut = true; p.kill("SIGKILL"); }, timeoutMs);
14
+ p.stdout.on("data", (d) => (stdout += d));
15
+ p.stderr.on("data", (d) => (stderr += d));
16
+ p.on("error", (e) => { clearTimeout(timer); resolve({ code: 127, stdout, stderr: String(e), timedOut }); });
17
+ p.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? 1, stdout, stderr, timedOut }); });
18
+ });
19
+ }
20
+ export async function shOk(cmd, cwd) {
21
+ const r = await sh(cmd, cwd);
22
+ if (r.code !== 0)
23
+ throw new Error(`command failed (${r.code}): ${cmd}\n${r.stderr || r.stdout}`);
24
+ return r.stdout;
25
+ }
26
+ export async function gitHead(cwd) {
27
+ return (await shOk("git rev-parse HEAD", cwd)).trim();
28
+ }
29
+ const sanitize = (branch) => branch.replace(/[^\w.-]+/g, "-");
30
+ const branchExists = async (repo, branch) => (await sh(`git rev-parse --verify ${shq(`refs/heads/${branch}`)}`, repo)).code === 0;
31
+ export async function resolveIntegrationBranch(repo, branch) {
32
+ if (!branch.startsWith("drovr/"))
33
+ return branch;
34
+ const legacy = `drover/${branch.slice("drovr/".length)}`;
35
+ return !(await branchExists(repo, branch)) && await branchExists(repo, legacy) ? legacy : branch;
36
+ }
37
+ const resolveTaskBranch = async (repo, branch) => {
38
+ const split = branch.lastIndexOf("--");
39
+ if (split < 0)
40
+ return branch;
41
+ const integration = await resolveIntegrationBranch(repo, branch.slice(0, split));
42
+ return `${integration}${branch.slice(split)}`;
43
+ };
44
+ export async function createWorktree(repo, branch, baseRef) {
45
+ branch = await resolveTaskBranch(repo, branch);
46
+ const dir = join(drovrDir(repo), "worktrees", sanitize(branch));
47
+ if (existsSync(dir))
48
+ await removeWorktree(repo, dir);
49
+ await shOk(`git worktree add -B ${shq(branch)} ${shq(dir)} ${shq(baseRef)}`, repo);
50
+ return dir;
51
+ }
52
+ export async function removeWorktree(repo, dir) {
53
+ await sh(`git worktree remove --force ${shq(dir)}`, repo); // best-effort; stale dirs are re-added with -B
54
+ await sh(`rm -rf ${shq(dir)}`, repo);
55
+ await sh("git worktree prune", repo);
56
+ }
@@ -0,0 +1,91 @@
1
+ import { z } from "zod";
2
+ import { type Assignment } from "../adapters/types.js";
3
+ import type { DrovrConfig } from "../config/config.js";
4
+ import { type TaskStatus } from "../graph/schema.js";
5
+ import { type RoutingProfile } from "../route/profile.js";
6
+ export interface JournalEvent {
7
+ ts: string;
8
+ event: string;
9
+ taskId?: string;
10
+ data: Record<string, unknown>;
11
+ }
12
+ export declare function formatJournalNarration({ event, taskId, data }: JournalEvent): string;
13
+ export interface ResumeState {
14
+ attempts: number;
15
+ tried: string[];
16
+ lastAssignment?: Assignment;
17
+ }
18
+ export declare const ATTEMPT_CAP_RELEASE: "attempt-cap";
19
+ export declare const PARK_KINDS: readonly ["ladder-exhausted", "attempt-cap", "gate-fail", "quota", "reroute-exhausted", "setup", "stall", "merge-conflict"];
20
+ export type ParkKind = (typeof PARK_KINDS)[number];
21
+ export declare const RETRY_MODES: readonly ["resume", "fresh"];
22
+ export type RetryMode = (typeof RETRY_MODES)[number];
23
+ export declare const TelemetryRowSchema: z.ZodObject<{
24
+ taskId: z.ZodString;
25
+ shape: z.ZodString;
26
+ adapter: z.ZodString;
27
+ model: z.ZodString;
28
+ channel: z.ZodString;
29
+ attempts: z.ZodNumber;
30
+ outcome: z.ZodEnum<{
31
+ failed: "failed";
32
+ done: "done";
33
+ human: "human";
34
+ }>;
35
+ durationMs: z.ZodNumber;
36
+ firstAttemptOk: z.ZodOptional<z.ZodBoolean>;
37
+ gateFails: z.ZodOptional<z.ZodNumber>;
38
+ consults: z.ZodOptional<z.ZodNumber>;
39
+ parkKind: z.ZodOptional<z.ZodEnum<{
40
+ setup: "setup";
41
+ "ladder-exhausted": "ladder-exhausted";
42
+ "attempt-cap": "attempt-cap";
43
+ "gate-fail": "gate-fail";
44
+ quota: "quota";
45
+ "reroute-exhausted": "reroute-exhausted";
46
+ stall: "stall";
47
+ "merge-conflict": "merge-conflict";
48
+ }>>;
49
+ tokens: z.ZodCatch<z.ZodOptional<z.ZodObject<{
50
+ input: z.ZodNumber;
51
+ output: z.ZodNumber;
52
+ cacheRead: z.ZodOptional<z.ZodNumber>;
53
+ cacheWrite: z.ZodOptional<z.ZodNumber>;
54
+ reasoning: z.ZodOptional<z.ZodNumber>;
55
+ }, z.core.$strip>>>;
56
+ meteredAttempts: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
57
+ quotaFailover: z.ZodOptional<z.ZodLiteral<true>>;
58
+ overrun: z.ZodOptional<z.ZodLiteral<true>>;
59
+ retryMode: z.ZodOptional<z.ZodEnum<{
60
+ resume: "resume";
61
+ fresh: "fresh";
62
+ }>>;
63
+ }, z.core.$strip>;
64
+ export type TelemetryRow = z.infer<typeof TelemetryRowSchema>;
65
+ export declare function newRunId(now?: Date): string;
66
+ export declare function readAllTelemetry(repoRoot: string, lastK: number, opts?: {
67
+ after?: string;
68
+ }): (TelemetryRow & {
69
+ runId: string;
70
+ })[];
71
+ export declare const RUNS_WINDOW = 50;
72
+ export declare function readProfileCursor(repoRoot: string): string | undefined;
73
+ export declare function loadRoutingProfile(repoRoot: string, cfg: DrovrConfig, opts?: {
74
+ preview?: boolean;
75
+ }): RoutingProfile | undefined;
76
+ export declare class Journal {
77
+ readonly dir: string;
78
+ readonly runId: string;
79
+ private readonly narrate?;
80
+ private constructor();
81
+ static create(repoRoot: string, runId: string, narrate?: (event: JournalEvent) => void): Journal;
82
+ static open(repoRoot: string, runId: string, narrate?: (event: JournalEvent) => void): Journal;
83
+ static latestRunId(repoRoot: string): string | null;
84
+ private get journalPath();
85
+ append(event: string, taskId?: string, data?: Record<string, unknown>): void;
86
+ read(): JournalEvent[];
87
+ replayStatuses(): Map<string, TaskStatus>;
88
+ replayResumeState(): Map<string, ResumeState>;
89
+ telemetry(row: TelemetryRow): void;
90
+ readTelemetry(): TelemetryRow[];
91
+ }
@@ -0,0 +1,287 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { channelKey, TokenUsageSchema } from "../adapters/types.js";
5
+ import { stateDirName } from "../graph/graph.js";
6
+ import { TIERS } from "../graph/schema.js";
7
+ import { buildProfile } from "../route/profile.js";
8
+ export function formatJournalNarration({ event, taskId, data }) {
9
+ const assignment = data.assignment;
10
+ const direct = [data.summary, data.reason, data.error, data.step, data.action, data.lint, data.branch, data.from]
11
+ .find((value) => typeof value === "string" || typeof value === "number");
12
+ const detail = Array.isArray(data.done)
13
+ ? `done ${data.done.length}, failed ${Array.isArray(data.failed) ? data.failed.length : 0}`
14
+ : typeof data.gate === "string"
15
+ ? `${data.gate}${data.pass === true ? " passed" : data.pass === false ? " failed" : ""}`
16
+ : typeof data.code === "number" ? `exit ${data.code}`
17
+ : typeof data.pid === "number" ? `pid ${data.pid}`
18
+ : typeof data.baseRef === "string" ? `base ${data.baseRef.slice(0, 12)}`
19
+ : direct === undefined
20
+ ? typeof assignment?.adapter === "string" && typeof assignment.model === "string" ? `${assignment.adapter}:${assignment.model}` : undefined
21
+ : String(direct);
22
+ return [event, taskId, detail?.replace(/\s+/g, " ").slice(0, 120)].filter(Boolean).join(" — ");
23
+ }
24
+ // v1.24 OBS-18: explicit data on task-approved when the operator releases an attempt-cap park.
25
+ // Approve stamps this; replayResumeState zeros the attempt counter (fresh budget) while keeping
26
+ // tried (consult bans / burned channels). Absent on pre-v1.24 events ⇒ inert (corpus outcome-identical).
27
+ export const ATTEMPT_CAP_RELEASE = "attempt-cap";
28
+ // Fail-closed shape for a dispatched assignment (journal.ts:75-90 posture): a malformed assignment in
29
+ // one dispatch degrades that single task toward today's behavior — counts toward attempts, contributes
30
+ // nothing to tried, poisons only lastAssignment — never crashes resume, never poisons other tasks.
31
+ const DispatchAssignmentSchema = z.object({
32
+ adapter: z.string(),
33
+ model: z.string(),
34
+ channel: z.enum(["sub", "api"]),
35
+ tier: z.enum(TIERS),
36
+ });
37
+ export const PARK_KINDS = ["ladder-exhausted", "attempt-cap", "gate-fail", "quota",
38
+ "reroute-exhausted", "setup", "stall", "merge-conflict"];
39
+ export const RETRY_MODES = ["resume", "fresh"];
40
+ export const TelemetryRowSchema = z.object({
41
+ // v1.5 core — required; every old row has all eight (daemon.ts writes all eight)
42
+ taskId: z.string(), shape: z.string(), adapter: z.string(), model: z.string(), channel: z.string(),
43
+ attempts: z.number(), outcome: z.enum(["done", "failed", "human"]), durationMs: z.number(),
44
+ // v1.6 additive (TEL-01/02) — OPTIONAL: a v1.5 row parses with these === undefined ("unobserved"),
45
+ // never false/0. Phase 12 readers must branch on === true / === false; `?? false` is the poisoning bug.
46
+ firstAttemptOk: z.boolean().optional(),
47
+ gateFails: z.number().optional(),
48
+ consults: z.number().optional(),
49
+ parkKind: z.enum(PARK_KINDS).optional(),
50
+ // v1.7 additive (SPEND-02) — OPTIONAL: absent = unmetered, never 0. .catch(undefined): a MALFORMED
51
+ // tokens sub-object degrades to unmetered instead of safeParse-dropping the whole row — a metering
52
+ // bug must never remove a row from profile derivation (that would let metering perturb routing).
53
+ tokens: TokenUsageSchema.optional().catch(undefined),
54
+ // v1.7 (SPEND-02): count of attempts that produced a usage record. Present IFF tokens is present.
55
+ // meteredAttempts < attempts ⇒ tokens is a FLOOR (partially metered task) — Phase 18 must label it,
56
+ // never print it as a total. Absent ⇒ the row is unmetered entirely.
57
+ meteredAttempts: z.number().int().positive().optional().catch(undefined),
58
+ // v1.8 additive (TEL-05) — OPTIONAL, z.literal(true) so `false` is UNREPRESENTABLE: absent = "no
59
+ // mid-task quota failover observed" (never false), present = attributed to the channel throttled
60
+ // away FROM. Phase 26 ROUTE-12 (utilization axis) consumes this; only the if(next) branch writes it.
61
+ quotaFailover: z.literal(true).optional(),
62
+ // v1.13 additive (ROUTE-18) — OPTIONAL, z.literal(true) so `false` is UNREPRESENTABLE: absent = "no
63
+ // overrun observed" (never false), present = the channel burned a window without emitting a trailer
64
+ // (worker-result ok:false, finished:false — no-trailer timeout OR trailer-less crash-exit). Written
65
+ // only at the daemon's !finished site (after the quota check, quota-disjoint); 48-01's ProfileRow
66
+ // (src/route/profile.ts) consumes the field-name contract `overrun`. Mirrors quotaFailover exactly.
67
+ overrun: z.literal(true).optional(),
68
+ // v1.29 additive: mode of the attempt represented by this row. Absent on old telemetry.
69
+ retryMode: z.enum(RETRY_MODES).optional(),
70
+ });
71
+ export function newRunId(now = new Date()) {
72
+ const p = (n, w = 2) => String(n).padStart(w, "0");
73
+ return `run-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
74
+ }
75
+ const runsDir = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "runs");
76
+ // One JSONL reader for every append-only log: skip blanks, drop any line that
77
+ // won't parse, keeping everything before it intact.
78
+ function readJsonl(path) {
79
+ if (!existsSync(path))
80
+ return [];
81
+ const out = [];
82
+ for (const line of readFileSync(path, "utf8").split("\n")) {
83
+ if (!line.trim())
84
+ continue;
85
+ try {
86
+ out.push(JSON.parse(line));
87
+ }
88
+ catch {
89
+ // torn trailing write after a crash — ignore; everything before it is intact
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+ // Cross-run telemetry for Phase-12 profile derivation: the last K runs' rows, each
95
+ // tagged with its runId (runIds are zero-padded run-YYYYMMDD-HHMMSS ⇒ plain .sort() is
96
+ // chronological, same as latestRunId). Rows are facts, not classifications — Phase 12
97
+ // owns the quality-denominator/reward policy. A safeParse failure drops that one row
98
+ // (same posture as a torn line); a garbage row must never crash profile derivation.
99
+ // Note: a per-task row attributes to the FINAL channel — a channel escalated away from
100
+ // mid-task contributes no row; accepted for v1.6.
101
+ export function readAllTelemetry(repoRoot, lastK, opts = {}) {
102
+ const dir = runsDir(repoRoot);
103
+ if (!existsSync(dir))
104
+ return [];
105
+ let runIds = readdirSync(dir).filter((d) => d.startsWith("run-")).sort();
106
+ // VIS-03 reset cursor: runIds are zero-padded run-YYYYMMDD-HHMMSS ⇒ string > is chronological
107
+ if (opts.after)
108
+ runIds = runIds.filter((id) => id > opts.after);
109
+ runIds = runIds.slice(-lastK);
110
+ const out = [];
111
+ for (const runId of runIds) {
112
+ for (const raw of readJsonl(join(dir, runId, "telemetry.jsonl"))) {
113
+ const r = TelemetryRowSchema.safeParse(raw);
114
+ if (r.success)
115
+ out.push({ ...r.data, runId });
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+ // ponytail: fixed 50-run window; promote to a routing.learned.* config knob only if operators need to tune it.
121
+ export const RUNS_WINDOW = 50;
122
+ // VIS-03 reset cursor — one trimmed runId line at .drovr/profile-since; absent/empty ⇒ undefined.
123
+ // Opaque: used ONLY in the runId > comparison above, never a shell or path join beyond .drovr/.
124
+ export function readProfileCursor(repoRoot) {
125
+ const path = join(repoRoot, stateDirName(repoRoot), "profile-since");
126
+ if (!existsSync(path))
127
+ return undefined;
128
+ return readFileSync(path, "utf8").trim() || undefined;
129
+ }
130
+ // The one shared profile builder (criterion 4: plan and daemon share ONE code path).
131
+ // preview:true bypasses the routing.learned:off short-circuit so `drovr plan` can render the
132
+ // trust-ramp preview while the daemon (no preview) stays inert (VALIDATION 13-01-11).
133
+ export function loadRoutingProfile(repoRoot, cfg, opts = {}) {
134
+ if (cfg.routing.learned === "off" && !opts.preview)
135
+ return undefined; // never built, never passed
136
+ const rows = readAllTelemetry(repoRoot, RUNS_WINDOW, { after: readProfileCursor(repoRoot) });
137
+ // ROUTE-15: halfLifeRuns threads from config as a pure param; undefined ⇒ module default (byte-identical).
138
+ return rows.length ? buildProfile(rows, { halfLifeRuns: cfg.routing.learnedTuning?.halfLifeRuns }) : undefined; // cold ⇒ undefined ⇒ v1.5 dead-code path
139
+ }
140
+ export class Journal {
141
+ dir;
142
+ runId;
143
+ narrate;
144
+ constructor(dir, runId, narrate) {
145
+ this.dir = dir;
146
+ this.runId = runId;
147
+ this.narrate = narrate;
148
+ }
149
+ static create(repoRoot, runId, narrate) {
150
+ const dir = join(runsDir(repoRoot), runId);
151
+ mkdirSync(dir, { recursive: true });
152
+ return new Journal(dir, runId, narrate);
153
+ }
154
+ static open(repoRoot, runId, narrate) {
155
+ const dir = join(runsDir(repoRoot), runId);
156
+ if (!existsSync(join(dir, "journal.jsonl")))
157
+ throw new Error(`no journal for ${runId} at ${dir}`);
158
+ return new Journal(dir, runId, narrate);
159
+ }
160
+ static latestRunId(repoRoot) {
161
+ if (!existsSync(runsDir(repoRoot)))
162
+ return null;
163
+ const ids = readdirSync(runsDir(repoRoot)).filter((d) => d.startsWith("run-")).sort();
164
+ return ids.at(-1) ?? null;
165
+ }
166
+ get journalPath() {
167
+ return join(this.dir, "journal.jsonl");
168
+ }
169
+ append(event, taskId, data = {}) {
170
+ const row = { ts: new Date().toISOString(), event, ...(taskId ? { taskId } : {}), data };
171
+ appendFileSync(this.journalPath, JSON.stringify(row) + "\n");
172
+ try {
173
+ this.narrate?.(row);
174
+ }
175
+ catch {
176
+ // narration is observational; a broken sink must not affect the journal or run
177
+ }
178
+ }
179
+ read() {
180
+ return readJsonl(this.journalPath);
181
+ }
182
+ replayStatuses() {
183
+ const s = new Map();
184
+ for (const e of this.read()) {
185
+ if (!e.taskId)
186
+ continue;
187
+ if (e.event === "task-dispatch")
188
+ s.set(e.taskId, "running");
189
+ else if (e.event === "task-done")
190
+ s.set(e.taskId, "done");
191
+ else if (e.event === "task-failed")
192
+ s.set(e.taskId, "failed");
193
+ else if (e.event === "task-human")
194
+ s.set(e.taskId, "human");
195
+ // GATE-08 (v1.12): approval is a journal EVENT, never a graph.json mutation (graph.json is compiled
196
+ // output; recompile re-emits humanGate:true and would silently erase it — Phase 42 D-02). Events
197
+ // replay in order, so task-human → task-approved lands on pending (last write wins). Additive-only:
198
+ // 26 real journals with no such event replay byte-identically (D-04).
199
+ else if (e.event === "task-approved")
200
+ s.set(e.taskId, "pending");
201
+ }
202
+ for (const [id, st] of s)
203
+ if (st === "running")
204
+ s.set(id, "pending");
205
+ return s;
206
+ }
207
+ // Phase 46 (RES-01/RES-02): companion replay — derives per-task resume state {attempts, tried,
208
+ // lastAssignment} from EXISTING events only (task-dispatch + consult-verdict + optional v1.24
209
+ // task-approved{release:attempt-cap}). Additive-only: no new required event, no schema change, the
210
+ // status replay above is byte-untouched (corpus criterion 3 is git-diff-provable). Motivated by the
211
+ // 2026-07-11 incident (run-20260711-185020, P43-03): `drovr resume` re-dispatched at attempt 0 on
212
+ // pi:zai/glm-5.2, the exact channel a frontier consult had just banned, because execTask's
213
+ // attempt/tried/assignment state is loop-local and dies with the process while the journal held every fact needed.
214
+ //
215
+ // attempts is a COUNT of task-dispatch events, NEVER max(data.attempt)+1: existing journals'
216
+ // post-resume dispatches restart at 0 (the bug corrupted its own evidence — incident journal L58
217
+ // logged attempt 0 two ms after run-resume). Count === max+1 on clean journals and is truthful on
218
+ // corrupted ones. tried is the ordered dedup of channelKey(assignment) across dispatches (≡ the
219
+ // pre-kill tried list). lastAssignment is the last well-formed dispatched assignment.
220
+ replayResumeState() {
221
+ const m = new Map();
222
+ const pendingReroute = new Set(); // reroute verdicts not yet cleared by a later dispatch
223
+ for (const e of this.read()) {
224
+ if (!e.taskId)
225
+ continue;
226
+ if (e.event === "task-dispatch") {
227
+ // A subsequent dispatch clears the pending reroute — the reroute was acted on pre-kill.
228
+ pendingReroute.delete(e.taskId);
229
+ let st = m.get(e.taskId);
230
+ if (!st) {
231
+ st = { attempts: 0, tried: [] };
232
+ m.set(e.taskId, st);
233
+ }
234
+ st.attempts++; // COUNT, never max(data.attempt)+1 — see rationale above
235
+ const parsed = DispatchAssignmentSchema.safeParse(e.data.assignment);
236
+ if (parsed.success) {
237
+ const key = channelKey(parsed.data);
238
+ if (!st.tried.includes(key))
239
+ st.tried.push(key);
240
+ st.lastAssignment = parsed.data;
241
+ }
242
+ else {
243
+ // fail closed: malformed assignment still COUNTS (above) but adds nothing to tried and
244
+ // poisons only lastAssignment (a malformed LAST dispatch must not be restored).
245
+ st.lastAssignment = undefined;
246
+ }
247
+ }
248
+ else if (e.event === "consult-verdict" && e.data.action === "reroute") {
249
+ // A reroute bans the in-force channel; retry/decompose/human verdicts ban nothing (D-03).
250
+ pendingReroute.add(e.taskId);
251
+ }
252
+ else if (e.event === "task-approved" && e.data.release === ATTEMPT_CAP_RELEASE) {
253
+ // v1.24 OBS-18: operator released an attempt-cap park. Pre-v1.24 task-approved events have no
254
+ // `release` key ⇒ this branch never fires (corpus criterion: identical statuses + resume state).
255
+ // attempts reset to 0 so the daemon's attempt-cap check does not re-park in the same tick;
256
+ // tried survives (consult bans and burned channels are not forgotten); lastAssignment is
257
+ // cleared so the daemon's nextChannel-over-tried path skips burned channels on first dispatch
258
+ // (restoring the last burned assignment would re-try it first — the failure the tried-list exists to prevent).
259
+ const st = m.get(e.taskId);
260
+ if (st) {
261
+ st.attempts = 0;
262
+ st.lastAssignment = undefined;
263
+ }
264
+ }
265
+ }
266
+ // Trailing-reroute edge (D-01 kill between verdict and dispatch): a reroute verdict with NO
267
+ // subsequent dispatch means the last-dispatched channel is itself banned — add it to tried (if
268
+ // absent) and clear lastAssignment so the daemon falls back to nextChannel over the exclusions.
269
+ for (const taskId of pendingReroute) {
270
+ const st = m.get(taskId);
271
+ if (st?.lastAssignment) {
272
+ const key = channelKey(st.lastAssignment);
273
+ if (!st.tried.includes(key))
274
+ st.tried.push(key);
275
+ st.lastAssignment = undefined;
276
+ }
277
+ }
278
+ return m;
279
+ }
280
+ telemetry(row) {
281
+ appendFileSync(join(this.dir, "telemetry.jsonl"), JSON.stringify(row) + "\n");
282
+ }
283
+ // Per-run, raw (NOT schema-validated) — report.ts reads v1.5 core fields; stays byte-compatible.
284
+ readTelemetry() {
285
+ return readJsonl(join(this.dir, "telemetry.jsonl"));
286
+ }
287
+ }
@@ -0,0 +1,29 @@
1
+ export declare const HEARTBEAT_MS = 10000;
2
+ export declare const STALE_MS = 60000;
3
+ export interface Inspection {
4
+ pid?: number;
5
+ runId?: string;
6
+ garbage: boolean;
7
+ dead: boolean;
8
+ expired: boolean;
9
+ mtimeMs: number;
10
+ ino: number;
11
+ }
12
+ export declare function shouldRefuse(i: Pick<Inspection, "garbage" | "dead">): boolean;
13
+ export declare function acquireRunLock(repoRoot: string, runId: string): {
14
+ reclaimed?: {
15
+ pid: number;
16
+ mtimeMs: number;
17
+ };
18
+ };
19
+ export declare function releaseRunLock(repoRoot: string): void;
20
+ export declare function isRunLockLive(repoRoot: string): boolean;
21
+ export declare function unlockRun(repoRoot: string): {
22
+ held: false;
23
+ } | {
24
+ held: true;
25
+ removed: true;
26
+ pid?: number;
27
+ runId?: string;
28
+ garbage: boolean;
29
+ };
@@ -0,0 +1,182 @@
1
+ import { linkSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { drovrDir } from "../graph/graph.js";
5
+ // HARD-01/02: coarse per-run advisory lock over .drovr/graph.json. LOCK-02: the lock is created by
6
+ // the link(2) idiom — write the full payload to graph.lock.<pid>.tmp, then linkSync(tmp, lockPath),
7
+ // which is atomic and throws EEXIST if the lock already exists (the mutual-exclusion primitive).
8
+ // rename() was REJECTED: it silently clobbers an existing destination, destroying that mutual
9
+ // exclusion (two daemons would both "acquire"). LOCK-02 (OBS-05): a PROVABLY-dead holder (ESRCH)
10
+ // self-clears immediately — expiry is NOT required. ESRCH is proof-positive death; the PID-reuse
11
+ // hazard runs the OTHER way (a reused pid reads ALIVE and refuses until `drovr unlock`). mtime stays
12
+ // the heartbeat — it feeds the reclaim race guard (ino+mtime re-stat) and the reclaimed audit value.
13
+ // Zero new deps — node:fs stdlib.
14
+ export const HEARTBEAT_MS = 10_000;
15
+ export const STALE_MS = 60_000; // 6× heartbeat headroom; PITFALLS floor is ≥5s beat / ~30s staleness
16
+ // T-10-01: the payload is a trust boundary — parse with zod, fail closed on garbage. Only
17
+ // pid/runId/startedAt are ever read; anything else is ignored (info-disclosure surface).
18
+ const PayloadSchema = z.object({ pid: z.number().int().positive(), runId: z.string(), startedAt: z.number() });
19
+ const lockPath = (repoRoot) => join(drovrDir(repoRoot), "graph.lock");
20
+ let heartbeat;
21
+ let heldPath;
22
+ // Best-effort release if the daemon exits without hitting its finally (crash mid-body). NO
23
+ // SIGINT/SIGTERM handlers — those change kill semantics and leak listeners across the test
24
+ // suite's many runDaemon calls; signal-death is exactly what stale-reclaim exists for.
25
+ process.once("exit", () => { if (heldPath)
26
+ unlinkIfOurs(heldPath); });
27
+ // LOCK-04: the ONE decision table. Both acquireRunLock and isRunLockLive consume this — the two
28
+ // hand-maintained copies of the rule cannot drift. The table changes HERE, once. unlockRun uses the
29
+ // SAME inspect() with its own live-holder refusal (`!dead && !garbage`) — it is the escape hatch, not
30
+ // a second copy of the rule.
31
+ // LOCK-02 (OBS-05): refuse iff garbage OR alive. dead (ESRCH) self-clears through the reclaim branch
32
+ // below — no 60s heartbeat wait. Expiry no longer participates in the DECISION (Inspect still carries
33
+ // `expired` for the race guard + reclaim audit); the heartbeat mechanism itself is untouched.
34
+ // ponytail: a pid REUSED by an unrelated live process reads ALIVE (kill(pid,0) succeeds) and now
35
+ // refuses indefinitely instead of expiring out in ≤60s — that is the fail-closed direction; the
36
+ // escape hatch is `drovr unlock`. Acceptable for a single-machine tool (ESRCH is the only
37
+ // proof-positive death; narrowing further needs pid-start-time correlation, out of scope here).
38
+ // LOCK-01: garbage ⇒ always refuse (mtime irrelevant). It short-circuits before dead can matter —
39
+ // so inspect()'s `dead = pid === undefined` fallback for the garbage row stays harmless. Safe post-16-01:
40
+ // the atomic link(2) write means drovr can no longer mint garbage itself; a garbage payload can only
41
+ // come from external corruption, which is exactly what must refuse. Only `drovr unlock` removes it
42
+ // — a self-heal reclaim would silently overwrite whatever corrupted the file.
43
+ export function shouldRefuse(i) {
44
+ return i.garbage || !i.dead;
45
+ }
46
+ // statSync throws ENOENT when no lock exists — callers treat that as "not held".
47
+ function inspect(p) {
48
+ const st = statSync(p); // single stat: both the heartbeat mtime and the reclaim-guard inode
49
+ const mtimeMs = st.mtimeMs;
50
+ const expired = Date.now() - mtimeMs > STALE_MS;
51
+ const parsed = PayloadSchema.safeParse(readPayload(p));
52
+ const garbage = !parsed.success; // LOCK-01: its own state — shouldRefuse refuses it unconditionally; only `drovr unlock` removes it
53
+ const pid = parsed.success ? parsed.data.pid : undefined;
54
+ let dead = pid === undefined; // harmless fallback for the garbage row — garbage short-circuits shouldRefuse before this is read
55
+ if (pid !== undefined) {
56
+ try {
57
+ process.kill(pid, 0);
58
+ dead = false;
59
+ } // no throw ⇒ ALIVE
60
+ catch (k) {
61
+ dead = k.code === "ESRCH";
62
+ } // ESRCH ⇒ dead; EPERM ⇒ ALIVE
63
+ }
64
+ return { pid, runId: parsed.success ? parsed.data.runId : undefined, garbage, dead, expired, mtimeMs, ino: st.ino };
65
+ }
66
+ function readPayload(p) {
67
+ try {
68
+ return JSON.parse(readFileSync(p, "utf8"));
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ function unlinkIfOurs(p) {
75
+ try {
76
+ const parsed = PayloadSchema.safeParse(readPayload(p));
77
+ if (parsed.success && parsed.data.pid === process.pid)
78
+ unlinkSync(p);
79
+ }
80
+ catch { /* ENOENT — nothing to release */ }
81
+ }
82
+ export function acquireRunLock(repoRoot, runId) {
83
+ const p = lockPath(repoRoot);
84
+ const tmp = join(drovrDir(repoRoot), `graph.lock.${process.pid}.tmp`);
85
+ try {
86
+ try {
87
+ unlinkSync(tmp);
88
+ }
89
+ catch { /* W6: best-effort clean of our own stray tmp from a prior SIGKILL */ }
90
+ writeFileSync(tmp, JSON.stringify({ pid: process.pid, runId, startedAt: Date.now() }));
91
+ try {
92
+ linkSync(tmp, p); // atomic: exists ⇒ EEXIST — replaces the old exclusive-create open, no write window on the lock path
93
+ }
94
+ finally {
95
+ unlinkSync(tmp); // the link survives; tmp is just a handle. finally ⇒ no litter, even for the EEXIST loser
96
+ }
97
+ heldPath = p;
98
+ heartbeat = setInterval(() => { try {
99
+ const now = new Date();
100
+ utimesSync(p, now, now);
101
+ }
102
+ catch { /* released */ } }, HEARTBEAT_MS);
103
+ heartbeat.unref(); // never hold the daemon's event loop open
104
+ return {};
105
+ }
106
+ catch (e) {
107
+ if (e.code !== "EEXIST")
108
+ throw e;
109
+ const insp = inspect(p);
110
+ const { pid, runId: heldRun, garbage, mtimeMs } = insp;
111
+ if (!shouldRefuse(insp)) {
112
+ // ponytail: inode+mtime guard NARROWS but does not close the cross-process reclaim double-unlink
113
+ // race; a real fix needs unlink-by-inode (no POSIX primitive) or a lock-directory rename dance.
114
+ // Single-machine tool — this is the ceiling.
115
+ const st = statSync(p); // re-stat immediately before removing — abort if replaced since inspect()
116
+ if (st.ino !== insp.ino || st.mtimeMs !== insp.mtimeMs)
117
+ return acquireRunLock(repoRoot, runId); // loser path
118
+ try {
119
+ unlinkSync(p);
120
+ }
121
+ catch (e2) {
122
+ if (e2.code !== "ENOENT")
123
+ throw e2;
124
+ } // successor already took it
125
+ const again = acquireRunLock(repoRoot, runId); // re-serializes two stealers through linkSync
126
+ return { reclaimed: { pid: pid ?? -1, mtimeMs }, ...again };
127
+ }
128
+ if (garbage)
129
+ throw new Error(".drovr/graph.lock holds an unreadable/garbage payload — refusing to reclaim it; run `drovr unlock` to remove it");
130
+ // LOCK-02: shouldRefuse is false whenever dead, so this throw is reached only for a LIVE holder
131
+ // (incl. EPERM = alive-but-not-ours). The dead-but-fresh case self-clears via the reclaim branch.
132
+ throw new Error(`.drovr/graph.lock held by pid ${pid ?? "?"}${heldRun ? ` (run ${heldRun})` : ""} — another drovr run? (operator escape: \`drovr unlock\`)`);
133
+ }
134
+ }
135
+ export function releaseRunLock(repoRoot) {
136
+ if (heartbeat) {
137
+ clearInterval(heartbeat);
138
+ heartbeat = undefined;
139
+ }
140
+ unlinkIfOurs(lockPath(repoRoot)); // never delete a reclaiming successor's lock — only ours
141
+ heldPath = undefined;
142
+ }
143
+ // Read-only check for compile: true iff a lock exists that the decision table would REFUSE on
144
+ // (alive, EPERM, or ANY garbage). A provably-dead holder (ESRCH) reads not-live — compile proceeds
145
+ // in step with resume (LOCK-02/OBS-05). Never mutates the lock.
146
+ export function isRunLockLive(repoRoot) {
147
+ try {
148
+ return shouldRefuse(inspect(lockPath(repoRoot)));
149
+ }
150
+ catch {
151
+ return false;
152
+ } // no lock
153
+ }
154
+ // LOCK-03: operator escape hatch. Liveness-checked delete — removes a dead-holder or garbage lock,
155
+ // REFUSES to remove one whose holder is alive (incl. EPERM = alive-but-not-ours). No --force: the
156
+ // refusal names the pid; the operator kills the process. Liveness logic stays here (LOCK-04
157
+ // discipline extends to this caller) — the CLI command is a thin formatter.
158
+ // W4 TOCTOU: liveness is checked, THEN unlinked — a live holder that dies (or a dead pid reused)
159
+ // between inspect() and unlinkSync is a window this does NOT close. Acceptable: unlock is
160
+ // operator-initiated on an already-parked run; worst case is removing a lock a just-reborn process
161
+ // would want, which the operator triggered and can recover by re-running. NOT an atomic re-check.
162
+ export function unlockRun(repoRoot) {
163
+ const p = lockPath(repoRoot);
164
+ let insp;
165
+ try {
166
+ insp = inspect(p);
167
+ }
168
+ catch {
169
+ return { held: false };
170
+ } // statSync ENOENT ⇒ no lock
171
+ if (!insp.dead && !insp.garbage) {
172
+ throw new Error(`.drovr/graph.lock held by LIVE pid ${insp.pid}${insp.runId ? ` (run ${insp.runId})` : ""} — refusing to unlock; stop that run first`);
173
+ }
174
+ try {
175
+ unlinkSync(p);
176
+ }
177
+ catch (e) {
178
+ if (e.code !== "ENOENT")
179
+ throw e;
180
+ }
181
+ return { held: true, removed: true, pid: insp.pid, runId: insp.runId, garbage: insp.garbage };
182
+ }