@on-par/factory-cli 0.0.0-stage → 0.9.0

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.
Files changed (147) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -2
  3. package/dist/cli/admission.d.ts +17 -0
  4. package/dist/cli/admission.d.ts.map +1 -0
  5. package/dist/cli/admission.js +37 -0
  6. package/dist/cli/admission.js.map +1 -0
  7. package/dist/cli/branch-prefix.d.ts +8 -0
  8. package/dist/cli/branch-prefix.d.ts.map +1 -0
  9. package/dist/cli/branch-prefix.js +16 -0
  10. package/dist/cli/branch-prefix.js.map +1 -0
  11. package/dist/cli/classifier-backtest.d.ts +50 -0
  12. package/dist/cli/classifier-backtest.d.ts.map +1 -0
  13. package/dist/cli/classifier-backtest.js +112 -0
  14. package/dist/cli/classifier-backtest.js.map +1 -0
  15. package/dist/cli/cost-json.d.ts +37 -0
  16. package/dist/cli/cost-json.d.ts.map +1 -0
  17. package/dist/cli/cost-json.js +48 -0
  18. package/dist/cli/cost-json.js.map +1 -0
  19. package/dist/cli/daemon.d.ts +113 -0
  20. package/dist/cli/daemon.d.ts.map +1 -0
  21. package/dist/cli/daemon.js +282 -0
  22. package/dist/cli/daemon.js.map +1 -0
  23. package/dist/cli/doctor.d.ts +127 -0
  24. package/dist/cli/doctor.d.ts.map +1 -0
  25. package/dist/cli/doctor.js +400 -0
  26. package/dist/cli/doctor.js.map +1 -0
  27. package/dist/cli/feedback.d.ts +74 -0
  28. package/dist/cli/feedback.d.ts.map +1 -0
  29. package/dist/cli/feedback.js +180 -0
  30. package/dist/cli/feedback.js.map +1 -0
  31. package/dist/cli/filing.d.ts +22 -0
  32. package/dist/cli/filing.d.ts.map +1 -0
  33. package/dist/cli/filing.js +53 -0
  34. package/dist/cli/filing.js.map +1 -0
  35. package/dist/cli/first-run.d.ts +9 -0
  36. package/dist/cli/first-run.d.ts.map +1 -0
  37. package/dist/cli/first-run.js +34 -0
  38. package/dist/cli/first-run.js.map +1 -0
  39. package/dist/cli/help-groups.d.ts +17 -0
  40. package/dist/cli/help-groups.d.ts.map +1 -0
  41. package/dist/cli/help-groups.js +50 -0
  42. package/dist/cli/help-groups.js.map +1 -0
  43. package/dist/cli/hosted-queue.d.ts +45 -0
  44. package/dist/cli/hosted-queue.d.ts.map +1 -0
  45. package/dist/cli/hosted-queue.js +74 -0
  46. package/dist/cli/hosted-queue.js.map +1 -0
  47. package/dist/cli/hosted-runner.d.ts +44 -0
  48. package/dist/cli/hosted-runner.d.ts.map +1 -0
  49. package/dist/cli/hosted-runner.js +68 -0
  50. package/dist/cli/hosted-runner.js.map +1 -0
  51. package/dist/cli/hosted.d.ts +38 -0
  52. package/dist/cli/hosted.d.ts.map +1 -0
  53. package/dist/cli/hosted.js +82 -0
  54. package/dist/cli/hosted.js.map +1 -0
  55. package/dist/cli/index.d.ts +656 -0
  56. package/dist/cli/index.d.ts.map +1 -0
  57. package/dist/cli/index.js +4801 -0
  58. package/dist/cli/index.js.map +1 -0
  59. package/dist/cli/init-files.d.ts +8 -0
  60. package/dist/cli/init-files.d.ts.map +1 -0
  61. package/dist/cli/init-files.js +45 -0
  62. package/dist/cli/init-files.js.map +1 -0
  63. package/dist/cli/logs.d.ts +27 -0
  64. package/dist/cli/logs.d.ts.map +1 -0
  65. package/dist/cli/logs.js +52 -0
  66. package/dist/cli/logs.js.map +1 -0
  67. package/dist/cli/merge-scope.d.ts +7 -0
  68. package/dist/cli/merge-scope.d.ts.map +1 -0
  69. package/dist/cli/merge-scope.js +14 -0
  70. package/dist/cli/merge-scope.js.map +1 -0
  71. package/dist/cli/octokit.d.ts +43 -0
  72. package/dist/cli/octokit.d.ts.map +1 -0
  73. package/dist/cli/octokit.js +87 -0
  74. package/dist/cli/octokit.js.map +1 -0
  75. package/dist/cli/queue-add-json.d.ts +25 -0
  76. package/dist/cli/queue-add-json.d.ts.map +1 -0
  77. package/dist/cli/queue-add-json.js +24 -0
  78. package/dist/cli/queue-add-json.js.map +1 -0
  79. package/dist/cli/queue-clear.d.ts +15 -0
  80. package/dist/cli/queue-clear.d.ts.map +1 -0
  81. package/dist/cli/queue-clear.js +58 -0
  82. package/dist/cli/queue-clear.js.map +1 -0
  83. package/dist/cli/queue-list.d.ts +24 -0
  84. package/dist/cli/queue-list.d.ts.map +1 -0
  85. package/dist/cli/queue-list.js +25 -0
  86. package/dist/cli/queue-list.js.map +1 -0
  87. package/dist/cli/ready-check.d.ts +98 -0
  88. package/dist/cli/ready-check.d.ts.map +1 -0
  89. package/dist/cli/ready-check.js +169 -0
  90. package/dist/cli/ready-check.js.map +1 -0
  91. package/dist/cli/reset.d.ts +40 -0
  92. package/dist/cli/reset.d.ts.map +1 -0
  93. package/dist/cli/reset.js +215 -0
  94. package/dist/cli/reset.js.map +1 -0
  95. package/dist/cli/run-children.d.ts +24 -0
  96. package/dist/cli/run-children.d.ts.map +1 -0
  97. package/dist/cli/run-children.js +73 -0
  98. package/dist/cli/run-children.js.map +1 -0
  99. package/dist/cli/run-flags.d.ts +14 -0
  100. package/dist/cli/run-flags.d.ts.map +1 -0
  101. package/dist/cli/run-flags.js +43 -0
  102. package/dist/cli/run-flags.js.map +1 -0
  103. package/dist/cli/runs-diffstat.d.ts +43 -0
  104. package/dist/cli/runs-diffstat.d.ts.map +1 -0
  105. package/dist/cli/runs-diffstat.js +91 -0
  106. package/dist/cli/runs-diffstat.js.map +1 -0
  107. package/dist/cli/runs.d.ts +36 -0
  108. package/dist/cli/runs.d.ts.map +1 -0
  109. package/dist/cli/runs.js +102 -0
  110. package/dist/cli/runs.js.map +1 -0
  111. package/dist/cli/staleness.d.ts +30 -0
  112. package/dist/cli/staleness.d.ts.map +1 -0
  113. package/dist/cli/staleness.js +132 -0
  114. package/dist/cli/staleness.js.map +1 -0
  115. package/dist/cli/status-json.d.ts +45 -0
  116. package/dist/cli/status-json.d.ts.map +1 -0
  117. package/dist/cli/status-json.js +22 -0
  118. package/dist/cli/status-json.js.map +1 -0
  119. package/dist/cli/stop-resume-json.d.ts +18 -0
  120. package/dist/cli/stop-resume-json.d.ts.map +1 -0
  121. package/dist/cli/stop-resume-json.js +10 -0
  122. package/dist/cli/stop-resume-json.js.map +1 -0
  123. package/dist/cli/stop-sentinel.d.ts +23 -0
  124. package/dist/cli/stop-sentinel.d.ts.map +1 -0
  125. package/dist/cli/stop-sentinel.js +61 -0
  126. package/dist/cli/stop-sentinel.js.map +1 -0
  127. package/dist/cli/sweep-heartbeat.d.ts +33 -0
  128. package/dist/cli/sweep-heartbeat.d.ts.map +1 -0
  129. package/dist/cli/sweep-heartbeat.js +65 -0
  130. package/dist/cli/sweep-heartbeat.js.map +1 -0
  131. package/dist/cli/unpark.d.ts +28 -0
  132. package/dist/cli/unpark.d.ts.map +1 -0
  133. package/dist/cli/unpark.js +68 -0
  134. package/dist/cli/unpark.js.map +1 -0
  135. package/dist/cli/usage-json.d.ts +20 -0
  136. package/dist/cli/usage-json.d.ts.map +1 -0
  137. package/dist/cli/usage-json.js +36 -0
  138. package/dist/cli/usage-json.js.map +1 -0
  139. package/dist/cli.d.ts +3 -0
  140. package/dist/cli.d.ts.map +1 -0
  141. package/dist/cli.js +6 -0
  142. package/dist/cli.js.map +1 -0
  143. package/dist/entry.d.ts +6 -0
  144. package/dist/entry.d.ts.map +1 -0
  145. package/dist/entry.js +13 -0
  146. package/dist/entry.js.map +1 -0
  147. package/package.json +48 -4
@@ -0,0 +1,4801 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ // packages/cli/src/cli/index.ts — CLI entry point: factory <command> [options]
3
+ import { exec as execCb, execSync } from 'node:child_process';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
6
+ import { createRequire } from 'node:module';
7
+ import { arch, hostname as osHostname, release, platform, userInfo } from 'node:os';
8
+ import { basename, dirname, relative, resolve } from 'node:path';
9
+ import { promisify, styleText } from 'node:util';
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, 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, reconstructHumanEvents, 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, 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, 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, withAdmissionGuard, withFileLock, withGitLock, withRunLock, wrapCommandInSandbox, writePortFile, } from '@on-par/factory-core/internal';
13
+ import { runTui } from '@on-par/factory-tui';
14
+ import { Command } from 'commander';
15
+ import { admissionStateReaderFor, runQueueReconcile } from './admission.js';
16
+ import { cmdClassifierBacktest, ClassifierBacktestInputError, } from './classifier-backtest.js';
17
+ import { runQueueClear } from './queue-clear.js';
18
+ import { buildCostJson } from './cost-json.js';
19
+ import { buildQueueAddJson } from './queue-add-json.js';
20
+ import { buildQueueListJson } from './queue-list.js';
21
+ import { runUnpark, UnparkError } from './unpark.js';
22
+ import { buildRunsJson, parseRunsLimit } from './runs.js';
23
+ import { buildRunDiffstatJson, NoRunError, parseDiffstatIssue } from './runs-diffstat.js';
24
+ import { buildStatusJson } from './status-json.js';
25
+ import { buildStopResumeJson } from './stop-resume-json.js';
26
+ import { buildUsageJson } from './usage-json.js';
27
+ import { parseResetIssues, runReset } from './reset.js';
28
+ import { cmdDaemonLogs, cmdDaemonStart, cmdDaemonStatus, cmdDaemonStop, DaemonCtlError, resolveFactorydConfig, } from './daemon.js';
29
+ import { analyzeEventLog, claudeKeychainCheck, doctorFailed, eventLogCheck, formatClaimReconcileReport, formatContainerReconcileReport, formatDoctorChecks, formatMicroVmReconcileReport, formatReconcileReport, formatWorktreeReconcileReport, leaseChecks, keychainPreflightError, orphanContainerChecks, orphanMicroVmChecks, runDoctorChecks, sandboxClaudeAuthChecks, unmergedGreenPrChecks, } from './doctor.js';
30
+ import { DOCS_URL, formatOverview, missingClaudeCliMessage, missingTokenMessage, notInitializedMessage, } from './first-run.js';
31
+ import { cmdHostedSmoke } from './hosted.js';
32
+ import { cmdHostedQueue } from './hosted-queue.js';
33
+ import { cmdHostedRunner } from './hosted-runner.js';
34
+ import { resolveFactoryCheckoutCommit, runFilingPreview } from './filing.js';
35
+ import { buildFeedbackDeps, runFeedback } from './feedback.js';
36
+ import { ensureFactoryExcluded, writeSampleQueue } from './init-files.js';
37
+ import { cmdLogs } from './logs.js';
38
+ import { applyHelpGroups } from './help-groups.js';
39
+ import { mergeScopeNotice } from './merge-scope.js';
40
+ import { createFactoryOctokit, formatDeprecation } from './octokit.js';
41
+ import { DeepCheckError, createDeepModelRunner, runIssueCheck } from './ready-check.js';
42
+ import { childRunSucceeded, formatChildRunSummary, runChildrenInOrder } from './run-children.js';
43
+ import { readRunFlagOverrides, writeRunFlagOverrides } from './run-flags.js';
44
+ import { distFreshnessProbe, runStalenessGuard } from './staleness.js';
45
+ import { readStopFileStatus, stopSentinelCheck, stopSentinelRunSkipMessage } from './stop-sentinel.js';
46
+ import { checkSweepHeartbeat, defaultSweepHeartbeatDeps, formatSweepHeartbeatStatusLine, sweepHeartbeatCheck, } from './sweep-heartbeat.js';
47
+ const DEFAULT_CONFIG_LOADERS = {
48
+ loadModelsConfig,
49
+ loadRoutesConfig,
50
+ loadFactoryConfigForRepo,
51
+ loadRepoConfig,
52
+ resolveTimeouts,
53
+ resolveSkipCI,
54
+ getConstitutionsDir,
55
+ resolveEffectiveModelPins,
56
+ };
57
+ /** Loaders for the current main() invocation; DEFAULT_CONFIG_LOADERS outside main(). */
58
+ let configLoaders = DEFAULT_CONFIG_LOADERS;
59
+ /** Per-field `??` so an explicit `undefined` override falls back instead of replacing the default. */
60
+ function resolveConfigLoaders(overrides, base = configLoaders) {
61
+ return {
62
+ loadModelsConfig: overrides.loadModelsConfig ?? base.loadModelsConfig,
63
+ loadRoutesConfig: overrides.loadRoutesConfig ?? base.loadRoutesConfig,
64
+ loadFactoryConfigForRepo: overrides.loadFactoryConfigForRepo ?? base.loadFactoryConfigForRepo,
65
+ loadRepoConfig: overrides.loadRepoConfig ?? base.loadRepoConfig,
66
+ resolveTimeouts: overrides.resolveTimeouts ?? base.resolveTimeouts,
67
+ resolveSkipCI: overrides.resolveSkipCI ?? base.resolveSkipCI,
68
+ getConstitutionsDir: overrides.getConstitutionsDir ?? base.getConstitutionsDir,
69
+ resolveEffectiveModelPins: overrides.resolveEffectiveModelPins ?? base.resolveEffectiveModelPins,
70
+ };
71
+ }
72
+ const DEFAULT_GIT_OPS = { setupWorktree, cleanupWorktree, gitFetch };
73
+ /** Git worktree/fetch ops for the current main() or cmdLand invocation; DEFAULT_GIT_OPS outside them. */
74
+ let gitOps = DEFAULT_GIT_OPS;
75
+ /** Per-field `??` so an explicit `undefined` override falls back instead of replacing the default. */
76
+ function resolveGitOps(overrides, base = gitOps) {
77
+ return {
78
+ setupWorktree: overrides.setupWorktree ?? base.setupWorktree,
79
+ cleanupWorktree: overrides.cleanupWorktree ?? base.cleanupWorktree,
80
+ gitFetch: overrides.gitFetch ?? base.gitFetch,
81
+ };
82
+ }
83
+ const DEFAULT_INTERNAL_OPS = {
84
+ watchChecks,
85
+ createLocalSmallDryRun,
86
+ readCosts,
87
+ sweepWorktrees,
88
+ releaseStaleClaims,
89
+ findUnmergedGreenPrs,
90
+ listOrphanContainers,
91
+ reapOrphanContainers,
92
+ listMicroVms,
93
+ reapOrphanMicroVm,
94
+ formatGcReport,
95
+ };
96
+ /** factory-core/internal ops for the current main() or cmdLand invocation; DEFAULT_INTERNAL_OPS outside them. */
97
+ let internalOps = DEFAULT_INTERNAL_OPS;
98
+ /** Per-field `??` so an explicit `undefined` override falls back instead of replacing the default. */
99
+ function resolveInternalOps(overrides, base = internalOps) {
100
+ return {
101
+ watchChecks: overrides.watchChecks ?? base.watchChecks,
102
+ createLocalSmallDryRun: overrides.createLocalSmallDryRun ?? base.createLocalSmallDryRun,
103
+ readCosts: overrides.readCosts ?? base.readCosts,
104
+ sweepWorktrees: overrides.sweepWorktrees ?? base.sweepWorktrees,
105
+ releaseStaleClaims: overrides.releaseStaleClaims ?? base.releaseStaleClaims,
106
+ findUnmergedGreenPrs: overrides.findUnmergedGreenPrs ?? base.findUnmergedGreenPrs,
107
+ listOrphanContainers: overrides.listOrphanContainers ?? base.listOrphanContainers,
108
+ reapOrphanContainers: overrides.reapOrphanContainers ?? base.reapOrphanContainers,
109
+ listMicroVms: overrides.listMicroVms ?? base.listMicroVms,
110
+ reapOrphanMicroVm: overrides.reapOrphanMicroVm ?? base.reapOrphanMicroVm,
111
+ formatGcReport: overrides.formatGcReport ?? base.formatGcReport,
112
+ };
113
+ }
114
+ const promisifiedExec = promisify(execCb);
115
+ const DEFAULT_SHELL_OPS = {
116
+ exec: async (command, options) => {
117
+ const { stdout, stderr } = await promisifiedExec(command, { ...options, encoding: 'utf-8' });
118
+ return { stdout, stderr };
119
+ },
120
+ execSync: (command, options) => String(execSync(command, { ...options, encoding: 'utf-8' })),
121
+ };
122
+ /** Shell exec seams for the current main() or cmdLand invocation; DEFAULT_SHELL_OPS outside them. */
123
+ let shellOps = DEFAULT_SHELL_OPS;
124
+ /** Per-field `??` so an explicit `undefined` override falls back instead of replacing the default. */
125
+ function resolveShellOps(overrides, base = shellOps) {
126
+ return {
127
+ exec: overrides.exec ?? base.exec,
128
+ execSync: overrides.execSync ?? base.execSync,
129
+ };
130
+ }
131
+ const DEFAULT_CORE_OPS = {
132
+ ModelRouter,
133
+ ConstitutionLoader,
134
+ ModelRegistry,
135
+ isCommandAvailable,
136
+ defaultFindPortListeners,
137
+ reapOrphanProcesses,
138
+ estimateTrailingSpend,
139
+ formatUsageReport,
140
+ watchUsage,
141
+ fetchSubscriptionUsage,
142
+ diagnoseModels,
143
+ writeLocalRunReport,
144
+ };
145
+ /** factory-core classes/ops for the current main() invocation; DEFAULT_CORE_OPS outside it. */
146
+ let coreOps = DEFAULT_CORE_OPS;
147
+ /** Per-field `??` so an explicit `undefined` override falls back instead of replacing the default. */
148
+ function resolveCoreOps(overrides, base = coreOps) {
149
+ return {
150
+ ModelRouter: overrides.ModelRouter ?? base.ModelRouter,
151
+ ConstitutionLoader: overrides.ConstitutionLoader ?? base.ConstitutionLoader,
152
+ ModelRegistry: overrides.ModelRegistry ?? base.ModelRegistry,
153
+ isCommandAvailable: overrides.isCommandAvailable ?? base.isCommandAvailable,
154
+ defaultFindPortListeners: overrides.defaultFindPortListeners ?? base.defaultFindPortListeners,
155
+ reapOrphanProcesses: overrides.reapOrphanProcesses ?? base.reapOrphanProcesses,
156
+ estimateTrailingSpend: overrides.estimateTrailingSpend ?? base.estimateTrailingSpend,
157
+ formatUsageReport: overrides.formatUsageReport ?? base.formatUsageReport,
158
+ watchUsage: overrides.watchUsage ?? base.watchUsage,
159
+ fetchSubscriptionUsage: overrides.fetchSubscriptionUsage ?? base.fetchSubscriptionUsage,
160
+ diagnoseModels: overrides.diagnoseModels ?? base.diagnoseModels,
161
+ writeLocalRunReport: overrides.writeLocalRunReport ?? base.writeLocalRunReport,
162
+ };
163
+ }
164
+ const DEFAULT_PHASE_OPS = { planPhase, buildPhase, checkPhase, shipPhase };
165
+ /** factory-core phases for the current main() invocation; DEFAULT_PHASE_OPS outside it. */
166
+ let phaseOps = DEFAULT_PHASE_OPS;
167
+ /** Per-field `??` so an explicit `undefined` override falls back instead of replacing the default. */
168
+ function resolvePhaseOps(overrides, base = phaseOps) {
169
+ return {
170
+ planPhase: overrides.planPhase ?? base.planPhase,
171
+ buildPhase: overrides.buildPhase ?? base.buildPhase,
172
+ checkPhase: overrides.checkPhase ?? base.checkPhase,
173
+ shipPhase: overrides.shipPhase ?? base.shipPhase,
174
+ };
175
+ }
176
+ export const PREREQUISITES_TEXT = `Prerequisites:
177
+ - Claude Code CLI installed and authenticated (Claude subscription): https://claude.com/claude-code
178
+ - GitHub CLI authenticated: gh auth login
179
+ - GITHUB_TOKEN or GH_TOKEN set (falls back to \`gh auth token\`)
180
+ Run inside a git repository with a GitHub remote.
181
+ `;
182
+ /** Printed after the command list in `factory --help`: the env vars operators reach for most. */
183
+ export const HELP_FOOTER_TEXT = `
184
+ Common environment variables:
185
+ FACTORY_MERGE=1 Squash-merge eligible PRs (--auto-merge / --no-auto-merge win)
186
+ FACTORY_MERGE_ADMIN=1 Merge with GitHub admin bypass of unmet requirements
187
+ FACTORY_SKIP_CI=1 Do not wait for CI before merging
188
+ FACTORY_SANDBOX=0 Disable the containment sandbox for agent runs (dangerous)
189
+ FACTORY_PLAN_MODEL=<id> Pick the PLAN model
190
+ FACTORY_BUILD_MODEL=<id> Pick the BUILD model
191
+ FACTORY_STOP_AT=<fraction> Usage fraction (0-1] at which work pauses
192
+ FACTORY_RESUME_AT=<fraction> Usage fraction (0-1] at which paused work resumes
193
+ FACTORY_ALLOW_STALE=1 Run even when the built CLI is older than its source
194
+ Every config key, its default, and its env var: docs/config.example.yaml
195
+
196
+ Run \`factory <command> --help\` for a command's options.
197
+ Docs: ${DOCS_URL}`;
198
+ // ---------- helpers ----------
199
+ /** PATH probe via the injectable shell seam (mirrors core isCommandAvailable). */
200
+ function isCommandAvailableViaShell(shell, cmd) {
201
+ try {
202
+ shell.execSync(`command -v ${cmd} 2>/dev/null`, { stdio: 'pipe' });
203
+ return true;
204
+ }
205
+ catch {
206
+ return false;
207
+ }
208
+ }
209
+ async function getRepoRoot(shell = shellOps) {
210
+ try {
211
+ const { stdout } = await shell.exec('git rev-parse --show-toplevel');
212
+ return stdout.trim();
213
+ }
214
+ catch {
215
+ throw new CliExitError('factory: not inside a git repository', 2);
216
+ }
217
+ }
218
+ async function getGitHubRepo(shell = shellOps) {
219
+ try {
220
+ const { stdout } = await shell.exec('gh repo view --json nameWithOwner --jq .nameWithOwner');
221
+ return stdout.trim();
222
+ }
223
+ catch {
224
+ throw new CliExitError('factory: no GitHub remote detected (gh repo view failed)', 2);
225
+ }
226
+ }
227
+ /** Env token first, then one `gh auth token` subprocess (≤5 s). `undefined` when neither yields one. */
228
+ function resolveGitHubToken(shell = shellOps) {
229
+ let token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
230
+ if (!token) {
231
+ try {
232
+ const out = shell.execSync('gh auth token', { timeout: 5_000 });
233
+ token = out.trim() || undefined;
234
+ }
235
+ catch { }
236
+ }
237
+ return token;
238
+ }
239
+ /** Octokit factory for the current main() or cmdLand invocation (CliDeps.octokit); unset outside them. */
240
+ let octokitFactory;
241
+ function getOctokit(shell = shellOps, options) {
242
+ return octokitFactory ? octokitFactory() : createFactoryOctokit(resolveGitHubToken(shell), options);
243
+ }
244
+ export function errorDetail(err) {
245
+ const e = err;
246
+ const stderr = typeof e?.stderr === 'string' ? e.stderr.trim() : '';
247
+ if (stderr)
248
+ return stderr;
249
+ if (typeof e?.message === 'string' && e.message)
250
+ return e.message;
251
+ return String(err);
252
+ }
253
+ export function hasGitHubToken(env = process.env, tryToken) {
254
+ if (env.GITHUB_TOKEN || env.GH_TOKEN)
255
+ return true;
256
+ try {
257
+ const out = (tryToken ?? (() => shellOps.execSync('gh auth token', { timeout: 5_000 })))();
258
+ return out.trim().length > 0;
259
+ }
260
+ catch {
261
+ return false;
262
+ }
263
+ }
264
+ // ---------- commands ----------
265
+ /** The minimal, pin-free repo config `factory init` writes. No model pins → routing
266
+ * stays on packaged defaults (policy=auto). YAML (header comment + `version: 2`) by default;
267
+ * `'json'` gives two-space JSON + trailing newline for repos that already use config.json.
268
+ * Every available key is documented in docs/config.example.jsonc. */
269
+ export function buildInitConfig(format = 'yaml') {
270
+ if (format === 'json')
271
+ return JSON.stringify({ version: 2 }, null, 2) + '\n';
272
+ return REPO_CONFIG_YAML_HEADER + 'version: 2\n';
273
+ }
274
+ /** Write content once without a check-then-act race. Callers choose whether an
275
+ * existing file is preserved or overwritten. */
276
+ function writeIfAbsent(path, content, label, force = false) {
277
+ if (force) {
278
+ writeFileSync(path, content);
279
+ console.log(styleText('green', `Wrote ${path}`));
280
+ return;
281
+ }
282
+ try {
283
+ writeFileSync(path, content, { flag: 'wx' });
284
+ console.log(styleText('green', `Wrote ${path}`));
285
+ }
286
+ catch (err) {
287
+ if (err.code === 'EEXIST') {
288
+ console.log(styleText('yellow', `${label} exists — leaving as-is (use --force to overwrite)`));
289
+ return;
290
+ }
291
+ throw err;
292
+ }
293
+ }
294
+ /** One-line onboarding reachability summary, e.g. "policy=auto, 3/7 models reachable". */
295
+ export function formatInitReachability(diagnoses) {
296
+ const reachable = diagnoses.filter((d) => d.reachable).length;
297
+ return `policy=auto, ${reachable}/${diagnoses.length} models reachable`;
298
+ }
299
+ async function cmdInit(opts = {}) {
300
+ const repoRoot = await getRepoRoot();
301
+ // init itself never calls GitHub, so a missing token is a warning here; ship/run still need one.
302
+ if (!hasGitHubToken()) {
303
+ console.error(styleText('yellow', `factory: warning: ${missingTokenMessage()}`));
304
+ }
305
+ const paths = getFactoryPaths(repoRoot);
306
+ ensureDir(paths.root);
307
+ ensureDir(paths.logs);
308
+ ensureDir(paths.plans);
309
+ // Add .factory/ to git exclude
310
+ ensureFactoryExcluded(resolve(repoRoot, '.git/info/exclude'));
311
+ // Create sample queue if not exists
312
+ writeSampleQueue(paths.queue);
313
+ // Write onboarding files. Idempotent: never clobber an existing file unless --force.
314
+ const force = opts.force === true;
315
+ // A fresh repo gets config.yaml; an existing config keeps its own format (--force never switches it).
316
+ const configPath = existsSync(paths.config) ? paths.config : resolve(paths.root, 'config.yaml');
317
+ const constitutionPath = resolve(paths.root, 'constitution.md');
318
+ const gitignorePath = resolve(paths.root, '.gitignore');
319
+ writeIfAbsent(configPath, buildInitConfig(configPath.endsWith('.json') ? 'json' : 'yaml'), `.factory/${basename(configPath)}`, force);
320
+ // Constitution scaffold: repo directory basename fills <product-name>/<Product>.
321
+ const repoName = basename(repoRoot);
322
+ const template = readFileSync(resolve(configLoaders.getConstitutionsDir(), '_template.md'), 'utf-8');
323
+ writeIfAbsent(constitutionPath, scaffoldConstitution(template, repoName), '.factory/constitution.md', force);
324
+ writeIfAbsent(gitignorePath, 'state/\n', '.factory/.gitignore', force);
325
+ // Doctor-style validation (policy=auto, N models reachable) — informational, never fails init.
326
+ const modelsConfig = applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot));
327
+ const registry = new coreOps.ModelRegistry(modelsConfig);
328
+ const diagnoses = coreOps.diagnoseModels(registry, {}, resolveExperimental(), resolveLocalOnly());
329
+ console.log(formatInitReachability(diagnoses));
330
+ if (!hasReachableWorker(diagnoses)) {
331
+ console.log(styleText('yellow', 'No worker model reachable yet — see `factory doctor` and `factory models --doctor`.'));
332
+ }
333
+ console.log(styleText('green', `Initialized ${paths.root}`));
334
+ console.log(`Next: factory constitution --product <name>, then factory triage`);
335
+ }
336
+ /** Rewrite a checkout's legacy `.factory/` inputs and runtime files to the v2
337
+ * layout. Every write is idempotent so it is safe to run this more than once. */
338
+ export async function runMigrate(repoRoot, opts = {}) {
339
+ const dryRun = opts.dryRun === true;
340
+ const paths = getFactoryPaths(repoRoot);
341
+ if (opts.toYaml) {
342
+ let result;
343
+ try {
344
+ result = (opts.migrateToYaml ?? migrateRepoConfigToYaml)(paths.root, { dryRun });
345
+ }
346
+ catch (err) {
347
+ throw new CliExitError(`factory: ${err.message}`, 1);
348
+ }
349
+ switch (result.status) {
350
+ case 'migrated':
351
+ console.log('converted .factory/config.json to .factory/config.yaml');
352
+ return;
353
+ case 'would-migrate':
354
+ console.log('would convert .factory/config.json to .factory/config.yaml');
355
+ return;
356
+ case 'already-yaml':
357
+ console.log(`config already YAML (${basename(result.path)}) — nothing to convert`);
358
+ return;
359
+ case 'no-config':
360
+ console.log('no .factory/config.json — nothing to convert');
361
+ return;
362
+ case 'round-trip-failed':
363
+ throw new CliExitError(`factory: config.yaml did not round-trip (${result.reason}); kept .factory/config.json`, 1);
364
+ }
365
+ }
366
+ const isRuntimePath = (path) => {
367
+ const fromState = relative(paths.state, path);
368
+ return fromState !== '' && !fromState.startsWith('..') && !resolve(paths.state, fromState).startsWith('..');
369
+ };
370
+ if (!dryRun)
371
+ mkdirSync(paths.state, { recursive: true });
372
+ const transientLocks = new Set(['merge.lock', 'git.lock', 'run.lock']);
373
+ for (const path of Object.values(paths)) {
374
+ if (!isRuntimePath(path) || transientLocks.has(basename(path)))
375
+ continue;
376
+ const legacyPath = resolve(paths.root, basename(path));
377
+ if (!existsSync(legacyPath) || existsSync(path))
378
+ continue;
379
+ if (dryRun) {
380
+ console.log(`would move ${legacyPath} to ${path}`);
381
+ }
382
+ else {
383
+ renameSync(legacyPath, path);
384
+ console.log(`moved ${legacyPath} to ${path}`);
385
+ }
386
+ }
387
+ let rawConfig;
388
+ try {
389
+ rawConfig = JSON.parse(readFileSync(paths.config, 'utf-8'));
390
+ }
391
+ catch (err) {
392
+ if (err.code !== 'ENOENT')
393
+ throw err;
394
+ }
395
+ if (!rawConfig) {
396
+ console.log('no .factory/config.json — nothing to rewrite');
397
+ }
398
+ else if (rawConfig.version === 2) {
399
+ console.log('config.json already v2');
400
+ }
401
+ else {
402
+ const v2 = configLoaders.loadRepoConfig(repoRoot);
403
+ if (!v2)
404
+ throw new Error(`Expected ${paths.config} to exist`);
405
+ const { $schema: _schema, version: _version, ...rest } = v2;
406
+ // loadRepoConfig only returns the model-routing namespace; carry the runtime-policy
407
+ // keys (sweep, run, timeouts, ...) over verbatim so the rewrite does not drop them.
408
+ const source = rawConfig;
409
+ const runtime = Object.fromEntries(FACTORY_RUNTIME_CONFIG_KEYS.filter((key) => key in source).map((key) => [key, source[key]]));
410
+ const content = JSON.stringify({
411
+ ...(typeof rawConfig.$schema === 'string' ? { $schema: rawConfig.$schema } : {}),
412
+ version: 2,
413
+ ...rest,
414
+ ...runtime,
415
+ }, null, 2) + '\n';
416
+ if (dryRun) {
417
+ console.log('would rewrite .factory/config.json to v2');
418
+ }
419
+ else {
420
+ writeFileSync(paths.config, content);
421
+ console.log('rewrote .factory/config.json to v2');
422
+ }
423
+ }
424
+ const constitutionPath = resolve(paths.root, 'constitution.md');
425
+ if (!existsSync(constitutionPath)) {
426
+ const product = readActiveProduct(paths.product);
427
+ const productConstitution = product ? resolve(configLoaders.getConstitutionsDir(), `${product}.md`) : undefined;
428
+ const content = productConstitution && existsSync(productConstitution)
429
+ ? readFileSync(productConstitution, 'utf-8')
430
+ : scaffoldConstitution(readFileSync(resolve(configLoaders.getConstitutionsDir(), '_template.md'), 'utf-8'), basename(repoRoot));
431
+ if (dryRun) {
432
+ console.log('would write .factory/constitution.md');
433
+ }
434
+ else {
435
+ writeIfAbsent(constitutionPath, content, '.factory/constitution.md');
436
+ }
437
+ }
438
+ else {
439
+ console.log('constitution.md already exists');
440
+ }
441
+ const gitignorePath = resolve(paths.root, '.gitignore');
442
+ if (!existsSync(gitignorePath)) {
443
+ if (dryRun) {
444
+ console.log('would write .factory/.gitignore');
445
+ }
446
+ else {
447
+ writeIfAbsent(gitignorePath, 'state/\n', '.factory/.gitignore');
448
+ }
449
+ }
450
+ else {
451
+ console.log('.gitignore already exists');
452
+ }
453
+ console.log(dryRun ? 'Migration preview complete.' : 'Migrated .factory/ to the v2 layout.');
454
+ console.log('Verify: factory status');
455
+ }
456
+ async function cmdMigrate(opts = {}) {
457
+ await runMigrate(await getRepoRoot(), opts);
458
+ }
459
+ export class InvalidProductNameError extends Error {
460
+ }
461
+ /** Expected user-facing CLI failure. Thrown by command helpers; only main() maps it to a process exit code. */
462
+ export class CliExitError extends Error {
463
+ code;
464
+ constructor(message, code) {
465
+ super(message);
466
+ this.code = code;
467
+ this.name = 'CliExitError';
468
+ }
469
+ }
470
+ /** Parse a CLI <issue> argument. Throws CliExitError(2) before any git/GitHub work runs. */
471
+ export function parseIssueArg(raw) {
472
+ const trimmed = raw.trim();
473
+ if (!/^\d+$/.test(trimmed) || Number(trimmed) < 1) {
474
+ throw new CliExitError(`factory: invalid issue argument '${raw}' — expected a positive integer issue number`, 2);
475
+ }
476
+ return Number(trimmed);
477
+ }
478
+ /** Run `factory feedback` and print the filed issue; validation errors become exit-2 CliExitErrors. */
479
+ export async function runFeedbackCommand(prUrl, opts, deps, log = console.log) {
480
+ try {
481
+ const result = await runFeedback(prUrl, opts, deps);
482
+ log(`Filed ${result.repo}#${result.issueNumber}`);
483
+ }
484
+ catch (err) {
485
+ if (err instanceof Error && err.message.startsWith('factory feedback:'))
486
+ throw new CliExitError(err.message, 2);
487
+ throw err;
488
+ }
489
+ }
490
+ export async function cmdFeedback(prUrl, opts) {
491
+ const paths = getFactoryPaths(await getRepoRoot());
492
+ const prefix = opts.branchPrefix === undefined ? resolveBranchPrefix() : branchPrefixOrExit(opts.branchPrefix);
493
+ await runFeedbackCommand(prUrl, opts, buildFeedbackDeps(getOctokit(), paths, prefix, createOctokitFilingClient));
494
+ }
495
+ async function resolveFactoryCommit() {
496
+ return resolveFactoryCheckoutCommit(dirname(fileURLToPath(import.meta.url)), async (cmd, cwd) => (await shellOps.exec(cmd, { cwd })).stdout);
497
+ }
498
+ export async function cmdFilingPreview(runId, opts) {
499
+ const paths = getFactoryPaths(await getRepoRoot());
500
+ const prefix = opts.branchPrefix === undefined ? resolveBranchPrefix() : branchPrefixOrExit(opts.branchPrefix);
501
+ const found = await runFilingPreview(runId, {
502
+ out: process.stdout,
503
+ err: process.stderr,
504
+ eventsFile: paths.events,
505
+ runsDir: daemonRuntimePaths().runsDir,
506
+ factoryVersion: getCliVersion(),
507
+ factoryCommit: resolveFactoryCommit,
508
+ os: `${platform()} ${release()} ${arch()}`,
509
+ nodeVersion: process.version,
510
+ usernames: [userInfo().username],
511
+ hostnames: [osHostname()],
512
+ branchPrefix: prefix,
513
+ });
514
+ if (!found)
515
+ throw new CliExitError(`no failure evidence found for run ${runId}`, 2);
516
+ }
517
+ function branchPrefixOrExit(raw) {
518
+ const prefix = resolveBranchPrefixOption(raw);
519
+ if (prefix === null)
520
+ throw new CliExitError(INVALID_BRANCH_PREFIX_MESSAGE, 2);
521
+ return prefix;
522
+ }
523
+ function sizeGateOrExit(raw) {
524
+ if (raw === undefined)
525
+ return undefined;
526
+ const mode = parseSizeGateMode(raw);
527
+ if (mode === undefined) {
528
+ throw new CliExitError(`factory: invalid --size-gate '${raw}' — expected one of: ${SIZE_GATE_MODES.join(', ')}`, 2);
529
+ }
530
+ return mode;
531
+ }
532
+ function maxSlicesOrExit(raw) {
533
+ if (raw === undefined)
534
+ return undefined;
535
+ const n = parseMaxSlices(raw);
536
+ if (n === undefined) {
537
+ throw new CliExitError(`factory: invalid --max-slices '${raw}' — expected a whole number from 1 to 20`, 2);
538
+ }
539
+ return n;
540
+ }
541
+ /** Deterministic run number for a brief, derived from its content digest.
542
+ * Reserved 9,000,000+ range keeps brief branches/worktrees clear of real issue numbers. */
543
+ function briefRunNumber(sha256Hex) {
544
+ return 9_000_000 + (Number.parseInt(sha256Hex.slice(0, 8), 16) % 1_000_000);
545
+ }
546
+ // Product names become a filename in the constitutions dir; keep them to a safe,
547
+ // listable charset. Leading '_' is reserved (listProducts hides `_*.md`).
548
+ export function assertValidProduct(product) {
549
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(product)) {
550
+ throw new InvalidProductNameError(`invalid product name '${product}': use letters, digits, '.', '_' or '-' and do not start with '_' or '.'`);
551
+ }
552
+ }
553
+ /**
554
+ * Read the active product name from the `.factory/product` file.
555
+ * Returns the trimmed content, or undefined when the file is missing
556
+ * or contains only whitespace.
557
+ */
558
+ export function readActiveProduct(productPath, deps = {}) {
559
+ const { fileExists = existsSync, readFile = (p) => readFileSync(p, 'utf-8') } = deps;
560
+ if (!fileExists(productPath))
561
+ return undefined;
562
+ const product = readFile(productPath).trim();
563
+ return product || undefined;
564
+ }
565
+ /** Extract the template's ```markdown skeleton and fill in the product name. */
566
+ export function scaffoldConstitution(template, product) {
567
+ const match = template.match(/```markdown\n([\s\S]*?)```/);
568
+ if (!match) {
569
+ throw new Error('constitution template is missing its ```markdown skeleton block');
570
+ }
571
+ const skeleton = match[1].replace(/\s+$/, '') + '\n';
572
+ const display = product
573
+ .split(/[-_]/)
574
+ .filter(Boolean)
575
+ .map((w) => w[0].toUpperCase() + w.slice(1))
576
+ .join(' ');
577
+ return skeleton.replaceAll('<product-name>', JSON.stringify(product)).replaceAll('<Product>', display);
578
+ }
579
+ export async function cmdConstitution(opts, deps = {}) {
580
+ const loader = new (resolveCoreOps(deps).ConstitutionLoader)();
581
+ if (typeof opts.init === 'string' || opts.init === true) {
582
+ const product = typeof opts.init === 'string' ? opts.init : undefined;
583
+ if (product !== undefined) {
584
+ try {
585
+ assertValidProduct(product);
586
+ }
587
+ catch (err) {
588
+ if (err instanceof InvalidProductNameError) {
589
+ throw new CliExitError(err.message, 2);
590
+ }
591
+ throw err;
592
+ }
593
+ }
594
+ const repoRoot = await getRepoRoot();
595
+ const paths = getFactoryPaths(repoRoot);
596
+ ensureDir(paths.root);
597
+ const constitutionPath = resolve(paths.root, 'constitution.md');
598
+ if (existsSync(constitutionPath) && !opts.force) {
599
+ throw new CliExitError('.factory/constitution.md already exists — use --force to overwrite', 1);
600
+ }
601
+ const template = readFileSync(resolve(configLoaders.getConstitutionsDir(), '_template.md'), 'utf-8');
602
+ const content = scaffoldConstitution(template, product ?? basename(repoRoot));
603
+ writeIfAbsent(constitutionPath, content, '.factory/constitution.md', opts.force);
604
+ console.log(`Next: factory constitution --product ${product ?? basename(repoRoot)}`);
605
+ return;
606
+ }
607
+ if (opts.list) {
608
+ const products = loader.listProducts();
609
+ for (const p of products)
610
+ console.log(` ${p}`);
611
+ return;
612
+ }
613
+ if (opts.product) {
614
+ const products = loader.listProducts();
615
+ if (!products.includes(opts.product)) {
616
+ throw new CliExitError(`No constitution '${opts.product}' found. Available products: ${products.join(', ')}`, 1);
617
+ }
618
+ const repoRoot = await getRepoRoot();
619
+ const paths = getFactoryPaths(repoRoot);
620
+ ensureDir(paths.root);
621
+ const constitutionPath = resolve(paths.root, 'constitution.md');
622
+ if (existsSync(constitutionPath) && !opts.force) {
623
+ throw new CliExitError('.factory/constitution.md already exists — use --force to overwrite', 1);
624
+ }
625
+ const content = readFileSync(resolve(configLoaders.getConstitutionsDir(), `${opts.product}.md`), 'utf-8');
626
+ writeIfAbsent(constitutionPath, content, '.factory/constitution.md', opts.force);
627
+ ensureDir(paths.state);
628
+ writeFileSync(paths.product, opts.product);
629
+ console.log(styleText('green', `Active product: ${opts.product}`));
630
+ return;
631
+ }
632
+ throw new CliExitError('usage: factory constitution --init <product> | --list | --product <name>', 2);
633
+ }
634
+ export function formatDoctorReport(diagnoses) {
635
+ const lines = [styleText('bold', '\n== Model Doctor ==')];
636
+ for (const d of diagnoses) {
637
+ const icon = d.reachable ? styleText('green', '✅') : styleText('red', '❌');
638
+ const tiers = d.tiers.join('/');
639
+ lines.push(` ${icon} ${d.model} provider=${d.provider} tier=${tiers} — ${d.reason}`);
640
+ }
641
+ return lines.join('\n');
642
+ }
643
+ export function hasReachableWorker(diagnoses) {
644
+ return diagnoses.some((d) => d.reachable && (d.tiers.includes('worker') || d.tiers.includes('worker_fallback')));
645
+ }
646
+ function ollamaModelSet() {
647
+ try {
648
+ const out = shellOps.execSync('ollama list', { timeout: 10_000 });
649
+ return new Set(out
650
+ .split('\n')
651
+ .slice(1)
652
+ .map((line) => line.trim().split(/\s+/)[0])
653
+ .filter(Boolean));
654
+ }
655
+ catch {
656
+ return undefined;
657
+ }
658
+ }
659
+ async function cmdModels(opts = {}) {
660
+ const repoRoot = await getRepoRoot();
661
+ const modelsConfig = applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot));
662
+ const registry = new coreOps.ModelRegistry(modelsConfig);
663
+ const allowExperimental = resolveExperimental();
664
+ const localOnly = resolveLocalOnly();
665
+ if (opts.doctor) {
666
+ const ollamaModels = ollamaModelSet();
667
+ const diagnoses = coreOps.diagnoseModels(registry, {
668
+ ollamaModelPresent: ollamaModels ? (model) => ollamaModels.has(model) : undefined,
669
+ }, allowExperimental, localOnly);
670
+ console.log(formatDoctorReport(diagnoses));
671
+ if (!hasReachableWorker(diagnoses)) {
672
+ throw new CliExitError('factory: no worker model is reachable — fix the reasons above before running a queue', 1);
673
+ }
674
+ return;
675
+ }
676
+ console.log(styleText('bold', '\n== Available Models =='));
677
+ for (const m of registry.list()) {
678
+ const tiers = registry.getTiers(m).join('/');
679
+ const est = registry.estimateCost(m, 1_000_000, 1_000_000);
680
+ const cost = est === null ? 'unpriced' : `$${est.toFixed(2)}/M`;
681
+ const gated = registry.isExperimental(m) && !allowExperimental;
682
+ const avail = !gated && registry.isAvailable(m) ? styleText('green', '✅') : styleText('red', '❌');
683
+ const tag = registry.isExperimental(m) ? styleText('yellow', ' [experimental]') : '';
684
+ console.log(` ${avail} ${m} tier=${tiers} ${cost}${tag}`);
685
+ }
686
+ console.log(styleText('bold', '\n== Tiers =='));
687
+ for (const tier of ['boss', 'worker', 'checker', 'triage']) {
688
+ const models = registry.getModelsInTier(tier);
689
+ console.log(` ${tier}: ${models.join(' ')}`);
690
+ }
691
+ }
692
+ async function cmdCost(opts = {}) {
693
+ const repoRoot = await getRepoRoot();
694
+ const paths = getFactoryPaths(repoRoot);
695
+ if (opts.json) {
696
+ let jsonCosts;
697
+ try {
698
+ jsonCosts = internalOps.readCosts(paths.costs);
699
+ }
700
+ catch (err) {
701
+ throw new CliExitError(`factory: cost failed — ${errorDetail(err)}`, 1);
702
+ }
703
+ console.log(JSON.stringify(buildCostJson(jsonCosts, opts.issue)));
704
+ return;
705
+ }
706
+ const costs = internalOps.readCosts(paths.costs);
707
+ if (opts.issue) {
708
+ const filtered = costs.filter((c) => c.issue === String(opts.issue));
709
+ if (filtered.length === 0) {
710
+ console.log(`no cost data for issue ${opts.issue}`);
711
+ return;
712
+ }
713
+ console.log(styleText('bold', `== Costs for issue ${opts.issue} ==`));
714
+ for (const c of filtered) {
715
+ console.log(` ${c.task} ${c.model} ${c.cost === null || c.cost === undefined ? 'unknown' : `$${c.cost.toFixed(4)}`}${c.failoverReason ? ` [failover: ${c.failoverReason}]` : ''}`);
716
+ }
717
+ const t = aggregateCosts(filtered).total;
718
+ console.log(' ---');
719
+ console.log(` Total: ${formatCostTotal(t.cost, t.unpricedCount)}`);
720
+ return;
721
+ }
722
+ if (costs.length === 0) {
723
+ console.log('no cost data yet');
724
+ return;
725
+ }
726
+ const byModel = new Map();
727
+ for (const c of costs) {
728
+ const e = byModel.get(c.model) ?? { tasks: 0, priced: 0, pricedCount: 0, unpriced: 0, failovers: 0 };
729
+ e.tasks++;
730
+ if (c.unpriced === true || c.cost === null || c.cost === undefined)
731
+ e.unpriced++;
732
+ else {
733
+ e.priced += c.cost;
734
+ e.pricedCount++;
735
+ }
736
+ if (c.failoverReason)
737
+ e.failovers++;
738
+ byModel.set(c.model, e);
739
+ }
740
+ console.log(styleText('bold', '== Cost Summary =='));
741
+ for (const [model, { tasks, priced, pricedCount, unpriced, failovers }] of byModel) {
742
+ const failoverSuffix = failovers > 0 ? ` (${failovers} failover${failovers === 1 ? '' : 's'})` : '';
743
+ const modelCost = formatCostTotal(pricedCount === 0 && unpriced > 0 ? null : priced, unpriced);
744
+ console.log(` ${model}: ${tasks} tasks, ${modelCost}${failoverSuffix}`);
745
+ }
746
+ const grand = aggregateCosts(costs).total;
747
+ console.log(' ---');
748
+ console.log(` Total: ${formatCostTotal(grand.cost, grand.unpricedCount)}`);
749
+ }
750
+ async function cmdCheck(issueRaw, opts) {
751
+ const issueNum = parseIssueArg(issueRaw);
752
+ const ghRepo = await getGitHubRepo();
753
+ const [owner, repoName] = ghRepo.split('/');
754
+ let runModel;
755
+ if (opts.deep) {
756
+ const repoRoot = await getRepoRoot();
757
+ const repoConfig = configLoaders.loadRepoConfig(repoRoot);
758
+ const effective = resolveEffectiveConfig(repoConfig);
759
+ const router = new coreOps.ModelRouter(applyRepoConfig(configLoaders.loadModelsConfig(), repoConfig), configLoaders.loadRoutesConfig(), false, undefined, effective.allowExperimental, effective.localOnly);
760
+ runModel = createDeepModelRunner(router, repoRoot);
761
+ }
762
+ let report;
763
+ try {
764
+ report = await runIssueCheck(issueNum, opts, {
765
+ runModel,
766
+ getIssue: async (n) => {
767
+ const { data } = await getOctokit().rest.issues.get({ owner, repo: repoName, issue_number: n });
768
+ return { title: data.title, body: data.body ?? null };
769
+ },
770
+ getIssueState: async (n) => {
771
+ const { data } = await getOctokit().rest.issues.get({ owner, repo: repoName, issue_number: n });
772
+ return data.state === 'closed' ? 'closed' : 'open';
773
+ },
774
+ log: (line) => console.log(line),
775
+ });
776
+ }
777
+ catch (err) {
778
+ if (err instanceof DeepCheckError)
779
+ throw new CliExitError(`factory: --deep failed — ${err.message}`, 2);
780
+ throw err;
781
+ }
782
+ if (report.exitCode === 1) {
783
+ throw new CliExitError(`factory: issue #${issueNum} is not factory-ready — ${report.reasons.join('; ')}`, 1);
784
+ }
785
+ if (report.exitCode === 3) {
786
+ throw new CliExitError(`factory: issue #${issueNum} would split — ${report.size.reason}`, 3);
787
+ }
788
+ }
789
+ async function currentCommitSha() {
790
+ try {
791
+ const { stdout } = await shellOps.exec('git rev-parse HEAD');
792
+ return stdout.trim();
793
+ }
794
+ catch {
795
+ return null;
796
+ }
797
+ }
798
+ function resolvedModelTiers(repoRoot, stateRoot) {
799
+ const modelsConfig = applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot, stateRoot));
800
+ return modelsConfig.tiers ?? {};
801
+ }
802
+ function readTextFileOrEmpty(path) {
803
+ try {
804
+ return readFileSync(path, 'utf-8');
805
+ }
806
+ catch (err) {
807
+ if (err.code === 'ENOENT')
808
+ return '';
809
+ throw err;
810
+ }
811
+ }
812
+ async function appendKpiSnapshot(paths, repoRoot, kpis) {
813
+ const record = kpisToHistoryRecord(kpis, new Date().toISOString().slice(0, 10), {
814
+ commitSha: await currentCommitSha(),
815
+ models: resolvedModelTiers(repoRoot, paths.root),
816
+ });
817
+ const updated = appendKpiHistoryLine(readTextFileOrEmpty(paths.kpiHistory), record);
818
+ ensureDir(paths.state);
819
+ writeFileSync(paths.kpiHistory, updated);
820
+ return { record, history: parseKpiHistory(updated) };
821
+ }
822
+ function persistClassifierOutcomes(paths, events, sources, opts) {
823
+ const records = joinClassifierOutcomes(events, sources, opts);
824
+ if (records.length === 0)
825
+ return;
826
+ const updated = mergeClassifierOutcomes(readTextFileOrEmpty(paths.classifierOutcomes), records);
827
+ ensureDir(paths.state);
828
+ writeFileSync(paths.classifierOutcomes, updated);
829
+ }
830
+ async function cmdClassifierReport(opts = {}) {
831
+ const repoRoot = await getRepoRoot();
832
+ const paths = getFactoryPaths(repoRoot);
833
+ const report = summarizeClassifierOutcomes(parseClassifierOutcomes(readTextFileOrEmpty(paths.classifierOutcomes)));
834
+ if (opts.json) {
835
+ console.log(JSON.stringify(report));
836
+ return;
837
+ }
838
+ for (const line of formatClassifierReport(report))
839
+ console.log(line);
840
+ }
841
+ async function cmdClassifierBacktestEntry(opts) {
842
+ const branchPrefix = branchPrefixOrExit(opts.branchPrefix);
843
+ const repoRoot = await getRepoRoot();
844
+ const paths = getFactoryPaths(repoRoot);
845
+ const [owner, repo] = (await getGitHubRepo()).split('/');
846
+ const repoConfig = configLoaders.loadRepoConfig(repoRoot);
847
+ const effective = resolveEffectiveConfig(repoConfig);
848
+ const router = new coreOps.ModelRouter(applyRepoConfig(configLoaders.loadModelsConfig(), repoConfig), configLoaders.loadRoutesConfig(), false, undefined, effective.allowExperimental, effective.localOnly);
849
+ try {
850
+ await cmdClassifierBacktest({ ...opts, branchPrefix }, {
851
+ repoRoot,
852
+ paths,
853
+ owner,
854
+ repo,
855
+ octokit: getOctokit(),
856
+ router,
857
+ rules: resolveReviewFloorRules(repoConfig),
858
+ modelPin: repoConfig?.models?.pins?.classifier,
859
+ windowDays: resolveDefectWindowDays(configLoaders.loadFactoryConfigForRepo(paths.config)),
860
+ exec: (cmd) => shellOps.exec(cmd, { cwd: repoRoot }),
861
+ readFile: (path) => readFileSync(path, 'utf-8'),
862
+ appendFile: (path, text) => appendFileSync(path, text),
863
+ ensureDir,
864
+ readEvents: (path) => (existsSync(path) ? readEvents(path) : []),
865
+ now: () => new Date().toISOString(),
866
+ log: (line) => console.log(line),
867
+ });
868
+ }
869
+ catch (err) {
870
+ if (err instanceof ClassifierBacktestInputError)
871
+ throw new CliExitError(err.message, 2);
872
+ throw err;
873
+ }
874
+ }
875
+ /** Read-only GitHub search dedup (#2102); on any error every cluster is marked tracking unknown (#2103). */
876
+ async function annotateGardenClusters(clusters) {
877
+ const warn = (err) => console.error(`factory garden: issue search unavailable, tracking unknown: ${errorDetail(err)}`);
878
+ try {
879
+ const [owner, repo] = (await getGitHubRepo()).split('/');
880
+ if (!owner || !repo)
881
+ throw new Error('cannot resolve the GitHub repository');
882
+ return await dedupGardenClusters(clusters, {
883
+ client: createOctokitFilingClient(getOctokit()),
884
+ owner,
885
+ repo,
886
+ onSearchError: warn,
887
+ });
888
+ }
889
+ catch (err) {
890
+ warn(err);
891
+ return markGardenTrackingUnknown(clusters);
892
+ }
893
+ }
894
+ async function cmdGarden(opts = {}) {
895
+ if (!opts.report) {
896
+ console.error('factory garden: pass --report (the only mode in this release)');
897
+ process.exit(1);
898
+ }
899
+ const since = opts.since ?? DEFAULT_GARDEN_SINCE;
900
+ const windowMs = parseGardenDuration(since);
901
+ if (windowMs === undefined) {
902
+ console.error(`factory garden: --since must look like 7d, 36h or 2w (got ${since})`);
903
+ process.exit(1);
904
+ }
905
+ const maxRaw = opts.maxClusters ?? String(DEFAULT_GARDEN_MAX_CLUSTERS);
906
+ if (!/^\d+$/.test(maxRaw) || Number(maxRaw) < 1) {
907
+ console.error(`factory garden: --max-clusters must be a positive integer (got ${maxRaw})`);
908
+ process.exit(1);
909
+ }
910
+ const maxClusters = Number(maxRaw);
911
+ const repoRoot = await getRepoRoot();
912
+ const paths = getFactoryPaths(repoRoot);
913
+ const events = filterGardenWindow(readHarvestEvents([paths.events]), windowMs, new Date());
914
+ const clusters = capGardenClusters(clusterGarden(events), maxClusters);
915
+ const annotated = clusters.length === 0 ? clusters : await annotateGardenClusters(clusters);
916
+ const shown = opts.onlyNew ? filterGardenOnlyNew(annotated) : annotated;
917
+ const text = opts.json ? renderGardenJson(shown, { since, maxClusters }) : renderGardenReport(shown).join('\n');
918
+ if (opts.out) {
919
+ try {
920
+ writeFileSync(opts.out, `${text}\n`);
921
+ }
922
+ catch (err) {
923
+ console.error(`factory garden: cannot write ${opts.out}: ${err.message}`);
924
+ process.exit(1);
925
+ }
926
+ console.error(`factory garden: wrote report to ${opts.out}`);
927
+ return;
928
+ }
929
+ if (opts.json)
930
+ console.log(text);
931
+ else
932
+ for (const line of text.split('\n'))
933
+ console.log(line);
934
+ }
935
+ async function cmdKpis(opts = {}) {
936
+ const branchPrefix = branchPrefixOrExit(opts.branchPrefix);
937
+ const repoRoot = await getRepoRoot();
938
+ const paths = getFactoryPaths(repoRoot);
939
+ const events = existsSync(paths.events) ? readEvents(paths.events) : [];
940
+ const costs = existsSync(paths.costs) ? internalOps.readCosts(paths.costs) : [];
941
+ let allEvents = events;
942
+ let prSources = [];
943
+ let owner = '';
944
+ let repoName = '';
945
+ try {
946
+ const ghRepo = await getGitHubRepo();
947
+ [owner, repoName] = ghRepo.split('/');
948
+ const issues = new Set(events.map((e) => e.issue).filter((i) => /^\d+$/.test(i)));
949
+ prSources = await fetchHumanEventSources(getOctokit(), owner, repoName, issues, branchPrefix);
950
+ allEvents = [...events, ...reconstructHumanEvents(prSources, events)];
951
+ }
952
+ catch (err) {
953
+ console.error(styleText('yellow', `factory: GitHub human-event reconstruction unavailable (${err?.message ?? err}) — KPIs use the local log only`));
954
+ }
955
+ if (prSources.length > 0) {
956
+ try {
957
+ const windowDays = resolveDefectWindowDays(configLoaders.loadFactoryConfigForRepo(paths.config));
958
+ const now = new Date().toISOString();
959
+ const merged = mergedPrRefs(prSources);
960
+ const sources = await fetchDefectSources(getOctokit(), owner, repoName, merged, { now, windowDays });
961
+ allEvents = [...allEvents, ...detectPostMergeDefects(sources, allEvents, { now, windowDays })];
962
+ try {
963
+ persistClassifierOutcomes(paths, allEvents, prSources, { now, windowDays });
964
+ }
965
+ catch (err) {
966
+ console.error(styleText('yellow', `factory: classifier outcomes not recorded (${err?.message ?? err})`));
967
+ }
968
+ }
969
+ catch (err) {
970
+ console.error(styleText('yellow', `factory: post-merge defect signals unavailable (${err?.message ?? err}) — postMergeDefectRate omitted from this snapshot`));
971
+ }
972
+ }
973
+ const kpis = computeHealthKpis(allEvents, costs);
974
+ let history;
975
+ try {
976
+ ({ history } = await appendKpiSnapshot(paths, repoRoot, kpis));
977
+ }
978
+ catch (err) {
979
+ console.error(styleText('yellow', `factory: KPI snapshot failed (${err?.message ?? err}) — showing the report without persisting a new snapshot`));
980
+ const existing = readTextFileOrEmpty(paths.kpiHistory);
981
+ history = existing ? parseKpiHistory(existing) : [];
982
+ }
983
+ console.log(renderKpiReport(kpis));
984
+ console.log(renderKpiTrend(history));
985
+ }
986
+ export function resolveUsageKnobs(env = process.env, repoConfig = null, overrides = {}) {
987
+ const { cap } = resolveUsageCap(repoConfig, env);
988
+ const watchdog = resolveWatchdogPolicy(repoConfig, env, overrides);
989
+ return {
990
+ cap,
991
+ stopAt: watchdog.stopAt,
992
+ resumeAt: watchdog.resumeAt,
993
+ pollMs: watchdog.pollMs,
994
+ watch: watchdog.watch,
995
+ estimator: watchdog.estimator,
996
+ watchSource: watchdog.sources.watch,
997
+ stopAtSource: watchdog.sources.stopAt,
998
+ resumeAtSource: watchdog.sources.resumeAt,
999
+ pollSource: watchdog.sources.pollMs,
1000
+ };
1001
+ }
1002
+ /** Human-readable name for whatever decided the usage-watch gate, used in the `factory run`
1003
+ * and `factory supervise` logs so an explicit operator override is always attributable (#1404). */
1004
+ export function usageWatchSourceLabel(source, watch) {
1005
+ switch (source) {
1006
+ case 'flag':
1007
+ return watch ? 'flag: --usage-watch' : 'flag: --no-usage-watch';
1008
+ case 'repo':
1009
+ return '.factory/config.json: budget.watchdog.watch';
1010
+ case 'env':
1011
+ return 'env: FACTORY_USAGE_WATCH';
1012
+ default:
1013
+ return 'default';
1014
+ }
1015
+ }
1016
+ export async function cmdUsage(deps = {}, opts = {}) {
1017
+ const ops = resolveCoreOps(deps);
1018
+ const repoRoot = await getRepoRoot(resolveShellOps(deps));
1019
+ let knobs;
1020
+ try {
1021
+ knobs = resolveUsageKnobs(process.env, configLoaders.loadRepoConfig(repoRoot));
1022
+ }
1023
+ catch (err) {
1024
+ throw new CliExitError(`factory: ${err.message}`, 2);
1025
+ }
1026
+ if (opts.json) {
1027
+ const subscription = await ops.fetchSubscriptionUsage();
1028
+ let estimate;
1029
+ try {
1030
+ const spend = ops.estimateTrailingSpend();
1031
+ estimate = Number.isFinite(spend) ? spend : null;
1032
+ }
1033
+ catch {
1034
+ estimate = null;
1035
+ }
1036
+ console.log(JSON.stringify(buildUsageJson(subscription, estimate, knobs.cap)));
1037
+ return;
1038
+ }
1039
+ const subscriptionPromise = ops.fetchSubscriptionUsage();
1040
+ const spend = ops.estimateTrailingSpend();
1041
+ const heuristicLine = ops.formatUsageReport(spend, knobs.cap);
1042
+ const subscription = await subscriptionPromise;
1043
+ if (subscription !== null) {
1044
+ const resets = subscription.fiveHourResetsAt ? `, resets ${subscription.fiveHourResetsAt}` : '';
1045
+ console.log(`5h subscription usage: ${Math.round(subscription.fiveHourUtilization)}% of plan limit${resets}`);
1046
+ console.log(`heuristic list-price estimate: ${heuristicLine}`);
1047
+ }
1048
+ else {
1049
+ console.log(styleText('yellow', `factory: real subscription usage unavailable — falling back to a rough list-price proxy, not the real subscription limit`));
1050
+ console.log(heuristicLine);
1051
+ }
1052
+ }
1053
+ function warnQueueDiagnostics(diagnostics) {
1054
+ for (const d of diagnostics) {
1055
+ console.error(styleText('yellow', `factory: queue ${d.message} — skipped`));
1056
+ }
1057
+ }
1058
+ /** Formats the time since `lastActivityAt` as a short duration (e.g. `3m`) for the
1059
+ * `factory status` `== Active ==` section's `[phase, <age> ago]` display (#1343). An
1060
+ * active claim's heartbeat is always within DEFAULT_QUEUE_ACTIVITY_STALE_THRESHOLD_MS
1061
+ * (15m), so minutes is the only unit that ever shows. */
1062
+ function formatClaimAge(lastActivityAt, now) {
1063
+ const ageMs = Math.max(0, now - Date.parse(lastActivityAt));
1064
+ const minutes = Math.floor(ageMs / 60_000);
1065
+ return minutes < 1 ? '<1m' : `${minutes}m`;
1066
+ }
1067
+ export async function cmdStatus(opts = {}) {
1068
+ if (opts.json) {
1069
+ // `--kpis` is deliberately ignored here: the JSON shape is fixed (#1101).
1070
+ const repoRoot = await getRepoRoot();
1071
+ const paths = getFactoryPaths(repoRoot);
1072
+ if (!existsSync(paths.root))
1073
+ throw new CliExitError(`factory: ${notInitializedMessage()}`, 2);
1074
+ const ghRepo = await getGitHubRepo();
1075
+ let active = [];
1076
+ if (existsSync(paths.queue)) {
1077
+ const { entries, diagnostics } = parseQueue(readFileSync(paths.queue, 'utf-8'));
1078
+ if (entries.length > 0)
1079
+ ({ active } = await partitionLocalQueueByActivity(entries, paths.runs));
1080
+ warnQueueDiagnostics(diagnostics);
1081
+ }
1082
+ console.log(JSON.stringify(buildStatusJson({
1083
+ repo: ghRepo,
1084
+ product: readActiveProduct(paths.product) ?? null,
1085
+ stop: existsSync(paths.stop),
1086
+ breakers: await new ProviderBreaker(paths.breaker).list(),
1087
+ active,
1088
+ now: Date.now(),
1089
+ })));
1090
+ return;
1091
+ }
1092
+ const repoRoot = await getRepoRoot();
1093
+ const ghRepo = await getGitHubRepo();
1094
+ const paths = getFactoryPaths(repoRoot);
1095
+ const repoConfig = configLoaders.loadRepoConfig(repoRoot);
1096
+ const modelsConfig = applyRepoConfig(configLoaders.loadModelsConfig(), repoConfig);
1097
+ const routesConfig = configLoaders.loadRoutesConfig();
1098
+ const effective = resolveEffectiveConfig(repoConfig);
1099
+ const router = new coreOps.ModelRouter(modelsConfig, routesConfig, false, undefined, effective.allowExperimental, effective.localOnly);
1100
+ const product = readActiveProduct(paths.product) ?? '(none)';
1101
+ console.log(styleText('bold', `== ${ghRepo} ==`));
1102
+ console.log(`Product: ${product}`);
1103
+ const worktreeParent = configLoaders.loadFactoryConfigForRepo(paths.config).worktree.parent;
1104
+ console.log(formatWorktreeLocation(resolveWorktreeRoot({ repoRoot, parent: worktreeParent, repo: ghRepo }), worktreeParent));
1105
+ console.log(styleText('bold', '\n== Active =='));
1106
+ if (existsSync(paths.queue)) {
1107
+ const { entries, diagnostics } = parseQueue(readFileSync(paths.queue, 'utf-8'));
1108
+ if (entries.length > 0) {
1109
+ const { active, staleCount } = await partitionLocalQueueByActivity(entries, paths.runs);
1110
+ if (active.length === 0) {
1111
+ console.log(' (idle — no active claims)');
1112
+ }
1113
+ else {
1114
+ const now = Date.now();
1115
+ for (const e of active) {
1116
+ console.log(` ${e.lane} ${e.issue} [${e.phase}, ${formatClaimAge(e.lastActivityAt, now)} ago]`);
1117
+ }
1118
+ }
1119
+ if (staleCount > 0) {
1120
+ console.log(` (${staleCount} stale entr${staleCount === 1 ? 'y' : 'ies'} hidden — no recent activity)`);
1121
+ }
1122
+ }
1123
+ else if (diagnostics.length === 0) {
1124
+ console.log(' (idle — no active claims)');
1125
+ }
1126
+ warnQueueDiagnostics(diagnostics);
1127
+ }
1128
+ else {
1129
+ console.log(' (no queue file)');
1130
+ }
1131
+ console.log(styleText('bold', '\n== Queue =='));
1132
+ if (!hasGitHubToken()) {
1133
+ console.log(' (no GitHub token — run `gh auth login`)');
1134
+ }
1135
+ else {
1136
+ const [owner, repoName] = ghRepo.split('/');
1137
+ const githubQueue = createGithubQueue({ client: createOctokitQueueClient(getOctokit()), owner, repo: repoName });
1138
+ try {
1139
+ const lanes = await githubQueue.lanes();
1140
+ let printed = false;
1141
+ for (const lane of lanes) {
1142
+ const issues = await githubQueue.list(lane);
1143
+ for (const issue of issues) {
1144
+ console.log(` ${lane} #${issue}`);
1145
+ printed = true;
1146
+ }
1147
+ }
1148
+ if (!printed) {
1149
+ console.log(' (no claimable work)');
1150
+ }
1151
+ }
1152
+ catch (err) {
1153
+ console.log(` (queue lookup failed — ${errorDetail(err)})`);
1154
+ }
1155
+ }
1156
+ console.log(styleText('bold', '\n== Health =='));
1157
+ if (opts.kpis) {
1158
+ console.log(styleText('bold', '\n Effective config:'));
1159
+ for (const line of describeEffectiveConfig({
1160
+ router,
1161
+ repo: repoConfig,
1162
+ repoConfigPath: '.factory/config.json',
1163
+ mergePolicy: resolveMergePolicy(configLoaders.loadFactoryConfigForRepo(paths.config), process.env, {
1164
+ auto: readRunFlagOverrides(paths.runFlags).autoMerge,
1165
+ }),
1166
+ prClassifier: resolvePrClassifierPolicy(configLoaders.loadFactoryConfigForRepo(paths.config), process.env, {
1167
+ classifier: readRunFlagOverrides(paths.runFlags).prClassifier,
1168
+ }),
1169
+ })) {
1170
+ console.log(` ${line}`);
1171
+ }
1172
+ }
1173
+ else {
1174
+ console.log('\n (Effective config and KPIs hidden — run `factory status --kpis` to view)');
1175
+ }
1176
+ console.log(styleText('bold', '\n Provider breaker:'));
1177
+ const openBreakers = await new ProviderBreaker(paths.breaker).list();
1178
+ if (openBreakers.length === 0) {
1179
+ console.log(' (closed)');
1180
+ }
1181
+ else {
1182
+ for (const b of openBreakers) {
1183
+ console.log(` ${b.provider}: OPEN (${b.reason}) — ${Math.ceil(b.remainingMs / 60_000)}m remaining`);
1184
+ }
1185
+ }
1186
+ console.log(styleText('bold', '\n Sweep heartbeat:'));
1187
+ const sweepStatus = checkSweepHeartbeat(configLoaders.loadFactoryConfigForRepo(paths.config).sweep, process.env, defaultSweepHeartbeatDeps());
1188
+ console.log(` ${formatSweepHeartbeatStatusLine(sweepStatus)}`);
1189
+ console.log(styleText('bold', '\n Last Events:'));
1190
+ if (existsSync(paths.events)) {
1191
+ const events = readFileSync(paths.events, 'utf-8').trim().split('\n').slice(-12);
1192
+ for (const e of events) {
1193
+ try {
1194
+ const ev = JSON.parse(e);
1195
+ console.log(` ${ev.type} #${ev.issue}: ${ev.msg}`);
1196
+ }
1197
+ catch { }
1198
+ }
1199
+ }
1200
+ else {
1201
+ console.log(' (none)');
1202
+ }
1203
+ if (opts.kpis) {
1204
+ console.log(styleText('bold', '\n KPIs:'));
1205
+ const kpiEvents = existsSync(paths.events) ? readEvents(paths.events) : [];
1206
+ const kpiCosts = existsSync(paths.costs) ? internalOps.readCosts(paths.costs) : [];
1207
+ for (const line of formatKpiLines(computeHealthKpis(kpiEvents, kpiCosts))) {
1208
+ console.log(` ${line}`);
1209
+ }
1210
+ }
1211
+ if (existsSync(paths.stop)) {
1212
+ console.log(styleText('red', '\n!! STOP file present — factory halting between issues'));
1213
+ }
1214
+ }
1215
+ /** GitHub is polled far less often than local files: a queue changes on the order of minutes. */
1216
+ const TUI_GITHUB_QUEUE_POLL_MS = 30_000;
1217
+ /** The Queue tab's backlog source for `factory tui` (#1362). Mirrors `factory run`: GitHub Issues
1218
+ * by default, the local queue file only under `--local-queue`. Never throws at build time — a
1219
+ * missing repo or token becomes a snapshot error the tab renders, so the TUI still starts.
1220
+ *
1221
+ * The token is resolved here, before Ink takes the terminal, so the `gh auth token` subprocess
1222
+ * never runs inside the first poll. Octokit is built at most once and only on the GitHub path;
1223
+ * when no token is present at startup, each poll re-resolves it so a `gh auth login` in another
1224
+ * terminal is picked up without restarting the TUI. */
1225
+ export function tuiQueueReader(input) {
1226
+ // Capture the invocation's shell and octokit seams: read() polls after main() has restored the module default.
1227
+ const shell = shellOps;
1228
+ const injectedOctokit = octokitFactory;
1229
+ const defaultOctokit = injectedOctokit ? () => injectedOctokit() : createFactoryOctokit;
1230
+ const { token: resolveToken = () => resolveGitHubToken(shell), octokit = defaultOctokit } = input;
1231
+ if (input.localQueue) {
1232
+ return { source: 'local file', read: () => readQueue(input.queueFile, input.queueProposedFile) };
1233
+ }
1234
+ if (input.repo === undefined) {
1235
+ return {
1236
+ source: 'GitHub',
1237
+ pollMs: TUI_GITHUB_QUEUE_POLL_MS,
1238
+ read: async () => ({ entries: [], error: 'repo detection failed — run inside a GitHub-backed checkout' }),
1239
+ };
1240
+ }
1241
+ const [owner, repoName] = input.repo.split('/');
1242
+ const clientFor = (token) => token === undefined ? undefined : createOctokitQueueClient(octokit(token));
1243
+ let client = clientFor(resolveToken());
1244
+ return {
1245
+ source: 'GitHub',
1246
+ pollMs: TUI_GITHUB_QUEUE_POLL_MS,
1247
+ read: async () => {
1248
+ client ??= clientFor(resolveToken());
1249
+ if (client === undefined)
1250
+ return { entries: [], error: 'no GitHub token — run `gh auth login`' };
1251
+ return readGithubQueueSnapshot({ client, owner, repo: repoName });
1252
+ },
1253
+ };
1254
+ }
1255
+ async function cmdTui(opts = {}, runTuiImpl = runTui) {
1256
+ const repoRoot = await getRepoRoot();
1257
+ const paths = getFactoryPaths(repoRoot);
1258
+ let repo;
1259
+ try {
1260
+ repo = await getGitHubRepo();
1261
+ }
1262
+ catch {
1263
+ // header just omits the repo
1264
+ }
1265
+ const repoConfig = configLoaders.loadRepoConfig(repoRoot);
1266
+ const modelsConfig = applyRepoConfig(configLoaders.loadModelsConfig(), repoConfig);
1267
+ const routesConfig = configLoaders.loadRoutesConfig();
1268
+ const effective = resolveEffectiveConfig(repoConfig);
1269
+ const router = new coreOps.ModelRouter(modelsConfig, routesConfig, false, undefined, effective.allowExperimental, effective.localOnly);
1270
+ const effectiveConfigLines = describeEffectiveConfig({
1271
+ router,
1272
+ repo: repoConfig,
1273
+ repoConfigPath: '.factory/config.json',
1274
+ mergePolicy: resolveMergePolicy(configLoaders.loadFactoryConfigForRepo(paths.config), process.env, {
1275
+ auto: readRunFlagOverrides(paths.runFlags).autoMerge,
1276
+ }),
1277
+ prClassifier: resolvePrClassifierPolicy(configLoaders.loadFactoryConfigForRepo(paths.config), process.env, {
1278
+ classifier: readRunFlagOverrides(paths.runFlags).prClassifier,
1279
+ }),
1280
+ });
1281
+ await runTuiImpl({
1282
+ eventsFile: paths.events,
1283
+ repo,
1284
+ stopFile: paths.stop,
1285
+ queueReader: tuiQueueReader({
1286
+ localQueue: opts.localQueue === true,
1287
+ queueFile: paths.queue,
1288
+ queueProposedFile: paths.queueProposed,
1289
+ repo,
1290
+ }),
1291
+ costsFile: paths.costs,
1292
+ approvalsDir: paths.approvals,
1293
+ steeringDir: paths.steering,
1294
+ runsDir: paths.runs,
1295
+ breakerFile: paths.breaker,
1296
+ effectiveConfigLines,
1297
+ });
1298
+ }
1299
+ export { parkEvents, parkReasonFor } from '@on-par/factory-core';
1300
+ export class LaneParkError extends Error {
1301
+ outcome;
1302
+ constructor(message, reason, failure) {
1303
+ super(message);
1304
+ this.outcome = {
1305
+ state: 'parked',
1306
+ reason,
1307
+ ...(failure?.failureSignature !== undefined ? { failureSignature: failure.failureSignature } : {}),
1308
+ ...(failure?.failingChecks !== undefined ? { failingChecks: failure.failingChecks } : {}),
1309
+ };
1310
+ }
1311
+ get reason() {
1312
+ return this.outcome.reason;
1313
+ }
1314
+ get failureSignature() {
1315
+ return this.outcome.failureSignature;
1316
+ }
1317
+ get failingChecks() {
1318
+ return this.outcome.failingChecks;
1319
+ }
1320
+ }
1321
+ /** CHECK found every failing checker also failing on the base SHA (#1928): the issue is
1322
+ * released to queued (not parked) and the lane pauses. */
1323
+ export class LaneEnvironmentPauseError extends Error {
1324
+ baseSha;
1325
+ failingChecks;
1326
+ failureSignature;
1327
+ constructor(message, failure) {
1328
+ super(message);
1329
+ this.baseSha = failure.baseSha;
1330
+ this.failingChecks = failure.failingChecks;
1331
+ if (failure.failureSignature !== undefined)
1332
+ this.failureSignature = failure.failureSignature;
1333
+ }
1334
+ }
1335
+ /** Resolves the stable lane URL a build/check agent should use, by probing whether
1336
+ * a factory proxy (started by `factory run`/`supervise` or `factory proxy`) is
1337
+ * currently alive for the configured domain. Never throws — an absent/dead proxy
1338
+ * just degrades to the existing port-based URL, single informational note either way. */
1339
+ export function resolveLaneBaseUrl(paths, settings, worktree, appPort, deps = {}) {
1340
+ const { isRunning = isProxyRunning } = deps;
1341
+ if (!settings.enabled || appPort === undefined)
1342
+ return { note: '' };
1343
+ const state = isRunning(paths.proxyState);
1344
+ if (state && state.domain === settings.domain) {
1345
+ const baseUrl = laneBaseUrl(worktree, { domain: state.domain, port: state.port });
1346
+ return { baseUrl, note: `stable lane URL ${baseUrl} -> 127.0.0.1:${appPort}` };
1347
+ }
1348
+ return { note: `proxy enabled but not running — using http://127.0.0.1:${appPort}` };
1349
+ }
1350
+ /** Builds the port-lease acquirer passed to runIssue, or undefined to skip it. A
1351
+ * docker-sandbox lane runs inside its own microVM network namespace and binds its
1352
+ * normal port there, so it needs no host port-lease and (since the proxy resolves
1353
+ * routes solely from the lease registry) no proxy route either — every other
1354
+ * runtime keeps acquiring a lease exactly as before. */
1355
+ export function resolveEnvironmentAcquirer(opts) {
1356
+ const findPortListeners = opts.findPortListeners ?? coreOps.defaultFindPortListeners;
1357
+ const reapProcesses = opts.reapOrphanProcesses ?? coreOps.reapOrphanProcesses;
1358
+ if (opts.laneSandboxRuntime === 'docker-sandbox') {
1359
+ opts.log('environment_lease', `docker-sandbox lane ${opts.worktree} binds its port inside its own microVM — skipping host port-lease`);
1360
+ return undefined;
1361
+ }
1362
+ return async () => {
1363
+ const reaped = [];
1364
+ const lease = await acquirePortLease({
1365
+ registryFile: opts.paths.ports,
1366
+ lockDir: opts.paths.portsLock,
1367
+ worktreeId: opts.worktree,
1368
+ branch: opts.branch,
1369
+ range: opts.range,
1370
+ onReap: (r) => {
1371
+ reaped.push(r);
1372
+ opts.log('environment_lease_reaped', `reaped stale lease: port ${r.lease.port}, pid ${r.lease.pid}, worktree ${r.lease.worktreeId} — reason: ${r.reason}`);
1373
+ },
1374
+ onPortConflict: (port) => {
1375
+ void findPortListeners(port)
1376
+ .then((listeners) => {
1377
+ const detail = listeners.length > 0
1378
+ ? listeners.map((l) => `pid ${l.pid} (${l.command})`).join(', ')
1379
+ : 'no listener details available';
1380
+ opts.log('environment_conflict', `port ${port} busy but unleased — reported as conflict, not terminated: ${detail}`);
1381
+ })
1382
+ .catch(() => {
1383
+ opts.log('environment_conflict', `port ${port} busy but unleased — reported as conflict, not terminated`);
1384
+ });
1385
+ },
1386
+ });
1387
+ opts.log('environment_lease', `leased port ${lease.port} for worktree ${opts.worktree}`);
1388
+ if (reaped.length > 0) {
1389
+ await reapProcesses({
1390
+ reaped,
1391
+ graceMs: opts.processGroupGraceMs,
1392
+ onEvent: (e) => opts.log('environment_orphan', `${e.action === 'killed' ? 'killed' : 'found'} pid ${e.pid} (pgid ${e.pgid}, ${e.command}) squatting port ${e.port} of dead lane ${e.worktreeId}${e.action === 'reported' ? ' — not factory-started, left running' : ''}`),
1393
+ });
1394
+ }
1395
+ return {
1396
+ port: lease.port,
1397
+ env: () => laneEnv(lease.port, process.env),
1398
+ recordPgid(pgid) {
1399
+ void recordLeasePgid({
1400
+ registryFile: opts.paths.ports,
1401
+ lockDir: opts.paths.portsLock,
1402
+ worktreeId: opts.worktree,
1403
+ pgid,
1404
+ }).catch(() => { });
1405
+ },
1406
+ async release() {
1407
+ await releasePortLease({
1408
+ registryFile: opts.paths.ports,
1409
+ lockDir: opts.paths.portsLock,
1410
+ worktreeId: opts.worktree,
1411
+ });
1412
+ },
1413
+ };
1414
+ };
1415
+ }
1416
+ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1417
+ const loaders = resolveConfigLoaders(deps);
1418
+ const git = resolveGitOps(deps);
1419
+ const shell = resolveShellOps(deps);
1420
+ const core = resolveCoreOps(deps);
1421
+ const phases = resolvePhaseOps(deps);
1422
+ const repoRoot = ctx?.repoRoot ?? (await getRepoRoot(shell));
1423
+ const ghRepo = ctx?.ghRepo ?? (await getGitHubRepo(shell));
1424
+ const paths = ctx?.paths ?? getFactoryPaths(repoRoot);
1425
+ // GitHub REST deprecations seen on this run's client become one warn event per route (#2218).
1426
+ const octokit = (deps.octokit ??
1427
+ (() => getOctokit(shell, {
1428
+ log: (d) => logEvent(paths.events, 'github_api_deprecated', issueNum, formatDeprecation(d), { lane: ctx?.lane }),
1429
+ })))();
1430
+ const [ghOwner, ghName] = ghRepo.split('/');
1431
+ const repoConfig = loaders.loadRepoConfig(repoRoot, paths.root);
1432
+ const factoryConfig = loaders.loadFactoryConfigForRepo(paths.config);
1433
+ const classifierPolicy = resolvePrClassifierPolicy(factoryConfig, process.env, { classifier: opts.prClassifier });
1434
+ const timeouts = loaders.resolveTimeouts(factoryConfig);
1435
+ const failoverSettings = resolveAutoFailover(factoryConfig);
1436
+ const breaker = new ProviderBreaker(paths.breaker);
1437
+ const reworkHistory = new ReworkHistory(paths.reworkHistory);
1438
+ const laneFileGuard = new LaneFileGuard(paths.laneFiles);
1439
+ const efficiency = resolveEfficiencyPolicy(repoConfig);
1440
+ const policy = {
1441
+ models: applyRepoConfig(loaders.loadModelsConfig(), repoConfig),
1442
+ routes: loaders.loadRoutesConfig(),
1443
+ sandbox: factoryConfig.sandbox,
1444
+ budget: { perIssueCapUsd: efficiency.perIssueCapUsd },
1445
+ effective: opts.branchPrefix === undefined
1446
+ ? resolveEffectiveConfig(repoConfig)
1447
+ : { ...resolveEffectiveConfig(repoConfig), branchPrefix: opts.branchPrefix },
1448
+ };
1449
+ const router = new core.ModelRouter(policy.models, policy.routes, false, undefined, policy.effective.allowExperimental, policy.effective.localOnly);
1450
+ let issueSpend = 0;
1451
+ let laneSandboxRuntime = 'none';
1452
+ const laneWorkspaceBackend = factoryConfig.workspace?.backend === 'disposable-docker' ? 'disposable-docker' : 'worktree';
1453
+ router.setCostSink((entry) => {
1454
+ issueSpend += entry.cost ?? 0;
1455
+ logCost(paths.costs, {
1456
+ ...entry,
1457
+ issue: String(issueNum),
1458
+ sandboxRuntime: laneSandboxRuntime,
1459
+ reworkRoundCount: laneReworkRounds,
1460
+ workspaceBackend: laneWorkspaceBackend,
1461
+ });
1462
+ });
1463
+ const modelPins = loaders.resolveEffectiveModelPins(router.registryRef, repoConfig);
1464
+ const codexOff = resolveCodexDisabled(repoConfig);
1465
+ const constitutionLoader = new core.ConstitutionLoader();
1466
+ const product = opts.product ?? readActiveProduct(paths.product);
1467
+ const autoRework = opts.autoRework ?? true;
1468
+ const work = ctx?.workRequest ??
1469
+ (await createDefaultWorkSourceRegistry({ octokit }).resolve(GITHUB_ISSUE_SOURCE, {
1470
+ repo: ghRepo,
1471
+ issue: issueNum,
1472
+ }));
1473
+ const issueTitle = work.title;
1474
+ const sizeGateMode = opts.sizeGate ?? resolveSizeGateMode(factoryConfig);
1475
+ const maxSlices = opts.maxSlices ?? resolveSizeGateMaxSlices(factoryConfig);
1476
+ // ADR-0147: slice k >= 2 builds on its own branch, so read the slice plan before the worktree exists.
1477
+ let sliceIndex;
1478
+ if (sizeGateMode === 'slice' && !ctx?.localOnly && work.kind === GITHUB_ISSUE_SOURCE) {
1479
+ try {
1480
+ const lookup = await findSlicePlanComment({ octokit, repo: ghRepo, issue: issueNum });
1481
+ if (lookup.status === 'found')
1482
+ sliceIndex = currentSlice(lookup.plan)?.index;
1483
+ }
1484
+ catch (err) {
1485
+ throw new LaneParkError(`slice plan lookup failed for #${issueNum}: ${errorDetail(err)} — parked before creating a worktree`, 'escalate');
1486
+ }
1487
+ }
1488
+ const branch = branchFor(issueNum, issueTitle, policy.effective.branchPrefix, sliceIndex);
1489
+ const worktree = ctx?.localOnly
1490
+ ? ctx.localOnly.workspace
1491
+ : worktreePathFor(repoRoot, ghRepo, issueNum, policy.effective.branchPrefix, loaders);
1492
+ const specPath = resolve(paths.plans, `issue-${issueNum}.md`);
1493
+ const runStartedAt = new Date().toISOString();
1494
+ const lane = ctx?.lane;
1495
+ // Captures the message of the last terminal event runIssue emits (ready/fail/
1496
+ // escalate/held/conflict/ci-failed/timeout) so the LaneParkError this adapter
1497
+ // raises below carries the same diagnostic text shipIssue used to throw with —
1498
+ // RunOutcome's parked variant carries only the ParkReason enum, not free text.
1499
+ const TERMINAL_EVENT_KINDS = new Set([
1500
+ 'ready',
1501
+ 'environment-released',
1502
+ 'fail',
1503
+ 'escalate',
1504
+ 'held',
1505
+ 'conflict',
1506
+ 'ci-failed',
1507
+ 'timeout',
1508
+ ]);
1509
+ let terminalMessage;
1510
+ let laneReworkRounds = 0;
1511
+ const mkLog = (phase) => (type, msg, extra) => {
1512
+ if (TERMINAL_EVENT_KINDS.has(type))
1513
+ terminalMessage = msg;
1514
+ if (type === 'rework')
1515
+ laneReworkRounds++;
1516
+ // checkPhase logs each skipped checker as a 'check' event prefixed "SKIPPED: " —
1517
+ // echo it to the terminal the way shipIssue used to print it directly.
1518
+ if (type === 'check' && msg.startsWith('SKIPPED: ')) {
1519
+ console.error(styleText('yellow', ` SKIP: ${msg.slice('SKIPPED: '.length)}`));
1520
+ }
1521
+ // Likewise for each failing checker, so a parked run still names which checker
1522
+ // failed and why — the parked RunOutcome carries only an aggregate count (#675).
1523
+ if (type === 'check' && msg.startsWith('FAILED: ')) {
1524
+ console.error(styleText('red', ` FAIL: ${msg.slice('FAILED: '.length)}`));
1525
+ }
1526
+ logEvent(paths.events, type, issueNum, msg, { ...extra, lane, phase });
1527
+ // Best-effort lastEvent summary (#1327) for factory.run.snapshot — an
1528
+ // observability side channel, so a write failure is swallowed, not logged (that
1529
+ // would re-enter this same function for every event this chokepoint fires on).
1530
+ touchLastEvent(phaseSnapshotFile(paths.runs, issueNum), summarizeEvent(type, msg)).catch(() => { });
1531
+ };
1532
+ const log = mkLog();
1533
+ log('issue-title', issueTitle);
1534
+ if (modelPins.plan) {
1535
+ const source = modelPins.sources.plan === 'repo' ? '.factory/config.json' : 'FACTORY_PLAN_MODEL';
1536
+ log('model-override', `plan model pinned to ${modelPins.plan} (${source})`);
1537
+ }
1538
+ if (modelPins.build) {
1539
+ const source = modelPins.sources.build === 'repo' ? '.factory/config.json' : 'FACTORY_BUILD_MODEL';
1540
+ log('model-override', `build model pinned to ${modelPins.build} (${source})`);
1541
+ }
1542
+ const skipReason = closedWorkSkipReason(work);
1543
+ if (skipReason) {
1544
+ log('skipped-already-closed', skipReason);
1545
+ console.log(styleText('yellow', `skipped: ${skipReason}`));
1546
+ throw new IssueSkippedError(skipReason, 'already-closed');
1547
+ }
1548
+ const sandboxPolicy = resolveSandboxPolicy(policy.sandbox, {
1549
+ worktree,
1550
+ repoRoot,
1551
+ cliDisabled: opts.sandbox === false,
1552
+ laneId: lane,
1553
+ isAvailable: (cmd) => isCommandAvailableViaShell(shell, cmd),
1554
+ });
1555
+ laneSandboxRuntime = sandboxPolicy?.runtime ?? 'none';
1556
+ const worktreeSandbox = worktreeSandboxFor(sandboxPolicy?.runtime, {
1557
+ allowHosts: sandboxPolicy?.allowHosts ?? [],
1558
+ });
1559
+ let activeSandboxPolicy;
1560
+ if (opts.sandbox === false) {
1561
+ console.error(styleText('yellow', 'factory: sandbox disabled by --no-sandbox — agent runs are UNCONTAINED'));
1562
+ log('sandbox-disabled', 'sandbox disabled by --no-sandbox');
1563
+ }
1564
+ else if (!sandboxPolicy) {
1565
+ log('sandbox-disabled', 'sandbox disabled by config/FACTORY_SANDBOX');
1566
+ }
1567
+ else if (sandboxPolicy.runtime === 'none') {
1568
+ log('sandbox-unavailable', 'no sandbox runtime found (sandbox-exec/firejail) — running uncontained');
1569
+ }
1570
+ else if (sandboxPolicy.runtime === 'docker-sandbox') {
1571
+ // docker-sandbox's containment is the microVM created by worktreeWorkspace below
1572
+ // (#653), not a command-prefix wrap — activeSandboxPolicy stays undefined because
1573
+ // wrapCommandInSandbox remains a no-op for this runtime. createMicroVm logs the
1574
+ // actual outcome ('sandbox' on success, 'sandbox-unavailable' on fallback), so no
1575
+ // speculative log is emitted here.
1576
+ }
1577
+ else {
1578
+ activeSandboxPolicy = sandboxPolicy;
1579
+ if (sandboxPolicy.allowHosts.length > 0) {
1580
+ log('sandbox-degraded', `host-level egress filtering unavailable in v1; intended allowlist: ${sandboxPolicy.allowHosts.join(', ')}`);
1581
+ }
1582
+ }
1583
+ // Setup worktree FIRST — PLAN needs cwd=worktree to run claude. Worktree cleanup
1584
+ // stays a CLI concern (not called here, matching today — a worktree persists past
1585
+ // a successful run for GC/sweep commands to reclaim later).
1586
+ let workspace;
1587
+ if (!ctx?.localOnly) {
1588
+ workspace = await worktreeWorkspace({
1589
+ repoRoot,
1590
+ branch,
1591
+ worktreePath: worktree,
1592
+ log,
1593
+ sandbox: worktreeSandbox,
1594
+ findOpenPr: (br) => findOpenPR(octokit, ghOwner, ghName, br),
1595
+ setup: (root, br, wt, sp, sandbox, setupLog, setupOpts) => withGitLock(root, () => withFileLock(paths.gitLock,
1596
+ // setupWorktree fetches origin itself before creating the worktree (#1167),
1597
+ // so no explicit gitFetch here — still under the git + file locks.
1598
+ () => git.setupWorktree(root, br, wt, sp, sandbox, setupLog, setupOpts), { onSteal: (pid) => log('lock-stolen', `stole ${paths.gitLock} from dead holder pid ${pid ?? 'unknown'}`) })),
1599
+ });
1600
+ log('worktree', `Worktree ready at ${worktree}`);
1601
+ }
1602
+ else {
1603
+ workspace = localOnlyWorkspace(ctx.localOnly);
1604
+ log('workspace', `local-only: using caller-provided workspace ${worktree} (no factory worktree created)`);
1605
+ }
1606
+ const planApprovalEnabled = opts.approvePlan ?? resolvePlanApproval(factoryConfig);
1607
+ const processGroupGraceMs = resolveProcessGroupGraceMs(factoryConfig);
1608
+ const portsSettings = resolveEnvironmentPorts(factoryConfig);
1609
+ if (!portsSettings.enabled) {
1610
+ log('environment_lease', 'port leasing disabled (environment.ports.enabled=false or FACTORY_ENV_PORTS=0)');
1611
+ }
1612
+ // acquireEnvironment.recordPgid/release only persist/deallocate the lease — process-
1613
+ // group tracking and killing is runIssue's own unconditional tracker (Invariant 3),
1614
+ // so a lease-disabled/failed run still tears down every spawned agent process.
1615
+ const acquireEnvironment = portsSettings.enabled
1616
+ ? resolveEnvironmentAcquirer({
1617
+ laneSandboxRuntime: sandboxPolicy?.runtime ?? 'none',
1618
+ paths,
1619
+ worktree,
1620
+ branch,
1621
+ range: portsSettings.range,
1622
+ processGroupGraceMs,
1623
+ log,
1624
+ findPortListeners: core.defaultFindPortListeners,
1625
+ reapOrphanProcesses: core.reapOrphanProcesses,
1626
+ })
1627
+ : undefined;
1628
+ const proxySettings = resolveEnvironmentProxy(factoryConfig);
1629
+ const resolveBaseUrl = (appPort) => resolveLaneBaseUrl(paths, proxySettings, worktree, appPort);
1630
+ const request = {
1631
+ issue: issueNum,
1632
+ repo: ghRepo,
1633
+ branch,
1634
+ specPath,
1635
+ work,
1636
+ workSource: ctx?.workSource,
1637
+ product,
1638
+ startedAt: runStartedAt,
1639
+ lane,
1640
+ options: {
1641
+ interactive: Boolean(opts.interactive),
1642
+ autoRework,
1643
+ approvePlan: planApprovalEnabled,
1644
+ sandboxDisabled: opts.sandbox === false,
1645
+ },
1646
+ localOnly: Boolean(ctx?.localOnly),
1647
+ blockUnresolvedRegressions: resolveDesignRegressionBlock(factoryConfig),
1648
+ publishFromBuild: resolveBuildPublish(factoryConfig),
1649
+ sizeGateMode,
1650
+ maxSlices,
1651
+ prClassifier: classifierPolicy.enabled
1652
+ ? {
1653
+ rules: resolveReviewFloorRules(repoConfig),
1654
+ gateLabel: resolveFilingPolicy(factoryConfig).selfFixLabel,
1655
+ modelPin: repoConfig?.models?.pins?.classifier,
1656
+ }
1657
+ : undefined,
1658
+ timeouts,
1659
+ modelPins,
1660
+ codexDisabled: codexOff,
1661
+ skipCI: loaders.resolveSkipCI(factoryConfig),
1662
+ failover: failoverSettings,
1663
+ efficiency: { maxReworkRounds: efficiency.maxReworkRounds, fastPath: efficiency.fastPath },
1664
+ sandboxPolicy: activeSandboxPolicy,
1665
+ processGroupGraceMs,
1666
+ preferredRoute: repoConfig?.route,
1667
+ eventsFile: paths.events,
1668
+ logsDir: paths.logs,
1669
+ baselineCachePath: paths.baselineCache,
1670
+ flakyLedgerPath: paths.flakyLedger,
1671
+ };
1672
+ const ports = {
1673
+ router,
1674
+ octokit,
1675
+ workspace,
1676
+ events: mkLog,
1677
+ acquireEnvironment,
1678
+ resolveBaseUrl,
1679
+ getIssueSpend: () => issueSpend,
1680
+ breaker,
1681
+ planPhase: phases.planPhase,
1682
+ buildPhase: phases.buildPhase,
1683
+ checkPhase: phases.checkPhase,
1684
+ shipPhase: phases.shipPhase,
1685
+ resolveConstitution: () =>
1686
+ // Resolved once here — runIssue calls this exactly once and reuses the value for
1687
+ // every phase, so the build worker can never author the standards it is graded by.
1688
+ constitutionLoader.resolve(worktree, product, factoryConfig.paths?.constitution),
1689
+ createApprovalGate: () => createFileApprovalGate({ dir: paths.approvals, timeoutMs: timeouts.approval * 1000 }),
1690
+ drainSteering: () => drainSteering(paths.steering, issueNum, worktree),
1691
+ reworkHistory,
1692
+ laneFileGuard,
1693
+ recordPhase: (phase) => {
1694
+ const now = new Date().toISOString();
1695
+ return writePhaseSnapshot(phaseSnapshotFile(paths.runs, issueNum), {
1696
+ issue: issueNum,
1697
+ phase,
1698
+ updatedAt: now,
1699
+ lastActivityAt: now,
1700
+ });
1701
+ },
1702
+ onActivity: () => touchRunActivity(phaseSnapshotFile(paths.runs, issueNum), new Date().toISOString()),
1703
+ onDecomposed: (childIssues) => {
1704
+ const childList = childIssues.map((n) => `#${n}`).join(', ');
1705
+ const planLog = mkLog('plan');
1706
+ try {
1707
+ const before = readTextFileOrEmpty(paths.queue);
1708
+ const rewrite = rewriteQueueForDecomposition(before, { issue: issueNum, childIssues });
1709
+ if (rewrite.changed) {
1710
+ writeFileSync(paths.queue, rewrite.content);
1711
+ planLog('decompose_filed', `queue entry for #${issueNum} replaced with ${childList}`);
1712
+ }
1713
+ else {
1714
+ planLog('decompose_filed', `#${issueNum} had no queue entry to replace — continuing the lane with ${childList}`);
1715
+ }
1716
+ }
1717
+ catch (err) {
1718
+ planLog('decompose_file_failed', `queue rewrite for #${issueNum} failed: ${errorDetail(err)}`);
1719
+ }
1720
+ throw new IssueDecomposedError(`issue #${issueNum} decomposed into ${childList}`, childIssues);
1721
+ },
1722
+ writeLocalRunReport: (info) => maybeWriteLocalRunReport({
1723
+ issueNum,
1724
+ paths,
1725
+ startedAt: runStartedAt,
1726
+ outcome: info.outcome,
1727
+ branch: info.branch,
1728
+ worktree,
1729
+ specPath,
1730
+ route: info.route,
1731
+ reason: info.reason,
1732
+ writeLocalRunReport: core.writeLocalRunReport,
1733
+ }),
1734
+ writeBenchmarkArtifacts: (info) => maybeWriteBenchmarkArtifacts({
1735
+ issueNum,
1736
+ paths,
1737
+ ctx,
1738
+ startedAt: runStartedAt,
1739
+ outcome: info.outcome === 'parked' ? 'failed' : info.outcome,
1740
+ branch: info.branch,
1741
+ specPath,
1742
+ route: info.route,
1743
+ checkSummary: info.checkSummary,
1744
+ reworkRounds: info.reworkRounds,
1745
+ failure: info.failure,
1746
+ reportPath: info.reportPath,
1747
+ diffBase: info.diffBase,
1748
+ log,
1749
+ }),
1750
+ };
1751
+ // #2214: report-only per-lane egress proxy for sandbox-exec. Started here, after worktree setup,
1752
+ // so the finally below is the single place that stops it on every exit path.
1753
+ const proxied = shouldProxyEgress(request.sandboxPolicy) ? request.sandboxPolicy : undefined;
1754
+ const egressProxy = proxied
1755
+ ? await startEgressProxy({
1756
+ allowHosts: proxied.allowHosts,
1757
+ onHost: (host, allowed) => log('egress_host', `${host} (${allowed ? 'on allowlist' : 'not on allowlist'})`),
1758
+ })
1759
+ : undefined;
1760
+ let outcome;
1761
+ try {
1762
+ outcome = await runIssue(proxied && egressProxy ? { ...request, sandboxPolicy: { ...proxied, proxyUrl: egressProxy.url } } : request, policy, ports);
1763
+ }
1764
+ finally {
1765
+ if (proxied && egressProxy) {
1766
+ log('egress_summary', egressSummaryMessage(egressProxy.hosts(), proxied.allowHosts));
1767
+ await egressProxy.close();
1768
+ }
1769
+ }
1770
+ ctx?.onOutcome?.(outcome);
1771
+ if (opts.interactive && !ctx?.localOnly && (outcome.state === 'ready' || outcome.state === 'shipped')) {
1772
+ const leftover = listQueuedSteering(paths.steering, issueNum);
1773
+ if (leftover.length > 0) {
1774
+ log('steering_unconsumed', `${leftover.length} steering message(s) not consumed (no worker phase remained)`);
1775
+ }
1776
+ }
1777
+ if (outcome.state === 'ready' || outcome.state === 'shipped') {
1778
+ if (ctx?.localOnly) {
1779
+ console.log(styleText('green', `✅ Local-only run complete in ${worktree} (no PR — publishing disabled)`));
1780
+ }
1781
+ else {
1782
+ console.log(styleText('green', `✅ Issue #${issueNum} → ${terminalMessage ?? 'ready'}`));
1783
+ }
1784
+ return outcome.branch;
1785
+ }
1786
+ if (outcome.state === 'released') {
1787
+ throw new LaneEnvironmentPauseError(terminalMessage ?? 'environment failure on base', {
1788
+ baseSha: outcome.baseSha,
1789
+ failingChecks: outcome.failingChecks,
1790
+ failureSignature: outcome.failureSignature,
1791
+ });
1792
+ }
1793
+ const reason = outcome.state === 'escalated' ? 'escalate' : outcome.reason;
1794
+ const message = outcome.state === 'escalated' ? outcome.reason : (terminalMessage ?? `run parked: ${outcome.reason}`);
1795
+ throw new LaneParkError(message, reason, outcome.state === 'parked'
1796
+ ? { failureSignature: outcome.failureSignature, failingChecks: outcome.failingChecks }
1797
+ : undefined);
1798
+ }
1799
+ async function maybeWriteLocalRunReport(opts) {
1800
+ if (process.env.FACTORY_LOCAL_ONLY !== '1')
1801
+ return undefined;
1802
+ const report = await (opts.writeLocalRunReport ?? coreOps.writeLocalRunReport)({
1803
+ issue: opts.issueNum,
1804
+ eventsFile: opts.paths.events,
1805
+ reportsDir: opts.paths.reports,
1806
+ startedAt: opts.startedAt,
1807
+ outcome: opts.outcome,
1808
+ profile: 'local-only',
1809
+ branch: opts.branch,
1810
+ worktree: opts.worktree,
1811
+ specPath: opts.specPath,
1812
+ route: opts.route,
1813
+ reason: opts.reason,
1814
+ });
1815
+ console.log(styleText('cyan', `local-only report: ${report.path}`));
1816
+ return report.path;
1817
+ }
1818
+ async function maybeWriteBenchmarkArtifacts(opts) {
1819
+ if (!opts.ctx?.localOnly || !opts.ctx.artifactsDir)
1820
+ return;
1821
+ try {
1822
+ const { manifestPath } = await writeBenchmarkArtifacts({
1823
+ issue: opts.issueNum,
1824
+ artifactsDir: opts.ctx.artifactsDir,
1825
+ eventsFile: opts.paths.events,
1826
+ costsFile: opts.paths.costs,
1827
+ startedAt: opts.startedAt,
1828
+ outcome: opts.outcome,
1829
+ workspace: opts.ctx.localOnly.workspace,
1830
+ branch: opts.branch,
1831
+ specPath: opts.specPath,
1832
+ route: opts.route,
1833
+ request: opts.ctx.workRequest,
1834
+ checkSummary: opts.checkSummary,
1835
+ reworkRounds: opts.reworkRounds,
1836
+ failure: opts.failure,
1837
+ reportPath: opts.reportPath,
1838
+ diffBase: opts.diffBase,
1839
+ });
1840
+ opts.log('benchmark-artifacts', `manifest written to ${manifestPath}`);
1841
+ console.log(styleText('cyan', `benchmark artifacts: ${manifestPath}`));
1842
+ }
1843
+ catch (err) {
1844
+ // Never let artifact emission mask the run outcome.
1845
+ opts.log('benchmark-artifacts-failed', `could not write benchmark artifacts: ${err.message}`);
1846
+ }
1847
+ }
1848
+ /** The autoGcOnRun-gated worktree sweep every pipeline entry point runs before shipping
1849
+ * (`factory run`, and since #1756 the one-shot `factory ship` / `factory run-issue`). Never
1850
+ * throws — a GC failure is a warn, never a reason to abort the command. */
1851
+ async function runAutoWorktreeGc(repoRoot, paths, ghRepo, eventScope) {
1852
+ try {
1853
+ const factoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
1854
+ if (!factoryConfig.worktree.autoGcOnRun)
1855
+ return;
1856
+ const gcLog = (type, msg) => logEvent(paths.events, type, '-', msg);
1857
+ const gcSandbox = gcWorktreeSandbox(factoryConfig.sandbox, repoRoot);
1858
+ const report = await withGitLock(repoRoot, () => withFileLock(paths.gitLock, () => internalOps.sweepWorktrees({
1859
+ repoRoot,
1860
+ ttlDays: factoryConfig.worktree.gcTtlDays,
1861
+ repo: ghRepo,
1862
+ branchPrefix: resolveBranchPrefix(),
1863
+ }, { log: gcLog, octokit: getOctokit(), sandbox: gcSandbox })));
1864
+ logEvent(paths.events, 'worktree-gc', eventScope, `removed ${report.removed.length} stale worktree(s), kept ${report.kept}`);
1865
+ console.log(internalOps.formatGcReport(report));
1866
+ }
1867
+ catch (err) {
1868
+ logEvent(paths.events, 'warn', eventScope, `worktree gc failed: ${err.message}`);
1869
+ }
1870
+ }
1871
+ /** Mid-run worktree GC between issues in a lane (#1757). Honors worktree.autoGcOnRun, holds
1872
+ * the git + file locks every worktree mutation holds, and skips (never waits) when another
1873
+ * lane holds the land lock so a sweep can never race a merge. Logs worktree-gc only when it
1874
+ * removed something. Never throws. */
1875
+ export async function sweepBetweenLaneIssues(issue, lane, repoRoot, ghRepo, paths, deps = {}) {
1876
+ const { loadConfig = configLoaders.loadFactoryConfigForRepo, sweep = internalOps.sweepWorktrees, emitEvent = logEvent, octokit = getOctokit, } = deps;
1877
+ try {
1878
+ const cfg = loadConfig(paths.config);
1879
+ if (!cfg.worktree.autoGcOnRun)
1880
+ return 'disabled';
1881
+ const gcLog = (type, msg) => emitEvent(paths.events, type, issue, msg, { lane });
1882
+ const sandbox = gcWorktreeSandbox(cfg.sandbox, repoRoot);
1883
+ const report = await withGitLock(repoRoot, () => withFileLock(paths.mergeLock, () => withFileLock(paths.gitLock, () => sweep({ repoRoot, ttlDays: cfg.worktree.gcTtlDays, repo: ghRepo, branchPrefix: resolveBranchPrefix() }, { log: gcLog, octokit: octokit(), sandbox })), { timeoutMs: 0 }));
1884
+ if (report.removed.length > 0) {
1885
+ emitEvent(paths.events, 'worktree-gc', issue, `removed ${report.removed.length} stale worktree(s), kept ${report.kept}`, { lane });
1886
+ console.log(internalOps.formatGcReport(report));
1887
+ }
1888
+ return 'swept';
1889
+ }
1890
+ catch (err) {
1891
+ // A zero-timeout merge-lock acquire failing means another lane is mid-land: skip quietly.
1892
+ if (err?.reason === 'timeout')
1893
+ return 'land-in-progress';
1894
+ emitEvent(paths.events, 'warn', issue, `mid-run worktree gc failed: ${err?.message ?? String(err)}`, { lane });
1895
+ return 'failed';
1896
+ }
1897
+ }
1898
+ async function cmdShip(issueNum, opts) {
1899
+ if (!coreOps.isCommandAvailable('claude')) {
1900
+ throw new CliExitError(`factory: ${missingClaudeCliMessage()}`, 2);
1901
+ }
1902
+ const repoRoot = await getRepoRoot();
1903
+ const paths = getFactoryPaths(repoRoot);
1904
+ if (!existsSync(paths.root)) {
1905
+ throw new CliExitError(`factory: ${notInitializedMessage()}`, 2);
1906
+ }
1907
+ const mergeNotice = mergeScopeNotice(process.env, issueNum);
1908
+ if (mergeNotice) {
1909
+ console.error(styleText('yellow', mergeNotice));
1910
+ logEvent(paths.events, 'warn', issueNum, mergeNotice);
1911
+ }
1912
+ const branchPrefix = branchPrefixOrExit(opts.branchPrefix);
1913
+ return withRepoRunLock(paths, 'factory ship', async () => {
1914
+ let ghRepo = null;
1915
+ try {
1916
+ ghRepo = await getGitHubRepo();
1917
+ }
1918
+ catch {
1919
+ /* shipIssue reports repo resolution itself */
1920
+ }
1921
+ if (ghRepo)
1922
+ await runAutoWorktreeGc(repoRoot, paths, ghRepo, issueNum);
1923
+ const priorEvents = existsSync(paths.events) ? readEvents(paths.events) : [];
1924
+ if (hasUnresolvedPark(priorEvents, String(issueNum))) {
1925
+ logEvent(paths.events, 'human-restarted', issueNum, 'manual retry of a previously parked/failed run', {
1926
+ actor: process.env.FACTORY_ACTOR ?? process.env.USER ?? 'unknown',
1927
+ });
1928
+ }
1929
+ try {
1930
+ return await shipIssue(issueNum, { ...opts, branchPrefix });
1931
+ }
1932
+ catch (err) {
1933
+ if (err instanceof IssueSkippedError)
1934
+ return;
1935
+ throw new CliExitError(`Ship failed for issue #${issueNum}: ${err.message}`, 1);
1936
+ }
1937
+ });
1938
+ }
1939
+ async function cmdRunIssue(issueNum, opts) {
1940
+ if (!coreOps.isCommandAvailable('claude')) {
1941
+ throw new CliExitError(`factory: ${missingClaudeCliMessage()}`, 2);
1942
+ }
1943
+ const repoRoot = await getRepoRoot();
1944
+ const paths = getFactoryPaths(repoRoot);
1945
+ if (!existsSync(paths.root)) {
1946
+ throw new CliExitError(`factory: ${notInitializedMessage()}`, 2);
1947
+ }
1948
+ const { runChildren, sizeGate: rawSizeGate, maxSlices: rawMaxSlices, ...shipOpts } = opts;
1949
+ const branchPrefix = branchPrefixOrExit(opts.branchPrefix);
1950
+ const sizeGate = sizeGateOrExit(rawSizeGate);
1951
+ const maxSlices = maxSlicesOrExit(rawMaxSlices);
1952
+ await withRepoRunLock(paths, 'factory run-issue', async () => {
1953
+ const ghRepo = await getGitHubRepo();
1954
+ // Pre-flight: resolve the issue through the canonical work-request seam
1955
+ // BEFORE any worktree or PR exists — a bad issue exits here.
1956
+ const workSources = createDefaultWorkSourceRegistry({ octokit: getOctokit() });
1957
+ let work;
1958
+ try {
1959
+ work = await workSources.resolve(GITHUB_ISSUE_SOURCE, {
1960
+ repo: ghRepo,
1961
+ issue: issueNum,
1962
+ });
1963
+ }
1964
+ catch (err) {
1965
+ if (err instanceof InvalidWorkRequestInputError) {
1966
+ throw new CliExitError(`factory: ${err.message}`, 2);
1967
+ }
1968
+ throw new CliExitError(`factory: could not resolve issue #${issueNum} in ${ghRepo} (${errorDetail(err)}) — no worktree or PR was created`, 2);
1969
+ }
1970
+ console.log(styleText('cyan', `one-shot: resolved ${work.id} — running the pipeline (queue untouched)`));
1971
+ await runAutoWorktreeGc(repoRoot, paths, ghRepo, issueNum);
1972
+ const priorEvents = existsSync(paths.events) ? readEvents(paths.events) : [];
1973
+ if (hasUnresolvedPark(priorEvents, String(issueNum))) {
1974
+ logEvent(paths.events, 'human-restarted', issueNum, 'manual retry of a previously parked/failed run', {
1975
+ actor: process.env.FACTORY_ACTOR ?? process.env.USER ?? 'unknown',
1976
+ });
1977
+ }
1978
+ try {
1979
+ await shipIssue(issueNum, { ...shipOpts, branchPrefix, sizeGate, maxSlices }, { repoRoot, ghRepo, workRequest: work });
1980
+ }
1981
+ catch (err) {
1982
+ if (err instanceof IssueSkippedError)
1983
+ return;
1984
+ if (err instanceof IssueDecomposedError && runChildren) {
1985
+ await runDecomposedChildren(issueNum, err.childIssues, { ...shipOpts, branchPrefix, sizeGate, maxSlices }, { repoRoot, ghRepo, workSources });
1986
+ return;
1987
+ }
1988
+ if (err instanceof IssueDecomposedError && !runChildren) {
1989
+ const children = err.childIssues.map((n) => `#${n}`).join(', ');
1990
+ throw new CliExitError(`Run failed for issue #${issueNum}: the size gate decomposed it into ${children} — no child issue was run. ` +
1991
+ `Re-run with \`factory run-issue ${issueNum} --run-children\` to run them.`, 1);
1992
+ }
1993
+ throw new CliExitError(`Run failed for issue #${issueNum}: ${err.message}`, 1);
1994
+ }
1995
+ });
1996
+ }
1997
+ /** Runs each decomposed child through the one-shot pipeline in build order, inside the caller's
1998
+ * run lock, then prints a per-child summary. Nested decompositions expand in place and closed
1999
+ * children are skipped. A failed or escalated child is recorded and the next one still runs;
2000
+ * `.factory/STOP` is checked before each child starts (read only, ADR-0111). Throws CliExitError(1)
2001
+ * unless every child is ready or skipped (#1747, #1748, #1749). */
2002
+ async function runDecomposedChildren(parent, children, opts, env) {
2003
+ const list = children.map((n) => `#${n}`).join(', ');
2004
+ console.log(styleText('cyan', `run-children: #${parent} decomposed into ${list} — running them in build order`));
2005
+ const paths = getFactoryPaths(env.repoRoot);
2006
+ const results = await runChildrenInOrder(children, async (child) => {
2007
+ let work;
2008
+ try {
2009
+ work = await env.workSources.resolve(GITHUB_ISSUE_SOURCE, {
2010
+ repo: env.ghRepo,
2011
+ issue: child,
2012
+ });
2013
+ }
2014
+ catch (err) {
2015
+ return { issue: child, status: 'failed', detail: `could not resolve issue #${child} (${errorDetail(err)})` };
2016
+ }
2017
+ console.log(styleText('cyan', `one-shot: resolved ${work.id} — running the pipeline (queue untouched)`));
2018
+ let outcome;
2019
+ try {
2020
+ await shipIssue(child, opts, {
2021
+ repoRoot: env.repoRoot,
2022
+ ghRepo: env.ghRepo,
2023
+ workRequest: work,
2024
+ onOutcome: (o) => {
2025
+ outcome = o;
2026
+ },
2027
+ });
2028
+ }
2029
+ catch (err) {
2030
+ if (err instanceof IssueDecomposedError) {
2031
+ const again = err.childIssues.map((n) => `#${n}`).join(', ');
2032
+ console.log(styleText('cyan', `run-children: #${child} decomposed into ${again} — running them in its place`));
2033
+ return { issue: child, status: 'decomposed', children: err.childIssues };
2034
+ }
2035
+ if (err instanceof IssueSkippedError) {
2036
+ return { issue: child, status: 'skipped', detail: err.message };
2037
+ }
2038
+ return { issue: child, status: 'failed', detail: err instanceof Error ? err.message : String(err) };
2039
+ }
2040
+ const done = outcome;
2041
+ return { issue: child, status: 'ready', prNumber: done && 'prNumber' in done ? done.prNumber : undefined };
2042
+ }, [parent], (next) => {
2043
+ if (!existsSync(paths.stop))
2044
+ return false;
2045
+ logEvent(paths.events, 'stopped', next, 'STOP file present');
2046
+ console.log(styleText('yellow', `run-children: .factory/STOP present — not starting #${next} or any later child`));
2047
+ return true;
2048
+ });
2049
+ const ok = childRunSucceeded(results);
2050
+ for (const line of formatChildRunSummary(parent, results)) {
2051
+ console.log(ok ? styleText('green', line) : styleText('yellow', line));
2052
+ }
2053
+ if (!ok) {
2054
+ throw new CliExitError(`Run failed for issue #${parent}: not every decomposed child reached ready-for-review or was skipped as closed (failed or not-run children are listed above)`, 1);
2055
+ }
2056
+ }
2057
+ async function cmdRunBrief(briefPath, opts) {
2058
+ if (!coreOps.isCommandAvailable('claude')) {
2059
+ throw new CliExitError(`factory: ${missingClaudeCliMessage()}`, 2);
2060
+ }
2061
+ const repoRoot = await getRepoRoot();
2062
+ const paths = getFactoryPaths(repoRoot);
2063
+ if (!existsSync(paths.root)) {
2064
+ throw new CliExitError(`factory: ${notInitializedMessage()}`, 2);
2065
+ }
2066
+ const branchPrefix = branchPrefixOrExit(opts.branchPrefix);
2067
+ await withRepoRunLock(paths, 'factory run-brief', async () => {
2068
+ let localOnly;
2069
+ if (opts.workspace !== undefined) {
2070
+ try {
2071
+ localOnly = resolveLocalOnlyPolicy(opts.workspace);
2072
+ }
2073
+ catch (err) {
2074
+ if (err instanceof InvalidWorkspaceError)
2075
+ throw new CliExitError(`factory: ${err.message}`, 2);
2076
+ throw err;
2077
+ }
2078
+ }
2079
+ let artifactsDir;
2080
+ if (opts.artifacts !== undefined) {
2081
+ if (!localOnly) {
2082
+ throw new CliExitError('factory: --artifacts requires --workspace (local-only runs only)', 2);
2083
+ }
2084
+ try {
2085
+ artifactsDir = resolveArtifactsDir(opts.artifacts);
2086
+ }
2087
+ catch (err) {
2088
+ if (err instanceof InvalidArtifactsDirError)
2089
+ throw new CliExitError(`factory: ${err.message}`, 2);
2090
+ throw err;
2091
+ }
2092
+ console.log(styleText('cyan', `local-only: benchmark artifacts will be written to ${artifactsDir}`));
2093
+ }
2094
+ // Local-only runs never touch GitHub — no remote required, no mutation possible.
2095
+ const ghRepo = localOnly ? 'local/workspace' : await getGitHubRepo();
2096
+ // Pre-flight: resolve the brief through the canonical work-request seam
2097
+ // BEFORE any worktree or PR exists — a malformed brief exits here, so BUILD never starts.
2098
+ const workSources = createDefaultWorkSourceRegistry({ octokit: getOctokit() });
2099
+ let work;
2100
+ try {
2101
+ work = await workSources.resolve(LOCAL_BRIEF_SOURCE, { path: briefPath });
2102
+ }
2103
+ catch (err) {
2104
+ if (err instanceof InvalidWorkRequestInputError) {
2105
+ throw new CliExitError(`factory: ${err.message}`, 2);
2106
+ }
2107
+ throw new CliExitError(`factory: could not resolve brief at ${briefPath} (${errorDetail(err)}) — no worktree or PR was created`, 2);
2108
+ }
2109
+ const digest = work.reference?.externalId ?? '';
2110
+ const runNum = briefRunNumber(digest);
2111
+ console.log(styleText('cyan', `one-shot: resolved ${work.id} — running the pipeline (queue untouched)`));
2112
+ logEvent(paths.events, 'work-source', runNum, `inline local brief ${briefPath} (sha256 ${digest})`);
2113
+ if (localOnly) {
2114
+ console.log(styleText('cyan', `local-only: workspace ${localOnly.workspace} — publishing disabled`));
2115
+ logEvent(paths.events, 'local-only', runNum, `caller-provided workspace ${localOnly.workspace} — publishing disabled`);
2116
+ }
2117
+ const priorEvents = existsSync(paths.events) ? readEvents(paths.events) : [];
2118
+ if (hasUnresolvedPark(priorEvents, String(runNum))) {
2119
+ logEvent(paths.events, 'human-restarted', runNum, 'manual retry of a previously parked/failed run', {
2120
+ actor: process.env.FACTORY_ACTOR ?? process.env.USER ?? 'unknown',
2121
+ });
2122
+ }
2123
+ try {
2124
+ await shipIssue(runNum, { ...opts, branchPrefix }, {
2125
+ repoRoot,
2126
+ ghRepo,
2127
+ workRequest: work,
2128
+ workSource: { kind: LOCAL_BRIEF_SOURCE, params: { path: briefPath } },
2129
+ localOnly,
2130
+ artifactsDir,
2131
+ });
2132
+ }
2133
+ catch (err) {
2134
+ throw new CliExitError(`Run failed for brief ${briefPath}: ${err.message}`, 1);
2135
+ }
2136
+ });
2137
+ }
2138
+ async function cmdLocalSmallDryRun(issueNum, opts) {
2139
+ const repoRoot = await getRepoRoot();
2140
+ const ghRepo = await getGitHubRepo();
2141
+ const paths = getFactoryPaths(repoRoot);
2142
+ const octokit = getOctokit();
2143
+ const [owner, repoName] = ghRepo.split('/');
2144
+ const specPath = resolve(repoRoot, opts.spec ?? resolve(paths.plans, `issue-${issueNum}.md`));
2145
+ const outputDir = resolve(repoRoot, opts.output ?? resolve(paths.state, 'local-small', `issue-${issueNum}`));
2146
+ const { data: issue } = await octokit.rest.issues.get({ owner, repo: repoName, issue_number: issueNum });
2147
+ const result = await internalOps.createLocalSmallDryRun({
2148
+ issue: issueNum,
2149
+ issueTitle: issue.title,
2150
+ issueBody: issue.body ?? '',
2151
+ repoRoot,
2152
+ specPath,
2153
+ outputDir,
2154
+ });
2155
+ console.log(styleText('green', `local-small dry run: ${result.planPath}`));
2156
+ console.log(styleText('green', `local-small context: ${result.contextPath}`));
2157
+ }
2158
+ async function cmdLocalSmallOvernight(opts) {
2159
+ const repoRoot = await getRepoRoot();
2160
+ const paths = getFactoryPaths(repoRoot);
2161
+ const queueFile = resolve(repoRoot, opts.queue ?? resolve(paths.state, 'local-small', 'overnight-queue'));
2162
+ const statePath = resolve(repoRoot, opts.state ?? resolve(paths.state, 'local-small', 'overnight-state.json'));
2163
+ if (!existsSync(queueFile)) {
2164
+ throw new CliExitError(`factory: overnight queue not found at ${queueFile} — create it with "<lane> <issue#>" lines`, 2);
2165
+ }
2166
+ const { entries, diagnostics } = parseQueue(readFileSync(queueFile, 'utf-8'));
2167
+ warnQueueDiagnostics(diagnostics);
2168
+ if (entries.length === 0) {
2169
+ throw new CliExitError(`factory: overnight queue at ${queueFile} has no valid entries`, 2);
2170
+ }
2171
+ await withRepoRunLock(paths, 'factory local-small-overnight', async () => {
2172
+ const ghRepo = await getGitHubRepo();
2173
+ process.env.FACTORY_LOCAL_ONLY = '1';
2174
+ const preflight = async () => {
2175
+ if (!coreOps.isCommandAvailable('claude'))
2176
+ return { ok: false, reason: missingClaudeCliMessage() };
2177
+ const registry = new coreOps.ModelRegistry(applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot)));
2178
+ const ollamaModels = ollamaModelSet();
2179
+ const diagnoses = coreOps.diagnoseModels(registry, { ollamaModelPresent: ollamaModels ? (m) => ollamaModels.has(m) : undefined }, resolveExperimental(), true);
2180
+ if (!hasReachableWorker(diagnoses)) {
2181
+ const reasons = diagnoses.filter((d) => !d.reachable).map((d) => `${d.model}: ${d.reason}`);
2182
+ return { ok: false, reason: `no reachable local worker model — ${reasons.join('; ')}` };
2183
+ }
2184
+ return { ok: true };
2185
+ };
2186
+ const processItem = async (issue) => {
2187
+ try {
2188
+ await shipIssue(issue, { autoRework: true, interactive: false }, { repoRoot, ghRepo, lane: 'overnight' });
2189
+ return { status: 'ready' };
2190
+ }
2191
+ catch (err) {
2192
+ if (err instanceof IssueSkippedError)
2193
+ return { status: 'parked', reason: err.message };
2194
+ return parkReasonFor(err) === 'escalate'
2195
+ ? { status: 'parked', reason: err.message }
2196
+ : { status: 'failed', reason: err.message };
2197
+ }
2198
+ };
2199
+ const report = (item) => {
2200
+ console.log(styleText('yellow', `factory: issue #${item.issue} ${item.status} — ${item.reason ?? 'unknown'}`));
2201
+ logEvent(paths.events, 'overnight-park', item.issue, `${item.status}: ${item.reason ?? 'unknown'} — see ${paths.reports}`);
2202
+ };
2203
+ const log = (type, msg) => logEvent(paths.events, type, '-', msg);
2204
+ const deps = { preflight, processItem, report, log };
2205
+ const result = await runOvernightQueue({ issues: entries.map((e) => e.issue), statePath }, deps);
2206
+ const ready = result.processed.filter((item) => item.status === 'ready');
2207
+ const parked = result.processed.filter((item) => item.status === 'parked');
2208
+ const failed = result.processed.filter((item) => item.status === 'failed');
2209
+ console.log(styleText('green', `overnight ready: ${ready.length}`));
2210
+ console.log(styleText('yellow', `overnight parked: ${parked.length}`));
2211
+ console.log(styleText('red', `overnight failed: ${failed.length}`));
2212
+ console.log(styleText('yellow', `overnight skipped (already resumed): ${result.skipped.length}`));
2213
+ if (result.halted) {
2214
+ throw new CliExitError(`factory: overnight halted before issue #${result.halted.issue}: ${result.halted.reason} — fix the environment and re-run to resume`, 4);
2215
+ }
2216
+ });
2217
+ }
2218
+ function worktreePathFor(repoRoot, ghRepo, issueNum, prefix, loaders = configLoaders) {
2219
+ return laneWorktreePath({
2220
+ repoRoot,
2221
+ parent: loaders.loadFactoryConfigForRepo(getFactoryPaths(repoRoot).config).worktree.parent,
2222
+ repo: ghRepo,
2223
+ issue: issueNum,
2224
+ prefix,
2225
+ });
2226
+ }
2227
+ /** Fences a whole run behind the checkout's `.factory/run.lock` (#598). A live holder is
2228
+ * refused immediately — never queued behind a multi-hour run — and a dead holder's lock is
2229
+ * reclaimed by withRunLock. Nested acquisition inside one process is a pass-through, so
2230
+ * `factory supervise` can hold this across its loop. */
2231
+ export async function withRepoRunLock(paths, command, fn, deps = {}) {
2232
+ const { runLock = withRunLock, emitEvent = logEvent } = deps;
2233
+ try {
2234
+ return await runLock(paths.runLock, fn, {
2235
+ command,
2236
+ onReclaim: (pid) => emitEvent(paths.events, 'lock-stolen', '-', `reclaimed ${paths.runLock} from dead holder pid ${pid ?? 'unknown'}`),
2237
+ });
2238
+ }
2239
+ catch (err) {
2240
+ if (err instanceof RunLockHeldError) {
2241
+ emitEvent(paths.events, 'run_lock_conflict', '-', `${command} refused: ${err.message}`);
2242
+ throw new CliExitError(`factory: ${err.message} — stop that run (or wait for it to finish) before starting another. ` +
2243
+ `A dead run's lock is reclaimed automatically; remove ${paths.runLock} only if you are sure no factory run is live.`, 2);
2244
+ }
2245
+ throw err;
2246
+ }
2247
+ }
2248
+ /** The repo's current docker-sandbox descriptor for worktree-gc sweeps, or undefined for
2249
+ * every other runtime/config state (including a fixture config with no `sandbox` key). */
2250
+ function gcWorktreeSandbox(sandboxCfg, repoRoot) {
2251
+ if (!sandboxCfg)
2252
+ return undefined;
2253
+ const policy = resolveSandboxPolicy(sandboxCfg, { worktree: repoRoot, repoRoot });
2254
+ return worktreeSandboxFor(policy?.runtime);
2255
+ }
2256
+ /** Eagerly reaps a parked lane's own worktree inside `factory run` (#1007). All the safety
2257
+ * gates live in reapLaneWorktree; this wrapper resolves the lane's conventional worktree
2258
+ * path and holds the same git+file lock pairing as every other worktree mutation site. A
2259
+ * reap failure is logged as a warn and never surfaces — parking must always succeed. */
2260
+ export async function reapParkedLaneWorktree(issue, repoRoot, ghRepo, paths, prefixOverride) {
2261
+ try {
2262
+ const factoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
2263
+ const branchPrefix = prefixOverride ?? resolveEffectiveConfig(configLoaders.loadRepoConfig(repoRoot)).branchPrefix;
2264
+ const worktreePath = worktreePathFor(repoRoot, ghRepo, issue, branchPrefix);
2265
+ const log = (type, msg) => logEvent(paths.events, type, issue, msg);
2266
+ const sandbox = gcWorktreeSandbox(factoryConfig.sandbox, repoRoot);
2267
+ await withGitLock(repoRoot, () => withFileLock(paths.gitLock, () => reapLaneWorktree(repoRoot, worktreePath, { issue, branchPrefix, log, sandbox })));
2268
+ }
2269
+ catch (err) {
2270
+ logEvent(paths.events, 'warn', issue, `parked-lane worktree reap failed: ${err?.message ?? String(err)}`);
2271
+ }
2272
+ }
2273
+ export async function cmdWorktreeGc(opts) {
2274
+ const repoRoot = await getRepoRoot();
2275
+ const paths = getFactoryPaths(repoRoot);
2276
+ const factoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
2277
+ const ttlDays = opts.ttlDays !== undefined ? Number(opts.ttlDays) : factoryConfig.worktree.gcTtlDays;
2278
+ if (!Number.isFinite(ttlDays) || ttlDays < 0) {
2279
+ throw new CliExitError('factory: --ttl-days must be a non-negative number', 2);
2280
+ }
2281
+ const branchPrefix = branchPrefixOrExit(opts.branchPrefix);
2282
+ const log = (type, msg) => logEvent(paths.events, type, '-', msg);
2283
+ // Best-effort GitHub evidence: tokenless/local-only repos keep today's pure-local behavior.
2284
+ const ghRepo = await getGitHubRepo().catch(() => undefined);
2285
+ const octokit = ghRepo ? (hasGitHubToken() ? getOctokit() : undefined) : undefined;
2286
+ const sandbox = gcWorktreeSandbox(factoryConfig.sandbox, repoRoot);
2287
+ const run = () => internalOps.sweepWorktrees({ repoRoot, ttlDays, dryRun: opts.dryRun, repo: ghRepo, branchPrefix }, { log, octokit, sandbox });
2288
+ const report = opts.dryRun ? await run() : await withGitLock(repoRoot, () => withFileLock(paths.gitLock, run));
2289
+ console.log(internalOps.formatGcReport(report));
2290
+ }
2291
+ /** `factory reset <issue...>` (#1787): wipes an issue's local state; see reset.ts. */
2292
+ export async function cmdReset(issues, opts) {
2293
+ let nums;
2294
+ try {
2295
+ nums = parseResetIssues(issues);
2296
+ }
2297
+ catch (err) {
2298
+ throw new CliExitError(err.message, 2);
2299
+ }
2300
+ const repoRoot = await getRepoRoot();
2301
+ const paths = getFactoryPaths(repoRoot);
2302
+ const branchPrefix = branchPrefixOrExit(opts.branchPrefix);
2303
+ const sandbox = gcWorktreeSandbox(configLoaders.loadFactoryConfigForRepo(paths.config).sandbox, repoRoot);
2304
+ const log = (type, msg) => logEvent(paths.events, type, '-', msg);
2305
+ const run = () => runReset(nums, {
2306
+ repoRoot,
2307
+ cwd: process.cwd(),
2308
+ paths,
2309
+ branchPrefix,
2310
+ git: (cmd) => shellOps.exec(cmd, { cwd: repoRoot }).then((r) => r.stdout),
2311
+ removeWorktree: (p) => gitOps.cleanupWorktree(repoRoot, p, log, sandbox),
2312
+ readLeases: () => readPortLeases(paths.ports),
2313
+ releaseLease: (id) => releasePortLease({ registryFile: paths.ports, lockDir: paths.portsLock, worktreeId: id }),
2314
+ runCommand: (cmd, o) => shellOps.exec(cmd, { cwd: o?.cwd ?? repoRoot }).then((r) => ({ stdout: r.stdout })),
2315
+ dryRun: opts.dryRun === true,
2316
+ force: opts.force === true,
2317
+ });
2318
+ // A dry run takes no lock: the lock itself writes files.
2319
+ const lines = opts.dryRun ? await run() : await withGitLock(repoRoot, () => withFileLock(paths.gitLock, run));
2320
+ for (const line of lines)
2321
+ console.log(line);
2322
+ }
2323
+ /** deps overrides the config loaders, git ops, internal ops and octokit for this call, like shipIssue. */
2324
+ export async function cmdLand(issueNum, opts = {}, deps = {}) {
2325
+ const previousLoaders = configLoaders;
2326
+ const previousOctokitFactory = octokitFactory;
2327
+ const previousGitOps = gitOps;
2328
+ const previousInternalOps = internalOps;
2329
+ const previousShellOps = shellOps;
2330
+ configLoaders = resolveConfigLoaders(deps);
2331
+ gitOps = resolveGitOps(deps);
2332
+ internalOps = resolveInternalOps(deps);
2333
+ shellOps = resolveShellOps(deps);
2334
+ octokitFactory = deps.octokit ?? octokitFactory;
2335
+ try {
2336
+ const branchPrefix = branchPrefixOrExit(opts.branchPrefix);
2337
+ const repoRoot = await getRepoRoot();
2338
+ const ghRepo = await getGitHubRepo();
2339
+ const paths = getFactoryPaths(repoRoot);
2340
+ const octokit = getOctokit();
2341
+ const factoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
2342
+ const skipCI = configLoaders.resolveSkipCI(factoryConfig);
2343
+ try {
2344
+ const result = await landIssue(issueNum, repoRoot, ghRepo, paths, octokit, skipCI, branchPrefix, {
2345
+ allowGated: opts.allowGated === true,
2346
+ });
2347
+ console.log(styleText('green', `✅ Landed PR #${result.prNumber} for issue #${issueNum}`));
2348
+ }
2349
+ catch (err) {
2350
+ if (err instanceof AwaitingReviewError) {
2351
+ console.log(styleText('yellow', `⏸ PR #${err.prNumber} for issue #${issueNum} awaiting human review — left open`));
2352
+ return;
2353
+ }
2354
+ if (err instanceof CiUnverifiedError) {
2355
+ console.log(styleText('yellow', `⏸ PR #${err.prNumber} for issue #${issueNum} — CI never reached a green verdict — left open, not merged`));
2356
+ return;
2357
+ }
2358
+ if (err instanceof CiFailedError) {
2359
+ console.log(styleText('yellow', `⏸ PR #${err.prNumber} for issue #${issueNum} has a failing CI check — left open, not merged`));
2360
+ return;
2361
+ }
2362
+ if (err instanceof MergeGatedError) {
2363
+ throw new CliExitError(`factory: ${err.message}`, 4);
2364
+ }
2365
+ if (err instanceof LandConflictError) {
2366
+ throw new CliExitError(`factory: ${err.message}`, 3);
2367
+ }
2368
+ if (err instanceof LandFailureError) {
2369
+ throw new CliExitError(`factory: ${err.message}`, err.code);
2370
+ }
2371
+ throw new CliExitError(`factory: merge failed for issue #${issueNum}: ${err.message}`, 5);
2372
+ }
2373
+ }
2374
+ finally {
2375
+ configLoaders = previousLoaders;
2376
+ gitOps = previousGitOps;
2377
+ internalOps = previousInternalOps;
2378
+ shellOps = previousShellOps;
2379
+ octokitFactory = previousOctokitFactory;
2380
+ }
2381
+ }
2382
+ async function landIssue(issueNum, repoRoot, ghRepo, paths, octokit, skipCI, branchPrefix, opts = {}) {
2383
+ const [owner, repoName] = ghRepo.split('/');
2384
+ const log = (type, msg, extra) => logEvent(paths.events, type, issueNum, msg, extra);
2385
+ // The issue title may have been edited since the PR was opened, so a
2386
+ // freshly-derived branch name can drift from the branch the PR actually
2387
+ // lives on (same failure mode fixed for waitForMerge in #51). Guess the
2388
+ // branch from the current title first, but fall back to matching the open
2389
+ // PR that references this issue directly and use its real head branch.
2390
+ // Legacy-prefix (LEGACY_BRANCH_PREFIX) branches are guessed too, after the resolved prefix (#1708).
2391
+ const resolved = branchPrefix ?? resolveEffectiveConfig(configLoaders.loadRepoConfig(repoRoot)).branchPrefix;
2392
+ const title = await getIssueTitle(ghRepo, issueNum, octokit);
2393
+ const guesses = factoryBranchPrefixes(resolved).map((p) => branchFor(issueNum, title, p));
2394
+ const guessedBranch = guesses[0];
2395
+ let branch = guessedBranch;
2396
+ let prNumber;
2397
+ try {
2398
+ const findByGuess = async () => {
2399
+ for (const guess of guesses) {
2400
+ const found = await findOpenPRNumber(octokit, owner, repoName, guess);
2401
+ if (found) {
2402
+ branch = guess;
2403
+ return found;
2404
+ }
2405
+ }
2406
+ return undefined;
2407
+ };
2408
+ [, prNumber] = await Promise.all([gitOps.gitFetch(repoRoot), findByGuess()]);
2409
+ if (!prNumber) {
2410
+ const fallback = await findOpenPRForIssue(octokit, owner, repoName, issueNum);
2411
+ if (fallback) {
2412
+ branch = fallback.branch;
2413
+ prNumber = fallback.number;
2414
+ }
2415
+ }
2416
+ }
2417
+ catch (err) {
2418
+ const failure = prLookupFailure(issueNum, guessedBranch, err);
2419
+ log('fail', failure.message);
2420
+ throw failure;
2421
+ }
2422
+ if (!prNumber) {
2423
+ log('fail', `no open PR for ${guessedBranch}`);
2424
+ throw new LandFailureError(`no open PR for issue #${issueNum} (${guessedBranch})`, 1);
2425
+ }
2426
+ const landFactoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
2427
+ // Every merge path honors the no-auto-merge gate on the issue and on the PR; unreadable labels fail closed (#1720).
2428
+ const filingPolicy = resolveFilingPolicy(landFactoryConfig);
2429
+ const gateLabel = filingPolicy.selfFixLabel;
2430
+ if (opts.allowGated) {
2431
+ console.log(`AUDIT factory land #${issueNum}: --allow-gated override — merging PR #${prNumber} past the ${gateLabel} gate`);
2432
+ log('merge-gated-override', `--allow-gated: merging PR #${prNumber} past ${gateLabel} gate (human override)`);
2433
+ }
2434
+ else {
2435
+ let where;
2436
+ try {
2437
+ const [issueLabels, prLabels] = await Promise.all([
2438
+ defaultListIssueLabels(octokit, owner, repoName, issueNum),
2439
+ defaultListIssueLabels(octokit, owner, repoName, prNumber),
2440
+ ]);
2441
+ if (isAutoMergeBlocked(issueLabels, filingPolicy))
2442
+ where = `issue #${issueNum} is labelled ${gateLabel}`;
2443
+ else if (isAutoMergeBlocked(prLabels, filingPolicy))
2444
+ where = `PR #${prNumber} is labelled ${gateLabel}`;
2445
+ }
2446
+ catch (err) {
2447
+ where = `labels could not be read (${errorDetail(err)}) — failing closed on the ${gateLabel} gate`;
2448
+ }
2449
+ if (where) {
2450
+ log('merge-gated', `land refused: ${where}`);
2451
+ throw new MergeGatedError(`not merging PR #${prNumber} for issue #${issueNum}: ${where} (human approval required). Re-run \`factory land ${issueNum} --allow-gated\` to merge it deliberately.`, issueNum, prNumber, gateLabel);
2452
+ }
2453
+ }
2454
+ const worktree = worktreePathFor(repoRoot, ghRepo, issueNum, branch.startsWith(`${LEGACY_BRANCH_PREFIX}/`) ? LEGACY_BRANCH_PREFIX : resolved);
2455
+ const landSandboxPolicy = resolveSandboxPolicy(landFactoryConfig.sandbox, {
2456
+ worktree,
2457
+ repoRoot,
2458
+ });
2459
+ const worktreeSandbox = worktreeSandboxFor(landSandboxPolicy?.runtime, {
2460
+ allowHosts: landSandboxPolicy?.allowHosts ?? [],
2461
+ });
2462
+ const withLandLock = (fn) => withGitLock(repoRoot, () => withFileLock(paths.mergeLock, fn, {
2463
+ onSteal: (pid) => log('lock-stolen', `stole ${paths.mergeLock} from dead holder pid ${pid ?? 'unknown'}`),
2464
+ }));
2465
+ try {
2466
+ try {
2467
+ await landOpenPullRequest({
2468
+ octokit,
2469
+ owner,
2470
+ repoName,
2471
+ ghRepo,
2472
+ repoRoot,
2473
+ issue: issueNum,
2474
+ branch,
2475
+ worktree,
2476
+ prNumber: prNumber,
2477
+ log,
2478
+ skipCI,
2479
+ adminMerge: resolveMergePolicy(landFactoryConfig).admin,
2480
+ withLock: withLandLock,
2481
+ ensureWorktree: async () => {
2482
+ if (!existsSync(worktree)) {
2483
+ await gitOps.setupWorktree(repoRoot, branch, worktree, `origin/${branch}`, worktreeSandbox, log);
2484
+ }
2485
+ },
2486
+ });
2487
+ }
2488
+ catch (err) {
2489
+ if (err instanceof AwaitingReviewError || err instanceof CiFailedError) {
2490
+ await withLandLock(() => gitOps.cleanupWorktree(repoRoot, worktree, log, worktreeSandbox));
2491
+ }
2492
+ throw err;
2493
+ }
2494
+ log('merged', `squash-merged PR #${prNumber}`);
2495
+ await withLandLock(() => gitOps.cleanupWorktree(repoRoot, worktree, log, worktreeSandbox));
2496
+ }
2497
+ catch (err) {
2498
+ if (err instanceof LandConflictError || err instanceof AwaitingReviewError || err instanceof CiFailedError)
2499
+ throw err;
2500
+ log('fail', `merge failed: ${err.message}`);
2501
+ throw new LandFailureError(`merge failed for issue #${issueNum}: ${err.message}`, 5);
2502
+ }
2503
+ return { branch, prNumber };
2504
+ }
2505
+ export function prLookupFailure(issueNum, branch, err) {
2506
+ return new LandFailureError(`PR lookup failed for issue #${issueNum} (${branch}): ${errorDetail(err)}`, 5);
2507
+ }
2508
+ async function cmdTriage(opts) {
2509
+ const repoRoot = await getRepoRoot();
2510
+ const ghRepo = await getGitHubRepo();
2511
+ const paths = getFactoryPaths(repoRoot);
2512
+ if (!existsSync(paths.root)) {
2513
+ throw new CliExitError(`factory: ${notInitializedMessage()}`, 2);
2514
+ }
2515
+ const product = opts.product ?? readActiveProduct(paths.product);
2516
+ const repoConfig = configLoaders.loadRepoConfig(repoRoot);
2517
+ const modelsConfig = applyRepoConfig(configLoaders.loadModelsConfig(), repoConfig);
2518
+ const routesConfig = configLoaders.loadRoutesConfig();
2519
+ const effective = resolveEffectiveConfig(repoConfig);
2520
+ const router = new coreOps.ModelRouter(modelsConfig, routesConfig, false, undefined, effective.allowExperimental, effective.localOnly);
2521
+ const model = router.resolve('triage') ?? 'claude-sonnet-5';
2522
+ const flag = router.registryRef.getClaudeFlag(model);
2523
+ const constitutionNote = product ? `Active constitution: ${product}.` : '';
2524
+ const prompt = `Triage the open GitHub issues of ${ghRepo} for an autonomous software factory.
2525
+ ${constitutionNote}
2526
+ Run: gh issue list --repo ${ghRepo} --state open --limit 100 --json number,title,labels,body
2527
+ Read every body. Exclude epics/PRDs/meta, external-account/credential/outreach issues,
2528
+ and anything too vague. Group by lane (same-file issues together). Order by dependency then value.
2529
+ Write ONLY the queue to ${paths.queueProposed} in format '<lane> <issue#>', with '#' comments
2530
+ explaining exclusions.`;
2531
+ let plannerError;
2532
+ logEvent(paths.events, 'triage', '-', `Triaging ${ghRepo} with ${model}`);
2533
+ await shellOps
2534
+ .exec(`claude -p ${shellEscape(prompt)} ${flag ? `--model ${flag}` : ''} --allowedTools "Bash(gh issue:*)" "Bash(gh repo:*)" Read Glob Grep Write`)
2535
+ .catch((err) => {
2536
+ plannerError = err;
2537
+ logEvent(paths.events, 'warn', '-', `triage planner failed: ${errorDetail(err)}`);
2538
+ });
2539
+ const proposed = existsSync(paths.queueProposed) ? readFileSync(paths.queueProposed, 'utf-8') : '';
2540
+ const message = triageProposalMessage(proposed, paths.queueProposed, paths.queue);
2541
+ if (message) {
2542
+ console.log(message);
2543
+ }
2544
+ else {
2545
+ throw triageNoProposalError(plannerError);
2546
+ }
2547
+ }
2548
+ export function triageProposalMessage(proposed, proposedPath, queuePath) {
2549
+ if (!proposed.trim())
2550
+ return null;
2551
+ return `${proposed}\n---\nreview and run: factory triage accept (promotes ${proposedPath} -> ${queuePath})`;
2552
+ }
2553
+ export async function cmdTriageAccept(opts) {
2554
+ const repoRoot = await getRepoRoot();
2555
+ const paths = getFactoryPaths(repoRoot);
2556
+ if (!existsSync(paths.queueProposed)) {
2557
+ console.log(`nothing to accept — ${paths.queueProposed} not found`);
2558
+ return; // zero exit
2559
+ }
2560
+ const content = readFileSync(paths.queueProposed, 'utf-8');
2561
+ const result = validateQueue(content);
2562
+ if (!opts.force && !result.ok) {
2563
+ throw new CliExitError(`factory: proposed queue is invalid — ${paths.queueProposed} left unchanged\n` +
2564
+ result.errors.map((e) => ` - ${e}`).join('\n'), 1);
2565
+ }
2566
+ renameSync(paths.queueProposed, paths.queue); // same dir → atomic
2567
+ let acceptedBy = 'unknown';
2568
+ try {
2569
+ acceptedBy = userInfo().username;
2570
+ }
2571
+ catch {
2572
+ /* keep 'unknown' */
2573
+ }
2574
+ const suffix = opts.force ? ' (--force, validation skipped)' : '';
2575
+ logEvent(paths.events, 'triage_accepted', '-', `accepted ${result.issues.length} issue(s) [${result.issues.join(', ')}] by ${acceptedBy}${suffix}`);
2576
+ console.log(styleText('green', `queue accepted — ${result.issues.length} issue(s) promoted to ${paths.queue}`));
2577
+ }
2578
+ export async function cmdQueueMigrate(opts = {}) {
2579
+ const repoRoot = await getRepoRoot();
2580
+ const paths = getFactoryPaths(repoRoot);
2581
+ const queueFile = opts.file ? resolve(repoRoot, opts.file) : paths.queue;
2582
+ if (!existsSync(queueFile)) {
2583
+ throw new CliExitError(`factory: queue not found at ${queueFile} — run factory init + triage first, or pass --file`, 2);
2584
+ }
2585
+ const { entries, diagnostics } = parseQueue(readFileSync(queueFile, 'utf-8'));
2586
+ // Stage 1 — structural problems, reported before any GitHub call.
2587
+ const structural = diagnostics.map((d) => d.message);
2588
+ const firstLineFor = new Map();
2589
+ for (const entry of entries) {
2590
+ const first = firstLineFor.get(entry.issue);
2591
+ if (first !== undefined) {
2592
+ structural.push(`line ${entry.lineNo}: duplicate issue #${entry.issue} (already queued at line ${first})`);
2593
+ }
2594
+ else {
2595
+ firstLineFor.set(entry.issue, entry.lineNo);
2596
+ }
2597
+ }
2598
+ if (entries.length === 0 && structural.length === 0) {
2599
+ structural.push('queue has no issue entries');
2600
+ }
2601
+ if (structural.length > 0) {
2602
+ throw new CliExitError(`factory: queue is invalid — ${queueFile} left unchanged\n` + structural.map((p) => ` - ${p}`).join('\n'), 1);
2603
+ }
2604
+ // Stage 2 — GitHub preflight (read-only). Any closed/PR/inaccessible entry aborts all.
2605
+ const [owner, repo] = (await getGitHubRepo()).split('/');
2606
+ const octokit = getOctokit();
2607
+ const unreachable = [];
2608
+ for (const entry of entries) {
2609
+ try {
2610
+ const { data } = await octokit.rest.issues.get({ owner, repo, issue_number: entry.issue });
2611
+ if (data.pull_request) {
2612
+ unreachable.push(`line ${entry.lineNo}: #${entry.issue} is a pull request, not an issue`);
2613
+ }
2614
+ else if (data.state === 'closed') {
2615
+ unreachable.push(`line ${entry.lineNo}: issue #${entry.issue} is closed — cannot queue`);
2616
+ }
2617
+ }
2618
+ catch (err) {
2619
+ unreachable.push(`line ${entry.lineNo}: issue #${entry.issue} is inaccessible — ${errorDetail(err)}`);
2620
+ }
2621
+ }
2622
+ if (unreachable.length > 0) {
2623
+ throw new CliExitError(`factory: queue migration aborted — ${unreachable.length} issue(s) cannot be migrated; no labels applied\n` +
2624
+ unreachable.map((p) => ` - ${p}`).join('\n'), 1);
2625
+ }
2626
+ const plan = planQueueMigration(entries);
2627
+ if (opts.dryRun) {
2628
+ console.log(styleText('cyan', `dry run — ${plan.length} issue(s) would be labelled from ${queueFile}; no GitHub changes made`));
2629
+ for (const step of plan) {
2630
+ console.log(` #${step.issue} → lane ${step.lane}, position ${step.position} [${step.labels.join(', ')}]`);
2631
+ }
2632
+ return;
2633
+ }
2634
+ const queue = createGithubQueue({ client: createOctokitQueueClient(octokit), owner, repo });
2635
+ try {
2636
+ await queue.migrateLocalQueue(entries);
2637
+ }
2638
+ catch (err) {
2639
+ throw new CliExitError(`factory: queue migration failed — ${errorDetail(err)}`, 1);
2640
+ }
2641
+ console.log(styleText('green', `queue migrated — ${entries.length} issue(s) labelled from ${queueFile}`));
2642
+ }
2643
+ /** Append one `queue_added` event for newly queued issues; the silent sink keeps stdout untouched. */
2644
+ async function recordQueueAdded(lane, results) {
2645
+ const queued = results.filter((r) => r.outcome === 'queued');
2646
+ if (queued.length === 0)
2647
+ return;
2648
+ try {
2649
+ const eventsFile = getFactoryPaths(await getRepoRoot()).events;
2650
+ const summary = queued.map((r) => `#${r.issue} (order ${r.position})`).join(', ');
2651
+ createLogger(eventsFile, { lane }, { out: { write: () => true } }).info('queue_added', `${lane}: ${summary} queued`);
2652
+ }
2653
+ catch (err) {
2654
+ console.error(styleText('yellow', `factory: queue add event not recorded — ${errorDetail(err)}`));
2655
+ }
2656
+ }
2657
+ /** Append one `issue_unparked` event; the silent sink keeps stdout untouched. */
2658
+ async function recordIssueUnparked(result) {
2659
+ try {
2660
+ const eventsFile = getFactoryPaths(await getRepoRoot()).events;
2661
+ createLogger(eventsFile, { lane: result.lane, issue: result.issue }, { out: { write: () => true } }).info('issue_unparked', `#${result.issue} unparked → ${result.lane} (order ${result.order})`);
2662
+ }
2663
+ catch (err) {
2664
+ console.error(styleText('yellow', `factory: unpark event not recorded — ${errorDetail(err)}`));
2665
+ }
2666
+ }
2667
+ export async function cmdUnpark(issueArg, opts = {}) {
2668
+ const issue = parseIssueArg(issueArg);
2669
+ let result;
2670
+ try {
2671
+ const [owner, repo] = (await getGitHubRepo()).split('/');
2672
+ result = await runUnpark({
2673
+ client: createOctokitQueueClient(getOctokit()),
2674
+ owner,
2675
+ repo,
2676
+ issue,
2677
+ ...(opts.lane === undefined ? {} : { lane: opts.lane }),
2678
+ });
2679
+ }
2680
+ catch (err) {
2681
+ const code = err instanceof UnparkError ? err.code : 'unpark-failed';
2682
+ throw new CliExitError(`factory: unpark #${issue} [${code}] — ${errorDetail(err)}`, 1);
2683
+ }
2684
+ await recordIssueUnparked(result);
2685
+ if (opts.json) {
2686
+ console.log(JSON.stringify(result));
2687
+ return;
2688
+ }
2689
+ console.log(styleText('green', `#${issue} unparked → lane ${result.lane}, position ${result.order}`));
2690
+ }
2691
+ export async function cmdQueueAdd(lane, issueArgs, opts = {}) {
2692
+ // Validate + dedupe BEFORE any GitHub call. parseIssueArg throws CliExitError(2) on bad input.
2693
+ const seen = new Set();
2694
+ const issues = [];
2695
+ for (const raw of issueArgs) {
2696
+ const n = parseIssueArg(raw);
2697
+ if (!seen.has(n)) {
2698
+ seen.add(n);
2699
+ issues.push(n);
2700
+ }
2701
+ }
2702
+ const enqueueAll = async () => {
2703
+ const [owner, repo] = (await getGitHubRepo()).split('/');
2704
+ const queue = createGithubQueue({ client: createOctokitQueueClient(getOctokit()), owner, repo });
2705
+ return queue.enqueue(lane, issues);
2706
+ };
2707
+ let results;
2708
+ if (opts.json) {
2709
+ try {
2710
+ results = await enqueueAll();
2711
+ }
2712
+ catch (err) {
2713
+ throw new CliExitError(`factory: queue add failed — ${errorDetail(err)}`, 1);
2714
+ }
2715
+ }
2716
+ else {
2717
+ results = await enqueueAll();
2718
+ }
2719
+ await recordQueueAdded(lane, results);
2720
+ if (opts.json) {
2721
+ console.log(JSON.stringify(buildQueueAddJson(lane, results)));
2722
+ const failedJson = results.filter((r) => r.outcome === 'failed');
2723
+ if (failedJson.length > 0) {
2724
+ throw new CliExitError(`factory: ${failedJson.length} issue(s) failed to queue`, 1);
2725
+ }
2726
+ return;
2727
+ }
2728
+ for (const r of results) {
2729
+ if (r.outcome === 'queued') {
2730
+ console.log(styleText('green', `#${r.issue} queued → lane ${lane}, position ${r.position}`));
2731
+ }
2732
+ else if (r.outcome === 'already-queued') {
2733
+ console.log(`#${r.issue} already queued — skipped`);
2734
+ }
2735
+ else {
2736
+ console.error(styleText('red', `#${r.issue} failed — ${r.detail}`));
2737
+ }
2738
+ }
2739
+ const failed = results.filter((r) => r.outcome === 'failed');
2740
+ if (failed.length > 0) {
2741
+ throw new CliExitError(`factory: ${failed.length} issue(s) failed to queue`, 1);
2742
+ }
2743
+ }
2744
+ export async function cmdQueueReconcile(opts = {}) {
2745
+ const repoRoot = await getRepoRoot();
2746
+ const paths = getFactoryPaths(repoRoot);
2747
+ const [owner, repo] = (await getGitHubRepo()).split('/');
2748
+ const client = createOctokitQueueClient(getOctokit());
2749
+ const { report, conflicts } = await runQueueReconcile({
2750
+ readSnapshot: () => readGithubQueueSnapshot({ client, owner, repo }),
2751
+ reader: admissionStateReaderFor(paths.state),
2752
+ ...(opts.lane === undefined ? {} : { lane: opts.lane }),
2753
+ });
2754
+ console.log(report);
2755
+ if (conflicts > 0) {
2756
+ throw new CliExitError(`factory: ${conflicts} queued issue(s) conflict with Factory App admission state`, 1);
2757
+ }
2758
+ }
2759
+ export async function cmdQueueList(opts = {}) {
2760
+ if (!opts.json)
2761
+ throw new CliExitError('factory: queue list requires --json', 2);
2762
+ const [owner, repo] = (await getGitHubRepo()).split('/');
2763
+ let snapshot;
2764
+ try {
2765
+ snapshot = await readGithubQueueSnapshot({ client: createOctokitQueueClient(getOctokit()), owner, repo });
2766
+ }
2767
+ catch (err) {
2768
+ throw new CliExitError(`factory: queue list failed — ${errorDetail(err)}`, 1);
2769
+ }
2770
+ console.log(JSON.stringify(buildQueueListJson(snapshot, opts.lane)));
2771
+ }
2772
+ export async function cmdRuns(opts = {}) {
2773
+ if (!opts.json)
2774
+ throw new CliExitError('factory: runs requires --json', 2);
2775
+ if (opts.diffstat !== undefined) {
2776
+ if (opts.limit !== undefined) {
2777
+ throw new CliExitError('factory: runs — --diffstat and --limit cannot be combined', 2);
2778
+ }
2779
+ let issue;
2780
+ try {
2781
+ issue = parseDiffstatIssue(opts.diffstat);
2782
+ }
2783
+ catch (err) {
2784
+ throw new CliExitError(`factory: runs — ${errorDetail(err)}`, 2);
2785
+ }
2786
+ const repoRoot = await getRepoRoot();
2787
+ let out;
2788
+ try {
2789
+ out = await buildRunDiffstatJson({ runsDir: getFactoryPaths(repoRoot).runs, repoRoot, issue });
2790
+ }
2791
+ catch (err) {
2792
+ if (err instanceof NoRunError)
2793
+ throw new CliExitError(`factory: runs — ${err.message}`, 1);
2794
+ throw new CliExitError(`factory: runs failed — ${errorDetail(err)}`, 1);
2795
+ }
2796
+ console.log(JSON.stringify(out));
2797
+ return;
2798
+ }
2799
+ let limit;
2800
+ try {
2801
+ limit = parseRunsLimit(opts.limit);
2802
+ }
2803
+ catch (err) {
2804
+ throw new CliExitError(`factory: runs — ${errorDetail(err)}`, 2);
2805
+ }
2806
+ const paths = getFactoryPaths(await getRepoRoot());
2807
+ let result;
2808
+ try {
2809
+ result = await buildRunsJson(paths.runs, { limit });
2810
+ }
2811
+ catch (err) {
2812
+ throw new CliExitError(`factory: runs failed — ${errorDetail(err)}`, 1);
2813
+ }
2814
+ for (const note of result.skipped)
2815
+ console.error(note);
2816
+ console.log(JSON.stringify(result.json));
2817
+ }
2818
+ export async function cmdQueueClear(opts = {}) {
2819
+ const [owner, repo] = (await getGitHubRepo()).split('/');
2820
+ const queue = createGithubQueue({ client: createOctokitQueueClient(getOctokit()), owner, repo });
2821
+ const { report, exitCode, message } = await runQueueClear({
2822
+ previewClear: () => queue.previewClear(opts.lane),
2823
+ clear: () => queue.clear(opts.lane),
2824
+ ...(opts.dryRun === undefined ? {} : { dryRun: opts.dryRun }),
2825
+ ...(opts.yes === undefined ? {} : { yes: opts.yes }),
2826
+ ...(opts.lane === undefined ? {} : { lane: opts.lane }),
2827
+ });
2828
+ console.log(report);
2829
+ if (exitCode !== 0)
2830
+ throw new CliExitError(message ?? 'factory: queue clear refused', exitCode);
2831
+ }
2832
+ export function triageNoProposalError(plannerError) {
2833
+ const detail = plannerError ? ` — planner failed: ${errorDetail(plannerError)}` : '';
2834
+ return new CliExitError(`triage produced no proposal${detail}`, 1);
2835
+ }
2836
+ /** Starts the in-process lane proxy for `factory run`/`supervise` when
2837
+ * `environment.proxy.enabled`. Never throws: a bind failure (EACCES on :80,
2838
+ * EADDRINUSE, …) is logged as a single `environment_proxy_unavailable` event
2839
+ * and lanes degrade to port-based URLs, exactly as if the proxy were disabled. */
2840
+ export async function startLaneProxy(paths, settings, deps = {}) {
2841
+ if (!settings.enabled)
2842
+ return {};
2843
+ const { createProxy = createLaneProxy, emitEvent = logEvent } = deps;
2844
+ const proxy = createProxy({ registryFile: paths.ports, domain: settings.domain, port: settings.port });
2845
+ try {
2846
+ const boundPort = await proxy.start();
2847
+ writeProxyState(paths.proxyState, {
2848
+ version: 1,
2849
+ pid: process.pid,
2850
+ port: boundPort,
2851
+ domain: settings.domain,
2852
+ startedAt: new Date().toISOString(),
2853
+ });
2854
+ emitEvent(paths.events, 'environment_proxy', 'all', `lane proxy listening on 127.0.0.1:${boundPort} (*.${settings.domain})`);
2855
+ return { proxy };
2856
+ }
2857
+ catch (err) {
2858
+ emitEvent(paths.events, 'environment_proxy_unavailable', 'all', `proxy unavailable (${err.message}) — lanes use port-based URLs`);
2859
+ return {};
2860
+ }
2861
+ }
2862
+ /** Runs the lane proxy in the foreground for manual use alongside single-lane
2863
+ * `factory ship` (which only probes proxy.json and degrades — it never spawns
2864
+ * a proxy itself). Running this command IS the opt-in: it starts even when
2865
+ * environment.proxy.enabled is false in factory.json. */
2866
+ async function cmdProxy() {
2867
+ const repoRoot = await getRepoRoot();
2868
+ const paths = getFactoryPaths(repoRoot);
2869
+ const factoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
2870
+ const settings = resolveEnvironmentProxy(factoryConfig);
2871
+ if (!settings.enabled) {
2872
+ console.log(styleText('yellow', 'factory proxy: environment.proxy.enabled is false in factory.json — starting anyway (running this command is the explicit opt-in)'));
2873
+ }
2874
+ const proxy = createLaneProxy({ registryFile: paths.ports, domain: settings.domain, port: settings.port });
2875
+ const boundPort = await proxy.start();
2876
+ writeProxyState(paths.proxyState, {
2877
+ version: 1,
2878
+ pid: process.pid,
2879
+ port: boundPort,
2880
+ domain: settings.domain,
2881
+ startedAt: new Date().toISOString(),
2882
+ });
2883
+ console.log(styleText('green', `factory proxy: listening on 127.0.0.1:${boundPort} (*.${settings.domain})`));
2884
+ const leases = readPortLeases(paths.ports);
2885
+ if (leases.length === 0) {
2886
+ console.log(' no active lane leases yet');
2887
+ }
2888
+ else {
2889
+ for (const lease of leases) {
2890
+ console.log(` ${laneHostLabel(lease.worktreeId)}.${settings.domain} -> 127.0.0.1:${lease.port}`);
2891
+ }
2892
+ }
2893
+ console.log(styleText('dim', ' *.localhost resolves to loopback in Chrome/Firefox out of the box (RFC 6761); Safari/curl may need ' +
2894
+ 'a dnsmasq-backed .test domain instead — documented here, not solved.'));
2895
+ await new Promise((resolveShutdown) => {
2896
+ const shutdown = () => {
2897
+ void proxy
2898
+ .stop()
2899
+ .catch(() => { })
2900
+ .then(() => {
2901
+ clearProxyState(paths.proxyState);
2902
+ resolveShutdown();
2903
+ });
2904
+ };
2905
+ process.once('SIGINT', shutdown);
2906
+ process.once('SIGTERM', shutdown);
2907
+ });
2908
+ process.exit(0);
2909
+ }
2910
+ /** Runs factoryd in the foreground: a loopback-only HTTP server over the repo
2911
+ * registry (~/.factory/registry.json) (#777). Owns the daemon runtime state
2912
+ * next to the registry file — daemon.pid single-instance guard, daemon.port
2913
+ * bound-address record, daemon.log append sink (#1177). `factory daemon
2914
+ * start|stop|status|logs` wrap this process in a launchd LaunchAgent (#1179). */
2915
+ async function cmdFactoryd(opts) {
2916
+ let config;
2917
+ try {
2918
+ config = resolveFactorydConfig(opts);
2919
+ }
2920
+ catch (err) {
2921
+ if (err instanceof DaemonCtlError)
2922
+ throw new CliExitError(err.message, err.code);
2923
+ throw err;
2924
+ }
2925
+ const { registryFile, port } = config;
2926
+ const runtime = daemonRuntimePaths(dirname(registryFile));
2927
+ const acquired = await acquirePidFile(runtime);
2928
+ if (!acquired.ok) {
2929
+ throw new CliExitError(`factoryd already running (pid ${acquired.holderPid}) — stop it or remove ${runtime.pidFile}`, 2);
2930
+ }
2931
+ const logSink = createDaemonLogSink(runtime.logFile);
2932
+ const log = (line) => {
2933
+ console.log(styleText('dim', line));
2934
+ logSink(line);
2935
+ };
2936
+ if (acquired.stalePid !== null)
2937
+ log(`removed stale pid file (pid ${acquired.stalePid})`);
2938
+ const runRuntime = await createRunRuntime({
2939
+ registryFile,
2940
+ allowParallel: true,
2941
+ execute: createShipExecutor({ cliEntrypoint: fileURLToPath(new URL('../cli.js', import.meta.url)) }),
2942
+ });
2943
+ const daemon = createFactorydServer({ registryFile, runsDir: runtime.runsDir, port, log, runRuntime });
2944
+ const boundPort = await daemon.start();
2945
+ await writePortFile(runtime, boundPort);
2946
+ const banner = [
2947
+ `factoryd: listening on 127.0.0.1:${boundPort}`,
2948
+ ` registry: ${registryFile}`,
2949
+ ` pid file: ${runtime.pidFile} (pid ${process.pid})`,
2950
+ ` port file: ${runtime.portFile}`,
2951
+ ` log file: ${runtime.logFile}`,
2952
+ ` GET http://127.0.0.1:${boundPort}/repos`,
2953
+ ` POST http://127.0.0.1:${boundPort}/runs {"runId","repo","issue"}`,
2954
+ ];
2955
+ console.log(styleText('green', banner[0] ?? ''));
2956
+ for (const line of banner.slice(1))
2957
+ console.log(line);
2958
+ for (const line of banner)
2959
+ logSink(line);
2960
+ await new Promise((resolveShutdown) => {
2961
+ const shutdown = () => {
2962
+ void daemon
2963
+ .stop()
2964
+ .catch(() => { })
2965
+ .then(() => releaseRuntimeFiles(runtime))
2966
+ .then(() => resolveShutdown());
2967
+ };
2968
+ process.once('SIGINT', shutdown);
2969
+ process.once('SIGTERM', shutdown);
2970
+ });
2971
+ process.exit(0);
2972
+ }
2973
+ function usageFlagOverrides(opts, threshold) {
2974
+ return {
2975
+ watch: opts.usageWatch,
2976
+ [threshold]: opts.usageThreshold === undefined ? undefined : Number(opts.usageThreshold),
2977
+ pollSeconds: opts.usagePoll === undefined ? undefined : Number(opts.usagePoll),
2978
+ };
2979
+ }
2980
+ async function cmdRun(opts = {}) {
2981
+ const repoRoot = await getRepoRoot();
2982
+ const paths = getFactoryPaths(repoRoot);
2983
+ const branchPrefix = branchPrefixOrExit(opts.branchPrefix);
2984
+ return withRepoRunLock(paths, 'factory run', async () => {
2985
+ const stopStatus = readStopFileStatus(paths);
2986
+ if (stopStatus.present) {
2987
+ const msg = stopSentinelRunSkipMessage(stopStatus);
2988
+ console.log(styleText('yellow', `!! ${msg}`));
2989
+ logEvent(paths.events, 'stopped', 'all', msg);
2990
+ return;
2991
+ }
2992
+ writeRunFlagOverrides(paths.runFlags, { autoMerge: opts.autoMerge, prClassifier: opts.prClassifier });
2993
+ const ghRepo = await getGitHubRepo();
2994
+ const factoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
2995
+ const keychainErr = keychainPreflightError(probeClaudeKeychain());
2996
+ if (keychainErr) {
2997
+ logEvent(paths.events, 'environment_warning', 'all', keychainErr);
2998
+ throw new Error(keychainErr);
2999
+ }
3000
+ await runAutoWorktreeGc(repoRoot, paths, ghRepo, 'all');
3001
+ const { lanes, diagnostics } = await planRunLanes({
3002
+ localQueue: opts.localQueue === true,
3003
+ readLocalQueue: () => (existsSync(paths.queue) ? readFileSync(paths.queue, 'utf-8') : null),
3004
+ queue: () => {
3005
+ const [owner, repo] = ghRepo.split('/');
3006
+ const octokit = getOctokit();
3007
+ return createGithubQueue({
3008
+ client: createOctokitQueueClient(octokit),
3009
+ owner,
3010
+ repo,
3011
+ preflight: withAdmissionGuard({
3012
+ reader: admissionStateReaderFor(paths.state),
3013
+ preflight: (issue) => preflightQueuedIssue(issue, createQueuePreflightOps(octokit, owner, repo)),
3014
+ onConflict: (issue, verdict) => {
3015
+ const line = `#${issue} refused — ${verdict.reason} | repair: ${verdict.repair}`;
3016
+ console.error(styleText('yellow', `factory: ${line}`));
3017
+ logEvent(paths.events, 'queue_admission_conflict', String(issue), line);
3018
+ },
3019
+ }),
3020
+ });
3021
+ },
3022
+ });
3023
+ warnQueueDiagnostics(diagnostics);
3024
+ let knobs;
3025
+ let laneBreakerThreshold;
3026
+ try {
3027
+ knobs = resolveUsageKnobs(process.env, configLoaders.loadRepoConfig(repoRoot), usageFlagOverrides(opts, 'stopAt'));
3028
+ laneBreakerThreshold = resolveLaneBreakerThreshold(configLoaders.loadRepoConfig(repoRoot));
3029
+ }
3030
+ catch (err) {
3031
+ throw new CliExitError(`factory: ${err.message}`, 2);
3032
+ }
3033
+ if (knobs.watchSource === 'flag') {
3034
+ const detail = `usage watchdog ${knobs.watch ? 'enabled' : 'disabled'} by ${usageWatchSourceLabel(knobs.watchSource, knobs.watch)}`;
3035
+ console.log(`[factory] run: ${detail}`);
3036
+ logEvent(paths.events, 'watchdog', 'all', detail);
3037
+ }
3038
+ if (knobs.stopAtSource === 'flag') {
3039
+ const detail = 'usage watchdog threshold set by flag: --usage-threshold';
3040
+ console.log(`[factory] run: ${detail}`);
3041
+ logEvent(paths.events, 'watchdog', 'all', detail);
3042
+ }
3043
+ if (knobs.pollSource === 'flag') {
3044
+ const detail = 'usage watchdog poll set by flag: --usage-poll';
3045
+ console.log(`[factory] run: ${detail}`);
3046
+ logEvent(paths.events, 'watchdog', 'all', detail);
3047
+ }
3048
+ const controller = new AbortController();
3049
+ const watchdog = knobs.watch
3050
+ ? coreOps
3051
+ .watchUsage({
3052
+ cap: knobs.cap,
3053
+ stopAt: knobs.stopAt,
3054
+ pollMs: knobs.pollMs,
3055
+ stopFile: paths.stop,
3056
+ eventsFile: paths.events,
3057
+ signal: controller.signal,
3058
+ estimator: knobs.estimator,
3059
+ })
3060
+ .catch((err) => {
3061
+ // a watchdog crash must never take down the run
3062
+ logEvent(paths.events, 'warn', '-', `usage watchdog crashed: ${err.message}`);
3063
+ })
3064
+ : Promise.resolve();
3065
+ const { proxy } = await startLaneProxy(paths, resolveEnvironmentProxy(factoryConfig));
3066
+ const workspaceBackend = factoryConfig.workspace?.backend ?? 'host';
3067
+ const runId = randomUUID();
3068
+ const containerEngine = workspaceBackend === 'disposable-docker' ? createDockerEngine({}) : undefined;
3069
+ try {
3070
+ // Run lanes in parallel
3071
+ const pids = [];
3072
+ for (const planned of lanes) {
3073
+ logEvent(paths.events, 'lane-start', '-', `lane '${planned.lane}' started${planned.issues.length ? ` (${planned.issues.length} issues)` : ''}`, { lane: planned.lane });
3074
+ if (containerEngine) {
3075
+ const provision = await provisionLaneContainer(containerEngine, workspaceBackend, runId, slugify(planned.lane), ghRepo);
3076
+ if (provision.attempted && !provision.created) {
3077
+ logEvent(paths.events, 'warn', '-', `disposable-docker container creation failed for lane '${planned.lane}': ${provision.error}`, { lane: planned.lane });
3078
+ }
3079
+ else if (provision.attempted && provision.created && provision.workspaceCloned === false) {
3080
+ logEvent(paths.events, 'warn', '-', `disposable-docker workspace clone failed for lane '${planned.lane}': ${provision.workspaceError}`, { lane: planned.lane });
3081
+ }
3082
+ }
3083
+ pids.push(runLane(planned.lane, planned.issues, repoRoot, ghRepo, paths, {
3084
+ ...planned.deps,
3085
+ laneBreakerThreshold,
3086
+ commentIssue: async (issue, body) => {
3087
+ const [owner, repoName] = ghRepo.split('/');
3088
+ await getOctokit().rest.issues.createComment({ owner, repo: repoName, issue_number: issue, body });
3089
+ },
3090
+ ship: (issue, o, c) => shipIssue(issue, { ...o, branchPrefix, prClassifier: opts.prClassifier }, c),
3091
+ sweepFinished: (issue) => sweepBetweenLaneIssues(issue, planned.lane, repoRoot, ghRepo, paths).then(() => { }),
3092
+ reapWorktree: (issue) => reapParkedLaneWorktree(issue, repoRoot, ghRepo, paths, opts.branchPrefix === undefined ? undefined : branchPrefix),
3093
+ waitMerge: (issue, branch, root, gh, p, d = {}) => waitForMerge(issue, branch, root, gh, p, { ...d, mergeOverrides: { auto: opts.autoMerge } }),
3094
+ }));
3095
+ }
3096
+ await Promise.allSettled(pids);
3097
+ controller.abort();
3098
+ await watchdog;
3099
+ logEvent(paths.events, 'run-done', 'all', 'all lanes finished');
3100
+ }
3101
+ finally {
3102
+ if (proxy) {
3103
+ await proxy.stop();
3104
+ clearProxyState(paths.proxyState);
3105
+ }
3106
+ }
3107
+ if (lanes.length > 0) {
3108
+ try {
3109
+ const events = existsSync(paths.events) ? readEvents(paths.events) : [];
3110
+ const costs = existsSync(paths.costs) ? internalOps.readCosts(paths.costs) : [];
3111
+ const kpis = computeHealthKpis(events, costs);
3112
+ await appendKpiSnapshot(paths, repoRoot, kpis);
3113
+ logEvent(paths.events, 'kpi-snapshot', 'all', `KPI snapshot appended to ${paths.kpiHistory}`);
3114
+ }
3115
+ catch (err) {
3116
+ logEvent(paths.events, 'warn', 'all', `kpi snapshot failed: ${err.message}`);
3117
+ }
3118
+ }
3119
+ });
3120
+ }
3121
+ /** Wraps cmdRun so an empty queue is a no-op (not a throw) while auto-ingest is enabled and watching. */
3122
+ export function createSuperviseRunQueue(paths, ingestCfg, deps = {}) {
3123
+ const { cmdRunFn = cmdRun, readQueueFile = () => (existsSync(paths.queue) ? readFileSync(paths.queue, 'utf-8') : ''), pendingCount = async () => parseQueue(readQueueFile()).entries.length, emitEvent = logEvent, } = deps;
3124
+ return async () => {
3125
+ if ((await pendingCount()) === 0 && ingestCfg.enabled) {
3126
+ emitEvent(paths.events, 'idle', 'auto', 'queue empty — waiting for ready issues');
3127
+ return;
3128
+ }
3129
+ await cmdRunFn();
3130
+ };
3131
+ }
3132
+ /** Builds the per-cycle auto-ingest hook passed to superviseLoop; failures are logged, never thrown. */
3133
+ export function createIngestHook(repoRoot, paths, ingestCfg, deps = {}) {
3134
+ const { runAutoIngestFn = runAutoIngest, emitEvent = logEvent } = deps;
3135
+ return async () => {
3136
+ try {
3137
+ const result = await runAutoIngestFn({
3138
+ repoDir: repoRoot,
3139
+ queueFile: paths.queue,
3140
+ watermarkFile: paths.ingestWatermark,
3141
+ label: ingestCfg.label,
3142
+ lane: ingestCfg.lane,
3143
+ maxPerCycle: ingestCfg.maxPerCycle,
3144
+ branchPrefix: deps.branchPrefix ?? resolveBranchPrefix(),
3145
+ forceAdmit: process.env.FACTORY_INGEST_FORCE_ADMIT === '1',
3146
+ });
3147
+ for (const held of result.skippedFileOverlap) {
3148
+ emitEvent(paths.events, 'ingest_file_overlap_held', held.issue, `#${held.issue} held back — names the same file (${held.path}) as already-admitted #${held.collidesWith}; set FACTORY_INGEST_FORCE_ADMIT=1 to admit both`);
3149
+ }
3150
+ if (result.appended.length) {
3151
+ emitEvent(paths.events, 'ingested', 'auto', `auto-ingest appended ${result.appended.length} ready issue(s): ${result.appended.join(', ')}`);
3152
+ }
3153
+ return result.appended.length;
3154
+ }
3155
+ catch (err) {
3156
+ emitEvent(paths.events, 'warn', 'auto', `auto-ingest failed: ${errorDetail(err)}`);
3157
+ return 0;
3158
+ }
3159
+ };
3160
+ }
3161
+ async function cmdSupervise(opts) {
3162
+ const branchPrefix = branchPrefixOrExit(opts.branchPrefix);
3163
+ const repoRoot = await getRepoRoot();
3164
+ const paths = getFactoryPaths(repoRoot);
3165
+ const ingestCfg = resolveIngestConfig(configLoaders.loadFactoryConfigForRepo(paths.config));
3166
+ const countPending = async () => {
3167
+ if (opts.localQueue === true) {
3168
+ const content = existsSync(paths.queue) ? readFileSync(paths.queue, 'utf-8') : '';
3169
+ const parsed = parseQueue(content);
3170
+ warnQueueDiagnostics(parsed.diagnostics);
3171
+ return parsed.entries.length;
3172
+ }
3173
+ const [owner, repo] = (await getGitHubRepo()).split('/');
3174
+ const gq = createGithubQueue({ client: createOctokitQueueClient(getOctokit()), owner, repo });
3175
+ const lanes = await gq.lanes();
3176
+ const counts = await Promise.all(lanes.map((lane) => gq.list(lane)));
3177
+ return counts.reduce((total, issues) => total + issues.length, 0);
3178
+ };
3179
+ if ((await countPending()) === 0 && !ingestCfg.enabled) {
3180
+ throw new CliExitError('queue empty — run factory init + triage first', 2);
3181
+ }
3182
+ let knobs;
3183
+ try {
3184
+ knobs = resolveUsageKnobs(process.env, configLoaders.loadRepoConfig(repoRoot), usageFlagOverrides(opts, 'resumeAt'));
3185
+ }
3186
+ catch (err) {
3187
+ throw new CliExitError(`factory: ${err.message}`, 2);
3188
+ }
3189
+ await withRepoRunLock(paths, 'factory supervise', () => superviseLoop({
3190
+ cap: knobs.cap,
3191
+ resumeAt: knobs.resumeAt,
3192
+ pollMs: knobs.pollMs,
3193
+ watch: knobs.watch,
3194
+ watchSource: knobs.watchSource,
3195
+ resumeAtSource: knobs.resumeAtSource,
3196
+ pollSource: knobs.pollSource,
3197
+ estimator: knobs.estimator,
3198
+ stopFile: paths.stop,
3199
+ eventsFile: paths.events,
3200
+ now: opts.now,
3201
+ runQueue: createSuperviseRunQueue(paths, ingestCfg, {
3202
+ cmdRunFn: () => cmdRun({
3203
+ localQueue: opts.localQueue,
3204
+ autoMerge: opts.autoMerge,
3205
+ usageWatch: opts.usageWatch,
3206
+ usageThreshold: opts.usageThreshold,
3207
+ usagePoll: opts.usagePoll,
3208
+ branchPrefix: opts.branchPrefix,
3209
+ }),
3210
+ pendingCount: countPending,
3211
+ }),
3212
+ ingest: ingestCfg.enabled ? createIngestHook(repoRoot, paths, ingestCfg, { branchPrefix }) : undefined,
3213
+ }));
3214
+ }
3215
+ export function laneQueueDeps(queue, lane) {
3216
+ return {
3217
+ claimNext: () => queue.claimNext(lane),
3218
+ releaseIssue: (issue, outcome) => queue.release(issue, outcome),
3219
+ heartbeat: (issue) => queue.heartbeat(issue),
3220
+ countRemaining: async () => (await queue.list(lane)).length,
3221
+ };
3222
+ }
3223
+ export function createQueuePreflightOps(octokit, owner, repo) {
3224
+ return {
3225
+ expectedHeadRepoFullName: `${owner}/${repo}`,
3226
+ findOpenPR: (issue) => findOpenPRForIssue(octokit, owner, repo, issue),
3227
+ getLandState: (prNumber) => getPullRequestLandState(octokit, owner, repo, prNumber),
3228
+ watch: (ref) => internalOps.watchChecks({ octokit, owner, repo, ref }),
3229
+ findSlicePlan: (issue) => findSlicePlanComment({ octokit, repo: `${owner}/${repo}`, issue }),
3230
+ getPullState: async (prNumber) => {
3231
+ const { data } = await octokit.rest.pulls.get({ owner, repo, pull_number: prNumber });
3232
+ if (data.merged || data.merged_at)
3233
+ return 'merged';
3234
+ return data.state === 'open' ? 'open' : 'closed';
3235
+ },
3236
+ saveSlicePlan: async (issue, plan) => {
3237
+ await upsertSlicePlanComment({ octokit, repo: `${owner}/${repo}`, issue, plan });
3238
+ },
3239
+ };
3240
+ }
3241
+ /** ADR-0147: wait on the current slice PR, record its merge, or park when it was closed unmerged.
3242
+ * Returns undefined to fall through to the Closes-PR preflight. */
3243
+ async function preflightSlice(issue, deps) {
3244
+ let lookup;
3245
+ try {
3246
+ lookup = await deps.findSlicePlan(issue);
3247
+ }
3248
+ catch (err) {
3249
+ return { kind: 'park', reason: `slice plan lookup failed: ${errorDetail(err)}` };
3250
+ }
3251
+ if (lookup.status === 'invalid') {
3252
+ return {
3253
+ kind: 'park',
3254
+ reason: `slice plan comment ${lookup.commentId} on #${issue} is unreadable (${lookup.error})`,
3255
+ };
3256
+ }
3257
+ if (lookup.status === 'not-found')
3258
+ return undefined;
3259
+ const { plan } = lookup;
3260
+ const slice = currentSlice(plan);
3261
+ if (slice === undefined || slice.state !== 'pr-open')
3262
+ return undefined;
3263
+ const label = `slice ${slice.index}/${plan.slices.length}`;
3264
+ if (slice.prNumber === undefined) {
3265
+ return { kind: 'park', reason: `${label} of #${issue} is pr-open but the slice plan records no PR number` };
3266
+ }
3267
+ const pr = slice.prNumber;
3268
+ let state;
3269
+ try {
3270
+ state = await deps.getPullState(pr);
3271
+ }
3272
+ catch (err) {
3273
+ return { kind: 'park', reason: `${label} PR #${pr} state lookup failed: ${errorDetail(err)}` };
3274
+ }
3275
+ if (state === 'open')
3276
+ return { kind: 'defer' };
3277
+ if (state === 'closed') {
3278
+ return { kind: 'park', reason: `${label} PR #${pr} was closed without merging` };
3279
+ }
3280
+ try {
3281
+ await deps.saveSlicePlan(issue, withSliceState(plan, slice.index, 'merged', pr));
3282
+ }
3283
+ catch (err) {
3284
+ return {
3285
+ kind: 'park',
3286
+ reason: `${label} PR #${pr} merged but updating the slice plan failed: ${errorDetail(err)}`,
3287
+ };
3288
+ }
3289
+ return undefined;
3290
+ }
3291
+ /** Resolve queued work from GitHub evidence without consuming model or worktree resources. */
3292
+ export async function preflightQueuedIssue(issue, deps) {
3293
+ if (issue.labels.includes('factory:in-progress'))
3294
+ return { kind: 'defer' };
3295
+ const slice = await preflightSlice(issue.number, deps);
3296
+ if (slice)
3297
+ return slice;
3298
+ let pr;
3299
+ try {
3300
+ pr = await deps.findOpenPR(issue.number);
3301
+ }
3302
+ catch (err) {
3303
+ return { kind: 'park', reason: `PR lookup failed: ${errorDetail(err)}` };
3304
+ }
3305
+ if (!pr)
3306
+ return { kind: 'build' };
3307
+ if (pr.headRepoFullName !== deps.expectedHeadRepoFullName) {
3308
+ return { kind: 'park', reason: `PR #${pr.number} head is not in ${deps.expectedHeadRepoFullName}` };
3309
+ }
3310
+ let state;
3311
+ try {
3312
+ state = await deps.getLandState(pr.number);
3313
+ }
3314
+ catch (err) {
3315
+ return { kind: 'park', reason: `PR #${pr.number} merge-state lookup failed: ${errorDetail(err)}` };
3316
+ }
3317
+ if (state.isDraft !== false) {
3318
+ return { kind: 'park', reason: `PR #${pr.number} is draft or its draft state is unavailable` };
3319
+ }
3320
+ if (!state.mergeStateStatus || state.mergeStateStatus === 'UNKNOWN') {
3321
+ return { kind: 'park', reason: `PR #${pr.number} merge state is unavailable` };
3322
+ }
3323
+ let outcome;
3324
+ try {
3325
+ outcome = await deps.watch(pr.headSha);
3326
+ }
3327
+ catch (err) {
3328
+ return { kind: 'park', reason: `CI watch for PR #${pr.number} failed: ${errorDetail(err)}` };
3329
+ }
3330
+ if (outcome !== 'success')
3331
+ return { kind: 'park', reason: `CI for PR #${pr.number} ended ${outcome}` };
3332
+ return { kind: 'adopt', branch: pr.branch };
3333
+ }
3334
+ export async function planRunLanes(input) {
3335
+ if (input.localQueue) {
3336
+ const content = input.readLocalQueue();
3337
+ if (content === null) {
3338
+ throw new CliExitError('queue empty — run factory init + triage first', 2);
3339
+ }
3340
+ const { entries, diagnostics } = parseQueue(content);
3341
+ const byLane = new Map();
3342
+ for (const e of entries) {
3343
+ if (!byLane.has(e.lane))
3344
+ byLane.set(e.lane, []);
3345
+ byLane.get(e.lane).push(e.issue);
3346
+ }
3347
+ return {
3348
+ lanes: [...byLane].map(([lane, issues]) => ({ lane, issues, deps: {} })),
3349
+ diagnostics,
3350
+ };
3351
+ }
3352
+ const gq = input.queue();
3353
+ const lanes = await gq.lanes();
3354
+ return {
3355
+ lanes: lanes.map((lane) => ({ lane, issues: [], deps: laneQueueDeps(gq, lane) })),
3356
+ diagnostics: [],
3357
+ };
3358
+ }
3359
+ /** How often an in-flight issue's claim lease is refreshed (#1500). Well inside the
3360
+ * 15-minute default lease (github-queue.ts's DEFAULT_CLAIM_LEASE_MS) so a normal poll
3361
+ * cadence never lets the lease lapse mid-ship. */
3362
+ const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000;
3363
+ /** Runs `work` while periodically calling `heartbeat(issue)` so a long-running ship or
3364
+ * merge wait keeps this claimant's lease fresh. A heartbeat failure is swallowed — losing
3365
+ * one refresh must never abort the work it is protecting. */
3366
+ async function withHeartbeat(issue, heartbeat, work) {
3367
+ if (!heartbeat)
3368
+ return work();
3369
+ const timer = setInterval(() => {
3370
+ heartbeat(issue).catch(() => { });
3371
+ }, HEARTBEAT_INTERVAL_MS);
3372
+ try {
3373
+ return await work();
3374
+ }
3375
+ finally {
3376
+ clearInterval(timer);
3377
+ }
3378
+ }
3379
+ export function renderLanePausedComment(payload) {
3380
+ const checks = payload.failingChecks.length
3381
+ ? payload.failingChecks.map((c) => `\`${c}\``).join(', ')
3382
+ : 'none recorded';
3383
+ return [
3384
+ `Lane '${payload.lane}' is paused: this issue and #${payload.firstIssue} parked with the same failure signature, so the factory stopped claiming issues on this lane.`,
3385
+ `Shared failing checks: ${checks}`,
3386
+ `To resume: fix the shared failure (often on the base branch), then re-run \`factory run\` to resume lane '${payload.lane}'.`,
3387
+ `Failure signature: \`${payload.signature}\``,
3388
+ ].join('\n\n');
3389
+ }
3390
+ export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {}) {
3391
+ const { ship = shipIssue, waitMerge = waitForMerge, pathExists = existsSync, emitEvent = logEvent, claimNext = async () => null, releaseIssue = async () => { }, heartbeat, reapWorktree = async () => { }, sweepFinished = async () => { }, countRemaining, laneBreakerThreshold = 0, commentIssue = async () => { }, } = deps;
3392
+ let streak = null;
3393
+ let merged = 0;
3394
+ let awaitingReview = 0;
3395
+ let skipped = 0;
3396
+ let decomposed = 0;
3397
+ let parked = 0;
3398
+ const buildClaim = (issue) => ({ issue, decision: { kind: 'build' } });
3399
+ const pending = issues.map(buildClaim);
3400
+ const seen = new Set(issues);
3401
+ // Truthful park-time remaining count (#1222): local pending plus the lane's still-queued
3402
+ // remote issues. Fail-closed — a count lookup may never change the message beyond the
3403
+ // local truth, and never lane flow.
3404
+ const remainingAfter = async (index) => {
3405
+ const local = pending.length - index - 1;
3406
+ if (!countRemaining)
3407
+ return local;
3408
+ try {
3409
+ return local + (await countRemaining());
3410
+ }
3411
+ catch {
3412
+ return local;
3413
+ }
3414
+ };
3415
+ // Every terminal outcome releases the claim, then gives finished worktrees a GC pass before
3416
+ // the lane moves on (#1757). The pass is fail-closed and must never change lane flow.
3417
+ const settle = async (issue, outcome) => {
3418
+ await releaseIssue(issue, outcome);
3419
+ await sweepFinished(issue).catch(() => { });
3420
+ };
3421
+ for (let i = 0;; i++) {
3422
+ if (i >= pending.length) {
3423
+ const claimed = await claimNext();
3424
+ if (claimed === null)
3425
+ break;
3426
+ seen.add(claimed.issue);
3427
+ pending.push(claimed);
3428
+ }
3429
+ const { issue, decision } = pending[i];
3430
+ if (pathExists(paths.stop)) {
3431
+ emitEvent(paths.events, 'stopped', issue, 'STOP file present', { lane });
3432
+ await releaseIssue(issue, 'queued');
3433
+ return;
3434
+ }
3435
+ if (decision.kind === 'park') {
3436
+ emitEvent(paths.events, 'escalate', issue, decision.reason, { lane });
3437
+ emitEvent(paths.events, 'parked', issue, `issue #${issue} parked (${decision.reason}); lane '${lane}' continuing, ${await remainingAfter(i)} issue(s) remaining`, { lane });
3438
+ // A preflight-parked issue may still have a stale worktree from a prior run; the reap
3439
+ // is fail-closed and must never change lane flow.
3440
+ await reapWorktree(issue).catch(() => { });
3441
+ await settle(issue, 'parked');
3442
+ parked++;
3443
+ streak = null;
3444
+ continue;
3445
+ }
3446
+ try {
3447
+ const sliceOutcome = {};
3448
+ const branch = decision.kind === 'adopt'
3449
+ ? decision.branch
3450
+ : await withHeartbeat(issue, heartbeat, () => ship(issue, {}, {
3451
+ repoRoot,
3452
+ ghRepo,
3453
+ paths,
3454
+ lane,
3455
+ onOutcome: (o) => {
3456
+ sliceOutcome.value = o;
3457
+ },
3458
+ }));
3459
+ const shipped = sliceOutcome.value;
3460
+ if (shipped?.state === 'ready' && shipped.slice && shipped.slice.index < shipped.slice.count) {
3461
+ // ADR-0147: a non-final slice's PR is open; hand the issue back for the next slice
3462
+ // instead of waiting for the merge.
3463
+ awaitingReview++;
3464
+ streak = null;
3465
+ await settle(issue, 'queued');
3466
+ continue;
3467
+ }
3468
+ await withHeartbeat(issue, heartbeat, () => waitMerge(issue, branch, repoRoot, ghRepo, paths));
3469
+ merged++;
3470
+ streak = null;
3471
+ await settle(issue, 'done');
3472
+ }
3473
+ catch (err) {
3474
+ if (err instanceof AwaitingReviewError) {
3475
+ // The land path already emitted the awaiting-review event and cleaned the
3476
+ // worktree — this is a clean outcome, not a park; move to the next issue.
3477
+ awaitingReview++;
3478
+ streak = null;
3479
+ await settle(issue, 'done');
3480
+ continue;
3481
+ }
3482
+ if (err instanceof IssueSkippedError) {
3483
+ // shipIssue already emitted skipped-already-closed; nothing was attempted.
3484
+ skipped++;
3485
+ await settle(issue, 'done');
3486
+ continue;
3487
+ }
3488
+ if (err instanceof IssueDecomposedError) {
3489
+ // shipIssue already rewrote the queue — splice the children in after the
3490
+ // current issue so this same lane run ships them, deduping ones already queued.
3491
+ const fresh = err.childIssues.filter((n) => !seen.has(n));
3492
+ for (const n of fresh)
3493
+ seen.add(n);
3494
+ pending.splice(i + 1, 0, ...fresh.map(buildClaim));
3495
+ decomposed++;
3496
+ emitEvent(paths.events, 'decompose_filed', issue, `lane '${lane}' continuing with ${fresh.length} child issue(s) in place of #${issue}`, { lane });
3497
+ await settle(issue, 'done');
3498
+ continue;
3499
+ }
3500
+ if (err instanceof LaneEnvironmentPauseError) {
3501
+ // The base is broken, not the issue (#1928): release to queued (never parked),
3502
+ // pause the lane whatever laneBreakerThreshold is, and stop claiming.
3503
+ streak = null;
3504
+ await reapWorktree(issue).catch(() => { });
3505
+ await settle(issue, 'queued');
3506
+ const payload = {
3507
+ lane,
3508
+ signature: err.failureSignature ?? '',
3509
+ failingChecks: err.failingChecks,
3510
+ firstIssue: issue,
3511
+ secondIssue: issue,
3512
+ cause: 'environment',
3513
+ baseSha: err.baseSha,
3514
+ };
3515
+ emitEvent(paths.events, 'lane-paused', lane, `lane '${lane}' paused: #${issue} failed only on checkers that also fail on base ${err.baseSha.slice(0, 8)} (${err.failingChecks.join(', ')}); #${issue} released to queued`, { lane, lanePaused: payload });
3516
+ return;
3517
+ }
3518
+ const reason = parkReasonFor(err);
3519
+ // Terminal reason events (escalate/timeout/fail/conflict) are emitted exactly
3520
+ // once by the layer that detects the failure — shipIssue for pipeline failures,
3521
+ // the land path for merge failures. runLane owns only lane-lifecycle events
3522
+ // (stopped/parked/lane-done), so injected ship functions never change ownership.
3523
+ const signature = err instanceof LaneParkError ? err.failureSignature : undefined;
3524
+ const checks = err instanceof LaneParkError ? (err.failingChecks ?? []) : [];
3525
+ if (!signature)
3526
+ streak = null;
3527
+ else if (streak && streak.signature === signature)
3528
+ streak.count++;
3529
+ else
3530
+ streak = { signature, firstIssue: issue, failingChecks: checks, count: 1 };
3531
+ const tripped = laneBreakerThreshold > 0 && streak !== null && streak.count >= laneBreakerThreshold;
3532
+ emitEvent(paths.events, 'parked', issue, `issue #${issue} parked (${reason}); ${tripped ? `lane '${lane}' paused (lane breaker)` : `lane '${lane}' continuing`}, ${await remainingAfter(i)} issue(s) remaining`, { lane });
3533
+ // Remove the parked lane's own worktree so a parked issue stops leaving a sibling
3534
+ // checkout behind (#1007); fail-closed and never allowed to change lane flow.
3535
+ await reapWorktree(issue).catch(() => { });
3536
+ await settle(issue, 'parked');
3537
+ parked++;
3538
+ if (tripped && streak) {
3539
+ const { signature: tripSignature, firstIssue, failingChecks: firstChecks } = streak;
3540
+ const payload = {
3541
+ lane,
3542
+ signature: tripSignature,
3543
+ failingChecks: checks.filter((c) => firstChecks.includes(c)),
3544
+ firstIssue,
3545
+ secondIssue: issue,
3546
+ };
3547
+ emitEvent(paths.events, 'lane-paused', lane, `lane '${lane}' paused: #${payload.firstIssue} and #${payload.secondIssue} parked with the same failure signature (${payload.failingChecks.join(', ') || 'no failing checks'})`, { lane, lanePaused: payload });
3548
+ // Tell the developer on GitHub why the lane stopped (#1919); fail-closed, never changes lane flow.
3549
+ await commentIssue(payload.secondIssue, renderLanePausedComment(payload)).catch(() => { });
3550
+ return;
3551
+ }
3552
+ continue;
3553
+ }
3554
+ }
3555
+ emitEvent(paths.events, 'lane-done', lane, `lane complete (${merged} merged, ${awaitingReview} awaiting review, ${skipped} skipped, ${decomposed} decomposed, ${parked} parked)`, { lane });
3556
+ }
3557
+ export async function isPrMerged(octokit, owner, repoName, branch) {
3558
+ const { data: prs } = await octokit.rest.pulls.list({
3559
+ owner,
3560
+ repo: repoName,
3561
+ state: 'all',
3562
+ head: `${owner}:${branch}`,
3563
+ });
3564
+ if (prs.length === 0)
3565
+ return false;
3566
+ // Ship-it branch names get reused across separate runs for the same issue
3567
+ // (e.g. a "verify-and-close" pass recreates the exact branch name an
3568
+ // earlier, already-merged PR used) — GitHub PR numbers only increase, so
3569
+ // the highest-numbered PR under this head is always the current one.
3570
+ // Checking "was ANY PR ever merged under this branch name" (the old
3571
+ // `state: 'closed'` + `.some()` behavior) matched the stale prior PR and
3572
+ // falsely reported a brand-new, still-open PR under the same name as
3573
+ // merged — waitForMerge then logged "landed" and skipped calling land(),
3574
+ // leaving the real PR open forever while the lane believed it was done.
3575
+ const latest = prs.reduce((newest, pr) => (pr.number > newest.number ? pr : newest));
3576
+ return Boolean(latest.merged_at);
3577
+ }
3578
+ export async function findOpenPRNumber(octokit, owner, repoName, branch) {
3579
+ const { data: prs } = await octokit.rest.pulls.list({
3580
+ owner,
3581
+ repo: repoName,
3582
+ state: 'open',
3583
+ head: `${owner}:${branch}`,
3584
+ });
3585
+ return prs[0]?.number;
3586
+ }
3587
+ export async function findOpenPRForIssue(octokit, owner, repoName, issueNum) {
3588
+ const perPage = 100;
3589
+ const matches = new RegExp(`\\bcloses\\s+#${issueNum}\\b`, 'i');
3590
+ for (let page = 1;; page++) {
3591
+ const { data: prs } = await octokit.rest.pulls.list({
3592
+ owner,
3593
+ repo: repoName,
3594
+ state: 'open',
3595
+ per_page: perPage,
3596
+ page,
3597
+ });
3598
+ const pr = prs.find((p) => matches.test(p.body ?? ''));
3599
+ if (pr) {
3600
+ const headRepoFullName = pr.head.repo?.full_name ?? null;
3601
+ if (headRepoFullName !== `${owner}/${repoName}`) {
3602
+ return {
3603
+ number: pr.number,
3604
+ branch: pr.head.ref,
3605
+ headSha: pr.head.sha,
3606
+ headRepoFullName,
3607
+ };
3608
+ }
3609
+ return { number: pr.number, branch: pr.head.ref, headSha: pr.head.sha, headRepoFullName };
3610
+ }
3611
+ if (prs.length < perPage)
3612
+ return undefined;
3613
+ }
3614
+ }
3615
+ export async function squashMergeAndDelete(octokit, owner, repoName, branch, prNumber,
3616
+ /** `sha`: the CI-verified head commit. GitHub refuses the merge if the PR head has moved. */
3617
+ opts = {}) {
3618
+ const { sha } = opts;
3619
+ if (opts.admin) {
3620
+ const run = opts.run ?? shellOps.exec;
3621
+ const pin = sha ? ` --match-head-commit ${shellEscape(sha)}` : '';
3622
+ await run(`gh pr merge ${prNumber} --repo ${shellEscape(`${owner}/${repoName}`)} --admin --squash --delete-branch${pin}`);
3623
+ return;
3624
+ }
3625
+ await octokit.rest.pulls.merge({
3626
+ owner,
3627
+ repo: repoName,
3628
+ pull_number: prNumber,
3629
+ merge_method: 'squash',
3630
+ ...(sha ? { sha } : {}),
3631
+ });
3632
+ // Best-effort branch delete: the merge is the source of truth.
3633
+ await octokit.rest.git.deleteRef({ owner, repo: repoName, ref: `heads/${branch}` }).catch(() => { });
3634
+ }
3635
+ export class MergeGatedError extends Error {
3636
+ issue;
3637
+ prNumber;
3638
+ label;
3639
+ constructor(message, issue, prNumber, label) {
3640
+ super(message);
3641
+ this.issue = issue;
3642
+ this.prNumber = prNumber;
3643
+ this.label = label;
3644
+ this.name = 'MergeGatedError';
3645
+ }
3646
+ }
3647
+ export class LandConflictError extends Error {
3648
+ parkReason = 'conflict';
3649
+ }
3650
+ /** The target issue was already resolved before the run started — a clean skip,
3651
+ * not a park. Callers advance to the next queue entry. (#681) */
3652
+ export class IssueSkippedError extends Error {
3653
+ reason;
3654
+ constructor(message, reason) {
3655
+ super(message);
3656
+ this.reason = reason;
3657
+ }
3658
+ }
3659
+ export class AwaitingReviewError extends Error {
3660
+ prNumber;
3661
+ constructor(message, prNumber) {
3662
+ super(message);
3663
+ this.prNumber = prNumber;
3664
+ }
3665
+ }
3666
+ /** The target issue tripped the PLAN pre-flight size gate and was decomposed into real
3667
+ * filed sub-issues; the queue was rewritten. Not a park — the lane continues with the
3668
+ * children in place of the oversized issue. (#823) */
3669
+ export class IssueDecomposedError extends Error {
3670
+ childIssues;
3671
+ constructor(message, childIssues) {
3672
+ super(message);
3673
+ this.childIssues = childIssues;
3674
+ }
3675
+ }
3676
+ export class LandFailureError extends Error {
3677
+ code;
3678
+ constructor(message, code) {
3679
+ super(message);
3680
+ this.code = code;
3681
+ }
3682
+ }
3683
+ /** Thrown when watchChecks reports a confirmed CI failure — a completed check run with a
3684
+ * non-passing conclusion. A confirmed failing check must never be merged, admin override
3685
+ * or not. See CiUnverifiedError for the no-verdict case. */
3686
+ export class CiFailedError extends Error {
3687
+ prNumber;
3688
+ parkReason = 'ci-failed';
3689
+ constructor(message, prNumber) {
3690
+ super(message);
3691
+ this.prNumber = prNumber;
3692
+ }
3693
+ }
3694
+ /** Thrown when the CI watch never reached a green verdict — a deadline timeout, no check runs ever
3695
+ * registering, or repeated API failures. A subclass of CiFailedError so every existing guard
3696
+ * (parkReasonFor → 'ci-failed', worktree cleanup, cmdLand's exit-0 path) treats "we don't know"
3697
+ * exactly like "we know it's red": never merge. */
3698
+ export class CiUnverifiedError extends CiFailedError {
3699
+ }
3700
+ const MAX_MERGE_ATTEMPTS = 5;
3701
+ const MERGE_RETRY_BASE_MS = 5_000;
3702
+ /** True when GitHub refused a SHA-pinned merge because the PR head moved past the verified
3703
+ * commit (REST 409, or gh's "Head branch was modified"). Retrying as-is can never succeed. */
3704
+ export function isHeadModifiedMergeError(err) {
3705
+ if (err?.status === 409)
3706
+ return true;
3707
+ const msg = `${err instanceof Error ? err.message : ''}\n${errorDetail(err)}`;
3708
+ return /head branch was modified/i.test(msg);
3709
+ }
3710
+ export function isReviewRequiredMergeError(err, state) {
3711
+ const msg = err instanceof Error ? err.message : String(err);
3712
+ if (/approving review/i.test(msg) || /review is required/i.test(msg))
3713
+ return true;
3714
+ return state.mergeStateStatus === 'BLOCKED' && state.reviewDecision === 'REVIEW_REQUIRED';
3715
+ }
3716
+ export async function getPullRequestLandState(octokit, owner, repoName, prNumber) {
3717
+ const result = await octokit.graphql(`query PullRequestLandState($owner: String!, $repo: String!, $number: Int!) {
3718
+ repository(owner: $owner, name: $repo) {
3719
+ pullRequest(number: $number) {
3720
+ id
3721
+ isDraft
3722
+ mergeStateStatus
3723
+ reviewDecision
3724
+ }
3725
+ }
3726
+ }`, { owner, repo: repoName, number: prNumber });
3727
+ return result.repository?.pullRequest ?? {};
3728
+ }
3729
+ export async function markPullRequestReady(octokit, pullRequestId) {
3730
+ await octokit.graphql(`mutation MarkPullRequestReady($id: ID!) {
3731
+ markPullRequestReadyForReview(input: { pullRequestId: $id }) {
3732
+ pullRequest { isDraft }
3733
+ }
3734
+ }`, { id: pullRequestId });
3735
+ }
3736
+ export async function rebaseDirtyPullRequest(opts) {
3737
+ const { issue, branch, worktree, prNumber, log, run = shellOps.exec, pathExists = existsSync } = opts;
3738
+ if (!pathExists(worktree)) {
3739
+ const msg = `PR #${prNumber} DIRTY on ${branch} and worktree gone`;
3740
+ log('conflict', msg);
3741
+ throw new LandConflictError(msg);
3742
+ }
3743
+ try {
3744
+ await run('git rebase origin/main', { cwd: worktree });
3745
+ await run(`git push --force-with-lease origin ${shellEscape(branch)}`, { cwd: worktree });
3746
+ }
3747
+ catch {
3748
+ // Best-effort cleanup: the conflict logged below is the error we surface.
3749
+ await run('git rebase --abort', { cwd: worktree }).catch(() => { });
3750
+ const msg = `rebase conflict on ${branch} — parked`;
3751
+ log('conflict', msg);
3752
+ throw new LandConflictError(`issue #${issue}: ${msg}`);
3753
+ }
3754
+ }
3755
+ export async function landOpenPullRequest(opts) {
3756
+ const { octokit, owner, repoName, issue, branch, worktree, prNumber, log, run, pathExists, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), skipCI = false, adminMerge = process.env.FACTORY_MERGE_ADMIN === '1', watch = internalOps.watchChecks, withLock = (fn) => fn(), ensureWorktree, } = opts;
3757
+ /** Watches CI on the PR's current head commit and returns that SHA once it is green, so the
3758
+ * merge can be pinned to exactly the commit CI verified. */
3759
+ const watchCi = async () => {
3760
+ let headSha;
3761
+ let outcome;
3762
+ try {
3763
+ const { data: pr } = await octokit.rest.pulls.get({ owner, repo: repoName, pull_number: prNumber });
3764
+ headSha = pr.head.sha;
3765
+ outcome = await watch({ octokit, owner, repo: repoName, ref: headSha });
3766
+ }
3767
+ catch (err) {
3768
+ const msg = `CI watch for ${branch} failed: ${errorDetail(err)} — refusing to merge without a green verdict`;
3769
+ log('ci-failed', msg);
3770
+ throw new CiUnverifiedError(msg, prNumber);
3771
+ }
3772
+ if (outcome === 'success')
3773
+ return headSha;
3774
+ if (outcome === 'failure') {
3775
+ const msg = `CI failed for PR #${prNumber} on ${branch} — refusing to merge with a failing check`;
3776
+ log('ci-failed', msg);
3777
+ throw new CiFailedError(msg, prNumber);
3778
+ }
3779
+ const msg = `CI watch for ${branch} ended ${outcome} — refusing to merge without a green verdict`;
3780
+ log('ci-failed', msg);
3781
+ throw new CiUnverifiedError(msg, prNumber);
3782
+ };
3783
+ // The CI watch is a 10-20 minute wait, not a mutation: it must never run under the land
3784
+ // locks, or every other lane's setupWorktree serializes behind it (#645).
3785
+ let verifiedSha = skipCI ? undefined : await watchCi();
3786
+ let state = await getPullRequestLandState(octokit, owner, repoName, prNumber);
3787
+ if (state.mergeStateStatus === 'DIRTY') {
3788
+ const rebased = await withLock(async () => {
3789
+ // Re-check under the lock: a sibling lane may have landed (or rebased this branch)
3790
+ // while we waited for it, so the DIRTY verdict read above can be stale.
3791
+ state = await getPullRequestLandState(octokit, owner, repoName, prNumber);
3792
+ if (state.mergeStateStatus !== 'DIRTY')
3793
+ return false;
3794
+ await ensureWorktree?.();
3795
+ await rebaseDirtyPullRequest({ issue, branch, worktree, prNumber, log, run, pathExists });
3796
+ return true;
3797
+ });
3798
+ if (rebased) {
3799
+ if (!skipCI) {
3800
+ verifiedSha = await watchCi();
3801
+ }
3802
+ state = await getPullRequestLandState(octokit, owner, repoName, prNumber);
3803
+ }
3804
+ }
3805
+ for (let attempt = 1;; attempt++) {
3806
+ try {
3807
+ await withLock(async () => {
3808
+ // Same rule as the rebase section: whatever we read before taking the lock (or before
3809
+ // the backoff sleep) may be stale, so the attempt decides from a fresh read.
3810
+ state = await getPullRequestLandState(octokit, owner, repoName, prNumber);
3811
+ if (state.isDraft && state.id) {
3812
+ log('land', `PR #${prNumber} still a draft — re-issuing ready-for-review (attempt ${attempt})`);
3813
+ await markPullRequestReady(octokit, state.id).catch((err) => log('warn', `ready-for-review flip failed for PR #${prNumber}: ${errorDetail(err)}`));
3814
+ }
3815
+ await squashMergeAndDelete(octokit, owner, repoName, branch, prNumber, {
3816
+ admin: adminMerge,
3817
+ run,
3818
+ sha: verifiedSha,
3819
+ });
3820
+ });
3821
+ return;
3822
+ }
3823
+ catch (err) {
3824
+ if (verifiedSha && isHeadModifiedMergeError(err)) {
3825
+ const msg = `PR #${prNumber} head moved past CI-verified commit ${verifiedSha} — refusing to merge an unverified head`;
3826
+ log('ci-failed', msg);
3827
+ throw new CiUnverifiedError(msg, prNumber);
3828
+ }
3829
+ if (isReviewRequiredMergeError(err, state)) {
3830
+ log('awaiting-review', `PR #${prNumber} blocked on human review — leaving open for approval`);
3831
+ throw new AwaitingReviewError(`PR #${prNumber} awaiting review: ${err.message}`, prNumber);
3832
+ }
3833
+ if (attempt >= MAX_MERGE_ATTEMPTS)
3834
+ throw err;
3835
+ log('land', `merge attempt ${attempt}/${MAX_MERGE_ATTEMPTS} failed (${err.message}); mergeStateStatus=${state.mergeStateStatus ?? 'unknown'} — retrying with backoff`);
3836
+ // Backoff outside the lock: waiting is not a mutation (#645).
3837
+ await sleep(MERGE_RETRY_BASE_MS * 2 ** (attempt - 1));
3838
+ }
3839
+ }
3840
+ }
3841
+ export function issueFromFactoryBranch(branch, prefix) {
3842
+ return factoryBranchIssue(branch, prefix) ?? undefined;
3843
+ }
3844
+ export async function listOpenFactoryPRs(octokit, owner, repoName, branchPrefix) {
3845
+ const prs = [];
3846
+ let cursor;
3847
+ for (;;) {
3848
+ const result = await octokit.graphql(`query OpenFactoryPRs($owner: String!, $repo: String!, $cursor: String) {
3849
+ repository(owner: $owner, name: $repo) {
3850
+ pullRequests(states: OPEN, first: 100, after: $cursor) {
3851
+ pageInfo { hasNextPage endCursor }
3852
+ nodes { number headRefName reviewDecision isDraft }
3853
+ }
3854
+ }
3855
+ }`, { owner, repo: repoName, cursor });
3856
+ const pullRequests = result.repository?.pullRequests;
3857
+ for (const node of pullRequests?.nodes ?? []) {
3858
+ if (factoryBranchPrefixes(branchPrefix).some((p) => node.headRefName.startsWith(`${p}/`))) {
3859
+ prs.push({
3860
+ number: node.number,
3861
+ branch: node.headRefName,
3862
+ reviewDecision: node.reviewDecision,
3863
+ isDraft: node.isDraft,
3864
+ });
3865
+ }
3866
+ }
3867
+ if (!pullRequests?.pageInfo?.hasNextPage)
3868
+ break;
3869
+ cursor = pullRequests.pageInfo.endCursor;
3870
+ }
3871
+ return prs;
3872
+ }
3873
+ export async function sweepApprovedPRs(repoRoot, ghRepo, paths, deps = {}, opts = {}) {
3874
+ const { createOctokit = getOctokit, loadConfig = configLoaders.loadFactoryConfigForRepo, listPRs = listOpenFactoryPRs, land = landIssue, emitEvent = logEvent, writeLine = (line) => console.log(line), } = deps;
3875
+ const [owner, repoName] = ghRepo.split('/');
3876
+ const octokit = createOctokit();
3877
+ const skipCI = configLoaders.resolveSkipCI(loadConfig(paths.config));
3878
+ const prs = await listPRs(octokit, owner, repoName, opts.branchPrefix);
3879
+ const landed = [];
3880
+ const skipped = [];
3881
+ const failed = [];
3882
+ for (const pr of prs) {
3883
+ const issue = issueFromFactoryBranch(pr.branch, opts.branchPrefix);
3884
+ if (issue === undefined) {
3885
+ const reason = 'no issue number in branch';
3886
+ skipped.push({ pr: pr.number, branch: pr.branch, reason });
3887
+ writeLine(`[factory] PR #${pr.number} (${pr.branch}) skipped: ${reason}`);
3888
+ continue;
3889
+ }
3890
+ if (pr.reviewDecision !== 'APPROVED') {
3891
+ const reason = `not approved (reviewDecision: ${pr.reviewDecision ?? 'none'})`;
3892
+ skipped.push({ pr: pr.number, branch: pr.branch, reason });
3893
+ writeLine(`[factory] PR #${pr.number} (${pr.branch}) skipped: ${reason}`);
3894
+ continue;
3895
+ }
3896
+ try {
3897
+ await land(issue, repoRoot, ghRepo, paths, octokit, skipCI, opts.branchPrefix);
3898
+ landed.push(pr.number);
3899
+ writeLine(`[factory] PR #${pr.number} (${pr.branch}) landed for issue #${issue}`);
3900
+ }
3901
+ catch (err) {
3902
+ const reason = errorDetail(err);
3903
+ failed.push({ pr: pr.number, issue, reason });
3904
+ writeLine(`[factory] PR #${pr.number} (${pr.branch}) failed to land: ${reason}`);
3905
+ continue;
3906
+ }
3907
+ }
3908
+ emitEvent(paths.events, 'resume-approved', '-', `sweep: ${landed.length} landed, ${skipped.length} skipped, ${failed.length} failed of ${prs.length} open factory PRs`);
3909
+ return { landed, skipped, failed };
3910
+ }
3911
+ export async function cmdResumeApproved(opts = {}) {
3912
+ const branchPrefix = branchPrefixOrExit(opts.branchPrefix);
3913
+ const repoRoot = await getRepoRoot();
3914
+ const ghRepo = await getGitHubRepo();
3915
+ const paths = getFactoryPaths(repoRoot);
3916
+ const result = await sweepApprovedPRs(repoRoot, ghRepo, paths, {}, { branchPrefix });
3917
+ console.log(styleText('green', `✅ resume-approved: ${result.landed.length} landed, ${result.skipped.length} skipped, ${result.failed.length} failed`));
3918
+ if (result.failed.length > 0) {
3919
+ throw new CliExitError(`factory: resume-approved: ${result.failed.length} PR(s) failed to land`, 5);
3920
+ }
3921
+ }
3922
+ /** Consecutive merged-state check failures tolerated before waitForMerge parks the lane.
3923
+ * At the shipped 120s poll that is ~20 minutes of 100% failure (ADR: sustained-failure park). */
3924
+ export const MERGE_CHECK_MAX_CONSECUTIVE_FAILURES = 10;
3925
+ /** Wall-clock backstop: continuous merged-state check failure for this long parks the lane even
3926
+ * if fewer than MERGE_CHECK_MAX_CONSECUTIVE_FAILURES attempts were made (slower poll cadence). */
3927
+ export const MERGE_CHECK_FAILURE_BUDGET_MS = 2 * 60 * 60 * 1000;
3928
+ /** GitHub signals both primary and secondary rate limits with 403 — the same status it uses for a
3929
+ * token that genuinely lacks the scope. Only a 403 with no rate-limit evidence is permanent. */
3930
+ function looksRateLimited(err) {
3931
+ const headers = err?.response?.headers ?? {};
3932
+ const remaining = headers['x-ratelimit-remaining'];
3933
+ if (typeof remaining === 'string' && remaining.trim() === '0')
3934
+ return true;
3935
+ if (typeof remaining === 'number' && remaining === 0)
3936
+ return true;
3937
+ if (headers['retry-after'] !== undefined)
3938
+ return true;
3939
+ return /rate limit|secondary rate|abuse detection/i.test(errorDetail(err));
3940
+ }
3941
+ /** True when a merged-state check failure cannot recover by retrying: an expired/revoked token
3942
+ * (401) or a token missing `pulls:read` (403 with no rate-limit evidence). */
3943
+ export function isPermanentMergeCheckError(err) {
3944
+ const status = err?.status;
3945
+ if (status === 401)
3946
+ return true;
3947
+ if (status !== 403)
3948
+ return false;
3949
+ return !looksRateLimited(err);
3950
+ }
3951
+ async function defaultListIssueLabels(octokit, owner, repo, issue) {
3952
+ const { data } = await octokit.rest.issues.listLabelsOnIssue({ owner, repo, issue_number: issue });
3953
+ return data.map((label) => label.name);
3954
+ }
3955
+ export async function waitForMerge(issue, branch, repoRoot, ghRepo, paths, deps = {}) {
3956
+ const { createOctokit = getOctokit, pathExists = existsSync, checkMerged = isPrMerged, loadConfig = configLoaders.loadFactoryConfigForRepo, land = landIssue, listIssueLabels = defaultListIssueLabels, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), emitEvent = logEvent, mergeEnabled, writeLine = (line) => console.log(line), now = () => Date.now(), mergeOverrides, } = deps;
3957
+ const factoryConfig = loadConfig(paths.config);
3958
+ const isMergeEnabled = mergeEnabled ?? (() => resolveMergePolicy(factoryConfig, process.env, mergeOverrides).auto);
3959
+ const skipCI = configLoaders.resolveSkipCI(factoryConfig);
3960
+ const filingPolicy = resolveFilingPolicy(factoryConfig);
3961
+ const octokit = createOctokit();
3962
+ const [owner, repoName] = ghRepo.split('/');
3963
+ emitEvent(paths.events, 'await-merge', issue, `waiting to merge ${branch}`);
3964
+ let consecutiveFailures = 0;
3965
+ let firstFailureAt = null;
3966
+ while (!pathExists(paths.stop)) {
3967
+ // The merge wait logs nothing on its happy path, so each poll bumps the per-issue heartbeat:
3968
+ // status/TUI staleness rules (ADR-0086, ADR-0093) otherwise read a live wait as a dead run.
3969
+ if (paths.runs)
3970
+ touchRunActivity(phaseSnapshotFile(paths.runs, issue), new Date().toISOString()).catch(() => { });
3971
+ let merged = false;
3972
+ try {
3973
+ merged = await checkMerged(octokit, owner, repoName, branch);
3974
+ consecutiveFailures = 0;
3975
+ firstFailureAt = null;
3976
+ }
3977
+ catch (err) {
3978
+ consecutiveFailures++;
3979
+ firstFailureAt ??= now();
3980
+ const detail = errorDetail(err);
3981
+ emitEvent(paths.events, 'warn', issue, `merged-state check failed (treating as not merged): ${detail}`);
3982
+ const permanent = isPermanentMergeCheckError(err);
3983
+ const elapsedMs = now() - firstFailureAt;
3984
+ const budgetSpent = elapsedMs >= MERGE_CHECK_FAILURE_BUDGET_MS;
3985
+ const streakSpent = consecutiveFailures >= MERGE_CHECK_MAX_CONSECUTIVE_FAILURES;
3986
+ if (permanent || streakSpent || budgetSpent) {
3987
+ const trigger = permanent
3988
+ ? 'permanent error (auth/scope — retrying cannot help)'
3989
+ : `${consecutiveFailures} consecutive failures over ${Math.round(elapsedMs / 1000)}s`;
3990
+ const msg = `merged-state check for ${branch} is not recovering — parking lane after ${trigger}: ${detail}`;
3991
+ emitEvent(paths.events, 'escalate', issue, msg);
3992
+ throw new LaneParkError(msg, 'escalate');
3993
+ }
3994
+ }
3995
+ if (merged) {
3996
+ emitEvent(paths.events, 'landed', issue, 'PR merged');
3997
+ return;
3998
+ }
3999
+ if (isMergeEnabled()) {
4000
+ let labels = [];
4001
+ try {
4002
+ labels = await listIssueLabels(octokit, owner, repoName, issue);
4003
+ }
4004
+ catch (err) {
4005
+ emitEvent(paths.events, 'warn', issue, `label check failed (treating as blocked): ${errorDetail(err)}`);
4006
+ labels = [filingPolicy.selfFixLabel];
4007
+ }
4008
+ if (isAutoMergeBlocked(labels, filingPolicy)) {
4009
+ emitEvent(paths.events, 'merge-gated', issue, `auto-merge blocked by ${filingPolicy.selfFixLabel} — awaiting human approval`);
4010
+ writeLine(`[factory] #${issue} auto-merge gated (${filingPolicy.selfFixLabel}); awaiting human merge (poll 120s)`);
4011
+ await sleep(120_000);
4012
+ continue;
4013
+ }
4014
+ await land(issue, repoRoot, ghRepo, paths, octokit, skipCI);
4015
+ return;
4016
+ }
4017
+ writeLine(`[factory] #${issue} awaiting human merge (poll 120s)`);
4018
+ await sleep(120_000);
4019
+ }
4020
+ }
4021
+ const USAGE_UNAVAILABLE_LINE = '[factory] supervise: usage signal unavailable — proceeding without resume gate (set FACTORY_USAGE_ESTIMATOR=1 to gate on the heuristic)';
4022
+ export async function superviseLoop(deps) {
4023
+ const { cap, resumeAt, pollMs, watch = true, watchSource = 'default', resumeAtSource = 'default', pollSource = 'default', estimator = false, stopFile, eventsFile, now, runQueue, readUsageFn = () => readUsage({ cap, estimator }), pathExists = existsSync, clearStop = (path) => rmSync(path, { force: true }), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), emitEvent = logEvent, writeLine = (line) => console.log(line), ingest, } = deps;
4024
+ let cycle = 0;
4025
+ if (resumeAtSource === 'flag') {
4026
+ const detail = 'usage watchdog threshold set by flag: --usage-threshold';
4027
+ writeLine(`[factory] supervise: ${detail}`);
4028
+ emitEvent(eventsFile, 'watchdog', 'usage', detail);
4029
+ }
4030
+ if (pollSource === 'flag') {
4031
+ const detail = 'usage watchdog poll set by flag: --usage-poll';
4032
+ writeLine(`[factory] supervise: ${detail}`);
4033
+ emitEvent(eventsFile, 'watchdog', 'usage', detail);
4034
+ }
4035
+ while (true) {
4036
+ cycle++;
4037
+ let pct = 0;
4038
+ let source = 'unavailable';
4039
+ if (!watch) {
4040
+ writeLine(`[factory] supervise: usage watchdog disabled (${usageWatchSourceLabel(watchSource, watch)}) — skipping resume gate`);
4041
+ }
4042
+ else {
4043
+ if (watchSource === 'flag') {
4044
+ writeLine(`[factory] supervise: usage watchdog enabled (${usageWatchSourceLabel(watchSource, watch)}) — enforcing resume gate`);
4045
+ }
4046
+ let reading = await readUsageFn();
4047
+ if (reading === null) {
4048
+ writeLine(USAGE_UNAVAILABLE_LINE);
4049
+ }
4050
+ else {
4051
+ pct = reading.pct;
4052
+ source = reading.source;
4053
+ if (cycle > 1 || !now) {
4054
+ while (pct >= resumeAt) {
4055
+ writeLine(`[factory] supervise: trailing usage (${source}) ${Math.round(pct * 100)}% >= resume gate ${Math.round(resumeAt * 100)}% — waiting ${pollMs / 1000}s`);
4056
+ await sleep(pollMs);
4057
+ reading = await readUsageFn();
4058
+ if (reading === null) {
4059
+ writeLine(USAGE_UNAVAILABLE_LINE);
4060
+ pct = 0;
4061
+ source = 'unavailable';
4062
+ break;
4063
+ }
4064
+ pct = reading.pct;
4065
+ source = reading.source;
4066
+ }
4067
+ }
4068
+ }
4069
+ }
4070
+ emitEvent(eventsFile, 'resumed', 'usage', `supervise cycle ${cycle}: trailing usage (${source}) at ${Math.round(pct * 100)}% of cap — starting run`);
4071
+ clearStop(stopFile);
4072
+ if (ingest) {
4073
+ const appended = await ingest();
4074
+ if (appended > 0)
4075
+ writeLine(`[factory] supervise: auto-ingest appended ${appended} ready issue(s)`);
4076
+ }
4077
+ await runQueue();
4078
+ if (pathExists(stopFile))
4079
+ continue;
4080
+ if (ingest) {
4081
+ await sleep(pollMs);
4082
+ continue;
4083
+ }
4084
+ break;
4085
+ }
4086
+ emitEvent(eventsFile, 'supervisor-done', 'usage', 'supervise finished — queue drained or lanes need attention');
4087
+ }
4088
+ export function getCliVersion() {
4089
+ return createRequire(import.meta.url)('../../package.json').version;
4090
+ }
4091
+ function toLeaseRow(health) {
4092
+ return {
4093
+ worktreeId: health.lease.worktreeId,
4094
+ branch: health.lease.branch,
4095
+ port: health.lease.port,
4096
+ pid: health.lease.pid,
4097
+ alive: health.alive,
4098
+ reason: health.reason,
4099
+ portSquatted: health.portSquatted,
4100
+ };
4101
+ }
4102
+ function toGreenPrRow(pr) {
4103
+ return { number: pr.number, branch: pr.branch, issue: pr.issue, ageHours: pr.ageHours };
4104
+ }
4105
+ async function scanGreenPrs(ghRepo, octokit) {
4106
+ if (!ghRepo || !octokit)
4107
+ return { status: 'skipped', detail: 'skipped — no GitHub repo or token' };
4108
+ const [owner, repoName] = ghRepo.split('/');
4109
+ try {
4110
+ const prs = await internalOps.findUnmergedGreenPrs({
4111
+ client: createOctokitGreenPrClient(octokit),
4112
+ owner,
4113
+ repo: repoName,
4114
+ });
4115
+ return { status: 'ok', rows: prs.map(toGreenPrRow) };
4116
+ }
4117
+ catch (err) {
4118
+ return { status: 'failed', detail: err?.message ?? String(err) };
4119
+ }
4120
+ }
4121
+ function probeClaudeKeychain() {
4122
+ if (process.platform !== 'darwin')
4123
+ return { status: 'skipped', detail: 'skipped — not macOS' };
4124
+ if (!coreOps.isCommandAvailable('claude'))
4125
+ return { status: 'skipped', detail: 'skipped — claude CLI not on PATH' };
4126
+ if (process.env.ANTHROPIC_API_KEY)
4127
+ return { status: 'skipped', detail: 'skipped — ANTHROPIC_API_KEY auth in use' };
4128
+ // Check the entry for the profile child `claude` runs will use (CLAUDE_CONFIG_DIR), not
4129
+ // always the default one.
4130
+ const service = claudeKeychainService();
4131
+ const profile = { service, configDir: claudeConfigDirOverride() };
4132
+ try {
4133
+ shellOps.execSync(`security find-generic-password -s ${shellEscape(service)}`, {
4134
+ timeout: 10_000,
4135
+ stdio: 'ignore',
4136
+ });
4137
+ return { status: 'readable', ...profile };
4138
+ }
4139
+ catch {
4140
+ return { status: 'unreadable', inTmux: !!process.env.TMUX, ...profile };
4141
+ }
4142
+ }
4143
+ async function cmdDoctor(opts = {}) {
4144
+ const checks = runDoctorChecks({
4145
+ commandAvailable: coreOps.isCommandAvailable,
4146
+ envPresent: (key) => !!process.env[key],
4147
+ tryExec: (cmd) => {
4148
+ try {
4149
+ return shellOps.execSync(cmd, { timeout: 10_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
4150
+ }
4151
+ catch {
4152
+ return null;
4153
+ }
4154
+ },
4155
+ pathExists: existsSync,
4156
+ distFreshness: distFreshnessProbe(import.meta.url),
4157
+ });
4158
+ let repoRoot;
4159
+ try {
4160
+ repoRoot = shellOps
4161
+ .execSync('git rev-parse --show-toplevel', {
4162
+ timeout: 10_000,
4163
+ stdio: ['ignore', 'pipe', 'ignore'],
4164
+ })
4165
+ .trim();
4166
+ }
4167
+ catch {
4168
+ repoRoot = null;
4169
+ }
4170
+ const CLAUDE_AUTH_PROBE = 'claude -p "reply with exactly: ok"';
4171
+ const probeExec = (cmd) => {
4172
+ try {
4173
+ return shellOps.execSync(cmd, { timeout: 120_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
4174
+ }
4175
+ catch {
4176
+ return null;
4177
+ }
4178
+ };
4179
+ let host = 'skipped';
4180
+ let hostDetail = 'skipped — claude CLI not on PATH';
4181
+ if (coreOps.isCommandAvailable('claude')) {
4182
+ host = probeExec(CLAUDE_AUTH_PROBE) === null ? 'failed' : 'ok';
4183
+ hostDetail = host === 'ok' ? 'claude -p succeeded on the host' : 'claude -p failed on the host';
4184
+ }
4185
+ let sandboxed = 'skipped';
4186
+ let sandboxDetail = 'skipped — host claude auth was not verified';
4187
+ if (host === 'ok') {
4188
+ const policy = repoRoot === null
4189
+ ? undefined
4190
+ : resolveSandboxPolicy(configLoaders.loadFactoryConfigForRepo(getFactoryPaths(repoRoot).config).sandbox, {
4191
+ worktree: repoRoot,
4192
+ repoRoot,
4193
+ isAvailable: (cmd) => isCommandAvailableViaShell(shellOps, cmd),
4194
+ });
4195
+ if (!policy) {
4196
+ sandboxDetail = 'skipped — sandbox disabled by config or FACTORY_SANDBOX';
4197
+ }
4198
+ else if (policy.runtime === 'none') {
4199
+ sandboxDetail = 'skipped — no sandbox runtime (sandbox-exec/firejail) on this host';
4200
+ }
4201
+ else if (policy.runtime === 'docker-sandbox') {
4202
+ sandboxDetail = 'skipped — docker-sandbox containment is the microVM, not command wrapping (#653)';
4203
+ }
4204
+ else {
4205
+ sandboxed = probeExec(wrapCommandInSandbox(CLAUDE_AUTH_PROBE, policy)) === null ? 'failed' : 'ok';
4206
+ sandboxDetail =
4207
+ sandboxed === 'ok'
4208
+ ? `claude -p succeeded under ${policy.runtime}`
4209
+ : `claude -p failed under ${policy.runtime} (local_auth)`;
4210
+ }
4211
+ }
4212
+ checks.push(claudeKeychainCheck(probeClaudeKeychain()));
4213
+ checks.push(...sandboxClaudeAuthChecks({ host, sandboxed, hostDetail, sandboxDetail }));
4214
+ if (repoRoot !== null) {
4215
+ const paths = getFactoryPaths(repoRoot);
4216
+ const factoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
4217
+ const health = await inspectPortLeases({ registryFile: paths.ports });
4218
+ checks.push(...leaseChecks(health.map(toLeaseRow)));
4219
+ const dockerAvailable = coreOps.isCommandAvailable('docker');
4220
+ const orphanContainers = dockerAvailable ? await internalOps.listOrphanContainers().catch(() => []) : [];
4221
+ checks.push(...orphanContainerChecks(orphanContainers.map((c) => c.name)));
4222
+ const sbxAvailable = coreOps.isCommandAvailable('sbx');
4223
+ const activeVmNames = new Set(health.filter((h) => h.alive).map((h) => microVmName(h.lease.worktreeId)));
4224
+ const orphanVmNames = sbxAvailable ? (await internalOps.listMicroVms()).filter((n) => !activeVmNames.has(n)) : [];
4225
+ checks.push(...orphanMicroVmChecks(orphanVmNames));
4226
+ const sweepStatus = checkSweepHeartbeat(factoryConfig.sweep, process.env, defaultSweepHeartbeatDeps());
4227
+ const sweepCheck = sweepHeartbeatCheck(sweepStatus);
4228
+ if (sweepCheck)
4229
+ checks.push(sweepCheck);
4230
+ checks.push(stopSentinelCheck(readStopFileStatus(paths)));
4231
+ const eventsContent = existsSync(paths.events) ? readFileSync(paths.events, 'utf-8') : null;
4232
+ checks.push(eventLogCheck(eventsContent === null ? null : analyzeEventLog(eventsContent)));
4233
+ // Full run-lifecycle reap (#998): dead-run worktrees and their branches. Uses the same
4234
+ // GitHub evidence and the same lock pairing as `factory worktree gc` (cmdWorktreeGc) —
4235
+ // a merged or closed PR is what makes a worktree reapable.
4236
+ const ghRepo = await getGitHubRepo().catch(() => undefined);
4237
+ const octokit = ghRepo && hasGitHubToken() ? getOctokit() : undefined;
4238
+ // Green-and-ready PRs with no merge (#1000). Report only — doctor never lands a PR.
4239
+ checks.push(...unmergedGreenPrChecks(await scanGreenPrs(ghRepo, octokit)));
4240
+ if (opts.reconcile) {
4241
+ if (orphanContainers.length > 0) {
4242
+ const reapedContainers = await internalOps.reapOrphanContainers(orphanContainers);
4243
+ console.log(formatContainerReconcileReport(reapedContainers));
4244
+ }
4245
+ if (orphanVmNames.length > 0) {
4246
+ const reapedVms = await Promise.all(orphanVmNames.map((name) => internalOps.reapOrphanMicroVm(name)));
4247
+ console.log(formatMicroVmReconcileReport(reapedVms));
4248
+ }
4249
+ const reaped = await reapStalePortLeases({ registryFile: paths.ports, lockDir: paths.portsLock });
4250
+ console.log(formatReconcileReport(reaped));
4251
+ if (reaped.length > 0) {
4252
+ const graceMs = resolveProcessGroupGraceMs(factoryConfig);
4253
+ const orphanEvents = await coreOps.reapOrphanProcesses({ reaped, graceMs });
4254
+ for (const e of orphanEvents) {
4255
+ console.log(`reconcile: ${e.action === 'killed' ? 'killed' : 'found'} pid ${e.pid} (pgid ${e.pgid}, ${e.command}) squatting port ${e.port} of dead lane ${e.worktreeId}${e.action === 'reported' ? ' — not factory-started, left running' : ''}`);
4256
+ }
4257
+ }
4258
+ try {
4259
+ const gcLog = (type, msg) => logEvent(paths.events, type, '-', msg);
4260
+ const gcSandbox = gcWorktreeSandbox(factoryConfig.sandbox, repoRoot);
4261
+ const report = await withGitLock(repoRoot, () => withFileLock(paths.gitLock, () => internalOps.sweepWorktrees({
4262
+ repoRoot,
4263
+ ttlDays: factoryConfig.worktree.gcTtlDays,
4264
+ repo: ghRepo,
4265
+ branchPrefix: resolveBranchPrefix(),
4266
+ }, { log: gcLog, octokit, sandbox: gcSandbox })));
4267
+ console.log(formatWorktreeReconcileReport(report.removed));
4268
+ }
4269
+ catch (err) {
4270
+ console.log(`reconcile: worktree sweep failed — ${err?.message ?? String(err)}`);
4271
+ }
4272
+ // Stale claim reap (#999): factory:in-progress claims naming a dead local pid go back to
4273
+ // factory:queued so the next run can claim them with no manual label surgery.
4274
+ try {
4275
+ if (ghRepo && octokit) {
4276
+ const [owner, repoName] = ghRepo.split('/');
4277
+ const released = await internalOps.releaseStaleClaims({
4278
+ client: createOctokitQueueClient(octokit),
4279
+ owner,
4280
+ repo: repoName,
4281
+ });
4282
+ console.log(formatClaimReconcileReport(released));
4283
+ }
4284
+ else {
4285
+ console.log('reconcile: skipping stale claims — no GitHub repo or token');
4286
+ }
4287
+ }
4288
+ catch (err) {
4289
+ console.log(`reconcile: stale claim release failed — ${err?.message ?? String(err)}`);
4290
+ }
4291
+ }
4292
+ }
4293
+ console.log(formatDoctorChecks(checks));
4294
+ if (doctorFailed(checks))
4295
+ process.exit(1);
4296
+ }
4297
+ /** Appends one event to the events log without echoing it to stdout (stop/resume --json must print only JSON). */
4298
+ function logEventQuietly(eventsFile, type, msg) {
4299
+ createLogger(eventsFile, { issue: '-' }, { out: { write: () => true } }).warn(type, msg);
4300
+ }
4301
+ /** Creates the STOP file in one exclusive open (no check-then-write race). Returns false when it already existed. */
4302
+ function createStopFile(stopFile) {
4303
+ try {
4304
+ writeFileSync(stopFile, '', { flag: 'wx' });
4305
+ return true;
4306
+ }
4307
+ catch (err) {
4308
+ if (err?.code === 'EEXIST')
4309
+ return false;
4310
+ throw err;
4311
+ }
4312
+ }
4313
+ /** Removes the STOP file in one unlink (no check-then-unlink race). Returns false when it was not there. */
4314
+ function removeStopFile(stopFile) {
4315
+ try {
4316
+ unlinkSync(stopFile);
4317
+ return true;
4318
+ }
4319
+ catch (err) {
4320
+ if (err?.code === 'ENOENT')
4321
+ return false;
4322
+ throw err;
4323
+ }
4324
+ }
4325
+ export async function cmdStop(opts = {}) {
4326
+ const repoRoot = await getRepoRoot();
4327
+ const paths = getFactoryPaths(repoRoot);
4328
+ const json = opts.json ? { repo: await getGitHubRepo() } : undefined;
4329
+ ensureDir(paths.state);
4330
+ const before = !createStopFile(paths.stop);
4331
+ const after = true;
4332
+ logEventQuietly(paths.events, 'stop-file-set', before ? 'factory stop: STOP already set (no-op)' : 'factory stop: STOP set — lanes halt between issues');
4333
+ if (json) {
4334
+ console.log(JSON.stringify(buildStopResumeJson({ action: 'stop', repo: json.repo, before, after })));
4335
+ return;
4336
+ }
4337
+ console.log('STOP set — lanes halt between issues');
4338
+ }
4339
+ export async function cmdResume(opts = {}) {
4340
+ const repoRoot = await getRepoRoot();
4341
+ const paths = getFactoryPaths(repoRoot);
4342
+ const json = opts.json ? { repo: await getGitHubRepo() } : undefined;
4343
+ const before = removeStopFile(paths.stop);
4344
+ const after = false;
4345
+ if (existsSync(paths.root)) {
4346
+ logEventQuietly(paths.events, 'stop-file-cleared', before ? 'factory resume: STOP cleared' : 'factory resume: STOP was not set (no-op)');
4347
+ }
4348
+ if (json) {
4349
+ console.log(JSON.stringify(buildStopResumeJson({ action: 'resume', repo: json.repo, before, after })));
4350
+ return;
4351
+ }
4352
+ console.log('STOP cleared');
4353
+ }
4354
+ // ---------- main ----------
4355
+ export async function main(argv = process.argv, deps = {}) {
4356
+ if (argv.slice(2).length === 0) {
4357
+ console.log(formatOverview());
4358
+ return;
4359
+ }
4360
+ const staleExit = runStalenessGuard({
4361
+ entryUrl: import.meta.url,
4362
+ env: process.env,
4363
+ argv,
4364
+ error: (m) => console.error(styleText('red', m)),
4365
+ warn: (m) => console.error(styleText('yellow', m)),
4366
+ });
4367
+ if (staleExit !== null) {
4368
+ process.exitCode = staleExit;
4369
+ return;
4370
+ }
4371
+ const program = new Command();
4372
+ program
4373
+ .name('factory')
4374
+ .description('Multi-agent software factory with boss-worker-checker orchestration')
4375
+ .version(getCliVersion())
4376
+ .addHelpText('before', PREREQUISITES_TEXT)
4377
+ .addHelpText('after', HELP_FOOTER_TEXT);
4378
+ program
4379
+ .command('init')
4380
+ .description('Set up .factory/ in this repo (config, constitution, queue file, .gitignore) and check model reachability')
4381
+ .option('--force', 'Overwrite an existing .factory config, constitution, and .gitignore')
4382
+ .action((opts) => cmdInit(opts));
4383
+ program
4384
+ .command('migrate')
4385
+ .description('Upgrade .factory/ to the v2 layout (config, constitution.md, .gitignore, state/); --to-yaml converts config.json to config.yaml')
4386
+ .option('--dry-run', 'Print planned changes without writing')
4387
+ .option('--to-yaml', 'Convert .factory/config.json to .factory/config.yaml (removes the JSON only after a round-trip check)')
4388
+ .action((opts) => cmdMigrate(opts));
4389
+ program
4390
+ .command('constitution')
4391
+ .description('Manage product constitutions — .factory/constitution.md in this repo is the source of truth')
4392
+ .option('--init [product]', 'Write .factory/constitution.md in this repo, scaffolded from the template (an optional name overrides the display name)')
4393
+ .option('--list', 'List available constitutions')
4394
+ .option('--product <name>', 'Seed .factory/constitution.md from the bundled example constitution for <name>')
4395
+ .option('--force', 'With --init or --product, overwrite an existing .factory/constitution.md')
4396
+ .action((opts) => cmdConstitution(opts));
4397
+ program
4398
+ .command('models')
4399
+ .description('List models with their tiers, cost per 1M tokens, and availability')
4400
+ .option('--doctor', 'Probe provider CLIs and env keys; report per-model reachability')
4401
+ .action(cmdModels);
4402
+ program
4403
+ .command('doctor')
4404
+ .description('Check your environment (git, claude, gh auth, GitHub token, npm, sandbox, .factory/)')
4405
+ .option('--reconcile', 'Reap stale port leases, dead-run worktrees, stale issue claims, orphan sf-job-*/factory.managed containers, and orphan factory-* sbx VMs')
4406
+ .action((opts) => cmdDoctor(opts));
4407
+ program
4408
+ .command('cost')
4409
+ .description('Show recorded model spend by model (--issue for one issue)')
4410
+ .option('--issue <number>', 'Show per-entry detail for one issue')
4411
+ .option('--json', 'Print one JSON object (schemaVersion 1): rows, perModel, total; unpriced cost is null')
4412
+ .action((opts) => cmdCost(opts));
4413
+ program
4414
+ .command('usage')
4415
+ .description('Report 5-hour subscription usage (falls back to a list-price estimate)')
4416
+ .option('--json', 'Print one JSON object (schemaVersion 1) instead of text')
4417
+ .action((opts) => cmdUsage({}, opts));
4418
+ program
4419
+ .command('status')
4420
+ .description('Show active runs, the GitHub queue, provider health, and recent events')
4421
+ .option('--kpis', 'Also show the full health KPIs and the effective config')
4422
+ .option('--json', 'Print one JSON object (schemaVersion 1): repo, product, stop, breaker, active claims; --kpis is ignored')
4423
+ .action((opts) => cmdStatus(opts));
4424
+ program
4425
+ .command('runs')
4426
+ .description('Print per-issue run state from the local runs directory as one JSON object, newest first (read-only)')
4427
+ .option('--json', 'Print one JSON object (schemaVersion 1) — required')
4428
+ .option('--limit <n>', 'Show at most N runs (default 50)')
4429
+ .option('--diffstat <issue>', "Print per-file added/deleted line counts for one issue's run branch vs origin/<default branch>")
4430
+ .action(async (opts) => {
4431
+ await cmdRuns(opts);
4432
+ });
4433
+ program
4434
+ .command('kpis')
4435
+ .description('Compute factory health KPIs and record a trend snapshot')
4436
+ .option('--branch-prefix <prefix>', BRANCH_PREFIX_MATCH_OPTION_DESCRIPTION)
4437
+ .action(async (opts) => {
4438
+ await cmdKpis(opts);
4439
+ });
4440
+ const classifierCmd = program
4441
+ .command('classifier')
4442
+ .description('Inspect or backtest the shadow PR classifier track record');
4443
+ classifierCmd
4444
+ .command('report')
4445
+ .description('Confusion table, rule-of-three slip bounds and model-vs-floor agreement from classifier-outcomes.jsonl')
4446
+ .option('--json', 'Print one JSON object with the same numbers')
4447
+ .action((opts) => cmdClassifierReport(opts));
4448
+ classifierCmd
4449
+ .command('backtest')
4450
+ .description('Replay the floor and shadow classifier over merged factory PRs and print the outcome report')
4451
+ .requiredOption('--since <date>', 'Only PRs merged on or after this date (YYYY-MM-DD)')
4452
+ .option('--limit <n>', 'Classify at most N PRs (oldest merge first)')
4453
+ .option('--labels <file>', 'CSV of hand labels `pr,class` (class: defect|clean|gated) that override heuristic outcomes')
4454
+ .option('--max-cost <usd>', 'Stop before the next PR once estimated model spend reaches this many USD')
4455
+ .option('--branch-prefix <prefix>', BRANCH_PREFIX_MATCH_OPTION_DESCRIPTION)
4456
+ .option('--json', 'Print one JSON object instead of the table')
4457
+ .action((opts) => cmdClassifierBacktestEntry(opts));
4458
+ program
4459
+ .command('garden')
4460
+ .description('Read-only report of recurring failure patterns in local events, deduped against open issues by GitHub search')
4461
+ .option('--report', 'Print recurring CHECK failure-signature clusters as markdown')
4462
+ .option('--since <duration>', 'Only consider events from this window, e.g. 7d, 36h, 2w', DEFAULT_GARDEN_SINCE)
4463
+ .option('--max-clusters <n>', 'Print at most this many clusters', String(DEFAULT_GARDEN_MAX_CLUSTERS))
4464
+ .option('--json', 'Print the same cluster data as JSON')
4465
+ .option('--out <file>', 'Write the report to this file instead of stdout')
4466
+ .option('--only-new', 'Omit clusters an open issue already tracks; keep new and tracked: unknown clusters')
4467
+ .action((opts) => cmdGarden(opts));
4468
+ program
4469
+ .command('tui')
4470
+ .description('Live read-only view of the current run (q to quit)')
4471
+ .option('--local-queue', 'Read .factory/queue instead of claiming issues from GitHub Issues')
4472
+ .action((opts) => cmdTui(opts, deps.runTui ?? runTui));
4473
+ program
4474
+ .command('logs')
4475
+ .description('Print pipeline events from .factory/events.ndjson (--follow to tail)')
4476
+ .option('--follow', 'Keep watching for new events')
4477
+ .option('--json', 'Emit one JSON object per line')
4478
+ .option('--issue <number>', 'Only show events for this issue')
4479
+ .action(async (opts) => {
4480
+ const repoRoot = await getRepoRoot();
4481
+ const paths = getFactoryPaths(repoRoot);
4482
+ await cmdLogs(opts, { eventsFile: paths.events });
4483
+ });
4484
+ program
4485
+ .command('feedback <pr-url>')
4486
+ .description("File one factory issue (bug, factory:needs-triage) from a PR's human review findings")
4487
+ .option('--note <text>', 'Use this as the issue title instead of the first finding')
4488
+ .option('--branch-prefix <prefix>', BRANCH_PREFIX_MATCH_OPTION_DESCRIPTION)
4489
+ .action(cmdFeedback);
4490
+ const filingCmd = program.command('filing').description('Inspect upstream factory reports (nothing is sent)');
4491
+ filingCmd
4492
+ .command('preview <run-id>')
4493
+ .description('Print the exact redacted [factory-report] title and body that would be sent upstream; sends nothing')
4494
+ .option('--branch-prefix <prefix>', BRANCH_PREFIX_MATCH_OPTION_DESCRIPTION)
4495
+ .action((runId, opts) => cmdFilingPreview(runId, opts));
4496
+ const triage = program
4497
+ .command('triage')
4498
+ .description('Have a model propose a queue from open issues into .factory/queue.proposed')
4499
+ .option('--product <name>', 'Product constitution to scope triage')
4500
+ .action(cmdTriage);
4501
+ triage
4502
+ .command('accept')
4503
+ .description('Validate and atomically promote .factory/queue.proposed to .factory/queue')
4504
+ .option('--force', 'Skip validation and promote as-is')
4505
+ .action(async (opts) => {
4506
+ await cmdTriageAccept(opts);
4507
+ });
4508
+ program
4509
+ .command('unpark <issue>')
4510
+ .description('Unpark a factory:parked issue: remove factory:parked and re-queue it in its lane with a fresh order label (same path as `queue add`)')
4511
+ .option('--lane <lane>', "Lane to re-queue into (default: the issue's existing factory:lane:* label)")
4512
+ .option('--json', 'Print one JSON object (schemaVersion 1): ok, action, issue, lane, order, labelsBefore, labelsAfter')
4513
+ .action(async (issue, opts) => {
4514
+ await cmdUnpark(issue, opts);
4515
+ });
4516
+ const queue = program
4517
+ .command('queue')
4518
+ .description('Manage the GitHub-label work queue (list, add, clear, reconcile, migrate)');
4519
+ queue
4520
+ .command('migrate')
4521
+ .description('Copy valid .factory/queue lane order into GitHub issue labels')
4522
+ .option('--file <path>', 'Read the legacy queue from an explicit path instead of .factory/queue')
4523
+ .option('--dry-run', 'Print the intended label updates without mutating GitHub')
4524
+ .action((opts) => cmdQueueMigrate(opts));
4525
+ queue
4526
+ .command('add <lane> <issues...>')
4527
+ .description('Queue explicit GitHub issues into a lane — applies factory:queued + factory:lane:<lane> (and an order label), creating any missing factory labels idempotently')
4528
+ .option('--json', 'Print one JSON object (schemaVersion 1): ok, action, lane, issues[{number, outcome, order, labelsAdded}], labelsCreated')
4529
+ .action(async (lane, issues, opts) => {
4530
+ await cmdQueueAdd(lane, issues, opts);
4531
+ });
4532
+ queue
4533
+ .command('reconcile')
4534
+ .description('Report queued GitHub issues that conflict with Factory App admission state, with the repair-flow pointer for each')
4535
+ .option('--lane <lane>', 'Only reconcile one lane')
4536
+ .action(async (opts) => {
4537
+ await cmdQueueReconcile(opts);
4538
+ });
4539
+ queue
4540
+ .command('list')
4541
+ .description('Print queued, in-progress and parked GitHub-queue issues per lane as one JSON object (read-only)')
4542
+ .option('--json', 'Print one JSON object (schemaVersion 1) — required')
4543
+ .option('--lane <lane>', 'Only list this lane (factory:lane:<lane>)')
4544
+ .action(async (opts) => {
4545
+ await cmdQueueList(opts);
4546
+ });
4547
+ queue
4548
+ .command('clear')
4549
+ .description('Empty the GitHub-backed queue: removes factory:queued and factory:order:* from every unclaimed queued issue (lane labels kept; claimed issues skipped). Preview with --dry-run; limit to one lane with --lane')
4550
+ .option('--dry-run', 'Print the preview and exit 0 without changing any label')
4551
+ .option('--yes', 'Confirm and remove the queue labels')
4552
+ .option('--lane <lane>', 'Only clear queued issues in this lane (factory:lane:<lane>)')
4553
+ .action(async (opts) => {
4554
+ await cmdQueueClear(opts);
4555
+ });
4556
+ const hosted = program
4557
+ .command('hosted')
4558
+ .description('Experimental remote-runner execution (needs FACTORY_HOSTED_EXEC=1)');
4559
+ hosted
4560
+ .command('smoke')
4561
+ .description('Local end-to-end hosted-exec smoke: create → lease → Docker run → result → cleanup')
4562
+ .option('--repo <slug>', 'owner/repo to clone and run against', 'on-par/software-factory')
4563
+ .option('--image <image>', 'Container image for the smoke run', 'node:20-alpine')
4564
+ .action(async (opts) => {
4565
+ await cmdHostedSmoke(opts);
4566
+ });
4567
+ hosted
4568
+ .command('runner')
4569
+ .description('Register capabilities with the local control plane and lease one compatible job, then exit')
4570
+ .option('--url <url>', 'Control-plane base URL', 'http://127.0.0.1:8799')
4571
+ .option('--runner-id <id>', 'Runner identity (default runner-<pid>)')
4572
+ .option('--capabilities <csv>', 'Comma-separated capability list', 'git,node')
4573
+ .option('--timeout <ms>', 'Bounded wait window for a compatible job, in ms', '30000')
4574
+ .option('--poll-interval <ms>', 'Delay between poll attempts, in ms', '2000')
4575
+ .option('--lease-ttl <ms>', 'Lease TTL, in ms', '300000')
4576
+ .option('--heartbeat-interval <ms>', 'Expected heartbeat interval, in ms', '30000')
4577
+ .action(async (opts) => {
4578
+ await cmdHostedRunner(opts);
4579
+ });
4580
+ hosted
4581
+ .command('queue')
4582
+ .description('Queue one job to the local control plane, tail it to terminal, print the result')
4583
+ .option('--url <url>', 'Control-plane base URL', 'http://127.0.0.1:8799')
4584
+ .option('--repo <slug>', 'owner/repo the job runs against', 'on-par/software-factory')
4585
+ .option('--task <text>', 'Opaque task payload')
4586
+ .option('--capabilities <csv>', 'Comma-separated required capabilities', 'git,node')
4587
+ .option('--authority <authority>', 'Required authority', 'repo:read')
4588
+ .option('--timeout <ms>', 'Bounded wait for a terminal result, in ms', '120000')
4589
+ .option('--poll-interval <ms>', 'Delay between summary polls, in ms', '1000')
4590
+ .option('--job-id <id>', 'Explicit job id (default server-generated)')
4591
+ .action(async (opts) => {
4592
+ await cmdHostedQueue(opts);
4593
+ });
4594
+ program
4595
+ .command('check <issue>')
4596
+ .alias('ready')
4597
+ .description('Check an issue before queuing: required fields and whether the size gate runs it as-is or would split it, plus an advisory INVEST report (read-only, optional --deep model review; exit 0 ready, 1 missing fields or ungradeable criteria, 2 invalid --deep output, 3 would split)')
4598
+ .option('--json', 'Print one JSON object with fields, criteria findings, size verdict, INVEST findings, and reasons')
4599
+ .option('--deep', 'Also call the triage-tier model once: preview the proposed split when the issue would split, otherwise suggest missing criteria and scope concerns (prints model id and cost; never files anything; exit 2 on invalid model output)')
4600
+ .action(cmdCheck);
4601
+ program
4602
+ .command('ship <issue>')
4603
+ .description('Plan → build → check one GitHub issue and open a ready-for-review PR; never merges (merge later with land)')
4604
+ .option('--product <name>', 'Override active product constitution')
4605
+ .option('--no-auto-rework', 'Disable automatic rework loop')
4606
+ .option('--interactive', 'Pause before opening the PR and wait for approval from the TUI')
4607
+ .option('--approve-plan', 'Pause after PLAN freezes the spec and wait for approval before BUILD')
4608
+ .option('--no-sandbox', 'Disable the containment sandbox for agent runs (dangerous)')
4609
+ .option('--branch-prefix <prefix>', BRANCH_PREFIX_OPTION_DESCRIPTION)
4610
+ .action(async (issueNum, opts) => {
4611
+ await cmdShip(parseIssueArg(issueNum), opts);
4612
+ });
4613
+ program
4614
+ .command('run-issue <issue>')
4615
+ .description('Like ship, but loads the issue first and exits 2 before any worktree or PR if it cannot be found')
4616
+ .option('--product <name>', 'Override active product constitution')
4617
+ .option('--no-auto-rework', 'Disable automatic rework loop')
4618
+ .option('--interactive', 'Pause before opening the PR and wait for approval from the TUI')
4619
+ .option('--approve-plan', 'Pause after PLAN freezes the spec and wait for approval before BUILD')
4620
+ .option('--no-sandbox', 'Disable the containment sandbox for agent runs (dangerous)')
4621
+ .option('--branch-prefix <prefix>', BRANCH_PREFIX_OPTION_DESCRIPTION)
4622
+ .option('--run-children', 'File mode only: when the size gate files child issues, run them (slice mode never files issues)')
4623
+ .option('--size-gate <mode>', 'Size gate mode for this run: file (file child issues / park), slice (ship as sequential slice PRs under the same issue; never files issues) or off (skip both gates); beats sizeGate.mode in the config')
4624
+ .option('--max-slices <count>', 'Slice mode: most slices a not-yet-started slice plan may have (1-20); an over-cap plan is recorded and parks; beats sizeGate.maxSlices (default 10)')
4625
+ .action(async (issueNum, opts) => {
4626
+ await cmdRunIssue(parseIssueArg(issueNum), opts);
4627
+ });
4628
+ program
4629
+ .command('run-brief <file>')
4630
+ .description('Like run-issue, but the work comes from a local Markdown brief instead of a GitHub issue')
4631
+ .option('--product <name>', 'Override active product constitution')
4632
+ .option('--no-auto-rework', 'Disable automatic rework loop')
4633
+ .option('--interactive', 'Pause before opening the PR and wait for approval from the TUI')
4634
+ .option('--approve-plan', 'Pause after PLAN freezes the spec and wait for approval before BUILD')
4635
+ .option('--no-sandbox', 'Disable the containment sandbox for agent runs (dangerous)')
4636
+ .option('--workspace <dir>', 'Local-only: run PLAN/BUILD/CHECK in this caller-provided git workspace and disable PR creation, merging, queue polling, and GitHub mutations')
4637
+ .option('--artifacts <dir>', 'Local-only: write a versioned benchmark artifact manifest (manifest.json, request.json, events.ndjson, diff.patch) to this directory; requires --workspace')
4638
+ .option('--branch-prefix <prefix>', BRANCH_PREFIX_OPTION_DESCRIPTION)
4639
+ .action(async (file, opts) => {
4640
+ await cmdRunBrief(file, opts);
4641
+ });
4642
+ program
4643
+ .command('local-small-dry-run <issue>')
4644
+ .description('Create a bounded local-small step plan and first context pack without changing source files')
4645
+ .option('--spec <path>', 'Frozen spec path; defaults to .factory/plans/issue-<n>.md')
4646
+ .option('--output <path>', 'Artifact directory; defaults to .factory/local-small/issue-<n>')
4647
+ .action(async (issueNum, opts) => {
4648
+ await cmdLocalSmallDryRun(parseIssueArg(issueNum), opts);
4649
+ });
4650
+ program
4651
+ .command('local-small-overnight')
4652
+ .description('Process a curated local-small queue one issue at a time: review-only PRs, never merges, parks on ambiguity')
4653
+ .option('--queue <path>', 'Curated queue file; defaults to .factory/local-small/overnight-queue')
4654
+ .option('--state <path>', 'Resume-state file; defaults to .factory/local-small/overnight-state.json')
4655
+ .action(async (opts) => {
4656
+ await cmdLocalSmallOvernight(opts);
4657
+ });
4658
+ program
4659
+ .command('land <issue>')
4660
+ .description("Squash-merge the issue's open PR once CI is green, then remove its worktree (left open if CI fails or review is pending)")
4661
+ .option('--branch-prefix <prefix>', BRANCH_PREFIX_MATCH_OPTION_DESCRIPTION)
4662
+ .option('--allow-gated', 'Merge even when the issue or PR carries the no-auto-merge gate label (human override; writes an AUDIT line and a merge-gated-override event)')
4663
+ .action(async (issueNum, opts) => {
4664
+ await cmdLand(parseIssueArg(issueNum), opts);
4665
+ });
4666
+ program
4667
+ .command('resume-approved')
4668
+ .description(`Land open factory PRs (factory/*, plus legacy ${LEGACY_BRANCH_PREFIX}/*) whose review is now approved; skip the rest`)
4669
+ .option('--branch-prefix <prefix>', BRANCH_PREFIX_MATCH_OPTION_DESCRIPTION)
4670
+ .action(async (opts) => {
4671
+ await cmdResumeApproved(opts);
4672
+ });
4673
+ program
4674
+ .command('run')
4675
+ .description('Claim queued issues (factory:queued labels) and ship them, lanes in parallel; merges only when auto-merge is on')
4676
+ .option('--local-queue', 'Read .factory/queue instead of claiming issues from GitHub Issues')
4677
+ .option('--auto-merge', 'Merge eligible PRs autonomously, overriding .factory/config.yaml and FACTORY_MERGE')
4678
+ .option('--no-auto-merge', 'Never merge autonomously, overriding .factory/config.yaml and FACTORY_MERGE')
4679
+ .option('--pr-classifier', 'Hold PRs whose review floor is B or C for a human (applies no-auto-merge), overriding .factory/config.json and FACTORY_PR_CLASSIFIER')
4680
+ .option('--no-pr-classifier', 'Never run the PR classifier gate, overriding .factory/config.json and FACTORY_PR_CLASSIFIER')
4681
+ .option('--usage-watch', 'Enforce the usage gate for this run, overriding .factory/config.yaml and FACTORY_USAGE_WATCH')
4682
+ .option('--no-usage-watch', 'Skip the usage gate for this run, overriding .factory/config.yaml and FACTORY_USAGE_WATCH')
4683
+ .option('--usage-threshold <fraction>', "Override .factory/config.yaml and FACTORY_STOP_AT for this run's usage threshold")
4684
+ .option('--usage-poll <seconds>', "Override .factory/config.yaml and FACTORY_USAGE_POLL for this run's usage poll")
4685
+ .option('--branch-prefix <prefix>', BRANCH_PREFIX_OPTION_DESCRIPTION)
4686
+ .action((opts) => cmdRun(opts));
4687
+ program
4688
+ .command('proxy')
4689
+ .description('Run the opt-in lane reverse proxy in the foreground (run and supervise start it themselves)')
4690
+ .action(cmdProxy);
4691
+ const daemonCmd = program
4692
+ .command('daemon')
4693
+ .description('Manage factoryd, the long-running background service for this user');
4694
+ daemonCmd
4695
+ .command('run')
4696
+ .description('Run factoryd in the foreground: a localhost-only HTTP API over the repo registry')
4697
+ .option('--port <n>', `Port to bind on 127.0.0.1 (default ${DEFAULT_FACTORYD_PORT})`)
4698
+ .option('--registry <file>', 'Registry file to serve (default ~/.factory/registry.json)')
4699
+ .option('--auto-update', 'Opt in to automatic factoryd self-update (default: off)')
4700
+ .action((opts) => cmdFactoryd(opts));
4701
+ const daemonCtl = (fn) => fn().catch((err) => {
4702
+ if (err instanceof DaemonCtlError)
4703
+ throw new CliExitError(err.message, err.code);
4704
+ throw err;
4705
+ });
4706
+ daemonCmd
4707
+ .command('start')
4708
+ .description('Install and load the com.onpar.factoryd LaunchAgent (macOS only; KeepAlive, RunAtLoad)')
4709
+ .action(() => daemonCtl(() => cmdDaemonStart(deps.daemon)));
4710
+ daemonCmd
4711
+ .command('stop')
4712
+ .description('Unload the LaunchAgent (macOS only; plist stays installed)')
4713
+ .action(() => daemonCtl(() => cmdDaemonStop(deps.daemon)));
4714
+ daemonCmd
4715
+ .command('status')
4716
+ .description('Report factoryd pid, uptime, and attached repos')
4717
+ .option('--json', 'Print one JSON object (schemaVersion 1) instead of text')
4718
+ .action((opts) => daemonCtl(() => cmdDaemonStatus(deps.daemon, { json: opts.json })));
4719
+ daemonCmd
4720
+ .command('logs')
4721
+ .description('Print or tail ~/.factory/daemon.log')
4722
+ .option('-f, --follow', 'Keep tailing')
4723
+ .option('-n, --lines <n>', 'Lines to print first (default 100)')
4724
+ .action((opts) => daemonCtl(() => cmdDaemonLogs(opts, deps.daemon)));
4725
+ const worktreeCmd = program.command('worktree').description('Clean up stale factory worktrees (gc)');
4726
+ worktreeCmd
4727
+ .command('gc')
4728
+ .description('Remove stale factory worktrees (merged/closed branches or older than TTL) and scrub credentials')
4729
+ .option('--dry-run', 'Preview what would be removed without deleting anything')
4730
+ .option('--ttl-days <n>', 'Override worktree.gcTtlDays from .factory/config.yaml (default 7)')
4731
+ .option('--branch-prefix <prefix>', BRANCH_PREFIX_MATCH_OPTION_DESCRIPTION)
4732
+ .action(cmdWorktreeGc);
4733
+ program
4734
+ .command('reset <issue...>')
4735
+ .description("Remove an issue's local factory state (worktree, local branch, plan/phase files, rework history, logs, leases) so the next run starts fresh. A worktree or branch with uncommitted changes or unpushed commits is kept unless --force is passed. Do not run while that issue's lane is active. Preview with --dry-run")
4736
+ .option('--dry-run', 'List every worktree, branch, file, history entry, log dir, claim and lease that would be removed, without changing anything')
4737
+ .option('--force', 'Remove worktrees and branches even when they have uncommitted changes or unpushed commits')
4738
+ .option('--branch-prefix <prefix>', BRANCH_PREFIX_MATCH_OPTION_DESCRIPTION)
4739
+ .action(cmdReset);
4740
+ program
4741
+ .command('supervise')
4742
+ .description('Unattended loop: wait for usage headroom, run the queue, repeat until it is empty')
4743
+ .option('--now', 'Skip the initial headroom wait')
4744
+ .option('--local-queue', 'Read .factory/queue instead of claiming issues from GitHub Issues')
4745
+ .option('--auto-merge', 'Merge eligible PRs autonomously, overriding .factory/config.yaml and FACTORY_MERGE')
4746
+ .option('--no-auto-merge', 'Never merge autonomously, overriding .factory/config.yaml and FACTORY_MERGE')
4747
+ .option('--usage-watch', 'Enforce the usage gate for this run, overriding .factory/config.yaml and FACTORY_USAGE_WATCH')
4748
+ .option('--no-usage-watch', 'Skip the usage gate for this run, overriding .factory/config.yaml and FACTORY_USAGE_WATCH')
4749
+ .option('--usage-threshold <fraction>', "Override .factory/config.yaml and FACTORY_RESUME_AT for this run's usage threshold")
4750
+ .option('--usage-poll <seconds>', "Override .factory/config.yaml and FACTORY_USAGE_POLL for this run's usage poll")
4751
+ .option('--branch-prefix <prefix>', BRANCH_PREFIX_OPTION_DESCRIPTION)
4752
+ .action(async (opts) => {
4753
+ await cmdSupervise(opts);
4754
+ });
4755
+ program
4756
+ .command('stop')
4757
+ .description('Tell running lanes to halt after their current issue')
4758
+ .option('--json', 'Print one JSON object (schemaVersion 1): action, repo, stopFlag before/after')
4759
+ .action((opts) => cmdStop(opts));
4760
+ program
4761
+ .command('resume')
4762
+ .description('Clear a stop so lanes pick up new issues again')
4763
+ .option('--json', 'Print one JSON object (schemaVersion 1): action, repo, stopFlag before/after')
4764
+ .action((opts) => cmdResume(opts));
4765
+ applyHelpGroups(program);
4766
+ const previousLoaders = configLoaders;
4767
+ const previousGitOps = gitOps;
4768
+ const previousInternalOps = internalOps;
4769
+ const previousShellOps = shellOps;
4770
+ const previousCoreOps = coreOps;
4771
+ const previousPhaseOps = phaseOps;
4772
+ const previousOctokitFactory = octokitFactory;
4773
+ configLoaders = resolveConfigLoaders(deps, DEFAULT_CONFIG_LOADERS);
4774
+ gitOps = resolveGitOps(deps, DEFAULT_GIT_OPS);
4775
+ internalOps = resolveInternalOps(deps, DEFAULT_INTERNAL_OPS);
4776
+ shellOps = resolveShellOps(deps, DEFAULT_SHELL_OPS);
4777
+ coreOps = resolveCoreOps(deps, DEFAULT_CORE_OPS);
4778
+ phaseOps = resolvePhaseOps(deps, DEFAULT_PHASE_OPS);
4779
+ octokitFactory = deps.octokit;
4780
+ try {
4781
+ await program.parseAsync(argv);
4782
+ }
4783
+ catch (err) {
4784
+ if (err instanceof CliExitError) {
4785
+ console.error(styleText('red', err.message));
4786
+ process.exitCode = err.code;
4787
+ return;
4788
+ }
4789
+ throw err;
4790
+ }
4791
+ finally {
4792
+ configLoaders = previousLoaders;
4793
+ gitOps = previousGitOps;
4794
+ internalOps = previousInternalOps;
4795
+ shellOps = previousShellOps;
4796
+ coreOps = previousCoreOps;
4797
+ octokitFactory = previousOctokitFactory;
4798
+ phaseOps = previousPhaseOps;
4799
+ }
4800
+ }
4801
+ //# sourceMappingURL=index.js.map