@yagni-app/code-staging 1.2.9-staging.2082.1 → 1.2.10-staging.2090.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.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The attempt's base commit's files, for what a resumed session measures
3
+ * before it builds.
4
+ *
5
+ * A fresh build measures the gate's checks on its clean tree, which is the
6
+ * attempt's base (YAG-1003, `measureOnBase`), so a failure the base already
7
+ * has is a note. A resumed session (a continuation, a fix round) starts on the
8
+ * branch past the earlier sessions' commits, so it used to measure nothing,
9
+ * and every failure the base already had was its own to fix: COREX-1291's
10
+ * sessions each took main's lint errors as findings, ended `round_cap`, and
11
+ * were resumed again (production, 2026-10-26).
12
+ *
13
+ * {@link measureOnBaseTree} puts the base commit's files in the checkout for
14
+ * one measurement and then puts the session's tree back exactly: HEAD, its
15
+ * branch, the index, every file git sees (`git stash --include-untracked`
16
+ * holds the uncommitted state meanwhile), the untracked empty folders and the
17
+ * uncommitted files' modes, which git keeps no record of. Ignored files stay
18
+ * where they are, so the clone's installed dependencies serve both trees; an
19
+ * attempt whose commits changed what is installed is therefore never
20
+ * measured, since the base's sources on the change's dependencies would
21
+ * excuse what the dependency change broke. It measures only when it can put
22
+ * the tree back: anything it cannot be sure of is a skip that leaves the tree
23
+ * as it was. A tree it cannot put back is a {@link BaseTreeRestoreError},
24
+ * never a quiet build on the wrong tree. Hooks are off for every call: a
25
+ * repository's post-checkout hook must not run.
26
+ */
27
+ /** One git call's outcome; a spawn error or a timeout is code -1. */
28
+ export interface BaseTreeGitResult {
29
+ code: number;
30
+ stdout: string;
31
+ stderr: string;
32
+ }
33
+ /** The git seam (default: real git, hooks off); injected in tests. */
34
+ export type BaseTreeGit = (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<BaseTreeGitResult>;
35
+ export declare const defaultBaseTreeGit: BaseTreeGit;
36
+ /** A commit id the session may put in a git command line, or undefined. */
37
+ export declare function baseCommitOf(value: string | undefined): string | undefined;
38
+ /** The tree put back after a measurement it could not be sure of. */
39
+ export declare class BaseTreeRestoreError extends Error {
40
+ /** The session's HEAD, its branch (null when detached) and the stash holding its uncommitted state. */
41
+ readonly head: string;
42
+ readonly branch: string | null;
43
+ readonly stash: string | null;
44
+ constructor(message: string, state: {
45
+ head: string;
46
+ branch: string | null;
47
+ stash: string | null;
48
+ });
49
+ }
50
+ export type BaseTreeOutcome<T> = {
51
+ kind: "measured";
52
+ value: T;
53
+ } | {
54
+ kind: "skipped";
55
+ reason: string;
56
+ };
57
+ /** The paths the session's HEAD changed since `base`, relative to `cwd`; null when git cannot say. */
58
+ export declare function pathsCommittedSince(cwd: string, base: string, git?: BaseTreeGit): Promise<string[] | null>;
59
+ /**
60
+ * Run `measure` on the files of the commit `base`, then put the tree back as
61
+ * it was. `skipped` (with why) when it could not start, the tree as it was;
62
+ * throws {@link BaseTreeRestoreError} when the tree could not be put back
63
+ * exactly, and rethrows what `measure` threw once the tree is back.
64
+ */
65
+ export declare function measureOnBaseTree<T>(cwd: string, base: string, measure: () => Promise<T>, opts?: {
66
+ git?: BaseTreeGit;
67
+ }): Promise<BaseTreeOutcome<T>>;
68
+ //# sourceMappingURL=baseTree.d.ts.map
@@ -0,0 +1,522 @@
1
+ /**
2
+ * The attempt's base commit's files, for what a resumed session measures
3
+ * before it builds.
4
+ *
5
+ * A fresh build measures the gate's checks on its clean tree, which is the
6
+ * attempt's base (YAG-1003, `measureOnBase`), so a failure the base already
7
+ * has is a note. A resumed session (a continuation, a fix round) starts on the
8
+ * branch past the earlier sessions' commits, so it used to measure nothing,
9
+ * and every failure the base already had was its own to fix: COREX-1291's
10
+ * sessions each took main's lint errors as findings, ended `round_cap`, and
11
+ * were resumed again (production, 2026-10-26).
12
+ *
13
+ * {@link measureOnBaseTree} puts the base commit's files in the checkout for
14
+ * one measurement and then puts the session's tree back exactly: HEAD, its
15
+ * branch, the index, every file git sees (`git stash --include-untracked`
16
+ * holds the uncommitted state meanwhile), the untracked empty folders and the
17
+ * uncommitted files' modes, which git keeps no record of. Ignored files stay
18
+ * where they are, so the clone's installed dependencies serve both trees; an
19
+ * attempt whose commits changed what is installed is therefore never
20
+ * measured, since the base's sources on the change's dependencies would
21
+ * excuse what the dependency change broke. It measures only when it can put
22
+ * the tree back: anything it cannot be sure of is a skip that leaves the tree
23
+ * as it was. A tree it cannot put back is a {@link BaseTreeRestoreError},
24
+ * never a quiet build on the wrong tree. Hooks are off for every call: a
25
+ * repository's post-checkout hook must not run.
26
+ */
27
+ import { execFile } from "node:child_process";
28
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { basename, join, resolve as resolvePath } from "node:path";
31
+ import { trackChild } from "./childRegistry.js";
32
+ const GIT_ARGS = [
33
+ "-c",
34
+ "core.hooksPath=/dev/null",
35
+ "-c",
36
+ "user.name=YAGNI",
37
+ "-c",
38
+ "user.email=worker@yagni.local",
39
+ ];
40
+ /** One git call's cap: a stash or a checkout of a large tree takes a while. */
41
+ const GIT_TIMEOUT_MS = 5 * 60_000;
42
+ export const defaultBaseTreeGit = (args, cwd, env) => new Promise((resolve) => {
43
+ const child = execFile("git", [...GIT_ARGS, ...args], {
44
+ cwd,
45
+ env: { ...process.env, ...env, GIT_TERMINAL_PROMPT: "0" },
46
+ maxBuffer: 64 * 1024 * 1024,
47
+ timeout: GIT_TIMEOUT_MS,
48
+ }, (err, stdout, stderr) => {
49
+ const code = err?.code;
50
+ resolve({
51
+ code: err ? (typeof code === "number" ? code : -1) : 0,
52
+ stdout: String(stdout ?? ""),
53
+ stderr: String(stderr ?? err?.message ?? ""),
54
+ });
55
+ });
56
+ trackChild(child);
57
+ });
58
+ const COMMIT_SHA = /^[0-9a-f]{40}$/;
59
+ /** A commit id the session may put in a git command line, or undefined. */
60
+ export function baseCommitOf(value) {
61
+ return value && COMMIT_SHA.test(value) ? value : undefined;
62
+ }
63
+ /** The tree put back after a measurement it could not be sure of. */
64
+ export class BaseTreeRestoreError extends Error {
65
+ /** The session's HEAD, its branch (null when detached) and the stash holding its uncommitted state. */
66
+ head;
67
+ branch;
68
+ stash;
69
+ constructor(message, state) {
70
+ super(message);
71
+ this.name = "BaseTreeRestoreError";
72
+ this.head = state.head;
73
+ this.branch = state.branch;
74
+ this.stash = state.stash;
75
+ }
76
+ }
77
+ const errorText = (err) => (err instanceof Error ? err.message : String(err));
78
+ const firstLine = (text) => text.trim().split("\n")[0]?.trim() ?? "";
79
+ function why(result) {
80
+ return firstLine(result.stderr) || firstLine(result.stdout) || `exit ${result.code}`;
81
+ }
82
+ const skipped = (reason) => ({
83
+ kind: "skipped",
84
+ reason,
85
+ });
86
+ /** The paths the session's HEAD changed since `base`, relative to `cwd`; null when git cannot say. */
87
+ export async function pathsCommittedSince(cwd, base, git = defaultBaseTreeGit) {
88
+ const diff = await git(["diff", "--name-only", "-z", "--no-renames", "--relative", base, "HEAD"], cwd);
89
+ return diff.code === 0 ? diff.stdout.split("\0").filter(Boolean) : null;
90
+ }
91
+ /** Files that say which dependencies or which toolchain a checkout installs. */
92
+ const DEPENDENCY_FILES = new Set([
93
+ "pnpm-lock.yaml",
94
+ "pnpm-workspace.yaml",
95
+ "yarn.lock",
96
+ ".yarnrc.yml",
97
+ "package-lock.json",
98
+ "npm-shrinkwrap.json",
99
+ "bun.lock",
100
+ "bun.lockb",
101
+ ".npmrc",
102
+ "composer.json",
103
+ "composer.lock",
104
+ "Gemfile",
105
+ "Gemfile.lock",
106
+ "go.mod",
107
+ "go.sum",
108
+ "go.work",
109
+ "go.work.sum",
110
+ "Cargo.toml",
111
+ "Cargo.lock",
112
+ "pyproject.toml",
113
+ "poetry.lock",
114
+ "uv.lock",
115
+ "Pipfile",
116
+ "Pipfile.lock",
117
+ "setup.py",
118
+ "setup.cfg",
119
+ "pom.xml",
120
+ "build.gradle",
121
+ "build.gradle.kts",
122
+ "gradle.lockfile",
123
+ "global.json",
124
+ "packages.lock.json",
125
+ "Directory.Packages.props",
126
+ ".nvmrc",
127
+ ".node-version",
128
+ ".tool-versions",
129
+ ".python-version",
130
+ ".ruby-version",
131
+ ".java-version",
132
+ "rust-toolchain",
133
+ "rust-toolchain.toml",
134
+ "mise.toml",
135
+ ".mise.toml",
136
+ ]);
137
+ const REQUIREMENTS_FILE = /^requirements.*\.(?:txt|in)$/;
138
+ /** A package.json's keys that change what an install puts in `node_modules`. */
139
+ const PACKAGE_INSTALL_KEYS = [
140
+ "dependencies",
141
+ "devDependencies",
142
+ "peerDependencies",
143
+ "optionalDependencies",
144
+ "bundleDependencies",
145
+ "bundledDependencies",
146
+ "overrides",
147
+ "resolutions",
148
+ "pnpm",
149
+ "packageManager",
150
+ "engines",
151
+ "workspaces",
152
+ ];
153
+ /** A JSON value as text with every object's keys sorted, so reordered keys compare equal. Pure. */
154
+ function canonicalJson(value) {
155
+ if (Array.isArray(value))
156
+ return `[${value.map(canonicalJson).join(",")}]`;
157
+ if (value && typeof value === "object") {
158
+ const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
159
+ return `{${entries.map(([key, inner]) => `${JSON.stringify(key)}:${canonicalJson(inner)}`).join(",")}}`;
160
+ }
161
+ return JSON.stringify(value) ?? "undefined";
162
+ }
163
+ /** Whether a package.json says to install something else at HEAD than at `base`; a side git cannot read differs. */
164
+ async function packageInstallDiffers(root, base, path, git) {
165
+ const read = async (rev) => {
166
+ const shown = await git(["show", `${rev}:${path}`], root);
167
+ if (shown.code !== 0)
168
+ return null;
169
+ try {
170
+ const value = JSON.parse(shown.stdout);
171
+ return value && typeof value === "object" ? value : null;
172
+ }
173
+ catch {
174
+ return null;
175
+ }
176
+ };
177
+ const before = await read(base);
178
+ const after = await read("HEAD");
179
+ if (!before || !after)
180
+ return true;
181
+ return PACKAGE_INSTALL_KEYS.some((key) => canonicalJson(before[key]) !== canonicalJson(after[key]));
182
+ }
183
+ /**
184
+ * Why the clone's installed dependencies are not the base's, or null when
185
+ * they are: the attempt's commits changed a lockfile, a manifest or a
186
+ * toolchain pin. Uncommitted changes (an install's lockfile churn) are not
187
+ * the attempt's and do not count.
188
+ */
189
+ async function dependencyChange(root, base, git) {
190
+ const diff = await git(["diff", "--name-only", "-z", "--no-renames", base, "HEAD"], root);
191
+ if (diff.code !== 0)
192
+ return `git could not list what the attempt changed: ${why(diff)}`;
193
+ for (const path of diff.stdout.split("\0").filter(Boolean)) {
194
+ const name = basename(path);
195
+ const installs = DEPENDENCY_FILES.has(name) ||
196
+ REQUIREMENTS_FILE.test(name) ||
197
+ (name === "package.json" && (await packageInstallDiffers(root, base, path, git)));
198
+ if (installs) {
199
+ return `the attempt changed what is installed since the base (${path}), so the installed dependencies are not the base's`;
200
+ }
201
+ }
202
+ return null;
203
+ }
204
+ async function fingerprint(top, git) {
205
+ const must = async (args, env) => {
206
+ const result = await git(args, top, env);
207
+ if (result.code !== 0)
208
+ throw new Error(`git ${args[0]} failed: ${why(result)}`);
209
+ return result.stdout.trim();
210
+ };
211
+ const head = await must(["rev-parse", "--verify", "HEAD"]);
212
+ const symbolic = await git(["symbolic-ref", "-q", "HEAD"], top);
213
+ const branch = symbolic.code === 0 ? symbolic.stdout.trim() : "";
214
+ // Fails on an index with unresolved conflicts.
215
+ const index = await must(["write-tree"]);
216
+ // Every file `.gitignore` leaves in, through a throwaway index seeded from
217
+ // the real one so `add -A` re-reads only what changed (treeSnapshot.ts).
218
+ const scratch = mkdtempSync(join(tmpdir(), "yagni-base-tree-"));
219
+ try {
220
+ const scratchIndex = join(scratch, "index");
221
+ const env = { GIT_INDEX_FILE: scratchIndex };
222
+ const realIndex = resolvePath(top, await must(["rev-parse", "--git-path", "index"]));
223
+ if (existsSync(realIndex))
224
+ copyFileSync(realIndex, scratchIndex);
225
+ else
226
+ await must(["read-tree", "HEAD"], env);
227
+ await must(["add", "-A"], env);
228
+ const files = await must(["write-tree"], env);
229
+ return { head, branch, index, files };
230
+ }
231
+ finally {
232
+ rmSync(scratch, { recursive: true, force: true });
233
+ }
234
+ }
235
+ /** The most untracked folders read for empty ones; past it the tree is not touched. */
236
+ const MAX_UNTRACKED_DIRS = 2_000;
237
+ /** Folders the empty-folder walk never enters: another repository, or installed dependencies. */
238
+ const UNWALKED_DIRS = new Set([".git", "node_modules"]);
239
+ /** The untracked folders with nothing in them, which `stash` and `clean` remove. */
240
+ async function untrackedEmptyDirs(root, git) {
241
+ const listed = await git(["ls-files", "--others", "--directory", "--exclude-standard", "-z"], root);
242
+ if (listed.code !== 0)
243
+ throw new Error(`git ls-files failed: ${why(listed)}`);
244
+ const empty = [];
245
+ let read = 0;
246
+ const walk = (dir) => {
247
+ read += 1;
248
+ if (read > MAX_UNTRACKED_DIRS)
249
+ throw new Error("too many untracked folders to put back");
250
+ const entries = readdirSync(join(root, dir), { withFileTypes: true });
251
+ if (entries.length === 0)
252
+ empty.push(dir);
253
+ for (const entry of entries) {
254
+ if (entry.isDirectory() && !UNWALKED_DIRS.has(entry.name))
255
+ walk(join(dir, entry.name));
256
+ }
257
+ };
258
+ for (const entry of listed.stdout.split("\0")) {
259
+ if (entry.endsWith("/"))
260
+ walk(entry.slice(0, -1));
261
+ }
262
+ return empty;
263
+ }
264
+ /** Each uncommitted file's permission bits, by repository-relative path. */
265
+ async function uncommittedModes(root, git) {
266
+ const status = await git(["status", "--porcelain", "-z", "--untracked-files=all"], root);
267
+ if (status.code !== 0)
268
+ throw new Error(`git status failed: ${why(status)}`);
269
+ const modes = new Map();
270
+ const fields = status.stdout.split("\0");
271
+ for (let i = 0; i < fields.length; i += 1) {
272
+ const field = fields[i] ?? "";
273
+ if (field.length < 4)
274
+ continue;
275
+ // A rename's or a copy's source path is the next field.
276
+ if (/[RC]/.test(field.slice(0, 2)))
277
+ i += 1;
278
+ const path = field.slice(3);
279
+ try {
280
+ const stat = lstatSync(join(root, path));
281
+ if (stat.isFile())
282
+ modes.set(path, stat.mode & 0o7777);
283
+ }
284
+ catch {
285
+ // Deleted in the tree: nothing to give a mode back to.
286
+ }
287
+ }
288
+ return modes;
289
+ }
290
+ async function readTree(root, git) {
291
+ return {
292
+ fingerprint: await fingerprint(root, git),
293
+ emptyDirs: await untrackedEmptyDirs(root, git),
294
+ modes: await uncommittedModes(root, git),
295
+ };
296
+ }
297
+ function differences(before, after) {
298
+ return [
299
+ ...(after.head !== before.head ? [`HEAD is ${after.head}, not ${before.head}`] : []),
300
+ ...(after.branch !== before.branch
301
+ ? [`HEAD is on ${after.branch || "no branch"}, not ${before.branch || "no branch"}`]
302
+ : []),
303
+ ...(after.index !== before.index ? ["the index differs"] : []),
304
+ ...(after.files !== before.files ? ["the files differ"] : []),
305
+ ];
306
+ }
307
+ const IN_PROGRESS = [
308
+ "MERGE_HEAD",
309
+ "CHERRY_PICK_HEAD",
310
+ "REVERT_HEAD",
311
+ "rebase-merge",
312
+ "rebase-apply",
313
+ ];
314
+ async function operationInProgress(top, git) {
315
+ for (const name of IN_PROGRESS) {
316
+ const path = await git(["rev-parse", "--git-path", name], top);
317
+ if (path.code !== 0)
318
+ return `git could not say whether a ${name} is in progress`;
319
+ if (existsSync(resolvePath(top, path.stdout.trim())))
320
+ return `a ${name} is in progress`;
321
+ }
322
+ return null;
323
+ }
324
+ async function refOf(top, ref, git) {
325
+ const result = await git(["rev-parse", "-q", "--verify", ref], top);
326
+ return result.code === 0 ? result.stdout.trim() : null;
327
+ }
328
+ /**
329
+ * Run `measure` on the files of the commit `base`, then put the tree back as
330
+ * it was. `skipped` (with why) when it could not start, the tree as it was;
331
+ * throws {@link BaseTreeRestoreError} when the tree could not be put back
332
+ * exactly, and rethrows what `measure` threw once the tree is back.
333
+ */
334
+ export async function measureOnBaseTree(cwd, base, measure, opts = {}) {
335
+ const git = opts.git ?? defaultBaseTreeGit;
336
+ if (!baseCommitOf(base))
337
+ return skipped("the base is not a commit id");
338
+ // Nothing is touched until the tree is read: any trouble here is a skip.
339
+ let root;
340
+ let before;
341
+ try {
342
+ const top = await git(["rev-parse", "--show-toplevel"], cwd);
343
+ if (top.code !== 0)
344
+ return skipped(`not a git checkout: ${why(top)}`);
345
+ root = top.stdout.trim();
346
+ if ((await git(["cat-file", "-e", `${base}^{commit}`], root)).code !== 0) {
347
+ return skipped("the base commit is not in this clone");
348
+ }
349
+ const busy = await operationInProgress(root, git);
350
+ if (busy)
351
+ return skipped(busy);
352
+ const installs = await dependencyChange(root, base, git);
353
+ if (installs)
354
+ return skipped(installs);
355
+ before = await readTree(root, git);
356
+ }
357
+ catch (err) {
358
+ return skipped(`the tree could not be read: ${errorText(err)}`);
359
+ }
360
+ const aside = await checkOutBase(root, base, git);
361
+ let ran;
362
+ if (aside.skip !== null) {
363
+ ran = { outcome: skipped(aside.skip) };
364
+ }
365
+ else {
366
+ try {
367
+ ran = { outcome: { kind: "measured", value: await measure() } };
368
+ }
369
+ catch (error) {
370
+ ran = { error };
371
+ }
372
+ }
373
+ const state = {
374
+ head: before.fingerprint.head,
375
+ branch: before.fingerprint.branch.replace(/^refs\/heads\//, "") || null,
376
+ stash: aside.stash,
377
+ };
378
+ try {
379
+ await putBack(root, before, state, aside.setAside, git);
380
+ }
381
+ catch (err) {
382
+ if (err instanceof BaseTreeRestoreError)
383
+ throw err;
384
+ throw new BaseTreeRestoreError(`the session's tree could not be put back after the base measurement: ${errorText(err)}`, state);
385
+ }
386
+ if ("error" in ran)
387
+ throw ran.error;
388
+ return ran.outcome;
389
+ }
390
+ /**
391
+ * Set the session's tree aside and put the base's files in it. `setAside`
392
+ * once everything git sees outside HEAD is in the stash: only then may
393
+ * putting the tree back force a checkout and clean what is left. `skip` says
394
+ * why the base is not checked out; the caller puts the tree back either way.
395
+ */
396
+ async function checkOutBase(root, base, git) {
397
+ let stash = null;
398
+ let setAside = false;
399
+ const done = (skip) => ({ stash, setAside, skip });
400
+ try {
401
+ const stashBefore = await refOf(root, "refs/stash", git);
402
+ const pushed = await git([
403
+ "stash",
404
+ "push",
405
+ "--include-untracked",
406
+ "--quiet",
407
+ "-m",
408
+ "yagni: the tree, set aside to measure the base",
409
+ ], root);
410
+ const stashAfter = await refOf(root, "refs/stash", git);
411
+ if (stashAfter && stashAfter !== stashBefore)
412
+ stash = stashAfter;
413
+ if (pushed.code !== 0)
414
+ return done(`the tree could not be set aside: ${why(pushed)}`);
415
+ const left = await git(["status", "--porcelain", "--untracked-files=all"], root);
416
+ if (left.code !== 0 || left.stdout.trim())
417
+ return done("the tree was not clean once set aside");
418
+ setAside = true;
419
+ // A file only the base tracks, sitting here ignored (a build output the
420
+ // change stopped tracking), would be overwritten and then deleted; one
421
+ // in the way of its folder makes the checkout refuse.
422
+ const baseOnly = await git(["diff", "--name-only", "-z", "--no-renames", "--diff-filter=D", base, "HEAD"], root);
423
+ if (baseOnly.code !== 0)
424
+ return done(`git could not compare the base: ${why(baseOnly)}`);
425
+ const inTheWay = baseOnly.stdout
426
+ .split("\0")
427
+ .filter(Boolean)
428
+ .find((path) => {
429
+ try {
430
+ lstatSync(join(root, path));
431
+ return true;
432
+ }
433
+ catch {
434
+ return false;
435
+ }
436
+ });
437
+ if (inTheWay)
438
+ return done(`an ignored file is where the base has a tracked one: ${inTheWay}`);
439
+ const onBase = await git(["checkout", "--quiet", "--no-overwrite-ignore", "--detach", base, "--"], root);
440
+ if (onBase.code !== 0)
441
+ return done(`the base could not be checked out: ${why(onBase)}`);
442
+ return done(null);
443
+ }
444
+ catch (err) {
445
+ return done(`the base could not be checked out: ${errorText(err)}`);
446
+ }
447
+ }
448
+ /**
449
+ * Put the tree back to `before`, or throw {@link BaseTreeRestoreError} saying
450
+ * what is wrong. Only a tree set aside whole is forced back to its branch and
451
+ * cleaned of what the measurement left; otherwise the stash, if the tree
452
+ * changed, goes back on the tree as it is. A `stash apply` that exits non-zero
453
+ * on a tree that is back all the same is not a failure.
454
+ */
455
+ async function putBack(root, before, state, setAside, git) {
456
+ const { stash } = state;
457
+ const recover = stash ? ` Its uncommitted state is in the stash ${stash}.` : "";
458
+ const fail = (what) => {
459
+ throw new BaseTreeRestoreError(`the session's tree could not be put back after the base measurement: ${what}.${recover}`, state);
460
+ };
461
+ const read = async () => {
462
+ try {
463
+ return await fingerprint(root, git);
464
+ }
465
+ catch (err) {
466
+ return fail(`the tree could not be read again: ${errorText(err)}`);
467
+ }
468
+ };
469
+ let applyFailed = null;
470
+ const apply = async () => {
471
+ if (!stash)
472
+ return;
473
+ const applied = await git(["stash", "apply", "--index", "--quiet", stash], root);
474
+ if (applied.code !== 0)
475
+ applyFailed = `git stash apply failed: ${why(applied)}`;
476
+ };
477
+ if (setAside) {
478
+ const back = await git(state.branch
479
+ ? ["checkout", "--quiet", "--force", state.branch, "--"]
480
+ : ["checkout", "--quiet", "--force", "--detach", state.head, "--"], root);
481
+ if (back.code !== 0)
482
+ fail(`git checkout ${state.branch ?? state.head} failed: ${why(back)}`);
483
+ const cleaned = await git(["clean", "-fdq"], root);
484
+ if (cleaned.code !== 0)
485
+ fail(`git clean failed: ${why(cleaned)}`);
486
+ await apply();
487
+ }
488
+ else if (differences(before.fingerprint, await read()).length > 0) {
489
+ // Setting it aside stopped partway, after it took something.
490
+ await apply();
491
+ }
492
+ const problems = restoreUnrecorded(root, before);
493
+ const off = differences(before.fingerprint, await read());
494
+ if (off.length > 0 || problems.length > 0) {
495
+ fail([...(applyFailed ? [applyFailed] : []), ...off, ...problems].join("; "));
496
+ }
497
+ if (stash && (await refOf(root, "refs/stash", git)) === stash) {
498
+ await git(["stash", "drop", "--quiet"], root);
499
+ }
500
+ }
501
+ /** Put back what git keeps no record of: the untracked empty folders and the uncommitted files' modes. What could not be. */
502
+ function restoreUnrecorded(root, before) {
503
+ const problems = [];
504
+ for (const dir of before.emptyDirs) {
505
+ try {
506
+ mkdirSync(join(root, dir), { recursive: true });
507
+ }
508
+ catch (err) {
509
+ problems.push(`the empty folder ${dir} could not be made again: ${errorText(err)}`);
510
+ }
511
+ }
512
+ for (const [path, mode] of before.modes) {
513
+ try {
514
+ chmodSync(join(root, path), mode);
515
+ }
516
+ catch (err) {
517
+ problems.push(`${path} could not be given its mode back: ${errorText(err)}`);
518
+ }
519
+ }
520
+ return problems;
521
+ }
522
+ //# sourceMappingURL=baseTree.js.map
@@ -152,6 +152,19 @@ export declare function ticketOfBranch(branch: string | null): string | undefine
152
152
  export declare function attemptCommitted(cwd: string, exec: WorktreeExec, attemptId: string): Promise<boolean | null>;
153
153
  /** Read the execution's work off git. Never throws: a read that fails is a note. */
154
154
  export declare function gitFacts(cwd: string, exec: WorktreeExec, baseSha: string): Promise<GitFacts>;
155
+ /**
156
+ * `result.json` for a session that stopped before it built anything, its
157
+ * tree not put back after the base measurement (baseTree.ts): a failed stop
158
+ * with why, on the head it started on, with nothing committed. Pure.
159
+ */
160
+ export declare function unbuiltCloudResult(input: {
161
+ identity: CloudIdentity;
162
+ stage: CloudStage;
163
+ head: string;
164
+ branch: string | null;
165
+ roundsDone: number;
166
+ note: string;
167
+ }): AttemptResult;
155
168
  /**
156
169
  * The checks that gave no verdict because the sandbox could not run them,
157
170
  * each with why, as one sentence for the person who reads the result: no
@@ -236,6 +236,30 @@ export async function gitFacts(cwd, exec, baseSha) {
236
236
  notes,
237
237
  };
238
238
  }
239
+ /**
240
+ * `result.json` for a session that stopped before it built anything, its
241
+ * tree not put back after the base measurement (baseTree.ts): a failed stop
242
+ * with why, on the head it started on, with nothing committed. Pure.
243
+ */
244
+ export function unbuiltCloudResult(input) {
245
+ return {
246
+ version: 1,
247
+ ...input.identity,
248
+ stage: input.stage,
249
+ headSha: input.head,
250
+ branch: input.branch,
251
+ baseSha: input.head,
252
+ stopReason: "failed",
253
+ rounds: input.roundsDone,
254
+ checks: [],
255
+ findingsOpen: [],
256
+ filesChanged: [],
257
+ commits: [],
258
+ notes: [input.note],
259
+ blockingKey: null,
260
+ usage: { tokens: 0, costMillicents: 0, byStage: [] },
261
+ };
262
+ }
239
263
  /** Checks the cannot-run note names before "and N more". */
240
264
  const MAX_NAMED_CANNOT_RUN = 5;
241
265
  /**
@@ -70,6 +70,7 @@
70
70
  * cannot finish, rather than being killed partway through one (see budget.ts). Such a run gets the sandbox lane's
71
71
  * ceilings (`SANDBOX_RUN_BUDGET`), not interactive /go's.
72
72
  */
73
+ import { measureOnBaseTree } from "./baseTree.js";
73
74
  import { snapshotWorkspace } from "./workspace.js";
74
75
  import type { WorktreeExec } from "./worktree.js";
75
76
  import { type TreeSnapshotDeps } from "./treeSnapshot.js";
@@ -183,6 +184,10 @@ export interface HeadlessGoDeps {
183
184
  makeRunVerify?: typeof makeRunVerify;
184
185
  /** The approved checks' run on the unchanged code (`measureOnBase`). Defaults to the real one; injected in tests. */
185
186
  measureChecksOnBase?: ReturnType<typeof makeMeasureChecksOnBase>;
187
+ /** A resumed session's switch to the attempt's base commit and back (baseTree.ts). Defaults to real git; injected in tests. */
188
+ measureOnBaseTree?: typeof measureOnBaseTree;
189
+ /** The paths the session's HEAD changed since the attempt's base, null when git cannot say. Defaults to real git; injected in tests. */
190
+ pathsCommittedSince?: (cwd: string, base: string) => Promise<string[] | null>;
186
191
  /** The verify gate's clean-tree baseline run (YAG-1003). Defaults to the real one; injected in tests. */
187
192
  takeVerifyBaseline?: (cwd: string, dirs: string[], signal?: AbortSignal, budgetMs?: number) => Promise<VerifyBaselineResult>;
188
193
  /** stdout sink; one call per NDJSON line, newline added by the caller. */
@@ -72,7 +72,8 @@
72
72
  */
73
73
  import { readFileSync, writeFileSync } from "node:fs";
74
74
  import { dirname, join, relative } from "node:path";
75
- import { CLOUD_SESSION_ENV, completeCloudSession, prepareCloudLane, readCloudEnv, } from "./cloud.js";
75
+ import { baseCommitOf, BaseTreeRestoreError, measureOnBaseTree, pathsCommittedSince, } from "./baseTree.js";
76
+ import { CLOUD_SESSION_ENV, completeCloudSession, prepareCloudLane, readCloudEnv, RESULT_FILE_NAME, unbuiltCloudResult, } from "./cloud.js";
76
77
  import { scanChange } from "./changeScan.js";
77
78
  import { continuationResumePlan, parseContinuation, } from "./continuation.js";
78
79
  import { finishExec } from "./finish.js";
@@ -450,10 +451,9 @@ export async function runHeadlessGo(argv, deps = {}) {
450
451
  // The gate's own checks, once on the clean tree before the build (YAG-1003),
451
452
  // in the packages the approved plan names: what already failed there is a
452
453
  // note, never a finding. Only a fresh build's tree is the clean base; a
453
- // continuation or a fix round already carries a change, so it takes none (a
454
- // cloud continuation's earlier work is committed on its branch, and taking
455
- // the baseline there would excuse the attempt's own breakage). It then leans
456
- // on `checks.json`, whose base standing the host took on the real base.
454
+ // continuation or a fix round already carries a change, so taking them on
455
+ // its own tree would excuse the attempt's own breakage. It takes them on
456
+ // the attempt's base commit instead (baseTree.ts), when its host names one.
457
457
  let baseline;
458
458
  const logPipeline = (event, data) => {
459
459
  deps.logger?.(event, data);
@@ -467,6 +467,41 @@ export async function runHeadlessGo(argv, deps = {}) {
467
467
  ? (event) => logPipeline("verify_check", event)
468
468
  : undefined;
469
469
  const heard = onCheck ? { onCheck } : {};
470
+ /**
471
+ * A resumed session whose tree could not be put back after its base
472
+ * measurement stops before it builds. Its tree is not the one it started
473
+ * on, so it commits nothing; a cloud session reports a failed stop on the
474
+ * head it started on, so its host pushes nothing either.
475
+ */
476
+ const stopUnbuilt = (err, base) => {
477
+ const message = `The session stops before it builds: ${err.message}`;
478
+ logPipeline("verify_base_tree", { base, error: err.message });
479
+ emit({ type: "pipeline_error", message });
480
+ writeErr(message);
481
+ if (cloud) {
482
+ const attempt = unbuiltCloudResult({
483
+ identity: cloud.identity,
484
+ stage: cloud.stage,
485
+ head: err.head,
486
+ branch: err.branch,
487
+ roundsDone: continuation?.reviewRoundsDone ?? 0,
488
+ note: message,
489
+ });
490
+ const path = join(cloud.outDir, RESULT_FILE_NAME);
491
+ try {
492
+ (deps.writeFile ?? writeFileSync)(path, `${JSON.stringify(attempt, null, 2)}\n`);
493
+ }
494
+ catch (writeError) {
495
+ // The stream's result then says why the file is missing, as a written one would.
496
+ const note = `${RESULT_FILE_NAME} could not be written: ${writeError instanceof Error ? writeError.message : String(writeError)}`;
497
+ attempt.notes.push(note);
498
+ logPipeline("result_write_failed", { path, error: note });
499
+ writeErr(note);
500
+ }
501
+ emit({ type: "pipeline_result", ...attempt });
502
+ }
503
+ return { exitCode: HEADLESS_GO_EXIT.unverified, error: message };
504
+ };
470
505
  // A measurement before the build (the baseline, the plan's checks on the
471
506
  // base) gets at most a sixth of the run's time left when it starts, and
472
507
  // none below a minute: the build itself must still fit. No deadline, no cap.
@@ -479,46 +514,48 @@ export async function runHeadlessGo(argv, deps = {}) {
479
514
  tooLittle: shareMs !== undefined && shareMs < MIN_BASELINE_BUDGET_MS,
480
515
  };
481
516
  };
482
- if (mission?.plan && !continuation && !fixRound && !deps.signal?.aborted) {
483
- const dirs = baselineDirs(cwd, planPaths(mission.plan), readFile);
517
+ /** The gate's detected checks on the tree as it is, in `dirs`; never throws. */
518
+ const takeBaseline = async (dirs) => {
484
519
  const share = preBuildShare();
485
- if (dirs.length > 0 && share.tooLittle) {
520
+ if (share.tooLittle) {
486
521
  logPipeline("verify_baseline", {
487
522
  skipped: "too little of the run's time left",
488
523
  deadlineLeftMs: share.leftMs,
489
524
  baselineBudgetMs: share.shareMs,
490
525
  });
526
+ return;
491
527
  }
492
- else if (dirs.length > 0) {
493
- try {
494
- const taken = await (deps.takeVerifyBaseline ?? makeTakeVerifyBaseline(heard))(cwd, dirs, deps.signal, share.shareMs);
495
- logPipeline("verify_baseline", {
496
- ran: taken.ran,
497
- failures: taken.failures,
498
- notes: taken.notes,
499
- });
500
- if (taken.baseline.size > 0)
501
- baseline = taken.baseline;
502
- }
503
- catch (err) {
504
- const message = err instanceof Error ? err.message : String(err);
505
- logPipeline("verify_baseline", { error: message });
506
- writeErr(`The verify baseline could not run (every failure counts): ${message}`);
507
- }
528
+ try {
529
+ const taken = await (deps.takeVerifyBaseline ?? makeTakeVerifyBaseline(heard))(cwd, dirs, deps.signal, share.shareMs);
530
+ logPipeline("verify_baseline", {
531
+ ran: taken.ran,
532
+ failures: taken.failures,
533
+ notes: taken.notes,
534
+ });
535
+ if (taken.baseline.size > 0)
536
+ baseline = taken.baseline;
537
+ }
538
+ catch (err) {
539
+ const message = err instanceof Error ? err.message : String(err);
540
+ logPipeline("verify_baseline", { error: message });
541
+ writeErr(`The verify baseline could not run (every failure counts): ${message}`);
508
542
  }
543
+ };
544
+ const resumed = Boolean(continuation || fixRound);
545
+ if (mission?.plan && !resumed && !deps.signal?.aborted) {
546
+ const dirs = baselineDirs(cwd, planPaths(mission.plan), readFile);
547
+ if (dirs.length > 0)
548
+ await takeBaseline(dirs);
509
549
  }
510
550
  // The approved checks the host has not seen pass on the unchanged code
511
551
  // (`measureOnBase`: the plan's, and CI's when the repository's record has
512
552
  // none of its own), each run once there before the build, on the same
513
553
  // fresh tree the baseline above reads: a check that already failed there is
514
- // not this change's to fix. A continuation or a fix round carries a change,
515
- // so it measures nothing; its host hands it what an earlier session found.
554
+ // not this change's to fix.
516
555
  let checksOnBase;
517
556
  const measureFile = gateOpts?.repoChecks?.() ?? null;
518
- if (measureFile?.checks.some((check) => check.measureOnBase) &&
519
- !continuation &&
520
- !fixRound &&
521
- !deps.signal?.aborted) {
557
+ /** The approved checks `file` marks to measure, on the tree as it is; never throws. */
558
+ const measureChecks = async (file) => {
522
559
  const share = preBuildShare();
523
560
  if (share.tooLittle) {
524
561
  logPipeline("verify_checks_on_base", {
@@ -526,24 +563,87 @@ export async function runHeadlessGo(argv, deps = {}) {
526
563
  leftMs: share.leftMs,
527
564
  shareMs: share.shareMs,
528
565
  });
566
+ return;
567
+ }
568
+ try {
569
+ const measured = await (deps.measureChecksOnBase ?? makeMeasureChecksOnBase(heard))(cwd, file, deps.signal, share.shareMs);
570
+ logPipeline("verify_checks_on_base", {
571
+ ran: measured.ran,
572
+ passed: [...measured.passed],
573
+ failed: [...measured.failed.keys()],
574
+ notes: measured.notes,
575
+ treeUndone: measured.treeUndone,
576
+ });
577
+ if (measured.passed.size > 0 || measured.failed.size > 0)
578
+ checksOnBase = measured;
579
+ }
580
+ catch (err) {
581
+ const message = err instanceof Error ? err.message : String(err);
582
+ logPipeline("verify_checks_on_base", { error: message });
583
+ writeErr(`The approved checks could not run on the unchanged code (each keeps its checks.json standing): ${message}`);
584
+ }
585
+ };
586
+ if (measureFile?.checks.some((check) => check.measureOnBase) &&
587
+ !resumed &&
588
+ !deps.signal?.aborted) {
589
+ await measureChecks(measureFile);
590
+ }
591
+ // A one-attempt lane continuation or fix round starts past the attempt's
592
+ // earlier commits, so it measures both on the attempt's base commit
593
+ // (`continue.json` names it, YAG-1042), with the tree put back after (YAG-1056).
594
+ // Before, its host had nothing to hand it for the gate's own checks and only
595
+ // a stale record for a plan check, so every failure main already had was its
596
+ // to fix, and COREX-1291's sessions ended `round_cap` on main's lint errors
597
+ // one after another (production, 2026-10-26). It measures every approved
598
+ // check it can run there, not only those marked: the record's green is what
599
+ // went stale. Its baseline covers the plan's packages and those the attempt
600
+ // changed. A base it cannot measure on leaves the run as it was; a tree it
601
+ // cannot put back stops the session before it builds.
602
+ if (cloud && resumed && !deps.signal?.aborted) {
603
+ const base = baseCommitOf(continuation?.baseSha) ?? baseCommitOf(fixRound?.baseRef);
604
+ const share = preBuildShare();
605
+ if (!base) {
606
+ logPipeline("verify_base_tree", { skipped: "the host named no base commit" });
607
+ }
608
+ else if (share.tooLittle) {
609
+ logPipeline("verify_base_tree", {
610
+ base,
611
+ skipped: "too little of the run's time left",
612
+ leftMs: share.leftMs,
613
+ shareMs: share.shareMs,
614
+ });
529
615
  }
530
616
  else {
617
+ const onBaseFile = measureFile && {
618
+ ...measureFile,
619
+ checks: measureFile.checks.map((check) => check.status === "cannot_run" ? check : { ...check, measureOnBase: true }),
620
+ };
621
+ const checksToMeasure = Boolean(onBaseFile?.checks.some((check) => check.status !== "cannot_run"));
531
622
  try {
532
- const measured = await (deps.measureChecksOnBase ?? makeMeasureChecksOnBase(heard))(cwd, measureFile, deps.signal, share.shareMs);
533
- logPipeline("verify_checks_on_base", {
534
- ran: measured.ran,
535
- passed: [...measured.passed],
536
- failed: [...measured.failed.keys()],
537
- notes: measured.notes,
538
- treeUndone: measured.treeUndone,
539
- });
540
- if (measured.passed.size > 0 || measured.failed.size > 0)
541
- checksOnBase = measured;
623
+ // A list git cannot read narrows the baseline to the plan's packages: said.
624
+ const changed = await (deps.pathsCommittedSince ?? pathsCommittedSince)(cwd, base);
625
+ const unread = changed === null ? { changedPathsUnread: true } : {};
626
+ const dirs = baselineDirs(cwd, [...planPaths(mission?.plan ?? ""), ...(changed ?? [])], readFile);
627
+ const outcome = dirs.length === 0 && !checksToMeasure
628
+ ? { kind: "skipped", reason: "nothing to measure" }
629
+ : await (deps.measureOnBaseTree ?? measureOnBaseTree)(cwd, base, async () => {
630
+ if (dirs.length > 0)
631
+ await takeBaseline(dirs);
632
+ if (onBaseFile && checksToMeasure)
633
+ await measureChecks(onBaseFile);
634
+ });
635
+ logPipeline("verify_base_tree", outcome.kind === "measured"
636
+ ? { base, measured: true, ...unread }
637
+ : { base, skipped: outcome.reason, ...unread });
542
638
  }
543
639
  catch (err) {
544
- const message = err instanceof Error ? err.message : String(err);
545
- logPipeline("verify_checks_on_base", { error: message });
546
- writeErr(`The approved checks could not run on the unchanged code (each keeps its checks.json standing): ${message}`);
640
+ // Any other error came once the tree was back: only the measurement failed.
641
+ if (err instanceof BaseTreeRestoreError)
642
+ return stopUnbuilt(err, base);
643
+ logPipeline("verify_base_tree", {
644
+ base,
645
+ error: err instanceof Error ? err.message : String(err),
646
+ });
547
647
  }
548
648
  }
549
649
  }
@@ -1734,9 +1734,10 @@ export function makeRunVerify(opts = {}) {
1734
1734
  * failure with its output. A check that passed on the unchanged code
1735
1735
  * (`passedOnBase`, measured in this environment) and now says it lacks a
1736
1736
  * credential failed: the change made it need one, and that is the change's
1737
- * (`needs` says what). Only the session that measured knows it passed: a
1738
- * resumed session's host carries forward what failed on the base, never
1739
- * what passed, so there the same check is could not run, failing open.
1737
+ * (`needs` says what). Only a session that measured knows it passed: a
1738
+ * resumed session that could not measure on the attempt's base gets what
1739
+ * failed there from its host, never what passed, so there the same check
1740
+ * is could not run, failing open.
1740
1741
  * The stream hears the command start, and its end here unless it failed:
1741
1742
  * a failure's caller tells its end once it decided whose the failure is.
1742
1743
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.2.9-staging.2082.1",
3
+ "version": "1.2.10-staging.2090.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": "aa51aeb734fe374a2b12c82a0680729e2bf5c4d1"
61
+ "yagniSourceSha": "103c1ce736a7d5b0d46b562836ea60279d755976"
62
62
  }