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.
- package/CHANGELOG.md +14 -0
- package/README.md +23 -0
- package/package.json +1 -1
- package/public/app.js +196 -33
- package/public/morph.js +89 -0
- package/public/review.js +14 -2
- package/public/scorecard.js +30 -4
- package/public/style.css +13 -0
- package/src/autopilot.js +184 -0
- package/src/backlog.js +21 -8
- package/src/cli.js +35 -2
- package/src/manager.js +34 -9
- package/src/repeats.js +155 -0
- package/src/review.js +14 -2
- package/src/scorecard.js +23 -0
- package/src/server.js +42 -6
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
|
@@ -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; }
|
package/src/autopilot.js
ADDED
|
@@ -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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
this.items().find((x) => x.projectKey === project || x.projectName === project)?.projectKey ??
|
|
190
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|