@staix/agent-hub 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -3
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +2 -1
  5. package/docs/agent-notes/bus.md +1 -0
  6. package/docs/agent-notes/daemon.md +4 -1
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +7 -3
  9. package/docs/agent-notes/tests.md +3 -0
  10. package/docs/design/adapters.md +56 -0
  11. package/docs/design/agent-hub-architecture.md +12 -0
  12. package/docs/design/bus.md +8 -0
  13. package/docs/design/task-board.md +19 -1
  14. package/docs/dogfood.md +13 -7
  15. package/docs/operations.md +177 -20
  16. package/docs/quickstart.md +18 -3
  17. package/docs/sdlc.md +2 -2
  18. package/docs/security.md +45 -4
  19. package/docs/smoke.md +13 -2
  20. package/docs/specs/2026-10-12-office-view-design.md +86 -3
  21. package/docs/verification/2026-11-03-0.29.0.md +59 -0
  22. package/docs/verification/2026-11-03-0.30.0.md +54 -0
  23. package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
  24. package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
  25. package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
  26. package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
  27. package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
  28. package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
  29. package/docs/verification/2026-11-03-live-board-latency.json +556 -0
  30. package/docs/verification/2026-11-03-live-board-latency.md +64 -0
  31. package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
  32. package/docs/verification/2026-11-03-pi-title-api.md +26 -0
  33. package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
  34. package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
  35. package/docs/verification/dogfood/2026-11-03.md +3095 -0
  36. package/docs/verification/pi-150-model-free.ts +57 -0
  37. package/docs/verification/pi-150-native.mjs +21 -0
  38. package/docs/verification/pi-150-title-api.mjs +31 -0
  39. package/docs/verified.json +14 -14
  40. package/package.json +2 -2
  41. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  42. package/plugins/agent-hub/server.js +715 -45
  43. package/src/adapters/compat.ts +10 -10
  44. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  45. package/src/adapters/local-worker.ts +22 -6
  46. package/src/adapters/pi.ts +92 -10
  47. package/src/adapters/registry-types.ts +4 -0
  48. package/src/adapters/registry.ts +2 -0
  49. package/src/cli/autonomy.ts +66 -1
  50. package/src/cli/console-state.ts +6 -2
  51. package/src/cli/help.ts +3 -1
  52. package/src/cli/identity.ts +8 -1
  53. package/src/cli/main.ts +39 -0
  54. package/src/cli/output.ts +5 -0
  55. package/src/cli/recovery-package.ts +9 -0
  56. package/src/cli/worktree-tidy.ts +158 -0
  57. package/src/cli/worktrees-output.ts +43 -0
  58. package/src/hub/board.ts +31 -5
  59. package/src/hub/bus.ts +4 -4
  60. package/src/hub/conductor.ts +4 -3
  61. package/src/hub/config-apply.ts +2 -1
  62. package/src/hub/control-client.ts +16 -1
  63. package/src/hub/control-write-trace.ts +409 -0
  64. package/src/hub/daemon.ts +408 -66
  65. package/src/hub/dogfood-read.ts +62 -9
  66. package/src/hub/hub-tools.ts +12 -6
  67. package/src/hub/manager.ts +9 -1
  68. package/src/hub/peer-doing.ts +33 -3
  69. package/src/hub/peers.ts +7 -0
  70. package/src/hub/routing.ts +9 -2
  71. package/src/hub/runtime-capture-prune.ts +4 -1
  72. package/src/hub/sdlc-release-authorization.ts +13 -4
  73. package/src/hub/sdlc-release.ts +23 -10
  74. package/src/hub/sdlc.ts +15 -2
  75. package/src/hub/settings.ts +3 -0
  76. package/src/hub/task-worktree.ts +331 -0
  77. package/src/hub/tasks.ts +192 -25
  78. package/src/hub/ui.ts +2 -0
  79. package/src/hub/worktree-prs.ts +100 -0
  80. package/src/hub/worktree-shelf.ts +246 -0
  81. package/src/local/deny.ts +49 -0
  82. package/src/local/tools.ts +4 -2
  83. package/src/memory/brief.ts +5 -0
  84. package/src/memory/capture.ts +1 -1
  85. package/src/memory/client.ts +5 -2
  86. package/src/pi/extension.ts +43 -3
  87. package/src/ui/index.html +133 -10
  88. package/src/ui/office-model.ts +64 -8
  89. package/src/ui/office-space.ts +1 -0
  90. package/src/ui/plugin.ts +14 -3
package/src/ui/index.html CHANGED
@@ -257,6 +257,7 @@ details.settings-group .row:last-child{border-bottom:0}
257
257
 
258
258
  .office-scene{display:block;max-width:100%;height:auto;margin-inline:auto;font-family:system-ui,sans-serif;font-size:1px}.office-scene :is(rect,path,line,polygon,circle){vector-effect:non-scaling-stroke;stroke-width:1px}.office-room{fill:var(--panel-soft);stroke:var(--line)}.office-room-title{fill:var(--ink);font-size:1.083333px;font-weight:600}.office-room-count{fill:var(--ink);font-size:1px;font-family:ui-monospace,monospace;font-variant-numeric:tabular-nums}.office-header-surface{fill:var(--panel);stroke:var(--line)}.office-count-pill{fill:var(--panel);stroke:var(--line)}.office-desk-surface{fill:var(--panel);stroke:var(--line)}.office-desk.office-dimmed .office-avatar{opacity:.55}.office-name{fill:var(--ink);font-size:1.083333px;font-weight:600}.office-state,.office-meta,.office-empty{fill:var(--muted);font-size:1px}.office-glyph,.office-hand{font-size:1px}.office-idle .office-glyph{fill:var(--green)}.office-busy .office-glyph{fill:var(--amber)}.office-offline .office-glyph,.office-unknown .office-glyph{fill:var(--muted)}.office-paused .office-glyph{fill:var(--amber)}.office-waiting .office-glyph{fill:var(--red)}.office-hand{fill:var(--red)}.office-bubble{fill:var(--panel);stroke:var(--blue)}.office-bubble-text{fill:var(--ink);font-size:1px}.office-mode{fill:var(--muted);font-size:1px;font-family:ui-monospace,monospace}.office-card{fill:var(--control);stroke:var(--control-border)}.office-card-text{fill:var(--ink);font-size:1px}.office-card-id{font-family:ui-monospace,monospace}.office-card-accent{fill:var(--line)}.office-waiting-card .office-card-accent{fill:var(--waiting)}.office-rework-card .office-card-accent{fill:var(--red)}.office-card-checking .office-card-accent{fill:var(--blue)}.office-more{fill:var(--blue);font-size:1px}.office-scene .office-spotlight .office-desk-surface,.office-scene .office-spotlight .office-card,.office-scene [role="button"]:focus-visible > .office-card{stroke:var(--focus);stroke-width:2px}.office-card-hit{fill:transparent;stroke:none;pointer-events:all}.office-card-focus:focus-visible{outline:none}.row.spotlight{background:var(--hover)}
259
259
 
260
+ .office-plan-shape{fill:none;stroke:var(--muted)}.office-reserved{fill:var(--peer-color);stroke:none}.office-dock-progress{fill:var(--blue);stroke:none}.office-release-dock:focus-visible .office-room{stroke:var(--focus);stroke-width:2px}
260
261
  .office-movable{transition:transform .8s ease}.office-arc .office-arc-path{fill:none;stroke:var(--blue);stroke-width:1px}.office-arc .office-arc-head{fill:var(--blue)}.office-arc-important .office-arc-path{stroke:var(--amber)}.office-arc-important .office-arc-head{fill:var(--amber)}.office-arc-dropped .office-arc-path{stroke:var(--red);stroke-dasharray:.6 .4}.office-arc-dropped .office-arc-head{fill:var(--red)}.office-arc-label{fill:var(--muted);font-size:1px}.office-alert{fill:var(--red);font-size:1px}.office-latch{fill:var(--amber);font-size:1px}.office-link-line{fill:none;stroke-width:1px;stroke-dasharray:.8 .5}.office-link-overlap .office-link-line{stroke:var(--amber)}.office-link-conflict .office-link-line{stroke:var(--red)}.office-link-cohort .office-link-line{stroke:var(--blue)}.office-link-label{font-size:1px;fill:var(--muted)}.office-link-conflict .office-link-label{fill:var(--red)}.office-link-overlap .office-link-label{fill:var(--amber)}.office-link-cohort .office-link-label{fill:var(--blue)}.office-checking{fill:var(--blue);font-size:.916667px}
261
262
  .office-avatar{fill:var(--muted)}.office-idle .office-avatar{fill:var(--dot-ok)}.office-busy .office-avatar{fill:var(--dot-warn)}.office-offline .office-avatar{fill:var(--dot)}.office-paused .office-avatar{fill:var(--dot-warn)}.office-waiting .office-avatar{fill:var(--amber)}.office-waiting .office-desk-surface{stroke:var(--amber)}.office-glyph-shape{fill:none;stroke-width:1px}.office-idle .office-glyph-shape{stroke:var(--dot-ok)}.office-busy .office-glyph-shape{stroke:var(--dot-warn);fill:var(--dot-warn)}.office-paused .office-glyph-shape{stroke:var(--dot-warn);fill:var(--dot-warn)}.office-offline .office-glyph-shape{stroke:var(--dot)}.office-handshape{fill:none;stroke:var(--dot-err);stroke-width:1px}.office-desk-leg{fill:var(--panel-soft);stroke:var(--line);stroke-width:1px}.office-nameplate{fill:var(--control);stroke:var(--control-border);stroke-width:1px}.office-room-wire{fill:none;stroke:var(--line);stroke-width:1px}.office-divider{fill:none;stroke:var(--line);stroke-width:1px}.office-shelf{fill:var(--panel-soft);stroke:var(--line);stroke-width:1px}.office-shelf-card{fill:var(--green-soft);stroke:var(--green);stroke-width:1px}.office-box-top{fill:var(--control);stroke:var(--control-border);stroke-width:1px}.office-box-left{fill:var(--panel-soft);stroke:var(--line);stroke-width:1px}.office-box-right{fill:var(--panel);stroke:var(--line);stroke-width:1px}.office-busy .office-glyph,.office-hand{animation:office-pulse 1.2s ease-in-out infinite}.office-appear{animation:office-appear .5s ease-out}.office-leaving{pointer-events:none;animation:office-fade .4s linear forwards}.office-fadeout{animation-name:office-fade;animation-duration:1s;animation-timing-function:linear;animation-fill-mode:forwards}.office-settle .office-movable{transition:none}.office-settle .office-appear{animation:none}
262
263
  @keyframes office-pulse{50%{opacity:.45}}@keyframes office-appear{from{opacity:0}}@keyframes office-fade{to{opacity:0}}
@@ -498,12 +499,14 @@ main[data-stale]>:not(#notice):not(#disconnected){opacity:.6;transition:opacity
498
499
  </div>
499
500
  </div>
500
501
  </section>
502
+ <section id="worktrees-section" aria-labelledby="worktrees-title" data-view="agents" hidden><div class="section-head"><h2 id="worktrees-title">Worktrees</h2><span id="worktrees-count" class="meta"></span></div><div id="worktrees"></div></section>
501
503
  <section aria-labelledby="bench-title" data-view="agents" hidden><div class="section-head"><h2 id="bench-title">Benchmarks</h2><span id="bench-count" class="meta"></span></div><div id="bench"></div></section>
502
504
  </div>
503
505
  </main>
504
506
  </div>
505
507
  </div>
506
508
  <dialog id="plan-dialog" aria-labelledby="plan-title"><div class="dialog-head"><h2 id="plan-title">Plan</h2><button type="button" id="plan-close" aria-label="Close plan sheet"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-close"/></svg></button></div>
509
+ <section class="plan-block" aria-labelledby="plan-h-intake"><h3 id="plan-h-intake">Capture a request</h3><form id="intake"><div class="field"><label for="intake-text">What should the team do?</label><textarea id="intake-text" required maxlength="7000" aria-describedby="intake-help intake-result"></textarea></div><div class="field"><label for="intake-sources">Source IDs (optional)</label><input id="intake-sources" maxlength="400" aria-describedby="intake-help intake-result"></div><p id="intake-help" class="meta">Creates draft Backlog cards, held until approval. Use registered src: IDs separated by spaces or commas. Approve or discard with ahub intake in a terminal.</p><button id="intake-submit" class="primary" data-mutation="project" type="submit">Create draft</button><p id="intake-result" class="meta" role="status" aria-live="polite" tabindex="-1"></p></form><div class="field"><h4>Registered sources (read-only)</h4><div id="sources" class="content"><p class="empty">Waiting for a project snapshot.</p></div></div></section>
507
510
  <section class="plan-block" aria-labelledby="plan-h-plan"><h3 id="plan-h-plan">Plan a task</h3><form id="planning"><div class="field"><label for="planning-task">Task</label><select id="planning-task"><option value="">Select task</option></select></div><div class="split"><label>Priority<select id="planning-priority"><option value="">Default</option><option value="0">P0</option><option value="1">P1</option><option value="2">P2</option><option value="3">P3</option></select></label><label>Order<input id="planning-rank" type="number" min="0" step="1"></label><label>Release<select id="planning-release"><option value="">No release</option></select></label><label>Planned start<input id="planning-start" type="datetime-local" step="any"></label><label>Due<input id="planning-due" type="datetime-local" step="any"></label></div><label><input id="planning-pinned" type="checkbox">Pin against planner changes</label><label>Reason<input id="planning-reason" maxlength="1000" required></label><div class="actions"><button id="planning-save" type="submit" data-mutation="project">Save plan</button><button id="planning-undo" type="button" data-mutation="project">Undo latest plan</button><button id="planning-reload" type="button">Reload current values</button></div><p id="planning-version" class="meta"></p></form></section><section id="split-panel" class="plan-block" aria-labelledby="plan-h-split" hidden><h3 id="plan-h-split">Split the selected backlog task</h3><form id="split-backlog"><label>Part titles, one per line<textarea id="split-titles" maxlength="2408" required></textarea></label><label><input id="split-sequential" type="checkbox">Each part waits for the previous part</label><label>Reason<input id="split-reason" maxlength="1000" required></label><p class="meta">Two to eight parts. The original task retains integration and acceptance. Unused parts can be undone from its current plan.</p><button id="split-save" type="submit" data-mutation="project">Stage parts</button></form></section><section class="plan-block" aria-labelledby="plan-h-releases"><h3 id="plan-h-releases">Releases</h3><div id="releases" class="content"></div><form id="release-new"><div class="split"><label>Name<input id="release-name" maxlength="64" required></label><label>Order<input id="release-order" type="number" min="0" value="0" step="1" required></label></div><label>Goal<input id="release-goal" maxlength="1000" required></label><button type="submit" data-mutation="project">Create release</button></form><form id="release-plan"><label>Release<select id="release-edit"><option value="">Select release</option></select></label><div class="split"><label>Planned start<input id="release-start" type="datetime-local" step="any"></label><label>Cut<input id="release-cut" type="datetime-local" step="any"></label><label>Ship<input id="release-ship" type="datetime-local" step="any"></label></div><button id="release-save" type="submit" data-mutation="project">Save dates</button><button id="release-reload" type="button">Reload current release</button><p id="release-version" class="meta"></p></form></section><section id="delivery" class="plan-block" aria-labelledby="plan-h-delivery"><h3 id="plan-h-delivery">Delivery evidence (read-only)</h3><div class="field"><label for="delivery-repo">GitHub repository</label><input id="delivery-repo" maxlength="200" placeholder="owner/repository"></div><div class="field"><label for="delivery-live">Live verification issue IDs (optional)</label><input id="delivery-live" maxlength="300" placeholder="Comma-separated issue numbers"></div><div class="actions"><button id="delivery-queue" type="button">Read merge queue</button><button id="delivery-release" type="button">Read release checklist</button></div><pre id="delivery-report" class="body" aria-live="polite"></pre></section><section class="plan-block" aria-labelledby="plan-h-propose"><h3 id="plan-h-propose">Propose a task</h3><form id="propose"><div class="field"><label for="title">Title</label><input id="title" name="title" required maxlength="300" placeholder="What should the team do?"></div><div class="field"><label for="detail">Details (optional)</label><textarea id="detail" name="detail" maxlength="8000"></textarea></div><div class="split"><div class="field"><label for="class">Class</label><select id="class" name="class"><option>implement</option><option>plan</option><option>bulk_edit</option><option>test</option><option>review</option><option>summarize</option><option>triage</option></select></div><div class="field"><label for="owner">Owner</label><select id="owner" name="owner"><option value="">Automatic</option></select></div></div><label><input id="backlog-proposal" type="checkbox"> Add to Backlog without offering</label><button class="primary" data-mutation="project" type="submit">Propose task</button></form></section>
508
511
  </dialog>
509
512
  <dialog id="task-detail-dialog" aria-labelledby="task-detail-title"><div class="dialog-head"><h2 id="task-detail-title">Task details</h2><button id="task-detail-close" type="button" autofocus>Close details</button></div><div id="task-detail-content"></div></dialog>
@@ -675,6 +678,7 @@ function saveDraft() {
675
678
  const previous = drafts.get(selectedProjectId) || {};
676
679
  drafts.set(selectedProjectId, {message:$('message').value, recipient:draftChoicesPending ? previous.recipient || '' : $('recipient').value,
677
680
  ...($('backlog-proposal')?.checked ? {backlog:true} : {}), title:$('title').value, detail:$('detail').value, class:$('class').value, owner:draftChoicesPending ? previous.owner || '' : $('owner').value});
681
+ drafts.get(selectedProjectId).intake = $('intake-text').value; drafts.get(selectedProjectId).intakeSources = $('intake-sources').value;
678
682
  }
679
683
  function restoreDraft(choices = false) {
680
684
  const draft = drafts.get(selectedProjectId) || {};
@@ -682,6 +686,7 @@ function restoreDraft(choices = false) {
682
686
  if (choices) {
683
687
  for (const id of ['recipient','owner']) $(id).value = Array.from($(id).options).some((o) => o.value === draft[id]) ? draft[id] : '';
684
688
  } else { $('message').value = draft.message || ''; $('title').value = draft.title || ''; $('detail').value = draft.detail || ''; $('class').value = draft.class || 'implement'; }
689
+ if (!choices) { $('intake-text').value = draft.intake || ''; $('intake-sources').value = draft.intakeSources || ''; }
685
690
  }
686
691
  function syncMutationControls() { document.querySelectorAll('button[data-mutation="project"]').forEach((b) => { b.disabled = pendingButtons.has(b) || Boolean(b.dataset.startKey && pendingStarts.has(b.dataset.startKey)) || (managerMode && !snapshotValid); }); }
687
692
  // The first-load blocks the page ships in #tasks and #peers (#296): a project switch puts copies of them back and update() replaces them
@@ -703,9 +708,10 @@ function resetView(id, instance) {
703
708
  $('split-titles').value='';$('split-reason').value='';$('split-sequential').checked=false;
704
709
  $('release-name').value='';$('release-goal').value='';$('release-order').value='0';
705
710
  $('delivery-report').textContent = ''; $('delivery-repo').value = ''; $('delivery-live').value = '';
711
+ $('intake-result').textContent = ''; $('intake-text').setAttribute('aria-invalid', 'false'); $('intake-sources').setAttribute('aria-invalid', 'false');
706
712
  // A project with a running instance is polled next, so the board and the peers show loading; "No project selected" is for no project.
707
713
  const loading = Boolean(id && instance) && typeof firstLoad !== 'undefined';
708
- for (const [id, text] of [['memory-list','No project selected.'],['memory-graph',''],['memory-detail',''],['peers','No project selected.'],['tasks','No project selected.'],['task-progress','No project selected.'],['permissions','No project selected.'],['budget','No project selected.'],['settings','No project selected.']]) {
714
+ for (const [id, text] of [['memory-list','No project selected.'],['memory-graph',''],['memory-detail',''],['peers','No project selected.'],['tasks','No project selected.'],['task-progress','No project selected.'],['permissions','No project selected.'],['budget','No project selected.'],['settings','No project selected.'],['sources','No project selected.']]) {
709
715
  $(id).replaceChildren();
710
716
  if (loading && firstLoad[id]?.length) $(id).replaceChildren(...firstLoad[id].map((node) => node.cloneNode(true)));
711
717
  else if (text) empty($(id), text);
@@ -1457,6 +1463,44 @@ function renderBoard(snapshot, peers, ids) {
1457
1463
  });
1458
1464
  refreshTaskDetail();
1459
1465
  }
1466
+ function renderSources(sources) {
1467
+ update('sources', sources ?? null, target => {
1468
+ if (!Array.isArray(sources)) return empty(target, 'Source list unavailable for this project snapshot.');
1469
+ if (!sources.length) return empty(target, 'No registered sources. Register a file or URL with ahub source add.');
1470
+ for (const source of sources) {
1471
+ const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
1472
+ head.append(el('code', source.id, 'label'), el('span', source.kind, 'meta'));
1473
+ const registered = Number.isSafeInteger(source.added_at) ? new Date(source.added_at).toLocaleString() : 'unknown';
1474
+ row.append(head, el('p', source.bytes + ' bytes · hash ' + source.hashPrefix + ' · registered ' + registered, 'meta'));
1475
+ target.append(row);
1476
+ }
1477
+ });
1478
+ }
1479
+ function intakeFeedback(text, error = false) {
1480
+ const result = $('intake-result'); result.textContent = text; result.classList.toggle('error', error);
1481
+ }
1482
+ async function submitIntake(event) {
1483
+ event.preventDefault();
1484
+ const submit = event.submitter || $('intake-submit');
1485
+ if (pendingButtons.has(submit) || stopped) return;
1486
+ const text = $('intake-text').value.trim(), sourceText = $('intake-sources').value.trim();
1487
+ const sources = sourceText ? sourceText.split(/[\s,]+/).filter(Boolean) : [];
1488
+ $('intake-text').setAttribute('aria-invalid', 'false'); $('intake-sources').setAttribute('aria-invalid', 'false');
1489
+ const invalid = !text || text.length > 7000 ? 'intake-text' : sources.length > 16 || new Set(sources).size !== sources.length || sources.some(id => !/^src:[0-9a-f]{16}$/.test(id)) ? 'intake-sources' : '';
1490
+ if (invalid) {
1491
+ intakeFeedback(invalid === 'intake-text' ? 'Enter a request of 1 to 7000 characters.' : 'Use at most 16 distinct registered src: IDs; run ahub source list to check them.', true);
1492
+ $(invalid).setAttribute('aria-invalid', 'true'); $(invalid).focus(); return;
1493
+ }
1494
+ const captured = context();
1495
+ intakeFeedback('Creating draft. Waiting for the hub result...');
1496
+ const accepted = await action({action:'intake', text, sources}, submit);
1497
+ const current = context();
1498
+ if (stopped || captured.generation !== current.generation || captured.projectId !== current.projectId || captured.instanceId !== current.instanceId) return;
1499
+ if (accepted) {
1500
+ if ($('intake-text').value.trim() === text && $('intake-sources').value.trim() === sourceText) { $('intake-text').value = ''; $('intake-sources').value = ''; }
1501
+ intakeFeedback('Draft saved to Backlog. Inspect it there; approve or discard with ahub intake in a terminal.');
1502
+ } else intakeFeedback('Intake was not confirmed. Read the notice and check Backlog before submitting again.', true);
1503
+ }
1460
1504
  const attentionBaseTitle = typeof document === 'undefined' ? 'agent-hub | Local dashboard' : document.title;
1461
1505
  let attentionSignature = '', attentionFirst;
1462
1506
  function reachAttention() {
@@ -1745,7 +1789,7 @@ function render(snapshot) {
1745
1789
  const current = (snapshot.boardLegend || []).find(item => item.peer === id)?.activeTasks, summary = el('p', undefined, 'peer-summary');
1746
1790
  summary.append(el('span', peer.queued + ' queued' + (peer.queuedImportant ? ' (' + peer.queuedImportant + ' important)' : ''), 'meta'), el('span', Array.isArray(current) && current.length ? 'Working on ' + current.map(task => '#' + task).join(', ') : 'No active task', 'meta'));
1747
1791
  // #878: the session title or current plan item the hub observed, already screened (a stub while private).
1748
- if (typeof peer.doing?.text === 'string' && peer.doing.text) summary.append(el('span', 'Doing: ' + peer.doing.text, 'meta'));
1792
+ if (typeof peer.doing?.text === 'string' && peer.doing.text) summary.append(el('span', (peer.doing.source === 'label' ? 'Unnamed session: ' : 'Doing: ') + peer.doing.text, 'meta'));
1749
1793
  row.append(head, summary);
1750
1794
  // A status notice asks a person to act (for example, reattach a native session): it stays visible, never folded away.
1751
1795
  if (typeof peer.statusNotice?.message === 'string' && peer.statusNotice.message) row.append(el('p', peer.statusNotice.message, 'meta notice-warn'));
@@ -1783,6 +1827,7 @@ function render(snapshot) {
1783
1827
  const peerSignature = JSON.stringify([ids, assignable]);
1784
1828
  if (signatures.get('peer-options') !== peerSignature) { signatures.set('peer-options', peerSignature); peerOptions($('owner'), assignable, 'Automatic'); peerOptions($('recipient'), ids, 'All peers'); if (draftChoicesPending) { restoreDraft(true); draftChoicesPending = false; } }
1785
1829
  renderBoard(snapshot, peers, ids);
1830
+ renderSources(snapshot.sources);
1786
1831
  renderApprovals(snapshot.permissions);
1787
1832
  update('budget', snapshot.budget, (target) => {
1788
1833
  if (!Object.keys(snapshot.budget).length) return empty(target, 'No usage readings yet. Usage appears when a peer reports it.');
@@ -1810,6 +1855,7 @@ function render(snapshot) {
1810
1855
  }
1811
1856
  });
1812
1857
  renderBench(snapshot.bench);
1858
+ renderWorktrees(snapshot.worktrees);
1813
1859
  renderMemory(snapshot.memory);
1814
1860
  renderSettings(snapshot.settings, snapshot.roster);
1815
1861
  renderUsage(snapshot.usage, snapshot.tasks, snapshot.budget);
@@ -1879,7 +1925,7 @@ function officeTile(w, h, cls) {
1879
1925
  }
1880
1926
  function officeViewBox(w, h, model) {
1881
1927
  if (officeProjection !== 'iso') return '0 0 ' + w + ' ' + h;
1882
- const corners = model ? [...model.rooms, ...model.desks].flatMap(tile => [officeProject(tile.x, tile.y), officeProject(tile.x + tile.w, tile.y), officeProject(tile.x, tile.y + tile.h), officeProject(tile.x + tile.w, tile.y + tile.h)].map(([x, y]) => [x, y + (model.rooms.includes(tile) ? officeRoomOffset : 0)])) : [officeProject(0, 0), officeProject(w, 0), officeProject(0, h), officeProject(w, h)];
1928
+ const corners = model ? [...model.rooms, ...(model.docks || []), ...model.desks].flatMap(tile => [officeProject(tile.x, tile.y), officeProject(tile.x + tile.w, tile.y), officeProject(tile.x, tile.y + tile.h), officeProject(tile.x + tile.w, tile.y + tile.h)].map(([x, y]) => [x, y + (model.desks.includes(tile) ? 0 : officeRoomOffset)])) : [officeProject(0, 0), officeProject(w, 0), officeProject(0, h), officeProject(w, h)];
1883
1929
  const xs = corners.map((p) => p[0]), ys = corners.map((p) => p[1]), m = 3;
1884
1930
  const minX = Math.min(...xs), minY = Math.min(...ys);
1885
1931
  return (minX - m) + ' ' + (minY - 6) + ' ' + (Math.max(...xs) - minX + 2 * m) + ' ' + (Math.max(...ys) - minY + 9);
@@ -1934,16 +1980,16 @@ function officeClip(text, max) {
1934
1980
  }
1935
1981
  let officeClipSerial = 0;
1936
1982
  let officeMeasureContext;
1937
- function officeTextWidth(value, monospace) {
1983
+ function officeTextWidth(value, monospace, size = OFFICE_SCALE, weight = '') {
1938
1984
  if (officeMeasureContext === undefined) {
1939
1985
  try { officeMeasureContext = document.createElement('canvas').getContext?.('2d') || null; } catch { officeMeasureContext = null; }
1940
1986
  }
1941
1987
  if (officeMeasureContext) {
1942
- officeMeasureContext.font = OFFICE_SCALE + 'px ' + (monospace ? 'ui-monospace,monospace' : 'system-ui,sans-serif');
1988
+ officeMeasureContext.font = (weight ? weight + ' ' : '') + size + 'px ' + (monospace ? 'ui-monospace,monospace' : 'system-ui,sans-serif');
1943
1989
  return officeMeasureContext.measureText(value).width;
1944
1990
  }
1945
1991
  // Non-browser fixtures or unavailable canvas: conservative glyph widths; clipPath is the hard boundary.
1946
- return [...value].reduce((width, char) => width + (!/^[ -~]$/.test(char) ? 1 : monospace ? 0.6 : /[MW@#%]/.test(char) ? 0.9 : /[ilI., ']/.test(char) ? 0.3 : 0.55), 0) * OFFICE_SCALE;
1992
+ return [...value].reduce((width, char) => width + (!/^[ -~]$/.test(char) ? 1 : monospace ? 0.6 : /[MW@#%]/.test(char) ? 0.9 : /[ilI., ']/.test(char) ? 0.3 : 0.55), 0) * size;
1947
1993
  }
1948
1994
  function officeCardLabel(card, width) {
1949
1995
  const prefix = '#' + card.id, rest = String(card.label).slice(prefix.length);
@@ -1990,6 +2036,7 @@ function spotlightRow(row) { if (spotlitRow && spotlitRow !== row) spotlitRow.cl
1990
2036
  function activateOfficePeer(id) { switchView('agents'); const row = peerRows.get(id); if (!row) return; spotlightRow(row); row.scrollIntoView({ block: 'nearest' }); row.focus?.(); }
1991
2037
  function activateOfficeTask(id) { setColumnFocus(null); if (!taskRows.has(id)) officeFilter(''); const row = taskRows.get(id); if (!row) return; spotlightRow(row); row.scrollIntoView({ block: 'nearest' }); openTaskDetail(id); }
1992
2038
  function officeFilter(state) { $('task-filter').value = state; $('task-peer-filter').value = ''; $('task-release-filter').value = ''; if (!stopped && snapshotValid && lastSnapshot) render(lastSnapshot); }
2039
+ function officeRelease(id) { setColumnFocus(null); $('task-filter').value = ''; $('task-peer-filter').value = ''; $('task-release-filter').value = id == null ? '' : String(id); if(id == null)$('task-lanes').value = 'releases'; if (!stopped && snapshotValid && lastSnapshot) render(lastSnapshot); switchView('board'); $('tasks').scrollIntoView({block:'nearest'}); }
1993
2040
  // Fade timing for a transient mark: it holds, then fades over the final second so it is gone at `until`.
1994
2041
  // Applied once at creation; later renders leave the running animation alone.
1995
2042
  function officeFade(node, until, now) {
@@ -2006,16 +2053,25 @@ function officeFade(node, until, now) {
2006
2053
  function officeCardPaint(node, card) {
2007
2054
  node.setAttribute('class', 'office-card-node office-movable' + (card.waiting ? ' office-waiting-card' : '') + (card.rework ? ' office-rework-card' : '') + (card.checking ? ' office-card-checking' : ''));
2008
2055
  node.setAttribute('aria-label', card.name);
2009
- const innerWidth = Math.max(0.1, card.w - 1.6 - (card.checking ? 1 : 0));
2056
+ const innerWidth = Math.max(0.1, card.w - 1.6 - (card.checking ? 1 : 0) - (card.releaseTag ? 1.8 : 0));
2010
2057
  const label = officeCardLabel(card, innerWidth * OFFICE_SCALE), prefix = '#' + card.id, clipId = node.dataset.officeClipId || (node.dataset.officeClipId = 'office-card-clip-' + (++officeClipSerial));
2011
2058
  const clip = officeNode('clipPath', { id: clipId, clipPathUnits: 'userSpaceOnUse' });
2012
2059
  clip.append(officeNode('rect', { x: 0.8, y: 0, width: innerWidth, height: card.h }));
2013
2060
  const text = officeNode('text', { x: 0.8, y: card.h / 2 + 0.33, class: 'office-card-text', 'clip-path': 'url(#' + clipId + ')' });
2061
+ const marked = card.reserved || card.pinned || card.waiting && card.state === 'backlog';
2062
+ if(marked)text.setAttribute('y','0.95');
2014
2063
  text.append(officeNode('tspan', { class: 'office-card-id' }, prefix), officeNode('tspan', {}, label.slice(prefix.length)));
2015
2064
  const children = [officeNode('title', {}, card.label), clip,
2016
2065
  officeNode('rect', { x: 0, y: 0, width: card.w, height: card.h, rx: 0.5, class: 'office-card' }),
2017
2066
  officeNode('rect', { x: 0.15, y: 0.25, width: 0.2, height: Math.max(0.4, card.h - 0.5), rx: 0.1, class: 'office-card-accent' }), text];
2018
2067
  if (card.checking) children.push(officeIcon('i-check', card.w - 1.5, card.h / 2 - 0.46, 0.92, 'office-checking'));
2068
+ const marks=[]; let mx=0.8;
2069
+ const mark=(shape,label)=>{const g=officeNode('g',{});shape.setAttribute('transform','translate('+mx+',1.35)');g.append(shape,officeNode('text',{x:mx+0.85,y:2.05,class:'office-meta'},label));marks.push(g);mx+=0.85+officeTextWidth(label,false)/OFFICE_SCALE+0.6;};
2070
+ if(card.reserved){const key=Object.hasOwn(peerPalette,card.reserved)?card.reserved:'other';mark(officeNode('circle',{cx:0.3,cy:0.3,r:0.25,class:'office-reserved peer-'+key}),officeClip(card.reserved,8));}
2071
+ if(card.pinned)mark(officeNode('path',{d:'M0 0H.6L.45 .4H.15ZM.3 .4V.8',class:'office-plan-shape'}),'pin');
2072
+ if(card.waiting && card.state==='backlog')mark(officeNode('path',{d:'M.3 0 .65 .65H0Z',class:'office-plan-shape'}),'waits'+(card.plannedDate?' '+card.plannedDate:''));
2073
+ if(marks.length){const group=officeNode('g',{'clip-path':'url(#'+clipId+')',class:'office-card-marks'});group.append(...marks);children.push(group);}
2074
+ if(card.releaseTag)children.push(officeNode('text',{x:card.w-0.6,y:marked?0.95:card.h/2+0.33,'text-anchor':'end',class:'office-meta'},'R'+card.releaseTag.ordinal));
2019
2075
  node.replaceChildren(...children);
2020
2076
  }
2021
2077
  function officeDeskPaint(node, desk) {
@@ -2064,6 +2120,7 @@ function officeDeskPaint(node, desk) {
2064
2120
  if (desk.tray.needsReview) tray.push(desk.tray.needsReview + ' to review');
2065
2121
  if (desk.tray.heldBy) tray.push('held by ' + desk.tray.heldBy);
2066
2122
  if (tray.length) children.push(officeNode('text', { x: 1, y: line, class: 'office-meta' }, officeClip(tray.join(' / '), 38)));
2123
+ if(desk.planner){const badge=officeNode('g',{class:'office-planner','aria-label':'planner, '+desk.unplanned+' unplanned backlog cards'});badge.append(officeNode('path',{d:'M7 5H8V6H7ZM7.5 6V6.5',class:'office-plan-shape'}),officeNode('text',{x:8.4,y:6,class:'office-meta'},String(desk.unplanned)));children.push(badge);}
2067
2124
  node.replaceChildren(...children);
2068
2125
  }
2069
2126
  // More is a sibling of the desk button, so assistive technology exposes both targets.
@@ -2082,6 +2139,7 @@ function officeRoomHeader(node, room) {
2082
2139
  node.replaceChildren(...(iso ? [officeNode('rect', { x: -0.5, y: -2.55, width: 12.5, height: 2, rx: 0.5, class: 'office-header-surface' })] : []), officeNode('text', { x: iso ? 0 : 1, y, class: 'office-room-title' }, room.label),
2083
2140
  officeNode('rect', { x: right - 3, y: y - 1.35, width: 3, height: 2, rx: 0.5, class: 'office-count-pill' }),
2084
2141
  officeNode('text', { x: right - 1.5, y, 'text-anchor': 'middle', class: 'office-room-count' }, String(room.count)));
2142
+ if(room.unplanned!==undefined)node.append(officeNode('text',{x:iso?0:1,y:y+1.15,class:'office-meta'},room.unplanned+' unplanned'));
2085
2143
  }
2086
2144
  function officeRoomPaint(node, room) {
2087
2145
  node.setAttribute('class', 'office-roombox office-roombox-' + room.id);
@@ -2095,12 +2153,21 @@ function officeRoomPaint(node, room) {
2095
2153
  const at = iso ? officeIsoLocal(room.w / 2, room.h - 1.5) : [1, room.h - 1];
2096
2154
  const more = officeNode('g', { tabindex: '0', role: 'button', 'aria-label': 'Show ' + room.overflow + ' more ' + room.filterState.replaceAll('_', ' ') + ' tasks in the task list' });
2097
2155
  more.append(officeNode('text', { x: at[0], y: at[1], 'text-anchor': iso ? 'middle' : 'start', class: 'office-more' }, '+' + room.overflow + ' more'));
2098
- const open = () => officeFilter(room.filterState);
2156
+ const open = () => { setColumnFocus(null); officeFilter(room.filterState); switchView('board'); };
2099
2157
  more.addEventListener('click', open);
2100
2158
  more.addEventListener('keydown', (event) => officeKeys(event, open));
2101
2159
  node.append(more);
2102
2160
  }
2103
2161
  }
2162
+ function officeDockPaint(node,dock) {
2163
+ officePlace(node,dock.x,dock.y,true);node.setAttribute('class','office-release-dock');node.setAttribute('aria-label','Release '+dock.ordinal+': '+dock.name+', '+dock.state+', '+dock.approved+' / '+dock.scope+' approved'+(dock.cut?', cut '+dock.cut:''));
2164
+ const iso=officeProjection==='iso',at=iso?officeIsoLocal(dock.w/2,7):[1,5],x=at[0],y=at[1],align=iso?'middle':'start';
2165
+ const full='R'+dock.ordinal+' '+dock.name,width=dock.w-2,chars=[...full],measure=value=>officeTextWidth(value,false,13,'600');let title=full;
2166
+ if(measure(title)>width*OFFICE_SCALE){let low=0,high=chars.length;while(low<high){const mid=Math.ceil((low+high)/2);if(measure(chars.slice(0,mid).join('')+'...')<=width*OFFICE_SCALE)low=mid;else high=mid-1;}title=chars.slice(0,low).join('').trimEnd()+'...';}
2167
+ const clipId=node.dataset.officeDockClipId || (node.dataset.officeDockClipId='office-dock-clip-'+(++officeClipSerial)),clip=officeNode('clipPath',{id:clipId,clipPathUnits:'userSpaceOnUse'});clip.append(officeNode('rect',{x:iso?x-width/2:x,y:y-1.15,width,height:1.5}));
2168
+ node.replaceChildren(officeTile(dock.w,dock.h,'office-room'),officeNode('title',{},full),clip,officeNode('text',{x,y,'text-anchor':align,class:'office-room-title office-dock-title','clip-path':'url(#'+clipId+')'},title),officeNode('text',{x,y:y+1.5,'text-anchor':align,class:'office-meta'},dock.state+' · '+dock.approved+' / '+dock.scope),officeNode('rect',{x:iso?x-5:1,y:y+2.3,width:10,height:0.3,class:'office-count-pill'}),officeNode('rect',{x:iso?x-5:1,y:y+2.3,width:10*dock.ratio,height:0.3,class:'office-dock-progress'}));
2169
+ if(dock.cut)node.append(officeNode('text',{x,y:y+4,'text-anchor':align,class:'office-meta'},'Cut '+dock.cut));
2170
+ }
2104
2171
  let officeScene = null;
2105
2172
  function officeEnsure(target) {
2106
2173
  if (officeScene) return officeScene;
@@ -2163,6 +2230,11 @@ function drawOffice(target, model) {
2163
2230
  const visible = roomCards.get(original.id), room = { ...original, cards: visible, overflow: original.overflow + original.cards.length - visible.length };
2164
2231
  keep('room:' + room.id, JSON.stringify({ ...room, cards: undefined, visibleCount: room.cards.length }), scene.layers.rooms, (node) => officeRoomPaint(node, room), false);
2165
2232
  }
2233
+ for(const dock of model.docks || []){
2234
+ const {node,fresh}=keep('dock:'+dock.id,JSON.stringify(dock),scene.layers.rooms,n=>officeDockPaint(n,dock),false);
2235
+ if(fresh&&!node.getAttribute('tabindex')){node.setAttribute('tabindex','0');node.setAttribute('role','button');const open=()=>officeRelease(dock.id);node.addEventListener('click',open);node.addEventListener('keydown',event=>officeKeys(event,open));}
2236
+ }
2237
+ if(model.extraReleases>0 && model.docks.length){const dock=model.docks.at(-1);const {node,fresh}=keep('dock:more',JSON.stringify([dock,model.extraReleases]),scene.layers.headers,n=>{officePlace(n,dock.x,dock.y,true);n.setAttribute('aria-label','Show '+model.extraReleases+' more releases');const at=officeProjection==='iso'?officeIsoLocal(dock.w/2,dock.h-2):[1,dock.h-2];n.replaceChildren(officeNode('text',{x:at[0],y:at[1],class:'office-more'},'+'+model.extraReleases+' releases'));},false);if(fresh&&!node.getAttribute('tabindex')){node.setAttribute('tabindex','0');node.setAttribute('role','button');const open=()=>officeRelease(null);node.addEventListener('click',open);node.addEventListener('keydown',event=>officeKeys(event,open));}}
2166
2238
  const deskCards = new Map(model.desks.map(desk => [desk.id, officeDeskCards(desk)]));
2167
2239
  for (const original of model.desks) {
2168
2240
  const visible = deskCards.get(original.id), desk = { ...original, cards: visible, extraCards: original.extraCards + original.cards.length - visible.length };
@@ -2194,7 +2266,7 @@ function drawOffice(target, model) {
2194
2266
  };
2195
2267
  const paintNodes = [], focusNodes = [];
2196
2268
  for (const card of cards) {
2197
- const { node } = keep('paint:card:' + card.id, JSON.stringify([card.label, card.name, card.waiting, card.rework, card.checking, card.w, card.h]), scene.layers.cards, n => { officeCardPaint(n, card); n.setAttribute('aria-hidden', 'true'); });
2269
+ const { node } = keep('paint:card:' + card.id, JSON.stringify([card.label, card.name, card.waiting, card.rework, card.checking, card.reserved, card.pinned, card.plannedDate, card.releaseTag, card.w, card.h]), scene.layers.cards, n => { officeCardPaint(n, card); n.setAttribute('aria-hidden', 'true'); });
2198
2270
  paintNodes.push(node); placeCard(node, card);
2199
2271
  }
2200
2272
  for (const card of focusCards) {
@@ -2211,7 +2283,7 @@ function drawOffice(target, model) {
2211
2283
  focusNodes.push(node); placeCard(node, card);
2212
2284
  }
2213
2285
  officeOrderNodes(scene.layers.cards, paintNodes); officeOrderNodes(scene.layers.cardFocus, focusNodes);
2214
- for (const room of model.rooms) keep('header:' + room.id, JSON.stringify([room.x, room.y, room.w, room.count, room.label]), scene.layers.headers, node => officeRoomHeader(node, room), false);
2286
+ for (const room of model.rooms) keep('header:' + room.id, JSON.stringify([room.x, room.y, room.w, room.count, room.label,room.unplanned]), scene.layers.headers, node => officeRoomHeader(node, room), false);
2215
2287
  if (model.extraPeers > 0) {
2216
2288
  keep('extra:peers', String(model.extraPeers) + ':' + model.width, scene.layers.marks,
2217
2289
  (node) => { officePlace(node, model.width, 0); node.replaceChildren(officeNode('text', { x: 0, y: 1.8, 'text-anchor': 'end', class: 'office-meta' }, '+' + model.extraPeers + ' peers')); }, false);
@@ -2299,6 +2371,56 @@ let streamFilter = '';
2299
2371
  function applyStreamFilter(node) { node.hidden = streamFilter !== '' && node.dataset.kind !== streamFilter; }
2300
2372
  function refreshStreamEmpty() { const has = $('stream').querySelector('.event:not([hidden])') !== null, any = $('stream').querySelector('.event') !== null; $('stream-empty').hidden = has || !any; }
2301
2373
  // #251: benchmark runs on this machine and arm comparisons per suite. Every bar carries its number as text.
2374
+ /* #1057: the worktree shelf. A panel refresh reads nothing from GitHub: the rows come from the hub's cached facts, and only the explicit 'Refresh PR states' button asks it to read PR states. Removal is never a button: the panel shows the command to run in a terminal. */
2375
+ function renderWorktrees(shelf) {
2376
+ const live = shelf && Array.isArray(shelf.live) ? shelf.live : [], settled = shelf && Array.isArray(shelf.settled) ? shelf.settled : [];
2377
+ const known = Boolean(shelf) && !shelf.unavailable && !shelf.pending;
2378
+ $('worktrees-count').textContent = known ? live.length + ' live, ' + settled.length + ' settled' : '';
2379
+ update('worktrees', shelf === undefined ? null : shelf, (target) => {
2380
+ if (shelf === undefined) return empty(target, 'Worktrees are shown on a current snapshot.');
2381
+ if (shelf.pending) return empty(target, 'Reading worktrees...');
2382
+ if (shelf.unavailable) return empty(target, 'Worktrees unavailable: git could not list this project\'s worktrees, or the hub is restarting.');
2383
+ const reasonText = {merged: 'PR merged', closed: 'PR closed', quiet: 'quiet for ' + (shelf.quietDays || 0) + '+ days', parked: 'parked by you'};
2384
+ const group = (title, rows, isSettled) => {
2385
+ const block = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
2386
+ head.append(el('span', title + ' (' + rows.length + ')', 'label'));
2387
+ block.append(head);
2388
+ if (!rows.length) block.append(el('p', isSettled ? 'Nothing settled.' : 'No live worktrees.', 'meta'));
2389
+ for (const item of rows) {
2390
+ const line = el('div', undefined, 'row-head'), name = el('span', String(item.path), 'label');
2391
+ line.append(name);
2392
+ if (item.branch) line.append(el('span', String(item.branch), 'meta'));
2393
+ if (item.task !== undefined) line.append(badge('#' + item.task + (item.taskState ? ' ' + item.taskState : '')));
2394
+ if (item.pr !== undefined) line.append(badge('PR ' + item.pr));
2395
+ if (isSettled && item.reason) line.append(badge(reasonText[item.reason] || String(item.reason), item.reason === 'parked' ? '' : 'ok'));
2396
+ if (item.outside) line.append(badge('outside project'));
2397
+ // `rel` and `data-ts` take epoch milliseconds (refreshRelativeTimes reads Number(dataset.ts)), never an ISO string.
2398
+ const stamp = el('span'); if (item.lastActiveAt) { stamp.dataset.ts = String(item.lastActiveAt); stamp.title = time(item.lastActiveAt); stamp.textContent = 'active ' + rel(item.lastActiveAt); } else stamp.textContent = 'activity unknown'; line.append(stamp);
2399
+ // Park moves a live row to the shelf and Pin keeps a settled row live; both are the person's call and the only writes here. 'Unset' returns the row to what the rules derive.
2400
+ if (!item.fixed) line.append(button(isSettled ? 'Pin' : 'Park', {action: 'worktree_override_set', path: String(item.path), value: isSettled ? 'active' : 'settled'}));
2401
+ else line.append(badge('never shelved'));
2402
+ if (item.reason === 'parked') line.append(button('Unset', {action: 'worktree_override_clear', path: String(item.path)}));
2403
+ block.append(line);
2404
+ }
2405
+ return block;
2406
+ };
2407
+ target.append(group('Live', live, false), group('Settled', settled, true));
2408
+ if (shelf.complete === false) target.append(el('p', 'Some worktrees could not be read in time; they are listed as live. The next refresh tries again.', 'meta'));
2409
+ const tools = el('div', undefined, 'row-head');
2410
+ const readNote = el('span', undefined, 'meta');
2411
+ if (shelf.prsReadAt) { readNote.append('PR states read '); const when = el('span'); when.dataset.ts = String(shelf.prsReadAt); when.title = time(shelf.prsReadAt); when.textContent = rel(shelf.prsReadAt); readNote.append(when); }
2412
+ else readNote.textContent = 'PR states not read yet: merged work shows as merged only after a guarded merge or a refresh.';
2413
+ tools.append(button('Refresh PR states', {action: 'worktrees_refresh_prs'}), readNote);
2414
+ const foot = el('div', undefined, 'row');
2415
+ foot.append(tools);
2416
+ if (settled.length) {
2417
+ const tidy = el('p', 'To remove settled worktrees safely, run in a terminal (it previews first and removes only after you confirm): ', 'meta');
2418
+ tidy.append(el('code', 'ahub worktrees tidy'));
2419
+ foot.append(tidy);
2420
+ }
2421
+ target.append(foot);
2422
+ });
2423
+ }
2302
2424
  function renderBench(bench) {
2303
2425
  const pct = (v) => v === null || v === undefined ? '-' : Math.round(v * 100) + '%';
2304
2426
  const num = (v) => v === null || v === undefined ? '-' : String(Math.round(v));
@@ -2541,6 +2663,7 @@ function renderStreamChips() {
2541
2663
  group.append(chip);
2542
2664
  }
2543
2665
  }
2666
+ $('intake').addEventListener('submit', submitIntake);
2544
2667
  $('planning-task').addEventListener('change',loadPlanning);$('planning-reload').addEventListener('click',loadPlanning);$('release-edit').addEventListener('change',loadReleasePlanning);$('release-reload').addEventListener('click',loadReleasePlanning);
2545
2668
  $('planning').addEventListener('submit',async event=>{event.preventDefault();if(!planningEdit || !sameBoardContext(planningEdit.context) || boardViewState?.snapshot.planningCapabilities?.enabled!==true)return notice('Select a task from its current board.',true);
2546
2669
  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};
@@ -33,6 +33,16 @@ export interface OfficeTaskInput {
33
33
  deps?: readonly number[];
34
34
  /** Optional (phase 3): the task's check is running; the task stays in_progress meanwhile. */
35
35
  checking?: boolean;
36
+ rank?: number | null;
37
+ priority?: number | null;
38
+ reserved?: string | null;
39
+ release?: number | null;
40
+ pinned?: boolean;
41
+ plannedStart?: number | null;
42
+ }
43
+ export interface OfficeReleaseInput {
44
+ id: number; name?: string; state?: string; order?: number; cut?: number | null;
45
+ tasks?: readonly { id: number; state?: string; deps?: readonly number[] }[];
36
46
  }
37
47
  /** Optional (phase 3): a warning line between desks. Task and peer ids only, never paths or symbols. */
38
48
  export interface OfficeLinkInput {
@@ -55,7 +65,8 @@ export interface OfficeEventInput {
55
65
  };
56
66
  }
57
67
  export interface OfficeSnapshotInput {
58
- status?: { peers?: Record<string, OfficePeerInput> };
68
+ status?: { peers?: Record<string, OfficePeerInput>; roleInfo?: Record<string, { flowRoles?: readonly string[] }> };
69
+ releases?: readonly OfficeReleaseInput[];
59
70
  tasks?: readonly OfficeTaskInput[];
60
71
  permissions?: readonly { peer?: string }[];
61
72
  budget?: Record<string, { paused?: { reason?: string; resetsAt?: number | null } | null; [key: string]: unknown }>;
@@ -84,6 +95,10 @@ export interface OfficeCard {
84
95
  rework: boolean;
85
96
  /** The task's check is running; the card stays at its owner's desk. */
86
97
  checking: boolean;
98
+ reserved: string;
99
+ pinned: boolean;
100
+ plannedDate: string;
101
+ releaseTag?: { ordinal: number; name: string };
87
102
  /** Visible text: "#id public title". */
88
103
  label: string;
89
104
  /** Accessible name: "#id class: public title (state)", plus waiting/rework/review marks. */
@@ -114,6 +129,8 @@ export interface OfficeDesk {
114
129
  breakResetsAt: number | null;
115
130
  cards: OfficeCard[];
116
131
  extraCards: number;
132
+ planner: boolean;
133
+ unplanned: number;
117
134
  }
118
135
  export interface OfficeRoom {
119
136
  id: string;
@@ -123,6 +140,12 @@ export interface OfficeRoom {
123
140
  count: number;
124
141
  cards: OfficeCard[];
125
142
  overflow: number;
143
+ unplanned?: number;
144
+ x: number; y: number; w: number; h: number;
145
+ }
146
+ export interface OfficeDock {
147
+ id: number; name: string; state: string; ordinal: number;
148
+ approved: number; scope: number; ratio: number; cut: string;
126
149
  x: number; y: number; w: number; h: number;
127
150
  }
128
151
  export interface OfficeTransitions {
@@ -158,6 +181,8 @@ export interface OfficeScene {
158
181
  desks: OfficeDesk[];
159
182
  extraPeers: number;
160
183
  rooms: OfficeRoom[];
184
+ docks: OfficeDock[];
185
+ extraReleases: number;
161
186
  counts: OfficeCounts;
162
187
  transitions: OfficeTransitions;
163
188
  links: OfficeLink[];
@@ -168,7 +193,7 @@ export interface OfficeScene {
168
193
  * every `cards` list. Zero geometry: the dashboard never draws it, acon Space maps it. Same allowlisted fields as `cards`.
169
194
  */
170
195
  attributedCards: OfficeCard[];
171
- /** Task id -> placement ("room:todo" | "room:work" | "room:review" | "shelf:done" | "desk:<peer>" | "counted:unknown"). */
196
+ /** Task id -> placement ("room:backlog" | "room:todo" | "room:work" | "room:review" | "shelf:done" | "desk:<peer>" | "counted:unknown"). */
172
197
  placed: Record<string, string>;
173
198
  arcs: OfficeArc[];
174
199
  alerts: OfficeAlert[];
@@ -204,6 +229,17 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
204
229
  const tasks = Array.isArray(snapshot?.tasks) ? snapshot.tasks.filter((task) => task && typeof task.id === "number") : [];
205
230
  const permissions = Array.isArray(snapshot?.permissions) ? snapshot.permissions : [];
206
231
  const budget = snapshot?.budget && typeof snapshot.budget === "object" ? snapshot.budget : {};
232
+ const date = (value: unknown) => {
233
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > 8_640_000_000_000_000) return "";
234
+ const local = new Date(value);
235
+ return `${local.getFullYear()}-${String(local.getMonth() + 1).padStart(2, "0")}-${String(local.getDate()).padStart(2, "0")}`;
236
+ };
237
+ const rank = (value: unknown) => typeof value === "number" && Number.isFinite(value) ? value : Infinity;
238
+ const backlog = tasks.filter(task => task.state === "backlog").sort((a, b) => (rank(a.rank) - rank(b.rank)) || (rank(a.priority) - rank(b.priority)) || a.id - b.id);
239
+ const unplanned = backlog.filter(task => rank(task.rank) === Infinity && !text(task.reserved)).length;
240
+ const releases: OfficeReleaseInput[] = (Array.isArray(snapshot?.releases) ? snapshot.releases : []).filter(row => row && Number.isSafeInteger(row.id) && row.id > 0 && ["planning", "active", "frozen"].includes(text(row.state)))
241
+ .sort((a, b) => (rank(a.order) - rank(b.order)) || a.id - b.id);
242
+ const docked = releases.slice(0, 3);
207
243
 
208
244
  // State counts mirror taskProgress (src/ui/task-progress.ts) exactly, including the waiting
209
245
  // rule on deps; test/office-model.test.ts pins the equality so a drift fails there.
@@ -272,17 +308,24 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
272
308
  const card = (task: OfficeTaskInput, x: number, y: number, w: number, h: number): OfficeCard => {
273
309
  const state = text(task.state) || "unknown";
274
310
  const taskClass = text(task.class) || "task";
275
- const waiting = waitingById.get(task.id) === true;
311
+ const plannedDate = state === "backlog" && date(task.plannedStart) && task.plannedStart! > now ? date(task.plannedStart) : "";
312
+ const waiting = waitingById.get(task.id) === true || state === "backlog" && ((task.deps ?? []).some(id => states.get(id) !== "approved") || !!plannedDate);
276
313
  const rework = state === "changes_requested";
277
314
  const checking = task.checking === true;
278
315
  const owner = text(task.owner);
279
316
  const reviewer = text(task.reviewer);
317
+ const reserved = state === "backlog" ? text(task.reserved) : "", pinned = state === "backlog" && task.pinned === true;
318
+ const releaseIndex = docked.findIndex(row => row.id === task.release);
319
+ const releaseTag = releaseIndex < 0 ? undefined : { ordinal: releaseIndex + 1, name: text(docked[releaseIndex]!.name) || "[withheld]" };
280
320
  let name = `#${task.id} ${taskClass}: ${text(task.title)} (${state.replaceAll("_", " ")})`;
281
- if (waiting) name += ", waiting on dependencies";
321
+ if (waiting) name += state === "backlog" ? ", waits" + (plannedDate ? ` until ${plannedDate}` : "") : ", waiting on dependencies";
322
+ if (reserved) name += `, reserved for ${reserved}`;
323
+ if (pinned) name += ", pinned";
324
+ if (releaseTag) name += `, release ${releaseTag.ordinal}: ${releaseTag.name}`;
282
325
  if (rework) name += ", rework";
283
326
  if (checking) name += ", check running";
284
327
  if (state === "in_review") name += `, ${owner || "unassigned"} reviewed by ${reviewer || "unassigned"}`;
285
- return { id: task.id, taskClass, state, owner, reviewer, waiting, rework, checking, label: `#${task.id} ${text(task.title)}`, name, x, y, w, h };
328
+ return { id: task.id, taskClass, state, owner, reviewer, waiting, rework, checking, reserved, pinned, plannedDate, ...(releaseTag ? { releaseTag } : {}), label: `#${task.id} ${text(task.title)}`, name, x, y, w, h };
286
329
  };
287
330
 
288
331
  const desks: OfficeDesk[] = shownIds.map((id) => {
@@ -320,6 +363,8 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
320
363
  if (finishing) parts.push("finishing a turn");
321
364
  if (condition !== "waiting" && count > 0) parts.push(`${count} approval${count === 1 ? "" : "s"} waiting`);
322
365
  if (!attached) parts.push("disconnected, work retained");
366
+ const planner = Array.isArray(snapshot.status?.roleInfo?.[id]?.flowRoles) && snapshot.status!.roleInfo![id]!.flowRoles!.includes("planner");
367
+ if (planner) parts.push(`planner, ${unplanned} unplanned backlog cards`);
323
368
  // An over-full desk shows two chips and a "+N more" line in the third slot.
324
369
  const shown = work.length > DESK_CARDS ? work.slice(0, DESK_CARDS - 1) : work;
325
370
  return {
@@ -331,12 +376,14 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
331
376
  finishing,
332
377
  cards: shown.map((task, index) => card(task, x + 0.8, y + 8.5 + index * 2.5, DESK_W - 1.6, 2)),
333
378
  extraCards: work.length - shown.length,
379
+ planner, unplanned: planner ? unplanned : 0,
334
380
  };
335
381
  });
336
382
 
337
383
  const desksBottom = Math.max(0, ...desks.map(desk => desk.y + desk.h));
338
384
  const roomsY = desksBottom ? desksBottom + 2 : 0;
339
385
  const roomDefs = [
386
+ { id: "backlog", label: "Backlog", filterState: "backlog", list: backlog, count: counts.backlog ?? 0 },
340
387
  { id: "todo", label: "To do", filterState: "proposed", list: todo, count: counts.proposed + counts.waiting },
341
388
  { id: "work", label: "Work floor", filterState: "work", list: workUnplaced, count: counts.in_progress + counts.changes_requested },
342
389
  { id: "review", label: "Review", filterState: "in_review", list: review, count: counts.in_review },
@@ -347,12 +394,20 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
347
394
  const y = roomsY + Math.floor(index / cols) * ROOM_DY;
348
395
  return {
349
396
  id: def.id, label: def.label, filterState: def.filterState, count: def.count,
397
+ ...(def.id === "backlog" && !desks.some(desk => desk.planner) ? { unplanned } : {}),
350
398
  // One column at 12 px per unit: 28 px cards, 6 px gaps, eight cards at most.
351
399
  cards: def.list.slice(0, ROOM_CARDS).map((task, row) => card(task, x + 1, y + 3.4 + row * (ROOM_CARD_H + ROOM_CARD_GAP), ROOM_W - 2, ROOM_CARD_H)),
352
400
  overflow: Math.max(0, def.list.length - ROOM_CARDS),
353
401
  x, y, w: ROOM_W, h: ROOM_H,
354
402
  };
355
403
  });
404
+ const docks: OfficeDock[] = docked.map((row, index) => {
405
+ const scope: NonNullable<OfficeReleaseInput["tasks"]> = Array.isArray(row.tasks) ? row.tasks.filter((task: NonNullable<OfficeReleaseInput["tasks"]>[number]) => task && Number.isSafeInteger(task.id) && task.id > 0) : [];
406
+ const approved = scope.filter(task => task.state === "approved").length, position = roomDefs.length + index;
407
+ return { id: row.id, name: text(row.name) || "[withheld]", state: text(row.state), ordinal: index + 1, approved, scope: scope.length,
408
+ ratio: scope.length ? approved / scope.length : 0, cut: date(row.cut), x: (position % cols) * ROOM_DX,
409
+ y: roomsY + Math.floor(position / cols) * ROOM_DY, w: ROOM_W, h: ROOM_H };
410
+ });
356
411
 
357
412
  const placed: Record<string, string> = {};
358
413
  for (const task of tasks) { const state = text(task.state);
@@ -362,7 +417,7 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
362
417
  : state === "approved" ? "shelf:done"
363
418
  : state === "cancelled" ? "counted:cancelled"
364
419
  : state === "in_progress" || state === "changes_requested" ? (owner && shownSet.has(owner) ? `desk:${owner}` : "room:work")
365
- : state === "backlog" ? "counted:backlog" : "counted:unknown";
420
+ : state === "backlog" ? "room:backlog" : "counted:unknown";
366
421
  }
367
422
  // A bubble's attributed task may sit past the rendering cut; keep its public card so a consumer that draws more cards than the page can.
368
423
  const drawn = new Set<number>([...desks.flatMap((desk) => desk.cards), ...rooms.flatMap((room) => room.cards)].map((entry) => entry.id));
@@ -454,9 +509,10 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
454
509
  }
455
510
  return {
456
511
  cols,
457
- width: Math.max(desksWidth, ...rooms.map(room => room.x + room.w), 1),
458
- height: Math.max(desksBottom, ...rooms.map(room => room.y + room.h), 1),
512
+ width: Math.max(desksWidth, ...[...rooms, ...docks].map(room => room.x + room.w), 1),
513
+ height: Math.max(desksBottom, ...[...rooms, ...docks].map(room => room.y + room.h), 1),
459
514
  desks, extraPeers, rooms, counts, transitions, slots, placed, attributedCards,
515
+ docks, extraReleases: Math.max(0, releases.length - docks.length),
460
516
  links,
461
517
  arcs: keptArcs, alerts: keptAlerts, latched, settle, at, eventTime, lastSeq,
462
518
  };
@@ -21,6 +21,7 @@ export interface SpaceScene {
21
21
  }
22
22
 
23
23
  const ZONES: Record<string, { kind: ZoneKind; title: string }> = {
24
+ backlog: { kind: "queue", title: "Backlog" },
24
25
  todo: { kind: "queue", title: "To do" }, work: { kind: "work", title: "Work floor" },
25
26
  review: { kind: "review", title: "Review" }, done: { kind: "done", title: "Done" },
26
27
  };
package/src/ui/plugin.ts CHANGED
@@ -46,10 +46,10 @@ export interface ConsolePlugin {
46
46
 
47
47
  const TITLES: Record<(typeof DASHBOARD_VIEWS)[number], string> = { board: "Board", office: "Office", stream: "Stream", agents: "Agents", message: "Message", memory: "Memory", settings: "Settings" };
48
48
  /** The dashboard's own actions plus the inbox verbs; the manager whitelist is still applied to each forwarded call. */
49
- const FORWARDED = ["start", "stop", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "config_edit_preview", "config_edit_apply", "start_peer", "sdlc_queue", "sdlc_release"] as const;
49
+ const FORWARDED = ["start", "stop", "send", "pause", "resume", "permit", "propose", "intake", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "config_edit_preview", "config_edit_apply", "start_peer", "sdlc_queue", "sdlc_release", "worktree_override_set", "worktree_override_clear", "worktrees_refresh_prs"] as const;
50
50
  const INBOX_VERBS = ["approve", "deny", "open"] as const;
51
51
  /** Outward or destructive: answered needsConfirm first (agent-console AGENTS.md); previews are read-only and stay direct. */
52
- const CONFIRMED = new Set(["stop", "permit", "setting", "setting_undo", "setting_reload", "config_edit_apply", "start_peer", "backlog_split", "send", "approve", "deny"]);
52
+ const CONFIRMED = new Set(["stop", "permit", "setting", "setting_undo", "setting_reload", "config_edit_apply", "start_peer", "backlog_split", "send", "approve", "deny", "worktree_override_set", "worktrees_refresh_prs"]);
53
53
  const fingerprintOf = (input: Record<string, unknown>) => {
54
54
  const { confirmed: _c, fingerprint: _f, reason: _r, ...rest } = input;
55
55
  return createHash("sha256").update(JSON.stringify(Object.keys(rest).sort().map((key) => [key, rest[key]]))).digest("hex");
@@ -176,6 +176,17 @@ export function createProvider(options: { home: string; registry?: Registry; lif
176
176
  verbs: [...(allow ? [{ id: "approve", label: "Allow once", primary: true }] : []), ...(deny ? [{ id: "deny", label: "Deny", destructive: true }] : [])],
177
177
  link: { panel: "agents", params: { project: project.id } } });
178
178
  }
179
+ // #1057 D4: one alert when the shelf holds more settled worktrees than `worktrees.alert_over` (0 = never). Counts only: no path, branch or title, and no action
180
+ // beyond opening the panel, because removal is the person's `ahub worktrees tidy` and never a button.
181
+ const shelf = snap.worktrees as { settled?: unknown[]; alertOver?: number } | undefined;
182
+ const settledCount = Array.isArray(shelf?.settled) ? shelf!.settled.length : 0;
183
+ if (typeof shelf?.alertOver === "number" && shelf.alertOver > 0 && settledCount > shelf.alertOver) {
184
+ const id = `worktrees:${project.id}`;
185
+ const at = seen.get(id) ?? new Date().toISOString();
186
+ seen.set(id, at);
187
+ items.push({ id, group: "alert", kind: "worktrees", title: `${settledCount} settled worktrees`, detail: `More than ${shelf.alertOver}. Review them in Agents, then run ahub worktrees tidy.`, project: name, at,
188
+ chip: { text: "worktrees", tone: "warn" }, verbs: [{ id: "open", label: "Open Agents", primary: true }], link: { panel: "agents", params: { project: project.id } } });
189
+ }
179
190
  for (const t of (snap.tasks as { id: number; title: string; state: string; reviewer: string | null; owner: string | null }[] | undefined) ?? []) {
180
191
  if (t.state !== "in_review" || t.reviewer !== "user") continue;
181
192
  const id = `review:${project.id}:${t.id}`;
@@ -231,7 +242,7 @@ export function createProvider(options: { home: string; registry?: Registry; lif
231
242
  /** approve/deny resolve to the hub's own `permit` with the request's option; nothing else is reachable from an item. */
232
243
  async function inboxVerb(input: Record<string, unknown>): Promise<ActionResult> {
233
244
  const m = typeof input.id === "string" ? /^perm:([^:]+):(.+)$/.exec(input.id) : null;
234
- if (input.action === "open") return { ok: true, message: "Open the Board panel to review." };
245
+ if (input.action === "open") return { ok: true, message: typeof input.id === "string" && input.id.startsWith("worktrees:") ? "Open the Agents panel to review the shelf." : "Open the Board panel to review." };
235
246
  if (!m) return { ok: false, error: "unknown inbox item" };
236
247
  const project = (await running()).find((p) => p.id === m[1]);
237
248
  if (!project) return { ok: false, error: "project is not running" };