@yagni-app/code-staging 1.2.7-staging.2056.1 → 1.2.7-staging.2062.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) {
@@ -549,6 +549,23 @@ export async function runHeadlessGo(argv, deps = {}) {
549
549
  // A cloud session reports the checks of the tree it commits: the gate's last
550
550
  // verdict, with the tree it judged.
551
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 } : {};
560
+ // Its reviewer reads the change from that commit too (YAG-1041). A
561
+ // continuation starts past the attempt's earlier commits, so its start is
562
+ // not where the change begins: its reviewer keeps the bare `git diff`.
563
+ const reviewLane = lane && baseSha && !continuation
564
+ ? {
565
+ ...lane,
566
+ stages: lane.stages.map((s) => (s.id === "review" ? { ...s, diffBase: baseSha } : s)),
567
+ }
568
+ : lane;
552
569
  let gate;
553
570
  let lastVerify;
554
571
  // A cloud session's gate also runs the host's secret and binary scans on
@@ -556,7 +573,7 @@ export async function runHeadlessGo(argv, deps = {}) {
556
573
  const exec = deps.exec ?? finishExec;
557
574
  const sessionSecrets = [env.YAGNI_TOKEN, env.GH_ENTERPRISE_TOKEN].filter((value) => Boolean(value));
558
575
  const scan = cloud
559
- ? (scanCwd, signal, skip) => scanChange(scanCwd, { exec, sessionSecrets }, skip, signal)
576
+ ? (scanCwd, signal, skip) => scanChange(scanCwd, { exec, sessionSecrets, ...onBase }, skip, signal)
560
577
  : undefined;
561
578
  if (gateOpts || baseline || cloud) {
562
579
  const made = (deps.makeRunVerify ?? makeRunVerify)({
@@ -564,7 +581,10 @@ export async function runHeadlessGo(argv, deps = {}) {
564
581
  ...(baseline ? { baseline } : {}),
565
582
  ...(checksOnBase ? { checksOnBase } : {}),
566
583
  ...(scan ? { scanChange: scan } : {}),
567
- ...(cloud ? { unreadTestFailureIsFinding: true } : {}),
584
+ // The one-attempt lane's host runs no checks after the build.
585
+ ...(cloud
586
+ ? { unreadTestFailureIsFinding: true, hostRechecks: false, ...(baseSha ? { baseSha } : {}) }
587
+ : {}),
568
588
  ...heard,
569
589
  });
570
590
  gate = made;
@@ -595,7 +615,7 @@ export async function runHeadlessGo(argv, deps = {}) {
595
615
  ...(budget ? { budget } : {}),
596
616
  ...(deps.stages ? { stages: deps.stages } : {}),
597
617
  grounded: deps.grounded ?? grounding,
598
- ...(lane ? { lane } : {}),
618
+ ...(reviewLane ? { lane: reviewLane } : {}),
599
619
  ...(mission ? { mission } : {}),
600
620
  ...(runVerify ? { runVerify } : {}),
601
621
  ...(baseline ? { runWorkstreamCheck: makeWorkstreamCheck({ baseline }) } : {}),
@@ -634,12 +654,6 @@ export async function runHeadlessGo(argv, deps = {}) {
634
654
  emit({ type: "pipeline_log", event, ...(data === undefined ? {} : { data }) });
635
655
  },
636
656
  };
637
- // A cloud session's start: the commit it builds on (its result's `baseSha`)
638
- // and the tree's pre-run dirt (an install's lockfile churn), which never
639
- // lands in its commit.
640
- const startTree = cloud ? await snapshot(cwd, deps.signal) : undefined;
641
- const baseSha = startTree?.head ?? "";
642
- const preRunDirt = startTree ? parseChangedPaths(startTree.status) : [];
643
657
  /** The cloud session's last act (cloud.ts `completeCloudSession`), mirrored on the stream. */
644
658
  const completeCloud = async (session, ran) => {
645
659
  const verifyGate = gate;
@@ -668,7 +682,7 @@ export async function runHeadlessGo(argv, deps = {}) {
668
682
  },
669
683
  }
670
684
  : {}),
671
- scan: () => scanChange(cwd, { exec, sessionSecrets }, preRunDirt),
685
+ scan: () => scanChange(cwd, { exec, sessionSecrets, ...onBase }, preRunDirt),
672
686
  planPaths: (planText) => baselineDirs(cwd, planPaths(planText), readFile).map((dir) => relative(cwd, dir) || "."),
673
687
  exec,
674
688
  writeFile: deps.writeFile ?? writeFileSync,
@@ -50,10 +50,11 @@ export declare function builderPersonaClause(workstream?: PartitionWorkstream, s
50
50
  * Resolve the system-prompt body for a stage. For non-review stages this is the
51
51
  * role body; for the review stage it appends the lens-specific clause (or, with
52
52
  * no lens, the one-reviewer clause covering all three) and the required
53
- * findings output contract so the reviewer's output parses. A stage carrying a
54
- * `personaClause` (the fan-out builders) gets it appended last, and one
55
- * carrying a `persona` (the cloud plan stage's host persona) runs with that in
56
- * place of its agent's body.
53
+ * findings output contract so the reviewer's output parses. A review carrying
54
+ * a `diffBase` (a cloud session's) diffs from that commit in its first step. A
55
+ * stage carrying a `personaClause` (the fan-out builders) gets it appended
56
+ * last, and one carrying a `persona` (the cloud plan stage's host persona)
57
+ * runs with that in place of its agent's body.
57
58
  *
58
59
  * `grounded` defaults true (every real /go stage). The M6 eval passes
59
60
  * `grounded: false` to select the grounding-free bodies + lens clauses for the
@@ -86,12 +86,22 @@ What was done.
86
86
  Any record_decision calls you made, and why.
87
87
  ## Notes
88
88
  Anything the reviewer should know (exact file paths, key functions touched).`;
89
+ /** The reviewer's first step, which a stage's `diffBase` rewrites (see `personaBody`). */
90
+ const REVIEWER_DIFF_STEP = "1. `git diff` to see the change.";
91
+ /**
92
+ * The reviewer's first step on a change read from `base`, the commit a cloud
93
+ * session started on: its model may have committed its work, which a bare
94
+ * `git diff` no longer shows (YAG-1041).
95
+ */
96
+ function reviewerDiffStepFrom(base) {
97
+ return `1. \`git diff ${base}\` to see the change: everything this session changed since its base commit ${base}, committed or not (a bare \`git diff\` shows only what is not committed yet). \`git log ${base}..HEAD\` lists its commits, and \`git status\` lists new files git does not track yet.`;
98
+ }
89
99
  const REVIEWER_BODY = `You are a senior, adversarial code reviewer. Analyze the change for real problems, not style nits. Bash is read-only here (\`git diff\`, \`git log\`, \`git show\`); do NOT modify files or run builds.
90
100
 
91
101
  You are grounded in how THIS company works — that context is your edge over a repo-only reviewer.
92
102
 
93
103
  Strategy:
94
- 1. \`git diff\` to see the change.
104
+ ${REVIEWER_DIFF_STEP}
95
105
  2. Read the modified files and their surroundings.
96
106
  3. Judge the change through your assigned lens (below).`;
97
107
  const ADVISOR_BODY = `You are a senior advisor, brought in on ONE hard call by an agent that is already working the problem. You are the strongest model in this system and you are expensive, so you are consulted, not driven — the caller does the work, you supply the judgment.
@@ -300,7 +310,7 @@ Anything the reviewer should know (exact file paths, key functions touched).`;
300
310
  const REVIEWER_BLIND = `You are a senior, adversarial code reviewer. Analyze the change for real problems, not style nits. Bash is read-only here (\`git diff\`, \`git log\`, \`git show\`); do NOT modify files or run builds.
301
311
 
302
312
  Strategy:
303
- 1. \`git diff\` to see the change.
313
+ ${REVIEWER_DIFF_STEP}
304
314
  2. Read the modified files and their surroundings.
305
315
  3. Judge the change through your assigned lens (below).`;
306
316
  const ADVISOR_BLIND = `You are a senior advisor, brought in on ONE hard call by an agent that is already working the problem. You are the strongest model in this system and you are expensive, so you are consulted, not driven — the caller does the work, you supply the judgment.
@@ -428,10 +438,11 @@ export function builderPersonaClause(workstream, siblings = []) {
428
438
  * Resolve the system-prompt body for a stage. For non-review stages this is the
429
439
  * role body; for the review stage it appends the lens-specific clause (or, with
430
440
  * no lens, the one-reviewer clause covering all three) and the required
431
- * findings output contract so the reviewer's output parses. A stage carrying a
432
- * `personaClause` (the fan-out builders) gets it appended last, and one
433
- * carrying a `persona` (the cloud plan stage's host persona) runs with that in
434
- * place of its agent's body.
441
+ * findings output contract so the reviewer's output parses. A review carrying
442
+ * a `diffBase` (a cloud session's) diffs from that commit in its first step. A
443
+ * stage carrying a `personaClause` (the fan-out builders) gets it appended
444
+ * last, and one carrying a `persona` (the cloud plan stage's host persona)
445
+ * runs with that in place of its agent's body.
435
446
  *
436
447
  * `grounded` defaults true (every real /go stage). The M6 eval passes
437
448
  * `grounded: false` to select the grounding-free bodies + lens clauses for the
@@ -443,7 +454,10 @@ export function personaBody(stage, opts = {}) {
443
454
  const base = stage.persona?.trim() || bodies[stage.agent];
444
455
  if (!base)
445
456
  throw new Error(`No persona body for agent "${stage.agent}"`);
446
- const parts = [base];
457
+ const diffBase = stage.diffBase;
458
+ const parts = [
459
+ diffBase ? base.replace(REVIEWER_DIFF_STEP, () => reviewerDiffStepFrom(diffBase)) : base,
460
+ ];
447
461
  if (stage.id === "review") {
448
462
  const clauses = grounded ? LENS_CLAUSES : BLIND_LENS_CLAUSES;
449
463
  if (stage.lens)
@@ -72,6 +72,12 @@ export interface PipelineStage {
72
72
  * plan stage takes the host's `plan-persona.md` (Reeve's persona) here.
73
73
  */
74
74
  persona?: string;
75
+ /**
76
+ * The commit a review reads the change from (a cloud session's start,
77
+ * YAG-1041): its first step is `git diff <diffBase>`, since a model that
78
+ * commits its work leaves a bare `git diff` empty. Absent: `git diff`.
79
+ */
80
+ diffBase?: string;
75
81
  /**
76
82
  * The stage's own clock, in milliseconds (`stageClock.ts`). Past it the
77
83
  * child refuses every tool call, so the model writes its answer from what it
@@ -244,13 +244,19 @@ export declare function parseChangedPaths(status: string): string[];
244
244
  */
245
245
  export declare function resolvePackageDirs(repoRoot: string, relPaths: string[], readFile: (path: string) => string): string[];
246
246
  /**
247
- * This run's OWN changed package dirs: the porcelain `status`'s packages MINUS the
248
- * packages touched only by `baselinePaths` (the tree's pre-run dirt). A package with
249
- * a NEW change stays in scope even if it also had pre-run dirt; a package whose every
250
- * changed path predates the run drops out. Pure (the `readFile` seam keeps it so), so
251
- * 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.
252
250
  */
253
- export declare function ownChangedDirs(repoRoot: string, status: string, baselinePaths: string[], readFile: (path: string) => string): string[];
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.
258
+ */
259
+ export declare function ownChangedDirs(repoRoot: string, status: string, baselinePaths: string[], readFile: (path: string) => string, committed?: string[]): string[];
254
260
  /**
255
261
  * Resolve the verify command(s) for a repo. An explicitly allowed `.yagni-code/verify`
256
262
  * override wins (first non-comment, non-empty line, whitespace-split into argv; run
@@ -495,6 +501,19 @@ export interface MakeRunVerifyOpts {
495
501
  * Recomputed each round so the scope follows the growing diff.
496
502
  */
497
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;
498
517
  exec?: VerifyExec;
499
518
  timeoutMs?: number;
500
519
  /**
@@ -520,8 +539,8 @@ export interface MakeRunVerifyOpts {
520
539
  onFailedChecks?: (results: FailedCheckResult[]) => void;
521
540
  /**
522
541
  * This run's own changed paths (repo-relative), which scope the CI jobs by
523
- * their `paths` filters. Default: the same `git status` the scope reads,
524
- * 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.
525
544
  */
526
545
  changedPaths?: (cwd: string, signal?: AbortSignal, baselinePaths?: string[]) => Promise<string[]>;
527
546
  /** Repository-checks env override for determinism in tests (default: buildRepoCheckEnv() per run). */
@@ -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
@@ -355,16 +355,23 @@ export function resolvePackageDirs(repoRoot, relPaths, readFile) {
355
355
  return [...dirs].sort();
356
356
  }
357
357
  /**
358
- * This run's OWN changed package dirs: the porcelain `status`'s packages MINUS the
359
- * packages touched only by `baselinePaths` (the tree's pre-run dirt). A package with
360
- * a NEW change stays in scope even if it also had pre-run dirt; a package whose every
361
- * changed path predates the run drops out. Pure (the `readFile` seam keeps it so), so
362
- * 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.
363
361
  */
364
- export function ownChangedDirs(repoRoot, status, baselinePaths, readFile) {
362
+ export function ownChangedPaths(status, baselinePaths, committed = []) {
365
363
  const baseline = new Set(baselinePaths);
366
- const own = parseChangedPaths(status).filter((p) => !baseline.has(p));
367
- 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);
368
375
  }
369
376
  /**
370
377
  * The highest-priority package.json script at `pkgDir` that is a string AND not a
@@ -1409,6 +1416,10 @@ function measuredBaseline(onBase, check) {
1409
1416
  export function makeRunVerify(opts = {}) {
1410
1417
  const detect = opts.detect ?? defaultDetect;
1411
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([]);
1412
1423
  const changedDirs = opts.changedDirs ??
1413
1424
  (async (cwd, signal, baselinePaths) => {
1414
1425
  const snapshot = await snapshotWorkspace(cwd, signal);
@@ -1417,7 +1428,7 @@ export function makeRunVerify(opts = {}) {
1417
1428
  // This run's OWN changed packages = the current diff minus the pre-run dirt, so
1418
1429
  // a stray pre-existing file (or an unrelated dirty package) never pulls its dir
1419
1430
  // into scope and the gate verifies what THIS run actually touched.
1420
- 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));
1421
1432
  });
1422
1433
  const exec = opts.exec ?? defaultExec;
1423
1434
  const timeoutMs = opts.timeoutMs ?? VERIFY_TIMEOUT_MS;
@@ -1428,8 +1439,7 @@ export function makeRunVerify(opts = {}) {
1428
1439
  const snapshot = await snapshotWorkspace(cwd, signal);
1429
1440
  if (!snapshot.tracked)
1430
1441
  return [];
1431
- const baseline = new Set(baselinePaths ?? []);
1432
- return parseChangedPaths(snapshot.status).filter((p) => !baseline.has(p));
1442
+ return ownChangedPaths(snapshot.status, baselinePaths ?? [], await committedOf(cwd, snapshot.head, signal));
1433
1443
  });
1434
1444
  const toolchain = opts.toolchain ?? sessionToolchain;
1435
1445
  const treeStatus = opts.treeStatus ??
@@ -1439,6 +1449,7 @@ export function makeRunVerify(opts = {}) {
1439
1449
  // keys from the last call it failed in.
1440
1450
  let repoChecksSpentMs = 0;
1441
1451
  const lastCiFailure = new Map();
1452
+ const hostRechecks = opts.hostRechecks === false ? "" : "; the host still runs the approved checks after the build";
1442
1453
  // Each approved check's last run in this build, by command (trimmed).
1443
1454
  const lastApproved = new Map();
1444
1455
  /**
@@ -1565,7 +1576,7 @@ export function makeRunVerify(opts = {}) {
1565
1576
  }
1566
1577
  const buildLeft = (opts.repoChecksBuildBudgetMs ?? REPO_CHECKS_BUILD_BUDGET_MS) - repoChecksSpentMs;
1567
1578
  if (buildLeft < REPO_CHECK_MIN_START_MS) {
1568
- 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");
1579
+ reasons.push(`approved checks and CI steps not run again: the build's check budget is spent${hostRechecks}`);
1569
1580
  return {
1570
1581
  findings,
1571
1582
  reasons,
@@ -1619,7 +1630,7 @@ export function makeRunVerify(opts = {}) {
1619
1630
  }
1620
1631
  }
1621
1632
  if (run.notReached > 0) {
1622
- 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}`);
1623
1634
  }
1624
1635
  const approvedRan = new Set(run.approvedRuns.values());
1625
1636
  const whyNotRun = signal?.aborted
@@ -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.2056.1",
3
+ "version": "1.2.7-staging.2062.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": "76a36c6b94db739ea0fb0544cbe9a26279e6881c"
61
+ "yagniSourceSha": "a52f06325dd8542ceb7055b6fc9a7c965c8c77bd"
62
62
  }