tickmarkr 1.44.0 → 1.46.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.
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { type Assignment } from "../adapters/types.js";
3
3
  import type { TickmarkrConfig } from "../config/config.js";
4
4
  import { type TaskStatus } from "../graph/schema.js";
5
- import { type RoutingProfile } from "../route/profile.js";
5
+ import { type ProfileDiscount, type RoutingProfile } from "../route/profile.js";
6
6
  export interface JournalEvent {
7
7
  ts: string;
8
8
  event: string;
@@ -16,10 +16,21 @@ export interface ResumeState {
16
16
  lastAssignment?: Assignment;
17
17
  }
18
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"];
19
+ export declare const PARK_KINDS: readonly ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota", "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved"];
20
20
  export type ParkKind = (typeof PARK_KINDS)[number];
21
21
  export declare const RETRY_MODES: readonly ["resume", "fresh"];
22
22
  export type RetryMode = (typeof RETRY_MODES)[number];
23
+ export declare const WORKER_RESULT_CAUSES: readonly ["provider-death", "stall-timeout", "malformed-trailer", "clean-exit-no-trailer"];
24
+ export type WorkerResultCause = (typeof WORKER_RESULT_CAUSES)[number];
25
+ /** OBS-53: classify worker-result failures so retries and routing see the true signal, not one lumped bucket. */
26
+ export declare function classifyWorkerResultCause(opts: {
27
+ output: string;
28
+ ok: boolean;
29
+ finished: boolean;
30
+ exitCode: number | null;
31
+ summary: string;
32
+ timedOut: boolean;
33
+ }): WorkerResultCause | undefined;
23
34
  export declare const TelemetryRowSchema: z.ZodObject<{
24
35
  taskId: z.ZodString;
25
36
  shape: z.ZodString;
@@ -42,9 +53,11 @@ export declare const TelemetryRowSchema: z.ZodObject<{
42
53
  "attempt-cap": "attempt-cap";
43
54
  "gate-fail": "gate-fail";
44
55
  quota: "quota";
56
+ "human-gate": "human-gate";
45
57
  "reroute-exhausted": "reroute-exhausted";
46
58
  stall: "stall";
47
59
  "merge-conflict": "merge-conflict";
60
+ "tip-moved": "tip-moved";
48
61
  }>>;
49
62
  tokens: z.ZodCatch<z.ZodOptional<z.ZodObject<{
50
63
  input: z.ZodNumber;
@@ -60,11 +73,44 @@ export declare const TelemetryRowSchema: z.ZodObject<{
60
73
  resume: "resume";
61
74
  fresh: "fresh";
62
75
  }>>;
76
+ signalQuality: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<0.25>, z.ZodLiteral<0.5>, z.ZodLiteral<0.75>, z.ZodLiteral<1>]>>;
77
+ signalBasis: z.ZodOptional<z.ZodEnum<{
78
+ proved: "proved";
79
+ "review-agree": "review-agree";
80
+ "judge-only": "judge-only";
81
+ legacy: "legacy";
82
+ vacuous: "vacuous";
83
+ skipped: "skipped";
84
+ }>>;
63
85
  }, z.core.$strip>;
64
86
  export type TelemetryRow = z.infer<typeof TelemetryRowSchema>;
65
- export declare function recordedGraphHash(events: JournalEvent[]): string | undefined;
66
- export declare function journalComparable(events: JournalEvent[], loadedHash: string): boolean;
87
+ export declare const SIGNAL_BASIS: readonly ["proved", "review-agree", "judge-only", "legacy", "vacuous", "skipped"];
88
+ export type SignalBasis = (typeof SIGNAL_BASIS)[number];
89
+ export declare const SIGNAL_QUALITY: Record<SignalBasis, 0 | 0.25 | 0.5 | 0.75 | 1>;
90
+ export declare function signalQualityFromBasis(basis: SignalBasis): 0 | 0.25 | 0.5 | 0.75 | 1;
91
+ export declare function deriveSignalBasis(gate: string, pass: boolean, details: string, meta?: Record<string, unknown>): SignalBasis;
92
+ export declare function gateResultJournalData(gate: string, pass: boolean, details: string, meta?: Record<string, unknown>): {
93
+ gate: string;
94
+ pass: boolean;
95
+ details: string;
96
+ signalBasis: SignalBasis;
97
+ signalQuality: number;
98
+ } & Record<string, unknown>;
99
+ export declare function recordedGraphDefinitionHash(events: JournalEvent[]): string | undefined;
100
+ export type EngagementCompare = {
101
+ comparable: true;
102
+ recorded: string;
103
+ } | {
104
+ comparable: false;
105
+ reason: "mismatch";
106
+ recorded: string;
107
+ } | {
108
+ comparable: false;
109
+ reason: "unbound";
110
+ };
111
+ export declare function engagementComparable(events: JournalEvent[], loadedHash: string): EngagementCompare;
67
112
  export declare function newRunId(now?: Date): string;
113
+ export declare function parseRunId(runId: string): string;
68
114
  export declare function readAllTelemetry(repoRoot: string, lastK: number, opts?: {
69
115
  after?: string;
70
116
  }): (TelemetryRow & {
@@ -72,6 +118,9 @@ export declare function readAllTelemetry(repoRoot: string, lastK: number, opts?:
72
118
  })[];
73
119
  export declare const RUNS_WINDOW = 50;
74
120
  export declare function readProfileCursor(repoRoot: string): string | undefined;
121
+ export declare function profileDiscountsPath(repoRoot: string): string;
122
+ export declare function readProfileDiscounts(repoRoot: string): ProfileDiscount[];
123
+ export declare function appendProfileDiscount(repoRoot: string, discount: ProfileDiscount): void;
75
124
  export declare function loadRoutingProfile(repoRoot: string, cfg: TickmarkrConfig, opts?: {
76
125
  preview?: boolean;
77
126
  }): RoutingProfile | undefined;
@@ -2,7 +2,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { channelKey, TokenUsageSchema } from "../adapters/types.js";
5
- import { stateDirName } from "../graph/graph.js";
5
+ import { stateDirName, tickmarkrDir } from "../graph/graph.js";
6
6
  import { TIERS } from "../graph/schema.js";
7
7
  import { buildProfile } from "../route/profile.js";
8
8
  export function formatJournalNarration({ event, taskId, data }) {
@@ -38,9 +38,28 @@ const DispatchAssignmentSchema = z.object({
38
38
  channel: z.enum(["sub", "api"]),
39
39
  tier: z.enum(TIERS),
40
40
  });
41
- export const PARK_KINDS = ["ladder-exhausted", "attempt-cap", "gate-fail", "quota",
42
- "reroute-exhausted", "setup", "stall", "merge-conflict"];
41
+ export const PARK_KINDS = ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota",
42
+ "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved"];
43
43
  export const RETRY_MODES = ["resume", "fresh"];
44
+ export const WORKER_RESULT_CAUSES = ["provider-death", "stall-timeout", "malformed-trailer", "clean-exit-no-trailer"];
45
+ // OBS-53: provider-outage signatures in dead worker output ("Unable to reach the model provider" and kin).
46
+ const PROVIDER_OUTAGE_RE = /Unable to reach the model provider|cannot reach the model provider|model provider.*(?:unavailable|unreachable)/i;
47
+ /** OBS-53: classify worker-result failures so retries and routing see the true signal, not one lumped bucket. */
48
+ export function classifyWorkerResultCause(opts) {
49
+ if (opts.ok && opts.finished)
50
+ return undefined;
51
+ if (PROVIDER_OUTAGE_RE.test(opts.output))
52
+ return "provider-death";
53
+ if (opts.summary === "unparseable TICKMARKR_RESULT trailer")
54
+ return "malformed-trailer";
55
+ if (!opts.finished && opts.timedOut)
56
+ return "stall-timeout";
57
+ if (!opts.finished && opts.exitCode !== null)
58
+ return "clean-exit-no-trailer";
59
+ if (!opts.finished)
60
+ return "stall-timeout";
61
+ return undefined;
62
+ }
44
63
  export const TelemetryRowSchema = z.object({
45
64
  // v1.5 core — required; every old row has all eight (daemon.ts writes all eight)
46
65
  taskId: z.string(), shape: z.string(), adapter: z.string(), model: z.string(), channel: z.string(),
@@ -71,23 +90,88 @@ export const TelemetryRowSchema = z.object({
71
90
  overrun: z.literal(true).optional(),
72
91
  // v1.29 additive: mode of the attempt represented by this row. Absent on old telemetry.
73
92
  retryMode: z.enum(RETRY_MODES).optional(),
93
+ // v1.46 additive (T5): gate-signal quality for routing hygiene — OPTIONAL, absent = legacy (0.25 at fold).
94
+ signalQuality: z.union([z.literal(0), z.literal(0.25), z.literal(0.5), z.literal(0.75), z.literal(1)]).optional(),
95
+ signalBasis: z.enum(["proved", "review-agree", "judge-only", "legacy", "vacuous", "skipped"]).optional(),
74
96
  });
75
- // OBS-52: graph hash on run-start binds status replay to the compiled graph that started the run.
76
- export function recordedGraphHash(events) {
97
+ // v1.46 T5 (Sol signal telemetry): gate-result rows carry explicit signalQuality so future defect windows
98
+ // are identifiable without forensics. Basis is the provenance claim; quality is the dyadic h-fold weight.
99
+ export const SIGNAL_BASIS = ["proved", "review-agree", "judge-only", "legacy", "vacuous", "skipped"];
100
+ export const SIGNAL_QUALITY = {
101
+ proved: 1,
102
+ "review-agree": 0.75,
103
+ "judge-only": 0.5,
104
+ legacy: 0.25,
105
+ vacuous: 0,
106
+ skipped: 0,
107
+ };
108
+ export function signalQualityFromBasis(basis) {
109
+ return SIGNAL_QUALITY[basis];
110
+ }
111
+ export function deriveSignalBasis(gate, pass, details, meta = {}) {
112
+ if (meta.skipped === true)
113
+ return "skipped";
114
+ if (meta.unparseable === true)
115
+ return "vacuous";
116
+ if (gate === "acceptance")
117
+ return "judge-only";
118
+ if (gate === "review")
119
+ return pass ? "review-agree" : "vacuous";
120
+ if (gate === "test" || gate === "build") {
121
+ if (!pass)
122
+ return "vacuous";
123
+ if (/no test files|0 tests|tests\s+0\s/i.test(details))
124
+ return "vacuous";
125
+ return "proved";
126
+ }
127
+ return pass ? "proved" : "vacuous";
128
+ }
129
+ // The canonical gate-result journal payload — daemon should spread this into append("gate-result", …).
130
+ export function gateResultJournalData(gate, pass, details, meta = {}) {
131
+ const signalBasis = deriveSignalBasis(gate, pass, details, meta);
132
+ return { gate, pass, details, ...meta, signalBasis, signalQuality: signalQualityFromBasis(signalBasis) };
133
+ }
134
+ // T3 (Sol #2 / Fable F2): one canonical engagement identity, shared by status AND resume. The run-start
135
+ // event records graphDefinitionHash (over compiled task definitions only — see graph.graphDefinitionHash);
136
+ // this is the single field both consumers read, and the single comparator below is the single place the
137
+ // journal↔graph join is decided. unbound (no recorded definition hash, e.g. a pre-v1.44 journal) and
138
+ // mismatch are both not-comparable — status renders the notice either way; resume refuses either way and
139
+ // distinguishes the reason only for its message and the --graph-changed release event.
140
+ export function recordedGraphDefinitionHash(events) {
77
141
  for (const e of events) {
78
- if (e.event === "run-start" && typeof e.data.graphHash === "string")
79
- return e.data.graphHash;
142
+ if (e.event === "run-start" && typeof e.data.graphDefinitionHash === "string")
143
+ return e.data.graphDefinitionHash;
80
144
  }
81
145
  return undefined;
82
146
  }
83
- export function journalComparable(events, loadedHash) {
84
- const recorded = recordedGraphHash(events);
85
- return recorded !== undefined && recorded === loadedHash;
147
+ // THE shared comparator (criterion: status and resume decide through one comparator). status reads
148
+ // .comparable; resume reads .comparable plus .reason/.recorded for its refusal message and the release.
149
+ export function engagementComparable(events, loadedHash) {
150
+ const recorded = recordedGraphDefinitionHash(events);
151
+ if (recorded === undefined)
152
+ return { comparable: false, reason: "unbound" };
153
+ return recorded === loadedHash ? { comparable: true, recorded } : { comparable: false, reason: "mismatch", recorded };
86
154
  }
87
155
  export function newRunId(now = new Date()) {
88
156
  const p = (n, w = 2) => String(n).padStart(w, "0");
89
157
  return `run-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
90
158
  }
159
+ // Sol #4: one strict parser for every journal open/create path — generated run-… ids plus test
160
+ // suffix chars only; forbid path separators, dot-segments, and empty ids.
161
+ export function parseRunId(runId) {
162
+ const id = runId.trim();
163
+ if (!id)
164
+ throw new Error("invalid run id: empty");
165
+ if (id.includes("/") || id.includes("\\"))
166
+ throw new Error(`invalid run id: ${runId}`);
167
+ for (const seg of id.split(/[/\\]/)) {
168
+ if (seg === "." || seg === "..")
169
+ throw new Error(`invalid run id: ${runId}`);
170
+ }
171
+ if (!/^run-[A-Za-z0-9][A-Za-z0-9_-]*$/.test(id))
172
+ throw new Error(`invalid run id: ${runId}`);
173
+ return id;
174
+ }
91
175
  const runsDir = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "runs");
92
176
  // One JSONL reader for every append-only log: skip blanks, drop any line that
93
177
  // won't parse, keeping everything before it intact.
@@ -143,6 +227,38 @@ export function readProfileCursor(repoRoot) {
143
227
  return undefined;
144
228
  return readFileSync(path, "utf8").trim() || undefined;
145
229
  }
230
+ // v1.46 T5 evidence hygiene state — one line per mark: `<runId> [<taskId>] <weight> # <reason>`.
231
+ // Follows the profile-since precedent: state file in .tickmarkr/, never config, never git.
232
+ const PROFILE_DISCOUNTS_RE = /^(run-[A-Za-z0-9][A-Za-z0-9_-]*)(?:\s+(\S+))?\s+(0|0\.5)\s+#\s+(.+)$/;
233
+ export function profileDiscountsPath(repoRoot) {
234
+ return join(repoRoot, stateDirName(repoRoot), "profile-discounts");
235
+ }
236
+ export function readProfileDiscounts(repoRoot) {
237
+ const path = profileDiscountsPath(repoRoot);
238
+ if (!existsSync(path))
239
+ return [];
240
+ const out = [];
241
+ for (const line of readFileSync(path, "utf8").split("\n")) {
242
+ const trimmed = line.trim();
243
+ if (!trimmed || trimmed.startsWith("#"))
244
+ continue;
245
+ const m = PROFILE_DISCOUNTS_RE.exec(trimmed);
246
+ if (!m)
247
+ continue;
248
+ out.push({
249
+ runId: m[1],
250
+ ...(m[2] ? { taskId: m[2] } : {}),
251
+ weight: m[3] === "0" ? 0 : 0.5,
252
+ reason: m[4].trim(),
253
+ });
254
+ }
255
+ return out;
256
+ }
257
+ export function appendProfileDiscount(repoRoot, discount) {
258
+ tickmarkrDir(repoRoot);
259
+ const line = `${discount.runId}${discount.taskId ? ` ${discount.taskId}` : ""} ${discount.weight} # ${discount.reason}\n`;
260
+ appendFileSync(profileDiscountsPath(repoRoot), line);
261
+ }
146
262
  // The one shared profile builder (criterion 4: plan and daemon share ONE code path).
147
263
  // preview:true bypasses the routing.learned:off short-circuit so `tickmarkr plan` can render the
148
264
  // trust-ramp preview while the daemon (no preview) stays inert (VALIDATION 13-01-11).
@@ -151,7 +267,8 @@ export function loadRoutingProfile(repoRoot, cfg, opts = {}) {
151
267
  return undefined; // never built, never passed
152
268
  const rows = readAllTelemetry(repoRoot, RUNS_WINDOW, { after: readProfileCursor(repoRoot) });
153
269
  // ROUTE-15: halfLifeRuns threads from config as a pure param; undefined ⇒ module default (byte-identical).
154
- return rows.length ? buildProfile(rows, { halfLifeRuns: cfg.routing.learnedTuning?.halfLifeRuns }) : undefined; // cold ⇒ undefined ⇒ v1.5 dead-code path
270
+ const discounts = readProfileDiscounts(repoRoot);
271
+ return rows.length ? buildProfile(rows, { halfLifeRuns: cfg.routing.learnedTuning?.halfLifeRuns, discounts }) : undefined; // cold ⇒ undefined ⇒ v1.5 dead-code path
155
272
  }
156
273
  export class Journal {
157
274
  dir;
@@ -163,15 +280,19 @@ export class Journal {
163
280
  this.narrate = narrate;
164
281
  }
165
282
  static create(repoRoot, runId, narrate) {
166
- const dir = join(runsDir(repoRoot), runId);
283
+ const id = parseRunId(runId);
284
+ const dir = join(runsDir(repoRoot), id);
285
+ if (existsSync(join(dir, "journal.jsonl")))
286
+ throw new Error(`journal already exists for ${id}`);
167
287
  mkdirSync(dir, { recursive: true });
168
- return new Journal(dir, runId, narrate);
288
+ return new Journal(dir, id, narrate);
169
289
  }
170
290
  static open(repoRoot, runId, narrate) {
171
- const dir = join(runsDir(repoRoot), runId);
291
+ const id = parseRunId(runId);
292
+ const dir = join(runsDir(repoRoot), id);
172
293
  if (!existsSync(join(dir, "journal.jsonl")))
173
- throw new Error(`no journal for ${runId} at ${dir}`);
174
- return new Journal(dir, runId, narrate);
294
+ throw new Error(`no journal for ${id} at ${dir}`);
295
+ return new Journal(dir, id, narrate);
175
296
  }
176
297
  // withJournal: journal.jsonl appears at first append, after Journal.create mkdirs — a caller that
177
298
  // will Journal.open the result (status, report) must fall back to the newest run that is actually
package/dist/run/lock.js CHANGED
@@ -141,9 +141,9 @@ export function releaseRunLock(repoRoot) {
141
141
  unlinkIfOurs(lockPath(repoRoot)); // never delete a reclaiming successor's lock — only ours
142
142
  heldPath = undefined;
143
143
  }
144
- // Read-only check for compile: true iff a lock exists that the decision table would REFUSE on
145
- // (alive, EPERM, or ANY garbage). A provably-dead holder (ESRCH) reads not-live compile proceeds
146
- // in step with resume (LOCK-02/OBS-05). Never mutates the lock.
144
+ // Read-only predicate: true iff a lock exists that the decision table would REFUSE on (alive,
145
+ // EPERM, or ANY garbage). A provably-dead holder (ESRCH) reads not-live (LOCK-02/OBS-05). Never
146
+ // mutates the lock. compile now acquires via acquireRunLock; this remains for drift oracles/tests.
147
147
  export function isRunLockLive(repoRoot) {
148
148
  try {
149
149
  return shouldRefuse(inspect(lockPath(repoRoot)));
package/dist/run/merge.js CHANGED
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { shq } from "../adapters/types.js";
4
4
  import { fingerprint } from "../gates/baseline.js";
5
5
  import { tickmarkrDir } from "../graph/graph.js";
6
- import { gitHead, linkNodeModules, resolveIntegrationBranch, sh, shOk, WORKTREES_DIR } from "./git.js";
6
+ import { gitHead, linkNodeModules, resolveIntegrationBranch, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
7
7
  export function integrationBranch(cfg, runId) {
8
8
  return `${cfg.integrationBranchPrefix}${runId}`;
9
9
  }
@@ -12,12 +12,12 @@ export async function ensureIntegration(repo, branch, baseRef) {
12
12
  branch = await resolveIntegrationBranch(repo, branch);
13
13
  const dir = join(tickmarkrDir(repo), WORKTREES_DIR, sanitize(branch));
14
14
  if (!existsSync(join(dir, ".git"))) {
15
- const exists = (await sh(`git rev-parse --verify refs/heads/${shq(branch)}`, repo)).code === 0;
15
+ const exists = (await shGit(`git rev-parse --verify refs/heads/${shq(branch)}`, repo)).code === 0;
16
16
  if (exists) {
17
- await shOk(`git worktree add ${shq(dir)} ${shq(branch)}`, repo);
17
+ await shGitOk(`git worktree add ${shq(dir)} ${shq(branch)}`, repo);
18
18
  }
19
19
  else {
20
- await shOk(`git worktree add -b ${shq(branch)} ${shq(dir)} ${shq(baseRef)}`, repo);
20
+ await shGitOk(`git worktree add -b ${shq(branch)} ${shq(dir)} ${shq(baseRef)}`, repo);
21
21
  }
22
22
  }
23
23
  linkNodeModules(repo, dir);
@@ -27,21 +27,21 @@ export function integrationHead(intWt) {
27
27
  return gitHead(intWt);
28
28
  }
29
29
  export async function mergeTask(intWt, taskBranch, message, gatedCommit) {
30
- const tip = await sh(`git rev-parse --verify ${shq(`refs/heads/${taskBranch}`)}`, intWt);
30
+ const tip = await shGit(`git rev-parse --verify ${shq(`refs/heads/${taskBranch}`)}`, intWt);
31
31
  if (tip.code !== 0)
32
32
  return { ok: false, conflict: tip.stderr || tip.stdout };
33
33
  const branchTip = tip.stdout.trim();
34
34
  if (branchTip !== gatedCommit)
35
35
  return { ok: false, tipMoved: { gatedCommit, branchTip } };
36
36
  // Merge the verified hash, not the mutable branch name: a move after the comparison cannot land ungated content.
37
- const r = await sh(`git merge --no-ff ${shq(gatedCommit)} -m ${shq(message)}`, intWt);
37
+ const r = await shGit(`git merge --no-ff ${shq(gatedCommit)} -m ${shq(message)}`, intWt);
38
38
  if (r.code === 0)
39
39
  return { ok: true };
40
- const conflict = (await sh("git status --porcelain", intWt)).stdout
40
+ const conflict = (await shGit("git status --porcelain", intWt)).stdout
41
41
  .split("\n")
42
42
  .filter((l) => l.startsWith("UU") || l.startsWith("AA"))
43
43
  .join("\n") || r.stderr || r.stdout;
44
- await sh("git merge --abort", intWt);
44
+ await shGit("git merge --abort", intWt);
45
45
  return { ok: false, conflict };
46
46
  }
47
47
  // OBS-34: strict exit-code verify on the integration tip — no baseline forgiveness.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.44.0",
3
+ "version": "1.46.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",