groove-dev 0.27.40 → 0.27.42

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 (39) hide show
  1. package/analyist/groove-security-audit.md +323 -0
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/adaptive.js +24 -5
  5. package/node_modules/@groove-dev/daemon/src/api.js +26 -8
  6. package/node_modules/@groove-dev/daemon/src/lockmanager.js +22 -12
  7. package/node_modules/@groove-dev/daemon/src/preview.js +30 -11
  8. package/node_modules/@groove-dev/daemon/src/process.js +26 -13
  9. package/node_modules/@groove-dev/daemon/src/teams.js +38 -9
  10. package/node_modules/@groove-dev/daemon/src/tool-executor.js +1 -1
  11. package/node_modules/@groove-dev/daemon/test/teams.test.js +13 -3
  12. package/node_modules/@groove-dev/gui/dist/assets/{index-zzVaD3-G.js → index-C1C2biHU.js} +250 -250
  13. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  14. package/node_modules/@groove-dev/gui/package.json +1 -1
  15. package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +1 -1
  16. package/node_modules/@groove-dev/gui/src/stores/groove.js +10 -5
  17. package/node_modules/@groove-dev/gui/src/views/agents.jsx +4 -8
  18. package/node_modules/@groove-dev/gui/src/views/settings.jsx +13 -0
  19. package/node_modules/@groove-dev/gui/vite.config.js +0 -3
  20. package/package.json +2 -3
  21. package/packages/cli/package.json +1 -1
  22. package/packages/daemon/package.json +1 -1
  23. package/packages/daemon/src/adaptive.js +24 -5
  24. package/packages/daemon/src/api.js +26 -8
  25. package/packages/daemon/src/lockmanager.js +22 -12
  26. package/packages/daemon/src/preview.js +30 -11
  27. package/packages/daemon/src/process.js +26 -13
  28. package/packages/daemon/src/teams.js +38 -9
  29. package/packages/daemon/src/tool-executor.js +1 -1
  30. package/packages/gui/dist/assets/{index-zzVaD3-G.js → index-C1C2biHU.js} +250 -250
  31. package/packages/gui/dist/index.html +1 -1
  32. package/packages/gui/package.json +1 -1
  33. package/packages/gui/src/components/ui/toast.jsx +1 -1
  34. package/packages/gui/src/stores/groove.js +10 -5
  35. package/packages/gui/src/views/agents.jsx +4 -8
  36. package/packages/gui/src/views/settings.jsx +13 -0
  37. package/packages/gui/vite.config.js +0 -3
  38. package/node_modules/@groove-dev/gui/src/lib/edition.js +0 -4
  39. package/packages/gui/src/lib/edition.js +0 -4
@@ -377,18 +377,21 @@ export class ProcessManager {
377
377
  }
378
378
  }
379
379
 
380
- // Scope collision check: refuse to spawn if another running agent already
381
- // claims overlapping files. Oversight roles (planner, QC, security) and
382
- // the ambassador bypass since their job requires broad access.
380
+ // Scope collision check: refuse to spawn if another running agent in the
381
+ // SAME working directory already claims overlapping files. Scopes are
382
+ // relative patterns rooted at the agent's workingDir, so two agents in
383
+ // different workingDirs can't step on each other even if their patterns
384
+ // look identical. Oversight roles (planner, QC, security) and ambassadors
385
+ // bypass entirely since their job requires broad access.
383
386
  const SCOPE_BYPASS_ROLES = new Set(['planner', 'fullstack', 'qc', 'pm', 'supervisor', 'security', 'ambassador']);
384
387
  if (config.scope && config.scope.length > 0 && !SCOPE_BYPASS_ROLES.has(config.role) && !config.allowScopeOverlap) {
385
- const conflict = locks.findOverlappingOwner(config.scope);
388
+ const conflict = locks.findOverlappingOwner(config.scope, config.workingDir);
386
389
  if (conflict.overlap) {
387
390
  const owner = registry.get(conflict.owner);
388
- if (owner && owner.status === 'running') {
391
+ if (owner && owner.status === 'running' && owner.workingDir === config.workingDir) {
389
392
  const ownerScope = Array.isArray(conflict.ownerScope) ? conflict.ownerScope.join(', ') : '';
390
393
  throw new Error(
391
- `Scope collision: ${config.role} scope [${config.scope.join(', ')}] overlaps with ${owner.name} (${owner.role}) which owns [${ownerScope}]. ` +
394
+ `Scope collision: ${config.role} scope [${config.scope.join(', ')}] overlaps with ${owner.name} (${owner.role}) which owns [${ownerScope}] in the same workspace. ` +
392
395
  `Two agents cannot edit the same files. Either narrow the scope or wait for ${owner.name} to finish.`
393
396
  );
394
397
  }
@@ -460,7 +463,7 @@ export class ProcessManager {
460
463
 
461
464
  // Register file locks for the agent's scope
462
465
  if (agent.scope && agent.scope.length > 0) {
463
- locks.register(agent.id, agent.scope);
466
+ locks.register(agent.id, agent.scope, agent.workingDir);
464
467
  }
465
468
 
466
469
  // Register ambassador with federation system
@@ -1098,16 +1101,25 @@ For normal file edits within your scope, proceed without review.
1098
1101
  * - The daemon has a preview plan stashed for this team (planner wrote one).
1099
1102
  * - No pending phase 2 groups for this team (QC hasn't spawned yet).
1100
1103
  * - Every non-planner team agent is in a terminal state.
1101
- * - At least one non-planner agent completed successfully (something to preview).
1102
- * Clears the plan after launching so repeated completions don't re-fire.
1104
+ * - At least one non-planner agent completed successfully.
1105
+ *
1106
+ * The plan is intentionally NOT cleared on failure so the user can hit the
1107
+ * "Launch Preview" button manually after fixing whatever blocked the auto
1108
+ * attempt (wrong cwd, missing deps, etc). We guard against infinite re-fires
1109
+ * with _previewAttempted per teamId.
1103
1110
  */
1104
1111
  _checkPreviewReady(teamId) {
1105
1112
  const preview = this.daemon.preview;
1106
1113
  if (!preview) return;
1114
+ if (!this._previewAttempted) this._previewAttempted = new Set();
1115
+ if (this._previewAttempted.has(teamId)) return;
1116
+
1107
1117
  const plan = preview.getPlan(teamId);
1108
- if (!plan) return;
1118
+ if (!plan) {
1119
+ this.daemon.audit?.log('preview.skipped', { teamId, reason: 'no_plan_stashed' });
1120
+ return;
1121
+ }
1109
1122
 
1110
- // If a phase 2 group for this team is still pending, let it spawn first.
1111
1123
  const pendingPhase2 = this.daemon._pendingPhase2 || [];
1112
1124
  for (const group of pendingPhase2) {
1113
1125
  for (const id of group.waitFor) {
@@ -1123,7 +1135,7 @@ For normal file edits within your scope, proceed without review.
1123
1135
  const anyCompleted = teamAgents.some((a) => a.status === 'completed');
1124
1136
  if (!allDone || !anyCompleted) return;
1125
1137
 
1126
- preview.clearPlan(teamId);
1138
+ this._previewAttempted.add(teamId);
1127
1139
  const workingDir = plan.workingDir;
1128
1140
  preview.launch(teamId, workingDir, plan.preview).then((result) => {
1129
1141
  if (!result.launched) {
@@ -1137,6 +1149,7 @@ For normal file edits within your scope, proceed without review.
1137
1149
  }
1138
1150
  }).catch((err) => {
1139
1151
  console.error(`[Groove] Preview launch error for team ${teamId}:`, err.message);
1152
+ this.daemon.broadcast({ type: 'preview:failed', teamId, reason: err.message });
1140
1153
  });
1141
1154
  }
1142
1155
 
@@ -1510,7 +1523,7 @@ For normal file edits within your scope, proceed without review.
1510
1523
 
1511
1524
  // Re-register locks
1512
1525
  if (newAgent.scope && newAgent.scope.length > 0) {
1513
- locks.register(newAgent.id, newAgent.scope);
1526
+ locks.register(newAgent.id, newAgent.scope, newAgent.workingDir);
1514
1527
  }
1515
1528
 
1516
1529
  // Spawn the resumed process
@@ -34,14 +34,30 @@ export class Teams {
34
34
  }
35
35
 
36
36
  _ensureDefault() {
37
- const hasDefault = [...this.teams.values()].some((t) => t.isDefault);
38
- if (!hasDefault) {
37
+ const defaultDir = resolve(this.daemon.projectDir, 'default');
38
+ const existing = [...this.teams.values()].find((t) => t.isDefault);
39
+
40
+ if (!existing) {
41
+ try { mkdirSync(defaultDir, { recursive: true }); } catch { /* may exist */ }
39
42
  const id = randomUUID().slice(0, 8);
40
- const team = { id, name: 'Default', isDefault: true, createdAt: new Date().toISOString() };
41
- // Default team uses the project directory (no subdirectory)
42
- team.workingDir = this.daemon.projectDir;
43
+ const team = {
44
+ id,
45
+ name: 'Default',
46
+ isDefault: true,
47
+ workingDir: defaultDir,
48
+ createdAt: new Date().toISOString(),
49
+ };
43
50
  this.teams.set(id, team);
44
51
  this._save();
52
+ return;
53
+ }
54
+
55
+ // Migrate legacy default teams that pointed at the project root — give them
56
+ // their own folder so generated files don't pile up alongside source code.
57
+ if (!existing.workingDir || existing.workingDir === this.daemon.projectDir) {
58
+ try { mkdirSync(defaultDir, { recursive: true }); } catch { /* may exist */ }
59
+ existing.workingDir = defaultDir;
60
+ this._save();
45
61
  }
46
62
  }
47
63
 
@@ -125,12 +141,13 @@ export class Teams {
125
141
  }
126
142
 
127
143
  /**
128
- * Delete a team — removes directory and all contents, moves agents to default.
144
+ * Delete a team — kills its agents, removes its directory, drops it from the
145
+ * registry. Deleting the default team regenerates a fresh empty one so users
146
+ * can wipe accumulated state and keep working without restarting the daemon.
129
147
  */
130
148
  delete(id) {
131
149
  const team = this.teams.get(id);
132
150
  if (!team) throw new Error('Team not found');
133
- if (team.isDefault) throw new Error('Cannot delete the default team');
134
151
 
135
152
  // Kill any running agents in this team
136
153
  const agents = this.daemon.registry.getAll().filter((a) => a.teamId === id);
@@ -145,8 +162,13 @@ export class Teams {
145
162
  this.daemon.registry.remove(agent.id);
146
163
  }
147
164
 
148
- // Remove the working directory
149
- if (team.workingDir && !team.isDefault && existsSync(team.workingDir)) {
165
+ // Remove the team's working directory — refuse to nuke the project root
166
+ // (legacy default teams that were never migrated point there).
167
+ if (
168
+ team.workingDir &&
169
+ team.workingDir !== this.daemon.projectDir &&
170
+ existsSync(team.workingDir)
171
+ ) {
150
172
  try {
151
173
  rmSync(team.workingDir, { recursive: true, force: true });
152
174
  } catch (err) {
@@ -158,6 +180,13 @@ export class Teams {
158
180
  this._save();
159
181
  this.daemon.broadcast({ type: 'team:deleted', teamId: id });
160
182
 
183
+ // Always keep a default team available — regenerate one with a clean folder
184
+ if (team.isDefault) {
185
+ this._ensureDefault();
186
+ const fresh = this.getDefault();
187
+ if (fresh) this.daemon.broadcast({ type: 'team:created', team: fresh });
188
+ }
189
+
161
190
  // Clean up orphaned logs immediately — don't wait for the 24h GC cycle
162
191
  try { this.daemon._gc(); } catch { /* gc should never block deletion */ }
163
192
 
@@ -158,7 +158,7 @@ export class ToolExecutor {
158
158
  _checkWriteScope(resolvedPath) {
159
159
  if (!this.daemon?.locks) return;
160
160
  const rel = relative(this.workingDir, resolvedPath);
161
- const result = this.daemon.locks.check(this.agentId, rel);
161
+ const result = this.daemon.locks.check(this.agentId, rel, this.workingDir);
162
162
  if (result.conflict) {
163
163
  // Record conflict for supervisor + token savings
164
164
  if (this.daemon.supervisor) {
@@ -135,9 +135,19 @@ describe('Teams', () => {
135
135
  assert.equal(msg.teamId, team.id);
136
136
  });
137
137
 
138
- it('should throw when deleting the default team', () => {
139
- const d = teams.getDefault();
140
- assert.throws(() => teams.delete(d.id), /Cannot delete the default/);
138
+ it('should regenerate a fresh default team when the default is deleted', () => {
139
+ const original = teams.getDefault();
140
+ broadcasts.length = 0;
141
+
142
+ teams.delete(original.id);
143
+
144
+ const fresh = teams.getDefault();
145
+ assert.ok(fresh, 'a new default team should be created');
146
+ assert.notEqual(fresh.id, original.id);
147
+ assert.equal(fresh.isDefault, true);
148
+ assert.equal(teams.list().length, 1);
149
+ assert.ok(broadcasts.find((b) => b.type === 'team:deleted' && b.teamId === original.id));
150
+ assert.ok(broadcasts.find((b) => b.type === 'team:created' && b.team?.id === fresh.id));
141
151
  });
142
152
 
143
153
  it('should throw when deleting nonexistent team', () => {