@staix/agent-hub 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +15 -7
  3. package/docs/agent-notes/README.md +13 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +10 -4
  7. package/docs/agent-notes/daemon.md +16 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +10 -4
  10. package/docs/agent-notes/tests.md +8 -4
  11. package/docs/design/adapters.md +108 -0
  12. package/docs/design/agent-hub-architecture.md +58 -0
  13. package/docs/design/bus.md +147 -1
  14. package/docs/design/task-board.md +100 -0
  15. package/docs/dogfood.md +29 -8
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +410 -86
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +25 -5
  21. package/docs/sdlc.md +1 -1
  22. package/docs/security.md +182 -62
  23. package/docs/smoke.md +13 -2
  24. package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
  25. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  26. package/docs/specs/2026-10-12-office-view-design.md +86 -3
  27. package/docs/verification/2026-11-03-0.30.0.md +54 -0
  28. package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
  29. package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
  30. package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
  31. package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
  32. package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
  33. package/docs/verification/2026-11-03-pi-title-api.md +26 -0
  34. package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
  35. package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
  36. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  37. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  38. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  39. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
  40. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  41. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  42. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  43. package/docs/verification/dogfood/2026-11-03.md +3095 -0
  44. package/docs/verification/pi-150-model-free.ts +57 -0
  45. package/docs/verification/pi-150-native.mjs +21 -0
  46. package/docs/verification/pi-150-title-api.mjs +31 -0
  47. package/docs/verified.json +14 -14
  48. package/package.json +3 -4
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/server.js +4565 -3617
  51. package/src/adapters/acp.ts +5 -4
  52. package/src/adapters/channel-peer.ts +72 -3
  53. package/src/adapters/claude-channel-evidence.ts +52 -0
  54. package/src/adapters/claude-channel.ts +61 -18
  55. package/src/adapters/claude-implementer.ts +29 -7
  56. package/src/adapters/claude-print-child.ts +12 -2
  57. package/src/adapters/codex-appserver.ts +13 -12
  58. package/src/adapters/compat.ts +13 -13
  59. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  60. package/src/adapters/definitions/qwen.ts +1 -0
  61. package/src/adapters/local-worker.ts +82 -23
  62. package/src/adapters/pi.ts +140 -16
  63. package/src/adapters/registry-types.ts +16 -0
  64. package/src/adapters/registry.ts +4 -0
  65. package/src/adapters/reviewer-print.ts +50 -11
  66. package/src/broker/server.ts +44 -13
  67. package/src/broker/store.ts +38 -8
  68. package/src/cli/answer-args.ts +50 -0
  69. package/src/cli/attention.ts +17 -2
  70. package/src/cli/autonomy.ts +66 -1
  71. package/src/cli/backlog-return-args.ts +37 -0
  72. package/src/cli/console-question.ts +110 -0
  73. package/src/cli/console-state.ts +82 -17
  74. package/src/cli/console.ts +54 -1
  75. package/src/cli/handoff-args.ts +15 -5
  76. package/src/cli/handoff-output.ts +15 -2
  77. package/src/cli/help.ts +10 -5
  78. package/src/cli/identity.ts +21 -4
  79. package/src/cli/main.ts +167 -16
  80. package/src/cli/memory.ts +107 -3
  81. package/src/cli/output.ts +29 -6
  82. package/src/cli/pet.ts +4 -4
  83. package/src/cli/results-args.ts +30 -0
  84. package/src/cli/runtime-version-warning.ts +50 -0
  85. package/src/cli/status-lines.ts +41 -1
  86. package/src/cli/tail-render.ts +1 -0
  87. package/src/cli/upgrade-interactive.ts +14 -2
  88. package/src/cli/worktree-tidy.ts +158 -0
  89. package/src/cli/worktrees-output.ts +43 -0
  90. package/src/hub/action-class.ts +9 -1
  91. package/src/hub/board.ts +396 -20
  92. package/src/hub/bus.ts +497 -57
  93. package/src/hub/completion-detail.ts +50 -0
  94. package/src/hub/completions-store.ts +168 -0
  95. package/src/hub/completions.ts +120 -0
  96. package/src/hub/conductor.ts +4 -3
  97. package/src/hub/config-apply.ts +8 -5
  98. package/src/hub/config-reload.ts +10 -1
  99. package/src/hub/control-client.ts +40 -4
  100. package/src/hub/daemon.ts +1795 -142
  101. package/src/hub/delivery-failure.ts +14 -0
  102. package/src/hub/delivery-hints.ts +14 -4
  103. package/src/hub/delivery-journal.ts +751 -42
  104. package/src/hub/delivery-superseded.ts +4 -1
  105. package/src/hub/dogfood-read.ts +62 -9
  106. package/src/hub/envelope.ts +3 -3
  107. package/src/hub/events.ts +6 -0
  108. package/src/hub/handoff.ts +145 -6
  109. package/src/hub/hub-tools.ts +66 -11
  110. package/src/hub/manager.ts +67 -3
  111. package/src/hub/memory-policy.ts +44 -1
  112. package/src/hub/memory-sync-policy.ts +109 -0
  113. package/src/hub/memory-sync-state.ts +145 -0
  114. package/src/hub/no-writes.ts +1 -1
  115. package/src/hub/operational-settings.ts +11 -2
  116. package/src/hub/operational-view.ts +12 -1
  117. package/src/hub/peer-doing.ts +33 -3
  118. package/src/hub/peers.ts +32 -2
  119. package/src/hub/planning.ts +6 -1
  120. package/src/hub/question-desk.ts +201 -0
  121. package/src/hub/question-detach.ts +6 -0
  122. package/src/hub/questions.ts +260 -0
  123. package/src/hub/research-evidence.ts +106 -0
  124. package/src/hub/restart.ts +14 -0
  125. package/src/hub/review-anchor.ts +181 -0
  126. package/src/hub/review-brief.ts +13 -0
  127. package/src/hub/review-comments.ts +115 -0
  128. package/src/hub/review-findings.ts +7 -2
  129. package/src/hub/review-material.ts +76 -0
  130. package/src/hub/routing.ts +3 -1
  131. package/src/hub/settings.ts +3 -0
  132. package/src/hub/task-sweep.ts +2 -0
  133. package/src/hub/tasks.ts +607 -71
  134. package/src/hub/ui.ts +2 -0
  135. package/src/hub/worktree-prs.ts +100 -0
  136. package/src/hub/worktree-shelf.ts +246 -0
  137. package/src/memory/capture.ts +5 -2
  138. package/src/pet/config.ts +25 -13
  139. package/src/pi/extension.ts +43 -3
  140. package/src/rooms/link.ts +209 -21
  141. package/src/rooms/remote-peer.ts +186 -19
  142. package/src/rooms/service.ts +338 -22
  143. package/src/rooms/store.ts +437 -65
  144. package/src/rooms/wire.ts +24 -6
  145. package/src/ui/index.html +271 -17
  146. package/src/ui/needs.ts +10 -4
  147. package/src/ui/office-model.ts +64 -8
  148. package/src/ui/office-space.ts +1 -0
  149. package/src/ui/plugin-questions.ts +56 -0
  150. package/src/ui/plugin-results.ts +81 -0
  151. package/src/ui/plugin.ts +219 -28
  152. package/src/pet/window.html +0 -114
  153. package/src/pet/window.jxa.js +0 -122
  154. package/src/pet/window.ts +0 -154
package/src/ui/needs.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type NeedKind = "approval" | "default" | "storage" | "recovery" | "delivery" | "review" | "message" | "budget" | "offline" | "stall" | "offer";
1
+ export type NeedKind = "approval" | "default" | "storage" | "recovery" | "delivery" | "review" | "message" | "budget" | "offline" | "stall" | "offer" | "question" | "result";
2
2
  export interface NeedItem { kind: NeedKind; level: "act" | "watch"; peer?: string; id?: string | number; until?: number; since?: number; }
3
3
  export interface NeedsInput {
4
4
  approvals?: readonly { id: string; peer?: string; expiresAt: number; since?: number }[];
@@ -10,6 +10,10 @@ export interface NeedsInput {
10
10
  facts?: readonly { kind: "review" | "offline" | "stall" | "offer"; id: number; peer?: string }[];
11
11
  messages?: readonly { id?: string; peer?: string; important: boolean; since?: number }[];
12
12
  budget?: Readonly<Record<string, { paused?: { since?: number; resetsAt?: number } }>>;
13
+ /** #1053: the open questions a peer asked the person: the set id, the asking peer and when it was asked, never a prompt or an option. */
14
+ questions?: readonly { set: string; peer?: string; since?: number }[];
15
+ /** #1054: unread results, a run a person started that settled and has not been seen: the completion id, the peer and when it settled, never a prompt or a reply. */
16
+ results?: readonly { id: string; peer?: string; since?: number }[];
13
17
  }
14
18
  export interface Needs { items: NeedItem[]; act: number; watch: number; state: "waiting" | "blocked" | "review" | "working" | "idle" | "sleeping"; }
15
19
  export interface NeedsSummary { act: number; watch: number; state: Needs["state"]; first?: NeedItem }
@@ -22,6 +26,7 @@ export function needs(input: NeedsInput, now: number): Needs {
22
26
  const add = (kind: NeedKind, level: "act" | "watch", peer?: unknown, identifier?: unknown, until?: unknown, since?: unknown) => items.push({ kind, level, ...(id(peer) ? { peer } : {}), ...(id(identifier) || typeof identifier === "number" && Number.isSafeInteger(identifier) && identifier > 0 ? { id: identifier } : {}), ...(time(until) ? { until } : {}), ...(time(since) ? { since } : {}) });
23
27
  for (const request of input.approvals ?? []) if (id(request.id) && time(request.expiresAt) && request.expiresAt > now) add("approval", "act", request.peer, request.id, request.expiresAt, request.since);
24
28
  for (const pending of input.defaults ?? []) if (id(pending.peer)) add("default", "act", pending.peer);
29
+ for (const question of input.questions ?? []) if (id(question.set)) add("question", "act", question.peer, question.set, undefined, question.since);
25
30
  if (input.storageBlocked === true) add("storage", "act");
26
31
  if (input.recovery) add("recovery", input.recovery.running === false ? "act" : "watch", undefined, input.recovery.id, undefined, input.recovery.since);
27
32
  for (const [peer, status] of Object.entries(input.peers ?? {})) {
@@ -44,17 +49,18 @@ export function needs(input: NeedsInput, now: number): Needs {
44
49
  else if (fact.kind === "offline" && agent(fact.peer) && !offline.has(fact.peer)) { offline.add(fact.peer); add("offline", "watch", fact.peer, fact.id); }
45
50
  }
46
51
  for (const [peer, budget] of Object.entries(input.budget ?? {})) if (budget.paused) add("budget", "watch", peer, undefined, budget.paused.resetsAt, budget.paused.since);
47
- const order: Record<NeedKind, number> = { approval: 0, default: 1, storage: 2, recovery: 3, delivery: 4, review: 5, stall: 6, offer: 7, message: 8, budget: 9, offline: 10 };
52
+ for (const result of input.results ?? []) if (id(result.id)) add("result", "watch", result.peer, result.id, undefined, result.since);
53
+ const order: Record<NeedKind, number> = { approval: 0, default: 1, question: 2, storage: 3, recovery: 4, delivery: 5, review: 6, stall: 7, offer: 8, message: 9, result: 10, budget: 11, offline: 12 };
48
54
  items.sort((a,b) => (a.level === "act" ? 0 : 1) - (b.level === "act" ? 0 : 1) || order[a.kind] - order[b.kind] || (a.kind === "approval" ? (a.until ?? Infinity) - (b.until ?? Infinity) : 0) || (a.since ?? 0) - (b.since ?? 0));
49
55
  const act = items.filter(item => item.level === "act").length, watch = items.length - act;
50
56
  const peers = Object.values(input.peers ?? {}), attached = peers.some(peer => peer.attached !== false && ["idle", "busy", "paused"].includes(peer.state ?? ""));
51
- const state: Needs["state"] = items.some(item => item.kind === "approval" || item.kind === "default") ? "waiting" : items.some(item => item.level === "act" && ["storage", "delivery", "recovery", "stall"].includes(item.kind)) ? "blocked" : act ? "review" : peers.some(peer => peer.attached !== false && peer.state === "busy") ? "working" : attached ? "idle" : "sleeping";
57
+ const state: Needs["state"] = items.some(item => item.kind === "approval" || item.kind === "default" || item.kind === "question") ? "waiting" : items.some(item => item.level === "act" && ["storage", "delivery", "recovery", "stall"].includes(item.kind)) ? "blocked" : act ? "review" : peers.some(peer => peer.attached !== false && peer.state === "busy") ? "working" : attached ? "idle" : "sleeping";
52
58
  return { items, act, watch, state };
53
59
  }
54
60
 
55
61
  /** Global status never reads console-local unread messages or source text. */
56
62
  export function summarizeNeeds(input: Omit<NeedsInput, "messages">, now: number): NeedsSummary {
57
63
  const value = needs({ approvals: input.approvals, defaults: input.defaults, peers: input.peers,
58
- storageBlocked: input.storageBlocked, recovery: input.recovery, tasks: input.tasks, facts: input.facts, budget: input.budget }, now);
64
+ storageBlocked: input.storageBlocked, recovery: input.recovery, tasks: input.tasks, facts: input.facts, budget: input.budget, questions: input.questions, results: input.results }, now);
59
65
  return { act: value.act, watch: value.watch, state: value.state, ...(value.items[0] ? { first: value.items[0] } : {}) };
60
66
  }
@@ -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
  };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * #1053 T5: the acon inbox side of a question a peer asked with hub_ask. Pure: it builds the inbox item from the stored row the hub reports and checks the
3
+ * person evidence an answer needs; `plugin.ts` owns the transport. No text here is logged.
4
+ *
5
+ * Authority: an answer is accepted only when the console advertises `action.meta` and `action()` received the console's own `meta` (`via: "console"`, a verified
6
+ * `identity`), which the page cannot set (agent-console `action(input, meta?)`). An answer without it is refused before any request reaches the hub.
7
+ */
8
+
9
+ import type { ActionMeta, InboxAnswers, InboxVerb, VerbForm } from "@staix/agent-console/contract";
10
+
11
+ // The acon types come only through the generated bridge (`scripts/acon-review-types.ts`, regenerated and compared by its check), so a drift of the form or of the answer it
12
+ // submits fails there and not at a user's console. Type-only: agent-hub keeps zero runtime dependencies.
13
+ export type { ActionMeta, InboxAnswers };
14
+ export interface QuestionRowView {
15
+ set: string; peer: string; created: number; private?: boolean;
16
+ questions: { id: string; prompt: string; options: { id: string; label: string; description?: string }[]; multi?: boolean; allowOther?: boolean }[];
17
+ }
18
+ type FormQuestion = VerbForm["questions"][number];
19
+ export interface QuestionItem {
20
+ id: string; group: "decision"; kind: string; title: string; actor: string; project: string; at: string; chip: { text: string; tone: "warn" };
21
+ verbs: InboxVerb[];
22
+ link: { panel: string; params: Record<string, string> };
23
+ }
24
+
25
+ /** Both features are needed: `inbox.form` renders the form, `action.meta` gives the answer its person evidence. One alone is the plain item. */
26
+ export const canAnswerInInbox = (features: readonly string[] | undefined): boolean => !!features?.includes("inbox.form") && features.includes("action.meta");
27
+
28
+ const flat = (text: string, max: number) => Array.from(text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029\s]+/g, " ").trim()).slice(0, max).join("");
29
+ export const questionItemId = (project: string, set: string) => `question:${project}:${set}`;
30
+ export const QUESTION_ITEM = /^question:([^:]+):(qs-[a-f0-9]{12})$/;
31
+
32
+ /**
33
+ * The inbox item. A private question (asked in a PII turn or about a PII task) gets a stub title and no detail: its text is in the form only, which is the person's
34
+ * own answer surface. Without the two features the item is a plain decision with an `open` verb that links to the agents panel.
35
+ */
36
+ export function questionItem(row: QuestionRowView, project: { id: string; name: string }, features: readonly string[] | undefined): QuestionItem {
37
+ const head = row.private ? "a question" : `: ${flat(row.questions[0]?.prompt ?? "", 80)}`;
38
+ const title = (row.private ? `${row.peer} asks ${head}` : `${row.peer} asks${head}`).slice(0, 200);
39
+ const link = { panel: "agents", params: { project: project.id } };
40
+ const base = { id: questionItemId(project.id, row.set), group: "decision" as const, title, actor: row.peer, project: project.name, at: new Date(row.created).toISOString(), chip: { text: "question", tone: "warn" as const }, link };
41
+ if (!canAnswerInInbox(features)) return { ...base, kind: "decision", verbs: [{ id: "open", label: "Open Agents", primary: true }] };
42
+ const form = { questions: row.questions.map<FormQuestion>(question => ({ id: question.id, prompt: question.prompt, options: question.options.map(option => ({ id: option.id, label: option.label, ...(option.description ? { description: option.description } : {}) })),
43
+ ...(question.multi === true ? { multiple: true } : {}), ...(question.allowOther === true ? { other: { maxLength: 500 } } : {}) })) };
44
+ return { ...base, kind: "question", verbs: [{ id: "answer", label: "Answer", primary: true, form }, { id: "open", label: "Open Agents" }] };
45
+ }
46
+
47
+ /** The console's evidence, or the refusal that tells the person how to get it. Page-supplied fields that would stand in for it are refused too. */
48
+ export function personEvidence(features: readonly string[] | undefined, meta: ActionMeta | undefined, input: Record<string, unknown>): { ok: true; identity: string } | { ok: false; error: string } {
49
+ if (!features?.includes("action.meta") || !meta || typeof meta !== "object") return { ok: false, error: "Answering needs a console with verified person identity; update acon and reopen this panel, or answer with ahub answer." };
50
+ const value = meta as unknown as Record<string, unknown>;
51
+ if (value.via !== "console" || typeof value.identity !== "string" || !value.identity.trim() || typeof value.node !== "string" || !value.node.trim())
52
+ return { ok: false, error: "Answering needs a console with verified person identity; update acon and reopen this panel, or answer with ahub answer." };
53
+ if (["author", "meta", "personMeta", "person", "identity", "surface", "questionSet", "projectId", "instanceId"].some(key => Object.hasOwn(input, key)))
54
+ return { ok: false, error: "The person's identity and the target come from the console; remove those fields and retry." };
55
+ return { ok: true, identity: value.identity.trim().slice(0, 200) };
56
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * #1054 T6: the acon inbox side of a result: a run a person started that settled and has not been seen. Pure: it builds the inbox item from the row the hub reports and checks the
3
+ * person evidence a receipt needs; `plugin.ts` owns the transport. No text here is logged.
4
+ *
5
+ * Publication: an item is published only to a console that advertises BOTH `inbox.result` (it renders the group) and `action.meta` (a receipt then has person evidence). One alone,
6
+ * or neither, and results stay in the ahub panel, `ahub console`, `ahub status` and `ahub results`: nothing is published that the console could not render or that the person
7
+ * could not mark read with evidence.
8
+ *
9
+ * Authority: `result_seen`, `results_read_all` and `results_unread_last` are accepted only when the console advertises `action.meta` and `action()` received the console's own
10
+ * `meta` (`via: "console"`, a verified `identity`), which the page cannot set (agent-console `action(input, meta?)`). They never go through the dashboard's forwarding list. The
11
+ * acon "seeing" rule (a focused, visible window, the result's end in view for a second, the latest edge) is agent-console#27 and is not decided here: this only accepts the receipt.
12
+ * Mark unread is NOT an inbox verb: a read item leaves the inbox, so mark-unread lives in the ahub panel (this plugin's dashboard) and in `ahub results --unread-last`.
13
+ */
14
+
15
+ import type { ActionMeta } from "@staix/agent-console/contract";
16
+
17
+ export type { ActionMeta };
18
+ export type ResultOutcome = "done" | "failed" | "limit" | "interrupted";
19
+ /** The detail the hub built at display (`completion-detail.ts`): a head of the request, a stub, or nothing. */
20
+ export type ResultDetailView = { kind: "head" | "stub"; text: string } | { kind: "none" };
21
+ /** One row of the hub's `results` snapshot field. */
22
+ export interface ResultRowView {
23
+ id: string; peer: string; outcome: ResultOutcome; settledAt: number; ms?: number; private?: boolean; title?: string; detail?: ResultDetailView;
24
+ }
25
+ export interface ResultItem {
26
+ id: string; group: "result"; kind: "result"; title: string; detail?: string; actor: string; project: string; at: string; chip: { text: string; tone: "ok" | "warn" | "err" | "neutral" };
27
+ verbs: { id: string; label: string; primary?: boolean }[];
28
+ link: { panel: string; params: Record<string, string> };
29
+ seen: { action: "result_seen" };
30
+ }
31
+
32
+ /**
33
+ * The receipt actions this plugin advertises (so acon lets the page and the inbox send them), and answers itself with the console's person meta: never in the dashboard's forwarding
34
+ * list. `result_seen` and `results_read_all` come from the inbox (acon's seeing rule and its "Mark all read") and from the panel; `results_unread_last` only from the panel, because a
35
+ * read item has left the inbox: there is no inbox verb for it. All three are the person's writes, so all three need the same evidence.
36
+ */
37
+ export const RESULT_ACTIONS = ["result_seen", "results_read_all", "results_unread_last"] as const;
38
+ export type ResultAction = (typeof RESULT_ACTIONS)[number];
39
+
40
+ /** Both features are needed. One alone is not enough: no group to render, or no person evidence for the receipt. */
41
+ export const canPublishResults = (features: readonly string[] | undefined): boolean => !!features?.includes("inbox.result") && features.includes("action.meta");
42
+
43
+ export const resultItemId = (project: string, id: string) => `result:${project}:${id}`;
44
+ export const RESULT_ITEM = /^result:([^:]+):(cmp-[a-f0-9]{12})$/;
45
+ const OUTCOME_WORD: Record<ResultOutcome, string> = { done: "finished", failed: "failed", limit: "stopped at a limit", interrupted: "interrupted" };
46
+ const CHIP: Record<ResultOutcome, { text: string; tone: "ok" | "warn" | "err" | "neutral" }> = {
47
+ done: { text: "done", tone: "ok" }, failed: { text: "failed", tone: "err" }, limit: { text: "limit", tone: "warn" }, interrupted: { text: "interrupted", tone: "warn" },
48
+ };
49
+ const flat = (text: string, max: number) => Array.from(text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029\s]+/g, " ").trim()).slice(0, max).join("");
50
+
51
+ /**
52
+ * The inbox item. The title is `<peer>: <outcome word>`, computed here from the row's peer and outcome and never taken from the row's own title text; a private result is a stub
53
+ * (`<peer>: a private result`) with no detail. The detail is whatever the hub built at display, or none: this never reads a prompt itself. `at` is the settle time, stable
54
+ * across snapshots, which is what acon's snooze and oldest-first order key on.
55
+ */
56
+ export function resultItem(row: ResultRowView, project: { id: string; name: string }): ResultItem {
57
+ const outcome: ResultOutcome = row.outcome in OUTCOME_WORD ? row.outcome : "interrupted";
58
+ const title = (row.private ? `${row.peer}: a private result` : `${row.peer}: ${OUTCOME_WORD[outcome]}`).slice(0, 200);
59
+ const detail = !row.private && row.detail && row.detail.kind !== "none" && typeof row.detail.text === "string" ? flat(row.detail.text, 200) : row.private ? "a private request" : "";
60
+ return {
61
+ id: resultItemId(project.id, row.id), group: "result", kind: "result", title, ...(detail ? { detail } : {}), actor: row.peer, project: project.name,
62
+ at: new Date(row.settledAt).toISOString(), chip: CHIP[outcome], verbs: [{ id: "open", label: "Open Agents", primary: true }],
63
+ link: { panel: "agents", params: { project: project.id } }, seen: { action: "result_seen" },
64
+ };
65
+ }
66
+
67
+ const REFUSAL = "Marking a result read needs a console with verified person identity; update acon and reopen this panel, or use ahub results.";
68
+ /**
69
+ * The console's evidence, or the refusal that tells the person how to get it. Page-supplied fields that would stand in for the person (`author`, `meta`, `identity`, `surface`) or
70
+ * name a completion by another key are refused too. `projectId` is allowed: the panel names the project it is showing, as every dashboard action does, and it only picks WHICH running
71
+ * project's receipt this is; the daemon instance is never taken from the page (`receiptVerb` resolves it from the running listing), and the person evidence is the console's alone.
72
+ */
73
+ export function receiptEvidence(features: readonly string[] | undefined, meta: ActionMeta | undefined, input: Record<string, unknown>): { ok: true } | { ok: false; error: string } {
74
+ if (!features?.includes("action.meta") || !meta || typeof meta !== "object") return { ok: false, error: REFUSAL };
75
+ const value = meta as unknown as Record<string, unknown>;
76
+ if (value.via !== "console" || typeof value.identity !== "string" || !value.identity.trim() || typeof value.node !== "string" || !value.node.trim()) return { ok: false, error: REFUSAL };
77
+ // `seenAt` and `explicit` are acon's own (the shell sends them with the seeing rule): the hub stamps its own time, so they are accepted and ignored, never trusted.
78
+ if (["author", "meta", "personMeta", "person", "identity", "surface", "completion", "completionId", "seen"].some(key => Object.hasOwn(input, key)))
79
+ return { ok: false, error: "The person's identity and the target come from the console; remove those fields and retry." };
80
+ return { ok: true };
81
+ }