@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,212 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const YAML = require("yaml");
6
+
7
+ /**
8
+ * Load a plan from a YAML file
9
+ * @param {string} filePath - Absolute path to the plan YAML file
10
+ * @returns {Object} - Parsed plan object with helper methods
11
+ */
12
+ function loadPlan(filePath) {
13
+ const content = fs.readFileSync(filePath, "utf8");
14
+ const doc = YAML.parseDocument(content);
15
+ const data = doc.toJS();
16
+
17
+ return {
18
+ filePath,
19
+ fileName: path.basename(filePath),
20
+ doc,
21
+ metadata: data.metadata || {},
22
+ steps: data.steps || [],
23
+
24
+ /**
25
+ * Get set of completed step IDs
26
+ * @returns {Set<string>}
27
+ */
28
+ getCompletedSteps() {
29
+ return new Set(
30
+ this.steps.filter((s) => s.status === "complete").map((s) => s.id)
31
+ );
32
+ },
33
+
34
+ /**
35
+ * Get set of blocked step IDs
36
+ * @returns {Set<string>}
37
+ */
38
+ getBlockedSteps() {
39
+ return new Set(
40
+ this.steps.filter((s) => s.status === "blocked").map((s) => s.id)
41
+ );
42
+ },
43
+
44
+ /**
45
+ * Check if all steps are complete or blocked
46
+ * @returns {boolean}
47
+ */
48
+ isComplete() {
49
+ return this.steps.every(
50
+ (s) => s.status === "complete" || s.status === "blocked"
51
+ );
52
+ },
53
+
54
+ /**
55
+ * Check if all steps are complete (none blocked)
56
+ * @returns {boolean}
57
+ */
58
+ isSuccessful() {
59
+ return this.steps.every((s) => s.status === "complete");
60
+ }
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Save a plan back to its YAML file (preserves comments)
66
+ * @param {Object} plan - The plan object (with filePath, doc, metadata, steps)
67
+ */
68
+ function savePlan(plan) {
69
+ const metadataNode = plan.doc.get("metadata", true);
70
+ const stepsNode = plan.doc.get("steps", true);
71
+
72
+ // Update metadata fields in-place
73
+ if (metadataNode) {
74
+ for (const [key, value] of Object.entries(plan.metadata)) {
75
+ metadataNode.set(key, value);
76
+ }
77
+ }
78
+
79
+ // Update step fields in-place
80
+ if (stepsNode && stepsNode.items) {
81
+ for (let i = 0; i < plan.steps.length; i++) {
82
+ const stepData = plan.steps[i];
83
+ const stepNode = stepsNode.items[i];
84
+ if (stepNode) {
85
+ for (const [key, value] of Object.entries(stepData)) {
86
+ stepNode.set(key, value);
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ fs.writeFileSync(plan.filePath, plan.doc.toString(), "utf8");
93
+ }
94
+
95
+ /**
96
+ * Update a specific step's status in a plan file (preserves comments)
97
+ * @param {string} filePath - Path to the plan file
98
+ * @param {string} stepId - ID of the step to update
99
+ * @param {string} status - New status value (pending, in_progress, complete, blocked)
100
+ * @param {Object} [extras] - Optional extra fields to update (e.g., blockedReason)
101
+ */
102
+ function updateStepStatus(filePath, stepId, status, extras = {}) {
103
+ const content = fs.readFileSync(filePath, "utf8");
104
+ const doc = YAML.parseDocument(content);
105
+ const stepsNode = doc.get("steps", true);
106
+
107
+ if (!stepsNode || !stepsNode.items) return;
108
+
109
+ for (const stepNode of stepsNode.items) {
110
+ const idNode = stepNode.get("id", true);
111
+ if (idNode && String(idNode) === stepId) {
112
+ stepNode.set("status", status);
113
+ for (const [key, value] of Object.entries(extras)) {
114
+ stepNode.set(key, value);
115
+ }
116
+ break;
117
+ }
118
+ }
119
+
120
+ fs.writeFileSync(filePath, doc.toString(), "utf8");
121
+ }
122
+
123
+ /**
124
+ * Update multiple steps' status at once (preserves comments)
125
+ * @param {string} filePath - Path to the plan file
126
+ * @param {Array<{stepId: string, status: string, extras?: Object}>} updates - Array of updates
127
+ */
128
+ function updateStepsStatus(filePath, updates) {
129
+ const content = fs.readFileSync(filePath, "utf8");
130
+ const doc = YAML.parseDocument(content);
131
+ const stepsNode = doc.get("steps", true);
132
+
133
+ if (!stepsNode || !stepsNode.items) return;
134
+
135
+ const updateMap = new Map(updates.map((u) => [u.stepId, u]));
136
+
137
+ for (const stepNode of stepsNode.items) {
138
+ const idNode = stepNode.get("id", true);
139
+ const stepId = idNode ? String(idNode) : null;
140
+ const update = updateMap.get(stepId);
141
+
142
+ if (update) {
143
+ stepNode.set("status", update.status);
144
+ for (const [key, value] of Object.entries(update.extras || {})) {
145
+ if (value === null) {
146
+ stepNode.delete(key); // Remove field when null
147
+ } else {
148
+ stepNode.set(key, value);
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ fs.writeFileSync(filePath, doc.toString(), "utf8");
155
+ }
156
+
157
+ /**
158
+ * Get all YAML plan files in a directory
159
+ * @param {string} dir - Directory to scan
160
+ * @returns {string[]} - Array of absolute file paths, sorted by name
161
+ */
162
+ function getPlanFiles(dir) {
163
+ if (!fs.existsSync(dir)) return [];
164
+
165
+ return fs
166
+ .readdirSync(dir)
167
+ .filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"))
168
+ .map((f) => path.join(dir, f))
169
+ .sort();
170
+ }
171
+
172
+ /**
173
+ * Move a completed plan to the completed directory
174
+ * @param {string} planFile - Current path of the plan
175
+ * @param {string} completedDir - Destination directory
176
+ */
177
+ function movePlanToCompleted(planFile, completedDir) {
178
+ const fileName = path.basename(planFile);
179
+ const destPath = path.join(completedDir, fileName);
180
+
181
+ if (!fs.existsSync(completedDir)) {
182
+ fs.mkdirSync(completedDir, { recursive: true });
183
+ }
184
+
185
+ fs.renameSync(planFile, destPath);
186
+ return destPath;
187
+ }
188
+
189
+ /**
190
+ * Get filenames of completed plans (for exclusion filtering)
191
+ * @param {string} completedDir - Path to completed directory
192
+ * @returns {Set<string>} - Set of filenames
193
+ */
194
+ function getCompletedPlanNames(completedDir) {
195
+ if (!fs.existsSync(completedDir)) return new Set();
196
+
197
+ return new Set(
198
+ fs
199
+ .readdirSync(completedDir)
200
+ .filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"))
201
+ );
202
+ }
203
+
204
+ module.exports = {
205
+ loadPlan,
206
+ savePlan,
207
+ updateStepStatus,
208
+ updateStepsStatus,
209
+ getPlanFiles,
210
+ movePlanToCompleted,
211
+ getCompletedPlanNames
212
+ };
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Progress Tracker for Plan Orchestration
3
+ *
4
+ * Tracks step completion, elapsed time, and provides ETA estimates
5
+ * during plan execution.
6
+ */
7
+
8
+ class ProgressTracker {
9
+ /**
10
+ * @param {number} totalSteps - Total number of steps in the plan
11
+ * @param {string} planFileName - Name of the plan file for display
12
+ */
13
+ constructor(totalSteps, planFileName) {
14
+ this.totalSteps = totalSteps;
15
+ this.planFileName = planFileName;
16
+ this.completedCount = 0;
17
+ this.blockedCount = 0;
18
+ this.startTime = Date.now();
19
+ this.stepCompletionTimes = []; // Duration of each completed step
20
+ this.stepStartTimes = new Map(); // stepId -> startTime
21
+ }
22
+
23
+ /**
24
+ * Initialize counts from existing plan state (for resume mode)
25
+ * @param {Object} plan - The loaded plan object
26
+ */
27
+ initializeFromPlan(plan) {
28
+ for (const step of plan.steps) {
29
+ if (step.status === "complete") {
30
+ this.completedCount++;
31
+ } else if (step.status === "blocked") {
32
+ this.blockedCount++;
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Record when step(s) begin execution
39
+ * @param {string[]} stepIds - Array of step IDs starting
40
+ */
41
+ recordStart(stepIds) {
42
+ const now = Date.now();
43
+ for (const stepId of stepIds) {
44
+ this.stepStartTimes.set(stepId, now);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Record a step completing successfully
50
+ * @param {string} stepId - The completed step ID
51
+ */
52
+ recordComplete(stepId) {
53
+ this.completedCount++;
54
+ const startTime = this.stepStartTimes.get(stepId);
55
+ if (startTime) {
56
+ const duration = Date.now() - startTime;
57
+ this.stepCompletionTimes.push(duration);
58
+ this.stepStartTimes.delete(stepId);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Record a step becoming blocked
64
+ * @param {string} stepId - The blocked step ID
65
+ */
66
+ recordBlocked(stepId) {
67
+ this.blockedCount++;
68
+ this.stepStartTimes.delete(stepId);
69
+ }
70
+
71
+ /**
72
+ * Get elapsed time since tracking started
73
+ * @returns {number} Elapsed time in milliseconds
74
+ */
75
+ getElapsed() {
76
+ return Date.now() - this.startTime;
77
+ }
78
+
79
+ /**
80
+ * Get estimated time remaining based on average step duration
81
+ * @returns {number|null} Estimated remaining time in ms, or null if not calculable
82
+ */
83
+ getEstimatedRemaining() {
84
+ if (this.stepCompletionTimes.length === 0) {
85
+ return null; // No data yet
86
+ }
87
+
88
+ const avgStepTime =
89
+ this.stepCompletionTimes.reduce((a, b) => a + b, 0) /
90
+ this.stepCompletionTimes.length;
91
+
92
+ const remainingSteps =
93
+ this.totalSteps - this.completedCount - this.blockedCount;
94
+
95
+ return avgStepTime * remainingSteps;
96
+ }
97
+
98
+ /**
99
+ * Format duration in human-readable format
100
+ * @param {number} ms - Duration in milliseconds
101
+ * @returns {string} Formatted string like "2m 30s" or "1h 5m"
102
+ */
103
+ formatDuration(ms) {
104
+ if (ms < 1000) {
105
+ return "<1s";
106
+ }
107
+
108
+ const seconds = Math.floor(ms / 1000);
109
+ const minutes = Math.floor(seconds / 60);
110
+ const hours = Math.floor(minutes / 60);
111
+
112
+ if (hours > 0) {
113
+ const remainingMinutes = minutes % 60;
114
+ return `${hours}h ${remainingMinutes}m`;
115
+ } else if (minutes > 0) {
116
+ const remainingSeconds = seconds % 60;
117
+ return `${minutes}m ${remainingSeconds}s`;
118
+ } else {
119
+ return `${seconds}s`;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Get completion percentage
125
+ * @returns {number} Percentage complete (0-100)
126
+ */
127
+ getPercentComplete() {
128
+ const processed = this.completedCount + this.blockedCount;
129
+ return Math.round((processed / this.totalSteps) * 100);
130
+ }
131
+
132
+ /**
133
+ * Log the start of plan execution
134
+ */
135
+ logStart() {
136
+ const initialComplete = this.completedCount;
137
+ const initialBlocked = this.blockedCount;
138
+ const pending = this.totalSteps - initialComplete - initialBlocked;
139
+
140
+ console.log(`[Progress] Starting plan: ${this.planFileName}`);
141
+ console.log(
142
+ `[Progress] Total steps: ${this.totalSteps} (${pending} pending, ${initialComplete} complete, ${initialBlocked} blocked)`
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Log when step(s) start
148
+ * @param {string[]} stepIds - Array of step IDs starting
149
+ */
150
+ logStepStart(stepIds) {
151
+ this.recordStart(stepIds);
152
+ const processed = this.completedCount + this.blockedCount;
153
+ const stepList = stepIds.join(", ");
154
+
155
+ if (stepIds.length === 1) {
156
+ console.log(
157
+ `[Progress] Starting ${stepList} (${processed + 1} of ${this.totalSteps})`
158
+ );
159
+ } else {
160
+ console.log(
161
+ `[Progress] Starting ${stepIds.length} steps: ${stepList} (${processed + 1}-${processed + stepIds.length} of ${this.totalSteps})`
162
+ );
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Log current progress after step completion
168
+ */
169
+ logProgress() {
170
+ const processed = this.completedCount + this.blockedCount;
171
+ const percent = this.getPercentComplete();
172
+ const elapsed = this.formatDuration(this.getElapsed());
173
+ const eta = this.getEstimatedRemaining();
174
+
175
+ let progressLine = `[Progress] ${processed}/${this.totalSteps} steps (${percent}%) | Elapsed: ${elapsed}`;
176
+
177
+ if (eta !== null) {
178
+ progressLine += ` | ETA: ${this.formatDuration(eta)}`;
179
+ } else {
180
+ progressLine += " | ETA: Calculating...";
181
+ }
182
+
183
+ console.log(progressLine);
184
+ }
185
+
186
+ /**
187
+ * Log final summary when plan execution ends
188
+ */
189
+ logSummary() {
190
+ const totalTime = this.formatDuration(this.getElapsed());
191
+ const avgTime =
192
+ this.stepCompletionTimes.length > 0
193
+ ? this.formatDuration(
194
+ this.stepCompletionTimes.reduce((a, b) => a + b, 0) /
195
+ this.stepCompletionTimes.length
196
+ )
197
+ : "N/A";
198
+
199
+ console.log(`[Progress] === Summary ===`);
200
+ console.log(
201
+ `[Progress] Total: ${this.totalSteps} steps (${this.completedCount} complete, ${this.blockedCount} blocked)`
202
+ );
203
+ console.log(`[Progress] Time: ${totalTime}`);
204
+ console.log(`[Progress] Avg step time: ${avgTime}`);
205
+ }
206
+ }
207
+
208
+ module.exports = { ProgressTracker };
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Report Format Definition (Single Source of Truth)
3
+ *
4
+ * IMPORTANT: Any changes to the structure or instructions below MUST be manually
5
+ * reflected in: agent/skills/orrery-report/SKILL.md
6
+ */
7
+
8
+ // 1. Define the Expected Shape (The Contract)
9
+ const REPORT_FIELDS = {
10
+ stepId: "The ID of the step being executed (string)",
11
+ status: "One of: 'complete', 'blocked'",
12
+ summary: "A concise summary of what was done (string)",
13
+ artifacts: "Array of file paths created or modified (string[])",
14
+ testResults: "Optional: Test outcome summary (string, e.g., '2/2 passed')",
15
+ blockedReason: "Required if status is 'blocked' (string)",
16
+ commitMessage:
17
+ "A meaningful commit message (string, e.g., 'feat: add login endpoint')"
18
+ };
19
+
20
+ // 2. Generate the Instruction for the Agent
21
+ function getFormatInstructions() {
22
+ return `## Output Contract
23
+
24
+ Output one JSON object per step to stdout. You may output multiple objects if processing multiple steps.
25
+
26
+ Success Example:
27
+ {"stepId": "step-1", "status": "complete", "summary": "Implemented login", "artifacts": ["src/auth.js"], "testResults": "5/5 passed", "commitMessage": "feat: add user authentication"}
28
+
29
+ Blocked Example:
30
+ {"stepId": "step-2", "status": "blocked", "blockedReason": "API is down", "summary": "Could not verify"}
31
+
32
+ Rules:
33
+ - JSON must be valid and on a single line.
34
+ - Do not wrap in markdown blocks (just raw JSON).
35
+ - Each step result must be a separate JSON object.`;
36
+ }
37
+
38
+ // 3. Validate/Normalize the incoming data
39
+ function validateAgentOutput(data) {
40
+ if (!data || typeof data !== "object") {
41
+ throw new Error("Report data must be an object");
42
+ }
43
+
44
+ // Required fields
45
+ if (!data.stepId || typeof data.stepId !== "string") {
46
+ throw new Error("Report missing required field: stepId (string)");
47
+ }
48
+
49
+ if (!data.status || !["complete", "blocked"].includes(data.status)) {
50
+ throw new Error(
51
+ `Invalid status: ${data.status}. Must be 'complete' or 'blocked'`
52
+ );
53
+ }
54
+
55
+ // Conditional requirements
56
+ if (data.status === "blocked" && !data.blockedReason) {
57
+ throw new Error(
58
+ "Report with status 'blocked' must include 'blockedReason'"
59
+ );
60
+ }
61
+
62
+ // Normalize optional fields
63
+ return {
64
+ stepId: data.stepId,
65
+ status: data.status,
66
+ summary:
67
+ data.summary ||
68
+ (data.status === "blocked" ? "Step blocked" : "Step completed"),
69
+ artifacts: Array.isArray(data.artifacts) ? data.artifacts : [],
70
+ testResults: data.testResults || null,
71
+ blockedReason: data.blockedReason || null,
72
+ commitMessage: data.commitMessage || `feat: complete step ${data.stepId}`
73
+ };
74
+ }
75
+
76
+ module.exports = {
77
+ REPORT_FIELDS,
78
+ getFormatInstructions,
79
+ validateAgentOutput
80
+ };