@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.
Files changed (40) hide show
  1. package/.devcontainer.example/Dockerfile +149 -0
  2. package/.devcontainer.example/devcontainer.json +61 -0
  3. package/.devcontainer.example/init-firewall.sh +175 -0
  4. package/LICENSE +21 -0
  5. package/README.md +139 -0
  6. package/agent/skills/discovery/SKILL.md +428 -0
  7. package/agent/skills/discovery/schemas/plan-schema.yaml +138 -0
  8. package/agent/skills/orrery-execute/SKILL.md +107 -0
  9. package/agent/skills/orrery-report/SKILL.md +119 -0
  10. package/agent/skills/orrery-review/SKILL.md +105 -0
  11. package/agent/skills/orrery-verify/SKILL.md +105 -0
  12. package/agent/skills/refine-plan/SKILL.md +291 -0
  13. package/agent/skills/simulate-plan/SKILL.md +244 -0
  14. package/bin/orrery.js +5 -0
  15. package/lib/cli/commands/help.js +21 -0
  16. package/lib/cli/commands/ingest-plan.js +56 -0
  17. package/lib/cli/commands/init.js +21 -0
  18. package/lib/cli/commands/install-devcontainer.js +97 -0
  19. package/lib/cli/commands/install-skills.js +182 -0
  20. package/lib/cli/commands/orchestrate.js +27 -0
  21. package/lib/cli/commands/resume.js +146 -0
  22. package/lib/cli/commands/status.js +137 -0
  23. package/lib/cli/commands/validate-plan.js +288 -0
  24. package/lib/cli/index.js +57 -0
  25. package/lib/orchestration/agent-invoker.js +595 -0
  26. package/lib/orchestration/condensed-plan.js +128 -0
  27. package/lib/orchestration/config.js +213 -0
  28. package/lib/orchestration/dependency-resolver.js +149 -0
  29. package/lib/orchestration/edit-invoker.js +115 -0
  30. package/lib/orchestration/index.js +1065 -0
  31. package/lib/orchestration/plan-loader.js +212 -0
  32. package/lib/orchestration/progress-tracker.js +208 -0
  33. package/lib/orchestration/report-format.js +80 -0
  34. package/lib/orchestration/review-invoker.js +305 -0
  35. package/lib/utils/agent-detector.js +47 -0
  36. package/lib/utils/git.js +297 -0
  37. package/lib/utils/paths.js +43 -0
  38. package/lib/utils/plan-detect.js +24 -0
  39. package/lib/utils/skill-copier.js +79 -0
  40. 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
+ };
@@ -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 };