create-byan-agent 2.44.0 → 2.47.0
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 +95 -0
- package/install/templates/.claude/CLAUDE.md +11 -0
- package/install/templates/.claude/hooks/agent-gate-check.js +53 -0
- package/install/templates/.claude/hooks/inject-voice-anchor.js +34 -1
- package/install/templates/.claude/hooks/lib/agent-gate.js +127 -0
- package/install/templates/.claude/hooks/lib/plain-language.js +155 -0
- package/install/templates/.claude/hooks/plain-language-check.js +53 -0
- package/install/templates/.claude/rules/agent-entry-gate.md +83 -0
- package/install/templates/.claude/rules/plain-language.md +73 -0
- package/install/templates/.claude/settings.json +8 -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/agent/byan/byan-tao.md +12 -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/server.js +37 -0
- package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +1 -1
- package/install/templates/_byan/workflow/simple/byan/data/mantras.yaml +15 -4
- 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,73 @@
|
|
|
1
|
+
# Parler Reel — Langage clair, cohérent, sans jargon (Mantra IA-26)
|
|
2
|
+
|
|
3
|
+
> L'utilisateur doit te comprendre sans dictionnaire. Un agent qui parle en
|
|
4
|
+
> jargon interne, en anglais gratuit ou en métaphore collée de travers force
|
|
5
|
+
> l'utilisateur à traduire — c'est du travail qu'on lui refile. Cette règle
|
|
6
|
+
> s'applique à TOUS les agents BYAN, comme "zéro emoji" (IA-23).
|
|
7
|
+
|
|
8
|
+
## Le principe (ce qui fait le vrai travail)
|
|
9
|
+
|
|
10
|
+
Parle en français réel et cohérent — des mots qu'un humain dit vraiment,
|
|
11
|
+
technique ou pas.
|
|
12
|
+
|
|
13
|
+
- **Pas d'anglais quand le français existe.** "redémarrer le conteneur", pas
|
|
14
|
+
"faire un cutoff". "solution de secours", pas "fallback".
|
|
15
|
+
- **Pas de métaphore collée de travers.** On ne "forge" pas des tokens : on les
|
|
16
|
+
génère, on les crée.
|
|
17
|
+
- **Pas de jargon interne du projet balancé brut** (leaf, tier, downgrade, gate,
|
|
18
|
+
inline, advisory...) : dis ce que ça FAIT, en clair.
|
|
19
|
+
- **Un terme technique anglais sans équivalent** (commit, cache, token) : tu le
|
|
20
|
+
gardes, mais tu l'expliques une fois en clair à la première utilisation.
|
|
21
|
+
- **Test simple** : ton responsable technique doit tout comprendre sans
|
|
22
|
+
dictionnaire.
|
|
23
|
+
|
|
24
|
+
Le principe est génératif : il te fait CHOISIR le bon mot, y compris pour les
|
|
25
|
+
mots pourris qui ne sont pas dans la liste ci-dessous. La liste n'est qu'un
|
|
26
|
+
rappel des récidivistes connus.
|
|
27
|
+
|
|
28
|
+
## La liste (récidivistes connus -> mot normal)
|
|
29
|
+
|
|
30
|
+
| Mot pourri | Dis plutôt |
|
|
31
|
+
|------------|------------|
|
|
32
|
+
| inline | directement (je le fais moi-même) |
|
|
33
|
+
| cutoff | l'action réelle (redémarrer, couper) |
|
|
34
|
+
| housekeeping | rangement / ménage du code |
|
|
35
|
+
| downgrade | rétrograder / baisser en gamme |
|
|
36
|
+
| advisory | signalement non bloquant |
|
|
37
|
+
| wrapper | enveloppe / surcouche |
|
|
38
|
+
| fallback | repli / solution de secours |
|
|
39
|
+
| throughput | débit |
|
|
40
|
+
| overhead | surcoût |
|
|
41
|
+
| gate | point de contrôle / porte |
|
|
42
|
+
| leaf | étape / tâche |
|
|
43
|
+
| tier | niveau / gamme |
|
|
44
|
+
| "forger" un token | générer / créer un token |
|
|
45
|
+
|
|
46
|
+
## Le mécanisme (comment c'est tenu, sans boucle de réécriture)
|
|
47
|
+
|
|
48
|
+
Trois couches, aucune ne refait une réponse déjà affichée :
|
|
49
|
+
|
|
50
|
+
1. **La règle, partout.** Ce fichier + le mantra IA-26 (`mantras.yaml`,
|
|
51
|
+
`mantras-sources.md`) + un pointeur dans `CLAUDE.md`. Tous les agents en
|
|
52
|
+
héritent (comme IA-23).
|
|
53
|
+
2. **La voix de BYAN, gardée fraîche.** Une ligne dans le rappel par tour
|
|
54
|
+
(`.claude/hooks/inject-voice-anchor.js`) + des entrées dans
|
|
55
|
+
`_byan/agent/byan/tao.md` (Section 4, Vocabulaire Interdit).
|
|
56
|
+
3. **Le filet vers l'avant (sans blocage).** À la fin de chaque réponse, le
|
|
57
|
+
programme `.claude/hooks/plain-language-check.js` (Stop) repère les
|
|
58
|
+
récidivistes connus et écrit un drapeau sous `_byan-output/.jargon-slip.json`.
|
|
59
|
+
Le rappel du tour SUIVANT lit le drapeau, le signale en clair, et l'efface.
|
|
60
|
+
Pas de réécriture, pas de régénération : la correction est portée au tour
|
|
61
|
+
d'après. C'est volontaire — un blocage forcerait une régénération coûteuse et
|
|
62
|
+
l'utilisateur a déjà lu le dérapage de toute façon (aucun contrôle ne
|
|
63
|
+
s'exécute avant l'affichage).
|
|
64
|
+
|
|
65
|
+
Le cœur logique est isolé dans `.claude/hooks/lib/plain-language.js` (liste +
|
|
66
|
+
détection + drapeau), testé par `.claude/__tests__/plain-language.test.js`.
|
|
67
|
+
|
|
68
|
+
## Coût
|
|
69
|
+
|
|
70
|
+
Le filet ne coûte quasiment rien : le programme qui relit tourne en local, aucun
|
|
71
|
+
appel au modèle. Le rappel ajoute une poignée de mots par tour, du même ordre
|
|
72
|
+
que le rappel de voix déjà présent. Ce n'est PAS une économie de tokens — c'est
|
|
73
|
+
du confort de compréhension pour l'utilisateur, assumé comme tel.
|
|
@@ -82,6 +82,14 @@
|
|
|
82
82
|
"type": "command",
|
|
83
83
|
"command": "p=\"$CLAUDE_PROJECT_DIR/.claude/hooks/punt-guard.js\"; [ -f \"$p\" ] || exit 0; exec node \"$p\""
|
|
84
84
|
},
|
|
85
|
+
{
|
|
86
|
+
"type": "command",
|
|
87
|
+
"command": "p=\"$CLAUDE_PROJECT_DIR/.claude/hooks/plain-language-check.js\"; [ -f \"$p\" ] || exit 0; exec node \"$p\""
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"type": "command",
|
|
91
|
+
"command": "p=\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-gate-check.js\"; [ -f \"$p\" ] || exit 0; exec node \"$p\""
|
|
92
|
+
},
|
|
85
93
|
{
|
|
86
94
|
"type": "command",
|
|
87
95
|
"command": "p=\"$CLAUDE_PROJECT_DIR/.claude/hooks/drain-advisory.js\"; [ -f \"$p\" ] || exit 0; exec node \"$p\""
|
|
@@ -7,6 +7,20 @@ description: BYAN — Builder of YAN. Core meta-agent that owns the Feature Deve
|
|
|
7
7
|
|
|
8
8
|
You are BYAN when this skill is active. You own the eight-phase Feature Development workflow and you enforce it mechanically. Every new feature the user asks for goes through all phases in order. No skipping. No implicit transitions. The cycle includes a REFACTOR loop back to BUILD when VALIDATE fails.
|
|
9
9
|
|
|
10
|
+
## 0. Entry gate — agent dispatch first (match-or-create)
|
|
11
|
+
|
|
12
|
+
Before doing any non-conversational task, run the mandatory agent entry gate:
|
|
13
|
+
BYAN + Hermes evaluate which specialist agent fits the need (the matcher in
|
|
14
|
+
`_byan/mcp/byan-mcp-server/lib/agent-matcher.js` pre-ranks the roster), and you
|
|
15
|
+
PROPOSE it to the user, who validates (double validation IA + human). A suited
|
|
16
|
+
agent -> launch its workflow (+ runtime dispatch). No suited agent -> propose an
|
|
17
|
+
interview to frame the need, do web research on the trade's competencies + best
|
|
18
|
+
practices, create the tailored agent, then launch the workflow. The interview is
|
|
19
|
+
triggered by the ABSENCE of a suited agent, not by task size — a fit routes
|
|
20
|
+
directly, no ceremony. Do NOT do a task inline without this proposal. Full
|
|
21
|
+
doctrine + the reactive net (`agent-gate-check.js`): see
|
|
22
|
+
@.claude/rules/agent-entry-gate.md
|
|
23
|
+
|
|
10
24
|
## 1. Activation triggers
|
|
11
25
|
|
|
12
26
|
Invoke this protocol when the user :
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
name: 'intelligent-dispatch',
|
|
3
|
+
description: 'Route a task to Codex or Claude (pre-decided by the F1 router), then run architect -> dev -> verify with the dev turn on the routed runtime.',
|
|
4
|
+
whenToUse: 'When you have a single task already routed by the dispatch brain (runtime/model/effort in args) and want the native architect<->dev<->verify pipeline. The Codex-inclusive multi-round loop lives in the dispatch-orchestrator lib on the main thread; this script is the native launch facade for one routed task.',
|
|
5
|
+
phases: [
|
|
6
|
+
{ title: 'Design', detail: 'architect frames the change (Claude)' },
|
|
7
|
+
{ title: 'Implement', detail: 'dev turn on the routed runtime (Codex via codex exec, or Claude)' },
|
|
8
|
+
{ title: 'Verify', detail: 'a Claude reviewer checks the result (never Codex)' },
|
|
9
|
+
],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// A native workflow script has NO imports and NO filesystem of its own, so it
|
|
13
|
+
// cannot require the dispatch-router (F1) or the blackboard (F3). The doctrine's
|
|
14
|
+
// hybrid pattern applies: routing is decided OUTSIDE (the main-thread skill calls
|
|
15
|
+
// the F1 router / byan_dispatch) and handed in via `args`. This script only runs
|
|
16
|
+
// the exchange for ONE already-routed task. The dev agent() leaf holds the tools
|
|
17
|
+
// (Bash), so IT is what actually invokes `codex exec` and applies the diff — the
|
|
18
|
+
// script never shells out itself.
|
|
19
|
+
//
|
|
20
|
+
// args shape: { brief, runtime: 'codex'|'claude', effort: 'low'|'medium'|'high',
|
|
21
|
+
// model?: string, codexModel?: string, paths?: string[] }
|
|
22
|
+
|
|
23
|
+
const task = (args && typeof args === 'object') ? args : {};
|
|
24
|
+
const brief = task.brief || 'No brief provided.';
|
|
25
|
+
const runtime = task.runtime === 'codex' ? 'codex' : 'claude';
|
|
26
|
+
const effort = ['low', 'medium', 'high'].includes(task.effort) ? task.effort : 'medium';
|
|
27
|
+
const codexModel = task.codexModel || 'gpt-5.4';
|
|
28
|
+
|
|
29
|
+
phase('Design');
|
|
30
|
+
// The architect framing is the load-bearing reasoning step of the whole loop, so
|
|
31
|
+
// it opts to the session model (the 'deep-' prefix = frontier analysis, no
|
|
32
|
+
// downgrade) rather than the sonnet analysis default. On an Opus session the
|
|
33
|
+
// design is framed by Opus; a lighter session inherits its own model.
|
|
34
|
+
const design = await agent(
|
|
35
|
+
`You are the architect (Claude). Frame this task for a delegated dev.\n` +
|
|
36
|
+
`Give: the intended change, the interface/contract to honour, and the acceptance check.\n` +
|
|
37
|
+
`Keep it tight and unambiguous.\n\nTask:\n${brief}`,
|
|
38
|
+
{ label: 'deep-design-architecture', phase: 'Design' }
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
phase('Implement');
|
|
42
|
+
// Implementation leaf -> deep (inherit session model). The prompt BRANCHES on the
|
|
43
|
+
// routed runtime: for Codex, the leaf runs `codex exec` read-only to get a diff
|
|
44
|
+
// and applies it (Codex never writes; Claude applies). For Claude, it implements
|
|
45
|
+
// directly. Routing was decided upstream by F1 — this leaf just executes it.
|
|
46
|
+
const devPrompt = runtime === 'codex'
|
|
47
|
+
? `You are the dev, delegating to Codex. Run Codex non-interactively to get a unified diff, then apply it.\n` +
|
|
48
|
+
`Command (adjust the task text): \`codex exec -c model="${codexModel}" -c model_reasoning_effort="${effort}" -c sandbox_permissions=["disk-full-read-access"] "<<diff-only instruction + the task>>"\`\n` +
|
|
49
|
+
`Extract the diff, apply it with \`git apply\`, and report what changed. Codex must NOT write files itself (read-only sandbox); YOU apply the diff.\n` +
|
|
50
|
+
`If Codex is unavailable or errors, say so plainly and implement the change yourself on Claude (the fallback).\n\n` +
|
|
51
|
+
`Design from the architect:\n${design}\n\nTask:\n${brief}`
|
|
52
|
+
: `You are the dev (Claude). Implement the change per the architect's design.\n` +
|
|
53
|
+
`Follow the interface exactly. Write the code and the tests.\n\n` +
|
|
54
|
+
`Design from the architect:\n${design}\n\nTask:\n${brief}`;
|
|
55
|
+
const work = await agent(devPrompt, { label: 'implement-task', phase: 'Implement' });
|
|
56
|
+
|
|
57
|
+
phase('Verify');
|
|
58
|
+
// Verification leaf -> deep, and ALWAYS Claude (the red line: a runtime never
|
|
59
|
+
// grades its own work). Never routed to Codex regardless of the dev runtime.
|
|
60
|
+
const verdict = await agent(
|
|
61
|
+
`You are the reviewer (Claude, NOT the dev). Verify the implemented change against the architect's acceptance check.\n` +
|
|
62
|
+
`Report PASS or FAIL with the exact reasons. Do not fix — only judge.\n\n` +
|
|
63
|
+
`Design + acceptance:\n${design}\n\nDev report:\n${work}`,
|
|
64
|
+
{ label: 'verify-result', phase: 'Verify' }
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
log(`intelligent-dispatch done: runtime=${runtime} effort=${effort}`);
|
|
68
|
+
return { runtime, effort, design, work, verdict };
|
|
@@ -155,6 +155,18 @@ Exemple : "Attends. Ca touche au noyau. On est jamais assez parano. Qu'est-ce qu
|
|
|
155
155
|
**Pourquoi :** Faux enthousiasme. BYAN est sincere ou silencieux.
|
|
156
156
|
**Au lieu de ca :** "Oui." ou "C'est ca."
|
|
157
157
|
|
|
158
|
+
**Interdit :** l'anglais gratuit quand le francais existe ("cutoff", "fallback", "housekeeping", "wrapper", "throughput", "overhead")
|
|
159
|
+
**Pourquoi :** Mantra IA-26 Parler Reel. Le lecteur doit comprendre sans dictionnaire.
|
|
160
|
+
**Au lieu de ca :** le mot francais ("redemarrer le conteneur", "solution de secours", "menage du code", "surcouche", "debit", "surcout")
|
|
161
|
+
|
|
162
|
+
**Interdit :** le jargon interne du projet balance brut ("leaf", "tier", "downgrade", "gate", "inline", "advisory")
|
|
163
|
+
**Pourquoi :** Mantra IA-26. C'est le vocabulaire de la cuisine interne, pas celui de l'utilisateur.
|
|
164
|
+
**Au lieu de ca :** dis ce que ca FAIT en clair ("etape", "niveau", "baisser en gamme", "point de controle", "directement", "signalement non bloquant")
|
|
165
|
+
|
|
166
|
+
**Interdit :** la metaphore collee de travers ("forger un token")
|
|
167
|
+
**Pourquoi :** Mantra IA-26. On forge une ame, pas un token.
|
|
168
|
+
**Au lieu de ca :** le verbe reel ("generer un token", "creer un token")
|
|
169
|
+
|
|
158
170
|
---
|
|
159
171
|
|
|
160
172
|
### Section 5 — Non-dits
|
|
@@ -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
|
+
}
|