@yemi33/squad 0.1.5 → 0.1.7

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
@@ -600,16 +600,29 @@ function spawnAgent(dispatchItem, config) {
600
600
  worktreePath = path.resolve(rootDir, engineConfig.worktreeRoot || '../worktrees', branchName);
601
601
  try {
602
602
  if (!fs.existsSync(worktreePath)) {
603
- log('info', `Creating worktree: ${worktreePath} on branch ${branchName}`);
604
- execSync(`git worktree add "${worktreePath}" -b "${branchName}" ${sanitizeBranch(project.mainBranch || 'main')}`, {
605
- cwd: rootDir, stdio: 'pipe'
606
- });
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 {}
607
620
  }
608
621
  cwd = worktreePath;
609
622
  } catch (err) {
610
623
  log('error', `Failed to create worktree for ${branchName}: ${err.message}`);
611
624
  // Fall back to main directory for non-writing tasks
612
- if (type === 'review' || type === 'analyze' || type === 'plan-to-prd') {
625
+ if (type === 'review' || type === 'analyze' || type === 'plan-to-prd' || type === 'plan') {
613
626
  cwd = rootDir;
614
627
  } else {
615
628
  completeDispatch(id, 'error', 'Worktree creation failed');
@@ -730,6 +743,16 @@ function spawnAgent(dispatchItem, config) {
730
743
  updateWorkItemStatus(meta, 'done', '');
731
744
  }
732
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
+
733
756
  // Post-completion: scan output for PRs and sync to pull-requests.json
734
757
  if (code === 0) {
735
758
  syncPrsFromOutput(stdout, agentId, meta, config);
@@ -872,6 +895,223 @@ function completeDispatch(id, result = 'success', reason = '') {
872
895
  }
873
896
  }
874
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
+
875
1115
  function updateWorkItemStatus(meta, status, reason) {
876
1116
  const itemId = meta.item?.id;
877
1117
  if (!itemId) return;
@@ -1334,6 +1574,114 @@ async function pollPrStatus(config) {
1334
1574
  }
1335
1575
  }
1336
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
+
1337
1685
  // ─── Post-Merge / Post-Close Hooks ───────────────────────────────────────────
1338
1686
 
1339
1687
  async function handlePostMerge(pr, project, config, newStatus) {
@@ -1358,11 +1706,9 @@ async function handlePostMerge(pr, project, config, newStatus) {
1358
1706
  // Only run remaining hooks for merged PRs (not abandoned)
1359
1707
  if (newStatus !== 'merged') return;
1360
1708
 
1361
- // 2. Update PRD item status to 'implemented'
1709
+ // 2. Update PRD item status to 'implemented' (squad-level PRD)
1362
1710
  if (pr.prdItems?.length > 0) {
1363
- const root = path.resolve(project.localPath);
1364
- const prdSrc = project.workSources?.prd || {};
1365
- const prdPath = path.resolve(root, prdSrc.path || 'docs/prd-gaps.json');
1711
+ const prdPath = path.join(SQUAD_DIR, 'prd.json');
1366
1712
  const prd = safeJson(prdPath);
1367
1713
  if (prd?.missing_features) {
1368
1714
  let updated = 0;
@@ -2191,33 +2537,49 @@ function isAlreadyDispatched(key) {
2191
2537
  }
2192
2538
 
2193
2539
  /**
2194
- * 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.
2195
2542
  */
2196
- function discoverFromPrd(config, project) {
2197
- const src = project?.workSources?.prd || config.workSources?.prd;
2198
- if (!src?.enabled) return [];
2199
-
2200
- const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(SQUAD_DIR, '..');
2201
- const prdPath = path.resolve(root, src.path);
2543
+ function discoverFromPrd(config) {
2544
+ const prdPath = path.join(SQUAD_DIR, 'prd.json');
2202
2545
  const prd = safeJson(prdPath);
2203
2546
  if (!prd) return [];
2204
2547
 
2205
- const cooldownMs = (src.cooldownMinutes || 30) * 60 * 1000;
2206
- 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'];
2207
2550
  const items = (prd.missing_features || []).filter(f => statusFilter.includes(f.status));
2208
2551
  const newWork = [];
2209
- const skipped = { dispatched: 0, cooldown: 0, noAgent: 0 };
2552
+ const skipped = { dispatched: 0, cooldown: 0, noAgent: 0, noProject: 0 };
2553
+ const allProjects = config.projects || [];
2210
2554
 
2211
2555
  for (const item of items) {
2212
- const key = `prd-${project?.name || 'default'}-${item.id}`;
2556
+ const key = `prd-squad-${item.id}`;
2213
2557
  if (isAlreadyDispatched(key)) { skipped.dispatched++; continue; }
2214
2558
  if (isOnCooldown(key, cooldownMs)) { skipped.cooldown++; continue; }
2215
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
+
2216
2566
  const workType = item.estimated_complexity === 'large' ? 'implement:large' : 'implement';
2217
2567
  const agentId = resolveAgent(workType, config);
2218
2568
  if (!agentId) { skipped.noAgent++; continue; }
2219
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);
2220
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
+
2221
2583
  const vars = {
2222
2584
  agent_id: agentId,
2223
2585
  agent_name: config.agents[agentId]?.name || agentId,
@@ -2229,15 +2591,16 @@ function discoverFromPrd(config, project) {
2229
2591
  item_description: item.description || '',
2230
2592
  branch_name: branchName,
2231
2593
  project_path: root,
2232
- main_branch: project?.mainBranch || 'main',
2594
+ main_branch: primary.mainBranch || 'main',
2233
2595
  worktree_path: path.resolve(root, config.engine?.worktreeRoot || '../worktrees', branchName),
2234
2596
  commit_message: `feat(${item.id.toLowerCase()}): ${item.name}`,
2235
2597
  team_root: SQUAD_DIR,
2236
- repo_id: project?.repositoryId || config.project?.repositoryId || '',
2237
- project_name: project?.name || config.project?.name || 'Unknown Project',
2238
- ado_org: project?.adoOrg || config.project?.adoOrg || 'Unknown',
2239
- ado_project: project?.adoProject || config.project?.adoProject || 'Unknown',
2240
- 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,
2241
2604
  };
2242
2605
 
2243
2606
  const prompt = renderPlaybook('implement', vars);
@@ -2248,17 +2611,17 @@ function discoverFromPrd(config, project) {
2248
2611
  agent: agentId,
2249
2612
  agentName: config.agents[agentId]?.name,
2250
2613
  agentRole: config.agents[agentId]?.role,
2251
- task: `[${project?.name || 'project'}] Implement ${item.id}: ${item.name}`,
2614
+ task: `[${vars.project_name}] Implement ${item.id}: ${item.name}`,
2252
2615
  prompt,
2253
- 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 } }
2254
2617
  });
2255
2618
 
2256
2619
  setCooldown(key);
2257
2620
  }
2258
2621
 
2259
- const skipTotal = skipped.dispatched + skipped.cooldown + skipped.noAgent;
2622
+ const skipTotal = skipped.dispatched + skipped.cooldown + skipped.noAgent + skipped.noProject;
2260
2623
  if (skipTotal > 0) {
2261
- 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)`);
2262
2625
  }
2263
2626
 
2264
2627
  return newWork;
@@ -2316,7 +2679,12 @@ function materializePlansAsWorkItems(config) {
2316
2679
  created: ts(),
2317
2680
  createdBy: 'engine:plan-discovery',
2318
2681
  sourcePlan: file,
2319
- 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,
2320
2688
  });
2321
2689
  created++;
2322
2690
  }
@@ -2324,6 +2692,36 @@ function materializePlansAsWorkItems(config) {
2324
2692
  if (created > 0) {
2325
2693
  safeWrite(wiPath, existingItems);
2326
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
+ }
2327
2725
  }
2328
2726
  }
2329
2727
  }
@@ -2429,6 +2827,51 @@ function discoverFromPrs(config, project) {
2429
2827
  setCooldown(key);
2430
2828
  }
2431
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
+
2432
2875
  // PRs with build failures → route to any idle agent
2433
2876
  if (pr.status === 'active' && pr.buildStatus === 'failing') {
2434
2877
  const key = `build-fix-${project?.name || 'default'}-${pr.id}`;
@@ -2545,6 +2988,9 @@ function discoverFromWorkItems(config, project) {
2545
2988
  for (const item of items) {
2546
2989
  if (item.status !== 'queued' && item.status !== 'pending') continue;
2547
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
+
2548
2994
  const key = `work-${project?.name || 'default'}-${item.id}`;
2549
2995
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) { skipped.gated++; continue; }
2550
2996
 
@@ -2555,7 +3001,8 @@ function discoverFromWorkItems(config, project) {
2555
3001
  const agentId = item.agent || resolveAgent(workType, config);
2556
3002
  if (!agentId) { skipped.noAgent++; continue; }
2557
3003
 
2558
- 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}`);
2559
3006
  const vars = {
2560
3007
  agent_id: agentId,
2561
3008
  agent_name: config.agents[agentId]?.name || agentId,
@@ -2581,9 +3028,22 @@ function discoverFromWorkItems(config, project) {
2581
3028
  date: dateStamp()
2582
3029
  };
2583
3030
 
2584
- // Use type-specific playbook if it exists (e.g., explore.md, review.md), fall back to work-item.md
2585
- const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd'];
2586
- 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
+ }
2587
3047
  const prompt = item.prompt || renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars) || item.description;
2588
3048
  if (!prompt) {
2589
3049
  log('warn', `No playbook rendered for ${item.id} (type: ${workType}, playbook: ${playbookName}) — skipping`);
@@ -2602,7 +3062,7 @@ function discoverFromWorkItems(config, project) {
2602
3062
  agentRole: config.agents[agentId]?.role,
2603
3063
  task: `[${project?.name || 'project'}] ${item.title || item.description?.slice(0, 80) || item.id}`,
2604
3064
  prompt,
2605
- 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 } }
2606
3066
  });
2607
3067
 
2608
3068
  setCooldown(key);
@@ -2859,7 +3319,15 @@ function discoverCentralWorkItems(config) {
2859
3319
  date: dateStamp()
2860
3320
  };
2861
3321
 
2862
- 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'];
2863
3331
  const playbookName = typeSpecificPlaybooks.includes(workType) ? workType : 'work-item';
2864
3332
  const prompt = renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars);
2865
3333
  if (!prompt) continue;
@@ -2917,7 +3385,26 @@ function discoverCentralWorkItems(config) {
2917
3385
  date: dateStamp()
2918
3386
  };
2919
3387
 
2920
- 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'];
2921
3408
  const playbookName = typeSpecificPlaybooks.includes(workType) ? workType : 'work-item';
2922
3409
  const prompt = renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars);
2923
3410
  if (!prompt) continue;
@@ -2946,7 +3433,7 @@ function discoverCentralWorkItems(config) {
2946
3433
 
2947
3434
  /**
2948
3435
  * Run all work discovery sources and queue new items
2949
- * 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)
2950
3437
  */
2951
3438
  function discoverWork(config) {
2952
3439
  const projects = getProjects(config);
@@ -2966,9 +3453,6 @@ function discoverWork(config) {
2966
3453
  allReviews.push(...prWork.filter(w => w.type === 'review'));
2967
3454
  allWorkItems.push(...prWork.filter(w => w.type === 'test'));
2968
3455
 
2969
- // Source 2: PRD gaps → implements
2970
- allImplements.push(...discoverFromPrd(config, project));
2971
-
2972
3456
  // Side-effect: specs → work items (picked up below)
2973
3457
  materializeSpecsAsWorkItems(config, project);
2974
3458
 
@@ -2976,6 +3460,9 @@ function discoverWork(config) {
2976
3460
  allWorkItems.push(...discoverFromWorkItems(config, project));
2977
3461
  }
2978
3462
 
3463
+ // Source 2: Squad-level PRD → implements (multi-project, called once outside project loop)
3464
+ allImplements.push(...discoverFromPrd(config));
3465
+
2979
3466
  // Central work items (project-agnostic — agent decides where to work)
2980
3467
  const centralWork = discoverCentralWorkItems(config);
2981
3468
 
@@ -3038,6 +3525,11 @@ async function tickInner() {
3038
3525
  try { await pollPrStatus(config); } catch (e) { log('warn', `PR status poll error: ${e.message}`); }
3039
3526
  }
3040
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
+
3041
3533
  // 3. Discover new work from sources
3042
3534
  discoverWork(config);
3043
3535
 
@@ -3057,7 +3549,7 @@ async function tickInner() {
3057
3549
  const slotsAvailable = maxConcurrent - activeCount;
3058
3550
 
3059
3551
  // Priority dispatch: fixes > reviews > plan-to-prd > implement > other
3060
- 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 };
3061
3553
  const itemPriority = { high: 0, medium: 1, low: 2 };
3062
3554
  dispatch.pending.sort((a, b) => {
3063
3555
  const ta = typePriority[a.type] ?? 5, tb = typePriority[b.type] ?? 5;