@legionworks/facet 1.9.0 → 1.10.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.
Files changed (77) hide show
  1. package/README.md +50 -21
  2. package/dist/gallery/{chunk-c6s9k9ty.js → chunk-0by8qn4x.js} +86 -3
  3. package/dist/gallery/{chunk-4g8rem85.css → chunk-2dge4gwb.css} +22 -1
  4. package/dist/gallery/frame/artifact.css +3 -0
  5. package/dist/gallery/frame/chunks/{markdown-fvfncadf.js → markdown-g3asgaw3.js} +8 -0
  6. package/dist/gallery/frame/frame.css +11 -24
  7. package/dist/gallery/frame/runtime/chart.js +24 -2
  8. package/dist/gallery/frame/runtime/html.js +1 -1
  9. package/dist/gallery/frame/runtime/markdown.js +1 -1
  10. package/dist/gallery/frame/runtime/mermaid.js +1 -1
  11. package/dist/gallery/frame/runtime/svg.js +1 -1
  12. package/dist/gallery/frame/runtime/tsx.js +1 -1
  13. package/dist/gallery/index.html +4 -2
  14. package/docs/reference/cli.md +30 -2
  15. package/docs/reference/export.md +4 -0
  16. package/docs/reference/html.md +3 -3
  17. package/docs/reference/mcp.md +29 -8
  18. package/docs/reference/security.md +7 -0
  19. package/docs/reference/storage.md +6 -1
  20. package/docs/reference/tsx.md +16 -0
  21. package/docs/reference/validation.md +34 -6
  22. package/package.json +4 -4
  23. package/skills/facet/SKILL.md +24 -2
  24. package/src/cli/commands/promote.ts +1 -0
  25. package/src/cli/commands/templates.ts +25 -0
  26. package/src/cli/main.ts +4 -0
  27. package/src/cli/parser.ts +4 -0
  28. package/src/cli/presenter.ts +48 -0
  29. package/src/gallery-web/app.ts +65 -2
  30. package/src/gallery-web/favicon.ts +1 -0
  31. package/src/gallery-web/frame/renderers/chart.ts +31 -1
  32. package/src/gallery-web/frame/runtime.ts +7 -1
  33. package/src/gallery-web/frame/styles/artifact.css +3 -0
  34. package/src/gallery-web/frame/styles/frame.css +11 -24
  35. package/src/gallery-web/frame-html.ts +7 -2
  36. package/src/gallery-web/index.html +3 -1
  37. package/src/gallery-web/styles/verdict.css +19 -4
  38. package/src/harness-adapters/mcp/cli-bridge.ts +11 -1
  39. package/src/harness-adapters/mcp/main.ts +2 -0
  40. package/src/harness-adapters/mcp/server.ts +92 -69
  41. package/src/harness-adapters/mcp/tool-schemas.ts +7 -0
  42. package/src/service/dispatcher.ts +22 -0
  43. package/src/service/router-guards.ts +2 -0
  44. package/src/service/store/migrations.ts +12 -2
  45. package/src/service/store/repository-lifecycle.ts +91 -19
  46. package/src/service/store/repository.ts +60 -2
  47. package/src/service/store/schema.ts +4 -0
  48. package/src/service/stored-verdict.ts +3 -0
  49. package/src/shared/contracts/artifact.ts +1 -0
  50. package/src/shared/contracts/commands/index.ts +10 -0
  51. package/src/shared/contracts/commands/names.ts +2 -0
  52. package/src/shared/contracts/commands/requests.ts +7 -0
  53. package/src/shared/contracts/commands/results.ts +20 -1
  54. package/src/shared/contracts/promotion.ts +16 -0
  55. package/src/shared/contracts/validation.ts +7 -0
  56. package/src/shared/errors/facet-error.ts +2 -0
  57. package/src/shared/errors/store-error.ts +4 -0
  58. package/src/shared/html/artifact-main.ts +3 -0
  59. package/src/shared/html/style-vocabulary.ts +1 -0
  60. package/src/shared/storage-version.ts +1 -1
  61. package/src/validation/tier0/dom-shim.ts +3 -18
  62. package/src/validation/tier0/markdown.ts +28 -5
  63. package/src/validation/tier0/mermaid.ts +17 -8
  64. package/src/validation/tier0/worker-dispatch.ts +1 -1
  65. package/src/validation/tier1/entries/tsx.ts +1 -0
  66. package/src/validation/tier1/harness.ts +2 -1
  67. package/src/validation/tier1/isolated-probe.ts +4 -0
  68. package/src/validation/tier1/protocol-probe.ts +37 -0
  69. package/src/validation/tier1/runner.ts +21 -4
  70. package/src/validation/tier1/verdict.ts +24 -39
  71. package/templates/capacity-report.tsx +1 -1
  72. package/templates/fleet-dashboard.html +1 -1
  73. package/templates/html-release-ledger.html +1 -1
  74. package/templates/html-status-report.html +1 -1
  75. package/templates/incident-console.tsx +1 -1
  76. package/templates/tsx-interactive-counter.tsx +1 -1
  77. package/templates/tsx-status-report.tsx +1 -1
@@ -4,12 +4,22 @@ On harnesses with shell access, the CLI is the integration; the MCP adapter is f
4
4
 
5
5
  Facet's npm package includes the `facet-mcp` bin. It needs Bun `1.4.0`; npm and pnpm install the package, but Bun remains the runtime.
6
6
 
7
- Run the adapter without a checkout:
7
+ Install globally, then run the bin with Bun `1.4.0` or newer:
8
8
 
9
9
  ```sh
10
- bunx -p @legionworks/facet facet-mcp
10
+ bun add -g @legionworks/facet
11
+ facet-mcp
11
12
  ```
12
13
 
14
+ Releases through `1.9.0` ship a `facet-mcp` bin that cannot launch: its entry file has no `#!/usr/bin/env bun` line. Use `1.10.0` or newer. With `1.10.0` on Bun `1.4.0`, each of these answered JSON-RPC `initialize` and listed six tools:
15
+
16
+ - `bun add -g @legionworks/facet`, then `facet-mcp`
17
+ - `npm i -g @legionworks/facet`, then `facet-mcp`
18
+ - `npx -p @legionworks/facet facet-mcp`
19
+ - `bunx -p @legionworks/facet facet-mcp`
20
+
21
+ Run `npx -p` from outside a Facet checkout. Inside one, npx resolves the local project instead of the published package and fails with `facet-mcp: command not found`.
22
+
13
23
  The adapter resolves the CLI in this order: `FACET_CLI`, then `bun <adapter-relative-repository>/src/cli/main.ts`, then `facet` on `PATH`. Set `FACET_CLI` to an absolute CLI executable when the adapter should use another installation.
14
24
 
15
25
  ## Register the server
@@ -21,7 +31,7 @@ OpenCode config:
21
31
  "mcp": {
22
32
  "facet": {
23
33
  "type": "local",
24
- "command": ["bunx", "-p", "@legionworks/facet", "facet-mcp"],
34
+ "command": ["facet-mcp"],
25
35
  "enabled": true
26
36
  }
27
37
  }
@@ -34,8 +44,7 @@ Claude Code project config (`.mcp.json`):
34
44
  {
35
45
  "mcpServers": {
36
46
  "facet": {
37
- "command": "bunx",
38
- "args": ["-p", "@legionworks/facet", "facet-mcp"]
47
+ "command": "facet-mcp"
39
48
  }
40
49
  }
41
50
  }
@@ -45,8 +54,7 @@ Codex config (`~/.codex/config.toml`):
45
54
 
46
55
  ```toml
47
56
  [mcp_servers.facet]
48
- command = "bunx"
49
- args = ["-p", "@legionworks/facet", "facet-mcp"]
57
+ command = "facet-mcp"
50
58
  ```
51
59
 
52
60
  Set `FACET_HOME` in the host configuration when the adapter must use a non-default Facet runtime directory. Set `FACET_CLI` only for an alternate CLI executable.
@@ -55,6 +63,7 @@ Set `FACET_HOME` in the host configuration when the adapter must use a non-defau
55
63
 
56
64
  | Tool | Inputs | Effect |
57
65
  | ----------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
66
+ | `facet_create` | `projectId`, `slug`, `title` | Creates an artifact; pass its returned `data.artifact.id` to publish. |
58
67
  | `facet_publish` | `artifactId`, `type`, exactly one of `sourceText` or `file`; optional `execution`, `renderer`, `note`, `parentRevisionId` | Publishes inline source through CLI stdin or reads the named local file. |
59
68
  | `facet_read_back` | `artifactId`; optional `revisionSha`, `tier` (`0` \| `1` \| `visual`) | Reads the latest or named revision. Tier 1 and visual need browser evidence. |
60
69
  | `facet_status` | optional `artifactId`, `start` | Reads status. Set `start` only when activation is intended. |
@@ -65,7 +74,15 @@ Set `FACET_HOME` in the host configuration when the adapter must use a non-defau
65
74
 
66
75
  Exactly one of `sourceText` or `file` is required. The adapter returns `invalid_request` when both or neither are supplied.
67
76
 
68
- The MCP surface is the five artifact tools; run `facet doctor` through the CLI.
77
+ Tier 0 means structure was parsed and compiled, not rendered. Request
78
+ `facet_read_back` with `tier: "visual"` before reporting a render. Visual
79
+ evidence covers the first rendered state of an interactive TSX artifact;
80
+ later interaction is not validated and does not change the stored verdict.
81
+ When testing by hand with the MCP Inspector CLI, `--tool-arg key=value` passes
82
+ `\n` literally, so multi-line `sourceText` arrives as one line; pass `file`
83
+ instead.
84
+
85
+ The MCP surface has six artifact tools. Start a cold-home flow with `facet_create`, then call `facet_publish`, `facet_read_back`, and `facet_export`. Each tool advertises its input schema in `tools/list`; run `facet doctor` through the CLI.
69
86
 
70
87
  ## Result and error handling
71
88
 
@@ -73,6 +90,10 @@ Every tool returns one text content item containing the complete versioned Facet
73
90
 
74
91
  Typed Facet failures return that same envelope with `isError: true`. The JSON body preserves `error.code`, `error.message`, `error.retryable`, and `error.details`. The adapter converts malformed CLI stdout and subprocess failures into typed `invalid_envelope` errors instead of throwing raw process text through MCP.
75
92
 
93
+ For `output_unwritable`, `error.details.out` is the absolute path whose write
94
+ failed. A malformed Mermaid fence is a verdict result, not a tool failure: the
95
+ call returns `isError: false` with `data.verdict.status: "error"`.
96
+
76
97
  ## Boundary
77
98
 
78
99
  The adapter only shells out to `facet` and parses the shared wire envelope. It does not import service, validation, or gallery code. The boundary checker permits only the MCP SDK, Zod, Node builtins, adapter-local modules, shared contracts, and the shared product version in `src/harness-adapters/mcp/`.
@@ -107,6 +107,13 @@ service.
107
107
  | `2` | Removes Tier 0 and Tier 1 network-namespace isolation. Real validators still run. |
108
108
  | `3` | Performs no validation and records `insecure:unvalidated`. |
109
109
 
110
+ Level 3 skips publish-time validation, not an explicit visual read-back.
111
+ `facet read-back --tier visual` still runs Tier 1, and that verdict carries the
112
+ insecure marker. Evidence consumers must check the marker, not infer trust from
113
+ `status` alone. A level-3 visual verdict does not count toward `facet promote`
114
+ either — the gate refuses it as `insecure:unvalidated` unless the operator
115
+ passes `--allow-unverified`.
116
+
110
117
  Levels compose as a forced floor: the effective level is never below the
111
118
  operator's `FACET_INSECURE` value. `FACET_INSECURE_AUTO=1` may raise a level when
112
119
  startup probes fail, but it never selects level 3. With auto mode off, hard
@@ -23,7 +23,7 @@ the evidence root and every per-run evidence directory.
23
23
  ## Schema migrations
24
24
 
25
25
  `runMigrations` records applied versions in `schema_migrations` and applies
26
- additive fragments in order. The current schema is v9:
26
+ additive fragments in order. The current schema is v10:
27
27
 
28
28
  | version | change |
29
29
  | ------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -36,6 +36,7 @@ additive fragments in order. The current schema is v9:
36
36
  | v7 | Backfills HTML observation defaults. |
37
37
  | v8 | Adds `tsx`, declared revision execution, and nullable `render_runs.compiled_path`. |
38
38
  | v9 | Adds `render_runs.screenshot_format`, recorded as `png` or `webp` for retained evidence. |
39
+ | v10 | Adds nullable `templates.promotion_override`; null for pre-v10 templates and for promotions that passed the verification gate. |
39
40
 
40
41
  Migrations are additive and transactional. Existing revisions are not rewritten
41
42
  when a later schema version is applied.
@@ -81,6 +82,10 @@ artifact and unlinks older screenshot and console files. Rows marked
81
82
  `retained = 1` are exempt. Cleanup is best-effort; the database row remains the
82
83
  authority and the orphan sweep can recover from stale files.
83
84
 
85
+ Facet targets one operator on a local machine. The evidence bound is the last
86
+ `EVIDENCE_LAST_N_PER_ARTIFACT` Tier 1 runs per artifact, plus any retained runs.
87
+ There is no quota; retained evidence can grow without a fixed limit.
88
+
84
89
  ## Templates
85
90
 
86
91
  A template records one immutable revision ID. Later publication to the source
@@ -29,6 +29,15 @@ and must not call `createRoot` or self-mount.
29
29
 
30
30
  ## Imports and styling
31
31
 
32
+ Artifacts cannot fetch data (`connect-src 'none'`). Generate source with the
33
+ data embedded, then publish it. For repeated updates, regenerate the file and
34
+ use `facet publish --watch` to publish each changed version:
35
+
36
+ ```sh
37
+ node -e 'const data = [{label:"A", value:3}]; require("node:fs").writeFileSync("report.tsx", `export default function Report(){ return <pre>{JSON.stringify(${JSON.stringify(data)})}</pre> }`)'
38
+ facet publish --artifact-id <id> --type tsx --file report.tsx
39
+ ```
40
+
32
41
  The compiler accepts only vendored modules: `react`, `react-dom`,
33
42
  `react-dom/client`, `react/jsx-runtime`, and `react/jsx-dev-runtime`.
34
43
  Pinned versions are React and React DOM `19.2.8`, TypeScript `5.7.3`.
@@ -62,6 +71,13 @@ has no host port or service capability.
62
71
 
63
72
  ## Storage and export
64
73
 
74
+ The TSX verifier loads the vendored `artifact.css` used by the gallery frame.
75
+ A runtime error after initial render, such as one thrown by a click handler,
76
+ appears as a gallery signal in an `aria-live="polite"` status region. It does
77
+ not rewrite the immutable verdict. It clears on revision swap. Switching the
78
+ theme keeps the frame mounted, including interactive component state and any
79
+ runtime error signal already displayed.
80
+
65
81
  Revision source remains immutable. Compilation creates derived bytes recorded at
66
82
  the run's `compiled_path`; TSX source export writes the original `.tsx` bytes.
67
83
  Render export remains the retained Tier 1 screenshot. Evidence retains the last
@@ -18,6 +18,7 @@ Every other layer is bound to it through `VerdictSchema.status`.
18
18
  | `partial:opaque_content` | An opaque DOM region was observed, so structural contents were not verified. MUST carry a screenshot path, or a typed `screenshotError` marker when capture fails transiently. |
19
19
  | `partial:external_resources` | The artifact references external HTTPS images the no-egress verifier could not observe. MUST carry a screenshot path, or a typed `screenshotError` marker when capture fails transiently. |
20
20
  | `partial:unstable` | TSX interactive mode: the structure observed at the render barrier differed from the structure observed after a bounded stability window. Deliberately NOT `tampered` — a legitimately animated or async-loading component also changes structure between observations, and branding that a forgery would manufacture the false-verdict class this project has spent three arcs eliminating. `tampered` stays reserved for channel divergence. MUST carry a screenshot path, or a typed `screenshotError` marker when capture fails transiently. |
21
+ | `partial:empty_render` | TSX Tier 1: all authoritative observations agree that the renderer root has no element children and no non-whitespace text. MUST carry a screenshot path, or a typed `screenshotError` marker when capture fails transiently. |
21
22
  | `tampered` | Page-shim or isolated-world observation diverges from protocol authority. |
22
23
  | `timeout` | The harness did not emit `render-complete` within `TIER1_RENDER_BARRIER_MS`. |
23
24
  | `shim_only` | Isolated-world channel missing; only the untrusted page-shim produced usable counts. |
@@ -43,11 +44,12 @@ ordering, top to bottom:
43
44
  3. `probe_only` / `shim_only` — channel availability (meta-claim about the channels, not the page).
44
45
  4. `error` — discriminative errors are non-empty, or observed counts disagree with the lexical expectation. Both survive a structure change: a parse error is a parse error whenever it was observed, and a count that never matched the source's expectation never matches.
45
46
  5. **`partial:unstable`** — TSX interactive mode; structure changed between the render barrier and the stability window.
46
- 6. `error` (declared-opaque, observed-zero) — the artifact declared opaque regions and none were seen. This sits BELOW `partial:unstable` deliberately: it compares the expectation against a SINGLE observation, so a structure change makes it unsafe to assert. A canvas that painted after the barrier would otherwise be reported as an artifact that never painted at all — a false accusation, which is the failure class the whole partial taxonomy exists to avoid.
47
- 7. `partial:opaque_content` — single-snapshot claim: structure has opaque regions.
48
- 8. `partial:external_resources` — single-snapshot claim: structure has external HTTP references.
49
- 9. `partial:layout_unverified` — single-snapshot claim: visible SVG with zeroed viewBoxes.
50
- 10. `ok`.
47
+ 6. `partial:empty_render` — TSX root emptiness, after instability and before other single-snapshot partials.
48
+ 7. `error` (declared-opaque, observed-zero) — the artifact declared opaque regions and none were seen.
49
+ 8. `partial:opaque_content` — single-snapshot claim: structure has opaque regions.
50
+ 9. `partial:external_resources` — single-snapshot claim: structure has external HTTP references.
51
+ 10. `partial:layout_unverified` — single-snapshot claim: visible SVG with zeroed viewBoxes.
52
+ 11. `ok`.
51
53
 
52
54
  The rule generalizing rows 4 and 6: a claim that depends on ONE observation
53
55
  loses to `partial:unstable`; a claim that holds regardless of when it was
@@ -68,13 +70,21 @@ mode 0700 — the canonical secret-bearing layout matches the DB file
68
70
  permissions.
69
71
 
70
72
  Tier 1 capture happens AFTER the verdict is derived so the
71
- `partial:layout_unverified`, `partial:opaque_content`, `partial:external_resources`, and `partial:unstable` screenshot mandates are honored. The runner
73
+ `partial:layout_unverified`, `partial:opaque_content`, `partial:external_resources`, `partial:unstable`, and `partial:empty_render` screenshot mandates are honored. The runner
72
74
  measures the whole artifact, bounds each axis at 4096 pixels and the total at
73
75
  8,388,608 pixels, and encodes evidence under the 8 MiB cap. New captures are
74
76
  WebP; legacy PNG evidence remains readable and exportable. Before static
75
77
  capture it emulates `prefers-reduced-motion: reduce` and awaits
76
78
  `document.fonts.ready`; these pre-flights keep repeated captures byte-identical.
77
79
 
80
+ Tier 1 uses the same artifact-type layout rules as the gallery frame: Mermaid,
81
+ SVG, and chart roots are safely centered on both axes, with oversized content
82
+ remaining reachable by scrolling; Markdown is top-aligned in a horizontally
83
+ centered column capped at about 92ch; HTML and TSX use top-aligned document
84
+ flow, where a fixed-width layout can center itself with `mx-auto`. The renderer-parity gate
85
+ compares both the gallery and verifier renderer-module sets and CSS/stylesheet
86
+ sets.
87
+
78
88
  Interactive TSX declares animated-capture eligibility; it does not imply that
79
89
  the component is always visibly changing. CSS/Web Animations are probed, and
80
90
  eligible captures may contain multiple WebP frames. Static artifacts retain a
@@ -90,8 +100,26 @@ browser or network-namespace failure records a Tier 1 `error` verdict with its
90
100
  typed `tier1_*` code; it does not erase the Tier 0 verdict or turn visual
91
101
  read-back into `revision_not_found`.
92
102
 
103
+ Tier 0 `ok` means the source passed structural checks; rendering is not
104
+ verified. The TTY presenter says `structure checked · rendering not verified`
105
+ and includes a visual read-back command. TSX Tier 0 reports `ok · compiled`.
106
+ The JSON envelope is unchanged. A malformed Mermaid fence returns `error` with
107
+ `mermaid_parse_error`, the parser message, and a zero-based `mermaid fence <i>`
108
+ location.
109
+
110
+ Tier 0 grammar-checks Mermaid fences but does not sanitize Mermaid label HTML;
111
+ it never renders a diagram. Sanitization happens in Tier 1, when Mermaid runs
112
+ in the real browser.
113
+
93
114
  ## Observed fields and renderer expectations
94
115
 
116
+ For Vega-Lite charts, Facet supplies 640×360 view defaults through
117
+ `config.view` only when the spec omits `autosize`, `config.view`, and both
118
+ top-level `width` and `height`. A supplied width or height is preserved while
119
+ the other dimension receives its default. An authored `config.view` or
120
+ `autosize` prevents defaulting. The composite-view defaults reach facet,
121
+ concat, and repeat children; author sizing is not replaced.
122
+
95
123
  The canonical observed fields include `rendererRootSvgCount`, `graphCount`,
96
124
  `mermaidNodeCount`, `visibleSvgCount`, `opaqueRegionCount`, `viewBoxes`,
97
125
  `errorCount`, and `discriminativeErrors`. `opaqueRegionCount` counts DOM
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@legionworks/facet",
3
- "version": "1.9.0",
3
+ "version": "1.10.1",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "artifacts",
@@ -20,8 +20,8 @@
20
20
  "url": "git+https://github.com/legion-works/facet.git"
21
21
  },
22
22
  "bin": {
23
- "facet": "./src/cli/main.ts",
24
- "facet-mcp": "./src/harness-adapters/mcp/main.ts"
23
+ "facet": "src/cli/main.ts",
24
+ "facet-mcp": "src/harness-adapters/mcp/main.ts"
25
25
  },
26
26
  "files": [
27
27
  "src/**",
@@ -42,7 +42,7 @@
42
42
  "build": "bun scripts/build-gallery.ts",
43
43
  "prepack": "bun scripts/build-gallery.ts --if-stale",
44
44
  "lint": "oxlint --deny-warnings",
45
- "format": "oxfmt",
45
+ "format": "bun scripts/check-format.ts --write",
46
46
  "format:check": "bun scripts/check-format.ts",
47
47
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.runtime-egress-fixture.json",
48
48
  "test": "bun test",
@@ -67,15 +67,33 @@ the detected evidence format:
67
67
 
68
68
  Promotion is operator-only. The CLI discovers the token from
69
69
  `FACET_PROMOTE_TOKEN`, then `FACET_HOME/secrets/promote.token`; never put a
70
- token on argv.
70
+ token on argv. First request visual read-back for the exact revision, then
71
+ promote it. Tier 0 or publication success is not visual verification. The
72
+ `templates` command lists promoted templates and their source verdicts.
73
+ `--allow-unverified` records the refusal reason as `promotionOverride` for
74
+ audit; it does not create verification or turn a refusal into a passing verdict.
75
+ A Tier 1 verdict carrying `insecure.level === 3` is refused as
76
+ `insecure:unvalidated` even when its status would otherwise be allow-listed —
77
+ levels 1 and 2 are real validator runs and keep status-based treatment.
71
78
 
72
79
  ## Error quick reference
73
80
 
81
+ Render statuses are a closed set: `ok`, `error`, `partial:layout_unverified`,
82
+ `partial:opaque_content`, `partial:external_resources`, `partial:unstable`,
83
+ `partial:empty_render`, `tampered`, `timeout`, `shim_only`, `probe_only`, and
84
+ `insecure:unvalidated`.
85
+
86
+ Tier 0 `ok` means structure checked, not rendered. For visual evidence, run
87
+ `facet read-back --artifact-id <id> --tier visual`. TSX Tier 0 `ok` means
88
+ `ok · compiled`. Tier 1 TSX can return `partial:empty_render` when all
89
+ authoritative channels agree that the renderer root has no elements or
90
+ non-whitespace text; the verdict requires a screenshot.
91
+
74
92
  | Error | Do next |
75
93
  | ------------------------ | ---------------------------------------------------------------------------------------------------------------- |
76
94
  | `invalid_request` | Correct the command flags, artifact type, or source bytes, then retry. |
77
95
  | `artifact_not_found` | List or create the artifact, then use its returned ID. |
78
- | `revision_not_found` | Omit the SHA for latest, or replace it with a returned revision SHA. |
96
+ | `revision_not_found` | Check that the artifact has a revision; omit the SHA for latest, or replace it with a returned revision SHA. |
79
97
  | `duplicate_revision` | Use `error.details.revisionSha` as the existing revision SHA; do not republish identical bytes. |
80
98
  | `evidence_unavailable` | Request Tier 1 evidence for that revision before a render export, or export source instead. |
81
99
  | `output_unwritable` | Choose a writable `--out` path, resolve collisions, or use `--force` deliberately. |
@@ -94,6 +112,10 @@ token on argv.
94
112
  or ask a human to inspect.
95
113
  - Preserve stdout exactly so callers can parse the versioned envelope.
96
114
  - With shell access, the CLI is the integration — MCP is for structured-tool-only hosts, even when the host owns an MCP client. On that surface, `facet_open_url` is safe because it always uses `--no-launch`.
115
+ - For MCP registration, use `bun add -g @legionworks/facet` and the bare
116
+ `facet-mcp` command, or the no-install fallback `npx -p @legionworks/facet
117
+ facet-mcp` (`bunx -p` also works). Use `1.10.0` or newer; earlier releases
118
+ ship a `facet-mcp` bin that cannot launch.
97
119
 
98
120
  ## Deeper reference
99
121
 
@@ -43,6 +43,7 @@ export function buildPromoteRequest(
43
43
  revisionId,
44
44
  name,
45
45
  promotedBy,
46
+ ...(args["allow-unverified"] === true ? { allowUnverified: true } : {}),
46
47
  ...(typeof artifactId === "string" ? { artifactId } : {}),
47
48
  ...(typeof description === "string" ? { description } : {}),
48
49
  };
@@ -0,0 +1,25 @@
1
+ import { generateRequestId } from "../../shared/util/time";
2
+ import { FacetError } from "../../shared/errors/facet-error";
3
+ import { MAX_LIST_LIMIT } from "../../shared/config/limits";
4
+ import type { TemplatesRequest } from "../../shared/contracts/commands/requests";
5
+
6
+ export function buildTemplatesRequest(
7
+ args: Readonly<Record<string, string | boolean>>,
8
+ ): TemplatesRequest {
9
+ const limit = args["limit"];
10
+ const limitNumber = typeof limit === "string" ? Number(limit) : undefined;
11
+ if (
12
+ limitNumber !== undefined &&
13
+ (!Number.isSafeInteger(limitNumber) || limitNumber < 1 || limitNumber > MAX_LIST_LIMIT)
14
+ ) {
15
+ throw new FacetError(
16
+ "invalid_request",
17
+ `--limit must be an integer between 1 and ${MAX_LIST_LIMIT}`,
18
+ );
19
+ }
20
+ return {
21
+ command: "templates",
22
+ requestId: generateRequestId(),
23
+ ...(limitNumber === undefined ? {} : { limit: limitNumber }),
24
+ };
25
+ }
package/src/cli/main.ts CHANGED
@@ -37,6 +37,7 @@ import { FacetClient } from "./client";
37
37
 
38
38
  import { buildCreateRequest } from "./commands/create";
39
39
  import { buildListRequest } from "./commands/list";
40
+ import { buildTemplatesRequest } from "./commands/templates";
40
41
  import { buildOpenRequest, launchDisplay } from "./commands/open";
41
42
  import { buildPinRequest } from "./commands/pin";
42
43
  import { buildPromoteRequest } from "./commands/promote";
@@ -168,6 +169,9 @@ async function executeVerb(
168
169
  case "list":
169
170
  request = buildListRequest(args);
170
171
  break;
172
+ case "templates":
173
+ request = buildTemplatesRequest(args);
174
+ break;
171
175
  case "readBack":
172
176
  request = buildReadBackRequest(args);
173
177
  break;
package/src/cli/parser.ts CHANGED
@@ -77,6 +77,7 @@ const VERB_FLAGS: Readonly<Record<CommandName, readonly FlagDefinition[]>> = {
77
77
  { flag: "--slug-prefix", takesValue: true },
78
78
  { flag: "--limit", takesValue: true },
79
79
  ],
80
+ templates: [{ flag: "--limit", takesValue: true }],
80
81
  readBack: [
81
82
  { flag: "--artifact-id", takesValue: true, required: true },
82
83
  { flag: "--revision-sha", takesValue: true },
@@ -97,6 +98,7 @@ const VERB_FLAGS: Readonly<Record<CommandName, readonly FlagDefinition[]>> = {
97
98
  { flag: "--name", takesValue: true, required: true },
98
99
  { flag: "--description", takesValue: true },
99
100
  { flag: "--promoted-by", takesValue: true, required: true },
101
+ { flag: "--allow-unverified", takesValue: false },
100
102
  ],
101
103
  instantiate: [
102
104
  { flag: "--name", takesValue: true, required: true },
@@ -121,6 +123,7 @@ const VERB_TO_COMMAND: Readonly<Record<string, CommandName>> = {
121
123
  create: "create",
122
124
  publish: "publish",
123
125
  list: "list",
126
+ templates: "templates",
124
127
  "read-back": "readBack",
125
128
  status: "status",
126
129
  open: "open",
@@ -134,6 +137,7 @@ const COMMAND_TO_VERB: Readonly<Record<CommandName, string>> = {
134
137
  create: "create",
135
138
  publish: "publish",
136
139
  list: "list",
140
+ templates: "templates",
137
141
  readBack: "read-back",
138
142
  status: "status",
139
143
  open: "open",
@@ -55,6 +55,7 @@ export const VERDICT_GLYPH: Record<RenderStatus, string> = {
55
55
  "partial:opaque_content": "◐",
56
56
  "partial:external_resources": "◐",
57
57
  "partial:unstable": "◐",
58
+ "partial:empty_render": "◐",
58
59
  tampered: "⊘",
59
60
  timeout: "◌",
60
61
  shim_only: "◇",
@@ -82,6 +83,7 @@ const STATUS_TONE: Record<RenderStatus, Tone> = {
82
83
  "partial:opaque_content": "yellow",
83
84
  "partial:external_resources": "yellow",
84
85
  "partial:unstable": "yellow",
86
+ "partial:empty_render": "yellow",
85
87
  tampered: "redInverse",
86
88
  timeout: "dim",
87
89
  shim_only: "dim",
@@ -101,6 +103,10 @@ function row(label: string, value: string): string {
101
103
  return ` ${label.padEnd(10, " ")}${value}`;
102
104
  }
103
105
 
106
+ function shortCell(value: string, width: number): string {
107
+ return value.length <= width ? value : `${value.slice(0, width - 1)}…`;
108
+ }
109
+
104
110
  function verdictLines(verdict: Verdict, caps: PresenterCaps): string[] {
105
111
  const paint = makePaint(caps);
106
112
  const tone = STATUS_TONE[verdict.status];
@@ -108,6 +114,9 @@ function verdictLines(verdict: Verdict, caps: PresenterCaps): string[] {
108
114
  const head = [
109
115
  paint(tone, `${VERDICT_GLYPH[verdict.status]} ${word}`),
110
116
  ...(detail === undefined ? [] : [paint("dim", detail)]),
117
+ ...(verdict.tier === 0 && verdict.status === "ok" && verdict.execution !== undefined
118
+ ? [paint("dim", "compiled")]
119
+ : []),
111
120
  paint("dim", `tier ${verdict.tier}`),
112
121
  `${verdict.artifactId} ${paint("dim", "@")} ${paint("cyan", sha8(verdict.revisionSha))}`,
113
122
  ].join(paint("dim", " · "));
@@ -121,6 +130,12 @@ function verdictLines(verdict: Verdict, caps: PresenterCaps): string[] {
121
130
  "observed",
122
131
  `svg ${o.rendererRootSvgCount} · graphs ${o.graphCount} · nodes ${o.mermaidNodeCount} · errors ${o.errorCount}`,
123
132
  ),
133
+ ...(verdict.tier === 0 && verdict.status === "ok"
134
+ ? [
135
+ " structure checked · rendering not verified",
136
+ row("verify", `facet read-back --artifact-id ${verdict.artifactId} --tier visual`),
137
+ ]
138
+ : []),
124
139
  ];
125
140
  const first = o.discriminativeErrors?.[0];
126
141
  if (first !== undefined)
@@ -163,6 +178,15 @@ export function presentEnvelope(envelope: FacetEnvelope<unknown>, caps: Presente
163
178
  if (revision?.sha256 !== undefined) {
164
179
  lines.push(row("read-back", `facet read-back --revision-sha ${revision.sha256}`));
165
180
  }
181
+ const tier0 = data["verdict"];
182
+ if (
183
+ typeof tier0 === "object" &&
184
+ tier0 !== null &&
185
+ (tier0 as Verdict).tier === 0 &&
186
+ (tier0 as Verdict).status === "ok"
187
+ ) {
188
+ lines.push(...verdictLines(tier0 as Verdict, caps));
189
+ }
166
190
  const tier1 = data["tier1Verdict"];
167
191
  if (typeof tier1 === "object" && tier1 !== null) {
168
192
  lines.push(...verdictLines(tier1 as Verdict, caps));
@@ -188,6 +212,30 @@ export function presentEnvelope(envelope: FacetEnvelope<unknown>, caps: Presente
188
212
  }
189
213
  }
190
214
 
215
+ if (command === "templates") {
216
+ const templates = Array.isArray(data["templates"]) ? data["templates"] : [];
217
+ const lines = ["NAME · SHA · VERDICT · OVERRIDE · BY · DATE"];
218
+ for (const entry of templates) {
219
+ const item = entry as Record<string, unknown>;
220
+ const verdict = item["sourceVerdict"] as { status: RenderStatus; tier: number } | null;
221
+ lines.push(
222
+ [
223
+ shortCell(String(item["name"]), 12),
224
+ String(item["revisionSha"]).slice(0, 12),
225
+ verdict === null
226
+ ? "—"
227
+ : `${VERDICT_GLYPH[verdict.status]} ${verdict.status} t${verdict.tier}`,
228
+ item["promotionOverride"] === null
229
+ ? ""
230
+ : shortCell(String(item["promotionOverride"]), 12),
231
+ shortCell(String(item["promotedBy"]), 8),
232
+ String(item["promotedAt"]).slice(0, 10),
233
+ ].join(" · "),
234
+ );
235
+ }
236
+ return lines;
237
+ }
238
+
191
239
  if (command === "doctor") {
192
240
  const probes = Array.isArray(data["probes"]) ? data["probes"] : [];
193
241
  const lines: string[] = [];
@@ -23,7 +23,12 @@
23
23
  * surfaces an error badge.
24
24
  */
25
25
 
26
- import { assertLoopbackHostname, buildFrameAttributes, type FrameAttributes } from "./frame-html";
26
+ import {
27
+ assertLoopbackHostname,
28
+ buildFrameAttributes,
29
+ usesArtifactStylesheet,
30
+ type FrameAttributes,
31
+ } from "./frame-html";
27
32
  import { planSwap, type SwapPlanStep } from "./swap";
28
33
  import { connectRevisionStream } from "./sse-client";
29
34
  import type { VerdictObserved } from "../shared/contracts/validation";
@@ -376,6 +381,7 @@ export interface CreatedArtifactFrame {
376
381
  payload: FrameRenderPayload,
377
382
  timeoutMs: number,
378
383
  ) => Promise<FrameRenderResultHandle>;
384
+ readonly setTheme: (theme: ResolvedGalleryTheme) => void;
379
385
  /** Handle from the last successful render (null before the first). */
380
386
  readonly renderResult: FrameRenderResultHandle | null;
381
387
  }
@@ -488,6 +494,14 @@ export function createArtifactFrame(options: CreateArtifactFrameOptions): Create
488
494
  renderResult = handle;
489
495
  return handle;
490
496
  },
497
+ setTheme(theme) {
498
+ // oxlint-disable-next-line no-underscore-dangle
499
+ const api = raw.contentWindow?.__facetFrame;
500
+ if (api === null || api === undefined || typeof api.setTheme !== "function") {
501
+ throw new Error("frame theme API unavailable");
502
+ }
503
+ api.setTheme(theme);
504
+ },
491
505
  get renderResult(): FrameRenderResultHandle | null {
492
506
  return renderResult;
493
507
  },
@@ -888,7 +902,9 @@ function setGalleryVerdict(document: Document, verdict: Verdict | null): void {
888
902
  ? "external"
889
903
  : verdict.status === "partial:unstable"
890
904
  ? "unstable"
891
- : null;
905
+ : verdict.status === "partial:empty_render"
906
+ ? "empty"
907
+ : null;
892
908
  const insecure = verdict.insecure === undefined ? null : `INSECURE L${verdict.insecure.level}`;
893
909
  const suffix = insecure === null ? `T${verdict.tier}` : `${insecure} · T${verdict.tier}`;
894
910
  tier.textContent = detail === null ? `· ${suffix}` : `· ${detail} · ${suffix}`;
@@ -1091,6 +1107,42 @@ export async function startGallery(runtime = browserGalleryRuntime()): Promise<v
1091
1107
  let removeThemePreferenceListener: () => void = noOp;
1092
1108
  let generation = 0;
1093
1109
  let activeFrame: CreatedArtifactFrame | null = null;
1110
+ let removeInteractionListener: () => void = noOp;
1111
+ // Every path that leaves the displayed frame ends the signal: a stale flag would paint the
1112
+ // previous artifact's runtime error onto whatever the shell shows next.
1113
+ const endInteractionSignal = (): void => {
1114
+ removeInteractionListener();
1115
+ const badge = document.getElementById("facet-verdict");
1116
+ if (badge !== null) delete badge.dataset.interactionError;
1117
+ };
1118
+ const activateInteractionSignal = (frame: CreatedArtifactFrame): void => {
1119
+ endInteractionSignal();
1120
+ const badge = document.getElementById("facet-verdict");
1121
+ updateGalleryStatus("displayed");
1122
+ const frameWindow = (frame.element.raw as HTMLIFrameElement).contentWindow;
1123
+ if (frameWindow === null || typeof frameWindow.addEventListener !== "function") return;
1124
+ const markFailure = (): void => {
1125
+ if (expired || activeFrame !== frame || frame.renderResult === null) return;
1126
+ if (badge !== null) badge.dataset.interactionError = "true";
1127
+ updateGalleryStatus("displayed · runtime error during interaction");
1128
+ };
1129
+ frameWindow.addEventListener("error", markFailure, true);
1130
+ frameWindow.addEventListener("unhandledrejection", markFailure, true);
1131
+ const frameDocument = (frame.element.raw as HTMLIFrameElement).contentDocument;
1132
+ const observer =
1133
+ frameDocument == null || typeof MutationObserver !== "function"
1134
+ ? null
1135
+ : new MutationObserver(() => {
1136
+ if (frameDocument.querySelector("[data-facet-error]")) markFailure();
1137
+ });
1138
+ if (frameDocument !== null)
1139
+ observer?.observe(frameDocument, { childList: true, subtree: true });
1140
+ removeInteractionListener = (): void => {
1141
+ frameWindow.removeEventListener("error", markFailure, true);
1142
+ frameWindow.removeEventListener("unhandledrejection", markFailure, true);
1143
+ observer?.disconnect();
1144
+ };
1145
+ };
1094
1146
  const expireSession = (): void => {
1095
1147
  if (expired) return;
1096
1148
  generation += 1;
@@ -1100,6 +1152,7 @@ export async function startGallery(runtime = browserGalleryRuntime()): Promise<v
1100
1152
  exportMenu?.sync();
1101
1153
  activeFrame?.renderResult?.setGestureMode("native");
1102
1154
  swaps?.close();
1155
+ endInteractionSignal();
1103
1156
  removeThemePreferenceListener();
1104
1157
  clearSession(window.sessionStorage);
1105
1158
  renderSessionExpired(
@@ -1237,6 +1290,7 @@ export async function startGallery(runtime = browserGalleryRuntime()): Promise<v
1237
1290
  updateGalleryFavicon(source.verdict?.status ?? "unverified");
1238
1291
  updateGalleryVerdict(source.verdict ?? null);
1239
1292
  updateGalleryStatus("displayed");
1293
+ activateInteractionSignal(current);
1240
1294
  updateLiveState("live");
1241
1295
  const themeToggle = document.getElementById("facet-theme-toggle");
1242
1296
  const updateThemeToggle = (mode: GalleryThemeMode): void => {
@@ -1269,6 +1323,11 @@ export async function startGallery(runtime = browserGalleryRuntime()): Promise<v
1269
1323
  const canCommit = (): boolean =>
1270
1324
  themeGeneration === generation && nextMode === requestedThemeMode;
1271
1325
  if (!canCommit()) return;
1326
+ if (usesArtifactStylesheet(source.artifactType)) {
1327
+ current.setTheme(nextResolvedTheme);
1328
+ commitTheme(nextMode, nextResolvedTheme);
1329
+ return;
1330
+ }
1272
1331
  const next = createArtifactFrame({
1273
1332
  artifactType: source.artifactType,
1274
1333
  renderer: source.renderer,
@@ -1295,6 +1354,7 @@ export async function startGallery(runtime = browserGalleryRuntime()): Promise<v
1295
1354
  if (!canCommit() || result.cancelled || result.failedNewFrameReady) return;
1296
1355
  current = next;
1297
1356
  activeFrame = next;
1357
+ activateInteractionSignal(next);
1298
1358
  commitTheme(nextMode, nextResolvedTheme);
1299
1359
  syncPanZoomToggle();
1300
1360
  syncZoomButtons();
@@ -1373,6 +1433,7 @@ export async function startGallery(runtime = browserGalleryRuntime()): Promise<v
1373
1433
  if (!result.failedNewFrameReady) {
1374
1434
  current = frame;
1375
1435
  activeFrame = frame;
1436
+ activateInteractionSignal(frame);
1376
1437
  source = revision;
1377
1438
  commitTheme(revisionThemeMode, revisionTheme);
1378
1439
  syncPanZoomToggle();
@@ -1439,6 +1500,7 @@ export async function startGallery(runtime = browserGalleryRuntime()): Promise<v
1439
1500
  updateSwapBar("start");
1440
1501
  updateGalleryFavicon("unverified");
1441
1502
  updateGalleryVerdict(null);
1503
+ endInteractionSignal();
1442
1504
  swaps?.enqueue({ kind: "revision", ...event });
1443
1505
  },
1444
1506
  onClose: (event) => {
@@ -1472,6 +1534,7 @@ export async function startGallery(runtime = browserGalleryRuntime()): Promise<v
1472
1534
  // releases the lease when the TTL fires, which is the only path
1473
1535
  // that lets "refresh the tab" reach the same displayed canvas.
1474
1536
  stream.close();
1537
+ removeInteractionListener();
1475
1538
  removeThemePreferenceListener();
1476
1539
  if (zoomButtonPoll !== undefined) clearInterval(zoomButtonPoll);
1477
1540
  };