opencode-feature-factory 0.2.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.
- package/LICENSE +21 -0
- package/README.md +877 -0
- package/assets/agent/backend-builder.md +73 -0
- package/assets/agent/codebase-researcher.md +56 -0
- package/assets/agent/design-interpreter.md +37 -0
- package/assets/agent/frontend-builder.md +74 -0
- package/assets/agent/implementation-validator.md +73 -0
- package/assets/agent/security-reviewer.md +60 -0
- package/assets/agent/spec-writer.md +94 -0
- package/assets/agent/story-reader.md +38 -0
- package/assets/agent/story-writer.md +39 -0
- package/assets/agent/test-verifier.md +73 -0
- package/assets/agent/work-decomposer.md +102 -0
- package/assets/agent/work-reviewer.md +77 -0
- package/assets/command/feature.md +68 -0
- package/assets/skills/feature/SCHEMA.md +990 -0
- package/assets/skills/feature/SKILL.md +620 -0
- package/dist/tui.js +3641 -0
- package/package.json +65 -0
- package/src/cleanup-sweep-command.js +75 -0
- package/src/cleanup-sweep-eligibility.js +581 -0
- package/src/cleanup-sweep-output.js +139 -0
- package/src/cleanup-sweep-report.js +548 -0
- package/src/cleanup-sweep.js +546 -0
- package/src/cli-output.js +251 -0
- package/src/cli.js +1152 -0
- package/src/config.js +231 -0
- package/src/cost-attribution.js +327 -0
- package/src/cost-report.js +185 -0
- package/src/detached-log-supervisor.js +178 -0
- package/src/doctor.js +598 -0
- package/src/env-snapshot.js +211 -0
- package/src/factory-diagnostics.js +429 -0
- package/src/factory-paths.js +40 -0
- package/src/factory.js +4769 -0
- package/src/feature-command-payload.js +378 -0
- package/src/git.js +110 -0
- package/src/github.js +252 -0
- package/src/hardening/atomic-write.js +954 -0
- package/src/hardening/line-output.js +139 -0
- package/src/hardening/output-policy.js +365 -0
- package/src/hardening/process-verification.js +542 -0
- package/src/hardening/sensitive-data.js +341 -0
- package/src/hardening/terminal-encoding.js +224 -0
- package/src/plugin.js +246 -0
- package/src/post-pr-ci.js +754 -0
- package/src/process-evidence.js +1139 -0
- package/src/refs.js +144 -0
- package/src/run-state.js +2411 -0
- package/src/steering-conflicts.js +77 -0
- package/src/telemetry.js +419 -0
- package/src/tui-data.js +499 -0
- package/src/tui-rendering.js +148 -0
- package/src/utils.js +35 -0
- package/src/validate.js +1655 -0
- package/src/worktrees.js +56 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,1152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { closeSync, constants as FS_CONSTANTS, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { abortSteeringAction, acknowledgeSteering, acknowledgeSteeringActionStart, adoptContinuation, cancelFactoryRun, cleanupRun, clearPrePrFence, consumeSteering, continueFactory, crossSteeringBoundary, establishPrePrFence, heartbeatStatus, listRuns, openSteeringBoundary, persistFactoryRunCreatedEnv, persistFactoryRunResumeEnv, postPrObserve, postPrRemediation, recordCostUsage, recordSteeringConflict, recoverDisruptedRun, resumeFactory, startFactory, startHeartbeat, status, stopHeartbeat, transitionGateDecisionAndHandoff, validateState, watchRun, writeGateAnswer, writeSteering } from "./factory.js";
|
|
9
|
+
import { formatCostAttributionSummary, sanitizePublicCostText } from "./cost-attribution.js";
|
|
10
|
+
import { buildCostReport, formatCostReport } from "./cost-report.js";
|
|
11
|
+
import { runDoctor } from "./doctor.js";
|
|
12
|
+
import { collectEnv } from "./env-snapshot.js";
|
|
13
|
+
import { readJsoncConfig } from "./config.js";
|
|
14
|
+
import { canonicalizeGithubPrUrl } from "./refs.js";
|
|
15
|
+
import { normalizePrNumber as normalizeTransitionPrNumber, transitionPrCreated, transitionRecoverOrphan, transitionRunJson, transitionRunSlice, transitionRunStep, transitionSliceMerged, transitionTerminalResult } from "./run-state.js";
|
|
16
|
+
import { HEARTBEAT_PROTECTED_GATES, validateRun, validateSlicesPlan } from "./validate.js";
|
|
17
|
+
import { isContainedPath } from "./utils.js";
|
|
18
|
+
import { factoryRepoFromRunDir, factoryRootsForLookup } from "./factory-paths.js";
|
|
19
|
+
import { printCliResult, projectCliData, projectCostReport, renderCliPath } from "./cli-output.js";
|
|
20
|
+
import { freeformSegment, identitySegment, renderErrorForTerminal, renderTerminalSegments, StructuredOutputError, TRUSTED_SEGMENTS } from "./hardening/output-policy.js";
|
|
21
|
+
import { serializeTerminalJson } from "./hardening/terminal-encoding.js";
|
|
22
|
+
import { runCleanupSweepCommand } from "./cleanup-sweep-command.js";
|
|
23
|
+
import { renderCleanupSweepReport } from "./cleanup-sweep-output.js";
|
|
24
|
+
import { executeCleanupSweep, previewCleanupSweep } from "./cleanup-sweep.js";
|
|
25
|
+
|
|
26
|
+
const cliPath = fileURLToPath(import.meta.url);
|
|
27
|
+
const root = dirname(dirname(cliPath));
|
|
28
|
+
const HEARTBEAT_PROTECTED_GATE_SET = new Set(HEARTBEAT_PROTECTED_GATES);
|
|
29
|
+
const HEARTBEAT_STEP_IN_FLIGHT_STATUSES = new Set(["running"]);
|
|
30
|
+
const HEARTBEAT_SLICE_IN_FLIGHT_STATUSES = new Set(["running", "review"]);
|
|
31
|
+
const HEARTBEAT_START_TIMEOUT_MS = 5000;
|
|
32
|
+
const HEARTBEAT_START_POLL_MS = 25;
|
|
33
|
+
const BOOLEAN_FLAGS = new Set(["--json", "--local", "--profiles", "--provider-smoke", "--telemetry", "--autonomous", "--detached", "--all", "--headless", "--ready", "--force", "--dry-run", "--start", "--stop", "--status", "--foreground", "--draft", "--no-draft", "--clear", "--post-pr-ci", "--no-post-pr-ci", "--new-pr"]);
|
|
34
|
+
const VALUE_FLAGS = new Set(["--repo", "--gh-account", "--model", "--interval", "--phase", "--reviewer", "--review", "--run-id", "--from", "--artifact", "--question-ref", "--answer-ref", "--answer", "--approval-source", "--decision-note", "--answered-at", "--reason", "--merge-commit", "--pr-url", "--pr-number", "--repository", "--head-sha", "--branch", "--worktree", "--attempts", "--evidence-ref", "--review-ref", "--artifact-ref", "--validator", "--security", "--report", "--message", "--ref", "--hash", "--boundary-token", "--action-token", "--fence-token", "--agent", "--step", "--slice-id", "--provider", "--source", "--operation", "--request-id", "--input-tokens", "--output-tokens", "--total-tokens", "--cache-creation-input-tokens", "--cache-read-input-tokens", "--reasoning-tokens", "--cost-total", "--cost-input", "--cost-output", "--cost-cache-creation", "--cost-cache-read", "--currency", "--recorded-at", "--entry-id", "--parent-span-id", "--traceparent", "--tracestate", "--post-pr-wait-minutes", "--post-pr-poll-seconds", "--post-pr-max-poll-seconds", "--post-pr-check-start-grace-seconds", "--post-pr-max-transient-errors", "--remediation-evidence-ref", "--failure-evidence-ref", "--test-evidence-ref", "--validator-report-ref", "--validator-review-ref", "--security-review-ref"]);
|
|
35
|
+
const COST_REPORT_BOOLEAN_FLAGS = new Set(["--json", "--telemetry"]);
|
|
36
|
+
const COST_REPORT_VALUE_FLAGS = new Set(["--repo"]);
|
|
37
|
+
const COST_NUMERIC_FLAGS = new Map([
|
|
38
|
+
["--input-tokens", "inputTokens"],
|
|
39
|
+
["--output-tokens", "outputTokens"],
|
|
40
|
+
["--total-tokens", "totalTokens"],
|
|
41
|
+
["--cache-creation-input-tokens", "cacheCreationInputTokens"],
|
|
42
|
+
["--cache-read-input-tokens", "cacheReadInputTokens"],
|
|
43
|
+
["--reasoning-tokens", "reasoningTokens"],
|
|
44
|
+
["--cost-total", "costTotal"],
|
|
45
|
+
["--cost-input", "costInput"],
|
|
46
|
+
["--cost-output", "costOutput"],
|
|
47
|
+
["--cost-cache-creation", "costCacheCreation"],
|
|
48
|
+
["--cost-cache-read", "costCacheRead"],
|
|
49
|
+
]);
|
|
50
|
+
const SAFE_COST_REPORT_RUN_ID_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/u;
|
|
51
|
+
|
|
52
|
+
function usage(write = console.log) {
|
|
53
|
+
write(`feature-factory
|
|
54
|
+
|
|
55
|
+
Commands:
|
|
56
|
+
install [--local] Add this package to ~/.config/opencode/opencode.jsonc
|
|
57
|
+
doctor [--local] [--profiles] [--telemetry] Check opencode/plugin/provider/tool prerequisites
|
|
58
|
+
factory start [--repo PATH] [--run-id ID] [--gh-account ACCOUNT] [--post-pr-ci|--no-post-pr-ci] [--headless|--autonomous|--detached] [--draft|--ready|--no-draft] [--parent-span-id ID] [--traceparent VALUE] [--tracestate VALUE] <prompt...>
|
|
59
|
+
factory resume-check <run-id> [--json] Recover/verify a disrupted resume without re-scaffolding
|
|
60
|
+
factory continue <blocked-run-id> --review <review-ref> --run-id <new-run-id> [--new-pr] [--post-pr-ci|--no-post-pr-ci] [--draft|--ready|--no-draft] [--dry-run] [--parent-span-id ID] [--traceparent VALUE] [--tracestate VALUE]
|
|
61
|
+
factory cancel <run-id> [--json]
|
|
62
|
+
factory steer <run-id> --message TEXT [--json]
|
|
63
|
+
factory steer-consume <run-id> --ref steering/<file>.json --hash sha256:<hash> [--json]
|
|
64
|
+
factory steer-ack <run-id> --ref steering/consumed-<file>.json --hash sha256:<hash> [--json]
|
|
65
|
+
factory steer-conflict <run-id> --ref steering/<file>.json --hash sha256:<hash> [--reason TEXT] [--json]
|
|
66
|
+
factory boundary-open <run-id> <gate|dispatch|remediation|terminal> [--json]
|
|
67
|
+
factory boundary-cross <run-id> <dispatch|remediation> --boundary-token TOKEN [--json]
|
|
68
|
+
factory action-started <run-id> <dispatch|remediation> --action-token TOKEN [--json]
|
|
69
|
+
factory action-abort <run-id> <dispatch|remediation> --action-token TOKEN [--json]
|
|
70
|
+
factory pr-fence <run-id> [--clear --fence-token TOKEN] [--json]
|
|
71
|
+
factory cost-record <run-id> --agent AGENT [--step STEP] [--slice-id ID] [--provider PROVIDER] [--model MODEL] [--source SOURCE] [--operation OP] [--request-id ID] [--input-tokens N] [--output-tokens N] [--total-tokens N] [--cache-creation-input-tokens N] [--cache-read-input-tokens N] [--reasoning-tokens N] [--cost-total N] [--cost-input N] [--cost-output N] [--cost-cache-creation N] [--cost-cache-read N] [--currency CODE] [--recorded-at ISO] [--entry-id ID] [--json]
|
|
72
|
+
factory cost-report <run-id> [--json] [--telemetry]
|
|
73
|
+
factory resume <run-id> [--headless|--autonomous|--detached] [--dry-run] [--json] [--parent-span-id ID] [--traceparent VALUE] [--tracestate VALUE]
|
|
74
|
+
factory list List local factory runs
|
|
75
|
+
factory status [run-id] Read .opencode/factory state
|
|
76
|
+
factory heartbeat <run-id> --start --phase <phase> [--interval MS] [--json] Start detached liveness ticker
|
|
77
|
+
factory heartbeat <run-id> --stop [--json]
|
|
78
|
+
factory heartbeat <run-id> --status [--json]
|
|
79
|
+
factory validate [run-id] Validate run.json and plan/slices.json
|
|
80
|
+
factory recover <run-id> [--reason TEXT] Mark orphaned/stale running run as needs-human
|
|
81
|
+
factory cleanup <run-id> [--dry-run] [--force] [--repo PATH] [--json]
|
|
82
|
+
factory cleanup --all --dry-run [--repo PATH] [--json]
|
|
83
|
+
factory cleanup --all --digest ff-cleanup-v1.<repository-sha256>.<envelope-sha256> [--repo PATH] [--json]
|
|
84
|
+
factory answer [--repo PATH] [--json] <run> <gate> <approve|stop|changes: ...>
|
|
85
|
+
factory gate-decision <run> <gate> <pending|approved|changes_requested|stopped> [--artifact REF] [--question-ref REF] [--answer-ref REF|--answer TEXT] [--approval-source SOURCE] [--boundary-token TOKEN]
|
|
86
|
+
factory slices-seed <run-id> --from plan/slices.json
|
|
87
|
+
factory slice-status <run-id> <slice-id> <running|review|blocked> [--branch REF] [--worktree PATH] [--attempts N] [--evidence-ref REF] [--review-ref REF] [--reason TEXT]
|
|
88
|
+
factory step <run-id> <agent> <running|accepted|rejected|blocked> [--artifact-ref REF] [--evidence-ref REF] [--review-ref REF] [--attempts N]
|
|
89
|
+
factory verdicts <run-id> --validator GO|GO-WITH-NITS|NO-GO --report artifacts/validation-report.md --security PASS|BLOCK --review-ref reviews/security-reviewer.json
|
|
90
|
+
factory terminal <run-id> <blocked|partial|needs-human> --reason TEXT --boundary-token TOKEN
|
|
91
|
+
factory slice-merged <run-id> <slice-id> --merge-commit SHA [--json]
|
|
92
|
+
factory pr-created <run-id> --pr-url URL --pr-number N --repository OWNER/REPO --fence-token TOKEN [--head-sha SHA] [--draft|--no-draft] [--json]
|
|
93
|
+
factory post-pr-observe <run-id> [--json]
|
|
94
|
+
factory post-pr-remediation <run-id> <attempt> running [--json]
|
|
95
|
+
factory post-pr-remediation <run-id> <attempt> revalidating --remediation-evidence-ref REF [--json]
|
|
96
|
+
factory post-pr-remediation <run-id> <attempt> failed --failure-evidence-ref REF [--json]
|
|
97
|
+
factory post-pr-remediation <run-id> <attempt> complete --head-sha SHA --test-evidence-ref REF --validator-report-ref REF --validator-review-ref REF --security-review-ref REF [--json]
|
|
98
|
+
factory watch [run-id] [--all] Print status changes as JSON
|
|
99
|
+
factory env Print detected versions, models, and capabilities
|
|
100
|
+
factory provenance Alias for factory env
|
|
101
|
+
`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function runCliCommand(argv, dependencies = {}) {
|
|
105
|
+
const [cmd, ...rest] = argv;
|
|
106
|
+
if (!cmd || cmd === "--help" || cmd === "-h") return usage();
|
|
107
|
+
if (cmd === "install") return install(rest);
|
|
108
|
+
if (cmd === "doctor") return doctor(rest);
|
|
109
|
+
if (cmd === "factory") return factory(rest, dependencies);
|
|
110
|
+
throw new Error(`unknown command: ${cmd}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function install(args) {
|
|
114
|
+
const local = args.includes("--local");
|
|
115
|
+
const home = homedir();
|
|
116
|
+
const configPath = join(home, ".config", "opencode", "opencode.jsonc");
|
|
117
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
118
|
+
const pluginSpec = local ? localPluginSpec() : "opencode-feature-factory";
|
|
119
|
+
const cfg = readJsoncConfig(configPath);
|
|
120
|
+
cfg.$schema ??= "https://opencode.ai/config.json";
|
|
121
|
+
cfg.plugin ??= [];
|
|
122
|
+
const oldSpec = local ? oldLocalPluginSpec() : null;
|
|
123
|
+
const matchesSpec = (entry) => pluginEntrySpec(entry) === pluginSpec || (oldSpec && pluginEntrySpec(entry) === oldSpec);
|
|
124
|
+
// Single-entry contract: keep exactly one registration. Prefer the first tuple
|
|
125
|
+
// match so existing options survive, rewrite its spec, and drop every other
|
|
126
|
+
// matching string/tuple duplicate (including stale legacy-local specs).
|
|
127
|
+
const matchIndexes = cfg.plugin.map((entry, index) => (matchesSpec(entry) ? index : -1)).filter((index) => index >= 0);
|
|
128
|
+
const hit = matchIndexes.find((index) => Array.isArray(cfg.plugin[index])) ?? matchIndexes[0] ?? -1;
|
|
129
|
+
if (hit === -1) cfg.plugin.push(pluginSpec);
|
|
130
|
+
if (hit !== -1 && Array.isArray(cfg.plugin[hit])) cfg.plugin[hit][0] = pluginSpec;
|
|
131
|
+
if (hit !== -1 && !Array.isArray(cfg.plugin[hit])) cfg.plugin[hit] = pluginSpec;
|
|
132
|
+
if (hit !== -1) cfg.plugin = cfg.plugin.filter((entry, index) => index === hit || !matchesSpec(entry));
|
|
133
|
+
writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n");
|
|
134
|
+
console.log(`configured opencode plugin: ${renderCliPath(pluginSpec)}`);
|
|
135
|
+
console.log(`updated: ${renderCliPath(configPath)}`);
|
|
136
|
+
console.log("restart opencode for plugin changes to take effect");
|
|
137
|
+
warnGlobalFeatureSkillConflicts(findGlobalFeatureSkillConflicts(home));
|
|
138
|
+
warnGlobalAgentConflicts(findGlobalAgentConflicts(home));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function findGlobalAgentConflicts(home) {
|
|
142
|
+
const names = readdirSync(join(root, "assets", "agent")).filter((name) => name.endsWith(".md"));
|
|
143
|
+
return names.flatMap((name) => [
|
|
144
|
+
join(home, ".config", "opencode", "agent", name),
|
|
145
|
+
join(home, ".config", "opencode", "agents", name),
|
|
146
|
+
]).filter((path) => existsSync(path));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function warnGlobalAgentConflicts(paths) {
|
|
150
|
+
if (!paths.length) return;
|
|
151
|
+
console.warn([
|
|
152
|
+
"",
|
|
153
|
+
"WARNING: existing global feature-factory agent definitions detected.",
|
|
154
|
+
"These files are not managed by opencode-feature-factory and can shadow the plugin's current prompts:",
|
|
155
|
+
...paths.map((path) => `- ${renderCliPath(path)}`),
|
|
156
|
+
"Remove stale files, or replace them with delegators that defer to the plugin-owned agents.",
|
|
157
|
+
"Restart opencode after changing agent files.",
|
|
158
|
+
].join("\n"));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function findGlobalFeatureSkillConflicts(home) {
|
|
162
|
+
return [
|
|
163
|
+
join(home, ".config", "opencode", "skills", "feature", "SKILL.md"),
|
|
164
|
+
join(home, ".config", "opencode", "skill", "feature", "SKILL.md"),
|
|
165
|
+
join(home, ".claude", "skills", "feature", "SKILL.md"),
|
|
166
|
+
join(home, ".agents", "skills", "feature", "SKILL.md"),
|
|
167
|
+
].filter((path) => existsSync(path));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function warnGlobalFeatureSkillConflicts(paths) {
|
|
171
|
+
if (!paths.length) return;
|
|
172
|
+
console.warn([
|
|
173
|
+
"",
|
|
174
|
+
"WARNING: existing global feature skill detected.",
|
|
175
|
+
"These files are not installed or managed by opencode-feature-factory and can shadow or conflict with the plugin's current feature workflow:",
|
|
176
|
+
...paths.map((path) => `- ${renderCliPath(path)}`),
|
|
177
|
+
"Remove stale files, or replace them with a delegator that reads the repo-seeded .opencode/skills/feature/SKILL.md before mutating factory state.",
|
|
178
|
+
"Restart opencode after changing skill files.",
|
|
179
|
+
].join("\n"));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function doctor(args) {
|
|
183
|
+
const ok = await runDoctor(options(args));
|
|
184
|
+
process.exitCode = ok ? 0 : 1;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function factory(args, dependencies = {}) {
|
|
188
|
+
const [sub, ...rest] = args;
|
|
189
|
+
if (sub === "answer") return answer(rest);
|
|
190
|
+
if (sub === "cost-report") return costReport(rest);
|
|
191
|
+
if (sub === "cleanup" && rest.some((argument) => argument === "--all" || argument === "--digest" || argument.startsWith("--all=") || argument.startsWith("--digest="))) return cleanupSweep(rest);
|
|
192
|
+
const opts = { ...options(rest), ...(dependencies.factoryOptions || {}) };
|
|
193
|
+
const positional = positionals(rest);
|
|
194
|
+
if (sub === "start") {
|
|
195
|
+
if (opts.dryRun) throw new Error("factory start --dry-run is unsupported");
|
|
196
|
+
const result = await startFactory(positional, opts);
|
|
197
|
+
print(result, opts);
|
|
198
|
+
if (result && typeof result === "object" && result.ok === false) process.exitCode = 1;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (sub === "resume-check") {
|
|
202
|
+
if (positional.length !== 1) throw new Error("factory resume-check requires exactly one <run-id>");
|
|
203
|
+
const result = await recoverDisruptedRun(positional[0], opts);
|
|
204
|
+
print(result, opts);
|
|
205
|
+
if (!result.ok) process.exitCode = 1;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (sub === "continue") {
|
|
209
|
+
if (positional.length !== 1) throw new Error("factory continue requires exactly one <blocked-run-id>");
|
|
210
|
+
return print(await continueFactory(positional[0], opts), opts);
|
|
211
|
+
}
|
|
212
|
+
if (sub === "adopt-continuation") {
|
|
213
|
+
if (positional.length !== 1) throw new Error("factory adopt-continuation requires exactly one <child-run-id>");
|
|
214
|
+
return print(await adoptContinuation(positional[0], opts), opts);
|
|
215
|
+
}
|
|
216
|
+
if (sub === "cancel") return cancel(rest);
|
|
217
|
+
if (sub === "steer") return steer(rest);
|
|
218
|
+
if (sub === "steer-consume") return steerConsume(rest);
|
|
219
|
+
if (sub === "steer-ack") return steerAck(rest);
|
|
220
|
+
if (sub === "steer-conflict") return steerConflict(rest);
|
|
221
|
+
if (sub === "boundary-open") return boundaryOpen(rest);
|
|
222
|
+
if (sub === "boundary-cross") return boundaryCross(rest);
|
|
223
|
+
if (sub === "action-started") return actionStarted(rest);
|
|
224
|
+
if (sub === "action-abort") return actionAbort(rest);
|
|
225
|
+
if (sub === "pr-fence") return prFence(rest);
|
|
226
|
+
if (sub === "cost-record") return costRecord(rest);
|
|
227
|
+
if (sub === "resume") return resume(rest, dependencies);
|
|
228
|
+
if (sub === "list") return print(listRuns(opts), opts);
|
|
229
|
+
if (sub === "status") return print(status(positional[0], opts), opts);
|
|
230
|
+
if (sub === "heartbeat") return heartbeat(rest);
|
|
231
|
+
if (sub === "cleanup") return print(await cleanupRun(positional[0], opts), opts);
|
|
232
|
+
if (sub === "recover") return recover(rest);
|
|
233
|
+
if (sub === "validate") {
|
|
234
|
+
const result = validateState(positional[0], opts);
|
|
235
|
+
print(result, { ...opts, json: true });
|
|
236
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (sub === "env" || sub === "provenance") return env(rest);
|
|
240
|
+
if (sub === "gate-decision") return gateDecision(rest, dependencies);
|
|
241
|
+
if (sub === "slices-seed") return slicesSeed(rest);
|
|
242
|
+
if (sub === "slice-status") return sliceStatus(rest);
|
|
243
|
+
if (sub === "step") return step(rest);
|
|
244
|
+
if (sub === "verdicts") return verdicts(rest);
|
|
245
|
+
if (sub === "terminal") return terminal(rest);
|
|
246
|
+
if (sub === "slice-merged") return sliceMerged(rest);
|
|
247
|
+
if (sub === "pr-created") return prCreated(rest);
|
|
248
|
+
if (sub === "post-pr-observe") {
|
|
249
|
+
if (positional.length !== 1) throw new Error("factory post-pr-observe requires exactly one <run-id>");
|
|
250
|
+
return print(await postPrObserve(positional[0], opts), opts);
|
|
251
|
+
}
|
|
252
|
+
if (sub === "post-pr-remediation") {
|
|
253
|
+
if (positional.length !== 3) throw new Error("factory post-pr-remediation requires <run-id> <attempt> <running|revalidating|failed|complete>");
|
|
254
|
+
return print(await postPrRemediation(positional[0], positional[1], positional[2], opts), opts);
|
|
255
|
+
}
|
|
256
|
+
if (sub === "watch") {
|
|
257
|
+
watchRun(positional[0], opts);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
console.error(renderTerminalSegments([
|
|
261
|
+
freeformSegment("unknown factory command"),
|
|
262
|
+
TRUSTED_SEGMENTS.COLON_SPACE,
|
|
263
|
+
freeformSegment(sub || ""),
|
|
264
|
+
]).trim());
|
|
265
|
+
usage(console.error);
|
|
266
|
+
process.exitCode = 1;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function cleanupSweep(args) {
|
|
270
|
+
process.exitCode = await runCleanupSweepCli(args);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function runCleanupSweepCli(args, dependencies = {}) {
|
|
274
|
+
const runCommand = dependencies.runCommand ?? runCleanupSweepCommand;
|
|
275
|
+
const render = dependencies.render ?? renderCleanupSweepReport;
|
|
276
|
+
const stdout = dependencies.stdout ?? console.log;
|
|
277
|
+
const stderr = dependencies.stderr ?? console.error;
|
|
278
|
+
try {
|
|
279
|
+
const result = await runCommand(args, {
|
|
280
|
+
preview: dependencies.preview ?? previewCleanupSweep,
|
|
281
|
+
execute: dependencies.execute ?? executeCleanupSweep,
|
|
282
|
+
});
|
|
283
|
+
stdout(render(result.report, { json: args.includes("--json") }));
|
|
284
|
+
return result.exitCode;
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (!(error instanceof StructuredOutputError)) throw error;
|
|
287
|
+
stderr(`error: ${renderErrorForTerminal(error)}`);
|
|
288
|
+
return 1;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function answer(args) {
|
|
293
|
+
const { opts, runId, gate, answerText } = parseAnswerArgs(args);
|
|
294
|
+
return print(writeGateAnswer(runId, gate, answerText, opts), opts);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function steer(args) {
|
|
298
|
+
const opts = options(args);
|
|
299
|
+
const positional = positionals(args);
|
|
300
|
+
const [runId] = positional;
|
|
301
|
+
if (!stringValue(runId) || positional.length !== 1) throw new Error("factory steer requires exactly one <run-id>");
|
|
302
|
+
return print(await writeSteering(runId, requiredOption(opts.message, "--message", "factory steer"), opts), opts);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function steerConsume(args) {
|
|
306
|
+
const opts = options(args);
|
|
307
|
+
const positional = positionals(args);
|
|
308
|
+
const [runId] = positional;
|
|
309
|
+
if (!stringValue(runId) || positional.length !== 1) throw new Error("factory steer-consume requires exactly one <run-id>");
|
|
310
|
+
const ref = requiredOption(opts.ref, "--ref", "factory steer-consume");
|
|
311
|
+
const hash = requiredOption(opts.hash, "--hash", "factory steer-consume");
|
|
312
|
+
return print(await consumeSteering(runId, { ref, hash }, opts), opts);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function steerAck(args) {
|
|
316
|
+
const opts = options(args);
|
|
317
|
+
const positional = positionals(args);
|
|
318
|
+
const [runId] = positional;
|
|
319
|
+
if (!stringValue(runId) || positional.length !== 1) throw new Error("factory steer-ack requires exactly one <run-id>");
|
|
320
|
+
const ref = requiredOption(opts.ref, "--ref", "factory steer-ack");
|
|
321
|
+
const hash = requiredOption(opts.hash, "--hash", "factory steer-ack");
|
|
322
|
+
return print(await acknowledgeSteering(runId, { ref, hash }, opts), opts);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function boundaryOpen(args) {
|
|
326
|
+
const opts = options(args);
|
|
327
|
+
const positional = positionals(args);
|
|
328
|
+
const [runId, kind] = positional;
|
|
329
|
+
if (!stringValue(runId) || !stringValue(kind) || positional.length !== 2) throw new Error("factory boundary-open requires <run-id> <gate|dispatch|remediation|terminal>");
|
|
330
|
+
return print(await openSteeringBoundary(runId, kind, opts), opts);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function boundaryCross(args) {
|
|
334
|
+
const opts = options(args);
|
|
335
|
+
const positional = positionals(args);
|
|
336
|
+
const [runId, kind] = positional;
|
|
337
|
+
if (!stringValue(runId) || !stringValue(kind) || positional.length !== 2) throw new Error("factory boundary-cross requires <run-id> <dispatch|remediation>");
|
|
338
|
+
return print(await crossSteeringBoundary(runId, kind, requiredOption(opts.boundaryToken, "--boundary-token", "factory boundary-cross"), opts), opts);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function actionStarted(args) {
|
|
342
|
+
const opts = options(args);
|
|
343
|
+
const positional = positionals(args);
|
|
344
|
+
const [runId, kind] = positional;
|
|
345
|
+
if (!stringValue(runId) || !stringValue(kind) || positional.length !== 2) throw new Error("factory action-started requires <run-id> <dispatch|remediation>");
|
|
346
|
+
return print(await acknowledgeSteeringActionStart(runId, kind, requiredOption(opts.actionToken, "--action-token", "factory action-started"), opts), opts);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function actionAbort(args) {
|
|
350
|
+
const opts = options(args);
|
|
351
|
+
const positional = positionals(args);
|
|
352
|
+
const [runId, kind] = positional;
|
|
353
|
+
if (!stringValue(runId) || !stringValue(kind) || positional.length !== 2) throw new Error("factory action-abort requires <run-id> <dispatch|remediation>");
|
|
354
|
+
return print(await abortSteeringAction(runId, kind, requiredOption(opts.actionToken, "--action-token", "factory action-abort"), opts), opts);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function prFence(args) {
|
|
358
|
+
const opts = options(args);
|
|
359
|
+
const positional = positionals(args);
|
|
360
|
+
const [runId] = positional;
|
|
361
|
+
if (!stringValue(runId) || positional.length !== 1) throw new Error("factory pr-fence requires exactly one <run-id>");
|
|
362
|
+
if (opts.clear) return print(await clearPrePrFence(runId, requiredOption(opts.fenceToken, "--fence-token", "factory pr-fence --clear"), opts), opts);
|
|
363
|
+
if (opts.fenceToken) throw staticCliError("factory pr-fence accepts --fence-token only with --clear");
|
|
364
|
+
return print(await establishPrePrFence(runId, opts), opts);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function cancel(args) {
|
|
368
|
+
const opts = options(args);
|
|
369
|
+
const positional = positionals(args);
|
|
370
|
+
const [runId] = positional;
|
|
371
|
+
if (!stringValue(runId) || positional.length !== 1) throw new Error("factory cancel requires exactly one <run-id>");
|
|
372
|
+
const result = await cancelFactoryRun(runId, opts);
|
|
373
|
+
print(result, opts);
|
|
374
|
+
if (!result.ok) process.exitCode = 1;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function steerConflict(args) {
|
|
378
|
+
const opts = options(args);
|
|
379
|
+
const positional = positionals(args);
|
|
380
|
+
const [runId] = positional;
|
|
381
|
+
if (!stringValue(runId) || positional.length !== 1) throw new Error("factory steer-conflict requires exactly one <run-id>");
|
|
382
|
+
const ref = requiredOption(opts.ref, "--ref", "factory steer-conflict");
|
|
383
|
+
const hash = requiredOption(opts.hash, "--hash", "factory steer-conflict");
|
|
384
|
+
const result = await recordSteeringConflict(runId, { ref, hash, reason: opts.reason }, opts);
|
|
385
|
+
print(result, opts);
|
|
386
|
+
if (!result.ok) process.exitCode = 1;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function costRecord(args) {
|
|
390
|
+
const opts = options(args);
|
|
391
|
+
const positional = positionals(args);
|
|
392
|
+
const [runId] = positional;
|
|
393
|
+
if (!stringValue(runId) || positional.length !== 1) throw new Error("factory cost-record requires exactly one <run-id>");
|
|
394
|
+
return print(await recordCostUsage(runId, costRecordInput(opts), opts), opts);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function costReport(args) {
|
|
398
|
+
try {
|
|
399
|
+
assertCostReportOptions(args);
|
|
400
|
+
const opts = options(args);
|
|
401
|
+
const positional = positionals(args);
|
|
402
|
+
if (positional.length !== 1) throw new Error("factory cost-report requires exactly one <run-id>");
|
|
403
|
+
|
|
404
|
+
const requestedRunId = normalizeCostReportRunId(positional[0]);
|
|
405
|
+
const runDir = resolveRunDir(requestedRunId, opts);
|
|
406
|
+
const run = readCostReportRunJson(runDir);
|
|
407
|
+
if (!run || typeof run !== "object" || Array.isArray(run)) throw new Error("run.json must contain an object");
|
|
408
|
+
|
|
409
|
+
const report = projectCostReport(buildCostReport(basename(runDir), run.cost_attribution, { telemetry: opts.telemetry }));
|
|
410
|
+
console.log(opts.json ? serializeTerminalJson(report, { space: 2 }) : formatCostReport(report));
|
|
411
|
+
} catch (error) {
|
|
412
|
+
throw structuredCostReportError(error);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function structuredCostReportError(error) {
|
|
417
|
+
const message = typeof error?.message === "string" ? error.message : "cost-report failed";
|
|
418
|
+
const jsonPrefix = "run.json must be valid JSON:";
|
|
419
|
+
if (message.startsWith(jsonPrefix)) {
|
|
420
|
+
return new StructuredOutputError(message, [
|
|
421
|
+
identitySegment(jsonPrefix),
|
|
422
|
+
freeformSegment(message.slice(jsonPrefix.length)),
|
|
423
|
+
]);
|
|
424
|
+
}
|
|
425
|
+
if (/^cost attribution aggregate overflow for [a-z_]+$/u.test(message)) {
|
|
426
|
+
return new StructuredOutputError(message, [identitySegment(message)]);
|
|
427
|
+
}
|
|
428
|
+
return error;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function resume(args, dependencies = {}) {
|
|
432
|
+
const opts = { ...options(args), ...(dependencies.factoryOptions || {}) };
|
|
433
|
+
const positional = positionals(args);
|
|
434
|
+
const [runId] = positional;
|
|
435
|
+
if (!stringValue(runId) || positional.length !== 1) throw new Error("factory resume requires exactly one <run-id>");
|
|
436
|
+
return print(await resumeFactory(runId, opts), opts);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function heartbeat(args) {
|
|
440
|
+
const opts = options(args);
|
|
441
|
+
const positional = positionals(args);
|
|
442
|
+
if (positional.length !== 1) throw new Error("factory heartbeat requires exactly one <run-id>");
|
|
443
|
+
|
|
444
|
+
const runId = normalizeHeartbeatRunId(positional[0]);
|
|
445
|
+
const mode = heartbeatMode(opts);
|
|
446
|
+
|
|
447
|
+
if (mode === "start") {
|
|
448
|
+
return print(await startHeartbeatProcess(runId, opts), opts);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (mode === "foreground") {
|
|
452
|
+
return print(
|
|
453
|
+
await startHeartbeat(runId, heartbeatStartConfig(opts), {
|
|
454
|
+
cwd: opts.cwd,
|
|
455
|
+
}),
|
|
456
|
+
opts,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (mode === "stop") {
|
|
461
|
+
return print(
|
|
462
|
+
await stopHeartbeat(
|
|
463
|
+
runId,
|
|
464
|
+
{},
|
|
465
|
+
opts,
|
|
466
|
+
),
|
|
467
|
+
opts,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (mode === "status") {
|
|
472
|
+
return print(publicHeartbeatStatus(runId, opts), opts);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
throw new Error("factory heartbeat requires exactly one of --start, --stop, --status, or internal --foreground");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function options(args) {
|
|
479
|
+
assertKnownOptions(args);
|
|
480
|
+
const opts = {
|
|
481
|
+
cwd: process.cwd(),
|
|
482
|
+
json: args.includes("--json"),
|
|
483
|
+
local: args.includes("--local"),
|
|
484
|
+
profiles: args.includes("--profiles"),
|
|
485
|
+
providerSmoke: args.includes("--provider-smoke"),
|
|
486
|
+
telemetry: args.includes("--telemetry"),
|
|
487
|
+
autonomous: args.includes("--autonomous"),
|
|
488
|
+
detached: args.includes("--detached"),
|
|
489
|
+
all: args.includes("--all"),
|
|
490
|
+
headless: args.includes("--headless") || args.includes("--detached"),
|
|
491
|
+
ready: args.includes("--ready"),
|
|
492
|
+
force: args.includes("--force"),
|
|
493
|
+
dryRun: args.includes("--dry-run"),
|
|
494
|
+
start: args.includes("--start"),
|
|
495
|
+
stop: args.includes("--stop"),
|
|
496
|
+
heartbeatStatus: args.includes("--status"),
|
|
497
|
+
foreground: args.includes("--foreground"),
|
|
498
|
+
clear: args.includes("--clear"),
|
|
499
|
+
postPrCi: args.includes("--post-pr-ci"),
|
|
500
|
+
noPostPrCi: args.includes("--no-post-pr-ci"),
|
|
501
|
+
newPr: args.includes("--new-pr"),
|
|
502
|
+
};
|
|
503
|
+
if (args.includes("--draft")) opts.draft = true;
|
|
504
|
+
if (args.includes("--no-draft")) opts.noDraft = true;
|
|
505
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
506
|
+
if (args[index] === "--repo") opts.cwd = resolve(args[++index]);
|
|
507
|
+
if (args[index] === "--gh-account") opts.ghAccount = args[++index];
|
|
508
|
+
if (args[index] === "--model") opts.model = args[++index];
|
|
509
|
+
if (args[index] === "--interval") opts.intervalMs = Number(args[++index]);
|
|
510
|
+
if (args[index] === "--phase") opts.phase = args[++index];
|
|
511
|
+
if (args[index] === "--reviewer") opts.reviewer = args[++index];
|
|
512
|
+
if (args[index] === "--review") opts.review = args[++index];
|
|
513
|
+
if (args[index] === "--run-id") opts.runId = args[++index];
|
|
514
|
+
if (args[index] === "--from") opts.from = args[++index];
|
|
515
|
+
if (args[index] === "--artifact") opts.artifact = args[++index];
|
|
516
|
+
if (args[index] === "--question-ref") opts.questionRef = args[++index];
|
|
517
|
+
if (args[index] === "--answer-ref") opts.answerRef = args[++index];
|
|
518
|
+
if (args[index] === "--answer") opts.answer = args[++index];
|
|
519
|
+
if (args[index] === "--approval-source") opts.approvalSource = args[++index];
|
|
520
|
+
if (args[index] === "--decision-note") opts.decisionNote = args[++index];
|
|
521
|
+
if (args[index] === "--answered-at") opts.answeredAt = args[++index];
|
|
522
|
+
if (args[index] === "--reason") opts.reason = args[++index];
|
|
523
|
+
if (args[index] === "--merge-commit") opts.mergeCommit = args[++index];
|
|
524
|
+
if (args[index] === "--pr-url") opts.prUrl = args[++index];
|
|
525
|
+
if (args[index] === "--pr-number") opts.prNumber = args[++index];
|
|
526
|
+
if (args[index] === "--repository") opts.repository = args[++index];
|
|
527
|
+
if (args[index] === "--head-sha") opts.headSha = args[++index];
|
|
528
|
+
if (args[index] === "--branch") opts.branch = args[++index];
|
|
529
|
+
if (args[index] === "--worktree") opts.worktree = args[++index];
|
|
530
|
+
if (args[index] === "--attempts") opts.attempts = Number(args[++index]);
|
|
531
|
+
if (args[index] === "--evidence-ref") opts.evidenceRef = args[++index];
|
|
532
|
+
if (args[index] === "--review-ref") opts.reviewRef = args[++index];
|
|
533
|
+
if (args[index] === "--artifact-ref") opts.artifactRef = args[++index];
|
|
534
|
+
if (args[index] === "--validator") opts.validator = args[++index];
|
|
535
|
+
if (args[index] === "--security") opts.security = args[++index];
|
|
536
|
+
if (args[index] === "--report") opts.report = args[++index];
|
|
537
|
+
if (args[index] === "--message") opts.message = args[++index];
|
|
538
|
+
if (args[index] === "--ref") opts.ref = args[++index];
|
|
539
|
+
if (args[index] === "--hash") opts.hash = args[++index];
|
|
540
|
+
if (args[index] === "--boundary-token") opts.boundaryToken = args[++index];
|
|
541
|
+
if (args[index] === "--action-token") opts.actionToken = args[++index];
|
|
542
|
+
if (args[index] === "--fence-token") opts.fenceToken = args[++index];
|
|
543
|
+
if (args[index] === "--agent") opts.agent = args[++index];
|
|
544
|
+
if (args[index] === "--step") opts.step = args[++index];
|
|
545
|
+
if (args[index] === "--slice-id") opts.sliceId = args[++index];
|
|
546
|
+
if (args[index] === "--provider") opts.provider = args[++index];
|
|
547
|
+
if (args[index] === "--source") opts.source = args[++index];
|
|
548
|
+
if (args[index] === "--operation") opts.operation = args[++index];
|
|
549
|
+
if (args[index] === "--request-id") opts.requestId = args[++index];
|
|
550
|
+
if (args[index] === "--currency") opts.currency = args[++index];
|
|
551
|
+
if (args[index] === "--recorded-at") opts.recordedAt = args[++index];
|
|
552
|
+
if (args[index] === "--entry-id") opts.entryId = args[++index];
|
|
553
|
+
if (args[index] === "--parent-span-id") opts.parentSpanId = args[++index];
|
|
554
|
+
if (args[index] === "--traceparent") opts.traceparent = args[++index];
|
|
555
|
+
if (args[index] === "--tracestate") opts.tracestate = args[++index];
|
|
556
|
+
if (args[index] === "--post-pr-wait-minutes") opts.postPrWaitMinutes = Number(args[++index]);
|
|
557
|
+
if (args[index] === "--post-pr-poll-seconds") opts.postPrPollSeconds = Number(args[++index]);
|
|
558
|
+
if (args[index] === "--post-pr-max-poll-seconds") opts.postPrMaxPollSeconds = Number(args[++index]);
|
|
559
|
+
if (args[index] === "--post-pr-check-start-grace-seconds") opts.postPrCheckStartGraceSeconds = Number(args[++index]);
|
|
560
|
+
if (args[index] === "--post-pr-max-transient-errors") opts.postPrMaxTransientErrors = Number(args[++index]);
|
|
561
|
+
if (args[index] === "--remediation-evidence-ref") opts.remediationEvidenceRef = args[++index];
|
|
562
|
+
if (args[index] === "--failure-evidence-ref") opts.failureEvidenceRef = args[++index];
|
|
563
|
+
if (args[index] === "--test-evidence-ref") opts.testEvidenceRef = args[++index];
|
|
564
|
+
if (args[index] === "--validator-report-ref") opts.validatorReportRef = args[++index];
|
|
565
|
+
if (args[index] === "--validator-review-ref") opts.validatorReviewRef = args[++index];
|
|
566
|
+
if (args[index] === "--security-review-ref") opts.securityReviewRef = args[++index];
|
|
567
|
+
if (COST_NUMERIC_FLAGS.has(args[index])) opts[COST_NUMERIC_FLAGS.get(args[index])] = parseCostNumericOption(args[index], args[++index]);
|
|
568
|
+
}
|
|
569
|
+
return opts;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function parseCostNumericOption(flag, raw) {
|
|
573
|
+
if (typeof raw !== "string" || raw.trim() === "") throw costNumericOptionError(flag);
|
|
574
|
+
const value = Number(raw);
|
|
575
|
+
if (!Number.isFinite(value) || value < 0) throw costNumericOptionError(flag);
|
|
576
|
+
return value;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function costNumericOptionError(flag) {
|
|
580
|
+
return new StructuredOutputError(`${flag} must be a finite non-negative number`, [
|
|
581
|
+
identitySegment(flag),
|
|
582
|
+
identitySegment(" must be a finite non-negative number"),
|
|
583
|
+
]);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function assertKnownOptions(args) {
|
|
587
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
588
|
+
const arg = args[index];
|
|
589
|
+
if (!arg.startsWith("--")) continue;
|
|
590
|
+
if (BOOLEAN_FLAGS.has(arg)) continue;
|
|
591
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
592
|
+
if (index + 1 >= args.length || args[index + 1].startsWith("--")) throw new Error(`${arg} requires a value`);
|
|
593
|
+
index += 1;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
throw new Error(`unknown option: ${arg}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function assertCostReportOptions(args) {
|
|
601
|
+
assertKnownOptions(args);
|
|
602
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
603
|
+
const arg = args[index];
|
|
604
|
+
if (!arg.startsWith("--")) continue;
|
|
605
|
+
if (COST_REPORT_BOOLEAN_FLAGS.has(arg)) continue;
|
|
606
|
+
if (COST_REPORT_VALUE_FLAGS.has(arg)) {
|
|
607
|
+
if (index + 1 >= args.length || args[index + 1].startsWith("--")) throw new Error(`${arg} requires a value`);
|
|
608
|
+
index += 1;
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
throw new Error(`factory cost-report does not support ${arg}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function positionals(args) {
|
|
616
|
+
const output = [];
|
|
617
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
618
|
+
const arg = args[index];
|
|
619
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
620
|
+
index += 1;
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
if (arg.startsWith("--")) continue;
|
|
624
|
+
output.push(arg);
|
|
625
|
+
}
|
|
626
|
+
return output;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function parseAnswerArgs(args) {
|
|
630
|
+
const optionArgs = [];
|
|
631
|
+
const positional = [];
|
|
632
|
+
let index = 0;
|
|
633
|
+
while (index < args.length && positional.length < 2) {
|
|
634
|
+
const arg = args[index];
|
|
635
|
+
if (arg.startsWith("--")) {
|
|
636
|
+
if (BOOLEAN_FLAGS.has(arg)) {
|
|
637
|
+
optionArgs.push(arg);
|
|
638
|
+
index += 1;
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
642
|
+
if (index + 1 >= args.length || args[index + 1].startsWith("--")) throw new Error(`${arg} requires a value`);
|
|
643
|
+
optionArgs.push(arg, args[index + 1]);
|
|
644
|
+
index += 2;
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
throw new Error(`unknown option: ${arg}`);
|
|
648
|
+
}
|
|
649
|
+
positional.push(arg);
|
|
650
|
+
index += 1;
|
|
651
|
+
}
|
|
652
|
+
if (positional.length !== 2 || index >= args.length) throw new Error("factory answer requires <run> <gate> <answer>");
|
|
653
|
+
return { opts: options(optionArgs), runId: positional[0], gate: positional[1], answerText: args.slice(index).join(" ") };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function env(args) {
|
|
657
|
+
const opts = options(args);
|
|
658
|
+
const positional = positionals(args);
|
|
659
|
+
const [action, runId] = positional;
|
|
660
|
+
if (action === "record-created") {
|
|
661
|
+
if (!stringValue(runId) || positional.length !== 2) throw new Error("factory env record-created requires <run-id>");
|
|
662
|
+
return print(await persistFactoryRunCreatedEnv(runId, opts), { ...opts, json: true });
|
|
663
|
+
}
|
|
664
|
+
if (action === "record-resume") {
|
|
665
|
+
if (!stringValue(runId) || positional.length !== 2) throw new Error("factory env record-resume requires <run-id>");
|
|
666
|
+
return print(await persistFactoryRunResumeEnv(runId, opts), { ...opts, json: true });
|
|
667
|
+
}
|
|
668
|
+
if (positional.length > 0) throw new Error(`unknown factory env action: ${action}`);
|
|
669
|
+
return print(await collectEnv({ cwd: opts.cwd }), { ...opts, json: true });
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function recover(args) {
|
|
673
|
+
const opts = options(args);
|
|
674
|
+
const positional = positionals(args);
|
|
675
|
+
const [runId] = positional;
|
|
676
|
+
if (!stringValue(runId) || positional.length !== 1) throw new Error("factory recover requires exactly one <run-id>");
|
|
677
|
+
return print(await transitionRecoverOrphan(resolveRunDir(runId, opts), opts.reason || "recovered orphaned factory run", opts), opts);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function gateDecision(args, dependencies = {}) {
|
|
681
|
+
const opts = { ...options(args), ...(dependencies.gateDecisionOptions || {}) };
|
|
682
|
+
const positional = positionals(args);
|
|
683
|
+
const [runId, gate, statusValue] = positional;
|
|
684
|
+
if (!stringValue(runId) || !stringValue(gate) || !stringValue(statusValue)) {
|
|
685
|
+
throw new Error("factory gate-decision requires <run> <gate> <pending|approved|changes_requested|stopped>");
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const decision = { status: normalizeGateDecisionStatus(statusValue) };
|
|
689
|
+
if (stringValue(opts.artifact)) decision.artifact = opts.artifact;
|
|
690
|
+
if (stringValue(opts.questionRef)) decision.question_ref = opts.questionRef;
|
|
691
|
+
if (stringValue(opts.answerRef)) decision.answer_ref = opts.answerRef;
|
|
692
|
+
if (stringValue(opts.answer)) decision.answer = opts.answer;
|
|
693
|
+
if (stringValue(opts.approvalSource)) decision.approval_source = opts.approvalSource;
|
|
694
|
+
if (stringValue(opts.decisionNote)) decision.decision_note = opts.decisionNote;
|
|
695
|
+
if (stringValue(opts.answeredAt)) decision.answered_at = opts.answeredAt;
|
|
696
|
+
|
|
697
|
+
const result = await transitionGateDecisionAndHandoff(resolveRunDir(runId, opts), gate, decision, opts);
|
|
698
|
+
if (opts.json && result.handoff) {
|
|
699
|
+
const projected = projectCliData(result);
|
|
700
|
+
if (typeof result.handoff.log === "string" && /^processes\/[A-Za-z0-9._-]+\.log$/u.test(result.handoff.log)) projected.handoff.log = result.handoff.log;
|
|
701
|
+
if (result.handoff.launch_claim_ref === "process-launch.lock/owner.json") projected.handoff.launch_claim_ref = result.handoff.launch_claim_ref;
|
|
702
|
+
console.log(serializeTerminalJson(projected, { space: 2 }));
|
|
703
|
+
} else {
|
|
704
|
+
print(result, opts);
|
|
705
|
+
}
|
|
706
|
+
if (result.handoff?.status === "recovery-required") process.exitCode = 2;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async function slicesSeed(args) {
|
|
710
|
+
const opts = options(args);
|
|
711
|
+
const positional = positionals(args);
|
|
712
|
+
const [runId] = positional;
|
|
713
|
+
if (!stringValue(runId) || positional.length !== 1) throw new Error("factory slices-seed requires exactly one <run-id>");
|
|
714
|
+
const from = requiredOption(opts.from, "--from", "factory slices-seed");
|
|
715
|
+
const runDir = resolveRunDir(runId, opts);
|
|
716
|
+
const plan = validateSlicesPlan(readJsonFile(resolve(opts.repoRoot || opts.cwd, from), "slices plan"));
|
|
717
|
+
const slices = plan.slices.map((slice) => ({
|
|
718
|
+
id: slice.id,
|
|
719
|
+
stack: slice.stack,
|
|
720
|
+
depends_on: Array.isArray(slice.depends_on) ? slice.depends_on : [],
|
|
721
|
+
status: "pending",
|
|
722
|
+
attempts: 0,
|
|
723
|
+
}));
|
|
724
|
+
return print(await transitionRunJson(runDir, (run) => {
|
|
725
|
+
if (!opts.force && Array.isArray(run.slices) && run.slices.some((slice) => slice?.status && slice.status !== "pending")) {
|
|
726
|
+
throw new Error("factory slices-seed refuses to replace non-pending slice progress without --force");
|
|
727
|
+
}
|
|
728
|
+
run.slices = slices;
|
|
729
|
+
}, opts), opts);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function sliceStatus(args) {
|
|
733
|
+
const opts = options(args);
|
|
734
|
+
const positional = positionals(args);
|
|
735
|
+
const [runId, sliceId, statusValue] = positional;
|
|
736
|
+
if (!stringValue(runId) || !stringValue(sliceId) || !stringValue(statusValue) || positional.length !== 3) {
|
|
737
|
+
throw new Error("factory slice-status requires <run-id> <slice-id> <running|review|blocked>");
|
|
738
|
+
}
|
|
739
|
+
const statusValueNormalized = normalizeSliceStatus(statusValue);
|
|
740
|
+
const update = { status: statusValueNormalized };
|
|
741
|
+
if (statusValueNormalized === "blocked") update.blocked_reason = requiredOption(opts.reason, "--reason", "factory slice-status blocked");
|
|
742
|
+
if (statusValueNormalized === "review") {
|
|
743
|
+
update.evidence_ref = requiredOption(opts.evidenceRef, "--evidence-ref", "factory slice-status review");
|
|
744
|
+
update.review_ref = requiredOption(opts.reviewRef, "--review-ref", "factory slice-status review");
|
|
745
|
+
}
|
|
746
|
+
if (stringValue(opts.branch)) update.branch = opts.branch;
|
|
747
|
+
if (stringValue(opts.worktree)) update.worktree = opts.worktree;
|
|
748
|
+
if (opts.attempts !== undefined) update.attempts = normalizeNonNegativeInteger(opts.attempts, "--attempts");
|
|
749
|
+
return print(await transitionRunSlice(resolveRunDir(runId, opts), sliceId, update, { ...opts, mustExist: true }), opts);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
async function step(args) {
|
|
753
|
+
const opts = options(args);
|
|
754
|
+
const positional = positionals(args);
|
|
755
|
+
const [runId, agent, statusValue] = positional;
|
|
756
|
+
if (!stringValue(runId) || !stringValue(agent) || !stringValue(statusValue) || positional.length !== 3) {
|
|
757
|
+
throw new Error("factory step requires <run-id> <agent> <running|accepted|rejected|blocked>");
|
|
758
|
+
}
|
|
759
|
+
const update = { status: normalizeStepStatus(statusValue) };
|
|
760
|
+
if (stringValue(opts.artifactRef)) update.artifact_ref = opts.artifactRef;
|
|
761
|
+
if (stringValue(opts.evidenceRef)) update.evidence_ref = opts.evidenceRef;
|
|
762
|
+
if (stringValue(opts.reviewRef)) update.review_ref = opts.reviewRef;
|
|
763
|
+
if (opts.attempts !== undefined) update.attempts = normalizeNonNegativeInteger(opts.attempts, "--attempts");
|
|
764
|
+
return print(await transitionRunStep(resolveRunDir(runId, opts), agent, update, { ...opts, mustExist: true }), opts);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async function verdicts(args) {
|
|
768
|
+
const opts = options(args);
|
|
769
|
+
const positional = positionals(args);
|
|
770
|
+
const [runId] = positional;
|
|
771
|
+
if (!stringValue(runId) || positional.length !== 1) throw new Error("factory verdicts requires exactly one <run-id>");
|
|
772
|
+
const validator = normalizeValidatorVerdict(requiredOption(opts.validator, "--validator", "factory verdicts"));
|
|
773
|
+
const security = normalizeSecurityVerdict(requiredOption(opts.security, "--security", "factory verdicts"));
|
|
774
|
+
const report = assertRefUnder(requiredOption(opts.report, "--report", "factory verdicts"), "artifacts/", "--report");
|
|
775
|
+
const reviewRef = assertRefUnder(requiredOption(opts.reviewRef, "--review-ref", "factory verdicts"), "reviews/", "--review-ref");
|
|
776
|
+
return print(await transitionRunJson(resolveRunDir(runId, opts), (run) => {
|
|
777
|
+
run.validator = { verdict: validator, report, review_ref: "reviews/implementation-validator.json" };
|
|
778
|
+
run.security_review = { verdict: security, review_ref: reviewRef };
|
|
779
|
+
}, opts), opts);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
async function terminal(args) {
|
|
783
|
+
const opts = options(args);
|
|
784
|
+
const positional = positionals(args);
|
|
785
|
+
const [runId, statusValue] = positional;
|
|
786
|
+
if (!stringValue(runId) || !stringValue(statusValue) || positional.length !== 2) {
|
|
787
|
+
throw new Error("factory terminal requires <run-id> <blocked|partial|needs-human>");
|
|
788
|
+
}
|
|
789
|
+
const statusValueNormalized = normalizeTerminalStatus(statusValue);
|
|
790
|
+
const reason = requiredOption(opts.reason, "--reason", "factory terminal");
|
|
791
|
+
opts.boundaryToken = requiredOption(opts.boundaryToken, "--boundary-token", "factory terminal");
|
|
792
|
+
return print(await transitionTerminalResult(resolveRunDir(runId, opts), { status: statusValueNormalized, reason }, opts), opts);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async function prCreated(args) {
|
|
796
|
+
const opts = options(args);
|
|
797
|
+
const positional = positionals(args);
|
|
798
|
+
const [runId] = positional;
|
|
799
|
+
if (!stringValue(runId) || positional.length !== 1) {
|
|
800
|
+
throw new Error("factory pr-created requires exactly one <run-id>");
|
|
801
|
+
}
|
|
802
|
+
if (opts.draft === true && opts.noDraft === true) throw new Error("factory pr-created accepts only one of --draft or --no-draft");
|
|
803
|
+
|
|
804
|
+
const request = {
|
|
805
|
+
pr_url: canonicalizeGithubPrUrl(requiredOption(opts.prUrl, "--pr-url")),
|
|
806
|
+
pr_number: normalizeCliPrNumber(requiredOption(opts.prNumber, "--pr-number")),
|
|
807
|
+
repository: requiredOption(opts.repository, "--repository"),
|
|
808
|
+
draft: opts.draft === true,
|
|
809
|
+
};
|
|
810
|
+
if (stringValue(opts.headSha)) request.head_sha = opts.headSha;
|
|
811
|
+
opts.fenceToken = requiredOption(opts.fenceToken, "--fence-token", "factory pr-created");
|
|
812
|
+
return print(await transitionPrCreated(resolveRunDir(runId, opts), request, opts), opts);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function sliceMerged(args) {
|
|
816
|
+
const opts = options(args);
|
|
817
|
+
const positional = positionals(args);
|
|
818
|
+
const [runId, sliceId] = positional;
|
|
819
|
+
if (!stringValue(runId) || !stringValue(sliceId) || positional.length !== 2) {
|
|
820
|
+
throw new Error("factory slice-merged requires <run-id> <slice-id>");
|
|
821
|
+
}
|
|
822
|
+
const request = { merge_commit: requiredOption(opts.mergeCommit, "--merge-commit", "factory slice-merged") };
|
|
823
|
+
return print(await transitionSliceMerged(resolveRunDir(runId, opts), sliceId, request, opts), opts);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function normalizeCliPrNumber(value) {
|
|
827
|
+
try {
|
|
828
|
+
return normalizeTransitionPrNumber(value);
|
|
829
|
+
} catch {
|
|
830
|
+
throw new Error("factory pr-created requires --pr-number to be a positive integer");
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function costRecordInput(opts) {
|
|
835
|
+
const input = {};
|
|
836
|
+
for (const [key, field] of [
|
|
837
|
+
["agent", "agent"],
|
|
838
|
+
["step", "step"],
|
|
839
|
+
["sliceId", "slice_id"],
|
|
840
|
+
["provider", "provider"],
|
|
841
|
+
["model", "model"],
|
|
842
|
+
["source", "source"],
|
|
843
|
+
["operation", "operation"],
|
|
844
|
+
["requestId", "request_id"],
|
|
845
|
+
["currency", "cost_currency"],
|
|
846
|
+
["recordedAt", "recorded_at"],
|
|
847
|
+
]) {
|
|
848
|
+
if (stringValue(opts[key])) input[field] = opts[key];
|
|
849
|
+
}
|
|
850
|
+
for (const [key, field] of [
|
|
851
|
+
["inputTokens", "input_tokens"],
|
|
852
|
+
["outputTokens", "output_tokens"],
|
|
853
|
+
["totalTokens", "total_tokens"],
|
|
854
|
+
["cacheCreationInputTokens", "cache_creation_input_tokens"],
|
|
855
|
+
["cacheReadInputTokens", "cache_read_input_tokens"],
|
|
856
|
+
["reasoningTokens", "reasoning_tokens"],
|
|
857
|
+
["costTotal", "cost_total"],
|
|
858
|
+
["costInput", "cost_input"],
|
|
859
|
+
["costOutput", "cost_output"],
|
|
860
|
+
["costCacheCreation", "cost_cache_creation"],
|
|
861
|
+
["costCacheRead", "cost_cache_read"],
|
|
862
|
+
]) {
|
|
863
|
+
if (opts[key] !== undefined) input[field] = opts[key];
|
|
864
|
+
}
|
|
865
|
+
return input;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function requiredOption(value, flag, command = "factory pr-created") {
|
|
869
|
+
if (!stringValue(value)) throw staticCliError(`${command} requires ${flag}`);
|
|
870
|
+
return value;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function staticCliError(message) {
|
|
874
|
+
return new StructuredOutputError(message, [identitySegment(message)]);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function normalizeGateDecisionStatus(value) {
|
|
878
|
+
const status = String(value).trim();
|
|
879
|
+
if (["pending", "approved", "changes_requested", "stopped"].includes(status)) return status;
|
|
880
|
+
if (status === "approve") return "approved";
|
|
881
|
+
if (status === "changes") return "changes_requested";
|
|
882
|
+
if (status === "stop") return "stopped";
|
|
883
|
+
throw new Error("gate decision status must be pending, approved, changes_requested, or stopped");
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function normalizeSliceStatus(value) {
|
|
887
|
+
const statusValue = String(value).trim();
|
|
888
|
+
if (["running", "review", "blocked"].includes(statusValue)) return statusValue;
|
|
889
|
+
throw new Error("slice status must be running, review, or blocked");
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function normalizeStepStatus(value) {
|
|
893
|
+
const statusValue = String(value).trim();
|
|
894
|
+
if (["running", "accepted", "rejected", "blocked"].includes(statusValue)) return statusValue;
|
|
895
|
+
throw new Error("step status must be running, accepted, rejected, or blocked");
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function normalizeTerminalStatus(value) {
|
|
899
|
+
const statusValue = String(value).trim();
|
|
900
|
+
if (["blocked", "partial", "needs-human"].includes(statusValue)) return statusValue;
|
|
901
|
+
throw new Error("terminal status must be blocked, partial, or needs-human");
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function normalizeValidatorVerdict(value) {
|
|
905
|
+
const verdict = String(value).trim();
|
|
906
|
+
if (["GO", "GO-WITH-NITS", "NO-GO"].includes(verdict)) return verdict;
|
|
907
|
+
throw new Error("validator verdict must be GO, GO-WITH-NITS, or NO-GO");
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function normalizeSecurityVerdict(value) {
|
|
911
|
+
const verdict = String(value).trim();
|
|
912
|
+
if (["PASS", "BLOCK"].includes(verdict)) return verdict;
|
|
913
|
+
throw new Error("security verdict must be PASS or BLOCK");
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function normalizeNonNegativeInteger(value, flag) {
|
|
917
|
+
if (!Number.isInteger(value) || value < 0) throw new Error(`${flag} must be a non-negative integer`);
|
|
918
|
+
return value;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function assertRefUnder(value, prefix, flag) {
|
|
922
|
+
const ref = String(value).trim();
|
|
923
|
+
if (!ref.startsWith(prefix)) throw new Error(`${flag} must be under ${prefix}`);
|
|
924
|
+
return ref;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function readJsonFile(file, label) {
|
|
928
|
+
try {
|
|
929
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
930
|
+
} catch (error) {
|
|
931
|
+
throw new Error(`${label} must be valid JSON: ${error.message}`);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function readCostReportRunJson(runDir) {
|
|
936
|
+
const file = join(runDir, "run.json");
|
|
937
|
+
if (typeof FS_CONSTANTS.O_NOFOLLOW !== "number") throw new Error("run.json cannot be read safely on this platform");
|
|
938
|
+
|
|
939
|
+
let descriptor;
|
|
940
|
+
let text;
|
|
941
|
+
try {
|
|
942
|
+
descriptor = openSync(file, FS_CONSTANTS.O_RDONLY | FS_CONSTANTS.O_NOFOLLOW);
|
|
943
|
+
if (!fstatSync(descriptor).isFile()) throw new Error("not a regular file");
|
|
944
|
+
text = readFileSync(descriptor, "utf8");
|
|
945
|
+
} catch (error) {
|
|
946
|
+
throw new Error(`run.json must be a regular file inside the run directory: ${error.message}`);
|
|
947
|
+
} finally {
|
|
948
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
try {
|
|
952
|
+
return JSON.parse(text);
|
|
953
|
+
} catch (error) {
|
|
954
|
+
throw new Error(`run.json must be valid JSON: ${error.message}`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function print(value, opts) {
|
|
959
|
+
return printCliResult(value, opts, { formatListCostColumn, cleanDiagnosticText });
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function formatListCostColumn(item) {
|
|
963
|
+
if (!item?.cost_summary) return "-";
|
|
964
|
+
return cleanDiagnosticText(formatCostAttributionSummary({ totals: item.cost_summary, updated_at: item.cost_summary.updated_at }));
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function cleanDiagnosticText(value) {
|
|
968
|
+
const text = sanitizePublicCostText(value);
|
|
969
|
+
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function heartbeatMode(opts) {
|
|
973
|
+
const modes = [
|
|
974
|
+
["start", opts.start],
|
|
975
|
+
["stop", opts.stop],
|
|
976
|
+
["status", opts.heartbeatStatus],
|
|
977
|
+
["foreground", opts.foreground],
|
|
978
|
+
].filter(([, enabled]) => enabled);
|
|
979
|
+
if (modes.length !== 1) {
|
|
980
|
+
throw new Error("factory heartbeat requires exactly one of --start, --stop, --status, or internal --foreground");
|
|
981
|
+
}
|
|
982
|
+
return modes[0][0];
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async function startHeartbeatProcess(runId, opts) {
|
|
986
|
+
const config = heartbeatStartConfig(opts);
|
|
987
|
+
const runDir = resolveRunDir(runId, opts);
|
|
988
|
+
const run = readHeartbeatStartRun(runDir);
|
|
989
|
+
let current = status(runId, opts);
|
|
990
|
+
if (current.status === "invalid") {
|
|
991
|
+
throw new Error(current.error || "run diagnostics failed closed");
|
|
992
|
+
}
|
|
993
|
+
if (current.status !== "running") {
|
|
994
|
+
throw new Error(`run '${current.run_id}' must be running to start a heartbeat`);
|
|
995
|
+
}
|
|
996
|
+
if (current.steering?.pending) throw new Error(`run '${current.run_id}' has pending steering; drain it before starting a heartbeat`);
|
|
997
|
+
if (current.steering?.uncheckpointed) throw new Error(`run '${current.run_id}' has consumed steering awaiting acknowledgement`);
|
|
998
|
+
if (current.steering?.pr_fence) throw new Error(`run '${current.run_id}' has an active pre-PR fence`);
|
|
999
|
+
if (HEARTBEAT_PROTECTED_GATE_SET.has(current.pending_gate)) {
|
|
1000
|
+
throw new Error(`run '${current.run_id}' is waiting at protected gate '${current.pending_gate}'`);
|
|
1001
|
+
}
|
|
1002
|
+
if (!hasInFlightHeartbeatWork(run)) {
|
|
1003
|
+
throw new Error(`run '${current.run_id}' has no in-flight factory work for heartbeat`);
|
|
1004
|
+
}
|
|
1005
|
+
const childArgs = [cliPath, "factory", "heartbeat", runId, "--foreground", "--phase", config.phase];
|
|
1006
|
+
if (config.intervalMs !== undefined) childArgs.push("--interval", String(config.intervalMs));
|
|
1007
|
+
|
|
1008
|
+
const child = spawn(process.execPath, childArgs, {
|
|
1009
|
+
cwd: opts.cwd,
|
|
1010
|
+
detached: true,
|
|
1011
|
+
env: { ...process.env },
|
|
1012
|
+
stdio: "ignore",
|
|
1013
|
+
});
|
|
1014
|
+
child.unref();
|
|
1015
|
+
|
|
1016
|
+
return waitForHeartbeatStart(runId, { cwd: opts.cwd, pid: child.pid });
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async function waitForHeartbeatStart(runId, opts = {}) {
|
|
1020
|
+
const deadline = Date.now() + HEARTBEAT_START_TIMEOUT_MS;
|
|
1021
|
+
while (Date.now() <= deadline) {
|
|
1022
|
+
const current = heartbeatStatus(runId, { cwd: opts.cwd });
|
|
1023
|
+
if (current?.pid === opts.pid && current.fresh) return current;
|
|
1024
|
+
if (opts.pid && !isProcessAlive(opts.pid)) break;
|
|
1025
|
+
await sleep(HEARTBEAT_START_POLL_MS);
|
|
1026
|
+
}
|
|
1027
|
+
throw new Error(`heartbeat failed to start for run '${runId}'`);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function heartbeatStartConfig(opts) {
|
|
1031
|
+
return {
|
|
1032
|
+
phase: normalizeHeartbeatPhase(opts.phase),
|
|
1033
|
+
intervalMs: normalizePositiveInteger(opts.intervalMs, "intervalMs"),
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function normalizeHeartbeatPhase(phase) {
|
|
1038
|
+
if (!stringValue(phase)) throw new Error("heartbeat phase must be a non-empty string");
|
|
1039
|
+
return phase.trim();
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function normalizePositiveInteger(value, name) {
|
|
1043
|
+
if (value === undefined || value === null) return undefined;
|
|
1044
|
+
const next = Number(value);
|
|
1045
|
+
if (!Number.isInteger(next) || next <= 0) throw new Error(`${name} must be a positive integer`);
|
|
1046
|
+
return next;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function resolveRunDir(runId, opts = {}) {
|
|
1050
|
+
const normalized = normalizeHeartbeatRunId(runId);
|
|
1051
|
+
for (const root of factoryRootsForLookup(opts.cwd || process.cwd())) {
|
|
1052
|
+
const dir = resolve(root, normalized);
|
|
1053
|
+
if (!existsSync(join(dir, "run.json"))) continue;
|
|
1054
|
+
if (!insideDirectory(root, dir)) throw new Error(`heartbeat run directory must be inside .opencode/factory: ${dir}`);
|
|
1055
|
+
return rememberFactoryRepo(opts, dir);
|
|
1056
|
+
}
|
|
1057
|
+
throw new Error(`run not found: ${runId}`);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function rememberFactoryRepo(opts, runDir) {
|
|
1061
|
+
if (opts && typeof opts === "object") opts.repoRoot = factoryRepoFromRunDir(runDir);
|
|
1062
|
+
return runDir;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function publicHeartbeatStatus(runId, opts = {}) {
|
|
1066
|
+
const current = heartbeatStatus(runId, opts);
|
|
1067
|
+
if (!current) return null;
|
|
1068
|
+
return current;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function readHeartbeatStartRun(runDir) {
|
|
1072
|
+
return validateRun(JSON.parse(readFileSync(join(runDir, "run.json"), "utf8")));
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function hasInFlightHeartbeatWork(run) {
|
|
1076
|
+
if (run?.status === "running" && run?.post_pr?.policy?.enabled === true && ["observing", "remediation-running", "revalidating"].includes(run.post_pr.phase)) return true;
|
|
1077
|
+
if (Array.isArray(run.steps) && run.steps.some((step) => HEARTBEAT_STEP_IN_FLIGHT_STATUSES.has(step?.status))) {
|
|
1078
|
+
return true;
|
|
1079
|
+
}
|
|
1080
|
+
if (Array.isArray(run.slices) && run.slices.some((slice) => HEARTBEAT_SLICE_IN_FLIGHT_STATUSES.has(slice?.status))) {
|
|
1081
|
+
return true;
|
|
1082
|
+
}
|
|
1083
|
+
return false;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function normalizeHeartbeatRunId(runId) {
|
|
1087
|
+
if (!stringValue(runId)) throw new Error("factory heartbeat requires exactly one <run-id>");
|
|
1088
|
+
const value = String(runId).trim();
|
|
1089
|
+
if (isAbsolute(value) || value.includes("/") || value.includes("\\") || value === "." || value === "..") {
|
|
1090
|
+
throw new Error("factory heartbeat requires a bare <run-id>, not a filesystem path");
|
|
1091
|
+
}
|
|
1092
|
+
return value;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function normalizeCostReportRunId(runId) {
|
|
1096
|
+
const value = String(runId);
|
|
1097
|
+
if (isAbsolute(value) || value.includes("/") || value.includes("\\") || value === "." || value === "..") {
|
|
1098
|
+
throw new Error("factory cost-report requires a bare <run-id>, not a filesystem path");
|
|
1099
|
+
}
|
|
1100
|
+
if (!SAFE_COST_REPORT_RUN_ID_PATTERN.test(value) || value.includes("..") || value.endsWith(".lock")) {
|
|
1101
|
+
throw new Error('factory cost-report requires a safe <run-id> using letters, digits, ".", "_", or "-"');
|
|
1102
|
+
}
|
|
1103
|
+
return value;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function insideDirectory(parent, child) {
|
|
1107
|
+
return isContainedPath(parent, child, { allowEqual: false });
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function isProcessAlive(pid) {
|
|
1111
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1112
|
+
try {
|
|
1113
|
+
process.kill(pid, 0);
|
|
1114
|
+
return true;
|
|
1115
|
+
} catch (error) {
|
|
1116
|
+
if (error?.code === "ESRCH") return false;
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function stringValue(value) {
|
|
1122
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function sleep(ms) {
|
|
1126
|
+
return new Promise((nextResolve) => setTimeout(nextResolve, ms));
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function localPluginSpec() {
|
|
1130
|
+
return pathToFileURL(root).href;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function oldLocalPluginSpec() {
|
|
1134
|
+
return pathToFileURL(join(root, "src", "plugin.js")).href;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function pluginEntrySpec(entry) {
|
|
1138
|
+
return Array.isArray(entry) ? entry[0] : entry;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (isDirectCliExecution()) {
|
|
1142
|
+
runCliCommand(process.argv.slice(2)).catch((error) => {
|
|
1143
|
+
console.error(`error: ${renderErrorForTerminal(error)}`);
|
|
1144
|
+
process.exitCode = 1;
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function isDirectCliExecution() {
|
|
1149
|
+
if (!process.argv[1]) return false;
|
|
1150
|
+
try { return realpathSync(process.argv[1]) === realpathSync(cliPath); }
|
|
1151
|
+
catch { return resolve(process.argv[1]) === cliPath; }
|
|
1152
|
+
}
|