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
560 changes: 549 additions & 11 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/reference/opencode-v2-facts.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,4 @@ Facts the pinned source cannot fully settle; harness must confirm before guidanc
- A reference/template/agent-definition sentence may assert a fact only if that fact is **settled** here, or **hedged** with the qualifying wording from this record. **open-harness** facts may only appear behind an explicit "pending verification" note.
- Cite the fact by section number (e.g. "per opencode-v2-facts §8") rather than restating fragile details.
- When adjudicating new contradictions during the rewrite, extend §13 here rather than resolving locally.
- Dependency-citation policy (adjudicated during C2, issue #25): the opencode-architect runtime pins the packages it executes (`@opencode/plugin` 2.0.23, `effect` 4.0.0-rc.112 at execution time; 2.0.24 was `latest` then — flagged, not adopted). Guidance sentences and templates naming the plugin-authoring packages **for consumers** must use `@latest` (e.g. `"@opencode/plugin": "latest"`, `"effect": "latest"`) so generated and taught packages resolve current versions at time of use. Never teach a pinned consumer version from guidance.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@
"templates"
],
"dependencies": {
"@opencode-ai/plugin": "*",
"@opencode/plugin": "2.0.23",
"effect": "4.0.0-rc.112",
"folder-hash": "^4.1.3",
"yaml": "^2.7.1"
},
"devDependencies": {
"@opencode-ai/sdk": "latest",
"@types/bun": "latest",
"@types/folder-hash": "^4.0.4",
"@types/node": "latest",
Expand Down
83 changes: 57 additions & 26 deletions src/agent-loader.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { parse as parseYaml } from "yaml";
import type { AgentConfig } from "@opencode-ai/sdk";
import type { Agent } from "@opencode/plugin/effect";

export const AGENT_FILENAMES: readonly string[] = [
"opencode-agent-designer.md",
Expand All @@ -19,28 +19,32 @@ export const AGENT_FILENAMES: readonly string[] = [
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
export const RELATIVE_REFERENCE_REGEX = /`((?:\.{1,2})(?:[\\/][^`\\/]+)+)`/g;

export interface LoadedAgent {
name: string;
system: string;
description: string;
mode: Agent.Info["mode"];
permissions: Agent.Info["permissions"];
}

interface AgentFrontmatter {
description: string;
mode: "primary" | "subagent" | "all";
tools?: Record<string, boolean>;
permission?: {
edit?: "ask" | "allow" | "deny";
bash?: ("ask" | "allow" | "deny") | Record<string, "ask" | "allow" | "deny">;
webfetch?: "ask" | "allow" | "deny";
doom_loop?: "ask" | "allow" | "deny";
external_directory?: "ask" | "allow" | "deny";
};
mode: Agent.Info["mode"];
tools: Record<string, boolean> | null;
permission: Record<string, PermissionEffect | Record<string, PermissionEffect>> | null;
}

type PermissionEffect = "allow" | "ask" | "deny";

export class AgentLoader {
private readonly agentsDir: string;

public constructor(agentsDir: string) {
this.agentsDir = agentsDir;
}

public async loadAgents(): Promise<Record<string, AgentConfig>> {
const agents: Record<string, AgentConfig> = {};
public async loadAgents(): Promise<Record<string, LoadedAgent>> {
const agents: Record<string, LoadedAgent> = {};

for (const filename of AGENT_FILENAMES) {
const agentPath = path.join(this.agentsDir, filename);
Expand All @@ -56,7 +60,7 @@ export class AgentLoader {
agentPath: string,
content: string,
agentName: string,
): Promise<AgentConfig> {
): Promise<LoadedAgent> {
const match = content.match(FRONTMATTER_REGEX);

if (!match || match.length < 3) {
Expand All @@ -66,26 +70,21 @@ export class AgentLoader {
const frontmatterYaml = match[1] as string;
const rawPrompt = match[2] as string;
const frontmatter = parseYaml(frontmatterYaml) as AgentFrontmatter;
const prompt = this.resolveReferencePaths(
const system = this.resolveReferencePaths(
rawPrompt.replace(/^\r?\n/, ""),
path.dirname(agentPath),
);

const config: AgentConfig = {
return {
name: agentName,
system,
description: frontmatter.description,
mode: frontmatter.mode,
prompt,
permissions: [
...this.toolRules(frontmatter.tools ?? null),
...this.permissionRules(frontmatter.permission ?? null),
],
};

if (frontmatter.tools) {
config.tools = frontmatter.tools;
}

if (frontmatter.permission) {
config.permission = frontmatter.permission;
}

return config;
}

private resolveReferencePaths(prompt: string, agentDir: string): string {
Expand All @@ -95,4 +94,36 @@ export class AgentLoader {
return `\`${absolute}\``;
});
}

private toolRules(tools: Record<string, boolean> | null): Agent.Info["permissions"] {
if (tools === null) return [];
return Object.entries(tools).map(([tool, enabled]) => ({
action: this.normalizeAction(tool),
resource: "*",
effect: enabled ? "allow" : "deny",
}));
}

private permissionRules(
permission: Record<string, PermissionEffect | Record<string, PermissionEffect>> | null,
): Agent.Info["permissions"] {
if (permission === null) return [];
return Object.entries(permission).flatMap(([key, rule]) => {
if (typeof rule === "string") {
return [{ action: this.normalizeAction(key), resource: "*", effect: rule }];
}
return Object.entries(rule).map(([resource, effect]) => ({
action: this.normalizeAction(key),
resource,
effect,
}));
});
}

private normalizeAction(action: string): string {
if (action === "write" || action === "patch") return "edit";
if (action === "task") return "subagent";
if (action === "bash") return "shell";
return action;
}
}
21 changes: 21 additions & 0 deletions src/asset-permission-advisor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { PermissionEvaluation } from "@opencode/plugin/effect/permission";

export class AssetPermissionAdvisor {
private readonly assetsDir: string;

public constructor(assetsDir: string) {
this.assetsDir = assetsDir.replaceAll("\\", "/").replace(/\/+$/, "");
}

public evaluate(input: PermissionEvaluation): void {
if (input.effect !== "ask") return;
if (input.action !== "external_directory") return;
if (!input.resources.every((resource) => this.isBundledResource(resource))) return;
input.effect = "allow";
}

private isBundledResource(resource: string): boolean {
const normalized = resource.replaceAll("\\", "/");
return normalized === this.assetsDir || normalized.startsWith(`${this.assetsDir}/`);
}
}
36 changes: 0 additions & 36 deletions src/permission-registrar.ts

This file was deleted.

85 changes: 62 additions & 23 deletions src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import type { Plugin } from "@opencode-ai/plugin";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { AgentLoader } from "./agent-loader";
import { AssetPermissionRegistrar } from "./permission-registrar";
import { Effect, type Scope } from "effect";
import { Plugin } from "@opencode/plugin/effect";
import type { AgentEditor } from "@opencode/plugin/effect/agent";
import { AgentLoader, type LoadedAgent } from "./agent-loader";
import { AssetPermissionAdvisor } from "./asset-permission-advisor";
import { Installer } from "./installer";

const PACKAGE_NAME = "opencode-architect";
const PLUGIN_ID = "opencode-architect";

export class OpencodeArchitectPlugin {
private readonly agentsDir: string;
private readonly assetsDir: string;
private readonly readVersion: () => Promise<string>;
private readonly hasInstall: (projectDir: string) => Promise<boolean>;
private readonly advisor: AssetPermissionAdvisor;
private failureAdvised = false;
private notInstalledAdvised = false;

Expand All @@ -25,27 +29,62 @@ export class OpencodeArchitectPlugin {
this.assetsDir = assetsDir ?? path.join(import.meta.dirname, "..");
this.readVersion = readVersion ?? (() => this.readPackageMetadata());
this.hasInstall = hasInstall ?? ((projectDir) => new Installer().hasManifestAnywhere(projectDir));
this.advisor = new AssetPermissionAdvisor(this.assetsDir);
}

public toPlugin(): Plugin {
return async (input) => {
const directory = typeof input?.directory === "string" ? input.directory : process.cwd();
return {
config: async (config) => {
try {
const agents = await new AgentLoader(this.agentsDir).loadAgents();
config.agent = config.agent || {};
for (const [name, agentConfig] of Object.entries(agents)) {
config.agent[name] = agentConfig;
}
new AssetPermissionRegistrar(this.assetsDir).register(config);
await this.adviseNotInstalledOnce(directory);
} catch (error) {
await this.adviseFailureOnce(error instanceof Error ? error.message : String(error));
}
},
};
};
public toDefinition(): Plugin.Plugin {
const plugin = this;
return Plugin.define({
id: PLUGIN_ID,
effect: Effect.fn(function* (context) {
const registered = yield* plugin.registerBundledAssets(context);
if (!registered) return;
yield* plugin.advisoryEffect(() => plugin.adviseNotInstalledOnce(context.location.directory));
}),
});
}

private registerBundledAssets(context: Plugin.Context): Effect.Effect<boolean, never, Scope.Scope> {
const plugin = this;
const loaded = Effect.tryPromise({
try: () => new AgentLoader(plugin.agentsDir).loadAgents(),
catch: (error: unknown) => (error instanceof Error ? error.message : String(error)),
});
return Effect.matchEffect(loaded, {
onFailure: (message) => Effect.as(plugin.advisoryEffect(() => plugin.adviseFailureOnce(message)), false),
onSuccess: (agents) => Effect.as(plugin.registrationEffect(context, agents), true),
});
}

private registrationEffect(
context: Plugin.Context,
agents: Record<string, LoadedAgent>,
): Effect.Effect<void, never, Scope.Scope> {
const plugin = this;
return Effect.flatMap(
context.agent.transform((editor) => {
for (const agent of Object.values(agents)) plugin.injectAgent(editor, agent);
}),
() =>
context.permission.hook("evaluate", (input) =>
Effect.sync(() => plugin.advisor.evaluate(input)),
),
);
}

private injectAgent(editor: AgentEditor, loaded: LoadedAgent): void {
editor.update(loaded.name, (agent) => {
agent.mode = loaded.mode;
agent.description = loaded.description;
agent.system = loaded.system;
agent.permissions.push(...loaded.permissions);
});
}

private advisoryEffect(run: () => Promise<void>): Effect.Effect<void, never, never> {
return Effect.asVoid(
Effect.orElseSucceed(Effect.tryPromise({ try: run, catch: () => "advisory failed" }), () => undefined),
);
}

private async adviseNotInstalledOnce(directory: string): Promise<void> {
Expand Down Expand Up @@ -94,4 +133,4 @@ export class OpencodeArchitectPlugin {

const opencodeArchitect = new OpencodeArchitectPlugin();

export default opencodeArchitect.toPlugin();
export default opencodeArchitect.toDefinition();
4 changes: 2 additions & 2 deletions templates/package-full.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ METADATA
},
"dependencies": {
"@inquirer/prompts": "^8.3.0",
"@opencode-ai/plugin": "*",
"@opencode-ai/sdk": "^1.2.20"
"@opencode/plugin": "latest",
"effect": "latest"
},
"devDependencies": {
"@types/bun": "latest",
Expand Down
10 changes: 5 additions & 5 deletions tests/agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ describe("AgentLoader", () => {
expect(Object.keys(agents)).toHaveLength(10);

for (const [name, agentConfig] of Object.entries(agents)) {
const prompt = agentConfig.prompt ?? "";
expect(prompt.trim().length, `${name} has an empty prompt`).toBeGreaterThan(0);
const system = agentConfig.system ?? "";
expect(system.trim().length, `${name} has an empty prompt`).toBeGreaterThan(0);
}
});

Expand All @@ -93,14 +93,14 @@ describe("AgentLoader", () => {
const agents = await new AgentLoader(AGENTS_DIR).loadAgents();

for (const [name, agentConfig] of Object.entries(agents)) {
const prompt = agentConfig.prompt ?? "";
const system = agentConfig.system ?? "";

const leftoverRelative = [...prompt.matchAll(RELATIVE_REFERENCE_REGEX)].map(
const leftoverRelative = [...system.matchAll(RELATIVE_REFERENCE_REGEX)].map(
(match) => match[1] ?? "",
);
expect(leftoverRelative, `${name} keeps unrewritten relative references`).toEqual([]);

for (const match of prompt.matchAll(ABSOLUTE_PATH_REGEX)) {
for (const match of system.matchAll(ABSOLUTE_PATH_REGEX)) {
const candidate = match[1] ?? "";
if (!isReferenceFilePath(candidate)) {
continue;
Expand Down
Loading
Loading