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,409 @@
1
+ // Fetch a portable agent definition from PetBox for petbox-wire apply.
2
+ //
3
+ // Contract (server: AgentDefsApi):
4
+ // GET {baseUrl}/api/{projectKey}/agent-defs/{key}
5
+ // header X-Api-Key; scope agents:read
6
+ // 200 → { key, version, definition: { name, roles: [...] }, created?, updated? }
7
+ //
8
+ // Polarity (definition-offline-lkg / wiring-memory-canon):
9
+ // Server is authoritative; disk is LKG replica under ~/.petbox/cache/<project>.agent-def.json.
10
+ // roles.json is the opposite polarity (disk authoritative) — not touched here.
11
+ //
12
+ // Resolution order on apply:
13
+ // 1. live server fetch (unless --offline)
14
+ // 2. LKG cache from last successful fetch (with explicit staleness mark)
15
+ // 3. DEFAULT_AGENT_DEFINITION only when no cache exists (fresh machine)
16
+ //
17
+ // Best-effort: network / 404 / auth / bad JSON / timeout → null from fetch (never throws).
18
+ //
19
+ // Plain TS for native node type-stripping: zero deps.
20
+
21
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import type { AgentDefinition, AgentRole, RoleEscalation, RoleSpawn } from "./agent-definition.ts";
25
+ import { DEFAULT_AGENT_DEFINITION, validateAgentDefinition } from "./agent-definition.ts";
26
+
27
+ export const DEFAULT_DEFINITION_KEY = "default";
28
+ export const AGENT_DEF_FETCH_TIMEOUT_MS = 8000;
29
+
30
+ /** Shown when apply uses LKG cache instead of a live server fetch (definition-offline-lkg). */
31
+ export const AGENT_DEF_STALE_MARKER =
32
+ "⚠ Agent definition is from the local LKG cache (PetBox unreachable) — may be stale.";
33
+
34
+ /** Successful server fetch: key + version envelope around a portable definition. */
35
+ export type FetchedAgentDefinition = {
36
+ readonly key: string;
37
+ readonly version: number;
38
+ readonly definition: AgentDefinition;
39
+ };
40
+
41
+ export type FetchAgentDefinitionOptions = {
42
+ readonly baseUrl: string;
43
+ readonly projectKey: string;
44
+ readonly apiKey: string;
45
+ /** Document key; defaults to `default`. */
46
+ readonly definitionKey?: string;
47
+ readonly timeoutMs?: number;
48
+ /** Injected for tests; defaults to global fetch. */
49
+ readonly fetchImpl?: typeof fetch;
50
+ };
51
+
52
+ export type AgentDefCacheRecord = {
53
+ readonly key: string;
54
+ readonly version: number;
55
+ readonly fetchedAt: string;
56
+ readonly definition: AgentDefinition;
57
+ };
58
+
59
+ /** Where a resolved definition came from (for logs / tests). */
60
+ export type AgentDefSource = "server" | "lkg" | "default";
61
+
62
+ export type ResolvedAgentDefinition = {
63
+ readonly definition: AgentDefinition;
64
+ readonly source: AgentDefSource;
65
+ /** True when source === "lkg" (explicit staleness). */
66
+ readonly stale: boolean;
67
+ readonly key?: string;
68
+ readonly version?: number;
69
+ /** Human-facing line for apply logs when stale. */
70
+ readonly staleMarker?: string;
71
+ };
72
+
73
+ export function agentDefCacheDir(homeDir: string = homedir()): string {
74
+ return join(homeDir, ".petbox", "cache");
75
+ }
76
+
77
+ /** Path: ~/.petbox/cache/<project>.agent-def.json (project may contain $ etc.). */
78
+ export function agentDefCachePath(projectKey: string, homeDir: string = homedir()): string {
79
+ // Same sanitization style as canon: use project key as filename stem.
80
+ const stem = String(projectKey).trim() || "unknown";
81
+ return join(agentDefCacheDir(homeDir), `${stem}.agent-def.json`);
82
+ }
83
+
84
+ /**
85
+ * Pure JSON → definition mapping. Returns null on any invalid/incomplete shape.
86
+ * Tolerates extra fields; rejects role.model (portable roster only).
87
+ */
88
+ export function parseAgentDefinitionResponse(json: unknown): FetchedAgentDefinition | null {
89
+ if (!json || typeof json !== "object") return null;
90
+ const root = json as Record<string, unknown>;
91
+
92
+ const key = typeof root["key"] === "string" && root["key"].trim() ? root["key"].trim() : null;
93
+ const version = parseVersion(root["version"]);
94
+ if (key === null || version === null) return null;
95
+
96
+ const defRaw = root["definition"];
97
+ if (!defRaw || typeof defRaw !== "object") return null;
98
+ const def = defRaw as Record<string, unknown>;
99
+
100
+ const name = typeof def["name"] === "string" && def["name"].trim() ? def["name"].trim() : null;
101
+ if (name === null) return null;
102
+ if (!Array.isArray(def["roles"]) || def["roles"].length === 0) return null;
103
+
104
+ const roles: AgentRole[] = [];
105
+ for (const item of def["roles"]) {
106
+ const role = mapRole(item);
107
+ if (role === null) return null;
108
+ roles.push(role);
109
+ }
110
+
111
+ return {
112
+ key,
113
+ version,
114
+ definition: { name, roles },
115
+ };
116
+ }
117
+
118
+ /**
119
+ * GET the named agent definition. Returns the mapped definition on 200 + valid body;
120
+ * null on any failure (network, timeout, non-OK status, bad JSON, invalid shape).
121
+ * Never throws. Does NOT write LKG — caller uses writeAgentDefCache on success.
122
+ */
123
+ export async function fetchAgentDefinition(
124
+ opts: FetchAgentDefinitionOptions,
125
+ ): Promise<FetchedAgentDefinition | null> {
126
+ try {
127
+ const base = String(opts.baseUrl ?? "").replace(/\/+$/, "");
128
+ const project = String(opts.projectKey ?? "").trim();
129
+ const apiKey = String(opts.apiKey ?? "").trim();
130
+ const defKey = (opts.definitionKey?.trim() || DEFAULT_DEFINITION_KEY).trim();
131
+ if (!base || !project || !apiKey || !defKey) return null;
132
+
133
+ const timeoutMs =
134
+ typeof opts.timeoutMs === "number" && opts.timeoutMs > 0
135
+ ? opts.timeoutMs
136
+ : AGENT_DEF_FETCH_TIMEOUT_MS;
137
+ const fetchFn = opts.fetchImpl ?? fetch;
138
+
139
+ const ctrl = new AbortController();
140
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
141
+ try {
142
+ const url = `${base}/api/${encodeURIComponent(project)}/agent-defs/${encodeURIComponent(defKey)}`;
143
+ const resp = await fetchFn(url, {
144
+ method: "GET",
145
+ // Connection: close — no lingering keep-alive socket after a short-lived hook
146
+ // process's single request (see canon.ts's fetchCanon for the full rationale).
147
+ headers: { "X-Api-Key": apiKey, Connection: "close" },
148
+ signal: ctrl.signal,
149
+ });
150
+ if (!resp.ok) return null;
151
+ const body = (await resp.json().catch(() => null)) as unknown;
152
+ return parseAgentDefinitionResponse(body);
153
+ } finally {
154
+ clearTimeout(timer);
155
+ }
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ /** Persist LKG after a successful fetch. Never throws. */
162
+ export function writeAgentDefCache(
163
+ projectKey: string,
164
+ fetched: FetchedAgentDefinition,
165
+ homeDir: string = homedir(),
166
+ now: () => string = () => new Date().toISOString(),
167
+ ): void {
168
+ try {
169
+ const dir = agentDefCacheDir(homeDir);
170
+ mkdirSync(dir, { recursive: true });
171
+ const record: AgentDefCacheRecord = {
172
+ key: fetched.key,
173
+ version: fetched.version,
174
+ fetchedAt: now(),
175
+ definition: fetched.definition,
176
+ };
177
+ writeFileSync(agentDefCachePath(projectKey, homeDir), JSON.stringify(record, null, 2) + "\n", "utf8");
178
+ } catch {
179
+ // best-effort
180
+ }
181
+ }
182
+
183
+ /** Read LKG cache. Returns null if missing/corrupt. Never throws. */
184
+ export function readAgentDefCache(
185
+ projectKey: string,
186
+ homeDir: string = homedir(),
187
+ ): AgentDefCacheRecord | null {
188
+ try {
189
+ const path = agentDefCachePath(projectKey, homeDir);
190
+ if (!existsSync(path)) return null;
191
+ const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
192
+ if (!raw || typeof raw !== "object") return null;
193
+ const r = raw as Record<string, unknown>;
194
+ const key = typeof r["key"] === "string" && r["key"].trim() ? r["key"].trim() : null;
195
+ const version = parseVersion(r["version"]);
196
+ const fetchedAt = typeof r["fetchedAt"] === "string" ? r["fetchedAt"] : "";
197
+ if (key === null || version === null) return null;
198
+ const def = r["definition"];
199
+ if (!def || typeof def !== "object") return null;
200
+ // Re-parse via envelope so shape validation matches server responses.
201
+ const mapped = parseAgentDefinitionResponse({
202
+ key,
203
+ version,
204
+ definition: def,
205
+ });
206
+ if (!mapped) return null;
207
+ try {
208
+ validateAgentDefinition(mapped.definition);
209
+ } catch {
210
+ return null;
211
+ }
212
+ return {
213
+ key: mapped.key,
214
+ version: mapped.version,
215
+ fetchedAt,
216
+ definition: mapped.definition,
217
+ };
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ export type ResolveAgentDefinitionOptions = {
224
+ readonly offline: boolean;
225
+ readonly definitionKey: string;
226
+ /** When set, used for cache path + fetch. When unset offline/no-registry → default. */
227
+ readonly projectKey?: string;
228
+ readonly baseUrl?: string;
229
+ readonly apiKey?: string;
230
+ readonly homeDir?: string;
231
+ readonly fetchImpl?: typeof fetch;
232
+ readonly timeoutMs?: number;
233
+ };
234
+
235
+ /**
236
+ * Resolve definition for apply: server → LKG → DEFAULT.
237
+ * --offline skips network, still prefers LKG over DEFAULT.
238
+ */
239
+ export async function resolveAgentDefinitionWithLkg(
240
+ opts: ResolveAgentDefinitionOptions,
241
+ ): Promise<ResolvedAgentDefinition> {
242
+ const homeDir = opts.homeDir ?? homedir();
243
+ const projectKey = opts.projectKey?.trim() ?? "";
244
+ const defKey = opts.definitionKey.trim() || DEFAULT_DEFINITION_KEY;
245
+
246
+ if (!opts.offline && projectKey && opts.baseUrl && opts.apiKey) {
247
+ const fetched = await fetchAgentDefinition({
248
+ baseUrl: opts.baseUrl,
249
+ projectKey,
250
+ apiKey: opts.apiKey,
251
+ definitionKey: defKey,
252
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
253
+ ...(opts.fetchImpl !== undefined ? { fetchImpl: opts.fetchImpl } : {}),
254
+ });
255
+ if (fetched) {
256
+ writeAgentDefCache(projectKey, fetched, homeDir);
257
+ return {
258
+ definition: fetched.definition,
259
+ source: "server",
260
+ stale: false,
261
+ key: fetched.key,
262
+ version: fetched.version,
263
+ };
264
+ }
265
+ }
266
+
267
+ // LKG before DEFAULT (definition-offline-lkg).
268
+ if (projectKey) {
269
+ const cached = readAgentDefCache(projectKey, homeDir);
270
+ if (cached) {
271
+ // Prefer matching key when possible; still use cache if only one project doc was stored.
272
+ if (cached.key === defKey || defKey === DEFAULT_DEFINITION_KEY) {
273
+ return {
274
+ definition: cached.definition,
275
+ source: "lkg",
276
+ stale: true,
277
+ key: cached.key,
278
+ version: cached.version,
279
+ staleMarker: AGENT_DEF_STALE_MARKER,
280
+ };
281
+ }
282
+ }
283
+ }
284
+
285
+ return {
286
+ definition: DEFAULT_AGENT_DEFINITION,
287
+ source: "default",
288
+ stale: false,
289
+ };
290
+ }
291
+
292
+ /** Minimal shape SessionStart injectors already have from registry.ts's resolveProject. */
293
+ export type SessionProjectRef = {
294
+ readonly project: string;
295
+ readonly baseUrl: string;
296
+ readonly apiKey: string;
297
+ };
298
+
299
+ /**
300
+ * Resolve the agent definition for a SessionStart banner — the SAME server → LKG → DEFAULT
301
+ * order `apply` uses (resolveAgentDefinitionWithLkg), pinned to the default definition key.
302
+ * This closes the asymmetry where subagent role files already got server-authored notes via
303
+ * `apply` but the main-loop banner (protocol.ts) was stuck on the hard-coded built-in default.
304
+ *
305
+ * Bounded by AGENT_DEF_FETCH_TIMEOUT_MS (same budget as the canon fetch already on this path,
306
+ * see canon.ts) so a wedged network never turns into an unbounded session-start stall — the
307
+ * abort falls through to the LKG cache, then the built-in default, never a crash or blank
308
+ * banner. Callers SHOULD run this concurrently with fetchCanonBlock (e.g. via Promise.all)
309
+ * rather than awaiting it first, so the two 8s budgets don't stack serially.
310
+ */
311
+ export async function resolveAgentDefinitionForSession(
312
+ resolved: SessionProjectRef,
313
+ opts?: { homeDir?: string; fetchImpl?: typeof fetch; timeoutMs?: number },
314
+ ): Promise<ResolvedAgentDefinition> {
315
+ return resolveAgentDefinitionWithLkg({
316
+ offline: false,
317
+ definitionKey: DEFAULT_DEFINITION_KEY,
318
+ projectKey: resolved.project,
319
+ baseUrl: resolved.baseUrl,
320
+ apiKey: resolved.apiKey,
321
+ ...(opts?.homeDir !== undefined ? { homeDir: opts.homeDir } : {}),
322
+ ...(opts?.fetchImpl !== undefined ? { fetchImpl: opts.fetchImpl } : {}),
323
+ ...(opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
324
+ });
325
+ }
326
+
327
+ function parseVersion(v: unknown): number | null {
328
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0) return Math.trunc(v);
329
+ if (typeof v === "string" && v.trim()) {
330
+ const n = Number(v);
331
+ if (Number.isFinite(n) && n >= 0) return Math.trunc(n);
332
+ }
333
+ return null;
334
+ }
335
+
336
+ function mapRole(item: unknown): AgentRole | null {
337
+ if (!item || typeof item !== "object") return null;
338
+ const r = item as Record<string, unknown>;
339
+
340
+ // Portable definitions must not carry model binding.
341
+ if ("model" in r) return null;
342
+
343
+ const slug = typeof r["slug"] === "string" && r["slug"].trim() ? r["slug"].trim() : null;
344
+ const tier = typeof r["tier"] === "string" && r["tier"].trim() ? r["tier"].trim() : null;
345
+ if (slug === null || tier === null) return null;
346
+
347
+ if (!Array.isArray(r["requiredCapabilities"])) return null;
348
+ const requiredCapabilities: string[] = [];
349
+ for (const c of r["requiredCapabilities"]) {
350
+ if (typeof c !== "string") return null;
351
+ requiredCapabilities.push(c);
352
+ }
353
+
354
+ const spawn = mapSpawn(r["spawn"]);
355
+ if (spawn === "invalid") return null;
356
+ const escalation = mapEscalation(r["escalation"]);
357
+ if (escalation === "invalid") return null;
358
+
359
+ const notes =
360
+ typeof r["notes"] === "string" && r["notes"].trim() ? r["notes"].trim() : undefined;
361
+
362
+ const role: AgentRole = {
363
+ slug,
364
+ tier,
365
+ requiredCapabilities,
366
+ ...(spawn ? { spawn } : {}),
367
+ ...(escalation ? { escalation } : {}),
368
+ ...(notes !== undefined ? { notes } : {}),
369
+ };
370
+ return role;
371
+ }
372
+
373
+ function mapSpawn(raw: unknown): RoleSpawn | null | "invalid" {
374
+ if (raw === undefined || raw === null) return null;
375
+ if (typeof raw !== "object") return "invalid";
376
+ const s = raw as Record<string, unknown>;
377
+ if (typeof s["allowed"] !== "boolean") return "invalid";
378
+ let allowedRoles: string[] | undefined;
379
+ if (s["allowedRoles"] !== undefined && s["allowedRoles"] !== null) {
380
+ if (!Array.isArray(s["allowedRoles"])) return "invalid";
381
+ allowedRoles = [];
382
+ for (const x of s["allowedRoles"]) {
383
+ if (typeof x !== "string") return "invalid";
384
+ allowedRoles.push(x);
385
+ }
386
+ }
387
+ return allowedRoles !== undefined
388
+ ? { allowed: s["allowed"], allowedRoles }
389
+ : { allowed: s["allowed"] };
390
+ }
391
+
392
+ function mapEscalation(raw: unknown): RoleEscalation | null | "invalid" {
393
+ if (raw === undefined || raw === null) return null;
394
+ if (typeof raw !== "object") return "invalid";
395
+ const e = raw as Record<string, unknown>;
396
+ if (typeof e["available"] !== "boolean") return "invalid";
397
+ let targets: string[] | undefined;
398
+ if (e["targets"] !== undefined && e["targets"] !== null) {
399
+ if (!Array.isArray(e["targets"])) return "invalid";
400
+ targets = [];
401
+ for (const x of e["targets"]) {
402
+ if (typeof x !== "string") return "invalid";
403
+ targets.push(x);
404
+ }
405
+ }
406
+ return targets !== undefined
407
+ ? { available: e["available"], targets }
408
+ : { available: e["available"] };
409
+ }
@@ -0,0 +1,210 @@
1
+ // Portable agent definition (roster only — no model binding).
2
+ //
3
+ // Spec (agent-definition-as-data, agent-definition-locality):
4
+ // - Roles carry slug, tier, requiredCapabilities, spawn, escalation.
5
+ // - model is NEVER part of this document (local binding lives in roles.json).
6
+ // - Built-in DEFAULT_AGENT_DEFINITION ships with the kit for offline compile;
7
+ // apply tries server fetch first (agent-def-fetch.ts) and falls back here.
8
+ //
9
+ // Plain TS for native node type-stripping: zero deps.
10
+
11
+ import type { Capability } from "./harness-capabilities.ts";
12
+
13
+ export type RoleSpawn = {
14
+ readonly allowed: boolean;
15
+ readonly allowedRoles?: ReadonlyArray<string>;
16
+ };
17
+
18
+ export type RoleEscalation = {
19
+ readonly available: boolean;
20
+ readonly targets?: ReadonlyArray<string>;
21
+ };
22
+
23
+ export type AgentRole = {
24
+ readonly slug: string;
25
+ readonly tier: string;
26
+ /** Harness capabilities this role needs; empty = no harness-specific needs. */
27
+ readonly requiredCapabilities: ReadonlyArray<Capability | string>;
28
+ readonly spawn?: RoleSpawn;
29
+ readonly escalation?: RoleEscalation;
30
+ /**
31
+ * Optional free-text notes rendered into per-role artifacts.
32
+ * Used for harness-aware caveats (e.g. explore model inheritance) without
33
+ * putting lies into the shared protocol block.
34
+ */
35
+ readonly notes?: string;
36
+ };
37
+
38
+ export type AgentDefinition = {
39
+ readonly name: string;
40
+ readonly roles: ReadonlyArray<AgentRole>;
41
+ };
42
+
43
+ /**
44
+ * The namespaced identity used for every rendered agent artifact: frontmatter `name:`, the
45
+ * emitted file's basename, and any prose that names a role as a spawn/escalation target
46
+ * (chore: petbox-namespaced-agent-names). `role.slug` stays the INTERNAL, unprefixed identity
47
+ * — the definition and `~/.petbox/roles.json` never change — only what apply RENDERS is
48
+ * namespaced. This is the single computation point: every renderer and prose injector must
49
+ * call this (or pass a bare slug through it) instead of interpolating role.slug/a slug string
50
+ * directly into anything user- or harness-facing, or the prefix drifts between call sites.
51
+ *
52
+ * Why: generated agents were occupying the most common user-agent names (`worker`, `explore`,
53
+ * ...) — colliding with a user's own agents, and shadowing Claude Code's built-in `Explore`
54
+ * agent under `.claude/agents/explore.md`. `petbox-<slug>` moves us into our own namespace.
55
+ */
56
+ export function emittedRoleName(roleOrSlug: { readonly slug: string } | string): string {
57
+ const slug = typeof roleOrSlug === "string" ? roleOrSlug : roleOrSlug.slug;
58
+ return `petbox-${slug}`;
59
+ }
60
+
61
+ /**
62
+ * Built-in portable roster for offline compile (petbox-wire doctor / apply).
63
+ * Includes `explore` so the roster matches harnesses that ship a built-in explore
64
+ * agent — with an explicit inheritance note (not a global "inheritance forbidden").
65
+ *
66
+ * Caps are honest for the roles: orchestrator needs mcp_main_session + spawn_subagents.
67
+ * Per harness-capabilities.ts, all three known harnesses (claude-code, opencode, droid)
68
+ * declare both, so DEFAULT passes truthfulness on every known harness today — droid in
69
+ * particular declares mcp_main_session, mcp_subagent, spawn_subagents, role_files,
70
+ * dynamic_model_at_spawn and hooks per Factory's docs. This is not guaranteed to hold for
71
+ * future/unknown harnesses; the gate (checkRoleTruthfulness) still blocks any role that
72
+ * claims a capability its target harness does not declare.
73
+ */
74
+ export const DEFAULT_AGENT_DEFINITION: AgentDefinition = {
75
+ name: "default",
76
+ roles: [
77
+ {
78
+ slug: "orchestrator",
79
+ tier: "orchestrator",
80
+ requiredCapabilities: ["mcp_main_session", "spawn_subagents"],
81
+ spawn: {
82
+ allowed: true,
83
+ allowedRoles: ["worker", "utility", "explore", "reserve"],
84
+ },
85
+ escalation: { available: true, targets: ["reserve"] },
86
+ notes:
87
+ "Main-loop role: plan, decompose, delegate, review, triage.\n\n" +
88
+ "1. **Delegate by DEFAULT.** Spawn a worker for anything beyond a trivial edit. Solo work is the exception you must justify.\n" +
89
+ "2. **ROLE and MODEL are two independent axes.** Role = what the agent is ALLOWED to do (spawn? edit files?). Model = how much thinking power it has. A worker on the strongest model is still a worker: a leaf that edits files. Reserve on any model still never edits files.\n" +
90
+ "3. **Model comes from the roster — do not pass one at spawn.** Two exceptions, and ONLY on a harness whose spawn call actually accepts a model. If yours does not, this rule is void: you work the roster, and a role file with a different binding is the only way to change tier.\n" +
91
+ " - **Security / authz work → the strongest tier you can name.** Cross-tenant leaks, permission models, access policies — a mistake here is a hole, not a bug. This is a rule about the KIND of task, not a guess about difficulty.\n" +
92
+ " - **Intrinsically hard work → a tier above the role's roster binding.** Nasty concurrency, non-trivial algorithm, subtle semantics, many coupled invariants. Do not send a cheap model to fail three times first.\n" +
93
+ " Name a TIER your harness accepts at spawn — never a bare model id. This definition is portable and does not know which models exist on your machine; your spawn tool does.\n" +
94
+ " Say why, one line, in the brief: `ESCALATION: <reason>`. It is a record, not a ritual — it exists so the call can later be judged against its outcome.\n" +
95
+ " **Being stuck is NOT a model escalation.** That is the reserve ROLE — see rule 4.\n" +
96
+ "4. **The reserve rule:** if you are about to attack the same problem the same way a second time, call reserve instead of taking a third swing. Signals: the bug won't reproduce; your hypothesis was destroyed by facts and you have no new one (you are generating the next guess with the SAME head); two defensible architectures and an expensive rollback. If it worked first try, reserve was not needed.\n" +
97
+ "5. **Never dictate a subagent's self-intro line.** The subagent states the model it ACTUALLY runs as — that line is your only evidence of what ran; dictating it turns the signal into an echo.\n" +
98
+ "6. **Search before re-deriving.** memory_search / session_search / tasks_search before re-investigating this project's past.\n" +
99
+ "7. **Respect the gates.** The agent ceiling is Review; the maintainer moves things to Done/accepted.",
100
+ },
101
+ {
102
+ slug: "worker",
103
+ tier: "worker",
104
+ // requiredCapabilities stays empty by design: worker is meant to stay portable
105
+ // even to a future/unknown harness without mcp_subagent. This is NOT a claim
106
+ // that MCP is unavailable to worker subagents on today's known harnesses —
107
+ // claude-code, opencode and droid all declare mcp_subagent (harness-capabilities.ts).
108
+ requiredCapabilities: [],
109
+ spawn: { allowed: false },
110
+ escalation: { available: true, targets: ["orchestrator"] },
111
+ notes:
112
+ "Scoped executor for ONE delegated task.\n\n" +
113
+ "1. **SELF-INTRO — your FIRST line, always:** `<the model you are actually running as> · worker`\n" +
114
+ " Name your OWN model. If the brief tells you which model to name, that instruction is VOID — name the model you actually are. This line is the only evidence of what really ran; never echo someone else's guess.\n" +
115
+ "2. **You are a LEAF.** Never spawn subagents, never delegate onward. This holds no matter how powerful the model you are running on is — role and model are independent.\n" +
116
+ "3. **Do ONLY the delegated task.** No scope expansion, no self-directed scouting, no fixing adjacent code. Ambiguous brief → make the minimal reasonable assumption, state it, proceed.\n" +
117
+ "4. **You DO have PetBox MCP.** Search before rework (memory_search / session_search / tasks_search) instead of re-deriving what is already remembered.\n" +
118
+ "5. **Verify empirically.** Measure, don't assert. Stay in your assigned worktree. Never push main or deploy unless the brief says so.\n" +
119
+ "6. **Stuck? Say so.** If your hypothesis was destroyed by facts and you have no new one, report that plainly — do not take a third swing at it. Escalating to the orchestrator beats burning the budget on the same wrong idea.\n" +
120
+ "7. **Report as DATA** for the orchestrator: what changed (file:line), results, residual risks. Not a human-facing essay.",
121
+ },
122
+ {
123
+ slug: "utility",
124
+ tier: "utility",
125
+ requiredCapabilities: [],
126
+ spawn: { allowed: false },
127
+ escalation: { available: true, targets: ["orchestrator"] },
128
+ notes:
129
+ "Fast simple work: search, summarize, mechanical edits.\n\n" +
130
+ "1. **SELF-INTRO — your FIRST line, always:** `<the model you are actually running as> · utility`\n" +
131
+ " Your own model, never one dictated by the brief.\n" +
132
+ "2. **You are a LEAF.** Never spawn subagents.\n" +
133
+ "3. **Escalate by REPORTING.** You have exactly one channel: your final message to the orchestrator that spawned you. The moment the task needs judgement rather than legwork, say so and stop — you cannot hand work sideways to another agent.",
134
+ },
135
+ {
136
+ slug: "reserve",
137
+ tier: "reserve",
138
+ // mcp_subagent, not mcp_main_session: reserve exists only as a subagent, so the
139
+ // capability the gate must check is the subagent MCP surface.
140
+ requiredCapabilities: ["mcp_subagent"],
141
+ spawn: { allowed: false },
142
+ escalation: { available: false },
143
+ notes:
144
+ "The second pair of eyes. Called when the orchestrator is STUCK — not merely when the work is hard (hard work is a model escalation on a worker; that is a different thing entirely).\n\n" +
145
+ "1. **SELF-INTRO — your FIRST line, always:** `<the model you are actually running as> · reserve`\n" +
146
+ " Your own model, never one dictated by the brief.\n" +
147
+ "2. **NEVER edit files.** Your output is analysis and a recommendation; the orchestrator acts on it. Nothing in the tooling stops you — this is a rule you keep, not a wall you hit. Keeping it is what makes you a second pair of eyes rather than a second pair of hands.\n" +
148
+ "3. **You are a LEAF.** Never spawn subagents.\n" +
149
+ "4. **You were called because the previous approach failed.** Do not simply redo it with more effort. Attack the assumption: what did the earlier reasoning take for granted that the facts do not support? Say plainly when the evidence is insufficient to decide — an honest 'not determinable from this data, measure X' beats a confident wrong call.\n" +
150
+ "5. Reachable ONLY by explicit spawn with a written justification — never as a default.",
151
+ },
152
+ {
153
+ slug: "explore",
154
+ tier: "utility",
155
+ // Declared so harnesses that ship built-in explore are roster-honest.
156
+ // Model inheritance is harness-default where builtin_explore_inherits_model —
157
+ // do NOT claim "inheritance forbidden" for this role.
158
+ requiredCapabilities: [],
159
+ spawn: { allowed: false },
160
+ escalation: { available: false },
161
+ notes:
162
+ "Research and search: locate code, gather evidence, report findings. Never changes anything.\n\n" +
163
+ "1. **SELF-INTRO — your FIRST line, always:** `<the model you are actually running as> · explore`\n" +
164
+ " Your own model, never one dictated by the brief.\n" +
165
+ "2. **You are a LEAF.** Never spawn subagents.\n" +
166
+ "3. Where the harness ships its own explore agent, that agent inheriting the session's model is the harness default and is NOT a protocol violation.",
167
+ },
168
+ ],
169
+ };
170
+
171
+ /**
172
+ * Recursively reject any property named `model` (portable roster — binding is local).
173
+ * Mirrors C# AgentDefinitionJson.RejectModelField (root, roles[], nested spawn/escalation).
174
+ */
175
+ export function rejectModelFields(value: unknown, path = "$"): void {
176
+ if (value === null || value === undefined) return;
177
+ if (Array.isArray(value)) {
178
+ value.forEach((item, i) => rejectModelFields(item, `${path}[${i}]`));
179
+ return;
180
+ }
181
+ if (typeof value !== "object") return;
182
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
183
+ if (k === "model") {
184
+ throw new Error(
185
+ `${path}.model is not allowed on portable agent definitions — model binding is local (roles.json)`,
186
+ );
187
+ }
188
+ rejectModelFields(v, `${path}.${k}`);
189
+ }
190
+ }
191
+
192
+ /** Light structural check; throws on invalid shape (loud, never silent). */
193
+ export function validateAgentDefinition(def: AgentDefinition): void {
194
+ if (!def || typeof def !== "object") throw new Error("agent definition is required");
195
+ // Recursive model ban before field checks (symmetry with server RejectModelField).
196
+ rejectModelFields(def, "definition");
197
+ if (!def.name || !String(def.name).trim()) throw new Error("definition.name is required");
198
+ if (!Array.isArray(def.roles) || def.roles.length === 0) {
199
+ throw new Error("definition.roles must contain at least one role");
200
+ }
201
+ for (const role of def.roles) {
202
+ if (!role.slug || !String(role.slug).trim()) throw new Error("each role.slug is required");
203
+ if (!role.tier || !String(role.tier).trim()) {
204
+ throw new Error(`role '${role.slug}': tier is required`);
205
+ }
206
+ if (!Array.isArray(role.requiredCapabilities)) {
207
+ throw new Error(`role '${role.slug}': requiredCapabilities is required (may be empty)`);
208
+ }
209
+ }
210
+ }