chati-dev 4.5.11 → 4.5.13
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/config.yaml +2 -2
- package/framework/constitution.md +21 -22
- package/framework/context/root.md +1 -1
- package/framework/data/entity-registry.yaml +1 -1
- package/framework/domains/constitution.yaml +1 -1
- package/framework/domains/workflows/brownfield-fullstack.yaml +5 -3
- package/framework/domains/workflows/brownfield-service.yaml +2 -3
- package/framework/domains/workflows/brownfield-ui.yaml +2 -3
- package/framework/domains/workflows/greenfield-fullstack.yaml +5 -3
- package/framework/domains/workflows/quick-flow.yaml +7 -6
- package/framework/domains/workflows/standard-flow.yaml +4 -5
- package/framework/frameworks/quality-dimensions.yaml +3 -3
- package/framework/manifest.json +37 -37
- package/framework/manifest.sig +1 -1
- package/framework/schemas/session.schema.json +1 -1
- package/framework/workflows/brownfield-fullstack.yaml +17 -123
- package/framework/workflows/brownfield-service.yaml +15 -109
- package/framework/workflows/brownfield-ui.yaml +15 -115
- package/framework/workflows/greenfield-fullstack.yaml +17 -130
- package/framework/workflows/quick-flow.yaml +20 -111
- package/framework/workflows/standard-flow.yaml +14 -158
- package/package.json +1 -1
- package/src/config/claude-settings-generator.js +4 -3
- package/src/config/context-file-generator.js +10 -1
- package/src/config/framework-adapter.js +31 -0
- package/src/installer/core.js +89 -8
- package/src/installer/templates.js +33 -3
- package/src/installer/validator.js +8 -2
- package/src/intelligence/registry-manager.js +11 -4
- package/src/orchestrator/cli.js +54 -28
- package/src/terminal/handoff-parser.js +23 -4
- package/src/terminal/prompt-builder.js +15 -1
- package/src/terminal/provider-preflight.js +78 -0
- package/src/terminal/run-agent.js +66 -23
- package/src/terminal/spawner.js +5 -1
- package/src/utils/schema-validator.js +5 -2
- package/src/wizard/index.js +1 -0
|
@@ -175,14 +175,14 @@ export function generateConfigYaml(config) {
|
|
|
175
175
|
model_fallback: true,
|
|
176
176
|
frustration_detection: true,
|
|
177
177
|
bash_security_checks: true,
|
|
178
|
-
// Agent Teams
|
|
178
|
+
// Agent Teams are planning-only in runtime v2.
|
|
179
179
|
// Only effective when the active session runs in Claude (gated in cli.js
|
|
180
180
|
// isAgentTeamsEnabled). Gemini and Codex always fall back to sequential
|
|
181
181
|
// pipeline silently. See plan: was previously false for "safe rollout"
|
|
182
182
|
// but the v4.2.0 launch never actually shipped, so this is a fresh start.
|
|
183
183
|
agent_teams: true,
|
|
184
184
|
team_planning_size: 4,
|
|
185
|
-
team_build_size: 2,
|
|
185
|
+
...(config.runtimeVersion === 2 ? {} : { team_build_size: 2 }),
|
|
186
186
|
team_echo_threshold: 0.92,
|
|
187
187
|
team_correction_cycles_max: 2,
|
|
188
188
|
};
|
|
@@ -229,12 +229,42 @@ name: chati
|
|
|
229
229
|
description: >
|
|
230
230
|
Use this skill when the user wants to start or continue a Chati.dev session.
|
|
231
231
|
This activates the orchestrator which routes to the correct agent in the pipeline.
|
|
232
|
-
Invoke when the user mentions chati, /chati, wants to plan,
|
|
232
|
+
Invoke when the user mentions chati, /chati, or wants to discover, plan, execute, or release a project.
|
|
233
233
|
Do NOT use for general coding questions unrelated to the Chati.dev pipeline.
|
|
234
234
|
---
|
|
235
235
|
|
|
236
236
|
# Chati.dev Orchestrator
|
|
237
237
|
|
|
238
|
+
## STEP 0 — Run the deterministic router (MUST be the first action)
|
|
239
|
+
|
|
240
|
+
Use the shell in the current project directory and run:
|
|
241
|
+
|
|
242
|
+
\`\`\`bash
|
|
243
|
+
node .chati.dev/orchestrator/chati-router.js
|
|
244
|
+
\`\`\`
|
|
245
|
+
|
|
246
|
+
Do not infer provider authentication, session state, model availability, or the
|
|
247
|
+
next pipeline action from files or prior messages. The router output is the
|
|
248
|
+
source of truth. Do not tell the user to log in unless the router or the exact
|
|
249
|
+
routed spawn command returns a provider authentication error.
|
|
250
|
+
|
|
251
|
+
Parse the JSON output before responding:
|
|
252
|
+
|
|
253
|
+
- If \`ok\` is \`false\`, report the exact \`error\` and relevant detail, then stop.
|
|
254
|
+
- If \`ok\` is \`true\`, use \`session.language\` for every response.
|
|
255
|
+
- Follow the returned \`action\` in the adapted orchestrator.
|
|
256
|
+
- When the action includes \`pipeline.spawn_command\`, execute that exact command.
|
|
257
|
+
Never replace it with an inferred provider check or a manually constructed command.
|
|
258
|
+
- A router-provided spawn command starts an external provider CLI. Execute it
|
|
259
|
+
with native escalated execution: set \`sandbox_permissions\` to
|
|
260
|
+
\`require_escalated\`, preserve the exact command and working directory, and
|
|
261
|
+
offer the narrow reusable prefix
|
|
262
|
+
\`["node", "<absolute path from spawn_command to run-agent.js>"]\`.
|
|
263
|
+
- Do not run that provider command inside the workspace sandbox first. A
|
|
264
|
+
\`PROVIDER_NOT_READY\` or \`PROVIDER_NOT_AUTHENTICATED\` result from a sandboxed
|
|
265
|
+
attempt is not proof that the user is logged out. Rerun it once with native
|
|
266
|
+
escalation before reporting an authentication failure.
|
|
267
|
+
|
|
238
268
|
## CRITICAL: Language Override
|
|
239
269
|
|
|
240
270
|
Read \`.chati/session.yaml\` field \`language\` BEFORE anything else.
|
|
@@ -17,6 +17,7 @@ function resolveFrameworkDir(targetDir) {
|
|
|
17
17
|
* Checks all primary agents, constitution, session, schemas, etc.
|
|
18
18
|
*/
|
|
19
19
|
export async function validateInstallation(targetDir) {
|
|
20
|
+
const isRuntimeV2 = existsSync(join(targetDir, '.chati', 'v2', 'installation.json'));
|
|
20
21
|
const results = {
|
|
21
22
|
agents: { pass: false, details: [] },
|
|
22
23
|
constitution: { pass: false, details: [] },
|
|
@@ -35,7 +36,7 @@ export async function validateInstallation(targetDir) {
|
|
|
35
36
|
};
|
|
36
37
|
|
|
37
38
|
// Check all primary agents (orchestrator + 12 specialized, sub-agents validated by managers)
|
|
38
|
-
const
|
|
39
|
+
const planningAgentFiles = [
|
|
39
40
|
'orchestrator/chati.md',
|
|
40
41
|
'agents/discover/greenfield-wu.md',
|
|
41
42
|
'agents/discover/brownfield-wu.md',
|
|
@@ -46,10 +47,15 @@ export async function validateInstallation(targetDir) {
|
|
|
46
47
|
'agents/plan/phases.md',
|
|
47
48
|
'agents/plan/tasks.md',
|
|
48
49
|
'agents/quality/qa-planning.md',
|
|
50
|
+
];
|
|
51
|
+
const legacyExecutionAgentFiles = [
|
|
49
52
|
'agents/quality/qa-implementation.md',
|
|
50
53
|
'agents/build/dev.md',
|
|
51
54
|
'agents/deploy/devops.md',
|
|
52
55
|
];
|
|
56
|
+
const agentFiles = isRuntimeV2
|
|
57
|
+
? planningAgentFiles
|
|
58
|
+
: [...planningAgentFiles, ...legacyExecutionAgentFiles];
|
|
53
59
|
|
|
54
60
|
let agentCount = 0;
|
|
55
61
|
for (const file of agentFiles) {
|
|
@@ -64,7 +70,7 @@ export async function validateInstallation(targetDir) {
|
|
|
64
70
|
results.agents.details.push({ file, exists: false, hasProtocols: false });
|
|
65
71
|
}
|
|
66
72
|
}
|
|
67
|
-
results.agents.pass = agentCount ===
|
|
73
|
+
results.agents.pass = agentCount === agentFiles.length;
|
|
68
74
|
results.total += 1;
|
|
69
75
|
if (results.agents.pass) results.passed += 1;
|
|
70
76
|
|
|
@@ -158,21 +158,28 @@ export function runHealthCheck(targetDir) {
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
// 4. Agent check
|
|
161
|
-
const
|
|
161
|
+
const planningAgentPaths = [
|
|
162
162
|
'orchestrator/chati.md',
|
|
163
163
|
'agents/discover/greenfield-wu.md', 'agents/discover/brownfield-wu.md',
|
|
164
164
|
'agents/discover/brief.md', 'agents/plan/detail.md',
|
|
165
165
|
'agents/plan/architect.md', 'agents/plan/ux.md',
|
|
166
166
|
'agents/plan/phases.md', 'agents/plan/tasks.md',
|
|
167
|
-
'agents/quality/qa-planning.md',
|
|
167
|
+
'agents/quality/qa-planning.md',
|
|
168
|
+
];
|
|
169
|
+
const legacyExecutionAgentPaths = [
|
|
170
|
+
'agents/quality/qa-implementation.md',
|
|
168
171
|
'agents/build/dev.md', 'agents/deploy/devops.md',
|
|
169
172
|
];
|
|
173
|
+
const isRuntimeV2 = existsSync(join(targetDir, '.chati', 'v2', 'installation.json'));
|
|
174
|
+
const agentPaths = isRuntimeV2
|
|
175
|
+
? planningAgentPaths
|
|
176
|
+
: [...planningAgentPaths, ...legacyExecutionAgentPaths];
|
|
170
177
|
let foundAgents = 0;
|
|
171
178
|
for (const p of agentPaths) {
|
|
172
179
|
if (existsSync(join(targetDir, fwDir, p))) foundAgents++;
|
|
173
180
|
}
|
|
174
|
-
checks.agents.pass = foundAgents ===
|
|
175
|
-
checks.agents.details = `${foundAgents}
|
|
181
|
+
checks.agents.pass = foundAgents === agentPaths.length;
|
|
182
|
+
checks.agents.details = `${foundAgents}/${agentPaths.length} present`;
|
|
176
183
|
|
|
177
184
|
// 5. Entity validation (registry vs filesystem)
|
|
178
185
|
const entityResult = validateEntities(targetDir);
|
package/src/orchestrator/cli.js
CHANGED
|
@@ -79,6 +79,8 @@ const LOCK_START = '<!-- chati-lock:start -->';
|
|
|
79
79
|
const LOCK_END = '<!-- chati-lock:end -->';
|
|
80
80
|
const STATE_START = '<!-- chati-state:start -->';
|
|
81
81
|
const STATE_END = '<!-- chati-state:end -->';
|
|
82
|
+
const PAUSE_START = '<!-- chati-pause:start -->';
|
|
83
|
+
const PAUSE_END = '<!-- chati-pause:end -->';
|
|
82
84
|
|
|
83
85
|
const RESUME_MESSAGES = {
|
|
84
86
|
en: 'Session saved. Type /chati anytime to resume.',
|
|
@@ -700,9 +702,32 @@ function replaceBlock(content, startMarker, endMarker, newInner) {
|
|
|
700
702
|
return content.trimEnd() + '\n\n' + block + '\n';
|
|
701
703
|
}
|
|
702
704
|
|
|
705
|
+
function sessionLockTargets(projectDir) {
|
|
706
|
+
const frameworkDir = resolveFrameworkDir(projectDir);
|
|
707
|
+
return [
|
|
708
|
+
{ path: join(projectDir, 'CLAUDE.local.md'), invocation: '/chati', provider: 'claude' },
|
|
709
|
+
{ path: join(projectDir, 'AGENTS.override.md'), invocation: '$chati', provider: 'codex' },
|
|
710
|
+
{ path: join(projectDir, '.grok', 'session-lock.md'), invocation: '/chati', provider: 'grok' },
|
|
711
|
+
{ path: join(projectDir, '.gemini', 'session-lock.md'), invocation: '/chati', provider: 'gemini' },
|
|
712
|
+
].map(target => ({
|
|
713
|
+
...target,
|
|
714
|
+
orchestratorPath: existsSync(join(projectDir, frameworkDir, '.adapted', target.provider, 'orchestrator', 'chati.md'))
|
|
715
|
+
? `${frameworkDir}/.adapted/${target.provider}/orchestrator/chati.md`
|
|
716
|
+
: `${frameworkDir}/orchestrator/chati.md`,
|
|
717
|
+
})).filter(({ path }) => path.endsWith('CLAUDE.local.md') || existsSync(path));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function removeLegacyPauseSection(content) {
|
|
721
|
+
let clean = content.replace(
|
|
722
|
+
/\n*<!-- chati-pause:start -->[\s\S]*?<!-- chati-pause:end -->\n*/g,
|
|
723
|
+
'\n'
|
|
724
|
+
);
|
|
725
|
+
// Migrate the unmarked block emitted by releases before 4.5.12.
|
|
726
|
+
clean = clean.replace(/\n+## Session Paused\n+[\s\S]*?(?=\n## |\n---|$)/g, '\n');
|
|
727
|
+
return clean;
|
|
728
|
+
}
|
|
729
|
+
|
|
703
730
|
function writeSessionLock(projectDir, currentAgent, stateInfo = {}) {
|
|
704
|
-
const localMdPath = join(projectDir, 'CLAUDE.local.md');
|
|
705
|
-
let content = existsSync(localMdPath) ? readFileSync(localMdPath, 'utf-8') : '';
|
|
706
731
|
|
|
707
732
|
// Phase display is authoritatively driven by session.mode on disk. Callers
|
|
708
733
|
// previously passed stateInfo.phase derived from transient intermediate
|
|
@@ -715,50 +740,51 @@ function writeSessionLock(projectDir, currentAgent, stateInfo = {}) {
|
|
|
715
740
|
if (loaded && session?.mode) authoritativePhase = session.mode;
|
|
716
741
|
} catch { /* non-fatal — fall back to stateInfo */ }
|
|
717
742
|
|
|
718
|
-
const lockInner = `## Session Lock -- ACTIVE
|
|
719
|
-
|
|
720
|
-
**Chati.dev session is ACTIVE.** Follow these rules for EVERY message:
|
|
721
|
-
|
|
722
|
-
1. Read \`${resolveFrameworkDir(projectDir)}/orchestrator/chati.md\` and follow its routing logic
|
|
723
|
-
2. Route ALL user messages through the current agent: \`${currentAgent}\`
|
|
724
|
-
3. NEVER respond outside of the Chati.dev system
|
|
725
|
-
4. The ONLY way to exit is via \`/chati exit\`, \`/chati stop\`, or \`/chati quit\``;
|
|
726
|
-
|
|
727
743
|
const stateInner = `## Current State
|
|
728
744
|
- **Agent**: ${currentAgent || 'None'}
|
|
729
745
|
- **Phase**: ${authoritativePhase || stateInfo.phase || 'discover'}
|
|
730
746
|
- **Pipeline**: ${stateInfo.position ?? 0}/${stateInfo.total ?? '?'} (${stateInfo.progress ?? 0}%)
|
|
731
747
|
- **Mode**: ${stateInfo.mode || 'interactive'}`;
|
|
732
748
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
749
|
+
for (const target of sessionLockTargets(projectDir)) {
|
|
750
|
+
let content = removeLegacyPauseSection(existsSync(target.path) ? readFileSync(target.path, 'utf-8') : '');
|
|
751
|
+
const lockInner = `## Session Lock: ACTIVE
|
|
752
|
+
|
|
753
|
+
**Chati.dev session is ACTIVE.** Follow these rules for EVERY message:
|
|
754
|
+
|
|
755
|
+
1. Read \`${target.orchestratorPath}\` and follow its routing logic
|
|
756
|
+
2. Route ALL user messages through the current agent: \`${currentAgent}\`
|
|
757
|
+
3. NEVER respond outside of the Chati.dev system
|
|
758
|
+
4. The ONLY way to exit is via \`${target.invocation} exit\`, \`${target.invocation} stop\`, or \`${target.invocation} quit\``;
|
|
759
|
+
content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
|
|
760
|
+
content = replaceBlock(content, STATE_START, STATE_END, stateInner);
|
|
761
|
+
writeFileSync(target.path, content.trimEnd() + '\n', 'utf-8');
|
|
762
|
+
}
|
|
736
763
|
}
|
|
737
764
|
|
|
738
765
|
/**
|
|
739
766
|
* Remove session lock block from CLAUDE.local.md.
|
|
740
767
|
*/
|
|
741
768
|
function removeSessionLock(projectDir, resumeMsg) {
|
|
742
|
-
const localMdPath = join(projectDir, 'CLAUDE.local.md');
|
|
743
|
-
if (!existsSync(localMdPath)) return;
|
|
744
|
-
|
|
745
|
-
let content = readFileSync(localMdPath, 'utf-8');
|
|
746
|
-
|
|
747
|
-
const lockInner = `## Session Lock
|
|
748
|
-
**Status: INACTIVE** — Type \`/chati\` to activate.`;
|
|
749
769
|
const stateInner = `## Current State
|
|
750
770
|
- **Agent**: None (ready to start)
|
|
751
771
|
- **Pipeline**: Pre-start
|
|
752
772
|
- **Mode**: interactive`;
|
|
753
773
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
content = content
|
|
774
|
+
for (const target of sessionLockTargets(projectDir)) {
|
|
775
|
+
let content = removeLegacyPauseSection(existsSync(target.path) ? readFileSync(target.path, 'utf-8') : '');
|
|
776
|
+
const lockInner = `## Session Lock
|
|
777
|
+
**Status: INACTIVE**: Type \`${target.invocation}\` to activate.`;
|
|
778
|
+
content = replaceBlock(content, LOCK_START, LOCK_END, lockInner);
|
|
779
|
+
content = replaceBlock(content, STATE_START, STATE_END, stateInner);
|
|
780
|
+
|
|
781
|
+
if (resumeMsg) {
|
|
782
|
+
const providerMessage = resumeMsg.replaceAll('/chati', target.invocation);
|
|
783
|
+
const pauseBlock = `${PAUSE_START}\n## Session Paused\n\n${providerMessage}\n${PAUSE_END}`;
|
|
784
|
+
content = content.trimEnd() + `\n\n${pauseBlock}\n`;
|
|
785
|
+
}
|
|
786
|
+
writeFileSync(target.path, content.trimEnd() + '\n', 'utf-8');
|
|
759
787
|
}
|
|
760
|
-
|
|
761
|
-
writeFileSync(localMdPath, content.trimEnd() + '\n', 'utf-8');
|
|
762
788
|
}
|
|
763
789
|
|
|
764
790
|
// ---------------------------------------------------------------------------
|
|
@@ -14,7 +14,15 @@ import { validateSchema, HANDOFF_SCHEMA } from '../utils/schema-validator.js';
|
|
|
14
14
|
* Valid status values for handoff blocks.
|
|
15
15
|
* @type {string[]}
|
|
16
16
|
*/
|
|
17
|
-
const VALID_STATUSES = [
|
|
17
|
+
const VALID_STATUSES = [
|
|
18
|
+
'complete', 'partial', 'needs_input', 'error', 'unknown',
|
|
19
|
+
'APPROVED', 'NEEDS_REVISION', 'BLOCKED',
|
|
20
|
+
];
|
|
21
|
+
const LEGACY_STATUS_ALIASES = {
|
|
22
|
+
APPROVED: 'complete',
|
|
23
|
+
NEEDS_REVISION: 'partial',
|
|
24
|
+
BLOCKED: 'needs_input',
|
|
25
|
+
};
|
|
18
26
|
|
|
19
27
|
/**
|
|
20
28
|
* Parse the <chati-handoff> block from agent stdout.
|
|
@@ -36,6 +44,7 @@ export function parseAgentOutput(output) {
|
|
|
36
44
|
|
|
37
45
|
const content = match[1].trim();
|
|
38
46
|
const handoff = parseHandoffFields(content);
|
|
47
|
+
handoff.status = LEGACY_STATUS_ALIASES[handoff.status] || handoff.status;
|
|
39
48
|
|
|
40
49
|
// Validate the parsed handoff
|
|
41
50
|
const { valid, warnings } = validateHandoff(handoff);
|
|
@@ -114,10 +123,11 @@ function parseHandoffFields(content) {
|
|
|
114
123
|
for (const line of lines) {
|
|
115
124
|
const trimmed = line.trim();
|
|
116
125
|
if (!trimmed) continue;
|
|
126
|
+
const indentation = line.length - line.trimStart().length;
|
|
117
127
|
|
|
118
128
|
// Check if this is a list item ( - value)
|
|
119
129
|
const listMatch = trimmed.match(/^-\s+(.+)/);
|
|
120
|
-
if (listMatch && currentKey && currentType === 'list') {
|
|
130
|
+
if (indentation > 0 && listMatch && currentKey && currentType === 'list') {
|
|
121
131
|
if (Array.isArray(result[currentKey])) {
|
|
122
132
|
result[currentKey].push(listMatch[1].trim());
|
|
123
133
|
}
|
|
@@ -126,7 +136,7 @@ function parseHandoffFields(content) {
|
|
|
126
136
|
|
|
127
137
|
// Check if this is a map item ( key: value) under a map key
|
|
128
138
|
const mapMatch = trimmed.match(/^(\w[\w_-]*):\s+(.+)/);
|
|
129
|
-
if (mapMatch && currentKey && currentType === 'map') {
|
|
139
|
+
if (indentation > 0 && mapMatch && currentKey && currentType === 'map') {
|
|
130
140
|
if (typeof result[currentKey] === 'object' && !Array.isArray(result[currentKey])) {
|
|
131
141
|
result[currentKey][mapMatch[1]] = mapMatch[2].trim();
|
|
132
142
|
}
|
|
@@ -145,7 +155,10 @@ function parseHandoffFields(content) {
|
|
|
145
155
|
currentType = 'list';
|
|
146
156
|
// If value is inline (not empty), treat as single item
|
|
147
157
|
if (value && value !== '') {
|
|
148
|
-
result[key] = [
|
|
158
|
+
if (value === '[]') result[key] = [];
|
|
159
|
+
else if (value.startsWith('[') && value.endsWith(']')) {
|
|
160
|
+
result[key] = value.slice(1, -1).split(',').map(item => item.trim()).filter(Boolean);
|
|
161
|
+
} else result[key] = [value];
|
|
149
162
|
currentKey = null;
|
|
150
163
|
currentType = null;
|
|
151
164
|
}
|
|
@@ -154,6 +167,12 @@ function parseHandoffFields(content) {
|
|
|
154
167
|
|
|
155
168
|
// Known map keys
|
|
156
169
|
if (['decisions'].includes(key)) {
|
|
170
|
+
if (value === '{}') {
|
|
171
|
+
result[key] = {};
|
|
172
|
+
currentKey = null;
|
|
173
|
+
currentType = null;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
157
176
|
currentKey = key;
|
|
158
177
|
currentType = 'map';
|
|
159
178
|
continue;
|
|
@@ -212,7 +212,11 @@ export function buildAgentPrompt(config) {
|
|
|
212
212
|
// 9. Anti-laziness reinforcement (proven remediation techniques)
|
|
213
213
|
sections.push(buildAntiLazinessSection(config.agent));
|
|
214
214
|
|
|
215
|
-
// 10.
|
|
215
|
+
// 10. Spawn authority. This late prompt section wins over stale provider
|
|
216
|
+
// context that may have been loaded before the synchronized session lock.
|
|
217
|
+
sections.push(buildSpawnAuthoritySection(config));
|
|
218
|
+
|
|
219
|
+
// 11. Output format instructions (handoff template — includes provider/model for audit)
|
|
216
220
|
sections.push(buildOutputInstructions({ provider: resolvedProvider, model }));
|
|
217
221
|
|
|
218
222
|
const prompt = sections.join('\n\n---\n\n');
|
|
@@ -233,6 +237,16 @@ export function buildAgentPrompt(config) {
|
|
|
233
237
|
};
|
|
234
238
|
}
|
|
235
239
|
|
|
240
|
+
function buildSpawnAuthoritySection(config) {
|
|
241
|
+
return `<!-- SPAWN AUTHORITY -->
|
|
242
|
+
## Active Orchestrator Assignment
|
|
243
|
+
|
|
244
|
+
This process was launched by the active Chati.dev orchestrator for agent \`${config.agent}\`.
|
|
245
|
+
Execute the assigned work now. Do not ask the user to activate Chati.dev, switch CLI, authenticate a provider, or restart the session.
|
|
246
|
+
If essential user information is missing, return status \`needs_input\` with exactly one concrete question in \`needs_input_question\`.
|
|
247
|
+
Never claim that this spawned session is paused. The parent orchestrator owns user interaction and will relay your structured handoff.`;
|
|
248
|
+
}
|
|
249
|
+
|
|
236
250
|
// ---------------------------------------------------------------------------
|
|
237
251
|
// Internal helpers
|
|
238
252
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic provider readiness checks used immediately before a routed
|
|
3
|
+
* execution. These checks never mutate provider configuration.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { getProvider } from './cli-registry.js';
|
|
7
|
+
import { cleanParentEnv } from './spawner.js';
|
|
8
|
+
|
|
9
|
+
const CHECKS = {
|
|
10
|
+
claude: { args: ['auth', 'status'], validate: validateClaude },
|
|
11
|
+
codex: { args: ['login', 'status'], validate: validateCodex },
|
|
12
|
+
grok: { args: ['inspect'], validate: validateGrok },
|
|
13
|
+
gemini: { args: ['--version'], validate: validateExit },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function checkProviderReadiness(providerName, options = {}) {
|
|
17
|
+
const runner = options.runner || spawnSync;
|
|
18
|
+
let provider;
|
|
19
|
+
try {
|
|
20
|
+
provider = getProvider(providerName);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return { ready: false, code: 'PROVIDER_UNKNOWN', provider: providerName, detail: error.message };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const check = CHECKS[providerName] || { args: ['--version'], validate: validateExit };
|
|
26
|
+
let result;
|
|
27
|
+
try {
|
|
28
|
+
result = runner(provider.command, check.args, {
|
|
29
|
+
cwd: options.workingDir || process.cwd(),
|
|
30
|
+
env: cleanParentEnv(process.env),
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
timeout: options.timeout || 30_000,
|
|
33
|
+
});
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return { ready: false, code: 'PROVIDER_PREFLIGHT_FAILED', provider: providerName, detail: error.message };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (result?.error?.code === 'ENOENT') {
|
|
39
|
+
return { ready: false, code: 'PROVIDER_CLI_MISSING', provider: providerName, detail: `${provider.command} is not installed` };
|
|
40
|
+
}
|
|
41
|
+
return check.validate(result, providerName);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function outputOf(result) {
|
|
45
|
+
return `${result?.stdout || ''}\n${result?.stderr || ''}`.trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validateExit(result, provider) {
|
|
49
|
+
return result?.status === 0
|
|
50
|
+
? { ready: true, code: 'PROVIDER_READY', provider }
|
|
51
|
+
: { ready: false, code: 'PROVIDER_NOT_READY', provider, detail: outputOf(result).slice(0, 500) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validateClaude(result, provider) {
|
|
55
|
+
if (result?.status !== 0) return validateExit(result, provider);
|
|
56
|
+
try {
|
|
57
|
+
const status = JSON.parse(result.stdout || '{}');
|
|
58
|
+
return status.loggedIn === true
|
|
59
|
+
? { ready: true, code: 'PROVIDER_READY', provider }
|
|
60
|
+
: { ready: false, code: 'PROVIDER_NOT_AUTHENTICATED', provider, detail: 'Claude CLI reports loggedIn=false' };
|
|
61
|
+
} catch {
|
|
62
|
+
return { ready: false, code: 'PROVIDER_PREFLIGHT_INVALID', provider, detail: 'Claude CLI returned an invalid auth status' };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateCodex(result, provider) {
|
|
67
|
+
if (result?.status !== 0) return validateExit(result, provider);
|
|
68
|
+
return /logged in/i.test(outputOf(result))
|
|
69
|
+
? { ready: true, code: 'PROVIDER_READY', provider }
|
|
70
|
+
: { ready: false, code: 'PROVIDER_NOT_AUTHENTICATED', provider, detail: 'Codex CLI did not confirm an authenticated session' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validateGrok(result, provider) {
|
|
74
|
+
// Grok currently exposes no stable auth-status command. `inspect` proves
|
|
75
|
+
// that the CLI can load the trusted project and its effective config.
|
|
76
|
+
return validateExit(result, provider);
|
|
77
|
+
}
|
|
78
|
+
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
|
|
16
16
|
import { fileURLToPath } from 'url';
|
|
17
17
|
import { buildAgentPrompt } from './prompt-builder.js';
|
|
18
|
-
import {
|
|
18
|
+
import { isTransientFailure, spawnTerminalWithRetry } from './spawner.js';
|
|
19
19
|
import { parseAgentOutput } from './handoff-parser.js';
|
|
20
|
+
import { checkProviderReadiness } from './provider-preflight.js';
|
|
20
21
|
import { createCostTracker } from './cost-tracker.js';
|
|
21
22
|
import { getRateLimiter } from './rate-limiter.js';
|
|
22
23
|
import { initCollector, track as telemetryTrack, flush as telemetryFlush } from '../telemetry/collector.js';
|
|
@@ -104,6 +105,11 @@ async function main() {
|
|
|
104
105
|
|
|
105
106
|
// Wait for rate limit slot before spawning
|
|
106
107
|
const spawnProvider = promptResult.provider || args.provider || 'claude';
|
|
108
|
+
const readiness = checkProviderReadiness(spawnProvider, { workingDir: projectDir });
|
|
109
|
+
if (!readiness.ready) {
|
|
110
|
+
outputResult({ status: 'error', error: readiness.detail, code: readiness.code, provider: spawnProvider });
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
107
113
|
const limiter = getRateLimiter(spawnProvider);
|
|
108
114
|
if (!limiter.canSpawn()) {
|
|
109
115
|
const rateLimitStart = Date.now();
|
|
@@ -121,7 +127,7 @@ async function main() {
|
|
|
121
127
|
let handle;
|
|
122
128
|
|
|
123
129
|
try {
|
|
124
|
-
|
|
130
|
+
const spawnConfig = {
|
|
125
131
|
agent: args.agent,
|
|
126
132
|
taskId: args['task-id'],
|
|
127
133
|
model: promptResult.model,
|
|
@@ -133,31 +139,25 @@ async function main() {
|
|
|
133
139
|
providerId: args['provider-id'] || null,
|
|
134
140
|
reasoningConfiguration: args['reasoning-configuration'] || null,
|
|
135
141
|
catalogSnapshotRef: args['catalog-snapshot-ref'] || null,
|
|
142
|
+
};
|
|
143
|
+
handle = await spawnTerminalWithRetry(spawnConfig, {
|
|
144
|
+
maxRetries: 1,
|
|
145
|
+
baseDelay: 500,
|
|
146
|
+
enableModelFallback: args['strict-provider'] !== 'true',
|
|
147
|
+
shouldRetry(exitCode, output) {
|
|
148
|
+
const combined = Array.isArray(output) ? output.join('') : String(output || '');
|
|
149
|
+
return isTransientFailure(exitCode, combined) || /not logged in|not authenticated/i.test(combined);
|
|
150
|
+
},
|
|
136
151
|
});
|
|
137
152
|
} catch (err) {
|
|
138
153
|
outputError(`Failed to spawn terminal: ${err.message}`);
|
|
139
154
|
process.exit(1);
|
|
140
155
|
}
|
|
141
156
|
|
|
142
|
-
// Wait for the process to complete
|
|
143
|
-
try {
|
|
144
|
-
await waitForExit(handle, timeout);
|
|
145
|
-
} catch (err) {
|
|
146
|
-
telemetryTrack('error_occurred', {
|
|
147
|
-
errorType: 'agent_failure',
|
|
148
|
-
agent: args.agent,
|
|
149
|
-
provider: promptResult.provider || args.provider || 'claude',
|
|
150
|
-
reasoningConfiguration: args['reasoning-configuration'] || null,
|
|
151
|
-
phase: sessionState?.phase || 'unknown',
|
|
152
|
-
});
|
|
153
|
-
await flushAndSend(projectDir);
|
|
154
|
-
outputError(`Terminal execution failed: ${err.message}`);
|
|
155
|
-
process.exit(2);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
157
|
const elapsed = Date.now() - startTime;
|
|
159
158
|
const stdout = handle.stdout.join('');
|
|
160
159
|
const stderr = handle.stderr.join('');
|
|
160
|
+
const providerOutput = `${stdout}\n${stderr}`;
|
|
161
161
|
|
|
162
162
|
// Track cost metrics
|
|
163
163
|
const tracker = createCostTracker();
|
|
@@ -186,7 +186,7 @@ async function main() {
|
|
|
186
186
|
model: costRecord.model,
|
|
187
187
|
duration: elapsed,
|
|
188
188
|
score: null, // Score is determined by the gate, not the agent
|
|
189
|
-
retryCount: 0,
|
|
189
|
+
retryCount: handle.retryCount || 0,
|
|
190
190
|
pipelineType: sessionState?.isQuickFlow ? 'quick-flow' : 'standard',
|
|
191
191
|
});
|
|
192
192
|
|
|
@@ -210,7 +210,19 @@ async function main() {
|
|
|
210
210
|
// Parse the handoff from stdout
|
|
211
211
|
const parsed = parseAgentOutput(stdout);
|
|
212
212
|
|
|
213
|
-
if (
|
|
213
|
+
if (handle.exitCode !== 0 && /not logged in|not authenticated/i.test(providerOutput)) {
|
|
214
|
+
outputResult({
|
|
215
|
+
status: 'error',
|
|
216
|
+
code: 'PROVIDER_AUTH_STATE_MISMATCH',
|
|
217
|
+
error: `${spawnProvider} passed preflight but rejected the spawned execution`,
|
|
218
|
+
provider: spawnProvider,
|
|
219
|
+
exitCode: handle.exitCode,
|
|
220
|
+
retryCount: handle.retryCount || 0,
|
|
221
|
+
});
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (parsed.found && parsed.valid) {
|
|
214
226
|
outputResult({
|
|
215
227
|
status: parsed.handoff.status,
|
|
216
228
|
agent: args.agent,
|
|
@@ -222,15 +234,33 @@ async function main() {
|
|
|
222
234
|
elapsed,
|
|
223
235
|
costEstimate,
|
|
224
236
|
});
|
|
237
|
+
} else if (recoverInteractiveHandoff(args.agent, stdout, handle.exitCode)) {
|
|
238
|
+
// Interactive discovery models sometimes answer with the user-facing
|
|
239
|
+
// question but omit the machine block. Preserve the exact question while
|
|
240
|
+
// restoring the deterministic relay contract for the parent orchestrator.
|
|
241
|
+
outputResult({
|
|
242
|
+
status: 'needs_input',
|
|
243
|
+
agent: args.agent,
|
|
244
|
+
model: promptResult.model,
|
|
245
|
+
provider: promptResult.provider || args.provider || 'claude',
|
|
246
|
+
exitCode: handle.exitCode,
|
|
247
|
+
handoff: recoverInteractiveHandoff(args.agent, stdout, handle.exitCode),
|
|
248
|
+
contractRecovery: 'interactive_output_wrapped',
|
|
249
|
+
elapsed,
|
|
250
|
+
costEstimate,
|
|
251
|
+
});
|
|
225
252
|
} else {
|
|
226
|
-
//
|
|
253
|
+
// A non-interactive agent that omits the handoff violated the execution
|
|
254
|
+
// contract and cannot be treated as successful or partially complete.
|
|
227
255
|
outputResult({
|
|
228
|
-
status:
|
|
256
|
+
status: 'error',
|
|
257
|
+
code: parsed.found ? 'INVALID_HANDOFF' : 'MISSING_HANDOFF',
|
|
229
258
|
agent: args.agent,
|
|
230
259
|
model: promptResult.model,
|
|
231
260
|
provider: promptResult.provider || args.provider || 'claude',
|
|
232
261
|
exitCode: handle.exitCode,
|
|
233
|
-
handoff:
|
|
262
|
+
handoff: parsed.handoff,
|
|
263
|
+
handoffWarnings: parsed.warnings,
|
|
234
264
|
rawOutput: stdout.slice(0, 5000), // Truncate to avoid huge JSON
|
|
235
265
|
stderr: stderr.slice(0, 2000),
|
|
236
266
|
elapsed,
|
|
@@ -306,6 +336,19 @@ function outputError(message) {
|
|
|
306
336
|
process.stdout.write(JSON.stringify({ status: 'error', error: message }) + '\n');
|
|
307
337
|
}
|
|
308
338
|
|
|
339
|
+
export function recoverInteractiveHandoff(agent, stdout, exitCode) {
|
|
340
|
+
if (exitCode !== 0 || !['greenfield-wu', 'brownfield-wu', 'brief'].includes(agent) || !stdout?.trim()) return null;
|
|
341
|
+
return {
|
|
342
|
+
status: 'needs_input',
|
|
343
|
+
score: null,
|
|
344
|
+
summary: 'Interactive agent requested user input.',
|
|
345
|
+
outputs: [],
|
|
346
|
+
decisions: {},
|
|
347
|
+
blockers: [],
|
|
348
|
+
needs_input_question: stdout.trim(),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
309
352
|
/**
|
|
310
353
|
* Minimal YAML parser for session.yaml (handles flat and one-level nested keys).
|
|
311
354
|
*/
|
package/src/terminal/spawner.js
CHANGED
|
@@ -579,11 +579,13 @@ export async function spawnTerminalWithRetry(config, retryOptions = {}) {
|
|
|
579
579
|
|
|
580
580
|
// Success — return immediately
|
|
581
581
|
if (handle.exitCode === 0) {
|
|
582
|
+
handle.retryCount = attempt;
|
|
582
583
|
return handle;
|
|
583
584
|
}
|
|
584
585
|
|
|
585
586
|
// Check if failure is transient and retries remain
|
|
586
|
-
|
|
587
|
+
const combinedOutput = [...(handle.stdout || []), ...(handle.stderr || [])];
|
|
588
|
+
if (attempt < maxRetries && shouldRetry(handle.exitCode, combinedOutput, handle)) {
|
|
587
589
|
const delay = baseDelay * Math.pow(2, attempt);
|
|
588
590
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
589
591
|
continue;
|
|
@@ -617,9 +619,11 @@ export async function spawnTerminalWithRetry(config, retryOptions = {}) {
|
|
|
617
619
|
timestamp: new Date().toISOString(),
|
|
618
620
|
};
|
|
619
621
|
|
|
622
|
+
fallbackHandle.retryCount = maxRetries;
|
|
620
623
|
return fallbackHandle;
|
|
621
624
|
}
|
|
622
625
|
}
|
|
623
626
|
|
|
627
|
+
if (lastHandle) lastHandle.retryCount = lastHandle.retryCount ?? maxRetries;
|
|
624
628
|
return lastHandle;
|
|
625
629
|
}
|
|
@@ -169,7 +169,7 @@ export const SESSION_SCHEMA = {
|
|
|
169
169
|
properties: {
|
|
170
170
|
project: { type: 'string', required: true, minLength: 1 },
|
|
171
171
|
language: { type: 'string', required: true, default: 'en' },
|
|
172
|
-
pipeline_phase: { type: 'string', enum: ['discover', 'plan', 'build', 'deploy', 'completed'] },
|
|
172
|
+
pipeline_phase: { type: 'string', enum: ['discover', 'plan', 'rail', 'release', 'build', 'validate', 'deploy', 'completed'] },
|
|
173
173
|
current_agent: { type: 'string' },
|
|
174
174
|
governance_mode: { type: 'string', enum: ['planning', 'build', 'deploy'] },
|
|
175
175
|
execution_mode: { type: 'string', enum: ['interactive', 'autonomous'] },
|
|
@@ -201,7 +201,10 @@ export const CONFIG_SCHEMA = {
|
|
|
201
201
|
export const HANDOFF_SCHEMA = {
|
|
202
202
|
required: ['status'],
|
|
203
203
|
properties: {
|
|
204
|
-
status: { type: 'string', required: true, enum: [
|
|
204
|
+
status: { type: 'string', required: true, enum: [
|
|
205
|
+
'complete', 'partial', 'needs_input', 'error', 'unknown',
|
|
206
|
+
'APPROVED', 'NEEDS_REVISION', 'BLOCKED',
|
|
207
|
+
] },
|
|
205
208
|
score: { type: 'number', min: 0, max: 100 },
|
|
206
209
|
summary: { type: 'string', maxLength: 2000 },
|
|
207
210
|
outputs: { type: 'array' },
|
package/src/wizard/index.js
CHANGED