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,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
+ });
@@ -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
+ });