Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions src/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,29 @@ import { applyBridgeToProcessEnv, buildBridgeValues, syncGradleProperties } from
import { startRails, type RailsHandle } from "./rails-lifecycle.js";
import { isStub } from "./stub.js";
import { trace } from "./trace.js";
import { buildRunReport, writeReport, type ReportFormat, type ReportPaths } from "./report/collect.js";
import { readPackageVersion } from "./version.js";
import type { RunReport } from "./report/model.js";
import type { JudgeResult } from "./agents/types.js";

export async function dispatch(spec: string): Promise<JudgeResult> {
export type DispatchReportOptions = {
enabled?: boolean;
format?: ReportFormat;
embed?: boolean;
dir?: string;
};

export type DispatchOptions = {
report?: DispatchReportOptions;
};

export type DispatchResult = JudgeResult & {
report: RunReport;
reportPaths: ReportPaths;
};

export async function dispatch(spec: string, options: DispatchOptions = {}): Promise<DispatchResult> {
const startedAt = Date.now();
const domain = await runPlanner(spec);

// Mirror the substrate's NATIVEAPPTEMPLATE_API_* config to the
Expand Down Expand Up @@ -98,8 +118,9 @@ export async function dispatch(spec: string): Promise<JudgeResult> {
trace("dispatch", `rails-lifecycle: live at ${railsServer.url} for Stage 2`);
}

let judge: JudgeResult;
try {
return await runJudge({
judge = await runJudge({
domain,
rails,
ios,
Expand All @@ -115,4 +136,33 @@ export async function dispatch(spec: string): Promise<JudgeResult> {
});
}
}

const report = buildRunReport({
spec,
domain,
judge,
reviewer,
agentVersion: readPackageVersion(),
judgeModel: "claude-opus-4-7",
visualLevel: visualLevel as 0 | 1 | 2,
startedAt,
finishedAt: Date.now(),
});

// Default off in stub mode so the test suite never writes into ./out.
const reportOpts = options.report ?? {};
const reportEnabled = reportOpts.enabled ?? !isStub("dispatch");
let reportPaths: ReportPaths = {};
if (reportEnabled) {
const dir = reportOpts.dir ?? resolve(process.cwd(), "out", domain.slug);
reportPaths = await writeReport(report, {
dir,
...(reportOpts.format !== undefined ? { format: reportOpts.format } : {}),
...(reportOpts.embed !== undefined ? { embed: reportOpts.embed } : {}),
});
const written = Object.values(reportPaths).filter(Boolean);
if (written.length > 0) trace("dispatch", `report: wrote ${written.join(", ")}`);
}

return { ...judge, report, reportPaths };
}
41 changes: 37 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,61 @@
#!/usr/bin/env node
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dispatch } from "./dispatch.js";
import { spawn } from "node:child_process";
import { dispatch, type DispatchReportOptions } from "./dispatch.js";
import { loadDotenvIfPresent } from "./env.js";

loadDotenvIfPresent();

type ParsedArgs = { spec: string; report: DispatchReportOptions; open: boolean };

function parseArgs(argv: readonly string[]): ParsedArgs {
const specParts: string[] = [];
const report: DispatchReportOptions = {};
let open = false;
for (const arg of argv) {
if (arg === "--no-report") report.enabled = false;
else if (arg === "--report-open") open = true;
else if (arg.startsWith("--report-format=")) {
const value = arg.slice("--report-format=".length);
if (value === "html" || value === "json" || value === "both") report.format = value;
} else if (arg.startsWith("--report-embed=")) {
report.embed = arg.slice("--report-embed=".length) !== "false";
} else {
specParts.push(arg);
}
}
return { spec: specParts.join(" ").trim(), report, open };
}

export async function main(spec?: string): Promise<void> {
const input = spec ?? process.argv.slice(2).join(" ").trim();
const parsed = parseArgs(process.argv.slice(2));
const input = (spec ?? parsed.spec).trim();
if (!input) {
console.error('Usage: nativeapptemplate-agent "your spec here"');
console.error(
'Usage: nativeapptemplate-agent "your spec here" [--no-report] [--report-format=html|json|both] [--report-embed=true|false] [--report-open]',
);
process.exitCode = 1;
return;
}

console.log(`nativeapptemplate-agent: received spec: ${input}`);
console.log('(tail tmp/trace/*.log in a tiled view via scripts/demo-tmux.sh)');

const result = await dispatch(input);
const result = await dispatch(input, { report: parsed.report });

console.log('');
console.log('=== run complete ===');
console.log(`result: ${result.summary}`);
console.log(`overall: ${result.overallPass ? 'PASS' : 'FAIL'}`);
if (result.reportPaths.htmlPath) {
console.log(`report: file://${result.reportPaths.htmlPath}`);
if (parsed.open && process.platform === 'darwin') {
spawn('open', [result.reportPaths.htmlPath], { stdio: 'ignore', detached: true }).unref();
}
} else if (result.reportPaths.jsonPath) {
console.log(`report: ${result.reportPaths.jsonPath}`);
}
}

// Entry guard: run main() when this file is the program entry point. Resolve
Expand Down
17 changes: 5 additions & 12 deletions src/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
#!/usr/bin/env node
import { realpathSync, readFileSync } from "node:fs";
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { dispatch } from "./dispatch.js";
import { loadDotenvIfPresent } from "./env.js";
import { readPackageVersion } from "./version.js";

// MCP surface (MONETIZATION.md §"MCP as a distribution surface"):
// thin wrapper around dispatch() so any MCP-compatible AI assistant
Expand Down Expand Up @@ -44,6 +44,9 @@ export function createMcpServer(): McpServer {
overallPass: result.overallPass,
summary: result.summary,
...(result.visual ? { visual: result.visual } : {}),
report: result.report,
...(result.reportPaths.htmlPath ? { reportHtmlPath: result.reportPaths.htmlPath } : {}),
...(result.reportPaths.jsonPath ? { reportJsonPath: result.reportPaths.jsonPath } : {}),
},
isError: !result.overallPass,
};
Expand All @@ -60,16 +63,6 @@ export async function main(): Promise<void> {
await server.connect(transport);
}

function readPackageVersion(): string {
try {
const here = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(resolve(here, "..", "package.json"), "utf8"));
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
} catch {
return "0.0.0";
}
}

if (isEntryPoint()) {
main().catch((err) => {
console.error(err);
Expand Down
146 changes: 146 additions & 0 deletions src/report/collect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { basename, isAbsolute, join, resolve } from "node:path";
import type { DomainSpec, JudgeResult, ReviewerResult } from "../agents/types.js";
import { renderReport } from "./render.js";
import type { AssetMap, RunReport } from "./model.js";

export type ReportFormat = "html" | "json" | "both";

export type BuildRunReportInput = {
spec: string;
domain: DomainSpec;
judge: JudgeResult;
reviewer: ReviewerResult;
agentVersion: string;
judgeModel: string;
visualLevel: 0 | 1 | 2;
startedAt: number;
finishedAt: number;
};

// Pure assembly: fold the run's pieces into the single RunReport
// aggregate. No I/O — writeReport handles disk.
export function buildRunReport(input: BuildRunReportInput): RunReport {
return {
meta: {
spec: input.spec,
slug: input.domain.slug,
displayName: input.domain.displayName,
agentVersion: input.agentVersion,
judgeModel: input.judgeModel,
visualLevel: input.visualLevel,
startedAt: new Date(input.startedAt).toISOString(),
finishedAt: new Date(input.finishedAt).toISOString(),
durationMs: input.finishedAt - input.startedAt,
},
overallPass: input.judge.overallPass,
summary: input.judge.summary,
platforms: input.judge.platforms ?? [],
reviewer: {
contractParity: input.reviewer.contractParity,
diffs: input.reviewer.diffs,
},
domain: {
renamePlan: input.domain.renamePlan.map((r) => ({ from: r.from, to: r.to })),
entities: input.domain.entities.map((e) => ({
name: e.name,
replaces: e.replaces,
fields: e.fields.map((f) => ({
name: f.name,
type: f.type,
...(f.references !== undefined ? { references: f.references } : {}),
})),
...(e.states !== undefined ? { states: e.states } : {}),
})),
},
};
}

export type WriteReportOptions = {
dir: string;
format?: ReportFormat;
embed?: boolean;
};

export type ReportPaths = {
jsonPath?: string;
htmlPath?: string;
};

export async function writeReport(report: RunReport, options: WriteReportOptions): Promise<ReportPaths> {
const format = options.format ?? "both";
const embed = options.embed ?? true;
await mkdir(options.dir, { recursive: true });

const paths: ReportPaths = {};

if (format === "json" || format === "both") {
const jsonPath = join(options.dir, "report.json");
await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
paths.jsonPath = jsonPath;
}

if (format === "html" || format === "both") {
const assets = await resolveAssets(report, options.dir, embed);
const htmlPath = join(options.dir, "validation-report.html");
await writeFile(htmlPath, renderReport(report, assets), "utf8");
paths.htmlPath = htmlPath;
}

return paths;
}

// Every screenshot file path referenced anywhere in the report,
// de-duplicated and in stable order.
export function collectScreenshotPaths(report: RunReport): string[] {
const seen = new Set<string>();
const add = (p: string | undefined): void => {
if (p && !seen.has(p)) seen.add(p);
};
for (const platform of report.platforms) {
const l3 = platform.layer3;
if (!l3) continue;
add(l3.screenshotPath);
const s2 = l3.stage2;
if (s2) {
add(s2.representativeScreenshot);
for (const s of s2.screenshots) add(s);
}
}
return [...seen];
}

// Turn screenshot file paths into render-ready <img src> values. With
// embed=true each PNG becomes a base64 data: URI (single self-contained
// file). With embed=false PNGs are copied to <dir>/report-assets/ and
// referenced by relative path. Unreadable paths are skipped — the
// renderer shows a placeholder for any path missing from the map.
async function resolveAssets(report: RunReport, dir: string, embed: boolean): Promise<AssetMap> {
const paths = collectScreenshotPaths(report);
if (paths.length === 0) return {};

const map: AssetMap = {};
const assetsDir = join(dir, "report-assets");
if (!embed) await mkdir(assetsDir, { recursive: true });

await Promise.all(
paths.map(async (p) => {
const abs = isAbsolute(p) ? p : resolve(process.cwd(), p);
try {
if (embed) {
const buf = await readFile(abs);
map[p] = `data:image/png;base64,${buf.toString("base64")}`;
} else {
const name = basename(abs);
await copyFile(abs, join(assetsDir, name));
map[p] = `report-assets/${name}`;
}
} catch {
// Leave unmapped — renderer renders a "screenshot unavailable"
// placeholder rather than a broken image.
}
}),
);

return map;
}
56 changes: 56 additions & 0 deletions src/report/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { PlatformDetail } from "../agents/types.js";

// The single aggregate the validation report renders from. Assembled by
// dispatch (src/report/collect.ts#buildRunReport) and serialized to
// report.json; renderReport is a pure function of it. See
// docs/validation-report.md.
export type RunReport = {
meta: RunMeta;
overallPass: boolean;
summary: string;
platforms: readonly PlatformDetail[];
reviewer: {
contractParity: "pass" | "fail";
diffs: readonly string[];
};
domain: {
renamePlan: readonly { from: string; to: string }[];
entities: readonly RunReportEntity[];
};
// Populated once the self-repair loop is wired (CLAUDE.md ≤5 cap).
// Rendered only when present.
repairAttempts?: readonly RepairAttempt[];
};

export type RunMeta = {
spec: string;
slug: string;
displayName: string;
agentVersion: string;
judgeModel: string;
visualLevel: 0 | 1 | 2;
startedAt: string;
finishedAt: string;
durationMs: number;
};

export type RunReportEntity = {
name: string;
replaces: string;
fields: readonly { name: string; type: string; references?: string }[];
states?: readonly string[];
};

export type RepairAttempt = {
iteration: number;
failingLayer: "layer1" | "layer2" | "layer3" | "reviewer";
platform?: "rails" | "ios" | "android";
action: string;
resolved: boolean;
};

// Maps an original screenshot file path to a render-ready <img src>
// value — a `data:` URI when embedded, or a relative path when
// externalized to report-assets/. Built by the collector (I/O) and
// passed to the pure renderer so render.ts never touches the filesystem.
export type AssetMap = Record<string, string>;
Loading
Loading