@bookedsolid/rea 0.3.0 → 0.4.0

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 (56) hide show
  1. package/.husky/pre-push +15 -18
  2. package/README.md +41 -1
  3. package/dist/cli/doctor.d.ts +19 -4
  4. package/dist/cli/doctor.js +172 -5
  5. package/dist/cli/index.js +9 -1
  6. package/dist/cli/init.js +93 -7
  7. package/dist/cli/install/pre-push.d.ts +335 -0
  8. package/dist/cli/install/pre-push.js +2818 -0
  9. package/dist/cli/serve.d.ts +64 -0
  10. package/dist/cli/serve.js +270 -2
  11. package/dist/cli/status.d.ts +90 -0
  12. package/dist/cli/status.js +399 -0
  13. package/dist/cli/utils.d.ts +4 -0
  14. package/dist/cli/utils.js +4 -0
  15. package/dist/gateway/circuit-breaker.d.ts +17 -0
  16. package/dist/gateway/circuit-breaker.js +32 -3
  17. package/dist/gateway/downstream-pool.d.ts +2 -1
  18. package/dist/gateway/downstream-pool.js +2 -2
  19. package/dist/gateway/downstream.d.ts +39 -3
  20. package/dist/gateway/downstream.js +73 -14
  21. package/dist/gateway/log.d.ts +122 -0
  22. package/dist/gateway/log.js +334 -0
  23. package/dist/gateway/middleware/audit.d.ts +10 -1
  24. package/dist/gateway/middleware/audit.js +26 -1
  25. package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
  26. package/dist/gateway/middleware/blocked-paths.js +439 -67
  27. package/dist/gateway/middleware/injection.d.ts +218 -13
  28. package/dist/gateway/middleware/injection.js +433 -51
  29. package/dist/gateway/middleware/kill-switch.d.ts +10 -1
  30. package/dist/gateway/middleware/kill-switch.js +20 -1
  31. package/dist/gateway/observability/metrics.d.ts +125 -0
  32. package/dist/gateway/observability/metrics.js +321 -0
  33. package/dist/gateway/server.d.ts +19 -0
  34. package/dist/gateway/server.js +99 -15
  35. package/dist/policy/loader.d.ts +13 -0
  36. package/dist/policy/loader.js +28 -0
  37. package/dist/policy/profiles.d.ts +13 -0
  38. package/dist/policy/profiles.js +12 -0
  39. package/dist/policy/types.d.ts +28 -0
  40. package/dist/registry/fingerprint.d.ts +73 -0
  41. package/dist/registry/fingerprint.js +81 -0
  42. package/dist/registry/fingerprints-store.d.ts +62 -0
  43. package/dist/registry/fingerprints-store.js +111 -0
  44. package/dist/registry/interpolate.d.ts +58 -0
  45. package/dist/registry/interpolate.js +121 -0
  46. package/dist/registry/loader.d.ts +2 -2
  47. package/dist/registry/loader.js +22 -1
  48. package/dist/registry/tofu-gate.d.ts +41 -0
  49. package/dist/registry/tofu-gate.js +189 -0
  50. package/dist/registry/tofu.d.ts +111 -0
  51. package/dist/registry/tofu.js +173 -0
  52. package/dist/registry/types.d.ts +9 -1
  53. package/package.json +1 -1
  54. package/profiles/bst-internal-no-codex.yaml +5 -0
  55. package/profiles/bst-internal.yaml +7 -0
  56. package/scripts/tarball-smoke.sh +197 -0
@@ -0,0 +1,173 @@
1
+ /**
2
+ * TOFU classifier — the G7 gate between `.rea/registry.yaml` and the
3
+ * downstream pool.
4
+ *
5
+ * For each server declared in the registry, classify as:
6
+ *
7
+ * - `first-seen` — no entry in `.rea/fingerprints.json`. Record the
8
+ * fingerprint, surface a LOUD block to the operator, allow the server
9
+ * to connect. This is the TOFU trust-on-first-use decision; the
10
+ * loudness is deliberate so a silent poisoning at first install is
11
+ * still visible in stderr / audit / logs.
12
+ *
13
+ * - `unchanged` — fingerprint matches the stored value. Proceed normally.
14
+ *
15
+ * - `drifted` — fingerprint differs from the stored value. Refuse to
16
+ * connect the server unless `REA_ACCEPT_DRIFT` names it for a single
17
+ * boot. The rest of the gateway stays up — other servers remain
18
+ * available, the upstream client just sees a smaller catalog.
19
+ *
20
+ * The audit entry, log line, and stderr block are emitted by the caller
21
+ * (the gateway startup sequence). This module is pure classification plus
22
+ * store updates; keeping it side-effect-free makes it unit-testable
23
+ * without stubbing the filesystem or the audit chain.
24
+ */
25
+ import { fingerprintServer } from './fingerprint.js';
26
+ import { FINGERPRINT_STORE_VERSION } from './fingerprints-store.js';
27
+ function parseAcceptDrift(raw) {
28
+ if (raw === undefined || raw.trim() === '')
29
+ return new Set();
30
+ return new Set(raw
31
+ .split(',')
32
+ .map((s) => s.trim())
33
+ .filter((s) => s.length > 0));
34
+ }
35
+ /**
36
+ * Classify every server in `servers` against the loaded `store`. Pure:
37
+ * does not read or write the filesystem. Returns one classification per
38
+ * server in the same order.
39
+ *
40
+ * ## Rename-with-removal defense (scope: narrow)
41
+ *
42
+ * A name-only lookup is not enough when an attacker rewrites a
43
+ * previously trusted entry AND changes its `name` at the same time: the
44
+ * stored lookup would miss and the tampered entry would land as benign
45
+ * `first-seen`. To close THAT specific shape we compute a rename signal
46
+ * at boot time:
47
+ *
48
+ * - `disappeared = stored_names - registry_names`
49
+ * - `appeared = registry_names - stored_names`
50
+ *
51
+ * If BOTH sets are non-empty in the same boot, at least one declared
52
+ * entry has been renamed-with-removal (stored entry vanished, a new name
53
+ * showed up). In that case every entry in `appeared` is promoted from
54
+ * `first-seen` to `drifted`: the operator MUST explicitly accept it via
55
+ * `REA_ACCEPT_DRIFT=<new-name>` before it connects.
56
+ *
57
+ * ### What this defense does NOT catch
58
+ *
59
+ * If the attacker leaves the old trusted entry in the registry (e.g.
60
+ * flipped `enabled: false`, or left untouched as a decoy) and ADDS a
61
+ * tampered entry under a new name, `disappeared` stays empty, the
62
+ * set-difference heuristic does not fire, and the new entry lands as
63
+ * `first-seen`. That is **not a bypass** of the TOFU contract — it is
64
+ * structurally identical to `operator added a new MCP server`, which
65
+ * TOFU intentionally allows with a LOUD stderr banner demanding the
66
+ * operator's attention. The first-seen banner is the enforcement
67
+ * mechanism for that shape; the rename-with-removal heuristic above is
68
+ * strictly additional coverage for the harder-to-notice shape where the
69
+ * old entry disappears at the same moment the new one arrives.
70
+ *
71
+ * Genuinely additive installs (new entry appended with no concurrent
72
+ * removal) also remain `first-seen` and get the usual LOUD banner.
73
+ *
74
+ * This is strictly additive over the name-based lookup — a name-matched
75
+ * drift (same name, changed config) still classifies as `drifted` via
76
+ * the primary path.
77
+ *
78
+ * The fingerprint itself already includes `server.name` (see
79
+ * `fingerprint.ts` canonicalization), so an attacker cannot make a
80
+ * renamed entry's fingerprint coincide with a stored one under a
81
+ * different name. That means a cross-name fingerprint match would never
82
+ * happen in practice — the set-difference heuristic above is what
83
+ * actually defends the rename-with-removal shape.
84
+ */
85
+ export function classifyServers(servers, store, opts = {}) {
86
+ const bypass = parseAcceptDrift(opts.acceptDrift);
87
+ const registryNames = new Set(servers.map((s) => s.name));
88
+ const storedNames = new Set(Object.keys(store.servers));
89
+ const disappeared = new Set();
90
+ for (const n of storedNames) {
91
+ if (!registryNames.has(n))
92
+ disappeared.add(n);
93
+ }
94
+ const appeared = new Set();
95
+ for (const n of registryNames) {
96
+ if (!storedNames.has(n))
97
+ appeared.add(n);
98
+ }
99
+ // A rename is only plausible when something vanished AND something new
100
+ // appeared in the same boot. Pure additions (disappeared empty) are
101
+ // still benign first-seen.
102
+ const renameDetected = disappeared.size > 0 && appeared.size > 0;
103
+ // When a rename is detected, surface one disappeared stored fingerprint
104
+ // so the drift-block banner and audit entry have a concrete `stored`
105
+ // value to display. The choice is deterministic (first in insertion
106
+ // order from the store object) and documented as representative rather
107
+ // than authoritative — the operator's job is to compare the new entry
108
+ // against .rea/fingerprints.json by hand.
109
+ const representativeStored = renameDetected && disappeared.size > 0
110
+ ? store.servers[[...disappeared][0]]
111
+ : undefined;
112
+ return servers.map((s) => {
113
+ const current = fingerprintServer(s);
114
+ const stored = store.servers[s.name];
115
+ if (stored === undefined) {
116
+ if (renameDetected && appeared.has(s.name)) {
117
+ // Rename-then-tamper defense: promote to drifted. Operator must
118
+ // REA_ACCEPT_DRIFT the new name to let it connect.
119
+ const c = {
120
+ server: s.name,
121
+ verdict: 'drifted',
122
+ current,
123
+ bypassed: bypass.has(s.name),
124
+ };
125
+ if (representativeStored !== undefined)
126
+ c.stored = representativeStored;
127
+ return c;
128
+ }
129
+ return { server: s.name, verdict: 'first-seen', current, bypassed: false };
130
+ }
131
+ if (stored === current) {
132
+ return { server: s.name, verdict: 'unchanged', current, stored, bypassed: false };
133
+ }
134
+ return {
135
+ server: s.name,
136
+ verdict: 'drifted',
137
+ current,
138
+ stored,
139
+ bypassed: bypass.has(s.name),
140
+ };
141
+ });
142
+ }
143
+ /**
144
+ * Merge classifications into an updated store. Applies the TOFU rule:
145
+ *
146
+ * - `first-seen` → add the current fingerprint.
147
+ * - `unchanged` → keep the existing value (no-op).
148
+ * - `drifted` → if bypassed, overwrite with the current fingerprint
149
+ * (operator has authorized the update); otherwise keep
150
+ * the stored value (drift persists across restart until
151
+ * explicitly accepted).
152
+ *
153
+ * Does not prune entries for servers that were removed from the registry —
154
+ * that decision is the operator's, and silently dropping fingerprints
155
+ * would let an attacker rename-then-reinstall a server to reset TOFU state.
156
+ */
157
+ export function updateStore(store, classifications) {
158
+ const next = {
159
+ version: FINGERPRINT_STORE_VERSION,
160
+ servers: { ...store.servers },
161
+ };
162
+ for (const c of classifications) {
163
+ if (c.verdict === 'first-seen') {
164
+ next.servers[c.server] = c.current;
165
+ continue;
166
+ }
167
+ if (c.verdict === 'drifted' && c.bypassed) {
168
+ next.servers[c.server] = c.current;
169
+ }
170
+ // unchanged / drifted-no-bypass → leave store untouched for this server
171
+ }
172
+ return next;
173
+ }
@@ -11,7 +11,15 @@ export interface RegistryServer {
11
11
  command: string;
12
12
  /** Arguments passed to the spawned child process. */
13
13
  args: string[];
14
- /** Environment variables merged onto the child process env. */
14
+ /**
15
+ * Environment variables merged onto the child process env. Values may
16
+ * reference rea-serve's own `process.env` via `${VAR}` syntax — e.g.
17
+ * `{ BOT_TOKEN: '${DISCORD_BOT_TOKEN}' }`. Only the curly-brace form is
18
+ * supported; no `$VAR`, no defaults, no command substitution. If a
19
+ * referenced var is unset at spawn time the affected server fails to
20
+ * start (the rest of the gateway still comes up). See
21
+ * `registry/interpolate.ts` for the full grammar and contract.
22
+ */
15
23
  env: Record<string, string>;
16
24
  /**
17
25
  * Optional opt-in list of operator-env var names to forward into the child.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
@@ -32,6 +32,11 @@ blocked_paths:
32
32
  - SECURITY.md
33
33
  - THREAT_MODEL.md
34
34
  notification_channel: ""
35
+ # G9: Booked-internal consumers retain the stricter 0.2.x posture — a single
36
+ # literal injection match at write/destructive tier denies (does not merely
37
+ # warn). External profiles inherit the schema default `false`.
38
+ injection:
39
+ suspicious_blocks_writes: true
35
40
  context_protection:
36
41
  delegate_to_subagent:
37
42
  - pnpm run build
@@ -15,6 +15,13 @@ blocked_paths:
15
15
  - SECURITY.md
16
16
  - THREAT_MODEL.md
17
17
  notification_channel: ""
18
+ # G9: Booked-internal consumers retain the stricter 0.2.x posture — a single
19
+ # literal injection match at write/destructive tier denies (does not merely
20
+ # warn). External profiles (open-source, client-engagement, minimal, lit-wc)
21
+ # inherit the schema default `false` so upgrading 0.2.x consumers are not
22
+ # silently tightened.
23
+ injection:
24
+ suspicious_blocks_writes: true
18
25
  context_protection:
19
26
  delegate_to_subagent:
20
27
  - pnpm run build
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env bash
2
+ # tarball-smoke.sh — exercise the packed @bookedsolid/rea tarball end-to-end
3
+ # in an isolated tempdir. Catches packaging regressions (missing files from
4
+ # `files:`, broken exports map, shebang / chmod issues on `bin`, postinstall
5
+ # failures, dependency resolution drift) BEFORE the tarball reaches npm.
6
+ #
7
+ # Must be run from the repo root. Assumes `dist/` has already been built.
8
+ #
9
+ # Runs under CI on every PR and on every push to main; also recommended as a
10
+ # manual gate before hand-authorizing a Changesets release PR merge.
11
+ #
12
+ # ## Developer-run negative probe (optional, not in CI)
13
+ #
14
+ # To verify the tree-equality asserts actually fail loud on a missing shipped
15
+ # file, temporarily drop `commands/` or `.husky/` from `package.json#files[]`,
16
+ # run this script, and confirm it exits non-zero at the install-surface diff
17
+ # step. Revert the `files:` edit before committing. CI does not run this probe
18
+ # because it would mutate package.json.
19
+ #
20
+ # Exit codes:
21
+ # 0 — smoke passed
22
+ # 1 — preflight failure (missing dist, pack failed)
23
+ # 2 — smoke assertion failure (bin missing, init/doctor failed, exports broken)
24
+ set -euo pipefail
25
+
26
+ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
27
+ cd "$REPO_ROOT"
28
+
29
+ if [ ! -d "dist" ]; then
30
+ echo "[smoke] FAIL — dist/ not found. Run 'pnpm build' first." >&2
31
+ exit 1
32
+ fi
33
+
34
+ PACK_DIR="$(mktemp -d -t rea-smoke-pack-XXXXXX)"
35
+ SMOKE_DIR="$(mktemp -d -t rea-smoke-install-XXXXXX)"
36
+ DIFF_TMP="$(mktemp -t rea-smoke-diff-XXXXXX)"
37
+ cleanup() { rm -rf -- "$PACK_DIR" "$SMOKE_DIR" 2>/dev/null || true; rm -f "$DIFF_TMP"; }
38
+ # EXIT alone misses Ctrl-C / TERM / HUP during local runs, leaving
39
+ # /tmp/rea-smoke-* tempdirs behind. Trap the interrupt signals too.
40
+ trap cleanup EXIT HUP INT TERM
41
+
42
+ echo "[smoke] pack → $PACK_DIR"
43
+ pnpm pack --pack-destination "$PACK_DIR" >/dev/null
44
+ TARBALL="$(ls "$PACK_DIR"/bookedsolid-rea-*.tgz | head -1)"
45
+ if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then
46
+ echo "[smoke] FAIL — pnpm pack produced no tarball in $PACK_DIR" >&2
47
+ exit 1
48
+ fi
49
+ echo "[smoke] tarball: $(basename "$TARBALL") ($(wc -c < "$TARBALL" | awk '{printf "%.0f KB\n", $1/1024}'))"
50
+
51
+ echo "[smoke] install in $SMOKE_DIR"
52
+ cd "$SMOKE_DIR"
53
+ npm init -y >/dev/null
54
+ npm install --no-audit --no-fund --loglevel=error "$TARBALL"
55
+
56
+ # Drop the temp package.json + lockfile that `npm init -y` + `npm install`
57
+ # wrote. The tempdir must look like a fresh consumer project (no package.json)
58
+ # so `rea init` exercises the same code path a brand-new consumer hits.
59
+ rm -f package.json package-lock.json
60
+ git init -q
61
+
62
+ echo "[smoke] rea --version"
63
+ VERSION_OUT="$(./node_modules/.bin/rea --version)"
64
+ # Pass the repo-root package.json path via argv to avoid interpolating it
65
+ # into a JS string literal — paths with apostrophes, backslashes, or `${...}`
66
+ # expansions would otherwise break the require() call.
67
+ EXPECTED_VERSION="$(node -p "require(process.argv[1]).version" "$REPO_ROOT/package.json")"
68
+ if [ "$VERSION_OUT" != "$EXPECTED_VERSION" ]; then
69
+ echo "[smoke] FAIL — rea --version returned '$VERSION_OUT', expected '$EXPECTED_VERSION'" >&2
70
+ exit 2
71
+ fi
72
+ echo "[smoke] → $VERSION_OUT"
73
+
74
+ echo "[smoke] rea --help"
75
+ ./node_modules/.bin/rea --help >/dev/null
76
+
77
+ echo "[smoke] rea init --yes --profile open-source"
78
+ ./node_modules/.bin/rea init --yes --profile open-source
79
+
80
+ # Verify the installed layout matches what init claims it wrote.
81
+ for expected in .rea/policy.yaml .rea/registry.yaml .claude/settings.json CLAUDE.md .rea/install-manifest.json; do
82
+ if [ ! -f "$expected" ]; then
83
+ echo "[smoke] FAIL — rea init did not create $expected" >&2
84
+ exit 2
85
+ fi
86
+ done
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Install-surface tree-equality asserts.
90
+ #
91
+ # Prior versions counted `.claude/agents/*.md` and `.claude/hooks/*.sh` and
92
+ # never verified `.claude/commands/` or the shipped `.husky/pre-push`. A
93
+ # tarball that dropped either surface still passed. We now diff sorted file
94
+ # lists: any missing OR extra file fails loud and names the delta.
95
+ #
96
+ # Surfaces under test:
97
+ # 1. .claude/agents/ ↔ repo agents/*.md (flat)
98
+ # 2. .claude/hooks/ ↔ repo hooks/**/*.sh (flat + _lib/)
99
+ # 3. .claude/commands/ ↔ repo commands/*.md (flat)
100
+ # 4. node_modules/.../.husky ↔ repo .husky/{commit-msg,pre-push}
101
+ #
102
+ # The `.husky/` check targets the package tree under node_modules because
103
+ # `rea init` only copies `.husky/*` into the consumer when `.husky/` already
104
+ # exists there. On a fresh consumer (this smoke's default), the hooks live as
105
+ # `.git/hooks/{commit-msg,pre-push}` via the fallback installers. What must
106
+ # ALWAYS be true is that the tarball itself ships the `.husky/` source of
107
+ # truth — without it, husky-using consumers get nothing.
108
+ # ---------------------------------------------------------------------------
109
+
110
+ assert_tree_equal() {
111
+ # $1 — label for error messages
112
+ # $2 — file listing of the source tree (one relative path per line)
113
+ # $3 — file listing of the installed tree (one relative path per line)
114
+ local label="$1" src="$2" dst="$3"
115
+ if [ -z "$src" ] || [ -z "$dst" ]; then
116
+ printf '[smoke] FAIL — empty file listing for %s\n' "$label" >&2
117
+ exit 2
118
+ fi
119
+ if ! diff -u <(printf '%s\n' "$src" | sort -u) <(printf '%s\n' "$dst" | sort -u) > "$DIFF_TMP" 2>&1; then
120
+ echo "[smoke] FAIL — $label differs between source tree and installed tree:" >&2
121
+ cat "$DIFF_TMP" >&2
122
+ exit 2
123
+ fi
124
+ }
125
+
126
+ # 1. agents — flat listing of *.md
127
+ AGENTS_SRC="$(cd "$REPO_ROOT/agents" && find . -maxdepth 1 -type f -name '*.md' | sed 's|^\./||')"
128
+ AGENTS_DST="$(cd "$SMOKE_DIR/.claude/agents" && find . -maxdepth 1 -type f -name '*.md' | sed 's|^\./||')"
129
+ assert_tree_equal ".claude/agents tree" "$AGENTS_SRC" "$AGENTS_DST"
130
+
131
+ # 2. hooks — recursive listing of *.sh (walks hooks/_lib/ too)
132
+ HOOKS_SRC="$(cd "$REPO_ROOT/hooks" && find . -type f -name '*.sh' | sed 's|^\./||')"
133
+ HOOKS_DST="$(cd "$SMOKE_DIR/.claude/hooks" && find . -type f -name '*.sh' | sed 's|^\./||')"
134
+ assert_tree_equal ".claude/hooks tree" "$HOOKS_SRC" "$HOOKS_DST"
135
+
136
+ # 3. commands — flat listing of *.md
137
+ COMMANDS_SRC="$(cd "$REPO_ROOT/commands" && find . -maxdepth 1 -type f -name '*.md' | sed 's|^\./||')"
138
+ COMMANDS_DST="$(cd "$SMOKE_DIR/.claude/commands" && find . -maxdepth 1 -type f -name '*.md' | sed 's|^\./||')"
139
+ assert_tree_equal ".claude/commands tree" "$COMMANDS_SRC" "$COMMANDS_DST"
140
+
141
+ # 4. husky — explicit pre-push + commit-msg existence inside the package
142
+ # tree under node_modules. `rea init` does not copy these into a fresh
143
+ # consumer's root, so we check the shipped copy directly. If either file
144
+ # is missing from the tarball, husky-using consumers silently get zero
145
+ # enforcement on their next `pnpm install`.
146
+ #
147
+ # Executable-bit check is intentionally NOT asserted here: npm's tarball
148
+ # format strips the group/other execute bits from non-`bin:` files on
149
+ # install, so the shipped file lives at mode 0644. What matters is that
150
+ # the installers in commit-msg.ts and pre-push.ts use these as templates
151
+ # and chmod the destination (.git/hooks/... or .husky/...) themselves.
152
+ HUSKY_PKG_DIR="$SMOKE_DIR/node_modules/@bookedsolid/rea/.husky"
153
+ for husky_file in commit-msg pre-push; do
154
+ path="$HUSKY_PKG_DIR/$husky_file"
155
+ if [ ! -f "$path" ]; then
156
+ echo "[smoke] FAIL — tarball missing .husky/$husky_file (expected at $path)" >&2
157
+ exit 2
158
+ fi
159
+ done
160
+
161
+ # On a fresh consumer (no pre-existing .husky/), rea installs the fallback
162
+ # pre-push + commit-msg into .git/hooks/. Assert that path landed too — it is
163
+ # the enforcement surface for this smoke's simulated consumer.
164
+ for git_hook in commit-msg pre-push; do
165
+ path="$SMOKE_DIR/.git/hooks/$git_hook"
166
+ if [ ! -f "$path" ]; then
167
+ echo "[smoke] FAIL — .git/hooks/$git_hook missing after rea init" >&2
168
+ exit 2
169
+ fi
170
+ if [ ! -x "$path" ]; then
171
+ echo "[smoke] FAIL — .git/hooks/$git_hook is not executable" >&2
172
+ exit 2
173
+ fi
174
+ done
175
+
176
+ AGENT_COUNT="$(printf '%s\n' "$AGENTS_DST" | grep -c . || true)"
177
+ HOOK_COUNT="$(printf '%s\n' "$HOOKS_DST" | grep -c . || true)"
178
+ COMMAND_COUNT="$(printf '%s\n' "$COMMANDS_DST" | grep -c . || true)"
179
+ echo "[smoke] → $AGENT_COUNT agents, $HOOK_COUNT hooks, $COMMAND_COUNT commands, .husky/{commit-msg,pre-push} shipped, .git/hooks/{commit-msg,pre-push} installed"
180
+
181
+ echo "[smoke] rea doctor"
182
+ ./node_modules/.bin/rea doctor
183
+
184
+ # Verify every declared public export resolves. If the exports map points at a
185
+ # file that didn't ship in `files:`, this is where we catch it.
186
+ echo "[smoke] resolve exports"
187
+ node --input-type=module -e "
188
+ import('@bookedsolid/rea').then(m => { if (typeof m !== 'object') { console.error('bad root export'); process.exit(2); } });
189
+ import('@bookedsolid/rea/policy').then(m => { if (!m) { console.error('bad /policy export'); process.exit(2); } });
190
+ import('@bookedsolid/rea/middleware').then(m => { if (!m) { console.error('bad /middleware export'); process.exit(2); } });
191
+ import('@bookedsolid/rea/audit').then(m => {
192
+ if (typeof m.appendAuditRecord !== 'function') { console.error('audit.appendAuditRecord not a function'); process.exit(2); }
193
+ });
194
+ "
195
+ echo "[smoke] → root, /policy, /middleware, /audit all resolve"
196
+
197
+ echo "[smoke] PASS"