@ours.network/fleet 1.1.0-nightly.9 → 1.1.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 (200) hide show
  1. package/README.md +231 -46
  2. package/dist/application/capabilities.js +18 -6
  3. package/dist/application/fleet-query-service.js +7 -4
  4. package/dist/application/role-command-service.js +9 -0
  5. package/dist/application/role-creation-service.d.ts +4 -4
  6. package/dist/application/role-creation-service.js +8 -1
  7. package/dist/application/role-repository.d.ts +4 -2
  8. package/dist/application/role-repository.js +6 -3
  9. package/dist/application/session-control.d.ts +3 -1
  10. package/dist/application/session-control.js +4 -2
  11. package/dist/application/task-room-service.d.ts +92 -39
  12. package/dist/application/task-room-service.js +562 -289
  13. package/dist/briefing.js +79 -22
  14. package/dist/build-info.json +5 -5
  15. package/dist/canonical-json.d.ts +2 -0
  16. package/dist/canonical-json.js +10 -0
  17. package/dist/cli.js +129 -47
  18. package/dist/config.d.ts +38 -3
  19. package/dist/config.js +193 -28
  20. package/dist/creation.d.ts +1 -1
  21. package/dist/creation.js +7 -4
  22. package/dist/docs.d.ts +1 -1
  23. package/dist/docs.js +181 -41
  24. package/dist/doctor.d.ts +2 -0
  25. package/dist/doctor.js +40 -4
  26. package/dist/fleet-command-audit.d.ts +22 -1
  27. package/dist/fleet-command-audit.js +355 -50
  28. package/dist/fleet-proxy.d.ts +5 -2
  29. package/dist/harness/claude-code-session.js +2 -1
  30. package/dist/harness/codex-app-server-proxy.d.ts +40 -0
  31. package/dist/harness/codex-app-server-proxy.js +348 -14
  32. package/dist/harness/codex-session.d.ts +9 -2
  33. package/dist/harness/codex-session.js +18 -2
  34. package/dist/harness/codex.d.ts +5 -1
  35. package/dist/harness/codex.js +104 -10
  36. package/dist/harness/types.d.ts +1 -1
  37. package/dist/index.d.ts +4 -3
  38. package/dist/index.js +3 -2
  39. package/dist/init-guidance.d.ts +1 -1
  40. package/dist/init-guidance.js +1 -1
  41. package/dist/init-wizard.d.ts +98 -0
  42. package/dist/init-wizard.js +631 -0
  43. package/dist/lifecycle-summary.d.ts +134 -0
  44. package/dist/lifecycle-summary.js +179 -0
  45. package/dist/loops/config.d.ts +10 -1
  46. package/dist/loops/config.js +38 -5
  47. package/dist/model-recovery.d.ts +1 -1
  48. package/dist/ops.js +2 -17
  49. package/dist/owner-channel/channel.d.ts +19 -3
  50. package/dist/owner-channel/channel.js +301 -120
  51. package/dist/owner-channel/commands.d.ts +28 -6
  52. package/dist/owner-channel/commands.js +109 -27
  53. package/dist/owner-channel/lifecycle-outbox.d.ts +24 -0
  54. package/dist/owner-channel/lifecycle-outbox.js +87 -0
  55. package/dist/owner-channel/ours-client.d.ts +19 -0
  56. package/dist/owner-channel/ours-client.js +3 -0
  57. package/dist/permissions.js +1 -1
  58. package/dist/preset-migration.d.ts +42 -0
  59. package/dist/preset-migration.js +450 -0
  60. package/dist/reports/artifact.d.ts +10 -0
  61. package/dist/reports/artifact.js +32 -0
  62. package/dist/reports/index.d.ts +5 -0
  63. package/dist/reports/index.js +5 -0
  64. package/dist/reports/render.d.ts +6 -0
  65. package/dist/reports/render.js +240 -0
  66. package/dist/reports/service.d.ts +42 -0
  67. package/dist/reports/service.js +57 -0
  68. package/dist/reports/tasks.d.ts +52 -0
  69. package/dist/reports/tasks.js +177 -0
  70. package/dist/reports/types.d.ts +315 -0
  71. package/dist/reports/types.js +6 -0
  72. package/dist/resolved-plan.js +13 -1
  73. package/dist/rooms-tasks/cli.d.ts +2 -0
  74. package/dist/rooms-tasks/cli.js +361 -285
  75. package/dist/rooms-tasks/close.d.ts +9 -1
  76. package/dist/rooms-tasks/close.js +23 -7
  77. package/dist/rooms-tasks/config.js +16 -25
  78. package/dist/rooms-tasks/cowork-adapter.d.ts +5 -0
  79. package/dist/rooms-tasks/cowork-adapter.js +13 -0
  80. package/dist/rooms-tasks/deletion.d.ts +51 -0
  81. package/dist/rooms-tasks/deletion.js +217 -0
  82. package/dist/rooms-tasks/external-worker.d.ts +8 -0
  83. package/dist/rooms-tasks/external-worker.js +38 -0
  84. package/dist/rooms-tasks/launch-snapshot.d.ts +19 -0
  85. package/dist/rooms-tasks/launch-snapshot.js +181 -0
  86. package/dist/rooms-tasks/member-overrides.d.ts +30 -0
  87. package/dist/rooms-tasks/member-overrides.js +204 -0
  88. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  89. package/dist/rooms-tasks/member-startup.js +9 -2
  90. package/dist/rooms-tasks/provision.js +146 -83
  91. package/dist/rooms-tasks/room-state.d.ts +1 -0
  92. package/dist/rooms-tasks/room-state.js +12 -1
  93. package/dist/rooms-tasks/task-room-name.d.ts +12 -0
  94. package/dist/rooms-tasks/task-room-name.js +61 -0
  95. package/dist/rooms-tasks/task-state.d.ts +115 -3
  96. package/dist/rooms-tasks/task-state.js +361 -11
  97. package/dist/rooms-tasks/templates.d.ts +4 -1
  98. package/dist/rooms-tasks/templates.js +34 -3
  99. package/dist/rooms-tasks/terminal.d.ts +3 -0
  100. package/dist/rooms-tasks/terminal.js +10 -4
  101. package/dist/rooms-tasks/types.d.ts +92 -7
  102. package/dist/rooms-tasks/types.js +14 -1
  103. package/dist/runner.d.ts +3 -0
  104. package/dist/runner.js +91 -44
  105. package/dist/session/acp.d.ts +4 -1
  106. package/dist/session/acp.js +29 -13
  107. package/dist/session/arbiter.d.ts +2 -1
  108. package/dist/session/arbiter.js +2 -0
  109. package/dist/session/codex-app-server-transport.d.ts +53 -0
  110. package/dist/session/codex-app-server-transport.js +184 -0
  111. package/dist/session/codex-app-server.d.ts +122 -0
  112. package/dist/session/codex-app-server.js +1031 -0
  113. package/dist/session/control.d.ts +1 -1
  114. package/dist/session/control.js +17 -1
  115. package/dist/session/conversation-types.d.ts +4 -4
  116. package/dist/session/types.d.ts +24 -6
  117. package/dist/session/types.js +28 -0
  118. package/dist/spawn.d.ts +9 -0
  119. package/dist/spawn.js +77 -9
  120. package/dist/web/fleet-config-service.d.ts +1 -0
  121. package/dist/web/fleet-config-service.js +25 -7
  122. package/dist/web/runtime.js +11 -6
  123. package/dist/web/server.js +28 -1
  124. package/dist/web-app/assets/{index-BbTc5KtZ.js → index-C0h3ALvs.js} +8 -8
  125. package/dist/web-app/index.html +1 -1
  126. package/package.json +4 -4
  127. package/presets/brain-catalog.json +17 -0
  128. package/presets/fleet/{agents → agent_templates}/Critic.yaml +2 -0
  129. package/presets/fleet/{agents/Agent.yaml → agent_templates/Developer.yaml} +3 -1
  130. package/presets/fleet/agent_templates/LocalCoordinator.yaml +50 -0
  131. package/presets/fleet/agents/{Developer.yaml → FleetCoordinator.yaml} +1 -1
  132. package/presets/fleet/brains/claude-fable-5-high.yaml +5 -0
  133. package/presets/fleet/brains/claude-fable-5-low.yaml +5 -0
  134. package/presets/fleet/brains/claude-fable-5-max.yaml +5 -0
  135. package/presets/fleet/brains/claude-fable-5-medium.yaml +5 -0
  136. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +5 -0
  137. package/presets/fleet/brains/claude-opus-5-high.yaml +5 -0
  138. package/presets/fleet/brains/claude-opus-5-low.yaml +5 -0
  139. package/presets/fleet/brains/claude-opus-5-max.yaml +5 -0
  140. package/presets/fleet/brains/claude-opus-5-medium.yaml +5 -0
  141. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +5 -0
  142. package/presets/fleet/brains/claude-sonnet-5-high.yaml +5 -0
  143. package/presets/fleet/brains/claude-sonnet-5-low.yaml +5 -0
  144. package/presets/fleet/brains/claude-sonnet-5-max.yaml +5 -0
  145. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +5 -0
  146. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +5 -0
  147. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +5 -0
  148. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +5 -0
  149. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +5 -0
  150. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +5 -0
  151. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +5 -0
  152. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +5 -0
  153. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +5 -0
  154. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +5 -0
  155. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +5 -0
  156. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +5 -0
  157. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +5 -0
  158. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +5 -0
  159. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +5 -0
  160. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +5 -0
  161. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +5 -0
  162. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +5 -0
  163. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +5 -0
  164. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +5 -0
  165. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +5 -0
  166. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +5 -0
  167. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +5 -0
  168. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +5 -0
  169. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +5 -0
  170. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +5 -0
  171. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +5 -0
  172. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +5 -0
  173. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +5 -0
  174. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +5 -0
  175. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +5 -0
  176. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +5 -0
  177. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +5 -0
  178. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +5 -0
  179. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +5 -0
  180. package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +5 -0
  181. package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +5 -0
  182. package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +5 -0
  183. package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +5 -0
  184. package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +5 -0
  185. package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +5 -0
  186. package/presets/fleet/roles/Coordinator.yaml +37 -0
  187. package/presets/fleet/roles/Critic.yaml +23 -1
  188. package/presets/fleet/roles/Developer.yaml +27 -5
  189. package/presets/fleet/roles/LocalCoordinator.yaml +35 -0
  190. package/presets/fleet/room_templates/pair.yaml +9 -7
  191. package/presets/fleet/room_templates/single.yaml +5 -4
  192. package/presets/fleet/room_templates/team.yaml +12 -8
  193. package/presets/manifest.json +2 -1
  194. package/presets/fleet/agents/Architect.yaml +0 -3
  195. package/presets/fleet/agents/Secretary.yaml +0 -3
  196. package/presets/fleet/agents/Tester.yaml +0 -3
  197. package/presets/fleet/roles/Agent.yaml +0 -6
  198. package/presets/fleet/roles/Architect.yaml +0 -6
  199. package/presets/fleet/roles/Secretary.yaml +0 -6
  200. package/presets/fleet/roles/Tester.yaml +0 -6
@@ -0,0 +1,240 @@
1
+ import { createHash } from 'node:crypto';
2
+ const MEDIA_TYPE = 'text/html; charset=utf-8';
3
+ const SAFE_FRAGMENT = /^[a-z0-9](?:[a-z0-9_.:-]{0,95})$/u;
4
+ const RFC3339_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u;
5
+ export const escapeReportHtml = (value) => String(value ?? '')
6
+ .replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
7
+ .replaceAll('"', '&quot;').replaceAll("'", '&#39;');
8
+ export function reportAnchor(prefix, raw) {
9
+ const normalized = raw.trim().toLowerCase();
10
+ if (SAFE_FRAGMENT.test(normalized) && `${prefix}-${normalized}`.length <= 110)
11
+ return `${prefix}-${normalized}`;
12
+ return `${prefix}-${createHash('sha256').update(raw).digest('hex').slice(0, 16)}`;
13
+ }
14
+ function reportTime(raw) {
15
+ if (!RFC3339_UTC.test(raw) || Number.isNaN(Date.parse(raw)))
16
+ return { text: `${raw} (unverified time)` };
17
+ return { text: raw, datetime: raw };
18
+ }
19
+ function validateModel(model) {
20
+ const sectionIds = new Set();
21
+ const targets = new Set();
22
+ for (const section of model.sections) {
23
+ const id = reportAnchor('section', section.id);
24
+ if (sectionIds.has(id))
25
+ throw new Error(`duplicate report section anchor: ${id}`);
26
+ sectionIds.add(id);
27
+ targets.add(id);
28
+ if (section.kind === 'task-navigator') {
29
+ const panelIds = new Set();
30
+ const taskIds = new Set();
31
+ for (const panel of section.panels) {
32
+ const panelId = reportAnchor('list', panel.id);
33
+ if (panelIds.has(panelId))
34
+ throw new Error(`duplicate task list anchor: ${panelId}`);
35
+ panelIds.add(panelId);
36
+ targets.add(panelId);
37
+ for (const task of panel.tasks) {
38
+ const taskId = itemAnchor(section.id, task.id);
39
+ if (taskIds.has(taskId))
40
+ throw new Error(`duplicate task anchor: ${taskId}`);
41
+ taskIds.add(taskId);
42
+ targets.add(taskId);
43
+ }
44
+ }
45
+ if (!panelIds.has(reportAnchor('list', section.defaultList)))
46
+ throw new Error('default task list is not included');
47
+ continue;
48
+ }
49
+ if (section.kind === 'list-board' || section.kind === 'inbox')
50
+ continue;
51
+ if (section.kind === 'cards' || section.kind === 'records') {
52
+ const itemIds = new Set();
53
+ for (const item of section.items) {
54
+ const itemId = itemAnchor(section.id, item.id);
55
+ if (itemIds.has(itemId))
56
+ throw new Error(`duplicate report card anchor: ${itemId}`);
57
+ itemIds.add(itemId);
58
+ targets.add(itemId);
59
+ }
60
+ continue;
61
+ }
62
+ if (section.kind !== 'table')
63
+ continue;
64
+ const rowIds = new Set();
65
+ for (const row of section.rows) {
66
+ if (row.cells.length !== section.columns.length)
67
+ throw new Error(`report table ${section.id} has a row with the wrong cell count`);
68
+ if (!row.id)
69
+ continue;
70
+ const rowId = itemAnchor(section.id, row.id);
71
+ if (rowIds.has(rowId))
72
+ throw new Error(`duplicate report row anchor: ${rowId}`);
73
+ rowIds.add(rowId);
74
+ targets.add(rowId);
75
+ }
76
+ }
77
+ const linkedValues = [...(model.summary ?? [])];
78
+ for (const section of model.sections) {
79
+ const values = section.kind === 'table' ? section.rows.flatMap(row => row.cells)
80
+ : section.kind === 'details' ? section.values
81
+ : section.kind === 'cards' ? section.items.flatMap(item => item.values)
82
+ : section.kind === 'records' ? section.items.flatMap(item => item.groups.flatMap(group => group.values))
83
+ : section.kind === 'list-board' ? section.items.flatMap(item => item.recent)
84
+ : section.kind === 'inbox' ? [...section.attention, ...section.active].flatMap(item => item.groups.flatMap(group => group.values))
85
+ : section.kind === 'task-navigator' ? section.panels.flatMap(panel => panel.tasks.flatMap(item => item.groups.flatMap(group => group.values))) : [];
86
+ linkedValues.push(...values);
87
+ if (section.kind === 'list-board')
88
+ linkedValues.push(...section.items.map(item => ({ label: 'List', value: item.name, target: item.taskTarget })));
89
+ }
90
+ for (const value of linkedValues)
91
+ if (value.target) {
92
+ const target = value.target.id ? itemAnchor(value.target.section, value.target.id)
93
+ : reportAnchor('section', value.target.section);
94
+ if (!targets.has(target))
95
+ throw new Error(`report link target does not exist: ${target}`);
96
+ }
97
+ }
98
+ function tone(value) {
99
+ return value && ['neutral', 'good', 'warning', 'bad', 'unknown'].includes(value)
100
+ ? value : 'neutral';
101
+ }
102
+ function itemAnchor(sectionId, rowId) {
103
+ return reportAnchor('item', `${sectionId}:${rowId}`);
104
+ }
105
+ function valueHtml(item) {
106
+ const rendered = item.value === null ? '<span class="unknown">Unknown</span>'
107
+ : escapeReportHtml(item.value);
108
+ const linked = item.target
109
+ ? `<a href="#${item.target.id ? itemAnchor(item.target.section, item.target.id) : reportAnchor('section', item.target.section)}">${rendered}</a>`
110
+ : rendered;
111
+ return `<span class="value tone-${tone(item.tone)}${item.multiline ? ' multiline' : ''}">${linked}</span>`;
112
+ }
113
+ function sectionHtml(section) {
114
+ const id = reportAnchor('section', section.id);
115
+ const heading = `<h2 id="${id}">${escapeReportHtml(section.title)}</h2>`;
116
+ if (section.kind === 'notice') {
117
+ return `<section class="notice tone-${tone(section.tone)}" aria-labelledby="${id}">${heading}<p>${escapeReportHtml(section.text)}</p></section>`;
118
+ }
119
+ const description = section.description ? `<p>${escapeReportHtml(section.description)}</p>` : '';
120
+ if (section.kind === 'details') {
121
+ const entries = section.values.map(item => `<div><dt>${escapeReportHtml(item.label)}</dt><dd>${valueHtml(item)}</dd></div>`).join('');
122
+ const paragraphs = (section.paragraphs ?? []).map(p => `<p class="prose">${escapeReportHtml(p)}</p>`).join('');
123
+ return `<section aria-labelledby="${id}">${heading}${description}<dl class="details">${entries}</dl>${paragraphs}</section>`;
124
+ }
125
+ if (section.kind === 'cards') {
126
+ const cards = section.items.map(item => {
127
+ const entries = item.values.map(value => `<div><dt>${escapeReportHtml(value.label)}</dt><dd>${valueHtml(value)}</dd></div>`).join('');
128
+ const paragraphs = (item.paragraphs ?? []).map(p => `<p class="prose">${escapeReportHtml(p)}</p>`).join('');
129
+ return `<details class="task-card tone-${tone(item.tone)}" id="${itemAnchor(section.id, item.id)}"${item.open ? ' open' : ''}><summary><strong>${escapeReportHtml(item.title)}</strong>${item.subtitle ? `<span>${escapeReportHtml(item.subtitle)}</span>` : ''}</summary><div class="task-body"><dl class="details">${entries}</dl>${paragraphs}</div></details>`;
130
+ }).join('');
131
+ const emptyCards = section.items.length ? '' : `<p class="empty">${escapeReportHtml(section.empty ?? 'No records.')}</p>`;
132
+ const cardTruncation = section.truncated ? `<p class="truncation" role="status">Showing ${section.truncated.shown} of ${section.truncated.total} records. Output was truncated.</p>` : '';
133
+ return `<section aria-labelledby="${id}">${heading}${description}${emptyCards}<div class="card-list">${cards}</div>${cardTruncation}</section>`;
134
+ }
135
+ if (section.kind === 'records') {
136
+ const records = section.items.map(item => {
137
+ const groups = item.groups.map(group => `<section class="record-group"><h4>${escapeReportHtml(group.title)}</h4><div class="table-wrap"><table style="--columns:2" class="field-table"><tbody>${group.values.map(value => `<tr><th scope="row">${escapeReportHtml(value.label)}</th><td>${valueHtml(value)}</td></tr>`).join('')}</tbody></table></div></section>`).join('');
138
+ return `<details class="record tone-${tone(item.tone)}" id="${itemAnchor(section.id, item.id)}"${item.open ? ' open' : ''}><summary><strong>${escapeReportHtml(item.title)}</strong><span>${escapeReportHtml(item.subtitle)}</span></summary><div class="record-body">${groups}</div></details>`;
139
+ }).join('');
140
+ const emptyRecords = section.items.length ? '' : `<p class="empty">${escapeReportHtml(section.empty ?? 'No records.')}</p>`;
141
+ const recordTruncation = section.truncated ? `<p class="truncation" role="status">Showing ${section.truncated.shown} of ${section.truncated.total} records. Output was truncated.</p>` : '';
142
+ return `<section aria-labelledby="${id}">${heading}${description}${emptyRecords}<div class="record-list">${records}</div>${recordTruncation}</section>`;
143
+ }
144
+ if (section.kind === 'list-board') {
145
+ const lists = section.items.map(item => {
146
+ const recent = item.recent.length ? `<ol class="recent-tasks">${item.recent.map(task => `<li>${valueHtml(task)}</li>`).join('')}</ol>` : '<p class="empty">No tasks in this list.</p>';
147
+ const clipped = item.recentTotal > item.recent.length ? `<p class="list-truncation">${item.recent.length} most recently updated of ${item.recentTotal}; updated-descending, then ID ascending.</p>` : item.recent.length ? '<p class="list-truncation">All tasks; updated-descending, then ID ascending.</p>' : '';
148
+ const created = item.createdAt ? `<time datetime="${escapeReportHtml(item.createdAt)}">${escapeReportHtml(item.createdAt)}</time>` : '<span class="unknown">Unknown / not supplied</span>';
149
+ return `<article class="list-unit" id="${itemAnchor(section.id, item.id)}"><header><p class="list-type">${item.builtIn ? 'Built-in list' : 'Custom list'}</p><h3>${escapeReportHtml(item.name)}</h3><p>Created: ${created}</p></header><ul class="list-signals" aria-label="Task counts"><li><strong>${item.counts.total}</strong><span>Total</span></li><li><strong>${item.counts.active}</strong><span>Active</span></li><li class="${item.counts.blocked ? 'has-warning' : ''}"><strong>${item.counts.blocked}</strong><span>Blocked</span></li><li><strong>${item.counts.terminal}</strong><span>Terminal</span></li></ul><div class="list-recent"><h4>Task index</h4>${recent}${clipped}</div><p class="list-entry"><a href="#${reportAnchor('section', item.taskTarget.section)}">Go to ${escapeReportHtml(item.name)} tasks ↓</a></p></article>`;
150
+ }).join('');
151
+ const listTruncation = section.truncated ? `<p class="truncation" role="status">Showing ${section.truncated.shown} of ${section.truncated.total} lists. Output was truncated.</p>` : '';
152
+ return `<section aria-labelledby="${id}">${heading}${section.description ? `<p>${escapeReportHtml(section.description)}</p>` : ''}<div class="list-board">${lists}</div>${listTruncation}</section>`;
153
+ }
154
+ if (section.kind === 'inbox') {
155
+ const listNav = section.lists.map(list => `<li${list.selected ? ' class="selected" aria-current="page"' : ''}><span class="list-name">${escapeReportHtml(list.name)}</span><span class="list-count">${list.count}</span>${list.blocked ? `<span class="blocked-count">${list.blocked} blocked</span>` : ''}</li>`).join('');
156
+ const taskRows = (items, toneName) => items.map(item => {
157
+ const groups = item.groups.map(group => `<section class="record-group"><h4>${escapeReportHtml(group.title)}</h4><div class="table-wrap"><table style="--columns:2" class="field-table"><tbody>${group.values.map(value => `<tr><th scope="row">${escapeReportHtml(value.label)}</th><td>${valueHtml(value)}</td></tr>`).join('')}</tbody></table></div></section>`).join('');
158
+ return `<details class="inbox-task tone-${tone(toneName)}" id="${itemAnchor(section.id, item.id)}"${toneName === 'warning' ? ' open' : ''}><summary><span class="inbox-title">${escapeReportHtml(item.title)}</span><span class="inbox-brief">${escapeReportHtml(item.brief)}</span><span class="inbox-meta"><strong>${escapeReportHtml(item.status)}</strong>${item.blocker ? ` · ${escapeReportHtml(item.blocker)}` : ''} · Updated ${escapeReportHtml(item.updated)}</span></summary><div class="inbox-detail">${groups}</div></details>`;
159
+ }).join('');
160
+ const attention = section.attention.length ? taskRows(section.attention, 'warning') : '<p class="clear-state">Nothing is blocked or waiting for review.</p>';
161
+ const active = section.active.length ? taskRows(section.active, 'good') : '<p class="empty">No active tasks in this list.</p>';
162
+ const terminal = section.terminal.length ? `<details class="terminal-group"><summary>Recently finished (${section.terminal.length})</summary><ul>${section.terminal.map(item => `<li><strong>${escapeReportHtml(item.title)}</strong><span>${escapeReportHtml(item.status)} · ${escapeReportHtml(item.outcome)} · Updated ${escapeReportHtml(item.updated)}</span></li>`).join('')}</ul></details>` : '';
163
+ return `<section class="inbox-shell" aria-labelledby="${id}"><aside class="inbox-sidebar" aria-label="Task lists"><h2>Lists</h2><ul>${listNav}</ul><p>Lists shown for orientation. This file contains <strong>${escapeReportHtml(section.selected.name)}</strong>.</p></aside><div class="inbox-main"><header><p class="eyebrow">Selected list</p><h2 id="${id}">${escapeReportHtml(section.selected.name)}</h2><p>${escapeReportHtml(section.description)}</p><p class="selection-count">${section.selected.shown} shown of ${section.selected.total} tasks</p></header><section class="attention"><h3>Needs attention</h3>${attention}</section><section><h3>Active work</h3>${active}</section>${terminal}</div></section>`;
164
+ }
165
+ if (section.kind === 'task-navigator') {
166
+ const panelAnchor = (raw) => reportAnchor('list', raw);
167
+ const links = section.lists.map(list => `<li><a href="#${panelAnchor(list.id)}"><span>${escapeReportHtml(list.name)}${list.id === section.defaultList ? ' <small>Starts here</small>' : ''}</span><strong>${list.count}</strong>${list.blocked ? `<small>${list.blocked} blocked</small>` : ''}</a></li>`).join('');
168
+ const taskRows = (items) => items.map(item => {
169
+ const groups = item.groups.map(group => `<section class="record-group"><h4>${escapeReportHtml(group.title)}</h4><div class="table-wrap"><table style="--columns:2" class="field-table"><tbody>${group.values.map(value => `<tr><th scope="row">${escapeReportHtml(value.label)}</th><td>${valueHtml(value)}</td></tr>`).join('')}</tbody></table></div></section>`).join('');
170
+ return `<tr class="task-summary" id="${itemAnchor(section.id, item.id)}" data-task-id="${escapeReportHtml(item.id)}" data-task-title="${escapeReportHtml(item.title)}" data-task-brief="${escapeReportHtml(item.brief)}" data-task-status="${escapeReportHtml(item.status)}"><th scope="row">${escapeReportHtml(item.title)}</th><td>${escapeReportHtml(item.brief)}</td><td><strong class="status-label">${escapeReportHtml(item.status)}</strong>${item.blocked ? '<span class="blocked-indicator">Blocked</span>' : ''}</td></tr><tr class="task-detail-row"><td colspan="3"><details class="task-table-details"><summary>View full details for ${escapeReportHtml(item.title)}</summary><div class="inbox-detail">${item.blocked ? `<p class="blocked-reason"><strong>Blocked:</strong> ${escapeReportHtml(item.blocked)}</p>` : ''}${groups}</div></details></td></tr>`;
171
+ }).join('');
172
+ const panels = section.panels.map(panel => `<section class="list-panel${panel.id === section.defaultList ? ' default-panel' : ''}" id="${panelAnchor(panel.id)}" tabindex="-1"><header><p class="eyebrow">Task list</p><h2>${escapeReportHtml(panel.name)}</h2><p>${escapeReportHtml(panel.description)}</p><p class="selection-count">${panel.shown} shown of ${panel.total} tasks</p></header>${panel.tasks.length ? `<div class="table-wrap task-index"><table style="--columns:3"><thead><tr><th scope="col">Name</th><th scope="col">Brief description</th><th scope="col">Status</th></tr></thead><tbody>${taskRows(panel.tasks)}</tbody></table></div>` : '<p class="empty">No tasks in this list.</p>'}</section>`).join('');
173
+ const selectors = section.lists.map(list => `.navigator-shell:has(#${panelAnchor(list.id)}:target) a[href="#${panelAnchor(list.id)}"]`).join(',');
174
+ return `<section class="navigator" aria-labelledby="${id}"><style>@supports selector(body:has(*)){.navigator:not(:has(.list-panel:target)) .navigator-sidebar a[href="#${panelAnchor(section.defaultList)}"],${selectors}{background:var(--fg);color:var(--bg)}@media(max-width:44rem){.navigator-panels .list-panel{display:none}.navigator:not(:has(.list-panel:target)) .default-panel,.navigator-panels .list-panel:target{display:block}}}</style><header class="navigator-hero"><p class="eyebrow">Fleet tasks</p><h1 id="${id}">${escapeReportHtml(section.title)}</h1><p>${escapeReportHtml(section.description)}</p></header><div class="navigator-shell"><aside class="navigator-sidebar" aria-label="Included task lists"><h2>Jump to list</h2><ul>${links}</ul><p class="bounded-note">Only the bounded lists included in this file are available.</p></aside><div class="navigator-panels">${panels}</div></div></section>`;
175
+ }
176
+ const head = section.columns.map(column => `<th scope="col">${escapeReportHtml(column)}</th>`).join('');
177
+ const rows = section.rows.map(row => {
178
+ const rowId = row.id ? ` id="${itemAnchor(section.id, row.id)}"` : '';
179
+ return `<tr${rowId}>${row.cells.map((cell, index) => `${index === 0 ? '<th scope="row">' : '<td>'}${valueHtml(cell)}${index === 0 ? '</th>' : '</td>'}`).join('')}</tr>`;
180
+ }).join('');
181
+ const empty = section.rows.length ? '' : `<p class="empty">${escapeReportHtml(section.empty ?? 'No records.')}</p>`;
182
+ const truncation = section.truncated
183
+ ? `<p class="truncation" role="status">Showing ${section.truncated.shown} of ${section.truncated.total} records. Output was truncated.</p>` : '';
184
+ return `<section aria-labelledby="${id}">${heading}${description}${empty}${section.rows.length ? `<div class="table-wrap"><table style="--columns:${section.columns.length}"><thead><tr>${head}</tr></thead><tbody>${rows}</tbody></table></div>` : ''}${truncation}</section>`;
185
+ }
186
+ export function reportCompare(a, b) {
187
+ return a < b ? -1 : a > b ? 1 : 0;
188
+ }
189
+ function canonicalEntries(values) {
190
+ return Object.entries(values ?? {}).sort(([a], [b]) => reportCompare(a, b));
191
+ }
192
+ export function renderReportHtml(model) {
193
+ validateModel(model);
194
+ const isFocusedInbox = model.sections.length === 1 && (model.sections[0]?.kind === 'inbox' || model.sections[0]?.kind === 'task-navigator');
195
+ const nav = model.sections.map(section => `<li><a href="#${reportAnchor('section', section.id)}">${escapeReportHtml(section.title)}</a></li>`).join('');
196
+ const summary = model.summary?.length
197
+ ? `<dl class="summary">${model.summary.map(item => `<div><dt>${escapeReportHtml(item.label)}</dt><dd>${valueHtml(item)}</dd></div>`).join('')}</dl>` : '';
198
+ const filters = canonicalEntries(model.filters).map(([key, value]) => `<li><strong>${escapeReportHtml(key)}:</strong> ${escapeReportHtml(value)}</li>`).join('');
199
+ const observations = canonicalEntries(model.observedAt).map(([key, value]) => {
200
+ const time = reportTime(value);
201
+ return `<li><strong>${escapeReportHtml(key)}:</strong> ${time.datetime ? `<time datetime="${time.datetime}">${time.text}</time>` : escapeReportHtml(time.text)}</li>`;
202
+ }).join('');
203
+ const unavailable = [...(model.unavailable ?? [])].sort(reportCompare);
204
+ const unavailableHtml = unavailable.length ? `<aside class="notice tone-warning"><h2>Unavailable data</h2><ul>${unavailable.map(x => `<li>${escapeReportHtml(x)}</li>`).join('')}</ul></aside>` : '';
205
+ const reportMetadata = isFocusedInbox
206
+ ? `<details class="report-metadata"><summary>Report details</summary><div>${filters ? `<h2>Filters</h2><ul>${filters}</ul>` : ''}${observations ? `<h2>Source observation times</h2><ul>${observations}</ul>` : ''}</div></details>`
207
+ : '';
208
+ return `<!doctype html>
209
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:">
210
+ <meta name="generator" content="${escapeReportHtml(`${model.source.name} ${model.source.version}`)}"><title>${escapeReportHtml(model.title)}</title>
211
+ <style>:root{color-scheme:light dark;--bg:#fff;--fg:#17202a;--muted:#5d6873;--line:#c8d0d8;--panel:#f5f7f9;--link:#075ea8;--good:#176b3a;--warn:#805b00;--bad:#a32020;--attention-bg:#fff4d6;--attention-fg:#352300;--attention-border:#8a5700;--attention-card-bg:#ffffff;--attention-card-fg:#17202a;--attention-accent:#684000}@media(prefers-color-scheme:dark){:root{--bg:#11161b;--fg:#edf2f7;--muted:#acb7c2;--line:#46515c;--panel:#1b232b;--link:#73bffc;--good:#70d69a;--warn:#ffd166;--bad:#ff8585;--attention-bg:#2a210d;--attention-fg:#fff2c2;--attention-border:#e0ad3e;--attention-card-bg:#171c22;--attention-card-fg:#f4f7fa;--attention-accent:#ffd166}}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,sans-serif}header,main,footer{max-width:76rem;margin:auto;padding:1rem 1.25rem}header{border-bottom:1px solid var(--line)}h1{font-size:clamp(1.7rem,4vw,2.5rem);margin:.2rem 0}h2{margin-top:2rem}a{color:var(--link);text-underline-offset:.15em}a:focus,summary:focus{outline:3px solid currentColor;outline-offset:3px}.meta,.empty{color:var(--muted)}nav ul{display:flex;flex-wrap:wrap;gap:.4rem 1.2rem;list-style:none;padding-left:0}.summary,.details{display:grid;grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));gap:.75rem}.summary div,.details div,.notice{padding:.8rem;border:1px solid var(--line);border-radius:.4rem;background:var(--panel)}dt{font-size:.85rem;color:var(--muted)}dd{margin:.15rem 0 0;font-weight:650;overflow-wrap:anywhere}.card-list{display:grid;gap:.75rem}.task-card{color:var(--fg);border:1px solid var(--line);border-left:4px solid var(--line);border-radius:.45rem;background:var(--panel)}.task-card.tone-good{color:var(--fg);border-left-color:var(--good)}.task-card.tone-warning{color:var(--fg);border-left-color:var(--warn)}.task-card.tone-bad{color:var(--fg);border-left-color:var(--bad)}.task-card summary{cursor:pointer;padding:.9rem 1rem;color:var(--fg)}.task-card summary strong,.task-card summary span{display:block}.task-card summary span{color:var(--muted);font-size:.9rem;margin-top:.15rem}.task-body{padding:0 1rem 1rem}.navigator-hero{max-width:76rem;margin:1rem auto;border:0;border-radius:1rem;padding:clamp(1.25rem,4vw,2.5rem);color:#fff;background:linear-gradient(135deg,#18356f 0%,#4a3aa8 100%);box-shadow:0 .8rem 2rem rgba(29,36,88,.18)}.navigator-hero h1{font-size:clamp(2rem,5vw,3.5rem);line-height:1.05;margin:.1rem 0}.navigator-hero .eyebrow,.navigator-hero p{color:#fff}.navigator-shell{display:grid;grid-template-columns:15rem minmax(0,1fr);gap:1.5rem;align-items:start}.navigator-panels{min-width:0}.navigator-sidebar{position:sticky;top:1rem;border:1px solid var(--line);border-radius:.6rem;background:var(--panel);padding:1rem}.navigator-sidebar h2{margin-top:0}.navigator-sidebar ul{list-style:none;padding:0;margin:0}.navigator-sidebar a{display:grid;grid-template-columns:1fr auto;gap:.15rem .5rem;padding:.65rem;border-radius:.35rem;color:var(--fg);text-decoration:none}.navigator-sidebar a small{display:block;font-weight:400}.navigator-sidebar a>small{grid-column:1/-1;color:var(--warn)}.bounded-note{font-size:.8rem;color:var(--muted)}.task-index{margin-top:1rem}.task-index table{min-width:48rem}.task-summary>th{width:16rem;border-left:4px solid var(--link);color:var(--link);font-size:1.05rem;font-weight:800}.task-summary>td:last-child{width:9rem}.status-label,.blocked-indicator{display:block}.blocked-indicator{margin-top:.2rem;color:var(--warn);font-size:.78rem;font-weight:700}.task-detail-row>td{padding:.35rem .7rem 1rem;background:var(--panel)}.task-table-details summary{cursor:pointer;font-size:.85rem;font-weight:600;color:var(--muted)}.blocked-reason{border-left:4px solid var(--warn);padding:.6rem;color:var(--fg)}.list-panel{scroll-margin-top:1rem;margin-bottom:3rem}.list-panel:target{outline:3px solid var(--link);outline-offset:.45rem;border-radius:.5rem}.list-panel>header{border-bottom:1px solid var(--line);padding:0 0 1rem}.list-panel>header h2{font-size:2rem;margin:.1rem 0}.inbox-shell{display:grid;grid-template-columns:15rem minmax(0,1fr);gap:1.5rem;align-items:start}.inbox-sidebar{position:sticky;top:1rem;border:1px solid var(--line);border-radius:.6rem;background:var(--panel);padding:1rem}.inbox-sidebar h2{margin-top:0}.inbox-sidebar ul{list-style:none;padding:0;margin:0}.inbox-sidebar li{display:grid;grid-template-columns:1fr auto;gap:.15rem .5rem;padding:.65rem;border-radius:.35rem}.inbox-sidebar li.selected{background:var(--fg);color:var(--bg);font-weight:700}.inbox-sidebar .blocked-count{grid-column:1/-1;font-size:.78rem;color:var(--warn)}.inbox-sidebar li.selected .blocked-count{color:inherit}.inbox-sidebar>p{font-size:.8rem;color:var(--muted)}.inbox-main>header{border:0;border-radius:1rem;padding:clamp(1.25rem,4vw,2.5rem);color:#fff;background:linear-gradient(135deg,#18356f 0%,#4a3aa8 100%);box-shadow:0 .8rem 2rem rgba(29,36,88,.18)}.inbox-main>header h2{font-size:clamp(2rem,5vw,3.5rem);line-height:1.05;margin:.1rem 0}.inbox-main>header .eyebrow,.inbox-main>header p{color:#fff}.inbox-main>header .selection-count{display:inline-block;margin:.5rem 0 0;padding:.3rem .65rem;border:1px solid rgba(255,255,255,.55);border-radius:999px;background:rgba(255,255,255,.12)}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-size:.75rem;font-weight:700;color:var(--muted)}.selection-count{font-weight:700}.attention{margin-top:1.5rem;border:1px solid var(--attention-border);border-left:5px solid var(--attention-border);border-radius:.75rem;padding:0 1rem 1rem;background:var(--attention-bg);color:var(--attention-fg)}.attention h3{color:var(--attention-fg)}.attention .inbox-task{background:var(--attention-card-bg);color:var(--attention-card-fg)}.attention .inbox-title,.attention .inbox-brief,.attention .inbox-meta,.attention .field-table,.attention .value{color:var(--attention-card-fg)}.attention .tone-warning{color:var(--attention-accent)}.attention .clear-state{color:var(--attention-fg)}.inbox-task{color:var(--fg);border:1px solid var(--line);border-radius:.5rem;background:var(--panel);margin:.65rem 0}.inbox-task.tone-warning,.inbox-task.tone-good{color:var(--fg)}.inbox-task summary{cursor:pointer;padding:1rem}.inbox-title,.inbox-brief,.inbox-meta{display:block}.inbox-title{font-weight:750}.inbox-brief{margin:.25rem 0}.inbox-meta{font-size:.85rem;color:var(--muted)}.inbox-detail{padding:0 1rem 1rem}.clear-state{border:1px solid var(--good);border-radius:.4rem;padding:.8rem}.terminal-group{margin-top:2rem;border-top:1px solid var(--line);padding-top:1rem;color:var(--muted)}.terminal-group summary{cursor:pointer;font-weight:700}.terminal-group li span{display:block;font-size:.85rem}.report-metadata{margin-top:2rem;border-top:1px solid var(--line);padding-top:1rem;color:var(--muted)}.report-metadata summary{cursor:pointer;font-weight:700}.report-metadata h2{font-size:1rem;margin:1rem 0 .25rem}.list-board{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,20rem),1fr));gap:1rem}.list-unit{border:1px solid var(--line);border-radius:.65rem;background:var(--panel);padding:1rem;display:flex;flex-direction:column}.list-unit header h3{font-size:1.35rem;margin:.1rem 0}.list-unit header p{margin:.15rem 0;color:var(--muted)}.list-type{text-transform:uppercase;letter-spacing:.08em;font-size:.75rem;font-weight:700}.list-signals{display:grid;grid-template-columns:repeat(4,1fr);gap:.4rem;list-style:none;padding:0;margin:1rem 0}.list-signals li{border:1px solid var(--line);border-radius:.35rem;padding:.5rem;text-align:center}.list-signals strong,.list-signals span{display:block}.list-signals strong{font-size:1.25rem}.list-signals span{font-size:.75rem;color:var(--muted)}.list-signals .has-warning{border-color:var(--warn)}.recent-tasks{padding-left:1.4rem}.recent-tasks li{margin:.35rem 0}.list-truncation{font-size:.85rem;color:var(--muted)}.list-entry{margin-top:auto;padding-top:.75rem;font-weight:700}.record-list{display:grid;gap:1rem}.record{color:var(--fg);border:1px solid var(--line);border-radius:.5rem;background:var(--panel)}.record.tone-good,.record.tone-warning,.record.tone-bad{color:var(--fg)}.record.tone-good{border-left:4px solid var(--good)}.record.tone-warning{border-left:4px solid var(--warn)}.record.tone-bad{border-left:4px solid var(--bad)}.record summary{cursor:pointer;padding:1rem}.record summary strong,.record summary span{display:block}.record summary span{color:var(--muted);font-size:.9rem}.record-body{padding:0 1rem 1rem}.record-group h4{margin:1rem 0 .4rem}.field-table{min-width:100%}.field-table th{width:12rem}.field-table .multiline{white-space:pre-wrap;font-weight:400}.table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:.4rem}table{border-collapse:collapse;width:100%;min-width:max(100%,calc(var(--columns) * 9rem))}th,td{text-align:left;padding:.6rem .7rem;border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere;min-width:8rem}thead{background:var(--panel)}tbody tr:last-child>*{border-bottom:0}.tone-good{color:var(--good)}.tone-warning{color:var(--warn)}.tone-bad{color:var(--bad)}.tone-unknown,.unknown{color:var(--muted);font-style:italic}.truncation{font-weight:700}.prose{white-space:pre-wrap;overflow-wrap:anywhere}footer{border-top:1px solid var(--line);color:var(--muted);font-size:.85rem}@media(max-width:44rem){.navigator-hero{margin:.5rem .75rem;padding:1.1rem 1rem;box-shadow:none}.navigator-hero h1{font-size:2rem}.navigator-hero p{margin:.35rem 0}.navigator-shell{grid-template-columns:1fr;gap:1rem}.navigator-sidebar{position:static;margin:0}.navigator-sidebar h2{font-size:1rem;margin:0 0 .5rem}.navigator-sidebar ul{display:flex;flex-wrap:wrap;gap:.35rem}.navigator-sidebar li{flex:1 1 8rem}.navigator-sidebar a{padding:.5rem}.bounded-note{margin:.5rem 0 0}.list-panel{margin-bottom:1rem}.list-panel>header h2{font-size:1.65rem}.task-index{overflow:visible;border:0}.task-index>table{display:block;min-width:0;width:100%}.task-index>table>thead{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.task-index>table>tbody{display:grid;gap:.75rem}.task-summary{display:grid;grid-template-columns:minmax(0,1fr) auto;border:1px solid var(--line);border-bottom:0;border-radius:.55rem .55rem 0 0;background:var(--bg)}.task-summary>th,.task-summary>td{display:block;min-width:0;border:0;padding:.65rem}.task-summary>th{grid-column:1;grid-row:1;width:auto;border-left:4px solid var(--link)}.task-summary>td:nth-child(2){grid-column:1/-1;grid-row:2;padding-top:0}.task-summary>td:last-child{grid-column:2;grid-row:1;width:auto;text-align:right}.task-detail-row{display:block;margin-top:-.75rem}.task-detail-row>td{display:block;border:1px solid var(--line);border-top:0;border-radius:0 0 .55rem .55rem;padding:.45rem .65rem}.task-table-details summary{font-size:.8rem}.inbox-shell{grid-template-columns:1fr}.inbox-shell{grid-template-columns:1fr}.inbox-sidebar{position:static}.inbox-sidebar ul{display:grid;grid-template-columns:repeat(2,1fr)}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}@media(forced-colors:active){.inbox-main>header{background:Canvas;color:CanvasText;border:2px solid CanvasText}.inbox-main>header .eyebrow,.inbox-main>header p{color:CanvasText}.attention{border:2px solid CanvasText}}@media print{.navigator-sidebar{position:static}.list-panel{display:block!important;outline:0!important}:root{color-scheme:light}nav{display:none}body{font-size:10pt}.table-wrap{overflow:visible}table{min-width:100%}th,td{min-width:0}.task-card{break-inside:avoid}.task-card:not([open])>:not(summary),.record:not([open])>:not(summary),.inbox-task:not([open])>:not(summary),.task-table-details:not([open])>:not(summary){display:block}section{break-inside:avoid}a{color:inherit;text-decoration:none}}</style></head><body>
212
+ ${isFocusedInbox ? '' : `<header><p class="meta">Fleet read-only report</p><h1>${escapeReportHtml(model.title)}</h1>${model.description ? `<p>${escapeReportHtml(model.description)}</p>` : ''}${summary}<nav aria-label="Report sections"><h2>Contents</h2><ul>${nav}</ul></nav>${filters ? `<section aria-labelledby="filters-heading"><h2 id="filters-heading">Filters</h2><ul>${filters}</ul></section>` : ''}${observations ? `<section aria-labelledby="observations-heading"><h2 id="observations-heading">Source observation times</h2><ul>${observations}</ul></section>` : ''}</header>`}
213
+ <main>${unavailableHtml}${model.sections.map(sectionHtml).join('')}${reportMetadata}</main>
214
+ <footer>${(() => { const t = reportTime(model.generatedAt); return `Generated at ${t.datetime ? `<time datetime="${t.datetime}">${t.text}</time>` : escapeReportHtml(t.text)}`; })()} by ${escapeReportHtml(model.source.name)} ${escapeReportHtml(model.source.version)}. This script-free artifact is read-only and contains no Fleet actions.</footer></body></html>\n`;
215
+ }
216
+ function safeFilenamePart(value) {
217
+ const safe = value.toLowerCase().replace(/[^a-z0-9_-]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 80);
218
+ return safe || 'report';
219
+ }
220
+ export function createReportArtifact(model, resourceId) {
221
+ const html = renderReportHtml(model);
222
+ const filename = `fleet-${safeFilenamePart(model.reportKind)}${resourceId ? `-${safeFilenamePart(resourceId)}` : ''}.html`;
223
+ return {
224
+ html,
225
+ metadata: {
226
+ schemaVersion: 1, reportKind: model.reportKind, filename, mediaType: MEDIA_TYPE,
227
+ byteSize: Buffer.byteLength(html), source: model.source, generatedAt: model.generatedAt,
228
+ filters: Object.fromEntries(canonicalEntries(model.filters)),
229
+ truncated: model.sections.some(section => section.kind === 'task-navigator'
230
+ ? section.panels.some(panel => panel.shown < panel.total)
231
+ : (section.kind === 'table' || section.kind === 'cards' || section.kind === 'records' || section.kind === 'list-board') && Boolean(section.truncated)),
232
+ unavailable: [...(model.unavailable ?? [])].sort(reportCompare),
233
+ observedAt: Object.fromEntries(canonicalEntries(model.observedAt)),
234
+ truncation: model.sections.flatMap(section => section.kind === 'task-navigator'
235
+ ? section.panels.filter(panel => panel.shown < panel.total).map(panel => ({ section: `${section.id}:${panel.id}`, shown: panel.shown, total: panel.total }))
236
+ : (section.kind === 'table' || section.kind === 'cards' || section.kind === 'records' || section.kind === 'list-board') && section.truncated
237
+ ? [{ section: section.id, ...section.truncated }] : []),
238
+ },
239
+ };
240
+ }
@@ -0,0 +1,42 @@
1
+ import type { ReportArtifact, ReportKind, ReportRequest, ReportViewModel } from './types.js';
2
+ export interface AuthorizedReportProvider<T = unknown> {
3
+ readonly surface: 'cli' | 'rest' | 'messenger';
4
+ /** Implementations must authorize and allowlist every returned nested resource. */
5
+ collect(request: ReportRequest, limits: {
6
+ maxRecords: number;
7
+ }): Promise<SafeSnapshot<T>>;
8
+ }
9
+ export interface SafeSnapshot<T> {
10
+ /** Data must already be constructed by a per-kind allowlist. */
11
+ data: T;
12
+ observedAt: Record<string, string>;
13
+ unavailable: string[];
14
+ stale: string[];
15
+ bounds: {
16
+ shown: number;
17
+ total: number;
18
+ truncated: boolean;
19
+ };
20
+ }
21
+ export interface ReportDefinition<R extends ReportRequest = ReportRequest, S = unknown> {
22
+ kind: R['kind'];
23
+ maxRecords: number;
24
+ validate(request: ReportRequest): request is R;
25
+ resourceId(request: R): string | undefined;
26
+ present(request: R, snapshot: SafeSnapshot<S>, generatedAt: string): ReportViewModel;
27
+ }
28
+ export declare class ReportRegistry {
29
+ #private;
30
+ register<R extends ReportRequest, S>(definition: ReportDefinition<R, S>): this;
31
+ get(kind: ReportKind): ReportDefinition;
32
+ kinds(): ReportKind[];
33
+ }
34
+ export declare class FleetReportService {
35
+ private readonly registry;
36
+ private readonly maxBytes;
37
+ constructor(registry: ReportRegistry, maxBytes?: number);
38
+ create(request: ReportRequest, context: {
39
+ provider: AuthorizedReportProvider<any>;
40
+ generatedAt: string;
41
+ }): Promise<ReportArtifact>;
42
+ }
@@ -0,0 +1,57 @@
1
+ import { createReportArtifact } from './render.js';
2
+ export class ReportRegistry {
3
+ #definitions = new Map();
4
+ register(definition) {
5
+ if (this.#definitions.has(definition.kind))
6
+ throw new Error(`duplicate report kind: ${definition.kind}`);
7
+ this.#definitions.set(definition.kind, definition);
8
+ return this;
9
+ }
10
+ get(kind) {
11
+ const definition = this.#definitions.get(kind);
12
+ if (!definition)
13
+ throw new Error(`unsupported report kind: ${kind}`);
14
+ return definition;
15
+ }
16
+ kinds() {
17
+ return [...this.#definitions.keys()].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
18
+ }
19
+ }
20
+ export class FleetReportService {
21
+ registry;
22
+ maxBytes;
23
+ constructor(registry, maxBytes = 5 * 1024 * 1024) {
24
+ this.registry = registry;
25
+ this.maxBytes = maxBytes;
26
+ }
27
+ async create(request, context) {
28
+ if (request.viewer.surface !== context.provider.surface)
29
+ throw new Error('report viewer does not match the authorized provider');
30
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u.test(context.generatedAt)
31
+ || Number.isNaN(Date.parse(context.generatedAt)))
32
+ throw new Error('generatedAt must be a valid UTC RFC3339 timestamp');
33
+ const definition = this.registry.get(request.kind);
34
+ if (!definition.validate(request))
35
+ throw new Error('invalid report selector');
36
+ const resourceId = definition.resourceId(request);
37
+ const snapshot = await context.provider.collect(request, { maxRecords: definition.maxRecords });
38
+ if (snapshot.bounds.shown > definition.maxRecords)
39
+ throw new Error('authorized report provider exceeded the record cap');
40
+ if (snapshot.bounds.total > snapshot.bounds.shown && !snapshot.bounds.truncated)
41
+ throw new Error('authorized report provider exceeded the record cap without truncation');
42
+ const model = definition.present(request, snapshot, context.generatedAt);
43
+ if (model.reportKind !== request.kind)
44
+ throw new Error('report presenter returned the wrong kind');
45
+ model.observedAt = { ...snapshot.observedAt, ...model.observedAt };
46
+ model.unavailable = [...new Set([...snapshot.unavailable, ...snapshot.stale.map(x => `${x} (stale)`), ...(model.unavailable ?? [])])];
47
+ const visibleTruncation = model.sections.some(section => section.kind === 'task-navigator'
48
+ ? section.panels.some(panel => panel.shown < panel.total)
49
+ : (section.kind === 'table' || section.kind === 'cards' || section.kind === 'records' || section.kind === 'list-board') && Boolean(section.truncated));
50
+ if (snapshot.bounds.truncated && !visibleTruncation)
51
+ throw new Error('report presenter did not disclose snapshot truncation');
52
+ const artifact = createReportArtifact(model, resourceId);
53
+ if (artifact.metadata.byteSize > this.maxBytes)
54
+ throw new Error(`report exceeds the ${this.maxBytes}-byte artifact limit`);
55
+ return artifact;
56
+ }
57
+ }
@@ -0,0 +1,52 @@
1
+ import type { TaskListRecord, TaskRecord, TaskState } from '../rooms-tasks/types.js';
2
+ import type { ReportViewModel } from './types.js';
3
+ import type { ReportArtifact, ReportViewer } from './types.js';
4
+ export declare const TASK_REPORT_MAX_RECORDS = 200;
5
+ export declare const TASK_REPORT_MAX_TEXT = 2000;
6
+ export declare function buildTasksReport(input: {
7
+ lists: TaskListRecord[];
8
+ tasks: TaskRecord[];
9
+ generatedAt: string;
10
+ selectedList?: string;
11
+ state?: TaskState;
12
+ source?: {
13
+ name: string;
14
+ version: string;
15
+ buildId?: string;
16
+ };
17
+ observedAt?: string;
18
+ totalsByList?: Record<string, number>;
19
+ maxRecords?: number;
20
+ }): ReportViewModel;
21
+ interface RawTaskReportData {
22
+ lists: TaskListRecord[];
23
+ tasks: TaskRecord[];
24
+ }
25
+ export declare function createTasksReport(input: {
26
+ viewer: ReportViewer;
27
+ collect: (limits: {
28
+ maxRecords: number;
29
+ }) => Promise<RawTaskReportData> | RawTaskReportData;
30
+ generatedAt?: string;
31
+ state?: TaskState;
32
+ selectedList?: string;
33
+ source?: {
34
+ name: string;
35
+ version: string;
36
+ buildId?: string;
37
+ };
38
+ maxBytes?: number;
39
+ }): Promise<ReportArtifact>;
40
+ export declare function createTaskReport(input: {
41
+ viewer: ReportViewer;
42
+ taskId: string;
43
+ collect: () => Promise<TaskRecord> | TaskRecord;
44
+ generatedAt?: string;
45
+ source?: {
46
+ name: string;
47
+ version: string;
48
+ buildId?: string;
49
+ };
50
+ maxBytes?: number;
51
+ }): Promise<ReportArtifact>;
52
+ export {};
@@ -0,0 +1,177 @@
1
+ import { FleetReportService, ReportRegistry } from './service.js';
2
+ import { createHash } from 'node:crypto';
3
+ export const TASK_REPORT_MAX_RECORDS = 200;
4
+ export const TASK_REPORT_MAX_TEXT = 2_000;
5
+ const TASK_REPORT_BRIEF_PREVIEW = 240;
6
+ const STATUS_LABEL = {
7
+ backlog: 'Backlog', provisioning: 'Provisioning', active: 'Active', review: 'Review',
8
+ done: 'Done', cancelled: 'Cancelled', failed: 'Failed',
9
+ };
10
+ const STATUS_TONE = {
11
+ backlog: 'neutral', provisioning: 'warning', active: 'good', review: 'warning',
12
+ done: 'good', cancelled: 'neutral', failed: 'bad',
13
+ };
14
+ const bounded = (value, limit = TASK_REPORT_MAX_TEXT) => {
15
+ if (value === undefined)
16
+ return null;
17
+ const points = [...value];
18
+ return points.length <= limit ? value
19
+ : `${points.slice(0, Math.max(0, limit - 34)).join('')}… [truncated from ${points.length} chars]`;
20
+ };
21
+ const briefPreview = (value) => {
22
+ const first = value?.split(/\r?\n/u).map(line => line.trim()).find(Boolean);
23
+ return bounded(first, TASK_REPORT_BRIEF_PREVIEW) ?? 'No brief supplied.';
24
+ };
25
+ const field = (label, value, tone, multiline = false) => ({
26
+ label, value, ...(tone ? { tone } : {}), ...(multiline ? { multiline: true } : {}),
27
+ });
28
+ const allowlistList = (list) => ({
29
+ list_id: list.list_id, name: bounded(list.name) ?? 'Unnamed list', built_in: list.built_in,
30
+ });
31
+ const allowlistTask = (task) => ({
32
+ task_id: task.task_id, list_id: task.list_id, list_name: bounded(task.list_name) ?? 'default',
33
+ title: bounded(task.title) ?? 'Untitled task', ...(task.brief === undefined ? {} : { brief: bounded(task.brief) ?? '' }),
34
+ state: task.state, ...(task.blocked ? { blocked: { reason: bounded(task.blocked.reason) ?? 'Blocked' } } : {}),
35
+ ...(task.room_id ? { room_id: bounded(task.room_id) ?? undefined } : {}),
36
+ ...(task.template ? { template: { name: bounded(task.template.name) ?? 'Unknown', version: task.template.version } } : {}),
37
+ member_roles: task.member_roles.map(member => ({ name: bounded(member.name) ?? 'Unknown', slot: bounded(member.slot) ?? 'Unknown', cowork_role: bounded(member.cowork_role) ?? 'Unknown' })),
38
+ origin: { type: task.origin.type }, created_at: task.created_at,
39
+ ...(task.started_at ? { started_at: task.started_at } : {}), ...(task.ended_at ? { ended_at: task.ended_at } : {}),
40
+ ...(task.outcome ? { outcome: { summary: bounded(task.outcome.summary) ?? undefined } } : {}),
41
+ });
42
+ function safeTask(task) {
43
+ const status = STATUS_LABEL[task.state];
44
+ const title = bounded(task.title) ?? 'Untitled task';
45
+ const brief = briefPreview(task.brief);
46
+ const groups = [
47
+ { title: '1. Identity and lifecycle', values: [
48
+ field('Task ID', task.task_id), field('Title', title),
49
+ field('Lifecycle status', status, STATUS_TONE[task.state]), field('List', task.list_name),
50
+ field('Blocked indicator', task.blocked ? bounded(task.blocked.reason) : 'No — unblocked', task.blocked ? 'warning' : undefined),
51
+ ] },
52
+ { title: '2. Task content', values: [
53
+ field('Brief', brief, undefined, true),
54
+ field('Detailed description', bounded(task.brief), undefined, true),
55
+ ] },
56
+ { title: '3. Work context', values: [
57
+ field('Room', task.room_id ?? null),
58
+ field('Template', task.template ? `${task.template.name}@${task.template.version}` : null),
59
+ field('Members and roles', task.member_roles.length
60
+ ? bounded(task.member_roles.map(member => `${member.name} — ${member.slot} — ${member.cowork_role}`).join('\n')) : null, undefined, true),
61
+ field('Origin', task.origin.type),
62
+ ] },
63
+ { title: '4. Timeline and outcome', values: [
64
+ field('Created', task.created_at), field('Started', task.started_at ?? null), field('Ended', task.ended_at ?? null),
65
+ field('Terminal outcome', bounded(task.outcome?.summary), undefined, true),
66
+ ] },
67
+ ];
68
+ return {
69
+ id: task.task_id, title, brief, status,
70
+ ...(task.blocked ? { blocked: bounded(task.blocked.reason) ?? 'Blocked' } : {}), updated: task.ended_at ?? task.started_at ?? task.created_at,
71
+ groups,
72
+ };
73
+ }
74
+ function presentTasksReport(input) {
75
+ const maxRecords = input.maxRecords ?? TASK_REPORT_MAX_RECORDS;
76
+ const includedLists = input.selectedList
77
+ ? input.lists.filter(list => list.name === input.selectedList) : input.lists;
78
+ let remaining = maxRecords;
79
+ const panels = includedLists.map(list => {
80
+ const all = input.tasks.filter(task => task.list_id === list.list_id);
81
+ const total = input.totalsByList?.[list.list_id] ?? all.length;
82
+ const shown = all.slice(0, remaining);
83
+ remaining -= shown.length;
84
+ return {
85
+ id: list.list_id, name: list.name, total, shown: shown.length,
86
+ description: shown.length === total ? `All ${total} tasks are included in source order.`
87
+ : `${shown.length} of ${total} tasks are included by the report bound; source order is preserved.`,
88
+ tasks: shown.map(safeTask),
89
+ };
90
+ });
91
+ const defaultList = includedLists.find(list => list.name === input.selectedList)?.list_id
92
+ ?? includedLists.find(list => list.built_in)?.list_id ?? includedLists[0]?.list_id ?? 'default';
93
+ const section = {
94
+ kind: 'task-navigator', id: 'task-lists', title: 'Fleet task lists',
95
+ description: 'One read-only task report shared by task and list commands.',
96
+ lists: panels.map(panel => ({ id: panel.id, name: panel.name, count: panel.total,
97
+ blocked: panel.tasks.filter(task => task.blocked).length })), defaultList, panels,
98
+ };
99
+ return {
100
+ schemaVersion: 1, reportKind: 'tasks', title: 'Fleet task lists',
101
+ description: section.description, generatedAt: input.generatedAt,
102
+ source: input.source ?? { name: 'ours-fleet', version: '1' },
103
+ filters: { ...(input.selectedList ? { list: input.selectedList } : {}), ...(input.state ? { state: input.state } : {}) },
104
+ observedAt: input.observedAt ? { tasks: input.observedAt } : {}, unavailable: [], sections: [section],
105
+ };
106
+ }
107
+ export function buildTasksReport(input) {
108
+ return presentTasksReport({ ...input, lists: input.lists.map(allowlistList), tasks: input.tasks.map(allowlistTask) });
109
+ }
110
+ export async function createTasksReport(input) {
111
+ const request = {
112
+ kind: 'tasks', viewer: input.viewer,
113
+ ...(input.state ? { state: input.state } : {}),
114
+ ...(input.selectedList ? { list: input.selectedList } : {}),
115
+ };
116
+ const registry = new ReportRegistry().register({
117
+ kind: 'tasks', maxRecords: TASK_REPORT_MAX_RECORDS,
118
+ validate: (candidate) => candidate.kind === 'tasks',
119
+ resourceId: () => undefined,
120
+ present: (candidate, snapshot, generatedAt) => presentTasksReport({
121
+ ...snapshot.data, generatedAt, observedAt: snapshot.observedAt.tasks, source: input.source,
122
+ selectedList: candidate.list, state: candidate.state,
123
+ maxRecords: TASK_REPORT_MAX_RECORDS,
124
+ }),
125
+ });
126
+ const provider = {
127
+ surface: input.viewer.surface,
128
+ collect: async (_request, limits) => {
129
+ const data = await input.collect(limits);
130
+ const observedAt = new Date().toISOString();
131
+ const total = data.tasks.length;
132
+ const tasks = data.tasks.slice(0, limits.maxRecords).map(allowlistTask);
133
+ const totalsByList = data.tasks.reduce((totals, task) => {
134
+ totals[task.list_id] = (totals[task.list_id] ?? 0) + 1;
135
+ return totals;
136
+ }, {});
137
+ return { data: { lists: data.lists.map(allowlistList), tasks, totalsByList }, observedAt: { tasks: observedAt }, unavailable: [], stale: [],
138
+ bounds: { shown: tasks.length, total, truncated: total > tasks.length } };
139
+ },
140
+ };
141
+ return new FleetReportService(registry, input.maxBytes)
142
+ .create(request, { provider, generatedAt: input.generatedAt ?? new Date().toISOString() });
143
+ }
144
+ export async function createTaskReport(input) {
145
+ const request = { kind: 'task', viewer: input.viewer, taskId: input.taskId };
146
+ const resourceSlug = input.taskId.toLowerCase().replace(/[^a-z0-9_-]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 68) || 'task';
147
+ const resourceName = `${resourceSlug}-${createHash('sha256').update(input.taskId).digest('hex').slice(0, 10)}`;
148
+ const registry = new ReportRegistry().register({
149
+ kind: 'task', maxRecords: 1,
150
+ validate: (candidate) => candidate.kind === 'task'
151
+ && typeof candidate.taskId === 'string' && candidate.taskId.length > 0 && candidate.taskId.length <= 200,
152
+ resourceId: () => resourceName,
153
+ present: (candidate, snapshot, generatedAt) => {
154
+ const task = snapshot.data;
155
+ const model = presentTasksReport({
156
+ lists: [{ list_id: task.list_id, name: task.list_name, built_in: task.list_id === 'default' }],
157
+ tasks: [task], generatedAt, observedAt: snapshot.observedAt.tasks, source: input.source,
158
+ selectedList: task.list_name, maxRecords: 1,
159
+ });
160
+ return { ...model, reportKind: 'task', title: `Task — ${task.title}`,
161
+ description: `Focused read-only task report for ${candidate.taskId}.`, filters: { task: candidate.taskId } };
162
+ },
163
+ });
164
+ const provider = {
165
+ surface: input.viewer.surface,
166
+ collect: async () => {
167
+ const collected = await input.collect();
168
+ if (collected.task_id !== input.taskId)
169
+ throw new Error('authorized task provider returned a mismatched resource');
170
+ const task = allowlistTask(collected);
171
+ return { data: task, observedAt: { tasks: new Date().toISOString() }, unavailable: [], stale: [],
172
+ bounds: { shown: 1, total: 1, truncated: false } };
173
+ },
174
+ };
175
+ return new FleetReportService(registry, input.maxBytes)
176
+ .create(request, { provider, generatedAt: input.generatedAt ?? new Date().toISOString() });
177
+ }