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,236 @@
1
+ // Protocol inject: memory always; spawn prescriptions when harness has spawn_subagents.
2
+ //
3
+ // Run: node --test src/protocol.test.ts
4
+
5
+ import assert from "node:assert/strict";
6
+ import { mkdtempSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { test } from "node:test";
10
+ import { resolveAgentDefinitionForSession } from "./agent-def-fetch.ts";
11
+ import { DEFAULT_AGENT_DEFINITION, type AgentDefinition } from "./agent-definition.ts";
12
+ import {
13
+ buildProtocol,
14
+ mcpPetboxTool,
15
+ orchestrationPrescriptionsAllowed,
16
+ } from "./protocol.ts";
17
+
18
+ const project = "demo";
19
+
20
+ function freshHome(): string {
21
+ return mkdtempSync(join(tmpdir(), "petbox-protocol-"));
22
+ }
23
+
24
+ const SERVER_BODY = {
25
+ key: "default",
26
+ version: 7,
27
+ definition: {
28
+ name: "server-roster",
29
+ roles: [
30
+ {
31
+ slug: "orchestrator",
32
+ tier: "orchestrator",
33
+ requiredCapabilities: ["mcp_main_session", "spawn_subagents"],
34
+ spawn: { allowed: true, allowedRoles: ["worker"] },
35
+ escalation: { available: true, targets: ["reserve"] },
36
+ notes: "SERVER-AUTHORED-ORCHESTRATOR-NOTES-xyz789",
37
+ },
38
+ ],
39
+ },
40
+ };
41
+
42
+ test("orchestrationPrescriptionsAllowed tracks spawn_subagents for all three harnesses", () => {
43
+ assert.equal(orchestrationPrescriptionsAllowed("claude-code"), true);
44
+ assert.equal(orchestrationPrescriptionsAllowed("opencode"), true);
45
+ // Factory Task tool on main session — https://docs.factory.ai/cli/configuration/custom-droids
46
+ assert.equal(orchestrationPrescriptionsAllowed("droid"), true);
47
+ assert.equal(orchestrationPrescriptionsAllowed(undefined), false);
48
+ assert.equal(orchestrationPrescriptionsAllowed("unknown"), false);
49
+ });
50
+
51
+ test("buildProtocol for droid CONTAINS spawn/orchestrator prose (Factory spawns via Task)", () => {
52
+ const text = buildProtocol(project, mcpPetboxTool, { harness: "droid" });
53
+ const lower = text.toLowerCase();
54
+ assert.ok(
55
+ lower.includes("spawn workers") || lower.includes("delegate by default"),
56
+ `droid protocol must prescribe spawn/delegate:\n${text}`,
57
+ );
58
+ assert.match(text, /orchestrator/i);
59
+ assert.match(text, /PetBox memory active/);
60
+ assert.match(text, /search before rework/i);
61
+ // Must not fall back to "· main" no-spawn self-intro
62
+ assert.ok(!lower.includes("· main"), `droid must not use no-spawn main intro:\n${text}`);
63
+ });
64
+
65
+ test("buildProtocol for claude-code / opencode MUST contain spawn/delegate language", () => {
66
+ for (const harness of ["claude-code", "opencode"] as const) {
67
+ const text = buildProtocol(project, mcpPetboxTool, { harness });
68
+ const lower = text.toLowerCase();
69
+ assert.ok(
70
+ lower.includes("spawn workers") || lower.includes("delegate by default"),
71
+ `${harness} protocol must prescribe spawn/delegate:\n${text}`,
72
+ );
73
+ assert.match(text, /orchestrator/i);
74
+ assert.match(text, /plan, decompose, delegate|Orchestrator notes/i);
75
+ }
76
+ });
77
+
78
+ test("buildProtocol without harness omits spawn prescriptions (safe default)", () => {
79
+ const text = buildProtocol(project, mcpPetboxTool);
80
+ const lower = text.toLowerCase();
81
+ assert.ok(!lower.includes("spawn workers"));
82
+ assert.ok(!lower.includes("delegate by default"));
83
+ });
84
+
85
+ // --- petbox-namespaced-agent-names: the literal spawn-target string in the orchestrator
86
+ // self-intro must be the COMPUTED, namespaced identity (apply-artifacts.ts's emittedRoleName),
87
+ // never a hardcoded `worker` constant — that was exactly the bug at protocol.ts:62 (a third
88
+ // source of truth that could drift from the actual .claude/agents/petbox-worker.md apply emits). ---
89
+
90
+ test("buildProtocol's orchestrator self-intro tells it to spawn the NAMESPACED worker name, not a bare `worker` constant", () => {
91
+ for (const harness of ["claude-code", "opencode", "droid"] as const) {
92
+ const text = buildProtocol(project, mcpPetboxTool, { harness });
93
+ assert.match(text, /Spawn as `petbox-worker`/, `${harness}: must render the computed emitted name`);
94
+ assert.ok(!text.includes("Spawn as `worker`"), `${harness}: must not hardcode the bare slug`);
95
+ }
96
+ });
97
+
98
+ test("buildProtocol computes the spawn-target name from the SUPPLIED definition, not a constant", () => {
99
+ // A definition whose worker role has a different internal slug still renders correctly —
100
+ // proves the name is computed per-definition, not baked into protocol.ts as a literal string.
101
+ const custom: AgentDefinition = {
102
+ name: "custom-roster",
103
+ roles: [
104
+ { slug: "orchestrator", tier: "orchestrator", requiredCapabilities: [] },
105
+ { slug: "worker", tier: "worker", requiredCapabilities: [] },
106
+ ],
107
+ };
108
+ const text = buildProtocol(project, mcpPetboxTool, { harness: "claude-code", definition: custom });
109
+ assert.match(text, /Spawn as `petbox-worker`/);
110
+ });
111
+
112
+ test("buildProtocol falls back to petbox-worker when the supplied definition has no worker role (never crashes)", () => {
113
+ const noWorker: AgentDefinition = {
114
+ name: "no-worker",
115
+ roles: [{ slug: "orchestrator", tier: "orchestrator", requiredCapabilities: [] }],
116
+ };
117
+ const text = buildProtocol(project, mcpPetboxTool, { harness: "claude-code", definition: noWorker });
118
+ assert.match(text, /Spawn as `petbox-worker`/);
119
+ });
120
+
121
+ // --- kit-prose-contradicts-server-definition: the kit banner must DELIVER role/model
122
+ // semantics from the server definition's notes, never assert its own competing version.
123
+ // Regression guard for two prose bugs that contradicted the definition inside the SAME
124
+ // injected banner: (1) telling the orchestrator to dictate a subagent's self-intro, which
125
+ // the definition's rule 5 explicitly forbids (the subagent's own self-report is the only
126
+ // evidence of what actually ran — a dictated line turns signal into echo); (2) pointing at
127
+ // a "worker preamble" that doesn't exist anywhere else in the kit. ---
128
+
129
+ test("buildProtocol's orchestrator self-intro no longer tells the orchestrator to dictate a subagent's self-intro", () => {
130
+ for (const harness of ["claude-code", "opencode", "droid"] as const) {
131
+ const text = buildProtocol(project, mcpPetboxTool, { harness });
132
+ assert.ok(
133
+ !text.includes("write their self-intro into the brief"),
134
+ `${harness}: kit must not instruct dictating a subagent's self-intro:\n${text}`,
135
+ );
136
+ }
137
+ });
138
+
139
+ test("buildProtocol does not reference a nonexistent 'worker preamble'", () => {
140
+ for (const harness of ["claude-code", "opencode", "droid"] as const) {
141
+ const text = buildProtocol(project, mcpPetboxTool, { harness });
142
+ assert.ok(
143
+ !/worker preamble/i.test(text),
144
+ `${harness}: kit must not promise a "worker preamble" the kit never defines:\n${text}`,
145
+ );
146
+ }
147
+ });
148
+
149
+ // --- definition-driven banner (wiring-startup-symmetry: main-loop banner must resolve the
150
+ // portable definition the same way `apply` does — server → LKG → built-in default) ---
151
+
152
+ test("buildProtocol renders orchestrator notes from an explicitly-supplied definition", () => {
153
+ const custom: AgentDefinition = {
154
+ name: "custom-roster",
155
+ roles: [
156
+ {
157
+ slug: "orchestrator",
158
+ tier: "orchestrator",
159
+ requiredCapabilities: [],
160
+ notes: "CUSTOM-SERVER-NOTES-abc123",
161
+ },
162
+ ],
163
+ };
164
+ const text = buildProtocol(project, mcpPetboxTool, { harness: "claude-code", definition: custom });
165
+ assert.match(text, /CUSTOM-SERVER-NOTES-abc123/);
166
+ const defaultOrchestratorNotes = DEFAULT_AGENT_DEFINITION.roles.find(
167
+ (r) => r.slug === "orchestrator",
168
+ )?.notes;
169
+ assert.ok(
170
+ !text.includes(defaultOrchestratorNotes ?? " "),
171
+ "must not also carry the built-in default's notes once a definition is supplied",
172
+ );
173
+ });
174
+
175
+ test("buildProtocol falls back to the built-in DEFAULT_AGENT_DEFINITION notes when no definition is supplied", () => {
176
+ const text = buildProtocol(project, mcpPetboxTool, { harness: "claude-code" });
177
+ const defaultOrchNotes = DEFAULT_AGENT_DEFINITION.roles.find((r) => r.slug === "orchestrator")?.notes;
178
+ assert.ok(defaultOrchNotes, "fixture assumption: DEFAULT_AGENT_DEFINITION has orchestrator notes");
179
+ assert.ok(text.includes(defaultOrchNotes!));
180
+ });
181
+
182
+ // --- end-to-end: resolveAgentDefinitionForSession (server → LKG → DEFAULT) feeding buildProtocol,
183
+ // the same wiring pull-memory.ts / droid-pull-memory.ts / opencode-plugin.ts now use. ---
184
+
185
+ test("SessionStart banner renders server-supplied definition when the server answers", async () => {
186
+ const home = freshHome();
187
+ try {
188
+ const got = await resolveAgentDefinitionForSession(
189
+ { project: "proj", baseUrl: "https://petbox.example", apiKey: "k" },
190
+ {
191
+ homeDir: home,
192
+ fetchImpl: async () =>
193
+ new Response(JSON.stringify(SERVER_BODY), {
194
+ status: 200,
195
+ headers: { "Content-Type": "application/json" },
196
+ }),
197
+ },
198
+ );
199
+ assert.equal(got.source, "server");
200
+ const text = buildProtocol("proj", mcpPetboxTool, {
201
+ harness: "claude-code",
202
+ definition: got.definition,
203
+ });
204
+ assert.match(text, /SERVER-AUTHORED-ORCHESTRATOR-NOTES-xyz789/);
205
+ assert.match(text, /PetBox memory active/);
206
+ } finally {
207
+ rmSync(home, { recursive: true, force: true });
208
+ }
209
+ });
210
+
211
+ test("SessionStart banner falls back to the built-in default when neither server nor LKG cache is available (never crashes, never empty)", async () => {
212
+ const home = freshHome(); // fresh dir → guaranteed no LKG cache file
213
+ try {
214
+ const got = await resolveAgentDefinitionForSession(
215
+ { project: "proj", baseUrl: "https://petbox.example", apiKey: "k" },
216
+ {
217
+ homeDir: home,
218
+ fetchImpl: async () => {
219
+ throw new Error("ECONNREFUSED");
220
+ },
221
+ },
222
+ );
223
+ assert.equal(got.source, "default");
224
+ assert.equal(got.definition, DEFAULT_AGENT_DEFINITION);
225
+
226
+ const text = buildProtocol("proj", mcpPetboxTool, {
227
+ harness: "claude-code",
228
+ definition: got.definition,
229
+ });
230
+ assert.ok(text.length > 0, "banner must never be empty");
231
+ assert.match(text, /PetBox memory active/);
232
+ assert.match(text, /orchestrator/i);
233
+ } finally {
234
+ rmSync(home, { recursive: true, force: true });
235
+ }
236
+ });
@@ -0,0 +1,136 @@
1
+ // Shared PetBox memory-protocol builder — the ONE implementation every SessionStart injector
2
+ // renders from (pull-memory.ts for Claude Code, opencode-plugin.ts for opencode,
3
+ // droid-pull-memory.ts for Factory Droid), so the protocol text can no longer drift between
4
+ // agents as hand-synced copies (spec: agent-wiring, wiring-single-source).
5
+ //
6
+ // The ONLY thing that varies per agent is how an MCP tool is named: pass a `tool` mapper that
7
+ // turns a bare verb (e.g. "memory_search") into that agent's fully-qualified tool name. Claude
8
+ // Code and Droid both expose petbox tools as `mcp__petbox__<verb>`; opencode as `petbox_<verb>`.
9
+ //
10
+ // Role-orchestration prescriptions (SPAWN by DEFAULT / fan-out / orchestrator mandate) are
11
+ // gated by harness capability spawn_subagents (definition-truthfulness / wiring-startup-symmetry).
12
+ // Memory protocol (search-before-rework, capture, tool names) is always emitted.
13
+ //
14
+ // The CC-only "resume/compact" suffix is opt-in via `opts.source` (Droid's SessionStart payload
15
+ // also carries a `source`, so it shares the same behavior). Any other source = no suffix.
16
+ //
17
+ // Plain TS for native node type-stripping: no enum/namespace/parameter-properties, zero deps.
18
+
19
+ import { DEFAULT_AGENT_DEFINITION, emittedRoleName, type AgentDefinition } from "./agent-definition.ts";
20
+ import { hasCapability } from "./harness-capabilities.ts";
21
+
22
+ export type ToolNamer = (verb: string) => string;
23
+
24
+ export type ProtocolOpts = {
25
+ // The SessionStart `source` (Claude Code / Droid). "resume" | "compact" append a recall nudge;
26
+ // anything else (or omitted) renders the base protocol only.
27
+ source?: string;
28
+ /**
29
+ * Target harness id (claude-code | opencode | droid). Controls whether spawn/orchestrator
30
+ * prescriptions are emitted. Injectors always pass it. Omitted / unknown → no spawn prose
31
+ * (never invent a capability the harness does not declare).
32
+ */
33
+ harness?: string;
34
+ /**
35
+ * Portable agent definition to render the orchestrator self-intro notes from. Callers
36
+ * resolve this the same way `apply` does — server fetch, then LKG cache, then the built-in
37
+ * default (agent-def-fetch.ts's resolveAgentDefinitionWithLkg) — so the main-loop banner no
38
+ * longer drifts from the per-role artifacts that already got server-authored notes. Omitted
39
+ * → DEFAULT_AGENT_DEFINITION (never crash, never an empty banner).
40
+ */
41
+ definition?: AgentDefinition;
42
+ };
43
+
44
+ /** True when protocol may prescribe SPAWN workers / fan-out / orchestrator mandate. */
45
+ export function orchestrationPrescriptionsAllowed(harness: string | undefined): boolean {
46
+ if (!harness) return false;
47
+ return hasCapability(harness, "spawn_subagents");
48
+ }
49
+
50
+ function buildSelfIntro(allowSpawn: boolean, definition: AgentDefinition): string {
51
+ if (allowSpawn) {
52
+ const orch = definition.roles.find((r) => r.slug === "orchestrator");
53
+ const notes =
54
+ orch?.notes?.trim() ||
55
+ "plan, decompose, delegate, review, triage. Prefer spawning workers over solo implementation.";
56
+ // The literal spawn target string an orchestrator passes to Agent/Task — MUST be the same
57
+ // computed, namespaced identity apply actually renders the worker role's file under
58
+ // (emittedRoleName; chore: petbox-namespaced-agent-names). A hardcoded `worker` here would
59
+ // be a THIRD source of truth that can drift from the emitted file/frontmatter name, which
60
+ // is exactly the bug this rename set out to close.
61
+ const workerRole = definition.roles.find((r) => r.slug === "worker");
62
+ const workerName = emittedRoleName(workerRole ?? "worker");
63
+ return `Your FIRST response MUST open with:
64
+ \`🧠 PetBox memory active\`
65
+ Then next line, your self-intro — exactly:
66
+ \`<your model name> · orchestrator\` — + one sentence naming your working rules (search-before-rework, capture-as-you-go, respect the gates). Banner reaches only main loop; role always \`orchestrator\`.
67
+
68
+ **Orchestrate — delegate by DEFAULT.** SPAWN workers for anything beyond a trivial edit — implementation, research, review, multi-file. Fan-out is default; solo is exception to justify. If several calls deep implementing, stop and delegate. (No subagent → inline is fine.) Spawn as \`${workerName}\`.
69
+
70
+ Orchestrator notes (from definition): ${notes}`;
71
+ }
72
+
73
+ // No spawn_subagents: memory protocol still applies; do not force orchestrator-spawn mandate.
74
+ return `Your FIRST response MUST open with:
75
+ \`🧠 PetBox memory active\`
76
+ Then next line, your self-intro — exactly:
77
+ \`<your model name> · main\` — + one sentence naming your working rules (search-before-rework, capture-as-you-go, respect the gates). This harness does not declare spawn_subagents — do not assume subagent fan-out is available; work in the main session.`;
78
+ }
79
+
80
+ // Build the memory-protocol block for a project. `tool` maps a bare verb to the agent's
81
+ // fully-qualified MCP tool name so the SAME text renders correct tool names everywhere.
82
+ // Spawn/orchestrator prescriptions only when opts.harness has spawn_subagents.
83
+ export function buildProtocol(project: string, tool: ToolNamer, opts?: ProtocolOpts): string {
84
+ const memorySearch = tool("memory_search");
85
+ const memoryGet = tool("memory_get");
86
+ const sessionSearch = tool("session_search");
87
+ const sessionGet = tool("session_get");
88
+ const memoryRemember = tool("memory_remember");
89
+ const memoryUpsert = tool("memory_upsert");
90
+ const tasksUpsert = tool("tasks_upsert");
91
+ const tasksMethodologyGuide = tool("tasks_methodology_guide");
92
+ const tasksMethodologyGet = tool("tasks_methodology_get");
93
+ const tasksWorkflow = tool("tasks_workflow");
94
+
95
+ const allowSpawn = orchestrationPrescriptionsAllowed(opts?.harness);
96
+ const definition = opts?.definition ?? DEFAULT_AGENT_DEFINITION;
97
+ const intro = buildSelfIntro(allowSpawn, definition);
98
+
99
+ let out = `## PetBox memory
100
+
101
+ This project is wired to PetBox (project \`${project}\`) over the \`petbox\` MCP.
102
+
103
+ ${intro}
104
+
105
+ PetBox remembers curated facts AND full session history. Start from SEARCH, not assumption.
106
+
107
+ **Rule — search before rework:** before re-deriving, re-investigating, re-deciding anything about this project's past, run \`${memorySearch}\` FIRST — redoing remembered work is the failure this protocol prevents. Before storing a fact, search for an existing entry and edit it (duplicates poison recall).
108
+
109
+ **Entry points:**
110
+
111
+ - **Facts — \`${memorySearch}\`**: \`q\` of confident words (ANDed, prefix-match, stemmed). \`bodyLen\` for snippets. No \`scope\` cascades project⊕workspace, all stores incl. \`autocaptured\` (per-hit label). No \`q\` = listing. Full body: \`${memoryGet}\`.
112
+ - **Conversations — \`${sessionSearch}\`**: for HOW something was decided, error text, or detail a fact wouldn't carry — two-stage session-archive search; each hit carries the message ordinal → \`${sessionGet}\` for verbatim source.
113
+
114
+ **Capture-as-you-go** — don't wait. After a decision, fix, pattern, or preference: \`${memoryRemember}\` (\`text\` = learning; \`type\` = User|Feedback|Project|Reference; \`scope\` = workspace for cross-project/user facts, else omit). Curated/temporal edits: \`${memoryUpsert}\`.
115
+
116
+ **Autocapture is LIVE:** server distills facts into \`autocaptured\` after each session. So: (1) don't re-store autocaptured entries — promotion is owner's call; (2) end-of-session sweep = INSURANCE: before stopping, store 1-3 must-not-wait learnings (decision+why, root cause, gotcha) and 0-2 friction notes (what got in the way, what looked stale). Skip narration, skip anything derivable from code/git.
117
+
118
+ **Process defects are findings, not obstacles:** never silently work around a process/doc defect or doc-vs-reality contradiction — file it on THIS project's methodology (do not invent board/type/status). Read process via \`${tasksMethodologyGuide}\` / \`${tasksMethodologyGet}\`; legal types/statuses for a board via \`${tasksWorkflow}\`; then \`${tasksUpsert}\` with those values. Process criticism is welcome, never scope creep.`;
119
+
120
+ const source = opts?.source;
121
+ if (source === "resume" || source === "compact") {
122
+ out += `\n\nSession ${source} — also recall recent session/decision memories to pick up where you left off.`;
123
+ }
124
+ return out;
125
+ }
126
+
127
+ // The Claude-Code / Droid tool namer: petbox tools are exposed as `mcp__petbox__<verb>`.
128
+ export const mcpPetboxTool: ToolNamer = (verb) => `mcp__petbox__${verb}`;
129
+
130
+ // The opencode tool namer: petbox tools are exposed as `petbox_<verb>`.
131
+ export const opencodePetboxTool: ToolNamer = (verb) => `petbox_${verb}`;
132
+
133
+ // Factory Droid exposes MCP tools as `<server>___<tool>` (triple underscore) — observed
134
+ // live in exec mode (session 7be9f6c2: `mcp__petbox__*` answers "not permitted in exec
135
+ // mode"); the docs' `mcp__<server>__<tool>` form did not match the shipped CLI.
136
+ export const droidPetboxTool: ToolNamer = (verb) => `petbox___${verb}`;
@@ -0,0 +1,168 @@
1
+ // Process-level regression test for the SessionStart hooks' wall-clock behavior.
2
+ //
3
+ // GAP THIS CLOSES: every other test in this kit imports functions and calls them in-process.
4
+ // None of them spawn a hook as a REAL OS process the way Claude Code / Factory Droid actually
5
+ // invoke it (stdin piped, argv-less, process exit observed from outside). That gap is exactly
6
+ // why a whole class of bug — exit-time crashes, stdout-flush races, and event-loop-drain
7
+ // latency — was invisible to `node --test` for as long as it was: nothing here ever measured
8
+ // "how long does the OS process actually take, end to end."
9
+ //
10
+ // This test spawns the REAL pull-memory.ts / droid-pull-memory.ts files as child processes,
11
+ // pointed (via an isolated HOME so the real ~/.petbox/projects.json is never touched) at a
12
+ // throwaway local HTTP server that answers both endpoints the hook calls (agent-defs, canon)
13
+ // as fast as a loopback socket allows. Against a server that is NOT slow, the hook's own
14
+ // wall-clock overhead must stay small and CONSTANT — if a future change reintroduces an
15
+ // uncleared timer or an unreffed-too-late handle, this is the test that catches it (a real
16
+ // remote server's occasional slowness is a separate, already-covered concern — see this
17
+ // commit's notes on the false "dangling timer" theory that was investigated and disproven for
18
+ // that case; this test is deliberately immune to that because the fake server never stalls).
19
+ //
20
+ // Run: node --test src/pull-memory.test.ts
21
+
22
+ import assert from "node:assert/strict";
23
+ import { spawn } from "node:child_process";
24
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
25
+ import http from "node:http";
26
+ import { tmpdir } from "node:os";
27
+ import { dirname, join } from "node:path";
28
+ import { test } from "node:test";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ const HERE = dirname(fileURLToPath(import.meta.url));
32
+
33
+ // Generous enough to never flake on a loaded CI box, tight enough to fail hard if a
34
+ // regression reintroduces anything resembling the old ~8s (timeout-budget) or ~18s
35
+ // (lingering-socket) waits this kit's exit path was built to avoid.
36
+ const WALL_CLOCK_BUDGET_MS = 2000;
37
+
38
+ type SpawnResult = { code: number | null; stdout: string; stderr: string; wallMs: number };
39
+
40
+ function runHook(
41
+ scriptPath: string,
42
+ input: string,
43
+ env: NodeJS.ProcessEnv,
44
+ cwd: string,
45
+ ): Promise<SpawnResult> {
46
+ return new Promise((resolve, reject) => {
47
+ const t0 = Date.now();
48
+ const child = spawn(process.execPath, [scriptPath], { env, cwd });
49
+ let stdout = "";
50
+ let stderr = "";
51
+ child.stdout.on("data", (c) => (stdout += c));
52
+ child.stderr.on("data", (c) => (stderr += c));
53
+ child.on("error", reject);
54
+ child.on("close", (code) => {
55
+ resolve({ code, stdout, stderr, wallMs: Date.now() - t0 });
56
+ });
57
+ child.stdin.write(input);
58
+ child.stdin.end();
59
+ });
60
+ }
61
+
62
+ // A throwaway local server that answers both hook-called endpoints immediately: 404 for the
63
+ // agent-def (so the hook falls to its built-in DEFAULT — no LKG cache involved, keeps the
64
+ // fixture simple) and 200 with an empty canon (so no canon block is appended). Neither path
65
+ // exercises the LKG cache; that is deliberate — this test is about wall clock, not content.
66
+ function startFastFakeServer(): Promise<{ port: number; close: () => Promise<void> }> {
67
+ return new Promise((resolve) => {
68
+ const server = http.createServer((req, res) => {
69
+ if (req.url?.includes("/agent-defs/")) {
70
+ res.writeHead(404, { "Content-Type": "application/json" }).end("{}");
71
+ return;
72
+ }
73
+ if (req.url?.includes("/memory/") && req.url?.includes("/canon")) {
74
+ res
75
+ .writeHead(200, { "Content-Type": "application/json" })
76
+ .end(JSON.stringify({ project: null, workspace: null }));
77
+ return;
78
+ }
79
+ res.writeHead(404).end();
80
+ });
81
+ server.listen(0, "127.0.0.1", () => {
82
+ const port = (server.address() as { port: number }).port;
83
+ resolve({
84
+ port,
85
+ close: () => new Promise((r) => server.close(() => r())),
86
+ });
87
+ });
88
+ });
89
+ }
90
+
91
+ function setUpIsolatedRegistry(baseUrl: string): { home: string; projectDir: string } {
92
+ const home = mkdtempSync(join(tmpdir(), "petbox-hook-proc-"));
93
+ const projectDir = join(home, "fake-project");
94
+ mkdirSync(projectDir, { recursive: true });
95
+ mkdirSync(join(home, ".petbox"), { recursive: true });
96
+ writeFileSync(
97
+ join(home, ".petbox", "projects.json"),
98
+ JSON.stringify({
99
+ entries: [{ prefix: projectDir, project: "fake-project", envVar: "FAKE_HOOK_TEST_KEY", baseUrl }],
100
+ }),
101
+ );
102
+ return { home, projectDir };
103
+ }
104
+
105
+ test("pull-memory.ts as a real process: wall clock stays well under budget against a fast server", async () => {
106
+ const { close, port } = await startFastFakeServer();
107
+ const { home, projectDir } = setUpIsolatedRegistry(`http://127.0.0.1:${port}`);
108
+ try {
109
+ const env: NodeJS.ProcessEnv = {
110
+ ...process.env,
111
+ HOME: home,
112
+ USERPROFILE: home, // os.homedir() reads USERPROFILE on win32, HOME on POSIX
113
+ FAKE_HOOK_TEST_KEY: "fake-key-for-test",
114
+ };
115
+ const input = JSON.stringify({
116
+ session_id: "test",
117
+ cwd: projectDir,
118
+ hook_event_name: "SessionStart",
119
+ source: "startup",
120
+ });
121
+
122
+ const result = await runHook(join(HERE, "pull-memory.ts"), input, env, projectDir);
123
+
124
+ assert.equal(result.code, 0, `expected exit 0, got ${result.code}. stderr: ${result.stderr}`);
125
+ assert.equal(result.stderr, "", "hook must never write to stderr on the happy path");
126
+ assert.ok(result.stdout.length > 0, "hook must print the banner");
127
+ assert.ok(
128
+ result.wallMs < WALL_CLOCK_BUDGET_MS,
129
+ `wall clock ${result.wallMs}ms exceeded the ${WALL_CLOCK_BUDGET_MS}ms budget against a server that never stalls — ` +
130
+ `this is the regression this test exists to catch (an uncleared timer / late-unreffed handle holding the loop open)`,
131
+ );
132
+ } finally {
133
+ await close();
134
+ rmSync(home, { recursive: true, force: true });
135
+ }
136
+ });
137
+
138
+ test("droid-pull-memory.ts as a real process: wall clock stays well under budget against a fast server", async () => {
139
+ const { close, port } = await startFastFakeServer();
140
+ const { home, projectDir } = setUpIsolatedRegistry(`http://127.0.0.1:${port}`);
141
+ try {
142
+ const env: NodeJS.ProcessEnv = {
143
+ ...process.env,
144
+ HOME: home,
145
+ USERPROFILE: home,
146
+ FAKE_HOOK_TEST_KEY: "fake-key-for-test",
147
+ };
148
+ const input = JSON.stringify({
149
+ session_id: "test",
150
+ cwd: projectDir,
151
+ hook_event_name: "SessionStart",
152
+ source: "startup",
153
+ });
154
+
155
+ const result = await runHook(join(HERE, "droid-pull-memory.ts"), input, env, projectDir);
156
+
157
+ assert.equal(result.code, 0, `expected exit 0, got ${result.code}. stderr: ${result.stderr}`);
158
+ assert.equal(result.stderr, "", "hook must never write to stderr on the happy path");
159
+ assert.ok(result.stdout.length > 0, "hook must print the banner");
160
+ assert.ok(
161
+ result.wallMs < WALL_CLOCK_BUDGET_MS,
162
+ `wall clock ${result.wallMs}ms exceeded the ${WALL_CLOCK_BUDGET_MS}ms budget against a server that never stalls`,
163
+ );
164
+ } finally {
165
+ await close();
166
+ rmSync(home, { recursive: true, force: true });
167
+ }
168
+ });
@@ -0,0 +1,119 @@
1
+ // Claude Code SessionStart hook (global) — port of pull-memory.ps1.
2
+ //
3
+ // Injects the PetBox memory protocol so the agent recalls relevant memory at session start
4
+ // and captures learnings as it works, via the already-connected petbox MCP (native memory.*
5
+ // tools). Stdout is added to the session context by Claude Code.
6
+ //
7
+ // The project is resolved from cwd via the shared registry; if the cwd is not a registered
8
+ // project this prints nothing and exits 0. Best-effort, never blocks — always exit 0.
9
+ //
10
+ // The banner's orchestrator notes resolve server → LKG cache → built-in default, same as
11
+ // `apply` (resolveAgentDefinitionForSession, wrapping agent-def-fetch.ts's
12
+ // resolveAgentDefinitionWithLkg). That fetch and the canon fetch run SEQUENTIALLY under one
13
+ // shared SESSION_FETCH_BUDGET_MS wall-clock budget (not Promise.all'd): the happy path for
14
+ // both requests together is ~100-200ms, so concurrency bought nothing there, and it made the
15
+ // two independent 8s timeouts stack in the worst case anyway if reasoned about naively.
16
+ // The real worst case — PetBox stalling under load from parallel agents — is handled by
17
+ // giving the whole hook one budget: whatever the agent-def fetch doesn't spend, the canon
18
+ // fetch inherits as its own timeout, so the combined worst case stays ~SESSION_FETCH_BUDGET_MS,
19
+ // not 2x it. A fetch that starts with little/no budget left degrades to its own fallback
20
+ // (LKG cache / built-in default / no canon) rather than blocking — see canon.ts's fetchCanon.
21
+
22
+ import { resolveAgentDefinitionForSession } from "./agent-def-fetch.ts";
23
+ import { fetchCanonBlock } from "./canon.ts";
24
+ import { unrefLingeringHandles } from "./hook-drain.ts";
25
+ import { buildProtocol, mcpPetboxTool } from "./protocol.ts";
26
+ import { resolveProject } from "./registry.ts";
27
+
28
+ // Shared wall-clock budget for BOTH fetches combined (agent-def, then canon) — see the
29
+ // module comment above.
30
+ //
31
+ // Deliberately SHORT. Waiting long for the server only pays off when the alternative is
32
+ // nothing — and it isn't: the LKG cache holds the same definition and canon, which change
33
+ // on the order of weeks, not sessions. So a slow server (a redeploy restarting the
34
+ // container is the common case) must cost the session start ~2s, not ~8s. Freshness is
35
+ // only lost in the narrow window where the documents changed AND the server is down right
36
+ // now; staleness there is cheap, an 8s stall on every session start is not.
37
+ const SESSION_FETCH_BUDGET_MS = 2000;
38
+
39
+ type HookInput = { cwd?: string; source?: string };
40
+
41
+ function readStdin(): Promise<string> {
42
+ return new Promise((resolve) => {
43
+ let buf = "";
44
+ process.stdin.setEncoding("utf8");
45
+ process.stdin.on("data", (c) => (buf += c));
46
+ process.stdin.on("end", () => resolve(buf));
47
+ process.stdin.on("error", () => resolve(buf));
48
+ });
49
+ }
50
+
51
+ // process.stdout.write() on a Windows pipe is asynchronous — the call can return before the
52
+ // OS-level write completes. Awaiting the write callback guarantees the banner is fully flushed
53
+ // before main() resolves, so the process never ends mid-write and truncates the banner (a
54
+ // slower server / bigger canon could otherwise ship a partial banner into the agent's context,
55
+ // silently — see the exit comment below for why we no longer race this against process.exit()).
56
+ function writeStdout(text: string): Promise<void> {
57
+ return new Promise((resolve) => {
58
+ if (text.length === 0) {
59
+ resolve();
60
+ return;
61
+ }
62
+ process.stdout.write(text, () => resolve());
63
+ });
64
+ }
65
+
66
+ async function main(): Promise<void> {
67
+ let source = "startup";
68
+ let cwd = "";
69
+ try {
70
+ const raw = await readStdin();
71
+ const j: HookInput = JSON.parse(raw);
72
+ if (typeof j.source === "string" && j.source.trim()) source = j.source.trim();
73
+ if (typeof j.cwd === "string") cwd = j.cwd;
74
+ } catch {
75
+ // fall through with defaults; cwd stays empty → resolves to null below
76
+ }
77
+
78
+ try {
79
+ const resolved = resolveProject(cwd);
80
+ if (!resolved) return; // not a registered project → no output
81
+
82
+ // Sequential under one shared budget (not Promise.all): whatever the first fetch doesn't
83
+ // spend is what the second gets, so the combined worst case is bounded by
84
+ // SESSION_FETCH_BUDGET_MS instead of stacking two independent timeouts.
85
+ const budgetStart = Date.now();
86
+ const defResult = await resolveAgentDefinitionForSession(resolved, {
87
+ timeoutMs: SESSION_FETCH_BUDGET_MS,
88
+ });
89
+ const remainingMs = SESSION_FETCH_BUDGET_MS - (Date.now() - budgetStart);
90
+ const canon = await fetchCanonBlock(resolved, { timeoutMs: remainingMs });
91
+
92
+ let out = buildProtocol(resolved.project, mcpPetboxTool, {
93
+ source,
94
+ harness: "claude-code",
95
+ definition: defResult.definition,
96
+ });
97
+ // Append the curated memory canon when available (best-effort; degrades to nothing).
98
+ if (canon) out += `\n\n${canon}`;
99
+ await writeStdout(out);
100
+ } catch {
101
+ // best-effort
102
+ }
103
+ }
104
+
105
+ // Exit cleanly instead of tearing the process down mid-close: a hard process.exit() while
106
+ // libuv handles from the HTTP fetches above are still closing raced Windows' async
107
+ // handle teardown (`Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c`)
108
+ // and could truncate the stdout write above (fire-and-forget on a Windows pipe). Setting
109
+ // exitCode and returning lets Node drain the event loop naturally instead — `Connection:
110
+ // close` (canon.ts / agent-def-fetch.ts) means a request that got a full response never
111
+ // leaves a keep-alive socket behind, and unrefLingeringHandles covers the other case
112
+ // (a request aborted mid-flight against a genuinely stalled server can leave its TLSSocket
113
+ // alive for several MORE seconds even with Connection: close — measured, not assumed; see
114
+ // hook-drain.ts) so a slow session start can't turn into an ~18s stall waiting on a socket
115
+ // nothing is still using.
116
+ main().finally(() => {
117
+ process.exitCode = 0;
118
+ unrefLingeringHandles();
119
+ });