tickmarkr 1.43.0 → 1.44.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.
@@ -1,9 +1,10 @@
1
1
  import { BANNER } from "../../brand.js";
2
2
  import { blockedTasks, loadGraph, pendingTasks } from "../../graph/graph.js";
3
3
  import { GATE_NAMES } from "../../graph/schema.js";
4
- import { Journal } from "../../run/journal.js";
4
+ import { Journal, journalComparable } from "../../run/journal.js";
5
5
  // ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
6
6
  const REFRESH_MS = 2000;
7
+ const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; run `tickmarkr run` to execute";
7
8
  // The timer must keep the process ALIVE: an unref'd timer here let the event loop drain after the
8
9
  // first frame, so a live `--watch` printed once and exited 0 (OBS-11). Never unref this.
9
10
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -40,6 +41,7 @@ const gateBox = (state, unicode) => {
40
41
  return state === "pass" ? "✓" : state === "fail" ? "✗" : state === "skip" ? "·" : "☐";
41
42
  return state === "pass" ? "[x]" : state === "fail" ? "[!]" : state === "skip" ? "." : "[ ]";
42
43
  };
44
+ const defaultGateStates = (task) => GATE_NAMES.map((gate) => task.gates.includes(gate) ? "open" : "skip");
43
45
  const gateStates = (task, events) => {
44
46
  const outcomes = new Map();
45
47
  const start = attemptStartIdx(events, task.id);
@@ -121,18 +123,23 @@ const renderFrame = (cwd) => {
121
123
  let replayed = null;
122
124
  let events = [];
123
125
  const contexts = new Map();
126
+ let comparable = false;
124
127
  if (runId) {
125
128
  const j = Journal.open(cwd, runId);
126
129
  events = j.read();
127
- replayed = j.replayStatuses();
128
- for (const e of events) {
129
- if (e.event === "task-dispatch" && e.taskId) {
130
- const a = e.data.assignment;
131
- if (typeof a.adapter === "string" && typeof a.model === "string")
132
- assignments.set(e.taskId, `${a.adapter}:${a.model}`);
133
- }
134
- if (e.event === "context-sample" && e.taskId && typeof e.data.tokens === "number" && Number.isFinite(e.data.tokens)) {
135
- contexts.set(e.taskId, e.data.tokens); // last write wins
130
+ // OBS-52: refuse journal join when the run's recorded graphHash ≠ loaded graph — bare task-id replay lied after recompile.
131
+ comparable = journalComparable(events, g.spec.hash);
132
+ if (comparable) {
133
+ replayed = j.replayStatuses();
134
+ for (const e of events) {
135
+ if (e.event === "task-dispatch" && e.taskId) {
136
+ const a = e.data.assignment;
137
+ if (typeof a.adapter === "string" && typeof a.model === "string")
138
+ assignments.set(e.taskId, `${a.adapter}:${a.model}`);
139
+ }
140
+ if (e.event === "context-sample" && e.taskId && typeof e.data.tokens === "number" && Number.isFinite(e.data.tokens)) {
141
+ contexts.set(e.taskId, e.data.tokens); // last write wins
142
+ }
136
143
  }
137
144
  }
138
145
  }
@@ -148,7 +155,7 @@ const renderFrame = (cwd) => {
148
155
  const label = starved.has(t.id) ? " starved" : waiting.has(t.id) ? " dep-waiting" : "";
149
156
  const channel = assignments.get(t.id) ?? "-";
150
157
  const assignCol = contexts.has(t.id) ? `${channel}${divider}ctx ${contexts.get(t.id)}` : channel;
151
- return { t, st, label, assignCol, states: gateStates(t, events) };
158
+ return { t, st, label, assignCol, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
152
159
  });
153
160
  const statusColor = (st) => st === "done" ? 32 : st === "failed" ? 31 : st === "human" ? 33 : 36;
154
161
  if (!unicode) {
@@ -160,7 +167,7 @@ const renderFrame = (cwd) => {
160
167
  return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
161
168
  });
162
169
  const header = runId
163
- ? `tickmarkr status${divider}run ${runId}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
170
+ ? `tickmarkr status${divider}run ${runId}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
164
171
  : `tickmarkr status${divider}no runs yet${divider}${done}/${g.tasks.length} done`;
165
172
  const legend = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
166
173
  return [header, legend, ...rows].join("\n");
@@ -181,7 +188,9 @@ const renderFrame = (cwd) => {
181
188
  .replaceAll(" · ", dot);
182
189
  const tally = `${done}/${g.tasks.length} done`;
183
190
  const header = ` ${color("✓", 32, true)} ${color("tickmarkr", 1, true)}${dot}` +
184
- (runId ? `run ${runId}${dot}${live}${dot}` : `no runs yet${dot}`) +
191
+ (runId
192
+ ? `run ${runId}${dot}${!comparable ? `${NOT_COMPARABLE_NOTICE}${dot}` : ""}${live}${dot}`
193
+ : `no runs yet${dot}`) +
185
194
  `${gauge} ${done === g.tasks.length && g.tasks.length > 0 ? color(tally, 32, true) : tally}`;
186
195
  const rule = dim("─".repeat(Math.min(width, 100)));
187
196
  const legend = dim(` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(" · ")}`);
@@ -144,6 +144,15 @@ export function compileNative(file) {
144
144
  if (plainCount > 0) {
145
145
  console.warn(`tickmarkr: ${plainCount} acceptance item${plainCount === 1 ? "" : "s"} in ${file} ${plainCount === 1 ? "is a plain string" : "are plain strings"} — compiled as judge oracle${plainCount === 1 ? "" : "s"}. Prefix with command:/test:/judge: to make the oracle explicit.`);
146
146
  }
147
+ // OBS-51: semicolon-joined judge criteria invite intermittent clause-split verdicts — warn per item.
148
+ for (const draft of drafts) {
149
+ for (const item of draft.acceptance) {
150
+ const text = typeof item === "string" ? item : item.oracle === "judge" ? item.text : null;
151
+ if (text?.includes(";")) {
152
+ console.warn(`tickmarkr: OBS-51: task ${draft.id} judge criterion contains semicolon-joined clauses — split into separate acceptance items: ${JSON.stringify(text)}`);
153
+ }
154
+ }
155
+ }
147
156
  return result;
148
157
  }
149
158
  // Commented native spec written to tickmarkr.spec.md by `tickmarkr init`. Documented via HTML comments (which the
@@ -93,12 +93,18 @@ Respond with ONLY this JSON (no prose before or after):
93
93
  meta: { unparseable: true, judge: channelKey({ adapter: judge.adapter.id, model: judge.model }) } };
94
94
  }
95
95
  const inconsistencies = [];
96
- if (v.criteria.length !== judgeItems.length) {
97
- inconsistencies.push(`judge verdict inconsistent: criteria count mismatch — expected ${judgeItems.length}, received ${v.criteria.length}`);
96
+ const expected = judgeItems.length;
97
+ const received = v.criteria.length;
98
+ // OBS-51: judges sometimes split one semicolon-joined criterion into multiple verdict items.
99
+ const clauseSplitAllPass = received > expected
100
+ && v.criteria.every((c) => c && typeof c === "object" && c.met === true);
101
+ if (received !== expected && !clauseSplitAllPass) {
102
+ inconsistencies.push(`judge verdict inconsistent: criteria count mismatch — expected ${expected}, received ${received}`);
98
103
  }
99
104
  v.criteria.forEach((c, i) => {
100
105
  if (!c || typeof c !== "object" || c.met !== true) {
101
- inconsistencies.push(`judge verdict inconsistent: criteria[${i}].met must be true`);
106
+ const unmet = c && typeof c === "object" && c.criterion ? ` unmet clause: ${c.criterion}` : "";
107
+ inconsistencies.push(`judge verdict inconsistent: criteria[${i}].met must be true${unmet}`);
102
108
  }
103
109
  });
104
110
  const pass = v.pass === true && inconsistencies.length === 0;
@@ -87,7 +87,7 @@ export async function runDaemon(repoRoot, opts = {}) {
87
87
  baseRef = await gitHead(repoRoot);
88
88
  baseline = await captureBaseline(repoRoot, commands);
89
89
  writeFileSync(join(journal.dir, "baseline.json"), JSON.stringify(baseline, null, 2));
90
- journal.append("run-start", undefined, { pid: process.pid, baseRef, commands, channels: channels.map(channelKey), branch }); // pid: v1.13 (VIS-11) liveness
90
+ journal.append("run-start", undefined, { pid: process.pid, baseRef, commands, channels: channels.map(channelKey), branch, graphHash: graph.spec.hash }); // graphHash: OBS-52 status join guard; pid: v1.13 (VIS-11) liveness
91
91
  }
92
92
  // T6: open the narrator AFTER run-start/run-resume is journaled so the watch surface has a run to
93
93
  // show. driver.narrator is undefined on subprocess → no-op (subprocess spawns nothing). Swallowed:
@@ -62,6 +62,8 @@ export declare const TelemetryRowSchema: z.ZodObject<{
62
62
  }>>;
63
63
  }, z.core.$strip>;
64
64
  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;
65
67
  export declare function newRunId(now?: Date): string;
66
68
  export declare function readAllTelemetry(repoRoot: string, lastK: number, opts?: {
67
69
  after?: string;
@@ -72,6 +72,18 @@ export const TelemetryRowSchema = z.object({
72
72
  // v1.29 additive: mode of the attempt represented by this row. Absent on old telemetry.
73
73
  retryMode: z.enum(RETRY_MODES).optional(),
74
74
  });
75
+ // OBS-52: graph hash on run-start binds status replay to the compiled graph that started the run.
76
+ export function recordedGraphHash(events) {
77
+ for (const e of events) {
78
+ if (e.event === "run-start" && typeof e.data.graphHash === "string")
79
+ return e.data.graphHash;
80
+ }
81
+ return undefined;
82
+ }
83
+ export function journalComparable(events, loadedHash) {
84
+ const recorded = recordedGraphHash(events);
85
+ return recorded !== undefined && recorded === loadedHash;
86
+ }
75
87
  export function newRunId(now = new Date()) {
76
88
  const p = (n, w = 2) => String(n).padStart(w, "0");
77
89
  return `run-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.43.0",
3
+ "version": "1.44.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",