ai-agentmanager 1.8.0 → 1.10.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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.10.0 (2026-10-31)
4
+
5
+ - **Review scorecard:** an agent's changes scored 0-100 for Intent, Spec (each acceptance criterion met / not met / unverified, with evidence), Tests, Safety, Scope and Plan, with an overall readiness, a recommendation (Ready to approve / Look at these first / Not ready) and **Where to look**: the few hunks a person should read. Facts the app measures cap the model's scores (failing CI, failing checks, code without tests, plan steps ticked, unassessed criteria) and the recommendation follows fixed rules. At the top of Review changes, as a **Scorecard** document, as a badge in Land, in the PR description, and posted on the PR with **Post to PR** (posting again updates the comment). Runs by itself when the agent marks its work done (`"scorecard": { "auto": false }` turns that off); `agentmgr doc <agent> scorecard`.
6
+
7
+ ## 1.9.0 (2026-10-31)
8
+
9
+ - **Intent, spec and plan:** each task agent now has INTENT.md (the request as given, its Jira summary, attachments and who started it; written by AI Agent Manager), SPEC.md (requirements, acceptance criteria, out of scope; the agent writes it before changing code) and PLAN.md (steps it ticks off), next to its brief. **Intent · Spec · Plan** buttons and the split view's Docs menu show them; Explain, the documents and AI review read the spec and plan; **Push & open PR** puts them in the pull request's description (intent in full, spec and plan folded). `"specs": false` in config.json turns the spec and plan instructions off.
10
+ - **Export diagrams:** every drawn diagram has **Copy image** (paste into Slack, a PR comment or a doc), **PNG** and **SVG**; a document with diagrams also downloads as one self-contained **.html**. Diagrams now use SVG text labels so they export as images.
11
+ - **Listen:** **🔊 Listen** reads Explain, the documents and the change walkthrough aloud with the browser's own voices (nothing leaves the device; works on phones). The walkthrough is read change by change, showing each as it's read. Pause, stop and speed (0.85× to 1.75×).
12
+
3
13
  ## 1.8.0 (2026-10-31)
4
14
 
5
15
  - **Update from your phone:** **Update to …** and **About → Update and restart** now show over remote access too; the install and restart run on the computer and the page reloads when it's back. It installs only the official release, from where this copy came from.
package/README.md CHANGED
@@ -85,13 +85,13 @@ Runs on macOS and Linux; on Windows, use WSL2. Requires Node 20+, `git`, and the
85
85
 
86
86
  ```sh
87
87
  npm install -g ai-agentmanager # the latest release
88
- npm install -g ai-agentmanager@1.8.0 # or a specific one
88
+ npm install -g ai-agentmanager@1.10.0 # or a specific one
89
89
  ```
90
90
 
91
91
  Or from the repository, at a release tag:
92
92
 
93
93
  ```sh
94
- git clone --branch v1.8.0 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
94
+ git clone --branch v1.10.0 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
95
95
  cd ai-agentmanager-core
96
96
  npm install
97
97
  npm link # optional: puts `agentmgr` on your PATH
@@ -111,7 +111,7 @@ npm link
111
111
  | Installed with | Update |
112
112
  | --- | --- |
113
113
  | npm | `npm install -g ai-agentmanager@latest` |
114
- | A clone at a release tag | `git fetch --tags && git checkout v1.8.0 && npm install` (the tag you want) |
114
+ | A clone at a release tag | `git fetch --tags && git checkout v1.10.0 && npm install` (the tag you want) |
115
115
  | A clone of `main` | `git pull && npm install` |
116
116
 
117
117
  **Check your setup.** `agentmgr doctor` checks Node.js, Claude Code and its sign-in, git, `gh`, the data folder, the daemon's port and Chrome, and says how to fix anything missing.
@@ -331,6 +331,8 @@ The Brief panel's **Share for review** buttons, or `agentmgr doc <agent> <kind>`
331
331
  | **Human vs AI** | Your top 5 decisions, each with why it needs you, the stakes, options and what the work suggests, plus a box for your answer. Up to 5 questions the AI answers, each with its confidence, evidence and how to check it. A second model's verdict on each. |
332
332
  | **Diagrams** | Diagrams only, for a quick grasp: the problem, the architecture (with what changes highlighted), the design, the data flow (animated) and the key interaction, plus the plan. Drawn from the documents already written and the agent's work. Optional: **Write all** leaves it out. |
333
333
 
334
+ Each drawn diagram has **Copy image** (paste it into Slack, a PR comment or a doc), **PNG** and **SVG**, and a document with diagrams also downloads as one **.html** file. **🔊 Listen** reads Explain, documents and the change walkthrough aloud with the browser's own voices; the walkthrough is read change by change, showing each as it goes.
335
+
334
336
  **Human vs AI.** This review takes two separate read-only calls.
335
337
  1. **The split.** The agent's engine splits the open questions. *For you to decide* covers judgment calls such as intent, priorities, risk appetite and trade-offs. *Answered by AI* covers reasoning and evaluation: correctness, edge cases, test coverage and what's left.
336
338
  2. **The second opinion.** Another model reviews that split against the same context. It marks each AI answer *agrees*, *partly* or *disagrees*, with a correction where needed. It marks each of your questions *keep*, *AI could answer* or *reword*, and lists questions both lists missed.
@@ -385,9 +387,11 @@ Each card has at most **three points of about 12 words each**, so they're easy t
385
387
 
386
388
  - **Files and diff.** The changed files on the left with lines added and removed, and the diff file by file on the right. Click a file header to fold it; very large files start folded.
387
389
  - **Line comments.** Click **+** next to a line (or its line number) to comment on it, and add an overall comment if you like. **Send to agent** types them into the agent as one message, each with its file, line and the code it is about, and asks it to address them. Comments not sent yet are kept in this browser.
390
+ - **Review scorecard.** **📊 Score changes** (it also runs by itself when the agent marks its work done) scores the changes 0-100 for **Intent**, **Spec** (each acceptance criterion met, not met or unverified, with evidence), **Tests**, **Safety**, **Scope** and **Plan**, and gives an overall readiness with a recommendation: *Ready to approve*, *Look at these first* or *Not ready*, plus **Where to look**, the few hunks worth a person's time. What the app measures caps the model: failing CI or checks, code changed without tests, plan steps not ticked, criteria not assessed. **Post to PR** puts it on the pull request as a comment (again: updates it), and Push & open PR includes it in the description. It also shows as a badge in Land. Turn the automatic run off with `"scorecard": { "auto": false }`.
388
391
  - **Explain changes** explains each change one by one: a title, what it changes, why (from the request and the conversation), the risk and what to check, in the order a reviewer should read them. Each explanation sits above its hunk, and **◀ / ▶** (or **[** and **]**) step through them, opening folded files on the way. When the agent changes the code afterwards, the page says so and **Explain again** brings it up to date. From the CLI: `agentmgr doc <agent> walkthrough`.
389
392
  - **Commit all** commits everything in the worktree, new files included, with your git name and email.
390
- - **Push & open PR** pushes the branch and opens a pull request with `gh`, against the branch the agent started from (title from your text, or from the commits; **Draft** if you like). With a pull request already open, it pushes to it. The pull request is linked to the agent.
393
+ - **Intent, spec and plan.** Each agent keeps INTENT.md (what was asked, written by AI Agent Manager), SPEC.md (requirements and acceptance criteria, written by the agent before it changes code) and PLAN.md (steps, ticked off as it goes) next to its brief. Open them with **Intent · Spec · Plan** or in the split view's Docs; Explain, the documents and AI review read them too. `"specs": false` in config.json stops asking agents for the spec and plan.
394
+ - **Push & open PR** pushes the branch and opens a pull request with `gh`, against the branch the agent started from (title from your text, or from the commits; **Draft** if you like). Its description carries the intent, and the spec and plan in folded sections. With a pull request already open, it pushes to it. The pull request is linked to the agent.
391
395
 
392
396
  The same from the API: `GET /api/agents/:id/changes`, `POST …/changes/comments`, `…/changes/commit`, `…/changes/pr`. Plugins can refuse a commit or a pull request (`agent.commit`, `agent.pr`).
393
397
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-agentmanager",
3
- "version": "1.8.0",
3
+ "version": "1.10.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
@@ -6,6 +6,9 @@ import { touchScroll } from '/touchscroll.js';
6
6
  import { openStream, post, REMOTE } from '/transport.js';
7
7
  import { attachBox } from '/attach.js';
8
8
  import { initSide } from '/side.js';
9
+ import { addTools, download, htmlFile, initExport } from '/export.js';
10
+ import { badgeHtml as scoreBadge, scorecardHtml, scorecardSpeech } from '/scorecard.js';
11
+ import { bindListen, listenHtml, play, speakable, now as speaking, stop as stopSpeaking } from '/speak.js';
9
12
  import { initReview } from '/review.js';
10
13
  import { initLand, prChips } from '/land.js';
11
14
  import { initTriggers } from '/triggers.js';
@@ -121,7 +124,7 @@ $('#new-playbook').onclick = () => {
121
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 });
122
125
  };
123
126
  $('#triggers-open').onclick = () => triggersUi.open();
124
- 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' });
125
128
 
126
129
  function statusOf(a) {
127
130
  if (a.runtime.planning) return a.runtime.attention ? 'attention' : 'planning';
@@ -169,10 +172,13 @@ const isDark = () => {
169
172
  const t = document.documentElement.dataset.theme;
170
173
  return t ? t === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
171
174
  };
175
+ // SVG text labels rather than HTML ones: a diagram then exports to PNG (the browser won't turn
176
+ // HTML-in-SVG into an image).
177
+ const mermaidConfig = () => ({ startOnLoad: false, securityLevel: 'strict', theme: isDark() ? 'dark' : 'default', htmlLabels: false, flowchart: { htmlLabels: false } });
172
178
  // Diagrams are drawn in the theme's colors: redraw them when it changes.
173
179
  window.addEventListener('agentmgr-theme', () => {
174
180
  diagrams.clear();
175
- mermaidLib?.initialize({ startOnLoad: false, securityLevel: 'strict', theme: isDark() ? 'dark' : 'default' });
181
+ mermaidLib?.initialize(mermaidConfig());
176
182
  for (const el of document.querySelectorAll('[data-mermaid][data-filled]')) delete el.dataset.filled;
177
183
  renderDiagrams(document);
178
184
  });
@@ -201,8 +207,7 @@ async function renderDiagrams(root) {
201
207
  try {
202
208
  if (!mermaidLib) {
203
209
  mermaidLib = (await import('/vendor/mermaid/mermaid.esm.min.mjs')).default;
204
- const dark = isDark();
205
- mermaidLib.initialize({ startOnLoad: false, securityLevel: 'strict', theme: dark ? 'dark' : 'default' });
210
+ mermaidLib.initialize(mermaidConfig());
206
211
  }
207
212
  const { svg } = await mermaidLib.render(`mmd-${diagrams.size}-${Date.now()}`, src);
208
213
  diagrams.set(src, { svg });
@@ -215,7 +220,10 @@ async function renderDiagrams(root) {
215
220
  for (const el of document.querySelectorAll('[data-mermaid]')) {
216
221
  if (el.dataset.mermaid !== src || el.dataset.filled) continue;
217
222
  el.dataset.filled = '1';
218
- if (out.svg) el.innerHTML = out.svg;
223
+ if (out.svg) {
224
+ el.innerHTML = out.svg;
225
+ addTools(el);
226
+ }
219
227
  else el.innerHTML = `<div class="hint">Diagram could not be drawn (${esc(out.error)}). Source:</div><pre>${esc(src)}</pre>`;
220
228
  }
221
229
  }
@@ -233,7 +241,7 @@ function explainBox(a) {
233
241
  ${ex.markdown
234
242
  ? `${stale ? '<p class="hint">The brief has changed since this explanation; use <b>Explain again</b> to refresh it.</p>' : ''}
235
243
  ${clamp(a, 'explain', md(ex.markdown))}
236
- <p class="hint">Explained ${ago(ex.at)}.</p>`
244
+ <p class="hint explain-foot">Explained ${ago(ex.at)}. ${listenHtml(`explain:${a.id}`, { title: 'Read the explanation aloud' })}</p>`
237
245
  : ''}
238
246
  </div>`;
239
247
  return { button, box };
@@ -260,12 +268,21 @@ const INSIGHT_ICONS = { why: '🎯', what: '📦', how: '🛠', now: '📍' };
260
268
 
261
269
  // ------------------------------------------------------------------ stakeholder documents
262
270
 
263
- 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' };
264
272
  // Optional extras that "Write all" leaves out.
265
- const DOC_EXTRAS = new Set(['diagrams']);
266
- const DOC_TITLES = { ...DOCS, plan: 'Implementation plan' };
273
+ const DOC_EXTRAS = new Set(['diagrams', 'scorecard']);
274
+ const DOC_TITLES = { ...DOCS, plan: 'Plan', intent: 'Intent', spec: 'Spec' };
275
+ // The agent's intent, spec and plan files (read from the daemon when opened; kept here by agent and kind).
276
+ const SPEC_KINDS = { intent: 'Intent', spec: 'Spec', plan: 'Plan' };
277
+ const specCache = new Map(); // "<agent>:<kind>" -> { markdown, at }
278
+ const isSpecKind = (a, kind) => Boolean(SPEC_KINDS[kind]) && !(kind === 'plan' && a?.planReview);
279
+ async function loadSpec(agentId, kind) {
280
+ const r = await api('GET', `/agents/${agentId}/specs/${kind}`).catch(() => null);
281
+ specCache.set(`${agentId}:${kind}`, { markdown: r?.markdown ?? null, at: r?.updatedAt ?? null, loaded: true });
282
+ }
267
283
  /** What the document dialog shows: a stakeholder document, or the agent's plan under review. */
268
- const docOf = (a, kind) => (kind === 'plan' ? a?.planReview && { markdown: a.planReview.markdown, at: a.planReview.updatedAt } : a?.docs?.[kind]);
284
+ const docOf = (a, kind) =>
285
+ isSpecKind(a, kind) ? specCache.get(`${a?.id}:${kind}`) : kind === 'plan' ? a?.planReview && { markdown: a.planReview.markdown, at: a.planReview.updatedAt } : a?.docs?.[kind];
269
286
 
270
287
  const VERDICT = { agree: '✓ Agrees', partly: '≈ Partly agrees', disagree: '✗ Disagrees', keep: '✓ Keep', ai: '🤖 AI could answer', reword: '✎ Reword' };
271
288
  const CONFIDENCE = { high: 'High confidence', medium: 'Medium confidence', low: 'Low confidence' };
@@ -334,7 +351,18 @@ function docButtons(a) {
334
351
  const d = a.docs?.[k];
335
352
  const stale = d?.markdown && a.brief?.updatedAt && d.briefUpdatedAt !== a.brief.updatedAt;
336
353
  return `<button class="small doc-btn ${d?.markdown ? 'has' : ''}" data-doc="${k}" ${d?.pending ? 'disabled' : ''}
337
- 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>`;
355
+ })
356
+ .join('')}</span>${specButtons(a)}`;
357
+ }
358
+
359
+ /** Intent · Spec · Plan: the files kept for reviewers (and put in the pull request's description). */
360
+ function specButtons(a) {
361
+ if (!a.specDocs) return '';
362
+ return `<span class="doc-buttons spec-buttons" title="For reviewers, people and agents: what was asked, what done means, and the plan. Push & open PR puts them in the description">${Object.entries(SPEC_KINDS)
363
+ .map(([k, label]) => {
364
+ const at = a.specDocs[k] ?? (k === 'plan' ? a.planReview?.updatedAt : null);
365
+ return `<button class="small doc-btn ${at ? 'has' : ''}" data-doc="${k}" title="${at ? `Open ${label} (updated ${ago(at)})` : k === 'intent' ? 'Written when the agent is created' : `The agent writes it ${k === 'spec' ? 'before changing code' : 'and keeps it current'}`}">${label}${at ? ' ✓' : ''}</button>`;
338
366
  })
339
367
  .join('')}</span>`;
340
368
  }
@@ -346,7 +374,9 @@ function renderDocDialog() {
346
374
  const a = agents.get(docView.agentId);
347
375
  const d = docOf(a, docView.kind);
348
376
  const label = DOC_TITLES[docView.kind];
349
- const plan = docView.kind === 'plan';
377
+ // The plan under review (approve / send back); otherwise a plain file to read.
378
+ const plan = docView.kind === 'plan' && Boolean(a?.planReview);
379
+ const specKind = isSpecKind(a, docView.kind);
350
380
  $('#doc-title').textContent = `${label} · ${a?.name ?? ''}`;
351
381
  const body = $('#doc-body');
352
382
  body.classList.toggle('diagrams-view', docView.kind === 'diagrams');
@@ -355,19 +385,24 @@ function renderDocDialog() {
355
385
  body.dataset.rendered = '';
356
386
  } else if (d?.error) body.innerHTML = `<p class="error">Couldn't write it: ${esc(d.error)}</p>`;
357
387
  else if (plan && !d?.markdown) body.innerHTML = '<p class="hint">The agent is investigating and hasn\'t written its plan yet. You\'ll see it here (and in the inbox) when it has.</p>';
388
+ else if (specKind && !d?.loaded) body.innerHTML = '<p class="hint">Loading…</p>';
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>`;
358
390
  else if (d?.markdown) {
359
391
  if (body.dataset.rendered !== d.at) {
360
- 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);
361
393
  body.dataset.rendered = d.at;
362
394
  renderDiagrams(body);
363
395
  }
364
396
  }
365
397
  const stale = !plan && d?.markdown && a.brief?.updatedAt && d.briefUpdatedAt !== a.brief.updatedAt;
366
398
  const by = d?.writer ? ` by ${d.writer}${d.critique ? `, checked by ${d.critique.label}` : ''}${d.from ? ` from ${d.from.length ? d.from.join(', ') : "the agent's work"}` : ''}` : '';
367
- $('#doc-meta').textContent = d?.markdown ? `${plan ? `Plan · ${PLAN_LABEL[a.planReview.status]} · updated` : docView.kind === 'diagrams' ? 'Drawn' : 'Written'} ${ago(d.at)}${by}${stale ? ' · the brief has changed since; Regenerate for the latest' : ''}` : '';
399
+ $('#doc-meta').textContent = d?.markdown ? `${plan ? `Plan · ${PLAN_LABEL[a.planReview.status]} · updated` : specKind ? 'Updated' : docView.kind === 'diagrams' ? 'Drawn' : 'Written'} ${ago(d.at)}${by}${stale && !specKind ? ' · the brief has changed since; Regenerate for the latest' : ''}${specKind ? ' · goes into the pull request description' : ''}` : '';
368
400
  for (const id of ['doc-copy', 'doc-download', 'doc-print']) $(`#${id}`).disabled = !d?.markdown;
369
- $('#doc-print').hidden = plan;
370
- $('#doc-regen').hidden = plan;
401
+ // Diagrams: the whole document as one HTML file with every diagram (each also has Copy / PNG / SVG).
402
+ $('#doc-html').hidden = !/```mermaid/.test(d?.markdown ?? '');
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' }) : '';
404
+ $('#doc-print').hidden = plan || specKind;
405
+ $('#doc-regen').hidden = plan || specKind;
371
406
  $('#doc-regen').disabled = Boolean(d?.pending);
372
407
  $('#doc-bg').hidden = !d?.pending;
373
408
  // Footer: send your answers (Human vs AI), or approve / send back the plan.
@@ -388,7 +423,10 @@ function openDoc(agentId, kind) {
388
423
  $('#doc-body').dataset.rendered = '';
389
424
  $('#doc-dialog').showModal();
390
425
  const a = agents.get(agentId);
391
- if (kind !== 'plan' && !a.docs?.[kind]?.markdown && !a.docs?.[kind]?.pending) generateDoc();
426
+ if (isSpecKind(a, kind)) {
427
+ specCache.delete(`${agentId}:${kind}`);
428
+ loadSpec(agentId, kind).then(() => docView?.agentId === agentId && docView.kind === kind && renderDocDialog());
429
+ } else if (kind !== 'plan' && !a.docs?.[kind]?.markdown && !a.docs?.[kind]?.pending) generateDoc();
392
430
  renderDocDialog();
393
431
  }
394
432
 
@@ -398,6 +436,7 @@ function generateDoc() {
398
436
  }
399
437
 
400
438
  $('#doc-close').onclick = () => {
439
+ if (speaking()?.id.startsWith('doc:')) stopSpeaking();
401
440
  docView = null;
402
441
  $('#doc-dialog').close();
403
442
  };
@@ -484,6 +523,26 @@ function planRow(a) {
484
523
  ${p.status !== 'approved' ? '<button class="small ghost" data-act="plan-skip" title="Stop waiting for review; the agent goes ahead">Skip review</button>' : ''}</div>`;
485
524
  }
486
525
  $('#doc-print').onclick = () => window.open(`/share.html#${docView.agentId}/${docView.kind}`, '_blank', 'noopener');
526
+ $('#doc-html').onclick = () => {
527
+ const a = agents.get(docView.agentId);
528
+ download(htmlFile($('#doc-body'), `${DOC_TITLES[docView.kind]}: ${a?.name ?? ''}`), `${a?.slug ?? 'agent'}-${docView.kind}.html`);
529
+ };
530
+
531
+ // Diagram export: files named after the agent they belong to.
532
+ initExport({ toast, base: (slot) => (slot.closest('#doc-body') && docView ? agents.get(docView.agentId)?.slug : agents.get(term.agentId)?.slug ?? agents.get(selectedId)?.slug) ?? '' });
533
+
534
+ // Listening: what each Listen control reads.
535
+ bindListen((id) => {
536
+ const [what, agentId, kind] = id.split(':');
537
+ const a = agents.get(agentId);
538
+ let parts = null;
539
+ if (what === 'explain') parts = [speakable(a?.explanation?.markdown)];
540
+ else if (what === 'doc') parts = [speakable(kind === 'explain' ? a?.explanation?.markdown : docOf(a, kind)?.markdown)];
541
+ else if (what === 'walk') parts = reviewUi.listenParts();
542
+ else if (what === 'score') parts = a?.docs?.scorecard?.data ? scorecardSpeech(a.docs.scorecard) : null;
543
+ else if (what === 'sidewalk') parts = sidePane?.listenParts();
544
+ if (!parts?.length || !play(id, parts)) toast('Nothing to read aloud here (or this browser has no speech)');
545
+ });
487
546
 
488
547
  /** Why / What / How / Now as four cards. */
489
548
  function insightsBox(a) {
@@ -2120,6 +2179,7 @@ sidePane = initSide({
2120
2179
  codeUi,
2121
2180
  docHtml,
2122
2181
  renderDiagrams,
2182
+ listenHtml,
2123
2183
  mainKind: () => term.kind,
2124
2184
  setMain: (kind) => term.agentId && term.open(term.agentId, kind),
2125
2185
  refit: () => requestAnimationFrame(() => term.refit()),
@@ -2909,6 +2969,10 @@ function upsert(a) {
2909
2969
  /** Views that show one agent's details outside the main render: keep them current. */
2910
2970
  function agentChanged(a) {
2911
2971
  if (a.id === term.agentId) term.renderTabs();
2972
+ // An open intent / spec / plan that the agent just updated: read it again.
2973
+ if (docView?.agentId === a.id && isSpecKind(a, docView.kind) && specCache.get(`${a.id}:${docView.kind}`)?.at !== (a.specDocs?.[docView.kind] ?? null)) {
2974
+ loadSpec(a.id, docView.kind).then(renderDocDialog);
2975
+ }
2912
2976
  reviewUi.agentChanged(a);
2913
2977
  sidePane?.agentChanged(a);
2914
2978
  }
@@ -0,0 +1,110 @@
1
+ // Taking diagrams out of the page, for a pull request or Slack: each drawn diagram gets Copy image,
2
+ // PNG and SVG; a whole document of diagrams downloads as one self-contained HTML file. Diagrams are
3
+ // drawn with SVG text labels (not HTML in foreignObject), so they turn into PNGs without the
4
+ // browser refusing a "tainted" canvas.
5
+
6
+ const TOOLS = `<span class="dg-tools"><button type="button" class="small ghost" data-dg="copy" title="Copy as an image: paste into Slack, a GitHub comment or a doc">Copy image</button><button type="button" class="small ghost" data-dg="png" title="Download as a PNG">PNG</button><button type="button" class="small ghost" data-dg="svg" title="Download as an SVG (sharp at any size)">SVG</button></span>`;
7
+
8
+ /** Add the export buttons to a drawn diagram slot. */
9
+ export function addTools(slot) {
10
+ if (slot.querySelector('.dg-tools') || !slot.querySelector('svg')) return;
11
+ slot.insertAdjacentHTML('afterbegin', TOOLS);
12
+ }
13
+
14
+ const pageBg = () => getComputedStyle(document.body).backgroundColor || '#ffffff';
15
+
16
+ /** A standalone copy of a diagram's SVG: sized, namespaced, on the page's background. */
17
+ export function svgText(svg, { background = pageBg() } = {}) {
18
+ const copy = svg.cloneNode(true);
19
+ copy.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
20
+ copy.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
21
+ const vb = svg.viewBox?.baseVal;
22
+ const box = svg.getBoundingClientRect();
23
+ const w = Math.ceil(vb?.width || box.width || 800);
24
+ const h = Math.ceil(vb?.height || box.height || 600);
25
+ copy.setAttribute('width', w);
26
+ copy.setAttribute('height', h);
27
+ copy.removeAttribute('style'); // mermaid's max-width would shrink it outside the page
28
+ if (background) {
29
+ const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
30
+ Object.entries({ x: vb?.x ?? 0, y: vb?.y ?? 0, width: '100%', height: '100%', fill: background }).forEach(([k, v]) => bg.setAttribute(k, v));
31
+ if (vb) {
32
+ bg.setAttribute('width', vb.width);
33
+ bg.setAttribute('height', vb.height);
34
+ }
35
+ copy.insertBefore(bg, copy.firstChild);
36
+ }
37
+ return { text: new XMLSerializer().serializeToString(copy), w, h };
38
+ }
39
+
40
+ /** The diagram as a PNG blob, at twice its size so it stays sharp on retina screens and in PRs. */
41
+ export function pngBlob(svg, scale = 2) {
42
+ const { text, w, h } = svgText(svg);
43
+ return new Promise((resolve, reject) => {
44
+ const img = new Image();
45
+ img.onload = () => {
46
+ const canvas = Object.assign(document.createElement('canvas'), { width: w * scale, height: h * scale });
47
+ const ctx = canvas.getContext('2d');
48
+ ctx.scale(scale, scale);
49
+ ctx.drawImage(img, 0, 0, w, h);
50
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Could not make the image'))), 'image/png');
51
+ };
52
+ img.onerror = () => reject(new Error('Could not draw the diagram as an image'));
53
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(text)}`;
54
+ });
55
+ }
56
+
57
+ export function download(blob, name) {
58
+ const link = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: name });
59
+ document.body.append(link);
60
+ link.click();
61
+ link.remove();
62
+ setTimeout(() => URL.revokeObjectURL(link.href), 2000);
63
+ }
64
+
65
+ const slugOf = (s) => String(s || 'diagram').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'diagram';
66
+
67
+ /** The diagram's name for files: its heading in the document, or "diagram". */
68
+ function nameOf(slot, base) {
69
+ let el = slot.previousElementSibling;
70
+ while (el && !/^H[1-4]$/.test(el.tagName)) el = el.previousElementSibling;
71
+ return `${base ? `${slugOf(base)}-` : ''}${slugOf(el?.textContent)}`;
72
+ }
73
+
74
+ /** One page with every diagram (and heading) of a rendered document, to keep or attach. */
75
+ export function htmlFile(article, title) {
76
+ const copy = article.cloneNode(true);
77
+ copy.querySelectorAll('.dg-tools').forEach((t) => t.remove());
78
+ copy.querySelectorAll('svg').forEach((svg, i) => {
79
+ const live = article.querySelectorAll('svg')[i];
80
+ if (live) svg.outerHTML = svgText(live, { background: null }).text;
81
+ });
82
+ const css = `body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;max-width:1000px;margin:32px auto;padding:0 16px;color:#1c2330;background:#fff}
83
+ h1{font-size:24px}h2{font-size:18px;margin-top:28px;border-top:1px solid #dde1e7;padding-top:14px}
84
+ .diagram{display:flex;justify-content:center;margin:12px 0;overflow-x:auto}svg{max-width:100%;height:auto}`;
85
+ const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title.replace(/</g, '&lt;')}</title><style>${css}</style></head><body>${copy.innerHTML}</body></html>`;
86
+ return new Blob([html], { type: 'text/html' });
87
+ }
88
+
89
+ /** Clicks on any diagram's buttons, wherever it is drawn. `base` names the files (the agent). */
90
+ export function initExport({ toast, base = () => '' }) {
91
+ document.addEventListener('click', async (e) => {
92
+ const b = e.target.closest('[data-dg]');
93
+ if (!b) return;
94
+ const slot = b.closest('.diagram');
95
+ const svg = slot?.querySelector('svg');
96
+ if (!svg) return;
97
+ const name = nameOf(slot, base(slot));
98
+ try {
99
+ if (b.dataset.dg === 'svg') download(new Blob([svgText(svg).text], { type: 'image/svg+xml' }), `${name}.svg`);
100
+ else if (b.dataset.dg === 'png') download(await pngBlob(svg), `${name}.png`);
101
+ else {
102
+ // Safari wants the promise handed over within the click.
103
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob(svg) })]);
104
+ toast('Diagram copied as an image: paste it into Slack, a PR comment or a doc');
105
+ }
106
+ } catch (err) {
107
+ toast(b.dataset.dg === 'copy' ? `Couldn't copy (${err.message}); use PNG instead` : `Couldn't export: ${err.message}`, 5000);
108
+ }
109
+ });
110
+ }
package/public/index.html CHANGED
@@ -378,7 +378,9 @@
378
378
  <button type="button" id="doc-bg" class="small" hidden title="Close this and keep writing; you'll be told when it's ready">Continue in background</button>
379
379
  <button type="button" id="doc-regen" class="small" title="Write it again from the agent's latest work">Regenerate</button>
380
380
  <button type="button" id="doc-copy" class="small" title="Copy as Markdown">Copy Markdown</button>
381
+ <span id="doc-listen"></span>
381
382
  <button type="button" id="doc-download" class="small">Download .md</button>
383
+ <button type="button" id="doc-html" class="small" hidden title="One HTML file with every diagram: open it anywhere, attach it, or print it">Download .html</button>
382
384
  <button type="button" id="doc-print" class="small primary" title="Open a print-ready page (Save as PDF)">Print / PDF</button>
383
385
  <button type="button" id="doc-close" class="small ghost">Close</button>
384
386
  </div>
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/review.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // Review screen: an agent's changes file by file, line comments sent back to the agent as one
2
2
  // message, and Commit / Push & open PR. Comments not sent yet are kept per agent in this browser.
3
3
  // With a walkthrough, each hunk shows its explanation and ◀ / ▶ step through them in reading order.
4
- import { cardHtml, explainButton, stepOf, walkthroughOf } from './walkthrough.js';
4
+ import { cardHtml, explainButton, listenParts as walkParts, stepOf, walkthroughOf } from './walkthrough.js';
5
+ import { listenHtml, now as speaking, stop as stopSpeaking } from './speak.js';
6
+ import { scorecardHtml, scorecardOf } from './scorecard.js';
5
7
 
6
8
  /** Unified diff -> [{ path, status, binary, hunks: [{ head, lines: [{ t, old, new, text }] }] }] */
7
9
  export function parseDiff(text) {
@@ -137,7 +139,7 @@ export function initReview({ api, esc, toast, agents, $ }) {
137
139
  for (const h of f.hunks) {
138
140
  const i = w.doc ? stepOf(w.doc, f.path, h.head) : -1;
139
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>`);
140
- 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>`);
141
143
  for (const l of h.lines) {
142
144
  const side = l.t === 'del' ? 'old' : 'new';
143
145
  const line = side === 'old' ? l.old : l.new;
@@ -156,6 +158,32 @@ export function initReview({ api, esc, toast, agents, $ }) {
156
158
  return `<div class="rv-file" id="rv-${encodeURIComponent(f.path)}">${head}<table class="rv-table">${rows.join('')}</table></div>`;
157
159
  }
158
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
+
159
187
  /** Explain changes, then ◀ n of N ▶ once there is a walkthrough. */
160
188
  function walkNav() {
161
189
  const w = walk();
@@ -163,7 +191,7 @@ export function initReview({ api, esc, toast, agents, $ }) {
163
191
  const nav = n
164
192
  ? `<span class="rv-walk"><button type="button" class="small ghost" data-rv-step="-1" title="Previous change ([)">◀</button><span class="hint">${st.step == null ? `${n} changes` : `${st.step + 1} of ${n}`}</span><button type="button" class="small ghost" data-rv-step="1" title="Next change (])">▶</button></span>`
165
193
  : '';
166
- return `${nav}${explainButton(w, 'data-rv-explain')}`;
194
+ return `${nav}${n ? listenHtml(`walk:${st.id}`, { title: 'Hear the changes one by one; each is shown as it is read' }) : ''}${explainButton(w, 'data-rv-explain')}`;
167
195
  }
168
196
 
169
197
  function walkNote() {
@@ -220,6 +248,7 @@ export function initReview({ api, esc, toast, agents, $ }) {
220
248
  ${d.files.length ? walkNav() : ''}
221
249
  <button type="button" class="small ghost" data-rv-reload>${st.loading ? 'Loading…' : 'Reload'}</button>
222
250
  </div>
251
+ ${scorePanel()}
223
252
  ${walkNote()}
224
253
  ${d.busy ? '<p class="hint rv-warn">The agent is working, so the changes may still move. Reload when it is idle.</p>' : ''}
225
254
  ${d.truncated ? '<p class="hint rv-warn">The diff is large and was cut short; open a shell for the rest.</p>' : ''}
@@ -288,6 +317,29 @@ export function initReview({ api, esc, toast, agents, $ }) {
288
317
  if (t.closest('[data-rv-reload]')) return keepInputs(), reload();
289
318
  const stepBtn = t.closest('[data-rv-step]');
290
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
+ }
291
343
  if (t.closest('[data-rv-explain]')) {
292
344
  keepInputs();
293
345
  try {
@@ -432,6 +484,7 @@ export function initReview({ api, esc, toast, agents, $ }) {
432
484
 
433
485
  $('#review-close').onclick = () => dlg.close();
434
486
  dlg.addEventListener('close', () => {
487
+ if (speaking()?.id.startsWith('walk:')) stopSpeaking();
435
488
  st.id = null;
436
489
  st.note = st.msg = st.prTitle = '';
437
490
  });
@@ -440,12 +493,21 @@ export function initReview({ api, esc, toast, agents, $ }) {
440
493
  function agentChanged(a) {
441
494
  if (!dlg.open || a.id !== st.id || !st.data) return;
442
495
  const w = a.docs?.walkthrough;
443
- 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}`;
444
498
  if (sig === st.walkSig) return;
445
499
  st.walkSig = sig;
446
500
  keepInputs();
447
501
  render();
448
502
  }
449
503
 
450
- return { open, agentChanged, get openId() { return dlg.open ? st.id : null; } };
504
+ /** What Listen reads: the summary, then each change still in the diff, shown as it is read. */
505
+ function listenParts() {
506
+ const w = walk();
507
+ if (!w.doc) return [];
508
+ const ok = new Set(present(w));
509
+ return walkParts(w.doc.data, (i) => (ok.has(i) ? () => gotoStep(i) : null)).filter((p) => typeof p === 'string' || ok.has(p.i));
510
+ }
511
+
512
+ return { open, agentChanged, listenParts, get openId() { return dlg.open ? st.id : null; } };
451
513
  }