@ours.network/fleet 1.0.5 → 1.1.0-nightly.10

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 (159) hide show
  1. package/README.md +122 -133
  2. package/dist/agent-recovery-gate.d.ts +17 -0
  3. package/dist/agent-recovery-gate.js +135 -0
  4. package/dist/application/capabilities.d.ts +0 -1
  5. package/dist/application/capabilities.js +3 -13
  6. package/dist/application/fleet-query-service.d.ts +0 -3
  7. package/dist/application/fleet-query-service.js +14 -25
  8. package/dist/application/role-creation-service.d.ts +5 -24
  9. package/dist/application/role-creation-service.js +27 -67
  10. package/dist/application/role-removal-service.js +6 -3
  11. package/dist/application/role-repository.d.ts +1 -2
  12. package/dist/application/role-repository.js +7 -7
  13. package/dist/application/session-control.d.ts +0 -12
  14. package/dist/application/session-control.js +0 -42
  15. package/dist/application/session-mutations.d.ts +5 -5
  16. package/dist/application/task-room-service.d.ts +1 -1
  17. package/dist/application/task-room-service.js +154 -16
  18. package/dist/application/types.d.ts +1 -8
  19. package/dist/briefing.js +12 -11
  20. package/dist/build-info.json +5 -5
  21. package/dist/cli.js +143 -63
  22. package/dist/config-yaml.d.ts +1 -1
  23. package/dist/config.d.ts +66 -6
  24. package/dist/config.js +398 -43
  25. package/dist/creation.d.ts +1 -1
  26. package/dist/creation.js +2 -2
  27. package/dist/daemon-recovery.d.ts +93 -0
  28. package/dist/daemon-recovery.js +328 -0
  29. package/dist/docs.d.ts +1 -1
  30. package/dist/docs.js +103 -127
  31. package/dist/doctor.js +18 -11
  32. package/dist/fleet-command-audit.d.ts +160 -0
  33. package/dist/fleet-command-audit.js +433 -0
  34. package/dist/fleet-proxy.d.ts +3 -1
  35. package/dist/fleet-proxy.js +30 -8
  36. package/dist/generated-agent-source.d.ts +9 -0
  37. package/dist/generated-agent-source.js +53 -0
  38. package/dist/harness/acp-session-transport.d.ts +8 -0
  39. package/dist/harness/acp-session-transport.js +3 -0
  40. package/dist/harness/agent-session.d.ts +51 -0
  41. package/dist/harness/agent-session.js +1 -0
  42. package/dist/harness/claude-code-session.d.ts +24 -0
  43. package/dist/harness/claude-code-session.js +36 -0
  44. package/dist/harness/claude-code.d.ts +2 -1
  45. package/dist/harness/claude-code.js +41 -81
  46. package/dist/harness/codex-session.d.ts +24 -0
  47. package/dist/harness/codex-session.js +36 -0
  48. package/dist/harness/codex.d.ts +2 -1
  49. package/dist/harness/codex.js +55 -45
  50. package/dist/harness/registry.js +4 -0
  51. package/dist/harness/types.d.ts +6 -38
  52. package/dist/index.d.ts +4 -6
  53. package/dist/index.js +2 -4
  54. package/dist/init-guidance.d.ts +1 -0
  55. package/dist/init-guidance.js +6 -0
  56. package/dist/isolation/resources.d.ts +3 -7
  57. package/dist/isolation/resources.js +3 -7
  58. package/dist/model-env.d.ts +4 -6
  59. package/dist/model-env.js +5 -8
  60. package/dist/monitor.d.ts +1 -24
  61. package/dist/monitor.js +5 -140
  62. package/dist/ops.js +4 -2
  63. package/dist/owner-channel/channel.d.ts +58 -2
  64. package/dist/owner-channel/channel.js +269 -29
  65. package/dist/owner-channel/commands.d.ts +2 -0
  66. package/dist/owner-channel/commands.js +31 -13
  67. package/dist/owner-channel/state.d.ts +2 -0
  68. package/dist/owner-channel/state.js +8 -2
  69. package/dist/preset-bootstrap.d.ts +10 -0
  70. package/dist/preset-bootstrap.js +85 -0
  71. package/dist/reports/artifact.d.ts +10 -0
  72. package/dist/reports/artifact.js +32 -0
  73. package/dist/reports/index.d.ts +5 -0
  74. package/dist/reports/index.js +5 -0
  75. package/dist/reports/render.d.ts +6 -0
  76. package/dist/reports/render.js +240 -0
  77. package/dist/reports/service.d.ts +42 -0
  78. package/dist/reports/service.js +57 -0
  79. package/dist/reports/tasks.d.ts +52 -0
  80. package/dist/reports/tasks.js +177 -0
  81. package/dist/reports/types.d.ts +315 -0
  82. package/dist/reports/types.js +6 -0
  83. package/dist/resolved-plan.d.ts +3 -2
  84. package/dist/resolved-plan.js +21 -5
  85. package/dist/rooms-tasks/cli.js +225 -40
  86. package/dist/rooms-tasks/config.js +24 -13
  87. package/dist/rooms-tasks/external-worker.d.ts +1 -0
  88. package/dist/rooms-tasks/external-worker.js +14 -9
  89. package/dist/rooms-tasks/provision.js +98 -36
  90. package/dist/rooms-tasks/templates.d.ts +0 -1
  91. package/dist/rooms-tasks/templates.js +9 -76
  92. package/dist/rooms-tasks/types.d.ts +10 -16
  93. package/dist/rooms-tasks/types.js +1 -4
  94. package/dist/runner.d.ts +13 -16
  95. package/dist/runner.js +110 -122
  96. package/dist/sensitive-config.d.ts +2 -0
  97. package/dist/sensitive-config.js +4 -0
  98. package/dist/session/acp.d.ts +7 -2
  99. package/dist/session/acp.js +29 -6
  100. package/dist/session/activity.d.ts +2 -3
  101. package/dist/session/activity.js +2 -3
  102. package/dist/session/arbiter.d.ts +4 -4
  103. package/dist/session/control.d.ts +17 -4
  104. package/dist/session/control.js +35 -0
  105. package/dist/session/types.d.ts +11 -5
  106. package/dist/spawn.d.ts +8 -33
  107. package/dist/spawn.js +53 -213
  108. package/dist/supervisor/none.d.ts +1 -1
  109. package/dist/supervisor/none.js +2 -2
  110. package/dist/temp-lifecycle.d.ts +1 -1
  111. package/dist/tmux.d.ts +1 -14
  112. package/dist/tmux.js +1 -51
  113. package/dist/watchdog/config.d.ts +10 -7
  114. package/dist/watchdog/config.js +30 -18
  115. package/dist/watchdog/run.js +12 -36
  116. package/dist/web/auth.d.ts +1 -1
  117. package/dist/web/fleet-config-service.d.ts +11 -8
  118. package/dist/web/fleet-config-service.js +384 -183
  119. package/dist/web/runtime.js +4 -15
  120. package/dist/web/server.d.ts +1 -3
  121. package/dist/web/server.js +3 -14
  122. package/dist/web/topology-promote.js +26 -19
  123. package/dist/web/topology.js +2 -2
  124. package/dist/web/yaml-document-edit.js +2 -0
  125. package/dist/web-app/assets/index-59GF-gsZ.css +1 -0
  126. package/dist/web-app/assets/index-BbTc5KtZ.js +10 -0
  127. package/dist/web-app/index.html +2 -2
  128. package/examples/fleet/agents/Alice.yaml +11 -0
  129. package/examples/fleet/agents/FleetCoordinator.yaml +7 -0
  130. package/examples/fleet/brains/claude-default.yaml +5 -0
  131. package/examples/fleet/roles/coordinator.yaml +5 -0
  132. package/examples/fleet/roles/developer.yaml +3 -0
  133. package/examples/fleet.yaml +74 -0
  134. package/package.json +5 -8
  135. package/presets/fleet/agents/Agent.yaml +3 -0
  136. package/presets/fleet/agents/Architect.yaml +3 -0
  137. package/presets/fleet/agents/Critic.yaml +3 -0
  138. package/presets/fleet/agents/Developer.yaml +3 -0
  139. package/presets/fleet/agents/Secretary.yaml +3 -0
  140. package/presets/fleet/agents/Tester.yaml +3 -0
  141. package/presets/fleet/brains/claude-default.yaml +5 -0
  142. package/presets/fleet/roles/Agent.yaml +6 -0
  143. package/presets/fleet/roles/Architect.yaml +6 -0
  144. package/presets/fleet/roles/Critic.yaml +6 -0
  145. package/presets/fleet/roles/Developer.yaml +6 -0
  146. package/presets/fleet/roles/Secretary.yaml +6 -0
  147. package/presets/fleet/roles/Tester.yaml +6 -0
  148. package/presets/fleet/room_templates/pair.yaml +11 -0
  149. package/presets/fleet/room_templates/single.yaml +8 -0
  150. package/presets/fleet/room_templates/team.yaml +12 -0
  151. package/presets/fleet.yaml +7 -0
  152. package/presets/manifest.json +5 -0
  153. package/dist/session/tmux.d.ts +0 -28
  154. package/dist/session/tmux.js +0 -80
  155. package/dist/web/terminal/bridge.d.ts +0 -27
  156. package/dist/web/terminal/bridge.js +0 -317
  157. package/dist/web-app/assets/TerminalView-C_G1ID2P.js +0 -9
  158. package/dist/web-app/assets/index-BCBK78hw.js +0 -10
  159. package/dist/web-app/assets/index-DuC-xnX4.css +0 -1
@@ -0,0 +1,85 @@
1
+ import { constants, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, chmodSync, } from 'node:fs';
2
+ import { dirname, join, relative, resolve, sep } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { defaultConfigPath } from './paths.js';
5
+ import { splitRootFor } from './config.js';
6
+ export const packagedPresetRoot = () => fileURLToPath(new URL('../presets', import.meta.url));
7
+ function assertSafeExisting(path, kind) {
8
+ const stat = lstatSync(path);
9
+ if (stat.isSymbolicLink())
10
+ throw new Error(`preset bootstrap refuses symlink target: ${path}`);
11
+ if (kind === 'file' ? !stat.isFile() : !stat.isDirectory())
12
+ throw new Error(`preset bootstrap expected ${kind}: ${path}`);
13
+ const uid = process.getuid?.();
14
+ if (uid !== undefined && stat.uid !== uid)
15
+ throw new Error(`preset bootstrap refuses target owned by uid ${stat.uid}: ${path}`);
16
+ if ((stat.mode & 0o022) !== 0)
17
+ throw new Error(`preset bootstrap refuses group/world-writable target: ${path}`);
18
+ }
19
+ function ensureDirectory(path) {
20
+ const parent = dirname(path);
21
+ if (parent !== path && !existsSync(parent))
22
+ ensureDirectory(parent);
23
+ if (existsSync(path)) {
24
+ assertSafeExisting(path, 'directory');
25
+ return;
26
+ }
27
+ mkdirSync(path, { mode: 0o700 });
28
+ }
29
+ function seedFile(source, target, result) {
30
+ ensureDirectory(dirname(target));
31
+ if (existsSync(target)) {
32
+ assertSafeExisting(target, 'file');
33
+ result.preserved.push(target);
34
+ return;
35
+ }
36
+ try {
37
+ copyFileSync(source, target, constants.COPYFILE_EXCL);
38
+ chmodSync(target, 0o600);
39
+ result.created.push(target);
40
+ }
41
+ catch (error) {
42
+ if (error.code !== 'EEXIST')
43
+ throw error;
44
+ assertSafeExisting(target, 'file');
45
+ result.preserved.push(target);
46
+ }
47
+ }
48
+ function presetFiles(root) {
49
+ const files = [];
50
+ const visit = (directory) => {
51
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
52
+ const path = join(directory, entry.name);
53
+ if (entry.isDirectory())
54
+ visit(path);
55
+ else if (entry.isFile())
56
+ files.push(path);
57
+ }
58
+ };
59
+ visit(root);
60
+ return files.sort();
61
+ }
62
+ /** Seed packaged defaults without replacing a single existing byte. */
63
+ export function bootstrapPresets(configuration = defaultConfigPath()) {
64
+ const sourceRoot = packagedPresetRoot();
65
+ const manifest = JSON.parse(readFileSync(join(sourceRoot, 'manifest.json'), 'utf8'));
66
+ if (manifest.schema_version !== 1 || !Number.isInteger(manifest.preset_revision))
67
+ throw new Error(`invalid packaged preset manifest: ${join(sourceRoot, 'manifest.json')}`);
68
+ const configPath = resolve(configuration);
69
+ const result = {
70
+ revision: manifest.preset_revision, sourceRoot, configPath, created: [], preserved: [],
71
+ };
72
+ ensureDirectory(dirname(configPath));
73
+ seedFile(join(sourceRoot, 'fleet.yaml'), configPath, result);
74
+ const sourceSplit = join(sourceRoot, 'fleet');
75
+ const targetSplit = splitRootFor(configPath);
76
+ if (existsSync(targetSplit))
77
+ assertSafeExisting(targetSplit, 'directory');
78
+ for (const source of presetFiles(sourceSplit)) {
79
+ const rel = relative(sourceSplit, source);
80
+ if (rel.startsWith('..') || rel.includes(`..${sep}`))
81
+ throw new Error(`invalid packaged preset path: ${source}`);
82
+ seedFile(source, join(targetSplit, rel), result);
83
+ }
84
+ return result;
85
+ }
@@ -0,0 +1,10 @@
1
+ import type { ReportArtifact } from './types.js';
2
+ export interface WriteArtifactOptions {
3
+ output: string;
4
+ overwrite?: boolean;
5
+ }
6
+ /** Atomic file delivery. Existing files are preserved unless overwrite is explicit. */
7
+ export declare function writeReportArtifact(artifact: ReportArtifact, options: WriteArtifactOptions): Promise<{
8
+ path: string;
9
+ overwritten: boolean;
10
+ }>;
@@ -0,0 +1,32 @@
1
+ import { link, lstat, open, rename, unlink } from 'node:fs/promises';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { basename, dirname, join, resolve } from 'node:path';
4
+ /** Atomic file delivery. Existing files are preserved unless overwrite is explicit. */
5
+ export async function writeReportArtifact(artifact, options) {
6
+ const target = resolve(options.output);
7
+ if (!target.toLowerCase().endsWith('.html'))
8
+ throw new Error('report output must use the .html extension');
9
+ const temporary = join(dirname(target), `.${basename(target)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
10
+ let handle;
11
+ let existed = false;
12
+ try {
13
+ handle = await open(temporary, 'wx', 0o600);
14
+ await handle.writeFile(artifact.html, 'utf8');
15
+ await handle.sync();
16
+ await handle.close();
17
+ handle = undefined;
18
+ if (!options.overwrite) {
19
+ await link(temporary, target);
20
+ await unlink(temporary);
21
+ return { path: target, overwritten: false };
22
+ }
23
+ existed = await lstat(target).then(() => true, () => false);
24
+ await rename(temporary, target);
25
+ return { path: target, overwritten: existed };
26
+ }
27
+ catch (error) {
28
+ await handle?.close().catch(() => { });
29
+ await unlink(temporary).catch(() => { });
30
+ throw error;
31
+ }
32
+ }
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export * from './render.js';
3
+ export * from './artifact.js';
4
+ export * from './service.js';
5
+ export * from './tasks.js';
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export * from './render.js';
3
+ export * from './artifact.js';
4
+ export * from './service.js';
5
+ export * from './tasks.js';
@@ -0,0 +1,6 @@
1
+ import type { ReportArtifact, ReportViewModel } from './types.js';
2
+ export declare const escapeReportHtml: (value: unknown) => string;
3
+ export declare function reportAnchor(prefix: string, raw: string): string;
4
+ export declare function reportCompare(a: string, b: string): number;
5
+ export declare function renderReportHtml(model: ReportViewModel): string;
6
+ export declare function createReportArtifact(model: ReportViewModel, resourceId?: string): ReportArtifact;
@@ -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 {};