chati-dev 2.0.4 → 2.0.5
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/framework/hooks/model-governance.js +38 -18
- package/framework/orchestrator/chati.md +125 -55
- package/package.json +1 -1
- package/src/terminal/handoff-parser.js +127 -0
- package/src/terminal/index.js +9 -0
- package/src/terminal/prompt-builder.js +313 -0
- package/src/terminal/run-agent.js +248 -0
- package/src/terminal/run-parallel.js +238 -0
- package/src/terminal/spawner.js +29 -18
|
@@ -5,37 +5,57 @@
|
|
|
5
5
|
* Validates that the model being used matches the agent's assignment.
|
|
6
6
|
* Constitution Article XVI enforcement.
|
|
7
7
|
*
|
|
8
|
-
* Model assignments (from
|
|
9
|
-
* - orchestrator: opus
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
13
|
-
* -
|
|
14
|
-
* -
|
|
15
|
-
* -
|
|
8
|
+
* Model assignments (from orchestrator spec — Model Map):
|
|
9
|
+
* - orchestrator: sonnet | upgrade: opus if complex routing
|
|
10
|
+
* - greenfield-wu: haiku | upgrade: sonnet if multi-stack
|
|
11
|
+
* - brownfield-wu: opus | no downgrade
|
|
12
|
+
* - brief: sonnet | upgrade: opus if 10+ integrations
|
|
13
|
+
* - detail: opus | no downgrade
|
|
14
|
+
* - architect: opus | no downgrade
|
|
15
|
+
* - ux: sonnet | upgrade: opus if design system from scratch
|
|
16
|
+
* - phases: sonnet | upgrade: opus if 20+ requirements
|
|
17
|
+
* - tasks: sonnet | upgrade: opus if 50+ tasks
|
|
18
|
+
* - qa-planning: opus | no downgrade
|
|
19
|
+
* - qa-implementation: opus | no downgrade
|
|
20
|
+
* - dev: opus | no downgrade
|
|
21
|
+
* - devops: sonnet | upgrade: opus if multi-env
|
|
16
22
|
*
|
|
17
|
-
* This hook is advisory — it warns but does not block.
|
|
23
|
+
* This hook is advisory in IDE mode — it warns but does not block.
|
|
18
24
|
*/
|
|
19
25
|
|
|
20
26
|
import { existsSync, readFileSync } from 'fs';
|
|
21
27
|
import { join } from 'path';
|
|
22
28
|
|
|
23
29
|
const AGENT_MODELS = {
|
|
24
|
-
orchestrator: '
|
|
25
|
-
'greenfield-wu': '
|
|
26
|
-
'brownfield-wu': '
|
|
30
|
+
orchestrator: 'sonnet',
|
|
31
|
+
'greenfield-wu': 'haiku',
|
|
32
|
+
'brownfield-wu': 'opus',
|
|
27
33
|
brief: 'sonnet',
|
|
28
|
-
detail: '
|
|
29
|
-
architect: '
|
|
34
|
+
detail: 'opus',
|
|
35
|
+
architect: 'opus',
|
|
30
36
|
ux: 'sonnet',
|
|
31
37
|
phases: 'sonnet',
|
|
32
38
|
tasks: 'sonnet',
|
|
33
|
-
'qa-planning': '
|
|
34
|
-
'qa-implementation': '
|
|
35
|
-
dev: '
|
|
39
|
+
'qa-planning': 'opus',
|
|
40
|
+
'qa-implementation': 'opus',
|
|
41
|
+
dev: 'opus',
|
|
36
42
|
devops: 'sonnet',
|
|
37
43
|
};
|
|
38
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Upgrade conditions per agent. When context matches, the model
|
|
47
|
+
* should be upgraded to the specified target.
|
|
48
|
+
*/
|
|
49
|
+
const UPGRADE_CONDITIONS = {
|
|
50
|
+
orchestrator: { to: 'opus', condition: 'complex routing or deviation handling' },
|
|
51
|
+
'greenfield-wu': { to: 'sonnet', condition: 'multi-stack or enterprise' },
|
|
52
|
+
brief: { to: 'opus', condition: '10+ integrations' },
|
|
53
|
+
ux: { to: 'opus', condition: 'design system from scratch' },
|
|
54
|
+
phases: { to: 'opus', condition: '20+ requirements' },
|
|
55
|
+
tasks: { to: 'opus', condition: '50+ tasks' },
|
|
56
|
+
devops: { to: 'opus', condition: 'multi-environment or IaC' },
|
|
57
|
+
};
|
|
58
|
+
|
|
39
59
|
function getCurrentAgent(projectDir) {
|
|
40
60
|
const sessionPath = join(projectDir, '.chati', 'session.yaml');
|
|
41
61
|
if (!existsSync(sessionPath)) return null;
|
|
@@ -71,6 +91,6 @@ async function main() {
|
|
|
71
91
|
}
|
|
72
92
|
}
|
|
73
93
|
|
|
74
|
-
export { AGENT_MODELS, getCurrentAgent };
|
|
94
|
+
export { AGENT_MODELS, UPGRADE_CONDITIONS, getCurrentAgent };
|
|
75
95
|
|
|
76
96
|
main();
|
|
@@ -158,57 +158,137 @@ brownfield-wu -> Brief -> Architect -> Detail -> UX -> Phases -> Tasks -> QA-Pla
|
|
|
158
158
|
| devops | chati.dev/agents/deploy/devops.md |
|
|
159
159
|
|
|
160
160
|
### Transition Logic
|
|
161
|
+
|
|
161
162
|
```
|
|
162
163
|
When an agent completes (score >= 95%):
|
|
163
164
|
1. Agent generates handoff at chati.dev/artifacts/handoffs/{agent-name}-handoff.md
|
|
164
165
|
2. Agent updates session.yaml (status: completed, score, completed_at)
|
|
165
166
|
3. Agent updates CLAUDE.md with current state
|
|
166
167
|
4. Orchestrator identifies next agent from pipeline
|
|
168
|
+
5. Update session.yaml: current_agent = next_agent
|
|
169
|
+
6. Update project.state if crossing macro-phase boundary:
|
|
170
|
+
- WU through QA-Planning = clarity
|
|
171
|
+
- Dev + QA-Implementation = build
|
|
172
|
+
- Final validation = validate
|
|
173
|
+
- DevOps = deploy
|
|
174
|
+
7. Activate agent using Hybrid Activation Protocol (see below)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Hybrid Activation Protocol
|
|
180
|
+
|
|
181
|
+
The orchestrator uses TWO activation modes depending on the agent type:
|
|
182
|
+
|
|
183
|
+
### Interactive Agents (run IN-CONVERSATION)
|
|
184
|
+
|
|
185
|
+
These agents require human interaction and run in the same terminal as the orchestrator:
|
|
186
|
+
- **greenfield-wu** — needs user description of the project
|
|
187
|
+
- **brownfield-wu** — needs user guidance on codebase
|
|
188
|
+
- **brief** — needs iterative requirement extraction with user
|
|
189
|
+
|
|
190
|
+
For interactive agents:
|
|
191
|
+
```
|
|
192
|
+
1. Read the agent's .md file from the Agent Location Map
|
|
193
|
+
2. Display model recommendation (see Model Map below)
|
|
194
|
+
3. Load agent context into the conversation
|
|
195
|
+
4. User interacts with the agent directly
|
|
196
|
+
5. Agent completes, generates handoff, orchestrator continues
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Autonomous Agents (run in SEPARATE TERMINALS)
|
|
200
|
+
|
|
201
|
+
All other agents run in separate Claude Code processes with the correct model:
|
|
202
|
+
- **detail**, **architect**, **ux**, **phases**, **tasks**
|
|
203
|
+
- **qa-planning**, **dev**, **qa-implementation**, **devops**
|
|
204
|
+
|
|
205
|
+
For autonomous agents:
|
|
206
|
+
```
|
|
207
|
+
1. Use the Bash tool to spawn the agent in a separate terminal:
|
|
208
|
+
|
|
209
|
+
node packages/chati-dev/src/terminal/run-agent.js \
|
|
210
|
+
--agent {agent_name} \
|
|
211
|
+
--task-id {primary_task_id} \
|
|
212
|
+
--project-dir {absolute_project_path} \
|
|
213
|
+
--previous-agent {previous_agent_name} \
|
|
214
|
+
--timeout 600000
|
|
215
|
+
|
|
216
|
+
2. Wait for the JSON output
|
|
217
|
+
|
|
218
|
+
3. Parse the result:
|
|
219
|
+
- If "status": "complete" → Save handoff, continue to next agent
|
|
220
|
+
- If "status": "needs_input" → Read "needs_input_question", ask the user,
|
|
221
|
+
then re-run with --additional-context "{user_response}"
|
|
222
|
+
- If "status": "error" → Apply Recovery Protocol (retry up to 2 times)
|
|
223
|
+
- If retries exhausted → Fall back to in-conversation activation
|
|
224
|
+
|
|
225
|
+
4. Update session.yaml with completion data
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Parallel Group Execution
|
|
167
229
|
|
|
168
230
|
╔══════════════════════════════════════════════════════════════╗
|
|
169
|
-
║
|
|
170
|
-
║ ║
|
|
171
|
-
║ Before activating next agent, check parallel eligibility: ║
|
|
231
|
+
║ PARALLELIZATION CHECK (MANDATORY — DO NOT SKIP) ║
|
|
172
232
|
║ ║
|
|
173
233
|
║ GROUP 1 — Clarity Phase (after Brief completes): ║
|
|
174
234
|
║ Agents: [detail, architect, ux] ║
|
|
175
|
-
║
|
|
176
|
-
║ HUMAN-IN-THE-LOOP: Offer parallel option (recommended) ║
|
|
177
|
-
║ Write scopes: ║
|
|
178
|
-
║ detail → chati.dev/artifacts/2-PRD/ ║
|
|
179
|
-
║ architect → chati.dev/artifacts/3-Architecture/ ║
|
|
180
|
-
║ ux → chati.dev/artifacts/4-UX/ ║
|
|
181
|
-
║ → Merge ALL handoffs before proceeding to Phases ║
|
|
235
|
+
║ ALL run in parallel via separate terminals ║
|
|
182
236
|
║ ║
|
|
183
237
|
║ GROUP 2 — Build Phase (Dev agent tasks): ║
|
|
184
|
-
║
|
|
185
|
-
║
|
|
186
|
-
║ → Tasks with no shared file deps = parallel ║
|
|
187
|
-
║ → Tasks with deps = sequential within their chain ║
|
|
188
|
-
║ → Each terminal gets isolated write scope per task ║
|
|
189
|
-
║ → Merge results after each parallel batch ║
|
|
190
|
-
║ ║
|
|
191
|
-
║ If next agent is NOT in a parallel group: ║
|
|
192
|
-
║ → Continue with sequential activation (step 5) ║
|
|
238
|
+
║ Independent tasks ALWAYS run in parallel ║
|
|
239
|
+
║ Tasks with dependencies = sequential within chain ║
|
|
193
240
|
╚══════════════════════════════════════════════════════════════╝
|
|
194
241
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
242
|
+
When the next agent is part of a parallel group, use:
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
node packages/chati-dev/src/terminal/run-parallel.js \
|
|
246
|
+
--agents detail,architect,ux \
|
|
247
|
+
--task-ids {task_id_detail},{task_id_architect},{task_id_ux} \
|
|
248
|
+
--project-dir {absolute_project_path} \
|
|
249
|
+
--previous-agent brief \
|
|
250
|
+
--timeout 900000
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Parse the consolidated JSON output:
|
|
254
|
+
- If all agents completed → merged handoff ready, continue to next sequential agent
|
|
255
|
+
- If partial failure → present failed agents to user with options:
|
|
256
|
+
1. Retry failed agents only
|
|
257
|
+
2. Continue with partial results
|
|
258
|
+
3. Fall back to sequential execution for failed agents
|
|
259
|
+
|
|
260
|
+
### Sequential Fallback
|
|
261
|
+
|
|
262
|
+
If terminal spawning fails (claude CLI not found, system error, etc.):
|
|
263
|
+
```
|
|
264
|
+
1. Log the failure
|
|
265
|
+
2. Fall back to in-conversation activation (read agent .md file, become agent)
|
|
266
|
+
3. Record in session.yaml:
|
|
267
|
+
terminal_fallback:
|
|
268
|
+
agent: {name}
|
|
269
|
+
reason: "{error_message}"
|
|
270
|
+
timestamp: "{now}"
|
|
271
|
+
4. Continue pipeline normally
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Needs-Input Relay Pattern
|
|
275
|
+
|
|
276
|
+
When a spawned agent needs user input:
|
|
277
|
+
```
|
|
278
|
+
1. Agent returns status: "needs_input" in JSON output
|
|
279
|
+
2. Agent includes question in "needs_input_question" field
|
|
280
|
+
3. Orchestrator reads the question
|
|
281
|
+
4. Orchestrator presents question to user (in their language)
|
|
282
|
+
5. User responds
|
|
283
|
+
6. Orchestrator re-spawns agent with --additional-context "{user_response}"
|
|
284
|
+
7. Repeat until agent completes or max 3 relay cycles
|
|
205
285
|
```
|
|
206
286
|
|
|
207
287
|
---
|
|
208
288
|
|
|
209
289
|
## Model Selection Protocol
|
|
210
290
|
|
|
211
|
-
|
|
291
|
+
Model selection is **enforced by construction**: the orchestrator passes `--model` to the spawned terminal. For interactive agents, the model is recommended to the user.
|
|
212
292
|
|
|
213
293
|
### Model Map (Quick Reference)
|
|
214
294
|
|
|
@@ -227,30 +307,19 @@ Before activating any agent, the orchestrator reads the agent's `Model` field an
|
|
|
227
307
|
| qa-implementation | opus | no downgrade |
|
|
228
308
|
| devops | sonnet | opus if multi-environment or IaC |
|
|
229
309
|
|
|
230
|
-
###
|
|
231
|
-
|
|
232
|
-
```
|
|
233
|
-
1. Read agent's Model field from its .md file
|
|
234
|
-
2. Extract default model and upgrade condition
|
|
235
|
-
3. If upgrade condition exists:
|
|
236
|
-
a. Check session context (project.type, codebase_size, integrations_count, requirements_count)
|
|
237
|
-
b. Check previous agent signals (handoff complexity, discovered tech debt)
|
|
238
|
-
c. If condition matches -> recommend upgraded model
|
|
239
|
-
d. If condition does not match -> use default model
|
|
240
|
-
4. If "no downgrade" -> always use the specified model
|
|
241
|
-
```
|
|
242
|
-
|
|
243
|
-
### Recommendation Message (IDE Mode)
|
|
244
|
-
|
|
245
|
-
When the recommended model differs from the current model, display:
|
|
310
|
+
### Enforcement
|
|
246
311
|
|
|
247
312
|
```
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
313
|
+
For autonomous agents (spawned terminals):
|
|
314
|
+
→ Model is AUTOMATICALLY selected and passed via --model flag
|
|
315
|
+
→ No user intervention needed
|
|
316
|
+
→ Correct model guaranteed by the prompt builder
|
|
251
317
|
|
|
252
|
-
|
|
253
|
-
|
|
318
|
+
For interactive agents (in-conversation):
|
|
319
|
+
→ Display model recommendation to user:
|
|
320
|
+
💡 Model recommendation for {agent_name}: {recommended_model}
|
|
321
|
+
To switch: /model {recommended_model}
|
|
322
|
+
→ Model governance hook provides defense-in-depth validation
|
|
254
323
|
```
|
|
255
324
|
|
|
256
325
|
### Session Logging
|
|
@@ -258,10 +327,11 @@ When the recommended model differs from the current model, display:
|
|
|
258
327
|
```yaml
|
|
259
328
|
# Appended to session.yaml on each agent activation
|
|
260
329
|
model_selections:
|
|
261
|
-
- agent:
|
|
262
|
-
recommended:
|
|
263
|
-
actual: opus
|
|
264
|
-
|
|
330
|
+
- agent: detail
|
|
331
|
+
recommended: opus
|
|
332
|
+
actual: opus
|
|
333
|
+
mode: terminal # terminal | in-conversation
|
|
334
|
+
reason: "no downgrade"
|
|
265
335
|
timestamp: "2026-..."
|
|
266
336
|
```
|
|
267
337
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Parse structured handoff data from spawned agent output.
|
|
3
|
+
*
|
|
4
|
+
* Agents running in separate `claude -p` terminals include a
|
|
5
|
+
* <chati-handoff> block in their output. This module extracts
|
|
6
|
+
* and parses that block so the orchestrator can read the results.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse the <chati-handoff> block from agent stdout.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} output - Full stdout from the agent process
|
|
13
|
+
* @returns {{ found: boolean, handoff: object|null, rawOutput: string }}
|
|
14
|
+
*/
|
|
15
|
+
export function parseAgentOutput(output) {
|
|
16
|
+
if (!output || typeof output !== 'string') {
|
|
17
|
+
return { found: false, handoff: null, rawOutput: '' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const match = output.match(/<chati-handoff>([\s\S]*?)<\/chati-handoff>/);
|
|
21
|
+
if (!match) {
|
|
22
|
+
return { found: false, handoff: null, rawOutput: output };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const content = match[1].trim();
|
|
26
|
+
const handoff = parseHandoffFields(content);
|
|
27
|
+
|
|
28
|
+
return { found: true, handoff, rawOutput: output };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse YAML-like key-value fields from the handoff block content.
|
|
33
|
+
*
|
|
34
|
+
* Supports:
|
|
35
|
+
* scalar: value
|
|
36
|
+
* list:
|
|
37
|
+
* - item1
|
|
38
|
+
* - item2
|
|
39
|
+
* map:
|
|
40
|
+
* key1: value1
|
|
41
|
+
* key2: value2
|
|
42
|
+
*
|
|
43
|
+
* @param {string} content - Content inside <chati-handoff> tags
|
|
44
|
+
* @returns {object} Parsed handoff data
|
|
45
|
+
*/
|
|
46
|
+
function parseHandoffFields(content) {
|
|
47
|
+
const result = {
|
|
48
|
+
status: 'unknown',
|
|
49
|
+
score: null,
|
|
50
|
+
summary: '',
|
|
51
|
+
outputs: [],
|
|
52
|
+
decisions: {},
|
|
53
|
+
blockers: [],
|
|
54
|
+
needs_input_question: null,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const lines = content.split('\n');
|
|
58
|
+
let currentKey = null;
|
|
59
|
+
let currentType = null; // 'list' | 'map'
|
|
60
|
+
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
const trimmed = line.trim();
|
|
63
|
+
if (!trimmed) continue;
|
|
64
|
+
|
|
65
|
+
// Check if this is a list item ( - value)
|
|
66
|
+
const listMatch = trimmed.match(/^-\s+(.+)/);
|
|
67
|
+
if (listMatch && currentKey && currentType === 'list') {
|
|
68
|
+
if (Array.isArray(result[currentKey])) {
|
|
69
|
+
result[currentKey].push(listMatch[1].trim());
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check if this is a map item ( key: value) under a map key
|
|
75
|
+
const mapMatch = trimmed.match(/^(\w[\w_-]*):\s+(.+)/);
|
|
76
|
+
if (mapMatch && currentKey && currentType === 'map') {
|
|
77
|
+
if (typeof result[currentKey] === 'object' && !Array.isArray(result[currentKey])) {
|
|
78
|
+
result[currentKey][mapMatch[1]] = mapMatch[2].trim();
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Top-level key: value
|
|
84
|
+
const kvMatch = trimmed.match(/^(\w[\w_-]*):\s*(.*)/);
|
|
85
|
+
if (kvMatch) {
|
|
86
|
+
const key = kvMatch[1];
|
|
87
|
+
const value = kvMatch[2].trim();
|
|
88
|
+
|
|
89
|
+
// Known list keys
|
|
90
|
+
if (['outputs', 'blockers'].includes(key)) {
|
|
91
|
+
currentKey = key;
|
|
92
|
+
currentType = 'list';
|
|
93
|
+
// If value is inline (not empty), treat as single item
|
|
94
|
+
if (value && value !== '') {
|
|
95
|
+
result[key] = [value];
|
|
96
|
+
currentKey = null;
|
|
97
|
+
currentType = null;
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Known map keys
|
|
103
|
+
if (['decisions'].includes(key)) {
|
|
104
|
+
currentKey = key;
|
|
105
|
+
currentType = 'map';
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Scalar keys
|
|
110
|
+
currentKey = null;
|
|
111
|
+
currentType = null;
|
|
112
|
+
|
|
113
|
+
if (key === 'status') {
|
|
114
|
+
result.status = value || 'unknown';
|
|
115
|
+
} else if (key === 'score') {
|
|
116
|
+
const num = parseInt(value, 10);
|
|
117
|
+
result.score = isNaN(num) ? null : num;
|
|
118
|
+
} else if (key === 'summary') {
|
|
119
|
+
result.summary = value || '';
|
|
120
|
+
} else if (key === 'needs_input_question') {
|
|
121
|
+
result.needs_input_question = value === 'null' || value === '' ? null : value;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return result;
|
|
127
|
+
}
|
package/src/terminal/index.js
CHANGED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Prompt builder for multi-terminal agent execution.
|
|
3
|
+
*
|
|
4
|
+
* Builds a complete, self-contained prompt for spawned agent terminals.
|
|
5
|
+
* When an agent runs in a separate `claude -p` process, it has zero
|
|
6
|
+
* conversation history — the prompt must contain everything: PRISM
|
|
7
|
+
* context, agent definition, previous handoff, write scope, and
|
|
8
|
+
* output format instructions.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { runPrism } from '../context/engine.js';
|
|
14
|
+
import { loadHandoff, formatHandoff } from '../tasks/handoff.js';
|
|
15
|
+
import { getWriteScope } from './isolation.js';
|
|
16
|
+
|
|
17
|
+
// Re-read at import time so the prompt builder always uses the correct map
|
|
18
|
+
// without circular dependency issues (the hook file runs main() on import
|
|
19
|
+
// which writes to stdout — we only need the data, so we inline the map).
|
|
20
|
+
const AGENT_MODELS = {
|
|
21
|
+
orchestrator: 'sonnet',
|
|
22
|
+
'greenfield-wu': 'haiku',
|
|
23
|
+
'brownfield-wu': 'opus',
|
|
24
|
+
brief: 'sonnet',
|
|
25
|
+
detail: 'opus',
|
|
26
|
+
architect: 'opus',
|
|
27
|
+
ux: 'sonnet',
|
|
28
|
+
phases: 'sonnet',
|
|
29
|
+
tasks: 'sonnet',
|
|
30
|
+
'qa-planning': 'opus',
|
|
31
|
+
'qa-implementation': 'opus',
|
|
32
|
+
dev: 'opus',
|
|
33
|
+
devops: 'sonnet',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Map of agent names to their definition file paths (relative to project root).
|
|
38
|
+
*/
|
|
39
|
+
export const AGENT_FILE_MAP = {
|
|
40
|
+
'greenfield-wu': 'chati.dev/agents/clarity/greenfield-wu.md',
|
|
41
|
+
'brownfield-wu': 'chati.dev/agents/clarity/brownfield-wu.md',
|
|
42
|
+
brief: 'chati.dev/agents/clarity/brief.md',
|
|
43
|
+
detail: 'chati.dev/agents/clarity/detail.md',
|
|
44
|
+
architect: 'chati.dev/agents/clarity/architect.md',
|
|
45
|
+
ux: 'chati.dev/agents/clarity/ux.md',
|
|
46
|
+
phases: 'chati.dev/agents/clarity/phases.md',
|
|
47
|
+
tasks: 'chati.dev/agents/clarity/tasks.md',
|
|
48
|
+
'qa-planning': 'chati.dev/agents/quality/qa-planning.md',
|
|
49
|
+
dev: 'chati.dev/agents/build/dev.md',
|
|
50
|
+
'qa-implementation': 'chati.dev/agents/quality/qa-implementation.md',
|
|
51
|
+
devops: 'chati.dev/agents/deploy/devops.md',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {object} PromptBuildConfig
|
|
56
|
+
* @property {string} agent - Agent name (e.g. 'detail')
|
|
57
|
+
* @property {string} taskId - Task identifier
|
|
58
|
+
* @property {string} projectDir - Project root (absolute path)
|
|
59
|
+
* @property {string} [previousAgent] - Agent that produced the handoff
|
|
60
|
+
* @property {string} [workflow] - Active workflow name
|
|
61
|
+
* @property {object} [sessionState] - Parsed session.yaml fields
|
|
62
|
+
* @property {string[]} [writeScope] - Override write scope
|
|
63
|
+
* @property {string} [additionalContext] - Extra context (e.g. user answer to needs_input)
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build a complete, self-contained prompt for a spawned agent terminal.
|
|
68
|
+
*
|
|
69
|
+
* @param {PromptBuildConfig} config
|
|
70
|
+
* @returns {{ prompt: string, model: string, metadata: { agent: string, layers: number, promptSize: number } }}
|
|
71
|
+
*/
|
|
72
|
+
export function buildAgentPrompt(config) {
|
|
73
|
+
if (!config || !config.agent) {
|
|
74
|
+
throw new Error('buildAgentPrompt requires config.agent');
|
|
75
|
+
}
|
|
76
|
+
if (!config.projectDir) {
|
|
77
|
+
throw new Error('buildAgentPrompt requires config.projectDir');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const sections = [];
|
|
81
|
+
|
|
82
|
+
// 1. PRISM Context (L0-L4) — spawned terminals always start FRESH
|
|
83
|
+
const prismResult = buildPrismSection(config);
|
|
84
|
+
if (prismResult.xml) {
|
|
85
|
+
sections.push(prismResult.xml);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2. Agent definition (.md file)
|
|
89
|
+
const agentDef = loadAgentDefinition(config.agent, config.projectDir);
|
|
90
|
+
if (agentDef) {
|
|
91
|
+
sections.push('<!-- AGENT DEFINITION -->\n' + agentDef);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 3. Previous handoff
|
|
95
|
+
const handoffSection = buildHandoffSection(config);
|
|
96
|
+
if (handoffSection) {
|
|
97
|
+
sections.push(handoffSection);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 4. Additional context (user input relay for needs_input)
|
|
101
|
+
if (config.additionalContext) {
|
|
102
|
+
sections.push(
|
|
103
|
+
'<!-- ADDITIONAL CONTEXT -->\n' +
|
|
104
|
+
'## Additional Context from User\n\n' +
|
|
105
|
+
config.additionalContext
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 5. Write scope instructions
|
|
110
|
+
sections.push(buildWriteScopeSection(config));
|
|
111
|
+
|
|
112
|
+
// 6. Session context
|
|
113
|
+
sections.push(buildSessionSection(config));
|
|
114
|
+
|
|
115
|
+
// 7. Output format instructions (handoff template)
|
|
116
|
+
sections.push(buildOutputInstructions());
|
|
117
|
+
|
|
118
|
+
const prompt = sections.join('\n\n---\n\n');
|
|
119
|
+
const model = AGENT_MODELS[config.agent] || 'sonnet';
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
prompt,
|
|
123
|
+
model,
|
|
124
|
+
metadata: {
|
|
125
|
+
agent: config.agent,
|
|
126
|
+
layers: prismResult.layerCount || 0,
|
|
127
|
+
promptSize: prompt.length,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Internal helpers
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Run the PRISM context engine for the agent.
|
|
138
|
+
* Spawned terminals always start FRESH (remainingPercent: 100).
|
|
139
|
+
*/
|
|
140
|
+
function buildPrismSection(config) {
|
|
141
|
+
const domainsDir = join(config.projectDir, 'chati.dev', 'domains');
|
|
142
|
+
|
|
143
|
+
if (!existsSync(domainsDir)) {
|
|
144
|
+
return { xml: null, layerCount: 0 };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const state = config.sessionState || {};
|
|
148
|
+
|
|
149
|
+
// Load handoff data for PRISM L3
|
|
150
|
+
let handoff = {};
|
|
151
|
+
if (config.previousAgent) {
|
|
152
|
+
const loaded = loadHandoff(config.projectDir, config.previousAgent);
|
|
153
|
+
if (loaded.loaded && loaded.handoff) {
|
|
154
|
+
handoff = loaded.handoff;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const result = runPrism({
|
|
159
|
+
domainsDir,
|
|
160
|
+
remainingPercent: 100, // Spawned terminals are always FRESH
|
|
161
|
+
mode: state.project?.state || 'clarity',
|
|
162
|
+
agent: config.agent,
|
|
163
|
+
workflow: config.workflow || null,
|
|
164
|
+
pipelinePosition: config.agent,
|
|
165
|
+
taskId: config.taskId || null,
|
|
166
|
+
handoff,
|
|
167
|
+
artifacts: state.artifacts || [],
|
|
168
|
+
taskCriteria: [],
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
xml: result.xml || null,
|
|
173
|
+
layerCount: result.layerCount || 0,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Load the agent's full .md definition file.
|
|
179
|
+
*/
|
|
180
|
+
function loadAgentDefinition(agent, projectDir) {
|
|
181
|
+
const relativePath = AGENT_FILE_MAP[agent];
|
|
182
|
+
if (!relativePath) return null;
|
|
183
|
+
|
|
184
|
+
const fullPath = join(projectDir, relativePath);
|
|
185
|
+
if (!existsSync(fullPath)) return null;
|
|
186
|
+
|
|
187
|
+
return readFileSync(fullPath, 'utf-8');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build the previous handoff section for the prompt.
|
|
192
|
+
*/
|
|
193
|
+
function buildHandoffSection(config) {
|
|
194
|
+
if (!config.previousAgent) return null;
|
|
195
|
+
|
|
196
|
+
const result = loadHandoff(config.projectDir, config.previousAgent);
|
|
197
|
+
if (!result.loaded || !result.handoff) return null;
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
'<!-- PREVIOUS HANDOFF -->\n' +
|
|
201
|
+
`## Handoff from ${config.previousAgent}\n\n` +
|
|
202
|
+
formatHandoff({
|
|
203
|
+
from: {
|
|
204
|
+
agent: result.handoff.from_agent || config.previousAgent,
|
|
205
|
+
task_id: result.handoff.from_task || 'unknown',
|
|
206
|
+
phase: result.handoff.from_phase || 'unknown',
|
|
207
|
+
},
|
|
208
|
+
to: config.agent,
|
|
209
|
+
timestamp: result.handoff.timestamp || new Date().toISOString(),
|
|
210
|
+
status: result.handoff.status || 'unknown',
|
|
211
|
+
score: result.handoff.score,
|
|
212
|
+
summary: result.handoff.summary || '',
|
|
213
|
+
outputs: result.handoff.outputs || [],
|
|
214
|
+
decisions: result.handoff.decisions || {},
|
|
215
|
+
blockers: result.handoff.blockers || [],
|
|
216
|
+
criteria_met: result.handoff.criteria_met || [],
|
|
217
|
+
criteria_unmet: result.handoff.criteria_unmet || [],
|
|
218
|
+
})
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Build write scope instructions embedded in the prompt.
|
|
224
|
+
* This is the primary enforcement mechanism — env vars alone are not
|
|
225
|
+
* respected by the spawned Claude process.
|
|
226
|
+
*/
|
|
227
|
+
function buildWriteScopeSection(config) {
|
|
228
|
+
const scope = config.writeScope || getWriteScope(config.agent);
|
|
229
|
+
|
|
230
|
+
if (scope.length === 0) {
|
|
231
|
+
return (
|
|
232
|
+
'<!-- WRITE SCOPE -->\n' +
|
|
233
|
+
'## Write Scope (MANDATORY)\n\n' +
|
|
234
|
+
'You have NO write access. This is a read-only execution.\n' +
|
|
235
|
+
'Do NOT use Write, Edit, or any file-modifying tool.'
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const paths = scope.map(p => `- \`${p}\``).join('\n');
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
'<!-- WRITE SCOPE -->\n' +
|
|
243
|
+
'## Write Scope (MANDATORY)\n\n' +
|
|
244
|
+
'You may ONLY write to these paths:\n' +
|
|
245
|
+
paths + '\n\n' +
|
|
246
|
+
'**All other write operations are BLOCKED.** Do NOT attempt to write, edit, or create files outside these paths. ' +
|
|
247
|
+
'Read access is unrestricted — you may read any file in the project.'
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Build session context section.
|
|
253
|
+
*/
|
|
254
|
+
function buildSessionSection(config) {
|
|
255
|
+
const state = config.sessionState || {};
|
|
256
|
+
const project = state.project || {};
|
|
257
|
+
|
|
258
|
+
const lines = [
|
|
259
|
+
'<!-- SESSION CONTEXT -->',
|
|
260
|
+
'## Session Context',
|
|
261
|
+
'',
|
|
262
|
+
`- **Project**: ${project.name || '(unnamed)'}`,
|
|
263
|
+
`- **Type**: ${project.type || 'greenfield'}`,
|
|
264
|
+
`- **Mode**: ${project.state || 'clarity'}`,
|
|
265
|
+
`- **Language**: ${state.language || 'en'} (interaction) / English (artifacts)`,
|
|
266
|
+
`- **User Level**: ${state.user_level || 'auto'}`,
|
|
267
|
+
`- **Execution Mode**: ${state.execution_mode || 'autonomous'}`,
|
|
268
|
+
`- **Your Agent**: ${config.agent}`,
|
|
269
|
+
`- **Your Model**: ${AGENT_MODELS[config.agent] || 'sonnet'}`,
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
return lines.join('\n');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Build output format instructions so the agent produces a parseable handoff.
|
|
277
|
+
*/
|
|
278
|
+
function buildOutputInstructions() {
|
|
279
|
+
return `<!-- OUTPUT INSTRUCTIONS -->
|
|
280
|
+
## Output Instructions (MANDATORY)
|
|
281
|
+
|
|
282
|
+
When you complete your work, you MUST include a structured handoff block at the END of your response.
|
|
283
|
+
This block is how the orchestrator reads your results. Without it, your work cannot be collected.
|
|
284
|
+
|
|
285
|
+
\`\`\`
|
|
286
|
+
<chati-handoff>
|
|
287
|
+
status: complete
|
|
288
|
+
score: 95
|
|
289
|
+
summary: One to three sentence summary of what was accomplished.
|
|
290
|
+
outputs:
|
|
291
|
+
- path/to/artifact1.md
|
|
292
|
+
- path/to/artifact2.md
|
|
293
|
+
decisions:
|
|
294
|
+
key1: value1
|
|
295
|
+
key2: value2
|
|
296
|
+
blockers:
|
|
297
|
+
- Description of any unresolved blocker
|
|
298
|
+
needs_input_question: null
|
|
299
|
+
</chati-handoff>
|
|
300
|
+
\`\`\`
|
|
301
|
+
|
|
302
|
+
### Status values:
|
|
303
|
+
- **complete**: All work finished successfully (score >= 95 required)
|
|
304
|
+
- **partial**: Some work done but not all criteria met
|
|
305
|
+
- **needs_input**: You need information from the user to continue. Set \`needs_input_question\` to your question.
|
|
306
|
+
- **error**: Something went wrong that you cannot recover from
|
|
307
|
+
|
|
308
|
+
### Important:
|
|
309
|
+
- The \`<chati-handoff>\` block MUST appear in your response
|
|
310
|
+
- Score must be 0-100 (95+ to pass quality gate)
|
|
311
|
+
- List ALL artifacts you created/modified in outputs
|
|
312
|
+
- If you need user input, set status to "needs_input" and write your question in needs_input_question`;
|
|
313
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI runner for single-agent terminal execution.
|
|
4
|
+
*
|
|
5
|
+
* Called by the orchestrator via the Bash tool to spawn an agent
|
|
6
|
+
* in a separate Claude Code process with the correct model.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node run-agent.js --agent detail --task-id expand-prd \
|
|
10
|
+
* --project-dir /path/to/project --previous-agent brief \
|
|
11
|
+
* --timeout 600000
|
|
12
|
+
*
|
|
13
|
+
* Outputs JSON to stdout for the orchestrator to parse.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
17
|
+
import { buildAgentPrompt } from './prompt-builder.js';
|
|
18
|
+
import { spawnTerminal } from './spawner.js';
|
|
19
|
+
import { parseAgentOutput } from './handoff-parser.js';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// CLI argument parsing (no external deps)
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv) {
|
|
26
|
+
const args = {};
|
|
27
|
+
for (let i = 2; i < argv.length; i++) {
|
|
28
|
+
const arg = argv[i];
|
|
29
|
+
if (arg.startsWith('--')) {
|
|
30
|
+
const key = arg.slice(2);
|
|
31
|
+
const next = argv[i + 1];
|
|
32
|
+
if (next && !next.startsWith('--')) {
|
|
33
|
+
args[key] = next;
|
|
34
|
+
i++;
|
|
35
|
+
} else {
|
|
36
|
+
args[key] = 'true';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return args;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Main
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
async function main() {
|
|
48
|
+
const args = parseArgs(process.argv);
|
|
49
|
+
|
|
50
|
+
// Validate required args
|
|
51
|
+
if (!args.agent) {
|
|
52
|
+
outputError('Missing required argument: --agent');
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
if (!args['task-id']) {
|
|
56
|
+
outputError('Missing required argument: --task-id');
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const projectDir = args['project-dir'] || process.cwd();
|
|
61
|
+
const timeout = parseInt(args.timeout, 10) || 600_000; // default 10 minutes
|
|
62
|
+
|
|
63
|
+
// Load session state if available
|
|
64
|
+
let sessionState = {};
|
|
65
|
+
try {
|
|
66
|
+
const { existsSync, readFileSync } = await import('fs');
|
|
67
|
+
const { join } = await import('path');
|
|
68
|
+
const sessionPath = join(projectDir, '.chati', 'session.yaml');
|
|
69
|
+
if (existsSync(sessionPath)) {
|
|
70
|
+
const raw = readFileSync(sessionPath, 'utf-8');
|
|
71
|
+
sessionState = parseSimpleYaml(raw);
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// Session state is optional — continue without it
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Build the agent prompt
|
|
78
|
+
let promptResult;
|
|
79
|
+
try {
|
|
80
|
+
promptResult = buildAgentPrompt({
|
|
81
|
+
agent: args.agent,
|
|
82
|
+
taskId: args['task-id'],
|
|
83
|
+
projectDir,
|
|
84
|
+
previousAgent: args['previous-agent'] || null,
|
|
85
|
+
workflow: args.workflow || null,
|
|
86
|
+
sessionState,
|
|
87
|
+
additionalContext: args['additional-context'] || null,
|
|
88
|
+
});
|
|
89
|
+
} catch (err) {
|
|
90
|
+
outputError(`Failed to build prompt: ${err.message}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Spawn the agent terminal
|
|
95
|
+
const startTime = Date.now();
|
|
96
|
+
let handle;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
handle = spawnTerminal({
|
|
100
|
+
agent: args.agent,
|
|
101
|
+
taskId: args['task-id'],
|
|
102
|
+
model: promptResult.model,
|
|
103
|
+
prompt: promptResult.prompt,
|
|
104
|
+
workingDir: projectDir,
|
|
105
|
+
timeout,
|
|
106
|
+
});
|
|
107
|
+
} catch (err) {
|
|
108
|
+
outputError(`Failed to spawn terminal: ${err.message}`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Wait for the process to complete
|
|
113
|
+
try {
|
|
114
|
+
await waitForExit(handle, timeout);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
outputError(`Terminal execution failed: ${err.message}`);
|
|
117
|
+
process.exit(2);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const elapsed = Date.now() - startTime;
|
|
121
|
+
const stdout = handle.stdout.join('');
|
|
122
|
+
const stderr = handle.stderr.join('');
|
|
123
|
+
|
|
124
|
+
// Parse the handoff from stdout
|
|
125
|
+
const parsed = parseAgentOutput(stdout);
|
|
126
|
+
|
|
127
|
+
if (parsed.found) {
|
|
128
|
+
outputResult({
|
|
129
|
+
status: parsed.handoff.status,
|
|
130
|
+
agent: args.agent,
|
|
131
|
+
model: promptResult.model,
|
|
132
|
+
exitCode: handle.exitCode,
|
|
133
|
+
handoff: parsed.handoff,
|
|
134
|
+
elapsed,
|
|
135
|
+
});
|
|
136
|
+
} else {
|
|
137
|
+
// No handoff block found — return raw output
|
|
138
|
+
outputResult({
|
|
139
|
+
status: handle.exitCode === 0 ? 'partial' : 'error',
|
|
140
|
+
agent: args.agent,
|
|
141
|
+
model: promptResult.model,
|
|
142
|
+
exitCode: handle.exitCode,
|
|
143
|
+
handoff: null,
|
|
144
|
+
rawOutput: stdout.slice(0, 5000), // Truncate to avoid huge JSON
|
|
145
|
+
stderr: stderr.slice(0, 2000),
|
|
146
|
+
elapsed,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
process.exit(handle.exitCode === 0 ? 0 : 1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Helpers
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Wait for a terminal handle's process to exit.
|
|
159
|
+
*/
|
|
160
|
+
function waitForExit(handle, timeout) {
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
if (handle.status === 'exited') {
|
|
163
|
+
return resolve();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
if (handle.process && typeof handle.process.kill === 'function') {
|
|
168
|
+
try { handle.process.kill('SIGKILL'); } catch { /* ignore */ }
|
|
169
|
+
}
|
|
170
|
+
handle.status = 'exited';
|
|
171
|
+
handle.exitCode = -2;
|
|
172
|
+
reject(new Error(`Terminal timed out after ${Math.round(timeout / 1000)}s`));
|
|
173
|
+
}, timeout);
|
|
174
|
+
|
|
175
|
+
handle.process.once('exit', () => {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
resolve();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
handle.process.once('error', (err) => {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
reject(err);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Output a structured JSON result to stdout.
|
|
189
|
+
*/
|
|
190
|
+
function outputResult(data) {
|
|
191
|
+
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Output an error as JSON to stdout (so orchestrator can parse it).
|
|
196
|
+
*/
|
|
197
|
+
function outputError(message) {
|
|
198
|
+
process.stdout.write(JSON.stringify({ status: 'error', error: message }) + '\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Minimal YAML parser for session.yaml (handles flat and one-level nested keys).
|
|
203
|
+
*/
|
|
204
|
+
function parseSimpleYaml(content) {
|
|
205
|
+
const result = {};
|
|
206
|
+
let currentSection = null;
|
|
207
|
+
|
|
208
|
+
for (const line of content.split('\n')) {
|
|
209
|
+
if (line.trim() === '' || line.trim().startsWith('#')) continue;
|
|
210
|
+
|
|
211
|
+
const indent = line.search(/\S/);
|
|
212
|
+
const trimmed = line.trim();
|
|
213
|
+
const kvMatch = trimmed.match(/^([\w_-]+):\s*(.*)/);
|
|
214
|
+
|
|
215
|
+
if (kvMatch) {
|
|
216
|
+
const key = kvMatch[1];
|
|
217
|
+
const value = kvMatch[2].trim().replace(/^["']|["']$/g, '');
|
|
218
|
+
|
|
219
|
+
if (indent === 0) {
|
|
220
|
+
if (value === '' || value === undefined) {
|
|
221
|
+
// Section header
|
|
222
|
+
currentSection = key;
|
|
223
|
+
result[key] = result[key] || {};
|
|
224
|
+
} else {
|
|
225
|
+
result[key] = value;
|
|
226
|
+
currentSection = null;
|
|
227
|
+
}
|
|
228
|
+
} else if (currentSection && indent > 0) {
|
|
229
|
+
if (typeof result[currentSection] !== 'object') {
|
|
230
|
+
result[currentSection] = {};
|
|
231
|
+
}
|
|
232
|
+
result[currentSection][key] = value;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Guard pattern (per project convention)
|
|
241
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
242
|
+
main().catch(err => {
|
|
243
|
+
outputError(err.message);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export { parseArgs, waitForExit, parseSimpleYaml };
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI runner for parallel agent terminal execution.
|
|
4
|
+
*
|
|
5
|
+
* Called by the orchestrator via the Bash tool to spawn multiple agents
|
|
6
|
+
* simultaneously in separate Claude Code processes.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node run-parallel.js --agents detail,architect,ux \
|
|
10
|
+
* --task-ids expand-prd,architect-design,ux-wireframe \
|
|
11
|
+
* --project-dir /path/to/project --previous-agent brief \
|
|
12
|
+
* --timeout 900000
|
|
13
|
+
*
|
|
14
|
+
* Outputs consolidated JSON to stdout for the orchestrator to parse.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
import { buildAgentPrompt } from './prompt-builder.js';
|
|
19
|
+
import { spawnParallelGroup } from './spawner.js';
|
|
20
|
+
import { TerminalMonitor } from './monitor.js';
|
|
21
|
+
import { collectResults, mergeHandoffs, buildConsolidatedHandoff } from './collector.js';
|
|
22
|
+
import { parseAgentOutput } from './handoff-parser.js';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// CLI argument parsing
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
function parseArgs(argv) {
|
|
29
|
+
const args = {};
|
|
30
|
+
for (let i = 2; i < argv.length; i++) {
|
|
31
|
+
const arg = argv[i];
|
|
32
|
+
if (arg.startsWith('--')) {
|
|
33
|
+
const key = arg.slice(2);
|
|
34
|
+
const next = argv[i + 1];
|
|
35
|
+
if (next && !next.startsWith('--')) {
|
|
36
|
+
args[key] = next;
|
|
37
|
+
i++;
|
|
38
|
+
} else {
|
|
39
|
+
args[key] = 'true';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return args;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Main
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
async function main() {
|
|
51
|
+
const args = parseArgs(process.argv);
|
|
52
|
+
|
|
53
|
+
if (!args.agents) {
|
|
54
|
+
outputError('Missing required argument: --agents (comma-separated)');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
if (!args['task-ids']) {
|
|
58
|
+
outputError('Missing required argument: --task-ids (comma-separated)');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const agents = args.agents.split(',').map(s => s.trim());
|
|
63
|
+
const taskIds = args['task-ids'].split(',').map(s => s.trim());
|
|
64
|
+
|
|
65
|
+
if (agents.length !== taskIds.length) {
|
|
66
|
+
outputError(`Agent count (${agents.length}) must match task-id count (${taskIds.length})`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const projectDir = args['project-dir'] || process.cwd();
|
|
71
|
+
const previousAgent = args['previous-agent'] || null;
|
|
72
|
+
const timeout = parseInt(args.timeout, 10) || 900_000; // default 15 minutes
|
|
73
|
+
|
|
74
|
+
// Load session state
|
|
75
|
+
let sessionState = {};
|
|
76
|
+
try {
|
|
77
|
+
const { existsSync, readFileSync } = await import('fs');
|
|
78
|
+
const { join } = await import('path');
|
|
79
|
+
const sessionPath = join(projectDir, '.chati', 'session.yaml');
|
|
80
|
+
if (existsSync(sessionPath)) {
|
|
81
|
+
const raw = readFileSync(sessionPath, 'utf-8');
|
|
82
|
+
// Minimal parse
|
|
83
|
+
sessionState = {};
|
|
84
|
+
for (const line of raw.split('\n')) {
|
|
85
|
+
const m = line.trim().match(/^([\w_-]+):\s*(.+)/);
|
|
86
|
+
if (m) sessionState[m[1]] = m[2].trim().replace(/^["']|["']$/g, '');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch { /* optional */ }
|
|
90
|
+
|
|
91
|
+
// Build prompts for all agents
|
|
92
|
+
const configs = [];
|
|
93
|
+
const startTime = Date.now();
|
|
94
|
+
|
|
95
|
+
for (let i = 0; i < agents.length; i++) {
|
|
96
|
+
try {
|
|
97
|
+
const promptResult = buildAgentPrompt({
|
|
98
|
+
agent: agents[i],
|
|
99
|
+
taskId: taskIds[i],
|
|
100
|
+
projectDir,
|
|
101
|
+
previousAgent,
|
|
102
|
+
sessionState,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
configs.push({
|
|
106
|
+
agent: agents[i],
|
|
107
|
+
taskId: taskIds[i],
|
|
108
|
+
model: promptResult.model,
|
|
109
|
+
prompt: promptResult.prompt,
|
|
110
|
+
workingDir: projectDir,
|
|
111
|
+
timeout,
|
|
112
|
+
});
|
|
113
|
+
} catch (err) {
|
|
114
|
+
outputError(`Failed to build prompt for ${agents[i]}: ${err.message}`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Spawn all terminals in parallel
|
|
120
|
+
let group;
|
|
121
|
+
try {
|
|
122
|
+
group = spawnParallelGroup(configs);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
outputError(`Failed to spawn parallel group: ${err.message}`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Monitor until completion
|
|
129
|
+
const monitor = new TerminalMonitor({ pollInterval: 2000, timeout });
|
|
130
|
+
|
|
131
|
+
for (const terminal of group.terminals) {
|
|
132
|
+
monitor.addTerminal(terminal);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await new Promise((resolve) => {
|
|
136
|
+
monitor.onComplete(() => {
|
|
137
|
+
monitor.stopMonitoring();
|
|
138
|
+
resolve();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Safety timeout
|
|
142
|
+
const safetyTimer = setTimeout(() => {
|
|
143
|
+
monitor.stopMonitoring();
|
|
144
|
+
resolve();
|
|
145
|
+
}, timeout + 10_000);
|
|
146
|
+
|
|
147
|
+
monitor.onComplete(() => clearTimeout(safetyTimer));
|
|
148
|
+
monitor.startMonitoring();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const elapsed = Date.now() - startTime;
|
|
152
|
+
|
|
153
|
+
// Collect and merge results
|
|
154
|
+
const rawResults = collectResults(group.groupId, group.terminals);
|
|
155
|
+
|
|
156
|
+
// Parse handoffs from each terminal's stdout
|
|
157
|
+
const agentResults = rawResults.results.map(r => {
|
|
158
|
+
const parsed = parseAgentOutput(r.stdout);
|
|
159
|
+
return {
|
|
160
|
+
...r,
|
|
161
|
+
handoff: parsed.found ? parsed.handoff : null,
|
|
162
|
+
handoffFound: parsed.found,
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Merge handoffs
|
|
167
|
+
const mergeInput = agentResults.map(r => ({
|
|
168
|
+
agent: r.agent,
|
|
169
|
+
status: r.handoff?.status || (r.exitCode === 0 ? 'complete' : 'failed'),
|
|
170
|
+
outputs: r.handoff?.outputs || [],
|
|
171
|
+
decisions: r.handoff?.decisions || {},
|
|
172
|
+
blockers: r.handoff?.blockers || [],
|
|
173
|
+
summary: r.handoff?.summary || '',
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
const merged = mergeHandoffs(mergeInput);
|
|
177
|
+
const nextAgent = determineNextAgent(agents);
|
|
178
|
+
const consolidated = buildConsolidatedHandoff(merged, nextAgent);
|
|
179
|
+
|
|
180
|
+
// Output consolidated result
|
|
181
|
+
const output = {
|
|
182
|
+
status: rawResults.summary.failed === 0 ? 'complete' : 'partial',
|
|
183
|
+
groupId: group.groupId,
|
|
184
|
+
agents: agents.map((a, i) => ({
|
|
185
|
+
agent: a,
|
|
186
|
+
model: configs[i].model,
|
|
187
|
+
status: agentResults[i].handoff?.status || (agentResults[i].exitCode === 0 ? 'complete' : 'failed'),
|
|
188
|
+
score: agentResults[i].handoff?.score || null,
|
|
189
|
+
exitCode: agentResults[i].exitCode,
|
|
190
|
+
handoffFound: agentResults[i].handoffFound,
|
|
191
|
+
})),
|
|
192
|
+
mergedHandoff: consolidated,
|
|
193
|
+
summary: rawResults.summary,
|
|
194
|
+
elapsed,
|
|
195
|
+
performance: {
|
|
196
|
+
sequentialEstimate: elapsed * agents.length,
|
|
197
|
+
parallelActual: elapsed,
|
|
198
|
+
timeSaved: elapsed * (agents.length - 1),
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
203
|
+
process.exit(rawResults.summary.failed > 0 ? 1 : 0);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Helpers
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Determine the next sequential agent after a parallel group.
|
|
212
|
+
* GROUP 1 (detail+architect+ux) → phases
|
|
213
|
+
* GROUP 2 (dev tasks) → qa-implementation
|
|
214
|
+
*/
|
|
215
|
+
function determineNextAgent(agents) {
|
|
216
|
+
const groupKey = agents.sort().join(',');
|
|
217
|
+
if (groupKey.includes('architect') && groupKey.includes('detail') && groupKey.includes('ux')) {
|
|
218
|
+
return 'phases';
|
|
219
|
+
}
|
|
220
|
+
if (agents.every(a => a === 'dev')) {
|
|
221
|
+
return 'qa-implementation';
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function outputError(message) {
|
|
227
|
+
process.stdout.write(JSON.stringify({ status: 'error', error: message }) + '\n');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Guard pattern
|
|
231
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
232
|
+
main().catch(err => {
|
|
233
|
+
outputError(err.message);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export { parseArgs, determineNextAgent };
|
package/src/terminal/spawner.js
CHANGED
|
@@ -40,8 +40,10 @@ export function _resetCounter() {
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* @typedef {object} SpawnConfig
|
|
43
|
-
* @property {string} agent
|
|
44
|
-
* @property {string} taskId
|
|
43
|
+
* @property {string} agent - Agent name (e.g. "architect")
|
|
44
|
+
* @property {string} taskId - Task identifier
|
|
45
|
+
* @property {string} [model] - LLM model (haiku/sonnet/opus)
|
|
46
|
+
* @property {string} [prompt] - Full prompt string (from prompt-builder, piped via stdin)
|
|
45
47
|
* @property {object} [contextPayload] - Context to inject via env var
|
|
46
48
|
* @property {string[]} [writeScope] - Override write scope
|
|
47
49
|
* @property {string} [workingDir] - Working directory for the process
|
|
@@ -54,6 +56,7 @@ export function _resetCounter() {
|
|
|
54
56
|
* @property {object|null} process - child_process.ChildProcess (null when dry)
|
|
55
57
|
* @property {string} agent - Agent name
|
|
56
58
|
* @property {string} taskId - Task identifier
|
|
59
|
+
* @property {string} model - Model used for this terminal
|
|
57
60
|
* @property {string} startedAt - ISO timestamp
|
|
58
61
|
* @property {string} status - "running" | "exited" | "killed"
|
|
59
62
|
* @property {number|null} exitCode - Process exit code (null while running)
|
|
@@ -68,7 +71,7 @@ export function _resetCounter() {
|
|
|
68
71
|
* perform any I/O and is therefore fully testable in isolation.
|
|
69
72
|
*
|
|
70
73
|
* @param {SpawnConfig} config
|
|
71
|
-
* @returns {{ command: string, args: string[], env: Record<string, string
|
|
74
|
+
* @returns {{ command: string, args: string[], env: Record<string, string>, terminalId: string, prompt: string|null }}
|
|
72
75
|
*/
|
|
73
76
|
export function buildSpawnCommand(config) {
|
|
74
77
|
if (!config || typeof config !== 'object') {
|
|
@@ -89,6 +92,7 @@ export function buildSpawnCommand(config) {
|
|
|
89
92
|
CHATI_TERMINAL_ID: terminalId,
|
|
90
93
|
CHATI_AGENT: config.agent,
|
|
91
94
|
CHATI_TASK_ID: config.taskId,
|
|
95
|
+
CHATI_SPAWNED: 'true',
|
|
92
96
|
};
|
|
93
97
|
|
|
94
98
|
if (config.contextPayload) {
|
|
@@ -99,19 +103,18 @@ export function buildSpawnCommand(config) {
|
|
|
99
103
|
}
|
|
100
104
|
}
|
|
101
105
|
|
|
102
|
-
// Build
|
|
103
|
-
const prompt = `Execute task ${config.taskId} as agent ${config.agent}. ` +
|
|
104
|
-
`Write scope: ${isolationEnv.CHATI_WRITE_SCOPE || 'none'}. ` +
|
|
105
|
-
`Terminal ID: ${terminalId}.`;
|
|
106
|
-
|
|
106
|
+
// Build CLI args — prompt is piped via stdin, NOT as a CLI argument
|
|
107
107
|
const command = 'claude';
|
|
108
|
-
const args = [
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
108
|
+
const args = ['--print', '--dangerously-skip-permissions'];
|
|
109
|
+
|
|
110
|
+
if (config.model) {
|
|
111
|
+
args.push('--model', config.model);
|
|
112
|
+
}
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
// Prompt is returned separately for stdin piping (avoids ARG_MAX limits)
|
|
115
|
+
const prompt = config.prompt || null;
|
|
116
|
+
|
|
117
|
+
return { command, args, env, terminalId, prompt };
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
/**
|
|
@@ -121,7 +124,7 @@ export function buildSpawnCommand(config) {
|
|
|
121
124
|
* @returns {TerminalHandle}
|
|
122
125
|
*/
|
|
123
126
|
export function spawnTerminal(config) {
|
|
124
|
-
const { command, args, env, terminalId } = buildSpawnCommand(config);
|
|
127
|
+
const { command, args, env, terminalId, prompt } = buildSpawnCommand(config);
|
|
125
128
|
|
|
126
129
|
const cwd = config.workingDir || process.cwd();
|
|
127
130
|
const timeout = config.timeout || 300_000; // default 5 minutes
|
|
@@ -129,15 +132,22 @@ export function spawnTerminal(config) {
|
|
|
129
132
|
const child = spawn(command, args, {
|
|
130
133
|
cwd,
|
|
131
134
|
env: { ...process.env, ...env },
|
|
132
|
-
stdio: ['
|
|
135
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
133
136
|
});
|
|
134
137
|
|
|
138
|
+
// Pipe prompt via stdin (avoids shell argument length limits)
|
|
139
|
+
if (prompt) {
|
|
140
|
+
child.stdin.write(prompt);
|
|
141
|
+
}
|
|
142
|
+
child.stdin.end();
|
|
143
|
+
|
|
135
144
|
/** @type {TerminalHandle} */
|
|
136
145
|
const handle = {
|
|
137
146
|
id: terminalId,
|
|
138
147
|
process: child,
|
|
139
148
|
agent: config.agent,
|
|
140
149
|
taskId: config.taskId,
|
|
150
|
+
model: config.model || 'sonnet',
|
|
141
151
|
startedAt: new Date().toISOString(),
|
|
142
152
|
status: 'running',
|
|
143
153
|
exitCode: null,
|
|
@@ -250,11 +260,11 @@ export function killTerminal(handle) {
|
|
|
250
260
|
* Return the current status snapshot of a terminal.
|
|
251
261
|
*
|
|
252
262
|
* @param {TerminalHandle} handle
|
|
253
|
-
* @returns {{ id: string, agent: string, status: string, elapsed: number, exitCode: number|null }}
|
|
263
|
+
* @returns {{ id: string, agent: string, model: string, status: string, elapsed: number, exitCode: number|null }}
|
|
254
264
|
*/
|
|
255
265
|
export function getTerminalStatus(handle) {
|
|
256
266
|
if (!handle) {
|
|
257
|
-
return { id: 'unknown', agent: 'unknown', status: 'unknown', elapsed: 0, exitCode: null };
|
|
267
|
+
return { id: 'unknown', agent: 'unknown', model: 'unknown', status: 'unknown', elapsed: 0, exitCode: null };
|
|
258
268
|
}
|
|
259
269
|
|
|
260
270
|
const elapsed = Date.now() - new Date(handle.startedAt).getTime();
|
|
@@ -262,6 +272,7 @@ export function getTerminalStatus(handle) {
|
|
|
262
272
|
return {
|
|
263
273
|
id: handle.id,
|
|
264
274
|
agent: handle.agent,
|
|
275
|
+
model: handle.model || 'unknown',
|
|
265
276
|
status: handle.status,
|
|
266
277
|
elapsed,
|
|
267
278
|
exitCode: handle.exitCode,
|