diff --git a/CONTEXT.md b/CONTEXT.md index 549f45b6..824c741b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -29,8 +29,11 @@ _Avoid_: User, tenant, owner (interchangeably) ### Work organization **Project**: -The unit of work organization — a directory registered with amicode where files, solves, and Sessions live. The canonical default Project is `~/armonia/` (the armonia workspace created by bootstrap-armonia.sh); the agent session cwd resolves there unless overridden. Every Session belongs to exactly one Project. -_Avoid_: Workspace, folder (as a concept name), repo +The unit of work organization — a directory registered with amicode where files, solves, and Sessions live. Every Session belongs to exactly one Project. Two flavors: +- **Research Project** — identified by a `research-project.toml` manifest at its root. Prescribed layout (`scripts/`, `data/`, `paper/`, `ledger/`, `reports/`, `config/`, `skills/`) and a linear lifecycle (proposing → designing → running → analyzing → writing → complete). Self-contained: all data, campaign ledgers, and project-specific skills live inside the directory. Created via `amico project create`. +- **Dev Project** — the existing git-repo model; any registered directory without `research-project.toml`. Canonical default: `~/armonia/`. + +_Avoid_: Workspace, folder (as a concept name), repo, Study (rejected alternative — researchers think in "projects") **Session**: One agent conversation, bound to exactly one Project at creation and never re-parented. Sessions are children of a Project — surfaced nested under their Project, never as a global flat list. @@ -68,8 +71,8 @@ The development mode: issue DAG → TDD slices → CI/review → landed delta _Avoid_: autobuild ("build" already means CI to everyone) **Campaign**: -One bounded run of either autonomous mode, with a ledger and a closing artifact — the umbrella word for what a director executes. Copilot sessions are not campaigns; campaign-internal state (receipts, dispatch logs, scratch) crosses a campaign boundary only by distilling into issues, vault cards, or the artifact banks. -_Avoid_: session (a copilot session is never a campaign) +One bounded run of either autonomous mode, with a ledger and a closing artifact — the umbrella word for what a director executes. Copilot sessions are not campaigns; campaign-internal state (receipts, dispatch logs, scratch) crosses a campaign boundary only by distilling into issues, vault cards, or the artifact banks. Within a Research Project, campaign ledgers live at `ledger/campaigns/campaign--.md`; outside a project, they live in the personal vault's `sessions/` directory. +_Avoid_: session (a copilot session is never a campaign; a campaign ledger is never a session ledger) **Gate pack**: The typed set of gates + phase templates an autonomous mode binds — the entire mode-specific part of the loop, held as committed data rather than prose, so the same director core runs any pack. @@ -118,6 +121,10 @@ _Avoid_: Dashboard (as the surface name), widget panel A sandboxed ES-module card rendered in an iframe within Home. Authored by the agent (`amicode_author_widget` tool) or shipped as a builtin. Communicates with the host via the bridge protocol (postMessage). Two size classes: hero (full panel width) and tile (half-width, 2-across). Each has a TOML manifest, a JS module, and optional config fields. _Avoid_: Card (ambiguous — the UI has many cards), tile (as the concept name — tile is a size class) +**Sidebar**: +The webview in the VS Code activity bar container, showing project navigation and system status. Contains action buttons (open chat, create project), a session-aware unified project tree (Research Projects with lifecycle metadata expanding into file trees; Dev Projects as plain expandable folders), and a collapsible fleet section (deferred). The sidebar is navigation chrome — it follows the active session's project binding but never drives session switching. Destinations open in the editor area. +_Avoid_: Explorer (VS Code's native file explorer is separate), Panel (the in-app dismissible drawer is a different concept) + ### Orthogonal axes **Domain Pack**: diff --git a/docs/adr/0012-research-projects.md b/docs/adr/0012-research-projects.md new file mode 100644 index 00000000..1ddb60ed --- /dev/null +++ b/docs/adr/0012-research-projects.md @@ -0,0 +1,54 @@ +# ADR 0012: Research Projects as a structured flavor of Project + +**Status:** proposed + +**Date:** 2026-08-31 + +## Context + +"Project" in Amicode means "a directory registered with amicode" — a workstation root (canonical default: `~/armonia/`). Researchers using Amicode organically create git repos for investigations (`diraq-esr-demo`, `fluxonium-demo`) but these lack prescribed structure, manifests, or autoresearch integration. Problem workspaces (`~/.amico/problems/`) are domain-pack-specific (pulse design only) and nearly unused in practice; the autoresearch loop operates through vault session ledgers, completely disconnected from any project structure. The project selector UI (`PromptProjectSelector`, 595 lines) is fully implemented but hidden in the Amicode webview behind an `!inAmicode()` gate. External users have no concept of armonia. + +## Decision + +Introduce "Research Project" as a structured flavor of the existing Project concept. A Research Project is a self-contained, git-backed directory identified by `research-project.toml`, with a prescribed layout (`scripts/`, `data/`, `paper/`, `ledger/`, `reports/`, `config/`, `skills/`) and a linear lifecycle (proposing → designing → running → analyzing → writing → complete). The existing git-repo model continues as "Dev Project." + +### Alternatives considered + +- **(B) Full redefine** — make "Project" mean only the research entity; rename the old concept to "Workspace." Rejected: breaks the glossary across CONTEXT.md, 4+ ADRs, the entire opencode session model, and the app UI. Migration cost outweighs the cleaner naming. +- **(C) New "Study" entity** — add a separate entity alongside the existing Project. Rejected: researchers think in "projects," not "studies." Two overlapping concepts creates permanent confusion. + +### Key structural decisions + +- **Workspace-backed selector.** The project selector is un-gated in the Amicode webview, shows workspace folders typed by `research-project.toml` presence, and grouped into Research and Development sections. Adding a project adds it to the VS Code workspace. Add-only; removal uses VS Code's native UI. +- **Project-specific skills.** Each research project can ship a `skills/` directory. On workspace folder changes, the extension scans for `research-project.toml` and adds each project's `skills/` as a skill source, triggering a `prepareOpencodeProject()` re-run. Merge priority: project > custom > workspace > shipped. +- **Gated paper-writing.** A shipped `paper-writer` skill enforces an outline-first gate: the agent refuses to write paper content until `paper/outline.md` is finalized by the user. Formatting proceeds section-by-section with user approval. Content provenance is maintained (every paragraph traces to an outline bullet). The agent does not generate novel claims, interpret results, or write the abstract. +- **Campaign ledgers.** Autoresearch execution records use `ledger/campaigns/` within the project (not vault `sessions/`), avoiding collision with the Session glossary term (one agent conversation). +- **Armonia untouched for now.** Armonia's current CONTEXT.md definition, ArmoniaService, and sidebar panel remain as-is. The future vision is to reposition Armonia as a multi-collaborator coordination layer, but that is a separate decision gated on this one succeeding. + +## Consequences + +### What changes + +- CONTEXT.md gains Research Project and Dev Project as flavor definitions under Project +- `ProjectTable` gains `research-project.toml` awareness for type detection +- `PromptProjectSelector` is un-gated in the Amicode webview, enriched with type grouping and research metadata +- Skill merge chain extended with project source: project > custom > workspace > shipped +- `prepareOpencodeProject()` triggered on workspace folder changes (project add/remove) +- Autoresearch director reads the research project directory as its context source +- Campaign ledgers at `ledger/campaigns/`, not vault `sessions/` +- Problem workspaces continue for the copilot interview but are not part of the research project layout +- Multi-root workspaces supported; session binding is explicit via the selector chip, immutable after creation +- Paper writing is gated behind user-authored, user-finalized outlines; the agent is a typesetter, not a co-author + +### Cross-repo impact + +Skill auto-loading and session binding require changes to the opencode engine (vendored fork), which ships on its own release cadence. These are cross-repo changes that need coordinated PRs. + +### Risks accepted + +- **Intermediate skill visibility:** Until per-session skill scoping is implemented, all project skills from all open projects are visible to all sessions regardless of binding. This is cosmetic — it doesn't cause incorrect behavior, but it means session binding is weaker than it appears. +- **Soft behavioral gates:** The paper-writer skill's content-provenance and forbidden-actions rules are LLM instructions, not code. They work as well as the model's instruction-following allows. + +### Reversibility + +If Research Projects don't get adoption, the rollback is low-cost: `research-project.toml` is additive (ignore it and the directory is a regular git repo), the selector enrichment is behind the existing `!inAmicode()` gate (re-enable it), and the skill auto-loading is a no-op when no projects have `skills/`. The CONTEXT.md glossary additions are the hardest to undo but are also the lowest-risk (they extend, not replace). Armonia's current definition is untouched by this decision. diff --git a/packages/amico-run/src/project.ts b/packages/amico-run/src/project.ts new file mode 100644 index 00000000..c03a08ef --- /dev/null +++ b/packages/amico-run/src/project.ts @@ -0,0 +1,416 @@ +// project.ts — pure logic for research project entities (issue #665). +// +// Schema definition, validation, slug generation, scaffolding, and template +// rendering. NO filesystem I/O — that lives in project_verb.ts. This module +// is the unit-testable core. + +// ── schema types ──────────────────────────────────────────────────────────── + +export const PROJECT_STATUSES = [ + "proposing", + "designing", + "running", + "analyzing", + "writing", + "complete", +] as const; + +export type ProjectStatus = (typeof PROJECT_STATUSES)[number]; + +export interface ProjectToml { + schema_version: number; + name: string; + slug: string; + question: string; + status: ProjectStatus; + created: string; // YYYY-MM-DD + tags?: string[]; + authors?: { + lead?: string; + collaborators?: string[]; + }; + venue?: { + name?: string; + deadline?: string; + }; + domain_pack?: { + name?: string; + }; + links?: { + related_projects?: string[]; + doi?: string; + }; +} + +// ── validation ────────────────────────────────────────────────────────────── + +export type ValidationResult = + | { ok: true } + | { ok: false; errors: string[] }; + +const REQUIRED_FIELDS: (keyof ProjectToml)[] = [ + "schema_version", + "name", + "slug", + "question", + "status", + "created", +]; + +export function validateProjectToml(data: unknown): ValidationResult { + if (typeof data !== "object" || data === null) { + return { ok: false, errors: ["research-project.toml must be a TOML table (object)"] }; + } + + const obj = data as Record; + const errors: string[] = []; + + for (const field of REQUIRED_FIELDS) { + if (obj[field] === undefined || obj[field] === null) { + errors.push(`missing required field: ${field}`); + } + } + + if (typeof obj.schema_version !== "undefined" && typeof obj.schema_version !== "number") { + errors.push("schema_version must be an integer"); + } + + if (typeof obj.status === "string" && !(PROJECT_STATUSES as readonly string[]).includes(obj.status)) { + errors.push( + `invalid status "${obj.status}" — must be one of: ${PROJECT_STATUSES.join(", ")}`, + ); + } + + return errors.length === 0 ? { ok: true } : { ok: false, errors }; +} + +// ── slug generation ───────────────────────────────────────────────────────── + +export function nameToSlug(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-+|-+$/g, ""); +} + +// ── scaffolding data ──────────────────────────────────────────────────────── + +/** The prescribed directory layout for a Research Project (PRD #663). */ +export const SCAFFOLD_DIRS = [ + "scripts", + "scripts/experiment", + "scripts/analysis", + "scripts/testbed", + "data/raw", + "data/processed", + "data/plots", + "paper/figures", + "paper/supplementary", + "ledger/hypotheses", + "ledger/observations", + "ledger/literature", + "ledger/campaigns", + "reports/weekly", + "reports/presentations", + "reports/milestones", + "config", + "skills", +] as const; + +// ── TOML rendering ────────────────────────────────────────────────────────── + +/** Render a ProjectToml to a TOML string. We hand-render to control section + * ordering and comments — smol-toml's stringify would work but produces less + * readable output for a manifest the user will edit. */ +export function renderProjectToml(p: ProjectToml): string { + const lines: string[] = []; + lines.push(`schema_version = ${p.schema_version}`); + lines.push(`name = ${q(p.name)}`); + lines.push(`slug = ${q(p.slug)}`); + lines.push(`question = ${q(p.question)}`); + lines.push(`status = ${q(p.status)}`); + lines.push(`created = ${q(p.created)}`); + + if (p.tags && p.tags.length > 0) { + lines.push(`tags = [${p.tags.map(q).join(", ")}]`); + } + + if (p.authors) { + lines.push(""); + lines.push("[authors]"); + if (p.authors.lead) lines.push(`lead = ${q(p.authors.lead)}`); + if (p.authors.collaborators && p.authors.collaborators.length > 0) { + lines.push(`collaborators = [${p.authors.collaborators.map(q).join(", ")}]`); + } + } + + if (p.venue) { + lines.push(""); + lines.push("[venue]"); + if (p.venue.name) lines.push(`name = ${q(p.venue.name)}`); + if (p.venue.deadline) lines.push(`deadline = ${q(p.venue.deadline)}`); + } + + if (p.domain_pack) { + lines.push(""); + lines.push("[domain_pack]"); + if (p.domain_pack.name) lines.push(`name = ${q(p.domain_pack.name)}`); + } + + if (p.links) { + lines.push(""); + lines.push("[links]"); + if (p.links.related_projects && p.links.related_projects.length > 0) { + lines.push(`related_projects = [${p.links.related_projects.map(q).join(", ")}]`); + } + if (p.links.doi) lines.push(`doi = ${q(p.links.doi)}`); + } + + lines.push(""); // trailing newline + return lines.join("\n"); +} + +function q(s: string): string { + return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +// ── template content ──────────────────────────────────────────────────────── + +/** Venue → LaTeX document class mapping. */ +export function venueToDocumentClass(venue?: string): "revtex" | "minimal" { + if (!venue) return "minimal"; + const v = venue.toLowerCase(); + if (v.includes("physical review letters") || v === "prl") return "revtex"; + if (v.includes("physical review x") || v === "prx") return "revtex"; + return "minimal"; +} + +export function renderMainTex(name: string, venue?: string): string { + const cls = venueToDocumentClass(venue); + if (cls === "revtex") { + return `\\documentclass[aps,prl,twocolumn,superscriptaddress]{revtex4-2} +\\usepackage{amsmath,amssymb,graphicx,hyperref} + +\\begin{document} + +\\title{${texEscape(name)}} +\\author{TODO} +\\affiliation{TODO} + +\\begin{abstract} +TODO +\\end{abstract} + +\\maketitle + +\\section{Introduction} +\\label{sec:intro} + +% TODO + +\\bibliography{references} + +\\end{document} +`; + } + return `\\documentclass[11pt]{article} +\\usepackage{amsmath,amssymb,graphicx,hyperref} + +\\title{${texEscape(name)}} +\\author{TODO} +\\date{\\today} + +\\begin{document} +\\maketitle + +\\begin{abstract} +TODO +\\end{abstract} + +\\section{Introduction} +\\label{sec:intro} + +% TODO + +\\bibliographystyle{plain} +\\bibliography{references} + +\\end{document} +`; +} + +function texEscape(s: string): string { + return s.replace(/[&%$#_{}~^\\]/g, (c) => `\\${c}`); +} + +export function renderOutlineMd(name: string, question: string, venue?: string): string { + const cls = venueToDocumentClass(venue); + const header = `--- +status: draft +last_reviewed: +sections_approved: [] +--- + +# ${name} + +**Research question:** ${question} + +`; + if (cls === "revtex") { + return `${header}## Outline (PRL/PRX compressed format) + +### Abstract +- [ ] TODO + +### Introduction +- [ ] TODO + +### Results +- [ ] TODO + +### Discussion +- [ ] TODO + +### Methods +- [ ] TODO + +### Supplementary +- [ ] TODO +`; + } + return `${header}## Outline + +### Abstract +- [ ] TODO + +### Introduction +- [ ] TODO + +### Methods +- [ ] TODO + +### Results +- [ ] TODO + +### Discussion +- [ ] TODO + +### Conclusion +- [ ] TODO +`; +} + +export function renderReadme(name: string, question: string): string { + return `# ${name} + +${question} + +## Layout + +- \`scripts/\` — \`experiment/\` for solves, \`analysis/\` for post-processing, \`testbed/\` for simulated environments and hardware interfaces +- \`data/\` — raw, processed, and plot outputs +- \`paper/\` — manuscript (\`outline.md\` → \`main.tex\`) +- \`ledger/\` — hypotheses, observations, literature, and campaign logs +- \`reports/\` — weekly updates, presentations, and milestone reports +- \`config/\` — system and lab configuration +- \`skills/\` — project-specific Amico skills +`; +} + +export const WEEKLY_REPORT_TEMPLATE = `--- +date: YYYY-MM-DD +author: +period: YYYY-MM-DD to YYYY-MM-DD +status: draft +--- + +# Weekly Update — [period] + +## Progress +- + +## Key Results +- + +## Blockers +- + +## Next Week +- + +## Notes +`; + +export const ROOT_GITIGNORE = `# Data artifacts (large binary files) +data/raw/**/*.jld2 +data/raw/**/*.hdf5 +data/raw/**/*.h5 + +# Python +*.pyc +__pycache__/ + +# OS +.DS_Store +`; + +export const PAPER_GITIGNORE = `# LaTeX build artifacts +*.aux +*.bbl +*.blg +*.log +*.out +*.toc +*.synctex.gz +*.fdb_latexmk +*.fls +*.pdf +`; + +export const PAPER_LATEXMKRC = `$pdflatex = 'pdflatex -interaction=nonstopmode %O %S'; +`; + +export const SCRIPTS_README = `# Scripts + +- \`experiment/\` — optimization solves, gate synthesis +- \`analysis/\` — post-processing, plotting, comparison +- \`testbed/\` — simulated gym environment, hardware-interfacing code +`; + +/** Build the full scaffold manifest: relative paths and their contents. Files + * with `null` content are directories (created with mkdirSync). */ +export function scaffoldManifest( + p: ProjectToml, +): { path: string; content: string | null }[] { + const items: { path: string; content: string | null }[] = []; + + // directories + for (const dir of SCAFFOLD_DIRS) { + items.push({ path: dir, content: null }); + } + + // root files + items.push({ path: "research-project.toml", content: renderProjectToml(p) }); + items.push({ path: "README.md", content: renderReadme(p.name, p.question) }); + items.push({ path: ".gitignore", content: ROOT_GITIGNORE }); + + // scripts/ + items.push({ path: "scripts/README.md", content: SCRIPTS_README }); + + // paper/ + items.push({ path: "paper/outline.md", content: renderOutlineMd(p.name, p.question, p.venue?.name) }); + items.push({ path: "paper/main.tex", content: renderMainTex(p.name, p.venue?.name) }); + items.push({ path: "paper/references.bib", content: "" }); + items.push({ path: "paper/latexmkrc", content: PAPER_LATEXMKRC }); + items.push({ path: "paper/.gitignore", content: PAPER_GITIGNORE }); + + // config/ stubs + items.push({ path: "config/system.toml", content: "# System configuration\n" }); + items.push({ path: "config/lab.toml", content: "# Lab configuration\n" }); + + // reports/ + items.push({ path: "reports/weekly/template.md", content: WEEKLY_REPORT_TEMPLATE }); + + return items; +} diff --git a/packages/amico-run/src/project_verb.ts b/packages/amico-run/src/project_verb.ts new file mode 100644 index 00000000..c75802b6 --- /dev/null +++ b/packages/amico-run/src/project_verb.ts @@ -0,0 +1,263 @@ +// project_verb.ts — CLI wrapper for `amico project create` and `amico project import`. +// Pure logic lives in project.ts; this module handles filesystem I/O, git init, +// flag parsing, and the verb dispatch. Part of #665. +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { homedir } from "node:os"; +import { parse as parseToml } from "smol-toml"; +import { + nameToSlug, + scaffoldManifest, + validateProjectToml, + type ProjectToml, +} from "./project.js"; +import type { VerbResult } from "./verbs.js"; + +function flagValue(argv: string[], name: string): string | undefined { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; +} + +/** Extract the first positional argument (not a --flag or a flag's value). */ +function positionalArg(argv: string[]): string | undefined { + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith("--")) { + i++; // skip the flag's value + continue; + } + return argv[i]; + } + return undefined; +} + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +// ── create ────────────────────────────────────────────────────────────────── + +export function projectCreate(argv: string[]): VerbResult { + const fail = (error: string): VerbResult => ({ + json: { verb: "project", subcommand: "create", error }, + code: 64, + }); + + // The project name is the first positional argument (non-flag) + const name = positionalArg(argv); + if (!name) return fail("project name is required: amico project create "); + + const slug = nameToSlug(name); + const defaultPath = join(homedir(), "projects", slug); + const projectDir = resolve(flagValue(argv, "--path") ?? defaultPath); + + // Idempotent: if research-project.toml already exists, validate and return + const tomlPath = join(projectDir, "research-project.toml"); + if (existsSync(tomlPath)) { + try { + const existing = parseToml(readFileSync(tomlPath, "utf8")) as unknown as ProjectToml; + const v = validateProjectToml(existing); + if (v.ok) { + return { + json: { + verb: "project", + subcommand: "create", + created: false, + idempotent: true, + path: projectDir, + slug: existing.slug, + }, + code: 0, + }; + } + } catch { + // If the existing TOML is invalid, fall through and overwrite + } + } + + const domain = flagValue(argv, "--domain"); + const venue = flagValue(argv, "--venue"); + const deadline = flagValue(argv, "--deadline"); + + const project: ProjectToml = { + schema_version: 1, + name, + slug, + question: flagValue(argv, "--question") ?? "TODO", + status: "proposing", + created: today(), + tags: [], + authors: { lead: flagValue(argv, "--author") }, + ...(domain ? { domain_pack: { name: domain } } : {}), + ...(venue ? { venue: { name: venue, ...(deadline ? { deadline } : {}) } } : {}), + }; + + // Create directory and scaffold + try { + mkdirSync(projectDir, { recursive: true }); + } catch (e) { + return fail(`failed to create directory: ${e instanceof Error ? e.message : String(e)}`); + } + + const manifest = scaffoldManifest(project); + try { + for (const item of manifest) { + const fullPath = join(projectDir, item.path); + if (item.content === null) { + mkdirSync(fullPath, { recursive: true }); + } else { + // Don't overwrite existing files (idempotent) + if (!existsSync(fullPath)) { + mkdirSync(join(fullPath, ".."), { recursive: true }); + writeFileSync(fullPath, item.content); + } + } + } + } catch (e) { + return fail(`failed to scaffold: ${e instanceof Error ? e.message : String(e)}`); + } + + // git init + initial commit + try { + if (!existsSync(join(projectDir, ".git"))) { + execFileSync("git", ["init"], { cwd: projectDir, stdio: "ignore" }); + execFileSync("git", ["add", "."], { cwd: projectDir, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", `init: scaffold research project "${name}"`], { + cwd: projectDir, + stdio: "ignore", + }); + } + } catch (e) { + // git failure is a warning, not a hard error — the scaffold is on disk + return { + json: { + verb: "project", + subcommand: "create", + created: true, + path: projectDir, + slug, + warning: `git init failed: ${e instanceof Error ? e.message : String(e)}`, + }, + code: 0, + }; + } + + return { + json: { + verb: "project", + subcommand: "create", + created: true, + path: projectDir, + slug, + }, + code: 0, + }; +} + +// ── import ────────────────────────────────────────────────────────────────── + +export function projectImport(argv: string[]): VerbResult { + const fail = (error: string): VerbResult => ({ + json: { verb: "project", subcommand: "import", error }, + code: 64, + }); + + // The directory is the first positional argument, or "." by default + const dir = resolve(positionalArg(argv) ?? "."); + + if (!existsSync(dir)) return fail(`directory not found: ${dir}`); + + const tomlPath = join(dir, "research-project.toml"); + + // Idempotent: if research-project.toml already exists, validate and return + if (existsSync(tomlPath)) { + try { + const existing = parseToml(readFileSync(tomlPath, "utf8")) as unknown as ProjectToml; + const v = validateProjectToml(existing); + if (v.ok) { + return { + json: { + verb: "project", + subcommand: "import", + imported: false, + idempotent: true, + path: dir, + slug: existing.slug, + }, + code: 0, + }; + } + } catch { + // Invalid TOML; fall through + } + } + + // For import, infer name from directory basename + const basename = dir.split("/").pop() || "unnamed"; + const name = flagValue(argv, "--name") ?? basename; + const slug = nameToSlug(name); + const question = flagValue(argv, "--question") ?? "TODO"; + const status = (flagValue(argv, "--status") ?? "running") as ProjectToml["status"]; + + const project: ProjectToml = { + schema_version: 1, + name, + slug, + question, + status, + created: today(), + }; + + // Scaffold missing directories without overwriting existing files + const manifest = scaffoldManifest(project); + const scaffolded: string[] = []; + + try { + for (const item of manifest) { + const fullPath = join(dir, item.path); + if (item.content === null) { + if (!existsSync(fullPath)) { + mkdirSync(fullPath, { recursive: true }); + scaffolded.push(item.path + "/"); + } + } else { + if (!existsSync(fullPath)) { + mkdirSync(join(fullPath, ".."), { recursive: true }); + writeFileSync(fullPath, item.content); + scaffolded.push(item.path); + } + } + } + } catch (e) { + return fail(`failed to scaffold: ${e instanceof Error ? e.message : String(e)}`); + } + + return { + json: { + verb: "project", + subcommand: "import", + imported: true, + path: dir, + slug, + scaffolded, + }, + code: 0, + }; +} + +// ── dispatch ──────────────────────────────────────────────────────────────── + +export function projectVerb(argv: string[]): VerbResult { + const sub = argv[0]; + const rest = argv.slice(1); + if (sub === "create") return projectCreate(rest); + if (sub === "import") return projectImport(rest); + return { + json: { + verb: "project", + error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, + usage: "amico project create [--path ] [--domain ] [--venue ] [--deadline ] | amico project import [] [--name ] [--question ] [--status ]", + }, + code: 64, + }; +} diff --git a/packages/amico-run/src/verbs.ts b/packages/amico-run/src/verbs.ts index 28f233cd..a8e50dfa 100644 --- a/packages/amico-run/src/verbs.ts +++ b/packages/amico-run/src/verbs.ts @@ -24,6 +24,7 @@ import { fleetVerb } from "./fleet_verb.js"; import { specVerb } from "./spec_verb.js"; import { planVerb } from "./plan_verb.js"; import { handoffVerb } from "./handoff_verb.js"; +import { projectVerb } from "./project_verb.js"; export interface VerbResult { json: unknown; // structured result (stdout as JSON for the CLI; tool content for MCP) @@ -201,4 +202,14 @@ const papers: Verb = { run: papersVerb, }; -export const SPINE_VERBS: Verb[] = [catalog, vault, device, note, ledger, profile, fleet, spec, plan, handoff, papers]; +// project — the research-project entity: create (scaffold + git init) and +// import (non-destructive scaffold over an existing directory). Part of #665. +const project: Verb = { + name: "project", + summary: "create a scaffolded research project / import an existing directory as a research project", + generalizes: "the amicode research-project entity lifecycle (PRD #663)", + slice: "research projects (#665)", + run: projectVerb, +}; + +export const SPINE_VERBS: Verb[] = [catalog, vault, device, note, ledger, profile, fleet, spec, plan, handoff, papers, project]; diff --git a/packages/amico-run/test/project_verb.test.ts b/packages/amico-run/test/project_verb.test.ts new file mode 100644 index 00000000..4cd5d622 --- /dev/null +++ b/packages/amico-run/test/project_verb.test.ts @@ -0,0 +1,423 @@ +// `amico project` — research project entity: schema validation, scaffolding, +// and CLI verbs (create / import). Pure logic in project.ts; verb I/O in +// project_verb.ts. Part of #665. +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + validateProjectToml, + nameToSlug, + scaffoldManifest, + venueToDocumentClass, + renderMainTex, + renderOutlineMd, + renderProjectToml, + SCAFFOLD_DIRS, + ROOT_GITIGNORE, + PAPER_GITIGNORE, + PAPER_LATEXMKRC, + type ProjectToml, +} from "../src/project.js"; + +// ── pure logic: schema validation ────────────────────────────────────────── + +describe("validateProjectToml", () => { + const valid: ProjectToml = { + schema_version: 1, + name: "Diraq ESR X-gate study", + slug: "diraq-esr-x-gate-study", + question: "Can we achieve F > 0.999 for a 29Si-robust X gate?", + status: "proposing", + created: "2026-08-31", + tags: ["transmon", "esr"], + authors: { lead: "JJ Lee" }, + domain_pack: { name: "quantum-control" }, + }; + + it("accepts a valid .amico with all required fields", () => { + const result = validateProjectToml(valid); + expect(result.ok).toBe(true); + }); + + it("accepts a minimal .amico (required fields only)", () => { + const minimal: ProjectToml = { + schema_version: 1, + name: "My Project", + slug: "my-project", + question: "What happens?", + status: "proposing", + created: "2026-08-31", + }; + const result = validateProjectToml(minimal); + expect(result.ok).toBe(true); + }); + + it("rejects missing required field: name", () => { + const bad = { ...valid, name: undefined } as unknown as ProjectToml; + const result = validateProjectToml(bad); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors.some((e: string) => e.includes("name"))).toBe(true); + }); + + it("rejects missing required field: schema_version", () => { + const bad = { ...valid, schema_version: undefined } as unknown as ProjectToml; + const result = validateProjectToml(bad); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors.some((e: string) => e.includes("schema_version"))).toBe(true); + }); + + it("rejects invalid status enum value", () => { + const bad = { ...valid, status: "thinking" } as unknown as ProjectToml; + const result = validateProjectToml(bad); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors.some((e: string) => e.includes("status"))).toBe(true); + }); + + it("accepts all valid status values", () => { + for (const s of ["proposing", "designing", "running", "analyzing", "writing", "complete"]) { + const result = validateProjectToml({ ...valid, status: s as ProjectToml["status"] }); + expect(result.ok).toBe(true); + } + }); + + it("tolerates unknown extra fields (forward-compatible)", () => { + const extended = { ...valid, future_field: "surprise" } as unknown as ProjectToml; + const result = validateProjectToml(extended); + expect(result.ok).toBe(true); + }); +}); + +// ── pure logic: slug generation ──────────────────────────────────────────── + +describe("nameToSlug", () => { + it("converts a project name to kebab-case slug", () => { + expect(nameToSlug("Diraq ESR X-gate study")).toBe("diraq-esr-x-gate-study"); + }); + + it("strips non-alphanumeric characters", () => { + expect(nameToSlug("My Cool Project!!!")).toBe("my-cool-project"); + }); + + it("collapses multiple hyphens", () => { + expect(nameToSlug("a -- b --- c")).toBe("a-b-c"); + }); + + it("trims leading/trailing hyphens", () => { + expect(nameToSlug(" --hello-- ")).toBe("hello"); + }); +}); + +// ── pure logic: scaffold manifest ────────────────────────────────────────── + +describe("scaffoldManifest", () => { + const project: ProjectToml = { + schema_version: 1, + name: "Test Project", + slug: "test-project", + question: "Does it work?", + status: "proposing", + created: "2026-08-31", + }; + + it("includes all prescribed directories from the PRD", () => { + const manifest = scaffoldManifest(project); + const dirs = manifest.filter((m) => m.content === null).map((m) => m.path); + for (const expected of SCAFFOLD_DIRS) { + expect(dirs).toContain(expected); + } + }); + + it("produces .amico, README.md, .gitignore at root", () => { + const manifest = scaffoldManifest(project); + const files = manifest.filter((m) => m.content !== null).map((m) => m.path); + expect(files).toContain("research-project.toml"); + expect(files).toContain("README.md"); + expect(files).toContain(".gitignore"); + }); + + it("produces paper/ files: outline.md, main.tex, references.bib, latexmkrc, .gitignore", () => { + const manifest = scaffoldManifest(project); + const files = manifest.filter((m) => m.content !== null).map((m) => m.path); + expect(files).toContain("paper/outline.md"); + expect(files).toContain("paper/main.tex"); + expect(files).toContain("paper/references.bib"); + expect(files).toContain("paper/latexmkrc"); + expect(files).toContain("paper/.gitignore"); + }); + + it("produces scripts/README.md", () => { + const manifest = scaffoldManifest(project); + const files = manifest.filter((m) => m.content !== null).map((m) => m.path); + expect(files).toContain("scripts/README.md"); + }); + + it("produces config stubs: system.toml, lab.toml", () => { + const manifest = scaffoldManifest(project); + const files = manifest.filter((m) => m.content !== null).map((m) => m.path); + expect(files).toContain("config/system.toml"); + expect(files).toContain("config/lab.toml"); + }); + + it("produces reports/ dirs and weekly template", () => { + const manifest = scaffoldManifest(project); + const dirs = manifest.filter((m) => m.content === null).map((m) => m.path); + expect(dirs).toContain("reports/weekly"); + expect(dirs).toContain("reports/presentations"); + expect(dirs).toContain("reports/milestones"); + + const files = manifest.filter((m) => m.content !== null).map((m) => m.path); + expect(files).toContain("reports/weekly/template.md"); + }); + + it("weekly template has YAML frontmatter with date and period fields", () => { + const manifest = scaffoldManifest(project); + const template = manifest.find((m) => m.path === "reports/weekly/template.md"); + expect(template).toBeDefined(); + expect(template!.content).toContain("date:"); + expect(template!.content).toContain("period:"); + expect(template!.content).toContain("## Progress"); + expect(template!.content).toContain("## Key Results"); + expect(template!.content).toContain("## Blockers"); + }); +}); + +// ── pure logic: TOML rendering round-trip ────────────────────────────────── + +describe("renderProjectToml", () => { + it("produces parseable TOML with all required fields", () => { + const project: ProjectToml = { + schema_version: 1, + name: "Test Project", + slug: "test-project", + question: "Does it work?", + status: "proposing", + created: "2026-08-31", + }; + const toml = renderProjectToml(project); + expect(toml).toContain('name = "Test Project"'); + expect(toml).toContain("schema_version = 1"); + expect(toml).toContain('status = "proposing"'); + }); +}); + +// ── pure logic: venue-aware templates ────────────────────────────────────── + +describe("venueToDocumentClass", () => { + it("maps Physical Review Letters to revtex", () => { + expect(venueToDocumentClass("Physical Review Letters")).toBe("revtex"); + }); + + it("maps Physical Review X to revtex", () => { + expect(venueToDocumentClass("Physical Review X")).toBe("revtex"); + }); + + it("maps PRL shorthand to revtex", () => { + expect(venueToDocumentClass("PRL")).toBe("revtex"); + }); + + it("maps unknown venue to minimal", () => { + expect(venueToDocumentClass("Nature Physics")).toBe("minimal"); + }); + + it("maps undefined venue to minimal", () => { + expect(venueToDocumentClass(undefined)).toBe("minimal"); + }); +}); + +describe("renderMainTex", () => { + it("uses revtex4-2 document class for PRL", () => { + const tex = renderMainTex("My Paper", "Physical Review Letters"); + expect(tex).toContain("revtex4-2"); + expect(tex).toContain("\\title{My Paper}"); + }); + + it("uses article document class for unknown venues", () => { + const tex = renderMainTex("My Paper", "Nature Physics"); + expect(tex).toContain("\\documentclass[11pt]{article}"); + expect(tex).not.toContain("revtex"); + }); + + it("uses article when no venue specified", () => { + const tex = renderMainTex("My Paper"); + expect(tex).toContain("\\documentclass[11pt]{article}"); + }); +}); + +describe("renderOutlineMd", () => { + it("includes YAML frontmatter with draft status", () => { + const md = renderOutlineMd("Test", "What?"); + expect(md).toMatch(/^---\nstatus: draft/); + expect(md).toContain("sections_approved: []"); + }); + + it("uses compressed PRL format for Physical Review Letters", () => { + const md = renderOutlineMd("Test", "What?", "Physical Review Letters"); + expect(md).toContain("PRL/PRX compressed format"); + expect(md).not.toContain("### Conclusion"); + }); + + it("uses full section format for unknown venues", () => { + const md = renderOutlineMd("Test", "What?"); + expect(md).toContain("### Conclusion"); + }); +}); + +// ── pure logic: gitignore content ────────────────────────────────────────── + +describe("gitignore content", () => { + it("root .gitignore includes jld2/hdf5/h5 patterns", () => { + expect(ROOT_GITIGNORE).toContain("*.jld2"); + expect(ROOT_GITIGNORE).toContain("*.hdf5"); + expect(ROOT_GITIGNORE).toContain("*.h5"); + expect(ROOT_GITIGNORE).toContain("__pycache__/"); + expect(ROOT_GITIGNORE).toContain(".DS_Store"); + }); + + it("paper .gitignore excludes LaTeX build artifacts", () => { + expect(PAPER_GITIGNORE).toContain("*.aux"); + expect(PAPER_GITIGNORE).toContain("*.bbl"); + expect(PAPER_GITIGNORE).toContain("*.synctex.gz"); + expect(PAPER_GITIGNORE).toContain("*.fdb_latexmk"); + expect(PAPER_GITIGNORE).toContain("*.pdf"); + }); + + it("paper/latexmkrc has the correct content", () => { + expect(PAPER_LATEXMKRC).toContain("pdflatex -interaction=nonstopmode"); + }); +}); + +// ── integration: project create verb ─────────────────────────────────────── + +import { projectCreate, projectImport } from "../src/project_verb.js"; + +describe("projectCreate", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "amico-project-test-")); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("creates a project directory with the full prescribed layout", () => { + const projectDir = join(tmpDir, "my-test-project"); + const result = projectCreate(["My Test Project", "--path", projectDir, "--question", "Does TDD work?"]); + + expect((result.json as Record).created).toBe(true); + expect(result.code).toBe(0); + + // Verify .amico exists and is valid + expect(existsSync(join(projectDir, "research-project.toml"))).toBe(true); + const tomlContent = readFileSync(join(projectDir, "research-project.toml"), "utf8"); + expect(tomlContent).toContain("My Test Project"); + expect(tomlContent).toContain("my-test-project"); + + // Verify prescribed directories + for (const dir of ["scripts", "scripts/experiment", "scripts/analysis", "scripts/testbed", "data/raw", "data/processed", + "data/plots", "paper/figures", "paper/supplementary", + "ledger/hypotheses", "ledger/observations", "ledger/literature", + "ledger/campaigns", "reports/weekly", "reports/presentations", + "reports/milestones", "config", "skills"]) { + expect(existsSync(join(projectDir, dir))).toBe(true); + } + + // Verify root files + expect(existsSync(join(projectDir, "README.md"))).toBe(true); + expect(existsSync(join(projectDir, ".gitignore"))).toBe(true); + + // Verify paper files + expect(existsSync(join(projectDir, "paper/outline.md"))).toBe(true); + expect(existsSync(join(projectDir, "paper/main.tex"))).toBe(true); + expect(existsSync(join(projectDir, "paper/references.bib"))).toBe(true); + expect(existsSync(join(projectDir, "paper/latexmkrc"))).toBe(true); + expect(existsSync(join(projectDir, "paper/.gitignore"))).toBe(true); + + // Verify git repository + expect(existsSync(join(projectDir, ".git"))).toBe(true); + }); + + it("uses REVTeX template when --venue is Physical Review Letters", () => { + const projectDir = join(tmpDir, "prl-project"); + projectCreate(["PRL Paper", "--path", projectDir, "--venue", "Physical Review Letters"]); + + const tex = readFileSync(join(projectDir, "paper/main.tex"), "utf8"); + expect(tex).toContain("revtex4-2"); + + const outline = readFileSync(join(projectDir, "paper/outline.md"), "utf8"); + expect(outline).toContain("PRL/PRX compressed format"); + }); + + it("is idempotent — re-running returns success without overwriting", () => { + const projectDir = join(tmpDir, "idempotent-project"); + const first = projectCreate(["Idempotent", "--path", projectDir]); + expect((first.json as Record).created).toBe(true); + + const second = projectCreate(["Idempotent", "--path", projectDir]); + expect(second.code).toBe(0); + expect((second.json as Record).idempotent).toBe(true); + }); + + it("returns error when no name is provided", () => { + const result = projectCreate(["--path", join(tmpDir, "bad")]); + expect(result.code).toBe(64); + expect((result.json as Record).error).toBeDefined(); + }); +}); + +// ── integration: project import verb ─────────────────────────────────────── + +describe("projectImport", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "amico-project-import-")); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("creates .amico and scaffolds missing dirs in an existing directory", () => { + const existingDir = join(tmpDir, "existing-project"); + mkdirSync(join(existingDir, "scripts"), { recursive: true }); + writeFileSync(join(existingDir, "scripts/my_solve.jl"), "# existing file"); + + const result = projectImport([existingDir, "--name", "Existing Project", "--question", "What?"]); + + expect((result.json as Record).imported).toBe(true); + expect(result.code).toBe(0); + + // .amico was created + expect(existsSync(join(existingDir, "research-project.toml"))).toBe(true); + + // Existing file was NOT overwritten + expect(readFileSync(join(existingDir, "scripts/my_solve.jl"), "utf8")).toBe("# existing file"); + + // Missing dirs were scaffolded + expect(existsSync(join(existingDir, "scripts/testbed"))).toBe(true); + expect(existsSync(join(existingDir, "data/raw"))).toBe(true); + expect(existsSync(join(existingDir, "ledger/campaigns"))).toBe(true); + expect(existsSync(join(existingDir, "paper/outline.md"))).toBe(true); + }); + + it("is idempotent — re-importing returns success without overwriting", () => { + const dir = join(tmpDir, "idempotent-import"); + mkdirSync(dir, { recursive: true }); + + const first = projectImport([dir, "--name", "Test"]); + expect((first.json as Record).imported).toBe(true); + + const second = projectImport([dir]); + expect(second.code).toBe(0); + expect((second.json as Record).idempotent).toBe(true); + }); + + it("returns error for nonexistent directory", () => { + const result = projectImport([join(tmpDir, "does-not-exist")]); + expect(result.code).toBe(64); + }); +}); diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-project-selector.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-project-selector.tsx index fae3ded5..470119c4 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/prompt-project-selector.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-project-selector.tsx @@ -16,7 +16,6 @@ import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { getProjectAvatarVariant } from "@/context/layout" import { useLanguage } from "@/context/language" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" -import { inAmicode } from "@/pages/session/use-amicode-commands" import { pathKey } from "@/utils/path-key" import { handleDocumentSearchKeydown } from "@/utils/search-keydown" import { createMenuDismissController } from "@/utils/menu-dismiss-controller" @@ -28,6 +27,8 @@ export type PromptProject = { sandboxes?: string[] icon?: { color?: string; url?: string; override?: string } server?: { key: string; name: string } + type?: "research" | "dev" + status?: string // research project lifecycle status (proposing/designing/running/analyzing/writing/complete) } export type PromptProjectControls = { @@ -362,11 +363,27 @@ export function PromptProjectSelector(props: { when={props.controller.servers().length > 1} fallback={ - - {(project) => ( - - )} - + {/* amicode#663: type-grouped listing (Research then Dev) */} + p.type === "research")}> +
+ Research +
+ p.type === "research")}> + {(project) => ( + + )} + +
+ p.type !== "research")}> +
+ Development +
+ p.type !== "research")}> + {(project) => ( + + )} + +
} > @@ -493,14 +510,11 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr /> )} - {/* amicode#203 (Kate): don't show the project name in the chat — the folder - icon + chevron keep the switcher reachable without naming the project. - Only suppressed inside the amicode webview. */} - - - {project() ? displayName(project()!) : local.controller.labels.new()} - - + {/* amicode#663: project name now always shown — the selector is enriched + with type grouping and status badges, so the name provides context. */} + + {project() ? displayName(project()!) : local.controller.labels.new()} + ) @@ -541,6 +555,13 @@ function ProjectItem(props: { variant={getProjectAvatarVariant(props.project.icon?.color)} /> {displayName(props.project)} + {/* amicode#663: research status badge */} + + + {props.project.status} + + diff --git a/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-view.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-view.test.ts index 0b39f095..2f9a07db 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-view.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-view.test.ts @@ -2,25 +2,18 @@ import { describe, expect, test } from "bun:test" import { readFileSync } from "node:fs" import { join } from "node:path" -// amicode#326 — the breadcrumb bar (project selector + workspace selector + -// git status) below the new-session composer must be hidden when running inside -// the Amicode webview. Sessions already scope to all workspace folders via the -// multi-directory engine (opencode#215), so the bar is redundant chrome. -// -// Source assertion: the view must gate the breadcrumb section on !inAmicode(). -// No component-render harness exists (no @solidjs/testing-library), so we -// verify structurally — the same pattern as prompt-input-clipboard-structure.test.ts. +// amicode#663 — the breadcrumb bar (project selector + workspace selector + +// git status) below the new-session composer is now SHOWN in the Amicode +// webview, enriched with type grouping (Research/Dev). The !inAmicode() gate +// was removed in #667 (Selector UI enrichment). const source = readFileSync(join(import.meta.dir, "new-session-view.tsx"), "utf8") -describe("breadcrumb bar hidden in Amicode (amicode#326)", () => { - test("imports inAmicode", () => { - expect(source).toContain("inAmicode") +describe("breadcrumb bar shown in Amicode (#663, #667)", () => { + test("no longer imports inAmicode (gate removed)", () => { + expect(source).not.toContain("inAmicode") }) - test("the project-selected breadcrumb block is gated on !inAmicode()", () => { - // The breadcrumb row renders inside a - // that must also check !inAmicode(). We verify both conditions appear - // together in the guard. - expect(source).toMatch(/!inAmicode\(\).*project\.selected\(\)/) + test("the project-selected breadcrumb block is NOT gated on !inAmicode()", () => { + expect(source).not.toMatch(/!inAmicode\(\)/) }) }) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-view.tsx index b5472725..d049b27a 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-view.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/new-session/new-session-view.tsx @@ -14,7 +14,6 @@ import { PromptProjectSelector, type PromptProjectController, } from "@/components/prompt-project-selector" -import { inAmicode } from "@/pages/session/use-amicode-commands" import { StatusPopoverV2 } from "@/components/status-popover" import { SessionChatsDropdown } from "@/components/session/session-header" import { useLanguage } from "@/context/language" @@ -74,11 +73,9 @@ export function NewSessionView(props: { - {/* amicode#326: hide the breadcrumb bar (project selector + - workspace selector + git status) inside the Amicode webview — - sessions already scope to all workspace folders via the - multi-directory engine (opencode#215). */} - + {/* amicode#663: un-gated — the project selector now renders inside + the Amicode webview with type grouping (Research/Dev). */} +
/ledger/hypotheses/`", + "- Campaign ledgers: `/ledger/campaigns/`", + "- Observations: `/ledger/observations/`", + "- Experiment scripts: `/scripts/experiment/`", + "- Analysis scripts: `/scripts/analysis/`", + "- Testbed: `/scripts/testbed/` (simulated environments, hardware interfaces)", + "- Data: `/data/`", + "- Config: `/config/`", + "- Paper: `/paper/`", + "- Reports: `/reports/`", + "", + "The checkout registry (`CHECKOUTS.md`) stays vault-based (fleet-wide).", + ]; + return lines.join("\n"); + } + return null; +} + +/** Extract a simple string value from TOML text (regex, not a full parser). + * Only handles `key = "value"` at the top level. */ +function extractTomlString(text: string, key: string): string | undefined { + const m = text.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, "m")); + return m?.[1]; +} + /** Read the current stack state (solver mode, routing, active problem, live * runs, fleet, and the personal-vault user-memory sections) and compose a * markdown block to inject into the agent's system prompt. Returns null @@ -631,6 +694,11 @@ export function buildStackStateBlock(): string | null { const fleet = buildFleetSection(); if (fleet) parts.push(fleet); + // Active Research Project (#670): inject project metadata when a session + // is bound to a workspace folder with research-project.toml. + const project = buildActiveProjectSection(); + if (project) parts.push(project); + // User-memory sections (live reads from the personal vault — splice order // parity with the retired boot-time file splice: about → recent → demos → // mount stack → memory index). diff --git a/packages/extension/skills/analyze/SKILL.md b/packages/extension/skills/analyze/SKILL.md index 54d75a26..77ebff27 100644 --- a/packages/extension/skills/analyze/SKILL.md +++ b/packages/extension/skills/analyze/SKILL.md @@ -3,9 +3,8 @@ name: analyze description: Post-experiment analysis and insight extraction — stagnation detection, failure classification, hyperparameter comparison. Use after optimization runs to extract patterns and generate insights. agents: [researcher, librarian, dreamer] surface: public -vault_contract: - folders: [experiments, insights] - catalog: [pulses] +project_contract: + folders: [ledger/observations, ledger/campaigns, scripts/analysis, data] --- Post-experiment analysis for Amico optimization results. @@ -23,8 +22,8 @@ The argument is: $ARGUMENTS After a batch of optimization experiments (3+), or when STRATEGY.md priorities need updating. ### Step 1: Gather Data -- Read recent experiment notes in `experiments/` across **every mounted vault** (read precedence per the `amico-vault` skill) -- Read catalog entries in `catalog/pulses/*/metadata.toml` from whichever mount holds the catalog +- Read recent experiment notes in `/ledger/observations/` +- Read catalog entries in `catalog/pulses/*/metadata.toml` if the project has a catalog - Group by (platform, gate) pairs ### Step 2: Stagnation Detection @@ -56,9 +55,8 @@ Trace chains via `warm_started_from` in catalog: - Flag chains where cold restart might beat continuing ### Step 6: Generate Outputs -- **Insight notes**: Create in `/insights/` (route per amico-vault) when patterns are clear (3+ supporting experiments) +- **Insight notes**: Create in `/ledger/observations/` when patterns are clear (3+ supporting experiments) - **Strategy suggestions**: Note which STRATEGY.md priorities are progressing vs stuck -- Use `/amico-vault` skill for correct frontmatter > **Fidelity convention:** Always report both fixed-phase and free-phase fidelity for multi-subsystem gates. Free-phase is the primary metric. Fixed-phase routinely underreports by 6–80 pp for entangling gates. Ref: [[insight-20260412-054400-synthesis-free-phase-gap-scales-with-gate-type]]. diff --git a/packages/extension/skills/autoresearch/SKILL.md b/packages/extension/skills/autoresearch/SKILL.md index 301a3781..2ee756c1 100644 --- a/packages/extension/skills/autoresearch/SKILL.md +++ b/packages/extension/skills/autoresearch/SKILL.md @@ -3,31 +3,51 @@ name: autoresearch description: The director's loop protocol for autonomous research sessions — session-ledger discipline, the hypothesizer/experimenter/analyzer trio, deliberate spec gates, checkout registry, and compaction-any-time safety. Use when starting, running, or resuming an autoresearch loop. agents: [hypothesizer, experimenter, analyzer] surface: public -vault_contract: - folders: [sessions, experiments, specs] - note_types: [session, experiment, spec, hypothesis] - frontmatter: [session_id, status, tags] +project_contract: + folders: [ledger/hypotheses, ledger/observations, ledger/campaigns, scripts, data, reports, config] --- # Autoresearch — the director's protocol -> **Install conventions** — this skill references *your personal vault* (the writable -> vault your Amicode studio mounts) for the session ledger, experiment notes, and specs, -> and your compute fleet where one exists. The protocol is engine- and install-neutral; -> bindings for a given engine stay engine-side (the opencode binding of the director role -> is the `autoresearch` primary agent card). +> **Install conventions** — this skill operates on a **Research Project** (detected by +> `research-project.toml` in the workspace). All load-bearing state — campaign ledgers, +> hypotheses, observations, specs, scripts, data, and reports — lives in the project +> directory. The protocol is engine- and install-neutral; bindings for a given engine stay +> engine-side (the opencode binding of the director role is the `autoresearch` primary +> agent card). **Entry points:** the `autoresearch` primary agent (Tab-switch into research mode — its prompt embeds this spine), direct invocation of this skill, or the standing line in the user's autoresearch kickoff prompts. All three lead here; this file is the protocol. -The operating principle: **the context window is a cache; the vault is the database.** -Every piece of load-bearing state lives in a vault note; the context window holds only the -working set. Compaction (manual or auto) then costs nothing but a cache refill. +The operating principle: **the context window is a cache; the project is the database.** +Every piece of load-bearing state lives in project files — campaign ledgers, hypotheses, +observations, scripts. The context window holds only the working set. Compaction (manual +or auto) then costs nothing but a cache refill. -## The session ledger (create at kickoff, before any work) +## Project paths -Path: `/sessions/session--.md`. Nine sections, in order: +All artifacts live inside the Research Project directory. The `## Active Research Project` +block in the system prompt (injected by `stack_state.ts` when a workspace folder has +`research-project.toml`) confirms the project root. + +| Artifact | Path | +|----------|------| +| Campaign ledger | `/ledger/campaigns/campaign--.md` | +| Hypotheses | `/ledger/hypotheses/` | +| Observations | `/ledger/observations/` | +| Spec cards | `/config/specs/` | +| Experiment scripts | `/scripts/experiment/` — optimization solves, gate synthesis | +| Analysis scripts | `/scripts/analysis/` — post-processing, plotting, comparison | +| Testbed | `/scripts/testbed/` — simulated gym environments, hardware interfaces | +| Data | `/data/` | +| Config | `/config/` | +| Reports | `/reports/` | +| Checkout registry | `/ledger/campaigns/CHECKOUTS.md` | + +## The campaign ledger (create at kickoff, before any work) + +Path: `/ledger/campaigns/campaign--.md`. Nine sections, in order: 1. Objective & standing directives 2. Hypothesis ledger (H# → verdict → evidence → wiki-links) @@ -35,7 +55,7 @@ Path: `/sessions/session--.md`. Nine sections, i in-flight subagent cast (role, session id, spec id, assigned env, expected artifacts) 4. Blocked & reasons 5. Next queue -6. Checkout topology (mirror of this session's rows in `sessions/CHECKOUTS.md`) +6. Checkout topology (mirror of this session's rows in `ledger/campaigns/CHECKOUTS.md`) 7. Gotchas & methodology 8. Loop log (append-only, one row per loop: date, H#, spec_id, review verdict, plan hash, experimenter session id, gate verdicts, advisory closures) @@ -55,12 +75,12 @@ reference the in-flight casts? Append the audit row to §9. 1. **Re-read the ledger** — from disk, never from memory. 2. **Hypothesis queue thin?** Cast the **hypothesizer** (read-only subagent): ranked hypotheses + a spec-card draft. Parent picks the winner. -3. **Spec gate (deliberate):** file the spec card to `/specs/`, run +3. **Spec gate (deliberate):** file the spec card to `/config/specs/`, run `amico spec review ` — resolve blocking findings, re-run (round budget 3). `--allow-unreviewed` is FORBIDDEN for launch-shaped work (spends compute); if compile refuses for want of review, the fix is to review, never the flag. 4. **Ledger the cast, THEN cast the experimenter** (one experiment per spec, assigned env - per `sessions/CHECKOUTS.md`; claim the checkout row first). Parallel experimenters only + per `ledger/campaigns/CHECKOUTS.md`; claim the checkout row first). Parallel experimenters only where the registry says files are disjoint. 5. **Run the gates yourself (parent, via bash):** test suites, `amico plan status` where a compiled plan exists. Verdicts are DERIVED, never self-reported. No LLM — including @@ -111,10 +131,10 @@ experiment, only the parent's own working notes); re-read + audit after every co ## Parallel sessions & shared checkouts -`sessions/CHECKOUTS.md` is the fleet-wide claim registry. Re-read it before casting any -experimenter; claim your row; release it when work lands. First-writer-wins, propagated by -the ~15-min sync; races inside the window are possible and visible — a visible conflict -beats a silent double-ownership every time. +`ledger/campaigns/CHECKOUTS.md` is the project-wide claim registry. Re-read it before +casting any experimenter; claim your row; release it when work lands. First-writer-wins; +races are possible and visible — a visible conflict beats a silent double-ownership every +time. ## Standing anti-gaming contract diff --git a/packages/extension/skills/deliberate/SKILL.md b/packages/extension/skills/deliberate/SKILL.md index 5e3fdac9..4c4715c4 100644 --- a/packages/extension/skills/deliberate/SKILL.md +++ b/packages/extension/skills/deliberate/SKILL.md @@ -4,8 +4,8 @@ description: "Use before any substantial work — a spec, adversarial review by agents: [researcher, experimenter, engineer] surface: public scenarios: [spec-underspecified-must-block, spec-adequate-must-pass] -vault_contract: - folders: [specs, plans] +project_contract: + folders: [config/specs] note_types: [spec, plan, spec-review] tags: [deliberation] --- @@ -48,7 +48,7 @@ by hand, which is a weaker claim and should read as one. One dialogue rule: **ask one question at a time.** A wall of questions gets a wall of shallow answers. -Write to `/specs/spec--.md`: +Write to `/config/specs/spec--.md`: ```yaml --- @@ -249,6 +249,6 @@ on **both** sides actually are — and whether the spec ever says. ## Related -- `amico-vault` — where specs and plans live, and the frontmatter schema +- `config/specs/` — where specs and plans live in a Research Project - `break-into-subissues`, `implement-issue` — the issue-tracker path, once a plan exists - `verification` — what a gate is and how to choose one diff --git a/packages/extension/skills/hypothesis-review/SKILL.md b/packages/extension/skills/hypothesis-review/SKILL.md index f725fa33..389b0a9d 100644 --- a/packages/extension/skills/hypothesis-review/SKILL.md +++ b/packages/extension/skills/hypothesis-review/SKILL.md @@ -3,20 +3,19 @@ name: hypothesis-review description: Rank open hypotheses by testability and impact. Invoked from researcher Step 0 to prioritize hypothesis-driven experiments. agents: [researcher] surface: public -vault_contract: - folders: [hypotheses] +project_contract: + folders: [ledger/hypotheses] frontmatter: [status] --- # Hypothesis Review -Rank open vault hypotheses to identify the highest-value experiments. +Rank open hypotheses to identify the highest-value experiments. ## Process -1. Glob `hypotheses/` across **every mounted vault** in the Armonia stack (see the - `amico-vault` skill for the mount set and read precedence) for notes with - `status: open` or `status: untested` +1. **Locate hypotheses.** Read from `/ledger/hypotheses/`. Filter for + notes with `status: open` or `status: untested`. 2. For each hypothesis, read the body and assess: - **Impact if confirmed** (numeric): high=3 (unlocks fidelity breakthrough or new platform), medium=2 (improves existing result), low=1 (incremental or niche) - **Ease of test** (numeric): easy=3 (1 cold-start experiment), moderate=2 (needs specific setup or 2-3 experiments), hard=1 (requires engineering or new infrastructure) @@ -28,7 +27,7 @@ Rank open vault hypotheses to identify the highest-value experiments. ```yaml hypotheses: - - path: "vault/hypotheses/hypothesis-YYYYMMDD-HHMMSS-topic.md" + - path: "ledger/hypotheses/hypothesis-YYYYMMDD-HHMMSS-topic.md" title: "Free-phase may help fluxonium Y gate" impact: 3 # high — could unlock new-best on stuck gate ease: 3 # easy — 1 cold-start with free_phase=true diff --git a/packages/extension/skills/migrate-research-project/SKILL.md b/packages/extension/skills/migrate-research-project/SKILL.md new file mode 100644 index 00000000..65c65a1f --- /dev/null +++ b/packages/extension/skills/migrate-research-project/SKILL.md @@ -0,0 +1,301 @@ +--- +name: migrate-research-project +description: Convert an existing research repo into a Research Project — scan, infer, interview, scaffold, verify. Use when a user opens a bare git repo and wants to adopt the prescribed layout without losing existing work. +agents: [] +surface: public +--- + +# Migrate Project + +Convert an existing research repository into a Research Project. The agent +drives the entire migration — reading files, classifying content, building a +plan, and executing approved moves. No CLI middleman for the interactive path. + +## When to invoke + +- User says "import this as a research project," "convert this repo," + "migrate this to the project layout," or similar +- User opens a git repo with research artifacts but no `research-project.toml` +- The onset router detects a repo with optimization scripts but no manifest + +## Prescribed layout (target state) + +``` +/ + research-project.toml # manifest (schema_version, name, slug, question, status, created) + README.md + .gitignore + scripts/ # all runnable code + experiment/ # optimization solves, gate synthesis + analysis/ # post-processing, plotting, comparison + testbed/ # simulated gym environment, hardware-interfacing code + data/ + raw/ # optimization output, raw results + processed/ # post-processed data + plots/ # generated figures + paper/ # manuscript (outline.md → main.tex) + outline.md + main.tex + references.bib + figures/ + supplementary/ + ledger/ + hypotheses/ # open questions, future directions + observations/ # experiment records, results summaries, methodology notes + literature/ # reading notes + campaigns/ # autoresearch campaign ledgers + reports/ + weekly/ # weekly updates (template.md provided) + presentations/ # slide decks + milestones/ # quarterly / grant reports + config/ # system model, lab config, hardware reference + skills/ # project-specific Amico skills +``` + +## The procedure + +### Phase 1 — Scan and classify + +Read the directory tree (depth 3 max). For every file and directory, read +the first 20–30 lines and classify it using the heuristics below. Build an +internal inventory before saying anything. + +#### Classification heuristics + +Read the file content — do not classify by filename alone. + +**Observations / results** — destination: `ledger/observations/` +- Contains fidelity numbers (`F =`, `1 - F`, `infidelity`, specific numeric results) +- Contains data tables, benchmarks, comparison tables +- Heading includes "results", "summary", "verification" +- Reports what happened in past tense ("achieved", "measured", "verified") + +**Hypotheses / future work** — destination: `ledger/hypotheses/` +- Contains "future", "deferred", "open question", "TODO", "next steps" +- Lists ideas that haven't been tested yet +- Uses speculative language ("could", "might", "worth trying", "if we") +- References work not yet done + +**Methodology / lessons learned** — destination: `ledger/observations/` +- Contains "what worked", "guide", "optimization", "approach" +- Documents parameter choices and their rationale +- Compares approaches ("we tried X, but Y worked better") +- Has lessons-learned character ("the key insight was") +- These are durable records of what was learned — they go with observations + +**System reference / hardware** — destination: `config/` +- Contains Hamiltonian definitions (`$H$`, `\hat H`, `H =`) +- Lists hardware parameters (frequencies, anharmonicities, coupling strengths) +- Defines the physical model, units, conventions +- Reference material that doesn't change between experiments + +**Experiment scripts** — destination: `scripts/experiment/` +- `.jl` files with `using Piccolo`, `QuantumCollocation`, `Piccolissimo` +- Contains `solve!`, `UnitarySmoothPulseProblem`, trajectory definitions +- The bulk of a researcher's scripts — optimization solves, gate synthesis + +**Analysis scripts** — destination: `scripts/analysis/` +- `.jl` or `.py` files that load results and produce plots or tables +- Contains `load`, `JLD2`, `CairoMakie`, `plot`, `DataFrame` +- Post-processing, comparison, Pareto-frontier scripts +- Distinct from experiment scripts: these read data, not produce it + +**Raw data / run output** — destination: `data/raw/` +- `.jld2`, `.hdf5`, `.h5` files +- Directories containing `result.toml`, `run.toml`, iteration logs +- Named with timestamps or run IDs (e.g., `yang_sqrtx_20260825_103713/`) + +**Plots and figures** — destination: `data/plots/` or `paper/figures/` +- `.png`, `.svg`, `.pdf` images +- If inside a `paper/` or `report/` context → `paper/figures/` +- If standalone or in a `data/` context → `data/plots/` + +**Reports** — destination: `reports/` +- Progress reports, weekly updates, preliminary reports +- Contains dated summaries aimed at an audience (team, PI, collaborators) +- Distinct from observations (observations record data; reports communicate it) + +**Literature** — destination: `ledger/literature/` +- PDFs of referenced papers, reading notes +- Already in `papers/` or `references/` → move to `ledger/literature/` + +**Source code** — stays as-is +- `src/`, `lib/` with Julia modules, utility functions +- Not part of the prescribed layout — the project may have its own package + +**Unclassifiable** — stays as-is +- When in doubt, do NOT propose a move +- Flag it in the plan as "unclassified — kept in place" + +#### Ambiguity rules + +- A file that fits two categories equally: pick the more specific one and + note the ambiguity in the Reason column +- A directory with mixed content (e.g., `docs/` with both results and + methodology): propose per-file moves, not a directory-level move +- A file you cannot classify after reading 30 lines: mark it "stays" with + reason "unclassified" + +### Phase 2 — Infer metadata + +Read these sources for manifest fields — do NOT ask for what you can read: + +**Name** (required): +1. First `# heading` in `README.md` +2. Else: directory basename, title-cased + +**Research question** (required): +1. Look for a line starting with "question:", "research question:", or + a sentence ending with `?` in the first 20 lines of `README.md` +2. Look in `PLAN.md` for an objective or question statement +3. If not found: you MUST ask (this is the one field that cannot be guessed) + +**Status** (required — infer, then confirm): +- Only scripts exist, no data → `"designing"` +- Has data/results but no analysis → `"running"` +- Has analysis, methodology docs → `"analyzing"` +- Has paper draft or outline → `"writing"` +- Has completed, submitted, or published paper → `"complete"` +- Ambiguous → pick the furthest stage that has evidence, present your + reasoning, let the researcher correct + +**Domain pack** (optional — infer silently): +- `using Piccolo` or `using Piccolissimo` → `quantum-control` +- Otherwise: omit (no default) + +**Author** (optional — infer silently): +- `git config user.name` in the repo, or omit + +### Phase 3 — Present the migration plan + +Present a single markdown table covering **every** file and directory you +found, plus every scaffold entry that will be created. This is the plan the +researcher reviews. + +Format: + +```markdown +## Migration Plan for [name] + +**Inferred metadata:** +- **Name:** [inferred] +- **Question:** [inferred or "I need to ask"] +- **Status:** [inferred] — [one sentence of reasoning] + +| # | Current path | → Destination | Action | Reason | +|---|-------------|---------------|--------|--------| +| 1 | `scripts/single_qubit/` | `scripts/single_qubit/` | stays | already in the right place | +| 2 | `data/x_gate/` | `data/raw/x_gate/` | move | raw optimization output (.jld2 files) | +| 3 | `docs/results_summary.md` | `ledger/observations/results_summary.md` | move | fidelity numbers, verification results | +| 4 | `docs/future_directions.md` | `ledger/hypotheses/future_directions.md` | move | deferred ideas, speculative language | +| 5 | `docs/optimization_guide.md` | `ledger/observations/optimization_guide.md` | move | methodology notes, "what worked" | +| 6 | `docs/system_model.md` | `config/system_model.md` | move | Hamiltonian, hardware params | +| 7 | `report/` | `reports/` | rename | singular → plural to match layout | +| 8 | `src/` | `src/` | stays | Julia modules, outside prescribed layout | +| 9 | `papers/yang_2020.pdf` | `papers/yang_2020.pdf` | stays | literature, keep as-is | +| 10 | `Project.toml` | `Project.toml` | stays | Julia package manifest | +| — | `paper/outline.md` | — | scaffold | template with inferred question | +| — | `paper/main.tex` | — | scaffold | minimal article template | +| — | `ledger/campaigns/` | — | scaffold | empty, for autoresearch | +| — | `reports/weekly/template.md` | — | scaffold | weekly update template | +| — | `config/system.toml` | — | scaffold | stub | +| — | `research-project.toml` | — | scaffold | manifest | +``` + +Then ask ONE question: + +> "Here's my migration plan. Review the table — tell me which moves to +> change, skip, or redirect, and I'll adjust. Or say 'looks good' to +> proceed." + +If the researcher requests changes, **update the plan table and re-present +it** — do not execute a partial plan. Iterate until they approve. + +### Phase 4 — Execute + +Once the researcher approves the plan, execute it in this order: + +1. **Create scaffold directories** — `mkdir -p` for every "scaffold" row +2. **Move files** — for each "move" row: + - `mkdir -p` the destination's parent directory + - If the file is git-tracked: `git mv ` + - If untracked: `mv ` +3. **Rename directories** — for each "rename" row: + - If git-tracked: `git mv ` + - If untracked: `mv ` +4. **Write scaffold files** — write `research-project.toml`, `README.md` + (only if none exists), `paper/outline.md`, `paper/main.tex`, + `reports/weekly/template.md`, config stubs, `.gitignore` files. + **Never overwrite an existing file.** +5. **Write `research-project.toml`** directly — use the schema from the + entity slice: + ```toml + schema_version = 1 + name = "" + slug = "" + question = "" + status = "" + created = "" + ``` +6. **Commit** — one commit with a descriptive message: + ``` + feat: migrate to research project layout + + Moved: + - docs/results_summary.md → ledger/observations/ + - docs/future_directions.md → ledger/hypotheses/ + - ... + Scaffolded: paper/, ledger/, reports/, config/, skills/ + ``` + +### Phase 5 — Verify and orient + +After execution, show the researcher: + +1. **What moved** — list each move with old → new path +2. **What was created** — list scaffolded directories and files +3. **What stayed** — confirm nothing was overwritten or lost +4. **Empty directories cleaned** — if a directory (e.g., `docs/`) is now + empty after all its files were moved out, note it: "docs/ is now empty — + you can remove it or keep it" +5. **Next steps** — suggest 1–2 concrete actions: + - "Review `paper/outline.md` — fill in your outline sections and the + paper-writer skill can help format it" + - "Your weekly report template is at `reports/weekly/template.md`" + - "Add this folder to your VS Code workspace to get project-aware skills" + +## Edge cases + +**Already a Research Project** (`research-project.toml` exists): +Say so. Offer to re-scan and scaffold any missing directories, or to +re-classify content that's in the wrong place. + +**Monorepo / multi-project directory:** +If the root has multiple independent research directories, ask which one to +migrate. Do not migrate the root. + +**No git:** +Warn the researcher. Offer to `git init` before migrating (so moves get +history). If they decline, use plain `mv` for everything. + +**Mixed-content directories:** +When a directory like `docs/` has files that belong in different places, +propose per-file moves in the plan table — never move the whole directory. + +**Name collision on move:** +If a destination file already exists (e.g., the researcher already has +`ledger/observations/optimization_guide.md`), flag it in the plan: "destination exists +— skip or overwrite?" Default: skip. + +**Large data directories:** +Don't read inside directories with 50+ files — classify by the directory +name and a sample of 3 filenames. Note: "sampled, not fully read." + +## What this skill is NOT + +- Not for greenfield projects (use `amico project create` for new work) +- Not a data migration tool (no cross-machine, no format conversion) +- Not the paper-writer (invoke that after migration if the researcher wants + to start writing) +- Not a renaming enforcer — if the researcher says "keep `docs/` as is," + respect that unconditionally diff --git a/packages/extension/skills/paper-writer/SKILL.md b/packages/extension/skills/paper-writer/SKILL.md new file mode 100644 index 00000000..b67fe512 --- /dev/null +++ b/packages/extension/skills/paper-writer/SKILL.md @@ -0,0 +1,310 @@ +--- +name: paper-writer +description: Gated agentic paper-writing workflow — outline-first, section-by-section, content-provenance. Use when the user asks to write, format, or edit sections of a research paper. +agents: [] +surface: public +--- + +# Paper Writer + +A gated workflow for formatting research papers from a user-authored outline. +The researcher owns the intellectual content; the agent is a typesetter, not a +co-author. + +## Gate model + +This skill enforces two classes of gate: + +- **Hard gates** — code-enforced by checking files and frontmatter before + acting. The agent reads `paper/outline.md`, inspects its YAML frontmatter, + and refuses to proceed if the gate conditions are not met. These are + deterministic. +- **Soft gates** — LLM instructions that guide behavior probabilistically. + They work as well as the model's instruction-following allows. This skill + is honest about the distinction. + +## Hard gates + +### 1. Outline-first gate + +**Before writing any content in `paper/main.tex`**, check that +`paper/outline.md` exists AND its YAML frontmatter has `status: final` (not +`draft`). + +If the outline is missing or still `status: draft`, **refuse** with: + +> "I need a finalized outline before writing the paper. Your outline is +> currently [missing / draft]. Please review `paper/outline.md`, mark each +> section as ready, and set `status: final` in the frontmatter." + +The user controls when the outline is final. The agent never changes the +outline's `status` field. + +### 2. Section-by-section approval + +Format **one section at a time** in this order: + +1. Read the section's outline bullets from `paper/outline.md` +2. Expand the bullets into LaTeX prose in `paper/main.tex` +3. Present the formatted section to the user for review +4. Wait for explicit approval before proceeding to the next section + +After user approval, add the section name to the `sections_approved[]` array +in `paper/outline.md` frontmatter. Never format a section already in the +approved list unless the user explicitly asks for a revision. + +### 3. Abstract prohibition + +The abstract is **always** user-authored. Never generate an abstract. If the +user asks the agent to write the abstract, respond: + +> "The abstract is your synthesis of the work — I can proofread or format it, +> but I shouldn't write it. Draft your abstract in the outline and I'll +> typeset it." + +## Soft gates (LLM instructions) + +These are behavioral rules, not code-enforceable constraints. They guide the +agent's output quality but are probabilistic. + +### Content provenance + +Every paragraph in `paper/main.tex` must trace back to a specific bullet in +`paper/outline.md`. When expanding a bullet into prose, cite the outline +bullet in a LaTeX comment: + +```latex +% outline: Results > bullet 3 +The optimized pulse achieves $F = 0.9995$ in 42~ns... +``` + +### Forbidden actions + +The agent MUST NOT: + +1. **Generate novel conclusions** — only format what the outline says +2. **Interpret results** — report numbers; do not explain their significance + beyond what the outline states +3. **Generate the abstract** — always user-authored (hard gate above) +4. **Select emphasis** — do not add "importantly," "notably," "remarkably" or + similar editorial emphasis not present in the outline +5. **Invent methodology details** — only include methods described in the + outline or referenced in `scripts/experiment/` (optimization solves) and + `scripts/testbed/` (system environment) +6. **Hallucinate citations** — only cite references that exist in + `paper/references.bib` + +### Data grounding + +Quantitative claims (fidelities, gate times, error rates, etc.) must cite +their source file in `data/`. When expanding an outline bullet that contains a +number, verify the number appears in a referenced data file. If the number +cannot be traced to `data/`, flag it: + +```latex +% WARNING: F = 0.9995 not found in data/ — verify before submission +``` + +## Capabilities + +The paper-writer skill supports seven operations: + +1. **Sentence expansion from outline** — expand outline bullets into + well-formed LaTeX paragraphs, one section at a time +2. **Data citation with inline results** — embed quantitative results from + `data/` files inline, with provenance comments +3. **Figure placement and captioning** — insert `\includegraphics` for files + in `paper/figures/`, draft captions from outline descriptions +4. **Bibliography management** — add BibTeX entries to `paper/references.bib`, + insert `\cite{}` references where the outline indicates citations +5. **Outline gap detection** — identify outline sections that are too thin to + expand into a full section and suggest what's missing +6. **Proofreading** — fix grammar, punctuation, and LaTeX formatting in + already-approved sections (does not change technical content) +7. **Equation formatting** — convert inline math descriptions in the outline + to properly formatted LaTeX equations + +## Venue-aware outline templates + +When scaffolding `paper/outline.md` for a new project, use the template +matching the project's venue. These templates define the section structure the +paper-writer expects. + +### PRL (Physical Review Letters) — compressed format + +```markdown +--- +status: draft +last_reviewed: +sections_approved: [] +--- + +# [Title] + +**Research question:** [question] + +## Outline (PRL compressed format) + +### Abstract +- [ ] [User-authored summary — max 600 words] + +### Introduction +- [ ] Context and motivation (1-2 paragraphs) +- [ ] State of the art and gap +- [ ] Our contribution (one sentence) + +### Results +- [ ] Main result with key figure +- [ ] Supporting measurements +- [ ] Comparison with prior work + +### Discussion +- [ ] Interpretation of results +- [ ] Limitations +- [ ] Outlook + +### Methods +- [ ] Experimental/computational setup +- [ ] Key parameters and their justification + +### Supplementary +- [ ] Extended data tables +- [ ] Additional figures +- [ ] Detailed derivations +``` + +### PRX Quantum — full format + +```markdown +--- +status: draft +last_reviewed: +sections_approved: [] +--- + +# [Title] + +**Research question:** [question] + +## Outline (PRX Quantum full format) + +### Abstract +- [ ] [User-authored summary] + +### Introduction +- [ ] Broad context (1 paragraph) +- [ ] Specific problem and prior work (2-3 paragraphs) +- [ ] Gap in existing approaches +- [ ] Our approach and key results (1 paragraph) +- [ ] Paper organization + +### Background +- [ ] Notation and conventions +- [ ] Review of relevant theory +- [ ] System model + +### Methods +- [ ] Problem formulation +- [ ] Optimization approach +- [ ] Computational details + +### Results +- [ ] Main result +- [ ] Systematic study / parameter sweep +- [ ] Robustness analysis +- [ ] Comparison with baselines + +### Discussion +- [ ] Interpretation +- [ ] Connections to related work +- [ ] Limitations and assumptions +- [ ] Future directions + +### Conclusion +- [ ] Summary of contributions +- [ ] Open questions + +### Acknowledgments +- [ ] Funding, collaborators +``` + +### arXiv general + +```markdown +--- +status: draft +last_reviewed: +sections_approved: [] +--- + +# [Title] + +**Research question:** [question] + +## Outline + +### Abstract +- [ ] [User-authored summary] + +### Introduction +- [ ] Motivation and context +- [ ] Prior work +- [ ] Our contribution + +### Methods +- [ ] Approach description +- [ ] Implementation details + +### Results +- [ ] Key findings +- [ ] Supporting evidence + +### Discussion +- [ ] Implications +- [ ] Limitations + +### Conclusion +- [ ] Summary and outlook +``` + +### Minimal (no venue) + +```markdown +--- +status: draft +last_reviewed: +sections_approved: [] +--- + +# [Title] + +**Research question:** [question] + +## Outline + +### Abstract +- [ ] [User-authored summary] + +### Introduction +- [ ] TODO + +### Methods +- [ ] TODO + +### Results +- [ ] TODO + +### Discussion +- [ ] TODO + +### Conclusion +- [ ] TODO +``` + +## Overriding per-project + +A Research Project can override this skill by placing a custom +`skills/paper-writer/SKILL.md` in its project directory. The project version +shadows the shipped version (project > custom > workspace > shipped in the +skill merge chain). This lets a team enforce lab-specific writing conventions +or venue requirements. diff --git a/packages/extension/src/amicode_service/project.ts b/packages/extension/src/amicode_service/project.ts index cd330a3e..4f655000 100644 --- a/packages/extension/src/amicode_service/project.ts +++ b/packages/extension/src/amicode_service/project.ts @@ -7,6 +7,7 @@ import { spawnSync } from "node:child_process" import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs" import { homedir } from "node:os" import path from "node:path" +import { detectProjectType, type ProjectType } from "../project/detect" /** The default parent when the client sends none — the webview doesn't know the * user's home dir, so the server owns the default (created on first use). */ @@ -109,7 +110,7 @@ export function createProject(rawBody: string): string { return JSON.stringify(createProjectAt(plan.target, plan.slug)) } -export type ProjectDirEntry = { slug: string; path: string } +export type ProjectDirEntry = { slug: string; path: string; type: ProjectType } /** Enumerate the immediate subdirectories of the projects parent — each folder * IS a project (amicode is folder-first). This is the source of truth for the @@ -132,7 +133,7 @@ export function listProjectDirs( if (!exists(parentDir)) return [] return readEntries(parentDir) .filter((e) => e.isDirectory && !e.name.startsWith(".")) - .map((e) => ({ slug: e.name, path: path.join(parentDir, e.name) })) + .map((e) => ({ slug: e.name, path: path.join(parentDir, e.name), type: detectProjectType(path.join(parentDir, e.name)) })) .sort((a, b) => a.slug.localeCompare(b.slug)) } diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 93ff48ed..aa994ee5 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -16,7 +16,7 @@ import { type LibraryRoot, type LibraryRootSpec, } from "./scores/package_skills"; -import { resolveUserSkills, resolveWorkspaceSkills, mergeSkillEntries } from "./scores/user_skill_providers"; +import { resolveUserSkills, resolveWorkspaceSkills, resolveProjectSkills, mergeSkillEntries } from "./scores/user_skill_providers"; import { readSolverModeState } from "./solver_mode"; import { studioPathsOrLegacy } from "@amicode/schema"; import { opencodeConfigDir } from "./opencode_xdg"; @@ -556,6 +556,10 @@ export interface OpencodeConfigOptions { /** Absolute path to the workspace .opencode/skills/ directory (issue #573). * Auto-loaded skills labeled (workspace). Undefined = no workspace skills. */ workspaceSkillsDir?: string; + /** Absolute paths to workspace folders (VS Code multi-root). Used for + * research-project skill discovery (#668): each folder with research-project.toml + * has its skills/ scanned. Undefined = no project skills. */ + workspaceFolders?: string[]; /** Stable project dir to (re)use across activations; created if missing and * safe to re-prepare (every write below is overwrite-idempotent). Default: * a fresh mkdtemp — but note the app PERSISTS the selected project per @@ -736,7 +740,10 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro const workspaceEntries = opts.workspaceSkillsDir ? resolveWorkspaceSkills(opts.workspaceSkillsDir) : []; - skillEntries = mergeSkillEntries(customEntries, workspaceEntries, shippedEntries); + const projectEntries = opts.workspaceFolders + ? resolveProjectSkills(opts.workspaceFolders) + : []; + skillEntries = mergeSkillEntries(projectEntries, customEntries, workspaceEntries, shippedEntries); const section = buildSkillIndexSection(skillEntries); if (section) finalContent = finalContent + "\n\n" + section; } catch (e) { diff --git a/packages/extension/src/project/detect.ts b/packages/extension/src/project/detect.ts new file mode 100644 index 00000000..4a072f82 --- /dev/null +++ b/packages/extension/src/project/detect.ts @@ -0,0 +1,24 @@ +// detect.ts — project type detection (#666). +// A Research Project is identified by a `research-project.toml` manifest at its root. +// Detection is by file presence only (fast stat, no content parsing). +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +export type ProjectType = "research" | "dev"; + +/** + * Detect whether a directory is a Research Project or a Dev Project. + * A Research Project has a `research-project.toml` manifest at its root. + * Everything else is a Dev Project (the existing git-repo model). + * + * Re-evaluated on each call — no caching — so a directory that gains + * `research-project.toml` after initial registration updates its type on next + * resolution. + */ +export function detectProjectType(dir: string): ProjectType { + try { + return existsSync(join(dir, "research-project.toml")) ? "research" : "dev"; + } catch { + return "dev"; + } +} diff --git a/packages/extension/src/scores/package_skills.ts b/packages/extension/src/scores/package_skills.ts index c002fa4d..14ecba00 100644 --- a/packages/extension/src/scores/package_skills.ts +++ b/packages/extension/src/scores/package_skills.ts @@ -22,7 +22,7 @@ import { parse as parseYaml } from "yaml"; // same parser as scores/loader.ts // Content is read on demand by the agent — never baked into the prompt or the // .vsix. Errors mirror the entitlements philosophy: skip + warn, never throw. export interface SkillIndexEntry { - source: "library" | "package" | "custom" | "workspace"; // platform | co-located | user-added | workspace .opencode/skills/ + source: "library" | "package" | "custom" | "workspace" | "project"; // platform | co-located | user-added | workspace .opencode/skills/ | research project skills/ package?: string; // absent for library entries (spec §3) name: string; description: string; @@ -256,6 +256,7 @@ export function stageOpencodeSkills(stageRoot: string, entries: SkillIndexEntry[ export function buildSkillIndexSection(entries: SkillIndexEntry[]): string { if (entries.length === 0) return ""; // no section at all (spec §3) const platform = entries.filter((e) => e.source === "library"); + const project = entries.filter((e) => e.source === "project"); const custom = entries.filter((e) => e.source === "custom"); const workspace = entries.filter((e) => e.source === "workspace"); const pkg = entries.filter((e) => e.source === "package"); @@ -272,6 +273,10 @@ export function buildSkillIndexSection(entries: SkillIndexEntry[]): string { (e) => `- **${e.name}** (platform reference) — ${e.description}\n - Use as physics reference — inline the constants; authored scripts stay self-contained (no \`include\` of demo-repo files).`, ), + ...project.map((e) => { + const label = (e as any).overridesShipped ? "(project, overrides platform)" : "(project)"; + return `- **${e.name}** ${label} — ${e.description}`; + }), ...custom.map((e) => { const label = (e as any).overridesShipped ? "(custom, overrides platform)" : "(custom)"; return `- **${e.name}** ${label} — ${e.description}`; diff --git a/packages/extension/src/scores/user_skill_providers.ts b/packages/extension/src/scores/user_skill_providers.ts index 396798bd..8578e3eb 100644 --- a/packages/extension/src/scores/user_skill_providers.ts +++ b/packages/extension/src/scores/user_skill_providers.ts @@ -1,6 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { parse as parseYaml } from "yaml"; +import { detectProjectType } from "../project/detect"; import type { SkillIndexEntry } from "./package_skills"; // --- Types --- @@ -85,17 +86,33 @@ export function resolveWorkspaceSkills(wsSkillsDir: string): SkillIndexEntry[] { return scanSkillDirectory(wsSkillsDir, "workspace"); } +/** Resolve project skills from research project workspace folders (#668). + * For each folder with `research-project.toml`, scan its `skills/` directory. + * Non-research directories are skipped. Order-preserving: first folder wins + * on name collision (consistent with workspace-folder order). */ +export function resolveProjectSkills(workspaceFolders: string[]): SkillIndexEntry[] { + const out: SkillIndexEntry[] = []; + for (const folder of workspaceFolders) { + if (detectProjectType(folder) !== "research") continue; + const skillsDir = path.join(folder, "skills"); + if (!fs.existsSync(skillsDir)) continue; + out.push(...scanSkillDirectory(skillsDir, "project" as SkillIndexEntry["source"])); + } + return out; +} + /** A merged entry may carry an `overridesShipped` flag when a custom/workspace * skill shadows a platform (library/package) skill of the same name. */ export interface MergedSkillEntry extends SkillIndexEntry { overridesShipped?: boolean; } -/** Merge skill entries with shadow semantics: custom > workspace > shipped. - * First match by name wins (resolution order). If a custom or workspace entry +/** Merge skill entries with shadow semantics: project > custom > workspace > shipped. + * First match by name wins (resolution order). If a higher-priority entry * shadows a shipped skill, the winner carries `overridesShipped: true` so the * Skill Index can label it appropriately. */ export function mergeSkillEntries( + project: SkillIndexEntry[], custom: SkillIndexEntry[], workspace: SkillIndexEntry[], shipped: SkillIndexEntry[], @@ -104,14 +121,22 @@ export function mergeSkillEntries( const shippedNames = new Set(shipped.map((e) => e.name)); const out: MergedSkillEntry[] = []; - // Custom first (highest priority) + // Project first (highest priority) + for (const e of project) { + if (seen.has(e.name)) continue; + seen.add(e.name); + const overrides = shippedNames.has(e.name); + if (overrides) console.warn(`amicode: project skill "${e.name}" shadows shipped skill`); + out.push(overrides ? { ...e, overridesShipped: true } : e); + } + // Custom second for (const e of custom) { if (seen.has(e.name)) continue; seen.add(e.name); const overrides = shippedNames.has(e.name); out.push(overrides ? { ...e, overridesShipped: true } : e); } - // Workspace second + // Workspace third for (const e of workspace) { if (seen.has(e.name)) continue; seen.add(e.name); diff --git a/packages/extension/test/project/detect.test.ts b/packages/extension/test/project/detect.test.ts new file mode 100644 index 00000000..24f1fe34 --- /dev/null +++ b/packages/extension/test/project/detect.test.ts @@ -0,0 +1,79 @@ +// Project type detection tests — #666. +// detectProjectType(dir) returns "research" if research-project.toml exists, "dev" otherwise. +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { detectProjectType } from "../../src/project/detect"; +import { listProjectDirs } from "../../src/amicode_service/project"; + +describe("detectProjectType", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "amicode-detect-")); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns 'research' when research-project.toml exists at the root", () => { + writeFileSync(join(tmpDir, "research-project.toml"), 'schema_version = 1\nname = "test"\n'); + expect(detectProjectType(tmpDir)).toBe("research"); + }); + + it("returns 'dev' when research-project.toml does not exist", () => { + expect(detectProjectType(tmpDir)).toBe("dev"); + }); + + it("returns 'dev' for a nonexistent directory", () => { + expect(detectProjectType(join(tmpDir, "nope"))).toBe("dev"); + }); + + it("detects type change: directory gains research-project.toml after first resolution", () => { + expect(detectProjectType(tmpDir)).toBe("dev"); + writeFileSync(join(tmpDir, "research-project.toml"), 'schema_version = 1\n'); + expect(detectProjectType(tmpDir)).toBe("research"); + }); +}); + +// ── integration: listProjectDirs carries type ────────────────────────────── + +describe("listProjectDirs with type detection", () => { + let parentDir: string; + + beforeEach(() => { + parentDir = mkdtempSync(join(tmpdir(), "amicode-list-")); + mkdirSync(join(parentDir, "research-proj")); + writeFileSync(join(parentDir, "research-proj", "research-project.toml"), 'schema_version = 1\n'); + mkdirSync(join(parentDir, "dev-proj")); + }); + + afterEach(() => { + rmSync(parentDir, { recursive: true, force: true }); + }); + + it("returns type 'research' for project with research-project.toml", () => { + const projects = listProjectDirs(parentDir); + const research = projects.find((p) => p.slug === "research-proj"); + expect(research).toBeDefined(); + expect(research!.type).toBe("research"); + }); + + it("returns type 'dev' for project without research-project.toml", () => { + const projects = listProjectDirs(parentDir); + const dev = projects.find((p) => p.slug === "dev-proj"); + expect(dev).toBeDefined(); + expect(dev!.type).toBe("dev"); + }); + + it("all entries carry the type field", () => { + const projects = listProjectDirs(parentDir); + expect(projects).toHaveLength(2); + for (const p of projects) { + expect(p.type).toBeDefined(); + expect(["research", "dev"]).toContain(p.type); + } + }); +}); diff --git a/packages/extension/test/scores/project_skills.test.ts b/packages/extension/test/scores/project_skills.test.ts new file mode 100644 index 00000000..fd517de0 --- /dev/null +++ b/packages/extension/test/scores/project_skills.test.ts @@ -0,0 +1,130 @@ +// Skill auto-loading from research project directories — #668. +// resolveProjectSkills scans workspace folders with .amico for skills/. +// mergeSkillEntries extended with project source (highest priority). +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + resolveProjectSkills, + mergeSkillEntries, +} from "../../src/scores/user_skill_providers"; +import type { SkillIndexEntry } from "../../src/scores/package_skills"; + +describe("resolveProjectSkills", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "amicode-project-skills-")); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("discovers skills from a research project's skills/ directory", () => { + const projectDir = join(tmpDir, "my-research"); + mkdirSync(join(projectDir, "skills", "my-analysis"), { recursive: true }); + writeFileSync(join(projectDir, "research-project.toml"), 'schema_version = 1\n'); + writeFileSync(join(projectDir, "skills", "my-analysis", "SKILL.md"), `--- +name: my-analysis +description: Custom analysis skill for this project +agents: [] +surface: public +--- +# My Analysis +`); + + const skills = resolveProjectSkills([projectDir]); + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe("my-analysis"); + expect(skills[0].source).toBe("project"); + expect(skills[0].description).toBe("Custom analysis skill for this project"); + }); + + it("ignores directories without .amico (dev projects)", () => { + const devDir = join(tmpDir, "dev-repo"); + mkdirSync(join(devDir, "skills", "some-skill"), { recursive: true }); + writeFileSync(join(devDir, "skills", "some-skill", "SKILL.md"), `--- +name: some-skill +description: A skill in a dev project +agents: [] +--- +# Some Skill +`); + + const skills = resolveProjectSkills([devDir]); + expect(skills).toHaveLength(0); + }); + + it("returns empty for a research project with no skills/ directory", () => { + const projectDir = join(tmpDir, "no-skills-proj"); + mkdirSync(projectDir); + writeFileSync(join(projectDir, "research-project.toml"), 'schema_version = 1\n'); + + const skills = resolveProjectSkills([projectDir]); + expect(skills).toHaveLength(0); + }); + + it("scans multiple research projects from workspace folders", () => { + const proj1 = join(tmpDir, "proj1"); + const proj2 = join(tmpDir, "proj2"); + mkdirSync(join(proj1, "skills", "skill-a"), { recursive: true }); + mkdirSync(join(proj2, "skills", "skill-b"), { recursive: true }); + writeFileSync(join(proj1, "research-project.toml"), 'schema_version = 1\n'); + writeFileSync(join(proj2, "research-project.toml"), 'schema_version = 1\n'); + writeFileSync(join(proj1, "skills", "skill-a", "SKILL.md"), `--- +name: skill-a +description: Skill A +--- +`); + writeFileSync(join(proj2, "skills", "skill-b", "SKILL.md"), `--- +name: skill-b +description: Skill B +--- +`); + + const skills = resolveProjectSkills([proj1, proj2]); + expect(skills).toHaveLength(2); + expect(skills.map((s) => s.name).sort()).toEqual(["skill-a", "skill-b"]); + }); +}); + +describe("mergeSkillEntries with project source", () => { + const shipped: SkillIndexEntry[] = [ + { source: "library", name: "debugging", description: "shipped debugging", path: "/lib/debugging/SKILL.md" }, + { source: "library", name: "tdd", description: "shipped tdd", path: "/lib/tdd/SKILL.md" }, + ]; + + it("project skills have highest priority (shadow custom, workspace, shipped)", () => { + const project: SkillIndexEntry[] = [ + { source: "project" as SkillIndexEntry["source"], name: "debugging", description: "project debugging", path: "/proj/skills/debugging/SKILL.md" }, + ]; + const merged = mergeSkillEntries(project, [], [], shipped); + const debug = merged.find((e) => e.name === "debugging"); + expect(debug).toBeDefined(); + expect(debug!.source).toBe("project"); + expect(debug!.description).toBe("project debugging"); + expect(debug!.overridesShipped).toBe(true); + }); + + it("falls back to existing priority when no project skills: custom > workspace > shipped", () => { + const custom: SkillIndexEntry[] = [ + { source: "custom", name: "debugging", description: "custom debugging", path: "/custom/debugging/SKILL.md" }, + ]; + const merged = mergeSkillEntries([], custom, [], shipped); + const debug = merged.find((e) => e.name === "debugging"); + expect(debug!.source).toBe("custom"); + expect(debug!.overridesShipped).toBe(true); + }); + + it("multi-project collision: first project in folder order wins", () => { + const project: SkillIndexEntry[] = [ + { source: "project" as SkillIndexEntry["source"], name: "analyze", description: "first", path: "/proj1/skills/analyze/SKILL.md" }, + { source: "project" as SkillIndexEntry["source"], name: "analyze", description: "second", path: "/proj2/skills/analyze/SKILL.md" }, + ]; + const merged = mergeSkillEntries(project, [], [], shipped); + const analyze = merged.find((e) => e.name === "analyze"); + expect(analyze!.description).toBe("first"); + }); +}); diff --git a/packages/extension/test/scores/user_skill_providers.test.ts b/packages/extension/test/scores/user_skill_providers.test.ts index bdea1896..79e8dca7 100644 --- a/packages/extension/test/scores/user_skill_providers.test.ts +++ b/packages/extension/test/scores/user_skill_providers.test.ts @@ -180,7 +180,7 @@ describe("mergeSkillEntries (issue #573 — shadow semantics: custom > workspace { source: "library", name: "transmon", description: "Transmon physics", path: "/lib/transmon/SKILL.md" }, ]; - const merged = mergeSkillEntries(custom, workspace, shipped); + const merged = mergeSkillEntries([], custom, workspace, shipped); // atoms from custom wins, transmon passes through expect(merged).toHaveLength(2); const atoms = merged.find((e) => e.name === "atoms")!; @@ -199,7 +199,7 @@ describe("mergeSkillEntries (issue #573 — shadow semantics: custom > workspace { source: "library", name: "tdd", description: "Standard TDD", path: "/lib/tdd/SKILL.md" }, ]; - const merged = mergeSkillEntries(custom, workspace, shipped); + const merged = mergeSkillEntries([], custom, workspace, shipped); expect(merged).toHaveLength(1); expect(merged[0].source).toBe("workspace"); expect(merged[0].description).toBe("Team TDD rules"); @@ -214,7 +214,7 @@ describe("mergeSkillEntries (issue #573 — shadow semantics: custom > workspace ]; const shipped: SkillIndexEntry[] = []; - const merged = mergeSkillEntries(custom, workspace, shipped); + const merged = mergeSkillEntries([], custom, workspace, shipped); expect(merged).toHaveLength(1); expect(merged[0].source).toBe("custom"); }); @@ -227,7 +227,7 @@ describe("mergeSkillEntries (issue #573 — shadow semantics: custom > workspace { source: "library", name: "atoms", description: "Original", path: "/lib/atoms/SKILL.md" }, ]; - const merged = mergeSkillEntries(custom, [], shipped); + const merged = mergeSkillEntries([], custom, [], shipped); const atoms = merged.find((e) => e.name === "atoms")!; // The entry should carry a flag indicating it overrides a platform skill expect((atoms as any).overridesShipped).toBe(true); diff --git a/packages/extension/test/stack_state.test.ts b/packages/extension/test/stack_state.test.ts index e3e70a50..91a30d94 100644 --- a/packages/extension/test/stack_state.test.ts +++ b/packages/extension/test/stack_state.test.ts @@ -249,6 +249,57 @@ describe("user-memory section text (parity oracle vs the retired user_splice.ts) }); }); +// ── Active Research Project injection (#670) ───────────────────────────────── + +describe("Active Research Project injection (#670)", () => { + it("project-bound session → '## Active Research Project' block in context", () => { + const projDir = mkTmp("research-proj-"); + fs.writeFileSync( + path.join(projDir, "research-project.toml"), + 'schema_version = 1\nname = "My Research"\nquestion = "Does it work?"\nstatus = "running"\n', + ); + const stubs = stubAllSeams({}); + process.env.AMICODE_WORKSPACE_FOLDERS = projDir; + try { + const block = buildStackStateBlock() ?? ""; + expect(block).toContain("## Active Research Project"); + expect(block).toContain("**My Research** (running)"); + expect(block).toContain("**Question:** Does it work?"); + expect(block).toContain(`**Path:** \`${projDir}\``); + expect(block).toContain("Hypotheses: `/ledger/hypotheses/`"); + expect(block).toContain("Experiment scripts: `/scripts/experiment/`"); + expect(block).toContain("Testbed: `/scripts/testbed/`"); + expect(block).toContain("Reports: `/reports/`"); + expect(block).toContain("`scripts/{experiment,analysis,testbed}`"); + } finally { + restoreSeams(stubs); + } + }); + + it("non-project session → no Active Research Project block", () => { + const stubs = stubAllSeams({}); + // No AMICODE_WORKSPACE_FOLDERS set (cleared by stubAllSeams) + try { + const block = buildStackStateBlock() ?? ""; + expect(block).not.toContain("## Active Research Project"); + } finally { + restoreSeams(stubs); + } + }); + + it("dev project (no research-project.toml) → no Active Research Project block", () => { + const devDir = mkTmp("dev-proj-"); + const stubs = stubAllSeams({}); + process.env.AMICODE_WORKSPACE_FOLDERS = devDir; + try { + const block = buildStackStateBlock() ?? ""; + expect(block).not.toContain("## Active Research Project"); + } finally { + restoreSeams(stubs); + } + }); +}); + // ── Caps + composition ─────────────────────────────────────────────────────── describe("caps + composition", () => { @@ -389,6 +440,7 @@ const SEAM_KEYS = [ "AMICODE_CONNECTIONS_FILE", "AMICODE_PROBLEMS_DIR", "AMICODE_RUNS_DIR", + "AMICODE_WORKSPACE_FOLDERS", ] as const; let fixtureRoot: string | undefined;