planr 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +9 -56
  2. package/docs/ARCHITECTURE.md +4 -4
  3. package/docs/CLI_REFERENCE.md +6 -14
  4. package/docs/CODEX.md +6 -6
  5. package/docs/EXAMPLE_WEBAPP.md +63 -97
  6. package/docs/GOALS.md +8 -25
  7. package/docs/INSTALL.md +2 -2
  8. package/docs/MCP_CONTRACT.md +2 -5
  9. package/docs/MODEL_ROUTING.md +20 -177
  10. package/docs/ROUTING_BUNDLES.md +32 -0
  11. package/docs/SKILLS.md +5 -3
  12. package/docs/fixtures/mcp-contract.json +3 -11
  13. package/npm/native/darwin-arm64/planr +0 -0
  14. package/npm/native/darwin-x86_64/planr +0 -0
  15. package/npm/native/linux-arm64/planr +0 -0
  16. package/npm/native/linux-x86_64/planr +0 -0
  17. package/package.json +2 -14
  18. package/plugins/planr/.claude-plugin/plugin.json +1 -1
  19. package/plugins/planr/.codex-plugin/plugin.json +1 -1
  20. package/plugins/planr/skills/planr-loop/SKILL.md +1 -1
  21. package/docs/PRESET_COMPOSITION.md +0 -109
  22. package/docs/PRESET_EVALUATION.md +0 -118
  23. package/docs/PRESET_REGISTRY.md +0 -61
  24. package/evaluations/preset-suite-v1.toml +0 -127
  25. package/evaluations/sol-luna-codex-v1.toml +0 -42
  26. package/plugins/planr/skills/planr-loop/agents/planr-reviewer.toml +0 -23
  27. package/plugins/planr/skills/planr-loop/agents/planr-worker.toml +0 -21
  28. package/presets/bindings/claude-native.toml +0 -52
  29. package/presets/bindings/codex-openai.toml +0 -56
  30. package/presets/bindings/cursor-fable-grok.toml +0 -49
  31. package/presets/bindings/cursor-openai.toml +0 -49
  32. package/presets/bindings/mixed-host.toml +0 -64
  33. package/presets/policies/balanced.toml +0 -50
  34. package/presets/policies/low-usage.toml +0 -50
  35. package/presets/policies/max-quality.toml +0 -50
  36. package/presets/policies/read-only-audit.toml +0 -50
  37. package/website/README.md +0 -79
  38. package/website/_headers +0 -7
  39. package/website/alchemy-runtime.test.mjs +0 -21
  40. package/website/app.mjs +0 -216
  41. package/website/build-catalog.mjs +0 -135
  42. package/website/build-site.test.mjs +0 -69
  43. package/website/catalog-model.mjs +0 -185
  44. package/website/catalog-model.test.mjs +0 -124
  45. package/website/cloudflare-launcher.test.mjs +0 -38
  46. package/website/data/catalog.json +0 -307
  47. package/website/index.html +0 -122
  48. package/website/registry/manifest.toml +0 -48
  49. package/website/registry/report.md +0 -33
  50. package/website/registry/trusted-maintainers.toml +0 -6
  51. package/website/registry/verification.json +0 -7258
  52. package/website/serve.mjs +0 -41
  53. package/website/styles.css +0 -201
  54. package/website/test-fixtures/recommended.json +0 -72
@@ -1,50 +0,0 @@
1
- schema_version = 1
2
- id = "max-quality"
3
- version = "1.0.0"
4
-
5
- [usage]
6
- max_active_agents = 4
7
- max_parallel_readers = 3
8
- max_parallel_writers = 1
9
- max_depth = 1
10
- max_attempts = 5
11
- max_wall_time_seconds = 7200
12
- max_tool_calls = 240
13
- review_reserve_percent = 40
14
- budget_exhaustion = "stop"
15
- metering = "estimated"
16
-
17
- [transitions.retry]
18
- max_same_route_retries = 1
19
-
20
- [transitions.availability_fallback]
21
- max_fallbacks = 1
22
- require_same_capability_class = true
23
-
24
- [transitions.quality_escalation]
25
- max_escalations = 2
26
- require_verification_evidence = true
27
-
28
- [transitions.quota_downgrade]
29
- enabled = false
30
- max_downgrades = 0
31
- noncritical_only = true
32
-
33
- [transitions.safety_stop]
34
- enabled = true
35
-
36
- [materiality]
37
- changed_files_threshold = 5
38
- changed_lines_threshold = 200
39
-
40
- [execution]
41
- max_read_scope_entries = 12
42
- max_write_scope_entries = 6
43
-
44
- [execution.roles.worker]
45
- tools = ["cargo", "git", "rg"]
46
-
47
- [execution.roles.worker.filesystem]
48
- read_roots = ["src", "tests", "docs"]
49
- write_roots = ["src", "tests", "docs"]
50
- allow_overwrite = false
@@ -1,50 +0,0 @@
1
- schema_version = 1
2
- id = "read-only-audit"
3
- version = "1.0.0"
4
-
5
- [usage]
6
- max_active_agents = 3
7
- max_parallel_readers = 3
8
- max_parallel_writers = 0
9
- max_depth = 1
10
- max_attempts = 2
11
- max_wall_time_seconds = 1800
12
- max_tool_calls = 80
13
- review_reserve_percent = 20
14
- budget_exhaustion = "stop"
15
- metering = "estimated"
16
-
17
- [transitions.retry]
18
- max_same_route_retries = 0
19
-
20
- [transitions.availability_fallback]
21
- max_fallbacks = 1
22
- require_same_capability_class = true
23
-
24
- [transitions.quality_escalation]
25
- max_escalations = 0
26
- require_verification_evidence = true
27
-
28
- [transitions.quota_downgrade]
29
- enabled = false
30
- max_downgrades = 0
31
- noncritical_only = true
32
-
33
- [transitions.safety_stop]
34
- enabled = true
35
-
36
- [materiality]
37
- changed_files_threshold = 1
38
- changed_lines_threshold = 1
39
-
40
- [execution]
41
- max_read_scope_entries = 12
42
- max_write_scope_entries = 1
43
-
44
- [execution.roles.worker]
45
- tools = ["git", "rg"]
46
-
47
- [execution.roles.worker.filesystem]
48
- read_roots = ["src", "tests", "docs"]
49
- write_roots = []
50
- allow_overwrite = false
package/website/README.md DELETED
@@ -1,79 +0,0 @@
1
- # Planr Preset Catalog
2
-
3
- This directory is a dependency-free static website. Production catalog data is never authored by hand: `build-catalog.mjs` invokes Planr's canonical registry verifier, requires a trusted maintainer signature and safe policy/binding preview, binds status to the evaluation report, and writes `data/catalog.json`.
4
-
5
- The committed catalog is generated from the included `registry/manifest.toml`, canonical `registry/verification.json`, and separately pinned public maintainer trust store. Its private release and telemetry signing keys are intentionally not committed. If verification expires, is deprecated, or no longer earns recommendation, the generated catalog preserves the visible lifecycle state instead of inventing or retaining a recommendation.
6
-
7
- ```sh
8
- node website/build-catalog.mjs \
9
- --planr-bin ./target/release/planr \
10
- --manifest website/registry/manifest.toml \
11
- --content-root . \
12
- --trust-store website/registry/trusted-maintainers.toml \
13
- --entry balanced-codex=codex \
14
- --at-unix 1783987200 \
15
- --output website/data/catalog.json
16
-
17
- npm run site:test
18
- npm run site:serve
19
- ```
20
-
21
- ## Cloudflare test deployment
22
-
23
- Cloudflare infrastructure is owned by the repository-root `alchemy.run.ts`. The
24
- deployment publishes a clean `dist/website` containing only runtime assets; tests,
25
- fixtures, registry inputs, trust stores, and build tooling are never uploaded.
26
-
27
- Install the pinned development dependency and authenticate Cloudflare using an existing
28
- Alchemy/Cloudflare profile, Wrangler login, or a least-privilege API token. Set the
29
- account id from `.env.example` in the process environment when account inference would
30
- be ambiguous. Planr never runs login/configure commands and never changes user-level
31
- configuration. This static stack has no secret bindings, so it deliberately requires no
32
- `ALCHEMY_PASSWORD` or repository-local secret file.
33
-
34
- The published Planr CLI retains its declared Node.js 18+ compatibility. Repository
35
- dependency installation and the separate Cloudflare deployment toolchain require Node.js
36
- 22+ because the pinned pnpm/Alchemy lockfile contains current tooling with that minimum.
37
- Select Node 22 or newer before invoking pnpm. On an unknown machine, this pnpm-free
38
- preflight is safe to run first even with Node 18 or 20:
39
-
40
- ```sh
41
- node scripts/check-alchemy-runtime.mjs
42
- ```
43
-
44
- The authoritative deployment launcher performs the same check before it starts pnpm or
45
- Alchemy, so older runtimes receive Planr's error instead of a Corepack or dependency crash.
46
-
47
- ```sh
48
- pnpm install
49
- pnpm site:check
50
- node scripts/cloudflare-test.mjs deploy
51
- ```
52
-
53
- After Node 22+ is active, `pnpm deploy:test` is an equivalent convenience alias.
54
-
55
- The committed `pnpm-workspace.yaml` is a repository-only dependency security policy:
56
- it allows the `esbuild` and `workerd` install scripts required by Alchemy and explicitly
57
- keeps the unused transitive `sharp` build disabled. It does not modify global pnpm state.
58
-
59
- `deploy:test` always targets the isolated Alchemy stage `test`. It creates a standalone
60
- Cloudflare Worker website and prints its Cloudflare-assigned `workers.dev` URL. The
61
- initial deployment deliberately configures no custom domain, DNS route, backend resource,
62
- or adoption of an existing Worker.
63
-
64
- Removing that test website is an explicit, separate operation:
65
-
66
- ```sh
67
- node scripts/cloudflare-test.mjs destroy
68
- ```
69
-
70
- After Node 22+ is active, `pnpm destroy:test` is an equivalent convenience alias.
71
-
72
- Never add private registry or telemetry signing keys to the deployment environment. The
73
- site deploy consumes only the already verified public `website/data/catalog.json`.
74
-
75
- Release operators must regenerate the evaluation report through `planr agents preset evaluate` with an independently pinned telemetry collector, update the manifest artifact digest, and sign the registry entry with the corresponding offline maintainer key before rebuilding this file. The public `registry/report.md` summarizes that evidence; the machine report and signature remain the verification source of truth.
76
-
77
- The report-wide `reproducible_evidence` flag summarizes the entire candidate matrix. Registry publication independently requires the selected candidate's complete reproducible evidence, passing thresholds, verified route receipts, and matching entry in `report.recommended`; candidates that fail those gates are not published as recommendations.
78
-
79
- The localhost-only `?fixture=recommended` query remains available for isolated UI regression checks. It is visibly labeled, ignored on non-local hosts, and never substitutes for live verification of the production `data/catalog.json` path.
package/website/_headers DELETED
@@ -1,7 +0,0 @@
1
- /*
2
- Cache-Control: public, max-age=0, must-revalidate
3
- Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
4
- Permissions-Policy: camera=(), geolocation=(), microphone=(), payment=(), usb=()
5
- Referrer-Policy: no-referrer
6
- X-Content-Type-Options: nosniff
7
- X-Frame-Options: DENY
@@ -1,21 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import test from "node:test";
3
- import {
4
- assertAlchemyRuntime,
5
- MINIMUM_ALCHEMY_NODE_MAJOR,
6
- } from "../scripts/check-alchemy-runtime.mjs";
7
-
8
- test("accepts the minimum supported Alchemy deployment runtime", () => {
9
- assert.equal(assertAlchemyRuntime("22.0.0"), MINIMUM_ALCHEMY_NODE_MAJOR);
10
- });
11
-
12
- test("rejects Node runtimes supported by the Planr CLI but not by Alchemy", () => {
13
- assert.throws(
14
- () => assertAlchemyRuntime("18.16.1"),
15
- /Cloudflare deployment requires Node\.js 22 or newer; current runtime is 18\.16\.1/,
16
- );
17
- assert.throws(
18
- () => assertAlchemyRuntime("20.19.5"),
19
- /Cloudflare deployment requires Node\.js 22 or newer/,
20
- );
21
- });
package/website/app.mjs DELETED
@@ -1,216 +0,0 @@
1
- import { formatBasisPoints, previewCommand, statusLabel, visibleCompositions } from "./catalog-model.mjs";
2
-
3
- const nodes = {
4
- selectA: document.querySelector("#composition-a"),
5
- selectB: document.querySelector("#composition-b"),
6
- cardA: document.querySelector("#comparison-a"),
7
- cardB: document.querySelector("#comparison-b"),
8
- comparison: document.querySelector("#comparison"),
9
- empty: document.querySelector("#empty-state"),
10
- emptyMessage: document.querySelector("#empty-message"),
11
- filter: document.querySelector("#recommended-only"),
12
- published: document.querySelector("#stat-published"),
13
- recommended: document.querySelector("#stat-recommended"),
14
- trust: document.querySelector("#stat-trust"),
15
- generated: document.querySelector("#generated-at"),
16
- copyStatus: document.querySelector("#copy-status"),
17
- fixtureBanner: document.querySelector("#fixture-banner"),
18
- };
19
-
20
- function catalogLocation() {
21
- const local = ["127.0.0.1", "localhost"].includes(location.hostname);
22
- const fixture = new URLSearchParams(location.search).get("fixture");
23
- if (local && fixture === "recommended") {
24
- nodes.fixtureBanner.hidden = false;
25
- return "./test-fixtures/recommended.json";
26
- }
27
- return "./data/catalog.json";
28
- }
29
-
30
- function text(parent, element, value, className) {
31
- const node = document.createElement(element);
32
- if (className) node.className = className;
33
- node.textContent = value;
34
- parent.append(node);
35
- return node;
36
- }
37
-
38
- function formatDate(unix) {
39
- if (!Number.isFinite(unix)) return "Not published";
40
- return new Intl.DateTimeFormat("en", { dateStyle: "medium", timeZone: "UTC" }).format(new Date(unix * 1000));
41
- }
42
-
43
- function formatNumber(value) {
44
- return Number.isFinite(Number(value)) ? new Intl.NumberFormat("en").format(Number(value)) : "—";
45
- }
46
-
47
- function optionLabel(entry) {
48
- return `${entry.policy.id} + ${entry.binding.id} · ${statusLabel(entry.status)}`;
49
- }
50
-
51
- function fillSelect(select, entries, preferredIndex) {
52
- const previous = select.value;
53
- select.replaceChildren();
54
- for (const entry of entries) {
55
- const option = document.createElement("option");
56
- option.value = entry.id;
57
- option.textContent = optionLabel(entry);
58
- select.append(option);
59
- }
60
- select.value = entries.some((entry) => entry.id === previous) ? previous : entries[Math.min(preferredIndex, entries.length - 1)]?.id ?? "";
61
- }
62
-
63
- function metric(parent, label, value) {
64
- const wrapper = document.createElement("div");
65
- wrapper.className = "metric";
66
- text(wrapper, "span", label);
67
- text(wrapper, "strong", value);
68
- parent.append(wrapper);
69
- }
70
-
71
- function section(parent, title) {
72
- const wrapper = document.createElement("section");
73
- wrapper.className = "card-section";
74
- text(wrapper, "h4", title);
75
- parent.append(wrapper);
76
- return wrapper;
77
- }
78
-
79
- function definitionRow(list, termValue, detailValue) {
80
- const row = document.createElement("div");
81
- text(row, "dt", termValue);
82
- text(row, "dd", detailValue);
83
- list.append(row);
84
- }
85
-
86
- function renderCard(card, entry) {
87
- card.replaceChildren();
88
- if (!entry) return;
89
- card.dataset.compositionId = entry.id;
90
-
91
- const top = document.createElement("div");
92
- top.className = "card-top";
93
- const title = document.createElement("div");
94
- text(title, "span", `${entry.binding.id} · ${entry.binding.host}`, "binding-name");
95
- text(title, "h3", entry.policy.id);
96
- const badge = text(top, "span", statusLabel(entry.status), `badge status-${entry.status}`);
97
- badge.setAttribute("aria-label", `Status: ${statusLabel(entry.status)}`);
98
- top.prepend(title);
99
- card.append(top);
100
- text(card, "p", `Policy ${entry.policy.version} · Binding ${entry.binding.version} · Entry ${entry.entryVersion}`, "meta-line");
101
-
102
- const metrics = document.createElement("div");
103
- metrics.className = "metric-grid";
104
- metric(metrics, "Active agents", formatNumber(entry.policy.usage.max_active_agents));
105
- metric(metrics, "Parallel writers", formatNumber(entry.policy.usage.max_parallel_writers));
106
- metric(metrics, "Max depth", formatNumber(entry.policy.usage.max_depth));
107
- metric(metrics, "Metering", entry.policy.usage.metering ?? "unavailable");
108
- card.append(metrics);
109
-
110
- const compatibility = section(card, "Compatibility");
111
- const compatibilityList = document.createElement("dl");
112
- compatibilityList.className = "compatibility-list";
113
- definitionRow(
114
- compatibilityList,
115
- "Supported hosts",
116
- entry.compatibility.hosts?.length ? entry.compatibility.hosts.join(", ") : "No compatible host declared",
117
- );
118
- definitionRow(
119
- compatibilityList,
120
- "Planr versions",
121
- `${entry.compatibility.minPlanrVersion ?? "Any"} through ${entry.compatibility.maxPlanrVersion ?? "latest"}`,
122
- );
123
- definitionRow(
124
- compatibilityList,
125
- "Lifecycle",
126
- entry.replacement
127
- ? `${statusLabel(entry.status)}; replace with ${entry.replacement}`
128
- : statusLabel(entry.status),
129
- );
130
- compatibility.append(compatibilityList);
131
-
132
- const enforcement = section(card, "Enforcement matrix");
133
- const list = document.createElement("dl");
134
- list.className = "enforcement-list";
135
- for (const item of entry.enforcement) {
136
- const row = document.createElement("div");
137
- const term = text(row, "dt", item.dimension);
138
- text(term, "span", item.state.replaceAll("_", " "), "state");
139
- text(row, "dd", item.detail);
140
- list.append(row);
141
- }
142
- enforcement.append(list);
143
-
144
- const evidence = section(card, "Evaluation & provenance");
145
- text(evidence, "p", `${entry.evaluation.suiteId} ${entry.evaluation.suiteVersion} · evaluated ${formatDate(entry.evaluation.evaluatedAtUnix)} · review ${formatDate(entry.evaluation.reviewAtUnix)}`, "meta-line");
146
- text(evidence, "p", `Quality ${formatBasisPoints(entry.evaluation.metrics?.average_quality_score_bps)} · runs ${formatNumber(entry.evaluation.metrics?.runs)} · oracle passes ${formatNumber(entry.evaluation.metrics?.oracle_passes)}`, "meta-line");
147
- text(evidence, "p", `Manifest ${entry.registry.manifestSha256}`, "hash");
148
- text(evidence, "p", `Signer ${entry.registry.signer} · signature verified · trusted maintainer`, "meta-line");
149
-
150
- const commandSection = section(card, "Preview safely");
151
- const commandBlock = document.createElement("div");
152
- commandBlock.className = "command-block";
153
- const command = previewCommand(entry.policy.id, entry.binding.id);
154
- text(commandBlock, "code", command);
155
- const button = text(commandBlock, "button", "Copy preview command", "copy-button");
156
- button.type = "button";
157
- button.dataset.command = command;
158
- commandSection.append(commandBlock);
159
- }
160
-
161
- function wireCopy() {
162
- document.addEventListener("click", async (event) => {
163
- const button = event.target.closest("button[data-command]");
164
- if (!button) return;
165
- try {
166
- await navigator.clipboard.writeText(button.dataset.command);
167
- button.textContent = "Copied";
168
- nodes.copyStatus.textContent = `Copied preview command for ${button.closest(".preset-card").dataset.compositionId}`;
169
- setTimeout(() => { button.textContent = "Copy preview command"; }, 1400);
170
- } catch {
171
- nodes.copyStatus.textContent = "Clipboard access was unavailable; select the command text to copy it manually.";
172
- }
173
- });
174
- }
175
-
176
- function render(catalog) {
177
- const entries = visibleCompositions(catalog, nodes.filter.checked);
178
- nodes.published.textContent = formatNumber(catalog.compositions.length);
179
- nodes.recommended.textContent = formatNumber(catalog.compositions.filter((entry) => entry.recommended).length);
180
- nodes.trust.textContent = catalog.source?.state === "verified_registry_projection" ? "Signed registry" : "Publication gated";
181
- nodes.generated.textContent = Number.isFinite(catalog.generatedAtUnix)
182
- ? `Projection generated ${formatDate(catalog.generatedAtUnix)} · ${catalog.source.trust}`
183
- : "No signed registry projection published.";
184
- fillSelect(nodes.selectA, entries, 0);
185
- fillSelect(nodes.selectB, entries, 1);
186
- const empty = entries.length === 0;
187
- nodes.empty.hidden = !empty;
188
- nodes.comparison.hidden = empty;
189
- if (empty) {
190
- nodes.emptyMessage.textContent = catalog.source?.message ?? (nodes.filter.checked ? "No published composition currently carries an evaluation-backed recommendation." : "No trusted compositions are published.");
191
- return;
192
- }
193
- const find = (id) => entries.find((entry) => entry.id === id);
194
- renderCard(nodes.cardA, find(nodes.selectA.value));
195
- renderCard(nodes.cardB, find(nodes.selectB.value));
196
- }
197
-
198
- async function start() {
199
- wireCopy();
200
- const response = await fetch(catalogLocation(), { cache: "no-store" });
201
- if (!response.ok) throw new Error(`catalog request failed with HTTP ${response.status}`);
202
- const catalog = await response.json();
203
- if (catalog.schemaVersion !== 1 || !Array.isArray(catalog.compositions)) throw new Error("unsupported catalog data");
204
- const update = () => render(catalog);
205
- nodes.filter.addEventListener("change", update);
206
- nodes.selectA.addEventListener("change", update);
207
- nodes.selectB.addEventListener("change", update);
208
- update();
209
- }
210
-
211
- start().catch((error) => {
212
- nodes.empty.hidden = false;
213
- nodes.comparison.hidden = true;
214
- nodes.emptyMessage.textContent = `Catalog unavailable: ${error.message}`;
215
- nodes.trust.textContent = "Unavailable";
216
- });
@@ -1,135 +0,0 @@
1
- #!/usr/bin/env node
2
- import { spawnSync } from "node:child_process";
3
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
- import { mkdir } from "node:fs/promises";
5
- import { tmpdir } from "node:os";
6
- import { dirname, isAbsolute, join, resolve } from "node:path";
7
- import { projectComposition, safeIdentifier } from "./catalog-model.mjs";
8
-
9
- function usage(message) {
10
- if (message) console.error(message);
11
- console.error(
12
- "usage: node website/build-catalog.mjs --manifest <registry.toml> --content-root <dir> --trust-store <trusted.toml> --entry <id=host> [--entry <id=host> ...] --at-unix <unix> --output <catalog.json> [--planr-bin <planr>]",
13
- );
14
- process.exit(2);
15
- }
16
-
17
- function parseArgs(argv) {
18
- const values = { entries: [], planrBin: "planr" };
19
- for (let index = 0; index < argv.length; index += 1) {
20
- const flag = argv[index];
21
- const value = argv[index + 1];
22
- if (!value) usage(`missing value for ${flag}`);
23
- index += 1;
24
- if (flag === "--entry") values.entries.push(value);
25
- else if (flag === "--manifest") values.manifest = value;
26
- else if (flag === "--content-root") values.contentRoot = value;
27
- else if (flag === "--trust-store") values.trustStore = value;
28
- else if (flag === "--at-unix") values.atUnix = Number(value);
29
- else if (flag === "--output") values.output = value;
30
- else if (flag === "--planr-bin") values.planrBin = value;
31
- else usage(`unknown argument ${flag}`);
32
- }
33
- if (
34
- !values.manifest ||
35
- !values.contentRoot ||
36
- !values.trustStore ||
37
- !values.output ||
38
- !Number.isSafeInteger(values.atUnix) ||
39
- values.entries.length === 0
40
- ) {
41
- usage("all required arguments must be provided");
42
- }
43
- return values;
44
- }
45
-
46
- function runJson(binary, args, cwd) {
47
- const result = spawnSync(binary, args, { cwd, encoding: "utf8" });
48
- if (result.error) throw result.error;
49
- if (result.status !== 0) {
50
- throw new Error(result.stderr.trim() || result.stdout.trim() || `${binary} exited ${result.status}`);
51
- }
52
- try {
53
- return JSON.parse(result.stdout);
54
- } catch (error) {
55
- throw new Error(`${binary} returned invalid JSON: ${error.message}`);
56
- }
57
- }
58
-
59
- function entrySpec(raw) {
60
- const separator = raw.lastIndexOf("=");
61
- if (separator < 1 || separator === raw.length - 1) usage(`invalid --entry ${raw}; expected id=host`);
62
- return { id: raw.slice(0, separator), host: raw.slice(separator + 1) };
63
- }
64
-
65
- function artifactPath(verified, kind, contentRoot) {
66
- const matches = verified.entry.artifacts.filter((artifact) => artifact.kind === kind);
67
- if (matches.length !== 1) throw new Error(`entry ${verified.entry.id} must contain one ${kind}`);
68
- return resolve(contentRoot, matches[0].path);
69
- }
70
-
71
- const options = parseArgs(process.argv.slice(2));
72
- const invocationRoot = process.cwd();
73
- const planrBin =
74
- isAbsolute(options.planrBin) || !options.planrBin.includes("/")
75
- ? options.planrBin
76
- : resolve(invocationRoot, options.planrBin);
77
- const manifest = resolve(invocationRoot, options.manifest);
78
- const contentRoot = resolve(invocationRoot, options.contentRoot);
79
- const trustStore = resolve(invocationRoot, options.trustStore);
80
- const scratch = mkdtempSync(join(tmpdir(), "planr-preset-site-"));
81
-
82
- try {
83
- const compositions = options.entries.map((raw) => {
84
- const { id, host } = entrySpec(raw);
85
- const verified = runJson(
86
- planrBin,
87
- [
88
- "--json",
89
- "agents",
90
- "preset",
91
- "registry",
92
- "verify",
93
- manifest,
94
- "--entry",
95
- id,
96
- "--content-root",
97
- contentRoot,
98
- "--trust-store",
99
- trustStore,
100
- "--at-unix",
101
- String(options.atUnix),
102
- "--host",
103
- host,
104
- ],
105
- scratch,
106
- );
107
- const verificationPath = artifactPath(verified, "verification", contentRoot);
108
- const policyId = safeIdentifier(verified.entry.evaluation?.policy_id, "policy id");
109
- const bindingId = safeIdentifier(verified.entry.evaluation?.binding_id, "binding id");
110
- const preview = runJson(
111
- planrBin,
112
- ["--json", "agents", "preset", "apply", policyId, "--binding", bindingId],
113
- scratch,
114
- );
115
- const verificationEnvelope = JSON.parse(readFileSync(verificationPath, "utf8"));
116
- return projectComposition({ verified, preview, verificationEnvelope });
117
- });
118
- const catalog = {
119
- schemaVersion: 1,
120
- generatedAtUnix: options.atUnix,
121
- source: {
122
- state: "verified_registry_projection",
123
- manifest: options.manifest,
124
- entryCount: compositions.length,
125
- trust: "planr_registry_v1",
126
- },
127
- compositions,
128
- };
129
- const output = resolve(invocationRoot, options.output);
130
- await mkdir(dirname(output), { recursive: true });
131
- writeFileSync(output, `${JSON.stringify(catalog, null, 2)}\n`, { mode: 0o644 });
132
- console.log(`wrote ${compositions.length} verified composition(s) to ${output}`);
133
- } finally {
134
- rmSync(scratch, { recursive: true, force: true });
135
- }
@@ -1,69 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import test from "node:test";
6
- import {
7
- buildSite,
8
- PUBLIC_SITE_FILES,
9
- verifyPublication,
10
- } from "../scripts/build-site.mjs";
11
-
12
- async function fixture() {
13
- const root = await mkdtemp(join(tmpdir(), "planr-site-build-"));
14
- const sourceRoot = join(root, "website");
15
- const outputRoot = join(root, "dist", "website");
16
- for (const relativePath of PUBLIC_SITE_FILES) {
17
- const path = join(sourceRoot, relativePath);
18
- await mkdir(join(path, ".."), { recursive: true });
19
- await writeFile(path, `public:${relativePath}\n`);
20
- }
21
- return { root, sourceRoot, outputRoot };
22
- }
23
-
24
- test("builds only the public runtime allowlist and preserves nested catalog bytes", async (t) => {
25
- const paths = await fixture();
26
- t.after(() => rm(paths.root, { recursive: true, force: true }));
27
- await writeFile(join(paths.sourceRoot, "build-catalog.mjs"), "must not be public\n");
28
- await mkdir(join(paths.sourceRoot, "registry"), { recursive: true });
29
- await writeFile(join(paths.sourceRoot, "registry", "trusted-maintainers.toml"), "not public\n");
30
-
31
- const files = await buildSite(paths);
32
-
33
- assert.deepEqual(files, [...PUBLIC_SITE_FILES].sort());
34
- assert.equal(
35
- await readFile(join(paths.outputRoot, "data", "catalog.json"), "utf8"),
36
- "public:data/catalog.json\n",
37
- );
38
- });
39
-
40
- test("fails before replacing output when a required source artifact is missing", async (t) => {
41
- const paths = await fixture();
42
- t.after(() => rm(paths.root, { recursive: true, force: true }));
43
- await mkdir(paths.outputRoot, { recursive: true });
44
- await writeFile(join(paths.outputRoot, "sentinel"), "keep on validation failure\n");
45
- await rm(join(paths.sourceRoot, "styles.css"));
46
-
47
- await assert.rejects(() => buildSite(paths), /missing public website artifact: styles\.css/);
48
- assert.equal(await readFile(join(paths.outputRoot, "sentinel"), "utf8"), "keep on validation failure\n");
49
- });
50
-
51
- test("publication verification rejects unexpected nested output", async (t) => {
52
- const paths = await fixture();
53
- t.after(() => rm(paths.root, { recursive: true, force: true }));
54
- await buildSite(paths);
55
- await mkdir(join(paths.outputRoot, "registry"), { recursive: true });
56
- await writeFile(join(paths.outputRoot, "registry", "manifest.toml"), "unexpected\n");
57
-
58
- await assert.rejects(() => verifyPublication(paths.outputRoot), /publish output mismatch/);
59
- });
60
-
61
- test("refuses an output directory inside the source tree", async (t) => {
62
- const paths = await fixture();
63
- t.after(() => rm(paths.root, { recursive: true, force: true }));
64
-
65
- await assert.rejects(
66
- () => buildSite({ sourceRoot: paths.sourceRoot, outputRoot: join(paths.sourceRoot, "dist") }),
67
- /must not be inside/,
68
- );
69
- });