mirror of
https://github.com/Heretek-OpenCode/heretek-opencode.git
synced 2026-11-07 10:05:58 -10:00
feat(packs): Tier 2 domain packs monorepo with pack-sdk contract
- pack-sdk 1.0.0: definePack(), PackManifestSchema (Zod), ready() handshake type, packConformance() (manifest/hostCompat/hooks/ tool round-trip/readiness/lane-image checks), hostSatisfiesCompat() - pack-godot 1.0.0: exact @opencode/plugin 2.0.26, ready() host+binary handshake, godot tool stubs + guard, companion addon identity, Dockerfile.lane (headless Godot 4.x) - pack-gdevelop 1.0.0: consolidated shim+studio boundary, representative patch stack with git-apply verification, donor fork decommissioned - pack-rea 1.0.0: readiness.ts handshake, agents/skills stubs, exact pins - 22-test conformance suite (bun test), tsc + oxlint clean
This commit is contained in:
1 parent
4c1686b5f8
commit
6635d9bb64
36 files changed
+2431
-2
No files matched your search
@@ -10,6 +10,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.3.4",
|
||||
"@types/node": "^22.0.0",
|
||||
"oxlint": "^0.15.0",
|
||||
"typescript": "^5.5.0",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@heretek-ai/opencode-packs",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Tier 2 domain pack monorepo: pack-sdk + pack-godot + pack-gdevelop + pack-rea",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"packs/*"
|
||||
],
|
||||
"scripts": {
|
||||
"check": "bunx tsc --noEmit -p tsconfig.json",
|
||||
"test": "bun test packages/pack-sdk/test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.3.4",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# `@heretek-ai/pack-sdk`
|
||||
|
||||
Shared contract for Tier 2 domain packs (`opencode-packs` monorepo).
|
||||
|
||||
## Contract
|
||||
|
||||
```ts
|
||||
import { definePack, packConformance, hostSatisfiesCompat } from "@heretek-ai/pack-sdk";
|
||||
|
||||
export default definePack({
|
||||
id: "mypack",
|
||||
version: "1.0.0",
|
||||
hostCompat: "2.0.x",
|
||||
laneRequires: [{ capability: "mypack", image: "ghcr.io/heretek-opencode/lane-mypack:1.x" }],
|
||||
tools: [{ server: "./mcp/mypack.js", transport: "stdio" }],
|
||||
skills: ["./skills/mypack.md"],
|
||||
commands: [{ name: "mypack.do", handler: "tools.mypackDo" }],
|
||||
hooks: { toolGuard: "./src/guard.ts" },
|
||||
ready: async (hostVersion) =>
|
||||
hostSatisfiesCompat("2.0.x", hostVersion)
|
||||
? { status: "ready" }
|
||||
: { status: "degraded", reason: `needs host 2.0.x, got ${hostVersion}` },
|
||||
});
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- `hostCompat` supports exact (`2.0.26`), x-range (`2.0.x`), caret, tilde, `*`.
|
||||
- `packConformance(manifest)` checks: manifest valid, hostCompat well-formed,
|
||||
hook signatures present, tool entries JSON round-trip, `ready()` is a function,
|
||||
lane image ref present whenever `laneRequires` is declared.
|
||||
- Readiness is a handshake, not a crash: a pack that cannot verify its runtime
|
||||
(missing binary, unreachable MCP) returns `{ status: "degraded", reason }`
|
||||
with a machine-readable, actionable reason.
|
||||
- All `@opencode/*` dependencies in every pack are pinned exact (`2.0.26`).
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@heretek-ai/pack-sdk",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared pack contract: definePack(), PackManifestSchema, ready() handshake, packConformance()",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"files": ["src", "README.md"],
|
||||
"scripts": {
|
||||
"check": "bunx tsc --noEmit -p ../../tsconfig.json",
|
||||
"test": "bun test test"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Pack manifest schema */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const LaneRequireSchema = z.object({
|
||||
capability: z.string().min(1),
|
||||
image: z.string().min(1),
|
||||
});
|
||||
|
||||
export const ToolRefSchema = z.object({
|
||||
server: z.string().min(1),
|
||||
transport: z.enum(["stdio", "http"]),
|
||||
/** Optional JSON-Schema-ish tool input schema; must survive a JSON round-trip. */
|
||||
schema: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const CommandRefSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
handler: z.string().min(1),
|
||||
});
|
||||
|
||||
/** Hook name -> handler module path. Values must be non-empty module refs. */
|
||||
export const HooksSchema = z.record(z.string().min(1));
|
||||
|
||||
export const PackManifestSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
version: z.string().min(1),
|
||||
hostCompat: z.string().min(1),
|
||||
laneRequires: z.array(LaneRequireSchema).optional(),
|
||||
tools: z.array(ToolRefSchema).optional(),
|
||||
skills: z.array(z.string().min(1)).optional(),
|
||||
commands: z.array(CommandRefSchema).optional(),
|
||||
hooks: HooksSchema.optional(),
|
||||
agents: z.array(z.string().min(1)).optional(),
|
||||
providers: z.array(z.string().min(1)).optional(),
|
||||
});
|
||||
|
||||
export type PackManifestData = z.infer<typeof PackManifestSchema>;
|
||||
export type LaneRequire = z.infer<typeof LaneRequireSchema>;
|
||||
export type ToolRef = z.infer<typeof ToolRefSchema>;
|
||||
export type CommandRef = z.infer<typeof CommandRefSchema>;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ready() handshake */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface ReadyResult {
|
||||
status: "ready" | "degraded";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type ReadyFn = (hostVersion: string) => Promise<ReadyResult>;
|
||||
|
||||
export interface PackManifest extends PackManifestData {
|
||||
ready: ReadyFn;
|
||||
}
|
||||
|
||||
/** Declare a pack. Validates the data half with Zod and requires a ready() handshake. */
|
||||
export function definePack(manifest: PackManifest): PackManifest {
|
||||
const { ready, ...data } = manifest;
|
||||
PackManifestSchema.parse(data);
|
||||
if (typeof ready !== "function") {
|
||||
const id = (data as { id?: unknown }).id;
|
||||
throw new Error(
|
||||
`pack ${JSON.stringify(id)}: manifest must export a ready(hostVersion) handshake function`,
|
||||
);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* hostCompat range handling */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const EXACT_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
|
||||
const XRANGE_RE = /^\d+(\.\d+)?(\.[xX*]|\.x)?$/;
|
||||
const CARET_RE = /^\^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
|
||||
const TILDE_RE = /^~\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
/** True when the hostCompat string is a supported range expression. */
|
||||
export function isValidCompatRange(range: string): boolean {
|
||||
return (
|
||||
range === "*" ||
|
||||
EXACT_RE.test(range) ||
|
||||
XRANGE_RE.test(range) ||
|
||||
CARET_RE.test(range) ||
|
||||
TILDE_RE.test(range)
|
||||
);
|
||||
}
|
||||
|
||||
function parseVersion(v: string): [number, number, number] | null {
|
||||
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
|
||||
if (!m) return null;
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
}
|
||||
|
||||
function cmp(a: [number, number, number], b: [number, number, number]): number {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Evaluate hostVersion against a hostCompat range. Unknown shapes fail closed (false). */
|
||||
export function hostSatisfiesCompat(hostCompat: string, hostVersion: string): boolean {
|
||||
const range = hostCompat.trim();
|
||||
const ver = parseVersion(hostVersion);
|
||||
if (!ver) return false;
|
||||
if (range === "*") return true;
|
||||
|
||||
if (EXACT_RE.test(range)) {
|
||||
const base = parseVersion(range);
|
||||
return base !== null && cmp(ver, base) === 0;
|
||||
}
|
||||
if (XRANGE_RE.test(range)) {
|
||||
const parts = range.split(".");
|
||||
const major = Number(parts[0]);
|
||||
if (ver[0] !== major) return false;
|
||||
if (parts.length === 1) return true;
|
||||
if (parts[1] === "x" || parts[1] === "X" || parts[1] === "*") return true;
|
||||
if (ver[1] !== Number(parts[1])) return false;
|
||||
if (parts.length === 2) return true;
|
||||
const patch = parts[2];
|
||||
return patch === "x" || patch === "X" || patch === "*";
|
||||
}
|
||||
if (CARET_RE.test(range)) {
|
||||
const base = parseVersion(range.slice(1))!;
|
||||
return cmp(ver, base) >= 0 && ver[0] === base[0];
|
||||
}
|
||||
if (TILDE_RE.test(range)) {
|
||||
const base = parseVersion(range.slice(1))!;
|
||||
return cmp(ver, base) >= 0 && ver[0] === base[0] && ver[1] === base[1];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Conformance */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface ConformanceResult {
|
||||
pass: boolean;
|
||||
fail: string[];
|
||||
}
|
||||
|
||||
function jsonRoundTrips(value: unknown): boolean {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(JSON.stringify(value))) === JSON.stringify(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pack contract checks:
|
||||
* manifest valid, hostCompat range well-formed, hook signatures present,
|
||||
* tool entries round-trip through JSON, readiness handshake is a function,
|
||||
* lane image refs present whenever laneRequires is declared.
|
||||
*/
|
||||
export function packConformance(manifest: unknown): ConformanceResult {
|
||||
const fail: string[] = [];
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return { pass: false, fail: ["manifest must be an object"] };
|
||||
}
|
||||
const m = manifest as Record<string, unknown>;
|
||||
const { ready, ...data } = m;
|
||||
|
||||
const parsed = PackManifestSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
fail.push(`manifest invalid: ${parsed.error.issues.map((i) => i.message).join("; ")}`);
|
||||
}
|
||||
const id = typeof m.id === "string" && m.id.length > 0 ? m.id : "<unknown>";
|
||||
|
||||
if (typeof ready !== "function") {
|
||||
fail.push(`pack "${id}": ready(hostVersion) handshake missing (must be a function)`);
|
||||
}
|
||||
|
||||
if (typeof m.hostCompat !== "string" || !isValidCompatRange(m.hostCompat)) {
|
||||
fail.push(
|
||||
`pack "${id}": hostCompat ${JSON.stringify(m.hostCompat)} is not a supported semver range ` +
|
||||
`(exact "2.0.26", x-range "2.0.x", caret "^2.0.0", tilde "~2.0.0", or "*")`,
|
||||
);
|
||||
}
|
||||
|
||||
if (m.hooks !== undefined) {
|
||||
if (typeof m.hooks !== "object" || m.hooks === null || Array.isArray(m.hooks)) {
|
||||
fail.push(`pack "${id}": hooks must be a { name: handlerPath } map`);
|
||||
} else {
|
||||
for (const [name, handler] of Object.entries(m.hooks as Record<string, unknown>)) {
|
||||
if (typeof handler !== "string" || handler.length === 0) {
|
||||
fail.push(`pack "${id}": hook "${name}" must map to a non-empty handler module path`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (m.tools !== undefined && Array.isArray(m.tools)) {
|
||||
m.tools.forEach((t, i) => {
|
||||
if (!jsonRoundTrips(t)) {
|
||||
fail.push(`pack "${id}": tools[${i}] does not survive a JSON schema round-trip`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (m.laneRequires !== undefined && Array.isArray(m.laneRequires)) {
|
||||
m.laneRequires.forEach((l, i) => {
|
||||
const lane = l as { capability?: unknown; image?: unknown };
|
||||
if (typeof lane.image !== "string" || lane.image.length === 0) {
|
||||
fail.push(
|
||||
`pack "${id}": laneRequires[${i}] declares capability ` +
|
||||
`${JSON.stringify(lane.capability)} without a lane image ref`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { pass: fail.length === 0, fail };
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
hostSatisfiesCompat,
|
||||
packConformance,
|
||||
} from "../src/index";
|
||||
|
||||
import godotManifest, { ready as godotReady } from "../../../packs/pack-godot/src/index";
|
||||
import { GODOT_TOOLS } from "../../../packs/pack-godot/src/tools";
|
||||
import { guardTool } from "../../../packs/pack-godot/src/guard";
|
||||
import gdevelopManifest, { ready as gdevelopReady } from "../../../packs/pack-gdevelop/src/index";
|
||||
import reaManifest, { ready as reaReady } from "../../../packs/pack-rea/src/index";
|
||||
import { checkReaReadiness } from "../../../packs/pack-rea/src/readiness";
|
||||
|
||||
const HOST = "2.0.26";
|
||||
|
||||
describe("packConformance", () => {
|
||||
test("passes for pack-godot", () => {
|
||||
const result = packConformance(godotManifest);
|
||||
expect({ result }).toEqual({ result: { pass: true, fail: [] } });
|
||||
});
|
||||
|
||||
test("passes for pack-gdevelop", () => {
|
||||
expect(packConformance(gdevelopManifest)).toEqual({ pass: true, fail: [] });
|
||||
});
|
||||
|
||||
test("passes for pack-rea", () => {
|
||||
expect(packConformance(reaManifest)).toEqual({ pass: true, fail: [] });
|
||||
});
|
||||
|
||||
test("fails on missing ready() handshake", () => {
|
||||
const { ready: _drop, ...data } = godotManifest;
|
||||
const result = packConformance(data);
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.fail.some((f) => f.includes("ready"))).toBe(true);
|
||||
});
|
||||
|
||||
test("fails on malformed hostCompat", () => {
|
||||
const result = packConformance({ ...godotManifest, hostCompat: "sometime-later" });
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.fail.some((f) => f.includes("hostCompat"))).toBe(true);
|
||||
});
|
||||
|
||||
test("fails on empty hook handler", () => {
|
||||
const result = packConformance({ ...godotManifest, hooks: { toolGuard: "" } });
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.fail.some((f) => f.includes("toolGuard"))).toBe(true);
|
||||
});
|
||||
|
||||
test("fails on laneRequires without an image ref", () => {
|
||||
const result = packConformance({
|
||||
...godotManifest,
|
||||
laneRequires: [{ capability: "godot", image: "" }],
|
||||
});
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.fail.some((f) => f.includes("image"))).toBe(true);
|
||||
});
|
||||
|
||||
test("fails on tool entry that does not JSON round-trip", () => {
|
||||
const result = packConformance({
|
||||
...godotManifest,
|
||||
tools: [{ server: "./mcp/godot.js", transport: "stdio", schema: { n: 1n } }],
|
||||
});
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.fail.some((f) => f.includes("round-trip"))).toBe(true);
|
||||
});
|
||||
|
||||
test("fails on an invalid manifest body", () => {
|
||||
const result = packConformance({ ...godotManifest, id: "" });
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.fail.some((f) => f.includes("manifest invalid"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hostSatisfiesCompat", () => {
|
||||
test("x-range, exact, caret, tilde, star", () => {
|
||||
expect(hostSatisfiesCompat("2.0.x", "2.0.26")).toBe(true);
|
||||
expect(hostSatisfiesCompat("2.0.x", "2.1.0")).toBe(false);
|
||||
expect(hostSatisfiesCompat("2.0.26", "2.0.26")).toBe(true);
|
||||
expect(hostSatisfiesCompat("2.0.26", "2.0.27")).toBe(false);
|
||||
expect(hostSatisfiesCompat("^2.0.0", "2.9.0")).toBe(true);
|
||||
expect(hostSatisfiesCompat("^2.0.0", "3.0.0")).toBe(false);
|
||||
expect(hostSatisfiesCompat("~2.0.0", "2.0.9")).toBe(true);
|
||||
expect(hostSatisfiesCompat("~2.0.0", "2.1.0")).toBe(false);
|
||||
expect(hostSatisfiesCompat("*", "9.9.9")).toBe(true);
|
||||
expect(hostSatisfiesCompat("bogus", HOST)).toBe(false);
|
||||
expect(hostSatisfiesCompat("2.0.x", "not-a-version")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ready() handshakes", () => {
|
||||
test("godot: ready when host matches and binary present", async () => {
|
||||
expect(await godotReady(HOST, { probeGodot: () => true })).toEqual({ status: "ready" });
|
||||
});
|
||||
|
||||
test("godot: degraded on host mismatch", async () => {
|
||||
const r = await godotReady("1.9.0", { probeGodot: () => true });
|
||||
expect(r.status).toBe("degraded");
|
||||
expect(r.reason).toContain("1.9.0");
|
||||
});
|
||||
|
||||
test("godot: degraded with reason when binary missing", async () => {
|
||||
const r = await godotReady(HOST, { probeGodot: () => false });
|
||||
expect(r.status).toBe("degraded");
|
||||
expect(r.reason).toContain("godot binary");
|
||||
});
|
||||
|
||||
test("gdevelop: ready on matching host, degraded otherwise", async () => {
|
||||
expect(await gdevelopReady(HOST)).toEqual({ status: "ready" });
|
||||
const r = await gdevelopReady("3.0.0");
|
||||
expect(r.status).toBe("degraded");
|
||||
expect(r.reason).toContain("3.0.0");
|
||||
});
|
||||
|
||||
test("rea: ready when host matches and REA responsive", async () => {
|
||||
expect(await reaReady(HOST, { rea: { probe: () => true } })).toEqual({ status: "ready" });
|
||||
});
|
||||
|
||||
test("rea: degraded with actionable remediation when REA missing", async () => {
|
||||
const r = await reaReady(HOST, { rea: { probe: () => false } });
|
||||
expect(r.status).toBe("degraded");
|
||||
expect(r.reason).toContain("rea doctor");
|
||||
expect(r.reason).toContain("rea setup");
|
||||
});
|
||||
|
||||
test("rea: degraded on host mismatch without probing REA", async () => {
|
||||
let probed = false;
|
||||
const r = await reaReady("1.0.0", {
|
||||
rea: {
|
||||
probe: () => {
|
||||
probed = true;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(r.status).toBe("degraded");
|
||||
expect(probed).toBe(false);
|
||||
});
|
||||
|
||||
test("checkReaReadiness returns remediation advice only (never throws)", async () => {
|
||||
const r = await checkReaReadiness({ probe: () => false });
|
||||
expect(r.status).toBe("degraded");
|
||||
expect(r.remediation.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("godot tool stubs", () => {
|
||||
test("tool defs JSON round-trip and guard allows only godot.*", () => {
|
||||
for (const tool of GODOT_TOOLS) {
|
||||
expect(JSON.parse(JSON.stringify(tool))).toEqual(tool);
|
||||
expect(tool.name.startsWith("godot.")).toBe(true);
|
||||
}
|
||||
expect(guardTool("godot.openScene").allow).toBe(true);
|
||||
expect(guardTool("fetch.raw").allow).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("@opencode/* version pins", () => {
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const packsDir = join(testDir, "..", "..", "..", "packs");
|
||||
const sections = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
|
||||
|
||||
for (const pack of ["pack-godot", "pack-gdevelop", "pack-rea"]) {
|
||||
test(`${pack}: every @opencode/* dep is exactly 2.0.26`, () => {
|
||||
const pkg = JSON.parse(readFileSync(join(packsDir, pack, "package.json"), "utf8")) as Record<
|
||||
string,
|
||||
Record<string, string> | undefined
|
||||
>;
|
||||
const pins: Array<[string, string, string]> = [];
|
||||
for (const section of sections) {
|
||||
for (const [name, version] of Object.entries(pkg[section] ?? {})) {
|
||||
if (name.startsWith("@opencode/")) pins.push([section, name, version]);
|
||||
}
|
||||
}
|
||||
expect(pins.length).toBeGreaterThan(0);
|
||||
for (const [section, name, version] of pins) {
|
||||
expect(version, `${pack} ${section}:${name}`).toBe("2.0.26");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
# lane-gdevelop — GDevelop verification lane for pack-gdevelop.
|
||||
# Built and published by CI as ghcr.io/heretek-opencode/lane-gdevelop:5.x.
|
||||
# CI passes --build-arg BASE_IMAGE=<digest-pinned ref>; the default below is
|
||||
# for local iteration only and must not be released unpinned.
|
||||
ARG BASE_IMAGE=oven/bun:1
|
||||
FROM ${BASE_IMAGE} AS lane
|
||||
|
||||
LABEL org.opencontainers.image.title="lane-gdevelop" \
|
||||
org.opencontainers.image.description="GDevelop patch-stack verification lane for pack-gdevelop"
|
||||
|
||||
ENV OPENCE_PACK=gdevelop
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
# Rebase-discipline gate (donor issue #13): the patch stack must apply cleanly.
|
||||
# The pack source is mounted at /work by the lane runner before this runs.
|
||||
CMD ["bun", "run", "verify-patches"]
|
||||
@@ -0,0 +1,14 @@
|
||||
# `@heretek-ai/pack-gdevelop`
|
||||
|
||||
GDevelop domain pack: one consolidated boundary from the donor's four
|
||||
packages (`ai-studio` + `opencode-gdevelop` + `unlock-shim`; the provider
|
||||
bridge stays optional at `./provider-bridge`, BYOK only).
|
||||
|
||||
- Exact `@opencode/plugin` pin: `2.0.26`.
|
||||
- `patches/` carries representative patch seams with automated
|
||||
`git apply --check` verification (`bun run verify-patches`) — the
|
||||
per-upstream-release rebase discipline (donor issue #13).
|
||||
- **Donor fork decommissioned (donor issue #6):** the `GDevelop-Unlocked`
|
||||
donor fork is archived and its daily sync dropped. This pack is the single
|
||||
source of truth; the donor remains readable history only.
|
||||
- `Dockerfile.lane` provides the reproducible verification environment.
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@heretek-ai/pack-gdevelop",
|
||||
"version": "1.0.0",
|
||||
"description": "GDevelop domain pack: consolidated unlock shim + BYOK AI studio + patch-stack verification",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"files": ["src", "skills", "patches", "scripts", "Dockerfile.lane", "README.md"],
|
||||
"scripts": {
|
||||
"check": "bunx tsc --noEmit -p ../../tsconfig.json",
|
||||
"verify-patches": "bun scripts/verify-patches.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "2.0.26"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
From <commit> Mon Sep 17 00:00:00 2001
|
||||
From: t <t@t>
|
||||
Date: Mon, 12 Oct 2026 12:50:55 -0400
|
||||
Subject: [PATCH] shim
|
||||
|
||||
---
|
||||
unlock-shim/control-shim.js | 10 ++++++++++
|
||||
1 file changed, 10 insertions(+)
|
||||
create mode 100644 unlock-shim/control-shim.js
|
||||
|
||||
diff --git a/unlock-shim/control-shim.js b/unlock-shim/control-shim.js
|
||||
new file mode 100644
|
||||
index 0000000..a471730
|
||||
--- /dev/null
|
||||
+++ b/unlock-shim/control-shim.js
|
||||
@@ -0,0 +1,10 @@
|
||||
+// unlock-shim control stub (representative of donor patch 06-electron-hook).
|
||||
+// The full shim preloads before the editor shell and exposes the local
|
||||
+// control channel consumed by opencode-gdevelop offline tools.
|
||||
+export const SHIM_CHANNEL = "heretek:gdevelop-control";
|
||||
+
|
||||
+export function installControlShim(target) {
|
||||
+ if (!target || typeof target !== "object") return { installed: false };
|
||||
+ target[SHIM_CHANNEL] = { version: 1, transport: "preload" };
|
||||
+ return { installed: true, channel: SHIM_CHANNEL };
|
||||
+}
|
||||
@@ -0,0 +1,27 @@
|
||||
From <commit> Mon Sep 17 00:00:00 2001
|
||||
From: t <t@t>
|
||||
Date: Mon, 12 Oct 2026 12:50:55 -0400
|
||||
Subject: [PATCH] studio
|
||||
|
||||
---
|
||||
ai-studio/mount.js | 11 +++++++++++
|
||||
1 file changed, 11 insertions(+)
|
||||
create mode 100644 ai-studio/mount.js
|
||||
|
||||
diff --git a/ai-studio/mount.js b/ai-studio/mount.js
|
||||
new file mode 100644
|
||||
index 0000000..8536d03
|
||||
--- /dev/null
|
||||
+++ b/ai-studio/mount.js
|
||||
@@ -0,0 +1,11 @@
|
||||
+// ai-studio mount stub (representative of donor patch 11-studio-runtime-mount).
|
||||
+// The full studio mounts a BYOK session runtime into the editor container;
|
||||
+// API keys are held in memory only and never persisted.
|
||||
+export const STUDIO_MOUNT_POINT = "ai-studio";
|
||||
+
|
||||
+export function mountStudio(container, { apiKey } = {}) {
|
||||
+ if (!container || typeof apiKey !== "string" || apiKey.length === 0) {
|
||||
+ return { mounted: false, reason: "container and in-memory apiKey required" };
|
||||
+ }
|
||||
+ return { mounted: true, mount: STUDIO_MOUNT_POINT, persistKey: false };
|
||||
+}
|
||||
@@ -0,0 +1,31 @@
|
||||
# patches/ — GDevelop patch stack (representative samples + verification)
|
||||
|
||||
## Donor stack (context)
|
||||
|
||||
The `GDevelop-Unlocked` donor carried a 14-patch surgical stack
|
||||
(`01-usage-predicate` … `14-ai-utils`) converting stock upstream GDevelop
|
||||
source to fork parity: entitlement unlocks (01–06), AI generation glue and
|
||||
BYOK routing (07–14), plus the `ai-studio` / `unlock-shim` runtime overlay
|
||||
that the hunks import. Patches were generated, anchor-gated, and applied by
|
||||
the donor's driver script before building.
|
||||
|
||||
## What lives here
|
||||
|
||||
This pack keeps **minimal representative patches** proving the rebase
|
||||
discipline, not the full donor stack:
|
||||
|
||||
| Patch | Represents |
|
||||
|---|---|
|
||||
| `01-unlock-shim-stub.patch` | Electron-hook / control-shim preload seam (donor 06) |
|
||||
| `02-ai-studio-mount.patch` | Studio runtime mount seam (donor 11) |
|
||||
|
||||
## Verification (donor issue #13)
|
||||
|
||||
`bun run verify-patches` (i.e. `scripts/verify-patches.ts`) asserts every
|
||||
`*.patch` applies cleanly via `git apply --check` in a pristine temp repo.
|
||||
On upstream GDevelop releases, CI re-runs this job and fails loudly on drift.
|
||||
|
||||
## Donor fork decommissioned (donor issue #6)
|
||||
|
||||
The donor fork is archived: daily sync is dropped, and this patch stack plus
|
||||
the consolidated pack are the single source of truth. See `../README.md`.
|
||||
@@ -0,0 +1,55 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Patch-stack rebase discipline (donor issue #13): every patch in patches/
|
||||
* must apply cleanly onto a pristine tree. Each patch is checked with
|
||||
* `git apply --check` inside a fresh empty repo so the verification is
|
||||
* independent of whatever checkout runs it.
|
||||
*/
|
||||
const thisDir = dirname(fileURLToPath(import.meta.url));
|
||||
const patchesDir = join(thisDir, "..", "patches");
|
||||
|
||||
function checkPatch(path: string): { ok: boolean; output: string } {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "gdevelop-patch-check-"));
|
||||
try {
|
||||
const init = spawnSync("git", ["init", "-q", "."], { cwd: workdir });
|
||||
if (init.status !== 0) {
|
||||
return { ok: false, output: `git init failed: ${init.stderr.toString()}` };
|
||||
}
|
||||
const check = spawnSync("git", ["apply", "--check", path], { cwd: workdir });
|
||||
const output = `${check.stdout.toString()}${check.stderr.toString()}`.trim();
|
||||
return { ok: check.status === 0, output };
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const patches = readdirSync(patchesDir)
|
||||
.filter((f) => f.endsWith(".patch"))
|
||||
.sort();
|
||||
|
||||
if (patches.length === 0) {
|
||||
console.error("verify-patches: no patches found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let failed = 0;
|
||||
for (const patch of patches) {
|
||||
const result = checkPatch(join(patchesDir, patch));
|
||||
if (result.ok) {
|
||||
console.log(`ok ${patch}`);
|
||||
} else {
|
||||
failed += 1;
|
||||
console.error(`FAIL ${patch}${result.output ? `: ${result.output}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`verify-patches: ${failed}/${patches.length} patches do not apply cleanly`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`verify-patches: ${patches.length}/${patches.length} patches apply cleanly`);
|
||||
@@ -0,0 +1,10 @@
|
||||
# gdevelop-byok skill (pack-gdevelop)
|
||||
|
||||
Applies when the operator asks for GDevelop AI assistance.
|
||||
|
||||
1. BYOK only: the API key lives in memory for the session. Never persist it,
|
||||
never send project content to hosted storage (no S3 presigned uploads).
|
||||
2. If the request cannot be served locally, say so and stop — do not silently
|
||||
fall back to a hosted endpoint.
|
||||
3. Patch-stack changes go through `bun run verify-patches` before anything
|
||||
else; a patch that does not apply cleanly is a hard stop.
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
definePack,
|
||||
hostSatisfiesCompat,
|
||||
type ReadyResult,
|
||||
} from "../../../packages/pack-sdk/src/index";
|
||||
|
||||
export const PACK_ID = "gdevelop";
|
||||
export const PACK_VERSION = "1.0.0";
|
||||
export const HOST_COMPAT = "2.0.x";
|
||||
|
||||
/**
|
||||
* Consolidation note: the donor carried four packages (ai-studio, opencode-gdevelop,
|
||||
* opencode-provider, unlock-shim). This pack merges the in-editor surface
|
||||
* (unlock-shim + opencode-gdevelop + ai-studio) into one boundary; the provider
|
||||
* bridge remains an optional subpath (`./provider-bridge`, BYOK only).
|
||||
*/
|
||||
export const CONSOLIDATED_MODULES = ["unlock-shim", "opencode-gdevelop", "ai-studio"] as const;
|
||||
export const OPTIONAL_PROVIDER_BRIDGE = "./provider-bridge";
|
||||
|
||||
export async function ready(hostVersion: string): Promise<ReadyResult> {
|
||||
if (!hostSatisfiesCompat(HOST_COMPAT, hostVersion)) {
|
||||
return {
|
||||
status: "degraded",
|
||||
reason: `pack-gdevelop ${PACK_VERSION} requires host ${HOST_COMPAT}, got ${hostVersion}`,
|
||||
};
|
||||
}
|
||||
return { status: "ready" };
|
||||
}
|
||||
|
||||
export const manifest = definePack({
|
||||
id: PACK_ID,
|
||||
version: PACK_VERSION,
|
||||
hostCompat: HOST_COMPAT,
|
||||
laneRequires: [{ capability: "gdevelop", image: "ghcr.io/heretek-opencode/lane-gdevelop:5.x" }],
|
||||
tools: [{ server: "./mcp/gdevelop.js", transport: "stdio" }],
|
||||
skills: ["./skills/gdevelop-byok.md"],
|
||||
commands: [{ name: "gdevelop.verify-patches", handler: "scripts.verifyPatches" }],
|
||||
providers: [OPTIONAL_PROVIDER_BRIDGE],
|
||||
ready,
|
||||
});
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,20 @@
|
||||
# lane-godot — headless Godot 4.x verification lane for pack-godot.
|
||||
# Built and published by CI as ghcr.io/heretek-opencode/lane-godot:4.x.
|
||||
# CI passes --build-arg BASE_IMAGE=<digest-pinned Godot headless ref>; the
|
||||
# default below is for local iteration only and must not be released unpinned.
|
||||
ARG BASE_IMAGE=barichello/godot-ci:latest
|
||||
FROM ${BASE_IMAGE} AS lane
|
||||
|
||||
LABEL org.opencontainers.image.title="lane-godot" \
|
||||
org.opencontainers.image.description="Headless Godot 4.x lane for pack-godot task verification"
|
||||
|
||||
ENV GODOT_HEADLESS=1 \
|
||||
OPENCE_PACK=godot
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
# godot.ping handshake target when no editor is attached.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
|
||||
CMD godot --version || exit 1
|
||||
|
||||
CMD ["godot", "--headless", "--version"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# `@heretek-ai/pack-godot`
|
||||
|
||||
Godot 4 domain pack: host-side plugin entry (`src/`), GDScript companion
|
||||
addon artifact (`companion/`), scene skill (`skills/`), headless lane image
|
||||
(`Dockerfile.lane`).
|
||||
|
||||
- Exact `@opencode/plugin` pin: `2.0.26` (fixes the donor's unpinned drift).
|
||||
- `ready(hostVersion)` requires host `2.0.x` and a resolvable `godot` binary;
|
||||
otherwise it returns `degraded` with an actionable reason.
|
||||
- `companion/` ships as the versioned `godot_opencode-<version>.zip` release
|
||||
artifact; only identity files live in-repo.
|
||||
@@ -0,0 +1,16 @@
|
||||
# companion/addons/godot_opencode/
|
||||
|
||||
GDScript editor-side companion addon for `pack-godot`.
|
||||
|
||||
- The full editor addon (connection, dispatcher, domain handlers, debugger,
|
||||
undo manager) ships as a **versioned zip artifact** attached to each
|
||||
`pack-godot` release (`godot_opencode-<version>.zip`), installed by
|
||||
extracting into `<project>/addons/godot_opencode/`.
|
||||
- This directory carries the minimum needed to identify the artifact:
|
||||
`plugin.cfg` (identity: name, version, entry script) and a minimal
|
||||
entry-point stub. The stub documents the bridge contract (authenticated
|
||||
loopback bridge, default port 9500) and points at the release zip for the
|
||||
full implementation.
|
||||
- Ported from the `godot-opencode` donor (`companion/addons/godot_opencode/`);
|
||||
the donor's unpinned host dependency is fixed here by the pack's exact
|
||||
`@opencode/plugin` pin (`2.0.26`).
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
@tool
|
||||
extends EditorPlugin
|
||||
## Godot OpenCode companion addon entry point (pack-godot stub).
|
||||
##
|
||||
## The full implementation (authenticated loopback bridge, dispatcher, domain
|
||||
## handlers, debugger integration) ships as the versioned release artifact
|
||||
## `godot_opencode-<version>.zip`. This stub exists so the addon identity
|
||||
## (plugin.cfg) resolves in a project tree; it performs no bridge I/O itself.
|
||||
|
||||
const BRIDGE_HOST := "127.0.0.1"
|
||||
const BRIDGE_PORT := 9500
|
||||
|
||||
|
||||
func _enter_tree() -> void:
|
||||
print("[godot_opencode] stub loaded; install godot_opencode-<version>.zip for the full bridge")
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
pass
|
||||
@@ -0,0 +1,7 @@
|
||||
[plugin]
|
||||
|
||||
name="Godot OpenCode"
|
||||
description="Godot Editor Companion Addon for OpenCode (authenticated loopback bridge; full implementation ships as the versioned release zip)"
|
||||
author="Heretek-OpenCode"
|
||||
version="1.0.0"
|
||||
script="godot_opencode.gd"
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@heretek-ai/pack-godot",
|
||||
"version": "1.0.0",
|
||||
"description": "Godot 4 domain pack: OpenCode plugin + GDScript companion addon + headless lane image",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"files": ["src", "skills", "companion", "Dockerfile.lane", "README.md"],
|
||||
"scripts": {
|
||||
"check": "bunx tsc --noEmit -p ../../tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "2.0.26"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode/cli": "2.0.26",
|
||||
"@opencode/sdk": "2.0.26",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# godot-scene skill (pack-godot)
|
||||
|
||||
Applies when the operator asks to inspect or change a Godot scene.
|
||||
|
||||
1. Run the `godot.ping` handshake first; on `degraded`, stop and surface the
|
||||
readiness reason instead of guessing at scene state.
|
||||
2. Prefer `godot.openScene` with a project-relative path (`res://…`).
|
||||
3. Never invent node paths: list the scene tree through the bridge before editing.
|
||||
4. Headless verification (`godot.runScene`) runs in the `lane-godot` image,
|
||||
never against the operator's live editor.
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Minimal tool-guard stub: allow only godot.* tools through this pack's lane. */
|
||||
|
||||
const ALLOWED_PREFIX = "godot.";
|
||||
|
||||
export function guardTool(toolName: string): { allow: boolean; reason?: string } {
|
||||
if (toolName.startsWith(ALLOWED_PREFIX)) return { allow: true };
|
||||
return { allow: false, reason: `pack-godot guard blocks non-godot tool "${toolName}"` };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { accessSync, constants as fsConstants } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
definePack,
|
||||
hostSatisfiesCompat,
|
||||
type ReadyResult,
|
||||
} from "../../../packages/pack-sdk/src/index";
|
||||
import { GODOT_TOOLS } from "./tools";
|
||||
|
||||
export const PACK_ID = "godot";
|
||||
export const PACK_VERSION = "1.0.0";
|
||||
export const HOST_COMPAT = "2.0.x";
|
||||
export const GODOT_BRIDGE_PORT = 9500;
|
||||
export const GODOT_BRIDGE_HOST = "127.0.0.1";
|
||||
|
||||
export interface GodotReadyOptions {
|
||||
/** Override the godot-binary probe (tests inject true/false here). */
|
||||
probeGodot?: () => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
/** True when a `godot` binary is resolvable (Bun.which, falling back to a PATH scan). */
|
||||
export function defaultGodotProbe(): boolean {
|
||||
const bunWhich = (globalThis as { Bun?: { which?: (cmd: string) => string | null } }).Bun?.which;
|
||||
if (typeof bunWhich === "function") {
|
||||
try {
|
||||
if (bunWhich("godot") !== null) return true;
|
||||
} catch {
|
||||
/* fall through to PATH scan */
|
||||
}
|
||||
}
|
||||
const delimiter = process.platform === "win32" ? ";" : ":";
|
||||
const suffixes = process.platform === "win32" ? ["", ".exe", ".cmd"] : [""];
|
||||
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
||||
if (!dir) continue;
|
||||
for (const suffix of suffixes) {
|
||||
try {
|
||||
accessSync(join(dir, `godot${suffix}`), fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
/* keep scanning */
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function ready(
|
||||
hostVersion: string,
|
||||
opts: GodotReadyOptions = {},
|
||||
): Promise<ReadyResult> {
|
||||
if (!hostSatisfiesCompat(HOST_COMPAT, hostVersion)) {
|
||||
return {
|
||||
status: "degraded",
|
||||
reason: `pack-godot ${PACK_VERSION} requires host ${HOST_COMPAT}, got ${hostVersion}`,
|
||||
};
|
||||
}
|
||||
const probe = opts.probeGodot ?? defaultGodotProbe;
|
||||
if (!(await probe())) {
|
||||
return {
|
||||
status: "degraded",
|
||||
reason:
|
||||
"godot binary not found on PATH; install a Godot 4.x headless editor build or run inside the lane-godot image",
|
||||
};
|
||||
}
|
||||
return { status: "ready" };
|
||||
}
|
||||
|
||||
export const manifest = definePack({
|
||||
id: PACK_ID,
|
||||
version: PACK_VERSION,
|
||||
hostCompat: HOST_COMPAT,
|
||||
laneRequires: [{ capability: "godot", image: "ghcr.io/heretek-opencode/lane-godot:4.x" }],
|
||||
tools: [{ server: "./mcp/godot.js", transport: "stdio" }],
|
||||
skills: ["./skills/godot-scene.md"],
|
||||
commands: [{ name: "godot.open-scene", handler: "tools.godotOpenScene" }],
|
||||
hooks: { toolGuard: "./src/guard.ts" },
|
||||
ready,
|
||||
});
|
||||
|
||||
export { GODOT_TOOLS };
|
||||
export default manifest;
|
||||
@@ -0,0 +1,44 @@
|
||||
/** Stub tool definitions for the Godot editor bridge (scene open / headless run / ping). */
|
||||
|
||||
export interface GodotToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const GODOT_TOOLS: GodotToolDef[] = [
|
||||
{
|
||||
name: "godot.ping",
|
||||
description: "Handshake check: verify the Godot editor bridge is reachable.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "godot.openScene",
|
||||
description: "Open a scene file in the attached Godot editor.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Project-relative scene path, e.g. res://main.tscn" },
|
||||
},
|
||||
required: ["path"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "godot.runScene",
|
||||
description: "Run a scene headless and return captured output.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Project-relative scene path" },
|
||||
timeoutMs: { type: "number", description: "Kill timeout in milliseconds" },
|
||||
},
|
||||
required: ["path"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,11 @@
|
||||
# `@heretek-ai/pack-rea`
|
||||
|
||||
REA reverse-engineering domain pack: standardized `readiness.ts` handshake
|
||||
plus agent and skill stubs.
|
||||
|
||||
- Exact pins: `@opencode/plugin` / `@opencode/cli` / `@opencode/sdk` `2.0.26`.
|
||||
- `ready(hostVersion)` requires host `2.0.x` **and** a responsive REA side
|
||||
(`rea` CLI on PATH); otherwise `degraded` with actionable remediation
|
||||
(`rea doctor --client opencode --json`, `rea setup --client opencode --dry-run`).
|
||||
- The readiness pattern here is the reference implementation promoted into
|
||||
`@heretek-ai/pack-sdk`'s pack contract.
|
||||
@@ -0,0 +1,10 @@
|
||||
# rea-analyst agent (pack-rea)
|
||||
|
||||
Reverse-engineering analyst: triages an unknown binary or codebase surface
|
||||
and produces a falsifiable investigation plan.
|
||||
|
||||
1. Start from `rea.check-readiness`; on `degraded`, hand the operator the
|
||||
remediation commands instead of proceeding blind.
|
||||
2. State every claim as verified or inferred, with the evidence that backs it.
|
||||
3. Prefer static observation over execution; never run untrusted binaries
|
||||
outside the pack lane.
|
||||
@@ -0,0 +1,8 @@
|
||||
# rea-investigator agent (pack-rea)
|
||||
|
||||
Deep-dive investigator: executes the analyst's plan and reconstructs
|
||||
behavior, data flows, and trust boundaries.
|
||||
|
||||
1. Work strictly inside the lane; no ambient network.
|
||||
2. Record provenance (tool, params hash, timestamp) for every finding.
|
||||
3. Stop at the first contradiction and re-plan — do not pave over anomalies.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@heretek-ai/pack-rea",
|
||||
"version": "1.0.0",
|
||||
"description": "REA reverse-engineering domain pack: readiness handshake + agents + skills",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"files": ["src", "agents", "skills", "README.md"],
|
||||
"scripts": {
|
||||
"check": "bunx tsc --noEmit -p ../../tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "2.0.26"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode/cli": "2.0.26",
|
||||
"@opencode/plugin": "2.0.26",
|
||||
"@opencode/sdk": "2.0.26",
|
||||
"rea-agents": "6.4.0",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# rea-evidence skill (pack-rea)
|
||||
|
||||
Every REA finding ships with evidence: the command, its exit code, and a
|
||||
content hash of its output. A finding without evidence is a hypothesis —
|
||||
label it as one.
|
||||
@@ -0,0 +1,7 @@
|
||||
# rea-readiness skill (pack-rea)
|
||||
|
||||
Before any REA work, run `rea.check-readiness`:
|
||||
|
||||
- `ready` → proceed.
|
||||
- `degraded` → surface `reason` plus each `remediation` command verbatim and
|
||||
stop. Do not improvise alternative tooling.
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
definePack,
|
||||
hostSatisfiesCompat,
|
||||
type ReadyResult,
|
||||
} from "../../../packages/pack-sdk/src/index";
|
||||
import { checkReaReadiness, type ReaReadinessOptions } from "./readiness";
|
||||
|
||||
export const PACK_ID = "rea";
|
||||
export const PACK_VERSION = "1.0.0";
|
||||
export const HOST_COMPAT = "2.0.x";
|
||||
|
||||
export interface ReaReadyOptions {
|
||||
rea?: ReaReadinessOptions;
|
||||
}
|
||||
|
||||
export async function ready(hostVersion: string, opts: ReaReadyOptions = {}): Promise<ReadyResult> {
|
||||
if (!hostSatisfiesCompat(HOST_COMPAT, hostVersion)) {
|
||||
return {
|
||||
status: "degraded",
|
||||
reason: `pack-rea ${PACK_VERSION} requires host ${HOST_COMPAT}, got ${hostVersion}`,
|
||||
};
|
||||
}
|
||||
const rea = await checkReaReadiness(opts.rea);
|
||||
if (rea.status !== "ready") {
|
||||
return {
|
||||
status: "degraded",
|
||||
reason: `${rea.reason}; remediation: ${rea.remediation.join(" | ")}`,
|
||||
};
|
||||
}
|
||||
return { status: "ready" };
|
||||
}
|
||||
|
||||
export const manifest = definePack({
|
||||
id: PACK_ID,
|
||||
version: PACK_VERSION,
|
||||
hostCompat: HOST_COMPAT,
|
||||
tools: [{ server: "./mcp/rea.js", transport: "stdio" }],
|
||||
agents: ["./agents/rea-analyst.md", "./agents/rea-investigator.md"],
|
||||
skills: ["./skills/rea-readiness.md", "./skills/rea-evidence.md"],
|
||||
commands: [{ name: "rea.check-readiness", handler: "readiness.checkReaReadiness" }],
|
||||
ready,
|
||||
});
|
||||
|
||||
export { checkReaReadiness };
|
||||
export default manifest;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { accessSync, constants as fsConstants } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Standardized readiness pattern (promoted from the opencode-rea donor):
|
||||
* check whether the REA MCP side is responsive and return ready|degraded
|
||||
* with actionable remediation. Advice strings only — never executed here.
|
||||
*/
|
||||
|
||||
export const DOCTOR_CMD = "rea doctor --client opencode --json";
|
||||
export const SETUP_CMD = "rea setup --client opencode --dry-run";
|
||||
|
||||
export interface ReaReadiness {
|
||||
status: "ready" | "degraded";
|
||||
reason?: string;
|
||||
remediation: string[];
|
||||
}
|
||||
|
||||
export interface ReaReadinessOptions {
|
||||
/** Override the responsiveness probe (tests inject true/false here). */
|
||||
probe?: () => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
function defaultReaProbe(): boolean {
|
||||
const bunWhich = (globalThis as { Bun?: { which?: (cmd: string) => string | null } }).Bun?.which;
|
||||
if (typeof bunWhich === "function") {
|
||||
try {
|
||||
if (bunWhich("rea") !== null) return true;
|
||||
} catch {
|
||||
/* fall through to PATH scan */
|
||||
}
|
||||
}
|
||||
const delimiter = process.platform === "win32" ? ";" : ":";
|
||||
const suffixes = process.platform === "win32" ? ["", ".exe", ".cmd"] : [""];
|
||||
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
||||
if (!dir) continue;
|
||||
for (const suffix of suffixes) {
|
||||
try {
|
||||
accessSync(join(dir, `rea${suffix}`), fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
/* keep scanning */
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function checkReaReadiness(
|
||||
opts: ReaReadinessOptions = {},
|
||||
): Promise<ReaReadiness> {
|
||||
const probe = opts.probe ?? defaultReaProbe;
|
||||
if (await probe()) {
|
||||
return { status: "ready", remediation: [] };
|
||||
}
|
||||
return {
|
||||
status: "degraded",
|
||||
reason:
|
||||
"REA MCP server is not responsive: the `rea` CLI was not found on PATH, so no REA tools are reachable",
|
||||
remediation: [DOCTOR_CMD, SETUP_CMD],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": [
|
||||
"packages/*/src/**/*",
|
||||
"packages/*/test/**/*",
|
||||
"packs/*/src/**/*",
|
||||
"packs/*/scripts/**/*"
|
||||
],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
+2
-2
@@ -11,8 +11,8 @@
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["packages/*/src/**/*", "packages/*/test/**/*", "packages/opencode-packs/packs/*/src/**/*"],
|
||||
"include": ["packages/*/src/**/*", "packages/*/test/**/*", "packages/opencode-packs/packs/*/src/**/*", "packages/opencode-packs/packs/*/scripts/**/*", "packages/opencode-packs/packages/*/src/**/*", "packages/opencode-packs/packages/*/test/**/*"],
|
||||
"exclude": ["node_modules", "dist", "worktrees"]
|
||||
}
|
||||
Reference in new issue
Block a user