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,158 @@
1
+ // Per-harness model-id policy for the agent-artifact compiler.
2
+ //
3
+ // Sibling of harness-capabilities.ts, same contract: EVERY cell is a factual claim about a
4
+ // harness taken from that harness's docs or a verified live observation. Do not invent.
5
+ //
6
+ // WHY THIS EXISTS (intake `wire-apply-writes-unresolvable-model-id`, 2026-07-12; policy
7
+ // REVISED 2026-07-13 after a live-fire measurement falsified the original premise — see task
8
+ // `model-gate-revision-premise-falsified`): `apply` wrote whatever ~/.petbox/roles.json said
9
+ // into the target harness's frontmatter. The 2026-07-12 incident was a roles.json whose
10
+ // `claude-code` block had been filled with a Factory Droid id (`custom:DeepSeek-V4-Pro-0`).
11
+ // The gate was built on the assumption that Claude Code SILENTLY falls back to the session's
12
+ // model on an unresolvable `model:`. A live measurement (`claude -p`, four runs incl. a haiku
13
+ // control and the literal incident id) disproved that: Claude Code fails LOUD — zero subagent
14
+ // tokens, `Agent terminated early due to an API error: There's an issue with the selected model
15
+ // (...)` — for every unresolvable id tried, alias-shaped or concrete-id-shaped alike.
16
+ //
17
+ // A closed allow-list built on the false premise mostly produced FALSE POSITIVES instead: a
18
+ // brand-new id Claude Code would happily accept (e.g. a future `claude-opus-5`) got blocked by
19
+ // `apply` before the harness ever saw it, purely because this file's list hadn't caught up yet.
20
+ //
21
+ // Policy is now THREE-tier per harness:
22
+ // known — an alias/`inherit` value (or, for an open-policy harness, any id — see below).
23
+ // No violation, no warning.
24
+ // unknown — shape-valid for this harness (looks like one of its own ids, e.g. `claude-*`) but
25
+ // not on the small known-alias list — plausibly a real id newer than this file.
26
+ // NON-BLOCKING: write it and warn. If it is actually wrong, the harness itself now
27
+ // fails loud at runtime (verified above) — an acceptable, visible failure mode this
28
+ // gate no longer needs to preempt.
29
+ // foreign — recognizably ANOTHER harness's id shape (droid's `custom:*` BYOK scheme, or a
30
+ // `provider/model` id — opencode's own syntax) landing in this harness's binding.
31
+ // That is the 2026-07-12 incident shape exactly. BLOCKING: refuse to write it.
32
+ //
33
+ // Per-harness id-space claim:
34
+ // closed — the harness's KNOWN aliases are enumerable (used for the "known" tier); ids outside
35
+ // that list are classified unknown/foreign by SHAPE, not by a maintained exhaustive
36
+ // catalog (see classifyModel below).
37
+ // open — the id space is provider-defined and open-ended (a registry/gateway resolves it);
38
+ // we make no claim at all, so the gate never fires. This is honesty, not laxity: a
39
+ // false allow-list would block legitimate ids.
40
+ //
41
+ // What this gate still cannot see, and remains defensive about: a model that IS valid but is
42
+ // excluded by an org `availableModels` policy, where Claude Code is documented to silently fall
43
+ // back to inherit. That path has not been measured live (it needs an org policy to test
44
+ // against) — it is the reason this gate is not simply deleted, only narrowed.
45
+ //
46
+ // Plain TS for native node type-stripping: zero deps.
47
+
48
+ import type { HarnessId } from "./harness-capabilities.ts";
49
+
50
+ export type HarnessModelPolicy =
51
+ | { readonly kind: "closed"; readonly allowed: readonly string[] }
52
+ | { readonly kind: "open"; readonly reason: string };
53
+
54
+ /** Three-tier classification of a candidate model id for a given harness (see file header). */
55
+ export type ModelClassification = "known" | "unknown" | "foreign";
56
+
57
+ /**
58
+ * Claude Code frontmatter `model:` known-alias list — tier aliases + `inherit`.
59
+ * Verified live in this repo's roster (.claude/agents/*.md, 2026-07-12) — opus, sonnet, haiku,
60
+ * fable all register and resolve; `inherit` is the documented "use the parent's model" value.
61
+ * Also verified live 2026-07-13: the Task-tool `model` parameter is a CLOSED ENUM restricted to
62
+ * exactly these four tier aliases (`sonnet|opus|haiku|fable`) — a concrete id there is rejected
63
+ * by input-schema validation before any API call. A concrete Anthropic model id (`claude-opus-4-8`
64
+ * and similar) is real and resolvable in frontmatter, but deliberately NOT enumerated here: the
65
+ * catalog changes faster than this file, and an unlisted-but-real id must warn, not block (see
66
+ * classifyModel's "unknown" tier) — that is the whole point of the 2026-07-13 revision.
67
+ */
68
+ const CLAUDE_CODE_MODELS: readonly string[] = ["inherit", "haiku", "sonnet", "opus", "fable"] as const;
69
+
70
+ const MODEL_POLICIES: Readonly<Record<HarnessId, HarnessModelPolicy>> = {
71
+ "claude-code": { kind: "closed", allowed: CLAUDE_CODE_MODELS },
72
+ // opencode resolves `provider/model` against whatever providers are configured locally
73
+ // (models.dev catalog + custom providers) — the set is not knowable from the kit.
74
+ opencode: {
75
+ kind: "open",
76
+ reason: "opencode resolves provider/model ids against the locally configured providers",
77
+ },
78
+ // Factory droid frontmatter takes `inherit` or an id from the workspace's model registry,
79
+ // including BYOK `custom:*` entries — not knowable from the kit.
80
+ // https://docs.factory.ai/cli/configuration/custom-droids § Controlling the model
81
+ droid: {
82
+ kind: "open",
83
+ reason: "droid resolves ids against the workspace model registry (incl. custom:* BYOK)",
84
+ },
85
+ };
86
+
87
+ /**
88
+ * Model policy for a harness id.
89
+ * Unknown harness → open (we know nothing about its model space; the capability gate already
90
+ * blocks every role on an unknown harness, so nothing slips through here).
91
+ */
92
+ export function harnessModelPolicy(harness: string): HarnessModelPolicy {
93
+ const p = (MODEL_POLICIES as Record<string, HarnessModelPolicy | undefined>)[harness];
94
+ return p ?? { kind: "open", reason: `unknown harness '${harness}' — no model claims` };
95
+ }
96
+
97
+ /** Known-alias ids for a harness ("known" tier only), or null when the harness's id space is open. */
98
+ export function allowedModels(harness: string): readonly string[] | null {
99
+ const p = harnessModelPolicy(harness);
100
+ return p.kind === "closed" ? p.allowed : null;
101
+ }
102
+
103
+ /** Strip a trailing context-window suffix: `claude-opus-4-8[1m]` → `claude-opus-4-8`. */
104
+ function stripContextSuffix(model: string): string {
105
+ return model.replace(/\[[^\]]*\]$/, "");
106
+ }
107
+
108
+ /**
109
+ * Shape of ANOTHER harness's model id landing in this binding: droid's BYOK scheme
110
+ * (`custom:DeepSeek-V4-Pro-0`) or a `provider/model` id (opencode's own syntax, e.g.
111
+ * `deepseek/deepseek-v4-pro`, `anthropic/claude-sonnet-4`). Claude Code frontmatter never uses
112
+ * either shape — this is a naming-convention fact, not a claim about any specific catalog.
113
+ */
114
+ function looksLikeForeignHarnessId(model: string): boolean {
115
+ return model.includes("/") || model.includes(":");
116
+ }
117
+
118
+ /**
119
+ * Shape of a Claude Code / Anthropic model id: the `claude-` prefix (case-insensitive), on the
120
+ * id with its context-window suffix already stripped. Every concrete Anthropic id observed to
121
+ * date starts this way — matching it is not a claim that any particular suffix resolves, only
122
+ * that it is plausibly this harness's own id and not a typo of something unrelated.
123
+ */
124
+ function looksLikeClaudeId(model: string): boolean {
125
+ return /^claude-/i.test(model);
126
+ }
127
+
128
+ /**
129
+ * Three-tier classification of `model` for `harness` (see file header for the policy).
130
+ * Open-policy harness → always "known" (no claim is made, so nothing is ever flagged).
131
+ * Closed-policy harness (claude-code today):
132
+ * - exact allow-list membership (case-insensitive, `[1m]`-suffix ignored) → "known";
133
+ * - else a recognizable foreign-harness shape (`provider/model`, `scheme:id`) → "foreign";
134
+ * - else this harness's own id shape (`claude-*`) → "unknown" (plausible, unverified);
135
+ * - else (matches no known shape at all, e.g. `gpt-5`) → "foreign".
136
+ * An empty/blank model has no shape to classify — callers must guard blank separately (unbound
137
+ * is legitimate inherit, not a model to classify; see checkRoleModelTruthfulness).
138
+ */
139
+ export function classifyModel(harness: string, model: string): ModelClassification {
140
+ const policy = harnessModelPolicy(harness);
141
+ if (policy.kind === "open") return "known";
142
+ const m = stripContextSuffix(model.trim()).toLowerCase();
143
+ if (!m) return "foreign";
144
+ if (policy.allowed.some((a) => a.toLowerCase() === m)) return "known";
145
+ if (looksLikeForeignHarnessId(m)) return "foreign";
146
+ if (looksLikeClaudeId(m)) return "unknown";
147
+ return "foreign";
148
+ }
149
+
150
+ /**
151
+ * Can `harness` be written `model` at all — i.e. is this NOT the blocking "foreign" tier?
152
+ * True for both "known" and "unknown" (shape-valid, non-blocking) classifications; false only
153
+ * for "foreign". Use classifyModel directly when the known/unknown distinction matters (e.g. to
154
+ * decide whether to emit a non-blocking warning).
155
+ */
156
+ export function isResolvableModel(harness: string, model: string): boolean {
157
+ return classifyModel(harness, model) !== "foreign";
158
+ }
@@ -0,0 +1,42 @@
1
+ // Shared "let me exit already" helper for the SessionStart/Stop hooks (pull-memory.ts,
2
+ // push-session.ts, droid-pull-memory.ts, droid-push-session.ts).
3
+ //
4
+ // Empirically measured (not assumed — see the exit comments in those files): an aborted
5
+ // fetch() against a REAL remote server that is slow/stalled (not a fast ECONNREFUSED) can
6
+ // leave its underlying TLSSocket alive for several seconds — sometimes ~10s — AFTER the
7
+ // AbortController fires and our own await has already settled, even with `Connection: close`
8
+ // sent on the request. `Connection: close` does stop keep-alive from being the default
9
+ // blocker (confirmed: a completed request's socket closes immediately), but it does not
10
+ // speed up the teardown of a socket whose request never got a response before being aborted.
11
+ // Left alone, a hook process that just returns from main() would sit waiting for the OS/TLS
12
+ // stack to finish that teardown, turning one slow session start into an ~18s stall (2x the
13
+ // fetch budget, since two separate fetch attempts can each leave one behind).
14
+ //
15
+ // The fix is NOT to hard `process.exit()` — that is the exact race that caused the original
16
+ // crash (`Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)`) when a handle was mid-close.
17
+ // Instead: mark any still-open handles as non-blocking for the event loop (`unref()`) once our
18
+ // own logical work is done. Node keeps tearing them down in the background exactly as it would
19
+ // have anyway (nothing is force-destroyed, so there is no close-teardown race) — it just stops
20
+ // waiting on them to decide the process is finished, so a natural exit follows immediately.
21
+ //
22
+ // process._getActiveHandles() is a private/undocumented Node API (no public equivalent exists;
23
+ // process.getActiveResourcesInfo() is public but returns descriptive strings, not handle
24
+ // references, so it cannot be used to unref anything). It has been stable across Node versions
25
+ // for a very long time and is the same mechanism debugging tools like wtfnode/why-is-node-running
26
+ // rely on. Best-effort: wrapped so a future Node removing/renaming it degrades to "do nothing"
27
+ // rather than throwing.
28
+ export function unrefLingeringHandles(): void {
29
+ try {
30
+ const handles = (process as unknown as { _getActiveHandles?: () => unknown[] })._getActiveHandles?.();
31
+ if (!Array.isArray(handles)) return;
32
+ for (const h of handles) {
33
+ try {
34
+ (h as { unref?: () => void }).unref?.();
35
+ } catch {
36
+ // best-effort per handle
37
+ }
38
+ }
39
+ } catch {
40
+ // best-effort: never let this stop the hook from exiting
41
+ }
42
+ }
@@ -0,0 +1,165 @@
1
+ // Unit tests for the retired-prompt-RAG hook MIGRATION (hook-prune.ts).
2
+ //
3
+ // What this pins: prompt-RAG was removed from the kit, but a machine that ever ran
4
+ // `petbox-wire --prompt-rag` still has the hook command written into ~/.claude/settings.json and
5
+ // ~/.factory/settings.json, pointing at a prompt-rag.ts the kit no longer ships. If wire only
6
+ // deleted the kit files, that hook would fail on EVERY prompt. So wire prunes it unconditionally,
7
+ // idempotently, and without disturbing any other hook — exactly the four properties below.
8
+ //
9
+ // The logic lives in its own module precisely so it can be tested: wire.ts runs main() at import
10
+ // time and can never be imported by a test (same reason wire-exit.ts / posix-env.ts exist).
11
+ //
12
+ // Run: node --test src/hook-prune.test.ts (Node >= 23.6 native TS type-stripping)
13
+
14
+ import assert from "node:assert/strict";
15
+ import { test } from "node:test";
16
+ import {
17
+ hasDeadPromptRagHook,
18
+ hasHookTargeting,
19
+ LEGACY_PROMPT_RAG_FILE,
20
+ pruneDeadPromptRagHooks,
21
+ pruneHooksTargeting,
22
+ } from "./hook-prune.ts";
23
+
24
+ // The two commands older kits actually wrote (CC plain; Droid with the --agent droid suffix).
25
+ const CC_RAG_CMD = 'node "C:\\Users\\x\\.petbox\\wire\\prompt-rag.ts"';
26
+ const DROID_RAG_CMD = 'node "C:\\Users\\x\\.petbox\\wire\\prompt-rag.ts" --agent droid';
27
+ const PUSH_CMD = 'node "C:\\Users\\x\\.petbox\\wire\\push-session.ts"';
28
+ const PULL_CMD = 'node "C:\\Users\\x\\.petbox\\wire\\pull-memory.ts"';
29
+ const FOREIGN_CMD = 'node "C:\\Users\\x\\my-own-hooks\\lint.ts"';
30
+
31
+ const cmd = (command: string) => ({ type: "command", command });
32
+
33
+ // A realistic ~/.claude/settings.json `hooks` object from a machine that opted into prompt-RAG.
34
+ function claudeHooksWithRag(): any {
35
+ return {
36
+ Stop: [{ hooks: [cmd(PUSH_CMD)] }],
37
+ SessionStart: [{ hooks: [cmd(PULL_CMD)] }],
38
+ UserPromptSubmit: [{ hooks: [cmd(CC_RAG_CMD)] }],
39
+ };
40
+ }
41
+
42
+ // ---- prune when present ----------------------------------------------------------------------
43
+
44
+ test("prune removes the dead prompt-rag hook from a Claude settings hooks object", () => {
45
+ const hooks = claudeHooksWithRag();
46
+ assert.equal(hasDeadPromptRagHook(hooks), true);
47
+
48
+ const pruned = pruneDeadPromptRagHooks(hooks);
49
+
50
+ assert.equal(pruned, 1);
51
+ assert.equal(hasDeadPromptRagHook(hooks), false);
52
+ // The now-empty event key is gone entirely (no `"UserPromptSubmit": []` litter).
53
+ assert.equal("UserPromptSubmit" in hooks, false);
54
+ });
55
+
56
+ test("prune catches the droid variant (`--agent droid` suffix) — matching is on the quoted basename", () => {
57
+ const hooks = { UserPromptSubmit: [{ hooks: [cmd(DROID_RAG_CMD)] }] };
58
+ assert.equal(hasDeadPromptRagHook(hooks), true);
59
+ assert.equal(pruneDeadPromptRagHooks(hooks), 1);
60
+ assert.equal(hasDeadPromptRagHook(hooks), false);
61
+ });
62
+
63
+ test("prune removes BOTH variants and every duplicate, wherever they sit", () => {
64
+ const hooks = {
65
+ UserPromptSubmit: [
66
+ { hooks: [cmd(CC_RAG_CMD), cmd(FOREIGN_CMD)] },
67
+ { hooks: [cmd(DROID_RAG_CMD)] },
68
+ { hooks: [cmd(CC_RAG_CMD)] }, // a duplicate group from a re-wire on an older kit
69
+ ],
70
+ };
71
+ assert.equal(pruneDeadPromptRagHooks(hooks), 3);
72
+ // The user's own hook in the shared group survives; the groups that held ONLY rag hooks are gone.
73
+ assert.deepEqual(hooks.UserPromptSubmit, [{ hooks: [cmd(FOREIGN_CMD)] }]);
74
+ });
75
+
76
+ // ---- no-op when absent -----------------------------------------------------------------------
77
+
78
+ test("no-op when no prompt-rag hook is present: nothing removed, object untouched", () => {
79
+ const hooks = {
80
+ Stop: [{ hooks: [cmd(PUSH_CMD)] }],
81
+ SessionStart: [{ hooks: [cmd(PULL_CMD)] }],
82
+ };
83
+ const before = JSON.stringify(hooks);
84
+
85
+ assert.equal(hasDeadPromptRagHook(hooks), false);
86
+ assert.equal(pruneDeadPromptRagHooks(hooks), 0);
87
+ assert.equal(JSON.stringify(hooks), before);
88
+ });
89
+
90
+ test("no-op on a settings file with no hooks at all (missing / junk hooks value)", () => {
91
+ assert.equal(pruneDeadPromptRagHooks(undefined), 0);
92
+ assert.equal(pruneDeadPromptRagHooks(null), 0);
93
+ assert.equal(pruneDeadPromptRagHooks("nonsense"), 0);
94
+ assert.equal(pruneDeadPromptRagHooks({}), 0);
95
+ assert.equal(hasDeadPromptRagHook(undefined), false);
96
+ assert.equal(hasDeadPromptRagHook({}), false);
97
+ });
98
+
99
+ // ---- idempotence -----------------------------------------------------------------------------
100
+
101
+ test("double run is a byte-identical no-op (idempotent: the second wire changes nothing)", () => {
102
+ const hooks = claudeHooksWithRag();
103
+
104
+ assert.equal(pruneDeadPromptRagHooks(hooks), 1);
105
+ const afterFirst = JSON.stringify(hooks);
106
+
107
+ // Second `wire` run on the same machine: nothing left to prune → nothing rewritten.
108
+ assert.equal(pruneDeadPromptRagHooks(hooks), 0);
109
+ assert.equal(JSON.stringify(hooks), afterFirst);
110
+
111
+ // …and a third, for good measure.
112
+ assert.equal(pruneDeadPromptRagHooks(hooks), 0);
113
+ assert.equal(JSON.stringify(hooks), afterFirst);
114
+ });
115
+
116
+ // ---- leaves everything else alone ------------------------------------------------------------
117
+
118
+ test("leaves other hooks alone — kit hooks, foreign hooks, and unrelated events all survive", () => {
119
+ const hooks: any = {
120
+ Stop: [{ hooks: [cmd(PUSH_CMD)] }],
121
+ SessionStart: [{ hooks: [cmd(PULL_CMD)] }],
122
+ UserPromptSubmit: [
123
+ { hooks: [cmd(FOREIGN_CMD)] }, // someone else's UserPromptSubmit hook
124
+ { hooks: [cmd(CC_RAG_CMD)] },
125
+ ],
126
+ PreToolUse: [{ matcher: "Bash", hooks: [cmd(FOREIGN_CMD)] }],
127
+ };
128
+
129
+ assert.equal(pruneDeadPromptRagHooks(hooks), 1);
130
+
131
+ assert.deepEqual(hooks, {
132
+ Stop: [{ hooks: [cmd(PUSH_CMD)] }],
133
+ SessionStart: [{ hooks: [cmd(PULL_CMD)] }],
134
+ UserPromptSubmit: [{ hooks: [cmd(FOREIGN_CMD)] }], // event kept — it still has a real hook
135
+ PreToolUse: [{ matcher: "Bash", hooks: [cmd(FOREIGN_CMD)] }], // matcher preserved
136
+ });
137
+ });
138
+
139
+ test("leaves shapes it does not own exactly as found (non-array event, pre-existing empty group)", () => {
140
+ const hooks: any = {
141
+ Weird: "not-an-array",
142
+ SessionStart: [{ hooks: [] }, { hooks: [cmd(PULL_CMD)] }], // an empty group we did NOT create
143
+ UserPromptSubmit: [{ hooks: [cmd(CC_RAG_CMD)] }],
144
+ };
145
+
146
+ assert.equal(pruneDeadPromptRagHooks(hooks), 1);
147
+
148
+ assert.deepEqual(hooks, {
149
+ Weird: "not-an-array",
150
+ SessionStart: [{ hooks: [] }, { hooks: [cmd(PULL_CMD)] }],
151
+ });
152
+ });
153
+
154
+ // ---- the generic primitive (kept from the deleted prompt-rag-hook.ts) -------------------------
155
+
156
+ test("pruneHooksTargeting / hasHookTargeting match on the QUOTED basename of any kit file", () => {
157
+ assert.equal(LEGACY_PROMPT_RAG_FILE, "prompt-rag.ts");
158
+
159
+ const hooks = { Stop: [{ hooks: [cmd(PUSH_CMD)] }] };
160
+ assert.equal(hasHookTargeting(hooks, "push-session.ts"), true);
161
+ assert.equal(hasHookTargeting(hooks, "pull-memory.ts"), false); // a different kit file
162
+ assert.equal(hasHookTargeting(hooks, "push-session"), false); // the CLOSING QUOTE is part of the needle
163
+ assert.equal(pruneHooksTargeting(hooks, "push-session.ts"), 1);
164
+ assert.deepEqual(hooks, {});
165
+ });
@@ -0,0 +1,83 @@
1
+ // Settings surgery for GLOBAL agent hook files (~/.claude/settings.json, ~/.factory/settings.json).
2
+ //
3
+ // Why this module exists at all: prompt-RAG (a global UserPromptSubmit hook that injected exact-match
4
+ // context) has been REMOVED from the kit. But a machine that ever ran `petbox-wire --prompt-rag` has
5
+ // the hook command WRITTEN INTO those two settings files, pointing at `<STABLE>/prompt-rag.ts`. The
6
+ // kit no longer ships that file, so the leftover hook would fail on EVERY prompt. Deleting the kit
7
+ // files without pruning the settings is therefore not a removal — it is a breakage.
8
+ //
9
+ // So `wire` runs the prune UNCONDITIONALLY (there is no flag left to gate it on) and idempotently:
10
+ // it removes only hook entries whose command targets prompt-rag.ts (both the plain Claude Code
11
+ // variant and the `--agent droid` one), leaves every other hook untouched, and a second run is a
12
+ // byte-identical no-op (the caller only rewrites the file when something was actually removed).
13
+ //
14
+ // Lives in its own side-effect-free module (like wire-exit.ts / posix-env.ts / wire-identity.ts)
15
+ // precisely so it is importable by a test: wire.ts runs main() at import time and cannot be.
16
+ //
17
+ // Plain TS for native node type-stripping: no enum/namespace/parameter-properties, zero deps.
18
+
19
+ // The kit file every legacy prompt-RAG hook command targets, on either agent. Both variants are
20
+ // node "<...>/prompt-rag.ts" (Claude Code)
21
+ // node "<...>/prompt-rag.ts" --agent droid (Factory Droid)
22
+ // so matching the QUOTED basename catches both without caring where the stable kit lives.
23
+ export const LEGACY_PROMPT_RAG_FILE = "prompt-rag.ts";
24
+
25
+ // Remove EVERY hook (across all events) whose command targets the given STABLE kit file, then drop
26
+ // the groups THIS call emptied (and an event key it emptied entirely). Mutates hooksObj in place;
27
+ // returns the count pruned. Deliberately conservative:
28
+ // - a non-array event value is left exactly as found (we never rewrite a shape we do not own),
29
+ // - a group that was already empty before this call is left alone (it is not ours to clean),
30
+ // so on a settings file with no prompt-rag hook this is a pure read: returns 0, mutates nothing.
31
+ export function pruneHooksTargeting(hooksObj: any, fileBasename: string): number {
32
+ if (!hooksObj || typeof hooksObj !== "object") return 0;
33
+ let removed = 0;
34
+ const needle = `${fileBasename}"`;
35
+ for (const event of Object.keys(hooksObj)) {
36
+ if (!Array.isArray(hooksObj[event])) continue;
37
+ const groups: any[] = hooksObj[event];
38
+ const emptiedHere = new Set<any>();
39
+ for (const g of groups) {
40
+ if (!g || !Array.isArray(g.hooks)) continue;
41
+ const before = g.hooks.length;
42
+ g.hooks = g.hooks.filter(
43
+ (h: any) => !(typeof h?.command === "string" && h.command.includes(needle)),
44
+ );
45
+ const gone = before - g.hooks.length;
46
+ removed += gone;
47
+ if (gone > 0 && g.hooks.length === 0) emptiedHere.add(g);
48
+ }
49
+ if (emptiedHere.size === 0) continue;
50
+ const kept = groups.filter((g) => !emptiedHere.has(g));
51
+ if (kept.length === 0) delete hooksObj[event];
52
+ else hooksObj[event] = kept;
53
+ }
54
+ return removed;
55
+ }
56
+
57
+ // Does this settings `hooks` object carry a hook targeting the given kit file? (Quoted-basename
58
+ // match, so it is agnostic to where the stable kit lives.) Read-only.
59
+ export function hasHookTargeting(hooksObj: any, fileBasename: string): boolean {
60
+ if (!hooksObj || typeof hooksObj !== "object") return false;
61
+ const needle = `${fileBasename}"`;
62
+ for (const event of Object.keys(hooksObj)) {
63
+ const groups: any[] = Array.isArray(hooksObj[event]) ? hooksObj[event] : [];
64
+ for (const g of groups) {
65
+ if (!g || !Array.isArray(g.hooks)) continue;
66
+ if (g.hooks.some((h: any) => typeof h?.command === "string" && h.command.includes(needle))) {
67
+ return true;
68
+ }
69
+ }
70
+ }
71
+ return false;
72
+ }
73
+
74
+ // The migration itself: drop every dead prompt-RAG hook from one agent's `hooks` object.
75
+ // Returns the number removed (0 = nothing to do = the caller must not rewrite the file).
76
+ export function pruneDeadPromptRagHooks(hooksObj: any): number {
77
+ return pruneHooksTargeting(hooksObj, LEGACY_PROMPT_RAG_FILE);
78
+ }
79
+
80
+ // Does this settings `hooks` object still carry a dead prompt-RAG hook?
81
+ export function hasDeadPromptRagHook(hooksObj: any): boolean {
82
+ return hasHookTargeting(hooksObj, LEGACY_PROMPT_RAG_FILE);
83
+ }