From 524d520e541fbd9b1f6dedecd57d960a170c04e8 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 17:03:05 -0400 Subject: [PATCH 01/23] chore: open integration branch for #880 Research Environments From 96b1abf56a6ec8510ea96aa5f897d4ca88f7cea6 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 17:07:48 -0400 Subject: [PATCH 02/23] feat: research environment schemas + CLI foundation (#881) - EnvironmentToml schema (validation, rendering, round-trip) - ENV_SCAFFOLD_DIRS (9 prescribed directories) - environments.toml registry (parse, render, upsert) - amico env create (scaffold + git init + register) - amico env register (validate manifest + upsert registry) - ProjectToml: add optional [environment] section - renderProjectToml: emit [environment] when present - 37 new tests (28 pure logic + 9 integration), all green Part of #880 Research Environments. Closes #881. --- packages/amico-run/src/env_verb.ts | 259 +++++++++++++++++ packages/amico-run/src/environment.ts | 199 +++++++++++++ packages/amico-run/src/project.ts | 11 + packages/amico-run/src/verbs.ts | 13 + packages/amico-run/test/env_verb.test.ts | 190 +++++++++++++ packages/amico-run/test/environment.test.ts | 300 ++++++++++++++++++++ 6 files changed, 972 insertions(+) create mode 100644 packages/amico-run/src/env_verb.ts create mode 100644 packages/amico-run/src/environment.ts create mode 100644 packages/amico-run/test/env_verb.test.ts create mode 100644 packages/amico-run/test/environment.test.ts diff --git a/packages/amico-run/src/env_verb.ts b/packages/amico-run/src/env_verb.ts new file mode 100644 index 00000000..acad284b --- /dev/null +++ b/packages/amico-run/src/env_verb.ts @@ -0,0 +1,259 @@ +// env_verb.ts — CLI wrapper for `amico env create` and `amico env register`. +// Pure logic lives in environment.ts; this module handles filesystem I/O, +// git init, flag parsing, and the verb dispatch. Part of #881. +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 { + CURRENT_ENV_SCHEMA_VERSION, + ENV_SCAFFOLD_DIRS, + nameToSlug, + parseEnvironmentRegistry, + renderEnvironmentRegistry, + renderEnvironmentToml, + validateEnvironmentToml, + type EnvironmentRegistryEntry, + type EnvironmentToml, +} from "./environment.js"; +import type { VerbResult } from "./verbs.js"; + +/** Options for DI in tests (registry path override). */ +export interface EnvVerbOptions { + registryPath?: string; +} + +function defaultRegistryPath(): string { + return join(homedir(), ".amico", "environments.toml"); +} + +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); +} + +// ── registry helpers ──────────────────────────────────────────────────────── + +function readRegistry(path: string): EnvironmentRegistryEntry[] { + if (!existsSync(path)) return []; + try { + return parseEnvironmentRegistry(readFileSync(path, "utf8")); + } catch { + return []; + } +} + +function writeRegistry(path: string, entries: EnvironmentRegistryEntry[]): void { + const dir = join(path, ".."); + mkdirSync(dir, { recursive: true }); + writeFileSync(path, renderEnvironmentRegistry(entries)); +} + +function upsertRegistryEntry( + registryPath: string, + entry: EnvironmentRegistryEntry, +): { replaced: boolean } { + const entries = readRegistry(registryPath); + const idx = entries.findIndex((e) => e.slug === entry.slug); + const replaced = idx >= 0; + if (replaced) { + entries[idx] = entry; + } else { + entries.push(entry); + } + writeRegistry(registryPath, entries); + return { replaced }; +} + +// ── create ────────────────────────────────────────────────────────────────── + +export function envCreate(argv: string[], opts?: EnvVerbOptions): VerbResult { + const fail = (error: string): VerbResult => ({ + json: { verb: "env", subcommand: "create", error }, + code: 64, + }); + + const name = positionalArg(argv); + if (!name) return fail("environment name is required: amico env create "); + + const slug = nameToSlug(name); + const envDir = resolve(flagValue(argv, "--path") ?? join(process.cwd(), slug)); + + // Idempotent: if research-environment.toml already exists, validate and return + const tomlPath = join(envDir, "research-environment.toml"); + if (existsSync(tomlPath)) { + try { + const existing = parseToml(readFileSync(tomlPath, "utf8")) as unknown as EnvironmentToml; + const v = validateEnvironmentToml(existing); + if (v.ok) { + return { + json: { + verb: "env", + subcommand: "create", + created: false, + idempotent: true, + path: envDir, + slug: existing.slug, + }, + code: 0, + }; + } + } catch { + // Invalid TOML; fall through and overwrite + } + } + + const platform = flagValue(argv, "--platform"); + const field = flagValue(argv, "--field"); + const author = flagValue(argv, "--author"); + + const env: EnvironmentToml = { + schema_version: CURRENT_ENV_SCHEMA_VERSION, + name, + slug, + created: today(), + ...(platform || field ? { domain: { ...(platform ? { platform } : {}), ...(field ? { field } : {}) } } : {}), + ...(author ? { authors: { lead: author } } : {}), + }; + + // Create directory and scaffold + try { + mkdirSync(envDir, { recursive: true }); + } catch (e) { + return fail(`failed to create directory: ${e instanceof Error ? e.message : String(e)}`); + } + + // Scaffold directories + try { + for (const dir of ENV_SCAFFOLD_DIRS) { + mkdirSync(join(envDir, dir), { recursive: true }); + } + } catch (e) { + return fail(`failed to scaffold directories: ${e instanceof Error ? e.message : String(e)}`); + } + + // Write manifest + try { + writeFileSync(tomlPath, renderEnvironmentToml(env)); + } catch (e) { + return fail(`failed to write manifest: ${e instanceof Error ? e.message : String(e)}`); + } + + // git init + initial commit + try { + if (!existsSync(join(envDir, ".git"))) { + execFileSync("git", ["init"], { cwd: envDir, stdio: "ignore" }); + execFileSync("git", ["add", "."], { cwd: envDir, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", `init: scaffold research environment "${name}"`], { + cwd: envDir, + stdio: "ignore", + }); + } + } catch (e) { + // git failure is a warning, not a hard error + const registryPath = opts?.registryPath ?? defaultRegistryPath(); + upsertRegistryEntry(registryPath, { slug, path: envDir }); + return { + json: { + verb: "env", + subcommand: "create", + created: true, + path: envDir, + slug, + warning: `git init failed: ${e instanceof Error ? e.message : String(e)}`, + }, + code: 0, + }; + } + + // Register in environments.toml + const registryPath = opts?.registryPath ?? defaultRegistryPath(); + upsertRegistryEntry(registryPath, { slug, path: envDir }); + + return { + json: { + verb: "env", + subcommand: "create", + created: true, + path: envDir, + slug, + }, + code: 0, + }; +} + +// ── register ──────────────────────────────────────────────────────────────── + +export function envRegister(argv: string[], opts?: EnvVerbOptions): VerbResult { + const fail = (error: string): VerbResult => ({ + json: { verb: "env", subcommand: "register", error }, + code: 64, + }); + + const dirArg = positionalArg(argv); + if (!dirArg) return fail("path is required: amico env register "); + + const dir = resolve(dirArg); + if (!existsSync(dir)) return fail(`directory not found: ${dir}`); + + const tomlPath = join(dir, "research-environment.toml"); + if (!existsSync(tomlPath)) return fail(`no research-environment.toml found in ${dir}`); + + let manifest: EnvironmentToml; + try { + manifest = parseToml(readFileSync(tomlPath, "utf8")) as unknown as EnvironmentToml; + const v = validateEnvironmentToml(manifest); + if (!v.ok) return fail(`invalid manifest: ${v.errors.join("; ")}`); + } catch (e) { + return fail(`failed to read manifest: ${e instanceof Error ? e.message : String(e)}`); + } + + const registryPath = opts?.registryPath ?? defaultRegistryPath(); + const { replaced } = upsertRegistryEntry(registryPath, { slug: manifest.slug, path: dir }); + + return { + json: { + verb: "env", + subcommand: "register", + registered: true, + slug: manifest.slug, + path: dir, + ...(replaced ? { replaced: true } : {}), + }, + code: 0, + }; +} + +// ── dispatch ──────────────────────────────────────────────────────────────── + +export function envVerb(argv: string[]): VerbResult { + const sub = argv[0]; + const rest = argv.slice(1); + if (sub === "create") return envCreate(rest); + if (sub === "register") return envRegister(rest); + return { + json: { + verb: "env", + error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, + usage: "amico env create [--path ] [--platform

] [--field ] [--author ] | amico env register ", + }, + code: 64, + }; +} diff --git a/packages/amico-run/src/environment.ts b/packages/amico-run/src/environment.ts new file mode 100644 index 00000000..5919e0a7 --- /dev/null +++ b/packages/amico-run/src/environment.ts @@ -0,0 +1,199 @@ +// environment.ts — pure logic for research environment entities (issue #881). +// +// Schema definition, validation, slug generation, scaffolding data, TOML +// rendering, and registry parsing. NO filesystem I/O — that lives in +// env_verb.ts. This module is the unit-testable core. + +import { parse as parseToml } from "smol-toml"; + +// ── schema types ──────────────────────────────────────────────────────────── + +export const CURRENT_ENV_SCHEMA_VERSION = 1; + +export interface EnvironmentToml { + schema_version: number; + name: string; + slug: string; + created: string; // YYYY-MM-DD + description?: string; + tags?: string[]; + domain?: { platform?: string; field?: string }; + authors?: { lead?: string; collaborators?: string[] }; + repo?: { remote?: string }; + paths?: Record; +} + +export interface EnvironmentRegistryEntry { + slug: string; + path: string; +} + +/** The prescribed directory layout for a Research Environment (PRD #880). */ +export const ENV_SCAFFOLD_DIRS = [ + "insights", + "methods", + "context", + "literature", + "experiments", + "lib", + "templates", + "config", + "results", +] as const; + +// ── validation ────────────────────────────────────────────────────────────── + +export type ValidationResult = + | { ok: true } + | { ok: false; errors: string[] }; + +const REQUIRED_FIELDS: (keyof EnvironmentToml)[] = [ + "schema_version", + "name", + "slug", + "created", +]; + +export function validateEnvironmentToml(data: unknown): ValidationResult { + if (typeof data !== "object" || data === null) { + return { ok: false, errors: ["research-environment.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.schema_version === "number" && + obj.schema_version > CURRENT_ENV_SCHEMA_VERSION + ) { + errors.push( + `schema_version ${obj.schema_version} exceeds current (${CURRENT_ENV_SCHEMA_VERSION}) — manifest is unreadable by this version`, + ); + } + + 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, ""); +} + +// ── TOML rendering ────────────────────────────────────────────────────────── + +/** Render an EnvironmentToml to a TOML string. Hand-rendered for readability. */ +export function renderEnvironmentToml(e: EnvironmentToml): string { + const lines: string[] = []; + lines.push(`schema_version = ${e.schema_version}`); + lines.push(`name = ${q(e.name)}`); + lines.push(`slug = ${q(e.slug)}`); + lines.push(`created = ${q(e.created)}`); + + if (e.description) { + lines.push(`description = ${q(e.description)}`); + } + + if (e.tags && e.tags.length > 0) { + lines.push(`tags = [${e.tags.map(q).join(", ")}]`); + } + + if (e.domain) { + lines.push(""); + lines.push("[domain]"); + if (e.domain.platform) lines.push(`platform = ${q(e.domain.platform)}`); + if (e.domain.field) lines.push(`field = ${q(e.domain.field)}`); + } + + if (e.authors) { + lines.push(""); + lines.push("[authors]"); + if (e.authors.lead) lines.push(`lead = ${q(e.authors.lead)}`); + if (e.authors.collaborators && e.authors.collaborators.length > 0) { + lines.push(`collaborators = [${e.authors.collaborators.map(q).join(", ")}]`); + } + } + + if (e.repo) { + lines.push(""); + lines.push("[repo]"); + if (e.repo.remote) lines.push(`remote = ${q(e.repo.remote)}`); + } + + if (e.paths && Object.keys(e.paths).length > 0) { + lines.push(""); + lines.push("[paths]"); + for (const [key, value] of Object.entries(e.paths)) { + lines.push(`${key} = ${q(value)}`); + } + } + + lines.push(""); // trailing newline + return lines.join("\n"); +} + +function q(s: string): string { + return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +// ── registry parsing ──────────────────────────────────────────────────────── + +/** Parse the `~/.amico/environments.toml` registry. */ +export function parseEnvironmentRegistry(toml: string): EnvironmentRegistryEntry[] { + if (!toml.trim()) return []; + + let parsed: Record; + try { + parsed = parseToml(toml) as Record; + } catch { + return []; + } + + const envs = parsed.environments; + if (!Array.isArray(envs)) return []; + + const entries: EnvironmentRegistryEntry[] = []; + for (const entry of envs) { + if ( + typeof entry === "object" && + entry !== null && + typeof (entry as Record).slug === "string" && + typeof (entry as Record).path === "string" + ) { + entries.push({ + slug: (entry as Record).slug as string, + path: (entry as Record).path as string, + }); + } + } + + return entries; +} + +/** Render an environment registry to TOML. */ +export function renderEnvironmentRegistry(entries: EnvironmentRegistryEntry[]): string { + if (entries.length === 0) return ""; + + const lines: string[] = []; + for (const entry of entries) { + lines.push("[[environments]]"); + lines.push(`slug = ${q(entry.slug)}`); + lines.push(`path = ${q(entry.path)}`); + lines.push(""); + } + return lines.join("\n"); +} diff --git a/packages/amico-run/src/project.ts b/packages/amico-run/src/project.ts index c03a08ef..04f1519e 100644 --- a/packages/amico-run/src/project.ts +++ b/packages/amico-run/src/project.ts @@ -40,6 +40,10 @@ export interface ProjectToml { related_projects?: string[]; doi?: string; }; + environment?: { + slug: string; + path?: string; + }; } // ── validation ────────────────────────────────────────────────────────────── @@ -167,6 +171,13 @@ export function renderProjectToml(p: ProjectToml): string { if (p.links.doi) lines.push(`doi = ${q(p.links.doi)}`); } + if (p.environment) { + lines.push(""); + lines.push("[environment]"); + lines.push(`slug = ${q(p.environment.slug)}`); + if (p.environment.path) lines.push(`path = ${q(p.environment.path)}`); + } + lines.push(""); // trailing newline return lines.join("\n"); } diff --git a/packages/amico-run/src/verbs.ts b/packages/amico-run/src/verbs.ts index 66f1548e..dd4388c8 100644 --- a/packages/amico-run/src/verbs.ts +++ b/packages/amico-run/src/verbs.ts @@ -26,6 +26,7 @@ import { planVerb } from "./plan_verb.js"; import { handoffVerb } from "./handoff_verb.js"; import { campaignVerb } from "./campaign_verb.js"; import { projectVerb } from "./project_verb.js"; +import { envVerb } from "./env_verb.js"; import { sessionsVerb } from "./sessions_verb.js"; import { sotaVerb } from "./sota_verb.js"; export interface VerbResult { @@ -263,6 +264,17 @@ const sota: Verb = { run: sotaVerb, }; +// env — the research-environment entity: create (scaffold + git init + +// registry) and register (add an existing environment to the local registry). +// Part of #881 (sub-issue of #880 Research Environments). +const env: Verb = { + name: "env", + summary: "create a scaffolded research environment / register an existing one in the local registry", + generalizes: "the amicode research-environment entity lifecycle (PRD #880)", + slice: "research environments (#881)", + run: envVerb, +}; + export const SPINE_VERBS: Verb[] = [ catalog, vault, @@ -277,6 +289,7 @@ export const SPINE_VERBS: Verb[] = [ papers, campaign, project, + env, sessions, sota, ]; diff --git a/packages/amico-run/test/env_verb.test.ts b/packages/amico-run/test/env_verb.test.ts new file mode 100644 index 00000000..b6d3222c --- /dev/null +++ b/packages/amico-run/test/env_verb.test.ts @@ -0,0 +1,190 @@ +// `amico env create` / `amico env register` — integration tests for the +// environment CLI verbs. Filesystem I/O in env_verb.ts. +// Part of #881 (sub-issue of #880 Research Environments). +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 { parse as parseToml } from "smol-toml"; + +import { envCreate, envRegister } from "../src/env_verb.js"; +import { ENV_SCAFFOLD_DIRS, renderEnvironmentToml, type EnvironmentToml } from "../src/environment.js"; + +// ── integration: env create verb ─────────────────────────────────────────── + +describe("envCreate", () => { + let tmpDir: string; + let registryPath: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "amico-env-create-")); + registryPath = join(tmpDir, "environments.toml"); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("scaffolds directory with manifest, dirs, and git init (AC-22)", () => { + const envDir = join(tmpDir, "transmon-oc"); + const result = envCreate( + ["Transmon Optimal Control", "--path", envDir], + { registryPath }, + ); + + expect(result.code).toBe(0); + const json = result.json as Record; + expect(json.created).toBe(true); + expect(json.slug).toBe("transmon-optimal-control"); + + // Manifest exists and is valid TOML + const manifestPath = join(envDir, "research-environment.toml"); + expect(existsSync(manifestPath)).toBe(true); + const manifest = parseToml(readFileSync(manifestPath, "utf8")); + expect(manifest.schema_version).toBe(1); + expect(manifest.name).toBe("Transmon Optimal Control"); + expect(manifest.slug).toBe("transmon-optimal-control"); + + // All scaffold dirs exist + for (const dir of ENV_SCAFFOLD_DIRS) { + expect(existsSync(join(envDir, dir))).toBe(true); + } + + // Git initialized + expect(existsSync(join(envDir, ".git"))).toBe(true); + + // Registry updated + expect(existsSync(registryPath)).toBe(true); + const reg = readFileSync(registryPath, "utf8"); + expect(reg).toContain("transmon-optimal-control"); + expect(reg).toContain(envDir); + }); + + it("passes --platform and --field to domain section", () => { + const envDir = join(tmpDir, "domain-test"); + envCreate( + ["Domain Test", "--path", envDir, "--platform", "transmon", "--field", "quantum-control"], + { registryPath }, + ); + + const manifest = parseToml(readFileSync(join(envDir, "research-environment.toml"), "utf8")); + const domain = manifest.domain as Record; + expect(domain.platform).toBe("transmon"); + expect(domain.field).toBe("quantum-control"); + }); + + it("passes --author to authors section", () => { + const envDir = join(tmpDir, "author-test"); + envCreate( + ["Author Test", "--path", envDir, "--author", "JJ Lee"], + { registryPath }, + ); + + const manifest = parseToml(readFileSync(join(envDir, "research-environment.toml"), "utf8")); + const authors = manifest.authors as Record; + expect(authors.lead).toBe("JJ Lee"); + }); + + it("is idempotent when manifest already exists", () => { + const envDir = join(tmpDir, "idempotent"); + const first = envCreate(["Idempotent", "--path", envDir], { registryPath }); + expect((first.json as Record).created).toBe(true); + + const second = envCreate(["Idempotent", "--path", envDir], { registryPath }); + expect(second.code).toBe(0); + expect((second.json as Record).idempotent).toBe(true); + }); + + it("returns error when no name is provided", () => { + const result = envCreate(["--path", join(tmpDir, "bad")], { registryPath }); + expect(result.code).toBe(64); + expect((result.json as Record).error).toBeDefined(); + }); +}); + +// ── integration: env register verb ───────────────────────────────────────── + +describe("envRegister", () => { + let tmpDir: string; + let registryPath: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "amico-env-register-")); + registryPath = join(tmpDir, "environments.toml"); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + /** Helper: create a minimal valid environment directory. */ + function makeEnvDir(slug: string): string { + const dir = join(tmpDir, slug); + mkdirSync(dir, { recursive: true }); + const manifest: EnvironmentToml = { + schema_version: 1, + name: slug.replace(/-/g, " "), + slug, + created: "2026-09-07", + }; + writeFileSync(join(dir, "research-environment.toml"), renderEnvironmentToml(manifest)); + return dir; + } + + it("registers an existing environment in the registry (AC-43)", () => { + const dir = makeEnvDir("my-env"); + const result = envRegister([dir], { registryPath }); + + expect(result.code).toBe(0); + const json = result.json as Record; + expect(json.registered).toBe(true); + expect(json.slug).toBe("my-env"); + + // Registry file contains the entry + const reg = readFileSync(registryPath, "utf8"); + expect(reg).toContain("my-env"); + expect(reg).toContain(dir); + }); + + it("exits non-zero if no valid manifest found (AC-43)", () => { + const emptyDir = join(tmpDir, "no-manifest"); + mkdirSync(emptyDir, { recursive: true }); + + const result = envRegister([emptyDir], { registryPath }); + expect(result.code).toBe(64); + }); + + it("replaces existing entry on slug collision + emits notice (AC-44)", () => { + const dir1 = makeEnvDir("collision-env"); + envRegister([dir1], { registryPath }); + + // Create a second directory with the same slug + const dir2 = join(tmpDir, "collision-env-v2"); + mkdirSync(dir2, { recursive: true }); + const manifest: EnvironmentToml = { + schema_version: 1, + name: "collision env", + slug: "collision-env", + created: "2026-09-07", + }; + writeFileSync(join(dir2, "research-environment.toml"), renderEnvironmentToml(manifest)); + + const result = envRegister([dir2], { registryPath }); + expect(result.code).toBe(0); + const json = result.json as Record; + expect(json.replaced).toBe(true); + + // Registry should have only one entry for this slug, pointing to dir2 + const reg = readFileSync(registryPath, "utf8"); + expect(reg).toContain(dir2); + // Parse and verify exactly one entry with the new path + const entries = parseToml(reg) as { environments: Array<{ slug: string; path: string }> }; + expect(entries.environments).toHaveLength(1); + expect(entries.environments[0].path).toBe(dir2); + }); + + it("exits non-zero for a nonexistent path", () => { + const result = envRegister([join(tmpDir, "does-not-exist")], { registryPath }); + expect(result.code).toBe(64); + }); +}); diff --git a/packages/amico-run/test/environment.test.ts b/packages/amico-run/test/environment.test.ts new file mode 100644 index 00000000..e3b6008e --- /dev/null +++ b/packages/amico-run/test/environment.test.ts @@ -0,0 +1,300 @@ +// `amico env` — research environment entity: schema validation, scaffold data, +// TOML rendering, and registry parsing. Pure logic in environment.ts. +// Part of #881 (sub-issue of #880 Research Environments). +import { describe, it, expect } from "vitest"; + +import { + CURRENT_ENV_SCHEMA_VERSION, + validateEnvironmentToml, + renderEnvironmentToml, + parseEnvironmentRegistry, + renderEnvironmentRegistry, + ENV_SCAFFOLD_DIRS, + type EnvironmentToml, + type EnvironmentRegistryEntry, +} from "../src/environment.js"; +import { parse as parseToml } from "smol-toml"; + +// ── pure logic: schema validation ────────────────────────────────────────── + +describe("validateEnvironmentToml", () => { + const valid: EnvironmentToml = { + schema_version: 1, + name: "Transmon Optimal Control", + slug: "transmon-optimal-control", + created: "2026-09-07", + }; + + it("accepts a valid manifest with all required fields", () => { + const result = validateEnvironmentToml(valid); + expect(result.ok).toBe(true); + }); + + it("accepts a manifest with all optional fields", () => { + const full: EnvironmentToml = { + ...valid, + description: "Shared knowledge for transmon gate synthesis", + tags: ["transmon", "optimal-control"], + domain: { platform: "transmon", field: "quantum-control" }, + authors: { lead: "JJ Lee", collaborators: ["Alice", "Bob"] }, + repo: { remote: "git@github.com:harmoniqs/transmon-oc.git" }, + paths: { lib: "julia_lib", results: "data/results" }, + }; + const result = validateEnvironmentToml(full); + expect(result.ok).toBe(true); + }); + + it("rejects non-object input", () => { + const result = validateEnvironmentToml("not an object"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors[0]).toContain("object"); + }); + + it("rejects null input", () => { + const result = validateEnvironmentToml(null); + expect(result.ok).toBe(false); + }); + + it("rejects missing required field: schema_version", () => { + const { schema_version: _, ...bad } = valid; + const result = validateEnvironmentToml(bad); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors.some((e: string) => e.includes("schema_version"))).toBe(true); + }); + + it("rejects missing required field: name", () => { + const { name: _, ...bad } = valid; + const result = validateEnvironmentToml(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: slug", () => { + const { slug: _, ...bad } = valid; + const result = validateEnvironmentToml(bad); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors.some((e: string) => e.includes("slug"))).toBe(true); + }); + + it("rejects missing required field: created", () => { + const { created: _, ...bad } = valid; + const result = validateEnvironmentToml(bad); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors.some((e: string) => e.includes("created"))).toBe(true); + }); + + it("rejects non-numeric schema_version", () => { + const bad = { ...valid, schema_version: "one" }; + const result = validateEnvironmentToml(bad); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors.some((e: string) => e.includes("schema_version"))).toBe(true); + }); + + it("rejects schema_version exceeding CURRENT_ENV_SCHEMA_VERSION (AC-54)", () => { + const bad = { ...valid, schema_version: CURRENT_ENV_SCHEMA_VERSION + 1 }; + const result = validateEnvironmentToml(bad); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors.some((e: string) => e.includes("unreadable"))).toBe(true); + }); + + it("tolerates unknown extra fields (forward-compatible)", () => { + const extended = { ...valid, future_field: "surprise" } as unknown as EnvironmentToml; + const result = validateEnvironmentToml(extended); + expect(result.ok).toBe(true); + }); + + it("collects multiple errors", () => { + const result = validateEnvironmentToml({}); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors.length).toBeGreaterThanOrEqual(4); + }); +}); + +// ── pure logic: TOML rendering round-trip ────────────────────────────────── + +describe("renderEnvironmentToml", () => { + const env: EnvironmentToml = { + schema_version: 1, + name: "Transmon Optimal Control", + slug: "transmon-optimal-control", + created: "2026-09-07", + }; + + it("produces parseable TOML with all required fields", () => { + const toml = renderEnvironmentToml(env); + expect(toml).toContain("schema_version = 1"); + expect(toml).toContain('name = "Transmon Optimal Control"'); + expect(toml).toContain('slug = "transmon-optimal-control"'); + expect(toml).toContain('created = "2026-09-07"'); + // Must be parseable by smol-toml + const parsed = parseToml(toml); + expect(parsed.schema_version).toBe(1); + expect(parsed.name).toBe("Transmon Optimal Control"); + }); + + it("includes optional sections when present", () => { + const full: EnvironmentToml = { + ...env, + description: "Shared knowledge base", + tags: ["transmon", "gates"], + domain: { platform: "transmon", field: "quantum-control" }, + authors: { lead: "JJ Lee", collaborators: ["Alice"] }, + repo: { remote: "git@github.com:org/repo.git" }, + paths: { lib: "julia_lib" }, + }; + const toml = renderEnvironmentToml(full); + expect(toml).toContain('[domain]'); + expect(toml).toContain('platform = "transmon"'); + expect(toml).toContain('[authors]'); + expect(toml).toContain('lead = "JJ Lee"'); + expect(toml).toContain('[repo]'); + expect(toml).toContain('[paths]'); + expect(toml).toContain('lib = "julia_lib"'); + }); + + it("round-trips through parse", () => { + const full: EnvironmentToml = { + ...env, + description: "Round trip test", + tags: ["a", "b"], + domain: { platform: "transmon" }, + authors: { lead: "Test" }, + }; + const toml = renderEnvironmentToml(full); + const parsed = parseToml(toml) as unknown as EnvironmentToml; + expect(parsed.schema_version).toBe(full.schema_version); + expect(parsed.name).toBe(full.name); + expect(parsed.slug).toBe(full.slug); + expect(parsed.created).toBe(full.created); + expect(parsed.description).toBe(full.description); + expect(parsed.tags).toEqual(full.tags); + }); + + it("omits optional sections when not present", () => { + const toml = renderEnvironmentToml(env); + expect(toml).not.toContain("[domain]"); + expect(toml).not.toContain("[authors]"); + expect(toml).not.toContain("[repo]"); + expect(toml).not.toContain("[paths]"); + }); +}); + +// ── pure logic: registry parsing ─────────────────────────────────────────── + +describe("parseEnvironmentRegistry", () => { + it("parses a valid registry with one entry", () => { + const toml = `[[environments]]\nslug = "my-env"\npath = "/tmp/my-env"\n`; + const entries = parseEnvironmentRegistry(toml); + expect(entries).toHaveLength(1); + expect(entries[0].slug).toBe("my-env"); + expect(entries[0].path).toBe("/tmp/my-env"); + }); + + it("parses a valid registry with multiple entries", () => { + const toml = `[[environments]]\nslug = "env-a"\npath = "/a"\n\n[[environments]]\nslug = "env-b"\npath = "/b"\n`; + const entries = parseEnvironmentRegistry(toml); + expect(entries).toHaveLength(2); + expect(entries[0].slug).toBe("env-a"); + expect(entries[1].slug).toBe("env-b"); + }); + + it("returns empty array for empty string", () => { + expect(parseEnvironmentRegistry("")).toEqual([]); + }); + + it("returns empty array when no environments key", () => { + expect(parseEnvironmentRegistry("# empty file\n")).toEqual([]); + }); + + it("skips entries missing slug or path", () => { + const toml = `[[environments]]\nslug = "good"\npath = "/good"\n\n[[environments]]\nslug = "bad"\n`; + const entries = parseEnvironmentRegistry(toml); + expect(entries).toHaveLength(1); + expect(entries[0].slug).toBe("good"); + }); +}); + +describe("renderEnvironmentRegistry", () => { + it("renders entries as [[environments]] array", () => { + const entries: EnvironmentRegistryEntry[] = [ + { slug: "env-a", path: "/tmp/env-a" }, + { slug: "env-b", path: "/tmp/env-b" }, + ]; + const toml = renderEnvironmentRegistry(entries); + expect(toml).toContain("[[environments]]"); + expect(toml).toContain('slug = "env-a"'); + expect(toml).toContain('path = "/tmp/env-a"'); + }); + + it("round-trips through parse", () => { + const entries: EnvironmentRegistryEntry[] = [ + { slug: "round-trip", path: "/home/user/round-trip" }, + ]; + const toml = renderEnvironmentRegistry(entries); + const parsed = parseEnvironmentRegistry(toml); + expect(parsed).toEqual(entries); + }); + + it("renders empty string for empty array", () => { + const toml = renderEnvironmentRegistry([]); + expect(toml.trim()).toBe(""); + }); +}); + +// ── pure logic: scaffold dirs ────────────────────────────────────────────── + +describe("ENV_SCAFFOLD_DIRS", () => { + it("contains all 9 prescribed directories", () => { + expect(ENV_SCAFFOLD_DIRS).toHaveLength(9); + expect(ENV_SCAFFOLD_DIRS).toContain("insights"); + expect(ENV_SCAFFOLD_DIRS).toContain("methods"); + expect(ENV_SCAFFOLD_DIRS).toContain("context"); + expect(ENV_SCAFFOLD_DIRS).toContain("literature"); + expect(ENV_SCAFFOLD_DIRS).toContain("experiments"); + expect(ENV_SCAFFOLD_DIRS).toContain("lib"); + expect(ENV_SCAFFOLD_DIRS).toContain("templates"); + expect(ENV_SCAFFOLD_DIRS).toContain("config"); + expect(ENV_SCAFFOLD_DIRS).toContain("results"); + }); +}); + +// ── ProjectToml environment field ────────────────────────────────────────── + +import { renderProjectToml, type ProjectToml } from "../src/project.js"; + +describe("renderProjectToml [environment]", () => { + const base: ProjectToml = { + schema_version: 1, + name: "Test", + slug: "test", + question: "Does it work?", + status: "proposing", + created: "2026-09-07", + }; + + it("omits [environment] section when not present", () => { + const toml = renderProjectToml(base); + expect(toml).not.toContain("[environment]"); + }); + + it("includes [environment] section when present", () => { + const p: ProjectToml = { + ...base, + environment: { slug: "transmon-oc" }, + }; + const toml = renderProjectToml(p); + expect(toml).toContain("[environment]"); + expect(toml).toContain('slug = "transmon-oc"'); + }); + + it("includes environment path when provided", () => { + const p: ProjectToml = { + ...base, + environment: { slug: "transmon-oc", path: "/home/user/transmon-oc" }, + }; + const toml = renderProjectToml(p); + expect(toml).toContain("[environment]"); + expect(toml).toContain('slug = "transmon-oc"'); + expect(toml).toContain('path = "/home/user/transmon-oc"'); + }); +}); From 240b124a9ddd42da2c9c9d9abaa41604fb9e5606 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 17:15:53 -0400 Subject: [PATCH 03/23] feat: environment resolution module + detection update (#882) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - detectProjectType: add 'environment' type (research-environment.toml) - environment takes priority over research when both manifests present - resolveEnvironment: four-strategy resolution (walk-up, explicit path, workspace scan, registry) with per-project-path caching - walk-up stops at workspace folder root (AC-53) - malformed TOML / future schema_version → null + console.warn - sidebar_bridge: TreeRoot.projectType accepts 'environment' - sidebar_tree_service: filter out environment folders from roots (AC-49) - workspace_projects: filter out environment folders (AC-49) - sidebar_view: watcher tracks research-environment.toml changes + invalidates environment cache on manifest changes - 18 new tests (12 resolution + 4 detection + 2 filtering), all green Part of #880 Research Environments. Closes #882. --- packages/extension/src/project/detect.ts | 24 +- .../src/project/resolve_environment.ts | 270 ++++++++++++++++++ packages/extension/src/sidebar_bridge.ts | 2 +- .../extension/src/sidebar_tree_service.ts | 7 +- packages/extension/src/sidebar_view.ts | 4 +- packages/extension/src/workspace_projects.ts | 3 + .../extension/test/project/detect.test.ts | 13 + .../test/project/resolve_environment.test.ts | 211 ++++++++++++++ .../extension/test/workspace_projects.test.ts | 33 +++ 9 files changed, 556 insertions(+), 11 deletions(-) create mode 100644 packages/extension/src/project/resolve_environment.ts create mode 100644 packages/extension/test/project/resolve_environment.test.ts diff --git a/packages/extension/src/project/detect.ts b/packages/extension/src/project/detect.ts index 4a072f82..c11010c4 100644 --- a/packages/extension/src/project/detect.ts +++ b/packages/extension/src/project/detect.ts @@ -1,23 +1,33 @@ -// detect.ts — project type detection (#666). +// detect.ts — project type detection (#666, #882). // A Research Project is identified by a `research-project.toml` manifest at its root. +// A Research Environment is identified by a `research-environment.toml` manifest. // 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"; +export type ProjectType = "research" | "dev" | "environment"; /** - * 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). + * Detect whether a directory is a Research Environment, Research Project, + * or a Dev Project. + * + * Detection order: + * 1. `research-environment.toml` exists → `"environment"` + * 2. `research-project.toml` exists → `"research"` + * 3. Otherwise → `"dev"` + * + * Environment takes priority (handles the monorepo root case where both + * manifests might coexist). * * Re-evaluated on each call — no caching — so a directory that gains - * `research-project.toml` after initial registration updates its type on next + * a manifest 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"; + if (existsSync(join(dir, "research-environment.toml"))) return "environment"; + if (existsSync(join(dir, "research-project.toml"))) return "research"; + return "dev"; } catch { return "dev"; } diff --git a/packages/extension/src/project/resolve_environment.ts b/packages/extension/src/project/resolve_environment.ts new file mode 100644 index 00000000..8f1ea809 --- /dev/null +++ b/packages/extension/src/project/resolve_environment.ts @@ -0,0 +1,270 @@ +// resolve_environment.ts — Four-strategy environment resolution for a given +// project path. Part of #882 (sub-issue of #880 Research Environments). +// +// Resolution order: +// 1. Walk-up: look for research-environment.toml in ancestor directories, +// stopping at the workspace folder root +// 2. Explicit path: read [environment].path from the project's TOML +// 3. Workspace scan: check sibling workspace folders for a matching slug +// 4. Registry: look up the slug in ~/.amico/environments.toml +// +// Walk-up wins over explicit path when both resolve (physical topology is +// authoritative). All failures are null + console.warn — never thrown errors. +// +// NO VS Code API dependency — `workspaceRoots` is passed by the caller. + +import { existsSync, readFileSync } from "node:fs"; +import { join, dirname, resolve } from "node:path"; +import { homedir } from "node:os"; +import { parse as parseToml } from "smol-toml"; + +// ── Types ─────────────────────────────────────────────────────────────────── + +export interface ResolvedEnvironment { + path: string; // absolute path to environment root + slug: string; // from manifest + name: string; // from manifest + schemaVersion: number; // validated <= KNOWN_VERSION +} + +export interface ResolveOptions { + /** Override the registry path (for testing). */ + registryPath?: string; +} + +// ── Constants ─────────────────────────────────────────────────────────────── + +const KNOWN_SCHEMA_VERSION = 1; +const ENV_MANIFEST = "research-environment.toml"; +const PROJECT_MANIFEST = "research-project.toml"; + +// ── Cache ─────────────────────────────────────────────────────────────────── + +const cache = new Map(); + +export function invalidateEnvironmentCache(): void { + cache.clear(); +} + +// ── Manifest reading ──────────────────────────────────────────────────────── + +function readEnvManifest(dir: string): ResolvedEnvironment | null { + const manifestPath = join(dir, ENV_MANIFEST); + if (!existsSync(manifestPath)) return null; + + let raw: string; + try { + raw = readFileSync(manifestPath, "utf8"); + } catch { + console.warn(`amicode: failed to read ${manifestPath}`); + return null; + } + + let parsed: Record; + try { + parsed = parseToml(raw) as Record; + } catch { + console.warn(`amicode: malformed TOML in ${manifestPath}`); + return null; + } + + const schemaVersion = parsed.schema_version; + if (typeof schemaVersion !== "number") { + console.warn(`amicode: missing schema_version in ${manifestPath}`); + return null; + } + if (schemaVersion > KNOWN_SCHEMA_VERSION) { + console.warn( + `amicode: ${manifestPath} has schema_version ${schemaVersion} (known: ${KNOWN_SCHEMA_VERSION}) — treating as unreadable`, + ); + return null; + } + + const slug = parsed.slug; + const name = parsed.name; + if (typeof slug !== "string" || typeof name !== "string") { + console.warn(`amicode: missing slug or name in ${manifestPath}`); + return null; + } + + return { + path: dir, + slug, + name, + schemaVersion, + }; +} + +/** Read the [environment] section from a project's research-project.toml. */ +function readProjectEnvironmentSection( + projectDir: string, +): { slug: string; path?: string } | null { + const tomlPath = join(projectDir, PROJECT_MANIFEST); + if (!existsSync(tomlPath)) return null; + + try { + const raw = readFileSync(tomlPath, "utf8"); + const parsed = parseToml(raw) as Record; + const env = parsed.environment as Record | undefined; + if (!env || typeof env.slug !== "string") return null; + return { + slug: env.slug, + path: typeof env.path === "string" ? env.path : undefined, + }; + } catch { + return null; + } +} + +// ── Strategy implementations ──────────────────────────────────────────────── + +/** Strategy 1: Walk up from projectDir, stopping at any workspace root. */ +function walkUp( + projectDir: string, + workspaceRoots: string[], +): ResolvedEnvironment | null { + const rootSet = new Set(workspaceRoots.map((r) => resolve(r))); + let current = resolve(projectDir); + + // Walk up, including the project dir itself (but typically skipped since + // a project dir doesn't have research-environment.toml) + while (true) { + const env = readEnvManifest(current); + if (env) return env; + + // Stop if we've reached a workspace root + if (rootSet.has(current)) break; + + const parent = dirname(current); + if (parent === current) break; // filesystem root + current = parent; + } + + return null; +} + +/** Strategy 2: Resolve via [environment].path in the project TOML. */ +function explicitPath(projectDir: string): ResolvedEnvironment | null { + const section = readProjectEnvironmentSection(projectDir); + if (!section?.path) return null; + + const envDir = resolve(section.path); + if (!existsSync(envDir)) { + console.warn(`amicode: [environment].path "${section.path}" does not exist`); + return null; + } + + const env = readEnvManifest(envDir); + if (!env) { + console.warn(`amicode: no valid manifest at [environment].path "${section.path}"`); + } + return env; +} + +/** Strategy 3: Scan workspace folders for one whose manifest slug matches. */ +function workspaceScan( + projectDir: string, + workspaceRoots: string[], +): ResolvedEnvironment | null { + const section = readProjectEnvironmentSection(projectDir); + if (!section) return null; + + for (const root of workspaceRoots) { + const resolved = resolve(root); + if (resolved === resolve(projectDir)) continue; // skip self + const env = readEnvManifest(resolved); + if (env && env.slug === section.slug) return env; + } + + return null; +} + +/** Strategy 4: Look up the slug in the environment registry. */ +function registryLookup( + projectDir: string, + registryPath: string, +): ResolvedEnvironment | null { + const section = readProjectEnvironmentSection(projectDir); + if (!section) return null; + + if (!existsSync(registryPath)) return null; + + try { + const raw = readFileSync(registryPath, "utf8"); + const parsed = parseToml(raw) as Record; + const envs = parsed.environments; + if (!Array.isArray(envs)) return null; + + for (const entry of envs) { + const e = entry as Record; + if (e.slug === section.slug && typeof e.path === "string") { + const envDir = resolve(e.path); + if (!existsSync(envDir)) continue; // stale entry + const env = readEnvManifest(envDir); + if (env) return env; + } + } + } catch { + // Registry parse failure — treat as empty + } + + return null; +} + +// ── Public API ────────────────────────────────────────────────────────────── + +/** + * Resolve the research environment for a project directory. + * Returns null if no environment is found or all candidates are invalid. + * + * Resolution order: walk-up → explicit path → workspace scan → registry. + * Walk-up wins over explicit path (physical topology is authoritative). + * + * Results are cached per project path. Call `invalidateEnvironmentCache()` + * when manifest files change. + */ +export function resolveEnvironment( + projectPath: string, + workspaceRoots: string[], + opts?: ResolveOptions, +): ResolvedEnvironment | null { + const key = resolve(projectPath); + + if (cache.has(key)) { + return cache.get(key)!; + } + + const registryPath = opts?.registryPath ?? join(homedir(), ".amico", "environments.toml"); + + // Strategy 1: walk-up (physical topology — authoritative) + const walkUpResult = walkUp(projectPath, workspaceRoots); + if (walkUpResult) { + cache.set(key, walkUpResult); + return walkUpResult; + } + + // Strategy 2: explicit [environment].path + const explicitResult = explicitPath(projectPath); + if (explicitResult) { + cache.set(key, explicitResult); + return explicitResult; + } + + // Strategy 3: workspace folder scan + const scanResult = workspaceScan(projectPath, workspaceRoots); + if (scanResult) { + cache.set(key, scanResult); + return scanResult; + } + + // Strategy 4: registry lookup + const regResult = registryLookup(projectPath, registryPath); + if (regResult) { + cache.set(key, regResult); + return regResult; + } + + // No environment found + cache.set(key, null); + return null; +} diff --git a/packages/extension/src/sidebar_bridge.ts b/packages/extension/src/sidebar_bridge.ts index 451ca749..938c1549 100644 --- a/packages/extension/src/sidebar_bridge.ts +++ b/packages/extension/src/sidebar_bridge.ts @@ -9,7 +9,7 @@ export interface TreeRoot { path: string; name: string; - projectType: "research" | "dev"; + projectType: "research" | "dev" | "environment"; metadata?: { phase?: string; lastActive?: string }; } diff --git a/packages/extension/src/sidebar_tree_service.ts b/packages/extension/src/sidebar_tree_service.ts index 96627e08..c08f5bb9 100644 --- a/packages/extension/src/sidebar_tree_service.ts +++ b/packages/extension/src/sidebar_tree_service.ts @@ -15,8 +15,8 @@ export interface RawDirEntry { } export interface TreeServiceDeps { - /** Classify a directory as research or dev. */ - detectProjectType: (dir: string) => "research" | "dev"; + /** Classify a directory as research, dev, or environment. */ + detectProjectType: (dir: string) => "research" | "dev" | "environment"; /** Read research-project.toml fields (name, status). Returns {} on failure. */ readToml: (dir: string) => { name?: string; status?: string }; /** Read immediate children of a directory. */ @@ -54,6 +54,9 @@ export class SidebarTreeService { const dir = folder.uri.fsPath; const projectType = this.deps.detectProjectType(dir); + // Environment folders are not shown as sidebar roots (AC-49) + if (projectType === "environment") continue; + if (projectType === "research") { const toml = this.deps.readToml(dir); research.push({ diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index f5180a78..cb0c7bad 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -15,6 +15,7 @@ import { handleSidebarMessage, type SidebarMessageHandlers, type SidebarDownMess import { SidebarTreeService, type RawDirEntry } from "./sidebar_tree_service"; import { ChatPanel } from "./chat_panel"; import { detectProjectType } from "./project/detect"; +import { invalidateEnvironmentCache } from "./project/resolve_environment"; // ── Icon theme resolution ──────────────────────────────────────────────────── @@ -490,7 +491,7 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { if (folder) { this.fsPendingFolders.add(folder.uri.fsPath); // Track whether this event might change a project's type classification - if (path.basename(uri.fsPath) === "research-project.toml") { + if (path.basename(uri.fsPath) === "research-project.toml" || path.basename(uri.fsPath) === "research-environment.toml") { this.fsPendingProjectTypeChange = true; } clearTimeout(this.fsDebounceTimer); @@ -501,6 +502,7 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { // file changes only need the children cache invalidation that the // webview's fs-changed handler already performs. if (this.fsPendingProjectTypeChange) { + invalidateEnvironmentCache(); this.postDown({ kind: "roots", roots: this.treeService.getRoots() }); queueMicrotask(() => this.pushGitStatus()); this.fsPendingProjectTypeChange = false; diff --git a/packages/extension/src/workspace_projects.ts b/packages/extension/src/workspace_projects.ts index c4568ffd..adeef833 100644 --- a/packages/extension/src/workspace_projects.ts +++ b/packages/extension/src/workspace_projects.ts @@ -42,6 +42,9 @@ export function getWorkspaceProjects(deps: WorkspaceProjectDeps): WorkspaceProje const dir = folder.uri.fsPath; const projectType = deps.detectProjectType(dir); + // Environment folders are excluded from the workspace project list (AC-49) + if (projectType === "environment") continue; + if (projectType === "research") { let toml: { name?: string; status?: string } = {}; try { diff --git a/packages/extension/test/project/detect.test.ts b/packages/extension/test/project/detect.test.ts index 24f1fe34..69fc6060 100644 --- a/packages/extension/test/project/detect.test.ts +++ b/packages/extension/test/project/detect.test.ts @@ -36,6 +36,19 @@ describe("detectProjectType", () => { writeFileSync(join(tmpDir, "research-project.toml"), 'schema_version = 1\n'); expect(detectProjectType(tmpDir)).toBe("research"); }); + + // ── environment detection (#882) ────────────────────────────────────── + + it("returns 'environment' when research-environment.toml exists", () => { + writeFileSync(join(tmpDir, "research-environment.toml"), 'schema_version = 1\nname = "env"\nslug = "env"\ncreated = "2026-09-07"\n'); + expect(detectProjectType(tmpDir)).toBe("environment"); + }); + + it("'environment' takes priority over 'research' when both manifests exist", () => { + writeFileSync(join(tmpDir, "research-environment.toml"), 'schema_version = 1\nname = "env"\nslug = "env"\ncreated = "2026-09-07"\n'); + writeFileSync(join(tmpDir, "research-project.toml"), 'schema_version = 1\nname = "proj"\n'); + expect(detectProjectType(tmpDir)).toBe("environment"); + }); }); // ── integration: listProjectDirs carries type ────────────────────────────── diff --git a/packages/extension/test/project/resolve_environment.test.ts b/packages/extension/test/project/resolve_environment.test.ts new file mode 100644 index 00000000..ec75b215 --- /dev/null +++ b/packages/extension/test/project/resolve_environment.test.ts @@ -0,0 +1,211 @@ +// resolve_environment.test.ts — fixture-based tests for the four-strategy +// environment resolution + edge cases. Part of #882. +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + resolveEnvironment, + invalidateEnvironmentCache, + type ResolvedEnvironment, +} from "../../src/project/resolve_environment"; + +describe("resolveEnvironment", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "amicode-resolve-env-")); + invalidateEnvironmentCache(); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + /** Helper: create a minimal environment dir with a manifest. */ + function makeEnv(dir: string, slug: string, name?: string): void { + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "research-environment.toml"), + `schema_version = 1\nname = "${name ?? slug}"\nslug = "${slug}"\ncreated = "2026-09-07"\n`, + ); + } + + /** Helper: create a project dir with a research-project.toml. */ + function makeProject(dir: string, envSection?: { slug: string; path?: string }): void { + mkdirSync(dir, { recursive: true }); + let toml = `schema_version = 1\nname = "Test"\nslug = "test"\nquestion = "?"\nstatus = "running"\ncreated = "2026-09-07"\n`; + if (envSection) { + toml += `\n[environment]\nslug = "${envSection.slug}"\n`; + if (envSection.path) toml += `path = "${envSection.path}"\n`; + } + writeFileSync(join(dir, "research-project.toml"), toml); + } + + // ── Strategy 1: walk-up ──────────────────────────────────────────────── + + it("walk-up: finds environment in parent directory", () => { + const envDir = tmpDir; + makeEnv(envDir, "my-env", "My Env"); + const projectDir = join(envDir, "projects", "my-project"); + makeProject(projectDir); + + const result = resolveEnvironment(projectDir, [tmpDir]); + expect(result).not.toBeNull(); + expect(result!.slug).toBe("my-env"); + expect(result!.name).toBe("My Env"); + expect(result!.path).toBe(envDir); + }); + + it("walk-up: stops at workspace folder root (AC-53)", () => { + // Environment is ABOVE the workspace root — should NOT be found + const wsRoot = join(tmpDir, "workspace"); + mkdirSync(wsRoot, { recursive: true }); + makeEnv(tmpDir, "above-ws"); + const projectDir = join(wsRoot, "my-project"); + makeProject(projectDir); + + const result = resolveEnvironment(projectDir, [wsRoot]); + expect(result).toBeNull(); + }); + + it("walk-up: finds environment at the workspace root itself", () => { + const wsRoot = join(tmpDir, "workspace"); + makeEnv(wsRoot, "ws-env"); + const projectDir = join(wsRoot, "projects", "child"); + makeProject(projectDir); + + const result = resolveEnvironment(projectDir, [wsRoot]); + expect(result).not.toBeNull(); + expect(result!.slug).toBe("ws-env"); + }); + + // ── Strategy 2: explicit path ────────────────────────────────────────── + + it("explicit path: resolves via [environment].path in project TOML", () => { + const envDir = join(tmpDir, "shared-env"); + makeEnv(envDir, "shared-env", "Shared Env"); + const projectDir = join(tmpDir, "my-project"); + makeProject(projectDir, { slug: "shared-env", path: envDir }); + + const result = resolveEnvironment(projectDir, [tmpDir]); + expect(result).not.toBeNull(); + expect(result!.slug).toBe("shared-env"); + expect(result!.path).toBe(envDir); + }); + + it("explicit path: returns null + no error when path has no manifest (AC-52)", () => { + const emptyDir = join(tmpDir, "empty-env"); + mkdirSync(emptyDir, { recursive: true }); + const projectDir = join(tmpDir, "my-project"); + makeProject(projectDir, { slug: "missing", path: emptyDir }); + + const result = resolveEnvironment(projectDir, [tmpDir]); + expect(result).toBeNull(); + }); + + // ── Strategy 3: workspace scan ───────────────────────────────────────── + + it("workspace scan: finds environment in a sibling workspace folder", () => { + const envDir = join(tmpDir, "env-folder"); + makeEnv(envDir, "scan-env", "Scanned Env"); + const projectDir = join(tmpDir, "my-project"); + makeProject(projectDir, { slug: "scan-env" }); + + const result = resolveEnvironment(projectDir, [projectDir, envDir]); + expect(result).not.toBeNull(); + expect(result!.slug).toBe("scan-env"); + expect(result!.path).toBe(envDir); + }); + + // ── Strategy 4: registry ─────────────────────────────────────────────── + + it("registry: finds environment from ~/.amico/environments.toml", () => { + const envDir = join(tmpDir, "registered-env"); + makeEnv(envDir, "reg-env", "Registered Env"); + const registryPath = join(tmpDir, "environments.toml"); + writeFileSync(registryPath, `[[environments]]\nslug = "reg-env"\npath = "${envDir}"\n`); + + const projectDir = join(tmpDir, "my-project"); + makeProject(projectDir, { slug: "reg-env" }); + + const result = resolveEnvironment(projectDir, [projectDir], { registryPath }); + expect(result).not.toBeNull(); + expect(result!.slug).toBe("reg-env"); + expect(result!.path).toBe(envDir); + }); + + // ── Edge cases ───────────────────────────────────────────────────────── + + it("project without [environment] section returns null (AC-4)", () => { + const projectDir = join(tmpDir, "plain-project"); + makeProject(projectDir); + + const result = resolveEnvironment(projectDir, [projectDir]); + expect(result).toBeNull(); + }); + + it("malformed TOML in manifest returns null (AC-51)", () => { + const envDir = join(tmpDir, "bad-env"); + mkdirSync(envDir, { recursive: true }); + writeFileSync(join(envDir, "research-environment.toml"), "this is not valid toml {{{{"); + const projectDir = join(envDir, "projects", "test"); + makeProject(projectDir); + + const result = resolveEnvironment(projectDir, [tmpDir]); + expect(result).toBeNull(); + }); + + it("schema_version exceeding known version returns null (AC-54)", () => { + const envDir = join(tmpDir, "future-env"); + mkdirSync(envDir, { recursive: true }); + writeFileSync( + join(envDir, "research-environment.toml"), + `schema_version = 999\nname = "future"\nslug = "future"\ncreated = "2026-09-07"\n`, + ); + const projectDir = join(envDir, "projects", "test"); + makeProject(projectDir); + + const result = resolveEnvironment(projectDir, [tmpDir]); + expect(result).toBeNull(); + }); + + it("walk-up vs explicit slug conflict: prefers walk-up", () => { + // Walk-up environment + const walkupEnv = tmpDir; + makeEnv(walkupEnv, "walkup-env"); + + // Explicit path environment (different slug) + const explicitEnv = join(tmpDir, "other-env"); + makeEnv(explicitEnv, "explicit-env"); + + // Project has [environment].slug pointing to explicit, but walk-up finds walkup-env + const projectDir = join(tmpDir, "projects", "child"); + makeProject(projectDir, { slug: "explicit-env", path: explicitEnv }); + + const result = resolveEnvironment(projectDir, [tmpDir]); + expect(result).not.toBeNull(); + expect(result!.slug).toBe("walkup-env"); + }); + + it("caches result per project path", () => { + const envDir = tmpDir; + makeEnv(envDir, "cached-env"); + const projectDir = join(envDir, "projects", "test"); + makeProject(projectDir); + + const first = resolveEnvironment(projectDir, [tmpDir]); + expect(first).not.toBeNull(); + + // Remove the manifest — cached result should still return + rmSync(join(envDir, "research-environment.toml")); + const second = resolveEnvironment(projectDir, [tmpDir]); + expect(second).not.toBeNull(); + expect(second!.slug).toBe("cached-env"); + + // Invalidate cache — now should return null + invalidateEnvironmentCache(); + const third = resolveEnvironment(projectDir, [tmpDir]); + expect(third).toBeNull(); + }); +}); diff --git a/packages/extension/test/workspace_projects.test.ts b/packages/extension/test/workspace_projects.test.ts index d0246a8f..6b08bb9d 100644 --- a/packages/extension/test/workspace_projects.test.ts +++ b/packages/extension/test/workspace_projects.test.ts @@ -117,4 +117,37 @@ describe("getWorkspaceProjects (#663)", () => { expect(result[0].name).toBe("bad-toml"); expect(result[0].type).toBe("research"); }); + + // ── environment filtering (#882) ────────────────────────────────────── + + it("excludes environment folders from workspace projects (AC-49)", () => { + const typeMap: Record = { + "/research": "research", + "/env": "environment", + "/dev": "dev", + }; + const result = getWorkspaceProjects( + deps( + [ + { name: "research", path: "/research" }, + { name: "env", path: "/env" }, + { name: "dev", path: "/dev" }, + ], + { detectProjectType: (dir) => typeMap[dir] ?? "dev" }, + ), + ); + expect(result).toHaveLength(2); + expect(result.map((p) => p.type)).toEqual(["research", "dev"]); + expect(result.find((p) => p.type === "environment" as string)).toBeUndefined(); + }); + + it("environment-only workspace returns empty array (AC-50)", () => { + const result = getWorkspaceProjects( + deps( + [{ name: "env", path: "/env" }], + { detectProjectType: () => "environment" }, + ), + ); + expect(result).toEqual([]); + }); }); From 578297871a5bdb63ae2adb281d1d0ec0ee594ba7 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 17:20:10 -0400 Subject: [PATCH 04/23] feat: system prompt context injection for research environments (#883) - buildResearchEnvironmentSection() in stack_state.ts: emits '## Active Research Environment' block with name, path, directory summary counts, language detection from lib/, and skills listing - Absent directories omitted (AC-28), researcher-added dirs auto-discovered (AC-29), 500+ entries reported as ~500+ - AMICODE_RESOLVED_ENVIRONMENT env var threaded through buildServerSpawnEnv - AMICODE_WORKSPACE_FOLDERS also added to spawn env (was test-only) - SEAM_KEYS updated with AMICODE_RESOLVED_ENVIRONMENT - 6 new tests (environment section, absent dirs, extra dirs, skills, malformed TOML, no env), all green Part of #880 Research Environments. Closes #883. --- .../extension/opencode-plugin/stack_state.ts | 130 ++++++++++++++++++ packages/extension/src/server_auth.ts | 10 ++ packages/extension/test/stack_state.test.ts | 126 +++++++++++++++++ 3 files changed, 266 insertions(+) diff --git a/packages/extension/opencode-plugin/stack_state.ts b/packages/extension/opencode-plugin/stack_state.ts index 4488d3f7..74ccb53a 100644 --- a/packages/extension/opencode-plugin/stack_state.ts +++ b/packages/extension/opencode-plugin/stack_state.ts @@ -777,6 +777,131 @@ function extractTomlString(text: string, key: string): string | undefined { return m?.[1]; } +// ── Active Research Environment injection (#883) ───────────────────────────── + +/** Read the resolved environment path from AMICODE_RESOLVED_ENVIRONMENT (set + * by the extension at server-spool), scan the directory, and return a context + * block. Returns null if no environment is resolved. */ +function buildResearchEnvironmentSection(): string | null { + const envDir = process.env.AMICODE_RESOLVED_ENVIRONMENT; + if (!envDir || !envDir.trim()) return null; + + const tomlPath = path.join(envDir, "research-environment.toml"); + if (!fs.existsSync(tomlPath)) return null; + + let tomlText: string; + try { + tomlText = fs.readFileSync(tomlPath, "utf8"); + } catch { + return null; + } + + const name = extractTomlString(tomlText, "name"); + if (!name) return null; // Malformed or missing name → skip + + const slug = extractTomlString(tomlText, "slug") ?? ""; + const description = extractTomlString(tomlText, "description") ?? ""; + + const lines: string[] = [ + "## Active Research Environment", + "", + `**${name}**${slug ? ` (\`${slug}\`)` : ""}`, + ]; + if (description) lines.push(description); + lines.push(`**Path:** \`${envDir}\``); + lines.push(""); + + // Scan directories: list present dirs with file counts, detect language in lib/ + const dirSummaries: string[] = []; + let entries: string[]; + try { + entries = fs.readdirSync(envDir); + } catch { + return lines.join("\n"); + } + + // Discover all content directories (exclude hidden, .git, manifest file) + const skipNames = new Set([".git", ".gitignore", "research-environment.toml"]); + const contentDirs = entries.filter((e) => { + if (skipNames.has(e) || e.startsWith(".")) return false; + try { + return fs.statSync(path.join(envDir, e)).isDirectory(); + } catch { + return false; + } + }); + + for (const dir of contentDirs.sort()) { + const dirPath = path.join(envDir, dir); + let fileCount: number; + try { + const children = fs.readdirSync(dirPath); + fileCount = children.filter((c) => { + try { + return fs.statSync(path.join(dirPath, c)).isFile(); + } catch { + return false; + } + }).length; + } catch { + continue; + } + if (fileCount === 0 && dir !== "skills") continue; // AC-28: absent/empty dirs omitted + + if (dir === "lib") { + // Language detection from lib/ contents + const lang = detectLibLanguage(dirPath); + dirSummaries.push(`- \`${dir}/\` — lib${lang ? ` (${lang})` : ""}`); + } else if (dir === "skills") { + // List skill subdirs + const skillDirs = listSubdirs(dirPath); + if (skillDirs.length > 0) { + dirSummaries.push(`- \`${dir}/\` — **Skills:** ${skillDirs.join(", ")}`); + } + } else if (fileCount >= 500) { + dirSummaries.push(`- \`${dir}/\` — ${dir} (~500+)`); + } else { + dirSummaries.push(`- \`${dir}/\` — ${dir} (${fileCount})`); + } + } + + if (dirSummaries.length > 0) { + lines.push("**Directories:**"); + lines.push(...dirSummaries); + } + + return lines.join("\n"); +} + +/** Detect the primary language from a lib/ directory's contents. */ +function detectLibLanguage(libDir: string): string | null { + try { + const entries = fs.readdirSync(libDir); + if (entries.includes("Project.toml") || entries.includes("Manifest.toml")) return "Julia"; + if (entries.includes("pyproject.toml") || entries.includes("setup.py") || entries.includes("setup.cfg")) return "Python"; + if (entries.includes("package.json")) return "Node"; + if (entries.includes("Cargo.toml")) return "Rust"; + } catch { + // ignore + } + return null; +} + +/** List immediate subdirectories of a directory. */ +function listSubdirs(dir: string): string[] { + try { + return fs.readdirSync(dir).filter((e) => { + try { + return fs.statSync(path.join(dir, e)).isDirectory(); + } catch { + return false; + } + }).sort(); + } catch { + return []; + } +} + /** 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 @@ -806,6 +931,11 @@ export function buildStackStateBlock(): string | null { const project = buildActiveProjectSection(); if (project) parts.push(project); + // Active Research Environment (#883): inject environment metadata when + // the extension resolved an environment for this session. + const env = buildResearchEnvironmentSection(); + if (env) parts.push(env); + // 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/src/server_auth.ts b/packages/extension/src/server_auth.ts index 285ba858..29fb5954 100644 --- a/packages/extension/src/server_auth.ts +++ b/packages/extension/src/server_auth.ts @@ -245,6 +245,12 @@ export function buildServerSpawnEnv(opts: { * Default: the host env (the spawn inherits it underneath anyway). Tests * pass a controlled env so key-set assertions stay machine-independent. */ env?: NodeJS.ProcessEnv; + /** Workspace folder paths (colon-separated) for the plugin's project/env + * section builders. Undefined = omitted (plugin's sections degrade to absent). */ + workspaceFolders?: string; + /** Absolute path to the resolved research environment for the plugin's + * context section (#883). Undefined = no environment bound. */ + resolvedEnvironment?: string; }): Record { const envSource = opts.env ?? process.env; const env: Record = { @@ -268,6 +274,10 @@ export function buildServerSpawnEnv(opts: { // Gated git credential helper (issue #399). {} unless the GitHub App // connection file exists AND the launcher dir resolved. ...buildGitCredentialHelperEnv(opts.amicoRunBinDir, envSource), + // Workspace folders and resolved environment for the plugin's context + // section builders (#670, #883). Absent = the sections degrade to absent. + ...(opts.workspaceFolders ? { AMICODE_WORKSPACE_FOLDERS: opts.workspaceFolders } : {}), + ...(opts.resolvedEnvironment ? { AMICODE_RESOLVED_ENVIRONMENT: opts.resolvedEnvironment } : {}), }; for (const key of SANDBOX_ENV_PASSTHROUGH) { const value = envSource[key]; diff --git a/packages/extension/test/stack_state.test.ts b/packages/extension/test/stack_state.test.ts index 4696448b..321f9ef4 100644 --- a/packages/extension/test/stack_state.test.ts +++ b/packages/extension/test/stack_state.test.ts @@ -327,6 +327,131 @@ describe("Active Research Project injection (#670)", () => { }); }); +// ── Active Research Environment injection (#883) ───────────────────────────── + +describe("Active Research Environment injection (#883)", () => { + it("environment-bound session → '## Active Research Environment' block in context", () => { + const envDir = mkTmp("env-"); + fs.writeFileSync( + path.join(envDir, "research-environment.toml"), + 'schema_version = 1\nname = "Transmon OC"\nslug = "transmon-oc"\ncreated = "2026-09-07"\ndescription = "Shared env"\n', + ); + // Create some scaffold dirs with contents + fs.mkdirSync(path.join(envDir, "insights"), { recursive: true }); + fs.writeFileSync(path.join(envDir, "insights", "note1.md"), "# Note"); + fs.writeFileSync(path.join(envDir, "insights", "note2.md"), "# Note 2"); + fs.mkdirSync(path.join(envDir, "lib"), { recursive: true }); + fs.writeFileSync(path.join(envDir, "lib", "Project.toml"), "# Julia project"); + fs.mkdirSync(path.join(envDir, "methods"), { recursive: true }); + + const stubs = stubAllSeams({}); + process.env.AMICODE_RESOLVED_ENVIRONMENT = envDir; + try { + const block = buildStackStateBlock() ?? ""; + expect(block).toContain("## Active Research Environment"); + expect(block).toContain("**Transmon OC**"); + expect(block).toContain(`**Path:** \`${envDir}\``); + expect(block).toContain("insights (2)"); + expect(block).toContain("lib (Julia)"); + // methods has zero files → absent from listing + expect(block).not.toContain("methods (0)"); + } finally { + restoreSeams(stubs); + } + }); + + it("no environment resolved → no Active Research Environment block", () => { + const stubs = stubAllSeams({}); + // No AMICODE_RESOLVED_ENVIRONMENT set (cleared by stubAllSeams) + try { + const block = buildStackStateBlock() ?? ""; + expect(block).not.toContain("## Active Research Environment"); + } finally { + restoreSeams(stubs); + } + }); + + it("malformed TOML → no Active Research Environment block, no throw", () => { + const envDir = mkTmp("bad-env-"); + fs.writeFileSync( + path.join(envDir, "research-environment.toml"), + "this is not valid {{{{ toml", + ); + const stubs = stubAllSeams({}); + process.env.AMICODE_RESOLVED_ENVIRONMENT = envDir; + try { + const block = buildStackStateBlock() ?? ""; + expect(block).not.toContain("## Active Research Environment"); + } finally { + restoreSeams(stubs); + } + }); + + it("absent directories omitted from listing (AC-28)", () => { + const envDir = mkTmp("sparse-env-"); + fs.writeFileSync( + path.join(envDir, "research-environment.toml"), + 'schema_version = 1\nname = "Sparse"\nslug = "sparse"\ncreated = "2026-09-07"\n', + ); + // Only create one directory with files + fs.mkdirSync(path.join(envDir, "results"), { recursive: true }); + fs.writeFileSync(path.join(envDir, "results", "run1.toml"), "# run"); + + const stubs = stubAllSeams({}); + process.env.AMICODE_RESOLVED_ENVIRONMENT = envDir; + try { + const block = buildStackStateBlock() ?? ""; + expect(block).toContain("## Active Research Environment"); + expect(block).toContain("results (1)"); + // Absent dirs should NOT appear + expect(block).not.toContain("insights"); + expect(block).not.toContain("methods"); + expect(block).not.toContain("literature"); + } finally { + restoreSeams(stubs); + } + }); + + it("researcher-added directories appear automatically (AC-29)", () => { + const envDir = mkTmp("extra-dirs-"); + fs.writeFileSync( + path.join(envDir, "research-environment.toml"), + 'schema_version = 1\nname = "Extra"\nslug = "extra"\ncreated = "2026-09-07"\n', + ); + // Create a researcher-added dir (not in scaffold) + fs.mkdirSync(path.join(envDir, "calibration"), { recursive: true }); + fs.writeFileSync(path.join(envDir, "calibration", "data.json"), "{}"); + + const stubs = stubAllSeams({}); + process.env.AMICODE_RESOLVED_ENVIRONMENT = envDir; + try { + const block = buildStackStateBlock() ?? ""; + expect(block).toContain("calibration (1)"); + } finally { + restoreSeams(stubs); + } + }); + + it("skills/ directory listed when present", () => { + const envDir = mkTmp("skills-env-"); + fs.writeFileSync( + path.join(envDir, "research-environment.toml"), + 'schema_version = 1\nname = "Skilled"\nslug = "skilled"\ncreated = "2026-09-07"\n', + ); + fs.mkdirSync(path.join(envDir, "skills", "my-skill"), { recursive: true }); + fs.writeFileSync(path.join(envDir, "skills", "my-skill", "SKILL.md"), "# Skill"); + + const stubs = stubAllSeams({}); + process.env.AMICODE_RESOLVED_ENVIRONMENT = envDir; + try { + const block = buildStackStateBlock() ?? ""; + expect(block).toContain("**Skills:** my-skill"); + } finally { + restoreSeams(stubs); + } + }); +}); + // ── Caps + composition ─────────────────────────────────────────────────────── describe("caps + composition", () => { @@ -616,6 +741,7 @@ const SEAM_KEYS = [ "AMICODE_PROBLEMS_DIR", "AMICODE_RUNS_DIR", "AMICODE_WORKSPACE_FOLDERS", + "AMICODE_RESOLVED_ENVIRONMENT", ] as const; let fixtureRoot: string | undefined; From a1b18b656b771e70d72551c5c46b7b9e43868f39 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 17:24:07 -0400 Subject: [PATCH 05/23] feat: skill resolution includes environment skills (#888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SkillIndexEntry.source: add 'environment' to the union - resolveEnvironmentSkills(): scan workspace folders with research-environment.toml for skills/ - mergeSkillEntries: 5-arg signature (project > environment > custom > workspace > shipped) — AC-31 - buildSkillIndexSection: render environment skills with (environment) label, between project and custom - opencode_config.ts: wire environment skill discovery + new merge - Environment skills shadow armonissima (shipped) at same name — AC-45 - 7 new tests (3 resolution + 4 merge), all green; existing tests updated to new 5-arg signature Part of #880 Research Environments. Closes #888. --- packages/extension/src/opencode_config.ts | 7 +- .../extension/src/scores/package_skills.ts | 7 +- .../src/scores/user_skill_providers.ts | 30 +++++- .../test/scores/project_skills.test.ts | 98 ++++++++++++++++++- .../test/scores/user_skill_providers.test.ts | 9 +- 5 files changed, 137 insertions(+), 14 deletions(-) diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index ab8d230e..647c2794 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -17,7 +17,7 @@ import { type LibraryRoot, type LibraryRootSpec, } from "./scores/package_skills"; -import { resolveUserSkills, resolveWorkspaceSkills, resolveProjectSkills, mergeSkillEntries } from "./scores/user_skill_providers"; +import { resolveUserSkills, resolveWorkspaceSkills, resolveProjectSkills, resolveEnvironmentSkills, mergeSkillEntries } from "./scores/user_skill_providers"; import { readSolverModeState } from "./solver_mode"; import { studioPathsOrLegacy } from "@amicode/schema"; import { opencodeConfigDir } from "./opencode_xdg"; @@ -794,7 +794,10 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro const projectEntries = opts.workspaceFolders ? resolveProjectSkills(opts.workspaceFolders) : []; - skillEntries = mergeSkillEntries(projectEntries, customEntries, workspaceEntries, shippedEntries); + const environmentEntries = opts.workspaceFolders + ? resolveEnvironmentSkills(opts.workspaceFolders) + : []; + skillEntries = mergeSkillEntries(projectEntries, environmentEntries, customEntries, workspaceEntries, shippedEntries); const section = buildSkillIndexSection(skillEntries); if (section) finalContent = finalContent + "\n\n" + section; } catch (e) { diff --git a/packages/extension/src/scores/package_skills.ts b/packages/extension/src/scores/package_skills.ts index 9cf803d7..3d254a77 100644 --- a/packages/extension/src/scores/package_skills.ts +++ b/packages/extension/src/scores/package_skills.ts @@ -27,7 +27,7 @@ import { // 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" | "project"; // platform | co-located | user-added | workspace .opencode/skills/ | research project skills/ + source: "library" | "package" | "custom" | "workspace" | "project" | "environment"; // platform | co-located | user-added | workspace .opencode/skills/ | research project skills/ | research environment skills/ package?: string; // absent for library entries (spec §3) name: string; description: string; @@ -434,6 +434,7 @@ 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 environment = entries.filter((e) => e.source === "environment"); const custom = entries.filter((e) => e.source === "custom"); const workspace = entries.filter((e) => e.source === "workspace"); const pkg = entries.filter((e) => e.source === "package"); @@ -454,6 +455,10 @@ export function buildSkillIndexSection(entries: SkillIndexEntry[]): string { const label = (e as any).overridesShipped ? "(project, overrides platform)" : "(project)"; return `- **${e.name}** ${label} — ${e.description}`; }), + ...environment.map((e) => { + const label = (e as any).overridesShipped ? "(environment, overrides platform)" : "(environment)"; + 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 8578e3eb..e3f9d480 100644 --- a/packages/extension/src/scores/user_skill_providers.ts +++ b/packages/extension/src/scores/user_skill_providers.ts @@ -101,18 +101,34 @@ export function resolveProjectSkills(workspaceFolders: string[]): SkillIndexEntr return out; } +/** Resolve environment skills from workspace folders with research-environment.toml (#888). + * For each folder with `research-environment.toml`, scan its `skills/` directory. + * Non-environment directories are skipped. */ +export function resolveEnvironmentSkills(workspaceFolders: string[]): SkillIndexEntry[] { + const out: SkillIndexEntry[] = []; + for (const folder of workspaceFolders) { + if (detectProjectType(folder) !== "environment") continue; + const skillsDir = path.join(folder, "skills"); + if (!fs.existsSync(skillsDir)) continue; + out.push(...scanSkillDirectory(skillsDir, "environment" 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: project > custom > workspace > shipped. +/** Merge skill entries with shadow semantics: + * project > environment > 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[], + environment: SkillIndexEntry[], custom: SkillIndexEntry[], workspace: SkillIndexEntry[], shipped: SkillIndexEntry[], @@ -129,14 +145,22 @@ export function mergeSkillEntries( if (overrides) console.warn(`amicode: project skill "${e.name}" shadows shipped skill`); out.push(overrides ? { ...e, overridesShipped: true } : e); } - // Custom second + // Environment second (#888) + for (const e of environment) { + if (seen.has(e.name)) continue; + seen.add(e.name); + const overrides = shippedNames.has(e.name); + if (overrides) console.warn(`amicode: environment skill "${e.name}" shadows shipped skill`); + out.push(overrides ? { ...e, overridesShipped: true } : e); + } + // Custom third 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 third + // Workspace fourth for (const e of workspace) { if (seen.has(e.name)) continue; seen.add(e.name); diff --git a/packages/extension/test/scores/project_skills.test.ts b/packages/extension/test/scores/project_skills.test.ts index fd517de0..a7440549 100644 --- a/packages/extension/test/scores/project_skills.test.ts +++ b/packages/extension/test/scores/project_skills.test.ts @@ -7,6 +7,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveProjectSkills, + resolveEnvironmentSkills, mergeSkillEntries, } from "../../src/scores/user_skill_providers"; import type { SkillIndexEntry } from "../../src/scores/package_skills"; @@ -100,7 +101,7 @@ describe("mergeSkillEntries with project source", () => { 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 merged = mergeSkillEntries(project, [], [], [], shipped); const debug = merged.find((e) => e.name === "debugging"); expect(debug).toBeDefined(); expect(debug!.source).toBe("project"); @@ -112,7 +113,7 @@ describe("mergeSkillEntries with project source", () => { const custom: SkillIndexEntry[] = [ { source: "custom", name: "debugging", description: "custom debugging", path: "/custom/debugging/SKILL.md" }, ]; - const merged = mergeSkillEntries([], custom, [], shipped); + const merged = mergeSkillEntries([], [], custom, [], shipped); const debug = merged.find((e) => e.name === "debugging"); expect(debug!.source).toBe("custom"); expect(debug!.overridesShipped).toBe(true); @@ -123,8 +124,99 @@ describe("mergeSkillEntries with project source", () => { { 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 merged = mergeSkillEntries(project, [], [], [], shipped); const analyze = merged.find((e) => e.name === "analyze"); expect(analyze!.description).toBe("first"); }); }); + +// ── Environment skill resolution (#888) ────────────────────────────────────── + +describe("resolveEnvironmentSkills (#888)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "amicode-env-skills-")); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("discovers skills from an environment's skills/ directory", () => { + const envDir = join(tmpDir, "my-env"); + mkdirSync(join(envDir, "skills", "env-analysis"), { recursive: true }); + writeFileSync(join(envDir, "research-environment.toml"), 'schema_version = 1\nname = "Env"\nslug = "env"\ncreated = "2026-09-07"\n'); + writeFileSync(join(envDir, "skills", "env-analysis", "SKILL.md"), `--- +name: env-analysis +description: Shared analysis skill from the environment +agents: [] +--- +# Env Analysis +`); + + const skills = resolveEnvironmentSkills([envDir]); + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe("env-analysis"); + expect(skills[0].source).toBe("environment"); + }); + + it("ignores non-environment directories", () => { + 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 +--- +`); + + const skills = resolveEnvironmentSkills([devDir]); + expect(skills).toHaveLength(0); + }); + + it("returns empty for environment with no skills/ directory", () => { + const envDir = join(tmpDir, "no-skills-env"); + mkdirSync(envDir); + writeFileSync(join(envDir, "research-environment.toml"), 'schema_version = 1\nname = "Env"\nslug = "env"\ncreated = "2026-09-07"\n'); + + const skills = resolveEnvironmentSkills([envDir]); + expect(skills).toHaveLength(0); + }); +}); + +describe("mergeSkillEntries with environment source (#888)", () => { + 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 > environment > custom > workspace > shipped (AC-31)", () => { + const project: SkillIndexEntry[] = [ + { source: "project", name: "debugging", description: "project debugging", path: "/proj/SKILL.md" }, + ]; + const environment: SkillIndexEntry[] = [ + { source: "environment" as SkillIndexEntry["source"], name: "debugging", description: "env debugging", path: "/env/SKILL.md" }, + { source: "environment" as SkillIndexEntry["source"], name: "tdd", description: "env tdd", path: "/env/tdd/SKILL.md" }, + ]; + const merged = mergeSkillEntries(project, environment, [], [], shipped); + + // project wins over environment for "debugging" + const debug = merged.find((e) => e.name === "debugging"); + expect(debug!.source).toBe("project"); + + // environment wins over shipped for "tdd" + const tdd = merged.find((e) => e.name === "tdd"); + expect(tdd!.source).toBe("environment"); + expect(tdd!.overridesShipped).toBe(true); + }); + + it("environment skill wins over armonissima (shipped) at same name (AC-45)", () => { + const environment: SkillIndexEntry[] = [ + { source: "environment" as SkillIndexEntry["source"], name: "debugging", description: "env debugging", path: "/env/SKILL.md" }, + ]; + const merged = mergeSkillEntries([], environment, [], [], shipped); + const debug = merged.find((e) => e.name === "debugging"); + expect(debug!.source).toBe("environment"); + expect(debug!.overridesShipped).toBe(true); + }); +}); diff --git a/packages/extension/test/scores/user_skill_providers.test.ts b/packages/extension/test/scores/user_skill_providers.test.ts index 79e8dca7..f31cb36f 100644 --- a/packages/extension/test/scores/user_skill_providers.test.ts +++ b/packages/extension/test/scores/user_skill_providers.test.ts @@ -180,8 +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); - // atoms from custom wins, transmon passes through + const merged = mergeSkillEntries([], [], custom, workspace, shipped); expect(merged).toHaveLength(2); const atoms = merged.find((e) => e.name === "atoms")!; expect(atoms.source).toBe("custom"); @@ -199,7 +198,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 +213,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 +226,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); From 58a98f427d246e4b470a7e1cd28aefdc50c7275a Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 17:25:46 -0400 Subject: [PATCH 06/23] =?UTF-8?q?feat:=20amico=20env=20promote=20=E2=80=94?= =?UTF-8?q?=20file=20promotion=20from=20project=20to=20environment=20(#889?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - envPromote(): promote files with frontmatter-based routing (type: insight → insights/, type: method → methods/, etc.) - Provenance stamps on copies (promoted_from_project, promoted_date) — AC-37 - Source stamps (promoted, promoted_to with env slug) to prevent re-promotion — AC-38 - --dry-run lists without writing — AC-40 - Already-promoted files skipped (same env slug check) — AC-48 - --target-dir override for files without type frontmatter - Git stages only the promoted files, never git add . - Verb registered as 'amico env promote' - 7 new integration tests, all green Part of #880 Research Environments. Closes #889. --- packages/amico-run/src/env_verb.ts | 202 +++++++++++++++++++- packages/amico-run/test/env_promote.test.ts | 173 +++++++++++++++++ 2 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 packages/amico-run/test/env_promote.test.ts diff --git a/packages/amico-run/src/env_verb.ts b/packages/amico-run/src/env_verb.ts index acad284b..b9ef1d9d 100644 --- a/packages/amico-run/src/env_verb.ts +++ b/packages/amico-run/src/env_verb.ts @@ -241,6 +241,205 @@ export function envRegister(argv: string[], opts?: EnvVerbOptions): VerbResult { }; } +// ── promote ───────────────────────────────────────────────────────────────── + +/** Type → target directory routing map. */ +const TYPE_ROUTE: Record = { + insight: "insights", + method: "methods", + context: "context", + literature: "literature", + experiment: "experiments", + template: "templates", + result: "results", +}; + +/** Extract a simple YAML frontmatter value from a markdown file. */ +function extractFrontmatter(text: string): Record { + const m = text.match(/^---\n([\s\S]*?)\n---/); + if (!m) return {}; + const fm: Record = {}; + for (const line of m[1].split("\n")) { + const kv = line.match(/^(\w[\w_]*)\s*:\s*"?([^"\n]*)"?$/); + if (kv) fm[kv[1]] = kv[2]; + } + return fm; +} + +/** Add or update a key in the YAML frontmatter. */ +function stampFrontmatter(text: string, key: string, value: string): string { + const fmMatch = text.match(/^(---\n)([\s\S]*?)(\n---)/); + if (!fmMatch) { + // No frontmatter → add one + return `---\n${key}: "${value}"\n---\n${text}`; + } + const [, open, body, close] = fmMatch; + // Check if key exists + const keyRe = new RegExp(`^${key}\\s*:.*$`, "m"); + if (keyRe.test(body)) { + // Replace existing + const updated = body.replace(keyRe, `${key}: "${value}"`); + return text.replace(fmMatch[0], `${open}${updated}${close}`); + } + // Append + return text.replace(fmMatch[0], `${open}${body}\n${key}: "${value}"${close}`); +} + +export function envPromote(argv: string[]): VerbResult { + const fail = (error: string): VerbResult => ({ + json: { verb: "env", subcommand: "promote", error }, + code: 64, + }); + + const envDir = resolve(flagValue(argv, "--env") ?? ""); + const dryRun = argv.includes("--dry-run"); + const targetDirOverride = flagValue(argv, "--target-dir"); + + // Extract the file arg (first positional that isn't a flag value) + const fileArgs: string[] = []; + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith("--")) { + if (argv[i] !== "--dry-run") i++; // skip flag value (except boolean flags) + continue; + } + fileArgs.push(argv[i]); + } + + if (fileArgs.length === 0) return fail("file path is required: amico env promote --env "); + if (!envDir) return fail("--env is required: amico env promote --env "); + + // Validate environment + const manifestPath = join(envDir, "research-environment.toml"); + if (!existsSync(manifestPath)) return fail(`no research-environment.toml found in ${envDir}`); + + let manifestText: string; + try { + manifestText = readFileSync(manifestPath, "utf8"); + } catch { + return fail(`failed to read manifest in ${envDir}`); + } + + // Extract slug from manifest (regex — no smol-toml dependency needed for simple key) + const slugMatch = manifestText.match(/^slug\s*=\s*"([^"]*)"/m); + if (!slugMatch) return fail("manifest missing slug field"); + const envSlug = slugMatch[1]; + + const promoted: string[] = []; + const skipped: string[] = []; + + for (const filePath of fileArgs) { + const absFile = resolve(filePath); + if (!existsSync(absFile)) { + return fail(`file not found: ${absFile}`); + } + + let text: string; + try { + text = readFileSync(absFile, "utf8"); + } catch (e) { + return fail(`failed to read ${absFile}: ${e instanceof Error ? e.message : String(e)}`); + } + + const fm = extractFrontmatter(text); + + // Check if already promoted to this env + if (fm.promoted && fm.promoted_to && fm.promoted_to.startsWith(`${envSlug}:`)) { + skipped.push(absFile); + continue; + } + + // Determine target directory + const type = fm.type; + let targetDir: string; + if (targetDirOverride) { + targetDir = targetDirOverride; + } else if (type && TYPE_ROUTE[type]) { + targetDir = TYPE_ROUTE[type]; + } else { + // Default: unrouted, goes to root of env (or error) + return fail(`no type in frontmatter and no --target-dir specified for ${absFile}`); + } + + const fileName = absFile.split("/").pop() || "promoted.md"; + const targetPath = join(envDir, targetDir, fileName); + const relTarget = `${envSlug}:${targetDir}/${fileName}`; + + if (dryRun) { + promoted.push(relTarget); + continue; + } + + // Create target directory if needed + mkdirSync(join(envDir, targetDir), { recursive: true }); + + // Stamp the copy with provenance + const now = new Date().toISOString(); + let copyText = stampFrontmatter(text, "promoted_from_project", absFile.split("/").slice(-2, -1)[0] || "unknown"); + copyText = stampFrontmatter(copyText, "promoted_date", now); + + try { + writeFileSync(targetPath, copyText); + } catch (e) { + return fail(`failed to write ${targetPath}: ${e instanceof Error ? e.message : String(e)}`); + } + + // Stamp the source to prevent re-promotion + let sourceText = stampFrontmatter(text, "promoted", now); + sourceText = stampFrontmatter(sourceText, "promoted_to", relTarget); + try { + writeFileSync(absFile, sourceText); + } catch { + // Source stamp failure is a warning, not fatal + } + + // Stage the promoted file in git (specific file, never `git add .`) + try { + execFileSync("git", ["add", targetPath], { cwd: envDir, stdio: "ignore" }); + } catch { + // git staging failure is a warning + } + + promoted.push(relTarget); + } + + if (dryRun) { + return { + json: { + verb: "env", + subcommand: "promote", + dry_run: true, + would_promote: promoted, + would_skip: skipped.length, + }, + code: 0, + }; + } + + if (skipped.length > 0 && promoted.length === 0) { + return { + json: { + verb: "env", + subcommand: "promote", + skipped: true, + reason: "already promoted to this environment", + }, + code: 0, + }; + } + + return { + json: { + verb: "env", + subcommand: "promote", + promoted: true, + files: promoted, + skipped: skipped.length, + env_slug: envSlug, + }, + code: 0, + }; +} + // ── dispatch ──────────────────────────────────────────────────────────────── export function envVerb(argv: string[]): VerbResult { @@ -248,11 +447,12 @@ export function envVerb(argv: string[]): VerbResult { const rest = argv.slice(1); if (sub === "create") return envCreate(rest); if (sub === "register") return envRegister(rest); + if (sub === "promote") return envPromote(rest); return { json: { verb: "env", error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, - usage: "amico env create [--path

] [--platform

] [--field ] [--author ] | amico env register ", + usage: "amico env create [--path

] [--platform

] [--field ] [--author ] | amico env register | amico env promote --env [--dry-run] [--target-dir

]", }, code: 64, }; diff --git a/packages/amico-run/test/env_promote.test.ts b/packages/amico-run/test/env_promote.test.ts new file mode 100644 index 00000000..0cb37aaa --- /dev/null +++ b/packages/amico-run/test/env_promote.test.ts @@ -0,0 +1,173 @@ +// `amico env promote` — promote files from a project to an environment. +// Part of #889 (sub-issue of #880 Research Environments). +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 { execFileSync } from "node:child_process"; + +import { envPromote } from "../src/env_verb.js"; +import { renderEnvironmentToml, type EnvironmentToml } from "../src/environment.js"; + +describe("envPromote", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "amico-env-promote-")); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + /** Helper: create a minimal git-initialized environment. */ + function makeEnv(slug: string): string { + const dir = join(tmpDir, slug); + mkdirSync(join(dir, "insights"), { recursive: true }); + mkdirSync(join(dir, "methods"), { recursive: true }); + const manifest: EnvironmentToml = { + schema_version: 1, + name: slug.replace(/-/g, " "), + slug, + created: "2026-09-07", + }; + writeFileSync(join(dir, "research-environment.toml"), renderEnvironmentToml(manifest)); + execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["add", "."], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: dir, stdio: "ignore" }); + return dir; + } + + /** Helper: create a source file with frontmatter. */ + function writeNote(dir: string, relPath: string, frontmatter: Record, body: string): string { + const full = join(dir, relPath); + mkdirSync(join(full, ".."), { recursive: true }); + const fm = Object.entries(frontmatter).map(([k, v]) => `${k}: "${v}"`).join("\n"); + writeFileSync(full, `---\n${fm}\n---\n${body}`); + return full; + } + + it("promotes a file to the correct directory based on type frontmatter (AC-47)", () => { + const envDir = makeEnv("my-env"); + const projectDir = join(tmpDir, "my-project"); + mkdirSync(projectDir, { recursive: true }); + writeNote(projectDir, "notes/smoothness.md", { + type: "insight", + visibility: "environment", + }, "# Smoothness tradeoff\n"); + + const result = envPromote( + [join(projectDir, "notes/smoothness.md"), "--env", envDir], + ); + + expect(result.code).toBe(0); + const json = result.json as Record; + expect(json.promoted).toBe(true); + + // File copied to env's insights/ + expect(existsSync(join(envDir, "insights", "smoothness.md"))).toBe(true); + + // Copy has provenance stamps (AC-37) + const copy = readFileSync(join(envDir, "insights", "smoothness.md"), "utf8"); + expect(copy).toContain("promoted_from_project"); + expect(copy).toContain("promoted_date"); + + // Source stamped to prevent re-promotion (AC-38) + const source = readFileSync(join(projectDir, "notes/smoothness.md"), "utf8"); + expect(source).toContain("promoted:"); + expect(source).toContain("promoted_to:"); + expect(source).toContain("my-env:"); + }); + + it("routes type: method to methods/", () => { + const envDir = makeEnv("test-env"); + const projectDir = join(tmpDir, "proj"); + mkdirSync(projectDir, { recursive: true }); + writeNote(projectDir, "method.md", { + type: "method", + visibility: "environment", + }, "# A method\n"); + + const result = envPromote( + [join(projectDir, "method.md"), "--env", envDir], + ); + + expect(result.code).toBe(0); + expect(existsSync(join(envDir, "methods", "method.md"))).toBe(true); + }); + + it("--dry-run lists without writing (AC-40)", () => { + const envDir = makeEnv("dry-env"); + const projectDir = join(tmpDir, "proj"); + mkdirSync(projectDir, { recursive: true }); + writeNote(projectDir, "note.md", { + type: "insight", + visibility: "environment", + }, "# Note\n"); + + const result = envPromote( + [join(projectDir, "note.md"), "--env", envDir, "--dry-run"], + ); + + expect(result.code).toBe(0); + const json = result.json as Record; + expect(json.dry_run).toBe(true); + + // File should NOT be copied + expect(existsSync(join(envDir, "insights", "note.md"))).toBe(false); + }); + + it("already-promoted file is skipped (AC-38)", () => { + const envDir = makeEnv("skip-env"); + const projectDir = join(tmpDir, "proj"); + mkdirSync(projectDir, { recursive: true }); + writeNote(projectDir, "dup.md", { + type: "insight", + visibility: "environment", + promoted: "2026-09-06T00:00:00Z", + promoted_to: "skip-env:insights/dup.md", + }, "# Already promoted\n"); + + const result = envPromote( + [join(projectDir, "dup.md"), "--env", envDir], + ); + + expect(result.code).toBe(0); + const json = result.json as Record; + expect(json.skipped).toBe(true); + }); + + it("returns error when no file specified", () => { + const result = envPromote(["--env", join(tmpDir, "some-env")]); + expect(result.code).toBe(64); + }); + + it("returns error when env path has no manifest", () => { + const emptyDir = join(tmpDir, "empty"); + mkdirSync(emptyDir, { recursive: true }); + const projectDir = join(tmpDir, "proj2"); + mkdirSync(projectDir, { recursive: true }); + writeNote(projectDir, "note.md", { type: "insight" }, "# Note\n"); + + const result = envPromote( + [join(projectDir, "note.md"), "--env", emptyDir], + ); + expect(result.code).toBe(64); + }); + + it("defaults type to unrouted when type frontmatter is absent", () => { + const envDir = makeEnv("unrouted-env"); + const projectDir = join(tmpDir, "proj"); + mkdirSync(projectDir, { recursive: true }); + writeNote(projectDir, "random.md", { + visibility: "environment", + }, "# No type\n"); + + const result = envPromote( + [join(projectDir, "random.md"), "--env", envDir, "--target-dir", "context"], + ); + + expect(result.code).toBe(0); + expect(existsSync(join(envDir, "context", "random.md"))).toBe(true); + }); +}); From 440de760f79e6629bda53945c1451bc83c3ed430 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 17:27:31 -0400 Subject: [PATCH 07/23] feat: sidebar environment pill data plumbing + color palette (#884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Color palette utility: hashCode, envColorIndex (slug → 0-7), truncateWithEllipsis (for pill label) - TreeRoot.environment field: name, slug, path, colorIndex - SidebarTreeService.getRoots: resolveEnvironment dep, attaches env data to research project roots - sidebar_view.ts: wires resolveEnvironment into the tree service - Resolution failure → graceful degradation (no pill, no crash) - AC-6 (deterministic slug hash), AC-10 (no pill without binding) - 14 new tests (color palette, hash, truncation, tree service wiring) - Webview pill rendering deferred to the fork's sidebar_webview.ts Part of #880 Research Environments. Closes #884. --- packages/extension/src/sidebar_bridge.ts | 30 ++++ .../extension/src/sidebar_tree_service.ts | 25 +++- packages/extension/src/sidebar_view.ts | 3 +- .../extension/test/sidebar_env_pill.test.ts | 134 ++++++++++++++++++ 4 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 packages/extension/test/sidebar_env_pill.test.ts diff --git a/packages/extension/src/sidebar_bridge.ts b/packages/extension/src/sidebar_bridge.ts index 938c1549..9ba9556f 100644 --- a/packages/extension/src/sidebar_bridge.ts +++ b/packages/extension/src/sidebar_bridge.ts @@ -6,11 +6,41 @@ // ── Data types ─────────────────────────────────────────────────────────────── +// ── Color palette utility (#884) ───────────────────────────────────────────── + +/** Deterministic hash code for a string (Java-style hashCode). */ +export function hashCode(s: string): number { + let hash = 0; + for (let i = 0; i < s.length; i++) { + hash = (Math.imul(31, hash) + s.charCodeAt(i)) | 0; + } + return hash; +} + +/** Map an environment slug to a palette index (0-7). */ +export function envColorIndex(slug: string): number { + return ((hashCode(slug) % 8) + 8) % 8; // ensure non-negative +} + +/** Truncate a string to maxLen characters with ellipsis. */ +export function truncateWithEllipsis(s: string, maxLen: number): string { + return s.length > maxLen ? s.slice(0, maxLen) + "\u2026" : s; +} + +// ── Tree data types ────────────────────────────────────────────────────────── + export interface TreeRoot { path: string; name: string; projectType: "research" | "dev" | "environment"; metadata?: { phase?: string; lastActive?: string }; + /** Resolved environment info, present when a research project is bound to an environment. */ + environment?: { + name: string; // display name from manifest + slug: string; // for tooltip and dedup + path: string; // absolute path, for tooltip + colorIndex: number; // 0-7, from hashCode(slug) % 8 + }; } export interface TreeEntry { diff --git a/packages/extension/src/sidebar_tree_service.ts b/packages/extension/src/sidebar_tree_service.ts index c08f5bb9..879bd67d 100644 --- a/packages/extension/src/sidebar_tree_service.ts +++ b/packages/extension/src/sidebar_tree_service.ts @@ -6,6 +6,7 @@ // (Node runtime) and the results are posted to the webview via bridge messages. import type { TreeRoot, TreeEntry } from "./sidebar_bridge"; +import { envColorIndex } from "./sidebar_bridge"; // ── Dependencies (injected for testability) ────────────────────────────────── @@ -19,6 +20,8 @@ export interface TreeServiceDeps { detectProjectType: (dir: string) => "research" | "dev" | "environment"; /** Read research-project.toml fields (name, status). Returns {} on failure. */ readToml: (dir: string) => { name?: string; status?: string }; + /** Resolve the research environment for a project directory. */ + resolveEnvironment?: (projectPath: string, workspaceRoots: string[]) => { path: string; slug: string; name: string; schemaVersion: number } | null; /** Read immediate children of a directory. */ readDirectory?: (dir: string) => Promise; /** Get exclude pattern strings from files.exclude. */ @@ -46,6 +49,7 @@ export class SidebarTreeService { */ getRoots(): TreeRoot[] { const workspaceFolders = this.deps.getWorkspaceFolders?.() ?? []; + const workspaceRoots = workspaceFolders.map((f) => f.uri.fsPath); const research: TreeRoot[] = []; const dev: TreeRoot[] = []; @@ -59,12 +63,29 @@ export class SidebarTreeService { if (projectType === "research") { const toml = this.deps.readToml(dir); - research.push({ + const root: TreeRoot = { path: dir, name: toml.name ?? folder.name, projectType: "research", metadata: toml.status ? { phase: toml.status } : undefined, - }); + }; + // Resolve environment for this project (#884) + if (this.deps.resolveEnvironment) { + try { + const env = this.deps.resolveEnvironment(dir, workspaceRoots); + if (env) { + root.environment = { + name: env.name, + slug: env.slug, + path: env.path, + colorIndex: envColorIndex(env.slug), + }; + } + } catch { + // Resolution failure → no pill, not a crash + } + } + research.push(root); } else { dev.push({ path: dir, diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index cb0c7bad..f9ebca83 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -15,7 +15,7 @@ import { handleSidebarMessage, type SidebarMessageHandlers, type SidebarDownMess import { SidebarTreeService, type RawDirEntry } from "./sidebar_tree_service"; import { ChatPanel } from "./chat_panel"; import { detectProjectType } from "./project/detect"; -import { invalidateEnvironmentCache } from "./project/resolve_environment"; +import { invalidateEnvironmentCache, resolveEnvironment } from "./project/resolve_environment"; // ── Icon theme resolution ──────────────────────────────────────────────────── @@ -305,6 +305,7 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { this.treeService = new SidebarTreeService({ detectProjectType, readToml: (dir) => readResearchToml(dir), + resolveEnvironment: (projectPath, workspaceRoots) => resolveEnvironment(projectPath, workspaceRoots), readDirectory: (dir) => readDirectoryEntries(dir), getExcludePatterns: () => getExcludePatterns(), getWorkspaceFolders: () => vscode.workspace.workspaceFolders ?? [], diff --git a/packages/extension/test/sidebar_env_pill.test.ts b/packages/extension/test/sidebar_env_pill.test.ts new file mode 100644 index 00000000..6cbd35ba --- /dev/null +++ b/packages/extension/test/sidebar_env_pill.test.ts @@ -0,0 +1,134 @@ +// Sidebar environment pill — color palette utility + TreeService wiring. +// Part of #884 (sub-issue of #880 Research Environments). +import { describe, it, expect } from "vitest"; +import { hashCode, envColorIndex, truncateWithEllipsis } from "../src/sidebar_bridge"; +import { SidebarTreeService } from "../src/sidebar_tree_service"; + +// ── Color palette utility ──────────────────────────────────────────────────── + +describe("envColorIndex (#884)", () => { + it("returns a value in [0, 7]", () => { + for (const slug of ["transmon-oc", "rydberg-gates", "fluxonium-env", "my-env"]) { + const idx = envColorIndex(slug); + expect(idx).toBeGreaterThanOrEqual(0); + expect(idx).toBeLessThanOrEqual(7); + } + }); + + it("same slug always produces the same index (deterministic)", () => { + const a = envColorIndex("transmon-optimal-control"); + const b = envColorIndex("transmon-optimal-control"); + expect(a).toBe(b); + }); + + it("different slugs can produce different indices", () => { + // Not guaranteed for any specific pair, but over many slugs we should see variation + const indices = new Set( + ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota"].map(envColorIndex) + ); + expect(indices.size).toBeGreaterThan(1); + }); +}); + +describe("hashCode", () => { + it("empty string returns 0", () => { + expect(hashCode("")).toBe(0); + }); + + it("produces consistent values", () => { + expect(hashCode("test")).toBe(hashCode("test")); + }); + + it("different strings produce different hashes", () => { + expect(hashCode("abc")).not.toBe(hashCode("xyz")); + }); +}); + +describe("truncateWithEllipsis", () => { + it("returns the string unchanged when within limit", () => { + expect(truncateWithEllipsis("short", 15)).toBe("short"); + }); + + it("truncates with ellipsis when exceeding limit", () => { + const result = truncateWithEllipsis("transmon-optimal-control", 15); + expect(result).toHaveLength(16); // 15 chars + ellipsis + expect(result.endsWith("\u2026")).toBe(true); + }); + + it("handles exact-length strings without truncation", () => { + expect(truncateWithEllipsis("exact", 5)).toBe("exact"); + }); +}); + +// ── TreeService environment wiring ─────────────────────────────────────────── + +describe("SidebarTreeService environment pill data (#884)", () => { + function makeService(overrides: Partial[0]> = {}) { + const folders = [ + { uri: { fsPath: "/proj" }, name: "proj" }, + ]; + return new SidebarTreeService({ + detectProjectType: () => "research", + readToml: () => ({ name: "My Project", status: "running" }), + resolveEnvironment: () => ({ + path: "/env/transmon-oc", + slug: "transmon-oc", + name: "Transmon OC", + schemaVersion: 1, + }), + getWorkspaceFolders: () => folders, + ...overrides, + }); + } + + it("attaches environment info to research project roots", () => { + const service = makeService(); + const roots = service.getRoots(); + expect(roots).toHaveLength(1); + expect(roots[0].environment).toBeDefined(); + expect(roots[0].environment!.slug).toBe("transmon-oc"); + expect(roots[0].environment!.name).toBe("Transmon OC"); + expect(roots[0].environment!.path).toBe("/env/transmon-oc"); + expect(roots[0].environment!.colorIndex).toBeGreaterThanOrEqual(0); + expect(roots[0].environment!.colorIndex).toBeLessThanOrEqual(7); + }); + + it("no environment → no environment field on root (AC-10)", () => { + const service = makeService({ + resolveEnvironment: () => null, + }); + const roots = service.getRoots(); + expect(roots).toHaveLength(1); + expect(roots[0].environment).toBeUndefined(); + }); + + it("resolveEnvironment not provided → no environment field", () => { + const service = makeService({ + resolveEnvironment: undefined, + }); + const roots = service.getRoots(); + expect(roots).toHaveLength(1); + expect(roots[0].environment).toBeUndefined(); + }); + + it("dev projects never get environment info", () => { + const service = makeService({ + detectProjectType: () => "dev", + resolveEnvironment: () => ({ + path: "/env/x", slug: "x", name: "X", schemaVersion: 1, + }), + }); + const roots = service.getRoots(); + expect(roots).toHaveLength(1); + expect(roots[0].environment).toBeUndefined(); + }); + + it("resolution failure → no environment, no crash", () => { + const service = makeService({ + resolveEnvironment: () => { throw new Error("boom"); }, + }); + const roots = service.getRoots(); + expect(roots).toHaveLength(1); + expect(roots[0].environment).toBeUndefined(); + }); +}); From d5643289e6b0ce6bb17ebcfb55abd81dff915599 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 17:28:33 -0400 Subject: [PATCH 08/23] feat: composer environment subtitle data plumbing (#886) - WorkspaceProjectEntry: add optional environment field - WorkspaceProjectDeps: add resolveEnvironment dep - getWorkspaceProjects: populate environment name for bound research projects; absent for unbound and dev projects - AC-19 (subtitle for bound), AC-20 (no subtitle for unbound/dev) - 3 new tests, all green - Fork-side rendering (prompt-project-selector.tsx) is a follow-up Part of #880 Research Environments. Closes #886. --- packages/extension/src/workspace_projects.ts | 14 ++++++ .../extension/test/workspace_projects.test.ts | 46 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/extension/src/workspace_projects.ts b/packages/extension/src/workspace_projects.ts index adeef833..2ce39ede 100644 --- a/packages/extension/src/workspace_projects.ts +++ b/packages/extension/src/workspace_projects.ts @@ -17,6 +17,8 @@ export interface WorkspaceProjectEntry { worktree: string; type: ProjectType; status?: string; + /** Environment name for bound research projects (#886). Absent for unbound or dev. */ + environment?: string; } /** Injected dependencies — testable without VS Code API or filesystem. */ @@ -24,6 +26,8 @@ export interface WorkspaceProjectDeps { getWorkspaceFolders: () => ReadonlyArray<{ uri: { fsPath: string }; name: string }>; detectProjectType: (dir: string) => ProjectType; readToml: (dir: string) => { name?: string; status?: string }; + /** Resolve the environment for a project directory. Returns null if unbound. */ + resolveEnvironment?: (projectPath: string, workspaceRoots: string[]) => { name: string } | null; } // ── Scanner ────────────────────────────────────────────────────────────────── @@ -35,6 +39,7 @@ export interface WorkspaceProjectDeps { */ export function getWorkspaceProjects(deps: WorkspaceProjectDeps): WorkspaceProjectEntry[] { const folders = deps.getWorkspaceFolders(); + const workspaceRoots = folders.map((f) => f.uri.fsPath); const research: WorkspaceProjectEntry[] = []; const dev: WorkspaceProjectEntry[] = []; @@ -58,6 +63,15 @@ export function getWorkspaceProjects(deps: WorkspaceProjectDeps): WorkspaceProje type: "research", }; if (toml.status) entry.status = toml.status; + // Resolve environment for subtitle (#886) + if (deps.resolveEnvironment) { + try { + const env = deps.resolveEnvironment(dir, workspaceRoots); + if (env) entry.environment = env.name; + } catch { + // Resolution failure → no subtitle + } + } research.push(entry); } else { dev.push({ diff --git a/packages/extension/test/workspace_projects.test.ts b/packages/extension/test/workspace_projects.test.ts index 6b08bb9d..86c236ea 100644 --- a/packages/extension/test/workspace_projects.test.ts +++ b/packages/extension/test/workspace_projects.test.ts @@ -150,4 +150,50 @@ describe("getWorkspaceProjects (#663)", () => { ); expect(result).toEqual([]); }); + + // ── environment subtitle (#886) ────────────────────────────────────── + + it("research project with environment gets subtitle (AC-19)", () => { + const result = getWorkspaceProjects( + deps( + [{ name: "fast-cz", path: "/fast-cz" }], + { + detectProjectType: () => "research", + readToml: () => ({ name: "Fast CZ Gate", status: "running" }), + resolveEnvironment: () => ({ name: "Transmon OC" }), + }, + ), + ); + expect(result).toHaveLength(1); + expect(result[0].environment).toBe("Transmon OC"); + }); + + it("research project without environment has no subtitle (AC-20)", () => { + const result = getWorkspaceProjects( + deps( + [{ name: "solo-proj", path: "/solo" }], + { + detectProjectType: () => "research", + readToml: () => ({ name: "Solo" }), + resolveEnvironment: () => null, + }, + ), + ); + expect(result).toHaveLength(1); + expect(result[0].environment).toBeUndefined(); + }); + + it("dev projects never get environment subtitle (AC-20)", () => { + const result = getWorkspaceProjects( + deps( + [{ name: "dev", path: "/dev" }], + { + detectProjectType: () => "dev", + resolveEnvironment: () => ({ name: "Should not appear" }), + }, + ), + ); + expect(result).toHaveLength(1); + expect(result[0].environment).toBeUndefined(); + }); }); From 308e93be28525ffceb803611c8283099a42fabf1 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 17:29:27 -0400 Subject: [PATCH 09/23] feat: commands + context menu bridge messages for environments (#887) - Register 3 command palette entries: Amicode: New Environment, Amicode: Bind to Environment, Amicode: Promote to Environment - sidebar_bridge: add BindToEnvironmentMessage and PromoteToEnvironmentMessage to SidebarUpMessage union - Command handlers and webview context menu items are fork-side follow-ups Part of #880 Research Environments. Closes #887. --- packages/extension/package.json | 13 +++++++++++++ packages/extension/src/sidebar_bridge.ts | 16 +++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index fdd00496..b035a7ad 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -187,6 +187,19 @@ { "command": "amicode.cycleAgent", "title": "Amicode: Cycle Agent" + }, + { + "command": "amicode.newEnvironment", + "title": "Amicode: New Environment", + "icon": "$(folder-library)" + }, + { + "command": "amicode.bindToEnvironment", + "title": "Amicode: Bind to Environment" + }, + { + "command": "amicode.promoteToEnvironment", + "title": "Amicode: Promote to Environment" } ], "keybindings": [ diff --git a/packages/extension/src/sidebar_bridge.ts b/packages/extension/src/sidebar_bridge.ts index 9ba9556f..5161abe0 100644 --- a/packages/extension/src/sidebar_bridge.ts +++ b/packages/extension/src/sidebar_bridge.ts @@ -102,6 +102,18 @@ export type FileOpMessage = { kind: "file-op" } & FileOpRequest; export type SetSectionOrderMessage = { kind: "set-section-order"; order: string[] }; export type ReorderRootMessage = { kind: "reorder-root"; sourcePath: string; targetPath: string; position: "before" | "after" }; +// ── Environment action messages (#887) ─────────────────────────────────────── + +export interface BindToEnvironmentMessage { + kind: "bind-to-environment"; + projectPath: string; +} + +export interface PromoteToEnvironmentMessage { + kind: "promote-to-environment"; + filePath: string; +} + export type SidebarUpMessage = | OpenChatMessage | NewProjectMessage @@ -111,7 +123,9 @@ export type SidebarUpMessage = | OpenFileMessage | FileOpMessage | SetSectionOrderMessage - | ReorderRootMessage; + | ReorderRootMessage + | BindToEnvironmentMessage + | PromoteToEnvironmentMessage; // ── Combined union (for the bridge type) ───────────────────────────────────── From bf446e78e9c34a2ef5ce86becb4019d2dcabe4b6 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 17:30:44 -0400 Subject: [PATCH 10/23] =?UTF-8?q?feat:=20autoresearch=20environment=20inte?= =?UTF-8?q?gration=20=E2=80=94=20digest,=20briefs,=20staging=20(#890)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Environment digest assembly at campaign kickoff (filtered by domain + tags + objective, updated at loop boundaries) - Subagent brief fields: env_digest_path for all three roles; env_lib_path, env_templates_path, env_config_path, env_context_path for the experimenter - Per-iteration staging: project card updates + insight proposals to ledger/environment-proposals/ - Promotion suggestion at campaign boundaries - All directory paths respect [paths] overrides from the manifest - Prose-only deliverable (skill SKILL.md update), no TypeScript tests Part of #880 Research Environments. Closes #890. --- packages/extension/skills/research/SKILL.md | 57 +++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/extension/skills/research/SKILL.md b/packages/extension/skills/research/SKILL.md index 4f8212e5..aa47b557 100644 --- a/packages/extension/skills/research/SKILL.md +++ b/packages/extension/skills/research/SKILL.md @@ -44,6 +44,63 @@ block in the system prompt (injected by `stack_state.ts` when a workspace folder | Config | `/config/` | | Reports | `/reports/` | | Checkout registry | `/ledger/campaigns/CHECKOUTS.md` | +| Environment proposals | `/ledger/environment-proposals/` (created on demand) | +| Environment digest | `/ledger/campaigns/env-digest-.md` | + +## Research Environment integration (#890) + +When the project is bound to a Research Environment (the `## Active Research +Environment` block appears in the system prompt), the environment provides +shared knowledge, code, and configuration across sibling projects. + +### Environment digest assembly + +At campaign **kickoff**, assemble an environment digest: + +1. Read the environment manifest (`research-environment.toml`) — note `[domain]` fields + and the `[paths]` section (directory names may be overridden). +2. Scan environment directories — `insights/`, `methods/`, `context/`, `literature/`, + `results/`, `templates/`, `config/`, `lib/`, `experiments/` — using names from `[paths]` + when overridden. +3. Filter by relevance: match the manifest `[domain]` + project `tags` + campaign objective. + This filtering is judgment-based, not programmatic. +4. Write the digest to `/ledger/campaigns/env-digest-.md`. +5. Include: relevant insights, methods, sibling project summaries, results candidates, + context notes. Add an excluded appendix listing what was omitted and why. +6. If the campaign ledger §1 contains a `digest_include` list, force those notes into the + digest regardless of filtering. + +**Update the digest at each loop boundary** — new environment content may have arrived. + +### Subagent briefs with environment paths + +When briefing subagents and the project is bound to an environment, include these fields: + +| Role | Fields in brief | +|------|----------------| +| **Hypothesizer** | `env_digest_path` — read the digest for cross-project context and provenance | +| **Experimenter** | `env_digest_path`, `env_lib_path`, `env_lib_importable` (false until deferred), `env_templates_path`, `env_config_path`, `env_context_path` | +| **Analyzer** | `env_digest_path` — read the digest; may propose environment insight proposals | + +All paths respect `[paths]` overrides from the environment manifest. + +When the project has **no environment binding**, omit these fields entirely (do not pass +empty strings or null values). + +### Per-iteration staging + +At each loop boundary (after the analyzer returns): + +1. **Stage project card update** — if the project's `research-project.toml` status changed + or best results improved, update the card. +2. **Stage insight proposals** — the analyzer may produce structured insight proposals + (YAML frontmatter with `type: insight`, `visibility: environment`). Write them to + `/ledger/environment-proposals/`. They are candidates for `amico env promote`. + +### Promotion suggestion + +At campaign boundaries (when the campaign objective is met or the loop closes), suggest +running `amico env promote` on any staged proposals in `ledger/environment-proposals/`. ## The campaign ledger (create at kickoff, before any work) From fd18556ce37429d563a0618494e7a5f692c7f353 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 17:32:02 -0400 Subject: [PATCH 11/23] feat: nested environment tree data plumbing (#885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TreeEntry: add entryKind ('environment-root') and environmentSlug fields for the environment row - SidebarTreeService: track root→environment map from getRoots(), append environment as last child of bound project roots in getChildren() - Environment row carries name, path, slug, and directory type - AC-11 (last child), AC-14 (lazy-loaded on getChildren call) - 3 new tests (environment child, no env, sort order), all green - Webview rendering (icon, italic, muted children) deferred to fork Part of #880 Research Environments. Closes #885. --- packages/extension/src/sidebar_bridge.ts | 4 ++ .../extension/src/sidebar_tree_service.ts | 21 +++++- .../extension/test/sidebar_env_tree.test.ts | 72 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 packages/extension/test/sidebar_env_tree.test.ts diff --git a/packages/extension/src/sidebar_bridge.ts b/packages/extension/src/sidebar_bridge.ts index 5161abe0..122bbe3b 100644 --- a/packages/extension/src/sidebar_bridge.ts +++ b/packages/extension/src/sidebar_bridge.ts @@ -48,6 +48,10 @@ export interface TreeEntry { type: "file" | "directory"; path: string; gitStatus?: "modified" | "added" | "deleted" | "untracked" | "ignored" | "conflict"; + /** Discriminant for the environment root row (#885). */ + entryKind?: "environment-root"; + /** Environment slug for coloring the environment root row (#885). */ + environmentSlug?: string; } // ── File operation types ───────────────────────────────────────────────────── diff --git a/packages/extension/src/sidebar_tree_service.ts b/packages/extension/src/sidebar_tree_service.ts index 879bd67d..cb09886c 100644 --- a/packages/extension/src/sidebar_tree_service.ts +++ b/packages/extension/src/sidebar_tree_service.ts @@ -38,6 +38,8 @@ export interface TreeServiceDeps { */ export class SidebarTreeService { private deps: TreeServiceDeps; + /** Map root path → resolved environment info from the last getRoots() call (#885). */ + private rootEnvironments = new Map(); constructor(deps: TreeServiceDeps) { this.deps = deps; @@ -53,6 +55,7 @@ export class SidebarTreeService { const research: TreeRoot[] = []; const dev: TreeRoot[] = []; + this.rootEnvironments.clear(); for (const folder of workspaceFolders) { const dir = folder.uri.fsPath; @@ -80,6 +83,7 @@ export class SidebarTreeService { path: env.path, colorIndex: envColorIndex(env.slug), }; + this.rootEnvironments.set(dir, { name: env.name, slug: env.slug, path: env.path }); } } catch { // Resolution failure → no pill, not a crash @@ -125,10 +129,25 @@ export class SidebarTreeService { return a.name.localeCompare(b.name); }); - return filtered.map((entry) => ({ + const entries: TreeEntry[] = filtered.map((entry) => ({ name: entry.name, type: entry.type, path: `${dirPath}/${entry.name}`, })); + + // If this is a project root with a bound environment, append the + // environment as the last child (#885) + const envInfo = this.rootEnvironments.get(dirPath); + if (envInfo) { + entries.push({ + name: envInfo.name, + type: "directory", + path: envInfo.path, + entryKind: "environment-root", + environmentSlug: envInfo.slug, + }); + } + + return entries; } } diff --git a/packages/extension/test/sidebar_env_tree.test.ts b/packages/extension/test/sidebar_env_tree.test.ts new file mode 100644 index 00000000..f42c4252 --- /dev/null +++ b/packages/extension/test/sidebar_env_tree.test.ts @@ -0,0 +1,72 @@ +// Sidebar nested environment tree — TreeService getChildren appends env row. +// Part of #885 (sub-issue of #880 Research Environments). +import { describe, it, expect } from "vitest"; +import { SidebarTreeService } from "../src/sidebar_tree_service"; +import type { RawDirEntry } from "../src/sidebar_tree_service"; + +describe("SidebarTreeService nested environment tree (#885)", () => { + function makeService(env: { name: string; slug: string; path: string } | null = { + path: "/env/transmon-oc", + slug: "transmon-oc", + name: "Transmon OC", + }) { + const folders = [ + { uri: { fsPath: "/proj" }, name: "proj" }, + ]; + return new SidebarTreeService({ + detectProjectType: () => "research", + readToml: () => ({ name: "My Project", status: "running" }), + resolveEnvironment: () => env ? { ...env, schemaVersion: 1 } : null, + readDirectory: async (dir: string): Promise => { + if (dir === "/proj") { + return [ + { name: "scripts", type: "directory" }, + { name: "data", type: "directory" }, + { name: "README.md", type: "file" }, + ]; + } + return []; + }, + getWorkspaceFolders: () => folders, + }); + } + + it("appends environment as last child of a bound project root (AC-11)", async () => { + const service = makeService(); + // Must call getRoots first to populate the environment map + service.getRoots(); + + const children = await service.getChildren("/proj"); + expect(children.length).toBe(4); // 3 regular + 1 environment + const envEntry = children[children.length - 1]; + expect(envEntry.entryKind).toBe("environment-root"); + expect(envEntry.name).toBe("Transmon OC"); + expect(envEntry.path).toBe("/env/transmon-oc"); + expect(envEntry.environmentSlug).toBe("transmon-oc"); + expect(envEntry.type).toBe("directory"); + }); + + it("no environment → no extra child", async () => { + const service = makeService(null); + service.getRoots(); + + const children = await service.getChildren("/proj"); + expect(children.length).toBe(3); // just the regular children + expect(children.find((c) => c.entryKind === "environment-root")).toBeUndefined(); + }); + + it("environment row is always last (after sorted files)", async () => { + const service = makeService(); + service.getRoots(); + + const children = await service.getChildren("/proj"); + // Regular entries should be sorted (dirs first, then files) + const regular = children.filter((c) => !c.entryKind); + expect(regular[0].name).toBe("data"); // dir + expect(regular[1].name).toBe("scripts"); // dir + expect(regular[2].name).toBe("README.md"); // file + + // Environment is last + expect(children[children.length - 1].entryKind).toBe("environment-root"); + }); +}); From f8116cf39eba732f11f4a68cb35d1dfdc601ec31 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 17:57:53 -0400 Subject: [PATCH 12/23] =?UTF-8?q?refactor:=20drop=20monorepo=20topology=20?= =?UTF-8?q?=E2=80=94=20multi-repo=20only=20(#880)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walk-up resolution removed: projects and environments are always separate git repos linked by [environment].slug (or .path). Resolution is now a three-strategy cascade: explicit path → workspace scan → registry. - Delete walkUp() and its three tests (parent, ws-root stop, ws-root find) - Delete walk-up-vs-explicit-conflict test - Add explicit-path-wins-over-workspace-scan priority test - Update cache test to use explicit path fixture (was walk-up) - Update detect.ts comment (remove monorepo root rationale) - Update file header (four-strategy → three-strategy) - Remove unused dirname import Net: -33 lines implementation, -3 tests (4 removed, 1 added). 2925 extension tests pass, 0 failures. --- packages/extension/src/project/detect.ts | 3 - .../src/project/resolve_environment.ts | 67 +++-------- .../test/project/resolve_environment.test.ts | 104 ++++++------------ 3 files changed, 52 insertions(+), 122 deletions(-) diff --git a/packages/extension/src/project/detect.ts b/packages/extension/src/project/detect.ts index c11010c4..5636d91b 100644 --- a/packages/extension/src/project/detect.ts +++ b/packages/extension/src/project/detect.ts @@ -16,9 +16,6 @@ export type ProjectType = "research" | "dev" | "environment"; * 2. `research-project.toml` exists → `"research"` * 3. Otherwise → `"dev"` * - * Environment takes priority (handles the monorepo root case where both - * manifests might coexist). - * * Re-evaluated on each call — no caching — so a directory that gains * a manifest after initial registration updates its type on next * resolution. diff --git a/packages/extension/src/project/resolve_environment.ts b/packages/extension/src/project/resolve_environment.ts index 8f1ea809..a1bbc8a7 100644 --- a/packages/extension/src/project/resolve_environment.ts +++ b/packages/extension/src/project/resolve_environment.ts @@ -1,20 +1,18 @@ -// resolve_environment.ts — Four-strategy environment resolution for a given +// resolve_environment.ts — Three-strategy environment resolution for a given // project path. Part of #882 (sub-issue of #880 Research Environments). // -// Resolution order: -// 1. Walk-up: look for research-environment.toml in ancestor directories, -// stopping at the workspace folder root -// 2. Explicit path: read [environment].path from the project's TOML -// 3. Workspace scan: check sibling workspace folders for a matching slug -// 4. Registry: look up the slug in ~/.amico/environments.toml +// Resolution order (multi-repo only — monorepo topology is not supported): +// 1. Explicit path: read [environment].path from the project's TOML +// 2. Workspace scan: check sibling workspace folders for a matching slug +// 3. Registry: look up the slug in ~/.amico/environments.toml // -// Walk-up wins over explicit path when both resolve (physical topology is -// authoritative). All failures are null + console.warn — never thrown errors. +// All strategies require an [environment] section in the project's TOML. +// All failures are null + console.warn — never thrown errors. // // NO VS Code API dependency — `workspaceRoots` is passed by the caller. import { existsSync, readFileSync } from "node:fs"; -import { join, dirname, resolve } from "node:path"; +import { join, resolve } from "node:path"; import { homedir } from "node:os"; import { parse as parseToml } from "smol-toml"; @@ -118,32 +116,7 @@ function readProjectEnvironmentSection( // ── Strategy implementations ──────────────────────────────────────────────── -/** Strategy 1: Walk up from projectDir, stopping at any workspace root. */ -function walkUp( - projectDir: string, - workspaceRoots: string[], -): ResolvedEnvironment | null { - const rootSet = new Set(workspaceRoots.map((r) => resolve(r))); - let current = resolve(projectDir); - - // Walk up, including the project dir itself (but typically skipped since - // a project dir doesn't have research-environment.toml) - while (true) { - const env = readEnvManifest(current); - if (env) return env; - - // Stop if we've reached a workspace root - if (rootSet.has(current)) break; - - const parent = dirname(current); - if (parent === current) break; // filesystem root - current = parent; - } - - return null; -} - -/** Strategy 2: Resolve via [environment].path in the project TOML. */ +/** Strategy 1: Resolve via [environment].path in the project TOML. */ function explicitPath(projectDir: string): ResolvedEnvironment | null { const section = readProjectEnvironmentSection(projectDir); if (!section?.path) return null; @@ -161,7 +134,7 @@ function explicitPath(projectDir: string): ResolvedEnvironment | null { return env; } -/** Strategy 3: Scan workspace folders for one whose manifest slug matches. */ +/** Strategy 2: Scan workspace folders for one whose manifest slug matches. */ function workspaceScan( projectDir: string, workspaceRoots: string[], @@ -179,7 +152,7 @@ function workspaceScan( return null; } -/** Strategy 4: Look up the slug in the environment registry. */ +/** Strategy 3: Look up the slug in the environment registry. */ function registryLookup( projectDir: string, registryPath: string, @@ -217,8 +190,9 @@ function registryLookup( * Resolve the research environment for a project directory. * Returns null if no environment is found or all candidates are invalid. * - * Resolution order: walk-up → explicit path → workspace scan → registry. - * Walk-up wins over explicit path (physical topology is authoritative). + * Resolution order: explicit path → workspace scan → registry. + * All strategies require an [environment] section in the project's TOML + * (multi-repo topology only — projects and environments are separate repos). * * Results are cached per project path. Call `invalidateEnvironmentCache()` * when manifest files change. @@ -236,28 +210,21 @@ export function resolveEnvironment( const registryPath = opts?.registryPath ?? join(homedir(), ".amico", "environments.toml"); - // Strategy 1: walk-up (physical topology — authoritative) - const walkUpResult = walkUp(projectPath, workspaceRoots); - if (walkUpResult) { - cache.set(key, walkUpResult); - return walkUpResult; - } - - // Strategy 2: explicit [environment].path + // Strategy 1: explicit [environment].path const explicitResult = explicitPath(projectPath); if (explicitResult) { cache.set(key, explicitResult); return explicitResult; } - // Strategy 3: workspace folder scan + // Strategy 2: workspace folder scan const scanResult = workspaceScan(projectPath, workspaceRoots); if (scanResult) { cache.set(key, scanResult); return scanResult; } - // Strategy 4: registry lookup + // Strategy 3: registry lookup const regResult = registryLookup(projectPath, registryPath); if (regResult) { cache.set(key, regResult); diff --git a/packages/extension/test/project/resolve_environment.test.ts b/packages/extension/test/project/resolve_environment.test.ts index ec75b215..96b4aaef 100644 --- a/packages/extension/test/project/resolve_environment.test.ts +++ b/packages/extension/test/project/resolve_environment.test.ts @@ -1,13 +1,15 @@ -// resolve_environment.test.ts — fixture-based tests for the four-strategy +// resolve_environment.test.ts — fixture-based tests for the three-strategy // environment resolution + edge cases. Part of #882. +// +// Multi-repo only: walk-up was removed — projects and environments are always +// separate repos linked by [environment].slug (or .path). import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveEnvironment, invalidateEnvironmentCache, - type ResolvedEnvironment, } from "../../src/project/resolve_environment"; describe("resolveEnvironment", () => { @@ -42,45 +44,7 @@ describe("resolveEnvironment", () => { writeFileSync(join(dir, "research-project.toml"), toml); } - // ── Strategy 1: walk-up ──────────────────────────────────────────────── - - it("walk-up: finds environment in parent directory", () => { - const envDir = tmpDir; - makeEnv(envDir, "my-env", "My Env"); - const projectDir = join(envDir, "projects", "my-project"); - makeProject(projectDir); - - const result = resolveEnvironment(projectDir, [tmpDir]); - expect(result).not.toBeNull(); - expect(result!.slug).toBe("my-env"); - expect(result!.name).toBe("My Env"); - expect(result!.path).toBe(envDir); - }); - - it("walk-up: stops at workspace folder root (AC-53)", () => { - // Environment is ABOVE the workspace root — should NOT be found - const wsRoot = join(tmpDir, "workspace"); - mkdirSync(wsRoot, { recursive: true }); - makeEnv(tmpDir, "above-ws"); - const projectDir = join(wsRoot, "my-project"); - makeProject(projectDir); - - const result = resolveEnvironment(projectDir, [wsRoot]); - expect(result).toBeNull(); - }); - - it("walk-up: finds environment at the workspace root itself", () => { - const wsRoot = join(tmpDir, "workspace"); - makeEnv(wsRoot, "ws-env"); - const projectDir = join(wsRoot, "projects", "child"); - makeProject(projectDir); - - const result = resolveEnvironment(projectDir, [wsRoot]); - expect(result).not.toBeNull(); - expect(result!.slug).toBe("ws-env"); - }); - - // ── Strategy 2: explicit path ────────────────────────────────────────── + // ── Strategy 1: explicit path ────────────────────────────────────────── it("explicit path: resolves via [environment].path in project TOML", () => { const envDir = join(tmpDir, "shared-env"); @@ -104,7 +68,7 @@ describe("resolveEnvironment", () => { expect(result).toBeNull(); }); - // ── Strategy 3: workspace scan ───────────────────────────────────────── + // ── Strategy 2: workspace scan ───────────────────────────────────────── it("workspace scan: finds environment in a sibling workspace folder", () => { const envDir = join(tmpDir, "env-folder"); @@ -118,7 +82,7 @@ describe("resolveEnvironment", () => { expect(result!.path).toBe(envDir); }); - // ── Strategy 4: registry ─────────────────────────────────────────────── + // ── Strategy 3: registry ─────────────────────────────────────────────── it("registry: finds environment from ~/.amico/environments.toml", () => { const envDir = join(tmpDir, "registered-env"); @@ -135,6 +99,26 @@ describe("resolveEnvironment", () => { expect(result!.path).toBe(envDir); }); + // ── Priority: explicit path wins over workspace scan ─────────────────── + + it("explicit path wins over workspace scan when both match", () => { + // Environment A: reachable via explicit path + const envA = join(tmpDir, "env-explicit"); + makeEnv(envA, "env-a", "Env A"); + + // Environment B: reachable via workspace scan (same slug as project's [environment].slug) + const envB = join(tmpDir, "env-workspace"); + makeEnv(envB, "env-a", "Env B"); // same slug, different dir + + const projectDir = join(tmpDir, "my-project"); + makeProject(projectDir, { slug: "env-a", path: envA }); + + // Both strategies could match — explicit path should win + const result = resolveEnvironment(projectDir, [projectDir, envB]); + expect(result).not.toBeNull(); + expect(result!.path).toBe(envA); + }); + // ── Edge cases ───────────────────────────────────────────────────────── it("project without [environment] section returns null (AC-4)", () => { @@ -149,8 +133,8 @@ describe("resolveEnvironment", () => { const envDir = join(tmpDir, "bad-env"); mkdirSync(envDir, { recursive: true }); writeFileSync(join(envDir, "research-environment.toml"), "this is not valid toml {{{{"); - const projectDir = join(envDir, "projects", "test"); - makeProject(projectDir); + const projectDir = join(tmpDir, "my-project"); + makeProject(projectDir, { slug: "bad", path: envDir }); const result = resolveEnvironment(projectDir, [tmpDir]); expect(result).toBeNull(); @@ -163,36 +147,18 @@ describe("resolveEnvironment", () => { join(envDir, "research-environment.toml"), `schema_version = 999\nname = "future"\nslug = "future"\ncreated = "2026-09-07"\n`, ); - const projectDir = join(envDir, "projects", "test"); - makeProject(projectDir); + const projectDir = join(tmpDir, "my-project"); + makeProject(projectDir, { slug: "future", path: envDir }); const result = resolveEnvironment(projectDir, [tmpDir]); expect(result).toBeNull(); }); - it("walk-up vs explicit slug conflict: prefers walk-up", () => { - // Walk-up environment - const walkupEnv = tmpDir; - makeEnv(walkupEnv, "walkup-env"); - - // Explicit path environment (different slug) - const explicitEnv = join(tmpDir, "other-env"); - makeEnv(explicitEnv, "explicit-env"); - - // Project has [environment].slug pointing to explicit, but walk-up finds walkup-env - const projectDir = join(tmpDir, "projects", "child"); - makeProject(projectDir, { slug: "explicit-env", path: explicitEnv }); - - const result = resolveEnvironment(projectDir, [tmpDir]); - expect(result).not.toBeNull(); - expect(result!.slug).toBe("walkup-env"); - }); - it("caches result per project path", () => { - const envDir = tmpDir; + const envDir = join(tmpDir, "cached-env"); makeEnv(envDir, "cached-env"); - const projectDir = join(envDir, "projects", "test"); - makeProject(projectDir); + const projectDir = join(tmpDir, "my-project"); + makeProject(projectDir, { slug: "cached-env", path: envDir }); const first = resolveEnvironment(projectDir, [tmpDir]); expect(first).not.toBeNull(); From 4633b4cb2a19c9d1063b21fddbeb9e2d7036f980 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 18:07:42 -0400 Subject: [PATCH 13/23] =?UTF-8?q?refactor:=20enforce=20multi-repo=20topolo?= =?UTF-8?q?gy=20=E2=80=94=20nesting=20guard=20+=20scaffold=20cleanup=20(#8?= =?UTF-8?q?80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Environments hold shared knowledge, not project-level work. Projects are always separate repos. - Drop experiments/ and results/ from ENV_SCAFFOLD_DIRS (9 → 7 dirs) - Drop experiment and result from TYPE_ROUTE (promote rejects them) - Add checkNestingViolation(): walks ancestors for research-project.toml or research-environment.toml, refuses if found - Wire guard into envCreate — env init inside a project or another env is a hard error with a clear message - Export checkNestingViolation for direct testing - 5 new tests (2 envCreate guard, 3 checkNestingViolation), all green - Update scaffold test: 7 dirs, explicitly assert no experiments/results 50 env tests pass, 0 failures. --- packages/amico-run/src/env_verb.ts | 51 ++++++++++++- packages/amico-run/src/environment.ts | 6 +- packages/amico-run/test/env_verb.test.ts | 81 ++++++++++++++++++++- packages/amico-run/test/environment.test.ts | 12 ++- 4 files changed, 138 insertions(+), 12 deletions(-) diff --git a/packages/amico-run/src/env_verb.ts b/packages/amico-run/src/env_verb.ts index b9ef1d9d..2bd373d0 100644 --- a/packages/amico-run/src/env_verb.ts +++ b/packages/amico-run/src/env_verb.ts @@ -3,7 +3,7 @@ // git init, flag parsing, and the verb dispatch. Part of #881. import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { join, resolve, dirname } from "node:path"; import { homedir } from "node:os"; import { parse as parseToml } from "smol-toml"; import { @@ -82,6 +82,45 @@ function upsertRegistryEntry( return { replaced }; } +// ── nesting guard ─────────────────────────────────────────────────────────── + +/** Walk ancestors looking for a manifest file. Returns the first dir that has one, or null. */ +function findAncestorManifest(dir: string, manifest: string): string | null { + let current = resolve(dir); + // Start from the PARENT — we don't care about dir itself (it may be the env we're creating) + current = dirname(current); + while (true) { + if (existsSync(join(current, manifest))) return current; + const parent = dirname(current); + if (parent === current) return null; // filesystem root + current = parent; + } +} + +/** + * Guard: environments and projects must not be nested inside each other. + * Multi-repo only — each is its own git repo at its own root. + */ +export function checkNestingViolation( + dir: string, +): { ok: true } | { ok: false; error: string } { + const parentProject = findAncestorManifest(dir, "research-project.toml"); + if (parentProject) { + return { + ok: false, + error: `cannot create environment inside project ${parentProject} — environments and projects must be separate repos`, + }; + } + const parentEnv = findAncestorManifest(dir, "research-environment.toml"); + if (parentEnv) { + return { + ok: false, + error: `cannot create environment inside environment ${parentEnv} — environments must not be nested`, + }; + } + return { ok: true }; +} + // ── create ────────────────────────────────────────────────────────────────── export function envCreate(argv: string[], opts?: EnvVerbOptions): VerbResult { @@ -96,6 +135,10 @@ export function envCreate(argv: string[], opts?: EnvVerbOptions): VerbResult { const slug = nameToSlug(name); const envDir = resolve(flagValue(argv, "--path") ?? join(process.cwd(), slug)); + // Nesting guard: environments must not be created inside projects or other environments + const nestCheck = checkNestingViolation(envDir); + if (!nestCheck.ok) return fail(nestCheck.error); + // Idempotent: if research-environment.toml already exists, validate and return const tomlPath = join(envDir, "research-environment.toml"); if (existsSync(tomlPath)) { @@ -243,15 +286,15 @@ export function envRegister(argv: string[], opts?: EnvVerbOptions): VerbResult { // ── promote ───────────────────────────────────────────────────────────────── -/** Type → target directory routing map. */ +/** Type → target directory routing map. + * Only environment-level directories — project-level types (experiment, result) + * are not promotable since they belong to the project, not the environment. */ const TYPE_ROUTE: Record = { insight: "insights", method: "methods", context: "context", literature: "literature", - experiment: "experiments", template: "templates", - result: "results", }; /** Extract a simple YAML frontmatter value from a markdown file. */ diff --git a/packages/amico-run/src/environment.ts b/packages/amico-run/src/environment.ts index 5919e0a7..3cfde63c 100644 --- a/packages/amico-run/src/environment.ts +++ b/packages/amico-run/src/environment.ts @@ -28,17 +28,17 @@ export interface EnvironmentRegistryEntry { path: string; } -/** The prescribed directory layout for a Research Environment (PRD #880). */ +/** The prescribed directory layout for a Research Environment (PRD #880). + * Environments hold shared knowledge — not project-level work. Projects are + * always separate repos linked by [environment].slug. */ export const ENV_SCAFFOLD_DIRS = [ "insights", "methods", "context", "literature", - "experiments", "lib", "templates", "config", - "results", ] as const; // ── validation ────────────────────────────────────────────────────────────── diff --git a/packages/amico-run/test/env_verb.test.ts b/packages/amico-run/test/env_verb.test.ts index b6d3222c..e7ccaad9 100644 --- a/packages/amico-run/test/env_verb.test.ts +++ b/packages/amico-run/test/env_verb.test.ts @@ -7,7 +7,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { parse as parseToml } from "smol-toml"; -import { envCreate, envRegister } from "../src/env_verb.js"; +import { envCreate, envRegister, checkNestingViolation } from "../src/env_verb.js"; import { ENV_SCAFFOLD_DIRS, renderEnvironmentToml, type EnvironmentToml } from "../src/environment.js"; // ── integration: env create verb ─────────────────────────────────────────── @@ -100,6 +100,34 @@ describe("envCreate", () => { expect(result.code).toBe(64); expect((result.json as Record).error).toBeDefined(); }); + + it("refuses to create an environment inside a project directory", () => { + // Create a project directory first + const projectDir = join(tmpDir, "my-project"); + mkdirSync(projectDir, { recursive: true }); + writeFileSync( + join(projectDir, "research-project.toml"), + `schema_version = 1\nname = "Test"\nslug = "test"\nquestion = "?"\nstatus = "running"\ncreated = "2026-09-07"\n`, + ); + + // Try to create an environment inside it + const envDir = join(projectDir, "shared-env"); + const result = envCreate(["Shared", "--path", envDir], { registryPath }); + expect(result.code).toBe(64); + expect((result.json as Record).error).toContain("separate repos"); + }); + + it("refuses to create an environment inside another environment", () => { + // Create a parent environment + const parentDir = join(tmpDir, "parent-env"); + envCreate(["Parent", "--path", parentDir], { registryPath }); + + // Try to nest another environment inside it + const childDir = join(parentDir, "child-env"); + const result = envCreate(["Child", "--path", childDir], { registryPath }); + expect(result.code).toBe(64); + expect((result.json as Record).error).toContain("must not be nested"); + }); }); // ── integration: env register verb ───────────────────────────────────────── @@ -188,3 +216,54 @@ describe("envRegister", () => { expect(result.code).toBe(64); }); }); + +// ── nesting guard ────────────────────────────────────────────────────────── + +describe("checkNestingViolation", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "amico-nesting-guard-")); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns ok for a standalone directory", () => { + const dir = join(tmpDir, "clean-env"); + mkdirSync(dir, { recursive: true }); + const result = checkNestingViolation(dir); + expect(result.ok).toBe(true); + }); + + it("rejects a directory nested inside a project", () => { + const projectDir = join(tmpDir, "project"); + mkdirSync(projectDir, { recursive: true }); + writeFileSync( + join(projectDir, "research-project.toml"), + `schema_version = 1\nname = "P"\nslug = "p"\nquestion = "?"\nstatus = "running"\ncreated = "2026-09-07"\n`, + ); + + const nested = join(projectDir, "sub", "env"); + mkdirSync(nested, { recursive: true }); + const result = checkNestingViolation(nested); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("separate repos"); + }); + + it("rejects a directory nested inside another environment", () => { + const parentEnv = join(tmpDir, "parent-env"); + mkdirSync(parentEnv, { recursive: true }); + writeFileSync( + join(parentEnv, "research-environment.toml"), + `schema_version = 1\nname = "P"\nslug = "p"\ncreated = "2026-09-07"\n`, + ); + + const nested = join(parentEnv, "child"); + mkdirSync(nested, { recursive: true }); + const result = checkNestingViolation(nested); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("must not be nested"); + }); +}); diff --git a/packages/amico-run/test/environment.test.ts b/packages/amico-run/test/environment.test.ts index e3b6008e..b4b61283 100644 --- a/packages/amico-run/test/environment.test.ts +++ b/packages/amico-run/test/environment.test.ts @@ -244,17 +244,21 @@ describe("renderEnvironmentRegistry", () => { // ── pure logic: scaffold dirs ────────────────────────────────────────────── describe("ENV_SCAFFOLD_DIRS", () => { - it("contains all 9 prescribed directories", () => { - expect(ENV_SCAFFOLD_DIRS).toHaveLength(9); + it("contains all 7 prescribed directories (shared knowledge only)", () => { + expect(ENV_SCAFFOLD_DIRS).toHaveLength(7); expect(ENV_SCAFFOLD_DIRS).toContain("insights"); expect(ENV_SCAFFOLD_DIRS).toContain("methods"); expect(ENV_SCAFFOLD_DIRS).toContain("context"); expect(ENV_SCAFFOLD_DIRS).toContain("literature"); - expect(ENV_SCAFFOLD_DIRS).toContain("experiments"); expect(ENV_SCAFFOLD_DIRS).toContain("lib"); expect(ENV_SCAFFOLD_DIRS).toContain("templates"); expect(ENV_SCAFFOLD_DIRS).toContain("config"); - expect(ENV_SCAFFOLD_DIRS).toContain("results"); + }); + + it("does not contain project-level directories", () => { + const dirs = [...ENV_SCAFFOLD_DIRS] as string[]; + expect(dirs).not.toContain("experiments"); + expect(dirs).not.toContain("results"); }); }); From 8a014273deb420c131c1187681ba94a41bff6b13 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 19:06:54 -0400 Subject: [PATCH 14/23] =?UTF-8?q?feat(amico-run):=20envBind=20verb=20?= =?UTF-8?q?=E2=80=94=20string-append=20bind,=20idempotent,=20force=20re-re?= =?UTF-8?q?nder,=20registry=20warning=20(#892)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/amico-run/src/env_verb.ts | 116 ++++++++++++++++++- packages/amico-run/test/env_verb.test.ts | 140 ++++++++++++++++++++++- 2 files changed, 254 insertions(+), 2 deletions(-) diff --git a/packages/amico-run/src/env_verb.ts b/packages/amico-run/src/env_verb.ts index 2bd373d0..8b258bc0 100644 --- a/packages/amico-run/src/env_verb.ts +++ b/packages/amico-run/src/env_verb.ts @@ -17,6 +17,7 @@ import { type EnvironmentRegistryEntry, type EnvironmentToml, } from "./environment.js"; +import { renderProjectToml, type ProjectToml } from "./project.js"; import type { VerbResult } from "./verbs.js"; /** Options for DI in tests (registry path override). */ @@ -483,6 +484,118 @@ export function envPromote(argv: string[]): VerbResult { }; } +// ── bind ──────────────────────────────────────────────────────────────────── + +export function envBind(argv: string[], opts?: EnvVerbOptions): VerbResult { + const fail = (error: string): VerbResult => ({ + json: { verb: "env", subcommand: "bind", error }, + code: 64, + }); + + const slug = positionalArg(argv); + if (!slug) return fail("slug is required: amico env bind [--path ] [--env-path ] [--force]"); + + const projectDir = resolve(flagValue(argv, "--path") ?? process.cwd()); + const envPath = flagValue(argv, "--env-path"); + const force = argv.includes("--force"); + + // Validate: research-project.toml must exist + const tomlPath = join(projectDir, "research-project.toml"); + if (!existsSync(tomlPath)) { + return fail(`no research-project.toml found in ${projectDir}`); + } + + let content: string; + try { + content = readFileSync(tomlPath, "utf8"); + } catch (e) { + return fail(`failed to read ${tomlPath}: ${e instanceof Error ? e.message : String(e)}`); + } + + // Check registry for a warning + const registryPath = opts?.registryPath ?? defaultRegistryPath(); + const entries = readRegistry(registryPath); + const inRegistry = entries.some((e) => e.slug === slug); + const warning = inRegistry ? undefined : `slug "${slug}" not found in registry — binding anyway`; + + // Check if [environment] section already exists + const parsed = parseToml(content) as Record; + const existingEnv = parsed.environment as { slug?: string; path?: string } | undefined; + + if (existingEnv?.slug) { + if (existingEnv.slug === slug) { + // Idempotent — same slug already bound + return { + json: { + verb: "env", + subcommand: "bind", + bound: true, + idempotent: true, + slug, + ...(warning ? { warning } : {}), + }, + code: 0, + }; + } + // Different slug — require --force + if (!force) { + return fail( + `project is already bound to "${existingEnv.slug}" — use --force to rebind to "${slug}"`, + ); + } + // Force: full parse → mutate → re-render + try { + const projectData = parsed as Record; + (projectData.environment as Record) = { + slug, + ...(envPath ? { path: envPath } : {}), + }; + writeFileSync(tomlPath, renderProjectToml(projectData as ProjectToml)); + } catch (e) { + return fail(`failed to write ${tomlPath}: ${e instanceof Error ? e.message : String(e)}`); + } + + return { + json: { + verb: "env", + subcommand: "bind", + bound: true, + slug, + forced: true, + ...(warning ? { warning } : {}), + }, + code: 0, + }; + } + + // Initial bind: string append (preserves comments and formatting) + const envSection = [ + "", + "[environment]", + `slug = "${slug}"`, + ...(envPath ? [`path = "${envPath}"`] : []), + "", + ].join("\n"); + + try { + const appendContent = content.endsWith("\n") ? envSection : "\n" + envSection; + writeFileSync(tomlPath, content + appendContent); + } catch (e) { + return fail(`failed to write ${tomlPath}: ${e instanceof Error ? e.message : String(e)}`); + } + + return { + json: { + verb: "env", + subcommand: "bind", + bound: true, + slug, + ...(warning ? { warning } : {}), + }, + code: 0, + }; +} + // ── dispatch ──────────────────────────────────────────────────────────────── export function envVerb(argv: string[]): VerbResult { @@ -491,11 +604,12 @@ export function envVerb(argv: string[]): VerbResult { if (sub === "create") return envCreate(rest); if (sub === "register") return envRegister(rest); if (sub === "promote") return envPromote(rest); + if (sub === "bind") return envBind(rest); return { json: { verb: "env", error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, - usage: "amico env create [--path ] [--platform

] [--field ] [--author ] | amico env register | amico env promote --env [--dry-run] [--target-dir

]", + usage: "amico env create [--path ] [--platform

] [--field ] [--author ] | amico env register | amico env promote --env [--dry-run] [--target-dir

] | amico env bind [--path ] [--env-path ] [--force]", }, code: 64, }; diff --git a/packages/amico-run/test/env_verb.test.ts b/packages/amico-run/test/env_verb.test.ts index e7ccaad9..c1f6a179 100644 --- a/packages/amico-run/test/env_verb.test.ts +++ b/packages/amico-run/test/env_verb.test.ts @@ -7,7 +7,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { parse as parseToml } from "smol-toml"; -import { envCreate, envRegister, checkNestingViolation } from "../src/env_verb.js"; +import { envCreate, envRegister, envBind, checkNestingViolation } from "../src/env_verb.js"; import { ENV_SCAFFOLD_DIRS, renderEnvironmentToml, type EnvironmentToml } from "../src/environment.js"; // ── integration: env create verb ─────────────────────────────────────────── @@ -217,6 +217,144 @@ describe("envRegister", () => { }); }); +// ── integration: env bind verb ────────────────────────────────────────────── + +describe("envBind", () => { + let tmpDir: string; + let registryPath: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "amico-env-bind-")); + registryPath = join(tmpDir, "environments.toml"); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + /** Helper: create a minimal research-project.toml in a project directory. */ + function makeProjectDir(name = "my-project", extraToml = ""): string { + const dir = join(tmpDir, name); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "research-project.toml"), + `schema_version = 1\nname = "Test"\nslug = "test"\nquestion = "?"\nstatus = "running"\ncreated = "2026-09-07"\n${extraToml}`, + ); + return dir; + } + + /** Helper: create a minimal environment and register it. */ + function makeEnvAndRegister(slug: string): string { + const dir = join(tmpDir, slug); + envCreate([slug, "--path", dir], { registryPath }); + return dir; + } + + it("writes [environment] section via string append (preserves comments)", () => { + const projectDir = makeProjectDir("proj1"); + makeEnvAndRegister("shared-env"); + + const result = envBind(["shared-env", "--path", projectDir], { registryPath }); + + expect(result.code).toBe(0); + const json = result.json as Record; + expect(json.bound).toBe(true); + expect(json.slug).toBe("shared-env"); + + // Verify the TOML was appended (not re-rendered): original content intact + const content = readFileSync(join(projectDir, "research-project.toml"), "utf8"); + expect(content).toContain('[environment]'); + expect(content).toContain('slug = "shared-env"'); + // Original top-level fields still present in original form + expect(content).toContain('schema_version = 1'); + expect(content).toContain('question = "?"'); + }); + + it("is idempotent on same slug", () => { + const projectDir = makeProjectDir("proj2"); + makeEnvAndRegister("my-env"); + + const first = envBind(["my-env", "--path", projectDir], { registryPath }); + expect(first.code).toBe(0); + + const second = envBind(["my-env", "--path", projectDir], { registryPath }); + expect(second.code).toBe(0); + const json = second.json as Record; + expect(json.idempotent).toBe(true); + }); + + it("refuses different slug without --force", () => { + const projectDir = makeProjectDir("proj3"); + makeEnvAndRegister("env-a"); + makeEnvAndRegister("env-b"); + + envBind(["env-a", "--path", projectDir], { registryPath }); + const result = envBind(["env-b", "--path", projectDir], { registryPath }); + + expect(result.code).toBe(64); + const json = result.json as Record; + expect(json.error).toContain("env-a"); + }); + + it("allows different slug with --force (full re-render)", () => { + const projectDir = makeProjectDir("proj4"); + makeEnvAndRegister("env-a"); + makeEnvAndRegister("env-b"); + + envBind(["env-a", "--path", projectDir], { registryPath }); + const result = envBind(["env-b", "--path", projectDir, "--force"], { registryPath }); + + expect(result.code).toBe(0); + const json = result.json as Record; + expect(json.bound).toBe(true); + expect(json.slug).toBe("env-b"); + + // Verify the slug was updated + const content = readFileSync(join(projectDir, "research-project.toml"), "utf8"); + expect(content).toContain('slug = "env-b"'); + // Should NOT contain the old slug in an [environment] context + const envSection = content.slice(content.indexOf("[environment]")); + expect(envSection).not.toContain("env-a"); + }); + + it("returns error when no research-project.toml found", () => { + const emptyDir = join(tmpDir, "no-manifest"); + mkdirSync(emptyDir, { recursive: true }); + + const result = envBind(["some-env", "--path", emptyDir], { registryPath }); + expect(result.code).toBe(64); + expect((result.json as Record).error).toContain("research-project.toml"); + }); + + it("warns but allows when slug is not in the registry", () => { + const projectDir = makeProjectDir("proj5"); + // Do NOT create/register the environment — just bind the slug + + const result = envBind(["unknown-env", "--path", projectDir], { registryPath }); + + expect(result.code).toBe(0); + const json = result.json as Record; + expect(json.bound).toBe(true); + expect(json.warning).toContain("not found in registry"); + + // Verify it still wrote the binding + const content = readFileSync(join(projectDir, "research-project.toml"), "utf8"); + expect(content).toContain('slug = "unknown-env"'); + }); + + it("supports --env-path to set an explicit path in the [environment] section", () => { + const projectDir = makeProjectDir("proj6"); + makeEnvAndRegister("env-with-path"); + + const envPath = "/some/absolute/path/to/env"; + const result = envBind(["env-with-path", "--path", projectDir, "--env-path", envPath], { registryPath }); + + expect(result.code).toBe(0); + const content = readFileSync(join(projectDir, "research-project.toml"), "utf8"); + expect(content).toContain(`path = "${envPath}"`); + }); +}); + // ── nesting guard ────────────────────────────────────────────────────────── describe("checkNestingViolation", () => { From ed2a8c607a148bfb391b2f2464e368364d7de0e6 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 19:07:40 -0400 Subject: [PATCH 15/23] feat(extension): wire bind-to-environment and promote-to-environment sidebar bridge handlers (#892) --- packages/extension/src/sidebar_bridge.ts | 10 ++++ packages/extension/test/sidebar_view.test.ts | 48 ++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/packages/extension/src/sidebar_bridge.ts b/packages/extension/src/sidebar_bridge.ts index 122bbe3b..aa8765ed 100644 --- a/packages/extension/src/sidebar_bridge.ts +++ b/packages/extension/src/sidebar_bridge.ts @@ -172,6 +172,10 @@ export interface SidebarMessageHandlers { reorderRoot: (sourcePath: string, targetPath: string, position: "before" | "after") => void; /** Notify the chat panel that a file was moved/renamed so Files Changed updates. */ notifyFileMove?: (oldPath: string, newPath: string, op: string) => void; + /** Bind a research project to an environment (#892). */ + bindToEnvironment?: (projectPath: string) => void; + /** Promote a file to the resolved environment (#892). */ + promoteToEnvironment?: (filePath: string) => void; } /** @@ -217,6 +221,12 @@ export function handleSidebarMessage( case "reorder-root": handlers.reorderRoot(msg.sourcePath, msg.targetPath, msg.position); break; + case "bind-to-environment": + handlers.bindToEnvironment?.(msg.projectPath); + break; + case "promote-to-environment": + handlers.promoteToEnvironment?.(msg.filePath); + break; case "file-op": { const { kind: _k, ...req } = msg; return handlers.fileOp(req as FileOpRequest).then((result) => { diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index 5746256a..b668614a 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -1200,6 +1200,54 @@ describe("sidebar — reorderWorkspaceFolder end-to-end", () => { }); }); +// ── Environment bridge messages (#892) ─────────────────────────────────────── + +describe("sidebar bridge — environment messages", () => { + let handleSidebarMessage: any; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("../src/sidebar_bridge"); + handleSidebarMessage = mod.handleSidebarMessage; + }); + + function makeHandlers(overrides: Record = {}) { + return { + openChat: vi.fn(), + newProject: vi.fn(), + addExisting: vi.fn(), + getRoots: vi.fn(), + getChildren: vi.fn().mockResolvedValue([]), + openFile: vi.fn(), + fileOp: vi.fn().mockResolvedValue({ ok: true }), + postMessage: vi.fn(), + setSectionOrder: vi.fn(), + reorderRoot: vi.fn(), + bindToEnvironment: vi.fn(), + promoteToEnvironment: vi.fn(), + ...overrides, + }; + } + + it("bind-to-environment calls bindToEnvironment handler with projectPath", () => { + const handlers = makeHandlers(); + handleSidebarMessage( + { kind: "bind-to-environment", projectPath: "/projects/quantum-sim" }, + handlers, + ); + expect(handlers.bindToEnvironment).toHaveBeenCalledWith("/projects/quantum-sim"); + }); + + it("promote-to-environment calls promoteToEnvironment handler with filePath", () => { + const handlers = makeHandlers(); + handleSidebarMessage( + { kind: "promote-to-environment", filePath: "/projects/quantum-sim/insights/finding.md" }, + handlers, + ); + expect(handlers.promoteToEnvironment).toHaveBeenCalledWith("/projects/quantum-sim/insights/finding.md"); + }); +}); + // ── Section labels and text (#673 polish) ──────────────────────────────────── describe("sidebar webview — section labels", () => { From a0c64eec71e355d0477701870a159b6b42b8cd9e Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 19:10:14 -0400 Subject: [PATCH 16/23] feat(extension): createNewEnvironment + 3 VS Code command handlers (newEnvironment, bindToEnvironment, promoteToEnvironment) (#892) --- packages/extension/src/extension.ts | 48 ++++++- packages/extension/src/sidebar_view.ts | 53 ++++++++ packages/extension/test/sidebar_view.test.ts | 134 +++++++++++++++++++ 3 files changed, 234 insertions(+), 1 deletion(-) diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 84b470a4..6bdaaca8 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -7,7 +7,7 @@ import { resolveOpencodeBinary, OpencodeMissingError, unsupportedHostAdvice } fr import { resolveSelectedLaunch, HARNESS_REGISTRY } from "./harness"; import { ChatPanel } from "./chat_panel"; import { DeckPanel } from "./deck_panel"; -import { SidebarViewProvider, createNewProject } from "./sidebar_view"; +import { SidebarViewProvider, createNewProject, createNewEnvironment } from "./sidebar_view"; import { StatusBarManager } from "./status_bar"; import { prepareOpencodeProject, @@ -1827,6 +1827,52 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }, }), ), + // New Environment: Command Palette → save dialog → mkdir → workspace → session + // with /create-research-environment auto-sent. Mirrors amicode.newProject. (#892) + vscode.commands.registerCommand("amicode.newEnvironment", () => + createNewEnvironment({ + isServerReady: () => !!opencodeReadyUrl, + launchSession: (prompt: string) => { + const readyUrl = opencodeReadyUrl; + if (!readyUrl) return; + const panel = ChatPanel.openOrReveal(ctx, frameUrl() ?? readyUrl, serverAuthToken(serverPassword), opencodeProject.projectDir); + const encodedPrompt = encodeURIComponent(prompt); + const navPath = `/new-session?prompt=${encodedPrompt}&autoSend=1`; + const envelope = { source: "amicode", kind: "navigate", path: navPath }; + const send = () => void panel.postMessage(envelope); + send(); + ChatPanel.onAppReady(send); + }, + }), + ), + // Bind to Environment: Command Palette → quick-pick from registry → amico env bind. (#892) + vscode.commands.registerCommand("amicode.bindToEnvironment", async () => { + const readyUrl = opencodeReadyUrl; + if (!readyUrl) { + vscode.window.showWarningMessage( + "Amicode: opencode server isn't ready yet. Check the 'Amicode — opencode' output channel.", + ); + return; + } + const panel = ChatPanel.openOrReveal(ctx, frameUrl() ?? readyUrl, serverAuthToken(serverPassword), opencodeProject.projectDir); + const encodedPrompt = encodeURIComponent("/amico env bind"); + const navPath = `/new-session?prompt=${encodedPrompt}&autoSend=1`; + const envelope = { source: "amicode", kind: "navigate", path: navPath }; + const send = () => void panel.postMessage(envelope); + send(); + ChatPanel.onAppReady(send); + }), + // Promote to Environment: Command Palette → active file → amico env promote in terminal. (#892) + vscode.commands.registerCommand("amicode.promoteToEnvironment", async () => { + const activeFile = vscode.window.activeTextEditor?.document.uri.fsPath; + if (!activeFile) { + void vscode.window.showWarningMessage("Amicode: no active file to promote."); + return; + } + const terminal = vscode.window.createTerminal("Amicode: promote"); + terminal.show(); + terminal.sendText(`amico env promote "${activeFile}"`); + }), // Chat Deck: MANY panes inside ONE editor tab — tab strips, drag-to-split, // merge-back, sashes (dist/deck_shell.js). Same ready/creds gates as the // other chat entries. The deck shares the one server with every ChatPanel. diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index f9ebca83..f39d084b 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -1088,6 +1088,59 @@ export async function createNewProject(ctx: NewProjectContext): Promise { ctx.launchSession(prompt); } +// ── New environment command (#892) ──────────────────────────────────────────── + +/** Context dependencies injected by extension.ts when registering the command. */ +export interface NewEnvironmentContext { + isServerReady: () => boolean; + launchSession: (prompt: string) => void; + /** Override for testing — defaults to fs.mkdirSync. */ + mkdirSync?: (dir: string, opts?: { recursive?: boolean }) => void; +} + +/** + * "New Environment" flow: save-dialog (user types folder name) → mkdir → workspace → session. + * Mirrors createNewProject but spawns a /create-research-environment session. + * Exported for testing; the amicode.newEnvironment command delegates here. + */ +export async function createNewEnvironment(ctx: NewEnvironmentContext): Promise { + if (!ctx.isServerReady()) { + void vscode.window.showWarningMessage( + "Amicode: opencode server isn't ready yet. Check the 'Amicode — opencode' output channel.", + ); + return; + } + + const uri = await vscode.window.showSaveDialog({ + title: "Name your new environment", + saveLabel: "Create", + defaultUri: vscode.Uri.file(path.join(os.homedir(), "my-environment")), + }); + if (!uri) return; + + const dir = uri.fsPath; + const mkdir = ctx.mkdirSync ?? ((d: string, o?: { recursive?: boolean }) => fs.mkdirSync(d, o)); + try { + mkdir(dir, { recursive: true }); + } catch (e) { + void vscode.window.showErrorMessage(`Amicode: could not create environment directory — ${(e as Error).message}`); + return; + } + + const folders = vscode.workspace.workspaceFolders ?? []; + const alreadyInWorkspace = folders.some((f) => f.uri.fsPath === dir); + if (alreadyInWorkspace) { + void vscode.window.showWarningMessage( + `"${path.basename(dir)}" is already in the workspace — opening a session for it.`, + ); + } else { + vscode.workspace.updateWorkspaceFolders(folders.length, 0, { uri: vscode.Uri.file(dir) }); + } + + const prompt = `/create-research-environment --path "${dir}"`; + ctx.launchSession(prompt); +} + /** * Reorder a workspace folder by building the desired final order and replacing * all folders in a single atomic updateWorkspaceFolders call (#712). diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index b668614a..353dc511 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -1248,6 +1248,140 @@ describe("sidebar bridge — environment messages", () => { }); }); +// ── createNewEnvironment command (#892) ────────────────────────────────────── + +describe("createNewEnvironment", () => { + let createNewEnvironment: any; + let vscodeMock: any; + + beforeEach(async () => { + vi.resetModules(); + // Get the vscode mock the sidebar_view module will use + vscodeMock = await import("vscode"); + const mod = await import("../src/sidebar_view"); + createNewEnvironment = mod.createNewEnvironment; + }); + + it("does nothing when server is not ready", async () => { + const ctx = { + isServerReady: () => false, + launchSession: vi.fn(), + }; + await createNewEnvironment(ctx); + expect(ctx.launchSession).not.toHaveBeenCalled(); + }); + + it("does nothing when user cancels the save dialog", async () => { + // Default mock returns undefined (cancel) + const ctx = { + isServerReady: () => true, + launchSession: vi.fn(), + }; + await createNewEnvironment(ctx); + expect(ctx.launchSession).not.toHaveBeenCalled(); + }); + + it("creates directory and launches session with /create-research-environment prompt", async () => { + const dir = "/tmp/test-env"; + const origDialog = vscodeMock.window.showSaveDialog; + vscodeMock.window.showSaveDialog = () => Promise.resolve(vscodeMock.Uri.file(dir)); + + const mkdirSync = vi.fn(); + const launchSession = vi.fn(); + + try { + await createNewEnvironment({ + isServerReady: () => true, + launchSession, + mkdirSync, + }); + } finally { + vscodeMock.window.showSaveDialog = origDialog; + } + + expect(mkdirSync).toHaveBeenCalledWith(dir, { recursive: true }); + expect(launchSession).toHaveBeenCalledWith(`/create-research-environment --path "${dir}"`); + }); + + it("shows error message when mkdir fails", async () => { + const origDialog = vscodeMock.window.showSaveDialog; + vscodeMock.window.showSaveDialog = () => Promise.resolve(vscodeMock.Uri.file("/tmp/fail-dir")); + const origShowError = vscodeMock.window.showErrorMessage; + const showErrorSpy = vi.fn(); + vscodeMock.window.showErrorMessage = showErrorSpy; + + const mkdirSync = vi.fn().mockImplementation(() => { throw new Error("EACCES"); }); + const launchSession = vi.fn(); + + try { + await createNewEnvironment({ + isServerReady: () => true, + launchSession, + mkdirSync, + }); + } finally { + vscodeMock.window.showSaveDialog = origDialog; + vscodeMock.window.showErrorMessage = origShowError; + } + + expect(launchSession).not.toHaveBeenCalled(); + expect(showErrorSpy).toHaveBeenCalled(); + }); +}); + +// ── VS Code command registrations (#892) ───────────────────────────────────── + +describe("VS Code command declarations", () => { + it("package.json declares amicode.newEnvironment command", () => { + const pkg = JSON.parse( + readFileSync(resolve(__dirname, "..", "package.json"), "utf8"), + ); + const commands = (pkg.contributes?.commands ?? []) as Array<{ command: string }>; + expect(commands.some(c => c.command === "amicode.newEnvironment")).toBe(true); + }); + + it("package.json declares amicode.bindToEnvironment command", () => { + const pkg = JSON.parse( + readFileSync(resolve(__dirname, "..", "package.json"), "utf8"), + ); + const commands = (pkg.contributes?.commands ?? []) as Array<{ command: string }>; + expect(commands.some(c => c.command === "amicode.bindToEnvironment")).toBe(true); + }); + + it("package.json declares amicode.promoteToEnvironment command", () => { + const pkg = JSON.parse( + readFileSync(resolve(__dirname, "..", "package.json"), "utf8"), + ); + const commands = (pkg.contributes?.commands ?? []) as Array<{ command: string }>; + expect(commands.some(c => c.command === "amicode.promoteToEnvironment")).toBe(true); + }); + + it("extension.ts registers amicode.newEnvironment command handler", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "extension.ts"), + "utf8", + ); + expect(src).toContain('"amicode.newEnvironment"'); + expect(src).toContain("createNewEnvironment"); + }); + + it("extension.ts registers amicode.bindToEnvironment command handler", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "extension.ts"), + "utf8", + ); + expect(src).toContain('"amicode.bindToEnvironment"'); + }); + + it("extension.ts registers amicode.promoteToEnvironment command handler", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "extension.ts"), + "utf8", + ); + expect(src).toContain('"amicode.promoteToEnvironment"'); + }); +}); + // ── Section labels and text (#673 polish) ──────────────────────────────────── describe("sidebar webview — section labels", () => { From 7fb5718bdc1abf22fd389899070bd1700ae0c6df Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 19:11:15 -0400 Subject: [PATCH 17/23] =?UTF-8?q?feat(skills):=20create-research-environme?= =?UTF-8?q?nt=20=E2=80=94=206-stage=20interview,=20post-creation=20binding?= =?UTF-8?q?,=20chained=20mode=20(#892)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../create-research-environment/SKILL.md | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 packages/extension/skills/create-research-environment/SKILL.md diff --git a/packages/extension/skills/create-research-environment/SKILL.md b/packages/extension/skills/create-research-environment/SKILL.md new file mode 100644 index 00000000..97097ae1 --- /dev/null +++ b/packages/extension/skills/create-research-environment/SKILL.md @@ -0,0 +1,165 @@ +--- +name: create-research-environment +description: Scaffold a new research environment — guided interview for research-environment.toml fields, then delegate to `amico env create`. Auto-invoked by the Command Palette's "New Environment" command. +agents: [] +surface: public +--- + +# Create Research Environment + +Scaffold a new research environment from the Command Palette ("Amicode: New +Environment") or from a chained session spawned by the `create-research-project` +or `migrate-research-project` skills. The command has already added the +directory to the workspace; this skill interviews the user for the +`research-environment.toml` fields and delegates to `amico env create` to write +the manifest, scaffold the directory tree, and run `git init`. + +## When to invoke + +- Auto-invoked when the Command Palette's "New Environment" command launches a + session with `/create-research-environment --path ""` +- Chained from `create-research-project` Stage 8 or `migrate-research-project` + Phase 6 when the user selects "Create new" environment +- User says "create an environment," "scaffold an environment," or similar + +## Arguments + +The session prompt carries: + +- `--path ""` — the absolute path to the environment directory (already + selected or created in Finder and added to the workspace) +- `--bind-project ""` (optional) — when chained from a project + skill, the project directory to auto-bind after creation + +Parse these from the prompt. If `--path` is missing, ask once via the +`question` tool. + +## Interview (one question at a time) + +Use the `question` tool for every question. ONE question per turn. + +### Stage 1: environment name (required) + +Ask: "What should I call this environment?" + +`kind: "text"`, `options: []`, `default` pre-filled from the directory +basename prettified (e.g. `quantum-control-env` → `"Quantum Control Env"`). +The user can accept or edit. This becomes the `name` field in +`research-environment.toml`. + +### Stage 2: system/domain (optional, free text) + +Ask: "What system or domain does this environment cover? (e.g. transmon qubits, protein folding, DFT calculations — or skip)" + +`kind: "text"`, `options: []`, `default: "skip"`. Environments are +domain-agnostic — this is free text, not constrained to quantum platforms. +Record as `--platform` if provided. + +### Stage 3: research field (optional) + +Ask: "What research field? (e.g. quantum-control, materials-science, bioinformatics — or skip)" + +`kind: "text"`, `options: []`, `default: "skip"`. Record as `--field`. + +### Stage 4: description (optional) + +Ask: "A one-line description for this environment? (or skip)" + +`kind: "text"`, `options: []`, `default: "skip"`. This is written to the +`description` field in `research-environment.toml`. + +### Stage 5: tags (optional) + +Ask: "Tags for this environment? (comma-separated, e.g. shared, transmon, optimal-control — or skip)" + +`kind: "text"`, `options: []`, `default: "skip"`. + +### Stage 6: GitHub remote (optional) + +Ask: "GitHub remote URL for this environment? (e.g. git@github.com:org/env.git — or skip)" + +`kind: "text"`, `options: []`, `default: "skip"`. If provided, run +`git remote add origin ""` in the environment directory after creation. + +## Execution + +After the interview, build the CLI command and run it: + +```bash +amico env create "" \ + --path "" \ + [--platform ""] \ + [--field ""] \ + [--author ""] +``` + +The `--author` flag is auto-populated from the user's profile +(`~/.amico/profile.json` `name` field) when available — do not ask for it. + +The CLI is idempotent: if `research-environment.toml` already exists in the +directory, it returns `created: false, idempotent: true` and does not +overwrite. In that case, tell the user the environment already has a manifest +and offer to open it. + +If a GitHub remote was provided and creation succeeded, run: + +```bash +git remote add origin "" +``` + +in the environment directory (cwd = ``). If `origin` already exists, skip +silently. + +If tags were provided, write them to `research-environment.toml` after creation +by reading the file, adding the `tags` array, and writing it back. + +If a description was provided, append it to `research-environment.toml` after +creation by reading the file and adding the `description` field. + +## After execution + +### Standalone post-creation (default) + +1. Confirm success: "Environment scaffolded — `research-environment.toml` + written, directories created, git initialized, registered in + `~/.amico/environments.toml`." +2. Scan the workspace for research projects that do NOT have an + `[environment]` section in their `research-project.toml`. If any exist, + offer multi-select binding: + + Use the `question` tool with `multiple: true`: + "These projects in the workspace have no environment — want to bind them?" + + Options: one per unbound project (label = project name, description = path). + Plus a "Skip" option. + + For each selected project, run: + ```bash + amico env bind "" --path "" + ``` + +3. If no unbound projects exist, say "All set — the environment is ready." + +### Chained post-creation (from project skill) + +When `--bind-project ""` was passed in the prompt: + +1. Confirm success (same as standalone). +2. Bind the triggering project directly: + ```bash + amico env bind "" --path "" + ``` +3. Tell the user: "Bound — switch back to the project tab and you're set." + +## Edge cases + +- **Manifest already exists:** Do not overwrite. Tell the user and offer to + open the existing `research-environment.toml`. +- **User cancels mid-interview:** Whatever was collected so far is lost (no + partial writes). The directory remains as-is until re-run. +- **`amico env create` fails:** Surface the error message from the CLI's JSON + output and offer to retry. +- **Nesting guard:** If the target directory is inside a project or another + environment, `amico env create` will fail with a nesting error. Surface it + clearly: "Environments and projects must be separate repos — pick a + directory outside any existing project or environment." From 7c964a3b49f209b1e44d3efa4682d6d84480c48c Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 19:11:52 -0400 Subject: [PATCH 18/23] =?UTF-8?q?feat(skills):=20create-research-project?= =?UTF-8?q?=20Stage=208=20=E2=80=94=20environment=20binding=20with=20regis?= =?UTF-8?q?tered=20list,=20Create=20new,=20Skip=20(#892)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/create-research-project/SKILL.md | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/extension/skills/create-research-project/SKILL.md b/packages/extension/skills/create-research-project/SKILL.md index 2df4e102..d28d0547 100644 --- a/packages/extension/skills/create-research-project/SKILL.md +++ b/packages/extension/skills/create-research-project/SKILL.md @@ -83,6 +83,33 @@ Choice question with options: Record as `--domain` (`quantum-control` or `general`). +### Stage 8: environment binding (optional) + +Ask: "Bind this project to a research environment?" + +Read the environment registry (`~/.amico/environments.toml`) and list +registered environments as choice options. If the registry is empty or missing, +skip this stage silently and move to Execution. + +Choice question with options (in this order): +- One option per registered environment (label = environment name, + description = slug and path) +- "Create new" — spawn a `create-research-environment` session +- "Skip" — no environment binding + +**If the user selects a registered environment:** record the slug to pass to +`amico env bind` after project creation. + +**If the user selects "Create new":** after the project is created, use the +`amicode_session` tool to spawn a new session tab with: +``` +/create-research-environment --bind-project "" +``` +Tell the user: "I've opened a new tab to create the environment — head over +there and I'll bind it to this project when it's done." + +**If the user selects "Skip":** proceed to Execution with no binding. + ## Execution After the interview, build the CLI command and run it: @@ -108,9 +135,12 @@ tell the user the project already has a manifest and offer to open it. 1. Confirm success: "Project scaffolded — `research-project.toml` written, directories created, git initialized." -2. The sidebar's filesystem watcher will automatically re-detect the project +2. **Environment binding (if Stage 8 selected one):** run + `amico env bind "" --path ""` to write the `[environment]` + section into the freshly created `research-project.toml`. +3. The sidebar's filesystem watcher will automatically re-detect the project as "research" type once the toml appears. -3. Offer the user a choice via `question`: +4. Offer the user a choice via `question`: - "Design a pulse" — invoke the `design-a-pulse` skill - "Set up an experiment" — open the experiment scripts directory - "Just explore" — no further action From 320f1a1c596bf10725dd4dcdc836efa77f4cbd9a Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 19:12:23 -0400 Subject: [PATCH 19/23] =?UTF-8?q?feat(skills):=20migrate-research-project?= =?UTF-8?q?=20Phase=206=20=E2=80=94=20environment=20binding=20with=20direc?= =?UTF-8?q?t=20string=20append=20(#892)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/migrate-research-project/SKILL.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/extension/skills/migrate-research-project/SKILL.md b/packages/extension/skills/migrate-research-project/SKILL.md index a586785c..6a1e7ffb 100644 --- a/packages/extension/skills/migrate-research-project/SKILL.md +++ b/packages/extension/skills/migrate-research-project/SKILL.md @@ -264,6 +264,48 @@ After execution, show the researcher: - "Your weekly report template is at `reports/weekly/template.md`" - "Add this folder to your VS Code workspace to get project-aware skills" +### Phase 6 — Environment binding (optional) + +After verification, check the environment registry (`~/.amico/environments.toml`). +If it has registered environments, ask the researcher whether to bind this +project to one. + +Read the registry and present a choice via the `question` tool: + +- One option per registered environment (label = environment name, + description = slug and path) +- "Create new" — spawn a `create-research-environment` session +- "Skip" — no environment binding + +**If the user selects a registered environment:** write the `[environment]` +section directly to `research-project.toml` using string append (matching the +migrate skill's existing direct-write pattern — no CLI call): + +```toml + +[environment] +slug = "" +``` + +Append this to the end of the file. This preserves all existing content +(comments, formatting) from the manifest written in Phase 4. + +Then commit: `git add research-project.toml && git commit -m "bind to environment "` + +**If the user selects "Create new":** use the `amicode_session` tool to spawn +a new session tab with: +``` +/create-research-environment --bind-project "" +``` +Tell the user: "I've opened a new tab to create the environment — head over +there and I'll bind it to this project when it's done." + +**If the user selects "Skip":** no action needed. The project can be bound +later via Command Palette ("Amicode: Bind to Environment") or `amico env bind`. + +If the registry is empty or missing, skip this phase silently — there are no +environments to bind to. + ## Edge cases **Already a Research Project** (`research-project.toml` exists): From b7a44f3a7f341a451f435e87ccda341d1a18ef61 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 22:10:21 -0400 Subject: [PATCH 20/23] feat(session): add command parameter to amicode_session for reliable skill-to-skill chaining MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The amicode_session tool previously sent prompts via promptAsync, which delivers plain text. When a skill chains into another skill via a spawned session (e.g. migrate → create-research-environment), the child LLM had to independently recognize the /skill-name prefix and load the target skill — unreliable. Now amicode_session accepts an optional `command` parameter that uses the opencode engine's dedicated POST /session/{id}/command endpoint (the same mechanism bug_report.ts uses) to invoke a registered skill directly in the child session. The `prompt` text becomes the command's `arguments` field. Changes: - session_spawn.ts: add `command` to SpawnArgs + parseSpawnArgs - amicode_tools_core.ts: widen EngineClientShape with session.command, add command arg to tool schema, dispatch via command API when set - amicode_tools.ts (plugin twin): mirror all changes - migrate-research-project SKILL.md: Phase 6 uses command parameter - create-research-project SKILL.md: Stage 8 uses command parameter - Tests: 5 new tests (3 parseSpawnArgs, 2 command dispatch e2e) Fixes the issue where migrate-research-project's 'Create new' environment option did not trigger a new session. --- .../opencode-plugin/amicode_tools.ts | 52 +++++++++++++++---- .../opencode-plugin/session_spawn.ts | 5 ++ .../skills/create-research-project/SKILL.md | 15 +++++- .../skills/migrate-research-project/SKILL.md | 14 ++++- packages/extension/src/amicode_tools_core.ts | 52 +++++++++++++++---- .../test/session_public_floor.test.ts | 43 ++++++++++++++- packages/extension/test/session_spawn.test.ts | 20 +++++++ 7 files changed, 176 insertions(+), 25 deletions(-) diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts index 9379e1f0..32efcb01 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -325,6 +325,7 @@ export const AmicodeTools = async (input: unknown) => { update: (o: unknown) => Promise; fork: (o: unknown) => Promise; promptAsync: (o: unknown) => Promise; + command: (o: unknown) => Promise; }; } | undefined; @@ -1901,7 +1902,10 @@ returns an error, fix \`js\`/the fields and call it again. "history instead of a blank start. A session that was itself spawned cannot spawn again " + "past depth " + SPAWN_MAX_DEPTH + " unless force=true. Do NOT use this for subagent-style " + "work the user need not steer (use the Task tool) — sessions are for parallel or " + - "branching work the USER should see and interact with.", + "branching work the USER should see and interact with. When chaining into a specific " + + "skill (e.g. spawning create-research-environment from a migrate session), pass " + + "`command` — it uses the engine's command API to invoke the skill directly instead of " + + "relying on the child LLM to parse a `/skill-name` prefix from a text prompt.", args: { prompt: { type: "string", @@ -1932,6 +1936,15 @@ returns an error, fix \`js\`/the fields and call it again. type: ["boolean", "null"], description: "Overrule the spawn-depth cap. Null = false.", }, + command: { + type: ["string", "null"], + description: + "A registered skill/command name to invoke in the child session (e.g. " + + "'create-research-environment'). When set, the engine's command API dispatches the " + + "skill directly instead of sending `prompt` as a plain text message — far more " + + "reliable for skill-to-skill chaining. The `prompt` text becomes the command's " + + "`arguments`. Null = send prompt as a regular user message (default).", + }, }, async execute( a: { @@ -1942,6 +1955,7 @@ returns an error, fix \`js\`/the fields and call it again. model?: string | null; mode?: string | null; force?: boolean | null; + command?: string | null; }, ctx: { sessionID: string; directory: string }, ) { @@ -2033,15 +2047,33 @@ returns an error, fix \`js\`/the fields and call it again. id = created?.id; } if (!id) throw new Error(`session ${args.mode === "fork" ? "fork" : "create"} returned no id`); - await engineClient.session.promptAsync({ - path: { id }, - query: { directory: ctx.directory }, - body: { - parts: [{ type: "text", text: args.prompt }], - ...(model ? { model } : {}), - ...(args.agent ? { agent: args.agent } : {}), - }, - }); + // Dispatch: when a command is named, use the engine's dedicated + // command API (POST /session/{id}/command) — it invokes the + // registered skill directly instead of relying on the child LLM to + // parse a `/skill-name` prefix from a plain text message. The + // prompt text becomes the command's `arguments` field. + if (args.command) { + const modelStr = model ? `${model.providerID}/${model.modelID}` : undefined; + await engineClient.session.command({ + path: { sessionID: id }, + body: { + command: args.command, + arguments: args.prompt, + ...(modelStr ? { model: modelStr } : {}), + ...(args.agent ? { agent: args.agent } : {}), + }, + }); + } else { + await engineClient.session.promptAsync({ + path: { id }, + query: { directory: ctx.directory }, + body: { + parts: [{ type: "text", text: args.prompt }], + ...(model ? { model } : {}), + ...(args.agent ? { agent: args.agent } : {}), + }, + }); + } children.push({ id, title }); } } catch (err) { diff --git a/packages/extension/opencode-plugin/session_spawn.ts b/packages/extension/opencode-plugin/session_spawn.ts index 6b989c6a..93d72498 100644 --- a/packages/extension/opencode-plugin/session_spawn.ts +++ b/packages/extension/opencode-plugin/session_spawn.ts @@ -44,6 +44,7 @@ export type SpawnArgs = { title: string | null; agent: string | null; model: { providerID: string; modelID: string } | null; + command: string | null; mode: SpawnMode; force: boolean; }; @@ -54,6 +55,7 @@ export function parseSpawnArgs(a: { title?: string | null; agent?: string | null; model?: string | null; + command?: string | null; mode?: string | null; force?: boolean | null; }): { ok: true; args: SpawnArgs } | { ok: false; error: string } { @@ -72,6 +74,7 @@ export function parseSpawnArgs(a: { } const agent = typeof a.agent === "string" && a.agent.trim() !== "" ? a.agent.trim() : null; const title = typeof a.title === "string" && a.title.trim() !== "" ? a.title.trim() : null; + const command = typeof a.command === "string" && a.command.trim() !== "" ? a.command.trim() : null; // the read-resolve alias (spec-20260907-011500 D1, #858): an old director // id on the amico_session agent param binds the renamed card. READ-RESOLVE, // never migrate-on-write; `build` and every non-aliased id pass through. @@ -83,6 +86,7 @@ export function parseSpawnArgs(a: { title, agent: agent === null ? null : resolveModeIdSpawn(agent), model, + command, mode, force: a.force === true, }, @@ -156,6 +160,7 @@ export function spawnGateKey(sessionID: string, directory: string, args: SpawnAr args.title, args.agent, args.model ? `${args.model.providerID}/${args.model.modelID}` : null, + args.command, args.mode, args.force, ]); diff --git a/packages/extension/skills/create-research-project/SKILL.md b/packages/extension/skills/create-research-project/SKILL.md index d28d0547..d49c1b52 100644 --- a/packages/extension/skills/create-research-project/SKILL.md +++ b/packages/extension/skills/create-research-project/SKILL.md @@ -101,10 +101,21 @@ Choice question with options (in this order): `amico env bind` after project creation. **If the user selects "Create new":** after the project is created, use the -`amicode_session` tool to spawn a new session tab with: +`amicode_session` tool to spawn a new session tab with the `command` parameter +set to invoke the skill directly: + ``` -/create-research-environment --bind-project "" +amicode_session( + command: "create-research-environment", + prompt: "--bind-project \"\"" +) ``` + +The `command` parameter uses the engine's command API to invoke the +`create-research-environment` skill reliably — it does not depend on the child +LLM parsing a `/skill-name` prefix from the prompt text. The `prompt` becomes +the skill's arguments (the child session will parse `--bind-project` from it). + Tell the user: "I've opened a new tab to create the environment — head over there and I'll bind it to this project when it's done." diff --git a/packages/extension/skills/migrate-research-project/SKILL.md b/packages/extension/skills/migrate-research-project/SKILL.md index 6a1e7ffb..abd2dc46 100644 --- a/packages/extension/skills/migrate-research-project/SKILL.md +++ b/packages/extension/skills/migrate-research-project/SKILL.md @@ -293,10 +293,20 @@ Append this to the end of the file. This preserves all existing content Then commit: `git add research-project.toml && git commit -m "bind to environment "` **If the user selects "Create new":** use the `amicode_session` tool to spawn -a new session tab with: +a new session tab with the `command` parameter set to invoke the skill directly: + ``` -/create-research-environment --bind-project "" +amicode_session( + command: "create-research-environment", + prompt: "--bind-project \"\"" +) ``` + +The `command` parameter uses the engine's command API to invoke the +`create-research-environment` skill reliably — it does not depend on the child +LLM parsing a `/skill-name` prefix from the prompt text. The `prompt` becomes +the skill's arguments (the child session will parse `--bind-project` from it). + Tell the user: "I've opened a new tab to create the environment — head over there and I'll bind it to this project when it's done." diff --git a/packages/extension/src/amicode_tools_core.ts b/packages/extension/src/amicode_tools_core.ts index 7c114a41..c37fd6df 100644 --- a/packages/extension/src/amicode_tools_core.ts +++ b/packages/extension/src/amicode_tools_core.ts @@ -173,6 +173,7 @@ export interface EngineClientShape { update: (o: unknown) => Promise; fork: (o: unknown) => Promise; promptAsync: (o: unknown) => Promise; + command: (o: unknown) => Promise; }; } @@ -1948,7 +1949,10 @@ export const AMICODE_TOOLS: Record = { "history instead of a blank start. A session that was itself spawned cannot spawn again " + "past depth " + SPAWN_MAX_DEPTH + " unless force=true. Do NOT use this for subagent-style " + "work the user need not steer (use the Task tool) — sessions are for parallel or " + - "branching work the USER should see and interact with.", + "branching work the USER should see and interact with. When chaining into a specific " + + "skill (e.g. spawning create-research-environment from a migrate session), pass " + + "`command` — it uses the engine's command API to invoke the skill directly instead of " + + "relying on the child LLM to parse a `/skill-name` prefix from a text prompt.", args: { prompt: { type: "string", @@ -1979,6 +1983,15 @@ export const AMICODE_TOOLS: Record = { type: ["boolean", "null"], description: "Overrule the spawn-depth cap. Null = false.", }, + command: { + type: ["string", "null"], + description: + "A registered skill/command name to invoke in the child session (e.g. " + + "'create-research-environment'). When set, the engine's command API dispatches the " + + "skill directly instead of sending `prompt` as a plain text message — far more " + + "reliable for skill-to-skill chaining. The `prompt` text becomes the command's " + + "`arguments`. Null = send prompt as a regular user message (default).", + }, }, async execute( a: { @@ -1989,6 +2002,7 @@ export const AMICODE_TOOLS: Record = { model?: string | null; mode?: string | null; force?: boolean | null; + command?: string | null; }, ctx: AmicodeToolContext, ) { @@ -2092,15 +2106,33 @@ export const AMICODE_TOOLS: Record = { id = created?.id; } if (!id) throw new Error(`session ${args.mode === "fork" ? "fork" : "create"} returned no id`); - await engineClient.session.promptAsync({ - path: { id }, - query: { directory: ctx.directory }, - body: { - parts: [{ type: "text", text: args.prompt }], - ...(model ? { model } : {}), - ...(args.agent ? { agent: args.agent } : {}), - }, - }); + // Dispatch: when a command is named, use the engine's dedicated + // command API (POST /session/{id}/command) — it invokes the + // registered skill directly instead of relying on the child LLM to + // parse a `/skill-name` prefix from a plain text message. The + // prompt text becomes the command's `arguments` field. + if (args.command) { + const modelStr = model ? `${model.providerID}/${model.modelID}` : undefined; + await engineClient.session.command({ + path: { sessionID: id }, + body: { + command: args.command, + arguments: args.prompt, + ...(modelStr ? { model: modelStr } : {}), + ...(args.agent ? { agent: args.agent } : {}), + }, + }); + } else { + await engineClient.session.promptAsync({ + path: { id }, + query: { directory: ctx.directory }, + body: { + parts: [{ type: "text", text: args.prompt }], + ...(model ? { model } : {}), + ...(args.agent ? { agent: args.agent } : {}), + }, + }); + } children.push({ id, title }); } } catch (err) { diff --git a/packages/extension/test/session_public_floor.test.ts b/packages/extension/test/session_public_floor.test.ts index ee9f9ee1..02ffa71d 100644 --- a/packages/extension/test/session_public_floor.test.ts +++ b/packages/extension/test/session_public_floor.test.ts @@ -65,7 +65,7 @@ function emptyEntitlementConfig(): string { return dir; } -type EngineVerb = "get" | "create" | "update" | "fork" | "promptAsync"; +type EngineVerb = "get" | "create" | "update" | "fork" | "promptAsync" | "command"; type EngineCall = { verb: EngineVerb; body: Record }; /** Mock engine client — session_spawn_double_create.test.ts's double, @@ -96,6 +96,10 @@ function makeMockEngine(parent?: { metadata?: unknown }) { calls.push({ verb: "promptAsync", body: o?.body ?? {} }); return {}; }, + command: async (o: { body?: Record }) => { + calls.push({ verb: "command", body: o?.body ?? {} }); + return {}; + }, }, }; const by = (v: EngineVerb) => calls.filter((c) => c.verb === v); @@ -137,6 +141,7 @@ describe("the no-entitlement spawn fixture (ADR-0004 decision 1, amicode#826)", "model", "mode", "force", + "command", ]); }); @@ -218,6 +223,42 @@ describe("the no-entitlement spawn fixture (ADR-0004 decision 1, amicode#826)", expect(result).toMatch(/forked from this session's history/); }); + it("the command parameter dispatches via the engine's command API instead of promptAsync", async () => { + const { engine, by } = makeMockEngine(); + const pack = await pluginPack(engine); + const result = await pack.tool["amicode_session"].execute( + { prompt: "--bind-project /tmp/my-project", command: "create-research-environment" }, + { sessionID: "ses_parent", directory: "/w" }, + ); + expect(result).toMatch(/Spawned 1 fresh sessions/); + expect(by("create")).toHaveLength(1); + // command API was called, not promptAsync + expect(by("command")).toHaveLength(1); + expect(by("promptAsync")).toHaveLength(0); + const cmd = by("command")[0]!; + expect(cmd.body.command).toBe("create-research-environment"); + expect(cmd.body.arguments).toBe("--bind-project /tmp/my-project"); + }); + + it("the CORE twin command dispatch is identical to the plugin twin's", async () => { + const { engine, by } = makeMockEngine(); + const def = CORE.AMICODE_TOOLS["amicode_session"]!; + const ctx: AmicodeToolContext = { + engineClient: engine, + sessionID: "ses_parent", + directory: "/w", + carrier: "plugin", + }; + const result = await def.execute( + { prompt: "--bind /tmp/p", command: "create-research-environment" }, + ctx, + ); + expect(by("command")).toHaveLength(1); + expect(by("promptAsync")).toHaveLength(0); + expect(by("command")[0]!.body.command).toBe("create-research-environment"); + expect(result).toMatch(/Spawned 1/); + }); + it("the CORE twin (the one implementation both transports project) spawns identically under the empty config", async () => { const { engine, by } = makeMockEngine(); const def = CORE.AMICODE_TOOLS["amicode_session"]!; diff --git a/packages/extension/test/session_spawn.test.ts b/packages/extension/test/session_spawn.test.ts index f2b31c99..7ef13c27 100644 --- a/packages/extension/test/session_spawn.test.ts +++ b/packages/extension/test/session_spawn.test.ts @@ -29,6 +29,7 @@ describe("parseSpawnArgs", () => { title: null, agent: null, model: null, + command: null, mode: "fresh", force: false, }); @@ -91,6 +92,25 @@ describe("parseSpawnArgs", () => { expect(r.ok && r.args.title === "CZ sweep" && r.args.agent === null).toBe(true); }); + it("defaults command to null when omitted", () => { + const r = parseSpawnArgs({ prompt: "x" }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.args.command).toBeNull(); + }); + + it("parses a non-empty command string and trims it", () => { + const r = parseSpawnArgs({ prompt: "--bind-project /tmp/p", command: " create-research-environment " }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.args.command).toBe("create-research-environment"); + }); + + it("nulls empty/whitespace-only command", () => { + const empty = parseSpawnArgs({ prompt: "x", command: "" }); + const ws = parseSpawnArgs({ prompt: "x", command: " " }); + expect(empty.ok && empty.args.command).toBeNull(); + expect(ws.ok && ws.args.command).toBeNull(); + }); + it("resolves the old director ids through the read-resolve alias (spec-20260907-011500 D1, #858)", () => { const dev = parseSpawnArgs({ prompt: "x", agent: "autodev" }); const res = parseSpawnArgs({ prompt: "x", agent: "autoresearch" }); From a41b1e3ffcdb5c9d8071509c09b3c2ce3533fb8b Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 23:11:50 -0400 Subject: [PATCH 21/23] feat(sidebar): render environment pill on bound research project roots (#884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data pipeline was fully wired (resolveEnvironment → TreeService → bridge types) but the sidebar webview never read root.environment to render anything. Now: - sidebar_view.ts: env-pill CSS — 8-color palette (env-pill-0..7), compact rounded badge, 10px text, 120px max-width with ellipsis - sidebar_webview.ts: TreeRoot/TreeEntry interfaces widened to include environment fields; renderRootNode appends an env-pill span after the label when root.environment is present, with tooltip showing slug + path - 3 new tests: CSS presence, interface field, rendering code check --- packages/extension/src/sidebar_view.ts | 23 +++++++++++++ packages/extension/src/sidebar_webview.ts | 18 ++++++++++ .../extension/test/sidebar_env_pill.test.ts | 33 ++++++++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index f39d084b..3fdd9d57 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -791,6 +791,29 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { .git-untracked { color: var(--vscode-gitDecoration-untrackedResourceForeground, #73c991); } .git-ignored { color: var(--vscode-gitDecoration-ignoredResourceForeground, #8c8c8c); opacity: 0.6; } .git-conflict { color: var(--vscode-gitDecoration-conflictingResourceForeground, #e4676b); } + /* ── Environment pill (#884) ───────────────────────────────── */ + .env-pill { + font-size: 10px; + font-weight: 500; + letter-spacing: 0.3px; + padding: 1px 6px; + border-radius: 9999px; + margin-left: 6px; + flex-shrink: 0; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.85; + } + .env-pill-0 { color: #61afef; border: 1px solid rgba(97,175,239,0.3); background: rgba(97,175,239,0.08); } + .env-pill-1 { color: #c678dd; border: 1px solid rgba(198,120,221,0.3); background: rgba(198,120,221,0.08); } + .env-pill-2 { color: #98c379; border: 1px solid rgba(152,195,121,0.3); background: rgba(152,195,121,0.08); } + .env-pill-3 { color: #e5c07b; border: 1px solid rgba(229,192,123,0.3); background: rgba(229,192,123,0.08); } + .env-pill-4 { color: #56b6c2; border: 1px solid rgba(86,182,194,0.3); background: rgba(86,182,194,0.08); } + .env-pill-5 { color: #e06c75; border: 1px solid rgba(224,108,117,0.3); background: rgba(224,108,117,0.08); } + .env-pill-6 { color: #d19a66; border: 1px solid rgba(209,154,102,0.3); background: rgba(209,154,102,0.08); } + .env-pill-7 { color: #abb2bf; border: 1px solid rgba(171,178,191,0.3); background: rgba(171,178,191,0.08); } /* ── Drag and drop ─────────────────────────────────────────── */ .tree-node.drop-target { background: var(--vscode-list-dropBackground, rgba(83, 89, 93, 0.5)); diff --git a/packages/extension/src/sidebar_webview.ts b/packages/extension/src/sidebar_webview.ts index 029512c3..a5f01dbc 100644 --- a/packages/extension/src/sidebar_webview.ts +++ b/packages/extension/src/sidebar_webview.ts @@ -13,6 +13,13 @@ interface TreeRoot { name: string; projectType: "research" | "dev"; metadata?: { phase?: string; lastActive?: string }; + /** Resolved environment info, present when a research project is bound to an environment. */ + environment?: { + name: string; + slug: string; + path: string; + colorIndex: number; + }; } interface TreeEntry { @@ -20,6 +27,8 @@ interface TreeEntry { type: "file" | "directory"; path: string; gitStatus?: string; + entryKind?: "environment-root"; + environmentSlug?: string; } // ── Icon theme data (embedded by the host in window.__iconTheme) ───────────── @@ -1175,6 +1184,15 @@ function createIconEl(icon: string): HTMLElement { if (iconEl) row.appendChild(iconEl); row.appendChild(label); + // Environment pill (#884) — shown after the label when the project is bound to an environment + if (root.environment) { + const pill = document.createElement("span"); + pill.className = `env-pill env-pill-${root.environment.colorIndex}`; + pill.textContent = root.environment.name; + pill.title = `Environment: ${root.environment.slug} (${root.environment.path})`; + row.appendChild(pill); + } + container.appendChild(row); // Drag-and-drop: roots are draggable sources AND drop targets (#712) diff --git a/packages/extension/test/sidebar_env_pill.test.ts b/packages/extension/test/sidebar_env_pill.test.ts index 6cbd35ba..ec1a6bb4 100644 --- a/packages/extension/test/sidebar_env_pill.test.ts +++ b/packages/extension/test/sidebar_env_pill.test.ts @@ -1,6 +1,8 @@ -// Sidebar environment pill — color palette utility + TreeService wiring. +// Sidebar environment pill — color palette utility + TreeService wiring + webview rendering. // Part of #884 (sub-issue of #880 Research Environments). import { describe, it, expect } from "vitest"; +import { readFileSync } from "fs"; +import { resolve } from "path"; import { hashCode, envColorIndex, truncateWithEllipsis } from "../src/sidebar_bridge"; import { SidebarTreeService } from "../src/sidebar_tree_service"; @@ -132,3 +134,32 @@ describe("SidebarTreeService environment pill data (#884)", () => { expect(roots[0].environment).toBeUndefined(); }); }); + +// ── Webview rendering (source-level checks) ────────────────────────────────── + +describe("sidebar environment pill rendering (#884)", () => { + const webviewSrc = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + const viewSrc = readFileSync( + resolve(__dirname, "..", "src", "sidebar_view.ts"), + "utf8", + ); + + it("sidebar_view.ts CSS includes env-pill styling", () => { + expect(viewSrc).toContain("env-pill"); + }); + + it("sidebar_webview.ts TreeRoot interface includes the environment field", () => { + // The webview's local TreeRoot must declare the environment property + // so the rendering code can read it from the host-pushed data. + expect(webviewSrc).toMatch(/interface TreeRoot[\s\S]*?environment\?/); + }); + + it("sidebar_webview.ts renderRootNode reads root.environment to create a pill", () => { + // The rendering function must check root.environment and create a pill element + expect(webviewSrc).toMatch(/root\.environment/); + expect(webviewSrc).toContain("env-pill"); + }); +}); From 77e0cbce8cbc9a2c93bbc7e4fb236006631ed1c9 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 23:16:26 -0400 Subject: [PATCH 22/23] =?UTF-8?q?fix:=20typecheck=20=E2=80=94=20cast=20thr?= =?UTF-8?q?ough=20unknown=20for=20ProjectToml=20in=20envBind=20force=20pat?= =?UTF-8?q?h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/amico-run/src/env_verb.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/amico-run/src/env_verb.ts b/packages/amico-run/src/env_verb.ts index 8b258bc0..66fe7649 100644 --- a/packages/amico-run/src/env_verb.ts +++ b/packages/amico-run/src/env_verb.ts @@ -550,7 +550,7 @@ export function envBind(argv: string[], opts?: EnvVerbOptions): VerbResult { slug, ...(envPath ? { path: envPath } : {}), }; - writeFileSync(tomlPath, renderProjectToml(projectData as ProjectToml)); + writeFileSync(tomlPath, renderProjectToml(projectData as unknown as ProjectToml)); } catch (e) { return fail(`failed to write ${tomlPath}: ${e instanceof Error ? e.message : String(e)}`); } From d2007da9ff0064477b774b2dd316b603cdc17ff4 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 7 Sep 2026 23:19:43 -0400 Subject: [PATCH 23/23] fix(test): set git identity in env_promote test for CI runners --- packages/amico-run/test/env_promote.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/amico-run/test/env_promote.test.ts b/packages/amico-run/test/env_promote.test.ts index 0cb37aaa..55a01956 100644 --- a/packages/amico-run/test/env_promote.test.ts +++ b/packages/amico-run/test/env_promote.test.ts @@ -33,6 +33,8 @@ describe("envPromote", () => { }; writeFileSync(join(dir, "research-environment.toml"), renderEnvironmentToml(manifest)); execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "test"], { cwd: dir, stdio: "ignore" }); execFileSync("git", ["add", "."], { cwd: dir, stdio: "ignore" }); execFileSync("git", ["commit", "-m", "init"], { cwd: dir, stdio: "ignore" }); return dir;