ai-agentmanager 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +8 -4
- package/package.json +1 -1
- package/public/app.js +81 -17
- package/public/export.js +110 -0
- package/public/index.html +2 -0
- package/public/land.js +2 -2
- package/public/review.js +67 -5
- package/public/scorecard.js +68 -0
- package/public/share.js +1 -1
- package/public/side.js +47 -9
- package/public/speak.js +171 -0
- package/public/style.css +40 -0
- package/public/walkthrough.js +16 -0
- package/src/brief.js +79 -0
- package/src/cli.js +2 -2
- package/src/manager.js +77 -1
- package/src/review.js +42 -2
- package/src/scorecard.js +189 -0
- package/src/server.js +2 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The review scorecard on the page: an overall readiness, a bar per score with why, the acceptance
|
|
2
|
+
// criteria, and where to look. Shared by Review changes, the Scorecard document and Land's badges.
|
|
3
|
+
|
|
4
|
+
const DIMENSIONS = [
|
|
5
|
+
['intent', 'Intent'],
|
|
6
|
+
['spec', 'Spec'],
|
|
7
|
+
['tests', 'Tests'],
|
|
8
|
+
['safety', 'Safety'],
|
|
9
|
+
['scope', 'Scope'],
|
|
10
|
+
['plan', 'Plan'],
|
|
11
|
+
];
|
|
12
|
+
export const RECOMMENDATION = { ready: 'Ready to approve', look: 'Look at these first', not_ready: 'Not ready' };
|
|
13
|
+
const tone = (n) => (n == null ? '' : n >= 80 ? 'good' : n >= 50 ? 'mid' : 'bad');
|
|
14
|
+
const recTone = { ready: 'good', look: 'mid', not_ready: 'bad' };
|
|
15
|
+
|
|
16
|
+
/** The scorecard of an agent and whether it still matches the changes (diffHash from /changes). */
|
|
17
|
+
export function scorecardOf(agent, diffHash) {
|
|
18
|
+
const d = agent?.docs?.scorecard;
|
|
19
|
+
return { doc: d?.data ? d : null, pending: Boolean(d?.pending), error: d?.error ?? null, stale: Boolean(d?.data && diffHash && d.diffHash !== diffHash) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A small badge: "82 · Ready to approve". */
|
|
23
|
+
export function badgeHtml(doc, esc) {
|
|
24
|
+
if (!doc?.data) return '';
|
|
25
|
+
const sc = doc.data;
|
|
26
|
+
return `<span class="sc-badge ${recTone[sc.recommendation]}" title="Review scorecard: ${esc(RECOMMENDATION[sc.recommendation])}${sc.reasons.length ? ` (${esc(sc.reasons.join('; '))})` : ''}">${sc.overall ?? '—'} · ${esc(RECOMMENDATION[sc.recommendation])}</span>`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The full card. `actions` is extra HTML for the head (buttons); look items carry
|
|
31
|
+
* data-sc-look="<file>\t<head>" for the page to jump to.
|
|
32
|
+
*/
|
|
33
|
+
export function scorecardHtml(doc, esc, { stale = false, actions = '' } = {}) {
|
|
34
|
+
const sc = doc.data;
|
|
35
|
+
const rows = DIMENSIONS.map(([k, label]) => {
|
|
36
|
+
const d = sc.scores[k];
|
|
37
|
+
if (!d) return `<div class="sc-row na"><span class="sc-label">${label}</span><span class="sc-bar"></span><span class="sc-n">—</span><span class="sc-why hint">${k === 'spec' ? 'No spec written' : 'Not scored'}</span></div>`;
|
|
38
|
+
return `<div class="sc-row"><span class="sc-label">${label}</span><span class="sc-bar"><span class="${tone(d.score)}" style="width:${d.score}%"></span></span><span class="sc-n">${d.score}</span><span class="sc-why">${esc(d.why)}${d.notes.length ? ` <span class="sc-note">${esc(d.notes.join('; '))}</span>` : ''}</span></div>`;
|
|
39
|
+
}).join('');
|
|
40
|
+
const crit = sc.criteria.length
|
|
41
|
+
? `<details class="sc-crit" ${sc.criteria.some((c) => c.status !== 'met') ? 'open' : ''}><summary>Acceptance criteria: ${sc.criteria.filter((c) => c.status === 'met').length} of ${sc.criteria.length} met</summary><ul>${sc.criteria.map((c) => `<li class="${c.status}"><span class="sc-mark">${c.status === 'met' ? '✓' : c.status === 'not_met' ? '✗' : '?'}</span>${esc(c.criterion)}${c.evidence ? ` <span class="hint">${esc(c.evidence)}</span>` : ''}</li>`).join('')}</ul></details>`
|
|
42
|
+
: '';
|
|
43
|
+
const look = sc.look.length
|
|
44
|
+
? `<div class="sc-look"><b>Where to look</b><ol>${sc.look.map((l) => `<li><button type="button" class="link" data-sc-look="${esc(`${l.file}\t${l.head}`)}"><code>${esc(l.file)}</code></button> ${esc(l.reason)}</li>`).join('')}</ol></div>`
|
|
45
|
+
: '';
|
|
46
|
+
const f = sc.facts;
|
|
47
|
+
return `<div class="scorecard">
|
|
48
|
+
<div class="sc-head">
|
|
49
|
+
<span class="sc-overall ${recTone[sc.recommendation]}"><b>${sc.overall ?? '—'}</b><small>/100</small></span>
|
|
50
|
+
<div class="sc-verdict"><b>${esc(RECOMMENDATION[sc.recommendation])}</b>${sc.reasons.length ? `<div class="hint">${esc(sc.reasons.join(' · '))}</div>` : ''}<div class="hint">${esc(sc.summary)}</div></div>
|
|
51
|
+
<span class="spacer"></span>${actions}
|
|
52
|
+
</div>
|
|
53
|
+
${stale ? '<p class="hint sc-stale"><b>The changes moved since this was scored.</b> Score again for the latest.</p>' : ''}
|
|
54
|
+
<div class="sc-rows">${rows}</div>
|
|
55
|
+
${crit}${look}
|
|
56
|
+
<div class="hint sc-facts">Measured: ${f.files} files (+${f.added} −${f.removed}) · ${f.testFiles} test files · CI ${esc(f.ci ?? 'n/a')} · checks ${f.checks.passed}✓ ${f.checks.failed}✗${f.plan ? ` · plan ${f.plan.done}/${f.plan.steps}` : ''}${doc.writer ? ` · scored by ${esc(doc.writer)}` : ''}</div>
|
|
57
|
+
</div>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What Listen reads: the verdict, then each score with why. */
|
|
61
|
+
export function scorecardSpeech(doc) {
|
|
62
|
+
const sc = doc.data;
|
|
63
|
+
return [
|
|
64
|
+
`${RECOMMENDATION[sc.recommendation]}. Overall ${sc.overall ?? 'not scored'} out of 100. ${sc.reasons.join('. ')} ${sc.summary}`,
|
|
65
|
+
...DIMENSIONS.filter(([k]) => sc.scores[k]).map(([k, label]) => `${label}: ${sc.scores[k].score}. ${sc.scores[k].why} ${sc.scores[k].notes.join('. ')}`),
|
|
66
|
+
...(sc.look.length ? [`Where to look: ${sc.look.map((l) => `${l.file.split('/').pop()}: ${l.reason}`).join('. ')}`] : []),
|
|
67
|
+
];
|
|
68
|
+
}
|
package/public/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,43 @@ 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); }
|
|
909
|
+
|
|
910
|
+
/* Review scorecard. */
|
|
911
|
+
.scorecard { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); }
|
|
912
|
+
.sc-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
913
|
+
.sc-overall { display: flex; align-items: baseline; gap: 2px; padding: 4px 10px; border-radius: 8px; border: 2px solid currentColor; }
|
|
914
|
+
.sc-overall b { font-size: 26px; line-height: 1; }
|
|
915
|
+
.sc-overall small { opacity: .7; }
|
|
916
|
+
.sc-verdict { min-width: 0; flex: 1 1 260px; }
|
|
917
|
+
.good { color: var(--green); } .mid { color: var(--yellow); } .bad { color: var(--red); }
|
|
918
|
+
.sc-rows { display: flex; flex-direction: column; gap: 4px; }
|
|
919
|
+
.sc-row { display: grid; grid-template-columns: 64px 140px 32px 1fr; gap: 8px; align-items: center; font-size: 13px; }
|
|
920
|
+
.sc-label { font-weight: 600; }
|
|
921
|
+
.sc-bar { height: 8px; border-radius: 4px; background: var(--panel-2); overflow: hidden; }
|
|
922
|
+
.sc-bar > span { display: block; height: 100%; background: currentColor; }
|
|
923
|
+
.sc-n { text-align: right; font-variant-numeric: tabular-nums; }
|
|
924
|
+
.sc-note { color: var(--yellow); }
|
|
925
|
+
.sc-row.na { opacity: .6; }
|
|
926
|
+
.sc-crit ul, .sc-look ol { margin: 4px 0 0; padding-left: 20px; }
|
|
927
|
+
.sc-crit li { list-style: none; margin-left: -16px; }
|
|
928
|
+
.sc-mark { display: inline-block; width: 16px; font-weight: 700; }
|
|
929
|
+
.sc-crit li.met .sc-mark { color: var(--green); } .sc-crit li.not_met .sc-mark { color: var(--red); } .sc-crit li.unverified .sc-mark { color: var(--yellow); }
|
|
930
|
+
.sc-facts { font-size: 11.5px; }
|
|
931
|
+
.sc-badge { display: inline-block; padding: 1px 8px; border-radius: 999px; border: 1px solid currentColor; font-size: 12px; white-space: nowrap; }
|
|
932
|
+
.rv-score { margin: 0 12px 8px; }
|
|
933
|
+
.rv-score-empty { display: flex; align-items: center; gap: 10px; margin: 0 12px 8px; }
|
|
934
|
+
.rv-hunk.flash td { animation: rvflash 1.6s; }
|
|
935
|
+
@keyframes rvflash { 0%, 60% { background: var(--accent); color: var(--accent-text); } }
|
|
936
|
+
@media (max-width: 640px) { .sc-row { grid-template-columns: 56px 1fr 28px; } .sc-why { grid-column: 1 / -1; } }
|
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/cli.js
CHANGED
|
@@ -430,9 +430,9 @@ export async function main(argv) {
|
|
|
430
430
|
|
|
431
431
|
program
|
|
432
432
|
.command('doc')
|
|
433
|
-
.description('Write a stakeholder document from the agent work: analysis, design, flow, proposal, humanai (Human vs AI), diagrams (diagrams only)
|
|
433
|
+
.description('Write a stakeholder document from the agent work: analysis, design, flow, proposal, humanai (Human vs AI), diagrams (diagrams only), walkthrough (its changes explained one by one) or scorecard (its changes scored for review)')
|
|
434
434
|
.argument('<agent>')
|
|
435
|
-
.argument('<kind>', 'analysis, design, flow, proposal, humanai, diagrams, walkthrough, or all (background; diagrams and
|
|
435
|
+
.argument('<kind>', 'analysis, design, flow, proposal, humanai, diagrams, walkthrough, scorecard, or all (background; diagrams, walkthrough and scorecard not included)')
|
|
436
436
|
.option('-o, --output <file>', 'also save the Markdown to this file')
|
|
437
437
|
.option('--cached', 'print the last version instead of writing a new one')
|
|
438
438
|
.option('-b, --background', 'start writing and return at once (kind "all" writes all five)')
|