create-byan-agent 2.45.0 → 2.47.1
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/CHANGELOG.md +86 -0
- package/install/templates/.claude/CLAUDE.md +12 -2
- package/install/templates/.claude/hooks/agent-gate-check.js +53 -0
- package/install/templates/.claude/hooks/inject-voice-anchor.js +15 -1
- package/install/templates/.claude/hooks/lib/agent-gate.js +127 -0
- package/install/templates/.claude/rules/agent-entry-gate.md +83 -0
- package/install/templates/.claude/settings.json +4 -0
- package/install/templates/.claude/skills/byan-byan/SKILL.md +14 -0
- package/install/templates/.claude/workflows/intelligent-dispatch.js +68 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/agent-matcher.js +167 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/codex-bridge.js +176 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/dispatch-blackboard.js +114 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/dispatch-orchestrator.js +116 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/dispatch-router.js +149 -0
- package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +1 -1
- package/install/templates/dist/skill-bundles/byan-byan.zip +0 -0
- package/install/templates/docs/intelligent-dispatch.md +84 -0
- package/package.json +1 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// F1 — the agent SUITABILITY matcher: the pre-filter Hermes+BYAN present at the
|
|
2
|
+
// entry gate. Given a task description and the agent roster, it ranks the
|
|
3
|
+
// candidates and returns a verdict {fit | no-fit}. It NEVER decides alone — it
|
|
4
|
+
// PROPOSES; the user validates (double validation IA + human). A no-fit verdict
|
|
5
|
+
// is the signal to run the interview -> web-research -> create-agent path instead
|
|
6
|
+
// of a workflow.
|
|
7
|
+
//
|
|
8
|
+
// Pure core (matchAgents) has no I/O so it is fully unit-testable. The loader
|
|
9
|
+
// (loadRoster) reads the roster from _byan/_config/agent-manifest.csv, isolated
|
|
10
|
+
// from the scoring.
|
|
11
|
+
//
|
|
12
|
+
// Scoring = curated Hermes trigger words (strong signal) layered on top of a
|
|
13
|
+
// plain text overlap with each agent's title+role. Deterministic: same input,
|
|
14
|
+
// same ranking.
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
|
|
18
|
+
// Fit verdict floor. A best score at/above this = at least one solid signal that
|
|
19
|
+
// an existing agent covers the need; below it, no agent fits -> interview.
|
|
20
|
+
export const FIT_THRESHOLD = 3;
|
|
21
|
+
|
|
22
|
+
// Curated Hermes routing signal: trigger word (deburred) -> agent name. This is
|
|
23
|
+
// the strong layer (weighted x3 in the score) because these are the hand-picked
|
|
24
|
+
// dispatch cues, not incidental text. Kept in sync in spirit with
|
|
25
|
+
// .claude/rules/hermes-dispatcher.md.
|
|
26
|
+
export const DOMAIN_KEYWORDS = Object.freeze({
|
|
27
|
+
analyst: ['analyse', 'analyser', 'requirement', 'requirements', 'brief', 'etude', 'marche', 'concurrent', 'besoin'],
|
|
28
|
+
architect: ['architecture', 'architect', 'conception', 'concois', 'systeme', 'stack', 'scalab', 'infrastructure', 'api'],
|
|
29
|
+
dev: ['code', 'coder', 'implemente', 'implementer', 'implement', 'developpe', 'dev', 'feature', 'refactor', 'bug', 'debug', 'fix', 'script', 'module', 'fonction', 'endpoint'],
|
|
30
|
+
quinn: ['test', 'tester', 'qa', 'coverage', 'couverture', 'assurance qualite'],
|
|
31
|
+
'tea-tea': ['atdd', 'nfr', 'test architect', 'ci/cd', 'automation de test'],
|
|
32
|
+
sm: ['sprint', 'backlog', 'scrum', 'planifier', 'planification', 'story', 'epic'],
|
|
33
|
+
'tech-writer': ['documente', 'documenter', 'documentation', 'guide', 'readme', 'redige'],
|
|
34
|
+
'ux-designer': ['ux', 'ui', 'mockup', 'maquette', 'interface', 'wireframe', 'design ux'],
|
|
35
|
+
pm: ['prd', 'produit', 'roadmap', 'product', 'vision produit'],
|
|
36
|
+
byan: ['creer un agent', 'nouvel agent', 'workflow byan', 'nouveau module'],
|
|
37
|
+
'brainstorming-coach': ['brainstorm', 'idee', 'ideation', 'innovation', 'remue-meninge'],
|
|
38
|
+
carmack: ['optimiser', 'optimisation', 'token', 'performance', 'perf'],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const STOPWORDS = new Set([
|
|
42
|
+
'le', 'la', 'les', 'un', 'une', 'des', 'de', 'du', 'et', 'ou', 'pour', 'avec',
|
|
43
|
+
'sur', 'dans', 'que', 'qui', 'ce', 'cette', 'mon', 'ma', 'mes', 'ton', 'son',
|
|
44
|
+
'the', 'a', 'an', 'of', 'to', 'and', 'or', 'for', 'with', 'on', 'in', 'is',
|
|
45
|
+
'fait', 'faire', 'veux', 'peux', 'ajoute', 'cree', 'creer', 'besoin', 'agent',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// deburr: lowercase + strip French diacritics so "marché" matches "marche".
|
|
49
|
+
export function deburr(s) {
|
|
50
|
+
return String(s == null ? '' : s)
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.normalize('NFD')
|
|
53
|
+
.replace(/[̀-ͯ]/g, ''); // strip combining diacritics
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function tokenize(text) {
|
|
57
|
+
return deburr(text)
|
|
58
|
+
.split(/[^a-z0-9]+/)
|
|
59
|
+
.filter((w) => w.length >= 3 && !STOPWORDS.has(w));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// scoreAgent — pure per-agent score. keyword hits (x3) + title/role text overlap.
|
|
63
|
+
export function scoreAgent(taskText, agent) {
|
|
64
|
+
const task = deburr(taskText);
|
|
65
|
+
const taskTokens = new Set(tokenize(taskText));
|
|
66
|
+
let keywordScore = 0;
|
|
67
|
+
const hits = [];
|
|
68
|
+
const kws = DOMAIN_KEYWORDS[agent.name] || [];
|
|
69
|
+
for (const kw of kws) {
|
|
70
|
+
if (task.includes(deburr(kw))) { keywordScore += 1; hits.push(kw); }
|
|
71
|
+
}
|
|
72
|
+
const agentTokens = tokenize(`${agent.title || ''} ${agent.role || ''}`);
|
|
73
|
+
let textScore = 0;
|
|
74
|
+
for (const t of agentTokens) if (taskTokens.has(t)) textScore += 1;
|
|
75
|
+
return { score: keywordScore * 3 + textScore, keywordScore, textScore, hits };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// matchAgents(taskText, roster) -> {
|
|
79
|
+
// fit, needsInterview, best, candidates: [{ name, title, score, why }]
|
|
80
|
+
// }
|
|
81
|
+
// candidates are ranked desc and limited to those with a non-zero score.
|
|
82
|
+
export function matchAgents(taskText, roster, { threshold = FIT_THRESHOLD, limit = 3 } = {}) {
|
|
83
|
+
const scored = (Array.isArray(roster) ? roster : [])
|
|
84
|
+
.map((agent) => {
|
|
85
|
+
const s = scoreAgent(taskText, agent);
|
|
86
|
+
return {
|
|
87
|
+
name: agent.name,
|
|
88
|
+
title: agent.title || agent.displayName || agent.name,
|
|
89
|
+
score: s.score,
|
|
90
|
+
why: s.hits.length ? `mots-cles: ${s.hits.join(', ')}` : (s.textScore ? 'recoupement titre/role' : ''),
|
|
91
|
+
};
|
|
92
|
+
})
|
|
93
|
+
.filter((c) => c.score > 0)
|
|
94
|
+
.sort((a, b) => b.score - a.score);
|
|
95
|
+
|
|
96
|
+
const candidates = scored.slice(0, limit);
|
|
97
|
+
const best = candidates[0] || null;
|
|
98
|
+
const fit = Boolean(best && best.score >= threshold);
|
|
99
|
+
return {
|
|
100
|
+
fit,
|
|
101
|
+
needsInterview: !fit,
|
|
102
|
+
best,
|
|
103
|
+
candidates,
|
|
104
|
+
recommendation: fit
|
|
105
|
+
? `Un agent adapte existe : @${best.name} (${best.why}). A valider.`
|
|
106
|
+
: "Aucun agent adapte trouve. Proposer une interview pour en creer un sur mesure.",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- loader (I/O isolated) ------------------------------------------------
|
|
111
|
+
|
|
112
|
+
// Minimal RFC-4180-ish CSV parser: handles double-quoted fields with embedded
|
|
113
|
+
// commas and escaped quotes (""). Enough for agent-manifest.csv; not a general
|
|
114
|
+
// CSV engine.
|
|
115
|
+
export function parseCsv(text) {
|
|
116
|
+
const rows = [];
|
|
117
|
+
let row = [];
|
|
118
|
+
let field = '';
|
|
119
|
+
let inQuotes = false;
|
|
120
|
+
const src = String(text == null ? '' : text);
|
|
121
|
+
for (let i = 0; i < src.length; i++) {
|
|
122
|
+
const c = src[i];
|
|
123
|
+
if (inQuotes) {
|
|
124
|
+
if (c === '"') {
|
|
125
|
+
if (src[i + 1] === '"') { field += '"'; i++; }
|
|
126
|
+
else inQuotes = false;
|
|
127
|
+
} else field += c;
|
|
128
|
+
} else if (c === '"') {
|
|
129
|
+
inQuotes = true;
|
|
130
|
+
} else if (c === ',') {
|
|
131
|
+
row.push(field); field = '';
|
|
132
|
+
} else if (c === '\n' || c === '\r') {
|
|
133
|
+
if (c === '\r' && src[i + 1] === '\n') i++;
|
|
134
|
+
row.push(field); field = '';
|
|
135
|
+
if (row.length > 1 || row[0] !== '') rows.push(row);
|
|
136
|
+
row = [];
|
|
137
|
+
} else field += c;
|
|
138
|
+
}
|
|
139
|
+
if (field !== '' || row.length) { row.push(field); rows.push(row); }
|
|
140
|
+
return rows;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// rosterFromCsv(text) -> [{ name, displayName, title, role }]. Pure (no fs).
|
|
144
|
+
export function rosterFromCsv(text) {
|
|
145
|
+
const rows = parseCsv(text);
|
|
146
|
+
if (!rows.length) return [];
|
|
147
|
+
const header = rows[0].map((h) => h.trim());
|
|
148
|
+
const idx = (col) => header.indexOf(col);
|
|
149
|
+
const iName = idx('name'), iDisplay = idx('displayName'), iTitle = idx('title'), iRole = idx('role');
|
|
150
|
+
return rows.slice(1)
|
|
151
|
+
.filter((r) => r[iName])
|
|
152
|
+
.map((r) => ({
|
|
153
|
+
name: r[iName],
|
|
154
|
+
displayName: iDisplay >= 0 ? r[iDisplay] : '',
|
|
155
|
+
title: iTitle >= 0 ? r[iTitle] : '',
|
|
156
|
+
role: iRole >= 0 ? r[iRole] : '',
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// loadRoster(manifestPath) -> roster array, or [] if the file is unreadable.
|
|
161
|
+
export function loadRoster(manifestPath) {
|
|
162
|
+
try {
|
|
163
|
+
return rosterFromCsv(fs.readFileSync(manifestPath, 'utf8'));
|
|
164
|
+
} catch {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// F2 — the Codex TRANSPORT, behind a clean adapter seam.
|
|
2
|
+
//
|
|
3
|
+
// Design (agreed with the user): the durable core (F1 router, F4 loop) must not
|
|
4
|
+
// care HOW we talk to Codex. So the transport is a swappable adapter:
|
|
5
|
+
// - codex-exec : SHIPPED. Runs `codex exec` non-interactively, read-only, and
|
|
6
|
+
// asks Codex for a unified DIFF on stdout. Codex never writes a
|
|
7
|
+
// file (works under Landlock) ; Claude applies the diff. This
|
|
8
|
+
// also keeps the verification red line: Claude, not Codex, is
|
|
9
|
+
// the one that lands and later checks the change.
|
|
10
|
+
// - codex-mcp : V2 SLOT. `codex mcp-server` exposes Codex as a callable tool
|
|
11
|
+
// for the tightest live coupling. Not wired yet — the adapter
|
|
12
|
+
// exists and honestly reports { available: false } so the loop
|
|
13
|
+
// can detect it and stay on codex-exec. It is a declared seam,
|
|
14
|
+
// not a stub pretending to work.
|
|
15
|
+
//
|
|
16
|
+
// Purity: everything that BUILDS a command or a prompt is pure and unit-tested.
|
|
17
|
+
// Everything that SPAWNS a process is isolated behind an injected `runner`, so
|
|
18
|
+
// tests exercise the bridge without ever invoking the real Codex CLI.
|
|
19
|
+
//
|
|
20
|
+
// Failure is a value, not an exception: runCodexExec returns { ok:false, reason }
|
|
21
|
+
// on any wire/availability/error path, so F4 can fall back to Claude (the "repli
|
|
22
|
+
// quand Codex sature" the user asked for) instead of crashing the loop.
|
|
23
|
+
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { CODEX_MODEL, EFFORTS, assertNoFable } from './dispatch-router.js';
|
|
30
|
+
|
|
31
|
+
export const ADAPTERS = Object.freeze({ EXEC: 'codex-exec', MCP: 'codex-mcp' });
|
|
32
|
+
|
|
33
|
+
// Codex config keys we drive via `codex exec -c key=value` (TOML values). Kept
|
|
34
|
+
// as named constants: if a Codex release renames one, this is the only edit.
|
|
35
|
+
// sandbox_permissions=["disk-full-read-access"] = read-only (from `codex --help`).
|
|
36
|
+
export const CODEX_CONFIG_KEYS = Object.freeze({
|
|
37
|
+
MODEL: 'model',
|
|
38
|
+
EFFORT: 'model_reasoning_effort',
|
|
39
|
+
SANDBOX: 'sandbox_permissions',
|
|
40
|
+
});
|
|
41
|
+
const READONLY_SANDBOX_TOML = '["disk-full-read-access"]';
|
|
42
|
+
|
|
43
|
+
// wrapDiffPrompt(task) — wrap a task so Codex returns a unified diff ONLY and does
|
|
44
|
+
// not attempt to modify files. The read-only sandbox already blocks writes; the
|
|
45
|
+
// prompt makes the intent explicit so Codex emits an appliable patch, not prose.
|
|
46
|
+
export function wrapDiffPrompt(task) {
|
|
47
|
+
const body = String(task == null ? '' : task).trim();
|
|
48
|
+
return [
|
|
49
|
+
'You are a delegated implementer. Do NOT modify files directly.',
|
|
50
|
+
'Produce ONLY a unified diff (git apply compatible) implementing the task,',
|
|
51
|
+
'between the exact markers <<<DIFF and DIFF>>>. No prose outside the markers.',
|
|
52
|
+
'',
|
|
53
|
+
'Task:',
|
|
54
|
+
body,
|
|
55
|
+
].join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// extractDiff(stdout) — pull the unified diff back out of Codex stdout. Prefers
|
|
59
|
+
// the explicit markers from wrapDiffPrompt; falls back to the first `diff --git`
|
|
60
|
+
// / `---`+`+++` block so a marker-less answer is still usable. Returns '' when no
|
|
61
|
+
// diff is present (caller treats that as "nothing to apply").
|
|
62
|
+
export function extractDiff(stdout) {
|
|
63
|
+
const text = String(stdout == null ? '' : stdout);
|
|
64
|
+
const marked = text.match(/<<<DIFF\s*([\s\S]*?)\s*DIFF>>>/);
|
|
65
|
+
if (marked) return marked[1].trim();
|
|
66
|
+
const gitDiff = text.match(/(diff --git [\s\S]*)$/);
|
|
67
|
+
if (gitDiff) return gitDiff[1].trim();
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// buildCodexExecArgs({ model, effort, prompt }) -> argv for the `codex` binary.
|
|
72
|
+
// Pure. Fable is refused here too (defence in depth). Effort is validated to the
|
|
73
|
+
// known set; the prompt is the trailing positional argument.
|
|
74
|
+
export function buildCodexExecArgs({ model = CODEX_MODEL, effort = EFFORTS.MEDIUM, prompt = '' } = {}) {
|
|
75
|
+
assertNoFable(model);
|
|
76
|
+
const effortValue = Object.values(EFFORTS).includes(effort) ? effort : EFFORTS.MEDIUM;
|
|
77
|
+
return [
|
|
78
|
+
'exec',
|
|
79
|
+
'-c', `${CODEX_CONFIG_KEYS.MODEL}="${model}"`,
|
|
80
|
+
'-c', `${CODEX_CONFIG_KEYS.EFFORT}="${effortValue}"`,
|
|
81
|
+
'-c', `${CODEX_CONFIG_KEYS.SANDBOX}=${READONLY_SANDBOX_TOML}`,
|
|
82
|
+
wrapDiffPrompt(prompt),
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// buildGitApplyArgs(diffPath) -> argv for `git`. --check first is the caller's
|
|
87
|
+
// choice; this returns the apply form. Pure.
|
|
88
|
+
export function buildGitApplyArgs(diffPath) {
|
|
89
|
+
return ['apply', '--whitespace=nowarn', String(diffPath)];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The default process runner. Isolated so every other function stays testable
|
|
93
|
+
// with a fake. Returns a normalized { code, stdout, stderr } shape.
|
|
94
|
+
export function defaultRunner({ cmd, args = [], input = '', cwd = process.cwd(), timeoutMs = 120000 }) {
|
|
95
|
+
const res = spawnSync(cmd, args, { input, cwd, timeout: timeoutMs, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
|
|
96
|
+
return {
|
|
97
|
+
code: typeof res.status === 'number' ? res.status : (res.error ? -1 : null),
|
|
98
|
+
stdout: res.stdout || '',
|
|
99
|
+
stderr: res.stderr || (res.error ? String(res.error.message) : ''),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// runCodexExec({ model, effort, prompt, cwd, runner, timeoutMs }) ->
|
|
104
|
+
// { ok:true, diff, raw } | { ok:false, reason, detail }
|
|
105
|
+
// reason: 'unavailable' (binary missing / spawn failed) | 'error' (non-zero exit)
|
|
106
|
+
// | 'empty' (ran but produced no diff). Never throws for a wire issue —
|
|
107
|
+
// F4 reads ok:false and falls back to Claude.
|
|
108
|
+
export function runCodexExec({ model = CODEX_MODEL, effort = EFFORTS.MEDIUM, prompt = '', cwd = process.cwd(), runner = defaultRunner, timeoutMs = 120000 } = {}) {
|
|
109
|
+
const args = buildCodexExecArgs({ model, effort, prompt });
|
|
110
|
+
let res;
|
|
111
|
+
try {
|
|
112
|
+
res = runner({ cmd: 'codex', args, cwd, timeoutMs });
|
|
113
|
+
} catch (e) {
|
|
114
|
+
return { ok: false, reason: 'unavailable', detail: String(e && e.message || e) };
|
|
115
|
+
}
|
|
116
|
+
if (!res || res.code == null || res.code < 0) {
|
|
117
|
+
return { ok: false, reason: 'unavailable', detail: (res && res.stderr) || 'codex not runnable' };
|
|
118
|
+
}
|
|
119
|
+
if (res.code !== 0) {
|
|
120
|
+
return { ok: false, reason: 'error', detail: res.stderr || `exit ${res.code}` };
|
|
121
|
+
}
|
|
122
|
+
const diff = extractDiff(res.stdout);
|
|
123
|
+
if (!diff) return { ok: false, reason: 'empty', detail: 'codex produced no diff' };
|
|
124
|
+
return { ok: true, diff, raw: res.stdout };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// applyDiff({ diff, cwd, runner }) -> { ok, detail }. Claude-side apply of the
|
|
128
|
+
// Codex diff via `git apply`. Writes the diff to a temp file (isolated I/O),
|
|
129
|
+
// runs git apply, cleans up. Guards against an empty diff.
|
|
130
|
+
export function applyDiff({ diff, cwd = process.cwd(), runner = defaultRunner } = {}) {
|
|
131
|
+
const body = String(diff == null ? '' : diff).trim();
|
|
132
|
+
if (!body) return { ok: false, detail: 'empty diff' };
|
|
133
|
+
const tmp = path.join(os.tmpdir(), `byan-codex-diff-${process.pid}-${body.length}.patch`);
|
|
134
|
+
try {
|
|
135
|
+
fs.writeFileSync(tmp, body.endsWith('\n') ? body : body + '\n');
|
|
136
|
+
const res = runner({ cmd: 'git', args: buildGitApplyArgs(tmp), cwd, timeoutMs: 30000 });
|
|
137
|
+
if (res && res.code === 0) return { ok: true, detail: 'applied' };
|
|
138
|
+
return { ok: false, detail: (res && res.stderr) || 'git apply failed' };
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return { ok: false, detail: String(e && e.message || e) };
|
|
141
|
+
} finally {
|
|
142
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* best-effort cleanup */ }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// probeCodex(runner) -> { available, version|reason }. A cheap liveness check the
|
|
147
|
+
// loop can call once before routing anything to Codex.
|
|
148
|
+
export function probeCodex(runner = defaultRunner) {
|
|
149
|
+
let res;
|
|
150
|
+
try {
|
|
151
|
+
res = runner({ cmd: 'codex', args: ['--version'], timeoutMs: 8000 });
|
|
152
|
+
} catch (e) {
|
|
153
|
+
return { available: false, reason: String(e && e.message || e) };
|
|
154
|
+
}
|
|
155
|
+
if (res && res.code === 0) return { available: true, version: String(res.stdout || '').trim() };
|
|
156
|
+
return { available: false, reason: (res && res.stderr) || 'codex --version failed' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// getAdapter(name, { execImpl }) -> an adapter object. The exec adapter is fully
|
|
160
|
+
// wired; the mcp adapter is the declared V2 seam and reports available:false so
|
|
161
|
+
// the loop stays on exec until it is built (honest, not a fake).
|
|
162
|
+
export function getAdapter(name = ADAPTERS.EXEC, { execImpl = runCodexExec } = {}) {
|
|
163
|
+
if (name === ADAPTERS.EXEC) {
|
|
164
|
+
return { name: ADAPTERS.EXEC, available: true, run: execImpl };
|
|
165
|
+
}
|
|
166
|
+
if (name === ADAPTERS.MCP) {
|
|
167
|
+
return {
|
|
168
|
+
name: ADAPTERS.MCP,
|
|
169
|
+
available: false, // V2 slot: `codex mcp-server` handshake not wired yet
|
|
170
|
+
run() {
|
|
171
|
+
return { ok: false, reason: 'unavailable', detail: 'codex-mcp adapter not wired (V2 slot)' };
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`codex-bridge: unknown adapter "${name}"`);
|
|
176
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// F3 — the shared BLACKBOARD: the turn-by-turn message log the agents use to
|
|
2
|
+
// exchange information (the honest substitute for a live peer chat). It is the
|
|
3
|
+
// analogue of the byan_web server holding the conversation: each agent reads the
|
|
4
|
+
// board at the start of its turn and appends to it at the end. The orchestrating
|
|
5
|
+
// loop (F4) owns the turn order; this module only stores and renders the exchange.
|
|
6
|
+
//
|
|
7
|
+
// A message is { turn, from, to, kind, content }. `to` may be a specific agent or
|
|
8
|
+
// '*' (broadcast). kind is one of KINDS. Pure functions build and render entries;
|
|
9
|
+
// I/O (append/read a JSONL sidecar under _byan-output/) is isolated so the pure
|
|
10
|
+
// core is unit-testable without the filesystem.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
export const KINDS = Object.freeze({
|
|
16
|
+
DESIGN: 'design',
|
|
17
|
+
WORK: 'work',
|
|
18
|
+
QUESTION: 'question',
|
|
19
|
+
ANSWER: 'answer',
|
|
20
|
+
RESULT: 'result',
|
|
21
|
+
NOTE: 'note',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const BROADCAST = '*';
|
|
25
|
+
|
|
26
|
+
// makeEntry — normalize a raw message into the stored shape. Pure. Coerces the
|
|
27
|
+
// turn to a non-negative integer and trims strings; an unknown kind becomes NOTE
|
|
28
|
+
// (never rejected, so a caller mistake degrades to a visible note rather than a
|
|
29
|
+
// throw mid-loop).
|
|
30
|
+
export function makeEntry({ turn = 0, from = '', to = BROADCAST, kind = KINDS.NOTE, content = '' } = {}) {
|
|
31
|
+
const t = Number.isInteger(turn) && turn >= 0 ? turn : 0;
|
|
32
|
+
const k = Object.values(KINDS).includes(kind) ? kind : KINDS.NOTE;
|
|
33
|
+
return {
|
|
34
|
+
turn: t,
|
|
35
|
+
from: String(from).trim(),
|
|
36
|
+
to: String(to).trim() || BROADCAST,
|
|
37
|
+
kind: k,
|
|
38
|
+
content: String(content == null ? '' : content).trim(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// entriesForAgent — the subset an agent should see: broadcasts plus anything
|
|
43
|
+
// addressed to it, plus its own messages (so it recalls what it already said).
|
|
44
|
+
// Pure.
|
|
45
|
+
export function entriesForAgent(entries, agentName) {
|
|
46
|
+
const me = String(agentName || '').trim();
|
|
47
|
+
return (Array.isArray(entries) ? entries : []).filter(
|
|
48
|
+
(e) => e && (e.to === BROADCAST || e.to === me || e.from === me)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// pendingQuestions — questions with no later ANSWER referencing the same asker.
|
|
53
|
+
// A question is answered when a later entry of kind ANSWER is sent to its `from`.
|
|
54
|
+
// Pure; lets the loop decide whether the exchange has converged.
|
|
55
|
+
export function pendingQuestions(entries) {
|
|
56
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
57
|
+
const questions = list.filter((e) => e && e.kind === KINDS.QUESTION);
|
|
58
|
+
return questions.filter((q) => {
|
|
59
|
+
return !list.some(
|
|
60
|
+
(a) => a && a.kind === KINDS.ANSWER && a.to === q.from && a.turn > q.turn
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// renderForAgent — the plain-text transcript an agent reads at the start of its
|
|
66
|
+
// turn. One line per relevant entry, in order, direction shown. Pure.
|
|
67
|
+
export function renderForAgent(entries, agentName) {
|
|
68
|
+
const relevant = entriesForAgent(entries, agentName);
|
|
69
|
+
if (!relevant.length) return 'Tableau partage : (vide - tu ouvres l echange)';
|
|
70
|
+
const lines = relevant.map(
|
|
71
|
+
(e) => `[t${e.turn} ${e.from} -> ${e.to}] ${e.kind}: ${e.content}`
|
|
72
|
+
);
|
|
73
|
+
return ['Tableau partage (echange en cours, lis avant de repondre) :', ...lines].join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- I/O (isolated) -------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
// The board lives under _byan-output/ (gitignored), one JSONL file per dispatch
|
|
79
|
+
// session. Session id is caller-supplied (the loop passes the fd/dispatch id).
|
|
80
|
+
export function blackboardPath(projectDir, sessionId) {
|
|
81
|
+
const safe = String(sessionId || 'default').replace(/[^A-Za-z0-9._-]/g, '_');
|
|
82
|
+
return path.join(projectDir, '_byan-output', 'dispatch', safe, 'blackboard.jsonl');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// appendEntry — normalize + append one line. Creates the session dir on first
|
|
86
|
+
// write. Returns the stored entry (best-effort: returns null on an I/O failure so
|
|
87
|
+
// the loop can note it without crashing).
|
|
88
|
+
export function appendEntry(projectDir, sessionId, raw) {
|
|
89
|
+
const entry = makeEntry(raw);
|
|
90
|
+
try {
|
|
91
|
+
const p = blackboardPath(projectDir, sessionId);
|
|
92
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
93
|
+
fs.appendFileSync(p, JSON.stringify(entry) + '\n');
|
|
94
|
+
return entry;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// readEntries — parse the JSONL board into an array, skipping malformed lines.
|
|
101
|
+
// Missing file -> []. Never throws.
|
|
102
|
+
export function readEntries(projectDir, sessionId) {
|
|
103
|
+
try {
|
|
104
|
+
const raw = fs.readFileSync(blackboardPath(projectDir, sessionId), 'utf8');
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const line of raw.split('\n')) {
|
|
107
|
+
if (!line.trim()) continue;
|
|
108
|
+
try { out.push(JSON.parse(line)); } catch { /* skip malformed line */ }
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
} catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// F4 — the ORCHESTRATOR core: the loop that makes an architect (Claude) and a
|
|
2
|
+
// dev (Codex or Claude, per F1) exchange turn by turn through the shared board
|
|
3
|
+
// (F3) until the exchange converges, then returns the result. This is the durable,
|
|
4
|
+
// unit-testable heart of "launch a workflow and the agents talk to each other".
|
|
5
|
+
//
|
|
6
|
+
// It runs as full Node (main thread or an MCP worker), NOT inside a sandboxed
|
|
7
|
+
// .claude/workflows script — that sandbox has no imports and no filesystem, so it
|
|
8
|
+
// could never use F1/F2/F3. A native workflow script can only be a THIN launch
|
|
9
|
+
// façade over this core (see docs). The core here owns the routing, the loop, and
|
|
10
|
+
// the convergence rule.
|
|
11
|
+
//
|
|
12
|
+
// Executors are INJECTED so the loop is testable without real agents or Codex:
|
|
13
|
+
// executors.architect(ctx) -> { content, question? } (always Claude)
|
|
14
|
+
// executors.claude(ctx) -> { content, question? } (dev work on Claude)
|
|
15
|
+
// executors.codex(ctx) -> { content, question?, diff? } (dev work on Codex)
|
|
16
|
+
// The orchestrator picks executors[route.runtime] for the dev turn; the architect
|
|
17
|
+
// is always Claude (judgment stays on Claude — the verification red line's sibling).
|
|
18
|
+
//
|
|
19
|
+
// Pure control flow: no direct I/O beyond the injected board writer/reader, which
|
|
20
|
+
// default to F3's fs-backed pair but are overridable in tests.
|
|
21
|
+
|
|
22
|
+
import { dispatch } from './dispatch-router.js';
|
|
23
|
+
import {
|
|
24
|
+
KINDS,
|
|
25
|
+
makeEntry,
|
|
26
|
+
renderForAgent,
|
|
27
|
+
pendingQuestions,
|
|
28
|
+
appendEntry as fsAppend,
|
|
29
|
+
readEntries as fsRead,
|
|
30
|
+
} from './dispatch-blackboard.js';
|
|
31
|
+
|
|
32
|
+
export const ARCHITECT = 'claude-architect';
|
|
33
|
+
export const DEV = 'dev';
|
|
34
|
+
export const DEFAULT_MAX_ROUNDS = 4;
|
|
35
|
+
|
|
36
|
+
// A board handle backed by F3's fs sidecar. Tests pass an in-memory one.
|
|
37
|
+
export function fsBoard(projectDir, sessionId) {
|
|
38
|
+
return {
|
|
39
|
+
append: (entry) => fsAppend(projectDir, sessionId, entry),
|
|
40
|
+
read: () => fsRead(projectDir, sessionId),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// An in-memory board (no I/O) — the default when no projectDir is given, and what
|
|
45
|
+
// tests use. Same shape as fsBoard.
|
|
46
|
+
export function memoryBoard(seed = []) {
|
|
47
|
+
const entries = seed.map(makeEntry);
|
|
48
|
+
return {
|
|
49
|
+
append: (raw) => { const e = makeEntry(raw); entries.push(e); return e; },
|
|
50
|
+
read: () => entries.slice(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// orchestrateTask — run ONE task to convergence. Returns
|
|
55
|
+
// { route, rounds, entries, status, diffs }
|
|
56
|
+
// status: 'converged' (dev produced a result with no open question)
|
|
57
|
+
// | 'max-rounds' (hit the round cap still exchanging)
|
|
58
|
+
// | 'dev-failed' (the dev executor reported a failure -> caller may fall back)
|
|
59
|
+
export function orchestrateTask({ task = {}, board, executors = {}, maxRounds = DEFAULT_MAX_ROUNDS } = {}) {
|
|
60
|
+
const route = dispatch({ nature: task.nature, complexity: task.complexity });
|
|
61
|
+
const devExec = executors[route.runtime];
|
|
62
|
+
const architectExec = executors.architect;
|
|
63
|
+
if (typeof devExec !== 'function' || typeof architectExec !== 'function') {
|
|
64
|
+
throw new Error(`orchestrateTask: missing executor for runtime "${route.runtime}" or architect`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Monotonic message counter (NOT per-round): each append gets the next turn, so
|
|
68
|
+
// an architect ANSWER always carries a turn strictly greater than the dev
|
|
69
|
+
// QUESTION it follows — which is what pendingQuestions needs to mark it resolved.
|
|
70
|
+
let turn = 0;
|
|
71
|
+
const post = (from, to, kind, content) => board.append({ turn: turn++, from, to, kind, content: content || '' });
|
|
72
|
+
|
|
73
|
+
// The architect opens with the design brief (addressed to dev).
|
|
74
|
+
const opening = architectExec({ task, route, round: 0, transcript: renderForAgent(board.read(), ARCHITECT) });
|
|
75
|
+
post(ARCHITECT, DEV, KINDS.DESIGN, (opening && opening.content) || task.brief || '');
|
|
76
|
+
|
|
77
|
+
const diffs = [];
|
|
78
|
+
let status = 'max-rounds';
|
|
79
|
+
for (let round = 1; round <= maxRounds; round++) {
|
|
80
|
+
// Dev turn: reads the board, does the work on its routed runtime.
|
|
81
|
+
const devOut = devExec({ task, route, round, transcript: renderForAgent(board.read(), DEV) }) || {};
|
|
82
|
+
if (devOut.ok === false || devOut.failed === true) {
|
|
83
|
+
post(DEV, ARCHITECT, KINDS.NOTE, `dev failed: ${devOut.detail || 'unknown'}`);
|
|
84
|
+
status = 'dev-failed';
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
if (devOut.diff) diffs.push(devOut.diff);
|
|
88
|
+
post(DEV, ARCHITECT, devOut.question ? KINDS.WORK : KINDS.RESULT, devOut.content);
|
|
89
|
+
if (devOut.question) post(DEV, ARCHITECT, KINDS.QUESTION, devOut.question);
|
|
90
|
+
|
|
91
|
+
// Converged: dev delivered a result and left no open question.
|
|
92
|
+
if (!pendingQuestions(board.read()).length) { status = 'converged'; break; }
|
|
93
|
+
|
|
94
|
+
// Architect answers the open question(s); the higher turn resolves them.
|
|
95
|
+
const archOut = architectExec({ task, route, round, transcript: renderForAgent(board.read(), ARCHITECT) }) || {};
|
|
96
|
+
post(ARCHITECT, DEV, KINDS.ANSWER, archOut.content);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { route, messages: board.read().filter((e) => e.turn > 0).length, entries: board.read(), status, diffs };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// orchestrate — run a whole task list. Each task gets its own board namespace when
|
|
103
|
+
// an fs board factory is supplied; otherwise an in-memory board per task. Returns
|
|
104
|
+
// one result per task, in order. Never routes verification to Codex (guaranteed by
|
|
105
|
+
// F1's router, re-asserted here as defence in depth).
|
|
106
|
+
export function orchestrate({ tasks = [], executors = {}, maxRounds = DEFAULT_MAX_ROUNDS, boardFor } = {}) {
|
|
107
|
+
return (Array.isArray(tasks) ? tasks : []).map((task, i) => {
|
|
108
|
+
const board = typeof boardFor === 'function' ? boardFor(task, i) : memoryBoard();
|
|
109
|
+
const result = orchestrateTask({ task, board, executors, maxRounds });
|
|
110
|
+
if (result.route.runtime === 'codex' && /verif|review|validate|audit/i.test(String(task.nature || ''))) {
|
|
111
|
+
// Should be impossible (router forces Claude), but fail loud if it ever regresses.
|
|
112
|
+
throw new Error(`orchestrate: verification task "${task.id || i}" routed to Codex — red line breached`);
|
|
113
|
+
}
|
|
114
|
+
return { taskId: task.id != null ? task.id : i, ...result };
|
|
115
|
+
});
|
|
116
|
+
}
|