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,99 @@
|
|
|
1
|
+
// Claude Code Stop hook (global) — port of push-session.ps1.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the session conversation into PetBox's Session module so the board auto-populates.
|
|
4
|
+
// The project + API key are resolved from cwd via the shared registry; if the cwd is not a
|
|
5
|
+
// registered project this exits immediately (first guard, before any work).
|
|
6
|
+
//
|
|
7
|
+
// Reads the full transcript JSONL, extracts the user/assistant text turns (tool dumps and
|
|
8
|
+
// system reminders excluded), and pushes only the INCREMENT via the server-authoritative
|
|
9
|
+
// append cursor (see append.ts): this process is fresh each turn, so it optimistically
|
|
10
|
+
// resends a small idempotent overlap window; a contiguity gap comes back as a structured
|
|
11
|
+
// 409 with the server's lastOrdinal and the tail is resent from there. Old servers without
|
|
12
|
+
// the append route fall back to the legacy full-snapshot push. Best-effort: every failure
|
|
13
|
+
// is swallowed and we ALWAYS exit 0 — never break the user's session.
|
|
14
|
+
|
|
15
|
+
import { pushTranscript } from "./append.ts";
|
|
16
|
+
import { unrefLingeringHandles } from "./hook-drain.ts";
|
|
17
|
+
import { resolveProject } from "./registry.ts";
|
|
18
|
+
import { buildMessages, type Msg } from "./transcript.ts";
|
|
19
|
+
// Observed role binding is stamped inside pushTranscript (X-PetBox-Session-Meta via
|
|
20
|
+
// resolveObservedBinding) — server stores observation only; local roles.json is SoT.
|
|
21
|
+
|
|
22
|
+
const FETCH_TIMEOUT_MS = 12000;
|
|
23
|
+
|
|
24
|
+
type HookInput = {
|
|
25
|
+
session_id?: string;
|
|
26
|
+
transcript_path?: string;
|
|
27
|
+
cwd?: string;
|
|
28
|
+
stop_hook_active?: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function readStdin(): Promise<string> {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
let buf = "";
|
|
34
|
+
process.stdin.setEncoding("utf8");
|
|
35
|
+
process.stdin.on("data", (c) => (buf += c));
|
|
36
|
+
process.stdin.on("end", () => resolve(buf));
|
|
37
|
+
process.stdin.on("error", () => resolve(buf));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function main(): Promise<void> {
|
|
42
|
+
try {
|
|
43
|
+
const raw = await readStdin();
|
|
44
|
+
let j: HookInput;
|
|
45
|
+
try {
|
|
46
|
+
j = JSON.parse(raw);
|
|
47
|
+
} catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (j.stop_hook_active) return;
|
|
51
|
+
|
|
52
|
+
// FIRST guard: not a registered project → silent no-op.
|
|
53
|
+
const resolved = resolveProject(j.cwd ?? "");
|
|
54
|
+
if (!resolved) return;
|
|
55
|
+
|
|
56
|
+
const sid = (j.session_id ?? "").trim();
|
|
57
|
+
const tp = (j.transcript_path ?? "").trim();
|
|
58
|
+
if (!sid || !tp) return;
|
|
59
|
+
|
|
60
|
+
let msgs: Msg[];
|
|
61
|
+
try {
|
|
62
|
+
msgs = await buildMessages(tp);
|
|
63
|
+
} catch {
|
|
64
|
+
return; // transcript missing/unreadable
|
|
65
|
+
}
|
|
66
|
+
if (msgs.length === 0) return; // empty body → server returns 400, don't push
|
|
67
|
+
|
|
68
|
+
// Fresh process each turn → no remembered cursor (null): pushTranscript guesses an
|
|
69
|
+
// idempotent overlap window and self-heals off the server's structured gap reject.
|
|
70
|
+
await pushTranscript(
|
|
71
|
+
{
|
|
72
|
+
baseUrl: resolved.baseUrl,
|
|
73
|
+
project: resolved.project,
|
|
74
|
+
sessionId: sid,
|
|
75
|
+
apiKey: resolved.apiKey,
|
|
76
|
+
agent: "claude-code",
|
|
77
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
78
|
+
},
|
|
79
|
+
msgs,
|
|
80
|
+
null,
|
|
81
|
+
);
|
|
82
|
+
} catch {
|
|
83
|
+
// best-effort: never break the user's session
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Exit cleanly instead of tearing the process down mid-close: a hard process.exit() while
|
|
88
|
+
// libuv handles from the HTTP push above are still closing can race Windows' async handle
|
|
89
|
+
// teardown (`Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c`) — the
|
|
90
|
+
// same crash observed in pull-memory.ts (see its exit comment). Setting exitCode and returning
|
|
91
|
+
// lets Node drain the event loop naturally instead — `Connection: close` (append.ts) covers a
|
|
92
|
+
// completed push, and unrefLingeringHandles covers a push aborted mid-flight against a stalled
|
|
93
|
+
// server (measured to leave its TLSSocket alive for several more seconds otherwise; see
|
|
94
|
+
// hook-drain.ts) so a slow Stop hook can't turn into a multi-second stall on a handle nothing
|
|
95
|
+
// is still using.
|
|
96
|
+
main().finally(() => {
|
|
97
|
+
process.exitCode = 0;
|
|
98
|
+
unrefLingeringHandles();
|
|
99
|
+
});
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Shared project resolver for the global agent-wiring kit.
|
|
2
|
+
//
|
|
3
|
+
// One global registry (`~/.petbox/projects.json`) maps a filesystem prefix to a PetBox
|
|
4
|
+
// project + the env var that holds its API key. The Claude Code user hooks and the global
|
|
5
|
+
// opencode plugin both run in EVERY project on the machine, so they resolve the active
|
|
6
|
+
// project by the current working directory (longest-prefix match) and no-op cleanly when
|
|
7
|
+
// the cwd is not registered.
|
|
8
|
+
//
|
|
9
|
+
// Plain TS for native node type-stripping: no enum/namespace/parameter-properties, type-only
|
|
10
|
+
// imports, zero deps.
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_BASE_URL = "https://petbox.3po.su";
|
|
17
|
+
|
|
18
|
+
// A registry entry may carry extra keys written by older kits (e.g. the removed `promptRag` gate) —
|
|
19
|
+
// they are simply ignored here and dropped the next time wire.ts upserts the entry.
|
|
20
|
+
export type RegistryEntry = {
|
|
21
|
+
prefix: string;
|
|
22
|
+
project: string;
|
|
23
|
+
envVar: string;
|
|
24
|
+
baseUrl?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ResolvedProject = {
|
|
28
|
+
project: string;
|
|
29
|
+
apiKey: string;
|
|
30
|
+
baseUrl: string;
|
|
31
|
+
envVar: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function registryPath(): string {
|
|
35
|
+
return join(homedir(), ".petbox", "projects.json");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Cross-platform key store written by wire.ts: ~/.petbox/keys.json is a flat JSON map
|
|
39
|
+
// { "<ENV_VAR>": "<key>" }. Read as a fallback when the env var is not set in the process
|
|
40
|
+
// (so a machine wired via `npx petbox-wire` works without a user-scope env var). Never throws.
|
|
41
|
+
function readKeyStore(envVar: string): string {
|
|
42
|
+
try {
|
|
43
|
+
const raw = readFileSync(join(homedir(), ".petbox", "keys.json"), "utf8");
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
const v = parsed && typeof parsed === "object" ? parsed[envVar] : undefined;
|
|
46
|
+
return typeof v === "string" ? v : "";
|
|
47
|
+
} catch {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Normalize a path for prefix comparison: unify separators to "/", drop a trailing
|
|
53
|
+
// separator, and lowercase on Windows (case-insensitive filesystem).
|
|
54
|
+
function normalize(p: string): string {
|
|
55
|
+
let n = String(p).replace(/[\\/]+/g, "/");
|
|
56
|
+
if (n.length > 1 && n.endsWith("/")) n = n.slice(0, -1);
|
|
57
|
+
if (process.platform === "win32") n = n.toLowerCase();
|
|
58
|
+
return n;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Segment-boundary prefix match: "d:/my/prj/yoba" must NOT match "d:/my/prj/yobapub".
|
|
62
|
+
// dir is a prefix of, or equal to, the entry path (so worktree subfolders are covered).
|
|
63
|
+
function isUnderPrefix(dir: string, prefix: string): boolean {
|
|
64
|
+
if (dir === prefix) return true;
|
|
65
|
+
return dir.startsWith(prefix + "/");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function readRegistry(): RegistryEntry[] {
|
|
69
|
+
try {
|
|
70
|
+
const raw = readFileSync(registryPath(), "utf8");
|
|
71
|
+
const parsed = JSON.parse(raw);
|
|
72
|
+
const entries = parsed && Array.isArray(parsed.entries) ? parsed.entries : [];
|
|
73
|
+
return entries.filter(
|
|
74
|
+
(e: unknown): e is RegistryEntry =>
|
|
75
|
+
!!e &&
|
|
76
|
+
typeof (e as RegistryEntry).prefix === "string" &&
|
|
77
|
+
typeof (e as RegistryEntry).project === "string" &&
|
|
78
|
+
typeof (e as RegistryEntry).envVar === "string",
|
|
79
|
+
);
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Resolve the active project for a directory. Returns null on ANY failure
|
|
86
|
+
// (no registry file, no match, empty env var) — never throws, because the hooks
|
|
87
|
+
// that call this run globally and must be a no-op outside registered projects.
|
|
88
|
+
export function resolveProject(dir: string): ResolvedProject | null {
|
|
89
|
+
try {
|
|
90
|
+
if (!dir || typeof dir !== "string") return null;
|
|
91
|
+
const entries = readRegistry();
|
|
92
|
+
if (entries.length === 0) return null;
|
|
93
|
+
|
|
94
|
+
const nd = normalize(dir);
|
|
95
|
+
let best: RegistryEntry | null = null;
|
|
96
|
+
let bestLen = -1;
|
|
97
|
+
for (const e of entries) {
|
|
98
|
+
const np = normalize(e.prefix);
|
|
99
|
+
if (isUnderPrefix(nd, np) && np.length > bestLen) {
|
|
100
|
+
best = e;
|
|
101
|
+
bestLen = np.length;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!best) return null;
|
|
105
|
+
|
|
106
|
+
// env var wins; fall back to ~/.petbox/keys.json (the wire.ts key store).
|
|
107
|
+
const apiKey = process.env[best.envVar] || readKeyStore(best.envVar);
|
|
108
|
+
if (!apiKey || apiKey.trim().length === 0) return null;
|
|
109
|
+
|
|
110
|
+
const baseUrl = (best.baseUrl && best.baseUrl.trim()) || DEFAULT_BASE_URL;
|
|
111
|
+
return {
|
|
112
|
+
project: best.project,
|
|
113
|
+
apiKey,
|
|
114
|
+
baseUrl: baseUrl.replace(/\/+$/, ""),
|
|
115
|
+
envVar: best.envVar,
|
|
116
|
+
};
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// Unit tests for the local role→model binding store (roles.ts).
|
|
2
|
+
//
|
|
3
|
+
// Run: node --test src/roles.test.ts (Node >= 23.6 native TS type-stripping; no build step)
|
|
4
|
+
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import {
|
|
11
|
+
exportRolesBootstrap,
|
|
12
|
+
formatResolvedBinding,
|
|
13
|
+
isEmptyRoles,
|
|
14
|
+
loadRoles,
|
|
15
|
+
resolveAgentRoles,
|
|
16
|
+
resolveObservedBinding,
|
|
17
|
+
rolesPath,
|
|
18
|
+
saveRoles,
|
|
19
|
+
useProfile,
|
|
20
|
+
type RolesFile,
|
|
21
|
+
} from "./roles.ts";
|
|
22
|
+
|
|
23
|
+
function freshHome(): string {
|
|
24
|
+
return mkdtempSync(join(tmpdir(), "petbox-wire-roles-"));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SAMPLE: RolesFile = {
|
|
28
|
+
activeProfile: "default",
|
|
29
|
+
profiles: {
|
|
30
|
+
default: {
|
|
31
|
+
agents: {
|
|
32
|
+
"claude-code": {
|
|
33
|
+
roles: {
|
|
34
|
+
orchestrator: { model: "claude-opus-4" },
|
|
35
|
+
worker: { model: "claude-sonnet-4" },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
opencode: {
|
|
39
|
+
roles: {
|
|
40
|
+
orchestrator: { model: "deepseek-chat" },
|
|
41
|
+
worker: { model: "deepseek-coder" },
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
test("load missing file → empty shell (never throws)", () => {
|
|
50
|
+
const home = freshHome();
|
|
51
|
+
try {
|
|
52
|
+
assert.equal(existsSync(rolesPath(home)), false);
|
|
53
|
+
const data = loadRoles(home);
|
|
54
|
+
assert.equal(data.activeProfile, "default");
|
|
55
|
+
assert.deepEqual(data.profiles, {});
|
|
56
|
+
assert.equal(isEmptyRoles(data), true);
|
|
57
|
+
} finally {
|
|
58
|
+
rmSync(home, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("load corrupt / non-object JSON → empty shell", () => {
|
|
63
|
+
const home = freshHome();
|
|
64
|
+
try {
|
|
65
|
+
mkdirSync(join(home, ".petbox"), { recursive: true });
|
|
66
|
+
writeFileSync(rolesPath(home), "not-json{{{", "utf8");
|
|
67
|
+
const data = loadRoles(home);
|
|
68
|
+
assert.equal(data.activeProfile, "default");
|
|
69
|
+
assert.deepEqual(data.profiles, {});
|
|
70
|
+
|
|
71
|
+
writeFileSync(rolesPath(home), "null", "utf8");
|
|
72
|
+
assert.deepEqual(loadRoles(home).profiles, {});
|
|
73
|
+
} finally {
|
|
74
|
+
rmSync(home, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("save/load roundtrip under temp HOME", () => {
|
|
79
|
+
const home = freshHome();
|
|
80
|
+
try {
|
|
81
|
+
saveRoles(SAMPLE, home);
|
|
82
|
+
assert.equal(existsSync(rolesPath(home)), true);
|
|
83
|
+
const loaded = loadRoles(home);
|
|
84
|
+
assert.equal(loaded.activeProfile, "default");
|
|
85
|
+
const defaultProfile = loaded.profiles["default"];
|
|
86
|
+
assert.ok(defaultProfile, "the 'default' profile must round-trip");
|
|
87
|
+
const claudeCode = defaultProfile.agents["claude-code"];
|
|
88
|
+
assert.ok(claudeCode, "claude-code agent bindings must round-trip");
|
|
89
|
+
assert.equal(claudeCode.roles["orchestrator"]?.model, "claude-opus-4");
|
|
90
|
+
const opencode = defaultProfile.agents["opencode"];
|
|
91
|
+
assert.ok(opencode, "opencode agent bindings must round-trip");
|
|
92
|
+
assert.equal(opencode.roles["worker"]?.model, "deepseek-coder");
|
|
93
|
+
// file is pretty-printed JSON
|
|
94
|
+
const raw = JSON.parse(readFileSync(rolesPath(home), "utf8"));
|
|
95
|
+
assert.equal(raw.activeProfile, "default");
|
|
96
|
+
} finally {
|
|
97
|
+
rmSync(home, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("profile use: set activeProfile and create shell if missing", () => {
|
|
102
|
+
const home = freshHome();
|
|
103
|
+
try {
|
|
104
|
+
let data = loadRoles(home);
|
|
105
|
+
data = useProfile(data, "work");
|
|
106
|
+
assert.equal(data.activeProfile, "work");
|
|
107
|
+
const workProfile = data.profiles["work"];
|
|
108
|
+
assert.ok(workProfile);
|
|
109
|
+
assert.deepEqual(workProfile.agents, {});
|
|
110
|
+
saveRoles(data, home);
|
|
111
|
+
|
|
112
|
+
// switching again keeps the shell and updates active
|
|
113
|
+
data = useProfile(loadRoles(home), "default");
|
|
114
|
+
assert.equal(data.activeProfile, "default");
|
|
115
|
+
assert.ok(data.profiles["work"], "prior profile shell retained");
|
|
116
|
+
saveRoles(data, home);
|
|
117
|
+
|
|
118
|
+
const reloaded = loadRoles(home);
|
|
119
|
+
assert.equal(reloaded.activeProfile, "default");
|
|
120
|
+
assert.ok(reloaded.profiles["work"]);
|
|
121
|
+
} finally {
|
|
122
|
+
rmSync(home, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("export shape is bootstrap-safe RolesFile (no secrets field)", () => {
|
|
127
|
+
const exported = exportRolesBootstrap(SAMPLE);
|
|
128
|
+
assert.equal(exported.activeProfile, "default");
|
|
129
|
+
const defaultProfile = exported.profiles["default"];
|
|
130
|
+
assert.ok(defaultProfile, "the 'default' profile must round-trip through export");
|
|
131
|
+
const claudeCode = defaultProfile.agents["claude-code"];
|
|
132
|
+
assert.ok(claudeCode, "claude-code agent bindings must round-trip through export");
|
|
133
|
+
assert.equal(
|
|
134
|
+
claudeCode.roles["worker"]?.model,
|
|
135
|
+
"claude-sonnet-4",
|
|
136
|
+
);
|
|
137
|
+
// no accidental secret-looking top-level keys
|
|
138
|
+
const keys = Object.keys(exported).sort();
|
|
139
|
+
assert.deepEqual(keys, ["activeProfile", "profiles"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("resolveAgentRoles / resolveObservedBinding do not invent defaults", () => {
|
|
143
|
+
const home = freshHome();
|
|
144
|
+
try {
|
|
145
|
+
assert.deepEqual(resolveAgentRoles(loadRoles(home), "claude-code"), {});
|
|
146
|
+
assert.equal(resolveObservedBinding("claude-code", home), null);
|
|
147
|
+
|
|
148
|
+
saveRoles(SAMPLE, home);
|
|
149
|
+
assert.deepEqual(resolveAgentRoles(loadRoles(home), "claude-code"), {
|
|
150
|
+
orchestrator: "claude-opus-4",
|
|
151
|
+
worker: "claude-sonnet-4",
|
|
152
|
+
});
|
|
153
|
+
const obs = resolveObservedBinding("claude-code", home);
|
|
154
|
+
assert.deepEqual(obs, {
|
|
155
|
+
profile: "default",
|
|
156
|
+
agent: "claude-code",
|
|
157
|
+
roles: { orchestrator: "claude-opus-4", worker: "claude-sonnet-4" },
|
|
158
|
+
});
|
|
159
|
+
// truly unknown agent → null observation
|
|
160
|
+
assert.equal(resolveObservedBinding("not-a-harness", home), null);
|
|
161
|
+
} finally {
|
|
162
|
+
rmSync(home, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("factory-droid alias resolves to canonical droid (session agent id)", () => {
|
|
167
|
+
const home = freshHome();
|
|
168
|
+
try {
|
|
169
|
+
saveRoles(
|
|
170
|
+
{
|
|
171
|
+
activeProfile: "default",
|
|
172
|
+
profiles: {
|
|
173
|
+
default: {
|
|
174
|
+
agents: {
|
|
175
|
+
// legacy / display name in roles.json
|
|
176
|
+
"factory-droid": {
|
|
177
|
+
roles: {
|
|
178
|
+
orchestrator: { model: "deepseek-v4-pro" },
|
|
179
|
+
worker: { model: "deepseek-v4-pro" },
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
home,
|
|
187
|
+
);
|
|
188
|
+
// push-session / droid-push stamps agent:"droid" — must find factory-droid bucket
|
|
189
|
+
assert.deepEqual(resolveAgentRoles(loadRoles(home), "droid"), {
|
|
190
|
+
orchestrator: "deepseek-v4-pro",
|
|
191
|
+
worker: "deepseek-v4-pro",
|
|
192
|
+
});
|
|
193
|
+
assert.deepEqual(resolveAgentRoles(loadRoles(home), "factory-droid"), {
|
|
194
|
+
orchestrator: "deepseek-v4-pro",
|
|
195
|
+
worker: "deepseek-v4-pro",
|
|
196
|
+
});
|
|
197
|
+
const obs = resolveObservedBinding("droid", home);
|
|
198
|
+
assert.deepEqual(obs, {
|
|
199
|
+
profile: "default",
|
|
200
|
+
agent: "droid", // stamp always uses canonical id
|
|
201
|
+
roles: { orchestrator: "deepseek-v4-pro", worker: "deepseek-v4-pro" },
|
|
202
|
+
});
|
|
203
|
+
// looking up via alias still stamps canonical agent id
|
|
204
|
+
assert.equal(resolveObservedBinding("factory-droid", home)?.agent, "droid");
|
|
205
|
+
} finally {
|
|
206
|
+
rmSync(home, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("formatResolvedBinding surfaces empty vs populated", () => {
|
|
211
|
+
const home = freshHome();
|
|
212
|
+
try {
|
|
213
|
+
const empty = formatResolvedBinding(loadRoles(home));
|
|
214
|
+
assert.match(empty, /activeProfile: default/);
|
|
215
|
+
assert.match(empty, /no agent role bindings/);
|
|
216
|
+
|
|
217
|
+
const filled = formatResolvedBinding(SAMPLE);
|
|
218
|
+
assert.match(filled, /claude-code:/);
|
|
219
|
+
assert.match(filled, /orchestrator: claude-opus-4/);
|
|
220
|
+
assert.match(filled, /opencode:/);
|
|
221
|
+
} finally {
|
|
222
|
+
rmSync(home, { recursive: true, force: true });
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("light validation drops junk role entries without model", () => {
|
|
227
|
+
const home = freshHome();
|
|
228
|
+
try {
|
|
229
|
+
mkdirSync(join(home, ".petbox"), { recursive: true });
|
|
230
|
+
writeFileSync(
|
|
231
|
+
rolesPath(home),
|
|
232
|
+
JSON.stringify({
|
|
233
|
+
activeProfile: "default",
|
|
234
|
+
profiles: {
|
|
235
|
+
default: {
|
|
236
|
+
agents: {
|
|
237
|
+
"claude-code": {
|
|
238
|
+
roles: {
|
|
239
|
+
orchestrator: { model: "ok-model" },
|
|
240
|
+
broken: { notModel: true },
|
|
241
|
+
empty: { model: " " },
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
}),
|
|
248
|
+
"utf8",
|
|
249
|
+
);
|
|
250
|
+
const data = loadRoles(home);
|
|
251
|
+
assert.deepEqual(resolveAgentRoles(data, "claude-code"), { orchestrator: "ok-model" });
|
|
252
|
+
} finally {
|
|
253
|
+
rmSync(home, { recursive: true, force: true });
|
|
254
|
+
}
|
|
255
|
+
});
|