@staix/agent-hub 0.12.22 → 0.12.24

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 (183) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +22 -9
  3. package/docs/addons/claude-mem.md +24 -0
  4. package/docs/addons/codex-plugin.md +8 -0
  5. package/docs/addons/dot-admit.md +8 -0
  6. package/docs/addons/open-code-review.md +8 -0
  7. package/docs/addons/ponytail.md +23 -0
  8. package/docs/addons/ripwire.md +22 -0
  9. package/docs/agent-notes/README.md +5 -0
  10. package/docs/agent-notes/adapters.md +18 -6
  11. package/docs/agent-notes/benchmarks.md +3 -0
  12. package/docs/agent-notes/budget.md +4 -0
  13. package/docs/agent-notes/bus.md +3 -1
  14. package/docs/agent-notes/daemon.md +14 -1
  15. package/docs/agent-notes/local-worker.md +6 -3
  16. package/docs/agent-notes/tasks.md +6 -1
  17. package/docs/agent-notes/tests.md +4 -1
  18. package/docs/bench.md +28 -3
  19. package/docs/cooperbench.md +4 -0
  20. package/docs/data-policy.md +36 -0
  21. package/docs/events.md +10 -3
  22. package/docs/gsd-collaboration-analysis.md +48 -0
  23. package/docs/memory.md +231 -0
  24. package/docs/operations.md +873 -30
  25. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  26. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  27. package/docs/profiles.md +57 -0
  28. package/docs/quickstart.md +9 -4
  29. package/docs/research.md +31 -1
  30. package/docs/roles.md +85 -0
  31. package/docs/sdlc.md +170 -0
  32. package/docs/security.md +124 -8
  33. package/docs/smoke.md +354 -5
  34. package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
  35. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  36. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  37. package/docs/text-projection.md +19 -0
  38. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  39. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  40. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  41. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  42. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  43. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  44. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  45. package/docs/verification/2026-10-13-board-phone.md +31 -0
  46. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  47. package/docs/verified.json +34 -16
  48. package/package.json +2 -1
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  51. package/plugins/agent-hub/server.js +1108 -261
  52. package/scripts/check-docs.mjs +129 -0
  53. package/src/adapters/acp-quota.ts +13 -0
  54. package/src/adapters/acp.ts +201 -20
  55. package/src/adapters/claude-channel.ts +48 -11
  56. package/src/adapters/codex-appserver.ts +54 -13
  57. package/src/adapters/jcode.ts +110 -0
  58. package/src/adapters/local-worker.ts +10 -7
  59. package/src/adapters/pi.ts +94 -20
  60. package/src/cli/attention.ts +60 -0
  61. package/src/cli/bench.ts +26 -6
  62. package/src/cli/board-read.ts +12 -0
  63. package/src/cli/console-state.ts +131 -39
  64. package/src/cli/console.ts +73 -17
  65. package/src/cli/facts-hook.ts +12 -4
  66. package/src/cli/help.ts +52 -21
  67. package/src/cli/identity.ts +19 -8
  68. package/src/cli/init.ts +48 -10
  69. package/src/cli/jcode-mcp.ts +58 -0
  70. package/src/cli/kimi-mcp.ts +53 -0
  71. package/src/cli/launch.ts +16 -1
  72. package/src/cli/main.ts +641 -193
  73. package/src/cli/memory.ts +193 -0
  74. package/src/cli/output.ts +404 -0
  75. package/src/cli/pet-config.ts +1 -0
  76. package/src/cli/pet.ts +83 -0
  77. package/src/cli/preview.ts +40 -6
  78. package/src/cli/reset.ts +3 -0
  79. package/src/cli/status-lines.ts +1 -29
  80. package/src/cli/tail-render.ts +1 -0
  81. package/src/cli/terminal-recovery.ts +27 -1
  82. package/src/cli/upgrade-runtime.ts +56 -10
  83. package/src/cli/upgrade.ts +23 -5
  84. package/src/cli/watch.ts +31 -0
  85. package/src/hub/activity.ts +53 -0
  86. package/src/hub/ask.ts +10 -5
  87. package/src/hub/bench.ts +10 -3
  88. package/src/hub/board.ts +224 -16
  89. package/src/hub/budget.ts +39 -7
  90. package/src/hub/bus.ts +58 -20
  91. package/src/hub/child-process.ts +11 -0
  92. package/src/hub/citations.ts +7 -0
  93. package/src/hub/conductor.ts +34 -17
  94. package/src/hub/config-trust.ts +20 -7
  95. package/src/hub/conflicts.ts +2 -0
  96. package/src/hub/context-window.ts +8 -1
  97. package/src/hub/crash.ts +11 -1
  98. package/src/hub/daemon.ts +1218 -113
  99. package/src/hub/db-timeout.ts +3 -0
  100. package/src/hub/delivery-journal.ts +7 -1
  101. package/src/hub/envelope.ts +1 -1
  102. package/src/hub/events.ts +14 -2
  103. package/src/hub/execution-budget.ts +2 -0
  104. package/src/hub/facts.ts +28 -8
  105. package/src/hub/git.ts +38 -0
  106. package/src/hub/github-import.ts +28 -0
  107. package/src/hub/hub-tools.ts +49 -10
  108. package/src/hub/inference.ts +1 -1
  109. package/src/hub/lifecycle-schema.ts +17 -0
  110. package/src/hub/lifecycle.ts +10 -4
  111. package/src/hub/listen.ts +15 -0
  112. package/src/hub/manager.ts +26 -6
  113. package/src/hub/path-policy.ts +96 -0
  114. package/src/hub/peer-catalog.ts +28 -0
  115. package/src/hub/peers.ts +5 -3
  116. package/src/hub/permission-mode.ts +33 -5
  117. package/src/hub/planning.ts +70 -0
  118. package/src/hub/profiles.ts +74 -0
  119. package/src/hub/project.ts +2 -1
  120. package/src/hub/projected-diff.ts +73 -0
  121. package/src/hub/projection-exec.ts +24 -0
  122. package/src/hub/recovery-error.ts +31 -0
  123. package/src/hub/recovery-store.ts +6 -5
  124. package/src/hub/registry.ts +2 -1
  125. package/src/hub/research.ts +144 -21
  126. package/src/hub/roles.ts +123 -0
  127. package/src/hub/roster.ts +23 -0
  128. package/src/hub/routing.ts +132 -18
  129. package/src/hub/sdlc-queue.ts +112 -0
  130. package/src/hub/sdlc-release.ts +125 -0
  131. package/src/hub/sdlc.ts +250 -0
  132. package/src/hub/settings.ts +411 -0
  133. package/src/hub/shipped-roles.ts +25 -0
  134. package/src/hub/snapshots.ts +92 -16
  135. package/src/hub/start-mode.ts +50 -0
  136. package/src/hub/supervision.ts +1 -0
  137. package/src/hub/task-sweep.ts +1 -1
  138. package/src/hub/tasks.ts +1014 -111
  139. package/src/hub/terminal-open.ts +64 -0
  140. package/src/hub/text-projection.ts +126 -0
  141. package/src/hub/tooling.ts +159 -0
  142. package/src/hub/ui.ts +40 -14
  143. package/src/hub/workflow.ts +71 -0
  144. package/src/local/deny.ts +29 -7
  145. package/src/local/sandbox.ts +50 -10
  146. package/src/local/tools.ts +11 -6
  147. package/src/memory/brief.ts +28 -14
  148. package/src/memory/import.ts +55 -0
  149. package/src/memory/recall.ts +86 -2
  150. package/src/memory/store.ts +750 -0
  151. package/src/memory/sync.ts +145 -0
  152. package/src/models/mlx.ts +2 -1
  153. package/src/pet/NOTICE.md +10 -0
  154. package/src/pet/characters.ts +1438 -0
  155. package/src/pet/config.ts +31 -0
  156. package/src/pet/frame.ts +17 -0
  157. package/src/pet/pixels.ts +28 -0
  158. package/src/pi/tool-receipts.ts +2 -1
  159. package/src/role-text.d.ts +1 -0
  160. package/src/ui/index.html +983 -40
  161. package/src/ui/needs.ts +58 -0
  162. package/src/ui/office-model.ts +440 -0
  163. package/src/ui/peer-palette.ts +16 -0
  164. package/src/ui/task-board.ts +49 -0
  165. package/src/ui/task-progress.ts +5 -1
  166. package/templates/AGENTS.block.md +3 -2
  167. package/templates/config.json +5 -1
  168. package/templates/profiles/dev.json +22 -0
  169. package/templates/profiles/general.json +26 -0
  170. package/templates/profiles/research.json +57 -0
  171. package/templates/profiles/writing.json +28 -0
  172. package/templates/roles/NOTICE.md +26 -0
  173. package/templates/roles/dev/backend-engineer.md +26 -0
  174. package/templates/roles/dev/code-reviewer.md +26 -0
  175. package/templates/roles/dev/frontend-engineer.md +26 -0
  176. package/templates/roles/dev/security-reviewer.md +26 -0
  177. package/templates/roles/dev/software-architect.md +26 -0
  178. package/templates/roles/dev/test-engineer.md +26 -0
  179. package/templates/roles/research/analyst.md +26 -0
  180. package/templates/roles/research/fact-checker.md +26 -0
  181. package/templates/roles/research/research-lead.md +26 -0
  182. package/templates/roles/research/researcher.md +26 -0
  183. package/templates/roles/research/statistician.md +26 -0
package/src/ui/index.html CHANGED
@@ -15,17 +15,32 @@ const dashboardTheme = (() => {
15
15
  </script>
16
16
  <style>
17
17
  :root{color-scheme:light;--waiting:#795399;--paper:#f5f4ef;--ink:#252b29;--muted:#68716c;--line:#d9ddd5;--panel:#ffffff;--panel-soft:#f4f6f2;--control:#ffffff;--control-border:#bbc4bb;--hover:#f0f4ef;--green:#245f45;--green-soft:#e4efe9;--amber:#8a5b12;--amber-soft:#f8eed6;--blue:#245a93;--blue-soft:#e6eef8;--red:#a2392f;--red-soft:#f6e5e2;--dot:#98a29b;--dot-ok:#2f7a52;--dot-warn:#b07a16;--dot-err:#b24539;--focus:#245f45;--on-accent:#ffffff}:root[data-theme="dark"]{color-scheme:dark;--waiting:#b9a0df;--paper:#161a18;--ink:#e5e8e3;--muted:#97a099;--line:#2c342f;--panel:#1d2320;--panel-soft:#17201b;--control:#1d2320;--control-border:#3a443d;--hover:#263029;--green:#6fc2a0;--green-soft:#1e3a2e;--amber:#d9a64f;--amber-soft:#3a2f14;--blue:#8ab8ea;--blue-soft:#1d2f44;--red:#e28b80;--red-soft:#3d2320;--dot:#5b6660;--dot-ok:#4cb77f;--dot-warn:#d9a64f;--dot-err:#e28b80;--focus:#6fc2a0;--on-accent:#161a18}@media(prefers-color-scheme:dark){:root:not([data-theme="light"]):not([data-theme="dark"]){color-scheme:dark;--waiting:#b9a0df;--paper:#161a18;--ink:#e5e8e3;--muted:#97a099;--line:#2c342f;--panel:#1d2320;--panel-soft:#17201b;--control:#1d2320;--control-border:#3a443d;--hover:#263029;--green:#6fc2a0;--green-soft:#1e3a2e;--amber:#d9a64f;--amber-soft:#3a2f14;--blue:#8ab8ea;--blue-soft:#1d2f44;--red:#e28b80;--red-soft:#3d2320;--dot:#5b6660;--dot-ok:#4cb77f;--dot-warn:#d9a64f;--dot-err:#e28b80;--focus:#6fc2a0;--on-accent:#161a18}}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 system-ui,sans-serif}header,main{max-width:1440px;margin:auto;padding:24px 32px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:20px}h1{font-size:24px;letter-spacing:-1px;margin:0}h1 span{font-weight:400;color:var(--muted)}h2{font-size:15px;margin:0}p{margin:6px 0}.eyebrow{overflow-wrap:anywhere;font-size:12px;color:var(--muted)}.eyebrow{letter-spacing:1.5px;text-transform:uppercase}.meta{overflow-wrap:anywhere;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}.connection{font-size:12px;color:var(--muted)}main{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:24px;align-items:start}.stack{display:flex;flex-direction:column;gap:24px;min-width:0}section{border:1px solid var(--line);background:var(--panel);border-radius:8px;overflow:hidden}.section-head{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid var(--line);gap:12px}.section-head .filters{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.content{padding:16px 18px}.empty{color:var(--muted);padding:20px 18px}.row{padding:14px 18px;border-bottom:1px solid var(--line)}.row:last-child{border-bottom:0}.row-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.peername{display:flex;align-items:center;gap:8px;min-width:0}.label{font-weight:600;overflow-wrap:anywhere}.badge{display:inline-block;font:11px ui-monospace,monospace;padding:2px 7px;background:var(--panel-soft);color:var(--muted);border-radius:4px;white-space:nowrap}.badge.ok{background:var(--green-soft);color:var(--green)}.badge.warn{color:var(--amber);background:var(--amber-soft)}.badge.info{color:var(--blue);background:var(--blue-soft)}.badge.err{color:var(--red);background:var(--red-soft)}.dot{flex:none;width:9px;height:9px;border-radius:50%;background:var(--dot)}.dot.ok{background:var(--dot-ok)}.dot.warn{background:var(--dot-warn)}.dot.err{background:var(--dot-err)}.chips{display:flex;gap:6px;flex-wrap:wrap}.chip{background:var(--control);color:var(--muted);border:1px solid var(--control-border);border-radius:999px;padding:4px 10px;font-size:12px;cursor:pointer;line-height:1.2}.chip:hover{background:var(--hover)}.chip[aria-pressed="true"]{background:var(--green);color:var(--on-accent);border-color:var(--green);font-weight:600}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px}button,input,select,textarea{font:inherit}button{background:var(--control);color:var(--ink);border:1px solid var(--control-border);border-radius:5px;padding:6px 10px;cursor:pointer}button:hover{background:var(--hover)}button.primary{background:var(--green);color:var(--on-accent);border-color:var(--green)}button.danger{color:var(--red)}button:disabled{opacity:.5;cursor:default}:focus-visible{outline:2px solid var(--focus);outline-offset:3px}input,select,textarea{width:100%;border:1px solid var(--control-border);border-radius:5px;padding:8px;background:var(--control);color:var(--ink)}input[type="checkbox"]{width:auto;margin-right:6px}textarea{resize:vertical;min-height:78px}label{display:block;font-size:12px;color:var(--muted);margin-bottom:5px}form .field{margin-bottom:12px}.actions select{width:auto;max-width:220px}.section-head .filters select{width:auto;max-width:220px}.body{white-space:pre-wrap;overflow-wrap:anywhere;margin-top:7px}.stream{height:360px;overflow:auto}.event{padding:12px 18px;border-bottom:1px solid var(--line)}.event[hidden]{display:none}.event .body{font:13px/1.55 ui-monospace,monospace}.notice{grid-column:1/-1;margin:0;border:1px solid var(--line);border-radius:6px;padding:10px 14px;background:var(--panel);overflow-wrap:anywhere}.notice.error{color:var(--red);border-color:var(--red)}.notice:empty{display:none}progress{width:100%;height:8px;accent-color:var(--green);display:block;margin:8px 0}progress.warn{accent-color:var(--amber)}progress.warn::-webkit-progress-value{background:var(--amber)}progress.critical{accent-color:var(--red)}progress.critical::-webkit-progress-value{background:var(--red)}progress::-moz-progress-bar{background:var(--green)}progress.warn::-moz-progress-bar{background:var(--amber)}progress.critical::-moz-progress-bar{background:var(--red)}.budget-window+.budget-window{margin-top:14px}.split{display:grid;grid-template-columns:1fr 1fr;gap:12px}details summary{cursor:pointer;padding:14px 18px;color:var(--green);font-weight:600}details form{padding:0 18px 18px}.task-list{max-height:460px;overflow:auto}footer{max-width:1440px;margin:auto;padding:0 32px 24px;color:var(--muted);font-size:12px}@media(max-width:900px){main{grid-template-columns:1fr}.stack{gap:18px}header,main{padding:18px}.stream{height:300px}footer{padding:0 18px 18px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}}
18
- .task-progress{padding:12px 18px;border-bottom:1px solid var(--line)}.task-progress svg{display:block;width:100%;height:12px;margin:8px 0}.progress-proposed{fill:var(--dot)}.progress-unknown{fill:var(--dot)}.progress-waiting{fill:var(--waiting)}.progress-in_progress{fill:var(--blue)}.progress-in_review{fill:var(--amber)}.progress-changes_requested{fill:var(--red)}.progress-approved{fill:var(--green)}.task-stage{font:12px ui-monospace,monospace;margin:8px 0;color:var(--muted)}.task-stage .reached{color:var(--green)}.task-stage.changes{color:var(--red)}.theme-choice{width:auto}
18
+ .task-progress{padding:12px 18px;border-bottom:1px solid var(--line)}.task-progress svg{display:block;width:100%;height:12px;margin:8px 0}.progress-proposed{fill:var(--dot)}.progress-unknown{fill:var(--dot)}.progress-waiting{fill:var(--waiting)}.progress-in_progress{fill:var(--blue)}.progress-in_review{fill:var(--amber)}.progress-changes_requested{fill:var(--red)}.progress-approved{fill:var(--green)}.task-stage{font:12px ui-monospace,monospace;margin:8px 0;color:var(--muted)}.task-stage .reached{color:var(--green)}.task-stage.changes{color:var(--red)}.theme-choice{width:auto}.actions input[type="text"]{width:auto;flex:1 1 160px;max-width:320px}details.settings-group>summary{padding:12px 18px;cursor:pointer;border-bottom:1px solid var(--line);font-weight:600}details.settings-group:last-child:not([open])>summary{border-bottom:0}.preview{margin-top:8px;padding:8px 10px;background:var(--panel-soft);border-radius:5px;font:12px ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.preview:empty{display:none}.memory-controls{display:flex;gap:8px;flex-wrap:wrap;padding:12px 18px;border-bottom:1px solid var(--line)}.memory-controls input,.memory-controls select{width:auto;flex:1 1 130px}.memory-row{display:block;width:100%;text-align:left;border:0;border-bottom:1px solid var(--line);border-radius:0;padding:10px 18px;background:var(--panel)}.memory-row[aria-pressed="true"]{background:var(--green-soft)}.memory-graph svg{display:block;width:100%;height:320px;background:var(--panel-soft)}.medge{stroke:var(--dot);stroke-width:1.5}.medge.rules_out{stroke:var(--red)}.medge.supersedes{stroke-dasharray:4 3}.medge.suggested{stroke-dasharray:2 4;opacity:.75}.medge.contradicts{stroke:var(--amber)}.mnode{cursor:pointer}.mnode circle{fill:var(--dot);stroke:var(--panel);stroke-width:2}.mnode.memory circle{fill:var(--blue)}.mnode.fail circle{fill:var(--red)}.mnode.decision circle{fill:var(--green)}.mnode.contract circle{fill:var(--amber)}.mnode.selected circle{stroke:var(--focus);stroke-width:3}.mnode:focus-visible circle{stroke:var(--focus);stroke-width:4}.mnode text{font:11px system-ui,sans-serif;fill:var(--ink)}.memory-edges{margin:0;padding:8px 18px 12px 34px;font:12px ui-monospace,monospace;color:var(--muted);max-height:160px;overflow:auto}
19
+ /* PEER_PALETTE_CSS */
20
+ .task-board-section{grid-column:1/-1}.board-columns{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;padding:18px}.board-column{min-width:0;background:var(--panel-soft);border:1px solid var(--line);border-radius:6px;padding:10px}.board-column h3{font-size:14px;margin:0 0 12px}.board-column h4{font-size:12px;color:var(--muted);margin:12px 0 8px}.task-card{background:var(--panel);border:1px solid var(--line);border-radius:5px;padding:12px;margin:0 0 10px;overflow-wrap:anywhere}.task-card .row-head{align-items:flex-start}.task-card .label{font-size:14px}.task-card .actions{display:grid;gap:8px}.task-card .actions select{max-width:none;width:100%}.task-card button,.task-card select,.agent-legend button{min-height:44px}.task-card .body{font-size:13px}.task-card .task-stage{font-size:12px}.board-columns .empty{padding:8px 0}.board-lane{border-top:1px solid var(--line)}.board-lane>h3{padding:14px 18px 0;font-size:15px}.peer-mark{display:inline-flex;align-items:center;gap:6px}.peer-color-dot{width:9px;height:9px;display:inline-block;border-radius:50%;background:var(--peer-color);flex-shrink:0}.peer-color-dot.reviewer{background:transparent;border:2px solid var(--peer-color)}.agent-legend{display:flex;flex-wrap:wrap;gap:8px;padding:14px 18px;border-bottom:1px solid var(--line)}.agent-legend .agent-slot{border:1px solid var(--line);border-radius:5px;padding:10px;flex:1 1 180px;min-width:0}.agent-legend .meta{overflow-wrap:anywhere}.board-drop-active{outline:2px solid var(--green);outline-offset:2px}.board-toolbar{padding:12px 18px;display:flex;gap:14px;flex-wrap:wrap;align-items:center}.board-toolbar label{margin:0}.board-toolbar select{width:auto;max-width:220px}.board-unknown{padding:12px 18px;color:var(--amber)}.task-list{max-height:none;overflow:visible}.progress-backlog{fill:var(--dot)}
21
+ @media(max-width:1100px){.board-columns{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.split{grid-template-columns:minmax(0,1fr)}.board-columns{grid-template-columns:1fr;padding:12px}.board-toolbar{align-items:stretch}.board-toolbar select{max-width:none;width:100%}.board-toolbar label{width:100%}}
22
+ .office-scene{display:block;width:100%;height:auto}.office-scene text{font-family:system-ui,sans-serif}.office-room{fill:var(--panel-soft);stroke:var(--line)}.office-room-title{fill:var(--muted);font-size:1.8px;letter-spacing:.1px;text-transform:uppercase}.office-room-count{fill:var(--ink);font-size:1.8px;font-variant-numeric:tabular-nums}.office-desk-surface{fill:var(--panel);stroke:var(--line)}.office-desk.office-dimmed{opacity:.55}.office-name{fill:var(--ink);font-size:1.6px;font-weight:600}.office-state{fill:var(--muted);font-size:1.4px}.office-glyph{font-size:1.8px}.office-idle .office-glyph{fill:var(--dot-ok)}.office-busy .office-glyph{fill:var(--dot-warn)}.office-offline .office-glyph{fill:var(--dot)}.office-paused .office-glyph{fill:var(--dot-warn)}.office-waiting .office-glyph{fill:var(--dot-err)}.office-unknown .office-glyph{fill:var(--dot)}.office-meta{fill:var(--muted);font-size:1.2px}.office-hand{fill:var(--dot-err);font-size:1.6px}.office-bubble{fill:var(--blue-soft);stroke:var(--blue)}.office-bubble-text{fill:var(--blue);font-size:1.3px}.office-mode{fill:var(--amber);font-size:1.2px;font-family:ui-monospace,monospace}.office-card{fill:var(--control);stroke:var(--control-border)}.office-card-text{fill:var(--ink);font-size:1.3px;font-family:ui-monospace,monospace}.office-waiting-card .office-card{stroke:var(--waiting);stroke-dasharray:.5 .4}.office-rework-card .office-card{stroke:var(--red)}.office-more{fill:var(--blue);font-size:1.4px}.office-spotlight .office-desk-surface,.office-spotlight .office-card{stroke:var(--focus);stroke-width:.5}.row.spotlight{background:var(--hover)}
23
+ .office-movable{transition:transform .8s ease}.office-arc .office-arc-path{fill:none;stroke:var(--blue);stroke-width:.35}.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:1.2px}.office-alert{fill:var(--red);font-size:1.4px}.office-latch{fill:var(--dot-warn);font-size:1.4px}.office-link-line{fill:none;stroke-width:.45;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:1.2px;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:1.1px}.office-card-checking .office-card{stroke:var(--blue)}
24
+ .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);stroke-width:.6}.office-glyph-shape{fill:none;stroke-width:.32}.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:.28}.office-desk-leg{fill:var(--panel-soft);stroke:var(--line);stroke-width:.2}.office-nameplate{fill:var(--control);stroke:var(--control-border);stroke-width:.2}.office-room-wire{fill:none;stroke:var(--line);stroke-width:.25}.office-divider{fill:none;stroke:var(--line);stroke-width:.2}.office-shelf{fill:var(--panel-soft);stroke:var(--line);stroke-width:.3}.office-shelf-card{fill:var(--green-soft);stroke:var(--green);stroke-width:.25}.office-box-top{fill:var(--control);stroke:var(--control-border);stroke-width:.2}.office-box-left{fill:var(--panel-soft);stroke:var(--line);stroke-width:.2}.office-box-right{fill:var(--panel);stroke:var(--line);stroke-width:.2}.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}
25
+ @keyframes office-pulse{50%{opacity:.45}}@keyframes office-appear{from{opacity:0}}@keyframes office-fade{to{opacity:0}}
26
+ @media(prefers-reduced-motion:reduce){.office-scene,.office-scene *{transition:none!important;animation:none!important}}
27
+
28
+ .pet-container[hidden]{display:none}.pet-container{display:flex;justify-content:flex-end;padding:8px 24px}.pet-control{display:flex;align-items:center;gap:12px;min-height:64px;min-width:40px;font-variant-numeric:tabular-nums}.pet-art{width:72px;height:64px;fill:currentColor}.pet-pixel{shape-rendering:crispEdges}.pet-accent{fill:var(--amber)}.pet-blink{opacity:0}.pet-waiting .pet-open{animation:pet-open 2s steps(1,end) infinite}.pet-waiting .pet-blink{animation:pet-blink 2s steps(1,end) infinite}@keyframes pet-open{50%{opacity:0}}@keyframes pet-blink{50%{opacity:1}}@media(prefers-reduced-motion:reduce){.pet-waiting .pet-open,.pet-waiting .pet-blink{animation:none}}
19
29
  </style>
20
30
  </head>
21
31
  <body>
22
- <header><div><div class="eyebrow">Local control room</div><h1>agent-hub <span>/ dashboard</span></h1><div id="project-context" class="meta"></div></div><div><label for="theme">Theme</label><select id="theme" class="theme-choice"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select><div id="connection" class="connection" role="status">Connecting...</div></div></header>
32
+ <header><div><div class="eyebrow">Local control room</div><h1>agent-hub <span>/ dashboard</span></h1><div id="project-context" class="meta"></div></div><div><label for="theme">Theme</label><select id="theme" class="theme-choice"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select><div id="connection" class="connection" role="status">Connecting...</div></div><div class="filters"><label for="office-view">View</label><select id="office-view"><option value="off">Off</option><option value="flat">Flat</option><option value="isometric">Isometric</option></select></div></header>
33
+ <div id="pet-container" class="pet-container" hidden></div>
23
34
  <main>
24
35
  <p id="notice" class="notice" role="status" aria-live="polite"></p>
36
+ <section class="task-board-section" aria-labelledby="tasks-title"><div class="section-head"><h2 id="tasks-title">Task board</h2><div class="filters"><select id="task-filter" aria-label="Filter tasks by state"><option value="">All states</option><option value="backlog">Backlog</option><option value="proposed">To Do</option><option value="work">Work (including rework)</option><option value="in_progress">In progress</option><option value="in_review">In review</option><option value="approved">Approved</option><option value="changes_requested">Changes requested</option></select><span id="task-count" class="meta"></span></div></div><div id="task-progress" class="task-progress" aria-live="polite"></div><div class="board-toolbar"><label for="task-peer-filter">Agent<select id="task-peer-filter"><option value="">All agents</option></select></label><label for="task-release-filter">Release<select id="task-release-filter"><option value="">All releases</option></select></label><label for="task-lanes">View<select id="task-lanes"><option value="columns">By state</option><option value="agents">By agent</option><option value="releases">By release</option></select></label></div><div id="task-legend" class="agent-legend" role="group" aria-label="Agent legend and assignment targets"></div><div id="tasks" class="task-list"></div><details><summary>Plan a task</summary><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></details><details id="split-panel" hidden><summary>Split the selected backlog task</summary><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></details><details><summary>Releases</summary><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></details><details id="delivery"><summary>Delivery evidence (read-only)</summary><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></details><details><summary>Propose a task</summary><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></details></section>
25
37
  <div class="stack">
38
+ <section id="office-section" aria-labelledby="office-title" hidden><div class="section-head"><h2 id="office-title">Office</h2></div><div id="office" class="content"></div></section>
26
39
  <section aria-labelledby="stream-title"><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>
27
- <section aria-labelledby="tasks-title"><div class="section-head"><h2 id="tasks-title">Task board</h2><div class="filters"><select id="task-filter" aria-label="Filter tasks by state"><option value="">All states</option><option value="proposed">Proposed</option><option value="in_progress">In progress</option><option value="in_review">In review</option><option value="approved">Approved</option><option value="changes_requested">Changes requested</option></select><span id="task-count" class="meta"></span></div></div><div id="task-progress" class="task-progress" aria-live="polite"></div><div id="tasks" class="task-list"></div><details><summary>Propose a task</summary><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><button class="primary" data-mutation="project" type="submit">Propose task</button></form></details></section>
40
+
41
+ <section aria-labelledby="memory-title"><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>
28
42
  <section aria-labelledby="compose-title"><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>
43
+ <section aria-labelledby="settings-title"><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>
29
44
  </div>
30
45
  <div class="stack">
31
46
  <section id="projects-panel" aria-labelledby="projects-title"><div class="section-head"><h2 id="projects-title">Projects</h2><span id="project-count" class="meta"></span></div><div id="projects"></div></section>
@@ -39,7 +54,17 @@ const dashboardTheme = (() => {
39
54
  <script>
40
55
  'use strict';
41
56
  /* TASK_PROGRESS_MODEL */
57
+ /* PEER_START_RETRY_MS */
58
+ /* BOARD_MODEL */
59
+ /* PET_MODEL */
60
+ /* OFFICE_MODEL */
61
+ const officeView = (() => {
62
+ let choice = 'off';
63
+ try { const saved = document.cookie.split(';').map(value => value.trim()).find(value => value.startsWith('agent-hub-office='))?.slice('agent-hub-office='.length); if (['off','flat','isometric'].includes(saved)) choice = saved; } catch {}
64
+ return { get: () => choice, set: (value) => { choice = ['off','flat','isometric'].includes(value) ? value : 'off'; try { document.cookie = `agent-hub-office=${choice}; Path=/; Max-Age=31536000; SameSite=Strict`; } catch {} } };
65
+ })();
42
66
  const themeControl = document.getElementById('theme'); themeControl.value = dashboardTheme.get(); themeControl.addEventListener('change', () => dashboardTheme.set(themeControl.value));
67
+ const officeControl = document.getElementById('office-view'); officeControl.value = officeView.get(); document.getElementById('office-section').hidden = officeView.get() === 'off'; officeControl.addEventListener('change', () => { officeView.set(officeControl.value); if (lastSnapshot) render(lastSnapshot); else document.getElementById('office-section').hidden = officeView.get() === 'off'; });
43
68
  let ticket = location.hash.slice(1);
44
69
  history.replaceState(null, '', location.pathname);
45
70
  const $ = (id) => document.getElementById(id);
@@ -47,8 +72,11 @@ const el = (tag, text, cls) => { const node = document.createElement(tag); if (t
47
72
  let stopped = false, cursor = 0, timer, projectsTimer, eventCount = 0;
48
73
  let managerMode = false, projectList = [], selectedProjectId = '', selectedInstanceId = '', snapshotValid = false, viewGeneration = 0, projectRequest = 0;
49
74
  let lastSnapshot = null;
75
+ let officePrev = null;
76
+ const peerRows = new Map(), taskRows = new Map(), officeDesks = new Map(), officeCards = new Map();
50
77
  const drafts = new Map();
51
78
  const pendingButtons = new WeakSet();
79
+ const pendingStarts = new Map();
52
80
  let draftChoicesPending = false;
53
81
  const signatures = new Map();
54
82
  const time = (value) => value ? new Date(value).toLocaleString() : 'unknown';
@@ -72,22 +100,48 @@ async function post(path, body) {
72
100
  const response = await fetch(path, {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
73
101
  if (response.status === 401) { expire(); throw new Error('Run ahub ui to start a new session.'); }
74
102
  const result = await response.json();
103
+ if (result.unconfirmed === true) return result;
75
104
  if (!response.ok || result.ok === false) throw new Error(result.error || 'Request failed (' + response.status + ').');
76
105
  return result;
77
106
  }
78
107
  function canMutate() { return !managerMode || snapshotValid; }
79
108
  function context() { return { projectId: selectedProjectId, instanceId: selectedInstanceId, generation: viewGeneration }; }
109
+ function startKey(peer, projectId = selectedProjectId, instanceId = selectedInstanceId) { return JSON.stringify([projectId, instanceId, peer]); }
110
+ function clearPendingStart(key) { const pending = pendingStarts.get(key); if (pending) { clearTimeout(pending.timer); pendingStarts.delete(key); } }
111
+ function redrawStartControls() { if (stopped) return; syncMutationControls(); if (snapshotValid && lastSnapshot) render(lastSnapshot); }
80
112
  async function action(payload, button, managerAction = false) {
81
113
  if (stopped) return false;
82
114
  if (!managerAction && !canMutate()) { notice('Wait for a current project snapshot before changing it.', true); return false; }
83
115
  const captured = context();
116
+ const key = payload.action === 'start_peer' ? startKey(payload.peer, captured.projectId, captured.instanceId) : undefined;
117
+ if (key && pendingStarts.has(key)) return false;
118
+ const pending = key ? {phase:'pending', text:'Starting ' + payload.peer + '. Waiting for the hub result.'} : undefined;
119
+ if (key) pendingStarts.set(key, pending);
84
120
  if (managerMode && !managerAction) payload = {...payload, projectId: captured.projectId, instanceId: captured.instanceId};
85
121
  if (button) { pendingButtons.add(button); button.disabled = true; }
86
- try { const result = await post('/action', payload); if (!managerMode || captured.generation === viewGeneration) notice(result.text || 'Action accepted.'); return true; }
87
- catch (error) { if (!stopped && (!managerMode || captured.generation === viewGeneration)) notice(error.message, true); return false; }
88
- finally { if (button) { pendingButtons.delete(button); if (!stopped) button.disabled = !managerAction && managerMode && !snapshotValid; } }
122
+ if (key) redrawStartControls();
123
+ try {
124
+ const result = await post('/action', payload);
125
+ if (result.unconfirmed === true) {
126
+ if (key && pendingStarts.get(key) === pending) {
127
+ if (managerMode) {
128
+ pending.phase = 'unconfirmed'; pending.retryAt = Date.now() + PEER_START_RETRY_MS;
129
+ pending.text = (result.text || 'Start result is unconfirmed. Check the Peers panel before retrying; the hub may still be starting the peer.') + ' Retry becomes available ' + Math.ceil(PEER_START_RETRY_MS / 1000) + ' seconds after this unconfirmed result unless the peer attaches sooner.';
130
+ pending.timer = setTimeout(() => { if (pendingStarts.get(key) === pending) { clearPendingStart(key); redrawStartControls(); } }, PEER_START_RETRY_MS);
131
+ } else clearPendingStart(key);
132
+ }
133
+ if (!managerMode || captured.generation === viewGeneration) notice(result.text || 'Start result is unconfirmed. Check the Peers panel before retrying; the hub may still be starting the peer.');
134
+ return false;
135
+ }
136
+ if (key && pendingStarts.get(key) === pending) clearPendingStart(key);
137
+ if (!managerMode || captured.generation === viewGeneration) notice(result.text || 'Action accepted.');
138
+ if ((payload.action === 'sdlc_queue' || payload.action === 'sdlc_release') && (stopped || captured.generation !== viewGeneration || captured.projectId !== selectedProjectId || captured.instanceId !== selectedInstanceId)) return false;
139
+ return (payload.action === 'sdlc_queue' || payload.action === 'sdlc_release') ? result.report : true;
140
+ }
141
+ catch (error) { if (key && pendingStarts.get(key) === pending) clearPendingStart(key); if (!stopped && (!managerMode || captured.generation === viewGeneration)) notice(error.message, true); return false; }
142
+ finally { if (button) { pendingButtons.delete(button); if (!stopped) button.disabled = Boolean(key && pendingStarts.has(key)) || (!managerAction && managerMode && !snapshotValid); } if (key) redrawStartControls(); }
89
143
  }
90
- function button(text, payload, cls, managerAction = false) { const node = el('button', text, cls); node.type = 'button'; node.dataset.mutation = managerAction ? 'manager' : 'project'; node.disabled = !managerAction && managerMode && !snapshotValid; node.addEventListener('click', () => void action(payload, node, managerAction)); return node; }
144
+ function button(text, payload, cls, managerAction = false) { const node = el('button', text, cls); node.type = 'button'; node.dataset.mutation = managerAction ? 'manager' : 'project'; if (payload.action === 'start_peer') node.dataset.startKey = startKey(payload.peer); node.disabled = Boolean(node.dataset.startKey && pendingStarts.has(node.dataset.startKey)) || (!managerAction && managerMode && !snapshotValid); node.addEventListener('click', () => void action(payload, node, managerAction)); return node; }
91
145
  function update(id, data, render) { const signature = JSON.stringify(data); if (signatures.get(id) === signature) return; signatures.set(id, signature); const target = $(id); target.replaceChildren(); render(target); }
92
146
  function empty(target, text) { target.append(el('p', text, 'empty')); }
93
147
  function count(value) { return Array.isArray(value) ? value.length : value && typeof value === 'object' ? Object.keys(value).length : value; }
@@ -96,22 +150,35 @@ function saveDraft() {
96
150
  if (!managerMode || !selectedProjectId) return;
97
151
  const previous = drafts.get(selectedProjectId) || {};
98
152
  drafts.set(selectedProjectId, {message:$('message').value, recipient:draftChoicesPending ? previous.recipient || '' : $('recipient').value,
99
- title:$('title').value, detail:$('detail').value, class:$('class').value, owner:draftChoicesPending ? previous.owner || '' : $('owner').value});
153
+ ...($('backlog-proposal')?.checked ? {backlog:true} : {}), title:$('title').value, detail:$('detail').value, class:$('class').value, owner:draftChoicesPending ? previous.owner || '' : $('owner').value});
100
154
  }
101
155
  function restoreDraft(choices = false) {
102
156
  const draft = drafts.get(selectedProjectId) || {};
157
+ const staged=$('backlog-proposal'); if(staged)staged.checked=draft.backlog===true;
103
158
  if (choices) {
104
159
  for (const id of ['recipient','owner']) $(id).value = Array.from($(id).options).some((o) => o.value === draft[id]) ? draft[id] : '';
105
160
  } else { $('message').value = draft.message || ''; $('title').value = draft.title || ''; $('detail').value = draft.detail || ''; $('class').value = draft.class || 'implement'; }
106
161
  }
107
- function syncMutationControls() { document.querySelectorAll('button[data-mutation="project"]').forEach((b) => { b.disabled = pendingButtons.has(b) || (managerMode && !snapshotValid); }); }
162
+ 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); }); }
108
163
  function resetView(id, instance) {
109
164
  saveDraft();
110
165
  selectedProjectId = id || ''; selectedInstanceId = instance || ''; snapshotValid = false; cursor = 0; eventCount = 0; viewGeneration++;
166
+ lastSnapshot = null;
167
+ officePrev = null; officeDesks.clear(); officeCards.clear(); officeScene = null;
168
+ $('office').replaceChildren(); $('office-section').hidden = true;
169
+ const pet=$('pet-container');if(pet){pet.replaceChildren();pet.hidden=true;}
170
+ if(typeof petSignature!=='undefined')petSignature='';if(typeof petFirst!=='undefined')petFirst=undefined;if(typeof petBaseTitle!=='undefined')document.title=petBaseTitle;
111
171
  signatures.clear(); $('stream').replaceChildren(); empty($('stream'), managerMode ? 'Select a running project...' : 'Waiting for hub activity...');
112
172
  if (streamFilter) { streamFilter = ''; for (const child of $('stream-filters').children) child.setAttribute('aria-pressed', 'false'); }
113
173
  $('stream-empty').hidden = true; $('task-filter').value = '';
114
- for (const [id, text] of [['peers','No project selected.'],['tasks','No project selected.'],['task-progress','No project selected.'],['permissions','No project selected.'],['budget','No project selected.']]) { $(id).replaceChildren(); empty($(id), text); }
174
+ if(typeof loadedPlanningDates!=='undefined')loadedPlanningDates.clear();
175
+ if(typeof planningEdit!=='undefined')planningEdit=null;if(typeof releaseEdit!=='undefined')releaseEdit=null; if (typeof boardViewState !== 'undefined') boardViewState=null; if (typeof showAllDone !== 'undefined') showAllDone=false;
176
+ const legend = $('task-legend'); if (legend) legend.replaceChildren(); const filter = $('task-peer-filter'); if (filter) filter.value = '';
177
+ $('split-titles').value='';$('split-reason').value='';$('split-sequential').checked=false;
178
+ $('release-name').value='';$('release-goal').value='';$('release-order').value='0';
179
+ $('delivery-report').textContent = ''; $('delivery-repo').value = ''; $('delivery-live').value = '';
180
+ 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.']]) { $(id).replaceChildren(); if (text) empty($(id), text); }
181
+ $('settings-session').textContent = '';
115
182
  $('peer-count').textContent = ''; $('task-count').textContent = ''; $('permission-count').textContent = ''; $('project-context').textContent = '';
116
183
  notice('');
117
184
  draftChoicesPending = managerMode && Boolean(selectedProjectId);
@@ -184,7 +251,7 @@ function renderApprovals(permissions) {
184
251
  update('permissions', permissions, (target) => {
185
252
  if (!permissions.length) return empty(target, 'No approvals waiting.');
186
253
  for (const permission of permissions) {
187
- const row = el('div', undefined, 'row'); row.append(el('div', permission.peer, 'label'), el('p', permission.title, 'body'));
254
+ const row = el('div', undefined, 'row'); row.dataset.permissionId=permission.id;row.tabIndex=0;row.append(el('div', permission.peer, 'label'), el('p', permission.title, 'body'));
188
255
  if (permission.terminalOnly) row.append(el('p', 'Review in the console or ahub tail; answer with ahub permit. You can deny here.', 'meta'));
189
256
  const actions = el('div', undefined, 'actions');
190
257
  for (const option of permission.options) { const deny = option.kind === 'reject_once' || option.kind === 'reject_always'; if (permission.terminalOnly && !deny) continue; actions.append(button(option.name, {action:'permit', id:permission.id, option:option.optionId}, deny ? 'danger' : 'primary')); }
@@ -192,55 +259,446 @@ function renderApprovals(permissions) {
192
259
  }
193
260
  });
194
261
  }
262
+ const SETTING_GROUPS = ['Roster', 'Permissions', 'Roles', 'Start', 'Routing', 'Switches'], INHERIT = '__inherit__';
263
+ const settingsOpen = new Set(['Permissions']);
264
+ const settingText = (value) => value === null || value === undefined ? '(not set)' : Array.isArray(value) ? (value.join(', ') || '(none)') : String(value);
265
+ async function previewSetting(row, value, out, control) {
266
+ if (stopped) return;
267
+ if (!canMutate()) return notice('Wait for a current project snapshot before previewing a change.', true);
268
+ const captured = context();
269
+ let payload = {action:'setting_preview', key:row.key, value};
270
+ if (managerMode) payload = {...payload, projectId: captured.projectId, instanceId: captured.instanceId};
271
+ control.disabled = true;
272
+ try {
273
+ const result = await post('/action', payload), lines = [...(result.lines || [])];
274
+ // Each side is route explain: the task line, "if it were assigned now:", then the trace.
275
+ for (const task of result.tasks || []) lines.push('', task.before[0], ' before:', ...task.before.slice(2).map((line) => ' ' + line), ' after:', ...task.after.slice(2).map((line) => ' ' + line));
276
+ if (result.hidden) lines.push('', result.hidden + ' more open tasks are not shown.');
277
+ out.textContent = lines.join('\n');
278
+ } catch (error) { out.textContent = error.message; }
279
+ finally { if (!stopped) control.disabled = false; }
280
+ }
281
+ function settingRow(row, raise) {
282
+ const node = el('div', undefined, 'row'), head = el('div', undefined, 'row-head'), id = 'setting-' + row.key.replaceAll('.', '-');
283
+ const title = el('label', row.label, 'label'); title.htmlFor = id;
284
+ head.append(title, badge(settingText(row.value), row.group === 'Permissions' && row.value !== null && row.value !== 'ask' ? 'warn' : ''));
285
+ node.append(head, el('p', row.key + ' / from ' + row.source + ' / ' + (row.applies === 'live' ? 'applies at once' : row.applies === 'peer start' ? "applies at the peer's next start" : 'read at hub start') + ' / written to ' + row.file, 'meta'));
286
+ if (row.pending !== undefined) node.append(el('p', 'Pending restart: ' + settingText(row.pending) + ' at the next ' + (row.applies === 'peer start' ? 'peer' : 'hub') + ' start.', 'meta'));
287
+ if (row.hint) node.append(el('p', 'Bounds: ' + row.hint, 'meta'));
288
+ if (row.note) node.append(el('p', row.note, 'meta'));
289
+ if (row.risk === 'raises' && !raise) node.append(el('p', 'Raising this needs a settings session: run ahub ui --settings.' + (row.floor === undefined ? '' : ' Setting it to ' + row.floor + ' works here.'), 'meta'));
290
+ const actions = el('div', undefined, 'actions'), stored = row.file !== 'this hub run';
291
+ let read, confirm, force;
292
+ if (row.type === 'roles') {
293
+ const current = Array.isArray(row.pending) ? row.pending : Array.isArray(row.value) ? row.value : [];
294
+ const choices = [0, 1].map(index => {
295
+ const select = el('select'); select.id = index ? id + '-second' : id;
296
+ if (index) select.setAttribute('aria-label', row.label + ': second optional role');
297
+ select.append(Object.assign(el('option', 'No role'), {value:''}));
298
+ for (const value of row.values || []) select.append(Object.assign(el('option', value), {value}));
299
+ if (current[index] && !(row.values || []).includes(current[index])) select.append(Object.assign(el('option', current[index] + ' (unavailable)'), {value:current[index],disabled:true}));
300
+ select.value = current[index] || ''; actions.append(select); return select;
301
+ });
302
+ read = () => choices.map(select => select.value).filter(Boolean);
303
+ } else if (row.type === 'integer') {
304
+ const input=el('input');input.type='number';input.id=id;input.step='1';input.min=String(row.min ?? 0);input.max=String(row.max ?? Number.MAX_SAFE_INTEGER);const current=row.pending!==undefined ? row.pending : row.value;input.value=current===null ? '' : String(current);input.placeholder='Empty inherits';
305
+ read=()=>input.value==='' ? null : Number(input.value);actions.append(input);
306
+ } else if (row.type === 'peers') {
307
+ const input = el('input'); input.type = 'text'; input.id = id; input.value = Array.isArray(row.value) ? row.value.join(', ') : ''; input.placeholder = 'peer ids, comma-separated; empty inherits'; input.autocomplete = 'off'; input.spellcheck = false;
308
+ read = () => { const list = input.value.split(',').map((value) => value.trim()).filter(Boolean); return list.length ? list : null; };
309
+ actions.append(input);
310
+ } else {
311
+ const select = el('select'), values = row.type === 'boolean' ? ['true', 'false'] : (row.values || []), current = row.value === null ? '' : String(row.value); select.id = id;
312
+ if (current && !values.includes(current)) { const option = el('option', current + ' (now)'); option.value = current; option.disabled = true; select.append(option); }
313
+ for (const value of values) { const option = el('option', value); option.value = value; select.append(option); }
314
+ if (stored) { const option = el('option', 'inherit (remove the machine-local value)'); option.value = INHERIT; select.append(option); }
315
+ select.value = current || (stored ? INHERIT : 'ask');
316
+ read = () => select.value === INHERIT ? null : row.type === 'boolean' ? select.value === 'true' : select.value;
317
+ actions.append(select);
318
+ if (row.group === 'Permissions') {
319
+ // never-ask: the person types the peer it is for, as the console asks for y. Removing the machine-local value
320
+ // can leave a never-ask from the shared file in force, so inherit offers the same field.
321
+ const peer = row.key.split('.').pop();
322
+ confirm = el('input'); confirm.type = 'text'; confirm.placeholder = 'type ' + peer + ' to confirm never-ask'; confirm.setAttribute('aria-label', 'Type ' + peer + ' to confirm never-ask for ' + peer); confirm.autocomplete = 'off'; confirm.spellcheck = false;
323
+ const removable = row.source === 'config.local.json'; // inherit changes something only where the machine-local file holds a value
324
+ const sync = () => { confirm.hidden = select.value !== 'never-ask' && !(select.value === INHERIT && removable); }; select.addEventListener('change', sync); sync();
325
+ actions.append(confirm);
326
+ }
327
+ }
328
+ if (row.group === 'Roster') {
329
+ const peer = row.key.split('.').pop();
330
+ confirm = el('input'); confirm.type = 'text'; confirm.placeholder = 'type ' + peer + ' to confirm removal'; confirm.setAttribute('aria-label', 'Confirm removal of ' + peer); confirm.autocomplete = 'off'; confirm.spellcheck = false;
331
+ force = el('input'); force.type = 'checkbox'; force.setAttribute('aria-label', 'Stop an active turn now');
332
+ const label = el('label', ' Stop an active turn now (--now)'); label.append(force); actions.append(confirm, label);
333
+ }
334
+ const out = el('div', undefined, 'preview'); out.setAttribute('role', 'status'); out.setAttribute('aria-live', 'polite');
335
+ if (row.type === 'roles') for (const select of actions.querySelectorAll('select')) select.addEventListener('change', () => { const ids = read(); out.textContent = new Set(ids).size !== ids.length ? 'Choose two distinct roles, or clear the second.' : 'Selection applies at the next peer start.'; });
336
+ if (row.group === 'Permissions' || row.group === 'Routing' || row.group === 'Roster') {
337
+ const preview = el('button', 'Preview'); preview.type = 'button'; preview.setAttribute('aria-label', 'Preview ' + row.label);
338
+ preview.addEventListener('click', () => void previewSetting(row, read(), out, preview)); actions.append(preview);
339
+ }
340
+ const save = el('button', 'Save', 'primary'); save.type = 'button'; save.dataset.mutation = 'project'; save.disabled = managerMode && !snapshotValid; save.setAttribute('aria-label', 'Save ' + row.label);
341
+ save.addEventListener('click', async () => { const payload = {action:'setting', key:row.key, value:read()}; if (confirm && !confirm.hidden && confirm.value.trim()) payload.confirm = confirm.value.trim(); if (force) payload.now = force.checked; if (row.group === 'Roster') await previewSetting(row, read(), out, save); if (row.type === 'roles') { if (new Set(payload.value).size !== payload.value.length) { out.textContent = 'Choose two distinct roles, or clear the second.'; return; } out.textContent = 'Saving role guidance...'; const accepted = await action(payload, save); out.textContent = accepted ? 'Saved. Applies at the next peer start.' : $('notice').textContent; } else void action(payload, save); });
342
+ actions.append(save); if (row.type === 'roles') { const inherit = el('button', 'Use profile/shared roles'); inherit.type = 'button'; inherit.dataset.mutation = 'project'; inherit.disabled = managerMode && !snapshotValid; inherit.setAttribute('aria-label', 'Inherit ' + row.label); inherit.addEventListener('click', async () => { out.textContent = 'Restoring inherited role guidance...'; const accepted = await action({action:'setting',key:row.key,value:null}, inherit); out.textContent = accepted ? 'Inherited selection saved. Applies at the next peer start.' : $('notice').textContent; }); actions.append(inherit); } node.append(actions, out);
343
+ return node;
344
+ }
345
+ const settingSlots = new Map();
346
+ function setText(node, text) { if (node.textContent !== text) node.textContent = text; }
347
+ function renderSettings(settings, roster = []) {
348
+ if (!settings || !Array.isArray(settings.rows)) { setText($('settings-session'), ''); settingSlots.clear(); return update('settings', 'none', (target) => empty(target, 'This hub does not report settings.')); }
349
+ const raise = typeof settings.sessionUntil === 'number' && settings.sessionUntil > Date.now();
350
+ // Written only when it changes: a status region that is rewritten every second is read out every second.
351
+ setText($('settings-session'), raise ? 'Settings session: ' + Math.max(1, Math.ceil((settings.sessionUntil - Date.now()) / 60000)) + ' min left' : 'Read, lower and safe switches only');
352
+ // The frame (the groups) follows the list of keys. The undo row and every setting sit in slots of their own and
353
+ // are rebuilt only when their own data changed, so a value being typed in one row survives a save of another.
354
+ update('settings', settings.rows.map((row) => row.key), (target) => {
355
+ settingSlots.clear();
356
+ const undo = el('div'); settingSlots.set('', { node: undo, signature: null }); target.append(undo);
357
+ for (const group of SETTING_GROUPS) {
358
+ const rows = settings.rows.filter((row) => row.group === group);
359
+ if (!rows.length) continue;
360
+ const box = el('details', undefined, 'settings-group'); box.open = settingsOpen.has(group);
361
+ box.addEventListener('toggle', () => { if (box.open) settingsOpen.add(group); else settingsOpen.delete(group); });
362
+ box.append(el('summary', group + ' (' + rows.length + ')'));
363
+ for (const row of rows) { const slot = el('div'); settingSlots.set(row.key, { node: slot, signature: '' }); box.append(slot); }
364
+ target.append(box);
365
+ }
366
+ });
367
+ const undoSlot = settingSlots.get(''), undoKey = settings.undo || '';
368
+ if (undoSlot && undoSlot.signature !== undoKey) {
369
+ undoSlot.signature = undoKey; undoSlot.node.replaceChildren();
370
+ if (undoKey) {
371
+ const row = el('div', undefined, 'row'); row.append(el('p', 'Last settings write: ' + undoKey, 'meta'));
372
+ const actions = el('div', undefined, 'actions');
373
+ if (undoKey.startsWith('roster.')) {
374
+ const peer = undoKey.slice('roster.'.length), confirm = el('input'); confirm.type = 'text'; confirm.placeholder = 'type ' + peer + ' if undo removes it'; confirm.setAttribute('aria-label', 'Confirm roster undo for ' + peer);
375
+ const force = el('input'); force.type = 'checkbox'; force.setAttribute('aria-label', 'Stop an active turn now for undo');
376
+ const undo = el('button', 'Undo last write'); undo.type = 'button'; undo.dataset.mutation = 'project'; undo.addEventListener('click', () => void action({action:'setting_undo', confirm:confirm.value.trim(), now:force.checked}, undo));
377
+ actions.append(confirm, el('label', 'Stop an active turn now'), force, undo);
378
+ } else actions.append(button('Undo last write', {action:'setting_undo'}));
379
+ row.append(actions); undoSlot.node.append(row);
380
+ }
381
+ }
382
+ for (const row of settings.rows) {
383
+ const membership = roster.find(peer => row.key === 'roster.' + peer.peer);
384
+ const slot = settingSlots.get(row.key), signature = JSON.stringify([row, raise]);
385
+ if (!slot) continue;
386
+ if (slot.signature !== signature) {
387
+ slot.signature = signature; slot.node.replaceChildren(settingRow(row, raise)); slot.membership = null;
388
+ }
389
+ if (membership) {
390
+ if (!slot.membership) { slot.membership = el('p', '', 'meta'); slot.node.append(slot.membership); }
391
+ setText(slot.membership, `${membership.state}; ${membership.queued} queued; ${membership.tasks} open tasks`);
392
+ }
393
+ }
394
+ }
395
+ function syncStarts(snapshot) {
396
+ for (const start of Array.isArray(snapshot.starts) ? snapshot.starts : []) if (start.attached) clearPendingStart(startKey(start.peer));
397
+ }
398
+ function renderStarts(target, starts) {
399
+ // A Start control per peer that is not attached: the mode it will use and where that comes from.
400
+ for (const start of starts) {
401
+ if (start.attached) { clearPendingStart(startKey(start.peer)); continue; }
402
+ const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
403
+ head.append(el('span', 'Start ' + start.peer, 'label'), badge(start.mode));
404
+ const tui = start.mode === 'tui';
405
+ row.append(head, el('p', !tui ? 'Starts headless: its start mode.' : start.via ? 'Start mode tui: opens ' + start.command + ' in a terminal through ' + start.via + '.' : 'Start mode tui, and the hub has no terminal to open (' + start.why + '). Run ' + start.command + ' in a terminal.', 'meta'));
406
+ if (!tui || start.via) { const actions = el('div', undefined, 'actions'); actions.append(button('Start ' + start.peer, {action:'start_peer', peer:start.peer})); row.append(actions); }
407
+ const pending = pendingStarts.get(startKey(start.peer));
408
+ if (pending) row.append(el('p', pending.text, 'meta'));
409
+ target.append(row);
410
+ }
411
+ }
412
+ let boardViewState = null, showAllDone = false;
413
+ let planningEdit = null, releaseEdit = null;
414
+ const loadedPlanningDates = new Map();
415
+ const localDateInput = value => {
416
+ if(!Number.isSafeInteger(value) || value<=0 || value>8_640_000_000_000_000)return '';
417
+ const date=new Date(value),pad=(value,size=2)=>String(value).padStart(size,'0');
418
+ return pad(date.getFullYear(),4)+'-'+pad(date.getMonth()+1)+'-'+pad(date.getDate())+'T'+pad(date.getHours())+':'+pad(date.getMinutes())+':'+pad(date.getSeconds())+'.'+pad(date.getMilliseconds(),3);
419
+ };
420
+ function loadPlanningDate(id,value) {
421
+ const input=$(id);input.value=localDateInput(value);
422
+ loadedPlanningDates.set(id,{text:input.value,utc:Number.isSafeInteger(value)&&value>0&&value<=8_640_000_000_000_000 ? value : null});
423
+ }
424
+ const plannedDateInput = id => {
425
+ const value=$(id).value,loaded=loadedPlanningDates.get(id);
426
+ if(loaded && loaded.text===value)return loaded.utc;
427
+ return value ? new Date(value.replace(/^(\d{5,})-/,(_match,year)=>'+'+year.padStart(6,'0')+'-')).getTime() : null;
428
+ };
429
+ const dateText = value => !Number.isSafeInteger(value) || value<=0 || value>8_640_000_000_000_000 ? 'unknown' : new Date(value).toLocaleString();
430
+ function loadPlanning() {
431
+ const task=boardViewState?.snapshot.tasks.find(task=>task.id===Number($('planning-task').value));
432
+ planningEdit=task ? {id:task.id,revision:task.planningRevision ?? 0,context:boardContext()} : null;
433
+ if(!task)return;
434
+ $('planning-priority').value=task.priority == null ? '' : String(task.priority);$('planning-rank').value=task.rank == null ? '' : String(task.rank);
435
+ $('planning-release').value=task.release == null ? '' : String(task.release);$('planning-pinned').checked=task.pinned===true;
436
+ loadPlanningDate('planning-start',task.plannedStart);loadPlanningDate('planning-due',task.due);$('planning-reason').value='';
437
+ $('planning-version').textContent='Loaded current task plan.';
438
+ }
439
+ function loadReleasePlanning() {
440
+ const release=boardViewState?.snapshot.releases?.find(release=>release.id===Number($('release-edit').value));
441
+ releaseEdit=release ? {id:release.id,revision:release.revision,context:boardContext()} : null;
442
+ $('release-version').textContent=release ? 'Loaded current release.' : 'Select release.';
443
+ for(const [id,key] of [['release-start','plannedStart'],['release-cut','cut'],['release-ship','ship']])loadPlanningDate(id,release?.[key]);
444
+ }
445
+ function planningOptions(node, rows, label, selected) {
446
+ node.replaceChildren();const initial=el('option',label);initial.value='';node.append(initial);
447
+ for(const row of rows){const option=el('option',row.label);option.value=String(row.id);node.append(option)}
448
+ if(rows.some(row=>String(row.id)===selected))node.value=selected;
449
+ }
450
+ function renderPlanning(snapshot) {
451
+ const supported=snapshot.planningCapabilities?.enabled===true;
452
+ $('split-panel').hidden=snapshot.planningCapabilities?.split!==true;$('split-save').disabled=snapshot.planningCapabilities?.split!==true || !sameBoardContext(boardContext());
453
+ for(const id of ['planning-save','planning-undo','release-save'])$(id).disabled=!supported || !sameBoardContext(boardContext());
454
+ const releases=Array.isArray(snapshot.releases)?snapshot.releases:[], releaseRows=releases.map(release=>({id:release.id,label:'#'+release.id+' '+release.name}));
455
+ for(const id of ['task-release-filter','planning-release','release-edit'])planningOptions($(id),releaseRows,id==='task-release-filter'?'All releases':'No release',$(id).value);
456
+ if(releaseEdit && (!sameBoardContext(releaseEdit.context) || releases.find(release=>release.id===releaseEdit.id)?.revision!==releaseEdit.revision)){releaseEdit=null;$('release-version').textContent='Release changed. Reload before saving.';}
457
+ const selection=$('planning-task').value;planningOptions($('planning-task'),snapshot.tasks.filter(task=>task.state!=='approved').map(task=>({id:task.id,label:'#'+task.id+' '+task.title})),'Select task',selection);
458
+ if(planningEdit && (!sameBoardContext(planningEdit.context) || !snapshot.tasks.some(task=>task.id===planningEdit.id)))planningEdit=null;
459
+ if(planningEdit && snapshot.tasks.find(task=>task.id===planningEdit.id)?.planningRevision!==planningEdit.revision)$('planning-version').textContent='Task changed. Reload before saving.';
460
+ const states=new Map(snapshot.tasks.map(task=>[task.id,task.state]));
461
+ update('releases',{releases,taskStates:[...states]},target=>{
462
+ if(!releases.length)return empty(target,'No releases planned.');
463
+ const auditIds=(ids,count)=>ids.map(id=>'#'+id).join(', ')+(Number.isSafeInteger(count)&&count>ids.length?' (showing '+ids.length+' of '+count+')':'');
464
+ for(const release of releases){const row=el('div',undefined,'row');row.append(el('strong','#'+release.id+' '+release.name),badge(release.state),el('p',release.goal));
465
+ const scope=Array.isArray(release.tasks)?release.tasks:null, remaining=scope?.filter(task=>task.state!=='approved');
466
+ const blocking=remaining?.filter(task=>(task.deps || []).some(id=>states.get(id)!=='approved'));
467
+ row.append(el('p',scope?remaining.length+' remaining of '+scope.length+' / '+blocking.length+' blocked':'Scope unknown','meta'));
468
+ if(scope){const progress=el('progress');progress.max=Math.max(1,scope.length);progress.value=scope.length-remaining.length;progress.setAttribute('aria-label','Approved tasks in '+release.name);row.append(progress)}
469
+ row.append(el('p','Start '+dateText(release.plannedStart)+' / cut '+dateText(release.cut)+' / ship '+dateText(release.ship),'meta'));
470
+ row.append(el('p','Actual: active '+dateText(release.activatedAt)+' / frozen '+dateText(release.frozenAt)+' / shipped '+dateText(release.shippedAt),'meta'));
471
+ if(Array.isArray(release.afterCutUnknown) && release.afterCutUnknown.length)row.append(el('p','Timing relative to release cut unknown: '+auditIds(release.afterCutUnknown,release.afterCutUnknownCount),'meta'));
472
+ if(Array.isArray(release.afterCut) && release.afterCut.length)row.append(el('p','Approved after cut outside this release: '+auditIds(release.afterCut,release.afterCutCount),'meta'));
473
+ const controls=el('div',undefined,'actions');for(const [from,state,label] of [['planning','active','Activate'],['active','frozen','Freeze'],['frozen','shipped','Record shipped']])if(release.state===from)controls.append(boardButton(label,{action:'release_change',id:release.id,revision:release.revision,fields:{state}}));
474
+ if(!['shipped','dropped'].includes(release.state))controls.append(boardButton('Drop release',{action:'release_change',id:release.id,revision:release.revision,fields:{state:'dropped'}}));
475
+ row.append(controls);target.append(row);
476
+ }
477
+ });
478
+ }
479
+
480
+ function peerMark(peer, reviewer = false) {
481
+ const key = Object.hasOwn(peerPalette, peer || '') ? peer : 'other';
482
+ const mark = el('span', undefined, 'peer-mark peer-' + key), color = el('span', undefined, 'peer-color-dot' + (reviewer ? ' reviewer' : ''));
483
+ color.setAttribute('aria-hidden', 'true'); mark.append(color, el('span', (reviewer ? 'reviewer: ' : 'owner: ') + (peer || 'unassigned'))); return mark;
484
+ }
485
+ function boardContext() {
486
+ const current=context(), snapshot=boardViewState?.snapshot || {};
487
+ return {...current,origin:location.origin,projectId:typeof snapshot.projectId==='string' ? snapshot.projectId : current.projectId,instanceId:typeof snapshot.instanceId==='string' ? snapshot.instanceId : current.instanceId};
488
+ }
489
+ function sameBoardContext(expected) {
490
+ const current=boardContext(); return !stopped && canMutate() && Boolean(current.projectId && current.instanceId) && expected.origin===current.origin && expected.projectId===current.projectId && expected.instanceId===current.instanceId && expected.generation===current.generation;
491
+ }
492
+ function boardButton(text, payload) {
493
+ const expected=boardContext(), node=el('button',text); node.type='button';node.dataset.mutation='project';node.disabled=managerMode && !snapshotValid;
494
+ node.addEventListener('click',()=>{if(!sameBoardContext(expected))return notice('The project changed. Use its current board.',true);void action(payload,node)});return node;
495
+ }
496
+ function draggedCard(event) {
497
+ if (!canMutate() || !boardViewState) return null;
498
+ try {
499
+ const raw = event.dataTransfer?.getData('application/x-agent-hub-task');
500
+ if (!raw || raw.length > 1024) return null;
501
+ const data = JSON.parse(raw), current = boardContext();
502
+ if (data.origin !== current.origin || data.projectId !== current.projectId || data.instanceId !== current.instanceId || data.generation !== current.generation || !Number.isSafeInteger(data.id)) return null;
503
+ return boardViewState.cards.get(data.id) || null;
504
+ } catch { return null; }
505
+ }
506
+ function dropTarget(node, accepted, payload) {
507
+ const expected=boardContext();
508
+ node.addEventListener('dragover', event => { if (sameBoardContext(expected)) { event.preventDefault(); node.classList.add('board-drop-active'); } });
509
+ node.addEventListener('dragleave', () => node.classList.remove('board-drop-active'));
510
+ node.addEventListener('drop', event => {
511
+ event.preventDefault(); node.classList.remove('board-drop-active');
512
+ if(!sameBoardContext(expected))return notice('The project changed. Use its current board.',true);
513
+ const card = draggedCard(event);
514
+ if (!card || !accepted(card)) return notice('This task cannot move here. Use its available buttons.', true);
515
+ void action({...payload, id:card.id});
516
+ });
517
+ }
518
+ function taskCard(task, card, stage, ids) {
519
+ const row = el('article', undefined, 'task-card'), head = el('div', undefined, 'row-head');
520
+ row.tabIndex = 0; taskRows.set(task.id,row);
521
+ const trackCard = on => { const sceneCard=officeCards.get(task.id);if(sceneCard)sceneCard.classList.toggle('office-spotlight',on); };
522
+ row.addEventListener('mouseenter',()=>trackCard(true));row.addEventListener('mouseleave',()=>trackCard(false));
523
+ row.addEventListener('focus',()=>trackCard(true));row.addEventListener('blur',()=>trackCard(false));
524
+
525
+ row.setAttribute('aria-label', '#' + task.id + ' ' + task.title + ', ' + task.state.replaceAll('_', ' ') + ', owner ' + (task.owner || 'unassigned') + ', priority P' + card.priority);
526
+ head.append(el('span', '#' + task.id + ' ' + task.title, 'label'), badge('P' + card.priority));
527
+ if (card.starred) { const star = el('span', '★'); star.setAttribute('aria-hidden','true'); head.append(star); }
528
+ row.append(head, peerMark(task.owner)); if (task.reviewer) row.append(peerMark(task.reviewer, true));
529
+ row.append(el('p', task.profileClass ? task.profileClass + ' (base ' + task.class + ')' : task.class, 'meta'));
530
+ if(task.role){const guidance='Task guidance: '+task.role+' (grants no authority)';row.append(el('p',guidance,'meta'));row.setAttribute('aria-label',row.getAttribute('aria-label')+', '+guidance);}
531
+ if(task.release!=null){const release=boardViewState?.snapshot.releases?.find(release=>release.id===task.release),label='Release #'+task.release+' '+(release?.name || '(label unavailable)');row.append(el('p',label,'meta'));row.setAttribute('aria-label',row.getAttribute('aria-label')+', '+label);}
532
+ if (stage) row.append(renderTaskStage(stage));
533
+ if (card.waiting.length) row.append(el('p', 'Waiting: ' + card.waiting.join('; '), 'meta'));
534
+ if (task.reworkRound) row.append(el('p', 'Rework round ' + task.reworkRound, 'meta'));
535
+ if (task.checking) row.append(el('p', 'Check running', 'meta'));
536
+ if (task.lifecycle) row.append(el('p', 'SDLC ' + task.lifecycle.mode + (task.lifecycle.workflow ? ' (' + task.lifecycle.workflow + ')' : '') + ' / ' + task.lifecycle.stage + ': ' + task.lifecycle.next + ' / next: ' + task.lifecycle.actor, 'meta'));
537
+ if (task.lifecycle?.definition) row.append(el('p', 'Stage base: ' + task.lifecycle.definition.base + ' / entry: ' + (task.lifecycle.entry?.length ? task.lifecycle.entry.join('; ') : 'ready') + ' / exit: ' + (task.lifecycle.exit?.length ? task.lifecycle.exit.join('; ') : 'ready'), 'meta'));
538
+ if (task.detail) row.append(el('p', task.detail, 'body'));
539
+ if(task.external?.provider==='github'){const source=el('a','GitHub #'+task.external.issue);source.href=task.external.url;source.rel='noreferrer';source.target='_blank';row.append(source);}
540
+ if(task.pinned)row.append(el('p','Pinned','meta'));
541
+ if(task.plannedStart != null || task.due != null)row.append(el('p','Planned '+dateText(task.plannedStart)+' / due '+dateText(task.due),'meta'));
542
+ if(task.due != null && task.due<Date.now() && task.state!=='approved')row.append(badge('Overdue','warn'));
543
+ if(task.actualDates)row.append(el('p','Actual start '+dateText(task.actualDates.startedAt)+' / review '+dateText(task.actualDates.reviewAt)+' / done '+dateText(task.actualDates.doneAt),'meta'));
544
+
545
+ const controls = el('div', undefined, 'actions');
546
+ if (card.pull && boardViewState?.snapshot.boardCapabilities?.backlog===true) controls.append(boardButton('Pull into To Do', {action:'backlog_pull',id:task.id}));
547
+ 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'}));
548
+ if (card.returnable && boardViewState?.snapshot.boardCapabilities?.backlog===true) controls.append(boardButton('Return to Backlog', {action:'backlog_return',id:task.id}));
549
+ if (card.assignable && ids.length) {
550
+ const expected=boardContext(), select = el('select'); select.setAttribute('aria-label', 'Assign task ' + task.id); peerOptions(select, ids, 'Select peer'); if (ids.includes(task.owner)) select.value = task.owner;
551
+ const assign = el('button', 'Assign'); assign.type='button'; assign.dataset.mutation='project'; assign.disabled=managerMode && !snapshotValid;
552
+ assign.addEventListener('click', () => { if(!sameBoardContext(expected))return notice('The project changed. Use its current board.',true);if(select.value)void action({action:'assign',id:task.id,peer:select.value},assign);else notice('Select a peer before assigning this task.',true); }); controls.append(select,assign);
553
+ }
554
+ if (controls.children.length) row.append(controls);
555
+ if (card.pull || card.assignable) {
556
+ row.draggable = true; row.title = 'Drag to an available peer or Backlog/To Do, or use the buttons.';
557
+ row.addEventListener('dragstart', event => {
558
+ if (!canMutate() || !event.dataTransfer) return event.preventDefault();
559
+ event.dataTransfer.effectAllowed='move'; event.dataTransfer.setData('application/x-agent-hub-task',JSON.stringify({...boardContext(),id:task.id}));
560
+ });
561
+ }
562
+ return row;
563
+ }
564
+ function renderBoard(snapshot, peers, ids) {
565
+ if (!Array.isArray(snapshot.tasks)) { boardViewState=null; return update('tasks','unavailable',target=>empty(target,'Task board unavailable.')); }
566
+ const progress = taskProgress(snapshot.tasks), model = boardColumns(snapshot.tasks, peers);
567
+ renderTaskProgress(progress);
568
+ const stages = new Map(progress.stages.map(stage=>[stage.id,stage])), byId = new Map(snapshot.tasks.map(task=>[task.id,task]));
569
+ const cards = new Map(model.columns.flatMap(column=>[...column.ready,...column.waiting]).map(card=>[card.id,card]));
570
+ boardViewState={snapshot,peers,ids,cards};
571
+ renderPlanning(snapshot);
572
+ const staged=$('backlog-proposal');staged.disabled=snapshot.boardCapabilities?.backlog!==true || !sameBoardContext(boardContext());
573
+ if(snapshot.boardCapabilities?.backlog!==true)staged.checked=false;
574
+ const peerFilter=$('task-peer-filter'), previous=peerFilter.value;
575
+ peerOptions(peerFilter,ids,'All agents'); if (ids.includes(previous)) peerFilter.value=previous;
576
+ const taskFilter=$('task-filter').value, ownerFilter=peerFilter.value, releaseFilter=$('task-release-filter').value, lanes=$('task-lanes').value;
577
+ const eligible=ids.filter(id=>['idle','busy'].includes(peers[id].state) && peers[id].attached!==false && peers[id].rostered!==false);
578
+ $('task-count').textContent=snapshot.tasks.length+' tasks';
579
+ update('task-legend',[snapshot.boardLegend,peers],target=>{
580
+ for (const id of ids) {
581
+ const data=(snapshot.boardLegend || []).find(item=>item.peer===id), row=el('div',undefined,'agent-slot');
582
+ row.append(peerMark(id),el('p',String(peers[id].state || 'unknown'),'meta'));
583
+ const wip=data && Number.isSafeInteger(data.wip) && data.wip>=0 ? data.wip : 'unknown';
584
+ row.append(el('p','WIP '+wip+(Number.isSafeInteger(data?.wipLimit) ? '/'+data.wipLimit : '')+' / permission '+(data?.permissionMode || 'unknown'),'meta'));
585
+ if (Array.isArray(data?.activeTasks) && data.activeTasks.length) row.append(el('p','Active tasks: '+data.activeTasks.map(id=>'#'+id).join(', '),'meta'));
586
+ const filter=el('button','View '+id);filter.type='button';filter.addEventListener('click',()=>{if(!boardViewState)return;peerFilter.value=id;renderBoard(boardViewState.snapshot,boardViewState.peers,boardViewState.ids)});row.append(filter);
587
+ if (eligible.includes(id)) dropTarget(row,card=>card.assignable,{action:'assign',peer:id});
588
+ target.append(row);
589
+ }
590
+ if (!ids.length) empty(target,'No peer status available.');
591
+ });
592
+ update('tasks',[snapshot.tasks,peers,snapshot.boardCapabilities,taskFilter,ownerFilter,releaseFilter,lanes,showAllDone],target=>{
593
+ taskRows.clear();
594
+ const visible=snapshot.tasks.filter(task=>(!taskFilter || (taskFilter==='work' ? ['in_progress','changes_requested'].includes(task.state) : task.state===taskFilter)) && (!ownerFilter || task.owner===ownerFilter || task.reviewer===ownerFilter) && (!releaseFilter || task.release===Number(releaseFilter)));
595
+ const visibleIds=new Set(visible.map(task=>task.id));
596
+ const owners=lanes==='agents' ? [...new Set(visible.map(task=>task.owner || ''))] : lanes==='releases' ? [...new Set(visible.map(task=>task.release ?? 0))] : [null];
597
+ if (!owners.length) owners.push(null);
598
+ for (const [laneIndex, owner] of owners.entries()) {
599
+ const lane=el('div',undefined,'board-lane');
600
+ if(owner!==null) {
601
+ const heading=el('h3',lanes==='releases' ? (snapshot.releases?.some(release=>release.id===owner) ? '#'+owner+' '+snapshot.releases.find(release=>release.id===owner).name : 'No release') : owner || 'Unassigned');
602
+ if(lanes==='agents' && eligible.includes(owner)) dropTarget(heading,card=>card.assignable,{action:'assign',peer:owner});
603
+ lane.append(heading);
604
+ }
605
+ const grid=el('div',undefined,'board-columns');
606
+ const inLane=card=>visibleIds.has(card.id) && (owner===null || (lanes==='releases' ? (byId.get(card.id)?.release ?? 0)===owner : (byId.get(card.id)?.owner || '')===owner));
607
+ for (const column of model.columns) {
608
+ const box=el('div',undefined,'board-column'), heading=el('h3',column.label);heading.id='board-lane-'+laneIndex+'-'+column.id;box.setAttribute('role','group');box.setAttribute('aria-labelledby',heading.id);box.append(heading);
609
+ if (column.id==='backlog' && snapshot.boardCapabilities?.backlog===true) dropTarget(box,card=>card.returnable,{action:'backlog_return'});
610
+ if (column.id==='todo' && snapshot.boardCapabilities?.backlog===true) dropTarget(box,card=>card.pull,{action:'backlog_pull'});
611
+ let ready=column.ready.filter(inLane), waiting=column.waiting.filter(inLane);
612
+ if(column.id==='done') ready.sort((a,b)=>(byId.get(b.id)?.updated || 0)-(byId.get(a.id)?.updated || 0) || b.id-a.id);
613
+ const hidden=column.id==='done' && !showAllDone ? Math.max(0,ready.length-10) : 0;
614
+ if(hidden) ready=ready.slice(0,10);
615
+ for(const [label,list] of [['Ready',ready],['Waiting',waiting]]) {
616
+ if(column.id==='todo') box.append(el('h4',label+' ('+list.length+')'));
617
+ for(const card of list) box.append(taskCard(byId.get(card.id),card,stages.get(card.id),eligible));
618
+ }
619
+ if(!ready.length && !waiting.length) empty(box,'No tasks');
620
+ if(hidden){const more=el('button','Show '+hidden+' older Done tasks');more.type='button';more.addEventListener('click',()=>{if(!boardViewState)return;showAllDone=true;renderBoard(boardViewState.snapshot,boardViewState.peers,boardViewState.ids)});box.append(more)}
621
+ grid.append(box);
622
+ }
623
+ lane.append(grid);target.append(lane);
624
+ }
625
+ const unknown=model.unknown.filter(id=>visibleIds.has(id));if(unknown.length) target.append(el('p','Unsupported task states: '+unknown.map(id=>'#'+id).join(', '),'board-unknown'));
626
+ });
627
+ }
628
+ const petBaseTitle = typeof document === 'undefined' ? 'agent-hub | Local dashboard' : document.title;
629
+ let petSignature = '', petFirst;
630
+ function reachPetNeed() {
631
+ if (!snapshotValid || !petFirst) return;
632
+ const item = petFirst;
633
+ const row = item.kind === 'review' || item.kind === 'offline' ? taskRows.get(item.id) : item.kind === 'delivery' || item.kind === 'default' ? peerRows.get(item.peer) : item.kind === 'approval' ? document.querySelector('[data-permission-id="'+item.id+'"]') : undefined;
634
+ if (row) { row.scrollIntoView({block:'center',behavior:'instant'}); row.focus?.(); return; }
635
+ const heading = item.kind === 'approval' ? 'permissions-title' : ['review','offline'].includes(item.kind) ? 'tasks-title' : item.kind === 'budget' ? 'budget-title' : 'peers-title';
636
+ $(heading)?.scrollIntoView({block:'start',behavior:'instant'});
637
+ notice('Attention: '+item.kind+(item.peer?' '+item.peer:'')+(item.id?' '+item.id:''));
638
+ }
639
+ function renderPetAttention(snapshot) {
640
+ const valid = snapshot && snapshot.pet?.enabled === true && typeof snapshot.pet.character === 'string' && Object.hasOwn(petCharacters,snapshot.pet.character) && snapshot.attention && typeof snapshot.attention === 'object' && !Array.isArray(snapshot.attention);
641
+ const target=$('pet-container');if(!target)return;
642
+ const input=snapshot?.attention;
643
+ const shape=valid && Array.isArray(input.approvals) && Array.isArray(input.defaults) && Array.isArray(input.tasks) && input.peers && typeof input.peers==='object' && !Array.isArray(input.peers) && input.budget && typeof input.budget==='object' && !Array.isArray(input.budget) && typeof input.storageBlocked==='boolean';
644
+ let value;try{if(shape)value=needs(input,Date.now());}catch{ /* malformed optional attention never breaks the existing dashboard */ }
645
+ const signature = JSON.stringify(value ? [snapshot.pet.character,snapshot.pet.blink,value] : null);
646
+ if(signature===petSignature)return;
647
+ petSignature=signature;petFirst=value?.items[0];
648
+ target.replaceChildren();target.hidden=!value;
649
+ const title=value?.act?'('+value.act+') agent-hub':petBaseTitle;if(document.title!==title)document.title=title;
650
+ if(!value)return;
651
+ const character=petCharacters[snapshot.pet.character],frame=character.frames.blocks[value.state].full,model=petPixels(frame);
652
+ const blink = snapshot.pet.blink!==false && value.items.some(item=>item.kind==='approval');
653
+ const button=el('button',undefined,'pet-control'+(blink?' pet-waiting':''));button.type='button';
654
+ const label=character.name+': '+value.state+', '+value.act+' action, '+value.watch+' watch';button.setAttribute('aria-label',label);button.addEventListener('click',reachPetNeed);
655
+ const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('viewBox','0 0 '+model.width+' '+model.height);svg.setAttribute('class','pet-art');svg.setAttribute('aria-hidden','true');
656
+ const accent=character.accent?.full;
657
+ const poses=blink?[['pet-open',model],['pet-blink',petPixels(character.frames.blocks.waitingBlink.full)]]:[['pet-open',model]];
658
+ for(const [cls,pose] of poses){const group=document.createElementNS('http://www.w3.org/2000/svg','g');group.setAttribute('class',cls);
659
+ for(const [x,y] of pose.pixels){const pixel=document.createElementNS('http://www.w3.org/2000/svg','rect');pixel.setAttribute('x',String(x));pixel.setAttribute('y',String(y));pixel.setAttribute('width','1');pixel.setAttribute('height','1');pixel.setAttribute('class',accent && Math.floor(y/8)===accent.row && Math.floor(x/5)>=accent.start && Math.floor(x/5)<accent.start+accent.length?'pet-pixel pet-accent':'pet-pixel');group.append(pixel);}svg.append(group);}
660
+ button.append(svg,el('span',value.state+' / '+value.act+' action'+(value.watch?' +'+value.watch+' watch':'')));target.append(button);
661
+ }
195
662
  function render(snapshot) {
196
- const peers = snapshot.status.peers || {}, ids = Object.keys(peers);
663
+ renderPetAttention(snapshot);
664
+ 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 : [];
197
665
  $('peer-count').textContent = ids.length + ' peers';
198
- update('peers', peers, (target) => {
199
- if (!ids.length) return empty(target, 'No peers connected. Start an agent from your terminal.');
666
+ syncStarts(snapshot);
667
+ update('peers', [peers, starts, starts.map(start => { const pending = pendingStarts.get(startKey(start.peer)); return pending ? [pending.phase, pending.retryAt || null] : null; })], (target) => {
668
+ peerRows.clear();
669
+ if (!ids.length) { empty(target, 'No peers connected. Start one below, or from your terminal.'); return renderStarts(target, starts); }
200
670
  for (const [id, peer] of Object.entries(peers)) {
201
671
  const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head'), name = el('div', undefined, 'peername');
202
672
  name.append(dot(peerDot[peer.state] || ''), el('span', id, 'label'));
203
- head.append(name, badge(peer.state, peerDot[peer.state] || ''));
673
+ head.append(name, badge(peer.rostered === false ? peer.state + ' off roster' : peer.state, peerDot[peer.state] || ''));
204
674
  row.append(head, el('p', peer.queued + ' queued' + (peer.queuedImportant ? ' (' + peer.queuedImportant + ' important)' : ''), 'meta'));
675
+ if (peer.flowRoles) row.append(el('p', 'Flow roles: ' + (peer.flowRoles.join(', ') || 'none'), 'meta'));
676
+ if (peer.catalogRoles) { row.append(el('p', 'Catalog guidance: ' + (Array.isArray(peer.catalogRoles.value) ? peer.catalogRoles.value.join(', ') || 'none' : 'unconfirmed'), 'meta')); if (peer.catalogRoles.pending !== undefined) row.append(el('p', 'Pending at next start: ' + (peer.catalogRoles.pending.join(', ') || 'none'), 'meta')); }
205
677
  if (peer.needsReview) row.append(el('p', peer.needsReview + ' need review: use ahub queue list', 'meta'));
206
678
  if (peer.oldestQueuedAt !== undefined) row.append(el('p', 'Oldest pending: ' + Math.max(0, Math.floor((Date.now() - peer.oldestQueuedAt) / 1000)) + 's', 'meta'));
207
679
  if (peer.attached === false) row.append(el('p', 'Disconnected recipient; work is retained.', 'meta'));
208
680
  if (peer.toolsOnly) row.append(el('p', peer.toolsOnly, 'meta'));
681
+ if (peer.permissionNote) row.append(el('p', peer.permissionNote, 'meta'));
682
+ if (peer.pullOnly && id === 'kimi') row.append(el('p', 'Last hub_inbox read: ' + (typeof peer.lastInboxAt === 'number' ? Math.max(0, Math.floor((Date.now() - peer.lastInboxAt) / 1000)) + 's ago' : 'never'), 'meta'));
209
683
  if (peer.paused) row.append(el('p', peer.paused, 'meta'));
210
684
  if (peer.requestedModel) row.append(el('p', 'Requested: ' + peer.requestedModel, 'meta'));
211
685
  for (const backend of peer.backends || []) {
212
686
  if (backend.requestedModel) row.append(el('p', backend.kind.toUpperCase() + ': ' + (backend.actualModel || 'model not reported') + ' (' + backend.state + ')', 'meta'));
213
687
  }
214
688
  const paused = peer.state === 'paused' || peer.paused;
689
+ row.tabIndex = 0; peerRows.set(id, row);
690
+ const trackDesk = (on) => { const desk = officeDesks.get(id); if (desk) desk.classList.toggle('office-spotlight', on); };
691
+ row.addEventListener('mouseenter', () => trackDesk(true)); row.addEventListener('mouseleave', () => trackDesk(false));
692
+ row.addEventListener('focus', () => trackDesk(true)); row.addEventListener('blur', () => trackDesk(false));
215
693
  row.append(button(paused ? 'Resume' : 'Pause', {action:paused ? 'resume' : 'pause', peer:id})); target.append(row);
216
694
  }
695
+ renderStarts(target, starts);
217
696
  });
697
+ renderOffice(snapshot);
218
698
  const assignable = ids.filter((id) => peers[id].state !== 'offline' && peers[id].attached !== false);
219
699
  const peerSignature = JSON.stringify([ids, assignable]);
220
700
  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; } }
221
- const progress = taskProgress(snapshot.tasks);
222
- renderTaskProgress(progress);
223
- const progressStages = new Map(progress.stages.map(stage => [stage.id, stage]));
224
- $('task-count').textContent = snapshot.tasks.length + ' tasks';
225
- const taskFilter = $('task-filter').value;
226
- const visibleTasks = taskFilter ? snapshot.tasks.filter((task) => task.state === taskFilter) : snapshot.tasks;
227
- update('tasks', [visibleTasks, ids, taskFilter, progress], (target) => {
228
- if (!snapshot.tasks.length) return empty(target, 'No public tasks yet. Propose a task below to get started.');
229
- if (!visibleTasks.length) return empty(target, 'No tasks in this state.');
230
- for (const task of visibleTasks) {
231
- const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
232
- head.append(el('span', '#' + task.id + ' ' + task.title, 'label'), badge(task.state.replaceAll('_', ' '), taskBadge[task.state] || ''));
233
- const stage = progressStages.get(task.id);
234
- row.append(head, renderTaskStage(stage), el('p', task.class + ' / ' + (task.owner || 'unassigned') + (task.reviewer ? ' / review: ' + task.reviewer : ''), 'meta'));
235
- if (task.detail) row.append(el('p', task.detail, 'body'));
236
- if (task.state !== 'approved' && ids.length) {
237
- const actions = el('div', undefined, 'actions'), select = el('select'); select.setAttribute('aria-label', 'Assign task ' + task.id); peerOptions(select, ids, 'Select peer'); if (ids.includes(task.owner)) select.value = task.owner;
238
- const assign = el('button', 'Assign'); assign.type = 'button'; assign.dataset.mutation = 'project'; assign.disabled = managerMode && !snapshotValid; assign.addEventListener('click', () => { if (select.value) void action({action:'assign', id:task.id, peer:select.value}, assign); else notice('Select a peer before assigning this task.', true); });
239
- actions.append(select, assign); row.append(actions);
240
- }
241
- target.append(row);
242
- }
243
- });
701
+ renderBoard(snapshot, peers, ids);
244
702
  renderApprovals(snapshot.permissions);
245
703
  update('budget', snapshot.budget, (target) => {
246
704
  if (!Object.keys(snapshot.budget).length) return empty(target, 'No usage readings yet. Usage appears when a peer reports it.');
@@ -267,6 +725,8 @@ function render(snapshot) {
267
725
  }
268
726
  });
269
727
  renderBench(snapshot.bench);
728
+ renderMemory(snapshot.memory);
729
+ renderSettings(snapshot.settings, snapshot.roster);
270
730
  const stream = $('stream');
271
731
  if (!eventCount) { stream.replaceChildren(); empty(stream, 'No activity yet.'); }
272
732
  for (const entry of snapshot.events || []) {
@@ -291,7 +751,7 @@ async function poll() {
291
751
  const captured = context();
292
752
  if (managerMode && (!captured.projectId || !captured.instanceId)) { if (!stopped) timer = setTimeout(poll, 1000); return; }
293
753
  try {
294
- const input = {after:cursor}; if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
754
+ const input = {after:cursor, memory:memoryInput()}; if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
295
755
  const result = await post('/snapshot', input);
296
756
  if (!managerMode || (result.projectId === captured.projectId && result.instanceId === captured.instanceId && captured.generation === viewGeneration)) {
297
757
  snapshotValid = true; lastSnapshot = result; render(result); $('connection').textContent = 'Live / updated ' + new Date().toLocaleTimeString();
@@ -300,6 +760,343 @@ async function poll() {
300
760
  catch (error) { if (!stopped) $('connection').textContent = 'Disconnected / retrying: ' + error.message; }
301
761
  if (!stopped) timer = setTimeout(poll, 1000);
302
762
  }
763
+ // office-scene-begin (issue 264): the office, drawn Flat or Isometric from the shared officeModel as inline
764
+ // SVG, re-rendered only when its signature changed; no timer, no frame loop, no request and no action here.
765
+ // Motion is CSS only: transform/opacity transitions for card moves, keyframe fades for arcs, alerts and the
766
+ // busy latch, timed at render from the model's timestamps; expired nodes purge on the next changed render or
767
+ // their own animationend. The projection (phase 4) maps grid coordinates only: x' = (x - y) cos 30, y' =
768
+ // (x + y) sin 30; nodes are laid out upright in local coordinates, so text is never rotated and the DOM
769
+ // order and accessible names do not depend on the projection.
770
+ let officeProjection = 'flat';
771
+ let officeLastProjection = '';
772
+ const officeRound = (v) => Math.round(v * 100) / 100;
773
+ function officeIsoLocal(x, y) { return [(x - y) * 0.8660254, (x + y) * 0.5]; }
774
+ function officeProject(x, y) { return officeProjection === 'iso' ? officeIsoLocal(x, y) : [x, y]; }
775
+ function officePlace(node, x, y) { const p = officeProject(x, y); node.style.transform = 'translate(' + p[0] + 'px,' + p[1] + 'px)'; }
776
+ function officePolygon(points, cls) { return officeNode('polygon', { points: points.map((p) => p.map(officeRound).join(',')).join(' '), class: cls }); }
777
+ function officeTile(w, h, cls) {
778
+ if (officeProjection !== 'iso') return officeNode('rect', { x: 0, y: 0, width: w, height: h, rx: 1, class: cls });
779
+ return officePolygon([[0, 0], officeIsoLocal(w, 0), officeIsoLocal(w, h), officeIsoLocal(0, h)], cls);
780
+ }
781
+ function officeViewBox(w, h) {
782
+ if (officeProjection !== 'iso') return '0 0 ' + w + ' ' + h;
783
+ const corners = [officeProject(0, 0), officeProject(w, 0), officeProject(0, h), officeProject(w, h)];
784
+ const xs = corners.map((p) => p[0]), ys = corners.map((p) => p[1]), m = 3;
785
+ const minX = Math.min(...xs), minY = Math.min(...ys);
786
+ return (minX - m) + ' ' + (minY - m) + ' ' + (Math.max(...xs) - minX + 2 * m) + ' ' + (Math.max(...ys) - minY + 2 * m);
787
+ }
788
+ // A raised hand as outline shapes (palm and fingers), coloured by the desk's condition class.
789
+ function officeHand(x, y) {
790
+ const g = officeNode('g', { class: 'office-handshape' });
791
+ g.append(officeNode('rect', { x: x + 0.05, y: y + 0.8, width: 1.5, height: 1.2, rx: 0.55 }));
792
+ for (let i = 0; i < 3; i++) g.append(officeNode('rect', { x: x + 0.24 + i * 0.42, y, width: 0.28, height: 1.05, rx: 0.14 }));
793
+ return g;
794
+ }
795
+ // The condition glyph as clean outline shapes; the label text next to it carries the state in words.
796
+ function officeGlyphNode(desk, cx, cy) {
797
+ if (desk.condition === 'waiting') return officeHand(cx - 0.8, cy - 1.05);
798
+ const g = officeNode('g', { class: 'office-glyph-shape' });
799
+ if (desk.condition === 'busy' || desk.condition === 'idle') g.append(officeNode('circle', { cx, cy, r: 0.8 }));
800
+ else if (desk.condition === 'paused') g.append(officeNode('rect', { x: cx - 0.65, y: cy - 0.75, width: 0.45, height: 1.5, rx: 0.15 }), officeNode('rect', { x: cx + 0.2, y: cy - 0.75, width: 0.45, height: 1.5, rx: 0.15 }));
801
+ else if (desk.condition === 'offline') g.append(officeNode('line', { x1: cx - 0.55, y1: cy - 0.55, x2: cx + 0.55, y2: cy + 0.55 }), officeNode('line', { x1: cx - 0.55, y1: cy + 0.55, x2: cx + 0.55, y2: cy - 0.55 }));
802
+ else g.append(officeNode('text', { x: cx - 0.4, y: cy + 0.55, class: 'office-glyph' }, desk.glyph));
803
+ return g;
804
+ }
805
+ // A person: head over shoulders, upright in both projections. Isometric stands behind the desk.
806
+ function officeAvatar() {
807
+ if (officeProjection === 'iso') return [
808
+ officeNode('circle', { cx: 1.5, cy: -3.7, r: 1.05, class: 'office-avatar' }),
809
+ officeNode('rect', { x: 0.55, y: -2.85, width: 1.9, height: 3.05, rx: 0.9, class: 'office-avatar' })];
810
+ return [
811
+ officeNode('circle', { cx: 2.2, cy: 3.1, r: 0.78, class: 'office-avatar' }),
812
+ officeNode('path', { d: 'M 1.2 4.6 A 1 1 0 0 1 3.2 4.6 Z', class: 'office-avatar' })];
813
+ }
814
+ // The Done shelf: a small cabinet of stacked card rows (flat) or boxes on a slab (iso); the count stays text.
815
+ function officeShelf(iso) {
816
+ if (!iso) {
817
+ const nodes = [officeNode('rect', { x: 6.5, y: 4.4, width: 9, height: 10.4, rx: 1, class: 'office-shelf' })];
818
+ for (const sy of [7.8, 11.2]) nodes.push(officeNode('line', { x1: 6.5, y1: sy, x2: 15.5, y2: sy, class: 'office-divider' }));
819
+ for (const sy of [5.1, 8.5, 11.9]) for (const sx of [7.2, 11.4]) nodes.push(officeNode('rect', { x: sx, y: sy, width: 3.4, height: 2.1, rx: 0.5, class: 'office-shelf-card' }));
820
+ return nodes;
821
+ }
822
+ const nodes = [officePolygon([officeIsoLocal(6, 3), officeIsoLocal(16, 3), officeIsoLocal(16, 9), officeIsoLocal(6, 9)], 'office-shelf')];
823
+ for (const [bx, by] of [[7, 4], [11.5, 4], [9.2, 6.2]]) {
824
+ const s = 2.4, rise = 2.2;
825
+ const top = [officeIsoLocal(bx, by), officeIsoLocal(bx + s, by), officeIsoLocal(bx + s, by + s), officeIsoLocal(bx, by + s)];
826
+ const up = top.map(([x, y]) => [x, y - rise]);
827
+ nodes.push(officePolygon(up, 'office-box-top'), officePolygon([up[3], up[2], top[2], top[3]], 'office-box-left'), officePolygon([up[2], up[1], top[1], top[2]], 'office-box-right'));
828
+ }
829
+ return nodes;
830
+ }
831
+ function officeClip(text, max) { text = String(text); return text.length > max ? text.slice(0, max - 3) + '...' : text; }
832
+ function officeNode(tag, attrs, text) { const node = document.createElementNS('http://www.w3.org/2000/svg', tag); for (const key in attrs) node.setAttribute(key, String(attrs[key])); if (text !== undefined) node.textContent = text; return node; }
833
+ function officeKeys(event, activate) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); activate(); } }
834
+ let spotlitRow = null;
835
+ function spotlightRow(row) { if (spotlitRow && spotlitRow !== row) spotlitRow.classList.remove('spotlight'); spotlitRow = row; if (row) row.classList.add('spotlight'); }
836
+ function activateOfficePeer(id) { const row = peerRows.get(id); if (!row) return; spotlightRow(row); row.scrollIntoView({ block: 'nearest' }); }
837
+ function activateOfficeTask(id) { if (!taskRows.has(id)) officeFilter(''); const row = taskRows.get(id); if (!row) return; spotlightRow(row); row.scrollIntoView({ block: 'nearest' }); }
838
+ function officeFilter(state) { $('task-filter').value = state; $('task-peer-filter').value = ''; $('task-release-filter').value = ''; if (!stopped && snapshotValid && lastSnapshot) render(lastSnapshot); }
839
+ // Fade timing for a transient mark: it holds, then fades over the final second so it is gone at `until`.
840
+ // Applied once at creation; later renders leave the running animation alone.
841
+ function officeFade(node, until, now) {
842
+ node.dataset.officeUntil = String(until);
843
+ const remaining = Math.max(0, until - now);
844
+ if (remaining <= 0) return false;
845
+ if (officeReducedMotion()) return true;
846
+ const duration = Math.min(1000, remaining);
847
+ node.setAttribute('class', ((node.getAttribute('class') || '') + ' office-fadeout').trim());
848
+ if (duration !== 1000) node.style.animationDuration = duration + 'ms';
849
+ node.style.animationDelay = (remaining - duration) + 'ms';
850
+ return true;
851
+ }
852
+ function officeCardPaint(node, card) {
853
+ node.setAttribute('class', 'office-card-node office-movable' + (card.waiting ? ' office-waiting-card' : '') + (card.rework ? ' office-rework-card' : '') + (card.checking ? ' office-card-checking' : ''));
854
+ node.setAttribute('aria-label', card.name);
855
+ const children = [
856
+ officeNode('rect', { x: 0, y: 0, width: card.w, height: card.h, rx: 0.6, class: 'office-card' }),
857
+ officeNode('text', { x: 0.8, y: card.h - 0.7, class: 'office-card-text' }, officeClip(card.label, card.w > 12 ? 22 : 10))];
858
+ if (card.checking) children.push(officeNode('text', { x: card.w - 0.6, y: card.h - 0.7, 'text-anchor': 'end', class: 'office-checking' }, '⟳'));
859
+ node.replaceChildren(...children);
860
+ }
861
+ function officeDeskPaint(node, desk) {
862
+ node.setAttribute('class', 'office-desk office-' + desk.condition + (desk.dimmed ? ' office-dimmed' : ''));
863
+ node.setAttribute('aria-label', desk.name);
864
+ officePlace(node, desk.x, desk.y);
865
+ const iso = officeProjection === 'iso';
866
+ const children = [];
867
+ if (iso) {
868
+ children.push(...officeAvatar());
869
+ // The desk is a light extrusion: the top tile plus two thin front legs.
870
+ const B = officeIsoLocal(desk.w, 0), C = officeIsoLocal(desk.w, desk.h), D = officeIsoLocal(0, desk.h);
871
+ for (const [from, to] of [[D, C], [B, C]]) {
872
+ const at = (t) => [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t];
873
+ const a = at(0.78), b = at(0.92);
874
+ children.push(officePolygon([a, b, [b[0], b[1] + 2.4], [a[0], a[1] + 2.4]], 'office-desk-leg'));
875
+ }
876
+ }
877
+ children.push(officeTile(desk.w, desk.h, 'office-desk-surface'));
878
+ if (iso) {
879
+ // The name plate is a small parallelogram on the desk's front edge; the name text stays upright.
880
+ const D = officeIsoLocal(0, desk.h), C = officeIsoLocal(desk.w, desk.h), off = officeIsoLocal(0, 1);
881
+ const c0 = [(D[0] + C[0]) / 2 + off[0], (D[1] + C[1]) / 2 + off[1]];
882
+ children.push(officePolygon([[-2.4, -0.75], [2.4, -0.75], [2.4, 0.75], [-2.4, 0.75]].map(([gx, gy]) => { const p = officeIsoLocal(gx, gy); return [c0[0] + p[0], c0[1] + p[1]]; }), 'office-nameplate'));
883
+ children.push(officeNode('text', { x: c0[0], y: c0[1] + 2.5, 'text-anchor': 'middle', class: 'office-name' }, desk.id));
884
+ } else {
885
+ children.push(officeNode('text', { x: 1, y: 2, class: 'office-name' }, desk.id));
886
+ children.push(...officeAvatar());
887
+ }
888
+ if (desk.bubble) {
889
+ const bx = iso ? 3.6 : 0.6; // clear of the standing avatar in iso
890
+ children.push(officeNode('rect', { x: bx, y: -2.3, width: desk.w - 1.2, height: 1.9, rx: 0.9, class: 'office-bubble' }));
891
+ children.push(officeNode('text', { x: bx + (desk.w - 1.2) / 2, y: -0.95, 'text-anchor': 'middle', class: 'office-bubble-text' }, officeClip(desk.bubble, 16)));
892
+ }
893
+ if (desk.mode) children.push(officeNode('text', { x: desk.w - 1, y: 2, 'text-anchor': 'end', class: 'office-mode' }, desk.mode));
894
+ children.push(officeGlyphNode(desk, iso ? 5.2 : 4.3, 3.65));
895
+ children.push(officeNode('text', { x: iso ? 6.3 : 5.3, y: 4, class: 'office-state' }, desk.label));
896
+ if (desk.approvals > 0) { children.push(officeHand(desk.w - 2.7, 2.5)); children.push(officeNode('text', { x: desk.w - 1, y: 4, 'text-anchor': 'end', class: 'office-hand' }, String(desk.approvals))); }
897
+ let line = 5.35;
898
+ if (desk.breakNote) { children.push(officeNode('text', { x: 1, y: line, class: 'office-meta' }, officeClip('Break: ' + desk.breakNote + (desk.breakResetsAt ? ' / resets ' + rel(desk.breakResetsAt) : ''), 38))); line += 1.05; }
899
+ if (desk.finishing) { children.push(officeNode('text', { x: 1, y: line, class: 'office-meta' }, 'finishing a turn')); line += 1.05; }
900
+ if (!desk.attached) { children.push(officeNode('text', { x: 1, y: line, class: 'office-meta' }, 'disconnected; work retained')); line += 1.05; }
901
+ const tray = [];
902
+ if (desk.tray.queued) tray.push(desk.tray.queued + ' queued');
903
+ if (desk.tray.important) tray.push(desk.tray.important + ' important');
904
+ if (desk.tray.needsReview) tray.push(desk.tray.needsReview + ' to review');
905
+ if (desk.tray.heldBy) tray.push('held by ' + desk.tray.heldBy);
906
+ if (tray.length) children.push(officeNode('text', { x: 1, y: line, class: 'office-meta' }, officeClip(tray.join(' / '), 38)));
907
+ if (desk.extraCards > 0) children.push(officeNode('text', { x: 1, y: desk.h - 0.4, class: 'office-meta' }, '+' + desk.extraCards + ' more'));
908
+ node.replaceChildren(...children);
909
+ }
910
+ function officeRoomPaint(node, room) {
911
+ node.setAttribute('class', 'office-roombox office-roombox-' + room.id);
912
+ officePlace(node, room.x, room.y);
913
+ const iso = officeProjection === 'iso';
914
+ const children = [officeTile(room.w, room.h, 'office-room')];
915
+ let moreAt = { x: 1, y: room.h - 1, anchor: 'start' };
916
+ if (iso) {
917
+ // A faint wireframe floor box: the floor tile and the two back vertical edges; the label sits at the base.
918
+ const B = officeIsoLocal(room.w, 0), D = officeIsoLocal(0, room.h), C = officeIsoLocal(room.w, room.h);
919
+ for (const p of [B, D]) children.push(officeNode('line', { x1: p[0], y1: p[1], x2: p[0], y2: p[1] - 6, class: 'office-room-wire' }));
920
+ children.push(officeNode('text', { x: C[0], y: C[1] + 2.4, 'text-anchor': 'middle', class: 'office-room-title' }, room.label));
921
+ children.push(officeNode('text', { x: C[0], y: C[1] + 3.8, 'text-anchor': 'middle', class: 'office-room-count' }, String(room.count)));
922
+ moreAt = { x: D[0], y: D[1] + 2.4, anchor: 'middle' };
923
+ } else {
924
+ if (room.x > 0) children.push(officeNode('line', { x1: -1, y1: 0.6, x2: -1, y2: room.h - 0.6, class: 'office-divider' }));
925
+ children.push(officeNode('text', { x: 1, y: 2.2, class: 'office-room-title' }, room.label));
926
+ children.push(officeNode('text', { x: room.w - 1, y: 2.2, 'text-anchor': 'end', class: 'office-room-count' }, String(room.count)));
927
+ }
928
+ if (room.id === 'done') children.push(...officeShelf(iso));
929
+ node.replaceChildren(...children);
930
+ if (room.overflow > 0 && room.filterState) {
931
+ const more = officeNode('g', { tabindex: '0', role: 'button', 'aria-label': 'Show ' + room.overflow + ' more ' + room.filterState.replaceAll('_', ' ') + ' tasks in the task list' });
932
+ more.append(officeNode('text', { x: moreAt.x, y: moreAt.y, 'text-anchor': moreAt.anchor, class: 'office-more' }, '+' + room.overflow + ' more'));
933
+ const open = () => officeFilter(room.filterState);
934
+ more.addEventListener('click', open);
935
+ more.addEventListener('keydown', (event) => officeKeys(event, open));
936
+ node.append(more);
937
+ }
938
+ }
939
+ let officeScene = null;
940
+ function officeEnsure(target) {
941
+ if (officeScene) return officeScene;
942
+ const svg = officeNode('svg', { class: 'office-scene', role: 'group', 'aria-label': 'Office: desks, rooms and task cards' });
943
+ const layers = {};
944
+ for (const name of ['rooms', 'desks', 'links', 'marks', 'arcs', 'cards']) { const layer = officeNode('g', { class: 'office-layer office-layer-' + name }); svg.append(layer); layers[name] = layer; }
945
+ officeScene = { svg, layers, els: new Map(), leaving: new Map(), settled: true };
946
+ target.replaceChildren(svg);
947
+ return officeScene;
948
+ }
949
+ function officeReducedMotion() { return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true; }
950
+ function officePrune(scene, now) {
951
+ if(!scene)return;
952
+ for(const [node,until] of scene.leaving)if(officeReducedMotion() || until<=now){node.remove();scene.leaving.delete(node)}
953
+ for(const [key,entry] of scene.els){
954
+ if(officeReducedMotion())entry.node.classList.remove('office-appear');
955
+ if(entry.node.dataset.officeUntil && Number(entry.node.dataset.officeUntil)<=now){entry.node.remove();scene.els.delete(key)}
956
+ }
957
+ }
958
+ function officeLeave(scene, key) {
959
+ const entry = scene.els.get(key);
960
+ if (!entry) return;
961
+ scene.els.delete(key);
962
+ if(officeReducedMotion() || scene.settled){entry.node.remove();return;}
963
+ scene.leaving.set(entry.node,Date.now()+400);
964
+ entry.node.classList.add('office-leaving');
965
+ entry.node.addEventListener('animationend', () => {entry.node.remove();scene.leaving.delete(entry.node)});
966
+ }
967
+ function drawOffice(target, model) {
968
+ const scene = officeEnsure(target), els = scene.els, now = Date.now();
969
+ scene.svg.setAttribute('viewBox', officeViewBox(model.width, model.height));
970
+ const switched = officeLastProjection !== officeProjection; // a view switch jumps, never animates
971
+ officeLastProjection = officeProjection;
972
+ scene.settled = model.settle || switched;
973
+ officePrune(scene, now);
974
+ scene.svg.setAttribute('class', 'office-scene' + (model.settle || switched ? ' office-settle' : ''));
975
+ const seen = new Set();
976
+ const keep = (key, sig, layer, paint, appear) => {
977
+ seen.add(key);
978
+ sig = officeProjection + '|' + sig; // a projection switch repaints every node in place
979
+ const entry = els.get(key);
980
+ if (entry && entry.sig === sig) return { node: entry.node, fresh: false };
981
+ const node = entry ? entry.node : officeNode('g', {});
982
+ paint(node);
983
+ if (entry) entry.sig = sig;
984
+ else {
985
+ if (appear !== false && !scene.settled && !officeReducedMotion()) { node.classList.add('office-appear'); node.addEventListener('animationend', () => node.classList.remove('office-appear')); }
986
+ layer.append(node);
987
+ els.set(key, { node, sig });
988
+ }
989
+ return { node, fresh: true };
990
+ };
991
+ for (const room of model.rooms) {
992
+ keep('room:' + room.id, JSON.stringify({ ...room, cards: undefined }), scene.layers.rooms, (node) => officeRoomPaint(node, room), false);
993
+ }
994
+ for (const desk of model.desks) {
995
+ const { node, fresh } = keep('desk:' + desk.id, JSON.stringify({ ...desk, cards: undefined }), scene.layers.desks, (n) => officeDeskPaint(n, desk));
996
+ if (fresh && !node.getAttribute('tabindex')) {
997
+ node.setAttribute('tabindex', '0'); node.setAttribute('role', 'button');
998
+ const open = () => activateOfficePeer(desk.id);
999
+ node.addEventListener('click', open);
1000
+ node.addEventListener('keydown', (event) => officeKeys(event, open));
1001
+ }
1002
+ }
1003
+ const cards = [];
1004
+ for (const desk of model.desks) cards.push(...desk.cards);
1005
+ for (const room of model.rooms) cards.push(...room.cards);
1006
+ for (const card of cards) {
1007
+ const key = 'card:' + card.id;
1008
+ const { node, fresh } = keep(key, JSON.stringify([card.label, card.name, card.waiting, card.rework, card.checking, card.w, card.h]), scene.layers.cards, (n) => officeCardPaint(n, card));
1009
+ if (fresh && !node.getAttribute('tabindex')) {
1010
+ node.setAttribute('tabindex', '0'); node.setAttribute('role', 'button');
1011
+ const open = () => activateOfficeTask(card.id);
1012
+ node.addEventListener('click', open);
1013
+ node.addEventListener('keydown', (event) => officeKeys(event, open));
1014
+ }
1015
+ const p = officeProject(card.x, card.y), transform = 'translate(' + p[0] + 'px,' + p[1] + 'px)';
1016
+ if (node.style.transform !== transform) node.style.transform = transform; // the only animated property
1017
+ }
1018
+ if (model.extraPeers > 0) {
1019
+ keep('extra:peers', String(model.extraPeers) + ':' + model.width, scene.layers.marks,
1020
+ (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);
1021
+ }
1022
+ const deskById = new Map(model.desks.map((desk) => [desk.id, desk]));
1023
+ // Warning lines between desks (phase 3): overlap, conflict and cohort links, ids only, state-driven (no fade).
1024
+ for (const link of model.links) {
1025
+ const offset = link.kind === 'overlap' ? -1.5 : link.kind === 'cohort' ? 1.5 : 0;
1026
+ const points = [...new Set(link.peers)].map((id) => deskById.get(id)).filter(Boolean).map((d) => {const p=officeProject(d.x + d.w / 2,d.y + d.h / 2);return [p[0],p[1]+offset]});
1027
+ if (points.length < 2) continue;
1028
+ const segments = [];
1029
+ for (let i = 0; i + 1 < points.length; i++) segments.push([points[i], points[i + 1]]);
1030
+ const label = link.kind + ' ' + link.tasks.map((id) => '#' + id).join(' ');
1031
+ keep('link:' + link.kind + ':' + link.tasks.join(','), JSON.stringify([link.kind, link.tasks, link.peers, points]), scene.layers.links,
1032
+ (n) => {
1033
+ n.setAttribute('class', 'office-link office-link-' + link.kind);
1034
+ n.setAttribute('aria-hidden', 'true');
1035
+ n.replaceChildren(...segments.map(([a, b]) => officeNode('line', { x1: a[0], y1: a[1], x2: b[0], y2: b[1], class: 'office-link-line' })),
1036
+ officeNode('text', { x: (segments[0][0][0] + segments[0][1][0]) / 2 + 1, y: (segments[0][0][1] + segments[0][1][1]) / 2 - 1, class: 'office-link-label' }, officeClip(label, 28)));
1037
+ }, false);
1038
+ }
1039
+ for (const arc of model.arcs) {
1040
+ if(arc.until<=now)continue;
1041
+ const from = deskById.get(arc.from);
1042
+ const label = arc.kind + (arc.task ? ' #' + arc.task : '');
1043
+ for (const target of arc.to.length ? arc.to : model.desks.map((desk) => desk.id)) {
1044
+ const to = deskById.get(target);
1045
+ if (!to) continue;
1046
+ const p1 = officeProject(from ? from.x + from.w / 2 : 1, from ? from.y : 1), p2 = officeProject(to.x + to.w / 2, to.y);
1047
+ const mx = (p1[0] + p2[0]) / 2, my = Math.min(p1[1], p2[1]) - 4;
1048
+ const angle = Math.atan2(p2[1] - my, p2[0] - mx);
1049
+ const tip = (a) => [officeRound(p2[0] - 1.5 * Math.cos(a)), officeRound(p2[1] - 1.5 * Math.sin(a))];
1050
+ const cls = 'office-arc' + (arc.priority === 'important' ? ' office-arc-important' : '') + (arc.dropped ? ' office-arc-dropped' : '');
1051
+ const { node, fresh } = keep('arc:' + arc.seq + ':' + target, JSON.stringify([arc.seq, target, arc.kind, arc.task, arc.priority, arc.dropped, p1, p2]), scene.layers.arcs,
1052
+ (n) => { n.setAttribute('class', cls); n.setAttribute('aria-hidden', 'true'); n.replaceChildren(
1053
+ officeNode('path', { d: 'M ' + officeRound(p1[0]) + ' ' + officeRound(p1[1]) + ' Q ' + officeRound(mx) + ' ' + officeRound(my) + ' ' + officeRound(p2[0]) + ' ' + officeRound(p2[1]), class: 'office-arc-path' }),
1054
+ officePolygon([[officeRound(p2[0]), officeRound(p2[1])], tip(angle - 0.45), tip(angle + 0.45)], 'office-arc-head'),
1055
+ officeNode('text', { x: mx, y: my - 0.8, 'text-anchor': 'middle', class: 'office-arc-label' }, label)); }, false);
1056
+ if (fresh) officeFade(node, arc.until, now);
1057
+ }
1058
+ }
1059
+ const latestAlert = new Map();
1060
+ for (const alert of model.alerts) if (alert.until > now) latestAlert.set(alert.peer, alert);
1061
+ for (const [peer, alert] of latestAlert) {
1062
+ const desk = deskById.get(peer);
1063
+ if (!desk) continue;
1064
+ const { node, fresh } = keep('alert:' + peer, String(alert.seq), scene.layers.marks,
1065
+ (n) => { officePlace(n, desk.x, desk.y); n.setAttribute('class', 'office-alertmark'); n.setAttribute('aria-hidden', 'true'); n.replaceChildren(officeNode('text', { x: desk.w - 1, y: 5.1, 'text-anchor': 'end', class: 'office-alert' }, '! ' + alert.kind)); }, false);
1066
+ if (fresh) officeFade(node, alert.until, now);
1067
+ }
1068
+ for (const desk of model.desks) {
1069
+ const latch = model.latched[desk.id];
1070
+ if (desk.condition !== 'idle' || !latch || latch.until <= now) continue;
1071
+ const { node, fresh } = keep('latch:' + desk.id, String(latch.seq), scene.layers.marks,
1072
+ (n) => { officePlace(n, desk.x, desk.y); n.setAttribute('class', 'office-latchmark'); n.setAttribute('aria-hidden', 'true'); n.replaceChildren(officeNode('text', { x: 5.2, y: 3.9, class: 'office-latch' }, '● busy')); }, false);
1073
+ if (fresh) officeFade(node, latch.until, now);
1074
+ }
1075
+ for (const key of [...els.keys()]) if (!seen.has(key)) officeLeave(scene, key);
1076
+ officeDesks.clear(); officeCards.clear();
1077
+ for (const [key, entry] of els) {
1078
+ if (key.startsWith('desk:')) officeDesks.set(key.slice(5), entry.node);
1079
+ else if (key.startsWith('card:')) officeCards.set(Number(key.slice(5)), entry.node);
1080
+ }
1081
+ }
1082
+ function renderOffice(snapshot) {
1083
+ const view = officeView.get(), section = $('office-section');
1084
+ officeProjection = view === 'isometric' ? 'iso' : 'flat';
1085
+ section.hidden = view === 'off';
1086
+ if (view === 'off') {
1087
+ if (signatures.has('office')) { signatures.delete('office'); $('office').replaceChildren(); officeScene = null; }
1088
+ officePrev = null; officeDesks.clear(); officeCards.clear(); return;
1089
+ }
1090
+ const cols = (section.clientWidth || 0) >= 1000 ? 4 : (section.clientWidth || 0) >= 700 ? 2 : 1;
1091
+ const model = officeModel(snapshot, officePrev, { cols, now: Date.now() });
1092
+ officePrev = model;
1093
+ const signature = JSON.stringify([view, cols, model]);
1094
+ officePrune(officeScene,Date.now());
1095
+ if (signatures.get('office') === signature) return;
1096
+ signatures.set('office', signature);
1097
+ drawOffice($('office'), model);
1098
+ }
1099
+ // office-scene-end
303
1100
  const STREAM_KINDS = ['chat', 'task', 'review', 'status', 'budget', 'presence', 'state'];
304
1101
  let streamFilter = '';
305
1102
  function applyStreamFilter(node) { node.hidden = streamFilter !== '' && node.dataset.kind !== streamFilter; }
@@ -344,6 +1141,132 @@ function renderBench(bench) {
344
1141
  }
345
1142
  });
346
1143
  }
1144
+ // #258: the hub's memory, read-only. A tier at a time, filtered; selecting an entry or a node shows its neighbourhood
1145
+ // (inline SVG, a small force layout computed here) with the same edges as a list, and its detail.
1146
+ const memoryQuery = {tier:'long', q:'', kind:'', peer:'', machine:'', since:'', node:'', hops:1};
1147
+ let memoryGeneration = -1;
1148
+ function memoryInput() {
1149
+ if (memoryGeneration !== viewGeneration) { // another project: its own nodes, peers and machines
1150
+ memoryGeneration = viewGeneration; memoryQuery.node = ''; memoryQuery.peer = ''; memoryQuery.machine = '';
1151
+ for (const id of ['memory-peer', 'memory-machine']) $(id).value = '';
1152
+ }
1153
+ const out = {tier:memoryQuery.tier, hops:memoryQuery.hops};
1154
+ for (const key of ['q', 'kind', 'peer', 'machine', 'node']) if (memoryQuery[key]) out[key] = memoryQuery[key];
1155
+ // A whole timestamp, floored to the hour so the query stays the same between polls.
1156
+ if (memoryQuery.since) out.since = new Date(Math.floor((Date.now() - Number(memoryQuery.since) * 86400000) / 3600000) * 3600000).toISOString();
1157
+ return out;
1158
+ }
1159
+ // The next poll (at most a second away) carries the changed query; polling is never started twice.
1160
+ function rerenderMemory() { for (const chip of $('memory-tiers').children) chip.setAttribute('aria-pressed', String(chip.dataset.tier === memoryQuery.tier)); }
1161
+ function memoryOptions(id, values, all, key) {
1162
+ const select = $(id), keep = select.value;
1163
+ const want = [''].concat(values);
1164
+ if ([...select.options].map((o) => o.value).join('\0') === want.join('\0')) return;
1165
+ select.replaceChildren(...want.map((v) => { const o = el('option', v || all); o.value = v; return o; }));
1166
+ select.value = want.includes(keep) ? keep : '';
1167
+ memoryQuery[key] = select.value; // a filter whose value is gone reads as none, on the page and in the next query
1168
+ }
1169
+ function layout(graph) {
1170
+ // Deterministic: start on a circle in node order, then a fixed number of spring steps; no animation loop.
1171
+ const W = 640, H = 320, n = graph.nodes.length, pos = new Map();
1172
+ graph.nodes.forEach((node, i) => pos.set(node.id, {x: W / 2 + Math.cos(2 * Math.PI * i / Math.max(n, 1)) * W * 0.35, y: H / 2 + Math.sin(2 * Math.PI * i / Math.max(n, 1)) * H * 0.35}));
1173
+ for (let step = 0; step < 120; step++) {
1174
+ const force = new Map(graph.nodes.map((node) => [node.id, {x: 0, y: 0}]));
1175
+ for (const a of graph.nodes) for (const b of graph.nodes) {
1176
+ if (a.id >= b.id) continue;
1177
+ const p = pos.get(a.id), q = pos.get(b.id), dx = p.x - q.x || 0.01, dy = p.y - q.y || 0.01, d2 = Math.max(dx * dx + dy * dy, 25), f = 4000 / d2;
1178
+ force.get(a.id).x += f * dx / Math.sqrt(d2); force.get(a.id).y += f * dy / Math.sqrt(d2); force.get(b.id).x -= f * dx / Math.sqrt(d2); force.get(b.id).y -= f * dy / Math.sqrt(d2);
1179
+ }
1180
+ for (const e of graph.edges) {
1181
+ const p = pos.get(e.from), q = pos.get(e.to); if (!p || !q) continue;
1182
+ const dx = q.x - p.x, dy = q.y - p.y, d = Math.sqrt(dx * dx + dy * dy) || 1, f = (d - 90) * 0.05;
1183
+ force.get(e.from).x += f * dx / d; force.get(e.from).y += f * dy / d; force.get(e.to).x -= f * dx / d; force.get(e.to).y -= f * dy / d;
1184
+ }
1185
+ for (const node of graph.nodes) { const p = pos.get(node.id), f = force.get(node.id); p.x = Math.min(W - 20, Math.max(20, p.x + Math.max(-10, Math.min(10, f.x)))); p.y = Math.min(H - 20, Math.max(20, p.y + Math.max(-10, Math.min(10, f.y)))); }
1186
+ }
1187
+ return pos;
1188
+ }
1189
+ function selectMemoryNode(node) { memoryQuery.node = memoryQuery.node === node ? '' : node; rerenderMemory(); }
1190
+ function renderMemory(memory) {
1191
+ const tiers = $('memory-tiers');
1192
+ if (!tiers.children.length) for (const [tier, label] of [['short', 'Short-term'], ['long', 'Long-term'], ['goals', 'Long-term tasks']]) {
1193
+ const chip = el('button', label, 'chip'); chip.type = 'button'; chip.dataset.tier = tier;
1194
+ chip.addEventListener('click', () => { memoryQuery.tier = tier; memoryQuery.node = ''; rerenderMemory(); });
1195
+ tiers.append(chip);
1196
+ }
1197
+ for (const chip of tiers.children) chip.setAttribute('aria-pressed', String(chip.dataset.tier === memoryQuery.tier));
1198
+ if (!memory) { $('memory-count').textContent = ''; update('memory-list', null, (target) => empty(target, 'Hub memory is off for this project, or nothing is stored yet.')); update('memory-graph', null, () => {}); update('memory-detail', null, () => {}); return; }
1199
+ $('memory-count').textContent = memory.counts.long + ' long-term / ' + memory.counts.short + ' short-term / ' + (memory.counts.goals || 0) + ' open tasks';
1200
+ if (memory.goals) {
1201
+ // Long-term tasks: state, who holds the lease and until when (a conflict when two machines claimed it), linked board tasks.
1202
+ const who = (m) => m === memory.machine ? 'this machine' : 'machine ' + m.slice(0, 8);
1203
+ update('memory-list', {goals: memory.goals, node: memoryQuery.node}, (target) => {
1204
+ if (!memory.goals.length) return empty(target, 'No long-term tasks. A person creates one with ahub goal new <title>.');
1205
+ for (const g of memory.goals) {
1206
+ const row = el('button', undefined, 'memory-row'); row.type = 'button'; row.setAttribute('aria-pressed', String(memoryQuery.node === 'goal:' + g.id));
1207
+ const head = el('div', undefined, 'row-head');
1208
+ head.append(el('span', g.title, 'label'), badge(g.state, g.state === 'done' ? 'ok' : g.state === 'blocked' ? 'err' : g.state === 'active' ? 'info' : ''));
1209
+ const lease = g.lease.holder ? 'held by ' + who(g.lease.holder) + ' until ' + time(g.lease.until) + (g.lease.conflict.length ? ' / conflict: ' + g.lease.conflict.map(who).join(', ') : '') : g.lease.expired ? 'free (lease of ' + who(g.lease.lastHolder) + ' ended ' + time(g.lease.until) + ')' : 'free';
1210
+ row.append(head, el('div', g.id.slice(0, 8) + ' / ' + lease + ' / ' + g.links.length + ' board task' + (g.links.length === 1 ? '' : 's') + (g.milestones.length ? ' / milestones: ' + g.milestones.join('; ') : ''), 'meta'));
1211
+ if (g.lease.conflict.length) row.append(badge('lease conflict', 'warn'));
1212
+ row.addEventListener('click', () => selectMemoryNode('goal:' + g.id));
1213
+ target.append(row);
1214
+ }
1215
+ });
1216
+ }
1217
+ memoryOptions('memory-peer', memory.peers, 'All peers', 'peer');
1218
+ memoryOptions('memory-machine', memory.machines, 'All machines', 'machine');
1219
+ if (!memory.goals) update('memory-list', {entries: memory.entries, node: memoryQuery.node}, (target) => {
1220
+ if (!memory.entries.length) return empty(target, 'No ' + memory.tier + '-term entries match.');
1221
+ for (const entry of memory.entries) {
1222
+ const row = el('button', undefined, 'memory-row'); row.type = 'button'; row.setAttribute('aria-pressed', String(memoryQuery.node === 'mem:' + entry.id));
1223
+ const head = el('div', undefined, 'row-head');
1224
+ 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' : ''));
1225
+ 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'));
1226
+ row.addEventListener('click', () => selectMemoryNode('mem:' + entry.id));
1227
+ target.append(row);
1228
+ }
1229
+ });
1230
+ update('memory-graph', memory.graph || null, (target) => {
1231
+ const graph = memory.graph;
1232
+ if (!graph || !graph.nodes.length) return;
1233
+ const NS = 'http://www.w3.org/2000/svg', svg = document.createElementNS(NS, 'svg'), pos = layout(graph);
1234
+ 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');
1235
+ for (const e of graph.edges) {
1236
+ const p = pos.get(e.from), q = pos.get(e.to); if (!p || !q) continue;
1237
+ 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);
1238
+ svg.append(line);
1239
+ }
1240
+ for (const node of graph.nodes) {
1241
+ const p = pos.get(node.id), g = document.createElementNS(NS, 'g'), c = document.createElementNS(NS, 'circle'), t = document.createElementNS(NS, 'text');
1242
+ 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);
1243
+ c.setAttribute('cx', p.x); c.setAttribute('cy', p.y); c.setAttribute('r', node.type === 'memory' ? 9 : 6);
1244
+ t.setAttribute('x', p.x + 11); t.setAttribute('y', p.y + 4); t.textContent = node.label.slice(0, 28);
1245
+ g.append(c, t);
1246
+ g.addEventListener('click', () => selectMemoryNode(node.id));
1247
+ g.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); selectMemoryNode(node.id); } });
1248
+ svg.append(g);
1249
+ }
1250
+ const list = el('ul', undefined, 'memory-edges');
1251
+ for (const e of graph.edges) list.append(el('li', e.from + ' ' + e.rel + ' ' + e.to));
1252
+ target.append(svg, list);
1253
+ });
1254
+ update('memory-detail', memory.detail || null, (target) => {
1255
+ const d = memory.detail; if (!d) return;
1256
+ const box = el('div', undefined, 'row');
1257
+ box.append(el('div', d.kind + ', ' + d.tier + '-term' + (d.expiresAt ? ' until ' + time(d.expiresAt) : '') + ', ' + d.scope, 'label'));
1258
+ box.append(el('div', 'by ' + d.peer + ' on machine ' + d.machine + ', ' + time(d.at) + ', project ' + d.project + (d.task ? ', task ' + d.task : '') + (d.machine === memory.machine ? ' (this machine)' : ' (synced)'), 'meta'));
1259
+ if (d.supersededBy) box.append(el('div', 'superseded by ' + d.supersededBy.slice(0, 8), 'meta'));
1260
+ if (d.promotedFrom) box.append(el('div', 'promoted from ' + d.promotedFrom.slice(0, 8), 'meta'));
1261
+ if (d.about.length) box.append(el('div', 'about: ' + d.about.join(', '), 'meta'));
1262
+ box.append(el('div', (d.title ? d.title + '\n' : '') + d.text, 'body'));
1263
+ target.append(box);
1264
+ });
1265
+ }
1266
+ 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(); });
1267
+ $('memory-hops').addEventListener('change', () => { memoryQuery.hops = Number($('memory-hops').value); rerenderMemory(); });
1268
+ let memorySearch;
1269
+ $('memory-q').addEventListener('input', () => { clearTimeout(memorySearch); memorySearch = setTimeout(() => { memoryQuery.q = $('memory-q').value.trim(); rerenderMemory(); }, 300); });
347
1270
  function renderStreamChips() {
348
1271
  const group = $('stream-filters');
349
1272
  if (group.children.length) return;
@@ -359,9 +1282,29 @@ function renderStreamChips() {
359
1282
  group.append(chip);
360
1283
  }
361
1284
  }
1285
+ $('planning-task').addEventListener('change',loadPlanning);$('planning-reload').addEventListener('click',loadPlanning);$('release-edit').addEventListener('change',loadReleasePlanning);$('release-reload').addEventListener('click',loadReleasePlanning);
1286
+ $('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);
1287
+ 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};
1288
+ await action({action:'planning',id:planningEdit.id,revision:planningEdit.revision,fields,reason:$('planning-reason').value},event.submitter);
1289
+ });
1290
+ $('split-backlog').addEventListener('submit',async event=>{event.preventDefault();if(!planningEdit || !sameBoardContext(planningEdit.context) || boardViewState?.snapshot.planningCapabilities?.split!==true)return notice('Select a current backlog task to split.',true);const titles=$('split-titles').value.split('\n').map(title=>title.trim()).filter(Boolean),parts=titles.map((title,index)=>({title,...($('split-sequential').checked && index>0 ? {afterParts:[index-1]} : {})}));await action({action:'backlog_split',id:planningEdit.id,revision:planningEdit.revision,parts,reason:$('split-reason').value},event.submitter)});
1291
+ $('planning-undo').addEventListener('click',async()=>{if(planningEdit && sameBoardContext(planningEdit.context))await action({action:'planning_undo',id:planningEdit.id,revision:planningEdit.revision},$('planning-undo'))});
1292
+ $('release-new').addEventListener('submit',async event=>{event.preventDefault();if(!sameBoardContext(boardContext()) || boardViewState?.snapshot.planningCapabilities?.enabled!==true)return notice('Release planning is unavailable.',true);await action({action:'release_new',name:$('release-name').value,goal:$('release-goal').value,order:Number($('release-order').value)},event.submitter)});
1293
+ $('release-plan').addEventListener('submit',async event=>{event.preventDefault();if(!releaseEdit || !sameBoardContext(releaseEdit.context))return notice('Select a current release.',true);await action({action:'release_change',id:releaseEdit.id,revision:releaseEdit.revision,fields:{plannedStart:plannedDateInput('release-start'),cut:plannedDateInput('release-cut'),ship:plannedDateInput('release-ship')}},event.submitter)});
1294
+ $('task-release-filter').addEventListener('change',()=>{if(boardViewState)renderBoard(boardViewState.snapshot,boardViewState.peers,boardViewState.ids)});
1295
+ $('task-peer-filter').addEventListener('change',()=>{if(boardViewState)renderBoard(boardViewState.snapshot,boardViewState.peers,boardViewState.ids)});
1296
+ $('task-lanes').addEventListener('change',()=>{if(boardViewState)renderBoard(boardViewState.snapshot,boardViewState.peers,boardViewState.ids)});
1297
+ for (const [id, operation] of [['delivery-queue','sdlc_queue'],['delivery-release','sdlc_release']]) $(id).addEventListener('click', async () => {
1298
+ const repo = $('delivery-repo').value.trim();
1299
+ const liveIssues = $('delivery-live').value.trim() ? $('delivery-live').value.split(/[, ]+/).filter(Boolean).map(Number) : [];
1300
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || liveIssues.length > 32 || liveIssues.some(id => !Number.isSafeInteger(id) || id <= 0)) { notice('Enter owner/repository and valid issue numbers.', true); return; }
1301
+ const report = await action({action:operation,repo,liveIssues}, $(id));
1302
+ if (report) $('delivery-report').textContent = JSON.stringify(report, null, 2);
1303
+ });
362
1304
  $('task-filter').addEventListener('change', () => { if (!stopped && snapshotValid && lastSnapshot) render(lastSnapshot); });
1305
+ window.addEventListener('resize', () => { if (!stopped && officeView.get() !== 'off' && snapshotValid && lastSnapshot) renderOffice(lastSnapshot); });
363
1306
  $('send').addEventListener('submit', async (event) => { event.preventDefault(); const generation = viewGeneration, body = $('message').value.trim(); if (!body) return; const to = $('recipient').value, payload = {action:'send', body}; if (to) payload.to = [to]; if (await action(payload, event.submitter) && generation === viewGeneration) $('message').value = ''; });
364
- $('propose').addEventListener('submit', async (event) => { event.preventDefault(); const generation = viewGeneration, title = $('title').value.trim(); if (!title) return; const payload = {action:'propose', title, class:$('class').value}; if ($('detail').value.trim()) payload.detail = $('detail').value.trim(); if ($('owner').value) payload.owner = $('owner').value; if (await action(payload, event.submitter) && generation === viewGeneration) { $('title').value = ''; $('detail').value = ''; } });
1307
+ $('propose').addEventListener('submit', async (event) => { event.preventDefault(); const generation = viewGeneration, title = $('title').value.trim(); if (!title) return; const payload = {action:'propose', title, class:$('class').value}; if ($('detail').value.trim()) payload.detail = $('detail').value.trim(); if ($('owner').value) payload.owner = $('owner').value; if ($('backlog-proposal').checked) { if(boardViewState?.snapshot.boardCapabilities?.backlog!==true || !sameBoardContext(boardContext())) return notice('Backlog is unavailable for this project snapshot.',true);payload.backlog=true; } if (await action(payload, event.submitter) && generation === viewGeneration) { $('title').value = ''; $('detail').value = ''; } });
365
1308
  (async () => { try { if (ticket) await post('/session', {ticket}); ticket = ''; renderStreamChips(); await refreshProjects(); await poll(); } catch (error) { ticket = ''; if (!stopped) { stopped = true; notice('Could not start session: ' + error.message + ' Run ahub ui to reopen.', true); $('connection').textContent = 'Session unavailable'; } } })();
366
1309
  </script>
367
1310
  </body>