ai-agentmanager 1.7.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/public/review.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // Review screen: an agent's changes file by file, line comments sent back to the agent as one
2
2
  // message, and Commit / Push & open PR. Comments not sent yet are kept per agent in this browser.
3
3
  // With a walkthrough, each hunk shows its explanation and ◀ / ▶ step through them in reading order.
4
- import { cardHtml, explainButton, stepOf, walkthroughOf } from './walkthrough.js';
4
+ import { cardHtml, explainButton, listenParts as walkParts, stepOf, walkthroughOf } from './walkthrough.js';
5
+ import { listenHtml, now as speaking, stop as stopSpeaking } from './speak.js';
5
6
 
6
7
  /** Unified diff -> [{ path, status, binary, hunks: [{ head, lines: [{ t, old, new, text }] }] }] */
7
8
  export function parseDiff(text) {
@@ -163,7 +164,7 @@ export function initReview({ api, esc, toast, agents, $ }) {
163
164
  const nav = n
164
165
  ? `<span class="rv-walk"><button type="button" class="small ghost" data-rv-step="-1" title="Previous change ([)">◀</button><span class="hint">${st.step == null ? `${n} changes` : `${st.step + 1} of ${n}`}</span><button type="button" class="small ghost" data-rv-step="1" title="Next change (])">▶</button></span>`
165
166
  : '';
166
- return `${nav}${explainButton(w, 'data-rv-explain')}`;
167
+ return `${nav}${n ? listenHtml(`walk:${st.id}`, { title: 'Hear the changes one by one; each is shown as it is read' }) : ''}${explainButton(w, 'data-rv-explain')}`;
167
168
  }
168
169
 
169
170
  function walkNote() {
@@ -432,6 +433,7 @@ export function initReview({ api, esc, toast, agents, $ }) {
432
433
 
433
434
  $('#review-close').onclick = () => dlg.close();
434
435
  dlg.addEventListener('close', () => {
436
+ if (speaking()?.id.startsWith('walk:')) stopSpeaking();
435
437
  st.id = null;
436
438
  st.note = st.msg = st.prTitle = '';
437
439
  });
@@ -447,5 +449,13 @@ export function initReview({ api, esc, toast, agents, $ }) {
447
449
  render();
448
450
  }
449
451
 
450
- return { open, agentChanged, get openId() { return dlg.open ? st.id : null; } };
452
+ /** What Listen reads: the summary, then each change still in the diff, shown as it is read. */
453
+ function listenParts() {
454
+ const w = walk();
455
+ if (!w.doc) return [];
456
+ const ok = new Set(present(w));
457
+ return walkParts(w.doc.data, (i) => (ok.has(i) ? () => gotoStep(i) : null)).filter((p) => typeof p === 'string' || ok.has(p.i));
458
+ }
459
+
460
+ return { open, agentChanged, listenParts, get openId() { return dlg.open ? st.id : null; } };
451
461
  }
package/public/share.js CHANGED
@@ -24,7 +24,7 @@ async function main() {
24
24
  document.title = (d.markdown.match(/^#\s+(.+)$/m)?.[1] ?? `${kind} · ${a.name}`).slice(0, 120);
25
25
  if (blocks.length) {
26
26
  const mermaid = (await import('/vendor/mermaid/mermaid.esm.min.mjs')).default;
27
- mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'default' });
27
+ mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'default', htmlLabels: false, flowchart: { htmlLabels: false } });
28
28
  for (const el of document.querySelectorAll('.diagram')) {
29
29
  try {
30
30
  el.innerHTML = (await mermaid.render(`d${el.dataset.i}`, blocks[Number(el.dataset.i)])).svg;
package/public/side.js CHANGED
@@ -2,10 +2,13 @@
2
2
  // pane shows the same agent's Code, its Docs (Diagrams by default) or its Changes explained one by
3
3
  // one. On, the tab and the document are remembered in this browser.
4
4
  import { parseDiff } from '/review.js';
5
- import { cardHtml, explainButton, walkthroughOf } from '/walkthrough.js';
5
+ import { cardHtml, explainButton, listenParts as walkParts, walkthroughOf } from '/walkthrough.js';
6
6
 
7
7
  const DOCS = [
8
8
  ['diagrams', 'Diagrams'],
9
+ ['intent', 'Intent'],
10
+ ['spec', 'Spec'],
11
+ ['plan', 'Plan'],
9
12
  ['design', 'Design'],
10
13
  ['flow', 'Flow'],
11
14
  ['analysis', 'Analysis'],
@@ -27,14 +30,23 @@ const pref = {
27
30
  },
28
31
  };
29
32
 
30
- export function initSide({ api, esc, toast, $, agents, codeUi, docHtml, renderDiagrams, mainKind, setMain, refit }) {
33
+ export function initSide({ api, esc, toast, $, agents, codeUi, docHtml, renderDiagrams, listenHtml, mainKind, setMain, refit }) {
31
34
  const pane = $('#side-pane');
32
35
  const body = $('#side-body');
33
36
  const code = $('#code');
34
37
  const codeHome = $('#term-main');
35
38
  const st = { on: pref.get('on', '0') === '1', tab: pref.get('tab', 'docs'), doc: pref.get('doc', 'diagrams'), agentId: null, changes: null, sig: '', loading: false };
36
39
 
37
- const docOf = (a, kind) => (kind === 'explain' ? a?.explanation : a?.docs?.[kind]);
40
+ // Intent, spec and plan are files the daemon reads on request; the rest live on the agent.
41
+ const FILES = new Set(['intent', 'spec', 'plan']);
42
+ const files = new Map(); // "<agent>:<kind>" -> { markdown, at }
43
+ const docOf = (a, kind) => (FILES.has(kind) ? files.get(`${a?.id}:${kind}`) : kind === 'explain' ? a?.explanation : a?.docs?.[kind]);
44
+ async function loadFile(kind) {
45
+ const id = st.agentId;
46
+ const r = await api('GET', `/agents/${id}/specs/${kind}`).catch(() => null);
47
+ files.set(`${id}:${kind}`, { markdown: r?.markdown ?? null, at: r?.updatedAt ?? null, loaded: true });
48
+ if (st.agentId === id && st.doc === kind) renderDocs(true);
49
+ }
38
50
 
39
51
  function show() {
40
52
  const on = st.on && Boolean(st.agentId);
@@ -56,22 +68,37 @@ export function initSide({ api, esc, toast, $, agents, codeUi, docHtml, renderDi
56
68
  // ------------------------------------------------------------ Docs
57
69
  function renderDocs(force) {
58
70
  const a = agents.get(st.agentId);
59
- const d = docOf(a, st.doc);
71
+ let d = docOf(a, st.doc);
72
+ // A file that changed on disk (or was never read): read it, then draw.
73
+ if (FILES.has(st.doc) && (!d || (d.at ?? null) !== (a?.specDocs?.[st.doc] ?? null))) {
74
+ if (!d?.loading) {
75
+ files.set(`${st.agentId}:${st.doc}`, { ...d, loading: true });
76
+ loadFile(st.doc);
77
+ }
78
+ d = d?.loaded ? d : null;
79
+ if (!d) {
80
+ body.innerHTML = '<p class="hint side-pad">Loading…</p>';
81
+ return;
82
+ }
83
+ }
60
84
  const sig = `docs|${st.agentId}|${st.doc}|${d?.at}|${d?.pending}|${d?.error}`;
61
85
  if (!force && sig === st.sig) return;
62
86
  st.sig = sig;
63
87
  const label = DOCS.find(([k]) => k === st.doc)?.[1] ?? st.doc;
64
88
  const picker = `<select data-side-doc>${DOCS.map(([k, l]) => `<option value="${k}" ${k === st.doc ? 'selected' : ''}>${l}${docOf(a, k)?.markdown ? ' ✓' : ''}</option>`).join('')}</select>`;
65
89
  const stale = d?.markdown && a?.brief?.updatedAt && d.briefUpdatedAt !== a.brief.updatedAt;
66
- const action = d?.pending
90
+ const action = FILES.has(st.doc)
91
+ ? ''
92
+ : d?.pending
67
93
  ? `<span class="hint">Writing…</span>`
68
94
  : `<button type="button" class="small ${d?.markdown ? 'ghost' : 'primary'}" data-side-write>${d?.markdown ? 'Again' : st.doc === 'diagrams' ? 'Draw' : 'Write'}</button>`;
69
95
  let content;
70
96
  if (d?.pending && !d?.markdown) content = `<p class="hint">Writing the ${esc(label.toLowerCase())} from the agent's work so far…</p>`;
71
97
  else if (d?.error && !d?.markdown) content = `<p class="error">Couldn't write it: ${esc(d.error)}</p>`;
72
98
  else if (d?.markdown) content = `<div class="md ${st.doc === 'diagrams' ? 'diagrams-view' : ''}">${docHtml(d.markdown)}</div>`;
73
- else content = `<p class="hint">No ${esc(label.toLowerCase())} for this agent yet.</p>`;
74
- body.innerHTML = `<div class="side-tools">${picker}<span class="spacer"></span>${stale ? '<span class="hint">brief changed since</span>' : ''}${action}</div><div class="side-scroll">${content}</div>`;
99
+ else content = `<p class="hint">No ${esc(label.toLowerCase())} for this agent yet.${FILES.has(st.doc) && st.doc !== 'intent' ? ' The agent writes it as it works.' : ''}</p>`;
100
+ const listen = d?.markdown && st.doc !== 'diagrams' ? listenHtml(`doc:${st.agentId}:${st.doc}`, { title: 'Read it aloud' }) : '';
101
+ body.innerHTML = `<div class="side-tools">${picker}<span class="spacer"></span>${stale ? '<span class="hint">brief changed since</span>' : ''}${listen}${action}</div><div class="side-scroll">${content}</div>`;
75
102
  renderDiagrams(body);
76
103
  }
77
104
 
@@ -107,7 +134,7 @@ export function initSide({ api, esc, toast, $, agents, codeUi, docHtml, renderDi
107
134
  const sig = `changes|${st.agentId}|${a?.docs?.walkthrough?.at}|${w.pending}|${w.error}|${st.changes.diffHash}`;
108
135
  if (!force && sig === st.sig) return;
109
136
  st.sig = sig;
110
- const tools = `<div class="side-tools">${explainButton(w, 'data-side-explain')}<span class="spacer"></span><button type="button" class="small ghost" data-side-reload title="Load the changes again">⟳</button></div>`;
137
+ const tools = `<div class="side-tools">${explainButton(w, 'data-side-explain')}${w.doc ? listenHtml(`sidewalk:${st.agentId}`, { title: 'Hear the changes one by one' }) : ''}<span class="spacer"></span><button type="button" class="small ghost" data-side-reload title="Load the changes again">⟳</button></div>`;
111
138
  let content;
112
139
  if (st.changes.error) content = `<p class="error">${esc(st.changes.error)}</p>`;
113
140
  else if (!st.changes.files.length) content = '<p class="hint">No changes yet.</p>';
@@ -115,7 +142,7 @@ export function initSide({ api, esc, toast, $, agents, codeUi, docHtml, renderDi
115
142
  else {
116
143
  const list = w.doc.data.changes;
117
144
  content = `<p class="hint">${esc(w.doc.data.summary)}${w.stale ? ' <b>The changes moved since this was written.</b>' : ''}</p>${list
118
- .map((c, i) => `<section class="sd-step"><div class="sd-file"><code>${esc(c.file)}</code></div>${cardHtml(c, i, list.length, esc)}${hunkHtml(c.file, c.head)}</section>`)
145
+ .map((c, i) => `<section class="sd-step" id="sd-step-${i}"><div class="sd-file"><code>${esc(c.file)}</code></div>${cardHtml(c, i, list.length, esc)}${hunkHtml(c.file, c.head)}</section>`)
119
146
  .join('')}`;
120
147
  }
121
148
  body.innerHTML = `${tools}<div class="side-scroll">${content}</div>`;
@@ -182,6 +209,17 @@ export function initSide({ api, esc, toast, $, agents, codeUi, docHtml, renderDi
182
209
  }
183
210
  },
184
211
  showing: (tab) => st.on && Boolean(st.agentId) && st.tab === tab,
212
+ /** What Listen reads in Changes: each change, scrolled to and marked as it is read. */
213
+ listenParts() {
214
+ const w = walkthroughOf(agents.get(st.agentId), st.changes?.diffHash);
215
+ if (!w.doc) return [];
216
+ return walkParts(w.doc.data, (i) => () => {
217
+ body.querySelectorAll('.sd-step.current').forEach((el) => el.classList.remove('current'));
218
+ const el = body.querySelector(`#sd-step-${i}`);
219
+ el?.classList.add('current');
220
+ el?.scrollIntoView({ block: 'start', behavior: 'smooth' });
221
+ });
222
+ },
185
223
  agentChanged(a) {
186
224
  if (!st.on || a.id !== st.agentId) return;
187
225
  if (st.tab === 'docs') renderDocs(false);
@@ -0,0 +1,171 @@
1
+ // Listening instead of reading: Explain, the documents and the change walkthrough read aloud with
2
+ // the browser's own speech (nothing leaves the device; it works on phones). Text goes in short
3
+ // pieces, since some browsers stop a long utterance partway. One thing plays at a time.
4
+
5
+ const RATES = [0.85, 1, 1.25, 1.5, 1.75];
6
+ const RATE_KEY = 'agentmgr.speechRate';
7
+
8
+ export const canSpeak = () => typeof window !== 'undefined' && 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window;
9
+
10
+ /** Markdown as something to say: no code, diagrams, links' addresses or markup. */
11
+ export function speakable(markdown) {
12
+ return String(markdown ?? '')
13
+ .replace(/```[\s\S]*?```/g, ' ')
14
+ .replace(/`([^`]+)`/g, '$1')
15
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
16
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
17
+ .replace(/https?:\/\/\S+/g, 'a link')
18
+ .replace(/^#{1,6}\s+(.*)$/gm, '$1.')
19
+ .replace(/^\s*[-*+]\s+/gm, '')
20
+ .replace(/^\s*\d+\.\s+/gm, '')
21
+ .replace(/[*_~>|]/g, '')
22
+ .replace(/\.\.+/g, '.')
23
+ .replace(/[ \t]+/g, ' ')
24
+ .trim();
25
+ }
26
+
27
+ /** Text in pieces of a few sentences (under ~220 characters), on paragraph and sentence breaks. */
28
+ export function pieces(text, max = 220) {
29
+ const out = [];
30
+ for (const para of String(text).split(/\n\s*\n|\n/)) {
31
+ const sentences = para.match(/[^.!?]+[.!?]*\s*/g) ?? [];
32
+ let cur = '';
33
+ for (const s of sentences) {
34
+ if ((cur + s).length > max && cur) {
35
+ out.push(cur.trim());
36
+ cur = '';
37
+ }
38
+ cur += s;
39
+ }
40
+ if (cur.trim()) out.push(cur.trim());
41
+ }
42
+ return out.filter((p) => /\w/.test(p));
43
+ }
44
+
45
+ let session = null; // { id, parts: [{ text, onStart }], i, paused, onEnd, onState }
46
+
47
+ function rate() {
48
+ try {
49
+ const r = Number(localStorage.getItem(RATE_KEY));
50
+ return RATES.includes(r) ? r : 1;
51
+ } catch {
52
+ return 1;
53
+ }
54
+ }
55
+
56
+ function playNext() {
57
+ const s = session;
58
+ if (!s) return;
59
+ if (s.i >= s.parts.length) return stop(true);
60
+ const part = s.parts[s.i];
61
+ const u = new SpeechSynthesisUtterance(part.text);
62
+ u.rate = rate();
63
+ u.onstart = () => part.onStart?.();
64
+ u.onend = () => {
65
+ if (session !== s || s.paused) return;
66
+ s.i++;
67
+ playNext();
68
+ };
69
+ u.onerror = (e) => {
70
+ if (session !== s || e.error === 'interrupted' || e.error === 'canceled') return;
71
+ stop(false);
72
+ };
73
+ speechSynthesis.speak(u);
74
+ }
75
+
76
+ /**
77
+ * Play `parts` (strings, or { text, onStart } to follow along, e.g. highlight a change) under `id`.
78
+ * `onState(state)` hears 'playing' | 'paused' | 'stopped'.
79
+ */
80
+ export function play(id, parts, { onState, onEnd } = {}) {
81
+ stop(false);
82
+ if (!canSpeak()) return false;
83
+ const list = parts.flatMap((p) => {
84
+ const text = typeof p === 'string' ? p : p.text;
85
+ const chunks = pieces(text);
86
+ return chunks.map((c, k) => ({ text: c, onStart: k === 0 && typeof p !== 'string' ? p.onStart : null }));
87
+ });
88
+ if (!list.length) return false;
89
+ session = { id, parts: list, i: 0, paused: false, onState, onEnd };
90
+ onState?.('playing');
91
+ notify();
92
+ playNext();
93
+ return true;
94
+ }
95
+
96
+ export function pause() {
97
+ if (!session || session.paused) return;
98
+ session.paused = true;
99
+ // Cancel rather than speechSynthesis.pause(): pausing is unreliable on Chrome and iOS. Resume
100
+ // restarts the piece that was playing.
101
+ speechSynthesis.cancel();
102
+ session.onState?.('paused');
103
+ notify();
104
+ }
105
+
106
+ export function resume() {
107
+ if (!session?.paused) return;
108
+ session.paused = false;
109
+ session.onState?.('playing');
110
+ notify();
111
+ playNext();
112
+ }
113
+
114
+ export function stop(finished = false) {
115
+ const s = session;
116
+ session = null;
117
+ if (canSpeak()) speechSynthesis.cancel();
118
+ if (s) {
119
+ s.onState?.('stopped');
120
+ if (finished) s.onEnd?.();
121
+ notify();
122
+ }
123
+ }
124
+
125
+ /** What is playing: { id, paused } or null. */
126
+ export const now = () => (session ? { id: session.id, paused: session.paused } : null);
127
+
128
+ export function cycleRate() {
129
+ const next = RATES[(RATES.indexOf(rate()) + 1) % RATES.length];
130
+ try {
131
+ localStorage.setItem(RATE_KEY, String(next));
132
+ } catch {}
133
+ notify();
134
+ return next;
135
+ }
136
+ export const rateLabel = () => `${rate()}×`;
137
+
138
+ // Buttons anywhere on the page redraw themselves from this.
139
+ const listeners = new Set();
140
+ export const onChange = (fn) => listeners.add(fn);
141
+ const notify = () => listeners.forEach((fn) => fn(now()));
142
+
143
+ /**
144
+ * A Listen control for `id`: 🔊 Listen, or ⏸ / ▶ with ■ and the speed while that id plays.
145
+ * Clicks are handled by `bindListen`; `data-say` names what to read.
146
+ */
147
+ export function listenHtml(id, { title = 'Read it aloud' } = {}) {
148
+ if (!canSpeak()) return '';
149
+ const cur = now();
150
+ if (cur?.id !== id) return `<span class="say" data-say="${id}"><button type="button" class="small ghost" data-say-act="play" title="${title}">🔊 Listen</button></span>`;
151
+ return `<span class="say on" data-say="${id}"><button type="button" class="small" data-say-act="${cur.paused ? 'resume' : 'pause'}" title="${cur.paused ? 'Resume' : 'Pause'}">${cur.paused ? '▶' : '⏸'}</button><button type="button" class="small ghost" data-say-act="stop" title="Stop">■</button><button type="button" class="small ghost" data-say-act="rate" title="Speed">${rateLabel()}</button></span>`;
152
+ }
153
+
154
+ /** Handle Listen controls: `start(id)` begins playing that id (it knows what to read). */
155
+ export function bindListen(start) {
156
+ document.addEventListener('click', (e) => {
157
+ const b = e.target.closest('[data-say-act]');
158
+ if (!b) return;
159
+ const id = b.closest('[data-say]').dataset.say;
160
+ const act = b.dataset.sayAct;
161
+ if (act === 'play') start(id);
162
+ else if (act === 'pause') pause();
163
+ else if (act === 'resume') resume();
164
+ else if (act === 'stop') stop(false);
165
+ else if (act === 'rate') cycleRate();
166
+ });
167
+ // Keep every visible control in step with what is playing.
168
+ onChange(() => {
169
+ for (const el of document.querySelectorAll('[data-say]')) el.outerHTML = listenHtml(el.dataset.say);
170
+ });
171
+ }
package/public/style.css CHANGED
@@ -15,8 +15,9 @@
15
15
  --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
16
16
  color-scheme: light;
17
17
  }
18
+ /* Dark: the system's setting unless the page is set to Light, or always when set to Dark (☰ menu). */
18
19
  @media (prefers-color-scheme: dark) {
19
- :root {
20
+ :root:not([data-theme="light"]) {
20
21
  --bg: #0d0f14;
21
22
  --panel: #151821;
22
23
  --panel-2: #1c202b;
@@ -31,6 +32,20 @@
31
32
  color-scheme: dark;
32
33
  }
33
34
  }
35
+ :root[data-theme="dark"] {
36
+ --bg: #0d0f14;
37
+ --panel: #151821;
38
+ --panel-2: #1c202b;
39
+ --border: #2a2f3c;
40
+ --text: #e5e8ef;
41
+ --muted: #8b93a7;
42
+ --accent: #6d8bff;
43
+ --accent-text: #0d0f14;
44
+ --green: #3ecf8e;
45
+ --yellow: #e6b450;
46
+ --red: #ff6b6b;
47
+ color-scheme: dark;
48
+ }
34
49
 
35
50
  * { box-sizing: border-box; }
36
51
  [hidden] { display: none !important; }
@@ -873,3 +888,21 @@ form.dropping { outline: 2px dashed var(--accent, #6b8cff); outline-offset: 4px;
873
888
  .sd-add { background: rgba(46, 160, 67, .15); }
874
889
  .sd-del { background: rgba(248, 81, 73, .15); }
875
890
  #term-split-btn.on { background: #262c38; }
891
+
892
+ /* Theme switch in the menu: Auto / Light / Dark. */
893
+ .menu-theme { display: flex; align-items: center; gap: 6px; padding: 6px 12px; }
894
+ .menu-theme .seg { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; margin-left: auto; }
895
+ .menu-theme .seg button { border: 0; border-radius: 0; padding: 3px 9px; font-size: 12px; background: transparent; }
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/update.js CHANGED
@@ -1,9 +1,9 @@
1
1
  // Updating from the app: a button in the top bar when a newer release is out, and "Update and
2
2
  // restart" in About. The daemon installs it (git checkout of the release tag, or npm install -g)
3
- // and restarts onto it; this page then waits for it to come back and reloads.
3
+ // and restarts onto it; this page then waits for it to come back and reloads. It works the same
4
+ // from a phone over remote access: the install runs on the computer.
4
5
 
5
6
  export function initUpdate({ api, esc, toast, $ }) {
6
- const remote = () => document.body.classList.contains('remote');
7
7
  let state = null;
8
8
 
9
9
  function show(s) {
@@ -20,7 +20,6 @@ export function initUpdate({ api, esc, toast, $ }) {
20
20
  }
21
21
 
22
22
  async function load() {
23
- if (remote()) return;
24
23
  try {
25
24
  show(await api('GET', '/update'));
26
25
  } catch {
@@ -62,9 +61,8 @@ export function initUpdate({ api, esc, toast, $ }) {
62
61
  }
63
62
  }
64
63
 
65
- /** "Check for updates" in About: the daemon's check where it can update itself. */
64
+ /** "Check for updates" in About: the daemon's check, and whether it can update itself. */
66
65
  async function check() {
67
- if (remote()) return null;
68
66
  const s = await api('POST', '/update/check');
69
67
  show(s);
70
68
  if (!s.newer) return `You have the newest release (${esc(s.current)}).`;
@@ -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/jira.js ADDED
@@ -0,0 +1,100 @@
1
+ // The summary of the Jira issue a request starts with, so an agent created from a link shows what
2
+ // the issue is about instead of the link. Fetched from Jira Cloud's REST API in the background;
3
+ // when it can't be fetched, the words after the link stand in for it.
4
+ //
5
+ // Only Atlassian Cloud sites (*.atlassian.net) and the site set here are contacted, and the email
6
+ // and API token go only to that site: requests can come from outside (GitHub issues, the
7
+ // assistant), and a link in one must not receive your token or reach internal addresses.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { paths } from './config.js';
11
+ import { leadingJiraKey } from './tracker.js';
12
+
13
+ const file = () => path.join(paths.home, 'jira.json');
14
+
15
+ /** Saved settings ({ site, email, token }), with JIRA_SITE / JIRA_EMAIL / JIRA_API_TOKEN as fallbacks. */
16
+ export function loadJira(env = process.env) {
17
+ let saved = {};
18
+ try {
19
+ saved = JSON.parse(fs.readFileSync(file(), 'utf8'));
20
+ } catch {}
21
+ return {
22
+ site: normSite(saved.site || env.JIRA_SITE || ''),
23
+ email: saved.email || env.JIRA_EMAIL || '',
24
+ token: saved.token || env.JIRA_API_TOKEN || '',
25
+ };
26
+ }
27
+
28
+ /** What the page shows: never the token itself. */
29
+ export const jiraView = (j = loadJira()) => ({ site: j.site, email: j.email, hasToken: Boolean(j.token) });
30
+
31
+ /** Save settings; an empty token keeps the saved one unless `clearToken`. */
32
+ export function saveJira({ site, email, token, clearToken = false } = {}) {
33
+ const cur = (() => {
34
+ try {
35
+ return JSON.parse(fs.readFileSync(file(), 'utf8'));
36
+ } catch {
37
+ return {};
38
+ }
39
+ })();
40
+ const next = {
41
+ site: site === undefined ? cur.site ?? '' : normSite(site),
42
+ email: email === undefined ? cur.email ?? '' : String(email).trim(),
43
+ token: clearToken ? '' : token ? String(token).trim() : cur.token ?? '',
44
+ };
45
+ if (next.site && !/^https:\/\/[\w.-]+$/.test(next.site)) throw Object.assign(new Error('The Jira site is an https address, like https://yourcompany.atlassian.net'), { status: 400 });
46
+ fs.mkdirSync(paths.home, { recursive: true });
47
+ fs.writeFileSync(file(), JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
48
+ return jiraView(loadJira());
49
+ }
50
+
51
+ function normSite(s) {
52
+ const v = String(s ?? '').trim().replace(/\/+$/, '');
53
+ if (!v) return '';
54
+ return /^https?:\/\//.test(v) ? v : `https://${v}`;
55
+ }
56
+
57
+ /** The issue a request starts with: { key, site } from its leading link, or null. */
58
+ export function leadingIssue(text) {
59
+ const key = leadingJiraKey(text);
60
+ if (!key) return null;
61
+ const url = String(text).trimStart().match(/^<?(https?:\/\/\S+)/)[1];
62
+ try {
63
+ return { key, site: new URL(url).origin };
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /** The person's own words after the leading link ("…/browse/ABC-1 fix the timeout" → "fix the timeout"). */
70
+ export function wordsAfterLink(text) {
71
+ const rest = String(text ?? '').trimStart().replace(/^<?https?:\/\/\S+>?/, '').trim();
72
+ return rest.replace(/^[-:–—,\s]+/, '').split('\n')[0].trim().slice(0, 200) || null;
73
+ }
74
+
75
+ /**
76
+ * The issue's summary, or null when it can't be had (not a site we contact, no access, offline).
77
+ * `fetchImpl` and `allow` are for tests.
78
+ */
79
+ export async function issueSummary({ key, site }, { jira = loadJira(), fetchImpl = fetch, timeoutMs = 6000, allow = null } = {}) {
80
+ const host = (() => {
81
+ try {
82
+ return new URL(site).host;
83
+ } catch {
84
+ return '';
85
+ }
86
+ })();
87
+ const configured = jira.site && new URL(jira.site).host === host;
88
+ const allowed = configured || /\.atlassian\.net$/.test(host) || allow?.(site);
89
+ if (!allowed) return null;
90
+ const headers = { accept: 'application/json' };
91
+ if (configured && jira.email && jira.token) headers.authorization = `Basic ${Buffer.from(`${jira.email}:${jira.token}`).toString('base64')}`;
92
+ try {
93
+ const res = await fetchImpl(`${site}/rest/api/3/issue/${encodeURIComponent(key)}?fields=summary`, { headers, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) });
94
+ if (!res.ok) return null;
95
+ const summary = (await res.json())?.fields?.summary;
96
+ return typeof summary === 'string' && summary.trim() ? summary.trim().slice(0, 300) : null;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }