@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/dashboard.html +117 -11
- package/dashboard.js +95 -10
- package/docs/self-improvement.md +25 -1
- package/engine.js +566 -54
- package/package.json +1 -1
- package/playbooks/ask.md +49 -0
- package/playbooks/implement-shared.md +68 -0
- package/playbooks/implement.md +13 -0
- package/playbooks/plan-to-prd.md +22 -1
- package/playbooks/plan.md +99 -0
- package/routing.md +2 -0
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 <
|
|
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 <
|
|
138
|
-
|
|
139
|
-
const start = Date.now(); while (Date.now() - start <
|
|
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
|
-
|
|
142
|
+
// Final attempt failed — fall through to direct write
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
|
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 (
|
|
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
|
|
2193
|
-
const
|
|
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 = (
|
|
2202
|
-
const statusFilter =
|
|
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-${
|
|
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:
|
|
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:
|
|
2233
|
-
project_name:
|
|
2234
|
-
ado_org:
|
|
2235
|
-
ado_project:
|
|
2236
|
-
repo_name:
|
|
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: `[${
|
|
2614
|
+
task: `[${vars.project_name}] Implement ${item.id}: ${item.name}`,
|
|
2248
2615
|
prompt,
|
|
2249
|
-
meta: { dispatchKey: key, source: 'prd', branch: branchName, item, project: { name:
|
|
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
|
|
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
|
|
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
|
-
//
|
|
2581
|
-
|
|
2582
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
-
|
|
3085
|
-
|
|
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.');
|