@yemi33/squad 0.1.12 → 0.1.14
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 +557 -8
- package/dashboard.js +340 -2
- package/engine.js +152 -6
- package/package.json +1 -1
- package/playbooks/plan-to-prd.md +2 -0
package/dashboard.html
CHANGED
|
@@ -47,6 +47,71 @@
|
|
|
47
47
|
.status-badge.working { background: rgba(210,153,34,0.15); color: var(--yellow); border: 1px solid var(--yellow); animation: pulse 1.5s infinite; }
|
|
48
48
|
.status-badge.done { background: rgba(63,185,80,0.15); color: var(--green); border: 1px solid var(--green); }
|
|
49
49
|
.agent-action { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
|
50
|
+
.modal-qa { border-top: 1px solid var(--border); padding: 10px 20px; }
|
|
51
|
+
.modal-qa-thread { max-height: 200px; overflow-y: auto; margin-bottom: 8px; }
|
|
52
|
+
.modal-qa-q { font-size: 12px; color: var(--blue); margin-bottom: 4px; font-weight: 600; }
|
|
53
|
+
.modal-qa-q .selection-ref { font-weight: 400; color: var(--muted); font-style: italic; display: block; font-size: 10px; margin-top: 2px; }
|
|
54
|
+
.modal-qa-a { font-size: 12px; color: var(--text); margin-bottom: 12px; padding: 8px 10px; background: var(--surface2); border-radius: 6px; border-left: 2px solid var(--blue); white-space: pre-wrap; word-break: break-word; line-height: 1.5; }
|
|
55
|
+
.modal-qa-loading { font-size: 11px; color: var(--muted); padding: 8px 10px; display: flex; align-items: center; gap: 8px; }
|
|
56
|
+
.modal-qa-loading .dot-pulse { display: inline-flex; gap: 3px; }
|
|
57
|
+
.modal-qa-loading .dot-pulse span { width: 5px; height: 5px; background: var(--blue); border-radius: 50%; animation: dotPulse 1.2s infinite; }
|
|
58
|
+
.modal-qa-loading .dot-pulse span:nth-child(2) { animation-delay: 0.2s; }
|
|
59
|
+
.modal-qa-loading .dot-pulse span:nth-child(3) { animation-delay: 0.4s; }
|
|
60
|
+
@keyframes dotPulse { 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } 40% { opacity: 1; transform: scale(1); } }
|
|
61
|
+
.modal-qa-input-wrap { display: flex; gap: 6px; }
|
|
62
|
+
.modal-qa-input { flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 6px 10px; font-size: 12px; color: var(--text); font-family: inherit; }
|
|
63
|
+
.modal-qa-input:focus { border-color: var(--blue); outline: none; }
|
|
64
|
+
.modal-qa-btn { background: var(--blue); color: #fff; border: none; border-radius: 4px; padding: 6px 14px; font-size: 12px; cursor: pointer; }
|
|
65
|
+
.modal-qa-btn:hover { opacity: 0.9; }
|
|
66
|
+
.modal-qa-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
67
|
+
.ask-selection-btn { display: none; position: fixed; z-index: 500; background: var(--blue); color: #fff; font-size: 11px; padding: 5px 12px; border-radius: 4px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
|
|
68
|
+
.ask-selection-btn:hover { opacity: 0.9; }
|
|
69
|
+
.plan-card { background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; padding: 12px; margin-bottom: 8px; }
|
|
70
|
+
.plan-card.awaiting { border-left: 3px solid var(--yellow, #d29922); }
|
|
71
|
+
.plan-card.approved { border-left: 3px solid var(--green); }
|
|
72
|
+
.plan-card.rejected { border-left: 3px solid var(--red); opacity: 0.6; }
|
|
73
|
+
.plan-card.revision-requested { border-left: 3px solid var(--purple, #a855f7); }
|
|
74
|
+
.plan-card-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; }
|
|
75
|
+
.plan-card-title { font-size: 13px; font-weight: 600; color: var(--text); }
|
|
76
|
+
.plan-card-meta { font-size: 10px; color: var(--muted); margin-top: 4px; display: flex; gap: 8px; flex-wrap: wrap; }
|
|
77
|
+
.plan-card-actions { display: flex; gap: 4px; margin-top: 8px; flex-wrap: wrap; }
|
|
78
|
+
.plan-btn { font-size: 11px; padding: 4px 10px; border-radius: 4px; cursor: pointer; border: 1px solid var(--border); background: var(--surface); color: var(--text); transition: all 0.15s; }
|
|
79
|
+
.plan-btn:hover { border-color: var(--text); }
|
|
80
|
+
.plan-btn.approve { color: var(--green); border-color: var(--green); }
|
|
81
|
+
.plan-btn.approve:hover { background: rgba(63,185,80,0.1); }
|
|
82
|
+
.plan-btn.revise { color: var(--yellow, #d29922); border-color: var(--yellow, #d29922); }
|
|
83
|
+
.plan-btn.revise:hover { background: rgba(210,153,34,0.1); }
|
|
84
|
+
.plan-btn.reject { color: var(--red); border-color: var(--red); }
|
|
85
|
+
.plan-btn.reject:hover { background: rgba(248,81,73,0.1); }
|
|
86
|
+
.plan-feedback-input { width: 100%; margin-top: 6px; padding: 6px 8px; font-size: 11px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-family: inherit; resize: vertical; min-height: 50px; }
|
|
87
|
+
.token-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 8px; margin-bottom: 12px; }
|
|
88
|
+
.token-tile { background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; }
|
|
89
|
+
.token-tile-label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
|
90
|
+
.token-tile-value { font-size: 20px; font-weight: 700; color: var(--text); margin-top: 2px; }
|
|
91
|
+
.token-tile-sub { font-size: 10px; color: var(--muted); margin-top: 2px; }
|
|
92
|
+
.token-chart { display: flex; align-items: flex-end; gap: 3px; height: 80px; margin: 8px 0; }
|
|
93
|
+
.token-bar { flex: 1; min-width: 8px; max-width: 24px; background: var(--blue); border-radius: 2px 2px 0 0; position: relative; cursor: default; transition: background 0.15s; }
|
|
94
|
+
.token-bar:hover { background: var(--green); }
|
|
95
|
+
.token-bar-tip { display: none; position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); background: var(--surface); border: 1px solid var(--border); border-radius: 4px; padding: 3px 6px; font-size: 9px; white-space: nowrap; z-index: 10; color: var(--text); }
|
|
96
|
+
.token-bar:hover .token-bar-tip { display: block; }
|
|
97
|
+
.token-chart-labels { display: flex; gap: 3px; }
|
|
98
|
+
.token-chart-labels span { flex: 1; min-width: 8px; max-width: 24px; font-size: 8px; color: var(--muted); text-align: center; overflow: hidden; }
|
|
99
|
+
.token-agent-table { width: 100%; margin-top: 10px; }
|
|
100
|
+
.token-agent-table th { text-align: left; font-size: 10px; color: var(--muted); font-weight: 500; padding: 4px 8px; border-bottom: 1px solid var(--border); }
|
|
101
|
+
.token-agent-table td { font-size: 11px; padding: 4px 8px; }
|
|
102
|
+
.kb-tabs { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; }
|
|
103
|
+
.kb-tab { background: var(--surface2); border: 1px solid var(--border); color: var(--muted); font-size: 11px; cursor: pointer; padding: 3px 10px; border-radius: 4px; transition: all 0.2s; }
|
|
104
|
+
.kb-tab:hover { color: var(--text); border-color: var(--text); }
|
|
105
|
+
.kb-tab.active { color: var(--blue); border-color: var(--blue); background: rgba(88,166,255,0.08); }
|
|
106
|
+
.kb-tab .badge { background: var(--border); color: var(--text); font-size: 9px; padding: 0 5px; border-radius: 8px; margin-left: 4px; }
|
|
107
|
+
.kb-list { max-height: 400px; overflow-y: auto; }
|
|
108
|
+
.kb-item { display: flex; align-items: flex-start; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); cursor: pointer; transition: background 0.1s; }
|
|
109
|
+
.kb-item:hover { background: var(--surface2); }
|
|
110
|
+
.kb-item:last-child { border-bottom: none; }
|
|
111
|
+
.kb-item-body { flex: 1; min-width: 0; }
|
|
112
|
+
.kb-item-title { font-size: 12px; color: var(--text); font-weight: 500; }
|
|
113
|
+
.kb-item-meta { font-size: 10px; color: var(--muted); margin-top: 2px; display: flex; gap: 8px; }
|
|
114
|
+
.kb-item-preview { font-size: 10px; color: var(--muted); margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
|
50
115
|
.agent-result { font-size: 10px; color: var(--text); background: var(--surface2); padding: 6px 8px; border-radius: 4px; margin-top: 6px; white-space: pre-wrap; word-break: break-word; max-height: 80px; overflow-y: auto; line-height: 1.4; border-left: 2px solid var(--blue); }
|
|
51
116
|
.agent-card { min-width: 0; }
|
|
52
117
|
.agent-emoji { font-size: 20px; margin-right: 4px; }
|
|
@@ -422,10 +487,12 @@
|
|
|
422
487
|
<div class="cmd-hints">
|
|
423
488
|
<span><code>@agent</code> assign</span>
|
|
424
489
|
<span><code>@everyone</code> fan-out</span>
|
|
490
|
+
<span><code>#project</code> target</span>
|
|
425
491
|
<span><code>!high</code> / <code>!low</code> priority</span>
|
|
426
|
-
<span><code>/
|
|
492
|
+
<span><code>/plan</code> feature plan</span>
|
|
427
493
|
<span><code>/prd</code> PRD item</span>
|
|
428
|
-
<span><code
|
|
494
|
+
<span><code>/note</code> team note</span>
|
|
495
|
+
<span>or just type a task</span>
|
|
429
496
|
<button class="cmd-history-btn" onclick="cmdShowHistory()">Past Commands</button>
|
|
430
497
|
</div>
|
|
431
498
|
<div class="cmd-toast" id="cmd-toast"></div>
|
|
@@ -453,6 +520,11 @@
|
|
|
453
520
|
<div id="pr-content"><p class="pr-empty">No pull requests yet.</p></div>
|
|
454
521
|
</section>
|
|
455
522
|
|
|
523
|
+
<section>
|
|
524
|
+
<h2>Plans <span class="count" id="plans-count">0</span></h2>
|
|
525
|
+
<div id="plans-list"><p class="empty">No plans yet. Use /plan in the command center to create one.</p></div>
|
|
526
|
+
</section>
|
|
527
|
+
|
|
456
528
|
<section>
|
|
457
529
|
<h2>Notes Inbox <span class="count" id="inbox-count">0</span></h2>
|
|
458
530
|
<div class="inbox-list" id="inbox-list">Loading...</div>
|
|
@@ -463,6 +535,12 @@
|
|
|
463
535
|
<div id="notes-list">Loading...</div>
|
|
464
536
|
</section>
|
|
465
537
|
|
|
538
|
+
<section>
|
|
539
|
+
<h2>Knowledge Base <span class="count" id="kb-count">0</span></h2>
|
|
540
|
+
<div class="kb-tabs" id="kb-tabs"></div>
|
|
541
|
+
<div class="kb-list" id="kb-list"><p class="empty">No knowledge entries yet. Notes are classified here after consolidation.</p></div>
|
|
542
|
+
</section>
|
|
543
|
+
|
|
466
544
|
<section>
|
|
467
545
|
<h2>Squad Skills <span class="count" id="skills-count">0</span></h2>
|
|
468
546
|
<div id="skills-list"><p class="empty">No skills yet. Agents create these when they discover repeatable workflows.</p></div>
|
|
@@ -485,6 +563,11 @@
|
|
|
485
563
|
<div id="metrics-content"><p class="empty">No metrics yet. Metrics appear after agents complete tasks.</p></div>
|
|
486
564
|
</section>
|
|
487
565
|
|
|
566
|
+
<section>
|
|
567
|
+
<h2>Token Usage</h2>
|
|
568
|
+
<div id="token-usage-content"><p class="empty">No usage data yet.</p></div>
|
|
569
|
+
</section>
|
|
570
|
+
|
|
488
571
|
<section class="pr-panel" id="completed-section">
|
|
489
572
|
<h2>Recent Completions <span class="count" id="completed-count">0</span></h2>
|
|
490
573
|
<div id="completed-content"><p class="empty">No completed dispatches yet.</p></div>
|
|
@@ -516,9 +599,19 @@
|
|
|
516
599
|
</div>
|
|
517
600
|
</div>
|
|
518
601
|
<div class="modal-body" id="modal-body"></div>
|
|
602
|
+
<div class="modal-qa" id="modal-qa">
|
|
603
|
+
<div class="modal-qa-thread" id="modal-qa-thread"></div>
|
|
604
|
+
<div class="modal-qa-input-wrap">
|
|
605
|
+
<input type="text" class="modal-qa-input" id="modal-qa-input" placeholder="Ask about this document (or select text first)..." onkeydown="if(event.key==='Enter')modalAskSubmit()">
|
|
606
|
+
<button class="modal-qa-btn" id="modal-qa-btn" onclick="modalAskSubmit()">Ask</button>
|
|
607
|
+
</div>
|
|
608
|
+
</div>
|
|
519
609
|
</div>
|
|
520
610
|
</div>
|
|
521
611
|
|
|
612
|
+
<!-- Floating "Ask about selection" button -->
|
|
613
|
+
<div class="ask-selection-btn" id="ask-selection-btn" onclick="modalAskAboutSelection()">Ask about this</div>
|
|
614
|
+
|
|
522
615
|
<script>
|
|
523
616
|
let inboxData = [];
|
|
524
617
|
let agentData = [];
|
|
@@ -629,7 +722,26 @@ function renderDetailContent(detail, tab) {
|
|
|
629
722
|
} else if (tab === 'charter') {
|
|
630
723
|
el.innerHTML = '<div class="section">' + escHtml(detail.charter || 'No charter found.') + '</div>';
|
|
631
724
|
} else if (tab === 'history') {
|
|
632
|
-
|
|
725
|
+
let html = '';
|
|
726
|
+
// Recent dispatch results
|
|
727
|
+
if (detail.recentDispatches && detail.recentDispatches.length > 0) {
|
|
728
|
+
html += '<h4>Recent Dispatches</h4><table class="pr-table" style="margin-bottom:16px"><thead><tr><th>Task</th><th>Type</th><th>Result</th><th>Completed</th></tr></thead><tbody>';
|
|
729
|
+
detail.recentDispatches.forEach(d => {
|
|
730
|
+
const isError = d.result === 'error';
|
|
731
|
+
const color = isError ? 'var(--red)' : 'var(--green)';
|
|
732
|
+
const reason = d.reason ? ' title="' + escHtml(d.reason) + '"' : '';
|
|
733
|
+
html += '<tr>' +
|
|
734
|
+
'<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml(d.task) + '">' + escHtml(d.task.slice(0, 80)) + '</td>' +
|
|
735
|
+
'<td><span class="dispatch-type ' + d.type + '">' + escHtml(d.type) + '</span></td>' +
|
|
736
|
+
'<td style="color:' + color + '"' + reason + '>' + escHtml(d.result) + (isError && d.reason ? ' <span style="font-size:10px;color:var(--muted)">(' + escHtml(d.reason.slice(0, 50)) + ')</span>' : '') + '</td>' +
|
|
737
|
+
'<td style="font-size:10px;color:var(--muted)">' + (d.completed_at ? new Date(d.completed_at).toLocaleString() : '') + '</td>' +
|
|
738
|
+
'</tr>';
|
|
739
|
+
});
|
|
740
|
+
html += '</tbody></table>';
|
|
741
|
+
}
|
|
742
|
+
// Raw history.md
|
|
743
|
+
html += '<h4>Task History</h4><div class="section">' + escHtml(detail.history || 'No history yet.') + '</div>';
|
|
744
|
+
el.innerHTML = html;
|
|
633
745
|
} else if (tab === 'output') {
|
|
634
746
|
el.innerHTML = '<div class="section">' + escHtml(detail.outputLog || 'No output log. The coordinator will save agent output here when tasks complete.') + '</div>';
|
|
635
747
|
}
|
|
@@ -893,7 +1005,15 @@ function openModal(i) {
|
|
|
893
1005
|
document.getElementById('modal-body').textContent = item.content;
|
|
894
1006
|
document.getElementById('modal').classList.add('open');
|
|
895
1007
|
}
|
|
896
|
-
function closeModal() {
|
|
1008
|
+
function closeModal() {
|
|
1009
|
+
document.getElementById('modal').classList.remove('open');
|
|
1010
|
+
// Clear Q&A state
|
|
1011
|
+
_modalDocContext = { title: '', content: '', selection: '' };
|
|
1012
|
+
document.getElementById('modal-qa-thread').innerHTML = '';
|
|
1013
|
+
document.getElementById('modal-qa-input').value = '';
|
|
1014
|
+
document.getElementById('modal-qa-input').placeholder = 'Ask about this document (or select text first)...';
|
|
1015
|
+
document.getElementById('ask-selection-btn').style.display = 'none';
|
|
1016
|
+
}
|
|
897
1017
|
|
|
898
1018
|
document.addEventListener('keydown', e => {
|
|
899
1019
|
if (e.key === 'Escape') { closeDetail(); closeModal(); }
|
|
@@ -1134,6 +1254,9 @@ async function refresh() {
|
|
|
1134
1254
|
renderMetrics(data.metrics || {});
|
|
1135
1255
|
renderWorkItems(data.workItems || []);
|
|
1136
1256
|
renderSkills(data.skills || []);
|
|
1257
|
+
// Refresh KB and plans less frequently (every 3rd cycle = ~12s)
|
|
1258
|
+
if (!window._kbRefreshCount) window._kbRefreshCount = 0;
|
|
1259
|
+
if (window._kbRefreshCount++ % 3 === 0) { refreshKnowledgeBase(); refreshPlans(); }
|
|
1137
1260
|
} catch(e) { console.error('refresh error', e); }
|
|
1138
1261
|
}
|
|
1139
1262
|
|
|
@@ -1357,9 +1480,10 @@ function openAllWorkItems() {
|
|
|
1357
1480
|
// -- Metrics --
|
|
1358
1481
|
function renderMetrics(metrics) {
|
|
1359
1482
|
const el = document.getElementById('metrics-content');
|
|
1360
|
-
const agents = Object.entries(metrics);
|
|
1483
|
+
const agents = Object.entries(metrics).filter(([k]) => !k.startsWith('_'));
|
|
1361
1484
|
if (agents.length === 0) {
|
|
1362
1485
|
el.innerHTML = '<p class="empty">No metrics yet. Metrics appear after agents complete tasks.</p>';
|
|
1486
|
+
renderTokenUsage(metrics);
|
|
1363
1487
|
return;
|
|
1364
1488
|
}
|
|
1365
1489
|
let html = '<table class="pr-table"><thead><tr><th>Agent</th><th>Done</th><th>Errors</th><th>PRs</th><th>Approved</th><th>Rejected</th><th>Rate</th><th>Reviews</th></tr></thead><tbody>';
|
|
@@ -1379,6 +1503,85 @@ function renderMetrics(metrics) {
|
|
|
1379
1503
|
}
|
|
1380
1504
|
html += '</tbody></table>';
|
|
1381
1505
|
el.innerHTML = html;
|
|
1506
|
+
renderTokenUsage(metrics);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function renderTokenUsage(metrics) {
|
|
1510
|
+
const el = document.getElementById('token-usage-content');
|
|
1511
|
+
const agents = Object.entries(metrics).filter(([k]) => !k.startsWith('_'));
|
|
1512
|
+
const daily = metrics._daily || {};
|
|
1513
|
+
|
|
1514
|
+
// Aggregate totals
|
|
1515
|
+
let totalCost = 0, totalInput = 0, totalOutput = 0, totalCache = 0;
|
|
1516
|
+
for (const [, m] of agents) {
|
|
1517
|
+
totalCost += m.totalCostUsd || 0;
|
|
1518
|
+
totalInput += m.totalInputTokens || 0;
|
|
1519
|
+
totalOutput += m.totalOutputTokens || 0;
|
|
1520
|
+
totalCache += m.totalCacheRead || 0;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
if (totalCost === 0 && Object.keys(daily).length === 0) {
|
|
1524
|
+
el.innerHTML = '<p class="empty">No usage data yet. Token tracking starts on next agent completion.</p>';
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
const fmtTokens = (n) => n >= 1000000 ? (n / 1000000).toFixed(1) + 'M' : n >= 1000 ? (n / 1000).toFixed(0) + 'K' : String(n);
|
|
1529
|
+
const fmtCost = (n) => '$' + n.toFixed(2);
|
|
1530
|
+
|
|
1531
|
+
// Summary tiles
|
|
1532
|
+
let html = '<div class="token-tiles">';
|
|
1533
|
+
html += '<div class="token-tile"><div class="token-tile-label">Total Cost</div><div class="token-tile-value">' + fmtCost(totalCost) + '</div></div>';
|
|
1534
|
+
html += '<div class="token-tile"><div class="token-tile-label">Input Tokens</div><div class="token-tile-value">' + fmtTokens(totalInput) + '</div></div>';
|
|
1535
|
+
html += '<div class="token-tile"><div class="token-tile-label">Output Tokens</div><div class="token-tile-value">' + fmtTokens(totalOutput) + '</div></div>';
|
|
1536
|
+
html += '<div class="token-tile"><div class="token-tile-label">Cache Reads</div><div class="token-tile-value">' + fmtTokens(totalCache) + '</div></div>';
|
|
1537
|
+
|
|
1538
|
+
// Today's cost
|
|
1539
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1540
|
+
const todayData = daily[today];
|
|
1541
|
+
if (todayData) {
|
|
1542
|
+
html += '<div class="token-tile"><div class="token-tile-label">Today</div><div class="token-tile-value">' + fmtCost(todayData.costUsd) + '</div><div class="token-tile-sub">' + todayData.tasks + ' tasks</div></div>';
|
|
1543
|
+
}
|
|
1544
|
+
html += '</div>';
|
|
1545
|
+
|
|
1546
|
+
// Daily bar chart (last 14 days)
|
|
1547
|
+
const days = Object.keys(daily).sort().slice(-14);
|
|
1548
|
+
if (days.length > 1) {
|
|
1549
|
+
const maxCost = Math.max(...days.map(d => daily[d].costUsd || 0), 0.01);
|
|
1550
|
+
html += '<div style="font-size:10px;color:var(--muted);margin:8px 0 4px">Daily Cost (last ' + days.length + ' days)</div>';
|
|
1551
|
+
html += '<div class="token-chart">';
|
|
1552
|
+
for (const day of days) {
|
|
1553
|
+
const d = daily[day];
|
|
1554
|
+
const pct = Math.max(((d.costUsd || 0) / maxCost) * 100, 2);
|
|
1555
|
+
html += '<div class="token-bar" style="height:' + pct + '%"><div class="token-bar-tip">' + day.slice(5) + ': ' + fmtCost(d.costUsd) + ' / ' + d.tasks + ' tasks</div></div>';
|
|
1556
|
+
}
|
|
1557
|
+
html += '</div>';
|
|
1558
|
+
html += '<div class="token-chart-labels">';
|
|
1559
|
+
for (const day of days) {
|
|
1560
|
+
html += '<span>' + day.slice(8) + '</span>';
|
|
1561
|
+
}
|
|
1562
|
+
html += '</div>';
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// Per-agent token table
|
|
1566
|
+
const agentsWithUsage = agents.filter(([, m]) => (m.totalCostUsd || 0) > 0);
|
|
1567
|
+
if (agentsWithUsage.length > 0) {
|
|
1568
|
+
html += '<table class="token-agent-table"><thead><tr><th>Agent</th><th>Cost</th><th>Input</th><th>Output</th><th>Cache</th><th>$/task</th></tr></thead><tbody>';
|
|
1569
|
+
for (const [id, m] of agentsWithUsage.sort((a, b) => (b[1].totalCostUsd || 0) - (a[1].totalCostUsd || 0))) {
|
|
1570
|
+
const tasks = (m.tasksCompleted || 0) + (m.tasksErrored || 0);
|
|
1571
|
+
const perTask = tasks > 0 ? fmtCost((m.totalCostUsd || 0) / tasks) : '-';
|
|
1572
|
+
html += '<tr>' +
|
|
1573
|
+
'<td style="font-weight:600">' + escHtml(id) + '</td>' +
|
|
1574
|
+
'<td>' + fmtCost(m.totalCostUsd || 0) + '</td>' +
|
|
1575
|
+
'<td>' + fmtTokens(m.totalInputTokens || 0) + '</td>' +
|
|
1576
|
+
'<td>' + fmtTokens(m.totalOutputTokens || 0) + '</td>' +
|
|
1577
|
+
'<td>' + fmtTokens(m.totalCacheRead || 0) + '</td>' +
|
|
1578
|
+
'<td style="color:var(--muted)">' + perTask + '</td>' +
|
|
1579
|
+
'</tr>';
|
|
1580
|
+
}
|
|
1581
|
+
html += '</tbody></table>';
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
el.innerHTML = html;
|
|
1382
1585
|
}
|
|
1383
1586
|
|
|
1384
1587
|
// -- Command Center (Unified Input) --
|
|
@@ -1404,7 +1607,7 @@ function detectWorkItemType(text) {
|
|
|
1404
1607
|
const t = text.toLowerCase();
|
|
1405
1608
|
const patterns = [
|
|
1406
1609
|
{ type: 'ask', words: ['explain', 'why does', 'why is', 'what does', 'how do i', 'how do you', 'what\'s the', 'tell me', 'can you explain', 'walk me through'] },
|
|
1407
|
-
{ type: 'explore', words: ['explore', 'investigate', 'understand', 'analyze', 'audit', 'document', 'architecture', 'how does', 'what is', 'look into', 'research', 'survey', 'map out', 'codebase'] },
|
|
1610
|
+
{ type: 'explore', words: ['explore', 'investigate', 'understand', 'analyze', 'audit', 'document', 'architecture', 'how does', 'what is', 'look into', 'research', 'survey', 'map out', 'codebase', 'make a note of', 'find out'] },
|
|
1408
1611
|
{ type: 'fix', words: ['fix', 'bug', 'broken', 'crash', 'error', 'issue', 'patch', 'repair', 'resolve', 'regression', 'failing', 'doesn\'t work', 'not working'] },
|
|
1409
1612
|
{ type: 'review', words: ['review', 'code review', 'check pr', 'look at pr', 'audit code', 'inspect'] },
|
|
1410
1613
|
{ type: 'test', words: ['test', 'write tests', 'add tests', 'unit test', 'e2e test', 'coverage', 'testing', 'build', 'run locally', 'localhost', 'start the', 'spin up', 'verify', 'check if it works'] },
|
|
@@ -1435,9 +1638,9 @@ function cmdParseInput(raw) {
|
|
|
1435
1638
|
if (/^\/decide\b/i.test(text) || /^\/note\b/i.test(text) || rememberPattern.test(text)) {
|
|
1436
1639
|
result.intent = 'note';
|
|
1437
1640
|
text = text.replace(/^\/decide\s*/i, '').replace(/^\/note\s*/i, '').replace(rememberPattern, '').trim();
|
|
1438
|
-
} else if (/^\/plan\b/i.test(text)) {
|
|
1641
|
+
} else if (/^\/plan\b/i.test(text) || /^(make a plan|plan out|plan for|plan how|create a plan|design a plan|come up with a plan|draft a plan|write a plan)\b/i.test(text)) {
|
|
1439
1642
|
result.intent = 'plan';
|
|
1440
|
-
text = text.replace(/^\/plan\s*/i, '');
|
|
1643
|
+
text = text.replace(/^\/plan\s*/i, '').replace(/^(make a plan for|plan out how|plan for how|plan how|create a plan for|design a plan for|come up with a plan for|draft a plan for|write a plan for|make a plan|plan out|create a plan|design a plan|come up with a plan|draft a plan|write a plan)\s*/i, '').trim();
|
|
1441
1644
|
// Extract branch strategy flag
|
|
1442
1645
|
if (/--parallel\b/i.test(text)) {
|
|
1443
1646
|
result.branchStrategy = 'parallel';
|
|
@@ -1846,6 +2049,352 @@ async function cmdSubmitPrd(parsed) {
|
|
|
1846
2049
|
const projLabel = (parsed.projects || []).length > 0 ? ' (' + parsed.projects.join(', ') + ')' : '';
|
|
1847
2050
|
showToast('cmd-toast', 'PRD item ' + (data.id || id) + ' added' + projLabel, true);
|
|
1848
2051
|
}
|
|
2052
|
+
// ─── Modal Q&A (Ask about document) ──────────────────────────────────────────
|
|
2053
|
+
|
|
2054
|
+
let _modalDocContext = { title: '', content: '', selection: '' };
|
|
2055
|
+
|
|
2056
|
+
// Track text selection in modal body for the floating "Ask about this" button
|
|
2057
|
+
document.addEventListener('mouseup', function(e) {
|
|
2058
|
+
const btn = document.getElementById('ask-selection-btn');
|
|
2059
|
+
const modalBody = document.getElementById('modal-body');
|
|
2060
|
+
const sel = window.getSelection();
|
|
2061
|
+
const text = sel?.toString()?.trim();
|
|
2062
|
+
|
|
2063
|
+
if (text && text.length > 5 && modalBody?.contains(sel?.anchorNode)) {
|
|
2064
|
+
_modalDocContext.selection = text;
|
|
2065
|
+
btn.style.display = 'block';
|
|
2066
|
+
btn.style.left = e.pageX + 'px';
|
|
2067
|
+
btn.style.top = (e.pageY - 35) + 'px';
|
|
2068
|
+
} else {
|
|
2069
|
+
btn.style.display = 'none';
|
|
2070
|
+
}
|
|
2071
|
+
});
|
|
2072
|
+
|
|
2073
|
+
function modalAskAboutSelection() {
|
|
2074
|
+
document.getElementById('ask-selection-btn').style.display = 'none';
|
|
2075
|
+
const input = document.getElementById('modal-qa-input');
|
|
2076
|
+
input.value = '';
|
|
2077
|
+
input.placeholder = 'Ask about: "' + _modalDocContext.selection.slice(0, 60) + '..."';
|
|
2078
|
+
input.focus();
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
async function modalAskSubmit() {
|
|
2082
|
+
const input = document.getElementById('modal-qa-input');
|
|
2083
|
+
const question = input.value.trim();
|
|
2084
|
+
if (!question) return;
|
|
2085
|
+
if (!_modalDocContext.content) return;
|
|
2086
|
+
|
|
2087
|
+
const thread = document.getElementById('modal-qa-thread');
|
|
2088
|
+
const btn = document.getElementById('modal-qa-btn');
|
|
2089
|
+
|
|
2090
|
+
// Show question
|
|
2091
|
+
let qHtml = '<div class="modal-qa-q">' + escHtml(question);
|
|
2092
|
+
if (_modalDocContext.selection) {
|
|
2093
|
+
qHtml += '<span class="selection-ref">Re: "' + escHtml(_modalDocContext.selection.slice(0, 100)) + ((_modalDocContext.selection.length > 100) ? '...' : '') + '"</span>';
|
|
2094
|
+
}
|
|
2095
|
+
qHtml += '</div>';
|
|
2096
|
+
thread.innerHTML += qHtml;
|
|
2097
|
+
|
|
2098
|
+
// Show loading
|
|
2099
|
+
const loadingId = 'qa-loading-' + Date.now();
|
|
2100
|
+
thread.innerHTML += '<div class="modal-qa-loading" id="' + loadingId + '"><div class="dot-pulse"><span></span><span></span><span></span></div> Thinking...</div>';
|
|
2101
|
+
thread.scrollTop = thread.scrollHeight;
|
|
2102
|
+
|
|
2103
|
+
input.value = '';
|
|
2104
|
+
input.placeholder = 'Ask another question...';
|
|
2105
|
+
btn.disabled = true;
|
|
2106
|
+
|
|
2107
|
+
try {
|
|
2108
|
+
const res = await fetch('/api/ask-about', {
|
|
2109
|
+
method: 'POST',
|
|
2110
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2111
|
+
body: JSON.stringify({
|
|
2112
|
+
question,
|
|
2113
|
+
document: _modalDocContext.content,
|
|
2114
|
+
title: _modalDocContext.title,
|
|
2115
|
+
selection: _modalDocContext.selection || '',
|
|
2116
|
+
}),
|
|
2117
|
+
});
|
|
2118
|
+
const data = await res.json();
|
|
2119
|
+
const loadingEl = document.getElementById(loadingId);
|
|
2120
|
+
if (loadingEl) loadingEl.remove();
|
|
2121
|
+
|
|
2122
|
+
if (data.ok && data.answer) {
|
|
2123
|
+
thread.innerHTML += '<div class="modal-qa-a">' + escHtml(data.answer) + '</div>';
|
|
2124
|
+
} else {
|
|
2125
|
+
thread.innerHTML += '<div class="modal-qa-a" style="color:var(--red)">Error: ' + escHtml(data.error || 'No answer') + '</div>';
|
|
2126
|
+
}
|
|
2127
|
+
} catch (e) {
|
|
2128
|
+
const loadingEl = document.getElementById(loadingId);
|
|
2129
|
+
if (loadingEl) loadingEl.remove();
|
|
2130
|
+
thread.innerHTML += '<div class="modal-qa-a" style="color:var(--red)">Error: ' + escHtml(e.message) + '</div>';
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
_modalDocContext.selection = ''; // Clear selection after asking
|
|
2134
|
+
btn.disabled = false;
|
|
2135
|
+
thread.scrollTop = thread.scrollHeight;
|
|
2136
|
+
input.focus();
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// Override closeModal to clear Q&A state
|
|
2140
|
+
const _origCloseModal = typeof closeModal === 'function' ? closeModal : null;
|
|
2141
|
+
|
|
2142
|
+
// ─── Plans (Approval Gate) ────────────────────────────────────────────────────
|
|
2143
|
+
|
|
2144
|
+
async function refreshPlans() {
|
|
2145
|
+
try {
|
|
2146
|
+
const plans = await fetch('/api/plans').then(r => r.json());
|
|
2147
|
+
renderPlans(plans);
|
|
2148
|
+
} catch {}
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
function renderPlans(plans) {
|
|
2152
|
+
const el = document.getElementById('plans-list');
|
|
2153
|
+
const countEl = document.getElementById('plans-count');
|
|
2154
|
+
countEl.textContent = plans.length;
|
|
2155
|
+
|
|
2156
|
+
if (plans.length === 0) {
|
|
2157
|
+
el.innerHTML = '<p class="empty">No plans yet. Use /plan in the command center to create one.</p>';
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
const statusLabels = { 'awaiting-approval': 'Awaiting Approval', 'approved': 'Approved', 'rejected': 'Rejected', 'revision-requested': 'Revision Requested', 'completed': 'Completed', 'active': 'Active' };
|
|
2162
|
+
const statusClass = (s) => s === 'awaiting-approval' ? 'awaiting' : s || '';
|
|
2163
|
+
|
|
2164
|
+
el.innerHTML = plans.map(p => {
|
|
2165
|
+
const status = p.status || 'active';
|
|
2166
|
+
const label = statusLabels[status] || status;
|
|
2167
|
+
const needsAction = status === 'awaiting-approval';
|
|
2168
|
+
const isRevision = status === 'revision-requested';
|
|
2169
|
+
|
|
2170
|
+
let actions = '';
|
|
2171
|
+
if (needsAction) {
|
|
2172
|
+
actions = '<div class="plan-card-actions">' +
|
|
2173
|
+
'<button class="plan-btn approve" onclick="planApprove(\'' + escHtml(p.file) + '\')">Approve</button>' +
|
|
2174
|
+
'<button class="plan-btn" style="color:var(--blue);border-color:var(--blue)" onclick="planDiscuss(\'' + escHtml(p.file) + '\')">Discuss & Revise</button>' +
|
|
2175
|
+
'<button class="plan-btn reject" onclick="planReject(\'' + escHtml(p.file) + '\')">Reject</button>' +
|
|
2176
|
+
'<button class="plan-btn" onclick="planView(\'' + escHtml(p.file) + '\')">View Full Plan</button>' +
|
|
2177
|
+
'</div>' +
|
|
2178
|
+
'<div id="revise-input-' + escHtml(p.file).replace(/\./g, '-') + '" style="display:none">' +
|
|
2179
|
+
'<textarea class="plan-feedback-input" placeholder="What should be changed? Be specific..." id="revise-feedback-' + escHtml(p.file).replace(/\./g, '-') + '"></textarea>' +
|
|
2180
|
+
'<div class="plan-card-actions" style="margin-top:4px">' +
|
|
2181
|
+
'<button class="plan-btn revise" onclick="planSubmitRevise(\'' + escHtml(p.file) + '\')">Submit Revision Request</button>' +
|
|
2182
|
+
'<button class="plan-btn" onclick="planHideRevise(\'' + escHtml(p.file) + '\')">Cancel</button>' +
|
|
2183
|
+
'</div>' +
|
|
2184
|
+
'</div>';
|
|
2185
|
+
} else if (isRevision) {
|
|
2186
|
+
actions = '<div class="plan-card-meta" style="margin-top:6px;color:var(--purple,#a855f7)">Revision in progress: ' + escHtml((p.revisionFeedback || '').slice(0, 100)) + '</div>';
|
|
2187
|
+
} else if (status === 'approved' || status === 'active') {
|
|
2188
|
+
actions = '<div class="plan-card-actions"><button class="plan-btn" onclick="planView(\'' + escHtml(p.file) + '\')">View</button></div>';
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
return '<div class="plan-card ' + statusClass(status) + '">' +
|
|
2192
|
+
'<div class="plan-card-header">' +
|
|
2193
|
+
'<div><div class="plan-card-title">' + escHtml(p.summary || p.file) + '</div>' +
|
|
2194
|
+
'<div class="plan-card-meta">' +
|
|
2195
|
+
'<span style="font-weight:600;color:' + (needsAction ? 'var(--yellow,#d29922)' : status === 'approved' ? 'var(--green)' : 'var(--muted)') + '">' + label + '</span>' +
|
|
2196
|
+
'<span>' + escHtml(p.project) + '</span>' +
|
|
2197
|
+
'<span>' + p.itemCount + ' items</span>' +
|
|
2198
|
+
'<span>' + escHtml(p.branchStrategy) + '</span>' +
|
|
2199
|
+
(p.generatedBy ? '<span>by ' + escHtml(p.generatedBy) + '</span>' : '') +
|
|
2200
|
+
(p.generatedAt ? '<span>' + p.generatedAt + '</span>' : '') +
|
|
2201
|
+
'</div>' +
|
|
2202
|
+
'</div>' +
|
|
2203
|
+
'</div>' +
|
|
2204
|
+
actions +
|
|
2205
|
+
'</div>';
|
|
2206
|
+
}).join('');
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
async function planApprove(file) {
|
|
2210
|
+
try {
|
|
2211
|
+
await fetch('/api/plans/approve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file }) });
|
|
2212
|
+
showToast('cmd-toast', 'Plan approved — work will begin on next engine tick', true);
|
|
2213
|
+
refreshPlans();
|
|
2214
|
+
} catch (e) { showToast('cmd-toast', 'Error: ' + e.message, false); }
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
async function planReject(file) {
|
|
2218
|
+
if (!confirm('Reject this plan? It will not be executed.')) return;
|
|
2219
|
+
const reason = prompt('Reason for rejection (optional):') || '';
|
|
2220
|
+
try {
|
|
2221
|
+
await fetch('/api/plans/reject', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file, reason }) });
|
|
2222
|
+
showToast('cmd-toast', 'Plan rejected', true);
|
|
2223
|
+
refreshPlans();
|
|
2224
|
+
} catch (e) { showToast('cmd-toast', 'Error: ' + e.message, false); }
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
function planShowRevise(file) {
|
|
2228
|
+
const id = 'revise-input-' + file.replace(/\./g, '-');
|
|
2229
|
+
document.getElementById(id).style.display = 'block';
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
function planHideRevise(file) {
|
|
2233
|
+
const id = 'revise-input-' + file.replace(/\./g, '-');
|
|
2234
|
+
document.getElementById(id).style.display = 'none';
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
async function planSubmitRevise(file) {
|
|
2238
|
+
const id = 'revise-feedback-' + file.replace(/\./g, '-');
|
|
2239
|
+
const feedback = document.getElementById(id).value.trim();
|
|
2240
|
+
if (!feedback) { showToast('cmd-toast', 'Please enter feedback', false); return; }
|
|
2241
|
+
try {
|
|
2242
|
+
const res = await fetch('/api/plans/revise', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file, feedback }) });
|
|
2243
|
+
const data = await res.json();
|
|
2244
|
+
showToast('cmd-toast', 'Revision requested — agent will update the plan (' + data.workItemId + ')', true);
|
|
2245
|
+
planHideRevise(file);
|
|
2246
|
+
refreshPlans();
|
|
2247
|
+
} catch (e) { showToast('cmd-toast', 'Error: ' + e.message, false); }
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
async function planDiscuss(file) {
|
|
2251
|
+
try {
|
|
2252
|
+
const res = await fetch('/api/plans/discuss', {
|
|
2253
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2254
|
+
body: JSON.stringify({ file })
|
|
2255
|
+
});
|
|
2256
|
+
const data = await res.json();
|
|
2257
|
+
if (!res.ok) throw new Error(data.error);
|
|
2258
|
+
|
|
2259
|
+
// Show the launch command in a modal
|
|
2260
|
+
const content = `To discuss and revise this plan interactively, run this command in a terminal:\n\n` +
|
|
2261
|
+
`━━━ Bash / Git Bash ━━━\n${data.command}\n\n` +
|
|
2262
|
+
`━━━ PowerShell ━━━\n${data.psCommand}\n\n` +
|
|
2263
|
+
`━━━━━━━━━━━━━━━━━━━━━━━\n\n` +
|
|
2264
|
+
`This launches an interactive Claude session with the plan pre-loaded.\n` +
|
|
2265
|
+
`Chat naturally to review and refine. When you're satisfied, say "approve" and the session will write the approved plan back to disk.\n\n` +
|
|
2266
|
+
`The engine will pick it up on the next tick and start dispatching work.`;
|
|
2267
|
+
|
|
2268
|
+
document.getElementById('modal-title').textContent = 'Discuss Plan: ' + file;
|
|
2269
|
+
document.getElementById('modal-body').textContent = content;
|
|
2270
|
+
document.getElementById('modal').classList.add('open');
|
|
2271
|
+
} catch (e) {
|
|
2272
|
+
showToast('cmd-toast', 'Error: ' + e.message, false);
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
async function planView(file) {
|
|
2277
|
+
try {
|
|
2278
|
+
const plan = await fetch('/api/plans/' + encodeURIComponent(file)).then(r => r.json());
|
|
2279
|
+
const items = (plan.missing_features || []).map((f, i) =>
|
|
2280
|
+
(i + 1) + '. [' + f.id + '] ' + f.name + ' (' + f.estimated_complexity + ', ' + f.priority + ')' +
|
|
2281
|
+
(f.depends_on?.length ? ' → depends on: ' + f.depends_on.join(', ') : '') +
|
|
2282
|
+
'\n ' + (f.description || '').slice(0, 150)
|
|
2283
|
+
).join('\n\n');
|
|
2284
|
+
const text = 'Project: ' + plan.project + '\nStrategy: ' + (plan.branch_strategy || 'parallel') +
|
|
2285
|
+
'\nBranch: ' + (plan.feature_branch || 'per-item') +
|
|
2286
|
+
'\nStatus: ' + (plan.status || 'active') +
|
|
2287
|
+
'\n\n--- Items ---\n\n' + items +
|
|
2288
|
+
(plan.open_questions?.length ? '\n\n--- Open Questions ---\n\n' + plan.open_questions.join('\n') : '');
|
|
2289
|
+
document.getElementById('modal-title').textContent = plan.plan_summary || file;
|
|
2290
|
+
document.getElementById('modal-body').textContent = text;
|
|
2291
|
+
document.getElementById('modal').classList.add('open');
|
|
2292
|
+
} catch (e) { console.error(e); }
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
// ─── Knowledge Base ──────────────────────────────────────────────────────────
|
|
2296
|
+
let _kbData = {};
|
|
2297
|
+
let _kbActiveTab = 'all';
|
|
2298
|
+
|
|
2299
|
+
const KB_CAT_LABELS = {
|
|
2300
|
+
architecture: 'Architecture',
|
|
2301
|
+
conventions: 'Conventions',
|
|
2302
|
+
'project-notes': 'Project Notes',
|
|
2303
|
+
'build-reports': 'Build Reports',
|
|
2304
|
+
reviews: 'Reviews',
|
|
2305
|
+
};
|
|
2306
|
+
const KB_CAT_ICONS = {
|
|
2307
|
+
architecture: '\u{1F3D7}',
|
|
2308
|
+
conventions: '\u{1F4CB}',
|
|
2309
|
+
'project-notes': '\u{1F4DD}',
|
|
2310
|
+
'build-reports': '\u{2699}',
|
|
2311
|
+
reviews: '\u{1F50D}',
|
|
2312
|
+
};
|
|
2313
|
+
|
|
2314
|
+
async function refreshKnowledgeBase() {
|
|
2315
|
+
try {
|
|
2316
|
+
_kbData = await fetch('/api/knowledge').then(r => r.json());
|
|
2317
|
+
renderKnowledgeBase();
|
|
2318
|
+
} catch {}
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
function renderKnowledgeBase() {
|
|
2322
|
+
const tabsEl = document.getElementById('kb-tabs');
|
|
2323
|
+
const listEl = document.getElementById('kb-list');
|
|
2324
|
+
const countEl = document.getElementById('kb-count');
|
|
2325
|
+
|
|
2326
|
+
// Count total
|
|
2327
|
+
let total = 0;
|
|
2328
|
+
for (const items of Object.values(_kbData)) total += items.length;
|
|
2329
|
+
countEl.textContent = total;
|
|
2330
|
+
|
|
2331
|
+
if (total === 0) {
|
|
2332
|
+
tabsEl.innerHTML = '';
|
|
2333
|
+
listEl.innerHTML = '<p class="empty">No knowledge entries yet. Notes are classified here after consolidation.</p>';
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
// Render tabs
|
|
2338
|
+
let tabsHtml = '<button class="kb-tab ' + (_kbActiveTab === 'all' ? 'active' : '') + '" onclick="kbSetTab(\'all\')">All <span class="badge">' + total + '</span></button>';
|
|
2339
|
+
for (const [cat, items] of Object.entries(_kbData)) {
|
|
2340
|
+
if (items.length === 0) continue;
|
|
2341
|
+
const label = KB_CAT_LABELS[cat] || cat;
|
|
2342
|
+
tabsHtml += '<button class="kb-tab ' + (_kbActiveTab === cat ? 'active' : '') + '" onclick="kbSetTab(\'' + cat + '\')">' + label + ' <span class="badge">' + items.length + '</span></button>';
|
|
2343
|
+
}
|
|
2344
|
+
tabsEl.innerHTML = tabsHtml;
|
|
2345
|
+
|
|
2346
|
+
// Collect items for active tab
|
|
2347
|
+
let items = [];
|
|
2348
|
+
if (_kbActiveTab === 'all') {
|
|
2349
|
+
for (const [cat, catItems] of Object.entries(_kbData)) {
|
|
2350
|
+
for (const item of catItems) items.push({ ...item, category: cat });
|
|
2351
|
+
}
|
|
2352
|
+
items.sort((a, b) => b.date.localeCompare(a.date));
|
|
2353
|
+
} else {
|
|
2354
|
+
items = (_kbData[_kbActiveTab] || []).map(i => ({ ...i, category: _kbActiveTab }));
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
if (items.length === 0) {
|
|
2358
|
+
listEl.innerHTML = '<p class="empty">No entries in this category.</p>';
|
|
2359
|
+
return;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
listEl.innerHTML = items.slice(0, 50).map(item => {
|
|
2363
|
+
const icon = KB_CAT_ICONS[item.category] || '\u{1F4C4}';
|
|
2364
|
+
const label = KB_CAT_LABELS[item.category] || item.category;
|
|
2365
|
+
return '<div class="kb-item" onclick="kbOpenItem(\'' + escHtml(item.category) + '\', \'' + escHtml(item.file) + '\')">' +
|
|
2366
|
+
'<div class="kb-item-body">' +
|
|
2367
|
+
'<div class="kb-item-title">' + icon + ' ' + escHtml(item.title) + '</div>' +
|
|
2368
|
+
'<div class="kb-item-meta">' +
|
|
2369
|
+
'<span>' + label + '</span>' +
|
|
2370
|
+
(item.agent ? '<span>' + item.agent + '</span>' : '') +
|
|
2371
|
+
'<span>' + (item.date || '') + '</span>' +
|
|
2372
|
+
'<span>' + Math.round(item.size / 1024) + 'KB</span>' +
|
|
2373
|
+
'</div>' +
|
|
2374
|
+
(item.preview ? '<div class="kb-item-preview">' + escHtml(item.preview) + '</div>' : '') +
|
|
2375
|
+
'</div>' +
|
|
2376
|
+
'</div>';
|
|
2377
|
+
}).join('');
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
function kbSetTab(tab) {
|
|
2381
|
+
_kbActiveTab = tab;
|
|
2382
|
+
renderKnowledgeBase();
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
async function kbOpenItem(category, file) {
|
|
2386
|
+
try {
|
|
2387
|
+
const content = await fetch('/api/knowledge/' + category + '/' + encodeURIComponent(file)).then(r => r.text());
|
|
2388
|
+
// Strip frontmatter for display
|
|
2389
|
+
const display = content.replace(/^---[\s\S]*?---\n*/m, '');
|
|
2390
|
+
document.getElementById('modal-title').textContent = file;
|
|
2391
|
+
document.getElementById('modal-body').textContent = display;
|
|
2392
|
+
document.getElementById('modal').classList.add('open');
|
|
2393
|
+
} catch (e) {
|
|
2394
|
+
console.error('Failed to load KB item:', e);
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
|
|
1849
2398
|
// ─── Command History ──────────────────────────────────────────────────────────
|
|
1850
2399
|
const CMD_HISTORY_KEY = 'squad-cmd-history';
|
|
1851
2400
|
const CMD_HISTORY_MAX = 50;
|
package/dashboard.js
CHANGED
|
@@ -75,7 +75,7 @@ function getAgentDetail(id) {
|
|
|
75
75
|
const agentDir = path.join(SQUAD_DIR, 'agents', id);
|
|
76
76
|
const charter = safeRead(path.join(agentDir, 'charter.md')) || 'No charter found.';
|
|
77
77
|
const history = safeRead(path.join(agentDir, 'history.md')) || 'No history yet.';
|
|
78
|
-
const outputLog = safeRead(path.join(agentDir, 'output.
|
|
78
|
+
const outputLog = safeRead(path.join(agentDir, 'output.log')) || '';
|
|
79
79
|
|
|
80
80
|
const statusJson = safeRead(path.join(agentDir, 'status.json'));
|
|
81
81
|
let statusData = null;
|
|
@@ -86,7 +86,25 @@ function getAgentDetail(id) {
|
|
|
86
86
|
.filter(f => f.includes(id))
|
|
87
87
|
.map(f => ({ name: f, content: safeRead(path.join(inboxDir, f)) || '' }));
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
// Recent completed dispatches for this agent (last 10)
|
|
90
|
+
let recentDispatches = [];
|
|
91
|
+
try {
|
|
92
|
+
const dispatch = JSON.parse(safeRead(path.join(SQUAD_DIR, 'engine', 'dispatch.json')) || '{}');
|
|
93
|
+
recentDispatches = (dispatch.completed || [])
|
|
94
|
+
.filter(d => d.agent === id)
|
|
95
|
+
.slice(-10)
|
|
96
|
+
.reverse()
|
|
97
|
+
.map(d => ({
|
|
98
|
+
id: d.id,
|
|
99
|
+
task: d.task || '',
|
|
100
|
+
type: d.type || '',
|
|
101
|
+
result: d.result || '',
|
|
102
|
+
reason: d.reason || '',
|
|
103
|
+
completed_at: d.completed_at || '',
|
|
104
|
+
}));
|
|
105
|
+
} catch {}
|
|
106
|
+
|
|
107
|
+
return { charter, history, statusData, outputLog, inboxContents, recentDispatches };
|
|
90
108
|
}
|
|
91
109
|
|
|
92
110
|
function getAgents() {
|
|
@@ -811,6 +829,326 @@ const server = http.createServer(async (req, res) => {
|
|
|
811
829
|
return;
|
|
812
830
|
}
|
|
813
831
|
|
|
832
|
+
// GET /api/knowledge — list all knowledge base entries grouped by category
|
|
833
|
+
if (req.method === 'GET' && req.url === '/api/knowledge') {
|
|
834
|
+
const kbDir = path.join(SQUAD_DIR, 'knowledge');
|
|
835
|
+
const categories = ['architecture', 'conventions', 'project-notes', 'build-reports', 'reviews'];
|
|
836
|
+
const result = {};
|
|
837
|
+
for (const cat of categories) {
|
|
838
|
+
const catDir = path.join(kbDir, cat);
|
|
839
|
+
const files = safeReadDir(catDir).filter(f => f.endsWith('.md')).sort().reverse();
|
|
840
|
+
result[cat] = files.map(f => {
|
|
841
|
+
const content = safeRead(path.join(catDir, f)) || '';
|
|
842
|
+
// Extract title from first heading
|
|
843
|
+
const titleMatch = content.match(/^#\s+(.+)/m);
|
|
844
|
+
const title = titleMatch ? titleMatch[1] : f.replace(/\.md$/, '');
|
|
845
|
+
// Extract agent and date from frontmatter
|
|
846
|
+
const agentMatch = content.match(/^agent:\s*(.+)/m);
|
|
847
|
+
const dateMatch = content.match(/^date:\s*(.+)/m);
|
|
848
|
+
return {
|
|
849
|
+
file: f,
|
|
850
|
+
category: cat,
|
|
851
|
+
title,
|
|
852
|
+
agent: agentMatch ? agentMatch[1].trim() : '',
|
|
853
|
+
date: dateMatch ? dateMatch[1].trim() : '',
|
|
854
|
+
size: content.length,
|
|
855
|
+
preview: content.replace(/^---[\s\S]*?---\n*/m, '').split('\n').filter(l => l.trim() && !l.startsWith('#')).slice(0, 3).join(' ').slice(0, 200),
|
|
856
|
+
};
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
return jsonReply(res, 200, result);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// GET /api/knowledge/:category/:file — read a specific knowledge base entry
|
|
863
|
+
const kbMatch = req.url.match(/^\/api\/knowledge\/([^/]+)\/([^?]+)/);
|
|
864
|
+
if (kbMatch && req.method === 'GET') {
|
|
865
|
+
const cat = kbMatch[1];
|
|
866
|
+
const file = decodeURIComponent(kbMatch[2]);
|
|
867
|
+
// Prevent path traversal
|
|
868
|
+
if (file.includes('..') || file.includes('/') || file.includes('\\')) {
|
|
869
|
+
return jsonReply(res, 400, { error: 'invalid file name' });
|
|
870
|
+
}
|
|
871
|
+
const content = safeRead(path.join(SQUAD_DIR, 'knowledge', cat, file));
|
|
872
|
+
if (content === null) return jsonReply(res, 404, { error: 'not found' });
|
|
873
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
874
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
875
|
+
res.end(content);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// GET /api/plans — list all plan files with status
|
|
880
|
+
if (req.method === 'GET' && req.url === '/api/plans') {
|
|
881
|
+
const plansDir = path.join(SQUAD_DIR, 'plans');
|
|
882
|
+
const files = safeReadDir(plansDir).filter(f => f.endsWith('.json'));
|
|
883
|
+
const plans = files.map(f => {
|
|
884
|
+
const plan = JSON.parse(safeRead(path.join(plansDir, f)) || '{}');
|
|
885
|
+
return {
|
|
886
|
+
file: f,
|
|
887
|
+
project: plan.project || '',
|
|
888
|
+
summary: plan.plan_summary || '',
|
|
889
|
+
status: plan.status || 'active',
|
|
890
|
+
branchStrategy: plan.branch_strategy || 'parallel',
|
|
891
|
+
featureBranch: plan.feature_branch || '',
|
|
892
|
+
itemCount: (plan.missing_features || []).length,
|
|
893
|
+
generatedBy: plan.generated_by || '',
|
|
894
|
+
generatedAt: plan.generated_at || '',
|
|
895
|
+
requiresApproval: plan.requires_approval || false,
|
|
896
|
+
revisionFeedback: plan.revision_feedback || null,
|
|
897
|
+
};
|
|
898
|
+
}).sort((a, b) => b.generatedAt.localeCompare(a.generatedAt));
|
|
899
|
+
return jsonReply(res, 200, plans);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// GET /api/plans/:file — read full plan JSON
|
|
903
|
+
const planFileMatch = req.url.match(/^\/api\/plans\/([^?]+)$/);
|
|
904
|
+
if (planFileMatch && req.method === 'GET') {
|
|
905
|
+
const file = decodeURIComponent(planFileMatch[1]);
|
|
906
|
+
if (file.includes('..') || file.includes('/') || file.includes('\\')) return jsonReply(res, 400, { error: 'invalid' });
|
|
907
|
+
const content = safeRead(path.join(SQUAD_DIR, 'plans', file));
|
|
908
|
+
if (!content) return jsonReply(res, 404, { error: 'not found' });
|
|
909
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
910
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
911
|
+
res.end(content);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// POST /api/plans/approve — approve a plan for execution
|
|
916
|
+
if (req.method === 'POST' && req.url === '/api/plans/approve') {
|
|
917
|
+
try {
|
|
918
|
+
const body = await readBody(req);
|
|
919
|
+
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
920
|
+
const planPath = path.join(SQUAD_DIR, 'plans', body.file);
|
|
921
|
+
const plan = JSON.parse(safeRead(planPath) || '{}');
|
|
922
|
+
plan.status = 'approved';
|
|
923
|
+
plan.approvedAt = new Date().toISOString();
|
|
924
|
+
plan.approvedBy = body.approvedBy || os.userInfo().username;
|
|
925
|
+
safeWrite(planPath, plan);
|
|
926
|
+
return jsonReply(res, 200, { ok: true, status: 'approved' });
|
|
927
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// POST /api/plans/reject — reject a plan
|
|
931
|
+
if (req.method === 'POST' && req.url === '/api/plans/reject') {
|
|
932
|
+
try {
|
|
933
|
+
const body = await readBody(req);
|
|
934
|
+
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
935
|
+
const planPath = path.join(SQUAD_DIR, 'plans', body.file);
|
|
936
|
+
const plan = JSON.parse(safeRead(planPath) || '{}');
|
|
937
|
+
plan.status = 'rejected';
|
|
938
|
+
plan.rejectedAt = new Date().toISOString();
|
|
939
|
+
plan.rejectedBy = body.rejectedBy || os.userInfo().username;
|
|
940
|
+
if (body.reason) plan.rejectionReason = body.reason;
|
|
941
|
+
safeWrite(planPath, plan);
|
|
942
|
+
return jsonReply(res, 200, { ok: true, status: 'rejected' });
|
|
943
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// POST /api/plans/revise — request revision with feedback, dispatches agent to revise
|
|
947
|
+
if (req.method === 'POST' && req.url === '/api/plans/revise') {
|
|
948
|
+
try {
|
|
949
|
+
const body = await readBody(req);
|
|
950
|
+
if (!body.file || !body.feedback) return jsonReply(res, 400, { error: 'file and feedback required' });
|
|
951
|
+
const planPath = path.join(SQUAD_DIR, 'plans', body.file);
|
|
952
|
+
const plan = JSON.parse(safeRead(planPath) || '{}');
|
|
953
|
+
plan.status = 'revision-requested';
|
|
954
|
+
plan.revision_feedback = body.feedback;
|
|
955
|
+
plan.revisionRequestedAt = new Date().toISOString();
|
|
956
|
+
plan.revisionRequestedBy = body.requestedBy || os.userInfo().username;
|
|
957
|
+
safeWrite(planPath, plan);
|
|
958
|
+
|
|
959
|
+
// Create a work item to revise the plan
|
|
960
|
+
const wiPath = path.join(SQUAD_DIR, 'work-items.json');
|
|
961
|
+
let items = [];
|
|
962
|
+
const existing = safeRead(wiPath);
|
|
963
|
+
if (existing) { try { items = JSON.parse(existing); } catch {} }
|
|
964
|
+
const maxNum = items.reduce(function(max, i) {
|
|
965
|
+
const m = (i.id || '').match(/(\d+)$/);
|
|
966
|
+
return m ? Math.max(max, parseInt(m[1])) : max;
|
|
967
|
+
}, 0);
|
|
968
|
+
const id = 'W' + String(maxNum + 1).padStart(3, '0');
|
|
969
|
+
items.push({
|
|
970
|
+
id, title: 'Revise plan: ' + (plan.plan_summary || body.file),
|
|
971
|
+
type: 'plan-to-prd', priority: 'high',
|
|
972
|
+
description: 'Revision requested on plan file: plans/' + body.file + '\n\nFeedback:\n' + body.feedback + '\n\nRevise the plan to address this feedback. Read the existing plan, apply the feedback, and overwrite the file with the updated version. Set status back to "awaiting-approval".',
|
|
973
|
+
status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard:revision',
|
|
974
|
+
project: plan.project || '',
|
|
975
|
+
planFile: body.file,
|
|
976
|
+
});
|
|
977
|
+
safeWrite(wiPath, items);
|
|
978
|
+
return jsonReply(res, 200, { ok: true, status: 'revision-requested', workItemId: id });
|
|
979
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// POST /api/plans/discuss — generate a plan discussion session script
|
|
983
|
+
if (req.method === 'POST' && req.url === '/api/plans/discuss') {
|
|
984
|
+
try {
|
|
985
|
+
const body = await readBody(req);
|
|
986
|
+
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
987
|
+
const planPath = path.join(SQUAD_DIR, 'plans', body.file);
|
|
988
|
+
const planContent = safeRead(planPath);
|
|
989
|
+
if (!planContent) return jsonReply(res, 404, { error: 'plan not found' });
|
|
990
|
+
|
|
991
|
+
const plan = JSON.parse(planContent);
|
|
992
|
+
const projectName = plan.project || 'Unknown';
|
|
993
|
+
|
|
994
|
+
// Build the session launch script
|
|
995
|
+
const sessionName = 'plan-review-' + body.file.replace(/\.json$/, '');
|
|
996
|
+
const sysPrompt = `You are a Plan Advisor helping a human review and refine a feature plan before it gets dispatched to an agent squad.
|
|
997
|
+
|
|
998
|
+
## Your Role
|
|
999
|
+
- Help the user understand, question, and refine the plan
|
|
1000
|
+
- Accept feedback and update the plan accordingly
|
|
1001
|
+
- When the user is satisfied, write the approved plan back to disk
|
|
1002
|
+
|
|
1003
|
+
## The Plan File
|
|
1004
|
+
Path: ${planPath}
|
|
1005
|
+
Project: ${projectName}
|
|
1006
|
+
|
|
1007
|
+
## How This Works
|
|
1008
|
+
1. The user will discuss the plan with you — answer questions, suggest changes
|
|
1009
|
+
2. When they want changes, update the plan items (add/remove/reorder/modify)
|
|
1010
|
+
3. When they say ANY of these (or similar intent):
|
|
1011
|
+
- "approve", "go", "ship it", "looks good", "lgtm"
|
|
1012
|
+
- "clear context and implement", "clear context and go"
|
|
1013
|
+
- "go build it", "start working", "dispatch", "execute"
|
|
1014
|
+
- "do it", "proceed", "let's go", "send it"
|
|
1015
|
+
|
|
1016
|
+
Then:
|
|
1017
|
+
a. Read the current plan file fresh from disk
|
|
1018
|
+
b. Update status to "approved", set approvedAt and approvedBy
|
|
1019
|
+
c. Write it back to ${planPath} using the Write tool
|
|
1020
|
+
d. Print exactly: "Plan approved and saved. The engine will dispatch work on the next tick. You can close this session."
|
|
1021
|
+
e. Then EXIT the session — use /exit or simply stop responding. The user does NOT need to interact further.
|
|
1022
|
+
|
|
1023
|
+
4. If they say "reject" or "cancel":
|
|
1024
|
+
- Update status to "rejected"
|
|
1025
|
+
- Write it back
|
|
1026
|
+
- Confirm and exit.
|
|
1027
|
+
|
|
1028
|
+
## Important
|
|
1029
|
+
- Always read the plan file fresh before writing (another process may have modified it)
|
|
1030
|
+
- Preserve all existing fields when writing back
|
|
1031
|
+
- Use the Write tool to save changes
|
|
1032
|
+
- You have full file access — you can also read the project codebase for context
|
|
1033
|
+
- When the user signals approval, ALWAYS write the file and exit. Do not ask for confirmation — their intent is clear.`;
|
|
1034
|
+
|
|
1035
|
+
const initialPrompt = `Here's the plan awaiting your review:
|
|
1036
|
+
|
|
1037
|
+
**${plan.plan_summary || body.file}**
|
|
1038
|
+
Project: ${projectName}
|
|
1039
|
+
Strategy: ${plan.branch_strategy || 'parallel'}
|
|
1040
|
+
Branch: ${plan.feature_branch || 'per-item'}
|
|
1041
|
+
Items: ${(plan.missing_features || []).length}
|
|
1042
|
+
|
|
1043
|
+
${(plan.missing_features || []).map((f, i) =>
|
|
1044
|
+
`${i + 1}. **${f.id}: ${f.name}** (${f.estimated_complexity}, ${f.priority})${f.depends_on?.length ? ' → depends on: ' + f.depends_on.join(', ') : ''}
|
|
1045
|
+
${f.description || ''}`
|
|
1046
|
+
).join('\n\n')}
|
|
1047
|
+
|
|
1048
|
+
${plan.open_questions?.length ? '\n**Open Questions:**\n' + plan.open_questions.map(q => '- ' + q).join('\n') : ''}
|
|
1049
|
+
|
|
1050
|
+
What would you like to discuss or change? When you're happy, say "approve" and I'll finalize it.`;
|
|
1051
|
+
|
|
1052
|
+
// Write session files
|
|
1053
|
+
const sessionDir = path.join(SQUAD_DIR, 'engine');
|
|
1054
|
+
const sysFile = path.join(sessionDir, `plan-discuss-sys-${Date.now()}.md`);
|
|
1055
|
+
const promptFile = path.join(sessionDir, `plan-discuss-prompt-${Date.now()}.md`);
|
|
1056
|
+
safeWrite(sysFile, sysPrompt);
|
|
1057
|
+
safeWrite(promptFile, initialPrompt);
|
|
1058
|
+
|
|
1059
|
+
// Generate the launch command
|
|
1060
|
+
const cmd = `claude --system-prompt "$(cat '${sysFile.replace(/\\/g, '/')}')" --name "${sessionName}" --add-dir "${SQUAD_DIR.replace(/\\/g, '/')}" < "${promptFile.replace(/\\/g, '/')}"`;
|
|
1061
|
+
|
|
1062
|
+
// Also generate a PowerShell-friendly version
|
|
1063
|
+
const psCmd = `Get-Content "${promptFile}" | claude --system-prompt (Get-Content "${sysFile}" -Raw) --name "${sessionName}" --add-dir "${SQUAD_DIR}"`;
|
|
1064
|
+
|
|
1065
|
+
return jsonReply(res, 200, {
|
|
1066
|
+
ok: true,
|
|
1067
|
+
sessionName,
|
|
1068
|
+
command: cmd,
|
|
1069
|
+
psCommand: psCmd,
|
|
1070
|
+
sysFile,
|
|
1071
|
+
promptFile,
|
|
1072
|
+
planFile: body.file,
|
|
1073
|
+
});
|
|
1074
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// POST /api/ask-about — ask a question about a document with context, answered by Haiku
|
|
1078
|
+
if (req.method === 'POST' && req.url === '/api/ask-about') {
|
|
1079
|
+
try {
|
|
1080
|
+
const body = await readBody(req);
|
|
1081
|
+
if (!body.question) return jsonReply(res, 400, { error: 'question required' });
|
|
1082
|
+
if (!body.document) return jsonReply(res, 400, { error: 'document required' });
|
|
1083
|
+
|
|
1084
|
+
const prompt = `You are answering a question about a document from a software engineering squad's knowledge base.
|
|
1085
|
+
|
|
1086
|
+
## Document
|
|
1087
|
+
${body.title ? '**Title:** ' + body.title + '\n' : ''}
|
|
1088
|
+
${body.document.slice(0, 15000)}
|
|
1089
|
+
|
|
1090
|
+
${body.selection ? '## Highlighted Selection\n\nThe user highlighted this specific part:\n> ' + body.selection.slice(0, 2000) + '\n' : ''}
|
|
1091
|
+
|
|
1092
|
+
## Question
|
|
1093
|
+
|
|
1094
|
+
${body.question}
|
|
1095
|
+
|
|
1096
|
+
## Instructions
|
|
1097
|
+
|
|
1098
|
+
Answer concisely and directly. Reference specific parts of the document. If the answer isn't in the document, say so. Use markdown formatting.`;
|
|
1099
|
+
|
|
1100
|
+
const sysPrompt = 'You are a concise technical assistant. Answer based on the document provided. No preamble.';
|
|
1101
|
+
|
|
1102
|
+
// Write temp files
|
|
1103
|
+
const id = Date.now();
|
|
1104
|
+
const promptPath = path.join(SQUAD_DIR, 'engine', 'ask-prompt-' + id + '.md');
|
|
1105
|
+
const sysPath = path.join(SQUAD_DIR, 'engine', 'ask-sys-' + id + '.md');
|
|
1106
|
+
safeWrite(promptPath, prompt);
|
|
1107
|
+
safeWrite(sysPath, sysPrompt);
|
|
1108
|
+
|
|
1109
|
+
// Spawn Haiku
|
|
1110
|
+
const spawnScript = path.join(SQUAD_DIR, 'engine', 'spawn-agent.js');
|
|
1111
|
+
const childEnv = { ...process.env };
|
|
1112
|
+
for (const key of Object.keys(childEnv)) {
|
|
1113
|
+
if (key === 'CLAUDECODE' || key.startsWith('CLAUDE_CODE') || key.startsWith('CLAUDECODE_')) delete childEnv[key];
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const { spawn: cpSpawn } = require('child_process');
|
|
1117
|
+
const proc = cpSpawn(process.execPath, [
|
|
1118
|
+
spawnScript, promptPath, sysPath,
|
|
1119
|
+
'--output-format', 'text', '--max-turns', '1', '--model', 'haiku',
|
|
1120
|
+
'--permission-mode', 'bypassPermissions', '--verbose',
|
|
1121
|
+
], { cwd: SQUAD_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: childEnv });
|
|
1122
|
+
|
|
1123
|
+
let stdout = '';
|
|
1124
|
+
let stderr = '';
|
|
1125
|
+
proc.stdout.on('data', d => { stdout += d.toString(); });
|
|
1126
|
+
proc.stderr.on('data', d => { stderr += d.toString(); });
|
|
1127
|
+
|
|
1128
|
+
// Timeout 60s
|
|
1129
|
+
const timeout = setTimeout(() => { try { proc.kill('SIGTERM'); } catch {} }, 60000);
|
|
1130
|
+
|
|
1131
|
+
proc.on('close', (code) => {
|
|
1132
|
+
clearTimeout(timeout);
|
|
1133
|
+
try { fs.unlinkSync(promptPath); } catch {}
|
|
1134
|
+
try { fs.unlinkSync(sysPath); } catch {}
|
|
1135
|
+
if (code === 0 && stdout.trim()) {
|
|
1136
|
+
return jsonReply(res, 200, { ok: true, answer: stdout.trim() });
|
|
1137
|
+
} else {
|
|
1138
|
+
return jsonReply(res, 500, { error: 'Failed to get answer', stderr: stderr.slice(0, 200) });
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
proc.on('error', (err) => {
|
|
1143
|
+
clearTimeout(timeout);
|
|
1144
|
+
try { fs.unlinkSync(promptPath); } catch {}
|
|
1145
|
+
try { fs.unlinkSync(sysPath); } catch {}
|
|
1146
|
+
return jsonReply(res, 500, { error: err.message });
|
|
1147
|
+
});
|
|
1148
|
+
return; // Don't fall through — response handled in callbacks
|
|
1149
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
1150
|
+
}
|
|
1151
|
+
|
|
814
1152
|
// POST /api/inbox/persist — promote an inbox item to team notes
|
|
815
1153
|
if (req.method === 'POST' && req.url === '/api/inbox/persist') {
|
|
816
1154
|
try {
|
package/engine.js
CHANGED
|
@@ -38,6 +38,7 @@ const CONTROL_PATH = path.join(ENGINE_DIR, 'control.json');
|
|
|
38
38
|
const DISPATCH_PATH = path.join(ENGINE_DIR, 'dispatch.json');
|
|
39
39
|
const LOG_PATH = path.join(ENGINE_DIR, 'log.json');
|
|
40
40
|
const INBOX_DIR = path.join(SQUAD_DIR, 'notes', 'inbox');
|
|
41
|
+
const KNOWLEDGE_DIR = path.join(SQUAD_DIR, 'knowledge');
|
|
41
42
|
const ARCHIVE_DIR = path.join(SQUAD_DIR, 'notes', 'archive');
|
|
42
43
|
const PLANS_DIR = path.join(SQUAD_DIR, 'plans');
|
|
43
44
|
const IDENTITY_DIR = path.join(SQUAD_DIR, 'identity');
|
|
@@ -738,8 +739,9 @@ function spawnAgent(dispatchItem, config) {
|
|
|
738
739
|
safeWrite(archivePath, outputContent);
|
|
739
740
|
safeWrite(latestPath, outputContent); // overwrite latest for dashboard compat
|
|
740
741
|
|
|
741
|
-
// Extract agent's final result text from stream-json output
|
|
742
|
+
// Extract agent's final result text + token usage from stream-json output
|
|
742
743
|
let resultSummary = '';
|
|
744
|
+
let taskUsage = null;
|
|
743
745
|
try {
|
|
744
746
|
const lines = stdout.split('\n');
|
|
745
747
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
@@ -747,8 +749,19 @@ function spawnAgent(dispatchItem, config) {
|
|
|
747
749
|
if (!line || !line.startsWith('{')) continue;
|
|
748
750
|
try {
|
|
749
751
|
const obj = JSON.parse(line);
|
|
750
|
-
if (obj.type === 'result'
|
|
751
|
-
resultSummary = obj.result.slice(0, 500);
|
|
752
|
+
if (obj.type === 'result') {
|
|
753
|
+
if (obj.result) resultSummary = obj.result.slice(0, 500);
|
|
754
|
+
if (obj.total_cost_usd || obj.usage) {
|
|
755
|
+
taskUsage = {
|
|
756
|
+
costUsd: obj.total_cost_usd || 0,
|
|
757
|
+
inputTokens: obj.usage?.input_tokens || 0,
|
|
758
|
+
outputTokens: obj.usage?.output_tokens || 0,
|
|
759
|
+
cacheRead: obj.usage?.cache_read_input_tokens || obj.usage?.cacheReadInputTokens || 0,
|
|
760
|
+
cacheCreation: obj.usage?.cache_creation_input_tokens || obj.usage?.cacheCreationInputTokens || 0,
|
|
761
|
+
durationMs: obj.duration_ms || 0,
|
|
762
|
+
numTurns: obj.num_turns || 0,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
752
765
|
break;
|
|
753
766
|
}
|
|
754
767
|
} catch {}
|
|
@@ -807,7 +820,7 @@ function spawnAgent(dispatchItem, config) {
|
|
|
807
820
|
updateAgentHistory(agentId, dispatchItem, code === 0 ? 'success' : 'error');
|
|
808
821
|
|
|
809
822
|
// Update quality metrics
|
|
810
|
-
updateMetrics(agentId, dispatchItem, code === 0 ? 'success' : 'error');
|
|
823
|
+
updateMetrics(agentId, dispatchItem, code === 0 ? 'success' : 'error', taskUsage);
|
|
811
824
|
|
|
812
825
|
// Cleanup temp files
|
|
813
826
|
try { fs.unlinkSync(sysPromptPath); } catch {}
|
|
@@ -1992,7 +2005,7 @@ function createReviewFeedbackForAuthor(reviewerAgentId, pr, config) {
|
|
|
1992
2005
|
log('info', `Created review feedback for ${authorAgentId} from ${reviewerAgentId} on ${pr.id}`);
|
|
1993
2006
|
}
|
|
1994
2007
|
|
|
1995
|
-
function updateMetrics(agentId, dispatchItem, result) {
|
|
2008
|
+
function updateMetrics(agentId, dispatchItem, result, taskUsage) {
|
|
1996
2009
|
const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
|
|
1997
2010
|
const metrics = safeJson(metricsPath) || {};
|
|
1998
2011
|
|
|
@@ -2005,7 +2018,11 @@ function updateMetrics(agentId, dispatchItem, result) {
|
|
|
2005
2018
|
prsRejected: 0,
|
|
2006
2019
|
reviewsDone: 0,
|
|
2007
2020
|
lastTask: null,
|
|
2008
|
-
lastCompleted: null
|
|
2021
|
+
lastCompleted: null,
|
|
2022
|
+
totalCostUsd: 0,
|
|
2023
|
+
totalInputTokens: 0,
|
|
2024
|
+
totalOutputTokens: 0,
|
|
2025
|
+
totalCacheRead: 0,
|
|
2009
2026
|
};
|
|
2010
2027
|
}
|
|
2011
2028
|
|
|
@@ -2021,6 +2038,35 @@ function updateMetrics(agentId, dispatchItem, result) {
|
|
|
2021
2038
|
m.tasksErrored++;
|
|
2022
2039
|
}
|
|
2023
2040
|
|
|
2041
|
+
// Track token usage per agent
|
|
2042
|
+
if (taskUsage) {
|
|
2043
|
+
m.totalCostUsd = (m.totalCostUsd || 0) + (taskUsage.costUsd || 0);
|
|
2044
|
+
m.totalInputTokens = (m.totalInputTokens || 0) + (taskUsage.inputTokens || 0);
|
|
2045
|
+
m.totalOutputTokens = (m.totalOutputTokens || 0) + (taskUsage.outputTokens || 0);
|
|
2046
|
+
m.totalCacheRead = (m.totalCacheRead || 0) + (taskUsage.cacheRead || 0);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
// Track daily usage (all agents combined)
|
|
2050
|
+
const today = dateStamp();
|
|
2051
|
+
if (!metrics._daily) metrics._daily = {};
|
|
2052
|
+
if (!metrics._daily[today]) metrics._daily[today] = { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, tasks: 0 };
|
|
2053
|
+
const daily = metrics._daily[today];
|
|
2054
|
+
daily.tasks++;
|
|
2055
|
+
if (taskUsage) {
|
|
2056
|
+
daily.costUsd += taskUsage.costUsd || 0;
|
|
2057
|
+
daily.inputTokens += taskUsage.inputTokens || 0;
|
|
2058
|
+
daily.outputTokens += taskUsage.outputTokens || 0;
|
|
2059
|
+
daily.cacheRead += taskUsage.cacheRead || 0;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
// Prune daily entries older than 30 days
|
|
2063
|
+
const cutoff = new Date();
|
|
2064
|
+
cutoff.setDate(cutoff.getDate() - 30);
|
|
2065
|
+
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
|
2066
|
+
for (const day of Object.keys(metrics._daily)) {
|
|
2067
|
+
if (day < cutoffStr) delete metrics._daily[day];
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2024
2070
|
safeWrite(metricsPath, metrics);
|
|
2025
2071
|
}
|
|
2026
2072
|
|
|
@@ -2053,6 +2099,25 @@ function consolidateInbox(config) {
|
|
|
2053
2099
|
function consolidateWithLLM(items, existingNotes, files, config) {
|
|
2054
2100
|
_consolidationInFlight = true;
|
|
2055
2101
|
|
|
2102
|
+
// Pre-classify items to generate KB paths for Haiku to reference
|
|
2103
|
+
const kbPaths = items.map(item => {
|
|
2104
|
+
const content = item.content || '';
|
|
2105
|
+
const name = (item.name || '').toLowerCase();
|
|
2106
|
+
const contentLower = content.toLowerCase();
|
|
2107
|
+
let cat = 'project-notes';
|
|
2108
|
+
if (name.includes('review') || name.includes('pr-') || name.includes('pr4') || name.includes('feedback')) cat = 'reviews';
|
|
2109
|
+
else if (name.includes('build') || name.includes('bt-') || contentLower.includes('build pass') || contentLower.includes('build fail') || contentLower.includes('lint')) cat = 'build-reports';
|
|
2110
|
+
else if (contentLower.includes('architecture') || contentLower.includes('design doc') || contentLower.includes('system design')) cat = 'architecture';
|
|
2111
|
+
else if (contentLower.includes('convention') || contentLower.includes('pattern') || contentLower.includes('always use') || contentLower.includes('best practice')) cat = 'conventions';
|
|
2112
|
+
const agentMatch = item.name.match(/^(\w+)-/);
|
|
2113
|
+
const agent = agentMatch ? agentMatch[1] : 'unknown';
|
|
2114
|
+
const titleMatch = content.match(/^#\s+(.+)/m);
|
|
2115
|
+
const titleSlug = titleMatch ? titleMatch[1].toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50) : item.name.replace(/\.md$/, '');
|
|
2116
|
+
return { file: item.name, category: cat, kbPath: `knowledge/${cat}/${dateStamp()}-${agent}-${titleSlug}.md` };
|
|
2117
|
+
});
|
|
2118
|
+
|
|
2119
|
+
const kbRefBlock = kbPaths.map(p => `- \`${p.file}\` → \`${p.kbPath}\``).join('\n');
|
|
2120
|
+
|
|
2056
2121
|
// Build the prompt with all inbox notes
|
|
2057
2122
|
const notesBlock = items.map(item =>
|
|
2058
2123
|
`<note file="${item.name}">\n${(item.content || '').slice(0, 8000)}\n</note>`
|
|
@@ -2097,6 +2162,10 @@ Read every inbox note carefully. Produce a consolidated digest following these r
|
|
|
2097
2162
|
|
|
2098
2163
|
6. **Write a descriptive title**: First line must be a single-line title summarizing what was learned. Do NOT use generic text like "Consolidated from N items".
|
|
2099
2164
|
|
|
2165
|
+
7. **Reference the knowledge base**: Each note is being filed into the knowledge base at these paths. After each insight bullet, add a reference link so readers know where to find the full detail:
|
|
2166
|
+
${kbRefBlock}
|
|
2167
|
+
Format: \`→ see knowledge/category/filename.md\` on a new line after the insight, indented.
|
|
2168
|
+
|
|
2100
2169
|
## Output Format
|
|
2101
2170
|
|
|
2102
2171
|
Respond with ONLY the markdown below — no preamble, no explanation, no code fences:
|
|
@@ -2106,6 +2175,7 @@ Respond with ONLY the markdown below — no preamble, no explanation, no code fe
|
|
|
2106
2175
|
|
|
2107
2176
|
#### Category Name
|
|
2108
2177
|
- **Bold key**: insight text _(agent)_
|
|
2178
|
+
→ see \`knowledge/category/filename.md\`
|
|
2109
2179
|
|
|
2110
2180
|
_Processed N notes, M insights extracted, K duplicates removed._
|
|
2111
2181
|
|
|
@@ -2194,6 +2264,7 @@ Use today's date: ${dateStamp()}`;
|
|
|
2194
2264
|
}
|
|
2195
2265
|
|
|
2196
2266
|
safeWrite(NOTES_PATH, newContent);
|
|
2267
|
+
classifyToKnowledgeBase(items);
|
|
2197
2268
|
archiveInboxFiles(files);
|
|
2198
2269
|
log('info', `LLM consolidation complete: ${files.length} notes processed by Haiku`);
|
|
2199
2270
|
} else {
|
|
@@ -2313,10 +2384,78 @@ function consolidateWithRegex(items, files) {
|
|
|
2313
2384
|
if (sections.length > 10) { newContent = sections[0] + '\n---\n\n### ' + sections.slice(-8).join('\n---\n\n### '); }
|
|
2314
2385
|
}
|
|
2315
2386
|
safeWrite(NOTES_PATH, newContent);
|
|
2387
|
+
classifyToKnowledgeBase(items);
|
|
2316
2388
|
archiveInboxFiles(files);
|
|
2317
2389
|
log('info', `Regex fallback: consolidated ${files.length} notes → ${deduped.length} insights into notes.md`);
|
|
2318
2390
|
}
|
|
2319
2391
|
|
|
2392
|
+
// ─── Knowledge Base Classification ───────────────────────────────────────────
|
|
2393
|
+
// Classifies each inbox note into a knowledge/ subdirectory based on content.
|
|
2394
|
+
// Full original content is preserved (not summarized) for deep reference.
|
|
2395
|
+
function classifyToKnowledgeBase(items) {
|
|
2396
|
+
if (!fs.existsSync(KNOWLEDGE_DIR)) fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true });
|
|
2397
|
+
|
|
2398
|
+
const categoryDirs = {
|
|
2399
|
+
architecture: path.join(KNOWLEDGE_DIR, 'architecture'),
|
|
2400
|
+
conventions: path.join(KNOWLEDGE_DIR, 'conventions'),
|
|
2401
|
+
'project-notes': path.join(KNOWLEDGE_DIR, 'project-notes'),
|
|
2402
|
+
'build-reports': path.join(KNOWLEDGE_DIR, 'build-reports'),
|
|
2403
|
+
reviews: path.join(KNOWLEDGE_DIR, 'reviews'),
|
|
2404
|
+
};
|
|
2405
|
+
for (const dir of Object.values(categoryDirs)) {
|
|
2406
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
let classified = 0;
|
|
2410
|
+
for (const item of items) {
|
|
2411
|
+
const content = item.content || '';
|
|
2412
|
+
const name = (item.name || '').toLowerCase();
|
|
2413
|
+
const contentLower = content.toLowerCase();
|
|
2414
|
+
|
|
2415
|
+
// Classify by filename patterns + content keywords
|
|
2416
|
+
let category = 'project-notes'; // default
|
|
2417
|
+
if (name.includes('review') || name.includes('pr-') || name.includes('pr4') || name.includes('feedback')) {
|
|
2418
|
+
category = 'reviews';
|
|
2419
|
+
} else if (name.includes('build') || name.includes('bt-') || contentLower.includes('build pass') || contentLower.includes('build fail') || contentLower.includes('lint')) {
|
|
2420
|
+
category = 'build-reports';
|
|
2421
|
+
} else if (contentLower.includes('architecture') || contentLower.includes('design doc') || contentLower.includes('system design') || contentLower.includes('data flow') || contentLower.includes('how it works')) {
|
|
2422
|
+
category = 'architecture';
|
|
2423
|
+
} else if (contentLower.includes('convention') || contentLower.includes('pattern') || contentLower.includes('always use') || contentLower.includes('never use') || contentLower.includes('rule:') || contentLower.includes('best practice')) {
|
|
2424
|
+
category = 'conventions';
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
// Write to knowledge base with clean filename
|
|
2428
|
+
const agentMatch = item.name.match(/^(\w+)-/);
|
|
2429
|
+
const agent = agentMatch ? agentMatch[1] : 'unknown';
|
|
2430
|
+
const titleMatch = content.match(/^#\s+(.+)/m);
|
|
2431
|
+
const titleSlug = titleMatch
|
|
2432
|
+
? titleMatch[1].toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
|
|
2433
|
+
: item.name.replace(/\.md$/, '');
|
|
2434
|
+
const kbFilename = `${dateStamp()}-${agent}-${titleSlug}.md`;
|
|
2435
|
+
const kbPath = path.join(categoryDirs[category], kbFilename);
|
|
2436
|
+
|
|
2437
|
+
// Add frontmatter with metadata
|
|
2438
|
+
const frontmatter = `---
|
|
2439
|
+
source: ${item.name}
|
|
2440
|
+
agent: ${agent}
|
|
2441
|
+
category: ${category}
|
|
2442
|
+
date: ${dateStamp()}
|
|
2443
|
+
---
|
|
2444
|
+
|
|
2445
|
+
`;
|
|
2446
|
+
try {
|
|
2447
|
+
safeWrite(kbPath, frontmatter + content);
|
|
2448
|
+
classified++;
|
|
2449
|
+
} catch (e) {
|
|
2450
|
+
log('warn', `Failed to classify ${item.name} to knowledge base: ${e.message}`);
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
if (classified > 0) {
|
|
2455
|
+
log('info', `Knowledge base: classified ${classified} note(s) into knowledge/`);
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2320
2459
|
function archiveInboxFiles(files) {
|
|
2321
2460
|
if (!fs.existsSync(ARCHIVE_DIR)) fs.mkdirSync(ARCHIVE_DIR, { recursive: true });
|
|
2322
2461
|
for (const f of files) {
|
|
@@ -2944,6 +3083,13 @@ function materializePlansAsWorkItems(config) {
|
|
|
2944
3083
|
const plan = safeJson(path.join(PLANS_DIR, file));
|
|
2945
3084
|
if (!plan?.missing_features) continue;
|
|
2946
3085
|
|
|
3086
|
+
// Human approval gate: plans start as 'awaiting-approval' and must be approved before work begins
|
|
3087
|
+
// Plans without a status (legacy) or with status 'approved' are allowed through
|
|
3088
|
+
const planStatus = plan.status || (plan.requires_approval ? 'awaiting-approval' : null);
|
|
3089
|
+
if (planStatus === 'awaiting-approval' || planStatus === 'rejected' || planStatus === 'revision-requested') {
|
|
3090
|
+
continue; // Skip — waiting for human approval or revision
|
|
3091
|
+
}
|
|
3092
|
+
|
|
2947
3093
|
const projectName = plan.project || file.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
|
|
2948
3094
|
const project = getProjects(config).find(p => p.name?.toLowerCase() === projectName.toLowerCase());
|
|
2949
3095
|
if (!project) continue;
|
package/package.json
CHANGED
package/playbooks/plan-to-prd.md
CHANGED
|
@@ -33,6 +33,8 @@ This file is NOT checked into the repo. The engine reads it on every tick and di
|
|
|
33
33
|
"generated_by": "{{agent_id}}",
|
|
34
34
|
"generated_at": "{{date}}",
|
|
35
35
|
"plan_summary": "{{plan_summary}}",
|
|
36
|
+
"status": "awaiting-approval",
|
|
37
|
+
"requires_approval": true,
|
|
36
38
|
"branch_strategy": "shared-branch|parallel",
|
|
37
39
|
"feature_branch": "feat/plan-short-name",
|
|
38
40
|
"missing_features": [
|