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,148 @@
1
+ // Unit + integration tests for the apply target-directory fix
2
+ // (bug: apply-root-overwrites-primary-worktree).
3
+ //
4
+ // Run: node --test src/apply-root.test.ts
5
+
6
+ import assert from "node:assert/strict";
7
+ import { execFileSync } from "node:child_process";
8
+ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { test } from "node:test";
12
+ import { defaultGitToplevel, resolveApplyRoot } from "./apply-root.ts";
13
+
14
+ function freshDir(): string {
15
+ // realpathSync so Windows short-path / symlink quirks in TMP don't make string comparisons
16
+ // against `git rev-parse --show-toplevel`'s output flaky.
17
+ return realpathSync(mkdtempSync(join(tmpdir(), "petbox-apply-root-")));
18
+ }
19
+
20
+ function norm(p: string): string {
21
+ return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
22
+ }
23
+
24
+ function hasGit(): boolean {
25
+ try {
26
+ execFileSync("git", ["--version"], { stdio: "ignore" });
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ // ---- pure unit tests (injected gitToplevel — no real git process) ----
34
+
35
+ test("resolveApplyRoot: uses the injected git toplevel and reports via 'git'", () => {
36
+ const result = resolveApplyRoot("/some/cwd", () => "/some/toplevel");
37
+ assert.deepEqual(result, { root: "/some/toplevel", via: "git" });
38
+ });
39
+
40
+ test("resolveApplyRoot: falls back to cwd (via 'cwd') when git resolution returns null", () => {
41
+ const result = resolveApplyRoot("/some/cwd", () => null);
42
+ assert.deepEqual(result, { root: "/some/cwd", via: "cwd" });
43
+ });
44
+
45
+ test("resolveApplyRoot: NEVER consults a registry — identical cwd/root regardless of any external prefix table", () => {
46
+ // The whole point of the fix: no registry longest-prefix matching happens here at all. The
47
+ // injected gitToplevel is the only source of truth for the target directory.
48
+ const result = resolveApplyRoot("/registered/prefix/worktree/x", () => "/registered/prefix/worktree/x");
49
+ assert.equal(result.root, "/registered/prefix/worktree/x");
50
+ assert.equal(result.via, "git");
51
+ });
52
+
53
+ // ---- defaultGitToplevel against a REAL git process ----
54
+
55
+ test("defaultGitToplevel: a plain non-git directory resolves to null", (t) => {
56
+ if (!hasGit()) {
57
+ t.skip("git not on PATH");
58
+ return;
59
+ }
60
+ const dir = freshDir();
61
+ try {
62
+ // A tmp dir is not expected to be inside this repo's tree; guard just in case.
63
+ const top = defaultGitToplevel(dir);
64
+ if (top !== null) {
65
+ t.skip(`environment surprise: ${dir} resolved inside a git tree (${top}) — skipping`);
66
+ return;
67
+ }
68
+ assert.equal(top, null);
69
+ } finally {
70
+ rmSync(dir, { recursive: true, force: true });
71
+ }
72
+ });
73
+
74
+ test("defaultGitToplevel: a git repo resolves to its own toplevel, from a nested subdir too", (t) => {
75
+ if (!hasGit()) {
76
+ t.skip("git not on PATH");
77
+ return;
78
+ }
79
+ const dir = freshDir();
80
+ try {
81
+ execFileSync("git", ["init", "-q"], { cwd: dir });
82
+ const top = defaultGitToplevel(dir);
83
+ assert.ok(top, "expected a toplevel for a freshly-init'd repo");
84
+ assert.equal(norm(top!), norm(dir));
85
+
86
+ const nested = join(dir, "a", "b", "c");
87
+ mkdirSync(nested, { recursive: true });
88
+ const topFromNested = defaultGitToplevel(nested);
89
+ assert.equal(norm(topFromNested!), norm(dir), "toplevel from a nested subdir must still be the repo root");
90
+ } finally {
91
+ rmSync(dir, { recursive: true, force: true });
92
+ }
93
+ });
94
+
95
+ // ---- the actual regression: worktree under a REGISTERED PRIMARY prefix must resolve to itself ----
96
+
97
+ test("resolveApplyRoot regression: apply from a real git worktree resolves to the WORKTREE, not the primary tree it was branched from", (t) => {
98
+ if (!hasGit()) {
99
+ t.skip("git not on PATH");
100
+ return;
101
+ }
102
+ const primary = freshDir();
103
+ let worktree: string | null = null;
104
+ try {
105
+ execFileSync("git", ["init", "-q"], { cwd: primary });
106
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: primary });
107
+ execFileSync("git", ["config", "user.name", "Test"], { cwd: primary });
108
+ writeFileSync(join(primary, "README.md"), "primary\n", "utf8");
109
+ execFileSync("git", ["add", "."], { cwd: primary });
110
+ execFileSync("git", ["commit", "-q", "-m", "init"], { cwd: primary });
111
+
112
+ // A worktree living UNDER the primary tree's own filesystem prefix — the exact shape that
113
+ // triggered the bug: a registry entry for `primary` would longest-prefix-match this path too.
114
+ worktree = join(primary, ".claude", "worktrees", "agent-x");
115
+ mkdirSync(join(primary, ".claude", "worktrees"), { recursive: true });
116
+ execFileSync("git", ["worktree", "add", "-q", "-b", "agent-x-branch", worktree], { cwd: primary });
117
+
118
+ // Simulate the OLD buggy behavior for contrast: a registry longest-prefix match for a
119
+ // `primary` entry would have returned `primary` for a cwd under it. The FIXED resolver must
120
+ // not do that — it must answer with the worktree itself.
121
+ const fromWorktree = resolveApplyRoot(worktree);
122
+ assert.equal(fromWorktree.via, "git");
123
+ assert.equal(
124
+ norm(fromWorktree.root),
125
+ norm(worktree),
126
+ "apply run from the worktree must target the worktree, never the primary tree",
127
+ );
128
+ assert.notEqual(
129
+ norm(fromWorktree.root),
130
+ norm(primary),
131
+ "must NOT resolve to the primary tree just because the worktree sits under its filesystem prefix",
132
+ );
133
+
134
+ // And apply from the primary tree itself still resolves to the primary tree — this isn't a
135
+ // "worktree always wins" hack, it's "git toplevel for THIS cwd, whichever tree that is".
136
+ const fromPrimary = resolveApplyRoot(primary);
137
+ assert.equal(norm(fromPrimary.root), norm(primary));
138
+ } finally {
139
+ if (worktree && existsSync(worktree)) {
140
+ try {
141
+ execFileSync("git", ["worktree", "remove", "-f", worktree], { cwd: primary });
142
+ } catch {
143
+ /* best-effort cleanup */
144
+ }
145
+ }
146
+ rmSync(primary, { recursive: true, force: true });
147
+ }
148
+ });
@@ -0,0 +1,68 @@
1
+ // Target directory resolution for petbox-wire apply (wiring-registry-resolve).
2
+ //
3
+ // Two DIFFERENT questions get answered by two DIFFERENT mechanisms — conflating them was the
4
+ // bug (apply-root-overwrites-primary-worktree):
5
+ // - PROJECT IDENTITY (projectKey / baseUrl / apiKey) comes from the registry
6
+ // (~/.petbox/projects.json), longest-prefix match — see registry.ts's resolveProject.
7
+ // - the ARTIFACT TARGET DIRECTORY (where apply writes .claude/agents/, .opencode/agent/,
8
+ // .factory/droids/) is the git worktree apply is actually running in.
9
+ //
10
+ // Before this fix, resolveApplyRoot ALSO used the registry's longest-prefix match to pick the
11
+ // target directory. A worktree living under a registered prefix (e.g.
12
+ // `<registered>/.claude/worktrees/agent-x`) resolved its apply root to the REGISTERED prefix —
13
+ // the PRIMARY tree — not the worktree apply was invoked from. `apply` run from inside a worktree
14
+ // silently rewrote the primary tree's `.claude/agents/*`, which may be checked out on an
15
+ // entirely different branch (a live, hostile side effect on someone else's working copy).
16
+ //
17
+ // Fix: the artifact root is git's own toplevel for cwd, full stop — no registry involved.
18
+ // Falls back to cwd when cwd is not inside a git working tree at all (plain directory, or a
19
+ // git binary that is missing/fails) — same "no better answer, use cwd" fallback the old code
20
+ // already had for the "no registry entry" case.
21
+ //
22
+ // Plain TS for native node type-stripping: zero deps beyond node:child_process.
23
+
24
+ import { execFileSync } from "node:child_process";
25
+
26
+ export type ApplyRootVia = "git" | "cwd";
27
+
28
+ export type ApplyRootResolution = {
29
+ readonly root: string;
30
+ readonly via: ApplyRootVia;
31
+ };
32
+
33
+ /**
34
+ * Default git toplevel probe: `git rev-parse --show-toplevel` from cwd. Returns null on ANY
35
+ * failure — cwd is not inside a git working tree, `git` is not on PATH, a bare/detached repo
36
+ * that cannot resolve a toplevel, etc. Never throws (mirrors registry.ts's resolveProject
37
+ * never-throws contract for the same reason: this runs inside a CLI step that must not crash
38
+ * just because cwd happens to be a plain, non-git directory).
39
+ */
40
+ export function defaultGitToplevel(cwd: string): string | null {
41
+ try {
42
+ const out = execFileSync("git", ["rev-parse", "--show-toplevel"], {
43
+ cwd,
44
+ encoding: "utf8",
45
+ stdio: ["ignore", "pipe", "ignore"],
46
+ });
47
+ const trimmed = out.trim();
48
+ return trimmed.length > 0 ? trimmed : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Resolve WHERE `apply` writes artifacts: the git worktree toplevel for cwd, or cwd itself when
56
+ * cwd is not inside a git working tree. Deliberately does NOT consult the registry — the
57
+ * registry answers project IDENTITY, not artifact location (see module comment). `gitToplevel`
58
+ * is injectable so tests never have to shell out to a real git process to exercise the pure
59
+ * fallback branch.
60
+ */
61
+ export function resolveApplyRoot(
62
+ cwd: string,
63
+ gitToplevel: (cwd: string) => string | null = defaultGitToplevel,
64
+ ): ApplyRootResolution {
65
+ const top = gitToplevel(cwd);
66
+ if (top) return { root: top, via: "git" };
67
+ return { root: cwd, via: "cwd" };
68
+ }
@@ -0,0 +1,169 @@
1
+ // Unit tests for the apply write guard (bug: apply-clobbers-user-agent-files) and the
2
+ // namespacing-rename legacy cleanup it enables (chore: petbox-namespaced-agent-names).
3
+ //
4
+ // Run: node --test src/apply-write.test.ts
5
+
6
+ import assert from "node:assert/strict";
7
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { test } from "node:test";
11
+ import { renderAgentMarkdown } from "./apply-artifacts.ts";
12
+ import { cleanupLegacyArtifact, writeArtifact } from "./apply-write.ts";
13
+ import { DEFAULT_AGENT_DEFINITION } from "./agent-definition.ts";
14
+ import { hasPetboxMarker, PETBOX_MARKER_LINE } from "./origin-marker.ts";
15
+
16
+ function freshDir(): string {
17
+ return mkdtempSync(join(tmpdir(), "petbox-apply-write-"));
18
+ }
19
+
20
+ test("hasPetboxMarker: true only for our marker inside frontmatter, never body text", () => {
21
+ assert.equal(hasPetboxMarker(`---\nname: x\n${PETBOX_MARKER_LINE}\n---\n\nbody`), true);
22
+ assert.equal(hasPetboxMarker(`---\nname: x\n---\n\nbody mentions petbox: managed here`), false);
23
+ assert.equal(hasPetboxMarker("no frontmatter at all"), false);
24
+ assert.equal(hasPetboxMarker(""), false);
25
+ });
26
+
27
+ test("writeArtifact: fresh path (no existing file) is written, reason 'new'", () => {
28
+ const dir = freshDir();
29
+ try {
30
+ const abs = join(dir, "sub", "petbox-worker.md");
31
+ const outcome = writeArtifact(abs, "hello");
32
+ assert.deepEqual(outcome, { kind: "written", path: abs, reason: "new" });
33
+ assert.equal(readFileSync(abs, "utf8"), "hello");
34
+ } finally {
35
+ rmSync(dir, { recursive: true, force: true });
36
+ }
37
+ });
38
+
39
+ test("writeArtifact: an existing file WITHOUT our marker is refused — content untouched, loud outcome", () => {
40
+ const dir = freshDir();
41
+ try {
42
+ const abs = join(dir, "worker.md");
43
+ const foreignContent = "# My own worker agent\n\nI wrote this by hand.\n";
44
+ writeFileSync(abs, foreignContent, "utf8");
45
+
46
+ const outcome = writeArtifact(abs, "petbox content");
47
+ assert.deepEqual(outcome, { kind: "blocked", path: abs });
48
+ // The file must be byte-for-byte exactly what the user had — apply never touches it.
49
+ assert.equal(readFileSync(abs, "utf8"), foreignContent);
50
+ } finally {
51
+ rmSync(dir, { recursive: true, force: true });
52
+ }
53
+ });
54
+
55
+ test("writeArtifact: an existing file WITH our marker is overwritten silently (routine re-apply)", () => {
56
+ const dir = freshDir();
57
+ try {
58
+ const abs = join(dir, "petbox-worker.md");
59
+ const oldOurs = `---\nname: petbox-worker\n${PETBOX_MARKER_LINE}\n---\n\nold body\n`;
60
+ writeFileSync(abs, oldOurs, "utf8");
61
+
62
+ const newContent = `---\nname: petbox-worker\n${PETBOX_MARKER_LINE}\n---\n\nnew body\n`;
63
+ const outcome = writeArtifact(abs, newContent);
64
+ assert.deepEqual(outcome, { kind: "written", path: abs, reason: "own" });
65
+ assert.equal(readFileSync(abs, "utf8"), newContent);
66
+ } finally {
67
+ rmSync(dir, { recursive: true, force: true });
68
+ }
69
+ });
70
+
71
+ test("writeArtifact: an existing marker-carrying file for a DIFFERENT role is still ours — overwritten (marker is the only signal)", () => {
72
+ // Documents the guard's actual contract: it trusts the marker, not path/name matching.
73
+ const dir = freshDir();
74
+ try {
75
+ const abs = join(dir, "petbox-worker.md");
76
+ writeFileSync(abs, `---\nname: petbox-utility\n${PETBOX_MARKER_LINE}\n---\n\nstale\n`, "utf8");
77
+ const outcome = writeArtifact(abs, "fresh");
78
+ assert.equal(outcome.kind, "written");
79
+ } finally {
80
+ rmSync(dir, { recursive: true, force: true });
81
+ }
82
+ });
83
+
84
+ test("cleanupLegacyArtifact: absent path → 'absent', no-op", () => {
85
+ const dir = freshDir();
86
+ try {
87
+ assert.equal(cleanupLegacyArtifact(join(dir, "worker.md")), "absent");
88
+ } finally {
89
+ rmSync(dir, { recursive: true, force: true });
90
+ }
91
+ });
92
+
93
+ test("cleanupLegacyArtifact: an OWNED pre-rename file is removed", () => {
94
+ const dir = freshDir();
95
+ try {
96
+ const abs = join(dir, "worker.md");
97
+ writeFileSync(abs, `---\nname: worker\n${PETBOX_MARKER_LINE}\n---\n\nold pre-rename body\n`, "utf8");
98
+ assert.equal(cleanupLegacyArtifact(abs), "removed");
99
+ assert.equal(existsSync(abs), false);
100
+ } finally {
101
+ rmSync(dir, { recursive: true, force: true });
102
+ }
103
+ });
104
+
105
+ test("cleanupLegacyArtifact: a FOREIGN file at the legacy path is left alone — never deleted", () => {
106
+ const dir = freshDir();
107
+ try {
108
+ const abs = join(dir, "worker.md");
109
+ const foreign = "# a real user agent named worker, no marker\n";
110
+ writeFileSync(abs, foreign, "utf8");
111
+ assert.equal(cleanupLegacyArtifact(abs), "kept-foreign");
112
+ assert.equal(existsSync(abs), true);
113
+ assert.equal(readFileSync(abs, "utf8"), foreign);
114
+ } finally {
115
+ rmSync(dir, { recursive: true, force: true });
116
+ }
117
+ });
118
+
119
+ // --- end-to-end-ish: a real renderAgentMarkdown output round-trips through the guard ---
120
+
121
+ test("a freshly rendered role file always carries the marker, so a second apply run is a silent no-op re-write", () => {
122
+ const role = DEFAULT_AGENT_DEFINITION.roles.find((r) => r.slug === "worker")!;
123
+ const content = renderAgentMarkdown(role);
124
+ assert.ok(hasPetboxMarker(content));
125
+
126
+ const dir = freshDir();
127
+ try {
128
+ const abs = join(dir, "petbox-worker.md");
129
+ const first = writeArtifact(abs, content);
130
+ assert.equal(first.kind, "written");
131
+ assert.equal(first.reason, "new");
132
+
133
+ const second = writeArtifact(abs, content);
134
+ assert.equal(second.kind, "written");
135
+ assert.equal(second.reason, "own");
136
+ } finally {
137
+ rmSync(dir, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ test("simulated apply flow: a real user's own worker.md survives the FIRST apply that now writes petbox-worker.md alongside it", () => {
142
+ // This is the scenario from the bug report: a friend has their own .claude/agents/worker.md.
143
+ // apply now writes .claude/agents/petbox-worker.md (namespacing) and must neither touch nor
144
+ // delete the user's worker.md (it has no marker, so it is never mistaken for a pre-rename
145
+ // leftover of OURS either).
146
+ const role = DEFAULT_AGENT_DEFINITION.roles.find((r) => r.slug === "worker")!;
147
+ const content = renderAgentMarkdown(role);
148
+
149
+ const dir = freshDir();
150
+ try {
151
+ const agentsDir = join(dir, ".claude", "agents");
152
+ mkdirSync(agentsDir, { recursive: true });
153
+ const userFile = join(agentsDir, "worker.md");
154
+ const userContent = "# worker\n\nMy hand-written worker agent. Do not touch.\n";
155
+ writeFileSync(userFile, userContent, "utf8");
156
+
157
+ const newAbs = join(agentsDir, "petbox-worker.md");
158
+ const writeOutcome = writeArtifact(newAbs, content);
159
+ assert.equal(writeOutcome.kind, "written");
160
+
161
+ // Legacy cleanup targets the bare name — but it is NOT ours (no marker), so it must survive.
162
+ const legacyOutcome = cleanupLegacyArtifact(userFile);
163
+ assert.equal(legacyOutcome, "kept-foreign");
164
+ assert.equal(readFileSync(userFile, "utf8"), userContent, "user's own file must be untouched");
165
+ assert.equal(readFileSync(newAbs, "utf8"), content);
166
+ } finally {
167
+ rmSync(dir, { recursive: true, force: true });
168
+ }
169
+ });
@@ -0,0 +1,84 @@
1
+ // Filesystem writer for petbox-wire apply's PlannedFiles.
2
+ //
3
+ // Kept out of apply-artifacts.ts (which stays pure / filesystem-free by its own contract —
4
+ // "Does not touch the filesystem") and out of wire.ts (whose main() runs at import time, so
5
+ // helpers that need to be unit-testable in isolation live in a side module — same pattern as
6
+ // posix-env.ts).
7
+ //
8
+ // The whole point (bug: apply-clobbers-user-agent-files): before this module existed, apply's
9
+ // write loop was an unconditional writeFileSync — no existence check, no origin marker, no
10
+ // refusal. A user's own `.claude/agents/worker.md` was destroyed on the first `apply` with zero
11
+ // warning. writeArtifact fixes that with ONE rule: an existing file is only ever overwritten
12
+ // when it already carries OUR origin marker (origin-marker.ts); anything else is refused, loud,
13
+ // non-zero exit at the call site — never partially touched.
14
+ //
15
+ // cleanupLegacyArtifact is the companion used by the namespaced-agent-names rename
16
+ // (petbox-namespaced-agent-names): once role files are emitted under a new `petbox-<slug>`
17
+ // name, the OLD unprefixed file (e.g. `.claude/agents/worker.md`) would otherwise be left
18
+ // behind as an orphan. It is only ever removed when it carries our marker — a real user file
19
+ // that happens to share the old bare name is left alone, exactly like writeArtifact.
20
+ //
21
+ // Plain TS for native node type-stripping: zero deps.
22
+
23
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
24
+ import { dirname } from "node:path";
25
+ import { hasPetboxMarker } from "./origin-marker.ts";
26
+
27
+ export type WriteOutcome =
28
+ | { readonly kind: "written"; readonly path: string; readonly reason: "new" | "own" }
29
+ | { readonly kind: "blocked"; readonly path: string };
30
+
31
+ /**
32
+ * Write one generated file to `absPath`.
33
+ * - Path does not exist → write it (reason "new").
34
+ * - Path exists and its frontmatter carries our origin marker → overwrite silently
35
+ * (reason "own") — this is the routine, expected re-apply case.
36
+ * - Path exists and does NOT carry our marker (a real file we did not create, or one we
37
+ * cannot even read) → refuse. Returns "blocked"; the file is left byte-for-byte untouched.
38
+ * Never throws for the ordinary cases above (a directory-creation failure still throws — that
39
+ * is a genuine environment error, not a clobber decision).
40
+ */
41
+ export function writeArtifact(absPath: string, content: string): WriteOutcome {
42
+ const existed = existsSync(absPath);
43
+ if (existed) {
44
+ let existing: string;
45
+ try {
46
+ existing = readFileSync(absPath, "utf8");
47
+ } catch {
48
+ // Unreadable existing entry (permissions, a directory, binary junk, ...) — treat as
49
+ // foreign rather than guess; never overwrite something we could not even inspect.
50
+ return { kind: "blocked", path: absPath };
51
+ }
52
+ if (!hasPetboxMarker(existing)) {
53
+ return { kind: "blocked", path: absPath };
54
+ }
55
+ }
56
+ mkdirSync(dirname(absPath), { recursive: true });
57
+ writeFileSync(absPath, content, "utf8");
58
+ return {
59
+ kind: "written",
60
+ path: absPath,
61
+ reason: existed ? "own" : "new",
62
+ };
63
+ }
64
+
65
+ export type LegacyCleanupOutcome = "removed" | "kept-foreign" | "absent";
66
+
67
+ /**
68
+ * Remove an old, pre-namespacing artifact at `absPath` IF AND ONLY IF it carries our origin
69
+ * marker. Returns "absent" when there is nothing there (the common steady-state case once
70
+ * migration has run once), "removed" when an owned leftover was deleted, "kept-foreign" when
71
+ * something exists there that is NOT ours — left untouched, never deleted, never renamed.
72
+ */
73
+ export function cleanupLegacyArtifact(absPath: string): LegacyCleanupOutcome {
74
+ if (!existsSync(absPath)) return "absent";
75
+ let existing: string;
76
+ try {
77
+ existing = readFileSync(absPath, "utf8");
78
+ } catch {
79
+ return "kept-foreign";
80
+ }
81
+ if (!hasPetboxMarker(existing)) return "kept-foreign";
82
+ unlinkSync(absPath);
83
+ return "removed";
84
+ }
package/src/canon.ts ADDED
@@ -0,0 +1,140 @@
1
+ // Shared memory-canon fetch + offline cache — the ONE implementation both SessionStart hooks
2
+ // (pull-memory.ts for Claude Code, opencode-plugin.ts for opencode) use, so the injected canon
3
+ // block is byte-identical across agents (spec: agent-wiring, wiring-canon-inject).
4
+ //
5
+ // The server exposes the curated memory index (canon) at
6
+ // GET {baseUrl}/api/memory/{project}/canon (header X-Api-Key)
7
+ // → 200 { "project": {body,updatedAt,version}|null, "workspace": {...}|null }
8
+ // We turn that into a markdown block appended to the session context. On any failure we fall
9
+ // back to a local cache (~/.petbox/cache/{project}.canon.md) written on the last good fetch,
10
+ // marked stale. This is best-effort and TOTAL: every path returns string | null, never throws.
11
+ //
12
+ // NOTE: production may not have this endpoint yet — a 404/error degrades gracefully (no canon
13
+ // block, the memory protocol is still injected by the caller).
14
+ //
15
+ // Plain TS for native node type-stripping: no enum/namespace/parameter-properties, type-only
16
+ // imports, zero deps.
17
+
18
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+ import type { ResolvedProject } from "./registry.ts";
22
+
23
+ const FETCH_TIMEOUT_MS = 8000;
24
+
25
+ const STALE_MARKER = "⚠ Canon below is from the local cache (PetBox unreachable) — may be stale.";
26
+
27
+ type CanonPart = { body?: unknown; updatedAt?: unknown; version?: unknown };
28
+ type CanonResponse = { project?: CanonPart | null; workspace?: CanonPart | null };
29
+
30
+ function cacheDir(): string {
31
+ return join(homedir(), ".petbox", "cache");
32
+ }
33
+
34
+ function cachePath(project: string): string {
35
+ return join(cacheDir(), `${project}.canon.md`);
36
+ }
37
+
38
+ // Pull a usable markdown body out of a canon part, or null when the part is missing/empty.
39
+ function partBody(part: CanonPart | null | undefined): string | null {
40
+ if (!part || typeof part.body !== "string") return null;
41
+ const body = part.body.trim();
42
+ return body.length > 0 ? body : null;
43
+ }
44
+
45
+ // Assemble the canon block from the two parts. Returns null when both are empty.
46
+ function buildBlock(project: string, resp: CanonResponse | null): string | null {
47
+ if (!resp) return null;
48
+ const projectBody = partBody(resp.project);
49
+ const workspaceBody = partBody(resp.workspace);
50
+ if (projectBody === null && workspaceBody === null) return null;
51
+
52
+ let out = `## PetBox memory canon
53
+
54
+ The curated memory index (canon) for this project — pointers to durable facts; pull full bodies via memory_get/memory_search.`;
55
+ if (projectBody !== null) {
56
+ out += `\n\n### Project (${project})\n\n${projectBody}`;
57
+ }
58
+ if (workspaceBody !== null) {
59
+ out += `\n\n### Workspace\n\n${workspaceBody}`;
60
+ }
61
+ return out;
62
+ }
63
+
64
+ // Returns { ok: true, resp } on a successful HTTP fetch (resp may still carry empty canon),
65
+ // or { ok: false } on any failure (404 endpoint-absent / 401 / 5xx / network / timeout / bad
66
+ // JSON) — the caller uses ok to decide whether to fall back to the stale offline cache.
67
+ async function fetchCanon(
68
+ resolved: ResolvedProject,
69
+ timeoutMs: number = FETCH_TIMEOUT_MS,
70
+ ): Promise<{ ok: true; resp: CanonResponse | null } | { ok: false }> {
71
+ const ctrl = new AbortController();
72
+ // timeoutMs <= 0 (budget already exhausted by a prior sequential fetch, e.g. pull-memory.ts's
73
+ // shared session-start budget) aborts on the next tick — same effect as skipping the network
74
+ // call, degrading straight to the offline cache below rather than blocking.
75
+ const timer = setTimeout(() => ctrl.abort(), Math.max(0, timeoutMs));
76
+ try {
77
+ const url = `${resolved.baseUrl}/api/memory/${resolved.project}/canon`;
78
+ const resp = await fetch(url, {
79
+ method: "GET",
80
+ // Connection: close so this socket doesn't linger keep-alive after the response —
81
+ // a SessionStart/Stop hook process exits right after this fetch, and a kept-alive
82
+ // socket is a libuv handle that either stalls natural process exit for seconds or
83
+ // races a forced process.exit() against the handle's own close teardown (the crash
84
+ // this header exists to prevent; see pull-memory.ts's exit comment).
85
+ headers: { "X-Api-Key": resolved.apiKey, Connection: "close" },
86
+ signal: ctrl.signal,
87
+ });
88
+ if (!resp.ok) return { ok: false }; // 404 (endpoint absent) / 401 / 5xx → degrade to cache
89
+ const j = (await resp.json().catch(() => null)) as CanonResponse | null;
90
+ return { ok: true, resp: j };
91
+ } catch {
92
+ return { ok: false }; // network/timeout → degrade to cache
93
+ } finally {
94
+ clearTimeout(timer);
95
+ }
96
+ }
97
+
98
+ async function writeCache(project: string, block: string): Promise<void> {
99
+ try {
100
+ await mkdir(cacheDir(), { recursive: true });
101
+ await writeFile(cachePath(project), block, "utf8");
102
+ } catch {
103
+ // best-effort: a failed cache write must not affect the returned block
104
+ }
105
+ }
106
+
107
+ async function readCache(project: string): Promise<string | null> {
108
+ try {
109
+ const body = await readFile(cachePath(project), "utf8");
110
+ return body.trim().length > 0 ? body : null;
111
+ } catch {
112
+ return null; // no cache file yet
113
+ }
114
+ }
115
+
116
+ // Build the canon block for a resolved project. On a successful fetch the fresh block is
117
+ // cached and returned; on failure a cached block (if any) is returned PREFIXED with a stale
118
+ // marker. Returns null when there is nothing to show (fetch failed AND no cache, or both
119
+ // canon parts are empty). Never throws.
120
+ export async function fetchCanonBlock(
121
+ resolved: ResolvedProject,
122
+ opts?: { timeoutMs?: number },
123
+ ): Promise<string | null> {
124
+ try {
125
+ const result = await fetchCanon(resolved, opts?.timeoutMs);
126
+ if (result.ok) {
127
+ // Successful fetch — the server is authoritative. A real block is cached and returned;
128
+ // an empty canon returns null (do NOT show a stale cache when the server says "nothing").
129
+ const block = buildBlock(resolved.project, result.resp);
130
+ if (block !== null) await writeCache(resolved.project, block);
131
+ return block;
132
+ }
133
+ // Fetch failed (endpoint absent / unreachable) → fall back to the offline cache if present.
134
+ const cached = await readCache(resolved.project);
135
+ if (cached !== null) return `${STALE_MARKER}\n\n${cached}`;
136
+ return null;
137
+ } catch {
138
+ return null; // total: any unexpected error → no canon block
139
+ }
140
+ }