hostinger-api-mcp 3.19.0 → 3.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@ Generated at release time from the diff between the previous and current
4
4
  build. Tool changes are listed individually; everything else is summarised
5
5
  by area.
6
6
 
7
+ ## v3.19.1 — 2026-10-28
8
+
9
+ ### Also in this release
10
+
11
+ - Bundled agent skills: `skills/headless/SKILL.md`, `skills/headless/connect/SKILL.md`, `skills/headless/run-log.mjs`
12
+ - Other files: `src/core/skills.js`, `src/core/skills.ts`
13
+
7
14
  ## v3.19.0 — 2026-10-28
8
15
 
9
16
  No changes to the published artifact.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hostinger-api-mcp",
3
- "version": "3.19.0",
3
+ "version": "3.19.1",
4
4
  "description": "Hostinger API — manage domains, DNS, hosting, VPS, billing and more from Gemini CLI.",
5
5
  "mcpServers": {
6
6
  "hostinger": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hostinger-api-mcp",
3
- "version": "3.19.0",
3
+ "version": "3.19.1",
4
4
  "mcpName": "io.github.hostinger/hostinger-api-mcp",
5
5
  "description": "MCP server for Hostinger API",
6
6
  "repository": {
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/hostinger/api-mcp-server",
7
7
  "source": "github"
8
8
  },
9
- "version": "3.19.0",
9
+ "version": "3.19.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@hostinger/mcp",
14
- "version": "3.19.0",
14
+ "version": "3.19.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -170,7 +170,7 @@ took from it.
170
170
  | File (under `~/.hostinger/<domain>/`) | Written by | Contains |
171
171
  |---|---|---|
172
172
  | `state.json` | every stage | tool, next stage, Hostinger connector version, timestamps |
173
- | `connect.json` | connect | tool, account/plan readiness |
173
+ | `connect.json` | connect | tool, MCP version, sign-in method, plan; shape in `connect/SKILL.md` |
174
174
  | `brief.md` | refine | refined brief, audience, tone, pages, must-haves |
175
175
  | `site.json` | provision | chosen backend, username, install id, all URLs |
176
176
  | `content-model.md` | content model | entities, fields, pages, navigation |
@@ -87,7 +87,28 @@ Credentials live in `~/.config/hostinger-mcp/credentials.json` (macOS/Linux) or
87
87
  If the user's tool isn't listed yet, connect it the generic way (register the Hostinger MCP server
88
88
  for that tool, or drive `npx --yes --package=@hostinger/mcp@latest hostinger-api-mcp` over stdio) and continue.
89
89
 
90
- ## Phase 3 - Continue to Headless Hosting steps
90
+ ## Phase 3 - Write `connect.json`
91
+
92
+ Once the tool is connected, write `~/.hostinger/<domain>/connect.json` (create the folder if it is
93
+ missing) in exactly this shape:
94
+
95
+ ```json
96
+ { "tool": "claude-code", "mcp": "hostinger-api-mcp 3.8.0", "auth": "oauth_cached",
97
+ "order_id": null, "plan": null, "connected_at": "2026-10-28T10:46:12Z" }
98
+ ```
99
+
100
+ - `tool`: the `<tool>` from the Phase 2 route (`claude-code`, `codex`, `cursor`, `vscode`,
101
+ `antigravity`, `devin`, or a short lowercase name for an unlisted tool), the same value later
102
+ passed to `run-start --tool`.
103
+ - `mcp`: one string, `hostinger-api-mcp <version>`, with the `version` from the `mcp_ok` event.
104
+ Never an object.
105
+ - `auth`: the `method` from the `logged_in` event (`api_token`, `oauth_cached` or `oauth`).
106
+ - `order_id`, `plan`: the active hosting plan's order id and name when you looked them up, else `null`.
107
+ - `connected_at`: the current time in UTC, ISO 8601 to the second.
108
+
109
+ Extra keys are allowed, but keep these six as above: the build report reads `tool` and `mcp`.
110
+
111
+ ## Phase 4 - Continue to Headless Hosting steps
91
112
 
92
113
  After evrything is verified and you have access to the Hostinger MCP Server and the skills then you can go back to `headless/SKILL.md` and follow the steps section to start the process.
93
114
 
@@ -100,6 +100,15 @@ function readEvents(ws) {
100
100
  .map((line) => JSON.parse(line));
101
101
  }
102
102
 
103
+ // connect.json from older runs may hold mcp as an object or only mcp_version; the report needs strings.
104
+ function readConnect(ws) {
105
+ const connect = readJson(join(ws.dir, 'connect.json'), {});
106
+ const mcp = connect.mcp ?? connect.mcp_version ?? null;
107
+ let version = mcp;
108
+ if (mcp !== null && typeof mcp !== 'string') version = mcp.name || mcp.version ? [mcp.name, mcp.version].filter(Boolean).join(' ') : JSON.stringify(mcp);
109
+ return { tool: typeof connect.tool === 'string' ? connect.tool : null, mcp: version };
110
+ }
111
+
103
112
  function runStart(ws, flags) {
104
113
  const state = loadState(ws);
105
114
  // A previous run that never reached its report (closed window, crash) is closed out first, so
@@ -111,7 +120,7 @@ function runStart(ws, flags) {
111
120
  const fresh = loadState(ws);
112
121
  const previous = fresh.run ?? {};
113
122
  const startedAt = now();
114
- const connect = readJson(join(ws.dir, 'connect.json'), {});
123
+ const connect = readConnect(ws);
115
124
  // Run ids are the start time; a second run in the same second gets a suffix.
116
125
  const taken = new Set([
117
126
  ...(fresh.history ?? []).map((h) => h.run),
@@ -268,7 +277,7 @@ function buildReport(ws, { outcome, tokens } = {}) {
268
277
  const run = currentRun(ws, state);
269
278
  const site = readJson(join(ws.dir, 'site.json'), {});
270
279
  const deploy = readJson(join(ws.dir, 'deploy.json'), {});
271
- const connect = readJson(join(ws.dir, 'connect.json'), {});
280
+ const connect = readConnect(ws);
272
281
  const history = (state.history ?? []).filter((h) => h.run === run.id);
273
282
  // Lines logged before events were checked on write may not match the schema.
274
283
  const dropped = [];
@@ -291,7 +300,7 @@ function buildReport(ws, { outcome, tokens } = {}) {
291
300
  const stillOpen = resolvedOutcome !== 'abandoned' && history.some((h) => !h.ended_at);
292
301
  const endedAt = stillOpen ? now() : lastActivity;
293
302
  const stages = history.map((h) => ({ ...h, minutes: minutesBetween(h.started_at, h.ended_at ?? endedAt) }));
294
- const gaps = [...loggingGaps(stages, run, resolvedOutcome), ...dropped];
303
+ const gaps = [...loggingGaps(stages, run, resolvedOutcome, site.backend), ...dropped];
295
304
  const strip = ({ ts, run: _run, type: _type, ...rest }) => ({ ...rest, logged_at: ts });
296
305
  const liveStage = stages.find((s) => s.stage === 'deploy' && s.result?.startsWith('ok'));
297
306
  if (!OUTCOMES.includes(resolvedOutcome)) fail(`unknown outcome "${resolvedOutcome}"; one of ${OUTCOMES.join(', ')}`);
@@ -316,7 +325,7 @@ function buildReport(ws, { outcome, tokens } = {}) {
316
325
  model: run.model ?? null,
317
326
  os: process.platform,
318
327
  node: process.version,
319
- mcp: connect.mcp ?? null,
328
+ mcp: connect.mcp,
320
329
  backend: site.backend ?? null,
321
330
  frontend: frontendStack(ws.dir),
322
331
  outcome: resolvedOutcome,
@@ -400,15 +409,21 @@ function reportRejected(ws, [runId]) {
400
409
 
401
410
  // Gaps are printed as warnings by `report`, so guesses never pass as measurements.
402
411
  const EXPECTED_FOR_BUILD = ['check_state', 'refine', 'provision', 'content_model', 'design', 'frontend', 'validate', 'deploy'];
403
- function loggingGaps(stages, run, outcome) {
412
+ // Stages a backend skips on purpose: webapp has no CMS, so no content model.
413
+ const SKIPPED_BY_BACKEND = { webapp: ['content_model'] };
414
+ function loggingGaps(stages, run, outcome, backend) {
404
415
  const gaps = [];
405
416
  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' });
417
+ // A skipped stage did no work, so it has no duration to measure.
418
+ const problem = s.result === 'skipped' ? null
419
+ : !s.ended_at ? 'started but never ended'
420
+ : s.start_estimated ? 'start time estimated, not measured'
421
+ : Date.parse(s.ended_at) === Date.parse(s.started_at) ? 'logged with no duration; probably started and ended in one go after the work'
422
+ : null;
423
+ if (problem) gaps.push({ stage: s.stage, phase: s.phase, problem });
409
424
  }
410
425
  if (run.kind === 'build' && outcome === 'live') {
411
- for (const stage of EXPECTED_FOR_BUILD) {
426
+ for (const stage of EXPECTED_FOR_BUILD.filter((name) => !SKIPPED_BY_BACKEND[backend]?.includes(name))) {
412
427
  if (!stages.some((s) => s.stage === stage)) gaps.push({ stage, phase: null, problem: 'never logged, though the site went live' });
413
428
  }
414
429
  }
@@ -57,17 +57,17 @@ export default [
57
57
  "resources": [
58
58
  {
59
59
  "uri": "skill://hostinger-headless/SKILL.md",
60
- "digest": "sha256:be4d4ac36de438c9dac23d2a6f13075298283d5da23002537d288cc5f98f9409",
61
- "size": 21141,
60
+ "digest": "sha256:d7591f9e80ad48a5b45f774076af52c7847865fc63398325bc2f63907c7df779",
61
+ "size": 21181,
62
62
  "mimeType": "text/markdown",
63
- "text": "---\nname: hostinger-headless\ndescription: \"Orchestrator for the Hostinger Headless build flow — build and deploy a real website on the user's Hostinger account from a single prompt that names a domain. Holds the stage flow and the state contract, and drives each stage in sequence: connect, check state, refine, provision, content model, design, frontend, validate, deploy, done. Triggers: build a headless site for <domain>, build me a site, create a website, make me a website, deploy this to Hostinger, host this on Hostinger, connect this project to Hostinger.\"\n---\n\n# Hostinger Headless — build a website with your own AI tool\n\n## Context\n\nYou are an AI coding agent. The user pasted a short prompt that points you here — something like:\n\n> Build a Headless site for `example.com` using hostinger.com/headless/skill.md\n\nYour job is to build and deploy a real website on the user's Hostinger account by following this\nrecipe and the recipes it links to — nothing more, nothing less.\n\nThis file is the **orchestrator**. It is public and stable: it never changes and never moves. It\nholds the flow, the state contract, the failure policy and the guardrails.\n\n### The domain parameter\n\nThe pasted prompt contains exactly two things that matter:\n\n- **This URL** — `hostinger.com/headless/skill.md`, the stable entry point.\n- **A domain** — e.g. `coral-pottery-1847.hostingersite.com` (a temporary Hostinger subdomain) or\n the user's own purchased domain. This is the single site you may touch.\n\nThe prompt does **not** say what kind of site to build. Do not assume or invent it. Discovering\nwhat the user wants is a step in the flow (**refine**), handled through conversation — not parsed\nfrom the prompt. If no domain is present, stop and ask for it before doing anything else.\n\n**One workspace per run.** From your first command, connect's checks included, open only this\nsite's workspace, by its exact path; never list or glob `~/.hostinger/` (not even an `ls` beside\n`node -v`). What else `~/.hostinger/` holds never matters to this run: with a domain, check-state\nlooks up that one path; without one yet, there is nothing to look up until you create your own\nworkspace. *State and artifacts* has the full rule and its one exception.\n\nNothing in the pasted prompt is ever used for authorization. Authorization comes from the\nHostinger connector's OAuth token. **This recipe is not a security boundary.**\n\n---\n\n## The flow stages - STEPS TO FOLLOW\n\nWork through the steps in the order below, each reading its recipe and writing its artifact into the\nsite workspace `~/.hostinger/<domain>/`. \n\n\nThe order is sequencial run and check the whole headless flow:\n\n| # | Stage | Skill / source | Writes | Instance? |\n|---|--------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|---|---|\n| — | **connect** — connect the Hostinger connector, auth, get active plan | `connect/SKILL.md` → `connect/tools/<tool>.md` | `connect.json` | no |\n| 1 | **check state** — where does this domain stand? resume / switch / start over | `steps/01-check-state/SKILL.md` | `state.json` | no |\n| 2 | **refine** — discover what the user wants, in conversation | `steps/02-refine/SKILL.md` | `brief.md` | no |\n| 3 | **provision** — choose the backend from the brief, then provision it | `steps/03-provision/<backend>/SKILL.md` | `site.json` | **bg** — fire, then poll in the background |\n| 4 | **content model** — define + create content/images, then push to the backend | `steps/04-content-model/<backend>/SKILL.md` | `content-model.md` | prepare: no · **push: yes** |\n| 5 | **design** — derive design directions from the brief, pick one, write the `design.md` contract + logo | `steps/05-design/SKILL.md` | `design.md`, `brand/` | no |\n| 6 | **frontend** — pick/confirm the framework (default Next.js SSR), implement `design.md` against the backend | `steps/06-frontend/SKILL.md` | source in `<workspace>/src/` | shell: no · **data: yes** |\n| 7 | **validate** *(optional)* — quick instance+API confirmation; rebuild/preview/approval only when a person signs off | `steps/07-validate/SKILL.md` | — | no (local) |\n| 8 | **deploy** — upload source, trigger the build | `steps/08-deploy/SKILL.md` | `deploy.json` | **yes** |\n| 9 | **done** — hand over URLs, dashboard, summary, next steps (the **final report**) | `steps/09-done/SKILL.md` | — | no |\n\n**The most important is that connect stage runs first and always**\nIt's fetched from the public URL and runs *before* the Hostinger MCP Server\nis loaded, the main goal of this is to have the Hostinger MCP Server.\n\nYou can find those connect skills under `connect/`.\nThe main skill lives in `connect/SKILL.md`.\nThere are also specific tools under `connect/tools/`.\n\nThe other 9 numbered steps live under `steps/` and are read from the installed skill folder (this will be guided in connect step).\n\n**One concurrency rule:** two *independent* stages in a single session may run at once, but never\ntwo writers of the same artifact, and never two agents/windows on the same domain — the `.lock`\n(Step 1) guards that. Parallelism is always *optional*: an agent that can't interleave work runs the\nsteps serially and is still correct. Saying this, Always that you consider, you can run the steps\nin parallel using different threads or processes to save time, but always ensure the right output.\n\nSteps can generate **State files** those files are only hints; the truth is what the Hostinger connector reports, so\nverify a \"done\" marker against reality before acting on it. If a step's `SKILL.md` is missing from the installed skill folder, stop and tell\nthe user rather than improvising the stage from its description.\n\nThe **Instance?** column says whether a stage touches the live backend:\n`no` = pure prepare work that can run during the provision wait;\n`push`/`data`/`yes` = a commit phase that joins the instance barrier;\n`bg` = provision itself, the background task.\n\n**Always end with the final report.** Whenever a run ends, give the user the final report defined\nin `steps/09-done/SKILL.md`: an outcome paragraph, a *Steps completed* table (one row per stage that\nran), the links, where state lives, and any gaps. That includes runs that stop early at a dummy or\nmissing step, a failed provision, or a user pause. Every run also saves a **build report**\n(`run-log.mjs report`, see *Run log and build report* below), on those early stops too.\n\n**Log the run as you go.** Wrap every stage (and each phase of a split stage) in\n`run-log.mjs stage-start` / `stage-end`, and log an event the moment something goes wrong, the user\ncorrects you, or a skill is missing or wrong — before you continue. See *Run log and build report*.\n\n**Why this order.** connect first, so account/plan failures surface before the user spends time\ntalking. Check-state right after, so a resumed, duplicate, or brand-new run is sorted out — and the\nuser is offered the choice to continue, switch domains, or start over — before any new work begins.\nRefinement before provisioning, because the backend cannot be chosen until the brief exists.\nValidation before deploy, always — nothing reaches the live domain unseen.\n\n### When you ask the user\n\nA question tool's result is the user's answer only when it carries their choice or their words. When\nit comes back skipped, rejected, empty or as an error (a *skipped* result), nobody has answered yet:\nCursor's `AskQuestion`, for one, returns \"Questions skipped by the user\" when no one touched it. Ask\nthe same questions again as plain text in your reply, end your turn, and continue from what the user\ntypes. One plain-text re-ask is enough: it can't come back skipped. Fill a question you asked with a\ndefault only when the user says so in their own words (\"you decide\", \"just build it\", \"skip that\"),\nor when they said up front that no one will be there to answer; then list those defaults under gaps\nin the final report. Every step that asks the user something follows this.\n\n### Background provision & the instance barrier\n\n**Provision always runs — what it does depends on what the domain already has.** Often onboarding on\nhostinger.com has already created the website before you run, so the domain *has* a headless instance;\nbut an instance existing does **not** mean provision is done or can be skipped. `check_state` works out\nwhich of these cases applies and records it, then provision takes the matching path:\n\n1. **An empty headless shell already exists for the domain (the common onboarding case).** Onboarding\n creates the website first — an **empty headless shell** (`hosting_websites_start-setup { type:\n \"headless_empty\" }`): `website_type` is `headless`, but **no backend is installed** (no WordPress at\n `/cms`, no store). This still goes through provision, which **adopts the shell and swaps it to the\n chosen backend** (the backend is picked in refine first): WordPress is installed into the shell's\n `/cms` with `wordpress_installations_install { type: \"headless\" }`, or the shell is adopted as the\n Ecommerce storefront host. **Never skip provision for a shell, and never create a second site.** See\n the provision router's *Onboarding handoff*.\n2. **No instance exists at all** (the user ran the headless flow without onboarding provisioning\n anything). Provision creates a fresh headless instance with `hosting_websites_start-setup` using the\n chosen backend's own `type`.\n3. **User wants a webapp.** Provision is a plain Node.js app host, not headless — `webapp` ignores\n headless shells entirely and is only reached by experimental actions, never the onboarding flow.\n\nEither way the create/install is asynchronous and returns `queued` immediately, then it's polled\nevery ~15 s for a few minutes — **what you poll depends on the path**: a fresh\n`hosting_websites_start-setup` is polled with `hosting_websites_list-setups` until `completed`, while a\nshell swap (`wordpress_installations_install`) creates no new setup, so it's polled with\n`wordpress_installations_list` until the `/cms` row is `is_valid`. Don't block on that poll. The\nsingle rule that makes the overlap safe:\n\n> **Fire provision as a background task, then do every stage's prepare work while it settles. The\n> moment a step must write to the live instance, it waits for provision to report ready (via the\n> backend step's readiness poll — see above). That wait is the *instance barrier*.**\n\n---\n\n## State and artifacts\n\nEverything for a domain — its state **and** its generated source — lives under the **site workspace\n`~/.hostinger/<domain>/`** (resolve `~` to the home directory: `$HOME` in a shell, `os.homedir()` in\nNode — `~/.hostinger` means `$HOME/.hostinger`). This is a **global, per-user** location, not a\nproject folder, so this domain's build is resumable from **any** directory — the current working\ndirectory does not matter. Create the workspace if it's missing and do all of a stage's work inside\nit (including the frontend `src/`, built and deployed from `~/.hostinger/<domain>/src/`).\n\n**The directory name is the domain**, so the files inside it never repeat it.\n\n**Read and write only this site's workspace.** The other workspaces under `~/.hostinger/` are the\nuser's other sites, often for other clients, so keep their briefs, designs and code out of this build.\nOpen `~/.hostinger/<domain>/` by its exact path; don't list or glob `~/.hostinger/`, and don't open,\ncopy from or diff against another workspace there, in any stage. A run that has no domain yet works\nonly in the one workspace it created itself; when provision moves it to `~/.hostinger/<new-domain>/`,\nthat becomes this site's workspace. One exception: if the user, in this conversation, names another\nof their own sites to reuse, read that one workspace, read-only, and tell them in one line what you\ntook from it.\n\n| File (under `~/.hostinger/<domain>/`) | Written by | Contains |\n|---|---|---|\n| `state.json` | every stage | tool, next stage, Hostinger connector version, timestamps |\n| `connect.json` | connect | tool, account/plan readiness |\n| `brief.md` | refine | refined brief, audience, tone, pages, must-haves |\n| `site.json` | provision | chosen backend, username, install id, all URLs |\n| `content-model.md` | content model | entities, fields, pages, navigation |\n| `design.md` (+ `brand/` logo files) | design | chosen design direction: tokens, type, logo, components, per-page composition, accessibility |\n| `src/` | frontend | the website source implementing `design.md`; framework choice is recorded in `state.json → frontend` |\n| `deploy.json` | deploy | build id, deployed at, live URLs |\n| `events.jsonl` | every stage, via `run-log.mjs` | append-only log of issues, user feedback, skill gaps and wins |\n| `reports/<run>.json` + `.html`, `report.html` | done (or any early stop), via `run-log.mjs` | the build report for each run; `report.html` is the latest |\n\n`state.json` is the minimum you must keep current. **`stage` is the *next* stage to run** (not the\nlast completed one), and it does **not** store the domain — the directory is the key. Because\nprovision runs in the background, its status lives in its own `provision` field rather than in\n`stage`, so prepare work can advance `stage` while provision is still settling:\n\n```json\n{\n \"tool\": \"claude-code\",\n \"stage\": \"content_model\",\n \"provision\": {\n \"status\": \"in_progress\",\n \"domain\": \"example.com\",\n \"started_at\": \"2026-10-08T10:00:00Z\"\n },\n \"updated_at\": \"2026-10-08T10:00:00Z\"\n}\n```\n\n- `provision` is **absent** until provision fires, `{\"status\":\"in_progress\", …}` while the\n background poll runs, and `{\"status\":\"completed\", …}` once `hosting_websites_list-setups` confirms\n it. `domain` is the poll target.\n- Any **instance-barrier** step (content-model push, frontend data, deploy) must read\n `provision.status == \"completed\"` — and re-verify it against the connector — before it runs. A\n prepare step ignores `provision` entirely.\n- On **resume**, `provision.status` is a hint only: re-poll `hosting_websites_list-setups` to learn\n the truth, then update the field.\n- `run` (the current run), `history` (one entry per stage or phase, with real start and end\n times), and `reports_sent` / `reports_rejected` (which copy of each report reached Hostinger) are\n written **only** by `run-log.mjs`. Leave them alone when you update `state.json` by hand.\n- The **onboarding slug** is how hPanel names the coding tool: `claude` for Claude Code, else\n `cursor`, `codex`, `vscode`, `antigravity`, `devin`. Map it from the `<tool>` you pass to\n `run-start --tool` (`state.json → run.tool`, e.g. `claude-code` → `claude`).\n\n---\n\n## Run log and build report\n\n`run-log.mjs` (next to this file) records the run while it happens and turns it into a report at the\nend. Timestamps come from the script, so they're measured, not remembered. It needs only Node.\nEvery command is `node <skill>/run-log.mjs <domain> …`, where `<skill>` is the folder this file is\nin. Write it out in full each time (a shell variable holding the command breaks in zsh):\n\n```bash\nnode <skill>/run-log.mjs <domain> run-start --tool <tool> --model <model> --skill <skill version> # check-state, first thing\nnode <skill>/run-log.mjs <domain> run-update --kind resume # check-state, once the route is agreed (or redesign / iterate)\nnode <skill>/run-log.mjs <domain> stage-start frontend # before a stage\nnode <skill>/run-log.mjs <domain> stage-end frontend ok_after_retries --note \"second framework tried\"\nnode <skill>/run-log.mjs <domain> stage-start frontend --phase shell # split stages log each phase\nnode <skill>/run-log.mjs <domain> event issue '{\"stage\":\"content_model\",\"symptom\":\"…\",\"cause\":\"…\",\"workaround\":\"…\",\"severity\":\"medium\",\"resolved\":true}'\nnode <skill>/run-log.mjs <domain> report --outcome live # done, or wherever the run stops\nnode <skill>/run-log.mjs <domain> report-sent <run> # after the report was sent (see below)\nnode <skill>/run-log.mjs <domain> report-rejected <run> # the send returned 422\nnode <skill>/run-log.mjs <domain> unsent # check-state: reports still to send\nnode <skill>/run-log.mjs <domain> run-resume --reason \"about/contact layout fix\" # follow-up in the same session\n```\n\n**Follow-ups continue the run.** When the user asks for a change after the final report in the\nsame session (a layout fix, a copy tweak, a redeploy), that work belongs to the run it follows:\ncall `run-resume`, log the feedback and the stages it re-runs as usual, then `report` again. That\nrewrites the same `reports/<run>.json` and `.html`, so you get one report that includes the follow-up,\nnot a second report that replaces `report.html`. Use `run-start` only in a new session; check-state\ndoes that.\n\n`stage-end` results: `ok`, `ok_after_retries`, `ok_with_fixups`, `failed`, `skipped`, `stopped`.\nStages: `check_state`, `refine`, `provision`, `content_model`, `design`, `frontend`, `validate`,\n`deploy`, `done` (connect has its own `connect.json`). Split stages use `--phase`: provision `fire` /\n`join`, content model `prepare` / `push`, frontend `shell` / `data`.\n\n**Forgot a `stage-start`?** Log it as soon as you notice, with roughly when the stage began:\n`stage-start frontend --started-ago 6m` (or `--started-at <ISO time>`). The same flags work on a\n`stage-end` that has no start. With neither, the script assumes the stage began when the previous one\nended. Either way the start is marked as estimated in the report. Never log a start and an end\nback to back after the work is done: that records a stage that took no time. If you already did,\ncorrect it with `stage-fix frontend --started-ago 6m`.\n\n**Log an event the moment it happens**, not at the end — details are gone by then:\n\n| Event | Log it when | Fields (required in bold) |\n|---|---|---|\n| `issue` | something fails, misbehaves, or needs a workaround — including your own mistakes | **stage**, **symptom**, cause, workaround, severity (`high`/`medium`/`low`), resolved, user_had_to_intervene |\n| `feedback` | the user corrects you, rejects something, or changes scope | **stage**, **category** (e.g. `design_rejected`, `layout_bug`, `scope_add`, `wrong_assumption`), **paraphrase** |\n| `skill_gap` | a step is dummy, missing, wrong or silent on what you needed | **skill** (path), **gap** |\n| `good` | something worked notably well or fast | **text** |\n\nParaphrase the user; don't paste long quotes, file contents, credentials or personal data. Set\n`user_had_to_intervene` when the user had to point the problem out.\n\n**Tokens** go on `report --tokens '<json>'` only if your tool tells you your own usage directly\n(`{\"source\":\"…\",\"requests\":…,\"input\":…,\"output\":…,\"cache_read\":…,\"cache_write\":…,\"est_cost_usd\":…}`).\nOtherwise leave it out and the report says `{\"source\":\"not reported\"}` (not null: the connector rejects a null `tokens`). Never read session transcripts or log\nfiles to get it.\n\nThe report is saved to `reports/<run>.json` and `.html`, and `report.html` in the workspace is the\nlatest. If a run never reaches its report (the window closed), the next `run-start` closes it as\n`abandoned`, so its log still becomes a report.\n\n**Send every report to Hostinger.** Sending is part of the flow, so don't ask first; tell the user in\none line right after the send (see `09-done`). Right after each\n`report` (follow-ups included), read `reports/<run>.json` and pass its contents, unchanged, as the\narguments of the Hostinger connector tool `hosting_headless_submit-build-report`. Then record the\nresult:\n\n- **Success:** `report-sent <run>`.\n- **422** (the report doesn't match the schema the endpoint holds): `report-rejected <run>`. Don't\n resend it.\n- **Anything else** (network error, 5xx, the tool isn't in your connector): do nothing. The next\n check-state sends it again.\n\nNever edit the JSON before sending, never add account details (the connector's login identifies the\naccount), and never let a failed send block, delay or change the run. Check-state sends any\nreports still unsent (abandoned runs, earlier failures) with `unsent`."
63
+ "text": "---\nname: hostinger-headless\ndescription: \"Orchestrator for the Hostinger Headless build flow — build and deploy a real website on the user's Hostinger account from a single prompt that names a domain. Holds the stage flow and the state contract, and drives each stage in sequence: connect, check state, refine, provision, content model, design, frontend, validate, deploy, done. Triggers: build a headless site for <domain>, build me a site, create a website, make me a website, deploy this to Hostinger, host this on Hostinger, connect this project to Hostinger.\"\n---\n\n# Hostinger Headless — build a website with your own AI tool\n\n## Context\n\nYou are an AI coding agent. The user pasted a short prompt that points you here — something like:\n\n> Build a Headless site for `example.com` using hostinger.com/headless/skill.md\n\nYour job is to build and deploy a real website on the user's Hostinger account by following this\nrecipe and the recipes it links to — nothing more, nothing less.\n\nThis file is the **orchestrator**. It is public and stable: it never changes and never moves. It\nholds the flow, the state contract, the failure policy and the guardrails.\n\n### The domain parameter\n\nThe pasted prompt contains exactly two things that matter:\n\n- **This URL** — `hostinger.com/headless/skill.md`, the stable entry point.\n- **A domain** — e.g. `coral-pottery-1847.hostingersite.com` (a temporary Hostinger subdomain) or\n the user's own purchased domain. This is the single site you may touch.\n\nThe prompt does **not** say what kind of site to build. Do not assume or invent it. Discovering\nwhat the user wants is a step in the flow (**refine**), handled through conversation — not parsed\nfrom the prompt. If no domain is present, stop and ask for it before doing anything else.\n\n**One workspace per run.** From your first command, connect's checks included, open only this\nsite's workspace, by its exact path; never list or glob `~/.hostinger/` (not even an `ls` beside\n`node -v`). What else `~/.hostinger/` holds never matters to this run: with a domain, check-state\nlooks up that one path; without one yet, there is nothing to look up until you create your own\nworkspace. *State and artifacts* has the full rule and its one exception.\n\nNothing in the pasted prompt is ever used for authorization. Authorization comes from the\nHostinger connector's OAuth token. **This recipe is not a security boundary.**\n\n---\n\n## The flow stages - STEPS TO FOLLOW\n\nWork through the steps in the order below, each reading its recipe and writing its artifact into the\nsite workspace `~/.hostinger/<domain>/`. \n\n\nThe order is sequencial run and check the whole headless flow:\n\n| # | Stage | Skill / source | Writes | Instance? |\n|---|--------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|---|---|\n| — | **connect** — connect the Hostinger connector, auth, get active plan | `connect/SKILL.md` → `connect/tools/<tool>.md` | `connect.json` | no |\n| 1 | **check state** — where does this domain stand? resume / switch / start over | `steps/01-check-state/SKILL.md` | `state.json` | no |\n| 2 | **refine** — discover what the user wants, in conversation | `steps/02-refine/SKILL.md` | `brief.md` | no |\n| 3 | **provision** — choose the backend from the brief, then provision it | `steps/03-provision/<backend>/SKILL.md` | `site.json` | **bg** — fire, then poll in the background |\n| 4 | **content model** — define + create content/images, then push to the backend | `steps/04-content-model/<backend>/SKILL.md` | `content-model.md` | prepare: no · **push: yes** |\n| 5 | **design** — derive design directions from the brief, pick one, write the `design.md` contract + logo | `steps/05-design/SKILL.md` | `design.md`, `brand/` | no |\n| 6 | **frontend** — pick/confirm the framework (default Next.js SSR), implement `design.md` against the backend | `steps/06-frontend/SKILL.md` | source in `<workspace>/src/` | shell: no · **data: yes** |\n| 7 | **validate** *(optional)* — quick instance+API confirmation; rebuild/preview/approval only when a person signs off | `steps/07-validate/SKILL.md` | — | no (local) |\n| 8 | **deploy** — upload source, trigger the build | `steps/08-deploy/SKILL.md` | `deploy.json` | **yes** |\n| 9 | **done** — hand over URLs, dashboard, summary, next steps (the **final report**) | `steps/09-done/SKILL.md` | — | no |\n\n**The most important is that connect stage runs first and always**\nIt's fetched from the public URL and runs *before* the Hostinger MCP Server\nis loaded, the main goal of this is to have the Hostinger MCP Server.\n\nYou can find those connect skills under `connect/`.\nThe main skill lives in `connect/SKILL.md`.\nThere are also specific tools under `connect/tools/`.\n\nThe other 9 numbered steps live under `steps/` and are read from the installed skill folder (this will be guided in connect step).\n\n**One concurrency rule:** two *independent* stages in a single session may run at once, but never\ntwo writers of the same artifact, and never two agents/windows on the same domain — the `.lock`\n(Step 1) guards that. Parallelism is always *optional*: an agent that can't interleave work runs the\nsteps serially and is still correct. Saying this, Always that you consider, you can run the steps\nin parallel using different threads or processes to save time, but always ensure the right output.\n\nSteps can generate **State files** those files are only hints; the truth is what the Hostinger connector reports, so\nverify a \"done\" marker against reality before acting on it. If a step's `SKILL.md` is missing from the installed skill folder, stop and tell\nthe user rather than improvising the stage from its description.\n\nThe **Instance?** column says whether a stage touches the live backend:\n`no` = pure prepare work that can run during the provision wait;\n`push`/`data`/`yes` = a commit phase that joins the instance barrier;\n`bg` = provision itself, the background task.\n\n**Always end with the final report.** Whenever a run ends, give the user the final report defined\nin `steps/09-done/SKILL.md`: an outcome paragraph, a *Steps completed* table (one row per stage that\nran), the links, where state lives, and any gaps. That includes runs that stop early at a dummy or\nmissing step, a failed provision, or a user pause. Every run also saves a **build report**\n(`run-log.mjs report`, see *Run log and build report* below), on those early stops too.\n\n**Log the run as you go.** Wrap every stage (and each phase of a split stage) in\n`run-log.mjs stage-start` / `stage-end`, and log an event the moment something goes wrong, the user\ncorrects you, or a skill is missing or wrong — before you continue. See *Run log and build report*.\n\n**Why this order.** connect first, so account/plan failures surface before the user spends time\ntalking. Check-state right after, so a resumed, duplicate, or brand-new run is sorted out — and the\nuser is offered the choice to continue, switch domains, or start over — before any new work begins.\nRefinement before provisioning, because the backend cannot be chosen until the brief exists.\nValidation before deploy, always — nothing reaches the live domain unseen.\n\n### When you ask the user\n\nA question tool's result is the user's answer only when it carries their choice or their words. When\nit comes back skipped, rejected, empty or as an error (a *skipped* result), nobody has answered yet:\nCursor's `AskQuestion`, for one, returns \"Questions skipped by the user\" when no one touched it. Ask\nthe same questions again as plain text in your reply, end your turn, and continue from what the user\ntypes. One plain-text re-ask is enough: it can't come back skipped. Fill a question you asked with a\ndefault only when the user says so in their own words (\"you decide\", \"just build it\", \"skip that\"),\nor when they said up front that no one will be there to answer; then list those defaults under gaps\nin the final report. Every step that asks the user something follows this.\n\n### Background provision & the instance barrier\n\n**Provision always runs — what it does depends on what the domain already has.** Often onboarding on\nhostinger.com has already created the website before you run, so the domain *has* a headless instance;\nbut an instance existing does **not** mean provision is done or can be skipped. `check_state` works out\nwhich of these cases applies and records it, then provision takes the matching path:\n\n1. **An empty headless shell already exists for the domain (the common onboarding case).** Onboarding\n creates the website first — an **empty headless shell** (`hosting_websites_start-setup { type:\n \"headless_empty\" }`): `website_type` is `headless`, but **no backend is installed** (no WordPress at\n `/cms`, no store). This still goes through provision, which **adopts the shell and swaps it to the\n chosen backend** (the backend is picked in refine first): WordPress is installed into the shell's\n `/cms` with `wordpress_installations_install { type: \"headless\" }`, or the shell is adopted as the\n Ecommerce storefront host. **Never skip provision for a shell, and never create a second site.** See\n the provision router's *Onboarding handoff*.\n2. **No instance exists at all** (the user ran the headless flow without onboarding provisioning\n anything). Provision creates a fresh headless instance with `hosting_websites_start-setup` using the\n chosen backend's own `type`.\n3. **User wants a webapp.** Provision is a plain Node.js app host, not headless — `webapp` ignores\n headless shells entirely and is only reached by experimental actions, never the onboarding flow.\n\nEither way the create/install is asynchronous and returns `queued` immediately, then it's polled\nevery ~15 s for a few minutes — **what you poll depends on the path**: a fresh\n`hosting_websites_start-setup` is polled with `hosting_websites_list-setups` until `completed`, while a\nshell swap (`wordpress_installations_install`) creates no new setup, so it's polled with\n`wordpress_installations_list` until the `/cms` row is `is_valid`. Don't block on that poll. The\nsingle rule that makes the overlap safe:\n\n> **Fire provision as a background task, then do every stage's prepare work while it settles. The\n> moment a step must write to the live instance, it waits for provision to report ready (via the\n> backend step's readiness poll — see above). That wait is the *instance barrier*.**\n\n---\n\n## State and artifacts\n\nEverything for a domain — its state **and** its generated source — lives under the **site workspace\n`~/.hostinger/<domain>/`** (resolve `~` to the home directory: `$HOME` in a shell, `os.homedir()` in\nNode — `~/.hostinger` means `$HOME/.hostinger`). This is a **global, per-user** location, not a\nproject folder, so this domain's build is resumable from **any** directory — the current working\ndirectory does not matter. Create the workspace if it's missing and do all of a stage's work inside\nit (including the frontend `src/`, built and deployed from `~/.hostinger/<domain>/src/`).\n\n**The directory name is the domain**, so the files inside it never repeat it.\n\n**Read and write only this site's workspace.** The other workspaces under `~/.hostinger/` are the\nuser's other sites, often for other clients, so keep their briefs, designs and code out of this build.\nOpen `~/.hostinger/<domain>/` by its exact path; don't list or glob `~/.hostinger/`, and don't open,\ncopy from or diff against another workspace there, in any stage. A run that has no domain yet works\nonly in the one workspace it created itself; when provision moves it to `~/.hostinger/<new-domain>/`,\nthat becomes this site's workspace. One exception: if the user, in this conversation, names another\nof their own sites to reuse, read that one workspace, read-only, and tell them in one line what you\ntook from it.\n\n| File (under `~/.hostinger/<domain>/`) | Written by | Contains |\n|---|---|---|\n| `state.json` | every stage | tool, next stage, Hostinger connector version, timestamps |\n| `connect.json` | connect | tool, MCP version, sign-in method, plan; shape in `connect/SKILL.md` |\n| `brief.md` | refine | refined brief, audience, tone, pages, must-haves |\n| `site.json` | provision | chosen backend, username, install id, all URLs |\n| `content-model.md` | content model | entities, fields, pages, navigation |\n| `design.md` (+ `brand/` logo files) | design | chosen design direction: tokens, type, logo, components, per-page composition, accessibility |\n| `src/` | frontend | the website source implementing `design.md`; framework choice is recorded in `state.json → frontend` |\n| `deploy.json` | deploy | build id, deployed at, live URLs |\n| `events.jsonl` | every stage, via `run-log.mjs` | append-only log of issues, user feedback, skill gaps and wins |\n| `reports/<run>.json` + `.html`, `report.html` | done (or any early stop), via `run-log.mjs` | the build report for each run; `report.html` is the latest |\n\n`state.json` is the minimum you must keep current. **`stage` is the *next* stage to run** (not the\nlast completed one), and it does **not** store the domain — the directory is the key. Because\nprovision runs in the background, its status lives in its own `provision` field rather than in\n`stage`, so prepare work can advance `stage` while provision is still settling:\n\n```json\n{\n \"tool\": \"claude-code\",\n \"stage\": \"content_model\",\n \"provision\": {\n \"status\": \"in_progress\",\n \"domain\": \"example.com\",\n \"started_at\": \"2026-10-08T10:00:00Z\"\n },\n \"updated_at\": \"2026-10-08T10:00:00Z\"\n}\n```\n\n- `provision` is **absent** until provision fires, `{\"status\":\"in_progress\", …}` while the\n background poll runs, and `{\"status\":\"completed\", …}` once `hosting_websites_list-setups` confirms\n it. `domain` is the poll target.\n- Any **instance-barrier** step (content-model push, frontend data, deploy) must read\n `provision.status == \"completed\"` — and re-verify it against the connector — before it runs. A\n prepare step ignores `provision` entirely.\n- On **resume**, `provision.status` is a hint only: re-poll `hosting_websites_list-setups` to learn\n the truth, then update the field.\n- `run` (the current run), `history` (one entry per stage or phase, with real start and end\n times), and `reports_sent` / `reports_rejected` (which copy of each report reached Hostinger) are\n written **only** by `run-log.mjs`. Leave them alone when you update `state.json` by hand.\n- The **onboarding slug** is how hPanel names the coding tool: `claude` for Claude Code, else\n `cursor`, `codex`, `vscode`, `antigravity`, `devin`. Map it from the `<tool>` you pass to\n `run-start --tool` (`state.json → run.tool`, e.g. `claude-code` → `claude`).\n\n---\n\n## Run log and build report\n\n`run-log.mjs` (next to this file) records the run while it happens and turns it into a report at the\nend. Timestamps come from the script, so they're measured, not remembered. It needs only Node.\nEvery command is `node <skill>/run-log.mjs <domain> …`, where `<skill>` is the folder this file is\nin. Write it out in full each time (a shell variable holding the command breaks in zsh):\n\n```bash\nnode <skill>/run-log.mjs <domain> run-start --tool <tool> --model <model> --skill <skill version> # check-state, first thing\nnode <skill>/run-log.mjs <domain> run-update --kind resume # check-state, once the route is agreed (or redesign / iterate)\nnode <skill>/run-log.mjs <domain> stage-start frontend # before a stage\nnode <skill>/run-log.mjs <domain> stage-end frontend ok_after_retries --note \"second framework tried\"\nnode <skill>/run-log.mjs <domain> stage-start frontend --phase shell # split stages log each phase\nnode <skill>/run-log.mjs <domain> event issue '{\"stage\":\"content_model\",\"symptom\":\"…\",\"cause\":\"…\",\"workaround\":\"…\",\"severity\":\"medium\",\"resolved\":true}'\nnode <skill>/run-log.mjs <domain> report --outcome live # done, or wherever the run stops\nnode <skill>/run-log.mjs <domain> report-sent <run> # after the report was sent (see below)\nnode <skill>/run-log.mjs <domain> report-rejected <run> # the send returned 422\nnode <skill>/run-log.mjs <domain> unsent # check-state: reports still to send\nnode <skill>/run-log.mjs <domain> run-resume --reason \"about/contact layout fix\" # follow-up in the same session\n```\n\n**Follow-ups continue the run.** When the user asks for a change after the final report in the\nsame session (a layout fix, a copy tweak, a redeploy), that work belongs to the run it follows:\ncall `run-resume`, log the feedback and the stages it re-runs as usual, then `report` again. That\nrewrites the same `reports/<run>.json` and `.html`, so you get one report that includes the follow-up,\nnot a second report that replaces `report.html`. Use `run-start` only in a new session; check-state\ndoes that.\n\n`stage-end` results: `ok`, `ok_after_retries`, `ok_with_fixups`, `failed`, `skipped`, `stopped`.\nStages: `check_state`, `refine`, `provision`, `content_model`, `design`, `frontend`, `validate`,\n`deploy`, `done` (connect has its own `connect.json`). Split stages use `--phase`: provision `fire` /\n`join`, content model `prepare` / `push`, frontend `shell` / `data`.\n\n**Forgot a `stage-start`?** Log it as soon as you notice, with roughly when the stage began:\n`stage-start frontend --started-ago 6m` (or `--started-at <ISO time>`). The same flags work on a\n`stage-end` that has no start. With neither, the script assumes the stage began when the previous one\nended. Either way the start is marked as estimated in the report. Never log a start and an end\nback to back after the work is done: that records a stage that took no time. If you already did,\ncorrect it with `stage-fix frontend --started-ago 6m`.\n\n**Log an event the moment it happens**, not at the end — details are gone by then:\n\n| Event | Log it when | Fields (required in bold) |\n|---|---|---|\n| `issue` | something fails, misbehaves, or needs a workaround — including your own mistakes | **stage**, **symptom**, cause, workaround, severity (`high`/`medium`/`low`), resolved, user_had_to_intervene |\n| `feedback` | the user corrects you, rejects something, or changes scope | **stage**, **category** (e.g. `design_rejected`, `layout_bug`, `scope_add`, `wrong_assumption`), **paraphrase** |\n| `skill_gap` | a step is dummy, missing, wrong or silent on what you needed | **skill** (path), **gap** |\n| `good` | something worked notably well or fast | **text** |\n\nParaphrase the user; don't paste long quotes, file contents, credentials or personal data. Set\n`user_had_to_intervene` when the user had to point the problem out.\n\n**Tokens** go on `report --tokens '<json>'` only if your tool tells you your own usage directly\n(`{\"source\":\"…\",\"requests\":…,\"input\":…,\"output\":…,\"cache_read\":…,\"cache_write\":…,\"est_cost_usd\":…}`).\nOtherwise leave it out and the report says `{\"source\":\"not reported\"}` (not null: the connector rejects a null `tokens`). Never read session transcripts or log\nfiles to get it.\n\nThe report is saved to `reports/<run>.json` and `.html`, and `report.html` in the workspace is the\nlatest. If a run never reaches its report (the window closed), the next `run-start` closes it as\n`abandoned`, so its log still becomes a report.\n\n**Send every report to Hostinger.** Sending is part of the flow, so don't ask first; tell the user in\none line right after the send (see `09-done`). Right after each\n`report` (follow-ups included), read `reports/<run>.json` and pass its contents, unchanged, as the\narguments of the Hostinger connector tool `hosting_headless_submit-build-report`. Then record the\nresult:\n\n- **Success:** `report-sent <run>`.\n- **422** (the report doesn't match the schema the endpoint holds): `report-rejected <run>`. Don't\n resend it.\n- **Anything else** (network error, 5xx, the tool isn't in your connector): do nothing. The next\n check-state sends it again.\n\nNever edit the JSON before sending, never add account details (the connector's login identifies the\naccount), and never let a failed send block, delay or change the run. Check-state sends any\nreports still unsent (abandoned runs, earlier failures) with `unsent`."
64
64
  },
65
65
  {
66
66
  "uri": "skill://hostinger-headless/connect/SKILL.md",
67
- "digest": "sha256:e1575edbb9e8a40d78ccbf1447cfe0f697c2332bf4a72621fbc4f5b6a190ced5",
68
- "size": 5801,
67
+ "digest": "sha256:401f2593c9f4fc19c2b090e75afdc0f53b96c3524d23df8233df1760d2e60d62",
68
+ "size": 6832,
69
69
  "mimeType": "text/markdown",
70
- "text": "---\nname: hostinger-headless-connect\ndescription: \"Connect step for the Hostinger Headless flow — verify Node, run the bootstrap (reach the Hostinger MCP server + OAuth sign-in), then route to the matching coding-tool skill under tools/ so MCP tools and skills are available before the build steps run. Triggers: connect to Hostinger, set up the Hostinger connector, start a headless build.\"\n---\n\n# Hostinger Headless Skill — connect\n\nYou are a coding agent, and this is a skill for you to execute. Follow the phases below in order.\n\nThis skill goal is to help you to connect to Hostinger via MCP. That can be done via Hostinger Connector or adding the MCP via MCP Server.\n\nWe support different coding Agentic coding tools such as Claude Code, Codex, Cursor, VS Code...and so on. \n\nYour goal is to route those tools to their specific skill inside the tools folder ensuring the tool is fully connected.\n\n\n## Phase 0 — Node (the single one prerequisite)\n\nThe Hostinger MCP server requires Node ≥ 20. Check it:\n\n```bash\nnode -v\n```\n\nIf that errors (Node not installed) or prints a version below 20, install or upgrade Node and re-check — do not try to work around it:\n\n- macOS: `brew install node` (or `nvm install 22 && nvm use 22`)\n- Linux: `nvm install 22 && nvm use 22` (or your distro's Node 20+ package)\n- Windows: `winget install OpenJS.NodeJS.LTS` (or download from nodejs.org)\n\n## Phase 1 — Run the bootstrap (deterministic)\n\nDownload the bootstrap script, then run it. It verifies the MCP server and handles sign-in, emitting one JSON event per line on stdout. Run it as a background/streaming process and relay its events to the user.\n\nThe script is safe and inspectable: it only checks the MCP server via `npx` and drives the OAuth sign-in (a browser flow with a localhost callback) — no other network calls, no filesystem writes. Read it first if your sandbox flags externally-downloaded code.\n\n```bash\n# macOS/Linux:\ncurl -fsSL -O https://raw.githubusercontent.com/hostinger/api-mcp-server/main/skills/headless/connect/bootstrap.mjs\n# Windows PowerShell:\niwr https://raw.githubusercontent.com/hostinger/api-mcp-server/main/skills/headless/connect/bootstrap.mjs -OutFile bootstrap.mjs\n\nnode bootstrap.mjs\n```\n\n### Relay these events\n\nThe script emits one JSON object per line:\n\n| Event | What to do |\n| --- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| `mcp_ok` | MCP server reachable — continue. |\n| `logged_in` | Already authenticated (env token or a previous sign-in) — continue. |\n| `awaiting_user` (`verificationUri`) | A browser window should have opened on the user's machine. Show the URL in plain prose and wait for the user to finish signing in. The OAuth callback lands on a localhost port, so the sign-in must be completed on this same machine. |\n| `success` | Bootstrap done — continue to Phase 2. |\n| `mcp_unreachable` / `login_failed` (with `detail`) | Stop and show the user the `detail`. Do not improvise a parallel setup by hand. |\n\nIf the environment has a `HOSTINGER_API_TOKEN` set (for example CI), the bootstrap reports `logged_in` immediately — no browser needed.\n\n### Sign in or out manually\n\nThe bootstrap runs the sign-in for you. To run it directly, or to switch accounts, use the MCP's own commands. `--login` opens the browser to the Hostinger sign-in page, waits for the localhost callback, stores the credentials and exits. `--logout` revokes the stored credentials, so the next sign-in opens the browser again.\n\n```bash\n# With a global install (npm install -g @hostinger/mcp):\nhostinger-api-mcp --login\nhostinger-api-mcp --logout\n\n# Without a global install:\nnpx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --login\nnpx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --logout\n```\n\nCredentials live in `~/.config/hostinger-mcp/credentials.json` (macOS/Linux) or `%APPDATA%\\hostinger-mcp\\credentials.json` (Windows) and are shared by every Hostinger MCP binary. OAuth works only in stdio mode; `--http` needs `HOSTINGER_API_TOKEN`.\n\n## Phase 2 - Route to the specific tool instructions\n\n| Tool | Route |\n|-------------|---------------------------------|\n| Claude Code | `connect/tools/claude-code.md` |\n| Codex | `connect/tools/codex.md` |\n| Cursor | `connect/tools/cursor.md` |\n| VS Code | `connect/tools/vscode.md` |\n| Antigravity | `connect/tools/antigravity.md` |\n| Devin | `connect/tools/devin.md` |\n\nIf the user's tool isn't listed yet, connect it the generic way (register the Hostinger MCP server\nfor that tool, or drive `npx --yes --package=@hostinger/mcp@latest hostinger-api-mcp` over stdio) and continue.\n\n## Phase 3 - Continue to Headless Hosting steps\n\nAfter evrything is verified and you have access to the Hostinger MCP Server and the skills then you can go back to `headless/SKILL.md` and follow the steps section to start the process.\n\n\n## Reference Sources\n\n- https://mcpservers.org/servers/hostinger/api-mcp-server\n- https://www.hostinger.com/connector\n"
70
+ "text": "---\nname: hostinger-headless-connect\ndescription: \"Connect step for the Hostinger Headless flow — verify Node, run the bootstrap (reach the Hostinger MCP server + OAuth sign-in), then route to the matching coding-tool skill under tools/ so MCP tools and skills are available before the build steps run. Triggers: connect to Hostinger, set up the Hostinger connector, start a headless build.\"\n---\n\n# Hostinger Headless Skill — connect\n\nYou are a coding agent, and this is a skill for you to execute. Follow the phases below in order.\n\nThis skill goal is to help you to connect to Hostinger via MCP. That can be done via Hostinger Connector or adding the MCP via MCP Server.\n\nWe support different coding Agentic coding tools such as Claude Code, Codex, Cursor, VS Code...and so on. \n\nYour goal is to route those tools to their specific skill inside the tools folder ensuring the tool is fully connected.\n\n\n## Phase 0 — Node (the single one prerequisite)\n\nThe Hostinger MCP server requires Node ≥ 20. Check it:\n\n```bash\nnode -v\n```\n\nIf that errors (Node not installed) or prints a version below 20, install or upgrade Node and re-check — do not try to work around it:\n\n- macOS: `brew install node` (or `nvm install 22 && nvm use 22`)\n- Linux: `nvm install 22 && nvm use 22` (or your distro's Node 20+ package)\n- Windows: `winget install OpenJS.NodeJS.LTS` (or download from nodejs.org)\n\n## Phase 1 — Run the bootstrap (deterministic)\n\nDownload the bootstrap script, then run it. It verifies the MCP server and handles sign-in, emitting one JSON event per line on stdout. Run it as a background/streaming process and relay its events to the user.\n\nThe script is safe and inspectable: it only checks the MCP server via `npx` and drives the OAuth sign-in (a browser flow with a localhost callback) — no other network calls, no filesystem writes. Read it first if your sandbox flags externally-downloaded code.\n\n```bash\n# macOS/Linux:\ncurl -fsSL -O https://raw.githubusercontent.com/hostinger/api-mcp-server/main/skills/headless/connect/bootstrap.mjs\n# Windows PowerShell:\niwr https://raw.githubusercontent.com/hostinger/api-mcp-server/main/skills/headless/connect/bootstrap.mjs -OutFile bootstrap.mjs\n\nnode bootstrap.mjs\n```\n\n### Relay these events\n\nThe script emits one JSON object per line:\n\n| Event | What to do |\n| --- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| `mcp_ok` | MCP server reachable — continue. |\n| `logged_in` | Already authenticated (env token or a previous sign-in) — continue. |\n| `awaiting_user` (`verificationUri`) | A browser window should have opened on the user's machine. Show the URL in plain prose and wait for the user to finish signing in. The OAuth callback lands on a localhost port, so the sign-in must be completed on this same machine. |\n| `success` | Bootstrap done — continue to Phase 2. |\n| `mcp_unreachable` / `login_failed` (with `detail`) | Stop and show the user the `detail`. Do not improvise a parallel setup by hand. |\n\nIf the environment has a `HOSTINGER_API_TOKEN` set (for example CI), the bootstrap reports `logged_in` immediately — no browser needed.\n\n### Sign in or out manually\n\nThe bootstrap runs the sign-in for you. To run it directly, or to switch accounts, use the MCP's own commands. `--login` opens the browser to the Hostinger sign-in page, waits for the localhost callback, stores the credentials and exits. `--logout` revokes the stored credentials, so the next sign-in opens the browser again.\n\n```bash\n# With a global install (npm install -g @hostinger/mcp):\nhostinger-api-mcp --login\nhostinger-api-mcp --logout\n\n# Without a global install:\nnpx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --login\nnpx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --logout\n```\n\nCredentials live in `~/.config/hostinger-mcp/credentials.json` (macOS/Linux) or `%APPDATA%\\hostinger-mcp\\credentials.json` (Windows) and are shared by every Hostinger MCP binary. OAuth works only in stdio mode; `--http` needs `HOSTINGER_API_TOKEN`.\n\n## Phase 2 - Route to the specific tool instructions\n\n| Tool | Route |\n|-------------|---------------------------------|\n| Claude Code | `connect/tools/claude-code.md` |\n| Codex | `connect/tools/codex.md` |\n| Cursor | `connect/tools/cursor.md` |\n| VS Code | `connect/tools/vscode.md` |\n| Antigravity | `connect/tools/antigravity.md` |\n| Devin | `connect/tools/devin.md` |\n\nIf the user's tool isn't listed yet, connect it the generic way (register the Hostinger MCP server\nfor that tool, or drive `npx --yes --package=@hostinger/mcp@latest hostinger-api-mcp` over stdio) and continue.\n\n## Phase 3 - Write `connect.json`\n\nOnce the tool is connected, write `~/.hostinger/<domain>/connect.json` (create the folder if it is\nmissing) in exactly this shape:\n\n```json\n{ \"tool\": \"claude-code\", \"mcp\": \"hostinger-api-mcp 3.8.0\", \"auth\": \"oauth_cached\",\n \"order_id\": null, \"plan\": null, \"connected_at\": \"2026-10-28T10:46:12Z\" }\n```\n\n- `tool`: the `<tool>` from the Phase 2 route (`claude-code`, `codex`, `cursor`, `vscode`,\n `antigravity`, `devin`, or a short lowercase name for an unlisted tool), the same value later\n passed to `run-start --tool`.\n- `mcp`: one string, `hostinger-api-mcp <version>`, with the `version` from the `mcp_ok` event.\n Never an object.\n- `auth`: the `method` from the `logged_in` event (`api_token`, `oauth_cached` or `oauth`).\n- `order_id`, `plan`: the active hosting plan's order id and name when you looked them up, else `null`.\n- `connected_at`: the current time in UTC, ISO 8601 to the second.\n\nExtra keys are allowed, but keep these six as above: the build report reads `tool` and `mcp`.\n\n## Phase 4 - Continue to Headless Hosting steps\n\nAfter evrything is verified and you have access to the Hostinger MCP Server and the skills then you can go back to `headless/SKILL.md` and follow the steps section to start the process.\n\n\n## Reference Sources\n\n- https://mcpservers.org/servers/hostinger/api-mcp-server\n- https://www.hostinger.com/connector\n"
71
71
  },
72
72
  {
73
73
  "uri": "skill://hostinger-headless/connect/bootstrap.mjs",
@@ -127,10 +127,10 @@ export default [
127
127
  },
128
128
  {
129
129
  "uri": "skill://hostinger-headless/run-log.mjs",
130
- "digest": "sha256:83ac43a6f414f431276ee3caa53eba68f983730d2b1095e1882acc665a7ba920",
131
- "size": 38936,
130
+ "digest": "sha256:fe0bf3d9723c7e28e5b47670f07aa04a013e31cd818823a2369ec879eb21a302",
131
+ "size": 39641,
132
132
  "mimeType": "text/plain",
133
- "text": "#!/usr/bin/env node\n// Hostinger Headless run log: records a build as it happens and writes reports/<run>.json + .html.\n//\n// node run-log.mjs <domain> run-start [--kind build|resume|redesign|iterate] [--tool <tool>] [--model <model>] [--skill <version>]\n// node run-log.mjs <domain> run-update [--kind …] [--model …] [--skill …]\n// node run-log.mjs <domain> run-resume [--reason \"<text>\"]\n// node run-log.mjs <domain> stage-start <stage> [--phase <phase>] [--started-ago 6m | --started-at <ISO time>]\n// node run-log.mjs <domain> stage-end <stage> <result> [--phase <phase>] [--note \"<text>\"] [--started-ago 6m | --started-at <ISO time>]\n// node run-log.mjs <domain> stage-fix <stage> [--phase <phase>] (--started-ago 6m | --started-at <ISO time>)\n// node run-log.mjs <domain> event <issue|feedback|skill_gap|good> '<json>'\n// node run-log.mjs <domain> report [--outcome live|stopped|failed|abandoned] [--tokens '<json>']\n// node run-log.mjs <domain> unsent\n// node run-log.mjs <domain> report-sent [<run>]\n// node run-log.mjs <domain> report-rejected [<run>]\n\nimport { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst STAGES = ['connect', 'check_state', 'refine', 'provision', 'content_model', 'design', 'frontend', 'validate', 'deploy', 'done'];\nconst RESULTS = ['ok', 'ok_after_retries', 'ok_with_fixups', 'failed', 'skipped', 'stopped'];\nconst OUTCOMES = ['live', 'stopped', 'failed', 'abandoned'];\nconst EVENT_FIELDS = {\n issue: { required: ['stage', 'symptom'], optional: ['cause', 'workaround', 'severity', 'resolved', 'user_had_to_intervene'] },\n feedback: { required: ['stage', 'category', 'paraphrase'], optional: [] },\n skill_gap: { required: ['skill', 'gap'], optional: ['stage'] },\n good: { required: ['text'], optional: ['stage'] },\n};\nconst EVENT_LISTS = { issue: 'issues', feedback: 'user_feedback', skill_gap: 'skill_gaps', good: 'what_went_well' };\n\nconst here = dirname(fileURLToPath(import.meta.url));\nconst now = () => new Date().toISOString().replace(/\\.\\d{3}Z$/, 'Z');\n// A start logged late is kept but marked estimated, so the report can tell measured times from remembered ones.\nfunction lateStart(flags, run) {\n let at = null;\n if (typeof flags['started-at'] === 'string') {\n 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`);\n at = Date.parse(flags['started-at']);\n } else if (typeof flags['started-ago'] === 'string') {\n const m = flags['started-ago'].match(/^(\\d+(?:\\.\\d+)?)\\s*([smh])$/);\n if (!m) fail(`--started-ago \"${flags['started-ago']}\" should look like 90s, 6m or 1h`);\n at = Date.now() - Number(m[1]) * { s: 1e3, m: 6e4, h: 36e5 }[m[2]];\n }\n if (at === null) return null;\n const clamped = Math.min(Math.max(at, Date.parse(run.started_at)), Date.now());\n return new Date(clamped).toISOString().replace(/\\.\\d{3}Z$/, 'Z');\n}\n\nconst fail = (message) => {\n console.error(`run-log: ${message}`);\n process.exit(1);\n};\n\nfunction parseArgs(argv) {\n const positional = [];\n const flags = {};\n for (let i = 0; i < argv.length; i += 1) {\n if (argv[i].startsWith('--')) {\n const key = argv[i].slice(2);\n const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[(i += 1)] : true;\n flags[key] = value;\n } else positional.push(argv[i]);\n }\n return { positional, flags };\n}\n\nconst readJson = (file, fallback) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : fallback);\nconst writeJson = (file, value) => writeFileSync(file, `${JSON.stringify(value, null, 2)}\\n`);\n\nfunction workspace(domain) {\n if (!domain || domain.startsWith('-')) fail('first argument must be the domain');\n const dir = join(homedir(), '.hostinger', domain);\n if (!existsSync(dir)) fail(`no workspace at ${dir}; connect creates it`);\n return {\n dir,\n domain,\n statePath: join(dir, 'state.json'),\n eventsPath: join(dir, 'events.jsonl'),\n reportsDir: join(dir, 'reports'),\n };\n}\n\nconst loadState = (ws) => readJson(ws.statePath, {});\nfunction saveState(ws, state) {\n state.updated_at = now();\n writeJson(ws.statePath, state);\n}\n\nfunction currentRun(ws, state) {\n if (!state.run?.id) fail('no run started; call run-start first (check-state does this)');\n return state.run;\n}\n\nfunction readEvents(ws) {\n if (!existsSync(ws.eventsPath)) return [];\n return readFileSync(ws.eventsPath, 'utf8')\n .split('\\n')\n .filter(Boolean)\n .map((line) => JSON.parse(line));\n}\n\nfunction runStart(ws, flags) {\n const state = loadState(ws);\n // A previous run that never reached its report (closed window, crash) is closed out first, so\n // its log still turns into a report.\n if (state.run?.id && !state.run.reported_at) {\n buildReport(ws, { outcome: 'abandoned' });\n console.log(`closed previous run ${state.run.id} as abandoned`);\n }\n const fresh = loadState(ws);\n const previous = fresh.run ?? {};\n const startedAt = now();\n const connect = readJson(join(ws.dir, 'connect.json'), {});\n // Run ids are the start time; a second run in the same second gets a suffix.\n const taken = new Set([\n ...(fresh.history ?? []).map((h) => h.run),\n ...(existsSync(ws.reportsDir) ? readdirSync(ws.reportsDir).map((f) => f.replace(/\\.(json|html)$/, '')) : []),\n ]);\n const base = startedAt.replace(/:/g, '-');\n let id = base;\n for (let n = 2; taken.has(id) || id === fresh.run?.id; n += 1) id = `${base}-${n}`;\n const kind = flags.kind ?? 'build';\n if (!['build', 'resume', 'redesign', 'iterate'].includes(kind)) fail(`unknown kind \"${kind}\"; one of build, resume, redesign, iterate`);\n fresh.run = {\n id,\n kind,\n started_at: startedAt,\n tool: flags.tool ?? connect.tool ?? fresh.tool ?? previous.tool ?? null,\n model: flags.model ?? previous.model ?? null,\n skill: flags.skill ?? previous.skill ?? null,\n };\n fresh.history ??= [];\n saveState(ws, fresh);\n console.log(`run ${fresh.run.id} started`);\n}\n\nfunction runUpdate(ws, flags) {\n const state = loadState(ws);\n const run = currentRun(ws, state);\n for (const key of ['kind', 'tool', 'model', 'skill']) if (typeof flags[key] === 'string') run[key] = flags[key];\n if (!['build', 'resume', 'redesign', 'iterate'].includes(run.kind)) fail(`unknown kind \"${run.kind}\"; one of build, resume, redesign, iterate`);\n saveState(ws, state);\n console.log(`run ${run.id} updated`);\n}\n\n// A follow-up in the same session (the user asks for a fix right after the final report) belongs\n// to the run it follows: reopen it, so the next `report` rewrites that run's report instead of\n// starting a second one.\nfunction runResume(ws, flags) {\n const state = loadState(ws);\n const run = currentRun(ws, state);\n if (!run.reported_at) {\n console.log(`run ${run.id} is still open; nothing to resume`);\n return;\n }\n run.follow_ups = [\n ...(run.follow_ups ?? []),\n { resumed_at: now(), after_report_at: run.reported_at, reason: typeof flags.reason === 'string' ? flags.reason : null },\n ];\n delete run.reported_at;\n saveState(ws, state);\n console.log(`run ${run.id} resumed (follow-up ${run.follow_ups.length})`);\n}\n\nfunction stageStart(ws, [stage], flags) {\n if (!STAGES.includes(stage)) fail(`unknown stage \"${stage}\"; one of ${STAGES.join(', ')}`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n state.history ??= [];\n const phase = flags.phase ?? null;\n const open = state.history.find((h) => h.run === run.id && h.stage === stage && h.phase === phase && !h.ended_at);\n if (open) {\n console.log(`${stage}${phase ? `/${phase}` : ''} already started at ${open.started_at}`);\n return;\n }\n const late = lateStart(flags, run);\n state.history.push({ run: run.id, stage, phase, started_at: late ?? now(), ended_at: null, result: null, note: null, ...(late ? { start_estimated: true } : {}) });\n saveState(ws, state);\n console.log(`${stage}${phase ? `/${phase}` : ''} started${late ? ` (estimated start ${late})` : ''}`);\n}\n\nfunction stageEnd(ws, [stage, result], flags) {\n if (!STAGES.includes(stage)) fail(`unknown stage \"${stage}\"`);\n if (!RESULTS.includes(result)) fail(`unknown result \"${result}\"; one of ${RESULTS.join(', ')}`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n state.history ??= [];\n const phase = flags.phase ?? null;\n let entry = [...state.history].reverse().find((h) => h.run === run.id && h.stage === stage && h.phase === phase && !h.ended_at);\n if (!entry) {\n // Missed stage-start: use the remembered start if given, else the end of the previous stage; either way estimated.\n const runEntries = state.history.filter((h) => h.run === run.id);\n const lastEnd = runEntries.map((h) => h.ended_at).filter(Boolean).sort().at(-1);\n const late = lateStart(flags, run);\n entry = { run: run.id, stage, phase, started_at: late ?? lastEnd ?? run.started_at, ended_at: null, result: null, note: null, start_estimated: true };\n state.history.push(entry);\n 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'}`);\n } else if (flags['started-at'] || flags['started-ago']) {\n entry.started_at = lateStart(flags, run);\n entry.start_estimated = true;\n }\n entry.ended_at = now();\n entry.result = result;\n if (typeof flags.note === 'string') entry.note = flags.note;\n saveState(ws, state);\n console.log(`${stage}${phase ? `/${phase}` : ''} ended: ${result}`);\n}\n\nfunction stageFix(ws, [stage], flags) {\n if (!STAGES.includes(stage)) fail(`unknown stage \"${stage}\"`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n const phase = flags.phase ?? null;\n const entry = [...(state.history ?? [])].reverse().find((h) => h.run === run.id && h.stage === stage && h.phase === phase);\n if (!entry) fail(`${stage}${phase ? `/${phase}` : ''} was never logged in this run; use stage-start with --started-ago instead`);\n const late = lateStart(flags, run);\n if (!late) fail('stage-fix needs --started-ago (e.g. 6m) or --started-at <ISO time>');\n if (entry.ended_at && late > entry.ended_at) fail(`the new start ${late} is after the stage ended (${entry.ended_at})`);\n entry.started_at = late;\n entry.start_estimated = true;\n saveState(ws, state);\n console.log(`${stage}${phase ? `/${phase}` : ''} start set to ${late} (estimated)`);\n}\n\nfunction event(ws, [type, raw]) {\n const spec = EVENT_FIELDS[type];\n if (!spec) fail(`unknown event type \"${type}\"; one of ${Object.keys(EVENT_FIELDS).join(', ')}`);\n let data;\n try {\n data = JSON.parse(raw ?? '');\n } catch {\n fail(`event ${type} needs a JSON object, e.g. '${JSON.stringify(Object.fromEntries(spec.required.map((k) => [k, '…'])))}'`);\n }\n const missing = spec.required.filter((k) => data[k] === undefined || data[k] === '');\n if (missing.length) fail(`event ${type} is missing ${missing.join(', ')}`);\n const unknown = Object.keys(data).filter((k) => !spec.required.includes(k) && !spec.optional.includes(k));\n if (unknown.length) fail(`event ${type} has unknown fields ${unknown.join(', ')}`);\n if (type === 'issue') {\n data.severity ??= 'medium';\n if (!['high', 'medium', 'low'].includes(data.severity)) fail('issue severity must be high, medium or low');\n data.resolved ??= false;\n data.user_had_to_intervene ??= false;\n }\n // A bad line in events.jsonl would fail every later report and run-start.\n validate({ ...data, logged_at: now() }, eventSchema(type), `event ${type}`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n appendFileSync(ws.eventsPath, `${JSON.stringify({ ts: now(), run: run.id, type, ...data })}\\n`);\n console.log(`${type} logged`);\n}\n\nconst minutesBetween = (a, b) => (a && b ? Math.round(((Date.parse(b) - Date.parse(a)) / 60000) * 10) / 10 : null);\n\nfunction frontendStack(dir) {\n // Framework is recorded in state.json by the frontend step.\n const state = readJson(join(dir, 'state.json'), {});\n const fe = state.frontend ?? {};\n const framework = fe.framework ?? fe.stack ?? null;\n if (framework) return fe.rendering ? `${framework} (${fe.rendering})` : framework;\n // Fallback: an older build may have a design.md with a \"## Stack\" heading.\n const design = existsSync(join(dir, 'design.md')) ? readFileSync(join(dir, 'design.md'), 'utf8') : '';\n return design.match(/## Stack\\s*\\n\\s*\\*\\*([^*]+)\\*\\*/)?.[1] ?? null;\n}\n\nfunction buildReport(ws, { outcome, tokens } = {}) {\n const state = loadState(ws);\n const run = currentRun(ws, state);\n const site = readJson(join(ws.dir, 'site.json'), {});\n const deploy = readJson(join(ws.dir, 'deploy.json'), {});\n const connect = readJson(join(ws.dir, 'connect.json'), {});\n const history = (state.history ?? []).filter((h) => h.run === run.id);\n // Lines logged before events were checked on write may not match the schema.\n const dropped = [];\n const events = readEvents(ws)\n .filter((e) => e.run === run.id)\n .filter((e) => {\n const { ts, run: _run, type, ...rest } = e;\n const problem = schemaError({ ...rest, logged_at: ts }, eventSchema(type), 'event');\n if (problem) dropped.push({ stage: typeof e.stage === 'string' ? e.stage : 'unknown', phase: null, problem: `left out a malformed ${type} event (${problem})` });\n return !problem;\n });\n const resolvedOutcome = outcome ?? (history.some((h) => h.stage === 'deploy' && h.result?.startsWith('ok')) ? 'live' : 'stopped');\n // A run ends at its last logged activity, so re-running `report` later (after fixing warnings, or\n // when the next run-start closes an abandoned run) doesn't stretch it to \"now\". Only a stage still\n // open on a live run keeps the clock running.\n const lastActivity = [run.started_at, ...history.flatMap((h) => [h.started_at, h.ended_at]), ...events.map((e) => e.ts)]\n .filter(Boolean)\n .sort()\n .at(-1);\n const stillOpen = resolvedOutcome !== 'abandoned' && history.some((h) => !h.ended_at);\n const endedAt = stillOpen ? now() : lastActivity;\n const stages = history.map((h) => ({ ...h, minutes: minutesBetween(h.started_at, h.ended_at ?? endedAt) }));\n const gaps = [...loggingGaps(stages, run, resolvedOutcome), ...dropped];\n const strip = ({ ts, run: _run, type: _type, ...rest }) => ({ ...rest, logged_at: ts });\n const liveStage = stages.find((s) => s.stage === 'deploy' && s.result?.startsWith('ok'));\n if (!OUTCOMES.includes(resolvedOutcome)) fail(`unknown outcome \"${resolvedOutcome}\"; one of ${OUTCOMES.join(', ')}`);\n\n const previous = existsSync(ws.reportsDir)\n ? readdirSync(ws.reportsDir)\n .filter((f) => f.endsWith('.json') && f !== `${run.id}.json`)\n .map((f) => readJson(join(ws.reportsDir, f), {}))\n .sort((a, b) => `${a.timing?.started_at}${a.run?.id}`.localeCompare(`${b.timing?.started_at}${b.run?.id}`))\n .map((r) => ({ run: r.run?.id, kind: r.run?.kind, outcome: r.info?.outcome, minutes: r.timing?.minutes_total, html: `${r.run?.id}.html` }))\n : [];\n\n const report = {\n schema_version: '1',\n generated_at: now(),\n // Intent, not delivery: delivery is tracked in state.json.\n consent: { sent: true, note: 'Sent to Hostinger automatically at the end of the run.' },\n run: { id: run.id, kind: run.kind, skill: run.skill, follow_ups: run.follow_ups ?? [] },\n info: {\n domain: ws.domain,\n tool: run.tool ?? connect.tool ?? null,\n model: run.model ?? null,\n os: process.platform,\n node: process.version,\n mcp: connect.mcp ?? null,\n backend: site.backend ?? null,\n frontend: frontendStack(ws.dir),\n outcome: resolvedOutcome,\n stopped_at: resolvedOutcome === 'live' ? null : stoppedAt(stages, state),\n live_url: resolvedOutcome === 'live' ? deploy.urls?.site ?? site.urls?.site ?? null : null,\n admin_url: site.urls?.wp_admin ?? null,\n },\n timing: {\n started_at: run.started_at,\n ended_at: endedAt,\n live_at: liveStage?.ended_at ?? null,\n minutes_total: minutesBetween(run.started_at, endedAt),\n minutes_to_live: liveStage ? minutesBetween(run.started_at, liveStage.ended_at) : null,\n stages: stages.map(({ run: _run, ...s }) => s),\n },\n // Not null: the MCP layer treats a null required field as missing, and the API reads {} as an array.\n tokens: tokens ?? { source: 'not reported' },\n issues: events.filter((e) => e.type === 'issue').map(strip),\n user_feedback: events.filter((e) => e.type === 'feedback').map(strip),\n skill_gaps: events.filter((e) => e.type === 'skill_gap').map(strip),\n what_went_well: events.filter((e) => e.type === 'good').map(strip),\n logging_gaps: gaps,\n previous_runs: previous,\n };\n\n validate(report, reportSchema());\n mkdirSync(ws.reportsDir, { recursive: true });\n writeJson(join(ws.reportsDir, `${run.id}.json`), report);\n writeFileSync(join(ws.reportsDir, `${run.id}.html`), renderHtml(report));\n copyFileSync(join(ws.reportsDir, `${run.id}.html`), join(ws.dir, 'report.html'));\n\n const fresh = loadState(ws);\n fresh.run.reported_at = now();\n saveState(ws, fresh);\n for (const g of gaps) console.log(`warning: ${g.stage}${g.phase ? `/${g.phase}` : ''}: ${g.problem}`);\n return join(ws.dir, 'report.html');\n}\n\n// A report counts as sent only while the sent copy's generated_at matches the file.\nfunction reportFile(ws, runId) {\n const id = runId ?? currentRun(ws, loadState(ws)).id;\n const file = join(ws.reportsDir, `${id}.json`);\n if (!existsSync(file)) fail(`no report for run ${id} at ${file}`);\n return { id, file, generatedAt: readJson(file, {}).generated_at };\n}\n\nfunction unsent(ws) {\n const state = loadState(ws);\n const files = existsSync(ws.reportsDir) ? readdirSync(ws.reportsDir).filter((f) => f.endsWith('.json')).sort() : [];\n // Older reports promised \"nothing is uploaded\".\n const pending = files\n .map((f) => {\n const report = readJson(join(ws.reportsDir, f), {});\n return { id: f.replace(/\\.json$/, ''), file: join(ws.reportsDir, f), generatedAt: report.generated_at, consent: report.consent };\n })\n .filter(({ consent }) => consent?.sent !== false)\n .filter(({ id, generatedAt }) => state.reports_sent?.[id] !== generatedAt && state.reports_rejected?.[id] !== generatedAt);\n if (!pending.length) console.log('no unsent reports');\n for (const { id, file } of pending) console.log(`${id} ${file}`);\n}\n\nfunction reportSent(ws, [runId]) {\n const { id, generatedAt } = reportFile(ws, runId);\n const state = loadState(ws);\n state.reports_sent = { ...state.reports_sent, [id]: generatedAt };\n saveState(ws, state);\n console.log(`report ${id} marked sent`);\n}\n\n// A 422 is schema drift, so resending won't help.\nfunction reportRejected(ws, [runId]) {\n const { id, generatedAt } = reportFile(ws, runId);\n const state = loadState(ws);\n state.reports_rejected = { ...state.reports_rejected, [id]: generatedAt };\n saveState(ws, state);\n const run = currentRun(ws, state);\n const symptom = `build report ${id} rejected with 422 by hosting_headless_submit-build-report`;\n 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`);\n console.log(`report ${id} marked rejected; issue logged in run ${run.id}`);\n}\n\n// Gaps are printed as warnings by `report`, so guesses never pass as measurements.\nconst EXPECTED_FOR_BUILD = ['check_state', 'refine', 'provision', 'content_model', 'design', 'frontend', 'validate', 'deploy'];\nfunction loggingGaps(stages, run, outcome) {\n const gaps = [];\n for (const s of stages) {\n if (!s.ended_at) gaps.push({ stage: s.stage, phase: s.phase, problem: 'started but never ended' });\n else if (s.start_estimated) gaps.push({ stage: s.stage, phase: s.phase, problem: 'start time estimated, not measured' });\n 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' });\n }\n if (run.kind === 'build' && outcome === 'live') {\n for (const stage of EXPECTED_FOR_BUILD) {\n if (!stages.some((s) => s.stage === stage)) gaps.push({ stage, phase: null, problem: 'never logged, though the site went live' });\n }\n }\n return gaps;\n}\n\nfunction stoppedAt(stages, state) {\n const stuck = [...stages].reverse().find((s) => !s.ended_at || s.result === 'failed' || s.result === 'stopped');\n return stuck?.stage ?? state.stage ?? null;\n}\n\n// Minimal JSON Schema subset, so reports from different tools share one shape without a dependency.\nconst reportSchema = () => readJson(join(here, 'report.schema.json'), null);\nconst eventSchema = (type) => reportSchema()?.properties?.[EVENT_LISTS[type]]?.items ?? null;\n\nfunction schemaError(value, schema, path = 'report') {\n if (!schema) return null;\n const types = [].concat(schema.type ?? []);\n const actual = value === null ? 'null' : Array.isArray(value) ? 'array' : Number.isInteger(value) ? 'integer' : typeof value;\n if (types.length && !types.includes(actual) && !(actual === 'integer' && types.includes('number'))) {\n return `${path} should be ${types.join(' or ')}, got ${actual}`;\n }\n if (schema.enum && !schema.enum.includes(value)) return `${path} should be one of ${schema.enum.join(', ')}`;\n if (actual === 'object') {\n for (const key of schema.required ?? []) if (!(key in value)) return `${path}.${key} is missing`;\n for (const [key, sub] of Object.entries(schema.properties ?? {})) {\n const error = key in value ? schemaError(value[key], sub, `${path}.${key}`) : null;\n if (error) return error;\n }\n }\n if (actual === 'array' && schema.items) {\n for (const [i, item] of value.entries()) {\n const error = schemaError(item, schema.items, `${path}[${i}]`);\n if (error) return error;\n }\n }\n return null;\n}\n\nfunction validate(value, schema, path) {\n const error = schemaError(value, schema, path);\n if (error) fail(error);\n}\n\nconst esc = (v) => String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\nconst label = (s) => String(s ?? '').replace(/_/g, ' ');\nconst time = (iso) => (iso ? iso.slice(11, 19) : '—');\nconst fmt = (n) => (typeof n === 'number' ? n.toLocaleString('en-US') : '—');\n\nfunction gantt(report) {\n const stages = report.timing.stages;\n if (!stages.length) return '<p class=\"muted\">No stages were logged for this run.</p>';\n const t0 = Date.parse(report.timing.started_at);\n const at = (iso) => (Date.parse(iso) - t0) / 60000;\n const total = Math.max(report.timing.minutes_total ?? 1, ...stages.map((s) => at(s.ended_at ?? report.timing.ended_at)), 1);\n const step = [1, 2, 5, 10, 15, 30, 60, 120, 240].find((s) => total / s <= 8) ?? 480;\n const span = Math.ceil(total / step) * step;\n const W = 760;\n const LABEL = 160;\n const ROW = 32;\n const TOP = 32;\n const H = TOP + ROW * stages.length + 32;\n const x = (m) => LABEL + ((W - LABEL - 40) * m) / span;\n 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>`];\n for (let m = 0; m <= span; m += step) {\n 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>`);\n }\n for (const f of report.run.follow_ups ?? []) {\n const fx = x(at(f.resumed_at)).toFixed(1);\n 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>`);\n }\n if (report.timing.live_at) {\n const lx = x(at(report.timing.live_at)).toFixed(1);\n 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>`);\n }\n stages.forEach((s, i) => {\n const y = TOP + i * ROW;\n const a = at(s.started_at);\n const b = at(s.ended_at ?? report.timing.ended_at);\n const width = Math.max(x(b) - x(a), 4);\n const name = `${label(s.stage)}${s.phase ? ` · ${s.phase}` : ''}`;\n const tip = [name, `${s.minutes} min`, s.result ? label(s.result) : 'not finished', s.start_estimated ? 'start estimated' : null, s.note].filter(Boolean).join(' · ');\n const failed = s.result === 'failed' || s.result === 'stopped' || !s.ended_at;\n const fill = failed ? 'url(#hatch)' : s.start_estimated ? 'none' : 'var(--series-1)';\n out.push(`<text x=\"${LABEL - 12}\" y=\"${(y + ROW / 2 + 4).toFixed(1)}\" class=\"row-label\" text-anchor=\"end\">${esc(name)}</text>`);\n 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>`);\n });\n 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>');\n return out.join('');\n}\n\nfunction renderHtml(r) {\n 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>`;\n const stepped = r.issues.filter((i) => i.user_had_to_intervene).length;\n const rejected = r.user_feedback.filter((f) => f.category === 'design_rejected').length;\n const t = r.tokens && ['input', 'output', 'cache_read', 'cache_write', 'est_cost_usd'].some((k) => r.tokens[k] != null) ? r.tokens : null;\n const tiles = [\n r.timing.minutes_to_live != null\n ? tile('Time to live', `${r.timing.minutes_to_live} min`, 'start of run → deploy finished')\n : tile('Outcome', label(r.info.outcome), r.info.stopped_at ? `stopped at ${label(r.info.stopped_at)}` : ''),\n tile('Run length', `${r.timing.minutes_total} min`, `${r.timing.stages.length} stage entries`),\n tile('Issues', String(r.issues.length), `${stepped} needed the user to step in`),\n tile('User feedback', String(r.user_feedback.length), rejected ? `${rejected} design rejections` : 'corrections and requests'),\n 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'),\n ].join('');\n const SEV = { high: ['●', 'critical', 'High'], medium: ['▲', 'serious', 'Medium'], low: ['■', 'warning', 'Low'] };\n const order = ['high', 'medium', 'low'];\n const issues = [...r.issues]\n .sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity))\n .map((i) => {\n const [icon, cls, name] = SEV[i.severity];\n 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>`;\n })\n .join('');\n const list = (items, render, empty) => (items.length ? `<ul>${items.map(render).join('')}</ul>` : `<p class=\"muted\">${empty}</p>`);\n const stageRows = r.timing.stages\n .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>`)\n .join('');\n const details = [\n ['Domain', r.info.domain],\n ['Outcome', [label(r.info.outcome), r.info.live_url].filter(Boolean).join(' · ')],\n ['Run', `${r.run.id} · ${r.run.kind}`],\n ['Tool / model', [r.info.tool, r.info.model].filter(Boolean).join(' · ') || '—'],\n ['Stack', [r.info.backend, r.info.frontend].filter(Boolean).join(' + ') || '—'],\n ['Environment', `${r.info.os} · Node ${r.info.node}${r.info.mcp ? ` · ${r.info.mcp}` : ''}`],\n ['Skill version', r.run.skill ?? '—'],\n ...(r.run.follow_ups?.length\n ? [['Follow-ups', r.run.follow_ups.map((f) => `resumed ${time(f.resumed_at)}${f.reason ? ` — ${f.reason}` : ''}`).join('; ')]]\n : []),\n ]\n .map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`)\n .join('');\n const tokenTable = t\n ? `<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>`\n : '';\n const previous = r.previous_runs.length\n ? `<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>`\n : '';\n\n return `<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Build report — ${esc(r.info.domain)}</title>\n<style>\n.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; }\n@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; } }\n* { box-sizing: border-box; }\nbody { margin:0; background:var(--surface-0); color:var(--text-primary); font:15px/1.5 system-ui,-apple-system,\"Segoe UI\",sans-serif; }\nmain { max-width:1080px; margin:0 auto; padding:32px 24px 64px; }\nh1 { font-size:28px; line-height:1.2; margin:0 0 4px; } h2 { font-size:18px; margin:0 0 16px; }\na { color:var(--series-1); }\n.lede { color:var(--text-secondary); margin:0 0 24px; }\nsection { background:var(--surface-1); border:1px solid var(--border); border-radius:12px; padding:24px; margin-bottom:24px; }\n.tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:16px; margin-bottom:24px; }\n.tile { background:var(--surface-1); border:1px solid var(--border); border-radius:12px; padding:16px; }\n.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; }\ndl { 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; }\n.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; }\n.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; }\n.bar { outline:none; } .bar:hover rect:last-child, .bar:focus rect:last-child { stroke:var(--text-primary); stroke-width:2; }\n.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; }\n.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); }\n#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; }\ntable { 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; }\ntd.num,th.num { text-align:right; font-variant-numeric:tabular-nums; } .table-wrap { overflow-x:auto; }\n.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); }\n.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; }\n.cols { display:grid; grid-template-columns:repeat(auto-fit,minmax(320px,1fr)); gap:24px; margin-bottom:24px; } .cols section { margin:0; }\nul { margin:0; padding-left:20px; } li { margin-bottom:8px; } .muted { color:var(--text-muted); font-size:13px; }\ncode { font-size:13px; background:var(--surface-0); padding:1px 4px; border-radius:4px; }\ndetails summary { cursor:pointer; color:var(--text-secondary); margin-top:16px; }\n</style></head><body class=\"viz-root\"><main>\n<h1>Build report — ${esc(r.info.domain)}</h1>\n<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>\n<div class=\"tiles\">${tiles}</div>\n<section><h2>Stage timeline</h2>${gantt(r)}\n<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>\n<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>\n${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>` : ''}\n<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>\n<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>\n<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>\n<div class=\"cols\"><section><h2>What went well</h2>${list(r.what_went_well, (g) => `<li>${esc(g.text)}</li>`, 'Nothing logged.')}</section>\n<section><h2>Run details</h2><dl>${details}</dl></section></div>\n${tokenTable}${previous}\n</main><div id=\"tip\" role=\"status\"></div>\n<script>\nconst tip = document.getElementById('tip');\nconst 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; };\ndocument.querySelectorAll('.bar').forEach((el) => {\n el.addEventListener('mousemove', (e) => show(el, e.clientX, e.clientY));\n el.addEventListener('mouseleave', () => (tip.style.opacity = 0));\n el.addEventListener('focus', () => { const r = el.getBoundingClientRect(); show(el, r.left + r.width / 2, r.top); });\n el.addEventListener('blur', () => (tip.style.opacity = 0));\n});\n</script></body></html>\n`;\n}\n\nconst { positional, flags } = parseArgs(process.argv.slice(2));\nconst [domain, command, ...rest] = positional;\nconst ws = workspace(domain);\nswitch (command) {\n case 'run-start':\n runStart(ws, flags);\n break;\n case 'run-update':\n runUpdate(ws, flags);\n break;\n case 'run-resume':\n runResume(ws, flags);\n break;\n case 'stage-start':\n stageStart(ws, rest, flags);\n break;\n case 'stage-end':\n stageEnd(ws, rest, flags);\n break;\n case 'stage-fix':\n stageFix(ws, rest, flags);\n break;\n case 'event':\n event(ws, rest);\n break;\n case 'report': {\n let tokens;\n if (typeof flags.tokens === 'string') {\n try {\n tokens = JSON.parse(flags.tokens);\n } catch {\n fail('--tokens must be JSON, e.g. \\'{\"input\":0,\"output\":0,\"source\":\"…\"}\\'');\n }\n // null and {} can't be sent; treat them as not reported.\n if (tokens === null || (typeof tokens === 'object' && !Array.isArray(tokens) && !Object.keys(tokens).length)) tokens = undefined;\n else validate(tokens, { ...reportSchema()?.properties?.tokens, type: 'object' }, '--tokens');\n }\n console.log(`report written: ${buildReport(ws, { outcome: flags.outcome, tokens })}`);\n const { id, file } = reportFile(ws);\n 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)`);\n break;\n }\n case 'unsent':\n unsent(ws);\n break;\n case 'report-sent':\n reportSent(ws, rest);\n break;\n case 'report-rejected':\n reportRejected(ws, rest);\n break;\n default:\n fail('command must be run-start, run-update, run-resume, stage-start, stage-end, stage-fix, event, report, unsent, report-sent or report-rejected');\n}\n"
133
+ "text": "#!/usr/bin/env node\n// Hostinger Headless run log: records a build as it happens and writes reports/<run>.json + .html.\n//\n// node run-log.mjs <domain> run-start [--kind build|resume|redesign|iterate] [--tool <tool>] [--model <model>] [--skill <version>]\n// node run-log.mjs <domain> run-update [--kind …] [--model …] [--skill …]\n// node run-log.mjs <domain> run-resume [--reason \"<text>\"]\n// node run-log.mjs <domain> stage-start <stage> [--phase <phase>] [--started-ago 6m | --started-at <ISO time>]\n// node run-log.mjs <domain> stage-end <stage> <result> [--phase <phase>] [--note \"<text>\"] [--started-ago 6m | --started-at <ISO time>]\n// node run-log.mjs <domain> stage-fix <stage> [--phase <phase>] (--started-ago 6m | --started-at <ISO time>)\n// node run-log.mjs <domain> event <issue|feedback|skill_gap|good> '<json>'\n// node run-log.mjs <domain> report [--outcome live|stopped|failed|abandoned] [--tokens '<json>']\n// node run-log.mjs <domain> unsent\n// node run-log.mjs <domain> report-sent [<run>]\n// node run-log.mjs <domain> report-rejected [<run>]\n\nimport { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst STAGES = ['connect', 'check_state', 'refine', 'provision', 'content_model', 'design', 'frontend', 'validate', 'deploy', 'done'];\nconst RESULTS = ['ok', 'ok_after_retries', 'ok_with_fixups', 'failed', 'skipped', 'stopped'];\nconst OUTCOMES = ['live', 'stopped', 'failed', 'abandoned'];\nconst EVENT_FIELDS = {\n issue: { required: ['stage', 'symptom'], optional: ['cause', 'workaround', 'severity', 'resolved', 'user_had_to_intervene'] },\n feedback: { required: ['stage', 'category', 'paraphrase'], optional: [] },\n skill_gap: { required: ['skill', 'gap'], optional: ['stage'] },\n good: { required: ['text'], optional: ['stage'] },\n};\nconst EVENT_LISTS = { issue: 'issues', feedback: 'user_feedback', skill_gap: 'skill_gaps', good: 'what_went_well' };\n\nconst here = dirname(fileURLToPath(import.meta.url));\nconst now = () => new Date().toISOString().replace(/\\.\\d{3}Z$/, 'Z');\n// A start logged late is kept but marked estimated, so the report can tell measured times from remembered ones.\nfunction lateStart(flags, run) {\n let at = null;\n if (typeof flags['started-at'] === 'string') {\n 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`);\n at = Date.parse(flags['started-at']);\n } else if (typeof flags['started-ago'] === 'string') {\n const m = flags['started-ago'].match(/^(\\d+(?:\\.\\d+)?)\\s*([smh])$/);\n if (!m) fail(`--started-ago \"${flags['started-ago']}\" should look like 90s, 6m or 1h`);\n at = Date.now() - Number(m[1]) * { s: 1e3, m: 6e4, h: 36e5 }[m[2]];\n }\n if (at === null) return null;\n const clamped = Math.min(Math.max(at, Date.parse(run.started_at)), Date.now());\n return new Date(clamped).toISOString().replace(/\\.\\d{3}Z$/, 'Z');\n}\n\nconst fail = (message) => {\n console.error(`run-log: ${message}`);\n process.exit(1);\n};\n\nfunction parseArgs(argv) {\n const positional = [];\n const flags = {};\n for (let i = 0; i < argv.length; i += 1) {\n if (argv[i].startsWith('--')) {\n const key = argv[i].slice(2);\n const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[(i += 1)] : true;\n flags[key] = value;\n } else positional.push(argv[i]);\n }\n return { positional, flags };\n}\n\nconst readJson = (file, fallback) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : fallback);\nconst writeJson = (file, value) => writeFileSync(file, `${JSON.stringify(value, null, 2)}\\n`);\n\nfunction workspace(domain) {\n if (!domain || domain.startsWith('-')) fail('first argument must be the domain');\n const dir = join(homedir(), '.hostinger', domain);\n if (!existsSync(dir)) fail(`no workspace at ${dir}; connect creates it`);\n return {\n dir,\n domain,\n statePath: join(dir, 'state.json'),\n eventsPath: join(dir, 'events.jsonl'),\n reportsDir: join(dir, 'reports'),\n };\n}\n\nconst loadState = (ws) => readJson(ws.statePath, {});\nfunction saveState(ws, state) {\n state.updated_at = now();\n writeJson(ws.statePath, state);\n}\n\nfunction currentRun(ws, state) {\n if (!state.run?.id) fail('no run started; call run-start first (check-state does this)');\n return state.run;\n}\n\nfunction readEvents(ws) {\n if (!existsSync(ws.eventsPath)) return [];\n return readFileSync(ws.eventsPath, 'utf8')\n .split('\\n')\n .filter(Boolean)\n .map((line) => JSON.parse(line));\n}\n\n// connect.json from older runs may hold mcp as an object or only mcp_version; the report needs strings.\nfunction readConnect(ws) {\n const connect = readJson(join(ws.dir, 'connect.json'), {});\n const mcp = connect.mcp ?? connect.mcp_version ?? null;\n let version = mcp;\n if (mcp !== null && typeof mcp !== 'string') version = mcp.name || mcp.version ? [mcp.name, mcp.version].filter(Boolean).join(' ') : JSON.stringify(mcp);\n return { tool: typeof connect.tool === 'string' ? connect.tool : null, mcp: version };\n}\n\nfunction runStart(ws, flags) {\n const state = loadState(ws);\n // A previous run that never reached its report (closed window, crash) is closed out first, so\n // its log still turns into a report.\n if (state.run?.id && !state.run.reported_at) {\n buildReport(ws, { outcome: 'abandoned' });\n console.log(`closed previous run ${state.run.id} as abandoned`);\n }\n const fresh = loadState(ws);\n const previous = fresh.run ?? {};\n const startedAt = now();\n const connect = readConnect(ws);\n // Run ids are the start time; a second run in the same second gets a suffix.\n const taken = new Set([\n ...(fresh.history ?? []).map((h) => h.run),\n ...(existsSync(ws.reportsDir) ? readdirSync(ws.reportsDir).map((f) => f.replace(/\\.(json|html)$/, '')) : []),\n ]);\n const base = startedAt.replace(/:/g, '-');\n let id = base;\n for (let n = 2; taken.has(id) || id === fresh.run?.id; n += 1) id = `${base}-${n}`;\n const kind = flags.kind ?? 'build';\n if (!['build', 'resume', 'redesign', 'iterate'].includes(kind)) fail(`unknown kind \"${kind}\"; one of build, resume, redesign, iterate`);\n fresh.run = {\n id,\n kind,\n started_at: startedAt,\n tool: flags.tool ?? connect.tool ?? fresh.tool ?? previous.tool ?? null,\n model: flags.model ?? previous.model ?? null,\n skill: flags.skill ?? previous.skill ?? null,\n };\n fresh.history ??= [];\n saveState(ws, fresh);\n console.log(`run ${fresh.run.id} started`);\n}\n\nfunction runUpdate(ws, flags) {\n const state = loadState(ws);\n const run = currentRun(ws, state);\n for (const key of ['kind', 'tool', 'model', 'skill']) if (typeof flags[key] === 'string') run[key] = flags[key];\n if (!['build', 'resume', 'redesign', 'iterate'].includes(run.kind)) fail(`unknown kind \"${run.kind}\"; one of build, resume, redesign, iterate`);\n saveState(ws, state);\n console.log(`run ${run.id} updated`);\n}\n\n// A follow-up in the same session (the user asks for a fix right after the final report) belongs\n// to the run it follows: reopen it, so the next `report` rewrites that run's report instead of\n// starting a second one.\nfunction runResume(ws, flags) {\n const state = loadState(ws);\n const run = currentRun(ws, state);\n if (!run.reported_at) {\n console.log(`run ${run.id} is still open; nothing to resume`);\n return;\n }\n run.follow_ups = [\n ...(run.follow_ups ?? []),\n { resumed_at: now(), after_report_at: run.reported_at, reason: typeof flags.reason === 'string' ? flags.reason : null },\n ];\n delete run.reported_at;\n saveState(ws, state);\n console.log(`run ${run.id} resumed (follow-up ${run.follow_ups.length})`);\n}\n\nfunction stageStart(ws, [stage], flags) {\n if (!STAGES.includes(stage)) fail(`unknown stage \"${stage}\"; one of ${STAGES.join(', ')}`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n state.history ??= [];\n const phase = flags.phase ?? null;\n const open = state.history.find((h) => h.run === run.id && h.stage === stage && h.phase === phase && !h.ended_at);\n if (open) {\n console.log(`${stage}${phase ? `/${phase}` : ''} already started at ${open.started_at}`);\n return;\n }\n const late = lateStart(flags, run);\n state.history.push({ run: run.id, stage, phase, started_at: late ?? now(), ended_at: null, result: null, note: null, ...(late ? { start_estimated: true } : {}) });\n saveState(ws, state);\n console.log(`${stage}${phase ? `/${phase}` : ''} started${late ? ` (estimated start ${late})` : ''}`);\n}\n\nfunction stageEnd(ws, [stage, result], flags) {\n if (!STAGES.includes(stage)) fail(`unknown stage \"${stage}\"`);\n if (!RESULTS.includes(result)) fail(`unknown result \"${result}\"; one of ${RESULTS.join(', ')}`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n state.history ??= [];\n const phase = flags.phase ?? null;\n let entry = [...state.history].reverse().find((h) => h.run === run.id && h.stage === stage && h.phase === phase && !h.ended_at);\n if (!entry) {\n // Missed stage-start: use the remembered start if given, else the end of the previous stage; either way estimated.\n const runEntries = state.history.filter((h) => h.run === run.id);\n const lastEnd = runEntries.map((h) => h.ended_at).filter(Boolean).sort().at(-1);\n const late = lateStart(flags, run);\n entry = { run: run.id, stage, phase, started_at: late ?? lastEnd ?? run.started_at, ended_at: null, result: null, note: null, start_estimated: true };\n state.history.push(entry);\n 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'}`);\n } else if (flags['started-at'] || flags['started-ago']) {\n entry.started_at = lateStart(flags, run);\n entry.start_estimated = true;\n }\n entry.ended_at = now();\n entry.result = result;\n if (typeof flags.note === 'string') entry.note = flags.note;\n saveState(ws, state);\n console.log(`${stage}${phase ? `/${phase}` : ''} ended: ${result}`);\n}\n\nfunction stageFix(ws, [stage], flags) {\n if (!STAGES.includes(stage)) fail(`unknown stage \"${stage}\"`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n const phase = flags.phase ?? null;\n const entry = [...(state.history ?? [])].reverse().find((h) => h.run === run.id && h.stage === stage && h.phase === phase);\n if (!entry) fail(`${stage}${phase ? `/${phase}` : ''} was never logged in this run; use stage-start with --started-ago instead`);\n const late = lateStart(flags, run);\n if (!late) fail('stage-fix needs --started-ago (e.g. 6m) or --started-at <ISO time>');\n if (entry.ended_at && late > entry.ended_at) fail(`the new start ${late} is after the stage ended (${entry.ended_at})`);\n entry.started_at = late;\n entry.start_estimated = true;\n saveState(ws, state);\n console.log(`${stage}${phase ? `/${phase}` : ''} start set to ${late} (estimated)`);\n}\n\nfunction event(ws, [type, raw]) {\n const spec = EVENT_FIELDS[type];\n if (!spec) fail(`unknown event type \"${type}\"; one of ${Object.keys(EVENT_FIELDS).join(', ')}`);\n let data;\n try {\n data = JSON.parse(raw ?? '');\n } catch {\n fail(`event ${type} needs a JSON object, e.g. '${JSON.stringify(Object.fromEntries(spec.required.map((k) => [k, '…'])))}'`);\n }\n const missing = spec.required.filter((k) => data[k] === undefined || data[k] === '');\n if (missing.length) fail(`event ${type} is missing ${missing.join(', ')}`);\n const unknown = Object.keys(data).filter((k) => !spec.required.includes(k) && !spec.optional.includes(k));\n if (unknown.length) fail(`event ${type} has unknown fields ${unknown.join(', ')}`);\n if (type === 'issue') {\n data.severity ??= 'medium';\n if (!['high', 'medium', 'low'].includes(data.severity)) fail('issue severity must be high, medium or low');\n data.resolved ??= false;\n data.user_had_to_intervene ??= false;\n }\n // A bad line in events.jsonl would fail every later report and run-start.\n validate({ ...data, logged_at: now() }, eventSchema(type), `event ${type}`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n appendFileSync(ws.eventsPath, `${JSON.stringify({ ts: now(), run: run.id, type, ...data })}\\n`);\n console.log(`${type} logged`);\n}\n\nconst minutesBetween = (a, b) => (a && b ? Math.round(((Date.parse(b) - Date.parse(a)) / 60000) * 10) / 10 : null);\n\nfunction frontendStack(dir) {\n // Framework is recorded in state.json by the frontend step.\n const state = readJson(join(dir, 'state.json'), {});\n const fe = state.frontend ?? {};\n const framework = fe.framework ?? fe.stack ?? null;\n if (framework) return fe.rendering ? `${framework} (${fe.rendering})` : framework;\n // Fallback: an older build may have a design.md with a \"## Stack\" heading.\n const design = existsSync(join(dir, 'design.md')) ? readFileSync(join(dir, 'design.md'), 'utf8') : '';\n return design.match(/## Stack\\s*\\n\\s*\\*\\*([^*]+)\\*\\*/)?.[1] ?? null;\n}\n\nfunction buildReport(ws, { outcome, tokens } = {}) {\n const state = loadState(ws);\n const run = currentRun(ws, state);\n const site = readJson(join(ws.dir, 'site.json'), {});\n const deploy = readJson(join(ws.dir, 'deploy.json'), {});\n const connect = readConnect(ws);\n const history = (state.history ?? []).filter((h) => h.run === run.id);\n // Lines logged before events were checked on write may not match the schema.\n const dropped = [];\n const events = readEvents(ws)\n .filter((e) => e.run === run.id)\n .filter((e) => {\n const { ts, run: _run, type, ...rest } = e;\n const problem = schemaError({ ...rest, logged_at: ts }, eventSchema(type), 'event');\n if (problem) dropped.push({ stage: typeof e.stage === 'string' ? e.stage : 'unknown', phase: null, problem: `left out a malformed ${type} event (${problem})` });\n return !problem;\n });\n const resolvedOutcome = outcome ?? (history.some((h) => h.stage === 'deploy' && h.result?.startsWith('ok')) ? 'live' : 'stopped');\n // A run ends at its last logged activity, so re-running `report` later (after fixing warnings, or\n // when the next run-start closes an abandoned run) doesn't stretch it to \"now\". Only a stage still\n // open on a live run keeps the clock running.\n const lastActivity = [run.started_at, ...history.flatMap((h) => [h.started_at, h.ended_at]), ...events.map((e) => e.ts)]\n .filter(Boolean)\n .sort()\n .at(-1);\n const stillOpen = resolvedOutcome !== 'abandoned' && history.some((h) => !h.ended_at);\n const endedAt = stillOpen ? now() : lastActivity;\n const stages = history.map((h) => ({ ...h, minutes: minutesBetween(h.started_at, h.ended_at ?? endedAt) }));\n const gaps = [...loggingGaps(stages, run, resolvedOutcome, site.backend), ...dropped];\n const strip = ({ ts, run: _run, type: _type, ...rest }) => ({ ...rest, logged_at: ts });\n const liveStage = stages.find((s) => s.stage === 'deploy' && s.result?.startsWith('ok'));\n if (!OUTCOMES.includes(resolvedOutcome)) fail(`unknown outcome \"${resolvedOutcome}\"; one of ${OUTCOMES.join(', ')}`);\n\n const previous = existsSync(ws.reportsDir)\n ? readdirSync(ws.reportsDir)\n .filter((f) => f.endsWith('.json') && f !== `${run.id}.json`)\n .map((f) => readJson(join(ws.reportsDir, f), {}))\n .sort((a, b) => `${a.timing?.started_at}${a.run?.id}`.localeCompare(`${b.timing?.started_at}${b.run?.id}`))\n .map((r) => ({ run: r.run?.id, kind: r.run?.kind, outcome: r.info?.outcome, minutes: r.timing?.minutes_total, html: `${r.run?.id}.html` }))\n : [];\n\n const report = {\n schema_version: '1',\n generated_at: now(),\n // Intent, not delivery: delivery is tracked in state.json.\n consent: { sent: true, note: 'Sent to Hostinger automatically at the end of the run.' },\n run: { id: run.id, kind: run.kind, skill: run.skill, follow_ups: run.follow_ups ?? [] },\n info: {\n domain: ws.domain,\n tool: run.tool ?? connect.tool ?? null,\n model: run.model ?? null,\n os: process.platform,\n node: process.version,\n mcp: connect.mcp,\n backend: site.backend ?? null,\n frontend: frontendStack(ws.dir),\n outcome: resolvedOutcome,\n stopped_at: resolvedOutcome === 'live' ? null : stoppedAt(stages, state),\n live_url: resolvedOutcome === 'live' ? deploy.urls?.site ?? site.urls?.site ?? null : null,\n admin_url: site.urls?.wp_admin ?? null,\n },\n timing: {\n started_at: run.started_at,\n ended_at: endedAt,\n live_at: liveStage?.ended_at ?? null,\n minutes_total: minutesBetween(run.started_at, endedAt),\n minutes_to_live: liveStage ? minutesBetween(run.started_at, liveStage.ended_at) : null,\n stages: stages.map(({ run: _run, ...s }) => s),\n },\n // Not null: the MCP layer treats a null required field as missing, and the API reads {} as an array.\n tokens: tokens ?? { source: 'not reported' },\n issues: events.filter((e) => e.type === 'issue').map(strip),\n user_feedback: events.filter((e) => e.type === 'feedback').map(strip),\n skill_gaps: events.filter((e) => e.type === 'skill_gap').map(strip),\n what_went_well: events.filter((e) => e.type === 'good').map(strip),\n logging_gaps: gaps,\n previous_runs: previous,\n };\n\n validate(report, reportSchema());\n mkdirSync(ws.reportsDir, { recursive: true });\n writeJson(join(ws.reportsDir, `${run.id}.json`), report);\n writeFileSync(join(ws.reportsDir, `${run.id}.html`), renderHtml(report));\n copyFileSync(join(ws.reportsDir, `${run.id}.html`), join(ws.dir, 'report.html'));\n\n const fresh = loadState(ws);\n fresh.run.reported_at = now();\n saveState(ws, fresh);\n for (const g of gaps) console.log(`warning: ${g.stage}${g.phase ? `/${g.phase}` : ''}: ${g.problem}`);\n return join(ws.dir, 'report.html');\n}\n\n// A report counts as sent only while the sent copy's generated_at matches the file.\nfunction reportFile(ws, runId) {\n const id = runId ?? currentRun(ws, loadState(ws)).id;\n const file = join(ws.reportsDir, `${id}.json`);\n if (!existsSync(file)) fail(`no report for run ${id} at ${file}`);\n return { id, file, generatedAt: readJson(file, {}).generated_at };\n}\n\nfunction unsent(ws) {\n const state = loadState(ws);\n const files = existsSync(ws.reportsDir) ? readdirSync(ws.reportsDir).filter((f) => f.endsWith('.json')).sort() : [];\n // Older reports promised \"nothing is uploaded\".\n const pending = files\n .map((f) => {\n const report = readJson(join(ws.reportsDir, f), {});\n return { id: f.replace(/\\.json$/, ''), file: join(ws.reportsDir, f), generatedAt: report.generated_at, consent: report.consent };\n })\n .filter(({ consent }) => consent?.sent !== false)\n .filter(({ id, generatedAt }) => state.reports_sent?.[id] !== generatedAt && state.reports_rejected?.[id] !== generatedAt);\n if (!pending.length) console.log('no unsent reports');\n for (const { id, file } of pending) console.log(`${id} ${file}`);\n}\n\nfunction reportSent(ws, [runId]) {\n const { id, generatedAt } = reportFile(ws, runId);\n const state = loadState(ws);\n state.reports_sent = { ...state.reports_sent, [id]: generatedAt };\n saveState(ws, state);\n console.log(`report ${id} marked sent`);\n}\n\n// A 422 is schema drift, so resending won't help.\nfunction reportRejected(ws, [runId]) {\n const { id, generatedAt } = reportFile(ws, runId);\n const state = loadState(ws);\n state.reports_rejected = { ...state.reports_rejected, [id]: generatedAt };\n saveState(ws, state);\n const run = currentRun(ws, state);\n const symptom = `build report ${id} rejected with 422 by hosting_headless_submit-build-report`;\n 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`);\n console.log(`report ${id} marked rejected; issue logged in run ${run.id}`);\n}\n\n// Gaps are printed as warnings by `report`, so guesses never pass as measurements.\nconst EXPECTED_FOR_BUILD = ['check_state', 'refine', 'provision', 'content_model', 'design', 'frontend', 'validate', 'deploy'];\n// Stages a backend skips on purpose: webapp has no CMS, so no content model.\nconst SKIPPED_BY_BACKEND = { webapp: ['content_model'] };\nfunction loggingGaps(stages, run, outcome, backend) {\n const gaps = [];\n for (const s of stages) {\n // A skipped stage did no work, so it has no duration to measure.\n const problem = s.result === 'skipped' ? null\n : !s.ended_at ? 'started but never ended'\n : s.start_estimated ? 'start time estimated, not measured'\n : Date.parse(s.ended_at) === Date.parse(s.started_at) ? 'logged with no duration; probably started and ended in one go after the work'\n : null;\n if (problem) gaps.push({ stage: s.stage, phase: s.phase, problem });\n }\n if (run.kind === 'build' && outcome === 'live') {\n for (const stage of EXPECTED_FOR_BUILD.filter((name) => !SKIPPED_BY_BACKEND[backend]?.includes(name))) {\n if (!stages.some((s) => s.stage === stage)) gaps.push({ stage, phase: null, problem: 'never logged, though the site went live' });\n }\n }\n return gaps;\n}\n\nfunction stoppedAt(stages, state) {\n const stuck = [...stages].reverse().find((s) => !s.ended_at || s.result === 'failed' || s.result === 'stopped');\n return stuck?.stage ?? state.stage ?? null;\n}\n\n// Minimal JSON Schema subset, so reports from different tools share one shape without a dependency.\nconst reportSchema = () => readJson(join(here, 'report.schema.json'), null);\nconst eventSchema = (type) => reportSchema()?.properties?.[EVENT_LISTS[type]]?.items ?? null;\n\nfunction schemaError(value, schema, path = 'report') {\n if (!schema) return null;\n const types = [].concat(schema.type ?? []);\n const actual = value === null ? 'null' : Array.isArray(value) ? 'array' : Number.isInteger(value) ? 'integer' : typeof value;\n if (types.length && !types.includes(actual) && !(actual === 'integer' && types.includes('number'))) {\n return `${path} should be ${types.join(' or ')}, got ${actual}`;\n }\n if (schema.enum && !schema.enum.includes(value)) return `${path} should be one of ${schema.enum.join(', ')}`;\n if (actual === 'object') {\n for (const key of schema.required ?? []) if (!(key in value)) return `${path}.${key} is missing`;\n for (const [key, sub] of Object.entries(schema.properties ?? {})) {\n const error = key in value ? schemaError(value[key], sub, `${path}.${key}`) : null;\n if (error) return error;\n }\n }\n if (actual === 'array' && schema.items) {\n for (const [i, item] of value.entries()) {\n const error = schemaError(item, schema.items, `${path}[${i}]`);\n if (error) return error;\n }\n }\n return null;\n}\n\nfunction validate(value, schema, path) {\n const error = schemaError(value, schema, path);\n if (error) fail(error);\n}\n\nconst esc = (v) => String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\nconst label = (s) => String(s ?? '').replace(/_/g, ' ');\nconst time = (iso) => (iso ? iso.slice(11, 19) : '—');\nconst fmt = (n) => (typeof n === 'number' ? n.toLocaleString('en-US') : '—');\n\nfunction gantt(report) {\n const stages = report.timing.stages;\n if (!stages.length) return '<p class=\"muted\">No stages were logged for this run.</p>';\n const t0 = Date.parse(report.timing.started_at);\n const at = (iso) => (Date.parse(iso) - t0) / 60000;\n const total = Math.max(report.timing.minutes_total ?? 1, ...stages.map((s) => at(s.ended_at ?? report.timing.ended_at)), 1);\n const step = [1, 2, 5, 10, 15, 30, 60, 120, 240].find((s) => total / s <= 8) ?? 480;\n const span = Math.ceil(total / step) * step;\n const W = 760;\n const LABEL = 160;\n const ROW = 32;\n const TOP = 32;\n const H = TOP + ROW * stages.length + 32;\n const x = (m) => LABEL + ((W - LABEL - 40) * m) / span;\n 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>`];\n for (let m = 0; m <= span; m += step) {\n 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>`);\n }\n for (const f of report.run.follow_ups ?? []) {\n const fx = x(at(f.resumed_at)).toFixed(1);\n 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>`);\n }\n if (report.timing.live_at) {\n const lx = x(at(report.timing.live_at)).toFixed(1);\n 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>`);\n }\n stages.forEach((s, i) => {\n const y = TOP + i * ROW;\n const a = at(s.started_at);\n const b = at(s.ended_at ?? report.timing.ended_at);\n const width = Math.max(x(b) - x(a), 4);\n const name = `${label(s.stage)}${s.phase ? ` · ${s.phase}` : ''}`;\n const tip = [name, `${s.minutes} min`, s.result ? label(s.result) : 'not finished', s.start_estimated ? 'start estimated' : null, s.note].filter(Boolean).join(' · ');\n const failed = s.result === 'failed' || s.result === 'stopped' || !s.ended_at;\n const fill = failed ? 'url(#hatch)' : s.start_estimated ? 'none' : 'var(--series-1)';\n out.push(`<text x=\"${LABEL - 12}\" y=\"${(y + ROW / 2 + 4).toFixed(1)}\" class=\"row-label\" text-anchor=\"end\">${esc(name)}</text>`);\n 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>`);\n });\n 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>');\n return out.join('');\n}\n\nfunction renderHtml(r) {\n 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>`;\n const stepped = r.issues.filter((i) => i.user_had_to_intervene).length;\n const rejected = r.user_feedback.filter((f) => f.category === 'design_rejected').length;\n const t = r.tokens && ['input', 'output', 'cache_read', 'cache_write', 'est_cost_usd'].some((k) => r.tokens[k] != null) ? r.tokens : null;\n const tiles = [\n r.timing.minutes_to_live != null\n ? tile('Time to live', `${r.timing.minutes_to_live} min`, 'start of run → deploy finished')\n : tile('Outcome', label(r.info.outcome), r.info.stopped_at ? `stopped at ${label(r.info.stopped_at)}` : ''),\n tile('Run length', `${r.timing.minutes_total} min`, `${r.timing.stages.length} stage entries`),\n tile('Issues', String(r.issues.length), `${stepped} needed the user to step in`),\n tile('User feedback', String(r.user_feedback.length), rejected ? `${rejected} design rejections` : 'corrections and requests'),\n 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'),\n ].join('');\n const SEV = { high: ['●', 'critical', 'High'], medium: ['▲', 'serious', 'Medium'], low: ['■', 'warning', 'Low'] };\n const order = ['high', 'medium', 'low'];\n const issues = [...r.issues]\n .sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity))\n .map((i) => {\n const [icon, cls, name] = SEV[i.severity];\n 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>`;\n })\n .join('');\n const list = (items, render, empty) => (items.length ? `<ul>${items.map(render).join('')}</ul>` : `<p class=\"muted\">${empty}</p>`);\n const stageRows = r.timing.stages\n .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>`)\n .join('');\n const details = [\n ['Domain', r.info.domain],\n ['Outcome', [label(r.info.outcome), r.info.live_url].filter(Boolean).join(' · ')],\n ['Run', `${r.run.id} · ${r.run.kind}`],\n ['Tool / model', [r.info.tool, r.info.model].filter(Boolean).join(' · ') || '—'],\n ['Stack', [r.info.backend, r.info.frontend].filter(Boolean).join(' + ') || '—'],\n ['Environment', `${r.info.os} · Node ${r.info.node}${r.info.mcp ? ` · ${r.info.mcp}` : ''}`],\n ['Skill version', r.run.skill ?? '—'],\n ...(r.run.follow_ups?.length\n ? [['Follow-ups', r.run.follow_ups.map((f) => `resumed ${time(f.resumed_at)}${f.reason ? ` — ${f.reason}` : ''}`).join('; ')]]\n : []),\n ]\n .map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`)\n .join('');\n const tokenTable = t\n ? `<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>`\n : '';\n const previous = r.previous_runs.length\n ? `<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>`\n : '';\n\n return `<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Build report — ${esc(r.info.domain)}</title>\n<style>\n.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; }\n@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; } }\n* { box-sizing: border-box; }\nbody { margin:0; background:var(--surface-0); color:var(--text-primary); font:15px/1.5 system-ui,-apple-system,\"Segoe UI\",sans-serif; }\nmain { max-width:1080px; margin:0 auto; padding:32px 24px 64px; }\nh1 { font-size:28px; line-height:1.2; margin:0 0 4px; } h2 { font-size:18px; margin:0 0 16px; }\na { color:var(--series-1); }\n.lede { color:var(--text-secondary); margin:0 0 24px; }\nsection { background:var(--surface-1); border:1px solid var(--border); border-radius:12px; padding:24px; margin-bottom:24px; }\n.tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:16px; margin-bottom:24px; }\n.tile { background:var(--surface-1); border:1px solid var(--border); border-radius:12px; padding:16px; }\n.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; }\ndl { 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; }\n.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; }\n.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; }\n.bar { outline:none; } .bar:hover rect:last-child, .bar:focus rect:last-child { stroke:var(--text-primary); stroke-width:2; }\n.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; }\n.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); }\n#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; }\ntable { 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; }\ntd.num,th.num { text-align:right; font-variant-numeric:tabular-nums; } .table-wrap { overflow-x:auto; }\n.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); }\n.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; }\n.cols { display:grid; grid-template-columns:repeat(auto-fit,minmax(320px,1fr)); gap:24px; margin-bottom:24px; } .cols section { margin:0; }\nul { margin:0; padding-left:20px; } li { margin-bottom:8px; } .muted { color:var(--text-muted); font-size:13px; }\ncode { font-size:13px; background:var(--surface-0); padding:1px 4px; border-radius:4px; }\ndetails summary { cursor:pointer; color:var(--text-secondary); margin-top:16px; }\n</style></head><body class=\"viz-root\"><main>\n<h1>Build report — ${esc(r.info.domain)}</h1>\n<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>\n<div class=\"tiles\">${tiles}</div>\n<section><h2>Stage timeline</h2>${gantt(r)}\n<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>\n<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>\n${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>` : ''}\n<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>\n<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>\n<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>\n<div class=\"cols\"><section><h2>What went well</h2>${list(r.what_went_well, (g) => `<li>${esc(g.text)}</li>`, 'Nothing logged.')}</section>\n<section><h2>Run details</h2><dl>${details}</dl></section></div>\n${tokenTable}${previous}\n</main><div id=\"tip\" role=\"status\"></div>\n<script>\nconst tip = document.getElementById('tip');\nconst 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; };\ndocument.querySelectorAll('.bar').forEach((el) => {\n el.addEventListener('mousemove', (e) => show(el, e.clientX, e.clientY));\n el.addEventListener('mouseleave', () => (tip.style.opacity = 0));\n el.addEventListener('focus', () => { const r = el.getBoundingClientRect(); show(el, r.left + r.width / 2, r.top); });\n el.addEventListener('blur', () => (tip.style.opacity = 0));\n});\n</script></body></html>\n`;\n}\n\nconst { positional, flags } = parseArgs(process.argv.slice(2));\nconst [domain, command, ...rest] = positional;\nconst ws = workspace(domain);\nswitch (command) {\n case 'run-start':\n runStart(ws, flags);\n break;\n case 'run-update':\n runUpdate(ws, flags);\n break;\n case 'run-resume':\n runResume(ws, flags);\n break;\n case 'stage-start':\n stageStart(ws, rest, flags);\n break;\n case 'stage-end':\n stageEnd(ws, rest, flags);\n break;\n case 'stage-fix':\n stageFix(ws, rest, flags);\n break;\n case 'event':\n event(ws, rest);\n break;\n case 'report': {\n let tokens;\n if (typeof flags.tokens === 'string') {\n try {\n tokens = JSON.parse(flags.tokens);\n } catch {\n fail('--tokens must be JSON, e.g. \\'{\"input\":0,\"output\":0,\"source\":\"…\"}\\'');\n }\n // null and {} can't be sent; treat them as not reported.\n if (tokens === null || (typeof tokens === 'object' && !Array.isArray(tokens) && !Object.keys(tokens).length)) tokens = undefined;\n else validate(tokens, { ...reportSchema()?.properties?.tokens, type: 'object' }, '--tokens');\n }\n console.log(`report written: ${buildReport(ws, { outcome: flags.outcome, tokens })}`);\n const { id, file } = reportFile(ws);\n 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)`);\n break;\n }\n case 'unsent':\n unsent(ws);\n break;\n case 'report-sent':\n reportSent(ws, rest);\n break;\n case 'report-rejected':\n reportRejected(ws, rest);\n break;\n default:\n fail('command must be run-start, run-update, run-resume, stage-start, stage-end, stage-fix, event, report, unsent, report-sent or report-rejected');\n}\n"
134
134
  },
135
135
  {
136
136
  "uri": "skill://hostinger-headless/steps/01-check-state/SKILL.md",
@@ -71,17 +71,17 @@ const skills: Skill[] = [
71
71
  "resources": [
72
72
  {
73
73
  "uri": "skill://hostinger-headless/SKILL.md",
74
- "digest": "sha256:be4d4ac36de438c9dac23d2a6f13075298283d5da23002537d288cc5f98f9409",
75
- "size": 21141,
74
+ "digest": "sha256:d7591f9e80ad48a5b45f774076af52c7847865fc63398325bc2f63907c7df779",
75
+ "size": 21181,
76
76
  "mimeType": "text/markdown",
77
- "text": "---\nname: hostinger-headless\ndescription: \"Orchestrator for the Hostinger Headless build flow — build and deploy a real website on the user's Hostinger account from a single prompt that names a domain. Holds the stage flow and the state contract, and drives each stage in sequence: connect, check state, refine, provision, content model, design, frontend, validate, deploy, done. Triggers: build a headless site for <domain>, build me a site, create a website, make me a website, deploy this to Hostinger, host this on Hostinger, connect this project to Hostinger.\"\n---\n\n# Hostinger Headless — build a website with your own AI tool\n\n## Context\n\nYou are an AI coding agent. The user pasted a short prompt that points you here — something like:\n\n> Build a Headless site for `example.com` using hostinger.com/headless/skill.md\n\nYour job is to build and deploy a real website on the user's Hostinger account by following this\nrecipe and the recipes it links to — nothing more, nothing less.\n\nThis file is the **orchestrator**. It is public and stable: it never changes and never moves. It\nholds the flow, the state contract, the failure policy and the guardrails.\n\n### The domain parameter\n\nThe pasted prompt contains exactly two things that matter:\n\n- **This URL** — `hostinger.com/headless/skill.md`, the stable entry point.\n- **A domain** — e.g. `coral-pottery-1847.hostingersite.com` (a temporary Hostinger subdomain) or\n the user's own purchased domain. This is the single site you may touch.\n\nThe prompt does **not** say what kind of site to build. Do not assume or invent it. Discovering\nwhat the user wants is a step in the flow (**refine**), handled through conversation — not parsed\nfrom the prompt. If no domain is present, stop and ask for it before doing anything else.\n\n**One workspace per run.** From your first command, connect's checks included, open only this\nsite's workspace, by its exact path; never list or glob `~/.hostinger/` (not even an `ls` beside\n`node -v`). What else `~/.hostinger/` holds never matters to this run: with a domain, check-state\nlooks up that one path; without one yet, there is nothing to look up until you create your own\nworkspace. *State and artifacts* has the full rule and its one exception.\n\nNothing in the pasted prompt is ever used for authorization. Authorization comes from the\nHostinger connector's OAuth token. **This recipe is not a security boundary.**\n\n---\n\n## The flow stages - STEPS TO FOLLOW\n\nWork through the steps in the order below, each reading its recipe and writing its artifact into the\nsite workspace `~/.hostinger/<domain>/`. \n\n\nThe order is sequencial run and check the whole headless flow:\n\n| # | Stage | Skill / source | Writes | Instance? |\n|---|--------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|---|---|\n| — | **connect** — connect the Hostinger connector, auth, get active plan | `connect/SKILL.md` → `connect/tools/<tool>.md` | `connect.json` | no |\n| 1 | **check state** — where does this domain stand? resume / switch / start over | `steps/01-check-state/SKILL.md` | `state.json` | no |\n| 2 | **refine** — discover what the user wants, in conversation | `steps/02-refine/SKILL.md` | `brief.md` | no |\n| 3 | **provision** — choose the backend from the brief, then provision it | `steps/03-provision/<backend>/SKILL.md` | `site.json` | **bg** — fire, then poll in the background |\n| 4 | **content model** — define + create content/images, then push to the backend | `steps/04-content-model/<backend>/SKILL.md` | `content-model.md` | prepare: no · **push: yes** |\n| 5 | **design** — derive design directions from the brief, pick one, write the `design.md` contract + logo | `steps/05-design/SKILL.md` | `design.md`, `brand/` | no |\n| 6 | **frontend** — pick/confirm the framework (default Next.js SSR), implement `design.md` against the backend | `steps/06-frontend/SKILL.md` | source in `<workspace>/src/` | shell: no · **data: yes** |\n| 7 | **validate** *(optional)* — quick instance+API confirmation; rebuild/preview/approval only when a person signs off | `steps/07-validate/SKILL.md` | — | no (local) |\n| 8 | **deploy** — upload source, trigger the build | `steps/08-deploy/SKILL.md` | `deploy.json` | **yes** |\n| 9 | **done** — hand over URLs, dashboard, summary, next steps (the **final report**) | `steps/09-done/SKILL.md` | — | no |\n\n**The most important is that connect stage runs first and always**\nIt's fetched from the public URL and runs *before* the Hostinger MCP Server\nis loaded, the main goal of this is to have the Hostinger MCP Server.\n\nYou can find those connect skills under `connect/`.\nThe main skill lives in `connect/SKILL.md`.\nThere are also specific tools under `connect/tools/`.\n\nThe other 9 numbered steps live under `steps/` and are read from the installed skill folder (this will be guided in connect step).\n\n**One concurrency rule:** two *independent* stages in a single session may run at once, but never\ntwo writers of the same artifact, and never two agents/windows on the same domain — the `.lock`\n(Step 1) guards that. Parallelism is always *optional*: an agent that can't interleave work runs the\nsteps serially and is still correct. Saying this, Always that you consider, you can run the steps\nin parallel using different threads or processes to save time, but always ensure the right output.\n\nSteps can generate **State files** those files are only hints; the truth is what the Hostinger connector reports, so\nverify a \"done\" marker against reality before acting on it. If a step's `SKILL.md` is missing from the installed skill folder, stop and tell\nthe user rather than improvising the stage from its description.\n\nThe **Instance?** column says whether a stage touches the live backend:\n`no` = pure prepare work that can run during the provision wait;\n`push`/`data`/`yes` = a commit phase that joins the instance barrier;\n`bg` = provision itself, the background task.\n\n**Always end with the final report.** Whenever a run ends, give the user the final report defined\nin `steps/09-done/SKILL.md`: an outcome paragraph, a *Steps completed* table (one row per stage that\nran), the links, where state lives, and any gaps. That includes runs that stop early at a dummy or\nmissing step, a failed provision, or a user pause. Every run also saves a **build report**\n(`run-log.mjs report`, see *Run log and build report* below), on those early stops too.\n\n**Log the run as you go.** Wrap every stage (and each phase of a split stage) in\n`run-log.mjs stage-start` / `stage-end`, and log an event the moment something goes wrong, the user\ncorrects you, or a skill is missing or wrong — before you continue. See *Run log and build report*.\n\n**Why this order.** connect first, so account/plan failures surface before the user spends time\ntalking. Check-state right after, so a resumed, duplicate, or brand-new run is sorted out — and the\nuser is offered the choice to continue, switch domains, or start over — before any new work begins.\nRefinement before provisioning, because the backend cannot be chosen until the brief exists.\nValidation before deploy, always — nothing reaches the live domain unseen.\n\n### When you ask the user\n\nA question tool's result is the user's answer only when it carries their choice or their words. When\nit comes back skipped, rejected, empty or as an error (a *skipped* result), nobody has answered yet:\nCursor's `AskQuestion`, for one, returns \"Questions skipped by the user\" when no one touched it. Ask\nthe same questions again as plain text in your reply, end your turn, and continue from what the user\ntypes. One plain-text re-ask is enough: it can't come back skipped. Fill a question you asked with a\ndefault only when the user says so in their own words (\"you decide\", \"just build it\", \"skip that\"),\nor when they said up front that no one will be there to answer; then list those defaults under gaps\nin the final report. Every step that asks the user something follows this.\n\n### Background provision & the instance barrier\n\n**Provision always runs — what it does depends on what the domain already has.** Often onboarding on\nhostinger.com has already created the website before you run, so the domain *has* a headless instance;\nbut an instance existing does **not** mean provision is done or can be skipped. `check_state` works out\nwhich of these cases applies and records it, then provision takes the matching path:\n\n1. **An empty headless shell already exists for the domain (the common onboarding case).** Onboarding\n creates the website first — an **empty headless shell** (`hosting_websites_start-setup { type:\n \"headless_empty\" }`): `website_type` is `headless`, but **no backend is installed** (no WordPress at\n `/cms`, no store). This still goes through provision, which **adopts the shell and swaps it to the\n chosen backend** (the backend is picked in refine first): WordPress is installed into the shell's\n `/cms` with `wordpress_installations_install { type: \"headless\" }`, or the shell is adopted as the\n Ecommerce storefront host. **Never skip provision for a shell, and never create a second site.** See\n the provision router's *Onboarding handoff*.\n2. **No instance exists at all** (the user ran the headless flow without onboarding provisioning\n anything). Provision creates a fresh headless instance with `hosting_websites_start-setup` using the\n chosen backend's own `type`.\n3. **User wants a webapp.** Provision is a plain Node.js app host, not headless — `webapp` ignores\n headless shells entirely and is only reached by experimental actions, never the onboarding flow.\n\nEither way the create/install is asynchronous and returns `queued` immediately, then it's polled\nevery ~15 s for a few minutes — **what you poll depends on the path**: a fresh\n`hosting_websites_start-setup` is polled with `hosting_websites_list-setups` until `completed`, while a\nshell swap (`wordpress_installations_install`) creates no new setup, so it's polled with\n`wordpress_installations_list` until the `/cms` row is `is_valid`. Don't block on that poll. The\nsingle rule that makes the overlap safe:\n\n> **Fire provision as a background task, then do every stage's prepare work while it settles. The\n> moment a step must write to the live instance, it waits for provision to report ready (via the\n> backend step's readiness poll — see above). That wait is the *instance barrier*.**\n\n---\n\n## State and artifacts\n\nEverything for a domain — its state **and** its generated source — lives under the **site workspace\n`~/.hostinger/<domain>/`** (resolve `~` to the home directory: `$HOME` in a shell, `os.homedir()` in\nNode — `~/.hostinger` means `$HOME/.hostinger`). This is a **global, per-user** location, not a\nproject folder, so this domain's build is resumable from **any** directory — the current working\ndirectory does not matter. Create the workspace if it's missing and do all of a stage's work inside\nit (including the frontend `src/`, built and deployed from `~/.hostinger/<domain>/src/`).\n\n**The directory name is the domain**, so the files inside it never repeat it.\n\n**Read and write only this site's workspace.** The other workspaces under `~/.hostinger/` are the\nuser's other sites, often for other clients, so keep their briefs, designs and code out of this build.\nOpen `~/.hostinger/<domain>/` by its exact path; don't list or glob `~/.hostinger/`, and don't open,\ncopy from or diff against another workspace there, in any stage. A run that has no domain yet works\nonly in the one workspace it created itself; when provision moves it to `~/.hostinger/<new-domain>/`,\nthat becomes this site's workspace. One exception: if the user, in this conversation, names another\nof their own sites to reuse, read that one workspace, read-only, and tell them in one line what you\ntook from it.\n\n| File (under `~/.hostinger/<domain>/`) | Written by | Contains |\n|---|---|---|\n| `state.json` | every stage | tool, next stage, Hostinger connector version, timestamps |\n| `connect.json` | connect | tool, account/plan readiness |\n| `brief.md` | refine | refined brief, audience, tone, pages, must-haves |\n| `site.json` | provision | chosen backend, username, install id, all URLs |\n| `content-model.md` | content model | entities, fields, pages, navigation |\n| `design.md` (+ `brand/` logo files) | design | chosen design direction: tokens, type, logo, components, per-page composition, accessibility |\n| `src/` | frontend | the website source implementing `design.md`; framework choice is recorded in `state.json → frontend` |\n| `deploy.json` | deploy | build id, deployed at, live URLs |\n| `events.jsonl` | every stage, via `run-log.mjs` | append-only log of issues, user feedback, skill gaps and wins |\n| `reports/<run>.json` + `.html`, `report.html` | done (or any early stop), via `run-log.mjs` | the build report for each run; `report.html` is the latest |\n\n`state.json` is the minimum you must keep current. **`stage` is the *next* stage to run** (not the\nlast completed one), and it does **not** store the domain — the directory is the key. Because\nprovision runs in the background, its status lives in its own `provision` field rather than in\n`stage`, so prepare work can advance `stage` while provision is still settling:\n\n```json\n{\n \"tool\": \"claude-code\",\n \"stage\": \"content_model\",\n \"provision\": {\n \"status\": \"in_progress\",\n \"domain\": \"example.com\",\n \"started_at\": \"2026-10-08T10:00:00Z\"\n },\n \"updated_at\": \"2026-10-08T10:00:00Z\"\n}\n```\n\n- `provision` is **absent** until provision fires, `{\"status\":\"in_progress\", …}` while the\n background poll runs, and `{\"status\":\"completed\", …}` once `hosting_websites_list-setups` confirms\n it. `domain` is the poll target.\n- Any **instance-barrier** step (content-model push, frontend data, deploy) must read\n `provision.status == \"completed\"` — and re-verify it against the connector — before it runs. A\n prepare step ignores `provision` entirely.\n- On **resume**, `provision.status` is a hint only: re-poll `hosting_websites_list-setups` to learn\n the truth, then update the field.\n- `run` (the current run), `history` (one entry per stage or phase, with real start and end\n times), and `reports_sent` / `reports_rejected` (which copy of each report reached Hostinger) are\n written **only** by `run-log.mjs`. Leave them alone when you update `state.json` by hand.\n- The **onboarding slug** is how hPanel names the coding tool: `claude` for Claude Code, else\n `cursor`, `codex`, `vscode`, `antigravity`, `devin`. Map it from the `<tool>` you pass to\n `run-start --tool` (`state.json → run.tool`, e.g. `claude-code` → `claude`).\n\n---\n\n## Run log and build report\n\n`run-log.mjs` (next to this file) records the run while it happens and turns it into a report at the\nend. Timestamps come from the script, so they're measured, not remembered. It needs only Node.\nEvery command is `node <skill>/run-log.mjs <domain> …`, where `<skill>` is the folder this file is\nin. Write it out in full each time (a shell variable holding the command breaks in zsh):\n\n```bash\nnode <skill>/run-log.mjs <domain> run-start --tool <tool> --model <model> --skill <skill version> # check-state, first thing\nnode <skill>/run-log.mjs <domain> run-update --kind resume # check-state, once the route is agreed (or redesign / iterate)\nnode <skill>/run-log.mjs <domain> stage-start frontend # before a stage\nnode <skill>/run-log.mjs <domain> stage-end frontend ok_after_retries --note \"second framework tried\"\nnode <skill>/run-log.mjs <domain> stage-start frontend --phase shell # split stages log each phase\nnode <skill>/run-log.mjs <domain> event issue '{\"stage\":\"content_model\",\"symptom\":\"…\",\"cause\":\"…\",\"workaround\":\"…\",\"severity\":\"medium\",\"resolved\":true}'\nnode <skill>/run-log.mjs <domain> report --outcome live # done, or wherever the run stops\nnode <skill>/run-log.mjs <domain> report-sent <run> # after the report was sent (see below)\nnode <skill>/run-log.mjs <domain> report-rejected <run> # the send returned 422\nnode <skill>/run-log.mjs <domain> unsent # check-state: reports still to send\nnode <skill>/run-log.mjs <domain> run-resume --reason \"about/contact layout fix\" # follow-up in the same session\n```\n\n**Follow-ups continue the run.** When the user asks for a change after the final report in the\nsame session (a layout fix, a copy tweak, a redeploy), that work belongs to the run it follows:\ncall `run-resume`, log the feedback and the stages it re-runs as usual, then `report` again. That\nrewrites the same `reports/<run>.json` and `.html`, so you get one report that includes the follow-up,\nnot a second report that replaces `report.html`. Use `run-start` only in a new session; check-state\ndoes that.\n\n`stage-end` results: `ok`, `ok_after_retries`, `ok_with_fixups`, `failed`, `skipped`, `stopped`.\nStages: `check_state`, `refine`, `provision`, `content_model`, `design`, `frontend`, `validate`,\n`deploy`, `done` (connect has its own `connect.json`). Split stages use `--phase`: provision `fire` /\n`join`, content model `prepare` / `push`, frontend `shell` / `data`.\n\n**Forgot a `stage-start`?** Log it as soon as you notice, with roughly when the stage began:\n`stage-start frontend --started-ago 6m` (or `--started-at <ISO time>`). The same flags work on a\n`stage-end` that has no start. With neither, the script assumes the stage began when the previous one\nended. Either way the start is marked as estimated in the report. Never log a start and an end\nback to back after the work is done: that records a stage that took no time. If you already did,\ncorrect it with `stage-fix frontend --started-ago 6m`.\n\n**Log an event the moment it happens**, not at the end — details are gone by then:\n\n| Event | Log it when | Fields (required in bold) |\n|---|---|---|\n| `issue` | something fails, misbehaves, or needs a workaround — including your own mistakes | **stage**, **symptom**, cause, workaround, severity (`high`/`medium`/`low`), resolved, user_had_to_intervene |\n| `feedback` | the user corrects you, rejects something, or changes scope | **stage**, **category** (e.g. `design_rejected`, `layout_bug`, `scope_add`, `wrong_assumption`), **paraphrase** |\n| `skill_gap` | a step is dummy, missing, wrong or silent on what you needed | **skill** (path), **gap** |\n| `good` | something worked notably well or fast | **text** |\n\nParaphrase the user; don't paste long quotes, file contents, credentials or personal data. Set\n`user_had_to_intervene` when the user had to point the problem out.\n\n**Tokens** go on `report --tokens '<json>'` only if your tool tells you your own usage directly\n(`{\"source\":\"…\",\"requests\":…,\"input\":…,\"output\":…,\"cache_read\":…,\"cache_write\":…,\"est_cost_usd\":…}`).\nOtherwise leave it out and the report says `{\"source\":\"not reported\"}` (not null: the connector rejects a null `tokens`). Never read session transcripts or log\nfiles to get it.\n\nThe report is saved to `reports/<run>.json` and `.html`, and `report.html` in the workspace is the\nlatest. If a run never reaches its report (the window closed), the next `run-start` closes it as\n`abandoned`, so its log still becomes a report.\n\n**Send every report to Hostinger.** Sending is part of the flow, so don't ask first; tell the user in\none line right after the send (see `09-done`). Right after each\n`report` (follow-ups included), read `reports/<run>.json` and pass its contents, unchanged, as the\narguments of the Hostinger connector tool `hosting_headless_submit-build-report`. Then record the\nresult:\n\n- **Success:** `report-sent <run>`.\n- **422** (the report doesn't match the schema the endpoint holds): `report-rejected <run>`. Don't\n resend it.\n- **Anything else** (network error, 5xx, the tool isn't in your connector): do nothing. The next\n check-state sends it again.\n\nNever edit the JSON before sending, never add account details (the connector's login identifies the\naccount), and never let a failed send block, delay or change the run. Check-state sends any\nreports still unsent (abandoned runs, earlier failures) with `unsent`."
77
+ "text": "---\nname: hostinger-headless\ndescription: \"Orchestrator for the Hostinger Headless build flow — build and deploy a real website on the user's Hostinger account from a single prompt that names a domain. Holds the stage flow and the state contract, and drives each stage in sequence: connect, check state, refine, provision, content model, design, frontend, validate, deploy, done. Triggers: build a headless site for <domain>, build me a site, create a website, make me a website, deploy this to Hostinger, host this on Hostinger, connect this project to Hostinger.\"\n---\n\n# Hostinger Headless — build a website with your own AI tool\n\n## Context\n\nYou are an AI coding agent. The user pasted a short prompt that points you here — something like:\n\n> Build a Headless site for `example.com` using hostinger.com/headless/skill.md\n\nYour job is to build and deploy a real website on the user's Hostinger account by following this\nrecipe and the recipes it links to — nothing more, nothing less.\n\nThis file is the **orchestrator**. It is public and stable: it never changes and never moves. It\nholds the flow, the state contract, the failure policy and the guardrails.\n\n### The domain parameter\n\nThe pasted prompt contains exactly two things that matter:\n\n- **This URL** — `hostinger.com/headless/skill.md`, the stable entry point.\n- **A domain** — e.g. `coral-pottery-1847.hostingersite.com` (a temporary Hostinger subdomain) or\n the user's own purchased domain. This is the single site you may touch.\n\nThe prompt does **not** say what kind of site to build. Do not assume or invent it. Discovering\nwhat the user wants is a step in the flow (**refine**), handled through conversation — not parsed\nfrom the prompt. If no domain is present, stop and ask for it before doing anything else.\n\n**One workspace per run.** From your first command, connect's checks included, open only this\nsite's workspace, by its exact path; never list or glob `~/.hostinger/` (not even an `ls` beside\n`node -v`). What else `~/.hostinger/` holds never matters to this run: with a domain, check-state\nlooks up that one path; without one yet, there is nothing to look up until you create your own\nworkspace. *State and artifacts* has the full rule and its one exception.\n\nNothing in the pasted prompt is ever used for authorization. Authorization comes from the\nHostinger connector's OAuth token. **This recipe is not a security boundary.**\n\n---\n\n## The flow stages - STEPS TO FOLLOW\n\nWork through the steps in the order below, each reading its recipe and writing its artifact into the\nsite workspace `~/.hostinger/<domain>/`. \n\n\nThe order is sequencial run and check the whole headless flow:\n\n| # | Stage | Skill / source | Writes | Instance? |\n|---|--------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|---|---|\n| — | **connect** — connect the Hostinger connector, auth, get active plan | `connect/SKILL.md` → `connect/tools/<tool>.md` | `connect.json` | no |\n| 1 | **check state** — where does this domain stand? resume / switch / start over | `steps/01-check-state/SKILL.md` | `state.json` | no |\n| 2 | **refine** — discover what the user wants, in conversation | `steps/02-refine/SKILL.md` | `brief.md` | no |\n| 3 | **provision** — choose the backend from the brief, then provision it | `steps/03-provision/<backend>/SKILL.md` | `site.json` | **bg** — fire, then poll in the background |\n| 4 | **content model** — define + create content/images, then push to the backend | `steps/04-content-model/<backend>/SKILL.md` | `content-model.md` | prepare: no · **push: yes** |\n| 5 | **design** — derive design directions from the brief, pick one, write the `design.md` contract + logo | `steps/05-design/SKILL.md` | `design.md`, `brand/` | no |\n| 6 | **frontend** — pick/confirm the framework (default Next.js SSR), implement `design.md` against the backend | `steps/06-frontend/SKILL.md` | source in `<workspace>/src/` | shell: no · **data: yes** |\n| 7 | **validate** *(optional)* — quick instance+API confirmation; rebuild/preview/approval only when a person signs off | `steps/07-validate/SKILL.md` | — | no (local) |\n| 8 | **deploy** — upload source, trigger the build | `steps/08-deploy/SKILL.md` | `deploy.json` | **yes** |\n| 9 | **done** — hand over URLs, dashboard, summary, next steps (the **final report**) | `steps/09-done/SKILL.md` | — | no |\n\n**The most important is that connect stage runs first and always**\nIt's fetched from the public URL and runs *before* the Hostinger MCP Server\nis loaded, the main goal of this is to have the Hostinger MCP Server.\n\nYou can find those connect skills under `connect/`.\nThe main skill lives in `connect/SKILL.md`.\nThere are also specific tools under `connect/tools/`.\n\nThe other 9 numbered steps live under `steps/` and are read from the installed skill folder (this will be guided in connect step).\n\n**One concurrency rule:** two *independent* stages in a single session may run at once, but never\ntwo writers of the same artifact, and never two agents/windows on the same domain — the `.lock`\n(Step 1) guards that. Parallelism is always *optional*: an agent that can't interleave work runs the\nsteps serially and is still correct. Saying this, Always that you consider, you can run the steps\nin parallel using different threads or processes to save time, but always ensure the right output.\n\nSteps can generate **State files** those files are only hints; the truth is what the Hostinger connector reports, so\nverify a \"done\" marker against reality before acting on it. If a step's `SKILL.md` is missing from the installed skill folder, stop and tell\nthe user rather than improvising the stage from its description.\n\nThe **Instance?** column says whether a stage touches the live backend:\n`no` = pure prepare work that can run during the provision wait;\n`push`/`data`/`yes` = a commit phase that joins the instance barrier;\n`bg` = provision itself, the background task.\n\n**Always end with the final report.** Whenever a run ends, give the user the final report defined\nin `steps/09-done/SKILL.md`: an outcome paragraph, a *Steps completed* table (one row per stage that\nran), the links, where state lives, and any gaps. That includes runs that stop early at a dummy or\nmissing step, a failed provision, or a user pause. Every run also saves a **build report**\n(`run-log.mjs report`, see *Run log and build report* below), on those early stops too.\n\n**Log the run as you go.** Wrap every stage (and each phase of a split stage) in\n`run-log.mjs stage-start` / `stage-end`, and log an event the moment something goes wrong, the user\ncorrects you, or a skill is missing or wrong — before you continue. See *Run log and build report*.\n\n**Why this order.** connect first, so account/plan failures surface before the user spends time\ntalking. Check-state right after, so a resumed, duplicate, or brand-new run is sorted out — and the\nuser is offered the choice to continue, switch domains, or start over — before any new work begins.\nRefinement before provisioning, because the backend cannot be chosen until the brief exists.\nValidation before deploy, always — nothing reaches the live domain unseen.\n\n### When you ask the user\n\nA question tool's result is the user's answer only when it carries their choice or their words. When\nit comes back skipped, rejected, empty or as an error (a *skipped* result), nobody has answered yet:\nCursor's `AskQuestion`, for one, returns \"Questions skipped by the user\" when no one touched it. Ask\nthe same questions again as plain text in your reply, end your turn, and continue from what the user\ntypes. One plain-text re-ask is enough: it can't come back skipped. Fill a question you asked with a\ndefault only when the user says so in their own words (\"you decide\", \"just build it\", \"skip that\"),\nor when they said up front that no one will be there to answer; then list those defaults under gaps\nin the final report. Every step that asks the user something follows this.\n\n### Background provision & the instance barrier\n\n**Provision always runs — what it does depends on what the domain already has.** Often onboarding on\nhostinger.com has already created the website before you run, so the domain *has* a headless instance;\nbut an instance existing does **not** mean provision is done or can be skipped. `check_state` works out\nwhich of these cases applies and records it, then provision takes the matching path:\n\n1. **An empty headless shell already exists for the domain (the common onboarding case).** Onboarding\n creates the website first — an **empty headless shell** (`hosting_websites_start-setup { type:\n \"headless_empty\" }`): `website_type` is `headless`, but **no backend is installed** (no WordPress at\n `/cms`, no store). This still goes through provision, which **adopts the shell and swaps it to the\n chosen backend** (the backend is picked in refine first): WordPress is installed into the shell's\n `/cms` with `wordpress_installations_install { type: \"headless\" }`, or the shell is adopted as the\n Ecommerce storefront host. **Never skip provision for a shell, and never create a second site.** See\n the provision router's *Onboarding handoff*.\n2. **No instance exists at all** (the user ran the headless flow without onboarding provisioning\n anything). Provision creates a fresh headless instance with `hosting_websites_start-setup` using the\n chosen backend's own `type`.\n3. **User wants a webapp.** Provision is a plain Node.js app host, not headless — `webapp` ignores\n headless shells entirely and is only reached by experimental actions, never the onboarding flow.\n\nEither way the create/install is asynchronous and returns `queued` immediately, then it's polled\nevery ~15 s for a few minutes — **what you poll depends on the path**: a fresh\n`hosting_websites_start-setup` is polled with `hosting_websites_list-setups` until `completed`, while a\nshell swap (`wordpress_installations_install`) creates no new setup, so it's polled with\n`wordpress_installations_list` until the `/cms` row is `is_valid`. Don't block on that poll. The\nsingle rule that makes the overlap safe:\n\n> **Fire provision as a background task, then do every stage's prepare work while it settles. The\n> moment a step must write to the live instance, it waits for provision to report ready (via the\n> backend step's readiness poll — see above). That wait is the *instance barrier*.**\n\n---\n\n## State and artifacts\n\nEverything for a domain — its state **and** its generated source — lives under the **site workspace\n`~/.hostinger/<domain>/`** (resolve `~` to the home directory: `$HOME` in a shell, `os.homedir()` in\nNode — `~/.hostinger` means `$HOME/.hostinger`). This is a **global, per-user** location, not a\nproject folder, so this domain's build is resumable from **any** directory — the current working\ndirectory does not matter. Create the workspace if it's missing and do all of a stage's work inside\nit (including the frontend `src/`, built and deployed from `~/.hostinger/<domain>/src/`).\n\n**The directory name is the domain**, so the files inside it never repeat it.\n\n**Read and write only this site's workspace.** The other workspaces under `~/.hostinger/` are the\nuser's other sites, often for other clients, so keep their briefs, designs and code out of this build.\nOpen `~/.hostinger/<domain>/` by its exact path; don't list or glob `~/.hostinger/`, and don't open,\ncopy from or diff against another workspace there, in any stage. A run that has no domain yet works\nonly in the one workspace it created itself; when provision moves it to `~/.hostinger/<new-domain>/`,\nthat becomes this site's workspace. One exception: if the user, in this conversation, names another\nof their own sites to reuse, read that one workspace, read-only, and tell them in one line what you\ntook from it.\n\n| File (under `~/.hostinger/<domain>/`) | Written by | Contains |\n|---|---|---|\n| `state.json` | every stage | tool, next stage, Hostinger connector version, timestamps |\n| `connect.json` | connect | tool, MCP version, sign-in method, plan; shape in `connect/SKILL.md` |\n| `brief.md` | refine | refined brief, audience, tone, pages, must-haves |\n| `site.json` | provision | chosen backend, username, install id, all URLs |\n| `content-model.md` | content model | entities, fields, pages, navigation |\n| `design.md` (+ `brand/` logo files) | design | chosen design direction: tokens, type, logo, components, per-page composition, accessibility |\n| `src/` | frontend | the website source implementing `design.md`; framework choice is recorded in `state.json → frontend` |\n| `deploy.json` | deploy | build id, deployed at, live URLs |\n| `events.jsonl` | every stage, via `run-log.mjs` | append-only log of issues, user feedback, skill gaps and wins |\n| `reports/<run>.json` + `.html`, `report.html` | done (or any early stop), via `run-log.mjs` | the build report for each run; `report.html` is the latest |\n\n`state.json` is the minimum you must keep current. **`stage` is the *next* stage to run** (not the\nlast completed one), and it does **not** store the domain — the directory is the key. Because\nprovision runs in the background, its status lives in its own `provision` field rather than in\n`stage`, so prepare work can advance `stage` while provision is still settling:\n\n```json\n{\n \"tool\": \"claude-code\",\n \"stage\": \"content_model\",\n \"provision\": {\n \"status\": \"in_progress\",\n \"domain\": \"example.com\",\n \"started_at\": \"2026-10-08T10:00:00Z\"\n },\n \"updated_at\": \"2026-10-08T10:00:00Z\"\n}\n```\n\n- `provision` is **absent** until provision fires, `{\"status\":\"in_progress\", …}` while the\n background poll runs, and `{\"status\":\"completed\", …}` once `hosting_websites_list-setups` confirms\n it. `domain` is the poll target.\n- Any **instance-barrier** step (content-model push, frontend data, deploy) must read\n `provision.status == \"completed\"` — and re-verify it against the connector — before it runs. A\n prepare step ignores `provision` entirely.\n- On **resume**, `provision.status` is a hint only: re-poll `hosting_websites_list-setups` to learn\n the truth, then update the field.\n- `run` (the current run), `history` (one entry per stage or phase, with real start and end\n times), and `reports_sent` / `reports_rejected` (which copy of each report reached Hostinger) are\n written **only** by `run-log.mjs`. Leave them alone when you update `state.json` by hand.\n- The **onboarding slug** is how hPanel names the coding tool: `claude` for Claude Code, else\n `cursor`, `codex`, `vscode`, `antigravity`, `devin`. Map it from the `<tool>` you pass to\n `run-start --tool` (`state.json → run.tool`, e.g. `claude-code` → `claude`).\n\n---\n\n## Run log and build report\n\n`run-log.mjs` (next to this file) records the run while it happens and turns it into a report at the\nend. Timestamps come from the script, so they're measured, not remembered. It needs only Node.\nEvery command is `node <skill>/run-log.mjs <domain> …`, where `<skill>` is the folder this file is\nin. Write it out in full each time (a shell variable holding the command breaks in zsh):\n\n```bash\nnode <skill>/run-log.mjs <domain> run-start --tool <tool> --model <model> --skill <skill version> # check-state, first thing\nnode <skill>/run-log.mjs <domain> run-update --kind resume # check-state, once the route is agreed (or redesign / iterate)\nnode <skill>/run-log.mjs <domain> stage-start frontend # before a stage\nnode <skill>/run-log.mjs <domain> stage-end frontend ok_after_retries --note \"second framework tried\"\nnode <skill>/run-log.mjs <domain> stage-start frontend --phase shell # split stages log each phase\nnode <skill>/run-log.mjs <domain> event issue '{\"stage\":\"content_model\",\"symptom\":\"…\",\"cause\":\"…\",\"workaround\":\"…\",\"severity\":\"medium\",\"resolved\":true}'\nnode <skill>/run-log.mjs <domain> report --outcome live # done, or wherever the run stops\nnode <skill>/run-log.mjs <domain> report-sent <run> # after the report was sent (see below)\nnode <skill>/run-log.mjs <domain> report-rejected <run> # the send returned 422\nnode <skill>/run-log.mjs <domain> unsent # check-state: reports still to send\nnode <skill>/run-log.mjs <domain> run-resume --reason \"about/contact layout fix\" # follow-up in the same session\n```\n\n**Follow-ups continue the run.** When the user asks for a change after the final report in the\nsame session (a layout fix, a copy tweak, a redeploy), that work belongs to the run it follows:\ncall `run-resume`, log the feedback and the stages it re-runs as usual, then `report` again. That\nrewrites the same `reports/<run>.json` and `.html`, so you get one report that includes the follow-up,\nnot a second report that replaces `report.html`. Use `run-start` only in a new session; check-state\ndoes that.\n\n`stage-end` results: `ok`, `ok_after_retries`, `ok_with_fixups`, `failed`, `skipped`, `stopped`.\nStages: `check_state`, `refine`, `provision`, `content_model`, `design`, `frontend`, `validate`,\n`deploy`, `done` (connect has its own `connect.json`). Split stages use `--phase`: provision `fire` /\n`join`, content model `prepare` / `push`, frontend `shell` / `data`.\n\n**Forgot a `stage-start`?** Log it as soon as you notice, with roughly when the stage began:\n`stage-start frontend --started-ago 6m` (or `--started-at <ISO time>`). The same flags work on a\n`stage-end` that has no start. With neither, the script assumes the stage began when the previous one\nended. Either way the start is marked as estimated in the report. Never log a start and an end\nback to back after the work is done: that records a stage that took no time. If you already did,\ncorrect it with `stage-fix frontend --started-ago 6m`.\n\n**Log an event the moment it happens**, not at the end — details are gone by then:\n\n| Event | Log it when | Fields (required in bold) |\n|---|---|---|\n| `issue` | something fails, misbehaves, or needs a workaround — including your own mistakes | **stage**, **symptom**, cause, workaround, severity (`high`/`medium`/`low`), resolved, user_had_to_intervene |\n| `feedback` | the user corrects you, rejects something, or changes scope | **stage**, **category** (e.g. `design_rejected`, `layout_bug`, `scope_add`, `wrong_assumption`), **paraphrase** |\n| `skill_gap` | a step is dummy, missing, wrong or silent on what you needed | **skill** (path), **gap** |\n| `good` | something worked notably well or fast | **text** |\n\nParaphrase the user; don't paste long quotes, file contents, credentials or personal data. Set\n`user_had_to_intervene` when the user had to point the problem out.\n\n**Tokens** go on `report --tokens '<json>'` only if your tool tells you your own usage directly\n(`{\"source\":\"…\",\"requests\":…,\"input\":…,\"output\":…,\"cache_read\":…,\"cache_write\":…,\"est_cost_usd\":…}`).\nOtherwise leave it out and the report says `{\"source\":\"not reported\"}` (not null: the connector rejects a null `tokens`). Never read session transcripts or log\nfiles to get it.\n\nThe report is saved to `reports/<run>.json` and `.html`, and `report.html` in the workspace is the\nlatest. If a run never reaches its report (the window closed), the next `run-start` closes it as\n`abandoned`, so its log still becomes a report.\n\n**Send every report to Hostinger.** Sending is part of the flow, so don't ask first; tell the user in\none line right after the send (see `09-done`). Right after each\n`report` (follow-ups included), read `reports/<run>.json` and pass its contents, unchanged, as the\narguments of the Hostinger connector tool `hosting_headless_submit-build-report`. Then record the\nresult:\n\n- **Success:** `report-sent <run>`.\n- **422** (the report doesn't match the schema the endpoint holds): `report-rejected <run>`. Don't\n resend it.\n- **Anything else** (network error, 5xx, the tool isn't in your connector): do nothing. The next\n check-state sends it again.\n\nNever edit the JSON before sending, never add account details (the connector's login identifies the\naccount), and never let a failed send block, delay or change the run. Check-state sends any\nreports still unsent (abandoned runs, earlier failures) with `unsent`."
78
78
  },
79
79
  {
80
80
  "uri": "skill://hostinger-headless/connect/SKILL.md",
81
- "digest": "sha256:e1575edbb9e8a40d78ccbf1447cfe0f697c2332bf4a72621fbc4f5b6a190ced5",
82
- "size": 5801,
81
+ "digest": "sha256:401f2593c9f4fc19c2b090e75afdc0f53b96c3524d23df8233df1760d2e60d62",
82
+ "size": 6832,
83
83
  "mimeType": "text/markdown",
84
- "text": "---\nname: hostinger-headless-connect\ndescription: \"Connect step for the Hostinger Headless flow — verify Node, run the bootstrap (reach the Hostinger MCP server + OAuth sign-in), then route to the matching coding-tool skill under tools/ so MCP tools and skills are available before the build steps run. Triggers: connect to Hostinger, set up the Hostinger connector, start a headless build.\"\n---\n\n# Hostinger Headless Skill — connect\n\nYou are a coding agent, and this is a skill for you to execute. Follow the phases below in order.\n\nThis skill goal is to help you to connect to Hostinger via MCP. That can be done via Hostinger Connector or adding the MCP via MCP Server.\n\nWe support different coding Agentic coding tools such as Claude Code, Codex, Cursor, VS Code...and so on. \n\nYour goal is to route those tools to their specific skill inside the tools folder ensuring the tool is fully connected.\n\n\n## Phase 0 — Node (the single one prerequisite)\n\nThe Hostinger MCP server requires Node ≥ 20. Check it:\n\n```bash\nnode -v\n```\n\nIf that errors (Node not installed) or prints a version below 20, install or upgrade Node and re-check — do not try to work around it:\n\n- macOS: `brew install node` (or `nvm install 22 && nvm use 22`)\n- Linux: `nvm install 22 && nvm use 22` (or your distro's Node 20+ package)\n- Windows: `winget install OpenJS.NodeJS.LTS` (or download from nodejs.org)\n\n## Phase 1 — Run the bootstrap (deterministic)\n\nDownload the bootstrap script, then run it. It verifies the MCP server and handles sign-in, emitting one JSON event per line on stdout. Run it as a background/streaming process and relay its events to the user.\n\nThe script is safe and inspectable: it only checks the MCP server via `npx` and drives the OAuth sign-in (a browser flow with a localhost callback) — no other network calls, no filesystem writes. Read it first if your sandbox flags externally-downloaded code.\n\n```bash\n# macOS/Linux:\ncurl -fsSL -O https://raw.githubusercontent.com/hostinger/api-mcp-server/main/skills/headless/connect/bootstrap.mjs\n# Windows PowerShell:\niwr https://raw.githubusercontent.com/hostinger/api-mcp-server/main/skills/headless/connect/bootstrap.mjs -OutFile bootstrap.mjs\n\nnode bootstrap.mjs\n```\n\n### Relay these events\n\nThe script emits one JSON object per line:\n\n| Event | What to do |\n| --- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| `mcp_ok` | MCP server reachable — continue. |\n| `logged_in` | Already authenticated (env token or a previous sign-in) — continue. |\n| `awaiting_user` (`verificationUri`) | A browser window should have opened on the user's machine. Show the URL in plain prose and wait for the user to finish signing in. The OAuth callback lands on a localhost port, so the sign-in must be completed on this same machine. |\n| `success` | Bootstrap done — continue to Phase 2. |\n| `mcp_unreachable` / `login_failed` (with `detail`) | Stop and show the user the `detail`. Do not improvise a parallel setup by hand. |\n\nIf the environment has a `HOSTINGER_API_TOKEN` set (for example CI), the bootstrap reports `logged_in` immediately — no browser needed.\n\n### Sign in or out manually\n\nThe bootstrap runs the sign-in for you. To run it directly, or to switch accounts, use the MCP's own commands. `--login` opens the browser to the Hostinger sign-in page, waits for the localhost callback, stores the credentials and exits. `--logout` revokes the stored credentials, so the next sign-in opens the browser again.\n\n```bash\n# With a global install (npm install -g @hostinger/mcp):\nhostinger-api-mcp --login\nhostinger-api-mcp --logout\n\n# Without a global install:\nnpx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --login\nnpx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --logout\n```\n\nCredentials live in `~/.config/hostinger-mcp/credentials.json` (macOS/Linux) or `%APPDATA%\\hostinger-mcp\\credentials.json` (Windows) and are shared by every Hostinger MCP binary. OAuth works only in stdio mode; `--http` needs `HOSTINGER_API_TOKEN`.\n\n## Phase 2 - Route to the specific tool instructions\n\n| Tool | Route |\n|-------------|---------------------------------|\n| Claude Code | `connect/tools/claude-code.md` |\n| Codex | `connect/tools/codex.md` |\n| Cursor | `connect/tools/cursor.md` |\n| VS Code | `connect/tools/vscode.md` |\n| Antigravity | `connect/tools/antigravity.md` |\n| Devin | `connect/tools/devin.md` |\n\nIf the user's tool isn't listed yet, connect it the generic way (register the Hostinger MCP server\nfor that tool, or drive `npx --yes --package=@hostinger/mcp@latest hostinger-api-mcp` over stdio) and continue.\n\n## Phase 3 - Continue to Headless Hosting steps\n\nAfter evrything is verified and you have access to the Hostinger MCP Server and the skills then you can go back to `headless/SKILL.md` and follow the steps section to start the process.\n\n\n## Reference Sources\n\n- https://mcpservers.org/servers/hostinger/api-mcp-server\n- https://www.hostinger.com/connector\n"
84
+ "text": "---\nname: hostinger-headless-connect\ndescription: \"Connect step for the Hostinger Headless flow — verify Node, run the bootstrap (reach the Hostinger MCP server + OAuth sign-in), then route to the matching coding-tool skill under tools/ so MCP tools and skills are available before the build steps run. Triggers: connect to Hostinger, set up the Hostinger connector, start a headless build.\"\n---\n\n# Hostinger Headless Skill — connect\n\nYou are a coding agent, and this is a skill for you to execute. Follow the phases below in order.\n\nThis skill goal is to help you to connect to Hostinger via MCP. That can be done via Hostinger Connector or adding the MCP via MCP Server.\n\nWe support different coding Agentic coding tools such as Claude Code, Codex, Cursor, VS Code...and so on. \n\nYour goal is to route those tools to their specific skill inside the tools folder ensuring the tool is fully connected.\n\n\n## Phase 0 — Node (the single one prerequisite)\n\nThe Hostinger MCP server requires Node ≥ 20. Check it:\n\n```bash\nnode -v\n```\n\nIf that errors (Node not installed) or prints a version below 20, install or upgrade Node and re-check — do not try to work around it:\n\n- macOS: `brew install node` (or `nvm install 22 && nvm use 22`)\n- Linux: `nvm install 22 && nvm use 22` (or your distro's Node 20+ package)\n- Windows: `winget install OpenJS.NodeJS.LTS` (or download from nodejs.org)\n\n## Phase 1 — Run the bootstrap (deterministic)\n\nDownload the bootstrap script, then run it. It verifies the MCP server and handles sign-in, emitting one JSON event per line on stdout. Run it as a background/streaming process and relay its events to the user.\n\nThe script is safe and inspectable: it only checks the MCP server via `npx` and drives the OAuth sign-in (a browser flow with a localhost callback) — no other network calls, no filesystem writes. Read it first if your sandbox flags externally-downloaded code.\n\n```bash\n# macOS/Linux:\ncurl -fsSL -O https://raw.githubusercontent.com/hostinger/api-mcp-server/main/skills/headless/connect/bootstrap.mjs\n# Windows PowerShell:\niwr https://raw.githubusercontent.com/hostinger/api-mcp-server/main/skills/headless/connect/bootstrap.mjs -OutFile bootstrap.mjs\n\nnode bootstrap.mjs\n```\n\n### Relay these events\n\nThe script emits one JSON object per line:\n\n| Event | What to do |\n| --- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| `mcp_ok` | MCP server reachable — continue. |\n| `logged_in` | Already authenticated (env token or a previous sign-in) — continue. |\n| `awaiting_user` (`verificationUri`) | A browser window should have opened on the user's machine. Show the URL in plain prose and wait for the user to finish signing in. The OAuth callback lands on a localhost port, so the sign-in must be completed on this same machine. |\n| `success` | Bootstrap done — continue to Phase 2. |\n| `mcp_unreachable` / `login_failed` (with `detail`) | Stop and show the user the `detail`. Do not improvise a parallel setup by hand. |\n\nIf the environment has a `HOSTINGER_API_TOKEN` set (for example CI), the bootstrap reports `logged_in` immediately — no browser needed.\n\n### Sign in or out manually\n\nThe bootstrap runs the sign-in for you. To run it directly, or to switch accounts, use the MCP's own commands. `--login` opens the browser to the Hostinger sign-in page, waits for the localhost callback, stores the credentials and exits. `--logout` revokes the stored credentials, so the next sign-in opens the browser again.\n\n```bash\n# With a global install (npm install -g @hostinger/mcp):\nhostinger-api-mcp --login\nhostinger-api-mcp --logout\n\n# Without a global install:\nnpx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --login\nnpx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --logout\n```\n\nCredentials live in `~/.config/hostinger-mcp/credentials.json` (macOS/Linux) or `%APPDATA%\\hostinger-mcp\\credentials.json` (Windows) and are shared by every Hostinger MCP binary. OAuth works only in stdio mode; `--http` needs `HOSTINGER_API_TOKEN`.\n\n## Phase 2 - Route to the specific tool instructions\n\n| Tool | Route |\n|-------------|---------------------------------|\n| Claude Code | `connect/tools/claude-code.md` |\n| Codex | `connect/tools/codex.md` |\n| Cursor | `connect/tools/cursor.md` |\n| VS Code | `connect/tools/vscode.md` |\n| Antigravity | `connect/tools/antigravity.md` |\n| Devin | `connect/tools/devin.md` |\n\nIf the user's tool isn't listed yet, connect it the generic way (register the Hostinger MCP server\nfor that tool, or drive `npx --yes --package=@hostinger/mcp@latest hostinger-api-mcp` over stdio) and continue.\n\n## Phase 3 - Write `connect.json`\n\nOnce the tool is connected, write `~/.hostinger/<domain>/connect.json` (create the folder if it is\nmissing) in exactly this shape:\n\n```json\n{ \"tool\": \"claude-code\", \"mcp\": \"hostinger-api-mcp 3.8.0\", \"auth\": \"oauth_cached\",\n \"order_id\": null, \"plan\": null, \"connected_at\": \"2026-10-28T10:46:12Z\" }\n```\n\n- `tool`: the `<tool>` from the Phase 2 route (`claude-code`, `codex`, `cursor`, `vscode`,\n `antigravity`, `devin`, or a short lowercase name for an unlisted tool), the same value later\n passed to `run-start --tool`.\n- `mcp`: one string, `hostinger-api-mcp <version>`, with the `version` from the `mcp_ok` event.\n Never an object.\n- `auth`: the `method` from the `logged_in` event (`api_token`, `oauth_cached` or `oauth`).\n- `order_id`, `plan`: the active hosting plan's order id and name when you looked them up, else `null`.\n- `connected_at`: the current time in UTC, ISO 8601 to the second.\n\nExtra keys are allowed, but keep these six as above: the build report reads `tool` and `mcp`.\n\n## Phase 4 - Continue to Headless Hosting steps\n\nAfter evrything is verified and you have access to the Hostinger MCP Server and the skills then you can go back to `headless/SKILL.md` and follow the steps section to start the process.\n\n\n## Reference Sources\n\n- https://mcpservers.org/servers/hostinger/api-mcp-server\n- https://www.hostinger.com/connector\n"
85
85
  },
86
86
  {
87
87
  "uri": "skill://hostinger-headless/connect/bootstrap.mjs",
@@ -141,10 +141,10 @@ const skills: Skill[] = [
141
141
  },
142
142
  {
143
143
  "uri": "skill://hostinger-headless/run-log.mjs",
144
- "digest": "sha256:83ac43a6f414f431276ee3caa53eba68f983730d2b1095e1882acc665a7ba920",
145
- "size": 38936,
144
+ "digest": "sha256:fe0bf3d9723c7e28e5b47670f07aa04a013e31cd818823a2369ec879eb21a302",
145
+ "size": 39641,
146
146
  "mimeType": "text/plain",
147
- "text": "#!/usr/bin/env node\n// Hostinger Headless run log: records a build as it happens and writes reports/<run>.json + .html.\n//\n// node run-log.mjs <domain> run-start [--kind build|resume|redesign|iterate] [--tool <tool>] [--model <model>] [--skill <version>]\n// node run-log.mjs <domain> run-update [--kind …] [--model …] [--skill …]\n// node run-log.mjs <domain> run-resume [--reason \"<text>\"]\n// node run-log.mjs <domain> stage-start <stage> [--phase <phase>] [--started-ago 6m | --started-at <ISO time>]\n// node run-log.mjs <domain> stage-end <stage> <result> [--phase <phase>] [--note \"<text>\"] [--started-ago 6m | --started-at <ISO time>]\n// node run-log.mjs <domain> stage-fix <stage> [--phase <phase>] (--started-ago 6m | --started-at <ISO time>)\n// node run-log.mjs <domain> event <issue|feedback|skill_gap|good> '<json>'\n// node run-log.mjs <domain> report [--outcome live|stopped|failed|abandoned] [--tokens '<json>']\n// node run-log.mjs <domain> unsent\n// node run-log.mjs <domain> report-sent [<run>]\n// node run-log.mjs <domain> report-rejected [<run>]\n\nimport { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst STAGES = ['connect', 'check_state', 'refine', 'provision', 'content_model', 'design', 'frontend', 'validate', 'deploy', 'done'];\nconst RESULTS = ['ok', 'ok_after_retries', 'ok_with_fixups', 'failed', 'skipped', 'stopped'];\nconst OUTCOMES = ['live', 'stopped', 'failed', 'abandoned'];\nconst EVENT_FIELDS = {\n issue: { required: ['stage', 'symptom'], optional: ['cause', 'workaround', 'severity', 'resolved', 'user_had_to_intervene'] },\n feedback: { required: ['stage', 'category', 'paraphrase'], optional: [] },\n skill_gap: { required: ['skill', 'gap'], optional: ['stage'] },\n good: { required: ['text'], optional: ['stage'] },\n};\nconst EVENT_LISTS = { issue: 'issues', feedback: 'user_feedback', skill_gap: 'skill_gaps', good: 'what_went_well' };\n\nconst here = dirname(fileURLToPath(import.meta.url));\nconst now = () => new Date().toISOString().replace(/\\.\\d{3}Z$/, 'Z');\n// A start logged late is kept but marked estimated, so the report can tell measured times from remembered ones.\nfunction lateStart(flags, run) {\n let at = null;\n if (typeof flags['started-at'] === 'string') {\n 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`);\n at = Date.parse(flags['started-at']);\n } else if (typeof flags['started-ago'] === 'string') {\n const m = flags['started-ago'].match(/^(\\d+(?:\\.\\d+)?)\\s*([smh])$/);\n if (!m) fail(`--started-ago \"${flags['started-ago']}\" should look like 90s, 6m or 1h`);\n at = Date.now() - Number(m[1]) * { s: 1e3, m: 6e4, h: 36e5 }[m[2]];\n }\n if (at === null) return null;\n const clamped = Math.min(Math.max(at, Date.parse(run.started_at)), Date.now());\n return new Date(clamped).toISOString().replace(/\\.\\d{3}Z$/, 'Z');\n}\n\nconst fail = (message) => {\n console.error(`run-log: ${message}`);\n process.exit(1);\n};\n\nfunction parseArgs(argv) {\n const positional = [];\n const flags = {};\n for (let i = 0; i < argv.length; i += 1) {\n if (argv[i].startsWith('--')) {\n const key = argv[i].slice(2);\n const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[(i += 1)] : true;\n flags[key] = value;\n } else positional.push(argv[i]);\n }\n return { positional, flags };\n}\n\nconst readJson = (file, fallback) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : fallback);\nconst writeJson = (file, value) => writeFileSync(file, `${JSON.stringify(value, null, 2)}\\n`);\n\nfunction workspace(domain) {\n if (!domain || domain.startsWith('-')) fail('first argument must be the domain');\n const dir = join(homedir(), '.hostinger', domain);\n if (!existsSync(dir)) fail(`no workspace at ${dir}; connect creates it`);\n return {\n dir,\n domain,\n statePath: join(dir, 'state.json'),\n eventsPath: join(dir, 'events.jsonl'),\n reportsDir: join(dir, 'reports'),\n };\n}\n\nconst loadState = (ws) => readJson(ws.statePath, {});\nfunction saveState(ws, state) {\n state.updated_at = now();\n writeJson(ws.statePath, state);\n}\n\nfunction currentRun(ws, state) {\n if (!state.run?.id) fail('no run started; call run-start first (check-state does this)');\n return state.run;\n}\n\nfunction readEvents(ws) {\n if (!existsSync(ws.eventsPath)) return [];\n return readFileSync(ws.eventsPath, 'utf8')\n .split('\\n')\n .filter(Boolean)\n .map((line) => JSON.parse(line));\n}\n\nfunction runStart(ws, flags) {\n const state = loadState(ws);\n // A previous run that never reached its report (closed window, crash) is closed out first, so\n // its log still turns into a report.\n if (state.run?.id && !state.run.reported_at) {\n buildReport(ws, { outcome: 'abandoned' });\n console.log(`closed previous run ${state.run.id} as abandoned`);\n }\n const fresh = loadState(ws);\n const previous = fresh.run ?? {};\n const startedAt = now();\n const connect = readJson(join(ws.dir, 'connect.json'), {});\n // Run ids are the start time; a second run in the same second gets a suffix.\n const taken = new Set([\n ...(fresh.history ?? []).map((h) => h.run),\n ...(existsSync(ws.reportsDir) ? readdirSync(ws.reportsDir).map((f) => f.replace(/\\.(json|html)$/, '')) : []),\n ]);\n const base = startedAt.replace(/:/g, '-');\n let id = base;\n for (let n = 2; taken.has(id) || id === fresh.run?.id; n += 1) id = `${base}-${n}`;\n const kind = flags.kind ?? 'build';\n if (!['build', 'resume', 'redesign', 'iterate'].includes(kind)) fail(`unknown kind \"${kind}\"; one of build, resume, redesign, iterate`);\n fresh.run = {\n id,\n kind,\n started_at: startedAt,\n tool: flags.tool ?? connect.tool ?? fresh.tool ?? previous.tool ?? null,\n model: flags.model ?? previous.model ?? null,\n skill: flags.skill ?? previous.skill ?? null,\n };\n fresh.history ??= [];\n saveState(ws, fresh);\n console.log(`run ${fresh.run.id} started`);\n}\n\nfunction runUpdate(ws, flags) {\n const state = loadState(ws);\n const run = currentRun(ws, state);\n for (const key of ['kind', 'tool', 'model', 'skill']) if (typeof flags[key] === 'string') run[key] = flags[key];\n if (!['build', 'resume', 'redesign', 'iterate'].includes(run.kind)) fail(`unknown kind \"${run.kind}\"; one of build, resume, redesign, iterate`);\n saveState(ws, state);\n console.log(`run ${run.id} updated`);\n}\n\n// A follow-up in the same session (the user asks for a fix right after the final report) belongs\n// to the run it follows: reopen it, so the next `report` rewrites that run's report instead of\n// starting a second one.\nfunction runResume(ws, flags) {\n const state = loadState(ws);\n const run = currentRun(ws, state);\n if (!run.reported_at) {\n console.log(`run ${run.id} is still open; nothing to resume`);\n return;\n }\n run.follow_ups = [\n ...(run.follow_ups ?? []),\n { resumed_at: now(), after_report_at: run.reported_at, reason: typeof flags.reason === 'string' ? flags.reason : null },\n ];\n delete run.reported_at;\n saveState(ws, state);\n console.log(`run ${run.id} resumed (follow-up ${run.follow_ups.length})`);\n}\n\nfunction stageStart(ws, [stage], flags) {\n if (!STAGES.includes(stage)) fail(`unknown stage \"${stage}\"; one of ${STAGES.join(', ')}`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n state.history ??= [];\n const phase = flags.phase ?? null;\n const open = state.history.find((h) => h.run === run.id && h.stage === stage && h.phase === phase && !h.ended_at);\n if (open) {\n console.log(`${stage}${phase ? `/${phase}` : ''} already started at ${open.started_at}`);\n return;\n }\n const late = lateStart(flags, run);\n state.history.push({ run: run.id, stage, phase, started_at: late ?? now(), ended_at: null, result: null, note: null, ...(late ? { start_estimated: true } : {}) });\n saveState(ws, state);\n console.log(`${stage}${phase ? `/${phase}` : ''} started${late ? ` (estimated start ${late})` : ''}`);\n}\n\nfunction stageEnd(ws, [stage, result], flags) {\n if (!STAGES.includes(stage)) fail(`unknown stage \"${stage}\"`);\n if (!RESULTS.includes(result)) fail(`unknown result \"${result}\"; one of ${RESULTS.join(', ')}`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n state.history ??= [];\n const phase = flags.phase ?? null;\n let entry = [...state.history].reverse().find((h) => h.run === run.id && h.stage === stage && h.phase === phase && !h.ended_at);\n if (!entry) {\n // Missed stage-start: use the remembered start if given, else the end of the previous stage; either way estimated.\n const runEntries = state.history.filter((h) => h.run === run.id);\n const lastEnd = runEntries.map((h) => h.ended_at).filter(Boolean).sort().at(-1);\n const late = lateStart(flags, run);\n entry = { run: run.id, stage, phase, started_at: late ?? lastEnd ?? run.started_at, ended_at: null, result: null, note: null, start_estimated: true };\n state.history.push(entry);\n 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'}`);\n } else if (flags['started-at'] || flags['started-ago']) {\n entry.started_at = lateStart(flags, run);\n entry.start_estimated = true;\n }\n entry.ended_at = now();\n entry.result = result;\n if (typeof flags.note === 'string') entry.note = flags.note;\n saveState(ws, state);\n console.log(`${stage}${phase ? `/${phase}` : ''} ended: ${result}`);\n}\n\nfunction stageFix(ws, [stage], flags) {\n if (!STAGES.includes(stage)) fail(`unknown stage \"${stage}\"`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n const phase = flags.phase ?? null;\n const entry = [...(state.history ?? [])].reverse().find((h) => h.run === run.id && h.stage === stage && h.phase === phase);\n if (!entry) fail(`${stage}${phase ? `/${phase}` : ''} was never logged in this run; use stage-start with --started-ago instead`);\n const late = lateStart(flags, run);\n if (!late) fail('stage-fix needs --started-ago (e.g. 6m) or --started-at <ISO time>');\n if (entry.ended_at && late > entry.ended_at) fail(`the new start ${late} is after the stage ended (${entry.ended_at})`);\n entry.started_at = late;\n entry.start_estimated = true;\n saveState(ws, state);\n console.log(`${stage}${phase ? `/${phase}` : ''} start set to ${late} (estimated)`);\n}\n\nfunction event(ws, [type, raw]) {\n const spec = EVENT_FIELDS[type];\n if (!spec) fail(`unknown event type \"${type}\"; one of ${Object.keys(EVENT_FIELDS).join(', ')}`);\n let data;\n try {\n data = JSON.parse(raw ?? '');\n } catch {\n fail(`event ${type} needs a JSON object, e.g. '${JSON.stringify(Object.fromEntries(spec.required.map((k) => [k, '…'])))}'`);\n }\n const missing = spec.required.filter((k) => data[k] === undefined || data[k] === '');\n if (missing.length) fail(`event ${type} is missing ${missing.join(', ')}`);\n const unknown = Object.keys(data).filter((k) => !spec.required.includes(k) && !spec.optional.includes(k));\n if (unknown.length) fail(`event ${type} has unknown fields ${unknown.join(', ')}`);\n if (type === 'issue') {\n data.severity ??= 'medium';\n if (!['high', 'medium', 'low'].includes(data.severity)) fail('issue severity must be high, medium or low');\n data.resolved ??= false;\n data.user_had_to_intervene ??= false;\n }\n // A bad line in events.jsonl would fail every later report and run-start.\n validate({ ...data, logged_at: now() }, eventSchema(type), `event ${type}`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n appendFileSync(ws.eventsPath, `${JSON.stringify({ ts: now(), run: run.id, type, ...data })}\\n`);\n console.log(`${type} logged`);\n}\n\nconst minutesBetween = (a, b) => (a && b ? Math.round(((Date.parse(b) - Date.parse(a)) / 60000) * 10) / 10 : null);\n\nfunction frontendStack(dir) {\n // Framework is recorded in state.json by the frontend step.\n const state = readJson(join(dir, 'state.json'), {});\n const fe = state.frontend ?? {};\n const framework = fe.framework ?? fe.stack ?? null;\n if (framework) return fe.rendering ? `${framework} (${fe.rendering})` : framework;\n // Fallback: an older build may have a design.md with a \"## Stack\" heading.\n const design = existsSync(join(dir, 'design.md')) ? readFileSync(join(dir, 'design.md'), 'utf8') : '';\n return design.match(/## Stack\\s*\\n\\s*\\*\\*([^*]+)\\*\\*/)?.[1] ?? null;\n}\n\nfunction buildReport(ws, { outcome, tokens } = {}) {\n const state = loadState(ws);\n const run = currentRun(ws, state);\n const site = readJson(join(ws.dir, 'site.json'), {});\n const deploy = readJson(join(ws.dir, 'deploy.json'), {});\n const connect = readJson(join(ws.dir, 'connect.json'), {});\n const history = (state.history ?? []).filter((h) => h.run === run.id);\n // Lines logged before events were checked on write may not match the schema.\n const dropped = [];\n const events = readEvents(ws)\n .filter((e) => e.run === run.id)\n .filter((e) => {\n const { ts, run: _run, type, ...rest } = e;\n const problem = schemaError({ ...rest, logged_at: ts }, eventSchema(type), 'event');\n if (problem) dropped.push({ stage: typeof e.stage === 'string' ? e.stage : 'unknown', phase: null, problem: `left out a malformed ${type} event (${problem})` });\n return !problem;\n });\n const resolvedOutcome = outcome ?? (history.some((h) => h.stage === 'deploy' && h.result?.startsWith('ok')) ? 'live' : 'stopped');\n // A run ends at its last logged activity, so re-running `report` later (after fixing warnings, or\n // when the next run-start closes an abandoned run) doesn't stretch it to \"now\". Only a stage still\n // open on a live run keeps the clock running.\n const lastActivity = [run.started_at, ...history.flatMap((h) => [h.started_at, h.ended_at]), ...events.map((e) => e.ts)]\n .filter(Boolean)\n .sort()\n .at(-1);\n const stillOpen = resolvedOutcome !== 'abandoned' && history.some((h) => !h.ended_at);\n const endedAt = stillOpen ? now() : lastActivity;\n const stages = history.map((h) => ({ ...h, minutes: minutesBetween(h.started_at, h.ended_at ?? endedAt) }));\n const gaps = [...loggingGaps(stages, run, resolvedOutcome), ...dropped];\n const strip = ({ ts, run: _run, type: _type, ...rest }) => ({ ...rest, logged_at: ts });\n const liveStage = stages.find((s) => s.stage === 'deploy' && s.result?.startsWith('ok'));\n if (!OUTCOMES.includes(resolvedOutcome)) fail(`unknown outcome \"${resolvedOutcome}\"; one of ${OUTCOMES.join(', ')}`);\n\n const previous = existsSync(ws.reportsDir)\n ? readdirSync(ws.reportsDir)\n .filter((f) => f.endsWith('.json') && f !== `${run.id}.json`)\n .map((f) => readJson(join(ws.reportsDir, f), {}))\n .sort((a, b) => `${a.timing?.started_at}${a.run?.id}`.localeCompare(`${b.timing?.started_at}${b.run?.id}`))\n .map((r) => ({ run: r.run?.id, kind: r.run?.kind, outcome: r.info?.outcome, minutes: r.timing?.minutes_total, html: `${r.run?.id}.html` }))\n : [];\n\n const report = {\n schema_version: '1',\n generated_at: now(),\n // Intent, not delivery: delivery is tracked in state.json.\n consent: { sent: true, note: 'Sent to Hostinger automatically at the end of the run.' },\n run: { id: run.id, kind: run.kind, skill: run.skill, follow_ups: run.follow_ups ?? [] },\n info: {\n domain: ws.domain,\n tool: run.tool ?? connect.tool ?? null,\n model: run.model ?? null,\n os: process.platform,\n node: process.version,\n mcp: connect.mcp ?? null,\n backend: site.backend ?? null,\n frontend: frontendStack(ws.dir),\n outcome: resolvedOutcome,\n stopped_at: resolvedOutcome === 'live' ? null : stoppedAt(stages, state),\n live_url: resolvedOutcome === 'live' ? deploy.urls?.site ?? site.urls?.site ?? null : null,\n admin_url: site.urls?.wp_admin ?? null,\n },\n timing: {\n started_at: run.started_at,\n ended_at: endedAt,\n live_at: liveStage?.ended_at ?? null,\n minutes_total: minutesBetween(run.started_at, endedAt),\n minutes_to_live: liveStage ? minutesBetween(run.started_at, liveStage.ended_at) : null,\n stages: stages.map(({ run: _run, ...s }) => s),\n },\n // Not null: the MCP layer treats a null required field as missing, and the API reads {} as an array.\n tokens: tokens ?? { source: 'not reported' },\n issues: events.filter((e) => e.type === 'issue').map(strip),\n user_feedback: events.filter((e) => e.type === 'feedback').map(strip),\n skill_gaps: events.filter((e) => e.type === 'skill_gap').map(strip),\n what_went_well: events.filter((e) => e.type === 'good').map(strip),\n logging_gaps: gaps,\n previous_runs: previous,\n };\n\n validate(report, reportSchema());\n mkdirSync(ws.reportsDir, { recursive: true });\n writeJson(join(ws.reportsDir, `${run.id}.json`), report);\n writeFileSync(join(ws.reportsDir, `${run.id}.html`), renderHtml(report));\n copyFileSync(join(ws.reportsDir, `${run.id}.html`), join(ws.dir, 'report.html'));\n\n const fresh = loadState(ws);\n fresh.run.reported_at = now();\n saveState(ws, fresh);\n for (const g of gaps) console.log(`warning: ${g.stage}${g.phase ? `/${g.phase}` : ''}: ${g.problem}`);\n return join(ws.dir, 'report.html');\n}\n\n// A report counts as sent only while the sent copy's generated_at matches the file.\nfunction reportFile(ws, runId) {\n const id = runId ?? currentRun(ws, loadState(ws)).id;\n const file = join(ws.reportsDir, `${id}.json`);\n if (!existsSync(file)) fail(`no report for run ${id} at ${file}`);\n return { id, file, generatedAt: readJson(file, {}).generated_at };\n}\n\nfunction unsent(ws) {\n const state = loadState(ws);\n const files = existsSync(ws.reportsDir) ? readdirSync(ws.reportsDir).filter((f) => f.endsWith('.json')).sort() : [];\n // Older reports promised \"nothing is uploaded\".\n const pending = files\n .map((f) => {\n const report = readJson(join(ws.reportsDir, f), {});\n return { id: f.replace(/\\.json$/, ''), file: join(ws.reportsDir, f), generatedAt: report.generated_at, consent: report.consent };\n })\n .filter(({ consent }) => consent?.sent !== false)\n .filter(({ id, generatedAt }) => state.reports_sent?.[id] !== generatedAt && state.reports_rejected?.[id] !== generatedAt);\n if (!pending.length) console.log('no unsent reports');\n for (const { id, file } of pending) console.log(`${id} ${file}`);\n}\n\nfunction reportSent(ws, [runId]) {\n const { id, generatedAt } = reportFile(ws, runId);\n const state = loadState(ws);\n state.reports_sent = { ...state.reports_sent, [id]: generatedAt };\n saveState(ws, state);\n console.log(`report ${id} marked sent`);\n}\n\n// A 422 is schema drift, so resending won't help.\nfunction reportRejected(ws, [runId]) {\n const { id, generatedAt } = reportFile(ws, runId);\n const state = loadState(ws);\n state.reports_rejected = { ...state.reports_rejected, [id]: generatedAt };\n saveState(ws, state);\n const run = currentRun(ws, state);\n const symptom = `build report ${id} rejected with 422 by hosting_headless_submit-build-report`;\n 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`);\n console.log(`report ${id} marked rejected; issue logged in run ${run.id}`);\n}\n\n// Gaps are printed as warnings by `report`, so guesses never pass as measurements.\nconst EXPECTED_FOR_BUILD = ['check_state', 'refine', 'provision', 'content_model', 'design', 'frontend', 'validate', 'deploy'];\nfunction loggingGaps(stages, run, outcome) {\n const gaps = [];\n for (const s of stages) {\n if (!s.ended_at) gaps.push({ stage: s.stage, phase: s.phase, problem: 'started but never ended' });\n else if (s.start_estimated) gaps.push({ stage: s.stage, phase: s.phase, problem: 'start time estimated, not measured' });\n 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' });\n }\n if (run.kind === 'build' && outcome === 'live') {\n for (const stage of EXPECTED_FOR_BUILD) {\n if (!stages.some((s) => s.stage === stage)) gaps.push({ stage, phase: null, problem: 'never logged, though the site went live' });\n }\n }\n return gaps;\n}\n\nfunction stoppedAt(stages, state) {\n const stuck = [...stages].reverse().find((s) => !s.ended_at || s.result === 'failed' || s.result === 'stopped');\n return stuck?.stage ?? state.stage ?? null;\n}\n\n// Minimal JSON Schema subset, so reports from different tools share one shape without a dependency.\nconst reportSchema = () => readJson(join(here, 'report.schema.json'), null);\nconst eventSchema = (type) => reportSchema()?.properties?.[EVENT_LISTS[type]]?.items ?? null;\n\nfunction schemaError(value, schema, path = 'report') {\n if (!schema) return null;\n const types = [].concat(schema.type ?? []);\n const actual = value === null ? 'null' : Array.isArray(value) ? 'array' : Number.isInteger(value) ? 'integer' : typeof value;\n if (types.length && !types.includes(actual) && !(actual === 'integer' && types.includes('number'))) {\n return `${path} should be ${types.join(' or ')}, got ${actual}`;\n }\n if (schema.enum && !schema.enum.includes(value)) return `${path} should be one of ${schema.enum.join(', ')}`;\n if (actual === 'object') {\n for (const key of schema.required ?? []) if (!(key in value)) return `${path}.${key} is missing`;\n for (const [key, sub] of Object.entries(schema.properties ?? {})) {\n const error = key in value ? schemaError(value[key], sub, `${path}.${key}`) : null;\n if (error) return error;\n }\n }\n if (actual === 'array' && schema.items) {\n for (const [i, item] of value.entries()) {\n const error = schemaError(item, schema.items, `${path}[${i}]`);\n if (error) return error;\n }\n }\n return null;\n}\n\nfunction validate(value, schema, path) {\n const error = schemaError(value, schema, path);\n if (error) fail(error);\n}\n\nconst esc = (v) => String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\nconst label = (s) => String(s ?? '').replace(/_/g, ' ');\nconst time = (iso) => (iso ? iso.slice(11, 19) : '—');\nconst fmt = (n) => (typeof n === 'number' ? n.toLocaleString('en-US') : '—');\n\nfunction gantt(report) {\n const stages = report.timing.stages;\n if (!stages.length) return '<p class=\"muted\">No stages were logged for this run.</p>';\n const t0 = Date.parse(report.timing.started_at);\n const at = (iso) => (Date.parse(iso) - t0) / 60000;\n const total = Math.max(report.timing.minutes_total ?? 1, ...stages.map((s) => at(s.ended_at ?? report.timing.ended_at)), 1);\n const step = [1, 2, 5, 10, 15, 30, 60, 120, 240].find((s) => total / s <= 8) ?? 480;\n const span = Math.ceil(total / step) * step;\n const W = 760;\n const LABEL = 160;\n const ROW = 32;\n const TOP = 32;\n const H = TOP + ROW * stages.length + 32;\n const x = (m) => LABEL + ((W - LABEL - 40) * m) / span;\n 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>`];\n for (let m = 0; m <= span; m += step) {\n 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>`);\n }\n for (const f of report.run.follow_ups ?? []) {\n const fx = x(at(f.resumed_at)).toFixed(1);\n 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>`);\n }\n if (report.timing.live_at) {\n const lx = x(at(report.timing.live_at)).toFixed(1);\n 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>`);\n }\n stages.forEach((s, i) => {\n const y = TOP + i * ROW;\n const a = at(s.started_at);\n const b = at(s.ended_at ?? report.timing.ended_at);\n const width = Math.max(x(b) - x(a), 4);\n const name = `${label(s.stage)}${s.phase ? ` · ${s.phase}` : ''}`;\n const tip = [name, `${s.minutes} min`, s.result ? label(s.result) : 'not finished', s.start_estimated ? 'start estimated' : null, s.note].filter(Boolean).join(' · ');\n const failed = s.result === 'failed' || s.result === 'stopped' || !s.ended_at;\n const fill = failed ? 'url(#hatch)' : s.start_estimated ? 'none' : 'var(--series-1)';\n out.push(`<text x=\"${LABEL - 12}\" y=\"${(y + ROW / 2 + 4).toFixed(1)}\" class=\"row-label\" text-anchor=\"end\">${esc(name)}</text>`);\n 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>`);\n });\n 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>');\n return out.join('');\n}\n\nfunction renderHtml(r) {\n 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>`;\n const stepped = r.issues.filter((i) => i.user_had_to_intervene).length;\n const rejected = r.user_feedback.filter((f) => f.category === 'design_rejected').length;\n const t = r.tokens && ['input', 'output', 'cache_read', 'cache_write', 'est_cost_usd'].some((k) => r.tokens[k] != null) ? r.tokens : null;\n const tiles = [\n r.timing.minutes_to_live != null\n ? tile('Time to live', `${r.timing.minutes_to_live} min`, 'start of run → deploy finished')\n : tile('Outcome', label(r.info.outcome), r.info.stopped_at ? `stopped at ${label(r.info.stopped_at)}` : ''),\n tile('Run length', `${r.timing.minutes_total} min`, `${r.timing.stages.length} stage entries`),\n tile('Issues', String(r.issues.length), `${stepped} needed the user to step in`),\n tile('User feedback', String(r.user_feedback.length), rejected ? `${rejected} design rejections` : 'corrections and requests'),\n 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'),\n ].join('');\n const SEV = { high: ['●', 'critical', 'High'], medium: ['▲', 'serious', 'Medium'], low: ['■', 'warning', 'Low'] };\n const order = ['high', 'medium', 'low'];\n const issues = [...r.issues]\n .sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity))\n .map((i) => {\n const [icon, cls, name] = SEV[i.severity];\n 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>`;\n })\n .join('');\n const list = (items, render, empty) => (items.length ? `<ul>${items.map(render).join('')}</ul>` : `<p class=\"muted\">${empty}</p>`);\n const stageRows = r.timing.stages\n .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>`)\n .join('');\n const details = [\n ['Domain', r.info.domain],\n ['Outcome', [label(r.info.outcome), r.info.live_url].filter(Boolean).join(' · ')],\n ['Run', `${r.run.id} · ${r.run.kind}`],\n ['Tool / model', [r.info.tool, r.info.model].filter(Boolean).join(' · ') || '—'],\n ['Stack', [r.info.backend, r.info.frontend].filter(Boolean).join(' + ') || '—'],\n ['Environment', `${r.info.os} · Node ${r.info.node}${r.info.mcp ? ` · ${r.info.mcp}` : ''}`],\n ['Skill version', r.run.skill ?? '—'],\n ...(r.run.follow_ups?.length\n ? [['Follow-ups', r.run.follow_ups.map((f) => `resumed ${time(f.resumed_at)}${f.reason ? ` — ${f.reason}` : ''}`).join('; ')]]\n : []),\n ]\n .map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`)\n .join('');\n const tokenTable = t\n ? `<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>`\n : '';\n const previous = r.previous_runs.length\n ? `<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>`\n : '';\n\n return `<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Build report — ${esc(r.info.domain)}</title>\n<style>\n.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; }\n@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; } }\n* { box-sizing: border-box; }\nbody { margin:0; background:var(--surface-0); color:var(--text-primary); font:15px/1.5 system-ui,-apple-system,\"Segoe UI\",sans-serif; }\nmain { max-width:1080px; margin:0 auto; padding:32px 24px 64px; }\nh1 { font-size:28px; line-height:1.2; margin:0 0 4px; } h2 { font-size:18px; margin:0 0 16px; }\na { color:var(--series-1); }\n.lede { color:var(--text-secondary); margin:0 0 24px; }\nsection { background:var(--surface-1); border:1px solid var(--border); border-radius:12px; padding:24px; margin-bottom:24px; }\n.tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:16px; margin-bottom:24px; }\n.tile { background:var(--surface-1); border:1px solid var(--border); border-radius:12px; padding:16px; }\n.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; }\ndl { 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; }\n.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; }\n.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; }\n.bar { outline:none; } .bar:hover rect:last-child, .bar:focus rect:last-child { stroke:var(--text-primary); stroke-width:2; }\n.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; }\n.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); }\n#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; }\ntable { 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; }\ntd.num,th.num { text-align:right; font-variant-numeric:tabular-nums; } .table-wrap { overflow-x:auto; }\n.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); }\n.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; }\n.cols { display:grid; grid-template-columns:repeat(auto-fit,minmax(320px,1fr)); gap:24px; margin-bottom:24px; } .cols section { margin:0; }\nul { margin:0; padding-left:20px; } li { margin-bottom:8px; } .muted { color:var(--text-muted); font-size:13px; }\ncode { font-size:13px; background:var(--surface-0); padding:1px 4px; border-radius:4px; }\ndetails summary { cursor:pointer; color:var(--text-secondary); margin-top:16px; }\n</style></head><body class=\"viz-root\"><main>\n<h1>Build report — ${esc(r.info.domain)}</h1>\n<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>\n<div class=\"tiles\">${tiles}</div>\n<section><h2>Stage timeline</h2>${gantt(r)}\n<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>\n<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>\n${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>` : ''}\n<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>\n<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>\n<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>\n<div class=\"cols\"><section><h2>What went well</h2>${list(r.what_went_well, (g) => `<li>${esc(g.text)}</li>`, 'Nothing logged.')}</section>\n<section><h2>Run details</h2><dl>${details}</dl></section></div>\n${tokenTable}${previous}\n</main><div id=\"tip\" role=\"status\"></div>\n<script>\nconst tip = document.getElementById('tip');\nconst 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; };\ndocument.querySelectorAll('.bar').forEach((el) => {\n el.addEventListener('mousemove', (e) => show(el, e.clientX, e.clientY));\n el.addEventListener('mouseleave', () => (tip.style.opacity = 0));\n el.addEventListener('focus', () => { const r = el.getBoundingClientRect(); show(el, r.left + r.width / 2, r.top); });\n el.addEventListener('blur', () => (tip.style.opacity = 0));\n});\n</script></body></html>\n`;\n}\n\nconst { positional, flags } = parseArgs(process.argv.slice(2));\nconst [domain, command, ...rest] = positional;\nconst ws = workspace(domain);\nswitch (command) {\n case 'run-start':\n runStart(ws, flags);\n break;\n case 'run-update':\n runUpdate(ws, flags);\n break;\n case 'run-resume':\n runResume(ws, flags);\n break;\n case 'stage-start':\n stageStart(ws, rest, flags);\n break;\n case 'stage-end':\n stageEnd(ws, rest, flags);\n break;\n case 'stage-fix':\n stageFix(ws, rest, flags);\n break;\n case 'event':\n event(ws, rest);\n break;\n case 'report': {\n let tokens;\n if (typeof flags.tokens === 'string') {\n try {\n tokens = JSON.parse(flags.tokens);\n } catch {\n fail('--tokens must be JSON, e.g. \\'{\"input\":0,\"output\":0,\"source\":\"…\"}\\'');\n }\n // null and {} can't be sent; treat them as not reported.\n if (tokens === null || (typeof tokens === 'object' && !Array.isArray(tokens) && !Object.keys(tokens).length)) tokens = undefined;\n else validate(tokens, { ...reportSchema()?.properties?.tokens, type: 'object' }, '--tokens');\n }\n console.log(`report written: ${buildReport(ws, { outcome: flags.outcome, tokens })}`);\n const { id, file } = reportFile(ws);\n 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)`);\n break;\n }\n case 'unsent':\n unsent(ws);\n break;\n case 'report-sent':\n reportSent(ws, rest);\n break;\n case 'report-rejected':\n reportRejected(ws, rest);\n break;\n default:\n fail('command must be run-start, run-update, run-resume, stage-start, stage-end, stage-fix, event, report, unsent, report-sent or report-rejected');\n}\n"
147
+ "text": "#!/usr/bin/env node\n// Hostinger Headless run log: records a build as it happens and writes reports/<run>.json + .html.\n//\n// node run-log.mjs <domain> run-start [--kind build|resume|redesign|iterate] [--tool <tool>] [--model <model>] [--skill <version>]\n// node run-log.mjs <domain> run-update [--kind …] [--model …] [--skill …]\n// node run-log.mjs <domain> run-resume [--reason \"<text>\"]\n// node run-log.mjs <domain> stage-start <stage> [--phase <phase>] [--started-ago 6m | --started-at <ISO time>]\n// node run-log.mjs <domain> stage-end <stage> <result> [--phase <phase>] [--note \"<text>\"] [--started-ago 6m | --started-at <ISO time>]\n// node run-log.mjs <domain> stage-fix <stage> [--phase <phase>] (--started-ago 6m | --started-at <ISO time>)\n// node run-log.mjs <domain> event <issue|feedback|skill_gap|good> '<json>'\n// node run-log.mjs <domain> report [--outcome live|stopped|failed|abandoned] [--tokens '<json>']\n// node run-log.mjs <domain> unsent\n// node run-log.mjs <domain> report-sent [<run>]\n// node run-log.mjs <domain> report-rejected [<run>]\n\nimport { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst STAGES = ['connect', 'check_state', 'refine', 'provision', 'content_model', 'design', 'frontend', 'validate', 'deploy', 'done'];\nconst RESULTS = ['ok', 'ok_after_retries', 'ok_with_fixups', 'failed', 'skipped', 'stopped'];\nconst OUTCOMES = ['live', 'stopped', 'failed', 'abandoned'];\nconst EVENT_FIELDS = {\n issue: { required: ['stage', 'symptom'], optional: ['cause', 'workaround', 'severity', 'resolved', 'user_had_to_intervene'] },\n feedback: { required: ['stage', 'category', 'paraphrase'], optional: [] },\n skill_gap: { required: ['skill', 'gap'], optional: ['stage'] },\n good: { required: ['text'], optional: ['stage'] },\n};\nconst EVENT_LISTS = { issue: 'issues', feedback: 'user_feedback', skill_gap: 'skill_gaps', good: 'what_went_well' };\n\nconst here = dirname(fileURLToPath(import.meta.url));\nconst now = () => new Date().toISOString().replace(/\\.\\d{3}Z$/, 'Z');\n// A start logged late is kept but marked estimated, so the report can tell measured times from remembered ones.\nfunction lateStart(flags, run) {\n let at = null;\n if (typeof flags['started-at'] === 'string') {\n 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`);\n at = Date.parse(flags['started-at']);\n } else if (typeof flags['started-ago'] === 'string') {\n const m = flags['started-ago'].match(/^(\\d+(?:\\.\\d+)?)\\s*([smh])$/);\n if (!m) fail(`--started-ago \"${flags['started-ago']}\" should look like 90s, 6m or 1h`);\n at = Date.now() - Number(m[1]) * { s: 1e3, m: 6e4, h: 36e5 }[m[2]];\n }\n if (at === null) return null;\n const clamped = Math.min(Math.max(at, Date.parse(run.started_at)), Date.now());\n return new Date(clamped).toISOString().replace(/\\.\\d{3}Z$/, 'Z');\n}\n\nconst fail = (message) => {\n console.error(`run-log: ${message}`);\n process.exit(1);\n};\n\nfunction parseArgs(argv) {\n const positional = [];\n const flags = {};\n for (let i = 0; i < argv.length; i += 1) {\n if (argv[i].startsWith('--')) {\n const key = argv[i].slice(2);\n const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[(i += 1)] : true;\n flags[key] = value;\n } else positional.push(argv[i]);\n }\n return { positional, flags };\n}\n\nconst readJson = (file, fallback) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : fallback);\nconst writeJson = (file, value) => writeFileSync(file, `${JSON.stringify(value, null, 2)}\\n`);\n\nfunction workspace(domain) {\n if (!domain || domain.startsWith('-')) fail('first argument must be the domain');\n const dir = join(homedir(), '.hostinger', domain);\n if (!existsSync(dir)) fail(`no workspace at ${dir}; connect creates it`);\n return {\n dir,\n domain,\n statePath: join(dir, 'state.json'),\n eventsPath: join(dir, 'events.jsonl'),\n reportsDir: join(dir, 'reports'),\n };\n}\n\nconst loadState = (ws) => readJson(ws.statePath, {});\nfunction saveState(ws, state) {\n state.updated_at = now();\n writeJson(ws.statePath, state);\n}\n\nfunction currentRun(ws, state) {\n if (!state.run?.id) fail('no run started; call run-start first (check-state does this)');\n return state.run;\n}\n\nfunction readEvents(ws) {\n if (!existsSync(ws.eventsPath)) return [];\n return readFileSync(ws.eventsPath, 'utf8')\n .split('\\n')\n .filter(Boolean)\n .map((line) => JSON.parse(line));\n}\n\n// connect.json from older runs may hold mcp as an object or only mcp_version; the report needs strings.\nfunction readConnect(ws) {\n const connect = readJson(join(ws.dir, 'connect.json'), {});\n const mcp = connect.mcp ?? connect.mcp_version ?? null;\n let version = mcp;\n if (mcp !== null && typeof mcp !== 'string') version = mcp.name || mcp.version ? [mcp.name, mcp.version].filter(Boolean).join(' ') : JSON.stringify(mcp);\n return { tool: typeof connect.tool === 'string' ? connect.tool : null, mcp: version };\n}\n\nfunction runStart(ws, flags) {\n const state = loadState(ws);\n // A previous run that never reached its report (closed window, crash) is closed out first, so\n // its log still turns into a report.\n if (state.run?.id && !state.run.reported_at) {\n buildReport(ws, { outcome: 'abandoned' });\n console.log(`closed previous run ${state.run.id} as abandoned`);\n }\n const fresh = loadState(ws);\n const previous = fresh.run ?? {};\n const startedAt = now();\n const connect = readConnect(ws);\n // Run ids are the start time; a second run in the same second gets a suffix.\n const taken = new Set([\n ...(fresh.history ?? []).map((h) => h.run),\n ...(existsSync(ws.reportsDir) ? readdirSync(ws.reportsDir).map((f) => f.replace(/\\.(json|html)$/, '')) : []),\n ]);\n const base = startedAt.replace(/:/g, '-');\n let id = base;\n for (let n = 2; taken.has(id) || id === fresh.run?.id; n += 1) id = `${base}-${n}`;\n const kind = flags.kind ?? 'build';\n if (!['build', 'resume', 'redesign', 'iterate'].includes(kind)) fail(`unknown kind \"${kind}\"; one of build, resume, redesign, iterate`);\n fresh.run = {\n id,\n kind,\n started_at: startedAt,\n tool: flags.tool ?? connect.tool ?? fresh.tool ?? previous.tool ?? null,\n model: flags.model ?? previous.model ?? null,\n skill: flags.skill ?? previous.skill ?? null,\n };\n fresh.history ??= [];\n saveState(ws, fresh);\n console.log(`run ${fresh.run.id} started`);\n}\n\nfunction runUpdate(ws, flags) {\n const state = loadState(ws);\n const run = currentRun(ws, state);\n for (const key of ['kind', 'tool', 'model', 'skill']) if (typeof flags[key] === 'string') run[key] = flags[key];\n if (!['build', 'resume', 'redesign', 'iterate'].includes(run.kind)) fail(`unknown kind \"${run.kind}\"; one of build, resume, redesign, iterate`);\n saveState(ws, state);\n console.log(`run ${run.id} updated`);\n}\n\n// A follow-up in the same session (the user asks for a fix right after the final report) belongs\n// to the run it follows: reopen it, so the next `report` rewrites that run's report instead of\n// starting a second one.\nfunction runResume(ws, flags) {\n const state = loadState(ws);\n const run = currentRun(ws, state);\n if (!run.reported_at) {\n console.log(`run ${run.id} is still open; nothing to resume`);\n return;\n }\n run.follow_ups = [\n ...(run.follow_ups ?? []),\n { resumed_at: now(), after_report_at: run.reported_at, reason: typeof flags.reason === 'string' ? flags.reason : null },\n ];\n delete run.reported_at;\n saveState(ws, state);\n console.log(`run ${run.id} resumed (follow-up ${run.follow_ups.length})`);\n}\n\nfunction stageStart(ws, [stage], flags) {\n if (!STAGES.includes(stage)) fail(`unknown stage \"${stage}\"; one of ${STAGES.join(', ')}`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n state.history ??= [];\n const phase = flags.phase ?? null;\n const open = state.history.find((h) => h.run === run.id && h.stage === stage && h.phase === phase && !h.ended_at);\n if (open) {\n console.log(`${stage}${phase ? `/${phase}` : ''} already started at ${open.started_at}`);\n return;\n }\n const late = lateStart(flags, run);\n state.history.push({ run: run.id, stage, phase, started_at: late ?? now(), ended_at: null, result: null, note: null, ...(late ? { start_estimated: true } : {}) });\n saveState(ws, state);\n console.log(`${stage}${phase ? `/${phase}` : ''} started${late ? ` (estimated start ${late})` : ''}`);\n}\n\nfunction stageEnd(ws, [stage, result], flags) {\n if (!STAGES.includes(stage)) fail(`unknown stage \"${stage}\"`);\n if (!RESULTS.includes(result)) fail(`unknown result \"${result}\"; one of ${RESULTS.join(', ')}`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n state.history ??= [];\n const phase = flags.phase ?? null;\n let entry = [...state.history].reverse().find((h) => h.run === run.id && h.stage === stage && h.phase === phase && !h.ended_at);\n if (!entry) {\n // Missed stage-start: use the remembered start if given, else the end of the previous stage; either way estimated.\n const runEntries = state.history.filter((h) => h.run === run.id);\n const lastEnd = runEntries.map((h) => h.ended_at).filter(Boolean).sort().at(-1);\n const late = lateStart(flags, run);\n entry = { run: run.id, stage, phase, started_at: late ?? lastEnd ?? run.started_at, ended_at: null, result: null, note: null, start_estimated: true };\n state.history.push(entry);\n 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'}`);\n } else if (flags['started-at'] || flags['started-ago']) {\n entry.started_at = lateStart(flags, run);\n entry.start_estimated = true;\n }\n entry.ended_at = now();\n entry.result = result;\n if (typeof flags.note === 'string') entry.note = flags.note;\n saveState(ws, state);\n console.log(`${stage}${phase ? `/${phase}` : ''} ended: ${result}`);\n}\n\nfunction stageFix(ws, [stage], flags) {\n if (!STAGES.includes(stage)) fail(`unknown stage \"${stage}\"`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n const phase = flags.phase ?? null;\n const entry = [...(state.history ?? [])].reverse().find((h) => h.run === run.id && h.stage === stage && h.phase === phase);\n if (!entry) fail(`${stage}${phase ? `/${phase}` : ''} was never logged in this run; use stage-start with --started-ago instead`);\n const late = lateStart(flags, run);\n if (!late) fail('stage-fix needs --started-ago (e.g. 6m) or --started-at <ISO time>');\n if (entry.ended_at && late > entry.ended_at) fail(`the new start ${late} is after the stage ended (${entry.ended_at})`);\n entry.started_at = late;\n entry.start_estimated = true;\n saveState(ws, state);\n console.log(`${stage}${phase ? `/${phase}` : ''} start set to ${late} (estimated)`);\n}\n\nfunction event(ws, [type, raw]) {\n const spec = EVENT_FIELDS[type];\n if (!spec) fail(`unknown event type \"${type}\"; one of ${Object.keys(EVENT_FIELDS).join(', ')}`);\n let data;\n try {\n data = JSON.parse(raw ?? '');\n } catch {\n fail(`event ${type} needs a JSON object, e.g. '${JSON.stringify(Object.fromEntries(spec.required.map((k) => [k, '…'])))}'`);\n }\n const missing = spec.required.filter((k) => data[k] === undefined || data[k] === '');\n if (missing.length) fail(`event ${type} is missing ${missing.join(', ')}`);\n const unknown = Object.keys(data).filter((k) => !spec.required.includes(k) && !spec.optional.includes(k));\n if (unknown.length) fail(`event ${type} has unknown fields ${unknown.join(', ')}`);\n if (type === 'issue') {\n data.severity ??= 'medium';\n if (!['high', 'medium', 'low'].includes(data.severity)) fail('issue severity must be high, medium or low');\n data.resolved ??= false;\n data.user_had_to_intervene ??= false;\n }\n // A bad line in events.jsonl would fail every later report and run-start.\n validate({ ...data, logged_at: now() }, eventSchema(type), `event ${type}`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n appendFileSync(ws.eventsPath, `${JSON.stringify({ ts: now(), run: run.id, type, ...data })}\\n`);\n console.log(`${type} logged`);\n}\n\nconst minutesBetween = (a, b) => (a && b ? Math.round(((Date.parse(b) - Date.parse(a)) / 60000) * 10) / 10 : null);\n\nfunction frontendStack(dir) {\n // Framework is recorded in state.json by the frontend step.\n const state = readJson(join(dir, 'state.json'), {});\n const fe = state.frontend ?? {};\n const framework = fe.framework ?? fe.stack ?? null;\n if (framework) return fe.rendering ? `${framework} (${fe.rendering})` : framework;\n // Fallback: an older build may have a design.md with a \"## Stack\" heading.\n const design = existsSync(join(dir, 'design.md')) ? readFileSync(join(dir, 'design.md'), 'utf8') : '';\n return design.match(/## Stack\\s*\\n\\s*\\*\\*([^*]+)\\*\\*/)?.[1] ?? null;\n}\n\nfunction buildReport(ws, { outcome, tokens } = {}) {\n const state = loadState(ws);\n const run = currentRun(ws, state);\n const site = readJson(join(ws.dir, 'site.json'), {});\n const deploy = readJson(join(ws.dir, 'deploy.json'), {});\n const connect = readConnect(ws);\n const history = (state.history ?? []).filter((h) => h.run === run.id);\n // Lines logged before events were checked on write may not match the schema.\n const dropped = [];\n const events = readEvents(ws)\n .filter((e) => e.run === run.id)\n .filter((e) => {\n const { ts, run: _run, type, ...rest } = e;\n const problem = schemaError({ ...rest, logged_at: ts }, eventSchema(type), 'event');\n if (problem) dropped.push({ stage: typeof e.stage === 'string' ? e.stage : 'unknown', phase: null, problem: `left out a malformed ${type} event (${problem})` });\n return !problem;\n });\n const resolvedOutcome = outcome ?? (history.some((h) => h.stage === 'deploy' && h.result?.startsWith('ok')) ? 'live' : 'stopped');\n // A run ends at its last logged activity, so re-running `report` later (after fixing warnings, or\n // when the next run-start closes an abandoned run) doesn't stretch it to \"now\". Only a stage still\n // open on a live run keeps the clock running.\n const lastActivity = [run.started_at, ...history.flatMap((h) => [h.started_at, h.ended_at]), ...events.map((e) => e.ts)]\n .filter(Boolean)\n .sort()\n .at(-1);\n const stillOpen = resolvedOutcome !== 'abandoned' && history.some((h) => !h.ended_at);\n const endedAt = stillOpen ? now() : lastActivity;\n const stages = history.map((h) => ({ ...h, minutes: minutesBetween(h.started_at, h.ended_at ?? endedAt) }));\n const gaps = [...loggingGaps(stages, run, resolvedOutcome, site.backend), ...dropped];\n const strip = ({ ts, run: _run, type: _type, ...rest }) => ({ ...rest, logged_at: ts });\n const liveStage = stages.find((s) => s.stage === 'deploy' && s.result?.startsWith('ok'));\n if (!OUTCOMES.includes(resolvedOutcome)) fail(`unknown outcome \"${resolvedOutcome}\"; one of ${OUTCOMES.join(', ')}`);\n\n const previous = existsSync(ws.reportsDir)\n ? readdirSync(ws.reportsDir)\n .filter((f) => f.endsWith('.json') && f !== `${run.id}.json`)\n .map((f) => readJson(join(ws.reportsDir, f), {}))\n .sort((a, b) => `${a.timing?.started_at}${a.run?.id}`.localeCompare(`${b.timing?.started_at}${b.run?.id}`))\n .map((r) => ({ run: r.run?.id, kind: r.run?.kind, outcome: r.info?.outcome, minutes: r.timing?.minutes_total, html: `${r.run?.id}.html` }))\n : [];\n\n const report = {\n schema_version: '1',\n generated_at: now(),\n // Intent, not delivery: delivery is tracked in state.json.\n consent: { sent: true, note: 'Sent to Hostinger automatically at the end of the run.' },\n run: { id: run.id, kind: run.kind, skill: run.skill, follow_ups: run.follow_ups ?? [] },\n info: {\n domain: ws.domain,\n tool: run.tool ?? connect.tool ?? null,\n model: run.model ?? null,\n os: process.platform,\n node: process.version,\n mcp: connect.mcp,\n backend: site.backend ?? null,\n frontend: frontendStack(ws.dir),\n outcome: resolvedOutcome,\n stopped_at: resolvedOutcome === 'live' ? null : stoppedAt(stages, state),\n live_url: resolvedOutcome === 'live' ? deploy.urls?.site ?? site.urls?.site ?? null : null,\n admin_url: site.urls?.wp_admin ?? null,\n },\n timing: {\n started_at: run.started_at,\n ended_at: endedAt,\n live_at: liveStage?.ended_at ?? null,\n minutes_total: minutesBetween(run.started_at, endedAt),\n minutes_to_live: liveStage ? minutesBetween(run.started_at, liveStage.ended_at) : null,\n stages: stages.map(({ run: _run, ...s }) => s),\n },\n // Not null: the MCP layer treats a null required field as missing, and the API reads {} as an array.\n tokens: tokens ?? { source: 'not reported' },\n issues: events.filter((e) => e.type === 'issue').map(strip),\n user_feedback: events.filter((e) => e.type === 'feedback').map(strip),\n skill_gaps: events.filter((e) => e.type === 'skill_gap').map(strip),\n what_went_well: events.filter((e) => e.type === 'good').map(strip),\n logging_gaps: gaps,\n previous_runs: previous,\n };\n\n validate(report, reportSchema());\n mkdirSync(ws.reportsDir, { recursive: true });\n writeJson(join(ws.reportsDir, `${run.id}.json`), report);\n writeFileSync(join(ws.reportsDir, `${run.id}.html`), renderHtml(report));\n copyFileSync(join(ws.reportsDir, `${run.id}.html`), join(ws.dir, 'report.html'));\n\n const fresh = loadState(ws);\n fresh.run.reported_at = now();\n saveState(ws, fresh);\n for (const g of gaps) console.log(`warning: ${g.stage}${g.phase ? `/${g.phase}` : ''}: ${g.problem}`);\n return join(ws.dir, 'report.html');\n}\n\n// A report counts as sent only while the sent copy's generated_at matches the file.\nfunction reportFile(ws, runId) {\n const id = runId ?? currentRun(ws, loadState(ws)).id;\n const file = join(ws.reportsDir, `${id}.json`);\n if (!existsSync(file)) fail(`no report for run ${id} at ${file}`);\n return { id, file, generatedAt: readJson(file, {}).generated_at };\n}\n\nfunction unsent(ws) {\n const state = loadState(ws);\n const files = existsSync(ws.reportsDir) ? readdirSync(ws.reportsDir).filter((f) => f.endsWith('.json')).sort() : [];\n // Older reports promised \"nothing is uploaded\".\n const pending = files\n .map((f) => {\n const report = readJson(join(ws.reportsDir, f), {});\n return { id: f.replace(/\\.json$/, ''), file: join(ws.reportsDir, f), generatedAt: report.generated_at, consent: report.consent };\n })\n .filter(({ consent }) => consent?.sent !== false)\n .filter(({ id, generatedAt }) => state.reports_sent?.[id] !== generatedAt && state.reports_rejected?.[id] !== generatedAt);\n if (!pending.length) console.log('no unsent reports');\n for (const { id, file } of pending) console.log(`${id} ${file}`);\n}\n\nfunction reportSent(ws, [runId]) {\n const { id, generatedAt } = reportFile(ws, runId);\n const state = loadState(ws);\n state.reports_sent = { ...state.reports_sent, [id]: generatedAt };\n saveState(ws, state);\n console.log(`report ${id} marked sent`);\n}\n\n// A 422 is schema drift, so resending won't help.\nfunction reportRejected(ws, [runId]) {\n const { id, generatedAt } = reportFile(ws, runId);\n const state = loadState(ws);\n state.reports_rejected = { ...state.reports_rejected, [id]: generatedAt };\n saveState(ws, state);\n const run = currentRun(ws, state);\n const symptom = `build report ${id} rejected with 422 by hosting_headless_submit-build-report`;\n 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`);\n console.log(`report ${id} marked rejected; issue logged in run ${run.id}`);\n}\n\n// Gaps are printed as warnings by `report`, so guesses never pass as measurements.\nconst EXPECTED_FOR_BUILD = ['check_state', 'refine', 'provision', 'content_model', 'design', 'frontend', 'validate', 'deploy'];\n// Stages a backend skips on purpose: webapp has no CMS, so no content model.\nconst SKIPPED_BY_BACKEND = { webapp: ['content_model'] };\nfunction loggingGaps(stages, run, outcome, backend) {\n const gaps = [];\n for (const s of stages) {\n // A skipped stage did no work, so it has no duration to measure.\n const problem = s.result === 'skipped' ? null\n : !s.ended_at ? 'started but never ended'\n : s.start_estimated ? 'start time estimated, not measured'\n : Date.parse(s.ended_at) === Date.parse(s.started_at) ? 'logged with no duration; probably started and ended in one go after the work'\n : null;\n if (problem) gaps.push({ stage: s.stage, phase: s.phase, problem });\n }\n if (run.kind === 'build' && outcome === 'live') {\n for (const stage of EXPECTED_FOR_BUILD.filter((name) => !SKIPPED_BY_BACKEND[backend]?.includes(name))) {\n if (!stages.some((s) => s.stage === stage)) gaps.push({ stage, phase: null, problem: 'never logged, though the site went live' });\n }\n }\n return gaps;\n}\n\nfunction stoppedAt(stages, state) {\n const stuck = [...stages].reverse().find((s) => !s.ended_at || s.result === 'failed' || s.result === 'stopped');\n return stuck?.stage ?? state.stage ?? null;\n}\n\n// Minimal JSON Schema subset, so reports from different tools share one shape without a dependency.\nconst reportSchema = () => readJson(join(here, 'report.schema.json'), null);\nconst eventSchema = (type) => reportSchema()?.properties?.[EVENT_LISTS[type]]?.items ?? null;\n\nfunction schemaError(value, schema, path = 'report') {\n if (!schema) return null;\n const types = [].concat(schema.type ?? []);\n const actual = value === null ? 'null' : Array.isArray(value) ? 'array' : Number.isInteger(value) ? 'integer' : typeof value;\n if (types.length && !types.includes(actual) && !(actual === 'integer' && types.includes('number'))) {\n return `${path} should be ${types.join(' or ')}, got ${actual}`;\n }\n if (schema.enum && !schema.enum.includes(value)) return `${path} should be one of ${schema.enum.join(', ')}`;\n if (actual === 'object') {\n for (const key of schema.required ?? []) if (!(key in value)) return `${path}.${key} is missing`;\n for (const [key, sub] of Object.entries(schema.properties ?? {})) {\n const error = key in value ? schemaError(value[key], sub, `${path}.${key}`) : null;\n if (error) return error;\n }\n }\n if (actual === 'array' && schema.items) {\n for (const [i, item] of value.entries()) {\n const error = schemaError(item, schema.items, `${path}[${i}]`);\n if (error) return error;\n }\n }\n return null;\n}\n\nfunction validate(value, schema, path) {\n const error = schemaError(value, schema, path);\n if (error) fail(error);\n}\n\nconst esc = (v) => String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\nconst label = (s) => String(s ?? '').replace(/_/g, ' ');\nconst time = (iso) => (iso ? iso.slice(11, 19) : '—');\nconst fmt = (n) => (typeof n === 'number' ? n.toLocaleString('en-US') : '—');\n\nfunction gantt(report) {\n const stages = report.timing.stages;\n if (!stages.length) return '<p class=\"muted\">No stages were logged for this run.</p>';\n const t0 = Date.parse(report.timing.started_at);\n const at = (iso) => (Date.parse(iso) - t0) / 60000;\n const total = Math.max(report.timing.minutes_total ?? 1, ...stages.map((s) => at(s.ended_at ?? report.timing.ended_at)), 1);\n const step = [1, 2, 5, 10, 15, 30, 60, 120, 240].find((s) => total / s <= 8) ?? 480;\n const span = Math.ceil(total / step) * step;\n const W = 760;\n const LABEL = 160;\n const ROW = 32;\n const TOP = 32;\n const H = TOP + ROW * stages.length + 32;\n const x = (m) => LABEL + ((W - LABEL - 40) * m) / span;\n 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>`];\n for (let m = 0; m <= span; m += step) {\n 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>`);\n }\n for (const f of report.run.follow_ups ?? []) {\n const fx = x(at(f.resumed_at)).toFixed(1);\n 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>`);\n }\n if (report.timing.live_at) {\n const lx = x(at(report.timing.live_at)).toFixed(1);\n 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>`);\n }\n stages.forEach((s, i) => {\n const y = TOP + i * ROW;\n const a = at(s.started_at);\n const b = at(s.ended_at ?? report.timing.ended_at);\n const width = Math.max(x(b) - x(a), 4);\n const name = `${label(s.stage)}${s.phase ? ` · ${s.phase}` : ''}`;\n const tip = [name, `${s.minutes} min`, s.result ? label(s.result) : 'not finished', s.start_estimated ? 'start estimated' : null, s.note].filter(Boolean).join(' · ');\n const failed = s.result === 'failed' || s.result === 'stopped' || !s.ended_at;\n const fill = failed ? 'url(#hatch)' : s.start_estimated ? 'none' : 'var(--series-1)';\n out.push(`<text x=\"${LABEL - 12}\" y=\"${(y + ROW / 2 + 4).toFixed(1)}\" class=\"row-label\" text-anchor=\"end\">${esc(name)}</text>`);\n 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>`);\n });\n 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>');\n return out.join('');\n}\n\nfunction renderHtml(r) {\n 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>`;\n const stepped = r.issues.filter((i) => i.user_had_to_intervene).length;\n const rejected = r.user_feedback.filter((f) => f.category === 'design_rejected').length;\n const t = r.tokens && ['input', 'output', 'cache_read', 'cache_write', 'est_cost_usd'].some((k) => r.tokens[k] != null) ? r.tokens : null;\n const tiles = [\n r.timing.minutes_to_live != null\n ? tile('Time to live', `${r.timing.minutes_to_live} min`, 'start of run → deploy finished')\n : tile('Outcome', label(r.info.outcome), r.info.stopped_at ? `stopped at ${label(r.info.stopped_at)}` : ''),\n tile('Run length', `${r.timing.minutes_total} min`, `${r.timing.stages.length} stage entries`),\n tile('Issues', String(r.issues.length), `${stepped} needed the user to step in`),\n tile('User feedback', String(r.user_feedback.length), rejected ? `${rejected} design rejections` : 'corrections and requests'),\n 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'),\n ].join('');\n const SEV = { high: ['●', 'critical', 'High'], medium: ['▲', 'serious', 'Medium'], low: ['■', 'warning', 'Low'] };\n const order = ['high', 'medium', 'low'];\n const issues = [...r.issues]\n .sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity))\n .map((i) => {\n const [icon, cls, name] = SEV[i.severity];\n 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>`;\n })\n .join('');\n const list = (items, render, empty) => (items.length ? `<ul>${items.map(render).join('')}</ul>` : `<p class=\"muted\">${empty}</p>`);\n const stageRows = r.timing.stages\n .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>`)\n .join('');\n const details = [\n ['Domain', r.info.domain],\n ['Outcome', [label(r.info.outcome), r.info.live_url].filter(Boolean).join(' · ')],\n ['Run', `${r.run.id} · ${r.run.kind}`],\n ['Tool / model', [r.info.tool, r.info.model].filter(Boolean).join(' · ') || '—'],\n ['Stack', [r.info.backend, r.info.frontend].filter(Boolean).join(' + ') || '—'],\n ['Environment', `${r.info.os} · Node ${r.info.node}${r.info.mcp ? ` · ${r.info.mcp}` : ''}`],\n ['Skill version', r.run.skill ?? '—'],\n ...(r.run.follow_ups?.length\n ? [['Follow-ups', r.run.follow_ups.map((f) => `resumed ${time(f.resumed_at)}${f.reason ? ` — ${f.reason}` : ''}`).join('; ')]]\n : []),\n ]\n .map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`)\n .join('');\n const tokenTable = t\n ? `<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>`\n : '';\n const previous = r.previous_runs.length\n ? `<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>`\n : '';\n\n return `<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Build report — ${esc(r.info.domain)}</title>\n<style>\n.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; }\n@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; } }\n* { box-sizing: border-box; }\nbody { margin:0; background:var(--surface-0); color:var(--text-primary); font:15px/1.5 system-ui,-apple-system,\"Segoe UI\",sans-serif; }\nmain { max-width:1080px; margin:0 auto; padding:32px 24px 64px; }\nh1 { font-size:28px; line-height:1.2; margin:0 0 4px; } h2 { font-size:18px; margin:0 0 16px; }\na { color:var(--series-1); }\n.lede { color:var(--text-secondary); margin:0 0 24px; }\nsection { background:var(--surface-1); border:1px solid var(--border); border-radius:12px; padding:24px; margin-bottom:24px; }\n.tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:16px; margin-bottom:24px; }\n.tile { background:var(--surface-1); border:1px solid var(--border); border-radius:12px; padding:16px; }\n.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; }\ndl { 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; }\n.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; }\n.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; }\n.bar { outline:none; } .bar:hover rect:last-child, .bar:focus rect:last-child { stroke:var(--text-primary); stroke-width:2; }\n.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; }\n.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); }\n#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; }\ntable { 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; }\ntd.num,th.num { text-align:right; font-variant-numeric:tabular-nums; } .table-wrap { overflow-x:auto; }\n.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); }\n.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; }\n.cols { display:grid; grid-template-columns:repeat(auto-fit,minmax(320px,1fr)); gap:24px; margin-bottom:24px; } .cols section { margin:0; }\nul { margin:0; padding-left:20px; } li { margin-bottom:8px; } .muted { color:var(--text-muted); font-size:13px; }\ncode { font-size:13px; background:var(--surface-0); padding:1px 4px; border-radius:4px; }\ndetails summary { cursor:pointer; color:var(--text-secondary); margin-top:16px; }\n</style></head><body class=\"viz-root\"><main>\n<h1>Build report — ${esc(r.info.domain)}</h1>\n<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>\n<div class=\"tiles\">${tiles}</div>\n<section><h2>Stage timeline</h2>${gantt(r)}\n<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>\n<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>\n${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>` : ''}\n<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>\n<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>\n<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>\n<div class=\"cols\"><section><h2>What went well</h2>${list(r.what_went_well, (g) => `<li>${esc(g.text)}</li>`, 'Nothing logged.')}</section>\n<section><h2>Run details</h2><dl>${details}</dl></section></div>\n${tokenTable}${previous}\n</main><div id=\"tip\" role=\"status\"></div>\n<script>\nconst tip = document.getElementById('tip');\nconst 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; };\ndocument.querySelectorAll('.bar').forEach((el) => {\n el.addEventListener('mousemove', (e) => show(el, e.clientX, e.clientY));\n el.addEventListener('mouseleave', () => (tip.style.opacity = 0));\n el.addEventListener('focus', () => { const r = el.getBoundingClientRect(); show(el, r.left + r.width / 2, r.top); });\n el.addEventListener('blur', () => (tip.style.opacity = 0));\n});\n</script></body></html>\n`;\n}\n\nconst { positional, flags } = parseArgs(process.argv.slice(2));\nconst [domain, command, ...rest] = positional;\nconst ws = workspace(domain);\nswitch (command) {\n case 'run-start':\n runStart(ws, flags);\n break;\n case 'run-update':\n runUpdate(ws, flags);\n break;\n case 'run-resume':\n runResume(ws, flags);\n break;\n case 'stage-start':\n stageStart(ws, rest, flags);\n break;\n case 'stage-end':\n stageEnd(ws, rest, flags);\n break;\n case 'stage-fix':\n stageFix(ws, rest, flags);\n break;\n case 'event':\n event(ws, rest);\n break;\n case 'report': {\n let tokens;\n if (typeof flags.tokens === 'string') {\n try {\n tokens = JSON.parse(flags.tokens);\n } catch {\n fail('--tokens must be JSON, e.g. \\'{\"input\":0,\"output\":0,\"source\":\"…\"}\\'');\n }\n // null and {} can't be sent; treat them as not reported.\n if (tokens === null || (typeof tokens === 'object' && !Array.isArray(tokens) && !Object.keys(tokens).length)) tokens = undefined;\n else validate(tokens, { ...reportSchema()?.properties?.tokens, type: 'object' }, '--tokens');\n }\n console.log(`report written: ${buildReport(ws, { outcome: flags.outcome, tokens })}`);\n const { id, file } = reportFile(ws);\n 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)`);\n break;\n }\n case 'unsent':\n unsent(ws);\n break;\n case 'report-sent':\n reportSent(ws, rest);\n break;\n case 'report-rejected':\n reportRejected(ws, rest);\n break;\n default:\n fail('command must be run-start, run-update, run-resume, stage-start, stage-end, stage-fix, event, report, unsent, report-sent or report-rejected');\n}\n"
148
148
  },
149
149
  {
150
150
  "uri": "skill://hostinger-headless/steps/01-check-state/SKILL.md",
@@ -4,4 +4,4 @@ import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/agency-hosting.js';
5
5
  import instructions from '../core/instructions.agency-hosting.js';
6
6
 
7
- startServer({ name: 'hostinger-agency-hosting-mcp', version: '3.19.0', tools, instructions });
7
+ startServer({ name: 'hostinger-agency-hosting-mcp', version: '3.19.1', tools, instructions });
@@ -4,4 +4,4 @@ import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/agency-hosting.js';
5
5
  import instructions from '../core/instructions.agency-hosting.js';
6
6
 
7
- startServer({ name: 'hostinger-agency-hosting-mcp', version: '3.19.0', tools, instructions });
7
+ startServer({ name: 'hostinger-agency-hosting-mcp', version: '3.19.1', tools, instructions });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/agent-readiness.js';
5
5
 
6
- startServer({ name: 'hostinger-agent-readiness-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-agent-readiness-mcp', version: '3.19.1', tools });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/agent-readiness.js';
5
5
 
6
- startServer({ name: 'hostinger-agent-readiness-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-agent-readiness-mcp', version: '3.19.1', tools });
@@ -5,4 +5,4 @@ import tools from '../core/tools/all.js';
5
5
  import instructions from '../core/instructions.js';
6
6
  import skills from '../core/skills.js';
7
7
 
8
- startServer({ name: 'hostinger-api-mcp', version: '3.19.0', tools, instructions, skills });
8
+ startServer({ name: 'hostinger-api-mcp', version: '3.19.1', tools, instructions, skills });
@@ -5,4 +5,4 @@ import tools from '../core/tools/all.js';
5
5
  import instructions from '../core/instructions.js';
6
6
  import skills from '../core/skills.js';
7
7
 
8
- startServer({ name: 'hostinger-api-mcp', version: '3.19.0', tools, instructions, skills });
8
+ startServer({ name: 'hostinger-api-mcp', version: '3.19.1', tools, instructions, skills });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/billing.js';
5
5
 
6
- startServer({ name: 'hostinger-billing-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-billing-mcp', version: '3.19.1', tools });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/billing.js';
5
5
 
6
- startServer({ name: 'hostinger-billing-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-billing-mcp', version: '3.19.1', tools });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/dns.js';
5
5
 
6
- startServer({ name: 'hostinger-dns-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-dns-mcp', version: '3.19.1', tools });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/dns.js';
5
5
 
6
- startServer({ name: 'hostinger-dns-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-dns-mcp', version: '3.19.1', tools });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/domains.js';
5
5
 
6
- startServer({ name: 'hostinger-domains-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-domains-mcp', version: '3.19.1', tools });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/domains.js';
5
5
 
6
- startServer({ name: 'hostinger-domains-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-domains-mcp', version: '3.19.1', tools });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/ecommerce.js';
5
5
 
6
- startServer({ name: 'hostinger-ecommerce-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-ecommerce-mcp', version: '3.19.1', tools });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/ecommerce.js';
5
5
 
6
- startServer({ name: 'hostinger-ecommerce-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-ecommerce-mcp', version: '3.19.1', tools });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/horizons.js';
5
5
 
6
- startServer({ name: 'hostinger-horizons-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-horizons-mcp', version: '3.19.1', tools });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/horizons.js';
5
5
 
6
- startServer({ name: 'hostinger-horizons-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-horizons-mcp', version: '3.19.1', tools });
@@ -4,4 +4,4 @@ import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/hosting.js';
5
5
  import instructions from '../core/instructions.js';
6
6
 
7
- startServer({ name: 'hostinger-hosting-mcp', version: '3.19.0', tools, instructions });
7
+ startServer({ name: 'hostinger-hosting-mcp', version: '3.19.1', tools, instructions });
@@ -4,4 +4,4 @@ import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/hosting.js';
5
5
  import instructions from '../core/instructions.js';
6
6
 
7
- startServer({ name: 'hostinger-hosting-mcp', version: '3.19.0', tools, instructions });
7
+ startServer({ name: 'hostinger-hosting-mcp', version: '3.19.1', tools, instructions });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/mail.js';
5
5
 
6
- startServer({ name: 'hostinger-mail-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-mail-mcp', version: '3.19.1', tools });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/mail.js';
5
5
 
6
- startServer({ name: 'hostinger-mail-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-mail-mcp', version: '3.19.1', tools });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/reach.js';
5
5
 
6
- startServer({ name: 'hostinger-reach-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-reach-mcp', version: '3.19.1', tools });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/reach.js';
5
5
 
6
- startServer({ name: 'hostinger-reach-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-reach-mcp', version: '3.19.1', tools });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/vps.js';
5
5
 
6
- startServer({ name: 'hostinger-vps-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-vps-mcp', version: '3.19.1', tools });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/vps.js';
5
5
 
6
- startServer({ name: 'hostinger-vps-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-vps-mcp', version: '3.19.1', tools });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/wordpress.js';
5
5
 
6
- startServer({ name: 'hostinger-wordpress-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-wordpress-mcp', version: '3.19.1', tools });
@@ -3,4 +3,4 @@
3
3
  import { startServer } from '../core/runtime.js';
4
4
  import tools from '../core/tools/wordpress.js';
5
5
 
6
- startServer({ name: 'hostinger-wordpress-mcp', version: '3.19.0', tools });
6
+ startServer({ name: 'hostinger-wordpress-mcp', version: '3.19.1', tools });