@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/README.md +5 -3
- package/dist/cli/doctor.d.ts +78 -3
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +355 -33
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/first-run.d.ts +6 -1
- package/dist/cli/first-run.d.ts.map +1 -1
- package/dist/cli/first-run.js +9 -2
- package/dist/cli/first-run.js.map +1 -1
- package/dist/cli/index.d.ts +26 -5
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +244 -112
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/stop-sentinel.d.ts.map +1 -1
- package/dist/cli/stop-sentinel.js +4 -3
- package/dist/cli/stop-sentinel.js.map +1 -1
- package/package.json +3 -3
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,
|
|
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 = `
|
|
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
|
-
|
|
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:
|
|
325
|
-
const
|
|
326
|
-
const
|
|
327
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
710
|
-
const avail =
|
|
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
|
-
|
|
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 (
|
|
1945
|
-
throw new CliExitError(`factory: ${
|
|
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 (
|
|
1986
|
-
throw new CliExitError(`factory: ${
|
|
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 (
|
|
2104
|
-
throw new CliExitError(`factory: ${
|
|
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 (
|
|
2221
|
-
return { ok: false, reason:
|
|
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
|
|
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
|
-
|
|
2567
|
-
|
|
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
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
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
|
|
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
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
repoRoot,
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
sandboxed === 'ok'
|
|
4361
|
-
|
|
4362
|
-
|
|
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.
|
|
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('--
|
|
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,
|
|
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')
|