ai-agentmanager 1.13.0 → 1.15.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 +13 -0
- package/README.md +26 -1
- package/docs/agentkit.md +1 -1
- package/package.json +1 -1
- package/public/app.js +124 -7
- package/public/index.html +5 -0
- package/public/menu.js +18 -0
- package/public/style.css +10 -0
- package/src/autopilot.js +184 -0
- package/src/backlog.js +19 -7
- package/src/builder.js +36 -15
- package/src/cli.js +57 -2
- package/src/manager.js +63 -2
- package/src/repeats.js +155 -0
- package/src/review.js +14 -2
- package/src/rules.js +161 -0
- package/src/server.js +44 -6
- package/src/stuck.js +9 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.15.0 (2026-11-03)
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
- **Requests you keep making become playbooks:** when you've typed alike requests 3 times (new agents and backlog items, with numbers, names, URLs and issue keys set aside), the Inbox offers **Make it a playbook?**. **Save as playbook…** opens the editor prefilled: the shared words become the request, the part that changes becomes `{{what}}`, and the projects they ran in become its projects. **Not now** stops offering it. Requests already saved as a playbook aren't offered. `"repeats": { "suggestAfter": 3, "enabled": true }` in config.json.
|
|
7
|
+
- **Commit all** no longer fails with `index.lock: File exists` when it lands on the app's own background `git status` of the same worktree. It retries for a moment.
|
|
8
|
+
|
|
9
|
+
## 1.14.0 (2026-11-02)
|
|
10
|
+
|
|
11
|
+
- **Fewer questions: approval rules.** When you allow the same command 3 times in a repo (`npm test`, an MCP tool, fetching from a site), the inbox offers **Always allow**. Allowed rules go to every Claude Code agent in that repo at launch and answer its open prompts, including the ones waiting when you allow it. Never offered for compound commands or ones that delete, escalate, publish or reach the network. Settings → Approval rules and `agentmgr rules` list them, with how many prompts each answered, and remove them.
|
|
12
|
+
- **Prompt cards say what they ask about:** "Wants to run `npm test`" (from the session's transcript) instead of only "Do you want to proceed?".
|
|
13
|
+
- **Yes to all:** several agents waiting on the same command get one card that answers them together.
|
|
14
|
+
- **Built agents' tests run in a throwaway copy** of the agent's folder: what a run writes (summaries, changed fixtures, memory) no longer stays in the agent or changes the next test's starting point. The copy keeps the agent's memory, not its engine sessions or run logs. A `workdir` outside the agent's folder is still used in place.
|
|
15
|
+
|
|
3
16
|
## 1.13.0 (2026-11-01)
|
|
4
17
|
|
|
5
18
|
- **Agents are built in an agent session.** **+ Build an agent** now starts an agent session that works in the new agent's folder (`~/.agentmanager/built/<name>/`) and goes straight to it. The session writes the analysis (`analysis.json`), `agent.yaml`, `instructions.md` and `tests.yaml`, and checks that the agent loads. You keep shaping the agent in that conversation, like any other session (terminal, resume, inbox).
|
package/README.md
CHANGED
|
@@ -514,6 +514,21 @@ Queue asks instead of starting them. Use **+ Add** in the Backlog tab, **Queue i
|
|
|
514
514
|
|
|
515
515
|
The backlog is saved with the rest of the state.
|
|
516
516
|
|
|
517
|
+
### Autopilot
|
|
518
|
+
|
|
519
|
+
Autopilot takes a project's finished items the rest of the way when the [review scorecard](#review-changes) says the work is ready. Set it per project with **🛫** in the project's row in the Backlog tab, or `agentmgr backlog autopilot <project> off|pr|merge`. It is off by default.
|
|
520
|
+
|
|
521
|
+
- **Off:** items wait in review for **Accept** or **Reopen**, as above.
|
|
522
|
+
- **Open PRs:** when an item reaches review, its changes are scored (again, if they changed since the last score). If the scorecard says *Ready to approve*, autopilot commits what's left (the item's title as the message), pushes and opens the pull request, with the scorecard in its description.
|
|
523
|
+
- **Merge:** also merges that pull request once its checks pass and GitHub allows it. A branch that fell behind is brought up to date first, and failing checks go back to the agent through [Landing](#landing-pull-requests).
|
|
524
|
+
|
|
525
|
+
What it does with work that isn't ready:
|
|
526
|
+
|
|
527
|
+
- **Not ready, for a concrete reason** (failing CI, acceptance criteria not met, barely verified): the item goes back to the same agent with those reasons. This happens twice at most; after that it waits for you.
|
|
528
|
+
- **Look at these first, a failed step, or a closed pull request:** autopilot stops and it waits for you in the **Inbox** ("Autopilot held"), saying why. You can **Review changes**, **Ship it** (go ahead anyway), or **Send back** with a note.
|
|
529
|
+
|
|
530
|
+
An item whose pull request merges is done. Each item shows where autopilot stands ("pull request open #12", "waiting for checks to merge", "sent back to the agent"). **Ship it** is also on every item in review, so you can ship one by hand with autopilot off. Plugins can still refuse the commit, the pull request or the merge (`agent.commit`, `agent.pr`, `agent.merge`).
|
|
531
|
+
|
|
517
532
|
## Playbooks
|
|
518
533
|
|
|
519
534
|
Saved requests you run again: **Backlog → Playbooks**, **Save as playbook…** in New agents, or `agentmgr playbook`. A playbook is a request, the projects to run it in (one agent each), and optionally a model, permission mode, priority and schedule:
|
|
@@ -524,6 +539,13 @@ Saved requests you run again: **Backlog → Playbooks**, **Save as playbook…**
|
|
|
524
539
|
|
|
525
540
|
Agents started by a playbook are marked **▶ <playbook>**, and the playbook lists the agents of its last run.
|
|
526
541
|
|
|
542
|
+
**Requests you keep making become playbooks.** AI Agent Manager remembers the requests you type for new agents and backlog items (the last 500, in `~/.agentmanager/asks.json`). Requests started by playbooks, triggers or the assistant aren't counted. When 3 read alike, the Inbox offers **Make it a playbook?** with the examples. "Alike" means the same words once numbers, quoted names, URLs, issue keys and filler words are set aside.
|
|
543
|
+
|
|
544
|
+
- **Save as playbook…** opens the playbook editor prefilled. The words the requests share become the request, and the part that changes becomes `{{what}}`: "Upgrade react to the latest version" and "Upgrade vite to the latest version" become "Upgrade `{{what}}` to the latest version". The projects they ran in become the playbook's projects.
|
|
545
|
+
- **Not now** stops offering that one.
|
|
546
|
+
- A request that is already a playbook isn't offered.
|
|
547
|
+
- Change the count with `"repeats": { "suggestAfter": 3 }`, or turn it off with `"repeats": { "enabled": false }`.
|
|
548
|
+
|
|
527
549
|
```sh
|
|
528
550
|
agentmgr playbook add "Flaky tests" "Find yesterday's flaky tests in CI and fix or quarantine them" --projects web,api --every weekday --at 09:00
|
|
529
551
|
agentmgr playbook ls
|
|
@@ -541,13 +563,16 @@ The **Inbox** tab (and `agentmgr inbox`) lists what needs you, across all agents
|
|
|
541
563
|
|
|
542
564
|
| Kind | What it is | How you answer |
|
|
543
565
|
| --- | --- | --- |
|
|
544
|
-
| Needs input | A permission prompt or question menu is open in the agent's terminal | Click a choice (1, 2, 3, Esc); no need to open the terminal |
|
|
566
|
+
| Needs input | A permission prompt or question menu is open in the agent's terminal; for Claude Code, the card shows the command or file it asks about | Click a choice (1, 2, 3, Esc); no need to open the terminal. Several agents waiting on the same command: **Yes to all** |
|
|
567
|
+
| Always allow? | You allowed the same command 3 times in a repo | **Always allow** (agents there stop asking) or **Not now** |
|
|
545
568
|
| Waiting on you | The AI judge read an idle screen and saw a question (or an error) no rule recognized | Click a choice or type an answer; **Not waiting** if it misread |
|
|
546
569
|
| Question | The agent asked something without stopping | Click an option or type an answer |
|
|
547
570
|
| AI review | The agent said it was done and the judge checked the work against the intent and brief | **Send back to finish** (types the missing items in) or **Looks good** |
|
|
548
571
|
| Stuck? | The agent seems to be going in circles | Send the suggested nudge (edit it first if you like) or dismiss |
|
|
549
572
|
| Overlap | It changes the same files as another agent | Tell one or both agents; see the suggested merge order |
|
|
550
573
|
|
|
574
|
+
**Fewer questions: approval rules.** Each yes you give a permission prompt counts toward the command, per repo (`npm test`, `pytest`, `git status`; an MCP tool; fetching from a site). After 3, the inbox offers to always allow it there. An allowed rule goes to every agent in that repo when it starts (Claude Code's `--allowedTools`), and answers its prompts that are already open, so you aren't asked again. Rules are never offered for commands that chain, redirect or substitute (`&&`, `|`, `>`, `$(…)`), or that delete, escalate, publish or reach the network (`rm`, `sudo`, `git push`, `npm publish`, `curl`…). Settings → Approval rules (or `agentmgr rules`) lists them with how many prompts each answered; **Remove** to be asked again. Stored in `~/.agentmanager/rules.json`; In config.json, `"rules": { "auto": false }` stops rules answering open prompts and `"rules": { "suggestAfter": 5 }` changes the count. Claude Code agents only for now.
|
|
575
|
+
|
|
551
576
|
**Questions without stopping.** Task agents are told to write questions that don't block all their work to `QUESTIONS.md` next to their brief, with options and the default they'll assume meanwhile, and then keep going:
|
|
552
577
|
|
|
553
578
|
```markdown
|
package/docs/agentkit.md
CHANGED
|
@@ -67,7 +67,7 @@ interfaces:
|
|
|
67
67
|
|
|
68
68
|
It asks you what only you know: accounts, labels, teams. You keep developing the agent in that conversation.
|
|
69
69
|
|
|
70
|
-
The session's page shows a panel for its agent: status and test results, with **Run tests** (each test runs through the real runtime and is judged against `expect`), **Start**/**Stop** (runs it continuously on this computer, with its chat page and API) and **Details**. Failing tests can go straight back to the session. The Built tab lists every built agent with a link to its session. Its details dialog sends change requests to the session and has the spec, executables and downloads. Results and executables from before the latest change are marked out of date.
|
|
70
|
+
The session's page shows a panel for its agent: status and test results, with **Run tests** (each test runs through the real runtime in a throwaway copy of the agent's folder, so what one run writes doesn't change the agent or the next test, and is judged against `expect` and the files it wrote), **Start**/**Stop** (runs it continuously on this computer, with its chat page and API) and **Details**. Failing tests can go straight back to the session. The Built tab lists every built agent with a link to its session. Its details dialog sends change requests to the session and has the spec, executables and downloads. Results and executables from before the latest change are marked out of date.
|
|
71
71
|
|
|
72
72
|
## A run
|
|
73
73
|
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -127,6 +127,8 @@ $('#new-playbook').onclick = () => {
|
|
|
127
127
|
};
|
|
128
128
|
$('#triggers-open').onclick = () => triggersUi.open();
|
|
129
129
|
let builtReports = []; // agents built with agentkit that report here (runs, approvals)
|
|
130
|
+
let repeatsState = { suggestions: [] }; // requests you keep making, offered as playbooks (src/repeats.js)
|
|
131
|
+
let rulesState = { allowed: [], suggestions: [] }; // approval rules learned from your answers (src/rules.js)
|
|
130
132
|
const builtUi = initBuilt({
|
|
131
133
|
api, esc, toast, $,
|
|
132
134
|
isOpen: () => sideTab === 'built',
|
|
@@ -854,7 +856,7 @@ function renderList() {
|
|
|
854
856
|
const ITEM_ACTIONS = {
|
|
855
857
|
queued: [['up', '▲', 'Move up'], ['down', '▼', 'Move down'], ['start', 'Start now', 'Start now, even if the project is at its limit'], ['cancel', 'Cancel', '']],
|
|
856
858
|
active: [['open', 'Open', 'Open its agent'], ['accept', 'Done', 'Mark done now'], ['cancel', 'Cancel', '']],
|
|
857
|
-
review: [['accept', 'Accept', 'Accept the work: done'], ['reopen', 'Reopen', 'Send it back to the same agent with a note'], ['open', 'Open', 'Open its agent']],
|
|
859
|
+
review: [['accept', 'Accept', 'Accept the work: done'], ['ship', 'Ship it', 'Commit, push and open its pull request (and merge it, at the Merge autopilot level)'], ['reopen', 'Reopen', 'Send it back to the same agent with a note'], ['open', 'Open', 'Open its agent']],
|
|
858
860
|
blocked: [['start', 'Retry', 'Retry the agent'], ['open', 'Open', 'Open its agent'], ['cancel', 'Cancel', '']],
|
|
859
861
|
done: [['reopen', 'Reopen', ''], ['open', 'Open', ''], ['remove', '✕', 'Remove from the list']],
|
|
860
862
|
cancelled: [['remove', '✕', 'Remove from the list']],
|
|
@@ -894,6 +896,7 @@ function renderBacklog() {
|
|
|
894
896
|
${p.shortcut ? `<span class="shortcut">${esc(p.shortcut)}</span>` : ''}
|
|
895
897
|
<span class="pname">${esc(g.name)}</span>
|
|
896
898
|
<button class="link" data-bl="limit" data-project="${esc(g.key)}" data-name="${esc(g.name)}" title="How many items of this project agents work on at once">${p.limit} at a time ✎</button>
|
|
899
|
+
<label class="bl-autopilot" title="${esc(AUTOPILOT_HINT)}">🛫 <select aria-label="Autopilot" data-bl-autopilot="${esc(g.key)}">${Object.entries(AUTOPILOT_LEVELS).map(([v, l]) => `<option value="${v}" ${(p.autopilot ?? 'off') === v ? 'selected' : ''}>${l}</option>`).join('')}</select></label>
|
|
897
900
|
</div>
|
|
898
901
|
<ul class="project-agents">${g.items.map(itemRow).join('')}</ul>
|
|
899
902
|
</li>`;
|
|
@@ -903,6 +906,17 @@ function renderBacklog() {
|
|
|
903
906
|
<p class="hint bl-foot"><label><input type="checkbox" data-bl="completed" ${showCompleted ? 'checked' : ''}/> Show done and cancelled</label></p>`;
|
|
904
907
|
}
|
|
905
908
|
|
|
909
|
+
const AUTOPILOT_LEVELS = { off: 'Off', pr: 'Open PRs', merge: 'Merge' };
|
|
910
|
+
const AUTOPILOT_HINT = 'When an item is done and its review scorecard says ready: Open PRs commits, pushes and opens the pull request; Merge also merges it once checks pass. Not-ready work goes back to the agent; anything else waits for you in the inbox.';
|
|
911
|
+
const AUTOPILOT_STATE = { queued: 'going ahead', scoring: 'scoring the changes', shipping: 'opening the pull request', pr: 'pull request open', landing: 'waiting for checks to merge', merging: 'merging', returned: 'sent back to the agent', held: 'waiting for you', merged: 'merged' };
|
|
912
|
+
/** Where autopilot stands on an item, under it in the backlog. */
|
|
913
|
+
function autopilotLine(it) {
|
|
914
|
+
const ap = it.autopilot;
|
|
915
|
+
if (!ap?.state || !['review', 'active', 'done'].includes(it.status) || (it.status === 'active' && ap.state !== 'returned')) return '';
|
|
916
|
+
const pr = ap.pr ? ` <a href="${esc(ap.pr)}" target="_blank" rel="noopener">#${esc(ap.pr.split('/').pop())} ↗</a>` : '';
|
|
917
|
+
return `<p class="hint bl-ai bl-ap ${ap.state === 'held' ? 'held' : ''}">🛫 Autopilot: ${AUTOPILOT_STATE[ap.state] ?? esc(ap.state)}${pr}${ap.note ? ` · ${esc(ap.note)}` : ''}</p>`;
|
|
918
|
+
}
|
|
919
|
+
|
|
906
920
|
function itemRow(it) {
|
|
907
921
|
const a = it.agent;
|
|
908
922
|
const st = it.status === 'active' && a?.attention ? 'attention' : it.status;
|
|
@@ -918,10 +932,11 @@ function itemRow(it) {
|
|
|
918
932
|
${it.base ? `<span>⎇ from ${esc(it.base)}</span>` : ''}
|
|
919
933
|
${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>`}
|
|
920
934
|
</div>
|
|
935
|
+
${autopilotLine(it)}
|
|
921
936
|
${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>` : ''}
|
|
922
937
|
${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>
|
|
923
938
|
<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>` : ''}
|
|
924
|
-
<div class="bl-actions">${(ITEM_ACTIONS[it.status] ?? []).map(([act, label, title]) => `<button class="small" data-bl="${act}" title="${esc(title)}">${label}</button>`).join('')}</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>
|
|
925
940
|
</li>`;
|
|
926
941
|
}
|
|
927
942
|
|
|
@@ -941,6 +956,12 @@ function showTab(tab) {
|
|
|
941
956
|
}
|
|
942
957
|
for (const b of document.querySelectorAll('.side-tab')) b.onclick = () => showTab(b.dataset.tab);
|
|
943
958
|
|
|
959
|
+
$('#backlog-view').addEventListener('change', (e) => {
|
|
960
|
+
const key = e.target.dataset?.blAutopilot;
|
|
961
|
+
if (!key) return;
|
|
962
|
+
const level = e.target.value;
|
|
963
|
+
act(() => api('POST', '/backlog/settings', { project: key, autopilot: level }), level === 'off' ? 'Autopilot off' : `Autopilot: ${AUTOPILOT_LEVELS[level]}`);
|
|
964
|
+
});
|
|
944
965
|
$('#backlog-view').addEventListener('click', async (e) => {
|
|
945
966
|
const el = e.target.closest('[data-bl]');
|
|
946
967
|
if (!el) return;
|
|
@@ -988,6 +1009,8 @@ $('#backlog-view').addEventListener('click', async (e) => {
|
|
|
988
1009
|
return blApi('POST', `/backlog/${id}/split`, { parts: it.aiSplit.parts });
|
|
989
1010
|
case 'keep-whole':
|
|
990
1011
|
return blApi('POST', `/backlog/${id}/keep-whole`);
|
|
1012
|
+
case 'ship':
|
|
1013
|
+
return act(() => api('POST', `/backlog/${id}/ship`), 'Shipping: opening its pull request');
|
|
991
1014
|
case 'open': {
|
|
992
1015
|
const agentId = it?.agent?.id;
|
|
993
1016
|
if (!agentId) return;
|
|
@@ -1013,7 +1036,7 @@ function flags(a) {
|
|
|
1013
1036
|
}
|
|
1014
1037
|
|
|
1015
1038
|
const TRIAGED = { now: 'sent (AI: urgent)', digest: 'held for the digest', skip: 'dropped by the AI judge' };
|
|
1016
|
-
const NEED_ORDER = { approval: 0, prompt: 0, waiting: 1, plan: 1, question: 1, team: 1, review: 2, stuck: 3, conflict: 4 };
|
|
1039
|
+
const NEED_ORDER = { approval: 0, prompt: 0, waiting: 1, plan: 1, question: 1, team: 1, rule: 2, review: 2, autopilot: 2, stuck: 3, conflict: 4, repeat: 5 };
|
|
1017
1040
|
/** What an agent needs from the user. `both`: overlaps on both agents (detail), else only the one that should rebase (inbox). */
|
|
1018
1041
|
function needsFor(a, { both = false } = {}) {
|
|
1019
1042
|
const items = [];
|
|
@@ -1037,20 +1060,61 @@ function teamNeeds() {
|
|
|
1037
1060
|
function approvalNeeds() {
|
|
1038
1061
|
return builtReports.flatMap((r) => r.pending.map((p) => ({ a: { id: r.key, name: r.title, priority: 1 }, kind: 'approval', key: `approval:${r.key}:${p.id}`, r, p })));
|
|
1039
1062
|
}
|
|
1063
|
+
/** The repo a rule belongs to, short: its last two path parts. */
|
|
1064
|
+
const repoLabel = (k) => String(k).replace(/^dir:/, '').split('/').filter(Boolean).slice(-2).join('/');
|
|
1065
|
+
/** Commands you keep allowing in a repo, offered as a standing rule. */
|
|
1066
|
+
function ruleNeeds() {
|
|
1067
|
+
return rulesState.suggestions.map((r) => ({ a: { id: 'rules', name: repoLabel(r.repo), priority: 2 }, kind: 'rule', key: `rule:${r.repo}:${r.rule}`, r }));
|
|
1068
|
+
}
|
|
1069
|
+
/** Backlog work autopilot held for you. */
|
|
1070
|
+
function autopilotNeeds() {
|
|
1071
|
+
return backlogState.items
|
|
1072
|
+
.filter((it) => it.status === 'review' && it.autopilot?.state === 'held' && it.autopilot.round === it.reviewAt)
|
|
1073
|
+
.map((it) => ({ a: { id: it.agentId ?? '', name: it.agent?.name ?? it.projectName, priority: it.priority }, kind: 'autopilot', key: `autopilot:${it.id}`, it }));
|
|
1074
|
+
}
|
|
1075
|
+
/** Requests you keep making, offered as a playbook. */
|
|
1076
|
+
function repeatNeeds() {
|
|
1077
|
+
return repeatsState.suggestions.map((r) => ({ a: { id: 'repeats', name: r.projects.join(', ') || 'Requests', priority: 3 }, kind: 'repeat', key: `repeat:${r.key}`, r }));
|
|
1078
|
+
}
|
|
1040
1079
|
function inboxItems() {
|
|
1041
|
-
return [...approvalNeeds(), ...[...agents.values()].flatMap((a) => needsFor(a)), ...teamNeeds()]
|
|
1080
|
+
return [...approvalNeeds(), ...[...agents.values()].flatMap((a) => needsFor(a)), ...teamNeeds(), ...ruleNeeds(), ...autopilotNeeds(), ...repeatNeeds()]
|
|
1042
1081
|
.sort((x, y) => NEED_ORDER[x.kind] - NEED_ORDER[y.kind] || (x.a.priority ?? 2) - (y.a.priority ?? 2));
|
|
1043
1082
|
}
|
|
1044
1083
|
|
|
1045
|
-
const NEED_LABEL = { approval: 'Approval', team: 'Team', prompt: 'Needs input', waiting: 'Waiting on you', plan: 'Plan review', question: 'Question', review: 'AI review', stuck: 'Stuck?', conflict: 'Overlap' };
|
|
1084
|
+
const NEED_LABEL = { repeat: 'Make it a playbook?', autopilot: 'Autopilot held', rule: 'Always allow?', approval: 'Approval', team: 'Team', prompt: 'Needs input', waiting: 'Waiting on you', plan: 'Plan review', question: 'Question', review: 'AI review', stuck: 'Stuck?', conflict: 'Overlap' };
|
|
1046
1085
|
/** Why the AI judge thinks so, as a small tag. */
|
|
1047
1086
|
const aiTag = (confidence, what = 'AI') => `<span class="tag ai" title="Judged by a model from the agent's activity; your reactions tune later calls">🤖 ${what}${confidence != null ? ` ${Math.round(confidence * 100)}%` : ''}</span>`;
|
|
1087
|
+
/** What a permission prompt asks about: the command, file or tool. */
|
|
1088
|
+
const toolLine = (t) => (t.name === 'Bash' ? `Wants to run <code>${esc(t.text)}</code>` : `Wants to use ${esc(t.name)}${t.text ? ` <code>${esc(t.text)}</code>` : ''}`);
|
|
1089
|
+
/** Agents waiting on the same command, answered together. */
|
|
1090
|
+
function promptGroups(items) {
|
|
1091
|
+
const groups = new Map();
|
|
1092
|
+
for (const it of items) {
|
|
1093
|
+
const p = it.kind === 'prompt' && it.a.runtime.prompt;
|
|
1094
|
+
if (!p?.tool || p.choices?.[0]?.key !== '1' || !/^yes\b/i.test(p.choices[0].label)) continue;
|
|
1095
|
+
const k = `${p.tool.name}\0${p.tool.text}`;
|
|
1096
|
+
(groups.get(k) ?? groups.set(k, []).get(k)).push(it);
|
|
1097
|
+
}
|
|
1098
|
+
return [...groups.values()].filter((g) => g.length > 1);
|
|
1099
|
+
}
|
|
1100
|
+
function groupCard(g) {
|
|
1101
|
+
const t = g[0].a.runtime.prompt.tool;
|
|
1102
|
+
const names = g.map((it) => `<a href="#${it.a.id}" data-goto="${it.a.id}">${esc(it.a.name)}</a>`).join(', ');
|
|
1103
|
+
return `<div class="need prompt group" data-need="group:${esc(g.map((it) => it.a.id).join(','))}" data-agents="${esc(g.map((it) => it.a.id).join(','))}">
|
|
1104
|
+
<div class="need-head"><span class="need-kind prompt">${g.length} agents</span></div>
|
|
1105
|
+
<p class="need-title">${toolLine(t)}</p><p class="hint">${names}</p>
|
|
1106
|
+
<div class="need-actions"><button class="small primary" data-ib="yes-all">Yes to all ${g.length}</button></div></div>`;
|
|
1107
|
+
}
|
|
1048
1108
|
function needCard(it, { inDetail = false } = {}) {
|
|
1049
1109
|
const { a } = it;
|
|
1050
1110
|
const who = inDetail
|
|
1051
1111
|
? ''
|
|
1052
1112
|
: it.kind === 'approval'
|
|
1053
1113
|
? `<a href="#" class="need-agent" data-ib="built-open">⚙ ${esc(a.name)}</a> <span class="hint">${esc(it.r.host)}</span>`
|
|
1114
|
+
: it.kind === 'rule'
|
|
1115
|
+
? `<span class="need-agent" title="${esc(it.r.repo)}">${esc(a.name)}</span>`
|
|
1116
|
+
: it.kind === 'repeat'
|
|
1117
|
+
? `<span class="need-agent">${esc(a.name)}</span>`
|
|
1054
1118
|
: `<a href="#${a.id}" class="need-agent" data-goto="${a.id}">${prioChip(a.priority ?? 2)}${esc(a.name)}</a>${a.projectInfo?.name ? ` <span class="hint">${esc(a.projectInfo.name)}</span>` : ''}`;
|
|
1055
1119
|
const head = `<div class="need-head"><span class="need-kind ${it.kind}">${NEED_LABEL[it.kind]}</span>${who}</div>`;
|
|
1056
1120
|
const draft = (k, fallback = '') => esc(needDrafts.get(`${it.key}:${k}`) ?? fallback);
|
|
@@ -1060,9 +1124,23 @@ function needCard(it, { inDetail = false } = {}) {
|
|
|
1060
1124
|
body = `<p class="need-title">Wants to run <code>${esc(p.tool)}</code> <span class="hint">asked ${ago(p.at)}</span></p>
|
|
1061
1125
|
<pre class="need-input">${esc(JSON.stringify(p.input, null, 2).slice(0, 1500))}</pre>
|
|
1062
1126
|
<div class="need-actions"><button class="small primary" data-ib="approve" data-allow="1">Approve</button><button class="small" data-ib="approve">Deny</button>${it.r.chat ? `<a class="button small ghost" href="${esc(it.r.chat)}" target="_blank" rel="noopener">Its chat ↗</a>` : ''}</div>`;
|
|
1127
|
+
} else if (it.kind === 'repeat') {
|
|
1128
|
+
const r = it.r;
|
|
1129
|
+
body = `<p class="need-title">You've asked for this ${r.count} times: <code>${esc(r.intent)}</code></p>
|
|
1130
|
+
<ul class="need-files">${r.examples.map((x) => `<li>${esc(x.length > 140 ? `${x.slice(0, 140)}…` : x)}</li>`).join('')}</ul>
|
|
1131
|
+
<p class="hint">Save it as a playbook to run it again in one click, or on a schedule.</p>
|
|
1132
|
+
<div class="need-actions"><button class="small primary" data-ib="repeat-save">Save as playbook…</button><button class="small" data-ib="repeat-dismiss">Not now</button></div>`;
|
|
1133
|
+
} else if (it.kind === 'autopilot') {
|
|
1134
|
+
body = `<p class="need-title">${esc(it.it.title)}</p>
|
|
1135
|
+
<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>`;
|
|
1137
|
+
} else if (it.kind === 'rule') {
|
|
1138
|
+
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
|
+
<p class="hint">Agents in ${esc(a.name)} won't ask about it again. Remove it any time in Settings → Approval rules.</p>
|
|
1140
|
+
<div class="need-actions"><button class="small primary" data-ib="rule-accept">Always allow</button><button class="small" data-ib="rule-dismiss">Not now</button></div>`;
|
|
1063
1141
|
} else if (it.kind === 'prompt') {
|
|
1064
1142
|
const p = a.runtime.prompt;
|
|
1065
|
-
body = `<p class="need-title">${esc(p?.question || a.runtime.attention)}</p
|
|
1143
|
+
body = `${p?.tool ? `<p class="need-title">${toolLine(p.tool)}</p><p class="hint">${esc(p.question || a.runtime.attention)}</p>` : `<p class="need-title">${esc(p?.question || a.runtime.attention)}</p>`}
|
|
1066
1144
|
<div class="need-actions">
|
|
1067
1145
|
${(p?.choices ?? []).map((c) => `<button class="small ${c.key === '1' ? 'primary' : ''}" data-ib="key" data-key="${c.key}">${c.key}. ${esc(c.label)}</button>`).join('')}
|
|
1068
1146
|
<button class="small" data-ib="key" data-key="esc" title="Escape">Esc</button>
|
|
@@ -1171,7 +1249,7 @@ function renderInbox() {
|
|
|
1171
1249
|
const view = $('#inbox-view');
|
|
1172
1250
|
if (view.contains(document.activeElement) && document.activeElement.dataset.draft) return; // don't yank the field being typed in
|
|
1173
1251
|
view.innerHTML = items.length
|
|
1174
|
-
? `<div class="counts"><span>${items.length} waiting on you</span></div><div class="needs inbox">${items.map((it) => needCard(it)).join('')}</div>`
|
|
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>`
|
|
1175
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>`;
|
|
1176
1254
|
view.insertAdjacentHTML('beforeend', judgeLine());
|
|
1177
1255
|
}
|
|
@@ -1208,6 +1286,39 @@ async function onNeed(e) {
|
|
|
1208
1286
|
}
|
|
1209
1287
|
case 'key':
|
|
1210
1288
|
return act(() => api('POST', `/agents/${id}/prompt-key`, { key: el.dataset.key }), 'Sent');
|
|
1289
|
+
case 'yes-all': {
|
|
1290
|
+
const ids = card.dataset.agents.split(',');
|
|
1291
|
+
return act(() => Promise.all(ids.map((x) => api('POST', `/agents/${x}/prompt-key`, { key: '1' }))), `Allowed for ${ids.length} agents`);
|
|
1292
|
+
}
|
|
1293
|
+
case 'repeat-save': {
|
|
1294
|
+
const r = repeatsState.suggestions.find((x) => `repeat:${x.key}` === key);
|
|
1295
|
+
return r && playbooksUi.open({ name: r.name, intent: r.intent, projects: r.projects });
|
|
1296
|
+
}
|
|
1297
|
+
case 'repeat-dismiss':
|
|
1298
|
+
return act(async () => {
|
|
1299
|
+
repeatsState = await api('POST', '/repeats/dismiss', { key: key.slice('repeat:'.length) });
|
|
1300
|
+
renderInbox();
|
|
1301
|
+
}, "Won't suggest it again");
|
|
1302
|
+
case 'ap-review':
|
|
1303
|
+
showTab('agents');
|
|
1304
|
+
select(id);
|
|
1305
|
+
return reviewUi.open(id);
|
|
1306
|
+
case 'ap-ship':
|
|
1307
|
+
return act(() => api('POST', `/backlog/${key.split(':')[1]}/ship`), 'Shipping: opening its pull request');
|
|
1308
|
+
case 'ap-reopen': {
|
|
1309
|
+
const note = prompt('What still needs doing? (sent to the same agent)', '');
|
|
1310
|
+
if (note === null) return;
|
|
1311
|
+
return act(() => api('POST', `/backlog/${key.split(':')[1]}/reopen`, { note }), 'Sent back');
|
|
1312
|
+
}
|
|
1313
|
+
case 'rule-accept':
|
|
1314
|
+
case 'rule-dismiss': {
|
|
1315
|
+
const [, repo, rule] = key.match(/^rule:(.+?):((?:Bash|WebFetch|mcp__).*)$/) ?? [];
|
|
1316
|
+
const accept = el.dataset.ib === 'rule-accept';
|
|
1317
|
+
return act(async () => {
|
|
1318
|
+
rulesState = await api('POST', `/rules/${accept ? 'accept' : 'dismiss'}`, { repo, rule });
|
|
1319
|
+
renderInbox();
|
|
1320
|
+
}, accept ? 'Allowed in this repo; agents there won\'t ask again' : 'Won\'t suggest it again');
|
|
1321
|
+
}
|
|
1211
1322
|
case 'terminal':
|
|
1212
1323
|
showTab('agents');
|
|
1213
1324
|
select(id);
|
|
@@ -3637,6 +3748,8 @@ function handleEvent(msg) {
|
|
|
3637
3748
|
teams.clear();
|
|
3638
3749
|
for (const t of msg.teams ?? []) teams.set(t.id, t);
|
|
3639
3750
|
builtReports = msg.built ?? [];
|
|
3751
|
+
if (msg.rules) rulesState = msg.rules;
|
|
3752
|
+
if (msg.repeats) repeatsState = msg.repeats;
|
|
3640
3753
|
builtUi.render();
|
|
3641
3754
|
msg.agents.forEach(watchDocs);
|
|
3642
3755
|
if (selectedId && !agents.has(selectedId)) selectedId = null;
|
|
@@ -3661,6 +3774,10 @@ function handleEvent(msg) {
|
|
|
3661
3774
|
teams.set(msg.team.id, msg.team);
|
|
3662
3775
|
} else if (msg.t === 'team-removed') {
|
|
3663
3776
|
teams.delete(msg.id);
|
|
3777
|
+
} else if (msg.t === 'rules') {
|
|
3778
|
+
rulesState = msg.rules;
|
|
3779
|
+
} else if (msg.t === 'repeats') {
|
|
3780
|
+
repeatsState = msg.repeats;
|
|
3664
3781
|
} else if (msg.t === 'built') {
|
|
3665
3782
|
builtReports = msg.reports;
|
|
3666
3783
|
builtUi.render();
|
package/public/index.html
CHANGED
|
@@ -745,6 +745,11 @@
|
|
|
745
745
|
<label class="check">Keep at most <input type="number" name="maxRunning" min="1" max="500" placeholder="no limit" class="num" /> agents open at once</label>
|
|
746
746
|
<p class="hint">Above it, the agents idle longest are closed (they resume when you open them), so the machine stays responsive. Never closed: agents working, waiting for your answer, active in the last 5 minutes, marked Keep, or in a team that is still going. <span id="settings-running"></span></p>
|
|
747
747
|
</fieldset>
|
|
748
|
+
<fieldset id="settings-rules">
|
|
749
|
+
<legend>Approval rules</legend>
|
|
750
|
+
<p class="hint">Commands you chose to always allow in a repo. Agents there run them without asking. After you allow the same command 3 times, the inbox offers a rule.</p>
|
|
751
|
+
<ul id="settings-rules-list" class="rules-list"></ul>
|
|
752
|
+
</fieldset>
|
|
748
753
|
<fieldset class="local-only" id="settings-service">
|
|
749
754
|
<legend>Start at login</legend>
|
|
750
755
|
<label class="check"><input type="checkbox" name="startAtLogin" /> Start AI Agent Manager when I log in</label>
|
package/public/menu.js
CHANGED
|
@@ -92,6 +92,7 @@ export function initMenu({ api, esc, toast, $ }) {
|
|
|
92
92
|
form.maxRunning.value = c.maxRunning ?? '';
|
|
93
93
|
$('#settings-running').textContent = `Open now: ${c.running}.`;
|
|
94
94
|
service = null;
|
|
95
|
+
showRules(await api('GET', '/rules').catch(() => null));
|
|
95
96
|
const u = await api('GET', '/update').catch(() => null);
|
|
96
97
|
form.updateCheck.checked = u?.check !== false;
|
|
97
98
|
if (!remote()) {
|
|
@@ -115,6 +116,23 @@ export function initMenu({ api, esc, toast, $ }) {
|
|
|
115
116
|
toast(`Error: ${err.message}`, 5000);
|
|
116
117
|
}
|
|
117
118
|
};
|
|
119
|
+
/** Allowed rules, newest first, each with what it has saved and a Remove button. */
|
|
120
|
+
const showRules = (r) => {
|
|
121
|
+
const list = [...(r?.allowed ?? [])].sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
122
|
+
$('#settings-rules-list').innerHTML = list.length
|
|
123
|
+
? list.map((x) => `<li><code>${esc(x.label)}</code> <span class="hint">in ${esc(String(x.repo).replace(/^dir:/, '').split('/').filter(Boolean).slice(-2).join('/'))}${x.uses ? ` · answered ${x.uses} prompt${x.uses === 1 ? '' : 's'}` : ''}</span> <button type="button" class="small ghost" data-rule-remove="${esc(x.rule)}" data-repo="${esc(x.repo)}">Remove</button></li>`).join('')
|
|
124
|
+
: '<li class="hint">None yet.</li>';
|
|
125
|
+
};
|
|
126
|
+
$('#settings-rules-list').addEventListener('click', async (e) => {
|
|
127
|
+
const b = e.target.closest('[data-rule-remove]');
|
|
128
|
+
if (!b) return;
|
|
129
|
+
try {
|
|
130
|
+
showRules(await api('POST', '/rules/remove', { repo: b.dataset.repo, rule: b.dataset.ruleRemove }));
|
|
131
|
+
toast('Rule removed; agents there will ask again');
|
|
132
|
+
} catch (err) {
|
|
133
|
+
toast(`Error: ${err.message}`, 5000);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
118
136
|
$('#settings-cancel').onclick = () => dialog.close();
|
|
119
137
|
dialog.addEventListener('click', (e) => {
|
|
120
138
|
const go = e.target.closest('[data-open]');
|
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); }
|
|
@@ -979,3 +983,9 @@ body.remote .settings-links .local-only { display: none; }
|
|
|
979
983
|
.built-tests pre, .built-runs div { white-space: pre-wrap; font-size: 12.5px; }
|
|
980
984
|
.code-area { width: 100%; font: 12.5px/1.45 var(--mono); margin-top: 6px; }
|
|
981
985
|
.built-runs { list-style: none; padding: 0; } .built-runs li { padding: 6px 0; border-bottom: 1px solid var(--border); }
|
|
986
|
+
|
|
987
|
+
/* Approval rules (Settings) and agents waiting on the same command (inbox) */
|
|
988
|
+
.rules-list { list-style: none; padding: 0; margin: 6px 0 0; display: grid; gap: 6px; }
|
|
989
|
+
.rules-list li { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
990
|
+
.rules-list li button { margin-left: auto; }
|
|
991
|
+
.need.group { border-style: dashed; }
|
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
|
+
}
|