@caseyharalson/orrery 0.7.1
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/.devcontainer.example/Dockerfile +149 -0
- package/.devcontainer.example/devcontainer.json +61 -0
- package/.devcontainer.example/init-firewall.sh +175 -0
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/agent/skills/discovery/SKILL.md +428 -0
- package/agent/skills/discovery/schemas/plan-schema.yaml +138 -0
- package/agent/skills/orrery-execute/SKILL.md +107 -0
- package/agent/skills/orrery-report/SKILL.md +119 -0
- package/agent/skills/orrery-review/SKILL.md +105 -0
- package/agent/skills/orrery-verify/SKILL.md +105 -0
- package/agent/skills/refine-plan/SKILL.md +291 -0
- package/agent/skills/simulate-plan/SKILL.md +244 -0
- package/bin/orrery.js +5 -0
- package/lib/cli/commands/help.js +21 -0
- package/lib/cli/commands/ingest-plan.js +56 -0
- package/lib/cli/commands/init.js +21 -0
- package/lib/cli/commands/install-devcontainer.js +97 -0
- package/lib/cli/commands/install-skills.js +182 -0
- package/lib/cli/commands/orchestrate.js +27 -0
- package/lib/cli/commands/resume.js +146 -0
- package/lib/cli/commands/status.js +137 -0
- package/lib/cli/commands/validate-plan.js +288 -0
- package/lib/cli/index.js +57 -0
- package/lib/orchestration/agent-invoker.js +595 -0
- package/lib/orchestration/condensed-plan.js +128 -0
- package/lib/orchestration/config.js +213 -0
- package/lib/orchestration/dependency-resolver.js +149 -0
- package/lib/orchestration/edit-invoker.js +115 -0
- package/lib/orchestration/index.js +1065 -0
- package/lib/orchestration/plan-loader.js +212 -0
- package/lib/orchestration/progress-tracker.js +208 -0
- package/lib/orchestration/report-format.js +80 -0
- package/lib/orchestration/review-invoker.js +305 -0
- package/lib/utils/agent-detector.js +47 -0
- package/lib/utils/git.js +297 -0
- package/lib/utils/paths.js +43 -0
- package/lib/utils/plan-detect.js +24 -0
- package/lib/utils/skill-copier.js +79 -0
- package/package.json +58 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
const { invokeAgentWithFailover } = require("./agent-invoker");
|
|
2
|
+
|
|
3
|
+
function formatStepContext(stepContext) {
|
|
4
|
+
if (stepContext === undefined || stepContext === null) {
|
|
5
|
+
return "(no step context provided)";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (typeof stepContext === "string") {
|
|
9
|
+
const trimmed = stepContext.trim();
|
|
10
|
+
return trimmed.length > 0 ? trimmed : "(no step context provided)";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
return JSON.stringify(stepContext, null, 2);
|
|
15
|
+
} catch {
|
|
16
|
+
return String(stepContext);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatFiles(files) {
|
|
21
|
+
if (!files) {
|
|
22
|
+
return "(no files provided)";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (Array.isArray(files)) {
|
|
26
|
+
if (files.length === 0) {
|
|
27
|
+
return "(no files provided)";
|
|
28
|
+
}
|
|
29
|
+
return files.map((file) => `- ${file}`).join("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const trimmed = String(files).trim();
|
|
33
|
+
return trimmed.length > 0 ? trimmed : "(no files provided)";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ensureFullFileInstruction(prompt) {
|
|
37
|
+
const fullFileRegex = /full file|full files|full contents/i;
|
|
38
|
+
if (fullFileRegex.test(prompt)) {
|
|
39
|
+
return prompt;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const instruction =
|
|
43
|
+
"Read the full contents of each modified file for context, not only the diff.";
|
|
44
|
+
return `${prompt}\n\n## Required Context\n${instruction}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildReviewPrompt(template, stepContext, files, diff) {
|
|
48
|
+
const basePrompt = String(template || "");
|
|
49
|
+
const promptWithContext = basePrompt
|
|
50
|
+
.replace("{stepContext}", formatStepContext(stepContext))
|
|
51
|
+
.replace("{files}", formatFiles(files))
|
|
52
|
+
.replace("{diff}", diff ? String(diff) : "(no diff provided)");
|
|
53
|
+
|
|
54
|
+
return ensureFullFileInstruction(promptWithContext);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildReviewConfig(config, prompt) {
|
|
58
|
+
const agents = {};
|
|
59
|
+
|
|
60
|
+
for (const [name, agentConfig] of Object.entries(config.agents || {})) {
|
|
61
|
+
const args = Array.isArray(agentConfig.args)
|
|
62
|
+
? agentConfig.args.slice()
|
|
63
|
+
: [];
|
|
64
|
+
if (args.length > 0) {
|
|
65
|
+
args[args.length - 1] = prompt;
|
|
66
|
+
}
|
|
67
|
+
agents[name] = {
|
|
68
|
+
...agentConfig,
|
|
69
|
+
args
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
...config,
|
|
75
|
+
agents
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function extractBalancedJson(str, start, openChar = "{", closeChar = "}") {
|
|
80
|
+
if (str[start] !== openChar) return null;
|
|
81
|
+
|
|
82
|
+
let depth = 0;
|
|
83
|
+
let inString = false;
|
|
84
|
+
let escapeNext = false;
|
|
85
|
+
|
|
86
|
+
for (let i = start; i < str.length; i++) {
|
|
87
|
+
const char = str[i];
|
|
88
|
+
|
|
89
|
+
if (escapeNext) {
|
|
90
|
+
escapeNext = false;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (char === "\\" && inString) {
|
|
95
|
+
escapeNext = true;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (char === '"') {
|
|
100
|
+
inString = !inString;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!inString) {
|
|
105
|
+
if (char === openChar) {
|
|
106
|
+
depth++;
|
|
107
|
+
} else if (char === closeChar) {
|
|
108
|
+
depth--;
|
|
109
|
+
if (depth === 0) {
|
|
110
|
+
return str.slice(start, i + 1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function extractJsonPayload(stdout) {
|
|
120
|
+
if (!stdout) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const codeBlockPattern = /```(?:json)?\s*([\s\S]*?)```/g;
|
|
125
|
+
let match;
|
|
126
|
+
while ((match = codeBlockPattern.exec(stdout)) !== null) {
|
|
127
|
+
const content = match[1].trim();
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(content);
|
|
130
|
+
} catch {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (let i = 0; i < stdout.length; i++) {
|
|
136
|
+
if (stdout[i] === "{") {
|
|
137
|
+
const jsonObj = extractBalancedJson(stdout, i, "{", "}");
|
|
138
|
+
if (jsonObj) {
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(jsonObj);
|
|
141
|
+
} catch {
|
|
142
|
+
// continue scanning
|
|
143
|
+
}
|
|
144
|
+
i += jsonObj.length - 1;
|
|
145
|
+
}
|
|
146
|
+
} else if (stdout[i] === "[") {
|
|
147
|
+
const jsonArr = extractBalancedJson(stdout, i, "[", "]");
|
|
148
|
+
if (jsonArr) {
|
|
149
|
+
try {
|
|
150
|
+
return JSON.parse(jsonArr);
|
|
151
|
+
} catch {
|
|
152
|
+
// continue scanning
|
|
153
|
+
}
|
|
154
|
+
i += jsonArr.length - 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function normalizeStatus(status) {
|
|
163
|
+
if (!status || typeof status !== "string") {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const normalized = status.trim().toLowerCase();
|
|
168
|
+
if (normalized === "approved") {
|
|
169
|
+
return "approved";
|
|
170
|
+
}
|
|
171
|
+
if (normalized === "needs_changes" || normalized === "changes_requested") {
|
|
172
|
+
return "needs_changes";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function normalizeFeedbackEntry(entry) {
|
|
179
|
+
if (!entry) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (typeof entry === "string") {
|
|
184
|
+
return {
|
|
185
|
+
comment: entry,
|
|
186
|
+
severity: "suggestion"
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (typeof entry !== "object") {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const comment =
|
|
195
|
+
typeof entry.comment === "string"
|
|
196
|
+
? entry.comment
|
|
197
|
+
: String(entry.comment || "");
|
|
198
|
+
if (!comment) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const output = {
|
|
203
|
+
comment,
|
|
204
|
+
severity: entry.severity === "blocking" ? "blocking" : "suggestion"
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
if (typeof entry.file === "string" && entry.file.trim()) {
|
|
208
|
+
output.file = entry.file.trim();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (Number.isFinite(entry.line)) {
|
|
212
|
+
output.line = entry.line;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return output;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function normalizeFeedback(feedback) {
|
|
219
|
+
if (!Array.isArray(feedback)) {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return feedback.map(normalizeFeedbackEntry).filter(Boolean);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseReviewResults(stdout) {
|
|
227
|
+
try {
|
|
228
|
+
const payload = extractJsonPayload(stdout);
|
|
229
|
+
if (!payload) {
|
|
230
|
+
return {
|
|
231
|
+
approved: true,
|
|
232
|
+
feedback: [],
|
|
233
|
+
error: "No JSON review output detected"
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const data = Array.isArray(payload) ? payload[0] : payload;
|
|
238
|
+
if (!data || typeof data !== "object") {
|
|
239
|
+
return {
|
|
240
|
+
approved: true,
|
|
241
|
+
feedback: [],
|
|
242
|
+
error: "Review output was not a JSON object"
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const status = normalizeStatus(data.status);
|
|
247
|
+
if (!status) {
|
|
248
|
+
return {
|
|
249
|
+
approved: true,
|
|
250
|
+
feedback: [],
|
|
251
|
+
error: `Unrecognized review status: ${data.status}`
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const feedback = normalizeFeedback(data.feedback || data.comments);
|
|
256
|
+
return {
|
|
257
|
+
approved: status === "approved",
|
|
258
|
+
feedback
|
|
259
|
+
};
|
|
260
|
+
} catch (error) {
|
|
261
|
+
return {
|
|
262
|
+
approved: true,
|
|
263
|
+
feedback: [],
|
|
264
|
+
error: error.message || "Failed to parse review output"
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function invokeReviewAgent(
|
|
270
|
+
config,
|
|
271
|
+
stepContext,
|
|
272
|
+
files,
|
|
273
|
+
diff,
|
|
274
|
+
repoRoot,
|
|
275
|
+
options = {}
|
|
276
|
+
) {
|
|
277
|
+
const promptTemplate =
|
|
278
|
+
(config && config.review && config.review.prompt) ||
|
|
279
|
+
config.REVIEW_PROMPT ||
|
|
280
|
+
"";
|
|
281
|
+
const prompt = buildReviewPrompt(promptTemplate, stepContext, files, diff);
|
|
282
|
+
const reviewConfig = buildReviewConfig(config, prompt);
|
|
283
|
+
const planFile = options.planFile || options.planPath || "review";
|
|
284
|
+
const stepIds = options.stepIds
|
|
285
|
+
? options.stepIds
|
|
286
|
+
: options.stepId
|
|
287
|
+
? [options.stepId]
|
|
288
|
+
: ["review"];
|
|
289
|
+
|
|
290
|
+
const handle = invokeAgentWithFailover(
|
|
291
|
+
reviewConfig,
|
|
292
|
+
planFile,
|
|
293
|
+
stepIds,
|
|
294
|
+
repoRoot,
|
|
295
|
+
options
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const result = await handle.completion;
|
|
299
|
+
return parseReviewResults(result.stdout || "");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
module.exports = {
|
|
303
|
+
invokeReviewAgent,
|
|
304
|
+
parseReviewResults
|
|
305
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
|
|
5
|
+
const AGENT_DIRS = {
|
|
6
|
+
claude: ".claude",
|
|
7
|
+
codex: ".codex",
|
|
8
|
+
gemini: ".gemini"
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function isDirectory(dirPath) {
|
|
12
|
+
try {
|
|
13
|
+
return fs.statSync(dirPath).isDirectory();
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function detectInstalledAgents() {
|
|
20
|
+
const homeDir = os.homedir();
|
|
21
|
+
if (!homeDir) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return Object.entries(AGENT_DIRS)
|
|
26
|
+
.filter(([, dirName]) => isDirectory(path.join(homeDir, dirName)))
|
|
27
|
+
.map(([agentName]) => agentName);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getAgentSkillsDir(agent) {
|
|
31
|
+
if (!agent) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const agentKey = agent.toLowerCase();
|
|
36
|
+
const dirName = AGENT_DIRS[agentKey];
|
|
37
|
+
if (!dirName) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return path.join(os.homedir(), dirName, "skills");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = {
|
|
45
|
+
detectInstalledAgents,
|
|
46
|
+
getAgentSkillsDir
|
|
47
|
+
};
|
package/lib/utils/git.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { execSync } = require("child_process");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Git helper functions for branch management and PR creation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Execute a git command and return the output
|
|
11
|
+
* @param {string} command - Git command (without 'git' prefix)
|
|
12
|
+
* @param {string} cwd - Working directory
|
|
13
|
+
* @returns {string} - Command output (trimmed)
|
|
14
|
+
*/
|
|
15
|
+
function git(command, cwd) {
|
|
16
|
+
try {
|
|
17
|
+
return execSync(`git ${command}`, {
|
|
18
|
+
cwd,
|
|
19
|
+
encoding: "utf8",
|
|
20
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
21
|
+
}).trim();
|
|
22
|
+
} catch (error) {
|
|
23
|
+
// Return stderr if available, otherwise throw
|
|
24
|
+
if (error.stderr) {
|
|
25
|
+
throw new Error(`git ${command} failed: ${error.stderr.trim()}`);
|
|
26
|
+
}
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get the current branch name
|
|
33
|
+
* @param {string} cwd - Working directory
|
|
34
|
+
* @returns {string} - Current branch name
|
|
35
|
+
*/
|
|
36
|
+
function getCurrentBranch(cwd) {
|
|
37
|
+
return git("rev-parse --abbrev-ref HEAD", cwd);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check if a branch exists (locally or remotely)
|
|
42
|
+
* @param {string} branchName - Branch name to check
|
|
43
|
+
* @param {string} cwd - Working directory
|
|
44
|
+
* @returns {boolean} - True if branch exists
|
|
45
|
+
*/
|
|
46
|
+
function branchExists(branchName, cwd) {
|
|
47
|
+
try {
|
|
48
|
+
git(`rev-parse --verify ${branchName}`, cwd);
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
// Also check remote
|
|
52
|
+
try {
|
|
53
|
+
git(`rev-parse --verify origin/${branchName}`, cwd);
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create a new branch from current HEAD
|
|
63
|
+
* @param {string} branchName - Name for the new branch
|
|
64
|
+
* @param {string} cwd - Working directory
|
|
65
|
+
*/
|
|
66
|
+
function createBranch(branchName, cwd) {
|
|
67
|
+
git(`checkout -b ${branchName}`, cwd);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Switch to an existing branch
|
|
72
|
+
* @param {string} branchName - Branch to switch to
|
|
73
|
+
* @param {string} cwd - Working directory
|
|
74
|
+
*/
|
|
75
|
+
function checkoutBranch(branchName, cwd) {
|
|
76
|
+
git(`checkout ${branchName}`, cwd);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Stage and commit changes
|
|
81
|
+
* @param {string} message - Commit message
|
|
82
|
+
* @param {string[]} files - Files to stage (empty array = all changes)
|
|
83
|
+
* @param {string} cwd - Working directory
|
|
84
|
+
* @returns {string} - Commit hash
|
|
85
|
+
*/
|
|
86
|
+
function commit(message, files, cwd) {
|
|
87
|
+
if (files && files.length > 0) {
|
|
88
|
+
git(`add ${files.map((f) => `"${f}"`).join(" ")}`, cwd);
|
|
89
|
+
} else {
|
|
90
|
+
git("add -A", cwd);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Check if there are changes to commit
|
|
94
|
+
try {
|
|
95
|
+
git("diff --cached --quiet", cwd);
|
|
96
|
+
// No changes to commit
|
|
97
|
+
return null;
|
|
98
|
+
} catch {
|
|
99
|
+
// There are changes, proceed with commit
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
git(`commit -m "${message.replace(/"/g, '\\"')}"`, cwd);
|
|
103
|
+
return git("rev-parse HEAD", cwd);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Push current branch to origin
|
|
108
|
+
* @param {string} cwd - Working directory
|
|
109
|
+
* @param {boolean} setUpstream - Whether to set upstream tracking
|
|
110
|
+
*/
|
|
111
|
+
function push(cwd, setUpstream = true) {
|
|
112
|
+
const branch = getCurrentBranch(cwd);
|
|
113
|
+
if (setUpstream) {
|
|
114
|
+
git(`push -u origin ${branch}`, cwd);
|
|
115
|
+
} else {
|
|
116
|
+
git("push", cwd);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get the GitHub repository URL from git remote
|
|
122
|
+
* @param {string} cwd - Working directory
|
|
123
|
+
* @returns {string|null} - GitHub HTTPS URL or null if not found
|
|
124
|
+
*/
|
|
125
|
+
function getGitHubRepoUrl(cwd) {
|
|
126
|
+
try {
|
|
127
|
+
const remoteUrl = git("remote get-url origin", cwd);
|
|
128
|
+
if (!remoteUrl) return null;
|
|
129
|
+
|
|
130
|
+
// Convert SSH or git URLs to HTTPS
|
|
131
|
+
let url = remoteUrl.trim();
|
|
132
|
+
|
|
133
|
+
// git@github.com:owner/repo.git -> https://github.com/owner/repo
|
|
134
|
+
if (url.startsWith("git@")) {
|
|
135
|
+
const match = url.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
|
|
136
|
+
if (match) {
|
|
137
|
+
url = `https://${match[1]}/${match[2]}`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Remove .git suffix
|
|
142
|
+
url = url.replace(/\.git$/, "");
|
|
143
|
+
|
|
144
|
+
// Remove git+ prefix
|
|
145
|
+
url = url.replace(/^git\+/, "");
|
|
146
|
+
|
|
147
|
+
return url;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Generate PR creation info (URL and details) without requiring gh CLI
|
|
155
|
+
* @param {string} title - PR title
|
|
156
|
+
* @param {string} body - PR body/description
|
|
157
|
+
* @param {string} baseBranch - Target branch for the PR
|
|
158
|
+
* @param {string} cwd - Working directory
|
|
159
|
+
* @returns {{url: string, title: string, body: string, headBranch: string, baseBranch: string, pushed: boolean}} - PR info
|
|
160
|
+
*/
|
|
161
|
+
function createPullRequest(title, body, baseBranch, cwd) {
|
|
162
|
+
const headBranch = getCurrentBranch(cwd);
|
|
163
|
+
let pushed = false;
|
|
164
|
+
|
|
165
|
+
// Try to push the branch
|
|
166
|
+
try {
|
|
167
|
+
push(cwd, true);
|
|
168
|
+
pushed = true;
|
|
169
|
+
} catch {
|
|
170
|
+
// May fail if no remote configured - continue anyway
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const repoUrl = getGitHubRepoUrl(cwd);
|
|
174
|
+
let prUrl = "";
|
|
175
|
+
|
|
176
|
+
if (repoUrl) {
|
|
177
|
+
// GitHub PR creation URL format
|
|
178
|
+
const encodedTitle = encodeURIComponent(title);
|
|
179
|
+
const encodedBody = encodeURIComponent(body);
|
|
180
|
+
prUrl = `${repoUrl}/compare/${baseBranch}...${headBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
url: prUrl,
|
|
185
|
+
title,
|
|
186
|
+
body,
|
|
187
|
+
headBranch,
|
|
188
|
+
baseBranch,
|
|
189
|
+
pushed
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Check if there are uncommitted changes
|
|
195
|
+
* @param {string} cwd - Working directory
|
|
196
|
+
* @returns {boolean} - True if there are uncommitted changes
|
|
197
|
+
*/
|
|
198
|
+
function hasUncommittedChanges(cwd) {
|
|
199
|
+
try {
|
|
200
|
+
git("diff --quiet", cwd);
|
|
201
|
+
git("diff --cached --quiet", cwd);
|
|
202
|
+
return false;
|
|
203
|
+
} catch {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get the diff for uncommitted changes (staged and unstaged)
|
|
210
|
+
* @param {string} cwd - Working directory
|
|
211
|
+
* @param {string[]} [files] - Optional file list to filter diff
|
|
212
|
+
* @returns {string} - Diff output or empty string if none
|
|
213
|
+
*/
|
|
214
|
+
function getUncommittedDiff(cwd, files) {
|
|
215
|
+
try {
|
|
216
|
+
if (!hasUncommittedChanges(cwd)) {
|
|
217
|
+
return "";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const fileArgs =
|
|
221
|
+
Array.isArray(files) && files.length > 0
|
|
222
|
+
? ` -- ${files.map((file) => `"${file}"`).join(" ")}`
|
|
223
|
+
: "";
|
|
224
|
+
|
|
225
|
+
const workingTreeDiff = git(`diff${fileArgs}`, cwd);
|
|
226
|
+
const stagedDiff = git(`diff --cached${fileArgs}`, cwd);
|
|
227
|
+
const combined = [workingTreeDiff, stagedDiff].filter(Boolean).join("\n");
|
|
228
|
+
|
|
229
|
+
return combined.trim();
|
|
230
|
+
} catch {
|
|
231
|
+
return "";
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Derive a branch name from a plan filename
|
|
237
|
+
* @param {string} planFileName - Plan filename (e.g., "2026-01-11-add-dummy-script.yaml")
|
|
238
|
+
* @returns {string} - Branch name (e.g., "plan/add-dummy-script")
|
|
239
|
+
*/
|
|
240
|
+
function deriveBranchName(planFileName) {
|
|
241
|
+
// Remove .yaml/.yml extension
|
|
242
|
+
let name = planFileName.replace(/\.ya?ml$/, "");
|
|
243
|
+
|
|
244
|
+
// Remove date prefix if present (YYYY-MM-DD-)
|
|
245
|
+
name = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
|
|
246
|
+
|
|
247
|
+
// Sanitize for git branch name
|
|
248
|
+
name = name
|
|
249
|
+
.toLowerCase()
|
|
250
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
251
|
+
.replace(/-+/g, "-")
|
|
252
|
+
.replace(/^-|-$/g, "");
|
|
253
|
+
|
|
254
|
+
return `plan/${name}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Stash any uncommitted changes
|
|
259
|
+
* @param {string} cwd - Working directory
|
|
260
|
+
* @returns {boolean} - True if changes were stashed
|
|
261
|
+
*/
|
|
262
|
+
function stash(cwd) {
|
|
263
|
+
if (!hasUncommittedChanges(cwd)) {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
git("stash push -m 'orchestrator-auto-stash'", cwd);
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Pop the most recent stash
|
|
272
|
+
* @param {string} cwd - Working directory
|
|
273
|
+
*/
|
|
274
|
+
function stashPop(cwd) {
|
|
275
|
+
try {
|
|
276
|
+
git("stash pop", cwd);
|
|
277
|
+
} catch {
|
|
278
|
+
// No stash to pop or conflict - ignore
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
module.exports = {
|
|
283
|
+
git,
|
|
284
|
+
getCurrentBranch,
|
|
285
|
+
branchExists,
|
|
286
|
+
createBranch,
|
|
287
|
+
checkoutBranch,
|
|
288
|
+
commit,
|
|
289
|
+
push,
|
|
290
|
+
createPullRequest,
|
|
291
|
+
getGitHubRepoUrl,
|
|
292
|
+
hasUncommittedChanges,
|
|
293
|
+
getUncommittedDiff,
|
|
294
|
+
deriveBranchName,
|
|
295
|
+
stash,
|
|
296
|
+
stashPop
|
|
297
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
const WORK_DIR_ENV = "ORRERY_WORK_DIR";
|
|
5
|
+
|
|
6
|
+
function ensureDir(dirPath) {
|
|
7
|
+
if (!fs.existsSync(dirPath)) {
|
|
8
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
return dirPath;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getWorkDir() {
|
|
14
|
+
const envDir = process.env[WORK_DIR_ENV];
|
|
15
|
+
if (envDir && envDir.trim()) {
|
|
16
|
+
return ensureDir(envDir.trim());
|
|
17
|
+
}
|
|
18
|
+
return ensureDir(path.join(process.cwd(), ".agent-work"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getPlansDir() {
|
|
22
|
+
return ensureDir(path.join(getWorkDir(), "plans"));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getCompletedDir() {
|
|
26
|
+
return ensureDir(path.join(getWorkDir(), "completed"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getReportsDir() {
|
|
30
|
+
return ensureDir(path.join(getWorkDir(), "reports"));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getTempDir() {
|
|
34
|
+
return ensureDir(path.join(getWorkDir(), "temp"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
getWorkDir,
|
|
39
|
+
getPlansDir,
|
|
40
|
+
getCompletedDir,
|
|
41
|
+
getReportsDir,
|
|
42
|
+
getTempDir
|
|
43
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const { getCurrentBranch } = require("./git");
|
|
2
|
+
const { getPlanFiles, loadPlan } = require("../orchestration/plan-loader");
|
|
3
|
+
const { getPlansDir } = require("./paths");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Find a plan that matches the current branch's work_branch metadata.
|
|
7
|
+
* Used for auto-detection when on a work branch.
|
|
8
|
+
* @returns {{planFile: string, plan: Object}|null} - The matching plan or null
|
|
9
|
+
*/
|
|
10
|
+
function findPlanForCurrentBranch() {
|
|
11
|
+
const currentBranch = getCurrentBranch(process.cwd());
|
|
12
|
+
const plansDir = getPlansDir();
|
|
13
|
+
const planFiles = getPlanFiles(plansDir);
|
|
14
|
+
|
|
15
|
+
for (const planFile of planFiles) {
|
|
16
|
+
const plan = loadPlan(planFile);
|
|
17
|
+
if (plan.metadata.work_branch === currentBranch) {
|
|
18
|
+
return { planFile, plan };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { findPlanForCurrentBranch };
|