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 +6 -0
- package/README.md +7 -4
- package/package.json +1 -1
- package/public/app.js +74 -12
- package/public/export.js +110 -0
- package/public/index.html +2 -0
- package/public/review.js +13 -3
- package/public/share.js +1 -1
- package/public/side.js +47 -9
- package/public/speak.js +171 -0
- package/public/style.css +12 -0
- package/public/walkthrough.js +16 -0
- package/src/brief.js +79 -0
- package/src/manager.js +37 -1
- package/src/review.js +10 -2
- package/src/server.js +1 -0
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.
|
|
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.
|
|
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.
|
|
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
|
-
- **
|
|
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
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(
|
|
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
|
-
|
|
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)
|
|
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)}
|
|
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: '
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
$('#doc-
|
|
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 (
|
|
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
|
}
|
package/public/export.js
ADDED
|
@@ -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, '<')}</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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
74
|
-
|
|
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);
|
package/public/speak.js
ADDED
|
@@ -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); }
|
package/public/walkthrough.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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)));
|