groove-dev 0.8.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.
Files changed (84) hide show
  1. package/CLAUDE.md +197 -0
  2. package/LICENSE +40 -0
  3. package/README.md +115 -0
  4. package/docs/GUI_DESIGN_SPEC.md +402 -0
  5. package/favicon.png +0 -0
  6. package/groove-logo-short.png +0 -0
  7. package/groove-logo.png +0 -0
  8. package/package.json +70 -0
  9. package/packages/cli/bin/groove.js +98 -0
  10. package/packages/cli/package.json +15 -0
  11. package/packages/cli/src/client.js +25 -0
  12. package/packages/cli/src/commands/agents.js +38 -0
  13. package/packages/cli/src/commands/approve.js +50 -0
  14. package/packages/cli/src/commands/config.js +35 -0
  15. package/packages/cli/src/commands/kill.js +15 -0
  16. package/packages/cli/src/commands/nuke.js +19 -0
  17. package/packages/cli/src/commands/providers.js +40 -0
  18. package/packages/cli/src/commands/rotate.js +16 -0
  19. package/packages/cli/src/commands/spawn.js +91 -0
  20. package/packages/cli/src/commands/start.js +31 -0
  21. package/packages/cli/src/commands/status.js +38 -0
  22. package/packages/cli/src/commands/stop.js +15 -0
  23. package/packages/cli/src/commands/team.js +77 -0
  24. package/packages/daemon/package.json +18 -0
  25. package/packages/daemon/src/adaptive.js +237 -0
  26. package/packages/daemon/src/api.js +533 -0
  27. package/packages/daemon/src/classifier.js +126 -0
  28. package/packages/daemon/src/credentials.js +121 -0
  29. package/packages/daemon/src/firstrun.js +93 -0
  30. package/packages/daemon/src/index.js +208 -0
  31. package/packages/daemon/src/introducer.js +238 -0
  32. package/packages/daemon/src/journalist.js +600 -0
  33. package/packages/daemon/src/lockmanager.js +58 -0
  34. package/packages/daemon/src/pm.js +108 -0
  35. package/packages/daemon/src/process.js +361 -0
  36. package/packages/daemon/src/providers/aider.js +72 -0
  37. package/packages/daemon/src/providers/base.js +38 -0
  38. package/packages/daemon/src/providers/claude-code.js +167 -0
  39. package/packages/daemon/src/providers/codex.js +68 -0
  40. package/packages/daemon/src/providers/gemini.js +62 -0
  41. package/packages/daemon/src/providers/index.js +38 -0
  42. package/packages/daemon/src/providers/ollama.js +94 -0
  43. package/packages/daemon/src/registry.js +89 -0
  44. package/packages/daemon/src/rotator.js +185 -0
  45. package/packages/daemon/src/router.js +132 -0
  46. package/packages/daemon/src/state.js +34 -0
  47. package/packages/daemon/src/supervisor.js +178 -0
  48. package/packages/daemon/src/teams.js +203 -0
  49. package/packages/daemon/src/terminal/base.js +27 -0
  50. package/packages/daemon/src/terminal/generic.js +27 -0
  51. package/packages/daemon/src/terminal/tmux.js +64 -0
  52. package/packages/daemon/src/tokentracker.js +124 -0
  53. package/packages/daemon/src/validate.js +122 -0
  54. package/packages/daemon/templates/api-builder.json +18 -0
  55. package/packages/daemon/templates/fullstack.json +18 -0
  56. package/packages/daemon/templates/monorepo.json +24 -0
  57. package/packages/gui/dist/assets/index-BO95Rm1F.js +73 -0
  58. package/packages/gui/dist/assets/index-CPzm9ZE9.css +1 -0
  59. package/packages/gui/dist/favicon.png +0 -0
  60. package/packages/gui/dist/groove-logo-short.png +0 -0
  61. package/packages/gui/dist/groove-logo.png +0 -0
  62. package/packages/gui/dist/index.html +13 -0
  63. package/packages/gui/index.html +12 -0
  64. package/packages/gui/package.json +22 -0
  65. package/packages/gui/public/favicon.png +0 -0
  66. package/packages/gui/public/groove-logo-short.png +0 -0
  67. package/packages/gui/public/groove-logo.png +0 -0
  68. package/packages/gui/src/App.jsx +215 -0
  69. package/packages/gui/src/components/AgentActions.jsx +347 -0
  70. package/packages/gui/src/components/AgentChat.jsx +479 -0
  71. package/packages/gui/src/components/AgentNode.jsx +117 -0
  72. package/packages/gui/src/components/AgentPanel.jsx +115 -0
  73. package/packages/gui/src/components/AgentStats.jsx +333 -0
  74. package/packages/gui/src/components/ApprovalQueue.jsx +156 -0
  75. package/packages/gui/src/components/EmptyState.jsx +100 -0
  76. package/packages/gui/src/components/SpawnPanel.jsx +515 -0
  77. package/packages/gui/src/components/TeamSelector.jsx +162 -0
  78. package/packages/gui/src/main.jsx +9 -0
  79. package/packages/gui/src/stores/groove.js +247 -0
  80. package/packages/gui/src/theme.css +67 -0
  81. package/packages/gui/src/views/AgentTree.jsx +148 -0
  82. package/packages/gui/src/views/CommandCenter.jsx +620 -0
  83. package/packages/gui/src/views/JournalistFeed.jsx +149 -0
  84. package/packages/gui/vite.config.js +19 -0
@@ -0,0 +1,600 @@
1
+ // GROOVE — The Journalist (Context Synthesis Engine)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs';
5
+ import { resolve } from 'path';
6
+ import { execFile } from 'child_process';
7
+ import { getProvider } from './providers/index.js';
8
+
9
+ const DEFAULT_INTERVAL = 120_000; // 2 minutes
10
+ const MAX_LOG_CHARS = 40_000; // ~10k tokens budget for synthesis input
11
+
12
+ export class Journalist {
13
+ constructor(daemon) {
14
+ this.daemon = daemon;
15
+ this.interval = null;
16
+ this.cycleCount = 0;
17
+ this.lastCycleAt = null;
18
+ this.lastLogSizes = {}; // agentId -> last known log byte size
19
+ this.synthesizing = false;
20
+ this.lastSynthesis = null; // last synthesis result text
21
+ this.history = []; // recent synthesis summaries
22
+ }
23
+
24
+ start(intervalMs = DEFAULT_INTERVAL) {
25
+ if (this.interval) return;
26
+
27
+ // Run first cycle immediately, then on interval
28
+ this.cycle();
29
+ this.interval = setInterval(() => this.cycle(), intervalMs);
30
+ console.log(` Journalist started (every ${intervalMs / 1000}s)`);
31
+ }
32
+
33
+ stop() {
34
+ if (this.interval) {
35
+ clearInterval(this.interval);
36
+ this.interval = null;
37
+ }
38
+ }
39
+
40
+ async cycle() {
41
+ if (this.synthesizing) return; // Don't overlap
42
+
43
+ const agents = this.daemon.registry.getAll();
44
+ const activeAgents = agents.filter((a) => a.status === 'running');
45
+
46
+ // Skip if no active agents
47
+ if (activeAgents.length === 0) return;
48
+
49
+ // Smart scheduling: skip if no new log output since last cycle
50
+ if (this.lastCycleAt && !this.hasNewActivity(activeAgents)) {
51
+ return;
52
+ }
53
+
54
+ this.synthesizing = true;
55
+ this.cycleCount++;
56
+ this.lastCycleAt = Date.now();
57
+
58
+ try {
59
+ // 1. Collect and filter recent logs
60
+ const filteredLogs = this.collectFilteredLogs(activeAgents);
61
+
62
+ // 2. Run AI synthesis (or fallback to structural summary)
63
+ const synthesis = await this.synthesize(activeAgents, filteredLogs);
64
+
65
+ // 3. Write outputs
66
+ this.writeProjectMap(synthesis.projectMap);
67
+ this.writeDecisionsLog(synthesis.decisions);
68
+ this.writeAgentSessionLogs(activeAgents, filteredLogs);
69
+
70
+ this.lastSynthesis = synthesis;
71
+ this.history.push({
72
+ cycle: this.cycleCount,
73
+ timestamp: new Date().toISOString(),
74
+ agentCount: activeAgents.length,
75
+ summary: synthesis.summary,
76
+ });
77
+ // Keep last 50 entries
78
+ if (this.history.length > 50) this.history = this.history.slice(-50);
79
+
80
+ this.daemon.broadcast({
81
+ type: 'journalist:cycle',
82
+ data: {
83
+ cycle: this.cycleCount,
84
+ agentCount: activeAgents.length,
85
+ summary: synthesis.summary,
86
+ projectMap: synthesis.projectMap,
87
+ },
88
+ });
89
+ } catch (err) {
90
+ console.error(' Journalist cycle failed:', err.message);
91
+ } finally {
92
+ this.synthesizing = false;
93
+ }
94
+ }
95
+
96
+ // --- Log collection & filtering ---
97
+
98
+ hasNewActivity(agents) {
99
+ for (const agent of agents) {
100
+ const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.name}.log`);
101
+ if (!existsSync(logPath)) continue;
102
+ try {
103
+ const size = statSync(logPath).size;
104
+ if (size !== (this.lastLogSizes[agent.id] || 0)) return true;
105
+ } catch { /* ignore */ }
106
+ }
107
+ return false;
108
+ }
109
+
110
+ collectFilteredLogs(agents) {
111
+ const result = {};
112
+
113
+ for (const agent of agents) {
114
+ const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.name}.log`);
115
+ if (!existsSync(logPath)) {
116
+ result[agent.id] = { agent, entries: [] };
117
+ continue;
118
+ }
119
+
120
+ try {
121
+ const content = readFileSync(logPath, 'utf8');
122
+ const size = Buffer.byteLength(content);
123
+ this.lastLogSizes[agent.id] = size;
124
+
125
+ const entries = this.filterLog(content, agent);
126
+ result[agent.id] = { agent, entries };
127
+ } catch {
128
+ result[agent.id] = { agent, entries: [] };
129
+ }
130
+ }
131
+
132
+ return result;
133
+ }
134
+
135
+ filterLog(rawLog, agent) {
136
+ // Parse stream-json lines and extract meaningful events
137
+ const entries = [];
138
+ const lines = rawLog.split('\n');
139
+
140
+ for (const line of lines) {
141
+ // Skip empty lines and GROOVE spawn headers
142
+ if (!line.trim() || line.startsWith('[')) continue;
143
+
144
+ try {
145
+ const data = JSON.parse(line);
146
+
147
+ // Tool use (file edits, commands)
148
+ if (data.type === 'assistant' && data.message?.content) {
149
+ const content = data.message.content;
150
+ if (Array.isArray(content)) {
151
+ for (const block of content) {
152
+ if (block.type === 'tool_use') {
153
+ entries.push({
154
+ type: 'tool',
155
+ tool: block.name,
156
+ input: this.summarizeToolInput(block.name, block.input),
157
+ timestamp: data.timestamp,
158
+ });
159
+ } else if (block.type === 'text' && block.text) {
160
+ // Only keep substantial text (decisions, explanations)
161
+ const text = block.text.trim();
162
+ if (text.length > 50) {
163
+ entries.push({ type: 'thinking', text: text.slice(0, 2000), timestamp: data.timestamp });
164
+ }
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ // Result / completion
171
+ if (data.type === 'result') {
172
+ entries.push({
173
+ type: 'result',
174
+ text: typeof data.result === 'string' ? data.result.slice(0, 3000) : '',
175
+ cost: data.total_cost_usd,
176
+ turns: data.num_turns,
177
+ duration: data.duration_ms,
178
+ });
179
+ }
180
+ } catch {
181
+ // Not JSON — check for plain text signals
182
+ if (line.includes('Error') || line.includes('error')) {
183
+ entries.push({ type: 'error', text: line.trim().slice(0, 200) });
184
+ }
185
+ }
186
+ }
187
+
188
+ return entries;
189
+ }
190
+
191
+ summarizeToolInput(toolName, input) {
192
+ if (!input) return '';
193
+ switch (toolName) {
194
+ case 'Write':
195
+ case 'Edit':
196
+ return input.file_path || input.path || '';
197
+ case 'Read':
198
+ return input.file_path || input.path || '';
199
+ case 'Bash':
200
+ return (input.command || '').slice(0, 100);
201
+ case 'Glob':
202
+ case 'Grep':
203
+ return input.pattern || '';
204
+ default:
205
+ return JSON.stringify(input).slice(0, 100);
206
+ }
207
+ }
208
+
209
+ // --- AI Synthesis ---
210
+
211
+ async synthesize(agents, filteredLogs) {
212
+ // Build synthesis prompt from filtered logs
213
+ const prompt = this.buildSynthesisPrompt(agents, filteredLogs);
214
+
215
+ // Try headless AI call for synthesis
216
+ try {
217
+ const result = await this.callHeadless(prompt);
218
+ return this.parseSynthesisResult(result, agents);
219
+ } catch (err) {
220
+ // Fallback to structural summary (no AI call)
221
+ console.error(' Journalist AI synthesis unavailable:', err.message);
222
+ return this.buildStructuralSummary(agents, filteredLogs);
223
+ }
224
+ }
225
+
226
+ buildSynthesisPrompt(agents, filteredLogs) {
227
+ const parts = [
228
+ 'You are The Journalist for GROOVE, an agent orchestration system.',
229
+ 'Analyze the following agent activity logs and produce a synthesis.',
230
+ '',
231
+ 'Output EXACTLY this format (use these exact headers):',
232
+ '',
233
+ '## Project Map',
234
+ '(What has been built/changed, organized by area. Be specific about files and functions.)',
235
+ '',
236
+ '## Decisions',
237
+ '(Key architectural and implementation decisions agents made, with reasoning.)',
238
+ '',
239
+ '## Summary',
240
+ '(2-3 sentence overview of current progress.)',
241
+ '',
242
+ '---',
243
+ '',
244
+ ];
245
+
246
+ // Add agent logs, budget-trimmed
247
+ let totalChars = 0;
248
+ for (const [agentId, { agent, entries }] of Object.entries(filteredLogs)) {
249
+ if (entries.length === 0) continue;
250
+
251
+ parts.push(`### Agent: ${agent.name} (${agent.role}, scope: ${agent.scope?.join(', ') || 'unrestricted'})`);
252
+
253
+ for (const entry of entries.slice(-100)) { // Last 100 events per agent
254
+ const line = this.formatEntry(entry);
255
+ if (totalChars + line.length > MAX_LOG_CHARS) break;
256
+ parts.push(line);
257
+ totalChars += line.length;
258
+ }
259
+ parts.push('');
260
+ }
261
+
262
+ return parts.join('\n');
263
+ }
264
+
265
+ formatEntry(entry) {
266
+ switch (entry.type) {
267
+ case 'tool':
268
+ return `- [tool] ${entry.tool}: ${entry.input}`;
269
+ case 'thinking':
270
+ return `- [thought] ${entry.text.slice(0, 200)}`;
271
+ case 'result':
272
+ return `- [result] ${entry.text.slice(0, 200)} (${entry.turns} turns, ${entry.duration}ms)`;
273
+ case 'error':
274
+ return `- [error] ${entry.text}`;
275
+ default:
276
+ return `- [${entry.type}] ${entry.text || ''}`;
277
+ }
278
+ }
279
+
280
+ async callHeadless(prompt) {
281
+ const provider = getProvider('claude-code');
282
+ if (!provider || !provider.constructor.isInstalled()) {
283
+ throw new Error('No provider available for synthesis');
284
+ }
285
+
286
+ // Use headless mode with Haiku — cheapest model, good enough for synthesis
287
+ const { command, args, env } = provider.buildHeadlessCommand(prompt, 'claude-haiku-4-5-20251001');
288
+
289
+ return new Promise((resolve, reject) => {
290
+ const proc = execFile(command, args, {
291
+ env: { ...process.env, ...env },
292
+ cwd: this.daemon.projectDir,
293
+ maxBuffer: 1024 * 1024 * 5,
294
+ timeout: 60_000,
295
+ }, (err, stdout, stderr) => {
296
+ if (err) return reject(err);
297
+
298
+ // Parse stream-json output to extract the result text
299
+ const lines = stdout.split('\n');
300
+ for (const line of lines) {
301
+ try {
302
+ const data = JSON.parse(line);
303
+ if (data.type === 'result' && data.result) {
304
+ return resolve(data.result);
305
+ }
306
+ } catch { /* skip */ }
307
+ }
308
+
309
+ // Fallback: return raw stdout
310
+ resolve(stdout);
311
+ });
312
+ });
313
+ }
314
+
315
+ parseSynthesisResult(text, agents) {
316
+ // Parse the structured output from AI
317
+ const sections = {
318
+ projectMap: '',
319
+ decisions: '',
320
+ summary: '',
321
+ };
322
+
323
+ const mapMatch = text.match(/## Project Map\n([\s\S]*?)(?=## Decisions|## Summary|$)/);
324
+ const decMatch = text.match(/## Decisions\n([\s\S]*?)(?=## Project Map|## Summary|$)/);
325
+ const sumMatch = text.match(/## Summary\n([\s\S]*?)(?=## Project Map|## Decisions|$)/);
326
+
327
+ sections.projectMap = this.buildProjectMapMd(
328
+ agents,
329
+ mapMatch ? mapMatch[1].trim() : 'No changes detected.'
330
+ );
331
+ sections.decisions = decMatch ? decMatch[1].trim() : '';
332
+ sections.summary = sumMatch ? sumMatch[1].trim() : 'Synthesis cycle complete.';
333
+
334
+ return sections;
335
+ }
336
+
337
+ // --- Fallback structural summary (no AI) ---
338
+
339
+ buildStructuralSummary(agents, filteredLogs) {
340
+ const mapParts = [];
341
+ const decisions = [];
342
+ const summaryParts = [];
343
+
344
+ for (const [agentId, { agent, entries }] of Object.entries(filteredLogs)) {
345
+ const tools = entries.filter((e) => e.type === 'tool');
346
+ const errors = entries.filter((e) => e.type === 'error');
347
+ const writes = tools.filter((t) => t.tool === 'Write' || t.tool === 'Edit');
348
+ const reads = tools.filter((t) => t.tool === 'Read');
349
+
350
+ mapParts.push(`### ${agent.name} (${agent.role})`);
351
+ if (writes.length > 0) {
352
+ mapParts.push(`Files modified:`);
353
+ const files = [...new Set(writes.map((w) => w.input).filter(Boolean))];
354
+ files.forEach((f) => mapParts.push(`- ${f}`));
355
+ }
356
+ if (reads.length > 0) {
357
+ const files = [...new Set(reads.map((r) => r.input).filter(Boolean))];
358
+ mapParts.push(`Files read: ${files.slice(0, 10).join(', ')}`);
359
+ }
360
+ if (errors.length > 0) {
361
+ mapParts.push(`Errors: ${errors.length}`);
362
+ }
363
+ mapParts.push(`Activity: ${entries.length} events, ${tools.length} tool calls`);
364
+ mapParts.push('');
365
+
366
+ summaryParts.push(`${agent.name}: ${tools.length} tool calls, ${writes.length} writes`);
367
+
368
+ // Extract decisions from thinking entries
369
+ const thoughts = entries.filter((e) => e.type === 'thinking');
370
+ for (const t of thoughts.slice(-3)) {
371
+ if (t.text.length > 100) {
372
+ decisions.push(`- **${agent.name}**: ${t.text.slice(0, 200)}`);
373
+ }
374
+ }
375
+ }
376
+
377
+ return {
378
+ projectMap: this.buildProjectMapMd(agents, mapParts.join('\n')),
379
+ decisions: decisions.join('\n'),
380
+ summary: summaryParts.join('. ') || 'No activity.',
381
+ };
382
+ }
383
+
384
+ buildProjectMapMd(agents, content) {
385
+ return [
386
+ `# GROOVE Project Map`,
387
+ ``,
388
+ `*Auto-generated by The Journalist. Cycle ${this.cycleCount}. Updated: ${new Date().toISOString()}*`,
389
+ ``,
390
+ `## Active Agents`,
391
+ ``,
392
+ ...agents.map((a) =>
393
+ `- **${a.name}** (${a.role}) — ${a.provider} — ${a.scope?.join(', ') || 'unrestricted'} — tokens: ${a.tokensUsed}`
394
+ ),
395
+ ``,
396
+ `## Activity`,
397
+ ``,
398
+ content,
399
+ ].join('\n');
400
+ }
401
+
402
+ // --- File outputs ---
403
+
404
+ writeProjectMap(content) {
405
+ writeFileSync(resolve(this.daemon.projectDir, 'GROOVE_PROJECT_MAP.md'), content);
406
+ }
407
+
408
+ writeDecisionsLog(decisions) {
409
+ if (!decisions) return;
410
+
411
+ const path = resolve(this.daemon.projectDir, 'GROOVE_DECISIONS.md');
412
+ const header = `# GROOVE Decisions Log\n\n*Auto-generated by The Journalist.*\n\n`;
413
+
414
+ let existing = '';
415
+ if (existsSync(path)) {
416
+ existing = readFileSync(path, 'utf8').replace(/^# GROOVE Decisions Log[\s\S]*?\n\n/, '');
417
+ }
418
+
419
+ const entry = `## Cycle ${this.cycleCount} — ${new Date().toISOString()}\n\n${decisions}\n\n`;
420
+ writeFileSync(path, header + entry + existing);
421
+ }
422
+
423
+ writeAgentSessionLogs(agents, filteredLogs) {
424
+ const logsDir = resolve(this.daemon.projectDir, 'GROOVE_AGENT_LOGS');
425
+ mkdirSync(logsDir, { recursive: true });
426
+
427
+ const dateStr = new Date().toISOString().split('T')[0];
428
+
429
+ for (const [agentId, { agent, entries }] of Object.entries(filteredLogs)) {
430
+ if (entries.length === 0) continue;
431
+
432
+ const agentDir = resolve(logsDir, agent.name);
433
+ mkdirSync(agentDir, { recursive: true });
434
+
435
+ const logPath = resolve(agentDir, `${dateStr}-session.md`);
436
+ const content = [
437
+ `# ${agent.name} — Session Log`,
438
+ ``,
439
+ `*Updated: ${new Date().toISOString()}*`,
440
+ ``,
441
+ `- Role: ${agent.role}`,
442
+ `- Provider: ${agent.provider}`,
443
+ `- Scope: ${agent.scope?.join(', ') || 'unrestricted'}`,
444
+ `- Tokens: ${agent.tokensUsed}`,
445
+ ``,
446
+ `## Activity`,
447
+ ``,
448
+ ...entries.slice(-100).map((e) => this.formatEntry(e)),
449
+ ].join('\n');
450
+
451
+ writeFileSync(logPath, content);
452
+ }
453
+ }
454
+
455
+ // --- Handoff Brief for Context Rotation ---
456
+
457
+ async generateHandoffBrief(agent) {
458
+ const filteredLogs = this.collectFilteredLogs([agent]);
459
+ const agentLog = filteredLogs[agent.id];
460
+ const entries = agentLog?.entries || [];
461
+
462
+ // Get current project map for context
463
+ const mapPath = resolve(this.daemon.projectDir, 'GROOVE_PROJECT_MAP.md');
464
+ const projectMap = existsSync(mapPath) ? readFileSync(mapPath, 'utf8') : '';
465
+
466
+ // Build a focused handoff brief
467
+ const toolSummary = entries
468
+ .filter((e) => e.type === 'tool')
469
+ .map((e) => `- ${e.tool}: ${e.input}`)
470
+ .slice(-30)
471
+ .join('\n');
472
+
473
+ const errorSummary = entries
474
+ .filter((e) => e.type === 'error')
475
+ .map((e) => `- ${e.text}`)
476
+ .slice(-10)
477
+ .join('\n');
478
+
479
+ const resultSummary = entries
480
+ .filter((e) => e.type === 'result')
481
+ .map((e) => e.text)
482
+ .slice(-3)
483
+ .join('\n');
484
+
485
+ return [
486
+ `# Agent Handoff Brief`,
487
+ ``,
488
+ `You are continuing the work of **${agent.name}** (role: ${agent.role}).`,
489
+ `This is a context rotation — the previous session is being replaced to keep context fresh.`,
490
+ ``,
491
+ `## Your Identity`,
492
+ `- Role: ${agent.role}`,
493
+ `- Scope: ${agent.scope?.join(', ') || 'unrestricted'}`,
494
+ `- Provider: ${agent.provider}`,
495
+ ``,
496
+ `## Previous Session`,
497
+ `- Tokens used: ${agent.tokensUsed}`,
498
+ `- Tool calls: ${entries.filter((e) => e.type === 'tool').length}`,
499
+ ``,
500
+ toolSummary ? `### Recent tool calls\n${toolSummary}\n` : '',
501
+ resultSummary ? `### Last results\n${resultSummary}\n` : '',
502
+ errorSummary ? `### Unresolved errors\n${errorSummary}\n` : '',
503
+ `## Current Project State`,
504
+ ``,
505
+ projectMap ? projectMap.slice(0, 10000) : 'No project map available yet.',
506
+ ``,
507
+ `## Instructions`,
508
+ ``,
509
+ `Continue the work from where the previous session left off.`,
510
+ `Review AGENTS_REGISTRY.md for team awareness.`,
511
+ agent.prompt ? `\nOriginal task: ${agent.prompt}` : '',
512
+ ].filter(Boolean).join('\n');
513
+ }
514
+
515
+ // --- Agent File Tracking ---
516
+
517
+ /**
518
+ * Extract files created/modified by an agent from its raw logs.
519
+ * Used by the Introducer to tell new agents what their teammates built.
520
+ */
521
+ getAgentFiles(agent) {
522
+ const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.name}.log`);
523
+ if (!existsSync(logPath)) return [];
524
+
525
+ try {
526
+ const content = readFileSync(logPath, 'utf8');
527
+ const files = new Set();
528
+
529
+ for (const line of content.split('\n')) {
530
+ if (!line.trim() || line.startsWith('[')) continue;
531
+ try {
532
+ const data = JSON.parse(line);
533
+ if (data.type === 'assistant' && Array.isArray(data.message?.content)) {
534
+ for (const block of data.message.content) {
535
+ if (block.type === 'tool_use' && (block.name === 'Write' || block.name === 'Edit')) {
536
+ const fp = block.input?.file_path || block.input?.path;
537
+ if (fp) {
538
+ // Store relative to project dir for readability
539
+ const projDir = this.daemon.projectDir;
540
+ const rel = fp.startsWith(projDir) ? fp.slice(projDir.length + 1) : fp;
541
+ files.add(rel);
542
+ }
543
+ }
544
+ }
545
+ }
546
+ } catch { /* skip non-JSON */ }
547
+ }
548
+
549
+ return [...files];
550
+ } catch {
551
+ return [];
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Get the final result/output text from an agent's logs.
557
+ * Used to capture planner conclusions, build summaries, etc.
558
+ */
559
+ getAgentResult(agent) {
560
+ const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.name}.log`);
561
+ if (!existsSync(logPath)) return '';
562
+
563
+ try {
564
+ const content = readFileSync(logPath, 'utf8');
565
+ let lastResult = '';
566
+
567
+ for (const line of content.split('\n')) {
568
+ try {
569
+ const data = JSON.parse(line);
570
+ if (data.type === 'result' && typeof data.result === 'string') {
571
+ lastResult = data.result;
572
+ }
573
+ } catch { /* skip */ }
574
+ }
575
+
576
+ return lastResult.slice(0, 5000);
577
+ } catch {
578
+ return '';
579
+ }
580
+ }
581
+
582
+ // --- Accessors ---
583
+
584
+ getLastSynthesis() {
585
+ return this.lastSynthesis;
586
+ }
587
+
588
+ getHistory() {
589
+ return this.history;
590
+ }
591
+
592
+ getStatus() {
593
+ return {
594
+ running: !!this.interval,
595
+ cycleCount: this.cycleCount,
596
+ lastCycleAt: this.lastCycleAt,
597
+ synthesizing: this.synthesizing,
598
+ };
599
+ }
600
+ }
@@ -0,0 +1,58 @@
1
+ // GROOVE — File Lock Manager
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
5
+ import { resolve } from 'path';
6
+ import { minimatch } from 'minimatch';
7
+
8
+ export class LockManager {
9
+ constructor(grooveDir) {
10
+ this.path = resolve(grooveDir, 'locks.json');
11
+ this.locks = new Map(); // agentId -> glob patterns[]
12
+ this.load();
13
+ }
14
+
15
+ load() {
16
+ if (existsSync(this.path)) {
17
+ try {
18
+ const data = JSON.parse(readFileSync(this.path, 'utf8'));
19
+ for (const [id, patterns] of Object.entries(data)) {
20
+ this.locks.set(id, patterns);
21
+ }
22
+ } catch {
23
+ // Start fresh
24
+ }
25
+ }
26
+ }
27
+
28
+ save() {
29
+ const obj = Object.fromEntries(this.locks);
30
+ writeFileSync(this.path, JSON.stringify(obj, null, 2));
31
+ }
32
+
33
+ register(agentId, patterns) {
34
+ this.locks.set(agentId, patterns);
35
+ this.save();
36
+ }
37
+
38
+ release(agentId) {
39
+ this.locks.delete(agentId);
40
+ this.save();
41
+ }
42
+
43
+ check(agentId, filePath) {
44
+ for (const [ownerId, patterns] of this.locks) {
45
+ if (ownerId === agentId) continue;
46
+ for (const pattern of patterns) {
47
+ if (minimatch(filePath, pattern)) {
48
+ return { conflict: true, owner: ownerId, pattern };
49
+ }
50
+ }
51
+ }
52
+ return { conflict: false };
53
+ }
54
+
55
+ getAll() {
56
+ return Object.fromEntries(this.locks);
57
+ }
58
+ }