chati-dev 2.0.4 → 2.0.6
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/README.md +48 -36
- package/framework/agents/{clarity → planning}/architect.md +1 -1
- package/framework/agents/{clarity → planning}/brief.md +1 -1
- package/framework/agents/{clarity → planning}/brownfield-wu.md +1 -1
- package/framework/agents/{clarity → planning}/detail.md +1 -1
- package/framework/agents/{clarity → planning}/greenfield-wu.md +1 -1
- package/framework/agents/{clarity → planning}/phases.md +1 -1
- package/framework/agents/{clarity → planning}/tasks.md +1 -1
- package/framework/agents/{clarity → planning}/ux.md +1 -1
- package/framework/agents/quality/qa-planning.md +5 -5
- package/framework/constitution.md +6 -6
- package/framework/context/governance.md +3 -3
- package/framework/context/quality.md +1 -1
- package/framework/context/root.md +1 -1
- package/framework/data/entity-registry.yaml +9 -9
- package/framework/domains/agents/qa-implementation.yaml +1 -1
- package/framework/domains/constitution.yaml +2 -2
- package/framework/domains/global.yaml +1 -1
- package/framework/hooks/mode-governance.js +5 -5
- package/framework/hooks/model-governance.js +38 -18
- package/framework/hooks/prism-engine.js +1 -1
- package/framework/hooks/session-digest.js +1 -1
- package/framework/i18n/en.yaml +1 -3
- package/framework/i18n/es.yaml +1 -3
- package/framework/i18n/fr.yaml +1 -3
- package/framework/i18n/pt.yaml +1 -3
- package/framework/intelligence/context-engine.md +3 -3
- package/framework/orchestrator/chati.md +148 -78
- package/framework/quality-gates/planning-gate.md +1 -1
- package/framework/schemas/context.schema.json +1 -1
- package/framework/schemas/session.schema.json +3 -3
- package/framework/tasks/architect-api-design.md +1 -1
- package/framework/tasks/architect-consolidate.md +1 -1
- package/framework/tasks/architect-db-design.md +1 -1
- package/framework/tasks/architect-design.md +1 -1
- package/framework/tasks/architect-security-review.md +1 -1
- package/framework/tasks/architect-stack-selection.md +1 -1
- package/framework/tasks/brief-consolidate.md +1 -1
- package/framework/tasks/brief-constraint-identify.md +1 -1
- package/framework/tasks/brief-extract-requirements.md +1 -1
- package/framework/tasks/brief-stakeholder-map.md +1 -1
- package/framework/tasks/brief-validate-completeness.md +1 -1
- package/framework/tasks/brownfield-wu-architecture-map.md +1 -1
- package/framework/tasks/brownfield-wu-deep-discovery.md +1 -1
- package/framework/tasks/brownfield-wu-dependency-scan.md +1 -1
- package/framework/tasks/brownfield-wu-migration-plan.md +1 -1
- package/framework/tasks/brownfield-wu-report.md +1 -1
- package/framework/tasks/brownfield-wu-risk-assess.md +1 -1
- package/framework/tasks/detail-acceptance-criteria.md +1 -1
- package/framework/tasks/detail-consolidate.md +1 -1
- package/framework/tasks/detail-edge-case-analysis.md +1 -1
- package/framework/tasks/detail-expand-prd.md +1 -1
- package/framework/tasks/detail-nfr-extraction.md +1 -1
- package/framework/tasks/greenfield-wu-analyze-empty.md +1 -1
- package/framework/tasks/greenfield-wu-report.md +1 -1
- package/framework/tasks/greenfield-wu-scaffold-detection.md +1 -1
- package/framework/tasks/greenfield-wu-tech-stack-assess.md +1 -1
- package/framework/tasks/orchestrator-deviation.md +3 -3
- package/framework/tasks/orchestrator-escalate.md +2 -2
- package/framework/tasks/orchestrator-handoff.md +6 -6
- package/framework/tasks/orchestrator-health.md +1 -1
- package/framework/tasks/orchestrator-mode-switch.md +20 -20
- package/framework/tasks/orchestrator-resume.md +6 -6
- package/framework/tasks/orchestrator-route.md +7 -7
- package/framework/tasks/orchestrator-status.md +8 -8
- package/framework/tasks/orchestrator-suggest-mode.md +13 -13
- package/framework/tasks/phases-breakdown.md +1 -1
- package/framework/tasks/phases-dependency-mapping.md +1 -1
- package/framework/tasks/phases-mvp-scoping.md +1 -1
- package/framework/tasks/qa-impl-regression-check.md +2 -2
- package/framework/tasks/qa-planning-consolidate.md +3 -3
- package/framework/tasks/qa-planning-coverage-plan.md +1 -1
- package/framework/tasks/qa-planning-gate-define.md +7 -7
- package/framework/tasks/qa-planning-risk-matrix.md +1 -1
- package/framework/tasks/qa-planning-test-strategy.md +1 -1
- package/framework/tasks/tasks-acceptance-write.md +1 -1
- package/framework/tasks/tasks-consolidate.md +1 -1
- package/framework/tasks/tasks-decompose.md +1 -1
- package/framework/tasks/tasks-estimate.md +1 -1
- package/framework/tasks/ux-a11y-check.md +1 -1
- package/framework/tasks/ux-component-map.md +1 -1
- package/framework/tasks/ux-consolidate.md +1 -1
- package/framework/tasks/ux-user-flow.md +1 -1
- package/framework/tasks/ux-wireframe.md +1 -1
- package/framework/workflows/brownfield-discovery.yaml +7 -7
- package/framework/workflows/brownfield-fullstack.yaml +9 -9
- package/framework/workflows/brownfield-service.yaml +5 -5
- package/framework/workflows/brownfield-ui.yaml +5 -5
- package/framework/workflows/greenfield-fullstack.yaml +9 -9
- package/package.json +2 -2
- package/scripts/bundle-framework.js +1 -1
- package/scripts/health-check.js +8 -8
- package/scripts/populate-entity-registry.js +1 -1
- package/scripts/validate-agents.js +1 -1
- package/scripts/validate-tasks.js +1 -1
- package/src/autonomy/autonomous-gate.js +1 -1
- package/src/autonomy/progress-reporter.js +9 -9
- package/src/config/mcp-configs.js +16 -85
- package/src/context/engine.js +2 -2
- package/src/context/layers/l1-global.js +3 -3
- package/src/dashboard/layout.js +7 -7
- package/src/dashboard/renderer.js +2 -2
- package/src/gates/g1-planning-complete.js +9 -9
- package/src/installer/core.js +11 -11
- package/src/installer/templates.js +1 -1
- package/src/installer/validator.js +8 -8
- package/src/intelligence/registry-manager.js +4 -4
- package/src/memory/session-digest.js +1 -1
- package/src/orchestrator/agent-selector.js +10 -10
- package/src/orchestrator/intent-classifier.js +4 -4
- package/src/orchestrator/pipeline-manager.js +3 -3
- package/src/orchestrator/pipeline-state.js +1 -1
- package/src/orchestrator/session-manager.js +3 -3
- package/src/tasks/executor.js +1 -1
- package/src/tasks/loader.js +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
- package/src/wizard/i18n.js +1 -3
- package/src/wizard/index.js +10 -15
- package/src/wizard/questions.js +7 -70
|
@@ -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/planning/greenfield-wu.md',
|
|
41
|
+
'brownfield-wu': 'chati.dev/agents/planning/brownfield-wu.md',
|
|
42
|
+
brief: 'chati.dev/agents/planning/brief.md',
|
|
43
|
+
detail: 'chati.dev/agents/planning/detail.md',
|
|
44
|
+
architect: 'chati.dev/agents/planning/architect.md',
|
|
45
|
+
ux: 'chati.dev/agents/planning/ux.md',
|
|
46
|
+
phases: 'chati.dev/agents/planning/phases.md',
|
|
47
|
+
tasks: 'chati.dev/agents/planning/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 || 'planning',
|
|
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 || 'planning'}`,
|
|
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 };
|