@brainervirus/workit-core 8.34.0 → 8.35.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.34.0",
3
+ "version": "8.35.2",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -103,7 +103,11 @@ const releaseTrackSchema = z
103
103
  ["requiredChecks", track.requiredChecks ?? []],
104
104
  ] as const) {
105
105
  if (new Set(values).size !== values.length) {
106
- context.addIssue({ code: "custom", path: [field], message: "entries must be unique" });
106
+ context.addIssue({
107
+ code: "custom",
108
+ path: [field],
109
+ message: "entries must be unique",
110
+ });
107
111
  }
108
112
  }
109
113
  if (
@@ -192,7 +196,10 @@ const workspaceConfigSchema = z
192
196
  .passthrough()
193
197
  .optional(),
194
198
  youtrack: z
195
- .object({ baseUrl: nonBlank.optional(), link_issues: z.boolean().optional() })
199
+ .object({
200
+ baseUrl: nonBlank.optional(),
201
+ link_issues: z.boolean().optional(),
202
+ })
196
203
  .passthrough()
197
204
  .optional(),
198
205
  /**
@@ -302,9 +309,16 @@ export const workspacesRevision = (contents: string | null): string =>
302
309
 
303
310
  export const validateWorkspacesDocument = (parsed: unknown, file: string): WorkspacesResult => {
304
311
  if (!isConfigObject(parsed)) {
305
- return { status: "malformed", path: file, entries: [], error: `${file} is not a JSON object` };
312
+ return {
313
+ status: "malformed",
314
+ path: file,
315
+ entries: [],
316
+ error: `${file} is not a JSON object`,
317
+ };
306
318
  }
307
- const validated = workspacesFileSchema.safeParse(parsed, { reportInput: true });
319
+ const validated = workspacesFileSchema.safeParse(parsed, {
320
+ reportInput: true,
321
+ });
308
322
  if (!validated.success) {
309
323
  const detail = validated.error.issues
310
324
  .map((issue) => {
@@ -353,7 +367,12 @@ export const readWorkspacesResult = (dir: string = configDir()): WorkspacesResul
353
367
  raw = readFileSync(file, "utf8");
354
368
  } catch (error) {
355
369
  if ((error as NodeJS.ErrnoException).code === "ENOENT")
356
- return { status: "missing", path: file, entries: [], revision: workspacesRevision(null) };
370
+ return {
371
+ status: "missing",
372
+ path: file,
373
+ entries: [],
374
+ revision: workspacesRevision(null),
375
+ };
357
376
  return {
358
377
  status: "malformed",
359
378
  path: file,
@@ -373,7 +392,10 @@ export const readWorkspacesResult = (dir: string = configDir()): WorkspacesResul
373
392
  error: `${file} is not valid JSON`,
374
393
  };
375
394
  }
376
- return { ...validateWorkspacesDocument(parsed, file), revision: workspacesRevision(raw) };
395
+ return {
396
+ ...validateWorkspacesDocument(parsed, file),
397
+ revision: workspacesRevision(raw),
398
+ };
377
399
  };
378
400
 
379
401
  /** Parse the workspaces.json list under an explicit config dir; [] when missing/malformed. */
@@ -615,7 +637,7 @@ export const workspaceOverlaps = (
615
637
  for (const b of entries.slice(i + 1)) {
616
638
  const aInB = matchWorkspace(b.glob, globRoot(a.glob));
617
639
  const bInA = matchWorkspace(a.glob, globRoot(b.glob));
618
- if (!aInB && !bInA) continue;
640
+ if (!aInB && !bInA && !globsMayOverlap(a.glob, b.glob)) continue;
619
641
  const [inner, outer] =
620
642
  aInB && !bInA
621
643
  ? [a, b]
@@ -818,7 +840,10 @@ export const resolveRuntimeReleaseTracks = (
818
840
  ): { workspace: string; releaseTracks: unknown } | null => {
819
841
  const matched = matchRuntimeWorkspace(cwd);
820
842
  return matched
821
- ? { workspace: matched.selected.name, releaseTracks: matched.selected.releaseTracks }
843
+ ? {
844
+ workspace: matched.selected.name,
845
+ releaseTracks: matched.selected.releaseTracks,
846
+ }
822
847
  : null;
823
848
  };
824
849
 
@@ -846,7 +871,10 @@ const inheritedSetting = (
846
871
  const enclosing = matches
847
872
  .filter((entry) => entry !== selected)
848
873
  .toSorted((a, b) => globSpecificity(b.glob) - globSpecificity(a.glob));
849
- return { workspace: selected.name, source: [selected, ...enclosing].find(has) ?? null };
874
+ return {
875
+ workspace: selected.name,
876
+ source: [selected, ...enclosing].find(has) ?? null,
877
+ };
850
878
  };
851
879
 
852
880
  /** Whether `pr merge` deletes the head branch by default: true unless a covering entry sets `deleteBranch: false`. */
@@ -906,8 +934,18 @@ export const workspaceDelegation = (cwd: string, dir?: string): DelegationMode =
906
934
  export const modelSources = (
907
935
  cwd: string,
908
936
  dir?: string,
909
- ): Array<{ name: string; global: boolean; models: unknown; tierModels: unknown }> => {
910
- const out: Array<{ name: string; global: boolean; models: unknown; tierModels: unknown }> = [];
937
+ ): Array<{
938
+ name: string;
939
+ global: boolean;
940
+ models: unknown;
941
+ tierModels: unknown;
942
+ }> => {
943
+ const out: Array<{
944
+ name: string;
945
+ global: boolean;
946
+ models: unknown;
947
+ tierModels: unknown;
948
+ }> = [];
911
949
  try {
912
950
  const matched = matchRuntimeWorkspace(cwd, dir);
913
951
  if (matched) {
@@ -1099,7 +1137,10 @@ export const resolveWorkspacePolicy = (
1099
1137
  status: "resolved" as const,
1100
1138
  profileName: null,
1101
1139
  workspace: null,
1102
- provenance: { branchPolicy: "user-default", commitPolicy: "user-default" },
1140
+ provenance: {
1141
+ branchPolicy: "user-default",
1142
+ commitPolicy: "user-default",
1143
+ },
1103
1144
  };
1104
1145
  if (selected.status === "invalid") return selected;
1105
1146
  return {
@@ -1130,7 +1171,11 @@ export const selectReleaseTrack = (
1130
1171
  error: `workspace ${JSON.stringify(workspace.name)} has no release track ${JSON.stringify(requestedTrack)}`,
1131
1172
  };
1132
1173
  }
1133
- return { status: "selected", name: requestedTrack, track: tracks[requestedTrack] };
1174
+ return {
1175
+ status: "selected",
1176
+ name: requestedTrack,
1177
+ track: tracks[requestedTrack],
1178
+ };
1134
1179
  };
1135
1180
 
1136
1181
  /**
@@ -29,6 +29,8 @@ export const PR_STATUS_QUERY = `query($owner: String!, $name: String!, $number:
29
29
 
30
30
  export const FIND_PR_QUERY = `query($owner: String!, $name: String!, $head: String!) { repository(owner: $owner, name: $name) { pullRequests(headRefName: $head, first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { number url state baseRefName headRefName headRefOid headRepositoryOwner { login } } } } }`;
31
31
 
32
+ export const BRANCH_CHECKS_QUERY = `query($owner: String!, $name: String!, $ref: String!, $contexts: String) { repository(owner: $owner, name: $name) { object(expression: $ref) { ... on Commit { oid statusCheckRollup { contexts(first: 100, after: $contexts) { pageInfo { hasNextPage endCursor } nodes { __typename ... on CheckRun { name status conclusion detailsUrl databaseId checkSuite { workflowRun { databaseId workflow { name } } } } ... on StatusContext { context state targetUrl } } } } } } } }`;
33
+
32
34
  const READY_MUTATION = `mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
33
35
  const DRAFT_MUTATION = `mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
34
36
  const REPLY_MUTATION = `mutation($thread: ID!, $body: String!) { addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $thread, body: $body }) { comment { url } } }`;
@@ -113,6 +115,12 @@ type GqlPr = {
113
115
  };
114
116
  };
115
117
 
118
+ type BranchChecksData = {
119
+ repository: {
120
+ object: { oid?: string; statusCheckRollup: { contexts: Page<GqlContext> } | null } | null;
121
+ } | null;
122
+ };
123
+
116
124
  type GqlResponse<T> = { data?: T; errors?: Array<{ message?: string; type?: string }> };
117
125
 
118
126
  const prState = (value: string): PrState =>
@@ -451,6 +459,36 @@ export function createGitHubForge(options: {
451
459
  return success(status);
452
460
  },
453
461
 
462
+ branchChecks(branch) {
463
+ const contexts: GqlContext[] = [];
464
+ let sha: string | null = null;
465
+ let cursor: string | null = null;
466
+ for (let page = 1; page <= maxPages; page += 1) {
467
+ const data: ForgeResult<BranchChecksData> = graphql<BranchChecksData>(
468
+ BRANCH_CHECKS_QUERY,
469
+ { owner, name, ref: `refs/heads/${branch}`, contexts: cursor },
470
+ `checks on ${branch}`,
471
+ );
472
+ if (!data.ok) return data;
473
+ const commit: NonNullable<BranchChecksData["repository"]>["object"] =
474
+ data.data.repository?.object ?? null;
475
+ if (!commit) return failure("not_found", `branch ${branch} was not found`);
476
+ sha = commit.oid ?? sha;
477
+ const rollup: { contexts: Page<GqlContext> } | null = commit.statusCheckRollup;
478
+ if (!rollup) break;
479
+ contexts.push(...rollup.contexts.nodes);
480
+ if (!rollup.contexts.pageInfo.hasNextPage) break;
481
+ cursor = rollup.contexts.pageInfo.endCursor;
482
+ }
483
+ const required = requiredContexts(branch);
484
+ const checks = latestPerName(
485
+ contexts.map(checkFromContext).filter((check): check is ForgeCheck => check !== null),
486
+ ).map((check) =>
487
+ required ? { ...check, required: required.includes(check.context) } : check,
488
+ );
489
+ return success({ sha, checks, requiredKnown: required !== null });
490
+ },
491
+
454
492
  jobLogTail(job, lines) {
455
493
  const text = apiText(
456
494
  runner,
@@ -465,6 +465,54 @@ export function createGitLabForge(options: {
465
465
  return success(status);
466
466
  },
467
467
 
468
+ branchChecks(branch) {
469
+ const tip = baseTip(branch);
470
+ if (!tip) return failure("not_found", `branch ${branch} was not found`);
471
+ const latest = apiJson<
472
+ Array<{ id: number; project_id?: number; status: string; web_url?: string }>
473
+ >(
474
+ runner,
475
+ "glab",
476
+ apiHost,
477
+ api(
478
+ `${project}/pipelines?ref=${encodeURIComponent(branch)}&sha=${tip}&order_by=id&sort=desc&per_page=1`,
479
+ ),
480
+ `pipelines on ${branch}`,
481
+ );
482
+ if (!latest.ok) return latest;
483
+ const pipeline = Array.isArray(latest.data) ? latest.data[0] : undefined;
484
+ if (!pipeline) return success({ sha: tip, checks: [], requiredKnown: true });
485
+ const scope = String(pipeline.project_id ?? encodeURIComponent(repo));
486
+ const jobs = pages(
487
+ `projects/${scope}/pipelines/${pipeline.id}/jobs`,
488
+ `pipeline ${pipeline.id} jobs`,
489
+ );
490
+ if (!jobs.ok) return jobs;
491
+ const bridges = pages(
492
+ `projects/${scope}/pipelines/${pipeline.id}/bridges`,
493
+ `pipeline ${pipeline.id} bridges`,
494
+ );
495
+ if (!bridges.ok) return bridges;
496
+ const checks = [
497
+ ...(jobs.data.items as GlJob[]).map((job) => checkFromJob(job, pipeline.id, scope)),
498
+ ...(bridges.data.items as GlBridge[]).map((bridge) =>
499
+ checkFromBridge(bridge, pipeline.id, scope),
500
+ ),
501
+ ];
502
+ if (
503
+ stateOf(pipeline.status, false) === "failing" &&
504
+ !checks.some((check) => check.state === "failing")
505
+ )
506
+ checks.push(
507
+ pipelineCheck("failing", pipeline.status, {
508
+ id: pipeline.id,
509
+ project_id: pipeline.project_id ?? 0,
510
+ web_url: pipeline.web_url,
511
+ }),
512
+ );
513
+ return success({ sha: tip, checks, requiredKnown: true });
514
+ },
515
+
468
516
  jobLogTail(job, lines) {
469
517
  const scope = job.scope ?? encodeURIComponent(repo);
470
518
  const text = apiText(
@@ -46,7 +46,14 @@ import type { LaneNeed, ReviewLane } from "../core/policy/lane";
46
46
  import { laneLabel, prLaneGate } from "../core/policy/pr-lane";
47
47
  import { buildStatusDoc, selectPr, type PrStatusDoc } from "./report";
48
48
  import type { ResolvedForge } from "./resolve";
49
- import { failure, success, type ForgeResult, type MergeMethod, type PrRef } from "./types";
49
+ import {
50
+ failure,
51
+ success,
52
+ type ForgeCheck,
53
+ type ForgeResult,
54
+ type MergeMethod,
55
+ type PrRef,
56
+ } from "./types";
50
57
 
51
58
  export type Sleep = (ms: number) => Promise<void>;
52
59
 
@@ -358,6 +365,8 @@ export type MergeInput = {
358
365
  expect?: { base: string; branch: string };
359
366
  /** Merge without a verdict (`merge: true` only); the reason is recorded. */
360
367
  unverified?: { reason: string };
368
+ /** Merge onto a base whose latest CI is red (`--base-red-ok`); the reason is recorded. */
369
+ baseRedOk?: { reason: string };
361
370
  /** Squash commit subject and body (`--subject`, `--body`); unset parts use the defaults. */
362
371
  subject?: string;
363
372
  body?: string;
@@ -393,6 +402,8 @@ export type MergeOutcome = {
393
402
  };
394
403
  /** Set when `--unverified` bypassed the verdict: the reason and its ledger row. */
395
404
  unverified: { reason: string; recorded: string } | null;
405
+ /** The base branch's CI at its head: a one-line note when it was not green, and the override used. */
406
+ baseCi: { note: string | null; override: { reason: string } | null };
396
407
  grant: { source: AutonomySource };
397
408
  /** `skipped`: the implicit default found a reason to keep the branch; the merge is unaffected. */
398
409
  deletedBranch: boolean | { error: string } | { skipped: string };
@@ -454,6 +465,7 @@ export type MergeRefusal = {
454
465
  | "closed"
455
466
  | "protected_branch"
456
467
  | "base_mismatch"
468
+ | "base_red"
457
469
  | "ledger_tampered"
458
470
  | "intent_unrecorded";
459
471
  next?: string;
@@ -632,6 +644,41 @@ function implicitDeleteSkip(
632
644
  return null;
633
645
  }
634
646
 
647
+ type BaseCi =
648
+ | { ok: true; note: string | null }
649
+ | { ok: false; failing: ForgeCheck[]; sha: string | null; requiredKnown: boolean };
650
+
651
+ /**
652
+ * The base branch head's CI before a merge lands on it: red (a failing check)
653
+ * refuses, pending or unreadable proceeds with a note, green or no checks
654
+ * proceeds quietly. Only a protected target is guarded: a stack's lower
655
+ * branch is its own PR's business.
656
+ */
657
+ function baseCiState(cwd: string, resolved: ResolvedForge, base: string): BaseCi {
658
+ if (!isProtectedTarget(cwd, base)) return { ok: true, note: null };
659
+ const read = resolved.forge.branchChecks(base);
660
+ if (!read.ok)
661
+ return { ok: true, note: `base ${base} CI not read (${read.error}); merging anyway` };
662
+ // Only checks branch protection requires gate; with the required set
663
+ // unreadable, count hard failures not known to be optional.
664
+ const { requiredKnown } = read.data;
665
+ const failing = read.data.checks.filter(
666
+ (check) =>
667
+ check.state === "failing" &&
668
+ (requiredKnown
669
+ ? check.required === true
670
+ : check.required !== false && check.conclusion === "failure"),
671
+ );
672
+ if (failing.length) return { ok: false, failing, sha: read.data.sha, requiredKnown };
673
+ const pending = read.data.checks.filter((check) => check.state === "pending");
674
+ return {
675
+ ok: true,
676
+ note: pending.length
677
+ ? `base ${base} CI is still running (${pending.map((check) => check.name).join(", ")}); merging without waiting`
678
+ : null,
679
+ };
680
+ }
681
+
635
682
  export async function mergePullRequest(
636
683
  cwd: string,
637
684
  resolved: ResolvedForge,
@@ -650,6 +697,13 @@ export async function mergePullRequest(
650
697
  { reason: "unverified_refused" },
651
698
  );
652
699
 
700
+ const baseRed = input.baseRedOk ?? null;
701
+ if (baseRed && !baseRed.reason.trim())
702
+ return failure(
703
+ "invalid_input",
704
+ "--base-red-ok needs --reason <why merging onto a red base is fine>",
705
+ );
706
+
653
707
  const number = selectPr(cwd, resolved, { pr: input.pr });
654
708
  if (!number.ok) return number;
655
709
  const status = resolved.forge.prStatus(number.data);
@@ -789,6 +843,20 @@ export async function mergePullRequest(
789
843
  );
790
844
  }
791
845
 
846
+ const baseCi = baseCiState(cwd, resolved, doc.base);
847
+ let baseRedOverride: { reason: string } | null = null;
848
+ if (!baseCi.ok) {
849
+ const names = baseCi.failing.map((failed) => failed.name).join(", ");
850
+ const url = baseCi.failing.find((failed) => failed.url)?.url ?? null;
851
+ if (!baseRed)
852
+ return refuse(
853
+ `base_red: ${doc.base} at ${baseCi.sha?.slice(0, 12) ?? "its head"} has failing CI (${names}${baseCi.requiredKnown ? "" : "; the required checks could not be read, so every failed check counts"})${url ? `: ${url}` : ""}; merging ${label} onto it would land on a red base`,
854
+ `fix ${doc.base} first, or, if the user accepts it: workit pr merge${input.pr ? ` --pr ${doc.number}` : ""} --base-red-ok --reason "<why>"`,
855
+ { reason: "base_red", blockers: baseCi.failing.map((failed) => failed.name) },
856
+ );
857
+ baseRedOverride = baseRed;
858
+ }
859
+
792
860
  const squashMessage = squashMessageFor(cwd, resolved, input, doc);
793
861
  if (squashMessage && !squashMessage.ok) return squashMessage;
794
862
  // The intent goes in before the forge write: a merge call that errors after
@@ -806,6 +874,7 @@ export async function mergePullRequest(
806
874
  review: verdict.review,
807
875
  lane: verdict.lane,
808
876
  ...(unverified ? { unverified: unverified.recorded } : {}),
877
+ ...(baseRedOverride ? { baseRedOk: baseRedOverride.reason } : {}),
809
878
  grant: grant.source,
810
879
  });
811
880
  if (!intent.ok)
@@ -841,6 +910,7 @@ export async function mergePullRequest(
841
910
  review: verdict.review,
842
911
  lane: verdict.lane,
843
912
  ...(unverified ? { unverified: unverified.recorded } : {}),
913
+ ...(baseRedOverride ? { baseRedOk: baseRedOverride.reason } : {}),
844
914
  grant: grant.source,
845
915
  intent: intentId,
846
916
  verified: false,
@@ -909,6 +979,14 @@ export async function mergePullRequest(
909
979
  mergeSha: merged.data.mergeSha,
910
980
  verdict,
911
981
  unverified,
982
+ baseCi: {
983
+ note: baseRedOverride
984
+ ? `base ${doc.base} CI is red; merged anyway (--base-red-ok: ${baseRedOverride.reason})`
985
+ : baseCi.ok
986
+ ? baseCi.note
987
+ : null,
988
+ override: baseRedOverride,
989
+ },
912
990
  grant: { source: grant.source },
913
991
  deletedBranch,
914
992
  recorded,
@@ -110,6 +110,16 @@ export type ForgePrStatus = {
110
110
  truncated: boolean;
111
111
  };
112
112
 
113
+ /** The checks on the latest commit of a branch (the merge guard reads the base's). */
114
+ export type RefChecks = {
115
+ /** The commit the checks belong to; null when the forge did not say. */
116
+ sha: string | null;
117
+ /** `required` is set from branch protection when `requiredKnown`; else it is null on GitHub. */
118
+ checks: ForgeCheck[];
119
+ /** True when the branch's required checks could be read (GitLab gates on the whole pipeline). */
120
+ requiredKnown: boolean;
121
+ };
122
+
113
123
  export type JobRef = { jobId: number; scope: string | null };
114
124
 
115
125
  export type RerunTarget =
@@ -196,6 +206,8 @@ export interface Forge {
196
206
  repoInfo(repo: string): ForgeResult<RepoInfo>;
197
207
  findPr(head: string, match: PrMatch): ForgeResult<PrRef | null>;
198
208
  prStatus(n: number): ForgeResult<ForgePrStatus>;
209
+ /** The checks on the head commit of branch `branch`; empty when CI has not reported for it. */
210
+ branchChecks(branch: string): ForgeResult<RefChecks>;
199
211
  jobLogTail(job: JobRef, lines: number): ForgeResult<string[]>;
200
212
  rerun(target: RerunTarget): ForgeResult<void>;
201
213
  /** Open a PR/MR; `headSha` is the head the forge reports right after creation. */
@@ -223,7 +223,10 @@ function entryCompactContext(
223
223
  return hit.value;
224
224
  const task = entry ? store.readTask(entry.id) : null;
225
225
  if (task && !task.ok) return null;
226
- const freshness: Freshness = { mode: "signal", current: () => recentSignal(store.root) };
226
+ const freshness: Freshness = {
227
+ mode: "signal",
228
+ current: () => recentSignal(store.root),
229
+ };
227
230
  const observed = task?.data.evidence.some(isObservedCheck) ?? false;
228
231
  const files = (task?.data.decisions ?? []).flatMap((decision) =>
229
232
  decision.data.binding.contentRefs.flatMap((ref) =>
@@ -298,7 +301,6 @@ export const sessionContextText = (
298
301
  return `<workit-contract>\n${invariantBootstrap()}${compact}${offer ? `\n${offer}` : ""}${options.addendum ? `\n${options.addendum}` : ""}\n</workit-contract>`;
299
302
  };
300
303
 
301
- /** Per-turn task context only (no bootstrap or offer), or null when none applies. */
302
304
  /** The checkout holding `cwd` and its branch, read from the git dir (no spawn). */
303
305
  const checkoutAt = (cwd: string): { root: string; branch: string | null } | null => {
304
306
  for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
@@ -311,7 +313,10 @@ const checkoutAt = (cwd: string): { root: string; branch: string | null } | null
311
313
  )
312
314
  : dotGit;
313
315
  const head = fs.readFileSync(path.join(admin, "HEAD"), "utf8").trim();
314
- return { root: dir, branch: /^ref: refs\/heads\/(.+)$/u.exec(head)?.[1] ?? null };
316
+ return {
317
+ root: dir,
318
+ branch: /^ref: refs\/heads\/(.+)$/u.exec(head)?.[1] ?? null,
319
+ };
315
320
  }
316
321
  if (path.dirname(dir) === dir) return null;
317
322
  }
@@ -333,6 +338,7 @@ export const whereLine = (cwd: string): string | null => {
333
338
  }
334
339
  };
335
340
 
341
+ /** Per-turn task context only (no bootstrap or offer), or null when none applies. */
336
342
  export const turnContextText = (input: HookInput, descriptor: HostDescriptor): string | null => {
337
343
  try {
338
344
  const text = sessionCompactContext(
@@ -422,7 +428,9 @@ export const changedTurnContext = (input: HookInput, text: string | null): strin
422
428
  if (!file) return text;
423
429
  const digest = digestOf(text);
424
430
  try {
425
- const previous = JSON.parse(fs.readFileSync(file, "utf8")) as { digest?: unknown };
431
+ const previous = JSON.parse(fs.readFileSync(file, "utf8")) as {
432
+ digest?: unknown;
433
+ };
426
434
  if (previous.digest === digest) return null;
427
435
  } catch {
428
436
  // Missing or unreadable digest: treat as changed.
package/src/ledger.ts CHANGED
@@ -110,7 +110,11 @@ export const PR_ROW_TYPES: ReadonlySet<string> = new Set([
110
110
  ]);
111
111
 
112
112
  /** Who wrote a row. Unverified: the environment says so (D18). */
113
- export type LedgerActor = { host: string; session: string | null; agentId: string | null };
113
+ export type LedgerActor = {
114
+ host: string;
115
+ session: string | null;
116
+ agentId: string | null;
117
+ };
114
118
 
115
119
  /** The code state a row was recorded against (§2.2). */
116
120
  export type CodeKey = {
@@ -180,7 +184,11 @@ export type VerdictRow = RowCommon & {
180
184
  untracked?: string[];
181
185
  untrackedCount?: number;
182
186
  };
183
- export type HandoffRow = RowCommon & { type: "handoff"; note: string | null; next: string };
187
+ export type HandoffRow = RowCommon & {
188
+ type: "handoff";
189
+ note: string | null;
190
+ next: string;
191
+ };
184
192
  /**
185
193
  * A lead's standing order for every worker of a fanout ("no new dependencies").
186
194
  * `stop` marks the stop line: running workers stop and report.
@@ -801,7 +809,11 @@ export type UnverifiedRow = {
801
809
  reason: UnverifiedReason;
802
810
  };
803
811
  /** The hash chain over the file: rows the CLI wrote, rows from before the chain, and the rest. */
804
- export type LedgerIntegrity = { chained: number; legacy: number; unverified: UnverifiedRow[] };
812
+ export type LedgerIntegrity = {
813
+ chained: number;
814
+ legacy: number;
815
+ unverified: UnverifiedRow[];
816
+ };
805
817
  export type LedgerRead = {
806
818
  path: string;
807
819
  rows: ReadRow[];
@@ -950,7 +962,12 @@ export function readLedger(
950
962
  });
951
963
  return {
952
964
  ok: true,
953
- value: { path: file.value, rows, skipped, integrity: options.integrity ? integrity : null },
965
+ value: {
966
+ path: file.value,
967
+ rows,
968
+ skipped,
969
+ integrity: options.integrity ? integrity : null,
970
+ },
954
971
  };
955
972
  }
956
973
 
@@ -997,7 +1014,12 @@ const tamperedWarning = (cwd: string, breaks: readonly UnverifiedRow[]): string
997
1014
  ", ",
998
1015
  )}${breaks.length > 3 ? ", …" : ""}): no verdict is accepted until it is repaired. ${tamperedUnblock(cwd)}`;
999
1016
 
1000
- export type RowFilter = { branch?: string; pr?: number; type?: string; last?: number };
1017
+ export type RowFilter = {
1018
+ branch?: string;
1019
+ pr?: number;
1020
+ type?: string;
1021
+ last?: number;
1022
+ };
1001
1023
 
1002
1024
  export function filterRows(rows: readonly ReadRow[], filter: RowFilter): ReadRow[] {
1003
1025
  const matched = rows.filter(
@@ -1088,7 +1110,11 @@ export function codeKey(
1088
1110
 
1089
1111
  export type VerdictBasis = "fresh" | "carried" | "stale" | "none";
1090
1112
 
1091
- type Keyed = { head: string | null; patchId: string | null; diffHash: string | null };
1113
+ type Keyed = {
1114
+ head: string | null;
1115
+ patchId: string | null;
1116
+ diffHash: string | null;
1117
+ };
1092
1118
 
1093
1119
  /** fresh: same head · carried: same non-null patch-id AND exact diff · stale: otherwise. */
1094
1120
  export function verdictBasis(verdict: Keyed, current: Keyed): Exclude<VerdictBasis, "none"> {
@@ -1170,7 +1196,11 @@ export type VerdictCheck = {
1170
1196
  * What merge gates read: an accepted strong (`verified`/`tests-verified`)
1171
1197
  * verdict and no current independent failure. `type-check-only` never counts.
1172
1198
  */
1173
- accepted: { accepted: boolean; verdict: ReadRow | null; reasons: RejectReason[] };
1199
+ accepted: {
1200
+ accepted: boolean;
1201
+ verdict: ReadRow | null;
1202
+ reasons: RejectReason[];
1203
+ };
1174
1204
  review: ReviewLabel;
1175
1205
  /** The newest current, strong, self verdict when `review` is self-reviewed. */
1176
1206
  selfVerdict: ReadRow | null;
@@ -1269,7 +1299,10 @@ export function authorSessions(
1269
1299
  cwd: string,
1270
1300
  rows: readonly ReadRow[],
1271
1301
  branch: string,
1272
- range: { base: string | null; head: string | null } = { base: null, head: null },
1302
+ range: { base: string | null; head: string | null } = {
1303
+ base: null,
1304
+ head: null,
1305
+ },
1273
1306
  scope: BranchScope = branchScope(cwd, branch),
1274
1307
  ): Set<string> {
1275
1308
  const sessions = new Set<string>();
@@ -1437,7 +1470,7 @@ function effectiveOf(
1437
1470
  * head to the current head. With `exactIn` (the checkout), each link is
1438
1471
  * re-read with exact blobs (sameOwnChange, #334), so a row recorded before
1439
1472
  * that check carries a pass only when the branch's own files are identical
1440
- * across the restack. Null keeps every observed link: a failure keeps
1473
+ * across the restack or the head is the judged head cleanly rebased (#468). Null keeps every observed link: a failure keeps
1441
1474
  * applying through them (failing closed the other way).
1442
1475
  */
1443
1476
  const restackExact = new WeakMap<ReadRow, boolean>();
@@ -1454,15 +1487,18 @@ function restackCarries(
1454
1487
  const cwd = exactIn;
1455
1488
  let known = restackExact.get(row);
1456
1489
  if (known === undefined) {
1457
- known =
1458
- typeof row.fromBase === "string" &&
1459
- typeof row.toBase === "string" &&
1460
- sameOwnChange(cwd, {
1461
- from: fromHead,
1462
- to: head,
1463
- fromBase: row.fromBase,
1464
- toBase: row.toBase,
1465
- });
1490
+ // Identical own blobs, or the head is exactly the judged head cleanly
1491
+ // rebased onto the moved base (#468): a base edit elsewhere in a shared file.
1492
+ const range =
1493
+ typeof row.fromBase === "string" && typeof row.toBase === "string"
1494
+ ? {
1495
+ from: fromHead,
1496
+ to: head,
1497
+ fromBase: row.fromBase,
1498
+ toBase: row.toBase,
1499
+ }
1500
+ : null;
1501
+ known = range !== null && (sameOwnChange(cwd, range) || cleanRebase(cwd, range));
1466
1502
  restackExact.set(row, known);
1467
1503
  }
1468
1504
  return known;
@@ -1510,7 +1546,10 @@ export function checkVerdicts(
1510
1546
  const cacheKey = `${base}\0${at}`;
1511
1547
  let key = keyCache.get(cacheKey);
1512
1548
  if (!key) {
1513
- key = { patchId: patchId(cwd, base, at), diffHash: diffHash(cwd, base, at) };
1549
+ key = {
1550
+ patchId: patchId(cwd, base, at),
1551
+ diffHash: diffHash(cwd, base, at),
1552
+ };
1514
1553
  keyCache.set(cacheKey, key);
1515
1554
  }
1516
1555
  return key;
@@ -1575,7 +1614,10 @@ export function checkVerdicts(
1575
1614
  const exactCarry = (row: ReadRow): boolean =>
1576
1615
  FAILING.has(String(row.result)) || exactMatch(row) !== null;
1577
1616
  const keyBasis = (row: ReadRow): Exclude<VerdictBasis, "none"> => {
1578
- const basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
1617
+ const basis = verdictBasis(row, {
1618
+ head,
1619
+ ...keyFor(row.base ?? fallbackBase),
1620
+ });
1579
1621
  return basis === "carried" && !exactCarry(row) ? "stale" : basis;
1580
1622
  };
1581
1623
  /** A pass the exact keys carry only through a clean rebase (the base also edited a branch file). */
@@ -1736,7 +1778,11 @@ export function checkVerdicts(
1736
1778
  (entry) => entry.accepted && STRONG_RESULTS.has(String(entry.verdict.result)),
1737
1779
  ) ?? null;
1738
1780
  const accepted = tampered.length
1739
- ? { accepted: false, verdict: null, reasons: ["ledger_tampered"] as RejectReason[] }
1781
+ ? {
1782
+ accepted: false,
1783
+ verdict: null,
1784
+ reasons: ["ledger_tampered"] as RejectReason[],
1785
+ }
1740
1786
  : newestAccepted && !failing
1741
1787
  ? { accepted: true, verdict: newestAccepted.verdict, reasons: [] }
1742
1788
  : {
@@ -1789,7 +1835,10 @@ export function checkVerdicts(
1789
1835
  selfVerdict: accepted.accepted ? null : (selfEntry?.verdict ?? null),
1790
1836
  base: newestCurrent?.verdict.base ?? fallbackBase,
1791
1837
  patchId: keyFor(newestCurrent?.verdict.base ?? fallbackBase).patchId,
1792
- current: { basis: newestCurrent?.basis ?? "none", verdict: newestCurrent?.verdict ?? null },
1838
+ current: {
1839
+ basis: newestCurrent?.basis ?? "none",
1840
+ verdict: newestCurrent?.verdict ?? null,
1841
+ },
1793
1842
  carry: carryEntry?.carry ?? null,
1794
1843
  reverify,
1795
1844
  accepted,
@@ -1868,7 +1917,13 @@ function checkSupersede(
1868
1917
 
1869
1918
  export function recordDecision(
1870
1919
  context: RecordContext,
1871
- input: { what?: string; why?: string; refs?: string[]; by?: string; views?: string[] },
1920
+ input: {
1921
+ what?: string;
1922
+ why?: string;
1923
+ refs?: string[];
1924
+ by?: string;
1925
+ views?: string[];
1926
+ },
1872
1927
  ): LedgerResult<DecisionRow> {
1873
1928
  if (input.by !== undefined && input.by !== "decider")
1874
1929
  return err("invalid_input", `--by takes "decider", got "${input.by}"`);
@@ -2081,7 +2136,11 @@ export function clearStanding(
2081
2136
  /** Lift the stop line: clear each stop order in force and keep the others; lead only. */
2082
2137
  export function liftStop(
2083
2138
  context: RecordContext,
2084
- input: { fanout: string; lead: string | null; leads?: readonly string[] | null },
2139
+ input: {
2140
+ fanout: string;
2141
+ lead: string | null;
2142
+ leads?: readonly string[] | null;
2143
+ },
2085
2144
  ): LedgerResult<StandingClearedRow[]> {
2086
2145
  const lead = leadOnly(context, input.lead);
2087
2146
  if (!lead.ok) return lead;
@@ -2242,10 +2301,16 @@ export function recordVerdict(
2242
2301
  );
2243
2302
  }
2244
2303
 
2245
- function untrackedField(cwd: string): { untracked?: string[]; untrackedCount?: number } {
2304
+ function untrackedField(cwd: string): {
2305
+ untracked?: string[];
2306
+ untrackedCount?: number;
2307
+ } {
2246
2308
  const files = untrackedFiles(cwd);
2247
2309
  return files.length
2248
- ? { untracked: files.slice(0, MAX_UNTRACKED_LISTED), untrackedCount: files.length }
2310
+ ? {
2311
+ untracked: files.slice(0, MAX_UNTRACKED_LISTED),
2312
+ untrackedCount: files.length,
2313
+ }
2249
2314
  : {};
2250
2315
  }
2251
2316
 
@@ -2255,7 +2320,12 @@ export function recordHandoff(
2255
2320
  ): LedgerResult<HandoffRow> {
2256
2321
  return appendRow<HandoffRow>(
2257
2322
  context.cwd,
2258
- { ...common(context, noteKey(context)), type: "handoff", note: input.note, next: input.next },
2323
+ {
2324
+ ...common(context, noteKey(context)),
2325
+ type: "handoff",
2326
+ note: input.note,
2327
+ next: input.next,
2328
+ },
2259
2329
  { now: context.now },
2260
2330
  );
2261
2331
  }
@@ -2404,7 +2474,12 @@ export function stackPosition(root: string, branch: string): StackPosition | nul
2404
2474
  return null;
2405
2475
  }
2406
2476
 
2407
- export type CheckFreshness = { name: string; result: string | null; fresh: boolean; at: string };
2477
+ export type CheckFreshness = {
2478
+ name: string;
2479
+ result: string | null;
2480
+ fresh: boolean;
2481
+ at: string;
2482
+ };
2408
2483
 
2409
2484
  type VerdictSummary = Omit<VerdictEntry, "verdict"> & { summary: string };
2410
2485
 
@@ -2453,7 +2528,11 @@ const dirtyState = (cwd: string): HandoffBrief["dirty"] => {
2453
2528
  // Renames and copies carry the source path as the next entry.
2454
2529
  if (entry[0] === "R" || entry[0] === "C") index += 1;
2455
2530
  }
2456
- return { dirty: paths.length > 0, files: paths.length, paths: paths.slice(0, MAX_DIRTY_PATHS) };
2531
+ return {
2532
+ dirty: paths.length > 0,
2533
+ files: paths.length,
2534
+ paths: paths.slice(0, MAX_DIRTY_PATHS),
2535
+ };
2457
2536
  };
2458
2537
 
2459
2538
  const nextCommandFor = (brief: Omit<HandoffBrief, "nextCommand" | "next">): string => {
@@ -2521,7 +2600,11 @@ export function buildHandoff(
2521
2600
  }));
2522
2601
  const lastHandoffRow = mine.findLast((row) => row.type === "handoff");
2523
2602
  const partial: Omit<HandoffBrief, "nextCommand" | "next"> = {
2524
- store: { root: store.value.root, shared: store.value.shared, ledger: ledger.path },
2603
+ store: {
2604
+ root: store.value.root,
2605
+ shared: store.value.shared,
2606
+ ledger: ledger.path,
2607
+ },
2525
2608
  branch,
2526
2609
  head,
2527
2610
  subject: head ? git(cwd, ["log", "-1", "--format=%s", head]) : null,
@@ -2559,6 +2642,10 @@ export function buildHandoff(
2559
2642
  const nextCommand = nextCommandFor(partial);
2560
2643
  return {
2561
2644
  ok: true,
2562
- value: { ...partial, nextCommand, next: options.next?.trim() || nextCommand },
2645
+ value: {
2646
+ ...partial,
2647
+ nextCommand,
2648
+ next: options.next?.trim() || nextCommand,
2649
+ },
2563
2650
  };
2564
2651
  }
package/src/stack.ts CHANGED
@@ -160,7 +160,12 @@ export const stackDeps: {
160
160
  // ---------------------------------------------------------------------------
161
161
  // git plumbing
162
162
 
163
- type GitRun = { ok: boolean; stdout: string; stderr: string; timedOut: boolean };
163
+ type GitRun = {
164
+ ok: boolean;
165
+ stdout: string;
166
+ stderr: string;
167
+ timedOut: boolean;
168
+ };
164
169
 
165
170
  const gitRun = (cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUTS.local): GitRun => {
166
171
  const run = spawnSync("git", args, {
@@ -245,7 +250,13 @@ const trackedDirt = (dir: string): boolean => {
245
250
 
246
251
  export type RestackOutcome =
247
252
  | { ok: true; head: string }
248
- | { ok: false; conflict: boolean; timedOut?: boolean; worktree: string; error: string };
253
+ | {
254
+ ok: false;
255
+ conflict: boolean;
256
+ timedOut?: boolean;
257
+ worktree: string;
258
+ error: string;
259
+ };
249
260
 
250
261
  /** Where sync checks out branches nobody has checked out (never the user's checkout). */
251
262
  const scratchDir = (cwd: string): string | null => {
@@ -269,14 +280,26 @@ function gitRestack(
269
280
  let dir = owner;
270
281
  if (!dir) {
271
282
  const scratch = scratchDir(cwd);
272
- if (!scratch) return { ok: false, conflict: false, worktree: cwd, error: "no store root" };
283
+ if (!scratch)
284
+ return {
285
+ ok: false,
286
+ conflict: false,
287
+ worktree: cwd,
288
+ error: "no store root",
289
+ };
273
290
  fs.mkdirSync(scratch, { recursive: true });
274
291
  dir = path.join(
275
292
  scratch,
276
293
  `${input.branch.replace(/[^A-Za-z0-9._-]+/gu, "_").slice(0, 40)}-${randomBytes(4).toString("hex")}`,
277
294
  );
278
295
  const added = gitRun(cwd, ["worktree", "add", "-q", dir, input.branch], GIT_TIMEOUTS.worktree);
279
- if (!added.ok) return { ok: false, conflict: false, worktree: cwd, error: firstLine(added) };
296
+ if (!added.ok)
297
+ return {
298
+ ok: false,
299
+ conflict: false,
300
+ worktree: cwd,
301
+ error: firstLine(added),
302
+ };
280
303
  }
281
304
  const run = gitRun(
282
305
  dir,
@@ -304,7 +327,12 @@ function gitRestack(
304
327
  error: `git rebase did not finish within ${stackDeps.rebaseTimeoutMs} ms`,
305
328
  };
306
329
  if (rebaseInProgress(dir))
307
- return { ok: false, conflict: true, worktree: dir, error: firstLine(run) };
330
+ return {
331
+ ok: false,
332
+ conflict: true,
333
+ worktree: dir,
334
+ error: firstLine(run),
335
+ };
308
336
  if (!owner) gitRun(cwd, ["worktree", "remove", "--force", dir]);
309
337
  return { ok: false, conflict: false, worktree: dir, error: firstLine(run) };
310
338
  }
@@ -312,7 +340,12 @@ function gitRestack(
312
340
  const head = branchTip(cwd, input.branch);
313
341
  return head
314
342
  ? { ok: true, head }
315
- : { ok: false, conflict: false, worktree: dir, error: `${input.branch} vanished` };
343
+ : {
344
+ ok: false,
345
+ conflict: false,
346
+ worktree: dir,
347
+ error: `${input.branch} vanished`,
348
+ };
316
349
  }
317
350
 
318
351
  /** Remove temporary sync worktrees that are clean and not mid-rebase. */
@@ -489,7 +522,10 @@ export function listStacks(cwd: string): StackResult<StackFile[]> {
489
522
  // Only files at the name's own path count (a renamed copy is ignored).
490
523
  if (parsed && fileFor(parsed.name) === name) out.push(parsed);
491
524
  }
492
- return { ok: true, data: out.toSorted((a, b) => a.name.localeCompare(b.name)) };
525
+ return {
526
+ ok: true,
527
+ data: out.toSorted((a, b) => a.name.localeCompare(b.name)),
528
+ };
493
529
  }
494
530
 
495
531
  /** The stack `name`, else the one holding the current branch, else the only one. */
@@ -539,7 +575,12 @@ const OWNERLESS_STALE_MS = 30_000;
539
575
 
540
576
  const OWNER = "owner";
541
577
 
542
- type LockOwner = { pid: number; processStart: string | null; host: string; nonce: string };
578
+ type LockOwner = {
579
+ pid: number;
580
+ processStart: string | null;
581
+ host: string;
582
+ nonce: string;
583
+ };
543
584
 
544
585
  const readOwner = (lock: string): { raw: string | null; age: number | null } => {
545
586
  let raw: string | null = null;
@@ -1039,7 +1080,13 @@ export type BranchStatus = {
1039
1080
 
1040
1081
  export type Qualification =
1041
1082
  | { ok: true }
1042
- | { ok: false; reason: StopReason; detail: string; unblock: string; ready: boolean };
1083
+ | {
1084
+ ok: false;
1085
+ reason: StopReason;
1086
+ detail: string;
1087
+ unblock: string;
1088
+ ready: boolean;
1089
+ };
1043
1090
 
1044
1091
  export type StatusOutcome = {
1045
1092
  name: string;
@@ -1063,7 +1110,10 @@ const noun = (resolved: ResolvedForge, pr: number): string =>
1063
1110
  function statusDoc(ctx: Ctx, pr: number): ForgeResult<PrStatusDoc> {
1064
1111
  const status = ctx.resolved.forge.prStatus(pr);
1065
1112
  if (!status.ok) return status;
1066
- return buildStatusDoc(ctx.cwd, ctx.resolved, status.data, { logLines: 0, behind: false });
1113
+ return buildStatusDoc(ctx.cwd, ctx.resolved, status.data, {
1114
+ logLines: 0,
1115
+ behind: false,
1116
+ });
1067
1117
  }
1068
1118
 
1069
1119
  /**
@@ -1094,7 +1144,10 @@ export function repoMismatch(ctx: Pick<Ctx, "resolved">, stack: StackFile): Stac
1094
1144
  "blocked",
1095
1145
  `repo_mismatch: stack ${stack.name} was planned on ${stack.forge ?? "?"} ${stack.repo ?? "?"}, but this checkout resolves to ${kind} ${repo}`,
1096
1146
  `workit stack plan --name ${stack.name} <branch…> # re-plan it here, or run from the right checkout`,
1097
- { planned: { forge: stack.forge, repo: stack.repo }, resolved: { forge: kind, repo } },
1147
+ {
1148
+ planned: { forge: stack.forge, repo: stack.repo },
1149
+ resolved: { forge: kind, repo },
1150
+ },
1098
1151
  );
1099
1152
  return null;
1100
1153
  }
@@ -1332,7 +1385,12 @@ export type SyncStep = {
1332
1385
  pr: number | null;
1333
1386
  parent: string;
1334
1387
  /** null: already on its parent. */
1335
- restack: { from: string; onto: string; head: string | null; carried: boolean | null } | null;
1388
+ restack: {
1389
+ from: string;
1390
+ onto: string;
1391
+ head: string | null;
1392
+ carried: boolean | null;
1393
+ } | null;
1336
1394
  push: { pushed: boolean; forced: boolean; sha: string } | null;
1337
1395
  retarget: { from: string; to: string } | null;
1338
1396
  };
@@ -1383,7 +1441,11 @@ function detectMerged(
1383
1441
  isAncestor(cwd, tip, trunkSha);
1384
1442
  }
1385
1443
  if (merged) {
1386
- entry.merged = { pr: entry.pr, mergeSha: null, at: new Date().toISOString() };
1444
+ entry.merged = {
1445
+ pr: entry.pr,
1446
+ mergeSha: null,
1447
+ at: new Date().toISOString(),
1448
+ };
1387
1449
  newly.push({ branch: entry.branch, pr: entry.pr });
1388
1450
  }
1389
1451
  }
@@ -1407,21 +1469,34 @@ function pushBranch(
1407
1469
  const remote = remoteRefTip(cwd, plan.data.rawUrl, `refs/heads/${branch}`);
1408
1470
  if (!remote.ok) return stackFail(remote.code, remote.error);
1409
1471
  if (remote.sha === plan.data.sha)
1410
- return { ok: true, data: { pushed: false, forced: false, sha: plan.data.sha } };
1472
+ return {
1473
+ ok: true,
1474
+ data: { pushed: false, forced: false, sha: plan.data.sha },
1475
+ };
1411
1476
  const grant = requireGrant(cwd, "push", { forge: !plan.data.local });
1412
1477
  if (!grant.allowed)
1413
- return stackFail("blocked", grant.error, grant.unblock, { reason: grant.reason });
1478
+ return stackFail("blocked", grant.error, grant.unblock, {
1479
+ reason: grant.reason,
1480
+ });
1414
1481
  const force = remote.sha !== null && !isAncestor(cwd, remote.sha, plan.data.sha);
1415
1482
  const expect = !force
1416
1483
  ? null
1417
1484
  : remote.sha === priorTip
1418
1485
  ? priorTip
1419
1486
  : (recordedRemoteTip(cwd, plan.data) ?? null);
1420
- const pushed = executePush(cwd, plan.data, { forceWithLease: force, expect, actor });
1487
+ const pushed = executePush(cwd, plan.data, {
1488
+ forceWithLease: force,
1489
+ expect,
1490
+ actor,
1491
+ });
1421
1492
  if (!pushed.ok) return { ...fromForge(pushed), data: { branch } };
1422
1493
  return {
1423
1494
  ok: true,
1424
- data: { pushed: pushed.data.pushed, forced: pushed.data.forced, sha: plan.data.sha },
1495
+ data: {
1496
+ pushed: pushed.data.pushed,
1497
+ forced: pushed.data.forced,
1498
+ sha: plan.data.sha,
1499
+ },
1425
1500
  };
1426
1501
  }
1427
1502
 
@@ -1584,7 +1659,11 @@ export function syncStack(
1584
1659
  "blocked",
1585
1660
  `conflict restacking ${entry.branch} onto ${parent.name}: ${rebased.error}`,
1586
1661
  `resolve the conflicts in ${rebased.worktree}, git rebase --continue, then workit stack sync # or git rebase --abort`,
1587
- { conflict: entry.branch, worktree: rebased.worktree, progress: outcome },
1662
+ {
1663
+ conflict: entry.branch,
1664
+ worktree: rebased.worktree,
1665
+ progress: outcome,
1666
+ },
1588
1667
  )
1589
1668
  : stackFail(
1590
1669
  "failed",
@@ -1670,13 +1749,21 @@ export function syncStack(
1670
1749
  p: patchId(cwd, fromBase, fromHead),
1671
1750
  d: diffHash(cwd, fromBase, fromHead),
1672
1751
  };
1673
- const after = { p: patchId(cwd, parentTip, newTip), d: diffHash(cwd, parentTip, newTip) };
1752
+ const after = {
1753
+ p: patchId(cwd, parentTip, newTip),
1754
+ d: diffHash(cwd, parentTip, newTip),
1755
+ };
1674
1756
  const equal =
1675
1757
  before.d !== null &&
1676
1758
  before.d === after.d &&
1677
1759
  before.p !== null &&
1678
1760
  before.p === after.p &&
1679
- sameOwnChange(cwd, { from: fromHead, to: newTip, fromBase, toBase: parentTip });
1761
+ sameOwnChange(cwd, {
1762
+ from: fromHead,
1763
+ to: newTip,
1764
+ fromBase,
1765
+ toBase: parentTip,
1766
+ });
1680
1767
  appendObserved(cwd, {
1681
1768
  type: "stack.restacked",
1682
1769
  actor,
@@ -1692,7 +1779,13 @@ export function syncStack(
1692
1779
  forced,
1693
1780
  });
1694
1781
  if (step.restack) step.restack.carried = equal;
1695
- else step.restack = { from: fromBase, onto: parentTip, head: newTip, carried: equal };
1782
+ else
1783
+ step.restack = {
1784
+ from: fromBase,
1785
+ onto: parentTip,
1786
+ head: newTip,
1787
+ carried: equal,
1788
+ };
1696
1789
  }
1697
1790
  if (options.publish) {
1698
1791
  touchStackLock();
@@ -1721,7 +1814,10 @@ export function syncStack(
1721
1814
  });
1722
1815
  const updated = ctx.resolved.forge.updateBase(entry.pr, parent.name);
1723
1816
  if (!updated.ok)
1724
- return { ...fromForge(updated), data: { branch: entry.branch, progress: outcome } };
1817
+ return {
1818
+ ...fromForge(updated),
1819
+ data: { branch: entry.branch, progress: outcome },
1820
+ };
1725
1821
  appendObserved(cwd, {
1726
1822
  type: "pr.retargeted",
1727
1823
  actor,
@@ -1766,6 +1862,8 @@ export type LandOptions = {
1766
1862
  method: MergeMethod;
1767
1863
  /** Land without verdicts (`merge: true` only); each bypass is recorded. */
1768
1864
  unverified?: { reason: string } | null;
1865
+ /** Merge onto a red base (`--base-red-ok`); each merge records the reason. */
1866
+ baseRedOk?: { reason: string } | null;
1769
1867
  /** How long to wait for CI on a PR that was just restacked. */
1770
1868
  timeoutMs: number;
1771
1869
  intervalMs: number;
@@ -1775,7 +1873,12 @@ export type LandOutcome = {
1775
1873
  name: string;
1776
1874
  dryRun: boolean;
1777
1875
  /** `unverified`: the ledger row of an `--unverified` bypass, else null. */
1778
- landed: Array<{ pr: number; branch: string; mergeSha: string | null; unverified: string | null }>;
1876
+ landed: Array<{
1877
+ pr: number;
1878
+ branch: string;
1879
+ mergeSha: string | null;
1880
+ unverified: string | null;
1881
+ }>;
1779
1882
  /** Dry run: the PRs that qualify now, in order (later ones assume a clean restack). */
1780
1883
  wouldLand: Array<{ pr: number; branch: string }>;
1781
1884
  stoppedAt: {
@@ -1849,7 +1952,9 @@ export async function landStack(
1849
1952
  const grant = requireGrant(ctx.cwd, "merge");
1850
1953
  if (!grant.allowed)
1851
1954
  return failWith(
1852
- stackFail("blocked", grant.error, grant.unblock, { reason: "grant_required" }),
1955
+ stackFail("blocked", grant.error, grant.unblock, {
1956
+ reason: "grant_required",
1957
+ }),
1853
1958
  );
1854
1959
  if (!grant.allowUnverified)
1855
1960
  return failWith(
@@ -1959,6 +2064,7 @@ export async function landStack(
1959
2064
  actor: ctx.actor,
1960
2065
  expect: { base: stack.trunk, branch: entry.branch },
1961
2066
  ...(bypass && !verdict.satisfied ? { unverified: bypass } : {}),
2067
+ ...(options.baseRedOk ? { baseRedOk: options.baseRedOk } : {}),
1962
2068
  },
1963
2069
  ctx.sleep,
1964
2070
  );
@@ -1982,7 +2088,11 @@ export async function landStack(
1982
2088
  mergeSha: merged.data.mergeSha,
1983
2089
  unverified: merged.data.unverified?.recorded ?? null,
1984
2090
  });
1985
- entry.merged = { pr, mergeSha: merged.data.mergeSha, at: new Date().toISOString() };
2091
+ entry.merged = {
2092
+ pr,
2093
+ mergeSha: merged.data.mergeSha,
2094
+ at: new Date().toISOString(),
2095
+ };
1986
2096
  const written = writeStack(ctx.cwd, stack);
1987
2097
  if (!written.ok) return failWith(written);
1988
2098
  // Move the next PR onto the trunk: restack, lease push, retarget. The