tickmarkr 2.7.9 → 2.8.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.
- 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/doctor.js +11 -2
- package/dist/cli/commands/verify.js +9 -1
- 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 +20 -11
- package/dist/run/git.d.ts +5 -1
- package/dist/run/git.js +7 -3
- package/dist/run/launchservices-check.d.ts +23 -1
- package/dist/run/launchservices-check.js +88 -3
- package/dist/run/merge.js +19 -10
- package/package.json +1 -1
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export declare function readCodexModelsCache(path?: string): {
|
|
|
5
5
|
retirements?: Record<string, ModelRetirement | null>;
|
|
6
6
|
hidden?: string[];
|
|
7
7
|
};
|
|
8
|
-
export declare const CODEX_GIT_GRANT = "-c \"sandbox_workspace_write.writable_roots=[$(tkr_common=$(git rev-parse --path-format=absolute --git-common-dir)
|
|
8
|
+
export declare const CODEX_GIT_GRANT = "-c \"sandbox_workspace_write.writable_roots=[$(tkr_common=$(git rev-parse --path-format=absolute --git-common-dir && echo /) && tkr_gitdir=$(git rev-parse --absolute-git-dir && echo /) && tkr_common=${tkr_common%?/} && tkr_gitdir=${tkr_gitdir%?/} && { tkr_head=$(git symbolic-ref -q HEAD); [ $? -le 1 ]; } && case $tkr_head in (refs/heads/*) tkr_leaf=${tkr_head%/*};; (*) tkr_leaf=;; esac && [ \"$(printf %s \"$tkr_common$tkr_gitdir$tkr_leaf\" | LC_ALL=C tr -d '\\0-\\37\\177\"\\\\')\" = \"$tkr_common$tkr_gitdir$tkr_leaf\" ] && { [ \"$tkr_gitdir\" = \"$tkr_common\" ] || printf '\"%s\",' \"$tkr_gitdir\"; } && printf '\"%s/objects\"' \"$tkr_common\" && { [ -z \"$tkr_leaf\" ] || printf ',\"%s/%s\",\"%s/logs/%s\"' \"$tkr_common\" \"$tkr_leaf\" \"$tkr_common\" \"$tkr_leaf\"; })]\"";
|
|
9
9
|
export declare const CODEX_TRUST_DIALOG: TrustDialog;
|
|
10
10
|
export declare const CODEX_INPUT_BOX: import("./types.js").InputBox;
|
|
11
11
|
export declare function seedCodexTrust(repoRoot: string, configPath?: string): TrustVerdict;
|
package/dist/adapters/codex.js
CHANGED
|
@@ -106,13 +106,25 @@ function codexRetirement(upgrade) {
|
|
|
106
106
|
// worktree cwd, for Codex WORKERS only: v2.6.7 T4 (D-1046) — invoke() (the headless worker form, including
|
|
107
107
|
// the interactive size fallback) and interactiveCommand carry it; headlessCommand, which judge, review,
|
|
108
108
|
// consult and the model probe launch, carries no writable_roots at all (those seats never commit).
|
|
109
|
-
// v2.6.6 T9 (K, D-912): the grant is
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
109
|
+
// v2.6.6 T9 (K, D-912) → v2.8.0 T1 (rows 17/18, D-910): the grant is a LEAF of what a linked-worktree branch
|
|
110
|
+
// commit writes, never the shared hooks/config (the shipped [common] and D-910's [common, gitdir] let a seat
|
|
111
|
+
// write <common>/hooks and <common>/config, which unsandboxed git later runs) and never whole common/refs and
|
|
112
|
+
// common/logs (which let a seat move main, any branch or tag, or the stash). The closed root table, in order:
|
|
113
|
+
// - the worktree's own gitdir, only when it differs from the common dir (an ordinary checkout gets none);
|
|
114
|
+
// - <common>/objects;
|
|
115
|
+
// - only when HEAD is a symbolic ref refs/heads/<B>: <common>/refs/heads/<D> and <common>/logs/refs/heads/<D>,
|
|
116
|
+
// <D> being <B> without its last slash-separated segment (refs/heads itself when <B> holds no slash);
|
|
117
|
+
// a detached HEAD gets no refs or reflog root.
|
|
118
|
+
// A directory outside any repository, any failed git read, or any root holding a character of the closed
|
|
119
|
+
// forbidden set (U+0000 to U+001F, U+007F DEL, the double quote, the backslash) yields the EMPTY array `[]`:
|
|
120
|
+
// no git write root, so a commit fails closed — never a partial array, an escaped string or codex's
|
|
121
|
+
// literal-string fallback. Every emitted root is therefore a TOML basic string that parses back to the exact
|
|
122
|
+
// path (git's own trailing newline is stripped through a sentinel, never a path's). No common-root fallback;
|
|
123
|
+
// packed-refs is not granted (packed-ref ops fail closed); stash, tags, notes and branches outside the leaf
|
|
124
|
+
// are denied. Declared: sibling refs in the same leaf (other task branches, the run's integration branch
|
|
125
|
+
// under the default prefix) stay writable until 2.8.1's integration identity.
|
|
114
126
|
// doctor's probe (codex-commit-check.ts) executes these exact bytes; it never rebuilds the grant.
|
|
115
|
-
export const CODEX_GIT_GRANT = `-c "sandbox_workspace_write.writable_roots=[$(tkr_common=$(git rev-parse --path-format=absolute --git-common-dir)
|
|
127
|
+
export const CODEX_GIT_GRANT = `-c "sandbox_workspace_write.writable_roots=[$(tkr_common=$(git rev-parse --path-format=absolute --git-common-dir && echo /) && tkr_gitdir=$(git rev-parse --absolute-git-dir && echo /) && tkr_common=\${tkr_common%?/} && tkr_gitdir=\${tkr_gitdir%?/} && { tkr_head=$(git symbolic-ref -q HEAD); [ $? -le 1 ]; } && case $tkr_head in (refs/heads/*) tkr_leaf=\${tkr_head%/*};; (*) tkr_leaf=;; esac && [ "$(printf %s "$tkr_common$tkr_gitdir$tkr_leaf" | LC_ALL=C tr -d '\\0-\\37\\177"\\\\')" = "$tkr_common$tkr_gitdir$tkr_leaf" ] && { [ "$tkr_gitdir" = "$tkr_common" ] || printf '"%s",' "$tkr_gitdir"; } && printf '"%s/objects"' "$tkr_common" && { [ -z "$tkr_leaf" ] || printf ',"%s/%s","%s/logs/%s"' "$tkr_common" "$tkr_leaf" "$tkr_common" "$tkr_leaf"; })]"`;
|
|
116
128
|
// OBS-125: codex 0.144.x gates enabled hooks behind PER-PROJECT-PATH hook trust — a NEW gate distinct
|
|
117
129
|
// from the directory trust seedCodexTrust seeds. Every worker runs in a fresh ephemeral worktree, an
|
|
118
130
|
// untrusted project, so codex re-prompts "Hooks need review" and the interactive worker stalls forever
|
package/dist/adapters/fake.js
CHANGED
|
@@ -2,6 +2,40 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { parseWorkerResult } from "./prompt.js";
|
|
4
4
|
import { OPENCODE_BUSY_FRAME_MARKERS, shq, TokenUsageSchema } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* S23: the first citation of the gate's nonce-framed index — from the LAST line exactly `CITABLE_LINES_BEGIN <nonce>`
|
|
7
|
+
* to the next line exactly `CITABLE_LINES_END <nonce>`. Every entry must be a JSON object with a string path and a
|
|
8
|
+
* non-empty array of non-negative safe-integer lines before any is used; otherwise (no block, no END, an empty or
|
|
9
|
+
* invalid entry anywhere) there is no citation. Never throws.
|
|
10
|
+
*/
|
|
11
|
+
function firstCitation(prompt, nonce) {
|
|
12
|
+
if (!nonce)
|
|
13
|
+
return undefined;
|
|
14
|
+
const lines = prompt.split("\n");
|
|
15
|
+
const begin = lines.lastIndexOf(`CITABLE_LINES_BEGIN ${nonce}`);
|
|
16
|
+
const end = begin < 0 ? -1 : lines.indexOf(`CITABLE_LINES_END ${nonce}`, begin + 1);
|
|
17
|
+
if (end < 0)
|
|
18
|
+
return undefined;
|
|
19
|
+
const entries = [];
|
|
20
|
+
for (const line of lines.slice(begin + 1, end)) {
|
|
21
|
+
let entry;
|
|
22
|
+
try {
|
|
23
|
+
entry = JSON.parse(line);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
29
|
+
return undefined;
|
|
30
|
+
const { path, lines: cited } = entry;
|
|
31
|
+
if (typeof path !== "string" || !Array.isArray(cited) || cited.length === 0
|
|
32
|
+
|| !cited.every((n) => Number.isSafeInteger(n) && n >= 0))
|
|
33
|
+
return undefined;
|
|
34
|
+
entries.push({ path, lines: cited });
|
|
35
|
+
}
|
|
36
|
+
const first = entries[0];
|
|
37
|
+
return first ? { path: first.path, line: first.lines[0] } : undefined;
|
|
38
|
+
}
|
|
5
39
|
// ponytail: deterministic scripted adapter — the whole integration suite runs on it, zero tokens.
|
|
6
40
|
export class FakeAdapter {
|
|
7
41
|
scriptPath;
|
|
@@ -77,13 +111,15 @@ export class FakeAdapter {
|
|
|
77
111
|
val = { ...verdict, criteria: items.map((criterion) => ({ criterion, met: true, reason: "scripted fake pass" })) };
|
|
78
112
|
}
|
|
79
113
|
}
|
|
114
|
+
// S23: a scripted row without evidence cites the first line of the first entry of the gate's own citable
|
|
115
|
+
// index — never a string. An invalid or unterminated index yields no evidence (the gate then fails closed).
|
|
80
116
|
if (key === "judge" && isJudge && val && typeof val === "object" && !Array.isArray(val)) {
|
|
81
117
|
const verdict = val;
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
118
|
+
const evidence = firstCitation(prompt, nonce);
|
|
119
|
+
if (evidence && Array.isArray(verdict.criteria)) {
|
|
84
120
|
val = {
|
|
85
121
|
...verdict,
|
|
86
|
-
criteria: verdict.criteria.map((row) => row && typeof row === "object" && !("evidence" in row) ? { ...row, evidence
|
|
122
|
+
criteria: verdict.criteria.map((row) => row && typeof row === "object" && !("evidence" in row) ? { ...row, evidence } : row),
|
|
87
123
|
};
|
|
88
124
|
}
|
|
89
125
|
}
|
|
@@ -152,10 +188,12 @@ export class FakeAdapter {
|
|
|
152
188
|
}
|
|
153
189
|
// the run nonce lives in the prompt writePrompt handed us; echo a matching trailer so
|
|
154
190
|
// parseWorkerResult(output, nonce) succeeds exactly as before the nonce existed
|
|
191
|
+
// S23: the verdict nonce is the LAST whole `VERDICT_NONCE: <hex>` line — the gate writes its own after the goal,
|
|
192
|
+
// criteria and diff, so a nonce line quoted in any of them never binds.
|
|
155
193
|
nonceFor(promptFile) {
|
|
156
194
|
try {
|
|
157
195
|
const prompt = readFileSync(promptFile, "utf8");
|
|
158
|
-
return
|
|
196
|
+
return prompt.split("\n").map((line) => /^VERDICT_NONCE: ([0-9a-f]+)$/.exec(line)?.[1]).filter(Boolean).at(-1)
|
|
159
197
|
?? /TICKMARKR_RESULT_([0-9a-z]+)/.exec(prompt)?.[1]
|
|
160
198
|
?? "";
|
|
161
199
|
}
|
|
@@ -22,7 +22,7 @@ import { sh } from "../../run/git.js";
|
|
|
22
22
|
import { auditNamedTestOracles, listVitestTests } from "../../gates/acceptance.js";
|
|
23
23
|
import { readReviewNoVerdictHistory, reviewNoVerdictRows } from "../../run/journal.js";
|
|
24
24
|
import { checkModelDiscovery } from "../../adapters/model-discovery-check.js";
|
|
25
|
-
import { recordLaunchServices } from "../../run/launchservices-check.js";
|
|
25
|
+
import { LAUNCHSERVICES_PORT_ADVISORY, probeLaunchServicesPorts, recordLaunchServices } from "../../run/launchservices-check.js";
|
|
26
26
|
/** Where a newer `table_<date>.csv` is discovered — the deployed site builds filenames by
|
|
27
27
|
* concatenation and publishes no index, so the release listing is the only enumerable surface. */
|
|
28
28
|
export const LIVEBENCH_RELEASES_URL = "https://api.github.com/repos/LiveBench/livebench.github.io/contents/public";
|
|
@@ -670,7 +670,8 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
670
670
|
const discovery = await checkModelDiscovery({ cwd, cfg, adapters, health, catalog, resolved: resolvedIdentitySnapshot, mayRefresh: !opts.catalog && !catalogRefreshLine && catalogRefreshAllowed, fetcher: opts.catalogFetcher, now: opts.catalogNow });
|
|
671
671
|
catalog = discovery.catalog;
|
|
672
672
|
aliasDriftFindings.push(...discovery.findings);
|
|
673
|
-
const
|
|
673
|
+
const portProbe = await probeLaunchServicesPorts();
|
|
674
|
+
const launchServices = await recordLaunchServices(readDoctor(cwd), health, portProbe.observation);
|
|
674
675
|
writeDoctor(cwd, launchServices.health);
|
|
675
676
|
const rows = adapters.map((a) => {
|
|
676
677
|
const h = health[a.id];
|
|
@@ -680,6 +681,14 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
680
681
|
const healthy = h.installed && (a.id !== kimi.id || h.authed) && !(a.id === codex.id && h.codexCommit === "escape");
|
|
681
682
|
return alignedStatusRow(healthy ? "pass" : "fail", a.id, state);
|
|
682
683
|
});
|
|
684
|
+
const ports = portProbe.ports;
|
|
685
|
+
const portLine = ports.status === "non-darwin" ? "launchservicesd ports: not applicable"
|
|
686
|
+
: ports.status === "read" ? `launchservicesd pid ${ports.pid}: ${ports.count} ports`
|
|
687
|
+
: `launchservicesd pid ${ports.status === "unknown" ? ports.pid ?? "unknown" : "unknown"}: ports unknown`;
|
|
688
|
+
if (ports.status !== "skipped") {
|
|
689
|
+
rows.push(alignedStatusRow(ports.status === "read" && ports.count < LAUNCHSERVICES_PORT_ADVISORY || ports.status === "non-darwin"
|
|
690
|
+
? "pass" : "warn", "launchservicesd", portLine));
|
|
691
|
+
}
|
|
683
692
|
const reviewDemotions = recentReviewDemotions(cwd);
|
|
684
693
|
if (reviewDemotions.length) {
|
|
685
694
|
rows.push(legend("recent review-seat demotions:"));
|
|
@@ -17,7 +17,7 @@ import { addDetachedWorktree, linkNodeModules, removeWorktree, shGit, shGitOk }
|
|
|
17
17
|
import { foldOwedChecks, integrationMapped, owedCriteria, Journal, OWED_DISCHARGE_EVENT } from "../../run/journal.js";
|
|
18
18
|
import { describeLeaseWait, withRepositoryLease } from "../../run/lease.js";
|
|
19
19
|
import { isPidLive } from "../../run/lock.js";
|
|
20
|
-
import { recordLaunchServices } from "../../run/launchservices-check.js";
|
|
20
|
+
import { LAUNCHSERVICES_PORT_STOP, probeLaunchServicesPorts, recordLaunchServices } from "../../run/launchservices-check.js";
|
|
21
21
|
/**
|
|
22
22
|
* tickmarkr verify — the gate battery as a standalone command (OPERATING-MODEL-2026-08-11 item 3).
|
|
23
23
|
*
|
|
@@ -184,6 +184,14 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
|
|
|
184
184
|
},
|
|
185
185
|
allowPositionals: false,
|
|
186
186
|
});
|
|
187
|
+
const portProbe = await probeLaunchServicesPorts({ requireTop: true });
|
|
188
|
+
const ports = portProbe.ports;
|
|
189
|
+
if (ports.status === "read" || ports.status === "unknown") {
|
|
190
|
+
console.error(`verify: ports ${ports.status === "read" ? ports.count : "unknown"}`);
|
|
191
|
+
}
|
|
192
|
+
if (ports.status === "read" && ports.count >= LAUNCHSERVICES_PORT_STOP) {
|
|
193
|
+
return { out: `verify refused: launchservicesd has ${ports.count} ports (threshold ${LAUNCHSERVICES_PORT_STOP}); log out or restart to reset launchservicesd before verifying`, code: 1 };
|
|
194
|
+
}
|
|
187
195
|
const stateRoot = await verifyStateRoot(cwd);
|
|
188
196
|
const fileRoot = (file) => existsSync(join(cwd, ".tickmarkr", file)) ? cwd : stateRoot;
|
|
189
197
|
if (resolve(stateRoot) !== resolve(cwd)) {
|
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
|
+
}
|
package/dist/gates/run-gates.js
CHANGED
|
@@ -7,7 +7,7 @@ import { TIER_RANK } from "../config/config.js";
|
|
|
7
7
|
import { getAdapter } from "../adapters/registry.js";
|
|
8
8
|
import { GATE_NAMES } from "../graph/schema.js";
|
|
9
9
|
import { acceptanceGate } from "./acceptance.js";
|
|
10
|
-
import { classifiedFileDurations, compareToBaseline, effectiveCeilingMs, waitForCalmWindow, calmWindowReady } from "./baseline.js";
|
|
10
|
+
import { classifiedFileDurations, compareToBaseline, effectiveCeilingMs, waitForCalmWindow, calmWindowReady, SIGNAL_EXIT_RE } from "./baseline.js";
|
|
11
11
|
import { evidenceGate } from "./evidence.js";
|
|
12
12
|
import { captureLlmOutput, SeatLaunchError } from "./llm.js";
|
|
13
13
|
import { withLaunchGuard } from "../drivers/orca.js";
|
|
@@ -364,30 +364,36 @@ export async function reobserveTestFiles(worktree, testCmd, baseline, files, art
|
|
|
364
364
|
return r.meta?.recovery === undefined ? r
|
|
365
365
|
: { ...r, pass: false, meta: { ...r.meta, classification: "infra", infra: true, retryable: false, recoveryRefused: true } };
|
|
366
366
|
}
|
|
367
|
-
const SIGNAL_EXIT_RE = /\b(?:SIGTERM|SIGKILL|signal\s+(?:9|15)|exit(?:s|ed|\s+code)?\s+(?:137|143))\b/i;
|
|
368
367
|
const FAILURE_IDENTITY_RE = /\b(?:AssertionError|FAIL\s+\S|Tests?\s+\d+\s+failed|expected\s+.+\s+to\s+)\b/i;
|
|
369
368
|
/** D1: apply the daemon's signal-only rider before either battery cache read or write. Its onGate
|
|
370
369
|
* classification happens after persistence, too late to keep a scripted runner's non-verdict out.
|
|
371
370
|
* Keep named failures as work verdicts and preserve details for failure-policy fingerprinting.
|
|
372
371
|
* A baseline-forgiven "green" whose runner was signal-killed is the same non-verdict: a killed job
|
|
373
|
-
* completed no suite
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
372
|
+
* completed no suite. The termination receipt decides first: a signal-terminated job is killed whatever its
|
|
373
|
+
* details say, and a green stands only on a receipt showing a normal exit — a receiptless CACHED one (an
|
|
374
|
+
* older or planted entry) is unavailable proof, so a fresh job measures instead. A normal exit 1 can still
|
|
375
|
+
* be a kill (a wrapper reports its killed worker and exits 1), and a forgiven green's details never carry
|
|
376
|
+
* the runner's output, so this rider cannot see it: S19m4 — the shared forgiveness decision
|
|
377
|
+
* (baseline.ts freshFailures) reads kill text beside a forgiven pass itself, turns one into infra that is
|
|
378
|
+
* never cached, and marks the green it does publish `killTextRead`. A cached forgiven green without that
|
|
379
|
+
* marker was written by an engine that never read kill text, so it is refused here — a normal exit 1
|
|
380
|
+
* receipt included — and the job runs again. A fresh row is this invocation's own: every production
|
|
381
|
+
* runner attaches its receipt, and the manifest path's verdict already requires a clean exit
|
|
378
382
|
* (test-manifest.ts verifyManifestReport). A no-command skip ran nothing and claims none. */
|
|
379
383
|
function classifySignalOnlyTest(g, cached = false) {
|
|
380
384
|
const termination = g.evidenceReceipt?.termination.kind;
|
|
381
385
|
const exitCode = g.evidenceReceipt?.termination.exitCode;
|
|
382
386
|
const killed = termination === "signal" || exitCode === 137 || exitCode === 143 || (!g.pass && SIGNAL_EXIT_RE.test(g.details));
|
|
383
|
-
const
|
|
387
|
+
const killTextUnread = cached && Array.isArray(g.meta?.forgivenFingerprints) && g.meta.killTextRead !== true;
|
|
388
|
+
const unproven = g.pass && g.meta?.skipped !== true && (killTextUnread || termination !== "exit" && (cached || termination !== undefined));
|
|
384
389
|
if (g.gate !== "test" || g.meta?.infra === true || !(killed || unproven))
|
|
385
390
|
return;
|
|
386
391
|
const named = Array.isArray(g.meta?.failingTests) && g.meta.failingTests.length > 0;
|
|
387
392
|
if (!g.pass && (named || FAILURE_IDENTITY_RE.test(g.details)))
|
|
388
393
|
return;
|
|
389
394
|
g.pass = false;
|
|
390
|
-
g.meta = { ...g.meta, classification: "infra", infra: true, retryable: false,
|
|
395
|
+
g.meta = { ...g.meta, classification: "infra", infra: true, retryable: false,
|
|
396
|
+
kind: killed || (termination && termination !== "exit") ? "signal-exit" : termination ? "kill-text-unread" : "no-exit-receipt" };
|
|
391
397
|
}
|
|
392
398
|
/** OBS-1151: a criterion's comparable subject — its canonical text, the task's declared bounds and the
|
|
393
399
|
* operator context. The cited files' blobs are compared separately, over the union of both citations. */
|
|
@@ -1193,7 +1199,7 @@ export async function runGates(task, ctx) {
|
|
|
1193
1199
|
// details. The v1.1 badReviewers precedent's TIMING can't transfer: its failover lands on the NEXT
|
|
1194
1200
|
// worker attempt — exactly what this fix forbids; only its meta-carries-channel pattern is mirrored.
|
|
1195
1201
|
// Straight-line single `if` — NO loop/counter/knob: exactly-once by construction (a knob is a
|
|
1196
|
-
// fail-closed weakening vector); a second garbage verdict fails the gate closed
|
|
1202
|
+
// fail-closed weakening vector); a second garbage verdict fails the gate closed as terminal infra (S23-F11).
|
|
1197
1203
|
// T6: exclusion mirrors consult reroute semantics — the flaked channel's whole adapter is banned, not
|
|
1198
1204
|
// just its exact channel key, so an outage window cannot re-select the vendor being routed around.
|
|
1199
1205
|
// If no other adapter is live, the exclusion degrades to a channel-level reroute within the same
|
|
@@ -1226,8 +1232,11 @@ export async function runGates(task, ctx) {
|
|
|
1226
1232
|
}
|
|
1227
1233
|
// OBS-1168(b): the re-routed seat could not launch either — no seat produced a verdict, so this is
|
|
1228
1234
|
// an infra park over whatever the deterministic gates proved, never a charge against the worker.
|
|
1229
|
-
|
|
1235
|
+
// S23-F11: the same holds for a retry whose verdict is still unparseable: two seats delivered no readable
|
|
1236
|
+
// verdict, which judges nothing about the worker's diff, so the row is terminal infra, never a charged red.
|
|
1237
|
+
if (a.meta?.cause === "seat-launch-failed" || (a.meta?.judgeRetry !== undefined && a.meta.unparseable === true)) {
|
|
1230
1238
|
a = { ...a, meta: { ...a.meta, classification: "infra", infra: true, retryable: false } };
|
|
1239
|
+
}
|
|
1231
1240
|
else if (!cancelled && Array.isArray(a.meta?.judgment))
|
|
1232
1241
|
a = await adjudicate(a);
|
|
1233
1242
|
// No dispatch, no key: a deterministic-oracle round writes no `invocations` field rather than an
|
package/dist/run/git.d.ts
CHANGED
|
@@ -154,6 +154,8 @@ export interface ShResult {
|
|
|
154
154
|
durationMs?: number;
|
|
155
155
|
/** T7: the capacity THIS child ran under, stamped where its environment was built (see `shell`). */
|
|
156
156
|
capacity?: RunCapacity;
|
|
157
|
+
/** Queue row 142: stdout exactly as the child wrote it, present only when the own-git caller asked for bytes. */
|
|
158
|
+
stdoutBytes?: Buffer;
|
|
157
159
|
}
|
|
158
160
|
export declare const setSpawnForTests: (fn: typeof spawn) => void;
|
|
159
161
|
export declare const resetSpawnForTests: () => void;
|
|
@@ -168,6 +170,8 @@ export interface ShellOptions {
|
|
|
168
170
|
onReceipt?: (receipt: ShellReceipt) => void;
|
|
169
171
|
/** Called once per invocation (1-based), including each pre-spawn retry. */
|
|
170
172
|
receiptAttribution?: (invocation: number) => CommandReceiptAttribution;
|
|
173
|
+
/** Queue row 142: also keep stdout as raw bytes (ShResult.stdoutBytes); only shGit's byte readers opt in. */
|
|
174
|
+
bytes?: boolean;
|
|
171
175
|
}
|
|
172
176
|
/** Shared command seam, including invocation-bound manifested runners. A PAYLOAD entry: its children keep the
|
|
173
177
|
* operator's git config (hooks, fsmonitor) and get no checkout pin — ownership follows the entry, never `login`
|
|
@@ -180,7 +184,7 @@ export declare function shell(cmd: string, cwd: string, timeoutMs: number, login
|
|
|
180
184
|
*/
|
|
181
185
|
export type OwnGitOptions = OwnGitCapability;
|
|
182
186
|
export declare function sh(cmd: string, cwd: string, timeoutMs?: number, options?: ShellOptions): Promise<ShResult>;
|
|
183
|
-
export declare function shGit(cmd: string, cwd: string, timeoutMs?: number, own?: OwnGitOptions): Promise<ShResult>;
|
|
187
|
+
export declare function shGit(cmd: string, cwd: string, timeoutMs?: number, own?: OwnGitOptions, options?: Pick<ShellOptions, "bytes">): Promise<ShResult>;
|
|
184
188
|
export declare function shOk(cmd: string, cwd: string): Promise<string>;
|
|
185
189
|
export declare function shGitOk(cmd: string, cwd: string, own?: OwnGitOptions): Promise<string>;
|
|
186
190
|
/**
|
package/dist/run/git.js
CHANGED
|
@@ -386,6 +386,7 @@ function executeShell(cmd, cwd, timeoutMs, login, options, observation, ownGit)
|
|
|
386
386
|
throw error;
|
|
387
387
|
}
|
|
388
388
|
let stdout = "", stderr = "";
|
|
389
|
+
const stdoutChunks = [];
|
|
389
390
|
const stdoutDecoder = new StringDecoder("utf8");
|
|
390
391
|
const stderrDecoder = new StringDecoder("utf8");
|
|
391
392
|
let timedOut = false, reapedGroup = false, done = false, started = false, outputSeen = false;
|
|
@@ -419,6 +420,7 @@ function executeShell(cmd, cwd, timeoutMs, login, options, observation, ownGit)
|
|
|
419
420
|
timedOut,
|
|
420
421
|
durationMs: Date.now() - startedAt,
|
|
421
422
|
capacity,
|
|
423
|
+
...(options.bytes ? { stdoutBytes: Buffer.concat(stdoutChunks) } : {}),
|
|
422
424
|
...(reapedGroup ? { reapedGroup: true } : {}),
|
|
423
425
|
...(reapError ? { reapError } : {}),
|
|
424
426
|
});
|
|
@@ -485,6 +487,8 @@ function executeShell(cmd, cwd, timeoutMs, login, options, observation, ownGit)
|
|
|
485
487
|
p.stdout.on("data", (d) => {
|
|
486
488
|
if (d.length > 0)
|
|
487
489
|
outputSeen = true;
|
|
490
|
+
if (options.bytes)
|
|
491
|
+
stdoutChunks.push(d);
|
|
488
492
|
stdout += stdoutDecoder.write(d);
|
|
489
493
|
});
|
|
490
494
|
p.stderr.on("data", (d) => {
|
|
@@ -576,9 +580,9 @@ export function sh(cmd, cwd, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS, options = {})
|
|
|
576
580
|
}
|
|
577
581
|
// Git plumbing never needs an operator profile; skip login-shell startup and its side effects.
|
|
578
582
|
// v2.6.8 T2: the explicit OWN-GIT entry — forced inert hooks/fsmonitor and, in a linked checkout, the trusted-path and
|
|
579
|
-
// ref-store pin.
|
|
580
|
-
export function shGit(cmd, cwd, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS, own = {}) {
|
|
581
|
-
return shellEntry(cmd, cwd, timeoutMs, false,
|
|
583
|
+
// ref-store pin. Queue row 142: `bytes` is the one opt-in raw-stdout capture; every other caller's output is unchanged.
|
|
584
|
+
export function shGit(cmd, cwd, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS, own = {}, options = {}) {
|
|
585
|
+
return shellEntry(cmd, cwd, timeoutMs, false, options, own);
|
|
582
586
|
}
|
|
583
587
|
export async function shOk(cmd, cwd) {
|
|
584
588
|
const r = await sh(cmd, cwd);
|
|
@@ -33,12 +33,34 @@ export type ProbeTimer = (fire: () => void, ms: number) => () => void;
|
|
|
33
33
|
interface ProbeSeam {
|
|
34
34
|
platform: NodeJS.Platform;
|
|
35
35
|
spawn: typeof spawn;
|
|
36
|
+
spawnTop?: typeof spawn;
|
|
36
37
|
setTimer: ProbeTimer;
|
|
37
38
|
signal?: AbortSignal;
|
|
38
39
|
}
|
|
39
40
|
export declare const setLaunchServicesProbeForTests: (s: Partial<ProbeSeam>) => void;
|
|
40
41
|
export declare const resetLaunchServicesProbeForTests: () => void;
|
|
41
42
|
export declare function observeLaunchServices(s?: ProbeSeam): Promise<LaunchServicesObservation>;
|
|
43
|
+
export declare const LAUNCHSERVICES_PORT_ADVISORY = 100000;
|
|
44
|
+
export declare const LAUNCHSERVICES_PORT_STOP = 200000;
|
|
45
|
+
export type LaunchServicesPorts = {
|
|
46
|
+
status: "skipped" | "non-darwin";
|
|
47
|
+
} | {
|
|
48
|
+
status: "unknown";
|
|
49
|
+
pid?: number;
|
|
50
|
+
} | {
|
|
51
|
+
status: "read";
|
|
52
|
+
pid: number;
|
|
53
|
+
count: number;
|
|
54
|
+
};
|
|
55
|
+
/** Closed top format: PID COMMAND #PORTS, then the next non-empty row for this pid and launchservicesd. */
|
|
56
|
+
export declare function parseTopPortCount(stdout: string, pid: number): number | null;
|
|
57
|
+
/** One identity read and, only for a readable Darwin identity, one bounded top child. */
|
|
58
|
+
export declare function probeLaunchServicesPorts(options?: {
|
|
59
|
+
requireTop?: boolean;
|
|
60
|
+
}): Promise<{
|
|
61
|
+
observation?: LaunchServicesObservation;
|
|
62
|
+
ports: LaunchServicesPorts;
|
|
63
|
+
}>;
|
|
42
64
|
export declare function launchServicesAdvisory(prior: LaunchServicesIdentity, now: LaunchServicesIdentity): string;
|
|
43
65
|
/** Pure: the table row for one observation against the held record, and the record to hold next. */
|
|
44
66
|
export declare function classifyLaunchServices(prior: Partial<LaunchServicesRecord> | undefined, observed: LaunchServicesObservation): {
|
|
@@ -48,7 +70,7 @@ export declare function classifyLaunchServices(prior: Partial<LaunchServicesReco
|
|
|
48
70
|
};
|
|
49
71
|
/** Observe, classify against the first record of `prior`, and hold the result on the first record of `health`.
|
|
50
72
|
* Returns `health` itself when nothing changed, else a copy — the argument is never mutated. */
|
|
51
|
-
export declare function recordLaunchServices(prior: Record<string, AuthHealth> | null, health: Record<string, AuthHealth
|
|
73
|
+
export declare function recordLaunchServices(prior: Record<string, AuthHealth> | null, health: Record<string, AuthHealth>, observation?: LaunchServicesObservation): Promise<{
|
|
52
74
|
status: LaunchServicesStatus;
|
|
53
75
|
health: Record<string, AuthHealth>;
|
|
54
76
|
line?: string;
|
|
@@ -27,12 +27,14 @@ export function parsePsIdentity(stdout) {
|
|
|
27
27
|
const production = () => ({
|
|
28
28
|
platform: process.platform,
|
|
29
29
|
spawn,
|
|
30
|
+
spawnTop: spawn,
|
|
30
31
|
setTimer: (fire, ms) => { const t = setTimeout(fire, ms); return () => clearTimeout(t); },
|
|
31
32
|
});
|
|
32
33
|
// ponytail: default-off under the test runner (the catalogRefreshAllowed precedent) so an unowned doctor
|
|
33
34
|
// test never takes a real host reading or changes its doctor.json bytes; tests opt in through the seam.
|
|
35
|
+
// The legacy identity-only seam leaves spawnTop unset, so old /bin/ps fixtures never touch the host top.
|
|
34
36
|
let seam;
|
|
35
|
-
export const setLaunchServicesProbeForTests = (s) => { seam = { ...production(), ...s }; };
|
|
37
|
+
export const setLaunchServicesProbeForTests = (s) => { seam = { ...production(), ...s, spawnTop: s.spawnTop }; };
|
|
36
38
|
export const resetLaunchServicesProbeForTests = () => { seam = undefined; };
|
|
37
39
|
const activeSeam = () => seam ?? (process.env.VITEST === "true" ? undefined : production());
|
|
38
40
|
/** One /bin/ps call that owns only its child, killed at the ceiling or on cancellation and reaped before it resolves. */
|
|
@@ -78,6 +80,88 @@ function readIdentity(s) {
|
|
|
78
80
|
export function observeLaunchServices(s = production()) {
|
|
79
81
|
return s.platform === "darwin" ? readIdentity(s) : Promise.resolve({ status: "non-darwin" });
|
|
80
82
|
}
|
|
83
|
+
export const LAUNCHSERVICES_PORT_ADVISORY = 100_000;
|
|
84
|
+
export const LAUNCHSERVICES_PORT_STOP = 200_000;
|
|
85
|
+
const TOP_BYTES = 1024 * 1024;
|
|
86
|
+
/** Closed top format: PID COMMAND #PORTS, then the next non-empty row for this pid and launchservicesd. */
|
|
87
|
+
export function parseTopPortCount(stdout, pid) {
|
|
88
|
+
const lines = stdout.split(/\r?\n/);
|
|
89
|
+
const header = lines.findIndex((line) => line.trim().split(/\s+/).join(" ") === "PID COMMAND #PORTS");
|
|
90
|
+
if (header < 0)
|
|
91
|
+
return null;
|
|
92
|
+
const row = lines.slice(header + 1).find((line) => line.trim() !== "");
|
|
93
|
+
if (!row)
|
|
94
|
+
return null;
|
|
95
|
+
const tokens = row.trim().split(/\s+/);
|
|
96
|
+
if (tokens.length !== 3 || tokens[0] !== String(pid) || tokens[1] !== "launchservicesd" || !/^[0-9]+[+-]?$/.test(tokens[2]))
|
|
97
|
+
return null;
|
|
98
|
+
const count = Number(tokens[2].replace(/[+-]$/, ""));
|
|
99
|
+
return Number.isSafeInteger(count) ? count : null;
|
|
100
|
+
}
|
|
101
|
+
// The captured macOS top layout is closed: only a matching three-token row after the exact header
|
|
102
|
+
// yields a count. 100000 ports is a doctor advisory; 200000 stops standalone verify before gates.
|
|
103
|
+
// Unknown (including an unreadable identity, malformed top output, or a killed child) never stops.
|
|
104
|
+
function readPortCount(s, pid) {
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
const unknown = () => resolve({ status: "unknown", pid });
|
|
107
|
+
if (s.signal?.aborted) {
|
|
108
|
+
unknown();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!s.spawnTop) {
|
|
112
|
+
unknown();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
let child;
|
|
116
|
+
try {
|
|
117
|
+
child = s.spawnTop("/usr/bin/top", ["-l", "1", "-stats", "pid,command,ports", "-pid", String(pid)], { stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, LC_ALL: "C" } });
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
unknown();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
let out = "";
|
|
124
|
+
let bytes = 0;
|
|
125
|
+
let failed = false;
|
|
126
|
+
const retire = () => { if (!failed) {
|
|
127
|
+
failed = true;
|
|
128
|
+
child.kill("SIGKILL");
|
|
129
|
+
} };
|
|
130
|
+
const clear = s.setTimer(retire, LAUNCHSERVICES_PROBE_CEILING_MS);
|
|
131
|
+
s.signal?.addEventListener("abort", retire, { once: true });
|
|
132
|
+
child.stdout?.on("data", (chunk) => {
|
|
133
|
+
if (failed)
|
|
134
|
+
return;
|
|
135
|
+
bytes += chunk.length;
|
|
136
|
+
if (bytes > TOP_BYTES) {
|
|
137
|
+
retire();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
out += chunk.toString("utf8");
|
|
141
|
+
});
|
|
142
|
+
child.once("error", () => { failed = true; });
|
|
143
|
+
child.once("close", (code) => {
|
|
144
|
+
clear();
|
|
145
|
+
s.signal?.removeEventListener("abort", retire);
|
|
146
|
+
if (failed || code !== 0)
|
|
147
|
+
return unknown();
|
|
148
|
+
const count = parseTopPortCount(out, pid);
|
|
149
|
+
return count === null ? unknown() : resolve({ status: "read", pid, count });
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/** One identity read and, only for a readable Darwin identity, one bounded top child. */
|
|
154
|
+
export async function probeLaunchServicesPorts(options = {}) {
|
|
155
|
+
const s = activeSeam();
|
|
156
|
+
if (!s || (options.requireTop && !s.spawnTop))
|
|
157
|
+
return { ports: { status: "skipped" } };
|
|
158
|
+
const observation = await observeLaunchServices(s);
|
|
159
|
+
if (observation.status === "non-darwin")
|
|
160
|
+
return { observation, ports: { status: "non-darwin" } };
|
|
161
|
+
if (observation.status === "unreadable")
|
|
162
|
+
return { observation, ports: { status: "unknown" } };
|
|
163
|
+
return { observation, ports: await readPortCount(s, observation.identity.pid) };
|
|
164
|
+
}
|
|
81
165
|
// doctor.json is never schema-validated: a malformed held record is no identity at all.
|
|
82
166
|
const knownPrior = (prior) => typeof prior?.pid === "number" && typeof prior.start === "string";
|
|
83
167
|
export function launchServicesAdvisory(prior, now) {
|
|
@@ -106,10 +190,11 @@ export function classifyLaunchServices(prior, observed) {
|
|
|
106
190
|
}
|
|
107
191
|
/** Observe, classify against the first record of `prior`, and hold the result on the first record of `health`.
|
|
108
192
|
* Returns `health` itself when nothing changed, else a copy — the argument is never mutated. */
|
|
109
|
-
export async function recordLaunchServices(prior, health) {
|
|
193
|
+
export async function recordLaunchServices(prior, health, observation) {
|
|
110
194
|
const held = prior ? Object.values(prior)[0]?.launchServices : undefined;
|
|
111
195
|
const s = activeSeam();
|
|
112
|
-
const r =
|
|
196
|
+
const r = observation ? classifyLaunchServices(held, observation)
|
|
197
|
+
: s ? classifyLaunchServices(held, await observeLaunchServices(s)) : { status: "skipped", next: knownPrior(held) ? held : undefined };
|
|
113
198
|
const holder = Object.keys(health)[0];
|
|
114
199
|
if (!holder || !r.next || JSON.stringify(health[holder].launchServices) === JSON.stringify(r.next))
|
|
115
200
|
return { ...r, health };
|
package/dist/run/merge.js
CHANGED
|
@@ -216,7 +216,9 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
|
|
|
216
216
|
// Persistent reuse requires the run's durable journal. Without it there is no
|
|
217
217
|
// recorded cycle provenance to reconcile with the caller's capacity checks.
|
|
218
218
|
const hit = journalFound ? store.get(id) : undefined;
|
|
219
|
-
|
|
219
|
+
// S19m4: a forgiven test green written before the tip read kill text never proves the suite completed.
|
|
220
|
+
const killTextUnread = gate === "test" && hit?.meta?.forgiven === true && hit.meta.killTextRead !== true;
|
|
221
|
+
if (hit && id && hit.pass && !isInfraResult(hit) && !killTextUnread) {
|
|
220
222
|
results.push({
|
|
221
223
|
gate,
|
|
222
224
|
cmd,
|
|
@@ -339,7 +341,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
|
|
|
339
341
|
});
|
|
340
342
|
continue;
|
|
341
343
|
}
|
|
342
|
-
const verdict = freshFailures(entry, stripped);
|
|
344
|
+
const verdict = freshFailures(entry, stripped, gate === "test");
|
|
343
345
|
const { failing, unreadable } = verdict;
|
|
344
346
|
// S22: a missing or unreadable entry forgives nothing; the red names it rather than a bare exit code.
|
|
345
347
|
const refusal = baselineEntryRefusal(gate, verdict);
|
|
@@ -366,8 +368,12 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
|
|
|
366
368
|
// often the previous session's — so a capture taken under a different resolved capacity forgives
|
|
367
369
|
// nothing at the tip either. An absent capacity is a pre-T7 baseline and keeps today's verdict.
|
|
368
370
|
const comparable = sameCapacity(entry?.capacity, r.capacity);
|
|
369
|
-
const
|
|
371
|
+
const forgivable = r.code !== 0 && baselineRed && failing.length === 0 && !unreadable && cause !== "infra"
|
|
370
372
|
&& comparable;
|
|
373
|
+
// S19m4 (battery parity): kill text beside forgivable failures means the suite never completed — red, infra.
|
|
374
|
+
const killLine = forgivable && !greenTeardown && !deficit ? verdict.killLine : undefined;
|
|
375
|
+
const forgiven = forgivable && killLine === undefined;
|
|
376
|
+
const rowCause = killLine !== undefined ? "infra" : cause;
|
|
371
377
|
const pass = !deficit && (r.code === 0 || greenTeardown || forgiven);
|
|
372
378
|
if (!pass)
|
|
373
379
|
writeTipLog(artifact, raw);
|
|
@@ -378,18 +384,20 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
|
|
|
378
384
|
evidenceReceipt, evidenceReceipts,
|
|
379
385
|
exitCode: r.code,
|
|
380
386
|
fingerprints: r.code !== 0 && !greenTeardown ? fingerprint(stripped) : [],
|
|
381
|
-
details: `${
|
|
387
|
+
details: `${rowCause === "infra" ? "infra; " : ""}${rerun ? `${rerun}: ` : ""}` + (deficit?.replace(/^infra; /, "") ?? (r.code === 0 ? "exit 0"
|
|
382
388
|
: greenTeardown ? `exit ${r.code} after a green suite summary; only the runner's teardown fingerprint followed it`
|
|
383
389
|
: forgiven ? `exit ${r.code} but only baseline-recorded failures (forgiven vs baseline)`
|
|
384
|
-
:
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
390
|
+
: killLine !== undefined ? `exit ${r.code}; every failure is baseline-recorded, but the runner printed kill text — `
|
|
391
|
+
+ `it never completed a suite, so nothing is forgiven: ${killLine}`
|
|
392
|
+
: !comparable && baselineRed && failing.length === 0
|
|
393
|
+
? `exit ${r.code}; every failure is baseline-recorded, but that capture ran under `
|
|
394
|
+
+ `${describeCapacity(entry?.capacity)} and this verification ran under ${describeCapacity(r.capacity)} `
|
|
395
|
+
+ `— forgiveness across a changed capacity is not evidence`
|
|
396
|
+
: `exit ${r.code}`))
|
|
389
397
|
// S22: every red here names a missing or unreadable entry beside its own cause, deficit included
|
|
390
398
|
+ (!pass && refusal ? `; ${refusal}` : ""),
|
|
391
399
|
...(forgiven && !deficit ? { forgiven: true, ...(baseline?.provenance ? { baselineProvenance: baseline.provenance } : {}) } : {}),
|
|
392
|
-
...(
|
|
400
|
+
...(rowCause ? { cause: rowCause } : {}),
|
|
393
401
|
...(pass ? {} : { artifact }),
|
|
394
402
|
};
|
|
395
403
|
results.push(tipResult);
|
|
@@ -408,6 +416,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
|
|
|
408
416
|
if (id && id.tree === completedTree && !isInfraResult(result)) {
|
|
409
417
|
store.set(id, { ...result, meta: {
|
|
410
418
|
fingerprints: result.fingerprints, forgiven: result.forgiven, baselineProvenance: result.baselineProvenance,
|
|
419
|
+
...(result.forgiven && result.gate === "test" ? { killTextRead: true } : {}),
|
|
411
420
|
reportPath: result.reportPath, spawnedCommand: result.spawnedCommand,
|
|
412
421
|
artifact: result.artifact,
|
|
413
422
|
evidenceReceipt: result.evidenceReceipt, evidenceReceipts: result.evidenceReceipts,
|