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,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
|
+
});
|
package/src/protocol.ts
ADDED
|
@@ -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
|
+
});
|