@aiwuk/team 0.0.0-stage → 5.0.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.
Files changed (116) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/LICENSE +21 -0
  3. package/README.md +149 -2
  4. package/README.zh-CN.md +150 -0
  5. package/access.js +93 -0
  6. package/activity-summary.js +36 -0
  7. package/allocation.js +34 -0
  8. package/board-dispatch.js +141 -0
  9. package/brand.js +41 -0
  10. package/client/00-core-zh.js +284 -0
  11. package/client/01-i18n-en.js +265 -0
  12. package/client/02-styles.js +336 -0
  13. package/client/02b-stage-styles.js +65 -0
  14. package/client/03-shared.js +82 -0
  15. package/client/03b-collapsible-list.js +34 -0
  16. package/client/03c-config-panel.js +605 -0
  17. package/client/04-tool-cards.js +110 -0
  18. package/client/04a-dispatch-confirm.js +162 -0
  19. package/client/04b-more-cards.js +78 -0
  20. package/client/04c-model-picker.js +68 -0
  21. package/client/04d-confirm-dock.js +40 -0
  22. package/client/05-policy.js +217 -0
  23. package/client/06-usage.js +195 -0
  24. package/client/06b-model-source.js +67 -0
  25. package/client/07-board-parts.js +312 -0
  26. package/client/07a-role-theme.js +25 -0
  27. package/client/08-artifacts-report.js +375 -0
  28. package/client/08a-task-control.js +126 -0
  29. package/client/08b-stage-sprites.js +97 -0
  30. package/client/08c-stage-colors.js +55 -0
  31. package/client/08c-stage-scene.js +238 -0
  32. package/client/08d-stage-adapter.js +65 -0
  33. package/client/08e-stage-activity.js +83 -0
  34. package/client/09-team-board.js +181 -0
  35. package/client/09b-role-view.js +95 -0
  36. package/client/09c-model-tabs.js +20 -0
  37. package/client/10-overview.js +364 -0
  38. package/client/10b-onboarding.js +121 -0
  39. package/client/11-register.js +249 -0
  40. package/client.js +5053 -0
  41. package/config-projection.js +40 -0
  42. package/config.js +411 -0
  43. package/cordis.patch.yml +110 -0
  44. package/defaults.js +222 -0
  45. package/dispatch-confirm.js +293 -0
  46. package/dispatch-routes-store.js +31 -0
  47. package/failover.js +85 -0
  48. package/file-limits.js +119 -0
  49. package/free-worker.mjs +125 -0
  50. package/http-guard.js +14 -0
  51. package/i18n/roles.en.json +23 -0
  52. package/i18n/roles.zh.json +23 -0
  53. package/i18n/server.en.json +659 -0
  54. package/i18n/server.zh.json +659 -0
  55. package/i18n.js +34 -0
  56. package/icon.svg +7 -0
  57. package/index.js +111 -0
  58. package/integrations-config.js +301 -0
  59. package/integrations-messages.js +62 -0
  60. package/integrations.js +17 -0
  61. package/lead-route.js +30 -0
  62. package/locale/en.json +6 -0
  63. package/locale/zh.json +6 -0
  64. package/long-context.js +30 -0
  65. package/managed-config-route.js +49 -0
  66. package/managed-ref.js +38 -0
  67. package/managed-resolve.js +47 -0
  68. package/member-task-control.js +242 -0
  69. package/memory-backends.js +89 -0
  70. package/memory-embed.js +70 -0
  71. package/memory-hub.js +150 -0
  72. package/memory-openviking.js +101 -0
  73. package/memory-redact.js +34 -0
  74. package/memory-store.js +219 -0
  75. package/memory-sync-queue.js +28 -0
  76. package/model-policy.js +85 -0
  77. package/model-prefs.js +104 -0
  78. package/observability-api.js +76 -0
  79. package/observability-projection.js +141 -0
  80. package/onboarding.js +47 -0
  81. package/opencode.js +74 -0
  82. package/package.json +153 -4
  83. package/plan-gate.js +92 -0
  84. package/preset-guard.js +47 -0
  85. package/request-evidence.js +41 -0
  86. package/roles.js +69 -0
  87. package/roster-defaults.js +70 -0
  88. package/route-health.js +39 -0
  89. package/runtime-observability.js +78 -0
  90. package/runtime-view.js +21 -0
  91. package/scripts/memory-cli.mjs +138 -0
  92. package/search-engines.js +119 -0
  93. package/search.js +164 -0
  94. package/session-policy.js +59 -0
  95. package/session-rotation.js +206 -0
  96. package/settings-schema.js +40 -0
  97. package/shared/managed-config.js +84 -0
  98. package/single-model.js +44 -0
  99. package/skills/project-role-theme/SKILL.md +46 -0
  100. package/task-bridge.js +32 -0
  101. package/task-control-api.js +36 -0
  102. package/task-control-audit.js +60 -0
  103. package/team.js +101 -0
  104. package/teammate-context.js +120 -0
  105. package/teammate-wait.js +18 -0
  106. package/template.js +46 -0
  107. package/theme-plugin.js +172 -0
  108. package/theme.js +115 -0
  109. package/token-discipline.js +116 -0
  110. package/token-metrics.js +37 -0
  111. package/tool-names.js +10 -0
  112. package/tool-schema.js +23 -0
  113. package/transcribe.js +104 -0
  114. package/ui.js +113 -0
  115. package/usage.js +92 -0
  116. package/workflow.js +246 -0
@@ -0,0 +1,375 @@
1
+ function ArtifactsRack({ sessionId, liveTeam, live, t }) {
2
+ const [filterRole, setFilterRole] = React.useState('all');
3
+ const [diffState, setDiffState] = React.useState({});
4
+ const [openError, setOpenError] = React.useState(null);
5
+
6
+ const AC = typeof AbortController === 'function' ? AbortController : class { constructor() { this.signal = { aborted: false }; } abort() { this.signal.aborted = true; } };
7
+ const abortRef = React.useRef(null);
8
+ if (!abortRef.current) abortRef.current = new AC();
9
+
10
+ React.useEffect(() => {
11
+ return () => {
12
+ if (abortRef.current) {
13
+ abortRef.current.abort();
14
+ }
15
+ };
16
+ }, []);
17
+
18
+ const members = liveTeam?.members ?? [];
19
+ const roleMap = new Map();
20
+ for (const m of members) {
21
+ if (m.id) roleMap.set(m.id, m.role ?? m.name ?? m.id);
22
+ }
23
+
24
+ // Aggregate every member capabilityTeamArtifacts projection
25
+ let versionUnsupported = false;
26
+ const allArtifacts = [];
27
+ const changesMap = new Map();
28
+
29
+ for (const m of members) {
30
+ if (!m.id) continue;
31
+ const artView = live?.projections?.(m.id)?.capabilityTeamArtifacts;
32
+ if (!artView || typeof artView !== 'object') continue;
33
+ if (artView.schemaVersion !== 1) {
34
+ versionUnsupported = true;
35
+ continue;
36
+ }
37
+ const roleId = roleMap.get(m.id) ?? m.name ?? m.id;
38
+ if (Array.isArray(artView.items)) {
39
+ for (const item of artView.items) {
40
+ allArtifacts.push({ ...item, memberSessionId: m.id, memberName: m.name, roleId });
41
+ }
42
+ }
43
+ if (Array.isArray(artView.changes)) {
44
+ for (const chg of artView.changes) {
45
+ const cKey = `${m.id}:${chg.seq}`;
46
+ if (!changesMap.has(cKey)) {
47
+ changesMap.set(cKey, { ...chg, memberSessionId: m.id, memberName: m.name, roleId });
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ if (versionUnsupported && !allArtifacts.length && !changesMap.size) {
54
+ return h('p', { className: 'ctm-sub ctm-warn' }, t('version.unsupported'));
55
+ }
56
+
57
+ // Sort by (at, sessionId, presentSeq, fileIndex) descending; retain at most 100 items
58
+ allArtifacts.sort((a, b) => {
59
+ const atDiff = (b.at ?? 0) - (a.at ?? 0);
60
+ if (atDiff !== 0) return atDiff;
61
+ const sDiff = String(b.sessionId ?? '').localeCompare(String(a.sessionId ?? ''));
62
+ if (sDiff !== 0) return sDiff;
63
+ const pDiff = (b.presentSeq ?? 0) - (a.presentSeq ?? 0);
64
+ if (pDiff !== 0) return pDiff;
65
+ return (b.fileIndex ?? 0) - (a.fileIndex ?? 0);
66
+ });
67
+ const shelfItems = allArtifacts.slice(0, 100);
68
+
69
+ const roles = Array.from(new Set(shelfItems.map(item => item.roleId))).filter(Boolean);
70
+ const filteredItems = filterRole === 'all' ? shelfItems : shelfItems.filter(item => item.roleId === filterRole);
71
+
72
+ // Changes in this session
73
+ const changesList = Array.from(changesMap.values()).sort((a, b) => (b.at ?? 0) - (a.at ?? 0));
74
+
75
+ const handleOpenPresent = async item => {
76
+ setOpenError(null);
77
+ try {
78
+ const res = await fetch(apiUrl('api/present.open', { sessionId: item.sessionId, seq: item.presentSeq, index: item.fileIndex }), {
79
+ method: 'POST',
80
+ signal: abortRef.current?.signal,
81
+ });
82
+ if (abortRef.current?.signal?.aborted) return;
83
+ if (!res.ok) {
84
+ setOpenError(`${t('artifacts.openFail')} (${res.status})`);
85
+ }
86
+ } catch (e) {
87
+ if (abortRef.current?.signal?.aborted || e?.name === 'AbortError') return;
88
+ setOpenError(String(e.message ?? e));
89
+ }
90
+ };
91
+
92
+ const handleToggleChange = async chg => {
93
+ const cKey = `${chg.memberSessionId}:${chg.seq}`;
94
+ if (diffState[cKey]) {
95
+ setDiffState(prev => { const n = { ...prev }; delete n[cKey]; return n; });
96
+ return;
97
+ }
98
+ setDiffState(prev => ({ ...prev, [cKey]: { loading: true } }));
99
+ try {
100
+ const sumRes = await fetch(apiUrl('api/changes.summary', { sessionId: chg.memberSessionId, seq: chg.seq }), {
101
+ signal: abortRef.current?.signal,
102
+ });
103
+ if (abortRef.current?.signal?.aborted) return;
104
+ if (!sumRes.ok) {
105
+ const errText = sumRes.status === 404 ? t('artifacts.baselineInvalid') : `${sumRes.status}`;
106
+ setDiffState(prev => ({ ...prev, [cKey]: { error: errText } }));
107
+ return;
108
+ }
109
+ const sumData = await sumRes.json();
110
+ if (abortRef.current?.signal?.aborted) return;
111
+ const files = sumData.files ?? [];
112
+ const diffResults = [];
113
+ for (let i = 0; i < files.length; i++) {
114
+ if (abortRef.current?.signal?.aborted) return;
115
+ const diffRes = await fetch(apiUrl('api/changes.diff', { sessionId: chg.memberSessionId, seq: chg.seq, index: i }), {
116
+ signal: abortRef.current?.signal,
117
+ });
118
+ if (abortRef.current?.signal?.aborted) return;
119
+ if (!diffRes.ok) {
120
+ diffResults.push({ path: files[i].path, error: diffRes.status === 404 ? t('artifacts.baselineInvalid') : `Error ${diffRes.status}` });
121
+ continue;
122
+ }
123
+ const diffJson = await diffRes.json();
124
+ if (abortRef.current?.signal?.aborted) return;
125
+ const textContent = JSON.stringify(diffJson);
126
+ if (textContent.length > 1024 * 1024) {
127
+ diffResults.push({ path: files[i].path, error: t('artifacts.diffOversized') });
128
+ } else {
129
+ diffResults.push({ path: files[i].path, diff: diffJson });
130
+ }
131
+ }
132
+ if (abortRef.current?.signal?.aborted) return;
133
+ setDiffState(prev => ({ ...prev, [cKey]: { files: diffResults } }));
134
+ } catch (e) {
135
+ if (abortRef.current?.signal?.aborted || e?.name === 'AbortError') return;
136
+ setDiffState(prev => ({ ...prev, [cKey]: { error: String(e.message ?? e) } }));
137
+ }
138
+ };
139
+
140
+ return h('div', { className: 'ctm-art-shelf' },
141
+ h('div', { className: 'ctm-art-filter' },
142
+ h('span', { className: 'ctm-label' }, t('artifacts.title')),
143
+ roles.length > 1 ? h('select', {
144
+ className: 'ctm-btn',
145
+ value: filterRole,
146
+ onChange: e => setFilterRole(e.target.value),
147
+ },
148
+ h('option', { value: 'all' }, t('artifacts.allRoles')),
149
+ ...roles.map(r => h('option', { key: r, value: r }, r))) : null),
150
+ openError ? h('p', { className: 'ctm-warn' }, openError) : null,
151
+ !filteredItems.length ? h('p', { className: 'ctm-sub' }, t('artifacts.empty'))
152
+ : h('div', { className: 'ctm-art-list' }, ...filteredItems.map(item =>
153
+ h('div', { key: `${item.sessionId}:${item.presentSeq}:${item.fileIndex}`, className: 'ctm-art-card' },
154
+ h('div', { className: 'ctm-art-head' },
155
+ h('span', { className: 'ctm-badge', 'data-tone': 'brand' }, item.roleId),
156
+ h('button', {
157
+ type: 'button', className: 'ctm-btn',
158
+ onClick: () => handleOpenPresent(item),
159
+ }, t('artifacts.openDesktop'))),
160
+ h('span', { className: 'ctm-art-path', title: item.path }, item.displayPath ?? item.path),
161
+ item.at ? h('span', { className: 'ctm-sub' }, when(item.at)) : null))),
162
+ h('div', { className: 'ctm-art-changes' },
163
+ h('span', { className: 'ctm-label' }, t('artifacts.changes')),
164
+ !changesList.length ? h('p', { className: 'ctm-sub' }, t('artifacts.noChanges'))
165
+ : (changesList.length <= 5
166
+ ? changesList.map(chg => {
167
+ const cKey = `${chg.memberSessionId}:${chg.seq}`;
168
+ const diffEntry = diffState[cKey];
169
+ return h('details', { key: cKey, className: 'ctm-chg-item', onToggle: e => { if (e.target.open && !diffEntry) handleToggleChange(chg); } },
170
+ h('summary', null, `${chg.roleId} · Turn ${chg.turn ?? '?'} · Seq ${chg.seq}`),
171
+ diffEntry?.loading ? h('p', { className: 'ctm-sub' }, t('overview.loading'))
172
+ : diffEntry?.error ? h('p', { className: 'ctm-warn' }, diffEntry.error)
173
+ : diffEntry?.files ? h('div', { className: 'ctm-chg-diff' },
174
+ ...diffEntry.files.map((f, fi) => h('div', { key: fi },
175
+ h('strong', null, f.path),
176
+ f.error ? h('p', { className: 'ctm-warn' }, f.error)
177
+ : h('pre', { className: 'ctm-pre' }, JSON.stringify(f.diff?.hunks ?? f.diff ?? t('artifacts.diffEmpty'), null, 2)))))
178
+ : null);
179
+ })
180
+ : h(CollapsibleList, {
181
+ items: changesList,
182
+ limit: 5,
183
+ t,
184
+ renderItem: chg => {
185
+ const cKey = `${chg.memberSessionId}:${chg.seq}`;
186
+ const diffEntry = diffState[cKey];
187
+ return h('details', { key: cKey, className: 'ctm-chg-item', onToggle: e => { if (e.target.open && !diffEntry) handleToggleChange(chg); } },
188
+ h('summary', null, `${chg.roleId} · Turn ${chg.turn ?? '?'} · Seq ${chg.seq}`),
189
+ diffEntry?.loading ? h('p', { className: 'ctm-sub' }, t('overview.loading'))
190
+ : diffEntry?.error ? h('p', { className: 'ctm-warn' }, diffEntry.error)
191
+ : diffEntry?.files ? h('div', { className: 'ctm-chg-diff' },
192
+ ...diffEntry.files.map((f, fi) => h('div', { key: fi },
193
+ h('strong', null, f.path),
194
+ f.error ? h('p', { className: 'ctm-warn' }, f.error)
195
+ : h('pre', { className: 'ctm-pre' }, JSON.stringify(f.diff?.hunks ?? f.diff ?? t('artifacts.diffEmpty'), null, 2)))))
196
+ : null);
197
+ },
198
+ }))));
199
+ }
200
+
201
+ /** Battle report view (MVP-B). */
202
+ function BattleReportView({ sessionId, liveTeam, live, t, runtimeState, singleModel }) {
203
+ const members = liveTeam?.members ?? [];
204
+ let totalTurns = 0;
205
+ let totalSteps = 0;
206
+ let totalCompletedMs = 0;
207
+ let earliestFirstTurn = null;
208
+ let latestEndTurn = null;
209
+
210
+ let uncachedInput = 0;
211
+ let cacheRead = 0;
212
+ let cacheWrite = 0;
213
+ let outputTokens = 0;
214
+
215
+ let boardMutationsVal = null;
216
+ let hostRetries = 0;
217
+ let failedTurns = 0;
218
+ let msgAccepted = 0;
219
+
220
+ let versionUnsupported = false;
221
+ let hasReport = false;
222
+ // Team-level usage coverage (item 2): any member reporting 'partial' downgrades the whole team to
223
+ // 'partial'; members reporting 'unknown' never contribute tokens to the totals below and are listed
224
+ // separately so the aggregate never silently absorbs an unknown member's usage as zero.
225
+ let teamCoverage = 'complete';
226
+ const unknownMembers = [];
227
+
228
+ for (const m of members) {
229
+ if (!m.id) continue;
230
+ const rpt = live?.projections?.(m.id)?.capabilityTeamReport;
231
+ if (!rpt || typeof rpt !== 'object') continue;
232
+ if (rpt.schemaVersion !== 1) {
233
+ versionUnsupported = true;
234
+ continue;
235
+ }
236
+ hasReport = true;
237
+ totalTurns += rpt.turns ?? 0;
238
+ totalSteps += rpt.steps ?? 0;
239
+ totalCompletedMs += rpt.completedTurnMs ?? 0;
240
+
241
+ if (typeof rpt.firstTurnAt === 'number') {
242
+ earliestFirstTurn = earliestFirstTurn === null ? rpt.firstTurnAt : Math.min(earliestFirstTurn, rpt.firstTurnAt);
243
+ }
244
+ if (typeof rpt.lastTurnEndAt === 'number') {
245
+ latestEndTurn = latestEndTurn === null ? rpt.lastTurnEndAt : Math.max(latestEndTurn, rpt.lastTurnEndAt);
246
+ }
247
+ if (typeof rpt.openTurnAt === 'number') {
248
+ latestEndTurn = latestEndTurn === null ? rpt.openTurnAt : Math.max(latestEndTurn, rpt.openTurnAt);
249
+ }
250
+
251
+ const memberCoverage = rpt.usageCoverage?.coverage;
252
+ if (memberCoverage === 'unknown') {
253
+ unknownMembers.push(m.name ?? m.id);
254
+ } else {
255
+ if (memberCoverage === 'partial') teamCoverage = teamCoverage === 'complete' ? 'partial' : teamCoverage;
256
+ const u = rpt.usage;
257
+ if (u) {
258
+ uncachedInput += u.uncachedInputTokens ?? 0;
259
+ cacheRead += u.cacheReadTokens ?? 0;
260
+ cacheWrite += u.cacheWriteTokens ?? 0;
261
+ outputTokens += u.outputTokens ?? 0;
262
+ }
263
+ }
264
+
265
+ const cnt = rpt.counters;
266
+ if (cnt) {
267
+ hostRetries += cnt.hostRetriesStarted ?? 0;
268
+ failedTurns += cnt.failedTurns ?? 0;
269
+ msgAccepted += cnt.sendMessageAccepted ?? 0;
270
+ if (m.role === 'lead' || m.name === 'lead') {
271
+ boardMutationsVal = cnt.boardMutations ?? null;
272
+ }
273
+ }
274
+ }
275
+
276
+ if (versionUnsupported && !hasReport) {
277
+ return h('p', { className: 'ctm-sub ctm-warn' }, t('version.unsupported'));
278
+ }
279
+
280
+ const billableTokens = uncachedInput + cacheWrite + outputTokens;
281
+ const windowEnd = latestEndTurn ?? Date.now();
282
+ const windowStart = earliestFirstTurn ?? windowEnd;
283
+ const windowSpan = elapsed(Math.max(0, windowEnd - windowStart));
284
+
285
+ // Runtime failover metrics summary
286
+ const runtimeAvailable = runtimeState && !runtimeState.unavailable && runtimeState.data;
287
+ const runtimeMembers = runtimeAvailable && Array.isArray(runtimeState.data.members) ? runtimeState.data.members : [];
288
+ let rRequestFailures = 0;
289
+ let rPluginRetries = 0;
290
+ let rFailoverSwitches = 0;
291
+ let rLongContextSwitches = 0;
292
+ if (runtimeAvailable) {
293
+ for (const rm of runtimeMembers) {
294
+ const rc = rm.counters;
295
+ if (rc) {
296
+ rRequestFailures += rc.requestFailures ?? 0;
297
+ rPluginRetries += rc.pluginRetries ?? 0;
298
+ rFailoverSwitches += rc.failoverSwitches ?? 0;
299
+ rLongContextSwitches += rc.longContextSwitches ?? 0;
300
+ }
301
+ }
302
+ }
303
+
304
+ // Aggregate presentation
305
+ return h('div', { className: 'ctm-rpt-shelf' },
306
+ h('span', { className: 'ctm-label' }, t('report.title'),
307
+ singleModel ? h('span', { className: 'ctm-badge', 'data-tone': 'warn', 'data-single-model': '', title: `${singleModel.provider}/${singleModel.model}` }, t('roles.singleModel')) : null),
308
+ h('p', { className: 'ctm-sub' },
309
+ fmt(t('report.window'), {
310
+ start: earliestFirstTurn ? when(earliestFirstTurn) : t('common.unknown'),
311
+ end: latestEndTurn ? when(latestEndTurn) : when(windowEnd),
312
+ span: windowSpan,
313
+ })),
314
+ h('div', { className: 'ctm-rpt-grid' },
315
+ h('div', { className: 'ctm-rpt-box' },
316
+ h('span', { className: 'ctm-sub' }, t('report.wallTime')),
317
+ h('strong', null, elapsed(totalCompletedMs))),
318
+ h('div', { className: 'ctm-rpt-box' },
319
+ h('span', { className: 'ctm-sub' }, t('report.tokensTotal')),
320
+ h('strong', null, compact(billableTokens)),
321
+ teamCoverage === 'partial' ? badge(t('usage.partialLow'), 'warn', 'team-partial') : null,
322
+ h('span', { className: 'ctm-sub' }, fmt(t('report.tokensIo'), { in: compact(uncachedInput), out: compact(outputTokens), cw: compact(cacheWrite) })),
323
+ unknownMembers.length ? h('p', { className: 'ctm-sub ctm-warn' }, fmt(t('report.unknownMembers'), { list: unknownMembers.join(t('common.listSep')) })) : null),
324
+ h('div', { className: 'ctm-rpt-box' },
325
+ h('span', { className: 'ctm-sub' }, t('report.cacheRead')),
326
+ h('strong', null, compact(cacheRead))),
327
+ h('div', { className: 'ctm-rpt-box' },
328
+ h('span', { className: 'ctm-sub' }, t('report.turnsSteps')),
329
+ h('strong', null, `${totalTurns} / ${totalSteps}`)),
330
+ h('div', { className: 'ctm-rpt-box' },
331
+ h('span', { className: 'ctm-sub' }, t('report.mutations')),
332
+ h('strong', null, boardMutationsVal !== null ? String(boardMutationsVal) : '—'))),
333
+ h('div', { className: 'ctm-rpt-section' },
334
+ h('span', { className: 'ctm-label' }, t('report.sectionOwn')),
335
+ h('div', { className: 'ctm-rpt-grid' },
336
+ h('div', { className: 'ctm-rpt-box' },
337
+ h('span', { className: 'ctm-sub' }, t('report.hostRetries', { n: '' })),
338
+ h('strong', null, String(hostRetries))),
339
+ h('div', { className: 'ctm-rpt-box' },
340
+ h('span', { className: 'ctm-sub' }, t('report.failedTurns', { n: '' })),
341
+ h('strong', null, String(failedTurns))),
342
+ h('div', { className: 'ctm-rpt-box' },
343
+ h('span', { className: 'ctm-sub' }, t('report.msgAccepted', { n: '' })),
344
+ h('strong', null, String(msgAccepted))))),
345
+ h('div', { className: 'ctm-rpt-section' },
346
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
347
+ h('span', { className: 'ctm-label' }, t('report.sectionRuntime')),
348
+ runtimeState?.restarted ? h('span', { className: 'ctm-badge', 'data-tone': 'brand' }, t('runtime.restarted')) : null),
349
+ h('div', { className: 'ctm-rpt-grid' },
350
+ h('div', { className: 'ctm-rpt-box' },
351
+ h('span', { className: 'ctm-sub' }, t('report.requestFailures', { n: '' })),
352
+ h('strong', null, runtimeAvailable ? String(rRequestFailures) : t('common.unknown'))),
353
+ h('div', { className: 'ctm-rpt-box' },
354
+ h('span', { className: 'ctm-sub' }, t('report.pluginRetries', { n: '' })),
355
+ h('strong', null, runtimeAvailable ? String(rPluginRetries) : t('common.unknown'))),
356
+ h('div', { className: 'ctm-rpt-box' },
357
+ h('span', { className: 'ctm-sub' }, t('report.failoverSwitches', { n: '' })),
358
+ h('strong', null, runtimeAvailable ? String(rFailoverSwitches) : t('common.unknown'))),
359
+ h('div', { className: 'ctm-rpt-box' },
360
+ h('span', { className: 'ctm-sub' }, t('report.longContextSwitches', { n: '' })),
361
+ h('strong', null, runtimeAvailable ? String(rLongContextSwitches) : t('common.unknown'))))),
362
+ h('details', { className: 'ctm-rpt-defs' },
363
+ h('summary', null, t('report.def.title')),
364
+ h('div', { className: 'ctm-rpt-defs-list' },
365
+ h('p', null, t('report.def.wallTime')),
366
+ h('p', null, t('report.def.cacheRead')),
367
+ h('p', null, t('report.def.mutations')),
368
+ h('p', null, t('report.def.hostRetries')),
369
+ h('p', null, t('report.def.failedTurns')),
370
+ h('p', null, t('report.def.msgAccepted')),
371
+ h('p', null, t('report.def.requestFailures')),
372
+ h('p', null, t('report.def.pluginRetries')),
373
+ h('p', null, t('report.def.failoverSwitches')),
374
+ h('p', null, t('report.def.longContextSwitches')))));
375
+ }
@@ -0,0 +1,126 @@
1
+ // ---------- task control (per-member step budget and temporary model) ----------
2
+ // Reads the lease from the runtime snapshot the board already subscribes to (no extra polling). Writes go
3
+ // through POST api/capability-team.task-control; a temporary model is only sent after the in-page confirm.
4
+ const CONTROL_ERRORS = { 400: 'badRequest', 403: 'forbidden', 404: 'notFound', 409: 'conflict', 422: 'invalid', 503: 'unavailable' };
5
+ const COMPLEXITIES = ['simple', 'medium', 'complex'];
6
+ const requestId = () => (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `r-${Date.now()}-${Math.random().toString(36).slice(2)}`);
7
+ const routeOf = r => (r ? `${r.provider ? r.provider + '/' : ''}${r.model ?? ''}` : '');
8
+ /** The member's live lease, else its most recent one. */
9
+ function leaseFor(control, roleId) {
10
+ const all = Array.isArray(control?.leases) ? control.leases.filter(l => l.roleId === roleId) : [];
11
+ return all.find(l => l.status !== 'ended') ?? all[all.length - 1];
12
+ }
13
+ const stepTone = judgment => (judgment === 'unqualified' ? 'error' : judgment === 'warning' ? 'warn' : 'ok');
14
+ async function controlRequest(path, init) {
15
+ let res;
16
+ try { res = await fetch(path, init); } catch (error) { if (error?.name === 'AbortError') throw error; return { ok: false, kind: 'network' }; }
17
+ let body = null;
18
+ try { body = await res.json(); } catch { /* empty or non-JSON body */ }
19
+ if (res.ok) return { ok: true, body };
20
+ return { ok: false, kind: CONTROL_ERRORS[res.status] ?? 'unknown', status: res.status, message: body?.message };
21
+ }
22
+ function TaskControl({ leadSessionId, roleId, name, configured, control, t }) {
23
+ const lease = leaseFor(control, roleId);
24
+ const [open, setOpen] = React.useState(false);
25
+ const [candidates, setCandidates] = React.useState(null);
26
+ const [pick, setPick] = React.useState('');
27
+ const [reason, setReason] = React.useState('');
28
+ const [complexity, setComplexity] = React.useState('medium');
29
+ const [confirming, setConfirming] = React.useState(false);
30
+ const [pending, setPending] = React.useState(false);
31
+ const [error, setError] = React.useState(null);
32
+ const aborter = React.useRef(null);
33
+ React.useEffect(() => () => aborter.current?.abort(), []);
34
+ const freshSignal = () => { aborter.current?.abort(); aborter.current = typeof AbortController === 'function' ? new AbortController() : null; return aborter.current?.signal; };
35
+ const stop = event => event.stopPropagation();
36
+ React.useEffect(() => {
37
+ if (!open || candidates || !lease || lease.status === 'ended') return;
38
+ let live = true;
39
+ controlRequest(apiUrl('api/capability-team.override-candidates', { sessionId: leadSessionId, role: roleId }), { signal: freshSignal() })
40
+ .then(r => { if (live) setCandidates(r.ok ? r.body : { error: r.kind, candidates: [] }); })
41
+ .catch(() => {});
42
+ return () => { live = false; };
43
+ }, [open, leadSessionId, roleId, Boolean(lease && lease.status !== 'ended')]);
44
+ const post = async body => {
45
+ setPending(true); setError(null);
46
+ try {
47
+ const r = await controlRequest(apiUrl('api/capability-team.task-control'), {
48
+ method: 'POST', headers: { 'content-type': 'application/json' }, signal: freshSignal(),
49
+ body: JSON.stringify({ sessionId: leadSessionId, requestId: requestId(), role: roleId, ...body }),
50
+ });
51
+ if (!r.ok) setError(r.kind); else { setConfirming(false); setPick(''); setReason(''); }
52
+ } catch (e) { if (e?.name !== 'AbortError') setError('network'); }
53
+ finally { setPending(false); }
54
+ };
55
+ const cas = lease ? { lease_id: lease.leaseId, expected_revision: lease.revision } : {};
56
+ const chosen = candidates?.candidates?.find(c => routeOf(c) === pick);
57
+ const ended = !lease || lease.status === 'ended';
58
+ const used = lease?.startsUsed ?? 0;
59
+ const est = lease?.estimatedSteps ?? 0;
60
+ const ratio = est > 0 ? Math.min(1, used / est) : 0;
61
+ const tone = stepTone(lease?.judgment);
62
+ const audit = (Array.isArray(control?.auditTail) ? control.auditTail : []).filter(r => r.roleId === roleId);
63
+ const errorText = error ? `${label(t, `control.error.${error}`, t('control.error.unknown'))}${error === 'conflict' ? ` ${t('control.error.reconfirm')}` : ''}` : null;
64
+ const summary = lease
65
+ ? (lease.status === 'ended' ? t('control.lease.ended') : fmt(t('control.steps.label'), { used, est }))
66
+ : t('control.budget.title');
67
+ return h('details', { className: 'ctm-tc', 'data-tone': tone, open, onClick: stop, onKeyDown: stop,
68
+ onToggle: event => setOpen(event.currentTarget.open) },
69
+ h('summary', null, h('span', { className: 'ctm-label' }, t('control.title')), h('span', { className: 'ctm-sub' }, summary)),
70
+ lease ? h('div', { className: 'ctm-tc-body' },
71
+ h('div', { className: 'ctm-tc-row' },
72
+ badge(label(t, `control.lease.status.${lease.status}`, lease.status), ended ? undefined : 'brand', 'st'),
73
+ !ended && lease.judgment !== 'within' ? badge(t(`control.steps.${lease.judgment}`), tone, 'j') : null),
74
+ est > 0 ? h('div', { className: 'ctm-us-meter', 'data-tone': tone, role: 'meter', 'aria-valuemin': 0, 'aria-valuemax': est, 'aria-valuenow': used, 'aria-label': fmt(t('control.steps.label'), { used, est }) },
75
+ h('i', { style: { width: `${(ratio * 100).toFixed(1)}%` } })) : null,
76
+ h('p', { className: 'ctm-sub' }, t('control.steps.disclaimer')),
77
+ lease.override
78
+ ? h('div', { className: 'ctm-tc-override' },
79
+ h('span', { className: 'ctm-chip' }, fmt(t('control.override.badge'), { from: routeOf(lease.override.from), to: routeOf(lease.override) })),
80
+ lease.expiresAt ? h('span', { className: 'ctm-sub' }, fmt(t('control.override.expiresAt'), { time: timeOfDay(lease.expiresAt) })) : null,
81
+ h('span', { className: 'ctm-sub' }, t('control.override.autoRestore')),
82
+ h('span', { className: 'ctm-sub' }, t('control.override.pauseLongContext')))
83
+ : h('p', { className: 'ctm-sub' }, t('control.override.none')),
84
+ ended ? null : h('div', { className: 'ctm-tc-actions' },
85
+ h('label', { className: 'ctm-sub' }, t('control.override.pick'),
86
+ h('select', { value: pick, disabled: pending || !candidates?.candidates?.length, onChange: e => { setPick(e.target.value); setConfirming(false); } },
87
+ h('option', { value: '' }, candidates?.candidates?.length ? '—' : t('control.override.noCandidates')),
88
+ ...(candidates?.candidates ?? []).map(c => h('option', { key: routeOf(c), value: routeOf(c) }, `${routeOf(c)} · ${label(t, `tier.${c.tier}`, c.tier)}`)))),
89
+ h('span', { className: 'ctm-sub' }, t('control.override.pickHint')),
90
+ h('input', { type: 'text', className: 'ctm-tc-reason', placeholder: t('control.override.reason'), value: reason, maxLength: 200, onChange: e => setReason(e.target.value) }),
91
+ h('div', { className: 'ctm-tc-row' },
92
+ h('button', { type: 'button', className: 'ctm-btn', disabled: pending || !chosen, onClick: () => setConfirming(true) }, t('control.override.setAction')),
93
+ lease.override ? h('button', { type: 'button', className: 'ctm-btn', disabled: pending, onClick: () => post({ action: 'clear-override', ...cas, reason }) }, t('control.override.restoreAction')) : null,
94
+ h('button', { type: 'button', className: 'ctm-btn', disabled: pending, onClick: () => post({ action: 'finish-lease', ...cas, reason }) }, t('control.action.end')))),
95
+ confirming && chosen ? h('div', { className: 'ctm-tc-confirm', role: 'alertdialog', 'aria-label': t('control.override.confirm.title') },
96
+ h('strong', null, t('control.override.confirm.title')),
97
+ h('span', null, fmt(t('control.override.confirm.member'), { name })),
98
+ h('span', null, fmt(t('control.override.confirm.change'), { from: lease.override ? routeOf(lease.override) : (configured || t('live.configModel')), to: routeOf(chosen), tier: label(t, `tier.${chosen.tier}`, chosen.tier) })),
99
+ h('span', { className: 'ctm-warn' }, t('control.override.confirm.cost')),
100
+ h('span', { className: 'ctm-sub' }, t('control.override.confirm.restore')),
101
+ h('div', { className: 'ctm-tc-row' },
102
+ h('button', { type: 'button', className: 'ctm-btn', 'data-primary': '', disabled: pending,
103
+ onClick: () => post({ action: 'set-override', ...cas, provider: chosen.provider, model: chosen.model, catalog_version: candidates.catalogVersion, reason }) }, t('control.override.confirm.ok')),
104
+ h('button', { type: 'button', className: 'ctm-btn', disabled: pending, onClick: () => setConfirming(false) }, t('control.override.confirm.cancel')))) : null)
105
+ : null,
106
+ ended ? h('div', { className: 'ctm-tc-row' },
107
+ h('label', { className: 'ctm-sub' }, t('control.budget.complexity'),
108
+ h('select', { value: complexity, disabled: pending, onChange: e => setComplexity(e.target.value) },
109
+ ...COMPLEXITIES.map(c => h('option', { key: c, value: c }, t(`control.budget.complexity.${c}`))))),
110
+ h('button', { type: 'button', className: 'ctm-btn', disabled: pending, onClick: () => post({ action: 'start-budget', complexity, reason }) }, t('control.budget.startAction'))) : null,
111
+ pending ? h('p', { className: 'ctm-sub' }, t('control.pending')) : null,
112
+ errorText ? h('p', { className: 'ctm-sub ctm-warn', role: 'alert' }, errorText) : null,
113
+ h('div', { className: 'ctm-tc-audit' },
114
+ h('span', { className: 'ctm-sub' }, t('control.audit.title')),
115
+ audit.length
116
+ ? (audit.length <= 5
117
+ ? h('ul', null, ...audit.map(r => h('li', { key: r.eventId }, `${timeOfDay(r.at)} · ${String(r.action ?? '')}${r.to ? ` → ${routeOf(r.to)}` : ''}${r.reason ? ` · ${String(r.reason)}` : ''}`)))
118
+ : h(CollapsibleList, {
119
+ items: audit,
120
+ limit: 5,
121
+ as: 'ul',
122
+ t,
123
+ renderItem: r => h('li', { key: r.eventId }, `${timeOfDay(r.at)} · ${String(r.action ?? '')}${r.to ? ` → ${routeOf(r.to)}` : ''}${r.reason ? ` · ${String(r.reason)}` : ''}`),
124
+ }))
125
+ : h('span', { className: 'ctm-sub' }, t('control.audit.empty'))));
126
+ }
@@ -0,0 +1,97 @@
1
+ // ---------- office stage: deterministic palettes and pixel sprites (canvas draws shapes only; every text is DOM) ----------
2
+ const STAGE_SKINS = ['#f3d3a2', '#f6e1c3', '#e8b98a', '#d9a06e', '#fbe3cc'];
3
+ /** Desk prop per capability; unknown capabilities get a bare desk. */
4
+ const STAGE_PROPS = { orchestrator: 'map', 'technical-director': 'scroll', backend: 'gear', frontend: 'palette', research: 'glass', writer: 'brush', qa: 'flask' };
5
+ function stageHash(str) { let n = 2166136261; for (const ch of String(str)) { n ^= ch.charCodeAt(0); n = Math.imul(n, 16777619) >>> 0; } return n; }
6
+ /** Theme-agnostic look: the role id picks the hues, the capability picks the prop, so any theme's names render consistently. */
7
+ function stagePalette(id, capability) {
8
+ const n = stageHash(id), hue = n % 360, hue2 = (hue + 150 + ((n >>> 8) % 60)) % 360;
9
+ const hsl = (h, s, l) => `hsl(${h} ${s}% ${l}%)`;
10
+ return { robe: hsl(hue, 55, 46), leg: hsl(hue, 45, 28), hat: hsl(hue2, 55, 42), accent: hsl((hue + 180) % 360, 75, 68),
11
+ skin: STAGE_SKINS[(n >>> 16) % STAGE_SKINS.length], prop: STAGE_PROPS[capability] ?? 'none' };
12
+ }
13
+ /** Desk with monitor and the capability prop. `px(x, y, w, h, color)` paints one device-independent rectangle. */
14
+ function stageDrawDesk(px, s, S, desk, deskTop) {
15
+ const x = s.home.x, y = s.home.y, present = s.data.status !== 'inactive';
16
+ px(x - 12 * S, y + S, 24 * S, 7 * S, desk); px(x - 12 * S, y, 24 * S, 5 * S, deskTop); px(x - 12 * S, y, 24 * S, S, 'rgba(255,255,255,.12)');
17
+ px(x - 4 * S, y - 5 * S, 8 * S, 5 * S, '#1a1b22');
18
+ stageDrawScreen(px, s, S, x, y, present);
19
+ px(x - S, y, 2 * S, S, '#111');
20
+ if (s.data.activity === 'reading') { // open book on the desk, the page flips every other frame
21
+ px(x - 6 * S, y + S, 5 * S, 3 * S, '#f1ead6'); px(x - 4 * S, y + S, S, 3 * S, '#cdbf9a');
22
+ if (s.frame % 2) px(x - 6 * S, y + S, 2 * S, 2 * S, '#ffffff');
23
+ }
24
+ const a = s.palette.accent;
25
+ switch (s.palette.prop) {
26
+ case 'map': px(x + 5 * S, y + S, 6 * S, 3 * S, '#d9c38a'); px(x + 6 * S, y + 2 * S, 2 * S, S, '#b3412f'); break;
27
+ case 'scroll': px(x + 6 * S, y + S, 5 * S, 2 * S, '#e8dcbf'); px(x + 6 * S, y + 3 * S, 5 * S, S, '#cdbf9a'); break;
28
+ case 'gear': px(x + 6 * S, y, 4 * S, 4 * S, '#9aa3ad'); px(x + 7 * S, y + S, 2 * S, 2 * S, '#3b4149'); px(x + 5 * S, y + S, S, 2 * S, '#9aa3ad'); px(x + 10 * S, y + S, S, 2 * S, '#9aa3ad'); break;
29
+ case 'palette': px(x + 5 * S, y + S, 6 * S, 3 * S, '#e8dcbf'); px(x + 6 * S, y + 2 * S, S, S, '#e0493f'); px(x + 8 * S, y + 2 * S, S, S, '#3a9ad9'); px(x + 10 * S, y + 2 * S, S, S, '#3ddc84'); break;
30
+ case 'glass': px(x + 6 * S, y, 3 * S, 3 * S, '#c9ced6'); px(x + 7 * S, y + S, S, S, '#a9d4ff'); px(x + 9 * S, y + 3 * S, 2 * S, S, '#8a5a2b'); break;
31
+ case 'brush': px(x + 6 * S, y + S, 2 * S, 3 * S, '#2b2b2b'); px(x + 9 * S, y + S, 2 * S, 3 * S, '#f1ead6'); break;
32
+ case 'flask': px(x + 7 * S, y - S, 2 * S, 2 * S, '#c9ced6'); px(x + 6 * S, y + S, 4 * S, 3 * S, '#d8dde3'); px(x + 7 * S, y + 2 * S, 2 * S, 2 * S, a); break;
33
+ default: break;
34
+ }
35
+ px(x - 10 * S, y + S, 3 * S, 2 * S, '#c84b3c'); px(x - 9 * S, y, S, S, '#f0e9d8');
36
+ }
37
+ /** Monitor content per activity: code lines scroll while writing, a green terminal flickers, failures turn red. */
38
+ function stageDrawScreen(px, s, S, x, y, present) {
39
+ const a = s.data.activity, f = s.frame;
40
+ const screen = !present ? '#2b3340' : a === 'failed' ? '#8f2f2f' : a === 'writing' ? '#1e3a5f' : a === 'terminal' ? '#0c2214'
41
+ : a === 'browsing' || a === 'searching' ? '#2d5f8a' : a === 'messaging' || a === 'board' ? '#5b4a8a' : a === 'thinking' || a === 'reading' ? '#3a4a5c' : '#4b5563';
42
+ px(x - 3 * S, y - 4 * S, 6 * S, 3 * S, screen);
43
+ if (a === 'writing') for (let i = 0; i < 3; i++) px(x - 2 * S, y - (4 - i) * S, ((f + i) % 3 + 2) * S, S * .6, i === (f % 3) ? '#bfe6ff' : '#6fa8dc');
44
+ else if (a === 'terminal') { px(x - 2 * S, y - 3 * S, 3 * S, S * .6, '#3ddc84'); if (f % 2) px(x + S, y - 3 * S, S, S * .6, '#b6ffcf'); }
45
+ else if (a === 'failed') { px(x - .5 * S, y - 4 * S, S, 2 * S * .8, '#ffd0d0'); if (f % 2) px(x - .5 * S, y - 1.6 * S, S, S * .5, '#ffd0d0'); }
46
+ else if (a === 'browsing' || a === 'searching') px(x - S, y - 4 * S + (f % 3) * S * .7, 2 * S, S * .6, '#a9d4ff');
47
+ else if (a === 'messaging' || a === 'board') px(x - 2 * S, y - 3 * S, 4 * S, S, f % 2 ? '#d9c38a' : '#f3e7c6');
48
+ }
49
+ /**
50
+ * 12×16 pixel person. The pose follows `s.data.activity` (typing / chin-on-hand / book / head-down / dozing) plus
51
+ * the one-shot overlays: `s.gesture` (idle stretch or coffee) and `s.celebrate` (jump with raised arms).
52
+ */
53
+ function stageDrawPerson(px, s, S) {
54
+ const c = s.palette, f = s.facing, walking = s.phase === 'walking', a = s.data.activity, fr = s.frame;
55
+ const jumping = s.celebrate > 0, typing = !jumping && ['writing', 'terminal', 'searching', 'browsing', 'messaging', 'board', 'working'].includes(a);
56
+ const lift = jumping ? Math.round(Math.abs(Math.sin(s.celebrate * 7)) * 4) * S : 0;
57
+ const bob = !jumping && !walking && fr === 2 && a !== 'failed' ? S : 0;
58
+ const x = s.x, y0 = s.y - 16 * S + bob - lift;
59
+ const X = u => x + (f > 0 ? u : (12 - u - 1)) * S - 6 * S, Y = v => y0 + v * S;
60
+ const R = (u, v, w, h, col) => px(f > 0 ? X(u) : X(u + w - 1), Y(v), w * S, h * S, col);
61
+ const legA = walking ? (fr % 2 ? 1 : 0) : 0;
62
+ R(4 - legA, 12, 2, 4, c.leg); R(7 + legA, 12, 2, 4, c.leg); R(4 - legA, 15, 2, 1, '#222'); R(7 + legA, 15, 2, 1, '#222');
63
+ R(3, 6, 7, 6, c.robe); R(6, 6, 1, 6, c.accent);
64
+ const arm = (u, top, col = c.robe) => { R(u, top, 1, 4, col); R(u, top + 3, 1, 1, c.skin); };
65
+ const head = a === 'failed' ? 1 : 0; // failed members hang their head
66
+ if (jumping || s.gesture === 'stretch') { R(2, 2, 1, 4, c.robe); R(10, 2, 1, 4, c.robe); R(2, 1, 1, 1, c.skin); R(10, 1, 1, 1, c.skin); }
67
+ else if (a === 'thinking') { arm(2, 7); R(10, 5, 1, 3, c.robe); R(9, 5, 1, 1, c.skin); } // chin on hand
68
+ else if (a === 'reading') { R(2, 8, 1, 2, c.robe); R(10, 8, 1, 2, c.robe); R(3, 9, 7, 2, '#e8dcbf'); R(6, 9, 1, 2, '#b3a37e'); }
69
+ else if (s.gesture === 'coffee') { arm(2, 7); R(10, 5, 1, 3, c.robe); R(9, 5, 2, 2, '#f1ead6'); R(9, 5, 2, 1, '#6b4226'); }
70
+ else {
71
+ const fast = a === 'writing' ? fr % 2 : fr === 1 ? 1 : 0, other = a === 'writing' ? 1 - fr % 2 : fr === 3 ? 1 : 0;
72
+ arm(2, 7 - (typing ? fast : 0)); arm(10, 7 - (typing ? other : 0));
73
+ }
74
+ R(4, 3 + head, 5, 3, c.skin);
75
+ if (a === 'waiting' || a === 'failed') { R(5, 5 + head, 1, 1, '#222'); R(8, 5 + head, 1, 1, '#222'); } // closed / downcast eyes
76
+ else { R(5, 4, 1, 1, '#222'); R(8, 4, 1, 1, '#222'); }
77
+ R(3, head, 7, 3, c.hat); R(3, 2 + head, 7, 1, c.accent);
78
+ if (s.data.lead) { R(4, -1 + head, 1, 1, '#ffd766'); R(6, -2 + head, 1, 2, '#ffd766'); R(8, -1 + head, 1, 1, '#ffd766'); }
79
+ if (a === 'failed') { R(11, -3, 1, 3, '#ff5c5c'); R(11, 1, 1, 1, '#ff5c5c'); } // red "!"
80
+ if (a === 'thinking') for (let i = 0; i < Math.max(1, fr % 4); i++) R(4 + i * 2, -3, 1, 1, '#e6e6ee'); // animated ellipsis
81
+ if (a === 'waiting') { const z = fr % 3; R(10 + z, -2 - z, 2, 1, '#cfd8ff'); R(11 + z, -3 - z, 1, 1, '#cfd8ff'); R(10 + z, -4 - z, 2, 1, '#cfd8ff'); } // zzz
82
+ if (s.data.ctxRatio >= .9) { R(1, 2 + (fr % 2), 1, 2, '#7fc8ff'); R(1, 4 + (fr % 2), 1, 1, '#c4e6ff'); } // sweat drop
83
+ }
84
+ /** A letter in flight: wax-sealed scroll from the Lead, blue-sealed report back to the Lead. */
85
+ function stageDrawCourier(px, c, S) {
86
+ px(c.x - 3 * S, c.y - 2 * S, 6 * S, 4 * S, '#f3e7c6'); px(c.x - 3 * S, c.y - 2 * S, 6 * S, S, '#d9c38a'); px(c.x - S, c.y - S, 2 * S, 2 * S, c.reply ? '#3a9ad9' : '#c84b3c');
87
+ }
88
+ /** Floor, wall and windows; reads the stage's colour tokens so light and dark themes both get a readable office. */
89
+ function stageDrawRoom(ctx, px, w, h, S, colors) {
90
+ ctx.fillStyle = colors.floorA; ctx.fillRect(0, 0, w, h);
91
+ const tile = 8 * S;
92
+ for (let y = 0; y < h; y += tile) for (let x = ((y / tile) % 2) * tile; x < w; x += tile * 2) px(x, y, tile, tile, colors.floorB);
93
+ for (let y = 0; y < h; y += tile) px(0, y, w, 1, colors.grout);
94
+ for (let x = 0; x < w; x += tile) px(x, 0, 1, h, colors.grout);
95
+ px(0, 0, w, 10 * S, colors.wall); px(0, 10 * S, w, 1, 'rgba(0,0,0,.35)');
96
+ for (let x = 6 * S; x < w; x += 42 * S) { px(x, 2 * S, 10 * S, 6 * S, '#7fb2e5'); px(x + S, 3 * S, 8 * S, 4 * S, '#a9d4ff'); }
97
+ }