chati-dev 4.5.10 → 4.5.12
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/agents/discover/brownfield-wu.md +1 -1
- package/framework/agents/plan/tasks.md +2 -2
- package/framework/config.yaml +2 -2
- package/framework/constitution.md +21 -22
- package/framework/context/governance.md +10 -10
- package/framework/context/quality.md +6 -6
- 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/hooks/git-push-authority.js +8 -4
- package/framework/manifest.json +49 -49
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati.md +29 -20
- package/framework/schemas/session.schema.json +5 -5
- 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 +10 -2
- package/src/context/layers/l1-global.js +2 -0
- package/src/installer/core.js +94 -13
- package/src/installer/templates.js +5 -4
- package/src/installer/validator.js +8 -2
- package/src/intelligence/registry-manager.js +11 -4
- package/src/orchestrator/cli.js +158 -42
- package/src/orchestrator/pipeline-manager.js +2 -0
- package/src/orchestrator/runtime-installation-v2.js +38 -0
- package/src/orchestrator/session-manager.js +1 -1
- 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
package/src/orchestrator/cli.js
CHANGED
|
@@ -53,7 +53,10 @@ import {
|
|
|
53
53
|
recordEvent, EventType, clearTimeline,
|
|
54
54
|
} from '../intelligence/timeline.js';
|
|
55
55
|
import { updateClaudeMd } from '../memory/magic-docs.js';
|
|
56
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
loadRuntimeInstallationV2, resolveRuntimeInvocationV2,
|
|
58
|
+
describeRuntimeInstallationV2, V2_PLANNING_AGENTS,
|
|
59
|
+
} from './runtime-installation-v2.js';
|
|
57
60
|
import { compilePlanningHandoff, PLANNING_V2_TASKS_PATH } from './planning-runtime.js';
|
|
58
61
|
import { persistRailHandoff } from './rail-runtime.js';
|
|
59
62
|
|
|
@@ -76,6 +79,8 @@ const LOCK_START = '<!-- chati-lock:start -->';
|
|
|
76
79
|
const LOCK_END = '<!-- chati-lock:end -->';
|
|
77
80
|
const STATE_START = '<!-- chati-state:start -->';
|
|
78
81
|
const STATE_END = '<!-- chati-state:end -->';
|
|
82
|
+
const PAUSE_START = '<!-- chati-pause:start -->';
|
|
83
|
+
const PAUSE_END = '<!-- chati-pause:end -->';
|
|
79
84
|
|
|
80
85
|
const RESUME_MESSAGES = {
|
|
81
86
|
en: 'Session saved. Type /chati anytime to resume.',
|
|
@@ -84,6 +89,16 @@ const RESUME_MESSAGES = {
|
|
|
84
89
|
fr: 'Session sauvee. Tapez /chati pour reprendre.',
|
|
85
90
|
};
|
|
86
91
|
|
|
92
|
+
function getRuntimeAgentPipeline(projectDir, session = null) {
|
|
93
|
+
if (!loadRuntimeInstallationV2(projectDir)) return AGENT_PIPELINE;
|
|
94
|
+
const projectType = session?.project_type || session?.project?.type || 'greenfield';
|
|
95
|
+
const selectedWu = projectType === 'brownfield' ? 'brownfield-wu' : 'greenfield-wu';
|
|
96
|
+
return AGENT_PIPELINE.filter(({ name }) =>
|
|
97
|
+
V2_PLANNING_AGENTS.includes(name)
|
|
98
|
+
&& (!name.endsWith('-wu') || name === selectedWu)
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
87
102
|
// ---------------------------------------------------------------------------
|
|
88
103
|
// QA-Visual scope helpers (Fase 6 — scope isolation)
|
|
89
104
|
//
|
|
@@ -516,8 +531,13 @@ function errorResult(message, code) {
|
|
|
516
531
|
*/
|
|
517
532
|
function sessionToPipelineState(session, projectDir) {
|
|
518
533
|
const agents = {};
|
|
534
|
+
const installationV2 = loadRuntimeInstallationV2(projectDir);
|
|
535
|
+
const allowedAgents = installationV2
|
|
536
|
+
? new Set(getRuntimeAgentPipeline(projectDir, session).map(({ name }) => name))
|
|
537
|
+
: null;
|
|
519
538
|
if (session.agents) {
|
|
520
539
|
for (const [name, data] of Object.entries(session.agents)) {
|
|
540
|
+
if (allowedAgents && !allowedAgents.has(name)) continue;
|
|
521
541
|
agents[name] = {
|
|
522
542
|
status: data.status || 'pending',
|
|
523
543
|
score: data.score ?? null,
|
|
@@ -536,7 +556,9 @@ function sessionToPipelineState(session, projectDir) {
|
|
|
536
556
|
startedAt: session.started_at || new Date().toISOString(),
|
|
537
557
|
completedAt: null,
|
|
538
558
|
agents,
|
|
539
|
-
completedAgents:
|
|
559
|
+
completedAgents: allowedAgents
|
|
560
|
+
? (session.completed_agents || []).filter((name) => allowedAgents.has(name))
|
|
561
|
+
: (session.completed_agents || []),
|
|
540
562
|
currentAgent: session.current_agent || null,
|
|
541
563
|
modeTransitions: session.mode_transitions || [],
|
|
542
564
|
history: [],
|
|
@@ -680,9 +702,32 @@ function replaceBlock(content, startMarker, endMarker, newInner) {
|
|
|
680
702
|
return content.trimEnd() + '\n\n' + block + '\n';
|
|
681
703
|
}
|
|
682
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
|
+
|
|
683
730
|
function writeSessionLock(projectDir, currentAgent, stateInfo = {}) {
|
|
684
|
-
const localMdPath = join(projectDir, 'CLAUDE.local.md');
|
|
685
|
-
let content = existsSync(localMdPath) ? readFileSync(localMdPath, 'utf-8') : '';
|
|
686
731
|
|
|
687
732
|
// Phase display is authoritatively driven by session.mode on disk. Callers
|
|
688
733
|
// previously passed stateInfo.phase derived from transient intermediate
|
|
@@ -695,50 +740,51 @@ function writeSessionLock(projectDir, currentAgent, stateInfo = {}) {
|
|
|
695
740
|
if (loaded && session?.mode) authoritativePhase = session.mode;
|
|
696
741
|
} catch { /* non-fatal — fall back to stateInfo */ }
|
|
697
742
|
|
|
698
|
-
const lockInner = `## Session Lock -- ACTIVE
|
|
699
|
-
|
|
700
|
-
**Chati.dev session is ACTIVE.** Follow these rules for EVERY message:
|
|
701
|
-
|
|
702
|
-
1. Read \`${resolveFrameworkDir(projectDir)}/orchestrator/chati.md\` and follow its routing logic
|
|
703
|
-
2. Route ALL user messages through the current agent: \`${currentAgent}\`
|
|
704
|
-
3. NEVER respond outside of the Chati.dev system
|
|
705
|
-
4. The ONLY way to exit is via \`/chati exit\`, \`/chati stop\`, or \`/chati quit\``;
|
|
706
|
-
|
|
707
743
|
const stateInner = `## Current State
|
|
708
744
|
- **Agent**: ${currentAgent || 'None'}
|
|
709
745
|
- **Phase**: ${authoritativePhase || stateInfo.phase || 'discover'}
|
|
710
746
|
- **Pipeline**: ${stateInfo.position ?? 0}/${stateInfo.total ?? '?'} (${stateInfo.progress ?? 0}%)
|
|
711
747
|
- **Mode**: ${stateInfo.mode || 'interactive'}`;
|
|
712
748
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
+
}
|
|
716
763
|
}
|
|
717
764
|
|
|
718
765
|
/**
|
|
719
766
|
* Remove session lock block from CLAUDE.local.md.
|
|
720
767
|
*/
|
|
721
768
|
function removeSessionLock(projectDir, resumeMsg) {
|
|
722
|
-
const localMdPath = join(projectDir, 'CLAUDE.local.md');
|
|
723
|
-
if (!existsSync(localMdPath)) return;
|
|
724
|
-
|
|
725
|
-
let content = readFileSync(localMdPath, 'utf-8');
|
|
726
|
-
|
|
727
|
-
const lockInner = `## Session Lock
|
|
728
|
-
**Status: INACTIVE** — Type \`/chati\` to activate.`;
|
|
729
769
|
const stateInner = `## Current State
|
|
730
770
|
- **Agent**: None (ready to start)
|
|
731
771
|
- **Pipeline**: Pre-start
|
|
732
772
|
- **Mode**: interactive`;
|
|
733
773
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
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');
|
|
739
787
|
}
|
|
740
|
-
|
|
741
|
-
writeFileSync(localMdPath, content.trimEnd() + '\n', 'utf-8');
|
|
742
788
|
}
|
|
743
789
|
|
|
744
790
|
// ---------------------------------------------------------------------------
|
|
@@ -767,11 +813,12 @@ async function handleNext(projectDir) {
|
|
|
767
813
|
const activating = ['activate_interactive', 'spawn_routed_interactive', 'spawn_autonomous', 'spawn_team', 'spawn_parallel'];
|
|
768
814
|
if (activating.includes(result.action)) {
|
|
769
815
|
try {
|
|
816
|
+
const runtimePipeline = getRuntimeAgentPipeline(projectDir, result.session);
|
|
770
817
|
writeSessionLock(projectDir, result.agent, {
|
|
771
818
|
phase: result.phase || 'discover',
|
|
772
819
|
mode: result.session?.execution_mode || 'interactive',
|
|
773
820
|
position: result.pipeline_progress?.completedAgents?.length ?? 0,
|
|
774
|
-
total:
|
|
821
|
+
total: runtimePipeline.length,
|
|
775
822
|
progress: result.pipeline_progress?.progress ?? 0,
|
|
776
823
|
});
|
|
777
824
|
} catch { /* non-fatal */ }
|
|
@@ -798,6 +845,38 @@ async function _handleNextInner(projectDir) {
|
|
|
798
845
|
return errorResult(`Session invalid: ${validation.reason}`, 'SESSION_INVALID');
|
|
799
846
|
}
|
|
800
847
|
|
|
848
|
+
const installationV2 = loadRuntimeInstallationV2(projectDir);
|
|
849
|
+
const runtimePipeline = getRuntimeAgentPipeline(projectDir, session);
|
|
850
|
+
if (installationV2 && session.rail_handoff_id) {
|
|
851
|
+
const completedPlanning = runtimePipeline.length;
|
|
852
|
+
return {
|
|
853
|
+
action: 'rail_execute',
|
|
854
|
+
agent: 'rail',
|
|
855
|
+
phase: 'rail',
|
|
856
|
+
handoff_id: session.rail_handoff_id,
|
|
857
|
+
rail_state: session.rail_state || 'ready',
|
|
858
|
+
pipeline_progress: {
|
|
859
|
+
phase: 'rail', progress: 100, completedCount: completedPlanning,
|
|
860
|
+
totalAgents: completedPlanning, completedAgents: session.completed_agents || [],
|
|
861
|
+
currentAgent: 'rail', nextAgent: null,
|
|
862
|
+
},
|
|
863
|
+
session: {
|
|
864
|
+
language: session.language,
|
|
865
|
+
project_type: session.project_type || session.project?.type,
|
|
866
|
+
execution_mode: session.execution_mode,
|
|
867
|
+
},
|
|
868
|
+
status_summary: 'Planning is sealed. Resume execution through RAIL.',
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const legacyExecutionAgents = new Set(['dev', 'qa-implementation', 'qa-visual', 'devops']);
|
|
873
|
+
if (installationV2 && legacyExecutionAgents.has(session.current_agent)) {
|
|
874
|
+
return errorResult(
|
|
875
|
+
'A v2 installation cannot resume a legacy Build/Deploy agent. Re-seal Tasks to create the RAIL handoff.',
|
|
876
|
+
'V2_RAIL_HANDOFF_REQUIRED',
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
|
|
801
880
|
const pipelineState = sessionToPipelineState(session, projectDir);
|
|
802
881
|
|
|
803
882
|
if (isPipelineComplete(pipelineState)) {
|
|
@@ -836,7 +915,7 @@ async function _handleNextInner(projectDir) {
|
|
|
836
915
|
handoff_status: { valid: true, missing: [], warnings: ['Resuming dev as Build Team'] },
|
|
837
916
|
gate_status: { canAdvance: true, reason: 'Build Team formation (Article XXI)' },
|
|
838
917
|
model_info: resolveAgentModel(agent, projectDir),
|
|
839
|
-
context_bracket: estimateContextBracket(completedAgents.length,
|
|
918
|
+
context_bracket: estimateContextBracket(completedAgents.length, runtimePipeline.length),
|
|
840
919
|
pipeline_progress: getPipelineProgress(pipelineState),
|
|
841
920
|
session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
|
|
842
921
|
status_summary: `Forming Build Team: dev + qa-implementation.`,
|
|
@@ -858,7 +937,7 @@ async function _handleNextInner(projectDir) {
|
|
|
858
937
|
handoff_status: { valid: true, missing: [], warnings: ['Resuming in-progress agent'] },
|
|
859
938
|
gate_status: { canAdvance: true, reason: 'Agent already in progress' },
|
|
860
939
|
model_info: modelInfo,
|
|
861
|
-
context_bracket: estimateContextBracket(completedAgents.length,
|
|
940
|
+
context_bracket: estimateContextBracket(completedAgents.length, runtimePipeline.length),
|
|
862
941
|
pipeline_progress: getPipelineProgress(pipelineState),
|
|
863
942
|
session: { language: session.language, project_type: session.project_type || session.project?.type, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
|
|
864
943
|
status_summary: `Resuming ${agent} (in progress).`,
|
|
@@ -887,7 +966,7 @@ async function _handleNextInner(projectDir) {
|
|
|
887
966
|
handoff_status: { valid: true, missing: [], warnings: [] },
|
|
888
967
|
gate_status: { canAdvance: true, reason: 'First agent in pipeline' },
|
|
889
968
|
model_info: modelInfo,
|
|
890
|
-
context_bracket: estimateContextBracket(0,
|
|
969
|
+
context_bracket: estimateContextBracket(0, runtimePipeline.length),
|
|
891
970
|
pipeline_progress: getPipelineProgress(pipelineState),
|
|
892
971
|
session: { language: session.language, project_type: projectType, execution_mode: session.execution_mode, user_level: session.user_level || 'auto' },
|
|
893
972
|
status_summary: `Starting pipeline with ${firstAgent}.`,
|
|
@@ -927,6 +1006,13 @@ async function _handleNextInner(projectDir) {
|
|
|
927
1006
|
const agentDef = getAgentDefinition(nextAgent);
|
|
928
1007
|
const isInteractive = INTERACTIVE_AGENTS.includes(nextAgent);
|
|
929
1008
|
|
|
1009
|
+
if (installationV2 && legacyExecutionAgents.has(nextAgent)) {
|
|
1010
|
+
return errorResult(
|
|
1011
|
+
'Planning reached the v2 execution boundary without a sealed RAIL handoff. Complete or re-run Tasks instead of entering Build/Deploy.',
|
|
1012
|
+
'V2_RAIL_HANDOFF_REQUIRED',
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
930
1016
|
// Validate handoff from previous agent
|
|
931
1017
|
let handoffStatus;
|
|
932
1018
|
try {
|
|
@@ -1011,7 +1097,7 @@ async function _handleNextInner(projectDir) {
|
|
|
1011
1097
|
recordEvent(projectDir, EventType.AGENT_ACTIVATED, nextAgent, { action, phase: agentDef?.phase });
|
|
1012
1098
|
|
|
1013
1099
|
const progress = getPipelineProgress(pipelineState);
|
|
1014
|
-
const bracket = estimateContextBracket(completedAgents.length,
|
|
1100
|
+
const bracket = estimateContextBracket(completedAgents.length, runtimePipeline.length);
|
|
1015
1101
|
|
|
1016
1102
|
const result = {
|
|
1017
1103
|
action,
|
|
@@ -1851,6 +1937,8 @@ async function handleAdvance(projectDir, args) {
|
|
|
1851
1937
|
// the handoff identifier and must enter the project-local RAIL lifecycle.
|
|
1852
1938
|
if (handoffData.rail_handoff) {
|
|
1853
1939
|
const railState = {
|
|
1940
|
+
mode: 'rail',
|
|
1941
|
+
project: { ...session.project, state: 'rail' },
|
|
1854
1942
|
current_agent: 'rail',
|
|
1855
1943
|
last_handoff: agent,
|
|
1856
1944
|
pipeline_position: (session.completed_agents || []).length + 1,
|
|
@@ -1860,12 +1948,28 @@ async function handleAdvance(projectDir, args) {
|
|
|
1860
1948
|
};
|
|
1861
1949
|
const saved = await updateSession(projectDir, railState);
|
|
1862
1950
|
if (saved && saved.saved === false) return errorResult(`Session update failed: ${saved.error}`, 'SESSION_WRITE_FAILED');
|
|
1951
|
+
const runtimePipeline = getRuntimeAgentPipeline(projectDir, session);
|
|
1952
|
+
try {
|
|
1953
|
+
writeSessionLock(projectDir, 'rail', {
|
|
1954
|
+
phase: 'rail', mode: session.execution_mode || 'interactive',
|
|
1955
|
+
position: runtimePipeline.length, total: runtimePipeline.length, progress: 100,
|
|
1956
|
+
});
|
|
1957
|
+
updateClaudeMd(projectDir, {
|
|
1958
|
+
currentAgent: 'rail',
|
|
1959
|
+
progress: { progress: 100, completedCount: runtimePipeline.length, totalAgents: runtimePipeline.length },
|
|
1960
|
+
});
|
|
1961
|
+
} catch { /* non-fatal: the canonical session state has already been persisted */ }
|
|
1863
1962
|
return {
|
|
1864
1963
|
advanced: true,
|
|
1865
1964
|
agent_completed: agent,
|
|
1866
1965
|
score,
|
|
1867
1966
|
rail_ready: true,
|
|
1868
1967
|
rail_handoff: handoffData.rail_handoff,
|
|
1968
|
+
pipeline_progress: {
|
|
1969
|
+
phase: 'rail', progress: 100, completedCount: runtimePipeline.length,
|
|
1970
|
+
totalAgents: runtimePipeline.length, completedAgents: [...(session.completed_agents || []), agent],
|
|
1971
|
+
currentAgent: 'rail', nextAgent: null,
|
|
1972
|
+
},
|
|
1869
1973
|
next: {
|
|
1870
1974
|
action: 'rail_execute',
|
|
1871
1975
|
handoff_id: handoffData.rail_handoff.handoff.handoff_id,
|
|
@@ -2277,8 +2381,10 @@ async function handleStatus(projectDir) {
|
|
|
2277
2381
|
}
|
|
2278
2382
|
|
|
2279
2383
|
// Build model map for all agents
|
|
2384
|
+
const runtimePipeline = getRuntimeAgentPipeline(projectDir, session);
|
|
2385
|
+
const runtimeAgentNames = new Set(runtimePipeline.map(({ name }) => name));
|
|
2280
2386
|
const agentModels = {};
|
|
2281
|
-
for (const agentDef of
|
|
2387
|
+
for (const agentDef of runtimePipeline) {
|
|
2282
2388
|
agentModels[agentDef.name] = resolveAgentModel(agentDef.name, projectDir);
|
|
2283
2389
|
}
|
|
2284
2390
|
|
|
@@ -2309,9 +2415,9 @@ async function handleStatus(projectDir) {
|
|
|
2309
2415
|
handoff_history: handoffHistory,
|
|
2310
2416
|
deviation_count: (session.deviations || []).length,
|
|
2311
2417
|
backlog: session.backlog || [],
|
|
2312
|
-
agents: session.agents || {},
|
|
2418
|
+
agents: Object.fromEntries(Object.entries(session.agents || {}).filter(([name]) => runtimeAgentNames.has(name))),
|
|
2313
2419
|
agent_models: agentModels,
|
|
2314
|
-
context_bracket: estimateContextBracket((session.completed_agents || []).length,
|
|
2420
|
+
context_bracket: estimateContextBracket((session.completed_agents || []).length, runtimePipeline.length),
|
|
2315
2421
|
context: {
|
|
2316
2422
|
model: activeModel,
|
|
2317
2423
|
provider: activeProvider,
|
|
@@ -2434,15 +2540,25 @@ async function handleExit(projectDir, args) {
|
|
|
2434
2540
|
}
|
|
2435
2541
|
|
|
2436
2542
|
async function handleProviders(projectDir) {
|
|
2543
|
+
const artifact = loadRuntimeInstallationV2(projectDir);
|
|
2544
|
+
if (artifact) {
|
|
2545
|
+
const { session } = loadSession(projectDir);
|
|
2546
|
+
const planningAgentModels = {};
|
|
2547
|
+
for (const { name } of getRuntimeAgentPipeline(projectDir, session)) {
|
|
2548
|
+
planningAgentModels[name] = resolveAgentModel(name, projectDir);
|
|
2549
|
+
}
|
|
2550
|
+
return {
|
|
2551
|
+
...describeRuntimeInstallationV2(artifact),
|
|
2552
|
+
planning_agent_models: planningAgentModels,
|
|
2553
|
+
};
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2437
2556
|
const agentModels = {};
|
|
2438
2557
|
for (const agentDef of AGENT_PIPELINE) {
|
|
2439
2558
|
agentModels[agentDef.name] = resolveAgentModel(agentDef.name, projectDir);
|
|
2440
2559
|
}
|
|
2441
2560
|
|
|
2442
|
-
const
|
|
2443
|
-
const enabledProviders = artifact
|
|
2444
|
-
? artifact.installation.enabled_providers.map(({ provider_id, harness_id }) => ({ provider_id, harness_id }))
|
|
2445
|
-
: [...new Set(Object.values(agentModels).map(({ provider }) => provider).filter(Boolean))].map((harness_id) => ({ harness_id }));
|
|
2561
|
+
const enabledProviders = [...new Set(Object.values(agentModels).map(({ provider }) => provider).filter(Boolean))].map((harness_id) => ({ harness_id }));
|
|
2446
2562
|
|
|
2447
2563
|
return {
|
|
2448
2564
|
enabled_providers: enabledProviders,
|
|
@@ -10,6 +10,44 @@ const AGENT_ACTIONS = Object.freeze({
|
|
|
10
10
|
devops: 'build',
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
+
export const V2_PLANNING_AGENTS = Object.freeze([
|
|
14
|
+
'greenfield-wu', 'brownfield-wu', 'brief',
|
|
15
|
+
'detail', 'architect', 'ux', 'qa-planning', 'phases', 'tasks',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const RAIL_ACTIONS = new Set(['build', 'review']);
|
|
19
|
+
|
|
20
|
+
export function describeRuntimeInstallationV2(artifact) {
|
|
21
|
+
const railExecutionPool = artifact.installation.enabled_providers.map((binding) => {
|
|
22
|
+
const capabilities = binding.allowed_models.flatMap((modelId) => {
|
|
23
|
+
const model = artifact.capability_snapshot.models.find((candidate) =>
|
|
24
|
+
candidate.provider_id === binding.provider_id && candidate.model_id === modelId
|
|
25
|
+
);
|
|
26
|
+
if (!model) return [];
|
|
27
|
+
const actions = model.actions.filter((action) => RAIL_ACTIONS.has(action));
|
|
28
|
+
if (actions.length === 0) return [];
|
|
29
|
+
return [{ model_id: modelId, actions, tier: model.tier || null }];
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
provider_id: binding.provider_id,
|
|
33
|
+
harness_id: binding.harness_id,
|
|
34
|
+
models: capabilities.map(({ model_id }) => model_id),
|
|
35
|
+
capabilities,
|
|
36
|
+
};
|
|
37
|
+
}).filter(({ models }) => models.length > 0);
|
|
38
|
+
|
|
39
|
+
return Object.freeze({
|
|
40
|
+
architecture: 'chati-v2-rail',
|
|
41
|
+
enabled_providers: artifact.installation.enabled_providers.map(({ provider_id, harness_id }) => ({ provider_id, harness_id })),
|
|
42
|
+
rail_execution_pool: railExecutionPool,
|
|
43
|
+
release_lane: {
|
|
44
|
+
authority: 'explicit-human-authorization',
|
|
45
|
+
execution_authority: 'rail',
|
|
46
|
+
legacy_devops_agent: false,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
13
51
|
export function loadRuntimeInstallationV2(projectDir, { clock = () => new Date() } = {}) {
|
|
14
52
|
const path = join(projectDir, INSTALLATION_ARTIFACT_PATH);
|
|
15
53
|
if (!existsSync(path)) return null;
|
|
@@ -973,7 +973,7 @@ export function validateSession(projectDir) {
|
|
|
973
973
|
}
|
|
974
974
|
|
|
975
975
|
// Validate mode
|
|
976
|
-
const validModes = ['discover', 'plan', 'build', 'validate', 'deploy', 'completed'];
|
|
976
|
+
const validModes = ['discover', 'plan', 'rail', 'release', 'build', 'validate', 'deploy', 'completed'];
|
|
977
977
|
if (!validModes.includes(session.mode)) {
|
|
978
978
|
return {
|
|
979
979
|
exists: true,
|
|
@@ -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
|
+
|