bueller-wheel 0.3.0 → 0.3.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/dist/issue-reader.js +92 -0
- package/dist/issue-summarize.js +236 -0
- package/package.json +3 -3
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
/**
|
|
3
|
+
* Parses an issue markdown file and extracts the conversation history
|
|
4
|
+
*
|
|
5
|
+
* @param filePath - Absolute path to the issue file
|
|
6
|
+
* @returns Parsed issue with message history
|
|
7
|
+
* @throws Error if file cannot be read or if the format is invalid
|
|
8
|
+
*/
|
|
9
|
+
export async function readIssue(filePath) {
|
|
10
|
+
let rawContent;
|
|
11
|
+
try {
|
|
12
|
+
rawContent = await fs.readFile(filePath, 'utf-8');
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
throw new Error(`Failed to read issue file at ${filePath}: ${String(error)}`);
|
|
16
|
+
}
|
|
17
|
+
return parseIssueContent(rawContent);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Parses issue content and extracts the conversation history
|
|
21
|
+
*
|
|
22
|
+
* @param content - Raw markdown content of the issue file
|
|
23
|
+
* @returns Parsed issue with message history
|
|
24
|
+
*/
|
|
25
|
+
export function parseIssueContent(content) {
|
|
26
|
+
const messages = [];
|
|
27
|
+
// Split by the separator (---)
|
|
28
|
+
const sections = content.split(/\n---\n/);
|
|
29
|
+
let messageIndex = 0;
|
|
30
|
+
for (const section of sections) {
|
|
31
|
+
const trimmedSection = section.trim();
|
|
32
|
+
// Skip empty sections
|
|
33
|
+
if (!trimmedSection) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
// Check if this section starts with @user: or @claude:
|
|
37
|
+
const userMatch = trimmedSection.match(/^@user:\s*([\s\S]*)$/);
|
|
38
|
+
const claudeMatch = trimmedSection.match(/^@claude:\s*([\s\S]*)$/);
|
|
39
|
+
if (userMatch) {
|
|
40
|
+
messages.push({
|
|
41
|
+
index: messageIndex++,
|
|
42
|
+
author: 'user',
|
|
43
|
+
content: userMatch[1].trim(),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
else if (claudeMatch) {
|
|
47
|
+
messages.push({
|
|
48
|
+
index: messageIndex++,
|
|
49
|
+
author: 'claude',
|
|
50
|
+
content: claudeMatch[1].trim(),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
// If no match, skip this section (handles malformed sections)
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
messages,
|
|
57
|
+
rawContent: content,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Gets the latest message from an issue
|
|
62
|
+
*
|
|
63
|
+
* @param issue - Parsed issue object
|
|
64
|
+
* @returns The most recent message, or undefined if no messages exist
|
|
65
|
+
*/
|
|
66
|
+
export function getLatestMessage(issue) {
|
|
67
|
+
if (issue.messages.length === 0) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
return issue.messages[issue.messages.length - 1];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Gets all messages from a specific author
|
|
74
|
+
*
|
|
75
|
+
* @param issue - Parsed issue object
|
|
76
|
+
* @param author - Author to filter by ('user' or 'claude')
|
|
77
|
+
* @returns Array of messages from the specified author
|
|
78
|
+
*/
|
|
79
|
+
export function getMessagesByAuthor(issue, author) {
|
|
80
|
+
return issue.messages.filter((msg) => msg.author === author);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Formats a message for appending to an issue file
|
|
84
|
+
*
|
|
85
|
+
* @param author - Author of the message ('user' or 'claude')
|
|
86
|
+
* @param content - Content of the message
|
|
87
|
+
* @returns Formatted message string ready to append to an issue file
|
|
88
|
+
*/
|
|
89
|
+
export function formatMessage(author, content) {
|
|
90
|
+
return `---\n\n@${author}: ${content}`;
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=issue-reader.js.map
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { parseIssueContent } from './issue-reader.js';
|
|
4
|
+
/**
|
|
5
|
+
* Searches for an issue file by filename across the issue directories
|
|
6
|
+
*
|
|
7
|
+
* @param filename - The issue filename (e.g., "p1-003-read-helper-002.md")
|
|
8
|
+
* @param issuesDir - Base issues directory
|
|
9
|
+
* @returns Located issue or null if not found
|
|
10
|
+
*/
|
|
11
|
+
export async function locateIssueFile(filename, issuesDir) {
|
|
12
|
+
const directories = [
|
|
13
|
+
{ dir: path.join(issuesDir, 'open'), status: 'open' },
|
|
14
|
+
{ dir: path.join(issuesDir, 'review'), status: 'review' },
|
|
15
|
+
{ dir: path.join(issuesDir, 'stuck'), status: 'stuck' },
|
|
16
|
+
];
|
|
17
|
+
for (const { dir, status } of directories) {
|
|
18
|
+
const filePath = path.join(dir, filename);
|
|
19
|
+
try {
|
|
20
|
+
await fs.access(filePath);
|
|
21
|
+
return {
|
|
22
|
+
filePath,
|
|
23
|
+
status,
|
|
24
|
+
filename,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// File doesn't exist in this directory, continue searching
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolves an issue reference (either a full path or filename) to a located issue
|
|
35
|
+
*
|
|
36
|
+
* @param reference - File path or filename
|
|
37
|
+
* @param issuesDir - Base issues directory
|
|
38
|
+
* @returns Located issue or null if not found
|
|
39
|
+
*/
|
|
40
|
+
export async function resolveIssueReference(reference, issuesDir) {
|
|
41
|
+
// Check if it looks like a path (contains path separators)
|
|
42
|
+
if (reference.includes('/') || reference.includes('\\')) {
|
|
43
|
+
// Treat as a file path (absolute or relative)
|
|
44
|
+
const absolutePath = path.isAbsolute(reference) ? reference : path.resolve(reference);
|
|
45
|
+
try {
|
|
46
|
+
await fs.access(absolutePath);
|
|
47
|
+
// Determine status from path
|
|
48
|
+
let status = 'open';
|
|
49
|
+
if (absolutePath.includes('/review/')) {
|
|
50
|
+
status = 'review';
|
|
51
|
+
}
|
|
52
|
+
else if (absolutePath.includes('/stuck/')) {
|
|
53
|
+
status = 'stuck';
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
filePath: absolutePath,
|
|
57
|
+
status,
|
|
58
|
+
filename: path.basename(absolutePath),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Otherwise, treat it as a filename and search for it
|
|
66
|
+
return locateIssueFile(reference, issuesDir);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Abbreviates a message based on its position in the conversation
|
|
70
|
+
*
|
|
71
|
+
* @param message - The message to abbreviate
|
|
72
|
+
* @param _position - Position in conversation ('first', 'middle', or 'last')
|
|
73
|
+
* @param maxLength - Maximum length for the abbreviated content
|
|
74
|
+
* @returns Abbreviated message
|
|
75
|
+
*/
|
|
76
|
+
function abbreviateMessage(message, _position, maxLength) {
|
|
77
|
+
const fullContent = message.content;
|
|
78
|
+
let abbreviated = fullContent;
|
|
79
|
+
let isAbbreviated = false;
|
|
80
|
+
if (fullContent.length > maxLength) {
|
|
81
|
+
abbreviated = fullContent.substring(0, maxLength).trimEnd() + '…';
|
|
82
|
+
isAbbreviated = true;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
index: message.index,
|
|
86
|
+
author: message.author,
|
|
87
|
+
content: abbreviated,
|
|
88
|
+
isAbbreviated,
|
|
89
|
+
fullContent,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Creates abbreviated messages from a parsed issue
|
|
94
|
+
*
|
|
95
|
+
* @param messages - Array of issue messages
|
|
96
|
+
* @returns Array of abbreviated messages
|
|
97
|
+
*/
|
|
98
|
+
function createAbbreviatedMessages(messages) {
|
|
99
|
+
if (messages.length === 0) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
if (messages.length === 1) {
|
|
103
|
+
// Single message - use 230 char limit
|
|
104
|
+
return [abbreviateMessage(messages[0], 'first', 230)];
|
|
105
|
+
}
|
|
106
|
+
const result = [];
|
|
107
|
+
// First message - 230 chars
|
|
108
|
+
result.push(abbreviateMessage(messages[0], 'first', 230));
|
|
109
|
+
// Middle messages - 70 chars
|
|
110
|
+
for (let i = 1; i < messages.length - 1; i++) {
|
|
111
|
+
result.push(abbreviateMessage(messages[i], 'middle', 70));
|
|
112
|
+
}
|
|
113
|
+
// Last message - 230 chars
|
|
114
|
+
result.push(abbreviateMessage(messages[messages.length - 1], 'last', 230));
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Summarizes an issue file with abbreviated messages
|
|
119
|
+
*
|
|
120
|
+
* @param locatedIssue - Located issue information
|
|
121
|
+
* @returns Issue summary
|
|
122
|
+
*/
|
|
123
|
+
export async function summarizeIssue(locatedIssue) {
|
|
124
|
+
const content = await fs.readFile(locatedIssue.filePath, 'utf-8');
|
|
125
|
+
const parsed = parseIssueContent(content);
|
|
126
|
+
const abbreviatedMessages = createAbbreviatedMessages(parsed.messages);
|
|
127
|
+
return {
|
|
128
|
+
issue: locatedIssue,
|
|
129
|
+
abbreviatedMessages,
|
|
130
|
+
messageCount: parsed.messages.length,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Parses index specification (e.g., "3" or "1,3")
|
|
135
|
+
*
|
|
136
|
+
* @param indexSpec - Index specification string
|
|
137
|
+
* @returns Object with indices array and whether it's a single index, or null if invalid
|
|
138
|
+
*/
|
|
139
|
+
export function parseIndexSpec(indexSpec) {
|
|
140
|
+
const parts = indexSpec.split(',').map((s) => s.trim());
|
|
141
|
+
if (parts.length === 1) {
|
|
142
|
+
// Single index
|
|
143
|
+
const index = parseInt(parts[0], 10);
|
|
144
|
+
if (isNaN(index) || index < 0) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return { indices: [index], isSingleIndex: true };
|
|
148
|
+
}
|
|
149
|
+
if (parts.length === 2) {
|
|
150
|
+
// Range
|
|
151
|
+
const start = parseInt(parts[0], 10);
|
|
152
|
+
const end = parseInt(parts[1], 10);
|
|
153
|
+
if (isNaN(start) || isNaN(end) || start < 0 || end < start) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const indices = [];
|
|
157
|
+
for (let i = start; i <= end; i++) {
|
|
158
|
+
indices.push(i);
|
|
159
|
+
}
|
|
160
|
+
return { indices, isSingleIndex: false };
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Expands specific messages in a summary based on index specification
|
|
166
|
+
*
|
|
167
|
+
* @param summary - Issue summary
|
|
168
|
+
* @param indexSpec - Index specification (e.g., "3" or "1,3")
|
|
169
|
+
* @returns New summary with expanded messages and filter info
|
|
170
|
+
*/
|
|
171
|
+
export function expandMessages(summary, indexSpec) {
|
|
172
|
+
const parsed = parseIndexSpec(indexSpec);
|
|
173
|
+
if (!parsed) {
|
|
174
|
+
return summary;
|
|
175
|
+
}
|
|
176
|
+
const { indices, isSingleIndex } = parsed;
|
|
177
|
+
const expandedMessages = summary.abbreviatedMessages.map((msg) => {
|
|
178
|
+
if (indices.includes(msg.index)) {
|
|
179
|
+
return {
|
|
180
|
+
...msg,
|
|
181
|
+
content: msg.fullContent,
|
|
182
|
+
isAbbreviated: false,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return msg;
|
|
186
|
+
});
|
|
187
|
+
return {
|
|
188
|
+
...summary,
|
|
189
|
+
abbreviatedMessages: expandedMessages,
|
|
190
|
+
filterToIndices: indices,
|
|
191
|
+
isSingleIndex,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Condenses text by trimming lines and replacing newlines with single spaces
|
|
196
|
+
*
|
|
197
|
+
* @param text - Text to condense
|
|
198
|
+
* @returns Condensed text
|
|
199
|
+
*/
|
|
200
|
+
function condenseText(text) {
|
|
201
|
+
return text
|
|
202
|
+
.split('\n')
|
|
203
|
+
.map((line) => line.trim())
|
|
204
|
+
.filter((line) => line.length > 0)
|
|
205
|
+
.join(' ');
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Formats an issue summary for console output
|
|
209
|
+
*
|
|
210
|
+
* @param summary - Issue summary (may include filterToIndices and isSingleIndex)
|
|
211
|
+
* @param indexSpec - Optional index specification for filtering display
|
|
212
|
+
* @returns Formatted string
|
|
213
|
+
*/
|
|
214
|
+
export function formatIssueSummary(summary, indexSpec) {
|
|
215
|
+
const lines = [];
|
|
216
|
+
// Header
|
|
217
|
+
const directory = `${summary.issue.status}/`;
|
|
218
|
+
const filename = summary.issue.filename;
|
|
219
|
+
lines.push(`${directory}${filename}`);
|
|
220
|
+
// Determine which messages to show
|
|
221
|
+
const messagesToShow = summary.filterToIndices
|
|
222
|
+
? summary.abbreviatedMessages.filter((msg) => summary.filterToIndices.includes(msg.index))
|
|
223
|
+
: summary.abbreviatedMessages;
|
|
224
|
+
// Messages
|
|
225
|
+
for (const msg of messagesToShow) {
|
|
226
|
+
const content = msg.isAbbreviated ? condenseText(msg.content) : msg.content;
|
|
227
|
+
lines.push(`[${msg.index}] @${msg.author}: ${content}`);
|
|
228
|
+
}
|
|
229
|
+
// Add follow-up action hint if not showing specific indices
|
|
230
|
+
if (!indexSpec || !summary.isSingleIndex) {
|
|
231
|
+
lines.push('');
|
|
232
|
+
lines.push('Pass `--index N` or `--index M,N` to see more.');
|
|
233
|
+
}
|
|
234
|
+
return lines.join('\n');
|
|
235
|
+
}
|
|
236
|
+
//# sourceMappingURL=issue-summarize.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bueller-wheel",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Headless Claude Code issue processor - A wrapper that runs Claude Code in a loop to process issues from a directory queue",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"bueller-wheel": "dist/index.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"build": "rm -rf dist out && tsc && mkdir -p dist && cp out/src
|
|
11
|
+
"build": "rm -rf dist out && tsc && mkdir -p dist && cp out/src/*.js dist/ && echo '#!/usr/bin/env node' | cat - dist/index.js > dist/index.tmp && mv dist/index.tmp dist/index.js && chmod +x dist/index.js",
|
|
12
12
|
"dev": "tsx src/index.ts",
|
|
13
13
|
"start": "node dist/index.js",
|
|
14
14
|
"test": "tsx tests/test-runner.ts",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"homepage": "https://github.com/fongandrew/bueller-wheel#readme",
|
|
39
39
|
"files": [
|
|
40
|
-
"dist
|
|
40
|
+
"dist/*.js",
|
|
41
41
|
"README.md",
|
|
42
42
|
"LICENSE"
|
|
43
43
|
],
|