ai-agentmanager 1.9.0 β†’ 1.11.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/public/app.js CHANGED
@@ -7,6 +7,7 @@ import { openStream, post, REMOTE } from '/transport.js';
7
7
  import { attachBox } from '/attach.js';
8
8
  import { initSide } from '/side.js';
9
9
  import { addTools, download, htmlFile, initExport } from '/export.js';
10
+ import { badgeHtml as scoreBadge, scorecardHtml, scorecardSpeech } from '/scorecard.js';
10
11
  import { bindListen, listenHtml, play, speakable, now as speaking, stop as stopSpeaking } from '/speak.js';
11
12
  import { initReview } from '/review.js';
12
13
  import { initLand, prChips } from '/land.js';
@@ -123,7 +124,7 @@ $('#new-playbook').onclick = () => {
123
124
  playbooksUi.open({ intent: f.intents.value.trim(), projects: f.project.value.trim() ? [f.project.value.trim()] : [], model: f.model.value.trim(), permissionMode: f.permissionMode.value });
124
125
  };
125
126
  $('#triggers-open').onclick = () => triggersUi.open();
126
- const landUi = initLand({ api, esc, toast, agents, $, select: (id) => (showTab('agents'), select(id)), openReview: (id) => reviewUi.open(id), isOpen: () => sideTab === 'land' });
127
+ const landUi = initLand({ api, esc, toast, agents, $, scoreBadge, select: (id) => (showTab('agents'), select(id)), openReview: (id) => reviewUi.open(id), isOpen: () => sideTab === 'land' });
127
128
 
128
129
  function statusOf(a) {
129
130
  if (a.runtime.planning) return a.runtime.attention ? 'attention' : 'planning';
@@ -267,9 +268,9 @@ const INSIGHT_ICONS = { why: '🎯', what: 'πŸ“¦', how: 'πŸ› ', now: 'πŸ“' };
267
268
 
268
269
  // ------------------------------------------------------------------ stakeholder documents
269
270
 
270
- const DOCS = { analysis: 'Analysis', design: 'Design', flow: 'Flow', proposal: 'Proposal', humanai: 'Human vs AI', diagrams: 'Diagrams' };
271
+ const DOCS = { analysis: 'Analysis', design: 'Design', flow: 'Flow', proposal: 'Proposal', humanai: 'Human vs AI', diagrams: 'Diagrams', scorecard: 'Scorecard' };
271
272
  // Optional extras that "Write all" leaves out.
272
- const DOC_EXTRAS = new Set(['diagrams']);
273
+ const DOC_EXTRAS = new Set(['diagrams', 'scorecard']);
273
274
  const DOC_TITLES = { ...DOCS, plan: 'Plan', intent: 'Intent', spec: 'Spec' };
274
275
  // The agent's intent, spec and plan files (read from the daemon when opened; kept here by agent and kind).
275
276
  const SPEC_KINDS = { intent: 'Intent', spec: 'Spec', plan: 'Plan' };
@@ -350,7 +351,7 @@ function docButtons(a) {
350
351
  const d = a.docs?.[k];
351
352
  const stale = d?.markdown && a.brief?.updatedAt && d.briefUpdatedAt !== a.brief.updatedAt;
352
353
  return `<button class="small doc-btn ${d?.markdown ? 'has' : ''}" data-doc="${k}" ${d?.pending ? 'disabled' : ''}
353
- title="${k === 'diagrams' ? (d?.markdown ? `Open the diagrams${stale ? ' (the brief changed since)' : ''}` : 'Draw the problem and its solution for a quick grasp: architecture, design, data flow (animated) and the key interaction, diagrams only. Built from the documents already written and the agent\'s work') : d?.markdown ? `Open the ${label.toLowerCase()} document${stale ? ' (the brief changed since)' : ''}` : `Write a ${label.toLowerCase()} document for stakeholders`}">${d?.pending ? `${label}…` : label}${d?.markdown ? (stale ? ' β€’' : ' βœ“') : ''}</button>`;
354
+ title="${k === 'scorecard' ? (d?.markdown ? `Open the review scorecard: ${d.data?.overall ?? 'β€”'}/100` : 'Score the changes for review: intent, spec, scope, plan, tests and safety, with an overall readiness and where to look') : k === 'diagrams' ? (d?.markdown ? `Open the diagrams${stale ? ' (the brief changed since)' : ''}` : 'Draw the problem and its solution for a quick grasp: architecture, design, data flow (animated) and the key interaction, diagrams only. Built from the documents already written and the agent\'s work') : d?.markdown ? `Open the ${label.toLowerCase()} document${stale ? ' (the brief changed since)' : ''}` : `Write a ${label.toLowerCase()} document for stakeholders`}">${d?.pending ? `${label}…` : label}${d?.markdown ? (stale ? ' β€’' : ' βœ“') : ''}</button>`;
354
355
  })
355
356
  .join('')}</span>${specButtons(a)}`;
356
357
  }
@@ -388,7 +389,7 @@ function renderDocDialog() {
388
389
  else if (specKind && !d?.markdown) body.innerHTML = `<p class="hint">${docView.kind === 'intent' ? 'No intent file for this agent (agents created before 1.9 have none).' : `The agent hasn't written its ${label.toLowerCase()} yet. It writes ${docView.kind === 'spec' ? 'the spec before changing code' : 'the plan and keeps it current'}.`}</p>`;
389
390
  else if (d?.markdown) {
390
391
  if (body.dataset.rendered !== d.at) {
391
- body.innerHTML = d.data ? humanAiHtml(d) : docHtml(d.markdown);
392
+ body.innerHTML = docView.kind === 'scorecard' ? scorecardHtml(d, esc) : d.data ? humanAiHtml(d) : docHtml(d.markdown);
392
393
  body.dataset.rendered = d.at;
393
394
  renderDiagrams(body);
394
395
  }
@@ -399,7 +400,7 @@ function renderDocDialog() {
399
400
  for (const id of ['doc-copy', 'doc-download', 'doc-print']) $(`#${id}`).disabled = !d?.markdown;
400
401
  // Diagrams: the whole document as one HTML file with every diagram (each also has Copy / PNG / SVG).
401
402
  $('#doc-html').hidden = !/```mermaid/.test(d?.markdown ?? '');
402
- $('#doc-listen').innerHTML = d?.markdown && docView.kind !== 'diagrams' ? listenHtml(`doc:${docView.agentId}:${docView.kind}`, { title: 'Read the document aloud' }) : '';
403
+ $('#doc-listen').innerHTML = d?.markdown && docView.kind !== 'diagrams' ? listenHtml(docView.kind === 'scorecard' ? `score:${docView.agentId}` : `doc:${docView.agentId}:${docView.kind}`, { title: 'Read the document aloud' }) : '';
403
404
  $('#doc-print').hidden = plan || specKind;
404
405
  $('#doc-regen').hidden = plan || specKind;
405
406
  $('#doc-regen').disabled = Boolean(d?.pending);
@@ -538,6 +539,7 @@ bindListen((id) => {
538
539
  if (what === 'explain') parts = [speakable(a?.explanation?.markdown)];
539
540
  else if (what === 'doc') parts = [speakable(kind === 'explain' ? a?.explanation?.markdown : docOf(a, kind)?.markdown)];
540
541
  else if (what === 'walk') parts = reviewUi.listenParts();
542
+ else if (what === 'score') parts = a?.docs?.scorecard?.data ? scorecardSpeech(a.docs.scorecard) : null;
541
543
  else if (what === 'sidewalk') parts = sidePane?.listenParts();
542
544
  if (!parts?.length || !play(id, parts)) toast('Nothing to read aloud here (or this browser has no speech)');
543
545
  });
@@ -707,7 +709,7 @@ function agentItem(a) {
707
709
  const idle = !isActive(a);
708
710
  return `<li data-id="${a.id}" ${a.kind === 'general' || a.kind === 'assistant' ? '' : 'draggable="true" '}class="agent pr${a.priority ?? 2} ${a.id === selectedId ? 'selected' : ''} ${idle ? 'idle' : ''}">
709
711
  <div class="line1">${prioChip(a.priority ?? 2)}${a.results?.outcome === 'blocked' ? '<span class="outcome-mark" title="Blocked">β›”</span>' : a.results?.outcome === 'done' ? '<span class="outcome-mark" title="Done">βœ…</span>' : ''}<span class="name">${esc(a.name)}</span>${flags(a)}<span class="pill ${st}">${pillText(st)}</span></div>
710
- <div class="intent" title="${esc(describeTitle(a) ?? '')}">${a.kind === 'general' ? '<span class="tag">general</span> ' : a.kind === 'assistant' ? '<span class="tag assistant">assistant</span> ' : a.startedBy ? '<span class="tag assistant" title="Started by the assistant">πŸ€– by assistant</span> ' : a.trigger ? `<span class="tag assistant" title="Started from ${a.trigger.source === 'github' ? 'a labeled GitHub issue' : 'the webhook'}${a.trigger.url ? `: ${esc(a.trigger.url)}` : ''}">⚑ ${a.trigger.source === 'github' ? 'GitHub' : 'webhook'}</span> ` : a.playbook ? `<span class="tag assistant" title="Started by the playbook ${esc(a.playbook.name)}">β–Ά ${esc(a.playbook.name)}</span> ` : ''}${a.engine && a.engine !== 'claude' ? `<span class="tag engine">${esc(ENGINE_LABEL[a.engine] ?? a.engine)}</span> ` : ''}${esc(describe(a) || (a.kind === 'assistant' ? 'Finds new work and starts agents for it' : 'General session'))}</div>
712
+ <div class="intent" title="${esc(describeTitle(a) ?? '')}">${a.kind === 'general' ? '<span class="tag">general</span> ' : a.kind === 'assistant' ? '<span class="tag assistant">assistant</span> ' : a.startedBy ? '<span class="tag assistant" title="Started by the assistant">πŸ€– by assistant</span> ' : a.trigger ? `<span class="tag assistant" title="Started from ${a.trigger.source === 'github' ? 'a labeled GitHub issue' : 'the webhook'}${a.trigger.url ? `: ${esc(a.trigger.url)}` : ''}">⚑ ${a.trigger.source === 'github' ? 'GitHub' : 'webhook'}</span> ` : a.playbook ? `<span class="tag assistant" title="Started by the playbook ${esc(a.playbook.name)}">β–Ά ${esc(a.playbook.name)}</span> ` : ''}${a.engine && a.engine !== 'claude' ? `<span class="tag engine">${esc(ENGINE_LABEL[a.engine] ?? a.engine)}</span> ` : ''}${a.isolation === 'container' ? '<span class="tag engine" title="Runs in its own Docker container: it sees only its own folder">πŸ“¦ container</span> ' : ''}${esc(describe(a) || (a.kind === 'assistant' ? 'Finds new work and starts agents for it' : 'General session'))}</div>
711
713
  ${a.status === 'preparing' && a.progress ? `<div class="progress-line">${esc(progressText(a.progress))}</div>` : ''}
712
714
  <div class="meta">
713
715
  <span class="mono">${a.id}</span>
@@ -3269,6 +3271,7 @@ function openNewDialog(mode = 'now') {
3269
3271
  $('#new-error').hidden = true;
3270
3272
  dialog.querySelector(`input[name="when"][value="${mode}"]`).checked = true;
3271
3273
  restoreIntentSplit();
3274
+ loadIsolation();
3272
3275
  syncWhen();
3273
3276
  dialog.showModal();
3274
3277
  dialog.querySelector('textarea').focus();
@@ -3431,6 +3434,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3431
3434
  model: f.get('model') || undefined,
3432
3435
  engine: f.get('engine') || undefined,
3433
3436
  worktree: f.get('worktree') ? true : undefined,
3437
+ ...containerChoice(e.target),
3434
3438
  plan: Boolean(f.get('plan')),
3435
3439
  planReview: Boolean(f.get('planReview')),
3436
3440
  priority: Number(f.get('priority')),
@@ -3462,6 +3466,7 @@ $('#new-form').addEventListener('submit', async (e) => {
3462
3466
  plan: Boolean(f.get('plan')),
3463
3467
  planReview: Boolean(f.get('planReview')),
3464
3468
  start: Boolean(f.get('start')),
3469
+ ...containerChoice(e.target),
3465
3470
  });
3466
3471
  created.forEach((a) => agents.set(a.id, a));
3467
3472
  dialog.close();
@@ -4150,9 +4155,36 @@ const describe = (a) => a.synopsis?.text || a.intent;
4150
4155
  const describeTitle = (a) => (a.synopsis?.text ? `${a.synopsis.source === 'jira' ? `${a.synopsis.key} in Jira` : `${a.synopsis.key} (summary not fetched; your words after the link)`}. Request: ${a.intent}` : a.intent);
4151
4156
 
4152
4157
  const ENGINE_LABEL = { claude: 'Claude', codex: 'Codex', antigravity: 'Antigravity', muse: 'Muse Code' };
4158
+ // Containers: offered when Docker runs here; Claude Code and Codex only.
4159
+ let isolationInfo = null;
4160
+ const containerChoice = (form) => (isolationInfo?.available && !$('#container-field').hidden ? { isolation: form.container.checked ? 'container' : '' } : {});
4161
+ function syncContainerField() {
4162
+ const form = $('#new-form');
4163
+ const engine = form.engine?.value || 'claude';
4164
+ const ok = isolationInfo?.available && isolationInfo.engines.includes(engine);
4165
+ $('#container-field').hidden = !isolationInfo?.available;
4166
+ form.container.disabled = !ok || !isolationInfo.imageBuilt;
4167
+ if (!ok) form.container.checked = false;
4168
+ $('#container-hint').textContent = !ok ? '(Claude Code and Codex only)' : !isolationInfo.imageBuilt ? '(build the image first: ☰ β†’ Settings β†’ Containers)' : !isolationInfo.hasClaudeToken && engine === 'claude' ? '(add the Claude token in ☰ β†’ Settings β†’ Containers)' : '(Docker: it sees only its own folder)';
4169
+ }
4170
+ async function loadIsolation() {
4171
+ try {
4172
+ isolationInfo = await api('GET', '/isolation');
4173
+ } catch {
4174
+ isolationInfo = null;
4175
+ }
4176
+ const form = $('#new-form');
4177
+ form.container.checked = isolationInfo?.default === 'container';
4178
+ syncContainerField();
4179
+ }
4180
+ $('#new-form').engine?.addEventListener('change', syncContainerField);
4181
+
4153
4182
  async function loadEngines() {
4154
4183
  try {
4155
- const list = (await api('GET', '/engines')).filter((e) => e.installed && e.id !== 'claude');
4184
+ const all = await api('GET', '/engines');
4185
+ // Engine names for tags and tabs, ACP agents included.
4186
+ for (const e of all) ENGINE_LABEL[e.id] ??= e.label;
4187
+ const list = all.filter((e) => e.installed && e.id !== 'claude');
4156
4188
  // Something to know before choosing an engine (Muse Code in contributor mode, say).
4157
4189
  const notices = Object.fromEntries(list.filter((e) => e.notice).map((e) => [e.id, e.notice]));
4158
4190
  for (const [field, notice] of [['#engine-field', '#engine-notice'], ['#ask-engine-field', '#ask-engine-notice']]) {
package/public/index.html CHANGED
@@ -245,6 +245,10 @@
245
245
  <input type="checkbox" name="worktree" checked />
246
246
  Give each agent its own git worktree and branch <span class="hint">(for a git folder; always on when cloning a repo URL)</span>
247
247
  </label>
248
+ <label class="check" id="container-field" hidden title="The agent sees only its own folder, its git data, brief and uploads, not the rest of this computer. Claude Code and Codex">
249
+ <input type="checkbox" name="container" />
250
+ Run each agent in its own container <span class="hint" id="container-hint">(Docker: it sees only its own folder)</span>
251
+ </label>
248
252
  <label class="check">
249
253
  <input type="checkbox" name="plan" checked />
250
254
  Plan with Claude while the repo clones <span class="hint">(repo URLs only; the session moves into the repo when it's ready)</span>
@@ -722,6 +726,15 @@
722
726
  <label class="check"><input type="checkbox" name="startAtLogin" /> Start AI Agent Manager when I log in</label>
723
727
  <p class="hint">After a restart or a crash, the daemon comes back by itself and running agents resume; without it, it starts only when you run <code>agentmgr</code>. <span id="settings-service-state"></span></p>
724
728
  </fieldset>
729
+ <fieldset class="local-only" id="settings-containers">
730
+ <legend>Containers</legend>
731
+ <p class="hint">Agents in a container (Docker) see only their own folder, its git data, brief and uploads: not the rest of this computer. <span id="settings-containers-state"></span></p>
732
+ <div class="row">
733
+ <label>Claude token for containers <span class="hint">run <code>claude setup-token</code> in a terminal and paste it</span><input name="claudeToken" type="password" autocomplete="new-password" /></label>
734
+ <div class="containers-actions"><button type="button" class="small" id="containers-build">Build image</button></div>
735
+ </div>
736
+ <label class="check"><input type="checkbox" name="containerDefault" /> Run new agents in containers by default</label>
737
+ </fieldset>
725
738
  <fieldset class="local-only">
726
739
  <legend>Jira</legend>
727
740
  <p class="hint">For an agent created from a Jira link, its description shows the issue's summary. Public issues on Atlassian Cloud need nothing here; for your company's site, add it with your email and an <a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank" rel="noopener">API token</a>. The token is kept on this computer and sent only to that site.</p>
@@ -738,7 +751,7 @@
738
751
  </fieldset>
739
752
  <fieldset>
740
753
  <legend>More</legend>
741
- <p class="hint settings-links"><button type="button" class="link" data-open="cleanup-btn">Clean up old agents</button> Β· <button type="button" class="link" data-open="spend-btn">Spend and budgets</button> Β· <button type="button" class="link local-only" data-open="alerts-btn">Slack alerts</button> Β· <button type="button" class="link local-only" data-open="remote-btn">Remote access</button></p>
754
+ <p class="hint settings-links"><button type="button" class="link" data-open="cleanup-btn">Clean up old agents</button><button type="button" class="link" data-open="spend-btn">Spend and budgets</button><button type="button" class="link local-only" data-open="alerts-btn">Slack alerts</button><button type="button" class="link local-only" data-open="remote-btn">Remote access</button></p>
742
755
  </fieldset>
743
756
  <p id="settings-error" class="error" hidden></p>
744
757
  <div class="dialog-actions">
package/public/land.js CHANGED
@@ -25,7 +25,7 @@ export function prChips(pr, esc) {
25
25
  return out.join(' ');
26
26
  }
27
27
 
28
- export function initLand({ api, esc, toast, agents, $, select, openReview, isOpen }) {
28
+ export function initLand({ api, esc, toast, agents, $, scoreBadge = () => '', select, openReview, isOpen }) {
29
29
  const view = $('#land-view');
30
30
  let settings = null;
31
31
 
@@ -46,7 +46,7 @@ export function initLand({ api, esc, toast, agents, $, select, openReview, isOpe
46
46
  <div class="line1"><span class="prio p${a.priority ?? 2}">P${a.priority ?? 2}</span>
47
47
  <a class="name" href="${esc(pr.url)}" target="_blank" rel="noopener" title="${esc(pr.title ?? pr.url)}">#${esc(pr.number ?? '')} ${esc(pr.title ?? pr.url)}</a></div>
48
48
  <div class="line2"><button class="link" data-land="goto">${esc(a.name)}</button>${a.runtime?.busy ? ' <span class="hint">working</span>' : ''}</div>
49
- <div class="land-chips">${prChips(pr, esc)}</div>
49
+ <div class="land-chips">${scoreBadge(a.docs?.scorecard, esc)} ${prChips(pr, esc)}</div>
50
50
  <div class="bl-actions">
51
51
  <button class="small" data-land="review" title="Its diff, line comments for the agent, commit and push">Review</button>
52
52
  ${land === 'behind' || pr.mergeable === 'CONFLICTING' ? '<button class="small" data-land="update" title="Merge the base branch in here (when the agent is idle and its work is committed) and push; otherwise, or on conflicts, ask the agent">Update</button>' : ''}
package/public/menu.js CHANGED
@@ -55,6 +55,36 @@ export function initMenu({ api, esc, toast, $ }) {
55
55
  const form = $('#settings-form');
56
56
  let service = null; // the login service as the dialog opened
57
57
  let jira = null; // the Jira settings as the dialog opened
58
+ let containers = null; // Docker and the agent image as the dialog opened
59
+ function showContainers() {
60
+ const c = containers;
61
+ $('#settings-containers').hidden = !c;
62
+ if (!c) return;
63
+ $('#settings-containers-state').textContent = !c.available
64
+ ? `Docker isn't running here${c.error ? ` (${c.error})` : ''}.`
65
+ : `Docker ${c.version} Β· image ${c.image}: ${c.building ? 'building…' : c.imageBuilt ? 'built' : 'not built yet'}${c.buildError ? ` (last build failed: ${c.buildError})` : ''} Β· Claude token ${c.hasClaudeToken ? 'set' : 'not set'}.`;
66
+ $('#containers-build').disabled = !c.available || c.building;
67
+ $('#containers-build').textContent = c.building ? 'Building…' : c.imageBuilt ? 'Rebuild image' : 'Build image';
68
+ form.containerDefault.disabled = !c.available;
69
+ }
70
+ $('#containers-build').onclick = async () => {
71
+ try {
72
+ containers = await api('POST', '/isolation/build');
73
+ showContainers();
74
+ toast('Building the agent image; it takes a few minutes the first time', 6000);
75
+ // Watch it finish.
76
+ const t = setInterval(async () => {
77
+ containers = await api('GET', '/isolation').catch(() => containers);
78
+ if (dialog.open) showContainers();
79
+ if (!containers?.building) {
80
+ clearInterval(t);
81
+ toast(containers?.imageBuilt && !containers.buildError ? 'Agent image built: agents can run in containers' : `Image build failed: ${containers?.buildError ?? 'see the daemon log'}`, 8000);
82
+ }
83
+ }, 5000);
84
+ } catch (err) {
85
+ toast(`Error: ${err.message}`, 6000);
86
+ }
87
+ };
58
88
  $('#settings-btn').onclick = async () => {
59
89
  $('#settings-error').hidden = true;
60
90
  try {
@@ -70,6 +100,11 @@ export function initMenu({ api, esc, toast, $ }) {
70
100
  form.jiraEmail.value = jira?.email ?? '';
71
101
  form.jiraToken.value = '';
72
102
  form.jiraToken.placeholder = jira?.hasToken ? 'saved (type to replace)' : '';
103
+ containers = await api('GET', '/isolation').catch(() => null);
104
+ showContainers();
105
+ form.claudeToken.value = '';
106
+ form.claudeToken.placeholder = containers?.hasClaudeToken ? 'saved (type to replace)' : 'from claude setup-token';
107
+ form.containerDefault.checked = containers?.default === 'container';
73
108
  service = await api('GET', '/service').catch(() => null);
74
109
  $('#settings-service').hidden = !service?.supported;
75
110
  form.startAtLogin.checked = Boolean(service?.installed);
@@ -96,6 +131,10 @@ export function initMenu({ api, esc, toast, $ }) {
96
131
  await api('PUT', '/jira', { site: form.jiraSite.value.trim(), email: form.jiraEmail.value.trim(), token: form.jiraToken.value.trim() || undefined });
97
132
  toast('Jira saved; summaries for agents from Jira links are being fetched', 4000);
98
133
  }
134
+ if (!remote() && containers?.available) {
135
+ if (form.claudeToken.value.trim()) await api('PUT', '/isolation/token', { token: form.claudeToken.value.trim() });
136
+ if (form.containerDefault.checked !== (containers.default === 'container')) await api('PUT', '/isolation/default', { container: form.containerDefault.checked });
137
+ }
99
138
  // Only when it changed: installing touches launchd / systemd.
100
139
  if (!remote() && service?.supported && form.startAtLogin.checked !== Boolean(service.installed)) {
101
140
  const s = await api('PUT', '/service', { enabled: form.startAtLogin.checked });
package/public/review.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // With a walkthrough, each hunk shows its explanation and β—€ / β–Ά step through them in reading order.
4
4
  import { cardHtml, explainButton, listenParts as walkParts, stepOf, walkthroughOf } from './walkthrough.js';
5
5
  import { listenHtml, now as speaking, stop as stopSpeaking } from './speak.js';
6
+ import { scorecardHtml, scorecardOf } from './scorecard.js';
6
7
 
7
8
  /** Unified diff -> [{ path, status, binary, hunks: [{ head, lines: [{ t, old, new, text }] }] }] */
8
9
  export function parseDiff(text) {
@@ -138,7 +139,7 @@ export function initReview({ api, esc, toast, agents, $ }) {
138
139
  for (const h of f.hunks) {
139
140
  const i = w.doc ? stepOf(w.doc, f.path, h.head) : -1;
140
141
  if (i >= 0) rows.push(`<tr class="rv-explain ${st.step === i ? 'current' : ''}" id="rv-step-${i}"><td colspan="3">${cardHtml(w.doc.data.changes[i], i, w.doc.data.changes.length, esc)}</td></tr>`);
141
- rows.push(`<tr class="rv-hunk"><td colspan="3">${esc(h.head)}</td></tr>`);
142
+ rows.push(`<tr class="rv-hunk" data-rv-hunk="${esc(`${f.path}\t${h.head}`)}"><td colspan="3">${esc(h.head)}</td></tr>`);
142
143
  for (const l of h.lines) {
143
144
  const side = l.t === 'del' ? 'old' : 'new';
144
145
  const line = side === 'old' ? l.old : l.new;
@@ -157,6 +158,32 @@ export function initReview({ api, esc, toast, agents, $ }) {
157
158
  return `<div class="rv-file" id="rv-${encodeURIComponent(f.path)}">${head}<table class="rv-table">${rows.join('')}</table></div>`;
158
159
  }
159
160
 
161
+ /** The review scorecard at the top: scores, verdict and where to look, or the button to score. */
162
+ function scorePanel() {
163
+ if (!st.data?.files.length) return '';
164
+ const sc = scorecardOf(agents.get(st.id), st.data.diffHash);
165
+ const openPr = st.data.prs.find((p) => p.state === 'OPEN');
166
+ const run = `<button type="button" class="small ${sc.doc && !sc.stale ? 'ghost' : 'primary'}" data-sc-run ${sc.pending ? 'disabled' : ''} title="Score the changes against the intent, spec, plan and tests, with where to look (a separate one-off call)">${sc.pending ? 'Scoring…' : sc.doc ? (sc.stale ? 'Score again (changed)' : 'Score again') : 'πŸ“Š Score changes'}</button>`;
167
+ if (!sc.doc) {
168
+ return `<div class="rv-score-empty">${run}<span class="hint">${sc.error && !sc.pending ? `<span class="error">Couldn't score: ${esc(sc.error)}</span>` : 'Scores against the intent, spec, scope, plan, tests and safety, with an overall readiness and the few places to read.'}</span></div>`;
169
+ }
170
+ const post = openPr ? `<button type="button" class="small ghost" data-sc-post title="Post the scorecard on the pull request as a comment (posting again updates it)">${sc.doc.postedTo === openPr.url ? 'Update on PR' : 'Post to PR'}</button>` : '';
171
+ return `<div class="rv-score">${scorecardHtml(sc.doc, esc, { stale: sc.stale, actions: `${listenHtml(`score:${st.id}`, { title: 'Hear the scorecard' })}${post}${run}` })}</div>`;
172
+ }
173
+
174
+ /** Open the file of a hunk (from "where to look") and scroll to it. */
175
+ function gotoHunk(key) {
176
+ const [file] = key.split('\t');
177
+ st.collapsed.delete(file);
178
+ keepInputs();
179
+ render();
180
+ const row = [...body.querySelectorAll('[data-rv-hunk]')].find((r) => r.dataset.rvHunk === key);
181
+ if (!row) return toast('That part of the change has moved since it was scored');
182
+ row.scrollIntoView({ block: 'center', behavior: 'smooth' });
183
+ row.classList.add('flash');
184
+ setTimeout(() => row.classList.remove('flash'), 1600);
185
+ }
186
+
160
187
  /** Explain changes, then β—€ n of N β–Ά once there is a walkthrough. */
161
188
  function walkNav() {
162
189
  const w = walk();
@@ -221,6 +248,7 @@ export function initReview({ api, esc, toast, agents, $ }) {
221
248
  ${d.files.length ? walkNav() : ''}
222
249
  <button type="button" class="small ghost" data-rv-reload>${st.loading ? 'Loading…' : 'Reload'}</button>
223
250
  </div>
251
+ ${scorePanel()}
224
252
  ${walkNote()}
225
253
  ${d.busy ? '<p class="hint rv-warn">The agent is working, so the changes may still move. Reload when it is idle.</p>' : ''}
226
254
  ${d.truncated ? '<p class="hint rv-warn">The diff is large and was cut short; open a shell for the rest.</p>' : ''}
@@ -289,6 +317,29 @@ export function initReview({ api, esc, toast, agents, $ }) {
289
317
  if (t.closest('[data-rv-reload]')) return keepInputs(), reload();
290
318
  const stepBtn = t.closest('[data-rv-step]');
291
319
  if (stepBtn) return step(Number(stepBtn.dataset.rvStep));
320
+ const look = t.closest('[data-sc-look]');
321
+ if (look) return gotoHunk(look.dataset.scLook);
322
+ if (t.closest('[data-sc-run]')) {
323
+ keepInputs();
324
+ try {
325
+ agents.set(st.id, await api('POST', `/agents/${st.id}/docs/scorecard?background=1`));
326
+ render();
327
+ } catch (err) {
328
+ toast(`Error: ${err.message}`, 5000);
329
+ }
330
+ return;
331
+ }
332
+ if (t.closest('[data-sc-post]')) {
333
+ t.disabled = true;
334
+ try {
335
+ const r = await api('POST', `/agents/${st.id}/scorecard/post`);
336
+ toast(r.edited ? 'Scorecard updated on the pull request' : 'Scorecard posted on the pull request', 4000, { label: 'Open', run: () => window.open(r.url, '_blank', 'noopener') });
337
+ } catch (err) {
338
+ toast(`Error: ${err.message}`, 6000);
339
+ }
340
+ t.disabled = false;
341
+ return;
342
+ }
292
343
  if (t.closest('[data-rv-explain]')) {
293
344
  keepInputs();
294
345
  try {
@@ -442,7 +493,8 @@ export function initReview({ api, esc, toast, agents, $ }) {
442
493
  function agentChanged(a) {
443
494
  if (!dlg.open || a.id !== st.id || !st.data) return;
444
495
  const w = a.docs?.walkthrough;
445
- const sig = `${w?.at}|${w?.pending}|${w?.error}`;
496
+ const sc = a.docs?.scorecard;
497
+ const sig = `${w?.at}|${w?.pending}|${w?.error}|${sc?.at}|${sc?.pending}|${sc?.error}|${sc?.postedTo}`;
446
498
  if (sig === st.walkSig) return;
447
499
  st.walkSig = sig;
448
500
  keepInputs();
@@ -0,0 +1,68 @@
1
+ // The review scorecard on the page: an overall readiness, a bar per score with why, the acceptance
2
+ // criteria, and where to look. Shared by Review changes, the Scorecard document and Land's badges.
3
+
4
+ const DIMENSIONS = [
5
+ ['intent', 'Intent'],
6
+ ['spec', 'Spec'],
7
+ ['tests', 'Tests'],
8
+ ['safety', 'Safety'],
9
+ ['scope', 'Scope'],
10
+ ['plan', 'Plan'],
11
+ ];
12
+ export const RECOMMENDATION = { ready: 'Ready to approve', look: 'Look at these first', not_ready: 'Not ready' };
13
+ const tone = (n) => (n == null ? '' : n >= 80 ? 'good' : n >= 50 ? 'mid' : 'bad');
14
+ const recTone = { ready: 'good', look: 'mid', not_ready: 'bad' };
15
+
16
+ /** The scorecard of an agent and whether it still matches the changes (diffHash from /changes). */
17
+ export function scorecardOf(agent, diffHash) {
18
+ const d = agent?.docs?.scorecard;
19
+ return { doc: d?.data ? d : null, pending: Boolean(d?.pending), error: d?.error ?? null, stale: Boolean(d?.data && diffHash && d.diffHash !== diffHash) };
20
+ }
21
+
22
+ /** A small badge: "82 Β· Ready to approve". */
23
+ export function badgeHtml(doc, esc) {
24
+ if (!doc?.data) return '';
25
+ const sc = doc.data;
26
+ return `<span class="sc-badge ${recTone[sc.recommendation]}" title="Review scorecard: ${esc(RECOMMENDATION[sc.recommendation])}${sc.reasons.length ? ` (${esc(sc.reasons.join('; '))})` : ''}">${sc.overall ?? 'β€”'} Β· ${esc(RECOMMENDATION[sc.recommendation])}</span>`;
27
+ }
28
+
29
+ /**
30
+ * The full card. `actions` is extra HTML for the head (buttons); look items carry
31
+ * data-sc-look="<file>\t<head>" for the page to jump to.
32
+ */
33
+ export function scorecardHtml(doc, esc, { stale = false, actions = '' } = {}) {
34
+ const sc = doc.data;
35
+ const rows = DIMENSIONS.map(([k, label]) => {
36
+ const d = sc.scores[k];
37
+ if (!d) return `<div class="sc-row na"><span class="sc-label">${label}</span><span class="sc-bar"></span><span class="sc-n">β€”</span><span class="sc-why hint">${k === 'spec' ? 'No spec written' : 'Not scored'}</span></div>`;
38
+ return `<div class="sc-row"><span class="sc-label">${label}</span><span class="sc-bar"><span class="${tone(d.score)}" style="width:${d.score}%"></span></span><span class="sc-n">${d.score}</span><span class="sc-why">${esc(d.why)}${d.notes.length ? ` <span class="sc-note">${esc(d.notes.join('; '))}</span>` : ''}</span></div>`;
39
+ }).join('');
40
+ const crit = sc.criteria.length
41
+ ? `<details class="sc-crit" ${sc.criteria.some((c) => c.status !== 'met') ? 'open' : ''}><summary>Acceptance criteria: ${sc.criteria.filter((c) => c.status === 'met').length} of ${sc.criteria.length} met</summary><ul>${sc.criteria.map((c) => `<li class="${c.status}"><span class="sc-mark">${c.status === 'met' ? 'βœ“' : c.status === 'not_met' ? 'βœ—' : '?'}</span>${esc(c.criterion)}${c.evidence ? ` <span class="hint">${esc(c.evidence)}</span>` : ''}</li>`).join('')}</ul></details>`
42
+ : '';
43
+ const look = sc.look.length
44
+ ? `<div class="sc-look"><b>Where to look</b><ol>${sc.look.map((l) => `<li><button type="button" class="link" data-sc-look="${esc(`${l.file}\t${l.head}`)}"><code>${esc(l.file)}</code></button> ${esc(l.reason)}</li>`).join('')}</ol></div>`
45
+ : '';
46
+ const f = sc.facts;
47
+ return `<div class="scorecard">
48
+ <div class="sc-head">
49
+ <span class="sc-overall ${recTone[sc.recommendation]}"><b>${sc.overall ?? 'β€”'}</b><small>/100</small></span>
50
+ <div class="sc-verdict"><b>${esc(RECOMMENDATION[sc.recommendation])}</b>${sc.reasons.length ? `<div class="hint">${esc(sc.reasons.join(' Β· '))}</div>` : ''}<div class="hint">${esc(sc.summary)}</div></div>
51
+ <span class="spacer"></span>${actions}
52
+ </div>
53
+ ${stale ? '<p class="hint sc-stale"><b>The changes moved since this was scored.</b> Score again for the latest.</p>' : ''}
54
+ <div class="sc-rows">${rows}</div>
55
+ ${crit}${look}
56
+ <div class="hint sc-facts">Measured: ${f.files} files (+${f.added} βˆ’${f.removed}) Β· ${f.testFiles} test files Β· CI ${esc(f.ci ?? 'n/a')} Β· checks ${f.checks.passed}βœ“ ${f.checks.failed}βœ—${f.plan ? ` Β· plan ${f.plan.done}/${f.plan.steps}` : ''}${doc.writer ? ` Β· scored by ${esc(doc.writer)}` : ''}</div>
57
+ </div>`;
58
+ }
59
+
60
+ /** What Listen reads: the verdict, then each score with why. */
61
+ export function scorecardSpeech(doc) {
62
+ const sc = doc.data;
63
+ return [
64
+ `${RECOMMENDATION[sc.recommendation]}. Overall ${sc.overall ?? 'not scored'} out of 100. ${sc.reasons.join('. ')} ${sc.summary}`,
65
+ ...DIMENSIONS.filter(([k]) => sc.scores[k]).map(([k, label]) => `${label}: ${sc.scores[k].score}. ${sc.scores[k].why} ${sc.scores[k].notes.join('. ')}`),
66
+ ...(sc.look.length ? [`Where to look: ${sc.look.map((l) => `${l.file.split('/').pop()}: ${l.reason}`).join('. ')}`] : []),
67
+ ];
68
+ }
package/public/style.css CHANGED
@@ -906,3 +906,37 @@ form.dropping { outline: 2px dashed var(--accent, #6b8cff); outline-offset: 4px;
906
906
  .say.on button:first-child { min-width: 30px; }
907
907
  .explain-foot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
908
908
  .sd-step.current { box-shadow: 0 0 0 2px var(--accent, #6b8cff); }
909
+
910
+ /* Review scorecard. */
911
+ .scorecard { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); }
912
+ .sc-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
913
+ .sc-overall { display: flex; align-items: baseline; gap: 2px; padding: 4px 10px; border-radius: 8px; border: 2px solid currentColor; }
914
+ .sc-overall b { font-size: 26px; line-height: 1; }
915
+ .sc-overall small { opacity: .7; }
916
+ .sc-verdict { min-width: 0; flex: 1 1 260px; }
917
+ .good { color: var(--green); } .mid { color: var(--yellow); } .bad { color: var(--red); }
918
+ .sc-rows { display: flex; flex-direction: column; gap: 4px; }
919
+ .sc-row { display: grid; grid-template-columns: 64px 140px 32px 1fr; gap: 8px; align-items: center; font-size: 13px; }
920
+ .sc-label { font-weight: 600; }
921
+ .sc-bar { height: 8px; border-radius: 4px; background: var(--panel-2); overflow: hidden; }
922
+ .sc-bar > span { display: block; height: 100%; background: currentColor; }
923
+ .sc-n { text-align: right; font-variant-numeric: tabular-nums; }
924
+ .sc-note { color: var(--yellow); }
925
+ .sc-row.na { opacity: .6; }
926
+ .sc-crit ul, .sc-look ol { margin: 4px 0 0; padding-left: 20px; }
927
+ .sc-crit li { list-style: none; margin-left: -16px; }
928
+ .sc-mark { display: inline-block; width: 16px; font-weight: 700; }
929
+ .sc-crit li.met .sc-mark { color: var(--green); } .sc-crit li.not_met .sc-mark { color: var(--red); } .sc-crit li.unverified .sc-mark { color: var(--yellow); }
930
+ .sc-facts { font-size: 11.5px; }
931
+ .sc-badge { display: inline-block; padding: 1px 8px; border-radius: 999px; border: 1px solid currentColor; font-size: 12px; white-space: nowrap; }
932
+ .rv-score { margin: 0 12px 8px; }
933
+ .rv-score-empty { display: flex; align-items: center; gap: 10px; margin: 0 12px 8px; }
934
+ .rv-hunk.flash td { animation: rvflash 1.6s; }
935
+ @keyframes rvflash { 0%, 60% { background: var(--accent); color: var(--accent-text); } }
936
+ @media (max-width: 640px) { .sc-row { grid-template-columns: 56px 1fr 28px; } .sc-why { grid-column: 1 / -1; } }
937
+
938
+ .containers-actions { display: flex; align-items: flex-end; }
939
+
940
+ /* Settings β†’ More: separators belong to the links, so hidden (local-only) ones leave none behind. */
941
+ .settings-links button + button::before { content: 'Β·'; display: inline-block; margin: 0 8px; color: var(--muted); }
942
+ body.remote .settings-links .local-only { display: none; }
package/src/backlog.js CHANGED
@@ -89,7 +89,7 @@ export class Backlog extends EventEmitter {
89
89
  // ------------------------------------------------------------ editing
90
90
 
91
91
  /** Queue one item per title. Options are what an agent would take (repo, dir, base, project, ...). */
92
- add(titles, { repo, dir, base, project, worktree, permissionMode, model, plan, planReview, engine, attachments, priority = 2 } = {}) {
92
+ add(titles, { repo, dir, base, project, worktree, permissionMode, model, plan, planReview, engine, attachments, isolation, priority = 2 } = {}) {
93
93
  const list = titles.map((t) => String(t ?? '').trim()).filter(Boolean);
94
94
  if (!list.length) throw err(400, 'At least one item is required');
95
95
  const pr = parsePriority(priority);
@@ -122,6 +122,8 @@ export class Backlog extends EventEmitter {
122
122
  engine: engine || null,
123
123
  // Staged uploads; each item's agent gets its own copies when it starts.
124
124
  attachments: Array.isArray(attachments) && attachments.length ? attachments.slice(0, 20).map(String) : undefined,
125
+ // "container", "" (on this computer), or undefined (the default when it starts).
126
+ isolation: isolation === undefined ? undefined : isolation || '',
125
127
  plan: Boolean(plan),
126
128
  planReview: planReview ?? null,
127
129
  priority: pr,
@@ -222,6 +224,7 @@ export class Backlog extends EventEmitter {
222
224
  model: it.model,
223
225
  engine: it.engine ?? undefined,
224
226
  attachments: it.attachments,
227
+ isolation: it.isolation,
225
228
  plan: it.plan,
226
229
  planReview: it.planReview ?? undefined,
227
230
  priority: it.priority,
package/src/cli.js CHANGED
@@ -343,6 +343,8 @@ export async function main(argv) {
343
343
  .option('-m, --model <model>', 'model (for the chosen engine)')
344
344
  .option('-e, --engine <engine>', 'agent engine: claude (default), codex, antigravity or muse')
345
345
  .option('--file-attach <path>', 'attach a file to the request (a screenshot, a log); repeat for more', collect)
346
+ .option('--container', 'run each agent in its own Docker container (see agentmgr sandbox)')
347
+ .option('--no-container', 'run on this computer even if containers are the default')
346
348
  .option('--plan', 'while a repo URL clones, plan the task with Claude (moves into the repo when ready)')
347
349
  .option('--no-start', 'create without launching')
348
350
  .option('-a, --attach', 'attach to the agent after launch (single intent only)')
@@ -357,6 +359,7 @@ export async function main(argv) {
357
359
  const created = await api('POST', '/agents', {
358
360
  intents,
359
361
  attachments,
362
+ isolation: cmd.getOptionValueSource('container') === 'cli' ? (opts.container ? 'container' : '') : undefined,
360
363
  repo: opts.repo,
361
364
  dir: opts.dir,
362
365
  name: opts.name,
@@ -383,6 +386,39 @@ export async function main(argv) {
383
386
  }
384
387
  });
385
388
 
389
+ const sb = program.command('sandbox').description('Agents in Docker containers: status, building the agent image, the Claude token');
390
+ sb.command('status')
391
+ .description('Whether Docker runs, the agent image is built, and containers can sign in to Claude')
392
+ .action(async () => {
393
+ await daemon();
394
+ const s = await api('GET', '/isolation');
395
+ console.log(`Docker: ${s.available ? green(`running (${s.version})`) : yellow(`not available${s.error ? `: ${s.error}` : ''}`)}`);
396
+ console.log(`Agent image: ${s.image} ${s.imageBuilt ? green('built') : s.building ? yellow('building…') : yellow('not built (agentmgr sandbox build)')}`);
397
+ console.log(`Claude token: ${s.hasClaudeToken ? green('set') : yellow('not set (run `claude setup-token`, then agentmgr sandbox token <token>)')}`);
398
+ console.log(`Default: ${s.default === 'container' ? 'new agents run in containers' : 'new agents run on this computer'}`);
399
+ if (s.buildError) console.log(yellow(`Last build failed: ${s.buildError}`));
400
+ });
401
+ sb.command('build')
402
+ .description('Build the image agents run in (Node, git, Claude Code, Codex); a few minutes the first time')
403
+ .action(async () => {
404
+ const { buildImage, isolationSettings } = await import('./container.js');
405
+ const { loadConfig } = await import('./config.js');
406
+ const cfg = loadConfig();
407
+ console.log(dim(`building ${isolationSettings(cfg).image}…`));
408
+ await buildImage(cfg, { log: (l) => process.stderr.write(dim(l)) });
409
+ console.log(`${green('βœ“')} built ${isolationSettings(cfg).image}`);
410
+ });
411
+ sb.command('token')
412
+ .description('Save the Claude Code token containers sign in with (from `claude setup-token`); --clear removes it')
413
+ .argument('[token]')
414
+ .option('--clear', 'remove the saved token')
415
+ .action(async (token, opts) => {
416
+ const { saveSecrets } = await import('./container.js');
417
+ if (!opts.clear && !token) throw new Error('Give the token from `claude setup-token`, or --clear');
418
+ saveSecrets({ claudeOauthToken: opts.clear ? null : token.trim() });
419
+ console.log(opts.clear ? 'Token removed' : `${green('βœ“')} token saved (in ~/.agentmanager/secrets.json, readable only by you)`);
420
+ });
421
+
386
422
  program
387
423
  .command('ask')
388
424
  .description('Open a general session (no repo or folder) on any engine and attach to it')
@@ -430,9 +466,9 @@ export async function main(argv) {
430
466
 
431
467
  program
432
468
  .command('doc')
433
- .description('Write a stakeholder document from the agent work: analysis, design, flow, proposal, humanai (Human vs AI), diagrams (diagrams only) or walkthrough (its changes explained one by one)')
469
+ .description('Write a stakeholder document from the agent work: analysis, design, flow, proposal, humanai (Human vs AI), diagrams (diagrams only), walkthrough (its changes explained one by one) or scorecard (its changes scored for review)')
434
470
  .argument('<agent>')
435
- .argument('<kind>', 'analysis, design, flow, proposal, humanai, diagrams, walkthrough, or all (background; diagrams and walkthrough not included)')
471
+ .argument('<kind>', 'analysis, design, flow, proposal, humanai, diagrams, walkthrough, scorecard, or all (background; diagrams, walkthrough and scorecard not included)')
436
472
  .option('-o, --output <file>', 'also save the Markdown to this file')
437
473
  .option('--cached', 'print the last version instead of writing a new one')
438
474
  .option('-b, --background', 'start writing and return at once (kind "all" writes all five)')