@hostinger/mcp 3.2.2 → 3.3.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 (64) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/gemini-extension.json +1 -1
  3. package/package.json +1 -1
  4. package/server.json +2 -2
  5. package/skills/headless/SKILL.md +247 -36
  6. package/skills/headless/connect/SKILL.md +103 -0
  7. package/skills/headless/{entry → connect}/bootstrap.mjs +8 -3
  8. package/skills/headless/connect/tools/antigravity.md +54 -0
  9. package/skills/headless/connect/tools/claude-code.md +46 -0
  10. package/skills/headless/connect/tools/codex.md +48 -0
  11. package/skills/headless/connect/tools/cursor.md +109 -0
  12. package/skills/headless/connect/tools/devin.md +50 -0
  13. package/skills/headless/connect/tools/vscode.md +57 -0
  14. package/skills/headless/report.schema.json +149 -0
  15. package/skills/headless/run-log.mjs +662 -0
  16. package/skills/headless/steps/01-check-state/SKILL.md +193 -0
  17. package/skills/headless/steps/02-refine/SKILL.md +206 -0
  18. package/skills/headless/steps/03-provision/SKILL.md +62 -0
  19. package/skills/headless/steps/03-provision/ecommerce/SKILL.md +133 -0
  20. package/skills/headless/steps/03-provision/webapp/SKILL.md +154 -0
  21. package/skills/headless/steps/03-provision/wordpress/SKILL.md +100 -0
  22. package/skills/headless/steps/04-content-model/SKILL.md +150 -0
  23. package/skills/headless/steps/04-content-model/ecommerce/SKILL.md +75 -0
  24. package/skills/headless/steps/04-content-model/wordpress/SKILL.md +178 -0
  25. package/skills/headless/steps/05-design/SKILL.md +455 -0
  26. package/skills/headless/steps/05-design/logo-kit.mjs +201 -0
  27. package/skills/headless/steps/06-frontend/SKILL.md +131 -0
  28. package/skills/headless/steps/07-validate/SKILL.md +53 -0
  29. package/skills/headless/steps/08-deploy/SKILL.md +171 -0
  30. package/skills/headless/steps/09-done/SKILL.md +151 -0
  31. package/src/core/skills.js +164 -24
  32. package/src/core/skills.ts +164 -24
  33. package/src/servers/agency-hosting.js +1 -1
  34. package/src/servers/agency-hosting.ts +1 -1
  35. package/src/servers/agent-readiness.js +1 -1
  36. package/src/servers/agent-readiness.ts +1 -1
  37. package/src/servers/all.js +1 -1
  38. package/src/servers/all.ts +1 -1
  39. package/src/servers/billing.js +1 -1
  40. package/src/servers/billing.ts +1 -1
  41. package/src/servers/dns.js +1 -1
  42. package/src/servers/dns.ts +1 -1
  43. package/src/servers/domains.js +1 -1
  44. package/src/servers/domains.ts +1 -1
  45. package/src/servers/ecommerce.js +1 -1
  46. package/src/servers/ecommerce.ts +1 -1
  47. package/src/servers/horizons.js +1 -1
  48. package/src/servers/horizons.ts +1 -1
  49. package/src/servers/hosting.js +1 -1
  50. package/src/servers/hosting.ts +1 -1
  51. package/src/servers/mail.js +1 -1
  52. package/src/servers/mail.ts +1 -1
  53. package/src/servers/reach.js +1 -1
  54. package/src/servers/reach.ts +1 -1
  55. package/src/servers/vps.js +1 -1
  56. package/src/servers/vps.ts +1 -1
  57. package/src/servers/wordpress.js +1 -1
  58. package/src/servers/wordpress.ts +1 -1
  59. package/skills/headless/entry/skill.md +0 -79
  60. package/skills/headless/references/DATABASE.md +0 -49
  61. package/skills/headless/references/DEPLOYMENT.md +0 -34
  62. package/skills/headless/references/SETUP.md +0 -35
  63. package/skills/headless/references/STORE.md +0 -52
  64. package/skills/headless/references/WORDPRESS.md +0 -51
@@ -0,0 +1,662 @@
1
+ #!/usr/bin/env node
2
+ // Hostinger Headless run log: records a build as it happens and writes reports/<run>.json + .html.
3
+ //
4
+ // node run-log.mjs <domain> run-start [--kind build|resume|redesign|iterate] [--tool <tool>] [--model <model>] [--skill <version>]
5
+ // node run-log.mjs <domain> run-update [--kind …] [--model …] [--skill …]
6
+ // node run-log.mjs <domain> run-resume [--reason "<text>"]
7
+ // node run-log.mjs <domain> stage-start <stage> [--phase <phase>] [--started-ago 6m | --started-at <ISO time>]
8
+ // node run-log.mjs <domain> stage-end <stage> <result> [--phase <phase>] [--note "<text>"] [--started-ago 6m | --started-at <ISO time>]
9
+ // node run-log.mjs <domain> stage-fix <stage> [--phase <phase>] (--started-ago 6m | --started-at <ISO time>)
10
+ // node run-log.mjs <domain> event <issue|feedback|skill_gap|good> '<json>'
11
+ // node run-log.mjs <domain> report [--outcome live|stopped|failed|abandoned] [--tokens '<json>']
12
+ // node run-log.mjs <domain> unsent
13
+ // node run-log.mjs <domain> report-sent [<run>]
14
+ // node run-log.mjs <domain> report-rejected [<run>]
15
+
16
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { dirname, join } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const STAGES = ['connect', 'check_state', 'refine', 'provision', 'content_model', 'design', 'frontend', 'validate', 'deploy', 'done'];
22
+ const RESULTS = ['ok', 'ok_after_retries', 'ok_with_fixups', 'failed', 'skipped', 'stopped'];
23
+ const OUTCOMES = ['live', 'stopped', 'failed', 'abandoned'];
24
+ const EVENT_FIELDS = {
25
+ issue: { required: ['stage', 'symptom'], optional: ['cause', 'workaround', 'severity', 'resolved', 'user_had_to_intervene'] },
26
+ feedback: { required: ['stage', 'category', 'paraphrase'], optional: [] },
27
+ skill_gap: { required: ['skill', 'gap'], optional: ['stage'] },
28
+ good: { required: ['text'], optional: ['stage'] },
29
+ };
30
+ const EVENT_LISTS = { issue: 'issues', feedback: 'user_feedback', skill_gap: 'skill_gaps', good: 'what_went_well' };
31
+
32
+ const here = dirname(fileURLToPath(import.meta.url));
33
+ const now = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
34
+ // A start logged late is kept but marked estimated, so the report can tell measured times from remembered ones.
35
+ function lateStart(flags, run) {
36
+ let at = null;
37
+ if (typeof flags['started-at'] === 'string') {
38
+ if (Number.isNaN(Date.parse(flags['started-at']))) fail(`--started-at "${flags['started-at']}" is not a time; use e.g. 2026-10-13T06:26:00Z`);
39
+ at = Date.parse(flags['started-at']);
40
+ } else if (typeof flags['started-ago'] === 'string') {
41
+ const m = flags['started-ago'].match(/^(\d+(?:\.\d+)?)\s*([smh])$/);
42
+ if (!m) fail(`--started-ago "${flags['started-ago']}" should look like 90s, 6m or 1h`);
43
+ at = Date.now() - Number(m[1]) * { s: 1e3, m: 6e4, h: 36e5 }[m[2]];
44
+ }
45
+ if (at === null) return null;
46
+ const clamped = Math.min(Math.max(at, Date.parse(run.started_at)), Date.now());
47
+ return new Date(clamped).toISOString().replace(/\.\d{3}Z$/, 'Z');
48
+ }
49
+
50
+ const fail = (message) => {
51
+ console.error(`run-log: ${message}`);
52
+ process.exit(1);
53
+ };
54
+
55
+ function parseArgs(argv) {
56
+ const positional = [];
57
+ const flags = {};
58
+ for (let i = 0; i < argv.length; i += 1) {
59
+ if (argv[i].startsWith('--')) {
60
+ const key = argv[i].slice(2);
61
+ const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[(i += 1)] : true;
62
+ flags[key] = value;
63
+ } else positional.push(argv[i]);
64
+ }
65
+ return { positional, flags };
66
+ }
67
+
68
+ const readJson = (file, fallback) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : fallback);
69
+ const writeJson = (file, value) => writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
70
+
71
+ function workspace(domain) {
72
+ if (!domain || domain.startsWith('-')) fail('first argument must be the domain');
73
+ const dir = join(homedir(), '.hostinger', domain);
74
+ if (!existsSync(dir)) fail(`no workspace at ${dir}; connect creates it`);
75
+ return {
76
+ dir,
77
+ domain,
78
+ statePath: join(dir, 'state.json'),
79
+ eventsPath: join(dir, 'events.jsonl'),
80
+ reportsDir: join(dir, 'reports'),
81
+ };
82
+ }
83
+
84
+ const loadState = (ws) => readJson(ws.statePath, {});
85
+ function saveState(ws, state) {
86
+ state.updated_at = now();
87
+ writeJson(ws.statePath, state);
88
+ }
89
+
90
+ function currentRun(ws, state) {
91
+ if (!state.run?.id) fail('no run started; call run-start first (check-state does this)');
92
+ return state.run;
93
+ }
94
+
95
+ function readEvents(ws) {
96
+ if (!existsSync(ws.eventsPath)) return [];
97
+ return readFileSync(ws.eventsPath, 'utf8')
98
+ .split('\n')
99
+ .filter(Boolean)
100
+ .map((line) => JSON.parse(line));
101
+ }
102
+
103
+ function runStart(ws, flags) {
104
+ const state = loadState(ws);
105
+ // A previous run that never reached its report (closed window, crash) is closed out first, so
106
+ // its log still turns into a report.
107
+ if (state.run?.id && !state.run.reported_at) {
108
+ buildReport(ws, { outcome: 'abandoned' });
109
+ console.log(`closed previous run ${state.run.id} as abandoned`);
110
+ }
111
+ const fresh = loadState(ws);
112
+ const previous = fresh.run ?? {};
113
+ const startedAt = now();
114
+ const connect = readJson(join(ws.dir, 'connect.json'), {});
115
+ // Run ids are the start time; a second run in the same second gets a suffix.
116
+ const taken = new Set([
117
+ ...(fresh.history ?? []).map((h) => h.run),
118
+ ...(existsSync(ws.reportsDir) ? readdirSync(ws.reportsDir).map((f) => f.replace(/\.(json|html)$/, '')) : []),
119
+ ]);
120
+ const base = startedAt.replace(/:/g, '-');
121
+ let id = base;
122
+ for (let n = 2; taken.has(id) || id === fresh.run?.id; n += 1) id = `${base}-${n}`;
123
+ const kind = flags.kind ?? 'build';
124
+ if (!['build', 'resume', 'redesign', 'iterate'].includes(kind)) fail(`unknown kind "${kind}"; one of build, resume, redesign, iterate`);
125
+ fresh.run = {
126
+ id,
127
+ kind,
128
+ started_at: startedAt,
129
+ tool: flags.tool ?? connect.tool ?? fresh.tool ?? previous.tool ?? null,
130
+ model: flags.model ?? previous.model ?? null,
131
+ skill: flags.skill ?? previous.skill ?? null,
132
+ };
133
+ fresh.history ??= [];
134
+ saveState(ws, fresh);
135
+ console.log(`run ${fresh.run.id} started`);
136
+ }
137
+
138
+ function runUpdate(ws, flags) {
139
+ const state = loadState(ws);
140
+ const run = currentRun(ws, state);
141
+ for (const key of ['kind', 'tool', 'model', 'skill']) if (typeof flags[key] === 'string') run[key] = flags[key];
142
+ if (!['build', 'resume', 'redesign', 'iterate'].includes(run.kind)) fail(`unknown kind "${run.kind}"; one of build, resume, redesign, iterate`);
143
+ saveState(ws, state);
144
+ console.log(`run ${run.id} updated`);
145
+ }
146
+
147
+ // A follow-up in the same session (the user asks for a fix right after the final report) belongs
148
+ // to the run it follows: reopen it, so the next `report` rewrites that run's report instead of
149
+ // starting a second one.
150
+ function runResume(ws, flags) {
151
+ const state = loadState(ws);
152
+ const run = currentRun(ws, state);
153
+ if (!run.reported_at) {
154
+ console.log(`run ${run.id} is still open; nothing to resume`);
155
+ return;
156
+ }
157
+ run.follow_ups = [
158
+ ...(run.follow_ups ?? []),
159
+ { resumed_at: now(), after_report_at: run.reported_at, reason: typeof flags.reason === 'string' ? flags.reason : null },
160
+ ];
161
+ delete run.reported_at;
162
+ saveState(ws, state);
163
+ console.log(`run ${run.id} resumed (follow-up ${run.follow_ups.length})`);
164
+ }
165
+
166
+ function stageStart(ws, [stage], flags) {
167
+ if (!STAGES.includes(stage)) fail(`unknown stage "${stage}"; one of ${STAGES.join(', ')}`);
168
+ const state = loadState(ws);
169
+ const run = currentRun(ws, state);
170
+ state.history ??= [];
171
+ const phase = flags.phase ?? null;
172
+ const open = state.history.find((h) => h.run === run.id && h.stage === stage && h.phase === phase && !h.ended_at);
173
+ if (open) {
174
+ console.log(`${stage}${phase ? `/${phase}` : ''} already started at ${open.started_at}`);
175
+ return;
176
+ }
177
+ const late = lateStart(flags, run);
178
+ state.history.push({ run: run.id, stage, phase, started_at: late ?? now(), ended_at: null, result: null, note: null, ...(late ? { start_estimated: true } : {}) });
179
+ saveState(ws, state);
180
+ console.log(`${stage}${phase ? `/${phase}` : ''} started${late ? ` (estimated start ${late})` : ''}`);
181
+ }
182
+
183
+ function stageEnd(ws, [stage, result], flags) {
184
+ if (!STAGES.includes(stage)) fail(`unknown stage "${stage}"`);
185
+ if (!RESULTS.includes(result)) fail(`unknown result "${result}"; one of ${RESULTS.join(', ')}`);
186
+ const state = loadState(ws);
187
+ const run = currentRun(ws, state);
188
+ state.history ??= [];
189
+ const phase = flags.phase ?? null;
190
+ let entry = [...state.history].reverse().find((h) => h.run === run.id && h.stage === stage && h.phase === phase && !h.ended_at);
191
+ if (!entry) {
192
+ // Missed stage-start: use the remembered start if given, else the end of the previous stage; either way estimated.
193
+ const runEntries = state.history.filter((h) => h.run === run.id);
194
+ const lastEnd = runEntries.map((h) => h.ended_at).filter(Boolean).sort().at(-1);
195
+ const late = lateStart(flags, run);
196
+ entry = { run: run.id, stage, phase, started_at: late ?? lastEnd ?? run.started_at, ended_at: null, result: null, note: null, start_estimated: true };
197
+ state.history.push(entry);
198
+ console.log(`${stage}${phase ? `/${phase}` : ''}: stage-start was not logged; start estimated as ${entry.started_at}${late ? '' : ' (end of the previous stage). Pass --started-ago if you know better'}`);
199
+ } else if (flags['started-at'] || flags['started-ago']) {
200
+ entry.started_at = lateStart(flags, run);
201
+ entry.start_estimated = true;
202
+ }
203
+ entry.ended_at = now();
204
+ entry.result = result;
205
+ if (typeof flags.note === 'string') entry.note = flags.note;
206
+ saveState(ws, state);
207
+ console.log(`${stage}${phase ? `/${phase}` : ''} ended: ${result}`);
208
+ }
209
+
210
+ function stageFix(ws, [stage], flags) {
211
+ if (!STAGES.includes(stage)) fail(`unknown stage "${stage}"`);
212
+ const state = loadState(ws);
213
+ const run = currentRun(ws, state);
214
+ const phase = flags.phase ?? null;
215
+ const entry = [...(state.history ?? [])].reverse().find((h) => h.run === run.id && h.stage === stage && h.phase === phase);
216
+ if (!entry) fail(`${stage}${phase ? `/${phase}` : ''} was never logged in this run; use stage-start with --started-ago instead`);
217
+ const late = lateStart(flags, run);
218
+ if (!late) fail('stage-fix needs --started-ago (e.g. 6m) or --started-at <ISO time>');
219
+ if (entry.ended_at && late > entry.ended_at) fail(`the new start ${late} is after the stage ended (${entry.ended_at})`);
220
+ entry.started_at = late;
221
+ entry.start_estimated = true;
222
+ saveState(ws, state);
223
+ console.log(`${stage}${phase ? `/${phase}` : ''} start set to ${late} (estimated)`);
224
+ }
225
+
226
+ function event(ws, [type, raw]) {
227
+ const spec = EVENT_FIELDS[type];
228
+ if (!spec) fail(`unknown event type "${type}"; one of ${Object.keys(EVENT_FIELDS).join(', ')}`);
229
+ let data;
230
+ try {
231
+ data = JSON.parse(raw ?? '');
232
+ } catch {
233
+ fail(`event ${type} needs a JSON object, e.g. '${JSON.stringify(Object.fromEntries(spec.required.map((k) => [k, '…'])))}'`);
234
+ }
235
+ const missing = spec.required.filter((k) => data[k] === undefined || data[k] === '');
236
+ if (missing.length) fail(`event ${type} is missing ${missing.join(', ')}`);
237
+ const unknown = Object.keys(data).filter((k) => !spec.required.includes(k) && !spec.optional.includes(k));
238
+ if (unknown.length) fail(`event ${type} has unknown fields ${unknown.join(', ')}`);
239
+ if (type === 'issue') {
240
+ data.severity ??= 'medium';
241
+ if (!['high', 'medium', 'low'].includes(data.severity)) fail('issue severity must be high, medium or low');
242
+ data.resolved ??= false;
243
+ data.user_had_to_intervene ??= false;
244
+ }
245
+ // A bad line in events.jsonl would fail every later report and run-start.
246
+ validate({ ...data, logged_at: now() }, eventSchema(type), `event ${type}`);
247
+ const state = loadState(ws);
248
+ const run = currentRun(ws, state);
249
+ appendFileSync(ws.eventsPath, `${JSON.stringify({ ts: now(), run: run.id, type, ...data })}\n`);
250
+ console.log(`${type} logged`);
251
+ }
252
+
253
+ const minutesBetween = (a, b) => (a && b ? Math.round(((Date.parse(b) - Date.parse(a)) / 60000) * 10) / 10 : null);
254
+
255
+ function frontendStack(dir) {
256
+ // Framework is recorded in state.json by the frontend step.
257
+ const state = readJson(join(dir, 'state.json'), {});
258
+ const fe = state.frontend ?? {};
259
+ const framework = fe.framework ?? fe.stack ?? null;
260
+ if (framework) return fe.rendering ? `${framework} (${fe.rendering})` : framework;
261
+ // Fallback: an older build may have a design.md with a "## Stack" heading.
262
+ const design = existsSync(join(dir, 'design.md')) ? readFileSync(join(dir, 'design.md'), 'utf8') : '';
263
+ return design.match(/## Stack\s*\n\s*\*\*([^*]+)\*\*/)?.[1] ?? null;
264
+ }
265
+
266
+ function buildReport(ws, { outcome, tokens } = {}) {
267
+ const state = loadState(ws);
268
+ const run = currentRun(ws, state);
269
+ const site = readJson(join(ws.dir, 'site.json'), {});
270
+ const deploy = readJson(join(ws.dir, 'deploy.json'), {});
271
+ const connect = readJson(join(ws.dir, 'connect.json'), {});
272
+ const history = (state.history ?? []).filter((h) => h.run === run.id);
273
+ // Lines logged before events were checked on write may not match the schema.
274
+ const dropped = [];
275
+ const events = readEvents(ws)
276
+ .filter((e) => e.run === run.id)
277
+ .filter((e) => {
278
+ const { ts, run: _run, type, ...rest } = e;
279
+ const problem = schemaError({ ...rest, logged_at: ts }, eventSchema(type), 'event');
280
+ if (problem) dropped.push({ stage: typeof e.stage === 'string' ? e.stage : 'unknown', phase: null, problem: `left out a malformed ${type} event (${problem})` });
281
+ return !problem;
282
+ });
283
+ const resolvedOutcome = outcome ?? (history.some((h) => h.stage === 'deploy' && h.result?.startsWith('ok')) ? 'live' : 'stopped');
284
+ // A run ends at its last logged activity, so re-running `report` later (after fixing warnings, or
285
+ // when the next run-start closes an abandoned run) doesn't stretch it to "now". Only a stage still
286
+ // open on a live run keeps the clock running.
287
+ const lastActivity = [run.started_at, ...history.flatMap((h) => [h.started_at, h.ended_at]), ...events.map((e) => e.ts)]
288
+ .filter(Boolean)
289
+ .sort()
290
+ .at(-1);
291
+ const stillOpen = resolvedOutcome !== 'abandoned' && history.some((h) => !h.ended_at);
292
+ const endedAt = stillOpen ? now() : lastActivity;
293
+ const stages = history.map((h) => ({ ...h, minutes: minutesBetween(h.started_at, h.ended_at ?? endedAt) }));
294
+ const gaps = [...loggingGaps(stages, run, resolvedOutcome), ...dropped];
295
+ const strip = ({ ts, run: _run, type: _type, ...rest }) => ({ ...rest, logged_at: ts });
296
+ const liveStage = stages.find((s) => s.stage === 'deploy' && s.result?.startsWith('ok'));
297
+ if (!OUTCOMES.includes(resolvedOutcome)) fail(`unknown outcome "${resolvedOutcome}"; one of ${OUTCOMES.join(', ')}`);
298
+
299
+ const previous = existsSync(ws.reportsDir)
300
+ ? readdirSync(ws.reportsDir)
301
+ .filter((f) => f.endsWith('.json') && f !== `${run.id}.json`)
302
+ .map((f) => readJson(join(ws.reportsDir, f), {}))
303
+ .sort((a, b) => `${a.timing?.started_at}${a.run?.id}`.localeCompare(`${b.timing?.started_at}${b.run?.id}`))
304
+ .map((r) => ({ run: r.run?.id, kind: r.run?.kind, outcome: r.info?.outcome, minutes: r.timing?.minutes_total, html: `${r.run?.id}.html` }))
305
+ : [];
306
+
307
+ const report = {
308
+ schema_version: '1',
309
+ generated_at: now(),
310
+ // Intent, not delivery: delivery is tracked in state.json.
311
+ consent: { sent: true, note: 'Sent to Hostinger automatically at the end of the run.' },
312
+ run: { id: run.id, kind: run.kind, skill: run.skill, follow_ups: run.follow_ups ?? [] },
313
+ info: {
314
+ domain: ws.domain,
315
+ tool: run.tool ?? connect.tool ?? null,
316
+ model: run.model ?? null,
317
+ os: process.platform,
318
+ node: process.version,
319
+ mcp: connect.mcp ?? null,
320
+ backend: site.backend ?? null,
321
+ frontend: frontendStack(ws.dir),
322
+ outcome: resolvedOutcome,
323
+ stopped_at: resolvedOutcome === 'live' ? null : stoppedAt(stages, state),
324
+ live_url: resolvedOutcome === 'live' ? deploy.urls?.site ?? site.urls?.site ?? null : null,
325
+ admin_url: site.urls?.wp_admin ?? null,
326
+ },
327
+ timing: {
328
+ started_at: run.started_at,
329
+ ended_at: endedAt,
330
+ live_at: liveStage?.ended_at ?? null,
331
+ minutes_total: minutesBetween(run.started_at, endedAt),
332
+ minutes_to_live: liveStage ? minutesBetween(run.started_at, liveStage.ended_at) : null,
333
+ stages: stages.map(({ run: _run, ...s }) => s),
334
+ },
335
+ // Not null: the MCP layer treats a null required field as missing, and the API reads {} as an array.
336
+ tokens: tokens ?? { source: 'not reported' },
337
+ issues: events.filter((e) => e.type === 'issue').map(strip),
338
+ user_feedback: events.filter((e) => e.type === 'feedback').map(strip),
339
+ skill_gaps: events.filter((e) => e.type === 'skill_gap').map(strip),
340
+ what_went_well: events.filter((e) => e.type === 'good').map(strip),
341
+ logging_gaps: gaps,
342
+ previous_runs: previous,
343
+ };
344
+
345
+ validate(report, reportSchema());
346
+ mkdirSync(ws.reportsDir, { recursive: true });
347
+ writeJson(join(ws.reportsDir, `${run.id}.json`), report);
348
+ writeFileSync(join(ws.reportsDir, `${run.id}.html`), renderHtml(report));
349
+ copyFileSync(join(ws.reportsDir, `${run.id}.html`), join(ws.dir, 'report.html'));
350
+
351
+ const fresh = loadState(ws);
352
+ fresh.run.reported_at = now();
353
+ saveState(ws, fresh);
354
+ for (const g of gaps) console.log(`warning: ${g.stage}${g.phase ? `/${g.phase}` : ''}: ${g.problem}`);
355
+ return join(ws.dir, 'report.html');
356
+ }
357
+
358
+ // A report counts as sent only while the sent copy's generated_at matches the file.
359
+ function reportFile(ws, runId) {
360
+ const id = runId ?? currentRun(ws, loadState(ws)).id;
361
+ const file = join(ws.reportsDir, `${id}.json`);
362
+ if (!existsSync(file)) fail(`no report for run ${id} at ${file}`);
363
+ return { id, file, generatedAt: readJson(file, {}).generated_at };
364
+ }
365
+
366
+ function unsent(ws) {
367
+ const state = loadState(ws);
368
+ const files = existsSync(ws.reportsDir) ? readdirSync(ws.reportsDir).filter((f) => f.endsWith('.json')).sort() : [];
369
+ // Older reports promised "nothing is uploaded".
370
+ const pending = files
371
+ .map((f) => {
372
+ const report = readJson(join(ws.reportsDir, f), {});
373
+ return { id: f.replace(/\.json$/, ''), file: join(ws.reportsDir, f), generatedAt: report.generated_at, consent: report.consent };
374
+ })
375
+ .filter(({ consent }) => consent?.sent !== false)
376
+ .filter(({ id, generatedAt }) => state.reports_sent?.[id] !== generatedAt && state.reports_rejected?.[id] !== generatedAt);
377
+ if (!pending.length) console.log('no unsent reports');
378
+ for (const { id, file } of pending) console.log(`${id} ${file}`);
379
+ }
380
+
381
+ function reportSent(ws, [runId]) {
382
+ const { id, generatedAt } = reportFile(ws, runId);
383
+ const state = loadState(ws);
384
+ state.reports_sent = { ...state.reports_sent, [id]: generatedAt };
385
+ saveState(ws, state);
386
+ console.log(`report ${id} marked sent`);
387
+ }
388
+
389
+ // A 422 is schema drift, so resending won't help.
390
+ function reportRejected(ws, [runId]) {
391
+ const { id, generatedAt } = reportFile(ws, runId);
392
+ const state = loadState(ws);
393
+ state.reports_rejected = { ...state.reports_rejected, [id]: generatedAt };
394
+ saveState(ws, state);
395
+ const run = currentRun(ws, state);
396
+ const symptom = `build report ${id} rejected with 422 by hosting_headless_submit-build-report`;
397
+ appendFileSync(ws.eventsPath, `${JSON.stringify({ ts: now(), run: run.id, type: 'issue', stage: run.reported_at ? 'done' : 'check_state', symptom, severity: 'medium', resolved: false, user_had_to_intervene: false })}\n`);
398
+ console.log(`report ${id} marked rejected; issue logged in run ${run.id}`);
399
+ }
400
+
401
+ // Gaps are printed as warnings by `report`, so guesses never pass as measurements.
402
+ const EXPECTED_FOR_BUILD = ['check_state', 'refine', 'provision', 'content_model', 'design', 'frontend', 'validate', 'deploy'];
403
+ function loggingGaps(stages, run, outcome) {
404
+ const gaps = [];
405
+ for (const s of stages) {
406
+ if (!s.ended_at) gaps.push({ stage: s.stage, phase: s.phase, problem: 'started but never ended' });
407
+ else if (s.start_estimated) gaps.push({ stage: s.stage, phase: s.phase, problem: 'start time estimated, not measured' });
408
+ else if (Date.parse(s.ended_at) - Date.parse(s.started_at) < 5000) gaps.push({ stage: s.stage, phase: s.phase, problem: 'logged with no duration; probably started and ended in one go after the work' });
409
+ }
410
+ if (run.kind === 'build' && outcome === 'live') {
411
+ for (const stage of EXPECTED_FOR_BUILD) {
412
+ if (!stages.some((s) => s.stage === stage)) gaps.push({ stage, phase: null, problem: 'never logged, though the site went live' });
413
+ }
414
+ }
415
+ return gaps;
416
+ }
417
+
418
+ function stoppedAt(stages, state) {
419
+ const stuck = [...stages].reverse().find((s) => !s.ended_at || s.result === 'failed' || s.result === 'stopped');
420
+ return stuck?.stage ?? state.stage ?? null;
421
+ }
422
+
423
+ // Minimal JSON Schema subset, so reports from different tools share one shape without a dependency.
424
+ const reportSchema = () => readJson(join(here, 'report.schema.json'), null);
425
+ const eventSchema = (type) => reportSchema()?.properties?.[EVENT_LISTS[type]]?.items ?? null;
426
+
427
+ function schemaError(value, schema, path = 'report') {
428
+ if (!schema) return null;
429
+ const types = [].concat(schema.type ?? []);
430
+ const actual = value === null ? 'null' : Array.isArray(value) ? 'array' : Number.isInteger(value) ? 'integer' : typeof value;
431
+ if (types.length && !types.includes(actual) && !(actual === 'integer' && types.includes('number'))) {
432
+ return `${path} should be ${types.join(' or ')}, got ${actual}`;
433
+ }
434
+ if (schema.enum && !schema.enum.includes(value)) return `${path} should be one of ${schema.enum.join(', ')}`;
435
+ if (actual === 'object') {
436
+ for (const key of schema.required ?? []) if (!(key in value)) return `${path}.${key} is missing`;
437
+ for (const [key, sub] of Object.entries(schema.properties ?? {})) {
438
+ const error = key in value ? schemaError(value[key], sub, `${path}.${key}`) : null;
439
+ if (error) return error;
440
+ }
441
+ }
442
+ if (actual === 'array' && schema.items) {
443
+ for (const [i, item] of value.entries()) {
444
+ const error = schemaError(item, schema.items, `${path}[${i}]`);
445
+ if (error) return error;
446
+ }
447
+ }
448
+ return null;
449
+ }
450
+
451
+ function validate(value, schema, path) {
452
+ const error = schemaError(value, schema, path);
453
+ if (error) fail(error);
454
+ }
455
+
456
+ const esc = (v) => String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
457
+ const label = (s) => String(s ?? '').replace(/_/g, ' ');
458
+ const time = (iso) => (iso ? iso.slice(11, 19) : '—');
459
+ const fmt = (n) => (typeof n === 'number' ? n.toLocaleString('en-US') : '—');
460
+
461
+ function gantt(report) {
462
+ const stages = report.timing.stages;
463
+ if (!stages.length) return '<p class="muted">No stages were logged for this run.</p>';
464
+ const t0 = Date.parse(report.timing.started_at);
465
+ const at = (iso) => (Date.parse(iso) - t0) / 60000;
466
+ const total = Math.max(report.timing.minutes_total ?? 1, ...stages.map((s) => at(s.ended_at ?? report.timing.ended_at)), 1);
467
+ const step = [1, 2, 5, 10, 15, 30, 60, 120, 240].find((s) => total / s <= 8) ?? 480;
468
+ const span = Math.ceil(total / step) * step;
469
+ const W = 760;
470
+ const LABEL = 160;
471
+ const ROW = 32;
472
+ const TOP = 32;
473
+ const H = TOP + ROW * stages.length + 32;
474
+ const x = (m) => LABEL + ((W - LABEL - 40) * m) / span;
475
+ const out = [`<svg viewBox="0 0 ${W} ${H}" role="img" aria-labelledby="gantt-title" class="gantt"><title id="gantt-title">Stage timeline, minutes from the start of the run</title>`];
476
+ for (let m = 0; m <= span; m += step) {
477
+ out.push(`<line x1="${x(m).toFixed(1)}" x2="${x(m).toFixed(1)}" y1="${TOP - 8}" y2="${H - 28}" class="grid"/><text x="${x(m).toFixed(1)}" y="${H - 10}" class="tick" text-anchor="middle">${m} min</text>`);
478
+ }
479
+ for (const f of report.run.follow_ups ?? []) {
480
+ const fx = x(at(f.resumed_at)).toFixed(1);
481
+ out.push(`<line x1="${fx}" x2="${fx}" y1="${TOP - 12}" y2="${H - 28}" class="follow-up"/><text x="${Number(fx) + 6}" y="${TOP - 12}" class="follow-up-label">Follow-up</text>`);
482
+ }
483
+ if (report.timing.live_at) {
484
+ const lx = x(at(report.timing.live_at)).toFixed(1);
485
+ out.push(`<line x1="${lx}" x2="${lx}" y1="${TOP - 12}" y2="${H - 28}" class="live"/><text x="${lx - 6}" y="${TOP - 12}" class="live-label" text-anchor="end">Live · ${report.timing.minutes_to_live} min</text>`);
486
+ }
487
+ stages.forEach((s, i) => {
488
+ const y = TOP + i * ROW;
489
+ const a = at(s.started_at);
490
+ const b = at(s.ended_at ?? report.timing.ended_at);
491
+ const width = Math.max(x(b) - x(a), 4);
492
+ const name = `${label(s.stage)}${s.phase ? ` · ${s.phase}` : ''}`;
493
+ const tip = [name, `${s.minutes} min`, s.result ? label(s.result) : 'not finished', s.start_estimated ? 'start estimated' : null, s.note].filter(Boolean).join(' · ');
494
+ const failed = s.result === 'failed' || s.result === 'stopped' || !s.ended_at;
495
+ const fill = failed ? 'url(#hatch)' : s.start_estimated ? 'none' : 'var(--series-1)';
496
+ out.push(`<text x="${LABEL - 12}" y="${(y + ROW / 2 + 4).toFixed(1)}" class="row-label" text-anchor="end">${esc(name)}</text>`);
497
+ out.push(`<g class="bar" tabindex="0" data-tip="${esc(tip)}"><rect x="${LABEL}" y="${y}" width="${W - LABEL - 16}" height="${ROW}" fill="transparent"/><rect x="${x(a).toFixed(1)}" y="${y + 8}" width="${width.toFixed(1)}" height="${ROW - 16}" rx="4" fill="${fill}"${s.start_estimated && !failed ? ' stroke="var(--series-1)" stroke-width="2" stroke-dasharray="4 3"' : ''}/></g>`);
498
+ });
499
+ out.push('<defs><pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="6" height="6" fill="var(--series-1)"/><line x1="0" y1="0" x2="0" y2="6" stroke="var(--surface-1)" stroke-width="2.5"/></pattern></defs></svg>');
500
+ return out.join('');
501
+ }
502
+
503
+ function renderHtml(r) {
504
+ const tile = (name, value, sub = '') => `<div class="tile"><div class="tile-label">${esc(name)}</div><div class="tile-value">${esc(value)}</div><div class="tile-sub">${esc(sub)}</div></div>`;
505
+ const stepped = r.issues.filter((i) => i.user_had_to_intervene).length;
506
+ const rejected = r.user_feedback.filter((f) => f.category === 'design_rejected').length;
507
+ const t = r.tokens && ['input', 'output', 'cache_read', 'cache_write', 'est_cost_usd'].some((k) => r.tokens[k] != null) ? r.tokens : null;
508
+ const tiles = [
509
+ r.timing.minutes_to_live != null
510
+ ? tile('Time to live', `${r.timing.minutes_to_live} min`, 'start of run → deploy finished')
511
+ : tile('Outcome', label(r.info.outcome), r.info.stopped_at ? `stopped at ${label(r.info.stopped_at)}` : ''),
512
+ tile('Run length', `${r.timing.minutes_total} min`, `${r.timing.stages.length} stage entries`),
513
+ tile('Issues', String(r.issues.length), `${stepped} needed the user to step in`),
514
+ tile('User feedback', String(r.user_feedback.length), rejected ? `${rejected} design rejections` : 'corrections and requests'),
515
+ t ? tile('Tokens', fmt((t.input ?? 0) + (t.output ?? 0) + (t.cache_read ?? 0) + (t.cache_write ?? 0)), t.est_cost_usd != null ? `≈ $${t.est_cost_usd} API-equivalent` : t.source ?? '') : tile('Tokens', '—', 'not reported by this tool'),
516
+ ].join('');
517
+ const SEV = { high: ['●', 'critical', 'High'], medium: ['▲', 'serious', 'Medium'], low: ['■', 'warning', 'Low'] };
518
+ const order = ['high', 'medium', 'low'];
519
+ const issues = [...r.issues]
520
+ .sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity))
521
+ .map((i) => {
522
+ const [icon, cls, name] = SEV[i.severity];
523
+ return `<tr><td><span class="sev sev-${cls}"><span aria-hidden="true">${icon}</span> ${name}</span></td><td>${esc(label(i.stage))}</td><td>${esc(i.symptom)}${i.user_had_to_intervene ? ' <span class="chip">user had to step in</span>' : ''}</td><td>${esc(i.cause)}</td><td>${esc(i.workaround)}</td><td>${i.resolved ? '✓ Resolved' : 'Open'}</td></tr>`;
524
+ })
525
+ .join('');
526
+ const list = (items, render, empty) => (items.length ? `<ul>${items.map(render).join('')}</ul>` : `<p class="muted">${empty}</p>`);
527
+ const stageRows = r.timing.stages
528
+ .map((s) => `<tr><td>${esc(label(s.stage))}${s.phase ? ` · ${esc(s.phase)}` : ''}</td><td>${time(s.started_at)}</td><td>${time(s.ended_at)}</td><td class="num">${s.minutes ?? '—'}</td><td>${esc(label(s.result ?? 'not finished'))}</td><td>${esc(s.note)}</td></tr>`)
529
+ .join('');
530
+ const details = [
531
+ ['Domain', r.info.domain],
532
+ ['Outcome', [label(r.info.outcome), r.info.live_url].filter(Boolean).join(' · ')],
533
+ ['Run', `${r.run.id} · ${r.run.kind}`],
534
+ ['Tool / model', [r.info.tool, r.info.model].filter(Boolean).join(' · ') || '—'],
535
+ ['Stack', [r.info.backend, r.info.frontend].filter(Boolean).join(' + ') || '—'],
536
+ ['Environment', `${r.info.os} · Node ${r.info.node}${r.info.mcp ? ` · ${r.info.mcp}` : ''}`],
537
+ ['Skill version', r.run.skill ?? '—'],
538
+ ...(r.run.follow_ups?.length
539
+ ? [['Follow-ups', r.run.follow_ups.map((f) => `resumed ${time(f.resumed_at)}${f.reason ? ` — ${f.reason}` : ''}`).join('; ')]]
540
+ : []),
541
+ ]
542
+ .map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`)
543
+ .join('');
544
+ const tokenTable = t
545
+ ? `<section><h2>Tokens</h2><div class="table-wrap"><table><thead><tr><th class="num">Requests</th><th class="num">Input</th><th class="num">Output</th><th class="num">Cache read</th><th class="num">Cache write</th><th class="num">≈ API cost</th></tr></thead><tbody><tr><td class="num">${fmt(t.requests)}</td><td class="num">${fmt(t.input)}</td><td class="num">${fmt(t.output)}</td><td class="num">${fmt(t.cache_read)}</td><td class="num">${fmt(t.cache_write)}</td><td class="num">${t.est_cost_usd != null ? `$${t.est_cost_usd}` : '—'}</td></tr></tbody></table></div><p class="muted">${esc(t.source ?? '')}</p></section>`
546
+ : '';
547
+ const previous = r.previous_runs.length
548
+ ? `<section><h2>Earlier runs on this domain</h2><ul>${r.previous_runs.map((p) => `<li><a href="${esc(p.html)}">${esc(p.run)}</a> — ${esc(p.kind)} · ${esc(label(p.outcome))}${p.minutes != null ? ` · ${p.minutes} min` : ''}</li>`).join('')}</ul></section>`
549
+ : '';
550
+
551
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
552
+ <title>Build report — ${esc(r.info.domain)}</title>
553
+ <style>
554
+ .viz-root { color-scheme: light; --surface-0:#f4f4f2; --surface-1:#fcfcfb; --border:#e2e1dc; --text-primary:#0b0b0b; --text-secondary:#52514e; --text-muted:#6b6a65; --series-1:#2a78d6; --grid:#e8e7e2; --warning:#fab219; --serious:#ec835a; --critical:#d03b3b; }
555
+ @media (prefers-color-scheme: dark) { .viz-root { color-scheme: dark; --surface-0:#111110; --surface-1:#1a1a19; --border:#33332f; --text-primary:#f0efec; --text-secondary:#c3c2b7; --text-muted:#a3a299; --series-1:#3987e5; --grid:#2a2a27; } }
556
+ * { box-sizing: border-box; }
557
+ body { margin:0; background:var(--surface-0); color:var(--text-primary); font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif; }
558
+ main { max-width:1080px; margin:0 auto; padding:32px 24px 64px; }
559
+ h1 { font-size:28px; line-height:1.2; margin:0 0 4px; } h2 { font-size:18px; margin:0 0 16px; }
560
+ a { color:var(--series-1); }
561
+ .lede { color:var(--text-secondary); margin:0 0 24px; }
562
+ section { background:var(--surface-1); border:1px solid var(--border); border-radius:12px; padding:24px; margin-bottom:24px; }
563
+ .tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:16px; margin-bottom:24px; }
564
+ .tile { background:var(--surface-1); border:1px solid var(--border); border-radius:12px; padding:16px; }
565
+ .tile-label { color:var(--text-secondary); font-size:13px; } .tile-value { font-size:28px; font-weight:650; line-height:1.3; font-variant-numeric:tabular-nums; } .tile-sub { color:var(--text-muted); font-size:13px; }
566
+ dl { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:12px 24px; margin:0; } dt { color:var(--text-muted); font-size:13px; } dd { margin:0; overflow-wrap:anywhere; }
567
+ .gantt { width:100%; height:auto; display:block; } .grid { stroke:var(--grid); stroke-width:1; } .tick { fill:var(--text-secondary); font-size:12px; } .row-label { fill:var(--text-primary); font-size:13px; text-transform:capitalize; }
568
+ .live { stroke:var(--text-primary); stroke-width:2; stroke-dasharray:4 4; } .follow-up { stroke:var(--text-muted); stroke-width:1; stroke-dasharray:2 4; } .follow-up-label { fill:var(--text-muted); font-size:12px; } .live-label { fill:var(--text-primary); font-size:12px; font-weight:600; }
569
+ .bar { outline:none; } .bar:hover rect:last-child, .bar:focus rect:last-child { stroke:var(--text-primary); stroke-width:2; }
570
+ .legend { display:flex; gap:24px; flex-wrap:wrap; color:var(--text-secondary); font-size:13px; margin-top:8px; } .swatch { display:inline-block; width:24px; height:12px; border-radius:4px; vertical-align:-1px; margin-right:6px; }
571
+ .sw-solid { background:var(--series-1); } .sw-est { border:2px dashed var(--series-1); } .sw-hatch { background:repeating-linear-gradient(45deg,var(--series-1) 0 3px,var(--surface-1) 3px 6px); }
572
+ #tip { position:fixed; pointer-events:none; background:var(--text-primary); color:var(--surface-1); font-size:13px; padding:8px 12px; border-radius:8px; max-width:340px; opacity:0; transition:opacity .1s; z-index:5; }
573
+ table { width:100%; border-collapse:collapse; font-size:14px; } th,td { text-align:left; padding:8px 12px; border-bottom:1px solid var(--border); vertical-align:top; } th { color:var(--text-secondary); font-weight:600; font-size:13px; }
574
+ td.num,th.num { text-align:right; font-variant-numeric:tabular-nums; } .table-wrap { overflow-x:auto; }
575
+ .sev { white-space:nowrap; font-weight:600; } .sev-critical span { color:var(--critical); } .sev-serious span { color:var(--serious); } .sev-warning span { color:var(--warning); }
576
+ .chip { display:inline-block; font-size:12px; padding:0 8px; border-radius:999px; border:1px solid var(--border); color:var(--text-secondary); white-space:nowrap; }
577
+ .cols { display:grid; grid-template-columns:repeat(auto-fit,minmax(320px,1fr)); gap:24px; margin-bottom:24px; } .cols section { margin:0; }
578
+ ul { margin:0; padding-left:20px; } li { margin-bottom:8px; } .muted { color:var(--text-muted); font-size:13px; }
579
+ code { font-size:13px; background:var(--surface-0); padding:1px 4px; border-radius:4px; }
580
+ details summary { cursor:pointer; color:var(--text-secondary); margin-top:16px; }
581
+ </style></head><body class="viz-root"><main>
582
+ <h1>Build report — ${esc(r.info.domain)}</h1>
583
+ <p class="lede">${esc(label(r.run.kind))} run · ${esc(r.timing.started_at.slice(0, 16).replace('T', ' '))} UTC · ${esc(label(r.info.outcome))}</p>
584
+ <div class="tiles">${tiles}</div>
585
+ <section><h2>Stage timeline</h2>${gantt(r)}
586
+ <div class="legend"><span><span class="swatch sw-solid"></span>Finished</span><span><span class="swatch sw-hatch"></span>Failed, stopped or not finished</span><span><span class="swatch sw-est"></span>Start estimated</span><span>Stages can overlap: prep work runs while provisioning settles.</span></div>
587
+ <details><summary>Show as table</summary><div class="table-wrap"><table><thead><tr><th>Stage</th><th>Start</th><th>End</th><th class="num">Minutes</th><th>Result</th><th>Note</th></tr></thead><tbody>${stageRows}</tbody></table></div></details></section>
588
+ ${r.logging_gaps.length ? `<section><h2>Logging gaps (${r.logging_gaps.length})</h2><p class="muted">Where the run log is missing or unsure, so times here are less reliable.</p><ul>${r.logging_gaps.map((g) => `<li><strong>${esc(label(g.stage))}${g.phase ? ` · ${esc(g.phase)}` : ''}</strong> — ${esc(g.problem)}</li>`).join('')}</ul></section>` : ''}
589
+ <section><h2>Issues (${r.issues.length})</h2>${r.issues.length ? `<div class="table-wrap"><table><thead><tr><th>Severity</th><th>Stage</th><th>Symptom</th><th>Cause</th><th>Workaround</th><th>Status</th></tr></thead><tbody>${issues}</tbody></table></div>` : '<p class="muted">No issues logged.</p>'}</section>
590
+ <div class="cols"><section><h2>User feedback</h2>${list(r.user_feedback, (f) => `<li><span class="chip">${esc(label(f.category))}</span> <span class="muted">${esc(label(f.stage))}</span> — “${esc(f.paraphrase)}”</li>`, 'No feedback logged.')}</section>
591
+ <section><h2>Skill gaps</h2>${list(r.skill_gaps, (g) => `<li><code>${esc(g.skill)}</code> — ${esc(g.gap)}</li>`, 'No skill gaps logged.')}</section></div>
592
+ <div class="cols"><section><h2>What went well</h2>${list(r.what_went_well, (g) => `<li>${esc(g.text)}</li>`, 'Nothing logged.')}</section>
593
+ <section><h2>Run details</h2><dl>${details}</dl></section></div>
594
+ ${tokenTable}${previous}
595
+ </main><div id="tip" role="status"></div>
596
+ <script>
597
+ const tip = document.getElementById('tip');
598
+ const show = (el, x, y) => { tip.textContent = el.dataset.tip; tip.style.left = Math.min(x + 12, innerWidth - 360) + 'px'; tip.style.top = (y + 12) + 'px'; tip.style.opacity = 1; };
599
+ document.querySelectorAll('.bar').forEach((el) => {
600
+ el.addEventListener('mousemove', (e) => show(el, e.clientX, e.clientY));
601
+ el.addEventListener('mouseleave', () => (tip.style.opacity = 0));
602
+ el.addEventListener('focus', () => { const r = el.getBoundingClientRect(); show(el, r.left + r.width / 2, r.top); });
603
+ el.addEventListener('blur', () => (tip.style.opacity = 0));
604
+ });
605
+ </script></body></html>
606
+ `;
607
+ }
608
+
609
+ const { positional, flags } = parseArgs(process.argv.slice(2));
610
+ const [domain, command, ...rest] = positional;
611
+ const ws = workspace(domain);
612
+ switch (command) {
613
+ case 'run-start':
614
+ runStart(ws, flags);
615
+ break;
616
+ case 'run-update':
617
+ runUpdate(ws, flags);
618
+ break;
619
+ case 'run-resume':
620
+ runResume(ws, flags);
621
+ break;
622
+ case 'stage-start':
623
+ stageStart(ws, rest, flags);
624
+ break;
625
+ case 'stage-end':
626
+ stageEnd(ws, rest, flags);
627
+ break;
628
+ case 'stage-fix':
629
+ stageFix(ws, rest, flags);
630
+ break;
631
+ case 'event':
632
+ event(ws, rest);
633
+ break;
634
+ case 'report': {
635
+ let tokens;
636
+ if (typeof flags.tokens === 'string') {
637
+ try {
638
+ tokens = JSON.parse(flags.tokens);
639
+ } catch {
640
+ fail('--tokens must be JSON, e.g. \'{"input":0,"output":0,"source":"…"}\'');
641
+ }
642
+ // null and {} can't be sent; treat them as not reported.
643
+ if (tokens === null || (typeof tokens === 'object' && !Array.isArray(tokens) && !Object.keys(tokens).length)) tokens = undefined;
644
+ else validate(tokens, { ...reportSchema()?.properties?.tokens, type: 'object' }, '--tokens');
645
+ }
646
+ console.log(`report written: ${buildReport(ws, { outcome: flags.outcome, tokens })}`);
647
+ const { id, file } = reportFile(ws);
648
+ console.log(`send it: pass ${file} unchanged to hosting_headless_submit-build-report, then run report-sent ${id} (or report-rejected ${id} on a 422)`);
649
+ break;
650
+ }
651
+ case 'unsent':
652
+ unsent(ws);
653
+ break;
654
+ case 'report-sent':
655
+ reportSent(ws, rest);
656
+ break;
657
+ case 'report-rejected':
658
+ reportRejected(ws, rest);
659
+ break;
660
+ default:
661
+ fail('command must be run-start, run-update, run-resume, stage-start, stage-end, stage-fix, event, report, unsent, report-sent or report-rejected');
662
+ }