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.
- package/README.md +119 -0
- package/bin/petbox-wire.js +30 -0
- package/package.json +44 -0
- package/src/agent-def-fetch.test.ts +317 -0
- package/src/agent-def-fetch.ts +409 -0
- package/src/agent-definition.ts +210 -0
- package/src/append-meta.test.ts +93 -0
- package/src/append.ts +172 -0
- package/src/apply-artifacts.ts +337 -0
- package/src/apply-root.test.ts +148 -0
- package/src/apply-root.ts +68 -0
- package/src/apply-write.test.ts +169 -0
- package/src/apply-write.ts +84 -0
- package/src/canon.ts +140 -0
- package/src/doctor-definition.test.ts +128 -0
- package/src/droid-pull-memory.ts +126 -0
- package/src/droid-push-session.ts +100 -0
- package/src/droid-transcript.ts +47 -0
- package/src/harness-capabilities.ts +126 -0
- package/src/harness-models.ts +158 -0
- package/src/hook-drain.ts +42 -0
- package/src/hook-prune.test.ts +165 -0
- package/src/hook-prune.ts +83 -0
- package/src/import-sessions.ts +261 -0
- package/src/opencode-plugin.ts +127 -0
- package/src/origin-marker.ts +37 -0
- package/src/posix-env.test.ts +99 -0
- package/src/posix-env.ts +61 -0
- package/src/protocol.test.ts +236 -0
- package/src/protocol.ts +136 -0
- package/src/pull-memory.test.ts +168 -0
- package/src/pull-memory.ts +119 -0
- package/src/push-session.ts +99 -0
- package/src/registry.ts +120 -0
- package/src/roles.test.ts +255 -0
- package/src/roles.ts +241 -0
- package/src/self-smoke.test.ts +103 -0
- package/src/self-smoke.ts +96 -0
- package/src/telemetry-settings.test.ts +65 -0
- package/src/telemetry-settings.ts +55 -0
- package/src/templates/SKILL.md +45 -0
- package/src/templates/agent-factory/SKILL.md +56 -0
- package/src/transcript.ts +86 -0
- package/src/truthfulness.test.ts +544 -0
- package/src/truthfulness.ts +164 -0
- package/src/wire-exit.test.ts +43 -0
- package/src/wire-exit.ts +25 -0
- package/src/wire-identity.test.ts +65 -0
- package/src/wire-identity.ts +64 -0
- 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
|
+
}
|