@on-par/factory-cli 0.9.2 → 0.9.3

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, 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';
13
13
  import { runTui } from '@on-par/factory-tui';
14
14
  import { Command } from 'commander';
15
15
  import { admissionStateReaderFor, runQueueReconcile } from './admission.js';
@@ -29,7 +29,7 @@ import { buildUsageJson } from './usage-json.js';
29
29
  import { parseResetIssues, runReset } from './reset.js';
30
30
  import { cmdDaemonLogs, cmdDaemonStart, cmdDaemonStatus, cmdDaemonStop, createAutoUpdateDeps, DaemonCtlError, detectInstall, loadDaemonFileConfig, recoverStaleDrain, resolveFactorydConfig, runAutoUpdateLoop, } from './daemon.js';
31
31
  import { checkForUpdate, cmdSelfUpdateCheck } from './self-update.js';
32
- import { analyzeEventLog, claudeKeychainCheck, doctorFailed, eventLogCheck, formatClaimReconcileReport, formatContainerReconcileReport, formatDoctorChecks, formatMicroVmReconcileReport, formatReconcileReport, formatWorktreeReconcileReport, hasSandboxRuntime, leaseChecks, keychainPreflightError, orphanContainerChecks, orphanMicroVmChecks, runDoctorChecks, sandboxAuthSkipDetail, sandboxClaudeAuthChecks, unmergedGreenPrChecks, } from './doctor.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
33
  import { DOCS_URL, formatOverview, missingClaudeCliMessage, missingTokenMessage, notInitializedMessage, } from './first-run.js';
34
34
  import { cmdHostedSmoke } from './hosted.js';
35
35
  import { cmdHostedQueue } from './hosted-queue.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
  }
@@ -297,8 +299,14 @@ function writeIfAbsent(path, content, label, force = false) {
297
299
  }
298
300
  /** One-line onboarding reachability summary, e.g. "policy=auto, 3/7 models reachable". */
299
301
  export function formatInitReachability(diagnoses) {
302
+ return `policy=auto, ${formatReachabilityCount(diagnoses)}`;
303
+ }
304
+ /** Shared "K/N models reachable[, U unverified (no auth evidence)]" count for init and models --doctor. */
305
+ export function formatReachabilityCount(diagnoses) {
300
306
  const reachable = diagnoses.filter((d) => d.reachable).length;
301
- return `policy=auto, ${reachable}/${diagnoses.length} models reachable`;
307
+ const unverified = diagnoses.filter((d) => !d.reachable && d.unverified).length;
308
+ const base = `${reachable}/${diagnoses.length} models reachable`;
309
+ return unverified > 0 ? `${base}, ${unverified} unverified (no auth evidence)` : base;
302
310
  }
303
311
  async function cmdInit(opts = {}) {
304
312
  const repoRoot = await getRepoRoot();
@@ -321,15 +329,17 @@ async function cmdInit(opts = {}) {
321
329
  const constitutionPath = resolve(paths.root, 'constitution.md');
322
330
  const gitignorePath = resolve(paths.root, '.gitignore');
323
331
  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);
332
+ // Constitution scaffold: the active product's bundled file, else the default-code constitution.
333
+ const constitutionsDir = configLoaders.getConstitutionsDir();
334
+ const active = readActiveProduct(paths.product);
335
+ const activeFile = active ? resolve(constitutionsDir, `${active}.md`) : undefined;
336
+ const source = activeFile && existsSync(activeFile) ? activeFile : resolve(constitutionsDir, 'default-code.md');
337
+ writeIfAbsent(constitutionPath, readFileSync(source, 'utf-8'), '.factory/constitution.md', force);
328
338
  writeIfAbsent(gitignorePath, 'state/\n', '.factory/.gitignore', force);
329
339
  // Doctor-style validation (policy=auto, N models reachable) — informational, never fails init.
330
340
  const modelsConfig = applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot));
331
341
  const registry = new coreOps.ModelRegistry(modelsConfig);
332
- const diagnoses = coreOps.diagnoseModels(registry, {}, resolveExperimental(), resolveLocalOnly());
342
+ const diagnoses = coreOps.diagnoseModels(registry, modelDoctorProbes(), resolveExperimental(), resolveLocalOnly());
333
343
  console.log(formatInitReachability(diagnoses));
334
344
  if (!hasReachableWorker(diagnoses)) {
335
345
  console.log(styleText('yellow', 'No worker model reachable yet — see `factory doctor` and `factory models --doctor`.'));
@@ -604,6 +614,40 @@ export function scaffoldConstitution(template, product) {
604
614
  .join(' ');
605
615
  return skeleton.replaceAll('<product-name>', JSON.stringify(product)).replaceAll('<Product>', display);
606
616
  }
617
+ /**
618
+ * True when `existing` is an untouched scaffold: byte-identical (CRLF and trailing whitespace
619
+ * normalised) to a bundled constitution or to the legacy `_template.md` scaffold for `repoName`.
620
+ * Exact equality only — an edited file is never treated as a scaffold.
621
+ */
622
+ export function isUnmodifiedConstitutionScaffold(existing, constitutionsDir, repoName) {
623
+ const norm = (s) => s.replace(/\r\n/g, '\n').trimEnd();
624
+ const target = norm(existing);
625
+ if (target === '')
626
+ return true;
627
+ let names = [];
628
+ try {
629
+ names = readdirSync(constitutionsDir).filter((f) => f.endsWith('.md') && !f.startsWith('_'));
630
+ }
631
+ catch {
632
+ // unreadable dir: no bundled files to compare against
633
+ }
634
+ for (const f of names) {
635
+ try {
636
+ if (norm(readFileSync(resolve(constitutionsDir, f), 'utf-8')) === target)
637
+ return true;
638
+ }
639
+ catch {
640
+ // skip unreadable entries
641
+ }
642
+ }
643
+ try {
644
+ const template = readFileSync(resolve(constitutionsDir, '_template.md'), 'utf-8');
645
+ return norm(scaffoldConstitution(template, repoName)) === target;
646
+ }
647
+ catch {
648
+ return false;
649
+ }
650
+ }
607
651
  export async function cmdConstitution(opts, deps = {}) {
608
652
  const loader = new (resolveCoreOps(deps).ConstitutionLoader)();
609
653
  if (typeof opts.init === 'string' || opts.init === true) {
@@ -634,8 +678,21 @@ export async function cmdConstitution(opts, deps = {}) {
634
678
  }
635
679
  if (opts.list) {
636
680
  const products = loader.listProducts();
637
- for (const p of products)
638
- console.log(` ${p}`);
681
+ for (const p of products) {
682
+ try {
683
+ const checkers = loader.load(p).checkers.join(', ');
684
+ console.log(` ${p} — ${checkers || '(no checkers)'}`);
685
+ }
686
+ catch {
687
+ console.log(` ${p}`);
688
+ }
689
+ }
690
+ return;
691
+ }
692
+ if (opts.checkers) {
693
+ for (const c of listBuiltInCheckers())
694
+ console.log(` ${c.name} — ${c.description}`);
695
+ console.log(" custom_<name> — agent-judged checker graded against the constitution body (declare it in the constitution's checkers list)");
639
696
  return;
640
697
  }
641
698
  if (opts.product) {
@@ -647,25 +704,33 @@ export async function cmdConstitution(opts, deps = {}) {
647
704
  const paths = getFactoryPaths(repoRoot);
648
705
  ensureDir(paths.root);
649
706
  const constitutionPath = resolve(paths.root, 'constitution.md');
650
- if (existsSync(constitutionPath) && !opts.force) {
707
+ const constitutionsDir = configLoaders.getConstitutionsDir();
708
+ const scaffold = existsSync(constitutionPath) &&
709
+ isUnmodifiedConstitutionScaffold(readFileSync(constitutionPath, 'utf-8'), constitutionsDir, basename(repoRoot));
710
+ if (existsSync(constitutionPath) && !opts.force && !scaffold) {
651
711
  throw new CliExitError('.factory/constitution.md already exists — use --force to overwrite', 1);
652
712
  }
653
- const content = readFileSync(resolve(configLoaders.getConstitutionsDir(), `${opts.product}.md`), 'utf-8');
654
- writeIfAbsent(constitutionPath, content, '.factory/constitution.md', opts.force);
713
+ const content = readFileSync(resolve(constitutionsDir, `${opts.product}.md`), 'utf-8');
714
+ writeIfAbsent(constitutionPath, content, '.factory/constitution.md', opts.force || scaffold);
655
715
  ensureDir(paths.state);
656
716
  writeFileSync(paths.product, opts.product);
657
717
  console.log(styleText('green', `Active product: ${opts.product}`));
658
718
  return;
659
719
  }
660
- throw new CliExitError('usage: factory constitution --init <product> | --list | --product <name>', 2);
720
+ throw new CliExitError('usage: factory constitution --init <product> | --list | --checkers | --product <name>', 2);
721
+ }
722
+ /** Single owner of the per-model status icon, shared by `factory models` and `models --doctor` (#2402). */
723
+ export function modelStatusIcon(d) {
724
+ return d.reachable ? styleText('green', '✅') : d.unverified ? styleText('yellow', '⚠️') : styleText('red', '❌');
661
725
  }
662
726
  export function formatDoctorReport(diagnoses) {
663
727
  const lines = [styleText('bold', '\n== Model Doctor ==')];
664
728
  for (const d of diagnoses) {
665
- const icon = d.reachable ? styleText('green', '✅') : styleText('red', '❌');
729
+ const icon = modelStatusIcon(d);
666
730
  const tiers = d.tiers.join('/');
667
731
  lines.push(` ${icon} ${d.model} provider=${d.provider} tier=${tiers} — ${d.reason}`);
668
732
  }
733
+ lines.push(` ${formatReachabilityCount(diagnoses)}`);
669
734
  return lines.join('\n');
670
735
  }
671
736
  export function hasReachableWorker(diagnoses) {
@@ -684,17 +749,23 @@ function ollamaModelSet() {
684
749
  return undefined;
685
750
  }
686
751
  }
752
+ /** 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();
756
+ return {
757
+ commandAvailable: coreOps.isCommandAvailable,
758
+ ollamaModelPresent: ollamaModels ? (model) => ollamaModels.has(model) : undefined,
759
+ };
760
+ }
687
761
  async function cmdModels(opts = {}) {
688
762
  const repoRoot = await getRepoRoot();
689
763
  const modelsConfig = applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot));
690
764
  const registry = new coreOps.ModelRegistry(modelsConfig);
691
765
  const allowExperimental = resolveExperimental();
692
766
  const localOnly = resolveLocalOnly();
767
+ const diagnoses = coreOps.diagnoseModels(registry, modelDoctorProbes(), allowExperimental, localOnly);
693
768
  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
769
  console.log(formatDoctorReport(diagnoses));
699
770
  if (!hasReachableWorker(diagnoses)) {
700
771
  throw new CliExitError('factory: no worker model is reachable — fix the reasons above before running a queue', 1);
@@ -702,12 +773,13 @@ async function cmdModels(opts = {}) {
702
773
  return;
703
774
  }
704
775
  console.log(styleText('bold', '\n== Available Models =='));
776
+ const byModel = new Map(diagnoses.map((d) => [d.model, d]));
705
777
  for (const m of registry.list()) {
706
778
  const tiers = registry.getTiers(m).join('/');
707
779
  const est = registry.estimateCost(m, 1_000_000, 1_000_000);
708
780
  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', '❌');
781
+ const d = byModel.get(m);
782
+ const avail = d ? modelStatusIcon(d) : styleText('red', '❌');
711
783
  const tag = registry.isExperimental(m) ? styleText('yellow', ' [experimental]') : '';
712
784
  console.log(` ${avail} ${m} tier=${tiers} ${cost}${tag}`);
713
785
  }
@@ -1730,7 +1802,8 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1730
1802
  resolveConstitution: () =>
1731
1803
  // Resolved once here — runIssue calls this exactly once and reuses the value for
1732
1804
  // every phase, so the build worker can never author the standards it is graded by.
1733
- constitutionLoader.resolve(worktree, product, factoryConfig.paths?.constitution),
1805
+ // A local-only workspace is caller-provided, not a checkout of repoRoot, so it keeps its own constitution.
1806
+ constitutionLoader.resolve(worktree, product, factoryConfig.paths?.constitution, ctx?.localOnly ? undefined : repoRoot),
1734
1807
  createApprovalGate: () => createFileApprovalGate({ dir: paths.approvals, timeoutMs: timeouts.approval * 1000 }),
1735
1808
  drainSteering: () => drainSteering(paths.steering, issueNum, worktree),
1736
1809
  reworkHistory,
@@ -2220,8 +2293,7 @@ async function cmdLocalSmallOvernight(opts) {
2220
2293
  if (!coreOps.isCommandAvailable('claude'))
2221
2294
  return { ok: false, reason: missingClaudeCliMessage() };
2222
2295
  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);
2296
+ const diagnoses = coreOps.diagnoseModels(registry, modelDoctorProbes(), resolveExperimental(), true);
2225
2297
  if (!hasReachableWorker(diagnoses)) {
2226
2298
  const reasons = diagnoses.filter((d) => !d.reachable).map((d) => `${d.model}: ${d.reason}`);
2227
2299
  return { ok: false, reason: `no reachable local worker model — ${reasons.join('; ')}` };
@@ -4383,6 +4455,15 @@ async function cmdDoctor(opts = {}) {
4383
4455
  checks.push(stopSentinelCheck(readStopFileStatus(paths)));
4384
4456
  const eventsContent = existsSync(paths.events) ? readFileSync(paths.events, 'utf-8') : null;
4385
4457
  checks.push(eventLogCheck(eventsContent === null ? null : analyzeEventLog(eventsContent)));
4458
+ let harnessProbes = [];
4459
+ try {
4460
+ const registry = new coreOps.ModelRegistry(applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot)));
4461
+ harnessProbes = coreOps.probeDeclaredHarnesses(registry);
4462
+ }
4463
+ catch {
4464
+ // models config problems are reported by `factory models --doctor`; doctor keeps going
4465
+ }
4466
+ checks.push(...declaredHarnessChecks(harnessProbes));
4386
4467
  // Full run-lifecycle reap (#998): dead-run worktrees and their branches. Uses the same
4387
4468
  // GitHub evidence and the same lock pairing as `factory worktree gc` (cmdWorktreeGc) —
4388
4469
  // a merged or closed PR is what makes a worktree reapable.
@@ -4555,8 +4636,9 @@ export async function main(argv = process.argv, deps = {}) {
4555
4636
  .command('constitution')
4556
4637
  .description('Manage product constitutions — .factory/constitution.md in this repo is the source of truth')
4557
4638
  .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>')
4639
+ .option('--list', 'List available constitutions with their checkers')
4640
+ .option('--checkers', 'List every built-in checker name with a one-line description')
4641
+ .option('--product <name>', 'Seed .factory/constitution.md from the bundled constitution for <name>; replaces an unmodified scaffold without --force')
4560
4642
  .option('--force', 'With --init or --product, overwrite an existing .factory/constitution.md')
4561
4643
  .action((opts) => cmdConstitution(opts));
4562
4644
  program