@on-par/factory-cli 0.9.4 → 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
@@ -5,11 +5,11 @@ import { randomUUID } from 'node:crypto';
5
5
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
6
6
  import { createRequire } from 'node:module';
7
7
  import { arch, hostname as osHostname, release, platform, userInfo } from 'node:os';
8
- import { basename, dirname, join, relative, resolve } from 'node:path';
8
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
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
- 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, 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, 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, createPlanReuseStore, checkBaseHealth, currentSlice, 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, 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';
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, 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';
@@ -29,7 +29,7 @@ import { buildUsageJson } from './usage-json.js';
29
29
  import { parseResetIssues, runReset } from './reset.js';
30
30
  import { cmdDaemonLogs, cmdDaemonStart, cmdDaemonStatus, cmdDaemonStop, createAutoUpdateDeps, DaemonCtlError, detectInstall, loadDaemonFileConfig, recoverStaleDrain, resolveFactorydConfig, runAutoUpdateLoop, } from './daemon.js';
31
31
  import { checkForUpdate, cmdSelfUpdateCheck } from './self-update.js';
32
- import { analyzeEventLog, claudeKeychainCheck, doctorFailed, eventLogCheck, formatClaimReconcileReport, formatContainerReconcileReport, formatDoctorChecks, formatMicroVmReconcileReport, formatReconcileReport, formatWorktreeReconcileReport, declaredHarnessChecks, hasSandboxRuntime, leaseChecks, keychainPreflightError, orphanContainerChecks, modelsRegistryChecks, orphanMicroVmChecks, runDoctorChecks, sandboxAuthSkipDetail, reconcileAgentAuthChecks, sandboxClaudeAuthChecks, unmergedGreenPrChecks, } from './doctor.js';
32
+ import { analyzeEventLog, claudeKeychainCheck, doctorFailed, eventLogCheck, formatClaimReconcileReport, formatContainerReconcileReport, formatDoctorChecks, formatMicroVmReconcileReport, formatReconcileReport, formatWorktreeReconcileReport, declaredHarnessChecks, hasSandboxRuntime, leaseChecks, keychainPreflightError, orphanContainerChecks, configLintChecks, modelsRegistryChecks, orphanMicroVmChecks, runDoctorChecks, sandboxAuthSkipDetail, reconcileAgentAuthChecks, repoCommandHarnesses, sandboxClaudeAuthChecks, unmergedGreenPrChecks, } from './doctor.js';
33
33
  import { DOCS_URL, formatOverview, availableAgentClis, missingAgentCliMessage, missingTokenMessage, notInitializedMessage, } from './first-run.js';
34
34
  import { cmdHostedSmoke } from './hosted.js';
35
35
  import { cmdHostedQueue } from './hosted-queue.js';
@@ -40,6 +40,7 @@ import { ensureFactoryExcluded, writeSampleQueue } from './init-files.js';
40
40
  import { cmdLogs } from './logs.js';
41
41
  import { applyHelpGroups } from './help-groups.js';
42
42
  import { mergeScopeNotice } from './merge-scope.js';
43
+ import { GITHUB_STUB_CALLS_FILE, recordGitPushes, resolveGitHubMode, selectOctokit } from './github-stub.js';
43
44
  import { createFactoryOctokit, formatDeprecation } from './octokit.js';
44
45
  import { DeepCheckError, createDeepModelRunner, runIssueCheck } from './ready-check.js';
45
46
  import { childRunSucceeded, formatChildRunSummary, runChildrenInOrder } from './run-children.js';
@@ -61,6 +62,11 @@ const DEFAULT_CONFIG_LOADERS = {
61
62
  /** Loaders for the current main() invocation; DEFAULT_CONFIG_LOADERS outside main(). */
62
63
  let configLoaders = DEFAULT_CONFIG_LOADERS;
63
64
  /** Per-field `??` so an explicit `undefined` override falls back instead of replacing the default. */
65
+ /** Display path for the repo config file in preflight messages: repo-relative, or absolute when the state root lives outside the repo (#2586). */
66
+ function repoConfigFileFor(repoRoot, absPath) {
67
+ const rel = relative(repoRoot, absPath);
68
+ return { absPath, relPath: rel === '' || rel.startsWith('..') || isAbsolute(rel) ? absPath : rel };
69
+ }
64
70
  function resolveConfigLoaders(overrides, base = configLoaders) {
65
71
  return {
66
72
  loadModelsConfig: overrides.loadModelsConfig ?? base.loadModelsConfig,
@@ -223,13 +229,39 @@ async function getRepoRoot(shell = shellOps) {
223
229
  throw new CliExitError('factory: not inside a git repository', 2);
224
230
  }
225
231
  }
232
+ const GH_UNAUTHENTICATED = /gh auth login|not logged in/i;
233
+ const GH_TOKEN_REJECTED = /bad credentials|HTTP 401|401 Unauthorized/i;
234
+ const GH_NO_GITHUB_REMOTE = /no git remotes found|point to a known GitHub host/i;
235
+ /**
236
+ * CliExitError message for a failed `gh repo view`; names a rejected token and its `repo` scope when gh reports a 401 (#2593),
237
+ * and gh auth when gh's stderr says it is logged out (#2592). The 401 check runs first because gh's 401 output also says "gh auth login".
238
+ * gh's known-host remote error is checked before the auth pattern because it also names `gh auth login` (#2594).
239
+ */
240
+ export function ghRepoViewFailureMessage(stderr) {
241
+ const detail = stderr
242
+ ?.trim()
243
+ .split('\n')
244
+ .find((l) => l.trim())
245
+ ?.trim();
246
+ if (stderr && GH_TOKEN_REJECTED.test(stderr)) {
247
+ return `factory: GitHub rejected the token (GH_TOKEN/GITHUB_TOKEN); use a token with the \`repo\` scope${detail ? ` (gh repo view failed: ${detail})` : ''}`;
248
+ }
249
+ if (stderr && GH_NO_GITHUB_REMOTE.test(stderr)) {
250
+ return 'factory: no GitHub remote detected (gh repo view failed)';
251
+ }
252
+ if (stderr && GH_UNAUTHENTICATED.test(stderr)) {
253
+ return `factory: gh is not authenticated, run \`gh auth login\`${detail ? ` (gh repo view failed: ${detail})` : ''}`;
254
+ }
255
+ return 'factory: no GitHub remote detected (gh repo view failed)';
256
+ }
226
257
  async function getGitHubRepo(shell = shellOps) {
227
258
  try {
228
259
  const { stdout } = await shell.exec('gh repo view --json nameWithOwner --jq .nameWithOwner');
229
260
  return stdout.trim();
230
261
  }
231
- catch {
232
- throw new CliExitError('factory: no GitHub remote detected (gh repo view failed)', 2);
262
+ catch (err) {
263
+ const stderr = err?.stderr;
264
+ throw new CliExitError(ghRepoViewFailureMessage(typeof stderr === 'string' ? stderr : undefined), 2);
233
265
  }
234
266
  }
235
267
  /** Env token first, then one `gh auth token` subprocess (≤5 s). `undefined` when neither yields one. */
@@ -247,7 +279,9 @@ function resolveGitHubToken(shell = shellOps) {
247
279
  /** Octokit factory for the current main() or cmdLand invocation (CliDeps.octokit); unset outside them. */
248
280
  let octokitFactory;
249
281
  function getOctokit(shell = shellOps, options) {
250
- return octokitFactory ? octokitFactory() : createFactoryOctokit(resolveGitHubToken(shell), options);
282
+ return octokitFactory
283
+ ? octokitFactory()
284
+ : selectOctokit(process.env, () => createFactoryOctokit(resolveGitHubToken(shell), options));
251
285
  }
252
286
  export function errorDetail(err) {
253
287
  const e = err;
@@ -868,6 +902,7 @@ async function cmdCheck(issueRaw, opts) {
868
902
  const repoConfig = configLoaders.loadRepoConfig(repoRoot);
869
903
  const effective = resolveEffectiveConfig(repoConfig);
870
904
  const router = new coreOps.ModelRouter(applyRepoConfig(configLoaders.loadModelsConfig(), repoConfig), configLoaders.loadRoutesConfig(), false, undefined, effective.allowExperimental, effective.localOnly);
905
+ router.setLocalAuthSink?.(recordLocalAuthFailure);
871
906
  runModel = createDeepModelRunner(router, repoRoot);
872
907
  }
873
908
  let report;
@@ -971,6 +1006,7 @@ async function cmdClassifierBacktestEntry(opts) {
971
1006
  const repoConfig = configLoaders.loadRepoConfig(repoRoot);
972
1007
  const effective = resolveEffectiveConfig(repoConfig);
973
1008
  const router = new coreOps.ModelRouter(applyRepoConfig(configLoaders.loadModelsConfig(), repoConfig), configLoaders.loadRoutesConfig(), false, undefined, effective.allowExperimental, effective.localOnly);
1009
+ router.setLocalAuthSink?.(recordLocalAuthFailure);
974
1010
  try {
975
1011
  await cmdClassifierBacktest({ ...opts, branchPrefix }, {
976
1012
  repoRoot,
@@ -1213,6 +1249,7 @@ export async function cmdStatus(opts = {}) {
1213
1249
  const routesConfig = configLoaders.loadRoutesConfig();
1214
1250
  const effective = resolveEffectiveConfig(repoConfig);
1215
1251
  const router = new coreOps.ModelRouter(modelsConfig, routesConfig, false, undefined, effective.allowExperimental, effective.localOnly);
1252
+ router.setLocalAuthSink?.(recordLocalAuthFailure);
1216
1253
  const product = readActiveProduct(paths.product) ?? '(none)';
1217
1254
  console.log(styleText('bold', `== ${ghRepo} ==`));
1218
1255
  console.log(`Product: ${product}`);
@@ -1391,6 +1428,7 @@ async function cmdTui(opts = {}, runTuiImpl = runTui) {
1391
1428
  const routesConfig = configLoaders.loadRoutesConfig();
1392
1429
  const effective = resolveEffectiveConfig(repoConfig);
1393
1430
  const router = new coreOps.ModelRouter(modelsConfig, routesConfig, false, undefined, effective.allowExperimental, effective.localOnly);
1431
+ router.setLocalAuthSink?.(recordLocalAuthFailure);
1394
1432
  const effectiveConfigLines = describeEffectiveConfig({
1395
1433
  router,
1396
1434
  repo: repoConfig,
@@ -1448,8 +1486,12 @@ export class LaneEnvironmentPauseError extends Error {
1448
1486
  baseSha;
1449
1487
  failingChecks;
1450
1488
  failureSignature;
1489
+ /** BUILD output was kept for `factory resume --from check` (#2564); runLane must not reap it. */
1490
+ buildPreserved;
1451
1491
  constructor(message, failure) {
1452
1492
  super(message);
1493
+ if (failure.buildPreserved !== undefined)
1494
+ this.buildPreserved = failure.buildPreserved;
1453
1495
  this.baseSha = failure.baseSha;
1454
1496
  this.failingChecks = failure.failingChecks;
1455
1497
  if (failure.failureSignature !== undefined)
@@ -1543,6 +1585,15 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1543
1585
  const shell = resolveShellOps(deps);
1544
1586
  const core = resolveCoreOps(deps);
1545
1587
  const phases = resolvePhaseOps(deps);
1588
+ // FACTORY_GITHUB=stub (#2652): record each `git push` ship runs in the stub's calls.jsonl.
1589
+ let shipPhaseForMode = phases.shipPhase;
1590
+ if (resolveGitHubMode(process.env) === 'stub') {
1591
+ const base = phases.shipPhase;
1592
+ const stubCalls = join(resolve(process.env.FACTORY_GITHUB_STUB_DIR ?? ''), GITHUB_STUB_CALLS_FILE);
1593
+ const defaultShipRunner = promisify(execCb);
1594
+ const wrapped = (o) => base({ ...o, run: recordGitPushes(o.run ?? defaultShipRunner, stubCalls, o.branch) });
1595
+ shipPhaseForMode = wrapped;
1596
+ }
1546
1597
  const repoRoot = ctx?.repoRoot ?? (await getRepoRoot(shell));
1547
1598
  const ghRepo = ctx?.ghRepo ?? (await getGitHubRepo(shell));
1548
1599
  const paths = ctx?.paths ?? getFactoryPaths(repoRoot);
@@ -1571,6 +1622,7 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1571
1622
  : { ...resolveEffectiveConfig(repoConfig), branchPrefix: opts.branchPrefix },
1572
1623
  };
1573
1624
  const router = new core.ModelRouter(policy.models, policy.routes, false, undefined, policy.effective.allowExperimental, policy.effective.localOnly);
1625
+ router.setLocalAuthSink?.(recordLocalAuthFailure);
1574
1626
  router.skipMissingHarnessClis(core.isCommandAvailable);
1575
1627
  let issueSpend = 0;
1576
1628
  let laneSandboxRuntime = 'none';
@@ -1600,7 +1652,11 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1600
1652
  const maxSlices = opts.maxSlices ?? resolveSizeGateMaxSlices(factoryConfig);
1601
1653
  // ADR-0147: slice k >= 2 builds on its own branch, so read the slice plan before the worktree exists.
1602
1654
  let sliceIndex;
1603
- if (sizeGateMode === 'slice' && !ctx?.localOnly && work.kind === GITHUB_ISSUE_SOURCE) {
1655
+ // ADR-0161: stack runs as slice until stacked PRs are built.
1656
+ if ((sizeGateMode === 'slice' || sizeGateMode === 'stack') &&
1657
+ !ctx?.localOnly &&
1658
+ !ctx?.resumeFrom &&
1659
+ work.kind === GITHUB_ISSUE_SOURCE) {
1604
1660
  try {
1605
1661
  const lookup = await findSlicePlanComment({ octokit, repo: ghRepo, issue: issueNum });
1606
1662
  if (lookup.status === 'found')
@@ -1610,10 +1666,12 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1610
1666
  throw new LaneParkError(`slice plan lookup failed for #${issueNum}: ${errorDetail(err)} — parked before creating a worktree`, 'escalate');
1611
1667
  }
1612
1668
  }
1613
- const branch = branchFor(issueNum, issueTitle, policy.effective.branchPrefix, sliceIndex);
1614
- const worktree = ctx?.localOnly
1615
- ? ctx.localOnly.workspace
1616
- : worktreePathFor(repoRoot, ghRepo, issueNum, policy.effective.branchPrefix, loaders);
1669
+ const branch = ctx?.resumeFrom?.branch ?? branchFor(issueNum, issueTitle, policy.effective.branchPrefix, sliceIndex);
1670
+ const worktree = ctx?.resumeFrom
1671
+ ? ctx.resumeFrom.worktree
1672
+ : ctx?.localOnly
1673
+ ? ctx.localOnly.workspace
1674
+ : worktreePathFor(repoRoot, ghRepo, issueNum, policy.effective.branchPrefix, loaders);
1617
1675
  const specPath = resolve(paths.plans, `issue-${issueNum}.md`);
1618
1676
  const runStartedAt = new Date().toISOString();
1619
1677
  const lane = ctx?.lane;
@@ -1628,6 +1686,7 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1628
1686
  'escalate',
1629
1687
  'held',
1630
1688
  'conflict',
1689
+ 'config',
1631
1690
  'ci-failed',
1632
1691
  'ci-infra',
1633
1692
  'timeout',
@@ -1710,7 +1769,11 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1710
1769
  // stays a CLI concern (not called here, matching today — a worktree persists past
1711
1770
  // a successful run for GC/sweep commands to reclaim later).
1712
1771
  let workspace;
1713
- if (!ctx?.localOnly) {
1772
+ if (ctx?.resumeFrom) {
1773
+ workspace = { path: worktree, dispose: async () => { } };
1774
+ log('worktree', `reusing kept worktree at ${worktree} (resume --from check)`);
1775
+ }
1776
+ else if (!ctx?.localOnly) {
1714
1777
  workspace = await worktreeWorkspace({
1715
1778
  repoRoot,
1716
1779
  branch,
@@ -1791,9 +1854,21 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1791
1854
  sandboxPolicy: activeSandboxPolicy,
1792
1855
  processGroupGraceMs,
1793
1856
  preferredRoute: repoConfig?.route,
1857
+ ...(ctx?.resumeFrom
1858
+ ? {
1859
+ resumeFrom: {
1860
+ phase: 'check',
1861
+ diffBase: ctx.resumeFrom.diffBase,
1862
+ route: ctx.resumeFrom.route,
1863
+ model: ctx.resumeFrom.model,
1864
+ buildMinutes: ctx.resumeFrom.buildMinutes,
1865
+ },
1866
+ }
1867
+ : {}),
1794
1868
  eventsFile: paths.events,
1795
1869
  logsDir: paths.logs,
1796
1870
  baselineCachePath: paths.baselineCache,
1871
+ repoConfigFile: repoConfigFileFor(repoRoot, paths.config),
1797
1872
  flakyLedgerPath: paths.flakyLedger,
1798
1873
  };
1799
1874
  const ports = {
@@ -1806,10 +1881,11 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1806
1881
  getIssueSpend: () => issueSpend,
1807
1882
  breaker,
1808
1883
  planReuse: ctx?.localOnly ? undefined : createPlanReuseStore(specPath),
1884
+ checkResume: ctx?.localOnly ? undefined : createCheckResumeStore(specPath),
1809
1885
  planPhase: phases.planPhase,
1810
1886
  buildPhase: phases.buildPhase,
1811
1887
  checkPhase: phases.checkPhase,
1812
- shipPhase: phases.shipPhase,
1888
+ shipPhase: shipPhaseForMode,
1813
1889
  checkBaseHealth: deps.checkBaseHealth ?? checkBaseHealth,
1814
1890
  resolveConstitution: () =>
1815
1891
  // Resolved once here — runIssue calls this exactly once and reuses the value for
@@ -1884,7 +1960,8 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1884
1960
  const egressProxy = proxied
1885
1961
  ? await startEgressProxy({
1886
1962
  allowHosts: proxied.allowHosts,
1887
- onHost: (host, allowed) => log('egress_host', `${host} (${allowed ? 'on allowlist' : 'not on allowlist'})`),
1963
+ blockTelemetry: proxied.blockTelemetry,
1964
+ onHost: (host, allowed) => log('egress_host', egressHostMessage(host, allowed)),
1888
1965
  })
1889
1966
  : undefined;
1890
1967
  let outcome;
@@ -1918,6 +1995,7 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1918
1995
  baseSha: outcome.baseSha,
1919
1996
  failingChecks: outcome.failingChecks,
1920
1997
  failureSignature: outcome.failureSignature,
1998
+ buildPreserved: outcome.buildPreserved,
1921
1999
  });
1922
2000
  }
1923
2001
  const reason = outcome.state === 'escalated' ? 'escalate' : outcome.reason;
@@ -2522,7 +2600,10 @@ async function landIssue(issueNum, repoRoot, ghRepo, paths, octokit, skipCI, bra
2522
2600
  // Legacy-prefix (LEGACY_BRANCH_PREFIX) branches are guessed too, after the resolved prefix (#1708).
2523
2601
  const resolved = branchPrefix ?? resolveEffectiveConfig(configLoaders.loadRepoConfig(repoRoot)).branchPrefix;
2524
2602
  const title = await getIssueTitle(ghRepo, issueNum, octokit);
2525
- 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));
2526
2607
  const guessedBranch = guesses[0];
2527
2608
  let branch = guessedBranch;
2528
2609
  let prNumber;
@@ -2538,7 +2619,7 @@ async function landIssue(issueNum, repoRoot, ghRepo, paths, octokit, skipCI, bra
2538
2619
  return undefined;
2539
2620
  };
2540
2621
  [, prNumber] = await Promise.all([gitOps.gitFetch(repoRoot), findByGuess()]);
2541
- if (!prNumber) {
2622
+ if (!prNumber && !opts.branch) {
2542
2623
  const fallback = await findOpenPRForIssue(octokit, owner, repoName, issueNum);
2543
2624
  if (fallback) {
2544
2625
  branch = fallback.branch;
@@ -2650,6 +2731,7 @@ async function cmdTriage(opts) {
2650
2731
  const routesConfig = configLoaders.loadRoutesConfig();
2651
2732
  const effective = resolveEffectiveConfig(repoConfig);
2652
2733
  const router = new coreOps.ModelRouter(modelsConfig, routesConfig, false, undefined, effective.allowExperimental, effective.localOnly);
2734
+ router.setLocalAuthSink?.(recordLocalAuthFailure);
2653
2735
  router.skipMissingHarnessClis(coreOps.isCommandAvailable);
2654
2736
  router.setCostSink((entry) => logCost(paths.costs, { ...entry, issue: '-' }));
2655
2737
  let plannerError;
@@ -2667,7 +2749,7 @@ async function cmdTriage(opts) {
2667
2749
  model = result.model;
2668
2750
  const queue = normalizeTriageQueue(result.output);
2669
2751
  if (queue) {
2670
- const header = `# Triage queue (model ${model}${product ? `, constitution ${product}` : ''})\n`;
2752
+ const header = triageQueueHeader(model, product);
2671
2753
  writeFileSync(paths.queueProposed, header + queue);
2672
2754
  }
2673
2755
  }
@@ -2686,6 +2768,9 @@ async function cmdTriage(opts) {
2686
2768
  throw triageNoProposalError(plannerError);
2687
2769
  }
2688
2770
  }
2771
+ export function triageQueueHeader(model, product) {
2772
+ return `# Triage queue (model ${model}${product ? `, constitution ${product}` : ''})\n`;
2773
+ }
2689
2774
  export function triageProposalMessage(proposed, proposedPath, queuePath) {
2690
2775
  if (!proposed.trim())
2691
2776
  return null;
@@ -2695,13 +2780,13 @@ export async function cmdTriageAccept(opts) {
2695
2780
  const repoRoot = await getRepoRoot();
2696
2781
  const paths = getFactoryPaths(repoRoot);
2697
2782
  if (!existsSync(paths.queueProposed)) {
2698
- console.log(`nothing to accept — ${paths.queueProposed} not found`);
2783
+ console.log(`nothing to accept: ${paths.queueProposed} not found`);
2699
2784
  return; // zero exit
2700
2785
  }
2701
2786
  const content = readFileSync(paths.queueProposed, 'utf-8');
2702
2787
  const result = validateQueue(content);
2703
2788
  if (!opts.force && !result.ok) {
2704
- throw new CliExitError(`factory: proposed queue is invalid — ${paths.queueProposed} left unchanged\n` +
2789
+ throw new CliExitError(`factory: proposed queue is invalid: ${paths.queueProposed} left unchanged\n` +
2705
2790
  result.errors.map((e) => ` - ${e}`).join('\n'), 1);
2706
2791
  }
2707
2792
  renameSync(paths.queueProposed, paths.queue); // same dir → atomic
@@ -2714,7 +2799,7 @@ export async function cmdTriageAccept(opts) {
2714
2799
  }
2715
2800
  const suffix = opts.force ? ' (--force, validation skipped)' : '';
2716
2801
  logEvent(paths.events, 'triage_accepted', '-', `accepted ${result.issues.length} issue(s) [${result.issues.join(', ')}] by ${acceptedBy}${suffix}`);
2717
- console.log(styleText('green', `queue accepted — ${result.issues.length} issue(s) promoted to ${paths.queue}`));
2802
+ console.log(styleText('green', `queue accepted: ${result.issues.length} issue(s) promoted to ${paths.queue}`));
2718
2803
  }
2719
2804
  export async function cmdQueueMigrate(opts = {}) {
2720
2805
  const repoRoot = await getRepoRoot();
@@ -3196,7 +3281,10 @@ async function cmdRun(opts = {}) {
3196
3281
  repo,
3197
3282
  preflight: withAdmissionGuard({
3198
3283
  reader: admissionStateReaderFor(paths.state),
3199
- 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
+ })),
3200
3288
  onConflict: (issue, verdict) => {
3201
3289
  const line = `#${issue} refused — ${verdict.reason} | repair: ${verdict.repair}`;
3202
3290
  console.error(styleText('yellow', `factory: ${line}`));
@@ -3231,6 +3319,27 @@ async function cmdRun(opts = {}) {
3231
3319
  console.log(`[factory] run: ${detail}`);
3232
3320
  logEvent(paths.events, 'watchdog', 'all', detail);
3233
3321
  }
3322
+ let watchdogHarness;
3323
+ if (knobs.watch) {
3324
+ try {
3325
+ const repoConfig = configLoaders.loadRepoConfig(repoRoot);
3326
+ const registry = new coreOps.ModelRegistry(applyRepoConfig(configLoaders.loadModelsConfig(), repoConfig));
3327
+ const pins = configLoaders.resolveEffectiveModelPins(registry, repoConfig, process.env);
3328
+ const build = resolveEffectiveBuildRoute(registry, repoConfig, pins);
3329
+ watchdogHarness = resolveRunUsageHarness({
3330
+ buildRoute: build.route,
3331
+ buildRouteSource: build.source,
3332
+ planHarnesses: [pins.plan, pins.planFallback]
3333
+ .filter((m) => !!m)
3334
+ .map((m) => registry.getHarnessId(m)),
3335
+ codexDisabled: resolveCodexDisabled(repoConfig),
3336
+ localOnly: resolveLocalOnly(),
3337
+ });
3338
+ }
3339
+ catch {
3340
+ watchdogHarness = undefined; // unresolvable config keeps today's Claude gating
3341
+ }
3342
+ }
3234
3343
  const controller = new AbortController();
3235
3344
  const watchdog = knobs.watch
3236
3345
  ? coreOps
@@ -3242,6 +3351,7 @@ async function cmdRun(opts = {}) {
3242
3351
  eventsFile: paths.events,
3243
3352
  signal: controller.signal,
3244
3353
  estimator: knobs.estimator,
3354
+ ...(watchdogHarness ? { harness: watchdogHarness } : {}),
3245
3355
  })
3246
3356
  .catch((err) => {
3247
3357
  // a watchdog crash must never take down the run
@@ -3290,6 +3400,7 @@ async function cmdRun(opts = {}) {
3290
3400
  ship: (issue, o, c) => shipIssue(issue, { ...o, branchPrefix, prClassifier: opts.prClassifier }, c),
3291
3401
  sweepFinished: (issue) => sweepBetweenLaneIssues(issue, planned.lane, repoRoot, ghRepo, paths).then(() => { }),
3292
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(() => { }),
3293
3404
  waitMerge: (issue, branch, root, gh, p, d = {}) => waitForMerge(issue, branch, root, gh, p, { ...d, mergeOverrides: { auto: opts.autoMerge } }),
3294
3405
  }));
3295
3406
  }
@@ -3424,8 +3535,13 @@ export function laneQueueDeps(queue, lane) {
3424
3535
  countRemaining: async () => (await queue.list(lane)).length,
3425
3536
  };
3426
3537
  }
3427
- export function createQueuePreflightOps(octokit, owner, repo) {
3538
+ export function createQueuePreflightOps(octokit, owner, repo, opts = {}) {
3428
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
+ },
3429
3545
  expectedHeadRepoFullName: `${owner}/${repo}`,
3430
3546
  findOpenPR: (issue) => findOpenPRForIssue(octokit, owner, repo, issue),
3431
3547
  getLandState: (prNumber) => getPullRequestLandState(octokit, owner, repo, prNumber),
@@ -3442,8 +3558,10 @@ export function createQueuePreflightOps(octokit, owner, repo) {
3442
3558
  },
3443
3559
  };
3444
3560
  }
3445
- /** ADR-0147: wait on the current slice PR, record its merge, or park when it was closed unmerged.
3446
- * 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. */
3447
3565
  async function preflightSlice(issue, deps) {
3448
3566
  let lookup;
3449
3567
  try {
@@ -3461,36 +3579,80 @@ async function preflightSlice(issue, deps) {
3461
3579
  if (lookup.status === 'not-found')
3462
3580
  return undefined;
3463
3581
  const { plan } = lookup;
3464
- const slice = currentSlice(plan);
3465
- 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)
3466
3585
  return undefined;
3467
- const label = `slice ${slice.index}/${plan.slices.length}`;
3468
- if (slice.prNumber === undefined) {
3469
- return { kind: 'park', reason: `${label} of #${issue} is pr-open but the slice plan records no PR number` };
3470
- }
3471
- const pr = slice.prNumber;
3472
- let state;
3473
- try {
3474
- state = await deps.getPullState(pr);
3475
- }
3476
- catch (err) {
3477
- return { kind: 'park', reason: `${label} PR #${pr} state lookup failed: ${errorDetail(err)}` };
3478
- }
3479
- if (state === 'open')
3480
- return { kind: 'defer' };
3481
- if (state === 'closed') {
3482
- return { kind: 'park', reason: `${label} PR #${pr} was closed without merging` };
3483
- }
3484
- try {
3485
- await deps.saveSlicePlan(issue, withSliceState(plan, slice.index, 'merged', pr));
3486
- }
3487
- catch (err) {
3488
- return {
3489
- kind: 'park',
3490
- reason: `${label} PR #${pr} merged but updating the slice plan failed: ${errorDetail(err)}`,
3491
- };
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 } };
3492
3654
  }
3493
- return undefined;
3655
+ return (await save()) ?? undefined;
3494
3656
  }
3495
3657
  /** Resolve queued work from GitHub evidence without consuming model or worktree resources. */
3496
3658
  export async function preflightQueuedIssue(issue, deps) {
@@ -3592,7 +3754,7 @@ export function renderLanePausedComment(payload) {
3592
3754
  ].join('\n\n');
3593
3755
  }
3594
3756
  export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {}) {
3595
- 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;
3596
3758
  let streak = null;
3597
3759
  let merged = 0;
3598
3760
  let awaitingReview = 0;
@@ -3654,6 +3816,7 @@ export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {})
3654
3816
  streak = null;
3655
3817
  continue;
3656
3818
  }
3819
+ const layer = decision.kind === 'adopt' ? decision.stackLayer : undefined;
3657
3820
  try {
3658
3821
  const sliceOutcome = {};
3659
3822
  const branch = decision.kind === 'adopt'
@@ -3676,6 +3839,15 @@ export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {})
3676
3839
  await settle(issue, 'queued');
3677
3840
  continue;
3678
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
+ }
3679
3851
  await withHeartbeat(issue, heartbeat, () => waitMerge(issue, branch, repoRoot, ghRepo, paths));
3680
3852
  merged++;
3681
3853
  streak = null;
@@ -3687,7 +3859,7 @@ export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {})
3687
3859
  // worktree — this is a clean outcome, not a park; move to the next issue.
3688
3860
  awaitingReview++;
3689
3861
  streak = null;
3690
- await settle(issue, 'done');
3862
+ await settle(issue, layer && layer.index < layer.count ? 'queued' : 'done');
3691
3863
  continue;
3692
3864
  }
3693
3865
  if (err instanceof IssueSkippedError) {
@@ -3712,7 +3884,9 @@ export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {})
3712
3884
  // The base is broken, not the issue (#1928): release to queued (never parked),
3713
3885
  // pause the lane whatever laneBreakerThreshold is, and stop claiming.
3714
3886
  streak = null;
3715
- await reapWorktree(issue).catch(() => { });
3887
+ // A preserved build is kept for `factory resume --from check` (#2564).
3888
+ if (!err.buildPreserved)
3889
+ await reapWorktree(issue).catch(() => { });
3716
3890
  await settle(issue, 'queued');
3717
3891
  // Persist the hold so a restarted supervisor remembers the broken base (#2348);
3718
3892
  // best-effort, never allowed to change lane flow.
@@ -4195,7 +4369,7 @@ async function defaultListIssueLabels(octokit, owner, repo, issue) {
4195
4369
  return data.map((label) => label.name);
4196
4370
  }
4197
4371
  export async function waitForMerge(issue, branch, repoRoot, ghRepo, paths, deps = {}) {
4198
- 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;
4199
4373
  const factoryConfig = loadConfig(paths.config);
4200
4374
  const isMergeEnabled = mergeEnabled ?? (() => resolveMergePolicy(factoryConfig, process.env, mergeOverrides).auto);
4201
4375
  const skipCI = configLoaders.resolveSkipCI(factoryConfig);
@@ -4253,7 +4427,10 @@ export async function waitForMerge(issue, branch, repoRoot, ghRepo, paths, deps
4253
4427
  await sleep(120_000);
4254
4428
  continue;
4255
4429
  }
4256
- 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);
4257
4434
  return;
4258
4435
  }
4259
4436
  writeLine(`[factory] #${issue} awaiting human merge (poll 120s)`);
@@ -4383,6 +4560,18 @@ function probeClaudeKeychain() {
4383
4560
  }
4384
4561
  }
4385
4562
  async function cmdDoctor(opts = {}) {
4563
+ let repoRoot;
4564
+ try {
4565
+ repoRoot = shellOps
4566
+ .execSync('git rev-parse --show-toplevel', {
4567
+ timeout: 10_000,
4568
+ stdio: ['ignore', 'pipe', 'ignore'],
4569
+ })
4570
+ .trim();
4571
+ }
4572
+ catch {
4573
+ repoRoot = null;
4574
+ }
4386
4575
  const checks = runDoctorChecks({
4387
4576
  commandAvailable: coreOps.isCommandAvailable,
4388
4577
  envPresent: (key) => !!process.env[key],
@@ -4409,20 +4598,13 @@ async function cmdDoctor(opts = {}) {
4409
4598
  },
4410
4599
  fileExists: existsSync,
4411
4600
  }),
4601
+ claudeSessionState: () => claudeStoredSessionState(),
4602
+ localAuthFailure: (agent) => localAuthFailureSinceLogin(agent),
4412
4603
  opencodeLoggedIn: () => hasOpencodeLoginEvidence(),
4604
+ commandHarnesses: () => repoRoot === null
4605
+ ? []
4606
+ : repoCommandHarnesses(configLoaders.loadModelsConfig().harnesses, configLoaders.loadRepoConfig(repoRoot)?.models?.registry),
4413
4607
  });
4414
- let repoRoot;
4415
- try {
4416
- repoRoot = shellOps
4417
- .execSync('git rev-parse --show-toplevel', {
4418
- timeout: 10_000,
4419
- stdio: ['ignore', 'pipe', 'ignore'],
4420
- })
4421
- .trim();
4422
- }
4423
- catch {
4424
- repoRoot = null;
4425
- }
4426
4608
  // Only with --probe-inference: these send a real prompt (uses quota). Plain doctor makes no inference call.
4427
4609
  if (opts.probeInference) {
4428
4610
  const CLAUDE_AUTH_PROBE = 'claude -p "reply with exactly: ok"';
@@ -4435,13 +4617,13 @@ async function cmdDoctor(opts = {}) {
4435
4617
  }
4436
4618
  };
4437
4619
  let host = 'skipped';
4438
- let hostDetail = 'skipped — claude CLI not on PATH';
4620
+ let hostDetail = 'skipped: claude CLI not on PATH';
4439
4621
  if (coreOps.isCommandAvailable('claude')) {
4440
4622
  host = probeExec(CLAUDE_AUTH_PROBE) === null ? 'failed' : 'ok';
4441
4623
  hostDetail = host === 'ok' ? 'claude -p succeeded on the host' : 'claude -p failed on the host';
4442
4624
  }
4443
4625
  let sandboxed = 'skipped';
4444
- let sandboxDetail = 'skipped — host claude auth was not verified';
4626
+ let sandboxDetail = 'skipped: host claude auth was not verified';
4445
4627
  const runtimeAvailable = hasSandboxRuntime(process.platform, (cmd) => isCommandAvailableViaShell(shellOps, cmd));
4446
4628
  const skip = sandboxAuthSkipDetail({ runtimeAvailable, hostOk: host === 'ok', inRepo: repoRoot !== null });
4447
4629
  if (skip !== null) {
@@ -4454,13 +4636,13 @@ async function cmdDoctor(opts = {}) {
4454
4636
  isAvailable: (cmd) => isCommandAvailableViaShell(shellOps, cmd),
4455
4637
  });
4456
4638
  if (!policy) {
4457
- sandboxDetail = 'skipped — sandbox disabled by config or FACTORY_SANDBOX';
4639
+ sandboxDetail = 'skipped: sandbox disabled by config or FACTORY_SANDBOX';
4458
4640
  }
4459
4641
  else if (policy.runtime === 'none') {
4460
- sandboxDetail = 'skipped — no sandbox runtime (sandbox-exec/firejail) on this host';
4642
+ sandboxDetail = 'skipped: no sandbox runtime (sandbox-exec/firejail) on this host';
4461
4643
  }
4462
4644
  else if (policy.runtime === 'docker-sandbox') {
4463
- sandboxDetail = 'skipped — docker-sandbox containment is the microVM, not command wrapping (#653)';
4645
+ sandboxDetail = 'skipped: docker-sandbox containment is the microVM, not command wrapping (#653)';
4464
4646
  }
4465
4647
  else {
4466
4648
  sandboxed = probeExec(wrapCommandInSandbox(CLAUDE_AUTH_PROBE, policy)) === null ? 'failed' : 'ok';
@@ -4476,7 +4658,13 @@ async function cmdDoctor(opts = {}) {
4476
4658
  checks.splice(0, checks.length, ...reconcileAgentAuthChecks(checks));
4477
4659
  if (repoRoot !== null) {
4478
4660
  const paths = getFactoryPaths(repoRoot);
4479
- const factoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
4661
+ let factoryConfig;
4662
+ try {
4663
+ factoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
4664
+ }
4665
+ catch {
4666
+ factoryConfig = loadFactoryConfig(); // schema errors are reported by the config row (#2611)
4667
+ }
4480
4668
  const health = await inspectPortLeases({ registryFile: paths.ports });
4481
4669
  checks.push(...leaseChecks(health.map(toLeaseRow)));
4482
4670
  const dockerAvailable = coreOps.isCommandAvailable('docker');
@@ -4493,6 +4681,15 @@ async function cmdDoctor(opts = {}) {
4493
4681
  checks.push(stopSentinelCheck(readStopFileStatus(paths)));
4494
4682
  const eventsContent = existsSync(paths.events) ? readFileSync(paths.events, 'utf-8') : null;
4495
4683
  checks.push(eventLogCheck(eventsContent === null ? null : analyzeEventLog(eventsContent)));
4684
+ let constitution = null;
4685
+ let loadError;
4686
+ try {
4687
+ constitution = new ConstitutionLoader().loadRepoConstitution(repoRoot, factoryConfig.paths?.constitution);
4688
+ }
4689
+ catch (err) {
4690
+ loadError = `cannot parse constitution: ${err instanceof Error ? err.message : String(err)}`;
4691
+ }
4692
+ checks.push(...configLintChecks({ constitution, configFile: repoConfigFileFor(repoRoot, paths.config) }, loadError));
4496
4693
  try {
4497
4694
  const declared = Object.keys(configLoaders.loadModelsConfig().harnesses ?? {});
4498
4695
  checks.push(...modelsRegistryChecks(paths.config, configLoaders.loadRepoConfig(repoRoot)?.models?.registry, [
@@ -4534,7 +4731,7 @@ async function cmdDoctor(opts = {}) {
4534
4731
  const graceMs = resolveProcessGroupGraceMs(factoryConfig);
4535
4732
  const orphanEvents = await coreOps.reapOrphanProcesses({ reaped, graceMs });
4536
4733
  for (const e of orphanEvents) {
4537
- console.log(`reconcile: ${e.action === 'killed' ? 'killed' : 'found'} pid ${e.pid} (pgid ${e.pgid}, ${e.command}) squatting port ${e.port} of dead lane ${e.worktreeId}${e.action === 'reported' ? ' — not factory-started, left running' : ''}`);
4734
+ console.log(`reconcile: ${e.action === 'killed' ? 'killed' : 'found'} pid ${e.pid} (pgid ${e.pgid}, ${e.command}) squatting port ${e.port} of dead lane ${e.worktreeId}${e.action === 'reported' ? '; not factory-started, left running' : ''}`);
4538
4735
  }
4539
4736
  }
4540
4737
  try {
@@ -4549,7 +4746,7 @@ async function cmdDoctor(opts = {}) {
4549
4746
  console.log(formatWorktreeReconcileReport(report.removed));
4550
4747
  }
4551
4748
  catch (err) {
4552
- console.log(`reconcile: worktree sweep failed — ${err?.message ?? String(err)}`);
4749
+ console.log(`reconcile: worktree sweep failed: ${err?.message ?? String(err)}`);
4553
4750
  }
4554
4751
  // Stale claim reap (#999): factory:in-progress claims naming a dead local pid go back to
4555
4752
  // factory:queued so the next run can claim them with no manual label surgery.
@@ -4564,11 +4761,11 @@ async function cmdDoctor(opts = {}) {
4564
4761
  console.log(formatClaimReconcileReport(released));
4565
4762
  }
4566
4763
  else {
4567
- console.log('reconcile: skipping stale claims — no GitHub repo or token');
4764
+ console.log('reconcile: skipping stale claims: no GitHub repo or token');
4568
4765
  }
4569
4766
  }
4570
4767
  catch (err) {
4571
- console.log(`reconcile: stale claim release failed — ${err?.message ?? String(err)}`);
4768
+ console.log(`reconcile: stale claim release failed: ${err?.message ?? String(err)}`);
4572
4769
  }
4573
4770
  }
4574
4771
  }
@@ -4618,7 +4815,17 @@ export async function cmdStop(opts = {}) {
4618
4815
  }
4619
4816
  console.log('STOP set — lanes halt between issues');
4620
4817
  }
4621
- export async function cmdResume(opts = {}) {
4818
+ export async function cmdResume(issue, opts = {}) {
4819
+ if (issue === undefined && opts.from !== undefined) {
4820
+ throw new CliExitError('factory: resume --from needs an issue number: factory resume <issue> --from check', 2);
4821
+ }
4822
+ if (issue !== undefined) {
4823
+ if (opts.from !== 'check') {
4824
+ throw new CliExitError('factory: resume <issue> only supports --from check (BUILD resume is #2320)', 2);
4825
+ }
4826
+ await resumeFromCheck(parseIssueArg(issue));
4827
+ return;
4828
+ }
4622
4829
  const repoRoot = await getRepoRoot();
4623
4830
  const paths = getFactoryPaths(repoRoot);
4624
4831
  const json = opts.json ? { repo: await getGitHubRepo() } : undefined;
@@ -4633,6 +4840,47 @@ export async function cmdResume(opts = {}) {
4633
4840
  }
4634
4841
  console.log('STOP cleared');
4635
4842
  }
4843
+ /** `factory resume <issue> --from check` (#2564): re-run CHECK and later phases on the kept worktree.
4844
+ * `--json` only applies to the STOP form and is ignored here. */
4845
+ async function resumeFromCheck(issueNum) {
4846
+ if (availableAgentClis(coreOps.isCommandAvailable).length === 0) {
4847
+ throw new CliExitError(`factory: ${missingAgentCliMessage()}`, 2);
4848
+ }
4849
+ const repoRoot = await getRepoRoot();
4850
+ const paths = getFactoryPaths(repoRoot);
4851
+ if (!existsSync(paths.root)) {
4852
+ throw new CliExitError(`factory: ${notInitializedMessage()}`, 2);
4853
+ }
4854
+ const record = await createCheckResumeStore(resolve(paths.plans, `issue-${issueNum}.md`)).load();
4855
+ if (!record || !existsSync(record.worktree)) {
4856
+ throw new CliExitError(`factory: no preserved build for #${issueNum} — nothing to resume; run factory run-issue ${issueNum}`, 2);
4857
+ }
4858
+ await withRepoRunLock(paths, 'factory resume', async () => {
4859
+ const ghRepo = await getGitHubRepo();
4860
+ const workSources = createDefaultWorkSourceRegistry({ octokit: getOctokit() });
4861
+ let work;
4862
+ try {
4863
+ work = await workSources.resolve(GITHUB_ISSUE_SOURCE, {
4864
+ repo: ghRepo,
4865
+ issue: issueNum,
4866
+ });
4867
+ }
4868
+ catch (err) {
4869
+ if (err instanceof InvalidWorkRequestInputError) {
4870
+ throw new CliExitError(`factory: ${err.message}`, 2);
4871
+ }
4872
+ throw new CliExitError(`factory: could not resolve issue #${issueNum} in ${ghRepo} (${errorDetail(err)}) — nothing was resumed`, 2);
4873
+ }
4874
+ try {
4875
+ await shipIssue(issueNum, {}, { repoRoot, ghRepo, workRequest: work, resumeFrom: record });
4876
+ }
4877
+ catch (err) {
4878
+ if (err instanceof IssueSkippedError)
4879
+ return;
4880
+ throw new CliExitError(`Resume failed for issue #${issueNum}: ${err.message}`, 1);
4881
+ }
4882
+ });
4883
+ }
4636
4884
  // ---------- main ----------
4637
4885
  export async function main(argv = process.argv, deps = {}) {
4638
4886
  if (argv.slice(2).length === 0) {
@@ -4793,13 +5041,13 @@ export async function main(argv = process.argv, deps = {}) {
4793
5041
  .action((runId, opts) => cmdFilingPreview(runId, opts));
4794
5042
  const triage = program
4795
5043
  .command('triage')
4796
- .description('Have a model propose a queue from open issues into .factory/queue.proposed')
5044
+ .description('Have a model propose a queue from open issues into .factory/state/queue.proposed')
4797
5045
  .option('--product <name>', 'Product constitution to scope triage')
4798
5046
  .option('--model <id>', 'Model to triage with (any registered model, Claude or Codex)')
4799
5047
  .action(cmdTriage);
4800
5048
  triage
4801
5049
  .command('accept')
4802
- .description('Validate and atomically promote .factory/queue.proposed to .factory/queue')
5050
+ .description('Validate and atomically promote .factory/state/queue.proposed to .factory/state/queue')
4803
5051
  .option('--force', 'Skip validation and promote as-is')
4804
5052
  .action(async (opts) => {
4805
5053
  await cmdTriageAccept(opts);
@@ -4919,7 +5167,7 @@ export async function main(argv = process.argv, deps = {}) {
4919
5167
  .option('--no-sandbox', 'Disable the containment sandbox for agent runs (dangerous)')
4920
5168
  .option('--branch-prefix <prefix>', BRANCH_PREFIX_OPTION_DESCRIPTION)
4921
5169
  .option('--run-children', 'File mode only: when the size gate files child issues, run them (slice mode never files issues)')
4922
- .option('--size-gate <mode>', 'Size gate mode for this run: file (file child issues / park), slice (ship as sequential slice PRs under the same issue; never files issues) or off (skip both gates); beats sizeGate.mode in the config')
5170
+ .option('--size-gate <mode>', 'Size gate mode for this run: file (file child issues / park), slice (ship as sequential slice PRs under the same issue; never files issues), off (skip both gates) or stack (stacked PR set, ADR-0161; runs as slice until stacks are built); beats sizeGate.mode in the config')
4923
5171
  .option('--max-slices <count>', 'Slice mode: most slices a not-yet-started slice plan may have (1-20); an over-cap plan is recorded and parks; beats sizeGate.maxSlices (default 10)')
4924
5172
  .action(async (issueNum, opts) => {
4925
5173
  await cmdRunIssue(parseIssueArg(issueNum), opts);
@@ -5069,10 +5317,11 @@ export async function main(argv = process.argv, deps = {}) {
5069
5317
  .option('--json', 'Print one JSON object (schemaVersion 1): action, repo, stopFlag before/after')
5070
5318
  .action((opts) => cmdStop(opts));
5071
5319
  program
5072
- .command('resume')
5073
- .description('Clear a stop so lanes pick up new issues again')
5320
+ .command('resume [issue]')
5321
+ .description('Clear a stop so lanes pick up new issues again; with <issue> --from check, re-run CHECK on a build kept after an environment failure')
5074
5322
  .option('--json', 'Print one JSON object (schemaVersion 1): action, repo, stopFlag before/after')
5075
- .action((opts) => cmdResume(opts));
5323
+ .option('--from <phase>', 'Resume a kept build at this phase (only: check)')
5324
+ .action((issue, opts) => cmdResume(issue, opts));
5076
5325
  applyHelpGroups(program);
5077
5326
  const previousLoaders = configLoaders;
5078
5327
  const previousGitOps = gitOps;