create-claude-cabinet 0.47.0 → 0.48.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.
- package/README.md +1 -0
- package/lib/CLAUDE.md +218 -0
- package/lib/cli.js +102 -344
- package/lib/copy.js +75 -1
- package/lib/engagement-server-setup.js +1 -17
- package/lib/engagement-setup.js +1 -1
- package/lib/installer-gate.js +135 -0
- package/lib/modules.js +292 -0
- package/lib/mux-setup.js +1 -17
- package/lib/project-context.js +16 -96
- package/lib/settings-merge.js +148 -9
- package/lib/site-audit-setup.js +23 -7
- package/lib/verify-setup.js +20 -9
- package/lib/watchtower-setup.js +1 -5
- package/package.json +1 -1
- package/templates/CLAUDE.md +959 -0
- package/templates/briefing/_briefing-template.md +6 -11
- package/templates/cabinet/eval-protocol.md +27 -10
- package/templates/cabinet/watchtower-contracts.md +39 -0
- package/templates/cabinet/worktree-invocation-contract.md +24 -0
- package/templates/engagement/__tests__/checklist-visibility.test.mjs +76 -0
- package/templates/engagement/engagement-checklist.mjs +26 -2
- package/templates/engagement/engagement-schema.md +8 -6
- package/templates/engagement/pib-db-patches/pib-db-lib.mjs +49 -9
- package/templates/engagement/pib-db-patches/pib-db-schema.sql +1 -1
- package/templates/engagement/pib-db-patches/pib-db.mjs +4 -1
- package/templates/engagement/sql-constants.mjs +9 -1
- package/templates/engagement-server/__tests__/cross-tenant-auth.test.mjs +87 -0
- package/templates/engagement-server/server.mjs +65 -40
- package/templates/hooks/action-completion-gate.sh +10 -0
- package/templates/hooks/action-quality-gate.sh +10 -0
- package/templates/hooks/skill-telemetry.sh +10 -5
- package/templates/hooks/skill-tool-telemetry.sh +11 -5
- package/templates/mcp/pib-db.json +1 -1
- package/templates/rules/enforcement-pipeline.md +0 -12
- package/templates/scripts/__tests__/api-usage-idle-gate.test.mjs +81 -0
- package/templates/scripts/__tests__/bsql-loader-verify.test.mjs +47 -0
- package/templates/scripts/__tests__/inbox-assessment.test.mjs +341 -0
- package/templates/scripts/__tests__/memory-reachability.test.mjs +150 -0
- package/templates/scripts/__tests__/ring1-pib-error-surfacing.test.mjs +69 -0
- package/templates/scripts/__tests__/ring1-stale-open-counts.test.mjs +143 -0
- package/templates/scripts/__tests__/ring3-thread-capture.test.mjs +213 -0
- package/templates/scripts/__tests__/watchtower-status-ring4.test.mjs +89 -0
- package/templates/scripts/audit-coherence-check.mjs +123 -0
- package/templates/scripts/audit-synth.mjs +117 -0
- package/templates/scripts/merge-findings.js +4 -1
- package/templates/scripts/patterns-scope-check.mjs +122 -0
- package/templates/scripts/pib-db-lib.mjs +61 -11
- package/templates/scripts/pib-db-mcp-server.mjs +2 -1
- package/templates/scripts/pib-db.mjs +2 -1
- package/templates/scripts/review-server.mjs +7 -12
- package/templates/scripts/triage-server.mjs +7 -8
- package/templates/scripts/watchtower-cross-ring-reader.mjs +20 -6
- package/templates/scripts/watchtower-inbox-assessment.mjs +706 -0
- package/templates/scripts/watchtower-lib.mjs +282 -4
- package/templates/scripts/watchtower-ring1.mjs +78 -38
- package/templates/scripts/watchtower-ring2.mjs +38 -12
- package/templates/scripts/watchtower-ring3-close.mjs +160 -21
- package/templates/scripts/watchtower-status.sh +17 -0
- package/templates/scripts/work-tracker-server.mjs +26 -22
- package/templates/skills/audit/SKILL.md +18 -5
- package/templates/skills/audit/phases/structural-checks.md +22 -0
- package/templates/skills/briefing/SKILL.md +8 -0
- package/templates/skills/cabinet-accessibility/SKILL.md +1 -1
- package/templates/skills/cc-publish/SKILL.md +41 -7
- package/templates/skills/debrief/phases/audit-pattern-capture.md +13 -0
- package/templates/skills/inbox/SKILL.md +95 -5
- package/templates/skills/orient/SKILL.md +4 -0
- package/templates/skills/pulse/SKILL.md +10 -8
- package/templates/skills/spring-clean/SKILL.md +1 -1
- package/templates/skills/spring-clean/phases/execute-decisions.md +1 -1
- package/templates/skills/validate/phases/validators.md +86 -0
- package/templates/skills/watchtower/SKILL.md +18 -26
- package/templates/workflows/deliberative-audit.js +258 -87
- package/templates/workflows/execute-group-complete.js +16 -2
- package/templates/workflows/execute-group-implement.js +16 -2
|
@@ -102,6 +102,64 @@ function resolveExternalUser(engagementId, email, roleMapping) {
|
|
|
102
102
|
return null;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// SECURITY (security-0003): pick EXACTLY ONE external engagement to forward a
|
|
106
|
+
// bearer token to — NEVER fan out across tenants. Scope by the X-Engagement-Id
|
|
107
|
+
// header; if it is absent, only a single-external-engagement deployment is
|
|
108
|
+
// unambiguous. A header naming an unknown/non-external engagement gets no
|
|
109
|
+
// fallback (returns null). Fanning the raw token out to every external
|
|
110
|
+
// engagement's validate_url leaked tenant A's credential to tenant B's infra
|
|
111
|
+
// and let an anonymous caller trigger N outbound HTTPS fetches.
|
|
112
|
+
function selectExternalTarget(externals, requestedId) {
|
|
113
|
+
if (requestedId) return externals.find((e) => e.id === requestedId) || null;
|
|
114
|
+
if (externals.length === 1) return externals[0];
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Forward the token to ONE engagement's validate_url and resolve the user.
|
|
119
|
+
// Returns the authenticated user object or null (never throws).
|
|
120
|
+
async function validateTokenAgainstEngagement(eng, raw) {
|
|
121
|
+
try {
|
|
122
|
+
const config = JSON.parse(eng.auth_config);
|
|
123
|
+
if (!config.validate_url) return null;
|
|
124
|
+
|
|
125
|
+
const resp = await fetch(config.validate_url, {
|
|
126
|
+
headers: { 'Authorization': `Bearer ${raw}` },
|
|
127
|
+
signal: AbortSignal.timeout(5000),
|
|
128
|
+
});
|
|
129
|
+
if (!resp.ok) return null;
|
|
130
|
+
|
|
131
|
+
const identity = await resp.json();
|
|
132
|
+
const email = identity.email;
|
|
133
|
+
if (!email) return null;
|
|
134
|
+
|
|
135
|
+
// Map platform role to engagement role via role_mapping
|
|
136
|
+
const roleMapping = config.role_mapping || {};
|
|
137
|
+
const mappedRole = roleMapping[identity.role] || identity.role;
|
|
138
|
+
|
|
139
|
+
// Find matching local user by email for message attribution
|
|
140
|
+
const user = resolveExternalUser(eng.id, email, roleMapping);
|
|
141
|
+
if (user) return { ...user, auth_mode: 'external' };
|
|
142
|
+
|
|
143
|
+
// User authenticated with platform but no local user record —
|
|
144
|
+
// auto-create so messages are properly attributed
|
|
145
|
+
const userId = `usr_${randomBytes(6).toString('hex')}`;
|
|
146
|
+
const engRole = ['consultant', 'client'].includes(mappedRole) ? mappedRole : 'client';
|
|
147
|
+
db.prepare(`INSERT INTO users (id, engagement_id, name, email, role) VALUES (?, ?, ?, ?, ?)`)
|
|
148
|
+
.run(userId, eng.id, email.split('@')[0], email, engRole);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
user_id: userId,
|
|
152
|
+
engagement_id: eng.id,
|
|
153
|
+
role: engRole,
|
|
154
|
+
user_name: email.split('@')[0],
|
|
155
|
+
auth_mode: 'external',
|
|
156
|
+
};
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error(`External auth failed for engagement ${eng.id}: ${err.message}`);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
105
163
|
async function authenticateRequest(req) {
|
|
106
164
|
const authHeader = req.headers['authorization'];
|
|
107
165
|
if (!authHeader || !authHeader.startsWith('Bearer ')) return null;
|
|
@@ -112,47 +170,14 @@ async function authenticateRequest(req) {
|
|
|
112
170
|
const localData = resolveLocalToken(hashToken(raw));
|
|
113
171
|
if (localData && localData.auth_mode === 'local') return localData;
|
|
114
172
|
|
|
115
|
-
// Path 2: external auth — forward token to client app's
|
|
173
|
+
// Path 2: external auth — forward the token to a SINGLE client app's
|
|
174
|
+
// validate_url (never a cross-tenant fan-out; see selectExternalTarget).
|
|
116
175
|
const externals = getExternalEngagements();
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const resp = await fetch(config.validate_url, {
|
|
123
|
-
headers: { 'Authorization': `Bearer ${raw}` },
|
|
124
|
-
signal: AbortSignal.timeout(5000),
|
|
125
|
-
});
|
|
126
|
-
if (!resp.ok) continue;
|
|
127
|
-
|
|
128
|
-
const identity = await resp.json();
|
|
129
|
-
const email = identity.email;
|
|
130
|
-
if (!email) continue;
|
|
131
|
-
|
|
132
|
-
// Map platform role to engagement role via role_mapping
|
|
133
|
-
const roleMapping = config.role_mapping || {};
|
|
134
|
-
const mappedRole = roleMapping[identity.role] || identity.role;
|
|
135
|
-
|
|
136
|
-
// Find matching local user by email for message attribution
|
|
137
|
-
const user = resolveExternalUser(eng.id, email, roleMapping);
|
|
138
|
-
if (user) return { ...user, auth_mode: 'external' };
|
|
139
|
-
|
|
140
|
-
// User authenticated with platform but no local user record —
|
|
141
|
-
// auto-create so messages are properly attributed
|
|
142
|
-
const userId = `usr_${randomBytes(6).toString('hex')}`;
|
|
143
|
-
const engRole = ['consultant', 'client'].includes(mappedRole) ? mappedRole : 'client';
|
|
144
|
-
db.prepare(`INSERT INTO users (id, engagement_id, name, email, role) VALUES (?, ?, ?, ?, ?)`)
|
|
145
|
-
.run(userId, eng.id, email.split('@')[0], email, engRole);
|
|
146
|
-
|
|
147
|
-
return {
|
|
148
|
-
user_id: userId,
|
|
149
|
-
engagement_id: eng.id,
|
|
150
|
-
role: engRole,
|
|
151
|
-
user_name: email.split('@')[0],
|
|
152
|
-
auth_mode: 'external',
|
|
153
|
-
};
|
|
154
|
-
} catch (err) {
|
|
155
|
-
console.error(`External auth failed for engagement ${eng.id}: ${err.message}`);
|
|
176
|
+
if (externals.length > 0) {
|
|
177
|
+
const target = selectExternalTarget(externals, req.headers['x-engagement-id']);
|
|
178
|
+
if (target) {
|
|
179
|
+
const authed = await validateTokenAgainstEngagement(target, raw);
|
|
180
|
+
if (authed) return authed;
|
|
156
181
|
}
|
|
157
182
|
}
|
|
158
183
|
|
|
@@ -14,6 +14,16 @@
|
|
|
14
14
|
# (the pib_complete_action args) is under `tool_input` (fall back to
|
|
15
15
|
# top-level for older payload shapes).
|
|
16
16
|
INPUT=$(cat)
|
|
17
|
+
|
|
18
|
+
# Fired-at-least-once telemetry (act:ff693d4c). Record that this gate's matcher
|
|
19
|
+
# actually matched and the hook ran — a dead matcher (the bare
|
|
20
|
+
# `pib_complete_action` spelling that never matched the real
|
|
21
|
+
# `mcp__pib-db__pib_complete_action` MCP tool name) leaves this file empty
|
|
22
|
+
# forever. Env-overridable for tests; fail-open so telemetry never breaks the gate.
|
|
23
|
+
FIRED_LOG="${CC_HOOK_FIRED_LOG:-${CLAUDE_PROJECT_DIR:-.}/.claude/state/hooks-fired.jsonl}"
|
|
24
|
+
mkdir -p "$(dirname "$FIRED_LOG")" 2>/dev/null \
|
|
25
|
+
&& printf '{"hook":"action-completion-gate","fired_at":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$FIRED_LOG" 2>/dev/null || true
|
|
26
|
+
|
|
17
27
|
FID=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); ti=d.get('tool_input', d); print(ti.get('fid',''))" 2>/dev/null)
|
|
18
28
|
|
|
19
29
|
if [ -z "$FID" ]; then
|
|
@@ -17,6 +17,16 @@
|
|
|
17
17
|
# top-level for older payload shapes). stdin is read ONCE — reuse $INPUT.
|
|
18
18
|
INPUT=$(cat)
|
|
19
19
|
|
|
20
|
+
# Fired-at-least-once telemetry (act:ff693d4c). Record that this gate's matcher
|
|
21
|
+
# actually matched and the hook ran. A dead matcher (the bare `pib_create_action`
|
|
22
|
+
# spelling that never matched the real `mcp__pib-db__pib_create_action` MCP tool
|
|
23
|
+
# name) leaves this file empty forever — which is what let this gate stay
|
|
24
|
+
# silently dead since it shipped. Env-overridable for tests; fail-open so
|
|
25
|
+
# telemetry can never break the gate.
|
|
26
|
+
FIRED_LOG="${CC_HOOK_FIRED_LOG:-${CLAUDE_PROJECT_DIR:-.}/.claude/state/hooks-fired.jsonl}"
|
|
27
|
+
mkdir -p "$(dirname "$FIRED_LOG")" 2>/dev/null \
|
|
28
|
+
&& printf '{"hook":"action-quality-gate","fired_at":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$FIRED_LOG" 2>/dev/null || true
|
|
29
|
+
|
|
20
30
|
NOTES=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); ti=d.get('tool_input', d); print(ti.get('notes',''))" 2>/dev/null)
|
|
21
31
|
TEXT=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); ti=d.get('tool_input', d); print(ti.get('text',''))" 2>/dev/null)
|
|
22
32
|
|
|
@@ -46,13 +46,18 @@ if [[ "$PROMPT" =~ ^/([a-z-]+) ]]; then
|
|
|
46
46
|
for skill in $SKILLS; do
|
|
47
47
|
if [[ "$COMMAND" == "$skill" ]]; then
|
|
48
48
|
TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
# SECURITY (security-0004): env-pass values; never interpolate hook
|
|
50
|
+
# payload fields into the python3 -c source (same class as
|
|
51
|
+
# skill-tool-telemetry.sh). COMMAND is regex-constrained to [a-z-]+
|
|
52
|
+
# here, but session_id is passed through raw — os.environ keeps both
|
|
53
|
+
# inert.
|
|
54
|
+
SKILL="$COMMAND" SESSION_ID="$SESSION_ID" TIMESTAMP="$TIMESTAMP" python3 -c "
|
|
55
|
+
import json, os
|
|
51
56
|
record = {
|
|
52
|
-
'ts': '
|
|
57
|
+
'ts': os.environ['TIMESTAMP'],
|
|
53
58
|
'event': 'skill-invoke',
|
|
54
|
-
'skill': '
|
|
55
|
-
'session_id': '
|
|
59
|
+
'skill': os.environ['SKILL'],
|
|
60
|
+
'session_id': os.environ['SESSION_ID'],
|
|
56
61
|
}
|
|
57
62
|
print(json.dumps(record))
|
|
58
63
|
" >> "$TELEMETRY_FILE"
|
|
@@ -37,14 +37,20 @@ except:
|
|
|
37
37
|
|
|
38
38
|
if [[ -n "$SKILL" ]]; then
|
|
39
39
|
TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
# SECURITY (security-0004): pass values via ENV, never concatenate hook
|
|
41
|
+
# payload fields into the python3 -c source. Interpolating '$SKILL' into the
|
|
42
|
+
# program let a skill value with a quote/newline (model-composed from
|
|
43
|
+
# tool_input.skill, reachable via prompt injection) break out of the string
|
|
44
|
+
# literal and execute arbitrary Python — command execution in a PostToolUse
|
|
45
|
+
# hook. os.environ reads them as inert data.
|
|
46
|
+
SKILL="$SKILL" SESSION_ID="$SESSION_ID" TIMESTAMP="$TIMESTAMP" python3 -c "
|
|
47
|
+
import json, os
|
|
42
48
|
record = {
|
|
43
|
-
'ts': '
|
|
49
|
+
'ts': os.environ['TIMESTAMP'],
|
|
44
50
|
'event': 'skill-invoke',
|
|
45
|
-
'skill': '
|
|
51
|
+
'skill': os.environ['SKILL'],
|
|
46
52
|
'source': 'tool',
|
|
47
|
-
'session_id': '
|
|
53
|
+
'session_id': os.environ['SESSION_ID'],
|
|
48
54
|
}
|
|
49
55
|
print(json.dumps(record))
|
|
50
56
|
" >> "$TELEMETRY_FILE"
|
|
@@ -141,15 +141,3 @@ The audit skill's triage history tracks which findings recur. The
|
|
|
141
141
|
enforcement pipeline's pattern files track which recurring findings are
|
|
142
142
|
ready for promotion. Together, they create a feedback loop from
|
|
143
143
|
observation to prevention.
|
|
144
|
-
|
|
145
|
-
## Current State
|
|
146
|
-
|
|
147
|
-
<!--
|
|
148
|
-
Track your project's enforcement pipeline state here:
|
|
149
|
-
|
|
150
|
-
- **N pattern files** in memory/patterns/
|
|
151
|
-
- **N raw observations** in memory/archive/ (or wherever you store them)
|
|
152
|
-
- **N rules files** in .claude/rules/
|
|
153
|
-
- **N hooks** in .claude/settings.json
|
|
154
|
-
- **N promotion candidates** identified
|
|
155
|
-
-->
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import {
|
|
7
|
+
recordApiUsage, readApiUsageRecords, summarizeApiUsage, API_PRICING,
|
|
8
|
+
computeActivitySignature, shouldRunIdlePass, markIdlePassRan, RING2_IDLE_FLOOR_MS,
|
|
9
|
+
} from '../watchtower-lib.mjs';
|
|
10
|
+
|
|
11
|
+
function tmpWt() { return mkdtempSync(join(tmpdir(), 'wt-usage-')); }
|
|
12
|
+
|
|
13
|
+
test('summarizeApiUsage: totals + $ estimate + window filter', () => {
|
|
14
|
+
const now = new Date('2026-07-12T12:00:00Z').getTime();
|
|
15
|
+
const recs = [
|
|
16
|
+
{ ts: '2026-07-12T11:00:00Z', ring: 'ring3', model: 'claude-sonnet-4-6', input_tokens: 1_000_000, output_tokens: 100_000 },
|
|
17
|
+
{ ts: '2026-07-12T11:30:00Z', ring: 'ring2', model: 'claude-sonnet-4-6', input_tokens: 500_000, output_tokens: 10_000 },
|
|
18
|
+
{ ts: '2026-07-01T00:00:00Z', ring: 'ring2', model: 'claude-sonnet-4-6', input_tokens: 9_999_999, output_tokens: 9_999_999 }, // outside 1d window
|
|
19
|
+
];
|
|
20
|
+
const s = summarizeApiUsage(recs, { nowMs: now, windowDays: 1 });
|
|
21
|
+
assert.equal(s.calls, 2, 'old record excluded by window');
|
|
22
|
+
assert.equal(s.input_tokens, 1_500_000);
|
|
23
|
+
assert.equal(s.output_tokens, 110_000);
|
|
24
|
+
// ring3: 1M*$3 + 100k*$15 = $3 + $1.5 = $4.50 ; ring2: 500k*$3 + 10k*$15 = $1.5 + $0.15 = $1.65
|
|
25
|
+
assert.equal(s.est_cost_usd, 6.15);
|
|
26
|
+
assert.equal(s.by_ring.ring3, 4.5);
|
|
27
|
+
assert.equal(s.by_ring.ring2, 1.65);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('summarizeApiUsage: empty + unknown model falls back to sonnet pricing', () => {
|
|
31
|
+
assert.equal(summarizeApiUsage([]).calls, 0);
|
|
32
|
+
const s = summarizeApiUsage([{ ts: new Date().toISOString(), model: 'mystery', input_tokens: 1_000_000, output_tokens: 0 }], { windowDays: 3650 });
|
|
33
|
+
assert.equal(s.est_cost_usd, 3, 'unknown model priced as sonnet-4-6 ($3/M in)');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('recordApiUsage → readApiUsageRecords round-trips, fail-open on bad dir', () => {
|
|
37
|
+
const wt = tmpWt();
|
|
38
|
+
try {
|
|
39
|
+
recordApiUsage({ ring: 'ring3', model: 'claude-sonnet-4-6', usage: { input_tokens: 42, output_tokens: 7 } }, { watchtowerDir: wt });
|
|
40
|
+
const recs = readApiUsageRecords(wt);
|
|
41
|
+
assert.equal(recs.length, 1);
|
|
42
|
+
assert.equal(recs[0].input_tokens, 42);
|
|
43
|
+
assert.equal(recs[0].ring, 'ring3');
|
|
44
|
+
// fail-open: a nonsense dir must not throw
|
|
45
|
+
assert.doesNotThrow(() => recordApiUsage({ usage: {} }, { watchtowerDir: '/nonexistent/\0/x' }));
|
|
46
|
+
} finally { rmSync(wt, { recursive: true, force: true }); }
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('shouldRunIdlePass: first-run runs; unchanged+in-floor skips; changed runs; floor elapsed runs', () => {
|
|
50
|
+
const wt = tmpWt();
|
|
51
|
+
try {
|
|
52
|
+
const t0 = 1_000_000_000_000;
|
|
53
|
+
// never ran → run
|
|
54
|
+
assert.equal(shouldRunIdlePass('p', { watchtowerDir: wt, signature: 'A', nowMs: t0 }).run, true);
|
|
55
|
+
markIdlePassRan('p', { watchtowerDir: wt, signature: 'A', nowMs: t0 });
|
|
56
|
+
// same signature, within floor → skip
|
|
57
|
+
const g1 = shouldRunIdlePass('p', { watchtowerDir: wt, signature: 'A', nowMs: t0 + 60_000 });
|
|
58
|
+
assert.equal(g1.run, false);
|
|
59
|
+
assert.equal(g1.reason, 'idle-skip');
|
|
60
|
+
// changed signature → run
|
|
61
|
+
assert.equal(shouldRunIdlePass('p', { watchtowerDir: wt, signature: 'B', nowMs: t0 + 60_000 }).reason, 'activity');
|
|
62
|
+
// floor elapsed → run
|
|
63
|
+
const g3 = shouldRunIdlePass('p', { watchtowerDir: wt, signature: 'A', nowMs: t0 + RING2_IDLE_FLOOR_MS + 1 });
|
|
64
|
+
assert.equal(g3.run, true);
|
|
65
|
+
assert.equal(g3.reason, 'idle-floor');
|
|
66
|
+
} finally { rmSync(wt, { recursive: true, force: true }); }
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('computeActivitySignature: stable when nothing changes, reflects ring3-health', () => {
|
|
70
|
+
const wt = tmpWt();
|
|
71
|
+
try {
|
|
72
|
+
const s1 = computeActivitySignature(wt); // no files → empty-ish, must not throw
|
|
73
|
+
assert.equal(typeof s1, 'string');
|
|
74
|
+
// writing a ring3-health changes the signature
|
|
75
|
+
mkdirSync(join(wt, 'state'), { recursive: true });
|
|
76
|
+
writeFileSync(join(wt, 'state', 'ring3-health.json'), JSON.stringify({ last_run: '2026-07-12T00:00:00Z', session_id: 'abc' }));
|
|
77
|
+
const s2 = computeActivitySignature(wt);
|
|
78
|
+
assert.notEqual(s1, s2);
|
|
79
|
+
assert.equal(computeActivitySignature(wt), s2, 'stable when unchanged');
|
|
80
|
+
} finally { rmSync(wt, { recursive: true, force: true }); }
|
|
81
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { loadBetterSqlite3, verifyDatabaseConstructor } from '../watchtower-lib.mjs';
|
|
5
|
+
|
|
6
|
+
// better-sqlite3 loads its native addon lazily at the first `new Database()`,
|
|
7
|
+
// not at require() — so a candidate that resolves to an ABI-mismatched copy
|
|
8
|
+
// (e.g. a stray ~/node_modules install built under a different node) passes a
|
|
9
|
+
// bare require and then explodes in the caller. verifyDatabaseConstructor
|
|
10
|
+
// forces the addon load inside the loader's per-candidate try so the
|
|
11
|
+
// candidate chain falls through instead (act:b9414039).
|
|
12
|
+
|
|
13
|
+
test('verifyDatabaseConstructor throws when construction throws (ABI-mismatch shape)', () => {
|
|
14
|
+
class Broken {
|
|
15
|
+
constructor() {
|
|
16
|
+
throw new Error(
|
|
17
|
+
"The module 'better_sqlite3.node' was compiled against a different Node.js version"
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
assert.throws(() => verifyDatabaseConstructor(Broken), /different Node\.js version/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('verifyDatabaseConstructor returns the constructor when construction works', () => {
|
|
25
|
+
const opened = [];
|
|
26
|
+
class Working {
|
|
27
|
+
constructor(name) { opened.push(name); }
|
|
28
|
+
close() { opened.pop(); }
|
|
29
|
+
}
|
|
30
|
+
const result = verifyDatabaseConstructor(Working);
|
|
31
|
+
assert.equal(result, Working);
|
|
32
|
+
// The throwaway :memory: db was opened and closed, not leaked.
|
|
33
|
+
assert.deepEqual(opened, []);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('loadBetterSqlite3 returns a verified, working constructor in this repo', () => {
|
|
37
|
+
const Database = loadBetterSqlite3(process.cwd());
|
|
38
|
+
assert.ok(Database, 'expected a candidate to resolve (repo node_modules has better-sqlite3)');
|
|
39
|
+
const db = new Database(':memory:');
|
|
40
|
+
try {
|
|
41
|
+
db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)');
|
|
42
|
+
db.prepare('INSERT INTO t (v) VALUES (?)').run('ok');
|
|
43
|
+
assert.equal(db.prepare('SELECT v FROM t').get().v, 'ok');
|
|
44
|
+
} finally {
|
|
45
|
+
db.close();
|
|
46
|
+
}
|
|
47
|
+
});
|