agentic-ads-engine 0.0.0-stage → 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 agentic-ads-engine contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,225 @@
1
- # Temporary Holding Version
1
+ # agentic-ads-engine
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Offline tools for ad drafts. Node 20 or later. Plain JavaScript ESM with JSDoc, no build step, and zero runtime dependencies.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install agentic-ads-engine # library and CLI in a project
9
+ npm install -g agentic-ads-engine # the agentic-ads and agentic-ads-mcp commands
10
+ ```
11
+
12
+ ## Quick start
13
+
14
+ ```sh
15
+ npx agentic-ads list-templates
16
+ npx agentic-ads hooks
17
+ npx agentic-ads lint --copy 'Browse available listings'
18
+ npx agentic-ads plan --site demo --goal discovery --dry-run
19
+ npx agentic-ads serve --port 8787 # HTTP API, loopback only unless AGENTIC_ADS_TOKEN is set
20
+ npx agentic-ads-mcp # MCP server over stdio
21
+ ```
22
+
23
+ From a git checkout, use `node bin/agentic-ads ...` and run `node test/e2e.mjs` for the tests.
24
+
25
+ CLI output is JSON. Exit codes: `0` for a result, `1` for invalid input, and `2` for blocked copy.
26
+ `lint` requires `--copy`. It accepts `--category housing|none`; the default is `housing`.
27
+ `plan` accepts `--site`, `--goal`, `--template`, `--ratio`, `--copy`, and `--category`.
28
+ Use `--option=value` for values that start with `--`: `node bin/agentic-ads lint --copy=--Sale--`.
29
+ This form preserves `=` inside values. Boolean options accept `=true` or `=false`; plans still require dry-run.
30
+ Site names are lowercase slugs. Ratios are `4:5` and `9:16`.
31
+
32
+ Plans default to dry-run. `--apply` always fails because this slice has no spending or launch implementation.
33
+ Plans return required slots and policy findings. They do not render an image.
34
+ The five templates cover door reveals, first-person UGC, before/after views, news frames, and help with timing.
35
+ Their slots and story beats differ. The HTML stub uses a shared text layout.
36
+
37
+ ## Library
38
+
39
+ ```js
40
+ import {
41
+ readScores, writeScores, selectFor, templateProvider, guardCost,
42
+ } from 'agentic-ads-engine';
43
+
44
+ await writeScores('./scores', 'demo', { help: 8 });
45
+ const scores = await readScores('./scores', 'demo');
46
+ const hook = selectFor({ id: 'demo', scores }, 'discovery');
47
+
48
+ const preview = templateProvider.run({
49
+ template: 'door-open-teaser',
50
+ ratio: '4:5',
51
+ brand: { name: 'Demo', primary: '#2455cc' },
52
+ slots: {
53
+ headline: 'Take a closer look',
54
+ detail: 'Review the available listing details',
55
+ cta: 'Browse listings',
56
+ },
57
+ });
58
+ console.log(hook.id, preview.mime); // help text/html
59
+ ```
60
+
61
+ `selectFor(site, goal)` is pure. Supply `{ id, scores }`; it never reads the store itself.
62
+ Only low-risk hooks qualify. Scores rank matching hooks; catalog order resolves ties.
63
+ No match returns `null`. Scores are finite, nonnegative numbers keyed by known hook IDs.
64
+ The caller defines the score metric. This slice does not measure performance or establish sample confidence.
65
+ CLI and MCP plans use cold-start scores. Load saved scores through the library when selecting hooks.
66
+
67
+ The file store writes one JSON snapshot per site. Replacement is atomic; callers must use one writer.
68
+ A missing file returns `{}`. Invalid files fail. Store directories must be trusted local directories.
69
+
70
+ Providers implement `capabilities()`, `estimate(input)`, and `run(input)`.
71
+ The stub returns escaped HTML, dimensions, a content hash, zero cost, and an explicit warning.
72
+ It returns `image: null`. It has no browser, remote asset loading, image generation, or raster conversion.
73
+ Brand colors accept six-digit hex values. The brand name is plain text.
74
+ Use the template descriptor to find required slots before calling the provider.
75
+ Call `lint` on every final slot value before external use. The renderer does not grant policy approval.
76
+
77
+ `guardCost` accepts `costMinor`, `adSpentMinor`, `monthSpentMinor`, `perAdMinor`, `perMonthMinor`, and `apply`.
78
+ All amounts must be nonnegative safe integers in the same currency.
79
+ Positive cost requires `apply: true`. Equal-to-cap amounts are allowed; excess amounts are blocked.
80
+ The caller supplies totals for the current calendar month, including reserved and uncertain charges.
81
+ This pure guard does not persist reservations, handle concurrency, reset monthly totals, or authorize a paid provider.
82
+ A future paid adapter must check and reserve costs atomically before calling a provider.
83
+
84
+ ## MCP
85
+
86
+ Start `node bin/agentic-ads-mcp`, or use the installed `agentic-ads-mcp` executable.
87
+ Configure the client to use this command with stdio transport.
88
+ The server uses newline-delimited JSON-RPC and protocol version `2024-11-05`.
89
+ It supports `initialize`, `ping`, `tools/list`, and `tools/call`.
90
+ The original four tools use the same names and arguments as the CLI; `--dry-run` becomes `dryRun`.
91
+ Stdout contains protocol messages only. Frames are limited to 65,536 characters.
92
+ Tool results contain JSON text. Blocked copy sets `isError: true` and retains its findings.
93
+
94
+ ## Honest limits
95
+
96
+ Lint detects selected English patterns. It cannot prove truth, consent, disclosure quality, or compliance.
97
+ It always returns a human review finding. A `review` status does not mean platform approval.
98
+ Housing is the conservative default. Targeting words, guarantees, superlatives, and below-market percentages are blocked.
99
+ News framing, staging, and urgency also require review. Conservative checks can produce false positives.
100
+ The package does not inspect actual audience settings or implement current platform policy in full.
101
+
102
+ Keep private data out of inputs. Contact-pattern checks are incomplete; they cannot reliably detect names, addresses, or secrets.
103
+ The CLI prints supplied copy in plans. No telemetry, account integration, or outbound network calls are included.
104
+ No source assets or competitor copy are bundled. Research informs the formats and disclosure needs only.
105
+
106
+ The E2E script spawns the CLI, MCP server, HTTP server, and a library integration process.
107
+ It records assertions in `test/artifacts/e2e.json`, which Git ignores and npm excludes.
108
+ Run `npm run ci:local` before any future push. Publishing and pushing are separate operator actions.
109
+
110
+ ## Video formats
111
+
112
+ ```sh
113
+ node bin/agentic-ads formats
114
+ node bin/agentic-ads formats --cost-class cheap --risk medium
115
+ node bin/agentic-ads formats show sitcom
116
+ node bin/agentic-ads plan --format sitcom --site demo
117
+ ```
118
+
119
+ Twelve formats cover the eight catalog recipes, lo-fi UGC, sitcom, storytime, and talking objects.
120
+ MCP adds `list_formats({costClass?, risk?})`, `get_format({id})`, and `plan_format({format, site, goal?, dryRun?})`.
121
+ These tools return the same JSON as the commands above.
122
+ The library exports `listFormats`, `getFormat`, and `planFormat` from the package root and `agentic-ads-engine/formats`.
123
+ Filters combine with AND. Unknown formats fail. Format plans require a site slug and default to dry-run.
124
+ Plans return ordered stages, stage estimates, total estimates, policy notes, QA gates, and zero actual spend.
125
+ Apply remains unavailable. QA gates describe required checks; this planner does not run them.
126
+
127
+ Source paths refer to the supplied `directory/ads-research` archive, reviewed on 2026-11-12.
128
+ Provider `verified` records that source review date. It does not promise current prices or successful renders.
129
+ `unverified: true` marks proposed routes or unresolved quality. Local routes can name tools instead of models.
130
+ Costs exclude labour and compute. Stage notes state asset counts and reuse assumptions.
131
+ The sitcom run cost $2.466; four mouth checks still failed. Its selected speech model preserves that limitation.
132
+ Lo-fi costs reference reused assets: $0.881 for three videos6 ads and $0.165 for three videos5 voice edits.
133
+ Fresh generation estimates differ. The style catalog did not render its recommended local recipes.
134
+ No paid calls, current price checks, media QA, or platform approval occur here.
135
+
136
+ Run `node test/mutate-formats.mjs` to repeat the three deliberate faults and restore the files.
137
+ It records failures and the restored pass in `test/artifacts/formats-mutations.json`.
138
+
139
+ ### Owner feedback checks
140
+
141
+ `agentic-ads qa-rules` lists R1 through R26. Use `agentic-ads qa-rules show R5`
142
+ to read one rule. MCP provides `list_qa_rules` and `get_qa_rule` with an `id` argument.
143
+
144
+ Each format declares `kinds` and `feedbackRules`. A format plan includes
145
+ `reviewChecklist` entries and the six instructions in `costRatchet`.
146
+ Every entry starts as `NOT CHECKED`. This status blocks shipping.
147
+ A reviewer must record PASS or FAIL with a measured value. Voice and sound fit need the owner's ear.
148
+
149
+ The JavaScript API exports `listRules()`, `getRule(id)`, and `checklistFor(format)`.
150
+ Pass a format descriptor from `getFormat(id)` to `checklistFor`.
151
+ Rules use `blocking` severity. `applies` names format kinds; `all` covers every format.
152
+ Story formats include stickman, POV, before-after, lo-fi, sitcom, and storytime.
153
+ Sitcom has dialogue. Talking-object has visible speech. All current formats have captions.
154
+
155
+ Run `node test/mutate-qa.mjs` to verify three deliberate faults and restore the files.
156
+ Results go to `test/artifacts/qa-mutations.json` and `test/artifacts/e2e.json`.
157
+
158
+ ## Hook catalog
159
+
160
+ ```sh
161
+ node bin/agentic-ads hooks
162
+ node bin/agentic-ads hooks show shocked-face
163
+ node bin/agentic-ads hooks pick --format pov --emotion curiosity --housing
164
+ ```
165
+
166
+ The catalog contains 42 hooks. Each includes format IDs, emotion, kind, risk, a housing flag, and a short rationale.
167
+ The patterns paraphrase the supplied playbook and style catalog. They include the six requested owner feedback openings.
168
+ No research records, personal data, or account IDs are included.
169
+
170
+ MCP exposes `list_hooks({format?, emotion?, housing?})`, `get_hook({id})`, and `pick_hooks({format, emotion?, housing?})`.
171
+ CLI and MCP picks return all matching hooks in catalog order, with cold-start scores.
172
+ The original `list-hooks` command and MCP tool still return the whole catalog.
173
+ Format, emotion, and housing filters combine with AND. Invalid filters fail; no matches return an empty list.
174
+
175
+ The library exports `listHooks`, `getHook`, and `pickHooks(filter, scores = {})`.
176
+ Picks rank matching hooks by descending score. Catalog order resolves ties.
177
+ `selectFor(site, goal, filter = {})` retains its low-risk rule and returns one hook or `null`.
178
+ The original two-argument call still works. Filters apply before score ranking.
179
+ `housing: true` keeps only hooks with `housingSafe: true`. It does not filter by risk level, so medium-risk hooks can still appear.
180
+
181
+ `housingSafe` means the pattern passes housing lint with generic slot values and contains no eligibility claim, invented number, or testimonial.
182
+ It does not approve arbitrary slot values or rendered media. Fill every slot and lint the final copy before use.
183
+ Every result still requires human review. Personal savings, occupancy, interviews, and broken-trust claims remain outside the housing-safe set.
184
+
185
+ Run `node test/mutate-hooks.mjs` to verify three single-line faults and restore the catalog.
186
+ Evidence goes to `test/artifacts/hooks-mutations.json`. E2E results go to `test/artifacts/e2e.json`.
187
+ `test/hook-expectations.json` pins every ID and housing flag; review changes to this file explicitly.
188
+
189
+ ## HTTP API for admin sites
190
+
191
+ Start `node bin/agentic-ads serve`. The defaults are `127.0.0.1:8787`.
192
+ Use `--host H` and `--port N` to change them. Port `0` selects an available port.
193
+ The startup JSON reports the bound port. SIGTERM closes the server and its connections.
194
+
195
+ ```sh
196
+ node bin/agentic-ads serve --port 8787
197
+ curl http://127.0.0.1:8787/health
198
+ curl http://127.0.0.1:8787/v1/formats/sitcom
199
+ curl http://127.0.0.1:8787/v1/qa-rules/R5
200
+ curl -H 'Content-Type: application/json' -d '{"copy":"Browse listings","category":"housing"}' http://127.0.0.1:8787/v1/lint
201
+ curl -H 'Content-Type: application/json' -d '{"format":"sitcom","site":"demo"}' http://127.0.0.1:8787/v1/plan
202
+ ```
203
+
204
+ GET routes: `/health`, `/v1/formats`, `/v1/formats/:id`, `/v1/hooks`, `/v1/templates`,
205
+ `/v1/qa-rules`, and `/v1/qa-rules/:id`. Health returns `{"status":"ok"}`.
206
+ POST routes: `/v1/lint` and `/v1/plan`. Their results match the CLI and MCP tools.
207
+ Blocked lint findings return HTTP 200 with `status: "blocked"`.
208
+ Plans only produce dry-run results. Any `apply` field is refused, including nested fields and query parameters.
209
+ JSON bodies have a 64 KiB limit. Requests have a ten-second timeout.
210
+ Errors return JSON without stacks: 400 invalid input, 401 unauthorized, 403 forbidden host, 404 missing route or item,
211
+ 405 wrong method, 408 timeout, 413 oversized body, and 415 unsupported media type.
212
+
213
+ Set `AGENTIC_ADS_TOKEN` to require `Authorization: Bearer <token>` on every route, including health.
214
+ The token needs 32 or more characters and must not start or end with spaces. Without a token, the server accepts only loopback `Host` values with its own port. This blocks DNS rebinding from a web page.
215
+ A non-loopback host requires this setting. The server never logs bodies or tokens.
216
+ Responses containing the configured token are refused. Use TLS through your admin site's server for remote access.
217
+
218
+ An admin site's backend can call these routes and keep the token in server-side settings.
219
+ Proxy calls through that backend for browser clients. CORS headers are absent by default.
220
+ `AGENTIC_ADS_CORS_ORIGIN=https://admin.example` allows that exact origin in response headers.
221
+ Unauthenticated browser preflight requests still return 401 when a token is set.
222
+ Direct cross-origin browser calls with bearer headers therefore require the backend proxy.
223
+
224
+ Run `node test/mutate-api.mjs` to prove authentication, the Host check, the token length rule, body limits, and apply checks detect deliberate faults.
225
+ The script restores the source and writes `test/artifacts/api-mutations.json`.
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ import { execute, tools } from '../src/index.js';
3
+ try {
4
+ let [action, ...argv] = process.argv.slice(2);
5
+ if (action === 'serve') {
6
+ await (await import('../src/http.js')).serve(argv);
7
+ } else {
8
+ const args = {};
9
+ if (action === 'hooks') {
10
+ action = argv[0] === 'show' ? 'get_hook' : argv[0] === 'pick' ? 'pick_hooks' : 'list_hooks';
11
+ if (action === 'get_hook') { argv.shift(); args.id = argv.shift(); }
12
+ if (action === 'pick_hooks' || argv[0] === 'list') argv.shift();
13
+ }
14
+ if (action === 'qa-rules') {
15
+ action = argv[0] === 'show' ? 'get_qa_rule' : 'list_qa_rules';
16
+ if (action === 'get_qa_rule') { argv.shift(); args.id = argv.shift(); }
17
+ }
18
+ if (action === 'formats') {
19
+ action = argv[0] === 'show' ? 'get_format' : 'list_formats';
20
+ if (action === 'get_format') { argv.shift(); args.id = argv.shift(); }
21
+ }
22
+ if (action === 'plan' && argv.some(arg => arg === '--format' || arg.startsWith('--format='))) action = 'plan_format';
23
+ const schema = tools.find(t => t.name === action)?.inputSchema;
24
+ if (!schema) throw new Error('Use hooks, qa-rules, formats, list-templates, list-hooks, lint, plan, or serve');
25
+ while (argv.length) {
26
+ const option = argv.shift(), separator = option.indexOf('=');
27
+ const flag = separator < 0 ? option : option.slice(0, separator);
28
+ const value = separator < 0 ? undefined : option.slice(separator + 1);
29
+ if (!flag.startsWith('--')) throw new Error('Expected an option');
30
+ const key = flag === '--dry-run' ? 'dryRun' : flag === '--cost-class' ? 'costClass' : flag.slice(2);
31
+ if (flag === '--apply' && ['plan', 'plan_format'].includes(action)) throw new Error('Apply is unavailable in this offline slice');
32
+ if (['--costClass', '--dryRun'].includes(flag) || !Object.hasOwn(schema.properties, key) || Object.hasOwn(args, key)) throw new Error('Unknown or repeated option');
33
+ if (schema.properties[key].type === 'boolean') {
34
+ if (value !== undefined && !['true', 'false'].includes(value)) throw new Error('Invalid argument');
35
+ args[key] = value !== 'false';
36
+ }
37
+ else if (value !== undefined) args[key] = value;
38
+ else {
39
+ if (!argv.length || argv[0].startsWith('--')) throw new Error(`Missing option value (use ${flag}=VALUE for a value that starts with --)`);
40
+ args[key] = argv.shift();
41
+ }
42
+ }
43
+ const result = execute(action, args);
44
+ console.log(JSON.stringify(result));
45
+ if (result.status === 'blocked' || result.policy?.status === 'blocked') process.exitCode = 2;
46
+ }
47
+ } catch (error) {
48
+ console.log(JSON.stringify({ error: error.message })); process.exitCode = 1;
49
+ }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { execute, tools } from '../src/index.js';
4
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
5
+ // MCP stdio uses one JSON-RPC message per line. stdout is protocol-only.
6
+ const send = message => process.stdout.write(JSON.stringify(message) + '\n');
7
+ const fail = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
8
+ let initialized = false;
9
+ function receive(line) {
10
+ let request;
11
+ try { request = JSON.parse(line); } catch { fail(null, -32700, 'Parse error'); return; }
12
+ const validId = request && (typeof request.id === 'string' || (typeof request.id === 'number' && Number.isFinite(request.id)));
13
+ if (!request || Array.isArray(request) || request.jsonrpc !== '2.0' || typeof request.method !== 'string' ||
14
+ (Object.hasOwn(request, 'id') && !validId)) { fail(null, -32600, 'Invalid request'); return; }
15
+ if (!Object.hasOwn(request, 'id')) return;
16
+ const { id, method, params = {} } = request;
17
+ if (!params || typeof params !== 'object' || Array.isArray(params)) { fail(id, -32602, 'Invalid params'); return; }
18
+ let result;
19
+ if (method === 'initialize') {
20
+ if (typeof params.protocolVersion !== 'string') { fail(id, -32602, 'Missing protocol version'); return; }
21
+ initialized = true;
22
+ result = { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'agentic-ads-engine', version } };
23
+ } else if (method === 'ping') result = {};
24
+ else if (!initialized) { fail(id, -32002, 'Initialize first'); return; }
25
+ else if (method === 'tools/list') result = { tools };
26
+ else if (method === 'tools/call') {
27
+ if (typeof params.name !== 'string' || !tools.some(t => t.name === params.name)) { fail(id, -32602, 'Unknown tool'); return; }
28
+ try {
29
+ const value = execute(params.name, params.arguments === undefined ? {} : params.arguments);
30
+ result = { content: [{ type: 'text', text: JSON.stringify(value) }], isError: value.status === 'blocked' || value.policy?.status === 'blocked' };
31
+ } catch (error) { result = { content: [{ type: 'text', text: error.message }], isError: true }; }
32
+ } else { fail(id, -32601, 'Method not found'); return; }
33
+ send({ jsonrpc: '2.0', id, result });
34
+ }
35
+ let buffer = '', dropping = false;
36
+ process.stdin.setEncoding('utf8');
37
+ process.stdin.on('data', chunk => {
38
+ // Bound each frame to prevent unbounded buffering from a malformed client.
39
+ for (const part of chunk.split(/(?<=\n)/)) {
40
+ if (!dropping) buffer += part;
41
+ if (buffer.length > 65536) { buffer = ''; dropping = true; fail(null, -32600, 'Frame too large'); }
42
+ if (part.endsWith('\n')) {
43
+ if (!dropping && buffer.trim()) receive(buffer);
44
+ buffer = ''; dropping = false;
45
+ }
46
+ }
47
+ });
48
+ process.stdin.on('end', () => { if (buffer.trim() && !dropping) receive(buffer); });
package/package.json CHANGED
@@ -1,6 +1,16 @@
1
1
  {
2
2
  "name": "agentic-ads-engine",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Offline ad templates, hook selection, policy lint, and planning tools",
5
+ "type": "module",
6
+ "engines": { "node": ">=20" },
7
+ "license": "MIT",
8
+ "repository": { "type": "git", "url": "git+https://github.com/itsryanarmani/agentic-ads-engine.git" },
9
+ "homepage": "https://github.com/itsryanarmani/agentic-ads-engine#readme",
10
+ "bugs": { "url": "https://github.com/itsryanarmani/agentic-ads-engine/issues" },
11
+ "exports": { ".": "./src/index.js", "./formats": "./src/formats/index.js", "./templates": "./src/templates/index.js", "./hooks": "./src/hooks/index.js", "./policy": "./src/policy/index.js", "./providers": "./src/providers/index.js" },
12
+ "bin": { "agentic-ads": "./bin/agentic-ads", "agentic-ads-mcp": "./bin/agentic-ads-mcp" },
13
+ "files": ["src/", "bin/", "README.md", "LICENSE"],
14
+ "scripts": { "test": "node test/e2e.mjs", "ci:local": "node test/e2e.mjs" },
15
+ "publishConfig": { "access": "public" }
16
+ }
@@ -0,0 +1,36 @@
1
+ import { checklistFor, costRatchet } from '../qa/index.js';
2
+ import { readFileSync } from 'node:fs';
3
+ const library = JSON.parse(readFileSync(new URL('./library.json', import.meta.url), 'utf8'));
4
+ const costs = ['free', 'cheap', 'mid', 'expensive'], risks = ['low', 'medium', 'high'];
5
+ function argumentsFor(value, keys) {
6
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !keys.includes(k))) throw new Error('Invalid format arguments');
7
+ }
8
+ /** Return isolated descriptors. Filters combine with AND. No providers run. */
9
+ export function listFormats(filter = {}) {
10
+ argumentsFor(filter, ['costClass', 'risk']);
11
+ if (filter.costClass !== undefined && !costs.includes(filter.costClass)) throw new Error('Invalid cost class');
12
+ if (filter.risk !== undefined && !risks.includes(filter.risk)) throw new Error('Invalid risk');
13
+ return structuredClone(library.filter(f => (!filter.costClass || f.costClass === filter.costClass) && (!filter.risk || f.policyRisk === filter.risk)));
14
+ }
15
+ export function getFormat(id) {
16
+ const found = library.find(f => f.id === id);
17
+ if (!found) throw new Error('Unknown format: ' + String(id));
18
+ return structuredClone(found);
19
+ }
20
+ /** Costs describe a possible production run, never money spent by this planner. */
21
+ export function planFormat(input = {}) {
22
+ argumentsFor(input, ['format', 'site', 'goal', 'dryRun', 'apply']);
23
+ if (input.apply !== undefined && input.apply !== false) throw new Error('Apply is unavailable');
24
+ if (input.dryRun !== undefined && input.dryRun !== true) throw new Error('Use dry-run; apply is unavailable');
25
+ const f = getFormat(input.format);
26
+ if (typeof input.site !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.site) || input.site.length > 80) throw new Error('Site must be a lowercase slug');
27
+ const goal = input.goal ?? 'discovery';
28
+ if (typeof goal !== 'string' || !goal.trim() || goal.length > 200) throw new Error('Invalid goal');
29
+ const stages = f.stages.map(id => ({ id, provider: f.providers[id], estimatedCostUsd: f.providers[id].estimatedCostUsd }));
30
+ const sum = key => Math.round(stages.reduce((n, s) => n + s.estimatedCostUsd[key], 0) * 1000) / 1000;
31
+ return { format: f.id, site: input.site, goal, dryRun: true, applied: false, spendUsd: 0, stages,
32
+ reviewChecklist: checklistFor(f), costRatchet,
33
+ qaGates: f.qaGates, policyRisk: f.policyRisk, riskNotes: f.riskNotes,
34
+ total: { low: sum('low'), high: sum('high'), note: f.estimatedCostUsd.note },
35
+ warnings: ['Plan only; no providers or QA checks run.', 'NOT CHECKED blocks shipping. Record PASS or FAIL with a measured value. Voice fit and sound fit need the owner’s ear.', 'Historical source review dates are not current price verification.'] };
36
+ }