create-byan-agent 2.35.0 → 2.39.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 +203 -0
- package/install/bin/create-byan-agent-v2.js +15 -0
- package/install/lib/claude-native-setup.js +12 -38
- package/install/lib/platforms/claude-code.js +28 -19
- package/install/package.json +1 -1
- package/install/packages/platform-config/lib/mcp-config.js +71 -5
- package/install/templates/.claude/CLAUDE.md +1 -0
- package/install/templates/.claude/hooks/inject-delivery-default.js +46 -0
- package/install/templates/.claude/hooks/leantime-fd-sync.js +12 -1
- package/install/templates/.claude/hooks/lib/delivery-contract.js +143 -0
- package/install/templates/.claude/hooks/lib/punt-detect.js +143 -0
- package/install/templates/.claude/hooks/lib/strict-config.json +14 -0
- package/install/templates/.claude/hooks/punt-guard.js +126 -0
- package/install/templates/.claude/hooks/strict-stop-guard.js +29 -6
- package/install/templates/.claude/settings.json +8 -0
- package/install/templates/.claude/skills/byan-strict/SKILL.md +9 -0
- package/install/templates/.githooks/pre-commit +18 -0
- package/install/templates/_byan/_config/delivery-default.json +22 -0
- package/install/templates/_byan/_config/strict-mode.yaml +25 -0
- package/install/templates/_byan/agent/byan/byan-soul.md +40 -0
- package/install/templates/_byan/mcp/byan-mcp-server/channel-entry.js +46 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/channel-poll.js +128 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/channel-server.js +234 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/completeness-evidence.js +159 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-fd-core.js +60 -2
- package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-sync.js +4 -1
- package/install/templates/_byan/mcp/byan-mcp-server/lib/precommit-gate.js +68 -2
- package/install/templates/_byan/mcp/byan-mcp-server/lib/strict-mode.js +78 -1
- package/install/templates/_byan/mcp/byan-mcp-server/lib/sync-rules.js +40 -2
- package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +1 -1
- package/install/templates/dist/skill-bundles/byan-strict.zip +0 -0
- package/install/templates/docs/leantime-integration.md +11 -1
- package/node_modules/byan-platform-config/lib/mcp-config.js +71 -5
- package/package.json +1 -1
- package/install/templates/.mcp.json.tmpl +0 -8
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* channel-entry.js — Entrypoint stdio du channel MCP byan pour Claude Code.
|
|
4
|
+
*
|
|
5
|
+
* POURQUOI un fichier séparé de server.js : le channel est un serveur MCP
|
|
6
|
+
* distinct du serveur principal byan-mcp. Claude Code le spawne comme un
|
|
7
|
+
* subprocess indépendant via --dangerously-load-development-channels.
|
|
8
|
+
* server.js ne doit pas acquérir le transport channel comme effet de bord.
|
|
9
|
+
*
|
|
10
|
+
* Commande de lancement (research preview) :
|
|
11
|
+
* claude --dangerously-load-development-channels server:byan-channel
|
|
12
|
+
*
|
|
13
|
+
* Enregistrement dans .mcp.json (section mcpServers) — exactement ce que pose
|
|
14
|
+
* l'installeur : chemin RELATIF au projectRoot (portable, jamais absolu) et env
|
|
15
|
+
* VIDE (la config est resolue au boot via resolveConfig ci-dessous ; aucun secret
|
|
16
|
+
* n'est ecrit dans .mcp.json qui est tracke par git) :
|
|
17
|
+
* "byan-channel": {
|
|
18
|
+
* "command": "node",
|
|
19
|
+
* "args": ["_byan/mcp/byan-mcp-server/channel-entry.js"],
|
|
20
|
+
* "env": {}
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* Voie plugin (Phase 2, non implémentée ici) :
|
|
24
|
+
* Wrapper le channel-entry.js dans un plugin Claude Code et l'enregistrer
|
|
25
|
+
* dans la marketplace pour qu'il soit accessible via
|
|
26
|
+
* --channels plugin:byan-channel@byan-marketplace.
|
|
27
|
+
* Nécessite que le channel soit sur l'allowlist Anthropic ou sur la liste
|
|
28
|
+
* allowedChannelPlugins de l'organisation (Enterprise).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { resolveConfig } from './lib/resolve-config.js';
|
|
32
|
+
import { runChannelStdio } from './lib/channel-server.js';
|
|
33
|
+
|
|
34
|
+
// Config : même resolver que server.js (env -> ~/.byan/credentials.json -> défauts).
|
|
35
|
+
const config = resolveConfig();
|
|
36
|
+
const apiUrl = config.BYAN_API_URL || process.env.BYAN_API_URL;
|
|
37
|
+
const apiToken = config.BYAN_API_TOKEN || process.env.BYAN_API_TOKEN;
|
|
38
|
+
|
|
39
|
+
if (!apiUrl) {
|
|
40
|
+
process.stderr.write('[byan-channel] BYAN_API_URL manquant — le poll sera désactivé\n');
|
|
41
|
+
}
|
|
42
|
+
if (!apiToken) {
|
|
43
|
+
process.stderr.write('[byan-channel] BYAN_API_TOKEN manquant — le poll et les replies seront désactivés\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await runChannelStdio({ apiUrl, apiToken });
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/channel-poll.js — Boucle de poll outbox byan_web pour le channel CC.
|
|
3
|
+
*
|
|
4
|
+
* POURQUOI ce module est séparé de channel-server.js : isoler le réseau
|
|
5
|
+
* permet de le mocker proprement dans les tests sans toucher au serveur MCP.
|
|
6
|
+
*
|
|
7
|
+
* Contrat API (réconcilié avec les routes F2b dans api/routes/sessions-cli.js) :
|
|
8
|
+
* GET /api/sessions/outbox
|
|
9
|
+
* -> { data: [{ id, session_id, content, meta? }] }
|
|
10
|
+
* Les messages sont scoped au user via le token (RBAC serveur).
|
|
11
|
+
* POST /api/sessions/:session_id/outbox/:msg_id/ack
|
|
12
|
+
* -> { ok: true }
|
|
13
|
+
* Marque le message comme livré ; l'API doit être idempotente.
|
|
14
|
+
*
|
|
15
|
+
* Sécurité / gate sender : le poll utilise le token byan_web du user (scope
|
|
16
|
+
* user). La barrière anti-injection réelle est le RBAC owner côté serveur :
|
|
17
|
+
* seuls les messages des sessions appartenant à ce user sont dans l'outbox.
|
|
18
|
+
* Ce module ne filtre pas davantage : le gate est la vraie barrière.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const DEFAULT_POLL_INTERVAL_MS = 5_000; // 5 s — équilibre réactivité / charge API
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 8_000; // abort si l'API ne répond pas
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Crée et démarre une boucle de poll vers l'outbox byan_web.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} opts
|
|
28
|
+
* @param {string} opts.apiUrl Base URL byan_web (sans /api final)
|
|
29
|
+
* @param {string} opts.apiToken Token byan_web du user (ApiKey scheme)
|
|
30
|
+
* @param {Function} opts.onMessage Callback async (msg) => void, appelé pour chaque message pending
|
|
31
|
+
* @param {number} [opts.intervalMs] Intervalle entre polls (défaut 5 s)
|
|
32
|
+
* @param {object} [opts.fetchImpl] Override fetch pour les tests
|
|
33
|
+
* @returns {{ stop: Function }} Stopper la boucle
|
|
34
|
+
*/
|
|
35
|
+
export function startPollLoop({ apiUrl, apiToken, onMessage, intervalMs, fetchImpl }) {
|
|
36
|
+
const interval = intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
37
|
+
const fetchFn = fetchImpl ?? globalThis.fetch;
|
|
38
|
+
|
|
39
|
+
let active = true;
|
|
40
|
+
let timer = null;
|
|
41
|
+
|
|
42
|
+
const authHeaders = () => {
|
|
43
|
+
// byan_web exige ApiKey pour les tokens préfixés byan_, Bearer sinon.
|
|
44
|
+
const scheme = apiToken && apiToken.startsWith('byan_') ? 'ApiKey' : 'Bearer';
|
|
45
|
+
return { Authorization: `${scheme} ${apiToken}` };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
async function poll() {
|
|
49
|
+
if (!active) return;
|
|
50
|
+
try {
|
|
51
|
+
const ctrl = new AbortController();
|
|
52
|
+
const timeout = setTimeout(() => ctrl.abort(), DEFAULT_TIMEOUT_MS);
|
|
53
|
+
let res;
|
|
54
|
+
try {
|
|
55
|
+
res = await fetchFn(`${apiUrl}/api/sessions/outbox`, {
|
|
56
|
+
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
|
57
|
+
signal: ctrl.signal,
|
|
58
|
+
});
|
|
59
|
+
} finally {
|
|
60
|
+
clearTimeout(timeout);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
// Best-effort : ne pas interrompre la boucle sur une erreur transitoire.
|
|
65
|
+
// 401/403 sont loggés mais pas relancés (le token est fixe dans cette session).
|
|
66
|
+
if (res.status === 401 || res.status === 403) {
|
|
67
|
+
process.stderr.write(`[byan-channel] outbox auth error ${res.status} — vérifier BYAN_API_TOKEN\n`);
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const body = await res.json();
|
|
73
|
+
const messages = Array.isArray(body?.data) ? body.data : [];
|
|
74
|
+
|
|
75
|
+
for (const msg of messages) {
|
|
76
|
+
if (!msg || !msg.id || !msg.session_id) continue;
|
|
77
|
+
|
|
78
|
+
// Livrer le message au channel (notification MCP).
|
|
79
|
+
try {
|
|
80
|
+
await onMessage(msg);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
process.stderr.write(`[byan-channel] onMessage error: ${err.message}\n`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Acquitter le message : marque "livré" côté serveur.
|
|
86
|
+
// Contrat F2b : POST /api/sessions/:session_id/outbox/:msg_id/ack
|
|
87
|
+
// Idempotent — si l'ack échoue on retente au prochain poll (le message
|
|
88
|
+
// réapparaît dans l'outbox, ce qui entraîne une double notification).
|
|
89
|
+
// La double notification est préférable à un message perdu.
|
|
90
|
+
try {
|
|
91
|
+
const ackCtrl = new AbortController();
|
|
92
|
+
const ackTimeout = setTimeout(() => ackCtrl.abort(), DEFAULT_TIMEOUT_MS);
|
|
93
|
+
try {
|
|
94
|
+
await fetchFn(
|
|
95
|
+
`${apiUrl}/api/sessions/${encodeURIComponent(msg.session_id)}/outbox/${encodeURIComponent(msg.id)}/ack`,
|
|
96
|
+
{ method: 'POST', headers: authHeaders(), signal: ackCtrl.signal }
|
|
97
|
+
);
|
|
98
|
+
} finally {
|
|
99
|
+
clearTimeout(ackTimeout);
|
|
100
|
+
}
|
|
101
|
+
} catch (ackErr) {
|
|
102
|
+
// Ack raté = best-effort ; on ne bloque pas la livraison.
|
|
103
|
+
process.stderr.write(`[byan-channel] ack failed for msg ${msg.id}: ${ackErr.message}\n`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// Réseau injoignable : best-effort, on retente au prochain tick.
|
|
108
|
+
if (err.name !== 'AbortError') {
|
|
109
|
+
process.stderr.write(`[byan-channel] poll error: ${err.message}\n`);
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
if (active) {
|
|
113
|
+
// Programmer le prochain poll seulement si la boucle est encore active.
|
|
114
|
+
timer = setTimeout(poll, interval);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Démarrage immédiat puis périodique.
|
|
120
|
+
timer = setTimeout(poll, 0);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
stop() {
|
|
124
|
+
active = false;
|
|
125
|
+
if (timer) clearTimeout(timer);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/channel-server.js — Serveur MCP channel Claude Code pour byan_web.
|
|
3
|
+
*
|
|
4
|
+
* POURQUOI ce fichier est une factory et non un entrypoint direct :
|
|
5
|
+
* la même factory est importée par channel-entry.js (entrypoint stdio pour
|
|
6
|
+
* --dangerously-load-development-channels) ET par les tests, sans effet de
|
|
7
|
+
* bord transport au import-time.
|
|
8
|
+
*
|
|
9
|
+
* Protocole Channel (research preview CC v2.1.80+) :
|
|
10
|
+
* - capabilities.experimental['claude/channel'] = {}
|
|
11
|
+
* -> CC enregistre un listener de notification
|
|
12
|
+
* - mcp.notification({ method: 'notifications/claude/channel', params })
|
|
13
|
+
* -> event injecté dans la session CC comme <channel source="byan" ...>
|
|
14
|
+
* - reply tool "byan_session_reply" (capabilities.tools = {})
|
|
15
|
+
* -> CC appelle le tool quand Claude veut répondre
|
|
16
|
+
*
|
|
17
|
+
* Gate sender : le poll utilise BYAN_API_TOKEN (scope user). La barrière
|
|
18
|
+
* anti-injection est le RBAC owner côté serveur byan_web ; ce channel ne
|
|
19
|
+
* reçoit que les messages des sessions du user authentifié.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
23
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
24
|
+
import {
|
|
25
|
+
ListToolsRequestSchema,
|
|
26
|
+
CallToolRequestSchema,
|
|
27
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
28
|
+
import { startPollLoop } from './channel-poll.js';
|
|
29
|
+
|
|
30
|
+
/** Intervalle de poll par défaut (ms). Exposé pour override dans les tests. */
|
|
31
|
+
export const DEFAULT_POLL_INTERVAL_MS = 5_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reply tool : Claude l'appelle pour renvoyer une réponse vers byan_web.
|
|
35
|
+
*
|
|
36
|
+
* Hypothèse F2b : POST /api/sessions/:session_id/reply { content }
|
|
37
|
+
* -> { ok: true }
|
|
38
|
+
* Le nom du tool côté CC sera mcp__byan-channel__byan_session_reply.
|
|
39
|
+
*/
|
|
40
|
+
const REPLY_TOOL = {
|
|
41
|
+
name: 'byan_session_reply',
|
|
42
|
+
description:
|
|
43
|
+
'Reply to a user message received from byan_web. Call this when a <channel source="byan"> event arrives and a response is expected. Pass the session_id from the event meta attribute and the text to send back.',
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
session_id: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
description: 'The session_id from the inbound <channel> event meta.',
|
|
50
|
+
},
|
|
51
|
+
content: {
|
|
52
|
+
type: 'string',
|
|
53
|
+
description: 'The reply text to send back to the user in byan_web.',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
required: ['session_id', 'content'],
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Crée un serveur MCP channel byan sans le connecter (transport séparé).
|
|
63
|
+
* Accepte un fetchImpl pour permettre le mock en test.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} opts
|
|
66
|
+
* @param {string} opts.apiUrl Base URL byan_web
|
|
67
|
+
* @param {string} opts.apiToken Token du user (ApiKey scheme si préfixe byan_)
|
|
68
|
+
* @param {number} [opts.intervalMs] Override intervalle de poll (tests)
|
|
69
|
+
* @param {object} [opts.fetchImpl] Override fetch (tests)
|
|
70
|
+
* @returns {Server} Instance MCP Server (non connectée)
|
|
71
|
+
*/
|
|
72
|
+
export function createChannelServer({ apiUrl, apiToken, intervalMs, fetchImpl } = {}) {
|
|
73
|
+
const fetchFn = fetchImpl ?? globalThis.fetch;
|
|
74
|
+
const interval = intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
75
|
+
|
|
76
|
+
const authHeader = () => {
|
|
77
|
+
if (!apiToken) return {};
|
|
78
|
+
const scheme = apiToken.startsWith('byan_') ? 'ApiKey' : 'Bearer';
|
|
79
|
+
return { Authorization: `${scheme} ${apiToken}` };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const mcp = new Server(
|
|
83
|
+
{ name: 'byan-channel', version: '0.1.0' },
|
|
84
|
+
{
|
|
85
|
+
capabilities: {
|
|
86
|
+
// Clé qui fait de ce serveur un channel CC.
|
|
87
|
+
// CC enregistre un listener de notification sur cette capability.
|
|
88
|
+
experimental: { 'claude/channel': {} },
|
|
89
|
+
// tools: {} requis pour que CC découvre les tools de réponse.
|
|
90
|
+
tools: {},
|
|
91
|
+
},
|
|
92
|
+
// Instructions injectées dans le system prompt CC.
|
|
93
|
+
// Dis à Claude : les events arrivent comme <channel source="byan" ...>,
|
|
94
|
+
// il doit répondre via byan_session_reply en passant session_id.
|
|
95
|
+
instructions:
|
|
96
|
+
'Events from byan_web arrive as <channel source="byan" session_id="..." msg_id="...">. ' +
|
|
97
|
+
'When a user message arrives, read it and reply using the byan_session_reply tool, ' +
|
|
98
|
+
'passing the session_id from the tag attribute. ' +
|
|
99
|
+
'Do not reply if the event is a system notification (no reply expected).',
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Découverte des tools par CC.
|
|
104
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
105
|
+
tools: [REPLY_TOOL],
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
// Appel du reply tool par Claude.
|
|
109
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
110
|
+
const { name, arguments: args = {} } = req.params;
|
|
111
|
+
|
|
112
|
+
if (name === 'byan_session_reply') {
|
|
113
|
+
const { session_id, content } = args;
|
|
114
|
+
if (!session_id || !content) {
|
|
115
|
+
return {
|
|
116
|
+
isError: true,
|
|
117
|
+
content: [{ type: 'text', text: 'Error: session_id and content are required.' }],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (!apiToken) {
|
|
121
|
+
return {
|
|
122
|
+
isError: true,
|
|
123
|
+
content: [{ type: 'text', text: 'Error: BYAN_API_TOKEN is not configured.' }],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Hypothèse F2b : POST /api/sessions/:session_id/reply { content }
|
|
128
|
+
try {
|
|
129
|
+
const ctrl = new AbortController();
|
|
130
|
+
const timeout = setTimeout(() => ctrl.abort(), 8_000);
|
|
131
|
+
let res;
|
|
132
|
+
try {
|
|
133
|
+
res = await fetchFn(
|
|
134
|
+
`${apiUrl}/api/sessions/${encodeURIComponent(session_id)}/reply`,
|
|
135
|
+
{
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: {
|
|
138
|
+
'Content-Type': 'application/json',
|
|
139
|
+
...authHeader(),
|
|
140
|
+
},
|
|
141
|
+
body: JSON.stringify({ content }),
|
|
142
|
+
signal: ctrl.signal,
|
|
143
|
+
}
|
|
144
|
+
);
|
|
145
|
+
} finally {
|
|
146
|
+
clearTimeout(timeout);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
const text = await res.text().catch(() => '');
|
|
151
|
+
return {
|
|
152
|
+
isError: true,
|
|
153
|
+
content: [{ type: 'text', text: `Error: byan_web replied ${res.status} — ${text}` }],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, session_id }) }] };
|
|
158
|
+
} catch (err) {
|
|
159
|
+
return {
|
|
160
|
+
isError: true,
|
|
161
|
+
content: [{ type: 'text', text: `Error sending reply: ${err.message}` }],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
isError: true,
|
|
168
|
+
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Boucle de poll : démarrée après la connexion transport pour que
|
|
173
|
+
// mcp.notification() ait un transport actif. La connexion est gérée par
|
|
174
|
+
// l'entrypoint ; ici on expose startPolling() pour que l'entrypoint
|
|
175
|
+
// démarre la boucle au bon moment.
|
|
176
|
+
let pollLoop = null;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Démarre la boucle de poll. Appeler APRÈS mcp.connect(transport).
|
|
180
|
+
* Retourne { stop } pour arrêter proprement.
|
|
181
|
+
*/
|
|
182
|
+
function startPolling() {
|
|
183
|
+
if (!apiUrl || !apiToken) {
|
|
184
|
+
process.stderr.write(
|
|
185
|
+
'[byan-channel] BYAN_API_URL ou BYAN_API_TOKEN manquant — poll désactivé\n'
|
|
186
|
+
);
|
|
187
|
+
return { stop: () => {} };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
pollLoop = startPollLoop({
|
|
191
|
+
apiUrl,
|
|
192
|
+
apiToken,
|
|
193
|
+
intervalMs: interval,
|
|
194
|
+
fetchImpl: fetchFn,
|
|
195
|
+
onMessage: async (msg) => {
|
|
196
|
+
// Émettre la notification CC pour chaque message outbox.
|
|
197
|
+
// meta : chaque clé devient un attribut sur le tag <channel>.
|
|
198
|
+
// Les clés contenant des tirets sont silencieusement ignorées par CC
|
|
199
|
+
// (constraint doc) ; on n'utilise que des underscores.
|
|
200
|
+
await mcp.notification({
|
|
201
|
+
method: 'notifications/claude/channel',
|
|
202
|
+
params: {
|
|
203
|
+
content: msg.content ?? '',
|
|
204
|
+
meta: {
|
|
205
|
+
session_id: String(msg.session_id),
|
|
206
|
+
msg_id: String(msg.id),
|
|
207
|
+
...(msg.meta && typeof msg.meta === 'object' ? msg.meta : {}),
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
return pollLoop;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Attache startPolling sur le serveur pour l'entrypoint.
|
|
218
|
+
mcp.startPolling = startPolling;
|
|
219
|
+
|
|
220
|
+
return mcp;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Lance le channel en mode stdio (entrypoint direct).
|
|
225
|
+
* Appelé uniquement depuis channel-entry.js, pas depuis les tests.
|
|
226
|
+
*/
|
|
227
|
+
export async function runChannelStdio({ apiUrl, apiToken, intervalMs, fetchImpl } = {}) {
|
|
228
|
+
const mcp = createChannelServer({ apiUrl, apiToken, intervalMs, fetchImpl });
|
|
229
|
+
await mcp.connect(new StdioServerTransport());
|
|
230
|
+
// La boucle de poll démarre APRÈS connect pour que la notification
|
|
231
|
+
// ait un transport actif.
|
|
232
|
+
mcp.startPolling();
|
|
233
|
+
// Le process reste vivant grâce au transport stdio.
|
|
234
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// BYAN completeness-evidence (F2).
|
|
2
|
+
//
|
|
3
|
+
// Strict mode already forces >= 3 self-verify passes whose verdict is the
|
|
4
|
+
// agent's word. This module adds an EVIDENCE layer on top: for each locked
|
|
5
|
+
// acceptance criterion it classifies the criterion (code-shaped vs prose) and
|
|
6
|
+
// collects whatever objective proof is available — a captured test-runner exit,
|
|
7
|
+
// a `git diff --stat` constrained to the allowedPaths, or the existence of a
|
|
8
|
+
// named file. The result is { perCriterion, missing }.
|
|
9
|
+
//
|
|
10
|
+
// It ships DISARMED. The strict complete() ATTACHES this report (so the agent
|
|
11
|
+
// and the audit trail see it) but only HARD-REJECTS when
|
|
12
|
+
// delivery-default.json completenessGate.armed === true (default false). With
|
|
13
|
+
// armed=false the report is pure observation: complete() behaves exactly as
|
|
14
|
+
// before. This is the same disarmed-net posture as the autobench Stop guard.
|
|
15
|
+
//
|
|
16
|
+
// The risky-but-pure half (classification, the report shape, the missing list)
|
|
17
|
+
// takes no I/O. The I/O half (running git, reading files) is injected via an
|
|
18
|
+
// `io` object so the unit tests pin behaviour without touching the real
|
|
19
|
+
// filesystem or spawning git — the same shape strict-sync / suitability-store use.
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { execFileSync } from 'node:child_process';
|
|
24
|
+
|
|
25
|
+
// A criterion is "code-shaped" when it names a testable/file/command artifact:
|
|
26
|
+
// a path, an extension, a test/spec word, a backticked token, or a runner. Else
|
|
27
|
+
// it is prose (a human-judged outcome with no mechanical proof). Pure.
|
|
28
|
+
const CODE_SHAPED_PATTERNS = [
|
|
29
|
+
/`[^`]+`/, // a backticked token (file, command, symbol)
|
|
30
|
+
/\b[\w./-]+\.(js|ts|mjs|cjs|json|yaml|yml|md|py|rs|go|sh)\b/i, // a filename with a known ext
|
|
31
|
+
/\b(test|tests|spec|coverage|passe?s?|green|exit\s*0)\b/i, // test/run vocabulary
|
|
32
|
+
/\b(npm|node|jest|git|curl|build|lint)\b/i, // a runner / command
|
|
33
|
+
/\//, // a path separator
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export function classifyCriterion(criterion) {
|
|
37
|
+
const text = String(criterion || '');
|
|
38
|
+
const isCode = CODE_SHAPED_PATTERNS.some((re) => re.test(text));
|
|
39
|
+
return isCode ? 'code' : 'prose';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Default I/O surface. Overridable for tests.
|
|
43
|
+
function defaultIo(projectRoot) {
|
|
44
|
+
const root = projectRoot || process.cwd();
|
|
45
|
+
return {
|
|
46
|
+
existsSync: (p) => fs.existsSync(path.isAbsolute(p) ? p : path.join(root, p)),
|
|
47
|
+
// git diff --stat constrained to the given paths, run at the project root.
|
|
48
|
+
gitDiffStat: (paths) => {
|
|
49
|
+
const argPaths = Array.isArray(paths) && paths.length ? ['--', ...paths] : [];
|
|
50
|
+
try {
|
|
51
|
+
const out = execFileSync('git', ['diff', '--stat', ...argPaths], {
|
|
52
|
+
cwd: root,
|
|
53
|
+
encoding: 'utf8',
|
|
54
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
55
|
+
});
|
|
56
|
+
return out.trim();
|
|
57
|
+
} catch {
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Pull the candidate file tokens out of a code-shaped criterion: backticked
|
|
65
|
+
// tokens and bare filenames-with-extension. Pure (string-only).
|
|
66
|
+
export function fileTokens(criterion) {
|
|
67
|
+
const text = String(criterion || '');
|
|
68
|
+
const tokens = new Set();
|
|
69
|
+
const backtick = /`([^`]+)`/g;
|
|
70
|
+
let m;
|
|
71
|
+
while ((m = backtick.exec(text))) {
|
|
72
|
+
const inner = m[1].trim();
|
|
73
|
+
// Only treat a backticked token as a file when it LOOKS like a path/file
|
|
74
|
+
// (has a separator or an extension); a backticked `npm test` is a command.
|
|
75
|
+
if (/[./]/.test(inner) && !/\s/.test(inner)) tokens.add(inner);
|
|
76
|
+
}
|
|
77
|
+
const bare = /\b[\w./-]+\.(?:js|ts|mjs|cjs|json|yaml|yml|md|py|rs|go|sh)\b/gi;
|
|
78
|
+
while ((m = bare.exec(text))) tokens.add(m[0]);
|
|
79
|
+
return [...tokens];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Build the evidence report for a set of criteria.
|
|
83
|
+
//
|
|
84
|
+
// criteria : array of acceptance-criterion strings (the locked scope)
|
|
85
|
+
// allowedPaths : the locked allowed paths (scopes the git diff)
|
|
86
|
+
// context : { testRun } where testRun is an optional captured run
|
|
87
|
+
// { ran:bool, exitCode:number, summary?:string } — the agent
|
|
88
|
+
// passes what it observed; we never re-run the suite here.
|
|
89
|
+
// io : injected I/O (existsSync, gitDiffStat). Defaults to real fs/git.
|
|
90
|
+
//
|
|
91
|
+
// Returns { perCriterion:[{criterion, kind, hasEvidence, evidence}], missing:[...] }.
|
|
92
|
+
// A code criterion HAS evidence when a named file exists OR a constrained git
|
|
93
|
+
// diff is non-empty OR a green test run was captured. A prose criterion never
|
|
94
|
+
// claims mechanical evidence (it is human-judged) and is reported as such — it
|
|
95
|
+
// is NOT counted as missing, because prose criteria have no mechanical proof by
|
|
96
|
+
// nature; only code criteria without any evidence land in `missing`.
|
|
97
|
+
export function buildEvidence({
|
|
98
|
+
criteria = [],
|
|
99
|
+
allowedPaths = [],
|
|
100
|
+
context = {},
|
|
101
|
+
projectRoot,
|
|
102
|
+
io,
|
|
103
|
+
} = {}) {
|
|
104
|
+
const surface = io || defaultIo(projectRoot);
|
|
105
|
+
const testRun = context && context.testRun;
|
|
106
|
+
const diffStat =
|
|
107
|
+
typeof surface.gitDiffStat === 'function' ? surface.gitDiffStat(allowedPaths) : '';
|
|
108
|
+
const hasDiff = Boolean(diffStat && diffStat.length);
|
|
109
|
+
const greenTest = Boolean(testRun && testRun.ran && Number(testRun.exitCode) === 0);
|
|
110
|
+
|
|
111
|
+
const perCriterion = [];
|
|
112
|
+
const missing = [];
|
|
113
|
+
|
|
114
|
+
for (const c of Array.isArray(criteria) ? criteria : []) {
|
|
115
|
+
const kind = classifyCriterion(c);
|
|
116
|
+
if (kind === 'prose') {
|
|
117
|
+
perCriterion.push({
|
|
118
|
+
criterion: c,
|
|
119
|
+
kind,
|
|
120
|
+
hasEvidence: false,
|
|
121
|
+
evidence: { type: 'prose', note: 'human-judged outcome — no mechanical proof expected' },
|
|
122
|
+
});
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const tokens = fileTokens(c);
|
|
127
|
+
const existing = tokens.filter((t) => {
|
|
128
|
+
try {
|
|
129
|
+
return surface.existsSync(t);
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
let hasEvidence = false;
|
|
136
|
+
let evidence = null;
|
|
137
|
+
if (existing.length) {
|
|
138
|
+
hasEvidence = true;
|
|
139
|
+
evidence = { type: 'file', files: existing };
|
|
140
|
+
} else if (greenTest) {
|
|
141
|
+
hasEvidence = true;
|
|
142
|
+
evidence = {
|
|
143
|
+
type: 'test',
|
|
144
|
+
exitCode: 0,
|
|
145
|
+
summary: testRun.summary || 'test run captured green',
|
|
146
|
+
};
|
|
147
|
+
} else if (hasDiff) {
|
|
148
|
+
hasEvidence = true;
|
|
149
|
+
evidence = { type: 'diff', stat: diffStat };
|
|
150
|
+
} else {
|
|
151
|
+
evidence = { type: 'none', note: 'no file, no green test run, no diff in allowedPaths' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
perCriterion.push({ criterion: c, kind, hasEvidence, evidence });
|
|
155
|
+
if (!hasEvidence) missing.push(c);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { perCriterion, missing };
|
|
159
|
+
}
|
|
@@ -70,6 +70,47 @@ const PHASE_RANK = {
|
|
|
70
70
|
ABORTED: 9,
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
+
// Map an FD backlog item's priority (P1/P2/P3) to a Leantime priority int.
|
|
74
|
+
// Leantime priority is high=3 .. low=1; an unknown priority yields undefined so
|
|
75
|
+
// the caller can OMIT the key (an absent priority must not be forced to a value).
|
|
76
|
+
export function priorityToLeantime(priority) {
|
|
77
|
+
switch (priority) {
|
|
78
|
+
case 'P1':
|
|
79
|
+
return 3;
|
|
80
|
+
case 'P2':
|
|
81
|
+
return 2;
|
|
82
|
+
case 'P3':
|
|
83
|
+
return 1;
|
|
84
|
+
default:
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Map an FD backlog item to a Leantime story-points effort estimate. Prefers a
|
|
90
|
+
// finite numeric `complexity` (0-100 scale, bucketed onto the Fibonacci scale
|
|
91
|
+
// Leantime uses for estimates), and falls back to the coarse priority signal
|
|
92
|
+
// when complexity is absent. ALWAYS returns a number so the caller can post a
|
|
93
|
+
// non-null estimate on every created task.
|
|
94
|
+
export function complexityToStorypoints(item) {
|
|
95
|
+
if (item && Number.isFinite(item.complexity)) {
|
|
96
|
+
const c = item.complexity;
|
|
97
|
+
if (c <= 15) return 2;
|
|
98
|
+
if (c <= 39) return 5;
|
|
99
|
+
if (c <= 69) return 8;
|
|
100
|
+
return 13;
|
|
101
|
+
}
|
|
102
|
+
switch (item && item.priority) {
|
|
103
|
+
case 'P1':
|
|
104
|
+
return 8;
|
|
105
|
+
case 'P2':
|
|
106
|
+
return 5;
|
|
107
|
+
case 'P3':
|
|
108
|
+
return 3;
|
|
109
|
+
default:
|
|
110
|
+
return 3;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
73
114
|
function lastReviewStatus(state) {
|
|
74
115
|
const arr = Array.isArray(state.review_findings) ? state.review_findings : [];
|
|
75
116
|
for (let i = arr.length - 1; i >= 0; i -= 1) {
|
|
@@ -126,7 +167,7 @@ export function columnForState(state) {
|
|
|
126
167
|
// Intent ops (the shell maps each to a leantime-sync call):
|
|
127
168
|
// { op:'project_ensure', name, slug, details }
|
|
128
169
|
// { op:'assign_user' } // only if configured (shell sequences it after project_ensure)
|
|
129
|
-
// { op:'task_create', backlogId, headline }
|
|
170
|
+
// { op:'task_create', backlogId, headline, storypoints, description, priority? }
|
|
130
171
|
// { op:'task_move', backlogId, column }
|
|
131
172
|
export function decideActions({ toolName, state, sidecar = {}, assignUserConfigured = false } = {}) {
|
|
132
173
|
const kind = fdToolKind(toolName);
|
|
@@ -161,7 +202,24 @@ export function decideActions({ toolName, state, sidecar = {}, assignUserConfigu
|
|
|
161
202
|
if (!item || !item.id) continue;
|
|
162
203
|
if (item.status === 'skipped') continue;
|
|
163
204
|
if (!tasks[item.id]) {
|
|
164
|
-
|
|
205
|
+
const headline = item.title || item.id;
|
|
206
|
+
// Enrich the create with effort + priority + a traceable description so
|
|
207
|
+
// the board item carries the BYAN signal, not just a bare title.
|
|
208
|
+
const priority = priorityToLeantime(item.priority);
|
|
209
|
+
const hasComplexity = Number.isFinite(item.complexity);
|
|
210
|
+
const description =
|
|
211
|
+
`BYAN FD ${state.fd_id || ''} -- ${headline}`.trim() +
|
|
212
|
+
(hasComplexity ? ` [complexity:${item.complexity}]` : '');
|
|
213
|
+
const intent = {
|
|
214
|
+
op: 'task_create',
|
|
215
|
+
backlogId: item.id,
|
|
216
|
+
headline,
|
|
217
|
+
storypoints: complexityToStorypoints(item),
|
|
218
|
+
description,
|
|
219
|
+
};
|
|
220
|
+
// priority is OMITTED when unknown (do not force an absent priority).
|
|
221
|
+
if (priority !== undefined) intent.priority = priority;
|
|
222
|
+
intents.push(intent);
|
|
165
223
|
}
|
|
166
224
|
}
|
|
167
225
|
}
|
|
@@ -216,7 +216,7 @@ export async function ensureProject({ name, slug, clientId, details } = {}, opts
|
|
|
216
216
|
// `id` so the caller can store it back into fd-state (idempotency lives in the
|
|
217
217
|
// caller: create-only-if the backlog item has no leantime_task_id yet).
|
|
218
218
|
export async function createTask(
|
|
219
|
-
{ projectId, headline, description, status, priority, editorId, tags, type = 'task' } = {},
|
|
219
|
+
{ projectId, headline, description, status, priority, storypoints, editorId, tags, type = 'task' } = {},
|
|
220
220
|
opts = {},
|
|
221
221
|
) {
|
|
222
222
|
if (!projectId) return { ok: false, synced: false, reason: 'no_project_id' };
|
|
@@ -226,6 +226,9 @@ export async function createTask(
|
|
|
226
226
|
if (description != null) values.description = description;
|
|
227
227
|
if (status != null) values.status = status;
|
|
228
228
|
if (priority != null) values.priority = priority;
|
|
229
|
+
// storypoints is the presumed Leantime effort field (UNVERIFIED against a live
|
|
230
|
+
// instance); an unknown key is at worst ignored by addTicket, so this is safe.
|
|
231
|
+
if (storypoints != null) values.storypoints = storypoints;
|
|
229
232
|
// Default the assignee to the configured human (LEANTIME_ASSIGN_USER_ID) so an
|
|
230
233
|
// auto-created task shows on a person's board, not only the API service user.
|
|
231
234
|
const resolvedEditor = editorId != null ? editorId : assignUserId();
|