Optional local Claude Ads run, evidence, input, comparison and report cockpit

An optional local workbench for Claude Ads runs. The five screens are Overview, Checks, Inputs, Compare and Reports. They use plain language and theme colors, and show recorded step state, the checks a run made, dated source support, indexed inputs, run differences and local report receipts. On screen, a "check" is one canonical finding, a "step" is one worker of the run and the "engine" is the local Claude Ads core. The guided demo uses synthetic fixtures in an isolated run root and policy copy.
The Claude Ads skill and matching Python core are prerequisites. This companion does not bundle Python or the core, install dependencies, grant advertising account permissions, call models, retrieve sources, or change campaigns. The ordinary skill installer does not install or enable it.
The runtime plugin ID is ads-cockpit; the display title is Claude Ads Cockpit. Select it separately after reviewing the package and verifying its checksum against a trusted channel. For the Claude Ads marketplace, its explicit install selector is ads-cockpit@ai-marketing-hub-claude-ads. Selecting or installing the main skill does not select this companion. Exact host API and native rendering acceptance are still pending. No real profile needs to be changed to inspect the source or run kit tests.
Set the host's companion configuration fields:
| Field | Value |
|---|---|
coreRoot | Explicit trusted absolute source checkout or verified standalone skill root containing the matching core and public policy resources. |
pythonExecutable | Explicit trusted absolute Python executable. Never a command found through PATH. |
runsRoot | Optional explicit absolute root containing compatible local runs. Required for client data commands; isolated demo supplies its own root. |
Both required fields default to blank. Set them with claude plugin configure ads-cockpit (or the plugin's options in /plugin). Until coreRoot and pythonExecutable are valid absolute paths for the host platform, every entry point (chooser, demo, band and doctor) says "Not set up yet" and names the two options; it never prints a configured path. A drive letter or UNC path is not accepted on macOS or Linux. The manifest declares all three fields as plain strings. claude plugin validate --strict also accepts the directory and file types on 2.1.296, but how the options dialog treats a blank default or an optional path was not exercised here, so they stay strings and the launcher is the check on every path.
Run /ads-cockpit doctor and inspect its compatibility status before opening client runs. It does not install or repair anything. A compatible run needs canonical manifest.json and strict cockpit-index v1 cockpit.json; missing indexes are reported as incompatible.
| Command | Result |
|---|---|
/ads-cockpit | Open the local run chooser. |
/ads-cockpit open <id> | Select a registered run by its safe run ID. |
/ads-cockpit demo | Explicitly create and enter the isolated synthetic demo. |
/ads-cockpit hide | Hide the pane, cancel refresh and clear displayed client state. |
/ads-cockpit doctor | Check the explicit binding and compatibility. |
The only run actions are validate-input, validate-run and render-report. They require the selected run's current authorization fingerprint and operate on local files. Report formats are Markdown, HTML and conditional PDF. PDF needs the optional renderer and system libraries in the configured trusted environment; an absent dependency or render failure must produce a clear unavailable/error receipt. A receipt records success, failure or blocking without proving advertising truth.
Keys: the tabs answer to 1 to 5, and the footer keys are r refresh, c runs, d details, h hide (demo: b back, n next, z reset, q exit). Screens fit the rows the pane has and never scroll: j shows the next page of a long list, u the previous one, and f filters Checks by result. A framed button names its key in its label, for example Next (n), Markdown (m), HTML (l), PDF (p) and Check whole run (k), because the terminal draws a key only on a plain button. Keys act while the pane or band holds focus (ctrl+x tab).
Views use cached state. A visible pending/running run refreshes at five-second intervals without overlapping requests, and only while the pane is placed and shown; a closed, undrawn or background pane starts no process. When a turn ends, a pending run is refreshed in the background without delaying the turn, and a finished run spawns nothing. Hiding or reloading cancels refresh. No pane opens automatically. Demo entry/exit clears client state, and leaving the demo returns to the chooser. The demo is an example, not client evidence.
From a reviewed source checkout, build a separate development archive:
python scripts/package_cockpit.py --root . --output-dir dist/cockpit-review package
python scripts/package_cockpit.py --root . --output-dir dist/cockpit-review verify
Choose a fresh output directory; existing output files are never overwritten. The ZIP has one ads-cockpit/ root and contains a fixed allowlist of companion runtime files, this README, its original state declaration and MIT license. It excludes Python/core files, generated host typings, tests, transcripts, raw captured evidence, client runs, private vault material and repository-only release evidence. A packaging test checks that every relative import in those files resolves inside the allowlist.
The two install routes differ. The ZIP above is the reviewed, allowlisted package. The marketplace entry ads-cockpit has the source ./mods/claude-ads-cockpit, which installs the whole directory as committed, including tests/, tsconfig.json and types/ beside the runtime files; only the generated host typings under .claude-plugin/types/ are git-ignored. The extra files are never loaded as hooks, but they are on disk. The accompanying manifest and SHA-256 file contain relative paths and hashes. Building does not install, load, enable or publish the companion. Reproducible bytes do not make a development archive an approved release.
The demo writes synthetic runs to a new claude-ads-demo-<random> folder under the system temp directory (/tmp on macOS and Linux). The cockpit never deletes these folders, because cleanup would need host reach it does not have. Hide the cockpit first, then remove only folders with that prefix when you no longer need them. They hold made-up data only.
Rollback starts with /ads-cockpit hide, then disable the companion through the host's plugin controls. To uninstall, remove only this companion's separately selected package using that host's ownership-aware removal flow. If the host cannot show exact owned files, stop for manual review. Retain the core, skill, client run directories and generated reports/receipts. No broad skill-name glob or profile cleanup is needed.
For source-checkout documentation, see the user guide, compatibility limits and proposed roadmap. These guides are repository resources and are not copied into the small optional archive. Local kit checks cannot establish terminal/Desktop painting, Windows/macOS native behavior, market readiness or product release readiness.
Verified here by claude plugin validate --strict and claude plugin test (Claude Code 2.1.296): the manifest, the static hook inventory, drawing-tree validity on terminal and Desktop surfaces, hook behavior with stubbed host calls, and launcher isolation tests on Linux. Not verified: real terminal or Desktop painting, Windows and macOS behavior, the options dialog, and how a live --plugin-dir session behaves. Test stubs stand in for the host, so none of these checks loaded the mod in a real session.
hooks/register.mjs 108 lines1import { atom, read, update } from 'claude-code';
2import { binding, buildArgv } from './launcher.mjs';
3import { MESSAGES } from './lexicon.mjs';
4import { createController, emptyView } from './controller.mjs';
5import { drawPane, drawBand } from './views.mjs';
6const PANE = 'ads-cockpit';
7const view = atom({ plugin: 'ads-cockpit', key: 'view' }, emptyView());
8let controller = null;
9async function invoke($, config, command, request = {}) {
10 const argv = buildArgv(config, command, request);
11 let result;
12 try { result = await $.process.run(argv, { timeoutMs: 30000 }); }
13 catch { throw new Error(MESSAGES.didNotFinish); }
14 if (result.exitCode !== 0 || typeof result.stdout !== 'string' || result.stdout.length > 2000000) throw new Error(MESSAGES.refused);
15 try { return JSON.parse(result.stdout); }
16 catch { throw new Error(MESSAGES.incompatible); }
17}
18function getController($, options) {
19 if (!controller) controller = createController({
20 protectedRoots: [options?.coreRoot, options?.runsRoot].filter(value => typeof value === 'string' && value),
21 call: (command, request) => invoke($, binding(options), command, request),
22 publish: value => update($, view, () => value),
23 // A timer callback has no caller to catch for it, so a failure ends here and never goes unhandled.
24 after: (ms, fn) => $.clock.after(ms, () => { try { Promise.resolve(fn()).catch(() => {}); } catch { /* ends this tick only */ } }),
25 // Drawn means placed and shown. Polling pauses while the pane is closed, undrawn or a background tab.
26 isDrawn: async () => (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced && pane.isShown),
27 });
28 return controller;
29}
30async function openWorkbench($, c, runId) {
31 if (controller !== c || !c.view().visible || c.view().selected !== runId) return;
32 // Only a user callback opens the cached workbench; no core read is needed.
33 await $.ui.open({id:PANE,title:'Claude Ads Cockpit',focus:true,columns:100,rows:40});
34}
35const controls = ($, c) => ({
36 workbench: runId => openWorkbench($,c,runId),
37 navigate: (tab,target,runId,fingerprint) => c.navigate(tab,target,runId,fingerprint),
38 chooser: () => c.chooser(), select: id => c.select(id), refresh: () => c.refresh(),
39 action: (operation, inputId, format, subject) => c.action(operation,inputId,format,subject), demo: () => c.demo(),
40 step: delta => c.step(delta), reset: () => c.reset(), tab: value => c.tab(value), details: () => c.details(), baseline: value => c.baseline(value),
41 page: delta => c.page(delta), filter: () => c.filter(), clearContext: () => c.clearContext(),
42 hide: async () => { await c.hide(); await $.ui.close({ id: PANE }); },
43 });
44export function register(on, options) {
45 on('session.start', async ($, e, next) => {
46 if (controller) await controller.dispose();
47 controller = null; await update($, view, () => emptyView());
48 // A taken name must not skip the rest of this hook, so the registration is allowed to fail.
49 try { await $.command.register({name:'ads-cockpit',description:'Open local Claude Ads cockpit or synthetic demo',argumentHint:'[open <id>|demo|hide|doctor]',immediate:true}); }
50 catch { $.ui.log('ads-cockpit: the /ads-cockpit command could not be registered (name taken?). The pane and band still work.', { to: 'debug' }); }
51 return next(e);
52 }).catch(($, e, next) => next(e));
53 on('classic.SessionStart', { source: ['clear','resume','fork'] }, async ($, e, next) => {
54 if (controller) await controller.dispose(); controller = null;
55 await update($, view, () => emptyView()); await $.ui.close({id:PANE});
56 return next(e);
57 }).catch(($, e, next) => next(e));
58 on('session.end', async ($, e, next) => { if (controller) await controller.dispose(); controller = null; return next(e); });
59 on('command.run', {command:'ads-cockpit'}, async ($, e) => {
60 const c = getController($, options); const args = e.args.trim().split(/\s+/).filter(Boolean);
61 if (args[0] === 'hide' && args.length === 1) { await controls($,c).hide(); return {text:'Claude Ads Cockpit hidden; session data cleared.'}; }
62 if (args[0] === 'doctor' && args.length === 1) {
63 const status = await c.doctor();
64 // An unavailable engine says why in plain words; the message is a fixed string, never a path.
65 return {text:`Claude Ads Cockpit local compatibility: ${status}.${status === 'unavailable' && c.view().error ? ` ${c.view().error}` : ''}`};
66 }
67 let pending;
68 if (!args.length) pending = c.chooser();
69 else if (args[0] === 'demo' && args.length === 1) pending = c.demo();
70 else if (args[0] === 'open' && args.length === 2 && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(args[1])) {
71 // Explicit real-run entry exits demo before selecting any client data.
72 pending = c.open(args[1]);
73 } else return {text:'Use /ads-cockpit [open <id>|demo|hide|doctor].'};
74 const ticket = c.version();
75 await pending;
76 if (controller !== c || c.version() !== ticket || !c.view().visible) return {};
77 const surfaces = await $.session.surfaces();
78 if (controller !== c || c.version() !== ticket || !c.view().visible) return {};
79 if (!surfaces.some(surface => surface === 'terminal' || surface === 'desktop' || surface?.surface === 'terminal' || surface?.surface === 'desktop')) {
80 await c.hide(); return {text:'Claude Ads Cockpit local request finished. Visual cockpit requires terminal or Desktop; session data cleared.'};
81 }
82 await $.ui.open({id:PANE,title:'Claude Ads Cockpit',focus:true,columns:100,rows:40});
83 return {};
84 });
85 on('ui.close', {id:PANE}, async ($, e, next) => { if (controller) await controller.hide(); return next(e); }).catch(($, e, next) => next(e));
86 // A turn end never waits on the core: a pending run is refreshed in the background, and only if it is drawn.
87 on('turn.complete', async ($, e, next) => {
88 if (controller) void controller.refreshIfPending().catch(() => {});
89 return next(e);
90 }).catch(($, e, next) => next(e));
91 on('ui.render', {component:'Pane'}, async ($, e, next) => {
92 if (e.requestId !== PANE) return next(e);
93 const cached = await read($,view);
94 if (!cached.visible) return next(e);
95 const ui = $.ui.resolve(e);
96 // Render reads session state only; controller effects run in user callbacks.
97 // The host says how many rows the body may use; the pane is drawn to fit them, so it never needs to scroll.
98 return drawPane(ui,cached,controls($,getController($, options)),e.props.bodyColumns,e.props.isFocused !== false,e.props.scroll?.bodyRows);
99 });
100 on('ui.render', {component:'AbovePrompt'}, async ($, e, next) => {
101 const other = await next(e);
102 if (e.props.hasSurvey) return other;
103 const cached = await read($,view);
104 if (!cached.visible) return other;
105 return drawBand($.ui.resolve(e),cached,other,controls($,getController($, options)),e.props.bodyColumns);
106 });
107}
108hooks/launcher.mjs 125 lines1import { MESSAGES } from './lexicon.mjs';
2// Deliberately fixed code and argv: no shell and no PATH lookup, because argv[0] is the configured
3// absolute interpreter. The child still inherits the host environment and the session working
4// directory; `-I -S` (below) is what isolates Python from PYTHON* variables, the current directory,
5// user site packages and executable .pth files.
6export const PYTHON_LOADER = `import sys, pathlib, json, tempfile, sysconfig, os
7root = pathlib.Path(sys.argv[1])
8def safe_root(p):
9 if not p.is_absolute() or any(q.is_symlink() for q in (p, *p.parents)):
10 raise ValueError("unsafe configured root")
11 return p.resolve(strict=True)
12try:
13 root = safe_root(root)
14 candidates = [root, root / "scripts"]
15 base = next((p for p in candidates if (p / "claude_ads_core" / "__init__.py").is_file()), None)
16 if base is None:
17 raise ValueError("core package unavailable")
18 package = base / "claude_ads_core"
19 for item in package.rglob("*"):
20 if item.is_symlink():
21 raise ValueError("unsafe core package")
22 # -I -S excludes environment, cwd, user sites and executable .pth files.
23 # Expose dependency directories only from the explicitly trusted interpreter.
24 executable = pathlib.Path(sys.executable)
25 venv = executable.parent.parent
26 config = venv / "pyvenv.cfg"
27 if config.exists():
28 if config.is_symlink() or not config.is_file():
29 raise ValueError("unsafe interpreter configuration")
30 dependency_base = safe_root(venv)
31 paths = sysconfig.get_paths(vars={"base": str(dependency_base), "platbase": str(dependency_base)})
32 else:
33 dependency_base = pathlib.Path(sys.prefix).resolve(strict=True)
34 paths = sysconfig.get_paths()
35 for name in ("purelib", "platlib"):
36 candidate = pathlib.Path(paths[name])
37 if not candidate.exists():
38 continue
39 candidate = candidate.resolve(strict=True)
40 if not candidate.is_dir() or not candidate.is_relative_to(dependency_base):
41 raise ValueError("unsafe interpreter dependencies")
42 if str(candidate) not in sys.path:
43 sys.path.append(str(candidate))
44 sys.path.insert(0, str(base))
45 from claude_ads_core.cli import main
46 argv = sys.argv[2:]
47 if len(argv) in (1, 2) and argv[0] == "demo-prepare":
48 # Do not let ambient TMPDIR place synthetic fixtures inside client runs.
49 temp_base = pathlib.Path("/tmp") if os.name != "nt" else pathlib.Path(pathlib.Path(sys.base_prefix).anchor) / "Windows" / "Temp"
50 temp_base = pathlib.Path(temp_base).resolve(strict=True)
51 protected = [root] + ([pathlib.Path(argv[1]).resolve()] if len(argv) == 2 else [])
52 if not temp_base.is_dir() or any(temp_base == p or temp_base.is_relative_to(p) for p in protected):
53 raise ValueError("isolated temporary directory unavailable")
54 parent = pathlib.Path(tempfile.mkdtemp(prefix="claude-ads-demo-", dir=temp_base))
55 destination = parent / "runs"
56 print(json.dumps({"interface_version": "1.0.0", "runs_root": str(destination), "policy_root": str(destination / "_policy")}))
57 raise SystemExit(0)
58 if len(argv) == 2 and argv[0] == "demo-setup":
59 from claude_ads_core.cockpit import create_demo
60 destination = pathlib.Path(argv[1])
61 parent = safe_root(destination.parent)
62 if destination.name != "runs" or not parent.name.startswith("claude-ads-demo-") or destination.exists():
63 raise ValueError("unsafe demo destination")
64 value = create_demo(destination, root)
65 if value.get("runs_root") != str(destination) or value.get("policy_root") != str(destination / "_policy"):
66 raise ValueError("mismatched demo destination")
67 print(json.dumps(value, ensure_ascii=True))
68 raise SystemExit(0)
69 raise SystemExit(main(argv))
70except (ValueError, OSError, ImportError):
71 print(json.dumps({"error":"Trusted core unavailable or configuration unsafe"}))
72 raise SystemExit(2)
73`;
74const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
75const SHA = /^[a-f0-9]{64}$/;
76const OPERATIONS = new Set(['validate-input', 'validate-run', 'render-report']);
77// The platform decides what "absolute" means. POSIX accepts only a rooted path, so a drive letter or a
78// UNC path can never become a relative argv[0]. Windows keeps its rooted and drive-letter forms.
79const hostPlatform = () => (typeof globalThis.process?.platform === 'string' ? globalThis.process.platform : 'linux');
80export function absolutePath(value, platform = hostPlatform()) {
81 const common = typeof value === 'string' && value.length > 1 && value.length <= 4096
82 && !/[\x00-\x1f\x7f]/.test(value)
83 && !value.split(/[\\/]/).some(x => x === '..' || x === '.');
84 if (!common) return false;
85 return platform === 'win32' ? /^\//.test(value) || /^[A-Za-z]:[\\/]/.test(value) : /^\/(?!\/)/.test(value) && !value.includes('\\');
86}
87export function binding(options) {
88 const config = { coreRoot: options?.coreRoot, pythonExecutable: options?.pythonExecutable, runsRoot: options?.runsRoot || null };
89 if (!absolutePath(config.coreRoot) || !absolutePath(config.pythonExecutable)
90 || (config.runsRoot !== null && !absolutePath(config.runsRoot))) {
91 throw new Error(MESSAGES.setup);
92 }
93 return config;
94}
95function id(value) {
96 if (!ID.test(value || '')) throw new Error(MESSAGES.badId);
97 return value;
98}
99export function buildArgv(config, command, request = {}) {
100 const prefix = [config.pythonExecutable, '-I', '-S', '-c', PYTHON_LOADER, config.coreRoot];
101 if (command === 'prepare-demo') return [...prefix, 'demo-prepare', ...(config.runsRoot ? [config.runsRoot] : [])];
102 if (command === 'demo') {
103 if (!absolutePath(request.runsRoot) || request.policyRoot?.replaceAll('\\','/') !== request.runsRoot.replaceAll('\\','/').replace(/\/$/, '') + '/_policy') throw new Error('Invalid isolated demo destination.');
104 return [...prefix, 'demo-setup', request.runsRoot];
105 }
106 if (!['doctor', 'snapshot', 'compare', 'action'].includes(command)) throw new Error('Unsupported local operation.');
107 const policyRoot = request.policyRoot || config.coreRoot;
108 const runsRoot = request.runsRoot || config.runsRoot;
109 if (!absolutePath(policyRoot) || (runsRoot && !absolutePath(runsRoot))) throw new Error('Invalid local root.');
110 const args = ['cockpit', command, '--policy-root', policyRoot];
111 if (runsRoot) args.push('--runs-root', runsRoot);
112 if (command !== 'doctor') {
113 if (!runsRoot) throw new Error(MESSAGES.runs);
114 args.push('--run-id', id(request.runId));
115 }
116 if (command === 'compare') args.push('--baseline-id', id(request.baselineId));
117 if (command === 'action') {
118 if (!OPERATIONS.has(request.operation) || !SHA.test(request.fingerprint || '')) throw new Error('Action unavailable without a current fingerprint.');
119 args.push('--operation', request.operation, '--expected-fingerprint', request.fingerprint);
120 if (request.operation === 'validate-input') args.push('--input-id', id(request.inputId));
121 if (request.operation === 'render-report') { const format = request.format || 'markdown'; if (!['markdown','html','pdf'].includes(format)) throw new Error('Unsupported report format.'); args.push('--format', format); };
122 }
123 return [...prefix, ...args];
124}
125hooks/lexicon.mjs 55 lines1// One source for what the person reads. Raw wire values never reach the screen.
2import { toneOf } from './theme.mjs';
3const w = (word, tone) => Object.freeze({ word, tone });
4export const LEXICON = Object.freeze({
5 workflow: { pending: w('Queued', 'muted'), running: w('Running', 'info'), completed: w('Finished', 'ok'), blocked: w('Blocked', 'bad'), failed: w('Failed', 'bad'), unknown: w('Unknown', 'muted') },
6 completeness: { complete: w('Complete', 'ok'), partial: w('Partial', 'warn'), failed: w('Failed', 'bad') },
7 worker: { pending: w('Queued', 'muted'), running: w('Working', 'info'), completed: w('Done', 'ok'), failed: w('Failed', 'bad'), skipped: w('Skipped', 'muted') },
8 result: { ok: w('Result recorded', 'ok'), needs_input: w('Waiting for your input', 'warn'), blocked: w('Blocked', 'bad'), failed: w('Failed', 'bad'), none: w('No result yet', 'muted') },
9 finding: { pass: w('Pass', 'ok'), fail: w('Fail', 'bad'), unknown: w('Not enough data', 'unclear'), not_applicable: w('Not applicable', 'muted') },
10 severity: { critical: w('Critical', 'bad'), high: w('High', 'bad'), medium: w('Medium', 'warn'), informational: w('Info', 'muted') },
11 confidence: { high: w('High', 'ok'), medium: w('Medium', 'warn'), low: w('Low', 'warn'), none: w('None', 'muted') },
12 support: { current: w('Up to date', 'ok'), stale: w('Out of date', 'warn'), demoted: w('Less certain', 'warn'), missing: w('No support', 'bad'), contested: w('Sources disagree', 'warn'), provisional: w('Not final', 'warn') },
13 input: { unchecked: w('Not checked yet', 'muted'), valid: w('Valid structure', 'ok'), invalid: w('Invalid', 'bad'), missing: w('File missing', 'bad') },
14 outcome: { succeeded: w('Succeeded', 'ok'), failed: w('Failed', 'bad'), blocked: w('Blocked', 'warn') },
15 change: { new_failure: w('New failure', 'bad'), new_finding: w('New finding', 'info'), newly_unknown: w('Now unknown', 'warn'), not_retested: w('Not retested', 'muted'), status_changed: w('Changed', 'info'), unchanged: w('Unchanged', 'muted') },
16 scoring: { normal: w('Scored', 'ok'), provisional: w('Early estimate', 'warn'), insufficient_evidence: w('Not enough evidence to score', 'muted') },
17 operation: { 'validate-input': w('Input check', 'muted'), 'validate-run': w('Run check', 'muted'), 'render-report': w('Report', 'muted') },
18 privacy: { public: w('Public', 'muted'), internal: w('Internal', 'muted'), confidential: w('Confidential', 'warn'), restricted: w('Restricted', 'warn') },
19});
20// Fixed messages that may reach the screen from outside the controller. Each is a constant with no
21// path, stderr or account data in it, so it can be shown as written. Anything else collapses to
22// MESSAGES.unavailable. Terms used on screen: checks, steps, platform and engine (the local Claude Ads core).
23export const MESSAGES = Object.freeze({
24 setup: 'Not set up yet. Set the ads-cockpit options coreRoot and pythonExecutable to full paths (run: claude plugin configure ads-cockpit), then open the cockpit again.',
25 runs: 'No runs folder is set. Set the ads-cockpit option runsRoot (run: claude plugin configure ads-cockpit), or try the practice demo.',
26 unavailable: 'Local request unavailable. Run doctor or refresh the selected run.',
27 didNotFinish: 'The local engine did not finish. Check coreRoot and pythonExecutable, then retry.',
28 refused: 'The local engine refused the request. Run doctor or refresh the selected run.',
29 incompatible: 'The local engine returned an incompatible response.',
30 badId: 'Select a valid registered run or input ID.',
31 bandSetup: 'Claude Ads Cockpit: not set up yet. Set coreRoot and pythonExecutable.',
32 bandUnavailable: 'Claude Ads Cockpit: local request unavailable.',
33});
34const NAMES = { google: 'Google Ads', meta: 'Meta Ads', youtube: 'YouTube Ads', linkedin: 'LinkedIn Ads', tiktok: 'TikTok Ads', microsoft: 'Microsoft Ads', apple: 'Apple Ads', amazon: 'Amazon Ads', reddit: 'Reddit Ads', pinterest: 'Pinterest Ads', snapchat: 'Snapchat Ads', x: 'X Ads' };
35export const humanize = value => String(value ?? '').replace(/[_-]+/g, ' ').trim().replace(/^./, c => c.toUpperCase());
36export function say(group, value) {
37 const entry = LEXICON[group]?.[value ?? 'none'];
38 return entry || Object.freeze({ word: humanize(value) || 'Unknown', tone: 'muted' });
39}
40export const toneFor = (group, value) => toneOf(say(group, value).tone);
41export const platformName = platform => (platform ? NAMES[platform] || humanize(platform) : 'Shared');
42export const shortHash = hash => String(hash ?? '').slice(0, 8);
43export function bytes(n) {
44 if (!Number.isFinite(n)) return '';
45 if (n < 1024) return `${n} B`;
46 if (n < 1048576) return `${(n / 1024).toFixed(n < 10240 ? 1 : 0)} KB`;
47 return `${(n / 1048576).toFixed(1)} MB`;
48}
49// Timestamps are shown in UTC so a screenshot means the same thing everywhere.
50export function when(iso) {
51 const s = String(iso ?? '');
52 return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s) ? `${s.slice(0, 10)} ${s.slice(11, 16)} UTC` : s || 'unknown';
53}
54export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
55hooks/controller.mjs 212 lines1import { MESSAGES } from './lexicon.mjs';
2import { doctorWire, snapshotWire, receiptWire, demoWire, preparedDemoWire, TABS } from './wire.mjs';
3export function emptyView() {
4 return { visible: false, selected: null, tab: 'Run', snapshot: null, doctor: null, receipt: null, error: null, busy: false, demo: null, baseline: '', context: null, details: false, page: 0, filter: 'all' };
5}
6// IDs and relative paths may contain punctuation. Match the complete literal
7// reference bounded by characters outside either token alphabet, never a word fragment.
8function hasLiteralReference(text, reference) {
9 const tokenCharacter = value => value !== undefined && /[\p{L}\p{N}\p{M}_./\\%-]/u.test(value);
10 let offset = 0;
11 while (offset < text.length) {
12 const index = text.indexOf(reference, offset);
13 if (index === -1) return false;
14 const end = index + reference.length;
15 if (!tokenCharacter(text[index - 1]) && !tokenCharacter(text[end])) return true;
16 offset = end;
17 }
18 return false;
19}
20const SHOWN = new Set([MESSAGES.setup, MESSAGES.runs, MESSAGES.didNotFinish, MESSAGES.refused, MESSAGES.incompatible, MESSAGES.badId]);
21// Original controller. Every dependent effect rechecks its captured generation.
22export function createController(adapter) {
23 let state = emptyView();
24 let epoch = 0;
25 let timer = null;
26 let refreshBusy = false;
27 let actionBusy = false;
28 let pendingWork = false;
29 let publishQueue = Promise.resolve();
30 const publish = () => {
31 const view = JSON.parse(JSON.stringify(state));
32 publishQueue = publishQueue.then(() => adapter.publish(view));
33 return publishQueue;
34 };
35 const stop = () => { if (timer) timer.cancel(); timer = null; };
36 const roots = () => state.demo ? { runsRoot: state.demo.runsRoot, policyRoot: state.demo.policyRoot } : {};
37 const generation = ticket => epoch === ticket && state.visible;
38 const selection = (ticket, runId, baseline) => generation(ticket) && state.selected === runId && state.baseline === baseline;
39 // Polling runs only while the pane is drawn. An undrawn, closed or backgrounded pane costs no process.
40 // If the adapter cannot say, the answer is "not drawn": the safe side is to start nothing.
41 const drawn = async () => {
42 if (typeof adapter.isDrawn !== 'function') return true;
43 try { return (await adapter.isDrawn()) === true; } catch { return false; }
44 };
45 // The timer callback never rejects: a failure ends this tick, and the next one is armed by the tick itself.
46 const tick = async () => {
47 timer = null;
48 if (!(await drawn())) { schedule(); return; }
49 await refresh();
50 };
51 const schedule = () => {
52 stop();
53 if (state.visible && state.selected && pendingWork) timer = adapter.after(5000, () => tick().catch(() => { schedule(); }));
54 };
55 // Only fixed, path-free messages reach the screen. A raw error (a path, stderr, account data) never does.
56 const diagnose = error => (typeof error?.message === 'string' && (error.message.startsWith('Engine response') || SHOWN.has(error.message)) ? error.message : MESSAGES.unavailable);
57 async function probe(request = roots()) {
58 const value = doctorWire(await adapter.call('doctor', request));
59 if (value.status !== 'ok') throw new Error('Engine response is blocked. Check policy and the configured engine.');
60 return value;
61 }
62 async function load(ticket, runId, baseline, boundRoots) {
63 const matches = () => selection(ticket, runId, baseline);
64 if (!matches()) return null;
65 const doctor = await probe(boundRoots);
66 if (!matches()) return null;
67 // A plain snapshot anchors the comparison's current run and policy subject.
68 const anchor = snapshotWire(await adapter.call('snapshot', { ...boundRoots, runId }));
69 if (!matches()) return null;
70 if (anchor.run_id !== runId || anchor.comparison !== null) throw new Error('Engine response has a mismatched selection.');
71 if (!baseline) return { doctor, snapshot: anchor };
72 const compared = snapshotWire(await adapter.call('compare', { ...boundRoots, runId, baselineId: baseline }));
73 if (!matches()) return null;
74 if (compared.run_id !== runId || compared.comparison?.baseline_run_id !== baseline
75 || compared.fingerprint !== anchor.fingerprint || compared.policy_fingerprint !== anchor.policy_fingerprint) {
76 throw new Error('Engine response has a mismatched comparison subject.');
77 }
78 return { doctor, snapshot: compared };
79 }
80 async function chooser() {
81 stop(); pendingWork = false; const ticket = ++epoch;
82 state = { ...emptyView(), visible: true, busy: true };
83 await publish();
84 if (!generation(ticket)) return;
85 try {
86 const doctor = await probe({});
87 if (generation(ticket)) state = { ...state, doctor, busy: false };
88 } catch (e) { if (generation(ticket)) state = { ...state, error: diagnose(e), busy: false }; }
89 if (generation(ticket)) await publish();
90 }
91 async function select(runId, tab = 'Run', baseline = '', retainTicket = null) {
92 if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(runId) || (baseline && (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(baseline) || baseline === runId))) return;
93 if (retainTicket !== null && !generation(retainTicket)) return;
94 stop(); pendingWork = false; const ticket = retainTicket === null ? ++epoch : retainTicket; const boundRoots = roots();
95 state = { ...state, visible: true, selected: runId, tab, baseline, context: null, snapshot: null, receipt: null, error: null, busy: true, page: 0, filter: 'all' };
96 await publish();
97 if (!selection(ticket,runId,baseline)) return;
98 try {
99 const loaded = await load(ticket, runId, baseline, boundRoots);
100 if (loaded && selection(ticket,runId,baseline)) {
101 pendingWork = ['pending','running'].includes(loaded.snapshot.workflow_status);
102 state = { ...state, ...loaded, busy: false };
103 }
104 } catch (e) { if (selection(ticket,runId,baseline)) state = { ...state, error: diagnose(e), busy: false }; }
105 if (selection(ticket,runId,baseline)) { await publish(); if (selection(ticket,runId,baseline)) schedule(); }
106 }
107 async function refresh() {
108 if (!state.visible || !state.selected || refreshBusy || actionBusy) { schedule(); return; }
109 refreshBusy = true; const ticket = epoch; const runId = state.selected; const boundRoots = roots(); const baseline = state.baseline;
110 try {
111 const loaded = await load(ticket, runId, baseline, boundRoots);
112 if (loaded && selection(ticket,runId,baseline)) {
113 pendingWork = ['pending','running'].includes(loaded.snapshot.workflow_status);
114 state = { ...state, ...loaded, context: loaded.snapshot.fingerprint === state.snapshot?.fingerprint ? state.context : null, error: null };
115 }
116 } catch (e) { if (selection(ticket,runId,baseline)) state = { ...state, snapshot: null, error: diagnose(e) }; }
117 finally {
118 refreshBusy = false;
119 if (selection(ticket,runId,baseline)) { await publish(); if (selection(ticket,runId,baseline)) schedule(); }
120 }
121 }
122 // For a turn end: refresh only a visible run that is still queued or running, and only while drawn.
123 async function refreshIfPending() {
124 if (!state.visible || !state.selected || !pendingWork || !(await drawn())) return;
125 await refresh();
126 }
127 async function action(operation, inputId = null, format = 'markdown', subject) {
128 if (!subject || subject.runId !== state.selected || subject.fingerprint !== state.snapshot?.fingerprint || subject.policyFingerprint !== state.snapshot?.policy_fingerprint) return;
129 if (actionBusy || refreshBusy || state.busy || state.error || !state.visible || !state.selected || !state.snapshot || !state.snapshot.permitted_actions.includes(operation)) return;
130 if (operation === 'validate-input' && !state.snapshot.inputs.some(input => input.id === inputId)) return;
131 const runId = state.selected, baseline = state.baseline, fingerprint = state.snapshot.fingerprint, policyFingerprint = state.snapshot.policy_fingerprint;
132 const ticket = epoch, boundRoots = roots(); const matches = () => selection(ticket,runId,baseline) && state.snapshot?.fingerprint === fingerprint && state.snapshot?.policy_fingerprint === policyFingerprint;
133 let actionFailure = null;
134 actionBusy = true; stop(); state = { ...state, busy: true, error: null, receipt: null }; await publish();
135 try {
136 if (!matches()) return;
137 await probe(boundRoots);
138 if (!matches()) return;
139 const receipt = receiptWire(await adapter.call('action', { ...boundRoots, runId, operation, fingerprint, format, ...(inputId ? { inputId } : {}) }));
140 if (!matches()) return;
141 if (receipt.run_id !== runId || receipt.operation !== operation || receipt.input_fingerprint !== fingerprint || receipt.policy_fingerprint !== policyFingerprint) throw new Error('Engine response has a mismatched receipt.');
142 state = { ...state, receipt };
143 } catch (e) { if (selection(ticket,runId,baseline)) { actionFailure = diagnose(e); state = { ...state, error: actionFailure }; } }
144 finally {
145 actionBusy = false;
146 if (selection(ticket,runId,baseline)) {
147 state = { ...state, busy: false }; await publish();
148 if (selection(ticket,runId,baseline)) await refresh();
149 if (actionFailure && selection(ticket,runId,baseline)) { state = { ...state, error: actionFailure }; await publish(); }
150 }
151 }
152 }
153 async function demo() {
154 stop(); pendingWork = false; const ticket = ++epoch;
155 state = { ...emptyView(), visible: true, busy: true }; await publish();
156 if (!generation(ticket)) return;
157 try {
158 await probe({});
159 if (!generation(ticket)) return;
160 const destination = preparedDemoWire(await adapter.call('prepare-demo', {}), adapter.protectedRoots || []);
161 if (!generation(ticket)) return;
162 const setup = demoWire(await adapter.call('demo', { runsRoot: destination.runs_root, policyRoot: destination.policy_root }));
163 if (!generation(ticket)) return;
164 if (setup.runs_root !== destination.runs_root || setup.policy_root !== destination.policy_root) throw new Error('Engine response has a mismatched demo destination.');
165 const demo = { runsRoot: setup.runs_root, policyRoot: setup.policy_root, steps: setup.steps, index: 0 };
166 state = { ...state, demo, busy: false };
167 const first = demo.steps[0];
168 await select(first.run_id, first.tab, first.baseline_id || '', ticket);
169 } catch (e) { if (generation(ticket)) { state = { ...state, busy: false, error: diagnose(e) }; await publish(); } }
170 }
171 async function step(delta) {
172 if (!state.demo || state.busy || actionBusy) return;
173 const index = Math.max(0, Math.min(state.demo.steps.length - 1, state.demo.index + delta));
174 state = { ...state, demo: { ...state.demo, index } };
175 const item = state.demo.steps[index]; await select(item.run_id, item.tab, item.baseline_id || '');
176 }
177 async function hide() { stop(); pendingWork = false; epoch++; state = emptyView(); await publish(); }
178 async function tab(value) { if (state.visible && TABS.includes(value)) { state = { ...state, tab: value, page: 0 }; await publish(); } }
179 async function details() { if (state.visible && state.selected) { state = { ...state, details: !state.details, page: 0 }; await publish(); } }
180 // Paging and filtering only change what is drawn from the cached snapshot. No process runs.
181 async function page(delta) { if (state.visible && (delta === 1 || delta === -1)) { state = { ...state, page: Math.max(0, state.page + delta) }; await publish(); } }
182 // Leave a opened check or input and return to the list. No process runs.
183 async function clearContext() { if (state.visible && state.selected && state.context) { state = { ...state, context: null, page: 0 }; await publish(); } }
184 async function filter() { if (state.visible && state.selected) { const order = ['all', 'fail', 'unknown', 'pass']; state = { ...state, filter: order[(order.indexOf(state.filter) + 1) % order.length], page: 0 }; await publish(); } }
185 async function navigate(tab, target, runId, fingerprint) {
186 // Cached navigation is bound to the rendered run and its exact input subject.
187 if (!state.visible || state.selected !== runId || !state.snapshot || state.snapshot.fingerprint !== fingerprint) return;
188 let context;
189 if (tab === 'Inputs') {
190 const worker = state.snapshot.workers.find(item => item.id === target?.workerId);
191 if (!worker || !worker.missing_inputs.includes(target.missingInput)) return;
192 const textMatches = state.snapshot.inputs.filter(input => hasLiteralReference(target.missingInput,input.id) || hasLiteralReference(target.missingInput,input.path));
193 const related = textMatches.length ? textMatches : state.snapshot.inputs.filter(input => worker.platform && input.platform === worker.platform);
194 context = { kind: 'inputs', workerId: worker.id, missingInput: target.missingInput, inputIds: related.map(input => input.id), match: textMatches.length ? 'text' : related.length ? 'platform' : 'none' };
195 } else if (tab === 'Evidence') {
196 const finding = state.snapshot.findings.find(item => item.platform === target?.platform && item.finding.control_id === target.controlId);
197 if (!finding) return;
198 context = { kind: 'finding', platform: finding.platform, controlId: finding.finding.control_id };
199 } else return;
200 state = { ...state, tab, context, page: 0 }; await publish();
201 }
202 async function baseline(value) {
203 if (state.visible && state.selected && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value) && value !== state.selected) await select(state.selected, 'Compare', value);
204 }
205 async function doctor() {
206 const ticket = epoch;
207 try { const result = doctorWire(await adapter.call('doctor', roots())); if (epoch === ticket) { state = { ...state, doctor: result, error: result.status === 'ok' ? null : 'Engine policy is blocked.' }; await publish(); } return result.status; }
208 catch (e) { if (epoch === ticket) { state = { ...state, error: diagnose(e) }; await publish(); } return 'unavailable'; }
209 }
210 return { chooser, select, open: runId => { state = emptyView(); return select(runId); }, version: () => epoch, refresh, refreshIfPending, action, demo, step, hide, tab, details, page, filter, clearContext, navigate, baseline, doctor, reset: () => state.demo ? step(-state.demo.index) : chooser(), view: () => state, dispose: hide };
211}
212hooks/views.mjs 155 lines1// The pane shell: header, practice-demo strip, tabs, one screen, then the footer.
2// Pure. It reads cached state and calls the same controls the controller has always exposed.
3import { makeKit } from './kit.mjs';
4import { say } from './lexicon.mjs';
5import { counts } from './model.mjs';
6import { toneOf } from './theme.mjs';
7import { TABS } from './wire.mjs';
8import { chooser } from './screens/chooser.mjs';
9import { compare } from './screens/compare.mjs';
10import { findings } from './screens/findings.mjs';
11import { inputs } from './screens/inputs.mjs';
12import { overview } from './screens/overview.mjs';
13import { reports } from './screens/reports.mjs';
14export { drawBand } from './band.mjs';
15
16const LABEL = { Run: 'Overview', Evidence: 'Checks', Inputs: 'Inputs', Compare: 'Compare', Reports: 'Reports' };
17const SCREENS = { Run: overview, Evidence: findings, Inputs: inputs, Compare: compare, Reports: reports };
18const COACH = [
19 'Press Next (n) to follow one problem from start to finish.',
20 'The card names the file it needs. Press Next (n) to see it.',
21 'Registered files, with a red cross where one is missing.',
22 'Press the Check button beside it (or k to check the whole run) and read the result.',
23 'Look for "Out of date" beside the check.',
24 'The finished work is still shown. Nothing is hidden.',
25 'Only what the evidence says. Nothing is claimed as fixed.',
26 'A receipt is saved for every attempt, even one that fails.',
27];
28
29// One plain sentence per step, shown large. The longer line beneath says what to look at.
30const HEADLINES = [
31 'A whole audit at a glance',
32 'Google Ads is stuck: it needs a file',
33 'The file it needs is missing',
34 'The corrected file is in. Check it.',
35 'One check rests on an expired source',
36 'One step failed, the rest still shows',
37 'Before and after, nothing claimed as fixed',
38 'Make a report and keep a receipt',
39];
40
41// Lines a row of plain buttons takes at this width ("1: Label" each, a gap between them).
42const rowLines = (labels, gap, width) => {
43 let lines = 1, used = 0;
44 for (const label of labels) {
45 const w = label.length + 3;
46 if (used && used + gap + w > width) { lines++; used = w; } else used += (used ? gap : 0) + w;
47 }
48 return lines;
49};
50
51function tabLabels(state, c) {
52 const badge = { Run: '', Evidence: c ? ` ${c.findings}` : '', Inputs: c && (c.missing.length || c.invalidInputs) ? ' ▲' : '', Compare: '', Reports: c && c.receipts ? ` ${c.receipts}` : '' };
53 return TABS.map(tab => `${tab === state.tab ? '▸ ' : ''}${LABEL[tab]}${badge[tab]}`);
54}
55
56function tabBar(kit, state, c, controls) {
57 const labels = tabLabels(state, c);
58 return kit.row(TABS.map((tab, index) => kit.button(`tab-${tab.toLowerCase()}`, labels[index], String(index + 1), () => controls.tab(tab), { plain: true })), { gap: 2, wrap: true });
59}
60
61// First line of the practice strip. The disclosure is worded to fit; it is never dropped.
62function demoHeader(kit, demo) {
63 const step = `Step ${demo.index + 1} of ${demo.steps.length}`;
64 const fixed = 'PRACTICE DATA'.length + step.length + (demo.steps.length * 2 - 1) + 6;
65 const note = ['Made-up data, not a real account', 'Made-up data'].find(text => fixed + text.length <= kit.width) ?? 'Made-up';
66 return kit.row([
67 kit.line([{ text: 'PRACTICE DATA', bold: true, color: 'warning' }, { text: step, dim: true }], { gap: 2, wrap: 'truncate-end' }),
68 kit.dots(demo.index, demo.steps.length),
69 kit.t(note, { dimColor: true, wrap: 'truncate-end' }),
70 ], { gap: 2 });
71}
72
73// The practice strip: what this step is, and what to look at. Height is fixed by `coachLines`.
74function demoStrip(kit, state, coachLines) {
75 const demo = state.demo;
76 const step = demo.steps[demo.index];
77 return [
78 demoHeader(kit, demo),
79 kit.t(demo.steps.length === HEADLINES.length ? HEADLINES[demo.index] : step.title, { bold: true, wrap: 'truncate-end', ...kit.toneStyle('info') }),
80 ...(coachLines > 0 && demo.steps.length === COACH.length ? kit.clamp(COACH[demo.index], coachLines, { dimColor: true }) : []),
81 ];
82}
83
84function demoControls(kit, state, controls) {
85 const off = state.busy;
86 const demo = state.demo;
87 return kit.row([
88 kit.button('demo-back', 'Back', 'b', () => controls.step(-1), { plain: true, off: off || demo.index === 0 }),
89 kit.button('demo-next', 'Next', 'n', () => controls.step(1), { primary: true, off: off || demo.index === demo.steps.length - 1 }),
90 kit.button('demo-reset', 'Reset', 'z', controls.reset, { plain: true, off }),
91 kit.button('demo-exit', 'Exit demo', 'q', controls.chooser, { plain: true }),
92 ], { gap: 2 });
93}
94
95function footer(kit, state, controls) {
96 const off = state.busy;
97 return kit.row([
98 kit.button('refresh', 'Refresh', 'r', controls.refresh, { plain: true, off: off || !state.selected }),
99 ...(state.selected ? [kit.button('chooser', 'Runs', 'c', controls.chooser, { plain: true, off })] : []),
100 ...(controls.details && state.selected ? [kit.button('details', state.details ? 'Fewer details' : 'Details', 'd', controls.details, { plain: true })] : []),
101 kit.button('hide', 'Hide', 'h', controls.hide, { plain: true }),
102 ], { gap: 2 });
103}
104
105// The pane is drawn to fit `rows` lines (the host's body height), so no screen needs to scroll.
106// Chrome (title, tabs, practice strip, notices, keys) is counted first; the rest is the screen's budget.
107export const MIN_ROWS = 14;
108export function drawPane(ui, state, controls, width = 60, isFocused = true, rows = 34) {
109 const total = Math.max(MIN_ROWS, Math.floor(Number.isFinite(rows) ? rows : 34));
110 const kit = makeKit(ui, width, total);
111 const snapshot = state.snapshot;
112 const c = snapshot ? counts(snapshot) : null;
113 const subject = snapshot ? { runId: state.selected, fingerprint: snapshot.fingerprint, policyFingerprint: snapshot.policy_fingerprint } : null;
114 const roomy = total >= 28;
115 let wrapped = 0;
116 const top = [];
117 const brand = kit.line([{ text: ' CLAUDE ADS ', bold: true, inverse: true, color: 'claude', raw: true }, ...(state.selected && !state.demo ? [{ text: ` ${state.selected}`, dim: true }] : []), ...(state.busy ? [{ text: ' working…', tone: 'info' }] : []), ...(isFocused ? [] : [{ text: ' ctrl+x tab to use the keys', dim: true }])], { wrap: 'truncate-end' });
118 // In the practice demo the run id means nothing, so the title and the tabs share one line when they fit.
119 const tabWidth = state.selected ? tabLabels(state, c).reduce((a, label) => a + label.length + 3, 0) + 2 * (TABS.length - 1) : 0;
120 const merged = Boolean(state.demo && state.selected && isFocused && 12 + 2 + tabWidth <= width);
121 top.push(merged ? kit.row([brand, tabBar(kit, state, c, controls)], { gap: 2 }) : brand);
122 const bottom = [];
123 if (state.demo) {
124 const coach = total >= 36 ? 3 : total >= 20 ? 2 : total >= 17 ? 1 : 0;
125 top.push(...demoStrip(kit, state, coach));
126 bottom.push(demoControls(kit, state, controls));
127 }
128 if (state.error) {
129 // A setup or engine message is shown whole, never cut. Its lines count against the budget.
130 top.push(kit.col([kit.row([kit.t('✗', kit.toneStyle('bad')), kit.t(state.error)], { gap: 1 })], { key: 'error' }));
131 wrapped += kit.countLines(state.error, 2) - 1;
132 }
133 if (state.selected && !merged) {
134 top.push(tabBar(kit, state, c, controls));
135 // At a narrow width the tab row wraps; its extra lines count against the budget.
136 wrapped += rowLines(tabLabels(state, c), 2, width) - 1;
137 }
138 if (state.selected && snapshot && state.receipt && state.tab !== 'Reports') {
139 const outcome = say('outcome', state.receipt.outcome);
140 top.push(kit.line([{ text: `${toneOf(outcome.tone).mark} `, tone: outcome.tone }, { text: say('operation', state.receipt.operation).word, bold: true }, { text: outcome.word, tone: outcome.tone }, ...(state.receipt.diagnostics[0] ? [{ text: state.receipt.diagnostics[0], dim: true }] : [])], { gap: 2, wrap: 'truncate-end' }));
141 }
142 bottom.push(footer(kit, state, controls));
143 const budget = Math.max(4, total - top.length - wrapped - bottom.length - (roomy ? 1 : 0));
144 const ctx = {
145 state, snapshot, context: state.context, controls, budget,
146 action: (operation, inputId = null, format = 'markdown') => controls.action(operation, inputId, format, subject),
147 go: (tab, target) => (target ? controls.navigate(tab, target, state.selected, snapshot.fingerprint) : controls.tab(tab)),
148 };
149 let body;
150 if (!state.selected) body = chooser(kit, ctx);
151 else if (!snapshot) body = [kit.t('No trusted data for this run yet.', { bold: true }), kit.hint('Press r to refresh, or choose another run.')];
152 else body = SCREENS[state.tab](kit, ctx);
153 return kit.col([...top, ...(roomy ? [kit.t(' ')] : []), ...body, ...bottom]);
154}
155hooks/theme.mjs 14 lines1// Semantic tones. Colors are theme keys, so the pane follows the person's dark or light theme.
2// No hex values here: a hex color would not adapt. Marks are single-width glyphs.
3export const TONES = Object.freeze({
4 ok: Object.freeze({ mark: '✓', color: 'success' }),
5 warn: Object.freeze({ mark: '▲', color: 'warning' }),
6 bad: Object.freeze({ mark: '✗', color: 'error' }),
7 info: Object.freeze({ mark: '●', color: 'suggestion' }),
8 muted: Object.freeze({ mark: '○', color: null }),
9 unclear: Object.freeze({ mark: '?', color: null }),
10});
11export const toneOf = name => TONES[name] || TONES.muted;
12// Hex only inside Svg, where theme keys do not apply. Mid-tones that hold on light and dark.
13export const SVG_COLORS = Object.freeze({ ok: '#16a34a', warn: '#d97706', bad: '#dc2626', info: '#3b82f6', muted: '#8a8f98', unclear: '#8a8f98' });
14hooks/wire.mjs 93 lines1import { absolutePath } from './launcher.mjs';
2// Fail closed at the display boundary. The core owns canonical validation.
3const SHA = /^[a-f0-9]{64}$/;
4const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
5const PLATFORMS = ['google','meta','youtube','linkedin','tiktok','microsoft','apple','amazon','reddit','pinterest','snapchat','x'];
6const EVIDENCE = ['current','stale','demoted','missing','contested','provisional'];
7const OPS = ['validate-input', 'validate-run', 'render-report'];
8export const TABS = ['Run', 'Evidence', 'Inputs', 'Compare', 'Reports'];
9export function safeText(value) {
10 return String(value ?? '').replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ').slice(0, 8000);
11}
12// One bounded line for a control label: control characters (tab, newline) and runs of spaces collapse.
13export function oneLine(value, max = 160) {
14 const text = safeText(value).replace(/\s+/g, ' ').trim();
15 return text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`;
16}
17function fail() { throw new Error('Engine response does not match cockpit interface 1.0.0.'); }
18function object(x, keys) {
19 if (!x || typeof x !== 'object' || Array.isArray(x) || Object.keys(x).length !== keys.length || keys.some(k => !Object.hasOwn(x, k))) fail();
20}
21function optionalObject(x, required, optional) {
22 if (!x || typeof x !== 'object' || Array.isArray(x) || required.some(k => !Object.hasOwn(x,k)) || Object.keys(x).some(k => !required.includes(k) && !optional.includes(k))) fail();
23}
24function list(x) { if (!Array.isArray(x) || x.length > 1000) fail(); }
25function str(x) { if (typeof x !== 'string' || x.length > 8192 || /[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/.test(x)) fail(); }
26function strings(x) { list(x); x.forEach(str); }
27function choice(x, values) { if (!values.includes(x)) fail(); }
28function rows(x, keys, check) { list(x); for (const row of x) { object(row, keys); check(row); } }
29function relative(x) { str(x); if (!x || x.startsWith('/') || /[\\:]/.test(x) || x.split('/').some(v => !v || v === '.' || v === '..')) fail(); }
30function timestamp(x) { str(x); if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(x) || !Number.isFinite(Date.parse(x))) fail(); }
31function hash(x) { if (!SHA.test(x || '')) fail(); }
32export function doctorWire(x) {
33 object(x, ['interface_version', 'status', 'core_version', 'profiles', 'runs', 'diagnostics']);
34 if (x.interface_version !== '1.0.0') fail();
35 choice(x.status, ['ok', 'blocked']); str(x.core_version); strings(x.diagnostics);
36 rows(x.profiles, ['platform', 'status', 'disabled_reason'], r => { str(r.platform); str(r.status); if (r.disabled_reason !== null) str(r.disabled_reason); });
37 rows(x.runs, ['id', 'status'], r => { if (!ID.test(r.id)) fail(); str(r.status); });
38 return x;
39}
40export function snapshotWire(x) {
41 object(x, ['schema_version','artifact_type','run_id','evaluated_at','fingerprint','policy_fingerprint','privacy_class','phase','completeness','workflow_status','workers','accounts','findings','claims','sources','inputs','artifacts','scoring','permitted_actions','limitations','comparison','receipts']);
42 if (x.schema_version !== '1.0.0' || x.artifact_type !== 'cockpit-snapshot' || !ID.test(x.run_id)) fail();
43 timestamp(x.evaluated_at); hash(x.fingerprint); hash(x.policy_fingerprint); choice(x.privacy_class,['public','internal','confidential','restricted']); if (x.phase !== null) str(x.phase);
44 choice(x.completeness, ['complete','partial','failed']); choice(x.workflow_status, ['pending','running','completed','blocked','failed','unknown']);
45 rows(x.workers, ['id','platform','status','result_status','missing_inputs','contradictions','stale_sources','recovery_hints'], r => { if (!ID.test(r.id)) fail(); if (r.platform !== null) choice(r.platform,PLATFORMS); choice(r.status,['pending','running','completed','failed','skipped']); choice(r.result_status, ['ok','needs_input','blocked','failed',null]); ['missing_inputs','contradictions','stale_sources','recovery_hints'].forEach(k => strings(r[k])); });
46 rows(x.accounts, ['platform','account_key','window','currency'], r => { choice(r.platform,PLATFORMS); hash(r.account_key); object(r.window,['start','end']); str(r.window.start); str(r.window.end); if (r.window.start > r.window.end) fail(); if (!/^[A-Z]{3}$/.test(r.currency)) fail(); });
47 rows(x.findings, ['platform','account_key','finding','control_title','severity','claim_ids','source_ids','evidence_status'], r => {
48 str(r.control_title); if (r.control_title.length > 200) fail();
49 choice(r.platform,PLATFORMS); if (r.account_key !== null) hash(r.account_key); optionalObject(r.finding,['schema_version','control_id','status','evidence','confidence','observation','diagnosis','recommendation'],['source_classification','score_contribution']); if (r.finding.schema_version !== '1.0.0') fail(); list(r.finding.evidence); if (r.finding.evidence.some(e => !e || typeof e !== 'object' || Array.isArray(e))) fail();
50 ['control_id','status','confidence','observation','diagnosis','recommendation'].forEach(k => str(r.finding[k]));
51 choice(r.finding.status, ['pass','fail','unknown','not_applicable']); choice(r.finding.confidence,['high','medium','low','none']); if (['pass','fail'].includes(r.finding.status) && !r.finding.evidence.length) fail(); if ('source_classification' in r.finding) choice(r.finding.source_classification,['evidence_based','practitioner','contested','folklore']); if ('score_contribution' in r.finding && r.finding.score_contribution !== null && !Number.isFinite(r.finding.score_contribution)) fail();
52 strings(r.claim_ids); strings(r.source_ids); choice(r.evidence_status,['current','stale','demoted','missing','contested','provisional']);
53 choice(r.severity,['critical','high','medium','informational']);
54 });
55 rows(x.claims, ['id','statement','verdict','classification','last_verified','refresh_due','effective_status','source_ids','control_ids'], r => { ['id','statement','verdict','classification','effective_status'].forEach(k => str(r[k])); for (const k of ['last_verified','refresh_due']) if (r[k] !== null) str(r[k]); strings(r.source_ids); strings(r.control_ids); });
56 rows(x.sources, ['id','title','locator','retrieved_at','refresh_due','confidence','status','claim_ids','control_ids'], r => { ['id','title','confidence','status'].forEach(k => str(r[k])); for (const k of ['retrieved_at','refresh_due']) if (r[k] !== null) str(r[k]); if (r.locator !== null) { str(r.locator); if (!/^https?:\/\//.test(r.locator)) fail(); } strings(r.claim_ids); strings(r.control_ids); });
57 rows(x.inputs, ['id','path','kind','contract','platform','sha256','validation','details'], r => { if (!ID.test(r.id)) fail(); relative(r.path); choice(r.kind,['json','csv']); if (r.contract !== null) str(r.contract); if (r.platform !== null) choice(r.platform,PLATFORMS); hash(r.sha256); choice(r.validation,['unchecked','valid','invalid','missing']); strings(r.details); });
58 rows(x.artifacts, ['path','kind','sha256','size'], r => { relative(r.path); str(r.kind); hash(r.sha256); if (!Number.isSafeInteger(r.size) || r.size < 0) fail(); });
59 rows(x.scoring, ['platform','health_score','evidence_coverage','status','profile_status'], r => { choice(r.platform,PLATFORMS); choice(r.status,['normal','provisional','insufficient_evidence']); choice(r.profile_status,['enabled','disabled']); for (const key of ['health_score','evidence_coverage']) if (r[key] !== null && (typeof r[key] !== 'number' || !Number.isFinite(r[key]) || r[key] < 0 || r[key] > 100)) fail(); if (r.evidence_coverage === null || (r.profile_status === 'disabled' && (r.health_score !== null || r.evidence_coverage !== 0))) fail(); });
60 rows(x.receipts,['path','sha256','size','receipt'], r => { relative(r.path); hash(r.sha256); if (!Number.isSafeInteger(r.size) || r.size < 0) fail(); receiptWire(r.receipt); if (r.receipt.run_id !== x.run_id || r.path !== `cockpit/receipts/${r.receipt.action_id}.json`) fail(); });
61 strings(x.permitted_actions); if (x.permitted_actions.some(v => !OPS.includes(v))) fail(); strings(x.limitations);
62 if (x.comparison !== null) {
63 object(x.comparison,['baseline_run_id','comparable','differences','findings']); str(x.comparison.baseline_run_id); if (typeof x.comparison.comparable !== 'boolean') fail(); strings(x.comparison.differences);
64 rows(x.comparison.findings,['platform','control_id','change','baseline_status','current_status'], r => { choice(r.platform,PLATFORMS); str(r.control_id); choice(r.change,['new_failure','new_finding','newly_unknown','not_retested','status_changed','unchanged']); for (const k of ['baseline_status','current_status']) choice(r[k],['pass','fail','unknown','not_applicable',null]); });
65 }
66 return x;
67}
68export function receiptWire(x) {
69 object(x,['schema_version','artifact_type','action_id','run_id','operation','started_at','finished_at','outcome','input_fingerprint','policy_fingerprint','outputs','diagnostics']);
70 if (x.schema_version !== '1.0.0' || x.artifact_type !== 'cockpit-action-receipt' || !ID.test(x.run_id)) fail();
71 if (!ID.test(x.action_id)) fail(); timestamp(x.started_at); timestamp(x.finished_at); if (Date.parse(x.finished_at) < Date.parse(x.started_at)) fail(); choice(x.operation,OPS); choice(x.outcome,['succeeded','failed','blocked']); hash(x.input_fingerprint); hash(x.policy_fingerprint); strings(x.diagnostics);
72 rows(x.outputs,['path','sha256','size'], r => { relative(r.path); hash(r.sha256); if (!Number.isSafeInteger(r.size) || r.size < 0) fail(); }); return x;
73}
74export function demoWire(x) {
75 object(x,['interface_version','runs_root','policy_root','steps']);
76 if (x.interface_version !== '1.0.0') fail(); str(x.runs_root); str(x.policy_root);
77 rows(x.steps,['run_id','title','baseline_id','tab'], r => { if (!ID.test(r.run_id)) fail(); str(r.title); if (r.baseline_id !== null && !ID.test(r.baseline_id)) fail(); choice(r.tab,TABS); });
78 if (!x.steps.length) fail(); return x;
79}
80
81export function preparedDemoWire(x, protectedRoots = []) {
82 object(x,['interface_version','runs_root','policy_root']);
83 if (x.interface_version !== '1.0.0' || !absolutePath(x.runs_root) || !absolutePath(x.policy_root)) fail();
84 const normalize = value => value.replaceAll('\\','/').replace(/\/$/,'').toLowerCase();
85 const root = normalize(x.runs_root), policy = normalize(x.policy_root);
86 if (!/\/claude-ads-demo-[a-z0-9_-]+\/runs$/.test(root) || policy !== root + '/_policy') fail();
87 for (const protectedRoot of protectedRoots) {
88 const real = normalize(protectedRoot);
89 if (root === real || root.startsWith(real + '/') || real.startsWith(root + '/')) fail();
90 }
91 return x;
92}
93hooks/kit.mjs 195 lines1// The small design kit every screen uses. Pure: it only builds elements from the surface's own
2// Box, Text and Button. Props are filtered so no undefined value reaches the strict validator, and
3// every string passes through safeText before it is drawn.
4import { toneOf } from './theme.mjs';
5import { oneLine, safeText } from './wire.mjs';
6
7const defined = object => Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined && value !== null));
8const cellText = cell => (cell !== null && typeof cell === 'object' ? (cell.parts ? cell.parts.map(p => (typeof p === 'string' ? p : p.text)).join('') : cell.text) : cell) ?? '';
9
10export function makeKit(ui, width = 60, rows = 34) {
11 const { Box, Text, Button } = ui;
12 const narrow = width < 54;
13 const wide = width >= 100;
14 const toneStyle = name => { const tone = toneOf(name); return tone.color ? { color: tone.color } : { dimColor: true }; };
15
16 const t = (content, options = {}) => Text({ children: [safeText(content)], ...defined(options) });
17 // A line of mixed styles. Parts are strings or {text, tone, bold, dim, color, inverse}.
18 // Color is applied reliably only to a Text that stands alone, so a line with any colored part is
19 // built from sibling Texts in a row. A line with no color keeps nested Texts, which wrap as one paragraph.
20 // Explicit style wins over the tone, but only where it is set: an undefined value must never erase the tone's color.
21 const partStyle = part => ({ ...(part.tone ? toneStyle(part.tone) : {}), ...defined({ bold: part.bold, dimColor: part.dim, color: part.color, inverse: part.inverse }) });
22 const line = (parts, options = {}) => {
23 const colored = parts.some(part => typeof part === 'object' && (part.tone || part.color));
24 if (colored) {
25 // Spacing comes from the row's gap, never from leading or trailing spaces inside a part.
26 const gap = options.gap ?? 1;
27 const piece = part => (typeof part === 'string' ? { text: part } : part);
28 const kids = parts.map(piece).map(part => ({ ...part, text: gap > 0 && !part.raw ? safeText(part.text).trim() : safeText(part.text) })).filter(part => part.text !== '');
29 if (options.wrap === 'truncate-end') {
30 // A line that must stay one line: trim from the end, last part first, so it never wraps.
31 let over = kids.reduce((a, part) => a + part.text.length, 0) + gap * Math.max(0, kids.length - 1) - (width - 1);
32 for (let i = kids.length - 1; i >= 0 && over > 0; i--) {
33 const keep = Math.max(1, kids[i].text.length - over);
34 if (keep < kids[i].text.length) { over -= kids[i].text.length - keep; kids[i] = { ...kids[i], text: `${kids[i].text.slice(0, Math.max(0, keep - 1))}…` }; }
35 }
36 return Box({ flexDirection: 'row', columnGap: gap, children: kids.map(part => Text({ children: [part.text], wrap: 'truncate-end', ...partStyle(part) })) });
37 }
38 return Box({ flexDirection: 'row', columnGap: gap, flexWrap: 'wrap', children: kids.map(part => Text({ children: [part.text], ...partStyle(part) })) });
39 }
40 const { gap, ...rest } = options;
41 return Text({ children: parts.map(part => (typeof part === 'string' ? safeText(part) : Text({ children: [safeText(part.text)], ...partStyle(part) }))), ...defined(rest) });
42 };
43 const hint = (content, options = {}) => t(content, { dimColor: true, ...options });
44 // A list item that may wrap. The glyph and text stay in one paragraph, so a wrapped line never leaves the glyph alone.
45 const item = (glyph, content) => Text({ children: [Text({ children: [`${glyph} `], bold: true }), safeText(content)] });
46 const mark = name => t(toneOf(name).mark, toneStyle(name));
47 const row = (children, { gap = 1, wrap = false, key } = {}) => Box({ ...defined({ key }), flexDirection: 'row', columnGap: gap, ...(wrap ? { flexWrap: 'wrap' } : {}), children });
48 const col = (children, { gap, key } = {}) => Box({ ...defined({ key, rowGap: gap }), flexDirection: 'column', children });
49 const section = children => Box({ flexDirection: 'column', marginTop: 1, children });
50 const indent = (children, by = 2) => Box({ flexDirection: 'column', marginLeft: by, children });
51 const card = (children, { tone = 'muted', key } = {}) => {
52 const t0 = toneOf(tone);
53 return Box({ ...defined({ key }), flexDirection: 'column', borderStyle: 'round', ...(t0.color ? { borderColor: t0.color } : { borderDimColor: true }), paddingX: 1, children });
54 };
55 const rule = () => t('─'.repeat(Math.max(8, Math.min(width, 120))), { dimColor: true, wrap: 'truncate-end' });
56 // A section title drawn as a rule: "── Title · note ─────". One line, never wraps.
57 const heading = (title, note) => {
58 const label = note ? `${title} · ${note}` : title;
59 return line([{ text: '── ', dim: true, raw: true }, { text: label, bold: true }, { text: ` ${'─'.repeat(Math.max(2, Math.min(width, 100) - label.length - 5))}`, dim: true, raw: true }], { gap: 0, wrap: 'truncate-end' });
60 };
61 // Text wrapped by hand into at most `n` lines of at most `room` characters. The last line ends with an
62 // ellipsis when the text did not fit, so a screen can budget its height exactly.
63 const wrapLines = (content, n, room) => {
64 const words = safeText(content).split(/\s+/).filter(Boolean);
65 const out = [];
66 let current = '';
67 for (const word of words) {
68 const next = current ? `${current} ${word}` : word;
69 if (next.length <= room) current = next;
70 else { if (current) out.push(current); current = word.length > room ? `${word.slice(0, room - 1)}…` : word; }
71 }
72 if (current) out.push(current);
73 const shown = out.slice(0, Math.max(1, n));
74 if (out.length > shown.length) shown[shown.length - 1] = `${shown[shown.length - 1].slice(0, Math.max(0, room - 1)).replace(/\s+$/, '')}…`;
75 return shown;
76 };
77 // How many lines `content` takes when wrapped to this width (after `indent` columns).
78 const countLines = (content, indent = 0) => wrapLines(content, 99, Math.max(8, width - indent)).length;
79 const clamp = (content, n, { indent = 0, ...style } = {}) => wrapLines(content, n, Math.max(8, width - indent))
80 .map(text => Text({ children: [indent ? `${' '.repeat(indent)}${text}` : text], wrap: 'truncate-end', ...defined(style) }));
81 // A labeled paragraph of at most `n` lines: a dim label, then the text wrapped beside it.
82 const field = (label, content, n, labelWidth = 11) => {
83 const pad = label.padEnd(labelWidth);
84 const lines = wrapLines(content, n, Math.max(8, width - labelWidth));
85 return lines.map((text, i) => (i === 0
86 ? line([{ text: pad, dim: true, raw: true }, { text, raw: true }], { gap: 0, wrap: 'truncate-end' })
87 : Text({ children: [`${' '.repeat(labelWidth)}${text}`], wrap: 'truncate-end' })));
88 };
89 const chip = (label, value, name = 'muted') => line([{ text: `${label} `, dim: true }, { text: String(value), bold: true, ...(name === 'muted' ? {} : { tone: name }) }]);
90 const bar = (done, total, length = 10, name = 'ok') => {
91 const filled = total > 0 ? Math.round((Math.min(done, total) / total) * length) : 0;
92 return line([{ text: '█'.repeat(filled), tone: name }, { text: '░'.repeat(length - filled), dim: true }]);
93 };
94 const dots = (index, total) => line(Array.from({ length: total }, (_, i) => ({ text: i <= index ? '●' : '○', ...(i === index ? { tone: 'info', bold: true } : i < index ? { tone: 'info' } : { dim: true }) })));
95 // A control, or its dim label when it cannot act right now. No disabled prop is ever drawn.
96 // Every label is one bounded line, whatever the run data held. The terminal draws a key only on a
97 // plain button, so a framed button names its key in the label: "Next (n)".
98 const button = (key, label, hotkey, onPress, { primary = false, plain = false, off = false } = {}) => {
99 const text = oneLine(label);
100 return off
101 ? t(text, { dimColor: true })
102 : Button({ key, label: hotkey && !plain ? `${text} (${hotkey})` : text, ...defined({ hotkey, plain: plain ? true : undefined, variant: primary && !plain ? 'primary' : undefined }), onPress });
103 };
104 // Aligned table from fixed-width cells. Cells are strings or {text, tone, bold}. One column may grow.
105 const table = (columns, tableRows, { header = true } = {}) => {
106 const gap = 2;
107 const fixed = columns.map((column, i) => (column.grow ? 0 : Math.min(column.max ?? 20, Math.max(column.head.length, ...tableRows.map(r => String(cellText(r[i])).length)))));
108 const used = fixed.reduce((a, b) => a + b, 0) + gap * (columns.length - 1) + 2;
109 const grow = Math.max(8, width - used);
110 const sizes = columns.map((column, i) => (column.grow ? grow : fixed[i]));
111 // Never wider than the room: shrink the widest column a cell at a time (cells truncate with an ellipsis).
112 let over = sizes.reduce((a, b) => a + b, 0) + gap * (columns.length - 1) - width;
113 while (over > 0) {
114 const widest = sizes.indexOf(Math.max(...sizes));
115 if (sizes[widest] <= 4) break;
116 sizes[widest] -= 1; over -= 1;
117 }
118 const cell = (column, i, content, header) => {
119 const tone = !header && content !== null && typeof content === 'object' ? content.tone : undefined;
120 const text = !header && content !== null && typeof content === 'object' && content.parts
121 ? line(content.parts, { gap: content.gap ?? 1 })
122 : t(cellText(content), { wrap: 'truncate-end', ...(header ? { dimColor: true } : tone ? toneStyle(tone) : {}), ...(!header && content?.bold ? { bold: true } : {}) });
123 return Box({ width: sizes[i], ...(column.align === 'right' ? { justifyContent: 'flex-end' } : {}), children: [text] });
124 };
125 return col([
126 ...(header ? [row(columns.map((column, i) => cell(column, i, column.head, true)), { gap })] : []),
127 ...tableRows.map((r, rowIndex) => row(columns.map((column, i) => cell(column, i, r[i], false)), { gap, key: `row-${rowIndex}` })),
128 ]);
129 };
130 // A bordered number tile: the value large and colored, a short label beneath.
131 const tile = (value, label, name = 'muted', size = 16) => {
132 const t0 = toneOf(name);
133 return Box({ flexDirection: 'column', borderStyle: 'round', ...(t0.color ? { borderColor: t0.color } : { borderDimColor: true }), paddingX: 1, width: size, children: [
134 t(String(value), { bold: true, wrap: 'truncate-end', ...(t0.color ? { color: t0.color } : {}) }),
135 t(label, { dimColor: true, wrap: 'truncate-end' }),
136 ] });
137 };
138 const tiles = list => {
139 const per = width < 76 ? 2 : list.length;
140 const size = Math.max(10, Math.floor((width - (per - 1)) / per));
141 return row(list.map(([value, label, name]) => tile(value, label, name, size)), { gap: 1, wrap: true });
142 };
143 // Split `total` cells between named counts, at least one cell for any non-zero count.
144 const allocate = (parts, length) => {
145 const total = parts.reduce((a, p) => a + p.n, 0);
146 if (!total) return parts.map(p => ({ ...p, cells: 0 }));
147 const cells = parts.map(p => (p.n ? Math.max(1, Math.round((p.n / total) * length)) : 0));
148 while (cells.reduce((a, b) => a + b, 0) > length) cells[cells.indexOf(Math.max(...cells))] -= 1;
149 while (cells.reduce((a, b) => a + b, 0) < length && total) cells[cells.indexOf(Math.max(...cells))] += 1;
150 return parts.map((p, i) => ({ ...p, cells: cells[i] }));
151 };
152 // One colored bar split by result, with its legend. Sized so bar and legend share a single line.
153 const stackBar = (parts, length) => {
154 const legend = parts.filter(p => p.n).map(p => ({ text: `${toneOf(p.tone).mark} ${p.n} ${p.label}`, tone: p.tone }));
155 const legendWidth = legend.reduce((a, p) => a + p.text.length + 2, 0);
156 const size = length ?? Math.max(8, Math.min(36, width - legendWidth - 2));
157 return row([
158 line(allocate(parts, size).filter(p => p.cells).map(p => ({ text: ({ ok: '█', bad: '▓', unclear: '░', muted: '▒' }[p.tone] || '█').repeat(p.cells), tone: p.tone, raw: true })), { gap: 0 }),
159 line(legend, { gap: 2, wrap: 'truncate-end' }),
160 ], { gap: 2 });
161 };
162 // A fixed-width meter for pass, fail and unclear counts: solid cells for what is known, light cells for what is not.
163 // Returns line parts so it can sit in a table cell.
164 const meterParts = (r, length) => allocate([{ n: r.pass, tone: 'ok', glyph: '█' }, { n: r.fail, tone: 'bad', glyph: '▓' }, { n: r.unknown, tone: 'unclear', glyph: '░' }], length)
165 .filter(p => p.cells).map(p => ({ text: p.glyph.repeat(p.cells), tone: p.tone, raw: true }));
166 // One square per check, colored by result. Truncated with a count when it would not fit.
167 const squares = (tones, room) => {
168 const fit = Math.max(4, room);
169 const shown = tones.slice(0, tones.length > fit ? fit - 3 : fit);
170 // Runs of one result share a Text, so a long audit stays a handful of elements.
171 const runs = [];
172 for (const name of shown) { if (runs.length && runs[runs.length - 1].tone === name) runs[runs.length - 1].text += '■'; else runs.push({ text: '■', tone: name }); }
173 return [...runs, ...(tones.length > shown.length ? [{ text: ` +${tones.length - shown.length}`, dim: true }] : [])];
174 };
175 // Four numbers in two lines: the values large and colored, the labels beneath, in aligned columns.
176 // On a narrow pane they become one line of text.
177 const stats = (list, compact = false) => {
178 if (compact || width < 56) return line(list.flatMap(([value, label, name]) => [{ text: String(value), bold: true, tone: name }, { text: label, dim: true }]), { gap: 1, wrap: 'truncate-end' });
179 const size = Math.floor(width / list.length);
180 const cells = (render) => row(list.map(item => Box({ width: size, children: [render(item)] })), { gap: 0 });
181 return col([
182 cells(([value, , name]) => t(String(value), { bold: true, wrap: 'truncate-end', ...toneStyle(name) })),
183 cells(([, label]) => t(label, { dimColor: true, wrap: 'truncate-end' })),
184 ]);
185 };
186 // Back and Next for a list that spans pages. One line; null when everything fits on one page.
187 const pager = (page, pages, { back, next }) => (pages <= 1 ? null : row([
188 button('page-back', 'Back', 'u', back, { plain: true, off: page <= 0 }),
189 button('page-next', 'Next', 'j', next, { plain: true, off: page >= pages - 1 }),
190 t(`page ${page + 1} of ${pages}`, { dimColor: true }),
191 ], { gap: 2 }));
192 const titleRule = title => line([{ text: `── ${title} `, bold: true }, { text: '─'.repeat(Math.max(2, Math.min(width, 100) - title.length - 4)), dim: true }], { wrap: 'truncate-end' });
193 return { ui, width, rows, narrow, wide, meterParts, clamp, countLines, field, stats, pager, tile, tiles, stackBar, squares, titleRule, t, line, hint, item, mark, row, col, section, indent, card, rule, heading, chip, bar, dots, button, table, toneStyle };
194}
195hooks/model.mjs 128 lines1// Pure derivation: one snapshot in, the facts every screen and the band share out.
2// Screens never recompute these, so the Overview and the detail views cannot disagree.
3import { platformName, plural, say } from './lexicon.mjs';
4
5export function counts(snapshot) {
6 const workers = snapshot.workers;
7 const done = workers.filter(w => w.status === 'completed').length;
8 const failed = workers.filter(w => w.status === 'failed' || w.result_status === 'failed').length;
9 const blocked = workers.filter(w => w.result_status === 'blocked').length;
10 const missing = workers.flatMap(w => w.missing_inputs.map(text => ({ workerId: w.id, platform: w.platform, text })));
11 const findingsOld = snapshot.findings.filter(f => f.evidence_status !== 'current').length;
12 const claimsOld = snapshot.claims.filter(c => c.effective_status !== 'current').length;
13 const sourcesOld = snapshot.sources.filter(s => s.status !== 'current').length;
14 const supportTotal = snapshot.findings.length + snapshot.claims.length + snapshot.sources.length;
15 const supportOld = findingsOld + claimsOld + sourcesOld;
16 const invalidInputs = snapshot.inputs.filter(i => i.validation === 'invalid' || i.validation === 'missing').length;
17 const uncheckedInputs = snapshot.inputs.filter(i => i.validation === 'unchecked').length;
18 return {
19 workers: workers.length, done, failed, blocked, missing,
20 findings: snapshot.findings.length, findingsOld, claimsOld, sourcesOld,
21 supportTotal, supportOld, supportCurrent: supportTotal - supportOld,
22 contradictions: workers.reduce((n, w) => n + w.contradictions.length, 0),
23 staleRefs: workers.reduce((n, w) => n + w.stale_sources.length, 0),
24 invalidInputs, uncheckedInputs, inputs: snapshot.inputs.length, receipts: snapshot.receipts.length,
25 };
26}
27
28// The single plain-language answer to "where is this run and what needs me".
29export function verdict(snapshot) {
30 const c = counts(snapshot);
31 const n = c.workers;
32 if (snapshot.workflow_status === 'failed' && snapshot.completeness === 'partial') return { tone: 'bad', headline: 'Partly done', detail: 'A step failed. What was recorded is still shown below.', action: { label: 'See checks', tab: 'Evidence', target: null } };
33 if (snapshot.workflow_status === 'failed') return { tone: 'bad', headline: 'The run failed', detail: 'A step stopped with an error. Read the notes under each platform.', action: null };
34 if (snapshot.workflow_status === 'blocked') return { tone: 'bad', headline: 'The run is blocked', detail: 'It cannot continue until the blocker is cleared.', action: null };
35 if (c.missing.length) {
36 const first = c.missing[0];
37 const more = c.missing.length > 1 ? ` (+${c.missing.length - 1} more)` : '';
38 return { tone: 'warn', headline: 'Waiting for you', detail: `${platformName(first.platform)} needs: ${first.text}${more}`, action: { label: 'Open Inputs', tab: 'Inputs', target: { workerId: first.workerId, missingInput: first.text } } };
39 }
40 if (c.failed || c.blocked) return { tone: 'bad', headline: 'Partly done', detail: `${c.done} of ${plural(n, 'step')} finished, ${plural(c.failed + c.blocked, 'step')} did not.`, action: { label: 'See checks', tab: 'Evidence', target: null } };
41 if (snapshot.workflow_status === 'pending' || snapshot.workflow_status === 'running') return { tone: 'info', headline: 'Running', detail: `${c.done} of ${plural(n, 'step')} finished. Progress comes only from recorded results.`, action: null };
42 if (snapshot.workflow_status === 'completed' && c.supportOld > 0) return { tone: 'warn', headline: 'Finished, with out-of-date support', detail: `${c.supportOld} of ${c.supportTotal} checks, claims and sources need a fresh look before you rely on them.`, action: { label: 'See checks', tab: 'Evidence', target: null } };
43 if (snapshot.workflow_status === 'completed') return { tone: 'ok', headline: 'Finished', detail: `All ${plural(n, 'step')} reported and the support is up to date.`, action: null };
44 return { tone: 'muted', headline: 'Status unknown', detail: 'The engine did not report a clear state.', action: null };
45}
46
47// One row per platform, built from whatever the snapshot actually records for it.
48export function platforms(snapshot) {
49 const seen = [];
50 const add = p => { if (p && !seen.includes(p)) seen.push(p); };
51 snapshot.workers.forEach(w => add(w.platform));
52 snapshot.findings.forEach(f => add(f.platform));
53 snapshot.scoring.forEach(s => add(s.platform));
54 const hasShared = snapshot.workers.some(w => !w.platform);
55 const rows = seen.map(platform => ({ platform, workers: snapshot.workers.filter(w => w.platform === platform), findings: snapshot.findings.filter(f => f.platform === platform), score: snapshot.scoring.find(s => s.platform === platform) || null }));
56 if (hasShared) rows.push({ platform: null, workers: snapshot.workers.filter(w => !w.platform), findings: [], score: null });
57 return rows.map(row => {
58 const ws = row.workers;
59 let tone = 'muted', word = 'Nothing recorded yet';
60 if (ws.length) {
61 if (ws.some(w => w.status === 'failed' || w.result_status === 'failed' || w.result_status === 'blocked')) { tone = 'bad'; word = ws.some(w => w.result_status === 'blocked') ? 'Blocked' : 'Failed'; }
62 else if (ws.some(w => w.missing_inputs.length || w.result_status === 'needs_input')) { tone = 'warn'; word = 'Waiting for your input'; }
63 else if (ws.some(w => w.status === 'running' || w.status === 'pending')) { tone = 'info'; word = ws.some(w => w.status === 'running') ? 'Working' : 'Queued'; }
64 else { tone = 'ok'; word = 'Done'; }
65 }
66 return { ...row, tone, word, done: ws.filter(w => w.status === 'completed').length };
67 });
68}
69
70// Scores are shown only when the core says a profile is enabled and gave a number.
71// A disabled profile never shows a number, whatever the wire carried.
72export function scoreLine(entry) {
73 if (!entry || entry.profile_status === 'disabled' || entry.health_score === null) return { shown: false, text: 'Not scored' };
74 return { shown: true, text: `${entry.health_score}/100`, coverage: entry.evidence_coverage === null ? null : `${entry.evidence_coverage}% evidence` };
75}
76export const allScoresOff = snapshot => snapshot.scoring.length > 0 && snapshot.scoring.every(s => !scoreLine(s).shown);
77export const scoringStatus = entry => say('scoring', entry.status);
78
79// Result counts for a list of findings, in the order the person reads them.
80export function resultCounts(findings) {
81 const n = status => findings.filter(f => f.finding.status === status).length;
82 return { pass: n('pass'), fail: n('fail'), unknown: n('unknown'), na: n('not_applicable') };
83}
84export const resultParts = r => [{ n: r.pass, label: 'passing', tone: 'ok' }, { n: r.fail, label: 'need work', tone: 'bad' }, { n: r.unknown, label: 'no data', tone: 'unclear' }, { n: r.na, label: 'n/a', tone: 'muted' }];
85const RANK = { fail: 0, unknown: 1, pass: 2, not_applicable: 3 };
86const SEVERITY = { critical: 0, high: 1, medium: 2, informational: 3 };
87// Failed checks first, then unknown ones, worst severity first, with out-of-date support as a tie-break.
88export function byAttention(findings) {
89 return findings.map((item, index) => ({ item, index })).sort((a, b) => (RANK[a.item.finding.status] - RANK[b.item.finding.status])
90 || ((SEVERITY[a.item.severity] ?? 9) - (SEVERITY[b.item.severity] ?? 9))
91 || ((a.item.evidence_status === 'current') - (b.item.evidence_status === 'current')) || a.index - b.index);
92}
93export const statusTone = status => ({ pass: 'ok', fail: 'bad', unknown: 'unclear' }[status] || 'muted');
94
95// Display grouping only: the registry leaves most controls unclassified, so checks are grouped by topic
96// from their id and title. First match wins; anything else lands in "Account setup".
97const AREAS = [
98 ['Quality score', /quality score|\bQS\b|expected ctr|ad relevance|landing page experience|ad copy relevance/i],
99 ['Landing pages', /landing page/i],
100 ['Tracking and measurement', /conversion|tracking|attribution|ga4|consent|measurement|floodlight|server-side|duplicate counting|tag firing|google tag|incrementality|brand-lift|reporting|metrics|meridian|attributed/i],
101 ['Keywords and search terms', /keyword|search.term|negative|match|close variant/i],
102 ['Budget and bidding', /budget|\bbid|cpa|roas|pacing|learning phase|portfolio|journey/i],
103 ['Audiences and targeting', /audience|geograph|placement|customer match|schedule|targeting|suitability|brand safety/i],
104 ['Ads and creative', /\bads?\b|rsa|asset|extension|sitelink|callout|snippet|creative|hook|format|concept|image|video|freshness|ctr/i],
105];
106export const areaOf = item => { const text = `${item.control_title} ${item.finding.control_id}`; return (AREAS.find(([, pattern]) => pattern.test(text)) || ['Account setup'])[0]; };
107
108// How much of the audit could be judged. "Not applicable" checks are left out of the base.
109export function coverage(findings) {
110 const r = resultCounts(findings);
111 const applicable = findings.length - r.na;
112 const judged = r.pass + r.fail;
113 return { ...r, total: findings.length, applicable, judged, percent: applicable ? Math.round((judged / applicable) * 100) : 0 };
114}
115
116// One row per topic, worst first: most checks needing work, then most still unclear.
117export function areaRows(findings) {
118 const by = new Map();
119 for (const item of findings) {
120 const area = areaOf(item);
121 if (!by.has(area)) by.set(area, []);
122 by.get(area).push(item);
123 }
124 return [...by.entries()].map(([area, items]) => ({ area, items, ...resultCounts(items) }))
125 .filter(row => row.pass + row.fail + row.unknown > 0)
126 .sort((a, b) => (b.fail - a.fail) || (b.unknown - a.unknown) || a.area.localeCompare(b.area));
127}
128hooks/screens/chooser.mjs 48 lines1// Chooser: no run selected yet. Pick a registered run, or try the practice demo. Drawn to fit ctx.budget lines.
2import { say } from '../lexicon.mjs';
3import { pack, pageList } from '../pack.mjs';
4import { toneOf } from '../theme.mjs';
5
6export function chooser(kit, ctx) {
7 const { state, controls, budget } = ctx;
8 const doctor = state.doctor;
9 const blocks = [];
10 blocks.push({
11 pri: 0, need: 2, want: 3,
12 build: n => [
13 kit.col([
14 kit.t('Choose a run', { bold: true }),
15 ...(n >= 3 || !doctor ? kit.clamp('A run is a saved Claude Ads audit on this computer. Nothing here changes an ad account.', 1, { dimColor: true }) : []),
16 ], { key: 'chooser' }),
17 ...(doctor ? [kit.line([{ text: `${toneOf(doctor.status === 'ok' ? 'ok' : 'bad').mark} `, tone: doctor.status === 'ok' ? 'ok' : 'bad' }, { text: `Engine ${doctor.core_version} · scoring policy ${doctor.status === 'ok' ? 'ready' : 'blocked'}`, dim: true }], { gap: 1, wrap: 'truncate-end' })] : []),
18 ].slice(0, 3),
19 });
20 blocks.push({ pri: 1, need: 2, want: 2, build: () => [kit.button('demo', 'Start the practice demo', 'd', controls.demo, { primary: true, off: state.busy }), kit.hint('A short guided tour on made-up data. Nothing real is read.', { wrap: 'truncate-end' })] });
21 if (doctor) {
22 if (!doctor.runs.length) {
23 blocks.push({ pri: 2, need: 2, want: 2, build: () => [kit.t('No runs found yet', { bold: true }), kit.hint('Set runsRoot to a folder of Claude Ads runs, or try the practice demo.', { wrap: 'truncate-end' })] });
24 } else {
25 const runs = doctor.runs.slice(0, 100);
26 blocks.push({
27 pri: 2, need: 2, want: 1 + runs.length + 1,
28 build: n => {
29 const { per, pages, pagerLine } = pageList(runs.length, n - 1);
30 const page = Math.min(state.page, pages - 1);
31 const pager = kit.pager(page, pages, { back: () => controls.page(-1), next: () => controls.page(1) });
32 return [
33 kit.heading('Registered runs', `${doctor.runs.length}`),
34 ...runs.slice(page * per, (page + 1) * per).map((run, i) => {
35 const s = say('workflow', run.status);
36 const n9 = page * per + i;
37 return kit.row([kit.button(`run-${run.id}`, run.id, n9 < 9 ? String(n9 + 1) : undefined, () => controls.select(run.id), { off: state.busy, plain: true }), kit.t(s.word, kit.toneStyle(s.tone))], { gap: 2 });
38 }),
39 ...(pager && pagerLine ? [pager] : []),
40 ];
41 },
42 });
43 }
44 }
45 if (kit.ui.Input && budget >= 14) blocks.push({ pri: 4, need: 3, want: 3, build: () => [kit.ui.Input({ key: 'open-run', label: 'Run ID', value: '', placeholder: 'Registered run ID', submitLabel: 'open', onSubmit: value => controls.select(value) })] });
46 return pack(kit, budget, blocks, { gap: budget >= 22 });
47}
48hooks/screens/compare.mjs 69 lines1// Compare: this run against a baseline, as a before and after table. Drawn to fit ctx.budget lines.
2import { platformName, plural, say } from '../lexicon.mjs';
3import { pack, pageList } from '../pack.mjs';
4import { toneOf } from '../theme.mjs';
5import { oneLine } from '../wire.mjs';
6
7export function compare(kit, ctx) {
8 const { snapshot, state, budget } = ctx;
9 const comparison = snapshot.comparison;
10 const room = kit.width - 4;
11 const blocks = [];
12 // The baseline box is the only way to pick a run to compare with, so it always keeps its room when drawn.
13 const canInput = kit.ui.Input && !state.demo;
14 const inputBlock = canInput ? { pri: 0, need: 3, want: 3, build: () => [kit.ui.Input({ key: 'baseline', label: 'Baseline run', value: state.baseline, placeholder: 'Registered run ID', submitLabel: 'compare', onSubmit: ctx.controls.baseline })] } : null;
15 if (!comparison) {
16 blocks.push({
17 pri: 1, need: 2, want: 3,
18 build: () => [kit.col([kit.t('Pick a run to compare with', { bold: true }), ...kit.clamp('Compare shows what changed between two runs of the same account and platform.', 2)], { key: 'compare-empty' })],
19 });
20 } else {
21 const tone = comparison.comparable ? 'ok' : 'warn';
22 // The engine lists one difference per check whose evidence changed. Those are counted, not listed;
23 // the table below already names every check that changed.
24 const evidenceDiffs = comparison.differences.filter(d => d.startsWith('Evidence differs'));
25 const general = comparison.differences.filter(d => !d.startsWith('Evidence differs'));
26 blocks.push({
27 pri: 1, need: 2, want: 2 + Math.min(4, general.length) + 1,
28 build: n => {
29 const list = [
30 ...general.map(d => kit.line([{ text: '· ', dim: true }, oneLine(d, room - 2)], { gap: 0, wrap: 'truncate-end' })),
31 ...(evidenceDiffs.length ? [kit.hint(`· ${plural(evidenceDiffs.length, 'check')} with different evidence`)] : []),
32 ];
33 return [
34 kit.line([{ text: `${toneOf(tone).mark} `, tone }, { text: `Compared with ${comparison.baseline_run_id}`, bold: true }, { text: comparison.comparable ? 'can be compared' : 'not directly comparable, read the differences first', dim: true }], { gap: 2, wrap: 'truncate-end' }),
35 ...list.slice(0, n - 1),
36 ];
37 },
38 });
39 if (comparison.findings.length) {
40 const word = status => (status === null ? '-' : say('finding', status).word);
41 const rowsFor = list => list.map(item => {
42 const change = say('change', item.change);
43 return [
44 { text: `${platformName(item.platform)} ${item.control_id}`, bold: true },
45 { text: word(item.baseline_status), tone: item.baseline_status ? say('finding', item.baseline_status).tone : 'muted' },
46 { text: `→ ${word(item.current_status)}`, tone: item.current_status ? say('finding', item.current_status).tone : 'muted' },
47 ...(kit.narrow ? [] : [{ text: change.word, tone: change.tone }]),
48 ];
49 });
50 blocks.push({
51 pri: 2, need: 3, want: 2 + comparison.findings.length,
52 build: n => {
53 const { per, pages, pagerLine } = pageList(comparison.findings.length, n - 2);
54 const page = Math.min(state.page, pages - 1);
55 const pager = kit.pager(page, pages, { back: () => ctx.controls.page(-1), next: () => ctx.controls.page(1) });
56 return [
57 kit.heading('What changed', plural(comparison.findings.length, 'check')),
58 kit.table([{ head: 'Check', grow: true }, { head: 'Before', max: 16 }, { head: 'After', max: 16 }, ...(kit.narrow ? [] : [{ head: 'Change', max: 14 }])], rowsFor(comparison.findings.slice(page * per, (page + 1) * per))),
59 ...(pager && pagerLine ? [pager] : []),
60 ];
61 },
62 });
63 }
64 }
65 if (inputBlock && budget >= 12) blocks.push(inputBlock);
66 blocks.push({ pri: 5, need: 0, want: 1, build: () => [kit.hint('A check that is absent now is not counted as fixed. Differences are observations, not proof of improvement.', { wrap: 'truncate-end' })] });
67 return pack(kit, budget, blocks, { gap: budget >= 22 });
68}
69hooks/screens/findings.mjs 128 lines1// Checks: every check the run made, worst first, filterable and paged so the screen never scrolls.
2// Opening one shows it on its own, sized to the room there is.
3import { platformName, plural, say, shortHash, when } from '../lexicon.mjs';
4import { byAttention, counts, resultCounts, resultParts, statusTone } from '../model.mjs';
5import { pageList } from '../pack.mjs';
6import { toneOf } from '../theme.mjs';
7import { oneLine } from '../wire.mjs';
8
9const FILTERS = { all: 'all', fail: 'fails', unknown: 'unknowns', pass: 'passes' };
10
11export function findings(kit, ctx) {
12 const { snapshot, state, context, budget } = ctx;
13 const selectedFinding = item => context?.kind === 'finding' && item.platform === context.platform && item.finding.control_id === context.controlId;
14 if (!snapshot.findings.length) {
15 return [kit.t('No checks recorded yet.', { bold: true }), kit.hint('They appear here when a step records a result.')];
16 }
17 const opened = snapshot.findings.find(selectedFinding);
18 if (opened) return detail(kit, ctx, opened, snapshot.findings.indexOf(opened));
19
20 const c = counts(snapshot);
21 if (state.details) return ledger(kit, ctx);
22 const out = [kit.stackBar(resultParts(resultCounts(snapshot.findings)))];
23
24 // The list: worst first, optionally narrowed to one result.
25 const filter = FILTERS[state.filter] ? state.filter : 'all';
26 const ordered = byAttention(snapshot.findings).filter(({ item }) => filter === 'all' || item.finding.status === filter);
27 const noSupport = snapshot.findings.filter(f => f.evidence_status === 'missing').length;
28 const several = new Set(snapshot.findings.map(f => f.platform)).size > 1;
29 // The title leads; the id trails for anyone who knows it. The platform appears only when the run has several.
30 const label = (item, old) => { const id = item.finding.control_id; const lead = `${several ? `${platformName(item.platform)}: ` : ''}${item.control_title}${old}`; return `${oneLine(lead, Math.max(12, kit.width - 8 - id.length))} ${id}`; };
31 const quiet = noSupport * 2 > snapshot.findings.length;
32 const showSupport = budget >= 14;
33 // Filter and pager share one line when they fit; on a narrow pane the pager takes its own line.
34 const split = kit.width < 58;
35 const controlLines = split ? 2 : 1;
36 const listRows = Math.max(1, budget - 1 - controlLines - (showSupport ? 1 : 0));
37 const pages = Math.max(1, Math.ceil(ordered.length / listRows));
38 const page = Math.min(state.page, pages - 1);
39 const pager = kit.pager(page, pages, { back: () => ctx.controls.page(-1), next: () => ctx.controls.page(1) });
40 const filterButton = kit.button('filter', filter === 'all' ? `Showing all ${ordered.length}` : `Showing ${FILTERS[filter]} ${ordered.length}`, 'f', () => ctx.controls.filter(), { plain: true });
41 if (split) out.push(filterButton, ...(pager ? [pager] : [kit.t(' ')]));
42 else out.push(kit.row([filterButton, ...(pager ? [pager] : [])], { gap: 3 }));
43 if (!ordered.length) out.push(kit.hint('No checks match this filter. Press f to change it.', { wrap: 'truncate-end' }));
44 for (const { item, index } of ordered.slice(page * listRows, (page + 1) * listRows)) {
45 const old = item.evidence_status === 'current' || (quiet && item.evidence_status === 'missing') ? '' : ` · ${say('support', item.evidence_status).word}`;
46 out.push(kit.row([kit.mark(statusTone(item.finding.status)), kit.button(`finding-open-${index}`, label(item, old), undefined, () => ctx.go('Evidence', { platform: item.platform, controlId: item.finding.control_id }), { plain: true })], { gap: 1 }));
47 }
48 if (showSupport) {
49 // When most checks have no registered support, say so once instead of on every line.
50 const note = quiet
51 ? `${noSupport} of ${snapshot.findings.length} checks have no registered source support yet. That is normal for most of the catalog.`
52 : c.supportTotal ? `${c.supportCurrent} of ${c.supportTotal} checks, claims and sources are up to date. Dates come from saved ledgers.` : 'Nothing recorded yet.';
53 out.push(kit.hint(note, { wrap: 'truncate-end' }));
54 }
55 return out;
56}
57
58// The ledger behind the checks: every claim and source, and the run's fingerprint. Paged to fit.
59function ledger(kit, ctx) {
60 const { snapshot, state, budget } = ctx;
61 const lines = [
62 ...snapshot.claims.map(x => kit.line([{ text: `${x.id} `, bold: true }, { text: say('support', x.effective_status).word, tone: say('support', x.effective_status).tone }, { text: `${x.classification}, ${x.verdict}; controls ${x.control_ids.join(', ') || 'none'}`, dim: true }], { gap: 2, wrap: 'truncate-end' })),
63 ...snapshot.sources.map(x => kit.line([{ text: `${x.id} `, bold: true }, { text: say('support', x.status).word, tone: say('support', x.status).tone }, { text: `${x.confidence} confidence; ${x.locator || 'no public link'}`, dim: true }], { gap: 2, wrap: 'truncate-end' })),
64 ];
65 const footer = kit.clamp(`Evaluated ${when(snapshot.evaluated_at)}. Run fingerprint ${shortHash(snapshot.fingerprint)} (a short code that changes whenever the run data changes).`, 3, { dimColor: true });
66 const c = counts(snapshot);
67 const head = kit.heading('Ledger', `${plural(snapshot.claims.length, 'claim')}, ${plural(snapshot.sources.length, 'source')}`);
68 const summary = kit.t(c.supportTotal ? `${c.supportCurrent} of ${c.supportTotal} checks, claims and sources are up to date.` : 'Nothing recorded yet.', { wrap: 'truncate-end', ...(c.supportOld ? kit.toneStyle('warn') : {}) });
69 const { per, pages, pagerLine } = pageList(lines.length, budget - 2 - footer.length);
70 const page = Math.min(state.page, pages - 1);
71 const pager = kit.pager(page, pages, { back: () => ctx.controls.page(-1), next: () => ctx.controls.page(1) });
72 return [head, summary, ...lines.slice(page * per, (page + 1) * per), ...(pager && pagerLine ? [pager] : []), ...footer];
73}
74
75// One check on its own. Each paragraph gets as many lines as the room allows.
76function detail(kit, ctx, item, index) {
77 const { snapshot, state, budget } = ctx;
78 const f = item.finding;
79 const status = say('finding', f.status);
80 const support = say('support', item.evidence_status);
81 const severity = say('severity', item.severity);
82 const claimById = new Map(snapshot.claims.map(x => [x.id, x]));
83 const sourceById = new Map(snapshot.sources.map(x => [x.id, x]));
84 const claims = item.claim_ids.map(id => claimById.get(id)).filter(Boolean);
85 const sources = item.source_ids.map(id => sourceById.get(id)).filter(Boolean);
86 const oldCount = [...claims, ...sources].filter(x => (x.effective_status ?? x.status) !== 'current').length;
87 const evidenceText = e => (e && typeof e === 'object' && typeof e.value === 'string' && typeof e.kind === 'string' ? `${e.kind.replace(/_/g, ' ')}: ${e.value}` : JSON.stringify(e));
88
89 // The card reads in three sentences: what we saw, what it means, what to do. Everything else is behind d.
90 const title = kit.line([{ text: `▸ ${toneOf(status.tone).mark} `, tone: status.tone }, { text: item.control_title || f.control_id, bold: true }, { text: status.word, tone: status.tone }], { gap: 2, wrap: 'truncate-end' });
91 const who = kit.hint(`${platformName(item.platform)} · ${f.control_id}`, { wrap: 'truncate-end' });
92 if (!state.details) {
93 const each = Math.max(1, Math.min(3, Math.floor((budget - 5) / 3)));
94 return [
95 title, who,
96 ...kit.field('What we saw', f.observation, each, 14),
97 ...kit.field('What it means', f.diagnosis, each, 14),
98 ...kit.field('What to do', f.recommendation, each, 14),
99 ...(budget >= 9 ? [kit.hint('Press d for severity, evidence and sources.', { wrap: 'truncate-end' })] : []),
100 kit.button('finding-back', 'Back to all checks', undefined, () => ctx.controls.clearContext(), { plain: true }),
101 ];
102 }
103 const fixed = 6; // title line, name line, chips, evidence line, support line, back button
104 const spare = Math.max(3, budget - fixed);
105 const each = Math.max(1, Math.min(4, Math.floor(spare / 3)));
106 const out = [
107 title, who,
108 kit.line([{ text: 'Severity ', dim: true }, { text: severity.word, tone: severity.tone }, { text: 'Confidence ', dim: true }, say('confidence', f.confidence).word, { text: 'Support ', dim: true }, { text: support.word, tone: support.tone }], { gap: 1, wrap: 'truncate-end' }),
109 ...kit.field('What we saw', f.observation, each, 14),
110 ...kit.field('What it means', f.diagnosis, each, 14),
111 ...kit.field('What to do', f.recommendation, each, 14),
112 kit.line([{ text: 'Evidence ', dim: true, raw: true }, f.evidence.length ? plural(f.evidence.length, 'item') : { text: 'none recorded', tone: 'warn' }, { text: ` Support ${plural(item.claim_ids.length, 'claim')}, ${plural(item.source_ids.length, 'source')}${oldCount ? ` (${oldCount} out of date)` : ''}`, dim: true }], { gap: 0, wrap: 'truncate-end' }),
113 ];
114 // The evidence items and the claims and sources behind them fill whatever room is left.
115 const left = budget - out.length - 1;
116 const extra = [
117 ...f.evidence.map((e, i) => kit.hint(`evidence ${i + 1}: ${evidenceText(e)}`, { wrap: 'truncate-end' })),
118 ...claims.map(claim => kit.hint(`claim ${claim.id} ${say('support', claim.effective_status).word} verified ${claim.last_verified ?? 'unknown'}, refresh due ${claim.refresh_due ?? 'unknown'}`, { wrap: 'truncate-end' })),
119 ...sources.map(source => kit.hint(`source ${source.id} ${say('support', source.status).word} ${source.title}, retrieved ${source.retrieved_at ?? 'unknown'}`, { wrap: 'truncate-end' })),
120 ];
121 if (left > 0) {
122 out.push(...extra.slice(0, left));
123 if (extra.length > left) out[out.length - 1] = kit.hint(`${extra.length - left + 1} more lines. Make the pane taller to see them.`, { wrap: 'truncate-end' });
124 }
125 out.push(kit.button('finding-back', 'Back to all checks', undefined, () => ctx.controls.clearContext(), { plain: true }));
126 return out;
127}
128