@yagni-app/code-staging 1.2.10-staging.2105.1 → 1.2.10-staging.2111.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The one rule for "did this change cause this failure?", as the session's
3
+ * verify gate reads it (`verify.ts`). This package does not depend on
4
+ * `@yagni/shared`, so the region between the markers below is a byte-for-byte
5
+ * copy of the same region of `packages/shared/src/checkAttribution.ts`, where
6
+ * the rule is written out; `test/pipeline/checkAttribution.test.ts` fails when
7
+ * the two differ, and both packages' tests read one corpus,
8
+ * `test/fixtures/checkAttribution.json`. Change the shared file and copy the
9
+ * region here.
10
+ *
11
+ * Copied rather than bundled: the extension is built by `tsc` alone and
12
+ * shipped inside the CLI's own package, and the identical copy keeps that so
13
+ * while the test keeps one rule.
14
+ */
15
+ /**
16
+ * A command as two lists compare it: the same words, whatever spaces sit
17
+ * between them, and a package manager's script named the one way it runs,
18
+ * so `yarn test` and `yarn run test`, `npm test` and `npm run test`, `pnpm
19
+ * lint` and `pnpm run lint` are one check. Only the leading form: a
20
+ * manager's flags before the script leave the command as it is. Both
21
+ * packages' tests read one corpus,
22
+ * pi-extension-yagni/test/fixtures/equivalentCheckCommands.json.
23
+ */
24
+ export declare function canonicalCheckCommand(command: string): string;
25
+ /**
26
+ * One check's identity across the change's run and the base's, and across
27
+ * sessions: the folder it runs in, relative to the repository root, and its
28
+ * command as {@link canonicalCheckCommand} reads it.
29
+ */
30
+ export declare function checkId(command: string, cwd: string): string;
31
+ /** A failing test's key: its name, without the duration a runner prints after it. */
32
+ export declare function testKey(name: string): string;
33
+ /** A located diagnostic's key: its file and message, never its line, which a change above it moves. */
34
+ export declare function diagKey(file: string, message: string): string;
35
+ /** A check annotation's key: its path and message, never its line. */
36
+ export declare function annotationKey(path: string, message: string): string;
37
+ /**
38
+ * What the change did to a failure: `caused` it (nothing of it was on the
39
+ * base), found it `preexisting` (every key was on the base at least as many
40
+ * times), `partly` caused it (some keys were, some were not), or there is
41
+ * `no_baseline` to compare with, and it counts as the change's.
42
+ */
43
+ export type FailureAttribution = "caused" | "preexisting" | "partly" | "no_baseline";
44
+ /**
45
+ * One run of a check as the rule reads it: its failure keys, null for a
46
+ * failure that names nothing, and the step that failed when the runner says.
47
+ * A run that passed has no keys.
48
+ */
49
+ export interface FailureRun {
50
+ keys: ReadonlyArray<string | null>;
51
+ step?: string | null;
52
+ }
53
+ /**
54
+ * The rule. `excused` is index for index with `head.keys`: true where the
55
+ * base already had that failure, each base key excusing one head key at most.
56
+ * Pure.
57
+ */
58
+ export declare function attributeFailure(head: FailureRun, base: FailureRun | null | undefined): {
59
+ verdict: FailureAttribution;
60
+ excused: boolean[];
61
+ };
62
+ /** What one check did on the base: it passed, or it failed with these keys. */
63
+ export interface BaseMeasurement {
64
+ id: string;
65
+ outcome: "passed" | "failed";
66
+ keys: string[];
67
+ }
68
+ /** The file the session writes beside its `result.json`, and the bounds both sides keep. */
69
+ export declare const BASE_MEASUREMENTS_FILE_NAME = "base-measurements.json";
70
+ export declare const MAX_BASE_MEASUREMENTS = 100;
71
+ export declare const MAX_BASE_KEYS = 1000;
72
+ export declare const MAX_BASE_KEY_CHARS = 500;
73
+ /**
74
+ * One measurement within the bounds: a key longer than the bound is left
75
+ * out, and keys past the count bound are dropped. Either only makes fewer
76
+ * failures excused, never more.
77
+ */
78
+ export declare function boundedMeasurement(measurement: BaseMeasurement): BaseMeasurement;
79
+ /**
80
+ * `base-measurements.json` as the host reads it (`parseBaseMeasurements` in
81
+ * `@yagni/shared`): the commit the checks ran on, and what each did there,
82
+ * each held to the bounds.
83
+ */
84
+ export declare function renderBaseMeasurements(baseSha: string, measured: ReadonlyMap<string, Omit<BaseMeasurement, "id">>): string;
85
+ //# sourceMappingURL=checkAttribution.d.ts.map
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The one rule for "did this change cause this failure?", as the session's
3
+ * verify gate reads it (`verify.ts`). This package does not depend on
4
+ * `@yagni/shared`, so the region between the markers below is a byte-for-byte
5
+ * copy of the same region of `packages/shared/src/checkAttribution.ts`, where
6
+ * the rule is written out; `test/pipeline/checkAttribution.test.ts` fails when
7
+ * the two differ, and both packages' tests read one corpus,
8
+ * `test/fixtures/checkAttribution.json`. Change the shared file and copy the
9
+ * region here.
10
+ *
11
+ * Copied rather than bundled: the extension is built by `tsc` alone and
12
+ * shipped inside the CLI's own package, and the identical copy keeps that so
13
+ * while the test keeps one rule.
14
+ */
15
+ // --- mirrored region: begin (pi-extension-yagni/src/pipeline/checkAttribution.ts)
16
+ /** Package-manager words that are the manager's own command, never a script. */
17
+ const YARN_COMMANDS = new Set("add audit autoclean bin cache check config constraints create dedupe dlx exec explain generate-lock-entry global help import info init install licenses link list login logout node npm outdated owner pack patch patch-commit plugin policies publish rebuild remove run set stage tag team unlink unplug up upgrade upgrade-interactive version versions why workspace workspaces".split(" "));
18
+ const PNPM_COMMANDS = new Set("add audit bin cache cat-file cat-index config create dedupe deploy dlx doctor env exec fetch find-hash i import init install install-test it licenses link list ln ls outdated pack patch patch-commit patch-remove prune publish rb rebuild recursive remove rm root run self-update server setup store un uninstall unlink up update why".split(" "));
19
+ /** The scripts npm runs without `run`: its lifecycle shorthands. */
20
+ const NPM_SCRIPT_SHORTHANDS = new Set(["test", "start", "stop", "restart"]);
21
+ /**
22
+ * A command as two lists compare it: the same words, whatever spaces sit
23
+ * between them, and a package manager's script named the one way it runs,
24
+ * so `yarn test` and `yarn run test`, `npm test` and `npm run test`, `pnpm
25
+ * lint` and `pnpm run lint` are one check. Only the leading form: a
26
+ * manager's flags before the script leave the command as it is. Both
27
+ * packages' tests read one corpus,
28
+ * pi-extension-yagni/test/fixtures/equivalentCheckCommands.json.
29
+ */
30
+ export function canonicalCheckCommand(command) {
31
+ const words = command.trim().split(/\s+/).filter(Boolean);
32
+ const [manager, next, ...rest] = words;
33
+ const script = /^[A-Za-z0-9][\w:.@/-]*$/;
34
+ if (manager === "npm" || manager === "yarn" || manager === "pnpm") {
35
+ const runs = next === "run" || (manager === "npm" && next === "run-script");
36
+ if (runs && rest[0] && script.test(rest[0]))
37
+ return [manager, "run", ...rest].join(" ");
38
+ const shorthand = next !== undefined &&
39
+ script.test(next) &&
40
+ (manager === "npm"
41
+ ? NPM_SCRIPT_SHORTHANDS.has(next)
42
+ : !(manager === "yarn" ? YARN_COMMANDS : PNPM_COMMANDS).has(next));
43
+ if (shorthand)
44
+ return [manager, "run", next, ...rest].join(" ");
45
+ }
46
+ return words.join(" ");
47
+ }
48
+ /**
49
+ * One check's identity across the change's run and the base's, and across
50
+ * sessions: the folder it runs in, relative to the repository root, and its
51
+ * command as {@link canonicalCheckCommand} reads it.
52
+ */
53
+ export function checkId(command, cwd) {
54
+ const dir = cwd
55
+ .replace(/\\/g, "/")
56
+ .replace(/^(?:\.\/+)+/, "")
57
+ .replace(/\/+$/, "") || ".";
58
+ return `${dir}\u0000${canonicalCheckCommand(command)}`;
59
+ }
60
+ /** A failing test's key: its name, without the duration a runner prints after it. */
61
+ export function testKey(name) {
62
+ return `test:${name.replace(/\s*\(\d+(?:\.\d+)?\s*m?s\)\s*$/, "").trim()}`;
63
+ }
64
+ /** A located diagnostic's key: its file and message, never its line, which a change above it moves. */
65
+ export function diagKey(file, message) {
66
+ return `diag:${file.replace(/\\/g, "/").replace(/^(?:\.\/+)+/, "")}\u0000${message.trim()}`;
67
+ }
68
+ /** A check annotation's key: its path and message, never its line. */
69
+ export function annotationKey(path, message) {
70
+ return `ann:${path}\u0000${message.trim()}`;
71
+ }
72
+ /**
73
+ * The rule. `excused` is index for index with `head.keys`: true where the
74
+ * base already had that failure, each base key excusing one head key at most.
75
+ * Pure.
76
+ */
77
+ export function attributeFailure(head, base) {
78
+ const excused = head.keys.map(() => false);
79
+ if (!base)
80
+ return { verdict: "no_baseline", excused };
81
+ if (head.step && base.step && head.step !== base.step)
82
+ return { verdict: "caused", excused };
83
+ const left = new Map();
84
+ for (const key of base.keys)
85
+ if (key !== null)
86
+ left.set(key, (left.get(key) ?? 0) + 1);
87
+ head.keys.forEach((key, index) => {
88
+ const count = key === null ? 0 : (left.get(key) ?? 0);
89
+ if (key === null || count === 0)
90
+ return;
91
+ left.set(key, count - 1);
92
+ excused[index] = true;
93
+ });
94
+ const count = excused.filter(Boolean).length;
95
+ const verdict = count === 0 ? "caused" : count === head.keys.length ? "preexisting" : "partly";
96
+ return { verdict, excused };
97
+ }
98
+ /** The file the session writes beside its `result.json`, and the bounds both sides keep. */
99
+ export const BASE_MEASUREMENTS_FILE_NAME = "base-measurements.json";
100
+ export const MAX_BASE_MEASUREMENTS = 100;
101
+ export const MAX_BASE_KEYS = 1_000;
102
+ export const MAX_BASE_KEY_CHARS = 500;
103
+ /**
104
+ * One measurement within the bounds: a key longer than the bound is left
105
+ * out, and keys past the count bound are dropped. Either only makes fewer
106
+ * failures excused, never more.
107
+ */
108
+ export function boundedMeasurement(measurement) {
109
+ return {
110
+ id: measurement.id,
111
+ outcome: measurement.outcome,
112
+ keys: measurement.outcome === "passed"
113
+ ? []
114
+ : measurement.keys
115
+ .filter((key) => key.length <= MAX_BASE_KEY_CHARS)
116
+ .slice(0, MAX_BASE_KEYS),
117
+ };
118
+ }
119
+ // --- mirrored region: end
120
+ /**
121
+ * `base-measurements.json` as the host reads it (`parseBaseMeasurements` in
122
+ * `@yagni/shared`): the commit the checks ran on, and what each did there,
123
+ * each held to the bounds.
124
+ */
125
+ export function renderBaseMeasurements(baseSha, measured) {
126
+ const checks = [...measured]
127
+ .slice(0, MAX_BASE_MEASUREMENTS)
128
+ .map(([id, measurement]) => boundedMeasurement({ id, ...measurement }));
129
+ return `${JSON.stringify({ version: 1, baseSha, checks }, null, 2)}\n`;
130
+ }
131
+ //# sourceMappingURL=checkAttribution.js.map
@@ -75,7 +75,7 @@ import { snapshotWorkspace } from "./workspace.js";
75
75
  import type { WorktreeExec } from "./worktree.js";
76
76
  import { type TreeSnapshotDeps } from "./treeSnapshot.js";
77
77
  import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
78
- import { makeMeasureChecksOnBase, makeRunVerify, type VerifyBaselineResult } from "./verify.js";
78
+ import { baseTargets, makeMeasureOnBase, makeRunVerify } from "./verify.js";
79
79
  import { type RunBudget } from "./budget.js";
80
80
  import type { Finding, JsonEvent, ModelTier, PipelineStage, StageId, StageTag, StageUsage, StopReason } from "./types.js";
81
81
  /** One-line usage copy, shared by every argument error. */
@@ -182,14 +182,14 @@ export interface HeadlessGoDeps {
182
182
  * there is none). Defaults to the real gate; injected in tests.
183
183
  */
184
184
  makeRunVerify?: typeof makeRunVerify;
185
- /** The approved checks' run on the unchanged code (`measureOnBase`). Defaults to the real one; injected in tests. */
186
- measureChecksOnBase?: ReturnType<typeof makeMeasureChecksOnBase>;
185
+ /** The checks' run on the unchanged code (YAG-1003). Defaults to the real one; injected in tests. */
186
+ measureOnBase?: ReturnType<typeof makeMeasureOnBase>;
187
+ /** What the run on the unchanged code measures. Defaults to the real detection; injected in tests. */
188
+ baseTargets?: typeof baseTargets;
187
189
  /** A resumed session's switch to the attempt's base commit and back (baseTree.ts). Defaults to real git; injected in tests. */
188
190
  measureOnBaseTree?: typeof measureOnBaseTree;
189
191
  /** The paths the session's HEAD changed since the attempt's base, null when git cannot say. Defaults to real git; injected in tests. */
190
192
  pathsCommittedSince?: (cwd: string, base: string) => Promise<string[] | null>;
191
- /** The verify gate's clean-tree baseline run (YAG-1003). Defaults to the real one; injected in tests. */
192
- takeVerifyBaseline?: (cwd: string, dirs: string[], signal?: AbortSignal, budgetMs?: number) => Promise<VerifyBaselineResult>;
193
193
  /** stdout sink; one call per NDJSON line, newline added by the caller. */
194
194
  write?: (line: string) => void;
195
195
  /** stderr sink for human-readable problems. */
@@ -86,7 +86,8 @@ import { normalizeMission } from "./mission.js";
86
86
  import { MISSION_CHECKS_FILE_NAME, MISSION_CI_RESULTS_FILE_NAME, readMissionChecksFile, renderCiResults, } from "./missionChecks.js";
87
87
  import { startTreeSnapshots } from "./treeSnapshot.js";
88
88
  import { PipelineStageError, runPipeline as defaultRunPipeline, } from "./orchestrator.js";
89
- import { baselineDirs, makeMeasureChecksOnBase, makeRunVerify, makeTakeVerifyBaseline, makeWorkstreamCheck, parseChangedPaths, planPaths, } from "./verify.js";
89
+ import { baselineDirs, baseTargets, makeMeasureOnBase, makeRunVerify, makeWorkstreamCheck, parseChangedPaths, planPaths, } from "./verify.js";
90
+ import { BASE_MEASUREMENTS_FILE_NAME, renderBaseMeasurements, } from "./checkAttribution.js";
90
91
  import { loadGroundingEnabled } from "../grounding.js";
91
92
  import { codeStateHome } from "../stateHome.js";
92
93
  import { parseTierCap, TIER_CAP_ENV } from "./tierCap.js";
@@ -448,13 +449,16 @@ export async function runHeadlessGo(argv, deps = {}) {
448
449
  ...(mission ? { mission } : {}),
449
450
  }, lane ? { maxRounds: lane.maxReviewRounds, committed: true } : {})
450
451
  : undefined;
451
- // The gate's own checks, once on the clean tree before the build (YAG-1003),
452
- // in the packages the approved plan names: what already failed there is a
453
- // note, never a finding. Only a fresh build's tree is the clean base; a
454
- // continuation or a fix round already carries a change, so taking them on
455
- // its own tree would excuse the attempt's own breakage. It takes them on
456
- // the attempt's base commit instead (baseTree.ts), when its host names one.
457
- let baseline;
452
+ // What the gate compares a failure with: each check it will judge, run once
453
+ // on the unchanged code before the build (YAG-1003, `makeMeasureOnBase`), so
454
+ // a failure the base already had is a note, never a finding
455
+ // (`attributeFailure`, checkAttribution.ts). A fresh build's tree is the
456
+ // base; a continuation or a fix round already carries a change, so taking
457
+ // them on its own tree would excuse the attempt's own breakage, and it
458
+ // measures on the attempt's base commit instead (baseTree.ts), when its host
459
+ // names one. What the attempt's earlier sessions measured on that base
460
+ // comes in `checks.json` (`baseMeasured`) and is not run again; what this
461
+ // session measures goes to `base-measurements.json` for the host to keep.
458
462
  const logPipeline = (event, data) => {
459
463
  deps.logger?.(event, data);
460
464
  if (stream)
@@ -502,9 +506,9 @@ export async function runHeadlessGo(argv, deps = {}) {
502
506
  }
503
507
  return { exitCode: HEADLESS_GO_EXIT.unverified, error: message };
504
508
  };
505
- // A measurement before the build (the baseline, the plan's checks on the
506
- // base) gets at most a sixth of the run's time left when it starts, and
507
- // none below a minute: the build itself must still fit. No deadline, no cap.
509
+ // A measurement before the build gets at most a sixth of the run's time
510
+ // left when it starts, and none below a minute: the build itself must
511
+ // still fit. No deadline, no cap.
508
512
  const preBuildShare = () => {
509
513
  const leftMs = budget?.deadlineMs === undefined ? undefined : budget.deadlineMs - (deps.now ?? Date.now)();
510
514
  const shareMs = leftMs === undefined ? undefined : Math.floor(leftMs / BASELINE_SHARE_OF_TIME_LEFT);
@@ -514,8 +518,15 @@ export async function runHeadlessGo(argv, deps = {}) {
514
518
  tooLittle: shareMs !== undefined && shareMs < MIN_BASELINE_BUDGET_MS,
515
519
  };
516
520
  };
517
- /** The gate's detected checks on the tree as it is, in `dirs`; never throws. */
518
- const takeBaseline = async (dirs) => {
521
+ const measureFile = gateOpts?.repoChecks?.() ?? null;
522
+ // The host matched them to the attempt's base: the tree a fresh build
523
+ // starts on, and the commit a resumed session measures on.
524
+ const measuredBase = carriedMeasurements(measureFile);
525
+ const carried = measuredBase.size;
526
+ const measuredHere = new Map();
527
+ const targetsOf = deps.baseTargets ?? baseTargets;
528
+ /** The targets no session measured yet, on the tree as it is; never throws. */
529
+ const measure = async (targets) => {
519
530
  const share = preBuildShare();
520
531
  if (share.tooLittle) {
521
532
  logPipeline("verify_baseline", {
@@ -526,79 +537,53 @@ export async function runHeadlessGo(argv, deps = {}) {
526
537
  return;
527
538
  }
528
539
  try {
529
- const taken = await (deps.takeVerifyBaseline ?? makeTakeVerifyBaseline(heard))(cwd, dirs, deps.signal, share.shareMs);
540
+ const result = await (deps.measureOnBase ?? makeMeasureOnBase(heard))(cwd, targets, deps.signal, share.shareMs);
530
541
  logPipeline("verify_baseline", {
531
- ran: taken.ran,
532
- failures: taken.failures,
533
- notes: taken.notes,
542
+ ran: result.ran,
543
+ failures: result.failures,
544
+ notes: result.notes,
545
+ treeUndone: result.treeUndone,
546
+ carried,
534
547
  });
535
- if (taken.baseline.size > 0)
536
- baseline = taken.baseline;
548
+ for (const [id, measurement] of result.measured) {
549
+ measuredBase.set(id, measurement);
550
+ measuredHere.set(id, measurement);
551
+ }
537
552
  }
538
553
  catch (err) {
539
554
  const message = err instanceof Error ? err.message : String(err);
540
555
  logPipeline("verify_baseline", { error: message });
541
- writeErr(`The verify baseline could not run (every failure counts): ${message}`);
556
+ writeErr(`The base measurement could not run (every failure counts): ${message}`);
542
557
  }
543
558
  };
559
+ const missing = (targets) => targets.filter((target) => !measuredBase.has(target.id));
544
560
  const resumed = Boolean(continuation || fixRound);
545
- if (mission?.plan && !resumed && !deps.signal?.aborted) {
546
- const dirs = baselineDirs(cwd, planPaths(mission.plan), readFile);
547
- if (dirs.length > 0)
548
- await takeBaseline(dirs);
549
- }
550
- // The approved checks the host has not seen pass on the unchanged code
551
- // (`measureOnBase`: the plan's, and CI's when the repository's record has
552
- // none of its own), each run once there before the build, on the same
553
- // fresh tree the baseline above reads: a check that already failed there is
554
- // not this change's to fix.
555
- let checksOnBase;
556
- const measureFile = gateOpts?.repoChecks?.() ?? null;
557
- /** The approved checks `file` marks to measure, on the tree as it is; never throws. */
558
- const measureChecks = async (file) => {
559
- const share = preBuildShare();
560
- if (share.tooLittle) {
561
- logPipeline("verify_checks_on_base", {
562
- skipped: "too little of the run's time left",
563
- leftMs: share.leftMs,
564
- shareMs: share.shareMs,
565
- });
566
- return;
567
- }
568
- try {
569
- const measured = await (deps.measureChecksOnBase ?? makeMeasureChecksOnBase(heard))(cwd, file, deps.signal, share.shareMs);
570
- logPipeline("verify_checks_on_base", {
571
- ran: measured.ran,
572
- passed: [...measured.passed],
573
- failed: [...measured.failed.keys()],
574
- notes: measured.notes,
575
- treeUndone: measured.treeUndone,
576
- });
577
- if (measured.passed.size > 0 || measured.failed.size > 0)
578
- checksOnBase = measured;
579
- }
580
- catch (err) {
581
- const message = err instanceof Error ? err.message : String(err);
582
- logPipeline("verify_checks_on_base", { error: message });
583
- writeErr(`The approved checks could not run on the unchanged code (each keeps its checks.json standing): ${message}`);
584
- }
585
- };
586
- if (measureFile?.checks.some((check) => check.measureOnBase) &&
587
- !resumed &&
588
- !deps.signal?.aborted) {
589
- await measureChecks(measureFile);
561
+ // The commit a resumed session measured on; a fresh build's is its start.
562
+ let measuredOn;
563
+ if (!resumed && !deps.signal?.aborted) {
564
+ // The packages the approved plan names, and the approved checks the host
565
+ // has not seen pass on the unchanged code (`measureOnBase`: the plan's,
566
+ // and CI's when the repository's record has none of its own).
567
+ const targets = missing(targetsOf(cwd, {
568
+ dirs: mission?.plan ? baselineDirs(cwd, planPaths(mission.plan), readFile) : [],
569
+ checks: measureFile,
570
+ }, gateOpts ?? {}));
571
+ if (targets.length > 0)
572
+ await measure(targets);
590
573
  }
591
574
  // A one-attempt lane continuation or fix round starts past the attempt's
592
- // earlier commits, so it measures both on the attempt's base commit
575
+ // earlier commits, so it measures on the attempt's base commit
593
576
  // (`continue.json` names it, YAG-1042), with the tree put back after (YAG-1056).
594
577
  // Before, its host had nothing to hand it for the gate's own checks and only
595
578
  // a stale record for a plan check, so every failure main already had was its
596
579
  // to fix, and COREX-1291's sessions ended `round_cap` on main's lint errors
597
580
  // one after another (production, 2026-10-26). It measures every approved
598
581
  // check it can run there, not only those marked: the record's green is what
599
- // went stale. Its baseline covers the plan's packages and those the attempt
600
- // changed. A base it cannot measure on leaves the run as it was; a tree it
601
- // cannot put back stops the session before it builds.
582
+ // went stale. It covers the plan's packages and those the attempt changed.
583
+ // What an earlier session measured on the same base is not run again, and
584
+ // with nothing missing the base is never checked out. A base it cannot
585
+ // measure on leaves the run as it was; a tree it cannot put back stops the
586
+ // session before it builds.
602
587
  if (cloud && resumed && !deps.signal?.aborted) {
603
588
  const base = baseCommitOf(continuation?.baseSha) ?? baseCommitOf(fixRound?.baseRef);
604
589
  const share = preBuildShare();
@@ -614,24 +599,19 @@ export async function runHeadlessGo(argv, deps = {}) {
614
599
  });
615
600
  }
616
601
  else {
617
- const onBaseFile = measureFile && {
618
- ...measureFile,
619
- checks: measureFile.checks.map((check) => check.status === "cannot_run" ? check : { ...check, measureOnBase: true }),
620
- };
621
- const checksToMeasure = Boolean(onBaseFile?.checks.some((check) => check.status !== "cannot_run"));
622
602
  try {
623
- // A list git cannot read narrows the baseline to the plan's packages: said.
603
+ // A list git cannot read narrows the measurement to the plan's packages: said.
624
604
  const changed = await (deps.pathsCommittedSince ?? pathsCommittedSince)(cwd, base);
625
605
  const unread = changed === null ? { changedPathsUnread: true } : {};
626
606
  const dirs = baselineDirs(cwd, [...planPaths(mission?.plan ?? ""), ...(changed ?? [])], readFile);
627
- const outcome = dirs.length === 0 && !checksToMeasure
628
- ? { kind: "skipped", reason: "nothing to measure" }
629
- : await (deps.measureOnBaseTree ?? measureOnBaseTree)(cwd, base, async () => {
630
- if (dirs.length > 0)
631
- await takeBaseline(dirs);
632
- if (onBaseFile && checksToMeasure)
633
- await measureChecks(onBaseFile);
634
- });
607
+ const targets = missing(targetsOf(cwd, { dirs, checks: measureFile, allChecks: true }, gateOpts ?? {}));
608
+ measuredOn = base;
609
+ const outcome = targets.length === 0
610
+ ? {
611
+ kind: "skipped",
612
+ reason: carried > 0 ? "measured by an earlier session" : "nothing to measure",
613
+ }
614
+ : await (deps.measureOnBaseTree ?? measureOnBaseTree)(cwd, base, () => measure(targets));
635
615
  logPipeline("verify_base_tree", outcome.kind === "measured"
636
616
  ? { base, measured: true, ...unread }
637
617
  : { base, skipped: outcome.reason, ...unread });
@@ -682,11 +662,22 @@ export async function runHeadlessGo(argv, deps = {}) {
682
662
  const scan = cloud
683
663
  ? (scanCwd, signal, skip) => scanChange(scanCwd, { exec, sessionSecrets, ...onBase }, skip, signal)
684
664
  : undefined;
685
- if (gateOpts || baseline || cloud) {
665
+ // What this session measured, for the host to keep: on the base a resumed
666
+ // session checked out, or the commit a fresh build starts on.
667
+ if (cloud && measuredHere.size > 0) {
668
+ writeBaseMeasurements({
669
+ outDir: cloud.outDir,
670
+ sha: measuredOn ?? baseSha,
671
+ measured: measuredHere,
672
+ writeFile: deps.writeFile ?? writeFileSync,
673
+ log: logPipeline,
674
+ });
675
+ }
676
+ const onBaseOpts = measuredBase.size > 0 ? { onBase: measuredBase } : {};
677
+ if (gateOpts || measuredBase.size > 0 || cloud) {
686
678
  const made = (deps.makeRunVerify ?? makeRunVerify)({
687
679
  ...gateOpts,
688
- ...(baseline ? { baseline } : {}),
689
- ...(checksOnBase ? { checksOnBase } : {}),
680
+ ...onBaseOpts,
690
681
  ...(scan ? { scanChange: scan } : {}),
691
682
  // The one-attempt lane's host runs no checks after the build.
692
683
  ...(cloud
@@ -725,7 +716,9 @@ export async function runHeadlessGo(argv, deps = {}) {
725
716
  ...(reviewLane ? { lane: reviewLane } : {}),
726
717
  ...(mission ? { mission } : {}),
727
718
  ...(runVerify ? { runVerify } : {}),
728
- ...(baseline ? { runWorkstreamCheck: makeWorkstreamCheck({ baseline }) } : {}),
719
+ ...(measuredBase.size > 0
720
+ ? { runWorkstreamCheck: makeWorkstreamCheck({ onBase: measuredBase }) }
721
+ : {}),
729
722
  // A cloud execution never starts on an earlier attempt's uncommitted
730
723
  // edits: that work is on the branch, so its tree baselines as it is.
731
724
  ...(continuation && !cloud ? { continuation: { stageAtStop: continuation.stageAtStop } } : {}),
@@ -859,4 +852,36 @@ export async function runHeadlessGo(argv, deps = {}) {
859
852
  function treeKey(snapshot) {
860
853
  return `${snapshot.head}\n${snapshot.status}`;
861
854
  }
855
+ /** What the attempt's earlier sessions measured on its base, as `checks.json` carries it, by check id. */
856
+ function carriedMeasurements(file) {
857
+ return new Map((file?.baseMeasured?.checks ?? []).map((check) => [
858
+ check.id,
859
+ { outcome: check.outcome, keys: check.keys },
860
+ ]));
861
+ }
862
+ /**
863
+ * `base-measurements.json` beside `result.json`, for the host to keep. With
864
+ * no commit to name, or a write that fails, it is logged and dropped: the
865
+ * next session measures again.
866
+ */
867
+ function writeBaseMeasurements(input) {
868
+ const path = join(input.outDir, BASE_MEASUREMENTS_FILE_NAME);
869
+ if (!input.sha) {
870
+ input.log("base_measurements_write_failed", {
871
+ path,
872
+ error: "no commit to name what was measured on",
873
+ measured: input.measured.size,
874
+ });
875
+ return;
876
+ }
877
+ try {
878
+ input.writeFile(path, renderBaseMeasurements(input.sha, input.measured));
879
+ }
880
+ catch (err) {
881
+ input.log("base_measurements_write_failed", {
882
+ path,
883
+ error: err instanceof Error ? err.message : String(err),
884
+ });
885
+ }
886
+ }
862
887
  //# sourceMappingURL=headlessGo.js.map
@@ -15,6 +15,7 @@
15
15
  * parser. A change there is a change here. A file that is missing or does not
16
16
  * read as version 1 means no repository checks, never a stopped build.
17
17
  */
18
+ import { type BaseMeasurement } from "./checkAttribution.js";
18
19
  export declare const MISSION_CHECKS_FILE_NAME = "checks.json";
19
20
  export type MissionCheckStatus = "runnable" | "fails_on_base" | "cannot_run";
20
21
  export interface MissionCheck {
@@ -25,7 +26,7 @@ export interface MissionCheck {
25
26
  * The host has not seen it pass on the unchanged code (an approved plan's
26
27
  * check, or one CI runs that has no baseline): the session runs it there
27
28
  * once before the build, and what that
28
- * measures stands over `status` (`makeMeasureChecksOnBase` in verify.ts).
29
+ * measures stands over `status` (`makeMeasureOnBase` in verify.ts).
29
30
  */
30
31
  measureOnBase?: boolean;
31
32
  }
@@ -61,6 +62,15 @@ export interface MissionChecksFile {
61
62
  name: string;
62
63
  reason: string;
63
64
  }>;
65
+ /**
66
+ * What the attempt's earlier sessions measured on its base commit, by check
67
+ * id (`checkId`): the session measures only the rest. A field that does
68
+ * not read is left out, never the file.
69
+ */
70
+ baseMeasured?: {
71
+ baseSha: string;
72
+ checks: BaseMeasurement[];
73
+ };
64
74
  }
65
75
  export declare const MISSION_CI_RESULTS_FILE_NAME = "ci-results.json";
66
76
  /** How one failed CI check's job went the last time the gate ran it. */
@@ -16,6 +16,7 @@
16
16
  * read as version 1 means no repository checks, never a stopped build.
17
17
  */
18
18
  import { readFileSync } from "node:fs";
19
+ import { boundedMeasurement } from "./checkAttribution.js";
19
20
  export const MISSION_CHECKS_FILE_NAME = "checks.json";
20
21
  export const MISSION_CI_RESULTS_FILE_NAME = "ci-results.json";
21
22
  /** `ci-results.json` (version 1), as the host reads it. */
@@ -98,6 +99,20 @@ function parseJob(value) {
98
99
  }
99
100
  return job;
100
101
  }
102
+ function parseBaseMeasured(value) {
103
+ if (!isRecord(value) || !isString(value.baseSha) || !Array.isArray(value.checks))
104
+ return undefined;
105
+ const checks = [];
106
+ for (const raw of value.checks) {
107
+ if (!isRecord(raw) || !isString(raw.id) || !raw.id)
108
+ continue;
109
+ if (raw.outcome !== "passed" && raw.outcome !== "failed")
110
+ continue;
111
+ const keys = Array.isArray(raw.keys) ? raw.keys.filter(isString) : [];
112
+ checks.push(boundedMeasurement({ id: raw.id, outcome: raw.outcome, keys }));
113
+ }
114
+ return { baseSha: value.baseSha, checks };
115
+ }
101
116
  function parseCannotRunChecks(value) {
102
117
  if (!Array.isArray(value))
103
118
  return null;
@@ -145,12 +160,19 @@ export function parseMissionChecksFile(text) {
145
160
  return null;
146
161
  ciJobs.push(job);
147
162
  }
163
+ const baseMeasured = parseBaseMeasured(parsed.baseMeasured);
164
+ const file = {
165
+ version: 1,
166
+ checks,
167
+ ciJobs,
168
+ ...(baseMeasured ? { baseMeasured } : {}),
169
+ };
148
170
  if (parsed.failedChecksCannotRun === undefined)
149
- return { version: 1, checks, ciJobs };
171
+ return file;
150
172
  const failedChecksCannotRun = parseCannotRunChecks(parsed.failedChecksCannotRun);
151
173
  if (!failedChecksCannotRun)
152
174
  return null;
153
- return { version: 1, checks, ciJobs, failedChecksCannotRun };
175
+ return { ...file, failedChecksCannotRun };
154
176
  }
155
177
  /**
156
178
  * Read the file. `missing` (no such file: a host that predates it, or a run