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.
@@ -37,7 +37,7 @@ export class FakeAdapter {
37
37
  let isJudge = false;
38
38
  try {
39
39
  prompt = readFileSync(promptFile, "utf8");
40
- isJudge = /TICKMARKR-JUDGE/.test(prompt);
40
+ isJudge = /TICKMARKR-(?:JUDGE|SCOPE)/.test(prompt);
41
41
  }
42
42
  catch {
43
43
  // unreadable promptFile: can't detect role; serve static values (legacy headless-call behavior)
@@ -61,7 +61,7 @@ export class FakeAdapter {
61
61
  writeFileSync(p, JSON.stringify(val ?? {}, null, 1));
62
62
  return p;
63
63
  };
64
- return `bash -c 'grep -q TICKMARKR-JUDGE ${shq(promptFile)} && cat ${shq(serve("judge"))}; grep -q TICKMARKR-REVIEW ${shq(promptFile)} && cat ${shq(serve("review"))}; grep -q TICKMARKR-CONSULT ${shq(promptFile)} && cat ${shq(serve("consult"))}; true'`;
64
+ return `bash -c 'grep -Eq "TICKMARKR-(JUDGE|SCOPE)" ${shq(promptFile)} && cat ${shq(serve("judge"))}; grep -q TICKMARKR-REVIEW ${shq(promptFile)} && cat ${shq(serve("review"))}; grep -q TICKMARKR-CONSULT ${shq(promptFile)} && cat ${shq(serve("consult"))}; true'`;
65
65
  }
66
66
  resumeCommand(_sessionId, promptFile, model) {
67
67
  return this.interactiveCommand(promptFile, model) ?? this.headlessCommand(promptFile, model);
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdtempSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { overlayPreferShapes, TIER_RANK } from "../config/config.js";
@@ -178,7 +178,13 @@ async function mapLimit(items, limit, fn) {
178
178
  }
179
179
  const doctorPath = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "doctor.json");
180
180
  function readDoctorFile(repoRoot) {
181
- return existsSync(doctorPath(repoRoot)) ? JSON.parse(readFileSync(doctorPath(repoRoot), "utf8")) : null;
181
+ try {
182
+ return existsSync(doctorPath(repoRoot)) ? JSON.parse(readFileSync(doctorPath(repoRoot), "utf8")) : null;
183
+ }
184
+ catch {
185
+ // A torn cache is disposable: callers already re-probe when this returns null.
186
+ return null;
187
+ }
182
188
  }
183
189
  export function writeDoctor(repoRoot, health) {
184
190
  tickmarkrDir(repoRoot);
@@ -187,7 +193,10 @@ export function writeDoctor(repoRoot, health) {
187
193
  delete payload[pendingAutoPreferKey];
188
194
  if (pending)
189
195
  payload.autoPrefer = pending;
190
- writeFileSync(doctorPath(repoRoot), JSON.stringify(payload, null, 2) + "\n");
196
+ const path = doctorPath(repoRoot);
197
+ const tmp = `${path}.tmp`;
198
+ writeFileSync(tmp, JSON.stringify(payload, null, 2) + "\n");
199
+ renameSync(tmp, path);
191
200
  }
192
201
  export function readDoctor(repoRoot) {
193
202
  const raw = readDoctorFile(repoRoot);
package/dist/brand.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { shq } from "./adapters/types.js";
1
2
  // TTY-only pixel-tick logo (assets/mark.svg is the image twin — same block geometry).
2
3
  // Never printed to pipes — the non-TTY stdout surface is byte-pinned by tests and consumed by machines.
3
4
  const B = "\x1b[1m", R = "\x1b[0m";
@@ -26,5 +27,5 @@ export function paneDispatchScript(body) {
26
27
  }
27
28
  /** OBS-50: one short herdr pane-run line; bootstrap lives in the script file beside the prompt. */
28
29
  export function paneDispatchCommand(scriptPath) {
29
- return `bash ${JSON.stringify(scriptPath)}`;
30
+ return `bash ${shq(scriptPath)}`;
30
31
  }
@@ -6,10 +6,10 @@ import { ATTEMPT_CAP_RELEASE, Journal } from "../../run/journal.js";
6
6
  // append-only journal. Writing it into tickmarkr's compiled graph artifact would be silently erased by
7
7
  // the next recompile (which re-emits humanGate:true from the plan frontmatter) — Phase 42 D-02.
8
8
  //
9
- // v1.24 OBS-18: when the park was attempt-cap (not a humanGate pre-dispatch park), the event also
10
- // carries `release: "attempt-cap"`. replayResumeState zeros the attempt budget on that marker so
11
- // resume dispatches instead of re-parking in the same tick; tried-list is preserved. humanGate
12
- // parks (attempts 0, reason starts with "humanGate:") stamp no release byte-identical to GATE-08.
9
+ // v1.24 OBS-18: when the park kind is attempt-cap (not a humanGate pre-dispatch park), the event
10
+ // also carries `release: "attempt-cap"`. replayResumeState zeros the attempt budget on that marker so
11
+ // resume dispatches instead of re-parking in the same tick; tried-list is preserved. Unknown kinds
12
+ // receive no release and remain fail-closed to a human rather than being inferred from prose.
13
13
  //
14
14
  // Fail-closed (D-05): unknown runId, unknown taskId, a not-parked task, and a double-approve are all
15
15
  // LOUD refusals that name the reason and append NO event — never a silent no-op. A handler throw
@@ -30,10 +30,9 @@ export async function approve(argv, cwd = process.cwd()) {
30
30
  throw new Error(`task ${taskId} is ${status}, not a parked human gate — refusing (a silent no-op would be worse)`);
31
31
  }
32
32
  // OBS-18: only the most recent task-human for this task decides whether this approval grants a
33
- // fresh attempt budget. Match the daemon's park reason literally (`attempt cap (N) reached`).
33
+ // fresh attempt budget. The closed daemon-issued kind, never a human prose string, controls release.
34
34
  const lastHuman = journal.read().filter((e) => e.event === "task-human" && e.taskId === taskId).at(-1);
35
- const capPark = typeof lastHuman?.data.reason === "string"
36
- && /attempt cap \(\d+\) reached/.test(lastHuman.data.reason);
35
+ const capPark = lastHuman?.data.kind === ATTEMPT_CAP_RELEASE;
37
36
  journal.append("task-approved", taskId, {
38
37
  by,
39
38
  ...(reason ? { reason } : {}),
@@ -2,7 +2,7 @@ import { isAbsolute, join } from "node:path";
2
2
  import { parseArgs } from "node:util";
3
3
  import { compileSource } from "../../compile/index.js";
4
4
  import { saveGraph, stateDirName } from "../../graph/graph.js";
5
- import { isRunLockLive } from "../../run/lock.js";
5
+ import { acquireRunLock, releaseRunLock } from "../../run/lock.js";
6
6
  export async function compile(argv, cwd = process.cwd()) {
7
7
  const { values, positionals } = parseArgs({
8
8
  args: argv,
@@ -14,11 +14,15 @@ export async function compile(argv, cwd = process.cwd()) {
14
14
  throw new Error("usage: tickmarkr compile <spec-dir-or-md> [--type speckit|prd|gsd|native]");
15
15
  // resolve against the target repo, not the process cwd (the CLI test passes a tmp repo)
16
16
  const g = compileSource(isAbsolute(src) ? src : join(cwd, src), values.type, cwd);
17
- // HARD-01: a compile clobbering a running daemon's graph.json is the same last-write-wins race
18
- // as a second daemon. Read-only check compile never acquires/holds (it's instantaneous).
17
+ // HARD-01 / Sol #3: hold the same link(2) run lock as the daemon around saveGraph so compile
18
+ // cannot swap graph.json under an active run between the daemon's read and act.
19
19
  const stateDir = stateDirName(cwd);
20
- if (isRunLockLive(cwd))
21
- throw new Error(`${stateDir}/graph.lock is held by another tickmarkr run, or is a stale/garbage lock — refusing to overwrite graph.json. If no run is active, run \`tickmarkr unlock\`.`);
22
- saveGraph(cwd, g);
20
+ acquireRunLock(cwd, "compile");
21
+ try {
22
+ saveGraph(cwd, g);
23
+ }
24
+ finally {
25
+ releaseRunLock(cwd);
26
+ }
23
27
  return `compiled ${src} → ${stateDir}/graph.json (${g.tasks.length} tasks, source ${g.spec.source}, hash ${g.spec.hash.slice(0, 12)})`;
24
28
  }
@@ -1,4 +1,4 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { parseArgs } from "node:util";
@@ -7,7 +7,59 @@ import { configTemplate, DEFAULT_CONFIG, globalConfigDir, loadConfig } from "../
7
7
  import { LEGACY_PREFIX, specTemplate } from "../../compile/native.js";
8
8
  import { BANNER } from "../../brand.js";
9
9
  import { tickmarkrDir } from "../../graph/graph.js";
10
+ import { Journal } from "../../run/journal.js";
10
11
  import { doctor } from "./doctor.js";
12
+ const SCAFFOLD_SPEC = "tickmarkr.spec.md";
13
+ // Operator-approved (2026-07-17) environments footer — three rows; no npm install for herdr
14
+ // (npm package "herdr" is a reserved 0.0.0 placeholder as of that date).
15
+ const ENVIRONMENTS_FOOTER = [
16
+ "environments:",
17
+ " herdr — the full cockpit — every worker, judge, and consult is a visible pane you can watch and unblock · https://herdr.dev",
18
+ " claude code — tickmarkr init --agent installs the /tkr skills + AGENTS.md so Claude Code (or any agent CLI) drives the loop natively",
19
+ " anywhere — no herdr? same fail-closed gates, headless subprocess driver",
20
+ ].join("\n");
21
+ /** Latest journal without a run-end event, if any. */
22
+ function activeRunId(cwd) {
23
+ const runId = Journal.latestRunId(cwd, { withJournal: true });
24
+ if (!runId)
25
+ return null;
26
+ try {
27
+ const events = Journal.open(cwd, runId).read();
28
+ if (events.some((e) => e.event === "run-end"))
29
+ return null;
30
+ return runId;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ /** Relative paths of specs/*.spec.md already in the repo. */
37
+ function existingSpecs(cwd) {
38
+ const dir = join(cwd, "specs");
39
+ if (!existsSync(dir))
40
+ return [];
41
+ try {
42
+ return readdirSync(dir)
43
+ .filter((f) => f.endsWith(".spec.md"))
44
+ .sort()
45
+ .map((f) => `specs/${f}`);
46
+ }
47
+ catch {
48
+ return [];
49
+ }
50
+ }
51
+ /** Context-aware next-steps line (operator-approved 2026-07-17). */
52
+ function nextSteps(cwd, scaffoldedSpec) {
53
+ const runId = activeRunId(cwd);
54
+ if (runId)
55
+ return `run ${runId} active — tickmarkr status`;
56
+ const specs = existingSpecs(cwd);
57
+ if (specs.length > 0) {
58
+ const listed = specs.length <= 3 ? specs.join(", ") : `${specs.slice(0, 3).join(", ")}, …`;
59
+ return `next: existing specs under specs/ (${listed}) — tickmarkr compile <spec> && tickmarkr plan && tickmarkr run`;
60
+ }
61
+ return `next: edit ${scaffoldedSpec}, then tickmarkr compile ${scaffoldedSpec} && tickmarkr plan && tickmarkr run`;
62
+ }
11
63
  const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
12
64
  const AGENT_SKILLS = ["tickmarkr-loop", "tickmarkr-auto"];
13
65
  const DOCS_BEGIN = "<!-- tickmarkr:agent-docs begin -->";
@@ -173,7 +225,7 @@ export async function init(argv, cwd = process.cwd()) {
173
225
  const repoConfigExists = existsSync(repoConfigPath);
174
226
  if (repoConfigExists)
175
227
  notes.push(`kept existing ${repoConfigPath}`);
176
- const specPath = join(cwd, "tickmarkr.spec.md");
228
+ const specPath = join(cwd, SCAFFOLD_SPEC);
177
229
  if (existsSync(specPath)) {
178
230
  notes.push(`kept existing ${specPath}`);
179
231
  }
@@ -210,7 +262,7 @@ export async function init(argv, cwd = process.cwd()) {
210
262
  }
211
263
  if (values.agent)
212
264
  await installAgentFiles(cwd, values.force ?? false, values.docs ?? false, notes);
213
- const body = `${notes.join("\n")}\n${doc}\nnext: edit tickmarkr.spec.md, then tickmarkr compile tickmarkr.spec.md && tickmarkr plan && tickmarkr run`;
265
+ const body = `${notes.join("\n")}\n${doc}\n${nextSteps(cwd, SCAFFOLD_SPEC)}\n${ENVIRONMENTS_FOOTER}`;
214
266
  if (visual() && !bannerEmitted)
215
267
  return BANNER + body;
216
268
  return body;
@@ -1,13 +1,21 @@
1
- import { writeFileSync } from "node:fs";
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { loadConfig } from "../../config/config.js";
4
4
  import { tickmarkrDir, stateDirName } from "../../graph/graph.js";
5
- import { learnedScore, MIN_SAMPLES, cellsOf } from "../../route/profile.js";
6
- import { Journal, loadRoutingProfile, readProfileCursor, RUNS_WINDOW } from "../../run/journal.js";
7
- // tickmarkr profile — inspect (show) and forget (reset) the derived learned-routing profile (VIS-03).
8
- // Read-only class like plan/status/report: no run lock. `reset` writes ONE cursor scalar, nothing else.
5
+ import { cellOf, cellSummary, explorationBonus, EXPLORE_CAP, learnedScore, learnedScoreTerms, MIN_SAMPLES, PRIOR_K, REF_MS, cellsOf, } from "../../route/profile.js";
6
+ import { Journal, loadRoutingProfile, parseRunId, readProfileCursor, readProfileDiscounts, appendProfileDiscount, profileDiscountsPath, RUNS_WINDOW, } from "../../run/journal.js";
7
+ // tickmarkr profile — inspect (show), forget (reset), and discount poisoned evidence (v1.46 T5).
8
+ // Read-only class like plan/status/report except discount append. `reset` writes ONE cursor scalar.
9
9
  export async function profile(argv, cwd = process.cwd()) {
10
- return argv[0] === "reset" ? reset(cwd) : show(cwd);
10
+ if (argv[0] === "reset")
11
+ return reset(cwd);
12
+ if (argv[0] === "discount")
13
+ return discount(argv.slice(1), cwd);
14
+ if (argv[0] === "discounts")
15
+ return listDiscounts(cwd);
16
+ if (argv[0] === "--explain" || argv.includes("--explain"))
17
+ return explain(argv, cwd);
18
+ return show(cwd);
11
19
  }
12
20
  // Non-destructive reset (T-13-06): a one-line runId cutoff at .tickmarkr/profile-since. NEVER deletes,
13
21
  // truncates, or rotates any telemetry — the profile is derived (PROF-01), so there is nothing to delete;
@@ -26,6 +34,119 @@ function reset(cwd) {
26
34
  ` to un-reset: delete ${stateDir}/profile-since.`,
27
35
  ].join("\n");
28
36
  }
37
+ function parseDiscountArgs(argv) {
38
+ let runId;
39
+ let taskId;
40
+ let weight;
41
+ let reason;
42
+ for (let i = 0; i < argv.length; i++) {
43
+ if (argv[i] === "--weight" && argv[i + 1]) {
44
+ const w = argv[++i];
45
+ if (w !== "0" && w !== "0.5")
46
+ throw new Error(`profile discount --weight must be 0 or 0.5 (got ${w})`);
47
+ weight = w === "0" ? 0 : 0.5;
48
+ }
49
+ else if (argv[i] === "--reason" && argv[i + 1]) {
50
+ reason = argv[++i];
51
+ }
52
+ else if (!argv[i].startsWith("--") && runId === undefined) {
53
+ runId = parseRunId(argv[i]);
54
+ }
55
+ else if (!argv[i].startsWith("--") && taskId === undefined) {
56
+ taskId = argv[i];
57
+ }
58
+ }
59
+ if (!runId)
60
+ throw new Error("profile discount requires <runId>");
61
+ if (weight === undefined)
62
+ throw new Error("profile discount requires --weight 0|0.5");
63
+ if (!reason?.trim())
64
+ throw new Error("profile discount requires --reason (a discount is an evidence claim)");
65
+ return { runId, taskId, weight, reason: reason.trim() };
66
+ }
67
+ function discount(argv, cwd) {
68
+ const mark = parseDiscountArgs(argv);
69
+ appendProfileDiscount(cwd, mark);
70
+ const stateDir = stateDirName(cwd);
71
+ const scope = mark.taskId ? `${mark.runId} task ${mark.taskId}` : mark.runId;
72
+ return [
73
+ `profile discount — marked ${scope} at weight ${mark.weight}.`,
74
+ ` reason: ${mark.reason}`,
75
+ ` wrote ${stateDir}/profile-discounts (append-only).`,
76
+ ` list: tickmarkr profile discounts`,
77
+ ].join("\n");
78
+ }
79
+ function listDiscounts(cwd) {
80
+ const marks = readProfileDiscounts(cwd);
81
+ const path = profileDiscountsPath(cwd);
82
+ if (!marks.length) {
83
+ return existsDiscountsFile(cwd)
84
+ ? `profile discounts — ${path}\n (no valid marks)`
85
+ : `profile discounts — no ${stateDirName(cwd)}/profile-discounts file yet.`;
86
+ }
87
+ const lines = marks.map((m) => {
88
+ const scope = m.taskId ? `${m.runId} ${m.taskId}` : m.runId;
89
+ return ` ${scope.padEnd(28)} weight=${m.weight} # ${m.reason}`;
90
+ });
91
+ return [`profile discounts — ${path}`, ...lines].join("\n");
92
+ }
93
+ function existsDiscountsFile(cwd) {
94
+ try {
95
+ readFileSync(profileDiscountsPath(cwd));
96
+ return true;
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ const fmtSigned = (n) => (n >= 0 ? `+${n.toFixed(3)}` : n.toFixed(3));
103
+ function explain(argv, cwd) {
104
+ const rest = argv[0] === "--explain" ? argv.slice(1) : argv.filter((a) => a !== "--explain");
105
+ const shape = rest[0];
106
+ const chKey = rest[1];
107
+ const channel = rest[2] ?? "sub";
108
+ if (!shape || !chKey)
109
+ throw new Error("profile --explain requires <shape> <channel>");
110
+ const cfg = loadConfig(cwd);
111
+ const p = loadRoutingProfile(cwd, cfg, { preview: true });
112
+ const tuning = { availWeight: cfg.routing.learnedTuning?.availWeight };
113
+ const cell = cellOf(p, shape, chKey, channel);
114
+ const terms = learnedScoreTerms(p, shape, chKey, channel, tuning);
115
+ const score = learnedScore(p, shape, chKey, channel, tuning);
116
+ const header = `${shape} × ${chKey} (${channel})`;
117
+ if (!cell) {
118
+ return [
119
+ header,
120
+ ` quality ${fmtSigned(terms.quality)} (no cell — neutral)`,
121
+ ` perf ${fmtSigned(terms.perf)}`,
122
+ ` avail ${fmtSigned(terms.avail)}`,
123
+ ` overrun ${fmtSigned(terms.overrun)}`,
124
+ ` score ${fmtSigned(score)}`,
125
+ ].join("\n");
126
+ }
127
+ const s = cellSummary(cell);
128
+ const disc = s.discounted > 0 ? `, ${s.discounted} rows discounted` : "";
129
+ const qualityDetail = s.cold
130
+ ? `n_eff ${s.nEff} < ${MIN_SAMPLES} (cold)`
131
+ : `q̂=(qSum ${cell.qSum} + ${PRIOR_K})/(n ${cell.n} + ${2 * PRIOR_K}) − 0.5 n_raw ${s.nRaw}${disc}`;
132
+ const perfDetail = cell.doneMedianMs === undefined || s.cold
133
+ ? `cold`
134
+ : `median ${Math.round(cell.doneMedianMs / 60_000)}m vs ref ${REF_MS / 60_000}m done=${cell.doneCount}`;
135
+ const availDetail = `quotaHits ${cell.quotaHits} / dispatches ${cell.dispatches}`;
136
+ const overrunDetail = `overruns ${cell.overruns ?? 0} / dispatches ${cell.dispatches}`;
137
+ const explore = cell.dispatches >= EXPLORE_CAP
138
+ ? `spent dispatches ${cell.dispatches} ≥ cap ${EXPLORE_CAP}`
139
+ : `remaining ${s.exploreRemaining} bonus ${explorationBonus(cell).toFixed(3)}`;
140
+ return [
141
+ header,
142
+ ` quality ${fmtSigned(terms.quality)} ${qualityDetail}`,
143
+ ` perf ${fmtSigned(terms.perf)} ${perfDetail}`,
144
+ ` avail ${fmtSigned(terms.avail)} ${availDetail}`,
145
+ ` overrun ${fmtSigned(terms.overrun)} ${overrunDetail}`,
146
+ ` score ${fmtSigned(score)} (deciding only below prefer/cost/tier — ROUTE-08)`,
147
+ ` explore ${explore}`,
148
+ ].join("\n");
149
+ }
29
150
  // Inspection surface: preview:true bypasses the routing.learned:off short-circuit so `show` renders the
30
151
  // profile even under the default off — same trust ramp as `tickmarkr plan`. The routing path stays inert.
31
152
  function show(cwd) {
@@ -51,7 +172,8 @@ function show(cwd) {
51
172
  // VIS-04/ROUTE-15 — preview must match routing's tuning, never the bare module default.
52
173
  const score = learnedScore(p, shape, chKey, channel, { availWeight: cfg.routing.learnedTuning?.availWeight }).toFixed(3);
53
174
  const cold = cell.n < MIN_SAMPLES ? ` cold (n<${MIN_SAMPLES})` : "";
54
- return ` ${shape.padEnd(10)} ${chKey.padEnd(28)} ${channel.padEnd(4)} n=${String(cell.n).padEnd(3)} q=${quality.padEnd(5)} ${median.padEnd(9)} disp=${String(cell.dispatches).padEnd(3)} score=${score}${cold}`;
175
+ const disc = (cell.discounted ?? 0) > 0 ? ` disc=${cell.discounted}` : "";
176
+ return ` ${shape.padEnd(10)} ${chKey.padEnd(28)} ${channel.padEnd(4)} n=${String(cell.n).padEnd(3)} q=${quality.padEnd(5)} ${median.padEnd(9)} disp=${String(cell.dispatches).padEnd(3)} score=${score}${disc}${cold}`;
55
177
  });
56
178
  return [...header, "", " shape channel class obs quality median dispatch score", ...rows].join("\n");
57
179
  }
@@ -8,7 +8,11 @@ export async function resume(argv, cwd = process.cwd()) {
8
8
  const runId = argv[0];
9
9
  if (!runId)
10
10
  throw new Error("usage: tickmarkr resume <run-id>");
11
- const s = await runDaemon(cwd, { runId, resume: true, driver: pickDriver(loadConfig(cwd)), narrate: (event) => console.log(formatJournalNarration(event)) });
11
+ // T3: --graph-changed is the operator's audited release of the engagement-identity guard (Sol #2 /
12
+ // Fable F2) — the daemon refuses a mismatched/unbound journal unless this is set, then journals a
13
+ // graph-rehash event naming both hashes. Strip the flag before runId resolution so a bare id still wins.
14
+ const graphChanged = argv.includes("--graph-changed");
15
+ const s = await runDaemon(cwd, { runId, resume: true, graphChanged, driver: pickDriver(loadConfig(cwd)), narrate: (event) => console.log(formatJournalNarration(event)) });
12
16
  const out = `resumed ${s.runId} — ${formatSummary(s)}`;
13
17
  return { out, code: summaryGreen(s) ? 0 : 2 };
14
18
  }
@@ -1,7 +1,7 @@
1
1
  import { BANNER } from "../../brand.js";
2
- import { blockedTasks, loadGraph, pendingTasks } from "../../graph/graph.js";
2
+ import { blockedTasks, graphDefinitionHash, loadGraph, pendingTasks } from "../../graph/graph.js";
3
3
  import { GATE_NAMES } from "../../graph/schema.js";
4
- import { Journal, journalComparable } from "../../run/journal.js";
4
+ import { Journal, engagementComparable } 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
7
  const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; run `tickmarkr run` to execute";
@@ -127,8 +127,10 @@ const renderFrame = (cwd) => {
127
127
  if (runId) {
128
128
  const j = Journal.open(cwd, runId);
129
129
  events = j.read();
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);
130
+ // T3 (Sol #2 / Fable F2): the SAME comparator resume uses (engagementComparable)one decision,
131
+ // two consumers. graphDefinitionHash (compiled task definitions only) survives status/evidence
132
+ // mutation but changes when a task definition changes, so a recompiled graph is detected here too.
133
+ comparable = engagementComparable(events, graphDefinitionHash(g)).comparable;
132
134
  if (comparable) {
133
135
  replayed = j.replayStatuses();
134
136
  for (const e of events) {
@@ -1,5 +1,6 @@
1
1
  import { readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { extname, join, relative } from "node:path";
3
+ import picomatch from "picomatch";
3
4
  // Advisory plan-time scan only (OBS-12/13/14/21). NEVER expands files[], fails compile, or
4
5
  // feeds the scope gate — a warning the author acts on. Plain-text (no AST), capped + sorted.
5
6
  /** Max test files walked under tests/ (sorted walk; rest ignored). */
@@ -99,7 +100,8 @@ export function collateralLints(tasks, repoRoot) {
99
100
  };
100
101
  const lines = [];
101
102
  for (const t of tasks) {
102
- const scoped = new Set(t.files.map((f) => f.replace(/^\.\//, "")));
103
+ // OBS-22: scopeGate accepts picomatch globs; advisory collateral warnings must agree.
104
+ const scoped = picomatch(t.files.map((f) => f.replace(/^\.\//, "")), { dot: true });
103
105
  const srcFiles = t.files.map((f) => f.replace(/^\.\//, "")).filter(isSrcPath);
104
106
  if (!srcFiles.length)
105
107
  continue;
@@ -107,7 +109,7 @@ export function collateralLints(tasks, repoRoot) {
107
109
  const needles = [...new Set(srcFiles.flatMap(needlesFor))];
108
110
  const hits = [];
109
111
  for (const tf of testFiles) {
110
- if (scoped.has(tf))
112
+ if (scoped(tf))
111
113
  continue;
112
114
  const text = read(tf);
113
115
  if (text === null)
@@ -28,6 +28,7 @@ export declare class HerdrDriver implements ExecutorDriver {
28
28
  private glyphFor;
29
29
  private joinGroup;
30
30
  run(slot: Slot, cmd: string): Promise<void>;
31
+ private waitOk;
31
32
  waitOutput(slot: Slot, pattern: string, timeoutMs: number, opts?: {
32
33
  regex?: boolean;
33
34
  }): Promise<boolean>;
@@ -275,15 +275,25 @@ export class HerdrDriver {
275
275
  if (r.code !== 0)
276
276
  throw new Error(`herdr pane run failed: ${r.stderr || r.stdout}`);
277
277
  }
278
+ waitOk(code, stdout) {
279
+ if (code !== 0 || !stdout.trim())
280
+ return code === 0; // herdr's successful waits may be silent
281
+ try {
282
+ return !Object.hasOwn(JSON.parse(stdout), "error");
283
+ }
284
+ catch {
285
+ return false; // a non-empty herdr wait response must be a parseable envelope
286
+ }
287
+ }
278
288
  async waitOutput(slot, pattern, timeoutMs, opts) {
279
289
  const pane = await this.paneId(slot);
280
290
  const r = await this.herdr(`wait output ${shq(pane)} --match ${shq(pattern)}${opts?.regex ? " --regex" : ""} --timeout ${Math.floor(timeoutMs)}`, slot.cwd, timeoutMs + 15_000);
281
- return r.code === 0 && !r.stdout.includes('"error"'); // dead pane: exit 0 + error json (herdr bite)
291
+ return this.waitOk(r.code, r.stdout); // dead pane: exit 0 + top-level error envelope (herdr bite)
282
292
  }
283
293
  async waitAgentStatus(slot, status, timeoutMs) {
284
294
  const pane = await this.paneId(slot);
285
295
  const r = await this.herdr(`wait agent-status ${shq(pane)} --status ${shq(status)} --timeout ${Math.floor(timeoutMs)}`, slot.cwd, timeoutMs + 15_000);
286
- return r.code === 0 && !r.stdout.includes('"error"');
296
+ return this.waitOk(r.code, r.stdout);
287
297
  }
288
298
  async status(slot) {
289
299
  return this.statusByName(slot.name);
@@ -10,6 +10,8 @@ export interface JudgeVerdict {
10
10
  reason: string;
11
11
  }>;
12
12
  }
13
+ export declare function judgeCriterionId(index: number): string;
14
+ export declare function testFiltered(testCmd: string, name: string): string;
13
15
  export interface AcceptanceGateOpts {
14
16
  testCmd?: string;
15
17
  diffCap?: number;