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,164 @@
1
+ // Definition truthfulness gate (definition-truthfulness).
2
+ //
3
+ // Two claims are gated, with the SAME contract (list violations → callers fail loud,
4
+ // never silently emit a role that carries one):
5
+ //
6
+ // 1. capability — a role may only require capabilities the target harness declares
7
+ // (harness-capabilities.ts).
8
+ // 2. model — a role's LOCAL model binding (~/.petbox/roles.json, passed in per role) is
9
+ // three-tier classified against the target harness (harness-models.ts): known/unknown
10
+ // never block, only a recognizably FOREIGN-harness id shape does (revised 2026-07-13,
11
+ // task `model-gate-revision-premise-falsified`, after a live measurement disproved the
12
+ // original premise — Claude Code does NOT silently inherit on an unresolvable frontmatter
13
+ // model, it fails LOUD at runtime with an API error; the gate now exists to catch a
14
+ // cross-harness id BEFORE that loud failure, not to prevent a silent one). Intake
15
+ // `wire-apply-writes-unresolvable-model-id`, 2026-07-12: a droid id landed in the
16
+ // claude-code block of roles.json.
17
+ // An ABSENT binding is not a violation — that is the harness's documented inherit
18
+ // behaviour and is surfaced as a warning by the caller, not a block. Neither is an
19
+ // "unknown" model (shape-valid, just not on the small known-alias list) — that is also
20
+ // surfaced as a warning, never a block (see modelShapeWarning below).
21
+ //
22
+ // NEVER silently drop a violation — callers must fail loud when the list is non-empty
23
+ // (doctor / apply / tests).
24
+ //
25
+ // Plain TS for native node type-stripping: zero deps.
26
+
27
+ import type { AgentDefinition, AgentRole } from "./agent-definition.ts";
28
+ import { harnessCapabilities } from "./harness-capabilities.ts";
29
+ import { allowedModels, classifyModel } from "./harness-models.ts";
30
+
31
+ export type CapabilityViolation = {
32
+ readonly role: string;
33
+ readonly capability: string;
34
+ readonly harness: string;
35
+ };
36
+
37
+ export type ModelViolation = {
38
+ readonly role: string;
39
+ readonly harness: string;
40
+ /** The unresolvable id the local binding asked us to write. */
41
+ readonly model: string;
42
+ /** Ids this harness can resolve (never empty — a model violation implies a closed policy). */
43
+ readonly allowedModels: readonly string[];
44
+ };
45
+
46
+ export type TruthfulnessViolation = CapabilityViolation | ModelViolation;
47
+
48
+ export function isModelViolation(v: TruthfulnessViolation): v is ModelViolation {
49
+ return "model" in v;
50
+ }
51
+
52
+ /**
53
+ * Effective required capabilities for a role.
54
+ * spawn.allowed === true implicitly requires spawn_subagents so spawn prose cannot
55
+ * bypass the capability gate by omitting it from requiredCapabilities.
56
+ */
57
+ export function effectiveRequiredCapabilities(role: AgentRole): readonly string[] {
58
+ const caps = [...role.requiredCapabilities];
59
+ if (role.spawn?.allowed === true && !caps.includes("spawn_subagents")) {
60
+ caps.push("spawn_subagents");
61
+ }
62
+ return caps;
63
+ }
64
+
65
+ /**
66
+ * Pure model gate for one role + harness + its local binding.
67
+ * Unbound (undefined / blank) → no violation (inherit is legitimate; caller warns).
68
+ * Harness with an open model-id space → no violation (we make no claim).
69
+ * Only the "foreign" tier (harness-models.ts) blocks — a recognizably different harness's id
70
+ * shape (`custom:*`, `provider/model`) landing in this binding. The "unknown" tier (shape-valid
71
+ * for this harness, just not on its small known-alias list) is NOT a violation — see
72
+ * modelShapeWarning for its non-blocking notice.
73
+ */
74
+ export function checkRoleModelTruthfulness(
75
+ role: AgentRole,
76
+ harness: string,
77
+ model: string | undefined,
78
+ ): readonly ModelViolation[] {
79
+ const m = (model ?? "").trim();
80
+ if (!m) return [];
81
+ if (classifyModel(harness, m) !== "foreign") return [];
82
+ return [
83
+ {
84
+ role: role.slug,
85
+ harness,
86
+ model: m,
87
+ allowedModels: allowedModels(harness) ?? [],
88
+ },
89
+ ];
90
+ }
91
+
92
+ /**
93
+ * Non-blocking notice for a role bound to a model that classifies "unknown": shape-valid for the
94
+ * target harness (e.g. `claude-*`) but not on its small known-alias list — plausibly a real,
95
+ * newer id this kit's list has not caught up with. Null when there is nothing to warn about
96
+ * (unbound, "known", "foreign" — foreign is a violation, not a warning — or an open-policy
97
+ * harness). Callers (apply-artifacts.ts) fold this into the same non-blocking warnings list used
98
+ * for an unbound model.
99
+ */
100
+ export function modelShapeWarning(
101
+ role: AgentRole,
102
+ harness: string,
103
+ model: string | undefined,
104
+ ): string | null {
105
+ const m = (model ?? "").trim();
106
+ if (!m) return null;
107
+ if (classifyModel(harness, m) !== "unknown") return null;
108
+ return (
109
+ `role '${role.slug}' is bound to model '${m}' on harness '${harness}', which is not on the ` +
110
+ `harness's known-alias list but matches its id shape — writing it unverified. If '${harness}' ` +
111
+ `cannot actually resolve it, that fails LOUD at runtime (an API error), not silently.`
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Pure gate for one role + harness (+ optional bound model) → violations (or empty).
117
+ * Unknown harness ids declare zero capabilities → every required cap is a violation.
118
+ */
119
+ export function checkRoleTruthfulness(
120
+ role: AgentRole,
121
+ harness: string,
122
+ model?: string,
123
+ ): readonly TruthfulnessViolation[] {
124
+ const caps = harnessCapabilities(harness);
125
+ const out: TruthfulnessViolation[] = [];
126
+ for (const capability of effectiveRequiredCapabilities(role)) {
127
+ if (!caps.has(capability)) {
128
+ out.push({ role: role.slug, capability, harness });
129
+ }
130
+ }
131
+ out.push(...checkRoleModelTruthfulness(role, harness, model));
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Pure gate: definition + harness (+ role→model binding map) → all role violations (or empty).
137
+ */
138
+ export function checkTruthfulness(
139
+ definition: AgentDefinition,
140
+ harness: string,
141
+ roleModels: Readonly<Record<string, string>> = {},
142
+ ): readonly TruthfulnessViolation[] {
143
+ const out: TruthfulnessViolation[] = [];
144
+ for (const role of definition.roles) {
145
+ out.push(...checkRoleTruthfulness(role, harness, roleModels[role.slug]));
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /** Human-readable multi-line report (empty string when no violations). */
151
+ export function formatViolations(violations: readonly TruthfulnessViolation[]): string {
152
+ if (violations.length === 0) return "";
153
+ return violations
154
+ .map((v) =>
155
+ isModelViolation(v)
156
+ ? ` role '${v.role}' is bound to model '${v.model}', which looks like ANOTHER harness's ` +
157
+ `model id, not one harness '${v.harness}' would own — writing it would be either ` +
158
+ `rejected loudly at runtime or silently satisfy a different harness's config, not ` +
159
+ `this one. Known ${v.harness} aliases: ${v.allowedModels.join(", ")}. Fix the binding ` +
160
+ `in ~/.petbox/roles.json (profile → agents.${v.harness}.roles.${v.role}.model).`
161
+ : ` role '${v.role}' requires capability '${v.capability}' which harness '${v.harness}' does not declare`,
162
+ )
163
+ .join("\n");
164
+ }
@@ -0,0 +1,43 @@
1
+ // Exit taxonomy: usage vs truthfulness must stay distinct (CI signal).
2
+ //
3
+ // Run: node --test src/wire-exit.test.ts
4
+
5
+ import assert from "node:assert/strict";
6
+ import { test } from "node:test";
7
+ import { classifyApplyExit, WIRE_EXIT } from "./wire-exit.ts";
8
+
9
+ test("WIRE_EXIT taxonomy is distinct", () => {
10
+ assert.equal(WIRE_EXIT.ok, 0);
11
+ assert.equal(WIRE_EXIT.hard, 1);
12
+ assert.equal(WIRE_EXIT.usage, 2);
13
+ assert.equal(WIRE_EXIT.truthfulness, 3);
14
+ const codes = new Set(Object.values(WIRE_EXIT));
15
+ assert.equal(codes.size, 4, "each exit class must have a unique code");
16
+ });
17
+
18
+ test("classifyApplyExit: usage errors are NOT truthfulness (different codes)", () => {
19
+ // Bad flags go through usage() → WIRE_EXIT.usage (2), never classifyApplyExit.
20
+ // This test locks the contract that truthfulness is 3 and usage is 2.
21
+ assert.notEqual(WIRE_EXIT.usage, WIRE_EXIT.truthfulness);
22
+ assert.equal(classifyApplyExit({ hadTruthfulnessBlock: true }), WIRE_EXIT.truthfulness);
23
+ assert.equal(classifyApplyExit({ hardError: true }), WIRE_EXIT.hard);
24
+ assert.equal(classifyApplyExit({ hardError: true, hadTruthfulnessBlock: true }), WIRE_EXIT.hard);
25
+ assert.equal(classifyApplyExit({}), WIRE_EXIT.ok);
26
+ assert.equal(classifyApplyExit({ hadTruthfulnessBlock: false }), WIRE_EXIT.ok);
27
+ });
28
+
29
+ test("usage code is 2 (convention) and truthfulness block is 3", () => {
30
+ // Simulated outcomes a CI script would branch on:
31
+ const usageTypo = WIRE_EXIT.usage; // e.g. `petbox-wire apply --definiton` → usage()
32
+ const policyBlock = classifyApplyExit({ hadTruthfulnessBlock: true });
33
+ assert.equal(usageTypo, 2);
34
+ assert.equal(policyBlock, 3);
35
+ assert.notEqual(usageTypo, policyBlock);
36
+ });
37
+
38
+ test("doctor and apply share truthfulness exit 3 (not hard 1)", () => {
39
+ // Both tools call classifyApplyExit({ hadTruthfulnessBlock: true }) for policy fails.
40
+ assert.equal(classifyApplyExit({ hadTruthfulnessBlock: true }), WIRE_EXIT.truthfulness);
41
+ assert.notEqual(WIRE_EXIT.truthfulness, WIRE_EXIT.hard);
42
+ assert.notEqual(WIRE_EXIT.truthfulness, WIRE_EXIT.usage);
43
+ });
@@ -0,0 +1,25 @@
1
+ // CLI exit taxonomy for petbox-wire apply / usage.
2
+ //
3
+ // Kept in a tiny module (not wire.ts) so unit tests can import without running wire main().
4
+ //
5
+ // 0 — full success (every known harness wrote every role)
6
+ // 1 — hard failure (invalid definition, unexpected throw)
7
+ // 2 — usage / bad arguments (convention)
8
+ // 3 — truthfulness: policy blocked some roles/harnesses (partial write possible)
9
+
10
+ export const WIRE_EXIT = {
11
+ ok: 0,
12
+ hard: 1,
13
+ usage: 2,
14
+ truthfulness: 3,
15
+ } as const;
16
+
17
+ /** Pure classifier for apply's process exit (testable without spawning). */
18
+ export function classifyApplyExit(opts: {
19
+ hardError?: boolean;
20
+ hadTruthfulnessBlock?: boolean;
21
+ }): number {
22
+ if (opts.hardError) return WIRE_EXIT.hard;
23
+ if (opts.hadTruthfulnessBlock) return WIRE_EXIT.truthfulness;
24
+ return WIRE_EXIT.ok;
25
+ }
@@ -0,0 +1,65 @@
1
+ // Unit tests for the wire's identity resolution: the canonical per-project API-key env var name
2
+ // and the workspace used by the skill template. Both live in wire-identity.ts specifically so
3
+ // they're importable here — wire.ts runs main() at module top level and must never be imported
4
+ // by a test (see posix-env.test.ts for the same note).
5
+ //
6
+ // Run: node --test src/wire-identity.test.ts (Node >= 23.6 native TS type-stripping)
7
+
8
+ import assert from "node:assert/strict";
9
+ import { test } from "node:test";
10
+ import { WIRE_EXIT } from "./wire-exit.ts";
11
+ import { deriveEnvVar, resolveWorkspace } from "./wire-identity.ts";
12
+
13
+ test("deriveEnvVar matches the server's EnvSlug (PETBOX_<PROJECT>_API_KEY)", () => {
14
+ // The Connect page (ProjectConnect.cshtml.cs EnvSlug) is the single source of truth for the
15
+ // name an operator is told to set — the CLI default must be byte-identical.
16
+ assert.equal(deriveEnvVar("$system"), "PETBOX_SYSTEM_API_KEY");
17
+ assert.equal(deriveEnvVar("kpvotes"), "PETBOX_KPVOTES_API_KEY");
18
+ assert.equal(deriveEnvVar("petbox"), "PETBOX_PETBOX_API_KEY");
19
+ });
20
+
21
+ test("deriveEnvVar collapses runs of punctuation and trims leading/trailing underscores", () => {
22
+ assert.equal(deriveEnvVar("my--weird..key"), "PETBOX_MY_WEIRD_KEY_API_KEY");
23
+ assert.equal(deriveEnvVar("$$foo-bar$$"), "PETBOX_FOO_BAR_API_KEY");
24
+ assert.equal(deriveEnvVar("a.b-c"), "PETBOX_A_B_C_API_KEY");
25
+ // Regression: the old scheme replaced 1:1 and never trimmed → "_SYSTEM_API_KEY".
26
+ assert.ok(!deriveEnvVar("$system").startsWith("_"));
27
+ assert.ok(!deriveEnvVar("$$foo-bar$$").includes("__"));
28
+ });
29
+
30
+ test("resolveWorkspace: --workspace flag wins over the server-reported workspace", () => {
31
+ const r = resolveWorkspace("acme", "server-ws");
32
+ assert.equal(r.ok, true);
33
+ assert.equal(r.ok && r.workspace, "acme");
34
+ assert.equal(r.ok && r.source, "flag");
35
+ });
36
+
37
+ test("resolveWorkspace: server-reported workspace is used when no flag is passed", () => {
38
+ const r = resolveWorkspace(undefined, "server-ws");
39
+ assert.equal(r.ok, true);
40
+ assert.equal(r.ok && r.workspace, "server-ws");
41
+ assert.equal(r.ok && r.source, "server");
42
+ });
43
+
44
+ test("resolveWorkspace: neither flag nor server value → usage error, exit 2 (no hardcoded default)", () => {
45
+ // An old server predating the `workspace` field on /api/auth/validate reports none.
46
+ const r = resolveWorkspace(undefined, undefined);
47
+ assert.equal(r.ok, false);
48
+ if (r.ok) return;
49
+ assert.equal(r.exitCode, WIRE_EXIT.usage);
50
+ assert.equal(r.exitCode, 2);
51
+ assert.match(r.message, /--workspace is required: this server did not report a workspace/);
52
+ // Never a personal workspace baked into the published CLI.
53
+ assert.doesNotMatch(r.message, /stdray/);
54
+ });
55
+
56
+ test("resolveWorkspace: blank strings count as absent (flag and server alike)", () => {
57
+ // Stored to one variable — two independent calls cannot be correlated by the type
58
+ // checker's control-flow narrowing (each is a fresh, unrelated WorkspaceResolution),
59
+ // and calling the function twice to check two different things was also wasteful.
60
+ const blank = resolveWorkspace(" ", "server-ws");
61
+ assert.equal(blank.ok && blank.workspace, "server-ws");
62
+ assert.equal(resolveWorkspace("", " ").ok, false);
63
+ const trimmed = resolveWorkspace(" acme ", undefined);
64
+ assert.equal(trimmed.ok && trimmed.workspace, "acme");
65
+ });
@@ -0,0 +1,64 @@
1
+ // Pure identity resolution for the full wire: the per-project API-key env var name and the
2
+ // workspace used in the skill template.
3
+ //
4
+ // Kept in a tiny side-effect-free module (not wire.ts) so unit tests can import it — wire.ts
5
+ // runs main() at module top level and must never be imported by a test (see posix-env.ts /
6
+ // bin/petbox-wire.js for the same reasoning).
7
+
8
+ import { WIRE_EXIT } from "./wire-exit.ts";
9
+
10
+ // Canonical env-var name for a project key. MUST stay byte-identical to the server's EnvSlug
11
+ // (PetBox.Web/Pages/Admin/ProjectConnect.cshtml.cs), which is what the Connect page shows an
12
+ // operator to paste: uppercase, every run of non-alphanumerics collapsed to a single "_",
13
+ // leading/trailing "_" trimmed, wrapped in PETBOX_…_API_KEY.
14
+ //
15
+ // "$system" → "PETBOX_SYSTEM_API_KEY" (not "_SYSTEM_API_KEY": the leading "$" is trimmed)
16
+ // "kpvotes" → "PETBOX_KPVOTES_API_KEY"
17
+ //
18
+ // This is only the DEFAULT for a fresh wire. An already-wired directory keeps the env-var name
19
+ // recorded in ~/.petbox/projects.json (registryEnvVar in wire.ts wins over this), and --env
20
+ // overrides both — so changing this name never orphans an existing machine's keys.json entry.
21
+ export function deriveEnvVar(projectKey: string): string {
22
+ // Same operation ORDER as EnvSlug: sanitise+trim first, uppercase last. Uppercasing first would
23
+ // diverge on non-ASCII letters that case-fold into ASCII (C#: "ı" → "_", uppercase-first: "I").
24
+ const slug = projectKey
25
+ .replace(/[^A-Za-z0-9]+/g, "_")
26
+ .replace(/^_+|_+$/g, "")
27
+ .toUpperCase();
28
+ return `PETBOX_${slug}_API_KEY`;
29
+ }
30
+
31
+ export type WorkspaceResolution =
32
+ | { ok: true; workspace: string; source: "flag" | "server" }
33
+ | { ok: false; message: string; exitCode: number };
34
+
35
+ const nonEmpty = (v: string | undefined | null): string | undefined => {
36
+ const s = typeof v === "string" ? v.trim() : "";
37
+ return s.length > 0 ? s : undefined;
38
+ };
39
+
40
+ // Workspace for the skill template's {{WORKSPACE}}. Resolution order:
41
+ // 1. --workspace WS (explicit operator override)
42
+ // 2. the `workspace` field of GET /api/auth/validate (the server owns this)
43
+ // 3. hard fail, exit 2 (usage) — NEVER a hardcoded personal workspace.
44
+ // An old server that predates the `workspace` field simply reports none → case 3, a clear
45
+ // actionable usage error rather than a crash or a silent wrong default.
46
+ export function resolveWorkspace(
47
+ flag: string | undefined,
48
+ serverWorkspace: string | undefined,
49
+ ): WorkspaceResolution {
50
+ const fromFlag = nonEmpty(flag);
51
+ if (fromFlag) return { ok: true, workspace: fromFlag, source: "flag" };
52
+
53
+ const fromServer = nonEmpty(serverWorkspace);
54
+ if (fromServer) return { ok: true, workspace: fromServer, source: "server" };
55
+
56
+ return {
57
+ ok: false,
58
+ exitCode: WIRE_EXIT.usage,
59
+ message:
60
+ "--workspace is required: this server did not report a workspace.\n" +
61
+ " GET /api/auth/validate returned no `workspace` field (an older PetBox server).\n" +
62
+ " Re-run with --workspace <WS> (the workspace the project belongs to), or upgrade the server.",
63
+ };
64
+ }