@staix/agent-hub 0.15.0 → 0.16.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +12 -8
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +6 -3
  5. package/docs/agent-notes/budget.md +4 -2
  6. package/docs/agent-notes/daemon.md +9 -3
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +13 -1
  9. package/docs/agent-notes/tests.md +3 -0
  10. package/docs/dogfood.md +13 -4
  11. package/docs/events.md +1 -1
  12. package/docs/memory.md +78 -10
  13. package/docs/operations.md +116 -30
  14. package/docs/quickstart.md +8 -4
  15. package/docs/security.md +80 -18
  16. package/docs/smoke.md +7 -3
  17. package/docs/specs/2026-09-19-agent-hub-design.md +84 -8
  18. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  19. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  20. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  21. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  22. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  23. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  24. package/docs/verified.json +26 -17
  25. package/package.json +2 -2
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +2085 -1702
  28. package/src/adapters/acp.ts +5 -2
  29. package/src/adapters/codex-appserver.ts +69 -1
  30. package/src/adapters/compat.ts +2 -2
  31. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  32. package/src/adapters/local-worker.ts +5 -4
  33. package/src/adapters/registry-types.ts +1 -1
  34. package/src/cli/console-state.ts +20 -2
  35. package/src/cli/console-symbols.ts +3 -2
  36. package/src/cli/console-usage.ts +5 -2
  37. package/src/cli/console.ts +5 -1
  38. package/src/cli/help.ts +7 -5
  39. package/src/cli/identity.ts +3 -1
  40. package/src/cli/main.ts +44 -10
  41. package/src/cli/memory.ts +119 -7
  42. package/src/cli/output.ts +12 -4
  43. package/src/hub/action-class.ts +211 -17
  44. package/src/hub/action-enforce.ts +13 -5
  45. package/src/hub/action-policy.ts +19 -8
  46. package/src/hub/ask.ts +16 -4
  47. package/src/hub/board.ts +86 -31
  48. package/src/hub/budget-notes.ts +65 -0
  49. package/src/hub/budget.ts +42 -10
  50. package/src/hub/bus.ts +1 -1
  51. package/src/hub/code-facts.ts +56 -16
  52. package/src/hub/conductor.ts +5 -2
  53. package/src/hub/config-apply.ts +7 -0
  54. package/src/hub/control-client.ts +1 -1
  55. package/src/hub/daemon.ts +250 -49
  56. package/src/hub/envelope.ts +1 -1
  57. package/src/hub/events.ts +2 -2
  58. package/src/hub/hub-tools.ts +3 -3
  59. package/src/hub/kimi-usage.ts +12 -0
  60. package/src/hub/lifecycle.ts +10 -5
  61. package/src/hub/memory-code-head.ts +44 -0
  62. package/src/hub/memory-code-overlay.ts +119 -0
  63. package/src/hub/memory-plan-guard.ts +10 -1
  64. package/src/hub/memory-stale-anchors.ts +159 -0
  65. package/src/hub/memory-symbols.ts +74 -0
  66. package/src/hub/planning.ts +10 -4
  67. package/src/hub/read-tools.ts +21 -3
  68. package/src/hub/sdlc.ts +34 -0
  69. package/src/hub/settings.ts +17 -2
  70. package/src/hub/settled-roles.ts +15 -3
  71. package/src/hub/task-sweep.ts +25 -3
  72. package/src/hub/tasks.ts +353 -64
  73. package/src/hub/token-window.ts +15 -2
  74. package/src/hub/window-kind.ts +13 -0
  75. package/src/local/tools.ts +5 -5
  76. package/src/memory/brief.ts +22 -12
  77. package/src/memory/capture.ts +26 -40
  78. package/src/memory/client.ts +137 -65
  79. package/src/memory/recall.ts +13 -22
  80. package/src/ui/index.html +100 -18
package/src/ui/index.html CHANGED
@@ -151,6 +151,13 @@ progress.critical::-moz-progress-bar{background:var(--red)}
151
151
  /* Memory */
152
152
  .memory-controls{display:flex;gap:var(--space-2);flex-wrap:wrap;padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--line)}
153
153
  .memory-controls input,.memory-controls select{width:auto;flex:1 1 130px}
154
+ .memory-code-toggle{display:flex;align-items:center;gap:var(--space-2);flex:1 1 210px;min-height:44px}
155
+ .memory-controls .memory-code-toggle input{flex:0 0 auto;width:auto;accent-color:var(--accent)}
156
+ #memory-code-help{flex:1 1 220px;align-self:center}
157
+ .memory-row.code-neighbour-note .label{text-decoration:underline dotted;text-underline-offset:3px}
158
+ .mnode.code-neighbour-note circle{stroke:var(--focus);stroke-width:3}
159
+ .medge.derived{stroke:var(--ink);stroke-dasharray:6 4}
160
+ .memory-code-coverage{margin:0;padding:var(--space-2) var(--space-4);overflow-wrap:anywhere}
154
161
  .memory-row{display:block;width:100%;text-align:left;border:0;border-bottom:1px solid var(--line);border-radius:0;padding:var(--space-3) var(--space-4);background:var(--panel)}
155
162
  .memory-row[aria-pressed="true"]{background:var(--accent-soft);box-shadow:inset 3px 0 0 var(--accent)}
156
163
  .memory-row:hover{background:var(--hover)}
@@ -440,7 +447,7 @@ main[data-stale]>:not(#notice):not(#disconnected){opacity:.6;transition:opacity
440
447
  <section id="office-section" aria-labelledby="office-title" data-view="office" hidden><div class="section-head"><h2 id="office-title">Office</h2><div class="filters"><label for="office-view">Office mode</label><select id="office-view"><option value="off">Off</option><option value="flat">Flat</option><option value="isometric">Isometric</option></select></div></div><div id="office" class="content"></div></section>
441
448
  <section aria-labelledby="stream-title" data-view="stream" hidden><div class="section-head"><h2 id="stream-title">Live stream</h2><div class="filters"><div id="stream-filters" class="chips" role="group" aria-label="Filter events by kind"></div><label><input id="follow" type="checkbox" checked> Follow latest</label></div></div><div id="stream" class="stream" tabindex="0" aria-label="Recent hub events"><p class="empty">Waiting for hub activity...</p></div><p id="stream-empty" class="empty" hidden>No events match this filter.</p></section>
442
449
 
443
- <section aria-labelledby="memory-title" data-view="memory" hidden><div class="section-head"><h2 id="memory-title">Memory</h2><div class="filters"><div id="memory-tiers" class="chips" role="group" aria-label="Memory tier"></div><span id="memory-count" class="meta"></span></div></div><div class="memory-controls"><input id="memory-q" type="search" maxlength="200" placeholder="Search memory" aria-label="Search memory"><select id="memory-kind" aria-label="Filter memory by kind"><option value="">All kinds</option><option value="decision">Decisions</option><option value="contract">Contracts</option><option value="fail">Fails</option><option value="finding">Findings</option></select><select id="memory-peer" aria-label="Filter memory by peer"><option value="">All peers</option></select><select id="memory-machine" aria-label="Filter memory by machine"><option value="">All machines</option></select><select id="memory-since" aria-label="Filter memory by time"><option value="">Any time</option><option value="1">Last day</option><option value="7">Last week</option><option value="30">Last 30 days</option></select><select id="memory-hops" aria-label="Graph neighbourhood depth"><option value="1">1 hop</option><option value="2">2 hops</option></select></div><div id="memory-list" class="task-list" aria-label="Memory entries"></div><div id="memory-graph" class="memory-graph"></div><div id="memory-detail"></div></section>
450
+ <section aria-labelledby="memory-title" data-view="memory" hidden><div class="section-head"><h2 id="memory-title">Memory</h2><div class="filters"><div id="memory-tiers" class="chips" role="group" aria-label="Memory tier"></div><span id="memory-count" class="meta"></span></div></div><div class="memory-controls"><input id="memory-q" type="search" maxlength="200" placeholder="Search memory" aria-label="Search memory"><select id="memory-kind" aria-label="Filter memory by kind"><option value="">All kinds</option><option value="decision">Decisions</option><option value="contract">Contracts</option><option value="fail">Fails</option><option value="finding">Findings</option></select><select id="memory-peer" aria-label="Filter memory by peer"><option value="">All peers</option></select><select id="memory-machine" aria-label="Filter memory by machine"><option value="">All machines</option></select><select id="memory-since" aria-label="Filter memory by time"><option value="">Any time</option><option value="1">Last day</option><option value="7">Last week</option><option value="30">Last 30 days</option></select><select id="memory-hops" aria-label="Graph neighbourhood depth"><option value="1">1 hop</option><option value="2">2 hops</option></select><label class="memory-code-toggle"><input id="memory-code-neighbours" type="checkbox" aria-describedby="memory-code-help" disabled>Code neighbours (1 hop)</label><span id="memory-code-help" class="meta">Select a symbol to inspect derived code neighbours.</span></div><div id="memory-list" class="task-list" aria-label="Memory entries"></div><div id="memory-graph" class="memory-graph"></div><div id="memory-detail"></div></section>
444
451
  <section aria-labelledby="compose-title" data-view="message" hidden><div class="section-head"><h2 id="compose-title">Console message</h2></div><form id="send" class="content"><div class="field"><label for="recipient">Recipient</label><select id="recipient" name="recipient"><option value="">All peers</option></select></div><div class="field"><label for="message">Message</label><textarea id="message" name="body" required maxlength="8000" placeholder="Send an instruction to the team..."></textarea></div><button class="primary" data-mutation="project" type="submit">Send message</button></form></section>
445
452
  <section aria-labelledby="settings-title" data-view="settings" hidden><div class="section-head"><h2 id="settings-title">Settings</h2><span id="settings-session" class="meta" role="status"></span></div><div id="settings"><p class="empty">Waiting for the hub...</p></div></section>
446
453
  </div>
@@ -834,7 +841,7 @@ function settingRow(row, raise) {
834
841
  const input=el('input');input.type='number';input.id=id;input.step=row.type === 'number' ? 'any' : '1';input.min=String(row.min ?? 0);input.max=String(row.max ?? Number.MAX_SAFE_INTEGER);const current=fileValue;input.value=current===null ? '' : String(current);input.placeholder='Empty inherits';
835
842
  read=()=>input.value==='' ? null : Number(input.value);actions.append(input);
836
843
  } else if (row.type === 'peers') {
837
- const input = el('input'); input.type = 'text'; input.id = id; input.value = Array.isArray(fileValue) ? fileValue.join(', ') : ''; input.placeholder = 'peer ids, comma-separated; empty inherits'; input.autocomplete = 'off'; input.spellcheck = false;
844
+ const input = el('input'); input.type = 'text'; input.id = id; input.value = Array.isArray(fileValue) ? fileValue.join(', ') : typeof fileValue === 'string' ? fileValue : ''; input.placeholder = 'peer ids, comma-separated; empty inherits'; input.autocomplete = 'off'; input.spellcheck = false;
838
845
  read = () => { const list = input.value.split(',').map((value) => value.trim()).filter(Boolean); return list.length ? list : null; };
839
846
  actions.append(input);
840
847
  } else {
@@ -1246,6 +1253,24 @@ function openMemoryAt(node) {
1246
1253
  memoryQuery.node = node; memoryQuery.q = ''; $('memory-q').value = '';
1247
1254
  closeTaskDetail(); switchView('memory'); rerenderMemory();
1248
1255
  }
1256
+ // #317: the task's deliverables as the board holds them (ids, hashes, sizes; a path only where the hub's PII screen allowed it). "changed since review" is shown only when the hub measured
1257
+ // exactly these records; the snapshot never hashes a file, so without a measurement the page says nothing about change.
1258
+ function deliverableLabel(d) {
1259
+ if (!d) return '';
1260
+ const n = d.files.length, base = n + (n === 1 ? ' file ' : ' files ') + d.state;
1261
+ if (!d.checked) return base;
1262
+ // Only a measurement in which every file was unchanged says so: a missing or unreadable file is shown as such, never as unchanged.
1263
+ const says = { changed: ', changed since review', missing: ', a file is missing', unknown: ', could not be measured', unchanged: ', unchanged when last checked' };
1264
+ return base + (says[d.checked.result] ?? says.unknown);
1265
+ }
1266
+ function deliverablesSection(task) {
1267
+ const d = task.deliverables; if (!d?.files?.length) return null;
1268
+ const section = el('section', undefined, 'detail-group'); section.append(el('h4', 'Deliverables', 'detail-heading'));
1269
+ section.append(el('p', deliverableLabel(d) + (d.checked ? '' : ' (not measured now: run ahub deliverables --check)'), d.checked && d.checked.result !== 'unchanged' ? 'meta warn' : 'meta'));
1270
+ const list = el('ul', undefined, 'deliverable-list');
1271
+ for (const file of d.files) list.append(el('li', (file.path || 'private file') + ' [' + file.format + (file.version ? ' ' + file.version : '') + '] ' + file.bytes + ' B, sha256 ' + file.sha256.slice(0, 12), 'meta'));
1272
+ section.append(list); return section;
1273
+ }
1249
1274
  function taskDetail(task, card, stage, ids, overdue) {
1250
1275
  const row = el('article', undefined, 'task-detail'), head = el('div', undefined, 'row-head');
1251
1276
  row.setAttribute('aria-label', '#' + task.id + ' ' + task.title + ', ' + task.state.replaceAll('_', ' ') + ', owner ' + (task.owner || 'unassigned') + ', priority P' + card.priority);
@@ -1277,6 +1302,7 @@ function taskDetail(task, card, stage, ids, overdue) {
1277
1302
  if(task.pinned)links.append(el('p','Pinned','meta'));
1278
1303
  if (links.children.length > 1) row.append(links);
1279
1304
  const code = codeGroupSection(task); if (code) row.append(code);
1305
+ const delivered = deliverablesSection(task); if (delivered) row.append(delivered);
1280
1306
  const controls = el('div', undefined, 'actions');
1281
1307
  if (card.pull && boardViewState?.snapshot.boardCapabilities?.backlog===true) controls.append(boardButton('Pull into To Do', {action:'backlog_pull',id:task.id}));
1282
1308
  if (card.pull && boardViewState?.snapshot.planningCapabilities?.wipPull===true) controls.append(boardButton('Pull within WIP', {action:'planner_pull',id:task.id,revision:task.planningRevision ?? 0,reason:'person requested a WIP-constrained pull'}));
@@ -1308,6 +1334,7 @@ function taskCard(task, card, stage, ids, overdue, compact = true) {
1308
1334
  if (card.starred) { const star = el('span', undefined, 'star'); star.title = 'starred'; star.append(icon('i-star', 'starred')); head.append(star); }
1309
1335
  const meta = el('div', undefined, 'card-meta'); meta.append(cardBadge(task, card, overdue));
1310
1336
  if (task.release != null) { const release=boardViewState?.snapshot.releases?.find(release=>release.id===task.release); meta.append(el('span', 'Release #' + task.release + (release?.name ? ' ' + release.name : ''), 'release-chip')); }
1337
+ if (task.deliverables?.files?.length) { const chip = el('span', 'Deliverables ' + task.deliverables.files.length + (task.deliverables.state === 'approved' ? ' approved' : '') + (task.deliverables.checked?.result === 'changed' ? ', changed' : task.deliverables.checked && task.deliverables.checked.result !== 'unchanged' ? ', ' + task.deliverables.checked.result : ''), 'release-chip deliverable-chip'); chip.title = deliverableLabel(task.deliverables); meta.append(chip); row.setAttribute('aria-label', row.getAttribute('aria-label') + ', ' + deliverableLabel(task.deliverables)); }
1311
1338
  row.append(head, meta);
1312
1339
  row.addEventListener('click', () => openTaskDetail(task.id));
1313
1340
  row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openTaskDetail(task.id); } });
@@ -1612,6 +1639,8 @@ function renderUsage(usage, tasks, budget) {
1612
1639
  }
1613
1640
  $('usage-task-count').textContent = taskCount + ' tasks';
1614
1641
  }
1642
+ // Mirrors windowKind in src/hub/window-kind.ts (the page imports nothing): a window from an older hub has no kind. test/ui.test.ts keeps the two equal.
1643
+ function windowKindOf(w) { return w.kind || (w.id === 'tokens' ? 'count' : w.source === 'ahub budget set' ? 'manual' : 'native'); }
1615
1644
  function render(snapshot) {
1616
1645
  renderNeedsAttention(snapshot);
1617
1646
  const peers = Object.fromEntries(Object.entries(snapshot.status.peers || {}).map(([id, peer]) => [id, {...peer, ...(snapshot.status.roleInfo || {})[id]}])), ids = Object.keys(peers), starts = Array.isArray(snapshot.starts) ? snapshot.starts : [];
@@ -1677,13 +1706,14 @@ function render(snapshot) {
1677
1706
  if (pct !== null) { const bar = el('progress'); bar.max = 1; bar.value = context.used; bar.setAttribute('aria-label', peer + ' context usage'); row.append(bar); }
1678
1707
  }
1679
1708
  if (budget.paused) { const line = el('p', 'Paused: ' + budget.paused.reason + ' / resets ', 'meta'), stamp = el('span'); stamp.dataset.ts = budget.paused.resetsAt; stamp.title = time(budget.paused.resetsAt); stamp.textContent = rel(budget.paused.resetsAt); line.append(stamp); row.append(line); }
1680
- if (!budget.windows.length) row.append(el('p', 'No usage windows reported.', 'meta'));
1709
+ if (!budget.windows.length) row.append(el('p', budget.note || 'No usage windows reported.', 'meta'));
1710
+ else if (budget.note) row.append(el('p', budget.note, 'meta'));
1681
1711
  for (const window of budget.windows) {
1682
1712
  const item = el('div', undefined, 'budget-window'), percent = Math.round(window.used * 100);
1683
- item.append(el('p', window.id + ': ' + percent + '% used' + (window.stale ? ' (stale)' : ''), 'meta'));
1713
+ item.append(el('p', window.id + ': ' + percent + '% used' + (window.stale ? ' (stale)' : window.superseded ? ' (information only)' : ''), 'meta'));
1684
1714
  const bar = el('progress'); bar.max = 1; bar.value = window.used; bar.className = percent >= 90 ? 'critical' : percent >= 70 ? 'warn' : '';
1685
1715
  bar.setAttribute('aria-label', peer + ' ' + window.id + ' usage'); item.append(bar);
1686
- const meta = el('p', window.source + (window.resetsAt ? ' / resets ' : ''), 'meta');
1716
+ const meta = el('p', windowKindOf(window) + ' / ' + window.source + (window.resetsAt ? ' / resets ' : ''), 'meta');
1687
1717
  if (window.resetsAt) { const stamp = el('span'); stamp.dataset.ts = window.resetsAt; stamp.title = time(window.resetsAt); stamp.textContent = rel(window.resetsAt); meta.append(stamp); }
1688
1718
  item.append(meta); row.append(item);
1689
1719
  }
@@ -2219,21 +2249,22 @@ function renderBench(bench) {
2219
2249
  }
2220
2250
  // #258: the hub's memory, read-only. A tier at a time, filtered; selecting an entry or a node shows its neighbourhood
2221
2251
  // (inline SVG, a small force layout computed here) with the same edges as a list, and its detail.
2222
- const memoryQuery = {tier:'long', q:'', kind:'', peer:'', machine:'', since:'', node:'', hops:1};
2252
+ const memoryQuery = {tier:'long', q:'', kind:'', peer:'', machine:'', since:'', node:'', hops:1, codeNeighbours:false};
2223
2253
  let memoryGeneration = -1;
2224
2254
  function memoryInput() {
2225
2255
  if (memoryGeneration !== viewGeneration) { // another project: its own nodes, peers and machines
2226
- memoryGeneration = viewGeneration; memoryQuery.node = ''; memoryQuery.peer = ''; memoryQuery.machine = '';
2256
+ memoryGeneration = viewGeneration; memoryQuery.node = ''; memoryQuery.peer = ''; memoryQuery.machine = ''; memoryQuery.codeNeighbours = false;
2227
2257
  for (const id of ['memory-peer', 'memory-machine']) $(id).value = '';
2228
2258
  }
2229
2259
  const out = {tier:memoryQuery.tier, hops:memoryQuery.hops};
2230
2260
  for (const key of ['q', 'kind', 'peer', 'machine', 'node']) if (memoryQuery[key]) out[key] = memoryQuery[key];
2261
+ if (memoryQuery.codeNeighbours === true && memoryQuery.node.startsWith('symbol:') && currentView === 'memory' && !document.hidden) out.codeNeighbours = true;
2231
2262
  // A whole timestamp, floored to the hour so the query stays the same between polls.
2232
2263
  if (memoryQuery.since) out.since = new Date(Math.floor((Date.now() - Number(memoryQuery.since) * 86400000) / 3600000) * 3600000).toISOString();
2233
2264
  return out;
2234
2265
  }
2235
2266
  // The next poll (at most a second away) carries the changed query; polling is never started twice.
2236
- function rerenderMemory() { for (const chip of $('memory-tiers').children) chip.setAttribute('aria-pressed', String(chip.dataset.tier === memoryQuery.tier)); }
2267
+ function rerenderMemory() { memoryCodeToggle(); for (const chip of $('memory-tiers').children) chip.setAttribute('aria-pressed', String(chip.dataset.tier === memoryQuery.tier)); }
2237
2268
  function memoryOptions(id, values, all, key) {
2238
2269
  const select = $(id), keep = select.value;
2239
2270
  const want = [''].concat(values);
@@ -2242,6 +2273,51 @@ function memoryOptions(id, values, all, key) {
2242
2273
  select.value = want.includes(keep) ? keep : '';
2243
2274
  memoryQuery[key] = select.value; // a filter whose value is gone reads as none, on the page and in the next query
2244
2275
  }
2276
+ // Render-only union: stored edges remain stored; code facts are separately SHA-stamped and never persisted here.
2277
+ function memoryPresentation(memory) {
2278
+ const stored = memory.graph || {nodes:[], edges:[]}, entries = memory.entries || [];
2279
+ const enabled = memoryQuery.codeNeighbours === true && memoryQuery.node.startsWith('symbol:') && memory.node === memoryQuery.node;
2280
+ if (!enabled) return {graph:stored, entries, noteIds:[], coverage:''};
2281
+ const code = memory.code, head = code?.head;
2282
+ if (!code || !/^[a-f0-9]{40}$/.test(head || '')) return {graph:stored, entries, noteIds:[], coverage:'Code neighbours: unknown (no current code facts).'};
2283
+ let coverage = 'Code neighbours: ' + (code.state === 'known' ? 'known' : 'unknown') + (code.truncated ? '; truncated' : '')
2284
+ + (code.countsFloor ? '; lower-bound counts' : '') + ' at ' + head + '; dashed edges are derived, never stored.';
2285
+ const nodes = new Map(stored.nodes.map(node => [node.id, node]));
2286
+ const candidateNodes = new Map((code.nodes || []).filter(node => node.derived === true && node.head === head && node.type === 'symbol' && typeof node.id === 'string' && node.id.startsWith('symbol:')).map(node => [node.id, node]));
2287
+ const edges = new Map(stored.edges.map(edge => [JSON.stringify([edge.from, edge.to, edge.rel]), edge]));
2288
+ const connected = new Set();
2289
+ for (const edge of code.edges || []) {
2290
+ if (edge.derived !== true || edge.head !== head || edge.rel !== 'calls' || !candidateNodes.has(edge.from) || !candidateNodes.has(edge.to)) continue;
2291
+ const key = JSON.stringify([edge.from, edge.to, edge.rel]);
2292
+ if (!edges.has(key)) edges.set(key, edge);
2293
+ connected.add(edge.from); connected.add(edge.to);
2294
+ }
2295
+ for (const [id, node] of candidateNodes) if ((connected.has(id) || id === memory.node) && !nodes.has(id)) nodes.set(id, node);
2296
+ const notes = (code.notes || []).filter(note => (!memoryQuery.kind || note.kind === memoryQuery.kind) && (!memoryQuery.tier || note.tier === memoryQuery.tier) && (!memoryQuery.peer || note.peer === memoryQuery.peer) && (!memoryQuery.machine || note.machine === memoryQuery.machine) && /^mem:[a-f0-9]{32}$/.test(note.id) && typeof note.line === 'string' && Array.isArray(note.about) && note.about.some(id => connected.has(id)));
2297
+ const noteIds = [...new Set(notes.map(note => note.id))], highlightIds = new Set(noteIds), byId = new Map(entries.map(entry => [entry.id, entry]));
2298
+ for (const note of notes) {
2299
+ const id = note.id.slice(4), existing = byId.get(id);
2300
+ byId.set(id, existing ? {...existing, codeNeighbour:true} : {id, title:null, text:note.line, kind:note.kind || 'finding', codeNeighbour:true, codeFallback:true, head});
2301
+ if (!nodes.has(note.id)) nodes.set(note.id, {id:note.id, type:'memory', label:note.line, kind:note.kind});
2302
+ for (const about of note.about) if (candidateNodes.has(about) && connected.has(about)) {
2303
+ highlightIds.add(about);
2304
+ const rel = note.kind === 'fail' ? 'rules_out' : 'about', key = JSON.stringify([note.id, about, rel]);
2305
+ if (!edges.has(key)) edges.set(key, {from:note.id, to:about, rel});
2306
+ }
2307
+ }
2308
+ const ordered = [...byId.values()].sort((a, b) => Number(b.kind === 'fail') - Number(a.kind === 'fail') || Number(b.codeNeighbour === true) - Number(a.codeNeighbour === true));
2309
+ const rank = node => node.kind === 'fail' ? 0 : node.id === memory.node ? 1 : highlightIds.has(node.id) ? 2 : node.derived ? 3 : 4;
2310
+ const orderedNodes = [...nodes.values()].sort((a, b) => rank(a) - rank(b)), keptNodes = orderedNodes.slice(0, 200), keptIds = new Set(keptNodes.map(node => node.id));
2311
+ const nodeOmitted = orderedNodes.length - keptNodes.length, noteOmitted = ordered.length - Math.min(ordered.length, 50);
2312
+ if (nodeOmitted || noteOmitted) coverage += '; display truncated: ' + nodeOmitted + ' nodes and ' + noteOmitted + ' entries omitted';
2313
+ return {graph:{nodes:keptNodes, edges:[...edges.values()].filter(edge => keptIds.has(edge.from) && keptIds.has(edge.to))}, entries:ordered.slice(0, 50), noteIds:[...highlightIds], coverage};
2314
+ }
2315
+ function memoryCodeToggle() {
2316
+ const toggle = $('memory-code-neighbours'), eligible = memoryQuery.node.startsWith('symbol:');
2317
+ if (!eligible) memoryQuery.codeNeighbours = false;
2318
+ toggle.disabled = !eligible; toggle.checked = memoryQuery.codeNeighbours === true;
2319
+ $('memory-code-help').textContent = eligible ? 'Optional current-head caller/callee context; dashed edges are derived.' : 'Select a symbol to inspect derived code neighbours.';
2320
+ }
2245
2321
  function layout(graph) {
2246
2322
  // Deterministic: start on a circle in node order, then a fixed number of spring steps; no animation loop.
2247
2323
  const W = 640, H = 320, n = graph.nodes.length, pos = new Map();
@@ -2264,6 +2340,7 @@ function layout(graph) {
2264
2340
  }
2265
2341
  function selectMemoryNode(node) { memoryQuery.node = memoryQuery.node === node ? '' : node; rerenderMemory(); }
2266
2342
  function renderMemory(memory) {
2343
+ memoryCodeToggle();
2267
2344
  const tiers = $('memory-tiers');
2268
2345
  if (!tiers.children.length) for (const [tier, label] of [['short', 'Short-term'], ['long', 'Long-term'], ['goals', 'Long-term tasks']]) {
2269
2346
  const chip = el('button', label, 'chip'); chip.type = 'button'; chip.dataset.tier = tier;
@@ -2272,6 +2349,7 @@ function renderMemory(memory) {
2272
2349
  }
2273
2350
  for (const chip of tiers.children) chip.setAttribute('aria-pressed', String(chip.dataset.tier === memoryQuery.tier));
2274
2351
  if (!memory) { $('memory-count').textContent = ''; update('memory-list', null, (target) => empty(target, 'Hub memory is off for this project, or nothing is stored yet. Save a note with ahub remember <text>.')); update('memory-graph', null, () => {}); update('memory-detail', null, () => {}); return; }
2352
+ const presentation = memoryPresentation(memory);
2275
2353
  $('memory-count').textContent = memory.counts.long + ' long-term / ' + memory.counts.short + ' short-term / ' + (memory.counts.goals || 0) + ' open tasks';
2276
2354
  if (memory.goals) {
2277
2355
  // Long-term tasks: state, who holds the lease and until when (a conflict when two machines claimed it), linked board tasks.
@@ -2297,15 +2375,16 @@ function renderMemory(memory) {
2297
2375
  }
2298
2376
  memoryOptions('memory-peer', memory.peers, 'All peers', 'peer');
2299
2377
  memoryOptions('memory-machine', memory.machines, 'All machines', 'machine');
2300
- if (!memory.goals) update('memory-list', {entries: memory.entries, node: memoryQuery.node}, (target) => {
2301
- if (!memory.entries.length) return empty(target, 'No ' + memory.tier + '-term entries match. Save one with ahub remember <text>.');
2378
+ if (!memory.goals) update('memory-list', {entries: presentation.entries, node: memoryQuery.node}, (target) => {
2379
+ if (!presentation.entries.length) return empty(target, 'No ' + memory.tier + '-term entries match. Save one with ahub remember <text>.');
2302
2380
  const keyed = keyedStart('memory');
2303
- for (const entry of memory.entries) {
2381
+ for (const entry of presentation.entries) {
2304
2382
  const row = keyed('mem:' + entry.id, JSON.stringify([entry, memoryQuery.node === 'mem:' + entry.id]), () => {
2305
- const row = el('button', undefined, 'memory-row'); row.type = 'button'; row.setAttribute('aria-pressed', String(memoryQuery.node === 'mem:' + entry.id));
2383
+ const row = el('button', undefined, 'memory-row' + (entry.codeNeighbour ? ' code-neighbour-note' : '')); row.type = 'button'; row.setAttribute('aria-pressed', String(memoryQuery.node === 'mem:' + entry.id));
2306
2384
  const head = el('div', undefined, 'row-head');
2307
2385
  head.append(el('span', (entry.title ? entry.title + ': ' : '') + entry.text.split('\n')[0].slice(0, 140), 'label'), badge(entry.kind, entry.kind === 'fail' ? 'err' : entry.kind === 'decision' ? 'ok' : entry.kind === 'contract' ? 'warn' : ''));
2308
- row.append(head, el('div', entry.id.slice(0, 8) + ' / ' + entry.peer + ' / ' + time(entry.at) + (entry.scope === 'shared' ? ' / shared' : '') + (entry.supersededBy ? ' / superseded' : '') + (entry.machine !== memory.machine ? ' / from machine ' + entry.machine.slice(0, 6) : ''), 'meta'));
2386
+ if (entry.codeNeighbour) head.append(badge('Code neighbour', 'info'));
2387
+ row.append(head, el('div', entry.codeFallback ? entry.id.slice(0, 8) + ' / via code neighbour at ' + entry.head : entry.id.slice(0, 8) + ' / ' + entry.peer + ' / ' + time(entry.at) + (entry.scope === 'shared' ? ' / shared' : '') + (entry.supersededBy ? ' / superseded' : '') + (entry.machine !== memory.machine ? ' / from machine ' + entry.machine.slice(0, 6) : ''), 'meta'));
2309
2388
  row.addEventListener('click', () => selectMemoryNode('mem:' + entry.id));
2310
2389
  return row;
2311
2390
  });
@@ -2313,19 +2392,21 @@ function renderMemory(memory) {
2313
2392
  }
2314
2393
  keyed.end();
2315
2394
  });
2316
- update('memory-graph', memory.graph || null, (target) => {
2317
- const graph = memory.graph;
2395
+ update('memory-graph', {graph:presentation.graph, coverage:presentation.coverage, noteIds:presentation.noteIds}, (target) => {
2396
+ const graph = presentation.graph;
2397
+ if (presentation.coverage) target.append(el('p', presentation.coverage, 'memory-code-coverage meta'));
2318
2398
  if (!graph || !graph.nodes.length) return;
2319
2399
  const NS = 'http://www.w3.org/2000/svg', svg = document.createElementNS(NS, 'svg'), pos = layout(graph);
2320
2400
  svg.setAttribute('viewBox', '0 0 640 320'); svg.setAttribute('role', 'img'); svg.setAttribute('aria-label', 'Neighbourhood of ' + memory.node + ': ' + graph.nodes.length + ' nodes, ' + graph.edges.length + ' edges; the list below has the same edges');
2321
2401
  for (const e of graph.edges) {
2322
2402
  const p = pos.get(e.from), q = pos.get(e.to); if (!p || !q) continue;
2323
- const line = document.createElementNS(NS, 'line'); line.setAttribute('x1', p.x); line.setAttribute('y1', p.y); line.setAttribute('x2', q.x); line.setAttribute('y2', q.y); line.setAttribute('class', 'medge ' + e.rel);
2403
+ const line = document.createElementNS(NS, 'line'); line.setAttribute('x1', p.x); line.setAttribute('y1', p.y); line.setAttribute('x2', q.x); line.setAttribute('y2', q.y); line.setAttribute('class', 'medge ' + e.rel + (e.derived ? ' derived' : ''));
2404
+ if (e.derived) { const title = document.createElementNS(NS, 'title'); title.textContent = 'Derived ' + e.rel + ' at ' + e.head; line.append(title); }
2324
2405
  svg.append(line);
2325
2406
  }
2326
2407
  for (const node of graph.nodes) {
2327
2408
  const p = pos.get(node.id), g = document.createElementNS(NS, 'g'), c = document.createElementNS(NS, 'circle'), t = document.createElementNS(NS, 'text');
2328
- g.setAttribute('class', 'mnode ' + node.type + (node.kind ? ' ' + node.kind : '') + (node.id === memory.node ? ' selected' : '')); g.setAttribute('tabindex', '0'); g.setAttribute('role', 'button'); g.setAttribute('aria-label', node.type + ' ' + node.label);
2409
+ g.setAttribute('class', 'mnode ' + node.type + (node.kind ? ' ' + node.kind : '') + (node.id === memory.node ? ' selected' : '') + (presentation.noteIds.includes(node.id) ? ' code-neighbour-note' : '')); g.setAttribute('tabindex', '0'); g.setAttribute('role', 'button'); g.setAttribute('aria-label', node.type + ' ' + node.label);
2329
2410
  c.setAttribute('cx', p.x); c.setAttribute('cy', p.y); c.setAttribute('r', node.type === 'memory' ? 9 : 6);
2330
2411
  t.setAttribute('x', p.x + 11); t.setAttribute('y', p.y + 4); t.textContent = node.label.slice(0, 28);
2331
2412
  g.append(c, t);
@@ -2334,7 +2415,7 @@ function renderMemory(memory) {
2334
2415
  svg.append(g);
2335
2416
  }
2336
2417
  const list = el('ul', undefined, 'memory-edges');
2337
- for (const e of graph.edges) list.append(el('li', e.from + ' ' + e.rel + ' ' + e.to));
2418
+ for (const e of graph.edges) list.append(el('li', e.from + ' ' + e.rel + ' ' + e.to + (e.derived ? ' (derived at ' + e.head + ')' : '')));
2338
2419
  target.append(svg, list);
2339
2420
  });
2340
2421
  update('memory-detail', memory.detail || null, (target) => {
@@ -2350,6 +2431,7 @@ function renderMemory(memory) {
2350
2431
  });
2351
2432
  }
2352
2433
  for (const [id, key] of [['memory-kind', 'kind'], ['memory-peer', 'peer'], ['memory-machine', 'machine'], ['memory-since', 'since']]) $(id).addEventListener('change', () => { memoryQuery[key] = $(id).value; rerenderMemory(); });
2434
+ $('memory-code-neighbours').addEventListener('change', () => { memoryQuery.codeNeighbours = $('memory-code-neighbours').checked === true; rerenderMemory(); });
2353
2435
  $('memory-hops').addEventListener('change', () => { memoryQuery.hops = Number($('memory-hops').value); rerenderMemory(); });
2354
2436
  let memorySearch;
2355
2437
  $('memory-q').addEventListener('input', () => { clearTimeout(memorySearch); memorySearch = setTimeout(() => { memoryQuery.q = $('memory-q').value.trim(); rerenderMemory(); }, 300); });