@on-par/factory-cli 0.9.3 → 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, 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, } 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,8 +29,8 @@ 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, orphanMicroVmChecks, runDoctorChecks, sandboxAuthSkipDetail, sandboxClaudeAuthChecks, unmergedGreenPrChecks, } from './doctor.js';
33
- import { DOCS_URL, formatOverview, missingClaudeCliMessage, missingTokenMessage, notInitializedMessage, } from './first-run.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
+ 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';
36
36
  import { cmdHostedRunner } from './hosted-runner.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,
@@ -179,7 +185,9 @@ function resolvePhaseOps(overrides, base = phaseOps) {
179
185
  shipPhase: overrides.shipPhase ?? base.shipPhase,
180
186
  };
181
187
  }
182
- export const PREREQUISITES_TEXT = `Prerequisites:
188
+ export const PREREQUISITES_TEXT = `First step: run \`factory doctor\` to check your setup and see what to fix.
189
+
190
+ Prerequisites:
183
191
  - Claude Code CLI installed and authenticated (Claude subscription): https://claude.com/claude-code
184
192
  - GitHub CLI authenticated: gh auth login
185
193
  - GITHUB_TOKEN or GH_TOKEN set (falls back to \`gh auth token\`)
@@ -221,13 +229,39 @@ async function getRepoRoot(shell = shellOps) {
221
229
  throw new CliExitError('factory: not inside a git repository', 2);
222
230
  }
223
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
+ }
224
257
  async function getGitHubRepo(shell = shellOps) {
225
258
  try {
226
259
  const { stdout } = await shell.exec('gh repo view --json nameWithOwner --jq .nameWithOwner');
227
260
  return stdout.trim();
228
261
  }
229
- catch {
230
- 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);
231
265
  }
232
266
  }
233
267
  /** Env token first, then one `gh auth token` subprocess (≤5 s). `undefined` when neither yields one. */
@@ -245,7 +279,9 @@ function resolveGitHubToken(shell = shellOps) {
245
279
  /** Octokit factory for the current main() or cmdLand invocation (CliDeps.octokit); unset outside them. */
246
280
  let octokitFactory;
247
281
  function getOctokit(shell = shellOps, options) {
248
- return octokitFactory ? octokitFactory() : createFactoryOctokit(resolveGitHubToken(shell), options);
282
+ return octokitFactory
283
+ ? octokitFactory()
284
+ : selectOctokit(process.env, () => createFactoryOctokit(resolveGitHubToken(shell), options));
249
285
  }
250
286
  export function errorDetail(err) {
251
287
  const e = err;
@@ -339,13 +375,13 @@ async function cmdInit(opts = {}) {
339
375
  // Doctor-style validation (policy=auto, N models reachable) — informational, never fails init.
340
376
  const modelsConfig = applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot));
341
377
  const registry = new coreOps.ModelRegistry(modelsConfig);
342
- const diagnoses = coreOps.diagnoseModels(registry, modelDoctorProbes(), resolveExperimental(), resolveLocalOnly());
378
+ const diagnoses = coreOps.diagnoseModels(registry, modelDoctorProbes({ requireOllamaOnPath: true }), resolveExperimental(), resolveLocalOnly());
343
379
  console.log(formatInitReachability(diagnoses));
344
380
  if (!hasReachableWorker(diagnoses)) {
345
381
  console.log(styleText('yellow', 'No worker model reachable yet — see `factory doctor` and `factory models --doctor`.'));
346
382
  }
347
383
  console.log(styleText('green', `Initialized ${paths.root}`));
348
- console.log(`Next: factory constitution --product <name>, then factory triage`);
384
+ console.log('Next: factory doctor, then factory triage (optional: factory constitution --product <name> to swap the default constitution)');
349
385
  }
350
386
  /** Rewrite a checkout's legacy `.factory/` inputs and runtime files to the v2
351
387
  * layout. Every write is idempotent so it is safe to run this more than once. */
@@ -723,12 +759,16 @@ export async function cmdConstitution(opts, deps = {}) {
723
759
  export function modelStatusIcon(d) {
724
760
  return d.reachable ? styleText('green', '✅') : d.unverified ? styleText('yellow', '⚠️') : styleText('red', '❌');
725
761
  }
726
- export function formatDoctorReport(diagnoses) {
762
+ /** Single owner of the `[repo-defined]` marker for models declared in `.factory/config.json` models.registry, shared by `factory models` and `models --doctor` (#2443). */
763
+ export function repoDefinedTag(model, repoDefined) {
764
+ return repoDefined.has(model) ? styleText('cyan', ' [repo-defined]') : '';
765
+ }
766
+ export function formatDoctorReport(diagnoses, repoDefined = new Set()) {
727
767
  const lines = [styleText('bold', '\n== Model Doctor ==')];
728
768
  for (const d of diagnoses) {
729
769
  const icon = modelStatusIcon(d);
730
770
  const tiers = d.tiers.join('/');
731
- lines.push(` ${icon} ${d.model} provider=${d.provider} tier=${tiers} — ${d.reason}`);
771
+ lines.push(` ${icon} ${d.model} provider=${d.provider} tier=${tiers} — ${d.reason}${repoDefinedTag(d.model, repoDefined)}`);
732
772
  }
733
773
  lines.push(` ${formatReachabilityCount(diagnoses)}`);
734
774
  return lines.join('\n');
@@ -736,7 +776,9 @@ export function formatDoctorReport(diagnoses) {
736
776
  export function hasReachableWorker(diagnoses) {
737
777
  return diagnoses.some((d) => d.reachable && (d.tiers.includes('worker') || d.tiers.includes('worker_fallback')));
738
778
  }
739
- function ollamaModelSet() {
779
+ function ollamaModelSet(requireOnPath = false) {
780
+ if (requireOnPath && !coreOps.isCommandAvailable('ollama'))
781
+ return undefined;
740
782
  try {
741
783
  const out = shellOps.execSync('ollama list', { timeout: 10_000 });
742
784
  return new Set(out
@@ -750,9 +792,10 @@ function ollamaModelSet() {
750
792
  }
751
793
  }
752
794
  /** Probes shared by every doctor-style reachability check (init, models --doctor, overnight
753
- * preflight), so their counts agree. */
754
- export function modelDoctorProbes() {
755
- const ollamaModels = ollamaModelSet();
795
+ * preflight), so their counts agree. init passes `requireOllamaOnPath` so a missing binary never
796
+ * reaches the shell (#2507). */
797
+ export function modelDoctorProbes(opts = {}) {
798
+ const ollamaModels = ollamaModelSet(opts.requireOllamaOnPath === true);
756
799
  return {
757
800
  commandAvailable: coreOps.isCommandAvailable,
758
801
  ollamaModelPresent: ollamaModels ? (model) => ollamaModels.has(model) : undefined,
@@ -760,13 +803,15 @@ export function modelDoctorProbes() {
760
803
  }
761
804
  async function cmdModels(opts = {}) {
762
805
  const repoRoot = await getRepoRoot();
763
- const modelsConfig = applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot));
806
+ const repoConfig = configLoaders.loadRepoConfig(repoRoot);
807
+ const modelsConfig = applyRepoConfig(configLoaders.loadModelsConfig(), repoConfig);
808
+ const repoDefined = new Set(Object.keys(repoConfig?.models?.registry ?? {}));
764
809
  const registry = new coreOps.ModelRegistry(modelsConfig);
765
810
  const allowExperimental = resolveExperimental();
766
811
  const localOnly = resolveLocalOnly();
767
812
  const diagnoses = coreOps.diagnoseModels(registry, modelDoctorProbes(), allowExperimental, localOnly);
768
813
  if (opts.doctor) {
769
- console.log(formatDoctorReport(diagnoses));
814
+ console.log(formatDoctorReport(diagnoses, repoDefined));
770
815
  if (!hasReachableWorker(diagnoses)) {
771
816
  throw new CliExitError('factory: no worker model is reachable — fix the reasons above before running a queue', 1);
772
817
  }
@@ -781,7 +826,7 @@ async function cmdModels(opts = {}) {
781
826
  const d = byModel.get(m);
782
827
  const avail = d ? modelStatusIcon(d) : styleText('red', '❌');
783
828
  const tag = registry.isExperimental(m) ? styleText('yellow', ' [experimental]') : '';
784
- console.log(` ${avail} ${m} tier=${tiers} ${cost}${tag}`);
829
+ console.log(` ${avail} ${m} tier=${tiers} ${cost}${tag}${repoDefinedTag(m, repoDefined)}`);
785
830
  }
786
831
  console.log(styleText('bold', '\n== Tiers =='));
787
832
  for (const tier of ['boss', 'worker', 'checker', 'triage']) {
@@ -1437,8 +1482,12 @@ export class LaneEnvironmentPauseError extends Error {
1437
1482
  baseSha;
1438
1483
  failingChecks;
1439
1484
  failureSignature;
1485
+ /** BUILD output was kept for `factory resume --from check` (#2564); runLane must not reap it. */
1486
+ buildPreserved;
1440
1487
  constructor(message, failure) {
1441
1488
  super(message);
1489
+ if (failure.buildPreserved !== undefined)
1490
+ this.buildPreserved = failure.buildPreserved;
1442
1491
  this.baseSha = failure.baseSha;
1443
1492
  this.failingChecks = failure.failingChecks;
1444
1493
  if (failure.failureSignature !== undefined)
@@ -1532,6 +1581,15 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1532
1581
  const shell = resolveShellOps(deps);
1533
1582
  const core = resolveCoreOps(deps);
1534
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
+ }
1535
1593
  const repoRoot = ctx?.repoRoot ?? (await getRepoRoot(shell));
1536
1594
  const ghRepo = ctx?.ghRepo ?? (await getGitHubRepo(shell));
1537
1595
  const paths = ctx?.paths ?? getFactoryPaths(repoRoot);
@@ -1560,6 +1618,7 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1560
1618
  : { ...resolveEffectiveConfig(repoConfig), branchPrefix: opts.branchPrefix },
1561
1619
  };
1562
1620
  const router = new core.ModelRouter(policy.models, policy.routes, false, undefined, policy.effective.allowExperimental, policy.effective.localOnly);
1621
+ router.skipMissingHarnessClis(core.isCommandAvailable);
1563
1622
  let issueSpend = 0;
1564
1623
  let laneSandboxRuntime = 'none';
1565
1624
  const laneWorkspaceBackend = factoryConfig.workspace?.backend === 'disposable-docker' ? 'disposable-docker' : 'worktree';
@@ -1588,7 +1647,11 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1588
1647
  const maxSlices = opts.maxSlices ?? resolveSizeGateMaxSlices(factoryConfig);
1589
1648
  // ADR-0147: slice k >= 2 builds on its own branch, so read the slice plan before the worktree exists.
1590
1649
  let sliceIndex;
1591
- 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) {
1592
1655
  try {
1593
1656
  const lookup = await findSlicePlanComment({ octokit, repo: ghRepo, issue: issueNum });
1594
1657
  if (lookup.status === 'found')
@@ -1598,10 +1661,12 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1598
1661
  throw new LaneParkError(`slice plan lookup failed for #${issueNum}: ${errorDetail(err)} — parked before creating a worktree`, 'escalate');
1599
1662
  }
1600
1663
  }
1601
- const branch = branchFor(issueNum, issueTitle, policy.effective.branchPrefix, sliceIndex);
1602
- const worktree = ctx?.localOnly
1603
- ? ctx.localOnly.workspace
1604
- : 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);
1605
1670
  const specPath = resolve(paths.plans, `issue-${issueNum}.md`);
1606
1671
  const runStartedAt = new Date().toISOString();
1607
1672
  const lane = ctx?.lane;
@@ -1616,6 +1681,7 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1616
1681
  'escalate',
1617
1682
  'held',
1618
1683
  'conflict',
1684
+ 'config',
1619
1685
  'ci-failed',
1620
1686
  'ci-infra',
1621
1687
  'timeout',
@@ -1698,7 +1764,11 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1698
1764
  // stays a CLI concern (not called here, matching today — a worktree persists past
1699
1765
  // a successful run for GC/sweep commands to reclaim later).
1700
1766
  let workspace;
1701
- 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) {
1702
1772
  workspace = await worktreeWorkspace({
1703
1773
  repoRoot,
1704
1774
  branch,
@@ -1779,9 +1849,21 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1779
1849
  sandboxPolicy: activeSandboxPolicy,
1780
1850
  processGroupGraceMs,
1781
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
+ : {}),
1782
1863
  eventsFile: paths.events,
1783
1864
  logsDir: paths.logs,
1784
1865
  baselineCachePath: paths.baselineCache,
1866
+ repoConfigFile: repoConfigFileFor(repoRoot, paths.config),
1785
1867
  flakyLedgerPath: paths.flakyLedger,
1786
1868
  };
1787
1869
  const ports = {
@@ -1794,10 +1876,11 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1794
1876
  getIssueSpend: () => issueSpend,
1795
1877
  breaker,
1796
1878
  planReuse: ctx?.localOnly ? undefined : createPlanReuseStore(specPath),
1879
+ checkResume: ctx?.localOnly ? undefined : createCheckResumeStore(specPath),
1797
1880
  planPhase: phases.planPhase,
1798
1881
  buildPhase: phases.buildPhase,
1799
1882
  checkPhase: phases.checkPhase,
1800
- shipPhase: phases.shipPhase,
1883
+ shipPhase: shipPhaseForMode,
1801
1884
  checkBaseHealth: deps.checkBaseHealth ?? checkBaseHealth,
1802
1885
  resolveConstitution: () =>
1803
1886
  // Resolved once here — runIssue calls this exactly once and reuses the value for
@@ -1872,7 +1955,8 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1872
1955
  const egressProxy = proxied
1873
1956
  ? await startEgressProxy({
1874
1957
  allowHosts: proxied.allowHosts,
1875
- 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)),
1876
1960
  })
1877
1961
  : undefined;
1878
1962
  let outcome;
@@ -1906,6 +1990,7 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1906
1990
  baseSha: outcome.baseSha,
1907
1991
  failingChecks: outcome.failingChecks,
1908
1992
  failureSignature: outcome.failureSignature,
1993
+ buildPreserved: outcome.buildPreserved,
1909
1994
  });
1910
1995
  }
1911
1996
  const reason = outcome.state === 'escalated' ? 'escalate' : outcome.reason;
@@ -2014,8 +2099,8 @@ export async function sweepBetweenLaneIssues(issue, lane, repoRoot, ghRepo, path
2014
2099
  }
2015
2100
  }
2016
2101
  async function cmdShip(issueNum, opts) {
2017
- if (!coreOps.isCommandAvailable('claude')) {
2018
- throw new CliExitError(`factory: ${missingClaudeCliMessage()}`, 2);
2102
+ if (availableAgentClis(coreOps.isCommandAvailable).length === 0) {
2103
+ throw new CliExitError(`factory: ${missingAgentCliMessage()}`, 2);
2019
2104
  }
2020
2105
  const repoRoot = await getRepoRoot();
2021
2106
  const paths = getFactoryPaths(repoRoot);
@@ -2050,13 +2135,16 @@ async function cmdShip(issueNum, opts) {
2050
2135
  catch (err) {
2051
2136
  if (err instanceof IssueSkippedError)
2052
2137
  return;
2138
+ // #2492: a pull request number is a usage error, not a failed ship.
2139
+ if (err instanceof InvalidWorkRequestInputError)
2140
+ throw new CliExitError(`factory: ${err.message}`, 2);
2053
2141
  throw new CliExitError(`Ship failed for issue #${issueNum}: ${err.message}`, 1);
2054
2142
  }
2055
2143
  });
2056
2144
  }
2057
2145
  async function cmdRunIssue(issueNum, opts) {
2058
- if (!coreOps.isCommandAvailable('claude')) {
2059
- throw new CliExitError(`factory: ${missingClaudeCliMessage()}`, 2);
2146
+ if (availableAgentClis(coreOps.isCommandAvailable).length === 0) {
2147
+ throw new CliExitError(`factory: ${missingAgentCliMessage()}`, 2);
2060
2148
  }
2061
2149
  const repoRoot = await getRepoRoot();
2062
2150
  const paths = getFactoryPaths(repoRoot);
@@ -2173,8 +2261,8 @@ async function runDecomposedChildren(parent, children, opts, env) {
2173
2261
  }
2174
2262
  }
2175
2263
  async function cmdRunBrief(briefPath, opts) {
2176
- if (!coreOps.isCommandAvailable('claude')) {
2177
- throw new CliExitError(`factory: ${missingClaudeCliMessage()}`, 2);
2264
+ if (availableAgentClis(coreOps.isCommandAvailable).length === 0) {
2265
+ throw new CliExitError(`factory: ${missingAgentCliMessage()}`, 2);
2178
2266
  }
2179
2267
  const repoRoot = await getRepoRoot();
2180
2268
  const paths = getFactoryPaths(repoRoot);
@@ -2290,8 +2378,8 @@ async function cmdLocalSmallOvernight(opts) {
2290
2378
  const ghRepo = await getGitHubRepo();
2291
2379
  process.env.FACTORY_LOCAL_ONLY = '1';
2292
2380
  const preflight = async () => {
2293
- if (!coreOps.isCommandAvailable('claude'))
2294
- return { ok: false, reason: missingClaudeCliMessage() };
2381
+ if (availableAgentClis(coreOps.isCommandAvailable).length === 0)
2382
+ return { ok: false, reason: missingAgentCliMessage() };
2295
2383
  const registry = new coreOps.ModelRegistry(applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot)));
2296
2384
  const diagnoses = coreOps.diagnoseModels(registry, modelDoctorProbes(), resolveExperimental(), true);
2297
2385
  if (!hasReachableWorker(diagnoses)) {
@@ -2635,28 +2723,33 @@ async function cmdTriage(opts) {
2635
2723
  const routesConfig = configLoaders.loadRoutesConfig();
2636
2724
  const effective = resolveEffectiveConfig(repoConfig);
2637
2725
  const router = new coreOps.ModelRouter(modelsConfig, routesConfig, false, undefined, effective.allowExperimental, effective.localOnly);
2638
- // Triage always runs `claude -p`, so only claude-cli models with a flag are runnable.
2639
- const registry = router.registryRef;
2640
- const resolved = router.resolveAll('triage');
2641
- const candidates = resolved.length > 0 ? resolved : ['claude-sonnet-5'];
2642
- const model = candidates.find((m) => registry.getHarnessId(m) === 'claude-cli' && registry.getClaudeFlag(m));
2643
- const flag = model ? registry.getClaudeFlag(model) : undefined;
2644
- const constitutionNote = product ? `Active constitution: ${product}.` : '';
2645
- const prompt = `Triage the open GitHub issues of ${ghRepo} for an autonomous software factory.
2646
- ${constitutionNote}
2647
- Run: gh issue list --repo ${ghRepo} --state open --limit 100 --json number,title,labels,body
2648
- Read every body. Exclude epics/PRDs/meta, external-account/credential/outreach issues,
2649
- and anything too vague. Group by lane (same-file issues together). Order by dependency then value.
2650
- Write ONLY the queue to ${paths.queueProposed} in format '<lane> <issue#>', with '#' comments
2651
- explaining exclusions.`;
2726
+ router.skipMissingHarnessClis(coreOps.isCommandAvailable);
2727
+ router.setCostSink((entry) => logCost(paths.costs, { ...entry, issue: '-' }));
2652
2728
  let plannerError;
2653
- logEvent(paths.events, 'triage', '-', `Triaging ${ghRepo} with ${flag ?? 'claude default model'}`);
2654
- await shellOps
2655
- .exec(`claude -p ${shellEscape(prompt)} ${flag ? `--model ${flag}` : ''} --allowedTools "Bash(gh issue:*)" "Bash(gh repo:*)" Read Glob Grep Write`)
2656
- .catch((err) => {
2729
+ let model;
2730
+ try {
2731
+ const { stdout: listed } = await shellOps.exec(`gh issue list --repo ${shellEscape(ghRepo)} --state open --limit 100 --json number,title,labels,body`);
2732
+ const prompt = buildTriagePrompt({ repo: ghRepo, product, candidates: parseTriageCandidates(listed || '[]') });
2733
+ logEvent(paths.events, 'triage', '-', `Triaging ${ghRepo} via the triage route`);
2734
+ const result = await router.run('triage', prompt, {
2735
+ worktree: repoRoot,
2736
+ timeoutSeconds: 600,
2737
+ ...(opts.model ? { modelOverride: opts.model } : {}),
2738
+ onLog: (msg) => logEvent(paths.events, 'triage', '-', msg),
2739
+ });
2740
+ model = result.model;
2741
+ const queue = normalizeTriageQueue(result.output);
2742
+ if (queue) {
2743
+ const header = triageQueueHeader(model, product);
2744
+ writeFileSync(paths.queueProposed, header + queue);
2745
+ }
2746
+ }
2747
+ catch (err) {
2657
2748
  plannerError = err;
2658
2749
  logEvent(paths.events, 'warn', '-', `triage planner failed: ${errorDetail(err)}`);
2659
- });
2750
+ }
2751
+ if (model)
2752
+ logEvent(paths.events, 'triage', '-', `Triaged ${ghRepo} with ${model}`);
2660
2753
  const proposed = existsSync(paths.queueProposed) ? readFileSync(paths.queueProposed, 'utf-8') : '';
2661
2754
  const message = triageProposalMessage(proposed, paths.queueProposed, paths.queue);
2662
2755
  if (message) {
@@ -2666,6 +2759,9 @@ explaining exclusions.`;
2666
2759
  throw triageNoProposalError(plannerError);
2667
2760
  }
2668
2761
  }
2762
+ export function triageQueueHeader(model, product) {
2763
+ return `# Triage queue (model ${model}${product ? `, constitution ${product}` : ''})\n`;
2764
+ }
2669
2765
  export function triageProposalMessage(proposed, proposedPath, queuePath) {
2670
2766
  if (!proposed.trim())
2671
2767
  return null;
@@ -2675,13 +2771,13 @@ export async function cmdTriageAccept(opts) {
2675
2771
  const repoRoot = await getRepoRoot();
2676
2772
  const paths = getFactoryPaths(repoRoot);
2677
2773
  if (!existsSync(paths.queueProposed)) {
2678
- console.log(`nothing to accept — ${paths.queueProposed} not found`);
2774
+ console.log(`nothing to accept: ${paths.queueProposed} not found`);
2679
2775
  return; // zero exit
2680
2776
  }
2681
2777
  const content = readFileSync(paths.queueProposed, 'utf-8');
2682
2778
  const result = validateQueue(content);
2683
2779
  if (!opts.force && !result.ok) {
2684
- 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` +
2685
2781
  result.errors.map((e) => ` - ${e}`).join('\n'), 1);
2686
2782
  }
2687
2783
  renameSync(paths.queueProposed, paths.queue); // same dir → atomic
@@ -2694,7 +2790,7 @@ export async function cmdTriageAccept(opts) {
2694
2790
  }
2695
2791
  const suffix = opts.force ? ' (--force, validation skipped)' : '';
2696
2792
  logEvent(paths.events, 'triage_accepted', '-', `accepted ${result.issues.length} issue(s) [${result.issues.join(', ')}] by ${acceptedBy}${suffix}`);
2697
- 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}`));
2698
2794
  }
2699
2795
  export async function cmdQueueMigrate(opts = {}) {
2700
2796
  const repoRoot = await getRepoRoot();
@@ -3211,6 +3307,27 @@ async function cmdRun(opts = {}) {
3211
3307
  console.log(`[factory] run: ${detail}`);
3212
3308
  logEvent(paths.events, 'watchdog', 'all', detail);
3213
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
+ }
3214
3331
  const controller = new AbortController();
3215
3332
  const watchdog = knobs.watch
3216
3333
  ? coreOps
@@ -3222,6 +3339,7 @@ async function cmdRun(opts = {}) {
3222
3339
  eventsFile: paths.events,
3223
3340
  signal: controller.signal,
3224
3341
  estimator: knobs.estimator,
3342
+ ...(watchdogHarness ? { harness: watchdogHarness } : {}),
3225
3343
  })
3226
3344
  .catch((err) => {
3227
3345
  // a watchdog crash must never take down the run
@@ -3692,7 +3810,9 @@ export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {})
3692
3810
  // The base is broken, not the issue (#1928): release to queued (never parked),
3693
3811
  // pause the lane whatever laneBreakerThreshold is, and stop claiming.
3694
3812
  streak = null;
3695
- 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(() => { });
3696
3816
  await settle(issue, 'queued');
3697
3817
  // Persist the hold so a restarted supervisor remembers the broken base (#2348);
3698
3818
  // best-effort, never allowed to change lane flow.
@@ -4363,20 +4483,6 @@ function probeClaudeKeychain() {
4363
4483
  }
4364
4484
  }
4365
4485
  async function cmdDoctor(opts = {}) {
4366
- const checks = runDoctorChecks({
4367
- commandAvailable: coreOps.isCommandAvailable,
4368
- envPresent: (key) => !!process.env[key],
4369
- tryExec: (cmd) => {
4370
- try {
4371
- return shellOps.execSync(cmd, { timeout: 10_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
4372
- }
4373
- catch {
4374
- return null;
4375
- }
4376
- },
4377
- pathExists: existsSync,
4378
- distFreshness: distFreshnessProbe(import.meta.url),
4379
- });
4380
4486
  let repoRoot;
4381
4487
  try {
4382
4488
  repoRoot = shellOps
@@ -4389,56 +4495,97 @@ async function cmdDoctor(opts = {}) {
4389
4495
  catch {
4390
4496
  repoRoot = null;
4391
4497
  }
4392
- const CLAUDE_AUTH_PROBE = 'claude -p "reply with exactly: ok"';
4393
- const probeExec = (cmd) => {
4394
- try {
4395
- return shellOps.execSync(cmd, { timeout: 120_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
4396
- }
4397
- catch {
4398
- return null;
4399
- }
4400
- };
4401
- let host = 'skipped';
4402
- let hostDetail = 'skipped — claude CLI not on PATH';
4403
- if (coreOps.isCommandAvailable('claude')) {
4404
- host = probeExec(CLAUDE_AUTH_PROBE) === null ? 'failed' : 'ok';
4405
- hostDetail = host === 'ok' ? 'claude -p succeeded on the host' : 'claude -p failed on the host';
4406
- }
4407
- let sandboxed = 'skipped';
4408
- let sandboxDetail = 'skipped — host claude auth was not verified';
4409
- const runtimeAvailable = hasSandboxRuntime(process.platform, (cmd) => isCommandAvailableViaShell(shellOps, cmd));
4410
- const skip = sandboxAuthSkipDetail({ runtimeAvailable, hostOk: host === 'ok', inRepo: repoRoot !== null });
4411
- if (skip !== null) {
4412
- sandboxDetail = skip;
4413
- }
4414
- else if (repoRoot !== null) {
4415
- const policy = resolveSandboxPolicy(configLoaders.loadFactoryConfigForRepo(getFactoryPaths(repoRoot).config).sandbox, {
4416
- worktree: repoRoot,
4417
- repoRoot,
4418
- isAvailable: (cmd) => isCommandAvailableViaShell(shellOps, cmd),
4419
- });
4420
- if (!policy) {
4421
- sandboxDetail = 'skipped — sandbox disabled by config or FACTORY_SANDBOX';
4422
- }
4423
- else if (policy.runtime === 'none') {
4424
- sandboxDetail = 'skipped — no sandbox runtime (sandbox-exec/firejail) on this host';
4425
- }
4426
- else if (policy.runtime === 'docker-sandbox') {
4427
- sandboxDetail = 'skipped — docker-sandbox containment is the microVM, not command wrapping (#653)';
4428
- }
4429
- else {
4430
- sandboxed = probeExec(wrapCommandInSandbox(CLAUDE_AUTH_PROBE, policy)) === null ? 'failed' : 'ok';
4431
- sandboxDetail =
4432
- sandboxed === 'ok'
4433
- ? `claude -p succeeded under ${policy.runtime}`
4434
- : `claude -p failed under ${policy.runtime} (local_auth)`;
4498
+ const checks = runDoctorChecks({
4499
+ commandAvailable: coreOps.isCommandAvailable,
4500
+ envPresent: (key) => !!process.env[key],
4501
+ tryExec: (cmd, opts) => {
4502
+ try {
4503
+ return shellOps
4504
+ .execSync(cmd, {
4505
+ timeout: opts?.timeoutMs ?? 10_000,
4506
+ stdio: ['ignore', 'pipe', 'ignore'],
4507
+ ...(opts?.env ? { env: { ...process.env, ...opts.env } } : {}),
4508
+ })
4509
+ .trim();
4510
+ }
4511
+ catch {
4512
+ return null;
4513
+ }
4514
+ },
4515
+ pathExists: existsSync,
4516
+ distFreshness: distFreshnessProbe(import.meta.url),
4517
+ codexSessionState: () => codexChatgptSessionState(),
4518
+ claudeLoggedIn: () => hasClaudeLoginEvidence({
4519
+ exec: (file, args) => {
4520
+ shellOps.execSync([file, ...args].map(shellEscape).join(' '), { timeout: 10_000, stdio: 'ignore' });
4521
+ },
4522
+ fileExists: existsSync,
4523
+ }),
4524
+ opencodeLoggedIn: () => hasOpencodeLoginEvidence(),
4525
+ commandHarnesses: () => repoRoot === null
4526
+ ? []
4527
+ : repoCommandHarnesses(configLoaders.loadModelsConfig().harnesses, configLoaders.loadRepoConfig(repoRoot)?.models?.registry),
4528
+ });
4529
+ // Only with --probe-inference: these send a real prompt (uses quota). Plain doctor makes no inference call.
4530
+ if (opts.probeInference) {
4531
+ const CLAUDE_AUTH_PROBE = 'claude -p "reply with exactly: ok"';
4532
+ const probeExec = (cmd) => {
4533
+ try {
4534
+ return shellOps.execSync(cmd, { timeout: 120_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
4535
+ }
4536
+ catch {
4537
+ return null;
4538
+ }
4539
+ };
4540
+ let host = 'skipped';
4541
+ let hostDetail = 'skipped: claude CLI not on PATH';
4542
+ if (coreOps.isCommandAvailable('claude')) {
4543
+ host = probeExec(CLAUDE_AUTH_PROBE) === null ? 'failed' : 'ok';
4544
+ hostDetail = host === 'ok' ? 'claude -p succeeded on the host' : 'claude -p failed on the host';
4545
+ }
4546
+ let sandboxed = 'skipped';
4547
+ let sandboxDetail = 'skipped: host claude auth was not verified';
4548
+ const runtimeAvailable = hasSandboxRuntime(process.platform, (cmd) => isCommandAvailableViaShell(shellOps, cmd));
4549
+ const skip = sandboxAuthSkipDetail({ runtimeAvailable, hostOk: host === 'ok', inRepo: repoRoot !== null });
4550
+ if (skip !== null) {
4551
+ sandboxDetail = skip;
4552
+ }
4553
+ else if (repoRoot !== null) {
4554
+ const policy = resolveSandboxPolicy(configLoaders.loadFactoryConfigForRepo(getFactoryPaths(repoRoot).config).sandbox, {
4555
+ worktree: repoRoot,
4556
+ repoRoot,
4557
+ isAvailable: (cmd) => isCommandAvailableViaShell(shellOps, cmd),
4558
+ });
4559
+ if (!policy) {
4560
+ sandboxDetail = 'skipped: sandbox disabled by config or FACTORY_SANDBOX';
4561
+ }
4562
+ else if (policy.runtime === 'none') {
4563
+ sandboxDetail = 'skipped: no sandbox runtime (sandbox-exec/firejail) on this host';
4564
+ }
4565
+ else if (policy.runtime === 'docker-sandbox') {
4566
+ sandboxDetail = 'skipped: docker-sandbox containment is the microVM, not command wrapping (#653)';
4567
+ }
4568
+ else {
4569
+ sandboxed = probeExec(wrapCommandInSandbox(CLAUDE_AUTH_PROBE, policy)) === null ? 'failed' : 'ok';
4570
+ sandboxDetail =
4571
+ sandboxed === 'ok'
4572
+ ? `claude -p succeeded under ${policy.runtime}`
4573
+ : `claude -p failed under ${policy.runtime} (local_auth)`;
4574
+ }
4435
4575
  }
4576
+ checks.push(...sandboxClaudeAuthChecks({ host, sandboxed, hostDetail, sandboxDetail }));
4436
4577
  }
4437
4578
  checks.push(claudeKeychainCheck(probeClaudeKeychain()));
4438
- checks.push(...sandboxClaudeAuthChecks({ host, sandboxed, hostDetail, sandboxDetail }));
4579
+ checks.splice(0, checks.length, ...reconcileAgentAuthChecks(checks));
4439
4580
  if (repoRoot !== null) {
4440
4581
  const paths = getFactoryPaths(repoRoot);
4441
- 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
+ }
4442
4589
  const health = await inspectPortLeases({ registryFile: paths.ports });
4443
4590
  checks.push(...leaseChecks(health.map(toLeaseRow)));
4444
4591
  const dockerAvailable = coreOps.isCommandAvailable('docker');
@@ -4455,6 +4602,25 @@ async function cmdDoctor(opts = {}) {
4455
4602
  checks.push(stopSentinelCheck(readStopFileStatus(paths)));
4456
4603
  const eventsContent = existsSync(paths.events) ? readFileSync(paths.events, 'utf-8') : null;
4457
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));
4614
+ try {
4615
+ const declared = Object.keys(configLoaders.loadModelsConfig().harnesses ?? {});
4616
+ checks.push(...modelsRegistryChecks(paths.config, configLoaders.loadRepoConfig(repoRoot)?.models?.registry, [
4617
+ ...KNOWN_HARNESS_IDS,
4618
+ ...declared,
4619
+ ]));
4620
+ }
4621
+ catch {
4622
+ // malformed config is reported by the load paths / `factory models --doctor`; doctor keeps going
4623
+ }
4458
4624
  let harnessProbes = [];
4459
4625
  try {
4460
4626
  const registry = new coreOps.ModelRegistry(applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot)));
@@ -4486,7 +4652,7 @@ async function cmdDoctor(opts = {}) {
4486
4652
  const graceMs = resolveProcessGroupGraceMs(factoryConfig);
4487
4653
  const orphanEvents = await coreOps.reapOrphanProcesses({ reaped, graceMs });
4488
4654
  for (const e of orphanEvents) {
4489
- 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' : ''}`);
4490
4656
  }
4491
4657
  }
4492
4658
  try {
@@ -4501,7 +4667,7 @@ async function cmdDoctor(opts = {}) {
4501
4667
  console.log(formatWorktreeReconcileReport(report.removed));
4502
4668
  }
4503
4669
  catch (err) {
4504
- console.log(`reconcile: worktree sweep failed — ${err?.message ?? String(err)}`);
4670
+ console.log(`reconcile: worktree sweep failed: ${err?.message ?? String(err)}`);
4505
4671
  }
4506
4672
  // Stale claim reap (#999): factory:in-progress claims naming a dead local pid go back to
4507
4673
  // factory:queued so the next run can claim them with no manual label surgery.
@@ -4516,15 +4682,15 @@ async function cmdDoctor(opts = {}) {
4516
4682
  console.log(formatClaimReconcileReport(released));
4517
4683
  }
4518
4684
  else {
4519
- console.log('reconcile: skipping stale claims — no GitHub repo or token');
4685
+ console.log('reconcile: skipping stale claims: no GitHub repo or token');
4520
4686
  }
4521
4687
  }
4522
4688
  catch (err) {
4523
- console.log(`reconcile: stale claim release failed — ${err?.message ?? String(err)}`);
4689
+ console.log(`reconcile: stale claim release failed: ${err?.message ?? String(err)}`);
4524
4690
  }
4525
4691
  }
4526
4692
  }
4527
- console.log(formatDoctorChecks(checks));
4693
+ console.log(formatDoctorChecks(checks, { inRepo: repoRoot !== null }));
4528
4694
  if (doctorFailed(checks))
4529
4695
  process.exit(1);
4530
4696
  }
@@ -4570,7 +4736,17 @@ export async function cmdStop(opts = {}) {
4570
4736
  }
4571
4737
  console.log('STOP set — lanes halt between issues');
4572
4738
  }
4573
- 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
+ }
4574
4750
  const repoRoot = await getRepoRoot();
4575
4751
  const paths = getFactoryPaths(repoRoot);
4576
4752
  const json = opts.json ? { repo: await getGitHubRepo() } : undefined;
@@ -4585,6 +4761,47 @@ export async function cmdResume(opts = {}) {
4585
4761
  }
4586
4762
  console.log('STOP cleared');
4587
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
+ }
4588
4805
  // ---------- main ----------
4589
4806
  export async function main(argv = process.argv, deps = {}) {
4590
4807
  if (argv.slice(2).length === 0) {
@@ -4648,8 +4865,9 @@ export async function main(argv = process.argv, deps = {}) {
4648
4865
  .action(cmdModels);
4649
4866
  program
4650
4867
  .command('doctor')
4651
- .description('Check your environment (git, claude, gh auth, GitHub token, npm, sandbox, .factory/)')
4868
+ .description('Check your environment (git, agent install and sign-in, gh auth, GitHub token, npm, sandbox, .factory/)')
4652
4869
  .option('--reconcile', 'Reap stale port leases, dead-run worktrees, stale issue claims, orphan sf-job-*/factory.managed containers, and orphan factory-* sbx VMs')
4870
+ .option('--probe-inference', 'Also send one real prompt with claude -p on the host and in the sandbox to verify Claude auth (uses quota)')
4653
4871
  .action((opts) => cmdDoctor(opts));
4654
4872
  program
4655
4873
  .command('cost')
@@ -4744,12 +4962,13 @@ export async function main(argv = process.argv, deps = {}) {
4744
4962
  .action((runId, opts) => cmdFilingPreview(runId, opts));
4745
4963
  const triage = program
4746
4964
  .command('triage')
4747
- .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')
4748
4966
  .option('--product <name>', 'Product constitution to scope triage')
4967
+ .option('--model <id>', 'Model to triage with (any registered model, Claude or Codex)')
4749
4968
  .action(cmdTriage);
4750
4969
  triage
4751
4970
  .command('accept')
4752
- .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')
4753
4972
  .option('--force', 'Skip validation and promote as-is')
4754
4973
  .action(async (opts) => {
4755
4974
  await cmdTriageAccept(opts);
@@ -4869,7 +5088,7 @@ export async function main(argv = process.argv, deps = {}) {
4869
5088
  .option('--no-sandbox', 'Disable the containment sandbox for agent runs (dangerous)')
4870
5089
  .option('--branch-prefix <prefix>', BRANCH_PREFIX_OPTION_DESCRIPTION)
4871
5090
  .option('--run-children', 'File mode only: when the size gate files child issues, run them (slice mode never files issues)')
4872
- .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')
4873
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)')
4874
5093
  .action(async (issueNum, opts) => {
4875
5094
  await cmdRunIssue(parseIssueArg(issueNum), opts);
@@ -5019,10 +5238,11 @@ export async function main(argv = process.argv, deps = {}) {
5019
5238
  .option('--json', 'Print one JSON object (schemaVersion 1): action, repo, stopFlag before/after')
5020
5239
  .action((opts) => cmdStop(opts));
5021
5240
  program
5022
- .command('resume')
5023
- .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')
5024
5243
  .option('--json', 'Print one JSON object (schemaVersion 1): action, repo, stopFlag before/after')
5025
- .action((opts) => cmdResume(opts));
5244
+ .option('--from <phase>', 'Resume a kept build at this phase (only: check)')
5245
+ .action((issue, opts) => cmdResume(issue, opts));
5026
5246
  applyHelpGroups(program);
5027
5247
  const previousLoaders = configLoaders;
5028
5248
  const previousGitOps = gitOps;