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,86 @@
1
+ // Shared Claude Code transcript parsing — the ONE implementation both the Stop hook
2
+ // (push-session.ts) and the history importer (import-sessions.ts) use, so what a session
3
+ // "is" cannot drift between live pushes and imports (spec: wiring-single-source).
4
+ //
5
+ // A transcript is JSONL; we keep the user/assistant TEXT turns in order and exclude tool
6
+ // dumps, meta/sidechain entries and harness chrome — tool outputs can carry secrets and
7
+ // the server only wants the dialogue (spec: wiring-history-import).
8
+ //
9
+ // Plain TS for native node type-stripping: zero deps.
10
+
11
+ import { createReadStream } from "node:fs";
12
+ import { createInterface } from "node:readline";
13
+
14
+ export type Msg = { role: string; content: string };
15
+
16
+ export function extractText(message: unknown): string {
17
+ const msg = message as { content?: unknown } | null;
18
+ if (!msg || msg.content == null) return "";
19
+ if (typeof msg.content === "string") return msg.content.trim();
20
+ if (Array.isArray(msg.content)) {
21
+ const parts = msg.content
22
+ .filter((p: any) => p && p.type === "text" && typeof p.text === "string")
23
+ .map((p: any) => p.text);
24
+ return parts.join("\n").trim();
25
+ }
26
+ return "";
27
+ }
28
+
29
+ export function isExcluded(text: string): boolean {
30
+ return (
31
+ text.startsWith("<system-reminder") ||
32
+ text.startsWith("<command-name>") ||
33
+ text.startsWith("<local-command")
34
+ );
35
+ }
36
+
37
+ // Collect the user/assistant text messages in transcript order. No rendering and no cap:
38
+ // the server needs the full, ordered transcript to assign stable per-message ordinals.
39
+ export async function buildMessages(transcriptPath: string): Promise<Msg[]> {
40
+ const rl = createInterface({
41
+ input: createReadStream(transcriptPath, { encoding: "utf8" }),
42
+ crlfDelay: Infinity,
43
+ });
44
+ const msgs: Msg[] = [];
45
+ for await (const line of rl) {
46
+ if (!line || line.trim().length === 0) continue;
47
+ let e: any;
48
+ try {
49
+ e = JSON.parse(line);
50
+ } catch {
51
+ continue;
52
+ }
53
+ if (e.type !== "user" && e.type !== "assistant") continue;
54
+ if (e.isMeta || e.isSidechain) continue;
55
+ const text = extractText(e.message);
56
+ if (text.length === 0) continue;
57
+ if (isExcluded(text)) continue;
58
+ msgs.push({ role: e.type, content: text });
59
+ }
60
+ return msgs;
61
+ }
62
+
63
+ // The cwd a transcript was recorded in (the first entry that carries one). Lets the
64
+ // importer attribute a transcript to a registered project WITHOUT reversing Claude's
65
+ // lossy path-encoding of the directory name.
66
+ export async function readTranscriptCwd(transcriptPath: string, maxLines = 25): Promise<string | null> {
67
+ const rl = createInterface({
68
+ input: createReadStream(transcriptPath, { encoding: "utf8" }),
69
+ crlfDelay: Infinity,
70
+ });
71
+ let seen = 0;
72
+ for await (const line of rl) {
73
+ if (++seen > maxLines) break;
74
+ if (!line || line.trim().length === 0) continue;
75
+ try {
76
+ const e = JSON.parse(line);
77
+ if (e && typeof e.cwd === "string" && e.cwd.length > 0) {
78
+ rl.close();
79
+ return e.cwd;
80
+ }
81
+ } catch {
82
+ /* skip unparseable line */
83
+ }
84
+ }
85
+ return null;
86
+ }
@@ -0,0 +1,544 @@
1
+ // Unit tests for the definition truthfulness gate + default agent definition + apply plans.
2
+ //
3
+ // Run: node --test src/truthfulness.test.ts (Node >= 23.6 native TS)
4
+
5
+ import assert from "node:assert/strict";
6
+ import { test } from "node:test";
7
+ import {
8
+ DEFAULT_AGENT_DEFINITION,
9
+ validateAgentDefinition,
10
+ type AgentDefinition,
11
+ } from "./agent-definition.ts";
12
+ import {
13
+ formatApplyBlocked,
14
+ planApply,
15
+ planOpencodeApply,
16
+ renderAgentMarkdown,
17
+ renderDroidMarkdown,
18
+ renderOpencodeAgentMarkdown,
19
+ } from "./apply-artifacts.ts";
20
+ import { hasPetboxMarker } from "./origin-marker.ts";
21
+ import { HARNESS_IDS, harnessCapabilities, hasCapability } from "./harness-capabilities.ts";
22
+ import { allowedModels, classifyModel, isResolvableModel } from "./harness-models.ts";
23
+ import {
24
+ checkTruthfulness,
25
+ formatViolations,
26
+ isModelViolation,
27
+ modelShapeWarning,
28
+ type ModelViolation,
29
+ } from "./truthfulness.ts";
30
+ import { classifyApplyExit, WIRE_EXIT } from "./wire-exit.ts";
31
+
32
+ test("claude-code declares role_files + spawn_subagents + mcp_subagent (verified live 2026-07-12)", () => {
33
+ const caps = harnessCapabilities("claude-code");
34
+ assert.equal(caps.has("mcp_main_session"), true);
35
+ assert.equal(caps.has("dynamic_model_at_spawn"), true);
36
+ assert.equal(caps.has("builtin_explore_inherits_model"), true);
37
+ assert.equal(caps.has("hooks"), true);
38
+ assert.equal(caps.has("role_files"), true);
39
+ assert.equal(caps.has("spawn_subagents"), true);
40
+ assert.equal(caps.has("mcp_subagent"), true);
41
+ });
42
+
43
+ test("opencode declares role_files + mcp_subagent + spawn_subagents, not dynamic_model_at_spawn", () => {
44
+ const caps = harnessCapabilities("opencode");
45
+ assert.equal(caps.has("role_files"), true);
46
+ assert.equal(caps.has("mcp_subagent"), true);
47
+ assert.equal(caps.has("builtin_explore_inherits_model"), true);
48
+ assert.equal(caps.has("spawn_subagents"), true);
49
+ assert.equal(caps.has("dynamic_model_at_spawn"), false);
50
+ });
51
+
52
+ test("droid matrix from Factory docs: role_files+spawn+mcp+dynamic+hooks; no explore-inherit claim", () => {
53
+ // https://docs.factory.ai/cli/configuration/custom-droids
54
+ // https://docs.factory.ai/cli/configuration/mcp
55
+ const caps = harnessCapabilities("droid");
56
+ for (const c of [
57
+ "mcp_main_session",
58
+ "mcp_subagent",
59
+ "spawn_subagents",
60
+ "role_files",
61
+ "dynamic_model_at_spawn",
62
+ "hooks",
63
+ ] as const) {
64
+ assert.equal(caps.has(c), true, `droid must declare ${c}`);
65
+ }
66
+ assert.equal(
67
+ hasCapability("droid", "builtin_explore_inherits_model"),
68
+ false,
69
+ "do not claim CC-style Explore inherit without separate verification",
70
+ );
71
+ });
72
+
73
+ test("constructed def requiring missing cap fails with role+capability+harness in message", () => {
74
+ const def: AgentDefinition = {
75
+ name: "t",
76
+ roles: [
77
+ {
78
+ slug: "worker",
79
+ tier: "worker",
80
+ // opencode does not declare dynamic_model_at_spawn (PR #18588) — a capability
81
+ // still genuinely absent from a known harness's row, unlike mcp_subagent which
82
+ // claude-code now declares (verified live 2026-07-12).
83
+ requiredCapabilities: ["dynamic_model_at_spawn"],
84
+ },
85
+ ],
86
+ };
87
+ const v = checkTruthfulness(def, "opencode");
88
+ assert.equal(v.length, 1);
89
+ assert.deepEqual(v[0], {
90
+ role: "worker",
91
+ capability: "dynamic_model_at_spawn",
92
+ harness: "opencode",
93
+ });
94
+ const msg = formatViolations(v);
95
+ assert.match(msg, /worker/);
96
+ assert.match(msg, /dynamic_model_at_spawn/);
97
+ assert.match(msg, /opencode/);
98
+ });
99
+
100
+ test("DEFAULT is truth-clean on all known harnesses (including droid)", () => {
101
+ validateAgentDefinition(DEFAULT_AGENT_DEFINITION);
102
+ for (const h of HARNESS_IDS) {
103
+ const v = checkTruthfulness(DEFAULT_AGENT_DEFINITION, h);
104
+ assert.deepEqual(v, [], `default must pass on ${h}: ${formatViolations(v)}`);
105
+ }
106
+ const explore = DEFAULT_AGENT_DEFINITION.roles.find((r) => r.slug === "explore");
107
+ assert.ok(explore);
108
+ assert.ok(!(explore!.notes ?? "").toLowerCase().includes("inheritance forbidden"));
109
+ });
110
+
111
+ test("gate still fires: role needing undeclared cap on a harness that lacks it", () => {
112
+ // opencode does not declare dynamic_model_at_spawn (PR #18588)
113
+ const def: AgentDefinition = {
114
+ name: "bad",
115
+ roles: [
116
+ {
117
+ slug: "worker",
118
+ tier: "worker",
119
+ requiredCapabilities: ["dynamic_model_at_spawn"],
120
+ },
121
+ ],
122
+ };
123
+ const v = checkTruthfulness(def, "opencode");
124
+ assert.equal(v.length, 1);
125
+ const [violation] = v;
126
+ assert.ok(violation);
127
+ // Narrow to CapabilityViolation: this def has no model binding, so the only possible
128
+ // violation kind is the missing-capability one — assert that explicitly rather than
129
+ // reading `.capability` off the union type (that field does not exist on ModelViolation).
130
+ assert.ok(!isModelViolation(violation), "expected a capability violation, not a model one");
131
+ assert.equal(violation.capability, "dynamic_model_at_spawn");
132
+ assert.equal(violation.harness, "opencode");
133
+ });
134
+
135
+ test("planApply: paths for claude-code, opencode, droid (.factory/droids)", () => {
136
+ const portable: AgentDefinition = {
137
+ name: "portable",
138
+ roles: [
139
+ {
140
+ slug: "worker",
141
+ tier: "worker",
142
+ requiredCapabilities: [],
143
+ spawn: { allowed: false },
144
+ },
145
+ ],
146
+ };
147
+ const cc = planApply(portable, "claude-code", {});
148
+ assert.equal(cc.violations.length, 0);
149
+ assert.ok(cc.files.some((f) => f.relativePath === ".claude/agents/petbox-worker.md"));
150
+ // Namespacing rename: the pre-prefix name is carried alongside so the writer can clean up
151
+ // an OWNED leftover from before the rename (never a foreign file — see apply-write.ts).
152
+ assert.ok(
153
+ cc.files.some((f) => f.legacyRelativePath === ".claude/agents/worker.md"),
154
+ "legacyRelativePath must point at the bare pre-namespacing name",
155
+ );
156
+
157
+ const oc = planApply(portable, "opencode", {});
158
+ assert.equal(oc.violations.length, 0);
159
+ assert.ok(oc.files.every((f) => f.relativePath.startsWith(".opencode/agent/")));
160
+ assert.ok(oc.files.some((f) => f.relativePath === ".opencode/agent/petbox-worker.md"));
161
+
162
+ const dr = planApply(portable, "droid", {});
163
+ assert.equal(dr.violations.length, 0);
164
+ assert.ok(dr.files.every((f) => f.relativePath.startsWith(".factory/droids/")));
165
+ assert.ok(dr.files.some((f) => f.relativePath === ".factory/droids/petbox-worker.md"));
166
+ assert.ok(dr.files.some((f) => f.legacyRelativePath === ".factory/droids/worker.md"));
167
+ });
168
+
169
+ test("planApply DEFAULT writes all three harnesses including droid droids", () => {
170
+ for (const h of HARNESS_IDS) {
171
+ const plan = planApply(DEFAULT_AGENT_DEFINITION, h, {});
172
+ assert.equal(plan.violations.length, 0, formatViolations(plan.violations));
173
+ assert.ok(plan.files.length >= 5, `${h} should emit all default roles`);
174
+ }
175
+ const droid = planApply(DEFAULT_AGENT_DEFINITION, "droid", {
176
+ orchestrator: "custom:deepseek-v4-pro",
177
+ });
178
+ const orch = droid.files.find((f) => f.relativePath.includes("orchestrator"));
179
+ assert.ok(orch);
180
+ assert.match(orch!.content, /^---\nname: petbox-orchestrator\n/m);
181
+ assert.match(orch!.content, /model: custom:deepseek-v4-pro/);
182
+ assert.match(orch!.content, /mcpServers: \["petbox"\]/);
183
+ assert.ok(hasPetboxMarker(orch!.content), "every generated file carries the origin marker");
184
+ });
185
+
186
+ test("renderDroidMarkdown: unbound model is inherit; bound uses roles.json value", () => {
187
+ const role = DEFAULT_AGENT_DEFINITION.roles.find((r) => r.slug === "worker")!;
188
+ const inherit = renderDroidMarkdown(role);
189
+ assert.match(inherit, /name: petbox-worker/);
190
+ assert.match(inherit, /model: inherit/);
191
+
192
+ const pinned = renderDroidMarkdown(role, "claude-sonnet-4-5-20250929");
193
+ assert.match(pinned, /model: claude-sonnet-4-5-20250929/);
194
+ });
195
+
196
+ test("planApply: emits clean roles, skips only dirty ones", () => {
197
+ const def: AgentDefinition = {
198
+ name: "mixed",
199
+ roles: [
200
+ {
201
+ slug: "worker",
202
+ tier: "worker",
203
+ requiredCapabilities: [],
204
+ spawn: { allowed: false },
205
+ },
206
+ {
207
+ slug: "needs-dyn",
208
+ tier: "worker",
209
+ requiredCapabilities: ["dynamic_model_at_spawn"],
210
+ spawn: { allowed: false },
211
+ },
212
+ ],
213
+ };
214
+ // opencode lacks dynamic_model_at_spawn
215
+ const plan = planApply(def, "opencode", {});
216
+ assert.equal(plan.files.length, 1);
217
+ assert.ok(plan.files[0]!.relativePath.endsWith("worker.md"));
218
+ assert.deepEqual(plan.skippedRoles, ["needs-dyn"]);
219
+ assert.equal(plan.violations.length, 1);
220
+ assert.match(formatApplyBlocked(plan.violations, "opencode", plan.skippedRoles), /needs-dyn/);
221
+ });
222
+
223
+ test("planApply: bound model in claude-code frontmatter; unbound omits model (+warns)", () => {
224
+ const portable: AgentDefinition = {
225
+ name: "p",
226
+ roles: [{ slug: "worker", tier: "worker", requiredCapabilities: [] }],
227
+ };
228
+ // Must be an id Claude Code actually resolves — the old fixture used a provider-prefixed
229
+ // "anthropic/claude-sonnet-4", which is exactly the shape the model gate now rejects.
230
+ const withModel = planApply(portable, "claude-code", { worker: "sonnet" });
231
+ assert.equal(withModel.violations.length, 0);
232
+ const worker = withModel.files.find((f) => f.relativePath.endsWith("worker.md"));
233
+ assert.ok(worker);
234
+ assert.match(worker!.content, /^---\nname: petbox-worker\nmodel: sonnet\n/m);
235
+ assert.deepEqual(withModel.warnings, []);
236
+
237
+ const unbound = planApply(portable, "claude-code", {});
238
+ const body = unbound.files[0]!.content;
239
+ assert.match(body, /^---\nname: petbox-worker\n/m, "name: is always emitted");
240
+ assert.ok(!/^model:/m.test(body.split("---")[1] ?? ""), "no invented model");
241
+ // Unbound is legal (inherit) but must not be silent.
242
+ assert.equal(unbound.violations.length, 0);
243
+ assert.equal(unbound.warnings.length, 1);
244
+ assert.match(unbound.warnings[0]!, /inherit the session\/parent model/);
245
+ });
246
+
247
+ test("model gate: claude-code role bound to a droid id is BLOCKED (not written)", () => {
248
+ const portable: AgentDefinition = {
249
+ name: "p",
250
+ roles: [
251
+ { slug: "worker", tier: "worker", requiredCapabilities: [] },
252
+ { slug: "utility", tier: "utility", requiredCapabilities: [] },
253
+ ],
254
+ };
255
+ // The 2026-07-12 incident: droid ids copied into the claude-code block of roles.json.
256
+ const plan = planApply(portable, "claude-code", {
257
+ worker: "custom:DeepSeek-V4-Pro-0",
258
+ utility: "haiku",
259
+ });
260
+
261
+ assert.deepEqual(plan.skippedRoles, ["worker"]);
262
+ assert.ok(
263
+ !plan.files.some((f) => f.relativePath.endsWith("worker.md")),
264
+ "an unresolvable model must never reach .claude/agents/*.md",
265
+ );
266
+ assert.ok(plan.files.some((f) => f.relativePath.endsWith("utility.md")));
267
+
268
+ assert.equal(plan.violations.length, 1);
269
+ const v = plan.violations[0]!;
270
+ assert.ok(isModelViolation(v));
271
+ assert.equal(v.role, "worker");
272
+ assert.equal(v.harness, "claude-code");
273
+ assert.equal(v.model, "custom:DeepSeek-V4-Pro-0");
274
+ assert.ok(v.allowedModels.includes("sonnet"));
275
+
276
+ const msg = formatApplyBlocked(plan.violations, plan.harness, plan.skippedRoles);
277
+ assert.match(msg, /worker/);
278
+ assert.match(msg, /custom:DeepSeek-V4-Pro-0/);
279
+ assert.match(msg, /claude-code/);
280
+ // Revised 2026-07-13: the gate no longer claims CC silently inherits on a bad id (a live
281
+ // measurement disproved that — CC fails LOUD at runtime). The message now says the id looks
282
+ // like another harness's, not this one's.
283
+ assert.match(msg, /looks like ANOTHER harness's model id/);
284
+ assert.doesNotMatch(msg, /SILENTLY inherit/);
285
+ assert.match(msg, /Known claude-code aliases: .*sonnet/);
286
+
287
+ // apply's exit contract: a blocked role ⇒ non-zero (3), same as a capability violation.
288
+ const hadTruthfulnessBlock = plan.violations.length > 0;
289
+ assert.equal(classifyApplyExit({ hadTruthfulnessBlock }), WIRE_EXIT.truthfulness);
290
+ assert.notEqual(WIRE_EXIT.truthfulness, 0);
291
+ });
292
+
293
+ test("model gate tier 1 (known): every claude-code alias in the live roster classifies known", () => {
294
+ // .claude/agents/*.md of this repo (verified 2026-07-12): opus/sonnet/haiku/fable.
295
+ for (const m of ["opus", "sonnet", "haiku", "fable", "inherit", "OPUS"]) {
296
+ assert.equal(classifyModel("claude-code", m), "known", `${m} must be known`);
297
+ assert.equal(isResolvableModel("claude-code", m), true, `${m} must resolve`);
298
+ }
299
+ assert.ok(allowedModels("claude-code")!.length > 0);
300
+ });
301
+
302
+ test("model gate tier 2 (unknown, non-blocking): shape-valid claude-* ids not on the alias list warn, never block", () => {
303
+ // Revised 2026-07-13 (model-gate-revision-premise-falsified): a live measurement disproved
304
+ // the "CC silently inherits on a bad id" premise the old closed list was built on, and showed
305
+ // CC fails LOUD at runtime instead. A concrete Anthropic id — real (claude-opus-4-8, incl. the
306
+ // 1M-context suffix form) or a plain TYPO (claude-sonnet-4.6) — is now "unknown": shape-valid,
307
+ // not on the small known-alias list, NOT blocked. The gate can no longer tell a typo from a
308
+ // real id it just hasn't heard of yet; that tradeoff is deliberate (see harness-models.ts).
309
+ for (const m of ["claude-opus-4-8", "claude-sonnet-5", "claude-opus-4-8[1m]", "claude-sonnet-4.6"]) {
310
+ assert.equal(classifyModel("claude-code", m), "unknown", `${m} must be unknown (shape-valid, unlisted)`);
311
+ assert.equal(isResolvableModel("claude-code", m), true, `${m} must not be blocked`);
312
+ }
313
+ });
314
+
315
+ test("model gate tier 3 (foreign, blocking): another harness's id shape, or no recognizable shape, is refused", () => {
316
+ for (const m of [
317
+ "custom:DeepSeek-V4-Pro-0", // droid BYOK scheme
318
+ "custom:Qwen3.7-Max-[1M-ctx-·-orchestrator]-0",
319
+ "deepseek/deepseek-v4-pro", // opencode provider/model shape
320
+ "opencode/some-model",
321
+ "anthropic/claude-sonnet-4", // provider-prefixed — still opencode's shape, not CC's
322
+ "gpt-5", // no recognizable shape at all — not claude-*, not provider/model or scheme:id
323
+ ]) {
324
+ assert.equal(classifyModel("claude-code", m), "foreign", `${m} must be foreign`);
325
+ assert.equal(isResolvableModel("claude-code", m), false, `${m} must NOT resolve`);
326
+ }
327
+ });
328
+
329
+ test("model gate: planApply WRITES a role bound to an unknown-tier model, with a non-blocking warning", () => {
330
+ // The whole point of the revision: a real-but-unlisted id (e.g. a future claude-opus-5) must
331
+ // not be blocked the way the old closed list would have blocked it.
332
+ const portable: AgentDefinition = {
333
+ name: "p",
334
+ roles: [{ slug: "worker", tier: "worker", requiredCapabilities: [] }],
335
+ };
336
+ const plan = planApply(portable, "claude-code", { worker: "claude-opus-5" });
337
+ assert.equal(plan.violations.length, 0, formatViolations(plan.violations));
338
+ assert.deepEqual(plan.skippedRoles, []);
339
+ const worker = plan.files.find((f) => f.relativePath.endsWith("worker.md"));
340
+ assert.ok(worker, "unknown-tier model must still be written, not blocked");
341
+ assert.match(worker!.content, /model: claude-opus-5/);
342
+ assert.equal(plan.warnings.length, 1);
343
+ assert.match(plan.warnings[0]!, /not on the harness's known-alias list/);
344
+ assert.match(plan.warnings[0]!, /fails LOUD at runtime/);
345
+
346
+ // Same fact via the pure helper directly.
347
+ const role = portable.roles[0]!;
348
+ const warning = modelShapeWarning(role, "claude-code", "claude-opus-5");
349
+ assert.ok(warning);
350
+ assert.equal(modelShapeWarning(role, "claude-code", "sonnet"), null, "known tier warns nothing");
351
+ assert.equal(
352
+ modelShapeWarning(role, "claude-code", "custom:DeepSeek-V4-Pro-0"),
353
+ null,
354
+ "foreign tier is a violation, not a warning — modelShapeWarning stays silent on it",
355
+ );
356
+ });
357
+
358
+ test("model gate: droid/opencode id spaces stay open (no invented allow-list)", () => {
359
+ // Both resolve ids against a local/provider registry — the kit makes no claim, so a real
360
+ // binding like custom:DeepSeek-V4-Pro-0 or deepseek/deepseek-v4-pro must NOT be blocked.
361
+ assert.equal(allowedModels("droid"), null);
362
+ assert.equal(allowedModels("opencode"), null);
363
+ assert.equal(isResolvableModel("droid", "custom:DeepSeek-V4-Pro-0"), true);
364
+ assert.equal(isResolvableModel("opencode", "deepseek/deepseek-v4-pro"), true);
365
+
366
+ const droid = planApply(DEFAULT_AGENT_DEFINITION, "droid", {
367
+ worker: "custom:DeepSeek-V4-Pro-0",
368
+ });
369
+ assert.equal(droid.violations.length, 0);
370
+ const oc = planApply(DEFAULT_AGENT_DEFINITION, "opencode", {
371
+ worker: "deepseek/deepseek-v4-pro",
372
+ });
373
+ assert.equal(oc.violations.length, 0);
374
+ });
375
+
376
+ test("checkTruthfulness (doctor path) also gates the local model binding", () => {
377
+ const clean = checkTruthfulness(DEFAULT_AGENT_DEFINITION, "claude-code", {
378
+ orchestrator: "opus",
379
+ worker: "sonnet",
380
+ utility: "haiku",
381
+ reserve: "fable",
382
+ explore: "haiku",
383
+ });
384
+ assert.deepEqual(clean, [], formatViolations(clean));
385
+
386
+ const dirty = checkTruthfulness(DEFAULT_AGENT_DEFINITION, "claude-code", {
387
+ worker: "custom:DeepSeek-V4-Pro-0",
388
+ });
389
+ assert.equal(dirty.length, 1);
390
+ assert.ok(isModelViolation(dirty[0]!));
391
+ assert.equal((dirty[0] as ModelViolation).role, "worker");
392
+ });
393
+
394
+ test("planOpencodeApply: bound model in frontmatter; unbound omits model", () => {
395
+ const withModel = planOpencodeApply(DEFAULT_AGENT_DEFINITION, {
396
+ worker: "deepseek/deepseek-v4-pro",
397
+ });
398
+ assert.equal(withModel.violations.length, 0);
399
+ const worker = withModel.files.find((f) => f.relativePath.endsWith("worker.md"));
400
+ assert.ok(worker);
401
+ assert.match(worker!.content, /^---\nname: petbox-worker\nmodel: deepseek\/deepseek-v4-pro\n/m);
402
+ });
403
+
404
+ test("renderOpencodeAgentMarkdown explore body does not forbid inheritance", () => {
405
+ const explore = DEFAULT_AGENT_DEFINITION.roles.find((r) => r.slug === "explore")!;
406
+ const md = renderOpencodeAgentMarkdown(explore);
407
+ assert.match(md, /Model inheritance/i);
408
+ assert.ok(!md.toLowerCase().includes("inheritance forbidden"));
409
+ });
410
+
411
+ test("validateAgentDefinition rejects role.model and nested model", () => {
412
+ assert.throws(
413
+ () =>
414
+ validateAgentDefinition({
415
+ name: "x",
416
+ roles: [
417
+ {
418
+ slug: "w",
419
+ tier: "worker",
420
+ requiredCapabilities: [],
421
+ // @ts-expect-error intentional
422
+ model: "should-not-be-here",
423
+ },
424
+ ],
425
+ }),
426
+ /model is not allowed/,
427
+ );
428
+ assert.throws(
429
+ () =>
430
+ validateAgentDefinition({
431
+ name: "x",
432
+ // @ts-expect-error intentional
433
+ model: "root-bad",
434
+ roles: [{ slug: "w", tier: "worker", requiredCapabilities: [] }],
435
+ }),
436
+ /model is not allowed/,
437
+ );
438
+ assert.throws(
439
+ () =>
440
+ validateAgentDefinition({
441
+ name: "x",
442
+ roles: [
443
+ {
444
+ slug: "w",
445
+ tier: "worker",
446
+ requiredCapabilities: [],
447
+ spawn: {
448
+ allowed: false,
449
+ // @ts-expect-error intentional
450
+ model: "nested-bad",
451
+ },
452
+ },
453
+ ],
454
+ }),
455
+ /model is not allowed/,
456
+ );
457
+ });
458
+
459
+ test("renderAgentMarkdown: generated claude-code role file carries name: as first frontmatter key", () => {
460
+ const role = DEFAULT_AGENT_DEFINITION.roles.find((r) => r.slug === "worker")!;
461
+ const md = renderAgentMarkdown(role);
462
+ assert.match(md, /^---\nname: petbox-worker\n/, "name: must be the first frontmatter key, namespaced");
463
+ // No tools: key — omission means the file inherits the harness's full tool set,
464
+ // including MCP (that is the intended policy; see harness-capabilities.ts).
465
+ assert.ok(!/^tools:/m.test(md), "must not emit a tools: key");
466
+ assert.ok(hasPetboxMarker(md), "generated file must carry the origin marker");
467
+ });
468
+
469
+ test("emitted agent names are namespaced petbox-<slug> across the whole default roster, every harness", () => {
470
+ // chore: petbox-namespaced-agent-names — role.slug (internal) stays bare; only the render
471
+ // is prefixed. Assert it holds for every role x harness, not just worker.
472
+ for (const harness of HARNESS_IDS) {
473
+ const plan = planApply(DEFAULT_AGENT_DEFINITION, harness, {});
474
+ assert.equal(plan.violations.length, 0, formatViolations(plan.violations));
475
+ for (const role of DEFAULT_AGENT_DEFINITION.roles) {
476
+ const file = plan.files.find((f) => f.relativePath.includes(`petbox-${role.slug}`));
477
+ assert.ok(file, `${harness}: expected a petbox-${role.slug} file`);
478
+ assert.match(file!.content, new RegExp(`name: petbox-${role.slug}\\b`));
479
+ assert.ok(hasPetboxMarker(file!.content));
480
+ // Never emit the bare, unprefixed name as the CURRENT (non-legacy) path.
481
+ assert.ok(
482
+ !plan.files.some((f) => f.relativePath.endsWith(`/${role.slug}.md`)),
483
+ `${harness}: must not also emit an unprefixed ${role.slug}.md as a current artifact`,
484
+ );
485
+ }
486
+ }
487
+ });
488
+
489
+ test("orchestrator body's spawn/escalation prose names the NAMESPACED target roles, not bare slugs", () => {
490
+ // protocol.ts:62-style bug, generalized: any prose naming a spawn/escalation target must
491
+ // render the computed identity — never role.slug directly — or a generated file points at
492
+ // a subagent_type that does not exist on disk.
493
+ const orchestrator = DEFAULT_AGENT_DEFINITION.roles.find((r) => r.slug === "orchestrator")!;
494
+ const md = renderAgentMarkdown(orchestrator);
495
+ assert.match(md, /Target roles:.*`petbox-worker`/);
496
+ assert.match(md, /Target roles:.*`petbox-utility`/);
497
+ assert.ok(!/Target roles:.*`worker`[,.]/.test(md), "must not list the bare slug");
498
+
499
+ const worker = DEFAULT_AGENT_DEFINITION.roles.find((r) => r.slug === "worker")!;
500
+ const workerMd = renderAgentMarkdown(worker);
501
+ assert.match(workerMd, /Escalation[\s\S]*`petbox-orchestrator`/);
502
+ });
503
+
504
+ test("renderAgentMarkdown / renderDroidMarkdown: role.notes land in the rendered body", () => {
505
+ const role = DEFAULT_AGENT_DEFINITION.roles.find((r) => r.slug === "orchestrator")!;
506
+ assert.ok(role.notes && role.notes.length > 0);
507
+ const cc = renderAgentMarkdown(role);
508
+ assert.ok(cc.includes(role.notes!), "claude-code/opencode body must include role.notes");
509
+ const droid = renderDroidMarkdown(role);
510
+ assert.ok(droid.includes(role.notes!), "droid body must include role.notes");
511
+ });
512
+
513
+ test("leaf role body states the no-spawn rule imperatively, independent of harness tool grants", () => {
514
+ const worker = DEFAULT_AGENT_DEFINITION.roles.find((r) => r.slug === "worker")!;
515
+ assert.equal(worker.spawn?.allowed, false);
516
+ const md = renderAgentMarkdown(worker);
517
+ assert.match(md, /MUST NOT spawn subagents/i);
518
+ });
519
+
520
+ test("claude-code declares mcp_subagent (verified live 2026-07-12)", () => {
521
+ assert.equal(hasCapability("claude-code", "mcp_subagent"), true);
522
+ });
523
+
524
+ test("DEFAULT reserve notes: offline fallback does not invert the live semantics (bug @80 item 5)", () => {
525
+ // The offline fallback once taught the OPPOSITE rule: "Heavy reasoning / architecture
526
+ // review / stuck points only" reads as "call reserve when the work is heavy" — the live
527
+ // server definition says the exact reverse (hard work is a model escalation on a worker;
528
+ // reserve is for being STUCK, never merely for difficulty). Guard both directions so a
529
+ // hand-copied fallback cannot silently drift back into the inverted phrasing.
530
+ const reserve = DEFAULT_AGENT_DEFINITION.roles.find((r) => r.slug === "reserve")!;
531
+ const notes = reserve.notes ?? "";
532
+ assert.match(notes, /STUCK/, "reserve notes must state the stuck-trigger explicitly");
533
+ assert.ok(
534
+ !/heavy reasoning/i.test(notes),
535
+ "must not reintroduce 'heavy reasoning ... only' framing (the inverted rule)",
536
+ );
537
+ assert.ok(
538
+ /not merely when the work is hard/i.test(notes),
539
+ "must explicitly rule out 'hard work' as a trigger for reserve",
540
+ );
541
+ // requiredCapabilities must target the subagent surface, not mcp_main_session — reserve
542
+ // exists only as a subagent (finding 4 of the same node, closed alongside this fallback).
543
+ assert.deepEqual([...reserve.requiredCapabilities], ["mcp_subagent"]);
544
+ });