@imdeadpool/guardex 7.0.41 → 7.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.
Files changed (118) hide show
  1. package/README.md +94 -13
  2. package/package.json +3 -1
  3. package/skills/gitguardex/SKILL.md +13 -0
  4. package/skills/guardex-merge-skills-to-dev/SKILL.md +59 -0
  5. package/skills/gx-act/SKILL.md +82 -0
  6. package/src/agents/cleanup-sessions.js +126 -0
  7. package/src/agents/finish.js +172 -0
  8. package/src/agents/inspect.js +202 -0
  9. package/src/agents/launch.js +249 -0
  10. package/src/agents/registry.js +133 -0
  11. package/src/agents/selection-panel.js +571 -0
  12. package/src/agents/sessions.js +151 -0
  13. package/src/agents/start.js +591 -0
  14. package/src/agents/status.js +146 -0
  15. package/src/agents/terminal.js +152 -0
  16. package/src/budget/index.js +344 -0
  17. package/src/ci-init/index.js +265 -0
  18. package/src/cli/args.js +357 -3
  19. package/src/cli/commands/agents.js +364 -0
  20. package/src/cli/commands/bootstrap.js +92 -0
  21. package/src/cli/commands/branch.js +127 -0
  22. package/src/cli/commands/claude.js +674 -0
  23. package/src/cli/commands/doctor.js +268 -0
  24. package/src/cli/commands/finish.js +26 -0
  25. package/src/cli/commands/mcp.js +122 -0
  26. package/src/cli/commands/misc.js +304 -0
  27. package/src/cli/commands/pr.js +439 -0
  28. package/src/cli/commands/prompt.js +92 -0
  29. package/src/cli/commands/release.js +305 -0
  30. package/src/cli/commands/report.js +244 -0
  31. package/src/cli/commands/review.js +32 -0
  32. package/src/cli/commands/setup.js +242 -0
  33. package/src/cli/commands/status.js +338 -0
  34. package/src/cli/commands/watch.js +234 -0
  35. package/src/cli/main.js +85 -3613
  36. package/src/cli/shared/repo-env.js +161 -0
  37. package/src/cli/shared/sandbox.js +417 -0
  38. package/src/cli/shared/scaffolding.js +535 -0
  39. package/src/cli/shared/toolchain-shims.js +420 -0
  40. package/src/cockpit/action-runner.js +3 -0
  41. package/src/cockpit/actions.js +80 -0
  42. package/src/cockpit/control.js +1121 -0
  43. package/src/cockpit/index.js +426 -0
  44. package/src/cockpit/kitty-layout.js +549 -0
  45. package/src/cockpit/kitty-tree.js +144 -0
  46. package/src/cockpit/logs-reader.js +182 -0
  47. package/src/cockpit/menu.js +204 -0
  48. package/src/cockpit/pane-actions.js +597 -0
  49. package/src/cockpit/pane-menu.js +387 -0
  50. package/src/cockpit/projects-finder.js +178 -0
  51. package/src/cockpit/render.js +215 -0
  52. package/src/cockpit/settings-render.js +128 -0
  53. package/src/cockpit/settings.js +124 -0
  54. package/src/cockpit/shortcuts.js +24 -0
  55. package/src/cockpit/sidebar.js +311 -0
  56. package/src/cockpit/state.js +72 -0
  57. package/src/cockpit/theme.js +128 -0
  58. package/src/cockpit/welcome.js +266 -0
  59. package/src/context.js +304 -43
  60. package/src/core/runtime.js +6 -1
  61. package/src/doctor/index.js +45 -15
  62. package/src/finish/index.js +186 -7
  63. package/src/finish/preflight.js +177 -0
  64. package/src/finish/review-gate.js +182 -0
  65. package/src/git/index.js +511 -4
  66. package/src/hooks/index.js +0 -64
  67. package/src/kitty/command.js +101 -0
  68. package/src/kitty/runtime.js +250 -0
  69. package/src/mcp/collect.js +370 -0
  70. package/src/mcp/server.js +157 -0
  71. package/src/output/index.js +68 -2
  72. package/src/pr-review.js +264 -0
  73. package/src/pr.js +381 -0
  74. package/src/sandbox/index.js +13 -2
  75. package/src/scaffold/agent-worktree-prep.js +213 -0
  76. package/src/scaffold/index.js +127 -10
  77. package/src/speckit/index.js +226 -0
  78. package/src/submodule/index.js +288 -0
  79. package/src/terminal/index.js +45 -0
  80. package/src/terminal/kitty.js +622 -0
  81. package/src/terminal/tmux.js +125 -0
  82. package/src/tmux/command.js +27 -0
  83. package/src/tmux/session.js +89 -0
  84. package/src/toolchain/index.js +20 -0
  85. package/templates/AGENTS.monorepo-apps.md +26 -0
  86. package/templates/AGENTS.multiagent-safety.md +63 -323
  87. package/templates/AGENTS.multiagent-safety.min.md +11 -0
  88. package/templates/codex/skills/gitguardex/SKILL.md +2 -0
  89. package/templates/codex/skills/gx-act/SKILL.md +82 -0
  90. package/templates/githooks/pre-commit +44 -20
  91. package/templates/github/workflows/README.md +87 -0
  92. package/templates/github/workflows/ci-full.yml +55 -0
  93. package/templates/github/workflows/ci.yml +56 -0
  94. package/templates/github/workflows/cr.yml +20 -1
  95. package/templates/scripts/agent-branch-finish.sh +519 -23
  96. package/templates/scripts/agent-branch-merge.sh +4 -1
  97. package/templates/scripts/agent-branch-start.sh +176 -24
  98. package/templates/scripts/agent-preflight.sh +115 -0
  99. package/templates/scripts/agent-worktree-prune.sh +96 -5
  100. package/templates/scripts/codex-agent.sh +41 -97
  101. package/templates/scripts/openspec/init-plan-workspace.sh +43 -0
  102. package/templates/scripts/review-bot-watch.sh +31 -2
  103. package/templates/scripts/agent-session-state.js +0 -171
  104. package/templates/scripts/install-vscode-active-agents-extension.js +0 -135
  105. package/templates/vscode/guardex-active-agents/README.md +0 -34
  106. package/templates/vscode/guardex-active-agents/extension.js +0 -3782
  107. package/templates/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json +0 -54
  108. package/templates/vscode/guardex-active-agents/fileicons/icons/agent.svg +0 -5
  109. package/templates/vscode/guardex-active-agents/fileicons/icons/branch.svg +0 -7
  110. package/templates/vscode/guardex-active-agents/fileicons/icons/config.svg +0 -4
  111. package/templates/vscode/guardex-active-agents/fileicons/icons/hook.svg +0 -4
  112. package/templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg +0 -5
  113. package/templates/vscode/guardex-active-agents/fileicons/icons/plan.svg +0 -4
  114. package/templates/vscode/guardex-active-agents/fileicons/icons/spec.svg +0 -5
  115. package/templates/vscode/guardex-active-agents/icon.png +0 -0
  116. package/templates/vscode/guardex-active-agents/media/active-agents-hivemind.svg +0 -14
  117. package/templates/vscode/guardex-active-agents/package.json +0 -169
  118. package/templates/vscode/guardex-active-agents/session-schema.js +0 -1348
@@ -0,0 +1,264 @@
1
+ const {
2
+ fs,
3
+ path,
4
+ os,
5
+ GH_BIN,
6
+ } = require('./context');
7
+ const { run } = require('./core/runtime');
8
+
9
+ const TOOL_PREFIX = '[gitguardex]';
10
+ const VALID_SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
11
+
12
+ function normalizeProvider(raw) {
13
+ const provider = String(raw || 'codex').trim().toLowerCase();
14
+ if (!['codex', 'claude'].includes(provider)) {
15
+ throw new Error(`Invalid provider: ${raw}`);
16
+ }
17
+ return provider;
18
+ }
19
+
20
+ function commandForProvider(provider, prompt) {
21
+ if (provider === 'claude') {
22
+ return { cmd: 'claude', args: ['-p', prompt] };
23
+ }
24
+ return { cmd: 'codex', args: ['exec', prompt] };
25
+ }
26
+
27
+ function compactReviewPrompt(diff) {
28
+ return [
29
+ 'You are gitguardex-code-assist, a PR review runner.',
30
+ 'Review this GitHub PR diff for correctness bugs, regressions, security issues, and missing tests.',
31
+ 'Return JSON only. Shape:',
32
+ '{"findings":[{"path":"file","line":123,"severity":"low|medium|high|critical","message":"concise finding","suggestion":"optional replacement or fix"}]}',
33
+ 'Rules: path and line must point to changed lines in the diff. Use an empty findings array when nothing is worth commenting.',
34
+ '',
35
+ 'PR diff:',
36
+ diff,
37
+ ].join('\n');
38
+ }
39
+
40
+ function extractJsonPayload(text) {
41
+ const raw = String(text || '').trim();
42
+ if (!raw) return { findings: [] };
43
+ const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
44
+ const candidate = fenced ? fenced[1].trim() : raw;
45
+ try {
46
+ return JSON.parse(candidate);
47
+ } catch (_error) {
48
+ const objectStart = candidate.indexOf('{');
49
+ const objectEnd = candidate.lastIndexOf('}');
50
+ if (objectStart >= 0 && objectEnd > objectStart) {
51
+ return JSON.parse(candidate.slice(objectStart, objectEnd + 1));
52
+ }
53
+ const arrayStart = candidate.indexOf('[');
54
+ const arrayEnd = candidate.lastIndexOf(']');
55
+ if (arrayStart >= 0 && arrayEnd > arrayStart) {
56
+ return { findings: JSON.parse(candidate.slice(arrayStart, arrayEnd + 1)) };
57
+ }
58
+ throw new Error('Review provider did not return parseable JSON findings');
59
+ }
60
+ }
61
+
62
+ function normalizeFinding(rawFinding) {
63
+ const pathValue = String(rawFinding?.path || '').trim();
64
+ const line = Number.parseInt(String(rawFinding?.line || ''), 10);
65
+ const severity = String(rawFinding?.severity || 'medium').trim().toLowerCase();
66
+ const message = String(rawFinding?.message || '').trim();
67
+ const suggestion = String(rawFinding?.suggestion || '').trim();
68
+ if (!pathValue || !Number.isInteger(line) || line <= 0 || !message) {
69
+ return null;
70
+ }
71
+ return {
72
+ path: pathValue,
73
+ line,
74
+ severity: VALID_SEVERITIES.has(severity) ? severity : 'medium',
75
+ message,
76
+ suggestion,
77
+ };
78
+ }
79
+
80
+ function normalizeFindings(providerOutput) {
81
+ const payload = extractJsonPayload(providerOutput);
82
+ const rawFindings = Array.isArray(payload) ? payload : payload.findings;
83
+ if (!Array.isArray(rawFindings)) {
84
+ throw new Error('Review provider JSON must contain a findings array');
85
+ }
86
+ return rawFindings.map(normalizeFinding).filter(Boolean);
87
+ }
88
+
89
+ function findingBody(finding) {
90
+ const lines = [`**${finding.severity.toUpperCase()}** ${finding.message}`];
91
+ if (finding.suggestion) {
92
+ lines.push('', 'Suggested fix:', '```suggestion', finding.suggestion, '```');
93
+ }
94
+ return lines.join('\n');
95
+ }
96
+
97
+ function renderMarkdownReview({ pr, provider, findings }) {
98
+ const lines = [
99
+ `# GitGuardex PR Review`,
100
+ '',
101
+ `- PR: #${pr}`,
102
+ `- Provider: ${provider}`,
103
+ `- Findings: ${findings.length}`,
104
+ '',
105
+ ];
106
+ if (findings.length === 0) {
107
+ lines.push('No findings.');
108
+ } else {
109
+ for (const finding of findings) {
110
+ lines.push(`## ${finding.severity.toUpperCase()} ${finding.path}:${finding.line}`);
111
+ lines.push('');
112
+ lines.push(finding.message);
113
+ if (finding.suggestion) {
114
+ lines.push('', '```suggestion', finding.suggestion, '```');
115
+ }
116
+ lines.push('');
117
+ }
118
+ }
119
+ return `${lines.join('\n').replace(/\n{3,}/g, '\n\n')}\n`;
120
+ }
121
+
122
+ function defaultArtifactPath(repoRoot, pr) {
123
+ return path.join(repoRoot, '.gitguardex', 'pr-reviews', `pr-${pr}.md`);
124
+ }
125
+
126
+ function writeArtifact(repoRoot, artifactPath, payload) {
127
+ const outputPath = artifactPath
128
+ ? path.resolve(repoRoot, artifactPath)
129
+ : defaultArtifactPath(repoRoot, payload.pr);
130
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
131
+ fs.writeFileSync(outputPath, renderMarkdownReview(payload), 'utf8');
132
+ return outputPath;
133
+ }
134
+
135
+ function githubAuthAvailable(env = process.env, runner = run) {
136
+ if (env.GITHUB_TOKEN || env.GH_TOKEN) return true;
137
+ const result = runner(GH_BIN, ['auth', 'status'], { timeout: 15_000 });
138
+ return result.status === 0;
139
+ }
140
+
141
+ function fetchPrDiff(pr, repoRoot, runner = run) {
142
+ const result = runner(GH_BIN, ['pr', 'diff', String(pr)], { cwd: repoRoot, timeout: 120_000 });
143
+ if (result.status !== 0) {
144
+ throw new Error(`gh pr diff ${pr} failed${result.stderr ? `\n${result.stderr.trim()}` : ''}`);
145
+ }
146
+ return result.stdout || '';
147
+ }
148
+
149
+ function runProviderReview(provider, diff, repoRoot, timeoutMs, runner = run) {
150
+ const prompt = compactReviewPrompt(diff);
151
+ const command = commandForProvider(provider, prompt);
152
+ const result = runner(command.cmd, command.args, { cwd: repoRoot, timeout: timeoutMs });
153
+ if (result.status !== 0) {
154
+ throw new Error(`${provider} review failed${result.stderr ? `\n${result.stderr.trim()}` : ''}`);
155
+ }
156
+ // A compliant provider always emits the findings JSON object (empty array when
157
+ // nothing is wrong). Empty stdout means the review did NOT run — fail closed so
158
+ // a silent no-op is never mistaken for "clean" by the merge gate.
159
+ if (!(result.stdout || '').trim()) {
160
+ throw new Error(`${provider} review returned no output (review did not run)`);
161
+ }
162
+ return normalizeFindings(result.stdout || '');
163
+ }
164
+
165
+ function postGithubReview(pr, findings, repoRoot, runner = run) {
166
+ const comments = findings.map((finding) => ({
167
+ path: finding.path,
168
+ line: finding.line,
169
+ side: 'RIGHT',
170
+ body: findingBody(finding),
171
+ }));
172
+ const body = findings.length > 0
173
+ ? `GitGuardex code-assist found ${findings.length} issue(s).`
174
+ : 'GitGuardex code-assist found no issues worth inline comments.';
175
+ const payload = {
176
+ event: 'COMMENT',
177
+ body,
178
+ comments,
179
+ };
180
+ const inputPath = path.join(os.tmpdir(), `gitguardex-pr-review-${process.pid}-${Date.now()}.json`);
181
+ fs.writeFileSync(inputPath, JSON.stringify(payload), 'utf8');
182
+ try {
183
+ const result = runner(GH_BIN, [
184
+ 'api',
185
+ `repos/:owner/:repo/pulls/${pr}/reviews`,
186
+ '--method',
187
+ 'POST',
188
+ '--input',
189
+ inputPath,
190
+ ], { cwd: repoRoot, timeout: 120_000 });
191
+ if (result.status !== 0) {
192
+ throw new Error(`gh api review post failed${result.stderr ? `\n${result.stderr.trim()}` : ''}`);
193
+ }
194
+ return result;
195
+ } finally {
196
+ try {
197
+ fs.unlinkSync(inputPath);
198
+ } catch (_error) {
199
+ // best effort cleanup for temp API payload
200
+ }
201
+ }
202
+ }
203
+
204
+ function runPrReview(options, deps = {}) {
205
+ const runner = deps.run || run;
206
+ const repoRoot = path.resolve(options.target || process.cwd());
207
+ const provider = normalizeProvider(options.provider);
208
+ const pr = String(options.pr);
209
+ const diff = fetchPrDiff(pr, repoRoot, runner);
210
+ const findings = runProviderReview(provider, diff, repoRoot, options.timeoutMs, runner);
211
+ const payload = { pr, provider, findings };
212
+
213
+ if (!options.post) {
214
+ const artifactPath = writeArtifact(repoRoot, options.artifact, payload);
215
+ return { posted: false, artifactPath, findings };
216
+ }
217
+
218
+ if (!githubAuthAvailable(process.env, runner)) {
219
+ const artifactPath = writeArtifact(repoRoot, options.artifact, payload);
220
+ return { posted: false, artifactPath, findings, reason: 'github-auth-unavailable' };
221
+ }
222
+
223
+ postGithubReview(pr, findings, repoRoot, runner);
224
+ return { posted: true, artifactPath: '', findings };
225
+ }
226
+
227
+ /**
228
+ * Decide whether a set of review findings clears the merge gate. Blocks on any
229
+ * finding whose severity is in `blockSeverities` (high + critical by default;
230
+ * low/medium are advisory). Pure + synchronous so it is trivially testable.
231
+ *
232
+ * Fail-closed semantics live in the CALLER: an absent/empty `findings` here is
233
+ * treated as clean, so the caller must independently treat a review that did
234
+ * NOT run (provider threw / timed out) as a block, never as clean.
235
+ */
236
+ function evaluateReviewGate(findings, { blockSeverities = ['high', 'critical'] } = {}) {
237
+ const block = new Set(blockSeverities.map((s) => String(s).toLowerCase()));
238
+ const list = Array.isArray(findings) ? findings : [];
239
+ const blocking = list.filter((f) => f && block.has(String(f.severity).toLowerCase()));
240
+ return { clean: blocking.length === 0, blocking };
241
+ }
242
+
243
+ function printPrReviewResult(result) {
244
+ if (result.posted) {
245
+ console.log(`${TOOL_PREFIX} Posted PR review with ${result.findings.length} finding(s).`);
246
+ return;
247
+ }
248
+ if (result.reason === 'github-auth-unavailable') {
249
+ console.log(`${TOOL_PREFIX} GitHub auth unavailable; wrote PR review artifact: ${result.artifactPath}`);
250
+ return;
251
+ }
252
+ console.log(`${TOOL_PREFIX} Wrote PR review artifact: ${result.artifactPath}`);
253
+ }
254
+
255
+ module.exports = {
256
+ compactReviewPrompt,
257
+ commandForProvider,
258
+ extractJsonPayload,
259
+ normalizeFindings,
260
+ renderMarkdownReview,
261
+ runPrReview,
262
+ evaluateReviewGate,
263
+ printPrReviewResult,
264
+ };
package/src/pr.js ADDED
@@ -0,0 +1,381 @@
1
+ // PR-from-worktree helpers.
2
+ //
3
+ // Centralizes the operations that the agent + worktree flow needs to drive a
4
+ // pull request: detecting the PR for the current branch, opening one
5
+ // idempotently, pushing + syncing, enabling auto-merge, and polling CI / merge
6
+ // state. The agent-branch-finish.sh script historically owned most of this;
7
+ // these helpers expose the same primitives to the JS CLI so `gx pr ...` can be
8
+ // driven without invoking the shell flow.
9
+
10
+ const { GH_BIN } = require('./context');
11
+ const { run } = require('./core/runtime');
12
+ const {
13
+ resolveRepoRoot,
14
+ currentBranchName,
15
+ hasOriginRemote,
16
+ detectDefaultBaseBranch,
17
+ } = require('./git');
18
+
19
+ const DEFAULT_POLL_INTERVAL_MS = 5_000;
20
+ const DEFAULT_POLL_TIMEOUT_MS = 10 * 60 * 1000;
21
+
22
+ class PrError extends Error {
23
+ constructor(message, { code = 'pr-error', cause = null } = {}) {
24
+ super(message);
25
+ this.name = 'PrError';
26
+ this.code = code;
27
+ if (cause) this.cause = cause;
28
+ }
29
+ }
30
+
31
+ function ghAvailable() {
32
+ const result = run(GH_BIN, ['--version'], { allowFailure: true });
33
+ return result.status === 0;
34
+ }
35
+
36
+ function ghAuthStatus() {
37
+ const result = run(GH_BIN, ['auth', 'status'], { allowFailure: true });
38
+ return {
39
+ authenticated: result.status === 0,
40
+ output: ((result.stdout || '') + (result.stderr || '')).trim(),
41
+ };
42
+ }
43
+
44
+ function ghJson(repoRoot, args) {
45
+ const result = run(GH_BIN, args, { cwd: repoRoot, allowFailure: true });
46
+ if (result.status !== 0) {
47
+ return { ok: false, error: (result.stderr || result.stdout || '').trim(), data: null };
48
+ }
49
+ const raw = (result.stdout || '').trim();
50
+ if (!raw) return { ok: true, data: null, error: null };
51
+ try {
52
+ return { ok: true, data: JSON.parse(raw), error: null };
53
+ } catch (error) {
54
+ return { ok: false, data: null, error: `gh JSON parse: ${error.message}` };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Resolve the PR for `branch` if any exists on the origin remote.
60
+ * Returns null when no PR is open (or any are merged / closed only).
61
+ */
62
+ function findOpenPrForBranch(repoRoot, branch) {
63
+ const result = ghJson(repoRoot, [
64
+ 'pr', 'list',
65
+ '--head', branch,
66
+ '--state', 'open',
67
+ '--json', 'number,url,state,isDraft,mergeable,mergeStateStatus,reviewDecision,headRefName,baseRefName,title,statusCheckRollup',
68
+ '--limit', '5',
69
+ ]);
70
+ if (!result.ok) {
71
+ throw new PrError(`gh pr list failed: ${result.error}`, { code: 'gh-list-failed' });
72
+ }
73
+ const items = Array.isArray(result.data) ? result.data : [];
74
+ if (items.length === 0) return null;
75
+ return items[0];
76
+ }
77
+
78
+ /**
79
+ * Resolve the most recent PR for `branch` regardless of state. Useful when
80
+ * the agent has already merged a previous PR for this branch.
81
+ */
82
+ function findLatestPrForBranch(repoRoot, branch) {
83
+ const result = ghJson(repoRoot, [
84
+ 'pr', 'list',
85
+ '--head', branch,
86
+ '--state', 'all',
87
+ '--json', 'number,url,state,isDraft,mergedAt,headRefName,baseRefName,title',
88
+ '--limit', '5',
89
+ ]);
90
+ if (!result.ok) {
91
+ throw new PrError(`gh pr list failed: ${result.error}`, { code: 'gh-list-failed' });
92
+ }
93
+ const items = Array.isArray(result.data) ? result.data : [];
94
+ if (items.length === 0) return null;
95
+ return items[0];
96
+ }
97
+
98
+ /**
99
+ * List ALL open PRs for the repo on origin in a single gh call, so callers can
100
+ * correlate many branches to their PRs without one gh round-trip per branch.
101
+ * Best-effort: never throws. Returns `{ prs, error }` so callers can tell a
102
+ * genuine "no open PRs" (`error: null`) from a failed lookup (gh missing /
103
+ * unauthenticated / offline → `error` set, `prs: []`).
104
+ * NB: `limit` (default 100) bounds correlation COVERAGE, not just payload size —
105
+ * a repo with more than `limit` open PRs may leave some lanes showing pr:null.
106
+ */
107
+ function listOpenPrsForRepo(repoRoot, { limit = 100 } = {}) {
108
+ const result = ghJson(repoRoot, [
109
+ 'pr', 'list',
110
+ '--state', 'open',
111
+ '--json', 'number,url,state,isDraft,mergeable,mergeStateStatus,reviewDecision,headRefName,baseRefName,title',
112
+ '--limit', String(limit),
113
+ ]);
114
+ if (!result.ok) return { prs: [], error: result.error || 'gh pr list failed' };
115
+ if (!Array.isArray(result.data)) return { prs: [], error: null };
116
+ return { prs: result.data, error: null };
117
+ }
118
+
119
+ function pushBranch(repoRoot, branch, { setUpstream = true } = {}) {
120
+ const args = ['push'];
121
+ if (setUpstream) args.push('-u');
122
+ args.push('origin', branch);
123
+ const result = run('git', args, { cwd: repoRoot, allowFailure: true });
124
+ return {
125
+ ok: result.status === 0,
126
+ output: ((result.stdout || '') + (result.stderr || '')).trim(),
127
+ };
128
+ }
129
+
130
+ function defaultPrTitleFromCommit(repoRoot, branch) {
131
+ const result = run('git', ['log', '-1', '--pretty=%s', branch], {
132
+ cwd: repoRoot, allowFailure: true,
133
+ });
134
+ if (result.status !== 0) return branch;
135
+ const subject = (result.stdout || '').trim();
136
+ return subject || branch;
137
+ }
138
+
139
+ function defaultPrBodyFromCommits(repoRoot, branch, baseBranch) {
140
+ const result = run('git', [
141
+ 'log',
142
+ `${baseBranch}..${branch}`,
143
+ '--pretty=format:- %s',
144
+ ], { cwd: repoRoot, allowFailure: true });
145
+ const list = (result.stdout || '').trim();
146
+ return [
147
+ '## Summary',
148
+ list || '- (no commits between base and branch yet)',
149
+ '',
150
+ '## Test plan',
151
+ '- [ ] verified locally',
152
+ ].join('\n');
153
+ }
154
+
155
+ /**
156
+ * Create (or fetch existing) PR for the current worktree branch. Idempotent:
157
+ * if an open PR is already associated with the branch, returns it without
158
+ * creating a duplicate.
159
+ *
160
+ * @param {object} options
161
+ * @param {string} options.repoRoot
162
+ * @param {string} options.branch
163
+ * @param {string} [options.base]
164
+ * @param {string} [options.title]
165
+ * @param {string} [options.body]
166
+ * @param {boolean} [options.draft]
167
+ * @param {boolean} [options.push]
168
+ */
169
+ function openPullRequest(options) {
170
+ const repoRoot = options.repoRoot;
171
+ const branch = options.branch;
172
+ if (!repoRoot) throw new PrError('repoRoot required', { code: 'bad-arg' });
173
+ if (!branch) throw new PrError('branch required', { code: 'bad-arg' });
174
+
175
+ if (!ghAvailable()) {
176
+ throw new PrError('gh CLI not installed. Install from https://cli.github.com/', {
177
+ code: 'gh-missing',
178
+ });
179
+ }
180
+ const auth = ghAuthStatus();
181
+ if (!auth.authenticated) {
182
+ throw new PrError(
183
+ `GitHub CLI is not authenticated. Run 'gh auth login' or set GITHUB_TOKEN.\n${auth.output}`,
184
+ { code: 'gh-unauthenticated' },
185
+ );
186
+ }
187
+ if (!hasOriginRemote(repoRoot)) {
188
+ throw new PrError('No `origin` remote configured for this repo', {
189
+ code: 'no-origin',
190
+ });
191
+ }
192
+
193
+ // Try to find an existing open PR first.
194
+ const existing = findOpenPrForBranch(repoRoot, branch);
195
+ if (existing) {
196
+ return { created: false, pr: existing };
197
+ }
198
+
199
+ if (options.push !== false) {
200
+ const push = pushBranch(repoRoot, branch);
201
+ if (!push.ok) {
202
+ throw new PrError(`git push failed:\n${push.output}`, { code: 'push-failed' });
203
+ }
204
+ }
205
+
206
+ const base = options.base || detectDefaultBaseBranch(repoRoot);
207
+ const title = options.title || defaultPrTitleFromCommit(repoRoot, branch);
208
+ const body = options.body || defaultPrBodyFromCommits(repoRoot, branch, base);
209
+
210
+ const args = [
211
+ 'pr', 'create',
212
+ '--base', base,
213
+ '--head', branch,
214
+ '--title', title,
215
+ '--body', body,
216
+ ];
217
+ if (options.draft !== false) args.push('--draft');
218
+
219
+ const result = run(GH_BIN, args, { cwd: repoRoot, allowFailure: true });
220
+ if (result.status !== 0) {
221
+ throw new PrError(
222
+ `gh pr create failed:\n${(result.stderr || result.stdout || '').trim()}`,
223
+ { code: 'gh-create-failed' },
224
+ );
225
+ }
226
+
227
+ const created = findOpenPrForBranch(repoRoot, branch);
228
+ if (!created) {
229
+ throw new PrError('PR appears to have been created but could not be located on origin', {
230
+ code: 'pr-lookup-failed',
231
+ });
232
+ }
233
+ return { created: true, pr: created };
234
+ }
235
+
236
+ /**
237
+ * Fetch the live status of the PR for `branch`. Returns null when no open PR
238
+ * exists. Status checks are summarized.
239
+ */
240
+ function getPullRequestStatus(repoRoot, branch) {
241
+ const pr = findOpenPrForBranch(repoRoot, branch);
242
+ if (!pr) return null;
243
+
244
+ const checks = Array.isArray(pr.statusCheckRollup) ? pr.statusCheckRollup : [];
245
+ const summary = checks.reduce(
246
+ (acc, check) => {
247
+ const state = String(check?.conclusion || check?.status || '').toUpperCase();
248
+ if (state === 'SUCCESS') acc.success += 1;
249
+ else if (state === 'FAILURE' || state === 'ERROR' || state === 'TIMED_OUT') acc.failed += 1;
250
+ else if (state === 'PENDING' || state === 'IN_PROGRESS' || state === 'QUEUED') acc.pending += 1;
251
+ else if (state === 'CANCELLED') acc.cancelled += 1;
252
+ else acc.other += 1;
253
+ return acc;
254
+ },
255
+ { success: 0, failed: 0, pending: 0, cancelled: 0, other: 0, total: checks.length },
256
+ );
257
+
258
+ return {
259
+ number: pr.number,
260
+ url: pr.url,
261
+ state: pr.state,
262
+ isDraft: pr.isDraft,
263
+ mergeable: pr.mergeable,
264
+ mergeStateStatus: pr.mergeStateStatus,
265
+ reviewDecision: pr.reviewDecision,
266
+ title: pr.title,
267
+ head: pr.headRefName,
268
+ base: pr.baseRefName,
269
+ checks: summary,
270
+ };
271
+ }
272
+
273
+ function enableAutoMerge(repoRoot, prNumber, { strategy = 'squash' } = {}) {
274
+ const flag = strategy === 'merge'
275
+ ? '--merge'
276
+ : strategy === 'rebase' ? '--rebase' : '--squash';
277
+ const result = run(GH_BIN, [
278
+ 'pr', 'merge', String(prNumber),
279
+ '--auto', flag, '--delete-branch',
280
+ ], { cwd: repoRoot, allowFailure: true });
281
+ return {
282
+ ok: result.status === 0,
283
+ output: ((result.stdout || '') + (result.stderr || '')).trim(),
284
+ };
285
+ }
286
+
287
+ function markPullRequestReady(repoRoot, prNumber) {
288
+ const result = run(GH_BIN, ['pr', 'ready', String(prNumber)], {
289
+ cwd: repoRoot, allowFailure: true,
290
+ });
291
+ return {
292
+ ok: result.status === 0,
293
+ output: ((result.stdout || '') + (result.stderr || '')).trim(),
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Watch a PR's CI + merge state until it merges, fails, or times out.
299
+ *
300
+ * Returns a structured result; does not throw on non-success states. Used by
301
+ * `gx pr watch`. `onEvent` is called for every poll tick with the current
302
+ * status snapshot, so callers can render progress.
303
+ */
304
+ async function watchPullRequest(options) {
305
+ const {
306
+ repoRoot,
307
+ branch,
308
+ intervalMs = DEFAULT_POLL_INTERVAL_MS,
309
+ timeoutMs = DEFAULT_POLL_TIMEOUT_MS,
310
+ onEvent = () => {},
311
+ } = options;
312
+
313
+ const deadline = Date.now() + timeoutMs;
314
+
315
+ // eslint-disable-next-line no-constant-condition
316
+ while (true) {
317
+ // Check open PRs first.
318
+ const open = findOpenPrForBranch(repoRoot, branch);
319
+ if (!open) {
320
+ const latest = findLatestPrForBranch(repoRoot, branch);
321
+ if (latest && latest.state === 'MERGED') {
322
+ onEvent({ phase: 'merged', pr: latest });
323
+ return { status: 'merged', pr: latest };
324
+ }
325
+ if (latest && latest.state === 'CLOSED') {
326
+ onEvent({ phase: 'closed', pr: latest });
327
+ return { status: 'closed', pr: latest };
328
+ }
329
+ onEvent({ phase: 'no-pr', pr: null });
330
+ return { status: 'no-pr', pr: null };
331
+ }
332
+
333
+ const snapshot = getPullRequestStatus(repoRoot, branch);
334
+ onEvent({ phase: 'polling', pr: snapshot });
335
+
336
+ if (snapshot && snapshot.checks.failed > 0) {
337
+ return { status: 'checks-failed', pr: snapshot };
338
+ }
339
+ if (
340
+ snapshot
341
+ && !snapshot.isDraft
342
+ && snapshot.mergeable === 'MERGEABLE'
343
+ && snapshot.checks.pending === 0
344
+ && snapshot.checks.failed === 0
345
+ && snapshot.checks.total > 0
346
+ ) {
347
+ // CI is green and PR is non-draft + mergeable; the merge itself will be
348
+ // driven by auto-merge or a separate `gh pr merge` invocation.
349
+ onEvent({ phase: 'ready', pr: snapshot });
350
+ }
351
+
352
+ if (Date.now() >= deadline) {
353
+ return { status: 'timeout', pr: snapshot };
354
+ }
355
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
356
+ }
357
+ }
358
+
359
+ function resolveRepoAndBranch(target) {
360
+ const repoRoot = resolveRepoRoot(target || process.cwd());
361
+ const branch = currentBranchName(repoRoot);
362
+ return { repoRoot, branch };
363
+ }
364
+
365
+ module.exports = {
366
+ PrError,
367
+ ghAvailable,
368
+ ghAuthStatus,
369
+ findOpenPrForBranch,
370
+ findLatestPrForBranch,
371
+ listOpenPrsForRepo,
372
+ pushBranch,
373
+ openPullRequest,
374
+ getPullRequestStatus,
375
+ enableAutoMerge,
376
+ markPullRequestReady,
377
+ watchPullRequest,
378
+ resolveRepoAndBranch,
379
+ defaultPrTitleFromCommit,
380
+ defaultPrBodyFromCommits,
381
+ };
@@ -13,6 +13,14 @@ const {
13
13
  gitRefExists,
14
14
  ensureRepoBranch,
15
15
  } = require('../git');
16
+ const { prepareAgentWorktree } = require('../scaffold/agent-worktree-prep');
17
+
18
+ function formatWorktreePrepOps(operations) {
19
+ if (!operations || operations.length === 0) return '';
20
+ return operations
21
+ .map((op) => `[agent-branch-start] worktree-prep ${op.status} ${op.file}${op.note ? ' — ' + op.note : ''}`)
22
+ .join('\n') + '\n';
23
+ }
16
24
 
17
25
  function hasGuardexBootstrapFiles(repoRoot) {
18
26
  const required = [
@@ -195,6 +203,7 @@ function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
195
203
  }
196
204
  }
197
205
 
206
+ const prepOps = prepareAgentWorktree(blocked.repoRoot, selectedWorktreePath);
198
207
  return {
199
208
  metadata: {
200
209
  branch: selectedBranch,
@@ -202,7 +211,8 @@ function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
202
211
  },
203
212
  stdout:
204
213
  `[agent-branch-start] Created branch: ${selectedBranch}\n` +
205
- `[agent-branch-start] Worktree: ${selectedWorktreePath}\n`,
214
+ `[agent-branch-start] Worktree: ${selectedWorktreePath}\n` +
215
+ formatWorktreePrepOps(prepOps),
206
216
  stderr: addResult.stderr || '',
207
217
  };
208
218
  }
@@ -246,9 +256,10 @@ function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) {
246
256
  return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
247
257
  }
248
258
 
259
+ const prepOps = prepareAgentWorktree(blocked.repoRoot, worktreePath);
249
260
  return {
250
261
  metadata,
251
- stdout: startResult.stdout || '',
262
+ stdout: (startResult.stdout || '') + formatWorktreePrepOps(prepOps),
252
263
  stderr: startResult.stderr || '',
253
264
  };
254
265
  }