@ikenga/contract 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,4 +9,6 @@
9
9
  export * from './adapter.js';
10
10
  export * from './acp.js';
11
11
  export * from './errors.js';
12
+ export * from './portability.js';
13
+ export * from './subagent-transcoder.js';
12
14
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/engine/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/engine/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,kBAAkB,CAAC;AACjC,cAAc,0BAA0B,CAAC"}
@@ -9,4 +9,6 @@
9
9
  export * from './adapter.js';
10
10
  export * from './acp.js';
11
11
  export * from './errors.js';
12
+ export * from './portability.js';
13
+ export * from './subagent-transcoder.js';
12
14
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/engine/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/engine/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,kBAAkB,CAAC;AACjC,cAAc,0BAA0B,CAAC"}
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Portability surface for engine pkgs — ADR-012.
3
+ *
4
+ * Engine pkgs implement this in parallel to the runtime adapter
5
+ * (`AcpEngine` in `./acp.ts`). The kernel's `engine_assets` registry calls
6
+ * these methods at pkg install / uninstall time, fanning the operation out
7
+ * to every installed `EngineAdapter` so a user's pkg investment (skills,
8
+ * commands, agents, MCP servers) survives an engine swap.
9
+ *
10
+ * No manifest schema changes — pkgs continue to declare `skills`,
11
+ * `commands`, `agents` as folder paths and `mcp[]` as inline entries.
12
+ * This file is a runtime interface; it has no Zod schema and no
13
+ * compile-time dependency on the manifest schema beyond reusing
14
+ * `McpServer` for the spec passed to `registerMcpServer`.
15
+ */
16
+ import type { McpServer } from '../manifest.js';
17
+ /**
18
+ * Result of an install / register operation. Per ADR §2 the kernel's pkg
19
+ * manager UI surfaces these so the user can see exactly what each engine
20
+ * adapter wrote.
21
+ */
22
+ export interface InstallReport {
23
+ /** Absolute paths the adapter wrote or symlinked. */
24
+ wrote: string[];
25
+ /** Targets that were already correct — idempotent no-op. */
26
+ skipped: string[];
27
+ /** Non-fatal notes (e.g. "commands not supported on Codex; skipped"). */
28
+ warnings: string[];
29
+ }
30
+ /**
31
+ * Dry-run output for `EngineAdapter.plan()`. Shape mirrors `InstallReport`
32
+ * plus the `(engineId, pkgId)` tuple so the pkg manager UI can render a
33
+ * per-engine breakdown ("this pkg will install assets into 3 engines").
34
+ */
35
+ export interface InstallPlan extends InstallReport {
36
+ /** Engine the plan was computed against — matches `EngineAdapter.id`. */
37
+ engineId: string;
38
+ /** Pkg the plan was computed for — matches the manifest `id` field. */
39
+ pkgId: string;
40
+ }
41
+ /**
42
+ * Minimal pkg manifest slice the adapter needs to compute a plan. Carved
43
+ * narrow on purpose: `plan()` runs before install, so it shouldn't load
44
+ * disk content the adapter wouldn't otherwise touch. Folder paths are
45
+ * relative to the pkg root (same convention as the on-disk manifest).
46
+ */
47
+ export interface ManifestSnapshot {
48
+ /** Pkg id (e.g. `com.ikenga.studio`). */
49
+ id: string;
50
+ /** Pkg slug used to namespace materialized dirs (id with `.` → `-`). */
51
+ slug: string;
52
+ /** Relative folder for `skills/<name>/SKILL.md` files, if any. */
53
+ skills?: string;
54
+ /** Relative folder for `commands/<name>.md` files, if any. */
55
+ commands?: string;
56
+ /** Relative folder for `agents/<name>.md` files, if any. */
57
+ agents?: string;
58
+ /** Inline MCP server specs from the manifest's `mcp[]` block. */
59
+ mcp: McpServer[];
60
+ }
61
+ /**
62
+ * Portability adapter exported by every engine pkg alongside its runtime
63
+ * `AcpEngine`. The kernel resolves both at load time (ADR §2).
64
+ *
65
+ * Folder-level methods. Each `install*` call is the adapter's chance to
66
+ * symlink, copy, or walk-and-transcode the whole folder — the choice
67
+ * depends on what the engine accepts natively. Idempotent by contract:
68
+ * re-running with unchanged inputs is a no-op.
69
+ *
70
+ * `registerMcpServer` writes a single entry into the engine's external
71
+ * settings file (`~/.claude/settings.json`, `~/.gemini/settings.json`,
72
+ * `~/.codex/config.toml`). Entries are keyed `ikenga.<pkg-slug>.<name>`
73
+ * per ADR §7 so they never collide with user-authored entries.
74
+ *
75
+ * Inverses tear down only what this pkg owned — user content in the same
76
+ * dir / settings file is left untouched.
77
+ */
78
+ export interface EngineAdapter {
79
+ /** Engine identifier — matches `engine.agentId` in the pkg manifest. */
80
+ readonly id: string;
81
+ /**
82
+ * Materialize the pkg's skills folder into the engine's recognized
83
+ * location. Idempotent.
84
+ */
85
+ installSkills(folder: string, pkgId: string, pkgSlug: string): Promise<InstallReport>;
86
+ /**
87
+ * Materialize the pkg's commands folder. Engines without a first-class
88
+ * commands primitive (Codex) may emit warnings and skip per-file
89
+ * instead of erroring — see ADR §5.
90
+ */
91
+ installCommands(folder: string, pkgId: string, pkgSlug: string): Promise<InstallReport>;
92
+ /** Materialize the pkg's agents folder. Codex transcodes MD→TOML. */
93
+ installAgents(folder: string, pkgId: string, pkgSlug: string): Promise<InstallReport>;
94
+ /**
95
+ * Register one MCP server in the engine's external settings file.
96
+ * Idempotent on the `(pkgSlug, server name)` key.
97
+ */
98
+ registerMcpServer(spec: McpServer, pkgId: string, pkgSlug: string): Promise<InstallReport>;
99
+ /** Inverse of `installSkills`. Removes only entries this pkg owned. */
100
+ uninstallSkills(pkgId: string, pkgSlug: string): Promise<void>;
101
+ /** Inverse of `installCommands`. */
102
+ uninstallCommands(pkgId: string, pkgSlug: string): Promise<void>;
103
+ /** Inverse of `installAgents`. */
104
+ uninstallAgents(pkgId: string, pkgSlug: string): Promise<void>;
105
+ /** Inverse of `registerMcpServer`. */
106
+ unregisterMcpServer(serverName: string, pkgId: string, pkgSlug: string): Promise<void>;
107
+ /**
108
+ * Dry-run used by the pkg manager UI. Returns the same shape as a real
109
+ * install would produce, without touching the filesystem.
110
+ */
111
+ plan(pkgId: string, pkgSlug: string, manifestSnapshot: ManifestSnapshot): Promise<InstallPlan>;
112
+ }
113
+ //# sourceMappingURL=portability.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"portability.d.ts","sourceRoot":"","sources":["../../src/engine/portability.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,qDAAqD;IACrD,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,4DAA4D;IAC5D,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,yEAAyE;IACzE,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;;GAIG;AACH,MAAM,WAAW,WAAY,SAAQ,aAAa;IAChD,yEAAyE;IACzE,QAAQ,EAAE,MAAM,CAAC;IACjB,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,yCAAyC;IACzC,EAAE,EAAE,MAAM,CAAC;IACX,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,kEAAkE;IAClE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4DAA4D;IAC5D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iEAAiE;IACjE,GAAG,EAAE,SAAS,EAAE,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,aAAa;IAC5B,wEAAwE;IACxE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IAEtF;;;;OAIG;IACH,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IAExF,qEAAqE;IACrE,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IAEtF;;;OAGG;IACH,iBAAiB,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IAE3F,uEAAuE;IACvE,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/D,oCAAoC;IACpC,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,kCAAkC;IAClC,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/D,sCAAsC;IACtC,mBAAmB,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEvF;;;OAGG;IACH,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,gBAAgB,EAAE,gBAAgB,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;CAChG"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Portability surface for engine pkgs — ADR-012.
3
+ *
4
+ * Engine pkgs implement this in parallel to the runtime adapter
5
+ * (`AcpEngine` in `./acp.ts`). The kernel's `engine_assets` registry calls
6
+ * these methods at pkg install / uninstall time, fanning the operation out
7
+ * to every installed `EngineAdapter` so a user's pkg investment (skills,
8
+ * commands, agents, MCP servers) survives an engine swap.
9
+ *
10
+ * No manifest schema changes — pkgs continue to declare `skills`,
11
+ * `commands`, `agents` as folder paths and `mcp[]` as inline entries.
12
+ * This file is a runtime interface; it has no Zod schema and no
13
+ * compile-time dependency on the manifest schema beyond reusing
14
+ * `McpServer` for the spec passed to `registerMcpServer`.
15
+ */
16
+ export {};
17
+ //# sourceMappingURL=portability.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"portability.js","sourceRoot":"","sources":["../../src/engine/portability.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Subagent format transcoder — ADR-012 §5.
3
+ *
4
+ * Translates between the canonical Markdown + YAML-frontmatter shape used
5
+ * by Claude Code and Gemini CLI and the TOML shape used by Codex CLI.
6
+ * Zero external dependencies: hand-rolled mini-parsers for YAML
7
+ * frontmatter and the TOML keys ADR §5 lists.
8
+ *
9
+ * Supported keys (top-level):
10
+ * - Canonical (round-trips): name, description, tools, model, system_prompt
11
+ * - Codex extras (preserved when present): developer_instructions,
12
+ * sandbox_mode, mcp_servers, skills
13
+ * - Claude/Gemini extras (preserved in YAML, ignored by Codex):
14
+ * temperature, max_turns, timeout_mins
15
+ *
16
+ * Nested table support is limited to `[mcp_servers]` — that's the only
17
+ * realistic surface for subagents. Arrays of tables, inline tables, and
18
+ * comments are not parsed; the transcoder operates on files we generate
19
+ * or files following ADR §5's spec.
20
+ */
21
+ export declare function mdToCodexToml(md: string): string;
22
+ export declare function codexTomlToMd(toml: string): string;
23
+ export declare function mdToGeminiCommandToml(md: string): string;
24
+ //# sourceMappingURL=subagent-transcoder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subagent-transcoder.d.ts","sourceRoot":"","sources":["../../src/engine/subagent-transcoder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAOhD;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMlD;AAED,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAOxD"}
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Subagent format transcoder — ADR-012 §5.
3
+ *
4
+ * Translates between the canonical Markdown + YAML-frontmatter shape used
5
+ * by Claude Code and Gemini CLI and the TOML shape used by Codex CLI.
6
+ * Zero external dependencies: hand-rolled mini-parsers for YAML
7
+ * frontmatter and the TOML keys ADR §5 lists.
8
+ *
9
+ * Supported keys (top-level):
10
+ * - Canonical (round-trips): name, description, tools, model, system_prompt
11
+ * - Codex extras (preserved when present): developer_instructions,
12
+ * sandbox_mode, mcp_servers, skills
13
+ * - Claude/Gemini extras (preserved in YAML, ignored by Codex):
14
+ * temperature, max_turns, timeout_mins
15
+ *
16
+ * Nested table support is limited to `[mcp_servers]` — that's the only
17
+ * realistic surface for subagents. Arrays of tables, inline tables, and
18
+ * comments are not parsed; the transcoder operates on files we generate
19
+ * or files following ADR §5's spec.
20
+ */
21
+ // ---------- Public API ----------
22
+ export function mdToCodexToml(md) {
23
+ const { frontmatter, body } = parseFrontmatter(md);
24
+ const fm = { ...frontmatter };
25
+ // Body becomes system_prompt unless the frontmatter already has one
26
+ // (in which case body wins — same convention as Gemini/Claude tooling).
27
+ if (body.length > 0)
28
+ fm.system_prompt = body;
29
+ return emitToml(fm);
30
+ }
31
+ export function codexTomlToMd(toml) {
32
+ const data = parseToml(toml);
33
+ const body = typeof data.system_prompt === 'string' ? data.system_prompt : '';
34
+ const fmOnly = { ...data };
35
+ delete fmOnly.system_prompt;
36
+ return emitFrontmatter(fmOnly) + body;
37
+ }
38
+ export function mdToGeminiCommandToml(md) {
39
+ // Gemini commands are slash commands, not subagents: the body is `prompt`
40
+ // (not `system_prompt`) and frontmatter keys live at the top level.
41
+ const { frontmatter, body } = parseFrontmatter(md);
42
+ const fm = { ...frontmatter };
43
+ if (body.length > 0)
44
+ fm.prompt = body;
45
+ return emitToml(fm);
46
+ }
47
+ const FRONT_DELIM = /^---\s*$/;
48
+ function parseFrontmatter(md) {
49
+ const lines = md.split('\n');
50
+ if (lines.length === 0 || !FRONT_DELIM.test(lines[0] ?? '')) {
51
+ return { frontmatter: {}, body: md };
52
+ }
53
+ let closeIdx = -1;
54
+ for (let i = 1; i < lines.length; i++) {
55
+ if (FRONT_DELIM.test(lines[i] ?? '')) {
56
+ closeIdx = i;
57
+ break;
58
+ }
59
+ }
60
+ if (closeIdx === -1) {
61
+ throw new Error('subagent transcoder: unterminated YAML frontmatter (missing closing `---`)');
62
+ }
63
+ const yamlLines = lines.slice(1, closeIdx);
64
+ const bodyLines = lines.slice(closeIdx + 1);
65
+ // Strip exactly one leading blank line between `---` and body, if present.
66
+ if (bodyLines.length > 0 && bodyLines[0] === '')
67
+ bodyLines.shift();
68
+ return {
69
+ frontmatter: parseYamlBlock(yamlLines),
70
+ body: bodyLines.join('\n'),
71
+ };
72
+ }
73
+ function parseYamlBlock(lines) {
74
+ const out = {};
75
+ let i = 0;
76
+ while (i < lines.length) {
77
+ const raw = lines[i] ?? '';
78
+ if (raw.trim() === '') {
79
+ i++;
80
+ continue;
81
+ }
82
+ if (/^\s/.test(raw)) {
83
+ throw new Error(`subagent transcoder: unexpected indented line in YAML frontmatter: ${JSON.stringify(raw)}`);
84
+ }
85
+ const colon = raw.indexOf(':');
86
+ if (colon < 0) {
87
+ throw new Error(`subagent transcoder: malformed YAML line (no \`:\`): ${JSON.stringify(raw)}`);
88
+ }
89
+ const key = raw.slice(0, colon).trim();
90
+ const rest = raw.slice(colon + 1).trim();
91
+ if (rest === '') {
92
+ // Block-style list: subsequent ` - item` lines.
93
+ const list = [];
94
+ i++;
95
+ while (i < lines.length && /^\s+-\s/.test(lines[i] ?? '')) {
96
+ const item = (lines[i] ?? '').replace(/^\s+-\s+/, '');
97
+ list.push(parseScalar(item));
98
+ i++;
99
+ }
100
+ out[key] = list;
101
+ continue;
102
+ }
103
+ out[key] = parseScalar(rest);
104
+ i++;
105
+ }
106
+ return out;
107
+ }
108
+ function parseScalar(s) {
109
+ const t = s.trim();
110
+ if (t.startsWith('[') && t.endsWith(']')) {
111
+ // Inline list: `[a, b, "c, d"]` — supports quoted items with commas.
112
+ const inner = t.slice(1, -1);
113
+ return splitInlineList(inner).map((p) => parseScalar(p));
114
+ }
115
+ if (t.startsWith('"') && t.endsWith('"') && t.length >= 2) {
116
+ // Double-quoted: honor JSON escape sequences (\n, \t, \\, \").
117
+ try {
118
+ return JSON.parse(t);
119
+ }
120
+ catch {
121
+ return t.slice(1, -1);
122
+ }
123
+ }
124
+ if (t.startsWith("'") && t.endsWith("'") && t.length >= 2) {
125
+ // Single-quoted YAML strings are literal.
126
+ return t.slice(1, -1);
127
+ }
128
+ if (t === 'true')
129
+ return true;
130
+ if (t === 'false')
131
+ return false;
132
+ if (t === 'null' || t === '~' || t === '')
133
+ return null;
134
+ if (/^-?\d+(\.\d+)?$/.test(t))
135
+ return Number(t);
136
+ return t;
137
+ }
138
+ function splitInlineList(inner) {
139
+ const out = [];
140
+ let buf = '';
141
+ let inQuote = null;
142
+ for (const ch of inner) {
143
+ if (inQuote) {
144
+ buf += ch;
145
+ if (ch === inQuote)
146
+ inQuote = null;
147
+ continue;
148
+ }
149
+ if (ch === '"' || ch === "'") {
150
+ inQuote = ch;
151
+ buf += ch;
152
+ continue;
153
+ }
154
+ if (ch === ',') {
155
+ out.push(buf.trim());
156
+ buf = '';
157
+ continue;
158
+ }
159
+ buf += ch;
160
+ }
161
+ if (buf.trim() !== '')
162
+ out.push(buf.trim());
163
+ return out;
164
+ }
165
+ // ---------- YAML frontmatter (write) ----------
166
+ function emitFrontmatter(fm) {
167
+ const keys = Object.keys(fm);
168
+ if (keys.length === 0)
169
+ return '---\n---\n';
170
+ const parts = ['---'];
171
+ for (const k of keys) {
172
+ const v = fm[k];
173
+ if (Array.isArray(v)) {
174
+ // Always emit lists in inline form for stability — round-trip with
175
+ // block-style lists still works because the reader accepts both.
176
+ parts.push(`${k}: [${v.map((it) => emitYamlScalar(it)).join(', ')}]`);
177
+ }
178
+ else if (v !== undefined && v !== null) {
179
+ parts.push(`${k}: ${emitYamlScalar(v)}`);
180
+ }
181
+ else if (v === null) {
182
+ parts.push(`${k}: null`);
183
+ }
184
+ }
185
+ parts.push('---');
186
+ parts.push('');
187
+ return parts.join('\n');
188
+ }
189
+ function emitYamlScalar(v) {
190
+ if (typeof v === 'number' || typeof v === 'boolean')
191
+ return String(v);
192
+ if (v === null || v === undefined)
193
+ return 'null';
194
+ const s = String(v);
195
+ // Quote when content has YAML-significant chars or leading/trailing space.
196
+ if (/[:#\[\]{},&*?|<>=!%@`]/.test(s) || /^\s|\s$/.test(s) || s === '') {
197
+ return JSON.stringify(s);
198
+ }
199
+ return s;
200
+ }
201
+ // ---------- TOML (write) ----------
202
+ function emitToml(data) {
203
+ const lines = [];
204
+ const nested = [];
205
+ for (const k of Object.keys(data)) {
206
+ const v = data[k];
207
+ if (isPlainObject(v)) {
208
+ nested.push([k, v]);
209
+ continue;
210
+ }
211
+ lines.push(`${k} = ${emitTomlValue(v, k)}`);
212
+ }
213
+ for (const [name, table] of nested) {
214
+ lines.push('');
215
+ lines.push(`[${name}]`);
216
+ for (const tk of Object.keys(table)) {
217
+ lines.push(`${tk} = ${emitTomlValue(table[tk], tk)}`);
218
+ }
219
+ }
220
+ return lines.join('\n') + '\n';
221
+ }
222
+ function emitTomlValue(v, key) {
223
+ if (typeof v === 'string') {
224
+ // Triple-quoted for the prompt-bearing keys + anything multi-line.
225
+ if (key === 'system_prompt' || key === 'prompt' || key === 'developer_instructions' || v.includes('\n')) {
226
+ // Triple-quoted multi-line string per TOML spec: the immediately
227
+ // following newline after the opening `"""` is stripped, so we
228
+ // preserve the body byte-for-byte by placing closing `"""` directly
229
+ // after the body (no separator newline).
230
+ const escaped = v.replace(/"""/g, '"\\""\\""');
231
+ return `"""\n${escaped}"""`;
232
+ }
233
+ return JSON.stringify(v);
234
+ }
235
+ if (typeof v === 'number' || typeof v === 'boolean')
236
+ return String(v);
237
+ if (v === null || v === undefined)
238
+ return '""';
239
+ if (Array.isArray(v)) {
240
+ return `[${v.map((it) => emitTomlValue(it, key)).join(', ')}]`;
241
+ }
242
+ // Fallback — should not be hit because objects are routed to nested tables.
243
+ return JSON.stringify(v);
244
+ }
245
+ function isPlainObject(v) {
246
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
247
+ }
248
+ // ---------- TOML (read) ----------
249
+ function parseToml(toml) {
250
+ const out = {};
251
+ const lines = toml.split('\n');
252
+ let i = 0;
253
+ let currentTable = out;
254
+ while (i < lines.length) {
255
+ const raw = lines[i] ?? '';
256
+ const trimmed = raw.trim();
257
+ if (trimmed === '' || trimmed.startsWith('#')) {
258
+ i++;
259
+ continue;
260
+ }
261
+ const tableMatch = /^\[([^\]]+)\]$/.exec(trimmed);
262
+ if (tableMatch) {
263
+ const name = (tableMatch[1] ?? '').trim();
264
+ const tbl = {};
265
+ out[name] = tbl;
266
+ currentTable = tbl;
267
+ i++;
268
+ continue;
269
+ }
270
+ const eq = raw.indexOf('=');
271
+ if (eq < 0) {
272
+ throw new Error(`subagent transcoder: malformed TOML line: ${JSON.stringify(raw)}`);
273
+ }
274
+ const key = raw.slice(0, eq).trim();
275
+ const restRaw = raw.slice(eq + 1);
276
+ const rest = restRaw.trim();
277
+ if (rest.startsWith('"""')) {
278
+ // Multi-line triple-quoted string. ADR §5 emits these with the
279
+ // opening `"""` on its own line; we accept both shapes.
280
+ const { value, consumed } = parseTripleQuoted(lines, i, eq);
281
+ currentTable[key] = value;
282
+ i += consumed;
283
+ continue;
284
+ }
285
+ currentTable[key] = parseTomlScalar(rest);
286
+ i++;
287
+ }
288
+ return out;
289
+ }
290
+ function parseTripleQuoted(lines, startIdx, eqPos) {
291
+ const first = lines[startIdx] ?? '';
292
+ // Strip the `key = """` prefix from the first line.
293
+ const afterOpen = first.slice(eqPos + 1).trim().slice(3);
294
+ let consumed = 1;
295
+ // Closing on the same line: `key = """text"""`
296
+ if (afterOpen.endsWith('"""') && afterOpen.length >= 3) {
297
+ return { value: unescapeTriple(afterOpen.slice(0, -3)), consumed };
298
+ }
299
+ // Per TOML spec, the newline immediately following the opening `"""`
300
+ // is stripped. We then collect content until the closing `"""` and
301
+ // include the body byte-for-byte (no synthetic trailing `\n`).
302
+ const buf = [];
303
+ if (afterOpen !== '')
304
+ buf.push(afterOpen);
305
+ for (let j = startIdx + 1; j < lines.length; j++) {
306
+ const ln = lines[j] ?? '';
307
+ consumed++;
308
+ if (ln.endsWith('"""')) {
309
+ const trailing = ln.slice(0, -3);
310
+ // Closing on its own line — the `\n` before `"""` is part of the
311
+ // body; otherwise the trailing chunk is the body's last line.
312
+ if (trailing === '' && buf.length > 0) {
313
+ return { value: unescapeTriple(buf.join('\n') + '\n'), consumed };
314
+ }
315
+ buf.push(trailing);
316
+ return { value: unescapeTriple(buf.join('\n')), consumed };
317
+ }
318
+ buf.push(ln);
319
+ }
320
+ throw new Error('subagent transcoder: unterminated TOML triple-quoted string');
321
+ }
322
+ function unescapeTriple(s) {
323
+ // Inverse of the `"""` → `"\""\""` escape applied during emit.
324
+ return s.replace(/"\\""\\""/g, '"""');
325
+ }
326
+ function parseTomlScalar(s) {
327
+ if (s.startsWith('"') && s.endsWith('"')) {
328
+ return JSON.parse(s);
329
+ }
330
+ if (s.startsWith('[') && s.endsWith(']')) {
331
+ return splitInlineList(s.slice(1, -1)).map((p) => parseTomlScalar(p));
332
+ }
333
+ if (s === 'true')
334
+ return true;
335
+ if (s === 'false')
336
+ return false;
337
+ if (/^-?\d+(\.\d+)?$/.test(s))
338
+ return Number(s);
339
+ return s;
340
+ }
341
+ //# sourceMappingURL=subagent-transcoder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subagent-transcoder.js","sourceRoot":"","sources":["../../src/engine/subagent-transcoder.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikenga/contract",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Shared contract for the Ikenga pkg system: manifest schema, RPC types, Engine interface, capability scopes.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -10,3 +10,5 @@
10
10
  export * from './adapter.js';
11
11
  export * from './acp.js';
12
12
  export * from './errors.js';
13
+ export * from './portability.js';
14
+ export * from './subagent-transcoder.js';
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Portability surface for engine pkgs — ADR-012.
3
+ *
4
+ * Engine pkgs implement this in parallel to the runtime adapter
5
+ * (`AcpEngine` in `./acp.ts`). The kernel's `engine_assets` registry calls
6
+ * these methods at pkg install / uninstall time, fanning the operation out
7
+ * to every installed `EngineAdapter` so a user's pkg investment (skills,
8
+ * commands, agents, MCP servers) survives an engine swap.
9
+ *
10
+ * No manifest schema changes — pkgs continue to declare `skills`,
11
+ * `commands`, `agents` as folder paths and `mcp[]` as inline entries.
12
+ * This file is a runtime interface; it has no Zod schema and no
13
+ * compile-time dependency on the manifest schema beyond reusing
14
+ * `McpServer` for the spec passed to `registerMcpServer`.
15
+ */
16
+
17
+ import type { McpServer } from '../manifest.js';
18
+
19
+ /**
20
+ * Result of an install / register operation. Per ADR §2 the kernel's pkg
21
+ * manager UI surfaces these so the user can see exactly what each engine
22
+ * adapter wrote.
23
+ */
24
+ export interface InstallReport {
25
+ /** Absolute paths the adapter wrote or symlinked. */
26
+ wrote: string[];
27
+ /** Targets that were already correct — idempotent no-op. */
28
+ skipped: string[];
29
+ /** Non-fatal notes (e.g. "commands not supported on Codex; skipped"). */
30
+ warnings: string[];
31
+ }
32
+
33
+ /**
34
+ * Dry-run output for `EngineAdapter.plan()`. Shape mirrors `InstallReport`
35
+ * plus the `(engineId, pkgId)` tuple so the pkg manager UI can render a
36
+ * per-engine breakdown ("this pkg will install assets into 3 engines").
37
+ */
38
+ export interface InstallPlan extends InstallReport {
39
+ /** Engine the plan was computed against — matches `EngineAdapter.id`. */
40
+ engineId: string;
41
+ /** Pkg the plan was computed for — matches the manifest `id` field. */
42
+ pkgId: string;
43
+ }
44
+
45
+ /**
46
+ * Minimal pkg manifest slice the adapter needs to compute a plan. Carved
47
+ * narrow on purpose: `plan()` runs before install, so it shouldn't load
48
+ * disk content the adapter wouldn't otherwise touch. Folder paths are
49
+ * relative to the pkg root (same convention as the on-disk manifest).
50
+ */
51
+ export interface ManifestSnapshot {
52
+ /** Pkg id (e.g. `com.ikenga.studio`). */
53
+ id: string;
54
+ /** Pkg slug used to namespace materialized dirs (id with `.` → `-`). */
55
+ slug: string;
56
+ /** Relative folder for `skills/<name>/SKILL.md` files, if any. */
57
+ skills?: string;
58
+ /** Relative folder for `commands/<name>.md` files, if any. */
59
+ commands?: string;
60
+ /** Relative folder for `agents/<name>.md` files, if any. */
61
+ agents?: string;
62
+ /** Inline MCP server specs from the manifest's `mcp[]` block. */
63
+ mcp: McpServer[];
64
+ }
65
+
66
+ /**
67
+ * Portability adapter exported by every engine pkg alongside its runtime
68
+ * `AcpEngine`. The kernel resolves both at load time (ADR §2).
69
+ *
70
+ * Folder-level methods. Each `install*` call is the adapter's chance to
71
+ * symlink, copy, or walk-and-transcode the whole folder — the choice
72
+ * depends on what the engine accepts natively. Idempotent by contract:
73
+ * re-running with unchanged inputs is a no-op.
74
+ *
75
+ * `registerMcpServer` writes a single entry into the engine's external
76
+ * settings file (`~/.claude/settings.json`, `~/.gemini/settings.json`,
77
+ * `~/.codex/config.toml`). Entries are keyed `ikenga.<pkg-slug>.<name>`
78
+ * per ADR §7 so they never collide with user-authored entries.
79
+ *
80
+ * Inverses tear down only what this pkg owned — user content in the same
81
+ * dir / settings file is left untouched.
82
+ */
83
+ export interface EngineAdapter {
84
+ /** Engine identifier — matches `engine.agentId` in the pkg manifest. */
85
+ readonly id: string;
86
+
87
+ /**
88
+ * Materialize the pkg's skills folder into the engine's recognized
89
+ * location. Idempotent.
90
+ */
91
+ installSkills(folder: string, pkgId: string, pkgSlug: string): Promise<InstallReport>;
92
+
93
+ /**
94
+ * Materialize the pkg's commands folder. Engines without a first-class
95
+ * commands primitive (Codex) may emit warnings and skip per-file
96
+ * instead of erroring — see ADR §5.
97
+ */
98
+ installCommands(folder: string, pkgId: string, pkgSlug: string): Promise<InstallReport>;
99
+
100
+ /** Materialize the pkg's agents folder. Codex transcodes MD→TOML. */
101
+ installAgents(folder: string, pkgId: string, pkgSlug: string): Promise<InstallReport>;
102
+
103
+ /**
104
+ * Register one MCP server in the engine's external settings file.
105
+ * Idempotent on the `(pkgSlug, server name)` key.
106
+ */
107
+ registerMcpServer(spec: McpServer, pkgId: string, pkgSlug: string): Promise<InstallReport>;
108
+
109
+ /** Inverse of `installSkills`. Removes only entries this pkg owned. */
110
+ uninstallSkills(pkgId: string, pkgSlug: string): Promise<void>;
111
+ /** Inverse of `installCommands`. */
112
+ uninstallCommands(pkgId: string, pkgSlug: string): Promise<void>;
113
+ /** Inverse of `installAgents`. */
114
+ uninstallAgents(pkgId: string, pkgSlug: string): Promise<void>;
115
+ /** Inverse of `registerMcpServer`. */
116
+ unregisterMcpServer(serverName: string, pkgId: string, pkgSlug: string): Promise<void>;
117
+
118
+ /**
119
+ * Dry-run used by the pkg manager UI. Returns the same shape as a real
120
+ * install would produce, without touching the filesystem.
121
+ */
122
+ plan(pkgId: string, pkgSlug: string, manifestSnapshot: ManifestSnapshot): Promise<InstallPlan>;
123
+ }
@@ -0,0 +1,306 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ codexTomlToMd,
5
+ mdToCodexToml,
6
+ mdToGeminiCommandToml,
7
+ } from './subagent-transcoder.js';
8
+
9
+ // Tiny TOML parser shared between tests — independent of the transcoder's
10
+ // internal parser so round-trip checks have something to compare against.
11
+ // Only handles the keys ADR §5 lists, which is what the transcoder emits.
12
+ function parseSimpleToml(toml: string): Record<string, unknown> {
13
+ const out: Record<string, unknown> = {};
14
+ const lines = toml.split('\n');
15
+ let i = 0;
16
+ let table: Record<string, unknown> = out;
17
+ while (i < lines.length) {
18
+ const raw = lines[i] ?? '';
19
+ const t = raw.trim();
20
+ if (t === '' || t.startsWith('#')) { i++; continue; }
21
+ const tableMatch = /^\[([^\]]+)\]$/.exec(t);
22
+ if (tableMatch) {
23
+ const name = (tableMatch[1] ?? '').trim();
24
+ const sub: Record<string, unknown> = {};
25
+ out[name] = sub;
26
+ table = sub;
27
+ i++;
28
+ continue;
29
+ }
30
+ const eq = raw.indexOf('=');
31
+ const key = raw.slice(0, eq).trim();
32
+ const restRaw = raw.slice(eq + 1);
33
+ const rest = restRaw.trim();
34
+ if (rest.startsWith('"""')) {
35
+ const afterOpen = rest.slice(3);
36
+ if (afterOpen.endsWith('"""') && afterOpen.length >= 3) {
37
+ table[key] = unescape(afterOpen.slice(0, -3));
38
+ i++;
39
+ continue;
40
+ }
41
+ const buf: string[] = [];
42
+ if (afterOpen !== '') buf.push(afterOpen);
43
+ i++;
44
+ while (i < lines.length) {
45
+ const ln = lines[i] ?? '';
46
+ if (ln.endsWith('"""')) {
47
+ const trailing = ln.slice(0, -3);
48
+ if (trailing === '' && buf.length > 0) {
49
+ table[key] = unescape(buf.join('\n') + '\n');
50
+ } else {
51
+ buf.push(trailing);
52
+ table[key] = unescape(buf.join('\n'));
53
+ }
54
+ i++;
55
+ break;
56
+ }
57
+ buf.push(ln);
58
+ i++;
59
+ }
60
+ continue;
61
+ }
62
+ if (rest.startsWith('"') && rest.endsWith('"')) {
63
+ table[key] = JSON.parse(rest);
64
+ } else if (rest.startsWith('[') && rest.endsWith(']')) {
65
+ const inner = rest.slice(1, -1).trim();
66
+ if (inner === '') {
67
+ table[key] = [];
68
+ } else {
69
+ table[key] = inner.split(',').map((p) => {
70
+ const v = p.trim();
71
+ if (v.startsWith('"') && v.endsWith('"')) return JSON.parse(v);
72
+ if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
73
+ return v;
74
+ });
75
+ }
76
+ } else if (rest === 'true') table[key] = true;
77
+ else if (rest === 'false') table[key] = false;
78
+ else if (/^-?\d+(\.\d+)?$/.test(rest)) table[key] = Number(rest);
79
+ else table[key] = rest;
80
+ i++;
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function unescape(s: string): string {
86
+ return s.replace(/"\\""\\""/g, '"""');
87
+ }
88
+
89
+ function deepEqualKeys(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
90
+ const ak = Object.keys(a).sort();
91
+ const bk = Object.keys(b).sort();
92
+ if (ak.join(',') !== bk.join(',')) return false;
93
+ for (const k of ak) {
94
+ const va = a[k];
95
+ const vb = b[k];
96
+ if (Array.isArray(va) && Array.isArray(vb)) {
97
+ if (va.length !== vb.length) return false;
98
+ for (let i = 0; i < va.length; i++) {
99
+ if (JSON.stringify(va[i]) !== JSON.stringify(vb[i])) return false;
100
+ }
101
+ continue;
102
+ }
103
+ if (typeof va === 'object' && va !== null && typeof vb === 'object' && vb !== null) {
104
+ if (!deepEqualKeys(va as Record<string, unknown>, vb as Record<string, unknown>)) return false;
105
+ continue;
106
+ }
107
+ if (va !== vb) return false;
108
+ }
109
+ return true;
110
+ }
111
+
112
+ // ---------------- TOML → MD → TOML round-trip ----------------
113
+
114
+ test('TOML → MD → TOML round-trip preserves canonical keys', () => {
115
+ const original = [
116
+ 'name = "my-agent"',
117
+ 'description = "Does the thing"',
118
+ 'tools = ["bash", "read"]',
119
+ 'model = "claude-sonnet-4"',
120
+ 'system_prompt = """',
121
+ 'You are a helpful agent.',
122
+ 'Be terse.',
123
+ '"""',
124
+ ].join('\n') + '\n';
125
+ const md = codexTomlToMd(original);
126
+ const reemitted = mdToCodexToml(md);
127
+ const a = parseSimpleToml(original);
128
+ const b = parseSimpleToml(reemitted);
129
+ assert.ok(deepEqualKeys(a, b), `mismatch:\n${JSON.stringify(a)}\nvs\n${JSON.stringify(b)}`);
130
+ });
131
+
132
+ // ---------------- MD → TOML → MD round-trip with every ADR §5 key ----------------
133
+
134
+ test('MD → TOML → MD round-trip preserves every ADR §5 key', () => {
135
+ const md = [
136
+ '---',
137
+ 'name: kitchen-sink',
138
+ 'description: Every key in ADR §5',
139
+ 'tools: [bash, read, write]',
140
+ 'model: claude-opus-4-7',
141
+ 'developer_instructions: Internal notes for Codex',
142
+ 'sandbox_mode: workspace-write',
143
+ 'temperature: 0.7',
144
+ 'max_turns: 20',
145
+ 'timeout_mins: 5',
146
+ '---',
147
+ '',
148
+ 'You are an agent that does everything.',
149
+ '',
150
+ 'Be concise.',
151
+ ].join('\n');
152
+ const toml = mdToCodexToml(md);
153
+ const back = codexTomlToMd(toml);
154
+ // Re-parse the returned MD's frontmatter into TOML again so we can
155
+ // diff structurally — frontmatter ordering is allowed to drift.
156
+ const reToml = mdToCodexToml(back);
157
+ const a = parseSimpleToml(toml);
158
+ const b = parseSimpleToml(reToml);
159
+ for (const k of [
160
+ 'name', 'description', 'tools', 'model',
161
+ 'developer_instructions', 'sandbox_mode',
162
+ 'temperature', 'max_turns', 'timeout_mins',
163
+ 'system_prompt',
164
+ ]) {
165
+ assert.ok(k in a, `lost key ${k} in first emit`);
166
+ assert.ok(k in b, `lost key ${k} in re-emit`);
167
+ assert.equal(JSON.stringify(a[k]), JSON.stringify(b[k]), `key ${k} drifted`);
168
+ }
169
+ });
170
+
171
+ // ---------------- Gemini command round-trip ----------------
172
+
173
+ test('mdToGeminiCommandToml round-trips body byte-equal modulo trailing newline', () => {
174
+ const body = 'Summarize the diff in 3 bullets.\nThen suggest tests.';
175
+ const md = [
176
+ '---',
177
+ 'name: summarize-diff',
178
+ 'description: Slash command for PR summaries',
179
+ '---',
180
+ '',
181
+ body,
182
+ ].join('\n');
183
+ const toml = mdToGeminiCommandToml(md);
184
+ const parsed = parseSimpleToml(toml);
185
+ assert.equal(parsed.name, 'summarize-diff');
186
+ assert.equal(parsed.description, 'Slash command for PR summaries');
187
+ assert.equal(parsed.prompt, body);
188
+ });
189
+
190
+ // ---------------- Empty frontmatter ----------------
191
+
192
+ test('mdToCodexToml handles empty frontmatter (no `---` block)', () => {
193
+ const md = 'Just a body, no frontmatter.';
194
+ const toml = mdToCodexToml(md);
195
+ const parsed = parseSimpleToml(toml);
196
+ assert.equal(parsed.system_prompt, 'Just a body, no frontmatter.');
197
+ });
198
+
199
+ // ---------------- Multi-line description ----------------
200
+
201
+ test('mdToCodexToml handles multi-line YAML body becoming system_prompt', () => {
202
+ const md = [
203
+ '---',
204
+ 'name: multi',
205
+ 'description: short',
206
+ '---',
207
+ '',
208
+ 'Line one.',
209
+ 'Line two.',
210
+ 'Line three.',
211
+ ].join('\n');
212
+ const toml = mdToCodexToml(md);
213
+ const parsed = parseSimpleToml(toml);
214
+ assert.equal(parsed.name, 'multi');
215
+ assert.equal(parsed.description, 'short');
216
+ assert.equal(parsed.system_prompt, 'Line one.\nLine two.\nLine three.');
217
+ });
218
+
219
+ // ---------------- Inline list values ----------------
220
+
221
+ test('inline list value (tools: [bash, read]) round-trips', () => {
222
+ const md = [
223
+ '---',
224
+ 'name: t',
225
+ 'description: d',
226
+ 'tools: [bash, read, write]',
227
+ '---',
228
+ '',
229
+ 'body',
230
+ ].join('\n');
231
+ const toml = mdToCodexToml(md);
232
+ const parsed = parseSimpleToml(toml);
233
+ assert.deepEqual(parsed.tools, ['bash', 'read', 'write']);
234
+ });
235
+
236
+ // ---------------- Block-style list values ----------------
237
+
238
+ test('block-style YAML list (`- a` lines) parses identically to inline', () => {
239
+ const md = [
240
+ '---',
241
+ 'name: t',
242
+ 'description: d',
243
+ 'tools:',
244
+ ' - bash',
245
+ ' - read',
246
+ '---',
247
+ '',
248
+ 'body',
249
+ ].join('\n');
250
+ const toml = mdToCodexToml(md);
251
+ const parsed = parseSimpleToml(toml);
252
+ assert.deepEqual(parsed.tools, ['bash', 'read']);
253
+ });
254
+
255
+ // ---------------- Markdown body containing triple quotes ----------------
256
+
257
+ test('body containing triple quotes survives MD → TOML → MD', () => {
258
+ const body = 'Use """fenced""" blocks for code in the response.';
259
+ const md = [
260
+ '---',
261
+ 'name: q',
262
+ 'description: d',
263
+ '---',
264
+ '',
265
+ body,
266
+ ].join('\n');
267
+ const toml = mdToCodexToml(md);
268
+ const parsed = parseSimpleToml(toml);
269
+ assert.equal(parsed.system_prompt, body);
270
+ // And full round-trip
271
+ const back = codexTomlToMd(toml);
272
+ const toml2 = mdToCodexToml(back);
273
+ const parsed2 = parseSimpleToml(toml2);
274
+ assert.equal(parsed2.system_prompt, body);
275
+ });
276
+
277
+ // ---------------- Codex extras preserved ----------------
278
+
279
+ test('Codex extras (developer_instructions, sandbox_mode) survive round-trip', () => {
280
+ const original = [
281
+ 'name = "codex-only"',
282
+ 'description = "uses extras"',
283
+ 'sandbox_mode = "read-only"',
284
+ 'developer_instructions = """',
285
+ 'Use the existing helper module.',
286
+ 'Do not network.',
287
+ '"""',
288
+ 'system_prompt = """',
289
+ 'You are Codex.',
290
+ '"""',
291
+ ].join('\n') + '\n';
292
+ const md = codexTomlToMd(original);
293
+ const reemitted = mdToCodexToml(md);
294
+ const a = parseSimpleToml(original);
295
+ const b = parseSimpleToml(reemitted);
296
+ assert.equal(a.sandbox_mode, b.sandbox_mode);
297
+ assert.equal(a.developer_instructions, b.developer_instructions);
298
+ assert.equal(a.system_prompt, b.system_prompt);
299
+ });
300
+
301
+ // ---------------- Unterminated frontmatter rejected ----------------
302
+
303
+ test('unterminated YAML frontmatter throws', () => {
304
+ const md = '---\nname: bad\n';
305
+ assert.throws(() => mdToCodexToml(md), /unterminated/);
306
+ });
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Subagent format transcoder — ADR-012 §5.
3
+ *
4
+ * Translates between the canonical Markdown + YAML-frontmatter shape used
5
+ * by Claude Code and Gemini CLI and the TOML shape used by Codex CLI.
6
+ * Zero external dependencies: hand-rolled mini-parsers for YAML
7
+ * frontmatter and the TOML keys ADR §5 lists.
8
+ *
9
+ * Supported keys (top-level):
10
+ * - Canonical (round-trips): name, description, tools, model, system_prompt
11
+ * - Codex extras (preserved when present): developer_instructions,
12
+ * sandbox_mode, mcp_servers, skills
13
+ * - Claude/Gemini extras (preserved in YAML, ignored by Codex):
14
+ * temperature, max_turns, timeout_mins
15
+ *
16
+ * Nested table support is limited to `[mcp_servers]` — that's the only
17
+ * realistic surface for subagents. Arrays of tables, inline tables, and
18
+ * comments are not parsed; the transcoder operates on files we generate
19
+ * or files following ADR §5's spec.
20
+ */
21
+
22
+ // ---------- Public API ----------
23
+
24
+ export function mdToCodexToml(md: string): string {
25
+ const { frontmatter, body } = parseFrontmatter(md);
26
+ const fm = { ...frontmatter };
27
+ // Body becomes system_prompt unless the frontmatter already has one
28
+ // (in which case body wins — same convention as Gemini/Claude tooling).
29
+ if (body.length > 0) fm.system_prompt = body;
30
+ return emitToml(fm);
31
+ }
32
+
33
+ export function codexTomlToMd(toml: string): string {
34
+ const data = parseToml(toml);
35
+ const body = typeof data.system_prompt === 'string' ? data.system_prompt : '';
36
+ const fmOnly: Record<string, unknown> = { ...data };
37
+ delete fmOnly.system_prompt;
38
+ return emitFrontmatter(fmOnly) + body;
39
+ }
40
+
41
+ export function mdToGeminiCommandToml(md: string): string {
42
+ // Gemini commands are slash commands, not subagents: the body is `prompt`
43
+ // (not `system_prompt`) and frontmatter keys live at the top level.
44
+ const { frontmatter, body } = parseFrontmatter(md);
45
+ const fm: Record<string, unknown> = { ...frontmatter };
46
+ if (body.length > 0) fm.prompt = body;
47
+ return emitToml(fm);
48
+ }
49
+
50
+ // ---------- YAML frontmatter (read) ----------
51
+
52
+ interface Frontmatter {
53
+ frontmatter: Record<string, unknown>;
54
+ body: string;
55
+ }
56
+
57
+ const FRONT_DELIM = /^---\s*$/;
58
+
59
+ function parseFrontmatter(md: string): Frontmatter {
60
+ const lines = md.split('\n');
61
+ if (lines.length === 0 || !FRONT_DELIM.test(lines[0] ?? '')) {
62
+ return { frontmatter: {}, body: md };
63
+ }
64
+ let closeIdx = -1;
65
+ for (let i = 1; i < lines.length; i++) {
66
+ if (FRONT_DELIM.test(lines[i] ?? '')) {
67
+ closeIdx = i;
68
+ break;
69
+ }
70
+ }
71
+ if (closeIdx === -1) {
72
+ throw new Error('subagent transcoder: unterminated YAML frontmatter (missing closing `---`)');
73
+ }
74
+ const yamlLines = lines.slice(1, closeIdx);
75
+ const bodyLines = lines.slice(closeIdx + 1);
76
+ // Strip exactly one leading blank line between `---` and body, if present.
77
+ if (bodyLines.length > 0 && bodyLines[0] === '') bodyLines.shift();
78
+ return {
79
+ frontmatter: parseYamlBlock(yamlLines),
80
+ body: bodyLines.join('\n'),
81
+ };
82
+ }
83
+
84
+ function parseYamlBlock(lines: string[]): Record<string, unknown> {
85
+ const out: Record<string, unknown> = {};
86
+ let i = 0;
87
+ while (i < lines.length) {
88
+ const raw = lines[i] ?? '';
89
+ if (raw.trim() === '') { i++; continue; }
90
+ if (/^\s/.test(raw)) {
91
+ throw new Error(`subagent transcoder: unexpected indented line in YAML frontmatter: ${JSON.stringify(raw)}`);
92
+ }
93
+ const colon = raw.indexOf(':');
94
+ if (colon < 0) {
95
+ throw new Error(`subagent transcoder: malformed YAML line (no \`:\`): ${JSON.stringify(raw)}`);
96
+ }
97
+ const key = raw.slice(0, colon).trim();
98
+ const rest = raw.slice(colon + 1).trim();
99
+ if (rest === '') {
100
+ // Block-style list: subsequent ` - item` lines.
101
+ const list: string[] = [];
102
+ i++;
103
+ while (i < lines.length && /^\s+-\s/.test(lines[i] ?? '')) {
104
+ const item = (lines[i] ?? '').replace(/^\s+-\s+/, '');
105
+ list.push(parseScalar(item) as string);
106
+ i++;
107
+ }
108
+ out[key] = list;
109
+ continue;
110
+ }
111
+ out[key] = parseScalar(rest);
112
+ i++;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function parseScalar(s: string): unknown {
118
+ const t = s.trim();
119
+ if (t.startsWith('[') && t.endsWith(']')) {
120
+ // Inline list: `[a, b, "c, d"]` — supports quoted items with commas.
121
+ const inner = t.slice(1, -1);
122
+ return splitInlineList(inner).map((p) => parseScalar(p));
123
+ }
124
+ if (t.startsWith('"') && t.endsWith('"') && t.length >= 2) {
125
+ // Double-quoted: honor JSON escape sequences (\n, \t, \\, \").
126
+ try {
127
+ return JSON.parse(t);
128
+ } catch {
129
+ return t.slice(1, -1);
130
+ }
131
+ }
132
+ if (t.startsWith("'") && t.endsWith("'") && t.length >= 2) {
133
+ // Single-quoted YAML strings are literal.
134
+ return t.slice(1, -1);
135
+ }
136
+ if (t === 'true') return true;
137
+ if (t === 'false') return false;
138
+ if (t === 'null' || t === '~' || t === '') return null;
139
+ if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
140
+ return t;
141
+ }
142
+
143
+ function splitInlineList(inner: string): string[] {
144
+ const out: string[] = [];
145
+ let buf = '';
146
+ let inQuote: string | null = null;
147
+ for (const ch of inner) {
148
+ if (inQuote) {
149
+ buf += ch;
150
+ if (ch === inQuote) inQuote = null;
151
+ continue;
152
+ }
153
+ if (ch === '"' || ch === "'") { inQuote = ch; buf += ch; continue; }
154
+ if (ch === ',') { out.push(buf.trim()); buf = ''; continue; }
155
+ buf += ch;
156
+ }
157
+ if (buf.trim() !== '') out.push(buf.trim());
158
+ return out;
159
+ }
160
+
161
+ // ---------- YAML frontmatter (write) ----------
162
+
163
+ function emitFrontmatter(fm: Record<string, unknown>): string {
164
+ const keys = Object.keys(fm);
165
+ if (keys.length === 0) return '---\n---\n';
166
+ const parts: string[] = ['---'];
167
+ for (const k of keys) {
168
+ const v = fm[k];
169
+ if (Array.isArray(v)) {
170
+ // Always emit lists in inline form for stability — round-trip with
171
+ // block-style lists still works because the reader accepts both.
172
+ parts.push(`${k}: [${v.map((it) => emitYamlScalar(it)).join(', ')}]`);
173
+ } else if (v !== undefined && v !== null) {
174
+ parts.push(`${k}: ${emitYamlScalar(v)}`);
175
+ } else if (v === null) {
176
+ parts.push(`${k}: null`);
177
+ }
178
+ }
179
+ parts.push('---');
180
+ parts.push('');
181
+ return parts.join('\n');
182
+ }
183
+
184
+ function emitYamlScalar(v: unknown): string {
185
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
186
+ if (v === null || v === undefined) return 'null';
187
+ const s = String(v);
188
+ // Quote when content has YAML-significant chars or leading/trailing space.
189
+ if (/[:#\[\]{},&*?|<>=!%@`]/.test(s) || /^\s|\s$/.test(s) || s === '') {
190
+ return JSON.stringify(s);
191
+ }
192
+ return s;
193
+ }
194
+
195
+ // ---------- TOML (write) ----------
196
+
197
+ function emitToml(data: Record<string, unknown>): string {
198
+ const lines: string[] = [];
199
+ const nested: Array<[string, Record<string, unknown>]> = [];
200
+ for (const k of Object.keys(data)) {
201
+ const v = data[k];
202
+ if (isPlainObject(v)) {
203
+ nested.push([k, v]);
204
+ continue;
205
+ }
206
+ lines.push(`${k} = ${emitTomlValue(v, k)}`);
207
+ }
208
+ for (const [name, table] of nested) {
209
+ lines.push('');
210
+ lines.push(`[${name}]`);
211
+ for (const tk of Object.keys(table)) {
212
+ lines.push(`${tk} = ${emitTomlValue((table as Record<string, unknown>)[tk], tk)}`);
213
+ }
214
+ }
215
+ return lines.join('\n') + '\n';
216
+ }
217
+
218
+ function emitTomlValue(v: unknown, key: string): string {
219
+ if (typeof v === 'string') {
220
+ // Triple-quoted for the prompt-bearing keys + anything multi-line.
221
+ if (key === 'system_prompt' || key === 'prompt' || key === 'developer_instructions' || v.includes('\n')) {
222
+ // Triple-quoted multi-line string per TOML spec: the immediately
223
+ // following newline after the opening `"""` is stripped, so we
224
+ // preserve the body byte-for-byte by placing closing `"""` directly
225
+ // after the body (no separator newline).
226
+ const escaped = v.replace(/"""/g, '"\\""\\""');
227
+ return `"""\n${escaped}"""`;
228
+ }
229
+ return JSON.stringify(v);
230
+ }
231
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
232
+ if (v === null || v === undefined) return '""';
233
+ if (Array.isArray(v)) {
234
+ return `[${v.map((it) => emitTomlValue(it, key)).join(', ')}]`;
235
+ }
236
+ // Fallback — should not be hit because objects are routed to nested tables.
237
+ return JSON.stringify(v);
238
+ }
239
+
240
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
241
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
242
+ }
243
+
244
+ // ---------- TOML (read) ----------
245
+
246
+ function parseToml(toml: string): Record<string, unknown> {
247
+ const out: Record<string, unknown> = {};
248
+ const lines = toml.split('\n');
249
+ let i = 0;
250
+ let currentTable: Record<string, unknown> = out;
251
+ while (i < lines.length) {
252
+ const raw = lines[i] ?? '';
253
+ const trimmed = raw.trim();
254
+ if (trimmed === '' || trimmed.startsWith('#')) { i++; continue; }
255
+ const tableMatch = /^\[([^\]]+)\]$/.exec(trimmed);
256
+ if (tableMatch) {
257
+ const name = (tableMatch[1] ?? '').trim();
258
+ const tbl: Record<string, unknown> = {};
259
+ out[name] = tbl;
260
+ currentTable = tbl;
261
+ i++;
262
+ continue;
263
+ }
264
+ const eq = raw.indexOf('=');
265
+ if (eq < 0) {
266
+ throw new Error(`subagent transcoder: malformed TOML line: ${JSON.stringify(raw)}`);
267
+ }
268
+ const key = raw.slice(0, eq).trim();
269
+ const restRaw = raw.slice(eq + 1);
270
+ const rest = restRaw.trim();
271
+ if (rest.startsWith('"""')) {
272
+ // Multi-line triple-quoted string. ADR §5 emits these with the
273
+ // opening `"""` on its own line; we accept both shapes.
274
+ const { value, consumed } = parseTripleQuoted(lines, i, eq);
275
+ currentTable[key] = value;
276
+ i += consumed;
277
+ continue;
278
+ }
279
+ currentTable[key] = parseTomlScalar(rest);
280
+ i++;
281
+ }
282
+ return out;
283
+ }
284
+
285
+ function parseTripleQuoted(lines: string[], startIdx: number, eqPos: number): { value: string; consumed: number } {
286
+ const first = lines[startIdx] ?? '';
287
+ // Strip the `key = """` prefix from the first line.
288
+ const afterOpen = first.slice(eqPos + 1).trim().slice(3);
289
+ let consumed = 1;
290
+ // Closing on the same line: `key = """text"""`
291
+ if (afterOpen.endsWith('"""') && afterOpen.length >= 3) {
292
+ return { value: unescapeTriple(afterOpen.slice(0, -3)), consumed };
293
+ }
294
+ // Per TOML spec, the newline immediately following the opening `"""`
295
+ // is stripped. We then collect content until the closing `"""` and
296
+ // include the body byte-for-byte (no synthetic trailing `\n`).
297
+ const buf: string[] = [];
298
+ if (afterOpen !== '') buf.push(afterOpen);
299
+ for (let j = startIdx + 1; j < lines.length; j++) {
300
+ const ln = lines[j] ?? '';
301
+ consumed++;
302
+ if (ln.endsWith('"""')) {
303
+ const trailing = ln.slice(0, -3);
304
+ // Closing on its own line — the `\n` before `"""` is part of the
305
+ // body; otherwise the trailing chunk is the body's last line.
306
+ if (trailing === '' && buf.length > 0) {
307
+ return { value: unescapeTriple(buf.join('\n') + '\n'), consumed };
308
+ }
309
+ buf.push(trailing);
310
+ return { value: unescapeTriple(buf.join('\n')), consumed };
311
+ }
312
+ buf.push(ln);
313
+ }
314
+ throw new Error('subagent transcoder: unterminated TOML triple-quoted string');
315
+ }
316
+
317
+ function unescapeTriple(s: string): string {
318
+ // Inverse of the `"""` → `"\""\""` escape applied during emit.
319
+ return s.replace(/"\\""\\""/g, '"""');
320
+ }
321
+
322
+ function parseTomlScalar(s: string): unknown {
323
+ if (s.startsWith('"') && s.endsWith('"')) {
324
+ return JSON.parse(s);
325
+ }
326
+ if (s.startsWith('[') && s.endsWith(']')) {
327
+ return splitInlineList(s.slice(1, -1)).map((p) => parseTomlScalar(p));
328
+ }
329
+ if (s === 'true') return true;
330
+ if (s === 'false') return false;
331
+ if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
332
+ return s;
333
+ }