@sulhadin/orchestrator 3.0.0-beta.8 → 3.0.0-beta.9
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/bin/index.js +123 -3
- package/bin/merge-config.test.js +135 -0
- package/package.json +2 -1
- package/template/.claude/agents/conductor.md +4 -4
- package/template/.orchestra/config.yml +12 -0
package/bin/index.js
CHANGED
|
@@ -95,6 +95,123 @@ function rmDirRecursive(dir) {
|
|
|
95
95
|
fs.rmdirSync(dir);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Simple YAML config merge: adds new keys from template, preserves user values.
|
|
100
|
+
* Works with flat and one-level nested YAML (no deep nesting needed for config.yml).
|
|
101
|
+
*/
|
|
102
|
+
function mergeConfigYaml(userContent, templateContent) {
|
|
103
|
+
const userLines = userContent.split("\n");
|
|
104
|
+
const templateLines = templateContent.split("\n");
|
|
105
|
+
|
|
106
|
+
// Parse YAML into { key: value } with awareness of sections
|
|
107
|
+
function parseYaml(lines) {
|
|
108
|
+
const result = {};
|
|
109
|
+
let currentSection = null;
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
// Skip comments and empty lines for parsing, but we'll preserve them
|
|
112
|
+
if (line.trim() === "" || line.trim().startsWith("#")) continue;
|
|
113
|
+
// Top-level section (no indent)
|
|
114
|
+
const sectionMatch = line.match(/^(\w[\w_-]*):\s*$/);
|
|
115
|
+
if (sectionMatch) {
|
|
116
|
+
currentSection = sectionMatch[1];
|
|
117
|
+
result[currentSection] = result[currentSection] || {};
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// Nested key (2-space indent)
|
|
121
|
+
const nestedMatch = line.match(/^ (\w[\w_-]*):\s*(.+)?$/);
|
|
122
|
+
if (nestedMatch && currentSection) {
|
|
123
|
+
const key = nestedMatch[1];
|
|
124
|
+
const value = nestedMatch[2] || "";
|
|
125
|
+
if (!result[currentSection]) result[currentSection] = {};
|
|
126
|
+
// Check if this is a sub-section (value is empty, next lines are indented more)
|
|
127
|
+
if (!value) {
|
|
128
|
+
result[currentSection][key] = result[currentSection][key] || {};
|
|
129
|
+
} else {
|
|
130
|
+
result[currentSection][key] = value;
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// Deeper nested key (4-space indent)
|
|
135
|
+
const deepMatch = line.match(/^ (\w[\w_-]*):\s*(.+)$/);
|
|
136
|
+
if (deepMatch && currentSection) {
|
|
137
|
+
// Find parent key (last nested key without value)
|
|
138
|
+
const parentKeys = Object.keys(result[currentSection] || {});
|
|
139
|
+
const parentKey = parentKeys.reverse().find(
|
|
140
|
+
(k) => typeof result[currentSection][k] === "object"
|
|
141
|
+
);
|
|
142
|
+
if (parentKey) {
|
|
143
|
+
result[currentSection][parentKey][deepMatch[1]] = deepMatch[2];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const userParsed = parseYaml(userLines);
|
|
151
|
+
const templateParsed = parseYaml(templateLines);
|
|
152
|
+
|
|
153
|
+
// Build merged config: start with template (has comments + structure), fill with user values
|
|
154
|
+
const merged = [];
|
|
155
|
+
let currentSection = null;
|
|
156
|
+
let currentSubSection = null;
|
|
157
|
+
|
|
158
|
+
for (const line of templateLines) {
|
|
159
|
+
const sectionMatch = line.match(/^(\w[\w_-]*):\s*$/);
|
|
160
|
+
if (sectionMatch) {
|
|
161
|
+
currentSection = sectionMatch[1];
|
|
162
|
+
currentSubSection = null;
|
|
163
|
+
merged.push(line);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const nestedMatch = line.match(/^ (\w[\w_-]*):\s*(.+)?$/);
|
|
168
|
+
if (nestedMatch && currentSection) {
|
|
169
|
+
const key = nestedMatch[1];
|
|
170
|
+
const templateValue = nestedMatch[2] || "";
|
|
171
|
+
|
|
172
|
+
if (!templateValue) {
|
|
173
|
+
// Sub-section header (e.g., "models:")
|
|
174
|
+
currentSubSection = key;
|
|
175
|
+
merged.push(line);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Has a value → this is a flat key, reset sub-section
|
|
180
|
+
currentSubSection = null;
|
|
181
|
+
|
|
182
|
+
// Check if user has this key
|
|
183
|
+
const userSection = userParsed[currentSection];
|
|
184
|
+
if (userSection && userSection[key] !== undefined && typeof userSection[key] !== "object") {
|
|
185
|
+
merged.push(` ${key}: ${userSection[key]}`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
// New key — use template value
|
|
189
|
+
merged.push(line);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const deepMatch = line.match(/^ (\w[\w_-]*):\s*(.+)$/);
|
|
194
|
+
if (deepMatch && currentSection && currentSubSection) {
|
|
195
|
+
const key = deepMatch[1];
|
|
196
|
+
const userSection = userParsed[currentSection];
|
|
197
|
+
if (userSection && typeof userSection[currentSubSection] === "object") {
|
|
198
|
+
const userValue = userSection[currentSubSection][key];
|
|
199
|
+
if (userValue !== undefined) {
|
|
200
|
+
merged.push(` ${key}: ${userValue}`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// New key — use template value
|
|
205
|
+
merged.push(line);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
merged.push(line);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return merged.join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
98
215
|
function extractOrchestraSection(content) {
|
|
99
216
|
const startIdx = content.indexOf(ORCHESTRA_SECTION_START);
|
|
100
217
|
if (startIdx === -1) return null;
|
|
@@ -302,11 +419,14 @@ function run() {
|
|
|
302
419
|
console.log(" [+] Restored knowledge.md");
|
|
303
420
|
}
|
|
304
421
|
|
|
305
|
-
//
|
|
422
|
+
// Merge config.yml: new template keys added, user values preserved
|
|
306
423
|
if (hasConfig && fs.existsSync(configBackup)) {
|
|
307
|
-
fs.
|
|
424
|
+
const userConfig = fs.readFileSync(configBackup, "utf-8");
|
|
425
|
+
const templateConfig = fs.readFileSync(path.join(orchestraDest, "config.yml"), "utf-8");
|
|
426
|
+
const mergedConfig = mergeConfigYaml(userConfig, templateConfig);
|
|
427
|
+
fs.writeFileSync(path.join(orchestraDest, "config.yml"), mergedConfig);
|
|
308
428
|
fs.unlinkSync(configBackup);
|
|
309
|
-
console.log(" [+]
|
|
429
|
+
console.log(" [+] Merged config.yml (user values preserved, new keys added)");
|
|
310
430
|
}
|
|
311
431
|
} else {
|
|
312
432
|
// ── Fresh install ──
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
const { describe, it } = require("node:test");
|
|
2
|
+
const assert = require("node:assert");
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
// Extract mergeConfigYaml from index.js
|
|
7
|
+
const src = fs.readFileSync(path.join(__dirname, "index.js"), "utf-8");
|
|
8
|
+
const match = src.match(/function mergeConfigYaml\([\s\S]*?^}/m);
|
|
9
|
+
eval(match[0]);
|
|
10
|
+
|
|
11
|
+
const userConfig = `pipeline:
|
|
12
|
+
models:
|
|
13
|
+
quick: haiku
|
|
14
|
+
standard: sonnet
|
|
15
|
+
complex: opus
|
|
16
|
+
rfc_approval: skip
|
|
17
|
+
push_approval: auto
|
|
18
|
+
review: required
|
|
19
|
+
parallel: disabled
|
|
20
|
+
|
|
21
|
+
thresholds:
|
|
22
|
+
re_review_lines: 50
|
|
23
|
+
phase_time_limit: 20
|
|
24
|
+
phase_tool_limit: 40
|
|
25
|
+
stuck_retry_limit: 5
|
|
26
|
+
|
|
27
|
+
verification:
|
|
28
|
+
typecheck: "yarn tsc --noEmit"
|
|
29
|
+
test: "yarn test"
|
|
30
|
+
lint: "yarn lint"
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
const templateConfig = `# Orchestra Configuration
|
|
34
|
+
# Customize pipeline behavior, thresholds, and verification commands.
|
|
35
|
+
|
|
36
|
+
pipeline:
|
|
37
|
+
# Model selection per phase complexity
|
|
38
|
+
models:
|
|
39
|
+
trivial: haiku
|
|
40
|
+
quick: sonnet
|
|
41
|
+
standard: sonnet
|
|
42
|
+
complex: opus
|
|
43
|
+
rfc_approval: required
|
|
44
|
+
push_approval: required
|
|
45
|
+
review: required
|
|
46
|
+
parallel: disabled
|
|
47
|
+
default_pipeline: full
|
|
48
|
+
default_complexity: standard
|
|
49
|
+
max_rfc_rounds: 3
|
|
50
|
+
|
|
51
|
+
thresholds:
|
|
52
|
+
milestone_lock_timeout: 120
|
|
53
|
+
re_review_lines: 30
|
|
54
|
+
phase_time_limit: 15
|
|
55
|
+
phase_tool_limit: 40
|
|
56
|
+
stuck_retry_limit: 3
|
|
57
|
+
|
|
58
|
+
verification:
|
|
59
|
+
typecheck: "npx tsc --noEmit"
|
|
60
|
+
test: "npm test"
|
|
61
|
+
lint: "npm run lint"
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
describe("mergeConfigYaml", () => {
|
|
65
|
+
const result = mergeConfigYaml(userConfig, templateConfig);
|
|
66
|
+
|
|
67
|
+
describe("new template keys are added", () => {
|
|
68
|
+
it("adds trivial model tier", () => {
|
|
69
|
+
assert.ok(result.includes("trivial: haiku"));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("adds default_pipeline", () => {
|
|
73
|
+
assert.ok(result.includes("default_pipeline: full"));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("adds default_complexity", () => {
|
|
77
|
+
assert.ok(result.includes("default_complexity: standard"));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("adds max_rfc_rounds", () => {
|
|
81
|
+
assert.ok(result.includes("max_rfc_rounds: 3"));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("adds milestone_lock_timeout", () => {
|
|
85
|
+
assert.ok(result.includes("milestone_lock_timeout: 120"));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("user values are preserved", () => {
|
|
90
|
+
it("keeps user models.quick value", () => {
|
|
91
|
+
assert.ok(result.includes("quick: haiku"));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("keeps user rfc_approval", () => {
|
|
95
|
+
assert.ok(result.includes("rfc_approval: skip"));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("keeps user push_approval", () => {
|
|
99
|
+
assert.ok(result.includes("push_approval: auto"));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("keeps user re_review_lines", () => {
|
|
103
|
+
assert.ok(result.includes("re_review_lines: 50"));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("keeps user phase_time_limit", () => {
|
|
107
|
+
assert.ok(result.includes("phase_time_limit: 20"));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("keeps user stuck_retry_limit", () => {
|
|
111
|
+
assert.ok(result.includes("stuck_retry_limit: 5"));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("keeps user verification commands", () => {
|
|
115
|
+
assert.ok(result.includes('typecheck: "yarn tsc --noEmit"'));
|
|
116
|
+
assert.ok(result.includes('test: "yarn test"'));
|
|
117
|
+
assert.ok(result.includes('lint: "yarn lint"'));
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("template structure is preserved", () => {
|
|
122
|
+
it("keeps comments from template", () => {
|
|
123
|
+
assert.ok(result.includes("# Orchestra Configuration"));
|
|
124
|
+
assert.ok(result.includes("# Model selection per phase complexity"));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("maintains section order", () => {
|
|
128
|
+
const pipelineIdx = result.indexOf("pipeline:");
|
|
129
|
+
const thresholdsIdx = result.indexOf("thresholds:");
|
|
130
|
+
const verificationIdx = result.indexOf("verification:");
|
|
131
|
+
assert.ok(pipelineIdx < thresholdsIdx);
|
|
132
|
+
assert.ok(thresholdsIdx < verificationIdx);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sulhadin/orchestrator",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.9",
|
|
4
4
|
"description": "AI Team Orchestration System — multi-role coordination for Claude Code",
|
|
5
5
|
"bin": "bin/index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
+
"test": "node --test bin/**/*.test.js",
|
|
7
8
|
"template": "node bin/build-template.js",
|
|
8
9
|
"prepare": "husky"
|
|
9
10
|
},
|
|
@@ -43,13 +43,13 @@ Read `Complexity` from milestone.md + `pipeline` from config.yml:
|
|
|
43
43
|
| `standard` | Phases → Review → Push |
|
|
44
44
|
| `full` | Architect → Phases → Review → Push |
|
|
45
45
|
|
|
46
|
-
Default: `
|
|
46
|
+
Default: config.yml `pipeline.default_pipeline` (default `full`).
|
|
47
47
|
|
|
48
48
|
## Milestone Lock
|
|
49
49
|
|
|
50
50
|
Before starting a milestone:
|
|
51
51
|
1. Check milestone.md for `Locked-By` field
|
|
52
|
-
2. If locked and <
|
|
52
|
+
2. If locked and < config.yml `thresholds.milestone_lock_timeout` minutes → skip this milestone
|
|
53
53
|
3. If no lock or stale → write `Locked-By: {timestamp}`
|
|
54
54
|
4. On completion or failure → remove `Locked-By`
|
|
55
55
|
|
|
@@ -64,7 +64,7 @@ For each phase:
|
|
|
64
64
|
- Read phase file — extract role, skills, scope, acceptance criteria, depends_on
|
|
65
65
|
- Check phase status — skip if `done`, resume if `in-progress`
|
|
66
66
|
- Verify dependencies — all `depends_on` phases must be `done`
|
|
67
|
-
- Select model: read `complexity` from phase file
|
|
67
|
+
- Select model: read `complexity` from phase file (default: config.yml `pipeline.default_complexity`), map via `pipeline.models:`
|
|
68
68
|
|
|
69
69
|
### 2. Pre-read & Compose Prompt (Conductor does this)
|
|
70
70
|
|
|
@@ -184,7 +184,7 @@ Read gate behavior from config.yml:
|
|
|
184
184
|
|
|
185
185
|
## Rejection Flow
|
|
186
186
|
|
|
187
|
-
- **RFC Rejected:** Ask feedback → architect revises → re-submit (max
|
|
187
|
+
- **RFC Rejected:** Ask feedback → architect revises → re-submit (max config.yml `pipeline.max_rfc_rounds`).
|
|
188
188
|
- **Push Rejected:** Ask feedback → create fix phase → re-submit.
|
|
189
189
|
|
|
190
190
|
## Milestone Completion
|
|
@@ -25,7 +25,19 @@ pipeline:
|
|
|
25
25
|
# When enabled, phases with depends_on: [] run in parallel
|
|
26
26
|
parallel: disabled
|
|
27
27
|
|
|
28
|
+
# Default pipeline when milestone Complexity is missing
|
|
29
|
+
default_pipeline: full # quick | standard | full
|
|
30
|
+
|
|
31
|
+
# Default phase complexity when not set by PM
|
|
32
|
+
default_complexity: standard # trivial | quick | standard | complex
|
|
33
|
+
|
|
34
|
+
# Max RFC rejection rounds before escalating to user
|
|
35
|
+
max_rfc_rounds: 3
|
|
36
|
+
|
|
28
37
|
thresholds:
|
|
38
|
+
# Milestone lock timeout in minutes (stale locks are ignored)
|
|
39
|
+
milestone_lock_timeout: 120
|
|
40
|
+
|
|
29
41
|
# Fix cycle: re-review if fix exceeds this many lines
|
|
30
42
|
re_review_lines: 30
|
|
31
43
|
|