@on-par/factory-cli 0.9.3 → 0.9.4

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
@@ -8,8 +8,8 @@ import { arch, hostname as osHostname, release, platform, userInfo } from 'node:
8
8
  import { basename, dirname, 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, 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';
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, modelsRegistryChecks, orphanMicroVmChecks, runDoctorChecks, sandboxAuthSkipDetail, reconcileAgentAuthChecks, 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';
@@ -179,7 +179,9 @@ function resolvePhaseOps(overrides, base = phaseOps) {
179
179
  shipPhase: overrides.shipPhase ?? base.shipPhase,
180
180
  };
181
181
  }
182
- export const PREREQUISITES_TEXT = `Prerequisites:
182
+ export const PREREQUISITES_TEXT = `First step: run \`factory doctor\` to check your setup and see what to fix.
183
+
184
+ Prerequisites:
183
185
  - Claude Code CLI installed and authenticated (Claude subscription): https://claude.com/claude-code
184
186
  - GitHub CLI authenticated: gh auth login
185
187
  - GITHUB_TOKEN or GH_TOKEN set (falls back to \`gh auth token\`)
@@ -339,13 +341,13 @@ async function cmdInit(opts = {}) {
339
341
  // Doctor-style validation (policy=auto, N models reachable) — informational, never fails init.
340
342
  const modelsConfig = applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot));
341
343
  const registry = new coreOps.ModelRegistry(modelsConfig);
342
- const diagnoses = coreOps.diagnoseModels(registry, modelDoctorProbes(), resolveExperimental(), resolveLocalOnly());
344
+ const diagnoses = coreOps.diagnoseModels(registry, modelDoctorProbes({ requireOllamaOnPath: true }), resolveExperimental(), resolveLocalOnly());
343
345
  console.log(formatInitReachability(diagnoses));
344
346
  if (!hasReachableWorker(diagnoses)) {
345
347
  console.log(styleText('yellow', 'No worker model reachable yet — see `factory doctor` and `factory models --doctor`.'));
346
348
  }
347
349
  console.log(styleText('green', `Initialized ${paths.root}`));
348
- console.log(`Next: factory constitution --product <name>, then factory triage`);
350
+ console.log('Next: factory doctor, then factory triage (optional: factory constitution --product <name> to swap the default constitution)');
349
351
  }
350
352
  /** Rewrite a checkout's legacy `.factory/` inputs and runtime files to the v2
351
353
  * layout. Every write is idempotent so it is safe to run this more than once. */
@@ -723,12 +725,16 @@ export async function cmdConstitution(opts, deps = {}) {
723
725
  export function modelStatusIcon(d) {
724
726
  return d.reachable ? styleText('green', '✅') : d.unverified ? styleText('yellow', '⚠️') : styleText('red', '❌');
725
727
  }
726
- export function formatDoctorReport(diagnoses) {
728
+ /** Single owner of the `[repo-defined]` marker for models declared in `.factory/config.json` models.registry, shared by `factory models` and `models --doctor` (#2443). */
729
+ export function repoDefinedTag(model, repoDefined) {
730
+ return repoDefined.has(model) ? styleText('cyan', ' [repo-defined]') : '';
731
+ }
732
+ export function formatDoctorReport(diagnoses, repoDefined = new Set()) {
727
733
  const lines = [styleText('bold', '\n== Model Doctor ==')];
728
734
  for (const d of diagnoses) {
729
735
  const icon = modelStatusIcon(d);
730
736
  const tiers = d.tiers.join('/');
731
- lines.push(` ${icon} ${d.model} provider=${d.provider} tier=${tiers} — ${d.reason}`);
737
+ lines.push(` ${icon} ${d.model} provider=${d.provider} tier=${tiers} — ${d.reason}${repoDefinedTag(d.model, repoDefined)}`);
732
738
  }
733
739
  lines.push(` ${formatReachabilityCount(diagnoses)}`);
734
740
  return lines.join('\n');
@@ -736,7 +742,9 @@ export function formatDoctorReport(diagnoses) {
736
742
  export function hasReachableWorker(diagnoses) {
737
743
  return diagnoses.some((d) => d.reachable && (d.tiers.includes('worker') || d.tiers.includes('worker_fallback')));
738
744
  }
739
- function ollamaModelSet() {
745
+ function ollamaModelSet(requireOnPath = false) {
746
+ if (requireOnPath && !coreOps.isCommandAvailable('ollama'))
747
+ return undefined;
740
748
  try {
741
749
  const out = shellOps.execSync('ollama list', { timeout: 10_000 });
742
750
  return new Set(out
@@ -750,9 +758,10 @@ function ollamaModelSet() {
750
758
  }
751
759
  }
752
760
  /** 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();
761
+ * preflight), so their counts agree. init passes `requireOllamaOnPath` so a missing binary never
762
+ * reaches the shell (#2507). */
763
+ export function modelDoctorProbes(opts = {}) {
764
+ const ollamaModels = ollamaModelSet(opts.requireOllamaOnPath === true);
756
765
  return {
757
766
  commandAvailable: coreOps.isCommandAvailable,
758
767
  ollamaModelPresent: ollamaModels ? (model) => ollamaModels.has(model) : undefined,
@@ -760,13 +769,15 @@ export function modelDoctorProbes() {
760
769
  }
761
770
  async function cmdModels(opts = {}) {
762
771
  const repoRoot = await getRepoRoot();
763
- const modelsConfig = applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot));
772
+ const repoConfig = configLoaders.loadRepoConfig(repoRoot);
773
+ const modelsConfig = applyRepoConfig(configLoaders.loadModelsConfig(), repoConfig);
774
+ const repoDefined = new Set(Object.keys(repoConfig?.models?.registry ?? {}));
764
775
  const registry = new coreOps.ModelRegistry(modelsConfig);
765
776
  const allowExperimental = resolveExperimental();
766
777
  const localOnly = resolveLocalOnly();
767
778
  const diagnoses = coreOps.diagnoseModels(registry, modelDoctorProbes(), allowExperimental, localOnly);
768
779
  if (opts.doctor) {
769
- console.log(formatDoctorReport(diagnoses));
780
+ console.log(formatDoctorReport(diagnoses, repoDefined));
770
781
  if (!hasReachableWorker(diagnoses)) {
771
782
  throw new CliExitError('factory: no worker model is reachable — fix the reasons above before running a queue', 1);
772
783
  }
@@ -781,7 +792,7 @@ async function cmdModels(opts = {}) {
781
792
  const d = byModel.get(m);
782
793
  const avail = d ? modelStatusIcon(d) : styleText('red', '❌');
783
794
  const tag = registry.isExperimental(m) ? styleText('yellow', ' [experimental]') : '';
784
- console.log(` ${avail} ${m} tier=${tiers} ${cost}${tag}`);
795
+ console.log(` ${avail} ${m} tier=${tiers} ${cost}${tag}${repoDefinedTag(m, repoDefined)}`);
785
796
  }
786
797
  console.log(styleText('bold', '\n== Tiers =='));
787
798
  for (const tier of ['boss', 'worker', 'checker', 'triage']) {
@@ -1560,6 +1571,7 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1560
1571
  : { ...resolveEffectiveConfig(repoConfig), branchPrefix: opts.branchPrefix },
1561
1572
  };
1562
1573
  const router = new core.ModelRouter(policy.models, policy.routes, false, undefined, policy.effective.allowExperimental, policy.effective.localOnly);
1574
+ router.skipMissingHarnessClis(core.isCommandAvailable);
1563
1575
  let issueSpend = 0;
1564
1576
  let laneSandboxRuntime = 'none';
1565
1577
  const laneWorkspaceBackend = factoryConfig.workspace?.backend === 'disposable-docker' ? 'disposable-docker' : 'worktree';
@@ -2014,8 +2026,8 @@ export async function sweepBetweenLaneIssues(issue, lane, repoRoot, ghRepo, path
2014
2026
  }
2015
2027
  }
2016
2028
  async function cmdShip(issueNum, opts) {
2017
- if (!coreOps.isCommandAvailable('claude')) {
2018
- throw new CliExitError(`factory: ${missingClaudeCliMessage()}`, 2);
2029
+ if (availableAgentClis(coreOps.isCommandAvailable).length === 0) {
2030
+ throw new CliExitError(`factory: ${missingAgentCliMessage()}`, 2);
2019
2031
  }
2020
2032
  const repoRoot = await getRepoRoot();
2021
2033
  const paths = getFactoryPaths(repoRoot);
@@ -2050,13 +2062,16 @@ async function cmdShip(issueNum, opts) {
2050
2062
  catch (err) {
2051
2063
  if (err instanceof IssueSkippedError)
2052
2064
  return;
2065
+ // #2492: a pull request number is a usage error, not a failed ship.
2066
+ if (err instanceof InvalidWorkRequestInputError)
2067
+ throw new CliExitError(`factory: ${err.message}`, 2);
2053
2068
  throw new CliExitError(`Ship failed for issue #${issueNum}: ${err.message}`, 1);
2054
2069
  }
2055
2070
  });
2056
2071
  }
2057
2072
  async function cmdRunIssue(issueNum, opts) {
2058
- if (!coreOps.isCommandAvailable('claude')) {
2059
- throw new CliExitError(`factory: ${missingClaudeCliMessage()}`, 2);
2073
+ if (availableAgentClis(coreOps.isCommandAvailable).length === 0) {
2074
+ throw new CliExitError(`factory: ${missingAgentCliMessage()}`, 2);
2060
2075
  }
2061
2076
  const repoRoot = await getRepoRoot();
2062
2077
  const paths = getFactoryPaths(repoRoot);
@@ -2173,8 +2188,8 @@ async function runDecomposedChildren(parent, children, opts, env) {
2173
2188
  }
2174
2189
  }
2175
2190
  async function cmdRunBrief(briefPath, opts) {
2176
- if (!coreOps.isCommandAvailable('claude')) {
2177
- throw new CliExitError(`factory: ${missingClaudeCliMessage()}`, 2);
2191
+ if (availableAgentClis(coreOps.isCommandAvailable).length === 0) {
2192
+ throw new CliExitError(`factory: ${missingAgentCliMessage()}`, 2);
2178
2193
  }
2179
2194
  const repoRoot = await getRepoRoot();
2180
2195
  const paths = getFactoryPaths(repoRoot);
@@ -2290,8 +2305,8 @@ async function cmdLocalSmallOvernight(opts) {
2290
2305
  const ghRepo = await getGitHubRepo();
2291
2306
  process.env.FACTORY_LOCAL_ONLY = '1';
2292
2307
  const preflight = async () => {
2293
- if (!coreOps.isCommandAvailable('claude'))
2294
- return { ok: false, reason: missingClaudeCliMessage() };
2308
+ if (availableAgentClis(coreOps.isCommandAvailable).length === 0)
2309
+ return { ok: false, reason: missingAgentCliMessage() };
2295
2310
  const registry = new coreOps.ModelRegistry(applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot)));
2296
2311
  const diagnoses = coreOps.diagnoseModels(registry, modelDoctorProbes(), resolveExperimental(), true);
2297
2312
  if (!hasReachableWorker(diagnoses)) {
@@ -2635,28 +2650,33 @@ async function cmdTriage(opts) {
2635
2650
  const routesConfig = configLoaders.loadRoutesConfig();
2636
2651
  const effective = resolveEffectiveConfig(repoConfig);
2637
2652
  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.`;
2653
+ router.skipMissingHarnessClis(coreOps.isCommandAvailable);
2654
+ router.setCostSink((entry) => logCost(paths.costs, { ...entry, issue: '-' }));
2652
2655
  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) => {
2656
+ let model;
2657
+ try {
2658
+ const { stdout: listed } = await shellOps.exec(`gh issue list --repo ${shellEscape(ghRepo)} --state open --limit 100 --json number,title,labels,body`);
2659
+ const prompt = buildTriagePrompt({ repo: ghRepo, product, candidates: parseTriageCandidates(listed || '[]') });
2660
+ logEvent(paths.events, 'triage', '-', `Triaging ${ghRepo} via the triage route`);
2661
+ const result = await router.run('triage', prompt, {
2662
+ worktree: repoRoot,
2663
+ timeoutSeconds: 600,
2664
+ ...(opts.model ? { modelOverride: opts.model } : {}),
2665
+ onLog: (msg) => logEvent(paths.events, 'triage', '-', msg),
2666
+ });
2667
+ model = result.model;
2668
+ const queue = normalizeTriageQueue(result.output);
2669
+ if (queue) {
2670
+ const header = `# Triage queue (model ${model}${product ? `, constitution ${product}` : ''})\n`;
2671
+ writeFileSync(paths.queueProposed, header + queue);
2672
+ }
2673
+ }
2674
+ catch (err) {
2657
2675
  plannerError = err;
2658
2676
  logEvent(paths.events, 'warn', '-', `triage planner failed: ${errorDetail(err)}`);
2659
- });
2677
+ }
2678
+ if (model)
2679
+ logEvent(paths.events, 'triage', '-', `Triaged ${ghRepo} with ${model}`);
2660
2680
  const proposed = existsSync(paths.queueProposed) ? readFileSync(paths.queueProposed, 'utf-8') : '';
2661
2681
  const message = triageProposalMessage(proposed, paths.queueProposed, paths.queue);
2662
2682
  if (message) {
@@ -4366,9 +4386,15 @@ async function cmdDoctor(opts = {}) {
4366
4386
  const checks = runDoctorChecks({
4367
4387
  commandAvailable: coreOps.isCommandAvailable,
4368
4388
  envPresent: (key) => !!process.env[key],
4369
- tryExec: (cmd) => {
4389
+ tryExec: (cmd, opts) => {
4370
4390
  try {
4371
- return shellOps.execSync(cmd, { timeout: 10_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
4391
+ return shellOps
4392
+ .execSync(cmd, {
4393
+ timeout: opts?.timeoutMs ?? 10_000,
4394
+ stdio: ['ignore', 'pipe', 'ignore'],
4395
+ ...(opts?.env ? { env: { ...process.env, ...opts.env } } : {}),
4396
+ })
4397
+ .trim();
4372
4398
  }
4373
4399
  catch {
4374
4400
  return null;
@@ -4376,6 +4402,14 @@ async function cmdDoctor(opts = {}) {
4376
4402
  },
4377
4403
  pathExists: existsSync,
4378
4404
  distFreshness: distFreshnessProbe(import.meta.url),
4405
+ codexSessionState: () => codexChatgptSessionState(),
4406
+ claudeLoggedIn: () => hasClaudeLoginEvidence({
4407
+ exec: (file, args) => {
4408
+ shellOps.execSync([file, ...args].map(shellEscape).join(' '), { timeout: 10_000, stdio: 'ignore' });
4409
+ },
4410
+ fileExists: existsSync,
4411
+ }),
4412
+ opencodeLoggedIn: () => hasOpencodeLoginEvidence(),
4379
4413
  });
4380
4414
  let repoRoot;
4381
4415
  try {
@@ -4389,53 +4423,57 @@ async function cmdDoctor(opts = {}) {
4389
4423
  catch {
4390
4424
  repoRoot = null;
4391
4425
  }
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)`;
4426
+ // Only with --probe-inference: these send a real prompt (uses quota). Plain doctor makes no inference call.
4427
+ if (opts.probeInference) {
4428
+ const CLAUDE_AUTH_PROBE = 'claude -p "reply with exactly: ok"';
4429
+ const probeExec = (cmd) => {
4430
+ try {
4431
+ return shellOps.execSync(cmd, { timeout: 120_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
4432
+ }
4433
+ catch {
4434
+ return null;
4435
+ }
4436
+ };
4437
+ let host = 'skipped';
4438
+ let hostDetail = 'skipped — claude CLI not on PATH';
4439
+ if (coreOps.isCommandAvailable('claude')) {
4440
+ host = probeExec(CLAUDE_AUTH_PROBE) === null ? 'failed' : 'ok';
4441
+ hostDetail = host === 'ok' ? 'claude -p succeeded on the host' : 'claude -p failed on the host';
4442
+ }
4443
+ let sandboxed = 'skipped';
4444
+ let sandboxDetail = 'skipped — host claude auth was not verified';
4445
+ const runtimeAvailable = hasSandboxRuntime(process.platform, (cmd) => isCommandAvailableViaShell(shellOps, cmd));
4446
+ const skip = sandboxAuthSkipDetail({ runtimeAvailable, hostOk: host === 'ok', inRepo: repoRoot !== null });
4447
+ if (skip !== null) {
4448
+ sandboxDetail = skip;
4449
+ }
4450
+ else if (repoRoot !== null) {
4451
+ const policy = resolveSandboxPolicy(configLoaders.loadFactoryConfigForRepo(getFactoryPaths(repoRoot).config).sandbox, {
4452
+ worktree: repoRoot,
4453
+ repoRoot,
4454
+ isAvailable: (cmd) => isCommandAvailableViaShell(shellOps, cmd),
4455
+ });
4456
+ if (!policy) {
4457
+ sandboxDetail = 'skipped — sandbox disabled by config or FACTORY_SANDBOX';
4458
+ }
4459
+ else if (policy.runtime === 'none') {
4460
+ sandboxDetail = 'skipped — no sandbox runtime (sandbox-exec/firejail) on this host';
4461
+ }
4462
+ else if (policy.runtime === 'docker-sandbox') {
4463
+ sandboxDetail = 'skipped — docker-sandbox containment is the microVM, not command wrapping (#653)';
4464
+ }
4465
+ else {
4466
+ sandboxed = probeExec(wrapCommandInSandbox(CLAUDE_AUTH_PROBE, policy)) === null ? 'failed' : 'ok';
4467
+ sandboxDetail =
4468
+ sandboxed === 'ok'
4469
+ ? `claude -p succeeded under ${policy.runtime}`
4470
+ : `claude -p failed under ${policy.runtime} (local_auth)`;
4471
+ }
4435
4472
  }
4473
+ checks.push(...sandboxClaudeAuthChecks({ host, sandboxed, hostDetail, sandboxDetail }));
4436
4474
  }
4437
4475
  checks.push(claudeKeychainCheck(probeClaudeKeychain()));
4438
- checks.push(...sandboxClaudeAuthChecks({ host, sandboxed, hostDetail, sandboxDetail }));
4476
+ checks.splice(0, checks.length, ...reconcileAgentAuthChecks(checks));
4439
4477
  if (repoRoot !== null) {
4440
4478
  const paths = getFactoryPaths(repoRoot);
4441
4479
  const factoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
@@ -4455,6 +4493,16 @@ async function cmdDoctor(opts = {}) {
4455
4493
  checks.push(stopSentinelCheck(readStopFileStatus(paths)));
4456
4494
  const eventsContent = existsSync(paths.events) ? readFileSync(paths.events, 'utf-8') : null;
4457
4495
  checks.push(eventLogCheck(eventsContent === null ? null : analyzeEventLog(eventsContent)));
4496
+ try {
4497
+ const declared = Object.keys(configLoaders.loadModelsConfig().harnesses ?? {});
4498
+ checks.push(...modelsRegistryChecks(paths.config, configLoaders.loadRepoConfig(repoRoot)?.models?.registry, [
4499
+ ...KNOWN_HARNESS_IDS,
4500
+ ...declared,
4501
+ ]));
4502
+ }
4503
+ catch {
4504
+ // malformed config is reported by the load paths / `factory models --doctor`; doctor keeps going
4505
+ }
4458
4506
  let harnessProbes = [];
4459
4507
  try {
4460
4508
  const registry = new coreOps.ModelRegistry(applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot)));
@@ -4524,7 +4572,7 @@ async function cmdDoctor(opts = {}) {
4524
4572
  }
4525
4573
  }
4526
4574
  }
4527
- console.log(formatDoctorChecks(checks));
4575
+ console.log(formatDoctorChecks(checks, { inRepo: repoRoot !== null }));
4528
4576
  if (doctorFailed(checks))
4529
4577
  process.exit(1);
4530
4578
  }
@@ -4648,8 +4696,9 @@ export async function main(argv = process.argv, deps = {}) {
4648
4696
  .action(cmdModels);
4649
4697
  program
4650
4698
  .command('doctor')
4651
- .description('Check your environment (git, claude, gh auth, GitHub token, npm, sandbox, .factory/)')
4699
+ .description('Check your environment (git, agent install and sign-in, gh auth, GitHub token, npm, sandbox, .factory/)')
4652
4700
  .option('--reconcile', 'Reap stale port leases, dead-run worktrees, stale issue claims, orphan sf-job-*/factory.managed containers, and orphan factory-* sbx VMs')
4701
+ .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
4702
  .action((opts) => cmdDoctor(opts));
4654
4703
  program
4655
4704
  .command('cost')
@@ -4746,6 +4795,7 @@ export async function main(argv = process.argv, deps = {}) {
4746
4795
  .command('triage')
4747
4796
  .description('Have a model propose a queue from open issues into .factory/queue.proposed')
4748
4797
  .option('--product <name>', 'Product constitution to scope triage')
4798
+ .option('--model <id>', 'Model to triage with (any registered model, Claude or Codex)')
4749
4799
  .action(cmdTriage);
4750
4800
  triage
4751
4801
  .command('accept')