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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,10 +219,34 @@
"amicode.skillLibraryRoots": {
"type": "array",
"items": {
"type": "string"
"anyOf": [
{
"type": "string"
},
{
"type": "object",
"required": [
"path",
"surfaces"
],
"properties": {
"path": {
"type": "string"
},
"surfaces": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1
}
},
"additionalProperties": false
}
]
},
"default": [],
"description": "Roots for the central skill library, scanned for skills tagged `surface: product`. Empty = ~/harmoniqs/amico-plugin/skills. Only product-tagged skills stage into Amicode; internal/untagged process skills never leak."
"markdownDescription": "Roots for the central skill library, scanned first-root-wins by directory name. Empty = the defaults: the dev's private plugin checkout (admits `surface: public` **and** `internal`), then the vendored public bundle (admits `public` only). Entries are typed roots `{\"path\": \"...\", \"surfaces\": [\"public\", ...]}` or plain strings. **Back-compat (ADR-0003):** a plain string keeps the pre-typing behavior — it admits `surface: public` only; existing string overrides keep working unchanged. Untagged/malformed skills are dropped from every root with a logged warning."
},
"amicode.vaultDir": {
"type": "string",
Expand Down
8 changes: 4 additions & 4 deletions packages/extension/skills.lock.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"version": "1.6.0",
"version": "1.7.0",
"repo": "harmoniqs/amico-plugin",
"tag": "skills-public-v1.6.0",
"asset": "amico-skills-public-1.6.0.tar.gz",
"sha256": "a8400b101eef68df8d5e3acec13bc922f98f45ce0d8da4747d1f46d791ec9eeb"
"tag": "skills-public-v1.7.0",
"asset": "amico-skills-public-1.7.0.tar.gz",
"sha256": "24146ddba3213234f0dc2d84259f94d053f2d8a50ca37afd05d95ad08de6a3a1"
}
14 changes: 11 additions & 3 deletions packages/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
buildOpencodeConfigContent,
resolveModelPin,
} from "./opencode_config";
import { parseLibraryRootSpecs } from "./scores/package_skills";
import { resolveAmicoRunBinDir, resolveRunsRoot } from "./opencode_paths";
import {
mintServerPassword,
Expand Down Expand Up @@ -415,6 +416,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
const v = vscode.workspace.getConfiguration("amicode").get<string[]>(key, []);
return Array.isArray(v) && v.length ? v : undefined;
};
// Typed library roots (ADR-0003): the setting mixes bare strings (public-only,
// pre-ADR behavior) and {path, surfaces} objects; malformed entries drop + warn.
const cfgLibraryRoots = () => {
const raw = vscode.workspace.getConfiguration("amicode").get<unknown>("skillLibraryRoots", []);
const parsed = parseLibraryRootSpecs(raw);
return parsed.length ? parsed : undefined;
};
const opencodeProject = prepareOpencodeProject({
agentsSrc: path.resolve(ctx.extensionPath, "AGENTS.md"),
// MODE-SELECTED vetted template: HP sessions get the Piccolissimo variant
Expand All @@ -427,7 +435,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
),
juliaProject: resolveJuliaProject(vscode.workspace.getConfiguration("amicode").get<string>("juliaProject", "")),
skillRoots: cfgArr("skillRoots"),
skillLibraryRoots: cfgArr("skillLibraryRoots"),
skillLibraryRoots: cfgLibraryRoots(),
// User-memory substrate (spec-20260705-002847): "" in the setting keeps the
// auto-resolve (kind=personal marker scan); a path pins the vault explicitly.
vaultDir: vscode.workspace.getConfiguration("amicode").get<string>("vaultDir", "") || undefined,
Expand Down Expand Up @@ -577,7 +585,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
vscode.workspace.getConfiguration("amicode").get<string>("juliaProject", ""),
),
skillRoots: cfgArr("skillRoots"),
skillLibraryRoots: cfgArr("skillLibraryRoots"),
skillLibraryRoots: cfgLibraryRoots(),
vaultDir: vscode.workspace.getConfiguration("amicode").get<string>("vaultDir", "") || undefined,
});
await serverManager?.stop();
Expand Down Expand Up @@ -707,7 +715,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
),
juliaProject: resolveJuliaProject(vscode.workspace.getConfiguration("amicode").get<string>("juliaProject", "")),
skillRoots: cfgArr("skillRoots"),
skillLibraryRoots: cfgArr("skillLibraryRoots"),
skillLibraryRoots: cfgLibraryRoots(),
vaultDir: vscode.workspace.getConfiguration("amicode").get<string>("vaultDir", "") || undefined,
projectDir: path.join((ctx.storageUri ?? ctx.globalStorageUri).fsPath, "opencode-project"),
});
Expand Down
35 changes: 23 additions & 12 deletions packages/extension/src/opencode_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
buildSkillIndexSection,
stageOpencodeSkills,
type SkillIndexEntry,
type LibraryRoot,
type LibraryRootSpec,
} from "./scores/package_skills";
import { readSolverModeState } from "./solver_mode";
import { buildRoutingSection, readRoutingContext } from "./routing";
Expand Down Expand Up @@ -144,16 +146,23 @@ export const DEFAULT_SCORES_ROOT = path.resolve(__dirname, "..", "scores");
* `surface: public` tag from the central amico-plugin library. Overridable
* via settings (Task 6). */
export const DEFAULT_SKILL_ROOTS = [path.join(os.homedir(), "harmoniqs", "packages")];
/** Library roots scanned (first-root-wins) for `surface: public` skills:
* 1. the dev's live amico-plugin checkout — full public set incl. the held
* physics skills (present locally, just excluded from the OSS artifact);
/** Library roots scanned (first-root-wins), TYPED by admitted surface set
* (ADR-0003, amicode#242):
* 1. the dev's live amico-plugin checkout — admits {public, internal}.
* Checkout presence IS the eligibility proof: internal SKILL.md content
* exists only in the private repo, so nobody stages skills they do not
* already possess. This is what gives brainstorming's publish/decompose
* steps (write-an-issue, break-into-subissues — surface:internal) their
* path to Amicode.
* 2. the vsix-bundled OSS subset (fetch_skills.mjs -> vendor/skills-public),
* the ONLY root a Marketplace user has. A dev has both; the checkout wins
* per dir name, so the bundle is a pure fallback. Missing roots are
* silently skipped (resolveLibrarySkills). */
export const DEFAULT_LIBRARY_ROOTS = [
path.join(os.homedir(), "harmoniqs", "amico-plugin", "skills"),
path.resolve(__dirname, "..", "vendor", "skills-public", "skills"),
* the ONLY root a Marketplace user has — admits {public} ONLY, defense in
* depth on top of the extract pipeline's guarantee; the vendored bundle
* must never ship internal skills. A dev has both; the checkout wins per
* dir name, so the bundle is a pure fallback. Missing roots are silently
* skipped (resolveLibrarySkills). */
export const DEFAULT_LIBRARY_ROOTS: LibraryRoot[] = [
{ path: path.join(os.homedir(), "harmoniqs", "amico-plugin", "skills"), surfaces: ["public", "internal"] },
{ path: path.resolve(__dirname, "..", "vendor", "skills-public", "skills"), surfaces: ["public"] },
];
/** The physics/optimization skill subset (formerly `surface: product`, now `public`) —
* a documentation/reference anchor, NOT a selection input (selection is purely by
Expand Down Expand Up @@ -461,9 +470,11 @@ export interface OpencodeConfigOptions {
entitlementsDir?: string;
/** Roots to search for co-located package skills (spec §3). Default: DEFAULT_SKILL_ROOTS. */
skillRoots?: string[];
/** Roots for the central library, scanned for `surface: public` skills
* (spec-20260713-003804). Default: DEFAULT_LIBRARY_ROOTS. */
skillLibraryRoots?: string[];
/** Roots for the central library, scanned under per-root surface eligibility
* (ADR-0003, amicode#242). Typed `{path, surfaces}` roots; a bare string
* admits public only (pre-ADR settings overrides keep working).
* Default: DEFAULT_LIBRARY_ROOTS. */
skillLibraryRoots?: LibraryRootSpec[];
/** Personal vault dir for the user-memory substrate (spec-20260705-002847),
* three-state (spec-20260707-002846 C1):
* undefined → auto-resolve the full Armonia mount stack under
Expand Down
85 changes: 69 additions & 16 deletions packages/extension/src/scores/package_skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,52 @@ export interface SkillIndexEntry {
path: string; // absolute SKILL.md path
}

/** A typed library root (ADR-0003, amicode#242): the directory PLUS the `surface:`
* tags it admits. Two tiers — the dev's private plugin checkout admits
* {public, internal} (checkout presence IS the eligibility proof: internal
* SKILL.md content exists only in the private repo, so nobody stages skills
* they do not already possess); the vendored public bundle admits {public}
* only, as defense in depth on top of the extract pipeline's guarantee. */
export interface LibraryRoot {
path: string;
surfaces: string[]; // admitted `surface:` tags
}
/** A bare string root keeps the pre-typing behavior: public-only. Settings
* overrides written before ADR-0003 are string arrays — they keep working. */
export type LibraryRootSpec = string | LibraryRoot;

function normalizeLibraryRoot(r: LibraryRootSpec): LibraryRoot {
return typeof r === "string" ? { path: r, surfaces: ["public"] } : r;
}

/** Parse the raw `amicode.skillLibraryRoots` setting value into root specs
* (ADR-0003 back-compat). Bare strings pass through (public-only, the pre-ADR
* behavior); typed objects need a non-empty `path` and a non-empty `surfaces`
* string array. Malformed entries are dropped with a warning — the settings
* surface mirrors the resolver's skip+warn philosophy, never throws. */
export function parseLibraryRootSpecs(raw: unknown): LibraryRootSpec[] {
if (!Array.isArray(raw)) return [];
const out: LibraryRootSpec[] = [];
for (const entry of raw) {
if (typeof entry === "string") {
out.push(entry);
continue;
}
const e = entry as Partial<LibraryRoot> | null;
const ok =
e !== null &&
typeof e === "object" &&
typeof e.path === "string" &&
e.path.trim() !== "" &&
Array.isArray(e.surfaces) &&
e.surfaces.length > 0 &&
e.surfaces.every((s) => typeof s === "string");
if (ok) out.push({ path: (e as LibraryRoot).path, surfaces: (e as LibraryRoot).surfaces });
else console.warn(`amicode: dropping malformed skillLibraryRoots entry: ${JSON.stringify(entry)}`);
}
return out;
}

function expandHome(p: string): string {
if (p === "~") return process.env.HOME ?? p;
if (p.startsWith("~/")) return path.join(process.env.HOME ?? "", p.slice(2));
Expand Down Expand Up @@ -81,31 +127,34 @@ export function resolvePackageSkills(allowlist: string[], roots: string[]): Skil
}

/** Library skills from the central amico-plugin library, discovered by SURFACE
* TAG (spec-20260713-003804). The library root is SCANNED, but ONLY skills whose
* frontmatter carries `surface: public` are returned — `internal`, untagged, and
* any other value are the leak hazard and are DROPPED. `public` = the OSS-shippable
* surface (the Armonia vault-management layer + physics/opt + generic craft); the
* tag IS the least-privilege guard. Staging (stageOpencodeSkills) copies only THIS
* selected set to the per-session stage dir — `skills.paths` never points at the
* library root itself. First root holding a given `<name>/SKILL.md` wins.
* TAG (spec-20260713-003804) under PER-ROOT eligibility (ADR-0003, amicode#242).
* Each root is scanned, but ONLY skills whose frontmatter `surface:` tag is in
* that root's admitted `surfaces` are returned — the private checkout root
* admits {public, internal}, the vendored bundle root admits {public} only, so
* internal content can stage ONLY from a checkout the user already possesses.
* Untagged and malformed skills are DROPPED from every root. Staging
* (stageOpencodeSkills) copies only THIS selected set to the per-session stage
* dir — `skills.paths` never points at a library root itself. First root
* holding a given `<name>/SKILL.md` wins.
*
* The private tier is NOT a library concern: private-package skills live co-located
* in their package repos and are gated by resolvePackageSkills (entitlement-derived
* allowlist ∩ repo presence). There is deliberately no library-level entitlement seam. */
export function resolveLibrarySkills(roots: string[]): SkillIndexEntry[] {
* The private tier is NOT otherwise a library concern: private-package skills
* live co-located in their package repos and are gated by resolvePackageSkills
* (entitlement-derived allowlist ∩ repo presence). */
export function resolveLibrarySkills(roots: LibraryRootSpec[]): SkillIndexEntry[] {
const out: SkillIndexEntry[] = [];
const seen = new Set<string>(); // first-root-wins, keyed by dir name
for (const r of roots) {
const root = expandHome(r);
const root = normalizeLibraryRoot(r);
const rootPath = expandHome(root.path);
let names: string[] = [];
try {
names = fs.readdirSync(root);
names = fs.readdirSync(rootPath);
} catch {
continue; // missing library root — silently skipped (session proceeds)
}
for (const name of names.sort()) {
if (seen.has(name)) continue;
const skillPath = path.join(root, name, "SKILL.md");
const skillPath = path.join(rootPath, name, "SKILL.md");
if (!fs.existsSync(skillPath)) continue;
let fm: { name: string; description: string; surface?: string };
try {
Expand All @@ -114,8 +163,12 @@ export function resolveLibrarySkills(roots: string[]): SkillIndexEntry[] {
console.warn(`amicode: skipping malformed library skill ${skillPath}: ${e}`);
continue;
}
if (fm.surface !== "public") continue; // THE GUARD: internal/untagged/product never stage
seen.add(name); // this dir is the authoritative public skill (earlier root wins)
if (fm.surface === undefined) {
console.warn(`amicode: dropping untagged library skill ${skillPath} (no surface: tag — default-deny)`);
continue;
}
if (!root.surfaces.includes(fm.surface)) continue; // THE GUARD, per-root
seen.add(name); // this dir is the authoritative skill of that name (earlier root wins)
out.push({ source: "library", name: fm.name, description: fm.description, path: skillPath });
}
}
Expand Down
32 changes: 31 additions & 1 deletion packages/extension/test/packaging.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

const VSIX = join(__dirname, "..", "amicode.vsix");
Expand Down Expand Up @@ -81,3 +81,33 @@ describe.skipIf(!existsSync(VSIX) && !REQUIRE_VSIX)("packaged VSIX contains runt
).toBe(true);
});
});

// Two-tier leak guard on the vendored artifact itself (ADR-0003, amicode#242).
// The bundle root admits {public} only at resolve time (the resolver half is in
// package_skills.test.ts); these tests guard the ARTIFACT — a corrupt extract or
// a mis-pinned lock that smuggled an internal skill must red here, not ship.
const SKILLS_BUNDLE = join(__dirname, "..", "vendor", "skills-public");
const HAVE_BUNDLE = existsSync(join(SKILLS_BUNDLE, "skills"));
describe.skipIf(!HAVE_BUNDLE && !REQUIRE_VSIX)("vendored public skill subset — two-tier leak guard (ADR-0003)", () => {
it("the vendored bundle exists (hard requirement under AMICODE_REQUIRE_VSIX=1)", () => {
expect(HAVE_BUNDLE, "no vendor/skills-public — run: pnpm --filter amicode fetch:skills").toBe(true);
});
it("every vendored SKILL.md carries surface: public — the bundle never ships internal (AC4)", () => {
const offenders: string[] = [];
for (const name of readdirSync(join(SKILLS_BUNDLE, "skills"))) {
const p = join(SKILLS_BUNDLE, "skills", name, "SKILL.md");
if (!existsSync(p)) continue;
const m = readFileSync(p, "utf8").match(/^---\n([\s\S]*?)\n---/);
const surface = m?.[1].match(/^surface:\s*(\S+)/m)?.[1];
if (surface !== "public") offenders.push(`${name} (surface=${surface ?? "MISSING"})`);
}
expect(offenders, `non-public skills in the vendored bundle: ${offenders.join(", ")}`).toEqual([]);
});
it("the re-tagged dev-workflow skills are absent from the vendored set (AC5)", () => {
const names = readdirSync(join(SKILLS_BUNDLE, "skills"));
// Re-tagged surface:internal by amico-plugin#52 — present in the public bundle
// up to skills-public-v1.6.0, absent from the first post-retag release.
expect(names).not.toContain("implement-issue");
expect(names).not.toContain("break-into-subissues");
});
});
Loading
Loading