@yagni-app/code-staging 1.2.4-staging.1809.1 → 1.2.4-staging.1821.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -60,6 +60,7 @@
60
60
  */
61
61
  import { type TreeSnapshotDeps } from "./treeSnapshot.js";
62
62
  import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
63
+ import { makeRunVerify } from "./verify.js";
63
64
  import { type RunBudget } from "./budget.js";
64
65
  import type { Finding, JsonEvent, ModelTier, PipelineStage, StageId, StageTag, StageUsage, StopReason } from "./types.js";
65
66
  /** One-line usage copy, shared by every argument error. */
@@ -152,6 +153,11 @@ export interface HeadlessGoDeps {
152
153
  now?: () => number;
153
154
  runPipeline?: typeof defaultRunPipeline;
154
155
  readFile?: (path: string) => string;
156
+ /**
157
+ * The verify gate a mission run uses, given its `checks.json` (null when
158
+ * there is none). Defaults to the real gate; injected in tests.
159
+ */
160
+ makeRunVerify?: typeof makeRunVerify;
155
161
  /** stdout sink; one call per NDJSON line, newline added by the caller. */
156
162
  write?: (line: string) => void;
157
163
  /** stderr sink for human-readable problems. */
@@ -59,12 +59,15 @@
59
59
  * ceilings (`SANDBOX_RUN_BUDGET`), not interactive /go's.
60
60
  */
61
61
  import { readFileSync } from "node:fs";
62
+ import { dirname, join } from "node:path";
62
63
  import { continuationResumePlan, parseContinuation } from "./continuation.js";
63
64
  import { resolveFanoutMode } from "./fanout.js";
64
65
  import { makeFanoutBeats } from "./fanoutBeats.js";
65
66
  import { normalizeMission } from "./mission.js";
67
+ import { MISSION_CHECKS_FILE_NAME, readMissionChecksFile } from "./missionChecks.js";
66
68
  import { startTreeSnapshots } from "./treeSnapshot.js";
67
69
  import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
70
+ import { makeRunVerify } from "./verify.js";
68
71
  import { loadGroundingEnabled } from "../grounding.js";
69
72
  import { codeStateHome } from "../stateHome.js";
70
73
  import { parseTierCap, TIER_CAP_ENV } from "./tierCap.js";
@@ -319,6 +322,22 @@ export async function runHeadlessGo(argv, deps = {}) {
319
322
  // NDJSON stream as `pipeline_fanout` records, and the mission collector
320
323
  // folds them onto the Run.
321
324
  const fanoutBeats = makeFanoutBeats();
325
+ // A mission's approved checks and its pull request's CI jobs, written by the
326
+ // host beside the ticket: the verify gate runs them inside the loop. No file
327
+ // (a host that predates it) leaves the gate as it was.
328
+ let runVerify;
329
+ if (mission && args.ticketFile) {
330
+ const checksPath = join(dirname(args.ticketFile), MISSION_CHECKS_FILE_NAME);
331
+ const read = readMissionChecksFile(checksPath, readFile);
332
+ if (read.file) {
333
+ const checks = read.file;
334
+ runVerify = (deps.makeRunVerify ?? makeRunVerify)({ repoChecks: () => checks });
335
+ }
336
+ else if (read.problem !== "missing") {
337
+ const why = read.problem === "malformed" ? "not a version 1 checks file" : "it could not be read";
338
+ writeErr(`Ignoring ${checksPath}: ${why}. The verify gate runs its own checks only.`);
339
+ }
340
+ }
322
341
  const pipelineDeps = {
323
342
  cwd,
324
343
  childEnv,
@@ -337,6 +356,7 @@ export async function runHeadlessGo(argv, deps = {}) {
337
356
  ...(deps.stages ? { stages: deps.stages } : {}),
338
357
  grounded: deps.grounded ?? grounding,
339
358
  ...(mission ? { mission } : {}),
359
+ ...(runVerify ? { runVerify } : {}),
340
360
  ...(continuation ? { continuation: { stageAtStop: continuation.stageAtStop } } : {}),
341
361
  ...(resumeFrom ? { resumeFrom } : {}),
342
362
  onEvent: (ev, tag) => {
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `checks.json`, the mission file that hands the build's verify gate what to
3
+ * run besides its own detected scripts: the plan's approved checks (each with
4
+ * what the host already knows: it fails on the unchanged code, or the sandbox
5
+ * cannot start it) and the pull request's CI jobs, read from the repository's
6
+ * workflows at build time.
7
+ *
8
+ * The format's producer is `renderMissionChecksJson` in
9
+ * `packages/backend/src/integrations/e2b/missionFiles.ts`; this package does
10
+ * not depend on the backend, so it reads the same version 1 with its own
11
+ * parser. A change there is a change here. A file that is missing or does not
12
+ * read as version 1 means no repository checks, never a stopped build.
13
+ */
14
+ export declare const MISSION_CHECKS_FILE_NAME = "checks.json";
15
+ export type MissionCheckStatus = "runnable" | "fails_on_base" | "cannot_run";
16
+ export interface MissionCheck {
17
+ command: string;
18
+ status: MissionCheckStatus;
19
+ cause?: string;
20
+ }
21
+ export interface MissionCiStep {
22
+ name: string;
23
+ run: string;
24
+ workingDirectory?: string;
25
+ env?: Record<string, string>;
26
+ }
27
+ export interface MissionCiJob {
28
+ workflow: string;
29
+ job: string;
30
+ name: string;
31
+ paths?: string[];
32
+ pathsIgnore?: string[];
33
+ steps: MissionCiStep[];
34
+ cannotRun: Array<{
35
+ step: string;
36
+ reason: string;
37
+ }>;
38
+ }
39
+ export interface MissionChecksFile {
40
+ version: 1;
41
+ checks: MissionCheck[];
42
+ ciJobs: MissionCiJob[];
43
+ }
44
+ /** The file, or null when the text is not a well-formed version 1 file. */
45
+ export declare function parseMissionChecksFile(text: string): MissionChecksFile | null;
46
+ /**
47
+ * Read the file. `missing` (no such file: a host that predates it, or a run
48
+ * with no mission) is silent; `unreadable` and `malformed` are the caller's to
49
+ * report once.
50
+ */
51
+ export declare function readMissionChecksFile(path: string, readFile?: (path: string) => string): {
52
+ file: MissionChecksFile;
53
+ } | {
54
+ file: null;
55
+ problem: "missing" | "unreadable" | "malformed";
56
+ };
57
+ /** GitHub's filter glob: `*` within one path segment, `**` across segments, `?` one character. */
58
+ export declare function globToRegExp(pattern: string): RegExp;
59
+ /**
60
+ * Whether a change to `changedPaths` runs this job on its pull request, by
61
+ * GitHub's rules: with `paths`, when any changed path matches; with
62
+ * `paths-ignore`, unless every changed path is ignored (a new folder is never
63
+ * taken as ignored). No filter: always. No changed paths: never.
64
+ */
65
+ export declare function ciJobInScope(job: Pick<MissionCiJob, "paths" | "pathsIgnore">, changedPaths: readonly string[]): boolean;
66
+ //# sourceMappingURL=missionChecks.d.ts.map
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `checks.json`, the mission file that hands the build's verify gate what to
3
+ * run besides its own detected scripts: the plan's approved checks (each with
4
+ * what the host already knows: it fails on the unchanged code, or the sandbox
5
+ * cannot start it) and the pull request's CI jobs, read from the repository's
6
+ * workflows at build time.
7
+ *
8
+ * The format's producer is `renderMissionChecksJson` in
9
+ * `packages/backend/src/integrations/e2b/missionFiles.ts`; this package does
10
+ * not depend on the backend, so it reads the same version 1 with its own
11
+ * parser. A change there is a change here. A file that is missing or does not
12
+ * read as version 1 means no repository checks, never a stopped build.
13
+ */
14
+ import { readFileSync } from "node:fs";
15
+ export const MISSION_CHECKS_FILE_NAME = "checks.json";
16
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
17
+ const isString = (value) => typeof value === "string";
18
+ function stringList(value) {
19
+ return Array.isArray(value) && value.every(isString) ? value : null;
20
+ }
21
+ function stringRecord(value) {
22
+ if (!isRecord(value))
23
+ return null;
24
+ return Object.values(value).every(isString) ? value : null;
25
+ }
26
+ function parseStep(value) {
27
+ if (!isRecord(value) || !isString(value.name) || !isString(value.run))
28
+ return null;
29
+ const step = { name: value.name, run: value.run };
30
+ if (value.workingDirectory !== undefined) {
31
+ if (!isString(value.workingDirectory))
32
+ return null;
33
+ step.workingDirectory = value.workingDirectory;
34
+ }
35
+ if (value.env !== undefined) {
36
+ const env = stringRecord(value.env);
37
+ if (!env)
38
+ return null;
39
+ step.env = env;
40
+ }
41
+ return step;
42
+ }
43
+ function parseJob(value) {
44
+ if (!isRecord(value) ||
45
+ !isString(value.workflow) ||
46
+ !isString(value.job) ||
47
+ !isString(value.name))
48
+ return null;
49
+ if (!Array.isArray(value.steps) || !Array.isArray(value.cannotRun))
50
+ return null;
51
+ const steps = [];
52
+ for (const raw of value.steps) {
53
+ const step = parseStep(raw);
54
+ if (!step)
55
+ return null;
56
+ steps.push(step);
57
+ }
58
+ const cannotRun = [];
59
+ for (const raw of value.cannotRun) {
60
+ if (!isRecord(raw) || !isString(raw.step) || !isString(raw.reason))
61
+ return null;
62
+ cannotRun.push({ step: raw.step, reason: raw.reason });
63
+ }
64
+ const job = {
65
+ workflow: value.workflow,
66
+ job: value.job,
67
+ name: value.name,
68
+ steps,
69
+ cannotRun,
70
+ };
71
+ for (const key of ["paths", "pathsIgnore"]) {
72
+ if (value[key] === undefined)
73
+ continue;
74
+ const list = stringList(value[key]);
75
+ if (!list)
76
+ return null;
77
+ job[key] = list;
78
+ }
79
+ return job;
80
+ }
81
+ /** The file, or null when the text is not a well-formed version 1 file. */
82
+ export function parseMissionChecksFile(text) {
83
+ let parsed;
84
+ try {
85
+ parsed = JSON.parse(text);
86
+ }
87
+ catch {
88
+ // Not JSON: the caller reports the null.
89
+ return null;
90
+ }
91
+ if (!isRecord(parsed) ||
92
+ parsed.version !== 1 ||
93
+ !Array.isArray(parsed.checks) ||
94
+ !Array.isArray(parsed.ciJobs)) {
95
+ return null;
96
+ }
97
+ const checks = [];
98
+ for (const raw of parsed.checks) {
99
+ if (!isRecord(raw) || !isString(raw.command))
100
+ return null;
101
+ if (raw.status !== "runnable" && raw.status !== "fails_on_base" && raw.status !== "cannot_run")
102
+ return null;
103
+ const check = { command: raw.command, status: raw.status };
104
+ if (isString(raw.cause))
105
+ check.cause = raw.cause;
106
+ checks.push(check);
107
+ }
108
+ const ciJobs = [];
109
+ for (const raw of parsed.ciJobs) {
110
+ const job = parseJob(raw);
111
+ if (!job)
112
+ return null;
113
+ ciJobs.push(job);
114
+ }
115
+ return { version: 1, checks, ciJobs };
116
+ }
117
+ /**
118
+ * Read the file. `missing` (no such file: a host that predates it, or a run
119
+ * with no mission) is silent; `unreadable` and `malformed` are the caller's to
120
+ * report once.
121
+ */
122
+ export function readMissionChecksFile(path, readFile = (p) => readFileSync(p, "utf8")) {
123
+ let text;
124
+ try {
125
+ text = readFile(path);
126
+ }
127
+ catch (err) {
128
+ const code = isRecord(err) && typeof err.code === "string" ? err.code : null;
129
+ return { file: null, problem: code === "ENOENT" ? "missing" : "unreadable" };
130
+ }
131
+ const file = parseMissionChecksFile(text);
132
+ return file ? { file } : { file: null, problem: "malformed" };
133
+ }
134
+ /** GitHub's filter glob: `*` within one path segment, `**` across segments, `?` one character. */
135
+ export function globToRegExp(pattern) {
136
+ let source = "";
137
+ for (let index = 0; index < pattern.length; index += 1) {
138
+ const char = pattern[index];
139
+ if (char === "*" && pattern[index + 1] === "*") {
140
+ source += ".*";
141
+ index += 1;
142
+ }
143
+ else if (char === "*") {
144
+ source += "[^/]*";
145
+ }
146
+ else if (char === "?") {
147
+ source += "[^/]";
148
+ }
149
+ else {
150
+ source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
151
+ }
152
+ }
153
+ return new RegExp(`^${source}$`);
154
+ }
155
+ /** The part of a pattern before its first glob character. */
156
+ function literalPrefix(pattern) {
157
+ const at = pattern.search(/[*?[]/);
158
+ return at < 0 ? pattern : pattern.slice(0, at);
159
+ }
160
+ /**
161
+ * Whether one changed path matches a pattern. Porcelain reports a new
162
+ * untracked folder as `dir/`, not its files: it matches when the pattern's
163
+ * literal start and the folder overlap, so a doubt runs the job.
164
+ */
165
+ function pathMatches(pattern, path) {
166
+ if (!path.endsWith("/"))
167
+ return globToRegExp(pattern).test(path);
168
+ const prefix = literalPrefix(pattern);
169
+ return prefix.startsWith(path) || path.startsWith(prefix);
170
+ }
171
+ /** An ordered list with `!` exclusions: the last pattern that matches decides. */
172
+ function listMatches(patterns, path) {
173
+ let matched = false;
174
+ for (const raw of patterns) {
175
+ const negated = raw.startsWith("!");
176
+ if (pathMatches(negated ? raw.slice(1) : raw, path))
177
+ matched = !negated;
178
+ }
179
+ return matched;
180
+ }
181
+ /**
182
+ * Whether a change to `changedPaths` runs this job on its pull request, by
183
+ * GitHub's rules: with `paths`, when any changed path matches; with
184
+ * `paths-ignore`, unless every changed path is ignored (a new folder is never
185
+ * taken as ignored). No filter: always. No changed paths: never.
186
+ */
187
+ export function ciJobInScope(job, changedPaths) {
188
+ if (changedPaths.length === 0)
189
+ return false;
190
+ if (job.paths)
191
+ return changedPaths.some((path) => listMatches(job.paths, path));
192
+ if (job.pathsIgnore) {
193
+ return changedPaths.some((path) => path.endsWith("/") || !listMatches(job.pathsIgnore, path));
194
+ }
195
+ return true;
196
+ }
197
+ //# sourceMappingURL=missionChecks.js.map
@@ -46,6 +46,8 @@
46
46
  * command output passes through {@link scrubSecrets} before it can enter
47
47
  * findings, handoffs, or run records (§0.9).
48
48
  */
49
+ import { type ToolchainFile } from "../toolchain/spawnHook.js";
50
+ import { type MissionChecksFile } from "./missionChecks.js";
49
51
  import type { Finding } from "./types.js";
50
52
  /**
51
53
  * Total wall-clock budget for a single verify run before it is abandoned
@@ -55,6 +57,17 @@ import type { Finding } from "./types.js";
55
57
  export declare const VERIFY_TIMEOUT_MS: number;
56
58
  /** Per-PACKAGE wall-clock budget for the test half (spec §3d); overrun fails open. Ten minutes until 2026-10-09. */
57
59
  export declare const VERIFY_TEST_TIMEOUT_MS: number;
60
+ /**
61
+ * The repository-checks half (the plan's approved checks and the pull
62
+ * request's CI steps) across a whole build: the gate runs after implement,
63
+ * after each fix turn and every review round, so a cap per call alone could
64
+ * add an hour to a build.
65
+ */
66
+ export declare const REPO_CHECKS_BUILD_BUDGET_MS: number;
67
+ /** The same half's cap in one gate call, inside the run budget's verify allowance. */
68
+ export declare const REPO_CHECKS_CALL_BUDGET_MS: number;
69
+ /** One approved check's or CI step's cap. */
70
+ export declare const REPO_CHECK_TIMEOUT_MS: number;
58
71
  /** A resolved verify command plus how it was found. */
59
72
  export interface VerifyCommand {
60
73
  /** Shell-free command + args, e.g. ["pnpm", "run", "typecheck"]. */
@@ -116,7 +129,10 @@ export declare function isFannedOutScript(body: string): boolean;
116
129
  * Parse `git status --porcelain` into repo-relative changed paths. Each line is
117
130
  * `XY <path>`; we drop the 2-char XY status + its separating space, and for a
118
131
  * rename/copy (`R old -> new`) take the destination. Pure; the impure git
119
- * shell-out is {@link snapshotWorkspace}.
132
+ * shell-out is {@link snapshotWorkspace}, whose status is trimmed as a whole:
133
+ * a first line ` M path` arrives as `M path`. A whole line always has a space
134
+ * third, so a line without one lost its leading space and its path starts one
135
+ * column earlier.
120
136
  */
121
137
  export declare function parseChangedPaths(status: string): string[];
122
138
  /**
@@ -261,6 +277,25 @@ export declare function buildVerifyEnv(source?: NodeJS.ProcessEnv): NodeJS.Proce
261
277
  * once instead of parking in watch mode until the 10-min cap kills it.
262
278
  */
263
279
  export declare function buildTestEnv(source?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
280
+ /**
281
+ * The environment an approved check or CI step runs under: the test half's
282
+ * (no `YAGNI_*`, no NODE_OPTIONS, CI on) minus the secrets the host strips
283
+ * from its own check runs, on the repository's toolchain when the build named
284
+ * one (the same PATH and variables the model's shell gets), then the step's
285
+ * own literal variables.
286
+ */
287
+ export declare function buildRepoCheckEnv(source?: NodeJS.ProcessEnv, toolchain?: ToolchainFile | null, stepEnv?: Record<string, string>): NodeJS.ProcessEnv;
288
+ /** One porcelain line's path, and whether it is untracked. */
289
+ interface TreeEntry {
290
+ path: string;
291
+ untracked: boolean;
292
+ }
293
+ /**
294
+ * The porcelain entries in `after` that `before` did not have: what the
295
+ * repository checks wrote into the tree. A path that was already dirty is the
296
+ * build's, and is not listed. Pure.
297
+ */
298
+ export declare function newTreeEntries(before: string, after: string): TreeEntry[];
264
299
  export declare function makeDefaultExec(opts?: {
265
300
  graceMs?: number;
266
301
  killTree?: (pid: number) => void;
@@ -289,6 +324,29 @@ export interface MakeRunVerifyOpts {
289
324
  testTimeoutMs?: number;
290
325
  /** Test-half env override for determinism in tests (default: buildTestEnv() per run). */
291
326
  testEnv?: NodeJS.ProcessEnv;
327
+ /**
328
+ * The mission's `checks.json` (the approved checks and the pull request's CI
329
+ * jobs). Absent or null: no repository-checks half, as before.
330
+ */
331
+ repoChecks?: () => MissionChecksFile | null;
332
+ /**
333
+ * This run's own changed paths (repo-relative), which scope the CI jobs by
334
+ * their `paths` filters. Default: the same `git status` the scope reads,
335
+ * minus the pre-run dirt.
336
+ */
337
+ changedPaths?: (cwd: string, signal?: AbortSignal, baselinePaths?: string[]) => Promise<string[]>;
338
+ /** Repository-checks env override for determinism in tests (default: buildRepoCheckEnv() per run). */
339
+ repoChecksEnv?: NodeJS.ProcessEnv;
340
+ /** The toolchain the repository checks run on (default: `YAGNI_TOOLCHAIN_FILE`, when it reads). */
341
+ toolchain?: () => ToolchainFile | null;
342
+ repoChecksBuildBudgetMs?: number;
343
+ repoChecksCallBudgetMs?: number;
344
+ repoCheckTimeoutMs?: number;
345
+ now?: () => number;
346
+ /** The tree's porcelain status before and after the half (default: `git status`). */
347
+ treeStatus?: (cwd: string, signal?: AbortSignal) => Promise<string>;
348
+ /** Undo what the half wrote into the tree (default: `git clean` / `git checkout` of exactly those paths). */
349
+ undoTreeWrites?: (cwd: string, entries: TreeEntry[]) => Promise<void>;
292
350
  }
293
351
  /**
294
352
  * One workstream as the scoped checker sees it: the name its findings are
@@ -365,4 +423,5 @@ export declare function makeWorkstreamCheck(opts?: MakeWorkstreamCheckOpts): (cw
365
423
  * blocks a real change on its own failure.
366
424
  */
367
425
  export declare function makeRunVerify(opts?: MakeRunVerifyOpts): (cwd: string, signal?: AbortSignal, baselinePaths?: string[]) => Promise<VerifyOutcome>;
426
+ export {};
368
427
  //# sourceMappingURL=verify.d.ts.map
@@ -54,8 +54,10 @@ import { readFileSync } from "node:fs";
54
54
  // backslashed strings that no longer compare equal to the git-derived repoRoot
55
55
  // and would break the walk-up termination check.
56
56
  import { basename, dirname, join } from "node:path/posix";
57
+ import { readToolchainFile } from "../toolchain/spawnHook.js";
57
58
  import { killTreeSync, trackChild } from "./childRegistry.js";
58
59
  import { claimCovers } from "./fanout.js";
60
+ import { ciJobInScope } from "./missionChecks.js";
59
61
  import { composeAbortSignal } from "./resilience.js";
60
62
  import { scrubSecrets } from "./scrubSecrets.js";
61
63
  import { snapshotWorkspace } from "./workspace.js";
@@ -67,6 +69,21 @@ import { snapshotWorkspace } from "./workspace.js";
67
69
  export const VERIFY_TIMEOUT_MS = 20 * 60_000;
68
70
  /** Per-PACKAGE wall-clock budget for the test half (spec §3d); overrun fails open. Ten minutes until 2026-10-09. */
69
71
  export const VERIFY_TEST_TIMEOUT_MS = 20 * 60_000;
72
+ /**
73
+ * The repository-checks half (the plan's approved checks and the pull
74
+ * request's CI steps) across a whole build: the gate runs after implement,
75
+ * after each fix turn and every review round, so a cap per call alone could
76
+ * add an hour to a build.
77
+ */
78
+ export const REPO_CHECKS_BUILD_BUDGET_MS = 30 * 60_000;
79
+ /** The same half's cap in one gate call, inside the run budget's verify allowance. */
80
+ export const REPO_CHECKS_CALL_BUDGET_MS = 15 * 60_000;
81
+ /** One approved check's or CI step's cap. */
82
+ export const REPO_CHECK_TIMEOUT_MS = 10 * 60_000;
83
+ /** Below this much budget left, the next repository check is not started. */
84
+ const REPO_CHECK_MIN_START_MS = 5_000;
85
+ /** Cannot-run entries named in the note before "and N more". */
86
+ const MAX_NAMED_CANNOT_RUN = 10;
70
87
  /** Cap on located findings parsed from one failing run, and on the opaque-tail message. */
71
88
  const MAX_LOCATED_FINDINGS = 25;
72
89
  const MAX_TAIL_CHARS = 1000;
@@ -129,14 +146,17 @@ export function isFannedOutScript(body) {
129
146
  * Parse `git status --porcelain` into repo-relative changed paths. Each line is
130
147
  * `XY <path>`; we drop the 2-char XY status + its separating space, and for a
131
148
  * rename/copy (`R old -> new`) take the destination. Pure; the impure git
132
- * shell-out is {@link snapshotWorkspace}.
149
+ * shell-out is {@link snapshotWorkspace}, whose status is trimmed as a whole:
150
+ * a first line ` M path` arrives as `M path`. A whole line always has a space
151
+ * third, so a line without one lost its leading space and its path starts one
152
+ * column earlier.
133
153
  */
134
154
  export function parseChangedPaths(status) {
135
155
  const paths = [];
136
156
  for (const raw of status.split("\n")) {
137
- if (raw.length < 4)
138
- continue; // need at least "XY p"
139
- const entry = raw.slice(3); // drop the 2-char XY status + its separating space
157
+ if (raw.length < 3)
158
+ continue; // need at least "X p"
159
+ const entry = raw[2] === " " ? raw.slice(3) : raw.slice(2);
140
160
  const arrow = entry.indexOf(" -> ");
141
161
  const path = (arrow >= 0 ? entry.slice(arrow + 4) : entry).trim();
142
162
  if (path)
@@ -640,6 +660,70 @@ export function buildTestEnv(source = process.env) {
640
660
  env.CI = "true";
641
661
  return env;
642
662
  }
663
+ /**
664
+ * The secrets a repository check never sees: a copy of the host's
665
+ * `VERIFICATION_SECRET_ENV_VARS` in
666
+ * `packages/backend/src/integrations/e2b/supervisedCandidateSession.ts`
667
+ * (`YAGNI_*` goes with the test env). This package cannot import the backend:
668
+ * a change to either list is a change to both.
669
+ */
670
+ const REPO_CHECK_SECRET_KEYS = [
671
+ "OPENAI_API_KEY",
672
+ "ANTHROPIC_API_KEY",
673
+ "FIREWORKS_API_KEY",
674
+ "BASETEN_API_KEY",
675
+ "GITHUB_TOKEN",
676
+ "GH_TOKEN",
677
+ ];
678
+ /**
679
+ * The environment an approved check or CI step runs under: the test half's
680
+ * (no `YAGNI_*`, no NODE_OPTIONS, CI on) minus the secrets the host strips
681
+ * from its own check runs, on the repository's toolchain when the build named
682
+ * one (the same PATH and variables the model's shell gets), then the step's
683
+ * own literal variables.
684
+ */
685
+ export function buildRepoCheckEnv(source = process.env, toolchain = null, stepEnv = {}) {
686
+ const env = buildTestEnv(source);
687
+ for (const key of REPO_CHECK_SECRET_KEYS)
688
+ delete env[key];
689
+ const withToolchain = toolchain ? { ...toolchain.env, ...env, PATH: toolchain.path } : env;
690
+ return { ...withToolchain, ...stepEnv, CI: "true" };
691
+ }
692
+ /** One repository command as the gate runs it: through bash, as Actions runs a `run:` step. */
693
+ function repoCheckArgv(run) {
694
+ return ["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", run];
695
+ }
696
+ /**
697
+ * The porcelain entries in `after` that `before` did not have: what the
698
+ * repository checks wrote into the tree. A path that was already dirty is the
699
+ * build's, and is not listed. Pure.
700
+ */
701
+ export function newTreeEntries(before, after) {
702
+ const known = new Set(parseChangedPaths(before));
703
+ return after
704
+ .split("\n")
705
+ .filter((line) => line.trim().length >= 3)
706
+ .flatMap((line) => {
707
+ const [path] = parseChangedPaths(line);
708
+ if (!path || known.has(path))
709
+ return [];
710
+ return [{ path, untracked: line.trimStart().startsWith("??") }];
711
+ });
712
+ }
713
+ function runGitQuiet(args, cwd) {
714
+ return new Promise((resolve, reject) => {
715
+ execFile("git", args, { cwd, maxBuffer: 1024 * 1024 }, (err) => (err ? reject(err) : resolve()));
716
+ });
717
+ }
718
+ /** Undo what the repository checks wrote: an untracked path is removed, a tracked one restored. */
719
+ async function defaultUndoTreeWrites(cwd, entries) {
720
+ const untracked = entries.filter((entry) => entry.untracked).map((entry) => entry.path);
721
+ const tracked = entries.filter((entry) => !entry.untracked).map((entry) => entry.path);
722
+ if (untracked.length > 0)
723
+ await runGitQuiet(["clean", "-fdq", "--", ...untracked], cwd);
724
+ if (tracked.length > 0)
725
+ await runGitQuiet(["checkout", "-q", "--", ...tracked], cwd);
726
+ }
643
727
  // `signal` is deliberately NOT passed into execFile: Node's built-in abort
644
728
  // handling kills via the child's pid, and when the spawn itself failed (missing
645
729
  // command, missing cwd) an abort landing in the same tick resolves that to pid 0
@@ -845,7 +929,213 @@ export function makeRunVerify(opts = {}) {
845
929
  const exec = opts.exec ?? defaultExec;
846
930
  const timeoutMs = opts.timeoutMs ?? VERIFY_TIMEOUT_MS;
847
931
  const testTimeoutMs = opts.testTimeoutMs ?? VERIFY_TEST_TIMEOUT_MS;
932
+ const now = opts.now ?? Date.now;
933
+ const changedPaths = opts.changedPaths ??
934
+ (async (cwd, signal, baselinePaths) => {
935
+ const snapshot = await snapshotWorkspace(cwd, signal);
936
+ if (!snapshot.tracked)
937
+ return [];
938
+ const baseline = new Set(baselinePaths ?? []);
939
+ return parseChangedPaths(snapshot.status).filter((p) => !baseline.has(p));
940
+ });
941
+ const toolchain = opts.toolchain ??
942
+ (() => {
943
+ const path = process.env.YAGNI_TOOLCHAIN_FILE?.trim();
944
+ return path ? readToolchainFile(path) : null;
945
+ });
946
+ const treeStatus = opts.treeStatus ?? (async (cwd, signal) => (await snapshotWorkspace(cwd, signal)).status);
947
+ const undoTreeWrites = opts.undoTreeWrites ?? defaultUndoTreeWrites;
948
+ // Across the whole build: the half's time spent, and each CI step's failure
949
+ // keys from the last call it failed in.
950
+ let repoChecksSpentMs = 0;
951
+ const lastCiFailure = new Map();
952
+ /**
953
+ * The repository-checks half: the plan's approved checks, then the pull
954
+ * request's CI steps its changed paths trigger. A failure becomes `critical`
955
+ * findings the fix turn works on, except where it can never clear: a check
956
+ * the sandbox cannot start, an infra failure, an approved check that already
957
+ * failed on the unchanged code (the host compares it after the build), and a
958
+ * CI step that fails exactly as it did before a fix turn (it may fail on the
959
+ * base too, and the pull request's CI shows it). Those are notes.
960
+ */
961
+ async function runRepoChecks(input) {
962
+ const { file, cwd, signal } = input;
963
+ const findings = [];
964
+ const reasons = [];
965
+ const labels = [];
966
+ const cannotRun = [];
967
+ for (const check of file.checks) {
968
+ if (check.status === "cannot_run")
969
+ cannotRun.push(`\`${check.command}\`${check.cause ? ` (${check.cause})` : ""}`);
970
+ }
971
+ const paths = await changedPaths(cwd, signal, input.baselinePaths);
972
+ const jobs = file.ciJobs.filter((job) => ciJobInScope(job, paths));
973
+ for (const job of jobs) {
974
+ for (const entry of job.cannotRun)
975
+ cannotRun.push(`CI ${job.name}, "${entry.step}" (${entry.reason})`);
976
+ }
977
+ if (cannotRun.length > 0) {
978
+ const named = cannotRun.slice(0, MAX_NAMED_CANNOT_RUN).join("; ");
979
+ const more = cannotRun.length > MAX_NAMED_CANNOT_RUN ? `; and ${cannotRun.length - MAX_NAMED_CANNOT_RUN} more` : "";
980
+ reasons.push(`could not run in the sandbox: ${named}${more}`);
981
+ }
982
+ const approved = file.checks.filter((check) => check.status !== "cannot_run");
983
+ const steps = jobs.flatMap((job) => job.steps.map((step) => ({ job, step })));
984
+ if (approved.length === 0 && steps.length === 0)
985
+ return { findings, reasons, labels };
986
+ const buildLeft = (opts.repoChecksBuildBudgetMs ?? REPO_CHECKS_BUILD_BUDGET_MS) - repoChecksSpentMs;
987
+ if (buildLeft < REPO_CHECK_MIN_START_MS) {
988
+ reasons.push("approved checks and CI steps not run again: the build's check budget is spent; the host still runs the approved checks after the build");
989
+ return { findings, reasons, labels };
990
+ }
991
+ const callBudget = Math.min(opts.repoChecksCallBudgetMs ?? REPO_CHECKS_CALL_BUDGET_MS, timeoutMs - (now() - input.callStartedAt), buildLeft);
992
+ const halfStartedAt = now();
993
+ const run = {
994
+ cwd,
995
+ signal,
996
+ left: () => callBudget - (now() - halfStartedAt),
997
+ toolchainFile: toolchain(),
998
+ findings,
999
+ reasons,
1000
+ labels,
1001
+ notReached: 0,
1002
+ };
1003
+ const before = await treeStatus(cwd, signal).catch((err) => {
1004
+ const msg = err instanceof Error ? err.message : String(err);
1005
+ reasons.push(`could not read the tree before the checks, so what they write is not undone: ${scrubSecrets(msg)}`);
1006
+ return null;
1007
+ });
1008
+ try {
1009
+ await runApprovedChecks(run, approved);
1010
+ await runCiSteps(run, steps, new Set(file.checks.map((check) => check.command.trim())));
1011
+ }
1012
+ finally {
1013
+ repoChecksSpentMs += now() - halfStartedAt;
1014
+ // What the checks wrote into the tree (a coverage folder, an updated
1015
+ // snapshot, a build output) is not the build's change: undone, so the
1016
+ // candidate never carries it.
1017
+ if (before !== null) {
1018
+ try {
1019
+ const written = newTreeEntries(before, await treeStatus(cwd));
1020
+ if (written.length > 0)
1021
+ await undoTreeWrites(cwd, written);
1022
+ }
1023
+ catch (err) {
1024
+ const msg = err instanceof Error ? err.message : String(err);
1025
+ reasons.push(`could not undo what the checks wrote into the tree: ${scrubSecrets(msg)}`);
1026
+ }
1027
+ }
1028
+ }
1029
+ if (run.notReached > 0) {
1030
+ reasons.push(`${run.notReached} approved check(s) or CI step(s) not reached in the gate's time budget; the host still runs the approved checks after the build`);
1031
+ }
1032
+ return { findings, reasons, labels };
1033
+ }
1034
+ /** One repository command's verdict: passed, a note (it could not run), or a failure with its output. */
1035
+ async function runRepoCommand(run, command, dir, stepEnv) {
1036
+ const capMs = Math.min(opts.repoCheckTimeoutMs ?? REPO_CHECK_TIMEOUT_MS, run.left());
1037
+ const composed = composeAbortSignal(run.signal, AbortSignal.timeout(Math.max(1, capMs)));
1038
+ const env = opts.repoChecksEnv
1039
+ ? { ...opts.repoChecksEnv, ...(stepEnv ?? {}) }
1040
+ : buildRepoCheckEnv(process.env, run.toolchainFile, stepEnv);
1041
+ try {
1042
+ const { code, output: rawOutput } = await exec(repoCheckArgv(command), dir, composed, env);
1043
+ if (code === 0)
1044
+ return { kind: "passed" };
1045
+ const output = scrubSecrets(rawOutput);
1046
+ const cls = classifyTestFailure(output, code);
1047
+ if (cls.kind === "infra")
1048
+ return { kind: "note", reason: `could not run: ${cls.reason}` };
1049
+ return { kind: "failed", output, code };
1050
+ }
1051
+ catch (err) {
1052
+ const msg = err instanceof Error ? err.message : String(err);
1053
+ return { kind: "note", reason: `could not run: ${scrubSecrets(msg)}` };
1054
+ }
1055
+ }
1056
+ /** A failure's findings, located from the folder the command ran in and prefixed with what ran. */
1057
+ function repoFailureFindings(run, failure, label, prefix, dir) {
1058
+ const cls = classifyTestFailure(failure.output, failure.code);
1059
+ const parsed = cls.kind === "failures" ? cls.findings : parseVerifyFailures(failure.output, failure.code, label);
1060
+ return parsed.map((f) => ({ ...liftFinding(f, run.cwd, dir), message: `${prefix}: ${f.message}` }));
1061
+ }
1062
+ const findingRoom = (run) => Math.max(0, MAX_LOCATED_FINDINGS - run.findings.length);
1063
+ /**
1064
+ * The plan's approved checks, at the repository root as the host runs them.
1065
+ * One that already failed on the unchanged code is a note: the host compares
1066
+ * the two after the build.
1067
+ */
1068
+ async function runApprovedChecks(run, approved) {
1069
+ for (const check of approved) {
1070
+ if (run.signal?.aborted)
1071
+ return;
1072
+ if (run.left() < REPO_CHECK_MIN_START_MS) {
1073
+ run.notReached += 1;
1074
+ continue;
1075
+ }
1076
+ const label = check.command.length > 80 ? `${check.command.slice(0, 77)}...` : check.command;
1077
+ const verdict = await runRepoCommand(run, check.command, run.cwd, undefined);
1078
+ if (verdict.kind === "note") {
1079
+ run.reasons.push(`approved check \`${label}\` ${verdict.reason}`);
1080
+ }
1081
+ else if (verdict.kind === "failed" && check.status === "fails_on_base") {
1082
+ run.reasons.push(`approved check \`${label}\` failed, and it already failed on the unchanged code; the host compares the two after the build`);
1083
+ }
1084
+ else {
1085
+ run.labels.push(label);
1086
+ if (verdict.kind === "failed") {
1087
+ const found = repoFailureFindings(run, verdict, label, `approved check \`${label}\``, run.cwd);
1088
+ run.findings.push(...found.slice(0, findingRoom(run)));
1089
+ }
1090
+ }
1091
+ }
1092
+ }
1093
+ /**
1094
+ * The pull request's CI steps, job by job. A job stops at its first failing
1095
+ * step, as it does on the runner; a step that is one of the approved checks
1096
+ * already ran. A step that fails with the same findings it failed with on
1097
+ * the previous call (so after a fix turn) is a note: it may fail on the base
1098
+ * too, and the pull request's CI shows it.
1099
+ */
1100
+ async function runCiSteps(run, steps, approvedCommands) {
1101
+ const stoppedJobs = new Set();
1102
+ for (const { job, step } of steps) {
1103
+ if (run.signal?.aborted)
1104
+ return;
1105
+ if (stoppedJobs.has(job) || approvedCommands.has(step.run.trim()))
1106
+ continue;
1107
+ if (run.left() < REPO_CHECK_MIN_START_MS) {
1108
+ run.notReached += 1;
1109
+ continue;
1110
+ }
1111
+ const where = `CI ${job.name}, step "${step.name}"`;
1112
+ const key = `${job.workflow}\u0000${job.job}\u0000${step.name}\u0000${step.run}`;
1113
+ const dir = step.workingDirectory ? join(run.cwd, step.workingDirectory.replace(/^\.\/+/, "")) : run.cwd;
1114
+ const verdict = await runRepoCommand(run, step.run, dir, step.env);
1115
+ if (verdict.kind === "passed") {
1116
+ lastCiFailure.delete(key);
1117
+ run.labels.push(`CI ${job.name}: ${step.name}`);
1118
+ continue;
1119
+ }
1120
+ stoppedJobs.add(job);
1121
+ if (verdict.kind === "note") {
1122
+ run.reasons.push(`${where} ${verdict.reason}`);
1123
+ continue;
1124
+ }
1125
+ const stepFindings = repoFailureFindings(run, verdict, step.name, where, dir);
1126
+ const keys = new Set(stepFindings.map((f) => f.message));
1127
+ const previous = lastCiFailure.get(key);
1128
+ lastCiFailure.set(key, keys);
1129
+ if (previous && keys.size > 0 && [...keys].every((k) => previous.has(k))) {
1130
+ run.reasons.push(`${where} still fails the same way after a fix turn; it may fail on the base too, and the pull request's CI shows it`);
1131
+ continue;
1132
+ }
1133
+ run.labels.push(`CI ${job.name}: ${step.name}`);
1134
+ run.findings.push(...stepFindings.slice(0, findingRoom(run)));
1135
+ }
1136
+ }
848
1137
  return async function runVerify(cwd, signal, baselinePaths) {
1138
+ const callStartedAt = now();
849
1139
  const dirs = await changedDirs(cwd, signal, baselinePaths);
850
1140
  const commands = detect(cwd, dirs);
851
1141
  // An explicitly-allowed .yagni-code/verify override IS the whole gate (spec
@@ -853,7 +1143,10 @@ export function makeRunVerify(opts = {}) {
853
1143
  // alongside it — the repo already told us exactly how to verify.
854
1144
  const overridden = commands.some((c) => c.source === "override");
855
1145
  const testCommands = overridden ? [] : detectTests(cwd, dirs);
856
- if (commands.length === 0 && testCommands.length === 0) {
1146
+ // The approved checks carry the plan's approval, not the repository's own
1147
+ // choice of how to verify, so an override never takes them out of the loop.
1148
+ const repoFile = opts.repoChecks?.() ?? null;
1149
+ if (commands.length === 0 && testCommands.length === 0 && !repoFile) {
857
1150
  return { ran: false, ok: true, reason: "verify skipped: no verify command detected", findings: [] };
858
1151
  }
859
1152
  const findings = [];
@@ -898,6 +1191,7 @@ export function makeRunVerify(opts = {}) {
898
1191
  // root cause, and the loop must fix the compile errors first anyway; the
899
1192
  // round where typecheck goes green runs the tests, so `clean` is never
900
1193
  // reached without the tests having run (or their absence honestly noted).
1194
+ const typecheckFailed = findings.length > 0;
901
1195
  const testLabels = [];
902
1196
  if (testCommands.length > 0) {
903
1197
  if (findings.length > 0) {
@@ -939,10 +1233,27 @@ export function makeRunVerify(opts = {}) {
939
1233
  }
940
1234
  }
941
1235
  }
942
- // The command labels list only what produced a verdict — on BOTH the
943
- // typecheck and test halves — so the FINISH trailer's `passed (<cmds>)`
944
- // never claims a check that was infra-skipped or threw (invariant §0.1).
945
- const command = [...new Set(typecheckLabels), ...new Set(testLabels)].join(", ");
1236
+ // REPOSITORY-CHECKS HALF: the plan's approved checks and the pull
1237
+ // request's CI steps (`checks.json`), after the test half, under the same
1238
+ // rule: a tree that does not compile fails them for the same root cause.
1239
+ const repoLabels = [];
1240
+ if (repoFile) {
1241
+ if (typecheckFailed) {
1242
+ reasons.push("approved checks and CI steps skipped: the typecheck half failed; a later round runs them");
1243
+ }
1244
+ else {
1245
+ const repo = await runRepoChecks({ file: repoFile, cwd, signal, baselinePaths, callStartedAt });
1246
+ reasons.push(...repo.reasons);
1247
+ repoLabels.push(...repo.labels);
1248
+ if (repo.labels.length > 0)
1249
+ anyRan = true;
1250
+ findings.push(...repo.findings);
1251
+ }
1252
+ }
1253
+ // The command labels list only what produced a verdict — on every half —
1254
+ // so the FINISH trailer's `passed (<cmds>)` never claims a check that was
1255
+ // infra-skipped or threw (invariant §0.1).
1256
+ const command = [...new Set(typecheckLabels), ...new Set(testLabels), ...new Set(repoLabels)].join(", ");
946
1257
  if (!anyRan) {
947
1258
  return { ran: false, ok: true, command, reason: reasons.join("; ") || "verify skipped", findings: [] };
948
1259
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.2.4-staging.1809.1",
3
+ "version": "1.2.4-staging.1821.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "33de16e5a97a30639624793c8e9f9d5120b7aea8"
61
+ "yagniSourceSha": "60f51163d9b1184bea0c2dca85cf2b7d07d6d275"
62
62
  }