@broberg/lens-daemon 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ // F126.2 — Vision composition critic (the model half).
2
+ //
3
+ // The complement to the deterministic geometry critic (critic-dom.ts). Geometry
4
+ // catches what you can MEASURE — overlap, occlusion, clipped text, off-viewport,
5
+ // contrast. Vision catches what you can only JUDGE — "this looks broken /
6
+ // unbalanced / off-brand / illegible on a busy background / wrong hierarchy."
7
+ //
8
+ // It costs a metered model call per screenshot (@broberg/ai-sdk, Haiku vision —
9
+ // cheapest-good-enough), so it is PER-PROJECT OPT-IN: the caller only invokes it
10
+ // when projects.settings_json.composition_critic.vision === true (F126.3). This
11
+ // module makes a call ONLY when asked; it never runs itself.
12
+
13
+ import type { CriticSeverity } from './critic-dom.ts';
14
+
15
+ export interface VisionFinding {
16
+ kind: 'vision';
17
+ severity: CriticSeverity;
18
+ /** Roughly where on the screen ("top-right", "the header", "primary button"). */
19
+ region: string;
20
+ message: string;
21
+ }
22
+
23
+ export interface VisionCriticResult {
24
+ findings: VisionFinding[];
25
+ /** Why the pass produced nothing actionable, if so: 'no-ai' (no key) | 'error'. */
26
+ skipped?: 'no-ai' | 'error';
27
+ /** Concrete model used, for the run report. */
28
+ model?: string;
29
+ }
30
+
31
+ // The free vision-model selector. A catalog key; resolved to a concrete route.
32
+ export type VisionModelKey = string;
33
+
34
+ export interface VisionModelSpec {
35
+ key: VisionModelKey;
36
+ label: string;
37
+ provider: string;
38
+ model: string;
39
+ transport: 'http';
40
+ inputPer1M: number;
41
+ outputPer1M: number;
42
+ /** EU-hosted → GDPR-safe for screenshots that may carry PII. */
43
+ eu: boolean;
44
+ /** Provider key(s); ANY present → the model is available. */
45
+ requiredEnvAny: string[];
46
+ }
47
+
48
+ // Default = mistral-large: EU/GDPR-safe (Lens screenshots can carry PII —
49
+ // usernames/emails on authed screens) + cheap + functional. Geometry already
50
+ // owns spatial overlaps (free, exact), so vision is the soft aesthetic bonus.
51
+ // mistral-small = cheapest EU but weaker. NO paid-Anthropic route (house rule —
52
+ // the haiku/sonnet options were removed 2026-06-30 with the API-key purge).
53
+ // (Validated 2026-06-11 vs the ✕-over-badge + a fuzzy-aesthetic case; Christian
54
+ // chose mistral-large. magistral-medium errored/empties → excluded; ai.chat +
55
+ // system-role beat ai.vision prompt-only for instruction-following → kept.)
56
+ // F008.28 — dated ids, never `-latest` (D-385d97): the same ids @broberg/ai-sdk
57
+ // pins its smart/cheap tiers to, with its prices. vision-models-pinned.test.ts holds it.
58
+ export const VISION_MODELS: VisionModelSpec[] = [
59
+ { key: 'mistral-large', label: 'Mistral Large (EU · GDPR-safe)', provider: 'mistral', model: 'mistral-large-2512', transport: 'http', inputPer1M: 0.5, outputPer1M: 1.5, eu: true, requiredEnvAny: ['MISTRAL_API_KEY'] },
60
+ { key: 'mistral-small', label: 'Mistral Small (EU · cheapest)', provider: 'mistral', model: 'mistral-small-2603', transport: 'http', inputPer1M: 0.15, outputPer1M: 0.6, eu: true, requiredEnvAny: ['MISTRAL_API_KEY'] },
61
+ ];
62
+
63
+ export const DEFAULT_VISION_MODEL: VisionModelKey = 'mistral-large';
64
+
65
+ function specFor(key: VisionModelKey | undefined): VisionModelSpec {
66
+ return VISION_MODELS.find((m) => m.key === key) ?? VISION_MODELS.find((m) => m.key === DEFAULT_VISION_MODEL)!;
67
+ }
68
+ function envPresent(spec: VisionModelSpec): boolean {
69
+ return spec.requiredEnvAny.some((e) => !!process.env[e]);
70
+ }
71
+ /** The chosen model if its key is armed, else the cheapest available (so a pick
72
+ * whose provider key isn't set still produces findings rather than skipping). */
73
+ function resolveSpec(key: VisionModelKey | undefined): VisionModelSpec | null {
74
+ const chosen = specFor(key);
75
+ if (envPresent(chosen)) return chosen;
76
+ return VISION_MODELS.filter(envPresent).sort((a, b) => a.inputPer1M - b.inputPer1M)[0] ?? null;
77
+ }
78
+
79
+ /** Catalog + per-model availability — single source for the Settings selector. */
80
+ export function listVisionModels() {
81
+ return {
82
+ default: DEFAULT_VISION_MODEL,
83
+ models: VISION_MODELS.map((m) => ({
84
+ key: m.key,
85
+ label: m.label,
86
+ eu: m.eu,
87
+ input_per_m: m.inputPer1M,
88
+ output_per_m: m.outputPer1M,
89
+ available: envPresent(m),
90
+ })),
91
+ };
92
+ }
93
+
94
+ const SYSTEM = [
95
+ 'You are a UI composition critic. You are shown ONE screenshot of a single app screen.',
96
+ 'Report ONLY genuine VISUAL composition problems a careful designer would flag:',
97
+ 'overlapping or colliding elements, a control sitting on top of text, text that is',
98
+ 'clipped / truncated / unreadable, content cut off or off-screen, low contrast,',
99
+ 'broken visual hierarchy, or anything that plainly "looks broken".',
100
+ 'Do NOT comment on content, wording, taste, or features. If the screen looks',
101
+ 'coherent, return an empty list. Be terse and concrete.',
102
+ ].join(' ');
103
+
104
+ const PROMPT = [
105
+ 'Return ONLY a JSON object, no prose, no markdown fence:',
106
+ '{"findings":[{"severity":"high|medium|low","region":"<short where>","issue":"<short what>"}]}',
107
+ 'high = a real defect that blocks shipping (text hidden, control unusable, content cut off).',
108
+ 'medium = noticeable but not blocking. low = minor polish.',
109
+ 'Empty findings array if the composition holds together.',
110
+ ].join(' ');
111
+
112
+ // Lazy, daemon-local AI client. Dynamic import so an absent/broken SDK can never
113
+ // crash the daemon — the caller just gets `skipped:'no-ai'`. Mirrors the
114
+ // mcp-tools getCardmemAi() builder; the daemon stands up its own (no cross-dep).
115
+ let aiPromise: Promise<{ chat?: (req: unknown) => Promise<{ text: string; usage: { model: string } }> } | null> | undefined;
116
+ function getDaemonAi() {
117
+ if (!aiPromise) {
118
+ aiPromise = (async () => {
119
+ try {
120
+ const sdk = await import('@broberg/ai-sdk');
121
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
122
+ const providers: Record<string, any> = {};
123
+ // Mistral (EU) is the DEFAULT vision provider (screenshots can carry PII).
124
+ if (process.env.MISTRAL_API_KEY) providers.mistral = sdk.mistralAdapter({ apiKey: process.env.MISTRAL_API_KEY });
125
+ // OpenRouter = transport for the Gemini last-resort fallback only (never default).
126
+ if (process.env.OPENROUTER_API_KEY) providers.openrouter = sdk.openrouterAdapter({ apiKey: process.env.OPENROUTER_API_KEY });
127
+ if (Object.keys(providers).length === 0) return null;
128
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
129
+ return sdk.createAI({ providers: providers as any }) as any;
130
+ } catch {
131
+ return null;
132
+ }
133
+ })();
134
+ }
135
+ return aiPromise;
136
+ }
137
+
138
+ /** Run the vision critic over a PNG screenshot. Makes exactly one model call.
139
+ * Only call this when the project has opted into vision (F126.3). */
140
+ export async function runVisionCritic(
141
+ screenshot: Buffer,
142
+ opts: { purpose?: string; model?: VisionModelKey } = {},
143
+ ): Promise<VisionCriticResult> {
144
+ const ai = await getDaemonAi();
145
+ const spec = resolveSpec(opts.model);
146
+ if (!ai || !ai.chat || !spec) return { findings: [], skipped: 'no-ai' };
147
+ // Mistral (EU) is the default; Gemini-via-OpenRouter is the last-resort fallback
148
+ // only (never default), used if the EU primary errors.
149
+ const fallback =
150
+ process.env.OPENROUTER_API_KEY && spec.provider !== 'openrouter'
151
+ ? [{ provider: 'openrouter', model: 'google/gemini-2.5-flash', transport: 'http' }]
152
+ : [];
153
+ try {
154
+ // ai.chat with a system role beats ai.vision (prompt-only) for this
155
+ // instruction-heavy critic task (validated 2026-06-11) — keep ai.chat.
156
+ const res = await ai.chat({
157
+ system: SYSTEM,
158
+ messages: [
159
+ {
160
+ role: 'user',
161
+ content: [
162
+ { type: 'text', text: PROMPT },
163
+ { type: 'image', image: screenshot, mimeType: 'image/png' },
164
+ ],
165
+ },
166
+ ],
167
+ override: { provider: spec.provider, model: spec.model, transport: spec.transport },
168
+ fallback,
169
+ maxTokens: 700,
170
+ temperature: 0,
171
+ purpose: opts.purpose ?? 'composition-critic',
172
+ });
173
+ const text = typeof res.text === 'string' ? res.text : '';
174
+ return { findings: parseFindings(text), model: res.usage?.model ?? spec.model };
175
+ } catch {
176
+ return { findings: [], skipped: 'error' };
177
+ }
178
+ }
179
+
180
+ const SEVERITIES: CriticSeverity[] = ['high', 'medium', 'low'];
181
+
182
+ /** Defensive parse — strip any markdown fence, take the first JSON object,
183
+ * coerce to VisionFinding[]. A malformed model reply yields []. */
184
+ export function parseFindings(text: string): VisionFinding[] {
185
+ try {
186
+ if (typeof text !== 'string') return [];
187
+ const cleaned = text.replace(/```json\s*|```/g, '').trim();
188
+ const start = cleaned.indexOf('{');
189
+ const end = cleaned.lastIndexOf('}');
190
+ if (start < 0 || end <= start) return [];
191
+ const obj = JSON.parse(cleaned.slice(start, end + 1));
192
+ const raw = Array.isArray(obj?.findings) ? obj.findings : [];
193
+ const out: VisionFinding[] = [];
194
+ for (const f of raw) {
195
+ const sev = SEVERITIES.includes(f?.severity) ? (f.severity as CriticSeverity) : 'medium';
196
+ const region = typeof f?.region === 'string' ? f.region.slice(0, 60) : 'screen';
197
+ const issue = typeof f?.issue === 'string' ? f.issue.slice(0, 200) : '';
198
+ if (!issue) continue;
199
+ out.push({ kind: 'vision', severity: sev, region, message: issue });
200
+ }
201
+ return out;
202
+ } catch {
203
+ return [];
204
+ }
205
+ }
package/src/critic.ts ADDED
@@ -0,0 +1,73 @@
1
+ // F126.4 — composition-critic orchestrator. Runs the requested engine(s) against
2
+ // a live page + its screenshot and rolls their findings into one report the
3
+ // Lens capture/verify result carries. Geometry (critic-dom) is free + always
4
+ // available; vision (critic-vision) is a metered call gated by the caller (the
5
+ // F095 Review gate reads the project's composition_critic.vision setting and
6
+ // passes 'both' vs 'dom').
7
+
8
+ import type { Page } from 'playwright';
9
+ import { runDomCritic, type DomFinding } from './critic-dom.ts';
10
+ import { runVisionCritic, type VisionFinding, type VisionModelKey } from './critic-vision.ts';
11
+
12
+ export type CriticMode = 'off' | 'dom' | 'vision' | 'both';
13
+
14
+ export interface CriticReport {
15
+ mode: CriticMode;
16
+ dom: DomFinding[];
17
+ vision: VisionFinding[];
18
+ high: number;
19
+ medium: number;
20
+ low: number;
21
+ /** Concrete vision model used, if vision ran. */
22
+ visionModel?: string;
23
+ /** Why vision produced nothing, if it was requested but didn't run. */
24
+ visionSkipped?: 'no-ai' | 'error';
25
+ }
26
+
27
+ export function emptyCritic(mode: CriticMode = 'off'): CriticReport {
28
+ return { mode, dom: [], vision: [], high: 0, medium: 0, low: 0 };
29
+ }
30
+
31
+ /** Run the requested critic engines. `dom` is deterministic + free; `vision`
32
+ * costs one model call. Returns a combined report with severity rollups. */
33
+ export async function runCritic(
34
+ page: Page,
35
+ screenshot: Buffer,
36
+ mode: CriticMode,
37
+ visionModelKey?: VisionModelKey,
38
+ ): Promise<CriticReport> {
39
+ if (mode === 'off') return emptyCritic('off');
40
+ const dom = mode === 'dom' || mode === 'both' ? await runDomCritic(page) : [];
41
+ let vision: VisionFinding[] = [];
42
+ let visionModel: string | undefined;
43
+ let visionSkipped: 'no-ai' | 'error' | undefined;
44
+ if (mode === 'vision' || mode === 'both') {
45
+ const r = await runVisionCritic(screenshot, { purpose: 'composition-critic', model: visionModelKey });
46
+ vision = r.findings;
47
+ visionModel = r.model;
48
+ visionSkipped = r.skipped;
49
+ }
50
+ const all = [...dom, ...vision];
51
+ return {
52
+ mode,
53
+ dom,
54
+ vision,
55
+ high: all.filter((f) => f.severity === 'high').length,
56
+ medium: all.filter((f) => f.severity === 'medium').length,
57
+ low: all.filter((f) => f.severity === 'low').length,
58
+ visionModel,
59
+ visionSkipped,
60
+ };
61
+ }
62
+
63
+ /** A compact, gate-friendly one-liner. */
64
+ export function criticSummary(r: CriticReport | null): string {
65
+ if (!r || r.mode === 'off') return 'composition critic off';
66
+ return `composition: ${r.high} high · ${r.medium} medium · ${r.low} low (${r.dom.length} geometry, ${r.vision.length} vision)`;
67
+ }
68
+
69
+ /** The single highest-severity finding's human message, for a fail reason. */
70
+ export function topFindingMessage(r: CriticReport): string | null {
71
+ const high = [...r.dom, ...r.vision].find((f) => f.severity === 'high');
72
+ return high?.message ?? null;
73
+ }
@@ -0,0 +1,333 @@
1
+ // lens F001.5 — the Lens routes that used to be registered one by one in the
2
+ // cardmem daemon's index.ts (lines 1270–1661 at cardmem ed10516f), folded into
3
+ // the package router so the daemon mounts ONE thing: createLensRouter(deps).
4
+ //
5
+ // Bodies are moved verbatim; only three references changed:
6
+ // config.sandbox_roots → deps.sandboxRoots
7
+ // config.port → deps.port
8
+ // lensStorage → deps.storage
9
+ // and `err instanceof SandboxError` became isSandboxError(err), because the
10
+ // sandbox (and its error class) stays the daemon's — see sandbox.ts.
11
+
12
+ import type { Hono } from 'hono';
13
+ import { scrapeArticle } from './scrape.ts';
14
+ import { startCaptureSession, finishCaptureSession, cancelCaptureSession, execCaptureSession, parseExecBody } from './capture-session.ts';
15
+ import { listVisionModels } from './critic-vision.ts';
16
+ import { lensCaptureCatalogue, lensRunFlow, artifactUrl, type LensDeps } from './service.ts';
17
+ import { loadManifest, manifestToCatalogue, inventoryTestIds, inventoryInteractiveGaps, inventoryChromeDrift, manifestFlows } from './manifest.ts';
18
+ import { inventorySwallowedErrors } from './swallowed-errors.ts';
19
+ import { flowTestIds, proposeFlow } from './flow.ts';
20
+ import { checkAnchors, decideVerdict } from './gate.ts';
21
+ import { runRecord } from './record-run.ts';
22
+
23
+ /** The daemon's SandboxError, recognised by name — the class itself is the daemon's. */
24
+ const isSandboxError = (err: unknown): err is Error => err instanceof Error && err.name === 'SandboxError';
25
+
26
+ export function registerDaemonRoutes(r: Hono, deps: LensDeps): void {
27
+ // F126.4 — vision-model catalog + availability (single source for the Settings
28
+ // composition-critic selector). Availability reflects which provider keys this
29
+ // daemon actually has armed.
30
+ r.get('/vision-models', (c) => c.json(listVisionModels()));
31
+
32
+ // F074.6 — manifest-driven catalogue + data-testid inventory. These read
33
+ // project files (sandbox-sensitive).
34
+ r.post('/catalogue', async (c) => {
35
+ const body = (await c.req.json().catch(() => ({}))) as { local_path?: string; base_url?: string };
36
+ if (!body.local_path) return c.json({ ok: false, error: 'local_path required' }, 400);
37
+ try {
38
+ const manifest = loadManifest(body.local_path, deps.sandboxRoots);
39
+ const result = await lensCaptureCatalogue(deps, manifestToCatalogue(manifest, body.base_url));
40
+ return c.json({ ok: true, ...result });
41
+ } catch (err) {
42
+ if (isSandboxError(err)) return c.json({ ok: false, error: `sandbox: ${err.message}` }, 403);
43
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
44
+ }
45
+ });
46
+
47
+ r.post('/testid-inventory', async (c) => {
48
+ const body = (await c.req.json().catch(() => ({}))) as { local_path?: string };
49
+ if (!body.local_path) return c.json({ ok: false, error: 'local_path required' }, 400);
50
+ try {
51
+ const inv = inventoryTestIds(body.local_path, deps.sandboxRoots);
52
+ // upmetrics #4 — also surface the interactive-gap count so the inventory
53
+ // answers "what still needs a testid", not just "what exists".
54
+ const gaps = inventoryInteractiveGaps(body.local_path, deps.sandboxRoots);
55
+ return c.json({ ok: true, ...inv, interactive_gaps: gaps.total_gaps, files_with_gaps: gaps.files_with_gaps });
56
+ } catch (err) {
57
+ if (isSandboxError(err)) return c.json({ ok: false, error: `sandbox: ${err.message}` }, 403);
58
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
59
+ }
60
+ });
61
+
62
+ // upmetrics #4 — the actionable list: interactive elements (buttons/inputs/
63
+ // selects/onClick) missing a data-testid, so step-2 instrumentation is concrete.
64
+ r.post('/testid-gaps', async (c) => {
65
+ const body = (await c.req.json().catch(() => ({}))) as { local_path?: string };
66
+ if (!body.local_path) return c.json({ ok: false, error: 'local_path required' }, 400);
67
+ try {
68
+ return c.json({ ok: true, ...inventoryInteractiveGaps(body.local_path, deps.sandboxRoots) });
69
+ } catch (err) {
70
+ if (isSandboxError(err)) return c.json({ ok: false, error: `sandbox: ${err.message}` }, 403);
71
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
72
+ }
73
+ });
74
+
75
+ // F074.32 — static scan for code that takes the data and drops the error. No
76
+ // browser: it reads source. Same body/sandbox contract as /lens/testid-gaps.
77
+ r.post('/swallowed-errors', async (c) => {
78
+ const body = (await c.req.json().catch(() => ({}))) as { local_path?: string };
79
+ if (!body.local_path) return c.json({ ok: false, error: 'local_path required' }, 400);
80
+ try {
81
+ return c.json({ ok: true, ...inventorySwallowedErrors(body.local_path, deps.sandboxRoots) });
82
+ } catch (err) {
83
+ if (isSandboxError(err)) return c.json({ ok: false, error: `sandbox: ${err.message}` }, 403);
84
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
85
+ }
86
+ });
87
+
88
+ // F125.2 — Lens scrape: an arbitrary article URL → clean markdown. Body: { url, storage_state_path? }.
89
+ r.post('/scrape', async (c) => {
90
+ const body = (await c.req.json().catch(() => ({}))) as { url?: string; storage_state_path?: string };
91
+ if (!body.url) return c.json({ ok: false, error: 'url required' }, 400);
92
+ try {
93
+ const result = await scrapeArticle(body.url, { storageStatePath: body.storage_state_path });
94
+ return c.json({ ok: true, ...result });
95
+ } catch (err) {
96
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
97
+ }
98
+ });
99
+
100
+ // F240.6 — headed capture-session. Open a VISIBLE browser so a human logs in to a
101
+ // 2FA-gated site, then dump the Playwright storageState to a local file. The
102
+ // response carries only the path + counts, never the session bytes.
103
+ r.post('/capture-session/start', async (c) => {
104
+ const body = (await c.req.json().catch(() => ({}))) as {
105
+ url?: string;
106
+ state_path?: string;
107
+ email?: string;
108
+ password?: string;
109
+ load_state_path?: string;
110
+ };
111
+ if (!body.url) return c.json({ ok: false, error: 'url required' }, 400);
112
+ if (!body.state_path) return c.json({ ok: false, error: 'state_path required' }, 400);
113
+ try {
114
+ const r = await startCaptureSession({
115
+ url: body.url,
116
+ statePath: body.state_path,
117
+ email: body.email,
118
+ password: body.password,
119
+ loadStatePath: body.load_state_path,
120
+ });
121
+ // F240.10 — pass the window verdict through UNCHANGED. A remote caller is the
122
+ // one who has to tell a human "the browser is open"; it must see what the
123
+ // engine actually concluded, including "no verdict".
124
+ return c.json({
125
+ ok: true,
126
+ session_id: r.sessionId,
127
+ url: r.url,
128
+ state_path: r.statePath,
129
+ loaded: r.loaded,
130
+ login: r.login,
131
+ window_visible: r.windowVisible,
132
+ window_check: r.windowCheck,
133
+ });
134
+ } catch (err) {
135
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
136
+ }
137
+ });
138
+
139
+ // F240.7 — drive fill/click/screenshot steps against the OPEN capture-session
140
+ // browser. Credential VALUES ride in step.value over loopback ONLY.
141
+ r.post('/capture-session/exec', async (c) => {
142
+ // F240.9 — PARSED, not cast.
143
+ const parsed = parseExecBody(await c.req.json().catch(() => ({})));
144
+ if (!parsed.ok) return c.json({ ok: false, error: parsed.error }, 400);
145
+ const body = parsed.value;
146
+ try {
147
+ const sessionId = body.session_id;
148
+ const r = await execCaptureSession({
149
+ sessionId,
150
+ steps: body.steps,
151
+ saveShot: async (buffer, index) => {
152
+ const key = `capture-sessions/${sessionId}/${Date.now()}-${index}.png`;
153
+ await deps.storage.put(key, buffer);
154
+ return artifactUrl(deps.port, key) ?? '';
155
+ },
156
+ });
157
+ return c.json(r);
158
+ } catch (err) {
159
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
160
+ }
161
+ });
162
+
163
+ r.post('/capture-session/finish', async (c) => {
164
+ const body = (await c.req.json().catch(() => ({}))) as { session_id?: string };
165
+ if (!body.session_id) return c.json({ ok: false, error: 'session_id required' }, 400);
166
+ try {
167
+ const r = await finishCaptureSession({ sessionId: body.session_id });
168
+ return c.json(r);
169
+ } catch (err) {
170
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
171
+ }
172
+ });
173
+
174
+ r.post('/capture-session/cancel', async (c) => {
175
+ const body = (await c.req.json().catch(() => ({}))) as { session_id?: string };
176
+ if (!body.session_id) return c.json({ ok: false, error: 'session_id required' }, 400);
177
+ const r = await cancelCaptureSession(body.session_id);
178
+ return c.json(r);
179
+ });
180
+
181
+ // F312 — LENS VIDEO. F312.25 — THIN: the handler lives in record-run.ts so the
182
+ // MCP verb `lens_record` runs the SAME code rather than a copy.
183
+ r.post('/record', async (c) => {
184
+ const raw = (await c.req.json().catch(() => ({}))) as Record<string, unknown>;
185
+ const out = await runRecord(raw, { config: { port: deps.port, sandbox_roots: deps.sandboxRoots }, storage: deps.storage });
186
+ const { status, ...rest } = out;
187
+ return c.json(rest, status as 200 | 400);
188
+ });
189
+
190
+ // F123.4 — design-consistency structural check (the F086-twin for layout).
191
+ r.post('/chrome-drift', async (c) => {
192
+ const body = (await c.req.json().catch(() => ({}))) as {
193
+ local_path?: string;
194
+ layout_component?: string;
195
+ shell_marker?: string;
196
+ };
197
+ if (!body.local_path) return c.json({ ok: false, error: 'local_path required' }, 400);
198
+ try {
199
+ return c.json({
200
+ ok: true,
201
+ ...inventoryChromeDrift(body.local_path, deps.sandboxRoots, {
202
+ layoutComponent: body.layout_component,
203
+ shellMarker: body.shell_marker,
204
+ }),
205
+ });
206
+ } catch (err) {
207
+ if (isSandboxError(err)) return c.json({ ok: false, error: `sandbox: ${err.message}` }, 403);
208
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
209
+ }
210
+ });
211
+
212
+ // F074.7 — Lens gate: manifest + testid inventory + catalogue → a single
213
+ // green/red verdict + action.
214
+ r.post('/gate', async (c) => {
215
+ const body = (await c.req.json().catch(() => ({}))) as { local_path?: string; base_url?: string };
216
+ if (!body.local_path) return c.json({ ok: false, error: 'local_path required' }, 400);
217
+ try {
218
+ const manifest = loadManifest(body.local_path, deps.sandboxRoots);
219
+ const inventory = inventoryTestIds(body.local_path, deps.sandboxRoots);
220
+ const anchors = checkAnchors(manifest, inventory.testids.map((t) => t.testid));
221
+ const catalogue = await lensCaptureCatalogue(deps, manifestToCatalogue(manifest, body.base_url));
222
+ const gate = decideVerdict(catalogue, anchors);
223
+ return c.json({ ok: true, gate, anchors, catalogue });
224
+ } catch (err) {
225
+ if (isSandboxError(err)) return c.json({ ok: false, error: `sandbox: ${err.message}` }, 403);
226
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
227
+ }
228
+ });
229
+
230
+ // F082.3 — flow-gate: run every manuscript flow in a project's manifest +
231
+ // cross-check each [data-testid] the steps reference against the repo's
232
+ // inventory (a missing anchor is a hard block). One green/red verdict.
233
+ r.post('/flow-gate', async (c) => {
234
+ const body = (await c.req.json().catch(() => ({}))) as { local_path?: string; base_url?: string };
235
+ if (!body.local_path) return c.json({ ok: false, error: 'local_path required' }, 400);
236
+ try {
237
+ const manifest = loadManifest(body.local_path, deps.sandboxRoots);
238
+ const inventory = inventoryTestIds(body.local_path, deps.sandboxRoots);
239
+ const known = new Set(inventory.testids.map((t) => t.testid));
240
+ const flows = manifestFlows(manifest, body.base_url);
241
+ if (flows.length === 0) {
242
+ return c.json({ ok: true, gate: { verdict: 'green', reason: 'no flows declared' }, flows: [] });
243
+ }
244
+ const results = [];
245
+ for (const flow of flows) {
246
+ const missing = flowTestIds(flow).filter((t) => !known.has(t));
247
+ if (missing.length > 0) {
248
+ // Hard block: a step targets an anchor that doesn't exist in the repo.
249
+ results.push({ flow: flow.name, status: 'blocked', missing_anchors: missing });
250
+ continue;
251
+ }
252
+ // upmetrics (F074.11 closeout) — the gate must inherit the manifest's
253
+ // statePath auth too: passing local_path lets effectiveFlowAuth resolve it.
254
+ const run = await lensRunFlow(deps, { project: manifest.project, flow, local_path: body.local_path });
255
+ results.push({ flow: flow.name, status: run.status, flow_run_id: run.flow_run_id, failed_at: run.failed_at, reason: run.reason });
256
+ }
257
+ const bad = results.filter((r) => r.status !== 'pass');
258
+ const gate = {
259
+ verdict: bad.length === 0 ? 'green' : 'red',
260
+ total: results.length,
261
+ passed: results.filter((r) => r.status === 'pass').length,
262
+ blocked: results.filter((r) => r.status === 'blocked').length,
263
+ failed: results.filter((r) => r.status === 'fail' || r.status === 'error').length,
264
+ };
265
+ return c.json({ ok: true, gate, flows: results });
266
+ } catch (err) {
267
+ if (isSandboxError(err)) return c.json({ ok: false, error: `sandbox: ${err.message}` }, 403);
268
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
269
+ }
270
+ });
271
+
272
+ // F082.5 STRETCH — discovery: propose a candidate smoke-flow from the project's
273
+ // manifest routes + their [data-testid] anchors, no hand-written manuscript.
274
+ r.post('/propose-flow', async (c) => {
275
+ const body = (await c.req.json().catch(() => ({}))) as { local_path?: string; base_url?: string };
276
+ if (!body.local_path) return c.json({ ok: false, error: 'local_path required' }, 400);
277
+ try {
278
+ const manifest = loadManifest(body.local_path, deps.sandboxRoots);
279
+ const extractTestid = (sel?: string | number): string | null => {
280
+ if (typeof sel !== 'string') return null;
281
+ const m = sel.match(/\[data-testid=["']?([^"'\]]+)["']?\]/);
282
+ return m ? m[1]! : null;
283
+ };
284
+ const seen = new Set<string>();
285
+ const surfaces: Array<{ path: string; testid: string }> = [];
286
+ for (const s of manifest.surfaces) {
287
+ const testid = extractTestid(s.selector) ?? extractTestid(s.waitFor);
288
+ if (!testid) continue;
289
+ const key = `${s.path}|${testid}`;
290
+ if (seen.has(key)) continue;
291
+ seen.add(key);
292
+ surfaces.push({ path: s.path, testid });
293
+ }
294
+ const flow = proposeFlow(`${manifest.project}-smoke`, body.base_url ?? manifest.base_url, surfaces);
295
+ return c.json({ ok: true, flow, surface_count: surfaces.length });
296
+ } catch (err) {
297
+ if (isSandboxError(err)) return c.json({ ok: false, error: `sandbox: ${err.message}` }, 403);
298
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
299
+ }
300
+ });
301
+
302
+ // F082 — hermetic E2E self-test fixture: a self-contained form the flow
303
+ // executor can drive end-to-end with NO external site + NO auth.
304
+ r.get('/_selftest', (c) =>
305
+ c.html(`<!doctype html><html><head><meta charset="utf-8"><title>Lens self-test</title></head>
306
+ <body data-testid="fixture-root" style="font-family:system-ui;padding:24px">
307
+ <h1 data-testid="fixture-title">Lens E2E self-test</h1>
308
+ <label>Name <input data-testid="name" /></label>
309
+ <label>Role <select data-testid="role"><option value="">—</option><option value="admin">admin</option><option value="user">user</option></select></label>
310
+ <label><input type="checkbox" data-testid="agree" /> Agree</label>
311
+ <button data-testid="submit">Create</button>
312
+ <div data-testid="success" style="display:none"></div>
313
+ <!-- F082.6 / upmetrics #2 — async-hydrating node: empty on mount, text after a
314
+ beat. A one-shot expectText races this; a polling expectText settles. -->
315
+ <div data-testid="hydrated"></div>
316
+ <script>
317
+ setTimeout(function () {
318
+ document.querySelector('[data-testid=hydrated]').textContent = 'loaded-data sdk 0.1.4';
319
+ }, 400);
320
+ document.querySelector('[data-testid=submit]').addEventListener('click', function () {
321
+ var name = document.querySelector('[data-testid=name]').value.trim();
322
+ var role = document.querySelector('[data-testid=role]').value;
323
+ var agree = document.querySelector('[data-testid=agree]').checked;
324
+ var out = document.querySelector('[data-testid=success]');
325
+ if (name && role && agree) {
326
+ out.textContent = 'Created: ' + name + ' (' + role + ')';
327
+ out.style.display = 'block';
328
+ }
329
+ });
330
+ </script>
331
+ </body></html>`),
332
+ );
333
+ }
@@ -0,0 +1,18 @@
1
+ // lens F001.5 — what a piece of Lens evidence is worth once the machine that made
2
+ // it is gone. Logic moved verbatim (comments shortened) from cardmem packages/shared/src/evidence-durability.ts
3
+ // (ed10516f). Lens WRITES artifact_backend, so Lens owns what it means; cardmem's
4
+ // gate re-exports this from @broberg/lens-daemon after the cutover.
5
+
6
+ /** What a piece of evidence is worth once the machine that made it is gone. */
7
+ export type EvidenceDurability = 'durable' | 'local_only' | 'unknown';
8
+
9
+ /**
10
+ * Classify a stored backend value. The `null` branch is load-bearing: `?? 'local'`
11
+ * would silently claim a measurement for every historic row, `?? 'durable'` would
12
+ * be catastrophic. "We did not record this" must never become a reading.
13
+ */
14
+ export function durabilityOf(backend: string | null | undefined): EvidenceDurability {
15
+ if (backend === 'tigris') return 'durable';
16
+ if (backend === 'local') return 'local_only';
17
+ return 'unknown';
18
+ }