@rayburst/cc 3.1.4 → 3.1.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/hooks/hooks.json +20 -0
- package/hooks/posttooluse.mjs +71 -0
- package/hooks/pretooluse.mjs +57 -40
- package/hooks/sessionstart.mjs +176 -29
- package/hooks/userpromptsubmit.mjs +320 -32
- package/package.json +4 -2
- package/server.bundle.mjs +1 -1
- package/hooks/product-context-block.mjs +0 -131
- package/hooks/rb-cache.mjs +0 -247
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* XML block builders for Rayburst hook context injection.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Build the main Product Context Block injected at session start.
|
|
7
|
-
* Contains rules, feature atlas summary, and board summary.
|
|
8
|
-
*/
|
|
9
|
-
export function buildProductContextBlock(features, cards) {
|
|
10
|
-
const featureList = Array.isArray(features) ? features : features?.data ?? [];
|
|
11
|
-
const cardList = Array.isArray(cards) ? cards : cards?.data ?? [];
|
|
12
|
-
|
|
13
|
-
// Count features by status
|
|
14
|
-
const counts = { draft: 0, active: 0, completed: 0, archived: 0 };
|
|
15
|
-
for (const f of featureList) {
|
|
16
|
-
counts[f.status || "draft"] = (counts[f.status || "draft"] || 0) + 1;
|
|
17
|
-
}
|
|
18
|
-
const total = featureList.length;
|
|
19
|
-
|
|
20
|
-
// Count cards by status
|
|
21
|
-
const cardCounts = {};
|
|
22
|
-
for (const c of cardList) {
|
|
23
|
-
cardCounts[c.status || "draft"] = (cardCounts[c.status || "draft"] || 0) + 1;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Build feature lines (compact: one line each)
|
|
27
|
-
const featureLines = featureList
|
|
28
|
-
.slice(0, 60)
|
|
29
|
-
.map((f) => {
|
|
30
|
-
const critCount = f.criteriaCount ?? f.criteria?.length ?? "?";
|
|
31
|
-
return ` <feature id="${f.id}" status="${f.status}" criteria="${critCount}">${escapeXml(f.title)}</feature>`;
|
|
32
|
-
})
|
|
33
|
-
.join("\n");
|
|
34
|
-
|
|
35
|
-
const truncated =
|
|
36
|
-
featureList.length > 60
|
|
37
|
-
? `\n <truncated shown="60" total="${featureList.length}"/>`
|
|
38
|
-
: "";
|
|
39
|
-
|
|
40
|
-
// Build card lines
|
|
41
|
-
const activeCards = cardList.filter(
|
|
42
|
-
(c) => c.status === "in-progress" || c.status === "validation" || c.status === "ready"
|
|
43
|
-
);
|
|
44
|
-
const cardLines = activeCards
|
|
45
|
-
.slice(0, 20)
|
|
46
|
-
.map((c) => {
|
|
47
|
-
const featureIds = (c.features || []).map((f) => f.id || f).join(",");
|
|
48
|
-
return ` <card id="${c.id}" status="${c.status}" features="${featureIds}">${escapeXml(c.title)}</card>`;
|
|
49
|
-
})
|
|
50
|
-
.join("\n");
|
|
51
|
-
|
|
52
|
-
return `<rayburst_product_context>
|
|
53
|
-
<rules>
|
|
54
|
-
<rule>You have access to a Rayburst feature atlas — a knowledge graph of product features with Gherkin acceptance criteria. Use it to understand what you're building.</rule>
|
|
55
|
-
<rule>Before implementing any task, check if a matching feature exists in the atlas below. If found, read its criteria with rb_get_feature and work against them.</rule>
|
|
56
|
-
<rule>If your changes may affect behaviors described in OTHER features' criteria, flag this to the user before proceeding.</rule>
|
|
57
|
-
<rule>NEVER create, update, or delete features or criteria in the atlas without explicitly asking the user for confirmation first. Show them what you want to change and wait for approval.</rule>
|
|
58
|
-
<rule>When implementation is complete, summarize which acceptance criteria were addressed and which remain pending.</rule>
|
|
59
|
-
<rule>Use mcp__plugin_rayburst_rayburst__rb_* MCP tools to interact with the atlas. Use rb_get_feature to load full criteria for a specific feature.</rule>
|
|
60
|
-
</rules>
|
|
61
|
-
|
|
62
|
-
<atlas summary="${total} features (${counts.draft} draft, ${counts.active} active, ${counts.completed} completed)">
|
|
63
|
-
${featureLines}${truncated}
|
|
64
|
-
</atlas>
|
|
65
|
-
|
|
66
|
-
<board summary="${cardList.length} cards — ${cardCounts["in-progress"] || 0} in-progress, ${cardCounts["validation"] || 0} validation, ${cardCounts["done"] || 0} done">
|
|
67
|
-
${cardLines}
|
|
68
|
-
</board>
|
|
69
|
-
</rayburst_product_context>`;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Build the Active Feature Block injected when a user prompt matches a feature.
|
|
74
|
-
* Contains full criteria for the matched feature(s).
|
|
75
|
-
*/
|
|
76
|
-
export function buildActiveFeatureBlock(features) {
|
|
77
|
-
if (!features || features.length === 0) return "";
|
|
78
|
-
|
|
79
|
-
const featureBlocks = features.map((f) => {
|
|
80
|
-
const criteria = (f.criteria || [])
|
|
81
|
-
.map((c) => {
|
|
82
|
-
return ` <criterion id="${c.id}" status="${c.status}">${escapeXml(c.title || "")} — ${escapeXml(c.description || "")}</criterion>`;
|
|
83
|
-
})
|
|
84
|
-
.join("\n");
|
|
85
|
-
|
|
86
|
-
return ` <feature id="${f.id}" title="${escapeXml(f.title)}" status="${f.status}">
|
|
87
|
-
<description>${escapeXml(f.description || "")}</description>
|
|
88
|
-
${criteria}
|
|
89
|
-
</feature>`;
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
return `<rayburst_active_feature>
|
|
93
|
-
${featureBlocks.join("\n")}
|
|
94
|
-
<guidance>Work against these acceptance criteria. When done, summarize which criteria your changes address and which remain.</guidance>
|
|
95
|
-
</rayburst_active_feature>`;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Build the Coding Reminder Block injected before Write/Edit calls.
|
|
100
|
-
*/
|
|
101
|
-
export function buildCodingReminderBlock(activeFeature, filePath, relatedFeatures) {
|
|
102
|
-
if (!activeFeature) return "";
|
|
103
|
-
|
|
104
|
-
const criteria = (activeFeature.criteria || [])
|
|
105
|
-
.map((c) => ` - [ ] ${escapeXml(c.title || c.description || "")}`)
|
|
106
|
-
.join("\n");
|
|
107
|
-
|
|
108
|
-
let relatedNote = "";
|
|
109
|
-
if (relatedFeatures && relatedFeatures.length > 0) {
|
|
110
|
-
const names = relatedFeatures
|
|
111
|
-
.map((f) => `"${escapeXml(f.title)}"`)
|
|
112
|
-
.join(", ");
|
|
113
|
-
relatedNote = `\n <related_features>This file may also relate to: ${names}. Check that your changes don't break their criteria.</related_features>`;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return `<rayburst_coding_reminder>
|
|
117
|
-
<active_feature>${escapeXml(activeFeature.title)} (${activeFeature.id})</active_feature>
|
|
118
|
-
<criteria_checklist>
|
|
119
|
-
${criteria}
|
|
120
|
-
</criteria_checklist>
|
|
121
|
-
<file>${escapeXml(filePath || "unknown")}</file>${relatedNote}
|
|
122
|
-
</rayburst_coding_reminder>`;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function escapeXml(str) {
|
|
126
|
-
return (str || "")
|
|
127
|
-
.replace(/&/g, "&")
|
|
128
|
-
.replace(/</g, "<")
|
|
129
|
-
.replace(/>/g, ">")
|
|
130
|
-
.replace(/"/g, """);
|
|
131
|
-
}
|
package/hooks/rb-cache.mjs
DELETED
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared utilities for Rayburst hooks.
|
|
3
|
-
* Config parsing, API calls, file caching, and feature matching.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
7
|
-
import { resolve } from "node:path";
|
|
8
|
-
import { createHash } from "node:crypto";
|
|
9
|
-
|
|
10
|
-
// ── Project dir & hashing ──
|
|
11
|
-
|
|
12
|
-
export function getProjectDir() {
|
|
13
|
-
return (
|
|
14
|
-
process.env.CLAUDE_PROJECT_DIR ||
|
|
15
|
-
process.env.CONTEXT_MODE_PROJECT_DIR ||
|
|
16
|
-
process.cwd()
|
|
17
|
-
);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function getProjectHash() {
|
|
21
|
-
return createHash("md5").update(getProjectDir()).digest("hex").slice(0, 12);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// ── Cache paths ──
|
|
25
|
-
|
|
26
|
-
export function getCachePath(type) {
|
|
27
|
-
return `/tmp/rb-${type}-${getProjectHash()}.json`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function readCache(type) {
|
|
31
|
-
try {
|
|
32
|
-
const path = getCachePath(type);
|
|
33
|
-
if (!existsSync(path)) return null;
|
|
34
|
-
const raw = readFileSync(path, "utf-8");
|
|
35
|
-
return JSON.parse(raw);
|
|
36
|
-
} catch {
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function writeCache(type, data) {
|
|
42
|
-
try {
|
|
43
|
-
const path = getCachePath(type);
|
|
44
|
-
writeFileSync(path, JSON.stringify(data), "utf-8");
|
|
45
|
-
} catch {
|
|
46
|
-
/* best effort */
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// ── Config parsing ──
|
|
51
|
-
|
|
52
|
-
export function readConfig() {
|
|
53
|
-
const projectDir = getProjectDir();
|
|
54
|
-
const configPath = resolve(projectDir, ".claude", "rb-config.md");
|
|
55
|
-
|
|
56
|
-
if (!existsSync(configPath)) return null;
|
|
57
|
-
|
|
58
|
-
const content = readFileSync(configPath, "utf-8");
|
|
59
|
-
|
|
60
|
-
function parseField(section, key) {
|
|
61
|
-
const sectionMatch = content.match(
|
|
62
|
-
new RegExp(`## ${section}[\\s\\S]*?(?=\\n## |$)`)
|
|
63
|
-
);
|
|
64
|
-
if (!sectionMatch) return null;
|
|
65
|
-
const lineMatch = sectionMatch[0].match(
|
|
66
|
-
new RegExp(`-\\s*${key}:\\s*(.+)`, "i")
|
|
67
|
-
);
|
|
68
|
-
if (lineMatch) return lineMatch[1].trim();
|
|
69
|
-
// Fallback: section content on next line (for Project URL)
|
|
70
|
-
const lines = sectionMatch[0]
|
|
71
|
-
.split("\n")
|
|
72
|
-
.filter((l) => l.trim() && !l.startsWith("#"));
|
|
73
|
-
return lines[0]?.trim() || null;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Read API key: env var takes priority, then config file
|
|
77
|
-
const apiKey = process.env.RAYBURST_API_KEY || parseField("API", "API Key");
|
|
78
|
-
const apiUrl =
|
|
79
|
-
process.env.RAYBURST_API_URL ||
|
|
80
|
-
parseField("API", "API URL") ||
|
|
81
|
-
"https://api.rayburst.app/api/v1/mcp";
|
|
82
|
-
const agentId = process.env.RAYBURST_AGENT_ID;
|
|
83
|
-
const boardId = parseField("Board", "ID");
|
|
84
|
-
const boardSlug = parseField("Board", "Slug");
|
|
85
|
-
const frontendProjectId = parseField("Projects", "Frontend");
|
|
86
|
-
const backendProjectId = parseField("Projects", "Backend");
|
|
87
|
-
const projectUrl = parseField("Project URL", null);
|
|
88
|
-
|
|
89
|
-
return {
|
|
90
|
-
apiKey,
|
|
91
|
-
apiUrl,
|
|
92
|
-
agentId,
|
|
93
|
-
boardId,
|
|
94
|
-
boardSlug,
|
|
95
|
-
frontendProjectId,
|
|
96
|
-
backendProjectId,
|
|
97
|
-
projectUrl,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// ── API calls ──
|
|
102
|
-
|
|
103
|
-
let requestId = 0;
|
|
104
|
-
|
|
105
|
-
export async function mcpCall(config, toolName, args = {}) {
|
|
106
|
-
const headers = {
|
|
107
|
-
"Content-Type": "application/json",
|
|
108
|
-
Accept: "application/json, text/event-stream",
|
|
109
|
-
Authorization: `Bearer ${config.apiKey}`,
|
|
110
|
-
};
|
|
111
|
-
if (config.agentId) headers["X-Agent-Id"] = config.agentId;
|
|
112
|
-
|
|
113
|
-
const controller = new AbortController();
|
|
114
|
-
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
115
|
-
|
|
116
|
-
try {
|
|
117
|
-
const res = await fetch(config.apiUrl, {
|
|
118
|
-
method: "POST",
|
|
119
|
-
headers,
|
|
120
|
-
body: JSON.stringify({
|
|
121
|
-
jsonrpc: "2.0",
|
|
122
|
-
id: ++requestId,
|
|
123
|
-
method: "tools/call",
|
|
124
|
-
params: { name: toolName, arguments: args },
|
|
125
|
-
}),
|
|
126
|
-
signal: controller.signal,
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
clearTimeout(timeout);
|
|
130
|
-
if (!res.ok) return null;
|
|
131
|
-
|
|
132
|
-
const contentType = res.headers.get("content-type") || "";
|
|
133
|
-
if (contentType.includes("text/event-stream")) {
|
|
134
|
-
const text = await res.text();
|
|
135
|
-
const lines = text.split("\n");
|
|
136
|
-
let lastData = null;
|
|
137
|
-
for (const line of lines) {
|
|
138
|
-
if (line.startsWith("data: ")) lastData = line.slice(6);
|
|
139
|
-
}
|
|
140
|
-
if (!lastData) return null;
|
|
141
|
-
try {
|
|
142
|
-
const parsed = JSON.parse(lastData);
|
|
143
|
-
return parsed.result ?? parsed;
|
|
144
|
-
} catch {
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const json = await res.json();
|
|
150
|
-
return json.result ?? json;
|
|
151
|
-
} catch {
|
|
152
|
-
clearTimeout(timeout);
|
|
153
|
-
return null;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export function extractData(result) {
|
|
158
|
-
if (!result) return null;
|
|
159
|
-
if (result.content && Array.isArray(result.content)) {
|
|
160
|
-
const textItem = result.content.find((c) => c.type === "text");
|
|
161
|
-
if (textItem) {
|
|
162
|
-
try {
|
|
163
|
-
return JSON.parse(textItem.text);
|
|
164
|
-
} catch {
|
|
165
|
-
return textItem.text;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
return result;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ── Feature matching ──
|
|
173
|
-
|
|
174
|
-
const STOP_WORDS = new Set([
|
|
175
|
-
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
|
|
176
|
-
"have", "has", "had", "do", "does", "did", "will", "would", "could",
|
|
177
|
-
"should", "may", "might", "shall", "can", "to", "of", "in", "for",
|
|
178
|
-
"on", "with", "at", "by", "from", "as", "into", "through", "during",
|
|
179
|
-
"before", "after", "above", "below", "between", "out", "off", "over",
|
|
180
|
-
"under", "again", "further", "then", "once", "here", "there", "when",
|
|
181
|
-
"where", "why", "how", "all", "each", "every", "both", "few", "more",
|
|
182
|
-
"most", "other", "some", "such", "no", "nor", "not", "only", "own",
|
|
183
|
-
"same", "so", "than", "too", "very", "just", "about", "up", "and",
|
|
184
|
-
"but", "or", "if", "it", "its", "this", "that", "these", "those",
|
|
185
|
-
"i", "me", "my", "we", "our", "you", "your", "he", "him", "his",
|
|
186
|
-
"she", "her", "they", "them", "their", "what", "which", "who", "whom",
|
|
187
|
-
"help", "implement", "add", "create", "make", "build", "fix", "update",
|
|
188
|
-
"change", "modify", "please", "need", "want", "let", "get",
|
|
189
|
-
]);
|
|
190
|
-
|
|
191
|
-
function tokenize(text) {
|
|
192
|
-
return text
|
|
193
|
-
.toLowerCase()
|
|
194
|
-
.replace(/[^a-z0-9\s-]/g, " ")
|
|
195
|
-
.split(/\s+/)
|
|
196
|
-
.filter((w) => w.length > 1 && !STOP_WORDS.has(w));
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Match a user prompt against a list of features.
|
|
201
|
-
* Returns matched features sorted by score, or empty array.
|
|
202
|
-
*/
|
|
203
|
-
export function matchFeatures(prompt, features) {
|
|
204
|
-
if (!prompt || !features || features.length === 0) return [];
|
|
205
|
-
|
|
206
|
-
const promptTokens = tokenize(prompt);
|
|
207
|
-
if (promptTokens.length === 0) return [];
|
|
208
|
-
|
|
209
|
-
const promptSet = new Set(promptTokens);
|
|
210
|
-
const promptLower = prompt.toLowerCase();
|
|
211
|
-
|
|
212
|
-
const scored = [];
|
|
213
|
-
|
|
214
|
-
for (const feature of features) {
|
|
215
|
-
const titleTokens = tokenize(feature.title || "");
|
|
216
|
-
const descTokens = tokenize(feature.description || "");
|
|
217
|
-
const featureTokens = new Set([...titleTokens, ...descTokens]);
|
|
218
|
-
|
|
219
|
-
// Count overlapping tokens
|
|
220
|
-
let overlap = 0;
|
|
221
|
-
for (const token of promptSet) {
|
|
222
|
-
if (featureTokens.has(token)) overlap++;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Bonus: consecutive phrase match in title
|
|
226
|
-
const titleLower = (feature.title || "").toLowerCase();
|
|
227
|
-
let phraseBonus = 0;
|
|
228
|
-
if (promptLower.includes(titleLower) || titleLower.includes(promptLower)) {
|
|
229
|
-
phraseBonus = 0.5;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const score = overlap / promptSet.size + phraseBonus;
|
|
233
|
-
|
|
234
|
-
if (score >= 0.25) {
|
|
235
|
-
scored.push({ feature, score });
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Sort by score desc, prefer active features on tie
|
|
240
|
-
scored.sort((a, b) => {
|
|
241
|
-
if (b.score !== a.score) return b.score - a.score;
|
|
242
|
-
const statusOrder = { active: 0, draft: 1, completed: 2, archived: 3 };
|
|
243
|
-
return (statusOrder[a.feature.status] ?? 9) - (statusOrder[b.feature.status] ?? 9);
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
return scored.slice(0, 3).map((s) => s.feature);
|
|
247
|
-
}
|