tickmarkr 1.87.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 (93) hide show
  1. package/dist/adapters/catalog.d.ts +18 -1
  2. package/dist/adapters/catalog.js +44 -1
  3. package/dist/adapters/fake.d.ts +2 -1
  4. package/dist/adapters/fake.js +7 -0
  5. package/dist/adapters/grok.js +11 -0
  6. package/dist/adapters/kimi.d.ts +2 -1
  7. package/dist/adapters/kimi.js +36 -0
  8. package/dist/adapters/opencode.js +17 -0
  9. package/dist/adapters/pi.js +11 -0
  10. package/dist/adapters/prompt.js +8 -1
  11. package/dist/adapters/registry.js +76 -57
  12. package/dist/adapters/types.d.ts +34 -3
  13. package/dist/adapters/types.js +99 -1
  14. package/dist/cli/commands/approve.d.ts +2 -0
  15. package/dist/cli/commands/approve.js +104 -84
  16. package/dist/cli/commands/compile.d.ts +1 -1
  17. package/dist/cli/commands/compile.js +29 -12
  18. package/dist/cli/commands/doctor.d.ts +16 -0
  19. package/dist/cli/commands/doctor.js +52 -0
  20. package/dist/cli/commands/init.js +2 -1
  21. package/dist/cli/commands/plan.d.ts +1 -1
  22. package/dist/cli/commands/plan.js +10 -1
  23. package/dist/cli/commands/report.js +49 -0
  24. package/dist/cli/commands/status.js +298 -96
  25. package/dist/cli/commands/verify.d.ts +9 -0
  26. package/dist/cli/commands/verify.js +177 -0
  27. package/dist/cli/harness.d.ts +13 -0
  28. package/dist/cli/harness.js +50 -0
  29. package/dist/cli/index.d.ts +1 -1
  30. package/dist/cli/index.js +3 -1
  31. package/dist/compile/collateral.js +11 -11
  32. package/dist/compile/common.js +2 -2
  33. package/dist/compile/index.d.ts +14 -3
  34. package/dist/compile/index.js +36 -10
  35. package/dist/compile/native.d.ts +15 -1
  36. package/dist/compile/native.js +310 -28
  37. package/dist/config/config.js +2 -2
  38. package/dist/drivers/subprocess.d.ts +6 -1
  39. package/dist/drivers/subprocess.js +9 -4
  40. package/dist/gates/acceptance.d.ts +21 -1
  41. package/dist/gates/acceptance.js +67 -22
  42. package/dist/gates/artifact-manifest.d.ts +119 -0
  43. package/dist/gates/artifact-manifest.js +357 -0
  44. package/dist/gates/baseline.d.ts +45 -5
  45. package/dist/gates/baseline.js +119 -15
  46. package/dist/gates/llm.js +37 -26
  47. package/dist/gates/review.d.ts +16 -11
  48. package/dist/gates/review.js +44 -150
  49. package/dist/gates/run-gates.js +124 -7
  50. package/dist/gates/scope.js +3 -3
  51. package/dist/graph/files-glob.d.ts +18 -0
  52. package/dist/graph/files-glob.js +22 -0
  53. package/dist/graph/schema.d.ts +3 -1
  54. package/dist/graph/schema.js +4 -1
  55. package/dist/run/daemon.d.ts +44 -0
  56. package/dist/run/daemon.js +2334 -1973
  57. package/dist/run/git.d.ts +53 -0
  58. package/dist/run/git.js +119 -5
  59. package/dist/run/interactive-seed.d.ts +6 -2
  60. package/dist/run/interactive-seed.js +72 -5
  61. package/dist/run/journal.d.ts +9 -1
  62. package/dist/run/journal.js +99 -9
  63. package/dist/run/lock.d.ts +11 -0
  64. package/dist/run/lock.js +97 -6
  65. package/dist/run/merge.d.ts +4 -1
  66. package/dist/run/merge.js +26 -7
  67. package/dist/run/outcome.d.ts +50 -0
  68. package/dist/run/outcome.js +152 -0
  69. package/dist/run/protocol.d.ts +460 -0
  70. package/dist/run/protocol.js +433 -0
  71. package/dist/run/supervision.d.ts +29 -0
  72. package/dist/run/supervision.js +189 -0
  73. package/fixtures/authoring-lints/01-awk-range-self-pass.spec.md +12 -0
  74. package/fixtures/authoring-lints/02-judge-text-key-miss.spec.md +7 -0
  75. package/fixtures/authoring-lints/03-c1-t41-rendered-observable.spec.md +8 -0
  76. package/fixtures/authoring-lints/04-c1-t24-named-file.spec.md +8 -0
  77. package/fixtures/authoring-lints/05-c2-t24-t28-dep-inversion.spec.md +7 -0
  78. package/fixtures/authoring-lints/06-c2-denumbered-coupling.spec.md +7 -0
  79. package/fixtures/authoring-lints/07-c3a-t41-line-count-proxy.spec.md +7 -0
  80. package/fixtures/authoring-lints/08-c3b-t41-governance-referent.spec.md +7 -0
  81. package/fixtures/authoring-lints/09-c4-universals-without-pointer.spec.md +7 -0
  82. package/fixtures/authoring-lints/10-c5-t34-conjunct-flood.spec.md +7 -0
  83. package/fixtures/authoring-lints/11-c6-t34-q3-q9-q20-bundle.spec.md +7 -0
  84. package/fixtures/authoring-lints/12-c7-t24-prose-seam.spec.md +8 -0
  85. package/fixtures/wrapped-acceptance.native.md +29 -0
  86. package/package.json +1 -1
  87. package/schema/rungraph.schema.json +21 -2
  88. package/skills/tickmarkr-overseer/SKILL.md +262 -5
  89. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +79 -8
  90. package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +80 -0
  91. package/skills/tickmarkr-overseer/scripts/watch-context.sh +86 -0
  92. package/skills/tickmarkr-overseer/scripts/watch-parks.sh +96 -0
  93. package/skills/tickmarkr-overseer/scripts/watch-pending-input.sh +201 -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}`);
@@ -82,14 +279,27 @@ export function compileNative(file) {
82
279
  const drafts = [];
83
280
  let plainCount = 0; // v1.19: plain-string acceptance items compiled as judge oracles (compat) — warn once
84
281
  let specMode;
85
- for (const line of content.split("\n")) {
282
+ let specBase;
283
+ for (const [index, line] of content.split("\n").entries()) {
86
284
  const heading = line.match(HEAD_RE);
87
285
  if (heading) {
88
- drafts.push({ id: heading[1], title: heading[2].trim(), fields: {}, acceptance: [], gates: [], hasGates: false, list: null, continuationField: null });
286
+ drafts.push({ id: heading[1], title: heading[2].trim(), fields: {}, acceptanceRaw: [], acceptance: [], gates: [], hasGates: false, list: null, itemOpen: false, continuationField: null });
89
287
  continue;
90
288
  }
91
289
  const draft = drafts.at(-1);
92
290
  if (!draft) {
291
+ const base = line.match(/^base:\s*(.*)$/);
292
+ if (base) {
293
+ const value = base[1].trim();
294
+ if (!value) {
295
+ throw new CompileError("spec front-matter base must not be empty — repair: write lower-case base: <commit-or-ref> at column zero");
296
+ }
297
+ specBase = value;
298
+ continue;
299
+ }
300
+ if (/^Base:\s*/.test(line)) {
301
+ throw new CompileError("native task unit contract rejects column-zero Base: as untyped prose — repair: write lower-case base: <commit-or-ref>");
302
+ }
93
303
  // v1.51 T2: spec front-matter — a top-level `mode: <name>` line before the first task heading
94
304
  // declares the engagement's routing mode (loses only to an explicit run flag).
95
305
  const fm = line.match(/^mode:\s*(\S+)\s*$/);
@@ -107,6 +317,7 @@ export function compileNative(file) {
107
317
  if (!FIELDS.has(name))
108
318
  invalid(draft.id, field[1], "is unknown");
109
319
  const value = field[2].trim();
320
+ draft.itemOpen = false;
110
321
  if (name === "acceptance" || name === "gates") {
111
322
  if (value)
112
323
  invalid(draft.id, field[1], "must be a nested list");
@@ -155,31 +366,51 @@ export function compileNative(file) {
155
366
  const value = nested[1].trim();
156
367
  if (!value)
157
368
  invalid(draft.id, draft.list, "must not contain empty entries");
158
- // v1.19: only the acceptance list parses typed oracle prefixes; gates stay plain names.
159
- if (draft.list === "acceptance") {
160
- const typed = value.match(ORACLE_RE);
161
- if (typed) {
162
- const [, kind, body] = typed;
163
- if (!body.trim())
164
- invalid(draft.id, "acceptance", `${kind} oracle must carry a value`);
165
- draft.acceptance.push(kind === "command" ? { oracle: "command", command: body.trim() }
166
- : kind === "test" ? { oracle: "test", test: body.trim() }
167
- : { oracle: "judge", text: body.trim() });
168
- }
169
- else {
170
- plainCount++;
171
- draft.acceptance.push(value);
172
- }
173
- }
174
- else {
175
- draft[draft.list].push(value);
176
- }
369
+ (draft.list === "acceptance" ? draft.acceptanceRaw : draft.gates).push(value);
370
+ draft.itemOpen = true;
371
+ continue;
372
+ }
373
+ // OBS-488: a deeper-indented non-item line under a list item is that item's wrapped
374
+ // continuation — join with a single space, exactly as OBS-60/OBS-308 guarantee for fields.
375
+ // 1.87.0 dropped these lines silently: 53/78 of run-551's criteria compiled to first-line
376
+ // stubs and every falsifier tail was invisible to the judge.
377
+ if (draft.list && draft.itemOpen && (line.startsWith(" ") || line.startsWith("\t")) && line.trim()) {
378
+ const items = draft.list === "acceptance" ? draft.acceptanceRaw : draft.gates;
379
+ items[items.length - 1] += ` ${line.trim()}`;
177
380
  continue;
178
381
  }
179
382
  if (line.startsWith("- "))
180
383
  invalid(draft.id, "field", `bullet is malformed: ${JSON.stringify(line)}`);
181
- if (line.trim() && !line.startsWith(" "))
182
- draft.list = null;
384
+ if (!line.trim()) {
385
+ draft.itemOpen = false;
386
+ continue;
387
+ }
388
+ // OBS-488 fail-closed invariant: a non-blank line inside a task block that no rule consumes
389
+ // is an error, never a silent drop — dropping author bytes is the meta-defect that hid the
390
+ // criterion truncation for months.
391
+ throw new CompileError(`Task ${draft.id} line ${index + 1}: no parse rule consumes this line: ${JSON.stringify(line)}.\n` +
392
+ `A non-blank line in a task block must be a "- field:" bullet, a " - " list item, or an\n` +
393
+ `indented continuation of the preceding field or list item. tickmarkr refuses to silently\n` +
394
+ `drop spec bytes (OBS-488) — move prose above the first task heading or into a field.`);
395
+ }
396
+ // OBS-488: typed-oracle prefixes parse on the COMPLETE joined item text, never its first
397
+ // physical line — a wrapped `command:` body or falsifier tail is part of the criterion.
398
+ for (const draft of drafts) {
399
+ for (const raw of draft.acceptanceRaw) {
400
+ const typed = raw.match(ORACLE_RE);
401
+ if (typed) {
402
+ const [, kind, body] = typed;
403
+ if (!body.trim())
404
+ invalid(draft.id, "acceptance", `${kind} oracle must carry a value`);
405
+ draft.acceptance.push(kind === "command" ? { oracle: "command", command: body.trim() }
406
+ : kind === "test" ? { oracle: "test", test: body.trim() }
407
+ : { oracle: "judge", text: body.trim() });
408
+ }
409
+ else {
410
+ plainCount++;
411
+ draft.acceptance.push(raw);
412
+ }
413
+ }
183
414
  }
184
415
  if (!drafts.length) {
185
416
  throw new CompileError(`${file} has no task sections. Expected "## T1: Title" headings with field bullets.`);
@@ -294,7 +525,7 @@ export function compileNative(file) {
294
525
  return [probe, ...variants];
295
526
  };
296
527
  const canHostTest = (entry) => {
297
- const self = picomatch(entry, { dot: true });
528
+ const self = filesGlob(entry); // Q120s shared matcher — paren dirs stay literal in the self-probe
298
529
  return expandBraces(entry)
299
530
  .flatMap((branch) => PROBE_TOKENS.map((token) => branch.replace(/\*+/g, token)))
300
531
  .flatMap(qmarkVariants)
@@ -358,9 +589,23 @@ export function compileNative(file) {
358
589
  const result = validateGraph({
359
590
  version: 1,
360
591
  ...(specMode ? { mode: specMode } : {}),
361
- spec: { source: "native", paths: [file], hash: sha256(content) },
592
+ spec: {
593
+ source: "native",
594
+ paths: [file],
595
+ hash: sha256(content),
596
+ ...(specBase !== undefined ? { base: specBase } : {}),
597
+ },
362
598
  tasks,
363
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
+ }
364
609
  // v1.19 read-old/write-new: a plain-string acceptance item compiles as a judge oracle. This is the
365
610
  // one-time nudge toward typed oracles (command/test/judge); PRD/Spec Kit/GSD stay silent (untouched).
366
611
  if (plainCount > 0) {
@@ -378,7 +623,7 @@ export function compileNative(file) {
378
623
  // OBS-170/OBS-184: warn per unreachable context: entry, classified by the action its author must
379
624
  // take. Warn-only by operator ruling (2026-08-03) — fail-closed would have refused to compile the
380
625
  // spec that shipped green. Fails open when git cannot answer, so non-repo fixtures stay silent.
381
- const repo = trackedAtHead(dirname(file));
626
+ const repo = tasks.some((task) => task.context.length > 0) ? trackedAtHead(dirname(file)) : undefined;
382
627
  if (repo) {
383
628
  const unreachable = [];
384
629
  for (const t of tasks) {
@@ -419,6 +664,8 @@ acceptance is required on every task (a nested list of observable outcomes).
419
664
  Spec front-matter (top-level, before the first task heading):
420
665
  mode: partner-led | risk-based | staff-led — this engagement's routing mode
421
666
  (loses only to an explicit \`run --mode\` flag)
667
+ base: commit-or-ref — optional declared source base; the declaration must begin at column zero
668
+ (runtime enforcement happens before a run)
422
669
 
423
670
  Fields available per task:
424
671
  goal: outcome the task must achieve (defaults to the title if omitted)
@@ -450,6 +697,10 @@ acceptance is required on every task (a nested list of observable outcomes).
450
697
  WHAT MAKES A CRITERION REAL:
451
698
  - "test:" must name a real test asserting on recorded state, journal lines, or drawn frames, and its
452
699
  title must match the criterion string verbatim. It also needs a collectable test path in files[].
700
+ - A "test:" oracle must name a TOP-LEVEL test case, never one nested under describe(). Vitest prefixes
701
+ nested cases with every suite title, while the acceptance gate builds an anchored pattern that
702
+ vitest -t applies against the FULL runner-visible name; the verbatim leaf title then selects ZERO
703
+ tests and the gate parks even though the suite is green. Measured: two parks in one run.
453
704
  - NO criterion may be satisfiable by an absence, a rename, a source-text grep, or an empty collection.
454
705
  "no file references X" is not a criterion — it passes in a repo where the feature was never built.
455
706
  - "goal:" is NEVER verification. Prose in the goal enforces nothing: every obligation needs an
@@ -461,9 +712,31 @@ acceptance is required on every task (a nested list of observable outcomes).
461
712
  correctly-shaped fix that runs SOMETIMES satisfies it. "cleanup is try/finally at all four sites"
462
713
  is satisfied by \`finally { if (cond) cleanup() }\` — the shape is present and the fix is inert in
463
714
  production. Say UNCONDITIONAL, or name the branch that may not exist.
715
+ - Ask of every criterion: COULD THIS BE SATISFIED BY CODE THAT NOTHING OUTSIDE THE TEST SUITE CALLS?
716
+ A criterion naming a CAPABILITY is satisfiable by a stub whose only caller is its own test, and the
717
+ judge cannot catch it — it reads the DIFF, and a stub's hunks are real. Name the PRODUCTION CALLER
718
+ that must exercise the capability, and the path it runs on; "X is supported" ships as dead code.
464
719
  - Enumerating one axis exhaustively is what hides the others. A spec that guards PARTIAL coverage
465
720
  site-by-site, member-by-member, can be defeated wholesale by CONDITIONAL coverage, which leaves
466
721
  every enumeration satisfied. After you enumerate, ask what a single flag would do to the whole set.
722
+ - A criterion that names a behaviour must name the VALUE AT WHICH IT WOULD BREAK. Every criterion is a
723
+ claim about a variable — a status, a width, a count, an arrival time — and if it does not say which
724
+ value of that variable is the hard one, THE TEST WILL CHOOSE THE EASY ONE AND BE GREEN. Measured: a
725
+ grace-window criterion whose tests pinned pane status to "working" when the defect needed "blocked";
726
+ a width criterion tested at 2-cell task ids when the schema permits 64; a fork-budget criterion
727
+ tested only where concurrency <= cores when config accepts every positive integer. Each was
728
+ satisfied exactly as written, and each shipped the defect. State six things per criterion:
729
+ 1. the PRODUCTION ENTRY POINT and the FINAL OBSERVABLE — what a caller invokes, what a reader sees;
730
+ 2. the AUTHORITATIVE SOURCE of each value, never a nearby re-derivation that can disagree with it;
731
+ 3. the QUANTIFIER, or the closed subject set the claim ranges over;
732
+ 4. the VARIABLE whose change could falsify the claim;
733
+ 5. TWO DISCRIMINATING CASES that vary that variable — or the invariance relation, if the claim is
734
+ that varying it changes nothing — drawn from the FULL domain the system accepts, never a
735
+ convenient subrange, because the domain is chosen by whoever must satisfy the criterion;
736
+ 6. the concrete FALSE-CLEAN case: what a passing test would look like if the mechanism were absent.
737
+ A criterion that cannot name (4) is a claim about nothing and its test cannot fail. If (5) has no
738
+ hard value anywhere in the domain, the criterion asserts a universal that may be FALSE ABOUT THE
739
+ WORLD — bound it or say where it stops holding, rather than demanding a value that does not exist.
467
740
 
468
741
  ORDERING AND OWNERSHIP:
469
742
  - Every path has exactly ONE owning task. Two tasks writing one file must be ORDERED by deps, or the
@@ -474,6 +747,15 @@ acceptance is required on every task (a nested list of observable outcomes).
474
747
  mechanism DOES for them; the most dangerous consumer is a file no task owns, because nothing fixes it
475
748
  and no edge can order it.
476
749
 
750
+ EVIDENCE DISCIPLINE THE REVIEWERS ENFORCE (write criteria assuming these; violations are material):
751
+ - A result row whose shape is unknown, mixed, or internally contradictory is MALFORMED and must fail
752
+ closed. Recognizing one discriminator never licenses ignoring the rest of the row; malformed input
753
+ may never normalize to passed or any other gate-satisfying outcome.
754
+ - Preserve machine evidence according to the producer's protocol: never trim, discard, or change
755
+ delimiters before validation. Git path evidence MUST use -z and NUL parsing with bytes preserved;
756
+ inspect timeout, signal, and exit status before interpreting output, because a failed or timed-out
757
+ producer may never normalize to an empty successful result.
758
+
477
759
  "timeout:" IS A KILL CEILING, NOT AN ESTIMATE. Never read a sum of timeouts as a predicted duration.
478
760
  -->
479
761
 
@@ -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,7 +1,12 @@
1
1
  import type { ExecutorDriver, NotifyOpts, Slot } from "./types.js";
2
2
  export declare const MAX_BUF: number;
3
3
  export declare const HERDR_CONTROL_VARS: readonly ["HERDR_ENV", "HERDR_SOCKET_PATH"];
4
- /** Copy of worker env with the fork cap applied and herdr control-plane vars stripped. */
4
+ /**
5
+ * Copy of worker env with the fork cap applied and herdr control-plane vars stripped.
6
+ * The cap is the one the enclosing run resolved (resolvedForkCap) — a worker's suites divide the
7
+ * same machine the gate shells do, so both seams have to read the same run-owned number rather
8
+ * than a flat constant. The operator's own export still wins.
9
+ */
5
10
  export declare function sealHerdrEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
6
11
  /** Pane/login-shell form of the same worker env seal (herdr seed + daemon setup). */
7
12
  export declare function herdrSealShellPrefix(env?: NodeJS.ProcessEnv): string;
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { shq } from "../adapters/types.js";
3
- import { createWorktree, DEFAULT_FORK_CAP, FORK_CAP_ENV } from "../run/git.js";
3
+ import { createWorktree, FORK_CAP_ENV, resolvedForkCap } from "../run/git.js";
4
4
  // HARD-03: cap retained worker output so a chatty worker can't grow tickmarkr's heap unbounded.
5
5
  // 2MB is safe against BOTH consumers: the largest read() call site asks for 1000 lines
6
6
  // (daemon.ts:217/247), and every marker (TICKMARKR_RESULT, TICKMARKR_EXIT_<nonce>) is emitted at the
@@ -13,18 +13,23 @@ export const MAX_BUF = 2 * 1024 * 1024;
13
13
  // (and its own herdr driver CLI calls) keep the live session. Socket path is the wire; HERDR_ENV
14
14
  // is the "I am inside herdr" gate every agent skill checks before mutating panes.
15
15
  export const HERDR_CONTROL_VARS = ["HERDR_ENV", "HERDR_SOCKET_PATH"];
16
- /** Copy of worker env with the fork cap applied and herdr control-plane vars stripped. */
16
+ /**
17
+ * Copy of worker env with the fork cap applied and herdr control-plane vars stripped.
18
+ * The cap is the one the enclosing run resolved (resolvedForkCap) — a worker's suites divide the
19
+ * same machine the gate shells do, so both seams have to read the same run-owned number rather
20
+ * than a flat constant. The operator's own export still wins.
21
+ */
17
22
  export function sealHerdrEnv(env = process.env) {
18
23
  const out = { ...env };
19
24
  if (!(FORK_CAP_ENV in out))
20
- out[FORK_CAP_ENV] = DEFAULT_FORK_CAP;
25
+ out[FORK_CAP_ENV] = resolvedForkCap();
21
26
  for (const k of HERDR_CONTROL_VARS)
22
27
  delete out[k];
23
28
  return out;
24
29
  }
25
30
  /** Pane/login-shell form of the same worker env seal (herdr seed + daemon setup). */
26
31
  export function herdrSealShellPrefix(env = process.env) {
27
- const forkCap = sealHerdrEnv(env)[FORK_CAP_ENV] ?? DEFAULT_FORK_CAP;
32
+ const forkCap = sealHerdrEnv(env)[FORK_CAP_ENV] ?? resolvedForkCap();
28
33
  return `export ${FORK_CAP_ENV}=${shq(forkCap)}; ` +
29
34
  HERDR_CONTROL_VARS.map((k) => `unset ${k}`).join("; ") + "; ";
30
35
  }
@@ -1,5 +1,5 @@
1
1
  import { type WorkerAdapter } from "../adapters/types.js";
2
- import { type Task } from "../graph/schema.js";
2
+ import { type AcceptanceItem, type Task } from "../graph/schema.js";
3
3
  import { type LlmVia } from "./llm.js";
4
4
  import type { GateResult } from "./types.js";
5
5
  export interface EvidenceCitation {
@@ -21,7 +21,27 @@ export interface JudgeVerdict {
21
21
  }>;
22
22
  }
23
23
  export declare function judgeCriterionId(index: number): string;
24
+ export declare function testFilterPattern(name: string): string;
24
25
  export declare function testFiltered(testCmd: string, name: string): string;
26
+ export interface VitestListedTest {
27
+ name: string;
28
+ file: string;
29
+ projectName?: string;
30
+ }
31
+ export type AcceptanceCorpusAuditResult = {
32
+ specPath: string;
33
+ status: "parse-failed";
34
+ error: string;
35
+ } | {
36
+ specPath: string;
37
+ status: "parsed";
38
+ item: AcceptanceItem;
39
+ namedTest?: {
40
+ criterion: string;
41
+ matches: VitestListedTest[];
42
+ };
43
+ };
44
+ export declare function auditAcceptanceCorpus(corpusRoot: string, listedTests: readonly VitestListedTest[]): AcceptanceCorpusAuditResult[];
25
45
  export interface AcceptanceGateOpts {
26
46
  testCmd?: string;
27
47
  diffCap?: number;