iriai-build 0.6.2 → 0.6.3

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.
package/bin/cli.js CHANGED
@@ -9,6 +9,7 @@ import { planCommand } from "../cli/commands/plan.js";
9
9
  import { implementationCommand } from "../cli/commands/implementation.js";
10
10
  import { launchCommand } from "../cli/commands/launch.js";
11
11
  import { slackCommand } from "../cli/commands/slack.js";
12
+ import { desktopCommand } from "../cli/commands/desktop.js";
12
13
  import { setupCommand } from "../cli/commands/setup.js";
13
14
  import { transferToSlackCommand } from "../cli/commands/transfer.js";
14
15
  import { ensureReady } from "../cli/first-run.js";
@@ -67,6 +68,15 @@ program
67
68
  await withReadyCheck(() => slackCommand());
68
69
  });
69
70
 
71
+ program
72
+ .command("desktop")
73
+ .description("Start the desktop companion bridge (WebSocket on port 9721).")
74
+ .option("--port <number>", "WebSocket server port (default: 9721)")
75
+ .option("--db <path>", "Database file path")
76
+ .action(async (opts) => {
77
+ await withReadyCheck(() => desktopCommand(opts));
78
+ });
79
+
70
80
  program
71
81
  .command("setup")
72
82
  .description("Configure Slack tokens, tool paths, and preferences.")
@@ -0,0 +1,19 @@
1
+ // desktop.js — `iriai-build desktop` command.
2
+ // Starts the desktop companion bridge (WebSocket-based, no Slack dependency).
3
+
4
+ export async function desktopCommand(opts = {}) {
5
+ if (opts.port) {
6
+ const port = parseInt(opts.port, 10);
7
+ if (isNaN(port) || port < 1 || port > 65535) {
8
+ console.error(`Invalid port: ${opts.port}. Must be a number between 1 and 65535.`);
9
+ process.exit(1);
10
+ }
11
+ process.env.DESKTOP_WS_PORT = String(port);
12
+ }
13
+ if (opts.db) {
14
+ process.env.DB_PATH = opts.db;
15
+ }
16
+
17
+ // bridge-desktop.js is self-executing on import
18
+ await import("../../v3/bridge-desktop.js");
19
+ }
@@ -266,6 +266,14 @@ export default class FeatureLead extends EventEmitter {
266
266
  onPhaseDone: () => {
267
267
  this._mainCrashCount = 0;
268
268
  this.emit("lifecycle", { key: this.key, event: "refresh-complete" });
269
+ // If gate evidence was already posted, don't chain — wait for user decision.
270
+ const currentGateTs = this._featureState?.gate_evidence_ts || null;
271
+ if (currentGateTs) {
272
+ this._state = "monitoring";
273
+ this.emit("lifecycle", { key: this.key, event: "gate-evidence-awaiting-decision" });
274
+ this._startTriggerDetection();
275
+ return;
276
+ }
269
277
  // Chain to a fresh session to dispatch the next gate — same as
270
278
  // _spawnTrigger("gate-ready") does. Without this, the FL drops
271
279
  // into monitoring with no process and no trigger detection,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iriai-build",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Iriai Build tool — AI agent orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,7 @@
22
22
  "@slack/web-api": "^7.8.0",
23
23
  "chokidar": "^4.0.3",
24
24
  "commander": "^14.0.3",
25
- "iriai-feedback": "^0.1.0"
25
+ "iriai-feedback": "^0.1.0",
26
+ "ws": "^8.19.0"
26
27
  }
27
28
  }
@@ -1,78 +1,348 @@
1
1
  // desktop-adapter.js — DesktopAdapter implementing InterfaceAdapter.
2
- // For iriai-command Tauri desktop app.
3
- // Stub — implementation deferred to Phase 7.
4
- // Will use WebSocket or stdin/stdout JSON protocol as Tauri sidecar.
2
+ // WebSocket server for iriai-command Tauri desktop app.
5
3
 
4
+ import { WebSocketServer, WebSocket } from "ws";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import * as queries from "../queries.js";
8
+ import * as db from "../db.js";
9
+ import { slugify } from "../helpers.js";
6
10
  import { InterfaceAdapter } from "./interface.js";
7
11
 
8
12
  export class DesktopAdapter extends InterfaceAdapter {
9
- constructor({ port } = {}) {
13
+ constructor({ port = 9721, db: dbRef, cityCatalogPath } = {}) {
10
14
  super();
11
- this.port = port || 9721;
12
- this._connections = new Set();
13
- // TODO: Start WebSocket server or stdin/stdout JSON protocol
15
+ this.port = port;
16
+ this.db = dbRef;
17
+ this.wss = null;
18
+ this.orchestrator = null;
19
+ this.assignedCities = new Set();
20
+
21
+ // Load city catalog
22
+ this.cityCatalog = [];
23
+ if (cityCatalogPath) {
24
+ try {
25
+ const raw = fs.readFileSync(cityCatalogPath, "utf-8");
26
+ this.cityCatalog = JSON.parse(raw);
27
+ } catch (err) {
28
+ if (err.code === 'ENOENT') {
29
+ console.warn(`[desktop] City catalog not found at ${cityCatalogPath}. Set CITY_CATALOG_PATH env var or place file at the expected location.`);
30
+ } else {
31
+ console.warn(`[desktop] Failed to parse city catalog at ${cityCatalogPath}: ${err.message}`);
32
+ }
33
+ }
34
+ }
35
+ this._cityIndex = 0;
36
+ }
37
+
38
+ setOrchestrator(orch) {
39
+ this.orchestrator = orch;
40
+ }
41
+
42
+ async start() {
43
+ return new Promise((resolve, reject) => {
44
+ this.wss = new WebSocketServer({ port: this.port });
45
+
46
+ this.wss.on("error", (err) => {
47
+ if (err.code === "EADDRINUSE") {
48
+ console.error(`[desktop] Port ${this.port} is already in use. Set DESKTOP_WS_PORT to use a different port.`);
49
+ }
50
+ reject(err);
51
+ });
52
+
53
+ this.wss.on("listening", () => {
54
+ this.wss.on("connection", (ws) => {
55
+ ws.send(JSON.stringify({ type: "welcome", version: "1.0" }));
56
+ ws.on("message", (raw) => this._handleMessage(ws, raw));
57
+ });
58
+ console.log(`Desktop adapter ready on ws://localhost:${this.port}`);
59
+ resolve();
60
+ });
61
+ });
62
+ }
63
+
64
+ async stop() {
65
+ if (this.wss) {
66
+ for (const client of this.wss.clients) {
67
+ client.close();
68
+ }
69
+ await new Promise((resolve) => this.wss.close(resolve));
70
+ this.wss = null;
71
+ }
72
+ }
73
+
74
+ // ─── Client Message Handler ─────────────────────────────────────────────────
75
+
76
+ async _handleMessage(ws, raw) {
77
+ let msg;
78
+ try {
79
+ msg = JSON.parse(raw.toString());
80
+ } catch {
81
+ ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
82
+ return;
83
+ }
84
+
85
+ try {
86
+ switch (msg.type) {
87
+ case "sync-request":
88
+ await this._handleSyncRequest(ws);
89
+ break;
90
+ case "create-feature":
91
+ await this._handleCreateFeature(ws, msg.payload || {});
92
+ break;
93
+ case "user-message":
94
+ await this._handleUserMessage(ws, msg.payload || {});
95
+ break;
96
+ case "resolve-decision":
97
+ await this._handleResolveDecision(ws, msg.payload || {});
98
+ break;
99
+ default:
100
+ ws.send(JSON.stringify({ type: "error", message: `Unknown message type: ${msg.type}` }));
101
+ }
102
+ } catch (err) {
103
+ console.error(`[desktop] Error handling ${msg.type}:`, err);
104
+ ws.send(JSON.stringify({ type: "error", message: err.message }));
105
+ }
106
+ }
107
+
108
+ async _handleSyncRequest(ws) {
109
+ const d = db.get();
110
+ if (!d) {
111
+ ws.send(JSON.stringify({ type: "error", message: "Database not ready" }));
112
+ return;
113
+ }
114
+ const features = queries.getAllFeatures();
115
+ const agents = d.prepare("SELECT * FROM agents ORDER BY updated_at DESC").all();
116
+ const decisions = d.prepare("SELECT * FROM decisions ORDER BY created_at DESC").all();
117
+ const events = d.prepare("SELECT * FROM events ORDER BY created_at DESC LIMIT 200").all();
118
+
119
+ ws.send(JSON.stringify({
120
+ type: "sync-response",
121
+ payload: { features, agents, decisions, events },
122
+ ts: Date.now(),
123
+ }));
124
+ }
125
+
126
+ async _handleCreateFeature(ws, payload) {
127
+ const slug = slugify(payload.description || "untitled");
128
+ const city = this._assignNextCity();
129
+
130
+ let feature = null;
131
+ if (this.orchestrator) {
132
+ feature = await this.orchestrator.initializeFeature(slug);
133
+ }
134
+
135
+ // Store city_id in feature metadata if we have a feature
136
+ if (feature && city) {
137
+ const metadata = queries.getFeatureMetadata(feature.id);
138
+ metadata.city_id = city.id;
139
+ metadata.city_name = city.name;
140
+ queries.updateFeatureMetadata(feature.id, metadata);
141
+ }
142
+
143
+ ws.send(JSON.stringify({
144
+ type: "feature-created",
145
+ payload: { feature, cityId: city ? city.id : null },
146
+ ts: Date.now(),
147
+ }));
148
+ }
149
+
150
+ async _handleUserMessage(ws, payload) {
151
+ if (!this.orchestrator) {
152
+ ws.send(JSON.stringify({ type: "error", message: "No orchestrator configured" }));
153
+ return;
154
+ }
155
+ if (!payload.featureId || !payload.text) {
156
+ ws.send(JSON.stringify({ type: "error", message: "Missing required fields: featureId, text" }));
157
+ return;
158
+ }
159
+ await this.orchestrator.routeUserMessage(payload.featureId, payload.text, "desktop");
160
+ }
161
+
162
+ async _handleResolveDecision(ws, payload) {
163
+ if (!this.orchestrator) {
164
+ ws.send(JSON.stringify({ type: "error", message: "No orchestrator configured" }));
165
+ return;
166
+ }
167
+ if (!payload.decisionId) {
168
+ ws.send(JSON.stringify({ type: "error", message: "Missing required field: decisionId" }));
169
+ return;
170
+ }
171
+ await this.orchestrator.handleDecisionClick(
172
+ payload.decisionId,
173
+ payload.optionId,
174
+ payload.feedback,
175
+ );
176
+ }
177
+
178
+ // ─── City Assignment ────────────────────────────────────────────────────────
179
+
180
+ _assignNextCity() {
181
+ if (this.cityCatalog.length === 0) return null;
182
+
183
+ // Find next unassigned city, wrapping around if all assigned
184
+ let city = null;
185
+
186
+ for (let i = 0; i < this.cityCatalog.length; i++) {
187
+ const candidate = this.cityCatalog[this._cityIndex % this.cityCatalog.length];
188
+ this._cityIndex = (this._cityIndex + 1) % this.cityCatalog.length;
189
+
190
+ if (!this.assignedCities.has(candidate.id)) {
191
+ city = candidate;
192
+ this.assignedCities.add(candidate.id);
193
+ return city;
194
+ }
195
+ }
196
+
197
+ // All cities assigned — wrap around, reset tracking, assign first
198
+ this.assignedCities.clear();
199
+ city = this.cityCatalog[this._cityIndex % this.cityCatalog.length];
200
+ this._cityIndex = (this._cityIndex + 1) % this.cityCatalog.length;
201
+ this.assignedCities.add(city.id);
202
+ return city;
14
203
  }
15
204
 
205
+ // ─── Broadcast Helper ──────────────────────────────────────────────────────
206
+
207
+ _broadcast(message) {
208
+ if (!this.wss) return;
209
+ const data = JSON.stringify(message);
210
+ for (const client of this.wss.clients) {
211
+ if (client.readyState === WebSocket.OPEN) {
212
+ client.send(data);
213
+ }
214
+ }
215
+ }
216
+
217
+ // ─── InterfaceAdapter Implementation ────────────────────────────────────────
218
+
16
219
  async createFeatureChannel(featureId, slug) {
17
- this._emit("feature:created", { featureId, slug });
220
+ const city = this._assignNextCity();
221
+
222
+ // Store city in feature metadata
223
+ if (city) {
224
+ const metadata = queries.getFeatureMetadata(featureId);
225
+ metadata.city_id = city.id;
226
+ metadata.city_name = city.name;
227
+ queries.updateFeatureMetadata(featureId, metadata);
228
+ }
229
+
230
+ this._broadcast({
231
+ type: "feature-created",
232
+ payload: { featureId, slug, city },
233
+ ts: Date.now(),
234
+ });
235
+
18
236
  return `desktop-${slug}`;
19
237
  }
20
238
 
21
239
  async postMessage(featureId, text) {
22
240
  const ref = Date.now().toString();
23
- this._emit("message", { featureId, text, ref });
241
+ this._broadcast({
242
+ type: "message",
243
+ payload: { featureId, text, ref },
244
+ ts: Date.now(),
245
+ });
24
246
  return { ref };
25
247
  }
26
248
 
27
249
  async postThreadMessage(featureId, text) {
28
- this._emit("thread-message", { featureId, text });
250
+ this._broadcast({
251
+ type: "thread-message",
252
+ payload: { featureId, text },
253
+ ts: Date.now(),
254
+ });
29
255
  }
30
256
 
31
257
  async postPipelineMessage(featureId, text) {
32
258
  const ref = Date.now().toString();
33
- this._emit("pipeline-message", { featureId, text, ref });
259
+ this._broadcast({
260
+ type: "pipeline-status",
261
+ payload: { featureId, text, ref },
262
+ ts: Date.now(),
263
+ });
34
264
  return { ref };
35
265
  }
36
266
 
37
267
  async postAgentResponse(featureId, agentLabel, content) {
38
268
  const ref = Date.now().toString();
39
- this._emit("agent-response", { featureId, agentLabel, content, ref });
269
+ this._broadcast({
270
+ type: "agent-response",
271
+ payload: { featureId, agentLabel, content, ref },
272
+ ts: Date.now(),
273
+ });
40
274
  return { ref };
41
275
  }
42
276
 
43
277
  async uploadArtifact(featureId, filePath, title) {
44
- this._emit("artifact", { featureId, filePath, title });
278
+ this._broadcast({
279
+ type: "artifact-uploaded",
280
+ payload: { featureId, filePath, title },
281
+ ts: Date.now(),
282
+ });
45
283
  }
46
284
 
47
285
  async postDecision(featureId, decision) {
48
286
  const ref = Date.now().toString();
49
- this._emit("decision", { featureId, decision, ref });
50
- // TODO: Wait for response event from Tauri client
287
+ this._broadcast({
288
+ type: "decision-posted",
289
+ payload: { featureId, decision, ref },
290
+ ts: Date.now(),
291
+ });
51
292
  return { ref };
52
293
  }
53
294
 
54
295
  async resolveDecisionMessage(featureId, messageRef, decisionId, selectedOption, selectedLabel, resolvedBy, feedback) {
55
- this._emit("decision-resolved", { featureId, decisionId, selectedOption, selectedLabel, resolvedBy, feedback });
296
+ this._broadcast({
297
+ type: "decision-resolved",
298
+ payload: { featureId, messageRef, decisionId, selectedOption, selectedLabel, resolvedBy, feedback },
299
+ ts: Date.now(),
300
+ });
56
301
  }
57
302
 
58
303
  async postPlanForApproval(featureId, planDir) {
59
- return this.postDecision(featureId, {
60
- id: "plan-approval",
61
- title: "Plan ready for approval",
62
- context: "All planning phases complete.",
63
- options: [
64
- { id: "approve", label: "Approve Plan", style: "primary" },
65
- { id: "reject", label: "Reject Plan", style: "danger" },
66
- ],
304
+ // List artifact files in the plan directory
305
+ let artifacts = [];
306
+ try {
307
+ artifacts = fs.readdirSync(planDir).filter((f) => !f.startsWith("."));
308
+ } catch {
309
+ // planDir may not exist yet
310
+ }
311
+
312
+ const ref = Date.now().toString();
313
+ this._broadcast({
314
+ type: "plan-approval",
315
+ payload: { featureId, planDir, artifacts, ref },
316
+ ts: Date.now(),
67
317
  });
318
+ return { ref };
68
319
  }
69
320
 
70
321
  async postFeatureComplete(featureId) {
71
- this._emit("feature-complete", { featureId });
322
+ this._broadcast({
323
+ type: "feature-complete",
324
+ payload: { featureId },
325
+ ts: Date.now(),
326
+ });
327
+ }
328
+
329
+ async pinMessage(_featureId, _messageTs) {
330
+ // No-op — desktop app doesn't need pinning
72
331
  }
73
332
 
74
- _emit(event, data) {
75
- // TODO: Send over WebSocket / stdout
76
- console.log(`[desktop] ${event}:`, JSON.stringify(data).slice(0, 200));
333
+ async markProcessing(featureId, messageRef) {
334
+ this._broadcast({
335
+ type: "processing-start",
336
+ payload: { featureId, messageRef },
337
+ ts: Date.now(),
338
+ });
339
+ }
340
+
341
+ async clearProcessing(featureId, messageRef) {
342
+ this._broadcast({
343
+ type: "processing-end",
344
+ payload: { featureId, messageRef },
345
+ ts: Date.now(),
346
+ });
77
347
  }
78
348
  }
@@ -308,11 +308,10 @@ export class ArtifactPortal {
308
308
  }
309
309
 
310
310
  _getReviewSessionsForFeature(featureId) {
311
- try {
312
- return queries.getReviewSessionsByFeature(featureId);
313
- } catch {
314
- return [];
315
- }
311
+ if (!this._reviewSessions) return [];
312
+ // Derive from in-memory sessions — no DB query needed
313
+ return this._reviewSessions.getAllSessions()
314
+ .filter(s => s.decisionId.includes(queries.getFeatureById(featureId)?.slug || "~~none~~"));
316
315
  }
317
316
 
318
317
  // ─── Shared CSS ────────────────────────────────────────────────────
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ // bridge-desktop.js — Desktop companion app bridge.
3
+ //
4
+ // Mirrors bridge-v3.js (Slack bridge) but uses DesktopAdapter (WebSocket)
5
+ // instead of SlackAdapter. No Slack tokens required.
6
+ //
7
+ // Usage: node v3/bridge-desktop.js
8
+
9
+ import path from "node:path";
10
+ import * as db from "./db.js";
11
+ import { DesktopAdapter } from "./adapters/desktop-adapter.js";
12
+ import { Orchestrator } from "./orchestrator.js";
13
+ import { Recovery } from "./recovery.js";
14
+ import { ReviewSessionManager } from "./review-sessions.js";
15
+ import { DB_PATH, PORTAL_PORT } from "./constants.js";
16
+ import { ArtifactPortal } from "./artifact-portal.js";
17
+
18
+ let _orchestrator = null;
19
+ let _portal = null;
20
+ let _adapter = null;
21
+
22
+ // ─── Config ──────────────────────────────────────────────────────────────────
23
+
24
+ const WS_PORT = process.env.DESKTOP_WS_PORT
25
+ ? parseInt(process.env.DESKTOP_WS_PORT, 10)
26
+ : 9721;
27
+
28
+ const ARTIFACT_PORT = process.env.ARTIFACT_PORT
29
+ ? parseInt(process.env.ARTIFACT_PORT, 10)
30
+ : PORTAL_PORT;
31
+
32
+ const CITY_CATALOG_PATH =
33
+ process.env.CITY_CATALOG_PATH ||
34
+ path.resolve(import.meta.dirname, "../data/city-catalog.json");
35
+
36
+ // ─── Startup ─────────────────────────────────────────────────────────────────
37
+
38
+ async function start() {
39
+ console.log("Starting Iriai Desktop Bridge (SQLite-backed)...");
40
+
41
+ // 1. Open database
42
+ try {
43
+ db.open(DB_PATH);
44
+ } catch (err) {
45
+ console.error(`[desktop-bridge] Failed to open database at ${DB_PATH}:`, err.message);
46
+ process.exit(1);
47
+ }
48
+ console.log(`Database opened: ${DB_PATH}`);
49
+
50
+ // 2. Create DesktopAdapter
51
+ const adapter = new DesktopAdapter({
52
+ port: WS_PORT,
53
+ db: null,
54
+ cityCatalogPath: CITY_CATALOG_PATH,
55
+ });
56
+ _adapter = adapter;
57
+
58
+ // 3. Create orchestrator with review session support
59
+ const reviewSessions = new ReviewSessionManager();
60
+ const orchestrator = new Orchestrator({ adapter, reviewSessions });
61
+ _orchestrator = orchestrator;
62
+ adapter.setOrchestrator(orchestrator);
63
+
64
+ // 4. Start artifact portal
65
+ const portal = new ArtifactPortal({ reviewSessions });
66
+ _portal = portal;
67
+ await portal.start(ARTIFACT_PORT);
68
+
69
+ // 5. Run recovery (process stale signals, reattach running agents)
70
+ const recovery = new Recovery({ orchestrator, adapter });
71
+ await recovery.run();
72
+
73
+ // 6. Start stale signal safety net
74
+ orchestrator.startStaleScan();
75
+
76
+ // 7. Start WebSocket server
77
+ await adapter.start();
78
+
79
+ console.log(`Desktop bridge ready on ws://localhost:${WS_PORT}`);
80
+ }
81
+
82
+ // ─── Graceful Shutdown ───────────────────────────────────────────────────────
83
+
84
+ async function shutdown(signal) {
85
+ console.log(`\n[desktop-bridge] ${signal} received — shutting down gracefully`);
86
+
87
+ if (_adapter) {
88
+ await _adapter.stop();
89
+ }
90
+
91
+ if (_portal) {
92
+ await _portal.stop();
93
+ }
94
+
95
+ if (_orchestrator) {
96
+ await _orchestrator.shutdown();
97
+ }
98
+
99
+ db.close();
100
+ console.log("[desktop-bridge] Database closed. Exiting.");
101
+ process.exit(0);
102
+ }
103
+
104
+ process.on("SIGINT", () => shutdown("SIGINT"));
105
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
106
+
107
+ process.on("unhandledRejection", (err) => {
108
+ console.error("[desktop-bridge] Unhandled rejection (non-fatal):", err?.message || err);
109
+ });
110
+
111
+ start().catch((err) => {
112
+ console.error("[desktop-bridge] Fatal:", err);
113
+ process.exit(1);
114
+ });
package/v3/operator.js CHANGED
@@ -30,6 +30,7 @@ export async function invokeOperator({ feature, operatorDir, flDir, featureDir,
30
30
  const history = await assembleHistory(feature.id);
31
31
  const activeAgents = assembleActiveAgents(feature.id);
32
32
  const pendingDecision = assemblePendingDecision(feature.id);
33
+ const routingTable = assembleRoutingTable(feature.id);
33
34
 
34
35
  const projectRoot = process.env.PROJECT_ROOT || process.cwd();
35
36
  const directoryMap = path.join(projectRoot, "DIRECTORY_MAP.MD");
@@ -47,6 +48,7 @@ export async function invokeOperator({ feature, operatorDir, flDir, featureDir,
47
48
  planDir,
48
49
  activePlanningRole,
49
50
  directoryMap,
51
+ routingTable,
50
52
  });
51
53
 
52
54
  // 3. Spawn claude via supervisor — default to --continue for session context,
@@ -232,6 +234,20 @@ export function assembleActiveAgents(featureId) {
232
234
  }).join("\n");
233
235
  }
234
236
 
237
+ /**
238
+ * Assemble a routing table of all agent keys for this feature.
239
+ * Gives the Operator exact keys to use in [ROUTE:] blocks.
240
+ */
241
+ export function assembleRoutingTable(featureId) {
242
+ const agents = queries.getAgentsByFeature(featureId);
243
+ if (!agents.length) return "(no agents registered)";
244
+
245
+ return agents
246
+ .filter(a => a.agent_type !== "operator") // exclude self
247
+ .map(a => `- [ROUTE:${a.agent_key}] → ${a.role_name || a.agent_type}${a.team_num ? ` (team ${a.team_num})` : ""} — ${a.status}`)
248
+ .join("\n");
249
+ }
250
+
235
251
  /**
236
252
  * Assemble pending decision info.
237
253
  */