create-byan-agent 2.11.3 → 2.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_byan/mcp/byan-mcp-server/LICENSE +21 -0
- package/_byan/mcp/byan-mcp-server/README.md +56 -0
- package/_byan/mcp/byan-mcp-server/lib/cli.js +108 -0
- package/_byan/mcp/byan-mcp-server/lib/copilot.js +148 -0
- package/_byan/mcp/byan-mcp-server/lib/dispatch.js +23 -0
- package/_byan/mcp/byan-mcp-server/lib/fd-state.js +163 -0
- package/_byan/mcp/byan-mcp-server/lib/kanban.js +226 -0
- package/_byan/mcp/byan-mcp-server/lib/peer-review.js +187 -0
- package/_byan/mcp/byan-mcp-server/lib/soul.js +64 -0
- package/_byan/mcp/byan-mcp-server/lib/workflow-scripts.js +156 -0
- package/_byan/mcp/byan-mcp-server/package.json +43 -0
- package/_byan/mcp/byan-mcp-server/server.js +1047 -0
- package/install/bin/create-byan-agent-v2.js +108 -2
- package/package.json +7 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# byan-mcp-server
|
|
2
|
+
|
|
3
|
+
MCP (Model Context Protocol) server that exposes the [byan_web](https://github.com/Les-fous-du-bus/byan_web) REST API as tools usable from Claude Code, Codex, or any MCP-aware client.
|
|
4
|
+
|
|
5
|
+
## What it exposes
|
|
6
|
+
|
|
7
|
+
- `byan_ping`, `byan_list_projects`, `byan_import_project` — project inventory
|
|
8
|
+
- `byan_dispatch` — task routing heuristic
|
|
9
|
+
- `byan_soul_read`, `byan_soul_memory_append` — BYAN soul system
|
|
10
|
+
- `byan_elo_summary` / `byan_elo_context` / `byan_elo_record` — ELO trust calibration
|
|
11
|
+
- `byan_fc_check` / `byan_fc_parse` — fact-check engine
|
|
12
|
+
- `byan_fd_start` / `byan_fd_status` / `byan_fd_advance` / `byan_fd_update` / `byan_fd_abort` — Feature Development (FD) lifecycle
|
|
13
|
+
- `byan_review_request` / `byan_review_verdict` / `byan_review_get` / `byan_review_pending` / `byan_review_pick_reviewer` — peer review
|
|
14
|
+
- `byan_kanban_create` / `byan_kanban_add` / `byan_kanban_move` / `byan_kanban_assign` / `byan_kanban_get` — party-mode kanban
|
|
15
|
+
- `byan_standup_post` / `byan_standup_read` / `byan_standup_blocked` — stand-up feed
|
|
16
|
+
- `byan_copilot_sessions` / `byan_copilot_session_events` / `byan_copilot_search` — Copilot CLI session inspector
|
|
17
|
+
- `byan_workflow_script_list` / `_get` / `_create` / `_update` / `_delete` / `_history` / `_rollback` / `_import` / `_validate` — workflow-node scripts CRUD
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g byan-mcp-server
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Configure
|
|
26
|
+
|
|
27
|
+
Register the server in your MCP client config. Example for Claude Code (`~/.config/claude/mcp.json` or project `.mcp.json`):
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"byan": {
|
|
33
|
+
"command": "byan-mcp",
|
|
34
|
+
"env": {
|
|
35
|
+
"BYAN_API_URL": "https://byan-api.example.com",
|
|
36
|
+
"BYAN_API_TOKEN": "byan_xxx_or_jwt"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Env vars
|
|
44
|
+
|
|
45
|
+
- `BYAN_API_URL` — byan_web API base URL (default `http://localhost:3737`)
|
|
46
|
+
- `BYAN_API_TOKEN` — API key (prefix `byan_` uses `ApiKey` scheme) or JWT (uses `Bearer`)
|
|
47
|
+
- `CLAUDE_PROJECT_DIR` — project root for filesystem-backed tools (soul, FD state, kanban)
|
|
48
|
+
|
|
49
|
+
## Requirements
|
|
50
|
+
|
|
51
|
+
- Node.js >= 18
|
|
52
|
+
- A running byan_web API (for auth-gated tools)
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
MIT
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const CLI_REL_PATH = 'bin/byan-v2-cli.js';
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
6
|
+
|
|
7
|
+
export function parseCliOutput(stdout) {
|
|
8
|
+
const trimmed = stdout.trim();
|
|
9
|
+
if (!trimmed) return { raw: '' };
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(trimmed);
|
|
13
|
+
} catch {
|
|
14
|
+
// fall through
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const firstBracket = trimmed.search(/[\[{]/);
|
|
18
|
+
if (firstBracket >= 0) {
|
|
19
|
+
const candidate = trimmed.slice(firstBracket);
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(candidate);
|
|
22
|
+
} catch {
|
|
23
|
+
// still not valid JSON
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { raw: trimmed };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveProjectRoot(envRoot) {
|
|
31
|
+
return envRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function runByanCli(args, options = {}) {
|
|
35
|
+
const root = resolveProjectRoot(options.projectRoot);
|
|
36
|
+
const script = path.join(root, CLI_REL_PATH);
|
|
37
|
+
const timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
38
|
+
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const child = spawn('node', [script, ...args], {
|
|
41
|
+
cwd: root,
|
|
42
|
+
env: { ...process.env },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
let stdout = '';
|
|
46
|
+
let stderr = '';
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
child.kill('SIGKILL');
|
|
49
|
+
reject(new Error(`byan-v2-cli timed out after ${timeoutMs}ms: ${args.join(' ')}`));
|
|
50
|
+
}, timeoutMs);
|
|
51
|
+
|
|
52
|
+
child.stdout.on('data', (d) => (stdout += d.toString()));
|
|
53
|
+
child.stderr.on('data', (d) => (stderr += d.toString()));
|
|
54
|
+
|
|
55
|
+
child.on('error', (err) => {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
reject(err);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
child.on('close', (code) => {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
if (code !== 0) {
|
|
63
|
+
const err = new Error(
|
|
64
|
+
`byan-v2-cli exited ${code}: ${stderr.trim() || stdout.trim()}`
|
|
65
|
+
);
|
|
66
|
+
err.code = code;
|
|
67
|
+
err.stderr = stderr;
|
|
68
|
+
err.stdout = stdout;
|
|
69
|
+
reject(err);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
resolve(parseCliOutput(stdout));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function eloSummary(opts) {
|
|
78
|
+
return runByanCli(['elo', 'summary'], opts);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function eloContext({ domain, ...opts }) {
|
|
82
|
+
if (!domain) throw new Error('domain is required');
|
|
83
|
+
return runByanCli(['elo', 'context', domain], opts);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function eloDashboard({ domain, ...opts }) {
|
|
87
|
+
return runByanCli(domain ? ['elo', 'dashboard', domain] : ['elo', 'dashboard'], opts);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function eloRecord({ domain, result, reason, ...opts }) {
|
|
91
|
+
if (!domain) throw new Error('domain is required');
|
|
92
|
+
if (!['VALIDATED', 'BLOCKED', 'PARTIAL'].includes(result)) {
|
|
93
|
+
throw new Error(`result must be VALIDATED|BLOCKED|PARTIAL, got: ${result}`);
|
|
94
|
+
}
|
|
95
|
+
const args = ['elo', 'record', domain, result];
|
|
96
|
+
if (reason) args.push(reason);
|
|
97
|
+
return runByanCli(args, opts);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function fcCheck({ text, ...opts }) {
|
|
101
|
+
if (!text || typeof text !== 'string') throw new Error('text is required');
|
|
102
|
+
return runByanCli(['fc', 'check', text], opts);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function fcParse({ text, ...opts }) {
|
|
106
|
+
if (!text || typeof text !== 'string') throw new Error('text is required');
|
|
107
|
+
return runByanCli(['fc', 'parse', text], opts);
|
|
108
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
const COPILOT_ROOT = process.env.BYAN_COPILOT_ROOT || path.join(os.homedir(), '.copilot', 'session-state');
|
|
6
|
+
|
|
7
|
+
function readJsonl(filePath, limit) {
|
|
8
|
+
if (!fs.existsSync(filePath)) return [];
|
|
9
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
|
|
10
|
+
const out = [];
|
|
11
|
+
for (const line of lines) {
|
|
12
|
+
try {
|
|
13
|
+
out.push(JSON.parse(line));
|
|
14
|
+
} catch {
|
|
15
|
+
// skip malformed
|
|
16
|
+
}
|
|
17
|
+
if (typeof limit === 'number' && out.length >= limit) break;
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function summarizeSession(sessionId) {
|
|
23
|
+
const eventsPath = path.join(COPILOT_ROOT, sessionId, 'events.jsonl');
|
|
24
|
+
if (!fs.existsSync(eventsPath)) return null;
|
|
25
|
+
|
|
26
|
+
const events = readJsonl(eventsPath);
|
|
27
|
+
if (events.length === 0) return null;
|
|
28
|
+
|
|
29
|
+
const start = events.find((e) => e.type === 'session.start');
|
|
30
|
+
const shutdown = events.find((e) => e.type === 'session.shutdown');
|
|
31
|
+
const agent = events.find((e) => e.type === 'subagent.selected');
|
|
32
|
+
|
|
33
|
+
const counts = {};
|
|
34
|
+
for (const e of events) {
|
|
35
|
+
counts[e.type] = (counts[e.type] || 0) + 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const userMessages = events.filter((e) => e.type === 'user.message');
|
|
39
|
+
const assistantMessages = events.filter((e) => e.type === 'assistant.message');
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
sessionId,
|
|
43
|
+
startTime: start?.data?.startTime || null,
|
|
44
|
+
endTime: shutdown?.timestamp || null,
|
|
45
|
+
cwd: start?.data?.context?.cwd || null,
|
|
46
|
+
branch: start?.data?.context?.branch || null,
|
|
47
|
+
agent: agent?.data?.agentName || null,
|
|
48
|
+
event_count: events.length,
|
|
49
|
+
user_messages: userMessages.length,
|
|
50
|
+
assistant_messages: assistantMessages.length,
|
|
51
|
+
tool_calls: counts['tool.execution_start'] || 0,
|
|
52
|
+
event_type_counts: counts,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function listSessions({ limit = 20, sinceIso = null, cwdFilter = null } = {}) {
|
|
57
|
+
if (!fs.existsSync(COPILOT_ROOT)) return { root: COPILOT_ROOT, sessions: [], total: 0, exists: false };
|
|
58
|
+
|
|
59
|
+
const dirs = fs
|
|
60
|
+
.readdirSync(COPILOT_ROOT, { withFileTypes: true })
|
|
61
|
+
.filter((d) => d.isDirectory())
|
|
62
|
+
.map((d) => d.name);
|
|
63
|
+
|
|
64
|
+
const summaries = [];
|
|
65
|
+
for (const id of dirs) {
|
|
66
|
+
const s = summarizeSession(id);
|
|
67
|
+
if (!s) continue;
|
|
68
|
+
if (sinceIso && s.startTime && Date.parse(s.startTime) < Date.parse(sinceIso)) continue;
|
|
69
|
+
if (cwdFilter && s.cwd && !s.cwd.includes(cwdFilter)) continue;
|
|
70
|
+
summaries.push(s);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
summaries.sort((a, b) => {
|
|
74
|
+
const at = Date.parse(a.startTime || 0);
|
|
75
|
+
const bt = Date.parse(b.startTime || 0);
|
|
76
|
+
return bt - at;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
root: COPILOT_ROOT,
|
|
81
|
+
total: summaries.length,
|
|
82
|
+
exists: true,
|
|
83
|
+
sessions: summaries.slice(0, limit),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function readSessionEvents({ sessionId, types = null, limit = 200 } = {}) {
|
|
88
|
+
if (!sessionId || typeof sessionId !== 'string') {
|
|
89
|
+
throw new Error('sessionId is required');
|
|
90
|
+
}
|
|
91
|
+
const eventsPath = path.join(COPILOT_ROOT, sessionId, 'events.jsonl');
|
|
92
|
+
if (!fs.existsSync(eventsPath)) {
|
|
93
|
+
throw new Error(`events.jsonl not found for session ${sessionId}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const allEvents = readJsonl(eventsPath);
|
|
97
|
+
const filtered = Array.isArray(types) && types.length > 0
|
|
98
|
+
? allEvents.filter((e) => types.includes(e.type))
|
|
99
|
+
: allEvents;
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
sessionId,
|
|
103
|
+
total: allEvents.length,
|
|
104
|
+
returned: Math.min(filtered.length, limit),
|
|
105
|
+
filtered_by_type: Array.isArray(types) ? types : null,
|
|
106
|
+
events: filtered.slice(0, limit),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function searchSessions({ query, types = ['user.message', 'assistant.message'], limit = 50 } = {}) {
|
|
111
|
+
if (!query || typeof query !== 'string') {
|
|
112
|
+
throw new Error('query is required');
|
|
113
|
+
}
|
|
114
|
+
if (!fs.existsSync(COPILOT_ROOT)) return { matches: [], total: 0 };
|
|
115
|
+
|
|
116
|
+
const q = query.toLowerCase();
|
|
117
|
+
const dirs = fs
|
|
118
|
+
.readdirSync(COPILOT_ROOT, { withFileTypes: true })
|
|
119
|
+
.filter((d) => d.isDirectory())
|
|
120
|
+
.map((d) => d.name);
|
|
121
|
+
|
|
122
|
+
const matches = [];
|
|
123
|
+
for (const sessionId of dirs) {
|
|
124
|
+
const eventsPath = path.join(COPILOT_ROOT, sessionId, 'events.jsonl');
|
|
125
|
+
if (!fs.existsSync(eventsPath)) continue;
|
|
126
|
+
const events = readJsonl(eventsPath);
|
|
127
|
+
for (const e of events) {
|
|
128
|
+
if (!types.includes(e.type)) continue;
|
|
129
|
+
const text = typeof e.data?.content === 'string'
|
|
130
|
+
? e.data.content
|
|
131
|
+
: typeof e.data?.text === 'string'
|
|
132
|
+
? e.data.text
|
|
133
|
+
: JSON.stringify(e.data || {});
|
|
134
|
+
if (text.toLowerCase().includes(q)) {
|
|
135
|
+
matches.push({
|
|
136
|
+
sessionId,
|
|
137
|
+
timestamp: e.timestamp,
|
|
138
|
+
type: e.type,
|
|
139
|
+
excerpt: text.slice(0, 300),
|
|
140
|
+
});
|
|
141
|
+
if (matches.length >= limit) break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (matches.length >= limit) break;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { query, total: matches.length, matches };
|
|
148
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function dispatch({ task, complexity, parallelizable }) {
|
|
2
|
+
const score =
|
|
3
|
+
typeof complexity === 'number'
|
|
4
|
+
? complexity
|
|
5
|
+
: Math.min(100, Math.floor((task?.length || 0) / 10));
|
|
6
|
+
const isPar = parallelizable === true;
|
|
7
|
+
|
|
8
|
+
let route, reasoning;
|
|
9
|
+
if (score < 15) {
|
|
10
|
+
route = 'main-thread';
|
|
11
|
+
reasoning = `Score ${score} < 15. Inline in current context, no delegation overhead.`;
|
|
12
|
+
} else if (score < 40 && isPar) {
|
|
13
|
+
route = 'agent-subagent-worktree';
|
|
14
|
+
reasoning = `Score ${score} + parallelizable. Spawn Claude Code Agent tool with worktree isolation.`;
|
|
15
|
+
} else if (score < 40) {
|
|
16
|
+
route = 'mcp-worker-haiku';
|
|
17
|
+
reasoning = `Score ${score}, sequential. Delegate to lightweight Haiku worker via MCP.`;
|
|
18
|
+
} else {
|
|
19
|
+
route = 'main-thread-opus';
|
|
20
|
+
reasoning = `Score ${score} >= 40. Complex task, keep in main thread with Opus reasoning.`;
|
|
21
|
+
}
|
|
22
|
+
return { score, route, reasoning, parallelizable: isPar };
|
|
23
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const PHASES = ['BRAINSTORM', 'PRUNE', 'DISPATCH', 'BUILD', 'VALIDATE', 'COMPLETED', 'ABORTED'];
|
|
5
|
+
|
|
6
|
+
function resolveRoot(projectRoot) {
|
|
7
|
+
return projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function statePath(projectRoot) {
|
|
11
|
+
return path.join(resolveRoot(projectRoot), '_byan-output', 'fd-state.json');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ensureDir(filePath) {
|
|
15
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readState(projectRoot) {
|
|
19
|
+
const p = statePath(projectRoot);
|
|
20
|
+
if (!fs.existsSync(p)) return null;
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeState(state, projectRoot) {
|
|
29
|
+
const p = statePath(projectRoot);
|
|
30
|
+
ensureDir(p);
|
|
31
|
+
fs.writeFileSync(p, JSON.stringify(state, null, 2));
|
|
32
|
+
return p;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function slugify(s) {
|
|
36
|
+
return String(s || 'feature')
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
39
|
+
.replace(/(^-|-$)/g, '')
|
|
40
|
+
.slice(0, 40);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stampId(now = new Date(), slug) {
|
|
44
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
45
|
+
const s =
|
|
46
|
+
now.getFullYear().toString() +
|
|
47
|
+
pad(now.getMonth() + 1) +
|
|
48
|
+
pad(now.getDate()) +
|
|
49
|
+
'-' +
|
|
50
|
+
pad(now.getHours()) +
|
|
51
|
+
pad(now.getMinutes()) +
|
|
52
|
+
pad(now.getSeconds());
|
|
53
|
+
return `${s}-${slugify(slug)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function start({ featureName, projectRoot, now = new Date(), force = false } = {}) {
|
|
57
|
+
const existing = readState(projectRoot);
|
|
58
|
+
if (existing && !['COMPLETED', 'ABORTED'].includes(existing.phase) && !force) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`FD already in progress (phase ${existing.phase}, fd_id ${existing.fd_id}). Abort or complete it first, or pass force=true.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const state = {
|
|
65
|
+
fd_id: stampId(now, featureName),
|
|
66
|
+
feature_name: featureName || 'unnamed',
|
|
67
|
+
phase: 'BRAINSTORM',
|
|
68
|
+
started_at: now.toISOString(),
|
|
69
|
+
updated_at: now.toISOString(),
|
|
70
|
+
phase_history: [{ phase: 'BRAINSTORM', entered_at: now.toISOString() }],
|
|
71
|
+
raw_ideas: [],
|
|
72
|
+
backlog: [],
|
|
73
|
+
dispatch_table: [],
|
|
74
|
+
commits: [],
|
|
75
|
+
notes: [],
|
|
76
|
+
};
|
|
77
|
+
writeState(state, projectRoot);
|
|
78
|
+
return state;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function status({ projectRoot } = {}) {
|
|
82
|
+
const state = readState(projectRoot);
|
|
83
|
+
if (!state) {
|
|
84
|
+
return { active: false, phase: null, fd_id: null };
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
active: !['COMPLETED', 'ABORTED'].includes(state.phase),
|
|
88
|
+
...state,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const BRAINSTORM_MIN_IDEAS = 10;
|
|
93
|
+
|
|
94
|
+
export function advance({ to, note, projectRoot, now = new Date(), force = false } = {}) {
|
|
95
|
+
if (!PHASES.includes(to)) {
|
|
96
|
+
throw new Error(`Invalid target phase ${to}. Must be one of ${PHASES.join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
const state = readState(projectRoot);
|
|
99
|
+
if (!state) throw new Error('No active FD session. Call start() first.');
|
|
100
|
+
if (['COMPLETED', 'ABORTED'].includes(state.phase)) {
|
|
101
|
+
throw new Error(`Current FD session is ${state.phase} and cannot advance.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const order = PHASES.indexOf(state.phase);
|
|
105
|
+
const target = PHASES.indexOf(to);
|
|
106
|
+
if (target < order && !['ABORTED', 'COMPLETED'].includes(to)) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Cannot move backwards from ${state.phase} to ${to}. Use abort() or fix the workflow.`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// BRAINSTORM exit gate : need >= BRAINSTORM_MIN_IDEAS raw ideas
|
|
113
|
+
if (
|
|
114
|
+
state.phase === 'BRAINSTORM' &&
|
|
115
|
+
to !== 'BRAINSTORM' &&
|
|
116
|
+
!['ABORTED'].includes(to) &&
|
|
117
|
+
!force
|
|
118
|
+
) {
|
|
119
|
+
const n = Array.isArray(state.raw_ideas) ? state.raw_ideas.length : 0;
|
|
120
|
+
if (n < BRAINSTORM_MIN_IDEAS) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`BRAINSTORM requires at least ${BRAINSTORM_MIN_IDEAS} raw ideas before advancing (currently ${n}). Add more via update({ patch: { raw_ideas: [...] } }), or pass force=true to skip.`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
state.phase = to;
|
|
128
|
+
state.updated_at = now.toISOString();
|
|
129
|
+
state.phase_history.push({ phase: to, entered_at: now.toISOString(), note: note || null });
|
|
130
|
+
|
|
131
|
+
writeState(state, projectRoot);
|
|
132
|
+
return state;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function update({ patch = {}, projectRoot, now = new Date() } = {}) {
|
|
136
|
+
const state = readState(projectRoot);
|
|
137
|
+
if (!state) throw new Error('No active FD session.');
|
|
138
|
+
|
|
139
|
+
const allowed = ['raw_ideas', 'backlog', 'dispatch_table', 'commits', 'notes', 'feature_name'];
|
|
140
|
+
for (const key of Object.keys(patch)) {
|
|
141
|
+
if (!allowed.includes(key)) {
|
|
142
|
+
throw new Error(`Field "${key}" is not patchable. Allowed: ${allowed.join(', ')}`);
|
|
143
|
+
}
|
|
144
|
+
state[key] = patch[key];
|
|
145
|
+
}
|
|
146
|
+
state.updated_at = now.toISOString();
|
|
147
|
+
|
|
148
|
+
writeState(state, projectRoot);
|
|
149
|
+
return state;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function abort({ reason, projectRoot, now = new Date() } = {}) {
|
|
153
|
+
const state = readState(projectRoot);
|
|
154
|
+
if (!state) throw new Error('No FD session to abort.');
|
|
155
|
+
state.phase = 'ABORTED';
|
|
156
|
+
state.updated_at = now.toISOString();
|
|
157
|
+
state.phase_history.push({ phase: 'ABORTED', entered_at: now.toISOString(), note: reason || null });
|
|
158
|
+
writeState(state, projectRoot);
|
|
159
|
+
return state;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const ALL_PHASES = PHASES;
|
|
163
|
+
export const BRAINSTORM_MIN = BRAINSTORM_MIN_IDEAS;
|