ai-agentmanager 1.8.0 → 1.9.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.9.0 (2026-10-31)
4
+
5
+ - **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.
6
+ - **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.
7
+ - **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×).
8
+
3
9
  ## 1.8.0 (2026-10-31)
4
10
 
5
11
  - **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.9.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.9.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.9.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.
@@ -387,7 +389,8 @@ Each card has at most **three points of about 12 words each**, so they're easy t
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.
388
390
  - **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
391
  - **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.
392
+ - **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.
393
+ - **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
394
 
392
395
  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
396
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-agentmanager",
3
- "version": "1.8.0",
3
+ "version": "1.9.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,8 @@ 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 { bindListen, listenHtml, play, speakable, now as speaking, stop as stopSpeaking } from '/speak.js';
9
11
  import { initReview } from '/review.js';
10
12
  import { initLand, prChips } from '/land.js';
11
13
  import { initTriggers } from '/triggers.js';
@@ -169,10 +171,13 @@ const isDark = () => {
169
171
  const t = document.documentElement.dataset.theme;
170
172
  return t ? t === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
171
173
  };
174
+ // SVG text labels rather than HTML ones: a diagram then exports to PNG (the browser won't turn
175
+ // HTML-in-SVG into an image).
176
+ const mermaidConfig = () => ({ startOnLoad: false, securityLevel: 'strict', theme: isDark() ? 'dark' : 'default', htmlLabels: false, flowchart: { htmlLabels: false } });
172
177
  // Diagrams are drawn in the theme's colors: redraw them when it changes.
173
178
  window.addEventListener('agentmgr-theme', () => {
174
179
  diagrams.clear();
175
- mermaidLib?.initialize({ startOnLoad: false, securityLevel: 'strict', theme: isDark() ? 'dark' : 'default' });
180
+ mermaidLib?.initialize(mermaidConfig());
176
181
  for (const el of document.querySelectorAll('[data-mermaid][data-filled]')) delete el.dataset.filled;
177
182
  renderDiagrams(document);
178
183
  });
@@ -201,8 +206,7 @@ async function renderDiagrams(root) {
201
206
  try {
202
207
  if (!mermaidLib) {
203
208
  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' });
209
+ mermaidLib.initialize(mermaidConfig());
206
210
  }
207
211
  const { svg } = await mermaidLib.render(`mmd-${diagrams.size}-${Date.now()}`, src);
208
212
  diagrams.set(src, { svg });
@@ -215,7 +219,10 @@ async function renderDiagrams(root) {
215
219
  for (const el of document.querySelectorAll('[data-mermaid]')) {
216
220
  if (el.dataset.mermaid !== src || el.dataset.filled) continue;
217
221
  el.dataset.filled = '1';
218
- if (out.svg) el.innerHTML = out.svg;
222
+ if (out.svg) {
223
+ el.innerHTML = out.svg;
224
+ addTools(el);
225
+ }
219
226
  else el.innerHTML = `<div class="hint">Diagram could not be drawn (${esc(out.error)}). Source:</div><pre>${esc(src)}</pre>`;
220
227
  }
221
228
  }
@@ -233,7 +240,7 @@ function explainBox(a) {
233
240
  ${ex.markdown
234
241
  ? `${stale ? '<p class="hint">The brief has changed since this explanation; use <b>Explain again</b> to refresh it.</p>' : ''}
235
242
  ${clamp(a, 'explain', md(ex.markdown))}
236
- <p class="hint">Explained ${ago(ex.at)}.</p>`
243
+ <p class="hint explain-foot">Explained ${ago(ex.at)}. ${listenHtml(`explain:${a.id}`, { title: 'Read the explanation aloud' })}</p>`
237
244
  : ''}
238
245
  </div>`;
239
246
  return { button, box };
@@ -263,9 +270,18 @@ const INSIGHT_ICONS = { why: '🎯', what: '📦', how: '🛠', now: '📍' };
263
270
  const DOCS = { analysis: 'Analysis', design: 'Design', flow: 'Flow', proposal: 'Proposal', humanai: 'Human vs AI', diagrams: 'Diagrams' };
264
271
  // Optional extras that "Write all" leaves out.
265
272
  const DOC_EXTRAS = new Set(['diagrams']);
266
- const DOC_TITLES = { ...DOCS, plan: 'Implementation plan' };
273
+ const DOC_TITLES = { ...DOCS, plan: 'Plan', intent: 'Intent', spec: 'Spec' };
274
+ // The agent's intent, spec and plan files (read from the daemon when opened; kept here by agent and kind).
275
+ const SPEC_KINDS = { intent: 'Intent', spec: 'Spec', plan: 'Plan' };
276
+ const specCache = new Map(); // "<agent>:<kind>" -> { markdown, at }
277
+ const isSpecKind = (a, kind) => Boolean(SPEC_KINDS[kind]) && !(kind === 'plan' && a?.planReview);
278
+ async function loadSpec(agentId, kind) {
279
+ const r = await api('GET', `/agents/${agentId}/specs/${kind}`).catch(() => null);
280
+ specCache.set(`${agentId}:${kind}`, { markdown: r?.markdown ?? null, at: r?.updatedAt ?? null, loaded: true });
281
+ }
267
282
  /** 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]);
283
+ const docOf = (a, kind) =>
284
+ isSpecKind(a, kind) ? specCache.get(`${a?.id}:${kind}`) : kind === 'plan' ? a?.planReview && { markdown: a.planReview.markdown, at: a.planReview.updatedAt } : a?.docs?.[kind];
269
285
 
270
286
  const VERDICT = { agree: '✓ Agrees', partly: '≈ Partly agrees', disagree: '✗ Disagrees', keep: '✓ Keep', ai: '🤖 AI could answer', reword: '✎ Reword' };
271
287
  const CONFIDENCE = { high: 'High confidence', medium: 'Medium confidence', low: 'Low confidence' };
@@ -336,6 +352,17 @@ function docButtons(a) {
336
352
  return `<button class="small doc-btn ${d?.markdown ? 'has' : ''}" data-doc="${k}" ${d?.pending ? 'disabled' : ''}
337
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>`;
338
354
  })
355
+ .join('')}</span>${specButtons(a)}`;
356
+ }
357
+
358
+ /** Intent · Spec · Plan: the files kept for reviewers (and put in the pull request's description). */
359
+ function specButtons(a) {
360
+ if (!a.specDocs) return '';
361
+ 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)
362
+ .map(([k, label]) => {
363
+ const at = a.specDocs[k] ?? (k === 'plan' ? a.planReview?.updatedAt : null);
364
+ 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>`;
365
+ })
339
366
  .join('')}</span>`;
340
367
  }
341
368
 
@@ -346,7 +373,9 @@ function renderDocDialog() {
346
373
  const a = agents.get(docView.agentId);
347
374
  const d = docOf(a, docView.kind);
348
375
  const label = DOC_TITLES[docView.kind];
349
- const plan = docView.kind === 'plan';
376
+ // The plan under review (approve / send back); otherwise a plain file to read.
377
+ const plan = docView.kind === 'plan' && Boolean(a?.planReview);
378
+ const specKind = isSpecKind(a, docView.kind);
350
379
  $('#doc-title').textContent = `${label} · ${a?.name ?? ''}`;
351
380
  const body = $('#doc-body');
352
381
  body.classList.toggle('diagrams-view', docView.kind === 'diagrams');
@@ -355,6 +384,8 @@ function renderDocDialog() {
355
384
  body.dataset.rendered = '';
356
385
  } else if (d?.error) body.innerHTML = `<p class="error">Couldn't write it: ${esc(d.error)}</p>`;
357
386
  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>';
387
+ else if (specKind && !d?.loaded) body.innerHTML = '<p class="hint">Loading…</p>';
388
+ 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
389
  else if (d?.markdown) {
359
390
  if (body.dataset.rendered !== d.at) {
360
391
  body.innerHTML = d.data ? humanAiHtml(d) : docHtml(d.markdown);
@@ -364,10 +395,13 @@ function renderDocDialog() {
364
395
  }
365
396
  const stale = !plan && d?.markdown && a.brief?.updatedAt && d.briefUpdatedAt !== a.brief.updatedAt;
366
397
  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' : ''}` : '';
398
+ $('#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
399
  for (const id of ['doc-copy', 'doc-download', 'doc-print']) $(`#${id}`).disabled = !d?.markdown;
369
- $('#doc-print').hidden = plan;
370
- $('#doc-regen').hidden = plan;
400
+ // Diagrams: the whole document as one HTML file with every diagram (each also has Copy / PNG / SVG).
401
+ $('#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-print').hidden = plan || specKind;
404
+ $('#doc-regen').hidden = plan || specKind;
371
405
  $('#doc-regen').disabled = Boolean(d?.pending);
372
406
  $('#doc-bg').hidden = !d?.pending;
373
407
  // Footer: send your answers (Human vs AI), or approve / send back the plan.
@@ -388,7 +422,10 @@ function openDoc(agentId, kind) {
388
422
  $('#doc-body').dataset.rendered = '';
389
423
  $('#doc-dialog').showModal();
390
424
  const a = agents.get(agentId);
391
- if (kind !== 'plan' && !a.docs?.[kind]?.markdown && !a.docs?.[kind]?.pending) generateDoc();
425
+ if (isSpecKind(a, kind)) {
426
+ specCache.delete(`${agentId}:${kind}`);
427
+ loadSpec(agentId, kind).then(() => docView?.agentId === agentId && docView.kind === kind && renderDocDialog());
428
+ } else if (kind !== 'plan' && !a.docs?.[kind]?.markdown && !a.docs?.[kind]?.pending) generateDoc();
392
429
  renderDocDialog();
393
430
  }
394
431
 
@@ -398,6 +435,7 @@ function generateDoc() {
398
435
  }
399
436
 
400
437
  $('#doc-close').onclick = () => {
438
+ if (speaking()?.id.startsWith('doc:')) stopSpeaking();
401
439
  docView = null;
402
440
  $('#doc-dialog').close();
403
441
  };
@@ -484,6 +522,25 @@ function planRow(a) {
484
522
  ${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
523
  }
486
524
  $('#doc-print').onclick = () => window.open(`/share.html#${docView.agentId}/${docView.kind}`, '_blank', 'noopener');
525
+ $('#doc-html').onclick = () => {
526
+ const a = agents.get(docView.agentId);
527
+ download(htmlFile($('#doc-body'), `${DOC_TITLES[docView.kind]}: ${a?.name ?? ''}`), `${a?.slug ?? 'agent'}-${docView.kind}.html`);
528
+ };
529
+
530
+ // Diagram export: files named after the agent they belong to.
531
+ initExport({ toast, base: (slot) => (slot.closest('#doc-body') && docView ? agents.get(docView.agentId)?.slug : agents.get(term.agentId)?.slug ?? agents.get(selectedId)?.slug) ?? '' });
532
+
533
+ // Listening: what each Listen control reads.
534
+ bindListen((id) => {
535
+ const [what, agentId, kind] = id.split(':');
536
+ const a = agents.get(agentId);
537
+ let parts = null;
538
+ if (what === 'explain') parts = [speakable(a?.explanation?.markdown)];
539
+ else if (what === 'doc') parts = [speakable(kind === 'explain' ? a?.explanation?.markdown : docOf(a, kind)?.markdown)];
540
+ else if (what === 'walk') parts = reviewUi.listenParts();
541
+ else if (what === 'sidewalk') parts = sidePane?.listenParts();
542
+ if (!parts?.length || !play(id, parts)) toast('Nothing to read aloud here (or this browser has no speech)');
543
+ });
487
544
 
488
545
  /** Why / What / How / Now as four cards. */
489
546
  function insightsBox(a) {
@@ -2120,6 +2177,7 @@ sidePane = initSide({
2120
2177
  codeUi,
2121
2178
  docHtml,
2122
2179
  renderDiagrams,
2180
+ listenHtml,
2123
2181
  mainKind: () => term.kind,
2124
2182
  setMain: (kind) => term.agentId && term.open(term.agentId, kind),
2125
2183
  refit: () => requestAnimationFrame(() => term.refit()),
@@ -2909,6 +2967,10 @@ function upsert(a) {
2909
2967
  /** Views that show one agent's details outside the main render: keep them current. */
2910
2968
  function agentChanged(a) {
2911
2969
  if (a.id === term.agentId) term.renderTabs();
2970
+ // An open intent / spec / plan that the agent just updated: read it again.
2971
+ if (docView?.agentId === a.id && isSpecKind(a, docView.kind) && specCache.get(`${a.id}:${docView.kind}`)?.at !== (a.specDocs?.[docView.kind] ?? null)) {
2972
+ loadSpec(a.id, docView.kind).then(renderDocDialog);
2973
+ }
2912
2974
  reviewUi.agentChanged(a);
2913
2975
  sidePane?.agentChanged(a);
2914
2976
  }
@@ -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/review.js CHANGED
@@ -1,7 +1,8 @@
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';
5
6
 
6
7
  /** Unified diff -> [{ path, status, binary, hunks: [{ head, lines: [{ t, old, new, text }] }] }] */
7
8
  export function parseDiff(text) {
@@ -163,7 +164,7 @@ export function initReview({ api, esc, toast, agents, $ }) {
163
164
  const nav = n
164
165
  ? `<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
166
  : '';
166
- return `${nav}${explainButton(w, 'data-rv-explain')}`;
167
+ 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
168
  }
168
169
 
169
170
  function walkNote() {
@@ -432,6 +433,7 @@ export function initReview({ api, esc, toast, agents, $ }) {
432
433
 
433
434
  $('#review-close').onclick = () => dlg.close();
434
435
  dlg.addEventListener('close', () => {
436
+ if (speaking()?.id.startsWith('walk:')) stopSpeaking();
435
437
  st.id = null;
436
438
  st.note = st.msg = st.prTitle = '';
437
439
  });
@@ -447,5 +449,13 @@ export function initReview({ api, esc, toast, agents, $ }) {
447
449
  render();
448
450
  }
449
451
 
450
- return { open, agentChanged, get openId() { return dlg.open ? st.id : null; } };
452
+ /** What Listen reads: the summary, then each change still in the diff, shown as it is read. */
453
+ function listenParts() {
454
+ const w = walk();
455
+ if (!w.doc) return [];
456
+ const ok = new Set(present(w));
457
+ return walkParts(w.doc.data, (i) => (ok.has(i) ? () => gotoStep(i) : null)).filter((p) => typeof p === 'string' || ok.has(p.i));
458
+ }
459
+
460
+ return { open, agentChanged, listenParts, get openId() { return dlg.open ? st.id : null; } };
451
461
  }
package/public/share.js CHANGED
@@ -24,7 +24,7 @@ async function main() {
24
24
  document.title = (d.markdown.match(/^#\s+(.+)$/m)?.[1] ?? `${kind} · ${a.name}`).slice(0, 120);
25
25
  if (blocks.length) {
26
26
  const mermaid = (await import('/vendor/mermaid/mermaid.esm.min.mjs')).default;
27
- mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'default' });
27
+ mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'default', htmlLabels: false, flowchart: { htmlLabels: false } });
28
28
  for (const el of document.querySelectorAll('.diagram')) {
29
29
  try {
30
30
  el.innerHTML = (await mermaid.render(`d${el.dataset.i}`, blocks[Number(el.dataset.i)])).svg;
package/public/side.js CHANGED
@@ -2,10 +2,13 @@
2
2
  // pane shows the same agent's Code, its Docs (Diagrams by default) or its Changes explained one by
3
3
  // one. On, the tab and the document are remembered in this browser.
4
4
  import { parseDiff } from '/review.js';
5
- import { cardHtml, explainButton, walkthroughOf } from '/walkthrough.js';
5
+ import { cardHtml, explainButton, listenParts as walkParts, walkthroughOf } from '/walkthrough.js';
6
6
 
7
7
  const DOCS = [
8
8
  ['diagrams', 'Diagrams'],
9
+ ['intent', 'Intent'],
10
+ ['spec', 'Spec'],
11
+ ['plan', 'Plan'],
9
12
  ['design', 'Design'],
10
13
  ['flow', 'Flow'],
11
14
  ['analysis', 'Analysis'],
@@ -27,14 +30,23 @@ const pref = {
27
30
  },
28
31
  };
29
32
 
30
- export function initSide({ api, esc, toast, $, agents, codeUi, docHtml, renderDiagrams, mainKind, setMain, refit }) {
33
+ export function initSide({ api, esc, toast, $, agents, codeUi, docHtml, renderDiagrams, listenHtml, mainKind, setMain, refit }) {
31
34
  const pane = $('#side-pane');
32
35
  const body = $('#side-body');
33
36
  const code = $('#code');
34
37
  const codeHome = $('#term-main');
35
38
  const st = { on: pref.get('on', '0') === '1', tab: pref.get('tab', 'docs'), doc: pref.get('doc', 'diagrams'), agentId: null, changes: null, sig: '', loading: false };
36
39
 
37
- const docOf = (a, kind) => (kind === 'explain' ? a?.explanation : a?.docs?.[kind]);
40
+ // Intent, spec and plan are files the daemon reads on request; the rest live on the agent.
41
+ const FILES = new Set(['intent', 'spec', 'plan']);
42
+ const files = new Map(); // "<agent>:<kind>" -> { markdown, at }
43
+ const docOf = (a, kind) => (FILES.has(kind) ? files.get(`${a?.id}:${kind}`) : kind === 'explain' ? a?.explanation : a?.docs?.[kind]);
44
+ async function loadFile(kind) {
45
+ const id = st.agentId;
46
+ const r = await api('GET', `/agents/${id}/specs/${kind}`).catch(() => null);
47
+ files.set(`${id}:${kind}`, { markdown: r?.markdown ?? null, at: r?.updatedAt ?? null, loaded: true });
48
+ if (st.agentId === id && st.doc === kind) renderDocs(true);
49
+ }
38
50
 
39
51
  function show() {
40
52
  const on = st.on && Boolean(st.agentId);
@@ -56,22 +68,37 @@ export function initSide({ api, esc, toast, $, agents, codeUi, docHtml, renderDi
56
68
  // ------------------------------------------------------------ Docs
57
69
  function renderDocs(force) {
58
70
  const a = agents.get(st.agentId);
59
- const d = docOf(a, st.doc);
71
+ let d = docOf(a, st.doc);
72
+ // A file that changed on disk (or was never read): read it, then draw.
73
+ if (FILES.has(st.doc) && (!d || (d.at ?? null) !== (a?.specDocs?.[st.doc] ?? null))) {
74
+ if (!d?.loading) {
75
+ files.set(`${st.agentId}:${st.doc}`, { ...d, loading: true });
76
+ loadFile(st.doc);
77
+ }
78
+ d = d?.loaded ? d : null;
79
+ if (!d) {
80
+ body.innerHTML = '<p class="hint side-pad">Loading…</p>';
81
+ return;
82
+ }
83
+ }
60
84
  const sig = `docs|${st.agentId}|${st.doc}|${d?.at}|${d?.pending}|${d?.error}`;
61
85
  if (!force && sig === st.sig) return;
62
86
  st.sig = sig;
63
87
  const label = DOCS.find(([k]) => k === st.doc)?.[1] ?? st.doc;
64
88
  const picker = `<select data-side-doc>${DOCS.map(([k, l]) => `<option value="${k}" ${k === st.doc ? 'selected' : ''}>${l}${docOf(a, k)?.markdown ? ' ✓' : ''}</option>`).join('')}</select>`;
65
89
  const stale = d?.markdown && a?.brief?.updatedAt && d.briefUpdatedAt !== a.brief.updatedAt;
66
- const action = d?.pending
90
+ const action = FILES.has(st.doc)
91
+ ? ''
92
+ : d?.pending
67
93
  ? `<span class="hint">Writing…</span>`
68
94
  : `<button type="button" class="small ${d?.markdown ? 'ghost' : 'primary'}" data-side-write>${d?.markdown ? 'Again' : st.doc === 'diagrams' ? 'Draw' : 'Write'}</button>`;
69
95
  let content;
70
96
  if (d?.pending && !d?.markdown) content = `<p class="hint">Writing the ${esc(label.toLowerCase())} from the agent's work so far…</p>`;
71
97
  else if (d?.error && !d?.markdown) content = `<p class="error">Couldn't write it: ${esc(d.error)}</p>`;
72
98
  else if (d?.markdown) content = `<div class="md ${st.doc === 'diagrams' ? 'diagrams-view' : ''}">${docHtml(d.markdown)}</div>`;
73
- else content = `<p class="hint">No ${esc(label.toLowerCase())} for this agent yet.</p>`;
74
- body.innerHTML = `<div class="side-tools">${picker}<span class="spacer"></span>${stale ? '<span class="hint">brief changed since</span>' : ''}${action}</div><div class="side-scroll">${content}</div>`;
99
+ else content = `<p class="hint">No ${esc(label.toLowerCase())} for this agent yet.${FILES.has(st.doc) && st.doc !== 'intent' ? ' The agent writes it as it works.' : ''}</p>`;
100
+ const listen = d?.markdown && st.doc !== 'diagrams' ? listenHtml(`doc:${st.agentId}:${st.doc}`, { title: 'Read it aloud' }) : '';
101
+ body.innerHTML = `<div class="side-tools">${picker}<span class="spacer"></span>${stale ? '<span class="hint">brief changed since</span>' : ''}${listen}${action}</div><div class="side-scroll">${content}</div>`;
75
102
  renderDiagrams(body);
76
103
  }
77
104
 
@@ -107,7 +134,7 @@ export function initSide({ api, esc, toast, $, agents, codeUi, docHtml, renderDi
107
134
  const sig = `changes|${st.agentId}|${a?.docs?.walkthrough?.at}|${w.pending}|${w.error}|${st.changes.diffHash}`;
108
135
  if (!force && sig === st.sig) return;
109
136
  st.sig = sig;
110
- const tools = `<div class="side-tools">${explainButton(w, 'data-side-explain')}<span class="spacer"></span><button type="button" class="small ghost" data-side-reload title="Load the changes again">⟳</button></div>`;
137
+ const tools = `<div class="side-tools">${explainButton(w, 'data-side-explain')}${w.doc ? listenHtml(`sidewalk:${st.agentId}`, { title: 'Hear the changes one by one' }) : ''}<span class="spacer"></span><button type="button" class="small ghost" data-side-reload title="Load the changes again">⟳</button></div>`;
111
138
  let content;
112
139
  if (st.changes.error) content = `<p class="error">${esc(st.changes.error)}</p>`;
113
140
  else if (!st.changes.files.length) content = '<p class="hint">No changes yet.</p>';
@@ -115,7 +142,7 @@ export function initSide({ api, esc, toast, $, agents, codeUi, docHtml, renderDi
115
142
  else {
116
143
  const list = w.doc.data.changes;
117
144
  content = `<p class="hint">${esc(w.doc.data.summary)}${w.stale ? ' <b>The changes moved since this was written.</b>' : ''}</p>${list
118
- .map((c, i) => `<section class="sd-step"><div class="sd-file"><code>${esc(c.file)}</code></div>${cardHtml(c, i, list.length, esc)}${hunkHtml(c.file, c.head)}</section>`)
145
+ .map((c, i) => `<section class="sd-step" id="sd-step-${i}"><div class="sd-file"><code>${esc(c.file)}</code></div>${cardHtml(c, i, list.length, esc)}${hunkHtml(c.file, c.head)}</section>`)
119
146
  .join('')}`;
120
147
  }
121
148
  body.innerHTML = `${tools}<div class="side-scroll">${content}</div>`;
@@ -182,6 +209,17 @@ export function initSide({ api, esc, toast, $, agents, codeUi, docHtml, renderDi
182
209
  }
183
210
  },
184
211
  showing: (tab) => st.on && Boolean(st.agentId) && st.tab === tab,
212
+ /** What Listen reads in Changes: each change, scrolled to and marked as it is read. */
213
+ listenParts() {
214
+ const w = walkthroughOf(agents.get(st.agentId), st.changes?.diffHash);
215
+ if (!w.doc) return [];
216
+ return walkParts(w.doc.data, (i) => () => {
217
+ body.querySelectorAll('.sd-step.current').forEach((el) => el.classList.remove('current'));
218
+ const el = body.querySelector(`#sd-step-${i}`);
219
+ el?.classList.add('current');
220
+ el?.scrollIntoView({ block: 'start', behavior: 'smooth' });
221
+ });
222
+ },
185
223
  agentChanged(a) {
186
224
  if (!st.on || a.id !== st.agentId) return;
187
225
  if (st.tab === 'docs') renderDocs(false);
@@ -0,0 +1,171 @@
1
+ // Listening instead of reading: Explain, the documents and the change walkthrough read aloud with
2
+ // the browser's own speech (nothing leaves the device; it works on phones). Text goes in short
3
+ // pieces, since some browsers stop a long utterance partway. One thing plays at a time.
4
+
5
+ const RATES = [0.85, 1, 1.25, 1.5, 1.75];
6
+ const RATE_KEY = 'agentmgr.speechRate';
7
+
8
+ export const canSpeak = () => typeof window !== 'undefined' && 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window;
9
+
10
+ /** Markdown as something to say: no code, diagrams, links' addresses or markup. */
11
+ export function speakable(markdown) {
12
+ return String(markdown ?? '')
13
+ .replace(/```[\s\S]*?```/g, ' ')
14
+ .replace(/`([^`]+)`/g, '$1')
15
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
16
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
17
+ .replace(/https?:\/\/\S+/g, 'a link')
18
+ .replace(/^#{1,6}\s+(.*)$/gm, '$1.')
19
+ .replace(/^\s*[-*+]\s+/gm, '')
20
+ .replace(/^\s*\d+\.\s+/gm, '')
21
+ .replace(/[*_~>|]/g, '')
22
+ .replace(/\.\.+/g, '.')
23
+ .replace(/[ \t]+/g, ' ')
24
+ .trim();
25
+ }
26
+
27
+ /** Text in pieces of a few sentences (under ~220 characters), on paragraph and sentence breaks. */
28
+ export function pieces(text, max = 220) {
29
+ const out = [];
30
+ for (const para of String(text).split(/\n\s*\n|\n/)) {
31
+ const sentences = para.match(/[^.!?]+[.!?]*\s*/g) ?? [];
32
+ let cur = '';
33
+ for (const s of sentences) {
34
+ if ((cur + s).length > max && cur) {
35
+ out.push(cur.trim());
36
+ cur = '';
37
+ }
38
+ cur += s;
39
+ }
40
+ if (cur.trim()) out.push(cur.trim());
41
+ }
42
+ return out.filter((p) => /\w/.test(p));
43
+ }
44
+
45
+ let session = null; // { id, parts: [{ text, onStart }], i, paused, onEnd, onState }
46
+
47
+ function rate() {
48
+ try {
49
+ const r = Number(localStorage.getItem(RATE_KEY));
50
+ return RATES.includes(r) ? r : 1;
51
+ } catch {
52
+ return 1;
53
+ }
54
+ }
55
+
56
+ function playNext() {
57
+ const s = session;
58
+ if (!s) return;
59
+ if (s.i >= s.parts.length) return stop(true);
60
+ const part = s.parts[s.i];
61
+ const u = new SpeechSynthesisUtterance(part.text);
62
+ u.rate = rate();
63
+ u.onstart = () => part.onStart?.();
64
+ u.onend = () => {
65
+ if (session !== s || s.paused) return;
66
+ s.i++;
67
+ playNext();
68
+ };
69
+ u.onerror = (e) => {
70
+ if (session !== s || e.error === 'interrupted' || e.error === 'canceled') return;
71
+ stop(false);
72
+ };
73
+ speechSynthesis.speak(u);
74
+ }
75
+
76
+ /**
77
+ * Play `parts` (strings, or { text, onStart } to follow along, e.g. highlight a change) under `id`.
78
+ * `onState(state)` hears 'playing' | 'paused' | 'stopped'.
79
+ */
80
+ export function play(id, parts, { onState, onEnd } = {}) {
81
+ stop(false);
82
+ if (!canSpeak()) return false;
83
+ const list = parts.flatMap((p) => {
84
+ const text = typeof p === 'string' ? p : p.text;
85
+ const chunks = pieces(text);
86
+ return chunks.map((c, k) => ({ text: c, onStart: k === 0 && typeof p !== 'string' ? p.onStart : null }));
87
+ });
88
+ if (!list.length) return false;
89
+ session = { id, parts: list, i: 0, paused: false, onState, onEnd };
90
+ onState?.('playing');
91
+ notify();
92
+ playNext();
93
+ return true;
94
+ }
95
+
96
+ export function pause() {
97
+ if (!session || session.paused) return;
98
+ session.paused = true;
99
+ // Cancel rather than speechSynthesis.pause(): pausing is unreliable on Chrome and iOS. Resume
100
+ // restarts the piece that was playing.
101
+ speechSynthesis.cancel();
102
+ session.onState?.('paused');
103
+ notify();
104
+ }
105
+
106
+ export function resume() {
107
+ if (!session?.paused) return;
108
+ session.paused = false;
109
+ session.onState?.('playing');
110
+ notify();
111
+ playNext();
112
+ }
113
+
114
+ export function stop(finished = false) {
115
+ const s = session;
116
+ session = null;
117
+ if (canSpeak()) speechSynthesis.cancel();
118
+ if (s) {
119
+ s.onState?.('stopped');
120
+ if (finished) s.onEnd?.();
121
+ notify();
122
+ }
123
+ }
124
+
125
+ /** What is playing: { id, paused } or null. */
126
+ export const now = () => (session ? { id: session.id, paused: session.paused } : null);
127
+
128
+ export function cycleRate() {
129
+ const next = RATES[(RATES.indexOf(rate()) + 1) % RATES.length];
130
+ try {
131
+ localStorage.setItem(RATE_KEY, String(next));
132
+ } catch {}
133
+ notify();
134
+ return next;
135
+ }
136
+ export const rateLabel = () => `${rate()}×`;
137
+
138
+ // Buttons anywhere on the page redraw themselves from this.
139
+ const listeners = new Set();
140
+ export const onChange = (fn) => listeners.add(fn);
141
+ const notify = () => listeners.forEach((fn) => fn(now()));
142
+
143
+ /**
144
+ * A Listen control for `id`: 🔊 Listen, or ⏸ / ▶ with ■ and the speed while that id plays.
145
+ * Clicks are handled by `bindListen`; `data-say` names what to read.
146
+ */
147
+ export function listenHtml(id, { title = 'Read it aloud' } = {}) {
148
+ if (!canSpeak()) return '';
149
+ const cur = now();
150
+ if (cur?.id !== id) return `<span class="say" data-say="${id}"><button type="button" class="small ghost" data-say-act="play" title="${title}">🔊 Listen</button></span>`;
151
+ return `<span class="say on" data-say="${id}"><button type="button" class="small" data-say-act="${cur.paused ? 'resume' : 'pause'}" title="${cur.paused ? 'Resume' : 'Pause'}">${cur.paused ? '▶' : '⏸'}</button><button type="button" class="small ghost" data-say-act="stop" title="Stop">■</button><button type="button" class="small ghost" data-say-act="rate" title="Speed">${rateLabel()}</button></span>`;
152
+ }
153
+
154
+ /** Handle Listen controls: `start(id)` begins playing that id (it knows what to read). */
155
+ export function bindListen(start) {
156
+ document.addEventListener('click', (e) => {
157
+ const b = e.target.closest('[data-say-act]');
158
+ if (!b) return;
159
+ const id = b.closest('[data-say]').dataset.say;
160
+ const act = b.dataset.sayAct;
161
+ if (act === 'play') start(id);
162
+ else if (act === 'pause') pause();
163
+ else if (act === 'resume') resume();
164
+ else if (act === 'stop') stop(false);
165
+ else if (act === 'rate') cycleRate();
166
+ });
167
+ // Keep every visible control in step with what is playing.
168
+ onChange(() => {
169
+ for (const el of document.querySelectorAll('[data-say]')) el.outerHTML = listenHtml(el.dataset.say);
170
+ });
171
+ }
package/public/style.css CHANGED
@@ -894,3 +894,15 @@ form.dropping { outline: 2px dashed var(--accent, #6b8cff); outline-offset: 4px;
894
894
  .menu-theme .seg { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; margin-left: auto; }
895
895
  .menu-theme .seg button { border: 0; border-radius: 0; padding: 3px 9px; font-size: 12px; background: transparent; }
896
896
  .menu-theme .seg button.on { background: var(--accent); color: var(--accent-text); }
897
+
898
+ /* Diagram export buttons: on each drawn diagram, shown on hover (always on touch screens). */
899
+ .diagram { position: relative; }
900
+ .dg-tools { position: absolute; top: 6px; right: 6px; display: flex; gap: 4px; opacity: 0; transition: opacity .15s; z-index: 1; }
901
+ .diagram:hover .dg-tools, .diagram:focus-within .dg-tools { opacity: 1; }
902
+ @media (hover: none) { .dg-tools { opacity: 1; position: static; justify-content: flex-end; margin-bottom: 4px; } }
903
+ .dg-tools button { background: var(--panel); }
904
+ /* Listen controls. */
905
+ .say { display: inline-flex; gap: 4px; align-items: center; vertical-align: middle; }
906
+ .say.on button:first-child { min-width: 30px; }
907
+ .explain-foot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
908
+ .sd-step.current { box-shadow: 0 0 0 2px var(--accent, #6b8cff); }
@@ -34,3 +34,19 @@ export function explainButton(w, attrs = 'data-wt-explain') {
34
34
  return `<button type="button" class="small ${w.doc && !w.stale ? 'ghost' : 'primary'}" ${attrs} ${w.pending ? 'disabled' : ''}
35
35
  title="Explain each change one by one: what, why, the risk and what to check, in the order to read them (a separate one-off call; the agent isn't interrupted)">${label}</button>`;
36
36
  }
37
+
38
+ /** What Listen reads for a walkthrough: the summary, then each change; `follow(i)` runs as change i starts. */
39
+ export function listenParts(data, follow = () => null) {
40
+ const n = data.changes.length;
41
+ return [
42
+ data.summary ? `${data.summary} ${n} change${n === 1 ? '' : 's'} to go through.` : `${n} changes to go through.`,
43
+ ...data.changes.map((c, i) => ({
44
+ i,
45
+ text: c.unexplained ? `Change ${i + 1} of ${n}, in ${spokenFile(c.file)}, has no explanation.` : `Change ${i + 1} of ${n}: ${c.title}. In ${spokenFile(c.file)}. ${c.what} ${c.why && c.why !== 'Not stated' ? `Why: ${c.why}` : ''} Risk: ${c.risk}. ${c.check ? `Check: ${c.check}` : ''}`,
46
+ onStart: follow(i),
47
+ })),
48
+ ];
49
+ }
50
+
51
+ /** "src/server/auth.js" read as "auth.js". */
52
+ const spokenFile = (f) => String(f).split('/').pop();
package/src/brief.js CHANGED
@@ -654,3 +654,82 @@ export function loadPlanFile(agentId, prev) {
654
654
  if (prev && prev.mtimeMs === st.mtimeMs) return null;
655
655
  return { markdown: fs.readFileSync(planPath(agentId), 'utf8').slice(0, 200_000), mtimeMs: st.mtimeMs, updatedAt: st.mtime.toISOString() };
656
656
  }
657
+
658
+ // ------------------------------------------------------------------ intent, spec and plan
659
+
660
+ // Three files per agent, next to the brief, for reviewers (people, other agents, AI review):
661
+ // INTENT.md - what was asked, written by AI Agent Manager when the agent is created
662
+ // SPEC.md - what done means (requirements, acceptance criteria), written by the agent first
663
+ // PLAN.md - how it gets there, step by step, kept by the agent (the plan review uses it too)
664
+ // Push & open PR puts them in the pull request's description.
665
+ export const INTENT_FILE = 'INTENT.md';
666
+ export const SPEC_FILE = 'SPEC.md';
667
+ export const intentPath = (agentId) => path.join(briefDir(agentId), INTENT_FILE);
668
+ export const specPath = (agentId) => path.join(briefDir(agentId), SPEC_FILE);
669
+ export const SPEC_DOCS = { intent: intentPath, spec: specPath, plan: planPath };
670
+
671
+ /** INTENT.md: the request as given, and where it came from. */
672
+ export function intentMarkdown(agent) {
673
+ const by = agent.startedBy ? 'the assistant' : agent.trigger ? (agent.trigger.source === 'github' ? 'a labeled GitHub issue' : 'a webhook') : agent.playbook ? `the playbook ${agent.playbook.name}` : agent.team ? `team ${agent.team.id} (${agent.team.role})` : agent.raceId ? `race ${agent.raceId}` : 'you';
674
+ const out = [`# Intent: ${agent.name}`, ''];
675
+ if (agent.synopsis?.text) out.push(`**${agent.synopsis.source === 'jira' ? `${agent.synopsis.key}: ` : ''}${agent.synopsis.text}**`, '');
676
+ out.push('## Request', '', agent.intent || '(none)', '');
677
+ const ctx = [
678
+ `- Started by: ${by}${agent.trigger?.url ? ` (${agent.trigger.url})` : ''}`,
679
+ agent.repo || agent.requestedDir ? `- Where: ${agent.repo || agent.requestedDir}${agent.base ? ` (from ${agent.base})` : ''}` : null,
680
+ agent.engine ? `- Engine: ${agent.engine}${agent.model ? ` (${agent.model})` : ''}` : null,
681
+ `- Created: ${agent.createdAt}`,
682
+ ].filter(Boolean);
683
+ out.push('## Context', '', ...ctx, '');
684
+ return out.join('\n');
685
+ }
686
+
687
+ /** The agent's instructions for SPEC.md and PLAN.md (the plan only when the plan review isn't on). */
688
+ export const specPrompt = (specFile, planFile, { withPlan = true } = {}) =>
689
+ `Before you change any code, write a short spec of the task to ${specFile} (Markdown), so reviewers (people and other agents) ` +
690
+ 'can check the work against it:\n# Spec: <short name>\n## Requirements\nWhat must be true when it is done, as a list.\n' +
691
+ '## Acceptance criteria\nHow each requirement is checked: a command, a test or an observable behavior.\n## Out of scope\n## Open questions\n' +
692
+ (withPlan
693
+ ? `Then write your plan to ${planFile}: "# Plan: <short name>", then "## Steps" as a task list ("- [ ]" to do, "- [x]" done), ` +
694
+ 'small enough to check, and "## Files to change". Keep both current: tick steps as you finish them, and update the spec ' +
695
+ 'if the requirements change (say why). Do not stop to wait for approval; carry on with the work. '
696
+ : 'Keep it current if the requirements change (say why). ') +
697
+ 'Both files are outside the repository: never copy them into the repo or commit them.';
698
+
699
+ /** One of the three (intent, spec, plan): its Markdown and when it changed, or null. */
700
+ export function readSpecDoc(agentId, name) {
701
+ const file = SPEC_DOCS[name]?.(agentId);
702
+ if (!file) return null;
703
+ try {
704
+ const st = fs.statSync(file);
705
+ return { name, path: file, markdown: fs.readFileSync(file, 'utf8').slice(0, 200_000), updatedAt: st.mtime.toISOString() };
706
+ } catch {
707
+ return null;
708
+ }
709
+ }
710
+
711
+ /** Which of the three exist, and when they changed (for the page's buttons). */
712
+ export function specDocsInfo(agentId) {
713
+ const out = {};
714
+ for (const [name, fn] of Object.entries(SPEC_DOCS)) {
715
+ try {
716
+ out[name] = fs.statSync(fn(agentId)).mtime.toISOString();
717
+ } catch {}
718
+ }
719
+ return out;
720
+ }
721
+
722
+ /** The pull request description's sections: the intent in full, the spec and plan folded. */
723
+ export function prSections(agentId, read = (n) => readSpecDoc(agentId, n)?.markdown) {
724
+ const doc = (n) => read(n)?.trim();
725
+ // Demote the files' own headings under the description's.
726
+ const demote = (md) => md.replace(/^(#{1,4}) /gm, '#$1 ').replace(/^## (Intent|Spec|Plan): .*\n+/m, '');
727
+ const parts = [];
728
+ const intent = doc('intent');
729
+ if (intent) parts.push('## Intent', '', demote(intent), '');
730
+ for (const [name, label] of [['spec', 'Spec'], ['plan', 'Plan']]) {
731
+ const md = doc(name);
732
+ if (md) parts.push(`<details><summary><b>${label}</b></summary>`, '', demote(md), '', '</details>', '');
733
+ }
734
+ return parts.length ? [...parts, '<sub>Intent, spec and plan kept by AI Agent Manager.</sub>'].join('\n') : '';
735
+ }
package/src/manager.js CHANGED
@@ -29,6 +29,12 @@ import {
29
29
  parseJsonReply,
30
30
  loadPlanFile,
31
31
  planApprovedPrompt,
32
+ intentMarkdown,
33
+ intentPath,
34
+ readSpecDoc,
35
+ specDocsInfo,
36
+ specPath,
37
+ specPrompt,
32
38
  planPath,
33
39
  planReviewPrompt,
34
40
  } from './brief.js';
@@ -336,6 +342,8 @@ export class AgentManager extends EventEmitter {
336
342
  const prs = Object.values(agent.prs ?? {});
337
343
  return {
338
344
  ...agent,
345
+ // Intent, spec and plan files that exist, with when each changed (task agents).
346
+ ...(NO_REPO.has(agent.kind) ? {} : { specDocs: specDocsInfo(agent.id) }),
339
347
  results: this.results(agent, rt, prs),
340
348
  priority: agent.priority ?? 2,
341
349
  activeAt: this.activeAt(agent, rt),
@@ -675,6 +683,7 @@ export class AgentManager extends EventEmitter {
675
683
  this.store.put(agent);
676
684
  this.registerProject(agent);
677
685
  this.changed(agent);
686
+ this.writeIntent(agent.id);
678
687
  if (issue) this.fetchSynopsis(agent.id).catch(() => {});
679
688
 
680
689
  this.plugins.emit('agent.created', { agent: this.auditView(agent) });
@@ -701,9 +710,27 @@ export class AgentManager extends EventEmitter {
701
710
  synopsis: { text: summary, source: 'jira', key: issue.key },
702
711
  ...(bug && !bug.title ? { bugs: { ...cur.bugs, [issue.key]: { ...bug, title: summary } } } : {}),
703
712
  });
713
+ this.writeIntent(cur.id);
704
714
  return summary;
705
715
  }
706
716
 
717
+ /** INTENT.md for a task agent: the request as given and where it came from (see brief.js). */
718
+ writeIntent(id) {
719
+ const agent = this.store.get(id);
720
+ if (!agent || NO_REPO.has(agent.kind)) return;
721
+ try {
722
+ fs.mkdirSync(briefDir(id), { recursive: true });
723
+ fs.writeFileSync(intentPath(id), intentMarkdown(agent));
724
+ } catch {}
725
+ }
726
+
727
+ /** One of an agent's intent, spec and plan files. */
728
+ specDoc(ref, name) {
729
+ const agent = this.mustGet(ref);
730
+ if (!['intent', 'spec', 'plan'].includes(name)) throw Object.assign(new Error('Unknown file (intent, spec or plan)'), { status: 400 });
731
+ return readSpecDoc(agent.id, name) ?? { name, markdown: null };
732
+ }
733
+
707
734
  /** Fill in summaries still missing (after Jira settings change); returns how many it found. */
708
735
  async refreshSynopses() {
709
736
  let found = 0;
@@ -983,6 +1010,13 @@ export class AgentManager extends EventEmitter {
983
1010
  prompts.push(briefPrompt(path.join(dir, path.basename(briefPath(agent.id)))));
984
1011
  if (this.config.inbox !== false) prompts.push(questionsPrompt(path.join(dir, path.basename(questionsPath(agent.id)))));
985
1012
  }
1013
+ // SPEC.md before changing code, and PLAN.md kept as it goes (unless the plan review asks for the plan).
1014
+ if (task && this.config.specs !== false) {
1015
+ fs.mkdirSync(briefDir(agent.id), { recursive: true });
1016
+ const dir = fs.realpathSync(briefDir(agent.id));
1017
+ writableDir ??= dir;
1018
+ prompts.push(specPrompt(path.join(dir, path.basename(specPath(agent.id))), path.join(dir, path.basename(planPath(agent.id))), { withPlan: !agent.planReview }));
1019
+ }
986
1020
  if (agent.planReview && task) {
987
1021
  fs.mkdirSync(briefDir(agent.id), { recursive: true });
988
1022
  const file = path.join(fs.realpathSync(briefDir(agent.id)), path.basename(planPath(agent.id)));
@@ -2008,7 +2042,9 @@ export class AgentManager extends EventEmitter {
2008
2042
  .join('\n\n');
2009
2043
  }
2010
2044
  }
2011
- return { intent: agent.intent, status, digest, repo, briefMarkdown };
2045
+ // The spec and plan go along with the brief: what done means, and how it gets there.
2046
+ const extra = ['spec', 'plan'].map((n) => readSpecDoc(agent.id, n)?.markdown.trim()).filter(Boolean);
2047
+ return { intent: agent.intent, status, digest, repo, briefMarkdown: [briefMarkdown, ...extra].filter(Boolean).join('\n\n') };
2012
2048
  }
2013
2049
 
2014
2050
  /** The engine (and model) that writes an agent's summaries: its own when it can make a one-shot call, else Claude Code. */
package/src/review.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // back to the agent as one message, and commit / push / open a pull request from the UI.
3
3
  import * as git from './git.js';
4
4
  import { hash } from './judge.js';
5
+ import { prSections } from './brief.js';
5
6
  import { NO_REPO } from './manager.js';
6
7
  import { ghBin } from './tracker.js';
7
8
 
@@ -193,8 +194,15 @@ export class Review {
193
194
  }
194
195
  const args = ['pr', 'create', '--head', branch];
195
196
  if (target) args.push('--base', target);
196
- if (String(title).trim()) args.push('--title', String(title).trim(), '--body', String(body ?? ''));
197
- else args.push('--fill');
197
+ // The description: your text (else the commits), then the intent, spec and plan.
198
+ const sections = prSections(agent.id);
199
+ if (String(title).trim() || sections) {
200
+ const log = base ? await git.run('git', ['log', '--reverse', '--format=%s', `${base.sha}..HEAD`], opts).catch(() => '') : '';
201
+ const subjects = log.split('\n').filter(Boolean);
202
+ const t = String(title).trim() || subjects[0] || agent.name;
203
+ const lead = String(body ?? '').trim() || (subjects.length > 1 ? subjects.map((x) => `- ${x}`).join('\n') : '');
204
+ args.push('--title', t, '--body', [lead, sections].filter(Boolean).join('\n\n'));
205
+ } else args.push('--fill');
198
206
  if (draft) args.push('--draft');
199
207
  let out;
200
208
  try {
package/src/server.js CHANGED
@@ -240,6 +240,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
240
240
  return manager.document(req.params.id, req.params.kind);
241
241
  }),
242
242
  );
243
+ api.get('/agents/:id/specs/:name', wrap((req) => manager.specDoc(req.params.id, req.params.name)));
243
244
  api.post('/agents/:id/plan', wrap((req) => manager.startPlanning(req.params.id)));
244
245
  api.post('/agents/:id/plan/approve', wrap((req) => manager.approvePlan(req.params.id, req.body?.note)));
245
246
  api.post('/agents/:id/plan/changes', wrap((req) => manager.requestPlanChanges(req.params.id, req.body?.note)));