groove-dev 0.10.10 → 0.12.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 (61) hide show
  1. package/README.md +24 -16
  2. package/node_modules/@groove-dev/cli/bin/groove.js +32 -0
  3. package/node_modules/@groove-dev/cli/package.json +1 -1
  4. package/node_modules/@groove-dev/cli/src/commands/audit.js +60 -0
  5. package/node_modules/@groove-dev/cli/src/commands/connect.js +279 -0
  6. package/node_modules/@groove-dev/cli/src/commands/disconnect.js +91 -0
  7. package/node_modules/@groove-dev/cli/src/commands/federation.js +84 -0
  8. package/node_modules/@groove-dev/cli/src/commands/start.js +7 -2
  9. package/node_modules/@groove-dev/cli/src/commands/status.js +4 -1
  10. package/node_modules/@groove-dev/daemon/package.json +1 -1
  11. package/node_modules/@groove-dev/daemon/src/api.js +128 -2
  12. package/node_modules/@groove-dev/daemon/src/audit.js +65 -0
  13. package/node_modules/@groove-dev/daemon/src/federation.js +352 -0
  14. package/node_modules/@groove-dev/daemon/src/firstrun.js +27 -2
  15. package/node_modules/@groove-dev/daemon/src/index.js +64 -6
  16. package/node_modules/@groove-dev/daemon/src/indexer.js +324 -0
  17. package/node_modules/@groove-dev/daemon/src/introducer.js +55 -4
  18. package/node_modules/@groove-dev/daemon/src/journalist.js +140 -51
  19. package/node_modules/@groove-dev/daemon/src/process.js +3 -2
  20. package/node_modules/@groove-dev/gui/dist/assets/index-B49YqEXS.js +73 -0
  21. package/{packages/gui/dist/assets/index-CPzm9ZE9.css → node_modules/@groove-dev/gui/dist/assets/index-Gfb8Zxy9.css} +1 -1
  22. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  23. package/node_modules/@groove-dev/gui/package.json +1 -1
  24. package/node_modules/@groove-dev/gui/src/App.jsx +24 -1
  25. package/node_modules/@groove-dev/gui/src/components/AgentNode.jsx +6 -4
  26. package/node_modules/@groove-dev/gui/src/components/AgentStats.jsx +1 -0
  27. package/node_modules/@groove-dev/gui/src/components/SpawnPanel.jsx +71 -1
  28. package/node_modules/@groove-dev/gui/src/stores/groove.js +19 -2
  29. package/node_modules/@groove-dev/gui/src/theme.css +2 -2
  30. package/package.json +1 -1
  31. package/packages/cli/bin/groove.js +32 -0
  32. package/packages/cli/package.json +1 -1
  33. package/packages/cli/src/commands/audit.js +60 -0
  34. package/packages/cli/src/commands/connect.js +279 -0
  35. package/packages/cli/src/commands/disconnect.js +91 -0
  36. package/packages/cli/src/commands/federation.js +84 -0
  37. package/packages/cli/src/commands/start.js +7 -2
  38. package/packages/cli/src/commands/status.js +4 -1
  39. package/packages/daemon/package.json +1 -1
  40. package/packages/daemon/src/api.js +128 -2
  41. package/packages/daemon/src/audit.js +65 -0
  42. package/packages/daemon/src/federation.js +352 -0
  43. package/packages/daemon/src/firstrun.js +27 -2
  44. package/packages/daemon/src/index.js +64 -6
  45. package/packages/daemon/src/indexer.js +324 -0
  46. package/packages/daemon/src/introducer.js +55 -4
  47. package/packages/daemon/src/journalist.js +140 -51
  48. package/packages/daemon/src/process.js +3 -2
  49. package/packages/gui/dist/assets/index-B49YqEXS.js +73 -0
  50. package/{node_modules/@groove-dev/gui/dist/assets/index-CPzm9ZE9.css → packages/gui/dist/assets/index-Gfb8Zxy9.css} +1 -1
  51. package/packages/gui/dist/index.html +2 -2
  52. package/packages/gui/package.json +1 -1
  53. package/packages/gui/src/App.jsx +24 -1
  54. package/packages/gui/src/components/AgentNode.jsx +6 -4
  55. package/packages/gui/src/components/AgentStats.jsx +1 -0
  56. package/packages/gui/src/components/SpawnPanel.jsx +71 -1
  57. package/packages/gui/src/stores/groove.js +19 -2
  58. package/packages/gui/src/theme.css +2 -2
  59. package/groove-logo.png +0 -0
  60. package/node_modules/@groove-dev/gui/dist/assets/index-BPVh7Oqk.js +0 -73
  61. package/packages/gui/dist/assets/index-BPVh7Oqk.js +0 -73
@@ -21,7 +21,7 @@ export function isFirstRun(grooveDir) {
21
21
  }
22
22
 
23
23
  // Show welcome banner on every startup
24
- export function printWelcome(port) {
24
+ export function printWelcome(port, host = '127.0.0.1') {
25
25
  const providers = listProviders();
26
26
  const installed = providers.filter((p) => p.installed);
27
27
  const notInstalled = providers.filter((p) => !p.installed);
@@ -52,7 +52,32 @@ export function printWelcome(port) {
52
52
  }
53
53
 
54
54
  console.log('');
55
- console.log(` GUI: http://localhost:${port}`);
55
+ const isRemote = host !== '127.0.0.1';
56
+ const isSSH = !!(process.env.SSH_CONNECTION || process.env.SSH_CLIENT);
57
+
58
+ if (isRemote) {
59
+ // Bound to network interface (Tailscale/LAN)
60
+ console.log(` GUI: http://${host}:${port}`);
61
+ console.log(` Host: ${host} (network-accessible)`);
62
+ } else if (isSSH) {
63
+ // Running on a VPS via SSH — print remote access instructions
64
+ const sshUser = process.env.USER || 'user';
65
+ const sshConn = process.env.SSH_CONNECTION || '';
66
+ const serverIp = sshConn.split(' ')[2] || '<this-server-ip>';
67
+
68
+ console.log(` GUI: http://localhost:${port} (this server only)`);
69
+ console.log('');
70
+ console.log(' ► Connect from your laptop:');
71
+ console.log(` groove connect ${sshUser}@${serverIp}`);
72
+ console.log('');
73
+ console.log(' Or manually:');
74
+ console.log(` ssh -L ${port + 1}:localhost:${port} ${sshUser}@${serverIp}`);
75
+ console.log(` Then open http://localhost:${port + 1}`);
76
+ } else {
77
+ // Local machine
78
+ console.log(` GUI: http://localhost:${port}`);
79
+ }
80
+
56
81
  console.log(' Docs: https://docs.groovedev.ai');
57
82
  console.log(' GitHub: https://github.com/grooveai-dev/groove');
58
83
  console.log('');
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { createServer as createHttpServer } from 'http';
5
5
  import { createServer as createNetServer } from 'net';
6
+ import { execFileSync } from 'child_process';
6
7
  import { resolve } from 'path';
7
8
  import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
8
9
  import express from 'express';
@@ -23,15 +24,56 @@ import { CredentialStore } from './credentials.js';
23
24
  import { TaskClassifier } from './classifier.js';
24
25
  import { ModelRouter } from './router.js';
25
26
  import { ProjectManager } from './pm.js';
27
+ import { CodebaseIndexer } from './indexer.js';
28
+ import { AuditLogger } from './audit.js';
29
+ import { Federation } from './federation.js';
26
30
  import { isFirstRun, runFirstTimeSetup, loadConfig, saveConfig, printWelcome } from './firstrun.js';
27
31
 
28
32
  const DEFAULT_PORT = 31415;
33
+ const DEFAULT_HOST = '127.0.0.1';
29
34
 
30
35
  export { loadConfig, saveConfig } from './firstrun.js';
31
36
 
37
+ /**
38
+ * Resolve the --host value to a bind address.
39
+ * - 'tailscale' → auto-detect via `tailscale ip -4`
40
+ * - '0.0.0.0' / '::' → rejected (security policy)
41
+ * - anything else → used as-is
42
+ */
43
+ function resolveHost(host) {
44
+ if (!host || host === 'localhost') return DEFAULT_HOST;
45
+
46
+ // Block direct internet exposure
47
+ if (host === '0.0.0.0' || host === '::') {
48
+ console.error('\n Direct internet exposure not supported.');
49
+ console.error(' Use `groove connect` (SSH tunnel) or `--host tailscale` instead.\n');
50
+ process.exit(1);
51
+ }
52
+
53
+ // Auto-detect Tailscale IP
54
+ if (host === 'tailscale') {
55
+ try {
56
+ const ip = execFileSync('tailscale', ['ip', '-4'], {
57
+ encoding: 'utf8',
58
+ timeout: 5000,
59
+ }).trim().split('\n')[0];
60
+ if (!ip) throw new Error('empty response');
61
+ return ip;
62
+ } catch (err) {
63
+ console.error('\n Could not detect Tailscale IP.');
64
+ console.error(' Make sure Tailscale is installed and running: `tailscale status`');
65
+ console.error(` Error: ${err.message}\n`);
66
+ process.exit(1);
67
+ }
68
+ }
69
+
70
+ return host;
71
+ }
72
+
32
73
  export class Daemon {
33
74
  constructor(options = {}) {
34
75
  this.port = options.port !== undefined ? options.port : (parseInt(process.env.GROOVE_PORT, 10) || DEFAULT_PORT);
76
+ this.host = resolveHost(options.host);
35
77
  this.projectDir = options.projectDir || process.cwd();
36
78
  this.grooveDir = options.grooveDir || resolve(this.projectDir, '.groove');
37
79
  this.pidFile = resolve(this.grooveDir, 'daemon.pid');
@@ -69,6 +111,9 @@ export class Daemon {
69
111
  this.classifier = new TaskClassifier();
70
112
  this.router = new ModelRouter(this);
71
113
  this.pm = new ProjectManager(this);
114
+ this.indexer = new CodebaseIndexer(this);
115
+ this.audit = new AuditLogger(this.grooveDir);
116
+ this.federation = new Federation(this);
72
117
 
73
118
  // HTTP + WebSocket server
74
119
  this.app = express();
@@ -78,13 +123,17 @@ export class Daemon {
78
123
  maxPayload: 1024 * 1024, // 1MB max message
79
124
  verifyClient: ({ req }) => {
80
125
  const origin = req.headers.origin;
81
- // Allow: no origin (CLI/native clients), localhost origins
126
+ // Allow: no origin (CLI/native clients), localhost origins, bound host
82
127
  if (!origin) return true;
83
128
  const allowed = [
84
129
  `http://localhost:${this.port}`,
85
130
  `http://127.0.0.1:${this.port}`,
86
131
  'http://localhost:3142',
87
132
  ];
133
+ // Allow the bound interface (for Tailscale/LAN access)
134
+ if (this.host !== DEFAULT_HOST) {
135
+ allowed.push(`http://${this.host}:${this.port}`);
136
+ }
88
137
  return allowed.includes(origin);
89
138
  },
90
139
  });
@@ -141,11 +190,12 @@ export class Daemon {
141
190
  }
142
191
 
143
192
  // Auto-find an open port if the default is taken
193
+ const bindHost = this.host;
144
194
  const checkPort = (port) => new Promise((res) => {
145
195
  const tester = createNetServer();
146
196
  tester.once('error', () => res(false));
147
197
  tester.once('listening', () => { tester.close(); res(true); });
148
- tester.listen(port, '127.0.0.1');
198
+ tester.listen(port, bindHost);
149
199
  }).catch(() => false);
150
200
 
151
201
  if (!(await checkPort(this.port))) {
@@ -170,17 +220,21 @@ export class Daemon {
170
220
  this.registry.restore(this.state.get('agents') || []);
171
221
 
172
222
  return new Promise((resolvePromise) => {
173
- this.server.listen(this.port, '127.0.0.1', () => {
223
+ this.server.listen(this.port, this.host, () => {
174
224
  writeFileSync(this.pidFile, String(process.pid));
175
- // Write actual port so CLI can find us (supports auto-port rotation)
225
+ // Write actual port and host so CLI can find us
176
226
  writeFileSync(resolve(this.grooveDir, 'daemon.port'), String(this.port));
227
+ writeFileSync(resolve(this.grooveDir, 'daemon.host'), this.host);
177
228
 
178
- printWelcome(this.port);
229
+ printWelcome(this.port, this.host);
179
230
 
180
231
  // Start background services
181
232
  this.journalist.start();
182
233
  this.rotator.start();
183
234
 
235
+ // Scan codebase for workspace/structure awareness
236
+ this.indexer.scan();
237
+
184
238
  resolvePromise(this);
185
239
  });
186
240
  });
@@ -198,10 +252,14 @@ export class Daemon {
198
252
  // Kill all agent processes
199
253
  await this.processes.killAll();
200
254
 
201
- // Clean up PID file
255
+ // Clean up PID and host files
202
256
  if (existsSync(this.pidFile)) {
203
257
  unlinkSync(this.pidFile);
204
258
  }
259
+ const hostFile = resolve(this.grooveDir, 'daemon.host');
260
+ if (existsSync(hostFile)) {
261
+ unlinkSync(hostFile);
262
+ }
205
263
 
206
264
  // Clean up generated files
207
265
  const registryPath = resolve(this.projectDir, 'AGENTS_REGISTRY.md');
@@ -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('');