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,128 @@
1
+ // Integration test for doctor gating the SAME definition apply would compile
2
+ // (bug: doctor-gates-wrong-definition).
3
+ //
4
+ // wire.ts runs main() at import time (see its own comment on why testable logic lives in side
5
+ // modules), so the only way to exercise `doctor`'s actual argv/behavior end-to-end is to spawn
6
+ // it as a real subprocess — same technique CI itself uses (`node src/wire.ts <args>`). This test
7
+ // seeds a fake ~/.petbox (via USERPROFILE/HOME redirection) with:
8
+ // - a registry entry for a throwaway project directory (identity)
9
+ // - an API key in the key store for that project's env var (resolveProject requires one)
10
+ // - an LKG agent-definition cache carrying a definition whose name is NOT "default" (the
11
+ // built-in DEFAULT_AGENT_DEFINITION.name), so the two are trivially distinguishable
12
+ //
13
+ // Before the fix, `doctor` ignored all of this and always gated the hard-coded
14
+ // DEFAULT_AGENT_DEFINITION (name "default") — so its output would show `definition="default"`
15
+ // even with a differently-named LKG cache sitting right there, identical to what `apply
16
+ // --offline` would use. After the fix, `doctor --offline` must report the LKG-cached name.
17
+ //
18
+ // Run: node --test src/doctor-definition.test.ts
19
+
20
+ import assert from "node:assert/strict";
21
+ import { spawnSync } from "node:child_process";
22
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+ import { test } from "node:test";
26
+
27
+ const WIRE_TS = join(import.meta.dirname, "wire.ts");
28
+
29
+ function freshDir(prefix: string): string {
30
+ return realpathSync(mkdtempSync(join(tmpdir(), prefix)));
31
+ }
32
+
33
+ // A minimal but shape-valid AgentDefinition + cache envelope (agent-def-fetch.ts's
34
+ // AgentDefCacheRecord / parseAgentDefinitionResponse contract).
35
+ function makeCustomDefRecord(name: string) {
36
+ return {
37
+ key: "default",
38
+ version: 7,
39
+ fetchedAt: new Date().toISOString(),
40
+ definition: {
41
+ name,
42
+ roles: [
43
+ {
44
+ slug: "worker",
45
+ tier: "worker",
46
+ requiredCapabilities: [],
47
+ },
48
+ ],
49
+ },
50
+ };
51
+ }
52
+
53
+ function runDoctor(cwd: string, homeDir: string, extraArgs: string[] = []): { stdout: string; stderr: string; status: number | null } {
54
+ const res = spawnSync(process.execPath, [WIRE_TS, "doctor", ...extraArgs], {
55
+ cwd,
56
+ encoding: "utf8",
57
+ env: {
58
+ ...process.env,
59
+ // Windows resolves homedir() from USERPROFILE; POSIX from HOME. Set both so the test is
60
+ // portable across the dev machine (win32) and any Linux CI runner.
61
+ USERPROFILE: homeDir,
62
+ HOME: homeDir,
63
+ HOMEDRIVE: undefined,
64
+ HOMEPATH: undefined,
65
+ },
66
+ });
67
+ return { stdout: res.stdout ?? "", stderr: res.stderr ?? "", status: res.status };
68
+ }
69
+
70
+ test("doctor --offline gates the LKG-cached definition, not the hard-coded built-in default", () => {
71
+ const homeDir = freshDir("petbox-doctor-home-");
72
+ const projectDir = freshDir("petbox-doctor-proj-");
73
+ try {
74
+ const petboxDir = join(homeDir, ".petbox");
75
+ mkdirSync(petboxDir, { recursive: true });
76
+ mkdirSync(join(petboxDir, "cache"), { recursive: true });
77
+
78
+ const envVar = "PETBOX_DOCTOR_TEST_API_KEY";
79
+ writeFileSync(
80
+ join(petboxDir, "projects.json"),
81
+ JSON.stringify({ entries: [{ prefix: projectDir, project: "doctor-test-proj", envVar }] }, null, 2),
82
+ "utf8",
83
+ );
84
+ writeFileSync(join(petboxDir, "keys.json"), JSON.stringify({ [envVar]: "fake-key-value" }, null, 2), "utf8");
85
+ writeFileSync(
86
+ join(petboxDir, "cache", "doctor-test-proj.agent-def.json"),
87
+ JSON.stringify(makeCustomDefRecord("custom-lkg-def"), null, 2),
88
+ "utf8",
89
+ );
90
+
91
+ const { stdout, stderr, status } = runDoctor(projectDir, homeDir, ["--offline"]);
92
+ const out = stdout + stderr;
93
+
94
+ assert.match(
95
+ out,
96
+ /definition="custom-lkg-def"/,
97
+ `doctor must gate the LKG-cached definition (custom-lkg-def), not the built-in default. Full output:\n${out}`,
98
+ );
99
+ assert.doesNotMatch(
100
+ out,
101
+ /definition="default"/,
102
+ `doctor must NOT fall back to the built-in DEFAULT_AGENT_DEFINITION when an LKG cache exists. Full output:\n${out}`,
103
+ );
104
+ assert.match(out, /using LKG definition/, "doctor must name which link it resolved (LKG here)");
105
+ // The custom def's single worker role has no requiredCapabilities, so every known harness
106
+ // passes trivially — doctor should exit clean.
107
+ assert.equal(status, 0);
108
+ } finally {
109
+ rmSync(homeDir, { recursive: true, force: true });
110
+ rmSync(projectDir, { recursive: true, force: true });
111
+ }
112
+ });
113
+
114
+ test("doctor --offline with NO registry entry / NO LKG cache falls back to the built-in default (unchanged behavior)", () => {
115
+ const homeDir = freshDir("petbox-doctor-home-");
116
+ const projectDir = freshDir("petbox-doctor-proj-");
117
+ try {
118
+ // No ~/.petbox at all — doctor must still work offline against the built-in default.
119
+ const { stdout, stderr, status } = runDoctor(projectDir, homeDir, ["--offline"]);
120
+ const out = stdout + stderr;
121
+ assert.match(out, /definition="default"/, `expected the built-in default when nothing is registered. Full output:\n${out}`);
122
+ assert.match(out, /offline default definition/);
123
+ assert.equal(status, 0);
124
+ } finally {
125
+ rmSync(homeDir, { recursive: true, force: true });
126
+ rmSync(projectDir, { recursive: true, force: true });
127
+ }
128
+ });
@@ -0,0 +1,126 @@
1
+ // Factory Droid SessionStart hook (global) — the droid port of pull-memory.ts.
2
+ //
3
+ // Injects the PetBox memory protocol + curated canon so the agent recalls relevant memory at
4
+ // session start and captures learnings as it works, via the already-connected petbox MCP.
5
+ //
6
+ // Droid names MCP tools `<server>___<tool>` (triple underscore) at runtime — observed live
7
+ // in exec mode; the docs' `mcp__<server>__<tool>` form did not match. The protocol renders
8
+ // with droidPetboxTool (`petbox___*`); the canon block stays byte-identical across agents. Droid's SessionStart stdin
9
+ // is snake_case (`session_id`, `transcript_path`, `cwd`, `source`), the same shape Claude Code
10
+ // uses, so we resolve the project from `cwd` and pass `source` through for the resume nudge.
11
+ //
12
+ // Output contract (docs): a SessionStart hook returns context to the model via the structured
13
+ // JSON `{ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext } }` on stdout
14
+ // (stdout-as-context is also accepted, but the structured form is the documented preference).
15
+ //
16
+ // Best-effort, always exit 0, no output for an unregistered cwd.
17
+ //
18
+ // The banner's orchestrator notes resolve server → LKG cache → built-in default, same as
19
+ // `apply` (resolveAgentDefinitionForSession, wrapping agent-def-fetch.ts's
20
+ // resolveAgentDefinitionWithLkg). That fetch and the canon fetch run SEQUENTIALLY under one
21
+ // shared SESSION_FETCH_BUDGET_MS wall-clock budget (not Promise.all'd) — verbatim the same
22
+ // reasoning as pull-memory.ts (this file's Claude Code counterpart): the happy path for both
23
+ // requests together is ~100-200ms, so concurrency bought nothing there, and it made the two
24
+ // independent 8s timeouts stack in the worst case if reasoned about naively. Giving the whole
25
+ // hook one budget means whatever the agent-def fetch doesn't spend, the canon fetch inherits
26
+ // as its own timeout, so the combined worst case stays ~SESSION_FETCH_BUDGET_MS, not 2x it. A
27
+ // fetch that starts with little/no budget left degrades to its own fallback (LKG cache /
28
+ // built-in default / no canon) rather than blocking — see canon.ts's fetchCanon.
29
+
30
+ import { resolveAgentDefinitionForSession } from "./agent-def-fetch.ts";
31
+ import { fetchCanonBlock } from "./canon.ts";
32
+ import { unrefLingeringHandles } from "./hook-drain.ts";
33
+ import { buildProtocol, droidPetboxTool } from "./protocol.ts";
34
+ import { resolveProject } from "./registry.ts";
35
+
36
+ // Shared wall-clock budget for BOTH fetches combined (agent-def, then canon) — see the
37
+ // module comment above. Kept in step with pull-memory.ts: short on purpose, because the LKG
38
+ // cache holds the same documents and a redeploy restarting the server must not tax every
39
+ // session start with an 8s stall. Stale-but-instant beats fresh-but-late here.
40
+ const SESSION_FETCH_BUDGET_MS = 2000;
41
+
42
+ type HookInput = { cwd?: string; source?: string };
43
+
44
+ function readStdin(): Promise<string> {
45
+ return new Promise((resolve) => {
46
+ let buf = "";
47
+ process.stdin.setEncoding("utf8");
48
+ process.stdin.on("data", (c) => (buf += c));
49
+ process.stdin.on("end", () => resolve(buf));
50
+ process.stdin.on("error", () => resolve(buf));
51
+ });
52
+ }
53
+
54
+ // process.stdout.write() on a Windows pipe is asynchronous — the call can return before the
55
+ // OS-level write completes. Awaiting the write callback guarantees the context JSON is fully
56
+ // flushed before main() resolves, so the process never ends mid-write and truncates it (see
57
+ // pull-memory.ts's identical helper for the full rationale).
58
+ function writeStdout(text: string): Promise<void> {
59
+ return new Promise((resolve) => {
60
+ if (text.length === 0) {
61
+ resolve();
62
+ return;
63
+ }
64
+ process.stdout.write(text, () => resolve());
65
+ });
66
+ }
67
+
68
+ async function main(): Promise<void> {
69
+ let source = "startup";
70
+ let cwd = "";
71
+ try {
72
+ const raw = await readStdin();
73
+ const j: HookInput = JSON.parse(raw);
74
+ if (typeof j.source === "string" && j.source.trim()) source = j.source.trim();
75
+ if (typeof j.cwd === "string") cwd = j.cwd;
76
+ } catch {
77
+ // fall through with defaults; cwd stays empty → resolves to null below
78
+ }
79
+
80
+ try {
81
+ const resolved = resolveProject(cwd);
82
+ if (!resolved) return; // not a registered project → no output
83
+
84
+ // Sequential under one shared budget (not Promise.all): whatever the first fetch doesn't
85
+ // spend is what the second gets, so the combined worst case is bounded by
86
+ // SESSION_FETCH_BUDGET_MS instead of stacking two independent timeouts.
87
+ const budgetStart = Date.now();
88
+ const defResult = await resolveAgentDefinitionForSession(resolved, {
89
+ timeoutMs: SESSION_FETCH_BUDGET_MS,
90
+ });
91
+ const remainingMs = SESSION_FETCH_BUDGET_MS - (Date.now() - budgetStart);
92
+ const canon = await fetchCanonBlock(resolved, { timeoutMs: remainingMs });
93
+
94
+ let context = buildProtocol(resolved.project, droidPetboxTool, {
95
+ source,
96
+ harness: "droid",
97
+ definition: defResult.definition,
98
+ });
99
+ // Append the curated memory canon when available (best-effort; degrades to nothing).
100
+ if (canon) context += `\n\n${canon}`;
101
+ const out = {
102
+ hookSpecificOutput: {
103
+ hookEventName: "SessionStart",
104
+ additionalContext: context,
105
+ },
106
+ };
107
+ await writeStdout(JSON.stringify(out));
108
+ } catch {
109
+ // best-effort
110
+ }
111
+ }
112
+
113
+ // Exit cleanly instead of tearing the process down mid-close: a hard process.exit() while
114
+ // libuv handles from the concurrent HTTP fetches are still closing raced Windows' async
115
+ // handle teardown (`Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c`)
116
+ // and could truncate the stdout write above (fire-and-forget on a Windows pipe) — the same
117
+ // crash observed in pull-memory.ts (see its exit comment). Setting exitCode and returning lets
118
+ // Node drain the event loop naturally instead — `Connection: close` (canon.ts /
119
+ // agent-def-fetch.ts) covers a completed fetch, and unrefLingeringHandles covers a fetch
120
+ // aborted mid-flight against a stalled server (measured to leave its TLSSocket alive for
121
+ // several more seconds otherwise; see hook-drain.ts) so a slow session start can't turn into
122
+ // a multi-second stall on a handle nothing is still using.
123
+ main().finally(() => {
124
+ process.exitCode = 0;
125
+ unrefLingeringHandles();
126
+ });
@@ -0,0 +1,100 @@
1
+ // Factory Droid Stop hook (global) — the droid port of push-session.ts.
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
+ // Droid's Stop stdin is snake_case (`session_id`, `transcript_path`, `cwd`, `stop_hook_active`)
8
+ // — the same shape Claude Code uses. We parse the droid JSONL (buildDroidMessages: user/
9
+ // assistant TEXT turns only, tool dumps + `<system-reminder>` injections excluded) and push
10
+ // only the INCREMENT via the server-authoritative append cursor (see append.ts): this process
11
+ // is fresh each turn, so it passes null and pushTranscript optimistically resends a small
12
+ // idempotent overlap window; a contiguity gap comes back as a structured 409 with the server's
13
+ // lastOrdinal and the tail is resent from there. Old servers without the append route fall back
14
+ // to the legacy full-snapshot push. Best-effort: every failure is swallowed and we ALWAYS
15
+ // exit 0 — never break the user's session.
16
+
17
+ import { pushTranscript } from "./append.ts";
18
+ import { buildDroidMessages } from "./droid-transcript.ts";
19
+ import { unrefLingeringHandles } from "./hook-drain.ts";
20
+ import { resolveProject } from "./registry.ts";
21
+ import type { Msg } from "./transcript.ts";
22
+
23
+ const FETCH_TIMEOUT_MS = 12000;
24
+
25
+ type HookInput = {
26
+ session_id?: string;
27
+ transcript_path?: string;
28
+ cwd?: string;
29
+ stop_hook_active?: boolean;
30
+ };
31
+
32
+ function readStdin(): Promise<string> {
33
+ return new Promise((resolve) => {
34
+ let buf = "";
35
+ process.stdin.setEncoding("utf8");
36
+ process.stdin.on("data", (c) => (buf += c));
37
+ process.stdin.on("end", () => resolve(buf));
38
+ process.stdin.on("error", () => resolve(buf));
39
+ });
40
+ }
41
+
42
+ async function main(): Promise<void> {
43
+ try {
44
+ const raw = await readStdin();
45
+ let j: HookInput;
46
+ try {
47
+ j = JSON.parse(raw);
48
+ } catch {
49
+ return;
50
+ }
51
+ if (j.stop_hook_active) return;
52
+
53
+ // FIRST guard: not a registered project → silent no-op (before any file/network work).
54
+ const resolved = resolveProject(j.cwd ?? "");
55
+ if (!resolved) return;
56
+
57
+ const sid = (j.session_id ?? "").trim();
58
+ const tp = (j.transcript_path ?? "").trim();
59
+ if (!sid || !tp) return;
60
+
61
+ let msgs: Msg[];
62
+ try {
63
+ msgs = await buildDroidMessages(tp);
64
+ } catch {
65
+ return; // transcript missing/unreadable
66
+ }
67
+ if (msgs.length === 0) return; // empty body → server returns 400, don't push
68
+
69
+ // Fresh process each turn → no remembered cursor (null): pushTranscript guesses an
70
+ // idempotent overlap window and self-heals off the server's structured gap reject.
71
+ await pushTranscript(
72
+ {
73
+ baseUrl: resolved.baseUrl,
74
+ project: resolved.project,
75
+ sessionId: sid,
76
+ apiKey: resolved.apiKey,
77
+ agent: "droid",
78
+ timeoutMs: FETCH_TIMEOUT_MS,
79
+ },
80
+ msgs,
81
+ null,
82
+ );
83
+ } catch {
84
+ // best-effort: never break the user's session
85
+ }
86
+ }
87
+
88
+ // Exit cleanly instead of tearing the process down mid-close: a hard process.exit() while
89
+ // libuv handles from the HTTP push above are still closing can race Windows' async handle
90
+ // teardown (`Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c`) — the
91
+ // same crash observed in pull-memory.ts (see its exit comment). Setting exitCode and returning
92
+ // lets Node drain the event loop naturally instead — `Connection: close` (append.ts) covers a
93
+ // completed push, and unrefLingeringHandles covers a push aborted mid-flight against a stalled
94
+ // server (measured to leave its TLSSocket alive for several more seconds otherwise; see
95
+ // hook-drain.ts) so a slow Stop hook can't turn into a multi-second stall on a handle nothing
96
+ // is still using.
97
+ main().finally(() => {
98
+ process.exitCode = 0;
99
+ unrefLingeringHandles();
100
+ });
@@ -0,0 +1,47 @@
1
+ // Factory Droid transcript parsing — the droid analogue of transcript.ts, kept as a thin
2
+ // adapter over the SHARED text-extraction/exclusion rules so what a "session turn" is cannot
3
+ // drift between agents (spec: agent-wiring, wiring-single-source).
4
+ //
5
+ // A droid transcript is JSONL, but its record shape differs from Claude Code's:
6
+ // - line 1 is `{type:"session_start", id, title, cwd, ...}` (skipped);
7
+ // - each turn is `{type:"message", message:{role, content}}` where content is either a
8
+ // string or an array of parts `{type:"text"|"thinking"|"tool_use"|"tool_result", ...}`.
9
+ // We keep the user/assistant TEXT turns in order and drop tool_use/tool_result/thinking dumps
10
+ // and harness chrome — reusing extractText (text-parts only) + isExcluded (`<system-reminder>`
11
+ // and friends) from transcript.ts so both agents share one definition. Droid also flags
12
+ // injected context with `visibility:"llm_only"`; we skip those too as a belt-and-suspenders
13
+ // guard alongside the system-reminder prefix check.
14
+ //
15
+ // Plain TS for native node type-stripping: zero deps.
16
+
17
+ import { createReadStream } from "node:fs";
18
+ import { createInterface } from "node:readline";
19
+ import { extractText, isExcluded, type Msg } from "./transcript.ts";
20
+
21
+ // Collect the user/assistant text messages in droid transcript order. No rendering and no
22
+ // cap: the server needs the full, ordered transcript to assign stable per-message ordinals.
23
+ export async function buildDroidMessages(transcriptPath: string): Promise<Msg[]> {
24
+ const rl = createInterface({
25
+ input: createReadStream(transcriptPath, { encoding: "utf8" }),
26
+ crlfDelay: Infinity,
27
+ });
28
+ const msgs: Msg[] = [];
29
+ for await (const line of rl) {
30
+ if (!line || line.trim().length === 0) continue;
31
+ let e: any;
32
+ try {
33
+ e = JSON.parse(line);
34
+ } catch {
35
+ continue;
36
+ }
37
+ if (e.type !== "message" || !e.message) continue;
38
+ const role = e.message.role;
39
+ if (role !== "user" && role !== "assistant") continue;
40
+ if (e.message.visibility === "llm_only") continue; // injected context, not a real turn
41
+ const text = extractText(e.message);
42
+ if (text.length === 0) continue; // thinking/tool_use/tool_result-only turn → no text
43
+ if (isExcluded(text)) continue; // <system-reminder> / harness chrome
44
+ msgs.push({ role, content: text });
45
+ }
46
+ return msgs;
47
+ }
@@ -0,0 +1,126 @@
1
+ // Per-harness capability matrix for the agent-artifact compiler.
2
+ //
3
+ // Spec (definition-truthfulness, per-harness-artifact): a role may only require
4
+ // capabilities the target harness declares. This matrix is kit data (versioned with
5
+ // the npm package) — capabilities change with harness versions, not with the
6
+ // portable agent definition.
7
+ //
8
+ // EVERY cell is a factual claim about a harness, taken from that harness's docs
9
+ // (or a verified live observation). Do not invent; do not copy another harness's row.
10
+ //
11
+ // Plain TS for native node type-stripping: zero deps.
12
+
13
+ export type HarnessId = "claude-code" | "opencode" | "droid";
14
+
15
+ /** Capability ids a role may list in requiredCapabilities. */
16
+ export type Capability =
17
+ | "mcp_main_session"
18
+ | "mcp_subagent"
19
+ | "dynamic_model_at_spawn"
20
+ | "role_files"
21
+ | "builtin_explore_inherits_model"
22
+ | "hooks"
23
+ | "spawn_subagents";
24
+
25
+ export const HARNESS_IDS: readonly HarnessId[] = ["claude-code", "opencode", "droid"] as const;
26
+
27
+ export const CAPABILITIES: readonly Capability[] = [
28
+ "mcp_main_session",
29
+ "mcp_subagent",
30
+ "dynamic_model_at_spawn",
31
+ "role_files",
32
+ "builtin_explore_inherits_model",
33
+ "hooks",
34
+ "spawn_subagents",
35
+ ] as const;
36
+
37
+ /**
38
+ * Capability matrix — sources cited per cell.
39
+ *
40
+ * claude-code (verified live 2026-07-10 + 2026-07-12 + Anthropic Claude Code agent docs):
41
+ * mcp_main_session — main session has MCP.
42
+ * mcp_subagent — declared (verified live 2026-07-12: a subagent with an unrestricted
43
+ * tool set called an MCP tool, mcp__petbox__whoami, successfully). The earlier
44
+ * "subagents lack MCP" note (2026-07-10) was an artifact of a hand-written
45
+ * ~/.claude/agents/worker.md `tools:` whitelist that hid MCP — not a harness limit.
46
+ * dynamic_model_at_spawn — model passed dynamically at Agent/Task spawn.
47
+ * role_files — project/user agents under .claude/agents/*.md.
48
+ * builtin_explore_inherits_model — built-in Explore inherits parent model by default.
49
+ * hooks — SessionStart/Stop/UserPromptSubmit hook surface.
50
+ * spawn_subagents — main session spawns via Agent/Task tool; subagents do not re-spawn.
51
+ *
52
+ * opencode:
53
+ * mcp_main_session / mcp_subagent — plugin injects system text with no main/subagent
54
+ * branching; MCP tools available the same way in both (kit observation).
55
+ * dynamic_model_at_spawn — NOT declared (opencode PR #18588: model not dynamic at spawn;
56
+ * roles are files only).
57
+ * role_files — .opencode/agent/*.md.
58
+ * builtin_explore_inherits_model — built-in explore inherits parent model.
59
+ * hooks — not a first-class CC-hook surface; leave undeclared.
60
+ * spawn_subagents — can spawn subagents (agent types / Task-equivalent).
61
+ *
62
+ * droid (Factory official docs — do not reintroduce enableHooks-MCP assumptions):
63
+ * role_files — custom droids in .factory/droids/*.md (project) and ~/.factory/droids/
64
+ * (user); project overrides user on name clash.
65
+ * https://docs.factory.ai/cli/configuration/custom-droids
66
+ * spawn_subagents — main session spawns via Task tool (subagent_type = built-in or
67
+ * custom droid name); subagents cannot spawn further (Task unavailable to them).
68
+ * https://docs.factory.ai/cli/configuration/custom-droids
69
+ * dynamic_model_at_spawn — droid frontmatter model: inherit | explicit id; inherit
70
+ * follows parent / complexity routing.
71
+ * https://docs.factory.ai/cli/configuration/custom-droids § Controlling the model
72
+ * mcp_main_session — MCP via .factory/mcp.json (user/folder/project).
73
+ * https://docs.factory.ai/cli/configuration/mcp
74
+ * mcp_subagent — custom droid mcpServers frontmatter scopes which configured MCP
75
+ * servers the subagent receives.
76
+ * https://docs.factory.ai/cli/configuration/custom-droids § Selecting MCP servers
77
+ * hooks — Factory settings hooks (Stop/SessionStart/UserPromptSubmit) used by the kit.
78
+ * builtin_explore_inherits_model — NOT declared: Factory ships built-in `explorer` with
79
+ * model inherit, but we do not equate it to CC/opencode "Explore" without a separate
80
+ * verification pass (leave honestly unspecified).
81
+ * Hierarchy note: org/project/folder/user settings; write project-level .factory/droids/
82
+ * only (additive). https://docs.factory.ai/enterprise/hierarchical-settings-and-org-control
83
+ */
84
+ const MATRIX: Readonly<Record<HarnessId, readonly Capability[]>> = {
85
+ "claude-code": [
86
+ "mcp_main_session", // live: main has MCP
87
+ "mcp_subagent", // live 2026-07-12: unrestricted subagent called mcp__petbox__whoami
88
+ "dynamic_model_at_spawn", // model at Agent/Task spawn
89
+ "role_files", // .claude/agents/*.md
90
+ "builtin_explore_inherits_model", // built-in Explore inherits parent
91
+ "hooks", // SessionStart/Stop/UserPromptSubmit
92
+ "spawn_subagents", // main spawns; subagents do not re-spawn
93
+ ],
94
+ opencode: [
95
+ "mcp_main_session", // same MCP surface main/sub (no branching inject)
96
+ "mcp_subagent", // subagents see MCP like main
97
+ "role_files", // .opencode/agent/*.md
98
+ "builtin_explore_inherits_model", // built-in explore inherits
99
+ "spawn_subagents", // can spawn agent types
100
+ // no dynamic_model_at_spawn — PR #18588
101
+ // no hooks — not CC-hook surface
102
+ ],
103
+ droid: [
104
+ "mcp_main_session", // docs: .factory/mcp.json — https://docs.factory.ai/cli/configuration/mcp
105
+ "mcp_subagent", // docs: mcpServers on custom droid — custom-droids § Selecting MCP servers
106
+ "spawn_subagents", // docs: Task tool from main — custom-droids
107
+ "role_files", // docs: .factory/droids/*.md — custom-droids
108
+ "dynamic_model_at_spawn", // docs: model inherit | explicit — custom-droids § model
109
+ "hooks", // kit uses Factory settings hooks
110
+ // no builtin_explore_inherits_model — explorer exists but not equated to CC Explore here
111
+ ],
112
+ };
113
+
114
+ /** Known capability set for a harness id. Unknown id → empty set (never invent). */
115
+ export function harnessCapabilities(harness: string): ReadonlySet<string> {
116
+ const list = (MATRIX as Record<string, readonly Capability[] | undefined>)[harness];
117
+ return new Set(list ?? []);
118
+ }
119
+
120
+ export function isKnownHarness(id: string): id is HarnessId {
121
+ return (HARNESS_IDS as readonly string[]).includes(id);
122
+ }
123
+
124
+ export function hasCapability(harness: string, capability: string): boolean {
125
+ return harnessCapabilities(harness).has(capability);
126
+ }