ai-agentmanager 1.14.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.
@@ -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
@@ -581,6 +581,10 @@ pre.diff .meta { color: var(--muted); font-weight: 600; }
581
581
 
582
582
  .bl-ai { margin: 4px 0 0; }
583
583
  .bl-ai ul { margin: 2px 0 4px 18px; padding: 0; }
584
+ .bl-ap.held { color: var(--yellow); }
585
+ .bl-ap { overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
586
+ .bl-autopilot { margin-left: auto; flex-shrink: 0; font-size: 12px; font-weight: 500; display: inline-flex; align-items: center; gap: 4px; }
587
+ .bl-autopilot select { font-size: 12px; padding: 1px 4px; }
584
588
 
585
589
  /* A CLI's sign-in link, above the terminal */
586
590
  .signin-bar { padding: 8px 12px; background: var(--panel-2); border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text); }
@@ -930,6 +934,12 @@ form.dropping { outline: 2px dashed var(--accent, #6b8cff); outline-offset: 4px;
930
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); }
931
935
  .sc-facts { font-size: 11.5px; }
932
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; }
933
943
  .rv-score { margin: 0 12px 8px; }
934
944
  .rv-score-empty { display: flex; align-items: center; gap: 10px; margin: 0 12px 8px; }
935
945
  .rv-hunk.flash td { animation: rvflash 1.6s; }
@@ -985,3 +995,6 @@ body.remote .settings-links .local-only { display: none; }
985
995
  .rules-list li { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
986
996
  .rules-list li button { margin-left: auto; }
987
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; }
@@ -0,0 +1,184 @@
1
+ // Autopilot: a backlog item's finished work goes on to a pull request, and merged, without you,
2
+ // when the review scorecard says it's ready. Set per project in the backlog:
3
+ // off - today's flow: the item waits in review for Accept or Reopen.
4
+ // pr - ready work is committed, pushed and opened as a pull request (scorecard in its description).
5
+ // merge - and the pull request is merged once its checks pass and GitHub allows it (a branch that
6
+ // fell behind is brought up to date first; failing checks go back to the agent through Landing).
7
+ // Work the scorecard finds not ready for a concrete reason (failing CI, criteria not met, barely
8
+ // verified) goes back to the agent with those reasons, a couple of times at most. Anything else
9
+ // ("look at these first", a failed step, a closed pull request) waits for you in the inbox, with
10
+ // Ship it to go ahead anyway. An item whose pull request merges is done.
11
+ import { RECOMMENDATION } from './scorecard.js';
12
+ import { landState } from './tracker.js';
13
+
14
+ export const AUTOPILOT_LEVELS = ['off', 'pr', 'merge'];
15
+ export const AUTOPILOT_LABEL = { off: 'Off', pr: 'Open pull requests', merge: 'Merge' };
16
+ const RETURNS = 2; // times not-ready work goes back to the agent before it waits for you
17
+
18
+ const now = () => new Date().toISOString();
19
+ const err = (status, message) => Object.assign(new Error(message), { status });
20
+
21
+ /** The message that sends not-ready work back to its agent. */
22
+ export function returnMessage(sc) {
23
+ const reasons = sc.reasons?.length ? sc.reasons.join('; ') : RECOMMENDATION[sc.recommendation];
24
+ const notMet = (sc.criteria ?? []).filter((c) => c.status === 'not_met').map((c) => `- ${c.criterion}${c.evidence ? ` (${c.evidence})` : ''}`);
25
+ return [`Autopilot: the review scorecard says this isn't ready yet: ${reasons}.`, notMet.length ? `Acceptance criteria not met:\n${notMet.join('\n')}` : '', 'Fix these and make sure the checks pass'].filter(Boolean).join('\n\n');
26
+ }
27
+
28
+ export class Autopilot {
29
+ constructor({ manager, store, backlog, review, landing, config = {}, log = (m) => console.log(`[agentmgr] ${m}`) }) {
30
+ Object.assign(this, { manager, store, backlog, review, landing, config, log });
31
+ this.returns = config.autopilot?.returns ?? RETURNS;
32
+ this.working = new Set(); // item ids with a step in progress
33
+ this.queued = null;
34
+ this.kick = () => this.schedule();
35
+ manager.on('change', this.kick);
36
+ manager.on('prs', this.kick);
37
+ backlog.on('change', this.kick);
38
+ this.timer = setInterval(this.kick, 30_000);
39
+ this.timer.unref?.();
40
+ }
41
+
42
+ close() {
43
+ clearInterval(this.timer);
44
+ this.manager.off('change', this.kick);
45
+ this.manager.off('prs', this.kick);
46
+ this.backlog.off('change', this.kick);
47
+ }
48
+
49
+ schedule() {
50
+ if (this.queued) return;
51
+ this.queued = setTimeout(() => {
52
+ this.queued = null;
53
+ this.tick().catch((e) => this.log(`autopilot: ${e.message}`));
54
+ }, 250);
55
+ this.queued.unref?.();
56
+ }
57
+
58
+ async tick() {
59
+ for (const it of this.backlog.items()) {
60
+ if (it.status !== 'review' || this.working.has(it.id)) continue;
61
+ this.working.add(it.id);
62
+ try {
63
+ await this.step(it);
64
+ } catch (e) {
65
+ this.hold(it, e.message);
66
+ } finally {
67
+ this.working.delete(it.id);
68
+ }
69
+ }
70
+ }
71
+
72
+ /** Record where autopilot stands on an item (shown in the backlog), only when it changed. */
73
+ note(it, patch) {
74
+ const next = { ...it.autopilot, ...patch };
75
+ if (JSON.stringify(next) === JSON.stringify(it.autopilot ?? {})) return;
76
+ it.autopilot = { ...next, at: now() };
77
+ this.backlog.changed();
78
+ }
79
+
80
+ /** Stop and wait for the person, for this round of review. */
81
+ hold(it, why) {
82
+ this.log(`autopilot: holding "${it.title}": ${why}`);
83
+ this.note(it, { state: 'held', note: String(why).slice(0, 500), round: it.reviewAt });
84
+ }
85
+
86
+ /** One look at an item in review: what's the next thing autopilot can do for it? */
87
+ async step(it) {
88
+ const agent = it.agentId && this.store.get(it.agentId);
89
+ if (!agent) return;
90
+ const ap = it.autopilot ?? {};
91
+ const pr = ap.pr ? agent.prs?.[ap.pr] : null;
92
+ // Its pull request merged (by autopilot, by you or by GitHub's auto-merge): the item is done.
93
+ if (pr?.state === 'MERGED') {
94
+ this.note(it, { state: 'merged', note: null });
95
+ this.backlog.accept(it.id);
96
+ return;
97
+ }
98
+ const level = this.backlog.autopilotFor(it.projectKey);
99
+ if (level === 'off' && !ap.ship) return;
100
+ if (ap.state === 'held' && ap.round === it.reviewAt) return;
101
+ if (this.manager.view(agent).runtime.busy) return;
102
+ if (pr?.state === 'CLOSED') return this.hold(it, 'Its pull request was closed without merging.');
103
+ // Shipped in this round of review: see it through. Reopened since: the new work is scored and pushed to it.
104
+ if (pr && ap.shipped === it.reviewAt) return level === 'merge' ? this.land(it, agent, pr) : undefined;
105
+
106
+ // Not shipped yet: the scorecard of the current changes decides (unless you said Ship it).
107
+ const changes = await this.review.get(agent.id);
108
+ if (!changes.files?.length) return this.hold(it, 'There are no changes to ship.');
109
+ if (ap.ship) return this.ship(it, agent, level);
110
+ const card = agent.docs?.scorecard;
111
+ if (card?.pending) return;
112
+ if (card?.diffHash !== changes.diffHash || !card?.data) {
113
+ if (ap.scored === changes.diffHash) return this.hold(it, card?.error ? `Couldn't score the changes: ${card.error}` : "Couldn't score the changes.");
114
+ this.note(it, { state: 'scoring', scored: changes.diffHash, note: null });
115
+ await this.manager.summarize(agent.id, 'scorecard').catch((e) => {
116
+ throw err(e.status, `Couldn't score the changes: ${e.message}`);
117
+ });
118
+ return this.schedule();
119
+ }
120
+ const sc = card.data;
121
+ if (sc.recommendation === 'ready') return this.ship(it, agent, level);
122
+ if (sc.recommendation === 'not_ready' && (ap.returns ?? 0) < this.returns) {
123
+ this.note(it, { state: 'returned', returns: (ap.returns ?? 0) + 1, note: sc.reasons.join('; ') });
124
+ this.backlog.reopen(it.id, { note: returnMessage(sc) });
125
+ return;
126
+ }
127
+ const why = sc.recommendation === 'look' ? `The scorecard says to look first (${sc.overall ?? '?'}/100)${sc.look?.length ? `: ${sc.look.map((l) => l.file).join(', ')}` : ''}.` : `Still not ready after ${ap.returns ?? 0} tr${ap.returns === 1 ? 'y' : 'ies'}: ${sc.reasons.join('; ')}.`;
128
+ this.hold(it, why);
129
+ }
130
+
131
+ /** Commit what's left, push and open the pull request; at "merge", landing takes it from there. */
132
+ async ship(it, agent, level) {
133
+ this.note(it, { state: 'shipping', note: null });
134
+ const title = it.title.split('\n')[0].slice(0, 100);
135
+ try {
136
+ await this.review.commit(agent.id, { message: title });
137
+ } catch (e) {
138
+ if (e.status !== 409 || !/nothing to commit/i.test(e.message)) throw e;
139
+ }
140
+ const { url } = await this.review.pr(agent.id, { title });
141
+ if (!url) throw err(502, 'Pushed, but no pull request link came back.');
142
+ this.log(`autopilot: opened ${url} for "${it.title}"`);
143
+ this.note(it, { state: level === 'merge' ? 'landing' : 'pr', pr: url, shipped: it.reviewAt, ship: false });
144
+ await this.manager.refresh(agent.id, { withPrs: true }).catch(() => {});
145
+ }
146
+
147
+ /** At "merge": merge a ready pull request, update one that fell behind, wait out the rest. */
148
+ async land(it, agent, pr) {
149
+ const state = landState(pr);
150
+ if (state === 'ready') {
151
+ this.note(it, { state: 'merging' });
152
+ await this.landing.merge(agent.id, { url: pr.url });
153
+ this.log(`autopilot: merged ${pr.url}`);
154
+ return;
155
+ }
156
+ if (state === 'behind' && it.autopilot.updated !== pr.head) {
157
+ this.note(it, { state: 'landing', updated: pr.head });
158
+ await this.landing.updateBranch(agent.id);
159
+ return;
160
+ }
161
+ const fixes = Object.keys(agent.landing?.sent ?? {}).filter((k) => k.startsWith(`${pr.url}#`)).length;
162
+ if (state === 'fix' && fixes >= this.landing.settings.maxFixes) return this.hold(it, `Its pull request still needs fixes after ${fixes} tries (checks, review or conflicts).`);
163
+ if (state === 'draft') return this.hold(it, 'Its pull request is a draft.');
164
+ this.note(it, { state: 'landing' });
165
+ }
166
+
167
+ /** "Ship it": go ahead with an item autopilot held (or one in review with autopilot off). */
168
+ shipNow(id) {
169
+ const it = this.backlog.get(id);
170
+ if (it.status !== 'review') throw err(409, `Item is ${it.status}`);
171
+ if (!it.agentId || !this.store.get(it.agentId)) throw err(409, 'The item has no agent');
172
+ this.note(it, { state: 'queued', ship: true, round: null, scored: null, note: null });
173
+ this.schedule();
174
+ return it;
175
+ }
176
+
177
+ /** Held items, for the inbox. */
178
+ inbox() {
179
+ return this.backlog
180
+ .items()
181
+ .filter((it) => it.status === 'review' && it.autopilot?.state === 'held' && it.autopilot.round === it.reviewAt)
182
+ .map((it) => ({ kind: 'autopilot', key: `autopilot:${it.id}`, title: it.title, itemId: it.id, agentId: it.agentId, project: it.projectName, priority: it.priority, note: it.autopilot.note, at: it.autopilot.at }));
183
+ }
184
+ }
package/src/backlog.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import crypto from 'node:crypto';
4
4
  import { EventEmitter } from 'node:events';
5
5
  import { parsePriority, projectOf } from './manager.js';
6
+ import { AUTOPILOT_LEVELS } from './autopilot.js';
6
7
  import { dispatchPrompt, hash } from './judge.js';
7
8
 
8
9
  const now = () => new Date().toISOString();
@@ -19,6 +20,7 @@ export class Backlog extends EventEmitter {
19
20
  this.config = config;
20
21
  store.data.backlog ??= { items: {}, limits: {}, autoRun: true };
21
22
  this.data = store.data.backlog;
23
+ this.data.autopilot ??= {};
22
24
  this.dispatching = false;
23
25
  this.plan = null; // the AI judge's dispatch plan: { hash, data, pending }
24
26
 
@@ -38,6 +40,12 @@ export class Backlog extends EventEmitter {
38
40
  return this.data.limits[projectKey] ?? this.defaultLimit();
39
41
  }
40
42
 
43
+ /** Autopilot for a project's items (see autopilot.js): "off", "pr" or "merge". */
44
+ autopilotFor(projectKey) {
45
+ const level = this.data.autopilot[projectKey] ?? this.config.autopilot?.level;
46
+ return AUTOPILOT_LEVELS.includes(level) ? level : 'off';
47
+ }
48
+
41
49
  items() {
42
50
  return Object.values(this.data.items).sort(
43
51
  (a, b) => a.priority - b.priority || a.order - b.order || a.createdAt.localeCompare(b.createdAt),
@@ -63,7 +71,7 @@ export class Backlog extends EventEmitter {
63
71
  const projects = {};
64
72
  for (const it of items) {
65
73
  const shortcut = Object.values(this.store.data.projects ?? {}).find((x) => x.key === it.projectKey)?.shortcut ?? null;
66
- const p = (projects[it.projectKey] ??= { key: it.projectKey, name: it.projectName, shortcut, limit: this.limitFor(it.projectKey), counts: {} });
74
+ const p = (projects[it.projectKey] ??= { key: it.projectKey, name: it.projectName, shortcut, limit: this.limitFor(it.projectKey), autopilot: this.autopilotFor(it.projectKey), counts: {} });
67
75
  p.counts[it.status] = (p.counts[it.status] ?? 0) + 1;
68
76
  }
69
77
  return {
@@ -180,14 +188,18 @@ export class Backlog extends EventEmitter {
180
188
  return { id };
181
189
  }
182
190
 
183
- settings({ autoRun, limits, project, limit } = {}) {
191
+ settings({ autoRun, limits, project, limit, autopilot } = {}) {
184
192
  if (typeof autoRun === 'boolean') this.data.autoRun = autoRun;
185
193
  if (limits && typeof limits === 'object') Object.assign(this.data.limits, limits);
186
- if (project !== undefined && limit !== undefined) {
187
- const key =
188
- this.manager.resolveProject(project)?.key ??
189
- this.items().find((x) => x.projectKey === project || x.projectName === project)?.projectKey ??
190
- project;
194
+ const key =
195
+ project === undefined
196
+ ? null
197
+ : this.manager.resolveProject(project)?.key ?? this.items().find((x) => x.projectKey === project || x.projectName === project)?.projectKey ?? project;
198
+ if (key && autopilot !== undefined) {
199
+ if (!AUTOPILOT_LEVELS.includes(autopilot)) throw err(400, `autopilot must be one of ${AUTOPILOT_LEVELS.join(', ')}`);
200
+ this.data.autopilot[key] = autopilot;
201
+ }
202
+ if (key && limit !== undefined) {
191
203
  const n = Number(limit);
192
204
  if (!(n >= 0 && n <= 20)) throw err(400, 'limit must be 0-20');
193
205
  this.data.limits[key] = n;
@@ -263,7 +275,8 @@ export class Backlog extends EventEmitter {
263
275
  Promise.resolve(this.manager.start(agentId))
264
276
  .then(() => new Promise((r) => setTimeout(r, 1500)))
265
277
  .then(() => {
266
- 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);
267
280
  setTimeout(() => this.manager.write(agentId, '\r'), 200);
268
281
  })
269
282
  .catch(() => {});
package/src/cli.js CHANGED
@@ -890,6 +890,16 @@ export async function main(argv) {
890
890
  if (!items.length) return console.log(dim('Nothing needs you right now.'));
891
891
  const label = { prompt: red('needs input'), waiting: red('waiting'), question: cyan('question'), review: cyan('AI review'), stuck: yellow('stuck?'), conflict: yellow('overlap') };
892
892
  for (const it of items) {
893
+ if (it.kind === 'repeat') {
894
+ console.log(`${cyan('make it a playbook?')} asked ${it.count} times: ${it.intent}`);
895
+ console.log(dim(` agentmgr playbook add "${it.name}" "${it.intent.replace(/"/g, '\\"')}" --projects ${it.projects.join(',') || '<project>'}`));
896
+ continue;
897
+ }
898
+ if (it.kind === 'autopilot') {
899
+ console.log(`${yellow('autopilot held')} ${bold(it.project ?? '')} ${it.title}`);
900
+ console.log(dim(` ${it.note ?? ''}\n agentmgr backlog ship ${it.itemId} · agentmgr changes ${it.agentId}`));
901
+ continue;
902
+ }
893
903
  if (it.kind === 'rule') {
894
904
  console.log(`${cyan('always allow?')} ${bold(repoLabel(it.repo))} ${it.title} ${dim(`(allowed ${it.n} times)`)}`);
895
905
  console.log(dim(' agentmgr rules'));
@@ -947,6 +957,17 @@ export async function main(argv) {
947
957
  console.log(`${green('✓')} answer queued for ${a.name}; it is typed into the session when the agent is idle`);
948
958
  });
949
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
+
950
971
  program
951
972
  .command('tell')
952
973
  .description("Send a message into an agent's session (typed in when it is idle; resumes it if closed)")
@@ -1244,15 +1265,18 @@ export async function main(argv) {
1244
1265
  const items = v.items.filter((i) => all || !['done', 'cancelled'].includes(i.status));
1245
1266
  console.log(`${v.autoRun ? green('running') : yellow('paused')} ${dim(`${v.defaultLimit} per project by default, ${v.maxActive} overall`)}`);
1246
1267
  if (!items.length) return console.log(dim('Backlog is empty. Add: agentmgr backlog add "ask" -r <repo>'));
1247
- const lim = Object.fromEntries(v.projects.map((p) => [p.key, p.limit]));
1268
+ const proj = Object.fromEntries(v.projects.map((p) => [p.key, p]));
1248
1269
  let last = null;
1249
1270
  for (const it of items) {
1250
1271
  if (it.projectKey !== last) {
1251
- console.log(`\n${bold(it.projectName)} ${dim(`(${lim[it.projectKey] ?? v.defaultLimit} at a time)`)}`);
1272
+ const p = proj[it.projectKey];
1273
+ const ap = p?.autopilot && p.autopilot !== 'off' ? `, autopilot: ${p.autopilot === 'merge' ? 'merge' : 'open PRs'}` : '';
1274
+ console.log(`\n${bold(it.projectName)} ${dim(`(${p?.limit ?? v.defaultLimit} at a time${ap})`)}`);
1252
1275
  last = it.projectKey;
1253
1276
  }
1254
1277
  const agent = it.agent ? dim(` → ${it.agent.name} (${it.agent.id})${it.agent.briefStatus ? ` brief: ${it.agent.briefStatus}` : ''}`) : '';
1255
1278
  console.log(` ${it.id} P${it.priority} ${(ST[it.status] ?? it.status).padEnd(18)} ${truncate(it.title, 60)}${agent}`);
1279
+ if (it.status === 'review' && it.autopilot?.state) console.log(dim(` autopilot: ${it.autopilot.state}${it.autopilot.pr ? ` ${it.autopilot.pr}` : ''}${it.autopilot.note ? `: ${it.autopilot.note}` : ''}`));
1256
1280
  }
1257
1281
  };
1258
1282
  bl.command('ls')
@@ -1340,6 +1364,15 @@ export async function main(argv) {
1340
1364
  await daemon();
1341
1365
  printBacklog(await api('POST', '/backlog/settings', { project, limit: Number(n) }));
1342
1366
  });
1367
+ bl.command('autopilot')
1368
+ .description("Take a project's finished items on by themselves when the review scorecard says ready: off, pr (open the pull request) or merge (and merge it once checks pass)")
1369
+ .argument('<project>', 'project name as shown in "backlog ls"')
1370
+ .argument('<level>', 'off, pr or merge')
1371
+ .action(async (project, level) => {
1372
+ await daemon();
1373
+ printBacklog(await api('POST', '/backlog/settings', { project, autopilot: level }));
1374
+ });
1375
+ itemCmd('ship', 'Go ahead with an item autopilot held: open its pull request (and merge, at the merge level)', 'ship');
1343
1376
  bl.command('pause')
1344
1377
  .description('Stop starting new items (active ones continue)')
1345
1378
  .action(async () => {
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';
@@ -286,6 +286,7 @@ export class AgentManager extends EventEmitter {
286
286
  this.outbox = new Map(); // agent id -> [message] waiting to be typed into its session
287
287
  // The AI judge: rules decide when to look, a model decides what is going on (see judge.js).
288
288
  this.rules = new Rules({ suggestAfter: config.rules?.suggestAfter });
289
+ this.inboxSources = [];
289
290
  this.judge = new Judge({ config, call: (agent, prompt, { timeoutMs }) => this.oneShot(agent, prompt, { ...this.judgeModel(agent), timeoutMs }) });
290
291
  this.stuckVerdicts = new Map(); // agent id -> Map(finding key -> { at, since, data })
291
292
  this.signIns = new Map(); // agent id -> { url, port, callback, terminal, at, finished } from a CLI's sign-in link
@@ -1955,13 +1956,17 @@ export class AgentManager extends EventEmitter {
1955
1956
  reloadBrief(id) {
1956
1957
  const agent = this.store.get(id);
1957
1958
  if (!agent) return;
1958
- const brief = loadBrief(id, agent.brief);
1959
+ const before = agent.brief; // patch() below changes agent.brief in place
1960
+ const brief = loadBrief(id, before);
1959
1961
  if (brief) {
1960
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;
1961
1966
  // The agent says it is done: have the judge check the work before the person looks.
1962
- 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(() => {});
1963
1968
  // ...and score it for the reviewer ("scorecard": { "auto": false } in config.json turns this off).
1964
- 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(() => {});
1965
1970
  }
1966
1971
  if (agent.planReview) {
1967
1972
  const file = loadPlanFile(id, agent.planReview);
@@ -2229,24 +2234,42 @@ export class AgentManager extends EventEmitter {
2229
2234
  const cur = this.store.get(agent.id);
2230
2235
  if (!cur) return null;
2231
2236
  return this.view(
2232
- this.patch(
2233
- agent.id,
2234
- summaryPatch(cur, 'scorecard', {
2237
+ this.patch(agent.id, {
2238
+ ...summaryPatch(cur, 'scorecard', {
2235
2239
  data: sc,
2236
2240
  cut,
2237
2241
  diffHash: hash(data.diff),
2238
2242
  markdown: scorecardMarkdown(sc, cur.name),
2239
2243
  writer: `${writer.engine.label}${writer.model ? ` (${writer.model})` : ''}`,
2240
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,
2241
2247
  at: now(),
2242
2248
  briefUpdatedAt: cur.brief?.updatedAt ?? null,
2243
2249
  pending: false,
2244
2250
  error: null,
2245
2251
  }),
2246
- ),
2252
+ improving: null,
2253
+ }),
2247
2254
  );
2248
2255
  }
2249
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
+
2250
2273
  /** Diagrams only: the problem and its solution drawn from the documents already written and the live context. */
2251
2274
  async runDiagrams(agent, ctx) {
2252
2275
  const cur0 = this.store.get(agent.id) ?? agent;
@@ -2875,7 +2898,9 @@ export class AgentManager extends EventEmitter {
2875
2898
  }
2876
2899
  // Commands you keep allowing in a repo: offer to always allow them there.
2877
2900
  for (const r of this.rules.view().suggestions) items.push({ kind: 'rule', key: `rule:${r.repo}:${r.rule}`, title: `Always allow ${r.label}?`, priority: 2, ...r });
2878
- const order = { prompt: 0, waiting: 1, question: 1, rule: 2, review: 2, stuck: 3, conflict: 4 };
2901
+ // Others' items (autopilot's held backlog work).
2902
+ for (const source of this.inboxSources) items.push(...source());
2903
+ const order = { prompt: 0, waiting: 1, question: 1, rule: 2, review: 2, autopilot: 2, stuck: 3, conflict: 4, repeat: 5 };
2879
2904
  return items.sort((a, b) => order[a.kind] - order[b.kind] || a.priority - b.priority);
2880
2905
  }
2881
2906