create-byan-agent 2.9.4 → 2.9.6

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 (98) hide show
  1. package/install/bin/byan-cleanup.js +156 -0
  2. package/install/bin/byan-kanban.js +159 -0
  3. package/install/bin/byan-ledger.js +45 -0
  4. package/install/bin/create-byan-agent-v2.js +15 -1
  5. package/install/lib/cleanup/detector.js +154 -0
  6. package/install/lib/cleanup/executor.js +72 -0
  7. package/install/lib/staging-consent.js +149 -0
  8. package/install/lib/subagent-generator.js +208 -0
  9. package/install/lib/token-ledger.js +131 -0
  10. package/install/templates/.claude/agents/bmad-bmad-master.md +14 -0
  11. package/install/templates/.claude/agents/bmad-bmb-agent-builder.md +14 -0
  12. package/install/templates/.claude/agents/bmad-bmb-module-builder.md +14 -0
  13. package/install/templates/.claude/agents/bmad-bmb-workflow-builder.md +14 -0
  14. package/install/templates/.claude/agents/bmad-bmm-analyst.md +14 -0
  15. package/install/templates/.claude/agents/bmad-bmm-architect.md +14 -0
  16. package/install/templates/.claude/agents/bmad-bmm-dev.md +14 -0
  17. package/install/templates/.claude/agents/bmad-bmm-pm.md +14 -0
  18. package/install/templates/.claude/agents/bmad-bmm-quick-flow-solo-dev.md +14 -0
  19. package/install/templates/.claude/agents/bmad-bmm-quinn.md +14 -0
  20. package/install/templates/.claude/agents/bmad-bmm-sm.md +14 -0
  21. package/install/templates/.claude/agents/bmad-bmm-tech-writer.md +14 -0
  22. package/install/templates/.claude/agents/bmad-bmm-ux-designer.md +14 -0
  23. package/install/templates/.claude/agents/bmad-byan-v2.md +14 -0
  24. package/install/templates/.claude/agents/bmad-byan.md +152 -0
  25. package/install/templates/.claude/agents/bmad-carmack.md +14 -0
  26. package/install/templates/.claude/agents/bmad-cis-brainstorming-coach.md +14 -0
  27. package/install/templates/.claude/agents/bmad-cis-creative-problem-solver.md +14 -0
  28. package/install/templates/.claude/agents/bmad-cis-design-thinking-coach.md +14 -0
  29. package/install/templates/.claude/agents/bmad-cis-innovation-strategist.md +14 -0
  30. package/install/templates/.claude/agents/bmad-cis-presentation-master.md +14 -0
  31. package/install/templates/.claude/agents/bmad-cis-storyteller.md +14 -0
  32. package/install/templates/.claude/agents/bmad-claude.md +26 -0
  33. package/install/templates/.claude/agents/bmad-codex.md +26 -0
  34. package/install/templates/.claude/agents/bmad-compliance.md +68 -0
  35. package/install/templates/.claude/agents/bmad-drawio.md +25 -0
  36. package/install/templates/.claude/agents/bmad-expert-merise-agile.md +54 -0
  37. package/install/templates/.claude/agents/bmad-fact-checker.md +14 -0
  38. package/install/templates/.claude/agents/bmad-forgeron.md +14 -0
  39. package/install/templates/.claude/agents/bmad-hermes.md +59 -0
  40. package/install/templates/.claude/agents/bmad-marc.md +25 -0
  41. package/install/templates/.claude/agents/bmad-patnote.md +26 -0
  42. package/install/templates/.claude/agents/bmad-rachid.md +25 -0
  43. package/install/templates/.claude/agents/bmad-tao.md +14 -0
  44. package/install/templates/.claude/agents/bmad-tea-tea.md +14 -0
  45. package/install/templates/.claude/agents/bmad-yanstaller.md +47 -0
  46. package/install/templates/.claude/hooks/fact-check-absolutes.js +185 -0
  47. package/install/templates/.claude/hooks/fd-phase-guard.js +87 -0
  48. package/install/templates/.claude/hooks/fd-response-check.js +92 -0
  49. package/install/templates/.claude/hooks/lib/failure-detector.js +14 -0
  50. package/install/templates/.claude/hooks/pre-compact-save.js +148 -0
  51. package/install/templates/.claude/hooks/stage-to-byan.js +119 -0
  52. package/install/templates/.claude/hooks/tool-failure-guard.js +6 -0
  53. package/install/templates/.claude/hooks/tool-transparency.js +4 -0
  54. package/install/templates/.claude/settings.json +27 -0
  55. package/install/templates/.claude/skills/byan-byan/SKILL.md +115 -163
  56. package/install/templates/.claude/skills/byan-orchestrate/SKILL.md +100 -0
  57. package/install/templates/.githooks/pre-commit +75 -0
  58. package/install/templates/.github/extensions/byan-staging/extension.mjs +169 -0
  59. package/install/templates/.github/extensions/byan-staging/package.json +8 -0
  60. package/install/templates/_byan/mcp/byan-mcp-server/lib/copilot.js +148 -0
  61. package/install/templates/_byan/mcp/byan-mcp-server/lib/fd-state.js +163 -0
  62. package/install/templates/_byan/mcp/byan-mcp-server/lib/kanban.js +226 -0
  63. package/install/templates/_byan/mcp/byan-mcp-server/lib/peer-review.js +187 -0
  64. package/install/templates/_byan/mcp/byan-mcp-server/server.js +463 -0
  65. package/install/templates/detector.js +154 -0
  66. package/package.json +6 -7
  67. package/src/loadbalancer/capability-matrix.js +157 -0
  68. package/src/loadbalancer/config.js +141 -0
  69. package/src/loadbalancer/graceful-degradation.js +212 -0
  70. package/src/loadbalancer/health-probe.js +151 -0
  71. package/src/loadbalancer/hooks/claude-hooks.js +53 -0
  72. package/src/loadbalancer/hooks/copilot-hooks.js +74 -0
  73. package/src/loadbalancer/index.js +81 -0
  74. package/src/loadbalancer/loadbalancer.default.yaml +65 -0
  75. package/src/loadbalancer/loadbalancer.js +324 -0
  76. package/src/loadbalancer/mcp-server.js +304 -0
  77. package/src/loadbalancer/metrics.js +146 -0
  78. package/src/loadbalancer/native/claude-integration.js +64 -0
  79. package/src/loadbalancer/native/copilot-integration.js +59 -0
  80. package/src/loadbalancer/pressure-score.js +102 -0
  81. package/src/loadbalancer/providers/base-provider.js +80 -0
  82. package/src/loadbalancer/providers/byan-api-provider.js +132 -0
  83. package/src/loadbalancer/providers/claude-provider.js +113 -0
  84. package/src/loadbalancer/providers/copilot-provider.js +104 -0
  85. package/src/loadbalancer/rate-limit-tracker.js +216 -0
  86. package/src/loadbalancer/session-bridge.js +179 -0
  87. package/src/loadbalancer/state/db.js +211 -0
  88. package/src/loadbalancer/state/migrations/001-initial.sql +50 -0
  89. package/src/loadbalancer/tools/index.js +123 -0
  90. package/src/loadbalancer/velocity-estimator.js +147 -0
  91. package/src/staging/staging.js +394 -0
  92. package/update-byan-agent/bin/update-byan-agent.js +27 -2
  93. package/API-BYAN-V2.md +0 -741
  94. package/BMAD-QUICK-REFERENCE.md +0 -370
  95. package/CHANGELOG-v2.1.0.md +0 -371
  96. package/MIGRATION-v2.0-to-v2.1.md +0 -430
  97. package/README-BYAN-V2.md +0 -446
  98. package/TEST-GUIDE-v2.3.2.md +0 -161
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ # BYAN pre-commit hook — enforce mantras score >= 80% on staged agent
3
+ # and skill files. Blocks the commit if any file drops below the
4
+ # threshold so the user can't accidentally push non-compliant artefacts.
5
+ #
6
+ # Install :
7
+ # git config core.hooksPath .githooks
8
+ #
9
+ # Bypass (emergency only) :
10
+ # git commit --no-verify
11
+ #
12
+ # Scope : only files matching
13
+ # _byan/bmb/agents/*.md, _byan/agents/*.md,
14
+ # .github/agents/*.md, .claude/skills/*/SKILL.md,
15
+ # .claude/agents/*.md
16
+ # are validated. Non-agent files are ignored.
17
+
18
+ set -euo pipefail
19
+
20
+ THRESHOLD=80
21
+ VALIDATOR="src/byan-v2/generation/mantra-validator.js"
22
+
23
+ if ! command -v node >/dev/null 2>&1; then
24
+ echo "[byan pre-commit] node not found, skipping mantra check"
25
+ exit 0
26
+ fi
27
+
28
+ if [ ! -f "$VALIDATOR" ]; then
29
+ exit 0
30
+ fi
31
+
32
+ staged=$(git diff --cached --name-only --diff-filter=ACM | grep -E '^(_byan/bmb/agents/.*\.md|_byan/agents/.*\.md|\.github/agents/.*\.md|\.claude/skills/.*SKILL\.md|\.claude/agents/.*\.md)$' || true)
33
+
34
+ if [ -z "$staged" ]; then
35
+ exit 0
36
+ fi
37
+
38
+ failed=0
39
+ while IFS= read -r file; do
40
+ [ -z "$file" ] && continue
41
+ [ ! -f "$file" ] && continue
42
+
43
+ score=$(node -e "
44
+ const V = require('./$VALIDATOR');
45
+ const fs = require('fs');
46
+ try {
47
+ const content = fs.readFileSync('$file', 'utf8');
48
+ const v = new V();
49
+ const res = v.validate(content);
50
+ const pct = Math.round((res.compliant.length / res.totalMantras) * 100);
51
+ process.stdout.write(String(pct));
52
+ } catch (e) {
53
+ process.stderr.write(e.message);
54
+ process.stdout.write('0');
55
+ }
56
+ " 2>/dev/null || echo "0")
57
+
58
+ if [ -z "$score" ] || [ "$score" = "0" ]; then
59
+ continue
60
+ fi
61
+
62
+ if [ "$score" -lt "$THRESHOLD" ]; then
63
+ echo "[byan pre-commit] FAIL $file : mantra score $score% < $THRESHOLD%"
64
+ failed=1
65
+ fi
66
+ done <<< "$staged"
67
+
68
+ if [ "$failed" -eq 1 ]; then
69
+ echo ""
70
+ echo "Commit blocked by BYAN mantra pre-commit hook."
71
+ echo "Fix the flagged files above, or bypass with 'git commit --no-verify' (emergency only)."
72
+ exit 1
73
+ fi
74
+
75
+ exit 0
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Copilot CLI extension — BYAN staging (SM1c).
3
+ *
4
+ * Attaches to the current Copilot session via joinSession() and
5
+ * triggers the BYAN staging pipeline at the end of each assistant
6
+ * turn. Delegates to src/staging/staging.js so Claude Code and Copilot
7
+ * CLI share the exact same extract / filter / dedup / queue / flush
8
+ * logic.
9
+ *
10
+ * How it's discovered : Copilot CLI scans .github/extensions/
11
+ * (project) and the user's copilot config for subdirectories
12
+ * containing extension.mjs. This file is auto-launched as a child
13
+ * process, gets @github/copilot-sdk on its module path, and calls
14
+ * joinSession with the hook registration below.
15
+ */
16
+
17
+ import { joinSession } from '@github/copilot-sdk/extension';
18
+ import { createRequire } from 'node:module';
19
+ import path from 'node:path';
20
+ import fs from 'node:fs';
21
+
22
+ const require = createRequire(import.meta.url);
23
+
24
+ const EXTENSION_DIR = path.dirname(new URL(import.meta.url).pathname);
25
+ const PROJECT_ROOT =
26
+ process.env.BYAN_PROJECT_ROOT ||
27
+ process.env.CLAUDE_PROJECT_DIR ||
28
+ findProjectRoot(EXTENSION_DIR);
29
+
30
+ function findProjectRoot(startDir) {
31
+ // Walk up until we find a package.json or .git — else cwd
32
+ let dir = startDir;
33
+ for (let i = 0; i < 6; i++) {
34
+ if (
35
+ fs.existsSync(path.join(dir, 'package.json')) ||
36
+ fs.existsSync(path.join(dir, '.git'))
37
+ ) {
38
+ return dir;
39
+ }
40
+ const parent = path.dirname(dir);
41
+ if (parent === dir) break;
42
+ dir = parent;
43
+ }
44
+ return process.cwd();
45
+ }
46
+
47
+ function loadStaging() {
48
+ try {
49
+ return require(path.join(PROJECT_ROOT, 'src', 'staging', 'staging.js'));
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function readSettingsEnv() {
56
+ const p = path.join(PROJECT_ROOT, '.claude', 'settings.local.json');
57
+ if (!fs.existsSync(p)) return {};
58
+ try {
59
+ const j = JSON.parse(fs.readFileSync(p, 'utf8'));
60
+ return j.env || {};
61
+ } catch {
62
+ return {};
63
+ }
64
+ }
65
+
66
+ function readMemorySyncConfig() {
67
+ const paths = [
68
+ path.join(PROJECT_ROOT, 'loadbalancer.yaml'),
69
+ path.join(PROJECT_ROOT, '_byan', 'config.yaml'),
70
+ ];
71
+ for (const p of paths) {
72
+ if (!fs.existsSync(p)) continue;
73
+ try {
74
+ const yaml = require('js-yaml');
75
+ const doc = yaml.load(fs.readFileSync(p, 'utf8'));
76
+ if (doc && doc.memory_sync) return doc.memory_sync;
77
+ } catch {
78
+ // fall through
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+
84
+ function buildConfig() {
85
+ const envFile = readSettingsEnv();
86
+ return {
87
+ byan_api_url: envFile.BYAN_API_URL || process.env.BYAN_API_URL || null,
88
+ byan_api_token: envFile.BYAN_API_TOKEN || process.env.BYAN_API_TOKEN || null,
89
+ memory_sync: readMemorySyncConfig() || {},
90
+ };
91
+ }
92
+
93
+ // Per-turn buffer of recent messages/tool calls keyed by sessionId.
94
+ const turnBuffer = new Map();
95
+
96
+ function bufferFor(sessionId) {
97
+ const key = sessionId || 'default';
98
+ if (!turnBuffer.has(key)) {
99
+ turnBuffer.set(key, { userMessages: [], assistantMessages: [], toolCalls: [] });
100
+ }
101
+ return turnBuffer.get(key);
102
+ }
103
+
104
+ function clearBuffer(sessionId) {
105
+ turnBuffer.delete(sessionId || 'default');
106
+ }
107
+
108
+ const session = await joinSession({
109
+ hooks: {
110
+ onSessionStart: async () => {
111
+ await session.log('BYAN staging extension loaded', { ephemeral: true });
112
+ },
113
+
114
+ onUserPromptSubmitted: async (input) => {
115
+ const buf = bufferFor(input.sessionId);
116
+ buf.userMessages.push({ role: 'user', content: input.prompt });
117
+ },
118
+
119
+ onPreToolUse: async (input) => {
120
+ const buf = bufferFor(input.sessionId);
121
+ buf.toolCalls.push({
122
+ name: input.toolName,
123
+ input: input.toolArgs || {},
124
+ });
125
+ },
126
+
127
+ onPostToolUse: async (input) => {
128
+ const buf = bufferFor(input.sessionId);
129
+ // If the last tool call has a text result, treat it as assistant output
130
+ if (input.toolResult && typeof input.toolResult.content === 'string') {
131
+ buf.assistantMessages.push({
132
+ role: 'assistant',
133
+ content: input.toolResult.content,
134
+ });
135
+ }
136
+ },
137
+
138
+ onSessionEnd: async (input) => {
139
+ const staging = loadStaging();
140
+ if (!staging) {
141
+ clearBuffer(input?.sessionId);
142
+ return;
143
+ }
144
+
145
+ const buf = bufferFor(input?.sessionId);
146
+ const turn = {
147
+ sessionId: input?.sessionId || null,
148
+ messages: [...buf.userMessages, ...buf.assistantMessages],
149
+ toolCalls: buf.toolCalls,
150
+ };
151
+
152
+ try {
153
+ await staging.processTurn({
154
+ turn,
155
+ cliSource: 'copilot-cli',
156
+ config: buildConfig(),
157
+ projectRoot: PROJECT_ROOT,
158
+ flushNow: true,
159
+ });
160
+ } catch {
161
+ // never block session end
162
+ }
163
+
164
+ clearBuffer(input?.sessionId);
165
+ },
166
+ },
167
+
168
+ tools: [],
169
+ });
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "@byan/copilot-staging-extension",
3
+ "version": "0.1.0",
4
+ "description": "Copilot CLI extension that stages conversation knowledge to byan_web /api/memory via the shared BYAN staging core.",
5
+ "type": "module",
6
+ "main": "extension.mjs",
7
+ "private": true
8
+ }
@@ -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,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;