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,213 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import type { AgentLoopStorage } from "./types.js";
|
|
3
|
+
import { AgentLoopError } from "./errors.js";
|
|
4
|
+
import { redactRemote } from "./command.js";
|
|
5
|
+
|
|
6
|
+
/** Result of a git side-effect that may safely no-op on resume. */
|
|
7
|
+
export interface GitLifecycleResult {
|
|
8
|
+
skipped: boolean;
|
|
9
|
+
message: string;
|
|
10
|
+
branch?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Return the current checked-out branch name. */
|
|
14
|
+
export function getCurrentBranch(repoRoot: string): string {
|
|
15
|
+
return git(repoRoot, ["branch", "--show-current"]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Return true when the worktree has no staged or unstaged changes. */
|
|
19
|
+
export function isWorktreeClean(repoRoot: string): boolean {
|
|
20
|
+
return git(repoRoot, ["status", "--short"]).length === 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Return the configured origin remote URL. */
|
|
24
|
+
export function getOriginRemote(repoRoot: string): string {
|
|
25
|
+
return git(repoRoot, ["remote", "get-url", "origin"]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Ensure origin points at GitHub, or raise the unsupported remote gate. */
|
|
29
|
+
export function assertGitHubRemote(repoRoot: string): void {
|
|
30
|
+
const remote = getOriginRemote(repoRoot);
|
|
31
|
+
if (!remote.includes("github.com")) {
|
|
32
|
+
throw new AgentLoopError("unsupported_remote", "origin remote is not a GitHub remote.", {
|
|
33
|
+
details: { remote: redactRemote(remote) },
|
|
34
|
+
exitCode: 2
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Synchronize the base branch using only checkout plus ff-only pull. */
|
|
40
|
+
export function syncBaseBranch(repoRoot: string, baseBranch: string): GitLifecycleResult {
|
|
41
|
+
assertGitHubRemote(repoRoot);
|
|
42
|
+
if (!isWorktreeClean(repoRoot)) {
|
|
43
|
+
throw new AgentLoopError("dirty_unowned_worktree", "Worktree must be clean before syncing base branch.", {
|
|
44
|
+
details: { baseBranch },
|
|
45
|
+
exitCode: 2
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
git(repoRoot, ["checkout", baseBranch]);
|
|
49
|
+
git(repoRoot, ["pull", "--ff-only", "origin", baseBranch]);
|
|
50
|
+
return { skipped: false, message: `Synced ${baseBranch}.`, branch: baseBranch };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Create or restore a lifecycle branch without silently overwriting unrelated branches. */
|
|
54
|
+
export function createBranch(
|
|
55
|
+
repoRoot: string,
|
|
56
|
+
branchName: string,
|
|
57
|
+
options: { storage?: AgentLoopStorage; runId?: string } = {}
|
|
58
|
+
): GitLifecycleResult {
|
|
59
|
+
const linked = options.runId && options.storage ? options.storage.getPrLink(options.runId) : undefined;
|
|
60
|
+
if (branchExists(repoRoot, branchName)) {
|
|
61
|
+
if (linked?.branch === branchName) {
|
|
62
|
+
git(repoRoot, ["checkout", branchName]);
|
|
63
|
+
recordDecision(options, "branch_reused", `Reused branch ${branchName}.`, { branchName });
|
|
64
|
+
return { skipped: true, message: `Reused current run branch ${branchName}.`, branch: branchName };
|
|
65
|
+
}
|
|
66
|
+
const suffixed = nextAvailableBranch(repoRoot, branchName);
|
|
67
|
+
git(repoRoot, ["checkout", "-b", suffixed]);
|
|
68
|
+
recordDecision(options, "branch_renamed", `Created suffixed branch ${suffixed}.`, {
|
|
69
|
+
requested: branchName,
|
|
70
|
+
actual: suffixed
|
|
71
|
+
});
|
|
72
|
+
return { skipped: false, message: `Created ${suffixed}.`, branch: suffixed };
|
|
73
|
+
}
|
|
74
|
+
git(repoRoot, ["checkout", "-b", branchName]);
|
|
75
|
+
return { skipped: false, message: `Created ${branchName}.`, branch: branchName };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Stage a constrained list of paths. */
|
|
79
|
+
export function stagePaths(repoRoot: string, paths: string[]): GitLifecycleResult {
|
|
80
|
+
if (paths.length === 0) {
|
|
81
|
+
return { skipped: true, message: "No paths to stage." };
|
|
82
|
+
}
|
|
83
|
+
git(repoRoot, ["add", "--", ...paths]);
|
|
84
|
+
return { skipped: false, message: `Staged ${paths.length} paths.` };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Commit staged changes when there is an actual staged diff. */
|
|
88
|
+
export function commit(repoRoot: string, message: string): GitLifecycleResult {
|
|
89
|
+
if (!hasStagedDiff(repoRoot)) {
|
|
90
|
+
return { skipped: true, message: "No staged diff to commit." };
|
|
91
|
+
}
|
|
92
|
+
git(repoRoot, ["commit", "-m", message]);
|
|
93
|
+
return { skipped: false, message };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Push a branch unless the local and remote refs already match. */
|
|
97
|
+
export function pushBranch(repoRoot: string, branchName: string): GitLifecycleResult {
|
|
98
|
+
const local = git(repoRoot, ["rev-parse", branchName]);
|
|
99
|
+
const remote = tryGit(repoRoot, ["rev-parse", `origin/${branchName}`]);
|
|
100
|
+
if (remote && remote === local) {
|
|
101
|
+
return { skipped: true, message: `origin/${branchName} already matches local.`, branch: branchName };
|
|
102
|
+
}
|
|
103
|
+
git(repoRoot, ["push", "-u", "origin", branchName]);
|
|
104
|
+
return { skipped: false, message: `Pushed ${branchName}.`, branch: branchName };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Return changed files relative to an optional base ref. */
|
|
108
|
+
export function getChangedFiles(repoRoot: string, baseRef?: string): string[] {
|
|
109
|
+
if (baseRef) {
|
|
110
|
+
return git(repoRoot, ["diff", "--name-only", baseRef]).split("\n").filter(Boolean);
|
|
111
|
+
}
|
|
112
|
+
return git(repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"])
|
|
113
|
+
.split("\n")
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.map((line) => parsePorcelainPath(line))
|
|
116
|
+
.filter((path, index, paths) => path.length > 0 && paths.indexOf(path) === index);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Execute a safe git command without shell interpretation. */
|
|
120
|
+
export function git(repoRoot: string, args: string[]): string {
|
|
121
|
+
rejectUnsafeGit(args);
|
|
122
|
+
return execFileSync("git", args, {
|
|
123
|
+
cwd: repoRoot,
|
|
124
|
+
encoding: "utf8",
|
|
125
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
126
|
+
}).trim();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function tryGit(repoRoot: string, args: string[]): string | undefined {
|
|
130
|
+
try {
|
|
131
|
+
return git(repoRoot, args);
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function branchExists(repoRoot: string, branchName: string): boolean {
|
|
138
|
+
return (
|
|
139
|
+
Boolean(tryGit(repoRoot, ["rev-parse", "--verify", branchName])) ||
|
|
140
|
+
Boolean(tryGit(repoRoot, ["ls-remote", "--heads", "origin", branchName]))
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function nextAvailableBranch(repoRoot: string, branchName: string): string {
|
|
145
|
+
for (let index = 2; index < 100; index += 1) {
|
|
146
|
+
const candidate = `${branchName}-${index}`;
|
|
147
|
+
if (!branchExists(repoRoot, candidate)) {
|
|
148
|
+
return candidate;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
throw new AgentLoopError("policy_violation", "Could not find an available branch suffix.", {
|
|
152
|
+
details: { branchName },
|
|
153
|
+
exitCode: 2
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function hasStagedDiff(repoRoot: string): boolean {
|
|
158
|
+
try {
|
|
159
|
+
git(repoRoot, ["diff", "--cached", "--quiet"]);
|
|
160
|
+
return false;
|
|
161
|
+
} catch {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parsePorcelainPath(line: string): string {
|
|
167
|
+
const renamed = line.slice(3).split(" -> ");
|
|
168
|
+
return (renamed.at(-1) ?? "").trim().replace(/^"|"$/g, "");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function recordDecision(
|
|
172
|
+
options: { storage?: AgentLoopStorage; runId?: string },
|
|
173
|
+
kind: string,
|
|
174
|
+
message: string,
|
|
175
|
+
details: unknown
|
|
176
|
+
): void {
|
|
177
|
+
if (options.storage && options.runId) {
|
|
178
|
+
options.storage.appendDecision({ runId: options.runId, kind, message, details });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function rejectUnsafeGit(args: string[]): void {
|
|
183
|
+
const command = stripGitGlobalOptions(args);
|
|
184
|
+
if (command[0] === "reset" && command.includes("--hard")) {
|
|
185
|
+
throw new AgentLoopError("policy_violation", "git reset --hard is not allowed.", { exitCode: 2 });
|
|
186
|
+
}
|
|
187
|
+
if (command[0] === "clean") {
|
|
188
|
+
throw new AgentLoopError("policy_violation", "git clean is not allowed.", { exitCode: 2 });
|
|
189
|
+
}
|
|
190
|
+
if (command[0] === "rebase") {
|
|
191
|
+
throw new AgentLoopError("policy_violation", "git rebase is not allowed.", { exitCode: 2 });
|
|
192
|
+
}
|
|
193
|
+
if (command[0] === "push" && command.some((arg) => arg === "-f" || arg.startsWith("--force"))) {
|
|
194
|
+
throw new AgentLoopError("policy_violation", "force push is not allowed.", { exitCode: 2 });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function stripGitGlobalOptions(args: string[]): string[] {
|
|
199
|
+
const result = [...args];
|
|
200
|
+
while (result.length > 0) {
|
|
201
|
+
const first = result[0];
|
|
202
|
+
if (first === "-C" || first === "--git-dir" || first === "--work-tree") {
|
|
203
|
+
result.splice(0, 2);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (first?.startsWith("--git-dir=") || first?.startsWith("--work-tree=")) {
|
|
207
|
+
result.shift();
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import type { AgentLoopConfig } from "./types.js";
|
|
3
|
+
import { AgentLoopError } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
export interface GitHubPullRequest {
|
|
6
|
+
number: number;
|
|
7
|
+
url: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
body?: string;
|
|
10
|
+
headRefName: string;
|
|
11
|
+
baseRefName: string;
|
|
12
|
+
state: string;
|
|
13
|
+
isDraft: boolean;
|
|
14
|
+
mergedAt?: string | null;
|
|
15
|
+
reviewDecision?: string;
|
|
16
|
+
statusCheckRollup?: unknown[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GitHubCommandOptions {
|
|
20
|
+
repoRoot: string;
|
|
21
|
+
config: AgentLoopConfig;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const REVIEW_THREADS_QUERY = `
|
|
26
|
+
query($owner: String!, $name: String!, $number: Int!) {
|
|
27
|
+
repository(owner: $owner, name: $name) {
|
|
28
|
+
pullRequest(number: $number) {
|
|
29
|
+
reviewThreads(first: 100) {
|
|
30
|
+
nodes {
|
|
31
|
+
isResolved
|
|
32
|
+
isOutdated
|
|
33
|
+
comments(first: 100) {
|
|
34
|
+
nodes {
|
|
35
|
+
id
|
|
36
|
+
url
|
|
37
|
+
body
|
|
38
|
+
path
|
|
39
|
+
line
|
|
40
|
+
diffHunk
|
|
41
|
+
author {
|
|
42
|
+
login
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}`;
|
|
51
|
+
|
|
52
|
+
/** Verify the current gh CLI session can access GitHub. */
|
|
53
|
+
export function checkGhAuth(repoRoot: string): void {
|
|
54
|
+
runGh(repoRoot, ["auth", "status"]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** List pull requests for a head branch. */
|
|
58
|
+
export async function listPullRequestsByHead(options: GitHubCommandOptions, branch: string): Promise<GitHubPullRequest[]> {
|
|
59
|
+
const stdout = await runGhJson(options, [
|
|
60
|
+
"pr",
|
|
61
|
+
"list",
|
|
62
|
+
"--head",
|
|
63
|
+
branch,
|
|
64
|
+
"--json",
|
|
65
|
+
"number,url,title,body,headRefName,baseRefName,state,isDraft,mergedAt"
|
|
66
|
+
]);
|
|
67
|
+
return parseJson(stdout, "Could not parse gh pr list output.") as GitHubPullRequest[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** List recent pull requests for the configured repository. */
|
|
71
|
+
export function listPullRequests(options: GitHubCommandOptions): GitHubPullRequest[] {
|
|
72
|
+
const stdout = runGh(options.repoRoot, [
|
|
73
|
+
"pr",
|
|
74
|
+
"list",
|
|
75
|
+
"--state",
|
|
76
|
+
"all",
|
|
77
|
+
"--limit",
|
|
78
|
+
"100",
|
|
79
|
+
"--json",
|
|
80
|
+
"number,url,title,body,headRefName,baseRefName,state,isDraft,mergedAt"
|
|
81
|
+
]);
|
|
82
|
+
return parseJson(stdout, "Could not parse gh pr list output.") as GitHubPullRequest[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** List open pull requests for the configured repository. */
|
|
86
|
+
export function listOpenPullRequests(options: GitHubCommandOptions): GitHubPullRequest[] {
|
|
87
|
+
const stdout = runGh(options.repoRoot, [
|
|
88
|
+
"pr",
|
|
89
|
+
"list",
|
|
90
|
+
"--state",
|
|
91
|
+
"open",
|
|
92
|
+
"--json",
|
|
93
|
+
"number,url,title,body,headRefName,baseRefName,state,isDraft,mergedAt"
|
|
94
|
+
]);
|
|
95
|
+
return parseJson(stdout, "Could not parse gh pr list output.") as GitHubPullRequest[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Read a pull request by number with fields needed by PR C. */
|
|
99
|
+
export async function viewPullRequest(options: GitHubCommandOptions, prNumber: number): Promise<GitHubPullRequest> {
|
|
100
|
+
const stdout = await runGhJson(options, [
|
|
101
|
+
"pr",
|
|
102
|
+
"view",
|
|
103
|
+
String(prNumber),
|
|
104
|
+
"--json",
|
|
105
|
+
"number,url,headRefName,baseRefName,state,isDraft,mergedAt,reviewDecision,statusCheckRollup"
|
|
106
|
+
]);
|
|
107
|
+
return parseJson(stdout, "Could not parse gh pr view output.") as GitHubPullRequest;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Fetch PR reviewThreads through GitHub GraphQL for comment-level state. */
|
|
111
|
+
export async function fetchReviewThreads(options: GitHubCommandOptions, prNumber: number): Promise<unknown> {
|
|
112
|
+
const [owner, name] = options.config.repoId.split("/");
|
|
113
|
+
if (!owner || !name) {
|
|
114
|
+
throw new AgentLoopError("invalid_config", "Config repoId must be owner/repo.");
|
|
115
|
+
}
|
|
116
|
+
const stdout = await runGhJson(options, [
|
|
117
|
+
"api",
|
|
118
|
+
"graphql",
|
|
119
|
+
"-f",
|
|
120
|
+
`query=${REVIEW_THREADS_QUERY}`,
|
|
121
|
+
"-F",
|
|
122
|
+
`owner=${owner}`,
|
|
123
|
+
"-F",
|
|
124
|
+
`name=${name}`,
|
|
125
|
+
"-F",
|
|
126
|
+
`number=${prNumber}`
|
|
127
|
+
]);
|
|
128
|
+
return parseJson(stdout, "Could not parse gh GraphQL output.");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Create a draft pull request and return the new URL. */
|
|
132
|
+
export function createDraftPullRequest(options: GitHubCommandOptions, input: {
|
|
133
|
+
title: string;
|
|
134
|
+
body: string;
|
|
135
|
+
head: string;
|
|
136
|
+
base: string;
|
|
137
|
+
}): string {
|
|
138
|
+
return runGh(options.repoRoot, [
|
|
139
|
+
"pr",
|
|
140
|
+
"create",
|
|
141
|
+
"--draft",
|
|
142
|
+
"--title",
|
|
143
|
+
input.title,
|
|
144
|
+
"--body",
|
|
145
|
+
input.body,
|
|
146
|
+
"--head",
|
|
147
|
+
input.head,
|
|
148
|
+
"--base",
|
|
149
|
+
input.base
|
|
150
|
+
]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Add a PR comment. */
|
|
154
|
+
export function commentOnPullRequest(repoRoot: string, prNumber: number, body: string): void {
|
|
155
|
+
runGh(repoRoot, ["pr", "comment", String(prNumber), "--body", body]);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Mark a draft PR ready for review. */
|
|
159
|
+
export function markPullRequestReady(repoRoot: string, prNumber: number): void {
|
|
160
|
+
runGh(repoRoot, ["pr", "ready", String(prNumber)]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Merge a PR only when the caller has already passed all PR C guards. */
|
|
164
|
+
export function mergePullRequest(repoRoot: string, prNumber: number): void {
|
|
165
|
+
runGh(repoRoot, ["pr", "merge", String(prNumber), "--merge"]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Run a gh read command with transient retry handling. */
|
|
169
|
+
export async function runGhJson(
|
|
170
|
+
options: GitHubCommandOptions,
|
|
171
|
+
args: string[],
|
|
172
|
+
signal = options.signal
|
|
173
|
+
): Promise<string> {
|
|
174
|
+
let lastError: unknown;
|
|
175
|
+
const attempts = options.config.githubRetryMaxAttempts;
|
|
176
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
177
|
+
try {
|
|
178
|
+
return runGh(options.repoRoot, args);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
lastError = error;
|
|
181
|
+
if (!(error instanceof AgentLoopError) || error.code !== "github_transient_failure") {
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
if (attempt < attempts) {
|
|
185
|
+
await sleep(options.config.githubRetryBaseDelayMs * 2 ** (attempt - 1), signal);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (lastError instanceof AgentLoopError) {
|
|
190
|
+
throw lastError;
|
|
191
|
+
}
|
|
192
|
+
throw new AgentLoopError("github_transient_failure", "GitHub command failed after retries.", {
|
|
193
|
+
details: { args },
|
|
194
|
+
exitCode: 2
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Run gh without shell interpretation and normalize common failure classes. */
|
|
199
|
+
export function runGh(repoRoot: string, args: string[]): string {
|
|
200
|
+
try {
|
|
201
|
+
return execFileSync("gh", args, {
|
|
202
|
+
cwd: repoRoot,
|
|
203
|
+
encoding: "utf8",
|
|
204
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
205
|
+
}).trim();
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw classifyGhError(error, args);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function classifyGhError(error: unknown, args: string[]): AgentLoopError {
|
|
212
|
+
const detail = error as { stderr?: string; stdout?: string; message?: string; status?: number };
|
|
213
|
+
const text = `${detail.stderr ?? ""}\n${detail.stdout ?? ""}\n${detail.message ?? ""}`.toLowerCase();
|
|
214
|
+
const details = { args, status: detail.status, stderr: detail.stderr };
|
|
215
|
+
if (text.includes("not logged") || text.includes("authentication") || text.includes("http 401")) {
|
|
216
|
+
return new AgentLoopError("needs_secret_or_login", "GitHub CLI authentication is required.", {
|
|
217
|
+
details,
|
|
218
|
+
exitCode: 2
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
if (isResourceLookup(args) && (text.includes("not found") || text.includes("could not resolve"))) {
|
|
222
|
+
return new AgentLoopError("github_resource_not_found", "GitHub resource was not found.", {
|
|
223
|
+
details: { ...details, classification: "not_found" }
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (
|
|
227
|
+
text.includes("rate limit") ||
|
|
228
|
+
text.includes("secondary rate") ||
|
|
229
|
+
text.includes("network") ||
|
|
230
|
+
text.includes("timed out") ||
|
|
231
|
+
text.includes("http 5")
|
|
232
|
+
) {
|
|
233
|
+
return new AgentLoopError("github_transient_failure", "GitHub transient failure.", {
|
|
234
|
+
details,
|
|
235
|
+
exitCode: 2
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return new AgentLoopError("storage_error", "GitHub CLI command failed.", { details });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function parseJson(value: string, message: string): unknown {
|
|
242
|
+
try {
|
|
243
|
+
return JSON.parse(value);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
throw new AgentLoopError("storage_error", message, {
|
|
246
|
+
details: { cause: error instanceof Error ? error.message : String(error) }
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isResourceLookup(args: string[]): boolean {
|
|
252
|
+
return args[0] === "pr" || (args[0] === "api" && args[1] === "graphql");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
256
|
+
return new Promise((resolve, reject) => {
|
|
257
|
+
const cleanup = (): void => signal?.removeEventListener("abort", abort);
|
|
258
|
+
const timer = setTimeout(() => {
|
|
259
|
+
cleanup();
|
|
260
|
+
resolve();
|
|
261
|
+
}, ms);
|
|
262
|
+
const abort = (): void => {
|
|
263
|
+
clearTimeout(timer);
|
|
264
|
+
cleanup();
|
|
265
|
+
reject(new AgentLoopError("github_transient_failure", "GitHub retry was aborted.", { exitCode: 2 }));
|
|
266
|
+
};
|
|
267
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
268
|
+
});
|
|
269
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { writeArtifact } from "./artifacts.js";
|
|
3
|
+
import { AgentLoopError } from "./errors.js";
|
|
4
|
+
import type { AgentLoopConfig, AgentLoopStorage } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export interface GitNexusResult {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
skipped: boolean;
|
|
9
|
+
stdout: string;
|
|
10
|
+
stderr: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Run `gitnexus status` as a best-effort repository health check. */
|
|
14
|
+
export function gitnexusStatus(repoRoot: string, config: AgentLoopConfig): GitNexusResult {
|
|
15
|
+
return runGitNexus(repoRoot, ["status"], config.gitnexusRequired);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Run `gitnexus analyze` as a best-effort repository index refresh. */
|
|
19
|
+
export function gitnexusAnalyze(repoRoot: string, config: AgentLoopConfig): GitNexusResult {
|
|
20
|
+
return runGitNexus(repoRoot, ["analyze"], config.gitnexusRequired);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Guard commit/push with `gitnexus detect_changes` when required by config. */
|
|
24
|
+
export function gitnexusDetectChanges(
|
|
25
|
+
repoRoot: string,
|
|
26
|
+
config: AgentLoopConfig,
|
|
27
|
+
storage: AgentLoopStorage,
|
|
28
|
+
runId: string
|
|
29
|
+
): GitNexusResult {
|
|
30
|
+
const result = runGitNexus(repoRoot, ["detect_changes"], config.gitnexusRequired);
|
|
31
|
+
if (!result.ok && config.gitnexusRequired) {
|
|
32
|
+
throw new AgentLoopError("gitnexus_check_failed", "GitNexus detect_changes did not pass.", {
|
|
33
|
+
details: { stdout: result.stdout, stderr: result.stderr },
|
|
34
|
+
exitCode: 2
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (!config.gitnexusRequired) {
|
|
38
|
+
const artifact = writeArtifact(
|
|
39
|
+
repoRoot,
|
|
40
|
+
storage,
|
|
41
|
+
runId,
|
|
42
|
+
"log",
|
|
43
|
+
"gitnexus-alternative-scope-check.txt",
|
|
44
|
+
`stdout:\n${result.stdout}\n\nstderr:\n${result.stderr}\n`
|
|
45
|
+
);
|
|
46
|
+
storage.appendDecision({
|
|
47
|
+
runId,
|
|
48
|
+
kind: "gitnexus_not_required",
|
|
49
|
+
message: "GitNexus detect_changes was not required; stored alternative scope evidence.",
|
|
50
|
+
details: { artifactId: artifact.id, ok: result.ok }
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Placeholder impact query for PR C callers and later PRs. */
|
|
57
|
+
export function gitnexusImpact(repoRoot: string, config: AgentLoopConfig): GitNexusResult {
|
|
58
|
+
return runGitNexus(repoRoot, ["impact"], config.gitnexusRequired);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function runGitNexus(repoRoot: string, args: string[], required: boolean): GitNexusResult {
|
|
62
|
+
try {
|
|
63
|
+
const stdout = execFileSync("npx", ["gitnexus", ...args], {
|
|
64
|
+
cwd: repoRoot,
|
|
65
|
+
encoding: "utf8",
|
|
66
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
67
|
+
});
|
|
68
|
+
return { ok: true, skipped: false, stdout: stdout.trim(), stderr: "" };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const typed = error as { stderr?: string; stdout?: string; message?: string; status?: number };
|
|
71
|
+
const result = {
|
|
72
|
+
ok: false,
|
|
73
|
+
skipped: false,
|
|
74
|
+
stdout: typed.stdout ?? "",
|
|
75
|
+
stderr: typed.stderr ?? typed.message ?? ""
|
|
76
|
+
};
|
|
77
|
+
if (required && isToolUnavailable(typed)) {
|
|
78
|
+
throw new AgentLoopError("required_tool_unavailable", "GitNexus is required but unavailable.", {
|
|
79
|
+
details: { args, stderr: result.stderr, status: typed.status },
|
|
80
|
+
exitCode: 2
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isToolUnavailable(error: { stderr?: string; message?: string; status?: number }): boolean {
|
|
88
|
+
const text = `${error.stderr ?? ""}\n${error.message ?? ""}`.toLowerCase();
|
|
89
|
+
return error.status === 127 || text.includes("not found") || text.includes("could not determine executable");
|
|
90
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface HappyCapability {
|
|
4
|
+
installed: boolean;
|
|
5
|
+
versionText?: string;
|
|
6
|
+
supportsNotify: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Detect local Happy notify support without starting sessions or remote bridges. */
|
|
10
|
+
export function detectHappy(): HappyCapability {
|
|
11
|
+
const help = runHappyHelp(["--help"]);
|
|
12
|
+
if (!help.ok) {
|
|
13
|
+
return { installed: false, supportsNotify: false };
|
|
14
|
+
}
|
|
15
|
+
const notify = runHappyHelp(["notify", "--help"]);
|
|
16
|
+
const versionText = firstLine(help.output);
|
|
17
|
+
return {
|
|
18
|
+
installed: true,
|
|
19
|
+
...(versionText ? { versionText } : {}),
|
|
20
|
+
supportsNotify: notify.ok
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function runHappyHelp(args: string[]): { ok: boolean; output: string } {
|
|
25
|
+
try {
|
|
26
|
+
return {
|
|
27
|
+
ok: true,
|
|
28
|
+
output: execFileSync("happy", args, {
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
31
|
+
timeout: 2_000
|
|
32
|
+
}).trim()
|
|
33
|
+
};
|
|
34
|
+
} catch {
|
|
35
|
+
return { ok: false, output: "" };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function firstLine(value: string): string | undefined {
|
|
40
|
+
const line = value.split(/\r?\n/).find((item) => item.trim().length > 0)?.trim();
|
|
41
|
+
return line ? line.slice(0, 200) : undefined;
|
|
42
|
+
}
|