@yemi33/squad 0.1.20 → 0.1.21

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/dashboard.js CHANGED
@@ -151,9 +151,32 @@ function getAgents() {
151
151
  }
152
152
 
153
153
  function getPrdInfo() {
154
- // Squad-level PRD single file at ~/.squad/prd.json
155
- const prdPath = path.join(SQUAD_DIR, 'prd.json');
156
- if (!fs.existsSync(prdPath)) return { progress: null, status: null };
154
+ // All PRD items come from plans/*.json (both manual /prd entries and agent-generated plans)
155
+ const plansDir = path.join(SQUAD_DIR, 'plans');
156
+ let allPrdItems = [];
157
+ let latestStat = null;
158
+
159
+ try {
160
+ const planFiles = fs.readdirSync(plansDir).filter(f => f.endsWith('.json'));
161
+ for (const pf of planFiles) {
162
+ try {
163
+ const plan = JSON.parse(fs.readFileSync(path.join(plansDir, pf), 'utf8'));
164
+ if (!plan.missing_features) continue;
165
+ const stat = fs.statSync(path.join(plansDir, pf));
166
+ if (!latestStat || stat.mtimeMs > latestStat.mtimeMs) latestStat = stat;
167
+ (plan.missing_features || []).forEach(f => allPrdItems.push({
168
+ ...f, _source: pf, _planStatus: plan.status || 'active',
169
+ _planSummary: plan.plan_summary || pf,
170
+ }));
171
+ } catch {}
172
+ }
173
+ } catch {}
174
+
175
+ // Legacy prd.json removed — all PRD items now in plans/*.json
176
+
177
+ if (allPrdItems.length === 0) return { progress: null, status: null };
178
+
179
+ const items = allPrdItems;
157
180
 
158
181
  try {
159
182
  const stat = fs.statSync(prdPath);
@@ -197,12 +220,12 @@ function getPrdInfo() {
197
220
 
198
221
  const status = {
199
222
  exists: true,
200
- age: timeSince(stat.mtimeMs),
201
- existing: data.existing_features?.length || 0,
202
- missing: data.missing_features?.length || 0,
203
- questions: data.open_questions?.length || 0,
204
- summary: data.summary || '',
205
- missingList: (data.missing_features || []).map(f => ({ id: f.id, name: f.name || f.title, priority: f.priority, complexity: f.estimated_complexity || f.size })),
223
+ age: latestStat ? timeSince(latestStat.mtimeMs) : 'unknown',
224
+ existing: 0,
225
+ missing: items.filter(i => i.status === 'missing').length,
226
+ questions: 0,
227
+ summary: '',
228
+ missingList: items.filter(i => i.status === 'missing').map(f => ({ id: f.id, name: f.name || f.title, priority: f.priority, complexity: f.estimated_complexity || f.size })),
206
229
  };
207
230
 
208
231
  return { progress, status };
@@ -262,29 +285,8 @@ function getPullRequests() {
262
285
  }
263
286
 
264
287
  function getArchivedPrds() {
265
- const firstProject = PROJECTS[0];
266
- const root = path.resolve(firstProject.localPath || path.resolve(SQUAD_DIR, '..'));
267
- const prdSrc = firstProject.workSources?.prd || CONFIG.workSources?.prd || {};
268
- const prdDir = path.dirname(path.resolve(root, prdSrc.path || 'docs/prd-gaps.json'));
269
- const archiveDir = path.join(prdDir, 'archive');
270
- return safeReadDir(archiveDir)
271
- .filter(f => f.startsWith('prd-gaps') && f.endsWith('.json'))
272
- .map(f => {
273
- try {
274
- const data = JSON.parse(fs.readFileSync(path.join(archiveDir, f), 'utf8'));
275
- const items = data.missing_features || [];
276
- return {
277
- file: f,
278
- version: data.version || f.replace('prd-gaps-', '').replace('.json', ''),
279
- summary: data.summary || '',
280
- total: items.length,
281
- existing_features: data.existing_features || [],
282
- missing_features: items,
283
- open_questions: data.open_questions || [],
284
- };
285
- } catch { return null; }
286
- })
287
- .filter(Boolean);
288
+ // All PRD items now in plans/*.json — no separate archive needed
289
+ return [];
288
290
  }
289
291
 
290
292
  function getEngineState() {
@@ -788,24 +790,35 @@ const server = http.createServer(async (req, res) => {
788
790
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
789
791
  }
790
792
 
791
- // POST /api/prd-items — squad-level PRD
793
+ // POST /api/prd-items — create a PRD item as a plan file in plans/ (auto-approved)
792
794
  if (req.method === 'POST' && req.url === '/api/prd-items') {
793
795
  try {
794
796
  const body = await readBody(req);
795
797
  if (!body.name || !body.name.trim()) return jsonReply(res, 400, { error: 'name is required' });
796
- const prdPath = path.join(SQUAD_DIR, 'prd.json');
797
- let data = { missing_features: [], existing_features: [], open_questions: [] };
798
- const existing = safeRead(prdPath);
799
- if (existing) { try { data = JSON.parse(existing); } catch {} }
800
- if (!data.missing_features) data.missing_features = [];
801
- data.missing_features.push({
802
- id: body.id, name: body.name, description: body.description || '',
803
- priority: body.priority || 'medium', estimated_complexity: body.estimated_complexity || 'medium',
804
- rationale: body.rationale || '', status: 'missing', affected_areas: [],
805
- projects: body.projects || [],
806
- });
807
- safeWrite(prdPath, data);
808
- return jsonReply(res, 200, { ok: true, id: body.id });
798
+
799
+ const plansDir = path.join(SQUAD_DIR, 'plans');
800
+ if (!fs.existsSync(plansDir)) fs.mkdirSync(plansDir, { recursive: true });
801
+
802
+ const id = body.id || ('M' + String(Date.now()).slice(-4));
803
+ const planFile = 'manual-' + Date.now() + '.json';
804
+ const plan = {
805
+ version: 'manual-' + new Date().toISOString().slice(0, 10),
806
+ project: body.project || (PROJECTS[0]?.name || 'Unknown'),
807
+ generated_by: 'dashboard',
808
+ generated_at: new Date().toISOString().slice(0, 10),
809
+ plan_summary: body.name,
810
+ status: 'awaiting-approval',
811
+ requires_approval: true,
812
+ branch_strategy: 'parallel',
813
+ missing_features: [{
814
+ id, name: body.name, description: body.description || '',
815
+ priority: body.priority || 'medium', estimated_complexity: body.estimated_complexity || 'medium',
816
+ status: 'missing', depends_on: [], acceptance_criteria: [],
817
+ }],
818
+ open_questions: [],
819
+ };
820
+ safeWrite(path.join(plansDir, planFile), plan);
821
+ return jsonReply(res, 200, { ok: true, id, file: planFile });
809
822
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
810
823
  }
811
824
 
@@ -909,37 +922,63 @@ const server = http.createServer(async (req, res) => {
909
922
  return;
910
923
  }
911
924
 
912
- // GET /api/plans — list all plan files with status
925
+ // GET /api/plans — list all plan files (.json PRDs + .md raw plans)
913
926
  if (req.method === 'GET' && req.url === '/api/plans') {
914
927
  const plansDir = path.join(SQUAD_DIR, 'plans');
915
- const files = safeReadDir(plansDir).filter(f => f.endsWith('.json'));
916
- const plans = files.map(f => {
917
- const plan = JSON.parse(safeRead(path.join(plansDir, f)) || '{}');
918
- return {
919
- file: f,
920
- project: plan.project || '',
921
- summary: plan.plan_summary || '',
922
- status: plan.status || 'active',
923
- branchStrategy: plan.branch_strategy || 'parallel',
924
- featureBranch: plan.feature_branch || '',
925
- itemCount: (plan.missing_features || []).length,
926
- generatedBy: plan.generated_by || '',
927
- generatedAt: plan.generated_at || '',
928
- requiresApproval: plan.requires_approval || false,
929
- revisionFeedback: plan.revision_feedback || null,
930
- };
931
- }).sort((a, b) => b.generatedAt.localeCompare(a.generatedAt));
928
+ const allFiles = safeReadDir(plansDir).filter(f => f.endsWith('.json') || f.endsWith('.md'));
929
+ const plans = allFiles.map(f => {
930
+ const content = safeRead(path.join(plansDir, f)) || '';
931
+ const isJson = f.endsWith('.json');
932
+ if (isJson) {
933
+ try {
934
+ const plan = JSON.parse(content);
935
+ return {
936
+ file: f, format: 'prd',
937
+ project: plan.project || '',
938
+ summary: plan.plan_summary || '',
939
+ status: plan.status || 'active',
940
+ branchStrategy: plan.branch_strategy || 'parallel',
941
+ featureBranch: plan.feature_branch || '',
942
+ itemCount: (plan.missing_features || []).length,
943
+ generatedBy: plan.generated_by || '',
944
+ generatedAt: plan.generated_at || '',
945
+ requiresApproval: plan.requires_approval || false,
946
+ revisionFeedback: plan.revision_feedback || null,
947
+ };
948
+ } catch { return null; }
949
+ } else {
950
+ // .md raw plan — extract metadata from markdown
951
+ const titleMatch = content.match(/^#\s+(?:Plan:\s*)?(.+)/m);
952
+ const projectMatch = content.match(/\*\*Project:\*\*\s*(.+)/m);
953
+ const authorMatch = content.match(/\*\*Author:\*\*\s*(.+)/m);
954
+ const dateMatch = content.match(/\*\*Date:\*\*\s*(.+)/m);
955
+ return {
956
+ file: f, format: 'draft',
957
+ project: projectMatch ? projectMatch[1].trim() : '',
958
+ summary: titleMatch ? titleMatch[1].trim() : f.replace('.md', ''),
959
+ status: 'draft',
960
+ branchStrategy: '',
961
+ featureBranch: '',
962
+ itemCount: (content.match(/^\d+\.\s+\*\*/gm) || []).length,
963
+ generatedBy: authorMatch ? authorMatch[1].trim() : '',
964
+ generatedAt: dateMatch ? dateMatch[1].trim() : '',
965
+ requiresApproval: false,
966
+ revisionFeedback: null,
967
+ };
968
+ }
969
+ }).filter(Boolean).sort((a, b) => (b.generatedAt || '').localeCompare(a.generatedAt || ''));
932
970
  return jsonReply(res, 200, plans);
933
971
  }
934
972
 
935
- // GET /api/plans/:file — read full plan JSON
973
+ // GET /api/plans/:file — read full plan (JSON or markdown)
936
974
  const planFileMatch = req.url.match(/^\/api\/plans\/([^?]+)$/);
937
975
  if (planFileMatch && req.method === 'GET') {
938
976
  const file = decodeURIComponent(planFileMatch[1]);
939
977
  if (file.includes('..') || file.includes('/') || file.includes('\\')) return jsonReply(res, 400, { error: 'invalid' });
940
978
  const content = safeRead(path.join(SQUAD_DIR, 'plans', file));
941
979
  if (!content) return jsonReply(res, 404, { error: 'not found' });
942
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
980
+ const contentType = file.endsWith('.json') ? 'application/json' : 'text/plain';
981
+ res.setHeader('Content-Type', contentType + '; charset=utf-8');
943
982
  res.setHeader('Access-Control-Allow-Origin', '*');
944
983
  res.end(content);
945
984
  return;
@@ -1107,6 +1146,250 @@ What would you like to discuss or change? When you're happy, say "approve" and I
1107
1146
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1108
1147
  }
1109
1148
 
1149
+ // POST /api/doc-chat — unified Q&A + steering: Haiku decides whether to answer or edit
1150
+ if (req.method === 'POST' && req.url === '/api/doc-chat') {
1151
+ try {
1152
+ const body = await readBody(req);
1153
+ if (!body.message) return jsonReply(res, 400, { error: 'message required' });
1154
+ if (!body.document) return jsonReply(res, 400, { error: 'document required' });
1155
+
1156
+ const canEdit = !!body.filePath;
1157
+ const isJson = body.filePath?.endsWith('.json');
1158
+
1159
+ // Read current content from disk if editable (authoritative source)
1160
+ let currentContent = body.document;
1161
+ let fullPath = null;
1162
+ if (canEdit) {
1163
+ fullPath = path.resolve(SQUAD_DIR, body.filePath);
1164
+ if (!fullPath.startsWith(path.resolve(SQUAD_DIR))) return jsonReply(res, 400, { error: 'path must be under squad directory' });
1165
+ const diskContent = safeRead(fullPath);
1166
+ if (diskContent !== null) currentContent = diskContent;
1167
+ }
1168
+
1169
+ const prompt = `You are a document assistant for a software engineering squad. You can answer questions AND edit documents.
1170
+
1171
+ ## Document
1172
+ ${body.title ? '**Title:** ' + body.title + '\n' : ''}File: ${body.filePath || '(read-only)'}
1173
+ ${isJson ? 'Format: JSON' : 'Format: Markdown'}
1174
+
1175
+ \`\`\`
1176
+ ${currentContent.slice(0, 20000)}
1177
+ \`\`\`
1178
+
1179
+ ${body.selection ? '## Highlighted Selection\n> ' + body.selection.slice(0, 1500) + '\n' : ''}
1180
+ ${(body.history && body.history.length > 0) ? '## Conversation So Far\n\n' + body.history.map(h => (h.role === 'user' ? 'User: ' : 'Assistant: ') + h.text).join('\n\n') + '\n' : ''}
1181
+ ## User Message
1182
+
1183
+ ${body.message}
1184
+
1185
+ ## Instructions
1186
+
1187
+ Determine what the user wants:
1188
+
1189
+ **If they're asking a question** (e.g., "what does this mean", "why is this", "explain"):
1190
+ - Answer concisely with source citations
1191
+ - Do NOT include the ---DOCUMENT--- delimiter
1192
+
1193
+ **If they want to edit the document** (e.g., "change", "remove", "add", "update", "rename", "fix", "reword"):
1194
+ - Briefly explain what you changed (1-2 sentences)
1195
+ - Then on a new line write exactly: ---DOCUMENT---
1196
+ - Then the COMPLETE updated document (full file, not a diff)
1197
+ ${isJson ? '- The document MUST be valid JSON. No markdown code fences.' : ''}
1198
+ ${!canEdit ? '\nNote: This document is READ-ONLY. Answer questions only — do not output ---DOCUMENT---.' : ''}
1199
+
1200
+ Be concise. No preamble.`;
1201
+
1202
+ const sysPrompt = 'You are a precise document assistant. Determine intent (question vs edit) from the user message. Follow the output format exactly.';
1203
+
1204
+ const id = Date.now();
1205
+ const promptPath = path.join(SQUAD_DIR, 'engine', 'chat-prompt-' + id + '.md');
1206
+ const sysPath = path.join(SQUAD_DIR, 'engine', 'chat-sys-' + id + '.md');
1207
+ safeWrite(promptPath, prompt);
1208
+ safeWrite(sysPath, sysPrompt);
1209
+
1210
+ const spawnScript = path.join(SQUAD_DIR, 'engine', 'spawn-agent.js');
1211
+ const childEnv = { ...process.env };
1212
+ for (const key of Object.keys(childEnv)) {
1213
+ if (key === 'CLAUDECODE' || key.startsWith('CLAUDE_CODE') || key.startsWith('CLAUDECODE_')) delete childEnv[key];
1214
+ }
1215
+
1216
+ const { spawn: cpSpawn } = require('child_process');
1217
+ const proc = cpSpawn(process.execPath, [
1218
+ spawnScript, promptPath, sysPath,
1219
+ '--output-format', 'text', '--max-turns', '1', '--model', 'haiku',
1220
+ '--permission-mode', 'bypassPermissions', '--verbose',
1221
+ ], { cwd: SQUAD_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: childEnv });
1222
+
1223
+ let stdout = '';
1224
+ let stderr = '';
1225
+ proc.stdout.on('data', d => { stdout += d.toString(); });
1226
+ proc.stderr.on('data', d => { stderr += d.toString(); });
1227
+
1228
+ const timeout = setTimeout(() => { try { proc.kill('SIGTERM'); } catch {} }, 90000);
1229
+
1230
+ proc.on('close', (code) => {
1231
+ clearTimeout(timeout);
1232
+ try { fs.unlinkSync(promptPath); } catch {}
1233
+ try { fs.unlinkSync(sysPath); } catch {}
1234
+
1235
+ if (code === 0 && stdout.trim()) {
1236
+ const output = stdout.trim();
1237
+ const delimIdx = output.indexOf('---DOCUMENT---');
1238
+
1239
+ if (delimIdx < 0) {
1240
+ // Q&A response — no edit
1241
+ return jsonReply(res, 200, { ok: true, answer: output, edited: false });
1242
+ }
1243
+
1244
+ // Edit response
1245
+ const explanation = output.slice(0, delimIdx).trim();
1246
+ let newContent = output.slice(delimIdx + '---DOCUMENT---'.length).trim();
1247
+ newContent = newContent.replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim();
1248
+
1249
+ if (isJson) {
1250
+ try { JSON.parse(newContent); } catch (e) {
1251
+ return jsonReply(res, 200, { ok: true, answer: explanation + '\n\n(JSON invalid — not saved: ' + e.message + ')', edited: false });
1252
+ }
1253
+ }
1254
+
1255
+ if (canEdit && fullPath) {
1256
+ safeWrite(fullPath, newContent);
1257
+ return jsonReply(res, 200, { ok: true, answer: explanation, edited: true, content: newContent });
1258
+ } else {
1259
+ return jsonReply(res, 200, { ok: true, answer: explanation + '\n\n(Read-only — changes not saved)', edited: false });
1260
+ }
1261
+ } else {
1262
+ return jsonReply(res, 500, { error: 'Failed (code=' + code + ')', stderr: stderr.slice(0, 200) });
1263
+ }
1264
+ });
1265
+
1266
+ proc.on('error', (err) => {
1267
+ clearTimeout(timeout);
1268
+ try { fs.unlinkSync(promptPath); } catch {}
1269
+ try { fs.unlinkSync(sysPath); } catch {}
1270
+ return jsonReply(res, 500, { error: err.message });
1271
+ });
1272
+ return;
1273
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1274
+ }
1275
+
1276
+ // POST /api/steer-document — modify a document via natural language instruction (Haiku)
1277
+ if (req.method === 'POST' && req.url === '/api/steer-document') {
1278
+ try {
1279
+ const body = await readBody(req);
1280
+ if (!body.instruction) return jsonReply(res, 400, { error: 'instruction required' });
1281
+ if (!body.filePath) return jsonReply(res, 400, { error: 'filePath required' });
1282
+
1283
+ // Read current document from disk (authoritative source)
1284
+ const fullPath = path.resolve(SQUAD_DIR, body.filePath);
1285
+ // Security: must be under SQUAD_DIR
1286
+ if (!fullPath.startsWith(path.resolve(SQUAD_DIR))) return jsonReply(res, 400, { error: 'path must be under squad directory' });
1287
+ const currentContent = safeRead(fullPath);
1288
+ if (currentContent === null) return jsonReply(res, 404, { error: 'file not found' });
1289
+
1290
+ const isJson = body.filePath.endsWith('.json');
1291
+ const prompt = `You are editing a document based on a user instruction.
1292
+
1293
+ ## Current Document
1294
+ File: ${body.filePath}
1295
+ ${isJson ? 'Format: JSON (you MUST output valid JSON)' : 'Format: Markdown'}
1296
+
1297
+ \`\`\`
1298
+ ${currentContent.slice(0, 20000)}
1299
+ \`\`\`
1300
+
1301
+ ${body.selection ? '## Context: User highlighted this section\n> ' + body.selection.slice(0, 1000) + '\n' : ''}
1302
+
1303
+ ## Instruction
1304
+
1305
+ ${body.instruction}
1306
+
1307
+ ## Output Format
1308
+
1309
+ Respond with EXACTLY two sections separated by the delimiter \`---DOCUMENT---\`:
1310
+
1311
+ 1. First: A brief explanation of what you changed (1-3 sentences)
1312
+ 2. Then the delimiter on its own line: \`---DOCUMENT---\`
1313
+ 3. Then the COMPLETE updated document content (not a diff — the full file)
1314
+
1315
+ Example:
1316
+ Removed item P003 and reordered remaining items.
1317
+ ---DOCUMENT---
1318
+ {full updated file content here}
1319
+
1320
+ ${isJson ? 'CRITICAL: The document section must be valid JSON. Do not add markdown code fences around it.' : ''}`;
1321
+
1322
+ const sysPrompt = 'You are a precise document editor. Follow the output format exactly. No code fences around the document output.';
1323
+
1324
+ const id = Date.now();
1325
+ const promptPath = path.join(SQUAD_DIR, 'engine', 'steer-prompt-' + id + '.md');
1326
+ const sysPath = path.join(SQUAD_DIR, 'engine', 'steer-sys-' + id + '.md');
1327
+ safeWrite(promptPath, prompt);
1328
+ safeWrite(sysPath, sysPrompt);
1329
+
1330
+ const spawnScript = path.join(SQUAD_DIR, 'engine', 'spawn-agent.js');
1331
+ const childEnv = { ...process.env };
1332
+ for (const key of Object.keys(childEnv)) {
1333
+ if (key === 'CLAUDECODE' || key.startsWith('CLAUDE_CODE') || key.startsWith('CLAUDECODE_')) delete childEnv[key];
1334
+ }
1335
+
1336
+ const { spawn: cpSpawn } = require('child_process');
1337
+ const proc = cpSpawn(process.execPath, [
1338
+ spawnScript, promptPath, sysPath,
1339
+ '--output-format', 'text', '--max-turns', '1', '--model', 'haiku',
1340
+ '--permission-mode', 'bypassPermissions', '--verbose',
1341
+ ], { cwd: SQUAD_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: childEnv });
1342
+
1343
+ let stdout = '';
1344
+ let stderr = '';
1345
+ proc.stdout.on('data', d => { stdout += d.toString(); });
1346
+ proc.stderr.on('data', d => { stderr += d.toString(); });
1347
+
1348
+ const timeout = setTimeout(() => { try { proc.kill('SIGTERM'); } catch {} }, 90000);
1349
+
1350
+ proc.on('close', (code) => {
1351
+ clearTimeout(timeout);
1352
+ try { fs.unlinkSync(promptPath); } catch {}
1353
+ try { fs.unlinkSync(sysPath); } catch {}
1354
+
1355
+ if (code === 0 && stdout.trim()) {
1356
+ const output = stdout.trim();
1357
+ const delimIdx = output.indexOf('---DOCUMENT---');
1358
+ if (delimIdx < 0) {
1359
+ return jsonReply(res, 200, { ok: true, answer: output, updated: false, reason: 'No document delimiter found — treated as Q&A response' });
1360
+ }
1361
+
1362
+ const explanation = output.slice(0, delimIdx).trim();
1363
+ let newContent = output.slice(delimIdx + '---DOCUMENT---'.length).trim();
1364
+
1365
+ // Strip code fences if model added them
1366
+ newContent = newContent.replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim();
1367
+
1368
+ // Validate JSON if applicable
1369
+ if (isJson) {
1370
+ try { JSON.parse(newContent); } catch (e) {
1371
+ return jsonReply(res, 200, { ok: true, answer: explanation + '\n\n(Warning: JSON validation failed — document not saved: ' + e.message + ')', updated: false });
1372
+ }
1373
+ }
1374
+
1375
+ // Write back
1376
+ safeWrite(fullPath, newContent);
1377
+ return jsonReply(res, 200, { ok: true, answer: explanation, updated: true, content: newContent });
1378
+ } else {
1379
+ return jsonReply(res, 500, { error: 'Steer failed (code=' + code + ')', stderr: stderr.slice(0, 200) });
1380
+ }
1381
+ });
1382
+
1383
+ proc.on('error', (err) => {
1384
+ clearTimeout(timeout);
1385
+ try { fs.unlinkSync(promptPath); } catch {}
1386
+ try { fs.unlinkSync(sysPath); } catch {}
1387
+ return jsonReply(res, 500, { error: err.message });
1388
+ });
1389
+ return;
1390
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1391
+ }
1392
+
1110
1393
  // POST /api/ask-about — ask a question about a document with context, answered by Haiku
1111
1394
  if (req.method === 'POST' && req.url === '/api/ask-about') {
1112
1395
  try {
@@ -25,7 +25,7 @@ Agent completes task
25
25
  Agent finishes task
26
26
  → writes notes/inbox/<agent>-<date>.md
27
27
  → engine checks inbox on next tick (~60s)
28
- → consolidateInbox() fires (threshold: 1 file)
28
+ → consolidateInbox() fires (threshold: 3 files)
29
29
  → spawns Claude Haiku for LLM-powered summarization
30
30
  → Haiku reads all inbox notes + existing notes.md
31
31
  → produces deduplicated, categorized digest
@@ -323,7 +323,7 @@ When a git merge or rebase produces conflicts in yarn.lock.
323
323
 
324
324
  | Setting | Default | What it controls |
325
325
  |---------|---------|-----------------|
326
- | `engine.inboxConsolidateThreshold` | 1 | Files needed before consolidation triggers |
326
+ | `engine.inboxConsolidateThreshold` | 3 | Files needed before consolidation triggers |
327
327
  | Consolidation model | Haiku | LLM used for summarization (fast, cheap) |
328
328
  | Consolidation timeout | 3 min | Max time for LLM call before fallback |
329
329
  | notes.md max size | 50KB | Auto-prunes old sections above this |
package/engine.js CHANGED
@@ -1784,24 +1784,28 @@ async function handlePostMerge(pr, project, config, newStatus) {
1784
1784
  // Only run remaining hooks for merged PRs (not abandoned)
1785
1785
  if (newStatus !== 'merged') return;
1786
1786
 
1787
- // 2. Update PRD item status to 'implemented' (squad-level PRD)
1787
+ // 2. Update PRD item status to 'implemented' in plan files
1788
1788
  if (pr.prdItems?.length > 0) {
1789
- const prdPath = path.join(SQUAD_DIR, 'prd.json');
1790
- const prd = safeJson(prdPath);
1791
- if (prd?.missing_features) {
1789
+ const plansDir = path.join(SQUAD_DIR, 'plans');
1790
+ try {
1791
+ const planFiles = fs.readdirSync(plansDir).filter(f => f.endsWith('.json'));
1792
1792
  let updated = 0;
1793
- for (const itemId of pr.prdItems) {
1794
- const feature = prd.missing_features.find(f => f.id === itemId);
1795
- if (feature && feature.status !== 'implemented') {
1796
- feature.status = 'implemented';
1797
- updated++;
1793
+ for (const pf of planFiles) {
1794
+ const plan = safeJson(path.join(plansDir, pf));
1795
+ if (!plan?.missing_features) continue;
1796
+ let changed = false;
1797
+ for (const itemId of pr.prdItems) {
1798
+ const feature = plan.missing_features.find(f => f.id === itemId);
1799
+ if (feature && feature.status !== 'implemented') {
1800
+ feature.status = 'implemented';
1801
+ changed = true;
1802
+ updated++;
1803
+ }
1798
1804
  }
1805
+ if (changed) safeWrite(path.join(plansDir, pf), plan);
1799
1806
  }
1800
- if (updated > 0) {
1801
- safeWrite(prdPath, prd);
1802
- log('info', `Post-merge: marked ${updated} PRD item(s) as implemented for ${pr.id}`);
1803
- }
1804
- }
1807
+ if (updated > 0) log('info', `Post-merge: marked ${updated} PRD item(s) as implemented for ${pr.id}`);
1808
+ } catch {}
1805
1809
  }
1806
1810
 
1807
1811
  // 3. Update agent metrics
@@ -2987,96 +2991,7 @@ function isAlreadyDispatched(key) {
2987
2991
  return recentCompleted.some(d => d.meta?.dispatchKey === key);
2988
2992
  }
2989
2993
 
2990
- /**
2991
- * Scan squad-level PRD (~/.squad/prd.json) for missing/planned items → queue implement tasks.
2992
- * Items can span multiple projects via the `projects` array field.
2993
- */
2994
- function discoverFromPrd(config) {
2995
- const prdPath = path.join(SQUAD_DIR, 'prd.json');
2996
- const prd = safeJson(prdPath);
2997
- if (!prd) return [];
2998
-
2999
- const cooldownMs = (config.workSources?.prd?.cooldownMinutes || 30) * 60 * 1000;
3000
- const statusFilter = config.workSources?.prd?.itemFilter?.status || ['missing', 'planned'];
3001
- const items = (prd.missing_features || []).filter(f => statusFilter.includes(f.status));
3002
- const newWork = [];
3003
- const skipped = { dispatched: 0, cooldown: 0, noAgent: 0, noProject: 0 };
3004
- const allProjects = config.projects || [];
3005
-
3006
- for (const item of items) {
3007
- const key = `prd-squad-${item.id}`;
3008
- if (isAlreadyDispatched(key)) { skipped.dispatched++; continue; }
3009
- if (isOnCooldown(key, cooldownMs)) { skipped.cooldown++; continue; }
3010
-
3011
- // Resolve target projects from item.projects array
3012
- const targetProjects = (item.projects || [])
3013
- .map(name => allProjects.find(p => p.name.toLowerCase() === name.toLowerCase()))
3014
- .filter(Boolean);
3015
- if (targetProjects.length === 0) { skipped.noProject++; continue; }
3016
-
3017
- const workType = item.estimated_complexity === 'large' ? 'implement:large' : 'implement';
3018
- const agentId = resolveAgent(workType, config);
3019
- if (!agentId) { skipped.noAgent++; continue; }
3020
-
3021
- // Primary project = first in the list (used for worktree, branch, PR)
3022
- const primary = targetProjects[0];
3023
- const root = path.resolve(primary.localPath);
3024
- const branchName = `feature/${item.id.toLowerCase()}-${item.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40)}`;
3025
-
3026
- // Build related projects context for multi-project items
3027
- let relatedProjects = '';
3028
- if (targetProjects.length > 1) {
3029
- relatedProjects = targetProjects.map(p =>
3030
- `- **${p.name}** — ${path.resolve(p.localPath)} (${p.adoOrg}/${p.adoProject}/${p.repoName}, branch: ${p.mainBranch || 'main'})`
3031
- ).join('\n');
3032
- }
3033
2994
 
3034
- const vars = {
3035
- agent_id: agentId,
3036
- agent_name: config.agents[agentId]?.name || agentId,
3037
- agent_role: config.agents[agentId]?.role || 'Agent',
3038
- item_id: item.id,
3039
- item_name: item.name,
3040
- item_priority: item.priority || 'medium',
3041
- item_complexity: item.estimated_complexity || 'medium',
3042
- item_description: item.description || '',
3043
- branch_name: branchName,
3044
- project_path: root,
3045
- main_branch: primary.mainBranch || 'main',
3046
- worktree_path: path.resolve(root, config.engine?.worktreeRoot || '../worktrees', branchName),
3047
- commit_message: `feat(${item.id.toLowerCase()}): ${item.name}`,
3048
- team_root: SQUAD_DIR,
3049
- repo_id: primary.repositoryId || '',
3050
- project_name: targetProjects.map(p => p.name).join(', '),
3051
- ado_org: primary.adoOrg || 'Unknown',
3052
- ado_project: primary.adoProject || 'Unknown',
3053
- repo_name: primary.repoName || 'Unknown',
3054
- related_projects: relatedProjects,
3055
- };
3056
-
3057
- const prompt = renderPlaybook('implement', vars);
3058
- if (!prompt) continue;
3059
-
3060
- newWork.push({
3061
- type: workType,
3062
- agent: agentId,
3063
- agentName: config.agents[agentId]?.name,
3064
- agentRole: config.agents[agentId]?.role,
3065
- task: `[${vars.project_name}] Implement ${item.id}: ${item.name}`,
3066
- prompt,
3067
- meta: { dispatchKey: key, source: 'prd', branch: branchName, item, project: { name: primary.name, localPath: primary.localPath } }
3068
- });
3069
-
3070
- setCooldown(key);
3071
- }
3072
-
3073
- const skipTotal = skipped.dispatched + skipped.cooldown + skipped.noAgent + skipped.noProject;
3074
- if (skipTotal > 0) {
3075
- log('debug', `PRD discovery: skipped ${skipTotal} items (${skipped.dispatched} dispatched, ${skipped.cooldown} cooldown, ${skipped.noAgent} no agent, ${skipped.noProject} no project)`);
3076
- }
3077
-
3078
- return newWork;
3079
- }
3080
2995
 
3081
2996
  /**
3082
2997
  * Scan ~/.squad/plans/ for plan-generated PRD files → queue implement tasks.
@@ -3942,7 +3857,7 @@ function discoverWork(config) {
3942
3857
  }
3943
3858
 
3944
3859
  // Source 2: Squad-level PRD → implements (multi-project, called once outside project loop)
3945
- allImplements.push(...discoverFromPrd(config));
3860
+ // PRD items now flow through plans/*.json → materializePlansAsWorkItems → discoverFromWorkItems
3946
3861
 
3947
3862
  // Central work items (project-agnostic — agent decides where to work)
3948
3863
  const centralWork = discoverCentralWorkItems(config);
@@ -4474,7 +4389,7 @@ const commands = {
4474
4389
  });
4475
4390
 
4476
4391
  console.log(`Dispatched: ${id} → ${config.agents[agentId]?.name} (${agentId})`);
4477
- console.log('The agent will analyze your plan and generate docs/prd-gaps.json as a PR.');
4392
+ console.log('The agent will analyze your plan and generate a PRD in plans/.');
4478
4393
 
4479
4394
  // Immediately dispatch if engine is running
4480
4395
  const control = getControl();
@@ -4619,7 +4534,7 @@ const commands = {
4619
4534
  console.log('\n=== Work Discovery (dry run) ===\n');
4620
4535
 
4621
4536
  materializePlansAsWorkItems(config);
4622
- const prdWork = discoverFromPrd(config);
4537
+ // PRD discovery removed — all items flow through plans/*.json
4623
4538
  const prWork = discoverFromPrs(config);
4624
4539
  const workItemWork = discoverFromWorkItems(config);
4625
4540
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/squad",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.squad/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "squad": "bin/squad.js"