tickmarkr 1.89.0 → 1.90.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 (39) hide show
  1. package/dist/cli/commands/doctor.d.ts +16 -0
  2. package/dist/cli/commands/doctor.js +52 -0
  3. package/dist/cli/commands/init.js +1 -0
  4. package/dist/cli/commands/verify.d.ts +9 -0
  5. package/dist/cli/commands/verify.js +177 -0
  6. package/dist/cli/index.d.ts +1 -1
  7. package/dist/cli/index.js +3 -1
  8. package/dist/compile/collateral.js +7 -7
  9. package/dist/compile/common.js +2 -2
  10. package/dist/compile/native.d.ts +15 -1
  11. package/dist/compile/native.js +209 -3
  12. package/dist/config/config.js +2 -2
  13. package/dist/gates/baseline.d.ts +39 -5
  14. package/dist/gates/baseline.js +67 -8
  15. package/dist/gates/scope.js +3 -3
  16. package/dist/graph/files-glob.d.ts +18 -0
  17. package/dist/graph/files-glob.js +22 -0
  18. package/dist/run/daemon.d.ts +2 -0
  19. package/dist/run/daemon.js +14 -7
  20. package/dist/run/git.d.ts +3 -0
  21. package/dist/run/git.js +6 -3
  22. package/dist/run/merge.d.ts +4 -1
  23. package/dist/run/merge.js +26 -7
  24. package/fixtures/authoring-lints/01-awk-range-self-pass.spec.md +12 -0
  25. package/fixtures/authoring-lints/02-judge-text-key-miss.spec.md +7 -0
  26. package/fixtures/authoring-lints/03-c1-t41-rendered-observable.spec.md +8 -0
  27. package/fixtures/authoring-lints/04-c1-t24-named-file.spec.md +8 -0
  28. package/fixtures/authoring-lints/05-c2-t24-t28-dep-inversion.spec.md +7 -0
  29. package/fixtures/authoring-lints/06-c2-denumbered-coupling.spec.md +7 -0
  30. package/fixtures/authoring-lints/07-c3a-t41-line-count-proxy.spec.md +7 -0
  31. package/fixtures/authoring-lints/08-c3b-t41-governance-referent.spec.md +7 -0
  32. package/fixtures/authoring-lints/09-c4-universals-without-pointer.spec.md +7 -0
  33. package/fixtures/authoring-lints/10-c5-t34-conjunct-flood.spec.md +7 -0
  34. package/fixtures/authoring-lints/11-c6-t34-q3-q9-q20-bundle.spec.md +7 -0
  35. package/fixtures/authoring-lints/12-c7-t24-prose-seam.spec.md +8 -0
  36. package/package.json +1 -1
  37. package/skills/tickmarkr-overseer/SKILL.md +5 -0
  38. package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +4 -1
  39. package/skills/tickmarkr-overseer/scripts/watch-pending-input.sh +18 -0
@@ -1,8 +1,9 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
- import { basename, dirname } from "node:path";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { filesGlob } from "../graph/files-glob.js";
4
5
  import picomatch from "picomatch";
5
- import { GATE_NAMES, GRAPH_ROUTING_MODES, ORACLES, SHAPES, TIERS, validateGraph } from "../graph/schema.js";
6
+ import { GATE_NAMES, GRAPH_ROUTING_MODES, ORACLES, renderAcceptanceItem, SHAPES, TIERS, validateGraph, } from "../graph/schema.js";
6
7
  import { CompileError, inferShape, sha256 } from "./common.js";
7
8
  // OBS-170/OBS-184: `context:` is a promise to the worker, and nothing ever checked it could be kept.
8
9
  // Workers run in `git worktree add <baseRef>` (run/git.ts:169-176), which materialises the base
@@ -71,6 +72,202 @@ const FIELDS = new Set(["goal", "shape", "deps", "files", "context", "complexity
71
72
  function invalid(task, field, detail) {
72
73
  throw new CompileError(`Task ${task} field "${field}" ${detail}`);
73
74
  }
75
+ export const AUTHORING_LINT_CODES = [
76
+ "criterion-scope",
77
+ "dependency-closure",
78
+ "dependency-coupling",
79
+ "proxy-metric",
80
+ "external-referent",
81
+ "closed-enumeration",
82
+ "one-behavior",
83
+ "concern-bundle",
84
+ "seam-exists",
85
+ ];
86
+ // The authoring frame's observable oracle is the branch-tip test corpus, never the mutable index or
87
+ // an author's untracked checkout. Load it once per repository: one bounded git read replaces a grep
88
+ // subprocess per token and preserves the base-only native compile's zero-Git property (the index is
89
+ // requested only when a criterion actually carries a rendered observable or a test basename).
90
+ const headTestCache = new Map();
91
+ function repositoryRoot(file) {
92
+ let current = resolve(dirname(file));
93
+ for (;;) {
94
+ if (existsSync(join(current, ".git")))
95
+ return current;
96
+ const parent = dirname(current);
97
+ if (parent === current)
98
+ return undefined;
99
+ current = parent;
100
+ }
101
+ }
102
+ function testsAtHead(root) {
103
+ const cached = headTestCache.get(root);
104
+ if (cached)
105
+ return cached;
106
+ const grep = spawnSync("git", ["-C", root, "grep", "-I", "-n", "-e", "", "HEAD", "--", "tests/*.test.ts", "tests/**/*.test.ts"], { encoding: "utf8", maxBuffer: 1 << 25 });
107
+ if (grep.status !== 0 || typeof grep.stdout !== "string") {
108
+ headTestCache.set(root, []);
109
+ return [];
110
+ }
111
+ const bodies = new Map();
112
+ for (const line of grep.stdout.split("\n")) {
113
+ const match = line.match(/^HEAD:(tests\/.*\.test\.ts):\d+:(.*)$/);
114
+ if (!match)
115
+ continue;
116
+ const lines = bodies.get(match[1]) ?? [];
117
+ lines.push(match[2]);
118
+ bodies.set(match[1], lines);
119
+ }
120
+ const tests = [...bodies].map(([path, lines]) => ({ path, text: lines.join("\n") }));
121
+ headTestCache.set(root, tests);
122
+ return tests;
123
+ }
124
+ function criterionTexts(task) {
125
+ return task.acceptance.map(renderAcceptanceItem);
126
+ }
127
+ function fixtureId(file) {
128
+ return basename(file).replace(/(?:\.spec)?\.md$/, "");
129
+ }
130
+ function strictNegativeCount(text) {
131
+ return (text.match(/\b(?:never|neither|cannot)\b|\bno\s+[a-z][a-z-]*(?:\s+[a-z][a-z-]*){0,3}\s+(?:may|can|supplies|becomes)\b/gi) ?? []).length;
132
+ }
133
+ function namedCriterionPaths(text, tests) {
134
+ const paths = new Set();
135
+ for (const match of text.matchAll(/(?:^|[\s("'`])((?:src|tests|fixtures|scripts)\/[A-Za-z0-9_@{}*?.,/+-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|txt))/g)) {
136
+ paths.add(match[1]);
137
+ }
138
+ for (const match of text.matchAll(/\b([A-Za-z0-9_.-]+\.test\.ts)\b/g)) {
139
+ for (const test of tests)
140
+ if (basename(test.path) === match[1])
141
+ paths.add(test.path);
142
+ }
143
+ return [...paths];
144
+ }
145
+ function renderedObservables(text) {
146
+ const exact = new Set();
147
+ const denominators = new Set();
148
+ for (const match of text.matchAll(/`([^`\n]{2,120})`/g)) {
149
+ const token = match[1].trim();
150
+ // Plain vocabulary in backticks is documentation, not a concrete rendered value. Digit-bearing,
151
+ // path/punctuation-shaped, snake/camel-case tokens are the branch-tip grep substrate from C1.
152
+ if (/\d|[_./:%-]|[a-z][A-Z]/.test(token))
153
+ exact.add(token);
154
+ }
155
+ for (const match of text.matchAll(/\b(\d+)\s*(?:\/|of)\s*(\d+)\b/gi)) {
156
+ exact.add(`${match[1]}/${match[2]}`);
157
+ denominators.add(match[2]);
158
+ }
159
+ return { exact: [...exact], denominators: [...denominators] };
160
+ }
161
+ function criterionScopeFinding(task, text, criterion, id, tests) {
162
+ if (task.files.length === 0)
163
+ return undefined; // empty files[] is deliberately unrestricted
164
+ const inScope = filesGlob(task.files.map((entry) => entry.replace(/^\.\//, ""))); // Q120s shared matcher
165
+ const named = namedCriterionPaths(text, tests);
166
+ const { exact, denominators } = renderedObservables(text);
167
+ const asserting = tests.filter((test) => exact.some((token) => test.text.includes(token))
168
+ || denominators.some((denominator) => new RegExp(`(?:^|\\D)\\d+/${denominator}(?:\\D|$)`).test(test.text))).map((test) => test.path);
169
+ const missing = [...new Set([...named, ...asserting])].filter((path) => !inScope(path));
170
+ if (missing.length === 0)
171
+ return undefined;
172
+ return {
173
+ code: "criterion-scope",
174
+ fixtureId: id,
175
+ taskId: task.id,
176
+ criterion,
177
+ detail: `criterion names asserting file${missing.length === 1 ? "" : "s"} outside expanded files[]: ${missing.join(", ")}`,
178
+ };
179
+ }
180
+ function exportedIdentifier(root, files, identifier) {
181
+ const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
182
+ const declaration = new RegExp(`\\bexport\\s+(?:(?:declare|default|async)\\s+)*(?:function|class|const|let|var|interface|type)\\s+${escaped}\\b|\\bexport\\s*\\{[^}]*\\b${escaped}\\b`);
183
+ return files.some((file) => {
184
+ if (/[*?{[]/.test(file))
185
+ return false;
186
+ try {
187
+ return declaration.test(readFileSync(join(root, file), "utf8"));
188
+ }
189
+ catch {
190
+ return false;
191
+ }
192
+ });
193
+ }
194
+ /**
195
+ * Frame-v2 authoring checks over the compiled native task shape. Criterion-scope is blocking at the
196
+ * compile boundary; the remaining checks are review findings, emitted by compileNative below.
197
+ */
198
+ export function authoringLintFindings(tasks, file) {
199
+ const id = fixtureId(file);
200
+ const root = repositoryRoot(file);
201
+ let indexedTests;
202
+ const testIndex = () => indexedTests ??= root ? testsAtHead(root) : [];
203
+ const findings = [];
204
+ for (const task of tasks) {
205
+ const criteria = criterionTexts(task);
206
+ for (const [index, text] of criteria.entries()) {
207
+ const needsTestIndex = /`[^`\n]{2,120}`|\b\d+\s*(?:\/|of)\s*\d+\b|\b[A-Za-z0-9_.-]+\.test\.ts\b/.test(text);
208
+ const scope = criterionScopeFinding(task, text, index + 1, id, needsTestIndex ? testIndex() : []);
209
+ if (scope)
210
+ findings.push(scope);
211
+ if (/line[- ]count|physical line|not greater than (?:the|\d)|no larger than|at most \d+ (?:lines|bytes)/i.test(text)) {
212
+ findings.push({ code: "proxy-metric", fixtureId: id, taskId: task.id, criterion: index + 1, detail: "criterion uses a proxy size metric; state the structural intent instead" });
213
+ }
214
+ if (/\bthe (?:audit|ruling|sweep|standby|decision|analysis|handoff)\b|\bper the\b|\bat \.planning\//i.test(text)) {
215
+ findings.push({ code: "external-referent", fixtureId: id, taskId: task.id, criterion: index + 1, detail: "criterion depends on an external governance referent; inline the deciding fact" });
216
+ }
217
+ const separators = text.match(/\band\b|\bwhile\b|\bthen\b|\bplus\b|\bwith\b|,|—/gi) ?? [];
218
+ if (separators.length >= 4) {
219
+ findings.push({ code: "one-behavior", fixtureId: id, taskId: task.id, criterion: index + 1, detail: `${separators.length} independently-failable separators; split unless one leg alone reds this title` });
220
+ }
221
+ }
222
+ const prose = [task.goal, ...criteria].join("\n");
223
+ const dependencyText = prose
224
+ .replace(/`[^`]*`/g, "")
225
+ .split("\n")
226
+ .filter((line) => !/\b(?:accepts?|rejects?)\b/i.test(line))
227
+ .join("\n");
228
+ const referenced = [...new Set(dependencyText.match(/\bT\d+\b/g) ?? [])]
229
+ .filter((dep) => dep !== task.id && !task.deps.includes(dep));
230
+ if (referenced.length > 0) {
231
+ findings.push({ code: "dependency-closure", fixtureId: id, taskId: task.id, detail: `references ${referenced.join(", ")} outside deps[]` });
232
+ }
233
+ if (/\bwhose independent [^.\n]{0,80}(?:classification|result|decision|output|evidence)\b|\b(?:from|through|provided by|owned by) (?:a |the )?(?:successor|downstream|later|sibling)\b/i.test(dependencyText)) {
234
+ findings.push({ code: "dependency-coupling", fixtureId: id, taskId: task.id, detail: "de-numbered cross-task coupling needs a dependency or a declared consumes contract" });
235
+ }
236
+ const negatives = strictNegativeCount(prose);
237
+ if (negatives >= 2 && !/enumerat|closed (?:input )?list|closed \w* table|STANDBY/i.test(prose)) {
238
+ findings.push({ code: "closed-enumeration", fixtureId: id, taskId: task.id, detail: `${negatives} strict prohibitions have no closed enumeration pointer` });
239
+ }
240
+ const concerns = [...new Set(task.goal.match(/\bQ\d+\b/g) ?? [])];
241
+ if (concerns.length > 1) {
242
+ findings.push({ code: "concern-bundle", fixtureId: id, taskId: task.id, detail: `goal bundles ${concerns.join(", ")}` });
243
+ }
244
+ if (root && negatives > 0) {
245
+ const monoliths = task.files.filter((entry) => {
246
+ if (!entry.startsWith("src/") || /[*?{[]/.test(entry))
247
+ return false;
248
+ try {
249
+ return readFileSync(join(root, entry), "utf8").split("\n").length > 1500;
250
+ }
251
+ catch {
252
+ return false;
253
+ }
254
+ });
255
+ if (monoliths.length > 0) {
256
+ const identifiers = [...prose.matchAll(/`([A-Za-z_$][A-Za-z0-9_$]*)`/g)].map((match) => match[1]);
257
+ const seamExists = identifiers.some((identifier) => exportedIdentifier(root, task.files, identifier));
258
+ const firstCreatesSeam = /\b(?:add|create|extract|introduce|ship)\b[^.\n]{0,100}\b(?:injectable|seam|clock|snapshot|evidence)\b/i.test(criteria[0] ?? "");
259
+ if (!seamExists && !firstCreatesSeam) {
260
+ findings.push({ code: "seam-exists", fixtureId: id, taskId: task.id, detail: `law-shaped task touches ${monoliths.join(", ")} but names no exported seam and does not create one first` });
261
+ }
262
+ }
263
+ }
264
+ }
265
+ return findings;
266
+ }
267
+ function renderAuthoringFinding(finding) {
268
+ const criterion = finding.criterion === undefined ? "" : ` criterion ${finding.criterion}`;
269
+ return `tickmarkr: authoring-lint[${finding.code}] fixture ${finding.fixtureId} task ${finding.taskId}${criterion}: ${finding.detail}`;
270
+ }
74
271
  export function compileNative(file) {
75
272
  if (!existsSync(file))
76
273
  throw new CompileError(`no such native spec file: ${file}`);
@@ -328,7 +525,7 @@ export function compileNative(file) {
328
525
  return [probe, ...variants];
329
526
  };
330
527
  const canHostTest = (entry) => {
331
- const self = picomatch(entry, { dot: true });
528
+ const self = filesGlob(entry); // Q120s shared matcher — paren dirs stay literal in the self-probe
332
529
  return expandBraces(entry)
333
530
  .flatMap((branch) => PROBE_TOKENS.map((token) => branch.replace(/\*+/g, token)))
334
531
  .flatMap(qmarkVariants)
@@ -400,6 +597,15 @@ export function compileNative(file) {
400
597
  },
401
598
  tasks,
402
599
  });
600
+ const authoringFindings = authoringLintFindings(result.tasks, file);
601
+ for (const finding of authoringFindings.filter(({ code }) => code !== "criterion-scope")) {
602
+ console.warn(renderAuthoringFinding(finding));
603
+ }
604
+ const scopeErrors = authoringFindings.filter(({ code }) => code === "criterion-scope");
605
+ if (scopeErrors.length > 0) {
606
+ throw new CompileError(`${file} violates the criterion-scope authoring lint (${scopeErrors.length} error${scopeErrors.length === 1 ? "" : "s"}):\n`
607
+ + scopeErrors.map((finding) => ` - ${renderAuthoringFinding(finding)}`).join("\n"));
608
+ }
403
609
  // v1.19 read-old/write-new: a plain-string acceptance item compiles as a judge oracle. This is the
404
610
  // one-time nudge toward typed oracles (command/test/judge); PRD/Spec Kit/GSD stay silent (untouched).
405
611
  if (plainCount > 0) {
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import picomatch from "picomatch";
4
+ import { filesGlob } from "../graph/files-glob.js";
5
5
  import { Document, parse } from "yaml";
6
6
  import { z } from "zod";
7
7
  import { CITED_MODEL_WINDOWS } from "../adapters/model-windows.js";
@@ -223,7 +223,7 @@ export function criticalPathHits(files, criticalPaths = []) {
223
223
  const critical = normPath(rawCritical);
224
224
  if (!critical)
225
225
  continue;
226
- if (f === critical || picomatch(critical, { dot: true })(f) || picomatch(f, { dot: true })(critical)
226
+ if (f === critical || filesGlob(critical)(f) || filesGlob(f)(critical) // Q120s shared matcher
227
227
  || (GLOB_META_RE.test(f) && GLOB_META_RE.test(critical) && globsMayIntersect(f, critical))) {
228
228
  hits.add(f);
229
229
  }
@@ -1,12 +1,18 @@
1
1
  import type { TickmarkrConfig } from "../config/config.js";
2
2
  import type { AcceptanceItem } from "../graph/schema.js";
3
+ import { type ShResult } from "../run/git.js";
3
4
  import type { GateResult } from "./types.js";
5
+ export interface BaselineCommand {
6
+ exitCode: number;
7
+ fingerprints: string[];
8
+ missingCommand?: boolean;
9
+ /** What this command actually took at capture, on a pristine tree. Absent in pre-v1.90 baselines. */
10
+ durationMs?: number;
11
+ /** The ceiling that measurement implies, persisted so every later battery uses the same number. */
12
+ ceilingMs?: number;
13
+ }
4
14
  export interface Baseline {
5
- commands: Record<string, {
6
- exitCode: number;
7
- fingerprints: string[];
8
- missingCommand?: boolean;
9
- }>;
15
+ commands: Record<string, BaselineCommand>;
10
16
  warnings?: BaselineWarning[];
11
17
  }
12
18
  export interface BaselineWarning {
@@ -22,7 +28,35 @@ export type FailureClassification = "regression" | "infra";
22
28
  export declare function classifyFailureOutput(output: string): FailureClassification | undefined;
23
29
  export declare const UNRECOGNIZED_FAILURE = "<unrecognized failure output>";
24
30
  export declare function fingerprint(output: string): string[];
31
+ /**
32
+ * Q121s: the battery's forgiveness math, exported so tip-verify applies the IDENTICAL rule.
33
+ * Returns the failure fingerprints of `raw` that are NOT in the baseline entry (fresh), and
34
+ * whether the output carried no recognizable failure shape at all.
35
+ */
36
+ export declare function freshFailures(entry: BaselineCommand | undefined, raw: string): {
37
+ failing: string[];
38
+ unreadable: boolean;
39
+ };
25
40
  export declare function detectGateCommands(repoRoot: string, cfg: TickmarkrConfig): Record<string, string>;
41
+ /**
42
+ * The ceiling a battery runs under: whichever is larger of the shipped constant and the slack applied
43
+ * to what this command actually measured. A pre-v1.90 baseline (no measurement) gets exactly the
44
+ * shipped constant, so nothing regresses on an existing on-disk baseline.json — the same read-old
45
+ * compat rule the fingerprint renormalizer follows.
46
+ */
47
+ export declare const effectiveCeilingMs: (entry?: Pick<BaselineCommand, "durationMs" | "ceilingMs">) => number;
48
+ /**
49
+ * The ONLY producer of a `ceiling-kill` gate result. It is gated on `timedOut`, which git.ts sets in
50
+ * exactly one place — inside the timer callback that issues the SIGKILL — so a process that exited on
51
+ * its own can never reach this text however it exited: a slow red suite, an unreadable runner, exit
52
+ * 137 from a kill somebody ELSE sent. Returning `undefined` rather than a result keeps that structural:
53
+ * every caller must go on to the ordinary verdict path, it cannot fall through into this one.
54
+ *
55
+ * A kill is not a verdict. The battery was still running when the machine took it away, so its exit
56
+ * code is evidence about the ceiling and nothing about the work — which is why this reports `infra`
57
+ * and never enters baseline forgiveness (there is no runner output to forgive).
58
+ */
59
+ export declare function ceilingKillResult(gate: string, r: ShResult, ceilingMs: number): GateResult | undefined;
26
60
  export declare function captureBaseline(cwd: string, commands: Record<string, string>): Promise<Baseline>;
27
61
  export interface VacuousOracleWarning {
28
62
  kind: "vacuous-oracle";
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { sh } from "../run/git.js";
3
+ import { DEFAULT_SHELL_TIMEOUT_MS, sh } from "../run/git.js";
4
4
  // incident #2 (run-20260709-104447): a vitest ✓ PASS line with "error" in the test NAME, wrapped in ANSI
5
5
  // codes that varied between baseline and worktree runs, was reported as a "new failure". Strip ANSI first;
6
6
  // a pass-marker line is never a failure. [\d;#] covers raw ANSI and digit-normalized ANSI ("\x1b[#m") from
@@ -111,6 +111,18 @@ const unrecognizedEvidence = (raw) => raw
111
111
  // stored baselines may predate ANSI/pass-marker hardening — renormalize at compare time so existing
112
112
  // on-disk baseline.json files stay comparable without recapture (compat invariant, CLAUDE.md)
113
113
  const renormalize = (fp) => normalizeLine(fp.replace(ANSI_RE, ""));
114
+ /**
115
+ * Q121s: the battery's forgiveness math, exported so tip-verify applies the IDENTICAL rule.
116
+ * Returns the failure fingerprints of `raw` that are NOT in the baseline entry (fresh), and
117
+ * whether the output carried no recognizable failure shape at all.
118
+ */
119
+ export function freshFailures(entry, raw) {
120
+ const known = new Set((entry?.fingerprints ?? []).map(renormalize));
121
+ // OBS-42: diagnostic headings enrich fingerprints but cannot invalidate legacy baselines.
122
+ const current = fingerprint(raw);
123
+ const fresh = current.filter((f) => !known.has(f) && (!FAIL_ANCHOR_RE.test(f) || f.startsWith("FAIL ")));
124
+ return { failing: fresh.filter((f) => f !== UNRECOGNIZED_FAILURE), unreadable: current.includes(UNRECOGNIZED_FAILURE) };
125
+ }
114
126
  export function detectGateCommands(repoRoot, cfg) {
115
127
  const out = {};
116
128
  const pkgPath = join(repoRoot, "package.json");
@@ -145,17 +157,59 @@ function missingConfiguredCommand(cmd, result) {
145
157
  const output = `${result.stdout}\n${result.stderr}`;
146
158
  return new RegExp(`(?:^|[:\\s])${reEscape(token)}:\\s+(?:command not found|No such file or directory)`, "i").test(output);
147
159
  }
160
+ /**
161
+ * How much longer than the baseline a battery may legitimately take. Gate batteries run inside a
162
+ * worktree beside up to `concurrency` siblings, so the same suite is genuinely slower than its
163
+ * pristine-tree capture — 3× is the headroom, not a performance budget.
164
+ */
165
+ const CEILING_SLACK = 3;
166
+ /**
167
+ * The ceiling a battery runs under: whichever is larger of the shipped constant and the slack applied
168
+ * to what this command actually measured. A pre-v1.90 baseline (no measurement) gets exactly the
169
+ * shipped constant, so nothing regresses on an existing on-disk baseline.json — the same read-old
170
+ * compat rule the fingerprint renormalizer follows.
171
+ */
172
+ export const effectiveCeilingMs = (entry) => entry?.ceilingMs ?? Math.max(DEFAULT_SHELL_TIMEOUT_MS, CEILING_SLACK * (entry?.durationMs ?? 0));
173
+ /**
174
+ * The ONLY producer of a `ceiling-kill` gate result. It is gated on `timedOut`, which git.ts sets in
175
+ * exactly one place — inside the timer callback that issues the SIGKILL — so a process that exited on
176
+ * its own can never reach this text however it exited: a slow red suite, an unreadable runner, exit
177
+ * 137 from a kill somebody ELSE sent. Returning `undefined` rather than a result keeps that structural:
178
+ * every caller must go on to the ordinary verdict path, it cannot fall through into this one.
179
+ *
180
+ * A kill is not a verdict. The battery was still running when the machine took it away, so its exit
181
+ * code is evidence about the ceiling and nothing about the work — which is why this reports `infra`
182
+ * and never enters baseline forgiveness (there is no runner output to forgive).
183
+ */
184
+ export function ceilingKillResult(gate, r, ceilingMs) {
185
+ if (r.timedOut !== true)
186
+ return undefined;
187
+ // no measurement only when a caller synthesized the result; a real kill fires AT the ceiling
188
+ const durationMs = r.durationMs ?? ceilingMs;
189
+ return {
190
+ gate,
191
+ pass: false,
192
+ details: `ceiling-kill: SIGKILLed after ${durationMs}ms at the configured ${ceilingMs}ms ceiling — `
193
+ + `the battery never returned a verdict, so this gate verified nothing (exit ${r.code} is the kill, not a test result)`,
194
+ meta: { classification: "infra", infra: true, kind: "ceiling-kill", durationMs, ceilingMs },
195
+ };
196
+ }
148
197
  export async function captureBaseline(cwd, commands) {
149
198
  const base = { commands: {} };
150
199
  for (const [name, cmd] of Object.entries(commands)) {
151
200
  const r = await sh(cmd, cwd);
152
201
  // ponytail: strip the executing cwd so repo-root capture and worktree compare fingerprint identically; /private-vs-/tmp symlink variance is out of scope
202
+ // ponytail: a capture that was itself killed records the ceiling as its "measurement", which
203
+ // scales the next ceiling up — the right direction for a suite that never finished once.
204
+ const durationMs = r.durationMs ?? 0;
153
205
  base.commands[name] = {
154
206
  exitCode: r.code,
155
207
  // a command that exits 0 has no failures to fingerprint — recording any would be a lie the
156
208
  // compare step then has to forgive
157
209
  fingerprints: r.code === 0 ? [] : fingerprint((r.stdout + "\n" + r.stderr).split(cwd).join("")),
158
210
  missingCommand: missingConfiguredCommand(cmd, r),
211
+ durationMs,
212
+ ceilingMs: effectiveCeilingMs({ durationMs }),
159
213
  };
160
214
  }
161
215
  const names = Object.keys(commands);
@@ -223,7 +277,17 @@ export async function compareToBaseline(cwd, commands, baseline, enabled) {
223
277
  results.push({ gate: name, pass: true, details: `no ${name} command detected — skipped`, meta: { skipped: true } });
224
278
  continue;
225
279
  }
226
- const r = await sh(cmd, cwd);
280
+ const ceilingMs = effectiveCeilingMs(baseline.commands[name]);
281
+ const r = await sh(cmd, cwd, ceilingMs);
282
+ // Q24: the kill is read BEFORE the exit code is interpreted at all. A SIGKILLed battery has
283
+ // whatever partial output it had flushed — typically no failure shape — so every path below
284
+ // would otherwise turn a timeout into a claim about the work: "no recognizable failure lines"
285
+ // when the baseline was green, or a forgiven pre-existing red when it was not. Neither is true.
286
+ const killed = ceilingKillResult(name, r, ceilingMs);
287
+ if (killed) {
288
+ results.push(killed);
289
+ continue;
290
+ }
227
291
  if (r.code === 0) {
228
292
  results.push({ gate: name, pass: true, details: "exit 0" });
229
293
  continue;
@@ -244,10 +308,6 @@ export async function compareToBaseline(cwd, commands, baseline, enabled) {
244
308
  });
245
309
  continue;
246
310
  }
247
- const known = new Set((baseline.commands[name]?.fingerprints ?? []).map(renormalize));
248
- // OBS-42: diagnostic headings enrich fingerprints but cannot invalidate legacy baselines.
249
- const current = fingerprint(raw);
250
- const fresh = current.filter((f) => !known.has(f) && (!FAIL_ANCHOR_RE.test(f) || f.startsWith("FAIL ")));
251
311
  // OBS-278: only a failure SHAPE is a verdict — everything fingerprint() keeps is one, except the
252
312
  // unrecognized-output marker, which is evidence for the operator and never grounds to reject.
253
313
  // ponytail: ceiling — a runner whose failure output holds no shape above and whose baseline is
@@ -255,8 +315,7 @@ export async function compareToBaseline(cwd, commands, baseline, enabled) {
255
315
  // below rather than reading as a verified green. Raise the ceiling by teaching isFailureShaped
256
316
  // that runner's position rule (leading verdict + identifier, or identifier + separator + trailing
257
317
  // verdict); loosening back to vocabulary re-opens OBS-278.
258
- const unreadable = current.includes(UNRECOGNIZED_FAILURE);
259
- const failing = fresh.filter((f) => f !== UNRECOGNIZED_FAILURE);
318
+ const { failing, unreadable } = freshFailures(baseline.commands[name], raw);
260
319
  if (!failing.length && (baseline.commands[name]?.exitCode ?? 1) === 0) {
261
320
  const closed = `command was green at baseline but now exits ${r.code} with no recognizable failure lines — failing closed`;
262
321
  const evidence = unrecognizedEvidence(raw);
@@ -1,4 +1,4 @@
1
- import picomatch from "picomatch";
1
+ import { filesGlob } from "../graph/files-glob.js";
2
2
  import { shGitOk } from "../run/git.js";
3
3
  // OBS-61: the live integration tip can advance between attempts (sibling merge) while a resumed
4
4
  // worktree stays parented on the old base — merge-base(tip, HEAD) pins the diff to the worktree's
@@ -11,7 +11,7 @@ export async function scopeDiffBase(worktree, integrationTip) {
11
11
  }
12
12
  /** Pure offender split — corpus-replay oracle exercises this exact decision logic. */
13
13
  export function dispositionOffenders(offenders, allowDeviations) {
14
- const allowedMatch = allowDeviations.length ? picomatch(allowDeviations, { dot: true }) : () => false;
14
+ const allowedMatch = allowDeviations.length ? filesGlob(allowDeviations) : () => false;
15
15
  const allowed = [];
16
16
  const hard = [];
17
17
  for (const f of offenders) {
@@ -27,7 +27,7 @@ export async function scopeGate(worktree, integrationTip, files, result, allowDe
27
27
  return { gate: "scope", pass: true, details: "no file scope declared — unrestricted" };
28
28
  const baseRef = await scopeDiffBase(worktree, integrationTip);
29
29
  const changed = (await shGitOk(`git diff --name-only '${baseRef}..HEAD'`, worktree)).trim().split("\n").filter(Boolean);
30
- const inScope = picomatch(files, { dot: true }); // byte-identical options to assertWriteScope in src/compile/common.ts
30
+ const inScope = filesGlob(files); // the ONE files[] matcher — src/graph/files-glob.ts (Q120s)
31
31
  const offenders = changed.filter((f) => !inScope(f));
32
32
  if (!offenders.length)
33
33
  return { gate: "scope", pass: true, details: `all ${changed.length} changed files in scope` };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The ONE matcher for files[] / scope.allowDeviations semantics (Q120s, TRIAL T-OBS-4).
3
+ *
4
+ * Parens in a files[] entry are LITERAL path characters. Expo Router and Next.js name
5
+ * group directories `(app)`, `(marketing)` — but picomatch compiles a bare `(app)` to a
6
+ * regex CAPTURE GROUP matching `app`, so a pattern naming such a path can never match it
7
+ * (SentioQ run-1: T3 burned 3 dispatches on a scope red unwinnable by construction).
8
+ * Brackets need no help: this picomatch already compiles `[id]` to the alternation
9
+ * `(?:\[id\]|[id])`, literal-or-class.
10
+ *
11
+ * The price is extglobs (`@(a|b)` etc.) become literal text in files[] entries — they
12
+ * have no recorded use in any spec, and a scope allowlist wants boring, predictable
13
+ * matching over regex power. Every consumer of files[]-shaped patterns MUST match
14
+ * through this module; a second picomatch call with hand-rolled options is how the
15
+ * gate and the compiler drift apart.
16
+ */
17
+ export declare const literalParens: (pattern: string) => string;
18
+ export declare function filesGlob(patterns: string | string[]): (path: string) => boolean;
@@ -0,0 +1,22 @@
1
+ import picomatch from "picomatch";
2
+ /**
3
+ * The ONE matcher for files[] / scope.allowDeviations semantics (Q120s, TRIAL T-OBS-4).
4
+ *
5
+ * Parens in a files[] entry are LITERAL path characters. Expo Router and Next.js name
6
+ * group directories `(app)`, `(marketing)` — but picomatch compiles a bare `(app)` to a
7
+ * regex CAPTURE GROUP matching `app`, so a pattern naming such a path can never match it
8
+ * (SentioQ run-1: T3 burned 3 dispatches on a scope red unwinnable by construction).
9
+ * Brackets need no help: this picomatch already compiles `[id]` to the alternation
10
+ * `(?:\[id\]|[id])`, literal-or-class.
11
+ *
12
+ * The price is extglobs (`@(a|b)` etc.) become literal text in files[] entries — they
13
+ * have no recorded use in any spec, and a scope allowlist wants boring, predictable
14
+ * matching over regex power. Every consumer of files[]-shaped patterns MUST match
15
+ * through this module; a second picomatch call with hand-rolled options is how the
16
+ * gate and the compiler drift apart.
17
+ */
18
+ export const literalParens = (pattern) => pattern.replace(/\\?[()]/g, (m) => (m.length === 2 ? m : `\\${m}`));
19
+ export function filesGlob(patterns) {
20
+ const list = (Array.isArray(patterns) ? patterns : [patterns]).map(literalParens);
21
+ return picomatch(list, { dot: true });
22
+ }
@@ -1,6 +1,7 @@
1
1
  import { type WorkerAdapter } from "../adapters/types.js";
2
2
  import { type ModeResolution, type RoutingMode, type TickmarkrConfig } from "../config/config.js";
3
3
  import { type ExecutorDriver } from "../drivers/types.js";
4
+ import { type Baseline } from "../gates/baseline.js";
4
5
  import type { GateResult } from "../gates/types.js";
5
6
  import { Journal, type JournalEvent } from "./journal.js";
6
7
  export interface RunOptions {
@@ -131,6 +132,7 @@ export declare function commandsHash(commands: Record<string, string>): string;
131
132
  */
132
133
  export declare function verifyIntegrationTipCached(intWt: string, commands: Record<string, string>, journal: Journal, opts?: {
133
134
  lastMergedTask?: string;
135
+ baseline?: Baseline;
134
136
  }): Promise<boolean>;
135
137
  export declare function workerTreeCpuMs(marker: string, cwd: string): Promise<{
136
138
  ms: number;
@@ -414,7 +414,7 @@ function lastVerifyCycle(events) {
414
414
  if (e.event === "tip-verify-start") {
415
415
  const { tip, cmdHash } = e.data;
416
416
  cur = typeof tip === "string" && typeof cmdHash === "string"
417
- ? { tip, cmdHash, gates: new Set(), failed: false }
417
+ ? { tip, cmdHash, gates: new Set(), failed: false, forgiven: false }
418
418
  : undefined;
419
419
  afterRunEnd = false;
420
420
  continue;
@@ -432,13 +432,19 @@ function lastVerifyCycle(events) {
432
432
  continue;
433
433
  }
434
434
  if (!cur || afterRunEnd || cur.tip !== tip || cur.cmdHash !== cmdHash) {
435
- cur = { tip, cmdHash, gates: new Set(), failed: false };
435
+ cur = { tip, cmdHash, gates: new Set(), failed: false, forgiven: false };
436
436
  }
437
437
  afterRunEnd = false;
438
438
  if (e.event === "tip-verify-failed")
439
439
  cur.failed = true;
440
- else
440
+ else {
441
441
  cur.gates.add(gate);
442
+ // Q121s review M1: a forgiven green depends on the baseline that forgave it. Baselines are not
443
+ // part of the cache key, so a forgiven cycle is NEVER cache-eligible — an unchanged tip whose
444
+ // baseline later vanishes or changes must re-run the full verify, not inherit the green.
445
+ if (e.data.forgiven === true)
446
+ cur.forgiven = true;
447
+ }
442
448
  }
443
449
  return cur;
444
450
  }
@@ -459,7 +465,7 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
459
465
  const porcelain = await shGit("git status --porcelain", intWt);
460
466
  const clean = porcelain.code === 0 && porcelain.stdout.trim() === "";
461
467
  const last = lastVerifyCycle(journal.read());
462
- const cached = last !== undefined && !last.failed && last.tip === tip && last.cmdHash === cmdHash
468
+ const cached = last !== undefined && !last.failed && !last.forgiven && last.tip === tip && last.cmdHash === cmdHash
463
469
  && Object.keys(commands).every((g) => last.gates.has(g));
464
470
  // A pair can be verified red and then green without either SHA or command hash changing (for
465
471
  // example, an external service or ignored fixture recovers). Delimit attempts explicitly so that
@@ -477,9 +483,10 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
477
483
  return false;
478
484
  }
479
485
  let tipFailed = false;
480
- for (const r of await verifyIntegrationTip(intWt, commands, journal.dir)) {
486
+ for (const r of await verifyIntegrationTip(intWt, commands, journal.dir, opts.baseline)) {
481
487
  if (r.pass) {
482
- journal.append("tip-verify", undefined, { gate: r.gate, cmd: r.cmd, pass: true, exitCode: r.exitCode, details: r.details, tip, cmdHash });
488
+ // Q121s: a forgiven pass journals its fingerprints — honest about what was carried, never a silent green.
489
+ journal.append("tip-verify", undefined, { gate: r.gate, cmd: r.cmd, pass: true, exitCode: r.exitCode, details: r.details, ...(r.forgiven ? { forgiven: true, fingerprints: r.fingerprints } : {}), tip, cmdHash });
483
490
  }
484
491
  else {
485
492
  journal.append("tip-verify-failed", undefined, {
@@ -3095,7 +3102,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3095
3102
  // OBS-34: post-merge integration-tip verify — strict exit codes, no baseline forgiveness.
3096
3103
  const lastMergedTask = [...journal.read()].reverse().find((e) => e.event === "merge" && e.taskId)?.taskId;
3097
3104
  if (summary.done.length > 0 && Object.keys(commands).length > 0) {
3098
- const tipFailed = await verifyIntegrationTipCached(intWt, commands, journal, { lastMergedTask });
3105
+ const tipFailed = await verifyIntegrationTipCached(intWt, commands, journal, { lastMergedTask, baseline });
3099
3106
  summary.tipVerify = tipFailed ? "failed" : "passed";
3100
3107
  if (tipFailed && lastMergedTask)
3101
3108
  summary.lastMergedTask = lastMergedTask;
package/dist/run/git.d.ts CHANGED
@@ -14,11 +14,14 @@ export declare const deriveForkCap: (concurrency: number, cores?: number) => num
14
14
  export declare const runWithForkBudget: <T>(concurrency: number, fn: () => Promise<T>) => Promise<T>;
15
15
  /** The cap owned by the run on this async context; the standalone default outside one. */
16
16
  export declare const resolvedForkCap: () => string;
17
+ /** The shipped shell ceiling: the fallback every caller gets when nothing measured a better one. */
18
+ export declare const DEFAULT_SHELL_TIMEOUT_MS = 600000;
17
19
  export interface ShResult {
18
20
  code: number;
19
21
  stdout: string;
20
22
  stderr: string;
21
23
  timedOut?: boolean;
24
+ durationMs?: number;
22
25
  }
23
26
  export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
24
27
  export declare function shGit(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
package/dist/run/git.js CHANGED
@@ -43,6 +43,8 @@ export const deriveForkCap = (concurrency, cores = availableParallelism()) => Ma
43
43
  export const runWithForkBudget = (concurrency, fn) => forkBudget.run(String(deriveForkCap(concurrency)), fn);
44
44
  /** The cap owned by the run on this async context; the standalone default outside one. */
45
45
  export const resolvedForkCap = () => forkBudget.getStore() ?? DEFAULT_FORK_CAP;
46
+ /** The shipped shell ceiling: the fallback every caller gets when nothing measured a better one. */
47
+ export const DEFAULT_SHELL_TIMEOUT_MS = 600000;
46
48
  // stdin "ignore": same class as HARD-05 / SubprocessDriver — never leave an open pipe a child can block on
47
49
  // (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
48
50
  function shell(cmd, cwd, timeoutMs, login) {
@@ -57,6 +59,7 @@ function shell(cmd, cwd, timeoutMs, login) {
57
59
  if (!(FORK_CAP_ENV in env))
58
60
  env[FORK_CAP_ENV] = resolvedForkCap();
59
61
  return new Promise((resolve) => {
62
+ const startedAt = Date.now();
60
63
  // detached: bash gets its own process group so a timeout can kill the whole tree —
61
64
  // SIGKILLing bash alone orphans grandchildren (codex/pi) that hold the stdio pipes
62
65
  // open, so "close" never fires and the promise wedges forever (v1.33.1 init hang).
@@ -68,7 +71,7 @@ function shell(cmd, cwd, timeoutMs, login) {
68
71
  return;
69
72
  done = true;
70
73
  clearTimeout(timer);
71
- resolve({ code, stdout, stderr: err ?? stderr, timedOut });
74
+ resolve({ code, stdout, stderr: err ?? stderr, timedOut, durationMs: Date.now() - startedAt });
72
75
  };
73
76
  const timer = setTimeout(() => {
74
77
  timedOut = true;
@@ -88,11 +91,11 @@ function shell(cmd, cwd, timeoutMs, login) {
88
91
  finish(code ?? 1); });
89
92
  });
90
93
  }
91
- export function sh(cmd, cwd, timeoutMs = 600000) {
94
+ export function sh(cmd, cwd, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS) {
92
95
  return shell(cmd, cwd, timeoutMs, true);
93
96
  }
94
97
  // Git plumbing never needs an operator profile; skip login-shell startup and its side effects.
95
- export function shGit(cmd, cwd, timeoutMs = 600000) {
98
+ export function shGit(cmd, cwd, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS) {
96
99
  return shell(cmd, cwd, timeoutMs, false);
97
100
  }
98
101
  export async function shOk(cmd, cwd) {