ai-agentmanager 1.15.0 → 1.16.0
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/CHANGELOG.md +8 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/public/app.js +126 -30
- package/public/morph.js +89 -0
- package/public/review.js +14 -2
- package/public/scorecard.js +30 -4
- package/public/style.css +9 -0
- package/src/backlog.js +2 -1
- package/src/cli.js +11 -0
- package/src/manager.js +30 -8
- package/src/scorecard.js +23 -0
- package/src/server.js +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.16.0 (2026-11-03)
|
|
4
|
+
|
|
5
|
+
- **Faster typing and switching with many agents:** the page no longer redraws everything on every agent update. Updates are drawn once per frame, only the views they touch are redrawn (another agent's update leaves the open agent's details and terminal alone), and lists change only the cards that changed. With 40 busy agents, the page's redraw work went from about 870 ms to 35 ms per 10 seconds. Add `?perf` to the address to see how long redraws take.
|
|
6
|
+
- Switching agents with a terminal open no longer leaves the previous agent in the address (a reload opened the wrong one).
|
|
7
|
+
- **Improve before review:** the review scorecard's score shows on the agent's row (📊 75), in its Results and on backlog items in review, before you open anything. **✨ Improve** sends the agent its own scorecard to fix: the low scores with why, the acceptance criteria not met or not verified, and where a reviewer would look. It's in Results, the scorecard, Review changes, backlog items in review, the inbox, and `agentmgr improve <agent>`. When the agent marks its brief done again, the changes are scored again, and the score shows how far it moved (*82 ↑7*).
|
|
8
|
+
- **The scorecard and the AI judge's review now run when an agent marks its work done, as documented.** Neither ever did: the brief was saved before the check for "newly done", so that check never saw the change. Expect one scorecard call per finished task agent (turn it off with `"scorecard": { "auto": false }`).
|
|
9
|
+
- A reopened backlog item's note of several lines arrives as one message instead of being sent line by line. This covers Autopilot's send-backs too.
|
|
10
|
+
|
|
3
11
|
## 1.15.0 (2026-11-03)
|
|
4
12
|
|
|
5
13
|
- **Autopilot for the backlog:** finished items go on to a pull request, and merged, without you, when the review scorecard says they're ready. Per project (🛫 in the Backlog tab, `agentmgr backlog autopilot <project> off|pr|merge`), off by default. *Open PRs* commits, pushes and opens the pull request. *Merge* also merges it once checks pass, updating a branch that fell behind. Not-ready work goes back to the agent with the scorecard's reasons (twice at most). Anything else waits in the Inbox as *Autopilot held*, with **Ship it**, **Review changes** and **Send back**. An item whose pull request merges is done. **Ship it** is on every item in review.
|
package/README.md
CHANGED
|
@@ -388,6 +388,7 @@ Each card has at most **three points of about 12 words each**, so they're easy t
|
|
|
388
388
|
- **Files and diff.** The changed files on the left with lines added and removed, and the diff file by file on the right. Click a file header to fold it; very large files start folded.
|
|
389
389
|
- **Line comments.** Click **+** next to a line (or its line number) to comment on it, and add an overall comment if you like. **Send to agent** types them into the agent as one message, each with its file, line and the code it is about, and asks it to address them. Comments not sent yet are kept in this browser.
|
|
390
390
|
- **Review scorecard.** **📊 Score changes** (it also runs by itself when the agent marks its work done) scores the changes 0-100 for **Intent**, **Spec** (each acceptance criterion met, not met or unverified, with evidence), **Tests**, **Safety**, **Scope** and **Plan**, and gives an overall readiness with a recommendation: *Ready to approve*, *Look at these first* or *Not ready*, plus **Where to look**, the few hunks worth a person's time. What the app measures caps the model: failing CI or checks, code changed without tests, plan steps not ticked, criteria not assessed. **Post to PR** puts it on the pull request as a comment (again: updates it), and Push & open PR includes it in the description. It also shows as a badge in Land. Turn the automatic run off with `"scorecard": { "auto": false }`.
|
|
391
|
+
- **Improve before you review.** The score shows before you open anything: 📊 on the agent's row, in its Results, and on backlog items in review. **✨ Improve** (in Results, the scorecard, Review changes, backlog items in review and the inbox, or `agentmgr improve <agent>`) sends the agent its own scorecard: the scores under 80 (lowest first, with why), the acceptance criteria not met or not verified, and where a reviewer would look. A backlog item in review is reopened with it. The agent shows *↻ Improving from 75*. When it next marks its brief done, the changes are scored again, also with the automatic run off, and the score shows how far it moved (*82 ↑7*).
|
|
391
392
|
- **Explain changes** explains each change one by one: a title, what it changes, why (from the request and the conversation), the risk and what to check, in the order a reviewer should read them. Each explanation sits above its hunk, and **◀ / ▶** (or **[** and **]**) step through them, opening folded files on the way. When the agent changes the code afterwards, the page says so and **Explain again** brings it up to date. From the CLI: `agentmgr doc <agent> walkthrough`.
|
|
392
393
|
- **Commit all** commits everything in the worktree, new files included, with your git name and email.
|
|
393
394
|
- **Intent, spec and plan.** Each agent keeps INTENT.md (what was asked, written by AI Agent Manager), SPEC.md (requirements and acceptance criteria, written by the agent before it changes code) and PLAN.md (steps, ticked off as it goes) next to its brief. Open them with **Intent · Spec · Plan** or in the split view's Docs; Explain, the documents and AI review read them too. `"specs": false` in config.json stops asking agents for the spec and plan.
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -9,7 +9,7 @@ import { initSide } from '/side.js';
|
|
|
9
9
|
import { openCopy, pasteInto } from '/termclip.js';
|
|
10
10
|
import { initBuilt } from '/built.js';
|
|
11
11
|
import { addTools, download, htmlFile, initExport } from '/export.js';
|
|
12
|
-
import { badgeHtml as scoreBadge, scorecardHtml, scorecardSpeech } from '/scorecard.js';
|
|
12
|
+
import { badgeHtml as scoreBadge, chipHtml as scoreChip, improveHtml, scorecardHtml, scorecardSpeech } from '/scorecard.js';
|
|
13
13
|
import { bindListen, listenHtml, play, speakable, now as speaking, stop as stopSpeaking } from '/speak.js';
|
|
14
14
|
import { initReview } from '/review.js';
|
|
15
15
|
import { initLand, prChips } from '/land.js';
|
|
@@ -21,6 +21,7 @@ import { initUpdate } from '/update.js';
|
|
|
21
21
|
import { initMenu } from '/menu.js';
|
|
22
22
|
import { initCode } from '/code.js';
|
|
23
23
|
import { initMachine } from '/machine.js';
|
|
24
|
+
import { morph } from '/morph.js';
|
|
24
25
|
|
|
25
26
|
const $ = (sel) => document.querySelector(sel);
|
|
26
27
|
const agents = new Map();
|
|
@@ -158,8 +159,11 @@ function fmtTokens(n) {
|
|
|
158
159
|
|
|
159
160
|
const ioOf = (u) => (u ? (u.io ?? u.input + u.output) : 0);
|
|
160
161
|
|
|
162
|
+
// One formatter for all: n.toLocaleString() builds a new one per call, slow on every card of every redraw.
|
|
163
|
+
const num = new Intl.NumberFormat().format;
|
|
164
|
+
|
|
161
165
|
function usageTitle(u) {
|
|
162
|
-
return `I/O ${ioOf(u)
|
|
166
|
+
return `I/O ${num(ioOf(u))} (input ${num(u.input)} + output ${num(u.output)}) · cache write ${num(u.cacheCreation)} · cache read ${num(u.cacheRead)} · total incl. cache ${num(u.total)} · ${u.requests} API requests`;
|
|
163
167
|
}
|
|
164
168
|
|
|
165
169
|
const tokensOpen = new Set(); // agents whose token breakdown is expanded
|
|
@@ -401,9 +405,11 @@ function renderDocDialog() {
|
|
|
401
405
|
else if (specKind && !d?.loaded) body.innerHTML = '<p class="hint">Loading…</p>';
|
|
402
406
|
else if (specKind && !d?.markdown) body.innerHTML = `<p class="hint">${docView.kind === 'intent' ? 'No intent file for this agent (agents created before 1.9 have none).' : `The agent hasn't written its ${label.toLowerCase()} yet. It writes ${docView.kind === 'spec' ? 'the spec before changing code' : 'the plan and keeps it current'}.`}</p>`;
|
|
403
407
|
else if (d?.markdown) {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
408
|
+
// The scorecard's Improve button follows the agent being improved.
|
|
409
|
+
const version = docView.kind === 'scorecard' ? `${d.at}:${Boolean(a.improving)}` : d.at;
|
|
410
|
+
if (body.dataset.rendered !== version) {
|
|
411
|
+
body.innerHTML = docView.kind === 'scorecard' ? scorecardHtml(d, esc, { actions: improveHtml(a) }) : d.data ? humanAiHtml(d) : docHtml(d.markdown);
|
|
412
|
+
body.dataset.rendered = version;
|
|
407
413
|
renderDiagrams(body);
|
|
408
414
|
}
|
|
409
415
|
}
|
|
@@ -591,6 +597,12 @@ const OUTCOME = {
|
|
|
591
597
|
'in progress': ['🔄', 'In progress'],
|
|
592
598
|
};
|
|
593
599
|
|
|
600
|
+
/** The scorecard before you review: the score, Improve, and the full card. */
|
|
601
|
+
function scoreStrip(a) {
|
|
602
|
+
if (!a.docs?.scorecard?.data && !a.improving) return '';
|
|
603
|
+
return `<div class="sc-strip">${scoreBadge(a.docs?.scorecard, esc, { improving: a.improving })} ${improveHtml(a)} ${a.docs?.scorecard?.data ? '<button class="small ghost" data-doc="scorecard">Scorecard</button>' : ''}</div>`;
|
|
604
|
+
}
|
|
605
|
+
|
|
594
606
|
/** End result, status and blockers: first thing a reviewer reads. */
|
|
595
607
|
function resultsCard(a) {
|
|
596
608
|
const r = a.results;
|
|
@@ -599,6 +611,7 @@ function resultsCard(a) {
|
|
|
599
611
|
const item = (x, cls) => `<li class="${cls}">${x.url ? `<a href="${esc(x.url)}" target="_blank" rel="noopener">${md(x.text).replace(/^<p>|<\/p>\s*$/g, '')}</a>` : md(x.text).replace(/^<p>|<\/p>\s*$/g, '')}</li>`;
|
|
600
612
|
return `<div class="results ${r.outcome.replace(' ', '-')}">
|
|
601
613
|
<h4>Results <span class="outcome">${icon} ${label}</span>${r.fromAgent ? '' : ' <span class="hint">(from what AI Agent Manager can see; the agent adds its own when it next updates its brief)</span>'}</h4>
|
|
614
|
+
${scoreStrip(a)}
|
|
602
615
|
${r.blockers.length ? `<ul class="blockers">${r.blockers.map((x) => item(x, 'blocker')).join('')}</ul>` : ''}
|
|
603
616
|
${r.delivered.length ? `<ul class="delivered">${r.delivered.map((x) => item(x, 'delivered')).join('')}</ul>` : '<p class="hint">Nothing delivered yet.</p>'}
|
|
604
617
|
${r.notes ? `<div class="md">${md(r.notes)}</div>` : ''}
|
|
@@ -731,6 +744,7 @@ function agentItem(a) {
|
|
|
731
744
|
${a.prs.length ? `<span>${a.prs.length} PR</span>` : ''}
|
|
732
745
|
${a.bugs.length ? `<span>${a.bugs.length} bug</span>` : ''}
|
|
733
746
|
${a.usage?.total ? `<span title="${esc(usageTitle(a.usage))}">${fmtTokens(ioOf(a.usage))} tok</span>` : ''}
|
|
747
|
+
${scoreChip(a, esc)}
|
|
734
748
|
${(a.brief?.references ?? []).filter((r) => r.key && r.url).map((r) => `<span class="tag key-ref" title="${esc(r.title || r.url)}">${esc(r.label)}</span>`).join('')}
|
|
735
749
|
${a.brief?.summary?.total
|
|
736
750
|
? `<span class="${a.brief.summary.failed ? 'fail' : ''}" title="${esc(checksSummary(a.brief))}">✓${a.brief.summary.passed}/${a.brief.summary.total}${a.brief.summary.failed ? ` ✗${a.brief.summary.failed}` : ''}</span>`
|
|
@@ -827,7 +841,7 @@ function renderList() {
|
|
|
827
841
|
const totals = new Map();
|
|
828
842
|
for (const a of all) totals.set(a.projectInfo?.key ?? 'other', (totals.get(a.projectInfo?.key ?? 'other') ?? 0) + 1);
|
|
829
843
|
|
|
830
|
-
$('#agent-list')
|
|
844
|
+
morph($('#agent-list'), projectGroups(shown)
|
|
831
845
|
.map((g) => {
|
|
832
846
|
const total = totals.get(g.key) ?? g.agents.length;
|
|
833
847
|
const n = g.agents.length;
|
|
@@ -848,7 +862,7 @@ function renderList() {
|
|
|
848
862
|
${open ? `<ul class="project-agents">${g.agents.map(agentItem).join('')}</ul>` : ''}
|
|
849
863
|
</li>`;
|
|
850
864
|
})
|
|
851
|
-
.join('');
|
|
865
|
+
.join(''));
|
|
852
866
|
}
|
|
853
867
|
|
|
854
868
|
// ------------------------------------------------------------------ backlog
|
|
@@ -877,7 +891,7 @@ function renderBacklog() {
|
|
|
877
891
|
}
|
|
878
892
|
const proj = Object.fromEntries(backlogState.projects.map((p) => [p.key, p]));
|
|
879
893
|
const count = (s) => backlogState.items.filter((i) => i.status === s).length;
|
|
880
|
-
$('#backlog-view')
|
|
894
|
+
morph($('#backlog-view'), `
|
|
881
895
|
<div class="counts">
|
|
882
896
|
<span>${count('queued')} queued</span><span>${count('active')} active</span>
|
|
883
897
|
${count('review') ? `<span class="fail">${count('review')} to review</span>` : '<span>0 to review</span>'}
|
|
@@ -903,7 +917,7 @@ function renderBacklog() {
|
|
|
903
917
|
})
|
|
904
918
|
.join('')}</ul>`
|
|
905
919
|
: `<div class="empty"><p>The backlog is empty.</p><p>Add asks with <b>+ Add</b> (or <b>+ New agents</b> → Queue in backlog). Agents pick them up one by one, ${backlogState.defaultLimit} at a time per project, until each is done.</p></div>`}
|
|
906
|
-
<p class="hint bl-foot"><label><input type="checkbox" data-bl="completed" ${showCompleted ? 'checked' : ''}/> Show done and cancelled</label></p
|
|
920
|
+
<p class="hint bl-foot"><label><input type="checkbox" data-bl="completed" ${showCompleted ? 'checked' : ''}/> Show done and cancelled</label></p>`);
|
|
907
921
|
}
|
|
908
922
|
|
|
909
923
|
const AUTOPILOT_LEVELS = { off: 'Off', pr: 'Open PRs', merge: 'Merge' };
|
|
@@ -930,13 +944,14 @@ function itemRow(it) {
|
|
|
930
944
|
<span class="mono">${it.id}</span>
|
|
931
945
|
${a ? `<span>agent <a href="#${a.id}" data-bl="open">${esc(a.name)}</a>${a.briefStatus ? ` · brief: ${esc(a.briefStatus)}` : ''}</span>` : ''}
|
|
932
946
|
${it.base ? `<span>⎇ from ${esc(it.base)}</span>` : ''}
|
|
947
|
+
${['review', 'active'].includes(it.status) ? scoreChip(agents.get(it.agentId), esc) : ''}
|
|
933
948
|
${it.doneAt ? `<span>done ${ago(it.doneAt)}</span>` : it.reviewAt ? `<span>ready ${ago(it.reviewAt)}</span>` : it.startedAt ? `<span>started ${ago(it.startedAt)}</span>` : `<span>added ${ago(it.createdAt)}</span>`}
|
|
934
949
|
</div>
|
|
935
950
|
${autopilotLine(it)}
|
|
936
951
|
${it.status === 'queued' && it.aiHold ? `<p class="hint bl-ai">🤖 Waits for <b>${esc(it.aiHold.afterTitle)}</b>${it.aiHold.reason ? `: ${esc(it.aiHold.reason)}` : ''}</p>` : ''}
|
|
937
952
|
${it.status === 'queued' && it.aiSplit ? `<div class="hint bl-ai">🤖 Could be split${it.aiSplit.reason ? ` (${esc(it.aiSplit.reason)})` : ''}:<ul>${it.aiSplit.parts.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
|
|
938
953
|
<button class="small" data-bl="split">Split into ${it.aiSplit.parts.length}</button> <button class="small ghost" data-bl="keep-whole">Keep whole</button></div>` : ''}
|
|
939
|
-
<div class="bl-actions">${(ITEM_ACTIONS[it.status] ?? []).filter(([act]) => act !== 'ship' || !['pr', 'landing', 'merging', 'shipping'].includes(it.autopilot?.state)).map(([act, label, title]) => `<button class="small" data-bl="${act}" title="${esc(title)}">${label}</button>`).join('')}</div>
|
|
954
|
+
<div class="bl-actions">${(ITEM_ACTIONS[it.status] ?? []).filter(([act]) => act !== 'ship' || !['pr', 'landing', 'merging', 'shipping'].includes(it.autopilot?.state)).map(([act, label, title]) => `<button class="small" data-bl="${act}" title="${esc(title)}">${label}</button>`).join('')}${it.status === 'review' ? improveHtml(agents.get(it.agentId)).replace('small primary', 'small') : ''}</div>
|
|
940
955
|
</li>`;
|
|
941
956
|
}
|
|
942
957
|
|
|
@@ -1133,7 +1148,7 @@ function needCard(it, { inDetail = false } = {}) {
|
|
|
1133
1148
|
} else if (it.kind === 'autopilot') {
|
|
1134
1149
|
body = `<p class="need-title">${esc(it.it.title)}</p>
|
|
1135
1150
|
<p class="hint">${esc(it.it.autopilot.note ?? '')}</p>
|
|
1136
|
-
<div class="need-actions"><button class="small primary" data-ib="ap-review">Review changes</button><button class="small" data-ib="ap-ship" title="Commit, push and open its pull request (and merge it, at the Merge level)">Ship it</button><button class="small" data-ib="ap-reopen">Send back…</button
|
|
1151
|
+
<div class="need-actions"><button class="small primary" data-ib="ap-review">Review changes</button><button class="small" data-ib="ap-ship" title="Commit, push and open its pull request (and merge it, at the Merge level)">Ship it</button><button class="small" data-ib="ap-reopen">Send back…</button>${improveHtml(agents.get(it.it.agentId)).replace('small primary', 'small')}</div>`;
|
|
1137
1152
|
} else if (it.kind === 'rule') {
|
|
1138
1153
|
body = `<p class="need-title">You've allowed <code>${esc(it.r.label)}</code> ${it.r.n} times in this repo. Always allow it here?</p>
|
|
1139
1154
|
<p class="hint">Agents in ${esc(a.name)} won't ask about it again. Remove it any time in Settings → Approval rules.</p>
|
|
@@ -1165,6 +1180,7 @@ function needCard(it, { inDetail = false } = {}) {
|
|
|
1165
1180
|
<div class="need-actions">
|
|
1166
1181
|
${r.missing.length ? '<button class="small primary" data-ib="review-finish" title="Send the missing items to the agent">Send back to finish</button>' : ''}
|
|
1167
1182
|
<button class="small ${r.missing.length ? '' : 'primary'}" data-ib="review-accept">Looks good</button>
|
|
1183
|
+
${improveHtml(a).replace('small primary', 'small')}
|
|
1168
1184
|
</div>`;
|
|
1169
1185
|
} else if (it.kind === 'team') {
|
|
1170
1186
|
const t = it.t;
|
|
@@ -1248,10 +1264,12 @@ function renderInbox() {
|
|
|
1248
1264
|
if (sideTab !== 'inbox') return;
|
|
1249
1265
|
const view = $('#inbox-view');
|
|
1250
1266
|
if (view.contains(document.activeElement) && document.activeElement.dataset.draft) return; // don't yank the field being typed in
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1267
|
+
morph(
|
|
1268
|
+
view,
|
|
1269
|
+
(items.length
|
|
1270
|
+
? `<div class="counts"><span>${items.length} waiting on you</span></div><div class="needs inbox">${promptGroups(items).map(groupCard).join('')}${items.map((it) => needCard(it)).join('')}</div>`
|
|
1271
|
+
: `<div class="empty"><p>Nothing needs you right now.</p><p>Prompts waiting for an answer, questions agents ask, agents that seem stuck and agents editing the same files show up here.</p></div>`) + judgeLine(),
|
|
1272
|
+
);
|
|
1255
1273
|
}
|
|
1256
1274
|
|
|
1257
1275
|
async function onNeed(e) {
|
|
@@ -1800,7 +1818,9 @@ function renderDetail() {
|
|
|
1800
1818
|
const u = a.usage ?? { total: 0 };
|
|
1801
1819
|
const current = a.sessions.find((s) => s.id === a.sessionId);
|
|
1802
1820
|
|
|
1803
|
-
|
|
1821
|
+
const changed = morph(
|
|
1822
|
+
body,
|
|
1823
|
+
`
|
|
1804
1824
|
<div class="d-head">
|
|
1805
1825
|
<div class="d-title">
|
|
1806
1826
|
<h2>
|
|
@@ -1948,7 +1968,9 @@ function renderDetail() {
|
|
|
1948
1968
|
<h3>Notes</h3>
|
|
1949
1969
|
<textarea class="notes" data-field="notes" placeholder="Anything worth remembering about this agent">${esc(drafts.notes ?? a.notes ?? '')}</textarea>
|
|
1950
1970
|
</div>
|
|
1951
|
-
</div
|
|
1971
|
+
</div>`,
|
|
1972
|
+
);
|
|
1973
|
+
if (!changed) return;
|
|
1952
1974
|
|
|
1953
1975
|
renderDiagrams(body);
|
|
1954
1976
|
markOverflow(body);
|
|
@@ -1963,22 +1985,77 @@ function renderDetail() {
|
|
|
1963
1985
|
}
|
|
1964
1986
|
}
|
|
1965
1987
|
|
|
1988
|
+
const VIEWS = {
|
|
1989
|
+
list: () => renderList(),
|
|
1990
|
+
backlog: () => renderBacklog(),
|
|
1991
|
+
land: () => landUi.render(),
|
|
1992
|
+
inbox: () => renderInbox(),
|
|
1993
|
+
race: () => renderRace(),
|
|
1994
|
+
team: () => renderTeam(),
|
|
1995
|
+
doc: () => renderDocDialog(),
|
|
1996
|
+
detail: () => renderDetail(),
|
|
1997
|
+
overlay: () => term.syncOverlay(),
|
|
1998
|
+
};
|
|
1999
|
+
const ALL_VIEWS = Object.keys(VIEWS);
|
|
2000
|
+
|
|
1966
2001
|
function render() {
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
landUi.render();
|
|
1970
|
-
renderInbox();
|
|
1971
|
-
renderRace();
|
|
1972
|
-
renderTeam();
|
|
1973
|
-
renderDocDialog();
|
|
1974
|
-
renderDetail();
|
|
1975
|
-
term.syncOverlay();
|
|
2002
|
+
dirty.clear();
|
|
2003
|
+
perf.draw(ALL_VIEWS);
|
|
1976
2004
|
}
|
|
1977
2005
|
|
|
2006
|
+
// Live updates come many times a second when many agents work. They mark the views they touch,
|
|
2007
|
+
// and those are drawn once in the next frame, so typing never waits behind a run of redraws.
|
|
2008
|
+
const dirty = new Set();
|
|
2009
|
+
let drawQueued = false;
|
|
2010
|
+
|
|
2011
|
+
function invalidate(views = ALL_VIEWS) {
|
|
2012
|
+
for (const v of views) dirty.add(v);
|
|
2013
|
+
if (drawQueued) return;
|
|
2014
|
+
drawQueued = true;
|
|
2015
|
+
const draw = () => {
|
|
2016
|
+
if (!drawQueued) return;
|
|
2017
|
+
drawQueued = false;
|
|
2018
|
+
const views = ALL_VIEWS.filter((v) => dirty.has(v));
|
|
2019
|
+
dirty.clear();
|
|
2020
|
+
perf.draw(views);
|
|
2021
|
+
};
|
|
2022
|
+
requestAnimationFrame(draw);
|
|
2023
|
+
// A background tab gets no frames; it still keeps its title ("(2) needs input") current.
|
|
2024
|
+
setTimeout(draw, 500);
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// ?perf: how long redraws take, in a corner of the page.
|
|
2028
|
+
const perf = {
|
|
2029
|
+
on: new URLSearchParams(location.search).has('perf'),
|
|
2030
|
+
runs: [], // [at, ms, { view: ms }] over the last 5 seconds
|
|
2031
|
+
draw(views) {
|
|
2032
|
+
if (!this.on) {
|
|
2033
|
+
for (const v of views) VIEWS[v]();
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
const each = {};
|
|
2037
|
+
const t = performance.now();
|
|
2038
|
+
for (const v of views) {
|
|
2039
|
+
const t0 = performance.now();
|
|
2040
|
+
VIEWS[v]();
|
|
2041
|
+
each[v] = performance.now() - t0;
|
|
2042
|
+
}
|
|
2043
|
+
const now = performance.now();
|
|
2044
|
+
this.runs = [...this.runs.filter(([at]) => now - at < 5000), [now, now - t, each]];
|
|
2045
|
+
const ms = this.runs.map(([, d]) => d).sort((x, y) => x - y);
|
|
2046
|
+
const by = {};
|
|
2047
|
+
for (const [, , e] of this.runs) for (const [v, d] of Object.entries(e)) by[v] = (by[v] ?? 0) + d;
|
|
2048
|
+
const top = Object.entries(by).sort((x, y) => y[1] - x[1]).slice(0, 3).map(([v, d]) => `${v} ${Math.round((d / ms.reduce((x, y) => x + y, 0)) * 100)}%`);
|
|
2049
|
+
const el = $('#perf') ?? document.body.appendChild(Object.assign(document.createElement('div'), { id: 'perf', className: 'perf' }));
|
|
2050
|
+
el.textContent = `redraw ${ms[ms.length >> 1].toFixed(1)} ms · p95 ${ms[Math.floor(ms.length * 0.95)].toFixed(1)} ms · ${(ms.length / 5).toFixed(1)}/s · ${top.join(' ')}`;
|
|
2051
|
+
},
|
|
2052
|
+
};
|
|
2053
|
+
|
|
1978
2054
|
function select(id) {
|
|
1979
2055
|
selectedId = id;
|
|
1980
|
-
|
|
2056
|
+
// Closing the terminal puts its agent in the address; the one switched to goes there after.
|
|
1981
2057
|
if (term.agentId && term.agentId !== id) term.close();
|
|
2058
|
+
history.replaceState(null, '', id ? `#${id}` : '#');
|
|
1982
2059
|
render();
|
|
1983
2060
|
}
|
|
1984
2061
|
|
|
@@ -2083,7 +2160,8 @@ const term = {
|
|
|
2083
2160
|
} else if (msg.t === 'error') {
|
|
2084
2161
|
toast(`Terminal unavailable: ${msg.error}`, 5000);
|
|
2085
2162
|
} else if (msg.t === 'o') {
|
|
2086
|
-
|
|
2163
|
+
// Output is most of what arrives (every keystroke's echo): nothing else on the page changes.
|
|
2164
|
+
return this.xterm.write(msg.d);
|
|
2087
2165
|
} else if (msg.t === 'exit') {
|
|
2088
2166
|
if (isShell(this.kind)) this.shellExited = true;
|
|
2089
2167
|
else this.xterm.write(`\r\n\x1b[2m[claude exited with code ${msg.code}; session kept]\x1b[0m\r\n`);
|
|
@@ -3130,6 +3208,16 @@ $('#counts').addEventListener('click', (e) => {
|
|
|
3130
3208
|
});
|
|
3131
3209
|
});
|
|
3132
3210
|
|
|
3211
|
+
// Improve (agent details, scorecard, backlog, inbox): its scorecard goes back to the agent to fix.
|
|
3212
|
+
document.addEventListener('click', (e) => {
|
|
3213
|
+
const btn = e.target.closest('[data-sc-improve]');
|
|
3214
|
+
if (!btn) return;
|
|
3215
|
+
e.preventDefault();
|
|
3216
|
+
e.stopPropagation();
|
|
3217
|
+
btn.disabled = true;
|
|
3218
|
+
act(async () => upsert(await api('POST', `/agents/${btn.dataset.scImprove}/scorecard/improve`)), 'Sent to the agent to improve; scored again when it is done').finally(() => (btn.disabled = false));
|
|
3219
|
+
});
|
|
3220
|
+
|
|
3133
3221
|
$('#agent-list').addEventListener('click', (e) => {
|
|
3134
3222
|
const rename = e.target.closest('[data-rename]');
|
|
3135
3223
|
if (rename) {
|
|
@@ -3759,10 +3847,18 @@ function handleEvent(msg) {
|
|
|
3759
3847
|
}
|
|
3760
3848
|
openTermOnLoad = false;
|
|
3761
3849
|
} else if (msg.t === 'agent') {
|
|
3762
|
-
|
|
3850
|
+
const id = msg.agent.id;
|
|
3851
|
+
agents.set(id, msg.agent);
|
|
3763
3852
|
watchDocs(msg.agent);
|
|
3764
3853
|
agentChanged(msg.agent);
|
|
3765
3854
|
if (msg.agent.builds) builtUi.sessionChanged(msg.agent);
|
|
3855
|
+
// The detail, terminal and document views show one agent; another agent's update leaves them be.
|
|
3856
|
+
return invalidate([
|
|
3857
|
+
'list', 'backlog', 'land', 'inbox', 'race', 'team',
|
|
3858
|
+
...(docView?.agentId === id ? ['doc'] : []),
|
|
3859
|
+
...(id === selectedId || msg.agent.forkOf?.agentId === selectedId ? ['detail'] : []),
|
|
3860
|
+
...(id === term.agentId ? ['overlay'] : []),
|
|
3861
|
+
]);
|
|
3766
3862
|
} else if (msg.t === 'removed') {
|
|
3767
3863
|
agents.delete(msg.id);
|
|
3768
3864
|
if (selectedId === msg.id) select(null);
|
|
@@ -3787,7 +3883,7 @@ function handleEvent(msg) {
|
|
|
3787
3883
|
projectsState = msg.projects;
|
|
3788
3884
|
renderProjectOptions();
|
|
3789
3885
|
}
|
|
3790
|
-
|
|
3886
|
+
invalidate();
|
|
3791
3887
|
}
|
|
3792
3888
|
|
|
3793
3889
|
function connectEvents() {
|
package/public/morph.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Live views are re-rendered as HTML strings many times a second (every agent update). Swapping
|
|
2
|
+
// innerHTML each time rebuilds every element, which the browser must restyle, lay out and paint,
|
|
3
|
+
// and it drops focus, selection and a click in progress. morph() changes only what differs: the
|
|
4
|
+
// same element stays in place when its tag (and data-id / id, when present) still match.
|
|
5
|
+
|
|
6
|
+
const FIELDS = new Set(['INPUT', 'SELECT', 'TEXTAREA']);
|
|
7
|
+
const keyOf = (n) => (n.nodeType === 1 ? n.getAttribute('data-id') ?? n.id ?? '' : '');
|
|
8
|
+
|
|
9
|
+
function sameKind(a, b) {
|
|
10
|
+
return a.nodeType === b.nodeType && a.nodeName === b.nodeName && keyOf(a) === keyOf(b);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function syncAttrs(from, to) {
|
|
14
|
+
for (const { name } of [...from.attributes]) if (!to.hasAttribute(name)) from.removeAttribute(name);
|
|
15
|
+
for (const { name, value } of to.attributes) if (from.getAttribute(name) !== value) from.setAttribute(name, value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// What a fresh element would show; the field being typed in keeps what's typed.
|
|
19
|
+
function syncField(from, to) {
|
|
20
|
+
if (from.type === 'checkbox' || from.type === 'radio') from.checked = to.checked;
|
|
21
|
+
else if (from !== document.activeElement && from.value !== to.value) from.value = to.value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function morphNode(from, to) {
|
|
25
|
+
// Native and fast: most of a list is unchanged. Its fields may still show something else.
|
|
26
|
+
if (from.isEqualNode(to)) {
|
|
27
|
+
if (from.nodeType !== 1) return;
|
|
28
|
+
if (FIELDS.has(from.nodeName)) return syncField(from, to);
|
|
29
|
+
const mine = from.querySelectorAll('input, select, textarea');
|
|
30
|
+
if (mine.length) {
|
|
31
|
+
const theirs = to.querySelectorAll('input, select, textarea');
|
|
32
|
+
mine.forEach((f, i) => syncField(f, theirs[i]));
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (from.nodeType !== 1) {
|
|
37
|
+
if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// A diagram drawn into its slot (renderDiagrams) stays while its source is the same.
|
|
41
|
+
if (from.dataset.filled && from.dataset.mermaid === to.dataset.mermaid) return;
|
|
42
|
+
syncAttrs(from, to);
|
|
43
|
+
if (from.nodeName === 'TEXTAREA') {
|
|
44
|
+
if (from.defaultValue !== to.defaultValue) from.defaultValue = to.defaultValue;
|
|
45
|
+
syncField(from, to);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
morphChildren(from, to);
|
|
49
|
+
if (from.nodeName === 'INPUT' || from.nodeName === 'SELECT') syncField(from, to);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function morphChildren(from, to) {
|
|
53
|
+
let a = from.firstChild;
|
|
54
|
+
let b = to.firstChild;
|
|
55
|
+
while (b) {
|
|
56
|
+
const next = b.nextSibling;
|
|
57
|
+
if (a && sameKind(a, b)) {
|
|
58
|
+
morphNode(a, b);
|
|
59
|
+
a = a.nextSibling;
|
|
60
|
+
} else {
|
|
61
|
+
// A keyed element further on (a card that moved up): bring it here instead of rebuilding it.
|
|
62
|
+
const key = keyOf(b);
|
|
63
|
+
let found = null;
|
|
64
|
+
if (key) for (let c = a?.nextSibling; c; c = c.nextSibling) if (sameKind(c, b)) { found = c; break; }
|
|
65
|
+
if (found) {
|
|
66
|
+
from.insertBefore(found, a);
|
|
67
|
+
morphNode(found, b);
|
|
68
|
+
} else from.insertBefore(b, a); // moves b out of the template
|
|
69
|
+
}
|
|
70
|
+
b = next;
|
|
71
|
+
}
|
|
72
|
+
while (a) {
|
|
73
|
+
const next = a.nextSibling;
|
|
74
|
+
a.remove();
|
|
75
|
+
a = next;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const tpl = document.createElement('template');
|
|
80
|
+
|
|
81
|
+
/** Make el's contents match html, touching only what changed. False when nothing did. */
|
|
82
|
+
export function morph(el, html) {
|
|
83
|
+
if (el.__html === html && el.firstChild) return false;
|
|
84
|
+
el.__html = html;
|
|
85
|
+
tpl.innerHTML = html;
|
|
86
|
+
morphChildren(el, tpl.content);
|
|
87
|
+
tpl.innerHTML = '';
|
|
88
|
+
return true;
|
|
89
|
+
}
|
package/public/review.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// With a walkthrough, each hunk shows its explanation and ◀ / ▶ step through them in reading order.
|
|
4
4
|
import { cardHtml, explainButton, listenParts as walkParts, stepOf, walkthroughOf } from './walkthrough.js';
|
|
5
5
|
import { listenHtml, now as speaking, stop as stopSpeaking } from './speak.js';
|
|
6
|
-
import { scorecardHtml, scorecardOf } from './scorecard.js';
|
|
6
|
+
import { improveHtml, scorecardHtml, scorecardOf } from './scorecard.js';
|
|
7
7
|
|
|
8
8
|
/** Unified diff -> [{ path, status, binary, hunks: [{ head, lines: [{ t, old, new, text }] }] }] */
|
|
9
9
|
export function parseDiff(text) {
|
|
@@ -168,7 +168,7 @@ export function initReview({ api, esc, toast, agents, $ }) {
|
|
|
168
168
|
return `<div class="rv-score-empty">${run}<span class="hint">${sc.error && !sc.pending ? `<span class="error">Couldn't score: ${esc(sc.error)}</span>` : 'Scores against the intent, spec, scope, plan, tests and safety, with an overall readiness and the few places to read.'}</span></div>`;
|
|
169
169
|
}
|
|
170
170
|
const post = openPr ? `<button type="button" class="small ghost" data-sc-post title="Post the scorecard on the pull request as a comment (posting again updates it)">${sc.doc.postedTo === openPr.url ? 'Update on PR' : 'Post to PR'}</button>` : '';
|
|
171
|
-
return `<div class="rv-score">${scorecardHtml(sc.doc, esc, { stale: sc.stale, actions: `${listenHtml(`score:${st.id}`, { title: 'Hear the scorecard' })}${post}${run}` })}</div>`;
|
|
171
|
+
return `<div class="rv-score">${scorecardHtml(sc.doc, esc, { stale: sc.stale, actions: `${listenHtml(`score:${st.id}`, { title: 'Hear the scorecard' })}${sc.stale ? '' : improveHtml(agents.get(st.id))}${post}${run}` })}</div>`;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
/** Open the file of a hunk (from "where to look") and scroll to it. */
|
|
@@ -329,6 +329,18 @@ export function initReview({ api, esc, toast, agents, $ }) {
|
|
|
329
329
|
}
|
|
330
330
|
return;
|
|
331
331
|
}
|
|
332
|
+
if (t.closest('[data-sc-improve]')) {
|
|
333
|
+
e.stopPropagation(); // the page's own Improve handler is for elsewhere
|
|
334
|
+
keepInputs();
|
|
335
|
+
try {
|
|
336
|
+
agents.set(st.id, await api('POST', `/agents/${st.id}/scorecard/improve`));
|
|
337
|
+
toast('Sent to the agent to improve; scored again when it is done', 5000);
|
|
338
|
+
render();
|
|
339
|
+
} catch (err) {
|
|
340
|
+
toast(`Error: ${err.message}`, 5000);
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
332
344
|
if (t.closest('[data-sc-post]')) {
|
|
333
345
|
t.disabled = true;
|
|
334
346
|
try {
|
package/public/scorecard.js
CHANGED
|
@@ -19,11 +19,37 @@ export function scorecardOf(agent, diffHash) {
|
|
|
19
19
|
return { doc: d?.data ? d : null, pending: Boolean(d?.pending), error: d?.error ?? null, stale: Boolean(d?.data && diffHash && d.diffHash !== diffHash) };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
22
|
+
/** How far Improve moved the score: "↑18" (from 64), or nothing. */
|
|
23
|
+
const delta = (doc) => {
|
|
24
|
+
const from = doc.improvedFrom;
|
|
25
|
+
const to = doc.data?.overall;
|
|
26
|
+
if (from == null || to == null || from === to) return '';
|
|
27
|
+
return `<span class="sc-delta ${to > from ? 'up' : 'down'}" title="After Improve: was ${from}">${to > from ? '↑' : '↓'}${Math.abs(to - from)}</span>`;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A small badge: "82 · Ready to approve". Pass the agent's `improving` to show it being improved. */
|
|
31
|
+
export function badgeHtml(doc, esc, { improving = null } = {}) {
|
|
32
|
+
if (improving) return `<span class="sc-badge" title="Sent back to improve its ${improving.from ?? '—'}/100; scored again when it is done">↻ Improving from ${improving.from ?? '—'}</span>`;
|
|
24
33
|
if (!doc?.data) return '';
|
|
25
34
|
const sc = doc.data;
|
|
26
|
-
return `<span class="sc-badge ${recTone[sc.recommendation]}" title="Review scorecard: ${esc(RECOMMENDATION[sc.recommendation])}${sc.reasons.length ? ` (${esc(sc.reasons.join('; '))})` : ''}">${sc.overall ?? '—'} · ${esc(RECOMMENDATION[sc.recommendation])}</span>`;
|
|
35
|
+
return `<span class="sc-badge ${recTone[sc.recommendation]}" title="Review scorecard: ${esc(RECOMMENDATION[sc.recommendation])}${sc.reasons.length ? ` (${esc(sc.reasons.join('; '))})` : ''}">${sc.overall ?? '—'}${delta(doc)} · ${esc(RECOMMENDATION[sc.recommendation])}</span>`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Just the number, for a list row: "82", "82↑18" or "↻" while being improved. */
|
|
39
|
+
export function chipHtml(agent, esc) {
|
|
40
|
+
const doc = agent?.docs?.scorecard;
|
|
41
|
+
if (agent?.improving) return `<span class="sc-chip" title="Being improved from ${agent.improving.from ?? '—'}/100">↻ ${agent.improving.from ?? ''}</span>`;
|
|
42
|
+
if (!doc?.data) return '';
|
|
43
|
+
return `<span class="sc-chip ${recTone[doc.data.recommendation]}" title="Review scorecard: ${doc.data.overall ?? '—'}/100, ${esc(RECOMMENDATION[doc.data.recommendation])}">📊 ${doc.data.overall ?? '—'}${delta(doc)}</span>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Improve: send the agent its scorecard to fix before review (POST /agents/:id/scorecard/improve). */
|
|
47
|
+
export function improveHtml(agent) {
|
|
48
|
+
const doc = agent?.docs?.scorecard;
|
|
49
|
+
if (!doc?.data) return '';
|
|
50
|
+
if (agent.improving) return '<button type="button" class="small ghost" disabled title="Scored again when the agent updates its brief">Improving…</button>';
|
|
51
|
+
const ready = doc.data.recommendation === 'ready';
|
|
52
|
+
return `<button type="button" class="small ${ready ? 'ghost' : 'primary'}" data-sc-improve="${agent.id}" title="Send the agent its low scores, unmet criteria and where a reviewer would look, to fix before you review; it is scored again when it is done">✨ Improve</button>`;
|
|
27
53
|
}
|
|
28
54
|
|
|
29
55
|
/**
|
|
@@ -46,7 +72,7 @@ export function scorecardHtml(doc, esc, { stale = false, actions = '' } = {}) {
|
|
|
46
72
|
const f = sc.facts;
|
|
47
73
|
return `<div class="scorecard">
|
|
48
74
|
<div class="sc-head">
|
|
49
|
-
<span class="sc-overall ${recTone[sc.recommendation]}"><b>${sc.overall ?? '—'}</b><small>/100</small
|
|
75
|
+
<span class="sc-overall ${recTone[sc.recommendation]}"><b>${sc.overall ?? '—'}</b><small>/100</small>${delta(doc)}</span>
|
|
50
76
|
<div class="sc-verdict"><b>${esc(RECOMMENDATION[sc.recommendation])}</b>${sc.reasons.length ? `<div class="hint">${esc(sc.reasons.join(' · '))}</div>` : ''}<div class="hint">${esc(sc.summary)}</div></div>
|
|
51
77
|
<span class="spacer"></span>${actions}
|
|
52
78
|
</div>
|
package/public/style.css
CHANGED
|
@@ -934,6 +934,12 @@ form.dropping { outline: 2px dashed var(--accent, #6b8cff); outline-offset: 4px;
|
|
|
934
934
|
.sc-crit li.met .sc-mark { color: var(--green); } .sc-crit li.not_met .sc-mark { color: var(--red); } .sc-crit li.unverified .sc-mark { color: var(--yellow); }
|
|
935
935
|
.sc-facts { font-size: 11.5px; }
|
|
936
936
|
.sc-badge { display: inline-block; padding: 1px 8px; border-radius: 999px; border: 1px solid currentColor; font-size: 12px; white-space: nowrap; }
|
|
937
|
+
/* Scorecard before review: the number on list rows, how far Improve moved it, and the strip in Results. */
|
|
938
|
+
.sc-chip { white-space: nowrap; }
|
|
939
|
+
.sc-delta { margin-left: 3px; font-size: 11px; font-weight: 600; }
|
|
940
|
+
.sc-delta.up { color: var(--green); } .sc-delta.down { color: var(--red); }
|
|
941
|
+
.sc-overall .sc-delta { align-self: center; }
|
|
942
|
+
.sc-strip { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 8px; }
|
|
937
943
|
.rv-score { margin: 0 12px 8px; }
|
|
938
944
|
.rv-score-empty { display: flex; align-items: center; gap: 10px; margin: 0 12px 8px; }
|
|
939
945
|
.rv-hunk.flash td { animation: rvflash 1.6s; }
|
|
@@ -989,3 +995,6 @@ body.remote .settings-links .local-only { display: none; }
|
|
|
989
995
|
.rules-list li { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
990
996
|
.rules-list li button { margin-left: auto; }
|
|
991
997
|
.need.group { border-style: dashed; }
|
|
998
|
+
|
|
999
|
+
/* ?perf: redraw timings */
|
|
1000
|
+
.perf { position: fixed; right: 8px; bottom: 8px; z-index: 1000; padding: 3px 8px; border-radius: 6px; background: var(--panel, #1a1d27); color: var(--muted); font: 11px ui-monospace, Menlo, monospace; pointer-events: none; opacity: 0.9; }
|
package/src/backlog.js
CHANGED
|
@@ -275,7 +275,8 @@ export class Backlog extends EventEmitter {
|
|
|
275
275
|
Promise.resolve(this.manager.start(agentId))
|
|
276
276
|
.then(() => new Promise((r) => setTimeout(r, 1500)))
|
|
277
277
|
.then(() => {
|
|
278
|
-
|
|
278
|
+
// Bracketed paste keeps a note of several lines one message (each line would submit).
|
|
279
|
+
this.manager.write(agentId, message.includes('\n') ? `\x1b[200~${message}\x1b[201~` : message);
|
|
279
280
|
setTimeout(() => this.manager.write(agentId, '\r'), 200);
|
|
280
281
|
})
|
|
281
282
|
.catch(() => {});
|
package/src/cli.js
CHANGED
|
@@ -957,6 +957,17 @@ export async function main(argv) {
|
|
|
957
957
|
console.log(`${green('✓')} answer queued for ${a.name}; it is typed into the session when the agent is idle`);
|
|
958
958
|
});
|
|
959
959
|
|
|
960
|
+
program
|
|
961
|
+
.command('improve')
|
|
962
|
+
.description("Before review: send an agent its scorecard's low scores, unmet criteria and where a reviewer would look, to fix; it is scored again when done")
|
|
963
|
+
.argument('<agent>')
|
|
964
|
+
.action(async (ref) => {
|
|
965
|
+
await daemon();
|
|
966
|
+
const a = await resolveAgent(ref);
|
|
967
|
+
await api('POST', `/agents/${a.id}/scorecard/improve`);
|
|
968
|
+
console.log(`${green('✓')} sent ${a.name} its scorecard (${a.docs.scorecard.data.overall ?? '—'}/100) to improve; scored again when it updates its brief`);
|
|
969
|
+
});
|
|
970
|
+
|
|
960
971
|
program
|
|
961
972
|
.command('tell')
|
|
962
973
|
.description("Send a message into an agent's session (typed in when it is idle; resumes it if closed)")
|
package/src/manager.js
CHANGED
|
@@ -101,7 +101,7 @@ import { spawn } from 'node:child_process';
|
|
|
101
101
|
import * as git from './git.js';
|
|
102
102
|
import { parseDiff } from '../public/review.js';
|
|
103
103
|
import { issueSummary, leadingIssue, wordsAfterLink } from './jira.js';
|
|
104
|
-
import { combine, facts as scoreFacts, scorecardMarkdown, scorecardPrompt } from './scorecard.js';
|
|
104
|
+
import { combine, facts as scoreFacts, improveMessage, scorecardMarkdown, scorecardPrompt } from './scorecard.js';
|
|
105
105
|
import { CONTAINER_ENGINES, claudeToken, containerName, containerRunning, engineMounts, gitCommonDir, removeContainer, runArgs, stopContainer } from './container.js';
|
|
106
106
|
import { conflictMessage, findConflicts, related, repoKey, summarize as summarizeConflicts } from './conflicts.js';
|
|
107
107
|
import { answerMessage, loadQuestions, questionsPath, questionsPrompt } from './inbox.js';
|
|
@@ -1956,13 +1956,17 @@ export class AgentManager extends EventEmitter {
|
|
|
1956
1956
|
reloadBrief(id) {
|
|
1957
1957
|
const agent = this.store.get(id);
|
|
1958
1958
|
if (!agent) return;
|
|
1959
|
-
const
|
|
1959
|
+
const before = agent.brief; // patch() below changes agent.brief in place
|
|
1960
|
+
const brief = loadBrief(id, before);
|
|
1960
1961
|
if (brief) {
|
|
1961
1962
|
this.patch(id, { brief });
|
|
1963
|
+
const finished = brief.status === 'done' && before?.status !== 'done';
|
|
1964
|
+
// Asked to improve: its next update of the brief as done is scored again (also with auto off).
|
|
1965
|
+
const improved = brief.status === 'done' && agent.improving && brief.updatedAt !== agent.improving.briefAt;
|
|
1962
1966
|
// The agent says it is done: have the judge check the work before the person looks.
|
|
1963
|
-
if (
|
|
1967
|
+
if (finished && this.judge.enabled) this.requestReview(id, { trigger: 'brief' }).catch(() => {});
|
|
1964
1968
|
// ...and score it for the reviewer ("scorecard": { "auto": false } in config.json turns this off).
|
|
1965
|
-
if (
|
|
1969
|
+
if ((improved || (finished && this.config.scorecard?.auto !== false)) && !NO_REPO.has(agent.kind) && this.changesSource) this.summarize(id, 'scorecard').catch(() => {});
|
|
1966
1970
|
}
|
|
1967
1971
|
if (agent.planReview) {
|
|
1968
1972
|
const file = loadPlanFile(id, agent.planReview);
|
|
@@ -2230,24 +2234,42 @@ export class AgentManager extends EventEmitter {
|
|
|
2230
2234
|
const cur = this.store.get(agent.id);
|
|
2231
2235
|
if (!cur) return null;
|
|
2232
2236
|
return this.view(
|
|
2233
|
-
this.patch(
|
|
2234
|
-
|
|
2235
|
-
summaryPatch(cur, 'scorecard', {
|
|
2237
|
+
this.patch(agent.id, {
|
|
2238
|
+
...summaryPatch(cur, 'scorecard', {
|
|
2236
2239
|
data: sc,
|
|
2237
2240
|
cut,
|
|
2238
2241
|
diffHash: hash(data.diff),
|
|
2239
2242
|
markdown: scorecardMarkdown(sc, cur.name),
|
|
2240
2243
|
writer: `${writer.engine.label}${writer.model ? ` (${writer.model})` : ''}`,
|
|
2241
2244
|
postedTo: cur.docs?.scorecard?.postedTo ?? null,
|
|
2245
|
+
// After Improve: the score it had, to show how far it moved.
|
|
2246
|
+
improvedFrom: cur.improving ? cur.improving.from : null,
|
|
2242
2247
|
at: now(),
|
|
2243
2248
|
briefUpdatedAt: cur.brief?.updatedAt ?? null,
|
|
2244
2249
|
pending: false,
|
|
2245
2250
|
error: null,
|
|
2246
2251
|
}),
|
|
2247
|
-
|
|
2252
|
+
improving: null,
|
|
2253
|
+
}),
|
|
2248
2254
|
);
|
|
2249
2255
|
}
|
|
2250
2256
|
|
|
2257
|
+
/**
|
|
2258
|
+
* Improve before review: the agent gets its scorecard's low scores, unmet criteria and where a
|
|
2259
|
+
* reviewer would look, and its changes are scored again when it next updates its brief as done.
|
|
2260
|
+
* `send` delivers the message (the backlog reopens an item in review); else it is told directly.
|
|
2261
|
+
*/
|
|
2262
|
+
improve(ref, { send } = {}) {
|
|
2263
|
+
const agent = this.mustGet(ref);
|
|
2264
|
+
const doc = agent.docs?.scorecard;
|
|
2265
|
+
if (doc?.pending) throw Object.assign(new Error('The changes are being scored; try again when that is done'), { status: 409 });
|
|
2266
|
+
if (!doc?.data) throw Object.assign(new Error('Score the changes first'), { status: 409 });
|
|
2267
|
+
const text = improveMessage(doc.data);
|
|
2268
|
+
if (send) send(text);
|
|
2269
|
+
else this.tell(agent.id, text, { source: 'scorecard' });
|
|
2270
|
+
return this.view(this.patch(agent.id, { improving: { from: doc.data.overall, at: now(), briefAt: agent.brief?.updatedAt ?? null } }));
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2251
2273
|
/** Diagrams only: the problem and its solution drawn from the documents already written and the live context. */
|
|
2252
2274
|
async runDiagrams(agent, ctx) {
|
|
2253
2275
|
const cur0 = this.store.get(agent.id) ?? agent;
|
package/src/scorecard.js
CHANGED
|
@@ -156,6 +156,29 @@ export function combine(reply, f, hunks) {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
export const RECOMMENDATION = { ready: 'Ready to approve', look: 'Look at these first', not_ready: 'Not ready' };
|
|
159
|
+
const IMPROVE_BELOW = 80; // scores under this are named in the Improve message
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Improve: the agent's own scorecard sent back to it before a person reviews the work. The scores
|
|
163
|
+
* to raise (lowest first, with why), the acceptance criteria not met or not verified, and where a
|
|
164
|
+
* reviewer would look.
|
|
165
|
+
*/
|
|
166
|
+
export function improveMessage(sc) {
|
|
167
|
+
const weak = DIMENSIONS.filter(([k]) => sc.scores[k] && sc.scores[k].score < IMPROVE_BELOW)
|
|
168
|
+
.sort(([a], [b]) => sc.scores[a].score - sc.scores[b].score)
|
|
169
|
+
.map(([k, label]) => `- ${label} ${sc.scores[k].score}/100: ${[sc.scores[k].why, ...sc.scores[k].notes].filter(Boolean).join('; ')}`);
|
|
170
|
+
const criteria = sc.criteria.filter((c) => c.status !== 'met').map((c) => `- ${c.status === 'not_met' ? 'Not met' : 'Not verified'}: ${c.criterion}${c.evidence ? ` (${c.evidence})` : ''}`);
|
|
171
|
+
const look = sc.look.map((l) => `- ${l.file}: ${l.reason}`);
|
|
172
|
+
return [
|
|
173
|
+
`Before this goes to review: the review scorecard rates your changes ${sc.overall ?? '?'}/100 (${RECOMMENDATION[sc.recommendation]}${sc.reasons.length ? `: ${sc.reasons.join('; ')}` : ''}). Improve them where they fall short.`,
|
|
174
|
+
weak.length ? `Scores to raise:\n${weak.join('\n')}` : '',
|
|
175
|
+
criteria.length ? `Acceptance criteria:\n${criteria.join('\n')}` : '',
|
|
176
|
+
look.length ? `Where a reviewer would look first:\n${look.join('\n')}` : '',
|
|
177
|
+
'Fix what these point at: add or fix tests and run them, meet the criteria, handle the risky spots. Stay within the task, and say in the brief what you left as it is and why. Update the brief when you are done; the changes are scored again then.',
|
|
178
|
+
]
|
|
179
|
+
.filter(Boolean)
|
|
180
|
+
.join('\n\n');
|
|
181
|
+
}
|
|
159
182
|
|
|
160
183
|
/** Markdown: for Copy / Download, the pull request's description and a PR comment. */
|
|
161
184
|
export function scorecardMarkdown(sc, title = '') {
|
package/src/server.js
CHANGED
|
@@ -268,6 +268,15 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
268
268
|
);
|
|
269
269
|
api.get('/agents/:id/specs/:name', wrap((req) => manager.specDoc(req.params.id, req.params.name)));
|
|
270
270
|
api.post('/agents/:id/scorecard/post', wrap((req) => review.postScorecard(req.params.id)));
|
|
271
|
+
// Improve: its scorecard goes back to the agent; a backlog item in review is reopened with it.
|
|
272
|
+
api.post(
|
|
273
|
+
'/agents/:id/scorecard/improve',
|
|
274
|
+
wrap((req) => {
|
|
275
|
+
const id = manager.mustGet(req.params.id).id;
|
|
276
|
+
const it = backlog.items().find((i) => i.agentId === id && i.status === 'review');
|
|
277
|
+
return manager.improve(id, it ? { send: (note) => backlog.reopen(it.id, { note }) } : {});
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
271
280
|
api.post('/agents/:id/plan', wrap((req) => manager.startPlanning(req.params.id)));
|
|
272
281
|
api.post('/agents/:id/plan/approve', wrap((req) => manager.approvePlan(req.params.id, req.body?.note)));
|
|
273
282
|
api.post('/agents/:id/plan/changes', wrap((req) => manager.requestPlanChanges(req.params.id, req.body?.note)));
|