@on-par/factory-cli 0.9.1 → 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, 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, 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';
@@ -27,15 +27,15 @@ import { buildStatusUpdateJson, formatUpdateStartupLine, formatUpdateStatusLine,
27
27
  import { buildStopResumeJson } from './stop-resume-json.js';
28
28
  import { buildUsageJson } from './usage-json.js';
29
29
  import { parseResetIssues, runReset } from './reset.js';
30
- import { cmdDaemonLogs, cmdDaemonStart, cmdDaemonStatus, cmdDaemonStop, DaemonCtlError, detectInstall, loadDaemonFileConfig, resolveFactorydConfig, } from './daemon.js';
31
- import { cmdSelfUpdateCheck } from './self-update.js';
32
- import { analyzeEventLog, claudeKeychainCheck, doctorFailed, eventLogCheck, formatClaimReconcileReport, formatContainerReconcileReport, formatDoctorChecks, formatMicroVmReconcileReport, formatReconcileReport, formatWorktreeReconcileReport, leaseChecks, keychainPreflightError, orphanContainerChecks, orphanMicroVmChecks, runDoctorChecks, sandboxClaudeAuthChecks, unmergedGreenPrChecks, } from './doctor.js';
30
+ import { cmdDaemonLogs, cmdDaemonStart, cmdDaemonStatus, cmdDaemonStop, createAutoUpdateDeps, DaemonCtlError, detectInstall, loadDaemonFileConfig, recoverStaleDrain, resolveFactorydConfig, runAutoUpdateLoop, } from './daemon.js';
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
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';
36
36
  import { cmdHostedRunner } from './hosted-runner.js';
37
37
  import { resolveFactoryCheckoutCommit, runFilingPreview } from './filing.js';
38
- import { buildFeedbackDeps, runFeedback } from './feedback.js';
38
+ import { buildFeedbackDeps, resolveFeedbackRepo, runFeedback } from './feedback.js';
39
39
  import { ensureFactoryExcluded, writeSampleQueue } from './init-files.js';
40
40
  import { cmdLogs } from './logs.js';
41
41
  import { applyHelpGroups } from './help-groups.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`.'));
@@ -491,10 +501,34 @@ export async function runFeedbackCommand(prUrl, opts, deps, log = console.log) {
491
501
  throw err;
492
502
  }
493
503
  }
504
+ /** owner/name of the checkout's origin remote, or null when there is none or it is not a GitHub remote. Never throws. */
505
+ export async function readOriginSlug(shell = shellOps) {
506
+ try {
507
+ const { stdout } = await shell.exec('git remote get-url origin');
508
+ return parseRemoteSlug(stdout.trim());
509
+ }
510
+ catch {
511
+ return null;
512
+ }
513
+ }
494
514
  export async function cmdFeedback(prUrl, opts) {
495
515
  const paths = getFactoryPaths(await getRepoRoot());
496
516
  const prefix = opts.branchPrefix === undefined ? resolveBranchPrefix() : branchPrefixOrExit(opts.branchPrefix);
497
- await runFeedbackCommand(prUrl, opts, buildFeedbackDeps(getOctokit(), paths, prefix, createOctokitFilingClient));
517
+ let repo;
518
+ try {
519
+ repo = resolveFeedbackRepo({
520
+ flag: opts.repo,
521
+ config: configLoaders.loadFactoryConfigForRepo(paths.config).filing.repo,
522
+ remote: await readOriginSlug(),
523
+ });
524
+ }
525
+ catch (err) {
526
+ throw new CliExitError(err instanceof Error ? err.message : String(err), 2);
527
+ }
528
+ await runFeedbackCommand(prUrl, opts, buildFeedbackDeps(getOctokit(), paths, prefix, createOctokitFilingClient, {
529
+ repo,
530
+ shareLocal: opts.includeLocalPaths === true,
531
+ }));
498
532
  }
499
533
  async function resolveFactoryCommit() {
500
534
  return resolveFactoryCheckoutCommit(dirname(fileURLToPath(import.meta.url)), async (cmd, cwd) => (await shellOps.exec(cmd, { cwd })).stdout);
@@ -580,6 +614,40 @@ export function scaffoldConstitution(template, product) {
580
614
  .join(' ');
581
615
  return skeleton.replaceAll('<product-name>', JSON.stringify(product)).replaceAll('<Product>', display);
582
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
+ }
583
651
  export async function cmdConstitution(opts, deps = {}) {
584
652
  const loader = new (resolveCoreOps(deps).ConstitutionLoader)();
585
653
  if (typeof opts.init === 'string' || opts.init === true) {
@@ -610,8 +678,21 @@ export async function cmdConstitution(opts, deps = {}) {
610
678
  }
611
679
  if (opts.list) {
612
680
  const products = loader.listProducts();
613
- for (const p of products)
614
- 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)");
615
696
  return;
616
697
  }
617
698
  if (opts.product) {
@@ -623,25 +704,33 @@ export async function cmdConstitution(opts, deps = {}) {
623
704
  const paths = getFactoryPaths(repoRoot);
624
705
  ensureDir(paths.root);
625
706
  const constitutionPath = resolve(paths.root, 'constitution.md');
626
- 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) {
627
711
  throw new CliExitError('.factory/constitution.md already exists — use --force to overwrite', 1);
628
712
  }
629
- const content = readFileSync(resolve(configLoaders.getConstitutionsDir(), `${opts.product}.md`), 'utf-8');
630
- 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);
631
715
  ensureDir(paths.state);
632
716
  writeFileSync(paths.product, opts.product);
633
717
  console.log(styleText('green', `Active product: ${opts.product}`));
634
718
  return;
635
719
  }
636
- 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', '❌');
637
725
  }
638
726
  export function formatDoctorReport(diagnoses) {
639
727
  const lines = [styleText('bold', '\n== Model Doctor ==')];
640
728
  for (const d of diagnoses) {
641
- const icon = d.reachable ? styleText('green', '✅') : styleText('red', '❌');
729
+ const icon = modelStatusIcon(d);
642
730
  const tiers = d.tiers.join('/');
643
731
  lines.push(` ${icon} ${d.model} provider=${d.provider} tier=${tiers} — ${d.reason}`);
644
732
  }
733
+ lines.push(` ${formatReachabilityCount(diagnoses)}`);
645
734
  return lines.join('\n');
646
735
  }
647
736
  export function hasReachableWorker(diagnoses) {
@@ -660,17 +749,23 @@ function ollamaModelSet() {
660
749
  return undefined;
661
750
  }
662
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
+ }
663
761
  async function cmdModels(opts = {}) {
664
762
  const repoRoot = await getRepoRoot();
665
763
  const modelsConfig = applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot));
666
764
  const registry = new coreOps.ModelRegistry(modelsConfig);
667
765
  const allowExperimental = resolveExperimental();
668
766
  const localOnly = resolveLocalOnly();
767
+ const diagnoses = coreOps.diagnoseModels(registry, modelDoctorProbes(), allowExperimental, localOnly);
669
768
  if (opts.doctor) {
670
- const ollamaModels = ollamaModelSet();
671
- const diagnoses = coreOps.diagnoseModels(registry, {
672
- ollamaModelPresent: ollamaModels ? (model) => ollamaModels.has(model) : undefined,
673
- }, allowExperimental, localOnly);
674
769
  console.log(formatDoctorReport(diagnoses));
675
770
  if (!hasReachableWorker(diagnoses)) {
676
771
  throw new CliExitError('factory: no worker model is reachable — fix the reasons above before running a queue', 1);
@@ -678,12 +773,13 @@ async function cmdModels(opts = {}) {
678
773
  return;
679
774
  }
680
775
  console.log(styleText('bold', '\n== Available Models =='));
776
+ const byModel = new Map(diagnoses.map((d) => [d.model, d]));
681
777
  for (const m of registry.list()) {
682
778
  const tiers = registry.getTiers(m).join('/');
683
779
  const est = registry.estimateCost(m, 1_000_000, 1_000_000);
684
780
  const cost = est === null ? 'unpriced' : `$${est.toFixed(2)}/M`;
685
- const gated = registry.isExperimental(m) && !allowExperimental;
686
- const avail = !gated && registry.isAvailable(m) ? styleText('green', '✅') : styleText('red', '❌');
781
+ const d = byModel.get(m);
782
+ const avail = d ? modelStatusIcon(d) : styleText('red', '❌');
687
783
  const tag = registry.isExperimental(m) ? styleText('yellow', ' [experimental]') : '';
688
784
  console.log(` ${avail} ${m} tier=${tiers} ${cost}${tag}`);
689
785
  }
@@ -1663,6 +1759,7 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1663
1759
  },
1664
1760
  localOnly: Boolean(ctx?.localOnly),
1665
1761
  blockUnresolvedRegressions: resolveDesignRegressionBlock(factoryConfig),
1762
+ readinessUpdateIssueBody: resolveReadinessUpdateIssueBody(factoryConfig),
1666
1763
  publishFromBuild: resolveBuildPublish(factoryConfig),
1667
1764
  sizeGateMode,
1668
1765
  maxSlices,
@@ -1705,7 +1802,8 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1705
1802
  resolveConstitution: () =>
1706
1803
  // Resolved once here — runIssue calls this exactly once and reuses the value for
1707
1804
  // every phase, so the build worker can never author the standards it is graded by.
1708
- 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),
1709
1807
  createApprovalGate: () => createFileApprovalGate({ dir: paths.approvals, timeoutMs: timeouts.approval * 1000 }),
1710
1808
  drainSteering: () => drainSteering(paths.steering, issueNum, worktree),
1711
1809
  reworkHistory,
@@ -2195,8 +2293,7 @@ async function cmdLocalSmallOvernight(opts) {
2195
2293
  if (!coreOps.isCommandAvailable('claude'))
2196
2294
  return { ok: false, reason: missingClaudeCliMessage() };
2197
2295
  const registry = new coreOps.ModelRegistry(applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot)));
2198
- const ollamaModels = ollamaModelSet();
2199
- const diagnoses = coreOps.diagnoseModels(registry, { ollamaModelPresent: ollamaModels ? (m) => ollamaModels.has(m) : undefined }, resolveExperimental(), true);
2296
+ const diagnoses = coreOps.diagnoseModels(registry, modelDoctorProbes(), resolveExperimental(), true);
2200
2297
  if (!hasReachableWorker(diagnoses)) {
2201
2298
  const reasons = diagnoses.filter((d) => !d.reachable).map((d) => `${d.model}: ${d.reason}`);
2202
2299
  return { ok: false, reason: `no reachable local worker model — ${reasons.join('; ')}` };
@@ -2538,8 +2635,12 @@ async function cmdTriage(opts) {
2538
2635
  const routesConfig = configLoaders.loadRoutesConfig();
2539
2636
  const effective = resolveEffectiveConfig(repoConfig);
2540
2637
  const router = new coreOps.ModelRouter(modelsConfig, routesConfig, false, undefined, effective.allowExperimental, effective.localOnly);
2541
- const model = router.resolve('triage') ?? 'claude-sonnet-5';
2542
- const flag = router.registryRef.getClaudeFlag(model);
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;
2543
2644
  const constitutionNote = product ? `Active constitution: ${product}.` : '';
2544
2645
  const prompt = `Triage the open GitHub issues of ${ghRepo} for an autonomous software factory.
2545
2646
  ${constitutionNote}
@@ -2549,7 +2650,7 @@ and anything too vague. Group by lane (same-file issues together). Order by depe
2549
2650
  Write ONLY the queue to ${paths.queueProposed} in format '<lane> <issue#>', with '#' comments
2550
2651
  explaining exclusions.`;
2551
2652
  let plannerError;
2552
- logEvent(paths.events, 'triage', '-', `Triaging ${ghRepo} with ${model}`);
2653
+ logEvent(paths.events, 'triage', '-', `Triaging ${ghRepo} with ${flag ?? 'claude default model'}`);
2553
2654
  await shellOps
2554
2655
  .exec(`claude -p ${shellEscape(prompt)} ${flag ? `--model ${flag}` : ''} --allowedTools "Bash(gh issue:*)" "Bash(gh repo:*)" Read Glob Grep Write`)
2555
2656
  .catch((err) => {
@@ -2960,7 +3061,19 @@ async function cmdFactoryd(opts) {
2960
3061
  for (const notice of config.notices)
2961
3062
  log(notice);
2962
3063
  const updateFile = updateStatePath(runtime.dir);
2963
- const updateState = initialUpdateState(config, readUpdateState(updateFile));
3064
+ const previousUpdate = readUpdateState(updateFile);
3065
+ for (const stop of recoverStaleDrain(previousUpdate, (file) => {
3066
+ try {
3067
+ unlinkSync(file);
3068
+ return true;
3069
+ }
3070
+ catch {
3071
+ return false;
3072
+ }
3073
+ })) {
3074
+ log(`update: removed stale STOP ${stop}`);
3075
+ }
3076
+ const updateState = initialUpdateState(config, previousUpdate);
2964
3077
  try {
2965
3078
  writeUpdateState(updateFile, updateState);
2966
3079
  }
@@ -2992,8 +3105,26 @@ async function cmdFactoryd(opts) {
2992
3105
  console.log(line);
2993
3106
  for (const line of banner)
2994
3107
  logSink(line);
3108
+ let shuttingDown = false;
3109
+ void runAutoUpdateLoop(config, {
3110
+ ...createAutoUpdateDeps({
3111
+ registryFile,
3112
+ stateFile: updateFile,
3113
+ cliEntrypoint: fileURLToPath(new URL('../cli.js', import.meta.url)),
3114
+ check: () => checkForUpdate(config.autoUpdate.mode === 'prerelease' ? 'prerelease' : 'stable'),
3115
+ runRuntime,
3116
+ log,
3117
+ exit: async (code) => {
3118
+ await daemon.stop().catch(() => { });
3119
+ await releaseRuntimeFiles(runtime);
3120
+ process.exit(code);
3121
+ },
3122
+ }),
3123
+ stopped: () => shuttingDown,
3124
+ });
2995
3125
  await new Promise((resolveShutdown) => {
2996
3126
  const shutdown = () => {
3127
+ shuttingDown = true;
2997
3128
  void daemon
2998
3129
  .stop()
2999
3130
  .catch(() => { })
@@ -4275,14 +4406,17 @@ async function cmdDoctor(opts = {}) {
4275
4406
  }
4276
4407
  let sandboxed = 'skipped';
4277
4408
  let sandboxDetail = 'skipped — host claude auth was not verified';
4278
- if (host === 'ok') {
4279
- const policy = repoRoot === null
4280
- ? undefined
4281
- : resolveSandboxPolicy(configLoaders.loadFactoryConfigForRepo(getFactoryPaths(repoRoot).config).sandbox, {
4282
- worktree: repoRoot,
4283
- repoRoot,
4284
- isAvailable: (cmd) => isCommandAvailableViaShell(shellOps, cmd),
4285
- });
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
+ });
4286
4420
  if (!policy) {
4287
4421
  sandboxDetail = 'skipped — sandbox disabled by config or FACTORY_SANDBOX';
4288
4422
  }
@@ -4321,6 +4455,15 @@ async function cmdDoctor(opts = {}) {
4321
4455
  checks.push(stopSentinelCheck(readStopFileStatus(paths)));
4322
4456
  const eventsContent = existsSync(paths.events) ? readFileSync(paths.events, 'utf-8') : null;
4323
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));
4324
4467
  // Full run-lifecycle reap (#998): dead-run worktrees and their branches. Uses the same
4325
4468
  // GitHub evidence and the same lock pairing as `factory worktree gc` (cmdWorktreeGc) —
4326
4469
  // a merged or closed PR is what makes a worktree reapable.
@@ -4493,8 +4636,9 @@ export async function main(argv = process.argv, deps = {}) {
4493
4636
  .command('constitution')
4494
4637
  .description('Manage product constitutions — .factory/constitution.md in this repo is the source of truth')
4495
4638
  .option('--init [product]', 'Write .factory/constitution.md in this repo, scaffolded from the template (an optional name overrides the display name)')
4496
- .option('--list', 'List available constitutions')
4497
- .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')
4498
4642
  .option('--force', 'With --init or --product, overwrite an existing .factory/constitution.md')
4499
4643
  .action((opts) => cmdConstitution(opts));
4500
4644
  program
@@ -4588,6 +4732,8 @@ export async function main(argv = process.argv, deps = {}) {
4588
4732
  .command('feedback <pr-url>')
4589
4733
  .description("File one factory issue (bug, factory:needs-triage) from a PR's human review findings")
4590
4734
  .option('--note <text>', 'Use this as the issue title instead of the first finding')
4735
+ .option('--repo <owner/name>', 'Repo to file the issue in (default: filing.repo, then the origin remote)')
4736
+ .option('--include-local-paths', 'Include this host name and local design/log paths in the issue')
4591
4737
  .option('--branch-prefix <prefix>', BRANCH_PREFIX_MATCH_OPTION_DESCRIPTION)
4592
4738
  .action(cmdFeedback);
4593
4739
  const filingCmd = program.command('filing').description('Inspect upstream factory reports (nothing is sent)');