dave-code 1.0.4 → 1.2.0
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/README.md +39 -5
- package/bin/aiClient.js +878 -159
- package/bin/check.js +11 -0
- package/bin/cliMenu.js +251 -172
- package/bin/commandRouter.js +70 -0
- package/bin/configManager.js +153 -64
- package/bin/contextManager.js +167 -0
- package/bin/index.js +2103 -573
- package/bin/markdownRenderer.js +264 -0
- package/bin/memoryManager.js +182 -0
- package/bin/planManager.js +291 -0
- package/bin/projectNotebookManager.js +839 -0
- package/bin/runtimeEvents.js +104 -0
- package/bin/scanManager.js +561 -0
- package/bin/sessionManager.js +182 -0
- package/bin/terminalRenderer.js +701 -0
- package/bin/textWidth.js +194 -0
- package/bin/thunderManager.js +302 -0
- package/bin/thunderOrchestrator.js +263 -0
- package/bin/thunderPrompts.js +53 -0
- package/bin/thunderRenderer.js +200 -0
- package/bin/toolRuntime.js +1607 -0
- package/package.json +6 -5
package/bin/textWidth.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for terminal text measurement.
|
|
3
|
+
*
|
|
4
|
+
* Every renderer, menu, and header shares these helpers so a wide character is
|
|
5
|
+
* measured identically everywhere. Divergent local copies previously caused box
|
|
6
|
+
* borders and progress rows to drift apart by one or two columns.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[a-zA-Z]/g;
|
|
10
|
+
const OSC_RE = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g;
|
|
11
|
+
const STRING_CONTROL_RE = /\x1b[PX^_][\s\S]*?\x1b\\/g;
|
|
12
|
+
const ESCAPE_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|.)/g;
|
|
13
|
+
|
|
14
|
+
// Zero-width: combining marks, variation selectors, joiners, and the BOM.
|
|
15
|
+
const ZERO_WIDTH_RANGES = [
|
|
16
|
+
[0x0300, 0x036f], [0x0483, 0x0489], [0x0591, 0x05bd], [0x0610, 0x061a],
|
|
17
|
+
[0x064b, 0x065f], [0x0670, 0x0670], [0x06d6, 0x06dc], [0x0e31, 0x0e31],
|
|
18
|
+
[0x0e34, 0x0e3a], [0x0eb1, 0x0eb1], [0x1ab0, 0x1aff], [0x1dc0, 0x1dff],
|
|
19
|
+
[0x200b, 0x200f], [0x20d0, 0x20f0], [0xfe00, 0xfe0f], [0xfe20, 0xfe2f],
|
|
20
|
+
[0xfeff, 0xfeff], [0xe0100, 0xe01ef]
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Full-width: CJK, Hangul, Kana, fullwidth forms, and pictographs/emoji.
|
|
24
|
+
const WIDE_RANGES = [
|
|
25
|
+
[0x1100, 0x115f], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf],
|
|
26
|
+
[0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xa960, 0xa97f], [0xac00, 0xd7a3],
|
|
27
|
+
[0xf900, 0xfaff], [0xfe10, 0xfe19], [0xfe30, 0xfe6f], [0xff00, 0xff60],
|
|
28
|
+
[0xffe0, 0xffe6], [0x16fe0, 0x16fe4], [0x17000, 0x18aff], [0x1b000, 0x1b2ff],
|
|
29
|
+
[0x1f004, 0x1f004], [0x1f0cf, 0x1f0cf], [0x1f18e, 0x1f18e], [0x1f191, 0x1f19a],
|
|
30
|
+
[0x1f200, 0x1f320], [0x1f32d, 0x1f335], [0x1f337, 0x1f37c], [0x1f37e, 0x1f393],
|
|
31
|
+
[0x1f3a0, 0x1f3ca], [0x1f3cf, 0x1f3d3], [0x1f3e0, 0x1f3f0], [0x1f3f4, 0x1f3f4],
|
|
32
|
+
[0x1f3f8, 0x1f43e], [0x1f440, 0x1f440], [0x1f442, 0x1f4fc], [0x1f4ff, 0x1f53d],
|
|
33
|
+
[0x1f54b, 0x1f54e], [0x1f550, 0x1f567], [0x1f57a, 0x1f57a], [0x1f595, 0x1f596],
|
|
34
|
+
[0x1f5a4, 0x1f5a4], [0x1f5fb, 0x1f64f], [0x1f680, 0x1f6c5], [0x1f6cc, 0x1f6cc],
|
|
35
|
+
[0x1f6d0, 0x1f6d2], [0x1f6eb, 0x1f6ec], [0x1f910, 0x1f9ff], [0x1fa70, 0x1faff],
|
|
36
|
+
[0x20000, 0x3fffd]
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function inRanges(code, ranges) {
|
|
40
|
+
let low = 0;
|
|
41
|
+
let high = ranges.length - 1;
|
|
42
|
+
while (low <= high) {
|
|
43
|
+
const mid = (low + high) >> 1;
|
|
44
|
+
if (code < ranges[mid][0]) high = mid - 1;
|
|
45
|
+
else if (code > ranges[mid][1]) low = mid + 1;
|
|
46
|
+
else return true;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function stripAnsi(text) {
|
|
52
|
+
return String(text ?? '').replace(ANSI_RE, '');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Remove escape sequences and control characters from model- or file-sourced
|
|
57
|
+
* text so untrusted content can never repaint or hijack the terminal.
|
|
58
|
+
*/
|
|
59
|
+
export function sanitizeUntrustedText(text) {
|
|
60
|
+
return String(text ?? '')
|
|
61
|
+
.replace(OSC_RE, '')
|
|
62
|
+
.replace(STRING_CONTROL_RE, '')
|
|
63
|
+
.replace(ESCAPE_RE, '')
|
|
64
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function charWidth(char) {
|
|
68
|
+
const code = char.codePointAt(0);
|
|
69
|
+
if (code === undefined) return 0;
|
|
70
|
+
if (code === 0x0a || code === 0x0d) return 0;
|
|
71
|
+
if (inRanges(code, ZERO_WIDTH_RANGES)) return 0;
|
|
72
|
+
if (code < 0x20 || (code >= 0x7f && code < 0xa0)) return 0;
|
|
73
|
+
return inRanges(code, WIDE_RANGES) ? 2 : 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function displayWidth(text) {
|
|
77
|
+
let width = 0;
|
|
78
|
+
for (const char of stripAnsi(text)) width += charWidth(char);
|
|
79
|
+
return width;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Pad on the right to an exact display width; never truncates. */
|
|
83
|
+
export function padEnd(text, width) {
|
|
84
|
+
const current = displayWidth(text);
|
|
85
|
+
return current >= width ? text : `${text}${' '.repeat(width - current)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Center within an exact display width; never truncates. */
|
|
89
|
+
export function padCenter(text, width) {
|
|
90
|
+
const current = displayWidth(text);
|
|
91
|
+
if (current >= width) return text;
|
|
92
|
+
const left = Math.floor((width - current) / 2);
|
|
93
|
+
return `${' '.repeat(left)}${text}${' '.repeat(width - current - left)}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Truncate from the right, appending an ellipsis when content is dropped. */
|
|
97
|
+
export function truncateEnd(text, maxWidth, ellipsis = '…') {
|
|
98
|
+
const value = String(text ?? '');
|
|
99
|
+
if (displayWidth(value) <= maxWidth) return value;
|
|
100
|
+
const ellipsisWidth = displayWidth(ellipsis);
|
|
101
|
+
if (maxWidth <= ellipsisWidth) return ellipsis.slice(0, Math.max(0, maxWidth));
|
|
102
|
+
const target = maxWidth - ellipsisWidth;
|
|
103
|
+
let output = '';
|
|
104
|
+
let width = 0;
|
|
105
|
+
for (const char of value) {
|
|
106
|
+
const next = charWidth(char);
|
|
107
|
+
if (width + next > target) break;
|
|
108
|
+
output += char;
|
|
109
|
+
width += next;
|
|
110
|
+
}
|
|
111
|
+
return `${output}${ellipsis}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Truncate the middle, keeping both ends visible. Preferred for file paths,
|
|
116
|
+
* where the leading directory and the file name both carry meaning.
|
|
117
|
+
*/
|
|
118
|
+
export function truncateMiddle(text, maxWidth, ellipsis = '...') {
|
|
119
|
+
const value = String(text ?? '');
|
|
120
|
+
if (displayWidth(value) <= maxWidth) return value;
|
|
121
|
+
const ellipsisWidth = displayWidth(ellipsis);
|
|
122
|
+
if (maxWidth <= ellipsisWidth) return ellipsis.slice(0, Math.max(0, maxWidth));
|
|
123
|
+
const target = maxWidth - ellipsisWidth;
|
|
124
|
+
const leftTarget = Math.ceil(target / 2);
|
|
125
|
+
const rightTarget = target - leftTarget;
|
|
126
|
+
|
|
127
|
+
let left = '';
|
|
128
|
+
let leftWidth = 0;
|
|
129
|
+
for (const char of value) {
|
|
130
|
+
const next = charWidth(char);
|
|
131
|
+
if (leftWidth + next > leftTarget) break;
|
|
132
|
+
left += char;
|
|
133
|
+
leftWidth += next;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let right = '';
|
|
137
|
+
let rightWidth = 0;
|
|
138
|
+
for (const char of [...value].reverse()) {
|
|
139
|
+
const next = charWidth(char);
|
|
140
|
+
if (rightWidth + next > rightTarget) break;
|
|
141
|
+
right = char + right;
|
|
142
|
+
rightWidth += next;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return `${left}${ellipsis}${right}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Wrap to a display width, breaking mid-word only when a word cannot fit. */
|
|
149
|
+
export function wrapText(text, width) {
|
|
150
|
+
const safeWidth = Math.max(1, Math.floor(width));
|
|
151
|
+
const lines = [];
|
|
152
|
+
const flush = line => lines.push(line.replace(/\s+$/, ''));
|
|
153
|
+
for (const paragraph of String(text ?? '').split('\n')) {
|
|
154
|
+
if (!paragraph) {
|
|
155
|
+
lines.push('');
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
let line = '';
|
|
159
|
+
let lineWidth = 0;
|
|
160
|
+
for (const word of paragraph.split(/(\s+)/)) {
|
|
161
|
+
if (!word) continue;
|
|
162
|
+
const wordWidth = displayWidth(word);
|
|
163
|
+
if (lineWidth + wordWidth <= safeWidth) {
|
|
164
|
+
line += word;
|
|
165
|
+
lineWidth += wordWidth;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (/^\s+$/.test(word)) {
|
|
169
|
+
flush(line);
|
|
170
|
+
line = '';
|
|
171
|
+
lineWidth = 0;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (line) {
|
|
175
|
+
flush(line);
|
|
176
|
+
line = '';
|
|
177
|
+
lineWidth = 0;
|
|
178
|
+
}
|
|
179
|
+
// A single word wider than the line must be split across rows.
|
|
180
|
+
for (const char of word) {
|
|
181
|
+
const next = charWidth(char);
|
|
182
|
+
if (lineWidth + next > safeWidth) {
|
|
183
|
+
flush(line);
|
|
184
|
+
line = '';
|
|
185
|
+
lineWidth = 0;
|
|
186
|
+
}
|
|
187
|
+
line += char;
|
|
188
|
+
lineWidth += next;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
flush(line);
|
|
192
|
+
}
|
|
193
|
+
return lines;
|
|
194
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
const STORE_VERSION = 2;
|
|
7
|
+
export const THUNDER_TIERS = Object.freeze({
|
|
8
|
+
balanced: { maxMembers: 6, concurrency: 4, validationDepth: 'standard' },
|
|
9
|
+
performance: { maxMembers: 9, concurrency: 6, validationDepth: 'deep' }
|
|
10
|
+
});
|
|
11
|
+
export const THUNDER_ROLES = Object.freeze([
|
|
12
|
+
'pm', 'techLead', 'frontend', 'backend', 'qa', 'designer', 'devops', 'securityData'
|
|
13
|
+
]);
|
|
14
|
+
export const MEMBER_STATUSES = new Set(['queued', 'working', 'waiting', 'reviewing', 'blocked', 'done']);
|
|
15
|
+
export const TASK_STATUSES = new Set(['pending', 'in_progress', 'blocked', 'completed']);
|
|
16
|
+
export const MESSAGE_TYPES = new Set(['update', 'question', 'answer', 'decision', 'handoff', 'risk', 'review']);
|
|
17
|
+
|
|
18
|
+
let teamsBaseDir = path.join(os.homedir(), '.dave-code-teams');
|
|
19
|
+
|
|
20
|
+
export function setTeamsBaseDirForTesting(directory) {
|
|
21
|
+
teamsBaseDir = directory;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function canonicalRoot(workspaceRoot) {
|
|
25
|
+
const resolved = path.resolve(workspaceRoot || process.cwd());
|
|
26
|
+
try {
|
|
27
|
+
const real = fs.realpathSync.native(resolved);
|
|
28
|
+
return process.platform === 'win32' ? real.toLowerCase() : real;
|
|
29
|
+
} catch {
|
|
30
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function workspaceKey(workspaceRoot) {
|
|
35
|
+
return crypto.createHash('sha256').update(canonicalRoot(workspaceRoot)).digest('hex');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getThunderStoreFile(workspaceRoot) {
|
|
39
|
+
return path.join(teamsBaseDir, `${workspaceKey(workspaceRoot)}.json`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function safeText(value, max = 4000) {
|
|
43
|
+
return String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').slice(0, max);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function atomicWrite(filePath, value) {
|
|
47
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
48
|
+
const temp = `${filePath}.${process.pid}.${crypto.randomBytes(5).toString('hex')}.tmp`;
|
|
49
|
+
fs.writeFileSync(temp, JSON.stringify(value, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
50
|
+
try { fs.chmodSync(temp, 0o600); } catch {}
|
|
51
|
+
try {
|
|
52
|
+
fs.renameSync(temp, filePath);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (process.platform !== 'win32' || !fs.existsSync(filePath)) throw error;
|
|
55
|
+
fs.unlinkSync(filePath);
|
|
56
|
+
fs.renameSync(temp, filePath);
|
|
57
|
+
} finally {
|
|
58
|
+
if (fs.existsSync(temp)) fs.unlinkSync(temp);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function emptyStore(workspaceRoot) {
|
|
63
|
+
return { version: STORE_VERSION, workspaceRoot: canonicalRoot(workspaceRoot), teams: [] };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeMember(member) {
|
|
67
|
+
if (!member || !THUNDER_ROLES.includes(member.role)) return null;
|
|
68
|
+
return {
|
|
69
|
+
id: safeText(member.id || crypto.randomUUID(), 100),
|
|
70
|
+
role: member.role,
|
|
71
|
+
name: safeText(member.name || member.role, 80),
|
|
72
|
+
profile: safeText(member.profile || '', 120),
|
|
73
|
+
phase: ['scan', 'read', 'act', 'verify'].includes(member.phase) ? member.phase : 'read',
|
|
74
|
+
status: MEMBER_STATUSES.has(member.status) ? member.status : 'queued',
|
|
75
|
+
currentTask: member.currentTask ? safeText(member.currentTask, 120) : null,
|
|
76
|
+
latestReport: safeText(member.latestReport || '', 600),
|
|
77
|
+
waitingFor: member.waitingFor ? safeText(member.waitingFor, 100) : null,
|
|
78
|
+
tokenUsage: {
|
|
79
|
+
input: Number(member.tokenUsage?.input) || 0,
|
|
80
|
+
output: Number(member.tokenUsage?.output) || 0
|
|
81
|
+
},
|
|
82
|
+
contextUsage: {
|
|
83
|
+
usedTokens: Number(member.contextUsage?.usedTokens) || 0,
|
|
84
|
+
budgetTokens: Number(member.contextUsage?.budgetTokens) || 0
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeTask(task) {
|
|
90
|
+
if (!task || typeof task !== 'object') return null;
|
|
91
|
+
return {
|
|
92
|
+
id: safeText(task.id || crypto.randomUUID(), 100),
|
|
93
|
+
title: safeText(task.title || 'Untitled task', 160),
|
|
94
|
+
purpose: safeText(task.purpose || '', 800),
|
|
95
|
+
owner: safeText(task.owner || '', 100),
|
|
96
|
+
status: TASK_STATUSES.has(task.status) ? task.status : 'pending',
|
|
97
|
+
dependencies: Array.isArray(task.dependencies) ? task.dependencies.map(value => safeText(value, 100)).slice(0, 20) : [],
|
|
98
|
+
inputs: Array.isArray(task.inputs) ? task.inputs.map(value => safeText(value, 500)).slice(0, 20) : [],
|
|
99
|
+
deliverable: safeText(task.deliverable || '', 1000),
|
|
100
|
+
fileScopes: Array.isArray(task.fileScopes) ? task.fileScopes.map(value => safeText(value, 300)).slice(0, 50) : [],
|
|
101
|
+
validation: safeText(task.validation || '', 1000)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeMessage(message) {
|
|
106
|
+
if (!message || !MESSAGE_TYPES.has(message.type)) return null;
|
|
107
|
+
return {
|
|
108
|
+
id: safeText(message.id || crypto.randomUUID(), 100),
|
|
109
|
+
from: safeText(message.from || '', 100),
|
|
110
|
+
to: safeText(message.to || '', 100),
|
|
111
|
+
type: message.type,
|
|
112
|
+
summary: safeText(message.summary || '', 1200),
|
|
113
|
+
refs: Array.isArray(message.refs) ? message.refs.map(value => safeText(value, 300)).slice(0, 20) : [],
|
|
114
|
+
requiresResponse: message.requiresResponse === true,
|
|
115
|
+
timestamp: Number(message.timestamp) || Date.now()
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeTeam(team) {
|
|
120
|
+
if (!team || typeof team !== 'object' || !team.id) return null;
|
|
121
|
+
const tier = team.resourceProposal?.tier === 'performance' ? 'performance' : 'balanced';
|
|
122
|
+
return {
|
|
123
|
+
id: safeText(team.id, 100),
|
|
124
|
+
planId: team.planId ? safeText(team.planId, 100) : null,
|
|
125
|
+
planName: safeText(team.planName || '', 80),
|
|
126
|
+
request: safeText(team.request || '', 12000),
|
|
127
|
+
phase: ['intake', 'staffing', 'planning', 'awaiting_code', 'executing', 'reviewing', 'completed', 'blocked', 'cancelled'].includes(team.phase) ? team.phase : 'intake',
|
|
128
|
+
resourceProposal: {
|
|
129
|
+
tier,
|
|
130
|
+
concurrency: Math.min(THUNDER_TIERS[tier].concurrency, Math.max(1, Number(team.resourceProposal?.concurrency) || THUNDER_TIERS[tier].concurrency)),
|
|
131
|
+
validationDepth: safeText(team.resourceProposal?.validationDepth || THUNDER_TIERS[tier].validationDepth, 30),
|
|
132
|
+
rationale: safeText(team.resourceProposal?.rationale || '', 1200),
|
|
133
|
+
performanceRecommended: team.resourceProposal?.performanceRecommended === true,
|
|
134
|
+
approved: team.resourceProposal?.approved === true,
|
|
135
|
+
approvedAt: Number(team.resourceProposal?.approvedAt) || null
|
|
136
|
+
},
|
|
137
|
+
scanSnapshot: team.scanSnapshot && typeof team.scanSnapshot === 'object' ? {
|
|
138
|
+
snapshotId: safeText(team.scanSnapshot.snapshotId || '', 100),
|
|
139
|
+
totalFiles: Number(team.scanSnapshot.totalFiles) || 0,
|
|
140
|
+
totalBytes: Number(team.scanSnapshot.totalBytes) || 0,
|
|
141
|
+
cacheHits: Number(team.scanSnapshot.cacheHits) || 0,
|
|
142
|
+
languages: team.scanSnapshot.languages && typeof team.scanSnapshot.languages === 'object' ? team.scanSnapshot.languages : {},
|
|
143
|
+
directories: team.scanSnapshot.directories && typeof team.scanSnapshot.directories === 'object' ? team.scanSnapshot.directories : {}
|
|
144
|
+
} : null,
|
|
145
|
+
contextPlan: team.contextPlan && typeof team.contextPlan === 'object' ? {
|
|
146
|
+
snapshotId: safeText(team.contextPlan.snapshotId || '', 100),
|
|
147
|
+
contextWindowTokens: Number(team.contextPlan.contextWindowTokens) || 32768,
|
|
148
|
+
usableTokens: Number(team.contextPlan.usableTokens) || 0,
|
|
149
|
+
budgetTokens: Number(team.contextPlan.budgetTokens) || 0,
|
|
150
|
+
reserveTokens: Number(team.contextPlan.reserveTokens) || 0,
|
|
151
|
+
rationale: safeText(team.contextPlan.rationale || '', 1200),
|
|
152
|
+
degraded: team.contextPlan.degraded === true
|
|
153
|
+
} : null,
|
|
154
|
+
members: Array.isArray(team.members) ? team.members.map(normalizeMember).filter(Boolean).slice(0, 9) : [],
|
|
155
|
+
tasks: Array.isArray(team.tasks) ? team.tasks.map(normalizeTask).filter(Boolean).slice(0, 100) : [],
|
|
156
|
+
messages: Array.isArray(team.messages) ? team.messages.map(normalizeMessage).filter(Boolean).slice(-500) : [],
|
|
157
|
+
decisions: Array.isArray(team.decisions) ? team.decisions.map(value => safeText(value, 1200)).slice(-100) : [],
|
|
158
|
+
createdAt: Number(team.createdAt) || Date.now(),
|
|
159
|
+
updatedAt: Number(team.updatedAt) || Date.now()
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function loadThunderStore(workspaceRoot) {
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(fs.readFileSync(getThunderStoreFile(workspaceRoot), 'utf8'));
|
|
166
|
+
if (![1, STORE_VERSION].includes(parsed?.version) || !Array.isArray(parsed.teams)) return emptyStore(workspaceRoot);
|
|
167
|
+
return { ...emptyStore(workspaceRoot), teams: parsed.teams.map(normalizeTeam).filter(Boolean) };
|
|
168
|
+
} catch {
|
|
169
|
+
return emptyStore(workspaceRoot);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function saveThunderStore(workspaceRoot, store) {
|
|
174
|
+
const normalized = {
|
|
175
|
+
...emptyStore(workspaceRoot),
|
|
176
|
+
teams: (store.teams || []).map(normalizeTeam).filter(Boolean).slice(-50)
|
|
177
|
+
};
|
|
178
|
+
atomicWrite(getThunderStoreFile(workspaceRoot), normalized);
|
|
179
|
+
return normalized;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function roleMember(role, index = 1) {
|
|
183
|
+
const labels = {
|
|
184
|
+
pm: 'PM', techLead: 'Tech Lead', frontend: 'Frontend', backend: 'Backend', qa: 'QA',
|
|
185
|
+
designer: 'Product Design', devops: 'DevOps/SRE', securityData: 'Security/Data'
|
|
186
|
+
};
|
|
187
|
+
return normalizeMember({
|
|
188
|
+
id: `${role}-${index}`,
|
|
189
|
+
role,
|
|
190
|
+
name: index > 1 ? `${labels[role]} ${index}` : labels[role],
|
|
191
|
+
status: 'queued'
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function proposeThunderResources(request, { performanceApproved = false } = {}) {
|
|
196
|
+
const text = String(request || '');
|
|
197
|
+
const complex = /(?:架构|迁移|重构|全栈|性能|安全|部署|数据库|跨平台|architecture|migration|refactor|full.?stack|performance|security|deploy|database)/i.test(text);
|
|
198
|
+
const frontend = /(?:前端|界面|UI|UX|网页|组件|样式|terminal|renderer|frontend|react|vue|css)/i.test(text);
|
|
199
|
+
const backend = /(?:后端|API|数据库|服务|server|backend|database|auth|接口)/i.test(text);
|
|
200
|
+
const design = /(?:设计|排版|交互|视觉|样式|UI|UX|美观|design|layout|style|accessibility)/i.test(text);
|
|
201
|
+
const devops = /(?:部署|CI|CD|容器|监控|云|deploy|docker|pipeline|monitor|cloud)/i.test(text);
|
|
202
|
+
const security = /(?:安全|凭据|权限|隐私|数据分析|机器学习|security|credential|privacy|machine learning)/i.test(text);
|
|
203
|
+
const tier = complex && performanceApproved ? 'performance' : 'balanced';
|
|
204
|
+
const limit = THUNDER_TIERS[tier];
|
|
205
|
+
const members = [roleMember('pm'), roleMember('techLead')];
|
|
206
|
+
if (frontend || (!backend && !devops)) members.push(roleMember('frontend'));
|
|
207
|
+
if (backend || complex) members.push(roleMember('backend'));
|
|
208
|
+
if (complex && frontend && backend) members.push(roleMember('backend', 2));
|
|
209
|
+
if (design) members.push(roleMember('designer'));
|
|
210
|
+
if (devops) members.push(roleMember('devops'));
|
|
211
|
+
if (security) members.push(roleMember('securityData'));
|
|
212
|
+
if (complex || /(?:测试|质量|bug|修复|test|quality|fix)/i.test(text)) members.push(roleMember('qa'));
|
|
213
|
+
const unique = [];
|
|
214
|
+
for (const member of members) {
|
|
215
|
+
if (unique.length >= limit.maxMembers) break;
|
|
216
|
+
if (!unique.some(item => item.id === member.id)) unique.push(member);
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
tier,
|
|
220
|
+
concurrency: Math.min(limit.concurrency, unique.length),
|
|
221
|
+
validationDepth: limit.validationDepth,
|
|
222
|
+
rationale: complex
|
|
223
|
+
? 'Cross-cutting or high-risk work needs leadership, implementation, and independent review.'
|
|
224
|
+
: 'A compact office team is sufficient; unrelated specialists are removed.',
|
|
225
|
+
performanceRecommended: complex,
|
|
226
|
+
approved: false,
|
|
227
|
+
approvedAt: null,
|
|
228
|
+
members: unique
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function createThunderTeam(workspaceRoot, { request, planName, resourceProposal, planId = null, scanSnapshot = null, contextPlan = null }) {
|
|
233
|
+
const store = loadThunderStore(workspaceRoot);
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
const team = normalizeTeam({
|
|
236
|
+
id: crypto.randomUUID(), planId, planName, request, phase: 'staffing', resourceProposal, scanSnapshot, contextPlan,
|
|
237
|
+
members: resourceProposal.members, tasks: [], messages: [], decisions: [], createdAt: now, updatedAt: now
|
|
238
|
+
});
|
|
239
|
+
store.teams.push(team);
|
|
240
|
+
saveThunderStore(workspaceRoot, store);
|
|
241
|
+
return team;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function getThunderTeam(workspaceRoot, teamId) {
|
|
245
|
+
return loadThunderStore(workspaceRoot).teams.find(team => team.id === teamId) || null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function findThunderTeamForPlan(workspaceRoot, planId) {
|
|
249
|
+
return loadThunderStore(workspaceRoot).teams
|
|
250
|
+
.filter(team => team.planId === planId)
|
|
251
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)[0] || null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function updateThunderTeam(workspaceRoot, teamId, updates) {
|
|
255
|
+
const store = loadThunderStore(workspaceRoot);
|
|
256
|
+
const index = store.teams.findIndex(team => team.id === teamId);
|
|
257
|
+
if (index === -1) return null;
|
|
258
|
+
const next = normalizeTeam({ ...store.teams[index], ...updates, id: teamId, updatedAt: Date.now() });
|
|
259
|
+
store.teams[index] = next;
|
|
260
|
+
saveThunderStore(workspaceRoot, store);
|
|
261
|
+
return next;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function updateThunderMember(workspaceRoot, teamId, memberId, updates) {
|
|
265
|
+
const team = getThunderTeam(workspaceRoot, teamId);
|
|
266
|
+
if (!team) return null;
|
|
267
|
+
team.members = team.members.map(member => member.id === memberId ? normalizeMember({ ...member, ...updates, id: member.id }) : member);
|
|
268
|
+
return updateThunderTeam(workspaceRoot, teamId, { members: team.members });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function upsertThunderTask(workspaceRoot, teamId, task) {
|
|
272
|
+
const team = getThunderTeam(workspaceRoot, teamId);
|
|
273
|
+
if (!team) return null;
|
|
274
|
+
const normalized = normalizeTask(task);
|
|
275
|
+
const index = team.tasks.findIndex(item => item.id === normalized.id);
|
|
276
|
+
if (index === -1) team.tasks.push(normalized); else team.tasks[index] = normalized;
|
|
277
|
+
return updateThunderTeam(workspaceRoot, teamId, { tasks: team.tasks });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function addThunderMessage(workspaceRoot, teamId, message) {
|
|
281
|
+
const team = getThunderTeam(workspaceRoot, teamId);
|
|
282
|
+
if (!team) return null;
|
|
283
|
+
const normalized = normalizeMessage(message);
|
|
284
|
+
if (!normalized) throw new Error('Invalid Thunder message.');
|
|
285
|
+
team.messages.push(normalized);
|
|
286
|
+
return updateThunderTeam(workspaceRoot, teamId, { messages: team.messages });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function approveThunderResources(workspaceRoot, teamId) {
|
|
290
|
+
const team = getThunderTeam(workspaceRoot, teamId);
|
|
291
|
+
if (!team) return null;
|
|
292
|
+
return updateThunderTeam(workspaceRoot, teamId, {
|
|
293
|
+
phase: 'planning',
|
|
294
|
+
resourceProposal: { ...team.resourceProposal, approved: true, approvedAt: Date.now() }
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function thunderSummary(workspaceRoot) {
|
|
299
|
+
const teams = loadThunderStore(workspaceRoot).teams;
|
|
300
|
+
const active = teams.filter(team => !['completed', 'cancelled'].includes(team.phase));
|
|
301
|
+
return { total: teams.length, active: active.length, latest: active.sort((a, b) => b.updatedAt - a.updatedAt)[0] || null };
|
|
302
|
+
}
|