groove-dev 0.27.25 → 0.27.27
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.
- package/CLAUDE.md +0 -7
- package/SECURITY_SWEEP.md +228 -0
- package/node_modules/@groove-dev/cli/package.json +3 -3
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/introducer.js +7 -7
- package/node_modules/@groove-dev/daemon/src/journalist.js +36 -6
- package/node_modules/@groove-dev/daemon/src/memory.js +29 -10
- package/node_modules/@groove-dev/daemon/src/process.js +17 -12
- package/node_modules/@groove-dev/daemon/src/providers/codex.js +34 -11
- package/node_modules/@groove-dev/daemon/src/rotator.js +24 -1
- package/node_modules/@groove-dev/daemon/test/introducer.test.js +63 -0
- package/node_modules/@groove-dev/daemon/test/journalist.test.js +106 -0
- package/node_modules/@groove-dev/daemon/test/memory.test.js +49 -0
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +99 -0
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/package.json +3 -3
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/introducer.js +7 -7
- package/packages/daemon/src/journalist.js +36 -6
- package/packages/daemon/src/memory.js +29 -10
- package/packages/daemon/src/process.js +17 -12
- package/packages/daemon/src/providers/codex.js +34 -11
- package/packages/daemon/src/rotator.js +24 -1
- package/packages/gui/package.json +1 -1
- package/MUST_FIX_ISSUES.md +0 -305
|
@@ -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', () => {
|
|
@@ -335,6 +335,61 @@ describe('Rotator', () => {
|
|
|
335
335
|
assert.equal(tokenCeilingRotations.length, 0);
|
|
336
336
|
});
|
|
337
337
|
|
|
338
|
+
it('should trigger estimated_context_ceiling when contextUsage is stale', async () => {
|
|
339
|
+
// Simulates a Codex agent that never reports contextUsage updates.
|
|
340
|
+
// The agent has consumed 200K tokens (100% of maxContext) but contextUsage stayed at 0.
|
|
341
|
+
const agent = {
|
|
342
|
+
id: 'cx1', name: 'codex-stale', role: 'backend', status: 'running',
|
|
343
|
+
provider: 'codex', scope: [], model: 'gpt-5.4',
|
|
344
|
+
tokensUsed: 200_000, contextUsage: 0, workingDir: '/tmp',
|
|
345
|
+
lastActivity: new Date(Date.now() - 30_000).toISOString(),
|
|
346
|
+
spawnedAt: new Date(Date.now() - 300_000).toISOString(),
|
|
347
|
+
};
|
|
348
|
+
mockDaemon.registry.agents = [agent];
|
|
349
|
+
|
|
350
|
+
// First check — records the initial contextUsage state
|
|
351
|
+
await rotator.check();
|
|
352
|
+
assert.equal(rotator.getHistory().length, 0); // No rotation yet
|
|
353
|
+
|
|
354
|
+
// Simulate 120+ seconds of stale contextUsage by backdating the timestamp
|
|
355
|
+
const state = rotator._lastContextState.get('cx1');
|
|
356
|
+
state.timestamp = Date.now() - 130_000; // 130s ago
|
|
357
|
+
|
|
358
|
+
// Second check — stale context + high tokens should trigger estimated_context_ceiling
|
|
359
|
+
await rotator.check();
|
|
360
|
+
|
|
361
|
+
const history = rotator.getHistory();
|
|
362
|
+
assert.equal(history.length, 1);
|
|
363
|
+
assert.equal(history[0].reason, 'estimated_context_ceiling');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('should NOT trigger estimated_context_ceiling when tokens are below HARD_CEILING', async () => {
|
|
367
|
+
const agent = {
|
|
368
|
+
id: 'cx2', name: 'codex-low', role: 'backend', status: 'running',
|
|
369
|
+
provider: 'codex', scope: [], model: 'gpt-5.4',
|
|
370
|
+
tokensUsed: 50_000, contextUsage: 0, workingDir: '/tmp',
|
|
371
|
+
lastActivity: new Date(Date.now() - 30_000).toISOString(),
|
|
372
|
+
spawnedAt: new Date(Date.now() - 300_000).toISOString(),
|
|
373
|
+
};
|
|
374
|
+
mockDaemon.registry.agents = [agent];
|
|
375
|
+
|
|
376
|
+
// First check to record state
|
|
377
|
+
await rotator.check();
|
|
378
|
+
const state = rotator._lastContextState.get('cx2');
|
|
379
|
+
state.timestamp = Date.now() - 130_000;
|
|
380
|
+
|
|
381
|
+
// Second check — 50K/200K = 25%, below 80% ceiling
|
|
382
|
+
await rotator.check();
|
|
383
|
+
|
|
384
|
+
const history = rotator.getHistory();
|
|
385
|
+
assert.equal(history.length, 0);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('should include estimatedCeilingRotations in stats', () => {
|
|
389
|
+
const stats = rotator.getStats();
|
|
390
|
+
assert.equal(stats.estimatedCeilingRotations, 0);
|
|
391
|
+
});
|
|
392
|
+
|
|
338
393
|
it('should record lastRotationTime after successful rotation', async () => {
|
|
339
394
|
const agent = {
|
|
340
395
|
id: 'a3', name: 'backend-3', role: 'backend',
|
|
@@ -352,4 +407,48 @@ describe('Rotator', () => {
|
|
|
352
407
|
const elapsed = Date.now() - rotator.lastRotationTime.get(newAgent.id);
|
|
353
408
|
assert.ok(elapsed < 1000); // Should be very recent
|
|
354
409
|
});
|
|
410
|
+
|
|
411
|
+
it('should pass isRotation flag through spawn config', async () => {
|
|
412
|
+
let spawnConfig = null;
|
|
413
|
+
mockDaemon.processes.spawn = async (config) => {
|
|
414
|
+
spawnConfig = config;
|
|
415
|
+
return { id: 'new-' + config.role, name: config.name, ...config };
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const agent = {
|
|
419
|
+
id: 'a4', name: 'backend-4', role: 'backend',
|
|
420
|
+
provider: 'claude-code', scope: [], model: null,
|
|
421
|
+
tokensUsed: 3000, contextUsage: 0.9, workingDir: '/tmp',
|
|
422
|
+
teamId: 'team-1',
|
|
423
|
+
};
|
|
424
|
+
mockDaemon.registry.agents = [agent];
|
|
425
|
+
|
|
426
|
+
await rotator.rotate('a4');
|
|
427
|
+
|
|
428
|
+
assert.ok(spawnConfig, 'spawn should have been called');
|
|
429
|
+
assert.equal(spawnConfig.isRotation, true, 'isRotation must be true for rotation spawns');
|
|
430
|
+
assert.equal(spawnConfig.teamId, 'team-1', 'teamId must be passed through');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('should pass teamId to appendHandoffBrief', async () => {
|
|
434
|
+
let appendArgs = null;
|
|
435
|
+
mockDaemon.memory.appendHandoffBrief = (role, entry, workingDir, teamId) => {
|
|
436
|
+
appendArgs = { role, workingDir, teamId };
|
|
437
|
+
return true;
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const agent = {
|
|
441
|
+
id: 'a5', name: 'backend-5', role: 'backend',
|
|
442
|
+
provider: 'claude-code', scope: [], model: null,
|
|
443
|
+
tokensUsed: 3000, contextUsage: 0.9, workingDir: '/tmp',
|
|
444
|
+
teamId: 'team-alpha',
|
|
445
|
+
};
|
|
446
|
+
mockDaemon.registry.agents = [agent];
|
|
447
|
+
|
|
448
|
+
await rotator.rotate('a5');
|
|
449
|
+
|
|
450
|
+
assert.ok(appendArgs, 'appendHandoffBrief should have been called');
|
|
451
|
+
assert.equal(appendArgs.teamId, 'team-alpha');
|
|
452
|
+
assert.equal(appendArgs.role, 'backend');
|
|
453
|
+
});
|
|
355
454
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.27",
|
|
4
4
|
"description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@groove-dev/cli",
|
|
3
|
-
"version": "0.27.
|
|
4
|
-
"description": "GROOVE CLI
|
|
3
|
+
"version": "0.27.27",
|
|
4
|
+
"description": "GROOVE CLI — manage AI coding agents from your terminal",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -13,4 +13,4 @@
|
|
|
13
13
|
"chalk": "^5.3.0"
|
|
14
14
|
},
|
|
15
15
|
"private": true
|
|
16
|
-
}
|
|
16
|
+
}
|
|
@@ -14,7 +14,7 @@ export class Introducer {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
generateContext(newAgent, options = {}) {
|
|
17
|
-
const { taskNegotiation, hasTask } = options;
|
|
17
|
+
const { taskNegotiation, hasTask, isRotation } = options;
|
|
18
18
|
const agents = this.daemon.registry.getAll();
|
|
19
19
|
// Only include ACTIVE agents — not completed/killed ones from previous sessions
|
|
20
20
|
// Completed agents' work is captured in the journalist's project map, not here
|
|
@@ -353,13 +353,13 @@ export class Introducer {
|
|
|
353
353
|
parts.push(`### Constraints (read carefully)\n${constraints}`);
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
-
if (hasTask) {
|
|
357
|
-
const discoveries = this.daemon.memory.getDiscoveriesMarkdown(newAgent.role,
|
|
356
|
+
if (hasTask || isRotation) {
|
|
357
|
+
const discoveries = this.daemon.memory.getDiscoveriesMarkdown(newAgent.role, 8, 600, newAgent.scope);
|
|
358
358
|
if (discoveries) {
|
|
359
359
|
parts.push(`### Known Fixes for ${newAgent.role} Role\n${discoveries}`);
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
-
const handoffs = this.daemon.memory.getRecentHandoffMarkdown(newAgent.role, 2, 1000, newAgent.workingDir);
|
|
362
|
+
const handoffs = this.daemon.memory.getRecentHandoffMarkdown(newAgent.role, 2, 1000, newAgent.workingDir, newAgent.teamId);
|
|
363
363
|
if (handoffs) {
|
|
364
364
|
parts.push(`### Recent Handoff History\n${handoffs}`);
|
|
365
365
|
}
|
|
@@ -367,9 +367,9 @@ export class Introducer {
|
|
|
367
367
|
|
|
368
368
|
if (parts.length > 0) {
|
|
369
369
|
memorySection = `\n## Project Memory (auto-generated)\n\n${parts.join('\n\n')}\n`;
|
|
370
|
-
// Hard budget:
|
|
371
|
-
if (memorySection.length >
|
|
372
|
-
memorySection = memorySection.slice(0,
|
|
370
|
+
// Hard budget: 3K chars total
|
|
371
|
+
if (memorySection.length > 3000) {
|
|
372
|
+
memorySection = memorySection.slice(0, 2997) + '...';
|
|
373
373
|
}
|
|
374
374
|
}
|
|
375
375
|
}
|
|
@@ -6,8 +6,9 @@ import { resolve } from 'path';
|
|
|
6
6
|
import { execFile, spawn as cpSpawn } from 'child_process';
|
|
7
7
|
import { getProvider, getInstalledProviders } from './providers/index.js';
|
|
8
8
|
|
|
9
|
-
const DEFAULT_INTERVAL =
|
|
9
|
+
const DEFAULT_INTERVAL = 300_000; // 5 minutes (safety-net fallback; event-driven triggers handle the normal case)
|
|
10
10
|
const MAX_LOG_CHARS = 100_000; // ~25k tokens budget for synthesis input (captures 80-90% of recent activity)
|
|
11
|
+
const DEBOUNCE_MS = 10_000; // requestSynthesis debounce window
|
|
11
12
|
|
|
12
13
|
export class Journalist {
|
|
13
14
|
constructor(daemon) {
|
|
@@ -19,6 +20,8 @@ export class Journalist {
|
|
|
19
20
|
this.synthesizing = false;
|
|
20
21
|
this.lastSynthesis = null; // last synthesis result text
|
|
21
22
|
this.history = []; // recent synthesis summaries
|
|
23
|
+
this._debounceTimer = null;
|
|
24
|
+
this._debounceReason = null;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
start(intervalMs = DEFAULT_INTERVAL) {
|
|
@@ -51,6 +54,33 @@ export class Journalist {
|
|
|
51
54
|
clearInterval(this.interval);
|
|
52
55
|
this.interval = null;
|
|
53
56
|
}
|
|
57
|
+
if (this._debounceTimer) {
|
|
58
|
+
clearTimeout(this._debounceTimer);
|
|
59
|
+
this._debounceTimer = null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
requestSynthesis(reason = 'unknown') {
|
|
64
|
+
if (this._debounceTimer) {
|
|
65
|
+
this._debounceReason = reason;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
this._debounceReason = reason;
|
|
69
|
+
this._debounceTimer = setTimeout(() => {
|
|
70
|
+
const r = this._debounceReason;
|
|
71
|
+
this._debounceTimer = null;
|
|
72
|
+
this._debounceReason = null;
|
|
73
|
+
this.cycle().catch((err) => {
|
|
74
|
+
console.error(` Journalist requestSynthesis(${r}) failed:`, err.message);
|
|
75
|
+
});
|
|
76
|
+
}, DEBOUNCE_MS);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async ensureFresh(maxAgeMs = 30000) {
|
|
80
|
+
if (this.lastCycleAt && (Date.now() - this.lastCycleAt) < maxAgeMs) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
await this.cycle();
|
|
54
84
|
}
|
|
55
85
|
|
|
56
86
|
async cycle() {
|
|
@@ -454,11 +484,11 @@ export class Journalist {
|
|
|
454
484
|
}
|
|
455
485
|
const provider = getProvider(providerId);
|
|
456
486
|
|
|
457
|
-
// Pick
|
|
458
|
-
const
|
|
459
|
-
|| provider.constructor.models?.find((m) => m.tier === '
|
|
487
|
+
// Pick medium tier for higher-quality synthesis (fewer but better cycles)
|
|
488
|
+
const selectedModel = provider.constructor.models?.find((m) => m.tier === 'medium')
|
|
489
|
+
|| provider.constructor.models?.find((m) => m.tier === 'light')
|
|
460
490
|
|| provider.constructor.models?.[0];
|
|
461
|
-
const modelId =
|
|
491
|
+
const modelId = selectedModel?.id || null;
|
|
462
492
|
|
|
463
493
|
const headlessCmd = provider.buildHeadlessCommand(prompt, modelId);
|
|
464
494
|
const { command, args, env, stdin: stdinData } = headlessCmd;
|
|
@@ -749,7 +779,7 @@ export class Journalist {
|
|
|
749
779
|
// Pull recent rotation history from persistent memory (Layer 7).
|
|
750
780
|
// Gives the new agent causal continuity: what the last 3 agents struggled
|
|
751
781
|
// with, decided, and solved — not just what the current session did.
|
|
752
|
-
const recentChain = this.daemon.memory?.getRecentHandoffMarkdown(agent.role, 3, 3000, agent.workingDir) || '';
|
|
782
|
+
const recentChain = this.daemon.memory?.getRecentHandoffMarkdown(agent.role, 3, 3000, agent.workingDir, agent.teamId) || '';
|
|
753
783
|
|
|
754
784
|
// Pull the user's recent messages scoped to this agent
|
|
755
785
|
const agentFeedback = this.getUserFeedback(agent.id).slice(-5);
|
|
@@ -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
|
-
|
|
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
|
}
|