From 175341eada968838318c12c516cb97aea8bc5860 Mon Sep 17 00:00:00 2001 From: Ash Anand Date: Mon, 15 Jun 2026 12:01:52 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20walking=20skeleton=20=E2=80=94=20sc?= =?UTF-8?q?ripted=20Run=20=E2=86=92=20Run=20Artifact=20on=20disk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vertical tracer-bullet slice of the Testbuds pipe, built TDD. A scripted Interaction event stream flows through Signal Capture → Findings Engine → Run Artifact → filesystem Artifact Store, with no real browser or LLM. - Lock the Surface-neutral Interaction boundary contract (interaction-contract.ts): event taxonomy + Element/Action/Mechanical vocabularies, Capabilities, BudConfig. - Signal Capture consumes the stream into a CapturedTrace. - Findings Engine maps Mechanical events to Findings via a declarative rules table (error_state → dead-end for now). - Artifact Store as a port; filesystem adapter (one JSON file per Run) — the hosted-tier seam per ADR-0001. - Run Orchestrator wires the pipe via injected deps. - Fake Interaction adapter + scripted stream as the shared downstream test harness. - CLI runs the canonical demo Run end-to-end and prints the saved artifact path. 8/8 tests pass, typecheck clean, biome clean. Closes #2. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/artifact-store.test.ts | 47 ++++++++ src/artifact-store.ts | 11 ++ src/cli.test.ts | 22 ++++ src/cli.ts | 31 +++++ src/demo-run.ts | 98 +++++++++++++++ src/fake-interaction-adapter.test.ts | 85 +++++++++++++ src/fake-interaction-adapter.ts | 38 ++++++ src/findings-engine.test.ts | 62 ++++++++++ src/findings-engine.ts | 42 +++++++ src/fs-artifact-store.ts | 20 ++++ src/interaction-contract.ts | 172 +++++++++++++++++++++++++++ src/run-artifact.ts | 18 +++ src/run-orchestrator.test.ts | 84 +++++++++++++ src/run-orchestrator.ts | 41 +++++++ src/scripted-stream.ts | 10 ++ src/signal-capture.test.ts | 52 ++++++++ src/signal-capture.ts | 19 +++ tsconfig.build.json | 4 + 18 files changed, 856 insertions(+) create mode 100644 src/artifact-store.test.ts create mode 100644 src/artifact-store.ts create mode 100644 src/cli.test.ts create mode 100644 src/cli.ts create mode 100644 src/demo-run.ts create mode 100644 src/fake-interaction-adapter.test.ts create mode 100644 src/fake-interaction-adapter.ts create mode 100644 src/findings-engine.test.ts create mode 100644 src/findings-engine.ts create mode 100644 src/fs-artifact-store.ts create mode 100644 src/interaction-contract.ts create mode 100644 src/run-artifact.ts create mode 100644 src/run-orchestrator.test.ts create mode 100644 src/run-orchestrator.ts create mode 100644 src/scripted-stream.ts create mode 100644 src/signal-capture.test.ts create mode 100644 src/signal-capture.ts create mode 100644 tsconfig.build.json diff --git a/src/artifact-store.test.ts b/src/artifact-store.test.ts new file mode 100644 index 0000000..85f7669 --- /dev/null +++ b/src/artifact-store.test.ts @@ -0,0 +1,47 @@ +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { FsArtifactStore } from './fs-artifact-store.js'; +import type { RunArtifact } from './run-artifact.js'; + +describe('ArtifactStore port contract (fs adapter)', () => { + it('round-trips a Run Artifact through save then load', async () => { + const baseDir = await mkdtemp(join(tmpdir(), 'testbuds-')); + const store = new FsArtifactStore(baseDir); + const artifact: RunArtifact = { + runId: 'run-001', + result: { outcome: 'achieved' }, + findings: [ + { + type: 'dead-end', + severity: 'high', + confidence: 'high', + stepRef: 3, + evidence: { + type: 'mechanical', + stepRef: 3, + kind: 'error_state', + confidence: 'high', + }, + }, + ], + trace: { + mechanical: [ + { + type: 'mechanical', + stepRef: 3, + kind: 'error_state', + confidence: 'high', + }, + ], + }, + metadata: { budConfig: { perceptionMode: 'hybrid' }, mode: 'persona' }, + }; + + const ref = await store.save(artifact); + const loaded = await store.load(ref); + + expect(loaded).toEqual(artifact); + }); +}); diff --git a/src/artifact-store.ts b/src/artifact-store.ts new file mode 100644 index 0000000..064ca17 --- /dev/null +++ b/src/artifact-store.ts @@ -0,0 +1,11 @@ +import type { RunArtifact } from './run-artifact.js'; + +/** + * Port for persisting Run Artifacts. v1 adapter is the local + * filesystem; a cloud adapter is the future hosted-tier seam. + */ +export interface ArtifactStore { + /** Persists the artifact and returns a ref it can be loaded by. */ + save(artifact: RunArtifact): Promise; + load(ref: string): Promise; +} diff --git a/src/cli.test.ts b/src/cli.test.ts new file mode 100644 index 0000000..82d634b --- /dev/null +++ b/src/cli.test.ts @@ -0,0 +1,22 @@ +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { main } from './cli.js'; +import { FsArtifactStore } from './fs-artifact-store.js'; + +describe('CLI', () => { + it('runs the scripted demo Run end-to-end and prints the saved artifact path', async () => { + const baseDir = await mkdtemp(join(tmpdir(), 'testbuds-')); + const lines: string[] = []; + + await main({ baseDir, out: (line) => lines.push(line) }); + + expect(lines).toHaveLength(1); + const ref = lines[0]; + expect(ref.startsWith(baseDir)).toBe(true); + const artifact = await new FsArtifactStore(baseDir).load(ref); + expect(artifact.result.outcome).toBe('achieved'); + expect(artifact.findings.length).toBeGreaterThan(0); + }); +}); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..17131c0 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,31 @@ +import { pathToFileURL } from 'node:url'; +import { demoInvocation, demoScript } from './demo-run.js'; +import { FakeInteractionAdapter } from './fake-interaction-adapter.js'; +import { FsArtifactStore } from './fs-artifact-store.js'; +import { run } from './run-orchestrator.js'; + +export interface CliOptions { + baseDir?: string; + out?: (line: string) => void; +} + +/** Runs the scripted demo Run end-to-end and prints the artifact path. */ +export async function main({ + baseDir = '.testbuds/runs', + out = console.log, +}: CliOptions = {}): Promise { + const adapter = new FakeInteractionAdapter(demoScript); + const store = new FsArtifactStore(baseDir); + const { ref } = await run(demoInvocation, { adapter, store }); + out(ref); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/src/demo-run.ts b/src/demo-run.ts new file mode 100644 index 0000000..0f735b8 --- /dev/null +++ b/src/demo-run.ts @@ -0,0 +1,98 @@ +import type { ScriptedRun } from './fake-interaction-adapter.js'; +import type { RunInvocation } from './interaction-contract.js'; + +/** + * The canonical scripted demo Run: a Bud signs up, hits an unexplained + * validation error, recovers, and reaches the goal. Drives the CLI + * walking skeleton and is reusable as a test fixture. + */ +export const demoScript: ScriptedRun = { + events: [ + { + type: 'perceived', + stepRef: 1, + elements: [ + { id: 'el-1', role: 'textfield', name: 'Email' }, + { id: 'el-2', role: 'button', name: 'Sign up' }, + ], + }, + { + type: 'intent', + stepRef: 1, + rationale: 'The email field is the obvious starting point', + action: { + type: 'input', + targetId: 'el-1', + value: 'bud@example.com', + reason: 'Fill in email', + }, + }, + { + type: 'action', + stepRef: 1, + action: { + type: 'input', + targetId: 'el-1', + value: 'bud@example.com', + reason: 'Fill in email', + }, + }, + { type: 'outcome', stepRef: 1, success: true }, + { + type: 'action', + stepRef: 2, + action: { + type: 'point', + targetId: 'el-2', + reason: 'Submit the signup form', + }, + }, + { type: 'outcome', stepRef: 2, success: false }, + { + type: 'mechanical', + stepRef: 2, + kind: 'error_state', + confidence: 'high', + detail: 'Validation error shown with no explanation of what to fix', + }, + { + type: 'reaction', + stepRef: 2, + utterance: 'Hmm, it says something went wrong but not what…', + expression: 'confused', + cause: 'error_state', + }, + { + type: 'action', + stepRef: 3, + action: { + type: 'point', + targetId: 'el-2', + reason: 'Retry submitting the form', + }, + }, + { type: 'outcome', stepRef: 3, success: true }, + { + type: 'mechanical', + stepRef: 3, + kind: 'goal_achieved', + confidence: 'high', + }, + { + type: 'reaction', + stepRef: 3, + utterance: 'Got there in the end!', + expression: 'happy', + cause: 'goal_achieved', + }, + { type: 'milestone', stepRef: 3, kind: 'goal_achieved' }, + ], + result: { outcome: 'achieved' }, +}; + +export const demoInvocation: RunInvocation = { + budConfig: { perceptionMode: 'hybrid', stepBudget: 10 }, + flowSpec: { goal: 'Sign up for an account' }, + mode: 'persona', + target: { surface: 'web', entry: 'fixture://signup' }, +}; diff --git a/src/fake-interaction-adapter.test.ts b/src/fake-interaction-adapter.test.ts new file mode 100644 index 0000000..0283b27 --- /dev/null +++ b/src/fake-interaction-adapter.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { FakeInteractionAdapter } from './fake-interaction-adapter.js'; +import type { RunEvent, RunInvocation } from './interaction-contract.js'; + +const invocation: RunInvocation = { + budConfig: { perceptionMode: 'hybrid' }, + flowSpec: { goal: 'Sign up for an account' }, + mode: 'persona', + target: { surface: 'web', entry: 'fixture://signup' }, +}; + +describe('Fake Interaction adapter', () => { + it('advertises a Capabilities descriptor', () => { + const adapter = new FakeInteractionAdapter({ + events: [], + result: { outcome: 'achieved' }, + }); + + expect(adapter.capabilities).toEqual({ + surface: 'web', + perceptionModes: ['a11y', 'vision', 'hybrid'], + inputs: ['point', 'input', 'scroll', 'key'], + canScreenshot: false, + liveView: false, + }); + }); + + it('emits the scripted event stream and then the final run result', async () => { + const events: RunEvent[] = [ + { + type: 'perceived', + stepRef: 1, + elements: [{ id: 'el-1', role: 'textfield', name: 'Email' }], + }, + { + type: 'intent', + stepRef: 1, + rationale: 'The email field is the obvious starting point', + action: { + type: 'input', + targetId: 'el-1', + value: 'bud@example.com', + reason: 'Fill in email', + }, + }, + { + type: 'action', + stepRef: 1, + action: { + type: 'input', + targetId: 'el-1', + value: 'bud@example.com', + reason: 'Fill in email', + }, + }, + { type: 'outcome', stepRef: 1, success: true }, + { + type: 'mechanical', + stepRef: 1, + kind: 'goal_achieved', + confidence: 'high', + }, + { + type: 'reaction', + stepRef: 1, + utterance: 'That was easy!', + expression: 'happy', + }, + { type: 'milestone', stepRef: 1, kind: 'goal_achieved' }, + ]; + const adapter = new FakeInteractionAdapter({ + events, + result: { outcome: 'achieved' }, + }); + + const run = adapter.interact(invocation); + const received: RunEvent[] = []; + for await (const event of run.events) { + received.push(event); + } + + expect(received).toEqual(events); + await expect(run.result).resolves.toEqual({ outcome: 'achieved' }); + }); +}); diff --git a/src/fake-interaction-adapter.ts b/src/fake-interaction-adapter.ts new file mode 100644 index 0000000..f18fd7d --- /dev/null +++ b/src/fake-interaction-adapter.ts @@ -0,0 +1,38 @@ +import type { + Capabilities, + InteractionAdapter, + InteractionRun, + RunEvent, + RunInvocation, + RunResult, +} from './interaction-contract.js'; +import { streamOf } from './scripted-stream.js'; + +/** A scripted Run: the events to emit and the result to report. */ +export interface ScriptedRun { + events: RunEvent[]; + result: RunResult; +} + +/** + * Interaction adapter that replays a scripted Run — no browser, no LLM. + * Exercises the whole pipe downstream of the Interaction boundary. + */ +export class FakeInteractionAdapter implements InteractionAdapter { + readonly capabilities: Capabilities = { + surface: 'web', + perceptionModes: ['a11y', 'vision', 'hybrid'], + inputs: ['point', 'input', 'scroll', 'key'], + canScreenshot: false, + liveView: false, + }; + + constructor(private readonly script: ScriptedRun) {} + + interact(_invocation: RunInvocation): InteractionRun { + return { + events: streamOf(this.script.events), + result: Promise.resolve(this.script.result), + }; + } +} diff --git a/src/findings-engine.test.ts b/src/findings-engine.test.ts new file mode 100644 index 0000000..b6611fe --- /dev/null +++ b/src/findings-engine.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { analyze } from './findings-engine.js'; +import type { CapturedTrace } from './signal-capture.js'; + +describe('Findings Engine', () => { + it('does not surface goal_achieved as a Finding — the run result already records it', () => { + const trace: CapturedTrace = { + mechanical: [ + { + type: 'mechanical', + stepRef: 2, + kind: 'error_state', + confidence: 'high', + }, + { + type: 'mechanical', + stepRef: 5, + kind: 'goal_achieved', + confidence: 'high', + }, + ], + }; + + const findings = analyze(trace); + + expect(findings).toEqual([ + { + type: 'dead-end', + severity: 'high', + confidence: 'high', + stepRef: 2, + evidence: trace.mechanical[0], + }, + ]); + }); + + it('maps an error_state Mechanical event to a dead-end Finding with evidence attached', () => { + const trace: CapturedTrace = { + mechanical: [ + { + type: 'mechanical', + stepRef: 3, + kind: 'error_state', + confidence: 'high', + detail: 'Error banner appeared after submit', + }, + ], + }; + + const findings = analyze(trace); + + expect(findings).toEqual([ + { + type: 'dead-end', + severity: 'high', + confidence: 'high', + stepRef: 3, + evidence: trace.mechanical[0], + }, + ]); + }); +}); diff --git a/src/findings-engine.ts b/src/findings-engine.ts new file mode 100644 index 0000000..07773a7 --- /dev/null +++ b/src/findings-engine.ts @@ -0,0 +1,42 @@ +import type { + Confidence, + MechanicalEvent, + MechanicalKind, +} from './interaction-contract.js'; +import type { CapturedTrace } from './signal-capture.js'; + +/** + * A single surfaced result from a Run, grounded in observable run + * evidence (a Mechanical event), never only the Bud's opinion. + */ +export interface Finding { + type: 'friction' | 'dead-end' | 'a11y-gap' | 'confusion'; + severity: 'low' | 'medium' | 'high'; + confidence: Confidence; + stepRef: number; + evidence: MechanicalEvent; +} + +/** How each Mechanical kind surfaces as a Finding. Unmapped kinds are not surfaced yet. */ +const FINDING_RULES: Partial< + Record> +> = { + error_state: { type: 'dead-end', severity: 'high' }, +}; + +/** Maps the Mechanical events in a CapturedTrace to Findings. */ +export function analyze(trace: CapturedTrace): Finding[] { + const findings: Finding[] = []; + for (const event of trace.mechanical) { + const rule = FINDING_RULES[event.kind]; + if (rule) { + findings.push({ + ...rule, + confidence: event.confidence, + stepRef: event.stepRef, + evidence: event, + }); + } + } + return findings; +} diff --git a/src/fs-artifact-store.ts b/src/fs-artifact-store.ts new file mode 100644 index 0000000..42361f4 --- /dev/null +++ b/src/fs-artifact-store.ts @@ -0,0 +1,20 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { ArtifactStore } from './artifact-store.js'; +import type { RunArtifact } from './run-artifact.js'; + +/** Local-filesystem Artifact Store: one JSON file per Run. */ +export class FsArtifactStore implements ArtifactStore { + constructor(private readonly baseDir: string) {} + + async save(artifact: RunArtifact): Promise { + await mkdir(this.baseDir, { recursive: true }); + const ref = join(this.baseDir, `${artifact.runId}.json`); + await writeFile(ref, JSON.stringify(artifact, null, '\t')); + return ref; + } + + async load(ref: string): Promise { + return JSON.parse(await readFile(ref, 'utf8')) as RunArtifact; + } +} diff --git a/src/interaction-contract.ts b/src/interaction-contract.ts new file mode 100644 index 0000000..afc9821 --- /dev/null +++ b/src/interaction-contract.ts @@ -0,0 +1,172 @@ +/** + * The Interaction boundary contract (see ARCHITECTURE.md). + * + * Everything crossing this boundary is Surface-neutral: web concepts + * (DOM, CSS selectors, URLs) must never appear in these types. + */ + +export type Confidence = 'low' | 'medium' | 'high'; + +/** What a Bud is allowed to perceive — the strongest behavioral lever. */ +export type PerceptionMode = 'a11y' | 'vision' | 'hybrid'; + +/** The lens a Run applies to a Flow Spec. v1 ships Persona mode. */ +export type Mode = 'persona' | 'assertion'; + +/** + * The resolved, declarative, Surface-neutral policy a Bud runs under. + * Carried as data across the boundary — never adapter code. + */ +export interface BudConfig { + perceptionMode: PerceptionMode; + /** Patience/step budget hard-capping a Run. */ + stepBudget?: number; + dispositionPrompt?: string; +} + +/** Normalized perceived element — ARIA-like, the cross-platform a11y model. */ +export interface PerceivedElement { + /** Adapter-assigned id, NOT a CSS selector. */ + id: string; + /** ARIA-like role: button | link | textfield | checkbox | heading | … */ + role: string; + name?: string; + value?: string; + state?: { + disabled?: boolean; + focused?: boolean; + checked?: boolean; + expanded?: boolean; + hidden?: boolean; + }; + text?: string; +} + +/** Normalized action — 'point' is a click on web, a tap on mobile. */ +export interface NormalizedAction { + type: + | 'point' + | 'input' + | 'scroll' + | 'gesture' + | 'key' + | 'navigate' + | 'wait' + | 'finish'; + targetId?: string; + value?: string; + reason: string; +} + +/** Grounded evidence event taxonomy — what Findings are built from. */ +export type MechanicalKind = + | 'deviation' + | 'backtrack' + | 'repeat_attempt' + | 'search_thrash' + | 'perception_miss' + | 'stuck' + | 'budget_exceeded' + | 'error_state' + | 'gave_up' + | 'goal_achieved'; + +/** The live event stream an Interaction adapter emits during a Run. */ +export type RunEvent = + | { + type: 'perceived'; + stepRef: number; + elements: PerceivedElement[]; + screenshotRef?: string; + } + | { + type: 'intent'; + stepRef: number; + rationale: string; + action: NormalizedAction; + } + | { type: 'action'; stepRef: number; action: NormalizedAction } + | { + type: 'outcome'; + stepRef: number; + success: boolean; + latencyMs?: number; + changed?: string; + } + | { + type: 'mechanical'; + stepRef: number; + kind: MechanicalKind; + confidence: Confidence; + perceivedRef?: string; + detail?: string; + } + | { + type: 'reaction'; + stepRef: number; + utterance: string; + expression: string; + cause?: MechanicalKind; + } + | { + type: 'milestone'; + stepRef: number; + kind: 'hint_reached' | 'deviated' | 'goal_achieved'; + detail?: string; + }; + +export type MechanicalEvent = Extract; + +/** Final run result an adapter reports after the event stream ends. */ +export interface RunResult { + outcome: 'achieved' | 'partial' | 'failed'; + traceRef?: string; +} + +export type Surface = 'web' | 'mobile-native' | 'desktop'; + +/** + * What an adapter can do — lets the Orchestrator validate a Bud config + * before a Run and fail loudly rather than silently degrade. + */ +export interface Capabilities { + surface: Surface; + perceptionModes: PerceptionMode[]; + inputs: NormalizedAction['type'][]; + canScreenshot: boolean; + liveView: boolean; +} + +/** A plain-language Flow: a goal plus optional soft step-hints. */ +export interface FlowSpec { + goal: string; + hints?: string[]; +} + +/** + * Opaque, surface-tagged handle to the thing under test. Only the + * matching adapter interprets `entry` and `auth`. + */ +export interface TargetHandle { + surface: Surface; + entry: string; + auth?: unknown; +} + +export interface RunInvocation { + budConfig: BudConfig; + flowSpec: FlowSpec; + mode: Mode; + target: TargetHandle; +} + +/** A Run in flight: the live event stream plus the final result. */ +export interface InteractionRun { + events: AsyncIterable; + result: Promise; +} + +export interface InteractionAdapter { + readonly capabilities: Capabilities; + interact(invocation: RunInvocation): InteractionRun; +} diff --git a/src/run-artifact.ts b/src/run-artifact.ts new file mode 100644 index 0000000..52182de --- /dev/null +++ b/src/run-artifact.ts @@ -0,0 +1,18 @@ +import type { Finding } from './findings-engine.js'; +import type { BudConfig, Mode, RunResult } from './interaction-contract.js'; +import type { CapturedTrace } from './signal-capture.js'; + +/** + * The durable, structured record of a Run: Findings (source of truth), + * the captured evidence trace, and run metadata. + */ +export interface RunArtifact { + runId: string; + result: RunResult; + findings: Finding[]; + trace: CapturedTrace; + metadata: { + budConfig: BudConfig; + mode: Mode; + }; +} diff --git a/src/run-orchestrator.test.ts b/src/run-orchestrator.test.ts new file mode 100644 index 0000000..9973b3f --- /dev/null +++ b/src/run-orchestrator.test.ts @@ -0,0 +1,84 @@ +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + FakeInteractionAdapter, + type ScriptedRun, +} from './fake-interaction-adapter.js'; +import { FsArtifactStore } from './fs-artifact-store.js'; +import type { RunInvocation } from './interaction-contract.js'; +import { run } from './run-orchestrator.js'; + +const script: ScriptedRun = { + events: [ + { + type: 'perceived', + stepRef: 1, + elements: [{ id: 'el-1', role: 'button', name: 'Sign up' }], + }, + { + type: 'action', + stepRef: 1, + action: { + type: 'point', + targetId: 'el-1', + reason: 'Open the signup form', + }, + }, + { type: 'outcome', stepRef: 1, success: true }, + { + type: 'mechanical', + stepRef: 2, + kind: 'error_state', + confidence: 'high', + detail: 'Validation error with no explanation', + }, + { + type: 'mechanical', + stepRef: 3, + kind: 'goal_achieved', + confidence: 'high', + }, + { type: 'milestone', stepRef: 3, kind: 'goal_achieved' }, + ], + result: { outcome: 'achieved' }, +}; + +const invocation: RunInvocation = { + budConfig: { perceptionMode: 'hybrid', stepBudget: 10 }, + flowSpec: { goal: 'Sign up for an account' }, + mode: 'persona', + target: { surface: 'web', entry: 'fixture://signup' }, +}; + +describe('Run Orchestrator', () => { + it('runs a scripted Run end-to-end and persists a correct Run Artifact', async () => { + const adapter = new FakeInteractionAdapter(script); + const store = new FsArtifactStore( + await mkdtemp(join(tmpdir(), 'testbuds-')), + ); + + const { artifact, ref } = await run(invocation, { adapter, store }); + + expect(artifact.result).toEqual({ outcome: 'achieved' }); + expect(artifact.trace.mechanical).toEqual([ + script.events[3], + script.events[4], + ]); + expect(artifact.findings).toEqual([ + { + type: 'dead-end', + severity: 'high', + confidence: 'high', + stepRef: 2, + evidence: script.events[3], + }, + ]); + expect(artifact.metadata).toEqual({ + budConfig: invocation.budConfig, + mode: 'persona', + }); + await expect(store.load(ref)).resolves.toEqual(artifact); + }); +}); diff --git a/src/run-orchestrator.ts b/src/run-orchestrator.ts new file mode 100644 index 0000000..34dfa82 --- /dev/null +++ b/src/run-orchestrator.ts @@ -0,0 +1,41 @@ +import { randomUUID } from 'node:crypto'; +import type { ArtifactStore } from './artifact-store.js'; +import { analyze } from './findings-engine.js'; +import type { + InteractionAdapter, + RunInvocation, +} from './interaction-contract.js'; +import type { RunArtifact } from './run-artifact.js'; +import { consume } from './signal-capture.js'; + +export interface RunDeps { + adapter: InteractionAdapter; + store: ArtifactStore; +} + +/** + * Wires a Run: Interaction → Signal Capture → Findings Engine → + * Artifact Store. Returns the artifact and the ref it was saved under. + */ +export async function run( + invocation: RunInvocation, + { adapter, store }: RunDeps, +): Promise<{ artifact: RunArtifact; ref: string }> { + const interactionRun = adapter.interact(invocation); + const trace = await consume(interactionRun.events); + const result = await interactionRun.result; + + const artifact: RunArtifact = { + runId: randomUUID(), + result, + findings: analyze(trace), + trace, + metadata: { + budConfig: invocation.budConfig, + mode: invocation.mode, + }, + }; + + const ref = await store.save(artifact); + return { artifact, ref }; +} diff --git a/src/scripted-stream.ts b/src/scripted-stream.ts new file mode 100644 index 0000000..13e451d --- /dev/null +++ b/src/scripted-stream.ts @@ -0,0 +1,10 @@ +import type { RunEvent } from './interaction-contract.js'; + +/** + * Turns a scripted list of events into the async event stream the + * Interaction boundary speaks. The shared test harness for every module + * downstream of the boundary — no real browser or LLM required. + */ +export async function* streamOf(events: RunEvent[]): AsyncGenerator { + yield* events; +} diff --git a/src/signal-capture.test.ts b/src/signal-capture.test.ts new file mode 100644 index 0000000..b18395a --- /dev/null +++ b/src/signal-capture.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import type { RunEvent } from './interaction-contract.js'; +import { streamOf } from './scripted-stream.js'; +import { consume } from './signal-capture.js'; + +describe('Signal Capture', () => { + it('records Mechanical events with their step refs from the event stream', async () => { + const events: RunEvent[] = [ + { + type: 'perceived', + stepRef: 1, + elements: [{ id: 'el-1', role: 'button', name: 'Submit' }], + }, + { + type: 'action', + stepRef: 1, + action: { type: 'point', targetId: 'el-1', reason: 'Submit the form' }, + }, + { + type: 'mechanical', + stepRef: 1, + kind: 'error_state', + confidence: 'high', + detail: 'Error banner appeared after submit', + }, + { + type: 'mechanical', + stepRef: 2, + kind: 'goal_achieved', + confidence: 'high', + }, + ]; + + const trace = await consume(streamOf(events)); + + expect(trace.mechanical).toEqual([ + { + type: 'mechanical', + stepRef: 1, + kind: 'error_state', + confidence: 'high', + detail: 'Error banner appeared after submit', + }, + { + type: 'mechanical', + stepRef: 2, + kind: 'goal_achieved', + confidence: 'high', + }, + ]); + }); +}); diff --git a/src/signal-capture.ts b/src/signal-capture.ts new file mode 100644 index 0000000..6a4eb60 --- /dev/null +++ b/src/signal-capture.ts @@ -0,0 +1,19 @@ +import type { MechanicalEvent, RunEvent } from './interaction-contract.js'; + +/** The evidence layer recorded from a Run's event stream. */ +export interface CapturedTrace { + mechanical: MechanicalEvent[]; +} + +/** Consumes an Interaction event stream into a CapturedTrace. */ +export async function consume( + stream: AsyncIterable, +): Promise { + const mechanical: MechanicalEvent[] = []; + for await (const event of stream) { + if (event.type === 'mechanical') { + mechanical.push(event); + } + } + return { mechanical }; +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..8c42059 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} From ac19179ed5cd9e06e1a4e17b12dbc9c76ccb0e49 Mon Sep 17 00:00:00 2001 From: Ash Anand Date: Mon, 15 Jun 2026 12:02:06 -0400 Subject: [PATCH 2/2] docs: add CLAUDE.md project guidance Fill the previously-empty CLAUDE.md with the "fight entropy / leave the codebase better" steering note for future iterations. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index e69de29..8ff26bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -0,0 +1,9 @@ +This codebase will outlive you. Every shortcut you take becomes +someone else's burden. Every hack compounds into technical debt +that slows the whole team down. + +You are not just writing code. You are shaping the future of this +project. The patterns you establish will be copied. The corners +you cut will be cut again. + +Fight entropy. Leave the codebase better than you found it.