SLOPSHOPPER

ads-cockpit

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

panebandcommandprocesstimer
★ 10,092v0.1.0MITupdated 2026-10-17AgriciDaniel/claude-ads/mods/claude-ads-cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ads-cockpit
│ ┃ Claude Ads Cockpit ✕ › fix the failing auth test and add an audit log call │ ┃ CLAUDE ADS ctrl+x tab to use the keys │ ┃ ✗ Not set up yet. Set the ads-cockpit option ⏺ Read(src/auth.ts) │ ┃ coreRoot and pythonExecutable to full path ⎿ Read 6 lines │ ┃ (run: claude plugin configure ads-cockpit) ⏺ Update(src/auth.ts) │ ┃ then open the cockpit again. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Choose a run ⎿ 3 pass, 1 fail │ ┃ A run is a saved Claude Ads audit on this c… │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ Start the practice demo (d) ] │ ┃ A short guided tour on made-up data. Nothin… ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Run ID: Registered run ID ⏎ open › /ads-cockpit │ ┃ Refresh h: Hide │ ⟨Claude Code's own drawing⟩ Claude Ads Cockpit: not set up yet. Set coreRoot and pythonExecutable. CLAUDE ADS no run chosen Choose a run or the practice demo. o: Open ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Claude Ads Cockpit: not set up yet. Set coreRoot and pythonExecutable. CLAUDE ADS no run chosen Choose a run or the practice demo. o: Open
Pane · Claude Ads Cockpit
CLAUDE ADS ctrl+x tab to use the keys ✗ Not set up yet. Set the ads-cockpit options coreRoot and pythonExecutable to full paths (run: claude plugin configu ads-cockpit), then open the cockpit again. Choose a run A run is a saved Claude Ads audit on this computer.… [ Start the practice demo (d) ] A short guided tour on made-up data. Nothing real is read. Run ID: Registered run ID ⏎ open Refresh h: Hide
README

Claude Ads Cockpit 0.1.0

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.

Explicit setup

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:

FieldValue
coreRootExplicit trusted absolute source checkout or verified standalone skill root containing the matching core and public policy resources.
pythonExecutableExplicit trusted absolute Python executable. Never a command found through PATH.
runsRootOptional 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.

Commands and local actions

CommandResult
/ads-cockpitOpen the local run chooser.
/ads-cockpit open <id>Select a registered run by its safe run ID.
/ads-cockpit demoExplicitly create and enter the isolated synthetic demo.
/ads-cockpit hideHide the pane, cancel refresh and clear displayed client state.
/ads-cockpit doctorCheck 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.

Optional package and rollback

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.

What is and is not verified

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.

Source 19 files
hooks/register.mjs 108 lines
1import { 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}
108
hooks/launcher.mjs 125 lines
1import { 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}
125
hooks/lexicon.mjs 55 lines
1// 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}`;
55
hooks/controller.mjs 212 lines
1import { 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}
212
hooks/views.mjs 155 lines
1// 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}
155
hooks/theme.mjs 14 lines
1// 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' });
14
hooks/wire.mjs 93 lines
1import { 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}
93
hooks/kit.mjs 195 lines
1// 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}
195
hooks/model.mjs 128 lines
1// 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}
128
hooks/screens/chooser.mjs 48 lines
1// 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}
48
hooks/screens/compare.mjs 69 lines
1// 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}
69
hooks/screens/findings.mjs 128 lines
1// 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