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.
- package/.groove-staging/state.json +3 -0
- package/.groove-staging/timeline.json +13 -0
- package/CLAUDE.md +0 -10
- package/DECENTRALIZED_NET_WP_V1.md +871 -0
- package/README.md +28 -0
- package/SECURITY_SWEEP.md +228 -0
- package/decentralized-net/ACTION_PLAN.md +422 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +99 -0
- 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 +29 -12
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +26 -1
- 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/dist/assets/{index-DieCV-v1.js → index-Ch1N9G4Z.js} +1728 -1728
- package/node_modules/@groove-dev/gui/dist/index.html +1 -1
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +147 -21
- package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +206 -44
- package/node_modules/@groove-dev/gui/src/components/marketplace/integration-wizard.jsx +11 -24
- package/node_modules/@groove-dev/gui/src/components/marketplace/marketplace-card.jsx +1 -36
- package/node_modules/@groove-dev/gui/src/lib/integration-logos.js +39 -0
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +99 -0
- 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 +29 -12
- package/packages/daemon/src/providers/claude-code.js +26 -1
- package/packages/daemon/src/providers/codex.js +34 -11
- package/packages/daemon/src/rotator.js +24 -1
- package/packages/gui/dist/assets/{index-DieCV-v1.js → index-Ch1N9G4Z.js} +1728 -1728
- package/packages/gui/dist/index.html +1 -1
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/agents/agent-config.jsx +147 -21
- package/packages/gui/src/components/agents/spawn-wizard.jsx +206 -44
- package/packages/gui/src/components/marketplace/integration-wizard.jsx +11 -24
- package/packages/gui/src/components/marketplace/marketplace-card.jsx +1 -36
- package/packages/gui/src/lib/integration-logos.js +39 -0
- 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
|
-
|
|
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
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
const
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
+
result = {
|
|
197
210
|
type: 'activity', subtype: 'assistant',
|
|
198
211
|
data: [{ type: 'text', text: steps }],
|
|
199
212
|
};
|
|
200
|
-
}
|
|
201
|
-
|
|
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
|
-
|
|
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', () => {
|