@yagni-app/code-staging 1.2.7-staging.2035.1 → 1.2.7-staging.2059.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.
@@ -52,11 +52,18 @@ export interface ChangeScanOpts {
52
52
  /** This session's own credentials, matched by value. */
53
53
  sessionSecrets?: readonly string[];
54
54
  readFile?: (path: string) => Buffer;
55
+ /**
56
+ * The commit the run started from: what it committed past it is scanned
57
+ * too, since a model that commits leaves nothing on top of HEAD (YAG-1039).
58
+ * Absent: HEAD.
59
+ */
60
+ base?: string;
55
61
  }
56
62
  /**
57
- * Scan what this run added on top of HEAD: tracked files' added lines, new
58
- * files whole, minus `skip` (the tree's pre-run dirt). Null when git could
59
- * not say what changed: the gate then notes the scan did not run.
63
+ * Scan what this run added on top of its base (HEAD, unless `opts.base`):
64
+ * tracked files' added lines, new files whole, minus `skip` (the tree's
65
+ * pre-run dirt). Null when git could not say what changed: the gate then
66
+ * notes the scan did not run.
60
67
  */
61
68
  export declare function scanChange(cwd: string, opts: ChangeScanOpts, skip?: readonly string[], signal?: AbortSignal): Promise<Finding[] | null>;
62
69
  //# sourceMappingURL=changeScan.d.ts.map
@@ -253,9 +253,10 @@ function bytesAreBinary(bytes) {
253
253
  /** How much of one new file is scanned: a credential sits in source, not past the first megabyte. */
254
254
  const MAX_SCANNED_FILE_BYTES = 1024 * 1024;
255
255
  /**
256
- * Scan what this run added on top of HEAD: tracked files' added lines, new
257
- * files whole, minus `skip` (the tree's pre-run dirt). Null when git could
258
- * not say what changed: the gate then notes the scan did not run.
256
+ * Scan what this run added on top of its base (HEAD, unless `opts.base`):
257
+ * tracked files' added lines, new files whole, minus `skip` (the tree's
258
+ * pre-run dirt). Null when git could not say what changed: the gate then
259
+ * notes the scan did not run.
259
260
  */
260
261
  export async function scanChange(cwd, opts, skip = [], signal) {
261
262
  // `git status --porcelain` lists an untracked folder as `dir/`, so a skip
@@ -267,10 +268,11 @@ export async function scanChange(cwd, opts, skip = [], signal) {
267
268
  has: (path) => skipFiles.has(path) || skipDirs.some((dir) => path.startsWith(dir)),
268
269
  };
269
270
  const git = (args) => opts.exec(["git", "-c", "core.quotepath=off", ...args], cwd, signal);
271
+ const base = opts.base ?? "HEAD";
270
272
  // A deleted binary is no binary in the change: the host lets it through.
271
273
  const [numstat, diff, others] = await Promise.all([
272
- git(["diff", "HEAD", "--numstat", "--no-renames", "--no-ext-diff", "--diff-filter=d"]),
273
- git(["diff", "HEAD", "-U0", "--no-color", "--no-renames", "--no-ext-diff"]),
274
+ git(["diff", base, "--numstat", "--no-renames", "--no-ext-diff", "--diff-filter=d"]),
275
+ git(["diff", base, "-U0", "--no-color", "--no-renames", "--no-ext-diff"]),
274
276
  git(["ls-files", "--others", "--exclude-standard"]),
275
277
  ]);
276
278
  if (numstat.code !== 0 || diff.code !== 0 || others.code !== 0)
@@ -190,8 +190,10 @@ export interface CloudCompletion {
190
190
  * commits its work locally on the branch the host checked out, so nothing is
191
191
  * lost when the sandbox goes. `result.json` is written last; the caller mirrors
192
192
  * it on the stream. `ok` is a written plan, or a clean candidate whose branch
193
- * carries this attempt's committed work, from this execution or an earlier
194
- * one: a clean stop with nothing committed at all delivered nothing.
193
+ * carries this attempt's committed work: a commit past the execution's base,
194
+ * whoever wrote it (a model that ran `git commit` too, YAG-1039), or an
195
+ * earlier execution's. A clean stop with nothing committed at all delivered
196
+ * nothing.
195
197
  */
196
198
  export declare function completeCloudSession(c: CloudCompletion, ran: {
197
199
  result: PipelineResult;
@@ -247,8 +247,10 @@ function findingKey(finding) {
247
247
  * commits its work locally on the branch the host checked out, so nothing is
248
248
  * lost when the sandbox goes. `result.json` is written last; the caller mirrors
249
249
  * it on the stream. `ok` is a written plan, or a clean candidate whose branch
250
- * carries this attempt's committed work, from this execution or an earlier
251
- * one: a clean stop with nothing committed at all delivered nothing.
250
+ * carries this attempt's committed work: a commit past the execution's base,
251
+ * whoever wrote it (a model that ran `git commit` too, YAG-1039), or an
252
+ * earlier execution's. A clean stop with nothing committed at all delivered
253
+ * nothing.
252
254
  */
253
255
  export async function completeCloudSession(c, ran) {
254
256
  const notes = "error" in ran ? [ran.error] : [];
@@ -334,21 +336,25 @@ export async function completeCloudSession(c, ran) {
334
336
  rounds: result?.rounds.length ?? 0,
335
337
  openPr: false,
336
338
  ...(c.preRunDirt.length > 0 ? { baselinePaths: c.preRunDirt } : {}),
339
+ ...(c.baseSha ? { baseSha: c.baseSha } : {}),
337
340
  exec: c.exec,
338
341
  });
339
342
  committed = finish.committed;
340
343
  notes.push(...finish.notes);
341
344
  }
345
+ const facts = await gitFacts(c.cwd, c.exec, c.baseSha);
346
+ notes.push(...facts.notes);
342
347
  let delivered = outputsWritten;
343
348
  if (c.stage !== "plan") {
344
- const onBranch = committed || (await attemptCommitted(c.cwd, c.exec, c.identity.attemptId));
349
+ // A commit past the base is this execution's work, whoever wrote it.
350
+ const onBranch = committed ||
351
+ facts.commits.length > 0 ||
352
+ (await attemptCommitted(c.cwd, c.exec, c.identity.attemptId));
345
353
  if (onBranch === null) {
346
354
  notes.push("git log failed, so the result cannot say whether the branch carries this attempt's work.");
347
355
  }
348
356
  delivered = onBranch === true;
349
357
  }
350
- const facts = await gitFacts(c.cwd, c.exec, c.baseSha);
351
- notes.push(...facts.notes);
352
358
  // What the final scan and checks found beside the last round's findings; a
353
359
  // finding both report is listed once.
354
360
  const seen = new Set((result?.findings ?? []).map(findingKey));
@@ -7,7 +7,8 @@
7
7
  * branch (the tree started clean, so everything is the run's own). In --here
8
8
  * mode it commits ONLY when the pre-run baseline was verifiably clean;
9
9
  * otherwise it skips with an honest note (never mixing user dirt into an
10
- * agent commit).
10
+ * agent commit). A run given its base commit (the cloud session) whose work
11
+ * is already committed past it gets the trailer on an empty commit instead.
11
12
  * - Commit message: `<type>(<scope>): <title>` + a 2-3 sentence plan summary +
12
13
  * the exact provenance trailer (YAGNI-Run / YAGNI-Ticket / YAGNI-Verify /
13
14
  * YAGNI-Decisions / YAGNI-Rounds / Co-Authored-By: YAGNI Code).
@@ -72,6 +73,13 @@ export interface RunFinishOpts extends TrailerInputs {
72
73
  * lands in the run's provenance commit. Absent ⇒ the whole diff is the run's.
73
74
  */
74
75
  baselinePaths?: string[];
76
+ /**
77
+ * The commit the run started from (a cloud session's). When the tree holds
78
+ * nothing of the run's own because the run already committed its work past
79
+ * it (the model ran `git commit`), FINISH puts the trailer on an empty
80
+ * commit on top, so the branch still carries the run's `YAGNI-Run` line.
81
+ */
82
+ baseSha?: string;
75
83
  signal?: AbortSignal;
76
84
  exec?: WorktreeExec;
77
85
  }
@@ -7,7 +7,8 @@
7
7
  * branch (the tree started clean, so everything is the run's own). In --here
8
8
  * mode it commits ONLY when the pre-run baseline was verifiably clean;
9
9
  * otherwise it skips with an honest note (never mixing user dirt into an
10
- * agent commit).
10
+ * agent commit). A run given its base commit (the cloud session) whose work
11
+ * is already committed past it gets the trailer on an empty commit instead.
11
12
  * - Commit message: `<type>(<scope>): <title>` + a 2-3 sentence plan summary +
12
13
  * the exact provenance trailer (YAGNI-Run / YAGNI-Ticket / YAGNI-Verify /
13
14
  * YAGNI-Decisions / YAGNI-Rounds / Co-Authored-By: YAGNI Code).
@@ -238,6 +239,27 @@ function tail(output) {
238
239
  .trim();
239
240
  return t;
240
241
  }
242
+ /**
243
+ * The paths the run's commits past `baseSha` changed: empty with no base, no
244
+ * commit past it, or a change that nets to nothing; `failed` when git could
245
+ * not say, so a note never reads that as nothing committed.
246
+ */
247
+ async function committedSince(run, baseSha) {
248
+ if (!baseSha)
249
+ return [];
250
+ const commits = await run(["git", "rev-list", `${baseSha}..HEAD`]);
251
+ if (commits.code !== 0)
252
+ return { failed: tail(commits.output) || "git rev-list failed" };
253
+ if (!commits.output.trim())
254
+ return [];
255
+ const diff = await run(["git", "diff", "--name-only", baseSha, "HEAD"]);
256
+ if (diff.code !== 0)
257
+ return { failed: tail(diff.output) || "git diff failed" };
258
+ return diff.output
259
+ .split("\n")
260
+ .map((line) => line.trim())
261
+ .filter(Boolean);
262
+ }
241
263
  /**
242
264
  * Run the FINISH stage. NEVER throws; every failure path returns an honest
243
265
  * {@link FinishResult} with notes. See the module docblock for the contract.
@@ -273,33 +295,42 @@ export async function runFinish(opts) {
273
295
  if (status.code !== 0) {
274
296
  return skipped(`FINISH could not read the git status (${tail(status.output) || "git failed"}); nothing was committed.`);
275
297
  }
276
- if (!status.output.trim()) {
277
- return skipped("FINISH found nothing to commit (the tree is already clean).");
278
- }
279
298
  // The run's OWN paths = everything changed now, minus what was already dirty
280
299
  // at pipeline start (bootstrap lockfile churn, or a pre-existing edit). We
281
300
  // commit only these, so the provenance commit never folds in non-run work.
282
301
  const changed = parseChangedPaths(status.output);
283
302
  const baseline = new Set(opts.baselinePaths ?? []);
284
303
  const runOwned = baseline.size === 0 ? changed : changed.filter((p) => !baseline.has(p));
285
- if (runOwned.length === 0) {
286
- return skipped("FINISH found nothing of the run's own to commit (the only changes were already present before the run).");
304
+ // Nothing of the run's own left in the tree, but its work may already be
305
+ // committed past its base: the trailer then goes on an empty commit.
306
+ const committed = runOwned.length === 0 ? await committedSince(run, opts.baseSha) : [];
307
+ if (!Array.isArray(committed)) {
308
+ return skipped(`FINISH could not read what the run committed past its base (${committed.failed}); nothing was committed.`);
309
+ }
310
+ if (runOwned.length === 0 && committed.length === 0) {
311
+ return skipped(status.output.trim()
312
+ ? "FINISH found nothing of the run's own to commit (the only changes were already present before the run)."
313
+ : "FINISH found nothing to commit (the tree is already clean).");
287
314
  }
315
+ const trailerOnly = runOwned.length === 0;
288
316
  // Message derivation off the run's real diff.
289
- const subject = commitSubject(commitType(opts.ticket, opts.ticketBrief), deriveCommitScope(runOwned), commitTitle(opts.ticket, opts.ticketBrief));
317
+ const subject = commitSubject(commitType(opts.ticket, opts.ticketBrief), deriveCommitScope(trailerOnly ? committed : runOwned), commitTitle(opts.ticket, opts.ticketBrief));
290
318
  const summary = summarizePlan(opts.planText, opts.ticket);
291
319
  const trailer = provenanceTrailer(opts);
292
320
  const message = buildCommitMessage(subject, summary, trailer);
293
321
  // No baseline dirt ⇒ everything is the run's own: `git add -A` (preserves
294
322
  // rename detection). With a baseline, stage only the run-owned paths so
295
323
  // bootstrap/pre-existing dirt never lands (`--` guards flag-like paths).
296
- const add = baseline.size === 0
297
- ? await run(["git", "add", "-A"])
298
- : await run(["git", "add", "--", ...runOwned]);
299
- if (add.code !== 0) {
300
- return skipped(`FINISH could not stage the changes (git add failed: ${tail(add.output)}); nothing was committed.`);
324
+ if (!trailerOnly) {
325
+ const add = baseline.size === 0
326
+ ? await run(["git", "add", "-A"])
327
+ : await run(["git", "add", "--", ...runOwned]);
328
+ if (add.code !== 0) {
329
+ return skipped(`FINISH could not stage the changes (git add failed: ${tail(add.output)}); nothing was committed.`);
330
+ }
301
331
  }
302
- let commit = await run(["git", "commit", "-m", message]);
332
+ const commitArgs = [...(trailerOnly ? ["--allow-empty"] : []), "-m", message];
333
+ let commit = await run(["git", "commit", ...commitArgs]);
303
334
  if (commit.code !== 0) {
304
335
  // Most likely a missing git identity in a fresh environment (mirrors
305
336
  // commitWip): retry once with an explicit YAGNI Code identity.
@@ -310,8 +341,7 @@ export async function runFinish(opts) {
310
341
  "-c",
311
342
  "user.email=code@yagni.app",
312
343
  "commit",
313
- "-m",
314
- message,
344
+ ...commitArgs,
315
345
  ]);
316
346
  }
317
347
  if (commit.code !== 0) {
@@ -456,6 +456,13 @@ export async function runHeadlessGo(argv, deps = {}) {
456
456
  if (stream)
457
457
  emit({ type: "pipeline_log", event, data });
458
458
  };
459
+ // Each command the gate runs, as it starts and ends: a check runs for
460
+ // minutes with no turn of the model's, and the host's ticket went quiet
461
+ // through it (YAG-1031). The host shows the command and its verdict.
462
+ const onCheck = stream
463
+ ? (event) => logPipeline("verify_check", event)
464
+ : undefined;
465
+ const heard = onCheck ? { onCheck } : {};
459
466
  // A measurement before the build (the baseline, the plan's checks on the
460
467
  // base) gets at most a sixth of the run's time left when it starts, and
461
468
  // none below a minute: the build itself must still fit. No deadline, no cap.
@@ -480,7 +487,7 @@ export async function runHeadlessGo(argv, deps = {}) {
480
487
  }
481
488
  else if (dirs.length > 0) {
482
489
  try {
483
- const taken = await (deps.takeVerifyBaseline ?? makeTakeVerifyBaseline())(cwd, dirs, deps.signal, share.shareMs);
490
+ const taken = await (deps.takeVerifyBaseline ?? makeTakeVerifyBaseline(heard))(cwd, dirs, deps.signal, share.shareMs);
484
491
  logPipeline("verify_baseline", {
485
492
  ran: taken.ran,
486
493
  failures: taken.failures,
@@ -518,7 +525,7 @@ export async function runHeadlessGo(argv, deps = {}) {
518
525
  }
519
526
  else {
520
527
  try {
521
- const measured = await (deps.measureChecksOnBase ?? makeMeasureChecksOnBase())(cwd, measureFile, deps.signal, share.shareMs);
528
+ const measured = await (deps.measureChecksOnBase ?? makeMeasureChecksOnBase(heard))(cwd, measureFile, deps.signal, share.shareMs);
522
529
  logPipeline("verify_checks_on_base", {
523
530
  ran: measured.ran,
524
531
  passed: [...measured.passed],
@@ -542,6 +549,14 @@ export async function runHeadlessGo(argv, deps = {}) {
542
549
  // A cloud session reports the checks of the tree it commits: the gate's last
543
550
  // verdict, with the tree it judged.
544
551
  const snapshot = deps.snapshotWorkspace ?? snapshotWorkspace;
552
+ // A cloud session's start: the commit it builds on (its result's `baseSha`,
553
+ // which its gate and scan read its change against, since a model may commit
554
+ // its own work) and the tree's pre-run dirt (an install's lockfile churn),
555
+ // which never lands in its commit.
556
+ const startTree = cloud ? await snapshot(cwd, deps.signal) : undefined;
557
+ const baseSha = startTree?.head ?? "";
558
+ const preRunDirt = startTree ? parseChangedPaths(startTree.status) : [];
559
+ const onBase = baseSha ? { base: baseSha } : {};
545
560
  let gate;
546
561
  let lastVerify;
547
562
  // A cloud session's gate also runs the host's secret and binary scans on
@@ -549,7 +564,7 @@ export async function runHeadlessGo(argv, deps = {}) {
549
564
  const exec = deps.exec ?? finishExec;
550
565
  const sessionSecrets = [env.YAGNI_TOKEN, env.GH_ENTERPRISE_TOKEN].filter((value) => Boolean(value));
551
566
  const scan = cloud
552
- ? (scanCwd, signal, skip) => scanChange(scanCwd, { exec, sessionSecrets }, skip, signal)
567
+ ? (scanCwd, signal, skip) => scanChange(scanCwd, { exec, sessionSecrets, ...onBase }, skip, signal)
553
568
  : undefined;
554
569
  if (gateOpts || baseline || cloud) {
555
570
  const made = (deps.makeRunVerify ?? makeRunVerify)({
@@ -557,7 +572,11 @@ export async function runHeadlessGo(argv, deps = {}) {
557
572
  ...(baseline ? { baseline } : {}),
558
573
  ...(checksOnBase ? { checksOnBase } : {}),
559
574
  ...(scan ? { scanChange: scan } : {}),
560
- ...(cloud ? { unreadTestFailureIsFinding: true } : {}),
575
+ // The one-attempt lane's host runs no checks after the build.
576
+ ...(cloud
577
+ ? { unreadTestFailureIsFinding: true, hostRechecks: false, ...(baseSha ? { baseSha } : {}) }
578
+ : {}),
579
+ ...heard,
561
580
  });
562
581
  gate = made;
563
582
  runVerify = async (verifyCwd, signal, baselinePaths) => {
@@ -626,12 +645,6 @@ export async function runHeadlessGo(argv, deps = {}) {
626
645
  emit({ type: "pipeline_log", event, ...(data === undefined ? {} : { data }) });
627
646
  },
628
647
  };
629
- // A cloud session's start: the commit it builds on (its result's `baseSha`)
630
- // and the tree's pre-run dirt (an install's lockfile churn), which never
631
- // lands in its commit.
632
- const startTree = cloud ? await snapshot(cwd, deps.signal) : undefined;
633
- const baseSha = startTree?.head ?? "";
634
- const preRunDirt = startTree ? parseChangedPaths(startTree.status) : [];
635
648
  /** The cloud session's last act (cloud.ts `completeCloudSession`), mirrored on the stream. */
636
649
  const completeCloud = async (session, ran) => {
637
650
  const verifyGate = gate;
@@ -660,7 +673,7 @@ export async function runHeadlessGo(argv, deps = {}) {
660
673
  },
661
674
  }
662
675
  : {}),
663
- scan: () => scanChange(cwd, { exec, sessionSecrets }, preRunDirt),
676
+ scan: () => scanChange(cwd, { exec, sessionSecrets, ...onBase }, preRunDirt),
664
677
  planPaths: (planText) => baselineDirs(cwd, planPaths(planText), readFile).map((dir) => relative(cwd, dir) || "."),
665
678
  exec,
666
679
  writeFile: deps.writeFile ?? writeFileSync,
@@ -158,6 +158,38 @@ export interface VerifyCheckRun {
158
158
  */
159
159
  note?: string;
160
160
  }
161
+ /**
162
+ * How the note of an approved check this gate run did not run starts (its
163
+ * budget was spent, it was not reached, the build stopped first, the
164
+ * typecheck half failed first). The host's pull request body reads these
165
+ * words (`CHECK_NOT_RUN_NOTE` in @yagni/shared's attempts.ts); a change here
166
+ * is a change there.
167
+ */
168
+ export declare const CHECK_NOT_RUN_NOTE = "not run on this tree";
169
+ /**
170
+ * One command the gate starts or ends, for the session's stream (headlessGo's
171
+ * `verify_check` line), so the ticket says what runs instead of going quiet
172
+ * for minutes (YAG-1031). `base` is a run on the unchanged code before the
173
+ * build. An end that did not pass carries the last few hundred characters
174
+ * of its output, scrubbed: the host logs them and never shows them.
175
+ */
176
+ export type VerifyCheckEvent = {
177
+ phase: "start";
178
+ name: string;
179
+ command: string;
180
+ cwd: string;
181
+ base?: true;
182
+ } | {
183
+ phase: "end";
184
+ name: string;
185
+ command: string;
186
+ cwd: string;
187
+ exitCode: number | null;
188
+ durationMs: number;
189
+ baseline?: VerifyCheckRun["baseline"];
190
+ base?: true;
191
+ tail?: string;
192
+ };
161
193
  /**
162
194
  * Why a command threw instead of exiting, as its check record says it. `run`
163
195
  * is the run's own signal (a /stop or the run's end); `capped` is the one
@@ -212,13 +244,19 @@ export declare function parseChangedPaths(status: string): string[];
212
244
  */
213
245
  export declare function resolvePackageDirs(repoRoot: string, relPaths: string[], readFile: (path: string) => string): string[];
214
246
  /**
215
- * This run's OWN changed package dirs: the porcelain `status`'s packages MINUS the
216
- * packages touched only by `baselinePaths` (the tree's pre-run dirt). A package with
217
- * a NEW change stays in scope even if it also had pre-run dirt; a package whose every
218
- * changed path predates the run drops out. Pure (the `readFile` seam keeps it so), so
219
- * an unrelated dirty package can never retarget the gate off this run's real diff.
247
+ * This run's OWN changed paths: the porcelain `status`'s, plus `committed` (what
248
+ * the run already committed past its base, YAG-1039), MINUS `baselinePaths` (the
249
+ * tree's pre-run dirt). Pure, deduped.
250
+ */
251
+ export declare function ownChangedPaths(status: string, baselinePaths: string[], committed?: string[]): string[];
252
+ /**
253
+ * This run's OWN changed package dirs: the packages of {@link ownChangedPaths}, so a
254
+ * package touched only by `baselinePaths` (the tree's pre-run dirt) drops out. A
255
+ * package with a NEW change stays in scope even if it also had pre-run dirt. Pure
256
+ * (the `readFile` seam keeps it so), so an unrelated dirty package can never
257
+ * retarget the gate off this run's real diff.
220
258
  */
221
- export declare function ownChangedDirs(repoRoot: string, status: string, baselinePaths: string[], readFile: (path: string) => string): string[];
259
+ export declare function ownChangedDirs(repoRoot: string, status: string, baselinePaths: string[], readFile: (path: string) => string, committed?: string[]): string[];
222
260
  /**
223
261
  * Resolve the verify command(s) for a repo. An explicitly allowed `.yagni-code/verify`
224
262
  * override wins (first non-comment, non-empty line, whitespace-split into argv; run
@@ -463,6 +501,19 @@ export interface MakeRunVerifyOpts {
463
501
  * Recomputed each round so the scope follows the growing diff.
464
502
  */
465
503
  changedDirs?: (cwd: string, signal?: AbortSignal, baselinePaths?: string[]) => Promise<string[]>;
504
+ /**
505
+ * The commit the run started from (the cloud session's): what the run
506
+ * committed past it counts as changed beside the uncommitted tree, in the
507
+ * default `changedDirs` and `changedPaths`, so a model that commits its own
508
+ * work keeps the gate's scope (YAG-1039). Absent: the tree alone.
509
+ */
510
+ baseSha?: string;
511
+ /**
512
+ * Whether the host runs the approved checks again after the build, which the
513
+ * gate's note of checks it left unrun says. Absent: it does (the mission
514
+ * lane). The one-attempt lane's host does not, so its session passes false.
515
+ */
516
+ hostRechecks?: boolean;
466
517
  exec?: VerifyExec;
467
518
  timeoutMs?: number;
468
519
  /**
@@ -488,8 +539,8 @@ export interface MakeRunVerifyOpts {
488
539
  onFailedChecks?: (results: FailedCheckResult[]) => void;
489
540
  /**
490
541
  * This run's own changed paths (repo-relative), which scope the CI jobs by
491
- * their `paths` filters. Default: the same `git status` the scope reads,
492
- * minus the pre-run dirt.
542
+ * their `paths` filters. Default: the same `git status` (and commits past
543
+ * `baseSha`) the scope reads, minus the pre-run dirt.
493
544
  */
494
545
  changedPaths?: (cwd: string, signal?: AbortSignal, baselinePaths?: string[]) => Promise<string[]>;
495
546
  /** Repository-checks env override for determinism in tests (default: buildRepoCheckEnv() per run). */
@@ -535,6 +586,8 @@ export interface MakeRunVerifyOpts {
535
586
  * removes. Absent: no scan half, as before (the cloud session sets it).
536
587
  */
537
588
  scanChange?: (cwd: string, signal?: AbortSignal, baselinePaths?: string[]) => Promise<Finding[] | null>;
589
+ /** Told as each command starts and ends (the session's stream). Absent: nobody hears it. */
590
+ onCheck?: (event: VerifyCheckEvent) => void;
538
591
  }
539
592
  /**
540
593
  * One workstream as the scoped checker sees it: the name its findings are
@@ -636,6 +689,8 @@ export interface MakeTakeVerifyBaselineOpts {
636
689
  now?: () => number;
637
690
  treeStatus?: (cwd: string, signal?: AbortSignal) => Promise<string>;
638
691
  undoTreeWrites?: (cwd: string, entries: TreeEntry[]) => Promise<void>;
692
+ /** Told as each command starts and ends on the clean tree (the session's stream). */
693
+ onCheck?: (event: VerifyCheckEvent) => void;
639
694
  }
640
695
  /** What the baseline run found, for the gate and for the run's log. */
641
696
  export interface VerifyBaselineResult {
@@ -698,6 +753,8 @@ export interface MakeMeasureChecksOnBaseOpts {
698
753
  now?: () => number;
699
754
  treeStatus?: (cwd: string, signal?: AbortSignal) => Promise<string>;
700
755
  undoTreeWrites?: (cwd: string, entries: TreeEntry[]) => Promise<void>;
756
+ /** Told as each check starts and ends on the unchanged code (the session's stream). */
757
+ onCheck?: (event: VerifyCheckEvent) => void;
701
758
  }
702
759
  /**
703
760
  * Run each approved check `checks.json` marks `measureOnBase` once on the
@@ -65,7 +65,7 @@ import { claimCovers } from "./fanout.js";
65
65
  import { ciJobInScope } from "./missionChecks.js";
66
66
  import { composeAbortSignal } from "./resilience.js";
67
67
  import { scrubSecrets } from "./scrubSecrets.js";
68
- import { snapshotWorkspace } from "./workspace.js";
68
+ import { committedPaths, snapshotWorkspace } from "./workspace.js";
69
69
  /**
70
70
  * Total wall-clock budget for a single verify run before it is abandoned
71
71
  * (fail-open). Ten minutes until 2026-10-09; twenty now, matching the run
@@ -195,6 +195,44 @@ export function changesGitState(command) {
195
195
  }
196
196
  return false;
197
197
  }
198
+ /**
199
+ * How the note of an approved check this gate run did not run starts (its
200
+ * budget was spent, it was not reached, the build stopped first, the
201
+ * typecheck half failed first). The host's pull request body reads these
202
+ * words (`CHECK_NOT_RUN_NOTE` in @yagni/shared's attempts.ts); a change here
203
+ * is a change there.
204
+ */
205
+ export const CHECK_NOT_RUN_NOTE = "not run on this tree";
206
+ /** How much of a failed command's output its end carries, for the host's own log. */
207
+ const CHECK_TAIL_CHARS = 400;
208
+ /** How a check went the last time it ran, after its not-run note; nothing when it never ran. */
209
+ function earlierRunWords(last) {
210
+ if (last === undefined)
211
+ return "";
212
+ if (last.exitCode === null)
213
+ return "; it gave no verdict on an earlier tree";
214
+ if (last.exitCode === 0)
215
+ return "; it passed on an earlier tree";
216
+ return `; it failed on an earlier tree (exit ${last.exitCode})`;
217
+ }
218
+ /** The end of a command's output, scrubbed, as a check's end carries it. */
219
+ function checkTail(output) {
220
+ return scrubSecrets(output).trimEnd().slice(-CHECK_TAIL_CHARS);
221
+ }
222
+ /** A recorded run's end, as the stream hears it: the output's tail only when it did not pass. */
223
+ function checkEnded(record, output) {
224
+ const tail = output && record.exitCode !== 0 ? checkTail(output) : "";
225
+ return {
226
+ phase: "end",
227
+ name: record.name,
228
+ command: record.command,
229
+ cwd: record.cwd,
230
+ exitCode: record.exitCode,
231
+ durationMs: record.durationMs,
232
+ baseline: record.baseline,
233
+ ...(tail ? { tail } : {}),
234
+ };
235
+ }
198
236
  /**
199
237
  * Why a command threw instead of exiting, as its check record says it. `run`
200
238
  * is the run's own signal (a /stop or the run's end); `capped` is the one
@@ -317,16 +355,23 @@ export function resolvePackageDirs(repoRoot, relPaths, readFile) {
317
355
  return [...dirs].sort();
318
356
  }
319
357
  /**
320
- * This run's OWN changed package dirs: the porcelain `status`'s packages MINUS the
321
- * packages touched only by `baselinePaths` (the tree's pre-run dirt). A package with
322
- * a NEW change stays in scope even if it also had pre-run dirt; a package whose every
323
- * changed path predates the run drops out. Pure (the `readFile` seam keeps it so), so
324
- * an unrelated dirty package can never retarget the gate off this run's real diff.
358
+ * This run's OWN changed paths: the porcelain `status`'s, plus `committed` (what
359
+ * the run already committed past its base, YAG-1039), MINUS `baselinePaths` (the
360
+ * tree's pre-run dirt). Pure, deduped.
325
361
  */
326
- export function ownChangedDirs(repoRoot, status, baselinePaths, readFile) {
362
+ export function ownChangedPaths(status, baselinePaths, committed = []) {
327
363
  const baseline = new Set(baselinePaths);
328
- const own = parseChangedPaths(status).filter((p) => !baseline.has(p));
329
- return resolvePackageDirs(repoRoot, own, readFile);
364
+ return [...new Set([...parseChangedPaths(status), ...committed])].filter((p) => !baseline.has(p));
365
+ }
366
+ /**
367
+ * This run's OWN changed package dirs: the packages of {@link ownChangedPaths}, so a
368
+ * package touched only by `baselinePaths` (the tree's pre-run dirt) drops out. A
369
+ * package with a NEW change stays in scope even if it also had pre-run dirt. Pure
370
+ * (the `readFile` seam keeps it so), so an unrelated dirty package can never
371
+ * retarget the gate off this run's real diff.
372
+ */
373
+ export function ownChangedDirs(repoRoot, status, baselinePaths, readFile, committed = []) {
374
+ return resolvePackageDirs(repoRoot, ownChangedPaths(status, baselinePaths, committed), readFile);
330
375
  }
331
376
  /**
332
377
  * The highest-priority package.json script at `pkgDir` that is a string AND not a
@@ -1371,6 +1416,10 @@ function measuredBaseline(onBase, check) {
1371
1416
  export function makeRunVerify(opts = {}) {
1372
1417
  const detect = opts.detect ?? defaultDetect;
1373
1418
  const detectTests = opts.detectTests ?? defaultDetectTests;
1419
+ // What the run committed past its base: a model that commits leaves the tree clean.
1420
+ const committedOf = (cwd, head, signal) => opts.baseSha && head !== opts.baseSha
1421
+ ? committedPaths(cwd, opts.baseSha, signal)
1422
+ : Promise.resolve([]);
1374
1423
  const changedDirs = opts.changedDirs ??
1375
1424
  (async (cwd, signal, baselinePaths) => {
1376
1425
  const snapshot = await snapshotWorkspace(cwd, signal);
@@ -1379,7 +1428,7 @@ export function makeRunVerify(opts = {}) {
1379
1428
  // This run's OWN changed packages = the current diff minus the pre-run dirt, so
1380
1429
  // a stray pre-existing file (or an unrelated dirty package) never pulls its dir
1381
1430
  // into scope and the gate verifies what THIS run actually touched.
1382
- return ownChangedDirs(cwd, snapshot.status, baselinePaths ?? [], (p) => readFileSync(p, "utf8"));
1431
+ return ownChangedDirs(cwd, snapshot.status, baselinePaths ?? [], (p) => readFileSync(p, "utf8"), await committedOf(cwd, snapshot.head, signal));
1383
1432
  });
1384
1433
  const exec = opts.exec ?? defaultExec;
1385
1434
  const timeoutMs = opts.timeoutMs ?? VERIFY_TIMEOUT_MS;
@@ -1390,8 +1439,7 @@ export function makeRunVerify(opts = {}) {
1390
1439
  const snapshot = await snapshotWorkspace(cwd, signal);
1391
1440
  if (!snapshot.tracked)
1392
1441
  return [];
1393
- const baseline = new Set(baselinePaths ?? []);
1394
- return parseChangedPaths(snapshot.status).filter((p) => !baseline.has(p));
1442
+ return ownChangedPaths(snapshot.status, baselinePaths ?? [], await committedOf(cwd, snapshot.head, signal));
1395
1443
  });
1396
1444
  const toolchain = opts.toolchain ?? sessionToolchain;
1397
1445
  const treeStatus = opts.treeStatus ??
@@ -1401,6 +1449,55 @@ export function makeRunVerify(opts = {}) {
1401
1449
  // keys from the last call it failed in.
1402
1450
  let repoChecksSpentMs = 0;
1403
1451
  const lastCiFailure = new Map();
1452
+ const hostRechecks = opts.hostRechecks === false ? "" : "; the host still runs the approved checks after the build";
1453
+ // Each approved check's last run in this build, by command (trimmed).
1454
+ const lastApproved = new Map();
1455
+ /**
1456
+ * The approved checks as one call reports them, in `checks.json`'s order:
1457
+ * one the sandbox cannot run with no verdict (#2694), one this call ran
1458
+ * with its record, and any other with no verdict on this tree, why, and how
1459
+ * it went the last time it ran. The cloud session's report is the last
1460
+ * call's: one that ran none of them (the build's check budget spent, the
1461
+ * typecheck half failing first) used to drop every one, and a pull request
1462
+ * whose plan named six checks said the session reported none (YAG-1031,
1463
+ * production 2026-10-25). A check that changes the checkout's git state is
1464
+ * never run and never listed.
1465
+ */
1466
+ function approvedRecords(file, ran, why) {
1467
+ return file.checks.flatMap((check) => {
1468
+ if (check.status === "cannot_run") {
1469
+ return [
1470
+ {
1471
+ name: checkLabel(check.command),
1472
+ command: check.command,
1473
+ cwd: ".",
1474
+ exitCode: null,
1475
+ durationMs: 0,
1476
+ baseline: "cannot_run",
1477
+ note: `could not run in the sandbox${check.cause ? `: ${check.cause}` : ""}`,
1478
+ },
1479
+ ];
1480
+ }
1481
+ if (changesGitState(check.command))
1482
+ return [];
1483
+ const key = check.command.trim();
1484
+ const record = ran.get(key);
1485
+ if (record)
1486
+ return [record];
1487
+ const last = lastApproved.get(key);
1488
+ return [
1489
+ {
1490
+ name: checkLabel(check.command),
1491
+ command: check.command,
1492
+ cwd: ".",
1493
+ exitCode: null,
1494
+ durationMs: 0,
1495
+ baseline: last?.baseline ?? measuredBaseline(opts.checksOnBase, check),
1496
+ note: `${CHECK_NOT_RUN_NOTE}: ${why}${earlierRunWords(last)}`,
1497
+ },
1498
+ ];
1499
+ });
1500
+ }
1404
1501
  /**
1405
1502
  * The repository-checks half: the plan's approved checks, then the pull
1406
1503
  * request's CI steps its changed paths trigger. A failure becomes `critical`
@@ -1417,22 +1514,12 @@ export function makeRunVerify(opts = {}) {
1417
1514
  const labels = [];
1418
1515
  const cannotRun = [];
1419
1516
  // An approved check the sandbox cannot run is on the report as one with no
1420
- // verdict, so a session that never ran it is never read as all green
1421
- // (#2694): the host leaves its pull request a draft.
1422
- const unrunnable = [];
1517
+ // verdict (`approvedRecords`), so a session that never ran it is never
1518
+ // read as all green (#2694): the host leaves its pull request a draft.
1423
1519
  for (const check of file.checks) {
1424
1520
  if (check.status !== "cannot_run")
1425
1521
  continue;
1426
1522
  cannotRun.push(`\`${check.command}\`${check.cause ? ` (${check.cause})` : ""}`);
1427
- unrunnable.push({
1428
- name: checkLabel(check.command),
1429
- command: check.command,
1430
- cwd: ".",
1431
- exitCode: null,
1432
- durationMs: 0,
1433
- baseline: "cannot_run",
1434
- note: `could not run in the sandbox${check.cause ? `: ${check.cause}` : ""}`,
1435
- });
1436
1523
  }
1437
1524
  const paths = await changedPaths(cwd, signal, input.baselinePaths);
1438
1525
  // A job the pull request's CI reported failed runs whatever its paths say.
@@ -1479,12 +1566,23 @@ export function makeRunVerify(opts = {}) {
1479
1566
  const steps = jobs.flatMap((job) => job.steps.map((step) => ({ job, step })));
1480
1567
  if (approved.length === 0 && steps.length === 0) {
1481
1568
  report();
1482
- return { findings, reasons, labels, checks: unrunnable };
1569
+ // Every approved check here is one the sandbox cannot run: none is "not run".
1570
+ return {
1571
+ findings,
1572
+ reasons,
1573
+ labels,
1574
+ checks: approvedRecords(file, new Map(), "the sandbox cannot run it"),
1575
+ };
1483
1576
  }
1484
1577
  const buildLeft = (opts.repoChecksBuildBudgetMs ?? REPO_CHECKS_BUILD_BUDGET_MS) - repoChecksSpentMs;
1485
1578
  if (buildLeft < REPO_CHECK_MIN_START_MS) {
1486
- 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");
1487
- return { findings, reasons, labels, checks: unrunnable };
1579
+ reasons.push(`approved checks and CI steps not run again: the build's check budget is spent${hostRechecks}`);
1580
+ return {
1581
+ findings,
1582
+ reasons,
1583
+ labels,
1584
+ checks: approvedRecords(file, new Map(), "the build's check budget is spent"),
1585
+ };
1488
1586
  }
1489
1587
  const callBudget = Math.min(opts.repoChecksCallBudgetMs ?? REPO_CHECKS_CALL_BUDGET_MS, timeoutMs - (now() - input.callStartedAt), buildLeft);
1490
1588
  const halfStartedAt = now();
@@ -1500,6 +1598,7 @@ export function makeRunVerify(opts = {}) {
1500
1598
  failedJobs,
1501
1599
  approvedFailed: new Set(),
1502
1600
  checks: [],
1601
+ approvedRuns: new Map(),
1503
1602
  };
1504
1603
  const before = await treeStatus(cwd, signal).catch((err) => {
1505
1604
  const msg = err instanceof Error ? err.message : String(err);
@@ -1531,9 +1630,21 @@ export function makeRunVerify(opts = {}) {
1531
1630
  }
1532
1631
  }
1533
1632
  if (run.notReached > 0) {
1534
- 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`);
1633
+ reasons.push(`${run.notReached} approved check(s) or CI step(s) not reached in the gate's time budget${hostRechecks}`);
1535
1634
  }
1536
- return { findings, reasons, labels, checks: [...unrunnable, ...run.checks] };
1635
+ const approvedRan = new Set(run.approvedRuns.values());
1636
+ const whyNotRun = signal?.aborted
1637
+ ? "the build stopped first"
1638
+ : "not reached in the gate's time budget";
1639
+ return {
1640
+ findings,
1641
+ reasons,
1642
+ labels,
1643
+ checks: [
1644
+ ...approvedRecords(file, run.approvedRuns, whyNotRun),
1645
+ ...run.checks.filter((record) => !approvedRan.has(record)),
1646
+ ],
1647
+ };
1537
1648
  }
1538
1649
  /**
1539
1650
  * One repository command's verdict: passed, a note (it could not run), or a
@@ -1543,6 +1654,8 @@ export function makeRunVerify(opts = {}) {
1543
1654
  * (`needs` says what). Only the session that measured knows it passed: a
1544
1655
  * resumed session's host carries forward what failed on the base, never
1545
1656
  * what passed, so there the same check is could not run, failing open.
1657
+ * The stream hears the command start, and its end here unless it failed:
1658
+ * a failure's caller tells its end once it decided whose the failure is.
1546
1659
  */
1547
1660
  async function runRepoCommand(run, command, dir, stepEnv, check) {
1548
1661
  const capMs = Math.min(opts.repoCheckTimeoutMs ?? REPO_CHECK_TIMEOUT_MS, run.left());
@@ -1550,6 +1663,8 @@ export function makeRunVerify(opts = {}) {
1550
1663
  const env = opts.repoChecksEnv
1551
1664
  ? { ...opts.repoChecksEnv, ...(stepEnv ?? {}) }
1552
1665
  : buildRepoCheckEnv(process.env, run.toolchainFile, stepEnv);
1666
+ const cwd = relative(run.cwd, dir) || ".";
1667
+ opts.onCheck?.({ phase: "start", name: check.name, command, cwd });
1553
1668
  const startedAt = now();
1554
1669
  try {
1555
1670
  const { code, output: rawOutput } = await exec(repoCheckArgv(command), dir, composed, env);
@@ -1557,7 +1672,7 @@ export function makeRunVerify(opts = {}) {
1557
1672
  const entry = {
1558
1673
  name: check.name,
1559
1674
  command,
1560
- cwd: relative(run.cwd, dir) || ".",
1675
+ cwd,
1561
1676
  exitCode: note ? null : code,
1562
1677
  durationMs: now() - startedAt,
1563
1678
  baseline,
@@ -1567,14 +1682,16 @@ export function makeRunVerify(opts = {}) {
1567
1682
  return entry;
1568
1683
  };
1569
1684
  if (code === 0) {
1570
- record(check.baseline);
1685
+ const entry = record(check.baseline);
1686
+ opts.onCheck?.(checkEnded(entry));
1571
1687
  return { kind: "passed" };
1572
1688
  }
1573
1689
  const output = scrubSecrets(rawOutput);
1574
1690
  const cls = classifyTestFailure(output, code, MAX_LOCATED_FINDINGS, env);
1575
1691
  if (cls.kind === "infra" && !(cls.needs && check.passedOnBase)) {
1576
1692
  const reason = `could not run: ${cls.reason}`;
1577
- record("cannot_run", `${reason}; ${exitSummary(check.name, code, output)}`);
1693
+ const entry = record("cannot_run", `${reason}; ${exitSummary(check.name, code, output)}`);
1694
+ opts.onCheck?.(checkEnded(entry, output));
1578
1695
  return { kind: "note", reason };
1579
1696
  }
1580
1697
  return {
@@ -1587,15 +1704,17 @@ export function makeRunVerify(opts = {}) {
1587
1704
  };
1588
1705
  }
1589
1706
  catch (err) {
1590
- run.checks.push({
1707
+ const entry = {
1591
1708
  name: check.name,
1592
1709
  command,
1593
- cwd: relative(run.cwd, dir) || ".",
1710
+ cwd,
1594
1711
  exitCode: null,
1595
1712
  durationMs: now() - startedAt,
1596
1713
  baseline: check.baseline,
1597
1714
  note: unexitedNote(err, run.signal, composed),
1598
- });
1715
+ };
1716
+ run.checks.push(entry);
1717
+ opts.onCheck?.(checkEnded(entry));
1599
1718
  const msg = err instanceof Error ? err.message : String(err);
1600
1719
  return { kind: "note", reason: `could not run: ${scrubSecrets(msg)}` };
1601
1720
  }
@@ -1628,11 +1747,18 @@ export function makeRunVerify(opts = {}) {
1628
1747
  // The failures it had there, when the session measured it failing.
1629
1748
  const failedOnBase = opts.checksOnBase?.failed.get(check.command.trim());
1630
1749
  const status = measuredBaseline(opts.checksOnBase, check);
1750
+ const recorded = run.checks.length;
1631
1751
  const verdict = await runRepoCommand(run, check.command, run.cwd, undefined, {
1632
1752
  name: label,
1633
1753
  baseline: status,
1634
1754
  passedOnBase: opts.checksOnBase?.passed.has(check.command.trim()) ?? false,
1635
1755
  });
1756
+ // `runRepoCommand` records every run once, whatever its verdict.
1757
+ const ran = run.checks[recorded];
1758
+ if (ran) {
1759
+ run.approvedRuns.set(check.command.trim(), ran);
1760
+ lastApproved.set(check.command.trim(), ran);
1761
+ }
1636
1762
  if (verdict.kind === "failed")
1637
1763
  run.approvedFailed.add(canonicalCheckCommand(check.command));
1638
1764
  if (verdict.kind === "failed" && verdict.needs) {
@@ -1668,6 +1794,8 @@ export function makeRunVerify(opts = {}) {
1668
1794
  run.findings.push(...found.slice(0, findingRoom(run)));
1669
1795
  }
1670
1796
  }
1797
+ if (verdict.kind === "failed")
1798
+ opts.onCheck?.(checkEnded(verdict.record, verdict.output));
1671
1799
  }
1672
1800
  }
1673
1801
  /**
@@ -1742,6 +1870,7 @@ export function makeRunVerify(opts = {}) {
1742
1870
  continue;
1743
1871
  }
1744
1872
  settle(job, "failed");
1873
+ opts.onCheck?.(checkEnded(verdict.record, verdict.output));
1745
1874
  const stepFindings = repoFailureFindings(run, verdict, step.name, where, dir);
1746
1875
  const keys = new Set(stepFindings.map((f) => f.message));
1747
1876
  const previous = lastCiFailure.get(key);
@@ -1791,15 +1920,27 @@ export function makeRunVerify(opts = {}) {
1791
1920
  const counts = opts.baseline?.get(verifyCommandKey(command));
1792
1921
  return counts && [...counts.values()].some((n) => n > 0) ? "fails_on_base" : "runnable";
1793
1922
  };
1794
- const recordCommand = (command, code, startedAt, baseline, note) => checks.push({
1923
+ /** A detected command starting, as the stream hears it. */
1924
+ const commandStarted = (command) => opts.onCheck?.({
1925
+ phase: "start",
1795
1926
  name: command.label,
1796
1927
  command: command.argv.join(" "),
1797
1928
  cwd: relative(cwd, command.cwd) || ".",
1798
- exitCode: code,
1799
- durationMs: now() - startedAt,
1800
- baseline,
1801
- ...(note ? { note } : {}),
1802
1929
  });
1930
+ /** A detected command's record, its end told to the stream with its output's tail. */
1931
+ const recordCommand = (command, code, startedAt, baseline, more = {}) => {
1932
+ const entry = {
1933
+ name: command.label,
1934
+ command: command.argv.join(" "),
1935
+ cwd: relative(cwd, command.cwd) || ".",
1936
+ exitCode: code,
1937
+ durationMs: now() - startedAt,
1938
+ baseline,
1939
+ ...(more.note ? { note: more.note } : {}),
1940
+ };
1941
+ checks.push(entry);
1942
+ opts.onCheck?.(checkEnded(entry, more.output));
1943
+ };
1803
1944
  // TYPECHECK HALF (frozen behavior, §0.2): run every changed package's build
1804
1945
  // check; union the findings. One package that cannot spawn fails OPEN (a
1805
1946
  // recorded reason, no finding) without sinking the verdict for the packages
@@ -1808,6 +1949,7 @@ export function makeRunVerify(opts = {}) {
1808
1949
  const typecheckEnv = buildGateVerifyEnv(process.env, toolchainFile);
1809
1950
  for (const command of commands) {
1810
1951
  const composed = composeAbortSignal(signal, AbortSignal.timeout(timeoutMs));
1952
+ commandStarted(command);
1811
1953
  const startedAt = now();
1812
1954
  try {
1813
1955
  const { code, output: rawOutput } = await exec(command.argv, command.cwd, composed, typecheckEnv);
@@ -1820,11 +1962,14 @@ export function makeRunVerify(opts = {}) {
1820
1962
  const infra = classifyVerifyInfraFailure(output, code);
1821
1963
  if (infra) {
1822
1964
  reasons.push(`verify (${command.label}) could not run: ${infra}`);
1823
- recordCommand(command, null, startedAt, "cannot_run", `could not run: ${infra}; ${exitSummary(command.label, code, output)}`);
1965
+ recordCommand(command, null, startedAt, "cannot_run", {
1966
+ note: `could not run: ${infra}; ${exitSummary(command.label, code, output)}`,
1967
+ output,
1968
+ });
1824
1969
  continue;
1825
1970
  }
1826
1971
  }
1827
- recordCommand(command, code, startedAt, standing(command));
1972
+ recordCommand(command, code, startedAt, standing(command), { output });
1828
1973
  anyRan = true;
1829
1974
  typecheckLabels.push(command.label);
1830
1975
  if (code !== 0) {
@@ -1835,7 +1980,9 @@ export function makeRunVerify(opts = {}) {
1835
1980
  }
1836
1981
  }
1837
1982
  catch (err) {
1838
- recordCommand(command, null, startedAt, standing(command), unexitedNote(err, signal, composed));
1983
+ recordCommand(command, null, startedAt, standing(command), {
1984
+ note: unexitedNote(err, signal, composed),
1985
+ });
1839
1986
  const msg = err instanceof Error ? err.message : String(err);
1840
1987
  reasons.push(`verify (${command.label}) could not run: ${scrubSecrets(msg)}`);
1841
1988
  }
@@ -1871,13 +2018,17 @@ export function makeRunVerify(opts = {}) {
1871
2018
  continue;
1872
2019
  }
1873
2020
  const composed = composeAbortSignal(signal, AbortSignal.timeout(testTimeoutMs));
2021
+ commandStarted(command);
1874
2022
  const startedAt = now();
1875
2023
  try {
1876
2024
  const { code, output: rawOutput } = await exec(command.argv, command.cwd, composed, testEnv);
1877
2025
  const output = scrubSecrets(rawOutput);
1878
2026
  const cls = classifyTestFailure(output, code, Number.POSITIVE_INFINITY, testEnv);
1879
2027
  if (cls.kind === "infra") {
1880
- recordCommand(command, null, startedAt, "cannot_run", `could not run: ${cls.reason}; ${exitSummary(command.label, code, output)}`);
2028
+ recordCommand(command, null, startedAt, "cannot_run", {
2029
+ note: `could not run: ${cls.reason}; ${exitSummary(command.label, code, output)}`,
2030
+ output,
2031
+ });
1881
2032
  reasons.push(`tests (${where}) could not run: ${cls.reason}; 'clean' reflects typecheck + review only`);
1882
2033
  continue;
1883
2034
  }
@@ -1887,19 +2038,22 @@ export function makeRunVerify(opts = {}) {
1887
2038
  testLabels.push(command.label);
1888
2039
  if (base === "fails_on_base") {
1889
2040
  const summary = exitSummary(command.label, code, output);
1890
- recordCommand(command, code, startedAt, base, `failed on the unchanged code too: ${summary}`);
2041
+ recordCommand(command, code, startedAt, base, {
2042
+ note: `failed on the unchanged code too: ${summary}`,
2043
+ output,
2044
+ });
1891
2045
  reasons.push(`tests (${where}) failed on the unchanged code too, so not a finding: ${summary}`);
1892
2046
  }
1893
2047
  else {
1894
2048
  // One finding per command, past the cap too: it is the only
1895
2049
  // word of this command's failure.
1896
- recordCommand(command, code, startedAt, base);
2050
+ recordCommand(command, code, startedAt, base, { output });
1897
2051
  testFindingCount += 1;
1898
2052
  findings.push(tailFinding(command.label, code, output));
1899
2053
  }
1900
2054
  continue;
1901
2055
  }
1902
- recordCommand(command, code, startedAt, base);
2056
+ recordCommand(command, code, startedAt, base, { output });
1903
2057
  if (cls.kind === "uncertain") {
1904
2058
  // Failing OPEN on an uninterpretable failure (§3d): an honest note,
1905
2059
  // never a guessed finding the fix stage cannot clear.
@@ -1918,7 +2072,9 @@ export function makeRunVerify(opts = {}) {
1918
2072
  }
1919
2073
  }
1920
2074
  catch (err) {
1921
- recordCommand(command, null, startedAt, standing(command), unexitedNote(err, signal, composed));
2075
+ recordCommand(command, null, startedAt, standing(command), {
2076
+ note: unexitedNote(err, signal, composed),
2077
+ });
1922
2078
  const msg = err instanceof Error ? err.message : String(err);
1923
2079
  reasons.push(`tests (${where}) could not run: ${scrubSecrets(msg)}`);
1924
2080
  }
@@ -1932,6 +2088,7 @@ export function makeRunVerify(opts = {}) {
1932
2088
  if (repoFile) {
1933
2089
  if (typecheckFailed) {
1934
2090
  reasons.push("approved checks and CI steps skipped: the typecheck half failed; a later round runs them");
2091
+ checks.push(...approvedRecords(repoFile, new Map(), "the typecheck half failed first"));
1935
2092
  }
1936
2093
  else {
1937
2094
  const repo = await runRepoChecks({
@@ -2056,7 +2213,8 @@ export function makeTakeVerifyBaseline(opts = {}) {
2056
2213
  for (const { command, test } of runs) {
2057
2214
  if (signal?.aborted)
2058
2215
  break;
2059
- const left = budgetMs - (now() - startedAt);
2216
+ const commandStartedAt = now();
2217
+ const left = budgetMs - (commandStartedAt - startedAt);
2060
2218
  if (left < REPO_CHECK_MIN_START_MS) {
2061
2219
  result.notes.push(`${command.label} (${command.cwd}): not reached in the baseline budget`);
2062
2220
  continue;
@@ -2065,8 +2223,23 @@ export function makeTakeVerifyBaseline(opts = {}) {
2065
2223
  ? (opts.testTimeoutMs ?? VERIFY_TEST_TIMEOUT_MS)
2066
2224
  : (opts.timeoutMs ?? VERIFY_TIMEOUT_MS), left);
2067
2225
  const composed = composeAbortSignal(signal, AbortSignal.timeout(cap));
2226
+ const heard = {
2227
+ name: command.label,
2228
+ command: command.argv.join(" "),
2229
+ cwd: relative(cwd, command.cwd) || ".",
2230
+ base: true,
2231
+ };
2232
+ opts.onCheck?.({ phase: "start", ...heard });
2233
+ const ended = (exitCode, output) => opts.onCheck?.({
2234
+ phase: "end",
2235
+ ...heard,
2236
+ exitCode,
2237
+ durationMs: now() - commandStartedAt,
2238
+ ...(output && exitCode !== 0 ? { tail: checkTail(output) } : {}),
2239
+ });
2068
2240
  try {
2069
2241
  const { code, output: rawOutput } = await exec(command.argv, command.cwd, composed, test ? testEnv : verifyEnv);
2242
+ ended(code, rawOutput);
2070
2243
  const output = scrubSecrets(rawOutput);
2071
2244
  let failed;
2072
2245
  if (test) {
@@ -2095,6 +2268,7 @@ export function makeTakeVerifyBaseline(opts = {}) {
2095
2268
  result.failures += n;
2096
2269
  }
2097
2270
  catch (err) {
2271
+ ended(null);
2098
2272
  const msg = err instanceof Error ? err.message : String(err);
2099
2273
  result.notes.push(`${command.label} (${command.cwd}): could not run: ${scrubSecrets(msg)}`);
2100
2274
  }
@@ -2170,14 +2344,25 @@ export function makeMeasureChecksOnBase(opts = {}) {
2170
2344
  break;
2171
2345
  const command = check.command.trim();
2172
2346
  const label = checkLabel(check.command);
2173
- const left = budgetMs - (now() - startedAt);
2347
+ const checkStartedAt = now();
2348
+ const left = budgetMs - (checkStartedAt - startedAt);
2174
2349
  if (left < REPO_CHECK_MIN_START_MS) {
2175
2350
  result.notes.push(`\`${label}\`: not reached in the budget`);
2176
2351
  continue;
2177
2352
  }
2178
2353
  const composed = composeAbortSignal(signal, AbortSignal.timeout(Math.min(opts.timeoutMs ?? REPO_CHECK_TIMEOUT_MS, left)));
2354
+ const heard = { name: label, command: check.command, cwd: ".", base: true };
2355
+ opts.onCheck?.({ phase: "start", ...heard });
2356
+ const ended = (exitCode, output) => opts.onCheck?.({
2357
+ phase: "end",
2358
+ ...heard,
2359
+ exitCode,
2360
+ durationMs: now() - checkStartedAt,
2361
+ ...(output && exitCode !== 0 ? { tail: checkTail(output) } : {}),
2362
+ });
2179
2363
  try {
2180
2364
  const { code, output: rawOutput } = await exec(repoCheckArgv(check.command), cwd, composed, env);
2365
+ ended(code, rawOutput);
2181
2366
  if (code === 0) {
2182
2367
  passed.add(command);
2183
2368
  result.ran.push(label);
@@ -2206,6 +2391,7 @@ export function makeMeasureChecksOnBase(opts = {}) {
2206
2391
  result.ran.push(label);
2207
2392
  }
2208
2393
  catch (err) {
2394
+ ended(null);
2209
2395
  result.notes.push(`\`${label}\`: ${unexitedNote(err, signal, composed)}`);
2210
2396
  }
2211
2397
  }
@@ -29,4 +29,10 @@ export declare function workspaceChanged(before: WorkspaceSnapshot, after: Works
29
29
  * report `tracked: false` and skip the second call.
30
30
  */
31
31
  export declare function snapshotWorkspace(cwd: string, signal?: AbortSignal): Promise<WorkspaceSnapshot>;
32
+ /**
33
+ * The repo-relative paths committed between `baseSha` and HEAD: what a run
34
+ * committed past its base. Empty on any git error, so a caller reads the
35
+ * uncommitted tree alone, as it did before.
36
+ */
37
+ export declare function committedPaths(cwd: string, baseSha: string, signal?: AbortSignal): Promise<string[]>;
32
38
  //# sourceMappingURL=workspace.d.ts.map
@@ -70,4 +70,16 @@ export async function snapshotWorkspace(cwd, signal) {
70
70
  const status = await runGit(["status", "--porcelain"], cwd, signal);
71
71
  return { tracked: true, head, status };
72
72
  }
73
+ /**
74
+ * The repo-relative paths committed between `baseSha` and HEAD: what a run
75
+ * committed past its base. Empty on any git error, so a caller reads the
76
+ * uncommitted tree alone, as it did before.
77
+ */
78
+ export async function committedPaths(cwd, baseSha, signal) {
79
+ const names = await runGit(["diff", "--name-only", baseSha, "HEAD"], cwd, signal);
80
+ return names
81
+ .split("\n")
82
+ .map((line) => line.trim())
83
+ .filter(Boolean);
84
+ }
73
85
  //# sourceMappingURL=workspace.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.2.7-staging.2035.1",
3
+ "version": "1.2.7-staging.2059.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": "83d927d6ece210d0734c46e6654c34bf89ecb565"
61
+ "yagniSourceSha": "6e9cbb06e7ae4d5b15b14fecc749fb5fbab2e93b"
62
62
  }