create-byan-agent 2.9.4 → 2.9.5

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 (92) 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/lib/cleanup/detector.js +154 -0
  5. package/install/lib/cleanup/executor.js +72 -0
  6. package/install/lib/subagent-generator.js +208 -0
  7. package/install/lib/token-ledger.js +131 -0
  8. package/install/templates/.claude/agents/bmad-bmad-master.md +14 -0
  9. package/install/templates/.claude/agents/bmad-bmb-agent-builder.md +14 -0
  10. package/install/templates/.claude/agents/bmad-bmb-module-builder.md +14 -0
  11. package/install/templates/.claude/agents/bmad-bmb-workflow-builder.md +14 -0
  12. package/install/templates/.claude/agents/bmad-bmm-analyst.md +14 -0
  13. package/install/templates/.claude/agents/bmad-bmm-architect.md +14 -0
  14. package/install/templates/.claude/agents/bmad-bmm-dev.md +14 -0
  15. package/install/templates/.claude/agents/bmad-bmm-pm.md +14 -0
  16. package/install/templates/.claude/agents/bmad-bmm-quick-flow-solo-dev.md +14 -0
  17. package/install/templates/.claude/agents/bmad-bmm-quinn.md +14 -0
  18. package/install/templates/.claude/agents/bmad-bmm-sm.md +14 -0
  19. package/install/templates/.claude/agents/bmad-bmm-tech-writer.md +14 -0
  20. package/install/templates/.claude/agents/bmad-bmm-ux-designer.md +14 -0
  21. package/install/templates/.claude/agents/bmad-byan-v2.md +14 -0
  22. package/install/templates/.claude/agents/bmad-byan.md +152 -0
  23. package/install/templates/.claude/agents/bmad-carmack.md +14 -0
  24. package/install/templates/.claude/agents/bmad-cis-brainstorming-coach.md +14 -0
  25. package/install/templates/.claude/agents/bmad-cis-creative-problem-solver.md +14 -0
  26. package/install/templates/.claude/agents/bmad-cis-design-thinking-coach.md +14 -0
  27. package/install/templates/.claude/agents/bmad-cis-innovation-strategist.md +14 -0
  28. package/install/templates/.claude/agents/bmad-cis-presentation-master.md +14 -0
  29. package/install/templates/.claude/agents/bmad-cis-storyteller.md +14 -0
  30. package/install/templates/.claude/agents/bmad-claude.md +26 -0
  31. package/install/templates/.claude/agents/bmad-codex.md +26 -0
  32. package/install/templates/.claude/agents/bmad-compliance.md +68 -0
  33. package/install/templates/.claude/agents/bmad-drawio.md +25 -0
  34. package/install/templates/.claude/agents/bmad-expert-merise-agile.md +54 -0
  35. package/install/templates/.claude/agents/bmad-fact-checker.md +14 -0
  36. package/install/templates/.claude/agents/bmad-forgeron.md +14 -0
  37. package/install/templates/.claude/agents/bmad-hermes.md +59 -0
  38. package/install/templates/.claude/agents/bmad-marc.md +25 -0
  39. package/install/templates/.claude/agents/bmad-patnote.md +26 -0
  40. package/install/templates/.claude/agents/bmad-rachid.md +25 -0
  41. package/install/templates/.claude/agents/bmad-tao.md +14 -0
  42. package/install/templates/.claude/agents/bmad-tea-tea.md +14 -0
  43. package/install/templates/.claude/agents/bmad-yanstaller.md +47 -0
  44. package/install/templates/.claude/hooks/fact-check-absolutes.js +185 -0
  45. package/install/templates/.claude/hooks/fd-phase-guard.js +87 -0
  46. package/install/templates/.claude/hooks/fd-response-check.js +92 -0
  47. package/install/templates/.claude/hooks/lib/failure-detector.js +14 -0
  48. package/install/templates/.claude/hooks/pre-compact-save.js +148 -0
  49. package/install/templates/.claude/hooks/tool-failure-guard.js +6 -0
  50. package/install/templates/.claude/hooks/tool-transparency.js +4 -0
  51. package/install/templates/.claude/settings.json +23 -0
  52. package/install/templates/.claude/skills/byan-byan/SKILL.md +115 -163
  53. package/install/templates/.claude/skills/byan-orchestrate/SKILL.md +100 -0
  54. package/install/templates/.githooks/pre-commit +75 -0
  55. package/install/templates/_byan/mcp/byan-mcp-server/lib/copilot.js +148 -0
  56. package/install/templates/_byan/mcp/byan-mcp-server/lib/fd-state.js +163 -0
  57. package/install/templates/_byan/mcp/byan-mcp-server/lib/kanban.js +226 -0
  58. package/install/templates/_byan/mcp/byan-mcp-server/lib/peer-review.js +187 -0
  59. package/install/templates/_byan/mcp/byan-mcp-server/server.js +463 -0
  60. package/install/templates/detector.js +154 -0
  61. package/package.json +6 -7
  62. package/src/loadbalancer/capability-matrix.js +157 -0
  63. package/src/loadbalancer/config.js +141 -0
  64. package/src/loadbalancer/graceful-degradation.js +212 -0
  65. package/src/loadbalancer/health-probe.js +151 -0
  66. package/src/loadbalancer/hooks/claude-hooks.js +53 -0
  67. package/src/loadbalancer/hooks/copilot-hooks.js +74 -0
  68. package/src/loadbalancer/index.js +81 -0
  69. package/src/loadbalancer/loadbalancer.default.yaml +65 -0
  70. package/src/loadbalancer/loadbalancer.js +324 -0
  71. package/src/loadbalancer/mcp-server.js +304 -0
  72. package/src/loadbalancer/metrics.js +146 -0
  73. package/src/loadbalancer/native/claude-integration.js +64 -0
  74. package/src/loadbalancer/native/copilot-integration.js +59 -0
  75. package/src/loadbalancer/pressure-score.js +102 -0
  76. package/src/loadbalancer/providers/base-provider.js +80 -0
  77. package/src/loadbalancer/providers/byan-api-provider.js +132 -0
  78. package/src/loadbalancer/providers/claude-provider.js +113 -0
  79. package/src/loadbalancer/providers/copilot-provider.js +104 -0
  80. package/src/loadbalancer/rate-limit-tracker.js +216 -0
  81. package/src/loadbalancer/session-bridge.js +179 -0
  82. package/src/loadbalancer/state/db.js +211 -0
  83. package/src/loadbalancer/state/migrations/001-initial.sql +50 -0
  84. package/src/loadbalancer/tools/index.js +123 -0
  85. package/src/loadbalancer/velocity-estimator.js +147 -0
  86. package/update-byan-agent/bin/update-byan-agent.js +27 -2
  87. package/API-BYAN-V2.md +0 -741
  88. package/BMAD-QUICK-REFERENCE.md +0 -370
  89. package/CHANGELOG-v2.1.0.md +0 -371
  90. package/MIGRATION-v2.0-to-v2.1.md +0 -430
  91. package/README-BYAN-V2.md +0 -446
  92. package/TEST-GUIDE-v2.3.2.md +0 -161
@@ -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;
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Kanban + stand-up registry for BYAN party-mode sessions.
3
+ *
4
+ * Kanban : _byan-output/party-mode-sessions/<session_id>/kanban.json
5
+ * columns : todo | doing | blocked | review | done
6
+ * cards : { id, title, assignee, priority, created_at, moved_at,
7
+ * column, blocker_reason? }
8
+ *
9
+ * Stand-up : _byan-output/party-mode-sessions/<session_id>/standup.jsonl
10
+ * entries : { agent, timestamp, did, blockers, next }
11
+ *
12
+ * Hermes watches stand-ups : an agent with 2+ consecutive "blocked"
13
+ * reports in the stand-up stream is flagged and their card is moved to
14
+ * `blocked` column in the kanban.
15
+ */
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+
20
+ const COLUMNS = ['todo', 'doing', 'blocked', 'review', 'done'];
21
+
22
+ function resolveRoot(projectRoot) {
23
+ return projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
24
+ }
25
+
26
+ function sessionDir(projectRoot, sessionId) {
27
+ return path.join(
28
+ resolveRoot(projectRoot),
29
+ '_byan-output',
30
+ 'party-mode-sessions',
31
+ sanitize(sessionId)
32
+ );
33
+ }
34
+
35
+ function sanitize(id) {
36
+ return String(id || 'default').replace(/[^a-zA-Z0-9._-]/g, '-').slice(0, 80);
37
+ }
38
+
39
+ function kanbanPath(projectRoot, sessionId) {
40
+ return path.join(sessionDir(projectRoot, sessionId), 'kanban.json');
41
+ }
42
+
43
+ function standupPath(projectRoot, sessionId) {
44
+ return path.join(sessionDir(projectRoot, sessionId), 'standup.jsonl');
45
+ }
46
+
47
+ function readKanban(projectRoot, sessionId) {
48
+ const p = kanbanPath(projectRoot, sessionId);
49
+ if (!fs.existsSync(p)) return null;
50
+ try {
51
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function writeKanban(projectRoot, sessionId, board) {
58
+ const p = kanbanPath(projectRoot, sessionId);
59
+ fs.mkdirSync(path.dirname(p), { recursive: true });
60
+ fs.writeFileSync(p, JSON.stringify(board, null, 2));
61
+ }
62
+
63
+ function emptyBoard(sessionId, now) {
64
+ return {
65
+ session_id: sessionId,
66
+ created_at: now.toISOString(),
67
+ updated_at: now.toISOString(),
68
+ columns: COLUMNS.slice(),
69
+ cards: {},
70
+ };
71
+ }
72
+
73
+ export function createBoard({ sessionId, projectRoot, now = new Date() } = {}) {
74
+ if (!sessionId) throw new Error('sessionId is required');
75
+ const existing = readKanban(projectRoot, sessionId);
76
+ if (existing) return existing;
77
+ const board = emptyBoard(sessionId, now);
78
+ writeKanban(projectRoot, sessionId, board);
79
+ return board;
80
+ }
81
+
82
+ export function addCard({
83
+ sessionId,
84
+ card,
85
+ projectRoot,
86
+ now = new Date(),
87
+ } = {}) {
88
+ if (!sessionId) throw new Error('sessionId is required');
89
+ if (!card || !card.id || !card.title) throw new Error('card.id and card.title required');
90
+
91
+ const board = readKanban(projectRoot, sessionId) || emptyBoard(sessionId, now);
92
+ if (board.cards[card.id]) throw new Error(`card ${card.id} already exists`);
93
+
94
+ board.cards[card.id] = {
95
+ id: card.id,
96
+ title: card.title,
97
+ assignee: card.assignee || null,
98
+ priority: card.priority || 'P2',
99
+ column: card.column || 'todo',
100
+ created_at: now.toISOString(),
101
+ moved_at: now.toISOString(),
102
+ blocker_reason: null,
103
+ };
104
+ board.updated_at = now.toISOString();
105
+
106
+ writeKanban(projectRoot, sessionId, board);
107
+ return board.cards[card.id];
108
+ }
109
+
110
+ export function moveCard({
111
+ sessionId,
112
+ cardId,
113
+ toColumn,
114
+ blocker_reason,
115
+ projectRoot,
116
+ now = new Date(),
117
+ } = {}) {
118
+ if (!COLUMNS.includes(toColumn)) {
119
+ throw new Error(`toColumn must be one of ${COLUMNS.join(', ')}, got ${toColumn}`);
120
+ }
121
+ const board = readKanban(projectRoot, sessionId);
122
+ if (!board) throw new Error(`no kanban for session ${sessionId}`);
123
+ if (!board.cards[cardId]) throw new Error(`card ${cardId} not found`);
124
+
125
+ const card = board.cards[cardId];
126
+ card.column = toColumn;
127
+ card.moved_at = now.toISOString();
128
+ card.blocker_reason = toColumn === 'blocked' ? blocker_reason || 'unspecified' : null;
129
+ board.updated_at = now.toISOString();
130
+
131
+ writeKanban(projectRoot, sessionId, board);
132
+ return card;
133
+ }
134
+
135
+ export function assignCard({
136
+ sessionId,
137
+ cardId,
138
+ assignee,
139
+ projectRoot,
140
+ now = new Date(),
141
+ } = {}) {
142
+ if (!assignee) throw new Error('assignee is required');
143
+ const board = readKanban(projectRoot, sessionId);
144
+ if (!board || !board.cards[cardId]) throw new Error(`card ${cardId} not found`);
145
+ board.cards[cardId].assignee = assignee;
146
+ board.cards[cardId].moved_at = now.toISOString();
147
+ board.updated_at = now.toISOString();
148
+ writeKanban(projectRoot, sessionId, board);
149
+ return board.cards[cardId];
150
+ }
151
+
152
+ export function getBoard({ sessionId, projectRoot } = {}) {
153
+ if (!sessionId) throw new Error('sessionId is required');
154
+ return readKanban(projectRoot, sessionId);
155
+ }
156
+
157
+ export function postStandup({
158
+ sessionId,
159
+ agent,
160
+ did,
161
+ blockers = [],
162
+ next,
163
+ projectRoot,
164
+ now = new Date(),
165
+ } = {}) {
166
+ if (!sessionId) throw new Error('sessionId is required');
167
+ if (!agent) throw new Error('agent is required');
168
+
169
+ const entry = {
170
+ agent,
171
+ timestamp: now.toISOString(),
172
+ did: did || '',
173
+ blockers: Array.isArray(blockers) ? blockers : [],
174
+ next: next || '',
175
+ };
176
+
177
+ const p = standupPath(projectRoot, sessionId);
178
+ fs.mkdirSync(path.dirname(p), { recursive: true });
179
+ fs.appendFileSync(p, JSON.stringify(entry) + '\n');
180
+ return entry;
181
+ }
182
+
183
+ export function readStandups({ sessionId, projectRoot, limit = 50 } = {}) {
184
+ const p = standupPath(projectRoot, sessionId);
185
+ if (!fs.existsSync(p)) return [];
186
+ const lines = fs.readFileSync(p, 'utf8').split('\n').filter(Boolean);
187
+ const out = [];
188
+ for (const line of lines) {
189
+ try {
190
+ out.push(JSON.parse(line));
191
+ } catch {
192
+ // skip malformed
193
+ }
194
+ }
195
+ return out.slice(-limit);
196
+ }
197
+
198
+ /**
199
+ * Detect agents with >= minStreak consecutive blocked stand-ups.
200
+ * Returns array of { agent, streak, lastAt }.
201
+ */
202
+ export function detectBlockedStreaks({ sessionId, minStreak = 2, projectRoot } = {}) {
203
+ const standups = readStandups({ sessionId, projectRoot, limit: 500 });
204
+ const streaks = {};
205
+ const agentLast = {};
206
+
207
+ for (const entry of standups) {
208
+ const isBlocked = Array.isArray(entry.blockers) && entry.blockers.length > 0;
209
+ if (isBlocked) {
210
+ streaks[entry.agent] = (streaks[entry.agent] || 0) + 1;
211
+ } else {
212
+ streaks[entry.agent] = 0;
213
+ }
214
+ agentLast[entry.agent] = entry.timestamp;
215
+ }
216
+
217
+ const flagged = [];
218
+ for (const [agent, n] of Object.entries(streaks)) {
219
+ if (n >= minStreak) {
220
+ flagged.push({ agent, streak: n, lastAt: agentLast[agent] });
221
+ }
222
+ }
223
+ return flagged;
224
+ }
225
+
226
+ export const KANBAN_COLUMNS = COLUMNS;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Peer review registry for BYAN agents working in party-mode.
3
+ *
4
+ * Contract :
5
+ * - An agent producing an artefact (commit, file change, spec) opens a
6
+ * review request via requestReview(). The request is persisted at
7
+ * _byan-output/reviews/<task_id>.json.
8
+ * - Another agent (must be ≠ author) issues a verdict via
9
+ * recordVerdict() with { verdict: approve | changes | block, comments,
10
+ * must_fix }.
11
+ * - listPending() returns all unresolved requests. pickReviewer()
12
+ * returns an alternative agent from the roster distinct from the
13
+ * author.
14
+ *
15
+ * Enforces the "reviewer must differ from author" invariant inside
16
+ * recordVerdict() and throws if violated.
17
+ */
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+
22
+ const DEFAULT_ROSTER = [
23
+ 'bmad-bmm-architect',
24
+ 'bmad-bmm-dev',
25
+ 'bmad-bmm-quinn',
26
+ 'bmad-bmm-pm',
27
+ 'bmad-bmm-sm',
28
+ 'bmad-bmm-analyst',
29
+ 'bmad-bmm-ux-designer',
30
+ 'bmad-bmm-tech-writer',
31
+ 'bmad-tea-tea',
32
+ 'bmad-compliance',
33
+ ];
34
+
35
+ function resolveRoot(projectRoot) {
36
+ return projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
37
+ }
38
+
39
+ function reviewsDir(projectRoot) {
40
+ return path.join(resolveRoot(projectRoot), '_byan-output', 'reviews');
41
+ }
42
+
43
+ function reviewPath(projectRoot, taskId) {
44
+ return path.join(reviewsDir(projectRoot), `${sanitizeId(taskId)}.json`);
45
+ }
46
+
47
+ function sanitizeId(id) {
48
+ return String(id).replace(/[^a-zA-Z0-9._-]/g, '-').slice(0, 80);
49
+ }
50
+
51
+ function readReview(projectRoot, taskId) {
52
+ const p = reviewPath(projectRoot, taskId);
53
+ if (!fs.existsSync(p)) return null;
54
+ try {
55
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ function writeReview(projectRoot, review) {
62
+ fs.mkdirSync(reviewsDir(projectRoot), { recursive: true });
63
+ fs.writeFileSync(reviewPath(projectRoot, review.task_id), JSON.stringify(review, null, 2));
64
+ }
65
+
66
+ export function requestReview({
67
+ task_id,
68
+ author,
69
+ artifact_paths = [],
70
+ description = '',
71
+ projectRoot,
72
+ now = new Date(),
73
+ } = {}) {
74
+ if (!task_id) throw new Error('task_id is required');
75
+ if (!author) throw new Error('author (agent name) is required');
76
+
77
+ const existing = readReview(projectRoot, task_id);
78
+ if (existing && existing.status === 'pending') {
79
+ throw new Error(`review for task ${task_id} already pending (author ${existing.author})`);
80
+ }
81
+
82
+ const review = {
83
+ task_id,
84
+ author,
85
+ artifact_paths: Array.isArray(artifact_paths) ? artifact_paths : [],
86
+ description: String(description || ''),
87
+ status: 'pending',
88
+ verdicts: [],
89
+ created_at: now.toISOString(),
90
+ updated_at: now.toISOString(),
91
+ };
92
+
93
+ writeReview(projectRoot, review);
94
+ return review;
95
+ }
96
+
97
+ export function recordVerdict({
98
+ task_id,
99
+ reviewer,
100
+ verdict,
101
+ comments = [],
102
+ must_fix = [],
103
+ projectRoot,
104
+ now = new Date(),
105
+ } = {}) {
106
+ if (!task_id) throw new Error('task_id is required');
107
+ if (!reviewer) throw new Error('reviewer (agent name) is required');
108
+ if (!['approve', 'changes', 'block'].includes(verdict)) {
109
+ throw new Error(`verdict must be approve | changes | block, got ${verdict}`);
110
+ }
111
+
112
+ const review = readReview(projectRoot, task_id);
113
+ if (!review) throw new Error(`no review found for task ${task_id} — call requestReview first`);
114
+
115
+ if (review.author === reviewer) {
116
+ throw new Error(
117
+ `reviewer (${reviewer}) cannot be the same as author (${review.author}). Pick a different agent.`
118
+ );
119
+ }
120
+
121
+ review.verdicts.push({
122
+ reviewer,
123
+ verdict,
124
+ comments: Array.isArray(comments) ? comments : [],
125
+ must_fix: Array.isArray(must_fix) ? must_fix : [],
126
+ at: now.toISOString(),
127
+ });
128
+
129
+ if (verdict === 'approve') review.status = 'approved';
130
+ else if (verdict === 'block') review.status = 'blocked';
131
+ else review.status = 'changes_requested';
132
+
133
+ review.updated_at = now.toISOString();
134
+ writeReview(projectRoot, review);
135
+ return review;
136
+ }
137
+
138
+ export function getReview({ task_id, projectRoot } = {}) {
139
+ if (!task_id) throw new Error('task_id is required');
140
+ return readReview(projectRoot, task_id);
141
+ }
142
+
143
+ export function listPending({ projectRoot } = {}) {
144
+ const dir = reviewsDir(projectRoot);
145
+ if (!fs.existsSync(dir)) return [];
146
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
147
+ const out = [];
148
+ for (const f of files) {
149
+ try {
150
+ const r = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
151
+ if (r.status === 'pending' || r.status === 'changes_requested') out.push(r);
152
+ } catch {
153
+ // skip malformed
154
+ }
155
+ }
156
+ out.sort((a, b) => (a.created_at > b.created_at ? -1 : 1));
157
+ return out;
158
+ }
159
+
160
+ export function pickReviewer({ author, preferredDomain, roster = DEFAULT_ROSTER } = {}) {
161
+ const domainPairs = {
162
+ dev: ['bmad-bmm-quinn', 'bmad-tea-tea'],
163
+ 'bmm-dev': ['bmad-bmm-quinn', 'bmad-tea-tea'],
164
+ 'bmad-bmm-dev': ['bmad-bmm-quinn', 'bmad-tea-tea'],
165
+ architect: ['bmad-tea-tea', 'bmad-bmm-quinn'],
166
+ 'bmad-bmm-architect': ['bmad-tea-tea', 'bmad-bmm-quinn'],
167
+ pm: ['bmad-bmm-sm', 'bmad-bmm-analyst'],
168
+ 'bmad-bmm-pm': ['bmad-bmm-sm', 'bmad-bmm-analyst'],
169
+ 'ux-designer': ['bmad-bmm-pm', 'bmad-bmm-analyst'],
170
+ 'bmad-bmm-ux-designer': ['bmad-bmm-pm', 'bmad-bmm-analyst'],
171
+ };
172
+
173
+ const keys = [preferredDomain, author].filter(Boolean);
174
+ for (const k of keys) {
175
+ const candidates = domainPairs[k] || [];
176
+ for (const c of candidates) {
177
+ if (c !== author) return c;
178
+ }
179
+ }
180
+
181
+ for (const r of roster) {
182
+ if (r !== author) return r;
183
+ }
184
+ return null;
185
+ }
186
+
187
+ export const DEFAULT_AGENT_ROSTER = DEFAULT_ROSTER;