@on-par/factory-cli 0.9.4 → 0.9.5-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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, 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';
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;
@@ -1448,8 +1482,12 @@ export class LaneEnvironmentPauseError extends Error {
1448
1482
  baseSha;
1449
1483
  failingChecks;
1450
1484
  failureSignature;
1485
+ /** BUILD output was kept for `factory resume --from check` (#2564); runLane must not reap it. */
1486
+ buildPreserved;
1451
1487
  constructor(message, failure) {
1452
1488
  super(message);
1489
+ if (failure.buildPreserved !== undefined)
1490
+ this.buildPreserved = failure.buildPreserved;
1453
1491
  this.baseSha = failure.baseSha;
1454
1492
  this.failingChecks = failure.failingChecks;
1455
1493
  if (failure.failureSignature !== undefined)
@@ -1543,6 +1581,15 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1543
1581
  const shell = resolveShellOps(deps);
1544
1582
  const core = resolveCoreOps(deps);
1545
1583
  const phases = resolvePhaseOps(deps);
1584
+ // FACTORY_GITHUB=stub (#2652): record each `git push` ship runs in the stub's calls.jsonl.
1585
+ let shipPhaseForMode = phases.shipPhase;
1586
+ if (resolveGitHubMode(process.env) === 'stub') {
1587
+ const base = phases.shipPhase;
1588
+ const stubCalls = join(resolve(process.env.FACTORY_GITHUB_STUB_DIR ?? ''), GITHUB_STUB_CALLS_FILE);
1589
+ const defaultShipRunner = promisify(execCb);
1590
+ const wrapped = (o) => base({ ...o, run: recordGitPushes(o.run ?? defaultShipRunner, stubCalls, o.branch) });
1591
+ shipPhaseForMode = wrapped;
1592
+ }
1546
1593
  const repoRoot = ctx?.repoRoot ?? (await getRepoRoot(shell));
1547
1594
  const ghRepo = ctx?.ghRepo ?? (await getGitHubRepo(shell));
1548
1595
  const paths = ctx?.paths ?? getFactoryPaths(repoRoot);
@@ -1600,7 +1647,11 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1600
1647
  const maxSlices = opts.maxSlices ?? resolveSizeGateMaxSlices(factoryConfig);
1601
1648
  // ADR-0147: slice k >= 2 builds on its own branch, so read the slice plan before the worktree exists.
1602
1649
  let sliceIndex;
1603
- if (sizeGateMode === 'slice' && !ctx?.localOnly && work.kind === GITHUB_ISSUE_SOURCE) {
1650
+ // ADR-0161: stack runs as slice until stacked PRs are built.
1651
+ if ((sizeGateMode === 'slice' || sizeGateMode === 'stack') &&
1652
+ !ctx?.localOnly &&
1653
+ !ctx?.resumeFrom &&
1654
+ work.kind === GITHUB_ISSUE_SOURCE) {
1604
1655
  try {
1605
1656
  const lookup = await findSlicePlanComment({ octokit, repo: ghRepo, issue: issueNum });
1606
1657
  if (lookup.status === 'found')
@@ -1610,10 +1661,12 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1610
1661
  throw new LaneParkError(`slice plan lookup failed for #${issueNum}: ${errorDetail(err)} — parked before creating a worktree`, 'escalate');
1611
1662
  }
1612
1663
  }
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);
1664
+ const branch = ctx?.resumeFrom?.branch ?? branchFor(issueNum, issueTitle, policy.effective.branchPrefix, sliceIndex);
1665
+ const worktree = ctx?.resumeFrom
1666
+ ? ctx.resumeFrom.worktree
1667
+ : ctx?.localOnly
1668
+ ? ctx.localOnly.workspace
1669
+ : worktreePathFor(repoRoot, ghRepo, issueNum, policy.effective.branchPrefix, loaders);
1617
1670
  const specPath = resolve(paths.plans, `issue-${issueNum}.md`);
1618
1671
  const runStartedAt = new Date().toISOString();
1619
1672
  const lane = ctx?.lane;
@@ -1628,6 +1681,7 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1628
1681
  'escalate',
1629
1682
  'held',
1630
1683
  'conflict',
1684
+ 'config',
1631
1685
  'ci-failed',
1632
1686
  'ci-infra',
1633
1687
  'timeout',
@@ -1710,7 +1764,11 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1710
1764
  // stays a CLI concern (not called here, matching today — a worktree persists past
1711
1765
  // a successful run for GC/sweep commands to reclaim later).
1712
1766
  let workspace;
1713
- if (!ctx?.localOnly) {
1767
+ if (ctx?.resumeFrom) {
1768
+ workspace = { path: worktree, dispose: async () => { } };
1769
+ log('worktree', `reusing kept worktree at ${worktree} (resume --from check)`);
1770
+ }
1771
+ else if (!ctx?.localOnly) {
1714
1772
  workspace = await worktreeWorkspace({
1715
1773
  repoRoot,
1716
1774
  branch,
@@ -1791,9 +1849,21 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1791
1849
  sandboxPolicy: activeSandboxPolicy,
1792
1850
  processGroupGraceMs,
1793
1851
  preferredRoute: repoConfig?.route,
1852
+ ...(ctx?.resumeFrom
1853
+ ? {
1854
+ resumeFrom: {
1855
+ phase: 'check',
1856
+ diffBase: ctx.resumeFrom.diffBase,
1857
+ route: ctx.resumeFrom.route,
1858
+ model: ctx.resumeFrom.model,
1859
+ buildMinutes: ctx.resumeFrom.buildMinutes,
1860
+ },
1861
+ }
1862
+ : {}),
1794
1863
  eventsFile: paths.events,
1795
1864
  logsDir: paths.logs,
1796
1865
  baselineCachePath: paths.baselineCache,
1866
+ repoConfigFile: repoConfigFileFor(repoRoot, paths.config),
1797
1867
  flakyLedgerPath: paths.flakyLedger,
1798
1868
  };
1799
1869
  const ports = {
@@ -1806,10 +1876,11 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1806
1876
  getIssueSpend: () => issueSpend,
1807
1877
  breaker,
1808
1878
  planReuse: ctx?.localOnly ? undefined : createPlanReuseStore(specPath),
1879
+ checkResume: ctx?.localOnly ? undefined : createCheckResumeStore(specPath),
1809
1880
  planPhase: phases.planPhase,
1810
1881
  buildPhase: phases.buildPhase,
1811
1882
  checkPhase: phases.checkPhase,
1812
- shipPhase: phases.shipPhase,
1883
+ shipPhase: shipPhaseForMode,
1813
1884
  checkBaseHealth: deps.checkBaseHealth ?? checkBaseHealth,
1814
1885
  resolveConstitution: () =>
1815
1886
  // Resolved once here — runIssue calls this exactly once and reuses the value for
@@ -1884,7 +1955,8 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1884
1955
  const egressProxy = proxied
1885
1956
  ? await startEgressProxy({
1886
1957
  allowHosts: proxied.allowHosts,
1887
- onHost: (host, allowed) => log('egress_host', `${host} (${allowed ? 'on allowlist' : 'not on allowlist'})`),
1958
+ blockTelemetry: proxied.blockTelemetry,
1959
+ onHost: (host, allowed) => log('egress_host', egressHostMessage(host, allowed)),
1888
1960
  })
1889
1961
  : undefined;
1890
1962
  let outcome;
@@ -1918,6 +1990,7 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1918
1990
  baseSha: outcome.baseSha,
1919
1991
  failingChecks: outcome.failingChecks,
1920
1992
  failureSignature: outcome.failureSignature,
1993
+ buildPreserved: outcome.buildPreserved,
1921
1994
  });
1922
1995
  }
1923
1996
  const reason = outcome.state === 'escalated' ? 'escalate' : outcome.reason;
@@ -2667,7 +2740,7 @@ async function cmdTriage(opts) {
2667
2740
  model = result.model;
2668
2741
  const queue = normalizeTriageQueue(result.output);
2669
2742
  if (queue) {
2670
- const header = `# Triage queue (model ${model}${product ? `, constitution ${product}` : ''})\n`;
2743
+ const header = triageQueueHeader(model, product);
2671
2744
  writeFileSync(paths.queueProposed, header + queue);
2672
2745
  }
2673
2746
  }
@@ -2686,6 +2759,9 @@ async function cmdTriage(opts) {
2686
2759
  throw triageNoProposalError(plannerError);
2687
2760
  }
2688
2761
  }
2762
+ export function triageQueueHeader(model, product) {
2763
+ return `# Triage queue (model ${model}${product ? `, constitution ${product}` : ''})\n`;
2764
+ }
2689
2765
  export function triageProposalMessage(proposed, proposedPath, queuePath) {
2690
2766
  if (!proposed.trim())
2691
2767
  return null;
@@ -2695,13 +2771,13 @@ export async function cmdTriageAccept(opts) {
2695
2771
  const repoRoot = await getRepoRoot();
2696
2772
  const paths = getFactoryPaths(repoRoot);
2697
2773
  if (!existsSync(paths.queueProposed)) {
2698
- console.log(`nothing to accept — ${paths.queueProposed} not found`);
2774
+ console.log(`nothing to accept: ${paths.queueProposed} not found`);
2699
2775
  return; // zero exit
2700
2776
  }
2701
2777
  const content = readFileSync(paths.queueProposed, 'utf-8');
2702
2778
  const result = validateQueue(content);
2703
2779
  if (!opts.force && !result.ok) {
2704
- throw new CliExitError(`factory: proposed queue is invalid — ${paths.queueProposed} left unchanged\n` +
2780
+ throw new CliExitError(`factory: proposed queue is invalid: ${paths.queueProposed} left unchanged\n` +
2705
2781
  result.errors.map((e) => ` - ${e}`).join('\n'), 1);
2706
2782
  }
2707
2783
  renameSync(paths.queueProposed, paths.queue); // same dir → atomic
@@ -2714,7 +2790,7 @@ export async function cmdTriageAccept(opts) {
2714
2790
  }
2715
2791
  const suffix = opts.force ? ' (--force, validation skipped)' : '';
2716
2792
  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}`));
2793
+ console.log(styleText('green', `queue accepted: ${result.issues.length} issue(s) promoted to ${paths.queue}`));
2718
2794
  }
2719
2795
  export async function cmdQueueMigrate(opts = {}) {
2720
2796
  const repoRoot = await getRepoRoot();
@@ -3231,6 +3307,27 @@ async function cmdRun(opts = {}) {
3231
3307
  console.log(`[factory] run: ${detail}`);
3232
3308
  logEvent(paths.events, 'watchdog', 'all', detail);
3233
3309
  }
3310
+ let watchdogHarness;
3311
+ if (knobs.watch) {
3312
+ try {
3313
+ const repoConfig = configLoaders.loadRepoConfig(repoRoot);
3314
+ const registry = new coreOps.ModelRegistry(applyRepoConfig(configLoaders.loadModelsConfig(), repoConfig));
3315
+ const pins = configLoaders.resolveEffectiveModelPins(registry, repoConfig, process.env);
3316
+ const build = resolveEffectiveBuildRoute(registry, repoConfig, pins);
3317
+ watchdogHarness = resolveRunUsageHarness({
3318
+ buildRoute: build.route,
3319
+ buildRouteSource: build.source,
3320
+ planHarnesses: [pins.plan, pins.planFallback]
3321
+ .filter((m) => !!m)
3322
+ .map((m) => registry.getHarnessId(m)),
3323
+ codexDisabled: resolveCodexDisabled(repoConfig),
3324
+ localOnly: resolveLocalOnly(),
3325
+ });
3326
+ }
3327
+ catch {
3328
+ watchdogHarness = undefined; // unresolvable config keeps today's Claude gating
3329
+ }
3330
+ }
3234
3331
  const controller = new AbortController();
3235
3332
  const watchdog = knobs.watch
3236
3333
  ? coreOps
@@ -3242,6 +3339,7 @@ async function cmdRun(opts = {}) {
3242
3339
  eventsFile: paths.events,
3243
3340
  signal: controller.signal,
3244
3341
  estimator: knobs.estimator,
3342
+ ...(watchdogHarness ? { harness: watchdogHarness } : {}),
3245
3343
  })
3246
3344
  .catch((err) => {
3247
3345
  // a watchdog crash must never take down the run
@@ -3712,7 +3810,9 @@ export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {})
3712
3810
  // The base is broken, not the issue (#1928): release to queued (never parked),
3713
3811
  // pause the lane whatever laneBreakerThreshold is, and stop claiming.
3714
3812
  streak = null;
3715
- await reapWorktree(issue).catch(() => { });
3813
+ // A preserved build is kept for `factory resume --from check` (#2564).
3814
+ if (!err.buildPreserved)
3815
+ await reapWorktree(issue).catch(() => { });
3716
3816
  await settle(issue, 'queued');
3717
3817
  // Persist the hold so a restarted supervisor remembers the broken base (#2348);
3718
3818
  // best-effort, never allowed to change lane flow.
@@ -4383,6 +4483,18 @@ function probeClaudeKeychain() {
4383
4483
  }
4384
4484
  }
4385
4485
  async function cmdDoctor(opts = {}) {
4486
+ let repoRoot;
4487
+ try {
4488
+ repoRoot = shellOps
4489
+ .execSync('git rev-parse --show-toplevel', {
4490
+ timeout: 10_000,
4491
+ stdio: ['ignore', 'pipe', 'ignore'],
4492
+ })
4493
+ .trim();
4494
+ }
4495
+ catch {
4496
+ repoRoot = null;
4497
+ }
4386
4498
  const checks = runDoctorChecks({
4387
4499
  commandAvailable: coreOps.isCommandAvailable,
4388
4500
  envPresent: (key) => !!process.env[key],
@@ -4410,19 +4522,10 @@ async function cmdDoctor(opts = {}) {
4410
4522
  fileExists: existsSync,
4411
4523
  }),
4412
4524
  opencodeLoggedIn: () => hasOpencodeLoginEvidence(),
4525
+ commandHarnesses: () => repoRoot === null
4526
+ ? []
4527
+ : repoCommandHarnesses(configLoaders.loadModelsConfig().harnesses, configLoaders.loadRepoConfig(repoRoot)?.models?.registry),
4413
4528
  });
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
4529
  // Only with --probe-inference: these send a real prompt (uses quota). Plain doctor makes no inference call.
4427
4530
  if (opts.probeInference) {
4428
4531
  const CLAUDE_AUTH_PROBE = 'claude -p "reply with exactly: ok"';
@@ -4435,13 +4538,13 @@ async function cmdDoctor(opts = {}) {
4435
4538
  }
4436
4539
  };
4437
4540
  let host = 'skipped';
4438
- let hostDetail = 'skipped — claude CLI not on PATH';
4541
+ let hostDetail = 'skipped: claude CLI not on PATH';
4439
4542
  if (coreOps.isCommandAvailable('claude')) {
4440
4543
  host = probeExec(CLAUDE_AUTH_PROBE) === null ? 'failed' : 'ok';
4441
4544
  hostDetail = host === 'ok' ? 'claude -p succeeded on the host' : 'claude -p failed on the host';
4442
4545
  }
4443
4546
  let sandboxed = 'skipped';
4444
- let sandboxDetail = 'skipped — host claude auth was not verified';
4547
+ let sandboxDetail = 'skipped: host claude auth was not verified';
4445
4548
  const runtimeAvailable = hasSandboxRuntime(process.platform, (cmd) => isCommandAvailableViaShell(shellOps, cmd));
4446
4549
  const skip = sandboxAuthSkipDetail({ runtimeAvailable, hostOk: host === 'ok', inRepo: repoRoot !== null });
4447
4550
  if (skip !== null) {
@@ -4454,13 +4557,13 @@ async function cmdDoctor(opts = {}) {
4454
4557
  isAvailable: (cmd) => isCommandAvailableViaShell(shellOps, cmd),
4455
4558
  });
4456
4559
  if (!policy) {
4457
- sandboxDetail = 'skipped — sandbox disabled by config or FACTORY_SANDBOX';
4560
+ sandboxDetail = 'skipped: sandbox disabled by config or FACTORY_SANDBOX';
4458
4561
  }
4459
4562
  else if (policy.runtime === 'none') {
4460
- sandboxDetail = 'skipped — no sandbox runtime (sandbox-exec/firejail) on this host';
4563
+ sandboxDetail = 'skipped: no sandbox runtime (sandbox-exec/firejail) on this host';
4461
4564
  }
4462
4565
  else if (policy.runtime === 'docker-sandbox') {
4463
- sandboxDetail = 'skipped — docker-sandbox containment is the microVM, not command wrapping (#653)';
4566
+ sandboxDetail = 'skipped: docker-sandbox containment is the microVM, not command wrapping (#653)';
4464
4567
  }
4465
4568
  else {
4466
4569
  sandboxed = probeExec(wrapCommandInSandbox(CLAUDE_AUTH_PROBE, policy)) === null ? 'failed' : 'ok';
@@ -4476,7 +4579,13 @@ async function cmdDoctor(opts = {}) {
4476
4579
  checks.splice(0, checks.length, ...reconcileAgentAuthChecks(checks));
4477
4580
  if (repoRoot !== null) {
4478
4581
  const paths = getFactoryPaths(repoRoot);
4479
- const factoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
4582
+ let factoryConfig;
4583
+ try {
4584
+ factoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
4585
+ }
4586
+ catch {
4587
+ factoryConfig = loadFactoryConfig(); // schema errors are reported by the config row (#2611)
4588
+ }
4480
4589
  const health = await inspectPortLeases({ registryFile: paths.ports });
4481
4590
  checks.push(...leaseChecks(health.map(toLeaseRow)));
4482
4591
  const dockerAvailable = coreOps.isCommandAvailable('docker');
@@ -4493,6 +4602,15 @@ async function cmdDoctor(opts = {}) {
4493
4602
  checks.push(stopSentinelCheck(readStopFileStatus(paths)));
4494
4603
  const eventsContent = existsSync(paths.events) ? readFileSync(paths.events, 'utf-8') : null;
4495
4604
  checks.push(eventLogCheck(eventsContent === null ? null : analyzeEventLog(eventsContent)));
4605
+ let constitution = null;
4606
+ let loadError;
4607
+ try {
4608
+ constitution = new ConstitutionLoader().loadRepoConstitution(repoRoot, factoryConfig.paths?.constitution);
4609
+ }
4610
+ catch (err) {
4611
+ loadError = `cannot parse constitution: ${err instanceof Error ? err.message : String(err)}`;
4612
+ }
4613
+ checks.push(...configLintChecks({ constitution, configFile: repoConfigFileFor(repoRoot, paths.config) }, loadError));
4496
4614
  try {
4497
4615
  const declared = Object.keys(configLoaders.loadModelsConfig().harnesses ?? {});
4498
4616
  checks.push(...modelsRegistryChecks(paths.config, configLoaders.loadRepoConfig(repoRoot)?.models?.registry, [
@@ -4534,7 +4652,7 @@ async function cmdDoctor(opts = {}) {
4534
4652
  const graceMs = resolveProcessGroupGraceMs(factoryConfig);
4535
4653
  const orphanEvents = await coreOps.reapOrphanProcesses({ reaped, graceMs });
4536
4654
  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' : ''}`);
4655
+ 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
4656
  }
4539
4657
  }
4540
4658
  try {
@@ -4549,7 +4667,7 @@ async function cmdDoctor(opts = {}) {
4549
4667
  console.log(formatWorktreeReconcileReport(report.removed));
4550
4668
  }
4551
4669
  catch (err) {
4552
- console.log(`reconcile: worktree sweep failed — ${err?.message ?? String(err)}`);
4670
+ console.log(`reconcile: worktree sweep failed: ${err?.message ?? String(err)}`);
4553
4671
  }
4554
4672
  // Stale claim reap (#999): factory:in-progress claims naming a dead local pid go back to
4555
4673
  // factory:queued so the next run can claim them with no manual label surgery.
@@ -4564,11 +4682,11 @@ async function cmdDoctor(opts = {}) {
4564
4682
  console.log(formatClaimReconcileReport(released));
4565
4683
  }
4566
4684
  else {
4567
- console.log('reconcile: skipping stale claims — no GitHub repo or token');
4685
+ console.log('reconcile: skipping stale claims: no GitHub repo or token');
4568
4686
  }
4569
4687
  }
4570
4688
  catch (err) {
4571
- console.log(`reconcile: stale claim release failed — ${err?.message ?? String(err)}`);
4689
+ console.log(`reconcile: stale claim release failed: ${err?.message ?? String(err)}`);
4572
4690
  }
4573
4691
  }
4574
4692
  }
@@ -4618,7 +4736,17 @@ export async function cmdStop(opts = {}) {
4618
4736
  }
4619
4737
  console.log('STOP set — lanes halt between issues');
4620
4738
  }
4621
- export async function cmdResume(opts = {}) {
4739
+ export async function cmdResume(issue, opts = {}) {
4740
+ if (issue === undefined && opts.from !== undefined) {
4741
+ throw new CliExitError('factory: resume --from needs an issue number: factory resume <issue> --from check', 2);
4742
+ }
4743
+ if (issue !== undefined) {
4744
+ if (opts.from !== 'check') {
4745
+ throw new CliExitError('factory: resume <issue> only supports --from check (BUILD resume is #2320)', 2);
4746
+ }
4747
+ await resumeFromCheck(parseIssueArg(issue));
4748
+ return;
4749
+ }
4622
4750
  const repoRoot = await getRepoRoot();
4623
4751
  const paths = getFactoryPaths(repoRoot);
4624
4752
  const json = opts.json ? { repo: await getGitHubRepo() } : undefined;
@@ -4633,6 +4761,47 @@ export async function cmdResume(opts = {}) {
4633
4761
  }
4634
4762
  console.log('STOP cleared');
4635
4763
  }
4764
+ /** `factory resume <issue> --from check` (#2564): re-run CHECK and later phases on the kept worktree.
4765
+ * `--json` only applies to the STOP form and is ignored here. */
4766
+ async function resumeFromCheck(issueNum) {
4767
+ if (availableAgentClis(coreOps.isCommandAvailable).length === 0) {
4768
+ throw new CliExitError(`factory: ${missingAgentCliMessage()}`, 2);
4769
+ }
4770
+ const repoRoot = await getRepoRoot();
4771
+ const paths = getFactoryPaths(repoRoot);
4772
+ if (!existsSync(paths.root)) {
4773
+ throw new CliExitError(`factory: ${notInitializedMessage()}`, 2);
4774
+ }
4775
+ const record = await createCheckResumeStore(resolve(paths.plans, `issue-${issueNum}.md`)).load();
4776
+ if (!record || !existsSync(record.worktree)) {
4777
+ throw new CliExitError(`factory: no preserved build for #${issueNum} — nothing to resume; run factory run-issue ${issueNum}`, 2);
4778
+ }
4779
+ await withRepoRunLock(paths, 'factory resume', async () => {
4780
+ const ghRepo = await getGitHubRepo();
4781
+ const workSources = createDefaultWorkSourceRegistry({ octokit: getOctokit() });
4782
+ let work;
4783
+ try {
4784
+ work = await workSources.resolve(GITHUB_ISSUE_SOURCE, {
4785
+ repo: ghRepo,
4786
+ issue: issueNum,
4787
+ });
4788
+ }
4789
+ catch (err) {
4790
+ if (err instanceof InvalidWorkRequestInputError) {
4791
+ throw new CliExitError(`factory: ${err.message}`, 2);
4792
+ }
4793
+ throw new CliExitError(`factory: could not resolve issue #${issueNum} in ${ghRepo} (${errorDetail(err)}) — nothing was resumed`, 2);
4794
+ }
4795
+ try {
4796
+ await shipIssue(issueNum, {}, { repoRoot, ghRepo, workRequest: work, resumeFrom: record });
4797
+ }
4798
+ catch (err) {
4799
+ if (err instanceof IssueSkippedError)
4800
+ return;
4801
+ throw new CliExitError(`Resume failed for issue #${issueNum}: ${err.message}`, 1);
4802
+ }
4803
+ });
4804
+ }
4636
4805
  // ---------- main ----------
4637
4806
  export async function main(argv = process.argv, deps = {}) {
4638
4807
  if (argv.slice(2).length === 0) {
@@ -4793,13 +4962,13 @@ export async function main(argv = process.argv, deps = {}) {
4793
4962
  .action((runId, opts) => cmdFilingPreview(runId, opts));
4794
4963
  const triage = program
4795
4964
  .command('triage')
4796
- .description('Have a model propose a queue from open issues into .factory/queue.proposed')
4965
+ .description('Have a model propose a queue from open issues into .factory/state/queue.proposed')
4797
4966
  .option('--product <name>', 'Product constitution to scope triage')
4798
4967
  .option('--model <id>', 'Model to triage with (any registered model, Claude or Codex)')
4799
4968
  .action(cmdTriage);
4800
4969
  triage
4801
4970
  .command('accept')
4802
- .description('Validate and atomically promote .factory/queue.proposed to .factory/queue')
4971
+ .description('Validate and atomically promote .factory/state/queue.proposed to .factory/state/queue')
4803
4972
  .option('--force', 'Skip validation and promote as-is')
4804
4973
  .action(async (opts) => {
4805
4974
  await cmdTriageAccept(opts);
@@ -4919,7 +5088,7 @@ export async function main(argv = process.argv, deps = {}) {
4919
5088
  .option('--no-sandbox', 'Disable the containment sandbox for agent runs (dangerous)')
4920
5089
  .option('--branch-prefix <prefix>', BRANCH_PREFIX_OPTION_DESCRIPTION)
4921
5090
  .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')
5091
+ .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
5092
  .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
5093
  .action(async (issueNum, opts) => {
4925
5094
  await cmdRunIssue(parseIssueArg(issueNum), opts);
@@ -5069,10 +5238,11 @@ export async function main(argv = process.argv, deps = {}) {
5069
5238
  .option('--json', 'Print one JSON object (schemaVersion 1): action, repo, stopFlag before/after')
5070
5239
  .action((opts) => cmdStop(opts));
5071
5240
  program
5072
- .command('resume')
5073
- .description('Clear a stop so lanes pick up new issues again')
5241
+ .command('resume [issue]')
5242
+ .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
5243
  .option('--json', 'Print one JSON object (schemaVersion 1): action, repo, stopFlag before/after')
5075
- .action((opts) => cmdResume(opts));
5244
+ .option('--from <phase>', 'Resume a kept build at this phase (only: check)')
5245
+ .action((issue, opts) => cmdResume(issue, opts));
5076
5246
  applyHelpGroups(program);
5077
5247
  const previousLoaders = configLoaders;
5078
5248
  const previousGitOps = gitOps;