diff --git a/README.md b/README.md index 8c1cf41..3c69b0b 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,10 @@ Running work log for this integration: [`WORKLOG.md`](WORKLOG.md). **[corpuslaw.us/autopilot](https://corpuslaw.us/autopilot)** — the Formation Autopilot from this repo (`autopilot/`), running on Alibaba Cloud Function -Compute. Describe a business in plain English; get a cited launch checklist and -a prefilled formation handoff. +Compute. Describe a business in plain English; get a launch checklist linked to +reviewed guides and the formation catalog, with unresolved requirements marked +Needs checking, and a prefilled formation handoff. Broad keyword law matches are +withheld until their relevance can be verified. ### 3. Drop a skill folder — or install a plugin @@ -167,7 +169,8 @@ jurisdiction is not covered rather than guessing. ## What's in this repo - **`autopilot/`** — Formation Autopilot: a Qwen Cloud agent that turns an - ambiguous founder description into a cited launch checklist and a prefilled, + ambiguous founder description into a launch checklist with guide and catalog + sources and a prefilled, human-approved formation handoff. *(Qwen Cloud Hackathon Track 4 winner.)* - **`mcp-server/`** — zero-dependency stdio MCP server, for clients that cannot speak HTTP MCP. @@ -205,7 +208,7 @@ See [docs/architecture.md](docs/architecture.md) for the Mermaid source. ## Two-lane model routing -The autopilot uses two Qwen models with different cost/quality profiles: +Legacy `/api/chat` uses two Qwen models with different cost/quality profiles: - **Fast lane** (`QWEN_MODEL_FAST`, default `qwen-flash-2025-07-28`): all standard turns — intent parsing, law search, checklist generation. Low latency, low spend. @@ -215,6 +218,8 @@ The autopilot uses two Qwen models with different cost/quality profiles: The lane switch is automatic. Routing logic lives in `autopilot/src/agent.ts`. The hourly spend cap (`SPEND_CAP_TURNS_PER_HOUR`) applies across both lanes. +The sourced `/api/plan` path builds deterministic guide/catalog items and uses +the fast lane only for optional ordering; model output cannot add legal sources. ## Human in the loop (gates) @@ -287,6 +292,8 @@ The server exposes: - `GET /healthz` — liveness probe - `POST /api/chat` — agent turn endpoint +- `POST /api/plan` — planning with guide and catalog sources; unresolved law remains Needs checking +- `POST /api/plan/handoff` — validate a ready-mode draft through the hosted handoff - `GET /` — chat UI Deploy to Alibaba Cloud Function Compute: see diff --git a/autopilot/src/plan-contract.ts b/autopilot/src/plan-contract.ts new file mode 100644 index 0000000..79dcbb6 --- /dev/null +++ b/autopilot/src/plan-contract.ts @@ -0,0 +1,47 @@ +/** Wire contract shared with corpus-agent-kit /api/plan, version 1. */ +export type PlanningMode = "exploring" | "ready" | "formed"; +export type EntityChoice = "llc" | "nonprofit" | null; +export type Campaign = Partial>; +export interface PlanInput { + mode: PlanningMode; + activity: string; + operatingState: string | null; + operatingCity: string; + formationState: string | null; + entityType: EntityChoice; + campaign: Campaign; +} +export interface PlanSource { + id: string; + kind: "law" | "catalog" | "guide"; + label: string; + url: string; + excerpt: string; + nodeId?: string; +} +export interface PlanItem { + id: string; + category: "entity" | "registered_agent" | "ein" | "annual_report" | "state_tax" | "local"; + title: string; + body: string; + sourceIds: string[]; + needsCheck: boolean; +} +export interface PlanResponse { + version: 1; + createdAt: string; + items: PlanItem[]; + sources: PlanSource[]; + pricing: { available: boolean; state: string | null; lines: string[]; checkedAt: string }; + tools: string[]; + warnings: string[]; +} +export type ItemProgress = "todo" | "done" | "not_applicable" | "needs_check"; +export interface PlanHandoff { url: string; missing: string[] } +export interface ServiceOffer { + id: "annual_report" | "renewal_bundle" | "registered_agent"; + title: string; + available: boolean; + description: string; + href: string; +} diff --git a/autopilot/src/plan-evidence.ts b/autopilot/src/plan-evidence.ts new file mode 100644 index 0000000..b678a2b --- /dev/null +++ b/autopilot/src/plan-evidence.ts @@ -0,0 +1,72 @@ +import type { PlanSource } from "./plan-contract.js"; +import { record, STATES } from "./plan-validation.js"; + +export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const clean = (s: string) => s.trim().toLowerCase(); +export function lawSource(nodeId: string, label: string, excerpt: string): PlanSource { + return { id: `law:${nodeId.toLowerCase()}`, kind: "law", nodeId: nodeId.toLowerCase(), label, + // Inspected app/code/[...path]/page.tsx accepts the stable UUID URL. + url: `https://corpuslaw.us/code/${nodeId.toLowerCase()}`, excerpt }; +} + +/** Parse only the actual numbered MCP transcript; never harvest arbitrary UUIDs/URLs. */ +export function parseSearch(text: string, jurisdictionName: string): PlanSource[] { + if (!/^\d+ of \d+ results for /m.test(text)) return []; + const blocks = text.split(/\n\n/); + const result: PlanSource[] = []; + for (const block of blocks) { + const lines = block.split("\n"); + const heading = lines[0]?.match(/^\d+\. (.+)$/)?.[1]; + const id = lines.at(-1)?.match(/^ node_id: (\S+)$/)?.[1]; + const where = lines[1]?.trim().split(" · ")[0]; + if (!heading || heading.length > 400 || !id || !UUID.test(id) || clean(where ?? "") !== clean(jurisdictionName)) continue; + const excerpt = lines.slice(2, -1).map(l => l.trim()).join("\n"); + if (!excerpt || excerpt.length > 3000) continue; + const note = text.startsWith("NOTE:") ? text.split("\n\n")[0] + "\n" : ""; + if (note.length > 2000) continue; + result.push(lawSource(id, heading, note + excerpt)); + if (result.length === 2) break; + } + return result; +} + +/** get_node may prefix its JSON with an effective-date warning. Preserve it. */ +export function parseNode(text: string, expectedId: string, jurisdictionName: string): PlanSource | null { + try { + const start = text.indexOf("{"); + if (start < 0) return null; + const prefix = text.slice(0, start).trim(); + if (prefix.length > 2000 || (prefix && !/^(?:NOTE|WARNING|NOT YET IN EFFECT)\b/i.test(prefix))) return null; + const node: unknown = JSON.parse(text.slice(start)); + if (!record(node) || typeof node.nodeId !== "string" || !UUID.test(node.nodeId) || node.nodeId.toLowerCase() !== expectedId.toLowerCase() || + typeof node.citation !== "string" || !node.citation || node.citation.length > 400 || + typeof node.bodyText !== "string" || !node.bodyText.trim() || !record(node.jurisdiction) || + typeof node.jurisdiction.name !== "string" || clean(node.jurisdiction.name) !== clean(jurisdictionName)) return null; + const heading = prefix ? prefix + "\n" : ""; + const marker = "\n…truncated; open the source for the complete provision."; + const bodyLimit = Math.min(3000, 5000 - heading.length); + const body = node.bodyText.length > bodyLimit + ? node.bodyText.slice(0, bodyLimit - marker.length) + marker : node.bodyText; + return lawSource(node.nodeId, node.citation, heading + body); + } catch { return null; } +} + +export function exactLocalCoverage(text: string, state: string, city: string): { code: string; name: string } | null { + // Strip only an exact selected-state suffix; another state or a second comma fails closed. + const parts = city.split(",").map(s => s.trim()); + if (parts.length > 2 || (parts.length === 2 && ![state, STATES[state]].some(s => clean(s) === clean(parts[1])))) return null; + const name = parts[0]; + if (!name) return null; + const names = [name, `${name}, ${state}`, `${name}, ${STATES[state]}`].map(clean); + const matches: { code: string; name: string }[] = []; + for (const line of text.split("\n")) { + const row = line.match(/^- ([A-Z0-9-]+) — (.+) \((?:municipal|city)\): (.+)$/); + if (!row || !row[1].startsWith(`${state}-`) || !names.includes(clean(row[2]))) continue; + const status = row[3]; + if (/PLACEHOLDER|not yet searchable/i.test(status)) continue; + const count = status.match(/([\d,]+) searchable chunks/); + if (!(count && Number(count[1].replace(/,/g, "")) > 0) && !/^delivered after the last daily count — searchable now;/.test(status)) continue; + matches.push({ code: row[1], name: row[2] }); + } + return matches.length === 1 ? matches[0] : null; +} diff --git a/autopilot/src/plan-tools.ts b/autopilot/src/plan-tools.ts new file mode 100644 index 0000000..9d2bd86 --- /dev/null +++ b/autopilot/src/plan-tools.ts @@ -0,0 +1,89 @@ +import { corpusBase } from "./tools/law-search.js"; +import { PlanError, record } from "./plan-validation.js"; + +export type ToolName = "formation.requirements" | "law.search" | "law.get_node" | "law.list_coverage" | "formation.handoff"; +export type ToolCall = (name: ToolName, args: Record, signal: AbortSignal) => Promise; +export const TOOL_TIMEOUT_MS = 4500; +export const PLAN_TIMEOUT_MS = 24000; +export const MAX_TOOL_BYTES = 131072; + +/** Only a known hosted semantic failure is exposed, using our own categorical copy. */ +export class UnsupportedFormationError extends PlanError { + constructor() { + super(400, "Corpus does not currently support formation for the selected state and entity. Choose another state or entity before continuing."); + } +} + +/** Enforces a deadline even when an injected/third-party implementation ignores abort. */ +export async function bounded(work: (signal: AbortSignal) => Promise, parent: AbortSignal, ms: number): Promise { + const controller = new AbortController(); + const abort = () => controller.abort(); + parent.addEventListener("abort", abort, { once: true }); + if (parent.aborted) abort(); + const timer = setTimeout(abort, ms); + let rejectAbort: () => void = () => {}; + const stopped = new Promise((_, reject) => { + rejectAbort = () => reject(new PlanError(504, "Planning request timed out.")); + controller.signal.addEventListener("abort", rejectAbort, { once: true }); + if (controller.signal.aborted) rejectAbort(); + }); + try { + if (controller.signal.aborted) return await stopped; + return await Promise.race([work(controller.signal), stopped]); + } finally { + clearTimeout(timer); + parent.removeEventListener("abort", abort); + controller.signal.removeEventListener("abort", rejectAbort); + } +} + +export async function readBoundedResponse(response: Response, signal: AbortSignal): Promise { + if (!response.ok || !response.body) throw new Error("Tool unavailable"); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + const cancel = () => { void reader.cancel().catch(() => {}); }; + signal.addEventListener("abort", cancel, { once: true }); + try { + for (;;) { + signal.throwIfAborted(); + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_TOOL_BYTES) throw new Error("Tool response too large"); + chunks.push(value); + } + signal.throwIfAborted(); + return Buffer.concat(chunks).toString("utf8"); + } finally { + signal.removeEventListener("abort", cancel); + await reader.cancel().catch(() => {}); + } +} + +/** MCP tools return prose in structuredContent.text / content[].text, not result objects. */ +export const hostedTool: ToolCall = async (name, args, signal) => { + const response = await fetch(`${corpusBase()}/api/mcp`, { + method: "POST", signal, + headers: { "Content-Type": "application/json", ...(process.env.CORPUS_API_KEY ? { Authorization: `Bearer ${process.env.CORPUS_API_KEY}` } : {}) }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }), + }); + const json: unknown = JSON.parse(await readBoundedResponse(response, signal)); + if (!record(json) || json.error || !record(json.result)) throw new Error("Tool failed"); + const result = json.result; + let text: unknown = record(result.structuredContent) ? result.structuredContent.text : undefined; + if (text === undefined && Array.isArray(result.content)) { + text = result.content.filter(c => record(c) && c.type === "text" && typeof c.text === "string").map(c => c.text).join("\n"); + } + if (result.isError || (record(result.structuredContent) && result.structuredContent.isError)) { + const kind = args.entityType === "llc" ? "LLC" : args.entityType === "nonprofit" ? "Nonprofit" : null; + if ((name === "formation.requirements" || name === "formation.handoff") && kind && + typeof args.state === "string" && /^[A-Z]{2}$/.test(args.state) && typeof text === "string" && + text.startsWith(`${kind} formation is not available in US-${args.state}. LLC formation is available in `)) { + throw new UnsupportedFormationError(); + } + throw new Error("Tool failed"); + } + if (typeof text !== "string" || !text.trim() || /^Error:/i.test(text.trim())) throw new Error("Tool text missing"); + return text; +}; diff --git a/autopilot/src/plan-validation.ts b/autopilot/src/plan-validation.ts new file mode 100644 index 0000000..fa42d53 --- /dev/null +++ b/autopilot/src/plan-validation.ts @@ -0,0 +1,38 @@ +import type { PlanInput } from "./plan-contract.js"; + +export class PlanError extends Error { + constructor(public status: number, message: string) { super(message); } +} + +export const STATES: Record = Object.fromEntries([ + "AL:Alabama", "AK:Alaska", "AZ:Arizona", "AR:Arkansas", "CA:California", "CO:Colorado", + "CT:Connecticut", "DE:Delaware", "DC:District of Columbia", "FL:Florida", "GA:Georgia", + "HI:Hawaii", "ID:Idaho", "IL:Illinois", "IN:Indiana", "IA:Iowa", "KS:Kansas", "KY:Kentucky", + "LA:Louisiana", "ME:Maine", "MD:Maryland", "MA:Massachusetts", "MI:Michigan", "MN:Minnesota", + "MS:Mississippi", "MO:Missouri", "MT:Montana", "NE:Nebraska", "NV:Nevada", "NH:New Hampshire", + "NJ:New Jersey", "NM:New Mexico", "NY:New York", "NC:North Carolina", "ND:North Dakota", + "OH:Ohio", "OK:Oklahoma", "OR:Oregon", "PA:Pennsylvania", "RI:Rhode Island", "SC:South Carolina", + "SD:South Dakota", "TN:Tennessee", "TX:Texas", "UT:Utah", "VT:Vermont", "VA:Virginia", + "WA:Washington", "WV:West Virginia", "WI:Wisconsin", "WY:Wyoming", +].map(s => s.split(":"))); + +export const CAMPAIGN_KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"] as const; +const FIELDS = ["mode", "activity", "operatingState", "operatingCity", "formationState", "entityType", "campaign"]; +export function record(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v); +} + +export function validatePlanInput(v: unknown): PlanInput { + const bad = () => { throw new PlanError(400, "Invalid planning input."); }; + if (!record(v) || Object.keys(v).some(k => !FIELDS.includes(k)) || FIELDS.some(k => !Object.hasOwn(v, k))) return bad(); + if (!["exploring", "ready", "formed"].includes(v.mode as string)) return bad(); + if (v.entityType !== null && v.entityType !== "llc" && v.entityType !== "nonprofit") return bad(); + for (const key of ["formationState", "operatingState"]) { + if (v[key] !== null && (typeof v[key] !== "string" || !Object.hasOwn(STATES, v[key] as string))) return bad(); + } + if (typeof v.activity !== "string" || v.activity.length > 500 || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(v.activity)) return bad(); + if (typeof v.operatingCity !== "string" || v.operatingCity.length > 100 || /[\x00-\x1f\x7f]/.test(v.operatingCity)) return bad(); + if (!record(v.campaign) || Object.keys(v.campaign).some(k => !CAMPAIGN_KEYS.includes(k as typeof CAMPAIGN_KEYS[number]))) return bad(); + if (Object.values(v.campaign).some(s => typeof s !== "string" || s.length > 100 || !/^[A-Za-z0-9_.~-]*$/.test(s))) return bad(); + return { ...v, activity: v.activity.replace(/\s+/g, " ").trim(), operatingCity: v.operatingCity.trim(), campaign: { ...v.campaign } } as PlanInput; +} diff --git a/autopilot/src/plan.ts b/autopilot/src/plan.ts new file mode 100644 index 0000000..b6e2435 --- /dev/null +++ b/autopilot/src/plan.ts @@ -0,0 +1,210 @@ +import type { PlanInput, PlanResponse, PlanHandoff, PlanItem, PlanSource } from "./plan-contract.js"; +import { bounded, hostedTool, MAX_TOOL_BYTES, PLAN_TIMEOUT_MS, TOOL_TIMEOUT_MS, UnsupportedFormationError, type ToolCall, type ToolName } from "./plan-tools.js"; +import { CAMPAIGN_KEYS, PlanError, validatePlanInput } from "./plan-validation.js"; +import { exactLocalCoverage } from "./plan-evidence.js"; +import { getQwen } from "./qwen.js"; +import { lane } from "./routing.js"; + +type Selector = (ids: string[], signal: AbortSignal, context: { input: PlanInput; items: Pick[] }) => Promise; +export interface PlanDeps { + tool?: ToolCall; + select?: Selector; + toolTimeoutMs?: number; + modelTimeoutMs?: number; + timeoutMs?: number; +} +const GUIDE = "https://corpuslaw.us/guides/after-forming-an-llc"; +const CHECKLIST = "https://corpuslaw.us/checklist"; +// Reviewed wording from funnel-content.ts and the after-forming-an-llc guide (2026-10-19). +const EIN = "A single-member LLC with no employees, no relevant excise-tax obligations, and no corporate tax election generally does not need a separate EIN for federal tax purposes. A multi-member LLC generally does. A bank or state requirement may also make an EIN necessary for your next step."; +const RA = "Most states require an LLC to name a registered agent — it can be the owner in many states, or a paid service. Minnesota, Pennsylvania and West Virginia make the agent optional (Minnesota and Pennsylvania still require a registered office address)."; +const ANNUAL = "Most states also charge a recurring fee to keep an LLC alive (often called an annual report or franchise tax) — the amount and due date vary by state."; +const ENTITY = "An LLC, an EIN, and a registered agent are three different things: the LLC is the legal entity your state creates; the EIN is the federal tax ID the IRS issues; the registered agent is the person or company that receives legal mail for the entity at a street address in the formation state."; + +const FINISH_RESERVE_MS = 500; +const MIN_TOOL_BUDGET_MS = 100; + +function requestTools(deps: PlanDeps, signal: AbortSignal, tools: string[], warnings: string[], deadline = Date.now() + PLAN_TIMEOUT_MS) { + let calls = 0; + return async (name: ToolName, args: Record): Promise => { + signal.throwIfAborted(); + const budget = Math.min(deps.toolTimeoutMs ?? TOOL_TIMEOUT_MS, TOOL_TIMEOUT_MS, deadline - Date.now() - FINISH_RESERVE_MS); + if (budget < Math.min(MIN_TOOL_BUDGET_MS, deps.toolTimeoutMs ?? TOOL_TIMEOUT_MS)) { + warnings.push(`${name} was skipped to finish within the planning deadline; related items need checking.`); + return null; + } + if (++calls > 6) throw new Error("Tool limit reached"); + try { + const text = await bounded(s => (deps.tool ?? hostedTool)(name, args, s), signal, budget); + if (typeof text !== "string" || !text.trim() || Buffer.byteLength(text) > MAX_TOOL_BYTES || /^Error:/i.test(text.trim())) throw new Error("Invalid tool text"); + if (!tools.includes(name)) tools.push(name); + return text; + } catch (error) { + signal.throwIfAborted(); + if (error instanceof UnsupportedFormationError) { + if (name === "formation.handoff") throw error; + warnings.push(error.message); + return null; + } + warnings.push(`${name} was unavailable or returned an unreadable response; related items need checking.`); + return null; + } + }; +} + +const selectKnown: Selector = async (ids, signal, context) => { + const response = await getQwen().chat.completions.create({ + model: lane("fast"), temperature: 0, max_tokens: 256, + messages: [{ role: "system", content: "Return only a JSON array ordering the supplied item IDs. Do not add IDs, prose or sources." }, { role: "user", content: JSON.stringify({ ids, ...context }) }], + }, { signal, timeout: 2000, maxRetries: 0 }); + const text = response.choices[0]?.message.content; + if (!text || text.length > 4096) throw new Error("Invalid ordering"); + return JSON.parse(text); +}; + +/** No user source records or model legal prose can enter the request-local ledger. */ +export async function buildPlan(raw: PlanInput, deps: PlanDeps = {}, parent = new AbortController().signal): Promise { + const input = validatePlanInput(raw); + const timeout = Math.min(deps.timeoutMs ?? PLAN_TIMEOUT_MS, PLAN_TIMEOUT_MS); + const deadline = Date.now() + timeout; + return bounded(async signal => { + const createdAt = new Date().toISOString(); + const tools: string[] = []; + const warnings = ["Source binding establishes provenance, not applicability or completeness. Unresolved items need checking."]; + const call = requestTools(deps, signal, tools, warnings, deadline); + const ledger = new Map(); + const items: PlanItem[] = []; + function guide(id: string, label: string, url: string, excerpt: string) { + ledger.set(id, { id, kind: "guide", label, url, excerpt }); + return id; + } + function item(id: string, category: PlanItem["category"], title: string, body: string, sourceIds: string[] = []) { + items.push({ id, category, title, body, sourceIds, needsCheck: true }); + } + const checklistEntity = guide("guide:entity", "The founder legal checklist", CHECKLIST, ENTITY); + const state = input.formationState; + let catalog: string | null = null; + if (state && input.entityType) { + const returned = await call("formation.requirements", { state, entityType: input.entityType }); + const header = `Formation requirements — ${input.entityType === "llc" ? "LLC" : "nonprofit corporation"} in ${state} (US-${state}):`; + if (returned?.startsWith(header + "\n") && returned.includes("\nREQUIRED (collect ALL of these from your user, in conversation):\n")) { + catalog = returned; + ledger.set("catalog:formation", { id: "catalog:formation", kind: "catalog", label: header.slice(0, -1), url: "https://corpuslaw.us/formation", excerpt: header }); + item("entity", "entity", input.mode === "formed" ? "Review the existing entity" : "Review the selected entity and formation state", header, ["catalog:formation", checklistEntity]); + if (input.mode !== "formed") { + const sections = returned.split(/\n\n/).filter(section => section.startsWith("REQUIRED (") || section.startsWith(`STATE-SPECIFIC QUESTIONS for ${state} (`)); + for (const [index, section] of sections.entries()) { + const body = section.split("\n").filter(line => line.startsWith("- ")).join("\n"); + if (!body || body.length > 3000) { + warnings.push("Some formation intake fields could not be displayed; check the complete intake with Corpus before proceeding."); + continue; + } + const id = `catalog:intake:${index}`; + ledger.set(id, { id, kind: "catalog", label: `${state} formation intake fields`, url: "https://corpuslaw.us/formation", excerpt: body }); + item(`intake_${index}`, "entity", index === 0 ? "Prepare these details for formation" : `Check the ${state} formation questions`, body, [id]); + } + } + } else if (returned) warnings.push("The formation catalog format or jurisdiction could not be verified; its requirements and prices were withheld."); + } + if (!catalog) item("entity", "entity", "Review entity and formation-state choices", ENTITY, [checklistEntity]); + warnings.push(state && input.entityType + ? `Formation catalog items concern ${state} and ${input.entityType} only; they do not establish operating-state requirements. ${input.mode === "formed" ? "Already-formed mode does not recommend a new formation." : ""}`.trim() + : "Formation state or entity is not selected. No entity type or formation jurisdiction has been assumed."); + + const raText = input.entityType === "nonprofit" ? "The registered-agent discussion in the linked checklist concerns LLCs. Check the nonprofit-specific requirements for the selected state." : RA; + item("registered_agent", "registered_agent", "Check the registered-agent or registered-office rules", raText, [guide("guide:ra", "The founder legal checklist — registered agents", CHECKLIST, RA)]); + item("ein", "ein", "Determine whether an EIN is needed", input.entityType === "nonprofit" + ? "The single-member LLC exception in the linked guide is specific to LLCs. Check the IRS requirements for this nonprofit. Apply directly through the IRS for free." + : EIN + " Apply directly through the IRS for free.", [guide("guide:ein", "After forming an LLC — EIN", GUIDE + "#step-ein", EIN), guide("guide:irs", "IRS — single-member LLC guidance", "https://www.irs.gov/businesses/small-businesses-self-employed/single-member-limited-liability-companies", "Official IRS page. No excerpt was retrieved; open the page for the current guidance.")]); + item("annual_report", "annual_report", "Check reports and recurring state obligations", input.entityType === "nonprofit" + ? "Check the selected state's nonprofit reporting requirements; the linked checklist describes LLCs." : ANNUAL, + [guide("guide:annual", "The founder legal checklist — recurring fees", CHECKLIST, ANNUAL)]); + if (catalog && state) { + const duties = catalog.split(/\n\n/).find(s => s.startsWith(`AFTER-FORMATION DUTIES in ${state} —`)); + for (const [i, line] of (duties?.split("\n").filter(l => l.startsWith("- ")) ?? []).slice(0, 4).entries()) { + const body = line.slice(2); + if (!body || body.length > 3000) continue; + const id = `catalog:duty:${i}`; + ledger.set(id, { id, kind: "catalog", label: `${state} formation catalog — after-formation duty`, url: "https://corpuslaw.us/formation", excerpt: body }); + item(`formation_duty_${i}`, /annual|report|franchise/i.test(body) ? "annual_report" : "entity", `${state} formation catalog: after-formation duty`, body, [id]); + } + } + item("state_tax", "state_tax", "Check tax registration in the operating state", input.operatingState + ? `Operating state: ${input.operatingState}. Tax registration applicability has not been determined. Check the rules using the actual activity and tax situation.` + : "Operating state is not selected. Tax registration applicability has not been determined."); + + async function search(query: string, jurisdiction: string, itemId: string) { + await call("law.search", { query, jurisdiction, mode: "keyword", limit: 2 }); + // A real node in the selected jurisdiction proves provenance only. Broad + // keyword matches have not been vetted for relevance to this plan, so do + // not bind them as supporting law or promote them with get_node. + const title = items.find(i => i.id === itemId)!.title; + warnings.push(`No relevant provision was verified for “${title}” (${jurisdiction}). Broad keyword matches are withheld; this does not establish that no requirements apply.`); + } + if (catalog && state) await search("annual report", state, "annual_report"); + if (input.operatingState) await search("business tax registration", input.operatingState, "state_tax"); + const coverage = input.operatingState && input.operatingCity ? await call("law.list_coverage", {}) : null; + const local = coverage && input.operatingState ? exactLocalCoverage(coverage, input.operatingState, input.operatingCity) : null; + item("local", "local", local ? "Local requirements need checking" : "Local coverage needs checking", local + ? `Coverage confirms a searchable entry for ${local.name} (${local.code}); it does not establish which rules apply to this activity. No relevant local provision was verified.` + : "No exact, unambiguous, searchable coverage entry was confirmed for the operating city. Local requirements have not been determined."); + if (local) await search("business license", local.code, "local"); + else warnings.push("Local coverage gap: no local search was run."); + + const lines = catalog?.split("\n").filter(l => /^(?:PRICE(?: \(live\))?:|FOUNDING DISCOUNT:|EXPEDITE \(|SKIP REVIEW \(|TENNESSEE MEMBERS:|EIN ADD-ON: temporarily unavailable)/.test(l)) ?? []; + const validPrice = lines.filter(l => /^PRICE \(live\): \$\d+(?:,\d{3})*\.\d{2} all-in — /.test(l)).length === 1 && + lines.filter(l => /^PRICE/.test(l)).length === 1 && lines.length <= 10 && + lines.every(l => l.length <= 6000) && Buffer.byteLength(lines.join("\n")) <= 16384; + const pricing = { available: input.mode === "ready" && validPrice, state, lines: input.mode === "ready" && validPrice ? lines : [], checkedAt: new Date().toISOString() }; + if (input.mode === "ready" && !pricing.available) warnings.push("Current pricing is unavailable; no price or founding offer has been assumed."); + + const selector = deps.select ?? (process.env.QWEN_API_KEY && process.env.QWEN_BASE_URL ? selectKnown : undefined); + if (selector && deadline - Date.now() < 2500) { + warnings.push("Qwen ordering was skipped to finish within the planning deadline; deterministic sourced items are shown."); + } else if (selector) { + try { + const selected = await bounded(s => selector(items.map(i => i.id), s, { input, items: items.map(({ id, title, category }) => ({ id, title, category })) }), signal, Math.min(deps.modelTimeoutMs ?? 2000, 2000)); + if (!Array.isArray(selected) || selected.length > 20 || selected.some(id => typeof id !== "string")) throw new Error("Invalid selection"); + const known = new Set(items.map(i => i.id)); + const ids = [...new Set(selected.filter(id => known.has(id)))]; + if (ids.length !== selected.length) warnings.push("Unknown or repeated model item IDs were ignored."); + items.sort((a, b) => (ids.includes(a.id) ? ids.indexOf(a.id) : 100) - (ids.includes(b.id) ? ids.indexOf(b.id) : 100)); + tools.push("qwen.order_known_items"); + } catch { + signal.throwIfAborted(); + warnings.push("Qwen ordering was unavailable; deterministic sourced items are shown."); + } + } else warnings.push("Qwen ordering is not configured; deterministic sourced items are shown."); + return { version: 1, createdAt, items, sources: [...ledger.values()], pricing, tools, warnings }; + }, parent, timeout); +} + +export async function handoffPlan(raw: PlanInput, deps: PlanDeps = {}, parent = new AbortController().signal): Promise { + const input = validatePlanInput(raw); + if (input.mode !== "ready" || !input.formationState || !input.entityType) throw new PlanError(400, "Choose ready mode, a formation state and an entity before continuing."); + return bounded(async signal => { + const text = await requestTools(deps, signal, [], [])("formation.handoff", { entityType: input.entityType, state: input.formationState, ...(input.activity ? { businessActivity: input.activity } : {}) }); + if (!text) throw new PlanError(502, "Formation handoff is unavailable."); + // Only an entire, bare URL line from the successful hosted tool is acceptable. + const links = text.split("\n").map(l => l.trim()).filter(l => /^https?:\/\//.test(l)); + if (links.length !== 1) throw new PlanError(502, "Formation handoff is unavailable."); + const rawUrl = links[0]; + let url: URL; + try { url = new URL(rawUrl); } catch { throw new PlanError(502, "Formation handoff is unavailable."); } + if (url.origin !== "https://corpuslaw.us" || url.pathname !== "/formation" || url.username || url.password || url.port || + !/^https:\/\/corpuslaw\.us\/formation#prefill=[A-Za-z0-9_-]+(?:&(?:src|ref)=[A-Za-z0-9_.~-]+)*$/.test(rawUrl)) throw new PlanError(502, "Formation handoff is unavailable."); + const fragment = new URLSearchParams(url.hash.slice(1)); + if (["src", "ref"].some(key => fragment.getAll(key).length > 1)) throw new PlanError(502, "Formation handoff is unavailable."); + const missingBlock = text.match(/(?:^|\n)STILL MISSING:\n((?:- [^\n]+\n?)+)/)?.[1]; + const errorsBlock = text.match(/(?:^|\n)BLOCKING ERRORS \(fix these\):\n((?:- [^\n]+\n?)+)/)?.[1]; + const missing = [missingBlock, errorsBlock].flatMap(b => b ? b.trim().split("\n").map(l => l.slice(2)) : []); + if ((!missing.length && !/^✓ Draft COMPLETE — /m.test(text)) || missing.length > 40 || missing.some(l => l.length > 500)) throw new PlanError(502, "Formation handoff is unavailable."); + // Append query UTMs without reserializing the returned query/fragment attribution. + const hash = rawUrl.indexOf("#"); + const campaign = new URLSearchParams(); + for (const key of CAMPAIGN_KEYS) if (input.campaign[key]) campaign.set(key, input.campaign[key]!); + const before = rawUrl.slice(0, hash); + const query = campaign.toString(); + return { url: before + (query ? (before.includes("?") ? "&" : "?") + query : "") + rawUrl.slice(hash), missing }; + }, parent, Math.min(deps.timeoutMs ?? PLAN_TIMEOUT_MS, PLAN_TIMEOUT_MS)); +} diff --git a/autopilot/src/server.ts b/autopilot/src/server.ts index 65d74f4..6aac8d9 100644 --- a/autopilot/src/server.ts +++ b/autopilot/src/server.ts @@ -3,6 +3,9 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { runAutopilot } from "./agent.js"; +import { buildPlan, handoffPlan } from "./plan.js"; +import { PlanError, validatePlanInput } from "./plan-validation.js"; +import { bounded } from "./plan-tools.js"; const here = dirname(fileURLToPath(import.meta.url)); // dist/server.js → ../public/index.html @@ -10,11 +13,51 @@ const INDEX_HTML = readFileSync(join(here, "..", "public", "index.html"), "utf8" interface Deps { run?: typeof runAutopilot; + plan?: typeof buildPlan; + handoff?: typeof handoffPlan; + bodyTimeoutMs?: number; + requestTimeoutMs?: number; +} + +export const MAX_PLAN_BODY_BYTES = 16384; +export const MAX_PLAN_RESPONSE_BYTES = 98304; +const BODY_TIMEOUT_MS = 5000; + +/** Event-based reader stops buffering immediately on size, disconnect or deadline. */ +function readBody(req: http.IncomingMessage, limit: number, timeoutMs: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let bytes = 0; + const cleanup = () => { + clearTimeout(timer); + req.off("data", data); req.off("end", end); req.off("error", error); + signal.removeEventListener("abort", abort); + }; + const fail = (e: Error) => { cleanup(); req.pause(); reject(e); }; + const data = (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > limit) return fail(new PlanError(413, "Request body is too large.")); + chunks.push(chunk); + }; + const end = () => { + cleanup(); + try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } + catch { reject(new PlanError(400, "Body must be JSON.")); } + }; + const error = () => fail(new PlanError(400, "Request body could not be read.")); + const abort = () => fail(new PlanError(408, "Request body timed out.")); + const timer = setTimeout(abort, timeoutMs); + req.on("data", data); req.once("end", end); req.once("error", error); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) return abort(); + const length = req.headers["content-length"]; + if (length && Number(length) > limit) fail(new PlanError(413, "Request body is too large.")); + }); } export function createServer(deps: Deps = {}): http.Server { const run = deps.run ?? runAutopilot; - // Naive global budget guard: judges + demo traffic share ~$23 of credit. + // Shared per-process request guard; this is not a global spending/dollar cap. let windowStart = Date.now(); let turnsThisWindow = 0; function underCap(): boolean { @@ -26,11 +69,17 @@ export function createServer(deps: Deps = {}): http.Server { return ++turnsThisWindow <= cap; } return http.createServer(async (req, res) => { + const isPlan = req.url === "/api/plan" || req.url === "/api/plan/handoff"; + const controller = new AbortController(); + const disconnect = () => controller.abort(); + req.once("aborted", disconnect); + res.once("close", () => { if (!res.writableFinished) disconnect(); }); try { // The demo page may be hosted off-origin (FC's default fcapp.run domain // force-downloads text/html via an injected Content-Disposition header), // so the API must be callable cross-origin. Public read-only demo — "*". res.setHeader("Access-Control-Allow-Origin", "*"); + if (isPlan) res.setHeader("Cache-Control", "no-store"); if (req.method === "OPTIONS") { res .writeHead(204, { @@ -49,17 +98,30 @@ export function createServer(deps: Deps = {}): http.Server { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(INDEX_HTML); return; } + if (req.method === "POST" && isPlan) { + if (!underCap()) { + res.writeHead(429, { "Content-Type": "application/json", "Connection": "close" }).end(JSON.stringify({ error: "Hourly request cap reached — try again later." })); + return; + } + const body = await readBody(req, MAX_PLAN_BODY_BYTES, Math.min(deps.bodyTimeoutMs ?? BODY_TIMEOUT_MS, BODY_TIMEOUT_MS), controller.signal); + const input = validatePlanInput(body); + const execute = req.url === "/api/plan/handoff" ? deps.handoff ?? handoffPlan : deps.plan ?? buildPlan; + const result = await bounded(s => execute(input, {}, s), controller.signal, Math.min(deps.requestTimeoutMs ?? 25000, 25000)); + const json = JSON.stringify(result); + if (Buffer.byteLength(json) > MAX_PLAN_RESPONSE_BYTES) throw new PlanError(502, "Planning response is unavailable."); + if (!res.destroyed) res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" }).end(json); + return; + } if (req.method === "POST" && req.url === "/api/chat") { if (!underCap()) { res.writeHead(429, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "Hourly budget cap reached — try again later." })); return; } - const chunks: Buffer[] = []; - for await (const c of req) chunks.push(c as Buffer); let body: any; try { - body = JSON.parse(Buffer.concat(chunks).toString("utf8")); - } catch { + body = await readBody(req, 65536, BODY_TIMEOUT_MS, controller.signal); + } catch (error) { + if (error instanceof PlanError && error.status !== 400) throw error; res.writeHead(400, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "Body must be JSON." })); return; } @@ -74,7 +136,14 @@ export function createServer(deps: Deps = {}): http.Server { } res.writeHead(404).end(); } catch (err) { - res.writeHead(500, { "Content-Type": "application/json" }).end(JSON.stringify({ error: String(err instanceof Error ? err.message : err) })); + if (res.destroyed) return; + const status = err instanceof PlanError ? err.status : 500; + const error = isPlan ? (err instanceof PlanError ? err.message : "Planning request failed.") : String(err instanceof Error ? err.message : err); + res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", "Connection": "close" }).end(JSON.stringify({ error })); + // For a rejected/incomplete body, release the paused socket after sending the error. + if (!req.complete) res.once("finish", () => req.destroy()); + } finally { + req.off("aborted", disconnect); } }); } diff --git a/autopilot/test/fixtures/README.md b/autopilot/test/fixtures/README.md new file mode 100644 index 0000000..8915f1d --- /dev/null +++ b/autopilot/test/fixtures/README.md @@ -0,0 +1,15 @@ +# Hosted transcript fixture + +`autopilot-hosted-transcripts.json` was generated through the real Corpus +`dispatch`/`runFormationRequirements`, `law.search`, `buildCoverageTranscript`, and +`formation.handoff` formatters using fictional mocked data, without network calls. +The source is Corpus base `bf1ed5de` plus the registered-agent quote correction +("included in the Corpus bundle; state requirements vary"). Corpus's `mcp.test.ts` +pins these exact transcripts and requires regeneration when their format changes. +Keep this copy synchronized with `apps/web/src/__tests__/fixtures/autopilot-hosted-transcripts.json` +in Corpus; do not replace it with hand-written approximations. + +`irrelevant-plan-searches.json` contains synthetic negative controls shaped like +hosted search transcripts. Education and insurance reports, aircraft registration, +and dog licensing must never become supporting law for a software-business plan. +These are parser-compatible test records, not verified legal citations or excerpts. diff --git a/autopilot/test/fixtures/autopilot-hosted-transcripts.json b/autopilot/test/fixtures/autopilot-hosted-transcripts.json new file mode 100644 index 0000000..e09cf67 --- /dev/null +++ b/autopilot/test/fixtures/autopilot-hosted-transcripts.json @@ -0,0 +1,6 @@ +{ + "requirements": "Formation requirements — LLC in MS (US-MS):\n\nREQUIRED (collect ALL of these from your user, in conversation):\n- proposed business name\n- principal office address (street, city, state)\n- contact email\n- NAICS industry code\n- management type (member- or manager-managed)\n- at least one member\n- an organizer\n\nSTATE-SPECIFIC QUESTIONS: none — MS has no extra LLC fields beyond the baseline.\n\nEIN ADD-ON (optional, $75.00): the federal tax ID, needed to open a bank account or hire. Offer it once. If accepted, also collect: responsible party (name + phone), start date (MM/DD/YYYY), principal business activity, and whether W-2 employees are expected in the next 12 months (if yes: first wage date, employee count, and whether employment tax liability will be ≤$1,000/yr). NEVER ask for a Social Security Number — the founder enters it in a secure panel on corpuslaw.us.\n\nPRICE (live): $246.00 all-in — $50.00 state fees + $100.00 registered agent year 1 (included in the Corpus bundle; state requirements vary) + $96.00 Corpus service. Optional EIN add-on: +$75.00.\n\nHOW TO ASK (one question at a time; skip any askIf that is not met):\nIf this is a single-owner LLC, the visible questions are: business name, what it does, where it operates, and who owns it. Assume member-managed. Do not ask the management question.\n- proposedName\n ask: What do you want to name the company?\n why: This becomes the legal name on the state filing. You can change it later, but it costs a fee.\n default: none — always ask\n askIf: always\n- businessActivity\n ask: In a sentence, what will the business actually do?\n why: Used to pick the NAICS industry code the state records. Describe the work, not the company name.\n default: none — always ask\n askIf: always\n- principalOffice\n ask: Where should official mail go — street, city, state, ZIP?\n why: This is the principal office on the filing. A home address is fine in most states. Put it in the URL hash (#street=&city=&zip=), never the query string.\n default: none — ask if unknown; the page chat will ask if you skip it\n askIf: not already known\n- contactEmail\n ask: What email should we use for this order?\n why: Order receipts and filing updates go here. Put it in the URL hash (#email=), never the query string.\n default: none — ask if unknown; the page chat will ask if you skip it\n askIf: not already known\n- naicsCode\n ask: Which industry code fits this business?\n why: Call formation.lookup_naics, or fetch https://corpuslaw.us/api/naics?q= plus their words. Confirm one code before recording it.\n default: derived from the business description when possible\n askIf: always, but offer 2–6 options rather than asking them to invent a number\n- management\n ask: Besides you, will anyone else have authority to make decisions for the business?\n why: If it's just you, member-managed is standard and simpler. Manager-managed is for when someone who is not an owner will run it.\n default: member-managed\n askIf: memberCount > 1\n- members\n ask: Who owns this company?\n why: Every LLC needs at least one member. If it's just the founder, record them as member and organizer and stop.\n default: the founder is the only member and the organizer\n askIf: always — but if they say it's just them, do not ask for more names\n- ein\n ask: Do you want Corpus to file for an EIN (federal tax ID) for $75?\n why: Needed to open a bank account or hire. You can also get one free at irs.gov after formation. Never ask for a Social Security Number.\n default: no\n askIf: only if the tool says the EIN add-on is available\n\nHOW TO FINISH: collect the answers above in conversation (formation.lookup_naics finds the industry code), then call formation.handoff with everything — it validates the draft for free and returns what is still missing. When it reports COMPLETE, give the user the returned link: they review the prefilled draft, sign in, and pay.", + "search": "1 of 1 results for \"business tax\" in MS (hybrid):\n\n1. Miss. Code § 67-1-5 — Definitions\n Mississippi · Mississippi Code\n As used in this chapter the following words shall have the meanings ascribed...\n node_id: 089a2104-d704-4e93-8072-5bbbabc7ace6", + "coverage": "Corpus covers 3 jurisdictions (2 fully searchable), 79,000 provisions (law with body text; 85,447 indexed sections and headings in total), and 320,000 searchable chunks (2 with published GIS). Rows flagged PLACEHOLDER are listed but not yet searchable — an empty search there is expected.\n\n- US — United States (federal): 54,000 provisions, 305,936 searchable chunks\n- CA-SF — San Francisco (municipal): 25,000 provisions, 12,785 searchable chunks · GIS: published (linked)\n- CA-SD — San Diego (municipal): PLACEHOLDER — listed but not yet searchable · GIS: published (pending)", + "handoff": "Draft saved into the link, but NOT yet complete.\n\nSTILL MISSING:\n- proposed business name\n- principal office address (street, city, state)\n- contact email\n- NAICS industry code\n- management type (member- or manager-managed)\n- at least one member\n- an organizer\n\nPRICE (live): $246.00 all-in — $50.00 state fees + $100.00 registered agent year 1 (included in the Corpus bundle; state requirements vary) + $96.00 Corpus service. Optional EIN add-on: +$75.00.\n\nBest path: collect the missing items from your user and call formation.handoff again — validation is free. Or hand off now and Corpus's agent will finish the intake in chat:\nhttps://corpuslaw.us/formation#prefill=eyJ2IjoyLCJlbnRpdHlUeXBlIjoibGxjIiwianVyaXNkaWN0aW9uIjoiVVMtTVMifQ\n\nPass businessActivity (one plain sentence, in the founder's words, of what the business does) so Corpus can research the licenses and permits that apply." +} diff --git a/autopilot/test/fixtures/irrelevant-plan-searches.json b/autopilot/test/fixtures/irrelevant-plan-searches.json new file mode 100644 index 0000000..b75a358 --- /dev/null +++ b/autopilot/test/fixtures/irrelevant-plan-searches.json @@ -0,0 +1,5 @@ +{ + "annual_report": "2 of 2 results for \"annual report\" in MS (keyword):\n\n1. Education achievement council — Annual report\n Mississippi · Synthetic negative-control code\n The education achievement council submits an annual report on its activities.\n node_id: 11111111-2222-4333-8444-555555555555\n\n2. Insurance society — Annual report\n Mississippi · Synthetic negative-control code\n An insurance society submits an annual report to the commissioner.\n node_id: aaaaaaaa-bbbb-4ccc-addd-eeeeeeeeeeee", + "state_tax": "1 of 1 results for \"business tax registration\" in MS (keyword):\n\n1. Aircraft registration\n Mississippi · Synthetic negative-control code\n Aircraft registration for business aircraft requires payment of the applicable tax.\n node_id: 22222222-3333-4444-8555-666666666666", + "local": "1 of 1 results for \"business license\" in MS-JXN (keyword):\n\n1. Dog license\n Jackson · Synthetic negative-control ordinance\n A dog owner must obtain a license.\n node_id: 33333333-4444-4555-8666-777777777777" +} diff --git a/autopilot/test/plan.test.ts b/autopilot/test/plan.test.ts new file mode 100644 index 0000000..3854b8c --- /dev/null +++ b/autopilot/test/plan.test.ts @@ -0,0 +1,400 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { buildPlan, handoffPlan } from "../src/plan.js"; +import { validatePlanInput } from "../src/plan-validation.js"; +import type { PlanInput } from "../src/plan-contract.js"; +import { exactLocalCoverage, parseNode, parseSearch } from "../src/plan-evidence.js"; +import { hostedTool, bounded, MAX_TOOL_BYTES, UnsupportedFormationError } from "../src/plan-tools.js"; +import type { ToolCall } from "../src/plan-tools.js"; +import hostedTranscripts from "./fixtures/autopilot-hosted-transcripts.json"; +import irrelevantSearches from "./fixtures/irrelevant-plan-searches.json"; + +const input: PlanInput = { mode: "ready", activity: "Fictional software business", formationState: "MS", operatingState: "TX", operatingCity: "Austin", entityType: "llc", campaign: {} }; +const catalog = `Formation requirements — LLC in MS (US-MS): + +REQUIRED (collect ALL of these from your user, in conversation): +- proposed name +- registered agent + +AFTER-FORMATION DUTIES in MS — tell your user about these BEFORE they pay: +- Annual report: confirm the current due date. + +EIN ADD-ON: needed for everyone. + +PRICE (live): $123.00 all-in — test quote only. + +FOUNDING DISCOUNT: $10 off only while slots remain; a quote does not reserve one.`; +const emptyTool = async () => "No results found."; +const uuid = "11111111-2222-4333-8444-555555555555"; +const otherId = "aaaaaaaa-bbbb-4ccc-addd-eeeeeeeeeeee"; +const search = (name = "Texas", id = uuid) => `1 of 1 results for "business tax registration" in TX (keyword):\n\n1. Tax Code § 1 — Registration\n ${name} · Tax Code\n Test source excerpt, not a conclusion.\n node_id: ${id}`; +const coverage = "- TX-AUS — Austin (municipal): 100 provisions, 80 searchable chunks"; +const node = (id = uuid, name = "Texas") => JSON.stringify({ nodeId: id, citation: "Tax Code § 1", bodyText: "Full test provision.", jurisdiction: { name } }); +function fixture(overrides: Partial> = {}): ToolCall { + return async (name, args) => overrides[name] ?? (name === "formation.requirements" ? catalog : name === "law.list_coverage" ? coverage : name === "law.get_node" ? node() : args.jurisdiction === "TX" ? search() : args.jurisdiction === "TX-AUS" ? search("Austin", otherId) : "No results found."); +} +afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.useRealTimers(); }); + +describe("sourced planning", () => { + it("withholds unvetted education, insurance, aircraft and local matches while preserving source gaps", async () => { + const tool = vi.fn(async (name, args) => name === "formation.requirements" ? catalog + : name === "law.list_coverage" ? "- MS-JXN — Jackson (municipal): 100 provisions, 80 searchable chunks" + : name === "law.search" ? args.query === "annual report" ? irrelevantSearches.annual_report + : args.query === "business tax registration" ? irrelevantSearches.state_tax : irrelevantSearches.local + : node()); + const plan = await buildPlan({ ...input, operatingState: "MS", operatingCity: "Jackson" }, { tool }); + expect(plan.sources.filter(s => s.kind === "law")).toEqual([]); + expect(plan.items.find(i => i.id === "annual_report")?.sourceIds).toEqual(["guide:annual"]); + expect(plan.items.find(i => i.id === "formation_duty_0")?.sourceIds).toEqual(["catalog:duty:0"]); + expect(plan.items.find(i => i.id === "state_tax")?.sourceIds).toEqual([]); + expect(plan.items.find(i => i.id === "local")?.sourceIds).toEqual([]); + expect(plan.items.find(i => i.id === "local")?.body).toContain("searchable entry for Jackson"); + expect(plan.warnings.filter(w => w.includes("No relevant provision was verified"))).toHaveLength(3); + expect(plan.tools).toContain("law.list_coverage"); + expect(tool.mock.calls.some(([name]) => name === "law.get_node")).toBe(false); + expect(plan.items.every(i => i.needsCheck)).toBe(true); + expect(JSON.stringify(plan)).not.toMatch(/Education achievement|Insurance society|Aircraft registration|Dog license/); + }); + it("rejects unsupported state and client-supplied sources", () => { + expect(() => validatePlanInput({ ...input, formationState: "ZZ" })).toThrow(); + expect(() => validatePlanInput({ ...input, sources: [] })).toThrow(); + }); + it("ignores injected model prose and fabricated item/citation IDs", async () => { + const p = await buildPlan(input, { tool: async n => n === "formation.requirements" ? catalog : emptyTool(), select: async () => ["invented-law", "entity", "https://evil.test"] }); + expect(p.items.some(i => i.id === "invented-law")).toBe(false); + expect(p.sources.some(s => s.kind === "law")).toBe(false); + expect(JSON.stringify(p)).not.toContain("needed for everyone"); + expect(p.items.find(i => i.category === "ein")?.body).toContain("no employees"); + }); + it("normalizes multiline descriptions and supplies bounded context to ordering", async () => { + expect(validatePlanInput({ ...input, activity: "software\nconsulting\tshop" }).activity).toBe("software consulting shop"); + const select = vi.fn(async () => ["entity"]); + const plan = await buildPlan(input, { tool: fixture(), select }); + expect(select.mock.calls[0]).toEqual([expect.any(Array), expect.any(AbortSignal), expect.objectContaining({ input, items: expect.arrayContaining([expect.objectContaining({ id: "entity", category: "entity" })]) })]); + expect(plan.items.find(i => i.id === "intake_0")?.body).toContain("proposed name"); + const formed = await buildPlan({ ...input, mode: "formed" }, { tool: fixture() }); + expect(formed.items.some(i => i.id.startsWith("intake_"))).toBe(false); + }); + it("never creates a handoff locally", async () => { + await expect(handoffPlan(input, { tool: emptyTool })).rejects.toThrow(); + }); + it.each([ + { mode: "other" }, { entityType: "corp" }, { operatingState: "PR" }, { formationState: "Mississippi" }, + { activity: "a".repeat(501) }, { operatingCity: "a".repeat(101) }, { activity: "line\u0000line" }, + { campaign: { ref: "x" } }, { campaign: { utm_source: "x".repeat(101) } }, + { campaign: { utm_source: "x&src=evil" } }, { campaign: [] }, { contactEmail: "a@b.test" }, + ])("rejects invalid input %j", delta => expect(() => validatePlanInput({ ...input, ...delta })).toThrow()); + + it.each(["exploring", "formed"] as const)("does not assume a state/entity in %s mode", async mode => { + const tool = vi.fn(emptyTool); + const p = await buildPlan({ ...input, mode, formationState: null, entityType: null, operatingState: null, operatingCity: "" }, { tool }); + expect(tool).not.toHaveBeenCalled(); + expect(p.pricing).toMatchObject({ available: false, state: null, lines: [] }); + expect(p.warnings.join(" ")).toContain("No entity type"); + expect(p.items.every(i => i.needsCheck)).toBe(true); + }); + it("binds curated sources only and ignores model-injected citations", async () => { + const p = await buildPlan(input, { tool: fixture(), select: async () => ["ein", `law:${otherId}`, "MS § invented"] }); + expect(p.sources.filter(s => s.kind === "law")).toEqual([]); + expect(p.items[0].id).toBe("ein"); + expect(p.items.every(i => i.sourceIds.every(id => p.sources.some(s => s.id === id)))).toBe(true); + expect(p.sources.find(s => s.id === "catalog:duty:0")?.excerpt).toBe("Annual report: confirm the current due date."); + expect(JSON.stringify(p)).not.toContain("MS § invented"); + const second = await buildPlan(input, { tool: fixture({ "law.search": "No results found." }) }); + expect(second.sources.some(s => s.kind === "law")).toBe(false); + }); + it("keeps formation and operating jurisdictions separate", async () => { + const tool = vi.fn(fixture({ "law.search": search("Mississippi") })); + const p = await buildPlan(input, { tool }); + expect(tool.mock.calls.some(([n, a]) => n === "law.search" && a.jurisdiction === "TX")).toBe(true); + expect(p.items.find(i => i.id === "state_tax")?.sourceIds).toEqual([]); + expect(p.items.find(i => i.id === "state_tax")?.body).toContain("Operating state: TX"); + expect(tool.mock.calls.some(([n, a]) => n === "law.search" && a.jurisdiction === "MS")).toBe(true); + expect(p.sources.filter(s => s.kind === "law")).toHaveLength(0); + expect(p.items.every(i => i.needsCheck)).toBe(true); + }); + it.each(["", "- TX-AUS — Austin (municipal): PLACEHOLDER — listed but not yet searchable", "- TX-AUS — Austin (municipal): 100 provisions, 0 searchable chunks", "- CA-AUS — Austin (municipal): 100 provisions, 10 searchable chunks", "- TX-AUS — Austin City (municipal): 100 provisions, 10 searchable chunks", coverage + "\n- TX-AUS2 — Austin (municipal): 100 provisions, 10 searchable chunks"])("does not search local law without exact unambiguous searchable coverage: %s", async cov => { + const tool = vi.fn(fixture({ "law.list_coverage": cov || "Coverage temporarily unavailable." })); + const p = await buildPlan(input, { tool }); + expect(tool.mock.calls.some(([n, a]) => n === "law.search" && a.jurisdiction === "TX-AUS")).toBe(false); + expect(p.warnings.join(" ")).toContain("Local coverage gap"); + }); + it.each(["Austin", "Austin, TX", "Austin, Texas", "Austin, tx"])("matches only selected-state city suffixes against confirmed coverage: %s", city => { + expect(exactLocalCoverage(coverage, "TX", city)?.code).toBe("TX-AUS"); + expect(exactLocalCoverage(coverage.replace("Austin (", "Austin, Texas ("), "TX", city)?.code).toBe("TX-AUS"); + }); + it.each(["Austin, CA", "Austin, California", "Austin, TX, USA", "Austin City, TX", "Austin, Tex", ", TX"])("rejects mismatched or fuzzy city input: %s", city => { + expect(exactLocalCoverage(coverage, "TX", city)).toBeNull(); + }); + it("accepts exact delivered searchable city entries", () => { + expect(exactLocalCoverage("- TX-AUS — Austin (municipal): delivered after the last daily count — searchable now; counts appear after the next daily refresh", "TX", "Austin")?.code).toBe("TX-AUS"); + }); + it("degrades on search and model failure without provider diagnostics", async () => { + const p = await buildPlan(input, { tool: async (n, a, s) => { if (n === "law.search") throw new Error("secret provider diagnostic"); return fixture()(n, a, s); }, select: async () => { throw new Error("secret provider diagnostic"); } }); + expect(p.items.length).toBeGreaterThanOrEqual(6); + expect(p.sources.some(s => s.kind === "law")).toBe(false); + expect(p.warnings.join(" ")).toContain("Qwen ordering was unavailable"); + expect(JSON.stringify(p)).not.toContain("secret provider"); + }); + it.each([catalog.replace("PRICE (live): $123.00 all-in — test quote only.", "PRICE: temporarily unavailable"), catalog.replace("MS (US-MS)", "TX (US-TX)"), "Error: unsupported entity", catalog.replace("PRICE (live): $123.00 all-in — test quote only.", "PRICE (live): unknown")])("withholds unavailable or unverified pricing", async text => { + const p = await buildPlan(input, { tool: fixture({ "formation.requirements": text }) }); + expect(p.pricing).toMatchObject({ available: false, lines: [] }); + }); + it("describes the unfetched IRS page without attributing Corpus copy to it", async () => { + const plan = await buildPlan(input, { tool: fixture() }); + expect(plan.sources.find(s => s.id === "guide:irs")?.excerpt).toBe("Official IRS page. No excerpt was retrieved; open the page for the current guidance."); + expect(plan.sources.find(s => s.id === "guide:ein")?.excerpt).toContain("A bank or state requirement"); + }); + it("preserves the complete current price and offer qualifier", async () => { + const p = await buildPlan(input, { tool: fixture() }); + expect(p.pricing).toMatchObject({ available: true, state: "MS", lines: ["PRICE (live): $123.00 all-in — test quote only.", "FOUNDING DISCOUNT: $10 off only while slots remain; a quote does not reserve one."] }); + expect(JSON.stringify(p)).not.toContain("needed for everyone"); + }); + it("uses a fixed local keyword query without sending the founder activity to law.search", async () => { + const tool = vi.fn(fixture()); + const plan = await buildPlan(input, { tool }); + const searches = tool.mock.calls.filter(([name]) => name === "law.search"); + expect(searches.every(([, args]) => !JSON.stringify(args).includes(input.activity))).toBe(true); + expect(searches.find(([, args]) => args.jurisdiction === "TX-AUS")?.[1]).toEqual({ + query: "business license", jurisdiction: "TX-AUS", mode: "keyword", limit: 2, + }); + expect(plan.items.find(i => i.id === "local")?.needsCheck).toBe(true); + expect(plan.items.find(i => i.id === "local")?.body).toContain("does not establish which rules apply"); + }); + it("uses five bounded tools without promoting a search hit to full provision evidence", async () => { + const tool = vi.fn(fixture()); + await buildPlan(input, { tool }); + expect(tool).toHaveBeenCalledTimes(5); + expect(tool.mock.calls.some(([name]) => name === "law.get_node")).toBe(false); + }); + it("cancels a hung tool and degrades deterministically", async () => { + let observed: AbortSignal | undefined; + const p = await buildPlan({ ...input, operatingState: null, operatingCity: "" }, { toolTimeoutMs: 5, tool: async (_n, _a, signal) => { observed = signal; return new Promise(() => {}); } }); + expect(observed?.aborted).toBe(true); + expect(p.pricing.available).toBe(false); + expect(p.warnings.join(" ")).toContain("formation.requirements was unavailable"); + }); + it("cancels a hung model without losing the plan", async () => { + let observed: AbortSignal | undefined; + const p = await buildPlan(input, { tool: fixture(), modelTimeoutMs: 5, select: async (_ids, s) => { observed = s; return new Promise(() => {}); } }); + expect(observed?.aborted).toBe(true); + expect(p.items.length).toBeGreaterThanOrEqual(6); + expect(p.tools).not.toContain("qwen.order_known_items"); + }); + it("returns curated output within 24 seconds for five 4.4-second tools and skips ordering", async () => { + vi.useFakeTimers(); + const started = Date.now(); + const signals: AbortSignal[] = []; + const select = vi.fn(async () => ["ein"]); + const tool = vi.fn(async (name, args, signal) => { + signals.push(signal); + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, 4400); + signal.addEventListener("abort", () => { clearTimeout(timer); reject(new Error("cancelled")); }, { once: true }); + }); + return fixture()(name, args, signal); + }) as ReturnType>; + let completedAt = 0; + const pending = buildPlan(input, { tool, select }).then(plan => { completedAt = Date.now(); return plan; }); + await vi.advanceTimersByTimeAsync(23500); + const plan = await pending; + expect(completedAt - started).toBe(22000); + expect(tool).toHaveBeenCalledTimes(5); + expect(signals.every(s => !s.aborted)).toBe(true); + expect(plan.pricing.available).toBe(true); + expect(plan.sources.some(s => s.kind === "law")).toBe(false); + expect(plan.items.find(i => i.id === "local")?.sourceIds).toEqual([]); + expect(plan.items.every(i => i.needsCheck)).toBe(true); + expect(plan.warnings.join(" ")).toContain("Qwen ordering was skipped"); + expect(select).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + it("skips calls without a useful remaining budget and retains deterministic items", async () => { + vi.useFakeTimers(); + const tool = vi.fn(async () => new Promise(() => {})); + const pending = buildPlan(input, { tool, timeoutMs: 5000 }); + await vi.advanceTimersByTimeAsync(4500); + const plan = await pending; + expect(tool).toHaveBeenCalledTimes(1); + expect(plan.items.length).toBeGreaterThanOrEqual(6); + expect(plan.warnings.join(" ")).toContain("was skipped to finish"); + expect(vi.getTimerCount()).toBe(0); + }); + it("caps tools at 4.5 seconds even when a larger per-tool limit is requested", async () => { + vi.useFakeTimers(); + let observed: AbortSignal | undefined; + const pending = buildPlan({ ...input, operatingState: null, operatingCity: "" }, { + toolTimeoutMs: 9000, tool: async (_n, _a, signal) => { observed = signal; return new Promise(() => {}); }, + }); + await vi.advanceTimersByTimeAsync(4500); + expect((await pending).items.length).toBeGreaterThanOrEqual(6); + expect(observed?.aborted).toBe(true); + }); + it("preserves caller cancellation even when tools ignore abort", async () => { + vi.useFakeTimers(); + const parent = new AbortController(); + let observed: AbortSignal | undefined; + const tool = vi.fn(async (_n, _a, signal) => { observed = signal; return new Promise(() => {}); }); + const pending = buildPlan(input, { tool }, parent.signal); + const rejected = expect(pending).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(100); + parent.abort(); + await rejected; + expect(observed?.aborted).toBe(true); + expect(tool).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + it("enforces the absolute deadline even when work ignores abort", async () => { + vi.useFakeTimers(); + const pending = bounded(async () => new Promise(() => {}), new AbortController().signal, 24000); + const rejected = expect(pending).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(24000); + await rejected; + expect(vi.getTimerCount()).toBe(0); + }); + it.each(["11111111-2222-0333-8444-555555555555", "11111111-2222-9333-8444-555555555555", "11111111-2222-4333-4444-555555555555"])("rejects law UUIDs incompatible with the web validator: %s", id => { + expect(parseSearch(search("Texas", id), "Texas")).toEqual([]); + expect(parseNode(node(id), id, "Texas")).toBeNull(); + }); + it("bounds node warning plus excerpt to 5000 characters and marks truncation", () => { + const prefix = "WARNING: " + "w".repeat(1991); + const bodyText = "b".repeat(7000); + const text = prefix + "\n" + JSON.stringify({ nodeId: uuid, citation: "Tax Code § 1", bodyText, jurisdiction: { name: "Texas" } }); + const source = parseNode(text, uuid, "Texas")!; + expect(source.excerpt).toHaveLength(5000); + expect(source.excerpt).toContain(prefix + "\n"); + expect(source.excerpt).toMatch(/…truncated; open the source for the complete provision\.$/); + expect(parseNode(node(), uuid, "Texas")?.excerpt).toBe("Full test provision."); + }); + it("parses node warnings and rejects mismatched/malformed evidence", () => { + expect(parseNode("NOT YET IN EFFECT: test warning.\n" + node(), uuid, "Texas")?.excerpt).toContain("NOT YET IN EFFECT"); + expect(parseNode(node(otherId), uuid, "Texas")).toBeNull(); + expect(parseNode(node(uuid, "Mississippi"), uuid, "Texas")).toBeNull(); + expect(parseNode("arbitrary prose " + node(), uuid, "Texas")).toBeNull(); + expect(parseNode(node() + " broken", uuid, "Texas")).toBeNull(); + expect(parseSearch(search("Texas", "fabricated"), "Texas")).toEqual([]); + expect(parseSearch(`node_id: ${uuid}`, "Texas")).toEqual([]); + expect(parseSearch("NOTE: results are PARTIAL\n\n" + search(), "Texas")[0].excerpt).toContain("PARTIAL"); + }); +}); + +describe("hosted formation handoff", () => { + const link = "https://corpuslaw.us/formation#prefill=abc_DEF-1&src=opaque.token-D&ref=Original_Code"; + const text = `Draft saved into the link, but NOT yet complete.\n\nSTILL MISSING:\n- proposed name\n- contact email\n\nOr hand off now:\n${link}`; + it("uses minimal hosted draft and preserves attribution byte-for-byte while appending UTMs before hash", async () => { + const tool = vi.fn(async () => text); + const result = await handoffPlan({ ...input, campaign: { utm_source: "qwen", utm_campaign: "founder_launch" } }, { tool }); + expect(tool.mock.calls[0]).toEqual(["formation.handoff", { entityType: "llc", state: "MS", businessActivity: input.activity }, expect.any(AbortSignal)]); + expect(result).toEqual({ url: link.replace("#", "?utm_source=qwen&utm_campaign=founder_launch#"), missing: ["proposed name", "contact email"] }); + }); + it("returns complete hosted draft without invented missing fields", async () => { + expect(await handoffPlan(input, { tool: async () => `✓ Draft COMPLETE — every required intake field is collected.\n\n${link}` })).toEqual({ url: link, missing: [] }); + }); + it.each(["exploring", "formed"] as const)("rejects %s without calling the tool", async mode => { + const tool = vi.fn(emptyTool); + await expect(handoffPlan({ ...input, mode }, { tool })).rejects.toThrow(); + expect(tool).not.toHaveBeenCalled(); + }); + it.each(["https://evil.test/formation#prefill=abc", "https://corpuslaw.us.evil.test/formation#prefill=abc", "https://corpuslaw.us/formation/other#prefill=abc", "https://corpuslaw.us:443/formation#prefill=abc", "https://user@corpuslaw.us/formation#prefill=abc", "http://corpuslaw.us/formation#prefill=abc", "https://corpuslaw.us/formation#prefill=abc&redirect=evil", "https://corpuslaw.us/x/../formation#prefill=abc"])("fails closed for malicious/noncanonical handoff URL %s", async url => { + await expect(handoffPlan(input, { tool: async () => text.replace(link, url) })).rejects.toThrow("unavailable"); + }); + it("fails closed when missing-field status cannot be parsed", async () => { + await expect(handoffPlan(input, { tool: async () => link })).rejects.toThrow("unavailable"); + }); + it.each(["?src=original&ref=old", "?redirect=https://evil.test"])("rejects unsupported hosted query %s", async query => { + await expect(handoffPlan(input, { tool: async () => text.replace(link, link.replace("#", query + "#")) })).rejects.toThrow("unavailable"); + }); + it("rejects duplicate attribution fragment values", async () => { + await expect(handoffPlan(input, { tool: async () => text.replace(link, link + "&src=other") })).rejects.toThrow("unavailable"); + }); + it.each([{ formationState: null }, { entityType: null }])("requires explicit formation choices: %j", async delta => { + const tool = vi.fn(emptyTool); + await expect(handoffPlan({ ...input, ...delta }, { tool })).rejects.toThrow(); + expect(tool).not.toHaveBeenCalled(); + }); +}); + +describe("MCP wire transport", () => { + it.each([true, false])("reads the real text envelope (structured=%s) and forwards configured API key", async structured => { + vi.stubEnv("CORPUS_API_KEY", "test-key-not-a-secret"); + const fetcher = vi.fn(async () => new Response(JSON.stringify({ result: structured ? { structuredContent: { text: catalog } } : { content: [{ type: "text", text: catalog }] } }))); + vi.stubGlobal("fetch", fetcher); + expect(await hostedTool("formation.requirements", { state: "MS", entityType: "llc" }, new AbortController().signal)).toBe(catalog); + const options = fetcher.mock.calls[0][1] as RequestInit; + expect(options.headers).toMatchObject({ Authorization: "Bearer test-key-not-a-secret" }); + expect(JSON.parse(options.body as string).params.name).toBe("formation.requirements"); + }); + it.each([{ error: { message: "provider diagnostics" } }, { result: { isError: true, content: [{ type: "text", text: catalog }] } }, { result: { structuredContent: { isError: true, text: catalog } } }, { result: { nodes: [{ node_id: uuid }] } }])("fails closed on malformed/failed tool envelopes", async body => { + vi.stubGlobal("fetch", async () => new Response(JSON.stringify(body))); + await expect(hostedTool("law.search", {}, new AbortController().signal)).rejects.toThrow(); + }); + it.each([true, false])("categorizes unsupported formation isError without disclosing hosted diagnostics (structured=%s)", async structured => { + const diagnostic = "Nonprofit formation is not available in US-MS. LLC formation is available in supported states; internal diagnostic marker."; + vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ result: structured + ? { structuredContent: { isError: true, text: diagnostic } } + : { isError: true, content: [{ type: "text", text: diagnostic }] } }))); + const plan = await buildPlan({ ...input, entityType: "nonprofit", operatingState: null, operatingCity: "" }); + expect(plan.warnings.join(" ")).toContain("does not currently support formation"); + expect(plan.pricing.available).toBe(false); + expect(JSON.stringify(plan)).not.toContain("internal diagnostic"); + await expect(handoffPlan({ ...input, entityType: "nonprofit" })).rejects.toBeInstanceOf(UnsupportedFormationError); + await expect(handoffPlan({ ...input, entityType: "nonprofit" })).rejects.toThrow("Choose another state or entity"); + }); + it.each([ + { state: "TX", isError: true, name: "formation.requirements" }, + { state: "MS", isError: false, name: "formation.requirements" }, + { state: "MS", isError: true, name: "law.search" }, + ] as const)("never promotes unverified unsupported text to a semantic error: %j", async ({ state, isError, name }) => { + const diagnostic = "Nonprofit formation is not available in US-MS. LLC formation is available in supported states; private details."; + vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ result: { isError, structuredContent: { text: diagnostic } } }))); + const pending = hostedTool(name, { state, entityType: "nonprofit" }, new AbortController().signal); + if (isError) { + await expect(pending).rejects.not.toBeInstanceOf(UnsupportedFormationError); + await expect(pending).rejects.toThrow("Tool failed"); + } else await expect(pending).resolves.toBe(diagnostic); + }); + it("bounds response bytes before JSON parsing", async () => { + vi.stubGlobal("fetch", async () => new Response("a".repeat(MAX_TOOL_BYTES + 1))); + await expect(hostedTool("law.search", {}, new AbortController().signal)).rejects.toThrow("too large"); + }); + it("handles already-cancelled requests", async () => { + const controller = new AbortController(); controller.abort(); + const work = vi.fn(async () => 1); + await expect(bounded(work, controller.signal, 10)).rejects.toThrow("timed out"); + expect(work).not.toHaveBeenCalled(); + }); +}); + + +describe("real hosted formatter contract", () => { + it("reads actual law.search text and preserves the hosted node ID", () => { + expect(parseSearch(hostedTranscripts.search, "Mississippi")).toEqual([expect.objectContaining({ + nodeId: "089a2104-d704-4e93-8072-5bbbabc7ace6", label: "Miss. Code § 67-1-5 — Definitions", + excerpt: "As used in this chapter the following words shall have the meanings ascribed...", + })]); + expect(parseSearch(hostedTranscripts.search, "Texas")).toEqual([]); + }); + it("reads actual coverage text and rejects its San Diego placeholder", () => { + expect(exactLocalCoverage(hostedTranscripts.coverage, "CA", "San Francisco, CA")).toEqual({ code: "CA-SF", name: "San Francisco" }); + expect(exactLocalCoverage(hostedTranscripts.coverage, "CA", "San Diego")).toBeNull(); + expect(exactLocalCoverage(hostedTranscripts.coverage, "TX", "San Francisco, CA")).toBeNull(); + }); + it("keeps actual requirements but withholds unvetted alcohol-law search matches", async () => { + const plan = await buildPlan({ ...input, operatingState: "MS", operatingCity: "" }, { + tool: async name => name === "formation.requirements" ? hostedTranscripts.requirements + : name === "law.search" ? hostedTranscripts.search : "No results found.", + }); + expect(plan.items.find(i => i.id === "intake_0")?.body).toContain("- proposed business name"); + expect(plan.items.find(i => i.id === "state_tax")?.sourceIds).toEqual([]); + expect(plan.sources.some(s => s.kind === "law")).toBe(false); + expect(plan.warnings.join(" ")).toContain("No relevant provision was verified"); + expect(plan.pricing.available).toBe(true); + expect(plan.pricing.lines).toEqual(hostedTranscripts.requirements.split("\n").filter(line => line.startsWith("PRICE (live):"))); + expect(plan.pricing.lines[0]).toContain("included in the Corpus bundle; state requirements vary"); + expect(plan.items.every(i => i.needsCheck)).toBe(true); + }); + it("reads the actual hosted handoff URL and its missing fields", async () => { + const result = await handoffPlan(input, { tool: async () => hostedTranscripts.handoff }); + expect(result.url).toBe(hostedTranscripts.handoff.split("\n").find(line => line.startsWith("https://"))); + expect(result.missing).toEqual(["proposed business name", "principal office address (street, city, state)", "contact email", "NAICS industry code", "management type (member- or manager-managed)", "at least one member", "an organizer"]); + }); +}); diff --git a/autopilot/test/server.test.ts b/autopilot/test/server.test.ts index f742d3c..0c6c6e3 100644 --- a/autopilot/test/server.test.ts +++ b/autopilot/test/server.test.ts @@ -1,71 +1,126 @@ -import { describe, it, expect, afterEach } from "vitest"; -import { createServer } from "../src/server.js"; -import type { AddressInfo } from "node:net"; +import { afterEach, describe, it, expect, vi } from "vitest"; +import { createServer, MAX_PLAN_BODY_BYTES, MAX_PLAN_RESPONSE_BYTES } from "../src/server.js"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; -let server: ReturnType | null = null; -afterEach(() => new Promise((r) => (server ? server.close(() => r()) : r()))); - -function start(deps?: any) { - server = createServer(deps); - return new Promise((resolve) => { - server!.listen(0, () => resolve(`http://127.0.0.1:${(server!.address() as AddressInfo).port}`)); - }); +// Invoke the real request listener without opening sockets or making network calls. +function client(deps: Parameters[0] = {}) { + const server = createServer(deps); + function request(path: string, options: { method?: string; body?: string; incomplete?: boolean; contentLength?: number } = {}) { + const req = new PassThrough() as PassThrough & { method: string; url: string; headers: Record; complete: boolean }; + req.method = options.method ?? "POST"; req.url = path; + req.headers = options.contentLength === undefined ? {} : { "content-length": String(options.contentLength) }; + req.complete = !options.incomplete; + const res = new EventEmitter() as any; + res.destroyed = false; res.writableFinished = false; + const headers = new Headers(); + res.setHeader = (key: string, value: string) => headers.set(key, value); + let status = 200; + res.writeHead = (code: number, extra: Record = {}) => { + status = code; Object.entries(extra).forEach(([k, v]) => headers.set(k, v)); return res; + }; + const response = new Promise(resolve => { + res.end = (body = "") => { + res.writableFinished = true; + resolve(new Response(status === 204 ? null : body, { status, headers })); + queueMicrotask(() => res.emit("finish")); + }; + }); + server.emit("request", req, res); + if (options.incomplete) { if (options.body) req.write(options.body); } + else req.end(options.body ?? ""); + return { response, req, res }; + } + const post = (path: string, body: unknown) => request(path, { body: JSON.stringify(body) }).response; + return { request, post }; } +const messages = { messages: [{ role: "user", content: "hello" }] }; +const input = { mode: "ready", activity: "Software", operatingState: "TX", operatingCity: "Austin", formationState: "MS", entityType: "llc", campaign: {} }; +afterEach(() => vi.unstubAllEnvs()); -describe("server", () => { +describe("server (no network)", () => { it("healthz responds ok", async () => { - const base = await start(); - expect(await (await fetch(`${base}/healthz`)).text()).toBe("ok"); + expect(await (await client().request("/healthz", { method: "GET" }).response).text()).toBe("ok"); }); - it("POST /api/chat runs the agent", async () => { - const base = await start({ run: async () => ({ reply: "hi", toolCalls: [] }) }); - const res = await fetch(`${base}/api/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages: [{ role: "user", content: "hello" }] }), - }); + it("POST /api/chat runs the legacy agent", async () => { + const res = await client({ run: async () => ({ reply: "hi", toolCalls: [] }) }).post("/api/chat", messages); expect(res.status).toBe(200); expect((await res.json()).reply).toBe("hi"); }); - it("answers CORS preflight and sets allow-origin on responses", async () => { - const base = await start({ run: async () => ({ reply: "hi", toolCalls: [] }) }); - const pre = await fetch(`${base}/api/chat`, { method: "OPTIONS" }); + it("answers CORS preflight and sets allow-origin", async () => { + const c = client({ run: async () => ({ reply: "hi", toolCalls: [] }) }); + const pre = await c.request("/api/plan", { method: "OPTIONS" }).response; expect(pre.status).toBe(204); expect(pre.headers.get("access-control-allow-origin")).toBe("*"); expect(pre.headers.get("access-control-allow-headers")).toContain("Content-Type"); - const res = await fetch(`${base}/api/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages: [{ role: "user", content: "hello" }] }), - }); - expect(res.headers.get("access-control-allow-origin")).toBe("*"); + expect((await c.post("/api/chat", messages)).headers.get("access-control-allow-origin")).toBe("*"); }); - it("rejects malformed bodies with 400", async () => { - const base = await start({ run: async () => ({ reply: "x", toolCalls: [] }) }); - const res = await fetch(`${base}/api/chat`, { method: "POST", body: "not json" }); - expect(res.status).toBe(400); + it.each(["/api/chat", "/api/plan"])("rejects malformed JSON on %s", async path => { + expect((await client().request(path, { body: "not json" }).response).status).toBe(400); }); - it("enforces the hourly spend cap with 429", async () => { - process.env.SPEND_CAP_TURNS_PER_HOUR = "1"; - const base = await start({ run: async () => ({ reply: "x", toolCalls: [] }) }); - const post = () => - fetch(`${base}/api/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }), - }); - expect((await post()).status).toBe(200); - expect((await post()).status).toBe(429); - delete process.env.SPEND_CAP_TURNS_PER_HOUR; - }); - it("preserves non-Error throw values in 500 error body", async () => { - const base = await start({ run: async () => { throw "oops"; } }); - const res = await fetch(`${base}/api/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages: [{ role: "user", content: "hello" }] }), - }); + it("preserves legacy error behavior", async () => { + const res = await client({ run: async () => { throw "oops"; } }).post("/api/chat", messages); expect(res.status).toBe(500); expect((await res.json()).error).toBe("oops"); }); + it("returns PlanResponse and PlanHandoff directly", async () => { + const plan = { version: 1 as const, createdAt: "now", items: [], sources: [], pricing: { available: false, state: null, lines: [], checkedAt: "now" }, tools: [], warnings: [] }; + const handoff = { url: "https://corpuslaw.us/formation#prefill=test", missing: ["name"] }; + const c = client({ plan: async () => plan, handoff: async () => handoff }); + const res = await c.post("/api/plan", input); + expect(await res.json()).toEqual(plan); + expect(res.headers.get("cache-control")).toBe("no-store"); + expect(await (await c.post("/api/plan/handoff", input)).json()).toEqual(handoff); + }); + it("rejects client source records before executing a plan", async () => { + const plan = vi.fn(); + expect((await client({ plan }).post("/api/plan", { ...input, sources: [] })).status).toBe(400); + expect(plan).not.toHaveBeenCalled(); + }); + it.each([false, true])("bounds body bytes (declared length=%s)", async declared => { + const plan = vi.fn(); + const res = await client({ plan }).request("/api/plan", { body: "x".repeat(MAX_PLAN_BODY_BYTES + 1), ...(declared ? { contentLength: MAX_PLAN_BODY_BYTES + 1 } : {}) }).response; + expect(res.status).toBe(413); expect(plan).not.toHaveBeenCalled(); + }); + it("bounds legacy body buffering", async () => { + expect((await client().request("/api/chat", { body: "x".repeat(65537) }).response).status).toBe(413); + }); + it("bounds serialized plan output", async () => { + const plan = async () => ({ version: 1 as const, createdAt: "now", items: [], sources: [], pricing: { available: false, state: null, lines: [], checkedAt: "now" }, tools: [], warnings: ["x".repeat(MAX_PLAN_RESPONSE_BYTES)] }); + expect((await client({ plan }).post("/api/plan", input)).status).toBe(502); + }); + it("times out a partial body", async () => { + expect((await client({ bodyTimeoutMs: 5 }).request("/api/plan", { body: "{", incomplete: true }).response).status).toBe(408); + }); + it("cancels timed-out handlers and hides diagnostics", async () => { + let signal: AbortSignal | undefined; + const res = await client({ requestTimeoutMs: 5, plan: async (_i, _d, s) => { signal = s; return new Promise(() => {}); } }).post("/api/plan", input); + expect(res.status).toBe(504); expect(signal?.aborted).toBe(true); + const failed = await client({ plan: async () => { throw new Error("private provider/key diagnostic"); } }).post("/api/plan", input); + expect(failed.status).toBe(500); expect(await failed.text()).not.toContain("private"); + }); + it("cancels downstream work when the client disconnects", async () => { + let signal: AbortSignal | undefined; + let started!: () => void; + const ready = new Promise(resolve => { started = resolve; }); + const { response, res } = client({ plan: async (_i, _d, s) => { signal = s; started(); return new Promise(() => {}); } }).request("/api/plan", { body: JSON.stringify(input) }); + await ready; res.emit("close"); await response; + expect(signal?.aborted).toBe(true); + }); + it("shares the existing cap across chat, plan and handoff", async () => { + vi.stubEnv("SPEND_CAP_TURNS_PER_HOUR", "1"); + const plan = vi.fn(); const handoff = vi.fn(); + const c = client({ run: async () => ({ reply: "x", toolCalls: [] }), plan, handoff }); + expect((await c.post("/api/chat", messages)).status).toBe(200); + expect((await c.post("/api/plan", input)).status).toBe(429); + expect((await c.post("/api/plan/handoff", input)).status).toBe(429); + expect(plan).not.toHaveBeenCalled(); expect(handoff).not.toHaveBeenCalled(); + }); + it("does not consume the cap for health and OPTIONS", async () => { + vi.stubEnv("SPEND_CAP_TURNS_PER_HOUR", "1"); + const c = client({ run: async () => ({ reply: "x", toolCalls: [] }) }); + await c.request("/healthz", { method: "GET" }).response; + await c.request("/api/plan", { method: "OPTIONS" }).response; + expect((await c.post("/api/chat", messages)).status).toBe(200); + }); }); diff --git a/docs/autopilot-phase1-release.md b/docs/autopilot-phase1-release.md new file mode 100644 index 0000000..cc23030 --- /dev/null +++ b/docs/autopilot-phase1-release.md @@ -0,0 +1,145 @@ +# Phase 1 sourced planning backend + +## Deployment state — 2026-10-26 + +The approved Function Compute code-only deployment is complete at source commit +`cb3999ba87ebf914e01407902aaea1716be946f0`, with FC code checksum +`13529343312431775614`. Runtime, environment and function configuration were verified +unchanged. The matching Corpus web is live at source +`c31b01adb307ad37302292f25ef03a67855f5bbd`, deployment +`dpl_8wG2Rqp8yZqYdbAaWxkkk8VeagaA` +([deployment URL](https://corpus-4hw3j0gf6-teakesdev-7793s-projects.vercel.app)). +Final production postchecks are pending; +the Autopilot email feature flag remains `false`. This is not a completed email launch. + +Production smoke exposed unrelated annual-report and aircraft-registration search +matches in an MS LLC software-consulting plan. The deployed relevance mitigation +withholds every broad annual-report, tax-registration and local-license search +result from the plan's source ledger and item bindings. Search calls remain only +in the diagnostic tool trace; they do not verify a plan statement. The first tax +hit is no longer promoted through `law.get_node`. Reviewed guide/catalog sources, +exact local coverage status and explicit unverified-provision gaps remain. +There is no heuristic or model-based applicability approval. + +The initial postdeployment FC smoke returned HTTP 200 in 3.85 seconds, with +7 items, 7 guide/catalog sources, zero law sources and all item bindings resolving. +The safe deployment receipt is +`/Volumes/WorkSSD/Artifacts/corpus/autopilot-production-rollout/fc-relevance-deployment.json`. +This check does not complete the email, alert or qualitative source-review pilot. + +The mitigation received independent Opus approval. At the exact deployed source +commit, `npm test` passed 144 Autopilot tests and 6 MCP bridge tests plus plugin/Codex +skill drift checks; `npm run build`, `npm run typecheck` and `git diff --check` all +exited 0. Logs and the commit/checkout/command manifest are preserved under +`/Volumes/WorkSSD/Artifacts/corpus/autopilot-production-rollout/source-relevance/cb3999ba87ebf914e01407902aaea1716be946f0/`. +Removing the remaining diagnostic-only searches is a follow-up suggestion, not +part of this deployed mitigation. The 20-plan qualitative review remains open. + +## Backend contract + +The FC API adds `POST /api/plan` (`PlanInput` → `PlanResponse`) and +`POST /api/plan/handoff` (`PlanInput` → `PlanHandoff`). Wire types are mirrored +in `autopilot/src/plan-contract.ts`. `/api/chat` remains available. + +Deploy FC before the web frontend, under separate deployment authorization. +An older FC returns 404: the frontend must preserve input and show its unavailable +fallback, never render legacy generated legal prose as a sourced plan. + +Plans use the hosted requirements catalog and reviewed guide sources. Broad law +search results are withheld because their relevance to the plan is unverified. +Evidence binding establishes provenance, not applicability or completeness. +Unknown requirements remain Needs checking. No purchase or filing happens here. + +The plan accepts only the seven `PlanInput` fields, bare US state/DC codes or +null, and `llc`/`nonprofit`/null. All items remain `needsCheck: true`: there is no +applicability checker. Catalog items identify the selected formation state; +tax searches use the operating state. Exact, unambiguous searchable municipal +coverage is required before a diagnostic local search. Neither coverage nor a +successful search establishes applicable requirements. Catalog text-format drift +fails closed. + +Pricing requires one valid current `PRICE (live)` line from the selected +formation catalog. Complete price/offer qualifiers are preserved; unavailable +prices and offers are withheld. `checkedAt` is a quote timestamp, not a reservation; +the web frontend must mark saved quotes stale and refresh before review. + +The handoff accepts ready mode with explicit state/entity, calls the hosted +`formation.handoff` only, and returns its missing fields. It appends sanitized +campaign query parameters before the fragment, preserving hosted `src`/`ref` +bytes. Only the exact HTTPS `corpuslaw.us/formation` destination is accepted. +There is no local prefill fallback. Normal hosted regulatory-note behavior may +occur; this backend starts no additional jobs, checkout or filing. + +Limits: 16 KiB plan request body, 5s body deadline, 24s planner deadline (25s HTTP +handler backstop), at most five hosted tool calls on the current path (the six-call +guard remains), including three diagnostic searches of two hits each, +4.5s per tool, 128 KiB per tool response, up to twelve plan items, at most eighteen source +records, ten pricing lines/16 KiB, and 96 KiB serialized HTTP response. Optional +Qwen ordering uses the existing fast lane, a 2s timeout, no retries and 256 output +tokens. Model IDs are intersected with candidates; all candidates remain present. +Failures retain deterministic items. Total planner timeout returns generic 504. +Disconnects cancel downstream requests. No diagnostics or plan text are logged. +The existing process-local hourly request counter is shared with `/api/chat`; +it is not a global dollar cap and its configuration is unchanged. Legacy chat +keeps its behavior, with a new 64 KiB/5s body-buffer safety limit. + +## Initial validation history — 2026-10-25 + +Initial validation, from +`/Users/ty/dev/.worktrees/corpus-agent-kit/autopilot-phase1`, implementation commit +`536d1fb6f4943cb1bae5549a307c3a58889e04c3`: + +- `npm test` — exit 0: 114 autopilot tests and 6 MCP bridge tests (120 total), + plus plugin/Codex skill drift checks. Includes 64 sourced-planning tests and + 17 in-process HTTP tests. All tool/model calls mocked; no sockets or live calls. +- `npm run typecheck` — exit 0 across all three workspaces. +- `npm run build` — exit 0 across all three workspaces, including widget budgets. +- `git diff --check` and exact wire-contract `cmp` — exit 0. + +Logs are preserved under `/Volumes/WorkSSD/Artifacts/corpus/autopilot-phase1-20261025`. +The initial socket-based HTTP tests failed with sandbox `listen EPERM`; they were +replaced by direct invocation of the real HTTP listener, and the full gate above +then passed. No dependencies, credentials, provider settings or billing changed. +Ty-assigned Opus 5.5 reviewed the initial implementation (APPROVE_WITH_NITS, no P0/P1); +the follow-up review confirmed all four P2 corrections resolved, with no P0/P1/P2. +Deployment had not occurred at this initial validation; the current deployed +state is recorded above. The implementation and mitigation are pushed on existing +PR [#11](https://github.com/teakesdev/corpus-agent-kit/pull/11); the PR is not merged. + +The controller took over after the delegate's sandbox could not write linked +Git metadata. Controller integration adds the actual catalog intake fields, +bounded intake/step context for Qwen ordering, multiline description normalization, +and stricter handoff query/fragment checks. The web tests consume an actual +`buildPlan` fixture including an IRS source and a 1,079-character offer qualifier. +No secret or live offer is contained in that fixture. + +The initial Corpus web implementation landed as `df44673fdab41da7ca497743d82da58e51886d01` after its +local unit/typecheck/docs gate. Its new route policy resolves the concurrent Phase 0 replay +finding with masks and a blocked prefill link. That initial landing was not deployment; +the current live web source is recorded above. + + +## Review corrections — 2026-10-25 + +Code commit `a4454e5a565e5df07bc548c19e77119fa189779f`, tested from +`/Users/ty/dev/.worktrees/corpus-agent-kit/autopilot-review-fixes`: + +- IRS guide entry states that no excerpt was retrieved; Corpus guide text keeps its own source. +- Tool budgets account for remaining overall time and reserve 500 ms to return partial results; + optional model ordering is skipped when time is short. Parent disconnect still cancels work. +- UUID rules match the web; full-provision excerpts are bounded and visibly truncated. +- Unsupported combinations get categorical errors, exact city/state suffixes are accepted, and + local search uses fixed keywords instead of the founder's activity sentence. +- Actual Corpus formatter fixtures cover requirements, search, coverage and handoff parsing. + +Controller independently reran `npm test` (143 autopilot + 6 MCP bridge = 149 tests, no skips), +`npm run typecheck` and `npm run build`; all exited 0. Wire contracts and the shared hosted fixture +compare byte-for-byte. Tools/models are mocked; no live metered call or filing occurred. + +Web fixes separately preserve progress through refresh, use monotonic quote age and respect the +public order-pause signal. The approved FC and matching web deployments are recorded above. +Monitor shared free-tier credits and regulatory-notes cost during the article pilot; coverage caching +is deferred. The 20-plan qualitative review remains open. Corpus Phase 2 resume/delete +endpoints are deployed and passed the public 404/200 fake-token smoke checks; +the email-status endpoint reports disabled. The cleanup cron is registered, with +scheduled-run success still pending. New email sending remains disabled pending checks.