@on-par/factory-cli 0.9.0 → 0.9.2

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 (51) hide show
  1. package/README.md +22 -5
  2. package/dist/cli/base-hold.d.ts +63 -0
  3. package/dist/cli/base-hold.d.ts.map +1 -0
  4. package/dist/cli/base-hold.js +122 -0
  5. package/dist/cli/base-hold.js.map +1 -0
  6. package/dist/cli/daemon.d.ts +111 -4
  7. package/dist/cli/daemon.d.ts.map +1 -1
  8. package/dist/cli/daemon.js +364 -6
  9. package/dist/cli/daemon.js.map +1 -1
  10. package/dist/cli/doctor.d.ts +11 -0
  11. package/dist/cli/doctor.d.ts.map +1 -1
  12. package/dist/cli/doctor.js +55 -6
  13. package/dist/cli/doctor.js.map +1 -1
  14. package/dist/cli/feedback.d.ts +18 -2
  15. package/dist/cli/feedback.d.ts.map +1 -1
  16. package/dist/cli/feedback.js +38 -4
  17. package/dist/cli/feedback.js.map +1 -1
  18. package/dist/cli/help-groups.d.ts.map +1 -1
  19. package/dist/cli/help-groups.js +10 -1
  20. package/dist/cli/help-groups.js.map +1 -1
  21. package/dist/cli/index.d.ts +24 -1
  22. package/dist/cli/index.d.ts.map +1 -1
  23. package/dist/cli/index.js +222 -43
  24. package/dist/cli/index.js.map +1 -1
  25. package/dist/cli/self-update.d.ts +68 -0
  26. package/dist/cli/self-update.d.ts.map +1 -0
  27. package/dist/cli/self-update.js +167 -0
  28. package/dist/cli/self-update.js.map +1 -0
  29. package/dist/cli/state-root.d.ts +5 -0
  30. package/dist/cli/state-root.d.ts.map +1 -0
  31. package/dist/cli/state-root.js +12 -0
  32. package/dist/cli/state-root.js.map +1 -0
  33. package/dist/cli/status-json.d.ts +4 -0
  34. package/dist/cli/status-json.d.ts.map +1 -1
  35. package/dist/cli/status-json.js +1 -0
  36. package/dist/cli/status-json.js.map +1 -1
  37. package/dist/cli/update-state.d.ts +46 -0
  38. package/dist/cli/update-state.d.ts.map +1 -0
  39. package/dist/cli/update-state.js +158 -0
  40. package/dist/cli/update-state.js.map +1 -0
  41. package/dist/cli.js +2 -2
  42. package/dist/cli.js.map +1 -1
  43. package/dist/entry.d.ts +1 -1
  44. package/dist/entry.d.ts.map +1 -1
  45. package/dist/entry.js +9 -2
  46. package/dist/entry.js.map +1 -1
  47. package/dist/node-version.d.ts +10 -0
  48. package/dist/node-version.d.ts.map +1 -0
  49. package/dist/node-version.js +15 -0
  50. package/dist/node-version.js.map +1 -0
  51. package/package.json +12 -4
package/dist/cli/index.js CHANGED
@@ -5,16 +5,17 @@ import { randomUUID } from 'node:crypto';
5
5
  import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
6
6
  import { createRequire } from 'node:module';
7
7
  import { arch, hostname as osHostname, release, platform, userInfo } from 'node:os';
8
- import { basename, dirname, relative, resolve } from 'node:path';
8
+ import { basename, dirname, join, relative, resolve } from 'node:path';
9
9
  import { promisify, styleText } from 'node:util';
10
10
  import { BRANCH_PREFIX_MATCH_OPTION_DESCRIPTION, BRANCH_PREFIX_OPTION_DESCRIPTION, INVALID_BRANCH_PREFIX_MESSAGE, resolveBranchPrefixOption, } from './branch-prefix.js';
11
- import { acquirePortLease, aggregateCosts, appendKpiHistoryLine, applyRepoConfig, buildPhase, checkPhase, clearProxyState, closedWorkSkipReason, computeHealthKpis, ConstitutionLoader, createDefaultWorkSourceRegistry, createFileApprovalGate, createLogger, createLaneProxy, defaultFindPortListeners, describeEffectiveConfig, detectPostMergeDefects, diagnoseModels, drainSteering, estimateTrailingSpend, fetchDefectSources, fetchHumanEventSources, fetchSubscriptionUsage, formatKpiLines, formatUsageReport, getConstitutionsDir, getFactoryPaths, GITHUB_ISSUE_SOURCE, hasUnresolvedPark, InvalidArtifactsDirError, InvalidWorkRequestInputError, InvalidWorkspaceError, inspectPortLeases, isCommandAvailable, isProxyRunning, 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';
11
+ import { acquirePortLease, aggregateCosts, appendKpiHistoryLine, applyRepoConfig, buildPhase, checkPhase, clearProxyState, closedWorkSkipReason, computeHealthKpis, ConstitutionLoader, createDefaultWorkSourceRegistry, createFileApprovalGate, createLogger, createLaneProxy, defaultFindPortListeners, describeEffectiveConfig, detectPostMergeDefects, diagnoseModels, drainSteering, estimateTrailingSpend, fetchDefectSources, fetchHumanEventSources, fetchSubscriptionUsage, formatKpiLines, formatUsageReport, getConstitutionsDir, getFactoryPaths, GITHUB_ISSUE_SOURCE, hasUnresolvedPark, InvalidArtifactsDirError, InvalidWorkRequestInputError, InvalidWorkspaceError, inspectPortLeases, isCommandAvailable, isProxyRunning, kpiBranchPrefixes, kpisToHistoryRecord, laneBaseUrl, laneEnv, LaneFileGuard, laneHostLabel, listQueuedSteering, loadFactoryConfigForRepo, loadModelsConfig, loadRepoConfig, loadRoutesConfig, LOCAL_BRIEF_SOURCE, localOnlyWorkspace, joinClassifierOutcomes, mergeClassifierOutcomes, parseClassifierOutcomes, summarizeClassifierOutcomes, formatClassifierReport, mergedPrRefs, ModelRegistry, ModelRouter, parkReasonFor, parseKpiHistory, parseQueue, partitionLocalQueueByActivity, phaseSnapshotFile, planPhase, provisionLaneContainer, ProviderBreaker, readEvents, readPortLeases, readQueue, readUsage, reapOrphanProcesses, reapStalePortLeases, reconstructKpiEvents, recordLeasePgid, releasePortLease, renderKpiReport, renderKpiTrend, resolveAutoFailover, resolveCodexDisabled, resolveDefectWindowDays, resolveBuildPublish, resolveSizeGateMode, parseSizeGateMode, parseMaxSlices, resolveSizeGateMaxSlices, SIZE_GATE_MODES, resolveDesignRegressionBlock, resolveReadinessUpdateIssueBody, resolveEffectiveModelPins, resolveEfficiencyPolicy, resolveLaneBreakerThreshold, resolveEnvironmentPorts, resolveEnvironmentProxy, resolveArtifactsDir, resolveIngestConfig, resolveLocalOnlyPolicy, resolveMergePolicy, resolvePlanApproval, resolveProcessGroupGraceMs, resolveSandboxPolicy, resolveSkipCI, resolveTimeouts, resolveUsageCap, resolveWatchdogPolicy, ReworkHistory, rewriteQueueForDecomposition, runAutoIngest, runIssue, shipPhase, summarizeEvent, formatCostTotal, touchLastEvent, touchRunActivity, validateQueue, watchUsage, worktreeWorkspace, writeBenchmarkArtifacts, writeLocalRunReport, writePhaseSnapshot, writeProxyState, } from '@on-par/factory-core';
12
+ import { acquirePidFile, createPlanReuseStore, checkBaseHealth, currentSlice, egressSummaryMessage, findSlicePlanComment, upsertSlicePlanComment, withSliceState, DEFAULT_GARDEN_MAX_CLUSTERS, DEFAULT_GARDEN_SINCE, capGardenClusters, clusterGarden, dedupGardenClusters, filterGardenOnlyNew, filterGardenWindow, markGardenTrackingUnknown, parseGardenDuration, readHarvestEvents, renderGardenJson, renderGardenReport, formatWorktreeLocation, laneWorktreePath, resolveWorktreeRoot, migrateRepoConfigToYaml, REPO_CONFIG_YAML_HEADER, branchFor, claudeConfigDirOverride, claudeKeychainService, cleanupWorktree, createDaemonLogSink, factoryBranchIssue, factoryBranchPrefixes, getIssueTitle, LEGACY_BRANCH_PREFIX, createDockerEngine, createFactorydServer, createRunRuntime, createShipExecutor, createGithubQueue, createLocalSmallDryRun, createOctokitFilingClient, createOctokitGreenPrClient, createOctokitQueueClient, daemonRuntimePaths, DEFAULT_FACTORYD_PORT, defaultRegistryPath, ensureDir, FACTORY_RUNTIME_CONFIG_KEYS, findUnmergedGreenPrs, formatGcReport, gitFetch, isAutoMergeBlocked, listMicroVms, listOrphanContainers, logCost, logEvent, microVmName, planQueueMigration, readCosts, readGithubQueueSnapshot, reapLaneWorktree, reapOrphanContainers, reapOrphanMicroVm, releaseRuntimeFiles, releaseStaleClaims, resolveBranchPrefix, resolveEffectiveConfig, worktreeSandboxFor, resolveExperimental, resolveFilingPolicy, resolveLocalOnly, resolvePrClassifierPolicy, resolveReviewFloorRules, RunLockHeldError, runOvernightQueue, findOpenPR, parseRemoteSlug, setupWorktree, shellEscape, shouldProxyEgress, slugify, startEgressProxy, sweepWorktrees, watchChecks, watchChecksWithInfraRerun, withAdmissionGuard, withFileLock, withGitLock, withRunLock, wrapCommandInSandbox, writePortFile, } from '@on-par/factory-core/internal';
13
13
  import { runTui } from '@on-par/factory-tui';
14
14
  import { Command } from 'commander';
15
15
  import { admissionStateReaderFor, runQueueReconcile } from './admission.js';
16
16
  import { cmdClassifierBacktest, ClassifierBacktestInputError, } from './classifier-backtest.js';
17
17
  import { runQueueClear } from './queue-clear.js';
18
+ import { applyStateRootOption } from './state-root.js';
18
19
  import { buildCostJson } from './cost-json.js';
19
20
  import { buildQueueAddJson } from './queue-add-json.js';
20
21
  import { buildQueueListJson } from './queue-list.js';
@@ -22,17 +23,19 @@ import { runUnpark, UnparkError } from './unpark.js';
22
23
  import { buildRunsJson, parseRunsLimit } from './runs.js';
23
24
  import { buildRunDiffstatJson, NoRunError, parseDiffstatIssue } from './runs-diffstat.js';
24
25
  import { buildStatusJson } from './status-json.js';
26
+ import { buildStatusUpdateJson, formatUpdateStartupLine, formatUpdateStatusLine, initialUpdateState, readUpdateState, updateStatePath, writeUpdateState, } from './update-state.js';
25
27
  import { buildStopResumeJson } from './stop-resume-json.js';
26
28
  import { buildUsageJson } from './usage-json.js';
27
29
  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 { cmdDaemonLogs, cmdDaemonStart, cmdDaemonStatus, cmdDaemonStop, createAutoUpdateDeps, DaemonCtlError, detectInstall, loadDaemonFileConfig, recoverStaleDrain, resolveFactorydConfig, runAutoUpdateLoop, } from './daemon.js';
31
+ import { checkForUpdate, cmdSelfUpdateCheck } from './self-update.js';
32
+ import { analyzeEventLog, claudeKeychainCheck, doctorFailed, eventLogCheck, formatClaimReconcileReport, formatContainerReconcileReport, formatDoctorChecks, formatMicroVmReconcileReport, formatReconcileReport, formatWorktreeReconcileReport, hasSandboxRuntime, leaseChecks, keychainPreflightError, orphanContainerChecks, orphanMicroVmChecks, runDoctorChecks, sandboxAuthSkipDetail, sandboxClaudeAuthChecks, unmergedGreenPrChecks, } from './doctor.js';
30
33
  import { DOCS_URL, formatOverview, missingClaudeCliMessage, missingTokenMessage, notInitializedMessage, } from './first-run.js';
31
34
  import { cmdHostedSmoke } from './hosted.js';
32
35
  import { cmdHostedQueue } from './hosted-queue.js';
33
36
  import { cmdHostedRunner } from './hosted-runner.js';
34
37
  import { resolveFactoryCheckoutCommit, runFilingPreview } from './filing.js';
35
- import { buildFeedbackDeps, runFeedback } from './feedback.js';
38
+ import { buildFeedbackDeps, resolveFeedbackRepo, runFeedback } from './feedback.js';
36
39
  import { ensureFactoryExcluded, writeSampleQueue } from './init-files.js';
37
40
  import { cmdLogs } from './logs.js';
38
41
  import { applyHelpGroups } from './help-groups.js';
@@ -40,6 +43,7 @@ import { mergeScopeNotice } from './merge-scope.js';
40
43
  import { createFactoryOctokit, formatDeprecation } from './octokit.js';
41
44
  import { DeepCheckError, createDeepModelRunner, runIssueCheck } from './ready-check.js';
42
45
  import { childRunSucceeded, formatChildRunSummary, runChildrenInOrder } from './run-children.js';
46
+ import { createBaseHoldGate, formatBaseHoldCleared, formatBaseHoldStatus, readBaseHold, writeBaseHold, } from './base-hold.js';
43
47
  import { readRunFlagOverrides, writeRunFlagOverrides } from './run-flags.js';
44
48
  import { distFreshnessProbe, runStalenessGuard } from './staleness.js';
45
49
  import { readStopFileStatus, stopSentinelCheck, stopSentinelRunSkipMessage } from './stop-sentinel.js';
@@ -487,10 +491,34 @@ export async function runFeedbackCommand(prUrl, opts, deps, log = console.log) {
487
491
  throw err;
488
492
  }
489
493
  }
494
+ /** owner/name of the checkout's origin remote, or null when there is none or it is not a GitHub remote. Never throws. */
495
+ export async function readOriginSlug(shell = shellOps) {
496
+ try {
497
+ const { stdout } = await shell.exec('git remote get-url origin');
498
+ return parseRemoteSlug(stdout.trim());
499
+ }
500
+ catch {
501
+ return null;
502
+ }
503
+ }
490
504
  export async function cmdFeedback(prUrl, opts) {
491
505
  const paths = getFactoryPaths(await getRepoRoot());
492
506
  const prefix = opts.branchPrefix === undefined ? resolveBranchPrefix() : branchPrefixOrExit(opts.branchPrefix);
493
- await runFeedbackCommand(prUrl, opts, buildFeedbackDeps(getOctokit(), paths, prefix, createOctokitFilingClient));
507
+ let repo;
508
+ try {
509
+ repo = resolveFeedbackRepo({
510
+ flag: opts.repo,
511
+ config: configLoaders.loadFactoryConfigForRepo(paths.config).filing.repo,
512
+ remote: await readOriginSlug(),
513
+ });
514
+ }
515
+ catch (err) {
516
+ throw new CliExitError(err instanceof Error ? err.message : String(err), 2);
517
+ }
518
+ await runFeedbackCommand(prUrl, opts, buildFeedbackDeps(getOctokit(), paths, prefix, createOctokitFilingClient, {
519
+ repo,
520
+ shareLocal: opts.includeLocalPaths === true,
521
+ }));
494
522
  }
495
523
  async function resolveFactoryCommit() {
496
524
  return resolveFactoryCheckoutCommit(dirname(fileURLToPath(import.meta.url)), async (cmd, cwd) => (await shellOps.exec(cmd, { cwd })).stdout);
@@ -809,10 +837,24 @@ function readTextFileOrEmpty(path) {
809
837
  throw err;
810
838
  }
811
839
  }
812
- async function appendKpiSnapshot(paths, repoRoot, kpis) {
840
+ /** Local events plus human-* events reconstructed from GitHub; warns and marks the result partial when the fetch fails (#2342). */
841
+ async function reconstructHumanEventsForKpis(events, branchPrefix) {
842
+ let owner = '';
843
+ let repoName = '';
844
+ const recon = await reconstructKpiEvents(events, async (issues) => {
845
+ [owner, repoName] = (await getGitHubRepo()).split('/');
846
+ return fetchHumanEventSources(getOctokit(), owner, repoName, issues, kpiBranchPrefixes(branchPrefix));
847
+ });
848
+ if (recon.partial) {
849
+ console.error(styleText('yellow', `factory: GitHub human-event reconstruction failed (${recon.error}) — KPI snapshot marked partial (local log only)`));
850
+ }
851
+ return { ...recon, owner, repoName };
852
+ }
853
+ async function appendKpiSnapshot(paths, repoRoot, kpis, opts = {}) {
813
854
  const record = kpisToHistoryRecord(kpis, new Date().toISOString().slice(0, 10), {
814
855
  commitSha: await currentCommitSha(),
815
856
  models: resolvedModelTiers(repoRoot, paths.root),
857
+ partial: opts.partial,
816
858
  });
817
859
  const updated = appendKpiHistoryLine(readTextFileOrEmpty(paths.kpiHistory), record);
818
860
  ensureDir(paths.state);
@@ -938,20 +980,10 @@ async function cmdKpis(opts = {}) {
938
980
  const paths = getFactoryPaths(repoRoot);
939
981
  const events = existsSync(paths.events) ? readEvents(paths.events) : [];
940
982
  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
- }
983
+ const recon = await reconstructHumanEventsForKpis(events, branchPrefix);
984
+ let allEvents = recon.events;
985
+ const prSources = recon.sources;
986
+ const { owner, repoName } = recon;
955
987
  if (prSources.length > 0) {
956
988
  try {
957
989
  const windowDays = resolveDefectWindowDays(configLoaders.loadFactoryConfigForRepo(paths.config));
@@ -973,7 +1005,7 @@ async function cmdKpis(opts = {}) {
973
1005
  const kpis = computeHealthKpis(allEvents, costs);
974
1006
  let history;
975
1007
  try {
976
- ({ history } = await appendKpiSnapshot(paths, repoRoot, kpis));
1008
+ ({ history } = await appendKpiSnapshot(paths, repoRoot, kpis, { partial: recon.partial }));
977
1009
  }
978
1010
  catch (err) {
979
1011
  console.error(styleText('yellow', `factory: KPI snapshot failed (${err?.message ?? err}) — showing the report without persisting a new snapshot`));
@@ -1085,6 +1117,7 @@ export async function cmdStatus(opts = {}) {
1085
1117
  stop: existsSync(paths.stop),
1086
1118
  breakers: await new ProviderBreaker(paths.breaker).list(),
1087
1119
  active,
1120
+ update: buildStatusUpdateJson(readUpdateState(updateStatePath(daemonRuntimePaths().dir))),
1088
1121
  now: Date.now(),
1089
1122
  })));
1090
1123
  return;
@@ -1128,6 +1161,12 @@ export async function cmdStatus(opts = {}) {
1128
1161
  else {
1129
1162
  console.log(' (no queue file)');
1130
1163
  }
1164
+ const baseHold = readBaseHold(paths.baseHold);
1165
+ if (baseHold !== null && baseHold.repo === ghRepo) {
1166
+ console.log(styleText('bold', '\n== Base hold =='));
1167
+ for (const line of formatBaseHoldStatus(baseHold))
1168
+ console.log(line);
1169
+ }
1131
1170
  console.log(styleText('bold', '\n== Queue =='));
1132
1171
  if (!hasGitHubToken()) {
1133
1172
  console.log(' (no GitHub token — run `gh auth login`)');
@@ -1154,6 +1193,8 @@ export async function cmdStatus(opts = {}) {
1154
1193
  }
1155
1194
  }
1156
1195
  console.log(styleText('bold', '\n== Health =='));
1196
+ const update = buildStatusUpdateJson(readUpdateState(updateStatePath(daemonRuntimePaths().dir)));
1197
+ console.log(` ${formatUpdateStatusLine(update)}`);
1157
1198
  if (opts.kpis) {
1158
1199
  console.log(styleText('bold', '\n Effective config:'));
1159
1200
  for (const line of describeEffectiveConfig({
@@ -1504,6 +1545,7 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1504
1545
  'held',
1505
1546
  'conflict',
1506
1547
  'ci-failed',
1548
+ 'ci-infra',
1507
1549
  'timeout',
1508
1550
  ]);
1509
1551
  let terminalMessage;
@@ -1645,6 +1687,7 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1645
1687
  },
1646
1688
  localOnly: Boolean(ctx?.localOnly),
1647
1689
  blockUnresolvedRegressions: resolveDesignRegressionBlock(factoryConfig),
1690
+ readinessUpdateIssueBody: resolveReadinessUpdateIssueBody(factoryConfig),
1648
1691
  publishFromBuild: resolveBuildPublish(factoryConfig),
1649
1692
  sizeGateMode,
1650
1693
  maxSlices,
@@ -1678,10 +1721,12 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
1678
1721
  resolveBaseUrl,
1679
1722
  getIssueSpend: () => issueSpend,
1680
1723
  breaker,
1724
+ planReuse: ctx?.localOnly ? undefined : createPlanReuseStore(specPath),
1681
1725
  planPhase: phases.planPhase,
1682
1726
  buildPhase: phases.buildPhase,
1683
1727
  checkPhase: phases.checkPhase,
1684
1728
  shipPhase: phases.shipPhase,
1729
+ checkBaseHealth: deps.checkBaseHealth ?? checkBaseHealth,
1685
1730
  resolveConstitution: () =>
1686
1731
  // Resolved once here — runIssue calls this exactly once and reuses the value for
1687
1732
  // every phase, so the build worker can never author the standards it is graded by.
@@ -2518,8 +2563,12 @@ async function cmdTriage(opts) {
2518
2563
  const routesConfig = configLoaders.loadRoutesConfig();
2519
2564
  const effective = resolveEffectiveConfig(repoConfig);
2520
2565
  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);
2566
+ // Triage always runs `claude -p`, so only claude-cli models with a flag are runnable.
2567
+ const registry = router.registryRef;
2568
+ const resolved = router.resolveAll('triage');
2569
+ const candidates = resolved.length > 0 ? resolved : ['claude-sonnet-5'];
2570
+ const model = candidates.find((m) => registry.getHarnessId(m) === 'claude-cli' && registry.getClaudeFlag(m));
2571
+ const flag = model ? registry.getClaudeFlag(model) : undefined;
2523
2572
  const constitutionNote = product ? `Active constitution: ${product}.` : '';
2524
2573
  const prompt = `Triage the open GitHub issues of ${ghRepo} for an autonomous software factory.
2525
2574
  ${constitutionNote}
@@ -2529,7 +2578,7 @@ and anything too vague. Group by lane (same-file issues together). Order by depe
2529
2578
  Write ONLY the queue to ${paths.queueProposed} in format '<lane> <issue#>', with '#' comments
2530
2579
  explaining exclusions.`;
2531
2580
  let plannerError;
2532
- logEvent(paths.events, 'triage', '-', `Triaging ${ghRepo} with ${model}`);
2581
+ logEvent(paths.events, 'triage', '-', `Triaging ${ghRepo} with ${flag ?? 'claude default model'}`);
2533
2582
  await shellOps
2534
2583
  .exec(`claude -p ${shellEscape(prompt)} ${flag ? `--model ${flag}` : ''} --allowedTools "Bash(gh issue:*)" "Bash(gh repo:*)" Read Glob Grep Write`)
2535
2584
  .catch((err) => {
@@ -2915,7 +2964,9 @@ async function cmdProxy() {
2915
2964
  async function cmdFactoryd(opts) {
2916
2965
  let config;
2917
2966
  try {
2918
- config = resolveFactorydConfig(opts);
2967
+ const registryFile = opts.registry ?? defaultRegistryPath();
2968
+ const fileConfig = loadDaemonFileConfig(join(dirname(registryFile), 'daemon.json'));
2969
+ config = resolveFactorydConfig({ ...opts, registry: registryFile }, { install: detectInstall(), fileConfig });
2919
2970
  }
2920
2971
  catch (err) {
2921
2972
  if (err instanceof DaemonCtlError)
@@ -2935,6 +2986,29 @@ async function cmdFactoryd(opts) {
2935
2986
  };
2936
2987
  if (acquired.stalePid !== null)
2937
2988
  log(`removed stale pid file (pid ${acquired.stalePid})`);
2989
+ for (const notice of config.notices)
2990
+ log(notice);
2991
+ const updateFile = updateStatePath(runtime.dir);
2992
+ const previousUpdate = readUpdateState(updateFile);
2993
+ for (const stop of recoverStaleDrain(previousUpdate, (file) => {
2994
+ try {
2995
+ unlinkSync(file);
2996
+ return true;
2997
+ }
2998
+ catch {
2999
+ return false;
3000
+ }
3001
+ })) {
3002
+ log(`update: removed stale STOP ${stop}`);
3003
+ }
3004
+ const updateState = initialUpdateState(config, previousUpdate);
3005
+ try {
3006
+ writeUpdateState(updateFile, updateState);
3007
+ }
3008
+ catch (err) {
3009
+ log(`could not write ${updateFile}: ${errorDetail(err)}`);
3010
+ }
3011
+ log(formatUpdateStartupLine(updateState));
2938
3012
  const runRuntime = await createRunRuntime({
2939
3013
  registryFile,
2940
3014
  allowParallel: true,
@@ -2946,6 +3020,8 @@ async function cmdFactoryd(opts) {
2946
3020
  const banner = [
2947
3021
  `factoryd: listening on 127.0.0.1:${boundPort}`,
2948
3022
  ` registry: ${registryFile}`,
3023
+ ` update mode: ${config.autoUpdate.mode} (channel ${config.autoUpdate.channel ?? 'none'}, install ${config.install.kind})`,
3024
+ ` update state: ${updateFile}`,
2949
3025
  ` pid file: ${runtime.pidFile} (pid ${process.pid})`,
2950
3026
  ` port file: ${runtime.portFile}`,
2951
3027
  ` log file: ${runtime.logFile}`,
@@ -2957,8 +3033,26 @@ async function cmdFactoryd(opts) {
2957
3033
  console.log(line);
2958
3034
  for (const line of banner)
2959
3035
  logSink(line);
3036
+ let shuttingDown = false;
3037
+ void runAutoUpdateLoop(config, {
3038
+ ...createAutoUpdateDeps({
3039
+ registryFile,
3040
+ stateFile: updateFile,
3041
+ cliEntrypoint: fileURLToPath(new URL('../cli.js', import.meta.url)),
3042
+ check: () => checkForUpdate(config.autoUpdate.mode === 'prerelease' ? 'prerelease' : 'stable'),
3043
+ runRuntime,
3044
+ log,
3045
+ exit: async (code) => {
3046
+ await daemon.stop().catch(() => { });
3047
+ await releaseRuntimeFiles(runtime);
3048
+ process.exit(code);
3049
+ },
3050
+ }),
3051
+ stopped: () => shuttingDown,
3052
+ });
2960
3053
  await new Promise((resolveShutdown) => {
2961
3054
  const shutdown = () => {
3055
+ shuttingDown = true;
2962
3056
  void daemon
2963
3057
  .stop()
2964
3058
  .catch(() => { })
@@ -3083,6 +3177,20 @@ async function cmdRun(opts = {}) {
3083
3177
  pids.push(runLane(planned.lane, planned.issues, repoRoot, ghRepo, paths, {
3084
3178
  ...planned.deps,
3085
3179
  laneBreakerThreshold,
3180
+ baseHoldGate: opts.localQueue === true
3181
+ ? undefined
3182
+ : createBaseHoldGate({
3183
+ file: paths.baseHold,
3184
+ repo: ghRepo,
3185
+ onCleared: (c) => logEvent(paths.events, 'base-hold-cleared', planned.lane, formatBaseHoldCleared(c), {
3186
+ lane: planned.lane,
3187
+ }),
3188
+ git: (cmd) => shellOps.exec(cmd, { cwd: repoRoot }).then((r) => r.stdout),
3189
+ listCheckRuns: (ref) => {
3190
+ const [owner, repoName] = ghRepo.split('/');
3191
+ return createOctokitGreenPrClient(getOctokit()).listCheckRuns({ owner, repo: repoName, ref });
3192
+ },
3193
+ }),
3086
3194
  commentIssue: async (issue, body) => {
3087
3195
  const [owner, repoName] = ghRepo.split('/');
3088
3196
  await getOctokit().rest.issues.createComment({ owner, repo: repoName, issue_number: issue, body });
@@ -3108,8 +3216,12 @@ async function cmdRun(opts = {}) {
3108
3216
  try {
3109
3217
  const events = existsSync(paths.events) ? readEvents(paths.events) : [];
3110
3218
  const costs = existsSync(paths.costs) ? internalOps.readCosts(paths.costs) : [];
3111
- const kpis = computeHealthKpis(events, costs);
3112
- await appendKpiSnapshot(paths, repoRoot, kpis);
3219
+ const recon = await reconstructHumanEventsForKpis(events, branchPrefix);
3220
+ const kpis = computeHealthKpis(recon.events, costs);
3221
+ await appendKpiSnapshot(paths, repoRoot, kpis, { partial: recon.partial });
3222
+ if (recon.partial) {
3223
+ logEvent(paths.events, 'warn', 'all', `kpi snapshot partial: GitHub human-event reconstruction failed: ${recon.error}`);
3224
+ }
3113
3225
  logEvent(paths.events, 'kpi-snapshot', 'all', `KPI snapshot appended to ${paths.kpiHistory}`);
3114
3226
  }
3115
3227
  catch (err) {
@@ -3388,7 +3500,7 @@ export function renderLanePausedComment(payload) {
3388
3500
  ].join('\n\n');
3389
3501
  }
3390
3502
  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;
3503
+ const { ship = shipIssue, waitMerge = waitForMerge, pathExists = existsSync, emitEvent = logEvent, claimNext = async () => null, releaseIssue = async () => { }, heartbeat, reapWorktree = async () => { }, sweepFinished = async () => { }, countRemaining, baseHoldGate, laneBreakerThreshold = 0, commentIssue = async () => { }, } = deps;
3392
3504
  let streak = null;
3393
3505
  let merged = 0;
3394
3506
  let awaitingReview = 0;
@@ -3420,6 +3532,13 @@ export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {})
3420
3532
  };
3421
3533
  for (let i = 0;; i++) {
3422
3534
  if (i >= pending.length) {
3535
+ // A persisted base hold on the unchanged origin/main SHA means the base is still broken
3536
+ // (#2349): claiming would just cycle an issue again, so stop this lane without claiming.
3537
+ const gate = baseHoldGate ? await baseHoldGate().catch(() => ({ kind: 'claim' })) : null;
3538
+ if (gate?.kind === 'hold') {
3539
+ emitEvent(paths.events, 'warn', lane, `lane '${lane}' not claiming: base hold on ${gate.hold.baseSha.slice(0, 8)} (${gate.hold.failingChecks.join(', ')}) — origin/main unchanged and its CI is not green`, { lane });
3540
+ return;
3541
+ }
3423
3542
  const claimed = await claimNext();
3424
3543
  if (claimed === null)
3425
3544
  break;
@@ -3503,6 +3622,17 @@ export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {})
3503
3622
  streak = null;
3504
3623
  await reapWorktree(issue).catch(() => { });
3505
3624
  await settle(issue, 'queued');
3625
+ // Persist the hold so a restarted supervisor remembers the broken base (#2348);
3626
+ // best-effort, never allowed to change lane flow.
3627
+ try {
3628
+ writeBaseHold(paths.baseHold, {
3629
+ repo: ghRepo,
3630
+ baseSha: err.baseSha,
3631
+ failingChecks: err.failingChecks,
3632
+ since: new Date().toISOString(),
3633
+ });
3634
+ }
3635
+ catch { }
3506
3636
  const payload = {
3507
3637
  lane,
3508
3638
  signature: err.failureSignature ?? '',
@@ -3697,6 +3827,12 @@ export class CiFailedError extends Error {
3697
3827
  * exactly like "we know it's red": never merge. */
3698
3828
  export class CiUnverifiedError extends CiFailedError {
3699
3829
  }
3830
+ /** Thrown when the one infra rerun (#2365) also failed for infrastructure reasons on the same head SHA (#2366).
3831
+ * A CiFailedError subclass so every guard still leaves the PR open and unmerged, but it parks as
3832
+ * 'ci-infra', which the ADR-0155 steward skips. */
3833
+ export class CiInfraError extends CiFailedError {
3834
+ parkReason = 'ci-infra';
3835
+ }
3700
3836
  const MAX_MERGE_ATTEMPTS = 5;
3701
3837
  const MERGE_RETRY_BASE_MS = 5_000;
3702
3838
  /** True when GitHub refused a SHA-pinned merge because the PR head moved past the verified
@@ -3753,7 +3889,7 @@ export async function rebaseDirtyPullRequest(opts) {
3753
3889
  }
3754
3890
  }
3755
3891
  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;
3892
+ 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, rerun, withLock = (fn) => fn(), ensureWorktree, } = opts;
3757
3893
  /** Watches CI on the PR's current head commit and returns that SHA once it is green, so the
3758
3894
  * merge can be pinned to exactly the commit CI verified. */
3759
3895
  const watchCi = async () => {
@@ -3762,7 +3898,16 @@ export async function landOpenPullRequest(opts) {
3762
3898
  try {
3763
3899
  const { data: pr } = await octokit.rest.pulls.get({ owner, repo: repoName, pull_number: prNumber });
3764
3900
  headSha = pr.head.sha;
3765
- outcome = await watch({ octokit, owner, repo: repoName, ref: headSha });
3901
+ outcome = await watchChecksWithInfraRerun({
3902
+ octokit,
3903
+ owner,
3904
+ repo: repoName,
3905
+ ref: headSha,
3906
+ watch,
3907
+ rerun,
3908
+ onRescued: ({ runIds }) => log('ci-rescued', `CI rescued for PR #${prNumber} on ${branch}: reran infra-failed run(s) ${runIds.join(', ')} once and the re-watch is green`),
3909
+ onRescueFailed: ({ runIds, outcome: failedOutcome }) => log('ci-rescue-failed', `CI rescue failed for PR #${prNumber} on ${branch}: reran infra-failed run(s) ${runIds.join(', ')} once and the re-watch ended ${failedOutcome}`),
3910
+ });
3766
3911
  }
3767
3912
  catch (err) {
3768
3913
  const msg = `CI watch for ${branch} failed: ${errorDetail(err)} — refusing to merge without a green verdict`;
@@ -3771,6 +3916,11 @@ export async function landOpenPullRequest(opts) {
3771
3916
  }
3772
3917
  if (outcome === 'success')
3773
3918
  return headSha;
3919
+ if (outcome === 'infra-failure') {
3920
+ const msg = `CI failed again for infrastructure reasons for PR #${prNumber} on ${branch} after one rerun — parked as ci-infra, not merged`;
3921
+ log('ci-infra', msg);
3922
+ throw new CiInfraError(msg, prNumber);
3923
+ }
3774
3924
  if (outcome === 'failure') {
3775
3925
  const msg = `CI failed for PR #${prNumber} on ${branch} — refusing to merge with a failing check`;
3776
3926
  log('ci-failed', msg);
@@ -4184,14 +4334,17 @@ async function cmdDoctor(opts = {}) {
4184
4334
  }
4185
4335
  let sandboxed = 'skipped';
4186
4336
  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
- });
4337
+ const runtimeAvailable = hasSandboxRuntime(process.platform, (cmd) => isCommandAvailableViaShell(shellOps, cmd));
4338
+ const skip = sandboxAuthSkipDetail({ runtimeAvailable, hostOk: host === 'ok', inRepo: repoRoot !== null });
4339
+ if (skip !== null) {
4340
+ sandboxDetail = skip;
4341
+ }
4342
+ else if (repoRoot !== null) {
4343
+ const policy = resolveSandboxPolicy(configLoaders.loadFactoryConfigForRepo(getFactoryPaths(repoRoot).config).sandbox, {
4344
+ worktree: repoRoot,
4345
+ repoRoot,
4346
+ isAvailable: (cmd) => isCommandAvailableViaShell(shellOps, cmd),
4347
+ });
4195
4348
  if (!policy) {
4196
4349
  sandboxDetail = 'skipped — sandbox disabled by config or FACTORY_SANDBOX';
4197
4350
  }
@@ -4374,7 +4527,19 @@ export async function main(argv = process.argv, deps = {}) {
4374
4527
  .description('Multi-agent software factory with boss-worker-checker orchestration')
4375
4528
  .version(getCliVersion())
4376
4529
  .addHelpText('before', PREREQUISITES_TEXT)
4377
- .addHelpText('after', HELP_FOOTER_TEXT);
4530
+ .addHelpText('after', HELP_FOOTER_TEXT)
4531
+ .option('--state-root <path>', 'Keep factory state and config outside the checkout: an absolute path, ~/..., or "auto" (~/.factory/<owner>/<name>); env FACTORY_STATE_ROOT');
4532
+ program.hook('preAction', async () => {
4533
+ const flag = program.opts().stateRoot;
4534
+ if (flag === undefined && !process.env.FACTORY_STATE_ROOT?.trim())
4535
+ return;
4536
+ try {
4537
+ applyStateRootOption(flag, await getRepoRoot());
4538
+ }
4539
+ catch (e) {
4540
+ throw new CliExitError(`factory: ${e.message}`, 2);
4541
+ }
4542
+ });
4378
4543
  program
4379
4544
  .command('init')
4380
4545
  .description('Set up .factory/ in this repo (config, constitution, queue file, .gitignore) and check model reachability')
@@ -4419,7 +4584,7 @@ export async function main(argv = process.argv, deps = {}) {
4419
4584
  .command('status')
4420
4585
  .description('Show active runs, the GitHub queue, provider health, and recent events')
4421
4586
  .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')
4587
+ .option('--json', 'Print one JSON object (schemaVersion 1): repo, product, stop, breaker, active claims, update; --kpis is ignored')
4423
4588
  .action((opts) => cmdStatus(opts));
4424
4589
  program
4425
4590
  .command('runs')
@@ -4485,6 +4650,8 @@ export async function main(argv = process.argv, deps = {}) {
4485
4650
  .command('feedback <pr-url>')
4486
4651
  .description("File one factory issue (bug, factory:needs-triage) from a PR's human review findings")
4487
4652
  .option('--note <text>', 'Use this as the issue title instead of the first finding')
4653
+ .option('--repo <owner/name>', 'Repo to file the issue in (default: filing.repo, then the origin remote)')
4654
+ .option('--include-local-paths', 'Include this host name and local design/log paths in the issue')
4488
4655
  .option('--branch-prefix <prefix>', BRANCH_PREFIX_MATCH_OPTION_DESCRIPTION)
4489
4656
  .action(cmdFeedback);
4490
4657
  const filingCmd = program.command('filing').description('Inspect upstream factory reports (nothing is sent)');
@@ -4696,7 +4863,8 @@ export async function main(argv = process.argv, deps = {}) {
4696
4863
  .description('Run factoryd in the foreground: a localhost-only HTTP API over the repo registry')
4697
4864
  .option('--port <n>', `Port to bind on 127.0.0.1 (default ${DEFAULT_FACTORYD_PORT})`)
4698
4865
  .option('--registry <file>', 'Registry file to serve (default ~/.factory/registry.json)')
4699
- .option('--auto-update', 'Opt in to automatic factoryd self-update (default: off)')
4866
+ .option('--update-mode <mode>', 'Self-update channel: manual, stable (npm latest) or prerelease (npm next) (default: stable on npm installs, manual otherwise)')
4867
+ .option('--auto-update', 'Deprecated: same as --update-mode stable')
4700
4868
  .action((opts) => cmdFactoryd(opts));
4701
4869
  const daemonCtl = (fn) => fn().catch((err) => {
4702
4870
  if (err instanceof DaemonCtlError)
@@ -4722,6 +4890,17 @@ export async function main(argv = process.argv, deps = {}) {
4722
4890
  .option('-f, --follow', 'Keep tailing')
4723
4891
  .option('-n, --lines <n>', 'Lines to print first (default 100)')
4724
4892
  .action((opts) => daemonCtl(() => cmdDaemonLogs(opts, deps.daemon)));
4893
+ program
4894
+ .command('self-update')
4895
+ .description('Check npm for a newer factory release (read-only; nothing is installed)')
4896
+ .option('--check', 'Compare the running version with the npm dist-tag (required)')
4897
+ .option('--json', 'Print one JSON object instead of text')
4898
+ .option('--update-mode <mode>', 'Channel to check: stable (npm latest, default) or prerelease (npm next)')
4899
+ .action(async (opts) => {
4900
+ if (opts.check !== true)
4901
+ throw new CliExitError('factory self-update: only --check is supported', 2);
4902
+ process.exitCode = await daemonCtl(() => cmdSelfUpdateCheck(opts, deps.selfUpdate));
4903
+ });
4725
4904
  const worktreeCmd = program.command('worktree').description('Clean up stale factory worktrees (gc)');
4726
4905
  worktreeCmd
4727
4906
  .command('gc')