From 8fa3f43e7ee66ceed4e70c22094c133b57b40346 Mon Sep 17 00:00:00 2001 From: BillyOutlast <172061051+BillyOutlast@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:51:49 -0400 Subject: [PATCH] feat(plugin): add gdevelop-architect agent persona (T10, #17) --- packages/opencode-gdevelop/README.md | 53 +++++ packages/opencode-gdevelop/agents.mjs | 76 +++++++ packages/opencode-gdevelop/index.mjs | 11 +- .../opencode-plugin-agent.test.mjs | 210 ++++++++++++++++++ 4 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 packages/opencode-gdevelop/README.md create mode 100644 packages/opencode-gdevelop/agents.mjs create mode 100644 tests/integration/opencode-plugin-agent.test.mjs diff --git a/packages/opencode-gdevelop/README.md b/packages/opencode-gdevelop/README.md new file mode 100644 index 0000000..97a60ba --- /dev/null +++ b/packages/opencode-gdevelop/README.md @@ -0,0 +1,53 @@ +# `@heretek-ai/opencode-gdevelop` + +OpenCode plugin for **local, offline** GDevelop 5 project control. Six +`gdevelop_*` tools plus a `gdevelop-architect` subagent persona, all running +in-process with zero network egress (never contacts `api.gdevelop.io`). + +## Install + +This package is a plain ESM module with **zero dependencies**. +`@opencode/plugin` is a host-provided peer API, not a bundled dep: the +OpenCode runtime resolves the import at load time. Outside the runtime +(tests, CI) the entry falls back to an identity `Plugin.define` +passthrough so the `{ id, setup }` definition stays importable. + +Point your OpenCode config at this directory as a local plugin; the entry +is `packages/opencode-gdevelop/index.mjs` (plugin id: `gdevelop`). + +## Tools + +| Tool | What it does | +| --- | --- | +| `gdevelop_project_read` | Inspect scenes/objects/behaviors/variables from a project on disk (optional `sceneName` narrows to one scene). | +| `gdevelop_project_create_scene` | Create a scene (layout); fails closed on duplicates/blank names. | +| `gdevelop_project_create_object` | Create/replace an object: `Sprite`, `TextObject::Text`, `PanelSpriteObject::PanelSprite`, `Scene3D::*`, or any engine type; scene-scoped or global. | +| `gdevelop_project_add_behavior` | Attach a behavior (e.g. `PlatformBehavior::PlatformerObjectBehavior`) with family defaults. | +| `gdevelop_project_add_events` | Append a `BuiltinCommonInstructions::Standard` event (`conditions[]`/`actions[]`) to a scene sheet. | +| `gdevelop_cli_run` | Format a headless CLI dispatch (`--run-command UPPER_SNAKE_CASE`, repeatable `--cmd-args`, `--disable-update-check`); the host spawns it. | + +Every mutation delegates to +`packages/unlock-shim/control-shim.mjs` (`readGDevelopProject`, +`createScene`, `createOrReplaceObject`, `addBehavior`, `addStandardEvent`, +`buildGDevelopCliCommand`, `writeGDevelopProject`) — no reimplemented +project logic. Inputs are plain JSON-Schema objects; each `execute` returns +a host-shaped `{ output, content }` result and rejects fail-closed with the +path/id named. + +## Agent: `gdevelop-architect` + +Registered via `ctx.agent.transform(editor => editor.update(...))` (host +upsert semantics). Subagent-mode persona whose system prompt covers GDevelop +5 architecture, the GDJS lifecycle (game loop, scene stack), event +conditions/actions/sub-events, and asset management. Permissions: every +`gdevelop_*` tool (derived from `TOOL_NAMES`, so they cannot drift) plus +host file `read`/`write`. + +## Offline scope + +- No `dependencies`/`devDependencies`; only `node:` builtins + the control shim. +- No `api.gdevelop.io` references anywhere in the package. +- Tests (`tests/integration/opencode-plugin-tools.test.mjs`, + `tests/integration/opencode-plugin-agent.test.mjs`) use in-process ctx + doubles with real registries and real scratch-project I/O under + `os.tmpdir()` — no mocks of the code under test. diff --git a/packages/opencode-gdevelop/agents.mjs b/packages/opencode-gdevelop/agents.mjs new file mode 100644 index 0000000..b85af6f --- /dev/null +++ b/packages/opencode-gdevelop/agents.mjs @@ -0,0 +1,76 @@ +// GDevelop architect agent persona (T10, issue #17). +// +// Registers the `gdevelop-architect` subagent through the host +// `ctx.agent.transform(editor => editor.update(id, fn))` API. The editor's +// `update` upserts (review/opencode/packages/core/src/agent.ts: a missing id +// starts from Info.default(id)), so this works as a pure registration with +// no pre-existing agent entry required. +// +// Tool permissions derive from TOOL_NAMES (tools.mjs) so the architect can +// always call every gdevelop_* tool, plus host file read/write. + +import { TOOL_NAMES } from './tools.mjs'; + +/** Stable host agent id. */ +export const ARCHITECT_ID = 'gdevelop-architect'; + +const SYSTEM_PROMPT = `You are the GDevelop 5 architect: a specialist in designing, building, and debugging local GDevelop game projects (GDJS runtime, offline). + +GDevelop 5 architecture +- A project (game.json, single-file or split-folder) holds properties, global objects/variables, resources, layouts (scenes), and external event sheets. +- A scene holds layers (with cameras and effects), instances placed on layers, scene objects, scene variables, behaviors on objects, and one event sheet. +- Objects carry a type (Sprite, TextObject::Text, PanelSpriteObject::PanelSprite, Scene3D::* 3D boxes, …), variables, effects, and behaviors. Prefer scene objects; use global objects only for cross-scene sharing. +- Behaviors attach capabilities (PlatformerObject, Physics2, TopDownMovement, …) with per-family default parameters; name each behavior instance explicitly. + +GDJS lifecycle +- The engine runs a game loop: input → events evaluation → behaviors stepping → rendering, once per frame per scene on the scene stack. +- Scene stack: scenes push/replace/pause; paused scenes keep state. Startup scene is the project's first layout unless changed. +- Hot-reload and preview inspection go through the debugger bridge; headless builds/exports go through the CLI dispatcher (--run-command UPPER_SNAKE_CASE with --cmd-args). + +Events: conditions, actions, sub-events +- An event sheet is an ordered list of BuiltinCommonInstructions::Standard events, each with conditions[] and actions[]. +- Conditions are all AND-ed (all must hold) unless marked OR; an empty condition list means "always". +- Actions run top-to-bottom when conditions hold; order actions so state reads precede state writes within the same frame. +- Sub-events run only when the parent event's conditions held; use them to narrow scope instead of duplicating parent conditions. +- Keep sheets small and layered: input events, then simulation, then camera/HUD updates. Never emit circular include cycles between external sheets. + +Asset management +- Every texture, audio file, and font must be declared in the project's resources with a stable name before events or objects reference it. +- Prefer sprite sheets / atlases over loose frames; keep audio in engine-supported formats and preload scene-critical assets. +- Reference assets by resource name, never by absolute disk path, so projects stay portable across machines and CI. + +Working rules +- Inspect first (gdevelop_project_read), then mutate in dependency order: scenes → objects → behaviors → events. +- Use the gdevelop_* tools for every project mutation; use host file read/write only for supporting files (docs, configs), never to hand-edit game.json. +- Format CLI work with gdevelop_cli_run and let the host spawn it; never invent command names outside UPPER_SNAKE_CASE. +- Report what changed per scene/object/event so the user can verify in the editor.`; + +const allow = action => ({ action, resource: '*', effect: 'allow' }); + +/** Canonical architect config (plain data; also applied via transform). */ +export const GDEVELOP_ARCHITECT = { + id: ARCHITECT_ID, + name: 'GDevelop Architect', + description: + 'Designs and mutates local GDevelop 5 projects: scenes, objects, behaviors, event sheets, assets, and headless CLI dispatches.', + system: SYSTEM_PROMPT, + mode: 'subagent', + permissions: [ + ...TOOL_NAMES.map(allow), + allow('read'), + allow('write'), + ], +}; + +/** Register (upsert) the architect persona on a host ctx. */ +export async function registerGDevelopAgents(ctx) { + await ctx.agent.transform(editor => { + editor.update(ARCHITECT_ID, agent => { + agent.name = GDEVELOP_ARCHITECT.name; + agent.description = GDEVELOP_ARCHITECT.description; + agent.system = GDEVELOP_ARCHITECT.system; + agent.mode = GDEVELOP_ARCHITECT.mode; + agent.permissions = GDEVELOP_ARCHITECT.permissions.map(rule => ({ ...rule })); + }); + }); +} diff --git a/packages/opencode-gdevelop/index.mjs b/packages/opencode-gdevelop/index.mjs index 22c36ed..df23bbe 100644 --- a/packages/opencode-gdevelop/index.mjs +++ b/packages/opencode-gdevelop/index.mjs @@ -1,15 +1,19 @@ -// GDevelop OpenCode plugin entry (T09, issue #17). +// GDevelop OpenCode plugin entry (T09 tools + T10 architect, issue #17). // // Registers the six gdevelop_* project-control tools through the host -// `ctx.tool.transform(editor => editor.add(tool))` API (Promise plugin API: +// `ctx.tool.transform(editor => editor.add(tool))` API and the +// gdevelop-architect persona through +// `ctx.agent.transform(editor => editor.update(id, fn))` (Promise plugin API: // review/opencode/packages/plugin/src/README.md; ToolEditor.add in -// review/opencode/packages/plugin/src/promise/tool.ts). +// review/opencode/packages/plugin/src/promise/tool.ts; agent upsert in +// review/opencode/packages/core/src/agent.ts). // // `@opencode/plugin` is a host-provided peer API, NOT a bundled dependency // (this package ships zero deps): the host resolves the import at load time, // while offline runs (tests, CI) fall back to the identity passthrough below. import { GDEVELOP_TOOLS } from './tools.mjs'; +import { registerGDevelopAgents } from './agents.mjs'; /** Raw plugin definition; also exported for offline/test harnesses. */ export const definition = { @@ -18,6 +22,7 @@ export const definition = { await ctx.tool.transform(editor => { for (const tool of GDEVELOP_TOOLS) editor.add(tool); }); + await registerGDevelopAgents(ctx); }, }; diff --git a/tests/integration/opencode-plugin-agent.test.mjs b/tests/integration/opencode-plugin-agent.test.mjs new file mode 100644 index 0000000..8917227 --- /dev/null +++ b/tests/integration/opencode-plugin-agent.test.mjs @@ -0,0 +1,210 @@ +// Integration Test: gdevelop-architect agent persona (T10, issue #17). +// +// Drives the real packages/opencode-gdevelop/ plugin setup through an +// in-process ctx double whose agent editor mirrors the host upsert +// semantics (review/opencode/packages/core/src/agent.ts: update() creates +// Info.default(id) when the id is absent), then confirms +// ctx.agent.get("gdevelop-architect") returns a valid config with real +// permission lists. Tool executors are NOT re-exercised here (covered by +// opencode-plugin-tools.test.mjs); this file asserts registration, +// persona content, permissions, packaging docs, and idempotency. +// +// Bare node:test + builtins only. No skipped tests. + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { definition as pluginDefinition } from '../../packages/opencode-gdevelop/index.mjs'; +import { + ARCHITECT_ID, + GDEVELOP_ARCHITECT, + registerGDevelopAgents, +} from '../../packages/opencode-gdevelop/agents.mjs'; +import { TOOL_NAMES } from '../../packages/opencode-gdevelop/tools.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '..', '..'); + +// Host-faithful agent editor double: update() upserts like core agent.ts. +const createAgentEditorDouble = (seed = []) => { + const agents = new Map(seed.map(a => [a.id, { ...a }])); + return { + agents, + list: () => [...agents.values()], + get: id => agents.get(id), + default: () => {}, + update: (id, fn) => { + const current = agents.get(id) || { id }; + if (!agents.has(id)) agents.set(id, current); + fn(current); + current.id = id; + }, + remove: id => { + agents.delete(id); + }, + }; +}; + +const createCtxDouble = (seedAgents = []) => { + const agentEditor = createAgentEditorDouble(seedAgents); + const tools = new Map(); + return { + agentEditor, + agent: { + transform: async fn => { + fn(agentEditor); + return { dispose: async () => {} }; + }, + reload: async () => {}, + get: async id => agentEditor.get(id), + list: async () => agentEditor.list(), + }, + tool: { + transform: async fn => { + fn({ + list: () => [...tools.values()], + get: id => tools.get(id), + namespace: () => {}, + add: tool => { + tools.set(tool.name, { ...tool, id: tool.name }); + }, + update: () => {}, + remove: id => { + tools.delete(id); + }, + }); + return { dispose: async () => {} }; + }, + reload: async () => {}, + }, + }; +}; + +describe('integration: gdevelop-architect registration (real setup)', () => { + let ctx; + beforeEach(async () => { + ctx = createCtxDouble(); + await pluginDefinition.setup(ctx); + }); + + it('registers the architect via ctx.agent.transform (upsert, id gdevelop-architect)', async () => { + assert.equal(ARCHITECT_ID, 'gdevelop-architect'); + const agent = await ctx.agent.get('gdevelop-architect'); + assert.ok(agent, 'architect present after setup'); + assert.equal(agent.id, 'gdevelop-architect'); + }); + + it('ctx.agent.get returns a valid config: subagent mode, named, described', async () => { + const agent = await ctx.agent.get('gdevelop-architect'); + assert.equal(agent.mode, 'subagent'); + assert.ok(typeof agent.name === 'string' && agent.name.length > 0); + assert.ok(typeof agent.description === 'string' && agent.description.length > 0); + assert.deepEqual( + { id: agent.id, mode: agent.mode }, + { id: GDEVELOP_ARCHITECT.id, mode: GDEVELOP_ARCHITECT.mode } + ); + }); + + it('system prompt covers GDevelop 5 architecture, GDJS lifecycle, events, assets', async () => { + const agent = await ctx.agent.get('gdevelop-architect'); + assert.ok(typeof agent.system === 'string' && agent.system.length > 200); + for (const topic of [ + /GDJS/i, + /lifecycle|game loop|scene stack/i, + /conditions/i, + /actions/i, + /sub-events/i, + /behavior/i, + /asset|resources/i, + ]) { + assert.match(agent.system, topic, `system prompt covers ${topic}`); + } + }); + + it('permission list allows all gdevelop_* tools plus file read/write (real lists)', async () => { + const agent = await ctx.agent.get('gdevelop-architect'); + assert.ok(Array.isArray(agent.permissions) && agent.permissions.length > 0); + for (const rule of agent.permissions) { + assert.ok(typeof rule.action === 'string'); + assert.ok(typeof rule.resource === 'string'); + assert.match(rule.effect, /^(allow|ask|deny)$/); + } + const allowed = new Set( + agent.permissions.filter(r => r.effect === 'allow').map(r => r.action) + ); + for (const tool of TOOL_NAMES) { + assert.ok(allowed.has(tool), `architect may call ${tool}`); + } + assert.ok(allowed.has('read'), 'architect may read files'); + assert.ok(allowed.has('write'), 'architect may write files'); + }); + + it('setup is idempotent and never clobbers sibling agents', async () => { + ctx.agentEditor.set ?? null; + const before = await ctx.agent.get('gdevelop-architect'); + await pluginDefinition.setup(ctx); + const after = await ctx.agent.get('gdevelop-architect'); + assert.deepEqual(after, before, 'second setup converges to the same config'); + // Sibling agent registered outside the plugin survives a re-setup. + ctx.agentEditor.update('build', a => { + a.mode = 'primary'; + a.name = 'Build'; + }); + await pluginDefinition.setup(ctx); + assert.equal((await ctx.agent.get('build')).name, 'Build'); + assert.equal((await ctx.agent.get('gdevelop-architect')).mode, 'subagent'); + }); + + it('registerGDevelopAgents is exported standalone and matches setup output', async () => { + const solo = createCtxDouble(); + await registerGDevelopAgents(solo); + assert.deepEqual( + await solo.agent.get('gdevelop-architect'), + await ctx.agent.get('gdevelop-architect') + ); + }); +}); + +describe('integration: gdevelop plugin docs + packaging (T10)', () => { + it('README documents tools, agent, and offline use', () => { + const readme = fs.readFileSync( + path.join(root, 'packages/opencode-gdevelop/README.md'), + 'utf8' + ); + for (const tool of TOOL_NAMES) { + assert.ok(readme.includes(tool), `README documents ${tool}`); + } + assert.ok(readme.includes('gdevelop-architect'), 'README documents the agent'); + assert.ok(/offline|zero-egress|zero egress/i.test(readme), 'README states offline scope'); + }); + + it('agent sources stay zero-dep and zero-egress', () => { + for (const file of ['agents.mjs', 'index.mjs']) { + const src = fs.readFileSync( + path.join(root, 'packages/opencode-gdevelop', file), + 'utf8' + ); + assert.ok(!src.includes('api.gdevelop.io'), `${file}: zero egress`); + } + const pkg = JSON.parse( + fs.readFileSync( + path.join(root, 'packages/opencode-gdevelop/package.json'), + 'utf8' + ) + ); + assert.equal(pkg.name, '@heretek-ai/opencode-gdevelop'); + assert.ok(!pkg.dependencies && !pkg.devDependencies, 'still zero deps'); + }); + + it('uses os.tmpdir() scratch semantics (no hardcoded /tmp)', () => { + const src = fs.readFileSync( + path.join(root, 'tests/integration/opencode-plugin-agent.test.mjs'), + 'utf8' + ); + assert.ok(!src.match(/['"]\/tmp\//), 'no hardcoded /tmp paths'); + }); +});