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.
@@ -0,0 +1,291 @@
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
+ const VALID_STATUSES = new Set(['ready', 'running', 'completed', 'blocked']);
8
+ let plansBaseDir = path.join(os.homedir(), '.dave-code-plans');
9
+
10
+ export function setPlansBaseDirForTesting(directory) {
11
+ plansBaseDir = directory;
12
+ }
13
+
14
+ export function canonicalWorkspaceRoot(workspaceRoot) {
15
+ const resolved = path.resolve(workspaceRoot || process.cwd());
16
+ let canonical = resolved;
17
+ try {
18
+ canonical = fs.realpathSync.native(resolved);
19
+ } catch {
20
+ // A workspace is validated by the caller. Keep the resolved value for diagnostics.
21
+ }
22
+ return process.platform === 'win32' ? canonical.toLowerCase() : canonical;
23
+ }
24
+
25
+ function workspaceKey(workspaceRoot) {
26
+ return crypto.createHash('sha256').update(canonicalWorkspaceRoot(workspaceRoot)).digest('hex');
27
+ }
28
+
29
+ export function getPlansFile(workspaceRoot) {
30
+ return path.join(plansBaseDir, `${workspaceKey(workspaceRoot)}.json`);
31
+ }
32
+
33
+ function ensurePlansDir() {
34
+ fs.mkdirSync(plansBaseDir, { recursive: true, mode: 0o700 });
35
+ try {
36
+ fs.chmodSync(plansBaseDir, 0o700);
37
+ } catch {
38
+ // Windows does not implement POSIX modes in the same way.
39
+ }
40
+ }
41
+
42
+ function normalizeName(name) {
43
+ return String(name || '').trim().toLocaleLowerCase();
44
+ }
45
+
46
+ export function validatePlanName(name) {
47
+ const value = String(name || '').trim();
48
+ if (!value) throw new Error('Plan name is required.');
49
+ if ([...value].length > 60) throw new Error('Plan name must be 60 characters or fewer.');
50
+ if (value.includes(':') || /[\u0000-\u001f\u007f]/.test(value)) {
51
+ throw new Error('Plan name cannot contain a colon or control characters.');
52
+ }
53
+ return value;
54
+ }
55
+
56
+ export function validatePlanContent(content) {
57
+ const text = String(content || '');
58
+ const checks = [
59
+ { label: 'goal and acceptance criteria', pattern: /目标|验收|goal|acceptance/i },
60
+ { label: 'verified current-state facts', pattern: /当前|现状|事实|current state|verified facts/i },
61
+ { label: 'ordered implementation steps', pattern: /步骤|step\s*\d|implementation steps/i },
62
+ { label: 'what and how details', pattern: /做什么|怎么做|具体实现|what|how|implementation/i },
63
+ { label: 'validation', pattern: /验证|测试|validation|test plan/i },
64
+ { label: 'risks and rollback', pattern: /风险|回退|回滚|risk|rollback/i }
65
+ ];
66
+ return checks.filter(check => !check.pattern.test(text)).map(check => check.label);
67
+ }
68
+
69
+ function validateStoredPlan(plan) {
70
+ if (!plan || typeof plan !== 'object') return null;
71
+ try {
72
+ const name = validatePlanName(plan.name);
73
+ const status = VALID_STATUSES.has(plan.status) ? plan.status : 'ready';
74
+ return {
75
+ id: String(plan.id || crypto.randomUUID()),
76
+ name,
77
+ status,
78
+ request: String(plan.request || ''),
79
+ content: String(plan.content || ''),
80
+ createdAt: Number(plan.createdAt) || Date.now(),
81
+ updatedAt: Number(plan.updatedAt) || Date.now(),
82
+ lastRun: plan.lastRun && typeof plan.lastRun === 'object' ? plan.lastRun : null,
83
+ workflowMode: plan.workflowMode === 'thunder' ? 'thunder' : 'highway',
84
+ teamId: plan.teamId ? String(plan.teamId) : null,
85
+ resourceProposal: plan.resourceProposal && typeof plan.resourceProposal === 'object' ? plan.resourceProposal : null,
86
+ teamSnapshot: plan.teamSnapshot && typeof plan.teamSnapshot === 'object' ? plan.teamSnapshot : null,
87
+ taskGraph: Array.isArray(plan.taskGraph) ? plan.taskGraph : [],
88
+ decisions: Array.isArray(plan.decisions) ? plan.decisions.map(String) : []
89
+ };
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ function emptyStore(workspaceRoot) {
96
+ return {
97
+ version: STORE_VERSION,
98
+ workspaceRoot: canonicalWorkspaceRoot(workspaceRoot),
99
+ plans: []
100
+ };
101
+ }
102
+
103
+ export function loadPlanStore(workspaceRoot) {
104
+ const filePath = getPlansFile(workspaceRoot);
105
+ try {
106
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
107
+ if (!parsed || ![1, STORE_VERSION].includes(parsed.version) || !Array.isArray(parsed.plans)) {
108
+ return emptyStore(workspaceRoot);
109
+ }
110
+ return {
111
+ version: STORE_VERSION,
112
+ workspaceRoot: canonicalWorkspaceRoot(workspaceRoot),
113
+ plans: parsed.plans.map(validateStoredPlan).filter(Boolean)
114
+ };
115
+ } catch {
116
+ return emptyStore(workspaceRoot);
117
+ }
118
+ }
119
+
120
+ function atomicWriteJson(filePath, value) {
121
+ ensurePlansDir();
122
+ const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
123
+ fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), { encoding: 'utf8', mode: 0o600 });
124
+ try {
125
+ fs.chmodSync(tempPath, 0o600);
126
+ } catch {
127
+ // Best effort on platforms without POSIX permissions.
128
+ }
129
+ try {
130
+ fs.renameSync(tempPath, filePath);
131
+ } catch (error) {
132
+ if (process.platform !== 'win32' || !fs.existsSync(filePath)) throw error;
133
+ fs.unlinkSync(filePath);
134
+ fs.renameSync(tempPath, filePath);
135
+ } finally {
136
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
137
+ }
138
+ }
139
+
140
+ export function savePlanStore(workspaceRoot, store) {
141
+ const normalized = {
142
+ version: STORE_VERSION,
143
+ workspaceRoot: canonicalWorkspaceRoot(workspaceRoot),
144
+ plans: (store.plans || []).map(validateStoredPlan).filter(Boolean)
145
+ };
146
+ atomicWriteJson(getPlansFile(workspaceRoot), normalized);
147
+ return normalized;
148
+ }
149
+
150
+ export function listPlans(workspaceRoot) {
151
+ return loadPlanStore(workspaceRoot).plans.sort((a, b) => {
152
+ const rank = status => status === 'running' ? 0 : status === 'ready' ? 1 : status === 'blocked' ? 2 : 3;
153
+ return rank(a.status) - rank(b.status) || b.updatedAt - a.updatedAt;
154
+ });
155
+ }
156
+
157
+ export function findPlan(workspaceRoot, name) {
158
+ const key = normalizeName(name);
159
+ return listPlans(workspaceRoot).find(plan => normalizeName(plan.name) === key) || null;
160
+ }
161
+
162
+ export function upsertPlan(workspaceRoot, { name, request, content, workflowMode = 'highway', teamId = null, resourceProposal = null, teamSnapshot = null, taskGraph = [], decisions = [] }, { replace = false } = {}) {
163
+ const validName = validatePlanName(name);
164
+ const validContent = String(content || '').trim();
165
+ const missingSections = validatePlanContent(validContent);
166
+ if (missingSections.length > 0) throw new Error(`Plan content is incomplete: missing ${missingSections.join(', ')}.`);
167
+ const store = loadPlanStore(workspaceRoot);
168
+ const index = store.plans.findIndex(plan => normalizeName(plan.name) === normalizeName(validName));
169
+ const now = Date.now();
170
+ if (index !== -1 && !replace) throw new Error(`Plan already exists: ${store.plans[index].name}`);
171
+
172
+ const next = index === -1
173
+ ? {
174
+ id: crypto.randomUUID(),
175
+ name: validName,
176
+ status: 'ready',
177
+ request: String(request || '').trim(),
178
+ content: validContent,
179
+ createdAt: now,
180
+ updatedAt: now,
181
+ lastRun: null,
182
+ workflowMode: workflowMode === 'thunder' ? 'thunder' : 'highway',
183
+ teamId,
184
+ resourceProposal,
185
+ teamSnapshot,
186
+ taskGraph,
187
+ decisions
188
+ }
189
+ : {
190
+ ...store.plans[index],
191
+ name: validName,
192
+ status: 'ready',
193
+ request: String(request || '').trim(),
194
+ content: validContent,
195
+ updatedAt: now,
196
+ lastRun: null,
197
+ workflowMode: workflowMode === 'thunder' ? 'thunder' : 'highway',
198
+ teamId,
199
+ resourceProposal,
200
+ teamSnapshot,
201
+ taskGraph,
202
+ decisions
203
+ };
204
+
205
+ if (index === -1) store.plans.push(next);
206
+ else store.plans[index] = next;
207
+ savePlanStore(workspaceRoot, store);
208
+ return next;
209
+ }
210
+
211
+ export function updatePlan(workspaceRoot, planId, updates = {}) {
212
+ const store = loadPlanStore(workspaceRoot);
213
+ const index = store.plans.findIndex(plan => plan.id === planId);
214
+ if (index === -1) return null;
215
+ const nextStatus = updates.status === undefined ? store.plans[index].status : updates.status;
216
+ if (!VALID_STATUSES.has(nextStatus)) throw new Error(`Invalid plan status: ${nextStatus}`);
217
+ store.plans[index] = {
218
+ ...store.plans[index],
219
+ ...updates,
220
+ id: store.plans[index].id,
221
+ name: updates.name === undefined ? store.plans[index].name : validatePlanName(updates.name),
222
+ status: nextStatus,
223
+ updatedAt: Date.now()
224
+ };
225
+ savePlanStore(workspaceRoot, store);
226
+ return store.plans[index];
227
+ }
228
+
229
+ export function renamePlan(workspaceRoot, planId, nextName) {
230
+ const validName = validatePlanName(nextName);
231
+ const store = loadPlanStore(workspaceRoot);
232
+ if (store.plans.some(plan => plan.id !== planId && normalizeName(plan.name) === normalizeName(validName))) {
233
+ throw new Error(`Plan already exists: ${validName}`);
234
+ }
235
+ const index = store.plans.findIndex(plan => plan.id === planId);
236
+ if (index === -1) return null;
237
+ store.plans[index] = { ...store.plans[index], name: validName, updatedAt: Date.now() };
238
+ savePlanStore(workspaceRoot, store);
239
+ return store.plans[index];
240
+ }
241
+
242
+ export function deletePlan(workspaceRoot, planId) {
243
+ const store = loadPlanStore(workspaceRoot);
244
+ const next = store.plans.filter(plan => plan.id !== planId);
245
+ if (next.length === store.plans.length) return false;
246
+ store.plans = next;
247
+ savePlanStore(workspaceRoot, store);
248
+ return true;
249
+ }
250
+
251
+ export function planStatusLines(workspaceRoot, limit = 4) {
252
+ const panel = planStatusPanel(workspaceRoot, limit);
253
+ const symbols = { ready: '○', running: '▶', completed: '✓', blocked: '!' };
254
+ const lines = panel.items.map(plan => `${symbols[plan.status] || '○'} ${plan.name}`);
255
+ if (panel.hidden > 0) lines.push(`+${panel.hidden} more`);
256
+ return lines;
257
+ }
258
+
259
+ /**
260
+ * Select plans for the persistent header panel. When active and completed plans
261
+ * coexist, reserve one slot for the most recently completed plan so completed
262
+ * work never disappears behind a long ready queue.
263
+ */
264
+ export function planStatusPanel(workspaceRoot, limit = 4) {
265
+ const plans = listPlans(workspaceRoot);
266
+ const safeLimit = Math.max(1, Math.floor(Number(limit) || 4));
267
+ const active = plans.filter(plan => plan.status !== 'completed');
268
+ const completed = plans
269
+ .filter(plan => plan.status === 'completed')
270
+ .sort((a, b) => b.updatedAt - a.updatedAt);
271
+
272
+ let items;
273
+ if (active.length > 0 && completed.length > 0 && safeLimit > 1) {
274
+ items = [...active.slice(0, safeLimit - 1), completed[0]];
275
+ } else {
276
+ items = (active.length > 0 ? active : completed).slice(0, safeLimit);
277
+ }
278
+
279
+ return {
280
+ items,
281
+ total: plans.length,
282
+ hidden: Math.max(0, plans.length - items.length),
283
+ counts: {
284
+ ready: plans.filter(plan => plan.status === 'ready').length,
285
+ running: plans.filter(plan => plan.status === 'running').length,
286
+ blocked: plans.filter(plan => plan.status === 'blocked').length,
287
+ completed: completed.length,
288
+ active: active.length
289
+ }
290
+ };
291
+ }