@staix/agent-hub 0.12.23 → 0.12.25

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 (161) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/README.md +8 -3
  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 +10 -1
  11. package/docs/agent-notes/bus.md +3 -1
  12. package/docs/agent-notes/daemon.md +7 -1
  13. package/docs/agent-notes/local-worker.md +4 -2
  14. package/docs/agent-notes/tasks.md +8 -0
  15. package/docs/agent-notes/tests.md +5 -1
  16. package/docs/bench.md +28 -3
  17. package/docs/cooperbench.md +4 -0
  18. package/docs/data-policy.md +36 -0
  19. package/docs/events.md +3 -0
  20. package/docs/gsd-collaboration-analysis.md +48 -0
  21. package/docs/memory.md +231 -0
  22. package/docs/operations.md +666 -19
  23. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  24. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  25. package/docs/profiles.md +57 -0
  26. package/docs/quickstart.md +2 -2
  27. package/docs/research.md +31 -1
  28. package/docs/roles.md +85 -0
  29. package/docs/sdlc.md +170 -0
  30. package/docs/security.md +111 -4
  31. package/docs/smoke.md +288 -0
  32. package/docs/specs/2026-09-19-agent-hub-design.md +633 -7
  33. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  34. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  35. package/docs/text-projection.md +19 -0
  36. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  37. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  38. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  39. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  40. package/docs/verification/2026-10-13-board-phone.md +31 -0
  41. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  42. package/docs/verification/2026-10-14-0.12.24-audit.md +123 -0
  43. package/docs/verification/2026-10-14-0.12.25.md +27 -0
  44. package/docs/verified.json +22 -13
  45. package/package.json +2 -1
  46. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  47. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  48. package/plugins/agent-hub/server.js +1175 -339
  49. package/scripts/check-docs.mjs +129 -0
  50. package/src/adapters/acp.ts +112 -13
  51. package/src/adapters/claude-channel.ts +48 -11
  52. package/src/adapters/codex-appserver.ts +11 -0
  53. package/src/adapters/jcode.ts +110 -0
  54. package/src/adapters/local-worker.ts +10 -7
  55. package/src/adapters/pi.ts +171 -23
  56. package/src/cli/attention.ts +60 -0
  57. package/src/cli/bench.ts +24 -5
  58. package/src/cli/board-read.ts +12 -0
  59. package/src/cli/console-state.ts +103 -17
  60. package/src/cli/console.ts +54 -8
  61. package/src/cli/facts-hook.ts +12 -4
  62. package/src/cli/help.ts +35 -9
  63. package/src/cli/identity.ts +18 -7
  64. package/src/cli/init.ts +48 -10
  65. package/src/cli/jcode-mcp.ts +58 -0
  66. package/src/cli/kimi-mcp.ts +53 -0
  67. package/src/cli/launch.ts +16 -1
  68. package/src/cli/main.ts +392 -57
  69. package/src/cli/memory.ts +193 -0
  70. package/src/cli/output.ts +45 -4
  71. package/src/cli/pet-config.ts +1 -0
  72. package/src/cli/pet.ts +83 -0
  73. package/src/cli/preview.ts +38 -5
  74. package/src/cli/status-lines.ts +1 -1
  75. package/src/cli/tail-render.ts +1 -0
  76. package/src/cli/upgrade-runtime.ts +32 -3
  77. package/src/cli/watch.ts +31 -0
  78. package/src/hub/activity.ts +53 -0
  79. package/src/hub/ask.ts +10 -5
  80. package/src/hub/bench.ts +10 -3
  81. package/src/hub/board.ts +222 -16
  82. package/src/hub/bus.ts +48 -9
  83. package/src/hub/citations.ts +7 -0
  84. package/src/hub/conductor.ts +12 -7
  85. package/src/hub/config-trust.ts +4 -2
  86. package/src/hub/crash.ts +9 -0
  87. package/src/hub/daemon.ts +914 -120
  88. package/src/hub/delivery-journal.ts +5 -0
  89. package/src/hub/envelope.ts +1 -1
  90. package/src/hub/events.ts +12 -2
  91. package/src/hub/facts.ts +26 -7
  92. package/src/hub/git.ts +8 -2
  93. package/src/hub/github-import.ts +28 -0
  94. package/src/hub/hub-tools.ts +49 -10
  95. package/src/hub/inference.ts +1 -1
  96. package/src/hub/lifecycle-schema.ts +17 -0
  97. package/src/hub/listen.ts +15 -0
  98. package/src/hub/manager.ts +12 -5
  99. package/src/hub/path-policy.ts +115 -0
  100. package/src/hub/peer-catalog.ts +28 -0
  101. package/src/hub/permission-mode.ts +19 -5
  102. package/src/hub/planning.ts +70 -0
  103. package/src/hub/profiles.ts +74 -0
  104. package/src/hub/projected-diff.ts +73 -0
  105. package/src/hub/projection-exec.ts +24 -0
  106. package/src/hub/research.ts +144 -21
  107. package/src/hub/roles.ts +123 -0
  108. package/src/hub/roster.ts +23 -0
  109. package/src/hub/routing.ts +18 -5
  110. package/src/hub/sdlc-queue.ts +112 -0
  111. package/src/hub/sdlc-release.ts +125 -0
  112. package/src/hub/sdlc.ts +250 -0
  113. package/src/hub/settings.ts +34 -15
  114. package/src/hub/shipped-roles.ts +25 -0
  115. package/src/hub/snapshots.ts +89 -15
  116. package/src/hub/start-mode.ts +7 -5
  117. package/src/hub/supervision.ts +1 -0
  118. package/src/hub/task-sweep.ts +1 -1
  119. package/src/hub/tasks.ts +905 -89
  120. package/src/hub/text-projection.ts +151 -0
  121. package/src/hub/tooling.ts +170 -0
  122. package/src/hub/ui.ts +16 -2
  123. package/src/hub/workflow.ts +71 -0
  124. package/src/local/sandbox.ts +6 -4
  125. package/src/local/tools.ts +11 -6
  126. package/src/memory/brief.ts +28 -14
  127. package/src/memory/import.ts +55 -0
  128. package/src/memory/recall.ts +84 -0
  129. package/src/memory/store.ts +757 -0
  130. package/src/memory/sync.ts +145 -0
  131. package/src/pet/NOTICE.md +10 -0
  132. package/src/pet/characters.ts +1438 -0
  133. package/src/pet/config.ts +31 -0
  134. package/src/pet/frame.ts +17 -0
  135. package/src/pet/pixels.ts +28 -0
  136. package/src/pi/extension.ts +40 -5
  137. package/src/role-text.d.ts +1 -0
  138. package/src/ui/index.html +843 -46
  139. package/src/ui/needs.ts +58 -0
  140. package/src/ui/office-model.ts +440 -0
  141. package/src/ui/peer-palette.ts +16 -0
  142. package/src/ui/task-board.ts +49 -0
  143. package/src/ui/task-progress.ts +5 -1
  144. package/templates/AGENTS.block.md +3 -2
  145. package/templates/config.json +5 -1
  146. package/templates/profiles/dev.json +22 -0
  147. package/templates/profiles/general.json +26 -0
  148. package/templates/profiles/research.json +57 -0
  149. package/templates/profiles/writing.json +28 -0
  150. package/templates/roles/NOTICE.md +26 -0
  151. package/templates/roles/dev/backend-engineer.md +26 -0
  152. package/templates/roles/dev/code-reviewer.md +26 -0
  153. package/templates/roles/dev/frontend-engineer.md +26 -0
  154. package/templates/roles/dev/security-reviewer.md +26 -0
  155. package/templates/roles/dev/software-architect.md +26 -0
  156. package/templates/roles/dev/test-engineer.md +26 -0
  157. package/templates/roles/research/analyst.md +26 -0
  158. package/templates/roles/research/fact-checker.md +26 -0
  159. package/templates/roles/research/research-lead.md +26 -0
  160. package/templates/roles/research/researcher.md +26 -0
  161. package/templates/roles/research/statistician.md +26 -0
package/src/ui/index.html CHANGED
@@ -15,16 +15,30 @@ 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}.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}
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>
29
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>
30
44
  </div>
@@ -41,7 +55,16 @@ const dashboardTheme = (() => {
41
55
  'use strict';
42
56
  /* TASK_PROGRESS_MODEL */
43
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
+ })();
44
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'; });
45
68
  let ticket = location.hash.slice(1);
46
69
  history.replaceState(null, '', location.pathname);
47
70
  const $ = (id) => document.getElementById(id);
@@ -49,6 +72,8 @@ const el = (tag, text, cls) => { const node = document.createElement(tag); if (t
49
72
  let stopped = false, cursor = 0, timer, projectsTimer, eventCount = 0;
50
73
  let managerMode = false, projectList = [], selectedProjectId = '', selectedInstanceId = '', snapshotValid = false, viewGeneration = 0, projectRequest = 0;
51
74
  let lastSnapshot = null;
75
+ let officePrev = null;
76
+ const peerRows = new Map(), taskRows = new Map(), officeDesks = new Map(), officeCards = new Map();
52
77
  const drafts = new Map();
53
78
  const pendingButtons = new WeakSet();
54
79
  const pendingStarts = new Map();
@@ -110,7 +135,8 @@ async function action(payload, button, managerAction = false) {
110
135
  }
111
136
  if (key && pendingStarts.get(key) === pending) clearPendingStart(key);
112
137
  if (!managerMode || captured.generation === viewGeneration) notice(result.text || 'Action accepted.');
113
- return true;
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;
114
140
  }
115
141
  catch (error) { if (key && pendingStarts.get(key) === pending) clearPendingStart(key); if (!stopped && (!managerMode || captured.generation === viewGeneration)) notice(error.message, true); return false; }
116
142
  finally { if (button) { pendingButtons.delete(button); if (!stopped) button.disabled = Boolean(key && pendingStarts.has(key)) || (!managerAction && managerMode && !snapshotValid); } if (key) redrawStartControls(); }
@@ -124,10 +150,11 @@ function saveDraft() {
124
150
  if (!managerMode || !selectedProjectId) return;
125
151
  const previous = drafts.get(selectedProjectId) || {};
126
152
  drafts.set(selectedProjectId, {message:$('message').value, recipient:draftChoicesPending ? previous.recipient || '' : $('recipient').value,
127
- 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});
128
154
  }
129
155
  function restoreDraft(choices = false) {
130
156
  const draft = drafts.get(selectedProjectId) || {};
157
+ const staged=$('backlog-proposal'); if(staged)staged.checked=draft.backlog===true;
131
158
  if (choices) {
132
159
  for (const id of ['recipient','owner']) $(id).value = Array.from($(id).options).some((o) => o.value === draft[id]) ? draft[id] : '';
133
160
  } else { $('message').value = draft.message || ''; $('title').value = draft.title || ''; $('detail').value = draft.detail || ''; $('class').value = draft.class || 'implement'; }
@@ -136,10 +163,21 @@ function syncMutationControls() { document.querySelectorAll('button[data-mutatio
136
163
  function resetView(id, instance) {
137
164
  saveDraft();
138
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;
139
171
  signatures.clear(); $('stream').replaceChildren(); empty($('stream'), managerMode ? 'Select a running project...' : 'Waiting for hub activity...');
140
172
  if (streamFilter) { streamFilter = ''; for (const child of $('stream-filters').children) child.setAttribute('aria-pressed', 'false'); }
141
173
  $('stream-empty').hidden = true; $('task-filter').value = '';
142
- 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.'],['settings','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); }
143
181
  $('settings-session').textContent = '';
144
182
  $('peer-count').textContent = ''; $('task-count').textContent = ''; $('permission-count').textContent = ''; $('project-context').textContent = '';
145
183
  notice('');
@@ -213,7 +251,7 @@ function renderApprovals(permissions) {
213
251
  update('permissions', permissions, (target) => {
214
252
  if (!permissions.length) return empty(target, 'No approvals waiting.');
215
253
  for (const permission of permissions) {
216
- 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'));
217
255
  if (permission.terminalOnly) row.append(el('p', 'Review in the console or ahub tail; answer with ahub permit. You can deny here.', 'meta'));
218
256
  const actions = el('div', undefined, 'actions');
219
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')); }
@@ -221,7 +259,7 @@ function renderApprovals(permissions) {
221
259
  }
222
260
  });
223
261
  }
224
- const SETTING_GROUPS = ['Permissions', 'Start', 'Routing', 'Switches'], INHERIT = '__inherit__';
262
+ const SETTING_GROUPS = ['Roster', 'Permissions', 'Roles', 'Start', 'Routing', 'Switches'], INHERIT = '__inherit__';
225
263
  const settingsOpen = new Set(['Permissions']);
226
264
  const settingText = (value) => value === null || value === undefined ? '(not set)' : Array.isArray(value) ? (value.join(', ') || '(none)') : String(value);
227
265
  async function previewSetting(row, value, out, control) {
@@ -245,13 +283,27 @@ function settingRow(row, raise) {
245
283
  const title = el('label', row.label, 'label'); title.htmlFor = id;
246
284
  head.append(title, badge(settingText(row.value), row.group === 'Permissions' && row.value !== null && row.value !== 'ask' ? 'warn' : ''));
247
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'));
248
- if (row.pending !== undefined) node.append(el('p', 'Pending restart: ' + settingText(row.pending) + ' at the next hub start.', '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'));
249
287
  if (row.hint) node.append(el('p', 'Bounds: ' + row.hint, 'meta'));
250
288
  if (row.note) node.append(el('p', row.note, 'meta'));
251
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'));
252
290
  const actions = el('div', undefined, 'actions'), stored = row.file !== 'this hub run';
253
- let read, confirm;
254
- if (row.type === 'peers') {
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') {
255
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;
256
308
  read = () => { const list = input.value.split(',').map((value) => value.trim()).filter(Boolean); return list.length ? list : null; };
257
309
  actions.append(input);
@@ -273,19 +325,26 @@ function settingRow(row, raise) {
273
325
  actions.append(confirm);
274
326
  }
275
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
+ }
276
334
  const out = el('div', undefined, 'preview'); out.setAttribute('role', 'status'); out.setAttribute('aria-live', 'polite');
277
- if (row.group === 'Permissions' || row.group === 'Routing') {
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') {
278
337
  const preview = el('button', 'Preview'); preview.type = 'button'; preview.setAttribute('aria-label', 'Preview ' + row.label);
279
338
  preview.addEventListener('click', () => void previewSetting(row, read(), out, preview)); actions.append(preview);
280
339
  }
281
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);
282
- save.addEventListener('click', () => { const payload = {action:'setting', key:row.key, value:read()}; if (confirm && !confirm.hidden && confirm.value.trim()) payload.confirm = confirm.value.trim(); void action(payload, save); });
283
- actions.append(save); node.append(actions, out);
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);
284
343
  return node;
285
344
  }
286
345
  const settingSlots = new Map();
287
346
  function setText(node, text) { if (node.textContent !== text) node.textContent = text; }
288
- function renderSettings(settings) {
347
+ function renderSettings(settings, roster = []) {
289
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.')); }
290
349
  const raise = typeof settings.sessionUntil === 'number' && settings.sessionUntil > Date.now();
291
350
  // Written only when it changes: a status region that is rewritten every second is read out every second.
@@ -310,13 +369,27 @@ function renderSettings(settings) {
310
369
  undoSlot.signature = undoKey; undoSlot.node.replaceChildren();
311
370
  if (undoKey) {
312
371
  const row = el('div', undefined, 'row'); row.append(el('p', 'Last settings write: ' + undoKey, 'meta'));
313
- const actions = el('div', undefined, 'actions'); actions.append(button('Undo last write', {action:'setting_undo'})); row.append(actions); undoSlot.node.append(row);
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);
314
380
  }
315
381
  }
316
382
  for (const row of settings.rows) {
383
+ const membership = roster.find(peer => row.key === 'roster.' + peer.peer);
317
384
  const slot = settingSlots.get(row.key), signature = JSON.stringify([row, raise]);
318
- if (!slot || slot.signature === signature) continue;
319
- slot.signature = signature; slot.node.replaceChildren(settingRow(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
+ }
320
393
  }
321
394
  }
322
395
  function syncStarts(snapshot) {
@@ -336,57 +409,297 @@ function renderStarts(target, starts) {
336
409
  target.append(row);
337
410
  }
338
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, overdue) {
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(overdue)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
+ const now = Date.now(), overdue = new Set(snapshot.tasks.filter(task=>task.due != null && task.due<now && task.state!=='approved').map(task=>task.id));
571
+ boardViewState={snapshot,peers,ids,cards};
572
+ renderPlanning(snapshot);
573
+ const staged=$('backlog-proposal');staged.disabled=snapshot.boardCapabilities?.backlog!==true || !sameBoardContext(boardContext());
574
+ if(snapshot.boardCapabilities?.backlog!==true)staged.checked=false;
575
+ const peerFilter=$('task-peer-filter'), previous=peerFilter.value;
576
+ peerOptions(peerFilter,ids,'All agents'); if (ids.includes(previous)) peerFilter.value=previous;
577
+ const taskFilter=$('task-filter').value, ownerFilter=peerFilter.value, releaseFilter=$('task-release-filter').value, lanes=$('task-lanes').value;
578
+ const eligible=ids.filter(id=>['idle','busy'].includes(peers[id].state) && peers[id].attached!==false && peers[id].rostered!==false);
579
+ $('task-count').textContent=snapshot.tasks.length+' tasks';
580
+ update('task-legend',[snapshot.boardLegend,peers],target=>{
581
+ for (const id of ids) {
582
+ const data=(snapshot.boardLegend || []).find(item=>item.peer===id), row=el('div',undefined,'agent-slot');
583
+ row.append(peerMark(id),el('p',String(peers[id].state || 'unknown'),'meta'));
584
+ const wip=data && Number.isSafeInteger(data.wip) && data.wip>=0 ? data.wip : 'unknown';
585
+ row.append(el('p','WIP '+wip+(Number.isSafeInteger(data?.wipLimit) ? '/'+data.wipLimit : '')+' / permission '+(data?.permissionMode || 'unknown'),'meta'));
586
+ if (Array.isArray(data?.activeTasks) && data.activeTasks.length) row.append(el('p','Active tasks: '+data.activeTasks.map(id=>'#'+id).join(', '),'meta'));
587
+ 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);
588
+ if (eligible.includes(id)) dropTarget(row,card=>card.assignable,{action:'assign',peer:id});
589
+ target.append(row);
590
+ }
591
+ if (!ids.length) empty(target,'No peer status available.');
592
+ });
593
+ update('tasks',[snapshot.tasks,peers,snapshot.boardCapabilities,taskFilter,ownerFilter,releaseFilter,lanes,showAllDone,[...overdue]],target=>{
594
+ taskRows.clear();
595
+ 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)));
596
+ const visibleIds=new Set(visible.map(task=>task.id));
597
+ const owners=lanes==='agents' ? [...new Set(visible.map(task=>task.owner || ''))] : lanes==='releases' ? [...new Set(visible.map(task=>task.release ?? 0))] : [null];
598
+ if (!owners.length) owners.push(null);
599
+ for (const [laneIndex, owner] of owners.entries()) {
600
+ const lane=el('div',undefined,'board-lane');
601
+ if(owner!==null) {
602
+ 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');
603
+ if(lanes==='agents' && eligible.includes(owner)) dropTarget(heading,card=>card.assignable,{action:'assign',peer:owner});
604
+ lane.append(heading);
605
+ }
606
+ const grid=el('div',undefined,'board-columns');
607
+ const inLane=card=>visibleIds.has(card.id) && (owner===null || (lanes==='releases' ? (byId.get(card.id)?.release ?? 0)===owner : (byId.get(card.id)?.owner || '')===owner));
608
+ for (const column of model.columns) {
609
+ 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);
610
+ if (column.id==='backlog' && snapshot.boardCapabilities?.backlog===true) dropTarget(box,card=>card.returnable,{action:'backlog_return'});
611
+ if (column.id==='todo' && snapshot.boardCapabilities?.backlog===true) dropTarget(box,card=>card.pull,{action:'backlog_pull'});
612
+ let ready=column.ready.filter(inLane), waiting=column.waiting.filter(inLane);
613
+ if(column.id==='done') ready.sort((a,b)=>(byId.get(b.id)?.updated || 0)-(byId.get(a.id)?.updated || 0) || b.id-a.id);
614
+ const hidden=column.id==='done' && !showAllDone ? Math.max(0,ready.length-10) : 0;
615
+ if(hidden) ready=ready.slice(0,10);
616
+ for(const [label,list] of [['Ready',ready],['Waiting',waiting]]) {
617
+ if(column.id==='todo') box.append(el('h4',label+' ('+list.length+')'));
618
+ for(const card of list) box.append(taskCard(byId.get(card.id),card,stages.get(card.id),eligible,overdue.has(card.id)));
619
+ }
620
+ if(!ready.length && !waiting.length) empty(box,'No tasks');
621
+ 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)}
622
+ grid.append(box);
623
+ }
624
+ lane.append(grid);target.append(lane);
625
+ }
626
+ 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'));
627
+ });
628
+ }
629
+ const petBaseTitle = typeof document === 'undefined' ? 'agent-hub | Local dashboard' : document.title;
630
+ let petSignature = '', petFirst;
631
+ function reachPetNeed() {
632
+ if (!snapshotValid || !petFirst) return;
633
+ const item = petFirst;
634
+ 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;
635
+ if (row) { row.scrollIntoView({block:'center',behavior:'instant'}); row.focus?.(); return; }
636
+ const heading = item.kind === 'approval' ? 'permissions-title' : ['review','offline'].includes(item.kind) ? 'tasks-title' : item.kind === 'budget' ? 'budget-title' : 'peers-title';
637
+ $(heading)?.scrollIntoView({block:'start',behavior:'instant'});
638
+ notice('Attention: '+item.kind+(item.peer?' '+item.peer:'')+(item.id?' '+item.id:''));
639
+ }
640
+ function renderPetAttention(snapshot) {
641
+ 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);
642
+ const target=$('pet-container');if(!target)return;
643
+ const input=snapshot?.attention;
644
+ 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';
645
+ let value;try{if(shape)value=needs(input,Date.now());}catch{ /* malformed optional attention never breaks the existing dashboard */ }
646
+ const signature = JSON.stringify(value ? [snapshot.pet.character,snapshot.pet.blink,value] : null);
647
+ if(signature===petSignature)return;
648
+ petSignature=signature;petFirst=value?.items[0];
649
+ target.replaceChildren();target.hidden=!value;
650
+ const title=value?.act?'('+value.act+') agent-hub':petBaseTitle;if(document.title!==title)document.title=title;
651
+ if(!value)return;
652
+ const character=petCharacters[snapshot.pet.character],frame=character.frames.blocks[value.state].full,model=petPixels(frame);
653
+ const blink = snapshot.pet.blink!==false && value.items.some(item=>item.kind==='approval');
654
+ const button=el('button',undefined,'pet-control'+(blink?' pet-waiting':''));button.type='button';
655
+ const label=character.name+': '+value.state+', '+value.act+' action, '+value.watch+' watch';button.setAttribute('aria-label',label);button.addEventListener('click',reachPetNeed);
656
+ 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');
657
+ const accent=character.accent?.full;
658
+ const poses=blink?[['pet-open',model],['pet-blink',petPixels(character.frames.blocks.waitingBlink.full)]]:[['pet-open',model]];
659
+ for(const [cls,pose] of poses){const group=document.createElementNS('http://www.w3.org/2000/svg','g');group.setAttribute('class',cls);
660
+ 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);}
661
+ button.append(svg,el('span',value.state+' / '+value.act+' action'+(value.watch?' +'+value.watch+' watch':'')));target.append(button);
662
+ }
339
663
  function render(snapshot) {
340
- const peers = snapshot.status.peers || {}, ids = Object.keys(peers), starts = Array.isArray(snapshot.starts) ? snapshot.starts : [];
664
+ renderPetAttention(snapshot);
665
+ 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 : [];
341
666
  $('peer-count').textContent = ids.length + ' peers';
342
667
  syncStarts(snapshot);
343
668
  update('peers', [peers, starts, starts.map(start => { const pending = pendingStarts.get(startKey(start.peer)); return pending ? [pending.phase, pending.retryAt || null] : null; })], (target) => {
669
+ peerRows.clear();
344
670
  if (!ids.length) { empty(target, 'No peers connected. Start one below, or from your terminal.'); return renderStarts(target, starts); }
345
671
  for (const [id, peer] of Object.entries(peers)) {
346
672
  const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head'), name = el('div', undefined, 'peername');
347
673
  name.append(dot(peerDot[peer.state] || ''), el('span', id, 'label'));
348
- head.append(name, badge(peer.state, peerDot[peer.state] || ''));
674
+ head.append(name, badge(peer.rostered === false ? peer.state + ' off roster' : peer.state, peerDot[peer.state] || ''));
349
675
  row.append(head, el('p', peer.queued + ' queued' + (peer.queuedImportant ? ' (' + peer.queuedImportant + ' important)' : ''), 'meta'));
676
+ if (peer.flowRoles) row.append(el('p', 'Flow roles: ' + (peer.flowRoles.join(', ') || 'none'), 'meta'));
677
+ 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')); }
350
678
  if (peer.needsReview) row.append(el('p', peer.needsReview + ' need review: use ahub queue list', 'meta'));
351
679
  if (peer.oldestQueuedAt !== undefined) row.append(el('p', 'Oldest pending: ' + Math.max(0, Math.floor((Date.now() - peer.oldestQueuedAt) / 1000)) + 's', 'meta'));
352
680
  if (peer.attached === false) row.append(el('p', 'Disconnected recipient; work is retained.', 'meta'));
353
681
  if (peer.toolsOnly) row.append(el('p', peer.toolsOnly, 'meta'));
682
+ if (peer.permissionNote) row.append(el('p', peer.permissionNote, 'meta'));
683
+ 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'));
354
684
  if (peer.paused) row.append(el('p', peer.paused, 'meta'));
355
685
  if (peer.requestedModel) row.append(el('p', 'Requested: ' + peer.requestedModel, 'meta'));
356
686
  for (const backend of peer.backends || []) {
357
687
  if (backend.requestedModel) row.append(el('p', backend.kind.toUpperCase() + ': ' + (backend.actualModel || 'model not reported') + ' (' + backend.state + ')', 'meta'));
358
688
  }
359
689
  const paused = peer.state === 'paused' || peer.paused;
690
+ row.tabIndex = 0; peerRows.set(id, row);
691
+ const trackDesk = (on) => { const desk = officeDesks.get(id); if (desk) desk.classList.toggle('office-spotlight', on); };
692
+ row.addEventListener('mouseenter', () => trackDesk(true)); row.addEventListener('mouseleave', () => trackDesk(false));
693
+ row.addEventListener('focus', () => trackDesk(true)); row.addEventListener('blur', () => trackDesk(false));
360
694
  row.append(button(paused ? 'Resume' : 'Pause', {action:paused ? 'resume' : 'pause', peer:id})); target.append(row);
361
695
  }
362
696
  renderStarts(target, starts);
363
697
  });
698
+ renderOffice(snapshot);
364
699
  const assignable = ids.filter((id) => peers[id].state !== 'offline' && peers[id].attached !== false);
365
700
  const peerSignature = JSON.stringify([ids, assignable]);
366
701
  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; } }
367
- const progress = taskProgress(snapshot.tasks);
368
- renderTaskProgress(progress);
369
- const progressStages = new Map(progress.stages.map(stage => [stage.id, stage]));
370
- $('task-count').textContent = snapshot.tasks.length + ' tasks';
371
- const taskFilter = $('task-filter').value;
372
- const visibleTasks = taskFilter ? snapshot.tasks.filter((task) => task.state === taskFilter) : snapshot.tasks;
373
- update('tasks', [visibleTasks, ids, taskFilter, progress], (target) => {
374
- if (!snapshot.tasks.length) return empty(target, 'No public tasks yet. Propose a task below to get started.');
375
- if (!visibleTasks.length) return empty(target, 'No tasks in this state.');
376
- for (const task of visibleTasks) {
377
- const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
378
- head.append(el('span', '#' + task.id + ' ' + task.title, 'label'), badge(task.state.replaceAll('_', ' '), taskBadge[task.state] || ''));
379
- const stage = progressStages.get(task.id);
380
- row.append(head, renderTaskStage(stage), el('p', task.class + ' / ' + (task.owner || 'unassigned') + (task.reviewer ? ' / review: ' + task.reviewer : ''), 'meta'));
381
- if (task.detail) row.append(el('p', task.detail, 'body'));
382
- if (task.state !== 'approved' && ids.length) {
383
- 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;
384
- 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); });
385
- actions.append(select, assign); row.append(actions);
386
- }
387
- target.append(row);
388
- }
389
- });
702
+ renderBoard(snapshot, peers, ids);
390
703
  renderApprovals(snapshot.permissions);
391
704
  update('budget', snapshot.budget, (target) => {
392
705
  if (!Object.keys(snapshot.budget).length) return empty(target, 'No usage readings yet. Usage appears when a peer reports it.');
@@ -413,7 +726,8 @@ function render(snapshot) {
413
726
  }
414
727
  });
415
728
  renderBench(snapshot.bench);
416
- renderSettings(snapshot.settings);
729
+ renderMemory(snapshot.memory);
730
+ renderSettings(snapshot.settings, snapshot.roster);
417
731
  const stream = $('stream');
418
732
  if (!eventCount) { stream.replaceChildren(); empty(stream, 'No activity yet.'); }
419
733
  for (const entry of snapshot.events || []) {
@@ -438,7 +752,7 @@ async function poll() {
438
752
  const captured = context();
439
753
  if (managerMode && (!captured.projectId || !captured.instanceId)) { if (!stopped) timer = setTimeout(poll, 1000); return; }
440
754
  try {
441
- const input = {after:cursor}; if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
755
+ const input = {after:cursor, memory:memoryInput()}; if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
442
756
  const result = await post('/snapshot', input);
443
757
  if (!managerMode || (result.projectId === captured.projectId && result.instanceId === captured.instanceId && captured.generation === viewGeneration)) {
444
758
  snapshotValid = true; lastSnapshot = result; render(result); $('connection').textContent = 'Live / updated ' + new Date().toLocaleTimeString();
@@ -447,6 +761,343 @@ async function poll() {
447
761
  catch (error) { if (!stopped) $('connection').textContent = 'Disconnected / retrying: ' + error.message; }
448
762
  if (!stopped) timer = setTimeout(poll, 1000);
449
763
  }
764
+ // office-scene-begin (issue 264): the office, drawn Flat or Isometric from the shared officeModel as inline
765
+ // SVG, re-rendered only when its signature changed; no timer, no frame loop, no request and no action here.
766
+ // Motion is CSS only: transform/opacity transitions for card moves, keyframe fades for arcs, alerts and the
767
+ // busy latch, timed at render from the model's timestamps; expired nodes purge on the next changed render or
768
+ // their own animationend. The projection (phase 4) maps grid coordinates only: x' = (x - y) cos 30, y' =
769
+ // (x + y) sin 30; nodes are laid out upright in local coordinates, so text is never rotated and the DOM
770
+ // order and accessible names do not depend on the projection.
771
+ let officeProjection = 'flat';
772
+ let officeLastProjection = '';
773
+ const officeRound = (v) => Math.round(v * 100) / 100;
774
+ function officeIsoLocal(x, y) { return [(x - y) * 0.8660254, (x + y) * 0.5]; }
775
+ function officeProject(x, y) { return officeProjection === 'iso' ? officeIsoLocal(x, y) : [x, y]; }
776
+ function officePlace(node, x, y) { const p = officeProject(x, y); node.style.transform = 'translate(' + p[0] + 'px,' + p[1] + 'px)'; }
777
+ function officePolygon(points, cls) { return officeNode('polygon', { points: points.map((p) => p.map(officeRound).join(',')).join(' '), class: cls }); }
778
+ function officeTile(w, h, cls) {
779
+ if (officeProjection !== 'iso') return officeNode('rect', { x: 0, y: 0, width: w, height: h, rx: 1, class: cls });
780
+ return officePolygon([[0, 0], officeIsoLocal(w, 0), officeIsoLocal(w, h), officeIsoLocal(0, h)], cls);
781
+ }
782
+ function officeViewBox(w, h) {
783
+ if (officeProjection !== 'iso') return '0 0 ' + w + ' ' + h;
784
+ const corners = [officeProject(0, 0), officeProject(w, 0), officeProject(0, h), officeProject(w, h)];
785
+ const xs = corners.map((p) => p[0]), ys = corners.map((p) => p[1]), m = 3;
786
+ const minX = Math.min(...xs), minY = Math.min(...ys);
787
+ return (minX - m) + ' ' + (minY - m) + ' ' + (Math.max(...xs) - minX + 2 * m) + ' ' + (Math.max(...ys) - minY + 2 * m);
788
+ }
789
+ // A raised hand as outline shapes (palm and fingers), coloured by the desk's condition class.
790
+ function officeHand(x, y) {
791
+ const g = officeNode('g', { class: 'office-handshape' });
792
+ g.append(officeNode('rect', { x: x + 0.05, y: y + 0.8, width: 1.5, height: 1.2, rx: 0.55 }));
793
+ 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 }));
794
+ return g;
795
+ }
796
+ // The condition glyph as clean outline shapes; the label text next to it carries the state in words.
797
+ function officeGlyphNode(desk, cx, cy) {
798
+ if (desk.condition === 'waiting') return officeHand(cx - 0.8, cy - 1.05);
799
+ const g = officeNode('g', { class: 'office-glyph-shape' });
800
+ if (desk.condition === 'busy' || desk.condition === 'idle') g.append(officeNode('circle', { cx, cy, r: 0.8 }));
801
+ 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 }));
802
+ 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 }));
803
+ else g.append(officeNode('text', { x: cx - 0.4, y: cy + 0.55, class: 'office-glyph' }, desk.glyph));
804
+ return g;
805
+ }
806
+ // A person: head over shoulders, upright in both projections. Isometric stands behind the desk.
807
+ function officeAvatar() {
808
+ if (officeProjection === 'iso') return [
809
+ officeNode('circle', { cx: 1.5, cy: -3.7, r: 1.05, class: 'office-avatar' }),
810
+ officeNode('rect', { x: 0.55, y: -2.85, width: 1.9, height: 3.05, rx: 0.9, class: 'office-avatar' })];
811
+ return [
812
+ officeNode('circle', { cx: 2.2, cy: 3.1, r: 0.78, class: 'office-avatar' }),
813
+ officeNode('path', { d: 'M 1.2 4.6 A 1 1 0 0 1 3.2 4.6 Z', class: 'office-avatar' })];
814
+ }
815
+ // The Done shelf: a small cabinet of stacked card rows (flat) or boxes on a slab (iso); the count stays text.
816
+ function officeShelf(iso) {
817
+ if (!iso) {
818
+ const nodes = [officeNode('rect', { x: 6.5, y: 4.4, width: 9, height: 10.4, rx: 1, class: 'office-shelf' })];
819
+ 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' }));
820
+ 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' }));
821
+ return nodes;
822
+ }
823
+ const nodes = [officePolygon([officeIsoLocal(6, 3), officeIsoLocal(16, 3), officeIsoLocal(16, 9), officeIsoLocal(6, 9)], 'office-shelf')];
824
+ for (const [bx, by] of [[7, 4], [11.5, 4], [9.2, 6.2]]) {
825
+ const s = 2.4, rise = 2.2;
826
+ const top = [officeIsoLocal(bx, by), officeIsoLocal(bx + s, by), officeIsoLocal(bx + s, by + s), officeIsoLocal(bx, by + s)];
827
+ const up = top.map(([x, y]) => [x, y - rise]);
828
+ 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'));
829
+ }
830
+ return nodes;
831
+ }
832
+ function officeClip(text, max) { text = String(text); return text.length > max ? text.slice(0, max - 3) + '...' : text; }
833
+ 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; }
834
+ function officeKeys(event, activate) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); activate(); } }
835
+ let spotlitRow = null;
836
+ function spotlightRow(row) { if (spotlitRow && spotlitRow !== row) spotlitRow.classList.remove('spotlight'); spotlitRow = row; if (row) row.classList.add('spotlight'); }
837
+ function activateOfficePeer(id) { const row = peerRows.get(id); if (!row) return; spotlightRow(row); row.scrollIntoView({ block: 'nearest' }); }
838
+ function activateOfficeTask(id) { if (!taskRows.has(id)) officeFilter(''); const row = taskRows.get(id); if (!row) return; spotlightRow(row); row.scrollIntoView({ block: 'nearest' }); }
839
+ function officeFilter(state) { $('task-filter').value = state; $('task-peer-filter').value = ''; $('task-release-filter').value = ''; if (!stopped && snapshotValid && lastSnapshot) render(lastSnapshot); }
840
+ // Fade timing for a transient mark: it holds, then fades over the final second so it is gone at `until`.
841
+ // Applied once at creation; later renders leave the running animation alone.
842
+ function officeFade(node, until, now) {
843
+ node.dataset.officeUntil = String(until);
844
+ const remaining = Math.max(0, until - now);
845
+ if (remaining <= 0) return false;
846
+ if (officeReducedMotion()) return true;
847
+ const duration = Math.min(1000, remaining);
848
+ node.setAttribute('class', ((node.getAttribute('class') || '') + ' office-fadeout').trim());
849
+ if (duration !== 1000) node.style.animationDuration = duration + 'ms';
850
+ node.style.animationDelay = (remaining - duration) + 'ms';
851
+ return true;
852
+ }
853
+ function officeCardPaint(node, card) {
854
+ node.setAttribute('class', 'office-card-node office-movable' + (card.waiting ? ' office-waiting-card' : '') + (card.rework ? ' office-rework-card' : '') + (card.checking ? ' office-card-checking' : ''));
855
+ node.setAttribute('aria-label', card.name);
856
+ const children = [
857
+ officeNode('rect', { x: 0, y: 0, width: card.w, height: card.h, rx: 0.6, class: 'office-card' }),
858
+ officeNode('text', { x: 0.8, y: card.h - 0.7, class: 'office-card-text' }, officeClip(card.label, card.w > 12 ? 22 : 10))];
859
+ if (card.checking) children.push(officeNode('text', { x: card.w - 0.6, y: card.h - 0.7, 'text-anchor': 'end', class: 'office-checking' }, '⟳'));
860
+ node.replaceChildren(...children);
861
+ }
862
+ function officeDeskPaint(node, desk) {
863
+ node.setAttribute('class', 'office-desk office-' + desk.condition + (desk.dimmed ? ' office-dimmed' : ''));
864
+ node.setAttribute('aria-label', desk.name);
865
+ officePlace(node, desk.x, desk.y);
866
+ const iso = officeProjection === 'iso';
867
+ const children = [];
868
+ if (iso) {
869
+ children.push(...officeAvatar());
870
+ // The desk is a light extrusion: the top tile plus two thin front legs.
871
+ const B = officeIsoLocal(desk.w, 0), C = officeIsoLocal(desk.w, desk.h), D = officeIsoLocal(0, desk.h);
872
+ for (const [from, to] of [[D, C], [B, C]]) {
873
+ const at = (t) => [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t];
874
+ const a = at(0.78), b = at(0.92);
875
+ children.push(officePolygon([a, b, [b[0], b[1] + 2.4], [a[0], a[1] + 2.4]], 'office-desk-leg'));
876
+ }
877
+ }
878
+ children.push(officeTile(desk.w, desk.h, 'office-desk-surface'));
879
+ if (iso) {
880
+ // The name plate is a small parallelogram on the desk's front edge; the name text stays upright.
881
+ const D = officeIsoLocal(0, desk.h), C = officeIsoLocal(desk.w, desk.h), off = officeIsoLocal(0, 1);
882
+ const c0 = [(D[0] + C[0]) / 2 + off[0], (D[1] + C[1]) / 2 + off[1]];
883
+ 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'));
884
+ children.push(officeNode('text', { x: c0[0], y: c0[1] + 2.5, 'text-anchor': 'middle', class: 'office-name' }, desk.id));
885
+ } else {
886
+ children.push(officeNode('text', { x: 1, y: 2, class: 'office-name' }, desk.id));
887
+ children.push(...officeAvatar());
888
+ }
889
+ if (desk.bubble) {
890
+ const bx = iso ? 3.6 : 0.6; // clear of the standing avatar in iso
891
+ children.push(officeNode('rect', { x: bx, y: -2.3, width: desk.w - 1.2, height: 1.9, rx: 0.9, class: 'office-bubble' }));
892
+ 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)));
893
+ }
894
+ if (desk.mode) children.push(officeNode('text', { x: desk.w - 1, y: 2, 'text-anchor': 'end', class: 'office-mode' }, desk.mode));
895
+ children.push(officeGlyphNode(desk, iso ? 5.2 : 4.3, 3.65));
896
+ children.push(officeNode('text', { x: iso ? 6.3 : 5.3, y: 4, class: 'office-state' }, desk.label));
897
+ 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))); }
898
+ let line = 5.35;
899
+ 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; }
900
+ if (desk.finishing) { children.push(officeNode('text', { x: 1, y: line, class: 'office-meta' }, 'finishing a turn')); line += 1.05; }
901
+ if (!desk.attached) { children.push(officeNode('text', { x: 1, y: line, class: 'office-meta' }, 'disconnected; work retained')); line += 1.05; }
902
+ const tray = [];
903
+ if (desk.tray.queued) tray.push(desk.tray.queued + ' queued');
904
+ if (desk.tray.important) tray.push(desk.tray.important + ' important');
905
+ if (desk.tray.needsReview) tray.push(desk.tray.needsReview + ' to review');
906
+ if (desk.tray.heldBy) tray.push('held by ' + desk.tray.heldBy);
907
+ if (tray.length) children.push(officeNode('text', { x: 1, y: line, class: 'office-meta' }, officeClip(tray.join(' / '), 38)));
908
+ if (desk.extraCards > 0) children.push(officeNode('text', { x: 1, y: desk.h - 0.4, class: 'office-meta' }, '+' + desk.extraCards + ' more'));
909
+ node.replaceChildren(...children);
910
+ }
911
+ function officeRoomPaint(node, room) {
912
+ node.setAttribute('class', 'office-roombox office-roombox-' + room.id);
913
+ officePlace(node, room.x, room.y);
914
+ const iso = officeProjection === 'iso';
915
+ const children = [officeTile(room.w, room.h, 'office-room')];
916
+ let moreAt = { x: 1, y: room.h - 1, anchor: 'start' };
917
+ if (iso) {
918
+ // A faint wireframe floor box: the floor tile and the two back vertical edges; the label sits at the base.
919
+ const B = officeIsoLocal(room.w, 0), D = officeIsoLocal(0, room.h), C = officeIsoLocal(room.w, room.h);
920
+ 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' }));
921
+ children.push(officeNode('text', { x: C[0], y: C[1] + 2.4, 'text-anchor': 'middle', class: 'office-room-title' }, room.label));
922
+ children.push(officeNode('text', { x: C[0], y: C[1] + 3.8, 'text-anchor': 'middle', class: 'office-room-count' }, String(room.count)));
923
+ moreAt = { x: D[0], y: D[1] + 2.4, anchor: 'middle' };
924
+ } else {
925
+ if (room.x > 0) children.push(officeNode('line', { x1: -1, y1: 0.6, x2: -1, y2: room.h - 0.6, class: 'office-divider' }));
926
+ children.push(officeNode('text', { x: 1, y: 2.2, class: 'office-room-title' }, room.label));
927
+ children.push(officeNode('text', { x: room.w - 1, y: 2.2, 'text-anchor': 'end', class: 'office-room-count' }, String(room.count)));
928
+ }
929
+ if (room.id === 'done') children.push(...officeShelf(iso));
930
+ node.replaceChildren(...children);
931
+ if (room.overflow > 0 && room.filterState) {
932
+ const more = officeNode('g', { tabindex: '0', role: 'button', 'aria-label': 'Show ' + room.overflow + ' more ' + room.filterState.replaceAll('_', ' ') + ' tasks in the task list' });
933
+ more.append(officeNode('text', { x: moreAt.x, y: moreAt.y, 'text-anchor': moreAt.anchor, class: 'office-more' }, '+' + room.overflow + ' more'));
934
+ const open = () => officeFilter(room.filterState);
935
+ more.addEventListener('click', open);
936
+ more.addEventListener('keydown', (event) => officeKeys(event, open));
937
+ node.append(more);
938
+ }
939
+ }
940
+ let officeScene = null;
941
+ function officeEnsure(target) {
942
+ if (officeScene) return officeScene;
943
+ const svg = officeNode('svg', { class: 'office-scene', role: 'group', 'aria-label': 'Office: desks, rooms and task cards' });
944
+ const layers = {};
945
+ 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; }
946
+ officeScene = { svg, layers, els: new Map(), leaving: new Map(), settled: true };
947
+ target.replaceChildren(svg);
948
+ return officeScene;
949
+ }
950
+ function officeReducedMotion() { return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true; }
951
+ function officePrune(scene, now) {
952
+ if(!scene)return;
953
+ for(const [node,until] of scene.leaving)if(officeReducedMotion() || until<=now){node.remove();scene.leaving.delete(node)}
954
+ for(const [key,entry] of scene.els){
955
+ if(officeReducedMotion())entry.node.classList.remove('office-appear');
956
+ if(entry.node.dataset.officeUntil && Number(entry.node.dataset.officeUntil)<=now){entry.node.remove();scene.els.delete(key)}
957
+ }
958
+ }
959
+ function officeLeave(scene, key) {
960
+ const entry = scene.els.get(key);
961
+ if (!entry) return;
962
+ scene.els.delete(key);
963
+ if(officeReducedMotion() || scene.settled){entry.node.remove();return;}
964
+ scene.leaving.set(entry.node,Date.now()+400);
965
+ entry.node.classList.add('office-leaving');
966
+ entry.node.addEventListener('animationend', () => {entry.node.remove();scene.leaving.delete(entry.node)});
967
+ }
968
+ function drawOffice(target, model) {
969
+ const scene = officeEnsure(target), els = scene.els, now = Date.now();
970
+ scene.svg.setAttribute('viewBox', officeViewBox(model.width, model.height));
971
+ const switched = officeLastProjection !== officeProjection; // a view switch jumps, never animates
972
+ officeLastProjection = officeProjection;
973
+ scene.settled = model.settle || switched;
974
+ officePrune(scene, now);
975
+ scene.svg.setAttribute('class', 'office-scene' + (model.settle || switched ? ' office-settle' : ''));
976
+ const seen = new Set();
977
+ const keep = (key, sig, layer, paint, appear) => {
978
+ seen.add(key);
979
+ sig = officeProjection + '|' + sig; // a projection switch repaints every node in place
980
+ const entry = els.get(key);
981
+ if (entry && entry.sig === sig) return { node: entry.node, fresh: false };
982
+ const node = entry ? entry.node : officeNode('g', {});
983
+ paint(node);
984
+ if (entry) entry.sig = sig;
985
+ else {
986
+ if (appear !== false && !scene.settled && !officeReducedMotion()) { node.classList.add('office-appear'); node.addEventListener('animationend', () => node.classList.remove('office-appear')); }
987
+ layer.append(node);
988
+ els.set(key, { node, sig });
989
+ }
990
+ return { node, fresh: true };
991
+ };
992
+ for (const room of model.rooms) {
993
+ keep('room:' + room.id, JSON.stringify({ ...room, cards: undefined }), scene.layers.rooms, (node) => officeRoomPaint(node, room), false);
994
+ }
995
+ for (const desk of model.desks) {
996
+ const { node, fresh } = keep('desk:' + desk.id, JSON.stringify({ ...desk, cards: undefined }), scene.layers.desks, (n) => officeDeskPaint(n, desk));
997
+ if (fresh && !node.getAttribute('tabindex')) {
998
+ node.setAttribute('tabindex', '0'); node.setAttribute('role', 'button');
999
+ const open = () => activateOfficePeer(desk.id);
1000
+ node.addEventListener('click', open);
1001
+ node.addEventListener('keydown', (event) => officeKeys(event, open));
1002
+ }
1003
+ }
1004
+ const cards = [];
1005
+ for (const desk of model.desks) cards.push(...desk.cards);
1006
+ for (const room of model.rooms) cards.push(...room.cards);
1007
+ for (const card of cards) {
1008
+ const key = 'card:' + card.id;
1009
+ 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));
1010
+ if (fresh && !node.getAttribute('tabindex')) {
1011
+ node.setAttribute('tabindex', '0'); node.setAttribute('role', 'button');
1012
+ const open = () => activateOfficeTask(card.id);
1013
+ node.addEventListener('click', open);
1014
+ node.addEventListener('keydown', (event) => officeKeys(event, open));
1015
+ }
1016
+ const p = officeProject(card.x, card.y), transform = 'translate(' + p[0] + 'px,' + p[1] + 'px)';
1017
+ if (node.style.transform !== transform) node.style.transform = transform; // the only animated property
1018
+ }
1019
+ if (model.extraPeers > 0) {
1020
+ keep('extra:peers', String(model.extraPeers) + ':' + model.width, scene.layers.marks,
1021
+ (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);
1022
+ }
1023
+ const deskById = new Map(model.desks.map((desk) => [desk.id, desk]));
1024
+ // Warning lines between desks (phase 3): overlap, conflict and cohort links, ids only, state-driven (no fade).
1025
+ for (const link of model.links) {
1026
+ const offset = link.kind === 'overlap' ? -1.5 : link.kind === 'cohort' ? 1.5 : 0;
1027
+ 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]});
1028
+ if (points.length < 2) continue;
1029
+ const segments = [];
1030
+ for (let i = 0; i + 1 < points.length; i++) segments.push([points[i], points[i + 1]]);
1031
+ const label = link.kind + ' ' + link.tasks.map((id) => '#' + id).join(' ');
1032
+ keep('link:' + link.kind + ':' + link.tasks.join(','), JSON.stringify([link.kind, link.tasks, link.peers, points]), scene.layers.links,
1033
+ (n) => {
1034
+ n.setAttribute('class', 'office-link office-link-' + link.kind);
1035
+ n.setAttribute('aria-hidden', 'true');
1036
+ n.replaceChildren(...segments.map(([a, b]) => officeNode('line', { x1: a[0], y1: a[1], x2: b[0], y2: b[1], class: 'office-link-line' })),
1037
+ 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)));
1038
+ }, false);
1039
+ }
1040
+ for (const arc of model.arcs) {
1041
+ if(arc.until<=now)continue;
1042
+ const from = deskById.get(arc.from);
1043
+ const label = arc.kind + (arc.task ? ' #' + arc.task : '');
1044
+ for (const target of arc.to.length ? arc.to : model.desks.map((desk) => desk.id)) {
1045
+ const to = deskById.get(target);
1046
+ if (!to) continue;
1047
+ const p1 = officeProject(from ? from.x + from.w / 2 : 1, from ? from.y : 1), p2 = officeProject(to.x + to.w / 2, to.y);
1048
+ const mx = (p1[0] + p2[0]) / 2, my = Math.min(p1[1], p2[1]) - 4;
1049
+ const angle = Math.atan2(p2[1] - my, p2[0] - mx);
1050
+ const tip = (a) => [officeRound(p2[0] - 1.5 * Math.cos(a)), officeRound(p2[1] - 1.5 * Math.sin(a))];
1051
+ const cls = 'office-arc' + (arc.priority === 'important' ? ' office-arc-important' : '') + (arc.dropped ? ' office-arc-dropped' : '');
1052
+ 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,
1053
+ (n) => { n.setAttribute('class', cls); n.setAttribute('aria-hidden', 'true'); n.replaceChildren(
1054
+ 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' }),
1055
+ officePolygon([[officeRound(p2[0]), officeRound(p2[1])], tip(angle - 0.45), tip(angle + 0.45)], 'office-arc-head'),
1056
+ officeNode('text', { x: mx, y: my - 0.8, 'text-anchor': 'middle', class: 'office-arc-label' }, label)); }, false);
1057
+ if (fresh) officeFade(node, arc.until, now);
1058
+ }
1059
+ }
1060
+ const latestAlert = new Map();
1061
+ for (const alert of model.alerts) if (alert.until > now) latestAlert.set(alert.peer, alert);
1062
+ for (const [peer, alert] of latestAlert) {
1063
+ const desk = deskById.get(peer);
1064
+ if (!desk) continue;
1065
+ const { node, fresh } = keep('alert:' + peer, String(alert.seq), scene.layers.marks,
1066
+ (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);
1067
+ if (fresh) officeFade(node, alert.until, now);
1068
+ }
1069
+ for (const desk of model.desks) {
1070
+ const latch = model.latched[desk.id];
1071
+ if (desk.condition !== 'idle' || !latch || latch.until <= now) continue;
1072
+ const { node, fresh } = keep('latch:' + desk.id, String(latch.seq), scene.layers.marks,
1073
+ (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);
1074
+ if (fresh) officeFade(node, latch.until, now);
1075
+ }
1076
+ for (const key of [...els.keys()]) if (!seen.has(key)) officeLeave(scene, key);
1077
+ officeDesks.clear(); officeCards.clear();
1078
+ for (const [key, entry] of els) {
1079
+ if (key.startsWith('desk:')) officeDesks.set(key.slice(5), entry.node);
1080
+ else if (key.startsWith('card:')) officeCards.set(Number(key.slice(5)), entry.node);
1081
+ }
1082
+ }
1083
+ function renderOffice(snapshot) {
1084
+ const view = officeView.get(), section = $('office-section');
1085
+ officeProjection = view === 'isometric' ? 'iso' : 'flat';
1086
+ section.hidden = view === 'off';
1087
+ if (view === 'off') {
1088
+ if (signatures.has('office')) { signatures.delete('office'); $('office').replaceChildren(); officeScene = null; }
1089
+ officePrev = null; officeDesks.clear(); officeCards.clear(); return;
1090
+ }
1091
+ const cols = (section.clientWidth || 0) >= 1000 ? 4 : (section.clientWidth || 0) >= 700 ? 2 : 1;
1092
+ const model = officeModel(snapshot, officePrev, { cols, now: Date.now() });
1093
+ officePrev = model;
1094
+ const signature = JSON.stringify([view, cols, model]);
1095
+ officePrune(officeScene,Date.now());
1096
+ if (signatures.get('office') === signature) return;
1097
+ signatures.set('office', signature);
1098
+ drawOffice($('office'), model);
1099
+ }
1100
+ // office-scene-end
450
1101
  const STREAM_KINDS = ['chat', 'task', 'review', 'status', 'budget', 'presence', 'state'];
451
1102
  let streamFilter = '';
452
1103
  function applyStreamFilter(node) { node.hidden = streamFilter !== '' && node.dataset.kind !== streamFilter; }
@@ -491,6 +1142,132 @@ function renderBench(bench) {
491
1142
  }
492
1143
  });
493
1144
  }
1145
+ // #258: the hub's memory, read-only. A tier at a time, filtered; selecting an entry or a node shows its neighbourhood
1146
+ // (inline SVG, a small force layout computed here) with the same edges as a list, and its detail.
1147
+ const memoryQuery = {tier:'long', q:'', kind:'', peer:'', machine:'', since:'', node:'', hops:1};
1148
+ let memoryGeneration = -1;
1149
+ function memoryInput() {
1150
+ if (memoryGeneration !== viewGeneration) { // another project: its own nodes, peers and machines
1151
+ memoryGeneration = viewGeneration; memoryQuery.node = ''; memoryQuery.peer = ''; memoryQuery.machine = '';
1152
+ for (const id of ['memory-peer', 'memory-machine']) $(id).value = '';
1153
+ }
1154
+ const out = {tier:memoryQuery.tier, hops:memoryQuery.hops};
1155
+ for (const key of ['q', 'kind', 'peer', 'machine', 'node']) if (memoryQuery[key]) out[key] = memoryQuery[key];
1156
+ // A whole timestamp, floored to the hour so the query stays the same between polls.
1157
+ if (memoryQuery.since) out.since = new Date(Math.floor((Date.now() - Number(memoryQuery.since) * 86400000) / 3600000) * 3600000).toISOString();
1158
+ return out;
1159
+ }
1160
+ // The next poll (at most a second away) carries the changed query; polling is never started twice.
1161
+ function rerenderMemory() { for (const chip of $('memory-tiers').children) chip.setAttribute('aria-pressed', String(chip.dataset.tier === memoryQuery.tier)); }
1162
+ function memoryOptions(id, values, all, key) {
1163
+ const select = $(id), keep = select.value;
1164
+ const want = [''].concat(values);
1165
+ if ([...select.options].map((o) => o.value).join('\0') === want.join('\0')) return;
1166
+ select.replaceChildren(...want.map((v) => { const o = el('option', v || all); o.value = v; return o; }));
1167
+ select.value = want.includes(keep) ? keep : '';
1168
+ memoryQuery[key] = select.value; // a filter whose value is gone reads as none, on the page and in the next query
1169
+ }
1170
+ function layout(graph) {
1171
+ // Deterministic: start on a circle in node order, then a fixed number of spring steps; no animation loop.
1172
+ const W = 640, H = 320, n = graph.nodes.length, pos = new Map();
1173
+ 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}));
1174
+ for (let step = 0; step < 120; step++) {
1175
+ const force = new Map(graph.nodes.map((node) => [node.id, {x: 0, y: 0}]));
1176
+ for (const a of graph.nodes) for (const b of graph.nodes) {
1177
+ if (a.id >= b.id) continue;
1178
+ 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;
1179
+ 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);
1180
+ }
1181
+ for (const e of graph.edges) {
1182
+ const p = pos.get(e.from), q = pos.get(e.to); if (!p || !q) continue;
1183
+ const dx = q.x - p.x, dy = q.y - p.y, d = Math.sqrt(dx * dx + dy * dy) || 1, f = (d - 90) * 0.05;
1184
+ 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;
1185
+ }
1186
+ 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)))); }
1187
+ }
1188
+ return pos;
1189
+ }
1190
+ function selectMemoryNode(node) { memoryQuery.node = memoryQuery.node === node ? '' : node; rerenderMemory(); }
1191
+ function renderMemory(memory) {
1192
+ const tiers = $('memory-tiers');
1193
+ if (!tiers.children.length) for (const [tier, label] of [['short', 'Short-term'], ['long', 'Long-term'], ['goals', 'Long-term tasks']]) {
1194
+ const chip = el('button', label, 'chip'); chip.type = 'button'; chip.dataset.tier = tier;
1195
+ chip.addEventListener('click', () => { memoryQuery.tier = tier; memoryQuery.node = ''; rerenderMemory(); });
1196
+ tiers.append(chip);
1197
+ }
1198
+ for (const chip of tiers.children) chip.setAttribute('aria-pressed', String(chip.dataset.tier === memoryQuery.tier));
1199
+ 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; }
1200
+ $('memory-count').textContent = memory.counts.long + ' long-term / ' + memory.counts.short + ' short-term / ' + (memory.counts.goals || 0) + ' open tasks';
1201
+ if (memory.goals) {
1202
+ // Long-term tasks: state, who holds the lease and until when (a conflict when two machines claimed it), linked board tasks.
1203
+ const who = (m) => m === memory.machine ? 'this machine' : 'machine ' + m.slice(0, 8);
1204
+ update('memory-list', {goals: memory.goals, node: memoryQuery.node}, (target) => {
1205
+ if (!memory.goals.length) return empty(target, 'No long-term tasks. A person creates one with ahub goal new <title>.');
1206
+ for (const g of memory.goals) {
1207
+ const row = el('button', undefined, 'memory-row'); row.type = 'button'; row.setAttribute('aria-pressed', String(memoryQuery.node === 'goal:' + g.id));
1208
+ const head = el('div', undefined, 'row-head');
1209
+ head.append(el('span', g.title, 'label'), badge(g.state, g.state === 'done' ? 'ok' : g.state === 'blocked' ? 'err' : g.state === 'active' ? 'info' : ''));
1210
+ 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';
1211
+ 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'));
1212
+ if (g.lease.conflict.length) row.append(badge('lease conflict', 'warn'));
1213
+ row.addEventListener('click', () => selectMemoryNode('goal:' + g.id));
1214
+ target.append(row);
1215
+ }
1216
+ });
1217
+ }
1218
+ memoryOptions('memory-peer', memory.peers, 'All peers', 'peer');
1219
+ memoryOptions('memory-machine', memory.machines, 'All machines', 'machine');
1220
+ if (!memory.goals) update('memory-list', {entries: memory.entries, node: memoryQuery.node}, (target) => {
1221
+ if (!memory.entries.length) return empty(target, 'No ' + memory.tier + '-term entries match.');
1222
+ for (const entry of memory.entries) {
1223
+ const row = el('button', undefined, 'memory-row'); row.type = 'button'; row.setAttribute('aria-pressed', String(memoryQuery.node === 'mem:' + entry.id));
1224
+ const head = el('div', undefined, 'row-head');
1225
+ 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' : ''));
1226
+ 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'));
1227
+ row.addEventListener('click', () => selectMemoryNode('mem:' + entry.id));
1228
+ target.append(row);
1229
+ }
1230
+ });
1231
+ update('memory-graph', memory.graph || null, (target) => {
1232
+ const graph = memory.graph;
1233
+ if (!graph || !graph.nodes.length) return;
1234
+ const NS = 'http://www.w3.org/2000/svg', svg = document.createElementNS(NS, 'svg'), pos = layout(graph);
1235
+ 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');
1236
+ for (const e of graph.edges) {
1237
+ const p = pos.get(e.from), q = pos.get(e.to); if (!p || !q) continue;
1238
+ 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);
1239
+ svg.append(line);
1240
+ }
1241
+ for (const node of graph.nodes) {
1242
+ const p = pos.get(node.id), g = document.createElementNS(NS, 'g'), c = document.createElementNS(NS, 'circle'), t = document.createElementNS(NS, 'text');
1243
+ 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);
1244
+ c.setAttribute('cx', p.x); c.setAttribute('cy', p.y); c.setAttribute('r', node.type === 'memory' ? 9 : 6);
1245
+ t.setAttribute('x', p.x + 11); t.setAttribute('y', p.y + 4); t.textContent = node.label.slice(0, 28);
1246
+ g.append(c, t);
1247
+ g.addEventListener('click', () => selectMemoryNode(node.id));
1248
+ g.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); selectMemoryNode(node.id); } });
1249
+ svg.append(g);
1250
+ }
1251
+ const list = el('ul', undefined, 'memory-edges');
1252
+ for (const e of graph.edges) list.append(el('li', e.from + ' ' + e.rel + ' ' + e.to));
1253
+ target.append(svg, list);
1254
+ });
1255
+ update('memory-detail', memory.detail || null, (target) => {
1256
+ const d = memory.detail; if (!d) return;
1257
+ const box = el('div', undefined, 'row');
1258
+ box.append(el('div', d.kind + ', ' + d.tier + '-term' + (d.expiresAt ? ' until ' + time(d.expiresAt) : '') + ', ' + d.scope, 'label'));
1259
+ 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'));
1260
+ if (d.supersededBy) box.append(el('div', 'superseded by ' + d.supersededBy.slice(0, 8), 'meta'));
1261
+ if (d.promotedFrom) box.append(el('div', 'promoted from ' + d.promotedFrom.slice(0, 8), 'meta'));
1262
+ if (d.about.length) box.append(el('div', 'about: ' + d.about.join(', '), 'meta'));
1263
+ box.append(el('div', (d.title ? d.title + '\n' : '') + d.text, 'body'));
1264
+ target.append(box);
1265
+ });
1266
+ }
1267
+ 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(); });
1268
+ $('memory-hops').addEventListener('change', () => { memoryQuery.hops = Number($('memory-hops').value); rerenderMemory(); });
1269
+ let memorySearch;
1270
+ $('memory-q').addEventListener('input', () => { clearTimeout(memorySearch); memorySearch = setTimeout(() => { memoryQuery.q = $('memory-q').value.trim(); rerenderMemory(); }, 300); });
494
1271
  function renderStreamChips() {
495
1272
  const group = $('stream-filters');
496
1273
  if (group.children.length) return;
@@ -506,9 +1283,29 @@ function renderStreamChips() {
506
1283
  group.append(chip);
507
1284
  }
508
1285
  }
1286
+ $('planning-task').addEventListener('change',loadPlanning);$('planning-reload').addEventListener('click',loadPlanning);$('release-edit').addEventListener('change',loadReleasePlanning);$('release-reload').addEventListener('click',loadReleasePlanning);
1287
+ $('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);
1288
+ 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};
1289
+ await action({action:'planning',id:planningEdit.id,revision:planningEdit.revision,fields,reason:$('planning-reason').value},event.submitter);
1290
+ });
1291
+ $('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)});
1292
+ $('planning-undo').addEventListener('click',async()=>{if(planningEdit && sameBoardContext(planningEdit.context))await action({action:'planning_undo',id:planningEdit.id,revision:planningEdit.revision},$('planning-undo'))});
1293
+ $('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)});
1294
+ $('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)});
1295
+ $('task-release-filter').addEventListener('change',()=>{if(boardViewState)renderBoard(boardViewState.snapshot,boardViewState.peers,boardViewState.ids)});
1296
+ $('task-peer-filter').addEventListener('change',()=>{if(boardViewState)renderBoard(boardViewState.snapshot,boardViewState.peers,boardViewState.ids)});
1297
+ $('task-lanes').addEventListener('change',()=>{if(boardViewState)renderBoard(boardViewState.snapshot,boardViewState.peers,boardViewState.ids)});
1298
+ for (const [id, operation] of [['delivery-queue','sdlc_queue'],['delivery-release','sdlc_release']]) $(id).addEventListener('click', async () => {
1299
+ const repo = $('delivery-repo').value.trim();
1300
+ const liveIssues = $('delivery-live').value.trim() ? $('delivery-live').value.split(/[, ]+/).filter(Boolean).map(Number) : [];
1301
+ 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; }
1302
+ const report = await action({action:operation,repo,liveIssues}, $(id));
1303
+ if (report) $('delivery-report').textContent = JSON.stringify(report, null, 2);
1304
+ });
509
1305
  $('task-filter').addEventListener('change', () => { if (!stopped && snapshotValid && lastSnapshot) render(lastSnapshot); });
1306
+ window.addEventListener('resize', () => { if (!stopped && officeView.get() !== 'off' && snapshotValid && lastSnapshot) renderOffice(lastSnapshot); });
510
1307
  $('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 = ''; });
511
- $('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 = ''; } });
1308
+ $('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 = ''; } });
512
1309
  (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'; } } })();
513
1310
  </script>
514
1311
  </body>