create-byan-agent 2.23.0 → 2.26.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 +230 -0
- package/README.md +9 -12
- package/install/bin/create-byan-agent-v2.js +29 -169
- package/install/lib/agent-generator.js +5 -5
- package/install/lib/byan-web-integration.js +1 -1
- package/install/lib/claude-native-setup.js +1 -1
- package/install/lib/phase2-chat.js +3 -10
- package/install/lib/platforms/claude-code.js +2 -2
- package/install/lib/platforms/index.js +0 -2
- package/install/lib/project-agents-generator.js +3 -3
- package/install/lib/staging-consent.js +3 -3
- package/install/lib/subagent-generator.js +3 -3
- package/install/lib/yanstaller/agent-launcher.js +1 -27
- package/install/lib/yanstaller/detector.js +4 -4
- package/install/lib/yanstaller/installer.js +0 -2
- package/install/lib/yanstaller/interviewer.js +1 -1
- package/install/lib/yanstaller/platform-selector.js +1 -13
- package/install/package.json +1 -1
- package/install/src/byan-v2/context/session-state.js +2 -2
- package/install/src/byan-v2/index.js +2 -6
- package/install/src/byan-v2/orchestrator/generation-state.js +4 -4
- package/install/src/webui/api.js +0 -2
- package/install/src/webui/chat/bridge.js +1 -13
- package/install/src/webui/chat/cli-detector.js +0 -23
- package/install/src/webui/public/app.js +1 -3
- package/install/src/webui/public/chat.html +0 -2
- package/install/src/webui/public/chat.js +0 -1
- package/install/src/webui/public/index.html +2 -2
- package/install/templates/.claude/CLAUDE.md +13 -2
- package/install/templates/.claude/agents/bmad-byan.md +1 -1
- package/install/templates/.claude/hooks/autobench-stop-guard.js +286 -0
- package/install/templates/.claude/hooks/drain-advisory.js +85 -0
- package/install/templates/.claude/hooks/fact-check-absolutes.js +1 -61
- package/install/templates/.claude/hooks/fact-check-claims.js +69 -0
- package/install/templates/.claude/hooks/fd-response-check.js +37 -46
- package/install/templates/.claude/hooks/inject-soul.js +64 -25
- package/install/templates/.claude/hooks/leantime-fd-sync.js +216 -0
- package/install/templates/.claude/hooks/lib/autobench-config.json +81 -0
- package/install/templates/.claude/hooks/lib/autobench-fc-enrich.js +251 -0
- package/install/templates/.claude/hooks/lib/autobench-ledger-report.js +253 -0
- package/install/templates/.claude/hooks/lib/autobench-runtime.js +199 -0
- package/install/templates/.claude/hooks/lib/fact-check-core.js +69 -0
- package/install/templates/.claude/hooks/lib/failure-detector.js +18 -4
- package/install/templates/.claude/hooks/lib/transcript-read.js +137 -0
- package/install/templates/.claude/hooks/soul-memory-check.js +49 -25
- package/install/templates/.claude/hooks/soul-memory-triggers.js +27 -8
- package/install/templates/.claude/hooks/stage-to-byan.js +25 -7
- package/install/templates/.claude/hooks/strict-stop-guard.js +4 -16
- package/install/templates/.claude/rules/benchmark.md +251 -0
- package/install/templates/.claude/rules/byan-agents.md +0 -1
- package/install/templates/.claude/rules/byan-api.md +64 -0
- package/install/templates/.claude/rules/fact-check.md +1 -1
- package/install/templates/.claude/rules/strict-mode.md +10 -9
- package/install/templates/.claude/settings.json +16 -0
- package/install/templates/.claude/skills/byan-benchmark/SKILL.md +159 -0
- package/install/templates/.claude/skills/byan-byan/SKILL.md +73 -12
- package/install/templates/.claude/skills/byan-fact-check/SKILL.md +1 -1
- package/install/templates/.claude/skills/byan-hermes-dispatch/SKILL.md +5 -6
- package/install/templates/.claude/skills/byan-insight/SKILL.md +56 -0
- package/install/templates/.claude/skills/byan-orchestrate/SKILL.md +11 -3
- package/install/templates/.claude/skills/byan-strict/SKILL.md +4 -1
- package/install/templates/.claude/workflows/INDEX.md +2 -1
- package/install/templates/.claude/workflows/byan-benchmark.js +328 -0
- package/install/templates/.claude/workflows/check-implementation-readiness.js +1 -1
- package/install/templates/_byan/_config/agent-manifest.csv +1 -1
- package/install/templates/_byan/_config/autobench.yaml +510 -0
- package/install/templates/_byan/_config/strict-mode.yaml +9 -3
- package/install/templates/_byan/_config/workflow-manifest.csv +1 -0
- package/install/templates/_byan/agent/byan/byan.md +1 -3
- package/install/templates/_byan/agent/byan-flat/byan.md +1 -3
- package/install/templates/_byan/agent/byan-test/byan-test.md +2 -2
- package/install/templates/_byan/agent/byan-test-flat/byan-test.md +2 -2
- package/install/templates/_byan/agent/byan.optimized/byan.optimized.md +2 -2
- package/install/templates/_byan/agent/byan.optimized-v2/byan.optimized-v2.md +2 -2
- package/install/templates/_byan/agent/claude/claude.md +0 -2
- package/install/templates/_byan/agent/codex/codex.md +0 -2
- package/install/templates/_byan/agent/rachid/rachid.md +2 -10
- package/install/templates/_byan/agent/rachid-flat/rachid.md +2 -11
- package/install/templates/_byan/agent/turbo-whisper/turbo-whisper.md +2 -5
- package/install/templates/_byan/agent/turbo-whisper-integration/turbo-whisper-integration.md +5 -13
- package/install/templates/_byan/agent/yanstaller/yanstaller.md +2 -24
- package/install/templates/_byan/config.yaml +0 -1
- package/install/templates/_byan/core/activation/soul-activation.md +3 -3
- package/install/templates/_byan/mcp/byan-mcp-server/bin/byan-insight-digest.js +31 -0
- package/install/templates/_byan/mcp/byan-mcp-server/bin/byan-sync-rules.js +20 -4
- package/install/templates/_byan/mcp/byan-mcp-server/lib/advisory-autofeed.js +96 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/index-generator.js +1 -1
- package/install/templates/_byan/mcp/byan-mcp-server/lib/insight-harvest.js +220 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/kanban.js +6 -3
- package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-fd-core.js +205 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-sync.js +415 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/outcome-buffer.js +64 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/precommit-gate.js +1 -1
- package/install/templates/_byan/mcp/byan-mcp-server/lib/strict-activation.js +1 -1
- package/install/templates/_byan/mcp/byan-mcp-server/lib/strict-mode.js +8 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/sync-rules.js +172 -23
- package/install/templates/_byan/mcp/byan-mcp-server/lib/workflows-generator.js +1 -0
- package/install/templates/_byan/mcp/byan-mcp-server/server.js +262 -81
- package/install/templates/_byan/worker/launchers/README.md +4 -24
- package/install/templates/_byan/worker/workers.md +8 -9
- package/install/templates/_byan/workflow/simple/bmb/byan-benchmark/workflow.md +86 -0
- package/install/templates/_byan/workflow/simple/byan/feature-workflow.md +2 -2
- package/install/templates/docs/leantime-integration.md +160 -0
- package/package.json +3 -7
- package/src/byan-v2/context/session-state.js +2 -2
- package/src/byan-v2/generation/mantra-validator.js +3 -3
- package/src/byan-v2/index.js +1 -5
- package/src/byan-v2/integration/voice-integration.js +1 -1
- package/src/byan-v2/orchestrator/generation-state.js +4 -4
- package/src/loadbalancer/loadbalancer.js +1 -1
- package/src/staging/staging.js +20 -6
- package/install/bin/build-copilot-stubs.js +0 -138
- package/install/lib/platforms/copilot-cli.js +0 -123
- package/install/lib/platforms/vscode.js +0 -51
- package/install/src/byan-v2/context/copilot-context.js +0 -79
- package/install/src/webui/chat/copilot-adapter.js +0 -68
- package/install/templates/.claude/agents/bmad-marc.md +0 -25
- package/install/templates/.claude/skills/byan-marc/SKILL.md +0 -20
- package/install/templates/.github/agents/bmad-agent-bmad-master.md +0 -16
- package/install/templates/.github/agents/bmad-agent-bmb-agent-builder.md +0 -16
- package/install/templates/.github/agents/bmad-agent-bmb-module-builder.md +0 -16
- package/install/templates/.github/agents/bmad-agent-bmb-workflow-builder.md +0 -16
- package/install/templates/.github/agents/bmad-agent-bmm-analyst.md +0 -16
- package/install/templates/.github/agents/bmad-agent-bmm-architect.md +0 -16
- package/install/templates/.github/agents/bmad-agent-bmm-dev.md +0 -16
- package/install/templates/.github/agents/bmad-agent-bmm-pm.md +0 -16
- package/install/templates/.github/agents/bmad-agent-bmm-quick-flow-solo-dev.md +0 -16
- package/install/templates/.github/agents/bmad-agent-bmm-quinn.md +0 -16
- package/install/templates/.github/agents/bmad-agent-bmm-sm.md +0 -16
- package/install/templates/.github/agents/bmad-agent-bmm-tech-writer.md +0 -16
- package/install/templates/.github/agents/bmad-agent-bmm-ux-designer.md +0 -16
- package/install/templates/.github/agents/bmad-agent-byan-test.md +0 -33
- package/install/templates/.github/agents/bmad-agent-byan-v2.md +0 -44
- package/install/templates/.github/agents/bmad-agent-byan.md +0 -1062
- package/install/templates/.github/agents/bmad-agent-carmack.md +0 -14
- package/install/templates/.github/agents/bmad-agent-cis-brainstorming-coach.md +0 -16
- package/install/templates/.github/agents/bmad-agent-cis-creative-problem-solver.md +0 -16
- package/install/templates/.github/agents/bmad-agent-cis-design-thinking-coach.md +0 -16
- package/install/templates/.github/agents/bmad-agent-cis-innovation-strategist.md +0 -16
- package/install/templates/.github/agents/bmad-agent-cis-presentation-master.md +0 -16
- package/install/templates/.github/agents/bmad-agent-cis-storyteller.md +0 -16
- package/install/templates/.github/agents/bmad-agent-claude.md +0 -49
- package/install/templates/.github/agents/bmad-agent-codex.md +0 -49
- package/install/templates/.github/agents/bmad-agent-drawio.md +0 -45
- package/install/templates/.github/agents/bmad-agent-fact-checker.md +0 -16
- package/install/templates/.github/agents/bmad-agent-forgeron.md +0 -15
- package/install/templates/.github/agents/bmad-agent-jimmy.md +0 -15
- package/install/templates/.github/agents/bmad-agent-marc.md +0 -49
- package/install/templates/.github/agents/bmad-agent-mike.md +0 -15
- package/install/templates/.github/agents/bmad-agent-patnote.md +0 -49
- package/install/templates/.github/agents/bmad-agent-rachid.md +0 -48
- package/install/templates/.github/agents/bmad-agent-skeptic.md +0 -16
- package/install/templates/.github/agents/bmad-agent-tao.md +0 -14
- package/install/templates/.github/agents/bmad-agent-tea-tea.md +0 -16
- package/install/templates/.github/agents/bmad-agent-test-dynamic.md +0 -22
- package/install/templates/.github/agents/bmad-agent-yanstaller-interview.md +0 -50
- package/install/templates/.github/agents/bmad-agent-yanstaller-phase2.md +0 -189
- package/install/templates/.github/agents/bmad-agent-yanstaller.md +0 -350
- package/install/templates/.github/agents/expert-merise-agile.md +0 -178
- package/install/templates/.github/agents/franck.md +0 -379
- package/install/templates/.github/agents/hermes.md +0 -575
- package/install/templates/.github/extensions/byan-staging/extension.mjs +0 -169
- package/install/templates/.github/extensions/byan-staging/package.json +0 -8
- package/install/templates/_byan/agent/marc/marc-soul.md +0 -47
- package/install/templates/_byan/agent/marc/marc-tao.md +0 -77
- package/install/templates/_byan/agent/marc/marc.md +0 -324
- package/install/templates/_byan/agent/marc-flat/marc.md +0 -387
- package/install/templates/_byan/mcp/byan-mcp-server/lib/copilot.js +0 -148
- package/install/templates/_byan/worker/launchers/launch-yanstaller-copilot.md +0 -173
- package/install/templates/workers/cost-optimizer.js +0 -169
- package/src/byan-v2/context/copilot-context.js +0 -79
- package/src/core/dispatcher/execution-router.js +0 -66
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
// BYAN <-> Leantime sync layer.
|
|
2
|
+
//
|
|
3
|
+
// Leantime (self-hosted project-management tool) is the authority for the
|
|
4
|
+
// projects/tasks that the FD workflow mirrors. This module is the only place
|
|
5
|
+
// that talks to the Leantime JSON-RPC API; the FD skill drives it through the
|
|
6
|
+
// byan_leantime_* MCP tools, never by importing this file from a workflow.
|
|
7
|
+
//
|
|
8
|
+
// Best-effort, like lib/strict-sync.js: a missing token/base, an unreachable
|
|
9
|
+
// instance, a non-2xx, a JSON-RPC error envelope, or a non-JSON (HTML login)
|
|
10
|
+
// body degrades to { ok:false, synced:false, reason } and never throws. A down
|
|
11
|
+
// Leantime must not break an FD phase transition.
|
|
12
|
+
//
|
|
13
|
+
// API shape (Leantime JSON-RPC 2.0):
|
|
14
|
+
// POST <base>/api/jsonrpc with header x-api-key: <token>
|
|
15
|
+
// body { jsonrpc:"2.0", id, method:"leantime.rpc.<domain>.<method>", params }
|
|
16
|
+
// Auth is the x-api-key header ONLY — never the byan_/ApiKey/Bearer scheme.
|
|
17
|
+
//
|
|
18
|
+
// Method names + param wrapping are read from the Leantime master source (L2).
|
|
19
|
+
// The single METHODS table below is the one place to correct them once the F0
|
|
20
|
+
// live-verify (one real POST with a Personal Access Token) confirms the wire
|
|
21
|
+
// format against projets.acadenice.com. Items tagged VERIFY@F0 are the ones the
|
|
22
|
+
// recon could not confirm without a live call.
|
|
23
|
+
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
25
|
+
const RPC_PATH = '/api/jsonrpc';
|
|
26
|
+
|
|
27
|
+
// Canonical FD lifecycle columns. Resolved to per-project Leantime status ids at
|
|
28
|
+
// call time via resolveStatusMap (Leantime statuses are configurable per project,
|
|
29
|
+
// so a fixed int map would drift). Mirrors lib/kanban.js COLUMNS.
|
|
30
|
+
const COLUMNS = ['todo', 'doing', 'blocked', 'review', 'done'];
|
|
31
|
+
|
|
32
|
+
// Single source of truth for the JSON-RPC method names (L2: Leantime master
|
|
33
|
+
// Projects.php / Tickets.php). Correct here after F0 if the live wire differs.
|
|
34
|
+
const METHODS = {
|
|
35
|
+
addProject: 'leantime.rpc.projects.addProject',
|
|
36
|
+
getAllProjects: 'leantime.rpc.projects.getAllProjects',
|
|
37
|
+
getUsersAssignedToProject: 'leantime.rpc.projects.getUsersAssignedToProject',
|
|
38
|
+
addTicket: 'leantime.rpc.tickets.addTicket',
|
|
39
|
+
updateTicket: 'leantime.rpc.tickets.updateTicket',
|
|
40
|
+
getTicket: 'leantime.rpc.tickets.getTicket', // VERIFY@F0 (single-ticket getter name)
|
|
41
|
+
getAllTickets: 'leantime.rpc.tickets.getAll',
|
|
42
|
+
getStatusLabels: 'leantime.rpc.tickets.getStatusLabels',
|
|
43
|
+
getAllClients: 'leantime.rpc.clients.getAllClients',
|
|
44
|
+
// F0-confirmed (Leantime 3.7.1 source): the JSON-RPC resolves SERVICE methods
|
|
45
|
+
// only. editUserProjectRelations is keyed by USER and RECONCILES the user's
|
|
46
|
+
// project list (adds the absent, deletes those not passed), so
|
|
47
|
+
// assignUserToProject reads the full list first. getProjectsAssignedToUser is
|
|
48
|
+
// that read.
|
|
49
|
+
editUserProjectRelations: 'leantime.rpc.projects.editUserProjectRelations',
|
|
50
|
+
getProjectsAssignedToUser: 'leantime.rpc.projects.getProjectsAssignedToUser',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Conservative fallback when resolveStatusMap cannot read the project's labels
|
|
54
|
+
// (Leantime default seed: 3=new). Confirmed/overridden at F0. Used only as a
|
|
55
|
+
// last resort so a status move degrades to a plausible id rather than throwing.
|
|
56
|
+
const DEFAULT_STATUS_MAP = {
|
|
57
|
+
todo: 3,
|
|
58
|
+
doing: 1,
|
|
59
|
+
blocked: 4,
|
|
60
|
+
review: 2,
|
|
61
|
+
done: 0,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function apiBase() {
|
|
65
|
+
return (process.env.LEANTIME_API_URL || '').replace(/\/+$/, '');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function apiToken() {
|
|
69
|
+
return process.env.LEANTIME_API_TOKEN || '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Leantime authenticates the JSON-RPC API with its own header, NOT an
|
|
73
|
+
// Authorization scheme. Reusing the byan_/ApiKey/Bearer switch would send a
|
|
74
|
+
// header Leantime ignores -> the call goes through unauthenticated (likely a
|
|
75
|
+
// 401, or a fall-through to the HTML login that the non_json guard catches; the
|
|
76
|
+
// exact code is confirmed at F0, see VERIFY@F0).
|
|
77
|
+
function authHeader(token) {
|
|
78
|
+
if (!token) return {};
|
|
79
|
+
return { 'x-api-key': token };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function syncEnabled({ token = apiToken(), base = apiBase() } = {}) {
|
|
83
|
+
return Boolean(token && base);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Low-level JSON-RPC call. Best-effort, never throws.
|
|
87
|
+
// Returns { ok, synced, reason?, data?, status?, error?, hint? }.
|
|
88
|
+
export async function rpc(
|
|
89
|
+
method,
|
|
90
|
+
params = {},
|
|
91
|
+
{ base = apiBase(), token = apiToken(), fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS, id = 'byan' } = {},
|
|
92
|
+
) {
|
|
93
|
+
if (!base) return { ok: false, synced: false, reason: 'no_base' };
|
|
94
|
+
if (!token) return { ok: false, synced: false, reason: 'no_token' };
|
|
95
|
+
if (typeof fetchImpl !== 'function') return { ok: false, synced: false, reason: 'no_fetch' };
|
|
96
|
+
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetchImpl(`${base}${RPC_PATH}`, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'Content-Type': 'application/json', ...authHeader(token) },
|
|
103
|
+
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
|
|
104
|
+
signal: controller.signal,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
return { ok: false, synced: false, reason: `http_${res.status}`, status: res.status };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Wrong-host guard (the BYAN_API_URL lesson): Leantime serves the HTML app
|
|
112
|
+
// AND the JSON-RPC API on the same domain. A 200 carrying HTML means
|
|
113
|
+
// LEANTIME_API_URL points at the UI, not the API. A parse failure (or a
|
|
114
|
+
// non-object body) is treated as a wrong-host/non-JSON response, never read
|
|
115
|
+
// as an empty result.
|
|
116
|
+
const contentType = (res.headers && typeof res.headers.get === 'function'
|
|
117
|
+
? (res.headers.get('content-type') || '')
|
|
118
|
+
: '').toLowerCase();
|
|
119
|
+
let data = null;
|
|
120
|
+
try {
|
|
121
|
+
data = await res.json();
|
|
122
|
+
} catch {
|
|
123
|
+
data = null;
|
|
124
|
+
}
|
|
125
|
+
if (data === null || typeof data !== 'object') {
|
|
126
|
+
const hint = contentType.includes('text/html')
|
|
127
|
+
? 'Expected JSON-RPC, got HTML. LEANTIME_API_URL likely points at the Leantime UI, not the /api/jsonrpc backend.'
|
|
128
|
+
: 'Expected a JSON-RPC envelope.';
|
|
129
|
+
return { ok: false, synced: false, reason: 'non_json', hint };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// `error` carries the Leantime envelope verbatim for the caller's own logic;
|
|
133
|
+
// a caller logging to a shared file must record `reason` only, never `error`,
|
|
134
|
+
// to keep a server message (which could echo input) out of the log.
|
|
135
|
+
if (data.error) {
|
|
136
|
+
return { ok: false, synced: false, reason: 'rpc_error', error: data.error, status: res.status };
|
|
137
|
+
}
|
|
138
|
+
// Leantime returns { jsonrpc, id, result }. `result` may legitimately be
|
|
139
|
+
// false/0/'' on a failed-but-200 op, so expose it verbatim.
|
|
140
|
+
return { ok: true, synced: true, status: res.status, data: data.result };
|
|
141
|
+
} catch (err) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
synced: false,
|
|
145
|
+
reason: err && err.name === 'AbortError' ? 'timeout' : 'network_error',
|
|
146
|
+
error: err ? err.message : String(err),
|
|
147
|
+
};
|
|
148
|
+
} finally {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// addProject takes `array $values`; addTicket takes `$values`. The recon read
|
|
154
|
+
// the named PHP arg as the params key (params:{values:{...}}). F0 (2026-06-15)
|
|
155
|
+
// confirmed live that the server expects params:{values:{...}} for both.
|
|
156
|
+
function wrapValues(values) {
|
|
157
|
+
return { values };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Leantime project/ticket ids are positive auto-increment integers. rpc() exposes
|
|
161
|
+
// a falsy JSON-RPC result verbatim (a 200 carrying result false/0/'' is a
|
|
162
|
+
// failed-but-not-errored op), so a create path must reject a non-positive id
|
|
163
|
+
// instead of persisting it back into fd-state as a real id.
|
|
164
|
+
function isValidId(v) {
|
|
165
|
+
if (typeof v === 'boolean') return false;
|
|
166
|
+
const n = Number(v);
|
|
167
|
+
if (Number.isFinite(n)) return n > 0;
|
|
168
|
+
return typeof v === 'string' && v.length > 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Leantime's addProject / addTicket return the new id wrapped in a
|
|
172
|
+
// single-element array (result:[id]) on 3.7.x, confirmed live at F0
|
|
173
|
+
// (2026-06-15: addProject -> [69], addTicket -> [770]). Other shapes give a
|
|
174
|
+
// scalar id or an { id } object. Normalize all three to the scalar id, so a
|
|
175
|
+
// create does not propagate an array (isValidId would accept Number([69])===69)
|
|
176
|
+
// and does not mis-read [id].id as undefined.
|
|
177
|
+
function firstId(data) {
|
|
178
|
+
if (Array.isArray(data)) return data.length ? data[0] : undefined;
|
|
179
|
+
if (data && typeof data === 'object') return data.id;
|
|
180
|
+
return data;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Idempotent create-or-fetch of a Leantime project from the FD project_context.
|
|
184
|
+
// Matches an existing project by exact name first (so re-running an FD or a
|
|
185
|
+
// REFACTOR loop does not duplicate). Returns { ok, id, created, synced, reason }.
|
|
186
|
+
export async function ensureProject({ name, slug, clientId, details } = {}, opts = {}) {
|
|
187
|
+
const term = name || slug;
|
|
188
|
+
if (!term) return { ok: false, synced: false, reason: 'no_name' };
|
|
189
|
+
|
|
190
|
+
const existing = await rpc(METHODS.getAllProjects, {}, opts);
|
|
191
|
+
if (existing.ok && Array.isArray(existing.data)) {
|
|
192
|
+
const hit = existing.data.find((p) => p && (p.name === term || p.name === name || p.name === slug));
|
|
193
|
+
if (hit) return { ok: true, synced: true, id: hit.id, created: false };
|
|
194
|
+
} else if (!existing.ok) {
|
|
195
|
+
// A failed list (not an empty one) means "is it absent?" is unknown. Creating
|
|
196
|
+
// here would mint a duplicate on the next FD re-run that hits the same
|
|
197
|
+
// transient error, breaking the idempotency contract. Surface the failure.
|
|
198
|
+
return { ...existing, created: false };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const resolvedClientId = clientId != null ? clientId : await resolveClientId(opts);
|
|
202
|
+
const created = await rpc(
|
|
203
|
+
METHODS.addProject,
|
|
204
|
+
wrapValues({ name: term, clientId: resolvedClientId, details: details || `Created by BYAN FD (slug: ${slug || term}).` }),
|
|
205
|
+
opts,
|
|
206
|
+
);
|
|
207
|
+
if (!created.ok) return created;
|
|
208
|
+
const newId = firstId(created.data);
|
|
209
|
+
if (!isValidId(newId)) {
|
|
210
|
+
return { ok: false, synced: false, reason: 'create_rejected', data: created.data };
|
|
211
|
+
}
|
|
212
|
+
return { ok: true, synced: true, id: newId, created: true };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Create one Leantime task from an FD backlog item. Returns the new task id in
|
|
216
|
+
// `id` so the caller can store it back into fd-state (idempotency lives in the
|
|
217
|
+
// caller: create-only-if the backlog item has no leantime_task_id yet).
|
|
218
|
+
export async function createTask(
|
|
219
|
+
{ projectId, headline, description, status, priority, editorId, tags, type = 'task' } = {},
|
|
220
|
+
opts = {},
|
|
221
|
+
) {
|
|
222
|
+
if (!projectId) return { ok: false, synced: false, reason: 'no_project_id' };
|
|
223
|
+
if (!headline) return { ok: false, synced: false, reason: 'no_headline' };
|
|
224
|
+
|
|
225
|
+
const values = { projectId, headline, type };
|
|
226
|
+
if (description != null) values.description = description;
|
|
227
|
+
if (status != null) values.status = status;
|
|
228
|
+
if (priority != null) values.priority = priority;
|
|
229
|
+
// Default the assignee to the configured human (LEANTIME_ASSIGN_USER_ID) so an
|
|
230
|
+
// auto-created task shows on a person's board, not only the API service user.
|
|
231
|
+
const resolvedEditor = editorId != null ? editorId : assignUserId();
|
|
232
|
+
if (resolvedEditor != null) values.editorId = resolvedEditor;
|
|
233
|
+
if (tags != null) values.tags = tags;
|
|
234
|
+
|
|
235
|
+
const res = await rpc(METHODS.addTicket, wrapValues(values), opts);
|
|
236
|
+
if (!res.ok) return res;
|
|
237
|
+
// addTicket returns the new id wrapped in a single-element array
|
|
238
|
+
// (result:[id], confirmed live at F0); other shapes give a scalar or { id }.
|
|
239
|
+
const id = firstId(res.data);
|
|
240
|
+
// A 200 with a falsy result is a failed-but-not-errored add, not a new id;
|
|
241
|
+
// do not persist it back into fd-state as a real task id.
|
|
242
|
+
if (!isValidId(id)) {
|
|
243
|
+
return { ok: false, synced: false, reason: 'create_rejected', data: res.data };
|
|
244
|
+
}
|
|
245
|
+
return { ok: true, synced: true, id };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Move a task to a lifecycle column. Resolves the column to the project's status
|
|
249
|
+
// id, then updates the ticket (Leantime has no dedicated status-change RPC).
|
|
250
|
+
export async function moveTask({ taskId, projectId, column, status }, opts = {}) {
|
|
251
|
+
if (!taskId) return { ok: false, synced: false, reason: 'no_task_id' };
|
|
252
|
+
let statusId = status;
|
|
253
|
+
if (statusId == null) {
|
|
254
|
+
if (!COLUMNS.includes(column)) return { ok: false, synced: false, reason: 'bad_column' };
|
|
255
|
+
const map = await resolveStatusMap({ projectId }, opts);
|
|
256
|
+
statusId = map[column];
|
|
257
|
+
// resolveStatusMap leaves a column undefined rather than collapse it onto an
|
|
258
|
+
// id already claimed by another column. Surface that instead of sending an
|
|
259
|
+
// undefined status (which JSON.stringify would silently drop from the body).
|
|
260
|
+
if (statusId == null) return { ok: false, synced: false, reason: 'unresolved_status' };
|
|
261
|
+
}
|
|
262
|
+
return rpc(METHODS.updateTicket, wrapValues({ id: taskId, status: statusId }), opts);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Set the assignee/editor of a task (mirror of byan_kanban_assign).
|
|
266
|
+
export async function assignTask({ taskId, editorId }, opts = {}) {
|
|
267
|
+
if (!taskId) return { ok: false, synced: false, reason: 'no_task_id' };
|
|
268
|
+
if (editorId == null) return { ok: false, synced: false, reason: 'no_editor_id' };
|
|
269
|
+
return rpc(METHODS.updateTicket, wrapValues({ id: taskId, editorId }), opts);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function getTask({ taskId }, opts = {}) {
|
|
273
|
+
if (!taskId) return { ok: false, synced: false, reason: 'no_task_id' };
|
|
274
|
+
return rpc(METHODS.getTicket, { id: taskId }, opts);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// List a project's tasks grouped by lifecycle column (mirror of byan_kanban_get).
|
|
278
|
+
export async function getBoard({ projectId }, opts = {}) {
|
|
279
|
+
if (!projectId) return { ok: false, synced: false, reason: 'no_project_id' };
|
|
280
|
+
const res = await rpc(METHODS.getAllTickets, { searchCriteria: { currentProject: projectId } }, opts);
|
|
281
|
+
if (!res.ok) return res;
|
|
282
|
+
const tickets = Array.isArray(res.data) ? res.data : [];
|
|
283
|
+
const map = await resolveStatusMap({ projectId }, opts);
|
|
284
|
+
const byStatusId = {};
|
|
285
|
+
// Skip an undefined sid: resolveStatusMap may leave a column unmapped (a
|
|
286
|
+
// would-collide default), and byStatusId[undefined] would coerce to the string
|
|
287
|
+
// key 'undefined', shadowing the 'todo' fallback for a genuinely null-status
|
|
288
|
+
// ticket. Only real status ids become keys.
|
|
289
|
+
for (const [col, sid] of Object.entries(map)) if (sid != null) byStatusId[sid] = col;
|
|
290
|
+
const board = Object.fromEntries(COLUMNS.map((c) => [c, []]));
|
|
291
|
+
for (const t of tickets) {
|
|
292
|
+
const col = byStatusId[t && t.status] || 'todo';
|
|
293
|
+
board[col].push(t);
|
|
294
|
+
}
|
|
295
|
+
return { ok: true, synced: true, data: board };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Build a COLUMN -> Leantime status-id map for a project. Reads the project's
|
|
299
|
+
// configured status labels and matches them to the canonical columns by label
|
|
300
|
+
// substring; falls back to DEFAULT_STATUS_MAP when the labels cannot be read,
|
|
301
|
+
// so a move degrades to a plausible id rather than throwing.
|
|
302
|
+
export async function resolveStatusMap({ projectId } = {}, opts = {}) {
|
|
303
|
+
const res = await rpc(METHODS.getStatusLabels, projectId != null ? { projectId } : {}, opts);
|
|
304
|
+
if (!res.ok || !res.data || typeof res.data !== 'object') return { ...DEFAULT_STATUS_MAP };
|
|
305
|
+
|
|
306
|
+
// res.data is typically { <statusId>: { name, ... } }. Match by name.
|
|
307
|
+
const entries = Object.entries(res.data);
|
|
308
|
+
// Short, ambiguous needles match on a word boundary so a custom label like
|
|
309
|
+
// 'Renewal' (contains 'new') is not mis-claimed; longer distinctive needles
|
|
310
|
+
// stay plain substrings.
|
|
311
|
+
const WORD_NEEDLES = new Set(['new', 'wip', 'qa']);
|
|
312
|
+
const labelHas = (label, n) => (WORD_NEEDLES.has(n) ? new RegExp(`\\b${n}\\b`).test(label) : label.includes(n));
|
|
313
|
+
const find = (...needles) => {
|
|
314
|
+
for (const [sid, meta] of entries) {
|
|
315
|
+
const label = String((meta && (meta.name || meta.label)) || '').toLowerCase();
|
|
316
|
+
if (needles.some((n) => labelHas(label, n))) return Number(sid);
|
|
317
|
+
}
|
|
318
|
+
return undefined;
|
|
319
|
+
};
|
|
320
|
+
const map = {
|
|
321
|
+
todo: find('todo', 'new', 'backlog', 'to do'),
|
|
322
|
+
doing: find('progress', 'doing', 'wip'),
|
|
323
|
+
blocked: find('block', 'hold'),
|
|
324
|
+
review: find('review', 'qa', 'test'),
|
|
325
|
+
done: find('done', 'closed', 'complete'),
|
|
326
|
+
};
|
|
327
|
+
// Fill any unmatched column from the conservative default, but do not reuse an
|
|
328
|
+
// id already claimed by a real label-matched column: two FD columns collapsing
|
|
329
|
+
// onto one status id would mis-bucket the board and mis-target a move (e.g.
|
|
330
|
+
// review and done both -> 2 when a project defines only 'Done'). A column that
|
|
331
|
+
// would only collide stays undefined; getBoard routes an unmapped status to
|
|
332
|
+
// 'todo' and moveTask surfaces 'unresolved_status'.
|
|
333
|
+
const claimed = new Set(Object.values(map).filter((v) => v != null));
|
|
334
|
+
for (const col of COLUMNS) {
|
|
335
|
+
if (map[col] != null) continue;
|
|
336
|
+
const fallback = DEFAULT_STATUS_MAP[col];
|
|
337
|
+
if (!claimed.has(fallback)) {
|
|
338
|
+
map[col] = fallback;
|
|
339
|
+
claimed.add(fallback);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return map;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Resolve a clientId for addProject (a project with no client may be orphaned).
|
|
346
|
+
// Prefers LEANTIME_CLIENT_ID, else the first client the API returns, else 1.
|
|
347
|
+
export async function resolveClientId(opts = {}) {
|
|
348
|
+
const fromEnv = process.env.LEANTIME_CLIENT_ID;
|
|
349
|
+
if (fromEnv) return Number(fromEnv);
|
|
350
|
+
const res = await rpc(METHODS.getAllClients, {}, opts);
|
|
351
|
+
if (res.ok && Array.isArray(res.data) && res.data.length > 0) {
|
|
352
|
+
return res.data[0].id != null ? res.data[0].id : 1;
|
|
353
|
+
}
|
|
354
|
+
return 1;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Resolve a valid editor/assignee id for a project (first assigned user).
|
|
358
|
+
export async function resolveEditorId({ projectId } = {}, opts = {}) {
|
|
359
|
+
if (!projectId) return null;
|
|
360
|
+
const res = await rpc(METHODS.getUsersAssignedToProject, { projectId }, opts);
|
|
361
|
+
if (res.ok && Array.isArray(res.data) && res.data.length > 0) {
|
|
362
|
+
return res.data[0].id != null ? res.data[0].id : null;
|
|
363
|
+
}
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// The configured human Leantime user id (LEANTIME_ASSIGN_USER_ID), or null. Used
|
|
368
|
+
// to make auto-created projects/tasks visible to a person: an API key creates
|
|
369
|
+
// them as a service user, hidden from a human's project selector until related.
|
|
370
|
+
export function assignUserId() {
|
|
371
|
+
const v = process.env.LEANTIME_ASSIGN_USER_ID;
|
|
372
|
+
const n = Number(v);
|
|
373
|
+
return v && Number.isFinite(n) && n > 0 ? n : null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Relate a human user to a project so the project shows in their selector.
|
|
377
|
+
//
|
|
378
|
+
// SAFETY (F0, Leantime 3.7.1 source): the only RPC-reachable write,
|
|
379
|
+
// editUserProjectRelations, is keyed by USER and RECONCILES — it deletes every
|
|
380
|
+
// relation NOT in the list it is handed. Passing a bare [projectId] would
|
|
381
|
+
// unassign the user from all their other projects. So this reads the user's
|
|
382
|
+
// CURRENT projects (projectStatus 'all') and writes the union. FAIL-CLOSED: if
|
|
383
|
+
// that read fails, returns empty, or is only partially parseable, it does NOT
|
|
384
|
+
// write (a partial list could drop real memberships) and surfaces a reason.
|
|
385
|
+
export async function assignUserToProject({ projectId, userId } = {}, opts = {}) {
|
|
386
|
+
const uid = userId != null ? userId : assignUserId();
|
|
387
|
+
if (uid == null) return { ok: false, synced: false, reason: 'no_assign_user' };
|
|
388
|
+
if (!projectId) return { ok: false, synced: false, reason: 'no_project_id' };
|
|
389
|
+
|
|
390
|
+
const current = await rpc(METHODS.getProjectsAssignedToUser, { userId: uid, projectStatus: 'all' }, opts);
|
|
391
|
+
if (!current.ok) return { ok: false, synced: false, reason: 'assign_read_failed' };
|
|
392
|
+
const raw = Array.isArray(current.data) ? current.data : [];
|
|
393
|
+
const ids = raw
|
|
394
|
+
.map((p) => (p && p.id != null ? Number(p.id) : null))
|
|
395
|
+
.filter((n) => Number.isFinite(n) && n > 0);
|
|
396
|
+
// Fail-closed on an empty read: "no projects" and a partial/failed read are
|
|
397
|
+
// indistinguishable here, and writing an incomplete list would delete real
|
|
398
|
+
// memberships. A human is in practice already on >= 1 project.
|
|
399
|
+
if (ids.length === 0) return { ok: false, synced: false, reason: 'assign_read_empty' };
|
|
400
|
+
// Fail-closed on a partially-parseable read: if any row did not yield a positive
|
|
401
|
+
// id, the read shape is not trusted. editUserProjectRelations reconciles, so
|
|
402
|
+
// writing the parsed subset would drop the memberships behind the unparsed rows.
|
|
403
|
+
if (ids.length !== raw.length) return { ok: false, synced: false, reason: 'assign_read_partial' };
|
|
404
|
+
if (ids.includes(Number(projectId))) return { ok: true, synced: true, alreadyAssigned: true };
|
|
405
|
+
|
|
406
|
+
const res = await rpc(
|
|
407
|
+
METHODS.editUserProjectRelations,
|
|
408
|
+
{ id: uid, projects: [...ids, Number(projectId)] },
|
|
409
|
+
opts,
|
|
410
|
+
);
|
|
411
|
+
if (!res.ok) return res;
|
|
412
|
+
return { ok: true, synced: true, assigned: true, userId: uid, projectId: Number(projectId) };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export { COLUMNS, METHODS, DEFAULT_STATUS_MAP };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Outcome buffer — the append-only capture file the advisory auto-feed drains.
|
|
2
|
+
//
|
|
3
|
+
// byan_outcome_log appends one validated outcome per line here during a turn; the
|
|
4
|
+
// drain-advisory Stop hook reads it at end of turn and records each new line into
|
|
5
|
+
// the advisory ledgers, advancing a line cursor for idempotency. Both sides take an
|
|
6
|
+
// injected `io` so the logic is testable without touching the real filesystem, and
|
|
7
|
+
// every operation is best-effort: a capture buffer must never break a turn.
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
export const BUFFER_REL = path.join('_byan-output', 'pending-outcomes.jsonl');
|
|
13
|
+
export const CURSOR_REL = path.join('_byan-output', '.advisory-cursor.json');
|
|
14
|
+
|
|
15
|
+
function bufferPath(rootDir) {
|
|
16
|
+
return path.join(rootDir, BUFFER_REL);
|
|
17
|
+
}
|
|
18
|
+
function cursorPath(rootDir) {
|
|
19
|
+
return path.join(rootDir, CURSOR_REL);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Append one outcome object as a jsonl line. Best-effort: returns true on write,
|
|
23
|
+
// false if the write threw (the caller stays safe).
|
|
24
|
+
export function appendOutcome(outcome, { rootDir, io = fs } = {}) {
|
|
25
|
+
try {
|
|
26
|
+
const p = bufferPath(rootDir);
|
|
27
|
+
io.mkdirSync(path.dirname(p), { recursive: true });
|
|
28
|
+
io.appendFileSync(p, JSON.stringify(outcome) + '\n');
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Read the raw buffer text, or '' if absent/unreadable.
|
|
36
|
+
export function readBuffer({ rootDir, io = fs } = {}) {
|
|
37
|
+
try {
|
|
38
|
+
return io.readFileSync(bufferPath(rootDir), 'utf8');
|
|
39
|
+
} catch {
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Read the drain cursor (number of buffer lines already recorded), or 0.
|
|
45
|
+
export function readCursor({ rootDir, io = fs } = {}) {
|
|
46
|
+
try {
|
|
47
|
+
const obj = JSON.parse(io.readFileSync(cursorPath(rootDir), 'utf8'));
|
|
48
|
+
return Number.isInteger(obj && obj.drained) && obj.drained >= 0 ? obj.drained : 0;
|
|
49
|
+
} catch {
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Persist the drain cursor. Best-effort.
|
|
55
|
+
export function writeCursor(drained, { rootDir, io = fs } = {}) {
|
|
56
|
+
try {
|
|
57
|
+
const p = cursorPath(rootDir);
|
|
58
|
+
io.mkdirSync(path.dirname(p), { recursive: true });
|
|
59
|
+
io.writeFileSync(p, JSON.stringify({ drained }) + '\n');
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -4,7 +4,7 @@ import { fetchSession, syncEnabled } from './strict-sync.js';
|
|
|
4
4
|
// BYAN Strict Mode pre-commit gate.
|
|
5
5
|
//
|
|
6
6
|
// The final, platform-agnostic net. Claude Code has in-session hooks; Codex
|
|
7
|
-
//
|
|
7
|
+
// does not. This gate runs at commit time on every platform, so an
|
|
8
8
|
// agent that engaged strict mode but bailed on verification cannot land the
|
|
9
9
|
// commit.
|
|
10
10
|
//
|
|
@@ -10,7 +10,7 @@ import yaml from 'js-yaml';
|
|
|
10
10
|
// that should be built under strict mode.
|
|
11
11
|
//
|
|
12
12
|
// This is the platform-agnostic counterpart to the strict-context-inject
|
|
13
|
-
// hook : Codex
|
|
13
|
+
// hook : Codex has no in-session hook, so it calls
|
|
14
14
|
// byan_strict_suggest to get the same signal.
|
|
15
15
|
|
|
16
16
|
const DEFAULT_CONFIG_REL = path.join('_byan', '_config', 'strict-mode.yaml');
|
|
@@ -108,6 +108,7 @@ export function lockScope({
|
|
|
108
108
|
scopeText,
|
|
109
109
|
acceptanceCriteria = [],
|
|
110
110
|
allowedPaths = [],
|
|
111
|
+
domain = '',
|
|
111
112
|
projectRoot,
|
|
112
113
|
now = new Date(),
|
|
113
114
|
force = false,
|
|
@@ -144,6 +145,10 @@ export function lockScope({
|
|
|
144
145
|
scope_text: scopeText.trim(),
|
|
145
146
|
acceptance_criteria: acceptanceCriteria,
|
|
146
147
|
allowed_paths: allowedPaths,
|
|
148
|
+
// Optional explicit ELO domain. Stored verbatim; validated (and dropped if
|
|
149
|
+
// unknown) only at outcome-emit time so lockScope never guesses. Drives the
|
|
150
|
+
// C3 learning-loop feed on complete (an explicit domain -> one VALIDATED tick).
|
|
151
|
+
domain: typeof domain === 'string' ? domain.trim() : '',
|
|
147
152
|
locked_at: now.toISOString(),
|
|
148
153
|
};
|
|
149
154
|
state.updated_at = now.toISOString();
|
|
@@ -288,6 +293,9 @@ export function complete({ projectRoot, now = new Date() } = {}) {
|
|
|
288
293
|
scope_hash: state.scope_lock.scope_hash,
|
|
289
294
|
pass_count: passCount,
|
|
290
295
|
completed_at: state.completed_at,
|
|
296
|
+
// Surfaced for the C3 learning-loop feed: an explicit ELO domain on the
|
|
297
|
+
// locked scope means a completed session is a VALIDATED outcome.
|
|
298
|
+
domain: state.scope_lock.domain || '',
|
|
291
299
|
};
|
|
292
300
|
}
|
|
293
301
|
|