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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-agentmanager",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "description": "Run background coding agents (Claude Code, Codex) per intent, and keep track of them from a web UI, your phone or the CLI",
5
5
  "keywords": [
6
6
  "claude",
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).toLocaleString()} (input ${u.input.toLocaleString()} + output ${u.output.toLocaleString()}) · cache write ${u.cacheCreation.toLocaleString()} · cache read ${u.cacheRead.toLocaleString()} · total incl. cache ${u.total.toLocaleString()} · ${u.requests} API requests`;
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
- if (body.dataset.rendered !== d.at) {
405
- body.innerHTML = docView.kind === 'scorecard' ? scorecardHtml(d, esc) : d.data ? humanAiHtml(d) : docHtml(d.markdown);
406
- body.dataset.rendered = d.at;
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').innerHTML = projectGroups(shown)
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').innerHTML = `
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></div>`;
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
- view.innerHTML = items.length
1252
- ? `<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>`
1253
- : `<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>`;
1254
- view.insertAdjacentHTML('beforeend', judgeLine());
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
- body.innerHTML = `
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
- renderList();
1968
- renderBacklog();
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
- history.replaceState(null, '', id ? `#${id}` : '#');
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
- this.xterm.write(msg.d);
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
- agents.set(msg.agent.id, msg.agent);
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
- render();
3886
+ invalidate();
3791
3887
  }
3792
3888
 
3793
3889
  function connectEvents() {
@@ -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 {
@@ -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
- /** A small badge: "82 · Ready to approve". */
23
- export function badgeHtml(doc, esc) {
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></span>
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
- this.manager.write(agentId, message);
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 brief = loadBrief(id, agent.brief);
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 (brief.status === 'done' && agent.brief?.status !== 'done' && this.judge.enabled) this.requestReview(id, { trigger: 'brief' }).catch(() => {});
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 (brief.status === 'done' && agent.brief?.status !== 'done' && this.config.scorecard?.auto !== false && !NO_REPO.has(agent.kind) && this.changesSource) this.summarize(id, 'scorecard').catch(() => {});
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
- agent.id,
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)));