Skip to content
Draft
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
30 changes: 24 additions & 6 deletions packages/extension/julia/Manifest.toml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 9 additions & 9 deletions packages/extension/julia/Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76"
# Measured against the registry today: with no compat, `Pkg.update("Piccolo")` on the
# shipped pair takes 1.19.0 -> 1.20.0. With `~1.19` it holds at 1.19.0.
#
# 1.20.0 is a deliberately breaking release (`integrator_type = :spline` now errors,
# `fidelity(qcp)` became free-phase-aware and returns different numbers for
# `free_phase = true` problems, and `sync_trajectory!` now warns on optimizer-vs-rollout
# divergence). Every shipped score and template was vetted against 1.19.0, so moving is
# a decision to make deliberately after re-vetting — not something a resolver should do
# on a user's machine mid-session.
# 1.20.0 and 1.21.0 are deliberately breaking releases (1.20: `integrator_type =
# :spline` now errors, `fidelity(qcp)` became free-phase-aware, and
# `sync_trajectory!` now warns on optimizer-vs-rollout divergence; 1.21: Specs
# Phase 1 wire format). Every shipped score and template was vetted against 1.19.0
# re-vetted here against 1.21.0 (solve_template.jl et al. do not use the
# breaking APIs, so no template changes were needed; see harmoniqs/Piccolo.jl#271).
#
# `~1.19` admits 1.19.x patches (non-breaking by SemVer) and refuses 1.20. Raise this in
# the same PR that re-vets the templates against the new minor.
Piccolo = "~1.19"
# `~1.21` admits 1.21.x patches and refuses 1.22 (which will carry Phase 1b
# parametric typing). Raise this with re-vetting for each new minor.
Piccolo = "~1.21"
452 changes: 450 additions & 2 deletions packages/extension/opencode-plugin/amicode_tools.ts

Large diffs are not rendered by default.

39 changes: 28 additions & 11 deletions packages/extension/opencode-plugin/problems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,22 +66,39 @@ export function setActiveSlug(slug: string): void {
}

// --- problem.json (the machine-read source) ----------------------------------
// W2.1: the workspace *card* is now `card.toml`/`card.json` (the `problem.toml`
// basename is the typed ProblemSpec, validated as `problemspec`). Legacy
// workspaces with `problem.toml`/`problem.json` still read correctly via fallback.

function readProblemMeta(slug: string): ProblemMeta | undefined {
const file = path.join(problemDir(slug), "problem.json");
if (!fs.existsSync(file)) return undefined;
try {
return JSON.parse(fs.readFileSync(file, "utf8")) as ProblemMeta;
} catch {
return undefined;
const candidates = [path.join(problemDir(slug), "card.json"), path.join(problemDir(slug), "problem.json")];
for (const file of candidates) {
if (!fs.existsSync(file)) continue;
try {
return JSON.parse(fs.readFileSync(file, "utf8")) as ProblemMeta;
} catch {
continue;
}
}
return undefined;
}

/** Write both problem.toml and its .json sidecar, stamping `recorded` = now. */
/** Write card.toml + card.json, stamping `recorded` = now. Legacy `problem.*`
* files are removed on next write to free the `problem.toml` basename for the
* typed ProblemSpec (W2.1). Reading still falls back to the legacy path. */
function writeProblemMeta(meta: ProblemMeta): void {
const stamped: ProblemMeta = { ...meta, recorded: new Date().toISOString() };
atomicWrite(path.join(problemDir(meta.slug), "problem.toml"), problemToml(stamped));
atomicWrite(path.join(problemDir(meta.slug), "problem.json"), problemJson(stamped));
const dir = problemDir(meta.slug);
atomicWrite(path.join(dir, "card.toml"), problemToml(stamped));
atomicWrite(path.join(dir, "card.json"), problemJson(stamped));
// Migrate legacy files away (best-effort, ignore ENOENT)
for (const legacy of [path.join(dir, "problem.toml"), path.join(dir, "problem.json")]) {
try {
if (fs.existsSync(legacy)) fs.unlinkSync(legacy);
} catch {
/* ignore */
}
}
}

/** First non-colliding slug: `base`, then `base-2`, `base-3`, … */
Expand Down Expand Up @@ -289,8 +306,8 @@ export function migrateLegacyEntities(
status: "archived",
recorded: new Date().toISOString(),
};
fs.writeFileSync(path.join(ws, "problem.toml"), problemToml(meta));
fs.writeFileSync(path.join(ws, "problem.json"), problemJson(meta));
fs.writeFileSync(path.join(ws, "card.toml"), problemToml(meta));
fs.writeFileSync(path.join(ws, "card.json"), problemJson(meta));
const others = fs
.readdirSync(problemsRoot, { withFileTypes: true })
.filter((e) => e.isDirectory() && e.name !== slug);
Expand Down
25 changes: 19 additions & 6 deletions packages/extension/test/problems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,31 @@ describe("problemsDir / problemDir", () => {
});

describe("createProblem", () => {
it("writes problem.toml + .json + entities/ and sets active", () => {
it("writes card.toml + card.json + entities/ and sets active (W2.1: problem.toml is the ProblemSpec)", () => {
const meta = createProblem("X gate on Q1");
expect(meta.slug).toBe("x-gate-on-q1");
expect(meta.status).toBe("designing");
const dir = problemDir("x-gate-on-q1");
expect(fs.existsSync(path.join(dir, "problem.toml"))).toBe(true);
expect(fs.existsSync(path.join(dir, "problem.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "card.toml"))).toBe(true);
expect(fs.existsSync(path.join(dir, "card.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "entities"))).toBe(true);
// legacy problem.* must NOT exist — that basename is now the ProblemSpec
expect(fs.existsSync(path.join(dir, "problem.toml"))).toBe(false);
expect(fs.existsSync(path.join(dir, "problem.json"))).toBe(false);
expect(readActiveSlug()).toBe("x-gate-on-q1");
const doc = parse(fs.readFileSync(path.join(dir, "problem.toml"), "utf8")) as any;
const doc = parse(fs.readFileSync(path.join(dir, "card.toml"), "utf8")) as any;
expect(doc.problem.name).toBe("X gate on Q1");
});
it("reads legacy problem.json when card.json absent (backward compat)", () => {
const dir = problemDir("legacy-read");
fs.mkdirSync(dir, { recursive: true });
// Simulate old workspace with problem.json only
fs.writeFileSync(path.join(dir, "problem.json"), JSON.stringify({ name: "old", slug: "legacy-read", created: new Date().toISOString(), status: "designing" }));
fs.writeFileSync(path.join(dir, "problem.toml"), `[problem]\nname = "old"\nslug = "legacy-read"\ncreated = "2026-01-01T00:00:00Z"\nstatus = "designing"\nrecorded = "2026-01-01T00:00:00Z"\n`);
setActiveSlug("legacy-read");
const opened = openProblem("legacy-read");
expect(opened?.slug).toBe("legacy-read");
});
it("auto-suffixes a colliding slug", () => {
createProblem("X gate");
const second = createProblem("X gate");
Expand Down Expand Up @@ -229,8 +242,8 @@ describe("migrateLegacyEntities (injectable roots — env-skip lives at the call
expect(fs.existsSync(path.join(ws, "score_manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(ws, "interview_state.json"))).toBe(true);
expect(fs.existsSync(path.join(ws, "usage.jsonl"))).toBe(true);
// synthesized archived meta + active set (no other problem)
const meta = JSON.parse(fs.readFileSync(path.join(ws, "problem.json"), "utf8"));
// synthesized archived meta + active set (no other problem) — W2.1: card.* is the card
const meta = JSON.parse(fs.readFileSync(path.join(ws, "card.json"), "utf8"));
expect(meta.status).toBe("archived");
expect(fs.readFileSync(path.join(root, "active"), "utf8").trim()).toBe(dirs[0]);
fs.rmSync(legacy, { recursive: true, force: true });
Expand Down
14 changes: 12 additions & 2 deletions packages/schema/julia/validate.jl
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,18 @@ kind_for_filename(path) = begin
b == "run.toml" ? "run" :
b == "result.toml" ? "result" :
b == "lab.toml" ? "lab" :
b == "problem.toml" ? "problemspec" :
b == "FINISHED" ? "finished" : nothing
b == "FINISHED" ? "finished" :
b == "card.toml" ? nothing :
b == "problem.toml" ? begin
# Table-sniff: legacy workspace cards share the basename but carry `[problem]`/`name =`.
# Return nothing (no schema) for a card so the CLI prompts for --schema instead of mis-validating.
try
raw = read(path, String)[1:min(4096, end)]
occursin(r"^\[problem\]"m, raw) && occursin(r"\bname\s*=\s*\"", raw) ? nothing : "problemspec"
catch
"problemspec"
end
end : nothing
end

schema_path(kind) = joinpath(SCHEMA_DIR, "$(kind).schema.json")
Expand Down
25 changes: 23 additions & 2 deletions packages/schema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import planSchema from "../schemas/plan.schema.json" with { type: "json" };
// "exports" map, so a subpath import would work, but the root export is the
// established, documented seam every other consumer uses — see `validate` below).
export { structureHash, problemHash, canonicalJson, fullDict, structureFields, sha256hex, designHash, planHash } from "./hashing.js";
export { projectToProblemSpec, isSpecExpressible } from "./project.js";
export type { FormulationEntityLike, CompositeSystemLike, ProjectionResult } from "./project.js";

// ajv-formats ships a CJS default export; under NodeNext the default import can
// bind the module namespace rather than the callable, so normalize defensively.
Expand Down Expand Up @@ -101,14 +103,33 @@ export interface Validation {
/** Resolve a schema kind from a file's basename, for the fixed-filename artifacts
* (run.toml, result.toml, lab.toml, FINISHED). Returns undefined for files
* with no canonical name (SolveSpec, catalog-entry) — those need an explicit
* --schema. The amico-validate CLI uses this for file-role resolution. */
* --schema. The amico-validate CLI uses this for file-role resolution.
*
* `problem.toml` is the ProblemSpec control artifact (schema_version=1, kind=control).
* The workspace *card* (`~/.amico/problems/<slug>/card.toml`, formerly `problem.toml`)
* shares the basename on older workspaces — to avoid misfiring, this function
* table-sniffs when the file exists: a file whose first 4 KiB contains a `[problem]`
* table with a `name =`/`slug =` key is the card, not a ProblemSpec, and returns
* undefined so the caller prompts for --schema instead of mis-validating. */
export function kindForFilename(filePath: string): SchemaKind | undefined {
const base = filePath.replace(/^.*[\\/]/, "");
if (base === "run.toml") return "run";
if (base === "result.toml") return "result";
if (base === "lab.toml") return "lab";
if (base === "FINISHED") return "finished";
if (base === "problem.toml") return "problemspec";
if (base === "problem.toml") {
// Table-sniff to disambiguate legacy workspace cards (W2.1).
// A card's TOML starts with `[problem]` and carries `name`/`slug`; a ProblemSpec
// starts with `schema_version = 1` / `kind = "control"` and a `[system]` table.
try {
const raw = readFileSync(filePath, "utf8").slice(0, 4096);
if (/^\[problem\]/m.test(raw) && /\bname\s*=\s*"/m.test(raw)) return undefined;
} catch {
// file absent / unreadable — fall through to the basename verdict
}
return "problemspec";
}
if (base === "card.toml") return undefined; // workspace card — no schema
return undefined;
}

Expand Down
Loading
Loading