petbox-wire 0.1.0-ci.1206

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.
Files changed (50) hide show
  1. package/README.md +119 -0
  2. package/bin/petbox-wire.js +30 -0
  3. package/package.json +44 -0
  4. package/src/agent-def-fetch.test.ts +317 -0
  5. package/src/agent-def-fetch.ts +409 -0
  6. package/src/agent-definition.ts +210 -0
  7. package/src/append-meta.test.ts +93 -0
  8. package/src/append.ts +172 -0
  9. package/src/apply-artifacts.ts +337 -0
  10. package/src/apply-root.test.ts +148 -0
  11. package/src/apply-root.ts +68 -0
  12. package/src/apply-write.test.ts +169 -0
  13. package/src/apply-write.ts +84 -0
  14. package/src/canon.ts +140 -0
  15. package/src/doctor-definition.test.ts +128 -0
  16. package/src/droid-pull-memory.ts +126 -0
  17. package/src/droid-push-session.ts +100 -0
  18. package/src/droid-transcript.ts +47 -0
  19. package/src/harness-capabilities.ts +126 -0
  20. package/src/harness-models.ts +158 -0
  21. package/src/hook-drain.ts +42 -0
  22. package/src/hook-prune.test.ts +165 -0
  23. package/src/hook-prune.ts +83 -0
  24. package/src/import-sessions.ts +261 -0
  25. package/src/opencode-plugin.ts +127 -0
  26. package/src/origin-marker.ts +37 -0
  27. package/src/posix-env.test.ts +99 -0
  28. package/src/posix-env.ts +61 -0
  29. package/src/protocol.test.ts +236 -0
  30. package/src/protocol.ts +136 -0
  31. package/src/pull-memory.test.ts +168 -0
  32. package/src/pull-memory.ts +119 -0
  33. package/src/push-session.ts +99 -0
  34. package/src/registry.ts +120 -0
  35. package/src/roles.test.ts +255 -0
  36. package/src/roles.ts +241 -0
  37. package/src/self-smoke.test.ts +103 -0
  38. package/src/self-smoke.ts +96 -0
  39. package/src/telemetry-settings.test.ts +65 -0
  40. package/src/telemetry-settings.ts +55 -0
  41. package/src/templates/SKILL.md +45 -0
  42. package/src/templates/agent-factory/SKILL.md +56 -0
  43. package/src/transcript.ts +86 -0
  44. package/src/truthfulness.test.ts +544 -0
  45. package/src/truthfulness.ts +164 -0
  46. package/src/wire-exit.test.ts +43 -0
  47. package/src/wire-exit.ts +25 -0
  48. package/src/wire-identity.test.ts +65 -0
  49. package/src/wire-identity.ts +64 -0
  50. package/src/wire.ts +1384 -0
@@ -0,0 +1,93 @@
1
+ // Unit tests for the observed role-binding session-meta header helper (append.ts).
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { test } from "node:test";
7
+ import { buildSessionMetaHeader } from "./append.ts";
8
+
9
+ function freshHome(): string {
10
+ const home = mkdtempSync(join(tmpdir(), "petbox-append-meta-"));
11
+ mkdirSync(join(home, ".petbox"), { recursive: true });
12
+ return home;
13
+ }
14
+
15
+ test("buildSessionMetaHeader returns null when no roles for agent", () => {
16
+ const home = freshHome();
17
+ try {
18
+ assert.equal(buildSessionMetaHeader("claude-code", home), null);
19
+ } finally {
20
+ rmSync(home, { recursive: true, force: true });
21
+ }
22
+ });
23
+
24
+ test("buildSessionMetaHeader wraps resolveObservedBinding as roleBinding", () => {
25
+ const home = freshHome();
26
+ try {
27
+ writeFileSync(
28
+ join(home, ".petbox", "roles.json"),
29
+ JSON.stringify({
30
+ activeProfile: "default",
31
+ profiles: {
32
+ default: {
33
+ agents: {
34
+ "claude-code": {
35
+ roles: {
36
+ orchestrator: { model: "claude-opus-4" },
37
+ worker: { model: "claude-sonnet-4" },
38
+ },
39
+ },
40
+ },
41
+ },
42
+ },
43
+ }),
44
+ );
45
+ const raw = buildSessionMetaHeader("claude-code", home);
46
+ assert.ok(raw);
47
+ assert.deepEqual(JSON.parse(raw!), {
48
+ roleBinding: {
49
+ profile: "default",
50
+ agent: "claude-code",
51
+ roles: {
52
+ orchestrator: "claude-opus-4",
53
+ worker: "claude-sonnet-4",
54
+ },
55
+ },
56
+ });
57
+ } finally {
58
+ rmSync(home, { recursive: true, force: true });
59
+ }
60
+ });
61
+
62
+ test("buildSessionMetaHeader: droid push finds factory-droid roles bucket", () => {
63
+ const home = freshHome();
64
+ try {
65
+ writeFileSync(
66
+ join(home, ".petbox", "roles.json"),
67
+ JSON.stringify({
68
+ activeProfile: "default",
69
+ profiles: {
70
+ default: {
71
+ agents: {
72
+ "factory-droid": {
73
+ roles: { orchestrator: { model: "deepseek-v4-pro" } },
74
+ },
75
+ },
76
+ },
77
+ },
78
+ }),
79
+ );
80
+ // droid-push-session.ts uses agent: "droid"
81
+ const raw = buildSessionMetaHeader("droid", home);
82
+ assert.ok(raw);
83
+ assert.deepEqual(JSON.parse(raw!), {
84
+ roleBinding: {
85
+ profile: "default",
86
+ agent: "droid",
87
+ roles: { orchestrator: "deepseek-v4-pro" },
88
+ },
89
+ });
90
+ } finally {
91
+ rmSync(home, { recursive: true, force: true });
92
+ }
93
+ });
package/src/append.ts ADDED
@@ -0,0 +1,172 @@
1
+ // Shared incremental session push — the ONE append-flow implementation both Stop hooks
2
+ // (push-session.ts for Claude Code, opencode-plugin.ts for opencode) use, so the wire
3
+ // protocol cannot drift between agents (spec: session-append-wire, wiring-single-source).
4
+ //
5
+ // The server owns the cursor: a session's lastOrdinal is its stored message count. The
6
+ // client sends only a tail batch tagged with `fromOrdinal` (the ordinal of the batch's
7
+ // first message):
8
+ // - contiguous / overlapping → 200 { lastOrdinal, appended } (overlap is idempotent —
9
+ // ordinals the server already holds are ignored, so guessing "a little too early"
10
+ // is always safe);
11
+ // - gap → 409 { error: "gap", lastOrdinal } → self-heal: resend from lastOrdinal+1.
12
+ //
13
+ // The client keeps NO durable state. A long-lived host (the opencode plugin) passes the
14
+ // lastOrdinal remembered from the previous response; a per-invocation host (the Claude
15
+ // Code Stop hook process is fresh each turn) passes null and we optimistically resend a
16
+ // small overlap window from the end of the local transcript — one round-trip in the
17
+ // steady state, two after a restart/outage (the 409 tells us where to resume).
18
+ //
19
+ // Fallback: an old server without the append route 404s → push the full transcript to the
20
+ // legacy last-write-wins endpoint, exactly what the hooks did before.
21
+ //
22
+ // Observed role binding (binding-not-server-authoritative): when ~/.petbox/roles.json has
23
+ // roles for the agent, stamp X-PetBox-Session-Meta with { roleBinding }. Best-effort —
24
+ // missing roles.json never fails the push. Server stores as session MetaJson observation
25
+ // only; local roles.json remains the source of truth.
26
+ //
27
+ // Plain TS for native node type-stripping: zero deps.
28
+
29
+ import { resolveObservedBinding } from "./roles.ts";
30
+ import type { Msg } from "./transcript.ts";
31
+
32
+ // How many trailing messages to optimistically resend when the server cursor is unknown.
33
+ // A turn typically adds 2-4 messages; overlap is idempotent, so oversizing only costs bytes.
34
+ const OVERLAP_WINDOW = 8;
35
+
36
+ const MAX_APPEND_ATTEMPTS = 3;
37
+
38
+ export type PushTarget = {
39
+ baseUrl: string;
40
+ project: string;
41
+ sessionId: string;
42
+ apiKey: string;
43
+ agent: string;
44
+ timeoutMs: number;
45
+ };
46
+
47
+ /**
48
+ * Pure helper: JSON for X-PetBox-Session-Meta, or null when no local binding / any error.
49
+ * Shape: { roleBinding: { profile, agent, roles: { role: model } } }
50
+ */
51
+ export function buildSessionMetaHeader(
52
+ agent: string,
53
+ homeDir?: string,
54
+ ): string | null {
55
+ try {
56
+ const obs =
57
+ homeDir === undefined
58
+ ? resolveObservedBinding(agent)
59
+ : resolveObservedBinding(agent, homeDir);
60
+ if (!obs) return null;
61
+ return JSON.stringify({ roleBinding: obs });
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function ndjson(msgs: readonly Msg[]): string {
68
+ return msgs.map((m) => JSON.stringify(m)).join("\n");
69
+ }
70
+
71
+ async function post(
72
+ url: string,
73
+ apiKey: string,
74
+ body: string,
75
+ timeoutMs: number,
76
+ metaHeader: string | null,
77
+ ): Promise<Response> {
78
+ const headers: Record<string, string> = {
79
+ "X-Api-Key": apiKey,
80
+ "Content-Type": "application/x-ndjson; charset=utf-8",
81
+ // Connection: close — no lingering keep-alive socket after this short-lived hook
82
+ // process's request (see canon.ts's fetchCanon for the full rationale).
83
+ Connection: "close",
84
+ };
85
+ if (metaHeader) headers["X-PetBox-Session-Meta"] = metaHeader;
86
+ const ctrl = new AbortController();
87
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
88
+ try {
89
+ return await fetch(url, {
90
+ method: "POST",
91
+ headers,
92
+ body,
93
+ signal: ctrl.signal,
94
+ });
95
+ } finally {
96
+ clearTimeout(timer);
97
+ }
98
+ }
99
+
100
+ // Push the (full, ordered) local transcript incrementally. `knownLastOrdinal` is the cursor
101
+ // remembered from a previous response in THIS process, or null when unknown. Returns the
102
+ // server's lastOrdinal after the push (feed it back next call), or null when every path
103
+ // failed — callers are best-effort and must swallow that.
104
+ export async function pushTranscript(
105
+ t: PushTarget,
106
+ msgs: readonly Msg[],
107
+ knownLastOrdinal: number | null,
108
+ ): Promise<number | null> {
109
+ if (msgs.length === 0) return knownLastOrdinal;
110
+
111
+ // The server already has everything we know about → nothing to send (the transcript is
112
+ // append-only, so equal length means equal content).
113
+ if (knownLastOrdinal !== null && knownLastOrdinal >= msgs.length) return knownLastOrdinal;
114
+
115
+ const base = `${t.baseUrl}/api/sessions/${t.project}/${encodeURIComponent(t.sessionId)}`;
116
+ // Stamp observed binding once per push; never let roles.json issues fail the transcript.
117
+ const metaHeader = buildSessionMetaHeader(t.agent);
118
+
119
+ let from =
120
+ knownLastOrdinal !== null && knownLastOrdinal >= 0
121
+ ? knownLastOrdinal + 1
122
+ : Math.max(1, msgs.length - OVERLAP_WINDOW + 1);
123
+
124
+ for (let attempt = 0; attempt < MAX_APPEND_ATTEMPTS; attempt++) {
125
+ let resp: Response;
126
+ try {
127
+ resp = await post(
128
+ `${base}/append?agent=${encodeURIComponent(t.agent)}&fromOrdinal=${from}`,
129
+ t.apiKey,
130
+ ndjson(msgs.slice(from - 1)),
131
+ t.timeoutMs,
132
+ metaHeader,
133
+ );
134
+ } catch {
135
+ return null; // network failure — a full-snapshot retry would fail the same way
136
+ }
137
+
138
+ if (resp.ok) {
139
+ const j = (await resp.json().catch(() => null)) as { lastOrdinal?: number } | null;
140
+ return j && typeof j.lastOrdinal === "number" ? j.lastOrdinal : msgs.length;
141
+ }
142
+
143
+ if (resp.status === 409) {
144
+ // Structured contiguity gap: the body carries the server's cursor. Resend from there.
145
+ const j = (await resp.json().catch(() => null)) as { lastOrdinal?: number } | null;
146
+ const last = j && typeof j.lastOrdinal === "number" ? j.lastOrdinal : null;
147
+ if (last === null) break; // unparseable reject → full-snapshot fallback
148
+ if (last >= msgs.length) return last; // server is already ahead of our local view
149
+ from = last + 1;
150
+ continue;
151
+ }
152
+
153
+ // 404 = old server without the append route; anything else = unknown failure.
154
+ // Either way the legacy full-snapshot push is the safe fallback.
155
+ break;
156
+ }
157
+
158
+ try {
159
+ const resp = await post(
160
+ `${base}?agent=${encodeURIComponent(t.agent)}`,
161
+ t.apiKey,
162
+ ndjson(msgs),
163
+ t.timeoutMs,
164
+ metaHeader,
165
+ );
166
+ if (!resp.ok) return null;
167
+ const j = (await resp.json().catch(() => null)) as { version?: number } | null;
168
+ return j && typeof j.version === "number" ? j.version : msgs.length;
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
@@ -0,0 +1,337 @@
1
+ // Pure compile helpers for petbox-wire apply (per-harness-artifact).
2
+ //
3
+ // planApply produces agent role files for any known harness.
4
+ // Per-role truthfulness: clean roles are emitted; dirty roles are skipped and
5
+ // reported in violations/skippedRoles (never silently drop a required line from
6
+ // a role that is emitted — the whole dirty role is blocked). "Dirty" covers BOTH
7
+ // a missing harness capability AND a local model binding that looks like ANOTHER
8
+ // harness's id shape (harness-models.ts's "foreign" tier) — writing that in would be
9
+ // either rejected loudly by the target harness at runtime or silently satisfy a
10
+ // different harness's config, not this one. A model that is merely unrecognized-but-
11
+ // shape-valid (harness-models.ts's "unknown" tier — e.g. a real `claude-*` id newer
12
+ // than the kit's small known-alias list) is NOT dirty: it is written, with a warning
13
+ // (see ApplyPlan.warnings / modelShapeWarning in truthfulness.ts).
14
+ //
15
+ // Paths (documented harness layouts):
16
+ // opencode → .opencode/agent/<role>.md
17
+ // claude-code → .claude/agents/<role>.md
18
+ // droid → .factory/droids/<name>.md (Factory custom droids; project level)
19
+ // https://docs.factory.ai/cli/configuration/custom-droids
20
+ //
21
+ // model: from local roles.json binding when present; droid unbound → model: inherit
22
+ // (Factory default). Never invent a concrete model id.
23
+ //
24
+ // Plain TS for native node type-stripping: zero deps.
25
+
26
+ import { join } from "node:path";
27
+ import { emittedRoleName, type AgentDefinition, type AgentRole } from "./agent-definition.ts";
28
+ import { isKnownHarness, type HarnessId } from "./harness-capabilities.ts";
29
+ import { PETBOX_MARKER_LINE } from "./origin-marker.ts";
30
+ import {
31
+ checkRoleTruthfulness,
32
+ formatViolations,
33
+ modelShapeWarning,
34
+ type TruthfulnessViolation,
35
+ } from "./truthfulness.ts";
36
+
37
+ /**
38
+ * Factory custom-droid name sanitizer: lowercase/digits/-/_ only (Factory DroidValidator —
39
+ * see renderDroidMarkdown below). Shared by renderDroidMarkdown (frontmatter `name:`) and
40
+ * planApply (the emitted/legacy droid file basenames) so the two can never drift apart.
41
+ */
42
+ export function sanitizeDroidName(name: string): string {
43
+ return name
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9_-]+/g, "-")
46
+ .replace(/^-+|-+$/g, "");
47
+ }
48
+
49
+ export type PlannedFile = {
50
+ readonly relativePath: string;
51
+ readonly content: string;
52
+ /**
53
+ * Where this role's file lived BEFORE namespacing (bare `role.slug`, no `petbox-` prefix),
54
+ * relative to the harness root — same convention as relativePath. apply's writer
55
+ * (apply-write.ts's cleanupLegacyArtifact) uses this to find and remove an old file we own;
56
+ * it is a no-op when nothing sits there, and it NEVER touches a path that lacks our origin
57
+ * marker (chore: petbox-namespaced-agent-names). Always distinct from relativePath — every
58
+ * role is namespaced now.
59
+ */
60
+ readonly legacyRelativePath: string;
61
+ };
62
+
63
+ export type ApplyPlan = {
64
+ readonly harness: string;
65
+ readonly files: readonly PlannedFile[];
66
+ /** Truthfulness violations (capability or model) for roles that were NOT written. */
67
+ readonly violations: readonly TruthfulnessViolation[];
68
+ /** Role slugs skipped because of violations. */
69
+ readonly skippedRoles: readonly string[];
70
+ /**
71
+ * Non-blocking notices, two kinds:
72
+ * - A role with NO local model binding is written without a model key (claude-code/opencode)
73
+ * or with `model: inherit` (droid) — legitimate, but it means the agent runs on the
74
+ * session/parent model. Warn so that is a choice, not a surprise.
75
+ * - A role bound to a model that classifies "unknown" (harness-models.ts) — shape-valid for
76
+ * the harness, just not on its small known-alias list — is written as bound, unverified.
77
+ * A role bound to a "foreign"-shaped id is NOT a warning — it is a violation (see violations).
78
+ */
79
+ readonly warnings: readonly string[];
80
+ };
81
+
82
+ /** Relative dir (posix) for agent role files per harness. */
83
+ export function agentFilesDir(harness: HarnessId): string {
84
+ switch (harness) {
85
+ case "opencode":
86
+ return ".opencode/agent";
87
+ case "claude-code":
88
+ return ".claude/agents";
89
+ case "droid":
90
+ // Factory custom droids — project level only (org-locked settings are out of scope).
91
+ // https://docs.factory.ai/cli/configuration/custom-droids
92
+ return ".factory/droids";
93
+ }
94
+ }
95
+
96
+ /** Shared role body (spawn / escalation / caps / notes). No protocol inject here. */
97
+ export function buildRoleBody(role: AgentRole): string {
98
+ const lines: string[] = [];
99
+ lines.push(`# ${emittedRoleName(role)}`);
100
+ lines.push("");
101
+ lines.push(`Tier: \`${role.tier}\``);
102
+ lines.push("");
103
+
104
+ if (role.notes) {
105
+ lines.push(role.notes);
106
+ lines.push("");
107
+ }
108
+
109
+ const caps = role.requiredCapabilities;
110
+ lines.push("## Required capabilities");
111
+ if (caps.length === 0) {
112
+ lines.push("- (none declared — role is harness-portable with no capability gate)");
113
+ } else {
114
+ for (const c of caps) lines.push(`- \`${c}\``);
115
+ }
116
+ lines.push("");
117
+
118
+ // Both lists below NAME SPAWN/ESCALATION TARGETS — the literal subagent_type / handoff-role
119
+ // string another role would use. They MUST render the same namespaced identity the target
120
+ // role's own file is emitted under (emittedRoleName), never the bare internal role.slug —
121
+ // otherwise a generated file would point at a subagent_type that does not exist on disk
122
+ // (chore: petbox-namespaced-agent-names, "prose naming a spawn type renders the computed
123
+ // name, never a constant/bare slug").
124
+ lines.push("## Spawn");
125
+ if (role.spawn?.allowed) {
126
+ const allowed = role.spawn.allowedRoles?.length
127
+ ? role.spawn.allowedRoles.map((r) => `\`${emittedRoleName(r)}\``).join(", ")
128
+ : "(any)";
129
+ lines.push(`- Allowed. Target roles: ${allowed}.`);
130
+ } else {
131
+ lines.push(
132
+ "- Not allowed. This is a leaf role: you MUST NOT spawn subagents " +
133
+ "(no Agent/Task/spawn tool use of any kind), regardless of what tools the " +
134
+ "harness makes available to this session.",
135
+ );
136
+ }
137
+ lines.push("");
138
+
139
+ lines.push("## Escalation");
140
+ if (role.escalation?.available) {
141
+ const targets = role.escalation.targets?.length
142
+ ? role.escalation.targets.map((t) => `\`${emittedRoleName(t)}\``).join(", ")
143
+ : "(unspecified)";
144
+ lines.push(`- Available → ${targets}.`);
145
+ } else {
146
+ lines.push("- Not available.");
147
+ }
148
+ lines.push("");
149
+
150
+ if (role.slug === "explore") {
151
+ lines.push("## Model inheritance");
152
+ lines.push(
153
+ "On harnesses that declare `builtin_explore_inherits_model`, inheritance is the harness default. " +
154
+ "Do not treat inheritance as a protocol violation for this role.",
155
+ );
156
+ lines.push("");
157
+ }
158
+
159
+ return lines.join("\n");
160
+ }
161
+
162
+ /**
163
+ * Claude Code / opencode agent markdown (YAML frontmatter + body).
164
+ * name: <emittedRoleName(role)> is the required first key — without it Claude Code will not
165
+ * register the file as an agent at all (mirrors renderDroidMarkdown's `name:`). Namespaced
166
+ * (`petbox-<slug>`), not the bare role.slug — see agent-definition.ts's emittedRoleName.
167
+ * model only when bound — never invent a model id.
168
+ * No `tools:` key: omitting it means the agent inherits the harness's full tool
169
+ * set, including MCP — that is the intended policy (see harness-capabilities.ts).
170
+ * Every generated file carries PETBOX_MARKER_LINE in its frontmatter (origin-marker.ts) —
171
+ * the ONLY thing apply's write guard trusts to tell "ours" from a real user file.
172
+ */
173
+ export function renderAgentMarkdown(role: AgentRole, model?: string): string {
174
+ const frontLines: string[] = [`name: ${emittedRoleName(role)}`];
175
+ if (model && model.trim()) {
176
+ frontLines.push(`model: ${model.trim()}`);
177
+ }
178
+ frontLines.push(`description: PetBox ${role.tier} role (${emittedRoleName(role)})`);
179
+ frontLines.push(PETBOX_MARKER_LINE);
180
+
181
+ const body = buildRoleBody(role);
182
+ return `---\n${frontLines.join("\n")}\n---\n\n${body.endsWith("\n") ? body : body + "\n"}`;
183
+ }
184
+
185
+ /**
186
+ * Factory custom droid markdown.
187
+ * Docs: https://docs.factory.ai/cli/configuration/custom-droids
188
+ * Frontmatter: name (required), description, model (inherit | explicit), optional
189
+ * reasoningEffort / tools / mcpServers. Body = system prompt.
190
+ * name: sanitizeDroidName(emittedRoleName(role)) — namespaced first, THEN sanitized, so the
191
+ * `petbox-` prefix survives Factory's DroidValidator (`[a-z0-9_-]` only) the same way it
192
+ * does on claude-code/opencode.
193
+ * model: bound value from roles.json, else `inherit` (Factory default — not an invented id).
194
+ * mcpServers: petbox when the role requires MCP (main or subagent surface).
195
+ * Carries PETBOX_MARKER_LINE, same as renderAgentMarkdown — see origin-marker.ts.
196
+ */
197
+ export function renderDroidMarkdown(
198
+ role: AgentRole,
199
+ model?: string,
200
+ opts?: { mcpServerName?: string },
201
+ ): string {
202
+ const name = sanitizeDroidName(emittedRoleName(role));
203
+ if (!name) {
204
+ throw new Error(`role '${role.slug}': cannot form a valid droid name`);
205
+ }
206
+
207
+ const description = (
208
+ role.notes?.trim() || `PetBox ${role.tier} role (${emittedRoleName(role)})`
209
+ ).slice(0, 500);
210
+ const modelLine =
211
+ model && model.trim() ? model.trim() : "inherit"; // Factory default when unbound
212
+
213
+ const front: string[] = [
214
+ `name: ${name}`,
215
+ `description: ${JSON.stringify(description)}`,
216
+ `model: ${modelLine}`,
217
+ ];
218
+
219
+ // Scope petbox MCP to roles that need it (mcpServers = configured server names).
220
+ const needsMcp =
221
+ role.requiredCapabilities.includes("mcp_main_session") ||
222
+ role.requiredCapabilities.includes("mcp_subagent") ||
223
+ role.spawn?.allowed === true;
224
+ if (needsMcp) {
225
+ const server = opts?.mcpServerName ?? "petbox";
226
+ front.push(`mcpServers: ["${server}"]`);
227
+ }
228
+ front.push(PETBOX_MARKER_LINE);
229
+
230
+ const body = buildRoleBody(role);
231
+ return `---\n${front.join("\n")}\n---\n\n${body.endsWith("\n") ? body : body + "\n"}`;
232
+ }
233
+
234
+ /** @deprecated alias — prefer renderAgentMarkdown */
235
+ export function renderOpencodeAgentMarkdown(role: AgentRole, model?: string): string {
236
+ return renderAgentMarkdown(role, model);
237
+ }
238
+
239
+ function renderForHarness(
240
+ harness: HarnessId,
241
+ role: AgentRole,
242
+ model: string | undefined,
243
+ ): string {
244
+ if (harness === "droid") return renderDroidMarkdown(role, model);
245
+ return renderAgentMarkdown(role, model);
246
+ }
247
+
248
+ /**
249
+ * Plan artifact writes for a definition + harness + optional role→model map.
250
+ * Emits clean roles; skips dirty roles with violations reported (not silent).
251
+ * Does not touch the filesystem.
252
+ */
253
+ export function planApply(
254
+ definition: AgentDefinition,
255
+ harness: string,
256
+ roleModels: Readonly<Record<string, string>> = {},
257
+ ): ApplyPlan {
258
+ if (!isKnownHarness(harness)) {
259
+ return {
260
+ harness,
261
+ files: [],
262
+ violations: definition.roles.flatMap((role) =>
263
+ role.requiredCapabilities.map((capability) => ({
264
+ role: role.slug,
265
+ capability: String(capability),
266
+ harness,
267
+ })),
268
+ ),
269
+ skippedRoles: definition.roles.map((r) => r.slug),
270
+ warnings: [],
271
+ };
272
+ }
273
+
274
+ const dir = agentFilesDir(harness);
275
+ const files: PlannedFile[] = [];
276
+ const violations: TruthfulnessViolation[] = [];
277
+ const skippedRoles: string[] = [];
278
+ const warnings: string[] = [];
279
+
280
+ for (const role of definition.roles) {
281
+ const model = roleModels[role.slug];
282
+ // Gate BEFORE render: a role bound to a "foreign"-shaped model id is blocked, exactly like
283
+ // a missing capability — never write an id that belongs to a different harness's config.
284
+ const roleViolations = checkRoleTruthfulness(role, harness, model);
285
+ if (roleViolations.length > 0) {
286
+ violations.push(...roleViolations);
287
+ skippedRoles.push(role.slug);
288
+ continue;
289
+ }
290
+ if (!model || !model.trim()) {
291
+ warnings.push(
292
+ `role '${role.slug}' has no model binding for harness '${harness}' — the agent will ` +
293
+ `inherit the session/parent model. Bind it in ~/.petbox/roles.json to pin a tier.`,
294
+ );
295
+ } else {
296
+ const shapeWarning = modelShapeWarning(role, harness, model);
297
+ if (shapeWarning) warnings.push(shapeWarning);
298
+ }
299
+ const fileName =
300
+ harness === "droid" ? `${sanitizeDroidName(emittedRoleName(role))}.md` : `${emittedRoleName(role)}.md`;
301
+ // Pre-namespacing name — same file this role used to emit before petbox-namespaced-agent-names.
302
+ // The writer uses this to find + remove an owned leftover (never a foreign file at that path).
303
+ const legacyFileName = harness === "droid" ? `${sanitizeDroidName(role.slug)}.md` : `${role.slug}.md`;
304
+ files.push({
305
+ relativePath: join(dir, fileName).replace(/\\/g, "/"),
306
+ legacyRelativePath: join(dir, legacyFileName).replace(/\\/g, "/"),
307
+ content: renderForHarness(harness, role, model),
308
+ });
309
+ }
310
+
311
+ return { harness, files, violations, skippedRoles, warnings };
312
+ }
313
+
314
+ /** Thin wrapper: planApply(..., "opencode"). */
315
+ export function planOpencodeApply(
316
+ definition: AgentDefinition,
317
+ roleModels: Readonly<Record<string, string>> = {},
318
+ ): ApplyPlan {
319
+ return planApply(definition, "opencode", roleModels);
320
+ }
321
+
322
+ /** Loud multi-line error for CLI when roles are blocked by the gate. */
323
+ export function formatApplyBlocked(
324
+ violations: readonly TruthfulnessViolation[],
325
+ harness: string,
326
+ skippedRoles?: readonly string[],
327
+ ): string {
328
+ const skip =
329
+ skippedRoles && skippedRoles.length > 0
330
+ ? `\n skipped roles: ${skippedRoles.join(", ")}`
331
+ : "";
332
+ return (
333
+ `apply: truthfulness gate blocked role(s) on harness '${harness}':\n` +
334
+ formatViolations(violations) +
335
+ skip
336
+ );
337
+ }