@on-par/factory-cli 0.9.2 → 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
@@ -2,14 +2,14 @@ import { fileURLToPath } from 'node:url';
2
2
  // packages/cli/src/cli/index.ts — CLI entry point: factory <command> [options]
3
3
  import { exec as execCb, execSync } from 'node:child_process';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
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
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, 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, 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, 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';
@@ -144,6 +144,7 @@ const DEFAULT_CORE_OPS = {
144
144
  watchUsage,
145
145
  fetchSubscriptionUsage,
146
146
  diagnoseModels,
147
+ probeDeclaredHarnesses,
147
148
  writeLocalRunReport,
148
149
  };
149
150
  /** factory-core classes/ops for the current main() invocation; DEFAULT_CORE_OPS outside it. */
@@ -162,6 +163,7 @@ function resolveCoreOps(overrides, base = coreOps) {
162
163
  watchUsage: overrides.watchUsage ?? base.watchUsage,
163
164
  fetchSubscriptionUsage: overrides.fetchSubscriptionUsage ?? base.fetchSubscriptionUsage,
164
165
  diagnoseModels: overrides.diagnoseModels ?? base.diagnoseModels,
166
+ probeDeclaredHarnesses: overrides.probeDeclaredHarnesses ?? base.probeDeclaredHarnesses,
165
167
  writeLocalRunReport: overrides.writeLocalRunReport ?? base.writeLocalRunReport,
166
168
  };
167
169
  }
@@ -177,7 +179,9 @@ function resolvePhaseOps(overrides, base = phaseOps) {
177
179
  shipPhase: overrides.shipPhase ?? base.shipPhase,
178
180
  };
179
181
  }
180
- 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:
181
185
  - Claude Code CLI installed and authenticated (Claude subscription): https://claude.com/claude-code
182
186
  - GitHub CLI authenticated: gh auth login
183
187
  - GITHUB_TOKEN or GH_TOKEN set (falls back to \`gh auth token\`)
@@ -297,8 +301,14 @@ function writeIfAbsent(path, content, label, force = false) {
297
301
  }
298
302
  /** One-line onboarding reachability summary, e.g. "policy=auto, 3/7 models reachable". */
299
303
  export function formatInitReachability(diagnoses) {
304
+ return `policy=auto, ${formatReachabilityCount(diagnoses)}`;
305
+ }
306
+ /** Shared "K/N models reachable[, U unverified (no auth evidence)]" count for init and models --doctor. */
307
+ export function formatReachabilityCount(diagnoses) {
300
308
  const reachable = diagnoses.filter((d) => d.reachable).length;
301
- return `policy=auto, ${reachable}/${diagnoses.length} models reachable`;
309
+ const unverified = diagnoses.filter((d) => !d.reachable && d.unverified).length;
310
+ const base = `${reachable}/${diagnoses.length} models reachable`;
311
+ return unverified > 0 ? `${base}, ${unverified} unverified (no auth evidence)` : base;
302
312
  }
303
313
  async function cmdInit(opts = {}) {
304
314
  const repoRoot = await getRepoRoot();
@@ -321,21 +331,23 @@ async function cmdInit(opts = {}) {
321
331
  const constitutionPath = resolve(paths.root, 'constitution.md');
322
332
  const gitignorePath = resolve(paths.root, '.gitignore');
323
333
  writeIfAbsent(configPath, buildInitConfig(configPath.endsWith('.json') ? 'json' : 'yaml'), `.factory/${basename(configPath)}`, force);
324
- // Constitution scaffold: repo directory basename fills <product-name>/<Product>.
325
- const repoName = basename(repoRoot);
326
- const template = readFileSync(resolve(configLoaders.getConstitutionsDir(), '_template.md'), 'utf-8');
327
- writeIfAbsent(constitutionPath, scaffoldConstitution(template, repoName), '.factory/constitution.md', force);
334
+ // Constitution scaffold: the active product's bundled file, else the default-code constitution.
335
+ const constitutionsDir = configLoaders.getConstitutionsDir();
336
+ const active = readActiveProduct(paths.product);
337
+ const activeFile = active ? resolve(constitutionsDir, `${active}.md`) : undefined;
338
+ const source = activeFile && existsSync(activeFile) ? activeFile : resolve(constitutionsDir, 'default-code.md');
339
+ writeIfAbsent(constitutionPath, readFileSync(source, 'utf-8'), '.factory/constitution.md', force);
328
340
  writeIfAbsent(gitignorePath, 'state/\n', '.factory/.gitignore', force);
329
341
  // Doctor-style validation (policy=auto, N models reachable) — informational, never fails init.
330
342
  const modelsConfig = applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot));
331
343
  const registry = new coreOps.ModelRegistry(modelsConfig);
332
- const diagnoses = coreOps.diagnoseModels(registry, {}, resolveExperimental(), resolveLocalOnly());
344
+ const diagnoses = coreOps.diagnoseModels(registry, modelDoctorProbes({ requireOllamaOnPath: true }), resolveExperimental(), resolveLocalOnly());
333
345
  console.log(formatInitReachability(diagnoses));
334
346
  if (!hasReachableWorker(diagnoses)) {
335
347
  console.log(styleText('yellow', 'No worker model reachable yet — see `factory doctor` and `factory models --doctor`.'));
336
348
  }
337
349
  console.log(styleText('green', `Initialized ${paths.root}`));
338
- 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)');
339
351
  }
340
352
  /** Rewrite a checkout's legacy `.factory/` inputs and runtime files to the v2
341
353
  * layout. Every write is idempotent so it is safe to run this more than once. */
@@ -604,6 +616,40 @@ export function scaffoldConstitution(template, product) {
604
616
  .join(' ');
605
617
  return skeleton.replaceAll('<product-name>', JSON.stringify(product)).replaceAll('<Product>', display);
606
618
  }
619
+ /**
620
+ * True when `existing` is an untouched scaffold: byte-identical (CRLF and trailing whitespace
621
+ * normalised) to a bundled constitution or to the legacy `_template.md` scaffold for `repoName`.
622
+ * Exact equality only — an edited file is never treated as a scaffold.
623
+ */
624
+ export function isUnmodifiedConstitutionScaffold(existing, constitutionsDir, repoName) {
625
+ const norm = (s) => s.replace(/\r\n/g, '\n').trimEnd();
626
+ const target = norm(existing);
627
+ if (target === '')
628
+ return true;
629
+ let names = [];
630
+ try {
631
+ names = readdirSync(constitutionsDir).filter((f) => f.endsWith('.md') && !f.startsWith('_'));
632
+ }
633
+ catch {
634
+ // unreadable dir: no bundled files to compare against
635
+ }
636
+ for (const f of names) {
637
+ try {
638
+ if (norm(readFileSync(resolve(constitutionsDir, f), 'utf-8')) === target)
639
+ return true;
640
+ }
641
+ catch {
642
+ // skip unreadable entries
643
+ }
644
+ }
645
+ try {
646
+ const template = readFileSync(resolve(constitutionsDir, '_template.md'), 'utf-8');
647
+ return norm(scaffoldConstitution(template, repoName)) === target;
648
+ }
649
+ catch {
650
+ return false;
651
+ }
652
+ }
607
653
  export async function cmdConstitution(opts, deps = {}) {
608
654
  const loader = new (resolveCoreOps(deps).ConstitutionLoader)();
609
655
  if (typeof opts.init === 'string' || opts.init === true) {
@@ -634,8 +680,21 @@ export async function cmdConstitution(opts, deps = {}) {
634
680
  }
635
681
  if (opts.list) {
636
682
  const products = loader.listProducts();
637
- for (const p of products)
638
- console.log(` ${p}`);
683
+ for (const p of products) {
684
+ try {
685
+ const checkers = loader.load(p).checkers.join(', ');
686
+ console.log(` ${p} — ${checkers || '(no checkers)'}`);
687
+ }
688
+ catch {
689
+ console.log(` ${p}`);
690
+ }
691
+ }
692
+ return;
693
+ }
694
+ if (opts.checkers) {
695
+ for (const c of listBuiltInCheckers())
696
+ console.log(` ${c.name} — ${c.description}`);
697
+ console.log(" custom_<name> — agent-judged checker graded against the constitution body (declare it in the constitution's checkers list)");
639
698
  return;
640
699
  }
641
700
  if (opts.product) {
@@ -647,31 +706,45 @@ export async function cmdConstitution(opts, deps = {}) {
647
706
  const paths = getFactoryPaths(repoRoot);
648
707
  ensureDir(paths.root);
649
708
  const constitutionPath = resolve(paths.root, 'constitution.md');
650
- if (existsSync(constitutionPath) && !opts.force) {
709
+ const constitutionsDir = configLoaders.getConstitutionsDir();
710
+ const scaffold = existsSync(constitutionPath) &&
711
+ isUnmodifiedConstitutionScaffold(readFileSync(constitutionPath, 'utf-8'), constitutionsDir, basename(repoRoot));
712
+ if (existsSync(constitutionPath) && !opts.force && !scaffold) {
651
713
  throw new CliExitError('.factory/constitution.md already exists — use --force to overwrite', 1);
652
714
  }
653
- const content = readFileSync(resolve(configLoaders.getConstitutionsDir(), `${opts.product}.md`), 'utf-8');
654
- writeIfAbsent(constitutionPath, content, '.factory/constitution.md', opts.force);
715
+ const content = readFileSync(resolve(constitutionsDir, `${opts.product}.md`), 'utf-8');
716
+ writeIfAbsent(constitutionPath, content, '.factory/constitution.md', opts.force || scaffold);
655
717
  ensureDir(paths.state);
656
718
  writeFileSync(paths.product, opts.product);
657
719
  console.log(styleText('green', `Active product: ${opts.product}`));
658
720
  return;
659
721
  }
660
- throw new CliExitError('usage: factory constitution --init <product> | --list | --product <name>', 2);
722
+ throw new CliExitError('usage: factory constitution --init <product> | --list | --checkers | --product <name>', 2);
723
+ }
724
+ /** Single owner of the per-model status icon, shared by `factory models` and `models --doctor` (#2402). */
725
+ export function modelStatusIcon(d) {
726
+ return d.reachable ? styleText('green', '✅') : d.unverified ? styleText('yellow', '⚠️') : styleText('red', '❌');
727
+ }
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]') : '';
661
731
  }
662
- export function formatDoctorReport(diagnoses) {
732
+ export function formatDoctorReport(diagnoses, repoDefined = new Set()) {
663
733
  const lines = [styleText('bold', '\n== Model Doctor ==')];
664
734
  for (const d of diagnoses) {
665
- const icon = d.reachable ? styleText('green', '✅') : styleText('red', '❌');
735
+ const icon = modelStatusIcon(d);
666
736
  const tiers = d.tiers.join('/');
667
- 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)}`);
668
738
  }
739
+ lines.push(` ${formatReachabilityCount(diagnoses)}`);
669
740
  return lines.join('\n');
670
741
  }
671
742
  export function hasReachableWorker(diagnoses) {
672
743
  return diagnoses.some((d) => d.reachable && (d.tiers.includes('worker') || d.tiers.includes('worker_fallback')));
673
744
  }
674
- function ollamaModelSet() {
745
+ function ollamaModelSet(requireOnPath = false) {
746
+ if (requireOnPath && !coreOps.isCommandAvailable('ollama'))
747
+ return undefined;
675
748
  try {
676
749
  const out = shellOps.execSync('ollama list', { timeout: 10_000 });
677
750
  return new Set(out
@@ -684,32 +757,42 @@ function ollamaModelSet() {
684
757
  return undefined;
685
758
  }
686
759
  }
760
+ /** Probes shared by every doctor-style reachability check (init, models --doctor, overnight
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);
765
+ return {
766
+ commandAvailable: coreOps.isCommandAvailable,
767
+ ollamaModelPresent: ollamaModels ? (model) => ollamaModels.has(model) : undefined,
768
+ };
769
+ }
687
770
  async function cmdModels(opts = {}) {
688
771
  const repoRoot = await getRepoRoot();
689
- 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 ?? {}));
690
775
  const registry = new coreOps.ModelRegistry(modelsConfig);
691
776
  const allowExperimental = resolveExperimental();
692
777
  const localOnly = resolveLocalOnly();
778
+ const diagnoses = coreOps.diagnoseModels(registry, modelDoctorProbes(), allowExperimental, localOnly);
693
779
  if (opts.doctor) {
694
- const ollamaModels = ollamaModelSet();
695
- const diagnoses = coreOps.diagnoseModels(registry, {
696
- ollamaModelPresent: ollamaModels ? (model) => ollamaModels.has(model) : undefined,
697
- }, allowExperimental, localOnly);
698
- console.log(formatDoctorReport(diagnoses));
780
+ console.log(formatDoctorReport(diagnoses, repoDefined));
699
781
  if (!hasReachableWorker(diagnoses)) {
700
782
  throw new CliExitError('factory: no worker model is reachable — fix the reasons above before running a queue', 1);
701
783
  }
702
784
  return;
703
785
  }
704
786
  console.log(styleText('bold', '\n== Available Models =='));
787
+ const byModel = new Map(diagnoses.map((d) => [d.model, d]));
705
788
  for (const m of registry.list()) {
706
789
  const tiers = registry.getTiers(m).join('/');
707
790
  const est = registry.estimateCost(m, 1_000_000, 1_000_000);
708
791
  const cost = est === null ? 'unpriced' : `$${est.toFixed(2)}/M`;
709
- const gated = registry.isExperimental(m) && !allowExperimental;
710
- const avail = !gated && registry.isAvailable(m) ? styleText('green', '✅') : styleText('red', '❌');
792
+ const d = byModel.get(m);
793
+ const avail = d ? modelStatusIcon(d) : styleText('red', '❌');
711
794
  const tag = registry.isExperimental(m) ? styleText('yellow', ' [experimental]') : '';
712
- console.log(` ${avail} ${m} tier=${tiers} ${cost}${tag}`);
795
+ console.log(` ${avail} ${m} tier=${tiers} ${cost}${tag}${repoDefinedTag(m, repoDefined)}`);
713
796
  }
714
797
  console.log(styleText('bold', '\n== Tiers =='));
715
798
  for (const tier of ['boss', 'worker', 'checker', 'triage']) {
@@ -1488,6 +1571,7 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1488
1571
  : { ...resolveEffectiveConfig(repoConfig), branchPrefix: opts.branchPrefix },
1489
1572
  };
1490
1573
  const router = new core.ModelRouter(policy.models, policy.routes, false, undefined, policy.effective.allowExperimental, policy.effective.localOnly);
1574
+ router.skipMissingHarnessClis(core.isCommandAvailable);
1491
1575
  let issueSpend = 0;
1492
1576
  let laneSandboxRuntime = 'none';
1493
1577
  const laneWorkspaceBackend = factoryConfig.workspace?.backend === 'disposable-docker' ? 'disposable-docker' : 'worktree';
@@ -1730,7 +1814,8 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1730
1814
  resolveConstitution: () =>
1731
1815
  // Resolved once here — runIssue calls this exactly once and reuses the value for
1732
1816
  // every phase, so the build worker can never author the standards it is graded by.
1733
- constitutionLoader.resolve(worktree, product, factoryConfig.paths?.constitution),
1817
+ // A local-only workspace is caller-provided, not a checkout of repoRoot, so it keeps its own constitution.
1818
+ constitutionLoader.resolve(worktree, product, factoryConfig.paths?.constitution, ctx?.localOnly ? undefined : repoRoot),
1734
1819
  createApprovalGate: () => createFileApprovalGate({ dir: paths.approvals, timeoutMs: timeouts.approval * 1000 }),
1735
1820
  drainSteering: () => drainSteering(paths.steering, issueNum, worktree),
1736
1821
  reworkHistory,
@@ -1941,8 +2026,8 @@ export async function sweepBetweenLaneIssues(issue, lane, repoRoot, ghRepo, path
1941
2026
  }
1942
2027
  }
1943
2028
  async function cmdShip(issueNum, opts) {
1944
- if (!coreOps.isCommandAvailable('claude')) {
1945
- throw new CliExitError(`factory: ${missingClaudeCliMessage()}`, 2);
2029
+ if (availableAgentClis(coreOps.isCommandAvailable).length === 0) {
2030
+ throw new CliExitError(`factory: ${missingAgentCliMessage()}`, 2);
1946
2031
  }
1947
2032
  const repoRoot = await getRepoRoot();
1948
2033
  const paths = getFactoryPaths(repoRoot);
@@ -1977,13 +2062,16 @@ async function cmdShip(issueNum, opts) {
1977
2062
  catch (err) {
1978
2063
  if (err instanceof IssueSkippedError)
1979
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);
1980
2068
  throw new CliExitError(`Ship failed for issue #${issueNum}: ${err.message}`, 1);
1981
2069
  }
1982
2070
  });
1983
2071
  }
1984
2072
  async function cmdRunIssue(issueNum, opts) {
1985
- if (!coreOps.isCommandAvailable('claude')) {
1986
- throw new CliExitError(`factory: ${missingClaudeCliMessage()}`, 2);
2073
+ if (availableAgentClis(coreOps.isCommandAvailable).length === 0) {
2074
+ throw new CliExitError(`factory: ${missingAgentCliMessage()}`, 2);
1987
2075
  }
1988
2076
  const repoRoot = await getRepoRoot();
1989
2077
  const paths = getFactoryPaths(repoRoot);
@@ -2100,8 +2188,8 @@ async function runDecomposedChildren(parent, children, opts, env) {
2100
2188
  }
2101
2189
  }
2102
2190
  async function cmdRunBrief(briefPath, opts) {
2103
- if (!coreOps.isCommandAvailable('claude')) {
2104
- throw new CliExitError(`factory: ${missingClaudeCliMessage()}`, 2);
2191
+ if (availableAgentClis(coreOps.isCommandAvailable).length === 0) {
2192
+ throw new CliExitError(`factory: ${missingAgentCliMessage()}`, 2);
2105
2193
  }
2106
2194
  const repoRoot = await getRepoRoot();
2107
2195
  const paths = getFactoryPaths(repoRoot);
@@ -2217,11 +2305,10 @@ async function cmdLocalSmallOvernight(opts) {
2217
2305
  const ghRepo = await getGitHubRepo();
2218
2306
  process.env.FACTORY_LOCAL_ONLY = '1';
2219
2307
  const preflight = async () => {
2220
- if (!coreOps.isCommandAvailable('claude'))
2221
- return { ok: false, reason: missingClaudeCliMessage() };
2308
+ if (availableAgentClis(coreOps.isCommandAvailable).length === 0)
2309
+ return { ok: false, reason: missingAgentCliMessage() };
2222
2310
  const registry = new coreOps.ModelRegistry(applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot)));
2223
- const ollamaModels = ollamaModelSet();
2224
- const diagnoses = coreOps.diagnoseModels(registry, { ollamaModelPresent: ollamaModels ? (m) => ollamaModels.has(m) : undefined }, resolveExperimental(), true);
2311
+ const diagnoses = coreOps.diagnoseModels(registry, modelDoctorProbes(), resolveExperimental(), true);
2225
2312
  if (!hasReachableWorker(diagnoses)) {
2226
2313
  const reasons = diagnoses.filter((d) => !d.reachable).map((d) => `${d.model}: ${d.reason}`);
2227
2314
  return { ok: false, reason: `no reachable local worker model — ${reasons.join('; ')}` };
@@ -2563,28 +2650,33 @@ async function cmdTriage(opts) {
2563
2650
  const routesConfig = configLoaders.loadRoutesConfig();
2564
2651
  const effective = resolveEffectiveConfig(repoConfig);
2565
2652
  const router = new coreOps.ModelRouter(modelsConfig, routesConfig, false, undefined, effective.allowExperimental, effective.localOnly);
2566
- // Triage always runs `claude -p`, so only claude-cli models with a flag are runnable.
2567
- const registry = router.registryRef;
2568
- const resolved = router.resolveAll('triage');
2569
- const candidates = resolved.length > 0 ? resolved : ['claude-sonnet-5'];
2570
- const model = candidates.find((m) => registry.getHarnessId(m) === 'claude-cli' && registry.getClaudeFlag(m));
2571
- const flag = model ? registry.getClaudeFlag(model) : undefined;
2572
- const constitutionNote = product ? `Active constitution: ${product}.` : '';
2573
- const prompt = `Triage the open GitHub issues of ${ghRepo} for an autonomous software factory.
2574
- ${constitutionNote}
2575
- Run: gh issue list --repo ${ghRepo} --state open --limit 100 --json number,title,labels,body
2576
- Read every body. Exclude epics/PRDs/meta, external-account/credential/outreach issues,
2577
- and anything too vague. Group by lane (same-file issues together). Order by dependency then value.
2578
- Write ONLY the queue to ${paths.queueProposed} in format '<lane> <issue#>', with '#' comments
2579
- explaining exclusions.`;
2653
+ router.skipMissingHarnessClis(coreOps.isCommandAvailable);
2654
+ router.setCostSink((entry) => logCost(paths.costs, { ...entry, issue: '-' }));
2580
2655
  let plannerError;
2581
- logEvent(paths.events, 'triage', '-', `Triaging ${ghRepo} with ${flag ?? 'claude default model'}`);
2582
- await shellOps
2583
- .exec(`claude -p ${shellEscape(prompt)} ${flag ? `--model ${flag}` : ''} --allowedTools "Bash(gh issue:*)" "Bash(gh repo:*)" Read Glob Grep Write`)
2584
- .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) {
2585
2675
  plannerError = err;
2586
2676
  logEvent(paths.events, 'warn', '-', `triage planner failed: ${errorDetail(err)}`);
2587
- });
2677
+ }
2678
+ if (model)
2679
+ logEvent(paths.events, 'triage', '-', `Triaged ${ghRepo} with ${model}`);
2588
2680
  const proposed = existsSync(paths.queueProposed) ? readFileSync(paths.queueProposed, 'utf-8') : '';
2589
2681
  const message = triageProposalMessage(proposed, paths.queueProposed, paths.queue);
2590
2682
  if (message) {
@@ -4294,9 +4386,15 @@ async function cmdDoctor(opts = {}) {
4294
4386
  const checks = runDoctorChecks({
4295
4387
  commandAvailable: coreOps.isCommandAvailable,
4296
4388
  envPresent: (key) => !!process.env[key],
4297
- tryExec: (cmd) => {
4389
+ tryExec: (cmd, opts) => {
4298
4390
  try {
4299
- 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();
4300
4398
  }
4301
4399
  catch {
4302
4400
  return null;
@@ -4304,6 +4402,14 @@ async function cmdDoctor(opts = {}) {
4304
4402
  },
4305
4403
  pathExists: existsSync,
4306
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(),
4307
4413
  });
4308
4414
  let repoRoot;
4309
4415
  try {
@@ -4317,53 +4423,57 @@ async function cmdDoctor(opts = {}) {
4317
4423
  catch {
4318
4424
  repoRoot = null;
4319
4425
  }
4320
- const CLAUDE_AUTH_PROBE = 'claude -p "reply with exactly: ok"';
4321
- const probeExec = (cmd) => {
4322
- try {
4323
- return shellOps.execSync(cmd, { timeout: 120_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
4324
- }
4325
- catch {
4326
- return null;
4327
- }
4328
- };
4329
- let host = 'skipped';
4330
- let hostDetail = 'skipped — claude CLI not on PATH';
4331
- if (coreOps.isCommandAvailable('claude')) {
4332
- host = probeExec(CLAUDE_AUTH_PROBE) === null ? 'failed' : 'ok';
4333
- hostDetail = host === 'ok' ? 'claude -p succeeded on the host' : 'claude -p failed on the host';
4334
- }
4335
- let sandboxed = 'skipped';
4336
- let sandboxDetail = 'skipped — host claude auth was not verified';
4337
- const runtimeAvailable = hasSandboxRuntime(process.platform, (cmd) => isCommandAvailableViaShell(shellOps, cmd));
4338
- const skip = sandboxAuthSkipDetail({ runtimeAvailable, hostOk: host === 'ok', inRepo: repoRoot !== null });
4339
- if (skip !== null) {
4340
- sandboxDetail = skip;
4341
- }
4342
- else if (repoRoot !== null) {
4343
- const policy = resolveSandboxPolicy(configLoaders.loadFactoryConfigForRepo(getFactoryPaths(repoRoot).config).sandbox, {
4344
- worktree: repoRoot,
4345
- repoRoot,
4346
- isAvailable: (cmd) => isCommandAvailableViaShell(shellOps, cmd),
4347
- });
4348
- if (!policy) {
4349
- sandboxDetail = 'skipped — sandbox disabled by config or FACTORY_SANDBOX';
4350
- }
4351
- else if (policy.runtime === 'none') {
4352
- sandboxDetail = 'skipped — no sandbox runtime (sandbox-exec/firejail) on this host';
4353
- }
4354
- else if (policy.runtime === 'docker-sandbox') {
4355
- sandboxDetail = 'skipped — docker-sandbox containment is the microVM, not command wrapping (#653)';
4356
- }
4357
- else {
4358
- sandboxed = probeExec(wrapCommandInSandbox(CLAUDE_AUTH_PROBE, policy)) === null ? 'failed' : 'ok';
4359
- sandboxDetail =
4360
- sandboxed === 'ok'
4361
- ? `claude -p succeeded under ${policy.runtime}`
4362
- : `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
+ }
4363
4472
  }
4473
+ checks.push(...sandboxClaudeAuthChecks({ host, sandboxed, hostDetail, sandboxDetail }));
4364
4474
  }
4365
4475
  checks.push(claudeKeychainCheck(probeClaudeKeychain()));
4366
- checks.push(...sandboxClaudeAuthChecks({ host, sandboxed, hostDetail, sandboxDetail }));
4476
+ checks.splice(0, checks.length, ...reconcileAgentAuthChecks(checks));
4367
4477
  if (repoRoot !== null) {
4368
4478
  const paths = getFactoryPaths(repoRoot);
4369
4479
  const factoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
@@ -4383,6 +4493,25 @@ async function cmdDoctor(opts = {}) {
4383
4493
  checks.push(stopSentinelCheck(readStopFileStatus(paths)));
4384
4494
  const eventsContent = existsSync(paths.events) ? readFileSync(paths.events, 'utf-8') : null;
4385
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
+ }
4506
+ let harnessProbes = [];
4507
+ try {
4508
+ const registry = new coreOps.ModelRegistry(applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot)));
4509
+ harnessProbes = coreOps.probeDeclaredHarnesses(registry);
4510
+ }
4511
+ catch {
4512
+ // models config problems are reported by `factory models --doctor`; doctor keeps going
4513
+ }
4514
+ checks.push(...declaredHarnessChecks(harnessProbes));
4386
4515
  // Full run-lifecycle reap (#998): dead-run worktrees and their branches. Uses the same
4387
4516
  // GitHub evidence and the same lock pairing as `factory worktree gc` (cmdWorktreeGc) —
4388
4517
  // a merged or closed PR is what makes a worktree reapable.
@@ -4443,7 +4572,7 @@ async function cmdDoctor(opts = {}) {
4443
4572
  }
4444
4573
  }
4445
4574
  }
4446
- console.log(formatDoctorChecks(checks));
4575
+ console.log(formatDoctorChecks(checks, { inRepo: repoRoot !== null }));
4447
4576
  if (doctorFailed(checks))
4448
4577
  process.exit(1);
4449
4578
  }
@@ -4555,8 +4684,9 @@ export async function main(argv = process.argv, deps = {}) {
4555
4684
  .command('constitution')
4556
4685
  .description('Manage product constitutions — .factory/constitution.md in this repo is the source of truth')
4557
4686
  .option('--init [product]', 'Write .factory/constitution.md in this repo, scaffolded from the template (an optional name overrides the display name)')
4558
- .option('--list', 'List available constitutions')
4559
- .option('--product <name>', 'Seed .factory/constitution.md from the bundled example constitution for <name>')
4687
+ .option('--list', 'List available constitutions with their checkers')
4688
+ .option('--checkers', 'List every built-in checker name with a one-line description')
4689
+ .option('--product <name>', 'Seed .factory/constitution.md from the bundled constitution for <name>; replaces an unmodified scaffold without --force')
4560
4690
  .option('--force', 'With --init or --product, overwrite an existing .factory/constitution.md')
4561
4691
  .action((opts) => cmdConstitution(opts));
4562
4692
  program
@@ -4566,8 +4696,9 @@ export async function main(argv = process.argv, deps = {}) {
4566
4696
  .action(cmdModels);
4567
4697
  program
4568
4698
  .command('doctor')
4569
- .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/)')
4570
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)')
4571
4702
  .action((opts) => cmdDoctor(opts));
4572
4703
  program
4573
4704
  .command('cost')
@@ -4664,6 +4795,7 @@ export async function main(argv = process.argv, deps = {}) {
4664
4795
  .command('triage')
4665
4796
  .description('Have a model propose a queue from open issues into .factory/queue.proposed')
4666
4797
  .option('--product <name>', 'Product constitution to scope triage')
4798
+ .option('--model <id>', 'Model to triage with (any registered model, Claude or Codex)')
4667
4799
  .action(cmdTriage);
4668
4800
  triage
4669
4801
  .command('accept')