feat(plugin): add gdevelop-architect agent persona (T10, #17)

This commit is contained in:
BillyOutlast committed 2026-10-12 00:51:49 -04:00
1 parent bc77e107d5
commit 8fa3f43e7e
4 files changed
+347 -3

No files matched your search

+53
View File
@@ -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.
+76
View File
@@ -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 }));
});
});
}
+8 -3
View File
@@ -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);
},
};
@@ -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');
});
});