tickmarkr 2.7.9 → 2.8.1
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.
- package/dist/adapters/codex.d.ts +1 -1
- package/dist/adapters/codex.js +18 -6
- package/dist/adapters/fake.js +42 -4
- package/dist/cli/commands/approve.js +33 -8
- package/dist/cli/commands/doctor.js +11 -2
- package/dist/cli/commands/verify.js +32 -12
- package/dist/gates/acceptance.d.ts +1 -1
- package/dist/gates/acceptance.js +75 -68
- package/dist/gates/baseline.d.ts +5 -1
- package/dist/gates/baseline.js +31 -6
- package/dist/gates/operation-facts.d.ts +47 -0
- package/dist/gates/operation-facts.js +84 -0
- package/dist/gates/run-gates.js +81 -12
- package/dist/run/daemon.d.ts +77 -13
- package/dist/run/daemon.js +348 -94
- package/dist/run/git.d.ts +21 -4
- package/dist/run/git.js +26 -9
- package/dist/run/launchservices-check.d.ts +23 -1
- package/dist/run/launchservices-check.js +88 -3
- package/dist/run/merge.d.ts +114 -7
- package/dist/run/merge.js +146 -44
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +8 -0
package/dist/gates/acceptance.js
CHANGED
|
@@ -12,6 +12,7 @@ import { classifyVerdictCause } from "./verdict-cause.js";
|
|
|
12
12
|
import { reviewableLogicDiff } from "./artifact-manifest.js";
|
|
13
13
|
import { diffSidePath } from "./git-paths.js";
|
|
14
14
|
import { classifyFailureOutput } from "./baseline.js";
|
|
15
|
+
import { operationFactsAtHead, readOperationFacts } from "./operation-facts.js";
|
|
15
16
|
// Fable F4: acceptance judge shares review's 900s timeout — 300s default killed frontier judges on cap-sized diffs.
|
|
16
17
|
const JUDGE_TIMEOUT_MS = 900_000;
|
|
17
18
|
const CitationSchema = z.object({ path: z.string(), line: z.number().int() });
|
|
@@ -19,9 +20,8 @@ const JudgeVerdictRowSchema = z.object({
|
|
|
19
20
|
criterion: z.string(),
|
|
20
21
|
met: z.boolean(),
|
|
21
22
|
reason: z.string(),
|
|
22
|
-
// Required
|
|
23
|
-
|
|
24
|
-
evidence: z.union([CitationSchema, z.string()]),
|
|
23
|
+
// Required: a row omitting evidence is malformed and fails closed like any other shape violation.
|
|
24
|
+
evidence: CitationSchema,
|
|
25
25
|
});
|
|
26
26
|
const JudgeVerdictSchema = z.object({
|
|
27
27
|
pass: z.boolean(),
|
|
@@ -33,15 +33,7 @@ export function judgeCriterionId(index) {
|
|
|
33
33
|
function renderJudgeCriterion(item, index) {
|
|
34
34
|
return `[${judgeCriterionId(index)}] ${renderAcceptanceItem(item)}`;
|
|
35
35
|
}
|
|
36
|
-
function checkJudgeVerdict(
|
|
37
|
-
const parsed = JudgeVerdictSchema.safeParse(raw);
|
|
38
|
-
if (!parsed.success) {
|
|
39
|
-
return {
|
|
40
|
-
verdict: { pass: false, criteria: [] },
|
|
41
|
-
inconsistencies: ["judge verdict inconsistent: malformed verdict shape"],
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
const v = parsed.data;
|
|
36
|
+
function checkJudgeVerdict(v, expectedIds) {
|
|
45
37
|
const inconsistencies = [];
|
|
46
38
|
const counts = new Map();
|
|
47
39
|
for (const row of v.criteria) {
|
|
@@ -65,7 +57,14 @@ function checkJudgeVerdict(raw, expectedIds) {
|
|
|
65
57
|
if (!v.pass && v.criteria.every((row) => row.met) && inconsistencies.length === 0 && v.criteria.length === expectedIds.length) {
|
|
66
58
|
inconsistencies.push("judge verdict inconsistent: pass=false contradicts all criteria met");
|
|
67
59
|
}
|
|
68
|
-
return
|
|
60
|
+
return inconsistencies;
|
|
61
|
+
}
|
|
62
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
63
|
+
// S23: the RAW verdict's citation shape, checked before the schema so a string, null or absent evidence, or a
|
|
64
|
+
// non-array criteria, is a protocol failure rather than a charged red. Reads fields only, never converts one.
|
|
65
|
+
function rawCitationsWellFormed(raw) {
|
|
66
|
+
return Array.isArray(raw.criteria) && raw.criteria.every((row) => isRecord(row) && isRecord(row.evidence)
|
|
67
|
+
&& typeof row.evidence.path === "string" && Number.isSafeInteger(row.evidence.line));
|
|
69
68
|
}
|
|
70
69
|
const isCommand = (a) => typeof a === "object" && a.oracle === "command";
|
|
71
70
|
const isTest = (a) => typeof a === "object" && a.oracle === "test";
|
|
@@ -288,24 +287,20 @@ export function changedLinesByFile(diff) {
|
|
|
288
287
|
set.add(newLine);
|
|
289
288
|
newLine++;
|
|
290
289
|
}
|
|
291
|
-
else if (c !== "-") {
|
|
292
|
-
|
|
290
|
+
else if (c !== "-" && c !== "\\") {
|
|
291
|
+
// S23 (A04): "" annotates the row above it and is skipped — it never ends the
|
|
292
|
+
// hunk, so a replaced last line's `+` row after `-old` and its marker stays citable.
|
|
293
|
+
inHunk = false; // a trailing blank or the next section — hunk body ended
|
|
293
294
|
}
|
|
294
295
|
}
|
|
295
296
|
return byFile;
|
|
296
297
|
}
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1)
|
|
304
|
-
j++;
|
|
305
|
-
parts.push(i === j ? `${sorted[i]}` : `${sorted[i]}-${sorted[j]}`);
|
|
306
|
-
i = j + 1;
|
|
307
|
-
}
|
|
308
|
-
return parts.join(", ");
|
|
298
|
+
// S23: the citable index is one JSON line per changed path between nonce-framed markers. JSON escapes every
|
|
299
|
+
// line terminator but U+2028/U+2029, which are written as their six-character ASCII escapes, so no name can end
|
|
300
|
+
// an entry line, forge a marker or a heading, and JSON.parse of the line gives the name back exactly.
|
|
301
|
+
function citableIndexEntry(path, lines) {
|
|
302
|
+
return JSON.stringify({ path, lines: [...lines].sort((a, b) => a - b) })
|
|
303
|
+
.replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
|
|
309
304
|
}
|
|
310
305
|
// Queue rows 128/129: "\n"-only line semantics — the m flag would also split a line at U+2028/U+2029.
|
|
311
306
|
const DIFF_SECTIONS = /(?<=^|\n)(?=diff --git )/;
|
|
@@ -315,17 +310,17 @@ function deletedPath(section) {
|
|
|
315
310
|
return null;
|
|
316
311
|
const oldPath = /(?<=^|\n)--- ([^\r\n]+)/.exec(section)?.[1]
|
|
317
312
|
?? /(?<=^|\n)Binary files ([^\r\n]+) and \/dev\/null differ(?=[\r\n]|$)/.exec(section)?.[1];
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const unquoted = oldPath.startsWith('"') && oldPath.endsWith('"') ? oldPath.slice(1, -1) : oldPath;
|
|
321
|
-
return unquoted.replace(/^a\//, "");
|
|
313
|
+
// S23 (A05): decoded by the shared side reader (C-quoting, the tab after a spaced name), as artifact-manifest does.
|
|
314
|
+
return oldPath ? diffSidePath(oldPath) : null;
|
|
322
315
|
}
|
|
323
316
|
// v1.82 T1 clause 7: the two shapes this task creates carry no changed hunk, so a judge asked to cite a
|
|
324
317
|
// changed line has nothing to cite — three review rounds died here. Each yields ONE citable operation
|
|
325
|
-
// fact, `{path, line: 0}`, read back from the judged text itself.
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
318
|
+
// fact, `{path, line: 0}`, read back from the judged text itself. Queue row 142 adds the three typed git
|
|
319
|
+
// facts (operation-facts.ts: a pure rename, an empty add, a mode-only change), measured from NUL-delimited
|
|
320
|
+
// raw output and never from this text. Only these RECORDED facts make line 0 citable; a zero line on any
|
|
321
|
+
// other path is still rejected as fabricated. Ordinary whole-file deletions stay uncitable deliberately:
|
|
322
|
+
// the relevance filter collapses them before this runs, and repairing that pre-existing limitation is
|
|
323
|
+
// outside this contract.
|
|
329
324
|
const OPERATION_FACT_LINE = 0;
|
|
330
325
|
function operationFactPaths(diff) {
|
|
331
326
|
return diff.split(DIFF_SECTIONS).flatMap((section) => {
|
|
@@ -336,12 +331,9 @@ function operationFactPaths(diff) {
|
|
|
336
331
|
return path && isProtectedEvidence(path) ? [path] : [];
|
|
337
332
|
});
|
|
338
333
|
}
|
|
339
|
-
// A citation is valid evidence iff the
|
|
340
|
-
// changed-hunk span, not exact-added-line)
|
|
341
|
-
|
|
342
|
-
function citesChangedLocation(evidence, changed, diff) {
|
|
343
|
-
if (typeof evidence === "string")
|
|
344
|
-
return evidence.trim().length > 0 && diff.includes(evidence);
|
|
334
|
+
// A citation is valid evidence iff its pair is in the changed map: a line inside a changed hunk of the cited file
|
|
335
|
+
// (OBS-129: changed-hunk span, not exact-added-line) or line 0 of a recorded operation fact.
|
|
336
|
+
function citesChangedLocation(evidence, changed) {
|
|
345
337
|
return changed.get(evidence.path)?.has(evidence.line) ?? false;
|
|
346
338
|
}
|
|
347
339
|
export async function acceptanceGate(task, worktree, baseRef,
|
|
@@ -389,6 +381,9 @@ judge, via, opts = {}) {
|
|
|
389
381
|
const warn = onlyJudge
|
|
390
382
|
? "WARNING: only judge oracles — no deterministic command/test oracle guards this task (deterministic preferred, spec §2).\n"
|
|
391
383
|
: "";
|
|
384
|
+
// queue row 142: the typed facts are bound to the commit HEAD resolves to BEFORE the judged diff is read,
|
|
385
|
+
// and kept below only while HEAD still resolves to it after that read (fetchTaskDiff reads HEAD itself).
|
|
386
|
+
const typed = await readOperationFacts(worktree, baseRef);
|
|
392
387
|
const fetched = await fetchTaskDiff(worktree, baseRef);
|
|
393
388
|
const diff = reviewableLogicDiff(fetched.full);
|
|
394
389
|
const diffCap = opts.diffCap ?? DEFAULT_DIFF_CAP;
|
|
@@ -400,8 +395,9 @@ judge, via, opts = {}) {
|
|
|
400
395
|
// citation in a real changed line instead of miscounting. Validated below against this same `changed`.
|
|
401
396
|
const changed = changedLinesByFile(diff);
|
|
402
397
|
// clause 7: fold the recorded operation facts into the same index the validator checks, so a change
|
|
403
|
-
// made only of set-aside captures
|
|
404
|
-
|
|
398
|
+
// made only of set-aside captures, removed protected evidence or a typed git fact still yields citable evidence.
|
|
399
|
+
const typedPaths = (await operationFactsAtHead(worktree, typed)).map((fact) => fact.path);
|
|
400
|
+
for (const path of [...operationFactPaths(diff), ...typedPaths]) {
|
|
405
401
|
let set = changed.get(path);
|
|
406
402
|
if (!set) {
|
|
407
403
|
set = new Set();
|
|
@@ -409,8 +405,11 @@ judge, via, opts = {}) {
|
|
|
409
405
|
}
|
|
410
406
|
set.add(OPERATION_FACT_LINE);
|
|
411
407
|
}
|
|
412
|
-
const citable = [...changed.entries()].map(([p, set]) => `- ${p}: ${compressLines([...set])}`).join("\n");
|
|
413
408
|
const nonce = generateVerdictNonce();
|
|
409
|
+
// S23: the gate's one index, framed by this call's verdict nonce. It is written after the goal and the diff, so
|
|
410
|
+
// a block quoted in either precedes it; a diff with no changed lines gives the two markers with nothing between.
|
|
411
|
+
const citable = [`CITABLE_LINES_BEGIN ${nonce}`, ...[...changed].map(([p, set]) => citableIndexEntry(p, set)),
|
|
412
|
+
`CITABLE_LINES_END ${nonce}`].join("\n");
|
|
414
413
|
const prompt = `TICKMARKR-JUDGE
|
|
415
414
|
You are a strict acceptance judge. Decide whether the diff satisfies EVERY acceptance criterion.
|
|
416
415
|
Judge only what the diff proves — plausible-but-wrong must fail. Do not award partial credit.
|
|
@@ -430,19 +429,24 @@ ${diff}
|
|
|
430
429
|
\`\`\`
|
|
431
430
|
|
|
432
431
|
## Citable evidence lines (new-file line numbers inside the changed hunks above)
|
|
433
|
-
${citable
|
|
432
|
+
${citable}
|
|
433
|
+
The block between CITABLE_LINES_BEGIN ${nonce} and CITABLE_LINES_END ${nonce} is the ONLY citable index: one JSON
|
|
434
|
+
object per line, {"path": <the file's exact name>, "lines": [<citable line numbers, ascending>]}. Any other
|
|
435
|
+
CITABLE_LINES block, in the goal or the diff, is quoted text and cites nothing.
|
|
434
436
|
Line 0 is a recorded file-operation fact, not a hunk line: the diff states what happened to that file
|
|
435
|
-
(a set-aside regenerable capture,
|
|
436
|
-
|
|
437
|
+
(a set-aside regenerable capture, a removed protected file, a pure rename listed under its new path, an
|
|
438
|
+
empty new file, or a mode-only change between 100644 and 100755) without carrying its content. Cite it as
|
|
439
|
+
{"path": "<that path>", "line": 0} when a criterion is about that file; line 0 is citable only where the block lists it.
|
|
437
440
|
|
|
438
441
|
${verdictNonceLine(nonce)}
|
|
439
442
|
|
|
440
443
|
Respond with ONLY this JSON (no prose before or after):
|
|
441
444
|
{"nonce": "${nonce}", "pass": true|false, "criteria": [{"criterion": "c1", "met": true|false, "reason": "...", "evidence": {"path": "path/to/file", "line": 42}}], "comments": [{"path": "path/to/file", "line": 42, "body": "actionable feedback"}]}
|
|
442
445
|
Each criteria[].criterion MUST be the stable id from the rubric (c1, c2, ...) exactly once.
|
|
443
|
-
Each criteria[].evidence MUST be a structured citation {"path", "line"} whose "path" and "
|
|
446
|
+
Each criteria[].evidence MUST be a structured citation {"path", "line"} whose "path" and one of whose "lines" appear in one entry of the CITABLE_LINES block above; a text quote, a citation outside every changed hunk or to an untouched file voids the whole verdict.
|
|
444
447
|
The top-level comments array is optional. Use it only for actionable line-anchored feedback.
|
|
445
448
|
`;
|
|
449
|
+
const judgeKey = channelKey({ adapter: judge.adapter.id, model: judge.model });
|
|
446
450
|
// OBS-1182: an unset seat passes no effort argument at all — the CLI default, and the call shape callers pin.
|
|
447
451
|
let raw;
|
|
448
452
|
try {
|
|
@@ -455,50 +459,53 @@ The top-level comments array is optional. Use it only for actionable line-anchor
|
|
|
455
459
|
throw error;
|
|
456
460
|
// OBS-1168(b): no seat, no verdict — run-gates re-routes the judge or parks infra; never a worker charge.
|
|
457
461
|
return { gate: "acceptance", pass: false, details: `${warn}${detBlock}judge dispatch failed — ${error.message} — failing closed`,
|
|
458
|
-
meta: { unparseable: true, cause: "seat-launch-failed", judge:
|
|
462
|
+
meta: { unparseable: true, cause: "seat-launch-failed", judge: judgeKey } };
|
|
459
463
|
}
|
|
460
464
|
const extracted = extractVerdictJson(raw, nonce);
|
|
461
465
|
if (!extracted) {
|
|
462
466
|
const cause = classifyVerdictCause(raw, nonce, "pass");
|
|
463
467
|
// GATE-09: structured meta names the flaked judge channel (mirrors review.ts:99 meta precedent) so
|
|
464
468
|
// run-gates can retry the judge on a failover channel without string-matching details (D-03).
|
|
465
|
-
// The parsed-verdict paths below are untouched — the flake signal is exactly extractJson→null here.
|
|
466
469
|
const failure = cause === "malformed-verdict"
|
|
467
470
|
? "judge output unparseable — failing closed"
|
|
468
471
|
: "judge dispatch failed — no structurally valid nonce-bound response; output unparseable — failing closed";
|
|
469
472
|
return { gate: "acceptance", pass: false,
|
|
470
473
|
details: warn + detBlock + failure,
|
|
471
|
-
meta: { unparseable: true, cause, judge:
|
|
474
|
+
meta: { unparseable: true, cause, judge: judgeKey } };
|
|
472
475
|
}
|
|
473
|
-
|
|
474
|
-
//
|
|
475
|
-
// the
|
|
476
|
-
//
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
if (
|
|
481
|
-
return
|
|
482
|
-
|
|
483
|
-
|
|
476
|
+
// S23: every protocol failure — the raw citation shape, the schema, or a citation outside `changed` (the exact
|
|
477
|
+
// index embedded in the prompt above, never the worktree) — is one malformed verdict: unparseable, so GATE-09
|
|
478
|
+
// retries the judge on a failover channel, and named by THIS call's own dispatch arguments, never by model
|
|
479
|
+
// output. The diagnostics are constant: no model value is converted to text, so none can throw here.
|
|
480
|
+
const malformed = (why) => ({ gate: "acceptance", pass: false,
|
|
481
|
+
details: `${warn}${detBlock}${why} — treating as unparseable, failing closed`,
|
|
482
|
+
meta: { unparseable: true, cause: "malformed-verdict", judge: judgeKey } });
|
|
483
|
+
if (!rawCitationsWellFormed(extracted))
|
|
484
|
+
return malformed("judge verdict malformed: malformed verdict shape (each criterion's evidence must be a {path, line} citation)");
|
|
485
|
+
const parsed = JudgeVerdictSchema.safeParse(extracted);
|
|
486
|
+
if (!parsed.success)
|
|
487
|
+
return malformed("judge verdict malformed: malformed verdict shape");
|
|
488
|
+
const v = parsed.data;
|
|
489
|
+
if (!v.criteria.every((row) => citesChangedLocation(row.evidence, changed))) {
|
|
490
|
+
return malformed("judge verdict cites evidence absent from the judged diff");
|
|
484
491
|
}
|
|
492
|
+
const inconsistencies = checkJudgeVerdict(v, expectedIds);
|
|
485
493
|
const pass = v.pass === true && inconsistencies.length === 0 && v.criteria.every((row) => row.met);
|
|
486
494
|
const lines = v.criteria.map((row) => `${row.met ? "✓" : "✗"} ${row.criterion}: ${row.reason}`);
|
|
487
495
|
if (!v.pass)
|
|
488
496
|
lines.push("judge verdict pass=false");
|
|
489
497
|
lines.push(...inconsistencies);
|
|
490
498
|
const prose = warn + detBlock + (lines.join("\n") || "judge passed");
|
|
491
|
-
// OBS-1151: each criterion's canonical text, ruling and the
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
// a comparable subject, and as an adjudicator it can confirm nothing — run-gates parks it infra.
|
|
499
|
+
// OBS-1151: each criterion's canonical text, ruling and the path its evidence cites. An inconsistent verdict
|
|
500
|
+
// (a duplicate row ruling both ways, a missing id) carries NO judgment: it is not a comparable subject, and as
|
|
501
|
+
// an adjudicator it can confirm nothing — run-gates parks it infra.
|
|
495
502
|
const judgment = inconsistencies.length ? undefined : v.criteria.flatMap((row) => {
|
|
496
503
|
const index = expectedIds.indexOf(row.criterion);
|
|
497
504
|
if (index < 0)
|
|
498
505
|
return [];
|
|
499
506
|
return [{ id: row.criterion, criterion: renderAcceptanceItem(judgeItems[index]), met: row.met,
|
|
500
|
-
paths:
|
|
507
|
+
paths: [row.evidence.path] }];
|
|
501
508
|
});
|
|
502
509
|
return { gate: "acceptance", pass, details: appendAnchoredReview(prose, extracted),
|
|
503
|
-
meta: { judge:
|
|
510
|
+
meta: { judge: judgeKey, ...(judgment ? { judgment } : {}) } };
|
|
504
511
|
}
|
package/dist/gates/baseline.d.ts
CHANGED
|
@@ -179,6 +179,8 @@ export interface BaselineWarning {
|
|
|
179
179
|
commands: string[];
|
|
180
180
|
reason: string;
|
|
181
181
|
}
|
|
182
|
+
/** D1's signal vocabulary, one copy for the gate battery and the tip (the daemon keeps its own). */
|
|
183
|
+
export declare const SIGNAL_EXIT_RE: RegExp;
|
|
182
184
|
export type FailureClassification = "regression" | "infra";
|
|
183
185
|
/**
|
|
184
186
|
* What a nonzero runner exit is evidence OF. `undefined` when the output names neither — the
|
|
@@ -207,6 +209,8 @@ export interface FreshFailureVerdict {
|
|
|
207
209
|
why?: string;
|
|
208
210
|
/** The one shared forgiveness decision both readers extend: the entry recorded a red VERDICT. */
|
|
209
211
|
baselineRed: boolean;
|
|
212
|
+
/** S19m4: the first kill line, when the caller read kill text (the test gate alone). A forgiven red beside one is infra. */
|
|
213
|
+
killLine?: string;
|
|
210
214
|
}
|
|
211
215
|
/** The red reason a missing or unreadable entry owes in BOTH readers; undefined for every other state. */
|
|
212
216
|
export declare function baselineEntryRefusal(gate: string, verdict: Pick<FreshFailureVerdict, "state" | "why">): string | undefined;
|
|
@@ -224,7 +228,7 @@ export declare function baselineEntryRefusal(gate: string, verdict: Pick<FreshFa
|
|
|
224
228
|
* forgiven, and a header printed under a realpath variant of the cwd compares as a different header.
|
|
225
229
|
* f is fresh when the candidate repeats it more often than the capture recorded it.
|
|
226
230
|
*/
|
|
227
|
-
export declare function freshFailures(entry: BaselineCommand | undefined, raw: string): FreshFailureVerdict;
|
|
231
|
+
export declare function freshFailures(entry: BaselineCommand | undefined, raw: string, readKillText?: boolean): FreshFailureVerdict;
|
|
228
232
|
export type PackageManager = "npm" | "pnpm" | "yarn" | "bun";
|
|
229
233
|
/**
|
|
230
234
|
* Operator directive 2026-08-12 (D-OBS-10): tickmarkr is package-manager agnostic.
|
package/dist/gates/baseline.js
CHANGED
|
@@ -387,6 +387,17 @@ const namesRegression = (l) => (isFailureShaped(l) || ERROR_CLASS_RE.test(l)) &&
|
|
|
387
387
|
// This does not make the vocabulary open-ended: INFRA_RE is still the one closed list, and
|
|
388
388
|
// classifyFailureOutput's ERROR_CLASS_RE/namesFailure vetoes still decide mixed lines.
|
|
389
389
|
const isFingerprintShaped = (l) => isFailureShaped(l) || INFRA_RE.test(l);
|
|
390
|
+
/** D1's signal vocabulary, one copy for the gate battery and the tip (the daemon keeps its own). */
|
|
391
|
+
export const SIGNAL_EXIT_RE = /\b(?:SIGTERM|SIGKILL|signal\s+(?:9|15)|exit(?:s|ed|\s+code)?\s+(?:137|143))\b/i;
|
|
392
|
+
/**
|
|
393
|
+
* S19m4: the first kill line of in-memory runner output — outside Vitest echo blocks, ANSI-stripped,
|
|
394
|
+
* read in its turbo-stripped form: it names a signal exit, is not failure-shaped, is neither a pass nor an
|
|
395
|
+
* operator line, and is not a script echo (`> ` — npm and pnpm print one before every script they run,
|
|
396
|
+
* nested ones included, and the echo is the command text, never kill text).
|
|
397
|
+
*/
|
|
398
|
+
const firstKillLine = (output) => withoutVitestEchoBlocks(output)
|
|
399
|
+
.map((l) => { const clean = l.replace(ANSI_RE, ""); return stripTurboPrefix(clean) ?? clean; })
|
|
400
|
+
.find((l) => SIGNAL_EXIT_RE.test(l) && !isFingerprintShaped(l) && !PASS_LINE_RE.test(l) && !OPERATOR_LINE_RE.test(l) && !l.startsWith("> "));
|
|
390
401
|
/**
|
|
391
402
|
* What a nonzero runner exit is evidence OF. `undefined` when the output names neither — the
|
|
392
403
|
* unreadable-runner case the existing fail-closed path already owns.
|
|
@@ -663,7 +674,10 @@ const rejects = (f) => !FAIL_ANCHOR_RE.test(f) || f.startsWith("FAIL ");
|
|
|
663
674
|
* forgiven, and a header printed under a realpath variant of the cwd compares as a different header.
|
|
664
675
|
* f is fresh when the candidate repeats it more often than the capture recorded it.
|
|
665
676
|
*/
|
|
666
|
-
export function freshFailures(entry, raw) {
|
|
677
|
+
export function freshFailures(entry, raw, readKillText = false) {
|
|
678
|
+
// S19m4: kill text is read here, before table T, from the in-memory output — never the redacted tail.
|
|
679
|
+
const killLine = readKillText ? firstKillLine(raw) : undefined;
|
|
680
|
+
const kill = killLine !== undefined ? { killLine } : {};
|
|
667
681
|
const { state, why } = entryState(entry);
|
|
668
682
|
const baselineRed = (state === "legacy" || state === "v2") && (entry.exitCode ?? 1) !== 0;
|
|
669
683
|
if (state === "v2") {
|
|
@@ -682,7 +696,7 @@ export function freshFailures(entry, raw) {
|
|
|
682
696
|
unreadable: seen.has(UNRECOGNIZED_FAILURE),
|
|
683
697
|
forgiven: [...new Set(occurrences.filter((o) => o.unattributed !== true && seen.get(o.id) <= (recorded.get(o.id) ?? 0)).map((o) => o.id))],
|
|
684
698
|
failingLines: fresh.map((o) => o.line),
|
|
685
|
-
state, baselineRed,
|
|
699
|
+
state, baselineRed, ...kill,
|
|
686
700
|
};
|
|
687
701
|
}
|
|
688
702
|
// OBS-534 (T2): an infra-recorded entry is a KILL, not a verdict — whatever fingerprints it carries
|
|
@@ -693,7 +707,7 @@ export function freshFailures(entry, raw) {
|
|
|
693
707
|
const fresh = current.filter((f) => !known.has(f) && rejects(f) && f !== UNRECOGNIZED_FAILURE);
|
|
694
708
|
// OBS-1123: the baseline-recorded half, named so a reader never has to infer it from the fresh half.
|
|
695
709
|
return { failing: fresh, unreadable: current.includes(UNRECOGNIZED_FAILURE),
|
|
696
|
-
forgiven: current.filter((f) => known.has(f)), failingLines: fresh, state, ...(why ? { why } : {}), baselineRed };
|
|
710
|
+
forgiven: current.filter((f) => known.has(f)), failingLines: fresh, state, ...(why ? { why } : {}), baselineRed, ...kill };
|
|
697
711
|
}
|
|
698
712
|
/**
|
|
699
713
|
* Operator directive 2026-08-12 (D-OBS-10): tickmarkr is package-manager agnostic.
|
|
@@ -1163,8 +1177,8 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
|
|
|
1163
1177
|
// below rather than reading as a verified green. Raise the ceiling by teaching isFailureShaped
|
|
1164
1178
|
// that runner's position rule (leading verdict + identifier, or identifier + separator + trailing
|
|
1165
1179
|
// verdict); loosening back to vocabulary re-opens OBS-278.
|
|
1166
|
-
const verdict = freshFailures(entry, raw);
|
|
1167
|
-
const { failing, unreadable, forgiven, failingLines, baselineRed } = verdict;
|
|
1180
|
+
const verdict = freshFailures(entry, raw, name === "test");
|
|
1181
|
+
const { failing, unreadable, forgiven, failingLines, baselineRed, killLine } = verdict;
|
|
1168
1182
|
// OBS-1123: which baseline-recorded fingerprints this verdict carried, and the capture that
|
|
1169
1183
|
// recorded them. Observational only — nothing below reads it to decide a verdict.
|
|
1170
1184
|
const provenance = baseline.provenance ? { baselineProvenance: baseline.provenance } : {};
|
|
@@ -1268,11 +1282,22 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
|
|
|
1268
1282
|
});
|
|
1269
1283
|
continue;
|
|
1270
1284
|
}
|
|
1285
|
+
// S19m4: a killed test runner completed no suite, so its familiar failures are no verdict to forgive.
|
|
1286
|
+
if (killLine !== undefined) {
|
|
1287
|
+
record({
|
|
1288
|
+
gate: name,
|
|
1289
|
+
pass: false,
|
|
1290
|
+
details: `infra; exit ${r.code}; every failure is recorded in the baseline, but the runner printed kill text — `
|
|
1291
|
+
+ `it never completed a suite, so nothing is forgiven: ${killLine}`,
|
|
1292
|
+
meta: { classification: "infra", infra: true, retryable: false, kind: "signal-exit", forgivenFingerprints: forgiven, ...provenance },
|
|
1293
|
+
});
|
|
1294
|
+
continue;
|
|
1295
|
+
}
|
|
1271
1296
|
record({
|
|
1272
1297
|
gate: name,
|
|
1273
1298
|
pass: true,
|
|
1274
1299
|
details: `exit ${r.code} but only pre-existing failures (forgiven)${unreadable ? " — no failure shape recognized in this output, so a new failure from this runner is invisible to the baseline gate" : ""}`,
|
|
1275
|
-
meta: { ...(classification ? { classification } : {}), forgivenFingerprints: forgiven, ...provenance },
|
|
1300
|
+
meta: { ...(classification ? { classification } : {}), forgivenFingerprints: forgiven, ...provenance, ...(name === "test" ? { killTextRead: true } : {}) },
|
|
1276
1301
|
});
|
|
1277
1302
|
}
|
|
1278
1303
|
return results;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue row 142 (ANL-021 P7, limited to three facts; D-1843, D-1844): a diff made only of a pure rename, an empty new
|
|
3
|
+
* file or a mode-only change carries no changed hunk, so the acceptance judge had nothing to cite. These facts come
|
|
4
|
+
* from TYPED git measurement, read beside the patch text and never from it: no diff --git, rename, mode or new file
|
|
5
|
+
* header line of the patch is ever an authority here, so a file whose content spells those headers creates nothing.
|
|
6
|
+
*
|
|
7
|
+
* Measurement: the worktree HEAD is resolved to one commit, then tickmarkr's own-git runner reads
|
|
8
|
+
* `git diff --raw -z --no-abbrev -M <baseRef>..<commit>` with stdout captured as BYTES. -M forces rename detection
|
|
9
|
+
* whatever diff.renames says in repository, global or inherited (GIT_CONFIG_*) configuration, and no copy option is
|
|
10
|
+
* passed; nothing else is configured. -z makes git print every path verbatim (never C-quoted, so core.quotePath is
|
|
11
|
+
* irrelevant), and the bytes are split on NUL before any decoding, so no name can move a record boundary.
|
|
12
|
+
*
|
|
13
|
+
* Closed record grammar, every field NUL-terminated:
|
|
14
|
+
* ':' oldmode SP newmode SP oldhash SP newhash SP status NUL path NUL [path NUL — exactly when status is R or C]
|
|
15
|
+
* modes are six octal digits, hashes 40 or 64 lowercase hex, status one of A C D M R T U X, and R and C (only they)
|
|
16
|
+
* carry exactly three score digits. A header outside the grammar, an empty path or a truncated record ends the read
|
|
17
|
+
* with NO facts at all. Each path is decoded by a FATAL UTF-8 decoder (BOM kept): a name that is not valid UTF-8
|
|
18
|
+
* makes its record no fact, so a lossy U+FFFD rendering can never authorize a valid U+FFFD-named file beside it.
|
|
19
|
+
*
|
|
20
|
+
* The closed fact table — each fact is line 0 of its exact path:
|
|
21
|
+
* pure rename R100, equal blob ids and equal modes; the fact names the destination (never the source)
|
|
22
|
+
* empty add A whose blob id equals this repository's empty blob (git hash-object -t blob /dev/null)
|
|
23
|
+
* mode-only change M, equal blob ids, modes 100644 and 100755 exchanged
|
|
24
|
+
* Every other record is no fact: binary adds and modifications (git's binary decision follows worker-writable
|
|
25
|
+
* attributes, and the judge cannot read the content), deletions (an empty file's included), renames below 100 or with
|
|
26
|
+
* a mode change, copies, type changes and every other status. A non-zero or killed read gives no facts, and so does a
|
|
27
|
+
* HEAD that resolves to another commit after the judged diff was read (operationFactsAtHead).
|
|
28
|
+
*/
|
|
29
|
+
export type OperationFactKind = "pure rename" | "empty add" | "mode-only change";
|
|
30
|
+
export interface OperationFact {
|
|
31
|
+
kind: OperationFactKind;
|
|
32
|
+
path: string;
|
|
33
|
+
}
|
|
34
|
+
export type OperationFactsRead = {
|
|
35
|
+
ok: true;
|
|
36
|
+
commit: string;
|
|
37
|
+
facts: OperationFact[];
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
error: string;
|
|
41
|
+
};
|
|
42
|
+
/** The facts in raw `-z` output, or null when any record falls outside the closed grammar. */
|
|
43
|
+
export declare function parseOperationFacts(raw: Buffer, emptyBlob: string): OperationFact[] | null;
|
|
44
|
+
/** Reads the typed operation facts of `<baseRef>..HEAD`, bound to the commit HEAD resolved to. Never throws. */
|
|
45
|
+
export declare function readOperationFacts(worktree: string, baseRef: string): Promise<OperationFactsRead>;
|
|
46
|
+
/** The read's facts only while HEAD still resolves to the commit they measured; call it after the judged diff is read. */
|
|
47
|
+
export declare function operationFactsAtHead(worktree: string, read: OperationFactsRead): Promise<OperationFact[]>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { shq } from "../adapters/types.js";
|
|
2
|
+
import { shGit } from "../run/git.js";
|
|
3
|
+
const RECORD_HEADER = /^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]{40}|[0-9a-f]{64}) ([0-9a-f]{40}|[0-9a-f]{64}) ([RC][0-9]{3}|[ADMTUX])$/;
|
|
4
|
+
const EXECUTABLE_EXCHANGE = new Set(["100644", "100755"]);
|
|
5
|
+
function decodeName(bytes) {
|
|
6
|
+
try {
|
|
7
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** The facts in raw `-z` output, or null when any record falls outside the closed grammar. */
|
|
14
|
+
export function parseOperationFacts(raw, emptyBlob) {
|
|
15
|
+
const fields = [];
|
|
16
|
+
for (let start = 0; start < raw.length;) {
|
|
17
|
+
const end = raw.indexOf(0, start);
|
|
18
|
+
if (end < 0)
|
|
19
|
+
return null; // a field git did not terminate: truncated output
|
|
20
|
+
fields.push(raw.subarray(start, end));
|
|
21
|
+
start = end + 1;
|
|
22
|
+
}
|
|
23
|
+
const facts = [];
|
|
24
|
+
for (let i = 0; i < fields.length;) {
|
|
25
|
+
// latin1 maps each byte to one code point, so a non-ASCII byte can never satisfy the ASCII header grammar
|
|
26
|
+
const header = RECORD_HEADER.exec(fields[i].toString("latin1"));
|
|
27
|
+
if (!header)
|
|
28
|
+
return null;
|
|
29
|
+
const [, oldMode, newMode, oldId, newId, status] = header;
|
|
30
|
+
const count = status[0] === "R" || status[0] === "C" ? 2 : 1;
|
|
31
|
+
const paths = fields.slice(i + 1, i + 1 + count);
|
|
32
|
+
if (paths.length !== count || paths.some((path) => path.length === 0))
|
|
33
|
+
return null;
|
|
34
|
+
i += 1 + count;
|
|
35
|
+
const names = paths.map(decodeName);
|
|
36
|
+
if (names.some((name) => name === null))
|
|
37
|
+
continue;
|
|
38
|
+
const path = names[count - 1];
|
|
39
|
+
if (status === "R100" && oldId === newId && oldMode === newMode)
|
|
40
|
+
facts.push({ kind: "pure rename", path });
|
|
41
|
+
else if (status === "A" && newId === emptyBlob)
|
|
42
|
+
facts.push({ kind: "empty add", path });
|
|
43
|
+
else if (status === "M" && oldId === newId && oldMode !== newMode && EXECUTABLE_EXCHANGE.has(oldMode) && EXECUTABLE_EXCHANGE.has(newMode)) {
|
|
44
|
+
facts.push({ kind: "mode-only change", path });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return facts;
|
|
48
|
+
}
|
|
49
|
+
async function resolveHead(worktree) {
|
|
50
|
+
const head = await shGit("git rev-parse --verify --end-of-options 'HEAD^{commit}'", worktree);
|
|
51
|
+
return head.code === 0 && !head.timedOut ? head.stdout.trim() : null;
|
|
52
|
+
}
|
|
53
|
+
/** Reads the typed operation facts of `<baseRef>..HEAD`, bound to the commit HEAD resolved to. Never throws. */
|
|
54
|
+
export async function readOperationFacts(worktree, baseRef) {
|
|
55
|
+
try {
|
|
56
|
+
const commit = await resolveHead(worktree);
|
|
57
|
+
if (!commit)
|
|
58
|
+
return { ok: false, error: "HEAD did not resolve to a commit" };
|
|
59
|
+
// --no-filters: the empty blob is git's, never a clean filter's output under a worker-written attribute
|
|
60
|
+
const empty = await shGit("git hash-object -t blob --no-filters /dev/null", worktree);
|
|
61
|
+
if (empty.code !== 0 || empty.timedOut)
|
|
62
|
+
return { ok: false, error: `git hash-object exited ${empty.code}` };
|
|
63
|
+
const raw = await shGit(`git diff --raw -z --no-abbrev -M ${shq(`${baseRef}..${commit}`)} --`, worktree, undefined, {}, { bytes: true });
|
|
64
|
+
if (raw.code !== 0 || raw.signalExit || raw.timedOut || !raw.stdoutBytes) {
|
|
65
|
+
return { ok: false, error: `git diff --raw exited ${raw.code}${raw.signalExit ? " (killed)" : ""}` };
|
|
66
|
+
}
|
|
67
|
+
const facts = parseOperationFacts(raw.stdoutBytes, empty.stdout.trim());
|
|
68
|
+
return facts ? { ok: true, commit, facts } : { ok: false, error: "a raw diff record is outside the closed grammar" };
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** The read's facts only while HEAD still resolves to the commit they measured; call it after the judged diff is read. */
|
|
75
|
+
export async function operationFactsAtHead(worktree, read) {
|
|
76
|
+
if (!read.ok)
|
|
77
|
+
return [];
|
|
78
|
+
try {
|
|
79
|
+
return (await resolveHead(worktree)) === read.commit ? read.facts : [];
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|