@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 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
- // Restore config.yml (user's customizations take priority)
422
+ // Merge config.yml: new template keys added, user values preserved
306
423
  if (hasConfig && fs.existsSync(configBackup)) {
307
- fs.copyFileSync(configBackup, path.join(orchestraDest, "config.yml"));
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(" [+] Restored config.yml (user customizations preserved)");
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.8",
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: `full` if Complexity missing.
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 < 2 hours old → skip this milestone
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, map via config.yml `pipeline.models:` (default: `standard`)
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 3 rounds).
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