tickmarkr 1.45.0 → 1.47.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,8 +73,29 @@ 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>;
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>;
65
99
  export declare function recordedGraphDefinitionHash(events: JournalEvent[]): string | undefined;
66
100
  export type EngagementCompare = {
67
101
  comparable: true;
@@ -84,6 +118,9 @@ export declare function readAllTelemetry(repoRoot: string, lastK: number, opts?:
84
118
  })[];
85
119
  export declare const RUNS_WINDOW = 50;
86
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;
87
124
  export declare function loadRoutingProfile(repoRoot: string, cfg: TickmarkrConfig, opts?: {
88
125
  preview?: boolean;
89
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,7 +90,47 @@ 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
  });
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
+ }
75
134
  // T3 (Sol #2 / Fable F2): one canonical engagement identity, shared by status AND resume. The run-start
76
135
  // event records graphDefinitionHash (over compiled task definitions only — see graph.graphDefinitionHash);
77
136
  // this is the single field both consumers read, and the single comparator below is the single place the
@@ -168,6 +227,38 @@ export function readProfileCursor(repoRoot) {
168
227
  return undefined;
169
228
  return readFileSync(path, "utf8").trim() || undefined;
170
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
+ }
171
262
  // The one shared profile builder (criterion 4: plan and daemon share ONE code path).
172
263
  // preview:true bypasses the routing.learned:off short-circuit so `tickmarkr plan` can render the
173
264
  // trust-ramp preview while the daemon (no preview) stays inert (VALIDATION 13-01-11).
@@ -176,7 +267,8 @@ export function loadRoutingProfile(repoRoot, cfg, opts = {}) {
176
267
  return undefined; // never built, never passed
177
268
  const rows = readAllTelemetry(repoRoot, RUNS_WINDOW, { after: readProfileCursor(repoRoot) });
178
269
  // ROUTE-15: halfLifeRuns threads from config as a pure param; undefined ⇒ module default (byte-identical).
179
- 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
180
272
  }
181
273
  export class Journal {
182
274
  dir;
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.45.0",
3
+ "version": "1.47.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",