@yagni-app/code-staging 1.2.6-staging.1951.1 → 1.2.6-staging.1956.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.
Files changed (30) hide show
  1. package/dist/extension/branding.d.ts +20 -0
  2. package/dist/extension/branding.js +49 -8
  3. package/dist/extension/index.js +5 -0
  4. package/dist/extension/pipeline/changeScan.d.ts +62 -0
  5. package/dist/extension/pipeline/changeScan.js +307 -0
  6. package/dist/extension/pipeline/cloud.d.ts +205 -0
  7. package/dist/extension/pipeline/cloud.js +382 -0
  8. package/dist/extension/pipeline/continuation.d.ts +7 -3
  9. package/dist/extension/pipeline/continuation.js +29 -13
  10. package/dist/extension/pipeline/findings.d.ts +9 -4
  11. package/dist/extension/pipeline/findings.js +25 -10
  12. package/dist/extension/pipeline/finish.d.ts +11 -1
  13. package/dist/extension/pipeline/finish.js +51 -5
  14. package/dist/extension/pipeline/headlessGo.d.ts +30 -3
  15. package/dist/extension/pipeline/headlessGo.js +253 -29
  16. package/dist/extension/pipeline/orchestrator.d.ts +8 -2
  17. package/dist/extension/pipeline/orchestrator.js +249 -68
  18. package/dist/extension/pipeline/personas.d.ts +8 -3
  19. package/dist/extension/pipeline/personas.js +42 -7
  20. package/dist/extension/pipeline/planOutput.d.ts +42 -0
  21. package/dist/extension/pipeline/planOutput.js +94 -0
  22. package/dist/extension/pipeline/stages.d.ts +34 -5
  23. package/dist/extension/pipeline/stages.js +136 -7
  24. package/dist/extension/pipeline/tierCap.d.ts +22 -1
  25. package/dist/extension/pipeline/tierCap.js +48 -1
  26. package/dist/extension/pipeline/types.d.ts +31 -0
  27. package/dist/extension/pipeline/types.js +2 -0
  28. package/dist/extension/pipeline/verify.d.ts +128 -4
  29. package/dist/extension/pipeline/verify.js +356 -31
  30. package/package.json +2 -2
@@ -24,6 +24,17 @@
24
24
  * run ends at the reviewed candidate and never commits, pushes, opens a PR, or
25
25
  * reports one. The mission's own prHandoff owns all of that.
26
26
  *
27
+ * CLOUD (`--cloud`, the one-attempt lane; cloud.ts): the session runs the
28
+ * cloud lane (stages.ts `cloudLane`: plan, implement, review, fix, then verify
29
+ * and finish; one writer, one reviewer, four rounds) on the host's input
30
+ * folder beside the ticket and its env (`YAGNI_ATTEMPT_ID`, `YAGNI_EXECUTION_ID`,
31
+ * `YAGNI_GENERATION`, `YAGNI_MISSION_OUT_DIR`, `YAGNI_GO_STAGE_TIERS`). A fresh
32
+ * attempt plans and stops, writing `plan.md` and `plan.json`; a plan file
33
+ * starts implement; a continue file names its stage. Every execution but a
34
+ * plan commits its work locally on the branch the host checked out, whatever
35
+ * its stop, and never pushes. Its last act is `result.json`, mirrored on the
36
+ * last `pipeline_result` line. Mission mode below is untouched by it.
37
+ *
27
38
  * CONTINUATION (`--continue-file`, mission mode only): the run continues an
28
39
  * earlier attempt whose change is already in the tree as uncommitted edits. The
29
40
  * build baselines the clean base, and an attempt that stopped in review, fix or
@@ -45,7 +56,8 @@
45
56
  * - Always, as the last line: the result object
46
57
  * `{type:"pipeline_result", ok, stopReason, rounds, findings, stages:[{stage,
47
58
  * tier, usage}], …}` — or `{type:"pipeline_error", …}` when a build stage
48
- * threw.
59
+ * threw. `preexistingFailures` (absent at 0) counts the gate's own
60
+ * typecheck, lint and test failures the clean tree already had (YAG-1003).
49
61
  * - Exit codes: 0 ONLY on a verified candidate (a `clean` stop), 1 for any
50
62
  * other outcome or a thrown pipeline error, 2 for a usage/input problem.
51
63
  *
@@ -59,8 +71,12 @@
59
71
  * ceilings (`SANDBOX_RUN_BUDGET`), not interactive /go's.
60
72
  */
61
73
  import { readFileSync, writeFileSync } from "node:fs";
62
- import { dirname, join } from "node:path";
63
- import { continuationResumePlan, parseContinuation } from "./continuation.js";
74
+ import { dirname, join, relative } from "node:path";
75
+ import { CLOUD_SESSION_ENV, completeCloudSession, prepareCloudLane, readCloudEnv, } from "./cloud.js";
76
+ import { scanChange } from "./changeScan.js";
77
+ import { continuationResumePlan, parseContinuation, } from "./continuation.js";
78
+ import { finishExec } from "./finish.js";
79
+ import { snapshotWorkspace } from "./workspace.js";
64
80
  import { resolveFanoutMode } from "./fanout.js";
65
81
  import { makeFanoutBeats } from "./fanoutBeats.js";
66
82
  import { isBlockingFinding } from "./findings.js";
@@ -68,20 +84,25 @@ import { MISSION_FIX_ROUND_FILE_NAME, readFixRoundFile } from "./fixRound.js";
68
84
  import { normalizeMission } from "./mission.js";
69
85
  import { MISSION_CHECKS_FILE_NAME, MISSION_CI_RESULTS_FILE_NAME, readMissionChecksFile, renderCiResults, } from "./missionChecks.js";
70
86
  import { startTreeSnapshots } from "./treeSnapshot.js";
71
- import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
72
- import { makeRunVerify } from "./verify.js";
87
+ import { PipelineStageError, runPipeline as defaultRunPipeline, } from "./orchestrator.js";
88
+ import { baselineDirs, makeRunVerify, makeTakeVerifyBaseline, makeWorkstreamCheck, parseChangedPaths, planPaths, } from "./verify.js";
73
89
  import { loadGroundingEnabled } from "../grounding.js";
74
90
  import { codeStateHome } from "../stateHome.js";
75
91
  import { parseTierCap, TIER_CAP_ENV } from "./tierCap.js";
76
- import { parsePipelineTimeLeft, PIPELINE_TIME_LEFT_ENV, SANDBOX_RUN_BUDGET } from "./budget.js";
92
+ import { parsePipelineTimeLeft, PIPELINE_TIME_LEFT_ENV, SANDBOX_RUN_BUDGET, } from "./budget.js";
77
93
  /** One-line usage copy, shared by every argument error. */
78
- export const HEADLESS_GO_USAGE = "Usage: yagni go --headless --ticket-file <path> [--plan-file <path>] [--memo-file <path>] [--continue-file <path>] [--run-id <id>] [--cwd <path>] [--json]";
94
+ export const HEADLESS_GO_USAGE = "Usage: yagni go --headless [--cloud] --ticket-file <path> [--plan-file <path>] [--memo-file <path>] [--continue-file <path>] [--run-id <id>] [--cwd <path>] [--json]";
95
+ /** With a deadline, the final verify of a cloud session needs at least this much of it left. */
96
+ const FINAL_VERIFY_MIN_LEFT_MS = 5 * 60_000;
79
97
  /**
80
98
  * Exit codes. `verified` is deliberately narrow: only a `clean` stop means the
81
99
  * run produced a reviewed candidate, so a round_cap / no_changes / failed run
82
100
  * can never read as success to a script, a smoke gate, or CI.
83
101
  */
84
102
  export const HEADLESS_GO_EXIT = { verified: 0, unverified: 1, usage: 2 };
103
+ /** The verify baseline's share of a deadline run's time left, and the least it is worth starting with. */
104
+ const BASELINE_SHARE_OF_TIME_LEFT = 6;
105
+ const MIN_BASELINE_BUDGET_MS = 60_000;
85
106
  const VALUE_FLAGS = {
86
107
  "--ticket-file": "ticketFile",
87
108
  "--plan-file": "planFile",
@@ -101,6 +122,7 @@ export function parseHeadlessGoArgs(argv) {
101
122
  const args = {
102
123
  headless: false,
103
124
  json: false,
125
+ cloud: false,
104
126
  unknownFlags: [],
105
127
  positionals: [],
106
128
  missingValues: [],
@@ -120,6 +142,10 @@ export function parseHeadlessGoArgs(argv) {
120
142
  args.json = true;
121
143
  continue;
122
144
  }
145
+ if (name === "--cloud") {
146
+ args.cloud = true;
147
+ continue;
148
+ }
123
149
  const key = VALUE_FLAGS[name];
124
150
  if (key) {
125
151
  const next = inlineValue ?? argv[i + 1];
@@ -195,7 +221,9 @@ function buildResult(result, ctx) {
195
221
  rounds: result.rounds.length,
196
222
  findings: result.findings.length,
197
223
  blocking: result.findings.filter(isBlocking).length,
198
- finalFindings: [...result.findings].sort((a, b) => Number(isBlocking(b)) - Number(isBlocking(a))).slice(0, 10),
224
+ finalFindings: [...result.findings]
225
+ .sort((a, b) => Number(isBlocking(b)) - Number(isBlocking(a)))
226
+ .slice(0, 10),
199
227
  stages: stageRows(result),
200
228
  ...(ctx.runId ? { runId: ctx.runId } : {}),
201
229
  ...(ctx.tierCap ? { tierCap: ctx.tierCap } : {}),
@@ -204,6 +232,7 @@ function buildResult(result, ctx) {
204
232
  ...(finish?.prUrl ? { prUrl: finish.prUrl } : {}),
205
233
  ...(result.verifyNote ? { verifyNote: result.verifyNote } : {}),
206
234
  ...(result.verifyCommand ? { verifyCommand: result.verifyCommand } : {}),
235
+ ...(ctx.preexistingFailures ? { preexistingFailures: ctx.preexistingFailures } : {}),
207
236
  };
208
237
  }
209
238
  /** Attribution rider for a streamed child event (never overwrites pi's own keys). */
@@ -245,7 +274,9 @@ export async function runHeadlessGo(argv, deps = {}) {
245
274
  return readFile(path);
246
275
  }
247
276
  catch (err) {
248
- return { error: `Could not read the ${label} at ${path}: ${err instanceof Error ? err.message : String(err)}` };
277
+ return {
278
+ error: `Could not read the ${label} at ${path}: ${err instanceof Error ? err.message : String(err)}`,
279
+ };
249
280
  }
250
281
  };
251
282
  const files = {
@@ -289,22 +320,35 @@ export async function runHeadlessGo(argv, deps = {}) {
289
320
  const deadlineMs = timeLeftMs === undefined ? undefined : (deps.now ?? Date.now)() + timeLeftMs;
290
321
  const budget = deadlineMs === undefined
291
322
  ? deps.budget
292
- : { ...(deps.budget ?? SANDBOX_RUN_BUDGET), deadlineMs: deps.budget?.deadlineMs ?? deadlineMs };
323
+ : {
324
+ ...(deps.budget ?? SANDBOX_RUN_BUDGET),
325
+ deadlineMs: deps.budget?.deadlineMs ?? deadlineMs,
326
+ };
293
327
  const cwd = args.cwd ?? deps.cwd ?? process.cwd();
294
328
  const emit = (obj) => write(JSON.stringify(obj));
295
329
  const stream = args.json;
330
+ // A blank plan or memo file is NOT mission context: it must never read as an
331
+ // approved plan (which would skip the pipeline's planning on nothing at all).
332
+ const mission = normalizeMission({ plan: files.plan, memo: files.memo });
333
+ // The cloud session (cloud.ts): its identity and output folder from the
334
+ // host's env, read before anything runs, since its result must carry them.
335
+ const cloudEnv = args.cloud ? readCloudEnv(env) : undefined;
336
+ if (cloudEnv && "problem" in cloudEnv) {
337
+ writeErr(cloudEnv.problem);
338
+ writeErr(HEADLESS_GO_USAGE);
339
+ return { exitCode: HEADLESS_GO_EXIT.usage, error: cloudEnv.problem };
340
+ }
296
341
  const childEnv = {
297
342
  ...(deps.childEnv ?? env),
298
343
  ...(tierCap ? { [TIER_CAP_ENV]: tierCap } : {}),
344
+ ...(cloudEnv ? { [CLOUD_SESSION_ENV]: "1" } : {}),
299
345
  };
300
- // A blank plan or memo file is NOT mission context: it must never read as an
301
- // approved plan (which would skip the pipeline's planning on nothing at all).
302
- const mission = normalizeMission({ plan: files.plan, memo: files.memo });
303
346
  // The continue file fails soft on every path: a continuation the run cannot
304
347
  // read still has the saved change in its tree, and a normal mission on it beats
305
- // no run at all.
348
+ // no run at all. A cloud session's continue file needs no plan: a fix or
349
+ // verify execution resumes from the attempt's branch.
306
350
  let continuation;
307
- if (args.continueFile && !mission) {
351
+ if (args.continueFile && !mission && !cloudEnv) {
308
352
  writeErr(`Ignoring --continue-file ${args.continueFile}: a continuation needs a mission (--plan-file or --memo-file).`);
309
353
  }
310
354
  else if (args.continueFile) {
@@ -317,9 +361,27 @@ export async function runHeadlessGo(argv, deps = {}) {
317
361
  else if (parsed)
318
362
  writeErr(`Ignoring --continue-file ${args.continueFile}: ${parsed.reason}. Running as a normal mission.`);
319
363
  }
320
- const resumeFrom = continuation
321
- ? continuationResumePlan(continuation, { ticket: files.ticket, ...(args.runId ? { runId: args.runId } : {}), ...(mission ? { mission } : {}) })
322
- : undefined;
364
+ // The cloud lane and the stage this execution starts at.
365
+ let cloud;
366
+ let lane;
367
+ if (cloudEnv) {
368
+ const prepared = prepareCloudLane({
369
+ inputDir: dirname(args.ticketFile ?? ""),
370
+ env,
371
+ ...(args.continueFile ? { continueFile: args.continueFile } : {}),
372
+ ...(continuation ? { continuation } : {}),
373
+ hasPlan: Boolean(mission?.plan),
374
+ readFile,
375
+ writeErr,
376
+ });
377
+ if ("problem" in prepared) {
378
+ writeErr(prepared.problem);
379
+ return { exitCode: HEADLESS_GO_EXIT.usage, error: prepared.problem };
380
+ }
381
+ cloud = { ...cloudEnv, stage: prepared.stage };
382
+ lane = prepared.lane;
383
+ }
384
+ const cloudStage = cloud?.stage;
323
385
  // The implement diamond's recording thread, per RUN. The beats ride the
324
386
  // NDJSON stream as `pipeline_fanout` records, and the mission collector
325
387
  // folds them onto the Run.
@@ -327,16 +389,16 @@ export async function runHeadlessGo(argv, deps = {}) {
327
389
  // A mission's approved checks and its pull request's CI jobs, written by the
328
390
  // host beside the ticket: the verify gate runs them inside the loop. No file
329
391
  // (a host that predates it) leaves the gate as it was.
330
- let runVerify;
331
- if (mission && args.ticketFile) {
392
+ let gateOpts;
393
+ if ((mission || cloud) && args.ticketFile) {
332
394
  const checksPath = join(dirname(args.ticketFile), MISSION_CHECKS_FILE_NAME);
333
395
  const read = readMissionChecksFile(checksPath, readFile);
334
396
  if (read.file) {
335
397
  const checks = read.file;
336
398
  // How the failed CI checks' jobs went, for the host, beside the checks
337
- // file. A write that fails is said once; the host then trusts the
338
- // round's own account, as before.
339
- const resultsPath = join(dirname(args.ticketFile), MISSION_CI_RESULTS_FILE_NAME);
399
+ // file (a cloud session's outputs folder). A write that fails is said
400
+ // once; the host then trusts the round's own account, as before.
401
+ const resultsPath = join(cloud?.outDir ?? dirname(args.ticketFile), MISSION_CI_RESULTS_FILE_NAME);
340
402
  let resultsWriteFailed = false;
341
403
  const onFailedChecks = (results) => {
342
404
  try {
@@ -349,7 +411,7 @@ export async function runHeadlessGo(argv, deps = {}) {
349
411
  writeErr(`Could not write ${resultsPath}: ${err instanceof Error ? err.message : String(err)}`);
350
412
  }
351
413
  };
352
- runVerify = (deps.makeRunVerify ?? makeRunVerify)({ repoChecks: () => checks, onFailedChecks });
414
+ gateOpts = { repoChecks: () => checks, onFailedChecks };
353
415
  }
354
416
  else if (read.problem !== "missing") {
355
417
  const why = read.problem === "malformed" ? "not a version 1 checks file" : "it could not be read";
@@ -361,7 +423,7 @@ export async function runHeadlessGo(argv, deps = {}) {
361
423
  // is an ordinary mission; an unreadable one is reported and the run goes on
362
424
  // the full path, which is slower but never wrong.
363
425
  let fixRound;
364
- if (mission && args.ticketFile) {
426
+ if ((cloud ? cloudStage === "fix" : mission) && args.ticketFile) {
365
427
  const fixPath = join(dirname(args.ticketFile), MISSION_FIX_ROUND_FILE_NAME);
366
428
  const read = readFixRoundFile(fixPath, readFile);
367
429
  if (read.file)
@@ -371,6 +433,91 @@ export async function runHeadlessGo(argv, deps = {}) {
371
433
  writeErr(`Ignoring ${fixPath}: ${why}. The fix round runs the full pipeline.`);
372
434
  }
373
435
  }
436
+ // A continuation past implement re-enters the review loop. A cloud fix
437
+ // execution with the round's findings in `fix-round.json` runs fix mode
438
+ // instead, which the loop's re-entry would otherwise override.
439
+ const resumeFrom = continuation && !(cloud && fixRound)
440
+ ? continuationResumePlan(continuation, {
441
+ ticket: files.ticket,
442
+ ...(args.runId ? { runId: args.runId } : {}),
443
+ ...(mission ? { mission } : {}),
444
+ }, lane ? { maxRounds: lane.maxReviewRounds, committed: true } : {})
445
+ : undefined;
446
+ // The gate's own checks, once on the clean tree before the build (YAG-1003),
447
+ // in the packages the approved plan names: what already failed there is a
448
+ // note, never a finding. Only a fresh build's tree is the clean base; a
449
+ // continuation or a fix round already carries a change, so it takes none (a
450
+ // cloud continuation's earlier work is committed on its branch, and taking
451
+ // the baseline there would excuse the attempt's own breakage). It then leans
452
+ // on `checks.json`, whose base standing the host took on the real base.
453
+ let baseline;
454
+ const logBaseline = (data) => {
455
+ deps.logger?.("verify_baseline", data);
456
+ if (stream)
457
+ emit({ type: "pipeline_log", event: "verify_baseline", data });
458
+ };
459
+ // With a deadline, the baseline gets at most a sixth of the run's time left,
460
+ // and none below a minute: the build itself must still fit.
461
+ const deadlineLeftMs = budget?.deadlineMs === undefined ? undefined : budget.deadlineMs - (deps.now ?? Date.now)();
462
+ const baselineBudgetMs = deadlineLeftMs === undefined
463
+ ? undefined
464
+ : Math.floor(deadlineLeftMs / BASELINE_SHARE_OF_TIME_LEFT);
465
+ if (mission?.plan && !continuation && !fixRound && !deps.signal?.aborted) {
466
+ const dirs = baselineDirs(cwd, planPaths(mission.plan), readFile);
467
+ if (dirs.length > 0 &&
468
+ baselineBudgetMs !== undefined &&
469
+ baselineBudgetMs < MIN_BASELINE_BUDGET_MS) {
470
+ logBaseline({
471
+ skipped: "too little of the run's time left",
472
+ deadlineLeftMs,
473
+ baselineBudgetMs,
474
+ });
475
+ }
476
+ else if (dirs.length > 0) {
477
+ try {
478
+ const taken = await (deps.takeVerifyBaseline ?? makeTakeVerifyBaseline())(cwd, dirs, deps.signal, baselineBudgetMs);
479
+ logBaseline({ ran: taken.ran, failures: taken.failures, notes: taken.notes });
480
+ if (taken.baseline.size > 0)
481
+ baseline = taken.baseline;
482
+ }
483
+ catch (err) {
484
+ const message = err instanceof Error ? err.message : String(err);
485
+ logBaseline({ error: message });
486
+ writeErr(`The verify baseline could not run (every failure counts): ${message}`);
487
+ }
488
+ }
489
+ }
490
+ // The last gate verdict's count of failures the clean tree already had, for the result.
491
+ let preexistingFailures = 0;
492
+ let runVerify;
493
+ // A cloud session reports the checks of the tree it commits: the gate's last
494
+ // verdict, with the tree it judged.
495
+ const snapshot = deps.snapshotWorkspace ?? snapshotWorkspace;
496
+ let gate;
497
+ let lastVerify;
498
+ // A cloud session's gate also runs the host's secret and binary scans on
499
+ // what it added (YAG-1000), its own run token among the secrets it looks for.
500
+ const exec = deps.exec ?? finishExec;
501
+ const sessionSecrets = [env.YAGNI_TOKEN, env.GH_ENTERPRISE_TOKEN].filter((value) => Boolean(value));
502
+ const scan = cloud
503
+ ? (scanCwd, signal, skip) => scanChange(scanCwd, { exec, sessionSecrets }, skip, signal)
504
+ : undefined;
505
+ if (gateOpts || baseline || cloud) {
506
+ const made = (deps.makeRunVerify ?? makeRunVerify)({
507
+ ...gateOpts,
508
+ ...(baseline ? { baseline } : {}),
509
+ ...(scan ? { scanChange: scan } : {}),
510
+ });
511
+ gate = made;
512
+ runVerify = async (verifyCwd, signal, baselinePaths) => {
513
+ const outcome = await made(verifyCwd, signal, baselinePaths);
514
+ if (outcome.ran)
515
+ preexistingFailures = outcome.preexisting ?? 0;
516
+ if (cloud)
517
+ lastVerify = { outcome, tree: treeKey(await snapshot(verifyCwd, signal)) };
518
+ return outcome;
519
+ };
520
+ }
374
521
  const pipelineDeps = {
375
522
  cwd,
376
523
  ...(fixRound ? { fixRound } : {}),
@@ -389,9 +536,13 @@ export async function runHeadlessGo(argv, deps = {}) {
389
536
  ...(budget ? { budget } : {}),
390
537
  ...(deps.stages ? { stages: deps.stages } : {}),
391
538
  grounded: deps.grounded ?? grounding,
539
+ ...(lane ? { lane } : {}),
392
540
  ...(mission ? { mission } : {}),
393
541
  ...(runVerify ? { runVerify } : {}),
394
- ...(continuation ? { continuation: { stageAtStop: continuation.stageAtStop } } : {}),
542
+ ...(baseline ? { runWorkstreamCheck: makeWorkstreamCheck({ baseline }) } : {}),
543
+ // A cloud execution never starts on an earlier attempt's uncommitted
544
+ // edits: that work is on the branch, so its tree baselines as it is.
545
+ ...(continuation && !cloud ? { continuation: { stageAtStop: continuation.stageAtStop } } : {}),
395
546
  ...(resumeFrom ? { resumeFrom } : {}),
396
547
  onEvent: (ev, tag) => {
397
548
  deps.onEvent?.(ev, tag);
@@ -424,19 +575,83 @@ export async function runHeadlessGo(argv, deps = {}) {
424
575
  emit({ type: "pipeline_log", event, ...(data === undefined ? {} : { data }) });
425
576
  },
426
577
  };
578
+ // A cloud session's start: the commit it builds on (its result's `baseSha`)
579
+ // and the tree's pre-run dirt (an install's lockfile churn), which never
580
+ // lands in its commit.
581
+ const startTree = cloud ? await snapshot(cwd, deps.signal) : undefined;
582
+ const baseSha = startTree?.head ?? "";
583
+ const preRunDirt = startTree ? parseChangedPaths(startTree.status) : [];
584
+ /** The cloud session's last act (cloud.ts `completeCloudSession`), mirrored on the stream. */
585
+ const completeCloud = async (session, ran) => {
586
+ const verifyGate = gate;
587
+ const done = await completeCloudSession({
588
+ identity: session.identity,
589
+ outDir: session.outDir,
590
+ stage: session.stage,
591
+ cwd,
592
+ baseSha,
593
+ preRunDirt,
594
+ ...(mission?.plan ? { plan: mission.plan } : {}),
595
+ roundsDone: continuation?.reviewRoundsDone ?? 0,
596
+ ...(lastVerify ? { lastVerify } : {}),
597
+ tree: async () => treeKey(await snapshot(cwd, deps.signal)),
598
+ // The checks run commands and may take minutes: with a deadline they
599
+ // need some of it left. The scan below runs no model and always runs.
600
+ ...(verifyGate
601
+ ? {
602
+ reverify: async () => {
603
+ const left = budget?.deadlineMs === undefined
604
+ ? undefined
605
+ : budget.deadlineMs - (deps.now ?? Date.now)();
606
+ if (deps.signal?.aborted || (left !== undefined && left < FINAL_VERIFY_MIN_LEFT_MS))
607
+ return null;
608
+ return verifyGate(cwd, deps.signal, preRunDirt);
609
+ },
610
+ }
611
+ : {}),
612
+ scan: () => scanChange(cwd, { exec, sessionSecrets }, preRunDirt),
613
+ planPaths: (planText) => baselineDirs(cwd, planPaths(planText), readFile).map((dir) => relative(cwd, dir) || "."),
614
+ exec,
615
+ writeFile: deps.writeFile ?? writeFileSync,
616
+ }, ran);
617
+ const result = "result" in ran ? ran.result : undefined;
618
+ const headless = result
619
+ ? buildResult(result, {
620
+ ...(args.runId ? { runId: args.runId } : {}),
621
+ ...(tierCap ? { tierCap } : {}),
622
+ preexistingFailures,
623
+ })
624
+ : undefined;
625
+ emit({ type: "pipeline_result", ...headless, ...done.attempt });
626
+ return {
627
+ exitCode: done.ok ? HEADLESS_GO_EXIT.verified : HEADLESS_GO_EXIT.unverified,
628
+ ...(headless ? { result: headless } : {}),
629
+ ...("error" in ran ? { error: ran.error } : {}),
630
+ };
631
+ };
427
632
  // A mission's tree is the only copy of its work until the host commits it,
428
- // so it is snapshotted for the host to recover from (treeSnapshot.ts).
429
- const stopSnapshots = mission
633
+ // so it is snapshotted for the host to recover from (treeSnapshot.ts). A
634
+ // cloud execution that builds is snapshotted the same way until it commits.
635
+ // It stops before a cloud session commits, so its last snapshot is the
636
+ // tree being committed, and only once.
637
+ const snapshots = (cloud ? cloudStage !== "plan" : mission)
430
638
  ? (deps.startTreeSnapshots ?? startTreeSnapshots)(cwd, {
431
639
  onError: (message) => writeErr(`Working-tree checkpoint failed (the run continues without it): ${message}`),
432
640
  })
433
641
  : null;
642
+ let stopping;
643
+ const stopSnapshots = () => (stopping ??= snapshots?.() ?? Promise.resolve());
434
644
  try {
435
645
  const result = await runPipeline(files.ticket, pipelineDeps);
646
+ if (cloud) {
647
+ await stopSnapshots();
648
+ return await completeCloud(cloud, { result });
649
+ }
436
650
  const headless = buildResult(result, {
437
651
  ...(args.runId ? { runId: args.runId } : {}),
438
652
  ...(tierCap ? { tierCap } : {}),
439
653
  ...(mission ? { missionMode: true } : {}),
654
+ preexistingFailures,
440
655
  });
441
656
  emit({ type: "pipeline_result", ...headless });
442
657
  return {
@@ -449,10 +664,19 @@ export async function runHeadlessGo(argv, deps = {}) {
449
664
  const stage = err?.stageId;
450
665
  emit({ type: "pipeline_error", ...(stage ? { stage } : {}), message });
451
666
  writeErr(message);
667
+ if (cloud) {
668
+ await stopSnapshots();
669
+ const partial = err instanceof PipelineStageError ? err.partial : [];
670
+ return await completeCloud(cloud, { error: message, partial });
671
+ }
452
672
  return { exitCode: HEADLESS_GO_EXIT.unverified, error: message };
453
673
  }
454
674
  finally {
455
- await stopSnapshots?.();
675
+ await stopSnapshots();
456
676
  }
457
677
  }
678
+ /** A tree as the cloud session compares it: HEAD and the porcelain status. */
679
+ function treeKey(snapshot) {
680
+ return `${snapshot.head}\n${snapshot.status}`;
681
+ }
458
682
  //# sourceMappingURL=headlessGo.js.map
@@ -32,7 +32,7 @@ import { type ResilienceAttemptRecord, type RunStageFn } from "./resilience.js";
32
32
  import { runStage as defaultRunStage } from "./runner.js";
33
33
  import { type VerifyOutcome, type WorkstreamCheckResult, type WorkstreamCheckTarget } from "./verify.js";
34
34
  import { snapshotWorkspace as defaultSnapshotWorkspace } from "./workspace.js";
35
- import { type CheckpointStore, type FanoutBudgetVerdict, type FanoutMode, type Finding, type PipelineStage, type JsonEvent, type PipelineProgress, type PipelineResult, type ResiliencePolicy, type ResumePlan, type StageId, type StageResult, type StageTag } from "./types.js";
35
+ import { type CheckpointStore, type FanoutBudgetVerdict, type FanoutMode, type Finding, type PipelineStage, type JsonEvent, type PipelineLane, type PipelineProgress, type PipelineResult, type ResiliencePolicy, type ResumePlan, type StageId, type StageResult, type StageTag } from "./types.js";
36
36
  /** Each review round's findings as the host stores them: bounded, messages clipped. */
37
37
  export declare const MAX_ROUND_ITEMS = 20;
38
38
  export interface RoundFindingItem {
@@ -87,10 +87,16 @@ export interface RunPipelineDeps {
87
87
  /** Wall clock for the budget's deadline checks (epoch ms). Defaults to `Date.now`; injected in tests. */
88
88
  now?: () => number;
89
89
  /**
90
- * M6 eval: the stage list to run. Defaults to selectStages("full"); the blind
90
+ * M6 eval: the stage list to run. Defaults to the lane's; the blind
91
91
  * lane passes a grounding-tool-stripped copy (see eval.ts#blindStages).
92
92
  */
93
93
  stages?: PipelineStage[];
94
+ /**
95
+ * The lane's stage list and loop rules (stages.ts). Defaults to `FULL_LANE`
96
+ * (the diamond, three lenses, two rounds); headless `--cloud` passes the
97
+ * cloud lane (one writer, one reviewer, four rounds, a plan that ends the run).
98
+ */
99
+ lane?: PipelineLane;
94
100
  /**
95
101
  * M6 eval: when false, run the BLIND lane (grounding-free personas via
96
102
  * personaBody's grounded flag, paired with grounding-stripped stages). Defaults