From 313c1db2c33f415b03932c0ba563d7d71f91cbc2 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:14:47 -0500 Subject: [PATCH 01/11] fix(evals): share bounded request policy with qualification (#17765) ## Thinking Path > - Paperclip manages agents and their work. > - Product E2E evaluations send model work through a gateway with cost limits. > - The gateway checks the request body before it reserves money or contacts a provider. > - A qualification probe needs that same check before it records a spending-cap denial. > - Namespace support already exists. Qualification must share that policy without sending a paid request. > - This pull request exposes the pure request validator and bounds the supported local tool definitions. > - The benefit is one request policy for qualification and forwarding. ## Linked Issues or Issue Description Refs: #17452, #17469. **What happened?** The request validator was private to `forwardEvalRequest`. A qualification caller could not reuse the request policy before admission. A separate check could accept a request body that forwarding rejects before a reservation. **Expected behavior** Qualification and forwarding use the same pure validator. One-level OpenAI namespaces with local function or custom tools remain supported. Invalid tool definitions fail before accounting or transport work. **Steps to reproduce** Use the synthetic native-daemon request fixture in `eval-gateway.test.ts`. It has seven local function tools and one namespace with five local function tools. Call the shared validator, then forward through the fake provider. Run the malformed namespace and tool-count cases to check rejection before side effects. The namespace behavior follows the [OpenAI function-calling guide](https://developers.openai.com/api/docs/guides/function-calling). This change preserves the one-level contract introduced in #17452 and hardened in #17469. ## What Changed - Export `boundedEvalPayload` with credential-free route fields. `forwardEvalRequest` calls that function. - Keep one namespace level with function or custom leaves. Require exact leaf types, nonblank string leaf names, and a closed namespace wrapper. - Limit OpenAI tool definitions to 1,024 per request. Count namespace wrappers and their children. - Add native-daemon-shaped forwarding coverage, malformed and hosted tool rejection, count and depth boundaries, and Anthropic compatibility checks. - Document the shared qualification policy in the Product E2E security guide. - Compile the real observability validator in the test parent. The owned CLI fixture imports JavaScript, so its process group does not include a test-only TypeScript compiler service. The parent resolves the compiler from the root package, which declares that dependency. Production cleanup checks and attempt deadlines stay intact. ## Verification - `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts eval-gateway.test.ts cost-ledger.test.ts provider-usage.test.ts operating-overhead.test.ts operations.test.ts`: 173 passed. - `pnpm test:e2e:runner:typecheck`: passed. - `pnpm -r typecheck`: passed. - `pnpm build`: passed. - Separate Node support checks: 137 passed. - `pnpm test:e2e:runner:unit`: 4,271 passed, two skipped, four failed in unchanged Daytona image and Pi catalog contracts. The same four failures reproduce on untouched base commit `a67a18c171816b097f46eae78fc58c7e55c9c565` (79 passed, four failed in those files). - `pnpm test:run`: interrupted and incomplete after required CI completed. Initial database startup failed because this checkout omitted the embedded PostgreSQL package postinstall aliases. The package hydrator restored them, and 14 real database checks passed. Earlier failures and the full output remain preserved; this local full run is not counted as passing verification. Its owned process group exited after SIGINT, with no owned survivors. - An offline full native authentication probe used the real Rust daemon and pinned Codex 0.160.0 Darwin SDK. The same captured request failed the older operations policy before reservation and reached a mock reservation with this policy. The OS sandbox allowed loopback only. It used synthetic credentials, retained no raw model payload, and made no provider calls. - Controller fixture suite: 70 passed. Three cold comparisons each confirmed that the old child started an owned compiler service and the corrected child did not. On Darwin the old compiler also exited cleanly, so this does not prove the exact historical Linux process race. Fresh Linux CI is required. - Independent review passed. The private qualification integration passed 14 exact-source compatibility checks and 67 shared-source tests without provider calls. - Required GitHub CI checks passed on the current head. The separate Entire approval gate still awaits an eligible reviewer; the delegated author account's explicitly AI-assisted approval does not satisfy it. ## Risks Requests with more than 1,024 OpenAI tool definitions or extra namespace wrapper fields now fail closed. New provider schema fields will need review. Anthropic tool handling, model prices, billing restrictions, output caps, and reservation ordering remain unchanged. This offline request-policy proof does not establish a completed model turn or Linux or Cloud onboarding acceptance. ## Model Used OpenAI Codex, GPT-6 family. The exact serving model identifier and context window are not exposed in this session. The model used reasoning, tools, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../controlled-evals/controller.test.mjs | 13 ++-- tests/runner-e2e/SECURITY.md | 11 +++ tests/runner-e2e/eval-gateway.test.ts | 67 ++++++++++++++++++- tests/runner-e2e/eval-gateway.ts | 37 +++++++--- 4 files changed, 112 insertions(+), 16 deletions(-) diff --git a/packages/paperclip-runner/scripts/controlled-evals/controller.test.mjs b/packages/paperclip-runner/scripts/controlled-evals/controller.test.mjs index 6b374dfea81..2de69815d93 100644 --- a/packages/paperclip-runner/scripts/controlled-evals/controller.test.mjs +++ b/packages/paperclip-runner/scripts/controlled-evals/controller.test.mjs @@ -6,6 +6,7 @@ import { writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; import { canonical, costRuntime, observabilityBoundConfiguration, requestBindingSha256, sha256, validateManifest } from './manifest.mjs'; import { attemptDirectory, runControlledEval } from './run.mjs'; import { summarizeControlledCampaign } from './report.mjs'; @@ -13,6 +14,8 @@ import { readRegularFile, replaceEvidenceFile } from './evidence-files.mjs'; import { decodeControlledEvidence, packControlledAttempt, readControlledEvidence, readAttemptControlledEvidence, summarizeControlledEvidence } from './archive.mjs'; import { aggregateProtocolEvalCampaign, sanitizeProtocolEvalRuns } from '../runner-protocol-eval-campaign.mjs'; +const { build } = createRequire(new URL('../../../../package.json', import.meta.url))('esbuild'); + const childScript = options => ` import assert from 'node:assert/strict'; import {createHash} from 'node:crypto'; @@ -49,10 +52,7 @@ if(args.has('--observability-config')) { } nativeObservability={mode:'controller_otlp',configured:true,verification:'unverified',projectId:config.projectId,traceId:config.traceId,spanId:config.spanId,descriptorSha256:createHash('sha256').update(raw).digest('hex')}; if(${Boolean(options.realObservabilityValidator)}) { - const {createRequire}=await import('node:module'); - const require=createRequire(${JSON.stringify(fileURLToPath(new URL('../../../../server/package.json', import.meta.url)))}); - const {tsImport}=await import(require.resolve('tsx/esm/api')); - const {prepareEvalObservabilityRuntime}=await tsImport(${JSON.stringify(fileURLToPath(new URL('../../src/cli/eval-observability-runtime.ts', import.meta.url)))},import.meta.url); + const {prepareEvalObservabilityRuntime}=await import(${JSON.stringify(options.observabilityValidatorModule ?? '')}); await mkdir(process.env.HOME+'/codex',{recursive:true}); const prepared=await prepareEvalObservabilityRuntime(args.get('--observability-config'),request, {evidence:{campaignId:descriptor.campaignId,executionId:descriptor.executionId},transportOptions:{environment:{CODEX_HOME:process.env.HOME+'/codex'}}},process.env); @@ -85,6 +85,11 @@ async function fixture(t, { count = 1, allocation = '0.01', providerProfile = 'c const root = await mkdtemp(join(tmpdir(), 'runner-controller-test-')); t.after(() => rm(root, { recursive: true, force: true })); const cli = join(root, 'fake-cli.mjs'), binary = join(root, 'runnerd'), archive = join(root, 'runner.tgz'); + if (options.realObservabilityValidator) { + options.observabilityValidatorModule = join(root, 'eval-observability-runtime.mjs'); + await build({ entryPoints: [fileURLToPath(new URL('../../src/cli/eval-observability-runtime.ts', import.meta.url))], + outfile: options.observabilityValidatorModule, bundle: true, platform: 'node', format: 'esm', target: 'node24', logLevel: 'silent' }); + } await writeFile(cli, childScript(options)); await writeFile(binary, 'synthetic daemon'); await writeFile(archive, 'synthetic package'); const provenance = async path => ({ path, sha256: sha256(await readFile(path)) }); const now = Date.now(), verifiedAt = new Date(now - 1000).toISOString(), expiresAt = new Date(now + 86_400_000).toISOString(); diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md index c9ad6c6d91d..72dc383a7a9 100644 --- a/tests/runner-e2e/SECURITY.md +++ b/tests/runner-e2e/SECURITY.md @@ -506,6 +506,17 @@ select arbitrary destinations. Gateway response caching and fallback are refused Request journal and usage receipts contain no prompt/response bodies or keys. The manifest, journal, and receipts are not public report projections. +`boundedEvalPayload` is the shared, credential-free request validator and +normalizer. Qualification callers must apply it before recording budget-denial +proof. The forwarding path applies the same policy before a ledger reservation. +OpenAI tools may use one namespace level around local function or custom tools. +Function and custom leaves require nonblank string names. +Namespace wrappers contain only `type`, `name`, optional `description`, and +`tools`. Nested namespaces, hosted tools, and tool lists attached to leaves fail +closed. The request may contain at most 1,024 tool definitions, counting namespace +wrappers and their children. Anthropic tool policy stays separate. Output limits, +billing-mode restrictions, and multimodal exclusions apply in both paths. + ## Configured repository and conversion proof The repository proof flow seeds only the exact `workspace` child of its resolved diff --git a/tests/runner-e2e/eval-gateway.test.ts b/tests/runner-e2e/eval-gateway.test.ts index c4c9e785b8d..150fac62f5b 100644 --- a/tests/runner-e2e/eval-gateway.test.ts +++ b/tests/runner-e2e/eval-gateway.test.ts @@ -6,7 +6,7 @@ import { connect } from "node:net"; import { afterEach, expect, it, vi } from "vitest"; import { openEvalCostLedger, readEvalCostLedger } from "./cost-ledger.js"; import { beginEvalAgentPhase, createEvalAttemptPhase, readEvalAttemptPhase } from "./attempt-phase.js"; -import { evalRequestReservation, EvalUsageStream, forwardEvalRequest, startEvalGateway, type EvalGatewayRoute, type EvalGatewayReceipt } from "./eval-gateway.js"; +import { boundedEvalPayload, evalRequestReservation, EvalUsageStream, forwardEvalRequest, startEvalGateway, type EvalGatewayRoute, type EvalGatewayReceipt } from "./eval-gateway.js"; const directories: string[] = [], closes: Array<() => Promise> = []; afterEach(async () => { for (const close of closes.splice(0).reverse()) await close(); for (const dir of directories.splice(0)) await rm(dir, { recursive: true, force: true }); }); const identity = { executionId: "hello", attemptId: "attempt", phase: "agent" as const }; @@ -135,6 +135,71 @@ it("admits local function and custom tools inside a Codex namespace", async () = expect(result.outcome).toBe("succeeded"); expect(transport).toHaveBeenCalledOnce(); }); +it("validates the native daemon's namespace envelope before reserving and forwarding", async () => { + const f = await fixture(); + const localTool = (name: string) => ({ type: "function", name, description: "Synthetic local tool", + parameters: { type: "object", properties: {}, additionalProperties: false }, strict: true }); + const tools = [...Array.from({ length: 7 }, (_, index) => localTool(`local_${index}`)), + { type: "namespace", name: "local_tools", description: "Synthetic local namespace", + tools: Array.from({ length: 5 }, (_, index) => localTool(`operation_${index}`)) }]; + const payload = Object.freeze({ ...f.payload, tools }); + const normalized = boundedEvalPayload(payload, { provider: route.provider, price: route.price, maximumOutputTokens: 100 }); + expect(normalized).toEqual({ ...payload, max_output_tokens: 100, service_tier: "default" }); + expect(payload).not.toHaveProperty("service_tier"); + expect(payload.max_output_tokens).toBe(500); + expect(f.ledger.snapshot().requests).toHaveLength(0); + const transport = vi.fn(async (_url, init) => { + expect(JSON.parse(init!.body as string)).toEqual(normalized); + expect((await readEvalCostLedger(f.file)).state.snapshot().inflight).toBe(1); + return response(); + }); + const receipt = await forwardEvalRequest({ ...f, payload, transport }); + expect(receipt).toMatchObject({ outcome: "succeeded", costUsd: "0.000068000", final: true }); + expect(transport).toHaveBeenCalledOnce(); +}); +it.each([ + { type: "namespace", name: " ", tools: [{ type: "function", name: "local" }] }, + { type: "namespace", name: "local", description: {}, tools: [{ type: "function", name: "local" }] }, + { type: "namespace", name: "local", tools: [] }, + { type: "namespace", name: "local", tools: null }, + { type: "namespace", name: "local", tools: [null] }, + { type: ["function"], name: "local" }, + { type: ["custom"], name: "local" }, + { type: "namespace", name: "local", tools: [{ type: ["function"], name: "local" }] }, + { type: "namespace", name: "local", tools: [{ type: ["custom"], name: "local" }] }, + ...[undefined, null, 7, "", " "].flatMap(name => ["function", "custom"].flatMap(type => + [{ type, name }, { type: "namespace", name: "local", tools: [{ type, name }] }])), + { type: "namespace", name: "local", tools: [{ type: "function", name: "local" }], future_hosted_tool: true }, + { type: "namespace", name: "local", tools: [{ type: "namespace", name: "nested", tools: [{ type: "function", name: "local" }] }] }, + ...["web_search", "file_search", "image_generation", "code_interpreter", "computer", "mcp", "unknown"].map(type => + ({ type: "namespace", name: "local", tools: [{ type }] })), +])("rejects malformed, nested, or hosted namespace tools before any side effects", async tool => { + const f = await fixture(), transport = vi.fn(), persist = vi.fn(); + const payload = { ...f.payload, tools: [tool] }; + expect(() => boundedEvalPayload(payload, f.route)).toThrow("unqualified_hosted_tool"); + await expect(forwardEvalRequest({ ...f, payload, transport, persist })).rejects.toThrow("unqualified_hosted_tool"); + expect(transport).not.toHaveBeenCalled(); expect(persist).not.toHaveBeenCalled(); + expect(f.ledger.snapshot().requests).toHaveLength(0); +}); +it.each(["flat", "namespace"] as const)("bounds the total %s tool definitions including namespace wrappers", async shape => { + const f = await fixture(), transport = vi.fn(), persist = vi.fn(); + const tool = { type: "function", name: "local_tool" }; + const tools = shape === "flat" ? Array.from({ length: 1024 }, () => tool) + : [{ type: "namespace", name: "local", tools: Array.from({ length: 1023 }, () => tool) }]; + expect(boundedEvalPayload({ ...f.payload, tools }, f.route).tools).toEqual(tools); + const payload = { ...f.payload, tools: [...tools, tool] }; + await expect(forwardEvalRequest({ ...f, payload, transport, persist })).rejects.toThrow("unqualified_hosted_tool"); + expect(transport).not.toHaveBeenCalled(); expect(persist).not.toHaveBeenCalled(); + expect(f.ledger.snapshot().requests).toHaveLength(0); +}); +it("keeps Anthropic custom tools and output limits separate from OpenAI namespaces", () => { + const anthropic = { provider: "anthropic" as const, price: { ...route.price, provider: "anthropic" as const }, maximumOutputTokens: 100 }; + const tools = [{ name: "local_tool", input_schema: { type: "object" } }, { type: "custom", name: "other_tool" }]; + expect(boundedEvalPayload({ model: "fixture", tools, max_tokens: 500 }, anthropic)) + .toEqual({ model: "fixture", tools, max_tokens: 100, service_tier: "standard_only" }); + expect(() => boundedEvalPayload({ model: "fixture", tools: [{ type: "namespace", name: "local", tools }] }, anthropic)) + .toThrow("unqualified_hosted_tool"); +}); it.each([ { type: "namespace", name: "local", tools: [{ type: "web_search" }] }, { type: "namespace", name: "local", tools: [{ type: "namespace", name: "nested", tools: [{ type: "web_search" }] }] }, diff --git a/tests/runner-e2e/eval-gateway.ts b/tests/runner-e2e/eval-gateway.ts index 6d42ce680e5..a90cd28047f 100644 --- a/tests/runner-e2e/eval-gateway.ts +++ b/tests/runner-e2e/eval-gateway.ts @@ -65,19 +65,34 @@ function modelReservationBound(route: EvalGatewayRoute, reservation: string) { }) : undefined; } -function boundedPayload(value: unknown, route: EvalGatewayRoute) { +function qualifiedOpenAiTools(tools: unknown): boolean { + if (!Array.isArray(tools)) return false; + let remaining = 1024; + const leaf = (tool: unknown): boolean => { + const definition = row(tool); + return --remaining >= 0 && (definition.type === "function" || definition.type === "custom") + && typeof definition.name === "string" && Boolean(definition.name.trim()) && !Object.hasOwn(definition, "tools"); + }; + return tools.every(tool => { + const definition = row(tool); + if (definition.type !== "namespace") return leaf(tool); + return --remaining >= 0 && typeof definition.name === "string" && Boolean(definition.name.trim()) + && (definition.description === undefined || typeof definition.description === "string") + && Object.keys(definition).every(key => ["type", "name", "description", "tools"].includes(key)) + && Array.isArray(definition.tools) && definition.tools.length > 0 && definition.tools.every(leaf); + }); +} + +export function boundedEvalPayload(value: unknown, route: Pick): Record { const body = row(value); if (body.model !== route.price.model) throw new EvalRequestError("unqualified_model"); if (body.background === true || body.mcp_servers !== undefined || body.service_tier !== undefined && !["auto", "default", "standard_only"].includes(String(body.service_tier))) throw new EvalRequestError("unqualified_billing_mode"); if (body.speed !== undefined && body.speed !== "standard" || body.inference_geo !== undefined) throw new EvalRequestError("unqualified_billing_mode"); - if (body.tools !== undefined && (!Array.isArray(body.tools) || body.tools.some(tool => { - const definition = row(tool), type = definition.type; - if (route.provider !== "openai") return type !== undefined && type !== "custom" || Object.hasOwn(definition, "tools"); - if (type === "namespace") return typeof definition.name !== "string" || !definition.name - || !Array.isArray(definition.tools) || !definition.tools.length - || definition.tools.some(nested => !["function", "custom"].includes(String(row(nested).type)) || Object.hasOwn(row(nested), "tools")); - return !["function", "custom"].includes(String(type)) || Object.hasOwn(definition, "tools"); - }))) throw new EvalRequestError("unqualified_hosted_tool"); + if (body.tools !== undefined && (route.provider === "openai" ? !qualifiedOpenAiTools(body.tools) + : !Array.isArray(body.tools) || body.tools.some(tool => { + const definition = row(tool), type = definition.type; + return type !== undefined && type !== "custom" || Object.hasOwn(definition, "tools"); + }))) throw new EvalRequestError("unqualified_hosted_tool"); const inspect = (item: unknown): void => { if (Array.isArray(item)) { item.forEach(inspect); return; } if (!item || typeof item !== "object") return; @@ -119,8 +134,8 @@ export async function forwardEvalRequest(input: { transport?: typeof fetch; }): Promise { const { route, ledger } = input; - let body: ReturnType; - try { body = boundedPayload(input.payload, route); } + let body: ReturnType; + try { body = boundedEvalPayload(input.payload, route); } catch (error) { throw error instanceof EvalRequestError ? error : new EvalRequestError("invalid_request_payload"); } const reservation = evalRequestReservation(route); if (!route.apiKey || /[\r\n]/.test(route.apiKey)) throw new Error("missing_gateway_credential"); From 46de15ff4b99bbee59c38f32f4f30b80ad5bbd24 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:18:53 -0500 Subject: [PATCH 02/11] fix(recovery): preserve native workspaces after control deadlines (#17788) ## Thinking Path > - Paperclip lets people manage agents and their saved work. > - Native runs can prepare a remote workspace before provider execution. > - A finalization deadline can stop a run outside its normal exception path. > - That handler marked the run failed without recording its prepared files for recovery. > - Retry then waited for a lease whose preservation had no durable owner. > - This pull request records the exact source in the deadline transaction through the existing recovery service. > - Existing containment and file recovery can then settle the source before another executor starts. ## Linked Issues or Issue Description **What happened?** A native run failed at its control deadline with a prepared workspace and no accepted result. Its environment lease remained active. The run had no workspace preservation record. A saved Retry waited for remote cleanup without a preservation operation to finish that cleanup. **Expected behavior** Record unfinished files when the deadline marks a native run failed. Use the existing stop-and-retain worker. Keep accepted results, exact source binding, and current task ownership authoritative. **Steps to reproduce** 1. Persist a native run with a prepared workspace, an active lease, and an expired control deadline. 2. Reconcile the deadline twice. 3. Inspect the source lease and the run's workspace recovery record. **Paperclip version or commit** Reproduced against `a67a18c171816b097f46eae78fc58c7e55c9c565`. **Deployment mode** Hosted native Runner with a remote sandbox. The regression uses real local PostgreSQL and no provider mocks. Related: Refs #17741 for committed Retry delivery. Refs #17073 for remote process-exit verification. This change records file preservation after a deadline; it does not change either policy. ## What Changed - Call the existing native workspace preservation service in the transaction that fails an abandoned run. - Preserve files after reassignment without changing the current task owner or state. - Add real-database cases for prepared, finalized, mismatched, reassigned, and accepted-result states. Repeated reconciliation retains one source obligation and creates no successor. - Document the deadline behavior and its existing access boundaries. ## Verification - Red: on unchanged production code, the prepared-workspace case failed because its recovery record was null. Finalized and mismatched controls passed. - Green: all 10 deadline reconciliation tests pass. The existing native workspace recovery suite passes all 48 cases. - `pnpm -r typecheck` passed. - `pnpm build` passed. - Map checks, module-boundary checks, and `git diff --check` passed. - Current-head CI passes on `23bf2aa4b50d2c4b74a9ee2ee47034ae542d25c2`: all GitHub Actions CI lanes, including grouped/serialized tests, Runner verification, typecheck, build, E2E, and Canary Dry Run, succeeded. Two Storybook-only checks were skipped. Greptile is 5/5 with no findings. Superagent Security Scan also passed. Entire Gates reports passing checks/findings and fails only because no reviewer has approved trail #10. - The first server shard 11/12 attempt passed all 1,750 assertions but reported an unhandled early process-exit rejection in the unchanged workspace-exposure fixture. Its focused local suite passed (28 passed, 3 skipped); one CI rerun passed. - Full local `pnpm test:run` was started, then interrupted after the complete CI lanes passed to avoid duplicate work. No full local suite pass is claimed. Local targeted tests, typecheck, and build passed. - On the affected staging instance, the existing deployed preservation helper registered the exact failed source. Normal Retry then drove actual sandbox stop, file recovery, and a persisted successful recovery receipt. A successor reached a separate remote process-fingerprint failure before provider execution. The automatic deadline change is not deployed; task execution recovery remains incomplete. - Initial local setup attempts did not reach product assertions: skipped dependency lifecycle scripts left native PostgreSQL library links absent, and the restricted shell blocked localhost binding. The package's link hydration and an authorized isolated listener resolved those setup prerequisites. One new negative-control assertion also required null-safe access; the final red run retained only the intended behavioral failure. ## Risks - File preservation must not certify provider termination. The existing stop receipt, source binding, copyback, and admission checks remain in force. - Preservation failure rolls back the deadline transaction, leaving its durable deadline eligible for another bounded attempt. - Reassignment preserves old files without changing the new owner's task. Accepted results retain their current finalizer. - No schema, credential, provider, or polling change. Historical failed runs still require scoped recovery; this patch does not replay them. ## Model Used OpenAI Codex based on GPT-6, with reasoning, repository inspection, code execution, and browser tools. The exact serving model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (targeted suites; full local suite interrupted after CI passed) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (CI lanes pass; external Entire reviewer-approval gate remains pending) - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- doc/execution-semantics.md | 7 +++ maps/agent-access-map/recovery.md | 4 ++ maps/feature-map/recovery.md | 2 + .../execution-control-reconciliation.test.ts | 61 ++++++++++++++++++- .../execution-control-reconciliation.ts | 6 +- 5 files changed, 78 insertions(+), 2 deletions(-) diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 03f0044f38d..2c6fc2218d0 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -1336,6 +1336,13 @@ does not reset the provider-attempt budget. Provider execution and control-plane finalization have different clocks. A healthy provider can think or execute a long tool without output. Once execution settles, recovery and finalization control steps have a 60-second deadline, checked on startup and every 15 seconds. With a healthy database and scheduler, an abandoned transition must be repaired or surfaced within 90 seconds. Terminal persistence must not wait on provider cleanup or publication; a late finalizer cannot change a reassigned or closed task or release another run's locks. Historical ambiguous runs are never automatically replayed after an upgrade. +When that deadline fails a native run with a prepared workspace, the same +transaction records the exact source's file-preservation obligation. Existing +stop-and-retain recovery contains the provider and saves the files before +ordinary Retry can continue. Finalized workspaces, mismatched sources and +accepted results keep their existing settlement paths. Reassignment does not +discard the stopped run's files or grant authority over the new assignee. + Every continuation carries the triggering request, ordered user direction, interaction outcomes, completed work, and explicit history coverage. A delivered message remains part of the task's request after its connection or approval resolves. The original title is background; a completed Notion read does not satisfy a later Gmail request. Author and source-trust boundaries survive rendering into both native and legacy prompts. Missing required history must be fetched before dispatch rather than described as complete. ### Interrupted conversation continuation diff --git a/maps/agent-access-map/recovery.md b/maps/agent-access-map/recovery.md index b2581d1d43b..7f212f4a3d2 100644 --- a/maps/agent-access-map/recovery.md +++ b/maps/agent-access-map/recovery.md @@ -4,6 +4,10 @@ Product map: [Recovery](../feature-map/recovery.md). Feature ID: `recovery`. +Native finalization deadline reconciliation records unfinished workspace +preservation through the existing server-owned recovery path. It grants no +additional agent API, provider-stop authority or permission to replay work. + Ordinary board Retry can reauthorize saved GitHub input for the same App after reconnect. It pins current runtime authority and preserves sender, repository, conversation and task checks. Pending input takes precedence over the older diff --git a/maps/feature-map/recovery.md b/maps/feature-map/recovery.md index c156b4d7cd7..8514cd446be 100644 --- a/maps/feature-map/recovery.md +++ b/maps/feature-map/recovery.md @@ -33,6 +33,8 @@ Agent access: [companion](../agent-access-map/recovery.md). workspace link when the destination is disabled. Local PostgreSQL and real filesystem regressions cover native and legacy recovery and one-time delivery; hosted provider recovery remains separate acceptance. + Native finalization deadline failures also retain the exact prepared source + for the same stop-and-save workflow before Retry admits another executor. - `unknown-outcome`: `recovery/unknown-outcome` — uncertain external side effects require inspection and an explicit decision, not automatic replay. - `user-continuation`: `recovery/user-continuation` — saved user messages after a stop are reconsidered once diff --git a/server/src/services/execution-control-reconciliation.test.ts b/server/src/services/execution-control-reconciliation.test.ts index 9d46777715c..1b873eb4e1e 100644 --- a/server/src/services/execution-control-reconciliation.test.ts +++ b/server/src/services/execution-control-reconciliation.test.ts @@ -2,7 +2,7 @@ import { enableRunFailureReporting } from "../__tests__/helpers/run-failure-repo import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { agents, companies, completionContracts, createDb, heartbeatRuns, issues, issueRecoveryActions, issueThreadInteractions, nativeRunResults, statusDecisions, workAssessments } from "@paperclipai/db"; +import { agents, companies, completionContracts, createDb, environmentLeases, environments, heartbeatRuns, issues, issueRecoveryActions, issueThreadInteractions, nativeRunFinalizations, nativeRunResults, statusDecisions, workAssessments } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, @@ -153,6 +153,65 @@ describeEmbeddedPostgres("reconcileAbandonedExecutionControl reports a genuine f expect(result.surfaced).toBe(1); expect(mockCaptureRunFailure.mock.calls.slice(captureCallsBefore)).toHaveLength(0); }); + + it.each(["prepared", "finalized", "mismatched", "reassigned", "accepted"] as const)("preserves only an exact unfinished native workspace after its deadline (%s)", async state => { + const f = await seedAbandonedRunFixture(); + const [environment] = await db.insert(environments).values({ name: `Deadline ${f.runId}`, driver: "sandbox" }).returning(); + const [lease] = await db.insert(environmentLeases).values({ companyId: f.companyId, issueId: f.issueId, + heartbeatRunId: f.runId, environmentId: environment.id, provider: "daytona", providerLeaseId: randomUUID(), + status: "active", leasePolicy: "reuse_by_environment", metadata: { remoteCwd: "/work" }, + }).returning(); + await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: f.issueId, runnerProfileJson: { + nativeWorkspaceSync: { schema: "paperclip.native-workspace-sync/v3", state: state === "finalized" ? state : "prepared", + descriptorSha256: "a".repeat(64), baselineSha256: "b".repeat(64), finalHostSha256: null, + workspaceId: f.runId, leaseId: lease.id, providerLeaseId: state === "mismatched" ? randomUUID() : lease.providerLeaseId, + remoteCwd: "/work", resourceDisposition: "keep_running" }, + } }).where(eq(heartbeatRuns.id, f.runId)); + await db.insert(nativeRunFinalizations).values({ runId: f.runId, companyId: f.companyId, issueId: f.issueId, + phase: "observed", leaseOwner: "abandoned-controller", controlDeadlineAt: new Date(Date.now() - 60_000) }); + if (state === "reassigned") { + const nextAgentId = randomUUID(); + await db.insert(agents).values({ id: nextAgentId, companyId: f.companyId, name: "New owner", adapterType: "codex_local" }); + await db.update(issues).set({ assigneeAgentId: nextAgentId, executionRunId: null, checkoutRunId: null }) + .where(eq(issues.id, f.issueId)); + } + if (state === "accepted") { + const contractId = randomUUID(), resultId = randomUUID(); + await db.insert(completionContracts).values({ id: contractId, companyId: f.companyId, issueId: f.issueId, + revision: 1, schemaVersion: "1", policyVersion: "test", risk: "low", completionAuthority: "agent_claim_policy", + incompleteCriteriaPolicy: "block", contractJson: {}, canonicalSha256: randomUUID(), createdByActorType: "agent", createdByActorId: f.agentId }); + await db.update(heartbeatRuns).set({ completionContractId: contractId }).where(eq(heartbeatRuns.id, f.runId)); + await db.insert(nativeRunResults).values({ id: resultId, companyId: f.companyId, issueId: f.issueId, runId: f.runId, + completionContractId: contractId, serverFingerprint: randomUUID(), schemaStatus: "valid", resultJson: {}, canonicalSha256: randomUUID() }); + await db.update(nativeRunFinalizations).set({ resultId }).where(eq(nativeRunFinalizations.runId, f.runId)); + } + + await reconcileAbandonedExecutionControl(db); + await reconcileAbandonedExecutionControl(db); + + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + const [retained] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, lease.id)); + expect(run).toMatchObject(state === "accepted" ? { status: "running", errorCode: null } + : { status: "failed", errorCode: "execution_finalization_deadline_exceeded" }); + if (state === "prepared" || state === "reassigned") { + expect(run.resultJson).toMatchObject({ workspaceRestoreRecovery: { leaseIds: [lease.id] } }); + expect(retained).toMatchObject({ status: "pending_cleanup", leasePolicy: "retain_on_failure", + metadata: { sandboxStopAndRetain: { runId: f.runId, providerLeaseId: lease.providerLeaseId } } }); + const recovery = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)); + expect(recovery.filter(action => action.evidence?.workspaceRestoreRecovery)).toMatchObject([ + { evidence: { runId: f.runId, workspaceRestoreRecovery: { leaseIds: [lease.id] } } }, + ]); + } else { + expect(run.resultJson ?? {}).not.toHaveProperty("workspaceRestoreRecovery"); + expect(retained).toMatchObject({ status: "active", leasePolicy: "reuse_by_environment" }); + } + const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId)); + if (state === "reassigned") { + expect(task.assigneeAgentId).not.toBe(f.agentId); + expect(task).toMatchObject({ status: "in_progress", executionRunId: null, checkoutRunId: null }); + } + expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1); + }); }); describeEmbeddedPostgres("native review execution reconciliation admission", () => { diff --git a/server/src/services/execution-control-reconciliation.ts b/server/src/services/execution-control-reconciliation.ts index 5b3a7aa3735..dd82e02b56a 100644 --- a/server/src/services/execution-control-reconciliation.ts +++ b/server/src/services/execution-control-reconciliation.ts @@ -14,6 +14,7 @@ import { issueRecoveryActionService } from "./issue-recovery-actions.js"; import { hasLiveLegacyController } from "./legacy-controller-lease.js"; import { reportRunFailure } from "./run-failure-report.js"; import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-runtime/native-review-participant.js"; +import { preserveNativeFailedWorkspace } from "./native-runtime/native-failed-workspace-recovery.js"; /** Only newly recorded control deadlines are eligible. Upgrades never replay ambiguous historical runs. */ export async function reconcileAbandonedExecutionControl( @@ -233,8 +234,10 @@ export async function reconcileAbandonedExecutionControl( ["done", "cancelled"].includes(task.status) || (task.executionRunId && task.executionRunId !== run.id) || (task.checkoutRunId && task.checkoutRunId !== run.id) - ) + ) { + if (updatedRun) await preserveNativeFailedWorkspace(tx as unknown as Db, updatedRun); return true; + } await tx .update(issues) .set({ executionRunId: null, checkoutRunId: null, updatedAt: now }) @@ -261,6 +264,7 @@ export async function reconcileAbandonedExecutionControl( maxAttempts: 3, supersedeOnIdentityChange: true, }); + if (updatedRun) await preserveNativeFailedWorkspace(tx as unknown as Db, updatedRun); return true; }); if (repaired) surfaced += 1; From dfedd73f569948a2b04145cffa038b554a0c5fd0 Mon Sep 17 00:00:00 2001 From: Tonio Date: Mon, 26 Oct 2026 12:26:44 -0700 Subject: [PATCH 03/11] feat(skills): add the directions-lab skill and its lab generator (#17437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Design explorations for the board UI are drawn as coded "directions labs": one page that shows what ships today next to numbered design directions, on every surface a feature appears. The artifact directions lab (#17213) is the first one, and it fed the Artifacts V1 work (#17228). > - That lab was built by hand. Each new lab would repeat the same plumbing: the URL state, the toolbar, the theme and animation toggles, the fetch stub, the frames, the capture script and the Storybook registration. > - Labs should also look and move the same way, so a shared link reads the same to everyone, and the design-proposals Storybook should be the place where every exploration is registered. > - This pull request adds the `directions-lab` agent skill. It asks how many directions and which surfaces, then a generator scaffolds a complete, verified lab in one command and registers it in the design-proposals Storybook. > - The benefit is that a designer gets a working lab with production next to the directions in minutes, and the agent spends its time on the drawing, not on the plumbing. ## Linked Issues or Issue Description Refs #17213 (the artifact directions lab, the precedent the templates were derived from) and #17228 (Artifacts V1, which shipped a direction from that lab). **Subsystem affected** Agent skills (`.agents/skills/`) for UI design work, the `ui/` Vite build, and the design-proposals section of the Storybook (`ui/storybook/stories/design-proposals/`). **Problem or motivation** A directions lab is the repo's way to compare design directions with production, but there is no repeatable way to make one. The artifact lab took many sessions of hand work, and a second lab would copy that work file by file and drift from the first. The animation details, the toggles and the Storybook metadata are easy to get wrong by hand. **Proposed solution** Add a skill that does the intake (two questions: how many directions, which surfaces) and a generator, `scripts/scaffold.mjs`, that writes a self-contained lab from templates derived from the artifact lab. The generator verifies the lab with the catalog check, the lab's tests and a Vite build, and removes everything again when a check fails. Every direction carries the V1 MVP hover motion verbatim, and a test in the skill fails when the precedent's motion block changes. **Alternatives considered** A shared runtime under `ui/src/directions-lab/` that every lab imports. It removes the per-lab copies, but it is product code under typecheck and a much larger change; it stays a follow-up. Instructions only, with no generator: the agent would rebuild the plumbing every time and the labs would drift. **Roadmap alignment** This fits the skills layer in `ROADMAP.md` (reusable procedures for agents). It does not change the Skills Manager, Skill Studio or the Skills Store, and it adds no product feature. ## What Changed - Add `.agents/skills/directions-lab/` with `SKILL.md`, `agents/openai.yaml` (Codex metadata) and the `.claude/skills/directions-lab` symlink, the same layout as the other repo skills. - Add `scripts/scaffold.mjs` with the subcommands `new`, `check`, `add-direction` and `list`, plus `scripts/lib/*`. - `new` renders in memory and refuses comment lines, files of 600 lines or more, and existing paths. - It then writes the lab, two `.claude/launch.json` entries and one `.gitignore` line. - It verifies the result, and rolls everything back on failure. - Add 34 templates under `assets/templates/` and the motion block `assets/motion/v1-mvp-motion.css`, derived from `ui/src/artifact-directions` at the commit recorded in `assets/PRECEDENT`. - A generated lab has: - an html entry and a Vite config; - a registry that the toolbar, keyboard digits, Board and capture script read at runtime; - a toolbar whose surfaces row is written only for a multi-surface lab; - a Today kit stub, one kit per direction with a motion specimen, five Vitest files and `shot.cjs`; - a design-proposals story with meta id `design--directions` and a literal `parameters.paperclip` block (`status: "proposed"`, `home: "design-proposals"`). - Add ten references: - lab anatomy; - Today, fixtures, build and deploy; - the motion spec; - questions and defaults; - drawing phases; - design-proposals Storybook registration; - lifecycle; - repo gates; - the generator; - `canonical-directory.md`, the paste-ready governance diffs that would make the design-proposals section the source of truth. They are for a separate pull request and are not applied here. - Add three eval cases under `evals/`: a two-surface lab with three directions, a one-surface lab with two directions, and a terse brief that must make the skill ask how many directions. ## Verification - `node --test .agents/skills/directions-lab/scripts/scaffold.test.mjs`: 29 tests pass in under a second. The tests cover flag parsing, context derivation and refused inputs, ports, the write guards and the shared-file edits. They also render the three eval shapes into a temporary root, check the surfaces-row rule and the mode-B story status rule, and run the motion drift test against `ui/src/artifact-directions/directions/d5-v1-mvp/tokens.css`. - A real scaffold into this checkout at the branch head: ```sh node .agents/skills/directions-lab/scripts/scaffold.mjs new --slug prsmoke --title "PR smoke" --surfaces thread:Task-thread:960@tasks,panel:Right-panel:434@tasks --directions 3 ``` The built-in checks passed: `node scripts/storybook-catalog.mjs --check`, `npx vitest run src/prsmoke-directions` and the lab's Vite build. The lab was then removed, and `git status` shows only the skill. - Browser check on a scaffolded lab (composer, two surfaces, three directions), done earlier in the same session: - The toolbar shows both rows with Today last as `0.Today`, and the theme and animation toggles. - A hovered motion specimen computes `translateY(-6px)` on the front, and the backings turn ±5° from their resting angles with a 300 ms transition. - After pressing `a`, nothing moves. - `add-direction` and `check --json` were proven on a second scaffolded lab: the toolbar legend reads `0–3` from the registry without a hand edit. - The root Vitest run is unaffected: `vitest.config.ts` lists explicit projects, and `.agents` is not one of them. The skill tests with `node --test` like the sibling skills. - Not run: `pnpm -r typecheck`, `pnpm build` and `pnpm build-storybook`. This change adds only files under the git-ignored `.agents/` tree plus one symlink, and touches no product code. ## Risks Low risk. The pull request adds a skill and a symlink and changes no product code, no dependencies and no lockfile. A lab that someone later scaffolds lands under `ui/src/`, where `tsc -b` and `pnpm test` see it. The generator verifies each lab and rolls back on failure, so a half-written lab is not left behind. `/.agents/` is git-ignored, so later edits to the skill must be force-added (`git add -f`), as for the other repo skills. The templates are copies of the precedent. When the artifact lab changes, the skill's drift test fails until the templates are updated and `assets/PRECEDENT` is bumped. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Fable 5.1 (Anthropic), model ID `claude-fable-5-1`, with extended thinking, for most of the work: mapping the precedent, the references, the generator design and implementation, and the proofs. Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, for the final cleanup and this pull request. Both ran in Claude Code with tool use (file edits, shell, the in-app browser) and multi-agent workflows for parallel mapping, judging and verification. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .agents/skills/directions-lab/SKILL.md | 294 ++++++++++ .../skills/directions-lab/agents/openai.yaml | 4 + .../skills/directions-lab/assets/PRECEDENT | 1 + .../assets/motion/v1-mvp-motion.css | 21 + .../assets/templates/api-routes.ts.mjs | 16 + .../assets/templates/api-stub.ts.mjs | 53 ++ .../assets/templates/build-info.ts.mjs | 5 + .../assets/templates/define-lab.ts.mjs | 77 +++ .../direction/MotionSpecimen.tsx.mjs | 56 ++ .../templates/direction/SlotBody.tsx.mjs | 15 + .../assets/templates/direction/index.tsx.mjs | 11 + .../assets/templates/direction/tokens.css.mjs | 30 + .../assets/templates/entry.html.mjs | 27 + .../assets/templates/fixtures.ts.mjs | 23 + .../assets/templates/frames/AppShell.tsx.mjs | 159 +++++ .../assets/templates/frames/Board.tsx.mjs | 124 ++++ .../templates/frames/LabToolbar.tsx.mjs | 105 ++++ .../assets/templates/frames/Slot.tsx.mjs | 11 + .../templates/frames/SurfaceFrame.tsx.mjs | 32 + .../templates/frames/SurfaceView.tsx.mjs | 13 + .../assets/templates/kit.ts.mjs | 49 ++ .../assets/templates/lab-state.ts.mjs | 47 ++ .../assets/templates/lab.css.mjs | 4 + .../assets/templates/launch-entries.json.mjs | 19 + .../assets/templates/main.tsx.mjs | 185 ++++++ .../assets/templates/providers.tsx.mjs | 25 + .../assets/templates/registry.ts.mjs | 35 ++ .../assets/templates/shot.cjs.mjs | 240 ++++++++ .../templates/stories/compare.stories.tsx.mjs | 89 +++ .../templates/tests/api-stub.test.ts.mjs | 46 ++ .../templates/tests/lab-state.test.ts.mjs | 64 ++ .../tests/motion-contract.test.ts.mjs | 118 ++++ .../templates/tests/registry.test.ts.mjs | 55 ++ .../assets/templates/tests/today.test.tsx.mjs | 67 +++ .../assets/templates/today/SlotBody.tsx.mjs | 22 + .../assets/templates/today/adapters.ts.mjs | 16 + .../assets/templates/today/index.tsx.mjs | 10 + .../assets/templates/vite.config.mjs.mjs | 96 +++ .../evals/asks-how-many-directions/case.yaml | 4 + .../graders/01-expected-output.md | 7 + .../evals/asks-how-many-directions/prompt.md | 8 + .../multi-surface-three-directions/case.yaml | 4 + .../graders/01-expected-output.md | 7 + .../multi-surface-three-directions/prompt.md | 8 + .../single-surface-two-directions/case.yaml | 4 + .../graders/01-expected-output.md | 7 + .../single-surface-two-directions/prompt.md | 8 + .../references/canonical-directory.md | 246 ++++++++ .../references/design-proposals-storybook.md | 397 +++++++++++++ .../references/drawing-phases.md | 112 ++++ .../directions-lab/references/generator.md | 303 ++++++++++ .../directions-lab/references/lab-anatomy.md | 351 +++++++++++ .../directions-lab/references/lifecycle.md | 253 ++++++++ .../directions-lab/references/motion-spec.md | 351 +++++++++++ .../references/questions-and-defaults.md | 221 +++++++ .../directions-lab/references/repo-gates.md | 355 +++++++++++ .../references/today-fixtures-and-build.md | 327 +++++++++++ .../directions-lab/scripts/lib/args.mjs | 47 ++ .../scripts/lib/capabilities.mjs | 49 ++ .../directions-lab/scripts/lib/check.mjs | 182 ++++++ .../directions-lab/scripts/lib/context.mjs | 174 ++++++ .../directions-lab/scripts/lib/modes.mjs | 25 + .../directions-lab/scripts/lib/ports.mjs | 46 ++ .../scripts/lib/registry-edit.mjs | 89 +++ .../directions-lab/scripts/lib/render.mjs | 83 +++ .../scripts/lib/shared-files.mjs | 84 +++ .../directions-lab/scripts/lib/verify.mjs | 49 ++ .../directions-lab/scripts/lib/write.mjs | 63 ++ .../directions-lab/scripts/scaffold.mjs | 282 +++++++++ .../directions-lab/scripts/scaffold.test.mjs | 554 ++++++++++++++++++ .claude/skills/directions-lab | 1 + 71 files changed, 6965 insertions(+) create mode 100644 .agents/skills/directions-lab/SKILL.md create mode 100644 .agents/skills/directions-lab/agents/openai.yaml create mode 100644 .agents/skills/directions-lab/assets/PRECEDENT create mode 100644 .agents/skills/directions-lab/assets/motion/v1-mvp-motion.css create mode 100644 .agents/skills/directions-lab/assets/templates/api-routes.ts.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/api-stub.ts.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/build-info.ts.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/define-lab.ts.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/direction/MotionSpecimen.tsx.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/direction/SlotBody.tsx.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/direction/index.tsx.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/direction/tokens.css.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/entry.html.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/fixtures.ts.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/frames/AppShell.tsx.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/frames/Board.tsx.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/frames/LabToolbar.tsx.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/frames/Slot.tsx.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/frames/SurfaceFrame.tsx.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/frames/SurfaceView.tsx.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/kit.ts.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/lab-state.ts.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/lab.css.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/launch-entries.json.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/main.tsx.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/providers.tsx.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/registry.ts.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/shot.cjs.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/stories/compare.stories.tsx.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/tests/api-stub.test.ts.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/tests/lab-state.test.ts.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/tests/motion-contract.test.ts.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/tests/registry.test.ts.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/tests/today.test.tsx.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/today/SlotBody.tsx.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/today/adapters.ts.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/today/index.tsx.mjs create mode 100644 .agents/skills/directions-lab/assets/templates/vite.config.mjs.mjs create mode 100644 .agents/skills/directions-lab/evals/asks-how-many-directions/case.yaml create mode 100644 .agents/skills/directions-lab/evals/asks-how-many-directions/graders/01-expected-output.md create mode 100644 .agents/skills/directions-lab/evals/asks-how-many-directions/prompt.md create mode 100644 .agents/skills/directions-lab/evals/multi-surface-three-directions/case.yaml create mode 100644 .agents/skills/directions-lab/evals/multi-surface-three-directions/graders/01-expected-output.md create mode 100644 .agents/skills/directions-lab/evals/multi-surface-three-directions/prompt.md create mode 100644 .agents/skills/directions-lab/evals/single-surface-two-directions/case.yaml create mode 100644 .agents/skills/directions-lab/evals/single-surface-two-directions/graders/01-expected-output.md create mode 100644 .agents/skills/directions-lab/evals/single-surface-two-directions/prompt.md create mode 100644 .agents/skills/directions-lab/references/canonical-directory.md create mode 100644 .agents/skills/directions-lab/references/design-proposals-storybook.md create mode 100644 .agents/skills/directions-lab/references/drawing-phases.md create mode 100644 .agents/skills/directions-lab/references/generator.md create mode 100644 .agents/skills/directions-lab/references/lab-anatomy.md create mode 100644 .agents/skills/directions-lab/references/lifecycle.md create mode 100644 .agents/skills/directions-lab/references/motion-spec.md create mode 100644 .agents/skills/directions-lab/references/questions-and-defaults.md create mode 100644 .agents/skills/directions-lab/references/repo-gates.md create mode 100644 .agents/skills/directions-lab/references/today-fixtures-and-build.md create mode 100644 .agents/skills/directions-lab/scripts/lib/args.mjs create mode 100644 .agents/skills/directions-lab/scripts/lib/capabilities.mjs create mode 100644 .agents/skills/directions-lab/scripts/lib/check.mjs create mode 100644 .agents/skills/directions-lab/scripts/lib/context.mjs create mode 100644 .agents/skills/directions-lab/scripts/lib/modes.mjs create mode 100644 .agents/skills/directions-lab/scripts/lib/ports.mjs create mode 100644 .agents/skills/directions-lab/scripts/lib/registry-edit.mjs create mode 100644 .agents/skills/directions-lab/scripts/lib/render.mjs create mode 100644 .agents/skills/directions-lab/scripts/lib/shared-files.mjs create mode 100644 .agents/skills/directions-lab/scripts/lib/verify.mjs create mode 100644 .agents/skills/directions-lab/scripts/lib/write.mjs create mode 100644 .agents/skills/directions-lab/scripts/scaffold.mjs create mode 100644 .agents/skills/directions-lab/scripts/scaffold.test.mjs create mode 120000 .claude/skills/directions-lab diff --git a/.agents/skills/directions-lab/SKILL.md b/.agents/skills/directions-lab/SKILL.md new file mode 100644 index 00000000000..5d505c8b252 --- /dev/null +++ b/.agents/skills/directions-lab/SKILL.md @@ -0,0 +1,294 @@ +--- +name: directions-lab +description: Scaffold a directions lab that shows shipped UI (Today) beside 2-3 design directions, with surface tabs, theme and animation toggles and the V1 MVP hover motion, registered in design-proposals Storybook. Use for a directions page or lab, or to explore design directions next to production. +--- + +# Directions lab + +A directions lab is how design explorations are made in this repo: one page, +one feature, Today plus two or three directions, every surface the feature +appears on, and the same controls every time, so a shared link reads the same +to everyone. The artifact directions lab (`ui/src/artifact-directions`) is the +precedent and stays the frozen reference; this skill generates new labs in the +same shape without redrawing the plumbing by hand. + +## What you are making + +- **The page.** `ui/-directions.html` plus `ui/src/-directions/`, + a second Vite entry with no backend. All state lives in the URL + (`?d=&s=&theme=&chrome=&anim=`, plus `proposal=1` for the proposal palette). + A registry module is the single source for the direction ids, digit keys, + labels, surfaces, landing view and story id; the toolbar, keyboard, overview + board and capture script read it at runtime, and `add-direction` keeps the + story's per-direction export in step, so a direction is one registry entry + plus one directory. +- **The toolbar.** Row 1 is the directions row: the numbered directions then + `0.Today` last, as a segmented control, with Theme and Animation + right-aligned. Row 2 is the surfaces row: Overview plus one tab per surface. + Row 2 is written only when the lab is scaffolded with more than one surface + (a surface added later to a one-surface lab needs row 2 by hand, + `references/lab-anatomy.md` section 8); with one surface the keyboard legend + joins row 1. Nothing else goes in the header. +- **Today.** Always present, always `0.Today`, always the shipped components + mounted on the lab's fixtures through small adapters and a fetch stub. It is + the control every direction is judged against, so it is never a redraw. +- **The directions.** Two or three, numbered, each a self-contained kit + directory with its own scoped `tokens.css`. Every direction carries the V1 + MVP motion block verbatim and a test that proves it, so hover feels the same + across labs. Read `references/motion-spec.md` before touching any of it. +- **The directory entry.** One story file under + `ui/storybook/stories/design-proposals/` with a stable id, catalogued as a + proposal. The design-proposals Storybook is becoming the canonical directory + of the design system; a lab exists there from its first commit. + +Hard rules, because the repo's gates enforce them and reviewers expect them: +no code comments in anything you commit, no file of 600 lines or more in a lab +(`check` refuses at 600), no +`package.json` script, no lockfile change, no screenshots or generated catalog +files in the repo. + +## Before you start + +Run from the repo root on a branch of your own (`claude/-directions`, +from `origin/master`). Check that `ui/node_modules/.bin/vite` exists; if not, +`pnpm install --frozen-lockfile`. Then run the generator's own tests: + +```sh +node --test .agents/skills/directions-lab/scripts/scaffold.test.mjs +``` + +Red here usually means a template or the motion asset has drifted from the +precedent (`ui/src/artifact-directions/directions/d5-v1-mvp/tokens.css`); two +cases also read the real feature map and the catalog script, so a changed +feature-map row or catalog message shows up here too. Fix the generator before +scaffolding anything; a lab built on a stale template fails later in ways that +look like your drawing. + +## Read the brief, then ground the subject + +Extract four things from what Tonio wrote: the subject (its slug and title), +the places it appears (one surface per place), a stated count or names of the +directions, and any flag or component names. Then look at the code before you +ask anything: + +```sh +rg -l "" ui/src/components ui/src/pages +rg -n "" maps/feature-map/*.md +``` + +The first grep gives the component files, the flag hook +(`useEnabled`), and, by following `useQuery`/`queryFn` into +`ui/src/api/*`, the `/api/` routes Today must answer. The second gives the +capability ids the story cites. This grounding is what makes the surfaces +question specific instead of generic. `references/questions-and-defaults.md` +has the full extraction and derivation tables. + +## Ask, in one call + +Ask at most two questions, in a single `AskUserQuestion` call, and skip each +one the brief already answers: + +1. **How many directions?** Options `2` and `3`. Say in the question that + three unnamed directions scaffold as `1.Refine` (tokens and spacing, same + structure), `2.Rework` (new components, same layout) and `3.Rethink` (new + layout), two as `1.Refine` and `2.Rethink`, and are renamed later. Skip + when the brief states a count or names the directions. +2. **Which surfaces?** Multi-select from the usage sites the grep found, plus + "only one place". Two or more selections produce the surfaces row. Skip + when the brief names the places. + +Never ask about theme, animation, Today, ports, the slug or the landing view; +those are fixed by the skill. When no question can be asked (a headless run), +write this before doing anything else, then proceed: "Defaulting to three +directions and one surface () because I cannot ask; change later with +`scaffold.mjs add-direction`." + +## State what you derived, then scaffold + +Before running the generator, state every derived value in your reply: slug +and title, each surface as `id:Label:width@nav` with the line the width comes +from, the direction count and names, the group, the capabilities with their +feature-map rows (or `[]` and the exception sentence), the flag hook and the +`/api/` paths Today will need. A wrong slug or capability is cheap to catch +here and expensive after files exist. Then: + +```sh +node .agents/skills/directions-lab/scripts/scaffold.mjs new \ + --slug composer --title "Composer" \ + --surfaces thread:Task-thread:960@tasks,panel:Right-panel:434@tasks \ + --directions 3 --dry-run +``` + +Read the plan (paths, ports, cells, story), drop `--dry-run`, and run it. +`new` renders everything in memory, refuses existing paths, writes the lab, +the html entry, the Vite config, the story file, two `.claude/launch.json` +entries and one `.gitignore` line, then verifies: the Storybook catalog check, +the lab's own Vitest files, and a Vite build. On any failure it deletes what +it wrote and restores the two shared files, so a half-written lab never +breaks `pnpm test` or `tsc -b` for the whole repo (`ui/vitest.config.ts` has no +include filter and `ui/tsconfig.json` includes all of `src`). Quote the +`--json` output in your reply. + +`--surfaces` takes `id:Label:width@nav` per place, where `nav` is the sidebar +item the shell highlights (`tasks`, `artifacts` or `other`) and the width is the +product's own constant for that place (see `references/questions-and-defaults.md`). +Flags you will use: `--names a,b,c` for stated names, `--group +Components|Pages|Layouts|Patterns` (Components when the grep hits +`ui/src/components`), `--capabilities area/key,...` (validated against +`maps/feature-map`; omit for `[]` plus an exception sentence), `--company +name:PREFIX` (default `acmehats:ACM`), `--landing auto|all|today|`, +`--ports dev,preview` (the generator picks free ones), `--keep-on-failure` to +inspect a failed scaffold. + +## Look at it + +Start the dev entry the generator added, `preview_start +{name: "-directions-dev"}`, and open three URLs: `/` (the landing), +`/?d=today&s=` and `/?d=&s=` +(`d1-refine` when the directions are unnamed). Confirm +`document.documentElement.dataset.labAnim === "1"`, hover a motion specimen +and watch the front rise and the two pages turn, press `a`, hover again and +see only the fill change. Read the console for `[-directions] unhandled` +lines: each one is a route Today will need. The overview board and the +capture script never show the movement (iframes take no pointer; stills ask +for reduced motion; `--hover` records one hovered frame, not the transition), +so the transition is verified only this way. + +## Commit the scaffold, then draw + +Commit the untouched scaffold first as `feat(ui): scaffold the +directions lab`. Reviewers then see the redraw as its own diff, not buried +under plumbing. Draw in this order; `references/drawing-phases.md` has the +done-when for each: + +1. **Frames.** Replace each `data-lab-stub="frame-<surface>"` region with the + product chrome around that place: copy the product's class strings, freeze + the registry width, reuse presentational primitives, let nothing scroll + internally. The precedent frames show how (`ThreadFrame`, `PanelFrame`, + `GalleryFrame`, `AppShell`). +2. **Fixtures and Today.** Retype the per-surface data in `kit.ts` to the + props each place receives, fill `fixtures.ts`, mount the shipped component + in `directions/today/<Pascal>Body.tsx` (PascalCase of the surface id, so + `ThreadBody.tsx` for `thread`) through `adapters.ts`, and register exactly + the routes it fetches inside `registerFeatureRoutes()` in `api-routes.ts`. + Today is done when it matches the product on the same data and its test + passes with the real component. +3. **Directions.** One at a time: replace the motion specimen with the + drawing, keep the hook names (`data-front` on every treatment branch, + `data-rotate` and `data-spread` with the inline base angle), put every raw + value in that direction's `tokens.css` (light base, `.dark` override), and + leave the motion block alone. Run `scaffold.mjs check --slug <slug>` after + each phase. + +Phase 2 comes before 3 because the slot props change shape in phase 2 and +every kit follows the type. + +## Capture + +```sh +node ui/src/<slug>-directions/shot.cjs --port <dev> --d all --out <scratch dir> +``` + +Captures are rest states at 1440×900, DPR 2, both themes; attach the PNGs to +the PR, never to the repo. `--hover` adds a rest/hover pair per direction from +a second browser context that allows motion; the browser pane with the Animation +toggle on is still the only place to judge the motion itself. + +## Hand off + +```sh +node .agents/skills/directions-lab/scripts/scaffold.mjs check --slug <slug> --strict --run +``` + +`--strict` refuses any remaining `data-lab-stub` marker; `--run` runs the +lab's tests, the catalog check, the token gates, a Vite build of the lab and +`cd ui && pnpm typecheck`, stopping at the first failure, and prints a +`## Verification` block whose `Not run:` lines name what stays manual +(`pnpm build-storybook`, the repo-wide typecheck); `--json` returns the same +results as data, with those items under `notRun`. Open the PR with every +section of `.github/PULL_REQUEST_TEMPLATE.md` filled, the linked-issue path B +with three filled feature-request labels, the story id, the launch entries +and the Vercel alias if you deployed. Offer the DESIGN.md directory row as +text in the PR body; never apply it from a lab PR. + +If the skill itself is in your diff, force-add it: `/.agents/` is git-ignored +and the repo's tracked skills were added with `git add -f`. + +## Storybook and the design directory + +`new` writes `ui/storybook/stories/design-proposals/<slug>-directions.stories.tsx` +with meta id `design-<slug>-directions`, title `Design proposals/<Group>/<Title> +directions/Compare`, a literal `parameters.paperclip` block (`status: +"proposed"`, `home: "design-proposals"`, `implementation: "prototype"`, +`boundary: "fixtures"`, capabilities, exception, notes) and stories Compare, +Today, one per direction, and `TodayProductionPalette` (the decorator themes +the whole iframe with the proposal palette; this one opts out so Today can be +seen as shipped). The page itself renders the production palette by default; +`?proposal=1` wraps it in the proposal theme for a side-by-side with the entry. +The catalog script validates all of it at Storybook start, in +`cd ui && pnpm typecheck` and in CI. + +The lifecycle a direction moves through, and what to edit by hand at each +step, is in `references/lifecycle.md`: proposed (what `new` writes), adopted +(product code behind an experimental setting, with a feature-home story), +current (flag removed), historical (retired with a `replacement`, moved out of +the proposals folder keeping its id). Today the catalog forces every entry in +the proposals folder to stay `proposed`; the governance change that would let +the directory carry `current` entries is written up as paste-ready diffs in +`references/canonical-directory.md`. Offer it once, as its own PR, only when +Tonio says yes. + +## Later asks + +- Add a direction: `scaffold.mjs add-direction --slug <slug> --name <Name>` + (next digit, registry entry, kit directory with the motion block and a + specimen, the story's `D<N>` export, then `check`, rolling back on failure). + The toolbar legend follows the registry at runtime. The generator refuses a + fourth without `--force-count`, which also records `allowMore` in the + registry; the story description's "1–N" sentence is the one thing to + update by hand. Once phase 2 has retyped the slot props in `kit.ts`, + `add-direction` refuses and prints the by-hand steps instead, because a + generated kit would not fit the new props. +- List labs: `scaffold.mjs list`. +- Rename a direction, add a surface, or move a direction through the + lifecycle: by hand, following `references/lab-anatomy.md` section 5 and 8 + and `references/lifecycle.md`. +- Publish: build with `<SLUG>_DIRECTIONS_OUT_DIR=<dir>/paperclip-<slug>-directions`, + remove `sw.js` from the output, `vercel deploy <that dir> --prod --yes` from + its parent; the basename names the project and the alias is the public URL. + `references/today-fixtures-and-build.md` section 11 has the details. + +## Do not + +- Run two full `ui` Vitest suites at once; run the lab's files alone. +- Land on `d=all&s=board` from a dev server with more than eight cells; use + the static launch entry (`<slug>-directions`) for the full board. +- Copy the artifact lab's `html:not(.dark)` token idiom; new labs put light + values in the base block and dark ones under `.dark [data-direction=…]`. +- Edit the motion block in a lab. A different rise or fan is Tonio's decision. +- Add comments, `package.json` scripts, lockfile changes, PAP ids or localhost + URLs to anything that goes in the PR. + +## Maintaining the skill + +The generator's templates were derived from the precedent at the commit +recorded in `assets/PRECEDENT`. `scripts/scaffold.test.mjs` pins the generated +structure and asserts the motion asset still equals the precedent's block +after substitution; when the precedent changes, re-derive the template and +bump the pin. `references/generator.md` explains the template conventions +and the context object. + +## References + +| File | Read it when | +| --- | --- | +| `references/questions-and-defaults.md` | Before asking; derivation tables, eval prompts as flag lines | +| `references/lab-anatomy.md` | Drawing frames or changing the toolbar, state or board | +| `references/today-fixtures-and-build.md` | Mounting Today, the fetch stub, build, capture, deploy | +| `references/motion-spec.md` | Any hover motion; the per-direction checklist | +| `references/drawing-phases.md` | After the scaffold, phase by phase | +| `references/design-proposals-storybook.md` | The story file, the catalog gate, the theme | +| `references/lifecycle.md` | Adopting, shipping or retiring a direction | +| `references/canonical-directory.md` | Only when Tonio approves the governance PR | +| `references/repo-gates.md` | Before the PR | +| `references/generator.md` | Changing the generator | diff --git a/.agents/skills/directions-lab/agents/openai.yaml b/.agents/skills/directions-lab/agents/openai.yaml new file mode 100644 index 00000000000..1ad24823d4c --- /dev/null +++ b/.agents/skills/directions-lab/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Directions Lab" + short_description: "Scaffold a Today-plus-directions lab page registered in the design-proposals Storybook" + default_prompt: "Use $directions-lab to stand up a directions lab for <feature> that shows what ships today next to 2-3 explorations." diff --git a/.agents/skills/directions-lab/assets/PRECEDENT b/.agents/skills/directions-lab/assets/PRECEDENT new file mode 100644 index 00000000000..127faa80685 --- /dev/null +++ b/.agents/skills/directions-lab/assets/PRECEDENT @@ -0,0 +1 @@ +5e26b733b 2026-10-24 origin/master at derivation; templates mirror ui/src/artifact-directions, ui/artifact-directions.html, ui/vite.artifact-directions.config.mjs and .claude/launch.json diff --git a/.agents/skills/directions-lab/assets/motion/v1-mvp-motion.css b/.agents/skills/directions-lab/assets/motion/v1-mvp-motion.css new file mode 100644 index 00000000000..7e8e16f6d97 --- /dev/null +++ b/.agents/skills/directions-lab/assets/motion/v1-mvp-motion.css @@ -0,0 +1,21 @@ +[data-direction="<id>"] [data-rotate] { + transform: rotate(var(--<p>-base, 0deg)); +} + +[data-direction="<id>"] { + --<p>-hover-ease: 300ms cubic-bezier(0.3, 0, 0.1, 1); +} +@media (prefers-reduced-motion: no-preference) { + html[data-lab-anim="1"] [data-direction="<id>"] :is([data-rotate][data-spread], [data-front]) { + transition: transform var(--<p>-hover-ease); + } + html[data-lab-anim="1"] [data-direction="<id>"] button:hover [data-front] { + transform: translateY(-6px); + } + html[data-lab-anim="1"] [data-direction="<id>"] button:hover [data-spread="left"] { + transform: rotate(calc(var(--<p>-base, 0deg) + 5deg)); + } + html[data-lab-anim="1"] [data-direction="<id>"] button:hover [data-spread="right"] { + transform: rotate(calc(var(--<p>-base, 0deg) - 5deg)); + } +} diff --git a/.agents/skills/directions-lab/assets/templates/api-routes.ts.mjs b/.agents/skills/directions-lab/assets/templates/api-routes.ts.mjs new file mode 100644 index 00000000000..4942af1f027 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/api-routes.ts.mjs @@ -0,0 +1,16 @@ +export default () => `import { registerApiRoute } from "./api-stub"; +import { registry } from "./registry"; + +let flags: Record<string, boolean> = { ...registry.today.flags }; + +export const currentFlags = (): Record<string, boolean> => flags; + +export function setLabFlags(next: Record<string, boolean>): void { + flags = { ...next }; +} + +export function registerFeatureRoutes(): void {} + +registerApiRoute((pathname) => (pathname.endsWith("/instance/settings/experimental") ? currentFlags() : undefined)); +registerFeatureRoutes(); +`; diff --git a/.agents/skills/directions-lab/assets/templates/api-stub.ts.mjs b/.agents/skills/directions-lab/assets/templates/api-stub.ts.mjs new file mode 100644 index 00000000000..6d1d28dd0f5 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/api-stub.ts.mjs @@ -0,0 +1,53 @@ +export default (ctx) => `import { COMPANY } from "./fixtures"; + +export type ApiRoute = (pathname: string, method: string) => unknown | undefined; + +const routes: ApiRoute[] = []; + +export function registerApiRoute(route: ApiRoute): void { + routes.push(route); +} + +const json = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + +function respond(pathname: string, method: string): Response { + for (const route of routes) { + const body = route(pathname, method); + if (body !== undefined) return json(body); + } + if (pathname.endsWith("/auth/get-session")) { + return json({ session: { id: "lab-session", userId: "lab-user" }, user: { id: "lab-user", name: "Lab", email: \`lab@\${COMPANY.name}.example\` } }); + } + if (pathname.endsWith("/companies")) return json([COMPANY]); + console.debug("${ctx.tag} unhandled", method, pathname); + return json({}); +} + +let holders = 0; +let realFetch: typeof window.fetch | null = null; +let stubFetch: typeof window.fetch | null = null; + +export function installApiStub(): () => void { + if (holders === 0) { + const original = window.fetch; + const real = original.bind(window); + realFetch = original; + stubFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (!url.includes("/api/")) return real(input as RequestInfo, init); + const { pathname } = new URL(url, window.location.origin); + return respond(pathname, (init?.method ?? "GET").toUpperCase()); + }) as typeof window.fetch; + window.fetch = stubFetch; + } + holders += 1; + let released = false; + return () => { + if (released) return; + released = true; + holders -= 1; + if (holders === 0 && realFetch && window.fetch === stubFetch) window.fetch = realFetch; + }; +} +`; diff --git a/.agents/skills/directions-lab/assets/templates/build-info.ts.mjs b/.agents/skills/directions-lab/assets/templates/build-info.ts.mjs new file mode 100644 index 00000000000..2697fb3f7f4 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/build-info.ts.mjs @@ -0,0 +1,5 @@ +export const envDts = (ctx) => `declare const ${ctx.buildDefine}: string | undefined; +`; + +export default (ctx) => `export const LAB_BUILD: string = typeof ${ctx.buildDefine} === "string" ? ${ctx.buildDefine} : "dev"; +`; diff --git a/.agents/skills/directions-lab/assets/templates/define-lab.ts.mjs b/.agents/skills/directions-lab/assets/templates/define-lab.ts.mjs new file mode 100644 index 00000000000..f2e14d12cb8 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/define-lab.ts.mjs @@ -0,0 +1,77 @@ +export default () => `export type ShellNav = "tasks" | "artifacts" | "other"; +export type LabStage = "shipped" | "proposed" | "adopted" | "current" | "historical"; + +export interface LabSurface { + readonly id: string; + readonly label: string; + readonly width: number; + readonly nav: ShellNav; +} + +export interface LabDirection { + readonly id: string; + readonly key: string; + readonly name: string; + readonly tagline: string; + readonly baseline?: boolean; + readonly stage: LabStage; +} + +export interface LabTask { + readonly identifier: string; + readonly title: string; +} + +export interface LabRegistry<D extends LabDirection = LabDirection, S extends LabSurface = LabSurface> { + readonly slug: string; + readonly title: string; + readonly storyId: string; + readonly group: string; + readonly capabilities: readonly string[]; + readonly publicUrl: string; + readonly surfaces: readonly S[]; + readonly directions: readonly D[]; + readonly baselinePosition: "last"; + readonly landing: { readonly direction: D["id"] | "all"; readonly view: S["id"] | "board" }; + readonly today: { readonly flags: Readonly<Record<string, boolean>> }; + readonly shell: { + readonly company: { readonly id: string; readonly name: string; readonly issuePrefix: string }; + readonly tasks: readonly LabTask[]; + }; + readonly provenance: { readonly runtimeCommit: string; readonly skillVersion: string; readonly createdAt: string }; + readonly allowMore?: boolean; + readonly allow: readonly string[]; +} + +function fail(slug: string, message: string): never { + throw new Error(\`defineLab(\${slug}): \${message}\`); +} + +export function defineLab<const D extends LabDirection, const S extends LabSurface>(registry: LabRegistry<D, S>): LabRegistry<D, S> { + const { slug, directions, surfaces, landing } = registry; + if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(slug)) fail(slug, "slug must be kebab-case"); + const ids: string[] = directions.map((direction) => direction.id); + if (new Set(ids).size !== ids.length) fail(slug, "direction ids must be unique"); + const keys = directions.map((direction) => direction.key); + if (new Set(keys).size !== keys.length || keys.some((key) => !/^[0-9]$/.test(key))) fail(slug, "direction keys must be unique single digits"); + const baselines = directions.filter((direction) => direction.baseline); + if (baselines.length !== 1 || baselines[0].id !== "today" || baselines[0].key !== "0") fail(slug, "exactly one baseline, with id today and key 0"); + if (directions[directions.length - 1]?.id !== "today") fail(slug, "the baseline comes last"); + const explorations = directions.filter((direction) => !direction.baseline); + if (!registry.allowMore && (explorations.length < 2 || explorations.length > 3)) fail(slug, "2 or 3 explorations unless allowMore is set"); + for (const direction of explorations) { + const match = /^d([1-9])-[a-z0-9]+(-[a-z0-9]+)*$/.exec(direction.id); + if (!match) fail(slug, \`exploration id \${direction.id} must be d<N>-<name>\`); + if (match[1] !== direction.key) fail(slug, \`exploration \${direction.id} must have key \${match[1]}\`); + if (!direction.name.startsWith(\`\${direction.key}.\`)) fail(slug, \`exploration \${direction.id} must be named "\${direction.key}.<Name>"\`); + } + if (surfaces.length < 1 || surfaces.length > 6) fail(slug, "1 to 6 surfaces"); + const surfaceIds: string[] = surfaces.map((surface) => surface.id); + if (new Set(surfaceIds).size !== surfaceIds.length) fail(slug, "surface ids must be unique"); + if (surfaceIds.includes("board")) fail(slug, "board is the overview, not a surface"); + if (surfaces.some((surface) => !(surface.width > 0))) fail(slug, "surface widths must be positive"); + if (landing.direction !== "all" && !ids.includes(landing.direction)) fail(slug, "landing direction must be a direction id or all"); + if (landing.view !== "board" && !surfaceIds.includes(landing.view)) fail(slug, "landing view must be a surface id or board"); + return registry; +} +`; diff --git a/.agents/skills/directions-lab/assets/templates/direction/MotionSpecimen.tsx.mjs b/.agents/skills/directions-lab/assets/templates/direction/MotionSpecimen.tsx.mjs new file mode 100644 index 00000000000..b4381750568 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/direction/MotionSpecimen.tsx.mjs @@ -0,0 +1,56 @@ +export default (ctx, direction) => { + const p = direction.prefix; + return `import type { CSSProperties } from "react"; +import type { LabRecord } from "../../kit"; + +const BOX = { width: 168, height: 150 }; +const SHEET = { width: 112, height: 134 }; +const FRONT = { left: (BOX.width - SHEET.width) / 2, top: 8 }; +const BACKINGS = [ + { spread: "left", rotate: -14, left: FRONT.left - 14, top: FRONT.top + 4 }, + { spread: "right", rotate: -5, left: FRONT.left + 14, top: FRONT.top + 4 }, +] as const; + +type Backing = (typeof BACKINGS)[number]; + +function BackingSheet({ spread, rotate, left, top }: Backing) { + const style = { + left, + top, + width: SHEET.width, + height: SHEET.height, + transformOrigin: \`\${SHEET.width / 2}px \${SHEET.height / 2}px\`, + filter: "var(--${p}-shadow-sheet)", + "--${p}-base": \`\${rotate}deg\`, + } as CSSProperties; + return <div data-rotate data-spread={spread} aria-hidden className="absolute rounded-(--${p}-sheet-radius) bg-(--${p}-paper)" style={style} />; +} + +export function MotionSpecimen({ item }: { item: LabRecord }) { + return ( + <button + type="button" + aria-label={\`Open \${item.title}\`} + className="flex h-(--${p}-tile-h) w-full flex-col items-center gap-3 rounded-(--${p}-tile-radius) px-4 pt-4 text-left outline-none transition-colors hover:bg-(--${p}-tile-hover) focus-visible:bg-(--${p}-tile-hover) focus-visible:ring-2 focus-visible:ring-ring" + > + <div className="relative shrink-0" style={{ width: BOX.width, height: BOX.height }}> + {BACKINGS.map((backing) => ( + <BackingSheet key={backing.spread} {...backing} /> + ))} + <div + data-front + className="absolute flex items-end rounded-(--${p}-sheet-radius) bg-(--${p}-paper) p-2 text-(length:--text-micro) leading-4 text-(--${p}-ink)" + style={{ left: FRONT.left, top: FRONT.top, width: SHEET.width, height: SHEET.height, filter: "var(--${p}-shadow-sheet)" }} + > + <span className="line-clamp-3 break-words">{item.title}</span> + </div> + </div> + <span className="line-clamp-2 w-full text-center text-sm font-medium leading-5 text-foreground">{item.title}</span> + <span className="text-xs leading-4 text-muted-foreground"> + {item.author} · {item.updatedLabel} + </span> + </button> + ); +} +`; +}; diff --git a/.agents/skills/directions-lab/assets/templates/direction/SlotBody.tsx.mjs b/.agents/skills/directions-lab/assets/templates/direction/SlotBody.tsx.mjs new file mode 100644 index 00000000000..81227e606d8 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/direction/SlotBody.tsx.mjs @@ -0,0 +1,15 @@ +export default (ctx, direction, surface) => `import type { SurfaceData } from "../../kit"; +import { MotionSpecimen } from "./MotionSpecimen"; + +export function ${surface.body}({ items }: SurfaceData["${surface.id}"]) { + return ( + <ul data-lab-stub="${direction.id}-${surface.id}" className="grid list-none grid-cols-(--${direction.prefix}-grid) gap-4 p-0"> + {items.map((item) => ( + <li key={item.id} className="min-w-0"> + <MotionSpecimen item={item} /> + </li> + ))} + </ul> + ); +} +`; diff --git a/.agents/skills/directions-lab/assets/templates/direction/index.tsx.mjs b/.agents/skills/directions-lab/assets/templates/direction/index.tsx.mjs new file mode 100644 index 00000000000..753bf42fb70 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/direction/index.tsx.mjs @@ -0,0 +1,11 @@ +export default (ctx, direction) => `import type { DirectionKit } from "../../kit"; +${ctx.surfaces.map((surface) => `import { ${surface.body} } from "./${surface.body}";`).join("\n")} +import "./tokens.css"; + +export const kit: DirectionKit = { + id: ${JSON.stringify(direction.id)}, + name: ${JSON.stringify(direction.name)}, + tagline: ${JSON.stringify(direction.tagline)}, +${ctx.surfaces.map((surface) => ` ${surface.body},`).join("\n")} +}; +`; diff --git a/.agents/skills/directions-lab/assets/templates/direction/tokens.css.mjs b/.agents/skills/directions-lab/assets/templates/direction/tokens.css.mjs new file mode 100644 index 00000000000..78adda7feb4 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/direction/tokens.css.mjs @@ -0,0 +1,30 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const MOTION_ASSET = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../motion/v1-mvp-motion.css"); + +export const motionBlock = (direction) => readFileSync(MOTION_ASSET, "utf8").replaceAll("<id>", direction.id).replaceAll("<p>", direction.prefix); + +export default (ctx, direction) => { + const p = direction.prefix; + return `[data-direction="${direction.id}"] { + --${p}-paper: #fafafa; + --${p}-ink: #262626; + --${p}-grid: repeat(auto-fill, minmax(200px, 1fr)); + --${p}-tile-h: 240px; + --${p}-tile-radius: 12px; + --${p}-tile-hover: var(--accent); + --${p}-sheet-radius: 8px; + --${p}-shadow-sheet: drop-shadow(0 3.406px 10px rgba(26, 26, 26, 0.0924)); + --${p}-shadow-print: 0 3.406px 20px rgba(26, 26, 26, 0.0924); +} + +.dark [data-direction="${direction.id}"] { + --${p}-tile-hover: #202020; + --${p}-shadow-sheet: drop-shadow(0 3.406px 10px rgba(26, 26, 26, 0.18)); + --${p}-shadow-print: 0 3.406px 20px rgba(26, 26, 26, 0.18); +} + +${motionBlock(direction)}`; +}; diff --git a/.agents/skills/directions-lab/assets/templates/entry.html.mjs b/.agents/skills/directions-lab/assets/templates/entry.html.mjs new file mode 100644 index 00000000000..28e35b0a932 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/entry.html.mjs @@ -0,0 +1,27 @@ +export default (ctx) => `<!DOCTYPE html> +<html lang="en" class="dark" style="color-scheme: dark"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> + <meta name="theme-color" content="#18181b" /> + <title>${ctx.title} directions + + + + +
+ + + +`; diff --git a/.agents/skills/directions-lab/assets/templates/fixtures.ts.mjs b/.agents/skills/directions-lab/assets/templates/fixtures.ts.mjs new file mode 100644 index 00000000000..960261af477 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/fixtures.ts.mjs @@ -0,0 +1,23 @@ +const PLACEHOLDERS = [ + { title: "First placeholder record", author: "Brand and Presentation Designer", at: "2026-09-28T11:07:00", label: "Sep 28, 2026, 11:07 AM" }, + { title: "Second placeholder record, with a longer title that wraps to a second line", author: "CEO", at: "2026-09-26T15:40:00", label: "Sep 26, 2026, 3:40 PM" }, + { title: "Third placeholder record", author: "Brand and Presentation Designer", at: "2026-09-24T09:12:00", label: "Sep 24, 2026, 9:12 AM" }, +]; + +export default (ctx) => `import type { LabRecord } from "./kit"; +import { registry } from "./registry"; + +export const COMPANY = registry.shell.company; +export const TASKS = registry.shell.tasks; +export const LAB_NOW = new Date("2026-09-28T18:00:00"); + +const record = (id: string, title: string, author: string, updatedAt: string, updatedLabel: string): LabRecord => ({ id, title, author, updatedAt, updatedLabel }); + +${ctx.surfaces + .map( + (surface) => `export const ${surface.fixture}: LabRecord[] = [ +${PLACEHOLDERS.map((item, index) => ` record("${surface.id}-${index + 1}", ${JSON.stringify(item.title)}, ${JSON.stringify(item.author)}, "${item.at}", "${item.label}"),`).join("\n")} +];`, + ) + .join("\n\n")} +`; diff --git a/.agents/skills/directions-lab/assets/templates/frames/AppShell.tsx.mjs b/.agents/skills/directions-lab/assets/templates/frames/AppShell.tsx.mjs new file mode 100644 index 00000000000..020e016a0ef --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/frames/AppShell.tsx.mjs @@ -0,0 +1,159 @@ +export default () => `import type { ReactNode } from "react"; +import { + Boxes, + ChevronsUpDown, + CircleCheck, + FolderOpen, + GitBranch, + History, + Inbox, + LayoutDashboard, + LifeBuoy, + Package, + Repeat, + Search, + SquarePen, + Unplug, + Users, + type LucideIcon, +} from "lucide-react"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; +import type { ShellNav } from "../define-lab"; +import { COMPANY, TASKS } from "../fixtures"; + +interface NavEntry { + label: string; + icon: LucideIcon; + nav?: ShellNav; +} + +const TOP: NavEntry[] = [ + { label: "New Task", icon: SquarePen }, + { label: "Search", icon: Search }, + { label: "Dashboard", icon: LayoutDashboard }, + { label: "Inbox", icon: Inbox }, +]; +const WORK: NavEntry[] = [ + { label: "Tasks", icon: CircleCheck, nav: "tasks" }, + { label: "Projects", icon: FolderOpen }, + { label: "Routines", icon: Repeat }, + { label: "Artifacts", icon: Package, nav: "artifacts" }, + { label: "Workspaces", icon: GitBranch }, +]; +const ORG: NavEntry[] = [ + { label: "Agents", icon: Users }, + { label: "Skills", icon: Boxes }, + { label: "Connectors", icon: Unplug }, + { label: "Audit", icon: History }, +]; +const RECENT = TASKS.map((task) => task.title); + +function NavItem({ label, icon: Icon, active }: { label: string; icon?: LucideIcon; active?: boolean }) { + return ( + + {Icon ? ( + + + + ) : null} + {label} + + ); +} + +function Section({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
+
+
+ {label} +
+
+
+
{children}
+
+ ); +} + +function Sidebar({ active }: { active: ShellNav }) { + return ( + + ); +} + +export function SupportButton() { + return ( + + + + Support + + + ); +} + +export function AppShell({ active, children }: { active: ShellNav; children: ReactNode }) { + return ( +
+
+ +
{children}
+
+
+ ); +} +`; diff --git a/.agents/skills/directions-lab/assets/templates/frames/Board.tsx.mjs b/.agents/skills/directions-lab/assets/templates/frames/Board.tsx.mjs new file mode 100644 index 00000000000..11e150ffd53 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/frames/Board.tsx.mjs @@ -0,0 +1,124 @@ +export default () => `import { useCallback, useEffect, useRef, useState, type CSSProperties, type SyntheticEvent } from "react"; +import { DIRECTION_IDS, KIT_META, SURFACE_IDS, surfaceOf, type DirectionId, type SurfaceId } from "../kit"; +import { labSearch, type LabTheme, type LabView } from "../lab-state"; + +const FRAME_WIDTH = 1440; +const FRAME_MIN_HEIGHT = 900; + +const LABEL = "font-mono text-(length:--text-nano) uppercase tracking-(--tracking-label) text-muted-foreground"; + +interface Toggles { + theme: LabTheme; + anim: boolean; + proposal: boolean; +} + +interface TileProps extends Toggles { + direction: DirectionId; + view: SurfaceId; + maxHeight?: number; +} + +function Tile({ direction, view, theme, anim, proposal, maxHeight }: TileProps) { + const wrapRef = useRef(null); + const contentObserver = useRef(null); + const [scale, setScale] = useState(0); + const [height, setHeight] = useState(FRAME_MIN_HEIGHT); + + useEffect(() => { + const wrap = wrapRef.current; + if (!wrap) return; + const observer = new ResizeObserver(([entry]) => setScale(entry.contentRect.width / FRAME_WIDTH)); + observer.observe(wrap); + return () => observer.disconnect(); + }, []); + + useEffect(() => () => contentObserver.current?.disconnect(), []); + + const onLoad = useCallback((event: SyntheticEvent) => { + const root = event.currentTarget.contentDocument?.getElementById("root"); + if (!root) return; + const measure = () => setHeight(Math.max(FRAME_MIN_HEIGHT, Math.ceil(root.getBoundingClientRect().height))); + contentObserver.current?.disconnect(); + contentObserver.current = new ResizeObserver(measure); + contentObserver.current.observe(root); + measure(); + }, []); + + const search = labSearch({ direction, view, theme, chrome: false, anim, proposal }); + const shown = Math.min(height, maxHeight ?? height); + return ( + +