tickmarkr 1.87.0 → 1.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/adapters/catalog.d.ts +18 -1
  2. package/dist/adapters/catalog.js +44 -1
  3. package/dist/adapters/fake.d.ts +2 -1
  4. package/dist/adapters/fake.js +7 -0
  5. package/dist/adapters/grok.js +11 -0
  6. package/dist/adapters/kimi.d.ts +2 -1
  7. package/dist/adapters/kimi.js +36 -0
  8. package/dist/adapters/opencode.js +17 -0
  9. package/dist/adapters/pi.js +11 -0
  10. package/dist/adapters/prompt.js +8 -1
  11. package/dist/adapters/registry.js +76 -57
  12. package/dist/adapters/types.d.ts +34 -3
  13. package/dist/adapters/types.js +99 -1
  14. package/dist/cli/commands/approve.d.ts +2 -0
  15. package/dist/cli/commands/approve.js +104 -84
  16. package/dist/cli/commands/compile.d.ts +1 -1
  17. package/dist/cli/commands/compile.js +29 -12
  18. package/dist/cli/commands/doctor.d.ts +16 -0
  19. package/dist/cli/commands/doctor.js +52 -0
  20. package/dist/cli/commands/init.js +2 -1
  21. package/dist/cli/commands/plan.d.ts +1 -1
  22. package/dist/cli/commands/plan.js +10 -1
  23. package/dist/cli/commands/report.js +49 -0
  24. package/dist/cli/commands/status.js +298 -96
  25. package/dist/cli/commands/verify.d.ts +9 -0
  26. package/dist/cli/commands/verify.js +177 -0
  27. package/dist/cli/harness.d.ts +13 -0
  28. package/dist/cli/harness.js +50 -0
  29. package/dist/cli/index.d.ts +1 -1
  30. package/dist/cli/index.js +3 -1
  31. package/dist/compile/collateral.js +11 -11
  32. package/dist/compile/common.js +2 -2
  33. package/dist/compile/index.d.ts +14 -3
  34. package/dist/compile/index.js +36 -10
  35. package/dist/compile/native.d.ts +15 -1
  36. package/dist/compile/native.js +310 -28
  37. package/dist/config/config.js +2 -2
  38. package/dist/drivers/subprocess.d.ts +6 -1
  39. package/dist/drivers/subprocess.js +9 -4
  40. package/dist/gates/acceptance.d.ts +21 -1
  41. package/dist/gates/acceptance.js +67 -22
  42. package/dist/gates/artifact-manifest.d.ts +119 -0
  43. package/dist/gates/artifact-manifest.js +357 -0
  44. package/dist/gates/baseline.d.ts +45 -5
  45. package/dist/gates/baseline.js +119 -15
  46. package/dist/gates/llm.js +37 -26
  47. package/dist/gates/review.d.ts +16 -11
  48. package/dist/gates/review.js +44 -150
  49. package/dist/gates/run-gates.js +124 -7
  50. package/dist/gates/scope.js +3 -3
  51. package/dist/graph/files-glob.d.ts +18 -0
  52. package/dist/graph/files-glob.js +22 -0
  53. package/dist/graph/schema.d.ts +3 -1
  54. package/dist/graph/schema.js +4 -1
  55. package/dist/run/daemon.d.ts +44 -0
  56. package/dist/run/daemon.js +2334 -1973
  57. package/dist/run/git.d.ts +53 -0
  58. package/dist/run/git.js +119 -5
  59. package/dist/run/interactive-seed.d.ts +6 -2
  60. package/dist/run/interactive-seed.js +72 -5
  61. package/dist/run/journal.d.ts +9 -1
  62. package/dist/run/journal.js +99 -9
  63. package/dist/run/lock.d.ts +11 -0
  64. package/dist/run/lock.js +97 -6
  65. package/dist/run/merge.d.ts +4 -1
  66. package/dist/run/merge.js +26 -7
  67. package/dist/run/outcome.d.ts +50 -0
  68. package/dist/run/outcome.js +152 -0
  69. package/dist/run/protocol.d.ts +460 -0
  70. package/dist/run/protocol.js +433 -0
  71. package/dist/run/supervision.d.ts +29 -0
  72. package/dist/run/supervision.js +189 -0
  73. package/fixtures/authoring-lints/01-awk-range-self-pass.spec.md +12 -0
  74. package/fixtures/authoring-lints/02-judge-text-key-miss.spec.md +7 -0
  75. package/fixtures/authoring-lints/03-c1-t41-rendered-observable.spec.md +8 -0
  76. package/fixtures/authoring-lints/04-c1-t24-named-file.spec.md +8 -0
  77. package/fixtures/authoring-lints/05-c2-t24-t28-dep-inversion.spec.md +7 -0
  78. package/fixtures/authoring-lints/06-c2-denumbered-coupling.spec.md +7 -0
  79. package/fixtures/authoring-lints/07-c3a-t41-line-count-proxy.spec.md +7 -0
  80. package/fixtures/authoring-lints/08-c3b-t41-governance-referent.spec.md +7 -0
  81. package/fixtures/authoring-lints/09-c4-universals-without-pointer.spec.md +7 -0
  82. package/fixtures/authoring-lints/10-c5-t34-conjunct-flood.spec.md +7 -0
  83. package/fixtures/authoring-lints/11-c6-t34-q3-q9-q20-bundle.spec.md +7 -0
  84. package/fixtures/authoring-lints/12-c7-t24-prose-seam.spec.md +8 -0
  85. package/fixtures/wrapped-acceptance.native.md +29 -0
  86. package/package.json +1 -1
  87. package/schema/rungraph.schema.json +21 -2
  88. package/skills/tickmarkr-overseer/SKILL.md +262 -5
  89. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +79 -8
  90. package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +80 -0
  91. package/skills/tickmarkr-overseer/scripts/watch-context.sh +86 -0
  92. package/skills/tickmarkr-overseer/scripts/watch-parks.sh +96 -0
  93. package/skills/tickmarkr-overseer/scripts/watch-pending-input.sh +201 -0
@@ -1,11 +1,15 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import { z } from "zod";
2
4
  import { channelKey, shq } from "../adapters/types.js";
5
+ import { compileNative } from "../compile/native.js";
3
6
  import { DEFAULT_DIFF_CAP } from "../config/config.js";
4
7
  import { renderAcceptanceItem } from "../graph/schema.js";
5
8
  import { sh } from "../run/git.js";
6
- import { checkDiffCap, fetchTaskDiff, isProtectedEvidence, setAsideReceiptPath } from "./review.js";
9
+ import { checkTaskDiffCaps, fetchTaskDiff, isProtectedEvidence, setAsideReceiptPath } from "./review.js";
7
10
  import { appendAnchoredReview, COMPLETION_FAKING_CHECKLIST, extractVerdictJson, generateVerdictNonce, runLlm, verdictNonceLine } from "./llm.js";
8
11
  import { classifyVerdictCause } from "./verdict-cause.js";
12
+ import { reviewableLogicDiff } from "./artifact-manifest.js";
9
13
  // Fable F4: acceptance judge shares review's 900s timeout — 300s default killed frontier judges on cap-sized diffs.
10
14
  const JUDGE_TIMEOUT_MS = 900_000;
11
15
  const CitationSchema = z.object({ path: z.string(), line: z.number().int() });
@@ -70,16 +74,75 @@ const isJudge = (a) => typeof a === "string" || (typeof a === "object" && a.orac
70
74
  function escapeRegExp(s) {
71
75
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
72
76
  }
77
+ // v1.89 T6: Vitest applies -t to the runner-visible full name (enclosing describe titles and the test
78
+ // title, space-joined). Escaping preserves literal criteria; anchoring makes equality, rather than
79
+ // substring containment, the named-test contract.
80
+ export function testFilterPattern(name) {
81
+ return `^${escapeRegExp(name)}$`;
82
+ }
73
83
  // OBS-55: when the base command already contains `--`, append -t after forwarded args — a second `--`
74
84
  // makes vitest treat -t as a positional file filter and the name filter is dropped.
75
85
  export function testFiltered(testCmd, name) {
76
- const pattern = escapeRegExp(name);
86
+ const pattern = testFilterPattern(name);
77
87
  const wrapped = /^\s*(npm|yarn|pnpm|npx)\b/.test(testCmd);
78
88
  if (wrapped && /\s--\s/.test(testCmd))
79
89
  return `${testCmd} -t ${shq(pattern)}`;
80
90
  const fwd = wrapped ? "-- " : "";
81
91
  return `${testCmd} ${fwd}-t ${shq(pattern)}`;
82
92
  }
93
+ function corpusSpecPaths(root) {
94
+ const paths = [];
95
+ const visit = (dir) => {
96
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
97
+ const path = join(dir, entry.name);
98
+ if (entry.isDirectory())
99
+ visit(path);
100
+ else if (entry.isFile() && entry.name.endsWith(".spec.md"))
101
+ paths.push(path);
102
+ }
103
+ };
104
+ visit(root);
105
+ return paths;
106
+ }
107
+ // v1.89 T6: mechanically bind the complete spec-corpus denominator to the runner's all-project JSON
108
+ // listing. Every discovered path contributes either all parser-produced acceptance items or one named
109
+ // parse failure; exceptions are evidence, never permission to shrink the corpus silently.
110
+ export function auditAcceptanceCorpus(corpusRoot, listedTests) {
111
+ const runnerNames = listedTests.map((listed) => ({
112
+ listed,
113
+ fullName: listed.name.split(" > ").join(" "),
114
+ }));
115
+ const results = [];
116
+ for (const specPath of corpusSpecPaths(corpusRoot)) {
117
+ try {
118
+ const graph = compileNative(specPath);
119
+ for (const task of graph.tasks) {
120
+ for (const item of task.acceptance) {
121
+ results.push({
122
+ specPath,
123
+ status: "parsed",
124
+ item,
125
+ ...(typeof item === "object" && item.oracle === "test"
126
+ ? {
127
+ namedTest: {
128
+ criterion: item.test,
129
+ matches: runnerNames
130
+ .filter(({ fullName }) => fullName === item.test)
131
+ .map(({ listed }) => listed),
132
+ },
133
+ }
134
+ : {}),
135
+ });
136
+ }
137
+ }
138
+ }
139
+ catch (error) {
140
+ const message = error instanceof Error ? error.message : String(error);
141
+ results.push({ specPath, status: "parse-failed", error: `${specPath}: ${message}` });
142
+ }
143
+ }
144
+ return results;
145
+ }
83
146
  // vitest/jest summary: "Tests N passed | M skipped (T)" — count passed+failed as actually ran.
84
147
  function testsRan(output) {
85
148
  const lines = output.replace(/\x1b\[[\d;#]*[A-Za-z]/g, "").split("\n");
@@ -185,23 +248,6 @@ function deletedPath(section) {
185
248
  const unquoted = oldPath.startsWith('"') && oldPath.endsWith('"') ? oldPath.slice(1, -1) : oldPath;
186
249
  return unquoted.replace(/^a\//, "");
187
250
  }
188
- // OBS-134: whole-file deletions are already fully described by their path. Sending every removed line
189
- // spends the cap and judge context on content that cannot exist after the change. Added and modified
190
- // sections pass through byte-for-byte, so their anti-flooding budget is unchanged.
191
- // v1.82 T1 clause 5: two exemptions, because this filter triggers on the very `deleted file mode` line
192
- // the set-aside preserves. Protected evidence (the frozen anchors, the captured journals) bypasses the
193
- // collapse so its deletion half reaches the measured text and the judge complete; and a section already
194
- // set aside is never reduced a second time, or this filter would erase the receipt that replaced it.
195
- function judgeRelevantDiff(diff) {
196
- return diff.split(DIFF_SECTIONS).map((section) => {
197
- if (setAsideReceiptPath(section))
198
- return section;
199
- const path = deletedPath(section);
200
- if (!path || isProtectedEvidence(path))
201
- return section;
202
- return `deleted file: ${path}\n`;
203
- }).join("");
204
- }
205
251
  // v1.82 T1 clause 7: the two shapes this task creates carry no changed hunk, so a judge asked to cite a
206
252
  // changed line has nothing to cite — three review rounds died here. Each yields ONE citable operation
207
253
  // fact, `{path, line: 0}`, read back from the judged text itself. Only these RECORDED facts make line 0
@@ -272,10 +318,9 @@ export async function acceptanceGate(task, worktree, baseRef, judge, via, opts =
272
318
  ? "WARNING: only judge oracles — no deterministic command/test oracle guards this task (deterministic preferred, spec §2).\n"
273
319
  : "";
274
320
  const fetched = await fetchTaskDiff(worktree, baseRef);
275
- const diff = judgeRelevantDiff(fetched.full);
276
- const forCap = judgeRelevantDiff(fetched.forCap);
321
+ const diff = reviewableLogicDiff(fetched.full);
277
322
  const diffCap = opts.diffCap ?? DEFAULT_DIFF_CAP;
278
- const capFail = checkDiffCap("acceptance", forCap.length, diffCap, warn + detBlock);
323
+ const capFail = checkTaskDiffCaps("acceptance", fetched, diffCap, warn + detBlock);
279
324
  if (capFail)
280
325
  return capFail;
281
326
  const expectedIds = judgeItems.map((_, index) => judgeCriterionId(index));
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Generated artifacts are not inferred from their directory or extension. A
3
+ * path receives capture accounting only when its manifest row names a
4
+ * registered producer and repeats that producer's current provenance exactly.
5
+ */
6
+ export type CaptureProducerProvenance = {
7
+ readonly source: string;
8
+ readonly entrypoint: string;
9
+ readonly revision: string;
10
+ };
11
+ export type CaptureProducerRegistration = {
12
+ readonly id: string;
13
+ readonly provenance: CaptureProducerProvenance;
14
+ };
15
+ export type CaptureArtifactManifestEntry = {
16
+ readonly path: string;
17
+ readonly producer: string;
18
+ readonly provenance: CaptureProducerProvenance;
19
+ };
20
+ export type CaptureArtifactManifest = {
21
+ readonly version: 1;
22
+ readonly producers: readonly CaptureProducerRegistration[];
23
+ readonly artifacts: readonly CaptureArtifactManifestEntry[];
24
+ };
25
+ export declare const CAPTURE_PRODUCERS: readonly [{
26
+ readonly id: "cockpit-golden-frames";
27
+ readonly provenance: {
28
+ readonly source: "src/tui/cockpit/capture.ts";
29
+ readonly entrypoint: "regenerateGoldenFrames";
30
+ readonly revision: "golden-frame-v1";
31
+ };
32
+ }, {
33
+ readonly id: "cockpit-colour-frames";
34
+ readonly provenance: {
35
+ readonly source: "src/tui/cockpit/capture.ts";
36
+ readonly entrypoint: "regenerateColourFrames";
37
+ readonly revision: "colour-frame-v1";
38
+ };
39
+ }];
40
+ export declare const CAPTURE_ARTIFACT_MANIFEST: {
41
+ readonly version: 1;
42
+ readonly producers: readonly [{
43
+ readonly id: "cockpit-golden-frames";
44
+ readonly provenance: {
45
+ readonly source: "src/tui/cockpit/capture.ts";
46
+ readonly entrypoint: "regenerateGoldenFrames";
47
+ readonly revision: "golden-frame-v1";
48
+ };
49
+ }, {
50
+ readonly id: "cockpit-colour-frames";
51
+ readonly provenance: {
52
+ readonly source: "src/tui/cockpit/capture.ts";
53
+ readonly entrypoint: "regenerateColourFrames";
54
+ readonly revision: "colour-frame-v1";
55
+ };
56
+ }];
57
+ readonly artifacts: readonly ({
58
+ path: string;
59
+ producer: string;
60
+ provenance: CaptureProducerProvenance;
61
+ } | {
62
+ path: string;
63
+ producer: string;
64
+ provenance: CaptureProducerProvenance;
65
+ })[];
66
+ };
67
+ /** Compatibility name for the pre-manifest gate API; now derived from one manifest. */
68
+ export declare const REGENERABLE_CAPTURE_PATHS: readonly string[];
69
+ export declare const PROTECTED_EVIDENCE_PREFIXES: readonly ["tests/fixtures/cockpit/anchors/", "tests/fixtures/cockpit/sources/", "tests/fixtures/cockpit/colour/sources/"];
70
+ export declare function isProtectedEvidence(path: string): boolean;
71
+ export type ArtifactPathClassification = {
72
+ readonly path: string;
73
+ readonly kind: "logic";
74
+ readonly reason: "protected-evidence" | "unmanifested" | "malformed-manifest" | "missing-producer" | "stale-provenance";
75
+ readonly error?: string;
76
+ } | {
77
+ readonly path: string;
78
+ readonly kind: "capture";
79
+ readonly reason: "manifest-provenance";
80
+ readonly producer: string;
81
+ readonly provenance: CaptureProducerProvenance;
82
+ };
83
+ export declare function classifyArtifactPath(path: string, manifest?: unknown): ArtifactPathClassification;
84
+ /** The path named by a citable capture receipt, or null when there is none. */
85
+ export declare function setAsideReceiptPath(section: string): string | null;
86
+ /**
87
+ * Whole-file source deletions retain their operation fact instead of spending
88
+ * the reader cap on bytes that no longer exist. Capture receipts and protected
89
+ * evidence are deliberately exempt from this older reduction.
90
+ */
91
+ export declare function reviewableLogicDiff(diff: string): string;
92
+ export type ArtifactDiffSection = {
93
+ readonly paths: readonly string[];
94
+ readonly kind: "capture" | "logic";
95
+ readonly reason: string;
96
+ readonly producer?: string;
97
+ readonly provenance?: CaptureProducerProvenance;
98
+ /** UTF-8 bytes left for a reviewer to read, including any receipt. */
99
+ readonly logicBytes: number;
100
+ /** UTF-8 bytes withheld behind the finite capture bound. */
101
+ readonly captureBytes: number;
102
+ };
103
+ export type ArtifactDiffMeasurement = {
104
+ readonly rendered: string;
105
+ readonly logicBytes: number;
106
+ readonly captureBytes: number;
107
+ readonly sections: readonly ArtifactDiffSection[];
108
+ };
109
+ /**
110
+ * Classify and compact a Git diff once. Invalid manifest facts remain ordinary
111
+ * logic. Valid capture content is replaced by one citable receipt, while its
112
+ * exact UTF-8 payload remains charged to the separate capture bucket.
113
+ */
114
+ export declare function measureArtifactDiff(diff: string, manifest?: unknown): ArtifactDiffMeasurement;
115
+ /** The original API now delegates to the provenance-backed measurement. */
116
+ export declare function setAsideRegenerableCaptures(diff: string): string;
117
+ export declare const MIN_CAPTURE_DIFF_CAP = 1000000;
118
+ export declare const CAPTURE_DIFF_CAP_MULTIPLIER = 4;
119
+ export declare function captureDiffCapFor(logicCap: number): number;
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Generated artifacts are not inferred from their directory or extension. A
3
+ * path receives capture accounting only when its manifest row names a
4
+ * registered producer and repeats that producer's current provenance exactly.
5
+ */
6
+ const GOLDEN_PROVENANCE = {
7
+ source: "src/tui/cockpit/capture.ts",
8
+ entrypoint: "regenerateGoldenFrames",
9
+ revision: "golden-frame-v1",
10
+ };
11
+ const COLOUR_PROVENANCE = {
12
+ source: "src/tui/cockpit/capture.ts",
13
+ entrypoint: "regenerateColourFrames",
14
+ revision: "colour-frame-v1",
15
+ };
16
+ export const CAPTURE_PRODUCERS = [
17
+ { id: "cockpit-golden-frames", provenance: GOLDEN_PROVENANCE },
18
+ { id: "cockpit-colour-frames", provenance: COLOUR_PROVENANCE },
19
+ ];
20
+ const goldenFrameNames = [
21
+ "run.width-stacked.80x24.txt",
22
+ "run.width-folded-keys.100x24.txt",
23
+ "run.width-three-column.140x24.txt",
24
+ "run.height-14.140x14.txt",
25
+ "run.height-18.140x18.txt",
26
+ "run.height-24.140x24.txt",
27
+ "run.height-40.140x40.txt",
28
+ "run.no-colour.140x24.txt",
29
+ "run.non-tty.140x24.txt",
30
+ "run.ci.140x24.txt",
31
+ "setup.width-stacked.80x24.txt",
32
+ "setup.width-folded-keys.100x24.txt",
33
+ "setup.width-three-column.140x24.txt",
34
+ "setup.height-14.140x14.txt",
35
+ "setup.height-18.140x18.txt",
36
+ "setup.height-24.140x24.txt",
37
+ "setup.height-40.140x40.txt",
38
+ "setup.no-colour.140x24.txt",
39
+ "setup.non-tty.140x24.txt",
40
+ "setup.ci.140x24.txt",
41
+ ];
42
+ const colourFrameNames = [
43
+ "run-20260718-000943.colour.140x24.txt",
44
+ "run-20260718-000943.no-colour.140x24.txt",
45
+ "run-20260725-025004.interrupted.colour.140x24.txt",
46
+ ];
47
+ const provenanceCopy = (provenance) => ({ ...provenance });
48
+ export const CAPTURE_ARTIFACT_MANIFEST = {
49
+ version: 1,
50
+ producers: CAPTURE_PRODUCERS,
51
+ artifacts: [
52
+ ...goldenFrameNames.map((fixture) => ({
53
+ path: `tests/fixtures/cockpit/frames/${fixture}`,
54
+ producer: "cockpit-golden-frames",
55
+ provenance: provenanceCopy(GOLDEN_PROVENANCE),
56
+ })),
57
+ ...colourFrameNames.map((fixture) => ({
58
+ path: `tests/fixtures/cockpit/colour/${fixture}`,
59
+ producer: "cockpit-colour-frames",
60
+ provenance: provenanceCopy(COLOUR_PROVENANCE),
61
+ })),
62
+ ],
63
+ };
64
+ /** Compatibility name for the pre-manifest gate API; now derived from one manifest. */
65
+ export const REGENERABLE_CAPTURE_PATHS = CAPTURE_ARTIFACT_MANIFEST.artifacts
66
+ .map((artifact) => artifact.path);
67
+ export const PROTECTED_EVIDENCE_PREFIXES = [
68
+ "tests/fixtures/cockpit/anchors/",
69
+ "tests/fixtures/cockpit/sources/",
70
+ "tests/fixtures/cockpit/colour/sources/",
71
+ ];
72
+ export function isProtectedEvidence(path) {
73
+ return PROTECTED_EVIDENCE_PREFIXES.some((prefix) => path.startsWith(prefix));
74
+ }
75
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
76
+ function isCanonicalRepoPath(value) {
77
+ if (typeof value !== "string" || value.length === 0)
78
+ return false;
79
+ if (value.startsWith("/") || value.includes("\\"))
80
+ return false;
81
+ const parts = value.split("/");
82
+ return parts.every((part) => part.length > 0 && part !== "." && part !== "..");
83
+ }
84
+ function parseProvenance(value) {
85
+ if (!isRecord(value))
86
+ return null;
87
+ const { source, entrypoint, revision } = value;
88
+ if (!isCanonicalRepoPath(source))
89
+ return null;
90
+ if (typeof entrypoint !== "string" || !/^[A-Za-z_$][\w$]*$/.test(entrypoint))
91
+ return null;
92
+ if (typeof revision !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(revision))
93
+ return null;
94
+ return { source, entrypoint, revision };
95
+ }
96
+ const sameProvenance = (left, right) => left.source === right.source
97
+ && left.entrypoint === right.entrypoint
98
+ && left.revision === right.revision;
99
+ function indexManifest(manifest) {
100
+ if (!isRecord(manifest) || manifest.version !== 1) {
101
+ return "artifact manifest version must be 1";
102
+ }
103
+ if (!Array.isArray(manifest.producers) || !Array.isArray(manifest.artifacts)) {
104
+ return "artifact manifest producers and artifacts must be arrays";
105
+ }
106
+ const producers = new Map();
107
+ for (const [index, raw] of manifest.producers.entries()) {
108
+ if (!isRecord(raw) || typeof raw.id !== "string" || !raw.id.trim()) {
109
+ return `artifact manifest producer ${index} is malformed`;
110
+ }
111
+ const provenance = parseProvenance(raw.provenance);
112
+ if (!provenance)
113
+ return `artifact manifest producer ${raw.id} has malformed provenance`;
114
+ if (producers.has(raw.id))
115
+ return `artifact manifest producer ${raw.id} is duplicated`;
116
+ producers.set(raw.id, { id: raw.id, provenance });
117
+ }
118
+ const artifacts = new Map();
119
+ for (const [index, raw] of manifest.artifacts.entries()) {
120
+ if (!isRecord(raw) || !isCanonicalRepoPath(raw.path)
121
+ || typeof raw.producer !== "string" || !raw.producer.trim()) {
122
+ return `artifact manifest entry ${index} is malformed`;
123
+ }
124
+ const provenance = parseProvenance(raw.provenance);
125
+ if (!provenance)
126
+ return `artifact manifest entry ${raw.path} has malformed provenance`;
127
+ if (artifacts.has(raw.path))
128
+ return `artifact manifest path ${raw.path} is duplicated`;
129
+ artifacts.set(raw.path, { path: raw.path, producer: raw.producer, provenance });
130
+ }
131
+ return { producers, artifacts };
132
+ }
133
+ export function classifyArtifactPath(path, manifest = CAPTURE_ARTIFACT_MANIFEST) {
134
+ // Protected evidence wins even over a forged manifest row.
135
+ if (isProtectedEvidence(path)) {
136
+ return { path, kind: "logic", reason: "protected-evidence" };
137
+ }
138
+ const indexed = indexManifest(manifest);
139
+ if (typeof indexed === "string") {
140
+ return { path, kind: "logic", reason: "malformed-manifest", error: indexed };
141
+ }
142
+ const artifact = indexed.artifacts.get(path);
143
+ if (!artifact)
144
+ return { path, kind: "logic", reason: "unmanifested" };
145
+ const producer = indexed.producers.get(artifact.producer);
146
+ if (!producer) {
147
+ return {
148
+ path,
149
+ kind: "logic",
150
+ reason: "missing-producer",
151
+ error: `capture producer ${artifact.producer} is not registered`,
152
+ };
153
+ }
154
+ if (!sameProvenance(artifact.provenance, producer.provenance)) {
155
+ return {
156
+ path,
157
+ kind: "logic",
158
+ reason: "stale-provenance",
159
+ error: `capture provenance for ${path} does not match producer ${producer.id}`,
160
+ };
161
+ }
162
+ return {
163
+ path,
164
+ kind: "capture",
165
+ reason: "manifest-provenance",
166
+ producer: producer.id,
167
+ provenance: producer.provenance,
168
+ };
169
+ }
170
+ const SET_ASIDE_RECEIPT = /^set aside: regenerable capture (.+?) — \d+ bytes withheld\b/m;
171
+ /** The path named by a citable capture receipt, or null when there is none. */
172
+ export function setAsideReceiptPath(section) {
173
+ return SET_ASIDE_RECEIPT.exec(section)?.[1] ?? null;
174
+ }
175
+ const DIFF_SECTIONS = /(?=^diff --git )/m;
176
+ function deletedPath(section) {
177
+ if (!/^deleted file mode /m.test(section))
178
+ return null;
179
+ const oldPath = /^--- (.+)$/m.exec(section)?.[1]
180
+ ?? /^Binary files (.+) and \/dev\/null differ$/m.exec(section)?.[1];
181
+ if (!oldPath || oldPath === "/dev/null")
182
+ return null;
183
+ return unquoteGitPath(oldPath).replace(/^a\//, "");
184
+ }
185
+ /**
186
+ * Whole-file source deletions retain their operation fact instead of spending
187
+ * the reader cap on bytes that no longer exist. Capture receipts and protected
188
+ * evidence are deliberately exempt from this older reduction.
189
+ */
190
+ export function reviewableLogicDiff(diff) {
191
+ return diff.split(DIFF_SECTIONS).map((section) => {
192
+ if (setAsideReceiptPath(section))
193
+ return section;
194
+ const path = deletedPath(section);
195
+ if (!path || isProtectedEvidence(path))
196
+ return section;
197
+ return `deleted file: ${path}\n`;
198
+ }).join("");
199
+ }
200
+ function unquoteGitPath(raw) {
201
+ const value = raw.trim();
202
+ if (!value.startsWith('"') || !value.endsWith('"'))
203
+ return value;
204
+ try {
205
+ return JSON.parse(value);
206
+ }
207
+ catch {
208
+ return value.slice(1, -1);
209
+ }
210
+ }
211
+ function diffSidePath(raw) {
212
+ const value = unquoteGitPath(raw);
213
+ if (value === "/dev/null")
214
+ return null;
215
+ return value.replace(/^[ab]\//, "");
216
+ }
217
+ function gitHeaderTokens(raw) {
218
+ return raw.match(/"(?:\\.|[^"\\])*"|\S+/g) ?? [];
219
+ }
220
+ function parseContentSection(section) {
221
+ const lines = section.split("\n");
222
+ const minus = lines.findIndex((line) => line.startsWith("--- "));
223
+ if (minus === -1 || !lines[minus + 1]?.startsWith("+++ "))
224
+ return null;
225
+ const body = lines.slice(minus + 2);
226
+ if (!body.some((line) => line.startsWith("@@ ")))
227
+ return null;
228
+ return {
229
+ lines,
230
+ minus,
231
+ sides: [
232
+ diffSidePath(lines[minus].slice(4)),
233
+ diffSidePath(lines[minus + 1].slice(4)),
234
+ ],
235
+ body,
236
+ };
237
+ }
238
+ function sectionPaths(section, parsed) {
239
+ if (parsed)
240
+ return [...new Set(parsed.sides.filter((path) => path !== null))];
241
+ const renamedFrom = /^rename from (.+)$/m.exec(section)?.[1];
242
+ const renamedTo = /^rename to (.+)$/m.exec(section)?.[1];
243
+ if (renamedFrom || renamedTo) {
244
+ return [...new Set([renamedFrom, renamedTo].filter((path) => path !== undefined).map(unquoteGitPath))];
245
+ }
246
+ const header = /^diff --git (.+)$/m.exec(section)?.[1];
247
+ if (!header)
248
+ return [];
249
+ const tokens = gitHeaderTokens(header);
250
+ if (tokens.length !== 2)
251
+ return [];
252
+ return [...new Set(tokens.map(diffSidePath).filter((path) => path !== null))];
253
+ }
254
+ function hunkPayload(body, sign) {
255
+ return body
256
+ .filter((line) => line.startsWith(sign) || line.startsWith("\\"))
257
+ .map((line) => line[0] === sign ? line.slice(1) : line)
258
+ .join("\n");
259
+ }
260
+ function kindOnlyPaths(sections) {
261
+ const removed = new Map();
262
+ const added = new Map();
263
+ for (const section of sections) {
264
+ const parsed = parseContentSection(section);
265
+ if (!parsed)
266
+ continue;
267
+ const [before, after] = parsed.sides;
268
+ if (before && !after)
269
+ removed.set(before, hunkPayload(parsed.body, "-"));
270
+ else if (after && !before)
271
+ added.set(after, hunkPayload(parsed.body, "+"));
272
+ }
273
+ return new Set([...removed]
274
+ .filter(([path, payload]) => added.get(path) === payload)
275
+ .map(([path]) => path));
276
+ }
277
+ function sameCaptureProducer(classifications) {
278
+ if (!classifications.length || classifications.some((row) => row.kind !== "capture"))
279
+ return false;
280
+ const [first] = classifications;
281
+ return first !== undefined && classifications.every((row) => row.kind === "capture"
282
+ && row.producer === first.producer
283
+ && sameProvenance(row.provenance, first.provenance));
284
+ }
285
+ /**
286
+ * Classify and compact a Git diff once. Invalid manifest facts remain ordinary
287
+ * logic. Valid capture content is replaced by one citable receipt, while its
288
+ * exact UTF-8 payload remains charged to the separate capture bucket.
289
+ */
290
+ export function measureArtifactDiff(diff, manifest = CAPTURE_ARTIFACT_MANIFEST) {
291
+ if (!diff.includes("diff --git ")) {
292
+ return {
293
+ rendered: diff,
294
+ logicBytes: Buffer.byteLength(diff, "utf8"),
295
+ captureBytes: 0,
296
+ sections: [],
297
+ };
298
+ }
299
+ const rawSections = diff.split(/(?=^diff --git )/m);
300
+ const kindOnly = kindOnlyPaths(rawSections);
301
+ const rendered = [];
302
+ const measurements = [];
303
+ for (const section of rawSections) {
304
+ const parsed = parseContentSection(section);
305
+ const paths = sectionPaths(section, parsed);
306
+ const classifications = paths.map((path) => classifyArtifactPath(path, manifest));
307
+ const validCapture = sameCaptureProducer(classifications);
308
+ const producer = validCapture ? classifications[0] : undefined;
309
+ const isKindOnly = paths.some((path) => kindOnly.has(path));
310
+ if (!parsed || !validCapture || !producer || isKindOnly) {
311
+ rendered.push(section);
312
+ measurements.push({
313
+ paths,
314
+ kind: validCapture ? "capture" : "logic",
315
+ reason: validCapture
316
+ ? parsed ? "content-identical-kind-change" : "file-operation-only"
317
+ : classifications.map((row) => row.reason).join(",") || "unparsed-diff-section",
318
+ ...(producer ? { producer: producer.producer, provenance: producer.provenance } : {}),
319
+ logicBytes: Buffer.byteLength(section, "utf8"),
320
+ captureBytes: 0,
321
+ });
322
+ continue;
323
+ }
324
+ const withheld = parsed.lines.slice(parsed.minus).join("\n");
325
+ const captureBytes = Buffer.byteLength(withheld, "utf8");
326
+ const receiptPath = paths.at(-1);
327
+ const receipt = `set aside: regenerable capture ${receiptPath} — ${captureBytes} bytes withheld (producer ${producer.producer}; provenance ${producer.provenance.source}#${producer.provenance.entrypoint}@${producer.provenance.revision})`;
328
+ const compact = `${parsed.lines.slice(0, parsed.minus).join("\n")}\n${receipt}\n`;
329
+ rendered.push(compact);
330
+ measurements.push({
331
+ paths,
332
+ kind: "capture",
333
+ reason: "manifest-provenance",
334
+ producer: producer.producer,
335
+ provenance: producer.provenance,
336
+ logicBytes: Buffer.byteLength(compact, "utf8"),
337
+ captureBytes,
338
+ });
339
+ }
340
+ return {
341
+ rendered: rendered.join(""),
342
+ logicBytes: measurements.reduce((sum, row) => sum + row.logicBytes, 0),
343
+ captureBytes: measurements.reduce((sum, row) => sum + row.captureBytes, 0),
344
+ sections: measurements,
345
+ };
346
+ }
347
+ /** The original API now delegates to the provenance-backed measurement. */
348
+ export function setAsideRegenerableCaptures(diff) {
349
+ return measureArtifactDiff(diff).rendered;
350
+ }
351
+ // The default corpus is about 134 KiB. Capture bytes are bounded, never free;
352
+ // the floor avoids making a deliberately lowered logic cap reintroduce Q14.
353
+ export const MIN_CAPTURE_DIFF_CAP = 1_000_000;
354
+ export const CAPTURE_DIFF_CAP_MULTIPLIER = 4;
355
+ export function captureDiffCapFor(logicCap) {
356
+ return Math.max(MIN_CAPTURE_DIFF_CAP, logicCap * CAPTURE_DIFF_CAP_MULTIPLIER);
357
+ }
@@ -1,12 +1,18 @@
1
1
  import type { TickmarkrConfig } from "../config/config.js";
2
2
  import type { AcceptanceItem } from "../graph/schema.js";
3
+ import { type ShResult } from "../run/git.js";
3
4
  import type { GateResult } from "./types.js";
5
+ export interface BaselineCommand {
6
+ exitCode: number;
7
+ fingerprints: string[];
8
+ missingCommand?: boolean;
9
+ /** What this command actually took at capture, on a pristine tree. Absent in pre-v1.90 baselines. */
10
+ durationMs?: number;
11
+ /** The ceiling that measurement implies, persisted so every later battery uses the same number. */
12
+ ceilingMs?: number;
13
+ }
4
14
  export interface Baseline {
5
- commands: Record<string, {
6
- exitCode: number;
7
- fingerprints: string[];
8
- missingCommand?: boolean;
9
- }>;
15
+ commands: Record<string, BaselineCommand>;
10
16
  warnings?: BaselineWarning[];
11
17
  }
12
18
  export interface BaselineWarning {
@@ -14,9 +20,43 @@ export interface BaselineWarning {
14
20
  commands: string[];
15
21
  reason: string;
16
22
  }
23
+ export type FailureClassification = "regression" | "infra";
24
+ /**
25
+ * What a nonzero runner exit is evidence OF. `undefined` when the output names neither — the
26
+ * unreadable-runner case the existing fail-closed path already owns.
27
+ */
28
+ export declare function classifyFailureOutput(output: string): FailureClassification | undefined;
17
29
  export declare const UNRECOGNIZED_FAILURE = "<unrecognized failure output>";
18
30
  export declare function fingerprint(output: string): string[];
31
+ /**
32
+ * Q121s: the battery's forgiveness math, exported so tip-verify applies the IDENTICAL rule.
33
+ * Returns the failure fingerprints of `raw` that are NOT in the baseline entry (fresh), and
34
+ * whether the output carried no recognizable failure shape at all.
35
+ */
36
+ export declare function freshFailures(entry: BaselineCommand | undefined, raw: string): {
37
+ failing: string[];
38
+ unreadable: boolean;
39
+ };
19
40
  export declare function detectGateCommands(repoRoot: string, cfg: TickmarkrConfig): Record<string, string>;
41
+ /**
42
+ * The ceiling a battery runs under: whichever is larger of the shipped constant and the slack applied
43
+ * to what this command actually measured. A pre-v1.90 baseline (no measurement) gets exactly the
44
+ * shipped constant, so nothing regresses on an existing on-disk baseline.json — the same read-old
45
+ * compat rule the fingerprint renormalizer follows.
46
+ */
47
+ export declare const effectiveCeilingMs: (entry?: Pick<BaselineCommand, "durationMs" | "ceilingMs">) => number;
48
+ /**
49
+ * The ONLY producer of a `ceiling-kill` gate result. It is gated on `timedOut`, which git.ts sets in
50
+ * exactly one place — inside the timer callback that issues the SIGKILL — so a process that exited on
51
+ * its own can never reach this text however it exited: a slow red suite, an unreadable runner, exit
52
+ * 137 from a kill somebody ELSE sent. Returning `undefined` rather than a result keeps that structural:
53
+ * every caller must go on to the ordinary verdict path, it cannot fall through into this one.
54
+ *
55
+ * A kill is not a verdict. The battery was still running when the machine took it away, so its exit
56
+ * code is evidence about the ceiling and nothing about the work — which is why this reports `infra`
57
+ * and never enters baseline forgiveness (there is no runner output to forgive).
58
+ */
59
+ export declare function ceilingKillResult(gate: string, r: ShResult, ceilingMs: number): GateResult | undefined;
20
60
  export declare function captureBaseline(cwd: string, commands: Record<string, string>): Promise<Baseline>;
21
61
  export interface VacuousOracleWarning {
22
62
  kind: "vacuous-oracle";