@yemi33/squad 0.1.4 → 0.1.6

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/engine.js CHANGED
@@ -129,23 +129,25 @@ function safeWrite(p, data) {
129
129
  try {
130
130
  fs.writeFileSync(tmp, content);
131
131
  // Atomic rename — retry on Windows EPERM (file locking)
132
- for (let attempt = 0; attempt < 3; attempt++) {
132
+ for (let attempt = 0; attempt < 5; attempt++) {
133
133
  try {
134
134
  fs.renameSync(tmp, p);
135
135
  return;
136
136
  } catch (e) {
137
- if (e.code === 'EPERM' && attempt < 2) {
138
- // Brief sync sleep (10ms) then retry
139
- const start = Date.now(); while (Date.now() - start < 10) {}
137
+ if (e.code === 'EPERM' && attempt < 4) {
138
+ const delay = 50 * (attempt + 1); // 50, 100, 150, 200ms
139
+ const start = Date.now(); while (Date.now() - start < delay) {}
140
140
  continue;
141
141
  }
142
- throw e;
142
+ // Final attempt failed — fall through to direct write
143
143
  }
144
144
  }
145
- } catch (e) {
146
- // Fallback: direct write if atomic rename fails entirely
145
+ // All rename attempts failed — direct write as fallback (not atomic but won't lose data)
146
+ try { fs.unlinkSync(tmp); } catch {}
147
+ fs.writeFileSync(p, content);
148
+ } catch {
149
+ // Even direct write failed — clean up tmp silently
147
150
  try { fs.unlinkSync(tmp); } catch {}
148
- try { fs.writeFileSync(p, content); } catch {}
149
151
  }
150
152
  }
151
153
 
@@ -598,16 +600,29 @@ function spawnAgent(dispatchItem, config) {
598
600
  worktreePath = path.resolve(rootDir, engineConfig.worktreeRoot || '../worktrees', branchName);
599
601
  try {
600
602
  if (!fs.existsSync(worktreePath)) {
601
- log('info', `Creating worktree: ${worktreePath} on branch ${branchName}`);
602
- execSync(`git worktree add "${worktreePath}" -b "${branchName}" ${sanitizeBranch(project.mainBranch || 'main')}`, {
603
- cwd: rootDir, stdio: 'pipe'
604
- });
603
+ const isSharedBranch = meta?.branchStrategy === 'shared-branch' || meta?.useExistingBranch;
604
+ if (isSharedBranch) {
605
+ // Shared branch: fetch and checkout existing branch (no -b)
606
+ log('info', `Creating worktree for shared branch: ${worktreePath} on ${branchName}`);
607
+ try { execSync(`git fetch origin "${branchName}"`, { cwd: rootDir, stdio: 'pipe' }); } catch {}
608
+ execSync(`git worktree add "${worktreePath}" "${branchName}"`, { cwd: rootDir, stdio: 'pipe' });
609
+ } else {
610
+ // Parallel: create new branch
611
+ log('info', `Creating worktree: ${worktreePath} on branch ${branchName}`);
612
+ execSync(`git worktree add "${worktreePath}" -b "${branchName}" ${sanitizeBranch(project.mainBranch || 'main')}`, {
613
+ cwd: rootDir, stdio: 'pipe'
614
+ });
615
+ }
616
+ } else if (meta?.branchStrategy === 'shared-branch') {
617
+ // Worktree exists — pull latest from prior plan item
618
+ log('info', `Pulling latest on shared branch ${branchName}`);
619
+ try { execSync(`git pull origin "${branchName}"`, { cwd: worktreePath, stdio: 'pipe' }); } catch {}
605
620
  }
606
621
  cwd = worktreePath;
607
622
  } catch (err) {
608
623
  log('error', `Failed to create worktree for ${branchName}: ${err.message}`);
609
624
  // Fall back to main directory for non-writing tasks
610
- if (type === 'review' || type === 'analyze' || type === 'plan-to-prd') {
625
+ if (type === 'review' || type === 'analyze' || type === 'plan-to-prd' || type === 'plan') {
611
626
  cwd = rootDir;
612
627
  } else {
613
628
  completeDispatch(id, 'error', 'Worktree creation failed');
@@ -728,6 +743,16 @@ function spawnAgent(dispatchItem, config) {
728
743
  updateWorkItemStatus(meta, 'done', '');
729
744
  }
730
745
 
746
+ // Post-completion: chain plan → plan-to-prd if this was a plan task
747
+ if (code === 0 && type === 'plan' && meta?.item?.chain === 'plan-to-prd') {
748
+ chainPlanToPrd(dispatchItem, meta, config);
749
+ }
750
+
751
+ // Post-completion: check shared-branch plan completion
752
+ if (code === 0 && meta?.item?.branchStrategy === 'shared-branch') {
753
+ checkPlanCompletion(meta, config);
754
+ }
755
+
731
756
  // Post-completion: scan output for PRs and sync to pull-requests.json
732
757
  if (code === 0) {
733
758
  syncPrsFromOutput(stdout, agentId, meta, config);
@@ -851,6 +876,8 @@ function completeDispatch(id, result = 'success', reason = '') {
851
876
  item.completed_at = ts();
852
877
  item.result = result;
853
878
  if (reason) item.reason = reason;
879
+ // Strip prompt from completed items (saves ~10KB per item, reduces file lock contention)
880
+ delete item.prompt;
854
881
  dispatch.completed = dispatch.completed || [];
855
882
  dispatch.completed.push(item);
856
883
  // Keep last 100 completed
@@ -868,6 +895,223 @@ function completeDispatch(id, result = 'success', reason = '') {
868
895
  }
869
896
  }
870
897
 
898
+ // ─── Dependency Gate ─────────────────────────────────────────────────────────
899
+ function areDependenciesMet(item, config) {
900
+ const deps = item.depends_on;
901
+ if (!deps || deps.length === 0) return true;
902
+ const sourcePlan = item.sourcePlan;
903
+ if (!sourcePlan) return true;
904
+ const projectName = item.project;
905
+ const projects = getProjects(config);
906
+ const project = projectName
907
+ ? projects.find(p => p.name?.toLowerCase() === projectName?.toLowerCase())
908
+ : projects[0];
909
+ if (!project) return true;
910
+ const wiPath = projectWorkItemsPath(project);
911
+ const workItems = safeJson(wiPath) || [];
912
+ for (const depId of deps) {
913
+ const depItem = workItems.find(w => w.sourcePlan === sourcePlan && w.planItemId === depId);
914
+ if (!depItem || depItem.status !== 'done') return false;
915
+ }
916
+ return true;
917
+ }
918
+
919
+ function detectDependencyCycles(items) {
920
+ const graph = new Map();
921
+ for (const item of items) graph.set(item.id, item.depends_on || []);
922
+ const visited = new Set(), inStack = new Set(), cycleIds = new Set();
923
+ function dfs(id) {
924
+ if (inStack.has(id)) { cycleIds.add(id); return true; }
925
+ if (visited.has(id)) return false;
926
+ visited.add(id); inStack.add(id);
927
+ for (const dep of (graph.get(id) || [])) { if (dfs(dep)) cycleIds.add(id); }
928
+ inStack.delete(id); return false;
929
+ }
930
+ for (const id of graph.keys()) dfs(id);
931
+ return [...cycleIds];
932
+ }
933
+
934
+ // ─── Plan Completion Detection ───────────────────────────────────────────────
935
+ function checkPlanCompletion(meta, config) {
936
+ const planFile = meta.item?.sourcePlan;
937
+ if (!planFile) return;
938
+ const plan = safeJson(path.join(PLANS_DIR, planFile));
939
+ if (!plan?.missing_features || plan.branch_strategy !== 'shared-branch') return;
940
+ if (plan.status === 'completed') return;
941
+ const projectName = plan.project;
942
+ const projects = getProjects(config);
943
+ const project = projectName
944
+ ? projects.find(p => p.name?.toLowerCase() === projectName?.toLowerCase()) : projects[0];
945
+ if (!project) return;
946
+ const wiPath = projectWorkItemsPath(project);
947
+ const workItems = safeJson(wiPath) || [];
948
+ const planItems = workItems.filter(w => w.sourcePlan === planFile && w.planItemId !== 'PR');
949
+ if (planItems.length === 0) return;
950
+ if (!planItems.every(w => w.status === 'done')) return;
951
+ log('info', `All ${planItems.length} items in plan ${planFile} completed — creating PR work item`);
952
+ const maxNum = workItems.reduce((max, i) => {
953
+ const m = (i.id || '').match(/(\d+)$/);
954
+ return m ? Math.max(max, parseInt(m[1])) : max;
955
+ }, 0);
956
+ const id = 'PL-W' + String(maxNum + 1).padStart(3, '0');
957
+ const featureBranch = plan.feature_branch;
958
+ const mainBranch = project.mainBranch || 'main';
959
+ const itemSummary = planItems.map(w => '- ' + w.planItemId + ': ' + w.title.replace('Implement: ', '')).join('\n');
960
+ workItems.push({
961
+ id, title: `Create PR for plan: ${plan.plan_summary || planFile}`,
962
+ type: 'implement', priority: 'high',
963
+ description: `All plan items from \`${planFile}\` are complete on branch \`${featureBranch}\`.
964
+ Create a pull request and clean up the worktree.
965
+
966
+ **Branch:** \`${featureBranch}\`
967
+ **Target:** \`${mainBranch}\`
968
+
969
+ ## Completed Items
970
+ ${itemSummary}
971
+
972
+ ## Instructions
973
+ 1. Rebase onto ${mainBranch} if needed (abort if conflicts — PR as-is is fine)
974
+ 2. Push: git push origin ${featureBranch} --force-with-lease
975
+ 3. Create the PR targeting ${mainBranch}
976
+ 4. Cleanup: git worktree remove ../worktrees/${featureBranch} --force`,
977
+ status: 'pending', created: ts(), createdBy: 'engine:plan-completion',
978
+ sourcePlan: planFile, planItemId: 'PR',
979
+ branch: featureBranch, branchStrategy: 'shared-branch', project: projectName,
980
+ });
981
+ safeWrite(wiPath, workItems);
982
+ plan.status = 'completed'; plan.completedAt = ts();
983
+ safeWrite(path.join(PLANS_DIR, planFile), plan);
984
+ }
985
+
986
+ // ─── Plan → PRD Chaining ─────────────────────────────────────────────────────
987
+ // When a 'plan' type task completes, find the plan file it wrote and dispatch
988
+ // a plan-to-prd task so Lambert converts it to structured PRD items.
989
+ function chainPlanToPrd(dispatchItem, meta, config) {
990
+ const planDir = path.join(SQUAD_DIR, 'plans');
991
+ if (!fs.existsSync(planDir)) {
992
+ log('warn', `Plan chaining: no plans/ directory found after plan task ${dispatchItem.id}`);
993
+ return;
994
+ }
995
+
996
+ // Find the plan file — look for recently created .md files in plans/
997
+ const planFiles = fs.readdirSync(planDir)
998
+ .filter(f => f.endsWith('.md'))
999
+ .map(f => ({ name: f, mtime: fs.statSync(path.join(planDir, f)).mtimeMs }))
1000
+ .sort((a, b) => b.mtime - a.mtime);
1001
+
1002
+ // Use the most recently modified plan file (the one the plan agent just wrote)
1003
+ const planFile = planFiles[0];
1004
+ if (!planFile) {
1005
+ log('warn', `Plan chaining: no .md plan files found in plans/ after task ${dispatchItem.id}`);
1006
+ return;
1007
+ }
1008
+
1009
+ const planPath = path.join(planDir, planFile.name);
1010
+ let planContent;
1011
+ try { planContent = fs.readFileSync(planPath, 'utf8'); } catch (e) {
1012
+ log('error', `Plan chaining: failed to read plan file ${planFile.name}: ${e.message}`);
1013
+ return;
1014
+ }
1015
+
1016
+ // Resolve target project
1017
+ const projectName = meta?.item?.project || meta?.project?.name;
1018
+ const projects = getProjects(config);
1019
+ const targetProject = projectName
1020
+ ? projects.find(p => p.name === projectName) || projects[0]
1021
+ : projects[0];
1022
+
1023
+ if (!targetProject) {
1024
+ log('error', 'Plan chaining: no target project available');
1025
+ return;
1026
+ }
1027
+
1028
+ // Resolve agent for plan-to-prd (typically Lambert)
1029
+ const agentId = resolveAgent('plan-to-prd', config);
1030
+ if (!agentId) {
1031
+ // No agent available now — create a pending work item so engine picks it up on next tick
1032
+ log('info', `Plan chaining: no agent available now, queuing plan-to-prd for next tick`);
1033
+ const wiPath = path.join(SQUAD_DIR, 'work-items.json');
1034
+ let items = [];
1035
+ try { items = JSON.parse(fs.readFileSync(wiPath, 'utf8')); } catch {}
1036
+ const maxNum = items.reduce((max, i) => {
1037
+ const m = (i.id || '').match(/(\d+)$/);
1038
+ return m ? Math.max(max, parseInt(m[1])) : max;
1039
+ }, 0);
1040
+ items.push({
1041
+ id: 'W' + String(maxNum + 1).padStart(3, '0'),
1042
+ title: `Convert plan to PRD: ${meta?.item?.title || planFile.name}`,
1043
+ type: 'plan-to-prd',
1044
+ priority: meta?.item?.priority || 'high',
1045
+ description: `Plan file: plans/${planFile.name}\nChained from plan task ${dispatchItem.id}`,
1046
+ status: 'pending',
1047
+ created: ts(),
1048
+ createdBy: 'engine:chain',
1049
+ project: targetProject.name,
1050
+ planFile: planFile.name,
1051
+ });
1052
+ safeWrite(wiPath, items);
1053
+ return;
1054
+ }
1055
+
1056
+ // Build plan-to-prd vars and dispatch immediately
1057
+ const vars = {
1058
+ agent_id: agentId,
1059
+ agent_name: config.agents[agentId]?.name || agentId,
1060
+ agent_role: config.agents[agentId]?.role || '',
1061
+ project_name: targetProject.name || 'Unknown',
1062
+ project_path: targetProject.localPath || '',
1063
+ main_branch: targetProject.mainBranch || 'main',
1064
+ ado_org: targetProject.adoOrg || config.project?.adoOrg || 'Unknown',
1065
+ ado_project: targetProject.adoProject || config.project?.adoProject || 'Unknown',
1066
+ repo_name: targetProject.repoName || config.project?.repoName || 'Unknown',
1067
+ team_root: SQUAD_DIR,
1068
+ date: dateStamp(),
1069
+ plan_content: planContent,
1070
+ plan_summary: (meta?.item?.title || planFile.name).substring(0, 80),
1071
+ project_name_lower: (targetProject.name || 'project').toLowerCase(),
1072
+ branch_strategy_hint: meta?.item?.branchStrategy
1073
+ ? `The user requested **${meta.item.branchStrategy}** strategy. Use this unless the analysis strongly suggests otherwise.`
1074
+ : 'Choose the best strategy based on your analysis of item dependencies.',
1075
+ };
1076
+
1077
+ if (!fs.existsSync(PLANS_DIR)) fs.mkdirSync(PLANS_DIR, { recursive: true });
1078
+
1079
+ const prompt = renderPlaybook('plan-to-prd', vars);
1080
+ if (!prompt) {
1081
+ log('error', 'Plan chaining: could not render plan-to-prd playbook');
1082
+ return;
1083
+ }
1084
+
1085
+ const id = addToDispatch({
1086
+ type: 'plan-to-prd',
1087
+ agent: agentId,
1088
+ agentName: config.agents[agentId]?.name,
1089
+ agentRole: config.agents[agentId]?.role,
1090
+ task: `[${targetProject.name}] Convert plan to PRD: ${vars.plan_summary}`,
1091
+ prompt,
1092
+ meta: {
1093
+ source: 'chain',
1094
+ chainedFrom: dispatchItem.id,
1095
+ project: { name: targetProject.name, localPath: targetProject.localPath },
1096
+ planFile: planFile.name,
1097
+ planSummary: vars.plan_summary,
1098
+ }
1099
+ });
1100
+
1101
+ log('info', `Plan chaining: dispatched plan-to-prd ${id} → ${config.agents[agentId]?.name} (chained from ${dispatchItem.id})`);
1102
+
1103
+ // Spawn immediately if engine is running
1104
+ const control = getControl();
1105
+ if (control.state === 'running') {
1106
+ const dispatch = getDispatch();
1107
+ const item = dispatch.pending.find(d => d.id === id);
1108
+ if (item) {
1109
+ spawnAgent(item, config);
1110
+ log('info', `Plan chaining: agent spawned immediately for ${id}`);
1111
+ }
1112
+ }
1113
+ }
1114
+
871
1115
  function updateWorkItemStatus(meta, status, reason) {
872
1116
  const itemId = meta.item?.id;
873
1117
  if (!itemId) return;
@@ -1330,6 +1574,114 @@ async function pollPrStatus(config) {
1330
1574
  }
1331
1575
  }
1332
1576
 
1577
+ // ─── Poll Human Comments on PRs ──────────────────────────────────────────────
1578
+
1579
+ async function pollPrHumanComments(config) {
1580
+ const token = getAdoToken();
1581
+ if (!token) return;
1582
+
1583
+ const projects = getProjects(config);
1584
+ let totalUpdated = 0;
1585
+
1586
+ for (const project of projects) {
1587
+ if (!project.adoOrg || !project.adoProject || !project.repositoryId) continue;
1588
+
1589
+ const prs = getPrs(project);
1590
+ const activePrs = prs.filter(pr => pr.status === 'active');
1591
+ if (activePrs.length === 0) continue;
1592
+
1593
+ let projectUpdated = 0;
1594
+
1595
+ for (const pr of activePrs) {
1596
+ const prNum = (pr.id || '').replace('PR-', '');
1597
+ if (!prNum) continue;
1598
+
1599
+ try {
1600
+ let orgBase;
1601
+ if (project.prUrlBase) {
1602
+ const m = project.prUrlBase.match(/^(https?:\/\/[^/]+(?:\/DefaultCollection)?)/);
1603
+ if (m) orgBase = m[1];
1604
+ }
1605
+ if (!orgBase) {
1606
+ orgBase = project.adoOrg.includes('.')
1607
+ ? `https://${project.adoOrg}`
1608
+ : `https://dev.azure.com/${project.adoOrg}`;
1609
+ }
1610
+
1611
+ const threadsUrl = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}/pullrequests/${prNum}/threads?api-version=7.1`;
1612
+ const threadsData = await adoFetch(threadsUrl, token);
1613
+ const threads = threadsData.value || [];
1614
+
1615
+ // First pass: count all unique human commenters across the entire PR
1616
+ const allHumanAuthors = new Set();
1617
+ for (const thread of threads) {
1618
+ for (const comment of (thread.comments || [])) {
1619
+ if (!comment.content || comment.commentType === 'system') continue;
1620
+ if (/\bSquad\s*\(/i.test(comment.content)) continue;
1621
+ allHumanAuthors.add(comment.author?.uniqueName || comment.author?.displayName || '');
1622
+ }
1623
+ }
1624
+ const soloReviewer = allHumanAuthors.size <= 1;
1625
+
1626
+ // Second pass: collect new human comments after cutoff
1627
+ const cutoff = pr.humanFeedback?.lastProcessedCommentDate || pr.created || '1970-01-01';
1628
+ const newHumanComments = [];
1629
+
1630
+ for (const thread of threads) {
1631
+ for (const comment of (thread.comments || [])) {
1632
+ if (!comment.content || comment.commentType === 'system') continue;
1633
+ if (/\bSquad\s*\(/i.test(comment.content)) continue;
1634
+ if (!(comment.publishedDate && comment.publishedDate > cutoff)) continue;
1635
+
1636
+ // Solo reviewer: all comments are actionable. Multiple humans: require @squad.
1637
+ if (!soloReviewer && !/@squad\b/i.test(comment.content)) continue;
1638
+
1639
+ newHumanComments.push({
1640
+ threadId: thread.id,
1641
+ commentId: comment.id,
1642
+ author: comment.author?.displayName || 'Human',
1643
+ content: comment.content,
1644
+ date: comment.publishedDate
1645
+ });
1646
+ }
1647
+ }
1648
+
1649
+ if (newHumanComments.length === 0) continue;
1650
+
1651
+ // Sort by date, concatenate feedback
1652
+ newHumanComments.sort((a, b) => a.date.localeCompare(b.date));
1653
+ const feedbackContent = newHumanComments
1654
+ .map(c => `**${c.author}** (${c.date}):\n${c.content.replace(/@squad\s*/gi, '').trim()}`)
1655
+ .join('\n\n---\n\n');
1656
+ const latestDate = newHumanComments[newHumanComments.length - 1].date;
1657
+
1658
+ pr.humanFeedback = {
1659
+ lastProcessedCommentDate: latestDate,
1660
+ pendingFix: true,
1661
+ feedbackContent
1662
+ };
1663
+
1664
+ log('info', `PR ${pr.id}: found ${newHumanComments.length} new human comment(s)${soloReviewer ? '' : ' (via @squad)'}`);
1665
+ projectUpdated++;
1666
+ } catch (e) {
1667
+ log('warn', `Failed to poll comments for ${pr.id}: ${e.message}`);
1668
+ }
1669
+ }
1670
+
1671
+ if (projectUpdated > 0) {
1672
+ const root = path.resolve(project.localPath);
1673
+ const prSrc = project.workSources?.pullRequests || {};
1674
+ const prPath = path.resolve(root, prSrc.path || '.squad/pull-requests.json');
1675
+ safeWrite(prPath, prs);
1676
+ totalUpdated += projectUpdated;
1677
+ }
1678
+ }
1679
+
1680
+ if (totalUpdated > 0) {
1681
+ log('info', `PR comment poll: found human feedback on ${totalUpdated} PR(s)`);
1682
+ }
1683
+ }
1684
+
1333
1685
  // ─── Post-Merge / Post-Close Hooks ───────────────────────────────────────────
1334
1686
 
1335
1687
  async function handlePostMerge(pr, project, config, newStatus) {
@@ -1354,11 +1706,9 @@ async function handlePostMerge(pr, project, config, newStatus) {
1354
1706
  // Only run remaining hooks for merged PRs (not abandoned)
1355
1707
  if (newStatus !== 'merged') return;
1356
1708
 
1357
- // 2. Update PRD item status to 'implemented'
1709
+ // 2. Update PRD item status to 'implemented' (squad-level PRD)
1358
1710
  if (pr.prdItems?.length > 0) {
1359
- const root = path.resolve(project.localPath);
1360
- const prdSrc = project.workSources?.prd || {};
1361
- const prdPath = path.resolve(root, prdSrc.path || 'docs/prd-gaps.json');
1711
+ const prdPath = path.join(SQUAD_DIR, 'prd.json');
1362
1712
  const prd = safeJson(prdPath);
1363
1713
  if (prd?.missing_features) {
1364
1714
  let updated = 0;
@@ -2187,33 +2537,49 @@ function isAlreadyDispatched(key) {
2187
2537
  }
2188
2538
 
2189
2539
  /**
2190
- * Scan PRD (docs/prd-gaps.json) for missing/planned items → queue implement tasks
2540
+ * Scan squad-level PRD (~/.squad/prd.json) for missing/planned items → queue implement tasks.
2541
+ * Items can span multiple projects via the `projects` array field.
2191
2542
  */
2192
- function discoverFromPrd(config, project) {
2193
- const src = project?.workSources?.prd || config.workSources?.prd;
2194
- if (!src?.enabled) return [];
2195
-
2196
- const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(SQUAD_DIR, '..');
2197
- const prdPath = path.resolve(root, src.path);
2543
+ function discoverFromPrd(config) {
2544
+ const prdPath = path.join(SQUAD_DIR, 'prd.json');
2198
2545
  const prd = safeJson(prdPath);
2199
2546
  if (!prd) return [];
2200
2547
 
2201
- const cooldownMs = (src.cooldownMinutes || 30) * 60 * 1000;
2202
- const statusFilter = src.itemFilter?.status || ['missing', 'planned'];
2548
+ const cooldownMs = (config.workSources?.prd?.cooldownMinutes || 30) * 60 * 1000;
2549
+ const statusFilter = config.workSources?.prd?.itemFilter?.status || ['missing', 'planned'];
2203
2550
  const items = (prd.missing_features || []).filter(f => statusFilter.includes(f.status));
2204
2551
  const newWork = [];
2205
- const skipped = { dispatched: 0, cooldown: 0, noAgent: 0 };
2552
+ const skipped = { dispatched: 0, cooldown: 0, noAgent: 0, noProject: 0 };
2553
+ const allProjects = config.projects || [];
2206
2554
 
2207
2555
  for (const item of items) {
2208
- const key = `prd-${project?.name || 'default'}-${item.id}`;
2556
+ const key = `prd-squad-${item.id}`;
2209
2557
  if (isAlreadyDispatched(key)) { skipped.dispatched++; continue; }
2210
2558
  if (isOnCooldown(key, cooldownMs)) { skipped.cooldown++; continue; }
2211
2559
 
2560
+ // Resolve target projects from item.projects array
2561
+ const targetProjects = (item.projects || [])
2562
+ .map(name => allProjects.find(p => p.name.toLowerCase() === name.toLowerCase()))
2563
+ .filter(Boolean);
2564
+ if (targetProjects.length === 0) { skipped.noProject++; continue; }
2565
+
2212
2566
  const workType = item.estimated_complexity === 'large' ? 'implement:large' : 'implement';
2213
2567
  const agentId = resolveAgent(workType, config);
2214
2568
  if (!agentId) { skipped.noAgent++; continue; }
2215
2569
 
2570
+ // Primary project = first in the list (used for worktree, branch, PR)
2571
+ const primary = targetProjects[0];
2572
+ const root = path.resolve(primary.localPath);
2216
2573
  const branchName = `feature/${item.id.toLowerCase()}-${item.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40)}`;
2574
+
2575
+ // Build related projects context for multi-project items
2576
+ let relatedProjects = '';
2577
+ if (targetProjects.length > 1) {
2578
+ relatedProjects = targetProjects.map(p =>
2579
+ `- **${p.name}** — ${path.resolve(p.localPath)} (${p.adoOrg}/${p.adoProject}/${p.repoName}, branch: ${p.mainBranch || 'main'})`
2580
+ ).join('\n');
2581
+ }
2582
+
2217
2583
  const vars = {
2218
2584
  agent_id: agentId,
2219
2585
  agent_name: config.agents[agentId]?.name || agentId,
@@ -2225,15 +2591,16 @@ function discoverFromPrd(config, project) {
2225
2591
  item_description: item.description || '',
2226
2592
  branch_name: branchName,
2227
2593
  project_path: root,
2228
- main_branch: project?.mainBranch || 'main',
2594
+ main_branch: primary.mainBranch || 'main',
2229
2595
  worktree_path: path.resolve(root, config.engine?.worktreeRoot || '../worktrees', branchName),
2230
2596
  commit_message: `feat(${item.id.toLowerCase()}): ${item.name}`,
2231
2597
  team_root: SQUAD_DIR,
2232
- repo_id: project?.repositoryId || config.project?.repositoryId || '',
2233
- project_name: project?.name || config.project?.name || 'Unknown Project',
2234
- ado_org: project?.adoOrg || config.project?.adoOrg || 'Unknown',
2235
- ado_project: project?.adoProject || config.project?.adoProject || 'Unknown',
2236
- repo_name: project?.repoName || config.project?.repoName || 'Unknown'
2598
+ repo_id: primary.repositoryId || '',
2599
+ project_name: targetProjects.map(p => p.name).join(', '),
2600
+ ado_org: primary.adoOrg || 'Unknown',
2601
+ ado_project: primary.adoProject || 'Unknown',
2602
+ repo_name: primary.repoName || 'Unknown',
2603
+ related_projects: relatedProjects,
2237
2604
  };
2238
2605
 
2239
2606
  const prompt = renderPlaybook('implement', vars);
@@ -2244,17 +2611,17 @@ function discoverFromPrd(config, project) {
2244
2611
  agent: agentId,
2245
2612
  agentName: config.agents[agentId]?.name,
2246
2613
  agentRole: config.agents[agentId]?.role,
2247
- task: `[${project?.name || 'project'}] Implement ${item.id}: ${item.name}`,
2614
+ task: `[${vars.project_name}] Implement ${item.id}: ${item.name}`,
2248
2615
  prompt,
2249
- meta: { dispatchKey: key, source: 'prd', branch: branchName, item, project: { name: project?.name, localPath: project?.localPath } }
2616
+ meta: { dispatchKey: key, source: 'prd', branch: branchName, item, project: { name: primary.name, localPath: primary.localPath } }
2250
2617
  });
2251
2618
 
2252
2619
  setCooldown(key);
2253
2620
  }
2254
2621
 
2255
- const skipTotal = skipped.dispatched + skipped.cooldown + skipped.noAgent;
2622
+ const skipTotal = skipped.dispatched + skipped.cooldown + skipped.noAgent + skipped.noProject;
2256
2623
  if (skipTotal > 0) {
2257
- log('debug', `PRD discovery (${project?.name}): skipped ${skipTotal} items (${skipped.dispatched} dispatched, ${skipped.cooldown} cooldown, ${skipped.noAgent} no agent)`);
2624
+ log('debug', `PRD discovery: skipped ${skipTotal} items (${skipped.dispatched} dispatched, ${skipped.cooldown} cooldown, ${skipped.noAgent} no agent, ${skipped.noProject} no project)`);
2258
2625
  }
2259
2626
 
2260
2627
  return newWork;
@@ -2312,7 +2679,12 @@ function materializePlansAsWorkItems(config) {
2312
2679
  created: ts(),
2313
2680
  createdBy: 'engine:plan-discovery',
2314
2681
  sourcePlan: file,
2315
- sourcePlanItem: item.id
2682
+ sourcePlanItem: item.id,
2683
+ planItemId: item.id,
2684
+ depends_on: item.depends_on || [],
2685
+ branchStrategy: plan.branch_strategy || 'parallel',
2686
+ featureBranch: plan.feature_branch || null,
2687
+ project: plan.project || null,
2316
2688
  });
2317
2689
  created++;
2318
2690
  }
@@ -2320,6 +2692,36 @@ function materializePlansAsWorkItems(config) {
2320
2692
  if (created > 0) {
2321
2693
  safeWrite(wiPath, existingItems);
2322
2694
  log('info', `Plan discovery: created ${created} work item(s) from ${file} → ${project.name}`);
2695
+
2696
+ // Pre-create shared feature branch if branch_strategy is shared-branch
2697
+ if (plan.branch_strategy === 'shared-branch' && plan.feature_branch) {
2698
+ try {
2699
+ const root = path.resolve(project.localPath);
2700
+ const mainBranch = project.mainBranch || 'main';
2701
+ const branch = sanitizeBranch(plan.feature_branch);
2702
+ // Create branch from main (idempotent — ignores if exists)
2703
+ execSync(`git branch "${branch}" "${mainBranch}" 2>/dev/null || true`, { cwd: root, stdio: 'pipe' });
2704
+ execSync(`git push -u origin "${branch}" 2>/dev/null || true`, { cwd: root, stdio: 'pipe' });
2705
+ log('info', `Shared branch pre-created: ${branch} for plan ${file}`);
2706
+ } catch (err) {
2707
+ log('warn', `Failed to pre-create shared branch for ${file}: ${err.message}`);
2708
+ }
2709
+ }
2710
+
2711
+ // Cycle detection for plan items
2712
+ const planDerivedItems = plan.missing_features.filter(f => f.depends_on && f.depends_on.length > 0);
2713
+ if (planDerivedItems.length > 0) {
2714
+ const cycles = detectDependencyCycles(plan.missing_features);
2715
+ if (cycles.length > 0) {
2716
+ log('warn', `Dependency cycle detected in plan ${file}: ${cycles.join(', ')} — clearing deps for cycling items`);
2717
+ for (const wi of existingItems) {
2718
+ if (wi.sourcePlan === file && cycles.includes(wi.planItemId)) {
2719
+ wi.depends_on = [];
2720
+ }
2721
+ }
2722
+ safeWrite(wiPath, existingItems);
2723
+ }
2724
+ }
2323
2725
  }
2324
2726
  }
2325
2727
  }
@@ -2425,6 +2827,51 @@ function discoverFromPrs(config, project) {
2425
2827
  setCooldown(key);
2426
2828
  }
2427
2829
 
2830
+ // PRs with pending human feedback (@squad comments) → route to author for fix
2831
+ if (pr.humanFeedback?.pendingFix) {
2832
+ const key = `human-fix-${project?.name || 'default'}-${pr.id}-${pr.humanFeedback.lastProcessedCommentDate}`;
2833
+ if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2834
+
2835
+ const agentId = resolveAgent('fix', config, pr.agent);
2836
+ if (!agentId) continue;
2837
+
2838
+ const prNumber = (pr.id || '').replace(/^PR-/, '');
2839
+ const vars = {
2840
+ agent_id: agentId,
2841
+ agent_name: config.agents[agentId]?.name || agentId,
2842
+ agent_role: config.agents[agentId]?.role || 'Agent',
2843
+ pr_id: pr.id,
2844
+ pr_number: prNumber,
2845
+ pr_title: pr.title || '',
2846
+ pr_branch: pr.branch || '',
2847
+ reviewer: 'Human Reviewer',
2848
+ review_note: pr.humanFeedback.feedbackContent || 'See PR thread comments',
2849
+ team_root: SQUAD_DIR,
2850
+ repo_id: project?.repositoryId || config.project?.repositoryId || '',
2851
+ project_name: project?.name || 'Unknown Project',
2852
+ ado_org: project?.adoOrg || 'Unknown',
2853
+ ado_project: project?.adoProject || 'Unknown',
2854
+ repo_name: project?.repoName || 'Unknown'
2855
+ };
2856
+
2857
+ const prompt = renderPlaybook('fix', vars);
2858
+ if (!prompt) continue;
2859
+
2860
+ newWork.push({
2861
+ type: 'fix',
2862
+ agent: agentId,
2863
+ agentName: config.agents[agentId]?.name,
2864
+ agentRole: config.agents[agentId]?.role,
2865
+ task: `[${project?.name || 'project'}] Fix PR ${pr.id} — human feedback`,
2866
+ prompt,
2867
+ meta: { dispatchKey: key, source: 'pr-human-feedback', pr, branch: pr.branch, project: { name: project?.name, localPath: project?.localPath } }
2868
+ });
2869
+
2870
+ // Clear pendingFix so it doesn't re-dispatch next tick
2871
+ pr.humanFeedback.pendingFix = false;
2872
+ setCooldown(key);
2873
+ }
2874
+
2428
2875
  // PRs with build failures → route to any idle agent
2429
2876
  if (pr.status === 'active' && pr.buildStatus === 'failing') {
2430
2877
  const key = `build-fix-${project?.name || 'default'}-${pr.id}`;
@@ -2541,6 +2988,9 @@ function discoverFromWorkItems(config, project) {
2541
2988
  for (const item of items) {
2542
2989
  if (item.status !== 'queued' && item.status !== 'pending') continue;
2543
2990
 
2991
+ // Dependency gate: skip items whose depends_on are not yet met
2992
+ if (item.depends_on && item.depends_on.length > 0 && !areDependenciesMet(item, config)) continue;
2993
+
2544
2994
  const key = `work-${project?.name || 'default'}-${item.id}`;
2545
2995
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) { skipped.gated++; continue; }
2546
2996
 
@@ -2551,7 +3001,8 @@ function discoverFromWorkItems(config, project) {
2551
3001
  const agentId = item.agent || resolveAgent(workType, config);
2552
3002
  if (!agentId) { skipped.noAgent++; continue; }
2553
3003
 
2554
- const branchName = item.branch || `work/${item.id}`;
3004
+ const isShared = item.branchStrategy === 'shared-branch' && item.featureBranch;
3005
+ const branchName = isShared ? item.featureBranch : (item.branch || `work/${item.id}`);
2555
3006
  const vars = {
2556
3007
  agent_id: agentId,
2557
3008
  agent_name: config.agents[agentId]?.name || agentId,
@@ -2577,9 +3028,22 @@ function discoverFromWorkItems(config, project) {
2577
3028
  date: dateStamp()
2578
3029
  };
2579
3030
 
2580
- // Use type-specific playbook if it exists (e.g., explore.md, review.md), fall back to work-item.md
2581
- const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd'];
2582
- const playbookName = typeSpecificPlaybooks.includes(workType) ? workType : 'work-item';
3031
+ // Inject ask-specific variables for the ask playbook
3032
+ if (workType === 'ask') {
3033
+ vars.question = item.title + (item.description ? '\n\n' + item.description : '');
3034
+ vars.task_id = item.id;
3035
+ vars.notes_content = '';
3036
+ try { vars.notes_content = fs.readFileSync(path.join(SQUAD_DIR, 'notes.md'), 'utf8'); } catch {}
3037
+ }
3038
+
3039
+ // Select playbook: shared-branch items use implement-shared, others use type-specific or work-item
3040
+ let playbookName;
3041
+ if (item.branchStrategy === 'shared-branch' && (workType === 'implement' || workType === 'implement:large')) {
3042
+ playbookName = 'implement-shared';
3043
+ } else {
3044
+ const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd', 'plan', 'ask'];
3045
+ playbookName = typeSpecificPlaybooks.includes(workType) ? workType : 'work-item';
3046
+ }
2583
3047
  const prompt = item.prompt || renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars) || item.description;
2584
3048
  if (!prompt) {
2585
3049
  log('warn', `No playbook rendered for ${item.id} (type: ${workType}, playbook: ${playbookName}) — skipping`);
@@ -2598,7 +3062,7 @@ function discoverFromWorkItems(config, project) {
2598
3062
  agentRole: config.agents[agentId]?.role,
2599
3063
  task: `[${project?.name || 'project'}] ${item.title || item.description?.slice(0, 80) || item.id}`,
2600
3064
  prompt,
2601
- meta: { dispatchKey: key, source: 'work-item', branch: branchName, item, project: { name: project?.name, localPath: project?.localPath } }
3065
+ meta: { dispatchKey: key, source: 'work-item', branch: branchName, branchStrategy: item.branchStrategy || 'parallel', useExistingBranch: !!(item.branchStrategy === 'shared-branch' && item.featureBranch), item, project: { name: project?.name, localPath: project?.localPath } }
2602
3066
  });
2603
3067
 
2604
3068
  setCooldown(key);
@@ -2855,7 +3319,15 @@ function discoverCentralWorkItems(config) {
2855
3319
  date: dateStamp()
2856
3320
  };
2857
3321
 
2858
- const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd'];
3322
+ // Inject ask-specific variables for the ask playbook
3323
+ if (workType === 'ask') {
3324
+ vars.question = item.title + (item.description ? '\n\n' + item.description : '');
3325
+ vars.task_id = item.id;
3326
+ vars.notes_content = '';
3327
+ try { vars.notes_content = fs.readFileSync(path.join(SQUAD_DIR, 'notes.md'), 'utf8'); } catch {}
3328
+ }
3329
+
3330
+ const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd', 'plan', 'ask'];
2859
3331
  const playbookName = typeSpecificPlaybooks.includes(workType) ? workType : 'work-item';
2860
3332
  const prompt = renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars);
2861
3333
  if (!prompt) continue;
@@ -2913,7 +3385,26 @@ function discoverCentralWorkItems(config) {
2913
3385
  date: dateStamp()
2914
3386
  };
2915
3387
 
2916
- const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd'];
3388
+ // Inject plan-specific variables for the plan playbook
3389
+ if (workType === 'plan') {
3390
+ const planFileName = `plan-${item.id.toLowerCase()}-${dateStamp()}.md`;
3391
+ vars.plan_content = item.title + (item.description ? '\n\n' + item.description : '');
3392
+ vars.plan_title = item.title;
3393
+ vars.plan_file = planFileName;
3394
+ vars.task_description = item.title;
3395
+ vars.notes_content = '';
3396
+ try { vars.notes_content = fs.readFileSync(path.join(SQUAD_DIR, 'notes.md'), 'utf8'); } catch {}
3397
+ }
3398
+
3399
+ // Inject ask-specific variables for the ask playbook
3400
+ if (workType === 'ask') {
3401
+ vars.question = item.title + (item.description ? '\n\n' + item.description : '');
3402
+ vars.task_id = item.id;
3403
+ vars.notes_content = '';
3404
+ try { vars.notes_content = fs.readFileSync(path.join(SQUAD_DIR, 'notes.md'), 'utf8'); } catch {}
3405
+ }
3406
+
3407
+ const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd', 'plan', 'ask'];
2917
3408
  const playbookName = typeSpecificPlaybooks.includes(workType) ? workType : 'work-item';
2918
3409
  const prompt = renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars);
2919
3410
  if (!prompt) continue;
@@ -2942,7 +3433,7 @@ function discoverCentralWorkItems(config) {
2942
3433
 
2943
3434
  /**
2944
3435
  * Run all work discovery sources and queue new items
2945
- * Priority: fix (0) > review (1) > implement (2) > work-items (3) > central (4)
3436
+ * Priority: fix (0) > ask (1) > review (1) > implement (2) > work-items (3) > central (4)
2946
3437
  */
2947
3438
  function discoverWork(config) {
2948
3439
  const projects = getProjects(config);
@@ -2962,9 +3453,6 @@ function discoverWork(config) {
2962
3453
  allReviews.push(...prWork.filter(w => w.type === 'review'));
2963
3454
  allWorkItems.push(...prWork.filter(w => w.type === 'test'));
2964
3455
 
2965
- // Source 2: PRD gaps → implements
2966
- allImplements.push(...discoverFromPrd(config, project));
2967
-
2968
3456
  // Side-effect: specs → work items (picked up below)
2969
3457
  materializeSpecsAsWorkItems(config, project);
2970
3458
 
@@ -2972,6 +3460,9 @@ function discoverWork(config) {
2972
3460
  allWorkItems.push(...discoverFromWorkItems(config, project));
2973
3461
  }
2974
3462
 
3463
+ // Source 2: Squad-level PRD → implements (multi-project, called once outside project loop)
3464
+ allImplements.push(...discoverFromPrd(config));
3465
+
2975
3466
  // Central work items (project-agnostic — agent decides where to work)
2976
3467
  const centralWork = discoverCentralWorkItems(config);
2977
3468
 
@@ -3008,7 +3499,10 @@ async function tick() {
3008
3499
 
3009
3500
  async function tickInner() {
3010
3501
  const control = getControl();
3011
- if (control.state !== 'running') return;
3502
+ if (control.state !== 'running') {
3503
+ log('info', `Engine state is "${control.state}" — exiting process`);
3504
+ process.exit(0);
3505
+ }
3012
3506
 
3013
3507
  const config = getConfig();
3014
3508
  tickCount++;
@@ -3031,6 +3525,11 @@ async function tickInner() {
3031
3525
  try { await pollPrStatus(config); } catch (e) { log('warn', `PR status poll error: ${e.message}`); }
3032
3526
  }
3033
3527
 
3528
+ // 2.7. Poll PR threads for human @squad comments (every 12 ticks = ~6 minutes)
3529
+ if (tickCount % 12 === 0) {
3530
+ try { await pollPrHumanComments(config); } catch (e) { log('warn', `PR comment poll error: ${e.message}`); }
3531
+ }
3532
+
3034
3533
  // 3. Discover new work from sources
3035
3534
  discoverWork(config);
3036
3535
 
@@ -3050,7 +3549,7 @@ async function tickInner() {
3050
3549
  const slotsAvailable = maxConcurrent - activeCount;
3051
3550
 
3052
3551
  // Priority dispatch: fixes > reviews > plan-to-prd > implement > other
3053
- const typePriority = { fix: 0, review: 1, test: 2, 'plan-to-prd': 3, 'implement:large': 4, implement: 5 };
3552
+ const typePriority = { fix: 0, ask: 1, review: 1, test: 2, plan: 3, 'plan-to-prd': 3, 'implement:large': 4, implement: 5 };
3054
3553
  const itemPriority = { high: 0, medium: 1, low: 2 };
3055
3554
  dispatch.pending.sort((a, b) => {
3056
3555
  const ta = typePriority[a.type] ?? 5, tb = typePriority[b.type] ?? 5;
@@ -3081,8 +3580,16 @@ const commands = {
3081
3580
  start() {
3082
3581
  const control = getControl();
3083
3582
  if (control.state === 'running') {
3084
- console.log('Engine is already running.');
3085
- return;
3583
+ // Check if the PID is actually alive
3584
+ let alive = false;
3585
+ if (control.pid) {
3586
+ try { process.kill(control.pid, 0); alive = true; } catch {}
3587
+ }
3588
+ if (alive) {
3589
+ console.log(`Engine is already running (PID ${control.pid}).`);
3590
+ return;
3591
+ }
3592
+ console.log(`Engine was running (PID ${control.pid}) but process is dead — restarting.`);
3086
3593
  }
3087
3594
 
3088
3595
  safeWrite(CONTROL_PATH, { state: 'running', pid: process.pid, started_at: ts() });
@@ -3147,6 +3654,11 @@ const commands = {
3147
3654
  console.log(' On next start, they\'ll get a 20-min grace period before being marked as orphans.');
3148
3655
  console.log(' To kill them now, run: node engine.js kill\n');
3149
3656
  }
3657
+ // Kill the running engine process by PID
3658
+ const control = getControl();
3659
+ if (control.pid && control.pid !== process.pid) {
3660
+ try { process.kill(control.pid); } catch {}
3661
+ }
3150
3662
  safeWrite(CONTROL_PATH, { state: 'stopped', stopped_at: ts() });
3151
3663
  log('info', 'Engine stopped');
3152
3664
  console.log('Engine stopped.');