groove-dev 0.27.26 → 0.27.28

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 (49) hide show
  1. package/.groove-staging/state.json +3 -0
  2. package/.groove-staging/timeline.json +13 -0
  3. package/CLAUDE.md +0 -10
  4. package/DECENTRALIZED_NET_WP_V1.md +871 -0
  5. package/README.md +28 -0
  6. package/SECURITY_SWEEP.md +228 -0
  7. package/decentralized-net/ACTION_PLAN.md +422 -0
  8. package/node_modules/@groove-dev/cli/package.json +1 -1
  9. package/node_modules/@groove-dev/daemon/package.json +1 -1
  10. package/node_modules/@groove-dev/daemon/src/api.js +99 -0
  11. package/node_modules/@groove-dev/daemon/src/introducer.js +7 -7
  12. package/node_modules/@groove-dev/daemon/src/journalist.js +36 -6
  13. package/node_modules/@groove-dev/daemon/src/memory.js +29 -10
  14. package/node_modules/@groove-dev/daemon/src/process.js +29 -12
  15. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +26 -1
  16. package/node_modules/@groove-dev/daemon/src/providers/codex.js +34 -11
  17. package/node_modules/@groove-dev/daemon/src/rotator.js +24 -1
  18. package/node_modules/@groove-dev/daemon/test/introducer.test.js +63 -0
  19. package/node_modules/@groove-dev/daemon/test/journalist.test.js +106 -0
  20. package/node_modules/@groove-dev/daemon/test/memory.test.js +49 -0
  21. package/node_modules/@groove-dev/daemon/test/rotator.test.js +99 -0
  22. package/node_modules/@groove-dev/gui/dist/assets/{index-DieCV-v1.js → index-Ch1N9G4Z.js} +1728 -1728
  23. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  24. package/node_modules/@groove-dev/gui/package.json +1 -1
  25. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +147 -21
  26. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +206 -44
  27. package/node_modules/@groove-dev/gui/src/components/marketplace/integration-wizard.jsx +11 -24
  28. package/node_modules/@groove-dev/gui/src/components/marketplace/marketplace-card.jsx +1 -36
  29. package/node_modules/@groove-dev/gui/src/lib/integration-logos.js +39 -0
  30. package/package.json +1 -1
  31. package/packages/cli/package.json +1 -1
  32. package/packages/daemon/package.json +1 -1
  33. package/packages/daemon/src/api.js +99 -0
  34. package/packages/daemon/src/introducer.js +7 -7
  35. package/packages/daemon/src/journalist.js +36 -6
  36. package/packages/daemon/src/memory.js +29 -10
  37. package/packages/daemon/src/process.js +29 -12
  38. package/packages/daemon/src/providers/claude-code.js +26 -1
  39. package/packages/daemon/src/providers/codex.js +34 -11
  40. package/packages/daemon/src/rotator.js +24 -1
  41. package/packages/gui/dist/assets/{index-DieCV-v1.js → index-Ch1N9G4Z.js} +1728 -1728
  42. package/packages/gui/dist/index.html +1 -1
  43. package/packages/gui/package.json +1 -1
  44. package/packages/gui/src/components/agents/agent-config.jsx +147 -21
  45. package/packages/gui/src/components/agents/spawn-wizard.jsx +206 -44
  46. package/packages/gui/src/components/marketplace/integration-wizard.jsx +11 -24
  47. package/packages/gui/src/components/marketplace/marketplace-card.jsx +1 -36
  48. package/packages/gui/src/lib/integration-logos.js +39 -0
  49. package/MUST_FIX_ISSUES.md +0 -305
@@ -12,6 +12,7 @@
12
12
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, appendFileSync, statSync } from 'fs';
13
13
  import { resolve, relative } from 'path';
14
14
  import { createHash } from 'crypto';
15
+ import { minimatch } from 'minimatch';
15
16
 
16
17
  const MAX_CONSTRAINTS = 50;
17
18
  const MAX_HANDOFF_ROTATIONS = 25;
@@ -141,7 +142,12 @@ export class MemoryStore {
141
142
  return safeName(rel);
142
143
  }
143
144
 
144
- _chainPath(role, workingDir) {
145
+ _chainPath(role, workingDir, teamId) {
146
+ if (teamId) {
147
+ const dir = resolve(this.handoffDir, safeName(teamId));
148
+ mkdirSync(dir, { recursive: true });
149
+ return resolve(dir, `${safeName(role)}.md`);
150
+ }
145
151
  const slug = this._workspaceSlug(workingDir);
146
152
  if (slug) {
147
153
  const dir = resolve(this.handoffDir, slug);
@@ -151,8 +157,8 @@ export class MemoryStore {
151
157
  return resolve(this.handoffDir, `${safeName(role)}.md`);
152
158
  }
153
159
 
154
- getHandoffChain(role, workingDir) {
155
- const path = this._chainPath(role, workingDir);
160
+ getHandoffChain(role, workingDir, teamId) {
161
+ const path = this._chainPath(role, workingDir, teamId);
156
162
  if (!existsSync(path)) return [];
157
163
  try {
158
164
  const content = readFileSync(path, 'utf8');
@@ -173,9 +179,9 @@ export class MemoryStore {
173
179
  }
174
180
  }
175
181
 
176
- appendHandoffBrief(role, entry, workingDir) {
182
+ appendHandoffBrief(role, entry, workingDir, teamId) {
177
183
  if (!role || !entry) return false;
178
- const chain = this.getHandoffChain(role, workingDir);
184
+ const chain = this.getHandoffChain(role, workingDir, teamId);
179
185
  const nextN = (chain[0]?.rotationN || 0) + 1;
180
186
 
181
187
  const block = [
@@ -203,15 +209,15 @@ export class MemoryStore {
203
209
  }
204
210
 
205
211
  try {
206
- writeFileSync(this._chainPath(role, workingDir), lines.join('\n'));
212
+ writeFileSync(this._chainPath(role, workingDir, teamId), lines.join('\n'));
207
213
  return true;
208
214
  } catch {
209
215
  return false;
210
216
  }
211
217
  }
212
218
 
213
- getRecentHandoffMarkdown(role, count = 3, maxChars = 4000, workingDir) {
214
- const chain = this.getHandoffChain(role, workingDir);
219
+ getRecentHandoffMarkdown(role, count = 3, maxChars = 4000, workingDir, teamId) {
220
+ const chain = this.getHandoffChain(role, workingDir, teamId);
215
221
  if (chain.length === 0) return '';
216
222
  const recent = chain.slice(0, count);
217
223
  const out = recent.map((e) => e.body || '').join('\n\n---\n\n');
@@ -300,9 +306,22 @@ export class MemoryStore {
300
306
  } catch { /* best-effort */ }
301
307
  }
302
308
 
303
- getDiscoveriesMarkdown(role, limit = 20, maxChars = 4000) {
304
- const entries = this.listDiscoveries({ role, limit });
309
+ getDiscoveriesMarkdown(role, limit = 20, maxChars = 4000, scope) {
310
+ let entries = this.listDiscoveries({ role, limit: limit * 3 });
305
311
  if (entries.length === 0) return '';
312
+
313
+ if (scope && Array.isArray(scope) && scope.length > 0) {
314
+ const filtered = entries.filter((d) => {
315
+ const file = d.fix || '';
316
+ const rel = file.startsWith(this.projectDir + '/') ? file.slice(this.projectDir.length + 1) : file;
317
+ return scope.some((pattern) => minimatch(rel, pattern, { dot: true }));
318
+ });
319
+ if (filtered.length >= 3) {
320
+ entries = filtered;
321
+ }
322
+ }
323
+
324
+ entries = entries.slice(0, limit);
306
325
  const lines = entries.map((d) => `- When \`${d.trigger}\` → fix: ${d.fix}`);
307
326
  return truncate(lines.join('\n'), maxChars);
308
327
  }
@@ -266,6 +266,18 @@ IMPORTANT: Do not use markdown formatting like ** or ### in your output. Write i
266
266
  `,
267
267
  };
268
268
 
269
+ // Role-to-integration mapping — recommended integrations per role for onboarding preflight
270
+ export const ROLE_INTEGRATIONS = {
271
+ ea: ['gmail', 'google-calendar'],
272
+ cmo: ['gmail', 'slack', 'hubspot'],
273
+ cfo: ['stripe', 'google-sheets'],
274
+ support: ['gmail', 'slack', 'zendesk'],
275
+ analyst: ['google-sheets', 'postgres', 'mixpanel'],
276
+ home: ['home-assistant'],
277
+ slides: ['google-slides'],
278
+ creative: ['google-docs'],
279
+ };
280
+
269
281
  // Permission-level prompt instructions
270
282
  // "auto" = PM reviews risky ops via API. "full" = no reviews, max speed.
271
283
  const PERMISSION_PROMPTS = {
@@ -427,12 +439,18 @@ export class ProcessManager {
427
439
  taskNegotiation = await this.negotiateTaskSplit(agent, sameRole);
428
440
  }
429
441
 
430
- // Generate introduction context (team awareness + negotiation)
431
- // Always pass hasTask: true so Layer 7 discoveries and handoff history
432
- // are injected for ALL agents, not just those with explicit prompts.
433
- // Without this, first-generation agents spawned with just a role never
434
- // receive prior discoveries and repeat mistakes Layer 7 already captured.
435
- const introContext = introducer.generateContext(agent, { taskNegotiation, hasTask: true });
442
+ // Compute hasTask from actual prompt content — agents spawned without a
443
+ // prompt should NOT receive handoff history (prevents cross-team contamination).
444
+ // Discoveries + constraints are always injected (project knowledge).
445
+ // Handoffs are injected only when the agent has a real task or is a rotation.
446
+ const hasTask = !!(config.prompt && config.prompt.trim().length > 0);
447
+ const isRotation = !!(config.isRotation);
448
+ const introContext = introducer.generateContext(agent, { taskNegotiation, hasTask, isRotation });
449
+
450
+ // Ensure the project map is fresh before the new agent reads CLAUDE.md
451
+ if (this.daemon.journalist) {
452
+ await this.daemon.journalist.ensureFresh(30000);
453
+ }
436
454
 
437
455
  // Track cold-start savings — agent gets context from planner/journalist/team
438
456
  // instead of exploring the codebase from scratch
@@ -597,7 +615,7 @@ For normal file edits within your scope, proceed without review.
597
615
 
598
616
  this.daemon.broadcast({ type: 'agent:exit', agentId: agent.id, code: code || 0, signal, status });
599
617
  if (this.daemon.integrations) this.daemon.integrations.refreshMcpJson();
600
- if (status === 'completed' && this.daemon.journalist) this.daemon.journalist.cycle().catch(() => {});
618
+ if (status === 'completed' && this.daemon.journalist) this.daemon.journalist.requestSynthesis('completion');
601
619
  this._checkPhase2(agent.id);
602
620
 
603
621
  // Auto-trigger idle QC + process cross-scope handoffs
@@ -783,10 +801,9 @@ For normal file edits within your scope, proceed without review.
783
801
  }
784
802
  }
785
803
 
786
- // Trigger journalist synthesis immediately on completion so the project
787
- // map is fresh for the next agent that spawns (don't wait for 120s cycle)
804
+ // Trigger journalist synthesis on completion (event-driven, debounced)
788
805
  if (finalStatus === 'completed' && this.daemon.journalist) {
789
- this.daemon.journalist.cycle().catch(() => {});
806
+ this.daemon.journalist.requestSynthesis('completion');
790
807
  }
791
808
 
792
809
  // Phase 2 auto-spawn: check if all phase 1 agents for a team are done
@@ -1168,7 +1185,7 @@ For normal file edits within your scope, proceed without review.
1168
1185
  oldTokens: agentData?.tokensUsed || 0,
1169
1186
  contextUsage: agentData?.contextUsage || 0,
1170
1187
  brief: brief.slice(0, 4000),
1171
- }, agent.workingDir);
1188
+ }, agent.workingDir, agent.teamId);
1172
1189
  } catch { /* best-effort */ }
1173
1190
  }
1174
1191
 
@@ -1369,7 +1386,7 @@ For normal file edits within your scope, proceed without review.
1369
1386
  registry.update(newAgent.id, { status: finalStatus, pid: null });
1370
1387
  this.daemon.broadcast({ type: 'agent:exit', agentId: newAgent.id, code, signal, status: finalStatus });
1371
1388
  if (finalStatus === 'completed' && this.daemon.journalist) {
1372
- this.daemon.journalist.cycle().catch(() => {});
1389
+ this.daemon.journalist.requestSynthesis('completion');
1373
1390
  }
1374
1391
  });
1375
1392
 
@@ -1,7 +1,7 @@
1
1
  // GROOVE — Claude Code Provider
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
- import { execSync } from 'child_process';
4
+ import { execSync, spawn as cpSpawn } from 'child_process';
5
5
  import { writeFileSync, readFileSync, existsSync } from 'fs';
6
6
  import { resolve } from 'path';
7
7
  import { homedir } from 'os';
@@ -223,4 +223,29 @@ export class ClaudeCodeProvider extends Provider {
223
223
 
224
224
  return merged;
225
225
  }
226
+
227
+ static getAuthStatus() {
228
+ try {
229
+ const out = execSync('claude auth status --json', { encoding: 'utf8', timeout: 10_000, stdio: ['pipe', 'pipe', 'pipe'] });
230
+ const data = JSON.parse(out);
231
+ return {
232
+ authenticated: true,
233
+ authMethod: data.authMethod || data.auth_method || 'unknown',
234
+ email: data.email || null,
235
+ subscriptionType: data.subscriptionType || data.subscription_type || null,
236
+ orgName: data.orgName || data.org_name || null,
237
+ };
238
+ } catch (err) {
239
+ return { authenticated: false, error: err.message };
240
+ }
241
+ }
242
+
243
+ static triggerLogin() {
244
+ const child = cpSpawn('claude', ['auth', 'login', '--claudeai'], {
245
+ detached: true,
246
+ stdio: 'ignore',
247
+ });
248
+ child.unref();
249
+ return { pid: child.pid };
250
+ }
226
251
  }
@@ -95,6 +95,7 @@ export class CodexProvider extends Provider {
95
95
  if (agent.prompt) args.push(agent.prompt);
96
96
 
97
97
  this._currentModel = agent.model;
98
+ this._sessionInputTokens = 0;
98
99
 
99
100
  return {
100
101
  command: 'codex',
@@ -109,6 +110,11 @@ export class CodexProvider extends Provider {
109
110
  return { command: 'codex', args, env: {} };
110
111
  }
111
112
 
113
+ _getMaxContext() {
114
+ const model = CodexProvider.models.find((m) => m.id === this._currentModel);
115
+ return model?.maxContext || 200000;
116
+ }
117
+
112
118
  switchModel(agent, newModel) {
113
119
  return false; // Codex doesn't support mid-session model switch
114
120
  }
@@ -175,36 +181,48 @@ export class CodexProvider extends Provider {
175
181
 
176
182
  case 'item.completed': {
177
183
  const item = event.item || {};
184
+
185
+ // Accumulate usage for intermediate context estimation.
186
+ // Codex only reports full contextUsage at turn.completed — without this,
187
+ // the rotator sees stale contextUsage between turns and never triggers.
188
+ if (event.usage) {
189
+ this._sessionInputTokens += event.usage.input_tokens || 0;
190
+ }
191
+
192
+ let result = null;
178
193
  if (item.type === 'agent_message') {
179
- return {
194
+ result = {
180
195
  type: 'activity', subtype: 'assistant',
181
196
  data: [{ type: 'text', text: item.text || '' }],
182
197
  };
183
- }
184
- if (item.type === 'command_execution') {
198
+ } else if (item.type === 'command_execution') {
185
199
  const output = (item.aggregated_output || '').slice(0, 2000);
186
- return {
200
+ result = {
187
201
  type: 'activity', subtype: 'assistant',
188
202
  data: [
189
203
  { type: 'tool_use', id: item.id || 'exec', name: 'Bash', input: { command: item.command } },
190
204
  ...(output ? [{ type: 'text', text: output }] : []),
191
205
  ],
192
206
  };
193
- }
194
- if (item.type === 'todo_list') {
207
+ } else if (item.type === 'todo_list') {
195
208
  const steps = (item.items || []).map((s) => `${s.completed ? '✓' : '○'} ${s.text}`).join('\n');
196
- return {
209
+ result = {
197
210
  type: 'activity', subtype: 'assistant',
198
211
  data: [{ type: 'text', text: steps }],
199
212
  };
200
- }
201
- if (item.type === 'file_edit' || item.type === 'file_write' || item.type === 'file_read') {
202
- return {
213
+ } else if (item.type === 'file_edit' || item.type === 'file_write' || item.type === 'file_read') {
214
+ result = {
203
215
  type: 'activity', subtype: 'assistant',
204
216
  data: [{ type: 'tool_use', id: item.id || 'file', name: item.type === 'file_read' ? 'Read' : item.type === 'file_write' ? 'Write' : 'Edit', input: { path: item.path || item.file || '' } }],
205
217
  };
206
218
  }
207
- return null;
219
+
220
+ // Attach intermediate context estimate so all 7 layers see Codex progress
221
+ if (result && this._sessionInputTokens > 0) {
222
+ result.contextUsage = this._sessionInputTokens / this._getMaxContext();
223
+ }
224
+
225
+ return result;
208
226
  }
209
227
 
210
228
  case 'turn.completed': {
@@ -215,11 +233,15 @@ export class CodexProvider extends Provider {
215
233
  const outputTokens = usage.output_tokens || 0;
216
234
  const cachedTokens = usage.cached_input_tokens || 0;
217
235
  const totalTokens = inputTokens + outputTokens;
236
+ const cacheCreationTokens = cachedTokens > 0 ? Math.max(0, inputTokens - cachedTokens) : 0;
218
237
 
219
238
  const model = CodexProvider.models.find((m) => m.id === this._currentModel);
220
239
  const pricing = model?.pricing;
221
240
  const maxContext = model?.maxContext || 200000;
222
241
 
242
+ // Sync accumulator to actual cumulative value from turn completion
243
+ this._sessionInputTokens = inputTokens;
244
+
223
245
  let estimatedCostUsd = 0;
224
246
  if (pricing) {
225
247
  const newInput = inputTokens - cachedTokens;
@@ -235,6 +257,7 @@ export class CodexProvider extends Provider {
235
257
  inputTokens,
236
258
  outputTokens,
237
259
  cacheReadTokens: cachedTokens,
260
+ cacheCreationTokens,
238
261
  contextUsage: inputTokens / maxContext,
239
262
  estimatedCostUsd,
240
263
  costSource: pricing ? 'calculated' : 'estimated',
@@ -31,6 +31,7 @@ export class Rotator extends EventEmitter {
31
31
  this.rotationHistory = [];
32
32
  this.rotating = new Set();
33
33
  this.lastRotationTime = new Map(); // agentId -> timestamp of last rotation
34
+ this._lastContextState = new Map(); // agentId -> { contextUsage, timestamp }
34
35
  this.enabled = false;
35
36
  this.liveScores = {};
36
37
  this.scoreHistory = {};
@@ -180,6 +181,25 @@ export class Rotator extends EventEmitter {
180
181
  continue;
181
182
  }
182
183
 
184
+ // Stale context fallback — safety net for providers (like Codex) that don't
185
+ // report intermediate contextUsage. If contextUsage hasn't changed in 120+
186
+ // seconds but tokens are being consumed, estimate from total tokens.
187
+ const knownCtx = this._lastContextState.get(agent.id);
188
+ if (!knownCtx || knownCtx.contextUsage !== agent.contextUsage) {
189
+ this._lastContextState.set(agent.id, { contextUsage: agent.contextUsage, timestamp: Date.now() });
190
+ } else if (agent.tokensUsed > 0 && (Date.now() - knownCtx.timestamp) >= 120_000) {
191
+ const providerClass = getProvider(agent.provider)?.constructor;
192
+ const models = providerClass?.models || [];
193
+ const model = models.find((m) => m.id === agent.model) || models[0];
194
+ const maxContext = model?.maxContext || 200000;
195
+ const estimatedContext = agent.tokensUsed / maxContext;
196
+ if (estimatedContext >= HARD_CEILING) {
197
+ console.log(` Rotator: ${agent.name} estimated context ${Math.round(estimatedContext * 100)}% (stale contextUsage fallback)`);
198
+ await this.rotate(agent.id, { reason: 'estimated_context_ceiling' });
199
+ continue;
200
+ }
201
+ }
202
+
183
203
  // Cooldown — skip threshold/quality rotation if recently rotated
184
204
  if (this._isOnCooldown(agent.id)) continue;
185
205
 
@@ -274,7 +294,7 @@ export class Rotator extends EventEmitter {
274
294
  oldTokens: agent.tokensUsed,
275
295
  contextUsage: agent.contextUsage,
276
296
  brief: brief.slice(0, 4000),
277
- }, agent.workingDir);
297
+ }, agent.workingDir, agent.teamId);
278
298
  }
279
299
 
280
300
  const record = {
@@ -312,6 +332,7 @@ export class Rotator extends EventEmitter {
312
332
  workingDir: agent.workingDir,
313
333
  name: agent.name,
314
334
  teamId: agent.teamId,
335
+ isRotation: true,
315
336
  });
316
337
  } catch (spawnErr) {
317
338
  // Spawn failed — re-add old agent so the user can see and retry.
@@ -499,6 +520,7 @@ export class Rotator extends EventEmitter {
499
520
  const naturalCompactions = this.rotationHistory.filter((r) => r.reason === 'natural_compaction').length;
500
521
  const hardCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'hard_ceiling').length;
501
522
  const tokenCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'token_ceiling').length;
523
+ const estimatedCeilingRotations = this.rotationHistory.filter((r) => r.reason === 'estimated_context_ceiling').length;
502
524
  return {
503
525
  enabled: this.enabled,
504
526
  totalRotations,
@@ -508,6 +530,7 @@ export class Rotator extends EventEmitter {
508
530
  naturalCompactions,
509
531
  hardCeilingRotations,
510
532
  tokenCeilingRotations,
533
+ estimatedCeilingRotations,
511
534
  rotating: Array.from(this.rotating),
512
535
  liveScores: this.liveScores,
513
536
  scoreHistory: this.scoreHistory,
@@ -163,6 +163,69 @@ describe('Introducer', () => {
163
163
  });
164
164
  });
165
165
 
166
+ describe('hasTask gate — cross-team contamination fix', () => {
167
+ it('should NOT inject handoffs for agents spawned without a prompt', () => {
168
+ const grooveDir = join(tmpDir, '.groove');
169
+ mkdirSync(grooveDir, { recursive: true });
170
+ const memory = new MemoryStore(grooveDir);
171
+ memory.appendHandoffBrief('planner', {
172
+ brief: 'Old team planner work that should not leak',
173
+ reason: 'completed',
174
+ });
175
+ memory.addDiscovery({ role: 'planner', trigger: 'some error', fix: 'some fix', outcome: 'success' });
176
+ memory.addConstraint({ text: 'Always use ESM', category: 'pattern' });
177
+
178
+ const daemon = { registry, projectDir: tmpDir, grooveDir, memory };
179
+ const intro = new Introducer(daemon);
180
+
181
+ const agent = registry.add({ role: 'planner', scope: [] });
182
+ // hasTask=false, isRotation=false — simulates a fresh spawn with no prompt
183
+ const ctx = intro.generateContext(agent, { hasTask: false, isRotation: false });
184
+
185
+ // Constraints should ALWAYS be injected (project knowledge)
186
+ assert.ok(ctx.includes('Always use ESM'), 'constraints should always be injected');
187
+ // Handoffs and discoveries should NOT be injected
188
+ assert.ok(!ctx.includes('Old team planner work'), 'handoffs must not leak to promptless agents');
189
+ assert.ok(!ctx.includes('Known Fixes'), 'discoveries must not be injected for promptless agents');
190
+ });
191
+
192
+ it('should inject handoffs for agents spawned WITH a prompt (hasTask=true)', () => {
193
+ const grooveDir = join(tmpDir, '.groove');
194
+ mkdirSync(grooveDir, { recursive: true });
195
+ const memory = new MemoryStore(grooveDir);
196
+ memory.appendHandoffBrief('backend', {
197
+ brief: 'Previous backend handoff',
198
+ reason: 'completed',
199
+ });
200
+
201
+ const daemon = { registry, projectDir: tmpDir, grooveDir, memory };
202
+ const intro = new Introducer(daemon);
203
+
204
+ const agent = registry.add({ role: 'backend', scope: [] });
205
+ const ctx = intro.generateContext(agent, { hasTask: true });
206
+
207
+ assert.ok(ctx.includes('Recent Handoff History'), 'handoffs should be injected when hasTask=true');
208
+ });
209
+
210
+ it('should inject handoffs for rotation replacements (isRotation=true)', () => {
211
+ const grooveDir = join(tmpDir, '.groove');
212
+ mkdirSync(grooveDir, { recursive: true });
213
+ const memory = new MemoryStore(grooveDir);
214
+ memory.appendHandoffBrief('backend', {
215
+ brief: 'Rotation handoff content',
216
+ reason: 'context_threshold',
217
+ });
218
+
219
+ const daemon = { registry, projectDir: tmpDir, grooveDir, memory };
220
+ const intro = new Introducer(daemon);
221
+
222
+ const agent = registry.add({ role: 'backend', scope: [] });
223
+ const ctx = intro.generateContext(agent, { hasTask: false, isRotation: true });
224
+
225
+ assert.ok(ctx.includes('Recent Handoff History'), 'handoffs should be injected for rotations');
226
+ });
227
+ });
228
+
166
229
  describe('CLAUDE.md injection', () => {
167
230
  it('should inject GROOVE section into existing CLAUDE.md', () => {
168
231
  writeFileSync(join(tmpDir, 'CLAUDE.md'), '# My Project\n\nSome content.\n');
@@ -311,6 +311,112 @@ describe('Journalist', () => {
311
311
  });
312
312
  });
313
313
 
314
+ describe('requestSynthesis', () => {
315
+ it('should debounce multiple calls within 10s into a single cycle', async () => {
316
+ const { daemon } = createMockDaemon();
317
+ const journalist = new Journalist(daemon);
318
+
319
+ let cycleCalls = 0;
320
+ journalist.cycle = async () => { cycleCalls++; };
321
+
322
+ journalist.requestSynthesis('completion');
323
+ journalist.requestSynthesis('spawn');
324
+ journalist.requestSynthesis('rotation');
325
+
326
+ assert.equal(cycleCalls, 0, 'cycle should not fire immediately');
327
+
328
+ // Wait for debounce to fire
329
+ await new Promise((r) => setTimeout(r, 11_000));
330
+ assert.equal(cycleCalls, 1, 'only one cycle should fire after debounce');
331
+
332
+ journalist.stop();
333
+ });
334
+
335
+ it('should track the latest reason', () => {
336
+ const { daemon } = createMockDaemon();
337
+ const journalist = new Journalist(daemon);
338
+ journalist.cycle = async () => {};
339
+
340
+ journalist.requestSynthesis('completion');
341
+ journalist.requestSynthesis('rotation');
342
+
343
+ assert.equal(journalist._debounceReason, 'rotation');
344
+ journalist.stop();
345
+ });
346
+ });
347
+
348
+ describe('ensureFresh', () => {
349
+ it('should skip synthesis when lastCycleAt is recent', async () => {
350
+ const { daemon } = createMockDaemon();
351
+ const journalist = new Journalist(daemon);
352
+
353
+ let cycleCalled = false;
354
+ journalist.cycle = async () => { cycleCalled = true; };
355
+ journalist.lastCycleAt = Date.now() - 5000; // 5s ago
356
+
357
+ await journalist.ensureFresh(30000);
358
+ assert.equal(cycleCalled, false, 'should skip when recent');
359
+ });
360
+
361
+ it('should trigger synthesis when lastCycleAt is stale', async () => {
362
+ const { daemon } = createMockDaemon();
363
+ const journalist = new Journalist(daemon);
364
+
365
+ let cycleCalled = false;
366
+ journalist.cycle = async () => { cycleCalled = true; };
367
+ journalist.lastCycleAt = Date.now() - 60_000; // 60s ago
368
+
369
+ await journalist.ensureFresh(30000);
370
+ assert.equal(cycleCalled, true, 'should trigger when stale');
371
+ });
372
+
373
+ it('should trigger synthesis when lastCycleAt is null', async () => {
374
+ const { daemon } = createMockDaemon();
375
+ const journalist = new Journalist(daemon);
376
+
377
+ let cycleCalled = false;
378
+ journalist.cycle = async () => { cycleCalled = true; };
379
+
380
+ await journalist.ensureFresh(30000);
381
+ assert.equal(cycleCalled, true, 'should trigger when never synthesized');
382
+ });
383
+ });
384
+
385
+ describe('model tier selection', () => {
386
+ it('should prefer medium tier over light tier for synthesis', () => {
387
+ // Verify the callHeadless model selection logic by checking the source
388
+ // The constructor picks medium first, falling back to light
389
+ const { daemon } = createMockDaemon();
390
+ const journalist = new Journalist(daemon);
391
+
392
+ // Simulate provider model selection logic matching callHeadless
393
+ const models = [
394
+ { id: 'haiku', tier: 'light' },
395
+ { id: 'sonnet', tier: 'medium' },
396
+ { id: 'opus', tier: 'heavy' },
397
+ ];
398
+
399
+ const selected = models.find((m) => m.tier === 'medium')
400
+ || models.find((m) => m.tier === 'light')
401
+ || models[0];
402
+
403
+ assert.equal(selected.id, 'sonnet', 'should select medium tier (Sonnet)');
404
+ });
405
+
406
+ it('should fall back to light tier when medium is unavailable', () => {
407
+ const models = [
408
+ { id: 'haiku', tier: 'light' },
409
+ { id: 'opus', tier: 'heavy' },
410
+ ];
411
+
412
+ const selected = models.find((m) => m.tier === 'medium')
413
+ || models.find((m) => m.tier === 'light')
414
+ || models[0];
415
+
416
+ assert.equal(selected.id, 'haiku', 'should fall back to light tier');
417
+ });
418
+ });
419
+
314
420
  describe('_extractConstraints', () => {
315
421
  it('should no-op since auto-extraction is disabled', () => {
316
422
  const { daemon, grooveDir } = createMockDaemon();
@@ -181,6 +181,55 @@ describe('MemoryStore', () => {
181
181
  assert.ok(!rootRoles.includes('frontend'));
182
182
  assert.ok(wsRoles.includes('frontend'));
183
183
  });
184
+
185
+ it('scopes chains by teamId — different teams produce different files', () => {
186
+ memory.appendHandoffBrief('planner', { brief: 'team-A planner work' }, null, 'team-A');
187
+ memory.appendHandoffBrief('planner', { brief: 'team-B planner work' }, null, 'team-B');
188
+
189
+ const chainA = memory.getHandoffChain('planner', null, 'team-A');
190
+ const chainB = memory.getHandoffChain('planner', null, 'team-B');
191
+ assert.equal(chainA.length, 1);
192
+ assert.equal(chainB.length, 1);
193
+ assert.match(chainA[0].body, /team-A planner/);
194
+ assert.match(chainB[0].body, /team-B planner/);
195
+ });
196
+
197
+ it('team-scoped chains are independent from root chains', () => {
198
+ memory.appendHandoffBrief('planner', { brief: 'root planner work' });
199
+ memory.appendHandoffBrief('planner', { brief: 'scoped planner work' }, null, 'team-X');
200
+
201
+ const rootChain = memory.getHandoffChain('planner');
202
+ const teamChain = memory.getHandoffChain('planner', null, 'team-X');
203
+ assert.equal(rootChain.length, 1);
204
+ assert.equal(teamChain.length, 1);
205
+ assert.match(rootChain[0].body, /root planner/);
206
+ assert.match(teamChain[0].body, /scoped planner/);
207
+ });
208
+
209
+ it('teamId takes priority over workingDir for chain path', () => {
210
+ const ws = join(tmpDir, '..', 'some-workspace');
211
+ memory.appendHandoffBrief('backend', { brief: 'team-scoped' }, ws, 'team-Z');
212
+ memory.appendHandoffBrief('backend', { brief: 'ws-scoped' }, ws);
213
+
214
+ const teamChain = memory.getHandoffChain('backend', ws, 'team-Z');
215
+ const wsChain = memory.getHandoffChain('backend', ws);
216
+ assert.equal(teamChain.length, 1);
217
+ assert.equal(wsChain.length, 1);
218
+ assert.match(teamChain[0].body, /team-scoped/);
219
+ assert.match(wsChain[0].body, /ws-scoped/);
220
+ });
221
+
222
+ it('getRecentHandoffMarkdown respects teamId', () => {
223
+ memory.appendHandoffBrief('planner', { brief: 'team-1 brief' }, null, 'team-1');
224
+ memory.appendHandoffBrief('planner', { brief: 'team-2 brief' }, null, 'team-2');
225
+
226
+ const md1 = memory.getRecentHandoffMarkdown('planner', 3, 4000, null, 'team-1');
227
+ const md2 = memory.getRecentHandoffMarkdown('planner', 3, 4000, null, 'team-2');
228
+ assert.match(md1, /team-1 brief/);
229
+ assert.ok(!md1.includes('team-2 brief'));
230
+ assert.match(md2, /team-2 brief/);
231
+ assert.ok(!md2.includes('team-1 brief'));
232
+ });
184
233
  });
185
234
 
186
235
  describe('discoveries', () => {