@yemi33/squad 0.1.16 → 0.1.18

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 CHANGED
@@ -151,7 +151,7 @@
151
151
  .prd-item-priority.low { background: rgba(139,148,158,0.15); color: var(--muted); }
152
152
  .prd-project-badge { font-size: 9px; padding: 1px 5px; border-radius: 6px; background: rgba(56,139,253,0.12); color: var(--blue); border: 1px solid rgba(56,139,253,0.25); white-space: nowrap; }
153
153
 
154
- .notes-preview { max-height: 240px; overflow-y: auto; font-size: 12px; line-height: 1.6; color: var(--muted); font-family: Consolas, monospace; white-space: pre-wrap; word-wrap: break-word; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; cursor: pointer; transition: border-color 0.2s; }
154
+ .notes-preview { max-height: 400px; overflow-y: auto; font-size: 12px; line-height: 1.6; color: var(--muted); font-family: Consolas, monospace; white-space: pre-wrap; word-wrap: break-word; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; cursor: pointer; transition: border-color 0.2s; }
155
155
  .notes-preview:hover { border-color: var(--blue); }
156
156
  .inbox-item { background: var(--surface2); border: 1px solid var(--border); border-left: 3px solid var(--purple); border-radius: 4px; padding: 10px 12px; cursor: pointer; }
157
157
  .inbox-item:hover { border-color: var(--blue); border-left-color: var(--blue); }
@@ -553,7 +553,7 @@
553
553
  </section>
554
554
 
555
555
  <section>
556
- <h2>Team Notes</h2>
556
+ <h2>Team Notes <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0" id="notes-updated"></span></h2>
557
557
  <div id="notes-list">Loading...</div>
558
558
  </section>
559
559
 
@@ -621,6 +621,9 @@
621
621
  <div class="modal-header">
622
622
  <h3 id="modal-title">—</h3>
623
623
  <div class="modal-header-actions">
624
+ <button class="modal-copy" id="modal-edit-btn" onclick="modalToggleEdit()" title="Edit" style="display:none"><svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61zM12.9 2.97L4.288 11.58l-.537 1.878 1.878-.537L14.242 4.31 12.9 2.97z"/></svg> Edit</button>
625
+ <button class="modal-copy" id="modal-save-btn" onclick="modalSaveEdit()" title="Save" style="display:none;color:var(--green);border-color:var(--green)"><svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/></svg> Save</button>
626
+ <button class="modal-copy" id="modal-cancel-edit-btn" onclick="modalCancelEdit()" title="Cancel edit" style="display:none">Cancel</button>
624
627
  <button class="modal-copy" id="modal-copy-btn" onclick="copyModalContent()" title="Copy to clipboard"><svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25z"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25z"/></svg> Copy</button>
625
628
  <button class="modal-close" onclick="closeModal()">X</button>
626
629
  </div>
@@ -938,20 +941,87 @@ async function openInboxInExplorer(name) {
938
941
 
939
942
  function renderNotes(notes) {
940
943
  const el = document.getElementById('notes-list');
941
- if (!notes || !notes.trim()) { el.innerHTML = '<p class="empty">No team notes yet.</p>'; return; }
942
- el.innerHTML = '<div class="notes-preview" onclick="openNotesModal()" title="Click to expand">' + escHtml(notes) + '</div>';
944
+ const content = typeof notes === 'object' ? notes.content : notes;
945
+ const updatedAt = typeof notes === 'object' ? notes.updatedAt : null;
946
+
947
+ // Show last updated timestamp
948
+ const updatedEl = document.getElementById('notes-updated');
949
+ if (updatedEl && updatedAt) {
950
+ const d = new Date(updatedAt);
951
+ updatedEl.textContent = 'updated ' + d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
952
+ }
953
+
954
+ if (!content || !content.trim()) { el.innerHTML = '<p class="empty">No team notes yet.</p>'; return; }
955
+ el.innerHTML = '<div class="notes-preview" onclick="openNotesModal()" title="Click to expand">' + escHtml(content) + '</div>';
943
956
  }
944
957
 
958
+ let _modalEditable = null; // tracks which file is editable (e.g., 'notes.md')
959
+
945
960
  function openNotesModal() {
946
961
  const preview = document.querySelector('.notes-preview');
947
962
  if (!preview) return;
963
+ const content = preview.textContent;
948
964
  document.getElementById('modal-title').textContent = 'Team Notes';
949
- document.getElementById('modal-body').textContent = preview.textContent;
965
+ document.getElementById('modal-body').textContent = content;
950
966
  document.getElementById('modal-body').style.fontFamily = 'Consolas, monospace';
951
967
  document.getElementById('modal-body').style.whiteSpace = 'pre-wrap';
968
+ _modalDocContext = { title: 'Team Notes', content, selection: '' };
969
+ _modalEditable = 'notes.md';
970
+ document.getElementById('modal-edit-btn').style.display = '';
952
971
  document.getElementById('modal').classList.add('open');
953
972
  }
954
973
 
974
+ function modalToggleEdit() {
975
+ const body = document.getElementById('modal-body');
976
+ body.contentEditable = 'true';
977
+ body.style.border = '1px solid var(--blue)';
978
+ body.style.borderRadius = '4px';
979
+ body.style.padding = '12px';
980
+ body.style.outline = 'none';
981
+ body.focus();
982
+ document.getElementById('modal-edit-btn').style.display = 'none';
983
+ document.getElementById('modal-save-btn').style.display = '';
984
+ document.getElementById('modal-cancel-edit-btn').style.display = '';
985
+ }
986
+
987
+ async function modalSaveEdit() {
988
+ if (!_modalEditable) return;
989
+ const body = document.getElementById('modal-body');
990
+ const content = body.innerText;
991
+
992
+ try {
993
+ const res = await fetch('/api/notes-save', {
994
+ method: 'POST',
995
+ headers: { 'Content-Type': 'application/json' },
996
+ body: JSON.stringify({ file: _modalEditable, content }),
997
+ });
998
+ const data = await res.json();
999
+ if (!res.ok) throw new Error(data.error || 'Save failed');
1000
+
1001
+ body.contentEditable = 'false';
1002
+ body.style.border = '';
1003
+ body.style.padding = '';
1004
+ document.getElementById('modal-edit-btn').style.display = '';
1005
+ document.getElementById('modal-save-btn').style.display = 'none';
1006
+ document.getElementById('modal-cancel-edit-btn').style.display = 'none';
1007
+ _modalDocContext.content = content;
1008
+ showToast('cmd-toast', 'Team Notes saved', true);
1009
+ } catch (e) {
1010
+ showToast('cmd-toast', 'Error: ' + e.message, false);
1011
+ }
1012
+ }
1013
+
1014
+ function modalCancelEdit() {
1015
+ const body = document.getElementById('modal-body');
1016
+ body.contentEditable = 'false';
1017
+ body.textContent = _modalDocContext.content; // revert
1018
+ body.style.border = '';
1019
+ body.style.padding = '';
1020
+ document.getElementById('modal-edit-btn').style.display = '';
1021
+ document.getElementById('modal-save-btn').style.display = 'none';
1022
+ document.getElementById('modal-cancel-edit-btn').style.display = 'none';
1023
+ }
1024
+
955
1025
  function renderPrd(prd) {
956
1026
  const section = document.getElementById('prd-content');
957
1027
  const badge = document.getElementById('prd-badge');
@@ -1070,6 +1140,15 @@ function closeModal() {
1070
1140
  document.getElementById('modal-qa-input').placeholder = 'Ask about this document (or select text first)...';
1071
1141
  document.getElementById('modal-qa-pill').style.display = 'none';
1072
1142
  document.getElementById('ask-selection-btn').style.display = 'none';
1143
+ // Clear edit state
1144
+ _modalEditable = null;
1145
+ const body = document.getElementById('modal-body');
1146
+ body.contentEditable = 'false';
1147
+ body.style.border = '';
1148
+ body.style.padding = '';
1149
+ document.getElementById('modal-edit-btn').style.display = 'none';
1150
+ document.getElementById('modal-save-btn').style.display = 'none';
1151
+ document.getElementById('modal-cancel-edit-btn').style.display = 'none';
1073
1152
  }
1074
1153
 
1075
1154
  document.addEventListener('keydown', e => {
package/dashboard.js CHANGED
@@ -228,7 +228,12 @@ function getInbox() {
228
228
  }
229
229
 
230
230
  function getNotes() {
231
- return safeRead(path.join(SQUAD_DIR, 'notes.md')) || '';
231
+ const notesPath = path.join(SQUAD_DIR, 'notes.md');
232
+ const content = safeRead(notesPath) || '';
233
+ try {
234
+ const stat = fs.statSync(notesPath);
235
+ return { content, updatedAt: stat.mtimeMs };
236
+ } catch { return { content, updatedAt: null }; }
232
237
  }
233
238
 
234
239
  function getPullRequests() {
@@ -844,6 +849,19 @@ const server = http.createServer(async (req, res) => {
844
849
  return;
845
850
  }
846
851
 
852
+ // POST /api/notes-save — save edited notes.md content
853
+ if (req.method === 'POST' && req.url === '/api/notes-save') {
854
+ try {
855
+ const body = await readBody(req);
856
+ if (!body.content && body.content !== '') return jsonReply(res, 400, { error: 'content required' });
857
+ const file = body.file || 'notes.md';
858
+ // Only allow saving notes.md (prevent arbitrary file writes)
859
+ if (file !== 'notes.md') return jsonReply(res, 400, { error: 'only notes.md can be edited' });
860
+ safeWrite(path.join(SQUAD_DIR, file), body.content);
861
+ return jsonReply(res, 200, { ok: true });
862
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
863
+ }
864
+
847
865
  // GET /api/knowledge — list all knowledge base entries grouped by category
848
866
  if (req.method === 'GET' && req.url === '/api/knowledge') {
849
867
  const kbDir = path.join(SQUAD_DIR, 'knowledge');
@@ -0,0 +1,107 @@
1
+ # Human vs. Automated — What Requires You, What Doesn't
2
+
3
+ ## Quick Reference
4
+
5
+ | Feature | Who starts it | Who runs it | Who decides | Who recovers |
6
+ |---------|--------------|-------------|-------------|-------------|
7
+ | Work items | You (dashboard) | Engine + agent | — | You (retry) |
8
+ | Plans | You (dashboard) | Agent writes plan | You (approve/reject) | You (revise) |
9
+ | PRD items | You (dashboard) | Engine dispatches | — | You (retry) |
10
+ | PR creation | Agent (auto) | Agent | — | — |
11
+ | PR review | Engine (auto-dispatch) | Agent reviewer | Human (vote to merge) | Human (comments → auto-fix) |
12
+ | Build failures | Engine (auto-detect) | Agent (auto-fix) | — | — |
13
+ | Notes | You (`/note`) or agent (findings) | Engine (consolidate) | You (promote to KB) | — |
14
+ | Cleanup | Engine (every 5 min) | Engine | — | — |
15
+ | Metrics | Engine (auto-collect) | Engine | You (view) | — |
16
+ | Error recovery | Engine (detect) | — | You (retry/delete) | You |
17
+ | Project linking | You (`squad add/scan`) | — | — | — |
18
+ | MCP servers | You (`~/.claude.json`) | Inherited by agents | — | — |
19
+
20
+ ## The Two Human Gates
21
+
22
+ Squad is designed around **two approval gates** where humans make decisions. Everything else is automated.
23
+
24
+ ### Gate 1: Plan Approval
25
+
26
+ When you submit a `/plan`, an agent creates a structured plan file. The plan sits in `awaiting-approval` status until you:
27
+ - **Approve** → engine auto-dispatches all plan items
28
+ - **Reject** → plan is archived
29
+ - **Revise** → feedback sent back to agent, plan is reworked
30
+
31
+ This is the only point where you decide *what* gets built.
32
+
33
+ ### Gate 2: PR Review
34
+
35
+ When agents create PRs, they need human review votes before merging. You can:
36
+ - **Approve** → PR is merge-ready
37
+ - **Comment** → engine detects `@squad` mentions, auto-dispatches a fix task
38
+ - **Request changes** → same as comment, triggers auto-fix
39
+
40
+ This is the only point where you decide if the *quality* is good enough.
41
+
42
+ ## Fully Automated (Zero Human Involvement)
43
+
44
+ These run continuously without you:
45
+
46
+ - **Work discovery** — engine scans all project queues every tick (~30s)
47
+ - **Agent dispatch** — engine picks the right agent, builds the prompt, spawns Claude
48
+ - **Worktree management** — create on dispatch, pull on shared-branch, clean after merge
49
+ - **PR status polling** — checks ADO for build status, review votes, merge state every ~3 min
50
+ - **Build failure detection** — auto-files fix tasks when CI fails
51
+ - **Inbox consolidation** — LLM-powered dedup and categorization when inbox hits threshold
52
+ - **Knowledge base classification** — auto-assigns category to consolidated notes
53
+ - **Heartbeat monitoring** — detects hung/dead agents, marks them failed
54
+ - **Blocking tool detection** — extends timeout when agent is in a long-running operation
55
+ - **Metrics collection** — tracks tasks, errors, PRs, approvals per agent
56
+ - **Dispatch priority** — fixes first, then reviews, then implementations
57
+ - **Cooldown & backoff** — prevents re-dispatching recently failed items
58
+ - **Zombie cleanup** — temp files, orphaned worktrees, stale processes every 5 min
59
+ - **Post-merge hooks** — worktree cleanup, PRD status update, metrics update
60
+
61
+ ## Human-Triggered, Then Autonomous
62
+
63
+ You kick these off, then they run without you:
64
+
65
+ - **Work items** — type in dashboard, engine dispatches, agent executes, PR created
66
+ - **PRD items** — `/prd` in dashboard, engine discovers and dispatches implement tasks
67
+ - **Fan-out** — one task dispatched to all idle agents in parallel
68
+ - **Retry** — click retry on failed item, engine re-dispatches fresh
69
+ - **Notes** — `/note` in dashboard, flows through inbox → consolidation → team knowledge
70
+ - **KB promotion** — click "Add to Knowledge Base", pick category, done
71
+ - **Project linking** — `squad scan` or `squad add`, engine discovers work on next tick
72
+
73
+ ## Human-in-the-Loop
74
+
75
+ These pause and wait for your input:
76
+
77
+ - **Plan approval** — agent writes plan, waits for approve/reject/revise
78
+ - **Plan discussion** — interactive Claude session where you refine the plan
79
+ - **PR merge** — agents can't merge their own PRs, humans must vote
80
+ - **PR feedback cycle** — human comments → auto-fix → human re-reviews (loop until approved)
81
+
82
+ ## Manual Only
83
+
84
+ These are entirely on you:
85
+
86
+ - **Project setup** — `squad init`, `squad scan`, `squad add`
87
+ - **Agent customization** — edit `agents/*/charter.md`, `routing.md`
88
+ - **Config changes** — edit `config.json` (engine settings, projects)
89
+ - **MCP server setup** — add servers to `~/.claude.json`
90
+ - **Dashboard access** — open browser to `http://localhost:7331`
91
+ - **Engine start/stop** — `node engine.js start/stop`
92
+
93
+ ## What Happens When You Walk Away
94
+
95
+ If you start the engine and dashboard, then leave:
96
+
97
+ 1. Engine ticks every 30 seconds
98
+ 2. Discovers pending work items, PRD gaps, PR reviews needed
99
+ 3. Dispatches agents (up to max concurrent)
100
+ 4. Agents create worktrees, write code, create PRs
101
+ 5. Engine monitors for completion, hung agents, build failures
102
+ 6. Successful work → PRs appear in your ADO/GitHub queue
103
+ 7. Failed work → marked failed, waiting for your retry
104
+ 8. Notes consolidated into team knowledge automatically
105
+ 9. Worktrees cleaned up after PRs merge
106
+
107
+ **What blocks:** Plans waiting for approval. PRs waiting for your review vote. Failed tasks waiting for retry. Everything else keeps moving.
package/engine.js CHANGED
@@ -797,8 +797,9 @@ function spawnAgent(dispatchItem, config) {
797
797
  }
798
798
 
799
799
  // Post-completion: scan output for PRs and sync to pull-requests.json
800
+ let prsCreatedCount = 0;
800
801
  if (code === 0) {
801
- syncPrsFromOutput(stdout, agentId, meta, config);
802
+ prsCreatedCount = syncPrsFromOutput(stdout, agentId, meta, config) || 0;
802
803
  }
803
804
 
804
805
  // Post-completion: update PR status if relevant
@@ -817,11 +818,12 @@ function spawnAgent(dispatchItem, config) {
817
818
  updateAgentHistory(agentId, dispatchItem, code === 0 ? 'success' : 'error');
818
819
 
819
820
  // Update quality metrics
820
- updateMetrics(agentId, dispatchItem, code === 0 ? 'success' : 'error', taskUsage);
821
+ updateMetrics(agentId, dispatchItem, code === 0 ? 'success' : 'error', taskUsage, prsCreatedCount);
821
822
 
822
- // Cleanup temp files
823
+ // Cleanup temp files (including PID file now that dispatch is complete)
823
824
  try { fs.unlinkSync(sysPromptPath); } catch {}
824
825
  try { fs.unlinkSync(promptPath); } catch {}
826
+ try { fs.unlinkSync(promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid')); } catch {}
825
827
 
826
828
  log('info', `Agent ${agentId} completed. Output saved to ${outputPath}`);
827
829
  });
@@ -856,6 +858,7 @@ function spawnAgent(dispatchItem, config) {
856
858
  }
857
859
 
858
860
  // Verify spawn after 5 seconds via PID file written by spawn-agent.js
861
+ // PID file is kept (not deleted) so engine can re-attach on restart
859
862
  setTimeout(() => {
860
863
  const pidFile = promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid');
861
864
  try {
@@ -863,12 +866,10 @@ function spawnAgent(dispatchItem, config) {
863
866
  if (pidStr) {
864
867
  log('info', `Agent ${agentId} verified via PID file: ${pidStr}`);
865
868
  }
866
- try { fs.unlinkSync(pidFile); } catch {}
869
+ // Don't delete keep for re-attachment on engine restart
867
870
  } catch {
868
- // No PID file — check if live output exists (spawn-agent.js may have written it)
869
871
  if (!fs.existsSync(liveOutputPath) || fs.statSync(liveOutputPath).size <= 200) {
870
872
  log('error', `Agent ${agentId} (${id}) — no PID file and no output after 5s. Spawn likely failed.`);
871
- // Don't mark as error yet — heartbeat will catch it at 5min
872
873
  }
873
874
  }
874
875
  }, 5000);
@@ -1045,10 +1046,7 @@ ${itemSummary}
1045
1046
  // a plan-to-prd task so Lambert converts it to structured PRD items.
1046
1047
  function chainPlanToPrd(dispatchItem, meta, config) {
1047
1048
  const planDir = path.join(SQUAD_DIR, 'plans');
1048
- if (!fs.existsSync(planDir)) {
1049
- log('warn', `Plan chaining: no plans/ directory found after plan task ${dispatchItem.id}`);
1050
- return;
1051
- }
1049
+ if (!fs.existsSync(planDir)) fs.mkdirSync(planDir, { recursive: true });
1052
1050
 
1053
1051
  // Use the plan filename from dispatch meta (set during plan task creation)
1054
1052
  // Falls back to mtime-based detection if meta doesn't have it
@@ -1322,7 +1320,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
1322
1320
  while ((match = prHeaderPattern.exec(content)) !== null) prMatches.add(match[1] || match[2]);
1323
1321
  }
1324
1322
 
1325
- if (prMatches.size === 0) return;
1323
+ if (prMatches.size === 0) return 0;
1326
1324
 
1327
1325
  // Determine which project to add PRs to
1328
1326
  const projects = getProjects(config);
@@ -1385,6 +1383,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
1385
1383
  safeWrite(prPath, prs);
1386
1384
  log('info', `Synced ${added} PR(s) from ${agentName}'s output to ${targetProject.name}/pull-requests.json`);
1387
1385
  }
1386
+ return added;
1388
1387
  }
1389
1388
 
1390
1389
  // ─── Post-Completion Hooks ──────────────────────────────────────────────────
@@ -1411,14 +1410,23 @@ function updatePrAfterReview(agentId, pr, project) {
1411
1410
  note: agentStatus.task || ''
1412
1411
  };
1413
1412
 
1414
- // Update author metrics
1413
+ // Update author metrics (deduplicated per PR — don't double-count re-reviews)
1415
1414
  const authorAgentId = (pr.agent || '').toLowerCase();
1416
1415
  if (authorAgentId && config.agents?.[authorAgentId]) {
1417
1416
  const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
1418
1417
  const metrics = safeJson(metricsPath) || {};
1419
1418
  if (!metrics[authorAgentId]) metrics[authorAgentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, reviewsDone:0, lastTask:null, lastCompleted:null };
1420
- if (squadVerdict === 'approved') metrics[authorAgentId].prsApproved++;
1421
- else if (squadVerdict === 'changes-requested') metrics[authorAgentId].prsRejected++;
1419
+ if (!metrics[authorAgentId]._reviewedPrs) metrics[authorAgentId]._reviewedPrs = {};
1420
+ const prevVerdict = metrics[authorAgentId]._reviewedPrs[pr.id];
1421
+ if (prevVerdict !== squadVerdict) {
1422
+ // Undo previous count if verdict changed (e.g. approved → changes-requested)
1423
+ if (prevVerdict === 'approved') metrics[authorAgentId].prsApproved = Math.max(0, (metrics[authorAgentId].prsApproved || 0) - 1);
1424
+ else if (prevVerdict === 'changes-requested') metrics[authorAgentId].prsRejected = Math.max(0, (metrics[authorAgentId].prsRejected || 0) - 1);
1425
+ // Apply new verdict
1426
+ if (squadVerdict === 'approved') metrics[authorAgentId].prsApproved++;
1427
+ else if (squadVerdict === 'changes-requested') metrics[authorAgentId].prsRejected++;
1428
+ metrics[authorAgentId]._reviewedPrs[pr.id] = squadVerdict;
1429
+ }
1422
1430
  safeWrite(metricsPath, metrics);
1423
1431
  }
1424
1432
 
@@ -2002,7 +2010,7 @@ function createReviewFeedbackForAuthor(reviewerAgentId, pr, config) {
2002
2010
  log('info', `Created review feedback for ${authorAgentId} from ${reviewerAgentId} on ${pr.id}`);
2003
2011
  }
2004
2012
 
2005
- function updateMetrics(agentId, dispatchItem, result, taskUsage) {
2013
+ function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount) {
2006
2014
  const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
2007
2015
  const metrics = safeJson(metricsPath) || {};
2008
2016
 
@@ -2029,7 +2037,7 @@ function updateMetrics(agentId, dispatchItem, result, taskUsage) {
2029
2037
 
2030
2038
  if (result === 'success') {
2031
2039
  m.tasksCompleted++;
2032
- if (dispatchItem.type === 'implement') m.prsCreated++;
2040
+ if (prsCreatedCount > 0) m.prsCreated = (m.prsCreated || 0) + prsCreatedCount;
2033
2041
  if (dispatchItem.type === 'review') m.reviewsDone++;
2034
2042
  } else {
2035
2043
  m.tasksErrored++;
@@ -3848,6 +3856,8 @@ function discoverCentralWorkItems(config) {
3848
3856
 
3849
3857
  // Inject plan-specific variables for the plan playbook
3850
3858
  if (workType === 'plan') {
3859
+ // Ensure plans directory exists before agent tries to write
3860
+ if (!fs.existsSync(PLANS_DIR)) fs.mkdirSync(PLANS_DIR, { recursive: true });
3851
3861
  const planFileName = `plan-${item.id.toLowerCase()}-${dateStamp()}.md`;
3852
3862
  vars.plan_content = item.title + (item.description ? '\n\n' + item.description : '');
3853
3863
  vars.plan_title = item.title;
@@ -4080,15 +4090,72 @@ const commands = {
4080
4090
  // Load persistent state
4081
4091
  loadCooldowns();
4082
4092
 
4083
- // Grace period for agents that survived a restart
4093
+ // Re-attach to surviving agent processes from previous session
4084
4094
  const dispatch = getDispatch();
4085
4095
  const activeOnStart = (dispatch.active || []);
4086
4096
  if (activeOnStart.length > 0) {
4087
- const gracePeriod = config.engine?.restartGracePeriod || 1200000; // 20min default
4088
- engineRestartGraceUntil = Date.now() + gracePeriod;
4089
- console.log(` ${activeOnStart.length} active dispatch(es) from previous session ${gracePeriod / 60000}min grace period before orphan detection`);
4097
+ let reattached = 0;
4098
+ for (const item of activeOnStart) {
4099
+ // Try to find the agent's PID: check status.json, or scan for live-output.log activity
4100
+ const agentId = item.agent;
4101
+ let agentPid = null;
4102
+
4103
+ // Method 1: Check PID file (if it wasn't cleaned up)
4104
+ const pidFile = path.join(ENGINE_DIR, `pid-${item.id}.pid`);
4105
+ try {
4106
+ const pidStr = fs.readFileSync(pidFile, 'utf8').trim();
4107
+ if (pidStr) agentPid = parseInt(pidStr);
4108
+ } catch {}
4109
+
4110
+ // Method 2: Check agent status.json for dispatch_id match
4111
+ if (!agentPid) {
4112
+ const status = getAgentStatus(agentId);
4113
+ if (status.dispatch_id === item.id) {
4114
+ // Agent was working on this dispatch — check if any process is producing output
4115
+ const liveLog = path.join(AGENTS_DIR, agentId, 'live-output.log');
4116
+ try {
4117
+ const stat = fs.statSync(liveLog);
4118
+ const ageMs = Date.now() - stat.mtimeMs;
4119
+ if (ageMs < 300000) { // live-output modified in last 5 min — agent likely alive
4120
+ agentPid = -1; // sentinel: alive but unknown PID
4121
+ }
4122
+ } catch {}
4123
+ }
4124
+ }
4125
+
4126
+ // Verify PID is actually alive
4127
+ if (agentPid && agentPid > 0) {
4128
+ try {
4129
+ if (process.platform === 'win32') {
4130
+ const out = execSync(`tasklist /FI "PID eq ${agentPid}" /NH`, { encoding: 'utf8', timeout: 3000 });
4131
+ if (!out.includes(String(agentPid))) agentPid = null;
4132
+ } else {
4133
+ process.kill(agentPid, 0);
4134
+ }
4135
+ } catch { agentPid = null; }
4136
+ }
4137
+
4138
+ if (agentPid) {
4139
+ // Re-attach: add sentinel to activeProcesses so orphan detector knows it's alive
4140
+ activeProcesses.set(item.id, { proc: { pid: agentPid > 0 ? agentPid : null }, agentId, startedAt: item.created_at, reattached: true });
4141
+ reattached++;
4142
+ log('info', `Re-attached to ${agentId} (${item.id}) — PID ${agentPid > 0 ? agentPid : 'unknown (active output)'}`);
4143
+ }
4144
+ }
4145
+
4146
+ // Grace period only for dispatches we couldn't re-attach to
4147
+ const unattached = activeOnStart.length - reattached;
4148
+ if (unattached > 0) {
4149
+ const gracePeriod = config.engine?.restartGracePeriod || 1200000; // 20min default
4150
+ engineRestartGraceUntil = Date.now() + gracePeriod;
4151
+ console.log(` ${unattached} unattached dispatch(es) — ${gracePeriod / 60000}min grace period`);
4152
+ }
4153
+ if (reattached > 0) {
4154
+ console.log(` Re-attached to ${reattached} surviving agent(s)`);
4155
+ }
4090
4156
  for (const item of activeOnStart) {
4091
- console.log(` - ${item.agentName || item.agent}: ${(item.task || '').slice(0, 70)}`);
4157
+ const attached = activeProcesses.has(item.id);
4158
+ console.log(` ${attached ? '✓' : '?'} ${item.agentName || item.agent}: ${(item.task || '').slice(0, 70)}`);
4092
4159
  }
4093
4160
  }
4094
4161
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/squad",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.squad/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "squad": "bin/squad.js"