diff --git a/packages/amico-run/schemas/doctor-report.schema.json b/packages/amico-run/schemas/doctor-report.schema.json
new file mode 100644
index 00000000..bb892418
--- /dev/null
+++ b/packages/amico-run/schemas/doctor-report.schema.json
@@ -0,0 +1,68 @@
+{
+ "$id": "https://harmoniqs.github.io/amicode/schemas/doctor-report.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": true,
+ "description": "The `amico doctor --json` machine contract (doctor v2, #525): the surface inventory the settings panel and watchdog consume. Canonical form: deep-sorted keys, 2-space indent, trailing newline.",
+ "properties": {
+ "surfaces": {
+ "additionalProperties": false,
+ "items": {
+ "additionalProperties": true,
+ "properties": {
+ "evidence": {
+ "items": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "minItems": 1,
+ "type": "array"
+ },
+ "source_version": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "surface": {
+ "enum": [
+ "server-binary",
+ "extension",
+ "vendored-binary",
+ "staged-skills",
+ "agent-cards-global",
+ "agent-cards-staging"
+ ]
+ },
+ "verdict": {
+ "enum": [
+ "current",
+ "stale",
+ "integrity-failure",
+ "unknown"
+ ]
+ },
+ "version": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "surface",
+ "version",
+ "verdict",
+ "evidence"
+ ],
+ "type": "object"
+ },
+ "minItems": 6,
+ "type": "array"
+ }
+ },
+ "required": [
+ "surfaces"
+ ],
+ "title": "doctor --json report",
+ "type": "object"
+}
diff --git a/packages/amico-run/src/amico.ts b/packages/amico-run/src/amico.ts
index 11dedba1..9b8e3fca 100644
--- a/packages/amico-run/src/amico.ts
+++ b/packages/amico-run/src/amico.ts
@@ -23,7 +23,7 @@ function usage(): string {
["resolve --platform
--kind --size ", "tier resolution → JSON (amico-run subcommand)"],
["sandbox --packages A,B,…", "generate a per-problem Julia env (amico-run subcommand)"],
["estimate | --spec ", "v0 size estimate → JSON suggestion signal, never a route (Δ10 #34)"],
- ["doctor", "validate the studio binding — paths, mounts, drift (#402)"],
+ ["doctor [--json] [--root-…]", "studio binding + fleet surface inventory — six records, verdicts (#402, #525)"],
[
"pasqal devices | submit --device --artifact [--confirm ]",
"Pasqal device path — list/select + gated submit (#160)",
@@ -77,11 +77,15 @@ export async function main(argv: string[]): Promise {
return launch(["estimate", ...rest]);
case "doctor": {
- // The studio binding's health check (#402): the manifest's world —
- // paths exist, mounts readable, exactly one rw personal, drift flagged.
+ // The studio binding's health check (#402) + the fleet surface inventory
+ // (#525): six records, each with version + verdict + evidence. `--json`
+ // emits the machine contract (canonical JSON, surfaces only); the human
+ // table derives from the same records. Root flags make every probe
+ // injectable — the fixture suite never touches the real fleet surfaces.
const { doctorReport } = await import("./doctor.js");
- const report = await doctorReport();
- console.log(report.rendered);
+ const report = await doctorReport(rest);
+ if (report.json !== null) console.log(report.json);
+ else console.log(report.rendered);
return report.exit;
}
diff --git a/packages/amico-run/src/doctor.ts b/packages/amico-run/src/doctor.ts
index cfa57cd3..2a537d65 100644
--- a/packages/amico-run/src/doctor.ts
+++ b/packages/amico-run/src/doctor.ts
@@ -1,9 +1,17 @@
-// doctor.ts — `amico doctor` (#402): validate the studio BINDING — the world,
-// not just the schema. The schema checks structure; this checks existence,
-// mount health, the exactly-one-rw-personal rule, and flags the KNOWN legacy
-// drift as warnings (the relocation slices' to-do list — drift is not breakage).
+// doctor.ts — `amico doctor`: v1 (#402) validates the studio BINDING — the
+// world, not just the schema; v2 (#525) adds the FLEET SURFACE INVENTORY
+// (surfaces.ts): five physical surfaces, six records, verdicts by version
+// string or content digest (never mtime). v1's checks are preserved verbatim
+// — the report GAINS a surfaces section; nothing v1 consumers rely on breaks.
import type { StudioPaths } from "@amicode/schema";
import { studioPathsOrLegacy } from "@amicode/schema";
+import {
+ surfaceInventory,
+ renderSurfacesTable,
+ canonicalJson,
+ type SurfaceContext,
+ type SurfacesReport,
+} from "./surfaces.js";
export interface Diagnosis {
ok: boolean; // no ERRORS (warnings don't fail the doctor)
@@ -71,8 +79,62 @@ export async function diagnoseStudio(paths: StudioPaths, exists: Exists): Promis
return { ok: errors.length === 0, errors, warnings, checks };
}
-/** The CLI entry: diagnose THIS machine's binding and print the table. */
-export async function doctorReport(): Promise<{ diagnosis: Diagnosis; rendered: string; exit: number }> {
+/** The CLI entry: diagnose THIS machine's binding and print the table.
+ * v2 (#525): accepts the doctor flags — `--json` (the machine contract) and
+ * the injectable roots (`--root-vscext`, `--root-config`, `--root-server`,
+ * `--root-repo-amicode`, `--root-repo-fork`, `--root-staging`, plus
+ * `--running-binary ` to stub the running-process evidence). With no
+ * args it behaves exactly as v1 plus the appended surfaces table. */
+export interface DoctorArgs {
+ json: boolean;
+ roots: Partial;
+ runningBinary: string | null;
+}
+
+export function parseDoctorArgs(argv: string[]): { ok: true; args: DoctorArgs } | { ok: false; message: string } {
+ const args: DoctorArgs = { json: false, roots: {}, runningBinary: null };
+ const rootFlags: Record = {
+ "--root-vscext": "rootVscext",
+ "--root-config": "rootConfig",
+ "--root-server": "rootServer",
+ "--root-repo-amicode": "rootRepoAmicode",
+ "--root-repo-fork": "rootRepoFork",
+ "--root-staging": "rootStaging",
+ };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === "--json") {
+ args.json = true;
+ } else if (a === "--running-binary") {
+ const v = argv[++i];
+ if (!v) return { ok: false, message: "--running-binary requires a path" };
+ args.runningBinary = v;
+ } else if (rootFlags[a]) {
+ const v = argv[++i];
+ if (!v) return { ok: false, message: `${a} requires a path` };
+ (args.roots as Record)[rootFlags[a]] = v;
+ } else {
+ return { ok: false, message: `unknown doctor flag: ${a}` };
+ }
+ }
+ if (args.runningBinary !== null) args.roots.runningBinary = args.runningBinary;
+ return { ok: true, args };
+}
+
+export async function doctorReport(
+ argv: string[] = [],
+): Promise<{ diagnosis: Diagnosis; surfaces: SurfacesReport; rendered: string; json: string | null; exit: number }> {
+ const parsed = parseDoctorArgs(argv);
+ if (!parsed.ok) {
+ const message = `doctor: ${parsed.message}`;
+ return {
+ diagnosis: { ok: false, errors: [message], warnings: [], checks: [] },
+ surfaces: { surfaces: [] },
+ rendered: message,
+ json: null,
+ exit: 64,
+ };
+ }
let paths = studioPathsOrLegacy();
try {
paths = studioPathsOrLegacy();
@@ -89,6 +151,7 @@ export async function doctorReport(): Promise<{ diagnosis: Diagnosis; rendered:
}
};
const diagnosis = await diagnoseStudio(paths, stat);
+ const surfaces = await surfaceInventory(parsed.args.roots);
const width = Math.max(...diagnosis.checks.map((c) => c.name.length));
const lines = diagnosis.checks.map((c) => {
const mark = c.status === "ok" ? "ok " : c.status === "warn" ? "warn" : "ERR ";
@@ -97,5 +160,16 @@ export async function doctorReport(): Promise<{ diagnosis: Diagnosis; rendered:
const summary = diagnosis.ok
? `studio binding healthy${diagnosis.warnings.length ? ` (${diagnosis.warnings.length} warning${diagnosis.warnings.length > 1 ? "s" : ""})` : ""}`
: `studio binding has ${diagnosis.errors.length} error${diagnosis.errors.length > 1 ? "s" : ""}`;
- return { diagnosis, rendered: `${summary}\n${lines.join("\n")}`, exit: diagnosis.ok ? 0 : 1 };
+ const rendered = `${summary}\n${lines.join("\n")}\n\n${renderSurfacesTable(surfaces.surfaces)}`;
+ // the machine contract (panel + watchdog): canonical JSON, surfaces only —
+ // deep-sorted keys, 2-space indent, trailing newline (the vault-card form)
+ const json = parsed.args.json ? canonicalJson({ surfaces: surfaces.surfaces }) : null;
+ return {
+ diagnosis,
+ surfaces,
+ rendered,
+ json,
+ // surfaces never fail the report (they degrade individually) — exit stays v1's
+ exit: diagnosis.ok ? 0 : 1,
+ };
}
diff --git a/packages/amico-run/src/doctor_schema.ts b/packages/amico-run/src/doctor_schema.ts
new file mode 100644
index 00000000..498763de
--- /dev/null
+++ b/packages/amico-run/src/doctor_schema.ts
@@ -0,0 +1,118 @@
+// doctor_schema.ts — validate `amico doctor --json` reports against the
+// COMMITTED JSON Schema (schemas/doctor-report.schema.json, #525). A minimal
+// JSON-Schema-subset engine covering exactly the keywords that schema uses
+// (type incl. type arrays, enum, required, properties, items, minItems,
+// minLength, additionalProperties) — the same zero-dependency approach as
+// the extension's vault_card_validator. Errors name the violated path.
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+export type SchemaError = { path: string; message: string };
+export type SchemaValidation = { ok: boolean; errors: SchemaError[] };
+
+type JsonSchema = Record;
+
+/** Load the committed doctor-report schema. Resolved from this module's
+ * location (src/ → ../schemas/); when running from the esbuild bundle
+ * (dist/), the package root is one level up from there too. */
+export function loadDoctorSchema(): JsonSchema {
+ const here = dirname(fileURLToPath(import.meta.url));
+ const candidates = [
+ join(here, "..", "schemas", "doctor-report.schema.json"), // src/ or dist/
+ join(here, "schemas", "doctor-report.schema.json"), // package root
+ ];
+ for (const p of candidates) {
+ try {
+ return JSON.parse(readFileSync(p, "utf8")) as JsonSchema;
+ } catch {
+ // try next candidate
+ }
+ }
+ throw new Error(`doctor-report.schema.json not found near ${here}`);
+}
+
+function checkType(value: unknown, type: string): boolean {
+ switch (type) {
+ case "string":
+ return typeof value === "string";
+ case "number":
+ return typeof value === "number" && Number.isFinite(value);
+ case "integer":
+ return typeof value === "number" && Number.isInteger(value);
+ case "boolean":
+ return typeof value === "boolean";
+ case "array":
+ return Array.isArray(value);
+ case "object":
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+ case "null":
+ return value === null;
+ default:
+ return true;
+ }
+}
+
+export function validateAgainstSchema(value: unknown, schema: JsonSchema, at = "$"): SchemaError[] {
+ const errors: SchemaError[] = [];
+ const push = (p: string, message: string) => errors.push({ path: p, message });
+
+ if (schema.const !== undefined && value !== schema.const) {
+ push(at, `must equal ${JSON.stringify(schema.const)}`);
+ }
+ if (Array.isArray(schema.enum) && !(schema.enum as unknown[]).includes(value)) {
+ push(at, `must be one of ${(schema.enum as unknown[]).map((v) => JSON.stringify(v)).join(", ")}`);
+ }
+
+ const type = schema.type;
+ if (typeof type === "string" && !checkType(value, type)) {
+ push(at, `must be ${type}`);
+ return errors;
+ }
+ if (Array.isArray(type) && !(type as string[]).some((t) => checkType(value, t))) {
+ push(at, `must be one of ${(type as string[]).join(" | ")}`);
+ return errors;
+ }
+
+ if (Array.isArray(value)) {
+ if (typeof schema.minItems === "number" && value.length < schema.minItems) {
+ push(at, `must have at least ${schema.minItems} items (found ${value.length})`);
+ }
+ if (schema.items && typeof schema.items === "object") {
+ value.forEach((v, i) => errors.push(...validateAgainstSchema(v, schema.items as JsonSchema, `${at}[${i}]`)));
+ }
+ return errors;
+ }
+
+ if (value !== null && typeof value === "object") {
+ const obj = value as Record;
+ if (Array.isArray(schema.required)) {
+ for (const key of schema.required as string[]) {
+ if (!(key in obj)) push(at, `missing required field "${key}"`);
+ }
+ }
+ if (schema.properties && typeof schema.properties === "object") {
+ for (const [key, sub] of Object.entries(schema.properties as Record)) {
+ if (key in obj) errors.push(...validateAgainstSchema(obj[key], sub, `${at}.${key}`));
+ }
+ }
+ if (schema.additionalProperties === false && schema.properties && typeof schema.properties === "object") {
+ const allowed = new Set(Object.keys(schema.properties as Record));
+ for (const key of Object.keys(obj)) {
+ if (!allowed.has(key)) push(`${at}.${key}`, `additional property not allowed here`);
+ }
+ }
+ return errors;
+ }
+
+ if (typeof value === "string" && typeof schema.minLength === "number" && value.length < schema.minLength) {
+ push(at, `must be at least ${schema.minLength} characters`);
+ }
+ return errors;
+}
+
+/** Validate a doctor --json report object against the committed schema. */
+export function validateDoctorReport(value: unknown, schema: JsonSchema = loadDoctorSchema()): SchemaValidation {
+ const errors = validateAgainstSchema(value, schema);
+ return { ok: errors.length === 0, errors };
+}
diff --git a/packages/amico-run/src/surfaces.ts b/packages/amico-run/src/surfaces.ts
new file mode 100644
index 00000000..de80683c
--- /dev/null
+++ b/packages/amico-run/src/surfaces.ts
@@ -0,0 +1,643 @@
+// surfaces.ts — doctor v2's surface inventory (#525, spec-20260823-094507 D1):
+// FIVE physical fleet surfaces, SIX records (agent cards are global + staging
+// deployments of one source). Every record carries surface, installed/running
+// version, source-of-truth version, verdict, evidence.
+//
+// Invariants (spec D1 + the 2026-08-08 mtime-race lesson):
+// - NO staleness judgment ever reads an mtime — version strings or content
+// digests, always. "Newest" always means version-sorted.
+// - Probes degrade INDIVIDUALLY: one unreachable source of truth degrades
+// that surface to `unknown`, never fails the report. Local hard facts
+// (sidecar mismatch, absent process, running≠frozen) outrank `unknown`;
+// `unknown` means "source of truth unreachable, nothing local to say".
+// - Read-only w.r.t. the fleet surfaces. Doctor's `git fetch` is a
+// source-of-truth refresh (remote-tracking refs + tags in the SOURCE
+// repos), explicitly not a surface mutation. No working tree is touched.
+// - Absent SURFACE (e.g. no process, no staged dir) → `stale` with absence
+// evidence — the repairable state. Absent SOURCE OF TRUTH → `unknown`.
+import { execFile } from "node:child_process";
+import { createHash } from "node:crypto";
+import { readFile, readdir } from "node:fs/promises";
+import { homedir } from "node:os";
+import { join } from "node:path";
+
+export type Verdict = "current" | "stale" | "integrity-failure" | "unknown";
+
+export const SURFACE_ORDER = [
+ "server-binary",
+ "extension",
+ "vendored-binary",
+ "staged-skills",
+ "agent-cards-global",
+ "agent-cards-staging",
+] as const;
+
+export type SurfaceName = (typeof SURFACE_ORDER)[number];
+
+export interface SurfaceRecord {
+ surface: SurfaceName;
+ /** installed/running version (or set digest for digest-identified surfaces) */
+ version: string | null;
+ /** source-of-truth version (or set digest) */
+ source_version: string | null;
+ verdict: Verdict;
+ /** digests, version strings, reason codes — at least one line, always */
+ evidence: string[];
+}
+
+export interface SurfacesReport {
+ surfaces: SurfaceRecord[];
+}
+
+export interface ExecResult {
+ code: number;
+ stdout: string;
+ stderr: string;
+}
+
+/** Injectable process runner. Never throws — failures are {code != 0}. */
+export type Exec = (file: string, args: string[]) => Promise;
+
+export interface SurfaceContext {
+ /** server dir holding bin/opencode + bin/opencode.sha256 (default ~/.amico/server) */
+ rootServer: string;
+ /** VS Code extensions dir (default ~/.vscode/extensions) */
+ rootVscext: string;
+ /** opencode config root; global cards at /agents (default ~/.config/opencode) */
+ rootConfig: string;
+ /** amicode repo checkout (default ~/armonia/repos/amicode) */
+ rootRepoAmicode: string;
+ /** opencode fork checkout, branch local/amicode (default ~/armonia/repos/opencode) */
+ rootRepoFork: string;
+ /** staged opencode-project dir (default /opencode-project-staging/opencode-project) */
+ rootStaging: string;
+ /** running-process evidence stub; null = discover via ps */
+ runningBinary: string | null;
+ /** vendor platform dir name, e.g. "darwin-arm64" */
+ platform: string;
+ run: Exec;
+ discoverRunning: () => Promise;
+}
+
+const GIT_TIMEOUT_MS = 30_000;
+
+export const realExec: Exec = (file, args) =>
+ new Promise((resolve) => {
+ execFile(file, args, { timeout: GIT_TIMEOUT_MS }, (err, stdout, stderr) => {
+ const code = err && typeof (err as { code?: number }).code === "number" ? (err as { code: number }).code : err ? 1 : 0;
+ resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
+ });
+ });
+
+/** ps-based live discovery of the running server binary (default; hermetic
+ * fixtures inject --running-binary or their own discoverRunning). */
+async function defaultDiscoverRunning(): Promise {
+ const r = await realExec("ps", ["-axo", "command="]);
+ if (r.code !== 0) return null;
+ for (const line of r.stdout.split("\n")) {
+ if (!/\bopencode serve\b/.test(line)) continue;
+ const bin = line.trim().split(/\s+/)[0] ?? "";
+ if (bin.includes("opencode")) return bin;
+ }
+ return null;
+}
+
+export function defaultSurfaceContext(): SurfaceContext {
+ const rootServer = process.env.AMICO_SERVER_DIR ?? join(homedir(), ".amico", "server");
+ return {
+ rootServer,
+ rootVscext: join(homedir(), ".vscode", "extensions"),
+ rootConfig: join(homedir(), ".config", "opencode"),
+ rootRepoAmicode: join(homedir(), "armonia", "repos", "amicode"),
+ rootRepoFork: join(homedir(), "armonia", "repos", "opencode"),
+ rootStaging: join(rootServer, "opencode-project-staging", "opencode-project"),
+ runningBinary: null,
+ platform: `${process.platform}-${process.arch}`,
+ run: realExec,
+ discoverRunning: defaultDiscoverRunning,
+ };
+}
+
+// ── small helpers ────────────────────────────────────────────────────────────
+
+const sha256hex = (buf: Buffer): string => createHash("sha256").update(buf).digest("hex");
+
+async function readFileSafe(p: string): Promise {
+ try {
+ return await readFile(p, "utf8");
+ } catch {
+ return null;
+ }
+}
+
+async function fileSha(p: string): Promise {
+ try {
+ return sha256hex(await readFile(p));
+ } catch {
+ return null;
+ }
+}
+
+/** Natural version compare (digits numerically, runs otherwise lexicographically).
+ * Returns <0, 0, >0. Handles "0.2.6", "1.18.10-amicode.15", "v1.18.12". */
+export function compareVersions(a: string, b: string): number {
+ const tok = (s: string) => s.match(/\d+|[^\d]+/g) ?? [];
+ const ta = tok(a);
+ const tb = tok(b);
+ const n = Math.max(ta.length, tb.length);
+ for (let i = 0; i < n; i++) {
+ const xa = ta[i];
+ const xb = tb[i];
+ if (xa === undefined) return -1;
+ if (xb === undefined) return 1;
+ const da = /^\d+$/.test(xa);
+ const db = /^\d+$/.test(xb);
+ if (da && db) {
+ const va = Number(xa);
+ const vb = Number(xb);
+ if (va !== vb) return va < vb ? -1 : 1;
+ } else if (da !== db) {
+ return da ? -1 : 1; // numeric chunks sort before alpha chunks
+ } else {
+ if (xa !== xb) return xa < xb ? -1 : 1;
+ }
+ }
+ return 0;
+}
+
+/** The leading numeric version of a string: "0.2.4-darwin-arm64" → "0.2.4". */
+export function versionPrefix(s: string): string {
+ return s.match(/\d+(?:\.\d+)*/)?.[0] ?? "";
+}
+
+/** Build date embedded in a frozen-binary version string, e.g.
+ * "0.0.0-local/amicode-202608231309" → Date(2026-08-23T13:09Z). Null if absent. */
+export function parseBuildDate(version: string): Date | null {
+ const m = version.match(/(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(?=\D|$)/);
+ if (!m) return null;
+ const [y, mo, d, h, mi] = m.slice(1).map(Number);
+ if (y < 2000) return null; // a 12-digit run from a year we never built in
+ const t = Date.UTC(y, mo - 1, d, h, mi);
+ return Number.isNaN(t) ? null : new Date(t);
+}
+
+/** Deterministic content digest of a directory: sha256 over the sorted
+ * relative-path + file-bytes pairs. mtime-free by construction. */
+async function dirDigest(dir: string): Promise {
+ const files: string[] = [];
+ const walk = async (rel: string) => {
+ let entries: import("node:fs").Dirent[];
+ try {
+ entries = await readdir(join(dir, rel), { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
+ const child = rel ? `${rel}/${e.name}` : e.name;
+ if (e.isDirectory()) await walk(child);
+ else if (e.isFile()) files.push(child);
+ }
+ };
+ await walk("");
+ if (files.length === 0) return null;
+ const parts: string[] = [];
+ for (const f of files) {
+ const bytes = await readFileSafeBuffer(join(dir, f));
+ if (bytes === null) return null;
+ parts.push(`${f}\0`);
+ parts.push(bytes.toString("binary"));
+ }
+ return sha256hex(Buffer.from(parts.join("\u0001"), "binary"));
+}
+
+async function readFileSafeBuffer(p: string): Promise {
+ try {
+ return await readFile(p);
+ } catch {
+ return null;
+ }
+}
+
+async function listSubdirs(dir: string): Promise {
+ try {
+ const entries = await readdir(dir, { withFileTypes: true });
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
+ } catch {
+ return [];
+ }
+}
+
+async function listFiles(dir: string): Promise {
+ try {
+ const entries = await readdir(dir, { withFileTypes: true });
+ return entries.filter((e) => e.isFile()).map((e) => e.name);
+ } catch {
+ return [];
+ }
+}
+
+/** Set digest over a name→digest map (sorted names) — the identity of a
+ * digest-identified surface (staged skills, agent cards). */
+function setDigest(items: Map): string {
+ const parts = [...items.entries()]
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
+ .map(([name, digest]) => `${name}\0${digest}`);
+ return sha256hex(Buffer.from(parts.join("\u0001"), "binary"));
+}
+
+const firstErrLine = (s: string, max = 200): string =>
+ s.split("\n").map((l) => l.trim()).filter(Boolean).join(" ").slice(0, max);
+
+// ── shared source-of-truth refresh ──────────────────────────────────────────
+
+export interface FetchOutcome {
+ ok: boolean;
+ error?: string;
+}
+
+/** `git fetch` in a SOURCE repo — writes remote-tracking refs (+ tags when
+ * asked) only; never touches a working tree. The vendored probe needs tags
+ * (release tags may live off-branch), so the fork fetch passes --tags. */
+async function fetchOrigin(run: Exec, repo: string, tags: boolean): Promise {
+ const args = ["-C", repo, "fetch", "origin"];
+ if (tags) args.push("--tags");
+ const r = await run("git", args);
+ return r.code === 0
+ ? { ok: true }
+ : { ok: false, error: firstErrLine(r.stderr || r.stdout) || `exit ${r.code}` };
+}
+
+async function gitOutput(run: Exec, repo: string, args: string[]): Promise {
+ const r = await run("git", ["-C", repo, ...args]);
+ return r.code === 0 ? r.stdout : null;
+}
+
+// ── extension-dir selection (version-sorted, never mtime) ──────────────────
+
+export interface NewestExtension {
+ dir: string;
+ /** raw suffix after "harmoniqs.amicode-", e.g. "0.2.4-darwin-arm64" */
+ suffix: string;
+ /** numeric version prefix, e.g. "0.2.4" */
+ version: string;
+}
+
+export async function newestExtensionDir(rootVscext: string): Promise {
+ const dirs = await listSubdirs(rootVscext);
+ const exts = dirs.filter((d) => /^harmoniqs\.amicode-/.test(d));
+ if (exts.length === 0) return null;
+ exts.sort((a, b) => {
+ const va = versionPrefix(a.replace(/^harmoniqs\.amicode-/, ""));
+ const vb = versionPrefix(b.replace(/^harmoniqs\.amicode-/, ""));
+ const byVersion = compareVersions(va, vb);
+ if (byVersion !== 0) return byVersion;
+ return compareVersions(a, b);
+ });
+ const newest = exts[exts.length - 1];
+ const suffix = newest.replace(/^harmoniqs\.amicode-/, "");
+ return { dir: join(rootVscext, newest), suffix, version: versionPrefix(suffix) || suffix };
+}
+
+// ── the six probes ───────────────────────────────────────────────────────────
+
+async function probeServerBinary(ctx: SurfaceContext, forkFetch: FetchOutcome): Promise {
+ const surface: SurfaceName = "server-binary";
+ const frozen = join(ctx.rootServer, "bin", "opencode");
+ const sidecarPath = `${frozen}.sha256`;
+
+ const frozenSha = await fileSha(frozen);
+ if (frozenSha === null) {
+ return { surface, version: null, source_version: null, verdict: "stale", evidence: [`frozen binary missing: ${frozen}`] };
+ }
+
+ // integrity: the freeze contract is binary + sidecar pair
+ const sidecarText = await readFileSafe(sidecarPath);
+ if (sidecarText === null) {
+ return { surface, version: null, source_version: null, verdict: "integrity-failure", evidence: [`sidecar missing: ${sidecarPath}`, `frozen sha256 ${frozenSha}`] };
+ }
+ const sidecarSha = sidecarText.match(/[0-9a-f]{64}/i)?.[0]?.toLowerCase() ?? null;
+ if (sidecarSha === null) {
+ return { surface, version: null, source_version: null, verdict: "integrity-failure", evidence: [`sidecar unreadable (no sha256 digest found): ${sidecarPath}`, `frozen sha256 ${frozenSha}`] };
+ }
+ if (sidecarSha !== frozenSha) {
+ return { surface, version: null, source_version: null, verdict: "integrity-failure", evidence: [`frozen sha256 ${frozenSha} ≠ sidecar ${sidecarSha} (tampered binary or stale sidecar)`, `sidecar ${sidecarPath}`] };
+ }
+
+ const versionRun = await ctx.run(frozen, ["--version"]);
+ if (versionRun.code !== 0) {
+ return { surface, version: null, source_version: null, verdict: "integrity-failure", evidence: [`frozen binary --version failed (exit ${versionRun.code}): ${firstErrLine(versionRun.stderr)}`, `frozen sha256 ${frozenSha} = sidecar`] };
+ }
+ const version = versionRun.stdout.trim().split("\n").pop() ?? "";
+
+ // running process (local facts)
+ const running = ctx.runningBinary ?? (await ctx.discoverRunning());
+ if (!running) {
+ return { surface, version, source_version: null, verdict: "stale", evidence: ["server-down: no running opencode serve process found", `frozen ${version} sha256 ${frozenSha} = sidecar`] };
+ }
+ const runningSha = await fileSha(running);
+ if (runningSha === null) {
+ return { surface, version, source_version: null, verdict: "stale", evidence: [`running binary unreadable: ${running}`, `frozen ${version} sha256 ${frozenSha} = sidecar`] };
+ }
+ if (runningSha !== frozenSha) {
+ return { surface, version, source_version: null, verdict: "stale", evidence: [`running ${running} sha256 ${runningSha} ≠ frozen sha256 ${frozenSha} (restart pending)`, `frozen ${version} sha256 ${frozenSha} = sidecar`] };
+ }
+
+ // version staleness vs the fetched fork HEAD
+ if (!forkFetch.ok) {
+ return { surface, version, source_version: null, verdict: "unknown", evidence: [`fork fetch failed (source of truth unreachable): ${forkFetch.error}`, `local checks pass: frozen sha = sidecar, running sha = frozen sha`] };
+ }
+ const headDateRaw = await gitOutput(ctx.run, ctx.rootRepoFork, ["log", "-1", "--format=%cI", "origin/local/amicode"]);
+ if (headDateRaw === null) {
+ return { surface, version, source_version: null, verdict: "unknown", evidence: ["fetched, but origin/local/amicode not found in the fork", `frozen ${version} sha256 ${frozenSha} = sidecar`] };
+ }
+ const headDate = new Date(headDateRaw.trim());
+ const headSha = (await gitOutput(ctx.run, ctx.rootRepoFork, ["rev-parse", "--short", "origin/local/amicode"]))?.trim() ?? "";
+ const buildDate = parseBuildDate(version);
+ if (buildDate !== null && headDate.getTime() > 0 && buildDate.getTime() < headDate.getTime()) {
+ return {
+ surface,
+ version,
+ source_version: headDateRaw.trim(),
+ verdict: "stale",
+ evidence: [
+ `build date ${buildDate.toISOString()} < HEAD commit date ${headDate.toISOString()} (origin/local/amicode ${headSha})`,
+ `frozen ${version} sha256 ${frozenSha} = sidecar; running sha = frozen sha`,
+ ],
+ };
+ }
+ return {
+ surface,
+ version,
+ source_version: headDateRaw.trim(),
+ verdict: "current",
+ evidence: [
+ `frozen ${version} sha256 ${frozenSha} = sidecar`,
+ `running ${running} sha256 = frozen sha256`,
+ `build date ${buildDate ? buildDate.toISOString() : "unparseable"} ≥ HEAD commit date ${headDate.toISOString()} (origin/local/amicode ${headSha})`,
+ ],
+ };
+}
+
+async function probeExtension(ctx: SurfaceContext, amicodeFetch: FetchOutcome): Promise {
+ const surface: SurfaceName = "extension";
+ if (!amicodeFetch.ok) {
+ return { surface, version: null, source_version: null, verdict: "unknown", evidence: [`amicode fetch failed (source of truth unreachable): ${amicodeFetch.error}`] };
+ }
+ const pkgRaw = await gitOutput(ctx.run, ctx.rootRepoAmicode, ["show", "origin/main:packages/extension/package.json"]);
+ let sourceVersion: string | null = null;
+ if (pkgRaw !== null) {
+ try {
+ sourceVersion = (JSON.parse(pkgRaw) as { version?: string }).version ?? null;
+ } catch {
+ sourceVersion = null;
+ }
+ }
+ if (pkgRaw === null || sourceVersion === null) {
+ return { surface, version: null, source_version: null, verdict: "unknown", evidence: ["origin/main packages/extension/package.json unreadable or has no version"] };
+ }
+ const newest = await newestExtensionDir(ctx.rootVscext);
+ if (newest === null) {
+ return { surface, version: null, source_version: sourceVersion, verdict: "stale", evidence: [`no installed extension dir (harmoniqs.amicode-*) under ${ctx.rootVscext}`, `origin/main version ${sourceVersion}`] };
+ }
+ const cmp = compareVersions(newest.version, sourceVersion);
+ const verdict: Verdict = cmp === 0 ? "current" : "stale";
+ const direction =
+ cmp === 0
+ ? `installed ${newest.version} = origin/main ${sourceVersion}`
+ : cmp < 0
+ ? `installed ${newest.version} behind origin/main ${sourceVersion}`
+ : `installed ${newest.version} ahead of origin/main ${sourceVersion} (source repo behind?)`;
+ return {
+ surface,
+ version: newest.suffix,
+ source_version: sourceVersion,
+ verdict,
+ evidence: [`installed ${newest.suffix} — version-sorted newest under ${ctx.rootVscext}`, direction],
+ };
+}
+
+async function probeVendoredBinary(ctx: SurfaceContext, forkFetch: FetchOutcome): Promise {
+ const surface: SurfaceName = "vendored-binary";
+ if (!forkFetch.ok) {
+ return { surface, version: null, source_version: null, verdict: "unknown", evidence: [`fork fetch failed (release tags not refreshable): ${forkFetch.error}`] };
+ }
+ const tagsRaw = await gitOutput(ctx.run, ctx.rootRepoFork, ["tag", "--list"]);
+ const releaseTags = (tagsRaw ?? "").split("\n").map((t) => t.trim()).filter(Boolean).filter((t) => /^v\d+\.\d+\.\d+-amicode\.\d+$/.test(t));
+ if (releaseTags.length === 0) {
+ return { surface, version: null, source_version: null, verdict: "unknown", evidence: ["no fork release tags (v-amicode.) in the fetched fork"] };
+ }
+ releaseTags.sort((a, b) => compareVersions(a.replace(/^v/, ""), b.replace(/^v/, "")));
+ const newestTag = releaseTags[releaseTags.length - 1];
+ const baseVersion = newestTag.replace(/^v(\d+\.\d+\.\d+)-amicode\.\d+$/, "$1");
+
+ const bin = join(ctx.rootRepoAmicode, "packages", "extension", "vendor", "opencode", ctx.platform, "opencode");
+ const binSha = await fileSha(bin);
+ if (binSha === null) {
+ return { surface, version: null, source_version: baseVersion, verdict: "stale", evidence: [`vendored binary missing: ${bin}`, `latest fork release tag ${newestTag} (base ${baseVersion})`] };
+ }
+ const vr = await ctx.run(bin, ["--version"]);
+ if (vr.code !== 0) {
+ return { surface, version: null, source_version: baseVersion, verdict: "stale", evidence: [`vendored binary --version failed (exit ${vr.code}): ${firstErrLine(vr.stderr)}`, `latest fork release tag ${newestTag} (base ${baseVersion})`] };
+ }
+ const printed = vr.stdout.trim().split("\n").pop() ?? "";
+ const cmp = compareVersions(printed, baseVersion);
+ return {
+ surface,
+ version: printed,
+ source_version: baseVersion,
+ verdict: cmp === 0 ? "current" : "stale",
+ evidence: [
+ `vendored ${bin} --version ${printed}`,
+ `latest fork release tag ${newestTag} (base ${baseVersion})`,
+ cmp === 0 ? `version = release tag base` : cmp < 0 ? `version behind release tag base ${baseVersion}` : `version ahead of release tag base ${baseVersion}`,
+ ],
+ };
+}
+
+async function probeStagedSkills(ctx: SurfaceContext): Promise {
+ const surface: SurfaceName = "staged-skills";
+ const newest = await newestExtensionDir(ctx.rootVscext);
+ const sourceSkillsDir = newest ? join(newest.dir, "skills") : null;
+ const sourceSkills = sourceSkillsDir ? await listSubdirs(sourceSkillsDir) : [];
+ if (newest === null || sourceSkills.length === 0) {
+ return { surface, version: null, source_version: null, verdict: "unknown", evidence: [`missing local source: no VSIX skills set (no harmoniqs.amicode-* dir with skills/ under ${ctx.rootVscext})`] };
+ }
+ const stagedDir = join(ctx.rootStaging, "skills");
+ const stagedSkills = await listSubdirs(stagedDir);
+ if (stagedSkills.length === 0) {
+ return { surface, version: null, source_version: null, verdict: "stale", evidence: [`staged skills dir missing or empty: ${stagedDir}`, `source: VSIX ${newest.suffix} skills set (${sourceSkills.length} skills)`] };
+ }
+ const sourceDigests = new Map();
+ const stagedDigests = new Map();
+ const diffs: string[] = [];
+ for (const skill of sourceSkills) {
+ const src = await dirDigest(join(sourceSkillsDir!, skill));
+ const dst = await dirDigest(join(stagedDir, skill));
+ if (src === null) continue; // unreadable source skill: not the staged surface's verdict
+ sourceDigests.set(skill, src);
+ if (dst === null) diffs.push(`skill ${skill} missing from staged set`);
+ else {
+ stagedDigests.set(skill, dst);
+ if (dst !== src) diffs.push(`skill ${skill} changed (staged ${dst.slice(0, 12)} ≠ VSIX ${src.slice(0, 12)})`);
+ }
+ }
+ // reverse direction (reviewer pass 2026-08-23): a skill staged but absent
+ // from the VSIX set is drift too — a leftover an older deployment dropped.
+ // Extras count toward the staged set digest, so version ≠ source when drifted.
+ for (const skill of stagedSkills) {
+ if (sourceSkills.includes(skill)) continue;
+ const dst = await dirDigest(join(stagedDir, skill));
+ if (dst !== null) stagedDigests.set(skill, dst);
+ diffs.push(`skill ${skill} extra in staged set (absent from VSIX source set)`);
+ }
+ const sourceSet = setDigest(sourceDigests);
+ const stagedSet = setDigest(stagedDigests);
+ if (diffs.length > 0) {
+ return { surface, version: `sha256:${stagedSet}`, source_version: `sha256:${sourceSet}`, verdict: "stale", evidence: [...diffs, `source: VSIX ${newest.suffix} skills set`] };
+ }
+ return {
+ surface,
+ version: `sha256:${stagedSet}`,
+ source_version: `sha256:${sourceSet}`,
+ verdict: "current",
+ evidence: [`all ${sourceDigests.size} staged skills byte-match the VSIX ${newest.suffix} skills set`, `set digest sha256:${stagedSet}`],
+ };
+}
+
+async function probeAgentCards(
+ ctx: SurfaceContext,
+ deployedDir: string,
+ surface: "agent-cards-global" | "agent-cards-staging",
+): Promise {
+ const srcDir = join(ctx.rootRepoAmicode, "packages", "extension", "agents");
+ const sourceCards = (await listFiles(srcDir)).filter((f) => f.endsWith(".md"));
+ if (sourceCards.length === 0) {
+ return { surface, version: null, source_version: null, verdict: "unknown", evidence: [`missing local source: ${srcDir} absent or has no cards`] };
+ }
+ const sourceDigests = new Map();
+ for (const card of sourceCards) {
+ const sha = await fileSha(join(srcDir, card));
+ if (sha !== null) sourceDigests.set(card, sha);
+ }
+ const sourceSet = setDigest(sourceDigests);
+
+ const deployedCards = (await listFiles(deployedDir)).filter((f) => f.endsWith(".md"));
+ if (deployedCards.length === 0) {
+ return { surface, version: null, source_version: `sha256:${sourceSet}`, verdict: "stale", evidence: [`deployed dir missing or empty: ${deployedDir}`, `source: ${sourceDigests.size} cards in ${srcDir}`] };
+ }
+ const deployedDigests = new Map();
+ const diffs: string[] = [];
+ for (const [card, srcSha] of sourceDigests) {
+ const dstSha = await fileSha(join(deployedDir, card));
+ if (dstSha === null) diffs.push(`card ${card} missing from ${deployedDir}`);
+ else {
+ deployedDigests.set(card, dstSha);
+ if (dstSha !== srcSha) diffs.push(`card ${card} changed (deployed ${dstSha.slice(0, 12)} ≠ source ${srcSha.slice(0, 12)})`);
+ }
+ }
+ // reverse direction (reviewer pass 2026-08-23): a deployed card absent from
+ // the sources is drift — a leftover the next deploy would never refresh.
+ // Extras count toward the deployed set digest, so version ≠ source when drifted.
+ for (const card of deployedCards) {
+ if (sourceCards.includes(card)) continue;
+ const dstSha = await fileSha(join(deployedDir, card));
+ if (dstSha !== null) deployedDigests.set(card, dstSha);
+ diffs.push(`card ${card} extra in deployed set (absent from sources)`);
+ }
+ const deployedSet = setDigest(deployedDigests);
+
+ // receipt — secondary evidence: the digest diff governs; a missing/lying
+ // receipt is itself staleness (no auditable current deployment)
+ if (diffs.length > 0) {
+ return { surface, version: `sha256:${deployedSet}`, source_version: `sha256:${sourceSet}`, verdict: "stale", evidence: [...diffs, `source: ${srcDir}`] };
+ }
+ const receiptRaw = await readFileSafe(join(srcDir, ".deploy-receipt.json"));
+ if (receiptRaw === null) {
+ return { surface, version: `sha256:${deployedSet}`, source_version: `sha256:${sourceSet}`, verdict: "stale", evidence: ["deploy receipt missing (.deploy-receipt.json) — deployment not auditable", `all ${sourceDigests.size} cards byte-match sources`] };
+ }
+ let receipt: { sources?: { card?: string; sha256?: string }[] } | null = null;
+ try {
+ receipt = JSON.parse(receiptRaw);
+ } catch {
+ receipt = null;
+ }
+ const receiptEntries = receipt?.sources ?? [];
+ if (receipt === null || receiptEntries.length === 0) {
+ return { surface, version: `sha256:${deployedSet}`, source_version: `sha256:${sourceSet}`, verdict: "stale", evidence: ["deploy receipt unparseable or empty — deployment not auditable", `all ${sourceDigests.size} cards byte-match sources`] };
+ }
+ const receiptMismatches: string[] = [];
+ for (const entry of receiptEntries) {
+ if (!entry.card) continue;
+ const claimed = (entry.sha256 ?? "").replace(/^sha256:/, "").toLowerCase();
+ const actual = sourceDigests.get(entry.card);
+ if (actual === undefined) receiptMismatches.push(`receipt names card ${entry.card} absent from sources`);
+ else if (claimed !== actual) receiptMismatches.push(`receipt source digest for ${entry.card} ≠ current source (${claimed.slice(0, 12)} ≠ ${actual.slice(0, 12)})`);
+ }
+ if (receiptMismatches.length > 0) {
+ return { surface, version: `sha256:${deployedSet}`, source_version: `sha256:${sourceSet}`, verdict: "stale", evidence: [...receiptMismatches, `all cards byte-match sources, but the receipt records different sources`] };
+ }
+ return {
+ surface,
+ version: `sha256:${deployedSet}`,
+ source_version: `sha256:${sourceSet}`,
+ verdict: "current",
+ evidence: [`all ${sourceDigests.size} cards byte-match sources (${deployedDir})`, `deploy receipt source digests match current sources`, `set digest sha256:${deployedSet}`],
+ };
+}
+
+// ── the inventory ────────────────────────────────────────────────────────────
+
+async function guarded(name: SurfaceName, fn: () => Promise): Promise {
+ try {
+ return await fn();
+ } catch (e) {
+ // probes degrade individually — never a failed report
+ return { surface: name, version: null, source_version: null, verdict: "unknown", evidence: [`probe error: ${e instanceof Error ? e.message : String(e)}`] };
+ }
+}
+
+export async function surfaceInventory(partial: Partial = {}): Promise {
+ const ctx: SurfaceContext = { ...defaultSurfaceContext(), ...partial };
+ // source-of-truth refresh: one fetch per source repo, shared by its probes
+ const forkFetch = await fetchOrigin(ctx.run, ctx.rootRepoFork, true); // tags: release tags
+ const amicodeFetch = await fetchOrigin(ctx.run, ctx.rootRepoAmicode, false);
+ const surfaces: SurfaceRecord[] = [];
+ surfaces.push(await guarded("server-binary", () => probeServerBinary(ctx, forkFetch)));
+ surfaces.push(await guarded("extension", () => probeExtension(ctx, amicodeFetch)));
+ surfaces.push(await guarded("vendored-binary", () => probeVendoredBinary(ctx, forkFetch)));
+ surfaces.push(await guarded("staged-skills", () => probeStagedSkills(ctx)));
+ surfaces.push(await guarded("agent-cards-global", () => probeAgentCards(ctx, join(ctx.rootConfig, "agents"), "agent-cards-global")));
+ surfaces.push(await guarded("agent-cards-staging", () => probeAgentCards(ctx, join(ctx.rootStaging, ".opencode", "agents"), "agent-cards-staging")));
+ return { surfaces };
+}
+
+// ── rendering + canonical JSON ───────────────────────────────────────────────
+
+/** Canonical JSON: deep-sorted keys, 2-space indent, trailing newline — the
+ * same contract as the vault-card slice's canonicalJson. */
+export function canonicalJson(value: unknown): string {
+ return `${JSON.stringify(sortDeep(value), null, 2)}\n`;
+}
+
+function sortDeep(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(sortDeep);
+ if (value !== null && typeof value === "object") {
+ return Object.fromEntries(
+ Object.keys(value as Record)
+ .sort()
+ .map((k) => [k, sortDeep((value as Record)[k])]),
+ );
+ }
+ return value;
+}
+
+/** The human table appended to doctor v1's binding report. */
+export function renderSurfacesTable(surfaces: SurfaceRecord[]): string {
+ const width = Math.max(...surfaces.map((s) => s.surface.length));
+ const lines = surfaces.map((s) => {
+ const v = s.version ?? "—";
+ const sv = s.source_version ?? "—";
+ return ` ${s.surface.padEnd(width)} ${String(v).padEnd(44)} ${String(sv).padEnd(44)} ${s.verdict}`;
+ });
+ return `surfaces:\n${lines.join("\n")}`;
+}
diff --git a/packages/amico-run/test/amico.test.ts b/packages/amico-run/test/amico.test.ts
index c1873a30..674809da 100644
--- a/packages/amico-run/test/amico.test.ts
+++ b/packages/amico-run/test/amico.test.ts
@@ -8,7 +8,8 @@ import { execFile, execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import { fakeJulia, hermeticOpsEnv, readToml, tmpRoot } from "./helpers.js";
+import { fakeJulia, hermeticOpsEnv, readToml, tmpRoot, buildDoctorWorld, cleanupTracked } from "./helpers.js";
+import { validateDoctorReport } from "../src/doctor_schema.js";
import { FakeCloud } from "./fake_cloud.js";
const BUNDLE = join(__dirname, "..", "dist", "amico.js");
@@ -269,3 +270,48 @@ describe("amico router — mcp-serve facade", () => {
expect(out.tools).toEqual(expect.arrayContaining(["amico_catalog"]));
});
});
+
+describe("amico router — doctor v2 (surface inventory, #525)", () => {
+ it("doctor --json with injected roots emits the canonical machine contract", () => {
+ const w = buildDoctorWorld();
+ const r = run([
+ "doctor",
+ "--json",
+ "--root-server", w.server,
+ "--root-vscext", w.vscext,
+ "--root-config", w.config,
+ "--root-repo-amicode", w.repoAmicode,
+ "--root-repo-fork", w.repoFork,
+ "--root-staging", w.staging,
+ "--running-binary", w.running,
+ ]);
+ // exit reflects the v1 studio binding only (machine-dependent); the
+ // surfaces contract is asserted on stdout, which must be JSON-only
+ const report = JSON.parse(r.stdout);
+ expect(report.surfaces).toHaveLength(6);
+ expect(report.surfaces.every((s: { verdict: string }) => s.verdict === "current")).toBe(true);
+ expect(validateDoctorReport(report).ok).toBe(true);
+ // canonical form: 2-space indent + trailing newline
+ expect(r.stdout.endsWith("\n")).toBe(true);
+ expect(r.stdout.split("\n")[1]).toBe(' "surfaces": [');
+ cleanupTracked();
+ });
+
+ it("doctor (human) prints the v1 binding table plus the surfaces section", () => {
+ const w = buildDoctorWorld();
+ const r = run([
+ "doctor",
+ "--root-server", w.server,
+ "--root-vscext", w.vscext,
+ "--root-config", w.config,
+ "--root-repo-amicode", w.repoAmicode,
+ "--root-repo-fork", w.repoFork,
+ "--root-staging", w.staging,
+ "--running-binary", w.running,
+ ]);
+ expect(r.stdout).toMatch(/studio binding/); // v1 section intact
+ expect(r.stdout).toMatch(/^surfaces:$/m); // v2 section gained
+ expect(r.stdout).toMatch(/server-binary/);
+ cleanupTracked();
+ });
+});
diff --git a/packages/amico-run/test/doctor.test.ts b/packages/amico-run/test/doctor.test.ts
index 3dec3a40..5f777b77 100644
--- a/packages/amico-run/test/doctor.test.ts
+++ b/packages/amico-run/test/doctor.test.ts
@@ -6,7 +6,7 @@ import { describe, test, expect } from "vitest";
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import { diagnoseStudio } from "../src/doctor.js";
+import { diagnoseStudio, parseDoctorArgs } from "../src/doctor.js";
import type { StudioPaths } from "@amicode/schema";
import { legacyStudioPaths } from "@amicode/schema";
@@ -108,3 +108,46 @@ describe("diagnoseStudio", () => {
});
});
+// ── v2 (#525): flag parsing + the composed report ────────────────────────────
+describe("parseDoctorArgs", () => {
+ test("no args = v1 behavior (no roots, human output)", () => {
+ const r = parseDoctorArgs([]);
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.args.json).toBe(false);
+ expect(r.args.roots).toEqual({});
+ expect(r.args.runningBinary).toBe(null);
+ });
+
+ test("every injectable root flag maps to its SurfaceContext key", () => {
+ const r = parseDoctorArgs([
+ "--json",
+ "--root-vscext", "/v",
+ "--root-config", "/c",
+ "--root-server", "/s",
+ "--root-repo-amicode", "/a",
+ "--root-repo-fork", "/f",
+ "--root-staging", "/st",
+ "--running-binary", "/r/opencode",
+ ]);
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.args.json).toBe(true);
+ expect(r.args.roots).toEqual({
+ rootVscext: "/v",
+ rootConfig: "/c",
+ rootServer: "/s",
+ rootRepoAmicode: "/a",
+ rootRepoFork: "/f",
+ rootStaging: "/st",
+ runningBinary: "/r/opencode",
+ });
+ });
+
+ test("unknown flag / missing value is a usage error", () => {
+ expect(parseDoctorArgs(["--nope"])).toMatchObject({ ok: false, message: /unknown doctor flag/ });
+ expect(parseDoctorArgs(["--root-server"])).toMatchObject({ ok: false, message: /requires a path/ });
+ expect(parseDoctorArgs(["--running-binary"])).toMatchObject({ ok: false, message: /requires a path/ });
+ });
+});
+
diff --git a/packages/amico-run/test/fixtures/surfaces/README.md b/packages/amico-run/test/fixtures/surfaces/README.md
new file mode 100644
index 00000000..8d3b58f8
--- /dev/null
+++ b/packages/amico-run/test/fixtures/surfaces/README.md
@@ -0,0 +1,83 @@
+# Doctor v2 fixture suite — authorship record
+
+The verdict-matrix suite lives in `packages/amico-run/test/surfaces.test.ts`
+(world builder: `test/helpers.ts`, `buildDoctorWorld`). This file records the
+authorship split the issue's Testing Decisions pin: **implementer authors the
+matrix cells; the reviewer adds adversarial variants.**
+
+## Implementer cells (this slice, #525)
+
+Every cell asserts its EXPECTED verdict, not mere record presence:
+
+| Cell | Fixture mechanism |
+| --- | --- |
+| current × 6 | healthy world; fake binaries print far-future build dates (`209901010000`), git commits pinned `2026-08-01T12:00:00Z` |
+| server-binary stale (version-stale) | frozen binary prints far-past build date (`202601010000`) < pinned HEAD commit date |
+| server-binary stale (running ≠ frozen) | `--running-binary` stub with different bytes → different sha |
+| server-binary stale (server-down) | injected `discoverRunning: () => null` (never the live ps) |
+| extension stale | version bump pushed to the bare remote from a throwaway clone — the checkout learns of it only via doctor's fetch |
+| vendored-binary stale | new release tag `v1.18.12-amicode.1` pushed to the fork bare remote |
+| staged-skills stale | one staged skill's content drifted (per-skill digest diff names it) |
+| agent-cards stale | three variants: tampered deployed card · receipt missing (bytes match — the receipt is the staleness) · receipt source digests lie |
+| integrity-failure | sidecar rewritten with a wrong digest |
+| unknown × 6 | dead-remote stubs (`remote set-url` → nonexistent path) for server-binary fork / extension amicode remote / vendored release tag; missing-local-source for staged-skills + both agent-cards records |
+| degradation proof | every source dead → six `unknown` records, report never fails |
+
+Determinism: no mtime is read anywhere; "newest" is version-sorted (the
+current-world fixture writes the 0.2.4 VSIX dir AFTER 0.2.6 so its mtime is
+newer — the probe must still pick 0.2.6).
+
+## Reviewer adversarial variants — pass 2026-08-23 (gate discharged)
+
+**reviewer pass 2026-08-23: 14 adversarial variants added; findings: 2 real
+predicate gaps (extra staged skill / extra deployed card both judged
+`current` by the one-directional digest loops), fixed minimally in
+`src/surfaces.ts` and pinned; 1 prediction wrong (13-digit build stamp — the
+parser's year guard rejects it), noted, not added.**
+
+### Findings — real predicate gaps, fixed + pinned
+
+1. **staged-skills extra skill**: a skill present in staging but absent from
+ the VSIX set was judged `current` — the digest loop ran source→staged
+ only, and the set digest silently excluded the extra (version equalled
+ source_version while the deployed set had drifted). Fix: extras are
+ flagged → `stale`, named in evidence, and count toward the staged set
+ digest.
+2. **agent-cards extra deployed card**: the same one-directional loop; an
+ extra deployed `.md` card was judged `current`. Fixed identically.
+
+ Direction ruling for both: extras are `stale`, not `unknown` — the source
+ of truth is fully readable and the drift is a local hard fact, repairable
+ by redeploy (the module's own "local facts outrank unknown" invariant).
+
+### Variants added (every one verified against the real probes first)
+
+| Variant | Pinned verdict |
+| --- | --- |
+| extra staged skill (reverse-direction drift) | stale — was `current` before the fix |
+| extra deployed agent card (reverse-direction drift) | stale — was `current` before the fix |
+| uppercase-hex sidecar digest | current (normalized, case-insensitive match) |
+| build date exactly == HEAD commit date | current (staleness is strict `<`) |
+| extension dirs 0.2.10 vs 0.2.9 | current via 0.2.10 (numeric sort; lexicographic flips) |
+| release tags v1.18.10-amicode.2 vs v1.18.9-amicode.15 | current (numeric sort → base 1.18.10) |
+| reachable fork remote, zero release tags | unknown (per-surface; server-binary stays current) |
+| unexecutable frozen binary (chmod 644) | integrity-failure (`--version failed`) |
+| extension installed ahead of origin/main | stale ("ahead" evidence) |
+| vendored `--version` trailing whitespace | current (trimmed) |
+| frozen binary missing | stale (absent surface = repairable) |
+| sidecar missing while binary present | integrity-failure |
+| staged skills dir entirely missing | stale |
+| unparseable deploy receipt | stale (bytes match — the receipt is the staleness) |
+
+Probed and NOT added: a 13-digit build stamp (`…-2026080112000`) was
+predicted to misparse as a year-260 date and flip the verdict to stale — it
+does not: the parser's `y < 2000` guard rejects the shifted window, and the
+verdict is `current` with honest "build date unparseable" evidence
+(prediction wrong; out-of-contract input, no action). Ruled out without a
+fixture: unparseable VSIX dir names (versionPrefix falls to "", sorts oldest
+— or stale-behind when alone) and prerelease `--version` strings (judged
+stale "ahead" of the release-tag base, which is defensible).
+
+Hermeticity: every fixture injects temp roots (tracked + cleaned via
+`cleanupTracked`); the real `~/.amico`, `~/.vscode`, and `~/armonia` are never
+touched; git "remotes" are local bare repos inside the temp world.
diff --git a/packages/amico-run/test/helpers.ts b/packages/amico-run/test/helpers.ts
index 498d68c7..9343612a 100644
--- a/packages/amico-run/test/helpers.ts
+++ b/packages/amico-run/test/helpers.ts
@@ -1,7 +1,11 @@
-import { mkdtempSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
+import { mkdtempSync, readFileSync, writeFileSync, chmodSync, mkdirSync, copyFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
+import { execFileSync } from "node:child_process";
+import { createHash } from "node:crypto";
import { parse } from "smol-toml";
+import type { SurfaceContext } from "../src/surfaces.js";
+import { realExec } from "../src/surfaces.js";
export function tmpRoot(): string {
return mkdtempSync(join(tmpdir(), "amico-run-test-"));
@@ -30,3 +34,228 @@ export function fakeJulia(dir: string, name: string, body: string): string {
chmodSync(p, 0o755);
return p;
}
+
+// ── doctor v2 fixture worlds (#525) ─────────────────────────────────────────
+// Fully hermetic surface fixtures: temp roots, fake binaries (shell scripts
+// printing PINNED version strings), fabricated sidecars, real git repos with
+// PINNED commit dates whose "remotes" are local bare repos. The real ~/.amico,
+// ~/.vscode and ~/armonia are never touched. Date determinism: current cells
+// pin far-future printed build dates + far-past git commit dates; stale cells
+// flip one side.
+
+/** Pinned git commit date for every fixture commit (far past vs build dates). */
+export const GIT_COMMIT_DATE = "2026-08-01T12:00:00Z";
+export const GIT_DATE_ENV = {
+ GIT_AUTHOR_DATE: GIT_COMMIT_DATE,
+ GIT_COMMITTER_DATE: GIT_COMMIT_DATE,
+ GIT_AUTHOR_NAME: "doctor fixture",
+ GIT_AUTHOR_EMAIL: "fixture@example.test",
+ GIT_COMMITTER_NAME: "doctor fixture",
+ GIT_COMMITTER_EMAIL: "fixture@example.test",
+};
+/** Far-future / far-past printed build dates (embedded in fake binaries). */
+export const FUTURE_BUILD = "0.0.0-local/amicode-209901010000";
+export const PAST_BUILD = "0.0.0-local/amicode-202601010000";
+
+let trackedDirs: string[] = [];
+
+/** A tracked temp dir — removed by cleanupTracked(). */
+export function trackTmp(prefix: string): string {
+ const d = mkdtempSync(join(tmpdir(), prefix));
+ trackedDirs.push(d);
+ return d;
+}
+
+export function cleanupTracked(): void {
+ for (const d of trackedDirs) rmSync(d, { recursive: true, force: true });
+ trackedDirs = [];
+}
+
+/** git with the pinned-date env (fixtures never depend on wall-clock time). */
+export function fixtureGit(dir: string, args: string[], env: Record = {}): void {
+ execFileSync("git", ["-C", dir, ...args], {
+ env: { ...process.env, ...GIT_DATE_ENV, ...env },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+}
+
+/** A fake "binary": a shell script printing a pinned version string. */
+export function fakeBin(dir: string, name: string, versionLine: string): string {
+ mkdirSync(dir, { recursive: true });
+ const p = join(dir, name);
+ writeFileSync(p, `#!/bin/sh\necho "${versionLine}"\n`);
+ chmodSync(p, 0o755);
+ return p;
+}
+
+export const sha256File = (p: string): string => createHash("sha256").update(readFileSync(p)).digest("hex");
+
+export function writeJsonFile(p: string, v: unknown): void {
+ writeFileSync(p, JSON.stringify(v, null, 2) + "\n");
+}
+
+export interface DoctorWorld {
+ root: string;
+ server: string;
+ vscext: string;
+ config: string;
+ repoAmicode: string;
+ repoFork: string;
+ remoteAmicode: string;
+ remoteFork: string;
+ staging: string;
+ running: string;
+ frozenBin: string;
+}
+
+export interface DoctorWorldOpts {
+ /** printed by the frozen server binary (build date embedded) */
+ frozenVersion?: string;
+ /** running-process stub; undefined = byte-copy of frozen; null = no process */
+ runningVersion?: string | null;
+}
+
+/** The vendored-binary platform dir, derived from the LIVE platform — the
+ * default SurfaceContext resolves the same way, so the CLI-level tests are
+ * runner-portable (darwin-arm64 here, linux-x64 on CI). */
+export const LIVE_PLATFORM = `${process.platform}-${process.arch}`;
+
+/** The canonical CURRENT doctor-v2 world: every surface at its source of truth. */
+export function buildDoctorWorld(opts: DoctorWorldOpts = {}): DoctorWorld {
+ const root = trackTmp("doctor-v2-");
+ const server = join(root, "server");
+ const vscext = join(root, "vscode", "extensions");
+ const config = join(root, "config", "opencode");
+ const staging = join(root, "staging", "opencode-project");
+ const repoAmicode = join(root, "repos", "amicode");
+ const repoFork = join(root, "repos", "opencode");
+ const remoteAmicode = join(root, "remotes", "amicode.git");
+ const remoteFork = join(root, "remotes", "opencode.git");
+
+ // ── frozen server binary + sidecar + running-process stub ──
+ const frozenBin = fakeBin(join(server, "bin"), "opencode", opts.frozenVersion ?? FUTURE_BUILD);
+ writeFileSync(`${frozenBin}.sha256`, `${sha256File(frozenBin)} opencode\n`);
+ const running = opts.runningVersion === null ? "" : join(root, "running-opencode");
+ if (opts.runningVersion === undefined) copyFileSync(frozenBin, running);
+ else if (opts.runningVersion !== null) fakeBin(root, "running-opencode", opts.runningVersion);
+
+ // ── VSIX extension dirs: 0.2.6 NEWEST by version, 0.2.4 written AFTER it
+ // (newer mtime) — proves selection is version-sorted, never mtime ──
+ const extDir = join(vscext, "harmoniqs.amicode-0.2.6");
+ const oldExtDir = join(vscext, "harmoniqs.amicode-0.2.4-darwin-arm64");
+ for (const s of ["alpha", "beta"]) {
+ mkdirSync(join(extDir, "skills", s), { recursive: true });
+ writeFileSync(join(extDir, "skills", s, "SKILL.md"), `# ${s}\nVSIX skill ${s} v0.2.6\n`);
+ }
+ for (const s of ["alpha", "beta"]) {
+ mkdirSync(join(oldExtDir, "skills", s), { recursive: true });
+ writeFileSync(join(oldExtDir, "skills", s, "SKILL.md"), `# ${s}\nVSIX skill ${s} v0.2.4\n`);
+ }
+
+ // ── staged skills: byte-identical to the newest VSIX set ──
+ for (const s of ["alpha", "beta"]) {
+ mkdirSync(join(staging, "skills", s), { recursive: true });
+ copyFileSync(join(extDir, "skills", s, "SKILL.md"), join(staging, "skills", s, "SKILL.md"));
+ }
+
+ // ── agent cards: source (amicode repo) + both deployments + receipt ──
+ const agentsSrc = join(repoAmicode, "packages", "extension", "agents");
+ for (const c of ["autodev.md", "autoresearch.md"]) {
+ mkdirSync(agentsSrc, { recursive: true });
+ writeFileSync(join(agentsSrc, c), `---\nmode: ${c.replace(".md", "")}\n---\n# ${c}\n`);
+ }
+ mkdirSync(join(config, "agents"), { recursive: true });
+ mkdirSync(join(staging, ".opencode", "agents"), { recursive: true });
+ for (const c of ["autodev.md", "autoresearch.md"]) {
+ copyFileSync(join(agentsSrc, c), join(config, "agents", c));
+ copyFileSync(join(agentsSrc, c), join(staging, ".opencode", "agents", c));
+ }
+ writeJsonFile(join(agentsSrc, ".deploy-receipt.json"), {
+ receipt_version: 1,
+ deployed_at: "2026-08-01T00:00:00.000Z",
+ dry_run: false,
+ sources: ["autodev.md", "autoresearch.md"].map((c) => ({
+ card: c,
+ path: join(agentsSrc, c),
+ sha256: `sha256:${sha256File(join(agentsSrc, c))}`,
+ })),
+ destinations: [],
+ });
+
+ // ── amicode repo (git fixture): extension version 0.2.6 on main, vendored
+ // binary printing the fork release base version, agent-card sources ──
+ writeFileSync(
+ join(repoAmicode, "packages", "extension", "package.json"),
+ JSON.stringify({ name: "amicode", version: "0.2.6" }, null, 2) + "\n",
+ );
+ fakeBin(
+ join(repoAmicode, "packages", "extension", "vendor", "opencode", LIVE_PLATFORM),
+ "opencode",
+ "1.18.10",
+ );
+ fixtureGit(repoAmicode, ["init", "-b", "main"]);
+ fixtureGit(repoAmicode, ["add", "-A"]);
+ fixtureGit(repoAmicode, ["commit", "-m", "amicode fixture"]);
+ execFileSync("git", ["init", "--bare", "-b", "main", remoteAmicode]);
+ fixtureGit(repoAmicode, ["remote", "add", "origin", remoteAmicode]);
+ fixtureGit(repoAmicode, ["push", "-u", "origin", "main"]);
+
+ // ── fork repo (git fixture): branch local/amicode, release tag on tip ──
+ mkdirSync(repoFork, { recursive: true });
+ writeFileSync(join(repoFork, "README.md"), "fork fixture\n");
+ fixtureGit(repoFork, ["init", "-b", "local/amicode"]);
+ fixtureGit(repoFork, ["add", "-A"]);
+ fixtureGit(repoFork, ["commit", "-m", "fork fixture"]);
+ fixtureGit(repoFork, ["tag", "v1.18.10-amicode.15"]);
+ execFileSync("git", ["init", "--bare", "-b", "local/amicode", remoteFork]);
+ fixtureGit(repoFork, ["remote", "add", "origin", remoteFork]);
+ fixtureGit(repoFork, ["push", "-u", "origin", "local/amicode"]);
+ fixtureGit(repoFork, ["push", "origin", "v1.18.10-amicode.15"]);
+
+ return { root, server, vscext, config, repoAmicode, repoFork, remoteAmicode, remoteFork, staging, running, frozenBin };
+}
+
+export function ctxForWorld(w: DoctorWorld, over: Partial = {}): SurfaceContext {
+ const base: SurfaceContext = {
+ rootServer: w.server,
+ rootVscext: w.vscext,
+ rootConfig: w.config,
+ rootRepoAmicode: w.repoAmicode,
+ rootRepoFork: w.repoFork,
+ rootStaging: w.staging,
+ runningBinary: w.running || null,
+ platform: LIVE_PLATFORM,
+ run: realExec,
+ // hermetic default: a fixture that forgets to stub running-process
+ // discovery gets server-down, never the REAL machine's process
+ discoverRunning: async () => null,
+ };
+ return { ...base, ...over };
+}
+
+/** Mutate a bare remote from a throwaway clone — the fixture's checkout learns
+ * of the change ONLY through doctor's fetch (source-of-truth movement). */
+export function withBareClone(bare: string, branch: string, fn: (clone: string) => void): void {
+ const clone = trackTmp("doctor-v2-clone-");
+ execFileSync("git", ["clone", "--branch", branch, bare, clone], { stdio: ["ignore", "pipe", "pipe"] });
+ fn(clone);
+ fixtureGit(clone, ["push", "origin", `HEAD:refs/heads/${branch}`]);
+}
+
+export function bumpExtensionOnRemote(bare: string, version: string): void {
+ withBareClone(bare, "main", (clone) => {
+ writeFileSync(
+ join(clone, "packages", "extension", "package.json"),
+ JSON.stringify({ name: "amicode", version }, null, 2) + "\n",
+ );
+ fixtureGit(clone, ["add", "-A"]);
+ fixtureGit(clone, ["commit", "-m", `bump extension to ${version}`]);
+ });
+}
+
+export function addReleaseTagOnRemote(bare: string, tag: string): void {
+ withBareClone(bare, "local/amicode", (clone) => {
+ fixtureGit(clone, ["tag", tag]);
+ fixtureGit(clone, ["push", "origin", tag]);
+ });
+}
diff --git a/packages/amico-run/test/surfaces.test.ts b/packages/amico-run/test/surfaces.test.ts
new file mode 100644
index 00000000..1ff869ba
--- /dev/null
+++ b/packages/amico-run/test/surfaces.test.ts
@@ -0,0 +1,550 @@
+// surfaces.test.ts — doctor v2's verdict-matrix fixture suite (#525, spec D1 +
+// Measurement Protocol). Fully hermetic: every fixture injects temp roots — fake
+// binaries are shell scripts printing PINNED version strings, sidecars are
+// fabricated next to them, git fixtures are real repos with PINNED commit dates
+// (env overrides at setup) whose "remotes" are local bare repos (or dead paths
+// for the unreachable-remote cells). The real ~/.amico, ~/.vscode and
+// ~/armonia are NEVER touched. The world builder lives in test/helpers.ts
+// (shared with the doctor unit + CLI tests).
+//
+// Date determinism (the spec's rule): current cells pin BOTH sides — fake
+// binaries print FAR-FUTURE build dates (2099…), git commits carry FAR-PAST
+// pinned dates (2026-08-01); stale cells flip one side. No mtime is ever read.
+//
+// Authorship (the house split): implementer authored these cells; the reviewer
+// adds adversarial variants — recorded in test/fixtures/surfaces/README.md.
+import { describe, test, expect } from "vitest";
+import { writeFileSync, rmSync, readFileSync, chmodSync, mkdirSync, cpSync } from "node:fs";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { dirname, join as joinPath } from "node:path";
+import { surfaceInventory, canonicalJson, type SurfaceContext, type SurfaceRecord } from "../src/surfaces.js";
+import { loadDoctorSchema, validateDoctorReport } from "../src/doctor_schema.js";
+import {
+ buildDoctorWorld,
+ ctxForWorld,
+ cleanupTracked,
+ fixtureGit,
+ bumpExtensionOnRemote,
+ addReleaseTagOnRemote,
+ fakeBin,
+ sha256File,
+ LIVE_PLATFORM,
+ GIT_COMMIT_DATE,
+ FUTURE_BUILD,
+ PAST_BUILD,
+ type DoctorWorld,
+} from "./helpers.js";
+
+const cleanup = cleanupTracked;
+
+const bySurface = (report: { surfaces: SurfaceRecord[] }, name: string): SurfaceRecord =>
+ report.surfaces.find((r) => r.surface === name)!;
+
+// ── the matrix ───────────────────────────────────────────────────────────────
+describe("doctor v2 surface inventory — current cells", () => {
+ test("current world: all six surfaces current, records complete and ordered", async () => {
+ const w = buildDoctorWorld();
+ const report = await surfaceInventory(ctxForWorld(w));
+ expect(report.surfaces.map((r) => [r.surface, r.verdict])).toEqual([
+ ["server-binary", "current"],
+ ["extension", "current"],
+ ["vendored-binary", "current"],
+ ["staged-skills", "current"],
+ ["agent-cards-global", "current"],
+ ["agent-cards-staging", "current"],
+ ]);
+ for (const r of report.surfaces) {
+ expect(r.version, `${r.surface} version`).toBeTruthy();
+ expect(r.source_version, `${r.surface} source_version`).toBeTruthy();
+ expect(r.evidence.length, `${r.surface} evidence`).toBeGreaterThan(0);
+ }
+ // server-binary: frozen version observed, source = fetched HEAD commit date
+ const sb = bySurface(report, "server-binary");
+ expect(sb.version).toBe(FUTURE_BUILD);
+ expect(sb.evidence.join(" ")).toMatch(/sha256/);
+ // extension: version-SORTED newest (0.2.6), never mtime (0.2.4 dir is newer)
+ const ext = bySurface(report, "extension");
+ expect(ext.version).toContain("0.2.6");
+ expect(ext.source_version).toBe("0.2.6");
+ // vendored: printed version == latest release tag base
+ const vb = bySurface(report, "vendored-binary");
+ expect(vb.version).toBe("1.18.10");
+ expect(vb.source_version).toBe("1.18.10");
+ cleanup();
+ });
+});
+
+describe("doctor v2 surface inventory — stale cells", () => {
+ test("server-binary stale (version): far-past build date < pinned HEAD commit date", async () => {
+ const w = buildDoctorWorld({ frozenVersion: PAST_BUILD }); // build 2026-01-01 < HEAD 2026-08-01
+ const report = await surfaceInventory(ctxForWorld(w));
+ const sb = bySurface(report, "server-binary");
+ expect(sb.verdict).toBe("stale");
+ expect(sb.version).toBe(PAST_BUILD);
+ expect(sb.evidence.join(" ")).toMatch(/build date .* < HEAD commit date/);
+ cleanup();
+ });
+
+ test("server-binary stale (restart pending): running binary sha ≠ frozen sha", async () => {
+ // different bytes (one-digit-different version line) → different sha
+ const w = buildDoctorWorld({ runningVersion: "0.0.0-local/amicode-209901010001" });
+ const report = await surfaceInventory(ctxForWorld(w));
+ const sb = bySurface(report, "server-binary");
+ expect(sb.verdict).toBe("stale");
+ expect(sb.evidence.join(" ")).toMatch(/running .* sha256 .* ≠ frozen sha256 .* \(restart pending\)/);
+ cleanup();
+ });
+
+ test("server-binary stale (server-down): absent process is stale with server-down evidence", async () => {
+ const w = buildDoctorWorld({ runningVersion: null });
+ const report = await surfaceInventory(ctxForWorld(w, { discoverRunning: async () => null }));
+ const sb = bySurface(report, "server-binary");
+ expect(sb.verdict).toBe("stale");
+ expect(sb.evidence.join(" ")).toMatch(/server-down: no running opencode serve process/);
+ cleanup();
+ });
+
+ test("extension stale: installed 0.2.6 behind fetched origin/main 0.2.7", async () => {
+ const w = buildDoctorWorld();
+ bumpExtensionOnRemote(w.remoteAmicode, "0.2.7");
+ const report = await surfaceInventory(ctxForWorld(w));
+ const ext = bySurface(report, "extension");
+ expect(ext.verdict).toBe("stale");
+ expect(ext.version).toContain("0.2.6");
+ expect(ext.source_version).toBe("0.2.7");
+ expect(ext.evidence.join(" ")).toMatch(/behind/);
+ cleanup();
+ });
+
+ test("vendored-binary stale: printed 1.18.10 behind new release tag base 1.18.12", async () => {
+ const w = buildDoctorWorld();
+ addReleaseTagOnRemote(w.remoteFork, "v1.18.12-amicode.1");
+ const report = await surfaceInventory(ctxForWorld(w));
+ const vb = bySurface(report, "vendored-binary");
+ expect(vb.verdict).toBe("stale");
+ expect(vb.version).toBe("1.18.10");
+ expect(vb.source_version).toBe("1.18.12");
+ expect(vb.evidence.join(" ")).toMatch(/behind/);
+ cleanup();
+ });
+
+ test("staged-skills stale: per-skill digest diff (changed skill named in evidence)", async () => {
+ const w = buildDoctorWorld();
+ writeFileSync(join(w.staging, "skills", "beta", "SKILL.md"), "# beta\nDRIFTED staged copy\n");
+ const report = await surfaceInventory(ctxForWorld(w));
+ const sk = bySurface(report, "staged-skills");
+ expect(sk.verdict).toBe("stale");
+ expect(sk.evidence.join(" ")).toMatch(/skill beta changed/);
+ expect(sk.evidence.join(" ")).not.toMatch(/skill alpha/); // alpha still byte-matches
+ cleanup();
+ });
+
+ test("agent-cards-global stale: deployed card tampered (per-card digest diff)", async () => {
+ const w = buildDoctorWorld();
+ writeFileSync(join(w.config, "agents", "autodev.md"), "---\nmode: autodev\n---\n# TAMPERED\n");
+ const report = await surfaceInventory(ctxForWorld(w));
+ const g = bySurface(report, "agent-cards-global");
+ expect(g.verdict).toBe("stale");
+ expect(g.evidence.join(" ")).toMatch(/card autodev\.md changed/);
+ const st = bySurface(report, "agent-cards-staging");
+ expect(st.verdict).toBe("current"); // the OTHER deployment is unaffected
+ cleanup();
+ });
+
+ test("agent-cards-staging stale: source present + receipt missing is stale (digest diff governs, receipt secondary)", async () => {
+ const w = buildDoctorWorld();
+ rmSync(join(w.repoAmicode, "packages", "extension", "agents", ".deploy-receipt.json"));
+ const report = await surfaceInventory(ctxForWorld(w));
+ for (const name of ["agent-cards-global", "agent-cards-staging"]) {
+ const r = bySurface(report, name);
+ expect(r.verdict).toBe("stale");
+ expect(r.evidence.join(" ")).toMatch(/receipt missing/);
+ expect(r.evidence.join(" ")).toMatch(/byte-match/); // bytes agree — the receipt is the staleness
+ }
+ cleanup();
+ });
+
+ test("agent-cards stale: receipt source digests ≠ current sources", async () => {
+ const w = buildDoctorWorld();
+ const receiptPath = join(w.repoAmicode, "packages", "extension", "agents", ".deploy-receipt.json");
+ const receipt = JSON.parse(readFileSync(receiptPath, "utf8")) as { sources: { card: string; sha256: string }[] };
+ receipt.sources[0].sha256 = "sha256:" + "0".repeat(64); // lies about autodev.md
+ writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + "\n");
+ const report = await surfaceInventory(ctxForWorld(w));
+ const g = bySurface(report, "agent-cards-global");
+ expect(g.verdict).toBe("stale");
+ expect(g.evidence.join(" ")).toMatch(/receipt source digest for autodev\.md ≠ current source/);
+ cleanup();
+ });
+});
+
+describe("doctor v2 surface inventory — integrity-failure cell", () => {
+ test("server-binary integrity-failure: tampered sidecar (frozen sha ≠ sidecar)", async () => {
+ const w = buildDoctorWorld();
+ const sidecar = `${w.frozenBin}.sha256`;
+ writeFileSync(sidecar, `${"0".repeat(64)} opencode\n`); // the sidecar lies
+ const report = await surfaceInventory(ctxForWorld(w));
+ const sb = bySurface(report, "server-binary");
+ expect(sb.verdict).toBe("integrity-failure");
+ expect(sb.evidence.join(" ")).toMatch(/frozen sha256 .* ≠ sidecar/);
+ // one bad surface never fails the report: the other five still judged
+ expect(bySurface(report, "extension").verdict).toBe("current");
+ expect(bySurface(report, "agent-cards-global").verdict).toBe("current");
+ cleanup();
+ });
+});
+
+describe("doctor v2 surface inventory — unknown cells (every surface degrades individually)", () => {
+ const DEAD_REMOTE = "/nonexistent/doctors-fixture-remote.git";
+
+ test("server-binary unknown: unreachable fork remote", async () => {
+ const w = buildDoctorWorld();
+ fixtureGit(w.repoFork, ["remote", "set-url", "origin", DEAD_REMOTE]);
+ const report = await surfaceInventory(ctxForWorld(w));
+ const sb = bySurface(report, "server-binary");
+ expect(sb.verdict).toBe("unknown");
+ expect(sb.evidence.join(" ")).toMatch(/fork fetch failed/);
+ // local facts still reported: integrity + running checks pass
+ expect(sb.evidence.join(" ")).toMatch(/local checks pass/);
+ cleanup();
+ });
+
+ test("extension unknown: unreachable amicode remote", async () => {
+ const w = buildDoctorWorld();
+ fixtureGit(w.repoAmicode, ["remote", "set-url", "origin", DEAD_REMOTE]);
+ const report = await surfaceInventory(ctxForWorld(w));
+ const ext = bySurface(report, "extension");
+ expect(ext.verdict).toBe("unknown");
+ expect(ext.evidence.join(" ")).toMatch(/amicode fetch failed/);
+ // the agent-cards source is the LOCAL checkout — unaffected by the dead remote
+ expect(bySurface(report, "agent-cards-global").verdict).toBe("current");
+ cleanup();
+ });
+
+ test("vendored-binary unknown: unreachable fork remote (release tags not refreshable)", async () => {
+ const w = buildDoctorWorld();
+ fixtureGit(w.repoFork, ["remote", "set-url", "origin", DEAD_REMOTE]);
+ const report = await surfaceInventory(ctxForWorld(w));
+ const vb = bySurface(report, "vendored-binary");
+ expect(vb.verdict).toBe("unknown");
+ expect(vb.evidence.join(" ")).toMatch(/fork fetch failed/);
+ cleanup();
+ });
+
+ test("staged-skills unknown: missing local source (no VSIX skills set)", async () => {
+ const w = buildDoctorWorld();
+ rmSync(w.vscext, { recursive: true, force: true });
+ const report = await surfaceInventory(ctxForWorld(w));
+ const sk = bySurface(report, "staged-skills");
+ expect(sk.verdict).toBe("unknown");
+ expect(sk.evidence.join(" ")).toMatch(/missing local source/);
+ cleanup();
+ });
+
+ test("agent-cards-global unknown: missing source dir", async () => {
+ const w = buildDoctorWorld();
+ rmSync(join(w.repoAmicode, "packages", "extension", "agents"), { recursive: true, force: true });
+ const report = await surfaceInventory(ctxForWorld(w));
+ const g = bySurface(report, "agent-cards-global");
+ expect(g.verdict).toBe("unknown");
+ expect(g.evidence.join(" ")).toMatch(/missing local source/);
+ cleanup();
+ });
+
+ test("agent-cards-staging unknown: missing source dir", async () => {
+ const w = buildDoctorWorld();
+ rmSync(join(w.repoAmicode, "packages", "extension", "agents"), { recursive: true, force: true });
+ const report = await surfaceInventory(ctxForWorld(w));
+ const st = bySurface(report, "agent-cards-staging");
+ expect(st.verdict).toBe("unknown");
+ expect(st.evidence.join(" ")).toMatch(/missing local source/);
+ cleanup();
+ });
+
+ test("no report ever fails: all six records present even when every source is unreachable", async () => {
+ const w = buildDoctorWorld();
+ fixtureGit(w.repoFork, ["remote", "set-url", "origin", DEAD_REMOTE]);
+ fixtureGit(w.repoAmicode, ["remote", "set-url", "origin", DEAD_REMOTE]);
+ rmSync(w.vscext, { recursive: true, force: true });
+ rmSync(join(w.repoAmicode, "packages", "extension", "agents"), { recursive: true, force: true });
+ const report = await surfaceInventory(ctxForWorld(w));
+ expect(report.surfaces).toHaveLength(6);
+ // every source of truth is dead → every surface degrades to unknown, and
+ // the report still returns all six records — never a failed report
+ expect(report.surfaces.every((r) => r.verdict === "unknown")).toBe(true);
+ expect(report.surfaces.every((r) => r.evidence.length > 0)).toBe(true);
+ cleanup();
+ });
+});
+
+// ── reviewer adversarial variants (pass 2026-08-23) ──────────────────────────
+// Born from the reviewer's scratch-probe pass against the real predicates
+// (authorship gate, #525). Two probes found real gaps — the digest loops ran
+// source→deployed only, so EXTRA staged/deployed content was judged current —
+// fixed in src/surfaces.ts and pinned here (reverse-direction drift). The rest
+// pin correct handling of adversarial inputs the implementer matrix never
+// exercised. Full record: test/fixtures/surfaces/README.md.
+describe("doctor v2 surface inventory — reviewer adversarial variants (2026-08-23)", () => {
+ test("staged-skills stale: extra staged skill absent from the VSIX set (reverse-direction drift)", async () => {
+ const w = buildDoctorWorld();
+ mkdirSync(join(w.staging, "skills", "ghost"), { recursive: true });
+ writeFileSync(join(w.staging, "skills", "ghost", "SKILL.md"), "# ghost\nleftover from an older deployment\n");
+ const report = await surfaceInventory(ctxForWorld(w));
+ const sk = bySurface(report, "staged-skills");
+ expect(sk.verdict).toBe("stale");
+ expect(sk.evidence.join(" ")).toMatch(/skill ghost extra in staged set/);
+ expect(sk.evidence.join(" ")).not.toMatch(/skill (alpha|beta) /); // shared skills still byte-match
+ expect(sk.version).not.toBe(sk.source_version); // deployed set identity includes the extra
+ cleanup();
+ });
+
+ test("agent-cards-global stale: extra deployed card absent from sources (reverse-direction drift)", async () => {
+ const w = buildDoctorWorld();
+ writeFileSync(join(w.config, "agents", "ghost.md"), "---\nmode: ghost\n---\n# ghost\n");
+ const report = await surfaceInventory(ctxForWorld(w));
+ const g = bySurface(report, "agent-cards-global");
+ expect(g.verdict).toBe("stale");
+ expect(g.evidence.join(" ")).toMatch(/card ghost\.md extra in deployed set/);
+ expect(g.version).not.toBe(g.source_version);
+ expect(bySurface(report, "agent-cards-staging").verdict).toBe("current"); // the other deployment is unaffected
+ cleanup();
+ });
+
+ test("server-binary current: uppercase-hex sidecar digest is normalized", async () => {
+ const w = buildDoctorWorld();
+ writeFileSync(`${w.frozenBin}.sha256`, `${sha256File(w.frozenBin).toUpperCase()} opencode\n`);
+ const report = await surfaceInventory(ctxForWorld(w));
+ const sb = bySurface(report, "server-binary");
+ expect(sb.verdict).toBe("current");
+ expect(sb.evidence.join(" ")).toMatch(/sha256 .* = sidecar/);
+ cleanup();
+ });
+
+ test("server-binary current: build date exactly equal to HEAD commit date (boundary — equal is current)", async () => {
+ // stamp derived from the pinned commit date, not duplicated: equal minutes
+ const equalStamp = GIT_COMMIT_DATE.slice(0, 16).replace(/[-T:]/g, "");
+ const w = buildDoctorWorld({ frozenVersion: `0.0.0-local/amicode-${equalStamp}` });
+ const report = await surfaceInventory(ctxForWorld(w));
+ const sb = bySurface(report, "server-binary");
+ expect(sb.verdict).toBe("current"); // staleness is strict <, so equal passes
+ expect(sb.evidence.join(" ")).toMatch(/build date .* ≥ HEAD commit date/);
+ cleanup();
+ });
+
+ test("extension current: numeric version sort — 0.2.10 is newer than 0.2.9 (lexicographic would flip)", async () => {
+ const w = buildDoctorWorld();
+ const skillSrc = join(w.vscext, "harmoniqs.amicode-0.2.6", "skills");
+ for (const v of ["0.2.9", "0.2.10"]) {
+ cpSync(skillSrc, join(w.vscext, `harmoniqs.amicode-${v}`, "skills"), { recursive: true });
+ }
+ rmSync(join(w.vscext, "harmoniqs.amicode-0.2.6"), { recursive: true, force: true });
+ rmSync(join(w.vscext, "harmoniqs.amicode-0.2.4-darwin-arm64"), { recursive: true, force: true });
+ bumpExtensionOnRemote(w.remoteAmicode, "0.2.10");
+ const report = await surfaceInventory(ctxForWorld(w));
+ const ext = bySurface(report, "extension");
+ expect(ext.verdict).toBe("current");
+ expect(ext.version).toBe("0.2.10");
+ expect(bySurface(report, "staged-skills").verdict).toBe("current"); // compared against the 0.2.10 set
+ cleanup();
+ });
+
+ test("vendored-binary unknown: reachable fork remote with NO release tags", async () => {
+ const w = buildDoctorWorld();
+ fixtureGit(w.repoFork, ["tag", "-d", "v1.18.10-amicode.15"]);
+ fixtureGit(w.repoFork, ["push", "origin", ":refs/tags/v1.18.10-amicode.15"]);
+ const report = await surfaceInventory(ctxForWorld(w));
+ const vb = bySurface(report, "vendored-binary");
+ expect(vb.verdict).toBe("unknown");
+ expect(vb.evidence.join(" ")).toMatch(/no fork release tags/);
+ // the remote is REACHABLE — only the tagless surface degrades
+ expect(bySurface(report, "server-binary").verdict).toBe("current");
+ cleanup();
+ });
+
+ test("vendored-binary current: release-tag sort is numeric (v1.18.10-amicode.2 > v1.18.9-amicode.15)", async () => {
+ const w = buildDoctorWorld();
+ fixtureGit(w.repoFork, ["tag", "-d", "v1.18.10-amicode.15"]);
+ fixtureGit(w.repoFork, ["push", "origin", ":refs/tags/v1.18.10-amicode.15"]);
+ addReleaseTagOnRemote(w.remoteFork, "v1.18.9-amicode.15");
+ addReleaseTagOnRemote(w.remoteFork, "v1.18.10-amicode.2");
+ const report = await surfaceInventory(ctxForWorld(w));
+ const vb = bySurface(report, "vendored-binary");
+ expect(vb.verdict).toBe("current");
+ expect(vb.source_version).toBe("1.18.10"); // string sort would crown v1.18.9-amicode.15 → stale "ahead"
+ expect(vb.evidence.join(" ")).toMatch(/latest fork release tag v1\.18\.10-amicode\.2/);
+ cleanup();
+ });
+
+ test("server-binary integrity-failure: unexecutable frozen binary (--version fails)", async () => {
+ const w = buildDoctorWorld();
+ chmodSync(w.frozenBin, 0o644); // bytes unchanged: sidecar still matches — the exec is what breaks
+ const report = await surfaceInventory(ctxForWorld(w));
+ const sb = bySurface(report, "server-binary");
+ expect(sb.verdict).toBe("integrity-failure");
+ expect(sb.evidence.join(" ")).toMatch(/--version failed/);
+ cleanup();
+ });
+
+ test("extension stale: installed AHEAD of origin/main (source repo behind)", async () => {
+ const w = buildDoctorWorld();
+ cpSync(join(w.vscext, "harmoniqs.amicode-0.2.6", "skills"), join(w.vscext, "harmoniqs.amicode-0.2.8", "skills"), { recursive: true });
+ const report = await surfaceInventory(ctxForWorld(w));
+ const ext = bySurface(report, "extension");
+ expect(ext.verdict).toBe("stale");
+ expect(ext.version).toBe("0.2.8");
+ expect(ext.source_version).toBe("0.2.6");
+ expect(ext.evidence.join(" ")).toMatch(/ahead of origin\/main/);
+ cleanup();
+ });
+
+ test("vendored-binary current: --version output with trailing whitespace is trimmed", async () => {
+ const w = buildDoctorWorld();
+ fakeBin(join(w.repoAmicode, "packages", "extension", "vendor", "opencode", LIVE_PLATFORM), "opencode", "1.18.10 ");
+ const report = await surfaceInventory(ctxForWorld(w));
+ const vb = bySurface(report, "vendored-binary");
+ expect(vb.verdict).toBe("current");
+ expect(vb.version).toBe("1.18.10"); // untrimmed it would compare unequal to the tag base
+ cleanup();
+ });
+
+ test("server-binary stale: frozen binary missing (absent surface is the repairable state)", async () => {
+ const w = buildDoctorWorld();
+ rmSync(w.frozenBin, { force: true });
+ const report = await surfaceInventory(ctxForWorld(w));
+ const sb = bySurface(report, "server-binary");
+ expect(sb.verdict).toBe("stale");
+ expect(sb.evidence.join(" ")).toMatch(/frozen binary missing/);
+ cleanup();
+ });
+
+ test("server-binary integrity-failure: sidecar missing while binary present", async () => {
+ const w = buildDoctorWorld();
+ rmSync(`${w.frozenBin}.sha256`, { force: true });
+ const report = await surfaceInventory(ctxForWorld(w));
+ const sb = bySurface(report, "server-binary");
+ expect(sb.verdict).toBe("integrity-failure");
+ expect(sb.evidence.join(" ")).toMatch(/sidecar missing/);
+ cleanup();
+ });
+
+ test("staged-skills stale: staged dir entirely missing", async () => {
+ const w = buildDoctorWorld();
+ rmSync(join(w.staging, "skills"), { recursive: true, force: true });
+ const report = await surfaceInventory(ctxForWorld(w));
+ const sk = bySurface(report, "staged-skills");
+ expect(sk.verdict).toBe("stale");
+ expect(sk.evidence.join(" ")).toMatch(/staged skills dir missing or empty/);
+ cleanup();
+ });
+
+ test("agent-cards stale: unparseable deploy receipt (bytes match — the receipt is the staleness)", async () => {
+ const w = buildDoctorWorld();
+ writeFileSync(join(w.repoAmicode, "packages", "extension", "agents", ".deploy-receipt.json"), "{not json");
+ const report = await surfaceInventory(ctxForWorld(w));
+ for (const name of ["agent-cards-global", "agent-cards-staging"]) {
+ const r = bySurface(report, name);
+ expect(r.verdict).toBe("stale");
+ expect(r.evidence.join(" ")).toMatch(/receipt unparseable/);
+ expect(r.evidence.join(" ")).toMatch(/byte-match/);
+ }
+ cleanup();
+ });
+});
+
+// ── the JSON contract (AC: schema + canonical form) ─────────────────────────
+describe("doctor v2 JSON contract", () => {
+ const schemaPath = joinPath(dirname(fileURLToPath(import.meta.url)), "..", "schemas", "doctor-report.schema.json");
+
+ const representativeWorlds: { name: string; build: () => Promise<{ report: unknown; world: DoctorWorld }> }[] = [
+ {
+ name: "current world",
+ build: async () => {
+ const w = buildDoctorWorld();
+ return { report: await surfaceInventory(ctxForWorld(w)), world: w };
+ },
+ },
+ {
+ name: "stale world (version-stale server binary)",
+ build: async () => {
+ const w = buildDoctorWorld({ frozenVersion: PAST_BUILD });
+ return { report: await surfaceInventory(ctxForWorld(w)), world: w };
+ },
+ },
+ {
+ name: "integrity-failure world (tampered sidecar)",
+ build: async () => {
+ const w = buildDoctorWorld();
+ writeFileSync(`${w.frozenBin}.sha256`, `${"0".repeat(64)} opencode\n`);
+ return { report: await surfaceInventory(ctxForWorld(w)), world: w };
+ },
+ },
+ {
+ name: "unknown world (dead fork remote)",
+ build: async () => {
+ const w = buildDoctorWorld();
+ fixtureGit(w.repoFork, ["remote", "set-url", "origin", "/nonexistent/x.git"]);
+ return { report: await surfaceInventory(ctxForWorld(w)), world: w };
+ },
+ },
+ ];
+
+ for (const world of representativeWorlds) {
+ test(`${world.name}: report validates against the committed schema and round-trips byte-equal under the canonical form`, async () => {
+ const { report } = await world.build();
+ const v = validateDoctorReport(report);
+ expect(v.errors, JSON.stringify(v.errors)).toEqual([]);
+ expect(v.ok).toBe(true);
+ const once = canonicalJson(report);
+ expect(once.endsWith("\n")).toBe(true); // trailing newline
+ expect(once).toBe(canonicalJson(JSON.parse(once))); // round-trip byte-equal
+ expect(once.split("\n")[1]).toBe(' "surfaces": ['); // 2-space indent, sorted keys
+ cleanup();
+ });
+ }
+
+ test("the committed schema file itself is canonical (deep-sorted keys, 2-space, trailing newline)", () => {
+ const raw = readFileSync(schemaPath, "utf8");
+ expect(raw).toBe(canonicalJson(JSON.parse(raw)));
+ });
+
+ test("the committed schema enforces minItems 6 and the required record fields", () => {
+ const schema = loadDoctorSchema();
+ const surfaces = (schema.properties as Record).surfaces;
+ expect(surfaces.minItems).toBe(6);
+ const record = (schema.properties as Record).surfaces.items;
+ expect([...record.required].sort()).toEqual(["evidence", "surface", "verdict", "version"]);
+ });
+
+ test("schema rejects: five surfaces, missing field, bad verdict, non-array evidence", () => {
+ const good = {
+ surfaces: [
+ { surface: "server-binary", version: "1", source_version: "1", verdict: "current", evidence: ["ok"] },
+ { surface: "extension", version: "1", source_version: "1", verdict: "current", evidence: ["ok"] },
+ { surface: "vendored-binary", version: "1", source_version: "1", verdict: "current", evidence: ["ok"] },
+ { surface: "staged-skills", version: "1", source_version: "1", verdict: "current", evidence: ["ok"] },
+ { surface: "agent-cards-global", version: "1", source_version: "1", verdict: "current", evidence: ["ok"] },
+ { surface: "agent-cards-staging", version: "1", source_version: "1", verdict: "current", evidence: ["ok"] },
+ ],
+ };
+ expect(validateDoctorReport(good).ok).toBe(true);
+
+ const five = { surfaces: good.surfaces.slice(0, 5) };
+ expect(validateDoctorReport(five).errors.some((e) => /at least 6 items/.test(e.message))).toBe(true);
+
+ const missingField = { surfaces: good.surfaces.map((s, i) => (i === 0 ? { surface: "server-binary", verdict: "current", evidence: ["x"] } : s)) };
+ expect(validateDoctorReport(missingField).errors.some((e) => e.path === "$.surfaces[0]" && /version/.test(e.message))).toBe(true);
+
+ const badVerdict = { surfaces: good.surfaces.map((s, i) => (i === 1 ? { ...s, verdict: "borked" } : s)) };
+ expect(validateDoctorReport(badVerdict).errors.some((e) => e.path === "$.surfaces[1].verdict")).toBe(true);
+
+ const badEvidence = { surfaces: good.surfaces.map((s, i) => (i === 2 ? { ...s, evidence: "ok" } : s)) };
+ expect(validateDoctorReport(badEvidence).errors.some((e) => e.path === "$.surfaces[2].evidence")).toBe(true);
+
+ const emptyEvidence = { surfaces: good.surfaces.map((s, i) => (i === 3 ? { ...s, evidence: [] } : s)) };
+ expect(validateDoctorReport(emptyEvidence).errors.some((e) => e.path === "$.surfaces[3].evidence")).toBe(true);
+
+ const unknownSurface = { surfaces: good.surfaces.map((s, i) => (i === 4 ? { ...s, surface: "sidecar-bin" } : s)) };
+ expect(validateDoctorReport(unknownSurface).errors.some((e) => e.path === "$.surfaces[4].surface")).toBe(true);
+ });
+});