groove-dev 0.27.39 → 0.27.41

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 CHANGED
@@ -263,3 +263,10 @@ Audit-driven release. Multi-agent orchestration system with 7 coordination layer
263
263
  - Dashboard: routing donut, cache panel, context health gauges
264
264
  - Monitor/QC agent mode (stay active, loop)
265
265
  - Distribution: demo video, HN launch, Twitter content
266
+
267
+ <!-- GROOVE:START -->
268
+ ## GROOVE Orchestration (auto-injected)
269
+ Active agents: 0
270
+ See AGENTS_REGISTRY.md for full agent state.
271
+ **Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.
272
+ <!-- GROOVE:END -->
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.39",
3
+ "version": "0.27.41",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.39",
3
+ "version": "0.27.41",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -3,6 +3,11 @@
3
3
 
4
4
  import { readFileSync, writeFileSync, existsSync } from 'fs';
5
5
  import { resolve } from 'path';
6
+ import { minimatch } from 'minimatch';
7
+
8
+ // Treat these scope entries as "unrestricted" — agent can touch any file
9
+ // under its workingDir without counting as a scope violation.
10
+ const UNRESTRICTED_SCOPE_PATTERNS = new Set(['**', '**/*', '*', '']);
6
11
 
7
12
  const DEFAULT_THRESHOLD = 0.75;
8
13
  const NUDGE_UP = 0.02; // Good session → allow more context
@@ -186,14 +191,28 @@ export class AdaptiveThresholds {
186
191
  signals.toolFailures++;
187
192
  }
188
193
 
189
- // Scope violations: writes outside declared scope
194
+ // Scope violations: writes outside declared scope. Use real glob matching
195
+ // (the naive substring check flagged every write when scope was `["**"]`
196
+ // because `file.includes("**")` is always false — which tanked the
197
+ // quality score and triggered false-positive rotations). An unrestricted
198
+ // scope (`**`, `**/*`, empty pattern) skips the check entirely.
190
199
  if (agentScope && agentScope.length > 0 && entry.input) {
191
200
  if (entry.tool === 'Write' || entry.tool === 'Edit') {
192
201
  const file = entry.input;
193
- const inScope = agentScope.some((pattern) =>
194
- file.includes(pattern.replace('/**', '').replace('**/', ''))
195
- );
196
- if (!inScope) signals.scopeViolations++;
202
+ const unrestricted = agentScope.some((p) => UNRESTRICTED_SCOPE_PATTERNS.has(String(p).trim()));
203
+ if (!unrestricted) {
204
+ const inScope = agentScope.some((pattern) => {
205
+ try {
206
+ if (minimatch(file, pattern, { matchBase: true, dot: true })) return true;
207
+ // Also try matching the basename and any path suffix, since
208
+ // scope patterns are relative to the agent's workingDir and
209
+ // the recorded input may be absolute.
210
+ const idx = file.indexOf('/' + pattern.replace(/\/?\*\*\/?/g, '').replace(/^\//, ''));
211
+ return idx >= 0;
212
+ } catch { return true; } // if the pattern is malformed, don't penalize
213
+ });
214
+ if (!inScope) signals.scopeViolations++;
215
+ }
197
216
  }
198
217
  }
199
218
  }
@@ -2650,11 +2650,15 @@ Keep responses concise. Help them think, don't lecture them about the system the
2650
2650
  if (!found) {
2651
2651
  return res.status(404).json({ error: 'No recommended team found. Run a planner first.' });
2652
2652
  }
2653
+ const planPath = found.path;
2654
+ const planContents = readFileSync(planPath, 'utf8');
2653
2655
  try {
2654
- const raw = JSON.parse(readFileSync(found.path, 'utf8'));
2656
+ const raw = JSON.parse(planContents);
2655
2657
 
2656
- // Delete immediately after reading to prevent duplicate launches from poll races
2657
- try { unlinkSync(found.path); } catch { /* already gone */ }
2658
+ // Delete immediately after reading to prevent duplicate launches from poll races.
2659
+ // If every spawn below fails, we'll restore the plan from planContents so the
2660
+ // user can retry without re-prompting the planner.
2661
+ try { unlinkSync(planPath); } catch { /* already gone */ }
2658
2662
 
2659
2663
  // Support both old format (bare array) and new format ({ projectDir, agents, preview })
2660
2664
  let agentConfigs;
@@ -2834,6 +2838,14 @@ Keep responses concise. Help them think, don't lecture them about the system the
2834
2838
  daemon.preview.stashPlan(defaultTeamId, previewBlock, projectWorkingDir);
2835
2839
  }
2836
2840
 
2841
+ // Restore the plan if nothing actually spawned or was reused — deleting
2842
+ // it on a total failure leaves the team with no recovery path. A failed
2843
+ // spawn (scope collision, provider unavailable, etc.) should be retryable
2844
+ // once the user fixes the condition.
2845
+ if (spawned.length === 0 && reused.length === 0 && failed.length > 0) {
2846
+ try { writeFileSync(planPath, planContents); } catch { /* best-effort */ }
2847
+ }
2848
+
2837
2849
  daemon.audit.log('team.launch', {
2838
2850
  phase1: spawned.length, reused: reused.length, phase2Pending: phase2.length, failed: failed.length,
2839
2851
  agents: [...spawned, ...reused].map((a) => a.role), projectDir: projectDir || null, preview: !!previewBlock,
@@ -2868,23 +2880,28 @@ Keep responses concise. Help them think, don't lecture them about the system the
2868
2880
  res.json(result);
2869
2881
  });
2870
2882
 
2871
- // Clean up stale artifacts (old plans, recommended teams, etc.)
2883
+ // Clean up stale artifacts. Scope to a single team when teamId is provided —
2884
+ // wiping every agent's working dir on a global cleanup would delete other
2885
+ // in-flight teams' unlaunched plans. When called with no teamId, only the
2886
+ // daemon-root plan file is touched (safe baseline).
2872
2887
  app.post('/api/cleanup', (req, res) => {
2888
+ const teamId = req.body?.teamId || req.query?.teamId || null;
2873
2889
  let cleaned = 0;
2874
- // Clean recommended-team.json from all known locations
2875
2890
  const locations = [resolve(daemon.grooveDir, 'recommended-team.json')];
2876
- for (const agent of daemon.registry.getAll()) {
2877
- if (agent.workingDir) {
2878
- locations.push(resolve(agent.workingDir, '.groove', 'recommended-team.json'));
2891
+
2892
+ if (teamId) {
2893
+ // Only agents in this team get their workspace scanned
2894
+ for (const agent of daemon.registry.getAll()) {
2895
+ if (agent.teamId === teamId && agent.workingDir) {
2896
+ locations.push(resolve(agent.workingDir, '.groove', 'recommended-team.json'));
2897
+ }
2879
2898
  }
2880
2899
  }
2881
- const defaultDir = daemon.config?.defaultWorkingDir;
2882
- if (defaultDir) locations.push(resolve(defaultDir, '.groove', 'recommended-team.json'));
2883
2900
 
2884
2901
  for (const p of locations) {
2885
2902
  if (existsSync(p)) { try { unlinkSync(p); cleaned++; } catch { /* */ } }
2886
2903
  }
2887
- daemon.audit.log('cleanup', { cleaned });
2904
+ daemon.audit.log('cleanup', { cleaned, teamId });
2888
2905
  res.json({ ok: true, cleaned });
2889
2906
  });
2890
2907
 
@@ -19,6 +19,11 @@ import { lookup as mimeLookup } from './mimetypes.js';
19
19
 
20
20
  const READY_TIMEOUT_MS = 60_000; // give dev servers a minute to boot
21
21
  const MAX_STDOUT_BYTES = 256 * 1024;
22
+ // Strip CSI/OSC/other ANSI escape sequences — Vite prints URLs with inline
23
+ // bold/color codes (e.g. "http://localhost:\x1b[1m5175\x1b[22m/") which would
24
+ // otherwise break port-number regexes.
25
+ const ANSI_REGEX = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
26
+ function stripAnsi(s) { return s.replace(ANSI_REGEX, ''); }
22
27
 
23
28
  export class PreviewService {
24
29
  constructor(daemon) {
@@ -71,28 +76,46 @@ export class PreviewService {
71
76
  * preview upfront at launch time and hand it back when the team completes.
72
77
  */
73
78
  async launch(teamId, workingDir, preview) {
79
+ this.daemon.audit?.log('preview.attempt', { teamId, workingDir, preview });
80
+
74
81
  if (!preview || !preview.kind || preview.kind === 'none' || preview.kind === 'cli') {
75
- return { launched: false, reason: preview?.kind || 'no_preview' };
82
+ const result = { launched: false, reason: preview?.kind || 'no_preview' };
83
+ this.daemon.audit?.log('preview.skipped', { teamId, reason: result.reason });
84
+ return result;
76
85
  }
77
86
 
78
- // Kill any existing preview for this team
79
87
  await this.kill(teamId);
80
88
 
81
- const baseDir = preview.cwd
82
- ? resolve(workingDir || this.daemon.projectDir, preview.cwd)
83
- : resolve(workingDir || this.daemon.projectDir);
89
+ // Resolve cwd with a sensible fallback. The planner sometimes names the
90
+ // cwd after projectDir which is applied by api/launch → the actual project
91
+ // root. If that specific subdir doesn't exist, try workingDir itself.
92
+ const root = resolve(workingDir || this.daemon.projectDir);
93
+ const candidates = [];
94
+ if (preview.cwd) candidates.push(resolve(root, preview.cwd));
95
+ candidates.push(root);
96
+ const baseDir = candidates.find((p) => existsSync(p));
84
97
 
85
- if (!existsSync(baseDir)) {
86
- return { launched: false, reason: `cwd_missing: ${baseDir}` };
98
+ if (!baseDir) {
99
+ const result = { launched: false, reason: `cwd_missing: tried ${candidates.join(' and ')}` };
100
+ this.daemon.audit?.log('preview.failed', { teamId, reason: result.reason });
101
+ return result;
87
102
  }
88
103
 
104
+ let result;
89
105
  if (preview.kind === 'static-html') {
90
- return this._launchStatic(teamId, baseDir, preview);
106
+ result = await this._launchStatic(teamId, baseDir, preview);
107
+ } else if (preview.kind === 'dev-server') {
108
+ result = await this._launchDevServer(teamId, baseDir, preview);
109
+ } else {
110
+ result = { launched: false, reason: `unknown_kind: ${preview.kind}` };
91
111
  }
92
- if (preview.kind === 'dev-server') {
93
- return this._launchDevServer(teamId, baseDir, preview);
112
+
113
+ if (result.launched) {
114
+ this.daemon.audit?.log('preview.launched', { teamId, url: result.url, kind: result.kind, baseDir });
115
+ } else {
116
+ this.daemon.audit?.log('preview.failed', { teamId, reason: result.reason, baseDir });
94
117
  }
95
- return { launched: false, reason: `unknown_kind: ${preview.kind}` };
118
+ return result;
96
119
  }
97
120
 
98
121
  _launchStatic(teamId, baseDir, preview) {
@@ -168,11 +191,12 @@ export class PreviewService {
168
191
  };
169
192
 
170
193
  const timer = setTimeout(() => {
171
- finish({ launched: false, reason: `timeout waiting for url in stdout; last stderr: ${stderrBuf.slice(-400)}` });
194
+ const tail = stripAnsi(stderrBuf).slice(-400) || stripAnsi(stdoutBuf).slice(-400) || '(no output)';
195
+ finish({ launched: false, reason: `timeout waiting for url in stdout; last output: ${tail}` });
172
196
  }, READY_TIMEOUT_MS);
173
197
 
174
198
  const tryMatch = () => {
175
- const combined = stdoutBuf + '\n' + stderrBuf;
199
+ const combined = stripAnsi(stdoutBuf + '\n' + stderrBuf);
176
200
  if (readyText && !combined.includes(readyText)) return;
177
201
  const m = combined.match(urlPattern);
178
202
  if (!m) return;
@@ -232,6 +256,7 @@ export class PreviewService {
232
256
  if (entry.server) entry.server.close();
233
257
  if (entry.proc) entry.proc.kill('SIGTERM');
234
258
  } catch { /* best-effort */ }
259
+ this.daemon.audit?.log('preview.stopped', { teamId });
235
260
  this.daemon.broadcast({ type: 'preview:stopped', teamId });
236
261
  return true;
237
262
  }
@@ -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
388
  const conflict = locks.findOverlappingOwner(config.scope);
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
  }
@@ -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
 
@@ -10,8 +10,10 @@ const DEFAULT_THRESHOLD = 0.65; // For non-self-managing providers (was 0.7
10
10
  const HARD_CEILING = 0.80; // Force rotate (was 0.85) — only for non-self-managing
11
11
  const CHECK_INTERVAL = 15_000;
12
12
  const QUALITY_THRESHOLD = 40; // Score below this triggers quality rotation
13
- const MIN_EVENTS = 10; // Minimum classifier events before scoring
14
- const MIN_AGE_SEC = 120; // Minimum agent age before quality rotation
13
+ const MIN_EVENTS = 20; // Minimum classifier events before scoring
14
+ const MIN_AGE_SEC = 300; // Minimum agent age before quality rotation (5 min)
15
+ const QUALITY_MIN_TOKENS = 20_000; // Minimum real token work before quality rotation can fire
16
+ const QUALITY_MIN_FILES = 3; // Or: 3 successful file writes proves the agent is productive
15
17
  const SCORE_HISTORY_MAX = 40; // ~10 min at 15s intervals
16
18
  const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between rotations per agent
17
19
  const QUALITY_COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes for quality degradation rotations
@@ -128,7 +130,20 @@ export class Rotator extends EventEmitter {
128
130
  return { score: 70, signals: {}, hasEnoughData: false, ageSec: Math.round(ageSec), eventCount: events.length };
129
131
  }
130
132
 
131
- const signals = this.daemon.adaptive.extractSignals(events, agent.scope);
133
+ // Productive-work floor: don't even score an agent that hasn't produced
134
+ // enough to judge. A frontend agent scaffolding a project naturally emits
135
+ // noisy signals (npm install warnings, Write retries) in its first few
136
+ // minutes; killing it mid-scaffold destroys the context it was building.
137
+ // Only allow the score to gate rotation once EITHER substantial tokens
138
+ // have flowed OR the agent has already written multiple files successfully.
139
+ const tokens = agent.tokensUsed || 0;
140
+ const signalsEarly = this.daemon.adaptive.extractSignals(events, agent.scope);
141
+ const filesWritten = signalsEarly.filesWritten || 0;
142
+ if (tokens < QUALITY_MIN_TOKENS && filesWritten < QUALITY_MIN_FILES) {
143
+ return { score: 70, signals: signalsEarly, hasEnoughData: false, ageSec: Math.round(ageSec), eventCount: events.length, reason: 'below_productive_floor' };
144
+ }
145
+
146
+ const signals = signalsEarly;
132
147
  let score = this.daemon.adaptive.scoreSession(signals);
133
148
 
134
149
  if (ageSec > 1800) score -= 5;