holo-codex 0.1.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/.agents/plugins/marketplace.json +20 -0
- package/CONTRIBUTING.md +54 -0
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/README.zh-CN.md +215 -0
- package/SECURITY.md +39 -0
- package/assets/brand/README.md +35 -0
- package/assets/brand/holo-codex-icon.svg +28 -0
- package/assets/brand/holo-codex-lockup.svg +49 -0
- package/assets/brand/holo-codex-mark.svg +33 -0
- package/assets/brand/holo-codex-plugin-card.png +0 -0
- package/assets/brand/holo-codex-plugin-card.svg +81 -0
- package/assets/brand/holo-codex-readme-hero.png +0 -0
- package/assets/brand/holo-codex-readme-hero.svg +140 -0
- package/assets/brand/holo-codex-social-preview.png +0 -0
- package/assets/brand/holo-codex-social-preview.svg +130 -0
- package/assets/brand/holo-codex-wordmark-options.svg +52 -0
- package/docs/checklists/agent-loop-first-delivery-audit.md +129 -0
- package/docs/examples/generic-loop-repo-hygiene.md +168 -0
- package/docs/install.md +190 -0
- package/docs/local-release-readiness.md +206 -0
- package/docs/release-checklist.md +144 -0
- package/docs/self-bootstrap.md +150 -0
- package/docs/trust-and-safety.md +45 -0
- package/package.json +83 -0
- package/plugins/autonomous-pr-loop/.codex-plugin/plugin.json +17 -0
- package/plugins/autonomous-pr-loop/.mcp.json +13 -0
- package/plugins/autonomous-pr-loop/bin/agent-loop.mjs +31 -0
- package/plugins/autonomous-pr-loop/core/artifacts.ts +164 -0
- package/plugins/autonomous-pr-loop/core/autonomy-policy.ts +206 -0
- package/plugins/autonomous-pr-loop/core/ci.ts +131 -0
- package/plugins/autonomous-pr-loop/core/cli-i18n.ts +123 -0
- package/plugins/autonomous-pr-loop/core/cli.ts +1413 -0
- package/plugins/autonomous-pr-loop/core/command-runner.ts +446 -0
- package/plugins/autonomous-pr-loop/core/command.ts +47 -0
- package/plugins/autonomous-pr-loop/core/config-editor.ts +140 -0
- package/plugins/autonomous-pr-loop/core/config.ts +293 -0
- package/plugins/autonomous-pr-loop/core/controller-host.ts +19 -0
- package/plugins/autonomous-pr-loop/core/dashboard-server.ts +536 -0
- package/plugins/autonomous-pr-loop/core/delivery-work-item.ts +217 -0
- package/plugins/autonomous-pr-loop/core/doctor.ts +335 -0
- package/plugins/autonomous-pr-loop/core/errors.ts +82 -0
- package/plugins/autonomous-pr-loop/core/gate-recovery.ts +176 -0
- package/plugins/autonomous-pr-loop/core/gates.ts +26 -0
- package/plugins/autonomous-pr-loop/core/generic-lifecycle.ts +399 -0
- package/plugins/autonomous-pr-loop/core/git.ts +213 -0
- package/plugins/autonomous-pr-loop/core/github.ts +269 -0
- package/plugins/autonomous-pr-loop/core/gitnexus.ts +90 -0
- package/plugins/autonomous-pr-loop/core/happy.ts +42 -0
- package/plugins/autonomous-pr-loop/core/hook-capture.ts +115 -0
- package/plugins/autonomous-pr-loop/core/hook-events.ts +22 -0
- package/plugins/autonomous-pr-loop/core/hook-installation.ts +85 -0
- package/plugins/autonomous-pr-loop/core/hook-observer.ts +84 -0
- package/plugins/autonomous-pr-loop/core/hook-policy.ts +423 -0
- package/plugins/autonomous-pr-loop/core/hook-router.ts +452 -0
- package/plugins/autonomous-pr-loop/core/index.ts +32 -0
- package/plugins/autonomous-pr-loop/core/local-install.ts +778 -0
- package/plugins/autonomous-pr-loop/core/locale.ts +60 -0
- package/plugins/autonomous-pr-loop/core/loop-shapes.ts +190 -0
- package/plugins/autonomous-pr-loop/core/mcp-controller.ts +1479 -0
- package/plugins/autonomous-pr-loop/core/notification-feed.ts +263 -0
- package/plugins/autonomous-pr-loop/core/plan-parser.ts +206 -0
- package/plugins/autonomous-pr-loop/core/plugin-paths.ts +32 -0
- package/plugins/autonomous-pr-loop/core/policy.ts +65 -0
- package/plugins/autonomous-pr-loop/core/pr-lifecycle.ts +464 -0
- package/plugins/autonomous-pr-loop/core/pr-selector.ts +284 -0
- package/plugins/autonomous-pr-loop/core/profiles.ts +439 -0
- package/plugins/autonomous-pr-loop/core/redaction.ts +17 -0
- package/plugins/autonomous-pr-loop/core/repo-root.ts +22 -0
- package/plugins/autonomous-pr-loop/core/review-comments.ts +77 -0
- package/plugins/autonomous-pr-loop/core/scope-guard.ts +179 -0
- package/plugins/autonomous-pr-loop/core/state-machine.ts +828 -0
- package/plugins/autonomous-pr-loop/core/state-types.ts +130 -0
- package/plugins/autonomous-pr-loop/core/storage.ts +2527 -0
- package/plugins/autonomous-pr-loop/core/types.ts +567 -0
- package/plugins/autonomous-pr-loop/core/worker-events.ts +412 -0
- package/plugins/autonomous-pr-loop/core/worker-policy.ts +72 -0
- package/plugins/autonomous-pr-loop/core/worker-prompts.ts +182 -0
- package/plugins/autonomous-pr-loop/core/worker.ts +809 -0
- package/plugins/autonomous-pr-loop/core/workflow-board.ts +1515 -0
- package/plugins/autonomous-pr-loop/hooks/dist/permission-request.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/post-compact.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/post-tool-use.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/pre-compact.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/pre-tool-use.js +3460 -0
- package/plugins/autonomous-pr-loop/hooks/dist/session-start.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/stop.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/user-prompt-submit.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/hooks.json +106 -0
- package/plugins/autonomous-pr-loop/hooks/observe-runner.ts +25 -0
- package/plugins/autonomous-pr-loop/hooks/permission-request.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/post-compact.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/post-tool-use.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/pre-compact.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/pre-tool-use.ts +44 -0
- package/plugins/autonomous-pr-loop/hooks/session-start.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/stop.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/user-prompt-submit.ts +4 -0
- package/plugins/autonomous-pr-loop/mcp-server/src/index.ts +87 -0
- package/plugins/autonomous-pr-loop/mcp-server/src/tools.ts +205 -0
- package/plugins/autonomous-pr-loop/package.json +9 -0
- package/plugins/autonomous-pr-loop/schemas/config.schema.json +74 -0
- package/plugins/autonomous-pr-loop/schemas/marketplace.schema.json +46 -0
- package/plugins/autonomous-pr-loop/schemas/plugin.schema.json +32 -0
- package/plugins/autonomous-pr-loop/schemas/state.schema.json +19 -0
- package/plugins/autonomous-pr-loop/schemas/worker-event.schema.json +19 -0
- package/plugins/autonomous-pr-loop/schemas/worker-result.schema.json +58 -0
- package/plugins/autonomous-pr-loop/scripts/agent-loop.ts +44 -0
- package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/SKILL.md +26 -0
- package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/agents/openai.yaml +6 -0
- package/plugins/autonomous-pr-loop/ui/index.html +26 -0
- package/plugins/autonomous-pr-loop/ui/public/favicon.svg +7 -0
- package/plugins/autonomous-pr-loop/ui/src/api.ts +639 -0
- package/plugins/autonomous-pr-loop/ui/src/app.tsx +238 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ActivityBadge.tsx +31 -0
- package/plugins/autonomous-pr-loop/ui/src/components/BrandMark.tsx +36 -0
- package/plugins/autonomous-pr-loop/ui/src/components/Collapsible.tsx +6 -0
- package/plugins/autonomous-pr-loop/ui/src/components/CommandPreview.tsx +15 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ConfigEditor.tsx +389 -0
- package/plugins/autonomous-pr-loop/ui/src/components/EmptyState.tsx +10 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ErrorState.tsx +12 -0
- package/plugins/autonomous-pr-loop/ui/src/components/List.tsx +7 -0
- package/plugins/autonomous-pr-loop/ui/src/components/MetricRow.tsx +6 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ResponsiveTable.tsx +65 -0
- package/plugins/autonomous-pr-loop/ui/src/components/RiskBadge.tsx +10 -0
- package/plugins/autonomous-pr-loop/ui/src/components/StatusBadge.tsx +29 -0
- package/plugins/autonomous-pr-loop/ui/src/components/TopMetric.tsx +10 -0
- package/plugins/autonomous-pr-loop/ui/src/fixtures.ts +1152 -0
- package/plugins/autonomous-pr-loop/ui/src/i18n.ts +1105 -0
- package/plugins/autonomous-pr-loop/ui/src/main.tsx +14 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenter.tsx +470 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenterParts.tsx +276 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/agent-timeline/AgentTimelineView.tsx +73 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/artifact-viewer/ArtifactViewer.tsx +44 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/dry-run-preview/DryRunPreview.tsx +66 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/event-ledger/EventLedger.tsx +17 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/gate-center/GateCenter.tsx +34 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/MissionControl.tsx +104 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/WorkflowBoard.tsx +577 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/notifications/NotificationsView.tsx +30 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/plan-navigator/PlanNavigator.tsx +19 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/policy-config/PolicyConfig.tsx +22 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/pr-inbox/PrInbox.tsx +26 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/recovery-center/RecoveryCenter.tsx +125 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/scope-guard/ScopeGuard.tsx +16 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/worker-runs/WorkerRuns.tsx +39 -0
- package/plugins/autonomous-pr-loop/ui/src/styles.css +2673 -0
- package/plugins/autonomous-pr-loop/ui/src/theme.ts +57 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,1413 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { cliText, parseLocaleOverride, resolveCliLocale, stripLocaleArgs } from "./cli-i18n.js";
|
|
6
|
+
import { redactRemote } from "./command.js";
|
|
7
|
+
import { configPath, loadConfig, statePath, withConfigDefaults } from "./config.js";
|
|
8
|
+
import { McpController, type McpResult } from "./mcp-controller.js";
|
|
9
|
+
import { startDashboardServer } from "./dashboard-server.js";
|
|
10
|
+
import { runDoctor } from "./doctor.js";
|
|
11
|
+
import { AgentLoopError, isGateCode, toErrorPayload, type AgentLoopErrorCode } from "./errors.js";
|
|
12
|
+
import { recoverBlockedRun } from "./gate-recovery.js";
|
|
13
|
+
import { agentLoopRouterHookEntries, collectHookCommands, isAgentLoopHookCommand, isLegacyAgentLoopHookCommand } from "./hook-installation.js";
|
|
14
|
+
import { hookRegistryPath, inspectHookRegistryLock, listHookBindings, removeHookBinding, upsertHookBinding } from "./hook-router.js";
|
|
15
|
+
import { inspectLocalInstall, installLocalAgentLoop, listLocalInstallSnapshots, pruneLocalInstallSnapshots, rollbackLocalAgentLoop } from "./local-install.js";
|
|
16
|
+
import { defaultPackageRoot, hookSourceRoot } from "./plugin-paths.js";
|
|
17
|
+
import { resumeStateMachine, runStateMachine, stopStateMachine } from "./state-machine.js";
|
|
18
|
+
import { SqliteAgentLoopStorage } from "./storage.js";
|
|
19
|
+
import { resolveRepoRoot } from "./repo-root.js";
|
|
20
|
+
import { bindDeliveryWorkItem } from "./delivery-work-item.js";
|
|
21
|
+
import { appendWorkflowEvidence, WORKFLOW_STAGE_DEFINITIONS, WORKFLOW_STAGE_IDS } from "./workflow-board.js";
|
|
22
|
+
import { inspectHookCapture } from "./hook-capture.js";
|
|
23
|
+
import type { StateMachineResult } from "./state-types.js";
|
|
24
|
+
import type { AgentLoopConfig, AgentTimelineSource, DoctorReport } from "./types.js";
|
|
25
|
+
import type { EffectiveLocale, LocaleSetting } from "./locale.js";
|
|
26
|
+
|
|
27
|
+
const DELIVERY_STAGE_STATUSES = ["pending", "active", "blocked", "done", "skipped", "manual", "failed"] as const;
|
|
28
|
+
|
|
29
|
+
export interface CliResult {
|
|
30
|
+
exitCode: 0 | 1 | 2;
|
|
31
|
+
stdout: string;
|
|
32
|
+
stderr: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ParsedCliInvocation {
|
|
36
|
+
command: string;
|
|
37
|
+
commandArgs: string[];
|
|
38
|
+
json: boolean;
|
|
39
|
+
localeOverride?: LocaleSetting;
|
|
40
|
+
targetRepoRoot: string;
|
|
41
|
+
targetPath: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Parse global CLI flags and resolve the target repository before command dispatch. */
|
|
45
|
+
export function parseCliInvocation(args: string[], cwd = process.cwd()): ParsedCliInvocation {
|
|
46
|
+
const json = args.includes("--json");
|
|
47
|
+
const localeOverride = parseCliLocale(args);
|
|
48
|
+
const withoutJson = args.filter((arg) => arg !== "--json");
|
|
49
|
+
const withoutLocale = stripLocaleArgs(withoutJson);
|
|
50
|
+
const { filtered, targetPath } = stripRepoArgs(withoutLocale, cwd);
|
|
51
|
+
return {
|
|
52
|
+
command: filtered[0] ?? "status",
|
|
53
|
+
commandArgs: filtered,
|
|
54
|
+
json,
|
|
55
|
+
...(localeOverride ? { localeOverride } : {}),
|
|
56
|
+
targetPath,
|
|
57
|
+
targetRepoRoot: resolveRepoRoot(targetPath)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Execute the `agent-loop` CLI and return captured output plus the intended exit code. */
|
|
62
|
+
export async function runAgentLoopCli(
|
|
63
|
+
args: string[],
|
|
64
|
+
cwd = process.cwd(),
|
|
65
|
+
options: { signal?: AbortSignal } = {}
|
|
66
|
+
): Promise<CliResult> {
|
|
67
|
+
let json = args.includes("--json");
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const parsed = parseCliInvocation(args, cwd);
|
|
71
|
+
json = parsed.json;
|
|
72
|
+
const { command, commandArgs: filtered, localeOverride, targetRepoRoot } = parsed;
|
|
73
|
+
const fallbackLocale = helpLocale(localeOverride);
|
|
74
|
+
|
|
75
|
+
if (command === "help" || command === "--help" || command === "-h") {
|
|
76
|
+
return helpResult(json);
|
|
77
|
+
}
|
|
78
|
+
if (isHelpRequest(filtered)) {
|
|
79
|
+
if (command === "evidence") {
|
|
80
|
+
return evidenceHelpResult(json);
|
|
81
|
+
}
|
|
82
|
+
if (command === "delivery") {
|
|
83
|
+
return deliveryHelpResult(json);
|
|
84
|
+
}
|
|
85
|
+
if (command === "local") {
|
|
86
|
+
return localHelpResult(json, filtered[1], filtered[2]);
|
|
87
|
+
}
|
|
88
|
+
const usage = commandHelpUsage(command);
|
|
89
|
+
if (usage) {
|
|
90
|
+
return helpResult(json, usage);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (command === "status") {
|
|
94
|
+
return await status(targetRepoRoot, json, localeOverride);
|
|
95
|
+
}
|
|
96
|
+
if (command === "init") {
|
|
97
|
+
return await init(targetRepoRoot, filtered.includes("--dry-run"), json, fallbackLocale);
|
|
98
|
+
}
|
|
99
|
+
if (command === "doctor") {
|
|
100
|
+
return await doctor(targetRepoRoot, json, localeOverride);
|
|
101
|
+
}
|
|
102
|
+
if (command === "run") {
|
|
103
|
+
return await run(targetRepoRoot, filtered, json, localeOverride, options.signal);
|
|
104
|
+
}
|
|
105
|
+
if (command === "step") {
|
|
106
|
+
return await step(targetRepoRoot, json, localeOverride, options.signal);
|
|
107
|
+
}
|
|
108
|
+
if (command === "resume") {
|
|
109
|
+
return await resume(targetRepoRoot, json, localeOverride);
|
|
110
|
+
}
|
|
111
|
+
if (command === "stop") {
|
|
112
|
+
return await stop(targetRepoRoot, json, localeOverride);
|
|
113
|
+
}
|
|
114
|
+
if (command === "logs") {
|
|
115
|
+
return await logs(targetRepoRoot, json, localeOverride);
|
|
116
|
+
}
|
|
117
|
+
if (command === "timeline") {
|
|
118
|
+
return timeline(targetRepoRoot, filtered, json, localeOverride);
|
|
119
|
+
}
|
|
120
|
+
if (command === "workers") {
|
|
121
|
+
return workers(targetRepoRoot, filtered, json, localeOverride);
|
|
122
|
+
}
|
|
123
|
+
if (command === "observe") {
|
|
124
|
+
return observe(targetRepoRoot, filtered, json, localeOverride);
|
|
125
|
+
}
|
|
126
|
+
if (command === "audit-export") {
|
|
127
|
+
return auditExport(targetRepoRoot, filtered, json, localeOverride);
|
|
128
|
+
}
|
|
129
|
+
if (command === "recover") {
|
|
130
|
+
return recover(targetRepoRoot, json, localeOverride);
|
|
131
|
+
}
|
|
132
|
+
if (command === "install-hooks") {
|
|
133
|
+
return installHooks(targetRepoRoot, json, localeOverride);
|
|
134
|
+
}
|
|
135
|
+
if (command === "hooks") {
|
|
136
|
+
return hooks(targetRepoRoot, filtered, json, localeOverride);
|
|
137
|
+
}
|
|
138
|
+
if (command === "local") {
|
|
139
|
+
return local(targetRepoRoot, filtered, json);
|
|
140
|
+
}
|
|
141
|
+
if (command === "approve-gate") {
|
|
142
|
+
return approveGate(targetRepoRoot, filtered, json, localeOverride);
|
|
143
|
+
}
|
|
144
|
+
if (command === "evidence") {
|
|
145
|
+
return evidence(targetRepoRoot, filtered, json, localeOverride);
|
|
146
|
+
}
|
|
147
|
+
if (command === "delivery") {
|
|
148
|
+
return delivery(targetRepoRoot, filtered, json, localeOverride);
|
|
149
|
+
}
|
|
150
|
+
if (command === "dashboard") {
|
|
151
|
+
return await dashboard(targetRepoRoot, filtered, json, localeOverride);
|
|
152
|
+
}
|
|
153
|
+
throw new AgentLoopError("unknown_command", `Unknown command: ${command}`);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
const payload = toErrorPayload(error);
|
|
156
|
+
const exitCode = error instanceof AgentLoopError ? error.exitCode : 1;
|
|
157
|
+
const stdout = json ? `${JSON.stringify({ ok: false, error: payload }, null, 2)}\n` : "";
|
|
158
|
+
const stderr = json ? "" : `${payload.code}: ${payload.message}\n${formatSafeErrorDetails(payload.details)}`;
|
|
159
|
+
return { exitCode, stdout, stderr };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isHelpRequest(args: string[]): boolean {
|
|
164
|
+
return args.some((arg, index) => (arg === "--help" || arg === "-h") && !isOptionValue(args, index));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isOptionValue(args: string[], index: number): boolean {
|
|
168
|
+
const previous = args[index - 1];
|
|
169
|
+
return previous !== undefined && OPTIONS_WITH_VALUES.has(previous);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function helpResult(json: boolean, usage = "agent-loop <command> [options]"): CliResult {
|
|
173
|
+
const commands = [
|
|
174
|
+
"status",
|
|
175
|
+
"init",
|
|
176
|
+
"doctor",
|
|
177
|
+
"run",
|
|
178
|
+
"step",
|
|
179
|
+
"resume",
|
|
180
|
+
"stop",
|
|
181
|
+
"logs",
|
|
182
|
+
"timeline",
|
|
183
|
+
"workers",
|
|
184
|
+
"observe",
|
|
185
|
+
"audit-export",
|
|
186
|
+
"recover",
|
|
187
|
+
"install-hooks",
|
|
188
|
+
"hooks",
|
|
189
|
+
"local",
|
|
190
|
+
"approve-gate",
|
|
191
|
+
"dashboard",
|
|
192
|
+
"evidence",
|
|
193
|
+
"delivery"
|
|
194
|
+
];
|
|
195
|
+
return ok(json, { ok: true, usage, commands }, [
|
|
196
|
+
`Usage: ${usage}`,
|
|
197
|
+
`Commands: ${commands.join(", ")}`
|
|
198
|
+
]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function commandHelpUsage(command: string): string | undefined {
|
|
202
|
+
const usages: Record<string, string> = {
|
|
203
|
+
status: "agent-loop status [--json]",
|
|
204
|
+
init: "agent-loop init [--dry-run] [--json]",
|
|
205
|
+
doctor: "agent-loop doctor [--json]",
|
|
206
|
+
run: "agent-loop run [--dry-run] [--until=gate] [--json]",
|
|
207
|
+
step: "agent-loop step [--json]",
|
|
208
|
+
resume: "agent-loop resume [--json]",
|
|
209
|
+
stop: "agent-loop stop [--json]",
|
|
210
|
+
logs: "agent-loop logs [--json]",
|
|
211
|
+
timeline: "agent-loop timeline [--limit N] [--cursor CURSOR] [--run RUN_ID] [--worker WORKER_ID] [--source SOURCE] [--json]",
|
|
212
|
+
workers: "agent-loop workers [--limit N] [--worker WORKER_ID] [--events] [--json]",
|
|
213
|
+
observe: "agent-loop observe [--limit N] [--json]",
|
|
214
|
+
"audit-export": "agent-loop audit-export --run RUN_ID --format markdown|json [--output PATH] [--json]",
|
|
215
|
+
recover: "agent-loop recover [--json]",
|
|
216
|
+
"install-hooks": "agent-loop install-hooks [--repo /path/to/repo] [--json]",
|
|
217
|
+
hooks: "agent-loop hooks install-router|bind|list|doctor|unbind [--session SESSION_ID] [--run RUN_ID] [--json]",
|
|
218
|
+
local: "agent-loop local install|rollback|doctor|snapshots [--repo /path/to/repo] [--snapshot PATH] [--json]",
|
|
219
|
+
"approve-gate": "agent-loop approve-gate <gate-id> --note \"...\" [--next-state STATE] [--json]",
|
|
220
|
+
dashboard: "agent-loop dashboard [--host 127.0.0.1] [--port 0] [--json]",
|
|
221
|
+
evidence: "agent-loop evidence append --stage STAGE --summary \"...\" [--run RUN_ID] [--substage ID] [--actor ACTOR] [--status STATUS] [--source SOURCE] [--ref REF] [--artifact ID] [--json]",
|
|
222
|
+
delivery: "agent-loop delivery bind|stage [options] [--json]"
|
|
223
|
+
};
|
|
224
|
+
return usages[command];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const OPTIONS_WITH_VALUES = new Set([
|
|
228
|
+
"--cursor",
|
|
229
|
+
"--format",
|
|
230
|
+
"--host",
|
|
231
|
+
"--actor",
|
|
232
|
+
"--artifact",
|
|
233
|
+
"--branch",
|
|
234
|
+
"--keep",
|
|
235
|
+
"--limit",
|
|
236
|
+
"--next-state",
|
|
237
|
+
"--note",
|
|
238
|
+
"--output",
|
|
239
|
+
"--port",
|
|
240
|
+
"--issue",
|
|
241
|
+
"--ref",
|
|
242
|
+
"--run",
|
|
243
|
+
"--source",
|
|
244
|
+
"--stage",
|
|
245
|
+
"--status",
|
|
246
|
+
"--session",
|
|
247
|
+
"--snapshot",
|
|
248
|
+
"--substage",
|
|
249
|
+
"--summary",
|
|
250
|
+
"--title",
|
|
251
|
+
"--url",
|
|
252
|
+
"--worker"
|
|
253
|
+
]);
|
|
254
|
+
|
|
255
|
+
async function status(repoRoot: string, json: boolean, localeOverride: LocaleSetting | undefined): Promise<CliResult> {
|
|
256
|
+
const { config } = loadConfig(repoRoot);
|
|
257
|
+
const locale = resolveCliLocale(localeOverride, config.locale);
|
|
258
|
+
const path = statePath(repoRoot);
|
|
259
|
+
const storage = new SqliteAgentLoopStorage(path);
|
|
260
|
+
let current: ReturnType<SqliteAgentLoopStorage["getCurrentStatus"]>;
|
|
261
|
+
try {
|
|
262
|
+
current = storage.getCurrentStatus();
|
|
263
|
+
} finally {
|
|
264
|
+
storage.close();
|
|
265
|
+
}
|
|
266
|
+
const payload = {
|
|
267
|
+
ok: true,
|
|
268
|
+
repoId: config.repoId,
|
|
269
|
+
baseBranch: config.baseBranch,
|
|
270
|
+
plansDir: config.plansDir,
|
|
271
|
+
storagePath: path,
|
|
272
|
+
status: current.status,
|
|
273
|
+
gate: current.gate
|
|
274
|
+
};
|
|
275
|
+
return {
|
|
276
|
+
...ok(json, payload, [
|
|
277
|
+
`${cliText(locale, "repoId")}: ${payload.repoId}`,
|
|
278
|
+
`${cliText(locale, "baseBranch")}: ${payload.baseBranch}`,
|
|
279
|
+
`${cliText(locale, "plansDir")}: ${payload.plansDir}`,
|
|
280
|
+
`${cliText(locale, "storage")}: ${payload.storagePath}`,
|
|
281
|
+
`${cliText(locale, "status")}: ${payload.status}`,
|
|
282
|
+
payload.gate ? `${cliText(locale, "gate")}: ${payload.gate.kind} - ${payload.gate.message}` : undefined
|
|
283
|
+
]),
|
|
284
|
+
exitCode: json ? 0 : current.status === "BLOCKED" || current.status === "STOPPED" ? 2 : 0
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function recover(repoRoot: string, json: boolean, localeOverride: LocaleSetting | undefined): CliResult {
|
|
289
|
+
const locale = localeForRepo(repoRoot, localeOverride);
|
|
290
|
+
const result = recoverBlockedRun(repoRoot, "cli");
|
|
291
|
+
return ok(json, result, [
|
|
292
|
+
`${cliText(locale, "recovered")}: ${result.recovered}`,
|
|
293
|
+
`repo gates: ${result.repo.recovered}`,
|
|
294
|
+
result.worker.recovered > 0
|
|
295
|
+
? `worker gates: ${result.worker.recovered} (${result.worker.gateKinds.join(", ")})`
|
|
296
|
+
: "worker gates: 0",
|
|
297
|
+
...(result.worker.recovered > 0 ? [`resume: agent-loop resume`] : [])
|
|
298
|
+
]);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function init(repoRoot: string, dryRun: boolean, json: boolean, locale: EffectiveLocale): Promise<CliResult> {
|
|
302
|
+
const configFile = configPath(repoRoot);
|
|
303
|
+
if (!dryRun && existsSync(configFile)) {
|
|
304
|
+
throw new AgentLoopError(
|
|
305
|
+
"config_exists",
|
|
306
|
+
".agent-loop/config.json already exists. PR A does not implement --force."
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const remote = git(repoRoot, ["remote", "get-url", "origin"]);
|
|
311
|
+
const currentBranch = getCurrentBranch(repoRoot);
|
|
312
|
+
if (!remote.includes("github.com")) {
|
|
313
|
+
throw new AgentLoopError("unsupported_remote", "origin remote is not a GitHub remote.", {
|
|
314
|
+
details: { remote: redactRemote(remote) },
|
|
315
|
+
exitCode: 2
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const config = inferConfig(repoRoot, remote);
|
|
320
|
+
const payload = {
|
|
321
|
+
ok: true,
|
|
322
|
+
dryRun,
|
|
323
|
+
configPath: configFile,
|
|
324
|
+
storagePath: statePath(repoRoot),
|
|
325
|
+
currentBranch,
|
|
326
|
+
config
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
if (!dryRun) {
|
|
330
|
+
mkdirSync(dirname(configFile), { recursive: true });
|
|
331
|
+
writeFileSync(configFile, `${JSON.stringify(config, null, 2)}\n`);
|
|
332
|
+
ensureAgentLoopGitignore(repoRoot);
|
|
333
|
+
const storage = new SqliteAgentLoopStorage(statePath(repoRoot));
|
|
334
|
+
try {
|
|
335
|
+
storage.writeRepoConfig(config);
|
|
336
|
+
} finally {
|
|
337
|
+
storage.close();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return ok(json, payload, [
|
|
342
|
+
dryRun ? cliText(locale, "initDryRun") : cliText(locale, "initDone"),
|
|
343
|
+
`${cliText(locale, "currentBranch")}: ${currentBranch}`,
|
|
344
|
+
`${cliText(locale, "repoId")}: ${config.repoId}`,
|
|
345
|
+
`${cliText(locale, "baseBranch")}: ${config.baseBranch}`,
|
|
346
|
+
`${cliText(locale, "plansDir")}: ${config.plansDir}`,
|
|
347
|
+
`${cliText(locale, "config")}: ${configFile}`,
|
|
348
|
+
`${cliText(locale, "storage")}: ${statePath(repoRoot)}`
|
|
349
|
+
]);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function doctor(repoRoot: string, json: boolean, localeOverride: LocaleSetting | undefined): Promise<CliResult> {
|
|
353
|
+
const locale = localeForRepo(repoRoot, localeOverride);
|
|
354
|
+
const report = runDoctor(repoRoot);
|
|
355
|
+
const exitCode: 0 | 1 | 2 = report.gate ? 2 : report.status === "fail" ? 1 : 0;
|
|
356
|
+
if (json) {
|
|
357
|
+
return { exitCode, stdout: `${JSON.stringify(report, null, 2)}\n`, stderr: "" };
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
exitCode,
|
|
361
|
+
stdout: formatDoctor(report, locale),
|
|
362
|
+
stderr: ""
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function run(repoRoot: string, args: string[], json: boolean, localeOverride: LocaleSetting | undefined, signal?: AbortSignal): Promise<CliResult> {
|
|
367
|
+
const locale = localeForRepo(repoRoot, localeOverride);
|
|
368
|
+
const result = await runStateMachine({
|
|
369
|
+
repoRoot,
|
|
370
|
+
dryRun: args.includes("--dry-run"),
|
|
371
|
+
untilGate: args.includes("--until=gate"),
|
|
372
|
+
signal
|
|
373
|
+
});
|
|
374
|
+
return stateResult(json, result, locale);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function step(repoRoot: string, json: boolean, localeOverride: LocaleSetting | undefined, signal?: AbortSignal): Promise<CliResult> {
|
|
378
|
+
const locale = localeForRepo(repoRoot, localeOverride);
|
|
379
|
+
const result = await runStateMachine({
|
|
380
|
+
repoRoot,
|
|
381
|
+
dryRun: false,
|
|
382
|
+
untilGate: false,
|
|
383
|
+
singleStep: true,
|
|
384
|
+
signal
|
|
385
|
+
});
|
|
386
|
+
return stateResult(json, result, locale);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function resume(repoRoot: string, json: boolean, localeOverride: LocaleSetting | undefined): Promise<CliResult> {
|
|
390
|
+
return stateResult(json, await resumeStateMachine(repoRoot), localeForRepo(repoRoot, localeOverride));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function stop(repoRoot: string, json: boolean, localeOverride: LocaleSetting | undefined): Promise<CliResult> {
|
|
394
|
+
return stateResult(json, stopStateMachine(repoRoot), localeForRepo(repoRoot, localeOverride), { jsonStoppedOk: true });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function logs(repoRoot: string, json: boolean, localeOverride: LocaleSetting | undefined): Promise<CliResult> {
|
|
398
|
+
localeForRepo(repoRoot, localeOverride);
|
|
399
|
+
const storage = new SqliteAgentLoopStorage(statePath(repoRoot));
|
|
400
|
+
try {
|
|
401
|
+
const events = storage.listEvents(50);
|
|
402
|
+
const payload = { ok: true, events };
|
|
403
|
+
// Event kind/message values are persisted ledger protocol data; do not translate them.
|
|
404
|
+
return ok(
|
|
405
|
+
json,
|
|
406
|
+
payload,
|
|
407
|
+
events.map((event) => `${event.createdAt} ${event.kind}: ${event.message}`)
|
|
408
|
+
);
|
|
409
|
+
} finally {
|
|
410
|
+
storage.close();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function timeline(repoRoot: string, args: string[], json: boolean, localeOverride: LocaleSetting | undefined): CliResult {
|
|
415
|
+
localeForRepo(repoRoot, localeOverride);
|
|
416
|
+
const source = optionArg(args, "--source");
|
|
417
|
+
const result = new McpController({ repoRoot }).loopAgentTimeline({
|
|
418
|
+
...numberOption(args, "--limit", "timeline --limit must be a non-negative integer."),
|
|
419
|
+
...stringOption(args, "--cursor", "cursor"),
|
|
420
|
+
...stringOption(args, "--run", "runId"),
|
|
421
|
+
...stringOption(args, "--worker", "workerId"),
|
|
422
|
+
...(source ? { sources: [parseTimelineSource(source)] } : {})
|
|
423
|
+
});
|
|
424
|
+
return controllerResult(json, result, (data) =>
|
|
425
|
+
(data.entries as Array<{ occurredAt: string; source: string; title: string; summary: string }>).map((entry) =>
|
|
426
|
+
`${entry.occurredAt} ${entry.source}: ${entry.title}${entry.summary ? ` - ${entry.summary}` : ""}`
|
|
427
|
+
)
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function workers(repoRoot: string, args: string[], json: boolean, localeOverride: LocaleSetting | undefined): CliResult {
|
|
432
|
+
localeForRepo(repoRoot, localeOverride);
|
|
433
|
+
const result = new McpController({ repoRoot }).loopListWorkers({
|
|
434
|
+
...numberOption(args, "--limit", "workers --limit must be a non-negative integer."),
|
|
435
|
+
...stringOption(args, "--worker", "workerId"),
|
|
436
|
+
includeEvents: args.includes("--events")
|
|
437
|
+
});
|
|
438
|
+
return controllerResult(json, result, (data) =>
|
|
439
|
+
(data.workers as Array<{ id: string; type: string; status: string; startedAt: string }>).map((worker) =>
|
|
440
|
+
`${worker.id} ${worker.type} ${worker.status} ${worker.startedAt}`
|
|
441
|
+
)
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function observe(repoRoot: string, args: string[], json: boolean, localeOverride: LocaleSetting | undefined): CliResult {
|
|
446
|
+
localeForRepo(repoRoot, localeOverride);
|
|
447
|
+
const limit = numberOption(args, "--limit", "observe --limit must be a non-negative integer.").limit ?? 20;
|
|
448
|
+
const result = new McpController({ repoRoot }).loopObserve(limit);
|
|
449
|
+
return controllerResult(json, result, (data) => {
|
|
450
|
+
const observeData = data as {
|
|
451
|
+
dashboard: { url: string; loopbackOnly: boolean };
|
|
452
|
+
happy: { installed: boolean; supportsNotify: boolean };
|
|
453
|
+
current: { status: string; gate?: { kind: string } };
|
|
454
|
+
timeline: { entries: unknown[] };
|
|
455
|
+
};
|
|
456
|
+
return [
|
|
457
|
+
`dashboard: ${observeData.dashboard.url} (${observeData.dashboard.loopbackOnly ? "loopback only" : "unknown"})`,
|
|
458
|
+
"token: run `agent-loop dashboard` and read stderr",
|
|
459
|
+
`status: ${observeData.current.status}`,
|
|
460
|
+
observeData.current.gate ? `gate: ${observeData.current.gate.kind}` : undefined,
|
|
461
|
+
`happy notify: ${observeData.happy.installed && observeData.happy.supportsNotify ? "available" : "unavailable"}`,
|
|
462
|
+
`timeline: ${observeData.timeline.entries.length} entries`
|
|
463
|
+
].filter((line): line is string => Boolean(line));
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function auditExport(repoRoot: string, args: string[], json: boolean, localeOverride: LocaleSetting | undefined): CliResult {
|
|
468
|
+
localeForRepo(repoRoot, localeOverride);
|
|
469
|
+
const runId = optionArg(args, "--run");
|
|
470
|
+
if (!runId) {
|
|
471
|
+
throw new AgentLoopError("unknown_command", "Usage: agent-loop audit-export --run RUN_ID --format markdown|json [--output PATH]");
|
|
472
|
+
}
|
|
473
|
+
const format = parseAuditFormat(optionArg(args, "--format") ?? "markdown");
|
|
474
|
+
const result = new McpController({ repoRoot }).loopExportAudit({ runId, format });
|
|
475
|
+
if (!result.ok || !result.data) {
|
|
476
|
+
return controllerResult(json, result, () => []);
|
|
477
|
+
}
|
|
478
|
+
const data = result.data as { content: string | Record<string, unknown> };
|
|
479
|
+
const output = optionArg(args, "--output");
|
|
480
|
+
const content = typeof data.content === "string" ? data.content : `${JSON.stringify(data.content, null, 2)}\n`;
|
|
481
|
+
if (output) {
|
|
482
|
+
writeFileSync(output, content);
|
|
483
|
+
}
|
|
484
|
+
if (json) {
|
|
485
|
+
return ok(true, { ok: true, ...result.data, ...(output ? { output } : {}) }, []);
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
exitCode: 0,
|
|
489
|
+
stdout: output ? `audit export written: ${output}\n` : content,
|
|
490
|
+
stderr: ""
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function evidence(repoRoot: string, args: string[], json: boolean, localeOverride: LocaleSetting | undefined): CliResult {
|
|
495
|
+
localeForRepo(repoRoot, localeOverride);
|
|
496
|
+
const subcommand = args[1];
|
|
497
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
498
|
+
return evidenceHelpResult(json);
|
|
499
|
+
}
|
|
500
|
+
if (subcommand !== "append") {
|
|
501
|
+
throw new AgentLoopError("unknown_command", "Usage: agent-loop evidence append --stage STAGE --summary \"...\" [--substage ID] [--artifact ID]");
|
|
502
|
+
}
|
|
503
|
+
const stageId = optionArg(args, "--stage");
|
|
504
|
+
const summary = optionArg(args, "--summary");
|
|
505
|
+
const storage = new SqliteAgentLoopStorage(statePath(repoRoot));
|
|
506
|
+
try {
|
|
507
|
+
const result = appendWorkflowEvidence(storage, {
|
|
508
|
+
runId: optionArg(args, "--run"),
|
|
509
|
+
stageId,
|
|
510
|
+
summary,
|
|
511
|
+
substageId: optionArg(args, "--substage"),
|
|
512
|
+
evidenceRefIds: optionArgs(args, "--ref"),
|
|
513
|
+
artifactIds: optionArgs(args, "--artifact"),
|
|
514
|
+
actor: optionArg(args, "--actor"),
|
|
515
|
+
status: optionArg(args, "--status"),
|
|
516
|
+
source: optionArg(args, "--source") ?? "cli",
|
|
517
|
+
review: reviewEvidenceFromArgs(args)
|
|
518
|
+
});
|
|
519
|
+
return ok(json, { ok: true, ...result }, [
|
|
520
|
+
`workflow evidence: ${result.event.id}`,
|
|
521
|
+
result.event.message
|
|
522
|
+
]);
|
|
523
|
+
} finally {
|
|
524
|
+
storage.close();
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function evidenceHelpResult(json: boolean): CliResult {
|
|
529
|
+
const substages = workflowEvidenceSubstageHelp();
|
|
530
|
+
return ok(json, {
|
|
531
|
+
ok: true,
|
|
532
|
+
usage: commandHelpUsage("evidence"),
|
|
533
|
+
stages: WORKFLOW_STAGE_IDS,
|
|
534
|
+
substages,
|
|
535
|
+
reviewFlags: ["--reviewer", "--requirement", "--progress", "--result", "--severity", "--model", "--session", "--conversation", "--comment-url", "--comment-id", "--reason"]
|
|
536
|
+
}, [
|
|
537
|
+
"Usage: agent-loop evidence append --stage STAGE --summary \"...\" [--substage ID] [--artifact ID]",
|
|
538
|
+
"Review evidence: add --stage review --reviewer claude_acp --requirement required --progress started|complete --result pass|block|warn|unknown --severity none|p3_only|p2_or_higher|unknown [--model NAME] [--session ID] [--conversation ID] [--comment-url URL] [--comment-id ID] [--reason TEXT]",
|
|
539
|
+
`Stages: ${WORKFLOW_STAGE_IDS.join(", ")}`,
|
|
540
|
+
"Substages:",
|
|
541
|
+
...substages.map((entry) => ` ${entry.stage}: ${entry.substages.join(", ")}`)
|
|
542
|
+
]);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function reviewEvidenceFromArgs(args: string[]): Record<string, string> | undefined {
|
|
546
|
+
const pairs: Array<[string, string]> = [
|
|
547
|
+
["reviewer", "--reviewer"],
|
|
548
|
+
["requirement", "--requirement"],
|
|
549
|
+
["progress", "--progress"],
|
|
550
|
+
["result", "--result"],
|
|
551
|
+
["model", "--model"],
|
|
552
|
+
["sessionId", "--session"],
|
|
553
|
+
["conversationId", "--conversation"],
|
|
554
|
+
["commentUrl", "--comment-url"],
|
|
555
|
+
["commentId", "--comment-id"],
|
|
556
|
+
["severitySummary", "--severity"],
|
|
557
|
+
["reason", "--reason"]
|
|
558
|
+
];
|
|
559
|
+
const review = Object.fromEntries(pairs.flatMap(([key, flag]) => {
|
|
560
|
+
const value = optionArg(args, flag);
|
|
561
|
+
return value ? [[key, value]] : [];
|
|
562
|
+
}));
|
|
563
|
+
return Object.keys(review).length > 0 ? review : undefined;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function workflowEvidenceSubstageHelp(): Array<{ stage: string; substages: string[] }> {
|
|
567
|
+
return WORKFLOW_STAGE_DEFINITIONS.map((stage) => ({
|
|
568
|
+
stage: stage.id,
|
|
569
|
+
substages: stage.substages.map((substage) => substage.id)
|
|
570
|
+
}));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function delivery(repoRoot: string, args: string[], json: boolean, localeOverride: LocaleSetting | undefined): CliResult {
|
|
574
|
+
localeForRepo(repoRoot, localeOverride);
|
|
575
|
+
const subcommand = args[1];
|
|
576
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
577
|
+
return deliveryHelpResult(json);
|
|
578
|
+
}
|
|
579
|
+
if (subcommand !== "bind" && subcommand !== "stage") {
|
|
580
|
+
throw new AgentLoopError("unknown_command", "Usage: agent-loop delivery bind|stage [options]");
|
|
581
|
+
}
|
|
582
|
+
const { config } = loadConfig(repoRoot);
|
|
583
|
+
if (config.loopShape !== "pr-loop") {
|
|
584
|
+
throw new AgentLoopError("invalid_config", "delivery is only supported for pr-loop repositories.");
|
|
585
|
+
}
|
|
586
|
+
if (subcommand === "stage") {
|
|
587
|
+
return deliveryStage(repoRoot, args, json);
|
|
588
|
+
}
|
|
589
|
+
const storage = new SqliteAgentLoopStorage(statePath(repoRoot));
|
|
590
|
+
try {
|
|
591
|
+
const result = bindDeliveryWorkItem(storage, {
|
|
592
|
+
...optionalCliValue(args, "--issue", "issue"),
|
|
593
|
+
...optionalCliValue(args, "--title", "title"),
|
|
594
|
+
...optionalCliValue(args, "--url", "url"),
|
|
595
|
+
...optionalCliValue(args, "--branch", "branch"),
|
|
596
|
+
...optionalCliValue(args, "--run", "runId"),
|
|
597
|
+
source: "cli"
|
|
598
|
+
});
|
|
599
|
+
const hookBinding = upsertHookBinding({ repoRoot, runId: result.run.id });
|
|
600
|
+
return ok(json, { ok: true, ...result, hookBinding }, [
|
|
601
|
+
`delivery run: ${result.run.id}`,
|
|
602
|
+
`issue: #${result.workItem.issue}`,
|
|
603
|
+
result.bound ? "bound: yes" : "bound: reused",
|
|
604
|
+
`hook binding: ${hookBinding.id}`
|
|
605
|
+
]);
|
|
606
|
+
} finally {
|
|
607
|
+
storage.close();
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function deliveryStage(repoRoot: string, args: string[], json: boolean): CliResult {
|
|
612
|
+
const status = optionArg(args, "--status") ?? "active";
|
|
613
|
+
if (!DELIVERY_STAGE_STATUSES.includes(status as (typeof DELIVERY_STAGE_STATUSES)[number])) {
|
|
614
|
+
throw new AgentLoopError("invalid_config", `delivery stage --status must be one of: ${DELIVERY_STAGE_STATUSES.join(", ")}.`);
|
|
615
|
+
}
|
|
616
|
+
const storage = new SqliteAgentLoopStorage(statePath(repoRoot));
|
|
617
|
+
try {
|
|
618
|
+
const result = appendWorkflowEvidence(storage, {
|
|
619
|
+
runId: optionArg(args, "--run"),
|
|
620
|
+
stageId: optionArg(args, "--stage"),
|
|
621
|
+
substageId: optionArg(args, "--substage"),
|
|
622
|
+
summary: optionArg(args, "--summary"),
|
|
623
|
+
evidenceRefIds: optionArgs(args, "--ref"),
|
|
624
|
+
actor: optionArg(args, "--actor") ?? "codex",
|
|
625
|
+
status,
|
|
626
|
+
source: "delivery_stage"
|
|
627
|
+
});
|
|
628
|
+
return ok(json, { ok: true, ...result }, [
|
|
629
|
+
`delivery stage: ${result.evidence.label}`,
|
|
630
|
+
result.event.message
|
|
631
|
+
]);
|
|
632
|
+
} finally {
|
|
633
|
+
storage.close();
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function deliveryHelpResult(json: boolean): CliResult {
|
|
638
|
+
const substages = workflowEvidenceSubstageHelp();
|
|
639
|
+
return ok(json, {
|
|
640
|
+
ok: true,
|
|
641
|
+
usage: commandHelpUsage("delivery"),
|
|
642
|
+
stages: WORKFLOW_STAGE_IDS,
|
|
643
|
+
statuses: DELIVERY_STAGE_STATUSES,
|
|
644
|
+
substages
|
|
645
|
+
}, [
|
|
646
|
+
"Usage: agent-loop delivery bind --issue N --title \"...\" --url https://github.com/OWNER/REPO/issues/N [--branch BRANCH] [--run RUN_ID]",
|
|
647
|
+
`Usage: agent-loop delivery stage --run RUN_ID --stage STAGE --status ${DELIVERY_STAGE_STATUSES.join("|")} --summary "..." [--substage ID] [--actor codex] [--ref URL]`,
|
|
648
|
+
`Stages: ${WORKFLOW_STAGE_IDS.join(", ")}`,
|
|
649
|
+
"Substages:",
|
|
650
|
+
...substages.map((entry) => ` ${entry.stage}: ${entry.substages.join(", ")}`)
|
|
651
|
+
]);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function installHooks(repoRoot: string, json: boolean, localeOverride: LocaleSetting | undefined): CliResult {
|
|
655
|
+
const locale = localeForRepo(repoRoot, localeOverride);
|
|
656
|
+
const packageRoot = defaultPackageRoot();
|
|
657
|
+
buildHookDist(packageRoot);
|
|
658
|
+
const install = installRouterHooks(packageRoot);
|
|
659
|
+
const binding = upsertHookBinding({ repoRoot });
|
|
660
|
+
return ok(json, {
|
|
661
|
+
ok: true,
|
|
662
|
+
hooksPath: install.hooksPath,
|
|
663
|
+
registryPath: hookRegistryPath(),
|
|
664
|
+
binding,
|
|
665
|
+
removedLegacyCommands: install.removedLegacyCommands
|
|
666
|
+
}, [
|
|
667
|
+
cliText(locale, "hooksInstalled"),
|
|
668
|
+
`${cliText(locale, "hooks")}: ${install.hooksPath}`,
|
|
669
|
+
`hook binding: ${binding.id}`
|
|
670
|
+
]);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function hooks(repoRoot: string, args: string[], json: boolean, localeOverride: LocaleSetting | undefined): CliResult {
|
|
674
|
+
const subcommand = args[1];
|
|
675
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
676
|
+
return ok(json, {
|
|
677
|
+
ok: true,
|
|
678
|
+
usage: commandHelpUsage("hooks"),
|
|
679
|
+
commands: ["install-router", "bind", "list", "doctor", "unbind"]
|
|
680
|
+
}, [
|
|
681
|
+
`Usage: ${commandHelpUsage("hooks")}`,
|
|
682
|
+
"Commands: install-router, bind, list, doctor, unbind"
|
|
683
|
+
]);
|
|
684
|
+
}
|
|
685
|
+
const packageRoot = defaultPackageRoot();
|
|
686
|
+
if (subcommand === "install-router") {
|
|
687
|
+
buildHookDist(packageRoot);
|
|
688
|
+
const install = installRouterHooks(packageRoot);
|
|
689
|
+
return ok(json, { ok: true, ...install }, [
|
|
690
|
+
"agent-loop hook router installed",
|
|
691
|
+
`hooks: ${install.hooksPath}`
|
|
692
|
+
]);
|
|
693
|
+
}
|
|
694
|
+
if (subcommand === "bind") {
|
|
695
|
+
const binding = upsertHookBinding({
|
|
696
|
+
repoRoot,
|
|
697
|
+
...optionalCliValue(args, "--run", "runId"),
|
|
698
|
+
...optionalCliValue(args, "--session", "sessionId")
|
|
699
|
+
});
|
|
700
|
+
return ok(json, { ok: true, binding, registryPath: hookRegistryPath() }, [
|
|
701
|
+
`hook binding: ${binding.id}`,
|
|
702
|
+
`repo: ${binding.repoRoot}`
|
|
703
|
+
]);
|
|
704
|
+
}
|
|
705
|
+
if (subcommand === "list") {
|
|
706
|
+
const bindings = listHookBindings();
|
|
707
|
+
return ok(json, { ok: true, registryPath: hookRegistryPath(), bindings }, bindings.length > 0
|
|
708
|
+
? bindings.map((binding) => `${binding.status}: ${binding.repoRoot}${binding.sessionIdHash ? ` sessionHash=${binding.sessionIdHash.slice(0, 12)}` : ""}`)
|
|
709
|
+
: ["no agent-loop hook bindings"]);
|
|
710
|
+
}
|
|
711
|
+
if (subcommand === "doctor") {
|
|
712
|
+
const report = hookInstallReport(repoRoot, packageRoot);
|
|
713
|
+
return ok(json, { ok: true, ...report }, [
|
|
714
|
+
report.routerInstalled ? "hook router installed" : "hook router missing",
|
|
715
|
+
`active bindings: ${report.activeBindings}`,
|
|
716
|
+
`legacy entries: ${report.legacyCommands.length}`,
|
|
717
|
+
`hook capture: ${report.hookCapture.status} - ${report.hookCapture.reason}`
|
|
718
|
+
]);
|
|
719
|
+
}
|
|
720
|
+
if (subcommand === "unbind") {
|
|
721
|
+
const removed = removeHookBinding({
|
|
722
|
+
repoRoot,
|
|
723
|
+
...optionalCliValue(args, "--session", "sessionId")
|
|
724
|
+
});
|
|
725
|
+
return ok(json, { ok: true, removed, registryPath: hookRegistryPath() }, [
|
|
726
|
+
`removed hook bindings: ${removed.length}`
|
|
727
|
+
]);
|
|
728
|
+
}
|
|
729
|
+
throw new AgentLoopError("unknown_command", `Unknown hooks command: ${subcommand}`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function local(repoRoot: string, args: string[], json: boolean): CliResult {
|
|
733
|
+
const subcommand = args[1];
|
|
734
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
735
|
+
return localHelpResult(json);
|
|
736
|
+
}
|
|
737
|
+
if (subcommand === "install") {
|
|
738
|
+
const result = installLocalAgentLoop({
|
|
739
|
+
repoRoot,
|
|
740
|
+
allowDirty: args.includes("--allow-dirty")
|
|
741
|
+
});
|
|
742
|
+
return ok(json, result, [
|
|
743
|
+
"agent-loop local install complete",
|
|
744
|
+
`snapshot: ${result.snapshotPath}`,
|
|
745
|
+
`rollback: ${result.rollbackCommand}`,
|
|
746
|
+
`router installed: ${result.localDoctor.hooks.routerInstalled}`,
|
|
747
|
+
`current repo bindings: ${result.localDoctor.bindings.currentRepoBindings}`
|
|
748
|
+
]);
|
|
749
|
+
}
|
|
750
|
+
if (subcommand === "rollback") {
|
|
751
|
+
const snapshotPath = optionArg(args, "--snapshot");
|
|
752
|
+
if (!snapshotPath) {
|
|
753
|
+
throw new AgentLoopError("invalid_config", "local rollback requires --snapshot PATH.");
|
|
754
|
+
}
|
|
755
|
+
const result = rollbackLocalAgentLoop({ snapshotPath });
|
|
756
|
+
return ok(json, result, [
|
|
757
|
+
"agent-loop local rollback complete",
|
|
758
|
+
`snapshot: ${result.snapshotPath}`,
|
|
759
|
+
`restored: ${result.restored.length}`,
|
|
760
|
+
`removed: ${result.removed.length}`,
|
|
761
|
+
...result.warnings
|
|
762
|
+
]);
|
|
763
|
+
}
|
|
764
|
+
if (subcommand === "doctor") {
|
|
765
|
+
const result = inspectLocalInstall({ repoRoot });
|
|
766
|
+
return ok(json, result, [
|
|
767
|
+
"agent-loop local doctor",
|
|
768
|
+
`binary: ${result.binary.path ?? "not found"}`,
|
|
769
|
+
`binary points to expected package: ${result.binary.pointsToExpectedPackage ? "yes" : "no"}`,
|
|
770
|
+
`router installed: ${result.hooks.routerInstalled}`,
|
|
771
|
+
`router points to expected dist: ${result.hooks.routerCommandsPointToExpectedDist ? "yes" : "no"}`,
|
|
772
|
+
`legacy entries: ${result.hooks.legacyCommands.length}`,
|
|
773
|
+
`current repo bindings: ${result.bindings.currentRepoBindings}`,
|
|
774
|
+
`stale/missing path bindings: ${result.bindings.staleOrMissingPathBindings}`,
|
|
775
|
+
`temp path bindings: ${result.bindings.tempPathBindings}`,
|
|
776
|
+
`registry lock: ${result.bindings.lock.exists ? result.bindings.lock.stale ? "stale" : "active" : "none"}`,
|
|
777
|
+
`self-link pollution: ${result.selfLinkPollution.clean ? "clean" : result.selfLinkPollution.files.join(", ")}`
|
|
778
|
+
]);
|
|
779
|
+
}
|
|
780
|
+
if (subcommand === "snapshots") {
|
|
781
|
+
if (args[2] === "prune") {
|
|
782
|
+
const keep = parsePositiveIntOption(args, "--keep", "local snapshots prune requires --keep with a positive integer.");
|
|
783
|
+
const result = pruneLocalInstallSnapshots({ keep, apply: args.includes("--apply") });
|
|
784
|
+
return ok(json, result, [
|
|
785
|
+
`snapshot prune: ${result.apply ? "applied" : "dry-run"}`,
|
|
786
|
+
`keep: ${result.keep}`,
|
|
787
|
+
`candidates: ${result.candidates.length}`,
|
|
788
|
+
...result.candidates.map((snapshot) => `candidate: ${snapshot.path}`),
|
|
789
|
+
`deleted: ${result.deleted.length}`,
|
|
790
|
+
...result.deleted.map((path) => `deleted: ${path}`),
|
|
791
|
+
...result.warnings
|
|
792
|
+
]);
|
|
793
|
+
}
|
|
794
|
+
if (args.includes("--keep") || args.includes("--apply")) {
|
|
795
|
+
throw new AgentLoopError("invalid_config", "Use `agent-loop local snapshots prune --keep N [--apply]` to prune snapshots.");
|
|
796
|
+
}
|
|
797
|
+
const result = listLocalInstallSnapshots();
|
|
798
|
+
return ok(json, result, [
|
|
799
|
+
`snapshots: ${result.snapshots.length}`,
|
|
800
|
+
...result.snapshots.map((snapshot) => snapshot.invalid ? `${snapshot.path} (invalid: ${snapshot.error ?? "unknown error"})` : snapshot.path)
|
|
801
|
+
]);
|
|
802
|
+
}
|
|
803
|
+
throw new AgentLoopError("unknown_command", `Unknown local command: ${subcommand}`);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function localHelpResult(json: boolean, subcommand?: string, nested?: string): CliResult {
|
|
807
|
+
const usages: Record<string, string> = {
|
|
808
|
+
install: "agent-loop local install --repo /path/to/repo [--allow-dirty] [--json]",
|
|
809
|
+
rollback: "agent-loop local rollback --snapshot /path/to/snapshot [--json]",
|
|
810
|
+
doctor: "agent-loop local doctor [--repo /path/to/repo] [--json]",
|
|
811
|
+
snapshots: "agent-loop local snapshots [--json]",
|
|
812
|
+
"snapshots prune": "agent-loop local snapshots prune --keep N [--apply] [--json]"
|
|
813
|
+
};
|
|
814
|
+
const usageKey = subcommand === "snapshots" && nested === "prune" ? "snapshots prune" : subcommand;
|
|
815
|
+
const usage = usageKey && usages[usageKey] ? usages[usageKey] : commandHelpUsage("local") ?? "agent-loop local <command>";
|
|
816
|
+
return ok(json, {
|
|
817
|
+
ok: true,
|
|
818
|
+
usage,
|
|
819
|
+
commands: ["install", "rollback", "doctor", "snapshots", "snapshots prune"]
|
|
820
|
+
}, [
|
|
821
|
+
`Usage: ${usage}`,
|
|
822
|
+
"Commands: install, rollback, doctor, snapshots, snapshots prune"
|
|
823
|
+
]);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function installRouterHooks(packageRoot: string): { hooksPath: string; removedLegacyCommands: string[] } {
|
|
827
|
+
const codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex");
|
|
828
|
+
const hooksPath = join(codexHome, "hooks.json");
|
|
829
|
+
const existing = readJsonObjectIfExists(hooksPath);
|
|
830
|
+
const { next, removedLegacyCommands } = mergeRouterHooks(existing, agentLoopRouterHookEntries(packageRoot));
|
|
831
|
+
mkdirSync(dirname(hooksPath), { recursive: true });
|
|
832
|
+
writeFileSync(hooksPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
833
|
+
return { hooksPath, removedLegacyCommands };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function buildHookDist(pluginRootPath: string): void {
|
|
837
|
+
const hookSource = join(hookSourceRoot(pluginRootPath), "pre-tool-use.ts");
|
|
838
|
+
const distScripts = agentLoopHookDistScripts(pluginRootPath);
|
|
839
|
+
const distReady = distScripts.every((script) => existsSync(script));
|
|
840
|
+
if (distReady && !existsSync(join(pluginRootPath, "pnpm-lock.yaml"))) {
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (!existsSync(hookSource)) {
|
|
844
|
+
if (distReady) {
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
throw new AgentLoopError("required_tool_unavailable", "agent-loop hook sources are missing in this repository.", {
|
|
848
|
+
details: { hookSource },
|
|
849
|
+
exitCode: 2
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
try {
|
|
853
|
+
execFileSync("pnpm", ["build:hooks"], {
|
|
854
|
+
cwd: pluginRootPath,
|
|
855
|
+
stdio: "pipe",
|
|
856
|
+
encoding: "utf8"
|
|
857
|
+
});
|
|
858
|
+
} catch (error) {
|
|
859
|
+
throw new AgentLoopError("required_tool_unavailable", "Failed to build agent-loop hook runners before installing hooks.", {
|
|
860
|
+
details: {
|
|
861
|
+
cause: error instanceof Error ? error.message : String(error)
|
|
862
|
+
},
|
|
863
|
+
exitCode: 2
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function agentLoopHookDistScripts(pluginRootPath: string): string[] {
|
|
869
|
+
const distRoot = join(hookSourceRoot(pluginRootPath), "dist");
|
|
870
|
+
return Object.values(agentLoopRouterHookEntries(pluginRootPath))
|
|
871
|
+
.flatMap(collectHookCommands)
|
|
872
|
+
.map((command) => command.match(/node '([^']+)'/)?.[1])
|
|
873
|
+
.filter((script): script is string => typeof script === "string" && script.startsWith(distRoot));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function approveGate(repoRoot: string, args: string[], json: boolean, localeOverride: LocaleSetting | undefined): CliResult {
|
|
877
|
+
const locale = localeForRepo(repoRoot, localeOverride);
|
|
878
|
+
const gateId = args[1];
|
|
879
|
+
const note = optionArg(args, "--note");
|
|
880
|
+
const nextState = optionArg(args, "--next-state");
|
|
881
|
+
if (!gateId) {
|
|
882
|
+
throw new AgentLoopError("unknown_command", "Usage: agent-loop approve-gate <gate-id> --note \"...\" [--next-state STATE]");
|
|
883
|
+
}
|
|
884
|
+
if (!note || note.trim().length === 0) {
|
|
885
|
+
throw new AgentLoopError("invalid_config", "approve-gate requires --note.");
|
|
886
|
+
}
|
|
887
|
+
const storage = new SqliteAgentLoopStorage(statePath(repoRoot));
|
|
888
|
+
try {
|
|
889
|
+
const gate = storage.decideGate(gateId, "approved", note);
|
|
890
|
+
const runId = gate.runId ?? storage.getCurrentRun()?.id;
|
|
891
|
+
if (runId) {
|
|
892
|
+
storage.appendDecision({
|
|
893
|
+
runId,
|
|
894
|
+
kind: "gate_approved",
|
|
895
|
+
message: `Approved gate ${gate.id}.`,
|
|
896
|
+
details: {
|
|
897
|
+
gateId: gate.id,
|
|
898
|
+
gateKind: gate.kind,
|
|
899
|
+
state: gateState(gate.details),
|
|
900
|
+
note,
|
|
901
|
+
source: "cli",
|
|
902
|
+
payload: nextState ? { nextState } : {},
|
|
903
|
+
gateDetails: gate.details
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
return ok(json, { ok: true, gate }, [
|
|
908
|
+
`${cliText(locale, "approvedGate")}: ${gate.id}`,
|
|
909
|
+
`${cliText(locale, "note")}: ${note}`
|
|
910
|
+
]);
|
|
911
|
+
} finally {
|
|
912
|
+
storage.close();
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function gateState(details: unknown): string | undefined {
|
|
917
|
+
if (typeof details !== "object" || details === null || Array.isArray(details)) return undefined;
|
|
918
|
+
const state = (details as { state?: unknown }).state;
|
|
919
|
+
return typeof state === "string" ? state : undefined;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
async function dashboard(repoRoot: string, args: string[], json: boolean, localeOverride: LocaleSetting | undefined): Promise<CliResult> {
|
|
923
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
924
|
+
const locale = helpLocale(localeOverride);
|
|
925
|
+
return ok(json, {
|
|
926
|
+
ok: true,
|
|
927
|
+
usage: "agent-loop dashboard [--host 127.0.0.1] [--port 0] [--json]"
|
|
928
|
+
}, [
|
|
929
|
+
"Usage: agent-loop dashboard [--host 127.0.0.1] [--port 0]",
|
|
930
|
+
cliText(locale, "dashboardHelp")
|
|
931
|
+
]);
|
|
932
|
+
}
|
|
933
|
+
const repoLocale = localeForRepo(repoRoot, localeOverride);
|
|
934
|
+
const host = optionArg(args, "--host");
|
|
935
|
+
const portValue = optionArg(args, "--port");
|
|
936
|
+
let parsedPort: number | undefined;
|
|
937
|
+
if (portValue !== undefined) {
|
|
938
|
+
const candidate = Number(portValue);
|
|
939
|
+
if (!Number.isInteger(candidate) || candidate < 0 || candidate > 65_535) {
|
|
940
|
+
throw new AgentLoopError("invalid_config", "dashboard --port must be an integer from 0 to 65535.");
|
|
941
|
+
}
|
|
942
|
+
parsedPort = candidate;
|
|
943
|
+
}
|
|
944
|
+
const server = await startDashboardServer({
|
|
945
|
+
repoRoot,
|
|
946
|
+
targetRepoRoot: repoRoot,
|
|
947
|
+
pluginRoot: defaultPackageRoot(),
|
|
948
|
+
...(host ? { host } : {}),
|
|
949
|
+
...(parsedPort !== undefined ? { port: parsedPort } : {})
|
|
950
|
+
});
|
|
951
|
+
const stdout = json
|
|
952
|
+
? `${JSON.stringify({ ok: true, url: server.url, host: server.host, port: server.port, loopbackOnly: true, targetRepoRoot: repoRoot }, null, 2)}\n`
|
|
953
|
+
: `${[cliText(repoLocale, "dashboardStarted"), `${cliText(repoLocale, "url")}: ${server.url}`, `targetRepoRoot: ${repoRoot}`].join("\n")}\n`;
|
|
954
|
+
return {
|
|
955
|
+
exitCode: 0,
|
|
956
|
+
stdout,
|
|
957
|
+
stderr: `dashboard token: ${server.token}\n# do not log or redirect this token\n`
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function inferConfig(repoRoot: string, remote: string): AgentLoopConfig {
|
|
962
|
+
const scripts = readPackageScripts(repoRoot);
|
|
963
|
+
const runner = detectPackageRunner(repoRoot);
|
|
964
|
+
const input: Parameters<typeof withConfigDefaults>[0] = {
|
|
965
|
+
repoId: parseGitHubRepoId(remote)
|
|
966
|
+
};
|
|
967
|
+
if (scripts.has("lint")) {
|
|
968
|
+
input.lintCommand = `${runner} lint`;
|
|
969
|
+
}
|
|
970
|
+
if (scripts.has("test")) {
|
|
971
|
+
input.testCommand = `${runner} test`;
|
|
972
|
+
}
|
|
973
|
+
return withConfigDefaults(input);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function readPackageScripts(repoRoot: string): Set<string> {
|
|
977
|
+
const packagePath = join(repoRoot, "package.json");
|
|
978
|
+
if (!existsSync(packagePath)) {
|
|
979
|
+
return new Set();
|
|
980
|
+
}
|
|
981
|
+
const parsed = JSON.parse(readFileSync(packagePath, "utf8")) as { scripts?: Record<string, string> };
|
|
982
|
+
return new Set(Object.keys(parsed.scripts ?? {}));
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function ensureAgentLoopGitignore(repoRoot: string): void {
|
|
986
|
+
const path = join(repoRoot, ".gitignore");
|
|
987
|
+
const entry = ".agent-loop/";
|
|
988
|
+
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
989
|
+
const lines = existing.split(/\r?\n/).map((line) => line.trim());
|
|
990
|
+
if (lines.includes(entry)) {
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
994
|
+
appendFileSync(path, `${prefix}${entry}\n`);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function parseGitHubRepoId(remote: string): string {
|
|
998
|
+
const normalized = remote.trim().replace(/\.git$/, "");
|
|
999
|
+
const sshMatch = /github\.com[:/]([^/]+\/[^/]+)$/.exec(normalized);
|
|
1000
|
+
if (sshMatch?.[1]) {
|
|
1001
|
+
return sshMatch[1];
|
|
1002
|
+
}
|
|
1003
|
+
throw new AgentLoopError("unsupported_remote", "Could not parse GitHub owner/repo from origin.", {
|
|
1004
|
+
details: { remote: redactRemote(remote) },
|
|
1005
|
+
exitCode: 2
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function detectPackageRunner(repoRoot: string): "pnpm" | "npm" | "yarn" | "bun" {
|
|
1010
|
+
if (existsSync(join(repoRoot, "pnpm-lock.yaml"))) {
|
|
1011
|
+
return "pnpm";
|
|
1012
|
+
}
|
|
1013
|
+
if (existsSync(join(repoRoot, "yarn.lock"))) {
|
|
1014
|
+
return "yarn";
|
|
1015
|
+
}
|
|
1016
|
+
if (existsSync(join(repoRoot, "bun.lock")) || existsSync(join(repoRoot, "bun.lockb"))) {
|
|
1017
|
+
return "bun";
|
|
1018
|
+
}
|
|
1019
|
+
if (existsSync(join(repoRoot, "package-lock.json")) || existsSync(join(repoRoot, "npm-shrinkwrap.json"))) {
|
|
1020
|
+
return "npm";
|
|
1021
|
+
}
|
|
1022
|
+
return "pnpm";
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function readJsonObjectIfExists(path: string): Record<string, unknown> {
|
|
1026
|
+
if (!existsSync(path)) {
|
|
1027
|
+
return {};
|
|
1028
|
+
}
|
|
1029
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
1030
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function mergeHooks(existing: Record<string, unknown>, additions: Record<string, unknown[]>): Record<string, unknown> {
|
|
1034
|
+
const next: Record<string, unknown> = { ...existing };
|
|
1035
|
+
for (const [event, entries] of Object.entries(additions)) {
|
|
1036
|
+
const current = normalizeHookEntries(next[event]);
|
|
1037
|
+
const commands = new Set(current.flatMap((entry) => hookCommands(entry)));
|
|
1038
|
+
for (const entry of entries) {
|
|
1039
|
+
const duplicate = hookCommands(entry).some((command) => commands.has(command));
|
|
1040
|
+
if (!duplicate) {
|
|
1041
|
+
current.push(entry);
|
|
1042
|
+
for (const command of hookCommands(entry)) {
|
|
1043
|
+
commands.add(command);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
next[event] = current;
|
|
1048
|
+
}
|
|
1049
|
+
return next;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function mergeRouterHooks(existing: Record<string, unknown>, additions: Record<string, unknown[]>): { next: Record<string, unknown>; removedLegacyCommands: string[] } {
|
|
1053
|
+
const next: Record<string, unknown> = { ...existing };
|
|
1054
|
+
const rootHooks = typeof next.hooks === "object" && next.hooks !== null && !Array.isArray(next.hooks)
|
|
1055
|
+
? { ...(next.hooks as Record<string, unknown>) }
|
|
1056
|
+
: {};
|
|
1057
|
+
const removedLegacyCommands: string[] = [];
|
|
1058
|
+
|
|
1059
|
+
for (const event of new Set([...Object.keys(additions), ...Object.keys(rootHooks)])) {
|
|
1060
|
+
const rootCurrent = normalizeHookEntries(rootHooks[event]);
|
|
1061
|
+
const topCurrent = event === "hooks" ? [] : normalizeHookEntries(next[event]);
|
|
1062
|
+
const filteredRoot = filterManagedAgentLoopEntries(rootCurrent, removedLegacyCommands);
|
|
1063
|
+
const filteredTop = filterManagedAgentLoopEntries(topCurrent, removedLegacyCommands);
|
|
1064
|
+
|
|
1065
|
+
if (event in additions) {
|
|
1066
|
+
const current = [...filteredRoot];
|
|
1067
|
+
const commands = new Set(current.flatMap((entry) => hookCommands(entry)));
|
|
1068
|
+
for (const entry of additions[event] ?? []) {
|
|
1069
|
+
const duplicate = hookCommands(entry).some((command) => commands.has(command));
|
|
1070
|
+
if (!duplicate) {
|
|
1071
|
+
current.push(entry);
|
|
1072
|
+
for (const command of hookCommands(entry)) {
|
|
1073
|
+
commands.add(command);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
rootHooks[event] = current;
|
|
1078
|
+
} else if (filteredRoot.length > 0) {
|
|
1079
|
+
rootHooks[event] = filteredRoot;
|
|
1080
|
+
} else {
|
|
1081
|
+
delete rootHooks[event];
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (event !== "hooks") {
|
|
1085
|
+
if (filteredTop.length > 0) {
|
|
1086
|
+
next[event] = filteredTop;
|
|
1087
|
+
} else if (topCurrent.length > 0) {
|
|
1088
|
+
delete next[event];
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
next.hooks = rootHooks;
|
|
1094
|
+
return { next, removedLegacyCommands };
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function filterManagedAgentLoopEntries(entries: unknown[], removedLegacyCommands: string[]): unknown[] {
|
|
1098
|
+
return entries
|
|
1099
|
+
.map((entry) => filterHookEntry(entry, removedLegacyCommands))
|
|
1100
|
+
.filter((entry): entry is unknown => entry !== undefined);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function filterHookEntry(entry: unknown, removedLegacyCommands: string[]): unknown | undefined {
|
|
1104
|
+
if (typeof entry !== "object" || entry === null || !("hooks" in entry) || !Array.isArray((entry as { hooks?: unknown }).hooks)) {
|
|
1105
|
+
return entry;
|
|
1106
|
+
}
|
|
1107
|
+
const hooks = (entry as { hooks: unknown[] }).hooks.filter((hook) => {
|
|
1108
|
+
const command = typeof hook === "object" && hook !== null && "command" in hook ? (hook as { command?: unknown }).command : undefined;
|
|
1109
|
+
if (typeof command !== "string" || !isAgentLoopHookCommand(command)) {
|
|
1110
|
+
return true;
|
|
1111
|
+
}
|
|
1112
|
+
if (isLegacyAgentLoopHookCommand(command)) {
|
|
1113
|
+
removedLegacyCommands.push(command);
|
|
1114
|
+
}
|
|
1115
|
+
return false;
|
|
1116
|
+
});
|
|
1117
|
+
return hooks.length > 0 ? { ...entry, hooks } : undefined;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function hookInstallReport(repoRoot: string, packageRoot: string): {
|
|
1121
|
+
hooksPath: string;
|
|
1122
|
+
registryPath: string;
|
|
1123
|
+
routerInstalled: boolean;
|
|
1124
|
+
missingRouterEvents: string[];
|
|
1125
|
+
legacyCommands: string[];
|
|
1126
|
+
activeBindings: number;
|
|
1127
|
+
currentRepoBindings: number;
|
|
1128
|
+
lock: ReturnType<typeof inspectHookRegistryLock>;
|
|
1129
|
+
hooksJsonError?: string;
|
|
1130
|
+
registryError?: string;
|
|
1131
|
+
hookCapture: ReturnType<typeof inspectHookCapture>;
|
|
1132
|
+
} {
|
|
1133
|
+
const codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex");
|
|
1134
|
+
const hooksPath = join(codexHome, "hooks.json");
|
|
1135
|
+
let existing: Record<string, unknown>;
|
|
1136
|
+
let hooksJsonError: string | undefined;
|
|
1137
|
+
try {
|
|
1138
|
+
existing = readJsonObjectIfExists(hooksPath);
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
existing = {};
|
|
1141
|
+
hooksJsonError = error instanceof Error ? error.message : String(error);
|
|
1142
|
+
}
|
|
1143
|
+
const commands = collectHookCommands(existing);
|
|
1144
|
+
const routerEntries = agentLoopRouterHookEntries(packageRoot);
|
|
1145
|
+
const missingRouterEvents = Object.entries(routerEntries)
|
|
1146
|
+
.filter(([, entries]) => !entries.some((entry) => hookCommands(entry).every((command) => commands.includes(command))))
|
|
1147
|
+
.map(([event]) => event);
|
|
1148
|
+
const legacyCommands = commands.filter(isLegacyAgentLoopHookCommand);
|
|
1149
|
+
let bindings: ReturnType<typeof listHookBindings>;
|
|
1150
|
+
let registryError: string | undefined;
|
|
1151
|
+
try {
|
|
1152
|
+
bindings = listHookBindings(codexHome);
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
bindings = [];
|
|
1155
|
+
registryError = error instanceof Error ? error.message : String(error);
|
|
1156
|
+
}
|
|
1157
|
+
const activeBindings = bindings.filter((binding) => binding.status === "active").length;
|
|
1158
|
+
const currentRepoBindings = bindings.filter((binding) => binding.status === "active" && binding.repoRoot === repoRoot).length;
|
|
1159
|
+
const lock = inspectHookRegistryLock(codexHome);
|
|
1160
|
+
const hookCapture = inspectHookCapture(repoRoot, codexHome);
|
|
1161
|
+
return {
|
|
1162
|
+
hooksPath,
|
|
1163
|
+
registryPath: hookRegistryPath(codexHome),
|
|
1164
|
+
routerInstalled: hooksJsonError === undefined && missingRouterEvents.length === 0,
|
|
1165
|
+
missingRouterEvents,
|
|
1166
|
+
legacyCommands,
|
|
1167
|
+
activeBindings,
|
|
1168
|
+
currentRepoBindings,
|
|
1169
|
+
lock,
|
|
1170
|
+
hookCapture,
|
|
1171
|
+
...(hooksJsonError ? { hooksJsonError } : {}),
|
|
1172
|
+
...(registryError ? { registryError } : {})
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function normalizeHookEntries(value: unknown): unknown[] {
|
|
1177
|
+
if (Array.isArray(value)) {
|
|
1178
|
+
return [...value];
|
|
1179
|
+
}
|
|
1180
|
+
if (typeof value === "object" && value !== null) {
|
|
1181
|
+
return [value];
|
|
1182
|
+
}
|
|
1183
|
+
return [];
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function hookCommands(entry: unknown): string[] {
|
|
1187
|
+
if (typeof entry !== "object" || entry === null || !("hooks" in entry) || !Array.isArray((entry as { hooks?: unknown }).hooks)) {
|
|
1188
|
+
return [];
|
|
1189
|
+
}
|
|
1190
|
+
return ((entry as { hooks: unknown[] }).hooks)
|
|
1191
|
+
.map((hook) => typeof hook === "object" && hook !== null && "command" in hook ? (hook as { command?: unknown }).command : undefined)
|
|
1192
|
+
.filter((command): command is string => typeof command === "string");
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function stripRepoArgs(args: string[], cwd: string): { filtered: string[]; targetPath: string } {
|
|
1196
|
+
const filtered: string[] = [];
|
|
1197
|
+
let repoArg: string | undefined;
|
|
1198
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1199
|
+
const arg = args[index]!;
|
|
1200
|
+
if (arg === "--repo") {
|
|
1201
|
+
const value = args[index + 1];
|
|
1202
|
+
if (!value || value.startsWith("--")) {
|
|
1203
|
+
throw new AgentLoopError("invalid_config", "--repo requires a path.");
|
|
1204
|
+
}
|
|
1205
|
+
if (repoArg !== undefined) {
|
|
1206
|
+
throw new AgentLoopError("invalid_config", "--repo may only be provided once.");
|
|
1207
|
+
}
|
|
1208
|
+
repoArg = value;
|
|
1209
|
+
index += 1;
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
if (arg.startsWith("--repo=")) {
|
|
1213
|
+
const value = arg.slice("--repo=".length);
|
|
1214
|
+
if (!value) {
|
|
1215
|
+
throw new AgentLoopError("invalid_config", "--repo requires a path.");
|
|
1216
|
+
}
|
|
1217
|
+
if (repoArg !== undefined) {
|
|
1218
|
+
throw new AgentLoopError("invalid_config", "--repo may only be provided once.");
|
|
1219
|
+
}
|
|
1220
|
+
repoArg = value;
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
filtered.push(arg);
|
|
1224
|
+
}
|
|
1225
|
+
return {
|
|
1226
|
+
filtered,
|
|
1227
|
+
targetPath: repoArg ? resolve(cwd, repoArg) : cwd
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function optionArg(args: string[], name: string): string | undefined {
|
|
1232
|
+
const index = args.indexOf(name);
|
|
1233
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function parsePositiveIntOption(args: string[], name: string, message: string): number {
|
|
1237
|
+
const value = optionArg(args, name);
|
|
1238
|
+
const parsed = value === undefined ? Number.NaN : Number(value);
|
|
1239
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
1240
|
+
throw new AgentLoopError("invalid_config", message);
|
|
1241
|
+
}
|
|
1242
|
+
return parsed;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function optionArgs(args: string[], name: string): string[] {
|
|
1246
|
+
const values: string[] = [];
|
|
1247
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1248
|
+
if (args[index] === name && args[index + 1] && !args[index + 1]!.startsWith("--")) {
|
|
1249
|
+
values.push(args[index + 1]!);
|
|
1250
|
+
index += 1;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
return values;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function optionalCliValue<K extends string>(args: string[], option: string, key: K): Partial<Record<K, string>> {
|
|
1257
|
+
const value = optionArg(args, option);
|
|
1258
|
+
return value === undefined ? {} : { [key]: value } as Partial<Record<K, string>>;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function numberOption(args: string[], name: string, message: string): { limit?: number } {
|
|
1262
|
+
const value = optionArg(args, name);
|
|
1263
|
+
if (value === undefined) {
|
|
1264
|
+
return {};
|
|
1265
|
+
}
|
|
1266
|
+
const parsed = Number(value);
|
|
1267
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
1268
|
+
throw new AgentLoopError("invalid_config", message);
|
|
1269
|
+
}
|
|
1270
|
+
return { limit: parsed };
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function stringOption(args: string[], name: string, key: "cursor" | "runId" | "workerId"): Record<string, string> {
|
|
1274
|
+
const value = optionArg(args, name);
|
|
1275
|
+
return value ? { [key]: value } : {};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function parseTimelineSource(value: string): AgentTimelineSource {
|
|
1279
|
+
const sources = new Set(["event", "worker_event", "worker", "state", "gate", "artifact", "decision"]);
|
|
1280
|
+
if (!sources.has(value)) {
|
|
1281
|
+
throw new AgentLoopError("invalid_config", "timeline --source must be event, worker_event, worker, state, gate, artifact, or decision.");
|
|
1282
|
+
}
|
|
1283
|
+
return value as AgentTimelineSource;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function parseAuditFormat(value: string): "markdown" | "json" {
|
|
1287
|
+
if (value === "markdown" || value === "json") {
|
|
1288
|
+
return value;
|
|
1289
|
+
}
|
|
1290
|
+
throw new AgentLoopError("invalid_config", "audit-export --format must be markdown or json.");
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function controllerResult(json: boolean, result: McpResult, lines: (data: Record<string, unknown>) => Array<string | undefined>): CliResult {
|
|
1294
|
+
if (!result.ok || !result.data) {
|
|
1295
|
+
const error = result.error ?? toErrorPayload(new AgentLoopError("storage_error", "Controller command failed."));
|
|
1296
|
+
return {
|
|
1297
|
+
exitCode: isGateCode(error.code as AgentLoopErrorCode) ? 2 : 1,
|
|
1298
|
+
stdout: json ? `${JSON.stringify({ ok: false, error }, null, 2)}\n` : "",
|
|
1299
|
+
stderr: json ? "" : `${error.code}: ${error.message}\n`
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
const payload = { ok: true, ...(result.data as Record<string, unknown>) };
|
|
1303
|
+
return {
|
|
1304
|
+
exitCode: 0,
|
|
1305
|
+
stdout: json ? `${JSON.stringify(payload, null, 2)}\n` : `${lines(result.data as Record<string, unknown>).filter(Boolean).join("\n")}\n`,
|
|
1306
|
+
stderr: ""
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function getCurrentBranch(repoRoot: string): string {
|
|
1311
|
+
try {
|
|
1312
|
+
const branch = git(repoRoot, ["branch", "--show-current"]);
|
|
1313
|
+
if (branch) {
|
|
1314
|
+
return branch;
|
|
1315
|
+
}
|
|
1316
|
+
} catch {
|
|
1317
|
+
// Fall through to symbolic-ref for unborn repositories.
|
|
1318
|
+
}
|
|
1319
|
+
return git(repoRoot, ["symbolic-ref", "--short", "HEAD"]);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function git(cwd: string, args: string[]): string {
|
|
1323
|
+
return execFileSync("git", args, {
|
|
1324
|
+
cwd,
|
|
1325
|
+
encoding: "utf8",
|
|
1326
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1327
|
+
}).trim();
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function ok(json: boolean, payload: unknown, lines: Array<string | undefined>): CliResult {
|
|
1331
|
+
return {
|
|
1332
|
+
exitCode: 0,
|
|
1333
|
+
stdout: json ? `${JSON.stringify(payload, null, 2)}\n` : `${lines.filter(Boolean).join("\n")}\n`,
|
|
1334
|
+
stderr: ""
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function formatSafeErrorDetails(details: unknown): string {
|
|
1339
|
+
if (typeof details !== "object" || details === null || Array.isArray(details)) {
|
|
1340
|
+
return "";
|
|
1341
|
+
}
|
|
1342
|
+
const record = details as Record<string, unknown>;
|
|
1343
|
+
const lines: string[] = [];
|
|
1344
|
+
if (typeof record.snapshotPath === "string") {
|
|
1345
|
+
lines.push(`snapshot: ${record.snapshotPath}`);
|
|
1346
|
+
}
|
|
1347
|
+
if (typeof record.rollbackCommand === "string") {
|
|
1348
|
+
lines.push(`rollback: ${record.rollbackCommand}`);
|
|
1349
|
+
}
|
|
1350
|
+
if (Array.isArray(record.manifestChanges) && record.manifestChanges.every((value) => typeof value === "string")) {
|
|
1351
|
+
lines.push(`manifest changes: ${record.manifestChanges.join(", ")}`);
|
|
1352
|
+
}
|
|
1353
|
+
if (Array.isArray(record.preservedBrokenFiles) && record.preservedBrokenFiles.every((value) => typeof value === "string")) {
|
|
1354
|
+
lines.push(`preserved broken files: ${record.preservedBrokenFiles.join(", ")}`);
|
|
1355
|
+
}
|
|
1356
|
+
return lines.length > 0 ? `${lines.join("\n")}\n` : "";
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function stateResult(json: boolean, result: StateMachineResult, locale: EffectiveLocale, options: { jsonStoppedOk?: boolean } = {}): CliResult {
|
|
1360
|
+
const exitCode: 0 | 1 | 2 = result.status === "BLOCKED" || result.status === "STOPPED" ? 2 : 0;
|
|
1361
|
+
if (json) {
|
|
1362
|
+
const jsonExitCode = result.gate || (result.status === "STOPPED" && !options.jsonStoppedOk) ? 2 : 0;
|
|
1363
|
+
return { exitCode: jsonExitCode, stdout: `${JSON.stringify(result, null, 2)}\n`, stderr: "" };
|
|
1364
|
+
}
|
|
1365
|
+
return {
|
|
1366
|
+
exitCode,
|
|
1367
|
+
stdout: `${[
|
|
1368
|
+
`${cliText(locale, "status")}: ${result.status}`,
|
|
1369
|
+
result.currentState ? `${cliText(locale, "state")}: ${result.currentState}` : undefined,
|
|
1370
|
+
result.runId ? `${cliText(locale, "runId")}: ${result.runId}` : undefined,
|
|
1371
|
+
...result.transitions.map((transition) => `${transition.from} -> ${transition.to}`),
|
|
1372
|
+
result.gate ? `${cliText(locale, "gate")}: ${result.gate.kind} - ${result.gate.message}` : undefined
|
|
1373
|
+
]
|
|
1374
|
+
.filter(Boolean)
|
|
1375
|
+
.join("\n")}\n`,
|
|
1376
|
+
stderr: ""
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function formatDoctor(report: DoctorReport, locale: EffectiveLocale): string {
|
|
1381
|
+
const lines = [`${cliText(locale, "doctor")}: ${report.status}`];
|
|
1382
|
+
if (report.gate) {
|
|
1383
|
+
lines.push(`${cliText(locale, "gate")}: ${report.gate}`);
|
|
1384
|
+
}
|
|
1385
|
+
for (const check of report.checks) {
|
|
1386
|
+
lines.push(`[${check.status}] ${check.name}: ${check.message}`);
|
|
1387
|
+
}
|
|
1388
|
+
return `${lines.join("\n")}\n`;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function parseCliLocale(args: string[]): LocaleSetting | undefined {
|
|
1392
|
+
const hasLocale = args.includes("--locale");
|
|
1393
|
+
const locale = parseLocaleOverride(args);
|
|
1394
|
+
if (hasLocale && locale === undefined) {
|
|
1395
|
+
throw new AgentLoopError("invalid_config", "--locale must be zh-CN, en-US, or system.");
|
|
1396
|
+
}
|
|
1397
|
+
return locale;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function localeForRepo(repoRoot: string, override: LocaleSetting | undefined): EffectiveLocale {
|
|
1401
|
+
try {
|
|
1402
|
+
return resolveCliLocale(override, loadConfig(repoRoot).config.locale);
|
|
1403
|
+
} catch (error) {
|
|
1404
|
+
if (error instanceof AgentLoopError && error.code === "needs_repo_init") {
|
|
1405
|
+
return resolveCliLocale(override, undefined);
|
|
1406
|
+
}
|
|
1407
|
+
throw error;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function helpLocale(override: LocaleSetting | undefined): EffectiveLocale {
|
|
1412
|
+
return resolveCliLocale(override, undefined);
|
|
1413
|
+
}
|