groove-dev 0.27.156 → 0.27.159

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 (91) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/journalist.js +61 -16
  4. package/node_modules/@groove-dev/daemon/src/process.js +130 -2
  5. package/node_modules/@groove-dev/daemon/src/rotator.js +2 -1
  6. package/node_modules/@groove-dev/daemon/src/routes/files.js +28 -6
  7. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +89 -71
  8. package/node_modules/@groove-dev/gui/dist/assets/index-Bij9o_dc.js +1020 -0
  9. package/node_modules/@groove-dev/gui/dist/assets/index-Dzofq3wS.css +1 -0
  10. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  11. package/node_modules/@groove-dev/gui/package.json +1 -2
  12. package/node_modules/@groove-dev/gui/src/app.css +2 -2
  13. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +8 -8
  14. package/node_modules/@groove-dev/gui/src/components/agents/diff-viewer.jsx +2 -2
  15. package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +11 -2
  16. package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +2 -2
  17. package/node_modules/@groove-dev/gui/src/components/dashboard/token-chart.jsx +2 -2
  18. package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +1 -1
  19. package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +8 -1
  20. package/node_modules/@groove-dev/gui/src/components/network/activity-chart.jsx +4 -4
  21. package/node_modules/@groove-dev/gui/src/components/network/performance-dashboard.jsx +1 -1
  22. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +18 -6
  23. package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +122 -17
  24. package/node_modules/@groove-dev/gui/src/stores/groove.js +9 -1
  25. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +69 -38
  26. package/node_modules/@groove-dev/gui/src/views/memory.jsx +121 -49
  27. package/package.json +1 -1
  28. package/packages/cli/package.json +1 -1
  29. package/packages/daemon/package.json +1 -1
  30. package/packages/daemon/src/journalist.js +61 -16
  31. package/packages/daemon/src/process.js +130 -2
  32. package/packages/daemon/src/rotator.js +2 -1
  33. package/packages/daemon/src/routes/files.js +28 -6
  34. package/packages/daemon/src/tunnel-manager.js +89 -71
  35. package/packages/gui/dist/assets/index-Bij9o_dc.js +1020 -0
  36. package/packages/gui/dist/assets/index-Dzofq3wS.css +1 -0
  37. package/packages/gui/dist/index.html +2 -2
  38. package/packages/gui/package.json +1 -2
  39. package/packages/gui/src/app.css +2 -2
  40. package/packages/gui/src/components/agents/agent-feed.jsx +8 -8
  41. package/packages/gui/src/components/agents/diff-viewer.jsx +2 -2
  42. package/packages/gui/src/components/agents/workspace-mode.jsx +11 -2
  43. package/packages/gui/src/components/dashboard/cache-ring.jsx +2 -2
  44. package/packages/gui/src/components/dashboard/token-chart.jsx +2 -2
  45. package/packages/gui/src/components/editor/terminal.jsx +1 -1
  46. package/packages/gui/src/components/layout/welcome-splash.jsx +8 -1
  47. package/packages/gui/src/components/network/activity-chart.jsx +4 -4
  48. package/packages/gui/src/components/network/performance-dashboard.jsx +1 -1
  49. package/packages/gui/src/components/settings/quick-connect.jsx +18 -6
  50. package/packages/gui/src/components/settings/ssh-wizard.jsx +122 -17
  51. package/packages/gui/src/stores/groove.js +9 -1
  52. package/packages/gui/src/stores/slices/agents-slice.js +69 -38
  53. package/packages/gui/src/views/memory.jsx +121 -49
  54. package/ssh/error.png +0 -0
  55. package/node_modules/@fontsource-variable/jetbrains-mono/CHANGELOG.md +0 -2
  56. package/node_modules/@fontsource-variable/jetbrains-mono/LICENSE +0 -93
  57. package/node_modules/@fontsource-variable/jetbrains-mono/README.md +0 -48
  58. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-ext-wght-italic.woff2 +0 -0
  59. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-ext-wght-normal.woff2 +0 -0
  60. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-wght-italic.woff2 +0 -0
  61. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-wght-normal.woff2 +0 -0
  62. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-greek-wght-italic.woff2 +0 -0
  63. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-greek-wght-normal.woff2 +0 -0
  64. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-italic.woff2 +0 -0
  65. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-normal.woff2 +0 -0
  66. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-italic.woff2 +0 -0
  67. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  68. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-vietnamese-wght-italic.woff2 +0 -0
  69. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-vietnamese-wght-normal.woff2 +0 -0
  70. package/node_modules/@fontsource-variable/jetbrains-mono/index.css +0 -59
  71. package/node_modules/@fontsource-variable/jetbrains-mono/metadata.json +0 -29
  72. package/node_modules/@fontsource-variable/jetbrains-mono/package.json +0 -47
  73. package/node_modules/@fontsource-variable/jetbrains-mono/scss/metadata.scss +0 -46
  74. package/node_modules/@fontsource-variable/jetbrains-mono/scss/mixins.scss +0 -193
  75. package/node_modules/@fontsource-variable/jetbrains-mono/unicode.json +0 -8
  76. package/node_modules/@fontsource-variable/jetbrains-mono/wght-italic.css +0 -59
  77. package/node_modules/@fontsource-variable/jetbrains-mono/wght.css +0 -59
  78. package/node_modules/@groove-dev/gui/dist/assets/index-COQYX12F.js +0 -1015
  79. package/node_modules/@groove-dev/gui/dist/assets/index-Diw6wDPU.css +0 -1
  80. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  81. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  82. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  83. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  84. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  85. package/packages/gui/dist/assets/index-COQYX12F.js +0 -1015
  86. package/packages/gui/dist/assets/index-Diw6wDPU.css +0 -1
  87. package/packages/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  88. package/packages/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  89. package/packages/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  90. package/packages/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  91. package/packages/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.156",
3
+ "version": "0.27.159",
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.156",
3
+ "version": "0.27.159",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -483,6 +483,9 @@ export class Journalist {
483
483
  'Be specific. Name files, functions, and line numbers. Do not summarize vaguely.',
484
484
  'Preserve the investigation narrative — the next agent needs to understand the',
485
485
  'journey, not just the destination.',
486
+ 'IMPORTANT: Focus on what the user ASKED the agent to do (the original task).',
487
+ 'Do NOT include observations about unrelated code issues, potential improvements,',
488
+ 'or things the agent noticed but did not act on. The next agent must stay on task.',
486
489
  'Keep your response under 4000 characters.',
487
490
  '',
488
491
  '---',
@@ -1024,7 +1027,13 @@ export class Journalist {
1024
1027
  originalTask ? `## Original Task\n\n${originalTask}\n` : '',
1025
1028
  ``,
1026
1029
  agent.role === 'planner' ? 'CRITICAL: You are a PLANNING ONLY agent. Do NOT implement code. Route all work to your team via .groove/recommended-team.json.\n' : '',
1027
- `Continue seamlessly — finish the work and deliver the output.`,
1030
+ `## Instructions`,
1031
+ ``,
1032
+ `Continue and finish the in-progress task — deliver the output. Stay focused on that specific task only.`,
1033
+ `- Do NOT explore the codebase looking for other things to fix or improve`,
1034
+ `- Do NOT start new work outside the original task scope`,
1035
+ `- Do NOT act on TODO comments, code quality issues, or improvements you notice in passing`,
1036
+ `- If the task is already complete, report what was accomplished and STOP — await new instructions from the user`,
1028
1037
  ].filter(Boolean).join('\n');
1029
1038
 
1030
1039
  // Hard cap: 16000 chars — investigation context needs room to preserve the full narrative
@@ -1142,34 +1151,60 @@ export class Journalist {
1142
1151
  * Build a full context-resume prompt that preserves the conversation
1143
1152
  * thread so a fresh agent picks up where the previous session left off.
1144
1153
  */
1145
- buildConversationResumePrompt(agent, userMessage) {
1154
+ buildConversationResumePrompt(agent, userMessage, { isRotation = false, reason } = {}) {
1146
1155
  const thread = this.extractConversationThread(agent);
1147
1156
  if (!thread) return null;
1148
1157
 
1149
1158
  const constraints = this.daemon.memory?.getConstraintsMarkdown(2000) || '';
1150
1159
  const discoveries = this.daemon.memory?.getDiscoveriesMarkdown(agent.role, 5, 1000, agent.scope, agent.teamId) || '';
1151
1160
 
1161
+ // Extract the user's original task from the conversation — the first substantial
1162
+ // user message is almost always the task assignment. This anchors the new agent.
1163
+ const originalTask = this._extractOriginalTask(thread);
1164
+
1165
+ // Rotation and idle-resume need very different framing. During rotation the agent
1166
+ // has no new user message — it must continue the exact in-progress task without
1167
+ // drifting. During idle-resume the user explicitly sent a message to continue.
1168
+ const isIdleResume = !isRotation && userMessage && userMessage.trim().length > 0;
1169
+
1170
+ const taskFocusBlock = isRotation ? [
1171
+ `## CRITICAL: Task Focus`,
1172
+ ``,
1173
+ `You were auto-rotated (reason: ${reason || 'context_management'}) — this is a routine context refresh, NOT a new assignment.`,
1174
+ originalTask ? `Your task: ${originalTask}` : '',
1175
+ ``,
1176
+ `Rules:`,
1177
+ `- Continue ONLY the task described in the conversation below`,
1178
+ `- Do NOT explore the codebase looking for other things to fix or improve`,
1179
+ `- Do NOT start new work that was not part of the original task`,
1180
+ `- Do NOT act on TODO comments, code quality issues, or improvements you notice in passing`,
1181
+ `- If the task is complete, report what was done and STOP — await new instructions from the user`,
1182
+ `- If the task is in progress, pick up exactly where the previous session left off`,
1183
+ ``,
1184
+ ] : [];
1185
+
1152
1186
  let prompt = [
1153
1187
  `# Session Context Resume`,
1154
1188
  ``,
1155
- `You are continuing a session that went idle. Below is the full conversation`,
1156
- `from your previous session your actual exchanges with the user. Pick up`,
1157
- `exactly where you left off. The user's new message follows at the end.`,
1189
+ isRotation
1190
+ ? `You are continuing a session after an automatic context rotation. Your context was refreshed but your task has NOT changed. The conversation below is your previous session — continue the same work.`
1191
+ : `You are continuing a session that went idle. Below is the full conversation from your previous session — your actual exchanges with the user. Pick up exactly where you left off. The user's new message follows at the end.`,
1158
1192
  ``,
1159
1193
  `Role: ${agent.role} | Provider: ${agent.provider} | Scope: ${agent.scope?.join(', ') || 'unrestricted'}`,
1160
1194
  agent.workingDir ? `Working directory: ${agent.workingDir}` : '',
1161
1195
  ``,
1196
+ ...taskFocusBlock,
1162
1197
  constraints ? `## Project Constraints\n\n${constraints}\n` : '',
1163
1198
  discoveries ? `## Known Issues & Fixes\n\n${discoveries}\n` : '',
1164
1199
  `## Previous Conversation\n\n${thread}`,
1165
1200
  ``,
1166
1201
  `---`,
1167
1202
  ``,
1168
- `## New Message From User`,
1169
- ``,
1170
- userMessage,
1203
+ isIdleResume ? `## New Message From User\n\n${userMessage}` : '',
1171
1204
  ``,
1172
- `Continue seamlessly from the conversation above. You have the full context of what was discussed, what was tried, what worked and what didn't. Do not ask the user to repeat anything.`,
1205
+ isRotation
1206
+ ? `Continue the in-progress task from the conversation above. Stay focused on that task only. Do not ask the user to repeat anything. If the task was already completed, state that and wait for new instructions.`
1207
+ : `Continue seamlessly from the conversation above. You have the full context of what was discussed, what was tried, what worked and what didn't. Do not ask the user to repeat anything.`,
1173
1208
  ].filter(Boolean).join('\n');
1174
1209
 
1175
1210
  // Hard cap at 80K chars (~20K tokens) to leave plenty of room in context window
@@ -1180,23 +1215,24 @@ export class Journalist {
1180
1215
  prompt = [
1181
1216
  `# Session Context Resume`,
1182
1217
  ``,
1183
- `You are continuing a session that went idle. Below is the conversation`,
1184
- `from your previous session (older turns summarized to fit). Pick up`,
1185
- `exactly where you left off.`,
1218
+ isRotation
1219
+ ? `You are continuing after an automatic context rotation. Your task has NOT changed.`
1220
+ : `You are continuing a session that went idle (older turns summarized to fit). Pick up exactly where you left off.`,
1186
1221
  ``,
1187
1222
  `Role: ${agent.role} | Scope: ${agent.scope?.join(', ') || 'unrestricted'}`,
1188
1223
  agent.workingDir ? `Working directory: ${agent.workingDir}` : '',
1189
1224
  ``,
1225
+ ...taskFocusBlock,
1190
1226
  constraints ? `## Project Constraints\n\n${constraints}\n` : '',
1191
1227
  `## Previous Conversation\n\n${smallerThread}`,
1192
1228
  ``,
1193
1229
  `---`,
1194
1230
  ``,
1195
- `## New Message From User`,
1196
- ``,
1197
- userMessage,
1231
+ isIdleResume ? `## New Message From User\n\n${userMessage}` : '',
1198
1232
  ``,
1199
- `Continue seamlessly. Do not ask the user to repeat anything.`,
1233
+ isRotation
1234
+ ? `Continue the in-progress task only. Do not explore or start new work. If done, state that and wait.`
1235
+ : `Continue seamlessly. Do not ask the user to repeat anything.`,
1200
1236
  ].filter(Boolean).join('\n');
1201
1237
  }
1202
1238
  }
@@ -1204,6 +1240,15 @@ export class Journalist {
1204
1240
  return prompt;
1205
1241
  }
1206
1242
 
1243
+ _extractOriginalTask(thread) {
1244
+ // Find the first substantial USER message in the thread — that's the task.
1245
+ const match = thread.match(/\[USER\]:\n([\s\S]*?)(?=\n\n---|\n\[CLAUDE\]:|$)/);
1246
+ if (!match) return '';
1247
+ const firstMsg = match[1].trim();
1248
+ if (firstMsg.length < 10) return '';
1249
+ return firstMsg.length > 500 ? firstMsg.slice(0, 500) + '...' : firstMsg;
1250
+ }
1251
+
1207
1252
  // --- Workspace Grouping ---
1208
1253
 
1209
1254
  /**
@@ -507,6 +507,7 @@ export class ProcessManager {
507
507
 
508
508
  if (finalStatus === 'completed' && agent.role === 'planner') {
509
509
  this._extractRecommendedTeam(agent, logPath);
510
+ this._consumeRecommendedTeamAutonomous(agent);
510
511
  this._handleReviewComplete(agent);
511
512
  }
512
513
 
@@ -534,7 +535,7 @@ export class ProcessManager {
534
535
 
535
536
  this._checkPhase2(agent.id);
536
537
 
537
- if (finalStatus === 'completed' && agent.role === 'fullstack' && agent.teamId) {
538
+ if (finalStatus === 'completed' && agent.role === 'fullstack' && agent.teamId && agent.metadata?.isQcPhase2) {
538
539
  this._triggerReview(agent);
539
540
  }
540
541
 
@@ -1755,6 +1756,114 @@ For normal file edits within your scope, proceed without review.
1755
1756
  } catch { /* best effort */ }
1756
1757
  }
1757
1758
 
1759
+ /**
1760
+ * Daemon-autonomous consumption of recommended-team.json.
1761
+ * If the file exists and the GUI hasn't already consumed it (no _pendingPhase2
1762
+ * for this team), broadcast a notification so the GUI picks it up on next tick.
1763
+ * This closes the race where GUI polling stops before the file is written.
1764
+ */
1765
+ _consumeRecommendedTeamAutonomous(agent) {
1766
+ try {
1767
+ const workDir = agent.workingDir || this.daemon.projectDir;
1768
+ const targetPath = resolve(workDir, '.groove', 'recommended-team.json');
1769
+ if (!existsSync(targetPath)) return;
1770
+
1771
+ const teamId = agent.teamId || null;
1772
+
1773
+ // If phase 2 is already pending for this team, GUI already consumed it
1774
+ const pending = this.daemon._pendingPhase2 || [];
1775
+ if (teamId && pending.some(g => g.agents.some(a => a.teamId === teamId))) return;
1776
+
1777
+ // Broadcast so the GUI knows to fetch — even if its polling interval was cleared
1778
+ this.daemon.broadcast({
1779
+ type: 'recommended-team:ready',
1780
+ teamId,
1781
+ agentId: agent.id,
1782
+ agentName: agent.name,
1783
+ });
1784
+
1785
+ // Delayed self-consumption: if the GUI doesn't consume within 5s, daemon does it directly
1786
+ setTimeout(() => {
1787
+ if (!existsSync(targetPath)) return; // GUI consumed it
1788
+ try {
1789
+ const raw = JSON.parse(readFileSync(targetPath, 'utf8'));
1790
+ delete raw._meta;
1791
+ const agentConfigs = Array.isArray(raw) ? raw : (raw.agents || []);
1792
+ if (agentConfigs.length === 0) return;
1793
+
1794
+ const phase1 = agentConfigs.filter(a => !a.phase || a.phase === 1);
1795
+ const phase2 = agentConfigs.filter(a => a.phase === 2);
1796
+ if (phase1.length === 0) return;
1797
+
1798
+ // Check again — GUI may have consumed during the timeout
1799
+ const currentPending = this.daemon._pendingPhase2 || [];
1800
+ if (teamId && currentPending.some(g => g.agents.some(a => a.teamId === teamId))) return;
1801
+
1802
+ const baseDir = agent.workingDir || this.daemon.projectDir;
1803
+ const projectDir = raw.projectDir || null;
1804
+ let projectWorkingDir = baseDir;
1805
+ if (projectDir) {
1806
+ const safeName = String(projectDir).replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 64);
1807
+ projectWorkingDir = resolve(baseDir, safeName);
1808
+ mkdirSync(projectWorkingDir, { recursive: true });
1809
+ }
1810
+
1811
+ const defaultTeamId = teamId || this.daemon.teams.getDefault()?.id || null;
1812
+ const phase1Ids = [];
1813
+
1814
+ // Spawn phase 1 agents
1815
+ const spawnPromises = phase1.map(async (config) => {
1816
+ try {
1817
+ const validated = validateAgentConfig({
1818
+ role: config.role,
1819
+ scope: config.scope || [],
1820
+ prompt: config.prompt || '',
1821
+ provider: config.provider || agent.provider || this.daemon.config?.defaultProvider,
1822
+ model: config.model || agent.model || this.daemon.config?.defaultModel || 'auto',
1823
+ permission: config.permission || 'auto',
1824
+ workingDir: config.workingDir || projectWorkingDir,
1825
+ name: config.name || undefined,
1826
+ });
1827
+ validated.teamId = defaultTeamId;
1828
+ const spawned = await this.spawn(validated);
1829
+ phase1Ids.push(spawned.id);
1830
+ return spawned;
1831
+ } catch (err) {
1832
+ console.error(`[Groove] Autonomous team launch: failed to spawn ${config.role}: ${err.message}`);
1833
+ return null;
1834
+ }
1835
+ });
1836
+
1837
+ Promise.all(spawnPromises).then(() => {
1838
+ // Set up phase 2 pending
1839
+ if (phase2.length > 0 && phase1Ids.length > 0) {
1840
+ this.daemon._pendingPhase2 = this.daemon._pendingPhase2 || [];
1841
+ this.daemon._pendingPhase2.push({
1842
+ waitFor: phase1Ids,
1843
+ agents: phase2.map(c => ({
1844
+ role: c.role, scope: c.scope || [], prompt: c.prompt || '',
1845
+ provider: c.provider || agent.provider || this.daemon.config?.defaultProvider,
1846
+ model: c.model || agent.model || this.daemon.config?.defaultModel || 'auto',
1847
+ permission: c.permission || 'auto',
1848
+ workingDir: c.workingDir || projectWorkingDir,
1849
+ name: c.name || undefined,
1850
+ teamId: defaultTeamId,
1851
+ })),
1852
+ });
1853
+ }
1854
+
1855
+ // Clean up the file
1856
+ try { unlinkSync(targetPath); } catch { /* */ }
1857
+ this.daemon.audit?.log('team.autonomousLaunch', { teamId: defaultTeamId, phase1: phase1Ids.length, phase2: phase2.length });
1858
+ console.log(`[Groove] Autonomous team launch: ${phase1Ids.length} phase 1 agents spawned for team ${defaultTeamId}`);
1859
+ });
1860
+ } catch (err) {
1861
+ console.error(`[Groove] Autonomous team consumption failed: ${err.message}`);
1862
+ }
1863
+ }, 5000);
1864
+ } catch { /* best effort */ }
1865
+ }
1866
+
1758
1867
  /**
1759
1868
  * Check if a completed/crashed agent was the last phase 1 agent in a team.
1760
1869
  * If so, auto-spawn the phase 2 (QC/finisher) agents.
@@ -1834,13 +1943,26 @@ For normal file edits within your scope, proceed without review.
1834
1943
  try {
1835
1944
  const validated = validateAgentConfig(config);
1836
1945
  if (!validated.teamId) validated.teamId = this.daemon.teams.getDefault()?.id || null;
1946
+ validated.metadata = { ...(validated.metadata || {}), isQcPhase2: true };
1947
+ const existingId = existing?.id || null;
1837
1948
  const p = this.spawn(validated).then((agent) => {
1949
+ registry.update(agent.id, { metadata: { ...(agent.metadata || {}), isQcPhase2: true } });
1838
1950
  this.daemon.broadcast({
1839
1951
  type: 'phase2:spawned',
1840
1952
  agentId: agent.id,
1953
+ oldAgentId: existingId,
1841
1954
  name: agent.name,
1842
1955
  role: agent.role,
1843
1956
  });
1957
+ if (existingId) {
1958
+ this.daemon.broadcast({
1959
+ type: 'rotation:complete',
1960
+ agentId: agent.id,
1961
+ oldAgentId: existingId,
1962
+ agentName: agent.name,
1963
+ reason: 'phase2_respawn',
1964
+ });
1965
+ }
1844
1966
  this.daemon.audit.log('phase2.autoSpawn', { id: agent.id, name: agent.name, role: agent.role });
1845
1967
  }).catch((err) => {
1846
1968
  console.error(`[Groove] Phase 2 spawn failed for ${config.role}: ${err.message}`);
@@ -1884,6 +2006,7 @@ For normal file edits within your scope, proceed without review.
1884
2006
  const teamId = agent.teamId;
1885
2007
  if (!teamId) return;
1886
2008
  if (this._reviewTriggered.has(teamId)) return;
2009
+ this._reviewTriggered.add(teamId);
1887
2010
 
1888
2011
  const registry = this.daemon.registry;
1889
2012
  const teamAgents = registry.getAll().filter(a => a.teamId === teamId);
@@ -1898,7 +2021,6 @@ For normal file edits within your scope, proceed without review.
1898
2021
  a.role !== 'planner' && (a.status === 'running' || a.status === 'starting'));
1899
2022
  if (hasRunning) return;
1900
2023
 
1901
- this._reviewTriggered.add(teamId);
1902
2024
  this._reviewPending.add(teamId);
1903
2025
 
1904
2026
  const journalist = this.daemon.journalist;
@@ -1912,6 +2034,12 @@ For normal file edits within your scope, proceed without review.
1912
2034
  for (const f of (journalist?.getAgentFiles(a) || [])) allFiles.add(f);
1913
2035
  }
1914
2036
 
2037
+ if (!originalSpec.trim() && !plannerResult.trim() && allFiles.size === 0) {
2038
+ console.log(`[Groove] Review skipped for team ${teamId}: no spec, plan, or files to review`);
2039
+ this._reviewPending.delete(teamId);
2040
+ return;
2041
+ }
2042
+
1915
2043
  const reviewPrompt = `You are reviewing a completed team build against the original specification.
1916
2044
 
1917
2045
  ## Original Task
@@ -367,7 +367,8 @@ export class Rotator extends EventEmitter {
367
367
  if (typeof journalist.buildConversationResumePrompt === 'function') {
368
368
  const conversationPrompt = journalist.buildConversationResumePrompt(
369
369
  agent,
370
- options.additionalPrompt || ''
370
+ options.additionalPrompt || '',
371
+ { isRotation: true, reason: options.reason || 'auto' }
371
372
  );
372
373
  if (conversationPrompt && conversationPrompt.length > 500) {
373
374
  brief = conversationPrompt;
@@ -665,24 +665,46 @@ export function registerFileRoutes(app, daemon) {
665
665
  const rawFiles = daemon.registry.getFilesTouched(req.params.id);
666
666
  const rootDir = agent.workingDir || daemon.projectDir;
667
667
 
668
- // Build git diff numstat for line-level +/- counts
668
+ // Build git diff numstat for line-level +/- counts (unstaged + staged + untracked)
669
669
  let numstatMap = {};
670
670
  const writtenPaths = rawFiles.filter(f => f.writes > 0).map(f => f.path);
671
671
  if (writtenPaths.length > 0) {
672
- try {
673
- const out = execFileSync('git', ['diff', '--numstat', '--', ...writtenPaths], {
674
- cwd: rootDir, timeout: 10000, maxBuffer: 2 * 1024 * 1024,
675
- }).toString();
672
+ const parseNumstat = (out) => {
676
673
  for (const line of out.split('\n')) {
677
674
  const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
678
- if (m) {
675
+ if (m && !numstatMap[m[3]]) {
679
676
  numstatMap[m[3]] = {
680
677
  additions: m[1] === '-' ? 0 : Number(m[1]),
681
678
  deletions: m[2] === '-' ? 0 : Number(m[2]),
682
679
  };
683
680
  }
684
681
  }
682
+ };
683
+ try {
684
+ const unstaged = execFileSync('git', ['diff', '--numstat', '--', ...writtenPaths], {
685
+ cwd: rootDir, timeout: 10000, maxBuffer: 2 * 1024 * 1024,
686
+ }).toString();
687
+ parseNumstat(unstaged);
685
688
  } catch { /* git not available or not a repo */ }
689
+ try {
690
+ const staged = execFileSync('git', ['diff', '--cached', '--numstat', '--', ...writtenPaths], {
691
+ cwd: rootDir, timeout: 10000, maxBuffer: 2 * 1024 * 1024,
692
+ }).toString();
693
+ parseNumstat(staged);
694
+ } catch { /* ignore */ }
695
+ // For untracked files not covered by diff, count lines as all additions
696
+ for (const p of writtenPaths) {
697
+ if (numstatMap[p]) continue;
698
+ const full = isAbsolute(p) ? p : resolve(rootDir, p);
699
+ try {
700
+ const stat = statSync(full);
701
+ if (stat.isFile()) {
702
+ const content = readFileSync(full, 'utf8');
703
+ const lineCount = content.split('\n').length;
704
+ numstatMap[p] = { additions: lineCount, deletions: 0 };
705
+ }
706
+ } catch { /* file may not exist */ }
707
+ }
686
708
  }
687
709
 
688
710
  const files = rawFiles.map(f => {