@on-par/factory-cli 0.9.5-next.1 → 0.9.5-next.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/dist/cli/index.js CHANGED
@@ -9,7 +9,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:pat
9
9
  import { promisify, styleText } from 'node:util';
10
10
  import { BRANCH_PREFIX_MATCH_OPTION_DESCRIPTION, BRANCH_PREFIX_OPTION_DESCRIPTION, INVALID_BRANCH_PREFIX_MESSAGE, resolveBranchPrefixOption, } from './branch-prefix.js';
11
11
  import { acquirePortLease, aggregateCosts, appendKpiHistoryLine, applyRepoConfig, buildPhase, checkPhase, clearProxyState, closedWorkSkipReason, computeHealthKpis, ConstitutionLoader, createDefaultWorkSourceRegistry, createFileApprovalGate, createLogger, createLaneProxy, defaultFindPortListeners, describeEffectiveConfig, detectPostMergeDefects, diagnoseModels, probeDeclaredHarnesses, drainSteering, estimateTrailingSpend, fetchDefectSources, fetchHumanEventSources, fetchSubscriptionUsage, formatKpiLines, formatUsageReport, getConstitutionsDir, getFactoryPaths, GITHUB_ISSUE_SOURCE, hasUnresolvedPark, InvalidArtifactsDirError, InvalidWorkRequestInputError, InvalidWorkspaceError, inspectPortLeases, isCommandAvailable, isProxyRunning, kpiBranchPrefixes, KNOWN_HARNESS_IDS, kpisToHistoryRecord, laneBaseUrl, laneEnv, LaneFileGuard, laneHostLabel, listQueuedSteering, loadFactoryConfig, loadFactoryConfigForRepo, loadModelsConfig, loadRepoConfig, loadRoutesConfig, LOCAL_BRIEF_SOURCE, localOnlyWorkspace, joinClassifierOutcomes, mergeClassifierOutcomes, parseClassifierOutcomes, summarizeClassifierOutcomes, formatClassifierReport, mergedPrRefs, ModelRegistry, ModelRouter, parkReasonFor, parseKpiHistory, parseQueue, partitionLocalQueueByActivity, phaseSnapshotFile, planPhase, provisionLaneContainer, ProviderBreaker, readEvents, readPortLeases, readQueue, readUsage, reapOrphanProcesses, reapStalePortLeases, reconstructKpiEvents, recordLeasePgid, releasePortLease, renderKpiReport, renderKpiTrend, resolveAutoFailover, resolveCodexDisabled, resolveDefectWindowDays, resolveBuildPublish, resolveSizeGateMode, parseSizeGateMode, parseMaxSlices, resolveSizeGateMaxSlices, SIZE_GATE_MODES, resolveDesignRegressionBlock, resolveReadinessUpdateIssueBody, resolveEffectiveBuildRoute, resolveEffectiveModelPins, resolveEfficiencyPolicy, resolveLaneBreakerThreshold, resolveEnvironmentPorts, resolveEnvironmentProxy, resolveArtifactsDir, resolveIngestConfig, resolveLocalOnlyPolicy, resolveMergePolicy, resolvePlanApproval, resolveProcessGroupGraceMs, resolveSandboxPolicy, resolveSkipCI, resolveTimeouts, resolveUsageCap, resolveWatchdogPolicy, ReworkHistory, rewriteQueueForDecomposition, runAutoIngest, runIssue, shipPhase, summarizeEvent, formatCostTotal, touchLastEvent, touchRunActivity, validateQueue, watchUsage, worktreeWorkspace, writeBenchmarkArtifacts, writeLocalRunReport, writePhaseSnapshot, writeProxyState, } from '@on-par/factory-core';
12
- import { acquirePidFile, createCheckResumeStore, createPlanReuseStore, checkBaseHealth, currentSlice, egressHostMessage, egressSummaryMessage, findSlicePlanComment, upsertSlicePlanComment, withSliceState, DEFAULT_GARDEN_MAX_CLUSTERS, DEFAULT_GARDEN_SINCE, capGardenClusters, clusterGarden, dedupGardenClusters, filterGardenOnlyNew, filterGardenWindow, markGardenTrackingUnknown, parseGardenDuration, readHarvestEvents, renderGardenJson, renderGardenReport, formatWorktreeLocation, laneWorktreePath, resolveWorktreeRoot, migrateRepoConfigToYaml, REPO_CONFIG_YAML_HEADER, branchFor, claudeConfigDirOverride, claudeKeychainService, cleanupWorktree, createDaemonLogSink, factoryBranchIssue, factoryBranchPrefixes, getIssueTitle, LEGACY_BRANCH_PREFIX, createDockerEngine, createFactorydServer, createRunRuntime, createShipExecutor, createGithubQueue, createLocalSmallDryRun, createOctokitFilingClient, createOctokitGreenPrClient, createOctokitQueueClient, daemonRuntimePaths, DEFAULT_FACTORYD_PORT, defaultRegistryPath, ensureDir, FACTORY_RUNTIME_CONFIG_KEYS, findUnmergedGreenPrs, formatGcReport, gitFetch, isAutoMergeBlocked, listMicroVms, listOrphanContainers, logCost, logEvent, microVmName, planQueueMigration, readCosts, readGithubQueueSnapshot, reapLaneWorktree, reapOrphanContainers, reapOrphanMicroVm, releaseRuntimeFiles, releaseStaleClaims, resolveBranchPrefix, resolveEffectiveConfig, worktreeSandboxFor, resolveExperimental, resolveFilingPolicy, resolveLocalOnly, resolvePrClassifierPolicy, resolveRunUsageHarness, resolveReviewFloorRules, RunLockHeldError, runOvernightQueue, findOpenPR, parseRemoteSlug, setupWorktree, shellEscape, listBuiltInCheckers, shouldProxyEgress, slugify, startEgressProxy, sweepWorktrees, watchChecks, watchChecksWithInfraRerun, withAdmissionGuard, withFileLock, withGitLock, withRunLock, wrapCommandInSandbox, writePortFile, buildTriagePrompt, normalizeTriageQueue, parseTriageCandidates, codexChatgptSessionState, hasClaudeLoginEvidence, hasOpencodeLoginEvidence, } from '@on-par/factory-core/internal';
12
+ import { acquirePidFile, createCheckResumeStore, createPlanReuseStore, checkBaseHealth, currentSlice, egressHostMessage, egressSummaryMessage, findSlicePlanComment, recordSliceMerged, upsertSlicePlanComment, withSliceState, DEFAULT_GARDEN_MAX_CLUSTERS, DEFAULT_GARDEN_SINCE, capGardenClusters, clusterGarden, dedupGardenClusters, filterGardenOnlyNew, filterGardenWindow, markGardenTrackingUnknown, parseGardenDuration, readHarvestEvents, renderGardenJson, renderGardenReport, formatWorktreeLocation, laneWorktreePath, resolveWorktreeRoot, migrateRepoConfigToYaml, REPO_CONFIG_YAML_HEADER, branchFor, claudeConfigDirOverride, claudeKeychainService, cleanupWorktree, createDaemonLogSink, factoryBranchIssue, factoryBranchPrefixes, getIssueTitle, LEGACY_BRANCH_PREFIX, createDockerEngine, createFactorydServer, createRunRuntime, createShipExecutor, createGithubQueue, createLocalSmallDryRun, createOctokitFilingClient, createOctokitGreenPrClient, createOctokitQueueClient, daemonRuntimePaths, DEFAULT_FACTORYD_PORT, defaultRegistryPath, ensureDir, FACTORY_RUNTIME_CONFIG_KEYS, findUnmergedGreenPrs, formatGcReport, gitFetch, isAutoMergeBlocked, listMicroVms, listOrphanContainers, logCost, logEvent, microVmName, planQueueMigration, readCosts, readGithubQueueSnapshot, reapLaneWorktree, reapOrphanContainers, reapOrphanMicroVm, releaseRuntimeFiles, releaseStaleClaims, resolveBranchPrefix, resolveEffectiveConfig, worktreeSandboxFor, resolveExperimental, resolveFilingPolicy, resolveLocalOnly, resolvePrClassifierPolicy, resolveRunUsageHarness, resolveReviewFloorRules, RunLockHeldError, runOvernightQueue, findOpenPR, parseRemoteSlug, setupWorktree, shellEscape, listBuiltInCheckers, shouldProxyEgress, slugify, startEgressProxy, sweepWorktrees, watchChecks, watchChecksWithInfraRerun, withAdmissionGuard, withFileLock, withGitLock, withRunLock, wrapCommandInSandbox, writePortFile, buildTriagePrompt, normalizeTriageQueue, parseTriageCandidates, codexChatgptSessionState, hasClaudeLoginEvidence, claudeStoredSessionState, localAuthFailureSinceLogin, recordLocalAuthFailure, hasOpencodeLoginEvidence, } from '@on-par/factory-core/internal';
13
13
  import { runTui } from '@on-par/factory-tui';
14
14
  import { Command } from 'commander';
15
15
  import { admissionStateReaderFor, runQueueReconcile } from './admission.js';
@@ -902,6 +902,7 @@ async function cmdCheck(issueRaw, opts) {
902
902
  const repoConfig = configLoaders.loadRepoConfig(repoRoot);
903
903
  const effective = resolveEffectiveConfig(repoConfig);
904
904
  const router = new coreOps.ModelRouter(applyRepoConfig(configLoaders.loadModelsConfig(), repoConfig), configLoaders.loadRoutesConfig(), false, undefined, effective.allowExperimental, effective.localOnly);
905
+ router.setLocalAuthSink?.(recordLocalAuthFailure);
905
906
  runModel = createDeepModelRunner(router, repoRoot);
906
907
  }
907
908
  let report;
@@ -1005,6 +1006,7 @@ async function cmdClassifierBacktestEntry(opts) {
1005
1006
  const repoConfig = configLoaders.loadRepoConfig(repoRoot);
1006
1007
  const effective = resolveEffectiveConfig(repoConfig);
1007
1008
  const router = new coreOps.ModelRouter(applyRepoConfig(configLoaders.loadModelsConfig(), repoConfig), configLoaders.loadRoutesConfig(), false, undefined, effective.allowExperimental, effective.localOnly);
1009
+ router.setLocalAuthSink?.(recordLocalAuthFailure);
1008
1010
  try {
1009
1011
  await cmdClassifierBacktest({ ...opts, branchPrefix }, {
1010
1012
  repoRoot,
@@ -1247,6 +1249,7 @@ export async function cmdStatus(opts = {}) {
1247
1249
  const routesConfig = configLoaders.loadRoutesConfig();
1248
1250
  const effective = resolveEffectiveConfig(repoConfig);
1249
1251
  const router = new coreOps.ModelRouter(modelsConfig, routesConfig, false, undefined, effective.allowExperimental, effective.localOnly);
1252
+ router.setLocalAuthSink?.(recordLocalAuthFailure);
1250
1253
  const product = readActiveProduct(paths.product) ?? '(none)';
1251
1254
  console.log(styleText('bold', `== ${ghRepo} ==`));
1252
1255
  console.log(`Product: ${product}`);
@@ -1425,6 +1428,7 @@ async function cmdTui(opts = {}, runTuiImpl = runTui) {
1425
1428
  const routesConfig = configLoaders.loadRoutesConfig();
1426
1429
  const effective = resolveEffectiveConfig(repoConfig);
1427
1430
  const router = new coreOps.ModelRouter(modelsConfig, routesConfig, false, undefined, effective.allowExperimental, effective.localOnly);
1431
+ router.setLocalAuthSink?.(recordLocalAuthFailure);
1428
1432
  const effectiveConfigLines = describeEffectiveConfig({
1429
1433
  router,
1430
1434
  repo: repoConfig,
@@ -1618,6 +1622,7 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1618
1622
  : { ...resolveEffectiveConfig(repoConfig), branchPrefix: opts.branchPrefix },
1619
1623
  };
1620
1624
  const router = new core.ModelRouter(policy.models, policy.routes, false, undefined, policy.effective.allowExperimental, policy.effective.localOnly);
1625
+ router.setLocalAuthSink?.(recordLocalAuthFailure);
1621
1626
  router.skipMissingHarnessClis(core.isCommandAvailable);
1622
1627
  let issueSpend = 0;
1623
1628
  let laneSandboxRuntime = 'none';
@@ -2595,7 +2600,10 @@ async function landIssue(issueNum, repoRoot, ghRepo, paths, octokit, skipCI, bra
2595
2600
  // Legacy-prefix (LEGACY_BRANCH_PREFIX) branches are guessed too, after the resolved prefix (#1708).
2596
2601
  const resolved = branchPrefix ?? resolveEffectiveConfig(configLoaders.loadRepoConfig(repoRoot)).branchPrefix;
2597
2602
  const title = await getIssueTitle(ghRepo, issueNum, octokit);
2598
- const guesses = factoryBranchPrefixes(resolved).map((p) => branchFor(issueNum, title, p));
2603
+ // ADR-0161: a stack layer lands by its exact branch, never by title guess or the Closes PR (the top layer).
2604
+ const guesses = opts.branch
2605
+ ? [opts.branch]
2606
+ : factoryBranchPrefixes(resolved).map((p) => branchFor(issueNum, title, p));
2599
2607
  const guessedBranch = guesses[0];
2600
2608
  let branch = guessedBranch;
2601
2609
  let prNumber;
@@ -2611,7 +2619,7 @@ async function landIssue(issueNum, repoRoot, ghRepo, paths, octokit, skipCI, bra
2611
2619
  return undefined;
2612
2620
  };
2613
2621
  [, prNumber] = await Promise.all([gitOps.gitFetch(repoRoot), findByGuess()]);
2614
- if (!prNumber) {
2622
+ if (!prNumber && !opts.branch) {
2615
2623
  const fallback = await findOpenPRForIssue(octokit, owner, repoName, issueNum);
2616
2624
  if (fallback) {
2617
2625
  branch = fallback.branch;
@@ -2723,6 +2731,7 @@ async function cmdTriage(opts) {
2723
2731
  const routesConfig = configLoaders.loadRoutesConfig();
2724
2732
  const effective = resolveEffectiveConfig(repoConfig);
2725
2733
  const router = new coreOps.ModelRouter(modelsConfig, routesConfig, false, undefined, effective.allowExperimental, effective.localOnly);
2734
+ router.setLocalAuthSink?.(recordLocalAuthFailure);
2726
2735
  router.skipMissingHarnessClis(coreOps.isCommandAvailable);
2727
2736
  router.setCostSink((entry) => logCost(paths.costs, { ...entry, issue: '-' }));
2728
2737
  let plannerError;
@@ -3272,7 +3281,10 @@ async function cmdRun(opts = {}) {
3272
3281
  repo,
3273
3282
  preflight: withAdmissionGuard({
3274
3283
  reader: admissionStateReaderFor(paths.state),
3275
- preflight: (issue) => preflightQueuedIssue(issue, createQueuePreflightOps(octokit, owner, repo)),
3284
+ preflight: (issue) => preflightQueuedIssue(issue, createQueuePreflightOps(octokit, owner, repo, {
3285
+ autoMergeStack: resolveSizeGateMode(factoryConfig) === 'stack' &&
3286
+ resolveMergePolicy(factoryConfig, process.env, { auto: opts.autoMerge }).auto,
3287
+ })),
3276
3288
  onConflict: (issue, verdict) => {
3277
3289
  const line = `#${issue} refused — ${verdict.reason} | repair: ${verdict.repair}`;
3278
3290
  console.error(styleText('yellow', `factory: ${line}`));
@@ -3388,6 +3400,7 @@ async function cmdRun(opts = {}) {
3388
3400
  ship: (issue, o, c) => shipIssue(issue, { ...o, branchPrefix, prClassifier: opts.prClassifier }, c),
3389
3401
  sweepFinished: (issue) => sweepBetweenLaneIssues(issue, planned.lane, repoRoot, ghRepo, paths).then(() => { }),
3390
3402
  reapWorktree: (issue) => reapParkedLaneWorktree(issue, repoRoot, ghRepo, paths, opts.branchPrefix === undefined ? undefined : branchPrefix),
3403
+ recordStackMerge: (issue, prNumber) => recordSliceMerged({ octokit: getOctokit(), repo: ghRepo, issue, prNumber }).then(() => { }),
3391
3404
  waitMerge: (issue, branch, root, gh, p, d = {}) => waitForMerge(issue, branch, root, gh, p, { ...d, mergeOverrides: { auto: opts.autoMerge } }),
3392
3405
  }));
3393
3406
  }
@@ -3522,8 +3535,13 @@ export function laneQueueDeps(queue, lane) {
3522
3535
  countRemaining: async () => (await queue.list(lane)).length,
3523
3536
  };
3524
3537
  }
3525
- export function createQueuePreflightOps(octokit, owner, repo) {
3538
+ export function createQueuePreflightOps(octokit, owner, repo, opts = {}) {
3526
3539
  return {
3540
+ autoMergeStack: opts.autoMergeStack ?? false,
3541
+ getPullRefs: async (prNumber) => {
3542
+ const { data } = await octokit.rest.pulls.get({ owner, repo, pull_number: prNumber });
3543
+ return { headRef: data.head.ref, baseRef: data.base.ref, headSha: data.head.sha };
3544
+ },
3527
3545
  expectedHeadRepoFullName: `${owner}/${repo}`,
3528
3546
  findOpenPR: (issue) => findOpenPRForIssue(octokit, owner, repo, issue),
3529
3547
  getLandState: (prNumber) => getPullRequestLandState(octokit, owner, repo, prNumber),
@@ -3540,8 +3558,10 @@ export function createQueuePreflightOps(octokit, owner, repo) {
3540
3558
  },
3541
3559
  };
3542
3560
  }
3543
- /** ADR-0147: wait on the current slice PR, record its merge, or park when it was closed unmerged.
3544
- * Returns undefined to fall through to the Closes-PR preflight. */
3561
+ /** ADR-0147/ADR-0161: walk every pr-open slice PR bottom-up. Merged layers are recorded, a closed layer parks
3562
+ * (naming the layers above it), and the lowest open layer defers, or is adopted when stack auto-merge is on and
3563
+ * its base is main with green CI on the rebased head. Returns undefined to fall through to the Closes-PR
3564
+ * preflight only when no slice PR is still open. */
3545
3565
  async function preflightSlice(issue, deps) {
3546
3566
  let lookup;
3547
3567
  try {
@@ -3559,36 +3579,80 @@ async function preflightSlice(issue, deps) {
3559
3579
  if (lookup.status === 'not-found')
3560
3580
  return undefined;
3561
3581
  const { plan } = lookup;
3562
- const slice = currentSlice(plan);
3563
- if (slice === undefined || slice.state !== 'pr-open')
3582
+ const n = plan.slices.length;
3583
+ const open = plan.slices.filter((s) => s.state === 'pr-open');
3584
+ if (open.length === 0)
3564
3585
  return undefined;
3565
- const label = `slice ${slice.index}/${plan.slices.length}`;
3566
- if (slice.prNumber === undefined) {
3567
- return { kind: 'park', reason: `${label} of #${issue} is pr-open but the slice plan records no PR number` };
3568
- }
3569
- const pr = slice.prNumber;
3570
- let state;
3571
- try {
3572
- state = await deps.getPullState(pr);
3573
- }
3574
- catch (err) {
3575
- return { kind: 'park', reason: `${label} PR #${pr} state lookup failed: ${errorDetail(err)}` };
3576
- }
3577
- if (state === 'open')
3578
- return { kind: 'defer' };
3579
- if (state === 'closed') {
3580
- return { kind: 'park', reason: `${label} PR #${pr} was closed without merging` };
3581
- }
3582
- try {
3583
- await deps.saveSlicePlan(issue, withSliceState(plan, slice.index, 'merged', pr));
3584
- }
3585
- catch (err) {
3586
- return {
3587
- kind: 'park',
3588
- reason: `${label} PR #${pr} merged but updating the slice plan failed: ${errorDetail(err)}`,
3589
- };
3586
+ let next = plan;
3587
+ let lastMerged;
3588
+ const save = async () => {
3589
+ if (lastMerged === undefined)
3590
+ return undefined;
3591
+ try {
3592
+ await deps.saveSlicePlan(issue, next);
3593
+ }
3594
+ catch (err) {
3595
+ return {
3596
+ kind: 'park',
3597
+ reason: `${lastMerged.label} PR #${lastMerged.pr} merged but updating the slice plan failed: ${errorDetail(err)}`,
3598
+ };
3599
+ }
3600
+ return undefined;
3601
+ };
3602
+ for (const [at, slice] of open.entries()) {
3603
+ const label = `slice ${slice.index}/${n}`;
3604
+ if (slice.prNumber === undefined) {
3605
+ return { kind: 'park', reason: `${label} of #${issue} is pr-open but the slice plan records no PR number` };
3606
+ }
3607
+ const pr = slice.prNumber;
3608
+ let state;
3609
+ try {
3610
+ state = await deps.getPullState(pr);
3611
+ }
3612
+ catch (err) {
3613
+ return { kind: 'park', reason: `${label} PR #${pr} state lookup failed: ${errorDetail(err)}` };
3614
+ }
3615
+ if (state === 'merged') {
3616
+ next = withSliceState(next, slice.index, 'merged', pr);
3617
+ lastMerged = { label, pr };
3618
+ continue;
3619
+ }
3620
+ if (state === 'closed') {
3621
+ const above = open.slice(at + 1);
3622
+ const suffix = above.length > 0
3623
+ ? `; layers above it: ${above.map((a) => `slice ${a.index}/${n} (#${a.prNumber})`).join(', ')}`
3624
+ : '';
3625
+ return { kind: 'park', reason: `${label} PR #${pr} was closed without merging${suffix}` };
3626
+ }
3627
+ const saved = await save();
3628
+ if (saved)
3629
+ return saved;
3630
+ if (!deps.autoMergeStack)
3631
+ return { kind: 'defer' };
3632
+ let refs;
3633
+ try {
3634
+ refs = await deps.getPullRefs(pr);
3635
+ }
3636
+ catch (err) {
3637
+ return { kind: 'park', reason: `${label} PR #${pr} ref lookup failed: ${errorDetail(err)}` };
3638
+ }
3639
+ // GitHub has not retargeted the layer to main yet: the lower layer is still unmerged.
3640
+ if (refs.baseRef !== 'main')
3641
+ return { kind: 'defer' };
3642
+ let outcome;
3643
+ try {
3644
+ outcome = await deps.watch(refs.headSha);
3645
+ }
3646
+ catch (err) {
3647
+ return { kind: 'park', reason: `CI watch for ${label} PR #${pr} failed: ${errorDetail(err)}` };
3648
+ }
3649
+ if (outcome === 'failure')
3650
+ return { kind: 'park', reason: `CI for ${label} PR #${pr} ended failure` };
3651
+ if (outcome !== 'success')
3652
+ return { kind: 'defer' };
3653
+ return { kind: 'adopt', branch: refs.headRef, stackLayer: { index: slice.index, count: n, prNumber: pr } };
3590
3654
  }
3591
- return undefined;
3655
+ return (await save()) ?? undefined;
3592
3656
  }
3593
3657
  /** Resolve queued work from GitHub evidence without consuming model or worktree resources. */
3594
3658
  export async function preflightQueuedIssue(issue, deps) {
@@ -3690,7 +3754,7 @@ export function renderLanePausedComment(payload) {
3690
3754
  ].join('\n\n');
3691
3755
  }
3692
3756
  export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {}) {
3693
- const { ship = shipIssue, waitMerge = waitForMerge, pathExists = existsSync, emitEvent = logEvent, claimNext = async () => null, releaseIssue = async () => { }, heartbeat, reapWorktree = async () => { }, sweepFinished = async () => { }, countRemaining, baseHoldGate, laneBreakerThreshold = 0, commentIssue = async () => { }, } = deps;
3757
+ const { ship = shipIssue, waitMerge = waitForMerge, recordStackMerge = async () => { }, pathExists = existsSync, emitEvent = logEvent, claimNext = async () => null, releaseIssue = async () => { }, heartbeat, reapWorktree = async () => { }, sweepFinished = async () => { }, countRemaining, baseHoldGate, laneBreakerThreshold = 0, commentIssue = async () => { }, } = deps;
3694
3758
  let streak = null;
3695
3759
  let merged = 0;
3696
3760
  let awaitingReview = 0;
@@ -3752,6 +3816,7 @@ export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {})
3752
3816
  streak = null;
3753
3817
  continue;
3754
3818
  }
3819
+ const layer = decision.kind === 'adopt' ? decision.stackLayer : undefined;
3755
3820
  try {
3756
3821
  const sliceOutcome = {};
3757
3822
  const branch = decision.kind === 'adopt'
@@ -3774,6 +3839,15 @@ export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {})
3774
3839
  await settle(issue, 'queued');
3775
3840
  continue;
3776
3841
  }
3842
+ if (layer) {
3843
+ // ADR-0161: land exactly this layer's branch, record it, and re-queue for the next layer.
3844
+ await withHeartbeat(issue, heartbeat, () => waitMerge(issue, branch, repoRoot, ghRepo, paths, { landBranch: branch }));
3845
+ await recordStackMerge(issue, layer.prNumber).catch((err) => emitEvent(paths.events, 'warn', issue, `slice ${layer.index}/${layer.count} merged but recording it failed: ${errorDetail(err)}`, { lane }));
3846
+ merged++;
3847
+ streak = null;
3848
+ await settle(issue, layer.index < layer.count ? 'queued' : 'done');
3849
+ continue;
3850
+ }
3777
3851
  await withHeartbeat(issue, heartbeat, () => waitMerge(issue, branch, repoRoot, ghRepo, paths));
3778
3852
  merged++;
3779
3853
  streak = null;
@@ -3785,7 +3859,7 @@ export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {})
3785
3859
  // worktree — this is a clean outcome, not a park; move to the next issue.
3786
3860
  awaitingReview++;
3787
3861
  streak = null;
3788
- await settle(issue, 'done');
3862
+ await settle(issue, layer && layer.index < layer.count ? 'queued' : 'done');
3789
3863
  continue;
3790
3864
  }
3791
3865
  if (err instanceof IssueSkippedError) {
@@ -4295,7 +4369,7 @@ async function defaultListIssueLabels(octokit, owner, repo, issue) {
4295
4369
  return data.map((label) => label.name);
4296
4370
  }
4297
4371
  export async function waitForMerge(issue, branch, repoRoot, ghRepo, paths, deps = {}) {
4298
- const { createOctokit = getOctokit, pathExists = existsSync, checkMerged = isPrMerged, loadConfig = configLoaders.loadFactoryConfigForRepo, land = landIssue, listIssueLabels = defaultListIssueLabels, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), emitEvent = logEvent, mergeEnabled, writeLine = (line) => console.log(line), now = () => Date.now(), mergeOverrides, } = deps;
4372
+ const { createOctokit = getOctokit, pathExists = existsSync, checkMerged = isPrMerged, loadConfig = configLoaders.loadFactoryConfigForRepo, land = landIssue, listIssueLabels = defaultListIssueLabels, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), emitEvent = logEvent, mergeEnabled, writeLine = (line) => console.log(line), now = () => Date.now(), mergeOverrides, landBranch, } = deps;
4299
4373
  const factoryConfig = loadConfig(paths.config);
4300
4374
  const isMergeEnabled = mergeEnabled ?? (() => resolveMergePolicy(factoryConfig, process.env, mergeOverrides).auto);
4301
4375
  const skipCI = configLoaders.resolveSkipCI(factoryConfig);
@@ -4353,7 +4427,10 @@ export async function waitForMerge(issue, branch, repoRoot, ghRepo, paths, deps
4353
4427
  await sleep(120_000);
4354
4428
  continue;
4355
4429
  }
4356
- await land(issue, repoRoot, ghRepo, paths, octokit, skipCI);
4430
+ if (landBranch)
4431
+ await land(issue, repoRoot, ghRepo, paths, octokit, skipCI, undefined, { branch: landBranch });
4432
+ else
4433
+ await land(issue, repoRoot, ghRepo, paths, octokit, skipCI);
4357
4434
  return;
4358
4435
  }
4359
4436
  writeLine(`[factory] #${issue} awaiting human merge (poll 120s)`);
@@ -4521,6 +4598,8 @@ async function cmdDoctor(opts = {}) {
4521
4598
  },
4522
4599
  fileExists: existsSync,
4523
4600
  }),
4601
+ claudeSessionState: () => claudeStoredSessionState(),
4602
+ localAuthFailure: (agent) => localAuthFailureSinceLogin(agent),
4524
4603
  opencodeLoggedIn: () => hasOpencodeLoginEvidence(),
4525
4604
  commandHarnesses: () => repoRoot === null
4526
4605
  ? []