create-byan-agent 2.35.0 → 2.38.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.
Files changed (27) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/install/bin/create-byan-agent-v2.js +15 -0
  3. package/install/lib/claude-native-setup.js +12 -38
  4. package/install/lib/platforms/claude-code.js +28 -19
  5. package/install/package.json +1 -1
  6. package/install/packages/platform-config/lib/mcp-config.js +71 -5
  7. package/install/templates/.claude/CLAUDE.md +1 -0
  8. package/install/templates/.claude/hooks/inject-delivery-default.js +46 -0
  9. package/install/templates/.claude/hooks/leantime-fd-sync.js +12 -1
  10. package/install/templates/.claude/hooks/lib/delivery-contract.js +143 -0
  11. package/install/templates/.claude/hooks/lib/punt-detect.js +143 -0
  12. package/install/templates/.claude/hooks/punt-guard.js +126 -0
  13. package/install/templates/.claude/hooks/strict-stop-guard.js +29 -6
  14. package/install/templates/.claude/settings.json +8 -0
  15. package/install/templates/_byan/_config/delivery-default.json +22 -0
  16. package/install/templates/_byan/mcp/byan-mcp-server/channel-entry.js +46 -0
  17. package/install/templates/_byan/mcp/byan-mcp-server/lib/channel-poll.js +128 -0
  18. package/install/templates/_byan/mcp/byan-mcp-server/lib/channel-server.js +234 -0
  19. package/install/templates/_byan/mcp/byan-mcp-server/lib/completeness-evidence.js +159 -0
  20. package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-fd-core.js +60 -2
  21. package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-sync.js +4 -1
  22. package/install/templates/_byan/mcp/byan-mcp-server/lib/precommit-gate.js +68 -2
  23. package/install/templates/_byan/mcp/byan-mcp-server/lib/strict-mode.js +78 -1
  24. package/install/templates/docs/leantime-integration.md +11 -1
  25. package/node_modules/byan-platform-config/lib/mcp-config.js +71 -5
  26. package/package.json +1 -1
  27. package/install/templates/.mcp.json.tmpl +0 -8
@@ -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
- intents.push({ op: 'task_create', backlogId: item.id, headline: item.title || item.id });
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();