ai-agentmanager 1.14.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 CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 1.14.0 (2026-11-02)
4
10
 
5
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.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-agentmanager",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Run background coding agents (Claude Code, Codex) per intent, and keep track of them from a web UI, your phone or the CLI",
5
5
  "keywords": [
6
6
  "claude",
package/public/app.js CHANGED
@@ -127,6 +127,7 @@ $('#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)
130
131
  let rulesState = { allowed: [], suggestions: [] }; // approval rules learned from your answers (src/rules.js)
131
132
  const builtUi = initBuilt({
132
133
  api, esc, toast, $,
@@ -855,7 +856,7 @@ function renderList() {
855
856
  const ITEM_ACTIONS = {
856
857
  queued: [['up', '▲', 'Move up'], ['down', '▼', 'Move down'], ['start', 'Start now', 'Start now, even if the project is at its limit'], ['cancel', 'Cancel', '']],
857
858
  active: [['open', 'Open', 'Open its agent'], ['accept', 'Done', 'Mark done now'], ['cancel', 'Cancel', '']],
858
- 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']],
859
860
  blocked: [['start', 'Retry', 'Retry the agent'], ['open', 'Open', 'Open its agent'], ['cancel', 'Cancel', '']],
860
861
  done: [['reopen', 'Reopen', ''], ['open', 'Open', ''], ['remove', '✕', 'Remove from the list']],
861
862
  cancelled: [['remove', '✕', 'Remove from the list']],
@@ -895,6 +896,7 @@ function renderBacklog() {
895
896
  ${p.shortcut ? `<span class="shortcut">${esc(p.shortcut)}</span>` : ''}
896
897
  <span class="pname">${esc(g.name)}</span>
897
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>
898
900
  </div>
899
901
  <ul class="project-agents">${g.items.map(itemRow).join('')}</ul>
900
902
  </li>`;
@@ -904,6 +906,17 @@ function renderBacklog() {
904
906
  <p class="hint bl-foot"><label><input type="checkbox" data-bl="completed" ${showCompleted ? 'checked' : ''}/> Show done and cancelled</label></p>`;
905
907
  }
906
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
+
907
920
  function itemRow(it) {
908
921
  const a = it.agent;
909
922
  const st = it.status === 'active' && a?.attention ? 'attention' : it.status;
@@ -919,10 +932,11 @@ function itemRow(it) {
919
932
  ${it.base ? `<span>⎇ from ${esc(it.base)}</span>` : ''}
920
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>`}
921
934
  </div>
935
+ ${autopilotLine(it)}
922
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>` : ''}
923
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>
924
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>` : ''}
925
- <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>
926
940
  </li>`;
927
941
  }
928
942
 
@@ -942,6 +956,12 @@ function showTab(tab) {
942
956
  }
943
957
  for (const b of document.querySelectorAll('.side-tab')) b.onclick = () => showTab(b.dataset.tab);
944
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
+ });
945
965
  $('#backlog-view').addEventListener('click', async (e) => {
946
966
  const el = e.target.closest('[data-bl]');
947
967
  if (!el) return;
@@ -989,6 +1009,8 @@ $('#backlog-view').addEventListener('click', async (e) => {
989
1009
  return blApi('POST', `/backlog/${id}/split`, { parts: it.aiSplit.parts });
990
1010
  case 'keep-whole':
991
1011
  return blApi('POST', `/backlog/${id}/keep-whole`);
1012
+ case 'ship':
1013
+ return act(() => api('POST', `/backlog/${id}/ship`), 'Shipping: opening its pull request');
992
1014
  case 'open': {
993
1015
  const agentId = it?.agent?.id;
994
1016
  if (!agentId) return;
@@ -1014,7 +1036,7 @@ function flags(a) {
1014
1036
  }
1015
1037
 
1016
1038
  const TRIAGED = { now: 'sent (AI: urgent)', digest: 'held for the digest', skip: 'dropped by the AI judge' };
1017
- const NEED_ORDER = { approval: 0, prompt: 0, waiting: 1, plan: 1, question: 1, team: 1, rule: 2, 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 };
1018
1040
  /** What an agent needs from the user. `both`: overlaps on both agents (detail), else only the one that should rebase (inbox). */
1019
1041
  function needsFor(a, { both = false } = {}) {
1020
1042
  const items = [];
@@ -1044,12 +1066,22 @@ const repoLabel = (k) => String(k).replace(/^dir:/, '').split('/').filter(Boolea
1044
1066
  function ruleNeeds() {
1045
1067
  return rulesState.suggestions.map((r) => ({ a: { id: 'rules', name: repoLabel(r.repo), priority: 2 }, kind: 'rule', key: `rule:${r.repo}:${r.rule}`, r }));
1046
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
+ }
1047
1079
  function inboxItems() {
1048
- return [...approvalNeeds(), ...[...agents.values()].flatMap((a) => needsFor(a)), ...teamNeeds(), ...ruleNeeds()]
1080
+ return [...approvalNeeds(), ...[...agents.values()].flatMap((a) => needsFor(a)), ...teamNeeds(), ...ruleNeeds(), ...autopilotNeeds(), ...repeatNeeds()]
1049
1081
  .sort((x, y) => NEED_ORDER[x.kind] - NEED_ORDER[y.kind] || (x.a.priority ?? 2) - (y.a.priority ?? 2));
1050
1082
  }
1051
1083
 
1052
- const NEED_LABEL = { 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' };
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' };
1053
1085
  /** Why the AI judge thinks so, as a small tag. */
1054
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>`;
1055
1087
  /** What a permission prompt asks about: the command, file or tool. */
@@ -1081,6 +1113,8 @@ function needCard(it, { inDetail = false } = {}) {
1081
1113
  ? `<a href="#" class="need-agent" data-ib="built-open">⚙ ${esc(a.name)}</a> <span class="hint">${esc(it.r.host)}</span>`
1082
1114
  : it.kind === 'rule'
1083
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>`
1084
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>` : ''}`;
1085
1119
  const head = `<div class="need-head"><span class="need-kind ${it.kind}">${NEED_LABEL[it.kind]}</span>${who}</div>`;
1086
1120
  const draft = (k, fallback = '') => esc(needDrafts.get(`${it.key}:${k}`) ?? fallback);
@@ -1090,6 +1124,16 @@ function needCard(it, { inDetail = false } = {}) {
1090
1124
  body = `<p class="need-title">Wants to run <code>${esc(p.tool)}</code> <span class="hint">asked ${ago(p.at)}</span></p>
1091
1125
  <pre class="need-input">${esc(JSON.stringify(p.input, null, 2).slice(0, 1500))}</pre>
1092
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>`;
1093
1137
  } else if (it.kind === 'rule') {
1094
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>
1095
1139
  <p class="hint">Agents in ${esc(a.name)} won't ask about it again. Remove it any time in Settings → Approval rules.</p>
@@ -1246,6 +1290,26 @@ async function onNeed(e) {
1246
1290
  const ids = card.dataset.agents.split(',');
1247
1291
  return act(() => Promise.all(ids.map((x) => api('POST', `/agents/${x}/prompt-key`, { key: '1' }))), `Allowed for ${ids.length} agents`);
1248
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
+ }
1249
1313
  case 'rule-accept':
1250
1314
  case 'rule-dismiss': {
1251
1315
  const [, repo, rule] = key.match(/^rule:(.+?):((?:Bash|WebFetch|mcp__).*)$/) ?? [];
@@ -3685,6 +3749,7 @@ function handleEvent(msg) {
3685
3749
  for (const t of msg.teams ?? []) teams.set(t.id, t);
3686
3750
  builtReports = msg.built ?? [];
3687
3751
  if (msg.rules) rulesState = msg.rules;
3752
+ if (msg.repeats) repeatsState = msg.repeats;
3688
3753
  builtUi.render();
3689
3754
  msg.agents.forEach(watchDocs);
3690
3755
  if (selectedId && !agents.has(selectedId)) selectedId = null;
@@ -3711,6 +3776,8 @@ function handleEvent(msg) {
3711
3776
  teams.delete(msg.id);
3712
3777
  } else if (msg.t === 'rules') {
3713
3778
  rulesState = msg.rules;
3779
+ } else if (msg.t === 'repeats') {
3780
+ repeatsState = msg.repeats;
3714
3781
  } else if (msg.t === 'built') {
3715
3782
  builtReports = msg.reports;
3716
3783
  builtUi.render();
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); }
@@ -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;
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'));
@@ -1244,15 +1254,18 @@ export async function main(argv) {
1244
1254
  const items = v.items.filter((i) => all || !['done', 'cancelled'].includes(i.status));
1245
1255
  console.log(`${v.autoRun ? green('running') : yellow('paused')} ${dim(`${v.defaultLimit} per project by default, ${v.maxActive} overall`)}`);
1246
1256
  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]));
1257
+ const proj = Object.fromEntries(v.projects.map((p) => [p.key, p]));
1248
1258
  let last = null;
1249
1259
  for (const it of items) {
1250
1260
  if (it.projectKey !== last) {
1251
- console.log(`\n${bold(it.projectName)} ${dim(`(${lim[it.projectKey] ?? v.defaultLimit} at a time)`)}`);
1261
+ const p = proj[it.projectKey];
1262
+ const ap = p?.autopilot && p.autopilot !== 'off' ? `, autopilot: ${p.autopilot === 'merge' ? 'merge' : 'open PRs'}` : '';
1263
+ console.log(`\n${bold(it.projectName)} ${dim(`(${p?.limit ?? v.defaultLimit} at a time${ap})`)}`);
1252
1264
  last = it.projectKey;
1253
1265
  }
1254
1266
  const agent = it.agent ? dim(` → ${it.agent.name} (${it.agent.id})${it.agent.briefStatus ? ` brief: ${it.agent.briefStatus}` : ''}`) : '';
1255
1267
  console.log(` ${it.id} P${it.priority} ${(ST[it.status] ?? it.status).padEnd(18)} ${truncate(it.title, 60)}${agent}`);
1268
+ 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
1269
  }
1257
1270
  };
1258
1271
  bl.command('ls')
@@ -1340,6 +1353,15 @@ export async function main(argv) {
1340
1353
  await daemon();
1341
1354
  printBacklog(await api('POST', '/backlog/settings', { project, limit: Number(n) }));
1342
1355
  });
1356
+ bl.command('autopilot')
1357
+ .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)")
1358
+ .argument('<project>', 'project name as shown in "backlog ls"')
1359
+ .argument('<level>', 'off, pr or merge')
1360
+ .action(async (project, level) => {
1361
+ await daemon();
1362
+ printBacklog(await api('POST', '/backlog/settings', { project, autopilot: level }));
1363
+ });
1364
+ itemCmd('ship', 'Go ahead with an item autopilot held: open its pull request (and merge, at the merge level)', 'ship');
1343
1365
  bl.command('pause')
1344
1366
  .description('Stop starting new items (active ones continue)')
1345
1367
  .action(async () => {
package/src/manager.js CHANGED
@@ -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
@@ -2875,7 +2876,9 @@ export class AgentManager extends EventEmitter {
2875
2876
  }
2876
2877
  // Commands you keep allowing in a repo: offer to always allow them there.
2877
2878
  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 };
2879
+ // Others' items (autopilot's held backlog work).
2880
+ for (const source of this.inboxSources) items.push(...source());
2881
+ const order = { prompt: 0, waiting: 1, question: 1, rule: 2, review: 2, autopilot: 2, stuck: 3, conflict: 4, repeat: 5 };
2879
2882
  return items.sort((a, b) => order[a.kind] - order[b.kind] || a.priority - b.priority);
2880
2883
  }
2881
2884
 
package/src/repeats.js ADDED
@@ -0,0 +1,155 @@
1
+ // Requests you keep making: the asks you type (new agents and backlog items, not ones a playbook,
2
+ // trigger or the assistant started) are remembered, the last 500 in ~/.agentmanager/asks.json. When
3
+ // 3 or more read alike (the same words once numbers, quoted names, URLs, issue keys and filler are
4
+ // set aside), the inbox offers to save them as a playbook: the words they share become the request,
5
+ // the part that changes a {{what}} placeholder, and the projects they ran in its projects.
6
+ import crypto from 'node:crypto';
7
+ import { EventEmitter } from 'node:events';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { paths } from './config.js';
11
+
12
+ export const SUGGEST_AFTER = 3;
13
+ const KEEP = 500;
14
+ const ALIKE = 0.5; // share of distinct words two asks must have in common
15
+ const STOP = new Set('a an and are as at be by do does for from get have in into is it its make me my of on or our please should so that the their them then this to up use we with you your'.split(' '));
16
+ const err = (status, message) => Object.assign(new Error(message), { status });
17
+
18
+ /** The words that say what an ask is about, without the parts that change from one to the next. */
19
+ export function words(text) {
20
+ const w = String(text ?? '')
21
+ .toLowerCase()
22
+ .replace(/https?:\/\/\S+/g, ' ')
23
+ .replace(/(^|\s)["'`“‘][^"'`”’\n]{1,80}["'`”’](?=[\s.,;:!?)]|$)/g, ' ')
24
+ .replace(/\b[a-z][a-z0-9]+-\d+\b/g, ' ')
25
+ .replace(/#\d+|\b\d[\w.]*/g, ' ')
26
+ .split(/[^a-z0-9_]+/)
27
+ .filter((x) => x.length > 1 && !STOP.has(x));
28
+ return [...new Set(w)];
29
+ }
30
+
31
+ export function alike(a, b) {
32
+ if (!a.length || !b.length) return 0;
33
+ const sb = new Set(b);
34
+ const both = a.filter((x) => sb.has(x)).length;
35
+ return both / (a.length + b.length - both);
36
+ }
37
+
38
+ /** Groups of asks that read alike, newest first; each group's seed is its newest ask. */
39
+ export function groups(asks, { threshold = ALIKE } = {}) {
40
+ const out = [];
41
+ // Newest first; asks recorded together keep their order.
42
+ const order = asks.map((a, i) => [a, i]).sort(([x, i], [y, j]) => y.at.localeCompare(x.at) || j - i);
43
+ for (const [ask] of order) {
44
+ const w = words(ask.text);
45
+ if (w.length < 2) continue;
46
+ const g = out.find((x) => alike(x.words, w) >= threshold);
47
+ if (g) g.asks.push(ask);
48
+ else out.push({ words: w, asks: [ask] });
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * A playbook request from asks that read alike: the words they all start and end with, around a
55
+ * {{what}} for the part that differs ("Upgrade react to the latest" / "Upgrade vite to the latest" →
56
+ * "Upgrade {{what}} to the latest"). When they share too little, the newest ask as it was written.
57
+ */
58
+ export function template(texts) {
59
+ const split = texts.map((t) => String(t).trim().split(/\s+/));
60
+ if (texts.every((t) => t.trim() === texts[0].trim())) return texts[0].trim();
61
+ const shortest = Math.min(...split.map((s) => s.length));
62
+ const same = (get) => split.every((s) => get(s) === get(split[0]));
63
+ let pre = 0;
64
+ while (pre < shortest && same((s) => s[pre].toLowerCase())) pre++;
65
+ let suf = 0;
66
+ while (suf < shortest - pre && same((s) => s[s.length - 1 - suf].toLowerCase())) suf++;
67
+ if (pre + suf < Math.max(2, Math.ceil(shortest / 2))) return texts[0].trim();
68
+ const head = split[0].slice(0, pre).join(' ');
69
+ const tail = split[0].slice(split[0].length - suf).join(' ');
70
+ return [head, '{{what}}', tail].filter(Boolean).join(' ');
71
+ }
72
+
73
+ /** A short name for the playbook, from its request. */
74
+ export function nameFor(intent) {
75
+ const plain = intent.replace(/\{\{\w+\}\}/g, ' ').replace(/\s+/g, ' ').trim();
76
+ const w = plain.split(' ').slice(0, 6);
77
+ while (w.length > 1 && STOP.has(w[w.length - 1].toLowerCase())) w.pop();
78
+ const name = w.join(' ');
79
+ return (name.charAt(0).toUpperCase() + name.slice(1)).replace(/[.,:;]+$/, '').slice(0, 60) || 'Repeated request';
80
+ }
81
+
82
+ export class Repeats extends EventEmitter {
83
+ constructor({ file = path.join(paths.home, 'asks.json'), suggestAfter = SUGGEST_AFTER, enabled = true, playbooks = () => [], projects = () => ({}) } = {}) {
84
+ super();
85
+ Object.assign(this, { file, suggestAfter, enabled, playbooks, projects });
86
+ try {
87
+ this.data = JSON.parse(fs.readFileSync(file, 'utf8'));
88
+ } catch {
89
+ this.data = {};
90
+ }
91
+ this.data.asks ??= [];
92
+ this.data.dismissed ??= [];
93
+ }
94
+
95
+ save() {
96
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
97
+ fs.writeFileSync(this.file, JSON.stringify(this.data, null, 2));
98
+ this.changed();
99
+ }
100
+
101
+ changed() {
102
+ this.emit('change', this.view());
103
+ }
104
+
105
+ /** Asks the person typed: [{ text, projectKey }]. */
106
+ record(asks) {
107
+ const at = new Date().toISOString();
108
+ const list = asks.map((a) => ({ text: String(a.text ?? '').trim().slice(0, 2000), projectKey: a.projectKey ?? null, at })).filter((a) => a.text);
109
+ if (!list.length) return;
110
+ this.data.asks = [...this.data.asks, ...list].slice(-KEEP);
111
+ this.save();
112
+ }
113
+
114
+ /** Requests asked for at least suggestAfter times, not already a playbook and not dismissed. */
115
+ suggestions() {
116
+ if (!this.enabled) return [];
117
+ const books = this.playbooks().map((p) => words(p.intent));
118
+ const projects = this.projects();
119
+ const out = [];
120
+ for (const g of groups(this.data.asks)) {
121
+ if (g.asks.length < this.suggestAfter) continue;
122
+ if (books.some((b) => alike(b, g.words) >= ALIKE)) continue;
123
+ if (this.data.dismissed.some((d) => alike(d, g.words) >= ALIKE)) continue;
124
+ const intent = template(g.asks.map((a) => a.text));
125
+ const names = [...new Set(g.asks.map((a) => projects[a.projectKey]).filter(Boolean))];
126
+ out.push({
127
+ key: crypto.createHash('sha1').update([...g.words].sort().join(' ')).digest('hex').slice(0, 12),
128
+ name: nameFor(intent),
129
+ intent,
130
+ projects: names,
131
+ count: g.asks.length,
132
+ examples: [...new Set(g.asks.map((a) => a.text))].slice(0, 3),
133
+ lastAt: g.asks[0].at,
134
+ });
135
+ }
136
+ return out;
137
+ }
138
+
139
+ /** "Not now": stop offering this one (asks like it keep counting, but it isn't offered again). */
140
+ dismiss(key) {
141
+ const g = groups(this.data.asks).find((x) => crypto.createHash('sha1').update([...x.words].sort().join(' ')).digest('hex').slice(0, 12) === key);
142
+ if (!g) throw err(404, 'No such suggestion');
143
+ this.data.dismissed = [...this.data.dismissed, g.words].slice(-100);
144
+ this.save();
145
+ return this.view();
146
+ }
147
+
148
+ view() {
149
+ return { suggestions: this.suggestions(), suggestAfter: this.suggestAfter };
150
+ }
151
+
152
+ inbox() {
153
+ return this.suggestions().map((s) => ({ ...s, kind: 'repeat', key: `repeat:${s.key}`, title: `Save "${s.name}" as a playbook?`, priority: 3 }));
154
+ }
155
+ }
package/src/review.js CHANGED
@@ -9,6 +9,18 @@ import { ghBin } from './tracker.js';
9
9
  const err = (status, message) => Object.assign(new Error(message), { status });
10
10
  const MAX_COMMENTS = 100;
11
11
 
12
+ /** A git command that writes the index, retried while another git process (the app's own status refresh) holds its lock. */
13
+ async function locked(args, opts, tries = 5) {
14
+ for (let i = 1; ; i++) {
15
+ try {
16
+ return await git.run('git', args, opts);
17
+ } catch (e) {
18
+ if (i >= tries || !/index\.lock|another git process/i.test(e.message)) throw e;
19
+ await new Promise((r) => setTimeout(r, 300 * i));
20
+ }
21
+ }
22
+ }
23
+
12
24
  /** "origin/main @ 1a2b3c4d5" -> { ref: 'origin/main', sha: '1a2b3c4d5' } */
13
25
  export function parseBaseRef(baseRef) {
14
26
  const m = /^(.*) @ ([0-9a-f]{4,40})$/.exec(baseRef ?? '');
@@ -190,8 +202,8 @@ export class Review {
190
202
  const opts = { cwd: agent.workdir, timeout: 60_000 };
191
203
  const status = await git.run('git', ['status', '--porcelain'], opts);
192
204
  if (!status.trim()) throw err(409, 'Nothing to commit');
193
- await git.run('git', ['add', '-A'], opts);
194
- await git.run('git', ['commit', '-m', message], opts);
205
+ await locked(['add', '-A'], opts);
206
+ await locked(['commit', '-m', message], opts);
195
207
  const sha = await git.run('git', ['rev-parse', '--short', 'HEAD'], opts);
196
208
  this.manager.plugins.emit('agent.commit', { agent: this.manager.auditView(agent), message, sha });
197
209
  this.manager.refresh(agent.id).catch(() => {});
package/src/server.js CHANGED
@@ -9,7 +9,7 @@ import { spawn } from 'node:child_process';
9
9
  import express from 'express';
10
10
  import { WebSocketServer } from 'ws';
11
11
  import { ensureHome, HOST, loadConfig, paths, PORT, saveConfig } from './config.js';
12
- import { AgentManager, MACHINE, shellNumber } from './manager.js';
12
+ import { AgentManager, MACHINE, projectOf, shellNumber } from './manager.js';
13
13
  import { Backlog } from './backlog.js';
14
14
  import { Races } from './races.js';
15
15
  import { Review } from './review.js';
@@ -17,9 +17,11 @@ import { Teams } from './teams.js';
17
17
  import { Assistant } from './assistant.js';
18
18
  import { Cleanup } from './cleanup.js';
19
19
  import { Landing } from './landing.js';
20
+ import { Autopilot } from './autopilot.js';
20
21
  import { Budgets } from './budgets.js';
21
22
  import { stats } from './stats.js';
22
23
  import { Playbooks } from './playbooks.js';
24
+ import { Repeats } from './repeats.js';
23
25
  import { Updates } from './update.js';
24
26
  import { identity } from './identity.js';
25
27
  import { Studio } from './studio.js';
@@ -90,6 +92,9 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
90
92
  const teams = new Teams({ manager, store, config });
91
93
  const cleanup = new Cleanup({ manager, store, config, teams });
92
94
  const landing = new Landing({ manager, store, config });
95
+ // Backlog work the scorecard calls ready goes on to a pull request (and merged) by itself.
96
+ const autopilot = new Autopilot({ manager, store, backlog, review, landing, config });
97
+ manager.inboxSources.push(() => autopilot.inbox());
93
98
  const assistant = new Assistant({ manager, store, config });
94
99
  // Alerts link to the phone address when remote access is on, else the local UI.
95
100
  const notifier = new Notifier({
@@ -104,6 +109,14 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
104
109
  const triggers = new Triggers({ manager, store, config, notifier });
105
110
  const budgets = new Budgets({ manager, store, config, notifier });
106
111
  const playbooks = new Playbooks({ manager, store });
112
+ // Requests you keep typing, offered as a playbook.
113
+ const repeats = new Repeats({
114
+ suggestAfter: config.repeats?.suggestAfter,
115
+ enabled: config.repeats?.enabled !== false,
116
+ playbooks: () => playbooks.list(),
117
+ projects: () => Object.fromEntries(Object.values(store.data.projects ?? {}).map((p) => [p.key, p.shortcut || p.name])),
118
+ });
119
+ manager.inboxSources.push(() => repeats.inbox());
107
120
  const memory = new Memory({ manager, config });
108
121
  const updates = new Updates({ config });
109
122
  let remoteServer = null;
@@ -230,6 +243,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
230
243
  // Agents the assistant's own session starts: its limits apply, and they are marked as its.
231
244
  const extra = assistant.admit(req.headers['x-agentmgr-agent'], list, opts);
232
245
  const created = manager.createMany(list, { ...opts, ...extra });
246
+ if (!extra.startedBy) repeats.record(created.map((a) => ({ text: a.intent, projectKey: projectOf(store.get(a.id) ?? a).key })));
233
247
  if (extra.startedBy) {
234
248
  for (const a of created) notifier.send('assistant', a, `was started by the assistant: ${a.intent.replace(/\s+/g, ' ').slice(0, 160)}`);
235
249
  }
@@ -443,7 +457,9 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
443
457
  '/backlog',
444
458
  wrap((req) => {
445
459
  const { items, item, ...opts } = req.body ?? {};
446
- return backlog.add(Array.isArray(items) ? items : [item], opts);
460
+ const added = backlog.add(Array.isArray(items) ? items : [item], opts);
461
+ repeats.record(added.map((it) => ({ text: it.title, projectKey: it.projectKey })));
462
+ return added;
447
463
  }),
448
464
  );
449
465
  // Everything waiting on the user: live prompts, agents' questions, stuck agents, overlaps.
@@ -658,9 +674,17 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
658
674
  };
659
675
  }));
660
676
  api.get('/playbooks', wrap(() => playbooks.list()));
661
- api.post('/playbooks', wrap((req) => playbooks.create(req.body ?? {})));
662
- api.put('/playbooks/:id', wrap((req) => playbooks.update(req.params.id, req.body ?? {})));
663
- api.delete('/playbooks/:id', wrap((req) => playbooks.remove(req.params.id)));
677
+ // Saving a playbook can answer a repeated-request suggestion.
678
+ const withRepeats = (fn) => (req) => {
679
+ const out = fn(req);
680
+ repeats.changed();
681
+ return out;
682
+ };
683
+ api.post('/playbooks', wrap(withRepeats((req) => playbooks.create(req.body ?? {}))));
684
+ api.put('/playbooks/:id', wrap(withRepeats((req) => playbooks.update(req.params.id, req.body ?? {}))));
685
+ api.delete('/playbooks/:id', wrap(withRepeats((req) => playbooks.remove(req.params.id))));
686
+ api.get('/repeats', wrap(() => repeats.view()));
687
+ api.post('/repeats/dismiss', wrap((req) => repeats.dismiss(String(req.body?.key ?? ''))));
664
688
  api.post('/playbooks/:id/run', wrap((req) => playbooks.run(req.params.id, { vars: req.body?.vars ?? {} })));
665
689
  api.get('/stats', wrap((req) => {
666
690
  const days = req.query.days === undefined ? 30 : Math.max(0, Math.min(3650, Math.floor(Number(req.query.days)) || 0));
@@ -713,6 +737,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
713
737
  api.post('/backlog/:id/move', wrap((req) => backlog.move(req.params.id, req.body?.where)));
714
738
  api.post('/backlog/:id/split', wrap((req) => backlog.split(req.params.id, req.body?.parts)));
715
739
  api.post('/backlog/:id/keep-whole', wrap((req) => backlog.keepWhole(req.params.id)));
740
+ api.post('/backlog/:id/ship', wrap((req) => autopilot.shipNow(req.params.id)));
716
741
  api.post(
717
742
  '/shutdown',
718
743
  wrap(() => {
@@ -923,6 +948,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
923
948
  manager.on('change', (agent) => broadcast({ t: 'agent', agent }));
924
949
  reports.on('change', () => broadcast({ t: 'built', reports: reports.list() }));
925
950
  manager.rules.on('change', (rules) => broadcast({ t: 'rules', rules }));
951
+ repeats.on('change', (view) => broadcast({ t: 'repeats', repeats: view }));
926
952
  manager.on('removed', (id) => {
927
953
  broadcast({ t: 'removed', id });
928
954
  live.closeAgent(id).catch(() => {});
@@ -959,7 +985,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
959
985
  const url = new URL(req.url, 'http://x');
960
986
  if (url.pathname === '/ws/events') {
961
987
  eventClients.add(ws);
962
- ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list(), built: reports.list(), rules: manager.rules.view() }));
988
+ ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list(), built: reports.list(), rules: manager.rules.view(), repeats: repeats.view() }));
963
989
  ws.on('close', () => eventClients.delete(ws));
964
990
  return;
965
991
  }
@@ -1040,6 +1066,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
1040
1066
  closing = true;
1041
1067
  cleanup.close();
1042
1068
  landing.close();
1069
+ autopilot.close();
1043
1070
  triggers.close();
1044
1071
  budgets.close();
1045
1072
  playbooks.close();