@staix/agent-hub 0.29.0 → 0.31.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 (154) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +15 -7
  3. package/docs/agent-notes/README.md +13 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +10 -4
  7. package/docs/agent-notes/daemon.md +16 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +10 -4
  10. package/docs/agent-notes/tests.md +8 -4
  11. package/docs/design/adapters.md +108 -0
  12. package/docs/design/agent-hub-architecture.md +58 -0
  13. package/docs/design/bus.md +147 -1
  14. package/docs/design/task-board.md +100 -0
  15. package/docs/dogfood.md +29 -8
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +410 -86
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +25 -5
  21. package/docs/sdlc.md +1 -1
  22. package/docs/security.md +182 -62
  23. package/docs/smoke.md +13 -2
  24. package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
  25. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  26. package/docs/specs/2026-10-12-office-view-design.md +86 -3
  27. package/docs/verification/2026-11-03-0.30.0.md +54 -0
  28. package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
  29. package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
  30. package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
  31. package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
  32. package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
  33. package/docs/verification/2026-11-03-pi-title-api.md +26 -0
  34. package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
  35. package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
  36. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  37. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  38. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  39. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
  40. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  41. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  42. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  43. package/docs/verification/dogfood/2026-11-03.md +3095 -0
  44. package/docs/verification/pi-150-model-free.ts +57 -0
  45. package/docs/verification/pi-150-native.mjs +21 -0
  46. package/docs/verification/pi-150-title-api.mjs +31 -0
  47. package/docs/verified.json +14 -14
  48. package/package.json +3 -4
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/server.js +4565 -3617
  51. package/src/adapters/acp.ts +5 -4
  52. package/src/adapters/channel-peer.ts +72 -3
  53. package/src/adapters/claude-channel-evidence.ts +52 -0
  54. package/src/adapters/claude-channel.ts +61 -18
  55. package/src/adapters/claude-implementer.ts +29 -7
  56. package/src/adapters/claude-print-child.ts +12 -2
  57. package/src/adapters/codex-appserver.ts +13 -12
  58. package/src/adapters/compat.ts +13 -13
  59. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  60. package/src/adapters/definitions/qwen.ts +1 -0
  61. package/src/adapters/local-worker.ts +82 -23
  62. package/src/adapters/pi.ts +140 -16
  63. package/src/adapters/registry-types.ts +16 -0
  64. package/src/adapters/registry.ts +4 -0
  65. package/src/adapters/reviewer-print.ts +50 -11
  66. package/src/broker/server.ts +44 -13
  67. package/src/broker/store.ts +38 -8
  68. package/src/cli/answer-args.ts +50 -0
  69. package/src/cli/attention.ts +17 -2
  70. package/src/cli/autonomy.ts +66 -1
  71. package/src/cli/backlog-return-args.ts +37 -0
  72. package/src/cli/console-question.ts +110 -0
  73. package/src/cli/console-state.ts +82 -17
  74. package/src/cli/console.ts +54 -1
  75. package/src/cli/handoff-args.ts +15 -5
  76. package/src/cli/handoff-output.ts +15 -2
  77. package/src/cli/help.ts +10 -5
  78. package/src/cli/identity.ts +21 -4
  79. package/src/cli/main.ts +167 -16
  80. package/src/cli/memory.ts +107 -3
  81. package/src/cli/output.ts +29 -6
  82. package/src/cli/pet.ts +4 -4
  83. package/src/cli/results-args.ts +30 -0
  84. package/src/cli/runtime-version-warning.ts +50 -0
  85. package/src/cli/status-lines.ts +41 -1
  86. package/src/cli/tail-render.ts +1 -0
  87. package/src/cli/upgrade-interactive.ts +14 -2
  88. package/src/cli/worktree-tidy.ts +158 -0
  89. package/src/cli/worktrees-output.ts +43 -0
  90. package/src/hub/action-class.ts +9 -1
  91. package/src/hub/board.ts +396 -20
  92. package/src/hub/bus.ts +497 -57
  93. package/src/hub/completion-detail.ts +50 -0
  94. package/src/hub/completions-store.ts +168 -0
  95. package/src/hub/completions.ts +120 -0
  96. package/src/hub/conductor.ts +4 -3
  97. package/src/hub/config-apply.ts +8 -5
  98. package/src/hub/config-reload.ts +10 -1
  99. package/src/hub/control-client.ts +40 -4
  100. package/src/hub/daemon.ts +1795 -142
  101. package/src/hub/delivery-failure.ts +14 -0
  102. package/src/hub/delivery-hints.ts +14 -4
  103. package/src/hub/delivery-journal.ts +751 -42
  104. package/src/hub/delivery-superseded.ts +4 -1
  105. package/src/hub/dogfood-read.ts +62 -9
  106. package/src/hub/envelope.ts +3 -3
  107. package/src/hub/events.ts +6 -0
  108. package/src/hub/handoff.ts +145 -6
  109. package/src/hub/hub-tools.ts +66 -11
  110. package/src/hub/manager.ts +67 -3
  111. package/src/hub/memory-policy.ts +44 -1
  112. package/src/hub/memory-sync-policy.ts +109 -0
  113. package/src/hub/memory-sync-state.ts +145 -0
  114. package/src/hub/no-writes.ts +1 -1
  115. package/src/hub/operational-settings.ts +11 -2
  116. package/src/hub/operational-view.ts +12 -1
  117. package/src/hub/peer-doing.ts +33 -3
  118. package/src/hub/peers.ts +32 -2
  119. package/src/hub/planning.ts +6 -1
  120. package/src/hub/question-desk.ts +201 -0
  121. package/src/hub/question-detach.ts +6 -0
  122. package/src/hub/questions.ts +260 -0
  123. package/src/hub/research-evidence.ts +106 -0
  124. package/src/hub/restart.ts +14 -0
  125. package/src/hub/review-anchor.ts +181 -0
  126. package/src/hub/review-brief.ts +13 -0
  127. package/src/hub/review-comments.ts +115 -0
  128. package/src/hub/review-findings.ts +7 -2
  129. package/src/hub/review-material.ts +76 -0
  130. package/src/hub/routing.ts +3 -1
  131. package/src/hub/settings.ts +3 -0
  132. package/src/hub/task-sweep.ts +2 -0
  133. package/src/hub/tasks.ts +607 -71
  134. package/src/hub/ui.ts +2 -0
  135. package/src/hub/worktree-prs.ts +100 -0
  136. package/src/hub/worktree-shelf.ts +246 -0
  137. package/src/memory/capture.ts +5 -2
  138. package/src/pet/config.ts +25 -13
  139. package/src/pi/extension.ts +43 -3
  140. package/src/rooms/link.ts +209 -21
  141. package/src/rooms/remote-peer.ts +186 -19
  142. package/src/rooms/service.ts +338 -22
  143. package/src/rooms/store.ts +437 -65
  144. package/src/rooms/wire.ts +24 -6
  145. package/src/ui/index.html +271 -17
  146. package/src/ui/needs.ts +10 -4
  147. package/src/ui/office-model.ts +64 -8
  148. package/src/ui/office-space.ts +1 -0
  149. package/src/ui/plugin-questions.ts +56 -0
  150. package/src/ui/plugin-results.ts +81 -0
  151. package/src/ui/plugin.ts +219 -28
  152. package/src/pet/window.html +0 -114
  153. package/src/pet/window.jxa.js +0 -122
  154. package/src/pet/window.ts +0 -154
package/src/ui/index.html CHANGED
@@ -257,6 +257,7 @@ details.settings-group .row:last-child{border-bottom:0}
257
257
 
258
258
  .office-scene{display:block;max-width:100%;height:auto;margin-inline:auto;font-family:system-ui,sans-serif;font-size:1px}.office-scene :is(rect,path,line,polygon,circle){vector-effect:non-scaling-stroke;stroke-width:1px}.office-room{fill:var(--panel-soft);stroke:var(--line)}.office-room-title{fill:var(--ink);font-size:1.083333px;font-weight:600}.office-room-count{fill:var(--ink);font-size:1px;font-family:ui-monospace,monospace;font-variant-numeric:tabular-nums}.office-header-surface{fill:var(--panel);stroke:var(--line)}.office-count-pill{fill:var(--panel);stroke:var(--line)}.office-desk-surface{fill:var(--panel);stroke:var(--line)}.office-desk.office-dimmed .office-avatar{opacity:.55}.office-name{fill:var(--ink);font-size:1.083333px;font-weight:600}.office-state,.office-meta,.office-empty{fill:var(--muted);font-size:1px}.office-glyph,.office-hand{font-size:1px}.office-idle .office-glyph{fill:var(--green)}.office-busy .office-glyph{fill:var(--amber)}.office-offline .office-glyph,.office-unknown .office-glyph{fill:var(--muted)}.office-paused .office-glyph{fill:var(--amber)}.office-waiting .office-glyph{fill:var(--red)}.office-hand{fill:var(--red)}.office-bubble{fill:var(--panel);stroke:var(--blue)}.office-bubble-text{fill:var(--ink);font-size:1px}.office-mode{fill:var(--muted);font-size:1px;font-family:ui-monospace,monospace}.office-card{fill:var(--control);stroke:var(--control-border)}.office-card-text{fill:var(--ink);font-size:1px}.office-card-id{font-family:ui-monospace,monospace}.office-card-accent{fill:var(--line)}.office-waiting-card .office-card-accent{fill:var(--waiting)}.office-rework-card .office-card-accent{fill:var(--red)}.office-card-checking .office-card-accent{fill:var(--blue)}.office-more{fill:var(--blue);font-size:1px}.office-scene .office-spotlight .office-desk-surface,.office-scene .office-spotlight .office-card,.office-scene [role="button"]:focus-visible > .office-card{stroke:var(--focus);stroke-width:2px}.office-card-hit{fill:transparent;stroke:none;pointer-events:all}.office-card-focus:focus-visible{outline:none}.row.spotlight{background:var(--hover)}
259
259
 
260
+ .office-plan-shape{fill:none;stroke:var(--muted)}.office-reserved{fill:var(--peer-color);stroke:none}.office-dock-progress{fill:var(--blue);stroke:none}.office-release-dock:focus-visible .office-room{stroke:var(--focus);stroke-width:2px}
260
261
  .office-movable{transition:transform .8s ease}.office-arc .office-arc-path{fill:none;stroke:var(--blue);stroke-width:1px}.office-arc .office-arc-head{fill:var(--blue)}.office-arc-important .office-arc-path{stroke:var(--amber)}.office-arc-important .office-arc-head{fill:var(--amber)}.office-arc-dropped .office-arc-path{stroke:var(--red);stroke-dasharray:.6 .4}.office-arc-dropped .office-arc-head{fill:var(--red)}.office-arc-label{fill:var(--muted);font-size:1px}.office-alert{fill:var(--red);font-size:1px}.office-latch{fill:var(--amber);font-size:1px}.office-link-line{fill:none;stroke-width:1px;stroke-dasharray:.8 .5}.office-link-overlap .office-link-line{stroke:var(--amber)}.office-link-conflict .office-link-line{stroke:var(--red)}.office-link-cohort .office-link-line{stroke:var(--blue)}.office-link-label{font-size:1px;fill:var(--muted)}.office-link-conflict .office-link-label{fill:var(--red)}.office-link-overlap .office-link-label{fill:var(--amber)}.office-link-cohort .office-link-label{fill:var(--blue)}.office-checking{fill:var(--blue);font-size:.916667px}
261
262
  .office-avatar{fill:var(--muted)}.office-idle .office-avatar{fill:var(--dot-ok)}.office-busy .office-avatar{fill:var(--dot-warn)}.office-offline .office-avatar{fill:var(--dot)}.office-paused .office-avatar{fill:var(--dot-warn)}.office-waiting .office-avatar{fill:var(--amber)}.office-waiting .office-desk-surface{stroke:var(--amber)}.office-glyph-shape{fill:none;stroke-width:1px}.office-idle .office-glyph-shape{stroke:var(--dot-ok)}.office-busy .office-glyph-shape{stroke:var(--dot-warn);fill:var(--dot-warn)}.office-paused .office-glyph-shape{stroke:var(--dot-warn);fill:var(--dot-warn)}.office-offline .office-glyph-shape{stroke:var(--dot)}.office-handshape{fill:none;stroke:var(--dot-err);stroke-width:1px}.office-desk-leg{fill:var(--panel-soft);stroke:var(--line);stroke-width:1px}.office-nameplate{fill:var(--control);stroke:var(--control-border);stroke-width:1px}.office-room-wire{fill:none;stroke:var(--line);stroke-width:1px}.office-divider{fill:none;stroke:var(--line);stroke-width:1px}.office-shelf{fill:var(--panel-soft);stroke:var(--line);stroke-width:1px}.office-shelf-card{fill:var(--green-soft);stroke:var(--green);stroke-width:1px}.office-box-top{fill:var(--control);stroke:var(--control-border);stroke-width:1px}.office-box-left{fill:var(--panel-soft);stroke:var(--line);stroke-width:1px}.office-box-right{fill:var(--panel);stroke:var(--line);stroke-width:1px}.office-busy .office-glyph,.office-hand{animation:office-pulse 1.2s ease-in-out infinite}.office-appear{animation:office-appear .5s ease-out}.office-leaving{pointer-events:none;animation:office-fade .4s linear forwards}.office-fadeout{animation-name:office-fade;animation-duration:1s;animation-timing-function:linear;animation-fill-mode:forwards}.office-settle .office-movable{transition:none}.office-settle .office-appear{animation:none}
262
263
  @keyframes office-pulse{50%{opacity:.45}}@keyframes office-appear{from{opacity:0}}@keyframes office-fade{to{opacity:0}}
@@ -449,7 +450,7 @@ main[data-stale]>:not(#notice):not(#disconnected){opacity:.6;transition:opacity
449
450
  <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>
450
451
  <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>
451
452
 
452
- <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>
453
+ <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><p id="memory-sync-status" class="meta" aria-live="polite"></p><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>
453
454
  <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>
454
455
  <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>
455
456
  </div>
@@ -458,6 +459,7 @@ main[data-stale]>:not(#notice):not(#disconnected){opacity:.6;transition:opacity
458
459
  <section id="schedules-section" aria-labelledby="schedules-title" data-view="agents" hidden><div class="section-head"><h2 id="schedules-title">Schedules</h2><span id="schedule-count" class="meta"></span></div><div id="schedules"></div></section>
459
460
  <section id="rooms-section" aria-labelledby="rooms-title" data-view="agents" data-empty="true" hidden><div class="section-head"><h2 id="rooms-title">Rooms</h2><span id="room-count" class="meta"></span></div><div id="rooms"></div></section>
460
461
  <section aria-labelledby="peers-title" data-view="agents" hidden><div class="section-head"><h2 id="peers-title">Peers &amp; queues</h2><span id="peer-count" class="meta"></span></div><div id="peers"><div class="skeleton" aria-hidden="true"><span class="skeleton-bar skeleton-tall"></span><span class="skeleton-bar skeleton-tall"></span><span class="skeleton-bar skeleton-tall"></span></div><p class="empty">Loading peers...</p></div></section>
462
+ <section id="results-section" aria-labelledby="results-title" data-view="agents" hidden><div class="section-head"><h2 id="results-title">Results</h2><span id="results-count" class="meta"></span></div><div id="results"></div></section>
461
463
  <section aria-labelledby="permissions-title" data-view="agents" hidden><div class="section-head"><h2 id="permissions-title">Pending approvals</h2><span id="permission-count" class="meta"></span></div><div id="permissions"></div></section>
462
464
  <section aria-labelledby="budget-title" data-view="agents" hidden><div class="section-head"><h2 id="budget-title">Usage budgets</h2></div><div id="budget"></div>
463
465
  <div id="usage-content" class="content">
@@ -498,12 +500,14 @@ main[data-stale]>:not(#notice):not(#disconnected){opacity:.6;transition:opacity
498
500
  </div>
499
501
  </div>
500
502
  </section>
503
+ <section id="worktrees-section" aria-labelledby="worktrees-title" data-view="agents" hidden><div class="section-head"><h2 id="worktrees-title">Worktrees</h2><span id="worktrees-count" class="meta"></span></div><div id="worktrees"></div></section>
501
504
  <section aria-labelledby="bench-title" data-view="agents" hidden><div class="section-head"><h2 id="bench-title">Benchmarks</h2><span id="bench-count" class="meta"></span></div><div id="bench"></div></section>
502
505
  </div>
503
506
  </main>
504
507
  </div>
505
508
  </div>
506
509
  <dialog id="plan-dialog" aria-labelledby="plan-title"><div class="dialog-head"><h2 id="plan-title">Plan</h2><button type="button" id="plan-close" aria-label="Close plan sheet"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-close"/></svg></button></div>
510
+ <section class="plan-block" aria-labelledby="plan-h-intake"><h3 id="plan-h-intake">Capture a request</h3><form id="intake"><div class="field"><label for="intake-text">What should the team do?</label><textarea id="intake-text" required maxlength="7000" aria-describedby="intake-help intake-result"></textarea></div><div class="field"><label for="intake-sources">Source IDs (optional)</label><input id="intake-sources" maxlength="400" aria-describedby="intake-help intake-result"></div><p id="intake-help" class="meta">Creates draft Backlog cards, held until approval. Use registered src: IDs separated by spaces or commas. Approve or discard with ahub intake in a terminal.</p><button id="intake-submit" class="primary" data-mutation="project" type="submit">Create draft</button><p id="intake-result" class="meta" role="status" aria-live="polite" tabindex="-1"></p></form><div class="field"><h4>Registered sources (read-only)</h4><div id="sources" class="content"><p class="empty">Waiting for a project snapshot.</p></div></div></section>
507
511
  <section class="plan-block" aria-labelledby="plan-h-plan"><h3 id="plan-h-plan">Plan a task</h3><form id="planning"><div class="field"><label for="planning-task">Task</label><select id="planning-task"><option value="">Select task</option></select></div><div class="split"><label>Priority<select id="planning-priority"><option value="">Default</option><option value="0">P0</option><option value="1">P1</option><option value="2">P2</option><option value="3">P3</option></select></label><label>Order<input id="planning-rank" type="number" min="0" step="1"></label><label>Release<select id="planning-release"><option value="">No release</option></select></label><label>Planned start<input id="planning-start" type="datetime-local" step="any"></label><label>Due<input id="planning-due" type="datetime-local" step="any"></label></div><label><input id="planning-pinned" type="checkbox">Pin against planner changes</label><label>Reason<input id="planning-reason" maxlength="1000" required></label><div class="actions"><button id="planning-save" type="submit" data-mutation="project">Save plan</button><button id="planning-undo" type="button" data-mutation="project">Undo latest plan</button><button id="planning-reload" type="button">Reload current values</button></div><p id="planning-version" class="meta"></p></form></section><section id="split-panel" class="plan-block" aria-labelledby="plan-h-split" hidden><h3 id="plan-h-split">Split the selected backlog task</h3><form id="split-backlog"><label>Part titles, one per line<textarea id="split-titles" maxlength="2408" required></textarea></label><label><input id="split-sequential" type="checkbox">Each part waits for the previous part</label><label>Reason<input id="split-reason" maxlength="1000" required></label><p class="meta">Two to eight parts. The original task retains integration and acceptance. Unused parts can be undone from its current plan.</p><button id="split-save" type="submit" data-mutation="project">Stage parts</button></form></section><section class="plan-block" aria-labelledby="plan-h-releases"><h3 id="plan-h-releases">Releases</h3><div id="releases" class="content"></div><form id="release-new"><div class="split"><label>Name<input id="release-name" maxlength="64" required></label><label>Order<input id="release-order" type="number" min="0" value="0" step="1" required></label></div><label>Goal<input id="release-goal" maxlength="1000" required></label><button type="submit" data-mutation="project">Create release</button></form><form id="release-plan"><label>Release<select id="release-edit"><option value="">Select release</option></select></label><div class="split"><label>Planned start<input id="release-start" type="datetime-local" step="any"></label><label>Cut<input id="release-cut" type="datetime-local" step="any"></label><label>Ship<input id="release-ship" type="datetime-local" step="any"></label></div><button id="release-save" type="submit" data-mutation="project">Save dates</button><button id="release-reload" type="button">Reload current release</button><p id="release-version" class="meta"></p></form></section><section id="delivery" class="plan-block" aria-labelledby="plan-h-delivery"><h3 id="plan-h-delivery">Delivery evidence (read-only)</h3><div class="field"><label for="delivery-repo">GitHub repository</label><input id="delivery-repo" maxlength="200" placeholder="owner/repository"></div><div class="field"><label for="delivery-live">Live verification issue IDs (optional)</label><input id="delivery-live" maxlength="300" placeholder="Comma-separated issue numbers"></div><div class="actions"><button id="delivery-queue" type="button">Read merge queue</button><button id="delivery-release" type="button">Read release checklist</button></div><pre id="delivery-report" class="body" aria-live="polite"></pre></section><section class="plan-block" aria-labelledby="plan-h-propose"><h3 id="plan-h-propose">Propose a task</h3><form id="propose"><div class="field"><label for="title">Title</label><input id="title" name="title" required maxlength="300" placeholder="What should the team do?"></div><div class="field"><label for="detail">Details (optional)</label><textarea id="detail" name="detail" maxlength="8000"></textarea></div><div class="split"><div class="field"><label for="class">Class</label><select id="class" name="class"><option>implement</option><option>plan</option><option>bulk_edit</option><option>test</option><option>review</option><option>summarize</option><option>triage</option></select></div><div class="field"><label for="owner">Owner</label><select id="owner" name="owner"><option value="">Automatic</option></select></div></div><label><input id="backlog-proposal" type="checkbox"> Add to Backlog without offering</label><button class="primary" data-mutation="project" type="submit">Propose task</button></form></section>
508
512
  </dialog>
509
513
  <dialog id="task-detail-dialog" aria-labelledby="task-detail-title"><div class="dialog-head"><h2 id="task-detail-title">Task details</h2><button id="task-detail-close" type="button" autofocus>Close details</button></div><div id="task-detail-content"></div></dialog>
@@ -571,6 +575,7 @@ function initializeDashboardNavigation() {
571
575
  let ticket = location.hash.slice(1);
572
576
  history.replaceState(null, '', location.pathname);
573
577
  const $ = (id) => document.getElementById(id);
578
+ const reviewKit = null;
574
579
  const el = (tag, text, cls) => { const node = document.createElement(tag); if (text !== undefined) node.textContent = String(text); if (cls) node.className = cls; return node; };
575
580
  let stopped = false, cursor = 0, timer, projectsTimer, eventCount = 0;
576
581
  const setConnection = (text, state) => { const node = $('connection'); node.textContent = text; node.dataset.state = state; };
@@ -658,6 +663,10 @@ async function action(payload, button, managerAction = false) {
658
663
  return false;
659
664
  }
660
665
  if (key && pendingStarts.get(key) === pending) clearPendingStart(key);
666
+ if (['review_comment_put','review_comment_delete','review_comment_send'].includes(payload.action)) {
667
+ reviewCommentsCache.delete(reviewCommentsCacheKey(boardViewState?.snapshot.tasks?.find(task => task.id === payload.task), captured));
668
+ if (taskDetailId === payload.task) void refreshReviewComments(taskDetailId);
669
+ }
661
670
  if (!managerMode || captured.generation === viewGeneration) notice(result.text || 'Action accepted.');
662
671
  if ((payload.action === 'sdlc_queue' || payload.action === 'sdlc_release') && (stopped || captured.generation !== viewGeneration || captured.projectId !== selectedProjectId || captured.instanceId !== selectedInstanceId)) return false;
663
672
  return (payload.action === 'sdlc_queue' || payload.action === 'sdlc_release') ? result.report : true;
@@ -675,6 +684,7 @@ function saveDraft() {
675
684
  const previous = drafts.get(selectedProjectId) || {};
676
685
  drafts.set(selectedProjectId, {message:$('message').value, recipient:draftChoicesPending ? previous.recipient || '' : $('recipient').value,
677
686
  ...($('backlog-proposal')?.checked ? {backlog:true} : {}), title:$('title').value, detail:$('detail').value, class:$('class').value, owner:draftChoicesPending ? previous.owner || '' : $('owner').value});
687
+ drafts.get(selectedProjectId).intake = $('intake-text').value; drafts.get(selectedProjectId).intakeSources = $('intake-sources').value;
678
688
  }
679
689
  function restoreDraft(choices = false) {
680
690
  const draft = drafts.get(selectedProjectId) || {};
@@ -682,6 +692,7 @@ function restoreDraft(choices = false) {
682
692
  if (choices) {
683
693
  for (const id of ['recipient','owner']) $(id).value = Array.from($(id).options).some((o) => o.value === draft[id]) ? draft[id] : '';
684
694
  } else { $('message').value = draft.message || ''; $('title').value = draft.title || ''; $('detail').value = draft.detail || ''; $('class').value = draft.class || 'implement'; }
695
+ if (!choices) { $('intake-text').value = draft.intake || ''; $('intake-sources').value = draft.intakeSources || ''; }
685
696
  }
686
697
  function syncMutationControls() { document.querySelectorAll('button[data-mutation="project"]').forEach((b) => { b.disabled = pendingButtons.has(b) || Boolean(b.dataset.startKey && pendingStarts.has(b.dataset.startKey)) || (managerMode && !snapshotValid); }); }
687
698
  // The first-load blocks the page ships in #tasks and #peers (#296): a project switch puts copies of them back and update() replaces them
@@ -690,7 +701,7 @@ const firstLoad = Object.fromEntries(['tasks', 'peers'].map((name) => [name, [..
690
701
  function resetView(id, instance) {
691
702
  saveDraft();
692
703
  selectedProjectId = id || ''; selectedInstanceId = instance || ''; snapshotValid = false; cursor = 0; eventCount = 0; viewGeneration++;
693
- lastSnapshot = null;
704
+ lastSnapshot = null; reviewCommentsCache.clear();
694
705
  officePrev = null; officeDesks.clear(); officeCards.clear(); officeScene = null;
695
706
  $('office').replaceChildren(); $('office-section').hidden = true;
696
707
  closeTaskDetail(); renderNeedsAttention(null); applyDashboardView();
@@ -703,9 +714,10 @@ function resetView(id, instance) {
703
714
  $('split-titles').value='';$('split-reason').value='';$('split-sequential').checked=false;
704
715
  $('release-name').value='';$('release-goal').value='';$('release-order').value='0';
705
716
  $('delivery-report').textContent = ''; $('delivery-repo').value = ''; $('delivery-live').value = '';
717
+ $('intake-result').textContent = ''; $('intake-text').setAttribute('aria-invalid', 'false'); $('intake-sources').setAttribute('aria-invalid', 'false');
706
718
  // A project with a running instance is polled next, so the board and the peers show loading; "No project selected" is for no project.
707
719
  const loading = Boolean(id && instance) && typeof firstLoad !== 'undefined';
708
- for (const [id, text] of [['memory-list','No project selected.'],['memory-graph',''],['memory-detail',''],['peers','No project selected.'],['tasks','No project selected.'],['task-progress','No project selected.'],['permissions','No project selected.'],['budget','No project selected.'],['settings','No project selected.']]) {
720
+ for (const [id, text] of [['memory-list','No project selected.'],['memory-graph',''],['memory-detail',''],['peers','No project selected.'],['tasks','No project selected.'],['task-progress','No project selected.'],['permissions','No project selected.'],['budget','No project selected.'],['settings','No project selected.'],['sources','No project selected.']]) {
709
721
  $(id).replaceChildren();
710
722
  if (loading && firstLoad[id]?.length) $(id).replaceChildren(...firstLoad[id].map((node) => node.cloneNode(true)));
711
723
  else if (text) empty($(id), text);
@@ -1096,6 +1108,7 @@ function keyedStart(name, enter = false) {
1096
1108
  }
1097
1109
  const BOARD_FOCUS_COLUMNS = ['backlog','todo','progress','review','done'];
1098
1110
  let columnFocus, taskDetailId = null;
1111
+ const reviewCommentsCache = new Map();
1099
1112
  let planningEdit = null, releaseEdit = null;
1100
1113
  function isClosedTaskState(state) { return state === 'approved' || state === 'cancelled'; }
1101
1114
  const loadedPlanningDates = new Map();
@@ -1275,6 +1288,57 @@ function deliverablesSection(task) {
1275
1288
  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'));
1276
1289
  section.append(list); return section;
1277
1290
  }
1291
+ function reviewCommentsSection(task) {
1292
+ const expectedReview = boardContext(), section = el('div', undefined, 'detail-group review-comments'); section.setAttribute('role', 'region'); section.setAttribute('aria-label', 'Review comments'); section.append(el('h4', 'Review comments', 'detail-heading'));
1293
+ const view = boardViewState?.snapshot.reviewComments, current = view?.task === task.id ? view : null;
1294
+ const counts = current?.counts || task.reviewComments;
1295
+ section.append(el('p', counts ? ['draft','sent','resolved','dismissed'].map(key => (counts[key] || 0) + ' ' + key).join(' / ') : 'No review comments.', 'meta'));
1296
+ const writable = !!reviewKit && boardViewState?.snapshot.reviewWritable === true;
1297
+ const refresh = el('button', 'Refresh review comments'); refresh.type = 'button'; refresh.onclick = () => void refreshReviewComments(task.id); section.append(refresh);
1298
+ if (current?.unavailable) section.append(el('p', 'Review comments unavailable. Refresh to try again.', 'meta warn'));
1299
+ if (writable) for (const pkg of current?.packages || []) if (pkg.delivery === 'unconfirmed') {
1300
+ const retry = el('button', 'Retry saved package (' + pkg.count + ' comments)'); retry.type = 'button'; retry.dataset.mutation = 'project';
1301
+ retry.onclick = () => sameBoardContext(expectedReview) && void action({action:'review_comment_send',task:task.id,packageId:pkg.id},retry);
1302
+ section.append(retry);
1303
+ }
1304
+ for (const comment of current?.comments || []) {
1305
+ const thread = comment.thread, id = thread?.id || comment.id, item = el('article', undefined, 'detail-group');
1306
+ const placement = comment.placement?.status || 'unavailable', range = thread && (placement === 'anchored' || placement === 'moved') ? comment.placement?.range || thread.anchor.selectors.find(selector => selector.kind === 'lineRange') : undefined;
1307
+ item.append(el('p', (thread ? thread.anchor.path + (range ? ':' + range.startLine + '-' + range.endLine : '') : 'Comment ' + id) + ' (' + placement + ') / ' + comment.lifecycle, 'meta'));
1308
+ if (thread) item.append(el('p', thread.body), el('p', thread.author + ' / ' + thread.at, 'meta'));
1309
+ if (writable) {
1310
+ if (comment.lifecycle === 'draft' && thread) {
1311
+ const edit = el('textarea'); edit.value = thread.body; edit.maxLength = 2048; edit.setAttribute('aria-label', 'Edit review comment ' + thread.id);
1312
+ const save = el('button', 'Save draft'); save.type='button'; save.dataset.mutation='project';
1313
+ save.onclick = () => sameBoardContext(expectedReview) && void action({action:'review_comment_put',task:task.id,id:thread.id,revision:comment.revision,commit:comment.commit,anchor:thread.anchor,body:edit.value},save);
1314
+ item.append(edit,save);
1315
+ }
1316
+ if (comment.lifecycle !== 'dismissed') item.append(boardButton(comment.lifecycle === 'draft' ? 'Delete draft' : 'Dismiss', {action:'review_comment_delete',task:task.id,id,revision:comment.revision}));
1317
+ }
1318
+ section.append(item);
1319
+ }
1320
+ if (!writable) section.append(el('p', 'Read-only here. Open this task in a current acon console to comment and send.', 'meta'));
1321
+ else {
1322
+ if (counts?.draft) section.append(boardButton('Send draft comments', {action:'review_comment_send',task:task.id}));
1323
+ const label = el('label', 'File to review'), path = el('input'); path.setAttribute('aria-label','Task file path to review'); path.placeholder = 'path/to/document.md'; label.append(path);
1324
+ const mount = el('div'), open = el('button', 'Open review file'); open.type = 'button';
1325
+ const expected = boardContext();
1326
+ open.addEventListener('click', async () => {
1327
+ if (!sameBoardContext(expected)) return;
1328
+ open.disabled = true;
1329
+ try {
1330
+ const result = await reviewKit.snapshot({panel:'review_blob',projectId:selectedProjectId,instanceId:selectedInstanceId,task:task.id,path:path.value,side:'head'});
1331
+ if (!sameBoardContext(expected) || taskDetailId !== task.id) return;
1332
+ if (result.ok === false || !result.doc) throw new Error(result.error || 'Review material unavailable.');
1333
+ mount.replaceChildren(reviewKit.render({doc:result.doc,threads:(current?.comments || []).filter(comment => comment.thread && comment.thread.anchor.path === result.doc.path && comment.thread.anchor.side !== 'base').map(comment => comment.thread),
1334
+ onComment: ({anchor,body}) => sameBoardContext(expected) && action({action:'review_comment_put',task:task.id,commit:result.head,anchor,body})}));
1335
+ } catch (error) { if (sameBoardContext(expected)) notice(error.message, true); }
1336
+ finally { open.disabled = false; }
1337
+ });
1338
+ section.append(label,open,mount);
1339
+ }
1340
+ return section;
1341
+ }
1278
1342
  function taskDetail(task, card, stage, ids, overdue) {
1279
1343
  const row = el('article', undefined, 'task-detail'), head = el('div', undefined, 'row-head');
1280
1344
  row.setAttribute('aria-label', '#' + task.id + ' ' + task.title + ', ' + task.state.replaceAll('_', ' ') + ', owner ' + (task.owner || 'unassigned') + ', priority P' + card.priority);
@@ -1311,6 +1375,7 @@ function taskDetail(task, card, stage, ids, overdue) {
1311
1375
  if (links.children.length > 1) row.append(links);
1312
1376
  const code = codeGroupSection(task); if (code) row.append(code);
1313
1377
  const delivered = deliverablesSection(task); if (delivered) row.append(delivered);
1378
+ row.append(reviewCommentsSection(task));
1314
1379
  const controls = el('div', undefined, 'actions');
1315
1380
  if (card.pull && boardViewState?.snapshot.boardCapabilities?.backlog===true) controls.append(boardButton('Pull into To Do', {action:'backlog_pull',id:task.id}));
1316
1381
  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'}));
@@ -1362,7 +1427,7 @@ function setColumnFocus(column) {
1362
1427
  if (boardViewState) renderBoard(boardViewState.snapshot, boardViewState.peers, boardViewState.ids);
1363
1428
  }
1364
1429
  function closeTaskDetail() {
1365
- taskDetailId = null; const dialog = $('task-detail-dialog'); if (dialog.open) dialog.close();
1430
+ taskDetailId = null; reviewCommentsCache.clear(); const dialog = $('task-detail-dialog'); if (dialog.open) dialog.close();
1366
1431
  $('task-detail-content').replaceChildren(); signatures.delete('task-detail-content');
1367
1432
  }
1368
1433
  function refreshTaskDetail() {
@@ -1373,13 +1438,29 @@ function refreshTaskDetail() {
1373
1438
  const ids = boardViewState.ids.filter(id => ['idle','busy'].includes(boardViewState.peers[id]?.state) && boardViewState.peers[id]?.attached !== false && boardViewState.peers[id]?.rostered !== false);
1374
1439
  const overdue = task.due != null && task.due < Date.now() && !isClosedTaskState(task.state);
1375
1440
  $('task-detail-title').textContent = 'Task #' + task.id;
1376
- update('task-detail-content', [task, card, stage, ids, overdue, boardContext()], target => target.append(taskDetail(task, card, stage, ids, overdue)));
1441
+ update('task-detail-content', [task, card, stage, ids, overdue, boardContext(), snapshot.reviewComments, snapshot.reviewWritable], target => target.append(taskDetail(task, card, stage, ids, overdue)));
1377
1442
  }
1378
1443
  function openTaskDetail(id) {
1379
1444
  if (stopped || !Number.isSafeInteger(id) || !snapshotValid || !boardViewState?.cards.has(id)) return;
1380
- switchView('board'); taskDetailId = id; refreshTaskDetail();
1445
+ if (taskDetailId !== id) reviewCommentsCache.clear();
1446
+ switchView('board'); taskDetailId = id; refreshTaskDetail(); void refreshReviewComments(id);
1381
1447
  const dialog = $('task-detail-dialog'); if (taskDetailId !== null && !dialog.open) dialog.showModal();
1382
1448
  }
1449
+ function reviewCommentsCacheKey(task, ctx = context()) {
1450
+ return JSON.stringify([ctx.projectId,ctx.instanceId,ctx.generation,task?.id ?? null,task?.reviewStateKey ?? null]);
1451
+ }
1452
+ async function refreshReviewComments(id) {
1453
+ if (stopped || taskDetailId !== id || !snapshotValid) return;
1454
+ const captured = context(), task = lastSnapshot?.tasks?.find(row => row.id === id), cacheKey = reviewCommentsCacheKey(task, captured), input = {after:cursor, view:'board', reviewTask:id, reviewRefresh:true};
1455
+ if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
1456
+ try {
1457
+ const result = await post('/snapshot', input);
1458
+ if (taskDetailId !== id || captured.generation !== viewGeneration || (managerMode && (result.projectId !== captured.projectId || result.instanceId !== captured.instanceId))
1459
+ || reviewCommentsCacheKey(lastSnapshot?.tasks?.find(row => row.id === id), captured) !== cacheKey) return;
1460
+ reviewCommentsCache.set(cacheKey, result.reviewComments || {task:id, unavailable:true});
1461
+ if (lastSnapshot) { lastSnapshot = {...lastSnapshot, reviewComments:reviewCommentsCache.get(cacheKey)}; render(lastSnapshot); }
1462
+ } catch (error) { if (taskDetailId === id) notice('Review comments unavailable: ' + error.message, true); }
1463
+ }
1383
1464
  function renderBoard(snapshot, peers, ids) {
1384
1465
  if (columnFocus === undefined) {
1385
1466
  columnFocus = dashboardCookie.get('agent-hub-column-focus') || null;
@@ -1457,6 +1538,44 @@ function renderBoard(snapshot, peers, ids) {
1457
1538
  });
1458
1539
  refreshTaskDetail();
1459
1540
  }
1541
+ function renderSources(sources) {
1542
+ update('sources', sources ?? null, target => {
1543
+ if (!Array.isArray(sources)) return empty(target, 'Source list unavailable for this project snapshot.');
1544
+ if (!sources.length) return empty(target, 'No registered sources. Register a file or URL with ahub source add.');
1545
+ for (const source of sources) {
1546
+ const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
1547
+ head.append(el('code', source.id, 'label'), el('span', source.kind, 'meta'));
1548
+ const registered = Number.isSafeInteger(source.added_at) ? new Date(source.added_at).toLocaleString() : 'unknown';
1549
+ row.append(head, el('p', source.bytes + ' bytes · hash ' + source.hashPrefix + ' · registered ' + registered, 'meta'));
1550
+ target.append(row);
1551
+ }
1552
+ });
1553
+ }
1554
+ function intakeFeedback(text, error = false) {
1555
+ const result = $('intake-result'); result.textContent = text; result.classList.toggle('error', error);
1556
+ }
1557
+ async function submitIntake(event) {
1558
+ event.preventDefault();
1559
+ const submit = event.submitter || $('intake-submit');
1560
+ if (pendingButtons.has(submit) || stopped) return;
1561
+ const text = $('intake-text').value.trim(), sourceText = $('intake-sources').value.trim();
1562
+ const sources = sourceText ? sourceText.split(/[\s,]+/).filter(Boolean) : [];
1563
+ $('intake-text').setAttribute('aria-invalid', 'false'); $('intake-sources').setAttribute('aria-invalid', 'false');
1564
+ const invalid = !text || text.length > 7000 ? 'intake-text' : sources.length > 16 || new Set(sources).size !== sources.length || sources.some(id => !/^src:[0-9a-f]{16}$/.test(id)) ? 'intake-sources' : '';
1565
+ if (invalid) {
1566
+ intakeFeedback(invalid === 'intake-text' ? 'Enter a request of 1 to 7000 characters.' : 'Use at most 16 distinct registered src: IDs; run ahub source list to check them.', true);
1567
+ $(invalid).setAttribute('aria-invalid', 'true'); $(invalid).focus(); return;
1568
+ }
1569
+ const captured = context();
1570
+ intakeFeedback('Creating draft. Waiting for the hub result...');
1571
+ const accepted = await action({action:'intake', text, sources}, submit);
1572
+ const current = context();
1573
+ if (stopped || captured.generation !== current.generation || captured.projectId !== current.projectId || captured.instanceId !== current.instanceId) return;
1574
+ if (accepted) {
1575
+ if ($('intake-text').value.trim() === text && $('intake-sources').value.trim() === sourceText) { $('intake-text').value = ''; $('intake-sources').value = ''; }
1576
+ intakeFeedback('Draft saved to Backlog. Inspect it there; approve or discard with ahub intake in a terminal.');
1577
+ } else intakeFeedback('Intake was not confirmed. Read the notice and check Backlog before submitting again.', true);
1578
+ }
1460
1579
  const attentionBaseTitle = typeof document === 'undefined' ? 'agent-hub | Local dashboard' : document.title;
1461
1580
  let attentionSignature = '', attentionFirst;
1462
1581
  function reachAttention() {
@@ -1745,7 +1864,7 @@ function render(snapshot) {
1745
1864
  const current = (snapshot.boardLegend || []).find(item => item.peer === id)?.activeTasks, summary = el('p', undefined, 'peer-summary');
1746
1865
  summary.append(el('span', peer.queued + ' queued' + (peer.queuedImportant ? ' (' + peer.queuedImportant + ' important)' : ''), 'meta'), el('span', Array.isArray(current) && current.length ? 'Working on ' + current.map(task => '#' + task).join(', ') : 'No active task', 'meta'));
1747
1866
  // #878: the session title or current plan item the hub observed, already screened (a stub while private).
1748
- if (typeof peer.doing?.text === 'string' && peer.doing.text) summary.append(el('span', 'Doing: ' + peer.doing.text, 'meta'));
1867
+ if (typeof peer.doing?.text === 'string' && peer.doing.text) summary.append(el('span', (peer.doing.source === 'label' ? 'Unnamed session: ' : 'Doing: ') + peer.doing.text, 'meta'));
1749
1868
  row.append(head, summary);
1750
1869
  // A status notice asks a person to act (for example, reattach a native session): it stays visible, never folded away.
1751
1870
  if (typeof peer.statusNotice?.message === 'string' && peer.statusNotice.message) row.append(el('p', peer.statusNotice.message, 'meta notice-warn'));
@@ -1783,6 +1902,7 @@ function render(snapshot) {
1783
1902
  const peerSignature = JSON.stringify([ids, assignable]);
1784
1903
  if (signatures.get('peer-options') !== peerSignature) { signatures.set('peer-options', peerSignature); peerOptions($('owner'), assignable, 'Automatic'); peerOptions($('recipient'), ids, 'All peers'); if (draftChoicesPending) { restoreDraft(true); draftChoicesPending = false; } }
1785
1904
  renderBoard(snapshot, peers, ids);
1905
+ renderSources(snapshot.sources);
1786
1906
  renderApprovals(snapshot.permissions);
1787
1907
  update('budget', snapshot.budget, (target) => {
1788
1908
  if (!Object.keys(snapshot.budget).length) return empty(target, 'No usage readings yet. Usage appears when a peer reports it.');
@@ -1810,7 +1930,9 @@ function render(snapshot) {
1810
1930
  }
1811
1931
  });
1812
1932
  renderBench(snapshot.bench);
1813
- renderMemory(snapshot.memory);
1933
+ renderResults(snapshot.results);
1934
+ renderWorktrees(snapshot.worktrees);
1935
+ renderMemory(snapshot.memory, snapshot.memorySync);
1814
1936
  renderSettings(snapshot.settings, snapshot.roster);
1815
1937
  renderUsage(snapshot.usage, snapshot.tasks, snapshot.budget);
1816
1938
  renderSchedules(snapshot.schedules);
@@ -1842,6 +1964,9 @@ async function poll() {
1842
1964
  const input = {after:cursor, memory:memoryInput(), view:currentView}; if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
1843
1965
  const result = await post('/snapshot', input);
1844
1966
  if (!managerMode || (result.projectId === captured.projectId && result.instanceId === captured.instanceId && captured.generation === viewGeneration)) {
1967
+ const detailTask = taskDetailId === null ? undefined : result.tasks?.find(task => task.id === taskDetailId), detailKey = reviewCommentsCacheKey(detailTask, captured);
1968
+ if (taskDetailId !== null && reviewCommentsCache.has(detailKey)) result.reviewComments = reviewCommentsCache.get(detailKey);
1969
+ else if (taskDetailId !== null) reviewCommentsCache.clear();
1845
1970
  snapshotValid = true; lastSnapshot = result; lastSnapshotAt = Date.now(); render(result); setConnection('Live / updated ' + new Date().toLocaleTimeString(), 'live'); setDisconnected(false);
1846
1971
  }
1847
1972
  }
@@ -1879,7 +2004,7 @@ function officeTile(w, h, cls) {
1879
2004
  }
1880
2005
  function officeViewBox(w, h, model) {
1881
2006
  if (officeProjection !== 'iso') return '0 0 ' + w + ' ' + h;
1882
- const corners = model ? [...model.rooms, ...model.desks].flatMap(tile => [officeProject(tile.x, tile.y), officeProject(tile.x + tile.w, tile.y), officeProject(tile.x, tile.y + tile.h), officeProject(tile.x + tile.w, tile.y + tile.h)].map(([x, y]) => [x, y + (model.rooms.includes(tile) ? officeRoomOffset : 0)])) : [officeProject(0, 0), officeProject(w, 0), officeProject(0, h), officeProject(w, h)];
2007
+ const corners = model ? [...model.rooms, ...(model.docks || []), ...model.desks].flatMap(tile => [officeProject(tile.x, tile.y), officeProject(tile.x + tile.w, tile.y), officeProject(tile.x, tile.y + tile.h), officeProject(tile.x + tile.w, tile.y + tile.h)].map(([x, y]) => [x, y + (model.desks.includes(tile) ? 0 : officeRoomOffset)])) : [officeProject(0, 0), officeProject(w, 0), officeProject(0, h), officeProject(w, h)];
1883
2008
  const xs = corners.map((p) => p[0]), ys = corners.map((p) => p[1]), m = 3;
1884
2009
  const minX = Math.min(...xs), minY = Math.min(...ys);
1885
2010
  return (minX - m) + ' ' + (minY - 6) + ' ' + (Math.max(...xs) - minX + 2 * m) + ' ' + (Math.max(...ys) - minY + 9);
@@ -1934,16 +2059,16 @@ function officeClip(text, max) {
1934
2059
  }
1935
2060
  let officeClipSerial = 0;
1936
2061
  let officeMeasureContext;
1937
- function officeTextWidth(value, monospace) {
2062
+ function officeTextWidth(value, monospace, size = OFFICE_SCALE, weight = '') {
1938
2063
  if (officeMeasureContext === undefined) {
1939
2064
  try { officeMeasureContext = document.createElement('canvas').getContext?.('2d') || null; } catch { officeMeasureContext = null; }
1940
2065
  }
1941
2066
  if (officeMeasureContext) {
1942
- officeMeasureContext.font = OFFICE_SCALE + 'px ' + (monospace ? 'ui-monospace,monospace' : 'system-ui,sans-serif');
2067
+ officeMeasureContext.font = (weight ? weight + ' ' : '') + size + 'px ' + (monospace ? 'ui-monospace,monospace' : 'system-ui,sans-serif');
1943
2068
  return officeMeasureContext.measureText(value).width;
1944
2069
  }
1945
2070
  // Non-browser fixtures or unavailable canvas: conservative glyph widths; clipPath is the hard boundary.
1946
- return [...value].reduce((width, char) => width + (!/^[ -~]$/.test(char) ? 1 : monospace ? 0.6 : /[MW@#%]/.test(char) ? 0.9 : /[ilI., ']/.test(char) ? 0.3 : 0.55), 0) * OFFICE_SCALE;
2071
+ return [...value].reduce((width, char) => width + (!/^[ -~]$/.test(char) ? 1 : monospace ? 0.6 : /[MW@#%]/.test(char) ? 0.9 : /[ilI., ']/.test(char) ? 0.3 : 0.55), 0) * size;
1947
2072
  }
1948
2073
  function officeCardLabel(card, width) {
1949
2074
  const prefix = '#' + card.id, rest = String(card.label).slice(prefix.length);
@@ -1990,6 +2115,7 @@ function spotlightRow(row) { if (spotlitRow && spotlitRow !== row) spotlitRow.cl
1990
2115
  function activateOfficePeer(id) { switchView('agents'); const row = peerRows.get(id); if (!row) return; spotlightRow(row); row.scrollIntoView({ block: 'nearest' }); row.focus?.(); }
1991
2116
  function activateOfficeTask(id) { setColumnFocus(null); if (!taskRows.has(id)) officeFilter(''); const row = taskRows.get(id); if (!row) return; spotlightRow(row); row.scrollIntoView({ block: 'nearest' }); openTaskDetail(id); }
1992
2117
  function officeFilter(state) { $('task-filter').value = state; $('task-peer-filter').value = ''; $('task-release-filter').value = ''; if (!stopped && snapshotValid && lastSnapshot) render(lastSnapshot); }
2118
+ function officeRelease(id) { setColumnFocus(null); $('task-filter').value = ''; $('task-peer-filter').value = ''; $('task-release-filter').value = id == null ? '' : String(id); if(id == null)$('task-lanes').value = 'releases'; if (!stopped && snapshotValid && lastSnapshot) render(lastSnapshot); switchView('board'); $('tasks').scrollIntoView({block:'nearest'}); }
1993
2119
  // Fade timing for a transient mark: it holds, then fades over the final second so it is gone at `until`.
1994
2120
  // Applied once at creation; later renders leave the running animation alone.
1995
2121
  function officeFade(node, until, now) {
@@ -2006,16 +2132,25 @@ function officeFade(node, until, now) {
2006
2132
  function officeCardPaint(node, card) {
2007
2133
  node.setAttribute('class', 'office-card-node office-movable' + (card.waiting ? ' office-waiting-card' : '') + (card.rework ? ' office-rework-card' : '') + (card.checking ? ' office-card-checking' : ''));
2008
2134
  node.setAttribute('aria-label', card.name);
2009
- const innerWidth = Math.max(0.1, card.w - 1.6 - (card.checking ? 1 : 0));
2135
+ const innerWidth = Math.max(0.1, card.w - 1.6 - (card.checking ? 1 : 0) - (card.releaseTag ? 1.8 : 0));
2010
2136
  const label = officeCardLabel(card, innerWidth * OFFICE_SCALE), prefix = '#' + card.id, clipId = node.dataset.officeClipId || (node.dataset.officeClipId = 'office-card-clip-' + (++officeClipSerial));
2011
2137
  const clip = officeNode('clipPath', { id: clipId, clipPathUnits: 'userSpaceOnUse' });
2012
2138
  clip.append(officeNode('rect', { x: 0.8, y: 0, width: innerWidth, height: card.h }));
2013
2139
  const text = officeNode('text', { x: 0.8, y: card.h / 2 + 0.33, class: 'office-card-text', 'clip-path': 'url(#' + clipId + ')' });
2140
+ const marked = card.reserved || card.pinned || card.waiting && card.state === 'backlog';
2141
+ if(marked)text.setAttribute('y','0.95');
2014
2142
  text.append(officeNode('tspan', { class: 'office-card-id' }, prefix), officeNode('tspan', {}, label.slice(prefix.length)));
2015
2143
  const children = [officeNode('title', {}, card.label), clip,
2016
2144
  officeNode('rect', { x: 0, y: 0, width: card.w, height: card.h, rx: 0.5, class: 'office-card' }),
2017
2145
  officeNode('rect', { x: 0.15, y: 0.25, width: 0.2, height: Math.max(0.4, card.h - 0.5), rx: 0.1, class: 'office-card-accent' }), text];
2018
2146
  if (card.checking) children.push(officeIcon('i-check', card.w - 1.5, card.h / 2 - 0.46, 0.92, 'office-checking'));
2147
+ const marks=[]; let mx=0.8;
2148
+ const mark=(shape,label)=>{const g=officeNode('g',{});shape.setAttribute('transform','translate('+mx+',1.35)');g.append(shape,officeNode('text',{x:mx+0.85,y:2.05,class:'office-meta'},label));marks.push(g);mx+=0.85+officeTextWidth(label,false)/OFFICE_SCALE+0.6;};
2149
+ if(card.reserved){const key=Object.hasOwn(peerPalette,card.reserved)?card.reserved:'other';mark(officeNode('circle',{cx:0.3,cy:0.3,r:0.25,class:'office-reserved peer-'+key}),officeClip(card.reserved,8));}
2150
+ if(card.pinned)mark(officeNode('path',{d:'M0 0H.6L.45 .4H.15ZM.3 .4V.8',class:'office-plan-shape'}),'pin');
2151
+ if(card.waiting && card.state==='backlog')mark(officeNode('path',{d:'M.3 0 .65 .65H0Z',class:'office-plan-shape'}),'waits'+(card.plannedDate?' '+card.plannedDate:''));
2152
+ if(marks.length){const group=officeNode('g',{'clip-path':'url(#'+clipId+')',class:'office-card-marks'});group.append(...marks);children.push(group);}
2153
+ if(card.releaseTag)children.push(officeNode('text',{x:card.w-0.6,y:marked?0.95:card.h/2+0.33,'text-anchor':'end',class:'office-meta'},'R'+card.releaseTag.ordinal));
2019
2154
  node.replaceChildren(...children);
2020
2155
  }
2021
2156
  function officeDeskPaint(node, desk) {
@@ -2064,6 +2199,7 @@ function officeDeskPaint(node, desk) {
2064
2199
  if (desk.tray.needsReview) tray.push(desk.tray.needsReview + ' to review');
2065
2200
  if (desk.tray.heldBy) tray.push('held by ' + desk.tray.heldBy);
2066
2201
  if (tray.length) children.push(officeNode('text', { x: 1, y: line, class: 'office-meta' }, officeClip(tray.join(' / '), 38)));
2202
+ if(desk.planner){const badge=officeNode('g',{class:'office-planner','aria-label':'planner, '+desk.unplanned+' unplanned backlog cards'});badge.append(officeNode('path',{d:'M7 5H8V6H7ZM7.5 6V6.5',class:'office-plan-shape'}),officeNode('text',{x:8.4,y:6,class:'office-meta'},String(desk.unplanned)));children.push(badge);}
2067
2203
  node.replaceChildren(...children);
2068
2204
  }
2069
2205
  // More is a sibling of the desk button, so assistive technology exposes both targets.
@@ -2082,6 +2218,7 @@ function officeRoomHeader(node, room) {
2082
2218
  node.replaceChildren(...(iso ? [officeNode('rect', { x: -0.5, y: -2.55, width: 12.5, height: 2, rx: 0.5, class: 'office-header-surface' })] : []), officeNode('text', { x: iso ? 0 : 1, y, class: 'office-room-title' }, room.label),
2083
2219
  officeNode('rect', { x: right - 3, y: y - 1.35, width: 3, height: 2, rx: 0.5, class: 'office-count-pill' }),
2084
2220
  officeNode('text', { x: right - 1.5, y, 'text-anchor': 'middle', class: 'office-room-count' }, String(room.count)));
2221
+ if(room.unplanned!==undefined)node.append(officeNode('text',{x:iso?0:1,y:y+1.15,class:'office-meta'},room.unplanned+' unplanned'));
2085
2222
  }
2086
2223
  function officeRoomPaint(node, room) {
2087
2224
  node.setAttribute('class', 'office-roombox office-roombox-' + room.id);
@@ -2095,12 +2232,21 @@ function officeRoomPaint(node, room) {
2095
2232
  const at = iso ? officeIsoLocal(room.w / 2, room.h - 1.5) : [1, room.h - 1];
2096
2233
  const more = officeNode('g', { tabindex: '0', role: 'button', 'aria-label': 'Show ' + room.overflow + ' more ' + room.filterState.replaceAll('_', ' ') + ' tasks in the task list' });
2097
2234
  more.append(officeNode('text', { x: at[0], y: at[1], 'text-anchor': iso ? 'middle' : 'start', class: 'office-more' }, '+' + room.overflow + ' more'));
2098
- const open = () => officeFilter(room.filterState);
2235
+ const open = () => { setColumnFocus(null); officeFilter(room.filterState); switchView('board'); };
2099
2236
  more.addEventListener('click', open);
2100
2237
  more.addEventListener('keydown', (event) => officeKeys(event, open));
2101
2238
  node.append(more);
2102
2239
  }
2103
2240
  }
2241
+ function officeDockPaint(node,dock) {
2242
+ officePlace(node,dock.x,dock.y,true);node.setAttribute('class','office-release-dock');node.setAttribute('aria-label','Release '+dock.ordinal+': '+dock.name+', '+dock.state+', '+dock.approved+' / '+dock.scope+' approved'+(dock.cut?', cut '+dock.cut:''));
2243
+ const iso=officeProjection==='iso',at=iso?officeIsoLocal(dock.w/2,7):[1,5],x=at[0],y=at[1],align=iso?'middle':'start';
2244
+ const full='R'+dock.ordinal+' '+dock.name,width=dock.w-2,chars=[...full],measure=value=>officeTextWidth(value,false,13,'600');let title=full;
2245
+ if(measure(title)>width*OFFICE_SCALE){let low=0,high=chars.length;while(low<high){const mid=Math.ceil((low+high)/2);if(measure(chars.slice(0,mid).join('')+'...')<=width*OFFICE_SCALE)low=mid;else high=mid-1;}title=chars.slice(0,low).join('').trimEnd()+'...';}
2246
+ const clipId=node.dataset.officeDockClipId || (node.dataset.officeDockClipId='office-dock-clip-'+(++officeClipSerial)),clip=officeNode('clipPath',{id:clipId,clipPathUnits:'userSpaceOnUse'});clip.append(officeNode('rect',{x:iso?x-width/2:x,y:y-1.15,width,height:1.5}));
2247
+ node.replaceChildren(officeTile(dock.w,dock.h,'office-room'),officeNode('title',{},full),clip,officeNode('text',{x,y,'text-anchor':align,class:'office-room-title office-dock-title','clip-path':'url(#'+clipId+')'},title),officeNode('text',{x,y:y+1.5,'text-anchor':align,class:'office-meta'},dock.state+' · '+dock.approved+' / '+dock.scope),officeNode('rect',{x:iso?x-5:1,y:y+2.3,width:10,height:0.3,class:'office-count-pill'}),officeNode('rect',{x:iso?x-5:1,y:y+2.3,width:10*dock.ratio,height:0.3,class:'office-dock-progress'}));
2248
+ if(dock.cut)node.append(officeNode('text',{x,y:y+4,'text-anchor':align,class:'office-meta'},'Cut '+dock.cut));
2249
+ }
2104
2250
  let officeScene = null;
2105
2251
  function officeEnsure(target) {
2106
2252
  if (officeScene) return officeScene;
@@ -2163,6 +2309,11 @@ function drawOffice(target, model) {
2163
2309
  const visible = roomCards.get(original.id), room = { ...original, cards: visible, overflow: original.overflow + original.cards.length - visible.length };
2164
2310
  keep('room:' + room.id, JSON.stringify({ ...room, cards: undefined, visibleCount: room.cards.length }), scene.layers.rooms, (node) => officeRoomPaint(node, room), false);
2165
2311
  }
2312
+ for(const dock of model.docks || []){
2313
+ const {node,fresh}=keep('dock:'+dock.id,JSON.stringify(dock),scene.layers.rooms,n=>officeDockPaint(n,dock),false);
2314
+ if(fresh&&!node.getAttribute('tabindex')){node.setAttribute('tabindex','0');node.setAttribute('role','button');const open=()=>officeRelease(dock.id);node.addEventListener('click',open);node.addEventListener('keydown',event=>officeKeys(event,open));}
2315
+ }
2316
+ if(model.extraReleases>0 && model.docks.length){const dock=model.docks.at(-1);const {node,fresh}=keep('dock:more',JSON.stringify([dock,model.extraReleases]),scene.layers.headers,n=>{officePlace(n,dock.x,dock.y,true);n.setAttribute('aria-label','Show '+model.extraReleases+' more releases');const at=officeProjection==='iso'?officeIsoLocal(dock.w/2,dock.h-2):[1,dock.h-2];n.replaceChildren(officeNode('text',{x:at[0],y:at[1],class:'office-more'},'+'+model.extraReleases+' releases'));},false);if(fresh&&!node.getAttribute('tabindex')){node.setAttribute('tabindex','0');node.setAttribute('role','button');const open=()=>officeRelease(null);node.addEventListener('click',open);node.addEventListener('keydown',event=>officeKeys(event,open));}}
2166
2317
  const deskCards = new Map(model.desks.map(desk => [desk.id, officeDeskCards(desk)]));
2167
2318
  for (const original of model.desks) {
2168
2319
  const visible = deskCards.get(original.id), desk = { ...original, cards: visible, extraCards: original.extraCards + original.cards.length - visible.length };
@@ -2194,7 +2345,7 @@ function drawOffice(target, model) {
2194
2345
  };
2195
2346
  const paintNodes = [], focusNodes = [];
2196
2347
  for (const card of cards) {
2197
- const { node } = keep('paint:card:' + card.id, JSON.stringify([card.label, card.name, card.waiting, card.rework, card.checking, card.w, card.h]), scene.layers.cards, n => { officeCardPaint(n, card); n.setAttribute('aria-hidden', 'true'); });
2348
+ const { node } = keep('paint:card:' + card.id, JSON.stringify([card.label, card.name, card.waiting, card.rework, card.checking, card.reserved, card.pinned, card.plannedDate, card.releaseTag, card.w, card.h]), scene.layers.cards, n => { officeCardPaint(n, card); n.setAttribute('aria-hidden', 'true'); });
2198
2349
  paintNodes.push(node); placeCard(node, card);
2199
2350
  }
2200
2351
  for (const card of focusCards) {
@@ -2211,7 +2362,7 @@ function drawOffice(target, model) {
2211
2362
  focusNodes.push(node); placeCard(node, card);
2212
2363
  }
2213
2364
  officeOrderNodes(scene.layers.cards, paintNodes); officeOrderNodes(scene.layers.cardFocus, focusNodes);
2214
- for (const room of model.rooms) keep('header:' + room.id, JSON.stringify([room.x, room.y, room.w, room.count, room.label]), scene.layers.headers, node => officeRoomHeader(node, room), false);
2365
+ for (const room of model.rooms) keep('header:' + room.id, JSON.stringify([room.x, room.y, room.w, room.count, room.label,room.unplanned]), scene.layers.headers, node => officeRoomHeader(node, room), false);
2215
2366
  if (model.extraPeers > 0) {
2216
2367
  keep('extra:peers', String(model.extraPeers) + ':' + model.width, scene.layers.marks,
2217
2368
  (node) => { officePlace(node, model.width, 0); node.replaceChildren(officeNode('text', { x: 0, y: 1.8, 'text-anchor': 'end', class: 'office-meta' }, '+' + model.extraPeers + ' peers')); }, false);
@@ -2299,6 +2450,85 @@ let streamFilter = '';
2299
2450
  function applyStreamFilter(node) { node.hidden = streamFilter !== '' && node.dataset.kind !== streamFilter; }
2300
2451
  function refreshStreamEmpty() { const has = $('stream').querySelector('.event:not([hidden])') !== null, any = $('stream').querySelector('.event') !== null; $('stream-empty').hidden = has || !any; }
2301
2452
  // #251: benchmark runs on this machine and arm comparisons per suite. Every bar carries its number as text.
2453
+ /* #1054 T6: unread results, the runs a person started that settled and have not been seen. The snapshot only READS them: building this list never marks one read (a poll is not a person
2454
+ looking). A receipt is a person's click: Mark read (one), Mark all read, or Mark last unread again (relights the result read last; it lives here and in `ahub results --unread-last`, never in
2455
+ the acon inbox, because a read item leaves the inbox). The title is `<peer>: <outcome word>` and the detail is what the hub built at display (a stub when private, nothing when the request is no
2456
+ longer held); text is set with textContent only. The acon seeing rule (focus, visibility, a second in view) is the shell's and is not here: this panel's marks are explicit clicks. */
2457
+ function renderResults(results) {
2458
+ const unread = results && Array.isArray(results.unread) ? results.unread : [];
2459
+ const more = results && Number.isSafeInteger(results.moreUnread) ? results.moreUnread : 0;
2460
+ $('results-count').textContent = results ? unread.length + more + ' unread' : '';
2461
+ update('results', results === undefined ? null : results, (target) => {
2462
+ if (results === undefined) return empty(target, 'Results are shown to the person on a current snapshot.');
2463
+ const tools = el('div', undefined, 'row-head');
2464
+ if (unread.length) tools.append(button('Mark all read', {action: 'results_read_all'}));
2465
+ if (results.canUnread) tools.append(button('Mark last unread again', {action: 'results_unread_last'}));
2466
+ if (!unread.length) target.append(el('p', 'Nothing unread.', 'meta'));
2467
+ for (const item of unread) {
2468
+ const block = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
2469
+ head.append(el('span', String(item.title), 'label'));
2470
+ head.append(badge(String(item.outcome), item.outcome === 'done' ? 'ok' : item.outcome === 'failed' ? 'err' : 'warn'));
2471
+ const stamp = el('span'); if (item.settledAt) { stamp.dataset.ts = String(item.settledAt); stamp.title = time(item.settledAt); stamp.textContent = 'settled ' + rel(item.settledAt); } else stamp.textContent = ''; head.append(stamp);
2472
+ if (Number.isFinite(item.ms)) head.append(el('span', Math.round(item.ms / 1000) + 's', 'meta'));
2473
+ head.append(button('Mark read', {action: 'result_seen', id: String(item.id)}));
2474
+ block.append(head);
2475
+ if (item.detail && (item.detail.kind === 'head' || item.detail.kind === 'stub') && typeof item.detail.text === 'string') block.append(el('p', item.detail.text, 'meta'));
2476
+ target.append(block);
2477
+ }
2478
+ if (more > 0) target.append(el('p', more + ' older unread; run ahub results --unread to list them all.', 'meta'));
2479
+ target.append(tools);
2480
+ });
2481
+ }
2482
+ /* #1057: the worktree shelf. A panel refresh reads nothing from GitHub: the rows come from the hub's cached facts, and only the explicit 'Refresh PR states' button asks it to read PR states. Removal is never a button: the panel shows the command to run in a terminal. */
2483
+ function renderWorktrees(shelf) {
2484
+ const live = shelf && Array.isArray(shelf.live) ? shelf.live : [], settled = shelf && Array.isArray(shelf.settled) ? shelf.settled : [];
2485
+ const known = Boolean(shelf) && !shelf.unavailable && !shelf.pending;
2486
+ $('worktrees-count').textContent = known ? live.length + ' live, ' + settled.length + ' settled' : '';
2487
+ update('worktrees', shelf === undefined ? null : shelf, (target) => {
2488
+ if (shelf === undefined) return empty(target, 'Worktrees are shown on a current snapshot.');
2489
+ if (shelf.pending) return empty(target, 'Reading worktrees...');
2490
+ if (shelf.unavailable) return empty(target, 'Worktrees unavailable: git could not list this project\'s worktrees, or the hub is restarting.');
2491
+ const reasonText = {merged: 'PR merged', closed: 'PR closed', quiet: 'quiet for ' + (shelf.quietDays || 0) + '+ days', parked: 'parked by you'};
2492
+ const group = (title, rows, isSettled) => {
2493
+ const block = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
2494
+ head.append(el('span', title + ' (' + rows.length + ')', 'label'));
2495
+ block.append(head);
2496
+ if (!rows.length) block.append(el('p', isSettled ? 'Nothing settled.' : 'No live worktrees.', 'meta'));
2497
+ for (const item of rows) {
2498
+ const line = el('div', undefined, 'row-head'), name = el('span', String(item.path), 'label');
2499
+ line.append(name);
2500
+ if (item.branch) line.append(el('span', String(item.branch), 'meta'));
2501
+ if (item.task !== undefined) line.append(badge('#' + item.task + (item.taskState ? ' ' + item.taskState : '')));
2502
+ if (item.pr !== undefined) line.append(badge('PR ' + item.pr));
2503
+ if (isSettled && item.reason) line.append(badge(reasonText[item.reason] || String(item.reason), item.reason === 'parked' ? '' : 'ok'));
2504
+ if (item.outside) line.append(badge('outside project'));
2505
+ // `rel` and `data-ts` take epoch milliseconds (refreshRelativeTimes reads Number(dataset.ts)), never an ISO string.
2506
+ const stamp = el('span'); if (item.lastActiveAt) { stamp.dataset.ts = String(item.lastActiveAt); stamp.title = time(item.lastActiveAt); stamp.textContent = 'active ' + rel(item.lastActiveAt); } else stamp.textContent = 'activity unknown'; line.append(stamp);
2507
+ // Park moves a live row to the shelf and Pin keeps a settled row live; both are the person's call and the only writes here. 'Unset' returns the row to what the rules derive.
2508
+ if (!item.fixed) line.append(button(isSettled ? 'Pin' : 'Park', {action: 'worktree_override_set', path: String(item.path), value: isSettled ? 'active' : 'settled'}));
2509
+ else line.append(badge('never shelved'));
2510
+ if (item.reason === 'parked') line.append(button('Unset', {action: 'worktree_override_clear', path: String(item.path)}));
2511
+ block.append(line);
2512
+ }
2513
+ return block;
2514
+ };
2515
+ target.append(group('Live', live, false), group('Settled', settled, true));
2516
+ if (shelf.complete === false) target.append(el('p', 'Some worktrees could not be read in time; they are listed as live. The next refresh tries again.', 'meta'));
2517
+ const tools = el('div', undefined, 'row-head');
2518
+ const readNote = el('span', undefined, 'meta');
2519
+ if (shelf.prsReadAt) { readNote.append('PR states read '); const when = el('span'); when.dataset.ts = String(shelf.prsReadAt); when.title = time(shelf.prsReadAt); when.textContent = rel(shelf.prsReadAt); readNote.append(when); }
2520
+ else readNote.textContent = 'PR states not read yet: merged work shows as merged only after a guarded merge or a refresh.';
2521
+ tools.append(button('Refresh PR states', {action: 'worktrees_refresh_prs'}), readNote);
2522
+ const foot = el('div', undefined, 'row');
2523
+ foot.append(tools);
2524
+ if (settled.length) {
2525
+ const tidy = el('p', 'To remove settled worktrees safely, run in a terminal (it previews first and removes only after you confirm): ', 'meta');
2526
+ tidy.append(el('code', 'ahub worktrees tidy'));
2527
+ foot.append(tidy);
2528
+ }
2529
+ target.append(foot);
2530
+ });
2531
+ }
2302
2532
  function renderBench(bench) {
2303
2533
  const pct = (v) => v === null || v === undefined ? '-' : Math.round(v * 100) + '%';
2304
2534
  const num = (v) => v === null || v === undefined ? '-' : String(Math.round(v));
@@ -2430,7 +2660,30 @@ function layout(graph) {
2430
2660
  return pos;
2431
2661
  }
2432
2662
  function selectMemoryNode(node) { memoryQuery.node = memoryQuery.node === node ? '' : node; rerenderMemory(); }
2433
- function renderMemory(memory) {
2663
+ function memorySyncLine(sync) {
2664
+ if (!sync || typeof sync.state !== 'string') return 'Machine sync: unknown';
2665
+ const hasTimestamp = sync.at !== undefined, validTimestamp = Number.isSafeInteger(sync.at) && Math.abs(sync.at) <= 8640000000000000;
2666
+ const stamp = hasTimestamp ? ' / last ' + (validTimestamp ? time(sync.at) : 'unknown') : '';
2667
+ if (sync.state === 'not-configured' || sync.state === 'stopped') {
2668
+ const next = sync.state === 'stopped' ? 'retry after hub restart' : 'configure a private sync remote';
2669
+ return 'Machine sync: ' + sync.state + stamp + ' / next: ' + next;
2670
+ }
2671
+ if (sync.state === 'ok' && Number.isSafeInteger(sync.withheldCount) && sync.withheldCount >= 0 && Number.isSafeInteger(sync.exportedCount) && sync.exportedCount >= 0)
2672
+ return 'Machine sync: ok' + stamp + ' / ' + sync.withheldCount + ' row' + (sync.withheldCount === 1 ? '' : 's') + ' withheld locally / ' + sync.exportedCount + ' exported';
2673
+ if (sync.state !== 'failed') return 'Machine sync: ' + (['disabled','unavailable','notrun','ok','unknown'].includes(sync.state) ? sync.state : 'unknown') + stamp;
2674
+ const causes = ['declined','remote','binding','stopped','transport','unknown'];
2675
+ const cause = causes.includes(sync.cause) ? sync.cause : 'unknown';
2676
+ if (cause === 'declined' && sync.placementActive === false) {
2677
+ const declined = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount >= 0 ? String(sync.declinedCount) : 'unknown';
2678
+ return 'machine sync blocked: ' + declined + ' declined rows; per-row placement is not active on this store (agent-mem has no activation command yet, agent-mem#281). Inspect with `ahub memory declined`.';
2679
+ }
2680
+ const count = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount >= 0 ? ' / ' + sync.declinedCount + ' declined' : '';
2681
+ const ids = cause === 'declined' && sync.declinedIdsKnown === false ? ' / declined ID set unknown' : '';
2682
+ const next = cause === 'declined' ? 'ahub memory declined' : cause === 'remote' ? 'configure a private sync remote' : cause === 'binding' ? 'ahub memory profile-check' : cause === 'stopped' ? 'retry after hub restart' : 'run ahub memory sync as a person';
2683
+ return 'Machine sync: failed (' + cause + count + ')' + stamp + ids + ' / next: ' + next;
2684
+ }
2685
+ function renderMemory(memory, sync) {
2686
+ $('memory-sync-status').textContent = memorySyncLine(sync);
2434
2687
  memoryCodeToggle();
2435
2688
  const tiers = $('memory-tiers');
2436
2689
  if (!tiers.children.length) for (const [tier, label] of [['short', 'Short-term'], ['long', 'Long-term'], ['goals', 'Long-term tasks']]) {
@@ -2541,6 +2794,7 @@ function renderStreamChips() {
2541
2794
  group.append(chip);
2542
2795
  }
2543
2796
  }
2797
+ $('intake').addEventListener('submit', submitIntake);
2544
2798
  $('planning-task').addEventListener('change',loadPlanning);$('planning-reload').addEventListener('click',loadPlanning);$('release-edit').addEventListener('change',loadReleasePlanning);$('release-reload').addEventListener('click',loadReleasePlanning);
2545
2799
  $('planning').addEventListener('submit',async event=>{event.preventDefault();if(!planningEdit || !sameBoardContext(planningEdit.context) || boardViewState?.snapshot.planningCapabilities?.enabled!==true)return notice('Select a task from its current board.',true);
2546
2800
  const fields={priority:$('planning-priority').value===''?null:Number($('planning-priority').value),rank:$('planning-rank').value===''?null:Number($('planning-rank').value),release:$('planning-release').value===''?null:Number($('planning-release').value),plannedStart:plannedDateInput('planning-start'),due:plannedDateInput('planning-due'),pinned:$('planning-pinned').checked};