groove-dev 0.10.10 → 0.11.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 (33) hide show
  1. package/README.md +24 -16
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +22 -0
  5. package/node_modules/@groove-dev/daemon/src/index.js +5 -0
  6. package/node_modules/@groove-dev/daemon/src/indexer.js +324 -0
  7. package/node_modules/@groove-dev/daemon/src/introducer.js +55 -4
  8. package/node_modules/@groove-dev/daemon/src/journalist.js +140 -51
  9. package/node_modules/@groove-dev/daemon/src/process.js +3 -2
  10. package/node_modules/@groove-dev/gui/dist/assets/index-BqZnnVJF.js +73 -0
  11. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  12. package/node_modules/@groove-dev/gui/package.json +1 -1
  13. package/node_modules/@groove-dev/gui/src/components/AgentNode.jsx +6 -4
  14. package/node_modules/@groove-dev/gui/src/components/AgentStats.jsx +1 -0
  15. package/node_modules/@groove-dev/gui/src/components/SpawnPanel.jsx +70 -0
  16. package/package.json +1 -1
  17. package/packages/cli/package.json +1 -1
  18. package/packages/daemon/package.json +1 -1
  19. package/packages/daemon/src/api.js +22 -0
  20. package/packages/daemon/src/index.js +5 -0
  21. package/packages/daemon/src/indexer.js +324 -0
  22. package/packages/daemon/src/introducer.js +55 -4
  23. package/packages/daemon/src/journalist.js +140 -51
  24. package/packages/daemon/src/process.js +3 -2
  25. package/packages/gui/dist/assets/index-BqZnnVJF.js +73 -0
  26. package/packages/gui/dist/index.html +1 -1
  27. package/packages/gui/package.json +1 -1
  28. package/packages/gui/src/components/AgentNode.jsx +6 -4
  29. package/packages/gui/src/components/AgentStats.jsx +1 -0
  30. package/packages/gui/src/components/SpawnPanel.jsx +70 -0
  31. package/groove-logo.png +0 -0
  32. package/node_modules/@groove-dev/gui/dist/assets/index-BPVh7Oqk.js +0 -73
  33. package/packages/gui/dist/assets/index-BPVh7Oqk.js +0 -73
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>GROOVE</title>
7
- <script type="module" crossorigin src="/assets/index-BPVh7Oqk.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-BqZnnVJF.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-CPzm9ZE9.css">
9
9
  </head>
10
10
  <body>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.10.10",
3
+ "version": "0.11.0",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -35,10 +35,12 @@ export default function AgentNode({ data }) {
35
35
  ? data.tokensUsed > 999 ? `${(data.tokensUsed / 1000).toFixed(1)}k` : `${data.tokensUsed}`
36
36
  : '0';
37
37
 
38
- // Get scope summary for activity text
39
- const activity = data.scope?.length > 0
40
- ? data.scope[0].replace(/\/\*\*$/, '').replace(/^src\//, '')
41
- : data.role;
38
+ // Get scope summary for activity text — prefer workingDir if set
39
+ const activity = data.workingDir
40
+ ? data.workingDir.replace(/^\.\//, '')
41
+ : data.scope?.length > 0
42
+ ? data.scope[0].replace(/\/\*\*$/, '').replace(/^src\//, '')
43
+ : data.role;
42
44
 
43
45
  return (
44
46
  <div style={{
@@ -61,6 +61,7 @@ export default function AgentStats({ agent }) {
61
61
  <InfoRow label="Role" value={agent.role} />
62
62
  <InfoRow label="Provider" value={agent.provider} />
63
63
  <InfoRow label="Model" value={agent.model || 'default'} />
64
+ {agent.workingDir && <InfoRow label="Directory" value={agent.workingDir} />}
64
65
  <InfoRow label="Scope" value={(agent.scope || []).join(', ') || 'unrestricted'} />
65
66
  <InfoRow label="Spawned" value={agent.spawnedAt ? new Date(agent.spawnedAt).toLocaleTimeString() : '-'} />
66
67
  <InfoRow label="Last Active" value={agent.lastActivity ? new Date(agent.lastActivity).toLocaleTimeString() : '-'} />
@@ -34,12 +34,15 @@ export default function SpawnPanel() {
34
34
  const [submitting, setSubmitting] = useState(false);
35
35
  const [error, setError] = useState('');
36
36
  const [showAdvanced, setShowAdvanced] = useState(false);
37
+ const [workingDir, setWorkingDir] = useState('');
38
+ const [workspaces, setWorkspaces] = useState([]);
37
39
  const [connectingProvider, setConnectingProvider] = useState(null);
38
40
  const [apiKeyInput, setApiKeyInput] = useState('');
39
41
  const [keySaving, setKeySaving] = useState(false);
40
42
 
41
43
  useEffect(() => {
42
44
  fetchProviders();
45
+ fetchWorkspaces();
43
46
  }, []);
44
47
 
45
48
  async function fetchProviders() {
@@ -49,6 +52,14 @@ export default function SpawnPanel() {
49
52
  } catch { /* ignore */ }
50
53
  }
51
54
 
55
+ async function fetchWorkspaces() {
56
+ try {
57
+ const res = await fetch('/api/indexer/workspaces');
58
+ const data = await res.json();
59
+ setWorkspaces(data.workspaces || []);
60
+ } catch { /* ignore */ }
61
+ }
62
+
52
63
  const selectedPreset = ROLE_PRESETS.find((p) => p.id === role);
53
64
  const effectiveScope = role === 'custom'
54
65
  ? scope
@@ -113,6 +124,7 @@ export default function SpawnPanel() {
113
124
  model: model || 'auto',
114
125
  provider,
115
126
  permission,
127
+ ...(workingDir.trim() ? { workingDir: workingDir.trim() } : {}),
116
128
  });
117
129
  closeDetail();
118
130
  } catch (err) {
@@ -198,6 +210,42 @@ export default function SpawnPanel() {
198
210
  rows={3}
199
211
  />
200
212
 
213
+ {/* Workspace picker — visible by default when workspaces detected */}
214
+ {workspaces.length > 0 && (
215
+ <>
216
+ <div style={styles.label}>DIRECTORY</div>
217
+ <div style={styles.wsRow}>
218
+ <button
219
+ type="button"
220
+ onClick={() => setWorkingDir('')}
221
+ style={{
222
+ ...styles.wsBtn,
223
+ ...(!workingDir ? { borderColor: 'var(--accent)', color: 'var(--text-bright)' } : {}),
224
+ }}
225
+ >
226
+ project root
227
+ </button>
228
+ {workspaces.map((ws) => (
229
+ <button
230
+ key={ws.path}
231
+ type="button"
232
+ onClick={() => setWorkingDir(ws.path)}
233
+ style={{
234
+ ...styles.wsBtn,
235
+ ...(workingDir === ws.path ? { borderColor: 'var(--accent)', color: 'var(--text-bright)' } : {}),
236
+ }}
237
+ title={`${ws.name} (${ws.files} files)`}
238
+ >
239
+ {ws.path}
240
+ </button>
241
+ ))}
242
+ </div>
243
+ <div style={styles.hint}>
244
+ Agent spawns inside this directory and only sees this subtree
245
+ </div>
246
+ </>
247
+ )}
248
+
201
249
  {/* Permissions */}
202
250
  <div style={styles.label}>PERMISSIONS</div>
203
251
  <div style={styles.permGrid}>
@@ -231,6 +279,18 @@ export default function SpawnPanel() {
231
279
 
232
280
  {showAdvanced && (
233
281
  <>
282
+ {/* Working directory — manual input for custom paths */}
283
+ <div style={styles.label}>WORKING DIRECTORY</div>
284
+ <input
285
+ style={styles.input}
286
+ placeholder="e.g. packages/frontend (default: project root)"
287
+ value={workingDir}
288
+ onChange={(e) => setWorkingDir(e.target.value)}
289
+ />
290
+ <div style={styles.hint}>
291
+ Relative path — or use the directory buttons above
292
+ </div>
293
+
234
294
  {/* Provider selector with connection flow */}
235
295
  <div style={styles.label}>PROVIDER</div>
236
296
  {providerList.map((p) => {
@@ -501,6 +561,16 @@ const styles = {
501
561
  hint: {
502
562
  fontSize: 10, color: 'var(--text-muted)', marginTop: 3,
503
563
  },
564
+ wsRow: {
565
+ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 6,
566
+ },
567
+ wsBtn: {
568
+ background: 'var(--bg-surface)', border: '1px solid var(--border)',
569
+ borderRadius: 2, padding: '3px 8px',
570
+ color: 'var(--text-dim)', fontSize: 10, cursor: 'pointer',
571
+ fontFamily: 'var(--font)',
572
+ transition: 'color 0.1s, border-color 0.1s',
573
+ },
504
574
  error: {
505
575
  color: 'var(--red)', fontSize: 11, marginTop: 8,
506
576
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.10.10",
3
+ "version": "0.11.0",
4
4
  "description": "Open-source agent orchestration layer for AI coding tools. GUI dashboard, multi-agent coordination, zero cold-start (Journalist), infinite sessions (adaptive context rotation), AI Project Manager, Quick Launch. Works with Claude Code, Codex, Gemini CLI, Ollama.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.10.10",
3
+ "version": "0.11.0",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.10.10",
3
+ "version": "0.11.0",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -341,6 +341,27 @@ export function createApi(app, daemon) {
341
341
  res.json(daemon.adaptive.getAllProfiles());
342
342
  });
343
343
 
344
+ // --- Codebase Indexer ---
345
+
346
+ app.get('/api/indexer', (req, res) => {
347
+ res.json(daemon.indexer.getStatus());
348
+ });
349
+
350
+ app.get('/api/indexer/workspaces', (req, res) => {
351
+ res.json({
352
+ workspaces: daemon.indexer.getWorkspaces(),
353
+ });
354
+ });
355
+
356
+ app.post('/api/indexer/rescan', (req, res) => {
357
+ try {
358
+ daemon.indexer.scan();
359
+ res.json({ ok: true, ...daemon.indexer.getStatus() });
360
+ } catch (err) {
361
+ res.status(500).json({ error: err.message });
362
+ }
363
+ });
364
+
344
365
  // --- Project Manager (AI Review Gate) ---
345
366
 
346
367
  // Agent knocks on PM before risky operations (Auto permission mode)
@@ -401,6 +422,7 @@ export function createApi(app, daemon) {
401
422
  provider: config.provider || 'claude-code',
402
423
  model: config.model || 'auto',
403
424
  permission: config.permission || 'auto',
425
+ workingDir: config.workingDir || undefined,
404
426
  });
405
427
  const agent = await daemon.processes.spawn(validated);
406
428
  spawned.push({ id: agent.id, name: agent.name, role: agent.role });
@@ -23,6 +23,7 @@ import { CredentialStore } from './credentials.js';
23
23
  import { TaskClassifier } from './classifier.js';
24
24
  import { ModelRouter } from './router.js';
25
25
  import { ProjectManager } from './pm.js';
26
+ import { CodebaseIndexer } from './indexer.js';
26
27
  import { isFirstRun, runFirstTimeSetup, loadConfig, saveConfig, printWelcome } from './firstrun.js';
27
28
 
28
29
  const DEFAULT_PORT = 31415;
@@ -69,6 +70,7 @@ export class Daemon {
69
70
  this.classifier = new TaskClassifier();
70
71
  this.router = new ModelRouter(this);
71
72
  this.pm = new ProjectManager(this);
73
+ this.indexer = new CodebaseIndexer(this);
72
74
 
73
75
  // HTTP + WebSocket server
74
76
  this.app = express();
@@ -181,6 +183,9 @@ export class Daemon {
181
183
  this.journalist.start();
182
184
  this.rotator.start();
183
185
 
186
+ // Scan codebase for workspace/structure awareness
187
+ this.indexer.scan();
188
+
184
189
  resolvePromise(this);
185
190
  });
186
191
  });
@@ -0,0 +1,324 @@
1
+ // GROOVE — Codebase Indexer
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // Scans the project structure on daemon start to detect monorepo workspaces,
5
+ // key files, and directory layout. Agents read this instead of spending
6
+ // thousands of tokens exploring the file tree.
7
+
8
+ import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from 'fs';
9
+ import { resolve, relative, basename, join } from 'path';
10
+
11
+ const IGNORE_DIRS = new Set([
12
+ 'node_modules', '.git', '.groove', 'dist', 'build', '.next', '.nuxt',
13
+ '.output', '.cache', '.turbo', '.vercel', '.svelte-kit', 'coverage',
14
+ '__pycache__', '.venv', 'venv', 'vendor', 'target', '.gradle',
15
+ ]);
16
+
17
+ const KEY_FILES = [
18
+ 'package.json', 'tsconfig.json', 'README.md', 'CLAUDE.md',
19
+ 'ARCHITECTURE.md', 'Cargo.toml', 'go.mod', 'pyproject.toml',
20
+ 'Makefile', 'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml',
21
+ ];
22
+
23
+ const MAX_DEPTH = 4;
24
+
25
+ export class CodebaseIndexer {
26
+ constructor(daemon) {
27
+ this.daemon = daemon;
28
+ this.index = null; // { tree, workspaces, keyFiles, stats }
29
+ this.lastIndexTime = null;
30
+ this.indexPath = resolve(daemon.grooveDir, 'codebase-index.json');
31
+ }
32
+
33
+ /**
34
+ * Scan the project directory and build the codebase index.
35
+ * Called on daemon start. Fast — only reads directory entries, not file contents.
36
+ */
37
+ scan() {
38
+ const rootDir = this.daemon.projectDir;
39
+ const tree = [];
40
+ const workspaces = [];
41
+ const keyFiles = [];
42
+ let totalFiles = 0;
43
+ let totalDirs = 0;
44
+
45
+ // Recursive directory walker (depth-limited)
46
+ const walk = (dir, depth, relPath) => {
47
+ if (depth > MAX_DEPTH) return;
48
+
49
+ let entries;
50
+ try {
51
+ entries = readdirSync(dir, { withFileTypes: true });
52
+ } catch {
53
+ return;
54
+ }
55
+
56
+ const dirs = [];
57
+ const files = [];
58
+
59
+ for (const entry of entries) {
60
+ if (entry.name.startsWith('.') && entry.name !== '.github') continue;
61
+
62
+ if (entry.isDirectory()) {
63
+ if (IGNORE_DIRS.has(entry.name)) continue;
64
+ dirs.push(entry.name);
65
+ totalDirs++;
66
+ } else if (entry.isFile()) {
67
+ files.push(entry.name);
68
+ totalFiles++;
69
+
70
+ // Track key files
71
+ if (KEY_FILES.includes(entry.name)) {
72
+ const filePath = relPath ? `${relPath}/${entry.name}` : entry.name;
73
+ keyFiles.push(filePath);
74
+ }
75
+ }
76
+ }
77
+
78
+ // Add to tree
79
+ const nodePath = relPath || '.';
80
+ tree.push({
81
+ path: nodePath,
82
+ depth,
83
+ dirs: dirs.length,
84
+ files: files.length,
85
+ children: dirs,
86
+ });
87
+
88
+ // Recurse into subdirectories
89
+ for (const d of dirs) {
90
+ walk(resolve(dir, d), depth + 1, relPath ? `${relPath}/${d}` : d);
91
+ }
92
+ };
93
+
94
+ walk(rootDir, 0, '');
95
+
96
+ // Detect workspaces
97
+ this.detectWorkspaces(rootDir, workspaces, tree);
98
+
99
+ this.index = {
100
+ projectName: basename(rootDir),
101
+ scannedAt: new Date().toISOString(),
102
+ stats: { totalFiles, totalDirs, treeDepth: MAX_DEPTH },
103
+ workspaces,
104
+ keyFiles,
105
+ tree,
106
+ };
107
+ this.lastIndexTime = Date.now();
108
+
109
+ // Persist to .groove/codebase-index.json
110
+ try {
111
+ writeFileSync(this.indexPath, JSON.stringify(this.index, null, 2));
112
+ } catch {
113
+ // Non-fatal — index is in memory either way
114
+ }
115
+
116
+ // Broadcast to GUI
117
+ this.daemon.broadcast({
118
+ type: 'indexer:complete',
119
+ data: {
120
+ workspaceCount: workspaces.length,
121
+ workspaces,
122
+ stats: this.index.stats,
123
+ },
124
+ });
125
+
126
+ return this.index;
127
+ }
128
+
129
+ /**
130
+ * Detect monorepo workspaces from common patterns.
131
+ */
132
+ detectWorkspaces(rootDir, workspaces, tree) {
133
+ // 1. npm/yarn workspaces (package.json → workspaces field)
134
+ const rootPkg = this.readJson(resolve(rootDir, 'package.json'));
135
+ if (rootPkg?.workspaces) {
136
+ const patterns = Array.isArray(rootPkg.workspaces)
137
+ ? rootPkg.workspaces
138
+ : rootPkg.workspaces.packages || [];
139
+ this.resolveWorkspacePatterns(rootDir, patterns, workspaces, 'npm-workspaces');
140
+ }
141
+
142
+ // 2. pnpm workspaces
143
+ const pnpmPath = resolve(rootDir, 'pnpm-workspace.yaml');
144
+ if (existsSync(pnpmPath)) {
145
+ try {
146
+ const content = readFileSync(pnpmPath, 'utf8');
147
+ // Simple YAML parse — extract lines like " - packages/*"
148
+ const patterns = [];
149
+ for (const line of content.split('\n')) {
150
+ const match = line.match(/^\s*-\s+['"]?([^'"]+)['"]?\s*$/);
151
+ if (match) patterns.push(match[1]);
152
+ }
153
+ if (patterns.length > 0) {
154
+ this.resolveWorkspacePatterns(rootDir, patterns, workspaces, 'pnpm-workspaces');
155
+ }
156
+ } catch { /* ignore */ }
157
+ }
158
+
159
+ // 3. lerna.json
160
+ const lernaPath = resolve(rootDir, 'lerna.json');
161
+ if (existsSync(lernaPath)) {
162
+ const lerna = this.readJson(lernaPath);
163
+ if (lerna?.packages) {
164
+ this.resolveWorkspacePatterns(rootDir, lerna.packages, workspaces, 'lerna');
165
+ }
166
+ }
167
+
168
+ // 4. Fallback: multiple package.json at depth 1-2 (non-declared monorepo)
169
+ if (workspaces.length === 0) {
170
+ const subPkgs = tree.filter((node) =>
171
+ node.depth >= 1 && node.depth <= 2 &&
172
+ this.hasKeyFile(rootDir, node.path, 'package.json')
173
+ );
174
+ if (subPkgs.length >= 2) {
175
+ for (const node of subPkgs) {
176
+ const pkgJson = this.readJson(resolve(rootDir, node.path, 'package.json'));
177
+ workspaces.push({
178
+ path: node.path,
179
+ name: pkgJson?.name || basename(node.path),
180
+ type: 'detected',
181
+ files: node.files,
182
+ dirs: node.dirs,
183
+ });
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Resolve workspace glob patterns (e.g. "packages/*") into actual directories.
191
+ */
192
+ resolveWorkspacePatterns(rootDir, patterns, workspaces, type) {
193
+ const seen = new Set(workspaces.map((w) => w.path));
194
+
195
+ for (const pattern of patterns) {
196
+ // Handle simple glob: "packages/*", "apps/*"
197
+ const clean = pattern.replace(/\/?\*+$/, '');
198
+ const parentDir = resolve(rootDir, clean);
199
+
200
+ if (!existsSync(parentDir)) continue;
201
+
202
+ try {
203
+ const stat = statSync(parentDir);
204
+
205
+ if (pattern.includes('*')) {
206
+ // It's a glob — list children of the parent directory
207
+ const entries = readdirSync(parentDir, { withFileTypes: true });
208
+ for (const entry of entries) {
209
+ if (!entry.isDirectory() || IGNORE_DIRS.has(entry.name)) continue;
210
+ const wsPath = clean ? `${clean}/${entry.name}` : entry.name;
211
+ if (seen.has(wsPath)) continue;
212
+ seen.add(wsPath);
213
+
214
+ const fullPath = resolve(rootDir, wsPath);
215
+ const pkgJson = this.readJson(resolve(fullPath, 'package.json'));
216
+ const info = this.dirInfo(fullPath);
217
+ workspaces.push({
218
+ path: wsPath,
219
+ name: pkgJson?.name || entry.name,
220
+ type,
221
+ files: info.files,
222
+ dirs: info.dirs,
223
+ });
224
+ }
225
+ } else if (stat.isDirectory()) {
226
+ // Direct path (e.g. "shared")
227
+ if (seen.has(clean)) continue;
228
+ seen.add(clean);
229
+
230
+ const pkgJson = this.readJson(resolve(parentDir, 'package.json'));
231
+ const info = this.dirInfo(parentDir);
232
+ workspaces.push({
233
+ path: clean,
234
+ name: pkgJson?.name || basename(clean),
235
+ type,
236
+ files: info.files,
237
+ dirs: info.dirs,
238
+ });
239
+ }
240
+ } catch { /* skip inaccessible dirs */ }
241
+ }
242
+ }
243
+
244
+ // ── Helpers ──
245
+
246
+ readJson(filePath) {
247
+ try {
248
+ return JSON.parse(readFileSync(filePath, 'utf8'));
249
+ } catch {
250
+ return null;
251
+ }
252
+ }
253
+
254
+ hasKeyFile(rootDir, relPath, filename) {
255
+ return existsSync(resolve(rootDir, relPath, filename));
256
+ }
257
+
258
+ dirInfo(dirPath) {
259
+ try {
260
+ const entries = readdirSync(dirPath, { withFileTypes: true });
261
+ let files = 0, dirs = 0;
262
+ for (const e of entries) {
263
+ if (e.name.startsWith('.')) continue;
264
+ if (e.isFile()) files++;
265
+ else if (e.isDirectory() && !IGNORE_DIRS.has(e.name)) dirs++;
266
+ }
267
+ return { files, dirs };
268
+ } catch {
269
+ return { files: 0, dirs: 0 };
270
+ }
271
+ }
272
+
273
+ // ── Public API ──
274
+
275
+ getWorkspaces() {
276
+ return this.index?.workspaces || [];
277
+ }
278
+
279
+ getIndex() {
280
+ return this.index;
281
+ }
282
+
283
+ /**
284
+ * Get a compact structural summary for agent context injection.
285
+ * Returns a markdown string with workspace layout and key files.
286
+ */
287
+ getStructureSummary() {
288
+ if (!this.index) return null;
289
+ const { workspaces, keyFiles, stats, projectName } = this.index;
290
+
291
+ const lines = [];
292
+ lines.push(`Project: **${projectName}** (${stats.totalFiles} files, ${stats.totalDirs} directories)`);
293
+
294
+ if (workspaces.length > 0) {
295
+ lines.push('');
296
+ lines.push(`Workspaces (${workspaces.length}):`);
297
+ for (const ws of workspaces) {
298
+ lines.push(`- \`${ws.path}/\` — ${ws.name} (${ws.files} files, ${ws.dirs} subdirs)`);
299
+ }
300
+ }
301
+
302
+ if (keyFiles.length > 0) {
303
+ lines.push('');
304
+ lines.push('Key files:');
305
+ for (const f of keyFiles.slice(0, 30)) {
306
+ lines.push(`- ${f}`);
307
+ }
308
+ if (keyFiles.length > 30) {
309
+ lines.push(`- *(+${keyFiles.length - 30} more)*`);
310
+ }
311
+ }
312
+
313
+ return lines.join('\n');
314
+ }
315
+
316
+ getStatus() {
317
+ return {
318
+ indexed: !!this.index,
319
+ lastIndexTime: this.lastIndexTime,
320
+ workspaceCount: this.index?.workspaces?.length || 0,
321
+ stats: this.index?.stats || null,
322
+ };
323
+ }
324
+ }
@@ -26,6 +26,10 @@ export class Introducer {
26
26
  `You are **${newAgent.name}** (role: ${newAgent.role}), managed by GROOVE.`,
27
27
  ];
28
28
 
29
+ if (newAgent.workingDir) {
30
+ lines.push(`Your working directory: \`${newAgent.workingDir}\` — you are spawned inside this subdirectory. Stay within it unless coordination requires otherwise.`);
31
+ }
32
+
29
33
  if (newAgent.scope && newAgent.scope.length > 0) {
30
34
  lines.push(`Your file scope: \`${newAgent.scope.join('`, `')}\``);
31
35
  } else {
@@ -45,7 +49,8 @@ export class Introducer {
45
49
 
46
50
  for (const other of others) {
47
51
  const scope = other.scope?.length > 0 ? other.scope.join(', ') : 'unrestricted';
48
- lines.push(`- **${other.name}** (${other.role})scope: ${scope} — ${other.status}`);
52
+ const dir = other.workingDir ? ` dir: ${other.workingDir}` : '';
53
+ lines.push(`- **${other.name}** (${other.role}) — scope: ${scope}${dir} — ${other.status}`);
49
54
 
50
55
  // Get files this agent created/modified
51
56
  const files = this.daemon.journalist?.getAgentFiles(other) || [];
@@ -138,9 +143,54 @@ export class Introducer {
138
143
  lines.push(`Read GROOVE_PROJECT_MAP.md for current project context from The Journalist.`);
139
144
  }
140
145
 
146
+ // Codebase structure injection — give agents instant orientation
147
+ const structureSummary = this.daemon.indexer?.getStructureSummary();
148
+ if (structureSummary) {
149
+ lines.push('');
150
+ lines.push(`## Codebase Structure (auto-indexed)`);
151
+ lines.push('');
152
+ lines.push(structureSummary);
153
+ }
154
+
155
+ // Architecture injection — auto-detect architecture docs and inject
156
+ // so every agent understands the big picture without spending tokens exploring
157
+ const archContent = this.loadArchitectureDoc();
158
+ if (archContent) {
159
+ lines.push('');
160
+ lines.push(`## Architecture (auto-injected)`);
161
+ lines.push('');
162
+ lines.push(archContent);
163
+ }
164
+
141
165
  return lines.join('\n');
142
166
  }
143
167
 
168
+ loadArchitectureDoc() {
169
+ const projectDir = this.daemon.projectDir;
170
+ const candidates = [
171
+ 'ARCHITECTURE.md',
172
+ 'docs/architecture.md',
173
+ '.github/ARCHITECTURE.md',
174
+ ];
175
+
176
+ for (const candidate of candidates) {
177
+ const fullPath = resolve(projectDir, candidate);
178
+ if (existsSync(fullPath)) {
179
+ try {
180
+ let content = readFileSync(fullPath, 'utf8').trim();
181
+ // Truncate to ~5K chars to keep context budget reasonable
182
+ if (content.length > 5000) {
183
+ content = content.slice(0, 5000) + '\n\n*(truncated — read full file for details)*';
184
+ }
185
+ return content;
186
+ } catch {
187
+ // ignore read errors
188
+ }
189
+ }
190
+ }
191
+ return null;
192
+ }
193
+
144
194
  writeRegistryFile(projectDir) {
145
195
  const agents = this.daemon.registry.getAll();
146
196
 
@@ -158,13 +208,14 @@ export class Introducer {
158
208
  ``,
159
209
  `*Auto-generated by GROOVE. Do not edit manually.*`,
160
210
  ``,
161
- `| ID | Name | Role | Provider | Scope | Status |`,
162
- `|----|------|------|----------|-------|--------|`,
211
+ `| ID | Name | Role | Provider | Directory | Scope | Status |`,
212
+ `|----|------|------|----------|-----------|-------|--------|`,
163
213
  ];
164
214
 
165
215
  for (const a of agents) {
166
216
  const scope = a.scope?.length > 0 ? `\`${a.scope.join('`, `')}\`` : '-';
167
- lines.push(`| ${escapeMd(a.id)} | ${escapeMd(a.name)} | ${escapeMd(a.role)} | ${escapeMd(a.provider)} | ${scope} | ${escapeMd(a.status)} |`);
217
+ const dir = a.workingDir ? escapeMd(a.workingDir) : '-';
218
+ lines.push(`| ${escapeMd(a.id)} | ${escapeMd(a.name)} | ${escapeMd(a.role)} | ${escapeMd(a.provider)} | ${dir} | ${scope} | ${escapeMd(a.status)} |`);
168
219
  }
169
220
 
170
221
  lines.push('');