@dev-loops/core 1.0.4-pre.0 → 1.0.4-pre.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.
@@ -264,7 +264,7 @@ export function sanitizeCopilotSummonTokens(text) {
264
264
  // line) from `text`, leaving only bare-text markdown to scan. Unlike
265
265
  // transformNonFencedLines, fenced content here must be REMOVED, not kept:
266
266
  // leaving it would let bare text inside a fence still match the summon scan.
267
- function stripMarkdownCodeForScan(text) {
267
+ export function stripMarkdownCodeForScan(text) {
268
268
  const lines = String(text).split(/\r?\n/);
269
269
  let inFencedBlock = false;
270
270
  let fencedDelimiter = "";
@@ -310,7 +310,8 @@ export function normalizeTimestamp(value) {
310
310
  export function extractReviewCommitSha(review) {
311
311
  const graphqlSha = typeof review?.commit?.oid === "string" ? review.commit.oid.trim() : "";
312
312
  const restSha = typeof review?.commit_id === "string" ? review.commit_id.trim() : "";
313
- const sha = graphqlSha || restSha;
313
+ const camelCaseSha = typeof review?.commitId === "string" ? review.commitId.trim() : "";
314
+ const sha = graphqlSha || restSha || camelCaseSha;
314
315
  return sha.length > 0 ? sha : null;
315
316
  }
316
317
 
@@ -768,6 +769,11 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
768
769
  let hasSubmittedReviewOnCurrentHead = false;
769
770
  let latestSubmittedReviewOnCurrentHeadAt = null;
770
771
  let hasBodyFindingOnCurrentHead = false;
772
+ // The id of the review whose body set hasBodyFindingOnCurrentHead: the
773
+ // review a copilot-body-disposition record must name to clear the finding.
774
+ // Updated on every branch that updates hasBodyFindingOnCurrentHead so the two
775
+ // never drift.
776
+ let bodyFindingReviewId = null;
771
777
  let completedCopilotReviewRounds = 0;
772
778
 
773
779
  for (const review of effectiveReviews) {
@@ -793,21 +799,28 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
793
799
  const submittedAt = typeof review?.submittedAt === "string"
794
800
  ? review.submittedAt
795
801
  : (typeof review?.submitted_at === "string" ? review.submitted_at : null);
802
+ const reviewId = review?.id !== null && review?.id !== undefined ? String(review.id) : null;
796
803
  if (submittedAt !== null && (latestSubmittedReviewOnCurrentHeadAt === null || submittedAt > latestSubmittedReviewOnCurrentHeadAt)) {
797
804
  latestSubmittedReviewOnCurrentHeadAt = submittedAt;
798
805
  hasBodyFindingOnCurrentHead = copilotReviewBodySignalsChanges(state, review?.body);
799
- } else if (submittedAt !== null && submittedAt === latestSubmittedReviewOnCurrentHeadAt) {
800
- // Equal-timestamp tie on the same head: fail toward surfacing so array
801
- // order never silently drops a finding when two reviews share a timestamp.
802
- hasBodyFindingOnCurrentHead = hasBodyFindingOnCurrentHead || copilotReviewBodySignalsChanges(state, review?.body);
803
- } else if (submittedAt === null && latestSubmittedReviewOnCurrentHeadAt === null) {
804
- hasBodyFindingOnCurrentHead = hasBodyFindingOnCurrentHead || copilotReviewBodySignalsChanges(state, review?.body);
806
+ bodyFindingReviewId = hasBodyFindingOnCurrentHead ? reviewId : null;
807
+ } else if (submittedAt === latestSubmittedReviewOnCurrentHeadAt) {
808
+ // Equal-timestamp (or both-null) tie on the same head: fail toward
809
+ // surfacing so array order never silently drops a finding. When two
810
+ // tied reviews both signal, no single review owns the finding, so
811
+ // bodyFindingReviewId is null and no disposition record can clear it.
812
+ const tieSignals = copilotReviewBodySignalsChanges(state, review?.body);
813
+ if (tieSignals) {
814
+ bodyFindingReviewId = hasBodyFindingOnCurrentHead ? null : reviewId;
815
+ }
816
+ hasBodyFindingOnCurrentHead = hasBodyFindingOnCurrentHead || tieSignals;
805
817
  }
806
818
  }
807
819
  }
808
820
 
809
821
  return {
810
822
  copilotReviews,
823
+ effectiveCopilotReviews: effectiveReviews,
811
824
  copilotReviewIds: copilotReviews
812
825
  .map((review) => review?.id)
813
826
  .filter((id) => id !== null && id !== undefined)
@@ -818,5 +831,6 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
818
831
  hasSubmittedReviewOnCurrentHead,
819
832
  latestSubmittedReviewOnCurrentHeadAt,
820
833
  hasBodyFindingOnCurrentHead,
834
+ bodyFindingReviewId,
821
835
  };
822
836
  }
package/src/github/gh.mjs CHANGED
@@ -67,7 +67,8 @@ export async function ghJson(args, { env, ghCommand = "gh", runChild = defaultRu
67
67
  * @param {typeof defaultRunChild} [runChild] - injectable child-exec seam.
68
68
  * @param {object} [opts]
69
69
  * @param {boolean} [opts.allowErrors] - when true, a GraphQL `errors` array
70
- * in the response is returned instead of thrown.
70
+ * in the response is returned instead of thrown. This includes a non-zero
71
+ * `gh` exit whose stdout is JSON with an `errors` array.
71
72
  */
72
73
  export async function ghGraphql(query, vars, env, runChild = defaultRunChild, { allowErrors = false } = {}) {
73
74
  const fieldArgs = [];
@@ -80,6 +81,18 @@ export async function ghGraphql(query, vars, env, runChild = defaultRunChild, {
80
81
  env,
81
82
  );
82
83
  if (result.code !== 0) {
84
+ // `gh api graphql` exits non-zero when the response carries GraphQL
85
+ // `errors` (e.g. NOT_FOUND) but still prints the JSON on stdout. With
86
+ // allowErrors the caller wants that errors array, so return it.
87
+ if (allowErrors) {
88
+ let errorPayload = null;
89
+ try {
90
+ errorPayload = JSON.parse(result.stdout);
91
+ } catch {
92
+ // not JSON: fall through to GH_API_ERROR
93
+ }
94
+ if (Array.isArray(errorPayload?.errors)) return errorPayload;
95
+ }
83
96
  const detail = result.stderr.trim() || `exit code ${result.code}`;
84
97
  throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
85
98
  }
@@ -121,6 +121,11 @@ export function parseReviewThreads(payload) {
121
121
  const threads = rawThreads.map((thread, threadIndex) => {
122
122
  const threadId = normalizeId(thread?.id ?? thread?.databaseId, `thread-${threadIndex + 1}`);
123
123
  const isResolved = Boolean(thread?.isResolved);
124
+ // The review that opened the thread: the root (first) comment's
125
+ // pullRequestReview id. null when the payload does not carry it, so a
126
+ // consumer that needs attribution treats the thread as unattributed.
127
+ const rootReviewId = extractRawComments(thread)[0]?.pullRequestReview?.id;
128
+ const reviewId = typeof rootReviewId === "string" && rootReviewId.length > 0 ? rootReviewId : null;
124
129
  const normalizedComments = extractRawComments(thread)
125
130
  .map((comment, commentIndex) => normalizeComment(comment, threadId, commentIndex, { isResolved }))
126
131
  .sort((left, right) => compareIds(left.id, right.id));
@@ -141,6 +146,7 @@ export function parseReviewThreads(payload) {
141
146
  return {
142
147
  id: threadId,
143
148
  isResolved,
149
+ reviewId,
144
150
  isActionable: actionableCommentIds.length > 0,
145
151
  commentIds,
146
152
  commentDatabaseIds,
@@ -198,6 +198,7 @@ export function buildSnapshotFromPrFacts({
198
198
  failureDetails = [],
199
199
  excludedFailureDetails,
200
200
  copilotBodyFeedbackUnresolved = false,
201
+ copilotPriorHeadBodyFeedbackUnresolved = false,
201
202
  }) {
202
203
  const prState = typeof prData?.state === "string" ? prData.state.toUpperCase() : "OPEN";
203
204
  const prMerged = prState === "MERGED";
@@ -228,6 +229,7 @@ export function buildSnapshotFromPrFacts({
228
229
  failureDetails,
229
230
  excludedFailureDetails: excludedFailureDetails ?? rollupDerivation.excludedFailureDetails,
230
231
  copilotBodyFeedbackUnresolved,
232
+ copilotPriorHeadBodyFeedbackUnresolved,
231
233
  });
232
234
  }
233
235
 
@@ -298,6 +300,10 @@ export function normalizeSnapshot(raw) {
298
300
  failureDetails: Array.isArray(raw.failureDetails) ? raw.failureDetails : [],
299
301
  excludedFailureDetails: Array.isArray(raw.excludedFailureDetails) ? raw.excludedFailureDetails : [],
300
302
  copilotBodyFeedbackUnresolved: Boolean(raw.copilotBodyFeedbackUnresolved),
303
+ // A body-only changes-recommended/unrecognized latest Copilot review on an
304
+ // earlier head with no trusted disposition record. Consumed only at the
305
+ // round cap, where no fresh Copilot review can supersede it.
306
+ copilotPriorHeadBodyFeedbackUnresolved: Boolean(raw.copilotPriorHeadBodyFeedbackUnresolved),
301
307
  };
302
308
  }
303
309
 
@@ -357,7 +363,8 @@ export function applyConfirmedReviewRequest(snapshot, reviewRequestStatus) {
357
363
  * nextAction: string,
358
364
  * autoRerequestEligible: boolean,
359
365
  * sameHeadCleanConverged: boolean,
360
- * roundCapCleanEligible: boolean
366
+ * roundCapCleanEligible: boolean,
367
+ * roundCapReopenEligible: boolean
361
368
  * }}
362
369
  */
363
370
  export function interpretLoopState(snapshot, refinementConfig) {
@@ -402,12 +409,18 @@ export function interpretLoopState(snapshot, refinementConfig) {
402
409
  const maxRounds = refinementConfig?.maxCopilotRounds;
403
410
  const reviewInFlight = s.copilotReviewRequestStatus === "requested"
404
411
  || s.copilotReviewRequestStatus === "already-requested";
412
+ // Clean at the cap except for an earlier-head body-only finding. That finding
413
+ // blocks the clean fallback, but a significant post-convergence change still
414
+ // opens a new Copilot cycle, whose review supersedes it.
415
+ let roundCapBlockedOnlyByPriorHeadBody = false;
405
416
  if (isCopilotRoundCapReached({ copilotReviewRoundCount: s.copilotReviewRoundCount, maxCopilotRounds: maxRounds })
406
417
  && state !== STATE.NO_PR && state !== STATE.DONE
407
418
  && state !== STATE.PR_DRAFT && state !== STATE.REVIEW_REQUEST_UNAVAILABLE
408
419
  && state !== STATE.BLOCKED_NEEDS_USER_DECISION) {
409
420
  const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen" || !preApprovalRequireCi;
410
- const cleanThreads = s.unresolvedThreadCount === 0 && !s.copilotBodyFeedbackUnresolved;
421
+ const cleanCurrentHead = s.unresolvedThreadCount === 0 && !s.copilotBodyFeedbackUnresolved;
422
+ const cleanThreads = cleanCurrentHead && !s.copilotPriorHeadBodyFeedbackUnresolved;
423
+ roundCapBlockedOnlyByPriorHeadBody = cleanCurrentHead && ciClean && !cleanThreads && !reviewInFlight;
411
424
  if (cleanThreads && ciClean) {
412
425
  state = STATE.ROUND_CAP_CLEAN_FALLBACK;
413
426
  } else if (!reviewInFlight) {
@@ -432,8 +445,12 @@ export function interpretLoopState(snapshot, refinementConfig) {
432
445
  // A current-head Copilot request is still active/pending and must settle before gate progression.
433
446
  state = STATE.WAITING_FOR_COPILOT_REVIEW;
434
447
  } else if (s.copilotReviewPresent) {
435
- // Copilot has reviewed at least once; all threads resolved
436
- if (ciBlocks) {
448
+ // Copilot has reviewed at least once; all threads resolved. A later
449
+ // body-only finding on an earlier commit outranks a clean current-head
450
+ // review, and merge refuses on it. A re-request would stop at the
451
+ // same-head clean suppression, so only a copilot-body-disposition record
452
+ // naming that review clears it.
453
+ if (ciBlocks || (s.copilotReviewOnCurrentHead && s.copilotPriorHeadBodyFeedbackUnresolved)) {
437
454
  state = STATE.BLOCKED_NEEDS_USER_DECISION;
438
455
  } else if (ciWaits) {
439
456
  state = STATE.WAITING_FOR_CI;
@@ -483,6 +500,9 @@ export function interpretLoopState(snapshot, refinementConfig) {
483
500
  }
484
501
 
485
502
  const roundCapCleanEligible = state === STATE.ROUND_CAP_CLEAN_FALLBACK;
503
+ // Cap states where a significant post-convergence change reopens a Copilot cycle.
504
+ const roundCapReopenEligible = roundCapCleanEligible
505
+ || (state === STATE.ROUND_CAP_REACHED && roundCapBlockedOnlyByPriorHeadBody);
486
506
 
487
507
  return {
488
508
  state,
@@ -491,6 +511,26 @@ export function interpretLoopState(snapshot, refinementConfig) {
491
511
  autoRerequestEligible,
492
512
  sameHeadCleanConverged,
493
513
  roundCapCleanEligible,
514
+ roundCapReopenEligible,
515
+ };
516
+ }
517
+
518
+ /**
519
+ * Reopen a Copilot cycle at the round cap after a significant post-convergence
520
+ * change landed on a newer head (see `roundCapReopenEligible`).
521
+ *
522
+ * @param {object} interpretation - interpretLoopState() output
523
+ * @returns {object} the interpretation routed to READY_TO_REREQUEST_REVIEW
524
+ */
525
+ export function reopenRoundCapCycle(interpretation) {
526
+ return {
527
+ ...interpretation,
528
+ state: STATE.READY_TO_REREQUEST_REVIEW,
529
+ nextAction: NEXT_ACTIONS[STATE.READY_TO_REREQUEST_REVIEW],
530
+ allowedTransitions: [...TRANSITIONS[STATE.READY_TO_REREQUEST_REVIEW]],
531
+ autoRerequestEligible: true,
532
+ roundCapCleanEligible: false,
533
+ roundCapReopenEligible: false,
494
534
  };
495
535
  }
496
536
 
@@ -257,13 +257,13 @@ export function dedupeActListByCluster(actFindings, clusters, allFindings) {
257
257
  }
258
258
 
259
259
  /**
260
- * A "clean" verdict means no finding at a BLOCKING severity remains open. It is
261
- * invalid only when a finding at a blocking severity was acted on — that is
262
- * unresolved blocking work, so the round cannot be clean. Acting on a
263
- * NON-BLOCKING finding (a medium in the fix window, a low the fixer triages) is
264
- * expected under a clean verdict per `GATE-EXEC-BLOCKING-ONLY-FIX`: the fix
265
- * cycle covers non-blocking findings even though they never block clean, so a
266
- * clean verdict routinely carries non-blocking act findings.
260
+ * A "clean" ledger severity verdict means no finding at a BLOCKING severity
261
+ * remains open. It is invalid only when a finding at a blocking severity was
262
+ * acted on — that is unresolved blocking work, so the round cannot be clean.
263
+ * The ledger's severity verdict may stay clean with NON-BLOCKING act findings
264
+ * (a medium in the fix window, a low the fixer triages). The posted review
265
+ * verdict is composed with the act list (ADR 0089), so such a round posts
266
+ * findings_present until its act items are fixed.
267
267
  *
268
268
  * Throws a clear Error on `overallVerdict === "clean"` with any act finding at a
269
269
  * blocking severity; returns `overallVerdict` unchanged otherwise.
@@ -288,8 +288,8 @@ export function assertCleanImpliesNoBlockingAct(overallVerdict, actFindings, blo
288
288
  const severities = [...new Set(offending.map((f) => normalizeSeverity(f?.severity)))].join(", ");
289
289
  throw new Error(
290
290
  `clean verdict is invalid with ${offending.length} acted finding(s) at a blocking severity (${severities}): ` +
291
- `a blocking-severity finding acted on this round cannot be clean. Non-blocking act findings are allowed under ` +
292
- `a clean verdict (GATE-EXEC-BLOCKING-ONLY-FIX).`,
291
+ `a blocking-severity finding acted on this round cannot be clean. The ledger's severity verdict may be clean with ` +
292
+ `non-blocking act findings; the posted review verdict is composed with the act list (ADR 0089).`,
293
293
  );
294
294
  }
295
295
  return overallVerdict;
@@ -1046,6 +1046,27 @@ export function applyJudgeDispositions(findings, judgeVerdict) {
1046
1046
  return { findings: enriched, scopeDrift: validated.scopeDrift };
1047
1047
  }
1048
1048
 
1049
+ /**
1050
+ * The judge's act list: the findings this PR must still fix. Pure.
1051
+ * @param {unknown} findings
1052
+ * @returns {Array<object>}
1053
+ */
1054
+ export function listOpenActItems(findings) {
1055
+ return (Array.isArray(findings) ? findings : []).filter((f) => f && f.judgeDisposition === "act");
1056
+ }
1057
+
1058
+ /**
1059
+ * Compose a round's review verdict with the judge act list (ADR 0089): a
1060
+ * non-empty act list keeps a `clean` severity verdict from `clean`. Never
1061
+ * lowers a verdict; the blockCleanOnFindingSeverities floor already applied.
1062
+ * @param {"clean"|"findings_present"|"blocked"} overallVerdict
1063
+ * @param {unknown} findings
1064
+ * @returns {"clean"|"findings_present"|"blocked"}
1065
+ */
1066
+ export function composeReviewVerdict(overallVerdict, findings) {
1067
+ return overallVerdict === "clean" && listOpenActItems(findings).length > 0 ? "findings_present" : overallVerdict;
1068
+ }
1069
+
1049
1070
  /**
1050
1071
  * Map consolidated findings into the `--findings` JSON shape consumed by
1051
1072
  * scripts/github/write-gate-findings-log.mjs (severity, angle, summary,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Main-checkout fast-forward command shape (#1596).
2
+ * Main-checkout fast-forward flow.
3
3
  *
4
4
  * The dev-loop merges remotely (`gh pr merge` → origin/main) but neither the merge
5
5
  * procedure nor the post-merge hooks fast-forwarded the main checkout's local
@@ -8,7 +8,7 @@
8
8
  * code — re-introducing the CI-wait stall every PR (e.g. #1531's fix was invisible
9
9
  * until the main checkout caught up).
10
10
  *
11
- * This module owns the shared, dependency-free command string both harness hooks
11
+ * This module owns the shared, dependency-free step-wise flow both harness hooks
12
12
  * (Pi `post-merge-update`, Claude `post-tool-use-merge`) run after a successful
13
13
  * merge. It is best-effort and NON-BLOCKING: `--ff-only` refuses a diverged `main`
14
14
  * without rewriting history, so a diverged checkout fails the merge step cleanly and
@@ -16,10 +16,11 @@
16
16
  * push). `mainCheckout` is POSIX single-quoted so consumer checkout paths containing
17
17
  * spaces or shell metacharacters cannot break or inject into the shell string.
18
18
  *
19
- * The `merge --ff-only` is guarded to only run when the main checkout is currently on
20
- * `main`, so a non-`main` checkout (detached HEAD, or another branch checked out)
21
- * warns-and-continues instead of fast-forwarding the wrong branch. No `git switch` is
22
- * performed (a state change) — only the guard test runs.
19
+ * `syncMainCheckout` inspects the current ref BEFORE submitting any merge command, so
20
+ * a non-`main` checkout (detached HEAD, or another branch checked out) is classified
21
+ * and reported as an action-required diagnostic instead of fast-forwarding the wrong
22
+ * branch. No `git switch`/`checkout`/`reset` command is ever submitted for a non-main
23
+ * checkout — only read-only inspection commands run.
23
24
  *
24
25
  * No imports so this file vendors into the `.claude/hooks/` bundle unchanged
25
26
  * (vendored modules may only import `node:` builtins or relative paths).
@@ -27,15 +28,18 @@
27
28
  import path from "node:path";
28
29
 
29
30
  /**
30
- * Timeout (ms) for the `git worktree list` resolution step (the fetch-half budget;
31
- * a separate fetch timeout isn't applied — the fetch runs inline within the merge
32
- * command under `MAIN_CHECKOUT_FF_MERGE_TIMEOUT_MS`).
31
+ * Timeout (ms) for the `git worktree list` resolution step (the harness hook's own
32
+ * budget; `syncMainCheckout`'s step-wise `fetch`/`rev-parse`/`merge` commands each
33
+ * run under `MAIN_CHECKOUT_FF_MERGE_TIMEOUT_MS` instead).
33
34
  */
34
35
  export const MAIN_CHECKOUT_FF_FETCH_TIMEOUT_MS = 60_000;
35
36
 
36
- /** Timeout (ms) for the `git merge --ff-only origin/main` half. */
37
+ /** Timeout (ms) for each step of `syncMainCheckout`'s fetch/rev-parse/merge flow. */
37
38
  export const MAIN_CHECKOUT_FF_MERGE_TIMEOUT_MS = 60_000;
38
39
 
40
+ /** Stable diagnostic kind for a main checkout proven not to be on `main`. */
41
+ export const MAIN_CHECKOUT_NOT_ON_MAIN_KIND = "main_checkout_not_on_main";
42
+
39
43
  /**
40
44
  * POSIX single-quote a path so spaces/shell metacharacters in a consumer's checkout
41
45
  * path cannot break or inject into the shell string.
@@ -45,17 +49,165 @@ function shellQuotePath(value) {
45
49
  }
46
50
 
47
51
  /**
48
- * Build the best-effort main-checkout fast-forward command string.
52
+ * Classify a main checkout's current full symbolic ref (`git rev-parse
53
+ * --symbolic-full-name HEAD` output) into one of the four states `syncMainCheckout`
54
+ * acts on. `--symbolic-full-name` (unlike `--abbrev-ref`) is never subject to
55
+ * `core.warnAmbiguousRefs` renaming a branch to `heads/<name>` when a tag or other ref
56
+ * shares its short name.
57
+ *
58
+ * @param {unknown} symbolicFullName - Raw (already-trimmed or not) `--symbolic-full-name
59
+ * HEAD` output.
60
+ * @returns {"main" | "other_branch" | "detached" | "unreadable"}
61
+ */
62
+ export function classifyMainCheckoutRef(symbolicFullName) {
63
+ if (typeof symbolicFullName !== "string") {
64
+ return "unreadable";
65
+ }
66
+ const trimmed = symbolicFullName.trim();
67
+ if (!trimmed) {
68
+ return "unreadable";
69
+ }
70
+ if (trimmed === "HEAD") {
71
+ return "detached";
72
+ }
73
+ if (trimmed === "refs/heads/main") {
74
+ return "main";
75
+ }
76
+ if (trimmed.startsWith("refs/heads/")) {
77
+ return "other_branch";
78
+ }
79
+ return "unreadable";
80
+ }
81
+
82
+ /**
83
+ * Build the `main_checkout_not_on_main` diagnostic, or `null` when any required field
84
+ * is missing or invalid. The one shared rendered `message` names the kind, the
85
+ * absolute checkout path, the current ref, the behind count, that no fast-forward
86
+ * happened, that the action is non-fatal, and the manual recovery — never a
87
+ * reset/force suggestion.
88
+ *
89
+ * @param {{ mainCheckout: unknown, ref: unknown, behindCount: unknown }} fields
90
+ * @returns {{ kind: string, severity: "error", mainCheckout: string, ref: string, behindCount: number, message: string } | null}
91
+ */
92
+ export function buildMainCheckoutNotOnMainDiagnostic({ mainCheckout, ref, behindCount } = {}) {
93
+ if (typeof mainCheckout !== "string" || !path.isAbsolute(mainCheckout)) {
94
+ return null;
95
+ }
96
+ if (typeof ref !== "string" || !ref.trim()) {
97
+ return null;
98
+ }
99
+ if (!Number.isInteger(behindCount) || behindCount < 0) {
100
+ return null;
101
+ }
102
+ const message =
103
+ `[dev-loops] post-merge: ${MAIN_CHECKOUT_NOT_ON_MAIN_KIND} — the main checkout at '${mainCheckout}' ` +
104
+ `is on ${ref}, ${behindCount} commit(s) behind origin/main after fetch. No fast-forward was performed; ` +
105
+ `this is non-fatal. Reconcile manually: preserve any local-only commits, then check out main and ` +
106
+ `fast-forward it to origin/main.`;
107
+ return { kind: MAIN_CHECKOUT_NOT_ON_MAIN_KIND, severity: "error", mainCheckout, ref, behindCount, message };
108
+ }
109
+
110
+ /**
111
+ * Run one shell-command step through the harness-supplied `run` adapter, normalizing
112
+ * a thrown/rejected `run` into the same `{ ok: false, reason }` shape as an adapter
113
+ * that resolves a failure — callers never need a second failure path.
114
+ *
115
+ * @param {(command: string) => Promise<{ ok: boolean, stdout?: string, reason?: string }>} run
116
+ * @param {string} command
117
+ * @returns {Promise<{ ok: true, stdout: string } | { ok: false, reason: string }>}
118
+ */
119
+ async function runStep(run, command) {
120
+ try {
121
+ const result = await run(command);
122
+ if (result?.ok) {
123
+ return { ok: true, stdout: typeof result.stdout === "string" ? result.stdout : "" };
124
+ }
125
+ return { ok: false, reason: result?.reason || "command failed" };
126
+ } catch (error) {
127
+ return { ok: false, reason: error?.message || String(error) };
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Best-effort, step-wise main-checkout sync.
133
+ *
134
+ * After a successful `fetch origin main`, inspects the checkout's current ref BEFORE
135
+ * submitting any merge command:
136
+ * - `main` → `git merge --ff-only origin/main`; ok → `fast_forwarded`; a diverged
137
+ * main fails the merge step cleanly → `skipped`.
138
+ * - detached HEAD or another named branch → never merged/switched/reset; instead
139
+ * the post-fetch `HEAD..origin/main` behind count is measured and reported as a
140
+ * `not_on_main` diagnostic (see `buildMainCheckoutNotOnMainDiagnostic`).
141
+ * Any step that cannot prove its outcome (fetch failure, unreadable ref, a failed or
142
+ * empty short-SHA resolution, a failed or non-numeric behind-count read) is reported
143
+ * as `skipped` instead of a partial diagnostic.
49
144
  *
50
145
  * @param {string} mainCheckout - Absolute path to the main (primary) git checkout.
51
- * @returns {string} `git -C '<main>' fetch origin main && [ "$(git -C '<main>' rev-parse --abbrev-ref HEAD)" = main ] && git -C '<main>' merge --ff-only origin/main` (path POSIX single-quoted; merge only runs when the main checkout is on `main`)
146
+ * @param {(command: string) => Promise<{ ok: boolean, stdout?: string, reason?: string }>} run
147
+ * Harness adapter that executes one shell command and resolves its outcome (may also
148
+ * throw — treated as a failed step).
149
+ * @returns {Promise<
150
+ * | { status: "fast_forwarded" }
151
+ * | { status: "skipped", reason: string }
152
+ * | { status: "not_on_main", diagnostic: ReturnType<typeof buildMainCheckoutNotOnMainDiagnostic> }
153
+ * >}
52
154
  */
53
- export function buildMainCheckoutFastForwardCommand(mainCheckout) {
155
+ export async function syncMainCheckout(mainCheckout, run) {
54
156
  const quoted = shellQuotePath(mainCheckout);
55
- // ponytail: guard with a `[ ... = main ]` test instead of switching branches — a
56
- // non-main checkout fails the && chain (warn-and-continue) rather than ff-ing the
57
- // wrong branch. No state change, no git switch.
58
- return `git -C ${quoted} fetch origin main && [ "$(git -C ${quoted} rev-parse --abbrev-ref HEAD)" = main ] && git -C ${quoted} merge --ff-only origin/main`;
157
+
158
+ const fetchResult = await runStep(run, `git -C ${quoted} fetch origin main`);
159
+ if (!fetchResult.ok) {
160
+ return { status: "skipped", reason: fetchResult.reason };
161
+ }
162
+
163
+ const refResult = await runStep(run, `git -C ${quoted} rev-parse --symbolic-full-name HEAD`);
164
+ if (!refResult.ok) {
165
+ return { status: "skipped", reason: refResult.reason };
166
+ }
167
+ const symbolicRef = refResult.stdout.trim();
168
+ const classification = classifyMainCheckoutRef(symbolicRef);
169
+ if (classification === "unreadable") {
170
+ return { status: "skipped", reason: `could not determine the current branch (got: ${JSON.stringify(symbolicRef)})` };
171
+ }
172
+
173
+ if (classification === "main") {
174
+ const mergeResult = await runStep(run, `git -C ${quoted} merge --ff-only origin/main`);
175
+ if (!mergeResult.ok) {
176
+ return { status: "skipped", reason: mergeResult.reason };
177
+ }
178
+ return { status: "fast_forwarded" };
179
+ }
180
+
181
+ let ref;
182
+ if (classification === "detached") {
183
+ const shortShaResult = await runStep(run, `git -C ${quoted} rev-parse --short HEAD`);
184
+ if (!shortShaResult.ok) {
185
+ return { status: "skipped", reason: shortShaResult.reason };
186
+ }
187
+ const shortSha = shortShaResult.stdout.trim();
188
+ if (!shortSha) {
189
+ return { status: "skipped", reason: "could not resolve a short SHA for the detached HEAD" };
190
+ }
191
+ ref = `detached@${shortSha}`;
192
+ } else {
193
+ ref = symbolicRef.slice("refs/heads/".length);
194
+ }
195
+
196
+ const behindResult = await runStep(run, `git -C ${quoted} rev-list --count HEAD..origin/main`);
197
+ if (!behindResult.ok) {
198
+ return { status: "skipped", reason: behindResult.reason };
199
+ }
200
+ const behindStdout = behindResult.stdout.trim();
201
+ if (!/^\d+$/.test(behindStdout)) {
202
+ return { status: "skipped", reason: `could not read the behind count (got: ${JSON.stringify(behindStdout)})` };
203
+ }
204
+ const behindCount = Number.parseInt(behindStdout, 10);
205
+
206
+ const diagnostic = buildMainCheckoutNotOnMainDiagnostic({ mainCheckout, ref, behindCount });
207
+ if (!diagnostic) {
208
+ return { status: "skipped", reason: "incomplete main_checkout_not_on_main diagnostic" };
209
+ }
210
+ return { status: "not_on_main", diagnostic };
59
211
  }
60
212
 
61
213
  /**